solar_line_core/
orbital_3d.rs

1/// 3D orbital analysis for SOLAR LINE.
2///
3/// Extends the 2D coplanar analysis with:
4/// - Out-of-ecliptic-plane positions and velocities
5/// - Saturn ring plane crossing analysis
6/// - Uranus axial tilt effects on approach geometry
7/// - Inclination change ΔV costs
8use crate::ephemeris::{self, Planet};
9use crate::units::{Km, Radians};
10use crate::vec3::Vec3;
11use std::f64::consts::PI;
12
13// ── Saturn Ring System ──────────────────────────────────────────────
14
15/// Saturn's axial tilt (obliquity) relative to its orbital plane: 26.73°
16pub const SATURN_OBLIQUITY_RAD: f64 = 26.73 * PI / 180.0;
17
18/// Saturn ring inner edge (D ring): ~66,900 km from center
19pub const SATURN_RING_INNER_KM: f64 = 66_900.0;
20
21/// Saturn ring outer edge (F ring): ~140,180 km from center
22pub const SATURN_RING_OUTER_KM: f64 = 140_180.0;
23
24/// Saturn main ring outer edge (A ring): ~136,775 km from center
25pub const SATURN_RING_A_OUTER_KM: f64 = 136_775.0;
26
27/// Saturn equatorial radius: 60,268 km
28pub const SATURN_EQUATORIAL_RADIUS_KM: f64 = 60_268.0;
29
30/// Enceladus orbital radius: ~238,020 km (outside ring system)
31/// Note: NASA JPL gives semi-major axis 238,042 km; 238,020 km is used
32/// throughout EP02 calculations for internal consistency.
33pub const ENCELADUS_ORBITAL_RADIUS_KM: f64 = 238_020.0;
34
35// ── Uranus System ───────────────────────────────────────────────────
36
37/// Uranus axial tilt: 97.77° (retrograde rotation)
38pub const URANUS_OBLIQUITY_RAD: f64 = 97.77 * PI / 180.0;
39
40/// Uranus equatorial radius: 25,559 km
41pub const URANUS_EQUATORIAL_RADIUS_KM: f64 = 25_559.0;
42
43/// Titania orbital radius: ~436,300 km
44pub const TITANIA_ORBITAL_RADIUS_KM: f64 = 436_300.0;
45
46/// Uranus ring system outer edge: ~51,149 km
47pub const URANUS_RING_OUTER_KM: f64 = 51_149.0;
48
49/// Result of analyzing whether a transfer trajectory crosses Saturn's ring plane.
50#[derive(Debug, Clone)]
51pub struct RingCrossingAnalysis {
52    /// Whether the trajectory crosses the ring plane
53    pub crosses_ring_plane: bool,
54    /// Distance from Saturn at ring plane crossing (km), if crossing occurs
55    pub crossing_distance_km: Option<f64>,
56    /// Whether the crossing occurs within the ring system (ring inner to outer)
57    pub within_rings: bool,
58    /// Z-height above/below Saturn's equatorial plane at closest approach
59    pub z_offset_at_closest_km: f64,
60    /// Angle between approach velocity and ring plane (radians)
61    pub approach_angle_to_ring_plane: Radians,
62}
63
64/// Compute the normal vector of Saturn's ring plane in ecliptic coordinates.
65///
66/// Saturn's ring plane coincides with its equatorial plane.
67/// The pole direction depends on Saturn's RAAN (Ω) and obliquity (ε).
68///
69/// The ring plane normal in ecliptic coordinates is derived from:
70/// - Saturn's orbital inclination to ecliptic
71/// - Saturn's obliquity (axial tilt relative to orbital plane)
72/// - Saturn's RAAN (longitude of ascending node)
73pub 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    // Saturn's north pole direction in ecliptic coordinates.
81    // First, the orbital pole is tilted by i_orb from ecliptic north.
82    // Then Saturn's spin axis is tilted by SATURN_OBLIQUITY from the orbital pole.
83    //
84    // For a simplified model, we combine the orbital inclination and obliquity.
85    // Saturn's north pole RA/Dec (J2000): RA=40.589°, Dec=83.537°
86    // In ecliptic coordinates, this gives us the ring plane normal.
87    //
88    // Using IAU J2000 pole: RA = 40.589°, Dec = 83.537°
89    // Convert equatorial to ecliptic (obliquity of ecliptic ε = 23.4393°)
90    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(); // Earth's obliquity
93
94    // Equatorial unit vector of Saturn's pole
95    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    // Rotate to ecliptic coordinates (rotate around x-axis by -ε)
100    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    // Precess from J2000 to epoch (small correction for ~2.4 centuries)
105    // For the accuracy needed here, J2000 pole is sufficient
106    let _ = (i_orb, omega, t); // suppress unused warnings — used for documentation
107
108    Vec3::new(ecl_x, ecl_y, ecl_z).normalize()
109}
110
111/// Analyze whether a trajectory approaching Saturn crosses its ring plane.
112///
113/// Given a spacecraft position relative to Saturn and approach velocity vector,
114/// determine if and where the trajectory intersects the ring plane.
115///
116/// `spacecraft_pos_km`: position relative to Saturn in ecliptic frame (km)
117/// `approach_vel`: velocity vector in ecliptic frame (unit direction)
118/// `jd`: Julian Date (for computing ring plane orientation)
119pub 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    // Distance of spacecraft from ring plane: d = pos · normal
127    let dist_from_plane = spacecraft_pos_km.dot_raw(ring_normal);
128
129    // Component of velocity along ring normal
130    let vel_normal = approach_vel.dot_raw(ring_normal);
131
132    // Angle between approach velocity and ring plane
133    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    // Check if trajectory crosses the ring plane
141    if vel_normal.abs() < 1e-15 {
142        // Moving parallel to ring plane
143        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    // Time parameter to ring plane crossing: t = -dist_from_plane / vel_normal
153    let t_crossing = -dist_from_plane / vel_normal;
154
155    if t_crossing < 0.0 {
156        // Crossing is behind the spacecraft (already passed)
157        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    // Position at ring plane crossing
167    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/// Result of analyzing Uranus approach geometry.
182#[derive(Debug, Clone)]
183pub struct UranusApproachAnalysis {
184    /// Angle between ecliptic plane and Uranus's equatorial plane (radians)
185    pub equatorial_ecliptic_angle: Radians,
186    /// Uranus spin axis direction in ecliptic coordinates (unit vector)
187    pub spin_axis_ecliptic: Vec3<f64>,
188    /// Whether the approach is roughly polar (within 30° of spin axis)
189    pub is_polar_approach: bool,
190    /// Whether the approach is roughly equatorial (within 30° of equatorial plane)
191    pub is_equatorial_approach: bool,
192    /// Angle between approach direction and Uranus equatorial plane (radians)
193    pub approach_to_equatorial: Radians,
194    /// Minimum distance from Uranus ring system during approach (km)
195    /// Negative means the approach passes through the ring plane within ring distance
196    pub ring_clearance_km: f64,
197}
198
199/// Compute Uranus's spin axis direction in ecliptic coordinates.
200///
201/// Uranus's north pole (IAU J2000): RA = 257.311°, Dec = -15.175°
202/// The negative declination and RA near 257° mean the axis lies nearly
203/// in the ecliptic plane, pointed roughly toward ecliptic longitude ~260°.
204pub fn uranus_spin_axis_ecliptic() -> Vec3<f64> {
205    // IAU J2000 pole: RA = 257.311°, Dec = -15.175°
206    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(); // Earth's obliquity
209
210    // Equatorial unit vector
211    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    // Rotate to ecliptic coordinates (rotate around x-axis by -ε)
216    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
223/// Analyze the approach geometry to Uranus from a given direction.
224///
225/// `approach_dir`: unit vector of approach direction in ecliptic frame
226///     (direction FROM which the spacecraft comes, i.e. opposite of velocity)
227/// `closest_approach_km`: planned closest approach distance from Uranus center
228pub 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    // Angle between spin axis and ecliptic north (0,0,1)
235    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    // Angle between approach direction and spin axis
240    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    // Approach angle relative to equatorial plane = 90° - angle_to_axis
245    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    // Ring clearance: project closest approach onto ring plane
251    // If approach passes through ring plane within ring distance, clearance is negative
252    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 is outside rings — safe
256        closest_approach_km - URANUS_RING_OUTER_KM
257    } else {
258        // Closest approach is inside ring zone — check z-offset
259        z_at_ring_distance // simplified: positive means above/below ring plane
260    };
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
272/// Compute the ecliptic z-height of a planet at a given Julian Date.
273///
274/// Returns the distance above (+) or below (-) the ecliptic plane in km.
275pub fn ecliptic_z_height(planet: Planet, jd: f64) -> Km {
276    let pos = ephemeris::planet_position(planet, jd);
277    Km(pos.z)
278}
279
280/// Compute the maximum ecliptic z-height a planet can reach.
281///
282/// For a planet with inclination i and semi-major axis a:
283/// max z ≈ a * sin(i) (approximate, ignoring eccentricity)
284pub 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
291/// Out-of-plane distance between two planet positions.
292///
293/// Returns the z-difference in km between two planets' positions at a given JD.
294pub 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
300/// Transfer inclination analysis: given departure and arrival planet positions,
301/// compute the required inclination change and associated ΔV penalty.
302///
303/// Returns (inclination_change_rad, dv_penalty_km_s) where:
304/// - inclination_change_rad: angle between departure and arrival orbital planes
305/// - dv_penalty_km_s: additional ΔV needed for plane change at given transfer velocity
306pub 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    // Inclination difference between orbital planes (simplified: direct subtraction)
316    let delta_i = (pos2.inclination.value() - pos1.inclination.value()).abs();
317
318    // ΔV for plane change: 2 * v * sin(Δi/2)
319    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        // Saturn's ring plane is tilted ~26.7° from its orbital plane,
344        // which itself is tilted ~2.5° from ecliptic.
345        // So the ring plane normal should be tilted ~24-29° from ecliptic north.
346        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        // Spacecraft approaching along ring normal direction (perpendicular to rings)
364        let pos = ring_normal.scale(500_000.0); // 500,000 km above ring plane
365        let vel = -ring_normal; // approaching toward Saturn
366
367        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        // Spacecraft moving parallel to ring plane
382        // Construct a velocity perpendicular to ring normal
383        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        // Uranus's spin axis is nearly in the ecliptic plane (97.77° obliquity)
397        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        // The z-component should be small (axis nearly in ecliptic plane)
402        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        // Approaching Uranus from within the ecliptic plane
412        let approach = Vec3::new(1.0, 0.0, 0.0);
413        let result = uranus_approach_analysis(approach, 100_000.0);
414
415        // Uranus's equatorial plane is nearly perpendicular to ecliptic
416        // So an ecliptic approach should be roughly polar
417        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        // Earth's orbital inclination to ecliptic is ~0°, so z-height should be tiny
437        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        // Jupiter has ~1.3° inclination, so at some epochs z can be nonzero
450        // At 5.2 AU with 1.3° inclination: max z ≈ 5.2 * sin(1.3°) ≈ 0.118 AU
451        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        // Saturn: ~9.54 AU, i ≈ 2.49° → max z ≈ 0.414 AU
463        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        // Earth (i≈0°) → Jupiter (i≈1.3°): small plane change
475        let jd = calendar_to_jd(2241, 9, 5.0); // SOLAR LINE epoch
476        let (delta_i, dv) = transfer_inclination_penalty(
477            Planet::Earth,
478            Planet::Jupiter,
479            jd,
480            30.0, // ~30 km/s transfer velocity
481        );
482
483        // 1.3° plane change at 30 km/s: ΔV ≈ 2 * 30 * sin(0.65°) ≈ 0.68 km/s
484        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        // Saturn (i≈2.49°) → Uranus (i≈0.77°): inclination decreases
495        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        // Should be non-trivial (millions of km possible at outer solar system distances)
510        // but bounded by max z-heights
511        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        // Verify that x² + y² + z² = distance² for all planets
523        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        // Latitude should be bounded by orbital inclination
543        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        // Earth's ecliptic inclination is ~0°, so z should be nearly 0
559        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        // Spacecraft is below ring plane and moving away — crossing is behind
572        let jd = ephemeris::J2000_JD;
573        let ring_normal = saturn_ring_plane_normal(jd);
574
575        // Below ring plane, moving further away
576        let pos = -ring_normal.scale(100_000.0);
577        let vel = -ring_normal; // moving further below
578        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        // Crossing the ring plane at a distance within the ring system
588        let jd = ephemeris::J2000_JD;
589        let ring_normal = saturn_ring_plane_normal(jd);
590
591        // Construct a velocity that crosses the ring plane at ~100,000 km from Saturn
592        // We need to be above the plane, with a velocity toward Saturn that crosses
593        // the ring plane at ring-radius distance.
594        // Place ship on ring normal at 200,000 km above, velocity toward ring center area
595        let pos = ring_normal.scale(200_000.0);
596        // Velocity: toward a point ~100,000 km from Saturn in the ring plane
597        // The ring plane has two axes perpendicular to ring_normal
598        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); // ~100,000 km from Saturn in ring plane
601        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            // The crossing should be near 100,000 km ± some geometry
607            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        // Approach exactly along Uranus's spin axis — should be polar approach
618        // Tests the acos clamp fix for exact-alignment floating point edge case
619        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        // approach_to_equatorial should be ~90° (perpendicular to equatorial plane)
627        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        // Approach inside ring zone
637        let approach = Vec3::new(1.0, 0.0, 0.0);
638        let result = uranus_approach_analysis(approach, 40_000.0);
639        // Closest approach (40,000 km) is inside ring outer (51,149 km)
640        // Ring clearance depends on z-offset
641        // Since this is inside the ring zone, the clearance is the z_offset
642        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        // Enceladus orbits at ~238,020 km, well outside the F-ring at ~140,180 km
651        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        // And outside the A-ring outer edge
658        assert!(
659            ENCELADUS_ORBITAL_RADIUS_KM > SATURN_RING_A_OUTER_KM,
660            "Enceladus should be outside A-ring"
661        );
662        // Margin: Enceladus is ~70% further out than the ring outer edge
663        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        // Spacecraft above ring plane approaching obliquely (45° mix of normal + in-plane)
677        let arb = Vec3::new(1.0, 0.0, 0.0);
678        let in_plane = ring_normal.cross_raw(arb).normalize();
679        // Velocity: equal parts toward ring plane and along it
680        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); // 300k km above ring plane
687
688        let result = saturn_ring_crossing(pos, vel, jd);
689        assert!(
690            result.crosses_ring_plane,
691            "oblique approach should cross ring plane"
692        );
693        // Approach angle should be ~45° (between parallel 0° and perpendicular 90°)
694        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        // Crossing distance should be positive (ahead of spacecraft)
701        assert!(result.crossing_distance_km.is_some());
702    }
703
704    #[test]
705    fn test_transfer_inclination_penalty_same_planet_zero() {
706        // Same planet → zero inclination change, zero ΔV
707        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        // The 2D projection (x, y) should approximately match the old coplanar results.
724        // Since we changed from simplified λ = ω+ν+Ω to proper rotation matrix,
725        // there may be small differences, but distances should match exactly.
726        let jd = ephemeris::J2000_JD;
727        for planet in &Planet::ALL {
728            let pos = ephemeris::planet_position(*planet, jd);
729            // x-y distance should approximately equal total distance (since z is small)
730            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            // z should be small fraction of total distance (planets have small inclinations)
734            // Mercury has the largest inclination at ~7°, so z/r can be up to sin(7°) ≈ 0.12
735            assert!(
736                z_fraction < 0.13,
737                "{:?}: z/r = {:.4}, z = {:.0} km",
738                planet,
739                z_fraction,
740                pos.z
741            );
742            // r_xy should be close to r_total
743            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}