Add multi-route comparison with Yen's k-shortest-paths algorithm
- Yen's algorithm in Rust: finds up to 3 distinct shortest routes by iteratively deviating from previously found paths with node exclusions - Routes are deduplicated by station sequence (same stations = same route even if timing differs slightly) - Dijkstra refactored to support node exclusion sets for Yen's spur paths - WASM API returns JSON array of RouteResult (was single object) - Frontend routing store holds multiple routes with selectedRouteIndex - Route tabs: "Fastest", "Alternative 1", "Alternative 2" with distinct colors (orange, blue, purple), click to switch and resample trajectory - Compact route planner layout: 2-column From/To, shorter window buttons - Tested: Earth L1 → Jupiter L1 finds 2-leg relay via Mercury L1 (6.9mo) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,145 +1,118 @@
|
||||
use crate::route::{RouteLeg, RouteResult};
|
||||
use crate::transfer_matrix::TransferMatrix;
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::BinaryHeap;
|
||||
use std::collections::{BinaryHeap, HashSet};
|
||||
|
||||
/// Find the optimal route between two stations using Dijkstra on
|
||||
/// the time-expanded graph.
|
||||
///
|
||||
/// Nodes: (station_id, week_index)
|
||||
/// Transfer edges: based on precomputed transfer matrix
|
||||
/// Wait edges: (station, t) → (station, t+1) with cost 1 week
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `matrix` - Precomputed transfer matrix
|
||||
/// * `from` - Source station index
|
||||
/// * `to` - Destination station index
|
||||
/// * `earliest_week` - Earliest allowed departure week
|
||||
/// * `latest_week` - Latest allowed departure week
|
||||
///
|
||||
/// # Returns
|
||||
/// The optimal route, or None if no route exists.
|
||||
pub fn find_optimal_route(
|
||||
/// Build the transfer lookup table for fast Dijkstra edge iteration.
|
||||
fn build_transfer_lookup(matrix: &TransferMatrix) -> Vec<Vec<(u16, u16)>> {
|
||||
let n = matrix.station_count;
|
||||
let t_max = matrix.week_count;
|
||||
let mut lookup: Vec<Vec<(u16, u16)>> = vec![Vec::new(); n * t_max];
|
||||
for entry in &matrix.entries {
|
||||
let key = entry.from as usize * t_max + entry.departure_week as usize;
|
||||
if key < lookup.len() {
|
||||
lookup[key].push((entry.to, entry.travel_weeks));
|
||||
}
|
||||
}
|
||||
lookup
|
||||
}
|
||||
|
||||
/// Run Dijkstra with optional node exclusions.
|
||||
/// Returns (path_as_node_ids, total_cost) or None.
|
||||
fn dijkstra_with_exclusions(
|
||||
matrix: &TransferMatrix,
|
||||
transfer_lookup: &[Vec<(u16, u16)>],
|
||||
from: usize,
|
||||
to: usize,
|
||||
earliest_week: usize,
|
||||
latest_week: usize,
|
||||
) -> Option<RouteResult> {
|
||||
excluded_nodes: &HashSet<usize>,
|
||||
) -> Option<(Vec<usize>, u32)> {
|
||||
let n = matrix.station_count;
|
||||
let t_max = matrix.week_count;
|
||||
|
||||
if from >= n || to >= n || from == to {
|
||||
return None;
|
||||
}
|
||||
|
||||
let latest_week = latest_week.min(t_max - 1);
|
||||
if earliest_week > latest_week {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Node encoding: node_id = station * t_max + week
|
||||
let node_count = n * t_max;
|
||||
|
||||
let mut dist = vec![u32::MAX; node_count];
|
||||
let mut prev: Vec<Option<usize>> = vec![None; node_count];
|
||||
|
||||
// Min-heap: (cost, node_id)
|
||||
let mut heap: BinaryHeap<Reverse<(u32, usize)>> = BinaryHeap::new();
|
||||
|
||||
// Seed: all departure times from the source station
|
||||
for week in earliest_week..=latest_week {
|
||||
let node = from * t_max + week;
|
||||
let cost = (week - earliest_week) as u32; // Waiting at origin costs time
|
||||
if excluded_nodes.contains(&node) {
|
||||
continue;
|
||||
}
|
||||
let cost = (week - earliest_week) as u32;
|
||||
dist[node] = cost;
|
||||
heap.push(Reverse((cost, node)));
|
||||
}
|
||||
|
||||
// Build a lookup for transfer matrix entries by (from, departure_week)
|
||||
// for faster access during Dijkstra
|
||||
let mut transfer_lookup: Vec<Vec<(u16, u16)>> = vec![Vec::new(); n * t_max];
|
||||
for entry in &matrix.entries {
|
||||
let key = entry.from as usize * t_max + entry.departure_week as usize;
|
||||
if key < transfer_lookup.len() {
|
||||
transfer_lookup[key].push((entry.to, entry.travel_weeks));
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(Reverse((cost, u))) = heap.pop() {
|
||||
let u_station = u / t_max;
|
||||
let u_week = u % t_max;
|
||||
|
||||
// Found destination — reconstruct path
|
||||
if u_station == to {
|
||||
return Some(reconstruct_route(matrix, &prev, u, earliest_week, t_max));
|
||||
// Reconstruct path
|
||||
let mut path = Vec::new();
|
||||
let mut node = u;
|
||||
loop {
|
||||
path.push(node);
|
||||
match prev[node] {
|
||||
Some(p) => node = p,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
path.reverse();
|
||||
return Some((path, cost));
|
||||
}
|
||||
|
||||
// Skip stale entries
|
||||
if cost > dist[u] {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wait edge: stay at this station for one more week
|
||||
// Wait edge
|
||||
if u_week + 1 < t_max {
|
||||
let v = u + 1; // Same station, next week
|
||||
let new_cost = cost + 1;
|
||||
if new_cost < dist[v] {
|
||||
dist[v] = new_cost;
|
||||
prev[v] = Some(u);
|
||||
heap.push(Reverse((new_cost, v)));
|
||||
let v = u + 1;
|
||||
if !excluded_nodes.contains(&v) {
|
||||
let new_cost = cost + 1;
|
||||
if new_cost < dist[v] {
|
||||
dist[v] = new_cost;
|
||||
prev[v] = Some(u);
|
||||
heap.push(Reverse((new_cost, v)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Transfer edges from this station at this week
|
||||
// Transfer edges
|
||||
let key = u_station * t_max + u_week;
|
||||
if key < transfer_lookup.len() {
|
||||
for &(to_station, travel_weeks) in &transfer_lookup[key] {
|
||||
let arrival_week = u_week + travel_weeks as usize;
|
||||
if arrival_week < t_max {
|
||||
let v = to_station as usize * t_max + arrival_week;
|
||||
let new_cost = cost + travel_weeks as u32;
|
||||
if new_cost < dist[v] {
|
||||
dist[v] = new_cost;
|
||||
prev[v] = Some(u);
|
||||
heap.push(Reverse((new_cost, v)));
|
||||
if !excluded_nodes.contains(&v) {
|
||||
let new_cost = cost + travel_weeks as u32;
|
||||
if new_cost < dist[v] {
|
||||
dist[v] = new_cost;
|
||||
prev[v] = Some(u);
|
||||
heap.push(Reverse((new_cost, v)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None // No route found
|
||||
None
|
||||
}
|
||||
|
||||
fn reconstruct_route(
|
||||
_matrix: &TransferMatrix,
|
||||
prev: &[Option<usize>],
|
||||
end_node: usize,
|
||||
earliest_week: usize,
|
||||
t_max: usize,
|
||||
) -> RouteResult {
|
||||
let mut path = Vec::new();
|
||||
let mut node = end_node;
|
||||
|
||||
// Trace back through prev pointers
|
||||
loop {
|
||||
path.push(node);
|
||||
match prev[node] {
|
||||
Some(p) => node = p,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
path.reverse();
|
||||
|
||||
// Convert path to route legs
|
||||
/// Convert a raw node path into a RouteResult.
|
||||
fn path_to_route(path: &[usize], t_max: usize) -> RouteResult {
|
||||
let mut legs = Vec::new();
|
||||
let mut i = 0;
|
||||
|
||||
while i < path.len() {
|
||||
let station = path[i] / t_max;
|
||||
let _week = path[i] % t_max;
|
||||
|
||||
// Find next different station in path
|
||||
let mut j = i + 1;
|
||||
while j < path.len() && path[j] / t_max == station {
|
||||
j += 1;
|
||||
@@ -147,7 +120,7 @@ fn reconstruct_route(
|
||||
|
||||
if j < path.len() {
|
||||
let next_station = path[j] / t_max;
|
||||
let departure_week = path[j - 1] % t_max; // Last week at current station
|
||||
let departure_week = path[j - 1] % t_max;
|
||||
let arrival_week = path[j] % t_max;
|
||||
let wait_weeks = if i == 0 {
|
||||
departure_week - (path[0] % t_max)
|
||||
@@ -167,12 +140,7 @@ fn reconstruct_route(
|
||||
i = j;
|
||||
}
|
||||
|
||||
let departure_week = if !path.is_empty() {
|
||||
path[0] % t_max
|
||||
} else {
|
||||
earliest_week
|
||||
};
|
||||
|
||||
let departure_week = if !path.is_empty() { path[0] % t_max } else { 0 };
|
||||
let total_time = if !path.is_empty() {
|
||||
(path.last().unwrap() % t_max) - departure_week
|
||||
} else {
|
||||
@@ -185,3 +153,177 @@ fn reconstruct_route(
|
||||
departure_week: departure_week as u32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the single optimal route.
|
||||
pub fn find_optimal_route(
|
||||
matrix: &TransferMatrix,
|
||||
from: usize,
|
||||
to: usize,
|
||||
earliest_week: usize,
|
||||
latest_week: usize,
|
||||
) -> Option<RouteResult> {
|
||||
let n = matrix.station_count;
|
||||
let t_max = matrix.week_count;
|
||||
if from >= n || to >= n || from == to { return None; }
|
||||
let latest_week = latest_week.min(t_max - 1);
|
||||
if earliest_week > latest_week { return None; }
|
||||
|
||||
let lookup = build_transfer_lookup(matrix);
|
||||
let empty = HashSet::new();
|
||||
|
||||
let (path, _cost) = dijkstra_with_exclusions(
|
||||
matrix, &lookup, from, to, earliest_week, latest_week, &empty,
|
||||
)?;
|
||||
|
||||
Some(path_to_route(&path, t_max))
|
||||
}
|
||||
|
||||
/// Find k-shortest paths using Yen's algorithm.
|
||||
///
|
||||
/// Returns up to `k` routes sorted by total time (shortest first).
|
||||
/// Routes are guaranteed to be distinct (different station sequences).
|
||||
pub fn find_k_shortest_routes(
|
||||
matrix: &TransferMatrix,
|
||||
from: usize,
|
||||
to: usize,
|
||||
earliest_week: usize,
|
||||
latest_week: usize,
|
||||
k: usize,
|
||||
) -> Vec<RouteResult> {
|
||||
let n = matrix.station_count;
|
||||
let t_max = matrix.week_count;
|
||||
if from >= n || to >= n || from == to || k == 0 { return vec![]; }
|
||||
let latest_week = latest_week.min(t_max - 1);
|
||||
if earliest_week > latest_week { return vec![]; }
|
||||
|
||||
let lookup = build_transfer_lookup(matrix);
|
||||
let empty = HashSet::new();
|
||||
|
||||
// Step 1: Find the shortest path
|
||||
let (first_path, first_cost) = match dijkstra_with_exclusions(
|
||||
matrix, &lookup, from, to, earliest_week, latest_week, &empty,
|
||||
) {
|
||||
Some(r) => r,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
let mut a_paths: Vec<(Vec<usize>, u32)> = vec![(first_path, first_cost)];
|
||||
let mut b_candidates: Vec<(Vec<usize>, u32)> = Vec::new();
|
||||
|
||||
// Track which station sequences we've already found to ensure diversity
|
||||
let mut seen_sequences: HashSet<Vec<usize>> = HashSet::new();
|
||||
seen_sequences.insert(
|
||||
station_sequence(&a_paths[0].0, t_max),
|
||||
);
|
||||
|
||||
for ki in 1..k {
|
||||
let prev_path = &a_paths[ki - 1].0;
|
||||
|
||||
// For each spur node in the previous path, find a deviation
|
||||
// We operate on "station transition" points in the path
|
||||
let transition_indices = find_transition_indices(prev_path, t_max);
|
||||
|
||||
for &spur_idx in &transition_indices {
|
||||
let spur_node = prev_path[spur_idx];
|
||||
|
||||
// Exclude nodes used by the root path (before spur point)
|
||||
// and nodes from previously found paths that share the same root
|
||||
let mut excluded = HashSet::new();
|
||||
|
||||
// Exclude the internal nodes of the root path up to spur
|
||||
for &node in &prev_path[..spur_idx] {
|
||||
excluded.insert(node);
|
||||
}
|
||||
|
||||
// Exclude edges/nodes from all existing A-paths that share
|
||||
// the same root (same path up to spur_idx)
|
||||
for (existing_path, _) in &a_paths {
|
||||
if existing_path.len() > spur_idx
|
||||
&& existing_path[..=spur_idx] == prev_path[..=spur_idx]
|
||||
{
|
||||
// Exclude the next node after spur in this existing path
|
||||
if spur_idx + 1 < existing_path.len() {
|
||||
excluded.insert(existing_path[spur_idx + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find shortest path from spur to destination
|
||||
let spur_station = spur_node / t_max;
|
||||
let spur_week = spur_node % t_max;
|
||||
|
||||
if let Some((spur_path, spur_cost)) = dijkstra_with_exclusions(
|
||||
matrix, &lookup, spur_station, to,
|
||||
spur_week, latest_week, &excluded,
|
||||
) {
|
||||
// Combine root + spur path
|
||||
let mut full_path = prev_path[..spur_idx].to_vec();
|
||||
full_path.extend_from_slice(&spur_path);
|
||||
|
||||
let root_cost = if spur_idx > 0 {
|
||||
// Cost of root portion
|
||||
(prev_path[spur_idx] % t_max - prev_path[0] % t_max) as u32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let total_cost = root_cost + spur_cost;
|
||||
|
||||
let seq = station_sequence(&full_path, t_max);
|
||||
if !seen_sequences.contains(&seq) {
|
||||
b_candidates.push((full_path, total_cost));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if b_candidates.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Pick the best candidate
|
||||
b_candidates.sort_by_key(|(_, cost)| *cost);
|
||||
|
||||
// Find the first candidate with a unique station sequence
|
||||
let mut found = false;
|
||||
while !b_candidates.is_empty() {
|
||||
let (path, cost) = b_candidates.remove(0);
|
||||
let seq = station_sequence(&path, t_max);
|
||||
if !seen_sequences.contains(&seq) {
|
||||
seen_sequences.insert(seq);
|
||||
a_paths.push((path, cost));
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
a_paths.into_iter().map(|(path, _)| path_to_route(&path, t_max)).collect()
|
||||
}
|
||||
|
||||
/// Extract the station-level sequence from a node path (ignoring time).
|
||||
fn station_sequence(path: &[usize], t_max: usize) -> Vec<usize> {
|
||||
let mut seq = Vec::new();
|
||||
let mut last_station = usize::MAX;
|
||||
for &node in path {
|
||||
let station = node / t_max;
|
||||
if station != last_station {
|
||||
seq.push(station);
|
||||
last_station = station;
|
||||
}
|
||||
}
|
||||
seq
|
||||
}
|
||||
|
||||
/// Find indices in the path where the station changes (transition points).
|
||||
fn find_transition_indices(path: &[usize], t_max: usize) -> Vec<usize> {
|
||||
let mut indices = vec![0]; // Always include the start
|
||||
for i in 1..path.len() {
|
||||
if path[i] / t_max != path[i - 1] / t_max {
|
||||
indices.push(i);
|
||||
}
|
||||
}
|
||||
indices
|
||||
}
|
||||
|
||||
@@ -213,17 +213,19 @@ pub fn compute_route(
|
||||
},
|
||||
);
|
||||
|
||||
// Find optimal route
|
||||
let route = router::find_optimal_route(
|
||||
// Find up to 3 shortest routes using Yen's algorithm
|
||||
let routes = router::find_k_shortest_routes(
|
||||
&matrix,
|
||||
from_station,
|
||||
to_station,
|
||||
0,
|
||||
week_window.saturating_sub(1),
|
||||
3,
|
||||
);
|
||||
|
||||
match route {
|
||||
Some(r) => serde_json::to_string(&r).unwrap_or_default(),
|
||||
None => String::new(),
|
||||
if routes.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
serde_json::to_string(&routes).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user