1use crate::units::{Eccentricity, Mu, Radians, Seconds};
9
10#[derive(Debug, Clone, Copy)]
12pub struct KeplerSolution {
13 pub eccentric_anomaly: Radians,
15 pub iterations: u32,
17 pub residual: f64,
19}
20
21const DEFAULT_TOL: f64 = 1e-14;
23
24const DEFAULT_MAX_ITER: u32 = 50;
26
27pub 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
44pub 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 let mut big_e = if ecc < 0.8 {
64 m + ecc * m.sin()
65 } else {
66 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 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
100pub 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
110pub 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
120pub fn eccentric_to_mean_anomaly(big_e: Radians, e: Eccentricity) -> Radians {
124 Radians(big_e.value() - e.value() * big_e.sin()).normalize()
125}
126
127pub 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
133pub 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
139pub fn mean_motion(mu: Mu, a: crate::units::Km) -> f64 {
146 (mu.value() / a.value().powi(3)).sqrt()
147}
148
149pub 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 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); }
172
173 #[test]
174 fn test_kepler_low_eccentricity() {
175 let e = Eccentricity::elliptical(0.0167).unwrap();
177 let m = Radians(PI / 4.0);
178 let sol = solve_kepler(m, e).unwrap();
179
180 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 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 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 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 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, "n = {}, expected ≈ {}",
290 n,
291 expected
292 );
293 }
294
295 #[test]
296 fn test_propagate_half_orbit() {
297 let n = TAU / 3600.0; let m0 = Radians(0.0);
300 let dt = crate::units::Seconds(1800.0); 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 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 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 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 #[test]
340 fn kepler_m_zero() {
341 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 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 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 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 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 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 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 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 let n = TAU / 3600.0;
450 let m0 = Radians(0.0);
451 let dt = Seconds(3600.0); let m1 = propagate_mean_anomaly(m0, n, dt);
453 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 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 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}