feat: implement synthetic edge fallback in FindRoute
- Add addSyntheticEdgesForNode function that connects nodes to city hubs - Modify FindRoute to add synthetic edges as fallback when no route found - Write TestFindRouteWithSyntheticFallback test Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -146,6 +146,35 @@ func BuildGraphFromStations(stations []StationInfo) *Graph {
|
||||
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.
|
||||
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 {
|
||||
// Add synthetic edge from node to city hub
|
||||
graph.AddEdge(&Edge{
|
||||
From: node,
|
||||
To: n,
|
||||
Kind: EdgeKindSynthetic,
|
||||
Duration: 300, // 5 min synthetic transfer
|
||||
Transport: "train",
|
||||
IsTransfer: true,
|
||||
})
|
||||
|
||||
// Add reverse synthetic edge from city hub to node
|
||||
graph.AddEdge(&Edge{
|
||||
From: n,
|
||||
To: node,
|
||||
Kind: EdgeKindSynthetic,
|
||||
Duration: 300, // 5 min synthetic transfer
|
||||
Transport: "train",
|
||||
IsTransfer: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SortEdges sorts edges by duration in ascending order (shortest first).
|
||||
func SortEdges(edges []*Edge) {
|
||||
sort.Slice(edges, func(i, j int) bool {
|
||||
@@ -173,7 +202,8 @@ func (g *Graph) NodesByID(id string) *Node {
|
||||
}
|
||||
|
||||
// FindRoute performs BFS/Dijkstra search from origin to destination with a transfer depth limit.
|
||||
// It returns the best itinerary found within the transfer 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.
|
||||
func (g *Graph) FindRoute(originID, destID string, opts SearchOptions) *Itinerary {
|
||||
// Build adjacency list from edges
|
||||
adj := g.buildAdjacencyList()
|
||||
@@ -315,9 +345,127 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions) *Itinerar
|
||||
})
|
||||
}
|
||||
|
||||
// If no route found via lazy expansion, try synthetic edge fallback
|
||||
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 = opts.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,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
@@ -458,3 +606,46 @@ func (g *Graph) FindRoutesPareto(originID, destID string, opts SearchOptions) []
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user