solar_line_core/
ephemeris.rs

1/// Approximate planetary ephemeris for SOLAR LINE analysis.
2///
3/// Uses mean Keplerian elements with linear secular rates (J2000 epoch).
4/// Source: Standish & Williams (JPL), "Keplerian Elements for Approximate
5/// Positions of the Major Planets" (valid 3000 BC – 3000 AD for outer planets).
6///
7/// Accuracy: ~1° for outer planets over century timescales. Sufficient for
8/// determining launch windows and phase angles in fiction analysis.
9///
10/// Reference frame: J2000 ecliptic, heliocentric.
11use crate::constants::{mu, orbit_radius, AU_KM};
12use crate::kepler;
13use crate::units::{Eccentricity, Km, Mu, Radians, Seconds};
14use std::f64::consts::PI;
15
16/// Julian Date of J2000.0 epoch (2000-01-01 12:00:00 TT)
17pub const J2000_JD: f64 = 2_451_545.0;
18
19/// Julian century in days
20const JULIAN_CENTURY_DAYS: f64 = 36525.0;
21
22/// Seconds per day
23const SECONDS_PER_DAY: f64 = 86400.0;
24
25/// Planets supported by the ephemeris
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Planet {
28    Mercury,
29    Venus,
30    Earth,
31    Mars,
32    Jupiter,
33    Saturn,
34    Uranus,
35    Neptune,
36}
37
38impl Planet {
39    /// All planets in order from Sun
40    pub const ALL: [Planet; 8] = [
41        Planet::Mercury,
42        Planet::Venus,
43        Planet::Earth,
44        Planet::Mars,
45        Planet::Jupiter,
46        Planet::Saturn,
47        Planet::Uranus,
48        Planet::Neptune,
49    ];
50
51    /// Semi-major axis (mean value) in km
52    pub fn semi_major_axis(self) -> Km {
53        match self {
54            Planet::Mercury => orbit_radius::MERCURY,
55            Planet::Venus => orbit_radius::VENUS,
56            Planet::Earth => orbit_radius::EARTH,
57            Planet::Mars => orbit_radius::MARS,
58            Planet::Jupiter => orbit_radius::JUPITER,
59            Planet::Saturn => orbit_radius::SATURN,
60            Planet::Uranus => orbit_radius::URANUS,
61            Planet::Neptune => Km(4_495_060_000.0),
62        }
63    }
64
65    /// Gravitational parameter μ = GM in km³/s²
66    pub fn mu(self) -> Mu {
67        match self {
68            Planet::Mercury => mu::MERCURY,
69            Planet::Venus => mu::VENUS,
70            Planet::Earth => mu::EARTH,
71            Planet::Mars => mu::MARS,
72            Planet::Jupiter => mu::JUPITER,
73            Planet::Saturn => mu::SATURN,
74            Planet::Uranus => mu::URANUS,
75            Planet::Neptune => mu::NEPTUNE,
76        }
77    }
78}
79
80/// Mean Keplerian elements at epoch with linear secular rates.
81///
82/// Elements: a, e, I, L (mean longitude), ω̃ (longitude of perihelion), Ω (RAAN)
83/// Each has a value at J2000 and a rate per Julian century.
84#[derive(Debug, Clone, Copy)]
85pub struct MeanElements {
86    /// Semi-major axis at J2000 (AU)
87    pub a0: f64,
88    /// Semi-major axis rate (AU/century)
89    pub a_dot: f64,
90    /// Eccentricity at J2000
91    pub e0: f64,
92    /// Eccentricity rate (per century)
93    pub e_dot: f64,
94    /// Inclination at J2000 (degrees)
95    pub i0: f64,
96    /// Inclination rate (degrees/century)
97    pub i_dot: f64,
98    /// Mean longitude at J2000 (degrees)
99    pub l0: f64,
100    /// Mean longitude rate (degrees/century)
101    pub l_dot: f64,
102    /// Longitude of perihelion at J2000 (degrees)
103    pub w_bar0: f64,
104    /// Longitude of perihelion rate (degrees/century)
105    pub w_bar_dot: f64,
106    /// Longitude of ascending node at J2000 (degrees)
107    pub omega0: f64,
108    /// Longitude of ascending node rate (degrees/century)
109    pub omega_dot: f64,
110}
111
112/// Get mean Keplerian elements for a planet.
113///
114/// Source: Standish & Williams, JPL
115/// "Keplerian Elements for Approximate Positions of the Major Planets"
116/// Table 1 (valid 3000 BC – 3000 AD)
117pub fn mean_elements(planet: Planet) -> MeanElements {
118    match planet {
119        Planet::Mercury => MeanElements {
120            a0: 0.387_098_31,
121            a_dot: 0.0,
122            e0: 0.205_630_69,
123            e_dot: 0.000_020_04,
124            i0: 7.004_86,
125            i_dot: -0.005_93,
126            l0: 252.250_84,
127            l_dot: 149_472.674_11,
128            w_bar0: 77.456_45,
129            w_bar_dot: 0.159_29,
130            omega0: 48.330_67,
131            omega_dot: -0.125_34,
132        },
133        Planet::Venus => MeanElements {
134            a0: 0.723_329_56,
135            a_dot: 0.0,
136            e0: 0.006_773_23,
137            e_dot: -0.000_047_64,
138            i0: 3.394_71,
139            i_dot: -0.008_67,
140            l0: 181.979_73,
141            l_dot: 58_517.815_39,
142            w_bar0: 131.563_70,
143            w_bar_dot: 0.002_68,
144            omega0: 76.679_92,
145            omega_dot: -0.278_01,
146        },
147        Planet::Earth => MeanElements {
148            a0: 1.000_002_61,
149            a_dot: 0.000_005_62,
150            e0: 0.016_708_57,
151            e_dot: -0.000_042_04,
152            i0: -0.000_15,
153            i_dot: -0.013_37,
154            l0: 100.464_57,
155            l_dot: 35_999.372_44,
156            w_bar0: 102.937_35,
157            w_bar_dot: 0.323_29,
158            omega0: 0.0,
159            omega_dot: 0.0,
160        },
161        Planet::Mars => MeanElements {
162            a0: 1.523_662_31,
163            a_dot: -0.000_073_28,
164            e0: 0.093_412_33,
165            e_dot: 0.000_090_48,
166            i0: 1.850_26,
167            i_dot: -0.006_75,
168            l0: -4.553_43,
169            l_dot: 19_140.299_34,
170            w_bar0: -23.943_62,
171            w_bar_dot: 0.445_41,
172            omega0: 49.558_09,
173            omega_dot: -0.291_08,
174        },
175        Planet::Jupiter => MeanElements {
176            a0: 5.202_603_91,
177            a_dot: 0.000_016_63,
178            e0: 0.048_497_64,
179            e_dot: 0.000_163_41,
180            i0: 1.303_30,
181            i_dot: -0.001_98,
182            l0: 34.396_44,
183            l_dot: 3_034.905_67,
184            w_bar0: 14.728_47,
185            w_bar_dot: 0.215_36,
186            omega0: 100.464_44,
187            omega_dot: 0.176_56,
188        },
189        Planet::Saturn => MeanElements {
190            a0: 9.554_909_16,
191            a_dot: -0.000_213_89,
192            e0: 0.055_508_62,
193            e_dot: -0.000_346_61,
194            i0: 2.488_68,
195            i_dot: 0.007_74,
196            l0: 49.954_24,
197            l_dot: 1_222.113_71,
198            w_bar0: 92.598_87,
199            w_bar_dot: -0.418_97,
200            omega0: 113.665_24,
201            omega_dot: -0.250_60,
202        },
203        Planet::Uranus => MeanElements {
204            a0: 19.218_446_10,
205            a_dot: -0.000_202_57,
206            e0: 0.046_295_11,
207            e_dot: -0.000_030_26,
208            i0: 0.773_20,
209            i_dot: 0.000_74,
210            l0: 313.238_18,
211            l_dot: 428.481_03,
212            w_bar0: 170.954_27,
213            w_bar_dot: 0.403_17,
214            omega0: 74.016_92,
215            omega_dot: 0.042_40,
216        },
217        Planet::Neptune => MeanElements {
218            a0: 30.110_386_88,
219            a_dot: 0.000_069_47,
220            e0: 0.008_989_22,
221            e_dot: 0.000_006_06,
222            i0: 1.769_17,
223            i_dot: -0.005_42,
224            l0: -55.120_02,
225            l_dot: 218.456_52,
226            w_bar0: 44.964_76,
227            w_bar_dot: -0.326_36,
228            omega0: 131.784_06,
229            omega_dot: -0.006_51,
230        },
231    }
232}
233
234/// Convert calendar date to Julian Date.
235///
236/// Valid for dates after 1582-10-15 (Gregorian calendar).
237/// Uses the standard algorithm from Meeus, "Astronomical Algorithms".
238pub fn calendar_to_jd(year: i32, month: u32, day: f64) -> f64 {
239    let (y, m) = if month <= 2 {
240        (year as f64 - 1.0, month as f64 + 12.0)
241    } else {
242        (year as f64, month as f64)
243    };
244
245    let a = (y / 100.0).floor();
246    let b = 2.0 - a + (a / 4.0).floor();
247
248    (365.25 * (y + 4716.0)).floor() + (30.6001 * (m + 1.0)).floor() + day + b - 1524.5
249}
250
251/// Convert Julian Date to calendar date (year, month, day with fractional part).
252pub fn jd_to_calendar(jd: f64) -> (i32, u32, f64) {
253    let z = (jd + 0.5).floor();
254    let f = jd + 0.5 - z;
255
256    let a = if z < 2_299_161.0 {
257        z
258    } else {
259        let alpha = ((z - 1_867_216.25) / 36_524.25).floor();
260        z + 1.0 + alpha - (alpha / 4.0).floor()
261    };
262
263    let b = a + 1524.0;
264    let c = ((b - 122.1) / 365.25).floor();
265    let d = (365.25 * c).floor();
266    let e = ((b - d) / 30.6001).floor();
267
268    let day = b - d - (30.6001 * e).floor() + f;
269    let month = if e < 14.0 { e - 1.0 } else { e - 13.0 };
270    let year = if month > 2.0 { c - 4716.0 } else { c - 4715.0 };
271
272    (year as i32, month as u32, day)
273}
274
275/// Heliocentric position of a planet in ecliptic coordinates.
276///
277/// Coordinates in km, where:
278/// - x-axis points toward vernal equinox
279/// - y-axis is 90° counter-clockwise in the ecliptic
280/// - z-axis points toward ecliptic north pole
281#[derive(Debug, Clone, Copy)]
282pub struct PlanetPosition {
283    /// Heliocentric ecliptic longitude (radians, 0 = vernal equinox)
284    pub longitude: Radians,
285    /// Heliocentric ecliptic latitude (radians, 0 = ecliptic plane)
286    pub latitude: Radians,
287    /// Heliocentric distance (km)
288    pub distance: Km,
289    /// X coordinate in ecliptic frame (km)
290    pub x: f64,
291    /// Y coordinate in ecliptic frame (km)
292    pub y: f64,
293    /// Z coordinate in ecliptic frame (km, positive = ecliptic north)
294    pub z: f64,
295    /// Orbital inclination at epoch (radians)
296    pub inclination: Radians,
297}
298
299/// Compute heliocentric position of a planet at a given Julian Date.
300///
301/// Uses mean Keplerian elements with secular perturbations.
302/// Returns full 3D ecliptic coordinates (x, y, z).
303/// Accuracy: ~1° for outer planets, ~2° for inner planets over centuries.
304pub fn planet_position(planet: Planet, jd: f64) -> PlanetPosition {
305    let elem = mean_elements(planet);
306    let t = (jd - J2000_JD) / JULIAN_CENTURY_DAYS; // centuries from J2000
307
308    // Compute elements at epoch
309    let a_au = elem.a0 + elem.a_dot * t;
310    let e = elem.e0 + elem.e_dot * t;
311    let i_deg = elem.i0 + elem.i_dot * t;
312    let l_deg = elem.l0 + elem.l_dot * t;
313    let w_bar_deg = elem.w_bar0 + elem.w_bar_dot * t;
314    let omega_deg = elem.omega0 + elem.omega_dot * t;
315
316    // Convert angles to radians
317    let i_rad = i_deg.to_radians();
318    let omega_rad = omega_deg.to_radians();
319
320    // Mean anomaly M = L - ω̃
321    let m_deg = l_deg - w_bar_deg;
322    let m_rad = Radians(m_deg.to_radians()).normalize();
323
324    // Argument of perihelion ω = ω̃ - Ω
325    let w_deg = w_bar_deg - omega_deg;
326    let w_rad = w_deg.to_radians();
327
328    // Solve Kepler's equation for eccentric anomaly
329    let ecc = Eccentricity::elliptical(e.clamp(0.0, 0.999)).unwrap();
330    let true_anomaly = kepler::mean_to_true_anomaly(m_rad, ecc)
331        .expect("Kepler solver should converge for planetary eccentricities");
332
333    // Heliocentric distance
334    let a_km = a_au * AU_KM;
335    let r_km = a_km * (1.0 - e * e) / (1.0 + e * true_anomaly.cos());
336
337    // Argument of latitude u = ω + ν
338    let u = w_rad + true_anomaly.value();
339
340    // Position in orbital plane (perifocal-like)
341    let x_orb = r_km * u.cos();
342    let y_orb = r_km * u.sin();
343
344    // Rotate to ecliptic frame:
345    //   x = cos(Ω)·x' - sin(Ω)·cos(i)·y'
346    //   y = sin(Ω)·x' + cos(Ω)·cos(i)·y'
347    //   z = sin(i)·y'
348    let cos_omega = omega_rad.cos();
349    let sin_omega = omega_rad.sin();
350    let cos_i = i_rad.cos();
351    let sin_i = i_rad.sin();
352
353    let x = cos_omega * x_orb - sin_omega * cos_i * y_orb;
354    let y = sin_omega * x_orb + cos_omega * cos_i * y_orb;
355    let z = sin_i * y_orb;
356
357    // Ecliptic longitude and latitude from 3D coordinates
358    let longitude = Radians(y.atan2(x)).normalize();
359    let latitude = Radians((z / r_km).clamp(-1.0, 1.0).asin());
360
361    PlanetPosition {
362        longitude,
363        latitude,
364        distance: Km(r_km),
365        x,
366        y,
367        z,
368        inclination: Radians(i_rad),
369    }
370}
371
372/// Compute the ecliptic longitude of a planet at a given Julian Date (radians).
373///
374/// Convenience wrapper around `planet_position`.
375pub fn planet_longitude(planet: Planet, jd: f64) -> Radians {
376    planet_position(planet, jd).longitude
377}
378
379/// Compute the angular separation between two planets at a given Julian Date.
380///
381/// Returns the signed angle from planet1 to planet2 in the direction of
382/// orbital motion (counter-clockwise), normalized to (-π, π].
383pub fn phase_angle(planet1: Planet, planet2: Planet, jd: f64) -> Radians {
384    let lon1 = planet_longitude(planet1, jd);
385    let lon2 = planet_longitude(planet2, jd);
386    (lon2 - lon1).normalize_signed()
387}
388
389/// Synodic period between two planets (seconds).
390///
391/// T_synodic = 1 / |1/T1 - 1/T2|
392///
393/// where T1, T2 are the orbital periods.
394pub fn synodic_period(planet1: Planet, planet2: Planet) -> Seconds {
395    let t1 = crate::orbits::orbital_period(mu::SUN, planet1.semi_major_axis());
396    let t2 = crate::orbits::orbital_period(mu::SUN, planet2.semi_major_axis());
397    let inv_diff = (1.0 / t1.value() - 1.0 / t2.value()).abs();
398    Seconds(1.0 / inv_diff)
399}
400
401/// Required phase angle for a Hohmann transfer between two circular orbits.
402///
403/// The destination planet must be ahead of the departure planet by this angle
404/// at the time of departure for the spacecraft to arrive when the destination
405/// planet reaches the arrival point.
406///
407/// Returns the phase angle in radians (always positive, measured counter-clockwise
408/// from departure to destination).
409pub fn hohmann_phase_angle(departure: Planet, arrival: Planet) -> Radians {
410    let r1 = departure.semi_major_axis().value();
411    let r2 = arrival.semi_major_axis().value();
412
413    // Transfer semi-major axis
414    let a_transfer = (r1 + r2) / 2.0;
415
416    // Transfer time (half the orbital period of the transfer ellipse)
417    let t_transfer = PI * (a_transfer.powi(3) / mu::SUN.value()).sqrt();
418
419    // Mean motion of destination planet
420    let n2 = kepler::mean_motion(mu::SUN, Km(r2));
421
422    // Angle destination travels during transfer
423    let theta_travel = n2 * t_transfer;
424
425    // Required phase angle: destination must be at (π - θ_travel) ahead
426    Radians(PI - theta_travel).normalize()
427}
428
429/// Hohmann transfer time between two planets (seconds).
430///
431/// Half the orbital period of the transfer ellipse.
432pub fn hohmann_transfer_time(departure: Planet, arrival: Planet) -> Seconds {
433    let r1 = departure.semi_major_axis().value();
434    let r2 = arrival.semi_major_axis().value();
435    let a_transfer = (r1 + r2) / 2.0;
436    Seconds(PI * (a_transfer.powi(3) / mu::SUN.value()).sqrt())
437}
438
439/// Find the next launch window for a Hohmann transfer after a given Julian Date.
440///
441/// Searches forward in time for when the phase angle between departure and
442/// arrival planets matches the required Hohmann phase angle.
443///
444/// Returns the Julian Date of the next launch window, or None if not found
445/// within the search limit (one synodic period + margin).
446pub fn next_hohmann_window(departure: Planet, arrival: Planet, after_jd: f64) -> Option<f64> {
447    let required = hohmann_phase_angle(departure, arrival);
448    let synodic = synodic_period(departure, arrival);
449
450    // Search over one synodic period with 0.1-day steps, then refine
451    let search_days = synodic.value() / SECONDS_PER_DAY * 1.2;
452    let step = 0.1; // days
453
454    let mut best_jd = after_jd;
455    let mut best_diff = f64::MAX;
456
457    let mut jd = after_jd;
458    while jd < after_jd + search_days {
459        let current_phase = phase_angle(departure, arrival, jd);
460        let diff = (current_phase - required).normalize_signed().value().abs();
461        if diff < best_diff {
462            best_diff = diff;
463            best_jd = jd;
464        }
465        jd += step;
466    }
467
468    // Refine with bisection
469    let mut lo = best_jd - step;
470    let mut hi = best_jd + step;
471
472    for _ in 0..60 {
473        let mid = (lo + hi) / 2.0;
474        let phase_lo = phase_angle(departure, arrival, lo);
475        let phase_mid = phase_angle(departure, arrival, mid);
476
477        let diff_lo = (phase_lo - required).normalize_signed().value();
478        let diff_mid = (phase_mid - required).normalize_signed().value();
479
480        if diff_lo * diff_mid <= 0.0 {
481            hi = mid;
482        } else {
483            lo = mid;
484        }
485    }
486
487    let result_jd = (lo + hi) / 2.0;
488    let final_diff = (phase_angle(departure, arrival, result_jd) - required)
489        .normalize_signed()
490        .value()
491        .abs();
492
493    // Accept if within 0.1° (good enough for fiction analysis)
494    if final_diff < 0.1_f64.to_radians() {
495        Some(result_jd)
496    } else {
497        None
498    }
499}
500
501/// Compute the position of a destination planet at arrival, given departure date
502/// and transfer time.
503///
504/// Returns the position at arrival and the phase angle error (how far the planet
505/// actually is from where a Hohmann arrival would need it).
506pub fn arrival_position(
507    arrival_planet: Planet,
508    departure_jd: f64,
509    transfer_time: Seconds,
510) -> PlanetPosition {
511    let arrival_jd = departure_jd + transfer_time.value() / SECONDS_PER_DAY;
512    planet_position(arrival_planet, arrival_jd)
513}
514
515/// Format Julian Date as a human-readable date string (YYYY-MM-DD).
516pub fn jd_to_date_string(jd: f64) -> String {
517    let (year, month, day) = jd_to_calendar(jd);
518    format!("{:04}-{:02}-{:02}", year, month, day.floor() as u32)
519}
520
521/// Elapsed time between two Julian Dates in days.
522pub fn elapsed_days(jd1: f64, jd2: f64) -> f64 {
523    jd2 - jd1
524}
525
526/// Elapsed time between two Julian Dates in hours.
527pub fn elapsed_hours(jd1: f64, jd2: f64) -> f64 {
528    (jd2 - jd1) * 24.0
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    #[test]
535    fn test_calendar_to_jd_j2000() {
536        // J2000.0 = 2000-01-01 12:00:00 TT = JD 2451545.0
537        let jd = calendar_to_jd(2000, 1, 1.5);
538        assert!(
539            (jd - J2000_JD).abs() < 1e-6,
540            "J2000 JD = {}, expected {}",
541            jd,
542            J2000_JD
543        );
544    }
545
546    #[test]
547    fn test_calendar_to_jd_known_dates() {
548        // 1999-12-31 0:00 UT = JD 2451543.5
549        let jd = calendar_to_jd(1999, 12, 31.0);
550        assert!(
551            (jd - 2_451_543.5).abs() < 1e-6,
552            "1999-12-31 JD = {}, expected 2451543.5",
553            jd
554        );
555
556        // Sputnik launch: 1957-10-04 0h UT = JD 2436115.5
557        let jd = calendar_to_jd(1957, 10, 4.0);
558        assert!(
559            (jd - 2_436_115.5).abs() < 1e-4,
560            "Sputnik JD = {}, expected ~2436115.5",
561            jd
562        );
563    }
564
565    #[test]
566    fn test_jd_calendar_round_trip() {
567        let test_dates = [
568            (2000, 1, 1.5),
569            (2024, 6, 15.0),
570            (1990, 3, 21.75),
571            (2100, 12, 31.25),
572        ];
573        for (y, m, d) in test_dates {
574            let jd = calendar_to_jd(y, m, d);
575            let (y2, m2, d2) = jd_to_calendar(jd);
576            assert_eq!(y, y2, "year mismatch for {:04}-{:02}-{}", y, m, d);
577            assert_eq!(m, m2, "month mismatch for {:04}-{:02}-{}", y, m, d);
578            assert!(
579                (d - d2).abs() < 1e-10,
580                "day mismatch for {:04}-{:02}-{}: got {}",
581                y,
582                m,
583                d,
584                d2
585            );
586        }
587    }
588
589    #[test]
590    fn test_planet_position_earth_j2000() {
591        // At J2000, Earth should be near longitude ~100° (mean longitude from elements)
592        let pos = planet_position(Planet::Earth, J2000_JD);
593
594        // Earth should be approximately 1 AU from Sun
595        let dist_au = pos.distance.value() / AU_KM;
596        assert!(
597            (dist_au - 1.0).abs() < 0.02,
598            "Earth distance at J2000 = {} AU, expected ~1.0",
599            dist_au
600        );
601    }
602
603    #[test]
604    fn test_planet_position_mars_distance() {
605        // Mars should be approximately 1.524 AU from Sun (varies with eccentricity)
606        let pos = planet_position(Planet::Mars, J2000_JD);
607        let dist_au = pos.distance.value() / AU_KM;
608        assert!(
609            (dist_au - 1.524).abs() < 0.15,
610            "Mars distance at J2000 = {} AU, expected ~1.524",
611            dist_au
612        );
613    }
614
615    #[test]
616    fn test_planet_position_jupiter_distance() {
617        // Jupiter should be approximately 5.2 AU from Sun
618        let pos = planet_position(Planet::Jupiter, J2000_JD);
619        let dist_au = pos.distance.value() / AU_KM;
620        assert!(
621            (dist_au - 5.2).abs() < 0.3,
622            "Jupiter distance at J2000 = {} AU, expected ~5.2",
623            dist_au
624        );
625    }
626
627    #[test]
628    fn test_planet_position_saturn_distance() {
629        let pos = planet_position(Planet::Saturn, J2000_JD);
630        let dist_au = pos.distance.value() / AU_KM;
631        assert!(
632            (dist_au - 9.54).abs() < 0.5,
633            "Saturn distance at J2000 = {} AU, expected ~9.54",
634            dist_au
635        );
636    }
637
638    #[test]
639    fn test_planet_position_uranus_distance() {
640        let pos = planet_position(Planet::Uranus, J2000_JD);
641        let dist_au = pos.distance.value() / AU_KM;
642        assert!(
643            (dist_au - 19.2).abs() < 1.0,
644            "Uranus distance at J2000 = {} AU, expected ~19.2",
645            dist_au
646        );
647    }
648
649    #[test]
650    fn test_planet_orbital_period_consistency() {
651        // Check that planets complete ~one orbit in their known period
652        // Earth: 365.25 days
653        let jd0 = J2000_JD;
654        let jd1 = jd0 + 365.25;
655        let lon0 = planet_longitude(Planet::Earth, jd0);
656        let lon1 = planet_longitude(Planet::Earth, jd1);
657
658        // After one year, Earth should return to approximately the same longitude
659        let diff = (lon1 - lon0).normalize_signed().value().abs();
660        assert!(
661            diff < 2.0_f64.to_radians(),
662            "Earth longitude after 1 year differs by {:.1}°, expected ~0°",
663            diff.to_degrees()
664        );
665    }
666
667    #[test]
668    fn test_mars_orbital_period() {
669        // Mars: ~686.97 days
670        let jd0 = J2000_JD;
671        let jd1 = jd0 + 686.97;
672        let lon0 = planet_longitude(Planet::Mars, jd0);
673        let lon1 = planet_longitude(Planet::Mars, jd1);
674
675        let diff = (lon1 - lon0).normalize_signed().value().abs();
676        assert!(
677            diff < 3.0_f64.to_radians(),
678            "Mars longitude after 687 days differs by {:.1}°, expected ~0°",
679            diff.to_degrees()
680        );
681    }
682
683    #[test]
684    fn test_jupiter_orbital_period() {
685        // Jupiter: ~4332.59 days (~11.86 years)
686        let jd0 = J2000_JD;
687        let jd1 = jd0 + 4332.59;
688        let lon0 = planet_longitude(Planet::Jupiter, jd0);
689        let lon1 = planet_longitude(Planet::Jupiter, jd1);
690
691        let diff = (lon1 - lon0).normalize_signed().value().abs();
692        assert!(
693            diff < 5.0_f64.to_radians(),
694            "Jupiter longitude after 4333 days differs by {:.1}°, expected ~0°",
695            diff.to_degrees()
696        );
697    }
698
699    #[test]
700    fn test_phase_angle_symmetry() {
701        // phase_angle(A, B) = -phase_angle(B, A)
702        let jd = J2000_JD;
703        let ab = phase_angle(Planet::Earth, Planet::Mars, jd);
704        let ba = phase_angle(Planet::Mars, Planet::Earth, jd);
705
706        assert!(
707            (ab.value() + ba.value()).abs() < 1e-10,
708            "phase_angle symmetry: {} + {} ≠ 0",
709            ab.value(),
710            ba.value()
711        );
712    }
713
714    #[test]
715    fn test_synodic_period_earth_mars() {
716        // Earth-Mars synodic period: ~780 days
717        let synodic = synodic_period(Planet::Earth, Planet::Mars);
718        let days = synodic.value() / SECONDS_PER_DAY;
719        assert!(
720            (days - 780.0).abs() < 10.0,
721            "Earth-Mars synodic period = {:.1} days, expected ~780",
722            days
723        );
724    }
725
726    #[test]
727    fn test_synodic_period_earth_jupiter() {
728        // Earth-Jupiter synodic period: ~398.88 days
729        let synodic = synodic_period(Planet::Earth, Planet::Jupiter);
730        let days = synodic.value() / SECONDS_PER_DAY;
731        assert!(
732            (days - 398.88).abs() < 5.0,
733            "Earth-Jupiter synodic period = {:.1} days, expected ~398.88",
734            days
735        );
736    }
737
738    #[test]
739    fn test_hohmann_phase_angle_earth_mars() {
740        // Earth→Mars Hohmann: required phase angle ~44.4°
741        let phase = hohmann_phase_angle(Planet::Earth, Planet::Mars);
742        let deg = phase.value().to_degrees();
743        assert!(
744            (deg - 44.4).abs() < 2.0,
745            "Earth→Mars Hohmann phase angle = {:.1}°, expected ~44.4°",
746            deg
747        );
748    }
749
750    #[test]
751    fn test_hohmann_transfer_time_earth_mars() {
752        // Earth→Mars Hohmann: ~259 days
753        let time = hohmann_transfer_time(Planet::Earth, Planet::Mars);
754        let days = time.value() / SECONDS_PER_DAY;
755        assert!(
756            (days - 259.0).abs() < 5.0,
757            "Earth→Mars Hohmann time = {:.0} days, expected ~259",
758            days
759        );
760    }
761
762    #[test]
763    fn test_hohmann_transfer_time_earth_jupiter() {
764        // Earth→Jupiter Hohmann: ~997 days
765        let time = hohmann_transfer_time(Planet::Earth, Planet::Jupiter);
766        let days = time.value() / SECONDS_PER_DAY;
767        assert!(
768            (days - 997.0).abs() < 20.0,
769            "Earth→Jupiter Hohmann time = {:.0} days, expected ~997",
770            days
771        );
772    }
773
774    #[test]
775    fn test_next_hohmann_window_finds_window() {
776        // There should be a Mars launch window within one synodic period from any date
777        let window = next_hohmann_window(Planet::Earth, Planet::Mars, J2000_JD);
778        assert!(
779            window.is_some(),
780            "Should find Earth→Mars Hohmann window near J2000"
781        );
782
783        let jd = window.unwrap();
784        // Window should be within one synodic period
785        let synodic_days = synodic_period(Planet::Earth, Planet::Mars).value() / SECONDS_PER_DAY;
786        assert!(
787            jd - J2000_JD < synodic_days * 1.2,
788            "Window at JD {} is too far from J2000 ({:.0} days, synodic = {:.0})",
789            jd,
790            jd - J2000_JD,
791            synodic_days
792        );
793    }
794
795    #[test]
796    fn test_jd_to_date_string() {
797        // J2000.0 = 2000-01-01
798        let s = jd_to_date_string(J2000_JD);
799        assert_eq!(s, "2000-01-01", "J2000 date string");
800
801        // 2024-01-01
802        let jd = calendar_to_jd(2024, 1, 1.0);
803        let s = jd_to_date_string(jd);
804        assert_eq!(s, "2024-01-01");
805    }
806
807    #[test]
808    fn test_elapsed_time() {
809        let jd1 = J2000_JD;
810        let jd2 = J2000_JD + 30.0;
811        assert!((elapsed_days(jd1, jd2) - 30.0).abs() < 1e-10);
812        assert!((elapsed_hours(jd1, jd2) - 720.0).abs() < 1e-8);
813    }
814
815    #[test]
816    fn test_planet_all() {
817        // Verify all planets can have their position computed
818        for planet in &Planet::ALL {
819            let pos = planet_position(*planet, J2000_JD);
820            assert!(pos.distance.value() > 0.0, "{:?} has zero distance", planet);
821            assert!(
822                pos.distance.value().is_finite(),
823                "{:?} distance is not finite",
824                planet
825            );
826        }
827    }
828
829    #[test]
830    fn test_planet_distance_ordering_at_j2000() {
831        // Distances should generally increase with order (on average)
832        // This tests the semi-major axis ordering
833        let planets = [
834            Planet::Mercury,
835            Planet::Venus,
836            Planet::Earth,
837            Planet::Mars,
838            Planet::Jupiter,
839            Planet::Saturn,
840            Planet::Uranus,
841            Planet::Neptune,
842        ];
843
844        for i in 0..planets.len() - 1 {
845            let a1 = planets[i].semi_major_axis().value();
846            let a2 = planets[i + 1].semi_major_axis().value();
847            assert!(
848                a1 < a2,
849                "{:?} semi-major axis ({}) >= {:?} semi-major axis ({})",
850                planets[i],
851                a1,
852                planets[i + 1],
853                a2,
854            );
855        }
856    }
857
858    #[test]
859    fn test_hohmann_phase_angle_mars_jupiter() {
860        // Mars→Jupiter: the required phase angle should be computed
861        let phase = hohmann_phase_angle(Planet::Mars, Planet::Jupiter);
862        let deg = phase.value().to_degrees();
863        // The exact value depends on the orbital radii, but should be reasonable
864        assert!(
865            deg > 0.0 && deg < 360.0,
866            "Mars→Jupiter phase angle = {:.1}°, should be in (0, 360)",
867            deg
868        );
869    }
870
871    #[test]
872    fn test_synodic_period_symmetry() {
873        // synodic_period(A, B) == synodic_period(B, A)
874        let ab = synodic_period(Planet::Earth, Planet::Mars);
875        let ba = synodic_period(Planet::Mars, Planet::Earth);
876        assert!(
877            (ab.value() - ba.value()).abs() < 1e-6,
878            "synodic period asymmetry: {} vs {}",
879            ab.value(),
880            ba.value()
881        );
882    }
883
884    #[test]
885    fn test_mean_elements_earth() {
886        let e = mean_elements(Planet::Earth);
887        // Earth semi-major axis ≈ 1.0 AU
888        assert!(
889            (e.a0 - 1.0).abs() < 0.01,
890            "Earth a0 = {}, expected ~1.0 AU",
891            e.a0
892        );
893        // Earth eccentricity ≈ 0.0167
894        assert!(
895            (e.e0 - 0.0167).abs() < 0.01,
896            "Earth e0 = {}, expected ~0.0167",
897            e.e0
898        );
899        // Earth inclination to ecliptic ≈ 0° (by definition)
900        assert!(e.i0.abs() < 0.01, "Earth i0 = {}, expected ~0°", e.i0);
901    }
902
903    #[test]
904    fn test_mean_elements_all_planets_sane() {
905        for planet in Planet::ALL {
906            let e = mean_elements(planet);
907            // Semi-major axis must be positive
908            assert!(
909                e.a0 > 0.0,
910                "{:?}: a0 must be positive, got {}",
911                planet,
912                e.a0
913            );
914            // Eccentricity in [0, 1)
915            assert!(
916                e.e0 >= 0.0 && e.e0 < 1.0,
917                "{:?}: eccentricity must be in [0,1), got {}",
918                planet,
919                e.e0
920            );
921            // Inclination in [0, 180)
922            assert!(
923                e.i0.abs() < 180.0,
924                "{:?}: inclination out of range: {}",
925                planet,
926                e.i0
927            );
928            // Mean longitude rate must be positive (prograde)
929            assert!(
930                e.l_dot > 0.0,
931                "{:?}: mean longitude rate should be positive, got {}",
932                planet,
933                e.l_dot
934            );
935        }
936    }
937
938    #[test]
939    fn test_mean_elements_ordering() {
940        // Planets further from Sun have larger semi-major axes
941        let mercury = mean_elements(Planet::Mercury);
942        let venus = mean_elements(Planet::Venus);
943        let earth = mean_elements(Planet::Earth);
944        let mars = mean_elements(Planet::Mars);
945        let jupiter = mean_elements(Planet::Jupiter);
946        let saturn = mean_elements(Planet::Saturn);
947        let uranus = mean_elements(Planet::Uranus);
948        let neptune = mean_elements(Planet::Neptune);
949
950        assert!(mercury.a0 < venus.a0);
951        assert!(venus.a0 < earth.a0);
952        assert!(earth.a0 < mars.a0);
953        assert!(mars.a0 < jupiter.a0);
954        assert!(jupiter.a0 < saturn.a0);
955        assert!(saturn.a0 < uranus.a0);
956        assert!(uranus.a0 < neptune.a0);
957    }
958
959    #[test]
960    fn test_mean_elements_known_values() {
961        // Mars semi-major axis ≈ 1.524 AU
962        let mars = mean_elements(Planet::Mars);
963        assert!(
964            (mars.a0 - 1.524).abs() < 0.01,
965            "Mars a0 = {}, expected ~1.524",
966            mars.a0
967        );
968        // Jupiter semi-major axis ≈ 5.203 AU
969        let jup = mean_elements(Planet::Jupiter);
970        assert!(
971            (jup.a0 - 5.203).abs() < 0.01,
972            "Jupiter a0 = {}, expected ~5.203",
973            jup.a0
974        );
975        // Neptune semi-major axis ≈ 30.07 AU
976        let nep = mean_elements(Planet::Neptune);
977        assert!(
978            (nep.a0 - 30.07).abs() < 0.1,
979            "Neptune a0 = {}, expected ~30.07",
980            nep.a0
981        );
982    }
983
984    #[test]
985    fn test_arrival_position_mars_after_hohmann() {
986        // Departure from Earth at J2000, ~259 day Hohmann to Mars
987        let jd_j2000 = 2_451_545.0;
988        let transfer_time = Seconds(259.0 * 86400.0);
989        let pos = arrival_position(Planet::Mars, jd_j2000, transfer_time);
990
991        // Mars should be at some valid heliocentric position (in km)
992        let dist_km = (pos.x * pos.x + pos.y * pos.y).sqrt();
993        let dist_au = dist_km / AU_KM;
994        assert!(
995            dist_au > 1.3 && dist_au < 1.7,
996            "Mars distance should be 1.3-1.7 AU, got {} AU",
997            dist_au
998        );
999    }
1000
1001    #[test]
1002    fn test_arrival_position_equals_planet_position_at_arrival_jd() {
1003        // arrival_position should equal planet_position at departure_jd + transfer_time/86400
1004        let jd = 2_460_000.0;
1005        let transfer = Seconds(100.0 * 86400.0);
1006        let arrival_jd = jd + 100.0;
1007
1008        let pos_via_arrival = arrival_position(Planet::Jupiter, jd, transfer);
1009        let pos_via_direct = planet_position(Planet::Jupiter, arrival_jd);
1010
1011        assert!(
1012            (pos_via_arrival.x - pos_via_direct.x).abs() < 1e-10,
1013            "x mismatch: {} vs {}",
1014            pos_via_arrival.x,
1015            pos_via_direct.x
1016        );
1017        assert!(
1018            (pos_via_arrival.y - pos_via_direct.y).abs() < 1e-10,
1019            "y mismatch: {} vs {}",
1020            pos_via_arrival.y,
1021            pos_via_direct.y
1022        );
1023    }
1024
1025    #[test]
1026    fn test_jd_calendar_round_trip_solar_line_epoch() {
1027        // calendar_to_jd → jd_to_calendar should recover original date
1028        // Using SOLAR LINE epoch (2215) to exercise far-future dates
1029        let (year, month, day) = (2215, 1, 15.5);
1030        let jd = calendar_to_jd(year, month, day);
1031        let (y2, m2, d2) = jd_to_calendar(jd);
1032        assert_eq!(y2, year, "year mismatch");
1033        assert_eq!(m2, month, "month mismatch");
1034        assert!((d2 - day).abs() < 1e-6, "day mismatch: {d2} vs {day}");
1035
1036        // Also verify a known date: J2000.0 = 2000-01-01T12:00
1037        let j2000 = calendar_to_jd(2000, 1, 1.5);
1038        assert!(
1039            (j2000 - J2000_JD).abs() < 1e-6,
1040            "J2000 mismatch: {j2000} vs {J2000_JD}"
1041        );
1042    }
1043
1044    #[test]
1045    fn test_synodic_periods_known_values() {
1046        // Earth-Mars synodic period ≈ 780 days (well-known value)
1047        let synodic = synodic_period(Planet::Earth, Planet::Mars);
1048        let days = synodic.value() / 86400.0;
1049        assert!(
1050            (days - 780.0).abs() < 10.0,
1051            "Earth-Mars synodic period: {days:.1} days (expected ~780)"
1052        );
1053
1054        // Earth-Jupiter synodic period ≈ 399 days
1055        let synodic_j = synodic_period(Planet::Earth, Planet::Jupiter);
1056        let days_j = synodic_j.value() / 86400.0;
1057        assert!(
1058            (days_j - 399.0).abs() < 5.0,
1059            "Earth-Jupiter synodic period: {days_j:.1} days (expected ~399)"
1060        );
1061    }
1062}