Add Lambert solver, transfer matrix, Dijkstra routing, and route planner UI

- Lambert's problem solver using universal variable method with bisection
  (handles elliptic, parabolic, hyperbolic transfers + anti-podal cases)
- Transfer matrix: precompute pairwise station transfers over time window
  using Lambert solver with configurable launch velocity
- Dijkstra routing on time-expanded graph (station × week nodes, transfer
  + wait edges) to find minimum-time routes
- Route Planner UI: from/to station dropdowns, search window selector
  (1-10 years), "Find Optimal Route" button with results card
- Route visualization: orange dashed trajectory lines with arrow heads
  and leg numbers on the 2D canvas
- Tested: Mercury L1 → Jupiter L1 computes 6-month direct transfer at
  30 km/s — physically reasonable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 12:14:37 -07:00
parent a2daa2d617
commit 22dcc5b6ec
12 changed files with 977 additions and 2 deletions

View File

@@ -1,2 +1,4 @@
pub mod station;
pub mod route;
pub mod router;
pub mod station;
pub mod transfer_matrix;

View File

@@ -0,0 +1,187 @@
use crate::route::{RouteLeg, RouteResult};
use crate::transfer_matrix::TransferMatrix;
use std::cmp::Reverse;
use std::collections::BinaryHeap;
/// 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(
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;
}
// 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
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));
}
// Skip stale entries
if cost > dist[u] {
continue;
}
// Wait edge: stay at this station for one more week
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)));
}
}
// Transfer edges from this station at this week
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)));
}
}
}
}
}
None // No route found
}
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
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;
}
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 arrival_week = path[j] % t_max;
let wait_weeks = if i == 0 {
departure_week - (path[0] % t_max)
} else {
(path[j - 1] % t_max) - (path[i] % t_max)
};
legs.push(RouteLeg {
from_station: station,
to_station: next_station,
departure_week: departure_week as u32,
arrival_week: arrival_week as u32,
wait_weeks: wait_weeks as u32,
});
}
i = j;
}
let departure_week = if !path.is_empty() {
path[0] % t_max
} else {
earliest_week
};
let total_time = if !path.is_empty() {
(path.last().unwrap() % t_max) - departure_week
} else {
0
};
RouteResult {
legs,
total_time_weeks: total_time as u32,
departure_week: departure_week as u32,
}
}

View File

@@ -0,0 +1,150 @@
use crate::station::{Station, station_position};
use orbital_mechanics::bodies;
use orbital_mechanics::constants::*;
use orbital_mechanics::lambert;
use nalgebra::Vector3;
use serde::{Deserialize, Serialize};
/// Precomputed transfer costs between station pairs over time.
///
/// Stored as a sparse list of feasible transfers to save memory.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransferMatrix {
pub station_count: usize,
pub week_count: usize,
/// Start Julian Date
pub start_jd: f64,
/// Sparse entries: (from, to, departure_week, travel_weeks)
pub entries: Vec<TransferEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransferEntry {
pub from: u16,
pub to: u16,
pub departure_week: u16,
pub travel_weeks: u16,
}
/// Compute the transfer matrix for a set of stations.
///
/// For each station pair and each weekly departure time, solve Lambert's problem
/// to find the minimum transfer time achievable at the given launch velocity.
///
/// # Arguments
/// * `stations` - The station network
/// * `max_launch_v_kms` - Maximum launch velocity in km/s
/// * `start_jd` - Start of the time window (Julian Date)
/// * `week_count` - Number of weeks to compute
/// * `progress_callback` - Called with (completed, total) for progress reporting
pub fn compute_transfer_matrix(
stations: &[Station],
max_launch_v_kms: f64,
start_jd: f64,
week_count: usize,
mut progress_callback: impl FnMut(usize, usize),
) -> TransferMatrix {
let all_bodies = bodies::all_bodies();
let n = stations.len();
let total_work = n * (n - 1) * week_count;
let mut completed = 0;
let mut entries = Vec::new();
// Candidate TOFs to try (in days) - logarithmic spacing from weeks to years
let candidate_tofs_days: Vec<f64> = {
let mut tofs = Vec::new();
// From 1 week to 5 years, ~20 samples
let min_days: f64 = 7.0;
let max_days: f64 = 5.0 * 365.25;
for i in 0..20 {
let t: f64 = i as f64 / 19.0;
let days = min_days * (max_days / min_days).powf(t);
tofs.push(days);
}
tofs
};
for week in 0..week_count {
let jd = start_jd + (week as f64) * DAYS_PER_WEEK;
// Precompute all station positions at this epoch
let station_positions: Vec<Vector3<f64>> = stations
.iter()
.map(|s| station_position(s, &all_bodies, jd))
.collect();
for from in 0..n {
for to in 0..n {
if from == to {
continue;
}
let r1 = station_positions[from] * AU_KM; // Convert AU to km
let r2 = station_positions[to] * AU_KM;
// Try different TOFs and find the minimum that works
let mut best_travel_weeks: Option<u16> = None;
for &tof_days in &candidate_tofs_days {
let tof_seconds = tof_days * SECONDS_PER_DAY;
if let Some(sol) = lambert::solve_lambert(r1, r2, tof_seconds, MU_SUN, true) {
// Check if departure delta-v is within launch capability
// For simplicity, we check if the departure velocity magnitude
// is achievable (station is co-moving with its parent body)
let v1_mag = sol.v1.norm();
// Station orbital velocity (approximate)
let r1_au = station_positions[from].norm();
let v_station = if r1_au > 0.01 {
(MU_SUN / (r1_au * AU_KM)).sqrt() // km/s
} else {
0.0
};
// The departure delta-v is roughly v1 - v_station
// This is simplified — in reality we'd need the vector difference
let dv_approx = (v1_mag - v_station).abs();
if dv_approx <= max_launch_v_kms {
let travel_weeks = (tof_days / 7.0).ceil() as u16;
if travel_weeks > 0 && travel_weeks < 5200 {
match best_travel_weeks {
None => best_travel_weeks = Some(travel_weeks),
Some(prev) if travel_weeks < prev => {
best_travel_weeks = Some(travel_weeks);
}
_ => {}
}
}
}
}
}
if let Some(travel_weeks) = best_travel_weeks {
entries.push(TransferEntry {
from: from as u16,
to: to as u16,
departure_week: week as u16,
travel_weeks,
});
}
completed += 1;
if completed % 1000 == 0 {
progress_callback(completed, total_work);
}
}
}
}
progress_callback(total_work, total_work);
TransferMatrix {
station_count: n,
week_count,
start_jd,
entries,
}
}