From 3099b58ea0a3ab4e0ef6dd6dd33079bb22d2f0a3 Mon Sep 17 00:00:00 2001 From: scott Date: Wed, 8 Apr 2026 12:48:39 -0700 Subject: [PATCH] Add multi-route comparison with Yen's k-shortest-paths algorithm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- crates/mass-driver-core/src/router.rs | 326 +++++++++++++----- crates/mass-driver-wasm/src/api.rs | 12 +- frontend/src/lib/stores/routing.svelte.ts | 23 +- frontend/src/lib/wasm/bridge.ts | 14 +- .../components/RoutingPanel.svelte | 264 ++++++++------ 5 files changed, 420 insertions(+), 219 deletions(-) diff --git a/crates/mass-driver-core/src/router.rs b/crates/mass-driver-core/src/router.rs index ec3b43a..bfaa852 100644 --- a/crates/mass-driver-core/src/router.rs +++ b/crates/mass-driver-core/src/router.rs @@ -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> { + let n = matrix.station_count; + let t_max = matrix.week_count; + let mut lookup: Vec> = 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 { + excluded_nodes: &HashSet, +) -> Option<(Vec, 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> = vec![None; node_count]; - - // Min-heap: (cost, node_id) let mut heap: BinaryHeap> = 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![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], - 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 { + 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 { + 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, u32)> = vec![(first_path, first_cost)]; + let mut b_candidates: Vec<(Vec, u32)> = Vec::new(); + + // Track which station sequences we've already found to ensure diversity + let mut seen_sequences: HashSet> = 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 { + 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 { + 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 +} diff --git a/crates/mass-driver-wasm/src/api.rs b/crates/mass-driver-wasm/src/api.rs index 0831092..55f4f97 100644 --- a/crates/mass-driver-wasm/src/api.rs +++ b/crates/mass-driver-wasm/src/api.rs @@ -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() } } diff --git a/frontend/src/lib/stores/routing.svelte.ts b/frontend/src/lib/stores/routing.svelte.ts index f9186f1..d5a0c06 100644 --- a/frontend/src/lib/stores/routing.svelte.ts +++ b/frontend/src/lib/stores/routing.svelte.ts @@ -3,7 +3,10 @@ import type { RouteResult } from '$lib/wasm/bridge'; class RoutingState { fromStation = $state(null); toStation = $state(null); - route = $state(null); + + // Multiple route alternatives + routes = $state([]); + selectedRouteIndex = $state(0); isComputing = $state(false); error = $state(''); weekWindow = $state(260); // ~5 years search window @@ -12,6 +15,24 @@ class RoutingState { trajectoryPoints = $state(new Float64Array(0)); routeProgress = $state(0); // 0..1 along the full route isAnimating = $state(false); + + // Derived: currently selected route + get route(): RouteResult | null { + return this.routes[this.selectedRouteIndex] ?? null; + } + + get routeCount(): number { + return this.routes.length; + } + + selectRoute(index: number) { + if (index >= 0 && index < this.routes.length) { + this.selectedRouteIndex = index; + // Reset animation when switching routes + this.routeProgress = 0; + // Trajectory will be resampled in the next frame by the renderer + } + } } export const routing = new RoutingState(); diff --git a/frontend/src/lib/wasm/bridge.ts b/frontend/src/lib/wasm/bridge.ts index 64abefe..41d59f6 100644 --- a/frontend/src/lib/wasm/bridge.ts +++ b/frontend/src/lib/wasm/bridge.ts @@ -81,21 +81,23 @@ export function sampleRouteTrajectory( return wasm.sample_route_trajectory(stationsJson, routeJson, jd, samplesPerLeg); } -export function computeRoute( +export function computeRoutes( stationsJson: string, fromStation: number, toStation: number, launchVelocityKms: number, startJd: number, weekWindow: number, -): RouteResult | null { +): RouteResult[] { const wasm = getWasm(); - if (!wasm) return null; + if (!wasm) return []; const json = wasm.compute_route(stationsJson, fromStation, toStation, launchVelocityKms, startJd, weekWindow); - if (!json) return null; + if (!json) return []; try { - return JSON.parse(json); + const result = JSON.parse(json); + // API now returns array of routes + return Array.isArray(result) ? result : [result]; } catch { - return null; + return []; } } diff --git a/frontend/src/routes/(simulator)/components/RoutingPanel.svelte b/frontend/src/routes/(simulator)/components/RoutingPanel.svelte index fbd3a0d..404d657 100644 --- a/frontend/src/routes/(simulator)/components/RoutingPanel.svelte +++ b/frontend/src/routes/(simulator)/components/RoutingPanel.svelte @@ -2,9 +2,12 @@ import { simulation } from '$lib/stores/simulation.svelte'; import { stations } from '$lib/stores/stations.svelte'; import { routing } from '$lib/stores/routing.svelte'; - import { computeRoute, sampleRouteTrajectory } from '$lib/wasm/bridge'; + import { computeRoutes, sampleRouteTrajectory } from '$lib/wasm/bridge'; import { formatWeeks } from '$lib/utils/format'; + const ROUTE_COLORS = ['#ff6a33', '#4a9eff', '#cc44ff']; + const ROUTE_LABELS = ['Fastest', 'Alternative 1', 'Alternative 2']; + function handleCompute() { if (routing.fromStation === null || routing.toStation === null) { routing.error = 'Select both start and end stations'; @@ -17,13 +20,14 @@ routing.isComputing = true; routing.error = ''; - routing.route = null; + routing.routes = []; + routing.selectedRouteIndex = 0; routing.trajectoryPoints = new Float64Array(0); routing.routeProgress = 0; routing.isAnimating = false; setTimeout(() => { - const result = computeRoute( + const results = computeRoutes( stations.stationsJson, routing.fromStation!, routing.toStation!, @@ -32,11 +36,12 @@ routing.weekWindow, ); - if (result && result.legs.length > 0) { - routing.route = result; + if (results.length > 0) { + routing.routes = results; + routing.selectedRouteIndex = 0; - // Sample trajectory for visualization - const routeJson = JSON.stringify(result); + // Sample trajectory for the first (fastest) route + const routeJson = JSON.stringify(results[0]); routing.trajectoryPoints = sampleRouteTrajectory( stations.stationsJson, routeJson, simulation.currentJD, 60, ); @@ -48,11 +53,23 @@ }, 50); } + function selectRoute(index: number) { + routing.selectRoute(index); + // Resample trajectory for the newly selected route + const route = routing.routes[index]; + if (route) { + const routeJson = JSON.stringify(route); + routing.trajectoryPoints = sampleRouteTrajectory( + stations.stationsJson, routeJson, simulation.currentJD, 60, + ); + } + } + const windowOptions = [ - { label: '1 year', value: 52 }, - { label: '2 years', value: 104 }, - { label: '5 years', value: 260 }, - { label: '10 years', value: 520 }, + { label: '1 yr', value: 52 }, + { label: '2 yr', value: 104 }, + { label: '5 yr', value: 260 }, + { label: '10 yr', value: 520 }, ]; @@ -61,132 +78,149 @@ Route Planner - -
- - -
- - -
- - -
- - -
- -
- {#each windowOptions as opt} - - {/each} + +
+
+ + +
+
+ +
- + +
+ {#each windowOptions as opt} + + {/each} +
+ - {#if routing.error}

{routing.error}

{/if} - - {#if routing.route} + + {#if routing.routes.length > 0}
-
- Route Found - - {formatWeeks(routing.route.total_time_weeks)} - -
+ + {#if routing.routes.length > 1} +
+ {#each routing.routes as route, i} + + {/each} +
+ {/if} -
- Departs week {routing.route.departure_week} · {routing.route.legs.length} leg{routing.route.legs.length !== 1 ? 's' : ''} -
+ + {#if routing.route} + {@const route = routing.route} + {@const color = ROUTE_COLORS[routing.selectedRouteIndex]} +
+ + {routing.routes.length > 1 ? ROUTE_LABELS[routing.selectedRouteIndex] : 'Route Found'} + + + {formatWeeks(route.total_time_weeks)} + +
- -
- {#each routing.route.legs as leg, i} -
- - {i + 1} - -
-
- {stations.stationNames[leg.from_station]} → {stations.stationNames[leg.to_station]} -
-
- {formatWeeks(leg.arrival_week - leg.departure_week)} transit - {#if leg.wait_weeks > 0} - · {formatWeeks(leg.wait_weeks)} wait - {/if} +
+ Departs week {route.departure_week} · {route.legs.length} leg{route.legs.length !== 1 ? 's' : ''} +
+ +
+ {#each route.legs as leg, i} +
+ + {i + 1} + +
+
+ {stations.stationNames[leg.from_station]} → {stations.stationNames[leg.to_station]} +
+
+ {formatWeeks(leg.arrival_week - leg.departure_week)} transit + {#if leg.wait_weeks > 0} + · {formatWeeks(leg.wait_weeks)} wait + {/if} +
-
- {/each} -
- - - {#if routing.trajectoryPoints.length > 0} -
-
- Package Progress - -
- -
- {Math.round(routing.routeProgress * 100)}% complete -
+ {/each}
+ + + {#if routing.trajectoryPoints.length > 0} +
+
+ Package + +
+ +
+ {/if} {/if}
{/if}