solar_line_core/
kepler.rs

1/// Kepler equation solver and anomaly conversions.
2///
3/// The Kepler equation relates mean anomaly M to eccentric anomaly E:
4///   M = E - e * sin(E)     (elliptical, e < 1)
5///
6/// This module provides Newton-Raphson iteration to solve for E given M,
7/// and conversions between mean anomaly, eccentric anomaly, and true anomaly.
8use crate::units::{Eccentricity, Mu, Radians, Seconds};
9
10/// Result of Kepler equation solver.
11#[derive(Debug, Clone, Copy)]
12pub struct KeplerSolution {
13    /// Eccentric anomaly (radians)
14    pub eccentric_anomaly: Radians,
15    /// Number of iterations used
16    pub iterations: u32,
17    /// Final residual |M - (E - e*sin(E))|
18    pub residual: f64,
19}
20
21/// Default tolerance for Kepler equation solver
22const DEFAULT_TOL: f64 = 1e-14;
23
24/// Default maximum iterations for Kepler equation solver
25const DEFAULT_MAX_ITER: u32 = 50;
26
27/// Solve Kepler's equation M = E - e*sin(E) for eccentric anomaly E.
28///
29/// Uses Newton-Raphson iteration with a smart initial guess.
30///
31/// # Arguments
32/// * `mean_anomaly` - Mean anomaly M (radians)
33/// * `e` - Eccentricity (must be < 1 for elliptical orbits)
34///
35/// # Returns
36/// `Ok(KeplerSolution)` on convergence, `Err` message if max iterations exceeded.
37///
38/// # Assumptions
39/// - Elliptical orbit only (e < 1). For hyperbolic orbits, use a different solver.
40pub fn solve_kepler(mean_anomaly: Radians, e: Eccentricity) -> Result<KeplerSolution, String> {
41    solve_kepler_with_params(mean_anomaly, e, DEFAULT_TOL, DEFAULT_MAX_ITER)
42}
43
44/// Solve Kepler's equation with custom tolerance and iteration limit.
45pub fn solve_kepler_with_params(
46    mean_anomaly: Radians,
47    e: Eccentricity,
48    tol: f64,
49    max_iter: u32,
50) -> Result<KeplerSolution, String> {
51    if !e.is_elliptical() {
52        return Err(format!(
53            "Kepler solver requires elliptical orbit (e < 1), got e={}",
54            e.value()
55        ));
56    }
57
58    let m = mean_anomaly.normalize().value();
59    let ecc = e.value();
60
61    // Initial guess: E₀ = M + e*sin(M) (good for low eccentricity)
62    // For higher eccentricity, use a better initial guess
63    let mut big_e = if ecc < 0.8 {
64        m + ecc * m.sin()
65    } else {
66        // For high eccentricity, start from π when M is near π
67        std::f64::consts::PI
68    };
69
70    for i in 0..max_iter {
71        let sin_e = big_e.sin();
72        let cos_e = big_e.cos();
73        let f = big_e - ecc * sin_e - m;
74        let f_prime = 1.0 - ecc * cos_e;
75
76        // Guard against near-zero derivative (shouldn't happen for e < 1)
77        if f_prime.abs() < 1e-30 {
78            return Err("Kepler solver: derivative near zero".to_string());
79        }
80
81        let delta = f / f_prime;
82        big_e -= delta;
83
84        if delta.abs() < tol {
85            let residual = (big_e - ecc * big_e.sin() - m).abs();
86            return Ok(KeplerSolution {
87                eccentric_anomaly: Radians(big_e),
88                iterations: i + 1,
89                residual,
90            });
91        }
92    }
93
94    Err(format!(
95        "Kepler solver did not converge after {} iterations (e={}, M={})",
96        max_iter, ecc, m
97    ))
98}
99
100/// Convert eccentric anomaly to true anomaly.
101///
102/// tan(ν/2) = sqrt((1+e)/(1-e)) * tan(E/2)
103pub fn eccentric_to_true_anomaly(big_e: Radians, e: Eccentricity) -> Radians {
104    let ecc = e.value();
105    let half_e = big_e.value() / 2.0;
106    let half_nu = ((1.0 + ecc) / (1.0 - ecc)).sqrt() * half_e.tan();
107    Radians(2.0 * half_nu.atan()).normalize()
108}
109
110/// Convert true anomaly to eccentric anomaly.
111///
112/// tan(E/2) = sqrt((1-e)/(1+e)) * tan(ν/2)
113pub fn true_to_eccentric_anomaly(nu: Radians, e: Eccentricity) -> Radians {
114    let ecc = e.value();
115    let half_nu = nu.value() / 2.0;
116    let half_e = ((1.0 - ecc) / (1.0 + ecc)).sqrt() * half_nu.tan();
117    Radians(2.0 * half_e.atan()).normalize()
118}
119
120/// Convert eccentric anomaly to mean anomaly.
121///
122/// M = E - e*sin(E)
123pub fn eccentric_to_mean_anomaly(big_e: Radians, e: Eccentricity) -> Radians {
124    Radians(big_e.value() - e.value() * big_e.sin()).normalize()
125}
126
127/// Convert true anomaly to mean anomaly (via eccentric anomaly).
128pub fn true_to_mean_anomaly(nu: Radians, e: Eccentricity) -> Radians {
129    let big_e = true_to_eccentric_anomaly(nu, e);
130    eccentric_to_mean_anomaly(big_e, e)
131}
132
133/// Convert mean anomaly to true anomaly (via Kepler equation solver).
134pub fn mean_to_true_anomaly(mean_anomaly: Radians, e: Eccentricity) -> Result<Radians, String> {
135    let solution = solve_kepler(mean_anomaly, e)?;
136    Ok(eccentric_to_true_anomaly(solution.eccentric_anomaly, e))
137}
138
139/// Compute mean anomaly at a future time given initial mean anomaly,
140/// mean motion n, and elapsed time Δt.
141///
142/// M(t) = M₀ + n * Δt
143///
144/// Mean motion n = sqrt(μ/a³)
145pub fn mean_motion(mu: Mu, a: crate::units::Km) -> f64 {
146    (mu.value() / a.value().powi(3)).sqrt()
147}
148
149/// Propagate mean anomaly forward by Δt seconds.
150pub fn propagate_mean_anomaly(m0: Radians, n: f64, dt: Seconds) -> Radians {
151    Radians(m0.value() + n * dt.value()).normalize()
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use std::f64::consts::{PI, TAU};
158
159    #[test]
160    fn test_kepler_circular_orbit() {
161        // For e=0, E = M exactly
162        let e = Eccentricity::elliptical(0.0).unwrap();
163        let m = Radians(1.5);
164        let sol = solve_kepler(m, e).unwrap();
165        assert!(
166            (sol.eccentric_anomaly.value() - 1.5).abs() < 1e-14,
167            "E={}, expected 1.5",
168            sol.eccentric_anomaly.value()
169        );
170        assert_eq!(sol.iterations, 1); // Should converge immediately
171    }
172
173    #[test]
174    fn test_kepler_low_eccentricity() {
175        // Earth-like orbit: e ≈ 0.0167
176        let e = Eccentricity::elliptical(0.0167).unwrap();
177        let m = Radians(PI / 4.0);
178        let sol = solve_kepler(m, e).unwrap();
179
180        // Verify: M = E - e*sin(E)
181        let big_e = sol.eccentric_anomaly.value();
182        let residual = (big_e - e.value() * big_e.sin() - (PI / 4.0)).abs();
183        assert!(
184            residual < 1e-14,
185            "residual = {}, expected < 1e-14",
186            residual
187        );
188    }
189
190    #[test]
191    fn test_kepler_moderate_eccentricity() {
192        // Mars-like orbit: e ≈ 0.0934
193        let e = Eccentricity::elliptical(0.0934).unwrap();
194        let m = Radians(2.0);
195        let sol = solve_kepler(m, e).unwrap();
196
197        let big_e = sol.eccentric_anomaly.value();
198        let residual = (big_e - e.value() * big_e.sin() - 2.0).abs();
199        assert!(
200            residual < 1e-14,
201            "residual = {}, expected < 1e-14",
202            residual
203        );
204    }
205
206    #[test]
207    fn test_kepler_high_eccentricity() {
208        // Comet-like orbit: e ≈ 0.967 (Halley's comet)
209        let e = Eccentricity::elliptical(0.967).unwrap();
210        for m_val in [0.1, 0.5, 1.0, PI / 2.0, PI, 5.0] {
211            let m = Radians(m_val);
212            let sol = solve_kepler(m, e).unwrap();
213
214            let big_e = sol.eccentric_anomaly.value();
215            let m_norm = m.normalize().value();
216            let residual = (big_e - e.value() * big_e.sin() - m_norm).abs();
217            assert!(
218                residual < 1e-12,
219                "e=0.967, M={}: residual = {}",
220                m_val,
221                residual
222            );
223        }
224    }
225
226    #[test]
227    fn test_kepler_rejects_hyperbolic() {
228        let e = Eccentricity::new(1.5).unwrap();
229        let result = solve_kepler(Radians(1.0), e);
230        assert!(result.is_err());
231    }
232
233    #[test]
234    fn test_anomaly_round_trip_true_eccentric() {
235        let e = Eccentricity::elliptical(0.3).unwrap();
236        let nu = Radians(1.2);
237        let big_e = true_to_eccentric_anomaly(nu, e);
238        let nu_back = eccentric_to_true_anomaly(big_e, e);
239        assert!(
240            (nu.normalize().value() - nu_back.value()).abs() < 1e-12,
241            "round trip failed: {} -> {} -> {}",
242            nu.value(),
243            big_e.value(),
244            nu_back.value()
245        );
246    }
247
248    #[test]
249    fn test_anomaly_round_trip_mean_eccentric() {
250        let e = Eccentricity::elliptical(0.5).unwrap();
251        let big_e = Radians(1.0);
252        let m = eccentric_to_mean_anomaly(big_e, e);
253        let sol = solve_kepler(m, e).unwrap();
254        assert!(
255            (sol.eccentric_anomaly.value() - big_e.value()).abs() < 1e-12,
256            "round trip failed: E={} -> M={} -> E={}",
257            big_e.value(),
258            m.value(),
259            sol.eccentric_anomaly.value()
260        );
261    }
262
263    #[test]
264    fn test_full_anomaly_round_trip() {
265        // ν → E → M → (solve Kepler) → E → ν
266        let e = Eccentricity::elliptical(0.2).unwrap();
267        let nu_original = Radians(2.5);
268        let m = true_to_mean_anomaly(nu_original, e);
269        let nu_recovered = mean_to_true_anomaly(m, e).unwrap();
270        assert!(
271            (nu_original.normalize().value() - nu_recovered.value()).abs() < 1e-11,
272            "full round trip failed: {} -> {} -> {}",
273            nu_original.value(),
274            m.value(),
275            nu_recovered.value()
276        );
277    }
278
279    #[test]
280    fn test_mean_motion_earth() {
281        // Earth mean motion around Sun: n = 2π / (365.25 * 86400) ≈ 1.991e-7 rad/s
282        let n = mean_motion(
283            crate::constants::mu::SUN,
284            crate::constants::orbit_radius::EARTH,
285        );
286        let expected = TAU / (365.25 * 86400.0);
287        assert!(
288            (n - expected).abs() / expected < 0.002, // within 0.2%
289            "n = {}, expected ≈ {}",
290            n,
291            expected
292        );
293    }
294
295    #[test]
296    fn test_propagate_half_orbit() {
297        // Start at M=0, propagate half an orbit → M should be ~π
298        let n = TAU / 3600.0; // period = 3600 s for simplicity
299        let m0 = Radians(0.0);
300        let dt = crate::units::Seconds(1800.0); // half period
301        let m1 = propagate_mean_anomaly(m0, n, dt);
302        assert!(
303            (m1.value() - PI).abs() < 1e-12,
304            "M after half orbit = {}, expected π",
305            m1.value()
306        );
307    }
308
309    #[test]
310    fn test_kepler_known_value() {
311        // Known test case from Vallado "Fundamentals of Astrodynamics"
312        // e = 0.4, M = 235.4° = 4.1102 rad
313        // Expected E ≈ 220.512° = 3.8489 rad (approximately)
314        let e = Eccentricity::elliptical(0.4).unwrap();
315        let m = Radians(235.4_f64.to_radians());
316        let sol = solve_kepler(m, e).unwrap();
317
318        // Verify the solution satisfies Kepler's equation
319        let big_e = sol.eccentric_anomaly.value();
320        let m_normalized = m.normalize().value();
321        let residual = (big_e - e.value() * big_e.sin() - m_normalized).abs();
322        assert!(
323            residual < 1e-14,
324            "Vallado test case: residual = {}",
325            residual
326        );
327
328        // E should be around 3.85 rad (220.5°)
329        let e_deg = sol.eccentric_anomaly.value().to_degrees();
330        assert!(
331            (e_deg - 220.5).abs() < 1.0,
332            "E = {}°, expected ~220.5°",
333            e_deg
334        );
335    }
336
337    // --- Edge case tests ---
338
339    #[test]
340    fn kepler_m_zero() {
341        // M = 0 → E = 0 for any eccentricity
342        let e = Eccentricity::elliptical(0.5).unwrap();
343        let sol = solve_kepler(Radians(0.0), e).unwrap();
344        assert!(
345            sol.eccentric_anomaly.value().abs() < 1e-14,
346            "E = {}, expected 0",
347            sol.eccentric_anomaly.value()
348        );
349    }
350
351    #[test]
352    fn kepler_m_pi() {
353        // M = π → E = π for any eccentricity (by symmetry)
354        let e = Eccentricity::elliptical(0.5).unwrap();
355        let sol = solve_kepler(Radians(PI), e).unwrap();
356        assert!(
357            (sol.eccentric_anomaly.value() - PI).abs() < 1e-13,
358            "E = {}, expected π",
359            sol.eccentric_anomaly.value()
360        );
361    }
362
363    #[test]
364    fn kepler_m_two_pi_normalizes() {
365        // M = 2π should normalize to M = 0 → E = 0
366        let e = Eccentricity::elliptical(0.3).unwrap();
367        let sol = solve_kepler(Radians(TAU), e).unwrap();
368        assert!(
369            sol.eccentric_anomaly.value().abs() < 1e-12
370                || (sol.eccentric_anomaly.value() - TAU).abs() < 1e-12,
371            "E = {}, expected ~0 or ~2π",
372            sol.eccentric_anomaly.value()
373        );
374    }
375
376    #[test]
377    fn kepler_near_parabolic() {
378        // Very high eccentricity, e = 0.999 — tests the high-e branch
379        let e = Eccentricity::elliptical(0.999).unwrap();
380        for m_val in [0.01, 0.5, PI / 2.0, PI] {
381            let m = Radians(m_val);
382            let sol = solve_kepler(m, e).unwrap();
383            let big_e = sol.eccentric_anomaly.value();
384            let m_norm = m.normalize().value();
385            let residual = (big_e - e.value() * big_e.sin() - m_norm).abs();
386            assert!(
387                residual < 1e-10,
388                "e=0.999, M={}: residual = {}",
389                m_val,
390                residual
391            );
392        }
393    }
394
395    #[test]
396    fn kepler_with_params_tight_tolerance() {
397        let e = Eccentricity::elliptical(0.5).unwrap();
398        let m = Radians(1.0);
399        let sol = solve_kepler_with_params(m, e, 1e-15, 100).unwrap();
400        assert!(sol.residual < 1e-14, "residual = {}", sol.residual);
401    }
402
403    #[test]
404    fn kepler_with_params_few_iterations() {
405        // With only 1 iteration and low eccentricity, should still converge
406        let e = Eccentricity::elliptical(0.0).unwrap();
407        let sol = solve_kepler_with_params(Radians(1.0), e, 1e-10, 1).unwrap();
408        assert_eq!(sol.iterations, 1);
409    }
410
411    #[test]
412    fn kepler_with_params_insufficient_iterations() {
413        // High eccentricity with only 2 iterations should fail
414        let e = Eccentricity::elliptical(0.99).unwrap();
415        let result = solve_kepler_with_params(Radians(0.1), e, 1e-15, 2);
416        assert!(result.is_err(), "should fail with too few iterations");
417    }
418
419    #[test]
420    fn kepler_rejects_parabolic() {
421        let e = Eccentricity::new(1.0).unwrap();
422        let result = solve_kepler(Radians(1.0), e);
423        assert!(result.is_err());
424    }
425
426    #[test]
427    fn anomaly_conversions_at_periapsis() {
428        // At periapsis: ν = 0, E = 0, M = 0
429        let e = Eccentricity::elliptical(0.5).unwrap();
430        let big_e = true_to_eccentric_anomaly(Radians(0.0), e);
431        assert!(big_e.value().abs() < 1e-14, "E = {}", big_e.value());
432        let m = eccentric_to_mean_anomaly(big_e, e);
433        assert!(m.value().abs() < 1e-14, "M = {}", m.value());
434    }
435
436    #[test]
437    fn anomaly_conversions_at_apoapsis() {
438        // At apoapsis: ν = π, E = π, M = π
439        let e = Eccentricity::elliptical(0.5).unwrap();
440        let big_e = true_to_eccentric_anomaly(Radians(PI), e);
441        assert!((big_e.value() - PI).abs() < 1e-12, "E = {}", big_e.value());
442        let m = eccentric_to_mean_anomaly(big_e, e);
443        assert!((m.value() - PI).abs() < 1e-12, "M = {}", m.value());
444    }
445
446    #[test]
447    fn propagate_full_orbit() {
448        // Start at M=0, propagate full period → M should return to 0
449        let n = TAU / 3600.0;
450        let m0 = Radians(0.0);
451        let dt = Seconds(3600.0); // full period
452        let m1 = propagate_mean_anomaly(m0, n, dt);
453        // Should normalize to 0 (or very close)
454        assert!(
455            m1.value().abs() < 1e-12 || (m1.value() - TAU).abs() < 1e-12,
456            "M after full orbit = {}",
457            m1.value()
458        );
459    }
460
461    #[test]
462    fn mean_motion_mars() {
463        // Mars orbital period ≈ 687 days
464        let n = mean_motion(
465            crate::constants::mu::SUN,
466            crate::constants::orbit_radius::MARS,
467        );
468        let period = TAU / n;
469        let period_days = period / 86400.0;
470        assert!(
471            (period_days - 687.0).abs() < 2.0,
472            "Mars period = {} days, expected ~687",
473            period_days
474        );
475    }
476
477    #[test]
478    fn full_round_trip_high_eccentricity() {
479        // ν → E → M → solve → E → ν at e = 0.9
480        let e = Eccentricity::elliptical(0.9).unwrap();
481        for nu_val in [0.5, 1.0, 2.0, 3.0, 5.0] {
482            let nu_orig = Radians(nu_val);
483            let m = true_to_mean_anomaly(nu_orig, e);
484            let nu_rec = mean_to_true_anomaly(m, e).unwrap();
485            let diff = (nu_orig.normalize().value() - nu_rec.value()).abs();
486            assert!(
487                diff < 1e-10 || (diff - TAU).abs() < 1e-10,
488                "e=0.9, ν={}: diff = {}",
489                nu_val,
490                diff
491            );
492        }
493    }
494}