1use crate::units::{Km, KmPerSec, Mu};
16
17pub 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#[derive(Debug, Clone, Copy)]
33pub struct FlybyResult {
34 pub turn_angle_rad: f64,
36 pub v_periapsis: f64,
38 pub v_inf_out: f64,
40 pub v_inf_out_dir: [f64; 3],
42}
43
44pub 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 let v_inf = (v_inf_in[0].powi(2) + v_inf_in[1].powi(2) + v_inf_in[2].powi(2)).sqrt();
67
68 let a = -mu / (v_inf * v_inf);
70
71 let e = 1.0 - r_p / a;
73
74 let turn_angle = 2.0 * (1.0 / e).asin();
76
77 let v_peri = (v_inf * v_inf + 2.0 * mu / r_p).sqrt();
79
80 let v_inf_out_dir = rodrigues_rotate(v_inf_in, flyby_plane_normal, turn_angle);
83 let v_inf_out_mag = v_inf; 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
101pub 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 let a_in = -mu / (v_inf_mag * v_inf_mag);
121 let e_in = 1.0 - r_p / a_in;
122
123 let half_turn_in = (1.0 / e_in).asin();
125
126 let v_peri_in = (v_inf_mag * v_inf_mag + 2.0 * mu / r_p).sqrt();
128
129 let v_peri_out = v_peri_in + dv;
131
132 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 };
139
140 let a_out = if v_inf_out > 1e-10 {
142 -mu / (v_inf_out * v_inf_out)
143 } else {
144 -1e20 };
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 let turn_angle = half_turn_in + half_turn_out;
155
156 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
173fn 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 let k_dot_v = k[0] * v[0] + k[1] * v[1] + k[2] * v[2];
180
181 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
195pub 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 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 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 let v_inf_in = [10.0, 0.0, 0.0]; let r_peri = Km(200_000.0); let normal = [0.0, 0.0, 1.0]; let result = unpowered_flyby(mu::JUPITER, v_inf_in, r_peri, normal);
249
250 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 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 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 let v_inf_in = [10.0, 0.0, 0.0];
286 let r_peri = Km(200_000.0); let burn_dv = KmPerSec(1.0); 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 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 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 let v_inf_in = [1500.0, 0.0, 0.0]; let r_peri = Km(100_000.0); let normal = [0.0, 0.0, 1.0];
316
317 let unpowered = unpowered_flyby(mu::JUPITER, v_inf_in, r_peri, normal);
319
320 assert!(
323 unpowered.turn_angle_rad < 0.01, "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 let burn_dv = KmPerSec(10.0); let powered = powered_flyby(mu::JUPITER, v_inf_in, r_peri, burn_dv, normal);
332
333 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 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]; 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], };
363
364 let v_helio = heliocentric_exit_velocity(planet_vel, &flyby);
365
366 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 #[test]
376 fn oracle_soi_radius_saturn() {
377 let r_soi = soi_radius(orbit_radius::SATURN, mu::SATURN, mu::SUN);
379 let actual_mkm = r_soi.value() / 1e6;
380 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 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 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 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 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 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 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 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 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 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 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 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 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 #[test]
504 fn edge_powered_flyby_zero_dv_equals_unpowered() {
505 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 let v_inf_in = [0.1, 0.0, 0.0]; let r_peri = Km(200_000.0);
551 let normal = [0.0, 0.0, 1.0];
552 let burn_dv = KmPerSec(0.0); let result = powered_flyby(mu::JUPITER, v_inf_in, r_peri, burn_dv, normal);
555
556 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 assert!(
565 result.turn_angle_rad > 2.5, "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 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 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 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 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 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 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 let v_inf_in = [5.0, 0.0, 0.0];
665 let r_peri = Km(100_000.0); 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 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 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 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 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 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 let planet_vel = [0.0, 13.07, 0.0]; 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], };
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 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], };
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 assert!(
752 (speed_pro - 23.07).abs() < 0.01,
753 "Prograde heliocentric speed: {:.2} km/s (expected 23.07)",
754 speed_pro
755 );
756 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 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 let v = [0.0, 1.0, 0.0];
796 let k = [1.0, 0.0, 0.0]; 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 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]; let normal_y = [0.0, 1.0, 0.0]; 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 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 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 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 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 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 let v_inf_in = [5.0, 3.0, 2.0]; let v_inf_mag = (25.0 + 9.0 + 4.0_f64).sqrt(); 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 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 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 let expected_z = v_inf_in[2] / v_inf_mag; 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}