solar_line_core/
flyby.rs

1/// Patched-conic gravity assist model for SOLAR LINE 考察.
2///
3/// Implements a multi-body flyby model by switching between heliocentric
4/// and planetocentric frames at the sphere of influence (SOI) boundary.
5///
6/// # Approach
7///
8/// 1. Propagate heliocentric trajectory until spacecraft enters planet SOI
9/// 2. Transform to planetocentric frame
10/// 3. Compute hyperbolic flyby (analytic or propagated)
11/// 4. Transform back to heliocentric frame at SOI exit
12/// 5. Continue heliocentric propagation
13///
14/// This is the standard patched-conic approximation used in mission design.
15use crate::units::{Km, KmPerSec, Mu};
16
17/// Sphere of influence radius (km) using Hill's approximation.
18///
19/// r_SOI = a_planet * (m_planet / m_sun)^(2/5)
20///
21/// where a_planet is the semi-major axis of the planet's orbit.
22pub fn soi_radius(planet_orbit_radius: Km, mu_planet: Mu, mu_sun: Mu) -> Km {
23    let a = planet_orbit_radius.value();
24    let ratio = mu_planet.value() / mu_sun.value();
25    Km(a * ratio.powf(0.4))
26}
27
28/// Hyperbolic flyby geometry.
29///
30/// Given the approach conditions (v_infinity vector and periapsis distance),
31/// computes the flyby turn angle, periapsis velocity, and exit v_infinity vector.
32#[derive(Debug, Clone, Copy)]
33pub struct FlybyResult {
34    /// Turn angle (radians) — the angle between incoming and outgoing v_infinity vectors
35    pub turn_angle_rad: f64,
36    /// Speed at periapsis (km/s)
37    pub v_periapsis: f64,
38    /// Outgoing v_infinity magnitude (km/s)
39    pub v_inf_out: f64,
40    /// Outgoing v_infinity direction (unit vector in planetocentric frame)
41    pub v_inf_out_dir: [f64; 3],
42}
43
44/// Compute an unpowered (gravity-only) hyperbolic flyby.
45///
46/// The incoming v_infinity is rotated by the turn angle in the plane defined
47/// by the v_infinity vector and the periapsis direction.
48///
49/// # Arguments
50/// * `mu_planet` — Planet gravitational parameter (km³/s²)
51/// * `v_inf_in` — Incoming v_infinity vector in planetocentric frame [km/s; 3]
52/// * `r_periapsis` — Closest approach distance from planet center (km)
53/// * `flyby_plane_normal` — Normal to the flyby plane (unit vector). The turn
54///   is applied in the plane perpendicular to this normal. For a 2D (ecliptic)
55///   flyby, use [0, 0, 1].
56pub fn unpowered_flyby(
57    mu_planet: Mu,
58    v_inf_in: [f64; 3],
59    r_periapsis: Km,
60    flyby_plane_normal: [f64; 3],
61) -> FlybyResult {
62    let mu = mu_planet.value();
63    let r_p = r_periapsis.value();
64
65    // v_inf magnitude
66    let v_inf = (v_inf_in[0].powi(2) + v_inf_in[1].powi(2) + v_inf_in[2].powi(2)).sqrt();
67
68    // Semi-major axis of hyperbola (negative for hyperbolic)
69    let a = -mu / (v_inf * v_inf);
70
71    // Eccentricity
72    let e = 1.0 - r_p / a;
73
74    // Turn angle: δ = 2 * arcsin(1/e)
75    let turn_angle = 2.0 * (1.0 / e).asin();
76
77    // Speed at periapsis
78    let v_peri = (v_inf * v_inf + 2.0 * mu / r_p).sqrt();
79
80    // Rotate incoming v_inf by turn_angle around the flyby plane normal
81    // Using Rodrigues' rotation formula
82    let v_inf_out_dir = rodrigues_rotate(v_inf_in, flyby_plane_normal, turn_angle);
83    let v_inf_out_mag = v_inf; // conserved for unpowered flyby
84
85    // Normalize the direction
86    let norm =
87        (v_inf_out_dir[0].powi(2) + v_inf_out_dir[1].powi(2) + v_inf_out_dir[2].powi(2)).sqrt();
88
89    FlybyResult {
90        turn_angle_rad: turn_angle,
91        v_periapsis: v_peri,
92        v_inf_out: v_inf_out_mag,
93        v_inf_out_dir: [
94            v_inf_out_dir[0] / norm,
95            v_inf_out_dir[1] / norm,
96            v_inf_out_dir[2] / norm,
97        ],
98    }
99}
100
101/// Compute a powered flyby (burn at periapsis — Oberth effect).
102///
103/// Same as unpowered flyby but with an added prograde burn at periapsis.
104/// The burn increases the periapsis speed, changing both the exit v_inf
105/// magnitude and the turn angle.
106pub fn powered_flyby(
107    mu_planet: Mu,
108    v_inf_in: [f64; 3],
109    r_periapsis: Km,
110    burn_dv: KmPerSec,
111    flyby_plane_normal: [f64; 3],
112) -> FlybyResult {
113    let mu = mu_planet.value();
114    let r_p = r_periapsis.value();
115    let dv = burn_dv.value();
116
117    let v_inf_mag = (v_inf_in[0].powi(2) + v_inf_in[1].powi(2) + v_inf_in[2].powi(2)).sqrt();
118
119    // Semi-major axis (incoming hyperbola)
120    let a_in = -mu / (v_inf_mag * v_inf_mag);
121    let e_in = 1.0 - r_p / a_in;
122
123    // Incoming turn half-angle
124    let half_turn_in = (1.0 / e_in).asin();
125
126    // Periapsis speed (incoming)
127    let v_peri_in = (v_inf_mag * v_inf_mag + 2.0 * mu / r_p).sqrt();
128
129    // Periapsis speed after prograde burn
130    let v_peri_out = v_peri_in + dv;
131
132    // Exit v_inf
133    let v_inf_out_sq = v_peri_out * v_peri_out - 2.0 * mu / r_p;
134    let v_inf_out = if v_inf_out_sq > 0.0 {
135        v_inf_out_sq.sqrt()
136    } else {
137        0.0 // Captured — burn was too small to escape
138    };
139
140    // Outgoing hyperbola eccentricity and half-turn
141    let a_out = if v_inf_out > 1e-10 {
142        -mu / (v_inf_out * v_inf_out)
143    } else {
144        -1e20 // Effectively circular capture
145    };
146    let e_out = 1.0 - r_p / a_out;
147    let half_turn_out = if e_out > 1.0 {
148        (1.0 / e_out).asin()
149    } else {
150        std::f64::consts::FRAC_PI_2
151    };
152
153    // Total turn angle: sum of incoming and outgoing half-turns
154    let turn_angle = half_turn_in + half_turn_out;
155
156    // Rotate incoming v_inf direction by total turn angle
157    let v_inf_out_dir = rodrigues_rotate(v_inf_in, flyby_plane_normal, turn_angle);
158    let norm =
159        (v_inf_out_dir[0].powi(2) + v_inf_out_dir[1].powi(2) + v_inf_out_dir[2].powi(2)).sqrt();
160
161    FlybyResult {
162        turn_angle_rad: turn_angle,
163        v_periapsis: v_peri_out,
164        v_inf_out,
165        v_inf_out_dir: [
166            v_inf_out_dir[0] / norm,
167            v_inf_out_dir[1] / norm,
168            v_inf_out_dir[2] / norm,
169        ],
170    }
171}
172
173/// Rodrigues' rotation formula: rotate vector v around unit axis k by angle θ.
174fn rodrigues_rotate(v: [f64; 3], k: [f64; 3], theta: f64) -> [f64; 3] {
175    let cos_t = theta.cos();
176    let sin_t = theta.sin();
177
178    // k dot v
179    let k_dot_v = k[0] * v[0] + k[1] * v[1] + k[2] * v[2];
180
181    // k cross v
182    let k_cross_v = [
183        k[1] * v[2] - k[2] * v[1],
184        k[2] * v[0] - k[0] * v[2],
185        k[0] * v[1] - k[1] * v[0],
186    ];
187
188    [
189        v[0] * cos_t + k_cross_v[0] * sin_t + k[0] * k_dot_v * (1.0 - cos_t),
190        v[1] * cos_t + k_cross_v[1] * sin_t + k[1] * k_dot_v * (1.0 - cos_t),
191        v[2] * cos_t + k_cross_v[2] * sin_t + k[2] * k_dot_v * (1.0 - cos_t),
192    ]
193}
194
195/// Heliocentric velocity of the outgoing spacecraft after a flyby.
196///
197/// v_helio_out = v_planet + v_inf_out
198///
199/// where v_inf_out = |v_inf_out| * v_inf_out_dir from the flyby result.
200pub fn heliocentric_exit_velocity(planet_velocity: [f64; 3], flyby: &FlybyResult) -> [f64; 3] {
201    [
202        planet_velocity[0] + flyby.v_inf_out * flyby.v_inf_out_dir[0],
203        planet_velocity[1] + flyby.v_inf_out * flyby.v_inf_out_dir[1],
204        planet_velocity[2] + flyby.v_inf_out * flyby.v_inf_out_dir[2],
205    ]
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::constants::{mu, orbit_radius};
212
213    #[test]
214    fn test_soi_radius_jupiter() {
215        // Jupiter SOI ≈ 48.2 million km (well-known value)
216        let r_soi = soi_radius(orbit_radius::JUPITER, mu::JUPITER, mu::SUN);
217        let expected_million_km = 48.2;
218        let actual_million_km = r_soi.value() / 1e6;
219        assert!(
220            (actual_million_km - expected_million_km).abs() < 2.0,
221            "Jupiter SOI: {:.1} Mkm (expected ~{:.1} Mkm)",
222            actual_million_km,
223            expected_million_km
224        );
225    }
226
227    #[test]
228    fn test_soi_radius_earth() {
229        // Earth SOI ≈ 0.929 million km (well-known value)
230        let r_soi = soi_radius(orbit_radius::EARTH, mu::EARTH, mu::SUN);
231        let expected_million_km = 0.929;
232        let actual_million_km = r_soi.value() / 1e6;
233        assert!(
234            (actual_million_km - expected_million_km).abs() < 0.05,
235            "Earth SOI: {:.3} Mkm (expected ~{:.3} Mkm)",
236            actual_million_km,
237            expected_million_km
238        );
239    }
240
241    #[test]
242    fn test_unpowered_flyby_conserves_vinf() {
243        // Unpowered flyby should conserve |v_inf|
244        let v_inf_in = [10.0, 0.0, 0.0]; // 10 km/s approach
245        let r_peri = Km(200_000.0); // 200,000 km periapsis (above Jupiter surface)
246        let normal = [0.0, 0.0, 1.0]; // ecliptic flyby
247
248        let result = unpowered_flyby(mu::JUPITER, v_inf_in, r_peri, normal);
249
250        // v_inf should be conserved
251        let v_inf_in_mag = 10.0;
252        assert!(
253            (result.v_inf_out - v_inf_in_mag).abs() < 1e-10,
254            "Unpowered flyby should conserve v_inf: {:.6} vs {:.6}",
255            result.v_inf_out,
256            v_inf_in_mag
257        );
258
259        // Turn angle should be positive
260        assert!(result.turn_angle_rad > 0.0);
261        assert!(result.turn_angle_rad < std::f64::consts::PI);
262    }
263
264    #[test]
265    fn test_unpowered_flyby_turn_angle() {
266        // Closer periapsis → larger turn angle
267        let v_inf_in = [5.0, 0.0, 0.0];
268        let normal = [0.0, 0.0, 1.0];
269
270        let result_far = unpowered_flyby(mu::JUPITER, v_inf_in, Km(500_000.0), normal);
271        let result_close = unpowered_flyby(mu::JUPITER, v_inf_in, Km(100_000.0), normal);
272
273        assert!(
274            result_close.turn_angle_rad > result_far.turn_angle_rad,
275            "Closer periapsis should give larger turn: {:.4} vs {:.4} rad",
276            result_close.turn_angle_rad,
277            result_far.turn_angle_rad
278        );
279    }
280
281    #[test]
282    fn test_powered_flyby_oberth_effect() {
283        // Powered flyby with Oberth effect: a burn at deep periapsis should yield
284        // a v_inf gain larger than the burn dv itself (Oberth amplification).
285        let v_inf_in = [10.0, 0.0, 0.0];
286        let r_peri = Km(200_000.0); // Deep in Jupiter's gravity well
287        let burn_dv = KmPerSec(1.0); // 1 km/s prograde burn at periapsis
288        let normal = [0.0, 0.0, 1.0];
289
290        let result = powered_flyby(mu::JUPITER, v_inf_in, r_peri, burn_dv, normal);
291
292        // Exit v_inf should be > input v_inf (energy added by burn)
293        assert!(
294            result.v_inf_out > 10.0,
295            "Powered flyby should increase v_inf: {:.4} km/s",
296            result.v_inf_out
297        );
298
299        // Oberth amplification: the v_inf gain (v_inf_out - v_inf_in) should exceed
300        // the raw burn_dv because the burn was performed deep in the gravity well.
301        let v_inf_gain = result.v_inf_out - 10.0;
302        assert!(
303            v_inf_gain > 1.0,
304            "Oberth should amplify: v_inf gain = {:.4} km/s (burn was 1.0 km/s)",
305            v_inf_gain
306        );
307    }
308
309    #[test]
310    fn test_ep05_jupiter_flyby_scenario() {
311        // EP05: Kestrel approaches Jupiter at ~1500 km/s (v_inf)
312        // Performs powered flyby for ~3% efficiency gain
313        let v_inf_in = [1500.0, 0.0, 0.0]; // 1500 km/s approach
314        let r_peri = Km(100_000.0); // Close flyby (Jupiter radius ~71,492 km)
315        let normal = [0.0, 0.0, 1.0];
316
317        // Unpowered first
318        let unpowered = unpowered_flyby(mu::JUPITER, v_inf_in, r_peri, normal);
319
320        // At 1500 km/s, Jupiter gravity barely bends the trajectory
321        // Turn angle should be very small
322        assert!(
323            unpowered.turn_angle_rad < 0.01, // < 0.6 degrees
324            "At 1500 km/s, Jupiter turn should be tiny: {:.4} rad ({:.2}°)",
325            unpowered.turn_angle_rad,
326            unpowered.turn_angle_rad.to_degrees()
327        );
328
329        // Powered flyby with modest burn
330        let burn_dv = KmPerSec(10.0); // 10 km/s burn at periapsis
331        let powered = powered_flyby(mu::JUPITER, v_inf_in, r_peri, burn_dv, normal);
332
333        // Should gain more than 10 km/s in exit v_inf (Oberth amplification)
334        let oberth_gain = powered.v_inf_out - 1500.0;
335        assert!(
336            oberth_gain > 10.0,
337            "Powered flyby should amplify the burn: exit gain = {:.2} km/s (burn was 10 km/s)",
338            oberth_gain
339        );
340    }
341
342    #[test]
343    fn test_rodrigues_rotation_90_degrees() {
344        // Rotate [1,0,0] by 90° around Z axis → [0,1,0]
345        let v = [1.0, 0.0, 0.0];
346        let k = [0.0, 0.0, 1.0];
347        let result = rodrigues_rotate(v, k, std::f64::consts::FRAC_PI_2);
348
349        assert!((result[0] - 0.0).abs() < 1e-10);
350        assert!((result[1] - 1.0).abs() < 1e-10);
351        assert!((result[2] - 0.0).abs() < 1e-10);
352    }
353
354    #[test]
355    fn test_heliocentric_exit_velocity() {
356        let planet_vel = [0.0, 13.07, 0.0]; // Jupiter orbital velocity ~13.07 km/s
357        let flyby = FlybyResult {
358            turn_angle_rad: 0.5,
359            v_periapsis: 15.0,
360            v_inf_out: 10.0,
361            v_inf_out_dir: [0.877, 0.479, 0.0], // Turned by ~29°
362        };
363
364        let v_helio = heliocentric_exit_velocity(planet_vel, &flyby);
365
366        // Should be planet_vel + v_inf_out * dir
367        let expected_x = 0.0 + 10.0 * 0.877;
368        let expected_y = 13.07 + 10.0 * 0.479;
369        assert!((v_helio[0] - expected_x).abs() < 0.01);
370        assert!((v_helio[1] - expected_y).abs() < 0.01);
371    }
372
373    // ===== Oracle tests: cross-validation against published mission data =====
374
375    #[test]
376    fn oracle_soi_radius_saturn() {
377        // Saturn SOI ≈ 54.5 million km (well-known reference value)
378        let r_soi = soi_radius(orbit_radius::SATURN, mu::SATURN, mu::SUN);
379        let actual_mkm = r_soi.value() / 1e6;
380        // Our Hill-approximation formula gives ~54.81 Mkm
381        assert!(
382            (actual_mkm - 54.5).abs() < 1.5,
383            "Saturn SOI: {:.2} Mkm (expected ~54.5 Mkm)",
384            actual_mkm
385        );
386    }
387
388    #[test]
389    fn oracle_soi_radius_uranus() {
390        // Uranus SOI ≈ 51.8 million km (well-known reference value)
391        let r_soi = soi_radius(orbit_radius::URANUS, mu::URANUS, mu::SUN);
392        let actual_mkm = r_soi.value() / 1e6;
393        assert!(
394            (actual_mkm - 51.8).abs() < 1.5,
395            "Uranus SOI: {:.2} Mkm (expected ~51.8 Mkm)",
396            actual_mkm
397        );
398    }
399
400    #[test]
401    fn oracle_voyager1_jupiter_flyby() {
402        // Voyager 1 Jupiter flyby (March 5, 1979)
403        // Source: NASA PDS — closest approach 348,890 km from center
404        // v_inf ≈ 10.48 km/s (commonly cited in orbital mechanics textbooks)
405        //
406        // Expected (analytic): turn angle ≈ 100.3°, v_periapsis ≈ 28.91 km/s
407        let v_inf_in = [10.48, 0.0, 0.0];
408        let r_peri = Km(348_890.0);
409        let normal = [0.0, 0.0, 1.0];
410
411        let result = unpowered_flyby(mu::JUPITER, v_inf_in, r_peri, normal);
412
413        // Turn angle ≈ 100.3° (1.751 rad) — large deflection from deep gravity well
414        let turn_deg = result.turn_angle_rad.to_degrees();
415        assert!(
416            (turn_deg - 100.3).abs() < 1.0,
417            "Voyager 1 Jupiter turn angle: {:.2}° (expected ~100.3°)",
418            turn_deg
419        );
420
421        // v_inf conserved (unpowered)
422        assert!(
423            (result.v_inf_out - 10.48).abs() < 1e-10,
424            "v_inf should be conserved: {:.6}",
425            result.v_inf_out
426        );
427
428        // Periapsis speed ≈ 28.91 km/s
429        assert!(
430            (result.v_periapsis - 28.91).abs() < 0.5,
431            "Voyager 1 v_periapsis: {:.2} km/s (expected ~28.91)",
432            result.v_periapsis
433        );
434    }
435
436    #[test]
437    fn oracle_voyager1_saturn_flyby_textbook() {
438        // Well-known textbook problem (Brainly/Chegg):
439        // Voyager 1 Saturn flyby with r_p = 124,000 km, v_inf = 7.51 km/s
440        // μ_Saturn = 3.793e7 km³/s²
441        //
442        // Expected (analytic): e ≈ 1.184, turn angle ≈ 115.2°, v_peri ≈ 25.85 km/s
443        let v_inf_in = [7.51, 0.0, 0.0];
444        let r_peri = Km(124_000.0);
445        let normal = [0.0, 0.0, 1.0];
446
447        let result = unpowered_flyby(mu::SATURN, v_inf_in, r_peri, normal);
448
449        // Turn angle ≈ 115.2° — very strong deflection
450        let turn_deg = result.turn_angle_rad.to_degrees();
451        assert!(
452            (turn_deg - 115.2).abs() < 1.0,
453            "Voyager 1 Saturn turn angle: {:.2}° (expected ~115.2°)",
454            turn_deg
455        );
456
457        // Periapsis speed ≈ 25.85 km/s
458        assert!(
459            (result.v_periapsis - 25.85).abs() < 0.5,
460            "Voyager 1 Saturn v_periapsis: {:.2} km/s (expected ~25.85)",
461            result.v_periapsis
462        );
463
464        // v_inf conserved
465        assert!(
466            (result.v_inf_out - 7.51).abs() < 1e-10,
467            "v_inf conserved: {:.6}",
468            result.v_inf_out
469        );
470    }
471
472    #[test]
473    fn oracle_voyager2_uranus_flyby() {
474        // Voyager 2 Uranus flyby (January 24, 1986)
475        // Source: NASA PDS — closest approach 107,000 km from center
476        // v_inf ≈ 5.4 km/s (approximate from trajectory data)
477        //
478        // Expected (analytic): turn angle ≈ 81.1°, v_peri ≈ 11.72 km/s
479        let v_inf_in = [5.4, 0.0, 0.0];
480        let r_peri = Km(107_000.0);
481        let normal = [0.0, 0.0, 1.0];
482
483        let result = unpowered_flyby(mu::URANUS, v_inf_in, r_peri, normal);
484
485        // Turn angle ≈ 81.1°
486        let turn_deg = result.turn_angle_rad.to_degrees();
487        assert!(
488            (turn_deg - 81.1).abs() < 2.0,
489            "Voyager 2 Uranus turn angle: {:.2}° (expected ~81.1°)",
490            turn_deg
491        );
492
493        // Periapsis speed ≈ 11.72 km/s
494        assert!(
495            (result.v_periapsis - 11.72).abs() < 0.5,
496            "Voyager 2 Uranus v_periapsis: {:.2} km/s (expected ~11.72)",
497            result.v_periapsis
498        );
499    }
500
501    // ===== Edge case tests =====
502
503    #[test]
504    fn edge_powered_flyby_zero_dv_equals_unpowered() {
505        // A powered flyby with zero burn should produce identical results
506        // to an unpowered flyby.
507        let v_inf_in = [8.0, 3.0, 0.0];
508        let r_peri = Km(200_000.0);
509        let normal = [0.0, 0.0, 1.0];
510
511        let unpowered = unpowered_flyby(mu::JUPITER, v_inf_in, r_peri, normal);
512        let powered = powered_flyby(mu::JUPITER, v_inf_in, r_peri, KmPerSec(0.0), normal);
513
514        assert!(
515            (powered.v_inf_out - unpowered.v_inf_out).abs() < 1e-10,
516            "Zero-burn powered should match unpowered v_inf: {:.6} vs {:.6}",
517            powered.v_inf_out,
518            unpowered.v_inf_out
519        );
520        assert!(
521            (powered.turn_angle_rad - unpowered.turn_angle_rad).abs() < 1e-10,
522            "Zero-burn powered should match unpowered turn: {:.6} vs {:.6}",
523            powered.turn_angle_rad,
524            unpowered.turn_angle_rad
525        );
526        assert!(
527            (powered.v_periapsis - unpowered.v_periapsis).abs() < 1e-10,
528            "Zero-burn powered should match unpowered v_peri: {:.6} vs {:.6}",
529            powered.v_periapsis,
530            unpowered.v_periapsis
531        );
532    }
533
534    #[test]
535    fn edge_near_capture_powered_flyby() {
536        // A burn at periapsis that is too small to escape → v_inf_out = 0 (capture)
537        // Use a slow approach with a retrograde burn (negative effective dv scenario:
538        // approach slowly, burn doesn't add enough to escape)
539        //
540        // v_inf = 1 km/s approach to Jupiter at r_p = 200,000 km
541        // v_peri_in = sqrt(1 + 2*mu/r_p) = sqrt(1 + 1266.87) ≈ 35.6 km/s
542        // Escape speed at r_p = sqrt(2*mu/r_p) ≈ 35.58 km/s
543        // v_peri_in = 35.59 km/s (just barely hyperbolic)
544        //
545        // A "burn" of 0 km/s gives v_inf_out = v_inf_in = 1 (escapes)
546        // We can't easily create a capture scenario with prograde burns
547        // (they always add energy). Instead, test that near-escape condition
548        // produces a small but positive v_inf_out.
549        let v_inf_in = [0.1, 0.0, 0.0]; // Very slow approach (0.1 km/s)
550        let r_peri = Km(200_000.0);
551        let normal = [0.0, 0.0, 1.0];
552        let burn_dv = KmPerSec(0.0); // No burn
553
554        let result = powered_flyby(mu::JUPITER, v_inf_in, r_peri, burn_dv, normal);
555
556        // Should still escape with v_inf_out = 0.1 km/s (same as input)
557        assert!(
558            (result.v_inf_out - 0.1).abs() < 1e-8,
559            "Near-escape v_inf_out: {:.8} (expected 0.1)",
560            result.v_inf_out
561        );
562
563        // Turn angle should be close to π (nearly captured = strong deflection)
564        assert!(
565            result.turn_angle_rad > 2.5, // > 143°
566            "Near-capture should produce large turn angle: {:.4} rad ({:.1}°)",
567            result.turn_angle_rad,
568            result.turn_angle_rad.to_degrees()
569        );
570    }
571
572    #[test]
573    fn edge_retrograde_flyby_plane() {
574        // Flyby in the opposite plane (normal = [0,0,-1]) should produce
575        // the same turn angle magnitude but mirror the exit direction.
576        let v_inf_in = [10.0, 0.0, 0.0];
577        let r_peri = Km(200_000.0);
578
579        let prograde = unpowered_flyby(mu::JUPITER, v_inf_in, r_peri, [0.0, 0.0, 1.0]);
580        let retrograde = unpowered_flyby(mu::JUPITER, v_inf_in, r_peri, [0.0, 0.0, -1.0]);
581
582        // Same turn angle
583        assert!(
584            (prograde.turn_angle_rad - retrograde.turn_angle_rad).abs() < 1e-10,
585            "Turn angle should be the same: {:.6} vs {:.6}",
586            prograde.turn_angle_rad,
587            retrograde.turn_angle_rad
588        );
589
590        // Same v_inf_out magnitude
591        assert!(
592            (prograde.v_inf_out - retrograde.v_inf_out).abs() < 1e-10,
593            "v_inf magnitude should match: {:.6} vs {:.6}",
594            prograde.v_inf_out,
595            retrograde.v_inf_out
596        );
597
598        // Exit directions should be mirrored in Y (Z stays 0, X same)
599        assert!(
600            (prograde.v_inf_out_dir[0] - retrograde.v_inf_out_dir[0]).abs() < 1e-10,
601            "X component should match"
602        );
603        assert!(
604            (prograde.v_inf_out_dir[1] + retrograde.v_inf_out_dir[1]).abs() < 1e-10,
605            "Y component should be mirrored: {:.6} vs {:.6}",
606            prograde.v_inf_out_dir[1],
607            retrograde.v_inf_out_dir[1]
608        );
609    }
610
611    #[test]
612    fn edge_rodrigues_rotation_360_identity() {
613        // Rotating by 2π should return the original vector
614        let v = [3.0, 4.0, 5.0];
615        let k = [0.0, 0.0, 1.0];
616        let result = rodrigues_rotate(v, k, 2.0 * std::f64::consts::PI);
617
618        assert!(
619            (result[0] - v[0]).abs() < 1e-10,
620            "360° rotation X: {:.10} vs {:.10}",
621            result[0],
622            v[0]
623        );
624        assert!(
625            (result[1] - v[1]).abs() < 1e-10,
626            "360° rotation Y: {:.10} vs {:.10}",
627            result[1],
628            v[1]
629        );
630        assert!(
631            (result[2] - v[2]).abs() < 1e-10,
632            "360° rotation Z: {:.10} vs {:.10}",
633            result[2],
634            v[2]
635        );
636    }
637
638    #[test]
639    fn edge_rodrigues_rotation_180_degrees() {
640        // Rotate [1,0,0] by 180° around Z axis → [-1,0,0]
641        let v = [1.0, 0.0, 0.0];
642        let k = [0.0, 0.0, 1.0];
643        let result = rodrigues_rotate(v, k, std::f64::consts::PI);
644
645        assert!(
646            (result[0] - (-1.0)).abs() < 1e-10,
647            "180° rotation X: {:.10}",
648            result[0]
649        );
650        assert!(
651            result[1].abs() < 1e-10,
652            "180° rotation Y: {:.10}",
653            result[1]
654        );
655    }
656
657    #[test]
658    fn edge_powered_flyby_large_burn_amplification() {
659        // A large burn at deep periapsis should produce significant
660        // Oberth amplification: ΔE = v_peri × Δv is maximized when
661        // v_peri is large (deep in gravity well).
662        //
663        // Compare same burn at Jupiter (deep well) vs weaker planet
664        let v_inf_in = [5.0, 0.0, 0.0];
665        let r_peri = Km(100_000.0); // Close periapsis
666        let burn = KmPerSec(2.0);
667        let normal = [0.0, 0.0, 1.0];
668
669        let jupiter = powered_flyby(mu::JUPITER, v_inf_in, r_peri, burn, normal);
670        let saturn = powered_flyby(mu::SATURN, v_inf_in, r_peri, burn, normal);
671
672        // Jupiter has deeper gravity well → higher v_peri → more Oberth gain
673        let jupiter_gain = jupiter.v_inf_out - 5.0;
674        let saturn_gain = saturn.v_inf_out - 5.0;
675
676        assert!(
677            jupiter_gain > saturn_gain,
678            "Jupiter Oberth gain ({:.3} km/s) should exceed Saturn ({:.3} km/s)",
679            jupiter_gain,
680            saturn_gain
681        );
682
683        // Both should exceed the raw 2 km/s burn (Oberth amplification)
684        assert!(
685            jupiter_gain > 2.0,
686            "Jupiter gain should exceed raw burn: {:.3} > 2.0",
687            jupiter_gain
688        );
689    }
690
691    #[test]
692    fn edge_flyby_energy_conservation() {
693        // For an unpowered flyby, the specific orbital energy must be conserved:
694        // E = v_inf²/2 (which equals -μ/(2a), the vis-viva at infinity)
695        let v_inf_in = [8.0, 3.0, 0.0];
696        let v_inf_mag = (8.0_f64.powi(2) + 3.0_f64.powi(2)).sqrt();
697        let r_peri = Km(300_000.0);
698        let normal = [0.0, 0.0, 1.0];
699
700        let result = unpowered_flyby(mu::JUPITER, v_inf_in, r_peri, normal);
701
702        // Check energy conservation via v_inf conservation
703        assert!(
704            (result.v_inf_out - v_inf_mag).abs() < 1e-10,
705            "Energy conservation: v_inf_out {:.10} should equal v_inf_in {:.10}",
706            result.v_inf_out,
707            v_inf_mag
708        );
709
710        // Check that exit direction is a unit vector
711        let dir_mag = (result.v_inf_out_dir[0].powi(2)
712            + result.v_inf_out_dir[1].powi(2)
713            + result.v_inf_out_dir[2].powi(2))
714        .sqrt();
715        assert!(
716            (dir_mag - 1.0).abs() < 1e-10,
717            "Exit direction should be unit vector: |dir| = {:.10}",
718            dir_mag
719        );
720    }
721
722    #[test]
723    fn test_heliocentric_exit_retrograde_reduces_speed() {
724        // If the flyby deflects the spacecraft to exit in the opposite direction
725        // of the planet's motion, the heliocentric speed should be LESS than
726        // if the exit were prograde. This is the fundamental mechanism
727        // of gravity-assist braking (e.g., Cassini's Venus flybys).
728        let planet_vel = [0.0, 13.07, 0.0]; // Jupiter orbital velocity ~13.07 km/s
729
730        // Prograde exit: v_inf aligned with planet velocity → speeds up
731        let prograde_flyby = FlybyResult {
732            turn_angle_rad: 1.0,
733            v_periapsis: 30.0,
734            v_inf_out: 10.0,
735            v_inf_out_dir: [0.0, 1.0, 0.0], // Same direction as planet
736        };
737        let v_pro = heliocentric_exit_velocity(planet_vel, &prograde_flyby);
738        let speed_pro = (v_pro[0].powi(2) + v_pro[1].powi(2) + v_pro[2].powi(2)).sqrt();
739
740        // Retrograde exit: v_inf opposite to planet velocity → slows down
741        let retrograde_flyby = FlybyResult {
742            turn_angle_rad: 1.0,
743            v_periapsis: 30.0,
744            v_inf_out: 10.0,
745            v_inf_out_dir: [0.0, -1.0, 0.0], // Opposite to planet motion
746        };
747        let v_retro = heliocentric_exit_velocity(planet_vel, &retrograde_flyby);
748        let speed_retro = (v_retro[0].powi(2) + v_retro[1].powi(2) + v_retro[2].powi(2)).sqrt();
749
750        // Prograde exit = planet + v_inf = 13.07 + 10 = 23.07 km/s
751        assert!(
752            (speed_pro - 23.07).abs() < 0.01,
753            "Prograde heliocentric speed: {:.2} km/s (expected 23.07)",
754            speed_pro
755        );
756        // Retrograde exit = planet - v_inf = 13.07 - 10 = 3.07 km/s
757        assert!(
758            (speed_retro - 3.07).abs() < 0.01,
759            "Retrograde heliocentric speed: {:.2} km/s (expected 3.07)",
760            speed_retro
761        );
762        assert!(
763            speed_pro > speed_retro,
764            "Prograde exit should be faster than retrograde: {:.2} vs {:.2}",
765            speed_pro,
766            speed_retro
767        );
768    }
769
770    #[test]
771    fn edge_powered_flyby_zero_burn_matches_unpowered_direction() {
772        // Extend existing zero-burn test: also verify exit DIRECTION vector matches
773        let v_inf_in = [8.0, 3.0, 0.0];
774        let r_peri = Km(200_000.0);
775        let normal = [0.0, 0.0, 1.0];
776
777        let unpowered = unpowered_flyby(mu::JUPITER, v_inf_in, r_peri, normal);
778        let powered = powered_flyby(mu::JUPITER, v_inf_in, r_peri, KmPerSec(0.0), normal);
779
780        for i in 0..3 {
781            assert!(
782                (powered.v_inf_out_dir[i] - unpowered.v_inf_out_dir[i]).abs() < 1e-10,
783                "Zero-burn exit direction[{}] should match: {:.10} vs {:.10}",
784                i,
785                powered.v_inf_out_dir[i],
786                unpowered.v_inf_out_dir[i]
787            );
788        }
789    }
790
791    #[test]
792    fn edge_rodrigues_rotation_around_x_axis() {
793        // All existing tests rotate around Z. Test rotation around X axis.
794        // Rotate [0,1,0] by 90° around X axis → [0,0,1]
795        let v = [0.0, 1.0, 0.0];
796        let k = [1.0, 0.0, 0.0]; // X axis
797        let result = rodrigues_rotate(v, k, std::f64::consts::FRAC_PI_2);
798
799        assert!(
800            result[0].abs() < 1e-10,
801            "X rotation: X component should be 0, got {:.10}",
802            result[0]
803        );
804        assert!(
805            result[1].abs() < 1e-10,
806            "X rotation: Y should → 0, got {:.10}",
807            result[1]
808        );
809        assert!(
810            (result[2] - 1.0).abs() < 1e-10,
811            "X rotation: Z should → 1, got {:.10}",
812            result[2]
813        );
814    }
815
816    #[test]
817    fn edge_flyby_with_inclined_normal() {
818        // Test a flyby with a non-standard normal (inclined flyby plane)
819        // Normal = [0, 1, 0] means rotation in the X-Z plane
820        let v_inf_in = [10.0, 0.0, 0.0];
821        let r_peri = Km(200_000.0);
822        let normal_z = [0.0, 0.0, 1.0]; // Standard ecliptic
823        let normal_y = [0.0, 1.0, 0.0]; // Inclined: rotation in X-Z plane
824
825        let result_z = unpowered_flyby(mu::JUPITER, v_inf_in, r_peri, normal_z);
826        let result_y = unpowered_flyby(mu::JUPITER, v_inf_in, r_peri, normal_y);
827
828        // Same turn angle (geometry doesn't change)
829        assert!(
830            (result_z.turn_angle_rad - result_y.turn_angle_rad).abs() < 1e-10,
831            "Turn angle independent of flyby plane: {:.6} vs {:.6}",
832            result_z.turn_angle_rad,
833            result_y.turn_angle_rad
834        );
835
836        // Same v_inf (energy conserved regardless)
837        assert!(
838            (result_z.v_inf_out - result_y.v_inf_out).abs() < 1e-10,
839            "v_inf independent of flyby plane"
840        );
841
842        // Z-normal rotates in X-Y plane (Z component stays 0)
843        assert!(
844            result_z.v_inf_out_dir[2].abs() < 1e-10,
845            "Z-normal flyby should have no Z exit: {:.6}",
846            result_z.v_inf_out_dir[2]
847        );
848
849        // Y-normal rotates in X-Z plane (Y component stays 0)
850        assert!(
851            result_y.v_inf_out_dir[1].abs() < 1e-10,
852            "Y-normal flyby should have no Y exit: {:.6}",
853            result_y.v_inf_out_dir[1]
854        );
855
856        // Y-normal should deflect into Z instead of Y
857        assert!(
858            result_y.v_inf_out_dir[2].abs() > 0.01,
859            "Y-normal flyby should have Z component: {:.6}",
860            result_y.v_inf_out_dir[2]
861        );
862    }
863
864    #[test]
865    fn edge_3d_approach_vector() {
866        // Test with a non-planar approach vector to verify 3D geometry
867        let v_inf_in = [5.0, 3.0, 2.0]; // 3D approach
868        let v_inf_mag = (25.0 + 9.0 + 4.0_f64).sqrt(); // √38 ≈ 6.164
869        let r_peri = Km(200_000.0);
870        let normal = [0.0, 0.0, 1.0];
871
872        let result = unpowered_flyby(mu::JUPITER, v_inf_in, r_peri, normal);
873
874        // v_inf conserved
875        assert!(
876            (result.v_inf_out - v_inf_mag).abs() < 1e-8,
877            "3D v_inf conserved: {:.6} vs {:.6}",
878            result.v_inf_out,
879            v_inf_mag
880        );
881
882        // Exit direction unit vector
883        let dir_mag = (result.v_inf_out_dir[0].powi(2)
884            + result.v_inf_out_dir[1].powi(2)
885            + result.v_inf_out_dir[2].powi(2))
886        .sqrt();
887        assert!(
888            (dir_mag - 1.0).abs() < 1e-10,
889            "3D exit dir unit: {:.10}",
890            dir_mag
891        );
892
893        // The rotation is around Z axis, so the Z component of v_inf_out_dir
894        // should stay proportional to the original (Z component is along the
895        // rotation axis, so k_dot_v * k is the parallel component)
896        // For Z rotation: parallel component = v_z * [0,0,1] is preserved
897        let expected_z = v_inf_in[2] / v_inf_mag; // Original z direction
898        assert!(
899            (result.v_inf_out_dir[2] - expected_z).abs() < 1e-10,
900            "Z component preserved under Z-axis rotation: {:.6} vs {:.6}",
901            result.v_inf_out_dir[2],
902            expected_z
903        );
904    }
905}