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