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,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
use mass_driver_core::router;
|
||||
use mass_driver_core::station;
|
||||
use mass_driver_core::transfer_matrix;
|
||||
use orbital_mechanics::bodies;
|
||||
|
||||
use orbital_mechanics::orbits;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
@@ -98,3 +101,59 @@ pub fn get_station_names(stations_json: &str) -> String {
|
||||
let names: Vec<&str> = stations.iter().map(|s| s.name.as_str()).collect();
|
||||
serde_json::to_string(&names).unwrap()
|
||||
}
|
||||
|
||||
/// Compute transfer matrix and find optimal route between two stations.
|
||||
///
|
||||
/// This is the main computation function. It:
|
||||
/// 1. Computes the transfer matrix for the given stations over a time window
|
||||
/// 2. Runs Dijkstra to find the optimal route
|
||||
///
|
||||
/// Returns JSON-serialized RouteResult, or empty string if no route found.
|
||||
///
|
||||
/// # Arguments (all as JSON)
|
||||
/// * `stations_json` - Station configuration
|
||||
/// * `from_station` - Source station index
|
||||
/// * `to_station` - Destination station index
|
||||
/// * `launch_velocity_kms` - Max launch velocity in km/s
|
||||
/// * `start_jd` - Start of search window (Julian Date)
|
||||
/// * `week_window` - Number of weeks to search
|
||||
#[wasm_bindgen]
|
||||
pub fn compute_route(
|
||||
stations_json: &str,
|
||||
from_station: usize,
|
||||
to_station: usize,
|
||||
launch_velocity_kms: f64,
|
||||
start_jd: f64,
|
||||
week_window: usize,
|
||||
) -> String {
|
||||
let stations: Vec<station::Station> = match serde_json::from_str(stations_json) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return String::new(),
|
||||
};
|
||||
|
||||
// Compute transfer matrix (the heavy part)
|
||||
let matrix = transfer_matrix::compute_transfer_matrix(
|
||||
&stations,
|
||||
launch_velocity_kms,
|
||||
start_jd,
|
||||
week_window,
|
||||
|_completed, _total| {
|
||||
// Progress callback — in WASM we can't easily report progress
|
||||
// to the main thread without Web Workers. For now, just run.
|
||||
},
|
||||
);
|
||||
|
||||
// Find optimal route
|
||||
let route = router::find_optimal_route(
|
||||
&matrix,
|
||||
from_station,
|
||||
to_station,
|
||||
0,
|
||||
week_window.saturating_sub(1),
|
||||
);
|
||||
|
||||
match route {
|
||||
Some(r) => serde_json::to_string(&r).unwrap_or_default(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
300
crates/orbital-mechanics/src/lambert.rs
Normal file
300
crates/orbital-mechanics/src/lambert.rs
Normal file
@@ -0,0 +1,300 @@
|
||||
//! Lambert's problem solver using universal variable method.
|
||||
//!
|
||||
//! Given two position vectors r1 and r2, and a time of flight tof,
|
||||
//! find the orbit connecting them. Returns departure and arrival velocities.
|
||||
//!
|
||||
//! Uses the algorithm from Curtis, "Orbital Mechanics for Engineering Students",
|
||||
//! with bisection for robust convergence.
|
||||
|
||||
use nalgebra::Vector3;
|
||||
|
||||
/// Solution to Lambert's problem.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LambertSolution {
|
||||
/// Departure velocity (km/s)
|
||||
pub v1: Vector3<f64>,
|
||||
/// Arrival velocity (km/s)
|
||||
pub v2: Vector3<f64>,
|
||||
}
|
||||
|
||||
/// Solve Lambert's problem.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `r1` - Initial position vector (km)
|
||||
/// * `r2` - Final position vector (km)
|
||||
/// * `tof` - Time of flight (seconds)
|
||||
/// * `mu` - Gravitational parameter (km³/s²)
|
||||
/// * `prograde` - If true, use prograde (short-way) transfer
|
||||
///
|
||||
/// # Returns
|
||||
/// `Some(LambertSolution)` if a solution exists, `None` otherwise.
|
||||
pub fn solve_lambert(
|
||||
r1: Vector3<f64>,
|
||||
r2: Vector3<f64>,
|
||||
tof: f64,
|
||||
mu: f64,
|
||||
prograde: bool,
|
||||
) -> Option<LambertSolution> {
|
||||
if tof <= 0.0 || mu <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let r1_norm = r1.norm();
|
||||
let r2_norm = r2.norm();
|
||||
|
||||
if r1_norm < 1e-6 || r2_norm < 1e-6 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Handle near-180° transfers by adding a tiny out-of-plane perturbation
|
||||
let cos_angle = r1.dot(&r2) / (r1_norm * r2_norm);
|
||||
let r2 = if cos_angle < -0.99999 {
|
||||
// Near anti-podal: perturb slightly to break degeneracy
|
||||
Vector3::new(r2.x, r2.y, r2.z + r2_norm * 1e-6)
|
||||
} else {
|
||||
r2
|
||||
};
|
||||
let r2_norm = r2.norm();
|
||||
|
||||
// Change in true anomaly
|
||||
let cos_dnu = r1.dot(&r2) / (r1_norm * r2_norm);
|
||||
let cos_dnu = cos_dnu.clamp(-1.0, 1.0);
|
||||
|
||||
let cross = r1.cross(&r2);
|
||||
|
||||
let sin_dnu = if prograde {
|
||||
if cross.z >= 0.0 { (1.0 - cos_dnu * cos_dnu).sqrt() }
|
||||
else { -(1.0 - cos_dnu * cos_dnu).sqrt() }
|
||||
} else {
|
||||
if cross.z < 0.0 { (1.0 - cos_dnu * cos_dnu).sqrt() }
|
||||
else { -(1.0 - cos_dnu * cos_dnu).sqrt() }
|
||||
};
|
||||
|
||||
// Parameter A (from Curtis Eq. 5.35)
|
||||
let a = sin_dnu * (r1_norm * r2_norm / (1.0 - cos_dnu)).sqrt();
|
||||
|
||||
if a.abs() < 1e-10 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Function to compute TOF for a given z using Stumpff functions
|
||||
let tof_from_z = |z: f64| -> Option<f64> {
|
||||
let (c2, c3) = stumpff(z);
|
||||
if c2.abs() < 1e-20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let y = r1_norm + r2_norm + a * (z * c3 - 1.0) / c2.sqrt();
|
||||
if y < 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let chi = (y / c2).sqrt();
|
||||
let t = (chi * chi * chi * c3 + a * y.sqrt()) / mu.sqrt();
|
||||
Some(t)
|
||||
};
|
||||
|
||||
// Find z using bisection + Newton hybrid
|
||||
// For elliptic orbits: z > 0
|
||||
// For hyperbolic orbits: z < 0
|
||||
|
||||
// Find a bracket [z_low, z_high] containing the solution.
|
||||
// z_low should give TOF < target, z_high should give TOF > target.
|
||||
let mut z_low = -4.0 * std::f64::consts::PI * std::f64::consts::PI;
|
||||
let mut z_high = 4.0 * std::f64::consts::PI * std::f64::consts::PI;
|
||||
|
||||
// Ensure z_high gives TOF >= target (keep doubling if needed)
|
||||
for _ in 0..50 {
|
||||
match tof_from_z(z_high) {
|
||||
Some(t) if t >= tof => break,
|
||||
_ => z_high *= 2.0,
|
||||
}
|
||||
if z_high > 1e10 { return None; }
|
||||
}
|
||||
|
||||
// Ensure z_low gives TOF < target (keep decreasing if needed)
|
||||
for _ in 0..50 {
|
||||
match tof_from_z(z_low) {
|
||||
Some(t) if t < tof => break,
|
||||
None => break, // y < 0 means TOF undefined → z is too low, which is fine
|
||||
_ => z_low -= 10.0,
|
||||
}
|
||||
if z_low < -1e10 { return None; }
|
||||
}
|
||||
|
||||
// Bisection iteration
|
||||
// Higher z → larger semi-major axis → longer TOF
|
||||
let mut z = 0.0;
|
||||
for _ in 0..200 {
|
||||
z = (z_low + z_high) / 2.0;
|
||||
|
||||
let t = match tof_from_z(z) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
// y < 0 means z is too low; increase lower bound
|
||||
z_low = z;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if (t - tof).abs() < 1e-6 {
|
||||
break; // Converged
|
||||
}
|
||||
|
||||
if t < tof {
|
||||
z_low = z; // Need more time → increase z
|
||||
} else {
|
||||
z_high = z; // Need less time → decrease z
|
||||
}
|
||||
|
||||
if (z_high - z_low).abs() < 1e-12 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute velocities from the converged z
|
||||
let (c2, c3) = stumpff(z);
|
||||
if c2.abs() < 1e-20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let y = r1_norm + r2_norm + a * (z * c3 - 1.0) / c2.sqrt();
|
||||
if y < 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Lagrange coefficients
|
||||
let f = 1.0 - y / r1_norm;
|
||||
let g = a * (y / mu).sqrt();
|
||||
let g_dot = 1.0 - y / r2_norm;
|
||||
|
||||
if g.abs() < 1e-20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let v1 = (r2 - f * r1) / g;
|
||||
let v2 = (g_dot * r2 - r1) / g;
|
||||
|
||||
Some(LambertSolution { v1, v2 })
|
||||
}
|
||||
|
||||
/// Stumpff functions c2(z) and c3(z).
|
||||
fn stumpff(z: f64) -> (f64, f64) {
|
||||
if z > 1e-6 {
|
||||
let sz = z.sqrt();
|
||||
let c2 = (1.0 - sz.cos()) / z;
|
||||
let c3 = (sz - sz.sin()) / (z * sz);
|
||||
(c2, c3)
|
||||
} else if z < -1e-6 {
|
||||
let sz = (-z).sqrt();
|
||||
let c2 = (sz.cosh() - 1.0) / (-z);
|
||||
let c3 = (sz.sinh() - sz) / ((-z) * sz);
|
||||
(c2, c3)
|
||||
} else {
|
||||
// Taylor series near z = 0
|
||||
(1.0 / 2.0 - z / 24.0 + z * z / 720.0,
|
||||
1.0 / 6.0 - z / 120.0 + z * z / 5040.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the delta-v required for a Lambert transfer.
|
||||
/// Returns (dv_departure, dv_arrival) in km/s, where dv is relative to
|
||||
/// the velocity at each position.
|
||||
pub fn transfer_delta_v(
|
||||
r1: Vector3<f64>,
|
||||
v1_body: Vector3<f64>,
|
||||
r2: Vector3<f64>,
|
||||
v2_body: Vector3<f64>,
|
||||
tof: f64,
|
||||
mu: f64,
|
||||
) -> Option<(f64, f64)> {
|
||||
let sol = solve_lambert(r1, r2, tof, mu, true)?;
|
||||
let dv1 = (sol.v1 - v1_body).norm();
|
||||
let dv2 = (sol.v2 - v2_body).norm();
|
||||
Some((dv1, dv2))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::constants::*;
|
||||
|
||||
#[test]
|
||||
fn test_hohmann_earth_mars() {
|
||||
// Earth at 1 AU, Mars at 1.524 AU (opposite side)
|
||||
let r1 = Vector3::new(AU_KM, 0.0, 0.0);
|
||||
let r2 = Vector3::new(-1.524 * AU_KM, 0.0, 0.0);
|
||||
|
||||
// Hohmann transfer time ≈ 259 days
|
||||
let tof = 259.0 * SECONDS_PER_DAY;
|
||||
|
||||
let result = solve_lambert(r1, r2, tof, MU_SUN, true);
|
||||
assert!(result.is_some(), "Lambert solver should find a solution");
|
||||
|
||||
let sol = result.unwrap();
|
||||
let v1_mag = sol.v1.norm();
|
||||
// Departure velocity should be ~30-35 km/s (Earth orbital velocity + transfer excess)
|
||||
assert!(
|
||||
v1_mag > 20.0 && v1_mag < 45.0,
|
||||
"Departure velocity {} km/s should be reasonable",
|
||||
v1_mag
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_short_transfer() {
|
||||
// 30-degree transfer at 1 AU
|
||||
let r1 = Vector3::new(AU_KM, 0.0, 0.0);
|
||||
let angle = std::f64::consts::FRAC_PI_6;
|
||||
let r2 = Vector3::new(AU_KM * angle.cos(), AU_KM * angle.sin(), 0.0);
|
||||
|
||||
let tof = 50.0 * SECONDS_PER_DAY;
|
||||
|
||||
let result = solve_lambert(r1, r2, tof, MU_SUN, true);
|
||||
assert!(result.is_some(), "Should solve short transfer");
|
||||
|
||||
let sol = result.unwrap();
|
||||
let v1_mag = sol.v1.norm();
|
||||
assert!(
|
||||
v1_mag > 10.0 && v1_mag < 60.0,
|
||||
"Velocity {} km/s should be reasonable",
|
||||
v1_mag
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_circular_orbit_consistency() {
|
||||
// If we solve Lambert for two points on a circular orbit with
|
||||
// the correct TOF, the departure velocity should match orbital velocity
|
||||
let r = AU_KM; // 1 AU
|
||||
let v_circ = (MU_SUN / r).sqrt(); // ~29.8 km/s
|
||||
|
||||
let angle = std::f64::consts::FRAC_PI_4; // 45 degrees
|
||||
let r1 = Vector3::new(r, 0.0, 0.0);
|
||||
let r2 = Vector3::new(r * angle.cos(), r * angle.sin(), 0.0);
|
||||
|
||||
// Time for 45° of circular orbit
|
||||
let period = 2.0 * std::f64::consts::PI * (r.powi(3) / MU_SUN).sqrt();
|
||||
let tof = period * 45.0 / 360.0;
|
||||
|
||||
let result = solve_lambert(r1, r2, tof, MU_SUN, true);
|
||||
assert!(result.is_some(), "Should solve circular orbit transfer");
|
||||
|
||||
let sol = result.unwrap();
|
||||
let v1_mag = sol.v1.norm();
|
||||
// Should be close to circular velocity
|
||||
assert!(
|
||||
(v1_mag - v_circ).abs() / v_circ < 0.05,
|
||||
"Departure velocity {} km/s should match circular velocity {} km/s",
|
||||
v1_mag, v_circ
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_degenerate_zero_tof() {
|
||||
let r1 = Vector3::new(AU_KM, 0.0, 0.0);
|
||||
let r2 = Vector3::new(0.0, AU_KM, 0.0);
|
||||
let result = solve_lambert(r1, r2, 0.0, MU_SUN, true);
|
||||
assert!(result.is_none(), "Zero TOF should return None");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod bodies;
|
||||
pub mod constants;
|
||||
pub mod kepler;
|
||||
pub mod lagrange;
|
||||
pub mod lambert;
|
||||
pub mod orbits;
|
||||
|
||||
pub use nalgebra::Vector3;
|
||||
|
||||
Reference in New Issue
Block a user