solar_line_core/
orbits.rs

1/// Orbital element types and state vectors for 2-body problem.
2use crate::units::{Eccentricity, Km, KmPerSec, Mu, Radians, Seconds};
3use crate::vec3::Vec3;
4
5/// Classical Keplerian orbital elements.
6///
7/// Assumption: These elements describe an orbit in a 2-body problem.
8/// The reference frame and epoch must be tracked separately.
9#[derive(Debug, Clone, Copy)]
10pub struct OrbitalElements {
11    /// Semi-major axis (km). Positive for elliptical orbits.
12    pub semi_major_axis: Km,
13    /// Orbital eccentricity (dimensionless, >= 0)
14    pub eccentricity: Eccentricity,
15    /// Inclination (radians, 0 to π)
16    pub inclination: Radians,
17    /// Right ascension of ascending node (radians, 0 to 2π)
18    pub raan: Radians,
19    /// Argument of periapsis (radians, 0 to 2π)
20    pub arg_periapsis: Radians,
21    /// True anomaly at epoch (radians)
22    pub true_anomaly: Radians,
23}
24
25/// Cartesian state vector in an inertial frame.
26#[derive(Debug, Clone, Copy)]
27pub struct StateVector {
28    /// Position vector (km)
29    pub position: Vec3<Km>,
30    /// Velocity vector (km/s)
31    pub velocity: Vec3<KmPerSec>,
32}
33
34impl StateVector {
35    pub fn new(position: Vec3<Km>, velocity: Vec3<KmPerSec>) -> Self {
36        Self { position, velocity }
37    }
38
39    /// Orbital radius (distance from central body)
40    pub fn radius(&self) -> Km {
41        Km(self.position.norm_raw())
42    }
43
44    /// Orbital speed (magnitude of velocity)
45    pub fn speed(&self) -> KmPerSec {
46        KmPerSec(self.velocity.norm_raw())
47    }
48}
49
50/// Vis-viva equation: v = sqrt(μ * (2/r - 1/a))
51///
52/// Calculates orbital velocity at distance r from the central body,
53/// for an orbit with semi-major axis a.
54pub fn vis_viva(mu: Mu, r: Km, a: Km) -> KmPerSec {
55    KmPerSec((mu.value() * (2.0 / r.value() - 1.0 / a.value())).sqrt())
56}
57
58/// Calculate ΔV for a Hohmann transfer between two circular orbits.
59///
60/// Returns (dv1, dv2): ΔV at departure and arrival (km/s).
61/// Assumes both orbits are circular and coplanar.
62pub fn hohmann_transfer_dv(mu: Mu, r1: Km, r2: Km) -> (KmPerSec, KmPerSec) {
63    let a_transfer = Km((r1.value() + r2.value()) / 2.0);
64
65    let v_circular_1 = KmPerSec((mu.value() / r1.value()).sqrt());
66    let v_transfer_1 = vis_viva(mu, r1, a_transfer);
67    let dv1 = (v_transfer_1 - v_circular_1).abs();
68
69    let v_circular_2 = KmPerSec((mu.value() / r2.value()).sqrt());
70    let v_transfer_2 = vis_viva(mu, r2, a_transfer);
71    let dv2 = (v_circular_2 - v_transfer_2).abs();
72
73    (dv1, dv2)
74}
75
76/// Orbital period for an elliptical orbit: T = 2π * sqrt(a³/μ)
77pub fn orbital_period(mu: Mu, a: Km) -> crate::units::Seconds {
78    let a_val = a.value();
79    crate::units::Seconds(std::f64::consts::TAU * (a_val.powi(3) / mu.value()).sqrt())
80}
81
82/// Specific orbital energy: ε = -μ/(2a)
83pub fn specific_energy(mu: Mu, a: Km) -> f64 {
84    -mu.value() / (2.0 * a.value())
85}
86
87/// Specific angular momentum magnitude for an elliptical orbit:
88/// h = sqrt(μ * a * (1 - e²))
89pub fn specific_angular_momentum(mu: Mu, a: Km, e: Eccentricity) -> f64 {
90    (mu.value() * a.value() * (1.0 - e.value().powi(2))).sqrt()
91}
92
93/// Required constant acceleration for a brachistochrone (flip-at-midpoint) transfer.
94///
95/// Model: accelerate for half the distance, flip, decelerate for the other half.
96/// a_required = 4 * d / t²
97///
98/// Returns acceleration in km/s².
99///
100/// Assumption: straight-line path, constant thrust, no gravity, rest-to-rest.
101pub fn brachistochrone_accel(distance: Km, time: Seconds) -> f64 {
102    (4.0 * distance.value()) / (time.value() * time.value())
103}
104
105/// ΔV for a brachistochrone transfer (constant thrust, flip at midpoint).
106///
107/// ΔV = a_required * t = 4 * d / t
108///
109/// Returns ΔV in km/s.
110///
111/// Assumption: straight-line path, constant thrust, no gravity, rest-to-rest.
112pub fn brachistochrone_dv(distance: Km, time: Seconds) -> KmPerSec {
113    KmPerSec((4.0 * distance.value()) / time.value())
114}
115
116/// Maximum reachable distance for a brachistochrone transfer at given acceleration and time.
117///
118/// d_max = a * t² / 4
119///
120/// Returns distance in km.
121pub fn brachistochrone_max_distance(accel_km_s2: f64, time: Seconds) -> Km {
122    Km(accel_km_s2 * time.value() * time.value() / 4.0)
123}
124
125/// Transfer time for a brachistochrone transfer given distance and constant acceleration.
126///
127/// t = 2 * sqrt(d / a)
128///
129/// Returns time in seconds.
130///
131/// Assumption: straight-line path, constant thrust, no gravity, rest-to-rest.
132pub fn brachistochrone_time(distance: Km, accel_km_s2: f64) -> Seconds {
133    Seconds(2.0 * (distance.value() / accel_km_s2).sqrt())
134}
135
136// ── Tsiolkovsky rocket equation ──────────────────────────────────────
137
138/// Convert specific impulse (seconds) to exhaust velocity (km/s).
139///
140/// vₑ = Isp × g₀, with g₀ = 9.80665 m/s² (converted to km/s).
141///
142/// Panics if `isp_s` is not positive.
143pub fn exhaust_velocity(isp_s: f64) -> KmPerSec {
144    assert!(isp_s > 0.0, "Isp must be positive");
145    KmPerSec(isp_s * crate::constants::G0_M_S2 / 1000.0)
146}
147
148/// Tsiolkovsky mass ratio: m₀/m_f = exp(ΔV / vₑ).
149///
150/// Returns the ratio of initial mass to final (dry) mass required
151/// to achieve the given ΔV at the given exhaust velocity.
152///
153/// Panics if `ve` is not positive or `delta_v` is negative.
154/// Returns f64::INFINITY if ΔV/vₑ overflows exp().
155pub fn mass_ratio(delta_v: KmPerSec, ve: KmPerSec) -> f64 {
156    assert!(ve.value() > 0.0, "exhaust velocity must be positive");
157    assert!(delta_v.value() >= 0.0, "delta-v must be non-negative");
158    (delta_v.value() / ve.value()).exp()
159}
160
161/// Propellant mass fraction: 1 - 1/mass_ratio = 1 - exp(-ΔV/vₑ).
162///
163/// The fraction of initial mass that must be propellant.
164/// Returns a value in [0, 1). For ΔV >> vₑ this approaches 1.
165pub fn propellant_fraction(delta_v: KmPerSec, ve: KmPerSec) -> f64 {
166    let mr = mass_ratio(delta_v, ve);
167    if mr.is_infinite() {
168        1.0
169    } else {
170        1.0 - 1.0 / mr
171    }
172}
173
174/// Required propellant mass (kg) given dry (post-burn) mass and ΔV.
175///
176/// m_prop = m_dry × (exp(ΔV/vₑ) - 1)
177pub fn required_propellant_mass(dry_mass_kg: f64, delta_v: KmPerSec, ve: KmPerSec) -> f64 {
178    dry_mass_kg * (mass_ratio(delta_v, ve) - 1.0)
179}
180
181/// Initial (pre-burn) mass (kg) given dry mass and ΔV.
182///
183/// m₀ = m_dry × exp(ΔV/vₑ)
184pub fn initial_mass(dry_mass_kg: f64, delta_v: KmPerSec, ve: KmPerSec) -> f64 {
185    dry_mass_kg * mass_ratio(delta_v, ve)
186}
187
188/// Mass flow rate (kg/s) for a given thrust (N) and exhaust velocity (km/s).
189///
190/// ṁ = F / vₑ  (with unit conversion: vₑ km/s → m/s)
191pub fn mass_flow_rate(thrust_n: f64, ve: KmPerSec) -> f64 {
192    assert!(ve.value() > 0.0, "exhaust velocity must be positive");
193    thrust_n / (ve.value() * 1000.0)
194}
195
196/// Jet power (W) for a given thrust (N) and exhaust velocity (km/s).
197///
198/// P_jet = ½ × F × vₑ  (kinetic power in the exhaust stream)
199pub fn jet_power(thrust_n: f64, ve: KmPerSec) -> f64 {
200    0.5 * thrust_n * ve.value() * 1000.0
201}
202
203// ── Oberth effect ──────────────────────────────────────────────────
204
205/// Effective velocity change from a burn performed at periapsis of a hyperbolic flyby.
206///
207/// When a spacecraft with hyperbolic excess speed `v_inf` performs a prograde burn
208/// of magnitude `burn_dv` at periapsis distance `r_periapsis` from a body with
209/// gravitational parameter `mu`, the resulting change in v_inf (at infinity) is
210/// larger than the burn_dv alone — this is the Oberth effect.
211///
212/// v_periapsis = sqrt(v_inf² + 2μ/r_p)
213/// v_periapsis_after_burn = v_periapsis + burn_dv
214/// v_inf_after = sqrt(v_periapsis_after_burn² - 2μ/r_p)
215/// oberth_gain = (v_inf_after - v_inf) - burn_dv
216///
217/// Returns the Oberth gain: the extra Δv_inf beyond what the burn alone provides.
218/// A positive value means the burn was amplified by the gravity well.
219///
220/// All velocities in km/s, distances in km, mu in km³/s².
221pub fn oberth_dv_gain(mu: Mu, r_periapsis: Km, v_inf: KmPerSec, burn_dv: KmPerSec) -> KmPerSec {
222    let v_inf_val = v_inf.value();
223    let mu_val = mu.value();
224    let r_p = r_periapsis.value();
225    let burn = burn_dv.value();
226
227    // Speed at periapsis (hyperbolic trajectory)
228    let v_peri = (v_inf_val * v_inf_val + 2.0 * mu_val / r_p).sqrt();
229    // Speed at periapsis after prograde burn
230    let v_peri_after = v_peri + burn;
231    // New v_inf after burn (at infinity, kinetic energy minus escape energy)
232    let v_inf_after_sq = v_peri_after * v_peri_after - 2.0 * mu_val / r_p;
233    let v_inf_after = if v_inf_after_sq > 0.0 {
234        v_inf_after_sq.sqrt()
235    } else {
236        // Burn not enough to escape — captured
237        0.0
238    };
239
240    // Oberth gain = (change in v_inf) - burn_dv
241    KmPerSec((v_inf_after - v_inf_val) - burn)
242}
243
244/// Convert classical Keplerian orbital elements to a Cartesian state vector.
245///
246/// Uses the standard rotation sequence: perifocal → inertial via (Ω, i, ω).
247/// The central body's gravitational parameter μ is needed to compute velocity.
248pub fn elements_to_state_vector(mu: Mu, elements: &OrbitalElements) -> StateVector {
249    let a = elements.semi_major_axis.value();
250    let e = elements.eccentricity.value();
251    let i = elements.inclination.value();
252    let raan = elements.raan.value();
253    let w = elements.arg_periapsis.value();
254    let nu = elements.true_anomaly.value();
255
256    // Semi-latus rectum
257    let p = a * (1.0 - e * e);
258
259    // Distance
260    let r = p / (1.0 + e * nu.cos());
261
262    // Position in perifocal frame (PQW)
263    let x_pqw = r * nu.cos();
264    let y_pqw = r * nu.sin();
265
266    // Velocity in perifocal frame
267    let mu_over_p = (mu.value() / p).sqrt();
268    let vx_pqw = -mu_over_p * nu.sin();
269    let vy_pqw = mu_over_p * (e + nu.cos());
270
271    // Rotation matrix elements (Ω, i, ω → ecliptic inertial)
272    let cos_w = w.cos();
273    let sin_w = w.sin();
274    let cos_i = i.cos();
275    let sin_i = i.sin();
276    let cos_raan = raan.cos();
277    let sin_raan = raan.sin();
278
279    // Column 1 of rotation matrix (P direction)
280    let r11 = cos_raan * cos_w - sin_raan * sin_w * cos_i;
281    let r21 = sin_raan * cos_w + cos_raan * sin_w * cos_i;
282    let r31 = sin_w * sin_i;
283
284    // Column 2 of rotation matrix (Q direction)
285    let r12 = -cos_raan * sin_w - sin_raan * cos_w * cos_i;
286    let r22 = -sin_raan * sin_w + cos_raan * cos_w * cos_i;
287    let r32 = cos_w * sin_i;
288
289    let pos = Vec3::new(
290        Km(r11 * x_pqw + r12 * y_pqw),
291        Km(r21 * x_pqw + r22 * y_pqw),
292        Km(r31 * x_pqw + r32 * y_pqw),
293    );
294
295    let vel = Vec3::new(
296        KmPerSec(r11 * vx_pqw + r12 * vy_pqw),
297        KmPerSec(r21 * vx_pqw + r22 * vy_pqw),
298        KmPerSec(r31 * vx_pqw + r32 * vy_pqw),
299    );
300
301    StateVector::new(pos, vel)
302}
303
304/// Compute the out-of-plane ΔV required for a simple plane change maneuver.
305///
306/// For a velocity `v` and plane change angle `delta_i` (radians),
307/// ΔV = 2 * v * sin(Δi / 2).
308pub fn plane_change_dv(v: KmPerSec, delta_i: Radians) -> KmPerSec {
309    KmPerSec(2.0 * v.value() * (delta_i.value() / 2.0).sin().abs())
310}
311
312/// Fractional Oberth efficiency: (Δv_inf / burn_dv) - 1.
313///
314/// Returns the fractional improvement in v_inf change relative to the burn magnitude.
315/// For example, 0.03 means 3% more effective than a burn in free space.
316pub fn oberth_efficiency(mu: Mu, r_periapsis: Km, v_inf: KmPerSec, burn_dv: KmPerSec) -> f64 {
317    let v_inf_val = v_inf.value();
318    let mu_val = mu.value();
319    let r_p = r_periapsis.value();
320    let burn = burn_dv.value();
321
322    if burn <= 0.0 {
323        return 0.0;
324    }
325
326    let v_peri = (v_inf_val * v_inf_val + 2.0 * mu_val / r_p).sqrt();
327    let v_peri_after = v_peri + burn;
328    let v_inf_after_sq = v_peri_after * v_peri_after - 2.0 * mu_val / r_p;
329    let v_inf_after = if v_inf_after_sq > 0.0 {
330        v_inf_after_sq.sqrt()
331    } else {
332        0.0
333    };
334
335    let delta_v_inf = v_inf_after - v_inf_val;
336    (delta_v_inf / burn) - 1.0
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::constants::{mu, orbit_radius, reference_orbits};
343
344    #[test]
345    fn test_vis_viva_circular_orbit() {
346        // For a circular orbit, r == a, so v = sqrt(μ/r)
347        let r = Km(6_778.0); // ~400km altitude LEO
348        let v = vis_viva(mu::EARTH, r, r);
349        let expected = (mu::EARTH.value() / r.value()).sqrt();
350        assert!(
351            (v.value() - expected).abs() < 1e-10,
352            "v={}, expected={}",
353            v.value(),
354            expected
355        );
356    }
357
358    #[test]
359    fn test_hohmann_leo_to_geo() {
360        let r1 = reference_orbits::LEO_RADIUS;
361        let r2 = reference_orbits::GEO_RADIUS;
362
363        let (dv1, dv2) = hohmann_transfer_dv(mu::EARTH, r1, r2);
364        let total_dv = dv1.value() + dv2.value();
365
366        // Expected total ΔV for LEO-GEO Hohmann is approximately 3.935 km/s
367        assert!(
368            (total_dv - 3.935).abs() < 0.05,
369            "total ΔV = {} km/s, expected ~3.935 km/s",
370            total_dv
371        );
372    }
373
374    #[test]
375    fn test_hohmann_earth_to_mars() {
376        let r_earth = orbit_radius::EARTH;
377        let r_mars = orbit_radius::MARS;
378
379        let (dv1, dv2) = hohmann_transfer_dv(mu::SUN, r_earth, r_mars);
380
381        assert!(
382            (dv1.value() - 2.94).abs() < 0.1,
383            "departure ΔV = {} km/s, expected ~2.94 km/s",
384            dv1.value()
385        );
386
387        assert!(
388            (dv2.value() - 2.65).abs() < 0.1,
389            "arrival ΔV = {} km/s, expected ~2.65 km/s",
390            dv2.value()
391        );
392    }
393
394    #[test]
395    fn test_orbital_period_earth() {
396        // Earth's orbital period around Sun: ~365.25 days
397        let period = orbital_period(mu::SUN, orbit_radius::EARTH);
398        let days = period.value() / 86400.0;
399        assert!(
400            (days - 365.25).abs() < 0.5,
401            "Earth orbital period = {} days, expected ~365.25",
402            days
403        );
404    }
405
406    #[test]
407    fn test_orbital_period_leo() {
408        // LEO period at ~200 km: ~88.5 minutes
409        let period = orbital_period(mu::EARTH, reference_orbits::LEO_RADIUS);
410        let minutes = period.value() / 60.0;
411        assert!(
412            (minutes - 88.5).abs() < 1.0,
413            "LEO orbital period = {} min, expected ~88.5 min",
414            minutes
415        );
416    }
417
418    #[test]
419    fn test_specific_energy_bound_orbit() {
420        // Bound (elliptical) orbits have negative specific energy
421        let energy = specific_energy(mu::EARTH, reference_orbits::LEO_RADIUS);
422        assert!(energy < 0.0, "LEO specific energy should be negative");
423    }
424
425    #[test]
426    fn test_specific_angular_momentum() {
427        // For circular orbit (e=0): h = sqrt(μ * r)
428        let e = Eccentricity::elliptical(0.0).unwrap();
429        let r = reference_orbits::LEO_RADIUS;
430        let h = specific_angular_momentum(mu::EARTH, r, e);
431        let expected = (mu::EARTH.value() * r.value()).sqrt();
432        assert!(
433            (h - expected).abs() < 1e-6,
434            "h = {}, expected = {}",
435            h,
436            expected
437        );
438    }
439
440    #[test]
441    fn test_state_vector_radius_speed() {
442        // ISS-like orbit: ~408 km altitude, ~7.66 km/s
443        let pos = Vec3::new(Km(6786.0), Km(0.0), Km(0.0));
444        let vel = Vec3::new(KmPerSec(0.0), KmPerSec(7.66), KmPerSec(0.0));
445        let state = StateVector::new(pos, vel);
446
447        assert!((state.radius().value() - 6786.0).abs() < 1e-10);
448        assert!((state.speed().value() - 7.66).abs() < 1e-10);
449    }
450
451    #[test]
452    fn test_brachistochrone_accel() {
453        // 1 AU in 72 hours (259,200 seconds)
454        // d = 149_597_870.7 km, t = 259200 s
455        // a = 4 * d / t² = 4 * 149597870.7 / 259200² = ~8.91e-3 km/s²
456        let d = Km(149_597_870.7);
457        let t = crate::units::Seconds(259_200.0);
458        let a = brachistochrone_accel(d, t);
459        let expected = 4.0 * 149_597_870.7 / (259_200.0 * 259_200.0);
460        assert!(
461            (a - expected).abs() < 1e-12,
462            "brachistochrone accel = {}, expected = {}",
463            a,
464            expected
465        );
466    }
467
468    #[test]
469    fn test_brachistochrone_dv() {
470        // ΔV = 4 * d / t
471        let d = Km(149_597_870.7);
472        let t = crate::units::Seconds(259_200.0);
473        let dv = brachistochrone_dv(d, t);
474        let expected = 4.0 * 149_597_870.7 / 259_200.0;
475        assert!(
476            (dv.value() - expected).abs() < 1e-8,
477            "brachistochrone ΔV = {} km/s, expected = {} km/s",
478            dv.value(),
479            expected
480        );
481    }
482
483    #[test]
484    fn test_brachistochrone_identity() {
485        // brachistochrone_dv = brachistochrone_accel * time
486        let d = Km(550_630_800.0); // Mars-Jupiter closest
487        let t = crate::units::Seconds(72.0 * 3600.0);
488        let a = brachistochrone_accel(d, t);
489        let dv = brachistochrone_dv(d, t);
490        assert!(
491            (dv.value() - a * t.value()).abs() < 1e-6,
492            "ΔV ({}) should equal a*t ({})",
493            dv.value(),
494            a * t.value()
495        );
496    }
497
498    #[test]
499    fn test_brachistochrone_max_distance() {
500        // Round-trip: max_distance at the computed accel should equal original distance
501        let d = Km(550_630_800.0);
502        let t = crate::units::Seconds(72.0 * 3600.0);
503        let a = brachistochrone_accel(d, t);
504        let d_max = brachistochrone_max_distance(a, t);
505        assert!(
506            (d_max.value() - d.value()).abs() < 1.0,
507            "round-trip distance: {} vs {}",
508            d_max.value(),
509            d.value()
510        );
511    }
512
513    #[test]
514    fn test_brachistochrone_time() {
515        // Round-trip: time at the computed accel should equal original time
516        let d = Km(550_630_800.0);
517        let t = crate::units::Seconds(72.0 * 3600.0);
518        let a = brachistochrone_accel(d, t);
519        let t_back = brachistochrone_time(d, a);
520        assert!(
521            (t_back.value() - t.value()).abs() < 1e-6,
522            "round-trip time: {} vs {}",
523            t_back.value(),
524            t.value()
525        );
526    }
527
528    #[test]
529    fn test_brachistochrone_time_1g_55m_km() {
530        // Mars closest approach ~55M km at 1G ≈ 41.6 hours
531        let d = Km(55_000_000.0);
532        let a = 9.81e-3; // 1G in km/s²
533        let t = brachistochrone_time(d, a);
534        let hours = t.value() / 3600.0;
535        assert!(
536            (hours - 41.6).abs() < 1.0,
537            "Mars at 1G should be ~41.6h, got {:.1}h",
538            hours
539        );
540    }
541
542    // ── Tsiolkovsky rocket equation tests ────────────────────────────
543
544    #[test]
545    fn test_exhaust_velocity_chemical() {
546        // Chemical rocket: Isp ≈ 450 s → vₑ ≈ 4.413 km/s
547        let ve = exhaust_velocity(450.0);
548        let expected = 450.0 * 9.80665 / 1000.0; // 4.41299 km/s
549        assert!(
550            (ve.value() - expected).abs() < 1e-6,
551            "ve = {} km/s, expected = {} km/s",
552            ve.value(),
553            expected
554        );
555    }
556
557    #[test]
558    fn test_exhaust_velocity_fusion() {
559        // D-He³ fusion: Isp ≈ 100,000 s → vₑ ≈ 980.665 km/s
560        let ve = exhaust_velocity(100_000.0);
561        assert!(
562            (ve.value() - 980.665).abs() < 0.001,
563            "ve = {} km/s, expected 980.665 km/s",
564            ve.value()
565        );
566    }
567
568    #[test]
569    fn test_mass_ratio_zero_dv() {
570        // Zero ΔV → mass ratio = 1 (no propellant needed)
571        let mr = mass_ratio(KmPerSec(0.0), KmPerSec(1.0));
572        assert!((mr - 1.0).abs() < 1e-15);
573    }
574
575    #[test]
576    fn test_mass_ratio_equal_dv_ve() {
577        // ΔV = vₑ → mass ratio = e ≈ 2.71828
578        let mr = mass_ratio(KmPerSec(10.0), KmPerSec(10.0));
579        assert!(
580            (mr - std::f64::consts::E).abs() < 1e-10,
581            "mass ratio = {}, expected e = {}",
582            mr,
583            std::f64::consts::E
584        );
585    }
586
587    #[test]
588    fn test_mass_ratio_chemical_leo() {
589        // Chemical rocket to LEO: ΔV ≈ 9.4 km/s, Isp 450s → vₑ ≈ 4.413 km/s
590        // mass ratio = exp(9.4/4.413) ≈ 8.37
591        let ve = exhaust_velocity(450.0);
592        let mr = mass_ratio(KmPerSec(9.4), ve);
593        assert!(
594            (mr - 8.37).abs() < 0.1,
595            "mass ratio = {}, expected ~8.37",
596            mr
597        );
598    }
599
600    #[test]
601    fn test_mass_ratio_overflow() {
602        // Extremely high ΔV/vₑ ratio → INFINITY
603        let mr = mass_ratio(KmPerSec(1e10), KmPerSec(1.0));
604        assert!(mr.is_infinite(), "expected INFINITY for extreme mass ratio");
605    }
606
607    #[test]
608    fn test_propellant_fraction_zero_dv() {
609        let pf = propellant_fraction(KmPerSec(0.0), KmPerSec(1.0));
610        assert!(pf.abs() < 1e-15, "zero ΔV → zero propellant fraction");
611    }
612
613    #[test]
614    fn test_propellant_fraction_moderate() {
615        // ΔV = vₑ → pf = 1 - 1/e ≈ 0.6321
616        let pf = propellant_fraction(KmPerSec(10.0), KmPerSec(10.0));
617        let expected = 1.0 - 1.0 / std::f64::consts::E;
618        assert!(
619            (pf - expected).abs() < 1e-10,
620            "propellant fraction = {}, expected = {}",
621            pf,
622            expected
623        );
624    }
625
626    #[test]
627    fn test_propellant_fraction_overflow() {
628        let pf = propellant_fraction(KmPerSec(1e10), KmPerSec(1.0));
629        assert!(
630            (pf - 1.0).abs() < 1e-15,
631            "extreme ΔV → propellant fraction = 1.0"
632        );
633    }
634
635    #[test]
636    fn test_required_propellant_mass() {
637        // 1000 kg dry, ΔV = vₑ → propellant = 1000 * (e - 1) ≈ 1718.28 kg
638        let prop = required_propellant_mass(1000.0, KmPerSec(10.0), KmPerSec(10.0));
639        let expected = 1000.0 * (std::f64::consts::E - 1.0);
640        assert!(
641            (prop - expected).abs() < 0.01,
642            "propellant = {} kg, expected = {} kg",
643            prop,
644            expected
645        );
646    }
647
648    #[test]
649    fn test_initial_mass() {
650        // 1000 kg dry, ΔV = vₑ → initial = 1000 * e ≈ 2718.28 kg
651        let m0 = initial_mass(1000.0, KmPerSec(10.0), KmPerSec(10.0));
652        let expected = 1000.0 * std::f64::consts::E;
653        assert!(
654            (m0 - expected).abs() < 0.01,
655            "initial mass = {} kg, expected = {} kg",
656            m0,
657            expected
658        );
659    }
660
661    #[test]
662    fn test_initial_mass_consistency() {
663        // initial_mass = dry_mass + required_propellant_mass
664        let dry = 500.0;
665        let dv = KmPerSec(20.0);
666        let ve = KmPerSec(10.0);
667        let m0 = initial_mass(dry, dv, ve);
668        let prop = required_propellant_mass(dry, dv, ve);
669        assert!(
670            (m0 - (dry + prop)).abs() < 1e-6,
671            "m0 = {}, dry + prop = {}",
672            m0,
673            dry + prop
674        );
675    }
676
677    #[test]
678    fn test_mass_flow_rate() {
679        // 9.8 MN thrust, vₑ = 980.665 km/s (Isp 100,000 s)
680        // ṁ = 9.8e6 / (980.665 * 1000) = 9.993 kg/s
681        let ve = exhaust_velocity(100_000.0);
682        let mdot = mass_flow_rate(9.8e6, ve);
683        let expected = 9.8e6 / (980.665 * 1000.0);
684        assert!(
685            (mdot - expected).abs() < 0.01,
686            "mdot = {} kg/s, expected = {} kg/s",
687            mdot,
688            expected
689        );
690    }
691
692    #[test]
693    fn test_jet_power() {
694        // 9.8 MN thrust, vₑ = 980.665 km/s
695        // P_jet = 0.5 * 9.8e6 * 980665 = 4.805e12 W ≈ 4.8 TW
696        let ve = exhaust_velocity(100_000.0);
697        let p = jet_power(9.8e6, ve);
698        let expected = 0.5 * 9.8e6 * 980.665 * 1000.0;
699        assert!(
700            (p - expected).abs() < 1.0,
701            "P_jet = {} W, expected = {} W",
702            p,
703            expected
704        );
705    }
706
707    #[test]
708    fn test_kestrel_propellant_budget_ep01() {
709        // Kestrel EP01: ΔV ≈ 8497 km/s, dry mass 300t = 300,000 kg
710        // At Isp 1,000,000 s (vₑ ≈ 9806.65 km/s):
711        // mass_ratio = exp(8497/9806.65) ≈ 2.376
712        // propellant fraction ≈ 0.579
713        let ve = exhaust_velocity(1_000_000.0);
714        let dv = KmPerSec(8497.0);
715        let pf = propellant_fraction(dv, ve);
716        assert!(
717            pf > 0.5 && pf < 0.7,
718            "EP01 propellant fraction = {} (expected 0.5-0.7 at Isp 10⁶ s)",
719            pf
720        );
721        // At Isp 100,000 s (vₑ ≈ 980.665 km/s):
722        // mass_ratio = exp(8497/980.665) ≈ 5826 → propellant fraction ≈ 0.99983
723        let ve_low = exhaust_velocity(100_000.0);
724        let pf_low = propellant_fraction(dv, ve_low);
725        assert!(
726            pf_low > 0.999,
727            "EP01 at Isp 10⁵ s → propellant fraction = {} (>99.9%, impractical)",
728            pf_low
729        );
730    }
731
732    // ── Oberth effect tests ───────────────────────────────────────────
733
734    #[test]
735    fn test_oberth_gain_zero_burn() {
736        // No burn → no gain
737        let gain = oberth_dv_gain(mu::JUPITER, Km(71_492.0), KmPerSec(10.0), KmPerSec(0.0));
738        assert!(
739            gain.value().abs() < 1e-10,
740            "zero burn should give zero gain, got {}",
741            gain.value()
742        );
743    }
744
745    #[test]
746    fn test_oberth_gain_positive() {
747        // A burn at Jupiter periapsis should yield positive Oberth gain
748        // Jupiter radius ≈ 71,492 km, v_inf = 10 km/s, burn = 1 km/s
749        let gain = oberth_dv_gain(mu::JUPITER, Km(71_492.0), KmPerSec(10.0), KmPerSec(1.0));
750        assert!(
751            gain.value() > 0.0,
752            "Oberth gain should be positive at Jupiter periapsis, got {}",
753            gain.value()
754        );
755    }
756
757    #[test]
758    fn test_oberth_gain_stronger_at_lower_periapsis() {
759        // Closer periapsis → larger Oberth gain
760        let gain_close = oberth_dv_gain(mu::JUPITER, Km(71_492.0), KmPerSec(10.0), KmPerSec(1.0));
761        let gain_far = oberth_dv_gain(
762            mu::JUPITER,
763            Km(71_492.0 * 5.0),
764            KmPerSec(10.0),
765            KmPerSec(1.0),
766        );
767        assert!(
768            gain_close.value() > gain_far.value(),
769            "closer periapsis should give larger gain: {} vs {}",
770            gain_close.value(),
771            gain_far.value()
772        );
773    }
774
775    #[test]
776    fn test_oberth_gain_negligible_at_high_v_inf() {
777        // At very high v_inf (1500 km/s), Jupiter's gravity well is tiny
778        // relative to kinetic energy → Oberth gain should be very small
779        let gain = oberth_dv_gain(
780            mu::JUPITER,
781            Km(71_492.0 * 3.0), // 3 RJ periapsis
782            KmPerSec(1500.0),
783            KmPerSec(50.0), // 50 km/s burn
784        );
785        let efficiency = oberth_efficiency(
786            mu::JUPITER,
787            Km(71_492.0 * 3.0),
788            KmPerSec(1500.0),
789            KmPerSec(50.0),
790        );
791        // At 1500 km/s, Jupiter's escape velocity at 3 RJ is ~40 km/s
792        // So v_peri ≈ sqrt(1500² + 40²) ≈ 1500.5 km/s — barely changes
793        // Efficiency should be small (a few percent at most)
794        assert!(
795            efficiency.abs() < 0.10,
796            "Oberth efficiency at 1500 km/s should be small, got {}",
797            efficiency
798        );
799        // But still positive
800        assert!(
801            gain.value() > 0.0,
802            "Oberth gain should still be positive: {}",
803            gain.value()
804        );
805    }
806
807    #[test]
808    fn test_oberth_ep05_jupiter_flyby_3_percent() {
809        // EP05: v_inf ≈ 1500 km/s, powered flyby at Jupiter
810        // The show claims "Oberth effect efficiency improvement approximately 3%"
811        // Let's verify this is in the right ballpark for reasonable parameters
812        //
813        // At 1500 km/s, we need to find what periapsis + burn_dv gives ~3%
814        // Try periapsis at 2-5 RJ range with burns of 30-100 km/s
815        let r_j = 71_492.0;
816        let v_inf = KmPerSec(1500.0);
817
818        // With a ~50 km/s burn at 3 RJ
819        let eff_3rj = oberth_efficiency(mu::JUPITER, Km(r_j * 3.0), v_inf, KmPerSec(50.0));
820
821        // The exact 3% depends on burn_dv and r_p choices.
822        // At 1500 km/s, Jupiter's well is shallow → efficiency is small.
823        // Check that 3% is achievable for some parameter combination.
824        // At 1 RJ with large burn:
825        let eff_1rj = oberth_efficiency(mu::JUPITER, Km(r_j), v_inf, KmPerSec(100.0));
826
827        // The efficiency increases at lower periapsis and larger burns
828        assert!(
829            eff_1rj > eff_3rj,
830            "1 RJ should give higher efficiency than 3 RJ"
831        );
832    }
833
834    #[test]
835    fn test_oberth_efficiency_low_v_inf() {
836        // At low v_inf (e.g. 5 km/s), Oberth effect should be very significant
837        let eff = oberth_efficiency(
838            mu::JUPITER,
839            Km(71_492.0), // 1 RJ
840            KmPerSec(5.0),
841            KmPerSec(1.0),
842        );
843        // At Jupiter surface with v_inf=5: v_peri = sqrt(25 + 2*126.7e6/71492) ≈ 59.5 km/s
844        // After 1 km/s burn: v_peri=60.5 → v_inf_after = sqrt(60.5² - 59.5²·(25/25)) ≈ ...
845        // Should be significant: >> 10%
846        assert!(
847            eff > 0.10,
848            "Oberth efficiency at low v_inf should be large, got {}",
849            eff
850        );
851    }
852
853    // ── elements_to_state_vector tests ─────────────────────────────────
854
855    #[test]
856    fn test_elements_to_state_circular_equatorial() {
857        // Circular equatorial orbit: e=0, i=0, Ω=0, ω=0, ν=0
858        // Position should be along x-axis, velocity along y-axis
859        let e = Eccentricity::elliptical(0.0).unwrap();
860        let elements = OrbitalElements {
861            semi_major_axis: Km(6778.0), // ~400 km LEO
862            eccentricity: e,
863            inclination: Radians(0.0),
864            raan: Radians(0.0),
865            arg_periapsis: Radians(0.0),
866            true_anomaly: Radians(0.0),
867        };
868
869        let sv = elements_to_state_vector(mu::EARTH, &elements);
870
871        // Position should be at (6778, 0, 0)
872        assert!(
873            (sv.position.x.value() - 6778.0).abs() < 1e-6,
874            "x = {}, expected 6778",
875            sv.position.x.value()
876        );
877        assert!(
878            sv.position.y.value().abs() < 1e-6,
879            "y = {}, expected 0",
880            sv.position.y.value()
881        );
882        assert!(
883            sv.position.z.value().abs() < 1e-6,
884            "z = {}, expected 0",
885            sv.position.z.value()
886        );
887
888        // Velocity should be (0, v_circular, 0)
889        let v_circ = (mu::EARTH.value() / 6778.0).sqrt();
890        assert!(
891            sv.velocity.x.value().abs() < 1e-6,
892            "vx = {}, expected 0",
893            sv.velocity.x.value()
894        );
895        assert!(
896            (sv.velocity.y.value() - v_circ).abs() < 1e-4,
897            "vy = {}, expected {}",
898            sv.velocity.y.value(),
899            v_circ
900        );
901        assert!(
902            sv.velocity.z.value().abs() < 1e-6,
903            "vz = {}, expected 0",
904            sv.velocity.z.value()
905        );
906    }
907
908    #[test]
909    fn test_elements_to_state_inclined_orbit() {
910        // 90° inclination: orbit is perpendicular to equatorial plane
911        let e = Eccentricity::elliptical(0.0).unwrap();
912        let elements = OrbitalElements {
913            semi_major_axis: Km(7000.0),
914            eccentricity: e,
915            inclination: Radians(std::f64::consts::FRAC_PI_2), // 90°
916            raan: Radians(0.0),
917            arg_periapsis: Radians(0.0),
918            true_anomaly: Radians(std::f64::consts::FRAC_PI_2), // 90° true anomaly
919        };
920
921        let sv = elements_to_state_vector(mu::EARTH, &elements);
922
923        // At ν=90° in a polar orbit (i=90°, Ω=0, ω=0):
924        // Position should be at (0, 0, r) — over the pole
925        assert!(
926            sv.position.x.value().abs() < 1e-4,
927            "x = {}, expected ~0",
928            sv.position.x.value()
929        );
930        assert!(
931            sv.position.y.value().abs() < 1e-4,
932            "y = {}, expected ~0",
933            sv.position.y.value()
934        );
935        assert!(
936            (sv.position.z.value() - 7000.0).abs() < 1e-4,
937            "z = {}, expected ~7000",
938            sv.position.z.value()
939        );
940    }
941
942    #[test]
943    fn test_elements_to_state_energy_conservation() {
944        // Verify that specific energy computed from state vector matches
945        // specific energy computed from semi-major axis
946        let e = Eccentricity::elliptical(0.1).unwrap();
947        let elements = OrbitalElements {
948            semi_major_axis: Km(10000.0),
949            eccentricity: e,
950            inclination: Radians(0.5),
951            raan: Radians(1.0),
952            arg_periapsis: Radians(2.0),
953            true_anomaly: Radians(1.5),
954        };
955
956        let sv = elements_to_state_vector(mu::EARTH, &elements);
957        let r = sv.radius().value();
958        let v = sv.speed().value();
959
960        // Specific energy from state vector: ε = v²/2 - μ/r
961        let eps_sv = v * v / 2.0 - mu::EARTH.value() / r;
962        // Specific energy from elements: ε = -μ/(2a)
963        let eps_elem = specific_energy(mu::EARTH, elements.semi_major_axis);
964
965        assert!(
966            (eps_sv - eps_elem).abs() / eps_elem.abs() < 1e-10,
967            "energy mismatch: state_vector = {:.6}, elements = {:.6}",
968            eps_sv,
969            eps_elem
970        );
971    }
972
973    #[test]
974    fn test_elements_to_state_angular_momentum() {
975        // Angular momentum magnitude should match h = sqrt(μ * p)
976        let e_val = 0.2;
977        let e = Eccentricity::elliptical(e_val).unwrap();
978        let a = Km(8000.0);
979        let elements = OrbitalElements {
980            semi_major_axis: a,
981            eccentricity: e,
982            inclination: Radians(0.3),
983            raan: Radians(0.7),
984            arg_periapsis: Radians(1.2),
985            true_anomaly: Radians(0.8),
986        };
987
988        let sv = elements_to_state_vector(mu::EARTH, &elements);
989
990        // h = |r × v|
991        let h_vec = sv.position.cross_raw_with(sv.velocity);
992        let h_magnitude = h_vec.norm_raw();
993
994        // Expected: h = sqrt(μ * a * (1 - e²))
995        let h_expected = specific_angular_momentum(mu::EARTH, a, e);
996
997        assert!(
998            (h_magnitude - h_expected).abs() / h_expected < 1e-10,
999            "angular momentum: computed = {:.6}, expected = {:.6}",
1000            h_magnitude,
1001            h_expected
1002        );
1003    }
1004
1005    // ── plane_change_dv tests ──────────────────────────────────────────
1006
1007    #[test]
1008    fn test_plane_change_dv_zero() {
1009        let dv = plane_change_dv(KmPerSec(10.0), Radians(0.0));
1010        assert!(dv.value().abs() < 1e-15, "zero plane change = zero ΔV");
1011    }
1012
1013    #[test]
1014    fn test_plane_change_dv_90_degrees() {
1015        // 90° plane change at 7 km/s: ΔV = 2 * 7 * sin(45°) ≈ 9.899 km/s
1016        let dv = plane_change_dv(KmPerSec(7.0), Radians(std::f64::consts::FRAC_PI_2));
1017        let expected = 2.0 * 7.0 * (std::f64::consts::FRAC_PI_4).sin();
1018        assert!(
1019            (dv.value() - expected).abs() < 1e-10,
1020            "90° plane change: {} km/s, expected {} km/s",
1021            dv.value(),
1022            expected
1023        );
1024    }
1025
1026    #[test]
1027    fn test_plane_change_dv_small_angle() {
1028        // Small angle approximation: ΔV ≈ v * Δi for small Δi
1029        let v = KmPerSec(30.0);
1030        let di = Radians(0.01); // ~0.57°
1031        let dv = plane_change_dv(v, di);
1032        // For small angle: 2*v*sin(Δi/2) ≈ v*Δi
1033        let approx = v.value() * di.value();
1034        assert!(
1035            (dv.value() - approx).abs() / approx < 0.001,
1036            "small angle: exact = {}, approx = {}",
1037            dv.value(),
1038            approx
1039        );
1040    }
1041}