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 // Calculate new transfers before checking visited 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 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 // Calculate new transfers before checking visited 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 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 }