Files
trip-planner/internal/routing/graph.go
Vladimir Zagainov d67bc5aca7 feat: Implement Pareto-front ranking integration with multi-criteria sorting
- Add RankingMode field to SearchOptions (fastest/fewest_transfers/cheapest)
- Update FindRoutesPareto to respect ranking mode when sorting
- Add ranking_mode query parameter to RouteSearch endpoint
- Add TestParetoFrontGeneration with subtests for all three modes

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 19:27:02 +03:00

944 lines
28 KiB
Go

package routing
import (
"context"
"sort"
"time"
"trip-planner/internal/yandex"
)
// 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
}
// NewGraph creates a new empty routing graph.
func NewGraph() *Graph {
return &Graph{
nodes: []*Node{},
edges: []*Edge{},
}
}
// 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) {
// 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, yclient ...*yandex.Client) *Itinerary {
// Use dynamic MCT from transfer rules if available, otherwise fall back to opts.MCT
mct := getMCTForTransfer(opts.MCT, g)
// 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{}},
}
// 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
// Check if we've visited this node with fewer transfers
visKey := current.nodeID
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++
}
// 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,
}
// 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
visKey := current.nodeID
if _, ok := visited[visKey]; ok {
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"),
}
resp, err := yandexClient.Do(context.Background(), "GET", "/v3.0/search/", query)
if err != nil {
// If API call fails, return nil (no route found)
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
visKey := current.nodeID
if _, ok := visited[visKey]; ok {
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)
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)
itinerary.TotalDuration += mct
}
// 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
}
// 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) []*Itinerary {
// Run multiple searches with different strategies to find diverse routes
var allItineraries []*Itinerary
// Search with different max transfer limits to find diverse routes
for maxTransfers := 0; maxTransfers <= opts.MaxTransfers; maxTransfers++ {
optsCopy := opts
optsCopy.MaxTransfers = maxTransfers
result := g.FindRoute(originID, destID, optsCopy)
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 := 1800 // 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
// This is a simplified lookup; in a full implementation, this would
// query the transfer_rules table from the database
// For now, return the default MCT. In a full implementation,
// this would query the transfer_rules table.
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 {
// Count unique destination cities for each station
// A station is selected as a hub if it has at least minOutgoingFlights connections to other cities
hubCities := make(map[string]bool)
for _, si := range stations {
cityKey := "city:" + si.CityCode
hubCities[cityKey] = true
}
uniqueCityCount := len(hubCities)
// Select stations that have enough unique city connections
var hubs []*Node
for _, si := range stations {
stationNode := &Node{
ID: si.ID,
Type: NodeTypeStation,
Name: si.Name,
CityCode: si.CityCode,
}
if uniqueCityCount >= minOutgoingFlights {
hubs = append(hubs, stationNode)
}
}
return hubs
}