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:
@@ -1,2 +1,4 @@
|
||||
pub mod station;
|
||||
pub mod route;
|
||||
pub mod router;
|
||||
pub mod station;
|
||||
pub mod transfer_matrix;
|
||||
|
||||
187
crates/mass-driver-core/src/router.rs
Normal file
187
crates/mass-driver-core/src/router.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
150
crates/mass-driver-core/src/transfer_matrix.rs
Normal file
150
crates/mass-driver-core/src/transfer_matrix.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user