1use crate::ephemeris::{self, Planet};
9use crate::units::{Km, Radians};
10use crate::vec3::Vec3;
11use std::f64::consts::PI;
12
13pub const SATURN_OBLIQUITY_RAD: f64 = 26.73 * PI / 180.0;
17
18pub const SATURN_RING_INNER_KM: f64 = 66_900.0;
20
21pub const SATURN_RING_OUTER_KM: f64 = 140_180.0;
23
24pub const SATURN_RING_A_OUTER_KM: f64 = 136_775.0;
26
27pub const SATURN_EQUATORIAL_RADIUS_KM: f64 = 60_268.0;
29
30pub const ENCELADUS_ORBITAL_RADIUS_KM: f64 = 238_020.0;
34
35pub const URANUS_OBLIQUITY_RAD: f64 = 97.77 * PI / 180.0;
39
40pub const URANUS_EQUATORIAL_RADIUS_KM: f64 = 25_559.0;
42
43pub const TITANIA_ORBITAL_RADIUS_KM: f64 = 436_300.0;
45
46pub const URANUS_RING_OUTER_KM: f64 = 51_149.0;
48
49#[derive(Debug, Clone)]
51pub struct RingCrossingAnalysis {
52 pub crosses_ring_plane: bool,
54 pub crossing_distance_km: Option<f64>,
56 pub within_rings: bool,
58 pub z_offset_at_closest_km: f64,
60 pub approach_angle_to_ring_plane: Radians,
62}
63
64pub fn saturn_ring_plane_normal(jd: f64) -> Vec3<f64> {
74 let elem = ephemeris::mean_elements(Planet::Saturn);
75 let t = (jd - ephemeris::J2000_JD) / 36525.0;
76
77 let i_orb = (elem.i0 + elem.i_dot * t).to_radians();
78 let omega = (elem.omega0 + elem.omega_dot * t).to_radians();
79
80 let ra_rad = 40.589_f64.to_radians();
91 let dec_rad = 83.537_f64.to_radians();
92 let eps = 23.4393_f64.to_radians(); let eq_x = dec_rad.cos() * ra_rad.cos();
96 let eq_y = dec_rad.cos() * ra_rad.sin();
97 let eq_z = dec_rad.sin();
98
99 let ecl_x = eq_x;
101 let ecl_y = eq_y * eps.cos() + eq_z * eps.sin();
102 let ecl_z = -eq_y * eps.sin() + eq_z * eps.cos();
103
104 let _ = (i_orb, omega, t); Vec3::new(ecl_x, ecl_y, ecl_z).normalize()
109}
110
111pub fn saturn_ring_crossing(
120 spacecraft_pos_km: Vec3<f64>,
121 approach_vel: Vec3<f64>,
122 jd: f64,
123) -> RingCrossingAnalysis {
124 let ring_normal = saturn_ring_plane_normal(jd);
125
126 let dist_from_plane = spacecraft_pos_km.dot_raw(ring_normal);
128
129 let vel_normal = approach_vel.dot_raw(ring_normal);
131
132 let vel_mag = approach_vel.norm_raw();
134 let approach_angle = if vel_mag > 1e-15 {
135 Radians((vel_normal / vel_mag).abs().clamp(0.0, 1.0).asin())
136 } else {
137 Radians(0.0)
138 };
139
140 if vel_normal.abs() < 1e-15 {
142 return RingCrossingAnalysis {
144 crosses_ring_plane: false,
145 crossing_distance_km: None,
146 within_rings: false,
147 z_offset_at_closest_km: dist_from_plane,
148 approach_angle_to_ring_plane: approach_angle,
149 };
150 }
151
152 let t_crossing = -dist_from_plane / vel_normal;
154
155 if t_crossing < 0.0 {
156 return RingCrossingAnalysis {
158 crosses_ring_plane: false,
159 crossing_distance_km: None,
160 within_rings: false,
161 z_offset_at_closest_km: dist_from_plane,
162 approach_angle_to_ring_plane: approach_angle,
163 };
164 }
165
166 let crossing_pos = spacecraft_pos_km + approach_vel.scale(t_crossing);
168 let crossing_dist = crossing_pos.norm_raw();
169
170 let within_rings = (SATURN_RING_INNER_KM..=SATURN_RING_OUTER_KM).contains(&crossing_dist);
171
172 RingCrossingAnalysis {
173 crosses_ring_plane: true,
174 crossing_distance_km: Some(crossing_dist),
175 within_rings,
176 z_offset_at_closest_km: dist_from_plane,
177 approach_angle_to_ring_plane: approach_angle,
178 }
179}
180
181#[derive(Debug, Clone)]
183pub struct UranusApproachAnalysis {
184 pub equatorial_ecliptic_angle: Radians,
186 pub spin_axis_ecliptic: Vec3<f64>,
188 pub is_polar_approach: bool,
190 pub is_equatorial_approach: bool,
192 pub approach_to_equatorial: Radians,
194 pub ring_clearance_km: f64,
197}
198
199pub fn uranus_spin_axis_ecliptic() -> Vec3<f64> {
205 let ra_rad = 257.311_f64.to_radians();
207 let dec_rad = (-15.175_f64).to_radians();
208 let eps = 23.4393_f64.to_radians(); let eq_x = dec_rad.cos() * ra_rad.cos();
212 let eq_y = dec_rad.cos() * ra_rad.sin();
213 let eq_z = dec_rad.sin();
214
215 let ecl_x = eq_x;
217 let ecl_y = eq_y * eps.cos() + eq_z * eps.sin();
218 let ecl_z = -eq_y * eps.sin() + eq_z * eps.cos();
219
220 Vec3::new(ecl_x, ecl_y, ecl_z).normalize()
221}
222
223pub fn uranus_approach_analysis(
229 approach_dir: Vec3<f64>,
230 closest_approach_km: f64,
231) -> UranusApproachAnalysis {
232 let spin_axis = uranus_spin_axis_ecliptic();
233
234 let ecliptic_north = Vec3::new(0.0, 0.0, 1.0);
236 let equatorial_ecliptic_angle =
237 Radians(spin_axis.dot_raw(ecliptic_north).clamp(-1.0, 1.0).acos());
238
239 let approach_normalized = approach_dir.normalize();
241 let cos_approach_to_axis = approach_normalized.dot_raw(spin_axis).abs().clamp(0.0, 1.0);
242 let approach_to_axis_angle = cos_approach_to_axis.acos();
243
244 let approach_to_equatorial = Radians(PI / 2.0 - approach_to_axis_angle);
246
247 let is_polar_approach = approach_to_axis_angle < (30.0_f64).to_radians();
248 let is_equatorial_approach = approach_to_equatorial.value().abs() < (30.0_f64).to_radians();
249
250 let sin_angle_to_equatorial = approach_to_equatorial.value().sin().abs();
253 let z_at_ring_distance = closest_approach_km * sin_angle_to_equatorial;
254 let ring_clearance = if closest_approach_km > URANUS_RING_OUTER_KM {
255 closest_approach_km - URANUS_RING_OUTER_KM
257 } else {
258 z_at_ring_distance };
261
262 UranusApproachAnalysis {
263 equatorial_ecliptic_angle,
264 spin_axis_ecliptic: spin_axis,
265 is_polar_approach,
266 is_equatorial_approach,
267 approach_to_equatorial,
268 ring_clearance_km: ring_clearance,
269 }
270}
271
272pub fn ecliptic_z_height(planet: Planet, jd: f64) -> Km {
276 let pos = ephemeris::planet_position(planet, jd);
277 Km(pos.z)
278}
279
280pub fn max_ecliptic_z_height(planet: Planet) -> Km {
285 let a = planet.semi_major_axis().value();
286 let elem = ephemeris::mean_elements(planet);
287 let i_rad = elem.i0.to_radians();
288 Km(a * i_rad.sin())
289}
290
291pub fn out_of_plane_distance(planet1: Planet, planet2: Planet, jd: f64) -> Km {
295 let pos1 = ephemeris::planet_position(planet1, jd);
296 let pos2 = ephemeris::planet_position(planet2, jd);
297 Km((pos2.z - pos1.z).abs())
298}
299
300pub fn transfer_inclination_penalty(
307 departure: Planet,
308 arrival: Planet,
309 jd: f64,
310 transfer_velocity_km_s: f64,
311) -> (Radians, f64) {
312 let pos1 = ephemeris::planet_position(departure, jd);
313 let pos2 = ephemeris::planet_position(arrival, jd);
314
315 let delta_i = (pos2.inclination.value() - pos1.inclination.value()).abs();
317
318 let dv_penalty = 2.0 * transfer_velocity_km_s * (delta_i / 2.0).sin();
320
321 (Radians(delta_i), dv_penalty)
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use crate::ephemeris::calendar_to_jd;
328
329 #[test]
330 fn test_saturn_ring_plane_normal_is_unit_vector() {
331 let jd = ephemeris::J2000_JD;
332 let normal = saturn_ring_plane_normal(jd);
333 let mag = normal.norm_raw();
334 assert!(
335 (mag - 1.0).abs() < 1e-10,
336 "ring plane normal should be unit vector, got magnitude {}",
337 mag
338 );
339 }
340
341 #[test]
342 fn test_saturn_ring_plane_normal_tilted_from_ecliptic() {
343 let jd = ephemeris::J2000_JD;
347 let normal = saturn_ring_plane_normal(jd);
348 let ecliptic_north = Vec3::new(0.0_f64, 0.0, 1.0);
349 let cos_angle = normal.dot_raw(ecliptic_north);
350 let angle_deg = cos_angle.acos().to_degrees();
351 assert!(
352 angle_deg > 20.0 && angle_deg < 35.0,
353 "ring plane normal angle from ecliptic north = {:.1}°, expected ~26°",
354 angle_deg
355 );
356 }
357
358 #[test]
359 fn test_saturn_ring_crossing_head_on() {
360 let jd = ephemeris::J2000_JD;
361 let ring_normal = saturn_ring_plane_normal(jd);
362
363 let pos = ring_normal.scale(500_000.0); let vel = -ring_normal; let result = saturn_ring_crossing(pos, vel, jd);
368 assert!(result.crosses_ring_plane, "should cross ring plane");
369 assert!(
370 result.approach_angle_to_ring_plane.value().to_degrees() > 80.0,
371 "approach should be nearly perpendicular to ring plane, got {:.1}°",
372 result.approach_angle_to_ring_plane.value().to_degrees()
373 );
374 }
375
376 #[test]
377 fn test_saturn_ring_crossing_parallel() {
378 let jd = ephemeris::J2000_JD;
379 let ring_normal = saturn_ring_plane_normal(jd);
380
381 let arbitrary = Vec3::new(1.0, 0.0, 0.0);
384 let parallel_vel = ring_normal.cross_raw(arbitrary).normalize();
385 let pos = ring_normal.scale(100_000.0);
386
387 let result = saturn_ring_crossing(pos, parallel_vel, jd);
388 assert!(
389 !result.crosses_ring_plane,
390 "should not cross ring plane when moving parallel"
391 );
392 }
393
394 #[test]
395 fn test_uranus_spin_axis_nearly_in_ecliptic() {
396 let spin = uranus_spin_axis_ecliptic();
398 let mag = spin.norm_raw();
399 assert!((mag - 1.0).abs() < 1e-10, "spin axis should be unit vector");
400
401 assert!(
403 spin.z.abs() < 0.3,
404 "Uranus spin axis z-component should be small (nearly in ecliptic), got {}",
405 spin.z
406 );
407 }
408
409 #[test]
410 fn test_uranus_approach_from_ecliptic() {
411 let approach = Vec3::new(1.0, 0.0, 0.0);
413 let result = uranus_approach_analysis(approach, 100_000.0);
414
415 assert!(
418 result.equatorial_ecliptic_angle.value().to_degrees() > 70.0,
419 "Uranus equatorial plane should be steeply tilted from ecliptic: {:.1}°",
420 result.equatorial_ecliptic_angle.value().to_degrees()
421 );
422 }
423
424 #[test]
425 fn test_uranus_ring_clearance_far_approach() {
426 let approach = Vec3::new(1.0, 0.0, 0.0);
427 let result = uranus_approach_analysis(approach, 200_000.0);
428 assert!(
429 result.ring_clearance_km > 0.0,
430 "approach at 200,000 km should clear rings (outer edge ~51,149 km)"
431 );
432 }
433
434 #[test]
435 fn test_ecliptic_z_height_earth_small() {
436 let jd = ephemeris::J2000_JD;
438 let z = ecliptic_z_height(Planet::Earth, jd);
439 let z_au = z.value() / 149_597_870.7;
440 assert!(
441 z_au.abs() < 0.01,
442 "Earth z-height should be near zero, got {} AU",
443 z_au
444 );
445 }
446
447 #[test]
448 fn test_ecliptic_z_height_jupiter_nonzero() {
449 let max_z = max_ecliptic_z_height(Planet::Jupiter);
452 let max_z_au = max_z.value() / 149_597_870.7;
453 assert!(
454 (max_z_au - 0.118).abs() < 0.02,
455 "Jupiter max z ≈ {:.3} AU, expected ~0.118 AU",
456 max_z_au
457 );
458 }
459
460 #[test]
461 fn test_max_ecliptic_z_saturn() {
462 let max_z = max_ecliptic_z_height(Planet::Saturn);
464 let max_z_au = max_z.value() / 149_597_870.7;
465 assert!(
466 (max_z_au - 0.414).abs() < 0.05,
467 "Saturn max z ≈ {:.3} AU, expected ~0.414 AU",
468 max_z_au
469 );
470 }
471
472 #[test]
473 fn test_transfer_inclination_penalty() {
474 let jd = calendar_to_jd(2241, 9, 5.0); let (delta_i, dv) = transfer_inclination_penalty(
477 Planet::Earth,
478 Planet::Jupiter,
479 jd,
480 30.0, );
482
483 assert!(
485 delta_i.value().to_degrees() < 3.0,
486 "Earth-Jupiter inclination change should be small: {:.2}°",
487 delta_i.value().to_degrees()
488 );
489 assert!(dv < 2.0, "plane change ΔV should be modest: {:.2} km/s", dv);
490 }
491
492 #[test]
493 fn test_transfer_inclination_penalty_saturn_uranus() {
494 let jd = calendar_to_jd(2242, 1, 1.0);
496 let (delta_i, _dv) = transfer_inclination_penalty(Planet::Saturn, Planet::Uranus, jd, 20.0);
497
498 assert!(
499 delta_i.value().to_degrees() < 3.0,
500 "Saturn-Uranus inclination change should be small: {:.2}°",
501 delta_i.value().to_degrees()
502 );
503 }
504
505 #[test]
506 fn test_out_of_plane_distance() {
507 let jd = calendar_to_jd(2241, 9, 5.0);
508 let z_diff = out_of_plane_distance(Planet::Mars, Planet::Jupiter, jd);
509 let max_z_j = max_ecliptic_z_height(Planet::Jupiter).value();
512 let max_z_m = max_ecliptic_z_height(Planet::Mars).value();
513 assert!(
514 z_diff.value() <= max_z_j + max_z_m,
515 "z-diff {} should not exceed sum of max z-heights",
516 z_diff.value()
517 );
518 }
519
520 #[test]
521 fn test_planet_position_3d_consistency() {
522 let jd = ephemeris::J2000_JD;
524 for planet in &Planet::ALL {
525 let pos = ephemeris::planet_position(*planet, jd);
526 let r_sq = pos.x * pos.x + pos.y * pos.y + pos.z * pos.z;
527 let d_sq = pos.distance.value() * pos.distance.value();
528 let rel_err = ((r_sq - d_sq) / d_sq).abs();
529 assert!(
530 rel_err < 1e-10,
531 "{:?}: sqrt(x²+y²+z²) = {:.0}, distance = {:.0}, rel_err = {:.2e}",
532 planet,
533 r_sq.sqrt(),
534 pos.distance.value(),
535 rel_err
536 );
537 }
538 }
539
540 #[test]
541 fn test_planet_latitude_bounded() {
542 let jd = ephemeris::J2000_JD;
544 for planet in &Planet::ALL {
545 let pos = ephemeris::planet_position(*planet, jd);
546 assert!(
547 pos.latitude.value().abs() <= pos.inclination.value() + 0.01,
548 "{:?}: latitude {:.4}° exceeds inclination {:.4}°",
549 planet,
550 pos.latitude.value().to_degrees(),
551 pos.inclination.value().to_degrees()
552 );
553 }
554 }
555
556 #[test]
557 fn test_planet_position_3d_earth_z_near_zero() {
558 let jd = ephemeris::J2000_JD;
560 let pos = ephemeris::planet_position(Planet::Earth, jd);
561 let z_au = pos.z / 149_597_870.7;
562 assert!(
563 z_au.abs() < 0.001,
564 "Earth z should be ~0, got {:.6} AU",
565 z_au
566 );
567 }
568
569 #[test]
570 fn test_saturn_ring_crossing_already_passed() {
571 let jd = ephemeris::J2000_JD;
573 let ring_normal = saturn_ring_plane_normal(jd);
574
575 let pos = -ring_normal.scale(100_000.0);
577 let vel = -ring_normal; let result = saturn_ring_crossing(pos, vel, jd);
579 assert!(
580 !result.crosses_ring_plane,
581 "should not cross ring plane when moving away"
582 );
583 }
584
585 #[test]
586 fn test_saturn_ring_crossing_within_rings() {
587 let jd = ephemeris::J2000_JD;
589 let ring_normal = saturn_ring_plane_normal(jd);
590
591 let pos = ring_normal.scale(200_000.0);
596 let arb = Vec3::new(1.0, 0.0, 0.0);
599 let in_plane = ring_normal.cross_raw(arb).normalize();
600 let target = in_plane.scale(100_000.0); let vel_dir = Vec3::new(target.x - pos.x, target.y - pos.y, target.z - pos.z).normalize();
602
603 let result = saturn_ring_crossing(pos, vel_dir, jd);
604 assert!(result.crosses_ring_plane);
605 if let Some(dist) = result.crossing_distance_km {
606 assert!(
608 dist > SATURN_RING_INNER_KM && dist < SATURN_RING_OUTER_KM * 2.0,
609 "crossing distance = {} km",
610 dist
611 );
612 }
613 }
614
615 #[test]
616 fn test_uranus_approach_polar() {
617 let spin = uranus_spin_axis_ecliptic();
620 let result = uranus_approach_analysis(spin, 100_000.0);
621 assert!(
622 result.is_polar_approach,
623 "approach along spin axis should be polar, approach_to_equatorial = {:.1}°",
624 result.approach_to_equatorial.value().to_degrees()
625 );
626 assert!(
628 result.approach_to_equatorial.value().to_degrees() > 60.0,
629 "should be high angle to equatorial: {:.1}°",
630 result.approach_to_equatorial.value().to_degrees()
631 );
632 }
633
634 #[test]
635 fn test_uranus_approach_close_ring_clearance() {
636 let approach = Vec3::new(1.0, 0.0, 0.0);
638 let result = uranus_approach_analysis(approach, 40_000.0);
639 assert!(
643 result.ring_clearance_km >= 0.0,
644 "ring clearance should be >= 0 (z-offset approach)"
645 );
646 }
647
648 #[test]
649 fn test_enceladus_outside_ring_system() {
650 assert!(
652 ENCELADUS_ORBITAL_RADIUS_KM > SATURN_RING_OUTER_KM,
653 "Enceladus ({} km) should be outside Saturn's ring outer edge ({} km)",
654 ENCELADUS_ORBITAL_RADIUS_KM,
655 SATURN_RING_OUTER_KM
656 );
657 assert!(
659 ENCELADUS_ORBITAL_RADIUS_KM > SATURN_RING_A_OUTER_KM,
660 "Enceladus should be outside A-ring"
661 );
662 let ratio = ENCELADUS_ORBITAL_RADIUS_KM / SATURN_RING_OUTER_KM;
664 assert!(
665 ratio > 1.5 && ratio < 2.0,
666 "Enceladus/ring ratio = {:.2}, expected ~1.7",
667 ratio
668 );
669 }
670
671 #[test]
672 fn test_saturn_ring_crossing_oblique_angle() {
673 let jd = ephemeris::J2000_JD;
674 let ring_normal = saturn_ring_plane_normal(jd);
675
676 let arb = Vec3::new(1.0, 0.0, 0.0);
678 let in_plane = ring_normal.cross_raw(arb).normalize();
679 let vel = Vec3::new(
681 -ring_normal.x + in_plane.x,
682 -ring_normal.y + in_plane.y,
683 -ring_normal.z + in_plane.z,
684 )
685 .normalize();
686 let pos = ring_normal.scale(300_000.0); let result = saturn_ring_crossing(pos, vel, jd);
689 assert!(
690 result.crosses_ring_plane,
691 "oblique approach should cross ring plane"
692 );
693 let angle_deg = result.approach_angle_to_ring_plane.value().to_degrees();
695 assert!(
696 angle_deg > 30.0 && angle_deg < 60.0,
697 "oblique approach angle = {:.1}°, expected ~45°",
698 angle_deg
699 );
700 assert!(result.crossing_distance_km.is_some());
702 }
703
704 #[test]
705 fn test_transfer_inclination_penalty_same_planet_zero() {
706 let jd = calendar_to_jd(2241, 9, 5.0);
708 let (delta_i, dv) = transfer_inclination_penalty(Planet::Earth, Planet::Earth, jd, 30.0);
709 assert!(
710 delta_i.value().abs() < 1e-15,
711 "same planet should have zero inclination change: {}",
712 delta_i.value()
713 );
714 assert!(
715 dv < 1e-10,
716 "same planet should have zero ΔV penalty: {}",
717 dv
718 );
719 }
720
721 #[test]
722 fn test_planet_position_3d_backwards_compatible() {
723 let jd = ephemeris::J2000_JD;
727 for planet in &Planet::ALL {
728 let pos = ephemeris::planet_position(*planet, jd);
729 let r_xy = (pos.x * pos.x + pos.y * pos.y).sqrt();
731 let r_total = pos.distance.value();
732 let z_fraction = pos.z.abs() / r_total;
733 assert!(
736 z_fraction < 0.13,
737 "{:?}: z/r = {:.4}, z = {:.0} km",
738 planet,
739 z_fraction,
740 pos.z
741 );
742 let r_diff = (r_xy - r_total).abs() / r_total;
744 assert!(
745 r_diff < 0.002,
746 "{:?}: r_xy/r = {:.6}, diff = {:.2e}",
747 planet,
748 r_xy / r_total,
749 r_diff
750 );
751 }
752 }
753}