1use crate::astro::elements::{ClassicalElements, OrbitType};
9use crate::astro::math::{normalize_angle, wrap_to_pi, SMALL};
10
11const PI: f64 = std::f64::consts::PI;
12const TOL_ABS: f64 = 1.0e-12;
13const TOL_REL: f64 = 1.0e-14;
14const MAX_ITER: usize = 50;
15
16#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
17pub enum AnomalyError {
18 #[error("non-finite input {field}")]
19 NonFinite { field: &'static str },
20 #[error("eccentricity must be non-negative")]
21 NegativeEccentricity,
22 #[error("mu must be positive")]
23 NonPositiveMu,
24 #[error("semi-latus rectum must be positive")]
25 NonPositiveSemiLatus,
26 #[error("true anomaly {nu} is beyond the open-orbit asymptote {limit}")]
27 BeyondAsymptote { nu: f64, limit: f64 },
28 #[error("inconsistent element {field}")]
29 InconsistentElements { field: &'static str },
30 #[error("Kepler solve did not converge in {iterations} iters (residual {residual})")]
31 NonConvergent { iterations: usize, residual: f64 },
32}
33
34#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct KeplerSolution {
36 pub anomaly: f64,
37 pub iterations: usize,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41enum Regime {
42 Elliptic,
43 Parabolic,
44 Hyperbolic,
45}
46
47pub fn solve_kepler(mean_anom: f64, ecc: f64) -> Result<KeplerSolution, AnomalyError> {
48 check_finite(mean_anom, "mean_anom")?;
49 validate_ecc(ecc)?;
50
51 match regime(ecc) {
52 Regime::Elliptic => solve_iterative(
53 normalize_angle(mean_anom),
54 ecc,
55 Regime::Elliptic,
56 elliptic_seed(mean_anom, ecc),
57 ),
58 Regime::Parabolic => Ok(KeplerSolution {
59 anomaly: barker_d_from_mean(mean_anom),
60 iterations: 0,
61 }),
62 Regime::Hyperbolic => solve_iterative(
63 mean_anom,
64 ecc,
65 Regime::Hyperbolic,
66 (mean_anom / ecc).asinh(),
67 ),
68 }
69}
70
71pub fn mean_to_eccentric(mean_anom: f64, ecc: f64) -> Result<f64, AnomalyError> {
72 Ok(solve_kepler(mean_anom, ecc)?.anomaly)
73}
74
75pub fn eccentric_to_mean(ecc_anom: f64, ecc: f64) -> Result<f64, AnomalyError> {
76 check_finite(ecc_anom, "ecc_anom")?;
77 validate_ecc(ecc)?;
78
79 match regime(ecc) {
80 Regime::Elliptic => {
81 let anomaly = normalize_angle(ecc_anom);
82 Ok(normalize_angle(anomaly - ecc * anomaly.sin()))
83 }
84 Regime::Parabolic => Ok(ecc_anom + ecc_anom.powi(3) / 3.0),
85 Regime::Hyperbolic => Ok(ecc * ecc_anom.sinh() - ecc_anom),
86 }
87}
88
89pub fn eccentric_to_true(ecc_anom: f64, ecc: f64) -> Result<f64, AnomalyError> {
90 check_finite(ecc_anom, "ecc_anom")?;
91 validate_ecc(ecc)?;
92
93 match regime(ecc) {
94 Regime::Elliptic => {
95 let anomaly = normalize_angle(ecc_anom);
96 let sin_nu = ((1.0 - ecc) * (1.0 + ecc)).sqrt() * anomaly.sin();
97 let cos_nu = anomaly.cos() - ecc;
98 Ok(normalize_angle(sin_nu.atan2(cos_nu)))
99 }
100 Regime::Parabolic => Ok(normalize_angle(2.0 * ecc_anom.atan())),
101 Regime::Hyperbolic => {
102 let sin_nu = ((ecc - 1.0) * (ecc + 1.0)).sqrt() * ecc_anom.sinh();
103 let cos_nu = ecc - ecc_anom.cosh();
104 Ok(normalize_angle(sin_nu.atan2(cos_nu)))
105 }
106 }
107}
108
109pub fn true_to_eccentric(true_anom: f64, ecc: f64) -> Result<f64, AnomalyError> {
110 check_finite(true_anom, "true_anom")?;
111 validate_ecc(ecc)?;
112
113 match regime(ecc) {
114 Regime::Elliptic => {
115 let nu = normalize_angle(true_anom);
116 let half_nu = 0.5 * nu;
117 let s = (1.0 - ecc).sqrt() * half_nu.sin();
118 let c = (1.0 + ecc).sqrt() * half_nu.cos();
119 let sin_e = 2.0 * s * c;
120 let cos_e = c * c - s * s;
121 Ok(normalize_angle(sin_e.atan2(cos_e)))
122 }
123 Regime::Parabolic => {
124 check_open_true_anomaly(true_anom, PI)?;
125 Ok((0.5 * wrap_to_pi(true_anom)).tan())
126 }
127 Regime::Hyperbolic => {
128 let limit = (-1.0 / ecc).acos();
129 check_open_true_anomaly(true_anom, limit)?;
130 let nu = wrap_to_pi(true_anom);
131 Ok((nu.sin() * ((ecc - 1.0) * (ecc + 1.0)).sqrt() / (1.0 + ecc * nu.cos())).asinh())
132 }
133 }
134}
135
136pub fn mean_to_true(mean_anom: f64, ecc: f64) -> Result<f64, AnomalyError> {
137 let ecc_anom = mean_to_eccentric(mean_anom, ecc)?;
138 eccentric_to_true(ecc_anom, ecc)
139}
140
141pub fn true_to_mean(true_anom: f64, ecc: f64) -> Result<f64, AnomalyError> {
142 let ecc_anom = true_to_eccentric(true_anom, ecc)?;
143 eccentric_to_mean(ecc_anom, ecc)
144}
145
146pub fn propagate_kepler(
147 elements: &ClassicalElements,
148 mu: f64,
149 dt: f64,
150) -> Result<ClassicalElements, AnomalyError> {
151 validate_propagation_inputs(elements, mu, dt)?;
152
153 let mut out = *elements;
154 let conic = regime(elements.ecc);
155
156 match elements.orbit_type {
157 OrbitType::CircularInclined => {
158 let mean_motion = (mu / elements.a.powi(3)).sqrt();
159 out.arglat = normalize_angle(elements.arglat + mean_motion * dt);
160 }
161 OrbitType::CircularEquatorial => {
162 let mean_motion = (mu / elements.a.powi(3)).sqrt();
163 out.truelon = normalize_angle(elements.truelon + mean_motion * dt);
164 }
165 OrbitType::EllipticalInclined | OrbitType::EllipticalEquatorial => {
166 let mean0 = true_to_mean(elements.nu, elements.ecc)?;
167 let mean1 = match conic {
168 Regime::Elliptic => {
169 let mean_motion = (mu / elements.a.powi(3)).sqrt();
170 normalize_angle(mean0 + mean_motion * dt)
171 }
172 Regime::Parabolic => {
173 let mean_motion = (mu / elements.p.powi(3)).sqrt();
174 mean0 + mean_motion * dt
175 }
176 Regime::Hyperbolic => {
177 let mean_motion = (mu / (-elements.a).powi(3)).sqrt();
178 mean0 + mean_motion * dt
179 }
180 };
181 out.nu = mean_to_true(mean1, elements.ecc)?;
182 }
183 }
184
185 Ok(out)
186}
187
188fn elliptic_seed(mean_anom: f64, ecc: f64) -> f64 {
189 let mean = normalize_angle(mean_anom);
190 if ecc < 0.8 {
191 mean + ecc * mean.sin()
192 } else {
193 PI
194 }
195}
196
197fn solve_iterative(
198 mean_anom: f64,
199 ecc: f64,
200 conic: Regime,
201 seed: f64,
202) -> Result<KeplerSolution, AnomalyError> {
203 let mut anomaly = seed;
204 let tolerance = TOL_ABS + TOL_REL * mean_anom.abs();
205 let mut residual = f64::NAN;
206
207 for iterations in 1..=MAX_ITER {
208 let (f, fp, fpp) = residuals(anomaly, mean_anom, ecc, conic);
209 residual = f;
210 if f.abs() <= tolerance {
211 return Ok(KeplerSolution {
212 anomaly: if conic == Regime::Elliptic {
213 normalize_angle(anomaly)
214 } else {
215 anomaly
216 },
217 iterations,
218 });
219 }
220
221 let denom = 2.0 * fp * fp - f * fpp;
222 let step = if denom.is_finite() && denom.abs() > f64::EPSILON {
223 2.0 * f * fp / denom
224 } else {
225 f / fp
226 };
227 anomaly -= step;
228 }
229
230 Err(AnomalyError::NonConvergent {
231 iterations: MAX_ITER,
232 residual,
233 })
234}
235
236fn residuals(anomaly: f64, mean_anom: f64, ecc: f64, conic: Regime) -> (f64, f64, f64) {
237 match conic {
238 Regime::Elliptic => (
239 anomaly - ecc * anomaly.sin() - mean_anom,
240 1.0 - ecc * anomaly.cos(),
241 ecc * anomaly.sin(),
242 ),
243 Regime::Hyperbolic => (
244 ecc * anomaly.sinh() - anomaly - mean_anom,
245 ecc * anomaly.cosh() - 1.0,
246 ecc * anomaly.sinh(),
247 ),
248 Regime::Parabolic => unreachable!(),
249 }
250}
251
252fn barker_d_from_mean(mean_anom: f64) -> f64 {
253 2.0 * ((1.5 * mean_anom).asinh() / 3.0).sinh()
254}
255
256fn validate_propagation_inputs(
257 elements: &ClassicalElements,
258 mu: f64,
259 dt: f64,
260) -> Result<(), AnomalyError> {
261 check_finite(mu, "mu")?;
262 if mu <= 0.0 {
263 return Err(AnomalyError::NonPositiveMu);
264 }
265 check_finite(dt, "dt")?;
266 validate_ecc(elements.ecc)?;
267 check_finite(elements.p, "p")?;
268 if elements.p <= 0.0 {
269 return Err(AnomalyError::NonPositiveSemiLatus);
270 }
271 check_finite(elements.incl, "incl")?;
272
273 match regime(elements.ecc) {
274 Regime::Elliptic => {
275 if !elements.a.is_finite() || elements.a <= 0.0 {
276 return Err(AnomalyError::InconsistentElements { field: "a" });
277 }
278 }
279 Regime::Parabolic => {}
280 Regime::Hyperbolic => {
281 if !elements.a.is_finite() || elements.a >= 0.0 {
282 return Err(AnomalyError::InconsistentElements { field: "a" });
283 }
284 }
285 }
286
287 validate_orbit_type_fields(elements)
288}
289
290fn validate_orbit_type_fields(elements: &ClassicalElements) -> Result<(), AnomalyError> {
291 let equatorial = is_equatorial(elements.incl);
292
293 match elements.orbit_type {
294 OrbitType::EllipticalInclined => {
295 check_finite(elements.raan, "raan")?;
296 check_finite(elements.argp, "argp")?;
297 check_finite(elements.nu, "nu")?;
298 require_eccentric(elements.ecc)?;
299 require_inclined(equatorial)?;
300 }
301 OrbitType::EllipticalEquatorial => {
302 check_finite(elements.lonper, "lonper")?;
303 check_finite(elements.nu, "nu")?;
304 require_eccentric(elements.ecc)?;
305 require_equatorial(equatorial)?;
306 }
307 OrbitType::CircularInclined => {
308 check_finite(elements.raan, "raan")?;
309 check_finite(elements.arglat, "arglat")?;
310 require_circular(elements.ecc)?;
311 require_inclined(equatorial)?;
312 }
313 OrbitType::CircularEquatorial => {
314 check_finite(elements.truelon, "truelon")?;
315 require_circular(elements.ecc)?;
316 require_equatorial(equatorial)?;
317 }
318 }
319
320 Ok(())
321}
322
323fn require_eccentric(ecc: f64) -> Result<(), AnomalyError> {
324 if ecc >= SMALL {
325 Ok(())
326 } else {
327 Err(AnomalyError::InconsistentElements { field: "ecc" })
328 }
329}
330
331fn require_circular(ecc: f64) -> Result<(), AnomalyError> {
332 if ecc < SMALL {
333 Ok(())
334 } else {
335 Err(AnomalyError::InconsistentElements { field: "ecc" })
336 }
337}
338
339fn require_equatorial(equatorial: bool) -> Result<(), AnomalyError> {
340 if equatorial {
341 Ok(())
342 } else {
343 Err(AnomalyError::InconsistentElements { field: "incl" })
344 }
345}
346
347fn require_inclined(equatorial: bool) -> Result<(), AnomalyError> {
348 if !equatorial {
349 Ok(())
350 } else {
351 Err(AnomalyError::InconsistentElements { field: "incl" })
352 }
353}
354
355fn is_equatorial(incl: f64) -> bool {
356 incl < SMALL || (incl - PI).abs() < SMALL
357}
358
359fn validate_ecc(ecc: f64) -> Result<(), AnomalyError> {
360 check_finite(ecc, "ecc")?;
361 if ecc < 0.0 {
362 Err(AnomalyError::NegativeEccentricity)
363 } else {
364 Ok(())
365 }
366}
367
368fn check_open_true_anomaly(true_anom: f64, limit: f64) -> Result<(), AnomalyError> {
369 if wrap_to_pi(true_anom).abs() >= limit {
370 Err(AnomalyError::BeyondAsymptote {
371 nu: true_anom,
372 limit,
373 })
374 } else {
375 Ok(())
376 }
377}
378
379fn check_finite(value: f64, field: &'static str) -> Result<(), AnomalyError> {
380 if value.is_finite() {
381 Ok(())
382 } else {
383 Err(AnomalyError::NonFinite { field })
384 }
385}
386
387fn regime(ecc: f64) -> Regime {
388 if ecc <= 1.0 - SMALL {
389 Regime::Elliptic
390 } else if ecc < 1.0 + SMALL {
391 Regime::Parabolic
392 } else {
393 Regime::Hyperbolic
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 const DEG: f64 = std::f64::consts::PI / 180.0;
402
403 fn assert_close(got: f64, want: f64, tol: f64, label: &str) {
404 assert!(
405 (got - want).abs() <= tol,
406 "{label}: got {got}, want {want}, diff {}",
407 (got - want).abs()
408 );
409 }
410
411 fn assert_angle_close(got: f64, want: f64, tol: f64, label: &str) {
412 let diff = wrap_to_pi(got - want).abs();
413 assert!(diff <= tol, "{label}: got {got}, want {want}, diff {diff}");
414 }
415
416 #[test]
417 fn vallado_example_2_1_elliptic_kepler() {
418 let eccentric = mean_to_eccentric(235.4 * DEG, 0.4).unwrap();
421 assert_close(eccentric, 220.512074 * DEG, 1.0e-6, "E");
422 }
423
424 #[test]
425 fn elliptic_round_trips_across_grid() {
426 let eccs = [
427 0.0,
428 1.0e-9,
429 0.01,
430 0.1,
431 0.3,
432 0.5,
433 0.7,
434 0.9,
435 0.99,
436 1.0 - 1.0e-6,
437 ];
438
439 for ecc in eccs {
440 for step in 0..24 {
441 let mean = step as f64 * crate::astro::math::TWO_PI / 24.0;
442 let eccentric = mean_to_eccentric(mean, ecc).unwrap();
443 let true_anom = eccentric_to_true(eccentric, ecc).unwrap();
444
445 assert_angle_close(
446 eccentric_to_mean(eccentric, ecc).unwrap(),
447 mean,
448 1.0e-11,
449 "M",
450 );
451 assert_angle_close(
452 true_to_eccentric(true_anom, ecc).unwrap(),
453 eccentric,
454 1.0e-11,
455 "E",
456 );
457 assert_angle_close(
458 true_to_mean(true_anom, ecc).unwrap(),
459 mean,
460 1.0e-11,
461 "M true",
462 );
463
464 let solution = solve_kepler(mean, ecc).unwrap();
465 let residual = wrap_to_pi(eccentric_to_mean(solution.anomaly, ecc).unwrap() - mean);
466 assert!(solution.iterations <= MAX_ITER);
467 assert!(residual.abs() <= TOL_ABS + TOL_REL * mean.abs());
468 }
469 }
470 }
471
472 #[test]
473 fn hyperbolic_round_trips_across_grid() {
474 let eccs = [1.0 + 1.0e-4, 1.1, 1.5, 2.4, 5.0];
475 let means = [-10.0, -3.0, -1.0, -0.1, 0.0, 0.1, 1.0, 3.0, 10.0];
476
477 for ecc in eccs {
478 for mean in means {
479 let hyper = mean_to_eccentric(mean, ecc).unwrap();
480 let true_anom = eccentric_to_true(hyper, ecc).unwrap();
481 let mean_back = eccentric_to_mean(hyper, ecc).unwrap();
482 let hyper_back = true_to_eccentric(true_anom, ecc).unwrap();
483 let mean_from_true = true_to_mean(true_anom, ecc).unwrap();
484
485 let scale = 1.0_f64.max(mean.abs());
486 assert!((mean_back - mean).abs() <= 1.0e-9 * scale);
487 assert!((hyper_back - hyper).abs() <= 1.0e-9 * 1.0_f64.max(hyper.abs()));
488 assert!((mean_from_true - mean).abs() <= 1.0e-9 * scale);
489
490 let solution = solve_kepler(mean, ecc).unwrap();
491 let residual = ecc * solution.anomaly.sinh() - solution.anomaly - mean;
492 assert!(solution.iterations <= MAX_ITER);
493 assert!(residual.abs() <= TOL_ABS + TOL_REL * mean.abs());
494 }
495 }
496 }
497
498 #[test]
499 fn parabolic_barker_round_trips_wide_signed_range() {
500 for step in 0..24 {
501 if step == 12 {
502 continue;
503 }
504 let true_anom = step as f64 * crate::astro::math::TWO_PI / 24.0;
505 let d = true_to_eccentric(true_anom, 1.0).unwrap();
506 let mean = eccentric_to_mean(d, 1.0).unwrap();
507 let d_back = mean_to_eccentric(mean, 1.0).unwrap();
508 let true_back = mean_to_true(mean, 1.0).unwrap();
509
510 assert!((d_back - d).abs() <= 1.0e-11 * 1.0_f64.max(d.abs()));
511 assert_angle_close(true_back, true_anom, 1.0e-11, "parabolic true");
512 }
513
514 let means = [-1.0e6, -10.0, -0.1, 0.0, 0.1, 10.0, 1.0e6];
515
516 for mean in means {
517 let d = mean_to_eccentric(mean, 1.0).unwrap();
518 let true_anom = eccentric_to_true(d, 1.0).unwrap();
519 let d_back = true_to_eccentric(true_anom, 1.0).unwrap();
520 let mean_back = true_to_mean(true_anom, 1.0).unwrap();
521
522 assert!((d_back - d).abs() <= 1.0e-11 * 1.0_f64.max(d.abs()));
523 assert!((mean_back - mean).abs() <= 1.0e-9 * 1.0_f64.max(mean.abs()));
524 assert_eq!(solve_kepler(mean, 1.0).unwrap().iterations, 0);
525 }
526 }
527
528 #[test]
529 fn large_elliptic_mean_anomaly_is_reduced() {
530 let baseline = mean_to_eccentric(1.3, 0.7).unwrap();
531 let many_revs = mean_to_eccentric(100.0 * crate::astro::math::TWO_PI + 1.3, 0.7).unwrap();
532 assert_angle_close(many_revs, baseline, 1.0e-12, "large M");
533 }
534
535 #[test]
536 fn degenerate_circular_anomalies_match() {
537 let mean = 5.8;
538 assert_angle_close(mean_to_eccentric(mean, 0.0).unwrap(), mean, 1.0e-14, "E");
539 assert_angle_close(mean_to_true(mean, 0.0).unwrap(), mean, 1.0e-14, "nu");
540 assert_angle_close(true_to_mean(mean, 0.0).unwrap(), mean, 1.0e-14, "M");
541 }
542
543 #[test]
544 fn true_anomaly_at_or_beyond_open_asymptote_is_rejected() {
545 assert!(matches!(
546 true_to_eccentric(PI, 1.0),
547 Err(AnomalyError::BeyondAsymptote { .. })
548 ));
549
550 let ecc = 1.5;
551 let limit = (-1.0_f64 / ecc).acos();
552 assert!(matches!(
553 true_to_eccentric(limit, ecc),
554 Err(AnomalyError::BeyondAsymptote { .. })
555 ));
556 assert!(matches!(
557 true_to_mean(limit + 1.0e-3, ecc),
558 Err(AnomalyError::BeyondAsymptote { .. })
559 ));
560 }
561
562 #[test]
563 fn scalar_error_paths_are_distinct() {
564 assert_eq!(
565 mean_to_eccentric(f64::NAN, 0.1),
566 Err(AnomalyError::NonFinite { field: "mean_anom" })
567 );
568 assert_eq!(
569 mean_to_eccentric(0.0, f64::INFINITY),
570 Err(AnomalyError::NonFinite { field: "ecc" })
571 );
572 assert_eq!(
573 mean_to_eccentric(0.0, -0.1),
574 Err(AnomalyError::NegativeEccentricity)
575 );
576 }
577}