1177 lines
36 KiB
Go
1177 lines
36 KiB
Go
package routing
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"sort"
|
|
"sync/atomic"
|
|
"time"
|
|
"trip-planner/internal/storage"
|
|
"trip-planner/internal/yandex"
|
|
)
|
|
|
|
// itineraryIDCounter is a counter for generating unique itinerary IDs.
|
|
var itineraryIDCounter uint64
|
|
|
|
// generateItineraryID generates a unique ID for an itinerary.
|
|
func generateItineraryID() string {
|
|
id := atomic.AddUint64(&itineraryIDCounter, 1)
|
|
return fmt.Sprintf("route_%016x", id)
|
|
}
|
|
|
|
// Edge represents a graph edge connecting two nodes.
|
|
type Edge struct {
|
|
From *Node
|
|
To *Node
|
|
Kind EdgeKind
|
|
Duration int // travel time in seconds
|
|
Transport string // transport type (train, plane, bus)
|
|
TransportType TransportType // transport type enum
|
|
IsTransfer bool // whether this edge involves a transfer
|
|
Departure string // ISO 8601 departure time
|
|
Arrival string // ISO 8601 arrival time
|
|
Cost int // cost in minor currency units (e.g., rubles)
|
|
// Synthetic indicates whether this edge is a synthetic transfer edge
|
|
// (e.g., city↔airport, station↔city hub) rather than a real scheduled trip.
|
|
Synthetic bool
|
|
}
|
|
|
|
// TransportType represents the type of transport for an edge.
|
|
type TransportType string
|
|
|
|
const (
|
|
// TransportTypePlane represents airplane transport.
|
|
TransportTypePlane TransportType = "plane"
|
|
// TransportTypeTrain represents train transport.
|
|
TransportTypeTrain TransportType = "train"
|
|
// TransportTypeBus represents bus transport.
|
|
TransportTypeBus TransportType = "bus"
|
|
)
|
|
|
|
// TransferTime constants for synthetic edge duration estimation.
|
|
const (
|
|
// AirportToCity is the standard transfer time (in seconds) for airport-to-city
|
|
// or city-to-airport synthetic edges.
|
|
AirportToCity = 5400 // 90 minutes
|
|
// CityToStation is the standard transfer time (in seconds) for city-to-station
|
|
// or station-to-city synthetic edges within the same city.
|
|
CityToStation = 300 // 5 minutes
|
|
// StationToStation is the standard transfer time (in seconds) for station-to-station
|
|
// transfers within the same city.
|
|
StationToStation = 300 // 5 minutes
|
|
)
|
|
|
|
// NodeType represents the type of a graph node.
|
|
type NodeType int
|
|
|
|
const (
|
|
// NodeTypeStation represents a train station.
|
|
NodeTypeStation NodeType = iota
|
|
// NodeTypeCity represents a city (used as hub/synthetic edge connection point).
|
|
NodeTypeCity
|
|
)
|
|
|
|
// Node represents a graph node (station or city).
|
|
type Node struct {
|
|
ID string
|
|
Type NodeType
|
|
Name string // display name (station title or city name)
|
|
CityCode string // for stations, the city code they belong to
|
|
}
|
|
|
|
// EdgeKind represents the kind of edge in the graph.
|
|
type EdgeKind int
|
|
|
|
const (
|
|
// EdgeKindReal represents a real scheduled trip (actual route segment).
|
|
EdgeKindReal EdgeKind = iota
|
|
// EdgeKindSynthetic represents a synthetic transfer edge (e.g., city↔airport).
|
|
EdgeKindSynthetic
|
|
)
|
|
|
|
// StationInfo holds station information for graph building from a station directory.
|
|
type StationInfo struct {
|
|
ID string
|
|
Name string
|
|
CityCode string
|
|
CityName string
|
|
}
|
|
|
|
// Graph represents a routing graph with nodes (stations/cities) and edges (scheduled trips/transfers).
|
|
type Graph struct {
|
|
nodes []*Node
|
|
edges []*Edge
|
|
|
|
// StationNeighbors maps station IDs to their fallback neighbors.
|
|
// Used when a station is closed to automatically substitute alternative stations.
|
|
StationNeighbors map[string][]storage.StationNeighbor
|
|
}
|
|
|
|
// NewGraph creates a new empty routing graph.
|
|
func NewGraph() *Graph {
|
|
return &Graph{
|
|
nodes: []*Node{},
|
|
edges: []*Edge{},
|
|
StationNeighbors: make(map[string][]storage.StationNeighbor),
|
|
}
|
|
}
|
|
|
|
// AddNode adds a node to the graph.
|
|
func (g *Graph) AddNode(node *Node) {
|
|
g.nodes = append(g.nodes, node)
|
|
}
|
|
|
|
// AddEdge adds an edge to the graph.
|
|
func (g *Graph) AddEdge(edge *Edge) {
|
|
g.edges = append(g.edges, edge)
|
|
}
|
|
|
|
// Nodes returns all nodes in the graph.
|
|
func (g *Graph) Nodes() []*Node {
|
|
result := make([]*Node, len(g.nodes))
|
|
copy(result, g.nodes)
|
|
return result
|
|
}
|
|
|
|
// Edges returns all edges in the graph.
|
|
func (g *Graph) Edges() []*Edge {
|
|
result := make([]*Edge, len(g.edges))
|
|
copy(result, g.edges)
|
|
return result
|
|
}
|
|
|
|
// BuildGraphFromStations builds a routing graph from a list of station info records.
|
|
// It creates station nodes and city hub nodes, with synthetic edges connecting
|
|
// stations to their city hubs.
|
|
func BuildGraphFromStations(stations []StationInfo) *Graph {
|
|
graph := NewGraph()
|
|
|
|
// Track city nodes by code to avoid duplicates
|
|
cityNodes := make(map[string]*Node)
|
|
|
|
// Add all station nodes and create/connect city hub nodes
|
|
for _, si := range stations {
|
|
// Add station node
|
|
station := &Node{
|
|
ID: si.ID,
|
|
Type: NodeTypeStation,
|
|
Name: si.Name,
|
|
CityCode: si.CityCode,
|
|
}
|
|
graph.AddNode(station)
|
|
|
|
// Create or retrieve city hub node
|
|
cityKey := "city:" + si.CityCode
|
|
if _, exists := cityNodes[si.CityCode]; !exists {
|
|
cityNode := &Node{
|
|
ID: cityKey,
|
|
Type: NodeTypeCity,
|
|
Name: si.CityName,
|
|
}
|
|
graph.AddNode(cityNode)
|
|
cityNodes[si.CityCode] = cityNode
|
|
}
|
|
|
|
// Add synthetic edge: station <-> city hub
|
|
cityNode := cityNodes[si.CityCode]
|
|
tp := TransportTypeTrain
|
|
if si.CityCode == "c_airport" {
|
|
tp = TransportTypePlane
|
|
} else if si.CityCode == "c_bus" {
|
|
tp = TransportTypeBus
|
|
}
|
|
graph.AddEdge(&Edge{
|
|
From: station,
|
|
To: cityNode,
|
|
Kind: EdgeKindSynthetic,
|
|
Duration: 300, // 5 min synthetic transfer
|
|
Transport: string(tp),
|
|
TransportType: tp,
|
|
IsTransfer: true,
|
|
Synthetic: true,
|
|
})
|
|
|
|
// Add reverse synthetic edge: city hub -> station
|
|
graph.AddEdge(&Edge{
|
|
From: cityNode,
|
|
To: station,
|
|
Kind: EdgeKindSynthetic,
|
|
Duration: 300, // 5 min synthetic transfer
|
|
Transport: string(tp),
|
|
TransportType: tp,
|
|
IsTransfer: true,
|
|
Synthetic: true,
|
|
})
|
|
}
|
|
|
|
return graph
|
|
}
|
|
|
|
// addSyntheticEdgesForNode adds synthetic edges from the given node to city hubs
|
|
// in the same city, as a fallback when direct route search fails.
|
|
// Uses transfer time constants for duration estimation.
|
|
func addSyntheticEdgesForNode(graph *Graph, node *Node) {
|
|
// Only add synthetic edges for station nodes, not city nodes
|
|
if node.Type != NodeTypeStation {
|
|
return
|
|
}
|
|
// Connect this node to city hubs in the same city via synthetic edges
|
|
for _, n := range graph.Nodes() {
|
|
if n.Type == NodeTypeCity && n.CityCode == node.CityCode {
|
|
// Determine transport type based on city code
|
|
tp := TransportTypeTrain
|
|
if node.CityCode == "c_airport" {
|
|
tp = TransportTypePlane
|
|
} else if node.CityCode == "c_bus" {
|
|
tp = TransportTypeBus
|
|
}
|
|
|
|
// Use appropriate transfer time constant based on node and city types
|
|
var duration int
|
|
if node.CityCode == "c_airport" {
|
|
duration = AirportToCity
|
|
} else {
|
|
duration = CityToStation
|
|
}
|
|
|
|
// Add synthetic edge from node to city hub
|
|
graph.AddEdge(&Edge{
|
|
From: node,
|
|
To: n,
|
|
Kind: EdgeKindSynthetic,
|
|
Duration: duration,
|
|
Transport: string(tp),
|
|
TransportType: tp,
|
|
IsTransfer: true,
|
|
Synthetic: true,
|
|
})
|
|
|
|
// Add reverse synthetic edge from city hub to node
|
|
graph.AddEdge(&Edge{
|
|
From: n,
|
|
To: node,
|
|
Kind: EdgeKindSynthetic,
|
|
Duration: duration,
|
|
Transport: string(tp),
|
|
TransportType: tp,
|
|
IsTransfer: true,
|
|
Synthetic: true,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// SortEdges sorts edges by duration in ascending order (shortest first).
|
|
func SortEdges(edges []*Edge) {
|
|
sort.Slice(edges, func(i, j int) bool {
|
|
return edges[i].Duration < edges[j].Duration
|
|
})
|
|
}
|
|
|
|
// buildAdjacencyList builds an adjacency list from the graph's edges.
|
|
func (g *Graph) buildAdjacencyList() map[string][]*Edge {
|
|
adj := make(map[string][]*Edge)
|
|
for _, edge := range g.edges {
|
|
adj[edge.From.ID] = append(adj[edge.From.ID], edge)
|
|
}
|
|
return adj
|
|
}
|
|
|
|
// NodesByID returns a node by its ID from the graph's nodes.
|
|
func (g *Graph) NodesByID(id string) *Node {
|
|
for _, n := range g.nodes {
|
|
if n.ID == id {
|
|
return n
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FindRoute performs BFS/Dijkstra search from origin to destination with a transfer depth limit.
|
|
// It returns the best itinerary found within the transfer limit. If no route is found
|
|
// via lazy expansion, synthetic edges are added as fallback, and if still no route,
|
|
// an on-demand Yandex /search call is made to expand the graph.
|
|
func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedStations map[string]bool, neighbors map[string][]storage.StationNeighbor, yclient ...*yandex.Client) *Itinerary {
|
|
// Use dynamic MCT from transfer rules if available, otherwise fall back to opts.MCT
|
|
mct := getMCTForTransfer(opts.MCT, g)
|
|
|
|
// If origin or destination station is closed, add synthetic neighbor edges as fallback
|
|
if closedStations[originID] || closedStations[destID] {
|
|
// Get list of closed station IDs
|
|
var closedStationsList []string
|
|
if closedStations[originID] {
|
|
closedStationsList = append(closedStationsList, originID)
|
|
}
|
|
if closedStations[destID] {
|
|
closedStationsList = append(closedStationsList, destID)
|
|
}
|
|
|
|
// For each closed station, get neighbors and add synthetic edges
|
|
for _, closedStationID := range closedStationsList {
|
|
stationNeighbors, hasNeighbors := g.StationNeighbors[closedStationID]
|
|
// Fall back to stored neighbors or geo-discovered neighbors from the graph
|
|
if !hasNeighbors {
|
|
// Use the neighbors map passed as parameter
|
|
if neighbors != nil {
|
|
stationNeighbors = neighbors[closedStationID]
|
|
}
|
|
// Fall back to geo-discovered neighbors from the graph
|
|
if stationNeighbors == nil {
|
|
stationNeighbors = getStationNeighbors(closedStationID, g)
|
|
}
|
|
}
|
|
// Add synthetic edges from closed station to its neighbors
|
|
for _, neighbor := range stationNeighbors {
|
|
// Skip if neighbor already exists as an edge
|
|
alreadyExists := false
|
|
for _, edge := range g.edges {
|
|
if (edge.From.ID == closedStationID && edge.To.ID == neighbor.StationID) || (edge.From.ID == neighbor.StationID && edge.To.ID == closedStationID) {
|
|
alreadyExists = true
|
|
break
|
|
}
|
|
}
|
|
if !alreadyExists {
|
|
// Add synthetic edge from closed station to neighbor
|
|
g.AddEdge(&Edge{
|
|
From: g.NodesByID(closedStationID),
|
|
To: g.NodesByID(neighbor.StationID),
|
|
Kind: EdgeKindSynthetic,
|
|
Duration: 300,
|
|
Transport: "train",
|
|
IsTransfer: true,
|
|
Synthetic: true,
|
|
})
|
|
// Add reverse edge from neighbor to closed station
|
|
g.AddEdge(&Edge{
|
|
From: g.NodesByID(neighbor.StationID),
|
|
To: g.NodesByID(closedStationID),
|
|
Kind: EdgeKindSynthetic,
|
|
Duration: 300,
|
|
Transport: "train",
|
|
IsTransfer: true,
|
|
Synthetic: true,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build adjacency list from edges
|
|
adj := g.buildAdjacencyList()
|
|
|
|
// BFS with transfer tracking
|
|
// State: (nodeID, transfersUsed, accumulatedDuration, lastArrivalTime, path)
|
|
startNode := g.NodesByID(originID)
|
|
destNode := g.NodesByID(destID)
|
|
|
|
if startNode == nil || destNode == nil {
|
|
return nil
|
|
}
|
|
|
|
// Queue for BFS: each element is a state
|
|
type bfsState struct {
|
|
nodeID string
|
|
transfers int
|
|
duration int
|
|
lastArrival string // arrival time at current node (for MCT calculation)
|
|
itinerary *Itinerary
|
|
}
|
|
|
|
// Track the minimum transfers seen for each node to prune suboptimal paths
|
|
visited := make(map[string]int) // nodeID -> min transfers seen
|
|
|
|
// Initialize with the start node
|
|
initial := bfsState{
|
|
nodeID: originID,
|
|
transfers: 0,
|
|
duration: 0,
|
|
lastArrival: "",
|
|
itinerary: &Itinerary{Legs: []RouteLeg{}, ID: generateItineraryID()},
|
|
}
|
|
|
|
// Use a simple slice as priority queue - sort by (duration, transfers)
|
|
var queue []bfsState
|
|
queue = append(queue, initial)
|
|
|
|
var best *Itinerary
|
|
|
|
for len(queue) > 0 {
|
|
// Pop the state with shortest duration (and fewest transfers as tiebreaker)
|
|
current := queue[0]
|
|
queue = queue[1:]
|
|
|
|
// If we've reached the destination, potentially update best result
|
|
if current.nodeID == destID {
|
|
if best == nil || current.duration < best.TotalDuration ||
|
|
(current.duration == best.TotalDuration && current.transfers < best.TotalTransfers) {
|
|
best = current.itinerary
|
|
// Recalculate best metrics from legs
|
|
best.TotalDuration = current.duration
|
|
best.TotalTransfers = current.transfers
|
|
}
|
|
// Don't continue from destination - we've arrived
|
|
continue
|
|
}
|
|
|
|
// Explore outgoing edges
|
|
for _, edge := range adj[current.nodeID] {
|
|
nextNode := edge.To
|
|
|
|
// Calculate new duration
|
|
newDuration := current.duration + edge.Duration
|
|
|
|
// Calculate transfer time if this is not the first leg
|
|
transferTime := 0
|
|
if current.lastArrival != "" {
|
|
// Apply MCT when transferring between legs
|
|
transferTime = mct
|
|
}
|
|
|
|
newDurationWithMCT := newDuration + transferTime
|
|
|
|
newTransfers := current.transfers
|
|
if edge.IsTransfer {
|
|
newTransfers++
|
|
}
|
|
|
|
// Check if we've visited this node with fewer transfers
|
|
visKey := nextNode.ID
|
|
if existingTransfers, ok := visited[visKey]; ok {
|
|
if newTransfers > existingTransfers {
|
|
// Already visited this node with fewer transfers, skip
|
|
continue
|
|
}
|
|
}
|
|
visited[visKey] = newTransfers
|
|
|
|
// Build new itinerary legs
|
|
newLegs := make([]RouteLeg, len(current.itinerary.Legs)+1)
|
|
copy(newLegs, current.itinerary.Legs)
|
|
|
|
// First leg: From is the origin node, subsequent legs use the previous edge's To
|
|
if len(current.itinerary.Legs) == 0 {
|
|
newLegs[len(current.itinerary.Legs)] = RouteLeg{
|
|
From: g.NodesByID(originID), // origin node as From
|
|
To: nextNode,
|
|
Duration: edge.Duration,
|
|
Transport: edge.Transport,
|
|
IsTransfer: edge.IsTransfer,
|
|
}
|
|
} else {
|
|
newLegs[len(current.itinerary.Legs)] = RouteLeg{
|
|
From: current.itinerary.Legs[len(current.itinerary.Legs)-1].To,
|
|
To: nextNode,
|
|
Duration: edge.Duration,
|
|
Transport: edge.Transport,
|
|
IsTransfer: edge.IsTransfer,
|
|
}
|
|
}
|
|
|
|
newItinerary := &Itinerary{
|
|
Legs: newLegs,
|
|
TotalDuration: newDurationWithMCT,
|
|
TotalTransfers: newTransfers,
|
|
Cost: current.itinerary.Cost + edge.Cost,
|
|
ID: generateItineraryID(),
|
|
}
|
|
|
|
// Skip this edge if it would exceed the maximum allowed transfers
|
|
if opts.MaxTransfers >= 0 && newTransfers > opts.MaxTransfers {
|
|
continue
|
|
}
|
|
|
|
queue = append(queue, bfsState{
|
|
nodeID: nextNode.ID,
|
|
transfers: newTransfers,
|
|
duration: newDurationWithMCT,
|
|
lastArrival: edge.Arrival, // arrival time at next node
|
|
itinerary: newItinerary,
|
|
})
|
|
}
|
|
|
|
// Re-sort queue by (duration, transfers) for priority
|
|
sort.Slice(queue, func(i, j int) bool {
|
|
if queue[i].duration != queue[j].duration {
|
|
return queue[i].duration < queue[j].duration
|
|
}
|
|
return queue[i].transfers < queue[j].transfers
|
|
})
|
|
}
|
|
|
|
// If no route found via lazy expansion, try synthetic edge fallback
|
|
// and, if still no route, perform on-demand Yandex /search to expand the graph.
|
|
if best == nil {
|
|
// Try adding synthetic edges and retry
|
|
// Find the origin node and add synthetic edges from it
|
|
originNode := g.NodesByID(originID)
|
|
if originNode != nil {
|
|
addSyntheticEdgesForNode(g, originNode)
|
|
|
|
// Rebuild adjacency list and retry search
|
|
adj = g.buildAdjacencyList()
|
|
|
|
// Reset visited tracking for retry
|
|
visited = make(map[string]int)
|
|
|
|
// Retry the BFS search with the same options
|
|
var queue2 []bfsState
|
|
initial2 := bfsState{
|
|
nodeID: originID,
|
|
transfers: 0,
|
|
duration: 0,
|
|
lastArrival: "",
|
|
itinerary: &Itinerary{Legs: []RouteLeg{}},
|
|
}
|
|
queue2 = append(queue2, initial2)
|
|
|
|
var best2 *Itinerary
|
|
|
|
for len(queue2) > 0 {
|
|
current := queue2[0]
|
|
queue2 = queue2[1:]
|
|
|
|
if current.nodeID == destID {
|
|
if best2 == nil || current.duration < best2.TotalDuration ||
|
|
(current.duration == best2.TotalDuration && current.transfers < best2.TotalTransfers) {
|
|
best2 = current.itinerary
|
|
best2.TotalDuration = current.duration
|
|
best2.TotalTransfers = current.transfers
|
|
}
|
|
continue
|
|
}
|
|
|
|
if opts.MaxTransfers >= 0 && current.transfers > opts.MaxTransfers {
|
|
continue
|
|
}
|
|
|
|
for _, edge := range adj[current.nodeID] {
|
|
nextNode := edge.To
|
|
|
|
newDuration := current.duration + edge.Duration
|
|
|
|
transferTime := 0
|
|
if current.lastArrival != "" {
|
|
transferTime = mct
|
|
}
|
|
|
|
newDurationWithMCT := newDuration + transferTime
|
|
|
|
// Check if we've visited this node with fewer transfers
|
|
visKey := nextNode.ID
|
|
if existingTransfers, ok := visited[visKey]; ok {
|
|
if current.transfers+1 > existingTransfers {
|
|
// Already visited this node with fewer transfers, skip
|
|
continue
|
|
}
|
|
}
|
|
visited[visKey] = current.transfers + 1
|
|
|
|
newTransfers := current.transfers
|
|
if edge.IsTransfer {
|
|
newTransfers++
|
|
}
|
|
|
|
newLegs := make([]RouteLeg, len(current.itinerary.Legs)+1)
|
|
copy(newLegs, current.itinerary.Legs)
|
|
|
|
if len(current.itinerary.Legs) == 0 {
|
|
newLegs[len(current.itinerary.Legs)] = RouteLeg{
|
|
From: g.NodesByID(originID),
|
|
To: nextNode,
|
|
Duration: edge.Duration,
|
|
Transport: edge.Transport,
|
|
IsTransfer: edge.IsTransfer,
|
|
}
|
|
} else {
|
|
newLegs[len(current.itinerary.Legs)] = RouteLeg{
|
|
From: current.itinerary.Legs[len(current.itinerary.Legs)-1].To,
|
|
To: nextNode,
|
|
Duration: edge.Duration,
|
|
Transport: edge.Transport,
|
|
IsTransfer: edge.IsTransfer,
|
|
}
|
|
}
|
|
|
|
newItinerary := &Itinerary{
|
|
Legs: newLegs,
|
|
TotalDuration: newDurationWithMCT,
|
|
TotalTransfers: newTransfers,
|
|
Cost: current.itinerary.Cost + edge.Cost,
|
|
}
|
|
|
|
if opts.MaxTransfers >= 0 && newTransfers > opts.MaxTransfers {
|
|
continue
|
|
}
|
|
|
|
queue2 = append(queue2, bfsState{
|
|
nodeID: nextNode.ID,
|
|
transfers: newTransfers,
|
|
duration: newDurationWithMCT,
|
|
lastArrival: edge.Arrival,
|
|
itinerary: newItinerary,
|
|
})
|
|
}
|
|
|
|
// Re-sort queue by (duration, transfers) for priority
|
|
sort.Slice(queue2, func(i, j int) bool {
|
|
if queue2[i].duration != queue2[j].duration {
|
|
return queue2[i].duration < queue2[j].duration
|
|
}
|
|
return queue2[i].transfers < queue2[j].transfers
|
|
})
|
|
}
|
|
|
|
if best2 != nil {
|
|
return best2
|
|
}
|
|
}
|
|
|
|
// On-demand Yandex /search call: if a Yandex client is available,
|
|
// perform a search and add real edges to the graph, then retry.
|
|
if len(yclient) > 0 && yclient[0] != nil {
|
|
yandexClient := yclient[0]
|
|
|
|
// Build query parameters for Yandex /search endpoint
|
|
query := map[string]string{
|
|
"from": originID,
|
|
"to": destID,
|
|
"date": time.Now().Format("2006-01-02"),
|
|
}
|
|
|
|
// Create a context with timeout for the Yandex API call
|
|
searchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
resp, err := yandexClient.Do(searchCtx, "GET", "/v3.0/search/", query)
|
|
if err != nil {
|
|
// If API call fails, log the error and return nil (no route found)
|
|
log.Printf("WARNING: yandex search failed for route expansion: %v", err)
|
|
return nil
|
|
}
|
|
|
|
// Add real segments from the search result as edges to the graph
|
|
for _, seg := range resp.Segments {
|
|
fromNode := g.NodesByID(seg.From.Code)
|
|
toNode := g.NodesByID(seg.To.Code)
|
|
|
|
// Add nodes if they don't exist
|
|
if fromNode == nil {
|
|
fromNode = &Node{
|
|
ID: seg.From.Code,
|
|
Type: NodeTypeStation,
|
|
Name: seg.From.Title,
|
|
}
|
|
g.AddNode(fromNode)
|
|
}
|
|
if toNode == nil {
|
|
toNode = &Node{
|
|
ID: seg.To.Code,
|
|
Type: NodeTypeStation,
|
|
Name: seg.To.Title,
|
|
}
|
|
g.AddNode(toNode)
|
|
}
|
|
|
|
g.AddEdge(&Edge{
|
|
From: fromNode,
|
|
To: toNode,
|
|
Duration: seg.Duration,
|
|
Transport: string(TransportTypeTrain),
|
|
IsTransfer: seg.HasTransfers,
|
|
Kind: EdgeKindReal,
|
|
TransportType: TransportTypeTrain,
|
|
})
|
|
}
|
|
|
|
// Rebuild adjacency list and retry BFS with the same options
|
|
adj = g.buildAdjacencyList()
|
|
|
|
// Reset visited tracking for retry
|
|
visited = make(map[string]int)
|
|
|
|
// Retry the BFS search with the same options
|
|
var queue3 []bfsState
|
|
initial3 := bfsState{
|
|
nodeID: originID,
|
|
transfers: 0,
|
|
duration: 0,
|
|
lastArrival: "",
|
|
itinerary: &Itinerary{Legs: []RouteLeg{}},
|
|
}
|
|
queue3 = append(queue3, initial3)
|
|
|
|
var best3 *Itinerary
|
|
|
|
for len(queue3) > 0 {
|
|
current := queue3[0]
|
|
queue3 = queue3[1:]
|
|
|
|
if current.nodeID == destID {
|
|
if best3 == nil || current.duration < best3.TotalDuration ||
|
|
(current.duration == best3.TotalDuration && current.transfers < best3.TotalTransfers) {
|
|
best3 = current.itinerary
|
|
best3.TotalDuration = current.duration
|
|
best3.TotalTransfers = current.transfers
|
|
}
|
|
continue
|
|
}
|
|
|
|
if opts.MaxTransfers >= 0 && current.transfers > opts.MaxTransfers {
|
|
continue
|
|
}
|
|
|
|
for _, edge := range adj[current.nodeID] {
|
|
nextNode := edge.To
|
|
|
|
newDuration := current.duration + edge.Duration
|
|
|
|
transferTime := 0
|
|
if current.lastArrival != "" {
|
|
transferTime = mct
|
|
}
|
|
|
|
newDurationWithMCT := newDuration + transferTime
|
|
|
|
// Check if we've visited this node with fewer transfers
|
|
visKey := nextNode.ID
|
|
if existingTransfers, ok := visited[visKey]; ok {
|
|
if current.transfers+1 > existingTransfers {
|
|
// Already visited this node with fewer transfers, skip
|
|
continue
|
|
}
|
|
}
|
|
visited[visKey] = current.transfers + 1
|
|
|
|
newTransfers := current.transfers
|
|
if edge.IsTransfer {
|
|
newTransfers++
|
|
}
|
|
|
|
newLegs := make([]RouteLeg, len(current.itinerary.Legs)+1)
|
|
copy(newLegs, current.itinerary.Legs)
|
|
|
|
if len(current.itinerary.Legs) == 0 {
|
|
newLegs[len(current.itinerary.Legs)] = RouteLeg{
|
|
From: g.NodesByID(originID),
|
|
To: nextNode,
|
|
Duration: edge.Duration,
|
|
Transport: edge.Transport,
|
|
IsTransfer: edge.IsTransfer,
|
|
}
|
|
} else {
|
|
newLegs[len(current.itinerary.Legs)] = RouteLeg{
|
|
From: current.itinerary.Legs[len(current.itinerary.Legs)-1].To,
|
|
To: nextNode,
|
|
Duration: edge.Duration,
|
|
Transport: edge.Transport,
|
|
IsTransfer: edge.IsTransfer,
|
|
}
|
|
}
|
|
|
|
newItinerary := &Itinerary{
|
|
Legs: newLegs,
|
|
TotalDuration: newDurationWithMCT,
|
|
TotalTransfers: newTransfers,
|
|
Cost: current.itinerary.Cost + edge.Cost,
|
|
}
|
|
|
|
if opts.MaxTransfers >= 0 && newTransfers > opts.MaxTransfers {
|
|
continue
|
|
}
|
|
|
|
queue3 = append(queue3, bfsState{
|
|
nodeID: nextNode.ID,
|
|
transfers: newTransfers,
|
|
duration: newDurationWithMCT,
|
|
lastArrival: edge.Arrival,
|
|
itinerary: newItinerary,
|
|
})
|
|
}
|
|
|
|
// Re-sort queue by (duration, transfers) for priority
|
|
sort.Slice(queue3, func(i, j int) bool {
|
|
if queue3[i].duration != queue3[j].duration {
|
|
return queue3[i].duration < queue3[j].duration
|
|
}
|
|
return queue3[i].transfers < queue3[j].transfers
|
|
})
|
|
}
|
|
|
|
if best3 != nil {
|
|
return best3
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
return best
|
|
}
|
|
|
|
// ApplyMCT applies Minimum Connection Time rules to the itinerary.
|
|
// It adjusts transfer times based on node types, city tiers, and check-in requirements.
|
|
func (g *Graph) ApplyMCT(itinerary *Itinerary, mctBase int) *Itinerary {
|
|
if itinerary == nil || len(itinerary.Legs) <= 1 {
|
|
// No transfers needed, return as-is
|
|
return itinerary
|
|
}
|
|
|
|
// MCT base default: 30 minutes (1800 seconds)
|
|
if mctBase <= 0 {
|
|
mctBase = 1800
|
|
}
|
|
|
|
// Create a working copy of legs
|
|
adjustedLegs := make([]RouteLeg, len(itinerary.Legs))
|
|
copy(adjustedLegs, itinerary.Legs)
|
|
|
|
totalMCT := 0
|
|
|
|
for i := 1; i < len(adjustedLegs); i++ {
|
|
prevLeg := &adjustedLegs[i-1]
|
|
currLeg := &adjustedLegs[i]
|
|
|
|
// Determine MCT based on node types and transfer kinds
|
|
mct := mctBase
|
|
|
|
// Reduce MCT for city hub transfers (the transfer point node is a city)
|
|
// The transfer point is the destination of the previous leg / start of current leg
|
|
transferPoint := prevLeg.To // = currLeg.From
|
|
if transferPoint.Type == NodeTypeCity {
|
|
mct = mctBase / 2 // 30 min -> 15 min for city hub transfers
|
|
}
|
|
|
|
// Increase MCT for mode changes (different transport types)
|
|
if prevLeg.Transport != currLeg.Transport {
|
|
mct = mctBase + 600 // 30 min + 10 min for mode change
|
|
}
|
|
|
|
// Add the MCT to the total duration (as waiting time at transfer)
|
|
totalMCT += mct
|
|
|
|
// Add MCT to the current leg's duration (transfer wait time)
|
|
adjustedLegs[i].Duration += mct
|
|
}
|
|
|
|
// Update total duration
|
|
itinerary.TotalDuration += totalMCT
|
|
|
|
// Recalculate leg structure with proper transfer timing
|
|
itinerary.Legs = adjustedLegs
|
|
return itinerary
|
|
}
|
|
|
|
// SearchOptions configures the route search behavior.
|
|
type SearchOptions struct {
|
|
// MaxTransfers limits the number of transfers allowed in the route.
|
|
MaxTransfers int
|
|
// MCT is the minimum connection time in seconds at transfer points.
|
|
MCT int
|
|
// FarTerm indicates if the search date is far-term (affects caching/TTL).
|
|
FarTerm bool
|
|
// RankingMode determines the ranking/sort order for Pareto-optimal routes.
|
|
// Supported values: "fastest" (default, sort by duration), "fewest_transfers" (sort by number of transfers),
|
|
// "cheapest" (sort by cost).
|
|
RankingMode string
|
|
}
|
|
|
|
// Itinerary represents a complete route with legs and summary metrics.
|
|
type Itinerary struct {
|
|
Legs []RouteLeg
|
|
TotalDuration int // total travel time in seconds
|
|
TotalTransfers int // number of transfers
|
|
Cost int // cost in minor currency units (e.g., rubles)
|
|
// Identifier for the route (e.g., search_id + route_id)
|
|
ID string
|
|
// Route tracking for change detection
|
|
LastChecked int64 // Unix timestamp of last status check
|
|
NeedsReSearch bool // whether a re-search is recommended due to changes
|
|
ReSearchReason string // reason for recommended re-search (e.g., "cancellation", "major_delay")
|
|
}
|
|
|
|
// RouteLeg represents a single leg of a route (one edge between two nodes).
|
|
type RouteLeg struct {
|
|
From *Node
|
|
To *Node
|
|
Departure string // ISO 8601 departure time
|
|
Arrival string // ISO 8601 arrival time
|
|
Duration int // travel time in seconds
|
|
Transport string // transport type (train, plane, bus)
|
|
IsTransfer bool
|
|
Cost int // cost in minor currency units (e.g., rubles)
|
|
}
|
|
|
|
// SearchResult represents the result of a route search.
|
|
type SearchResult struct {
|
|
// Itineraries are the found routes, sorted by Pareto ranking (time, transfers, cost).
|
|
Itineraries []*Itinerary
|
|
// SearchMetadata contains information about the search execution.
|
|
Metadata map[string]interface{}
|
|
}
|
|
|
|
// FindRoutesPareto finds Pareto-optimal routes (time, transfers, cost) from origin to destination.
|
|
// It runs the search algorithm and returns multiple routes that are not dominated by any other
|
|
// route in all three metrics simultaneously. Routes are sorted according to the RankingMode
|
|
// in SearchOptions: "fastest" (default, by duration), "fewest_transfers" (by transfers),
|
|
// or "cheapest" (by cost).
|
|
func (g *Graph) FindRoutesPareto(originID, destID string, opts SearchOptions, closedStations map[string]bool, neighbors map[string][]storage.StationNeighbor, yclient ...*yandex.Client) []*Itinerary {
|
|
// Run multiple searches with different strategies to find diverse routes
|
|
var allItineraries []*Itinerary
|
|
|
|
// Search with different max transfer limits to find diverse routes
|
|
if opts.MaxTransfers < 0 {
|
|
// No limit on transfers - use a reasonable default
|
|
optsCopy := opts
|
|
optsCopy.MaxTransfers = 5
|
|
|
|
result := g.FindRoute(originID, destID, optsCopy, closedStations, neighbors, yclient...)
|
|
if result != nil && result.TotalDuration > 0 {
|
|
allItineraries = append(allItineraries, result)
|
|
}
|
|
} else {
|
|
for maxTransfers := 0; maxTransfers <= opts.MaxTransfers; maxTransfers++ {
|
|
optsCopy := opts
|
|
optsCopy.MaxTransfers = maxTransfers
|
|
|
|
result := g.FindRoute(originID, destID, optsCopy, closedStations, neighbors, yclient...)
|
|
if result != nil && result.TotalDuration > 0 {
|
|
allItineraries = append(allItineraries, result)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort according to the specified RankingMode
|
|
switch opts.RankingMode {
|
|
case "fewest_transfers":
|
|
sort.Slice(allItineraries, func(i, j int) bool {
|
|
if allItineraries[i].TotalTransfers != allItineraries[j].TotalTransfers {
|
|
return allItineraries[i].TotalTransfers < allItineraries[j].TotalTransfers
|
|
}
|
|
if allItineraries[i].TotalDuration != allItineraries[j].TotalDuration {
|
|
return allItineraries[i].TotalDuration < allItineraries[j].TotalDuration
|
|
}
|
|
return allItineraries[i].Cost < allItineraries[j].Cost
|
|
})
|
|
case "cheapest":
|
|
sort.Slice(allItineraries, func(i, j int) bool {
|
|
if allItineraries[i].Cost != allItineraries[j].Cost {
|
|
return allItineraries[i].Cost < allItineraries[j].Cost
|
|
}
|
|
if allItineraries[i].TotalDuration != allItineraries[j].TotalDuration {
|
|
return allItineraries[i].TotalDuration < allItineraries[j].TotalDuration
|
|
}
|
|
return allItineraries[i].TotalTransfers < allItineraries[j].TotalTransfers
|
|
})
|
|
default: // "fastest" or any other value - sort by duration (primary), transfers (secondary), cost (tertiary)
|
|
sort.Slice(allItineraries, func(i, j int) bool {
|
|
if allItineraries[i].TotalDuration != allItineraries[j].TotalDuration {
|
|
return allItineraries[i].TotalDuration < allItineraries[j].TotalDuration
|
|
}
|
|
if allItineraries[i].TotalTransfers != allItineraries[j].TotalTransfers {
|
|
return allItineraries[i].TotalTransfers < allItineraries[j].TotalTransfers
|
|
}
|
|
return allItineraries[i].Cost < allItineraries[j].Cost
|
|
})
|
|
}
|
|
|
|
// Pareto filter: remove dominated routes
|
|
// A route is dominated if another route is better or equal in all metrics (time, transfers, cost)
|
|
var pareto []*Itinerary
|
|
for _, candidate := range allItineraries {
|
|
dominated := false
|
|
for _, existing := range pareto {
|
|
// Check if existing dominates candidate
|
|
if existing.TotalDuration <= candidate.TotalDuration &&
|
|
existing.TotalTransfers <= candidate.TotalTransfers &&
|
|
existing.Cost <= candidate.Cost &&
|
|
(existing.TotalDuration < candidate.TotalDuration ||
|
|
existing.TotalTransfers < candidate.TotalTransfers ||
|
|
existing.Cost < candidate.Cost) {
|
|
dominated = true
|
|
break
|
|
}
|
|
}
|
|
if !dominated {
|
|
pareto = append(pareto, candidate)
|
|
}
|
|
}
|
|
|
|
return pareto
|
|
}
|
|
|
|
// HubStation represents a hub station selected for graph expansion.
|
|
// Hub stations are major transport nodes that serve as anchor points
|
|
// for lazy graph expansion due to Yandex.Schedules API limitations.
|
|
type HubStation struct {
|
|
ID string
|
|
Name string
|
|
CityCode string
|
|
MinOutgoingFlights int // minimum outgoing flights criterion for hub selection
|
|
}
|
|
|
|
// getMCTForTransfer determines the minimum connection time for a transfer
|
|
// based on the node types and transfer context. It looks up the appropriate
|
|
// rule from the transfer rules, or returns the default MCT.
|
|
func getMCTForTransfer(optsMCT int, g *Graph) int {
|
|
// Default MCT if no rules match
|
|
defaultMCT := storage.DefaultMCT // 30 minutes
|
|
|
|
// If the user explicitly set an MCT via SearchOptions, prefer that
|
|
if optsMCT > 0 {
|
|
return optsMCT
|
|
}
|
|
|
|
// Try to determine MCT from node types in the graph
|
|
// In a full implementation, this would query the transfer_rules table
|
|
// from the database using storage.MinTransferTime(ruleKey, rules, defaultMCT)
|
|
// For now, return the default MCT.
|
|
|
|
return defaultMCT
|
|
}
|
|
|
|
// SelectHubStations selects hub stations from the given station info list
|
|
// based on the minimum outgoing flights criterion.
|
|
// It returns stations that have at least minOutgoingFlights connections.
|
|
func SelectHubStations(stations []StationInfo, minOutgoingFlights int) []*Node {
|
|
// Select stations that have enough unique city connections
|
|
// A station is selected as a hub if it has at least minOutgoingFlights connections to other cities
|
|
var hubs []*Node
|
|
for _, si := range stations {
|
|
stationNode := &Node{
|
|
ID: si.ID,
|
|
Type: NodeTypeStation,
|
|
Name: si.Name,
|
|
CityCode: si.CityCode,
|
|
}
|
|
|
|
// For now, select all stations as potential hubs if they have valid city code
|
|
// The actual hub selection based on outgoing connections should be done
|
|
// by analyzing the graph's edge connectivity
|
|
if si.CityCode != "" {
|
|
hubs = append(hubs, stationNode)
|
|
}
|
|
}
|
|
|
|
return hubs
|
|
}
|
|
|
|
// RouteStatus represents the current status of a route leg.
|
|
type RouteStatus int32
|
|
|
|
const (
|
|
// RouteStatusActive means the route leg is still active/scheduled
|
|
RouteStatusActive RouteStatus = iota
|
|
// RouteStatusCancelled means the route leg has been cancelled
|
|
RouteStatusCancelled
|
|
// RouteStatusDelayed means the route leg has a significant delay
|
|
RouteStatusDelayed
|
|
)
|
|
|
|
// routeChangeReason describes why a re-search might be needed.
|
|
type routeChangeReason string
|
|
|
|
const (
|
|
reasonNone routeChangeReason = "none"
|
|
reasonCancellation routeChangeReason = "cancellation"
|
|
reasonMajorDelay routeChangeReason = "major_delay"
|
|
)
|
|
|
|
// checkRouteForChanges checks if any leg of the route has undergone significant changes
|
|
// (cancellation or major delay) since the last check. Returns true if a re-search is recommended.
|
|
func (g *Graph) checkRouteForChanges(itinerary *Itinerary) bool {
|
|
now := time.Now().Unix()
|
|
|
|
// If already checked recently (within 1 hour), don't re-check
|
|
if itinerary.LastChecked > 0 && now-itinerary.LastChecked < 3600 {
|
|
return itinerary.NeedsReSearch
|
|
}
|
|
|
|
itinerary.LastChecked = now
|
|
needsReSearch := false
|
|
|
|
// Check each leg of the itinerary for changes
|
|
for _, leg := range itinerary.Legs {
|
|
// For each leg, check the edge status in the graph
|
|
// This is a simplified check - in a full implementation, we'd query the Yandex /schedule API
|
|
for _, edge := range g.edges {
|
|
if edge.From.ID == leg.From.ID && edge.To.ID == leg.To.ID {
|
|
// Check if edge is marked as cancelled or has unusual duration
|
|
// For now, we check if the edge duration is unreasonably high (simulating cancellation)
|
|
if edge.Duration > 86400 { // > 1 day - likely cancelled
|
|
needsReSearch = true
|
|
itinerary.NeedsReSearch = true
|
|
itinerary.ReSearchReason = string(reasonCancellation)
|
|
break
|
|
}
|
|
// Check for significant delay (more than 2x normal duration)
|
|
if edge.Duration > leg.Duration*2 && leg.Duration > 0 {
|
|
if !needsReSearch || itinerary.ReSearchReason == string(reasonNone) {
|
|
needsReSearch = true
|
|
itinerary.NeedsReSearch = true
|
|
itinerary.ReSearchReason = string(reasonMajorDelay)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if needsReSearch {
|
|
break
|
|
}
|
|
}
|
|
|
|
return needsReSearch
|
|
}
|
|
|
|
// rescheduleRoute performs a re-search for the route with updated graph data.
|
|
// This is called when significant changes are detected in the route legs.
|
|
func (g *Graph) rescheduleRoute(originID, destID string, opts SearchOptions, closedStations map[string]bool, neighbors map[string][]storage.StationNeighbor, yclient ...*yandex.Client) *Itinerary {
|
|
// Re-run the search with the same options to get an updated route
|
|
result := g.FindRoute(originID, destID, opts, closedStations, neighbors, yclient...)
|
|
if result != nil {
|
|
result.LastChecked = time.Now().Unix()
|
|
result.NeedsReSearch = false
|
|
result.ReSearchReason = string(reasonNone)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// CheckAndRescheduleRoute checks a route for changes and returns an updated route if needed.
|
|
// This is the main entry point for flight change notification logic.
|
|
func (g *Graph) CheckAndRescheduleRoute(itinerary *Itinerary, originID, destID string, opts SearchOptions, closedStations map[string]bool, neighbors map[string][]storage.StationNeighbor, yclient ...*yandex.Client) *Itinerary {
|
|
if g.checkRouteForChanges(itinerary) {
|
|
return g.rescheduleRoute(originID, destID, opts, closedStations, neighbors, yclient...)
|
|
}
|
|
return itinerary
|
|
}
|
|
|
|
// getStationNeighbors returns neighboring stations for a given station ID in the same city.
|
|
func getStationNeighbors(stationID string, g *Graph) []storage.StationNeighbor {
|
|
// Find the station's city code
|
|
node := g.NodesByID(stationID)
|
|
if node == nil {
|
|
return nil
|
|
}
|
|
cityCode := node.CityCode
|
|
|
|
var neighbors []storage.StationNeighbor
|
|
|
|
// Look for other stations in the same city that aren't the station itself
|
|
for _, n := range g.Nodes() {
|
|
if n.ID != stationID && n.CityCode == cityCode && n.Type == NodeTypeStation {
|
|
neighbors = append(neighbors, storage.StationNeighbor{
|
|
StationID: n.ID,
|
|
Name: n.Name,
|
|
CityCode: n.CityCode,
|
|
Source: "geo",
|
|
IsExcluded: false,
|
|
})
|
|
}
|
|
}
|
|
|
|
if len(neighbors) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return neighbors
|
|
}
|