1use std::f64::consts::PI;
8use std::f64::consts::TAU;
9use std::fmt::Display;
10
11use glam::DVec3;
12use lox_test_utils::ApproxEq;
13use lox_test_utils::approx_eq;
14use thiserror::Error;
15
16use crate::anomalies::AnomalyError;
17use crate::anomalies::MeanAnomaly;
18use crate::anomalies::{EccentricAnomaly, TrueAnomaly};
19use crate::time::deltas::TimeDelta;
20use crate::utils::Linspace;
21use crate::{
22 coords::Cartesian,
23 glam::Azimuth,
24 units::{Angle, AngleUnits, Distance, DistanceUnits},
25};
26
27#[derive(Debug, Default, Clone, Copy, PartialEq, PartialOrd, ApproxEq)]
29#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
30#[repr(transparent)]
31pub struct GravitationalParameter(f64);
32
33impl GravitationalParameter {
34 pub const fn m3_per_s2(mu: f64) -> Self {
36 Self(mu)
37 }
38
39 pub const fn km3_per_s2(mu: f64) -> Self {
41 Self(1e9 * mu)
42 }
43
44 pub const fn as_f64(&self) -> f64 {
46 self.0
47 }
48}
49
50impl Display for GravitationalParameter {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 (self.0 * 1e-9).fmt(f)?;
53 write!(f, " km³/s²")
54 }
55}
56
57pub type SemiMajorAxis = Distance;
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
63pub enum OrbitType {
64 Circular,
66 Elliptic,
68 Parabolic,
70 Hyperbolic,
72}
73
74impl Display for OrbitType {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 match self {
77 OrbitType::Circular => "circular".fmt(f),
78 OrbitType::Elliptic => "elliptic".fmt(f),
79 OrbitType::Parabolic => "parabolic".fmt(f),
80 OrbitType::Hyperbolic => "hyperbolic".fmt(f),
81 }
82 }
83}
84
85#[derive(Debug, Clone, Error)]
87#[error("eccentricity cannot be negative but was {0}")]
88pub struct NegativeEccentricityError(f64);
89
90#[derive(Debug, Default, Clone, Copy, PartialEq, PartialOrd, ApproxEq)]
92#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
93#[repr(transparent)]
94pub struct Eccentricity(f64);
95
96impl Eccentricity {
97 pub const fn try_new(ecc: f64) -> Result<Eccentricity, NegativeEccentricityError> {
103 if ecc < 0.0 {
104 return Err(NegativeEccentricityError(ecc));
105 }
106 Ok(Eccentricity(ecc))
107 }
108
109 pub const fn as_f64(&self) -> f64 {
111 self.0
112 }
113
114 pub fn orbit_type(&self) -> OrbitType {
116 match self.0 {
117 ecc if approx_eq!(ecc, 0.0, atol <= 1e-8) => OrbitType::Circular,
118 ecc if approx_eq!(ecc, 1.0, rtol <= 1e-8) => OrbitType::Parabolic,
119 ecc if ecc > 0.0 && ecc < 1.0 => OrbitType::Elliptic,
120 _ => OrbitType::Hyperbolic,
121 }
122 }
123
124 pub fn is_circular(&self) -> bool {
126 matches!(self.orbit_type(), OrbitType::Circular)
127 }
128
129 pub fn is_elliptic(&self) -> bool {
131 matches!(self.orbit_type(), OrbitType::Elliptic)
132 }
133
134 pub fn is_parabolic(&self) -> bool {
136 matches!(self.orbit_type(), OrbitType::Parabolic)
137 }
138
139 pub fn is_hyperbolic(&self) -> bool {
141 matches!(self.orbit_type(), OrbitType::Hyperbolic)
142 }
143
144 pub fn is_circular_or_elliptic(&self) -> bool {
146 matches!(self.orbit_type(), OrbitType::Circular | OrbitType::Elliptic)
147 }
148}
149
150impl Display for Eccentricity {
151 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152 self.0.fmt(f)
153 }
154}
155
156#[derive(Debug, Clone, Error)]
158#[error("inclination must be between 0 and 180 deg but was {0}")]
159pub struct InclinationError(Angle);
160
161#[derive(Debug, Default, Clone, Copy, PartialEq, PartialOrd, ApproxEq)]
163#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
164#[repr(transparent)]
165pub struct Inclination(Angle);
166
167impl Inclination {
168 pub const fn try_new(inclination: Angle) -> Result<Inclination, InclinationError> {
170 let inc = inclination.as_f64();
171 if inc < 0.0 || inc > PI {
172 return Err(InclinationError(inclination));
173 }
174 Ok(Inclination(inclination))
175 }
176
177 pub const fn as_f64(&self) -> f64 {
179 self.0.as_f64()
180 }
181}
182
183impl Display for Inclination {
184 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185 self.0.fmt(f)
186 }
187}
188
189#[derive(Debug, Clone, Error)]
191#[error("longitude of ascending node must be between 0 and 360 deg but was {0}")]
192pub struct LongitudeOfAscendingNodeError(Angle);
193
194#[derive(Debug, Default, Clone, Copy, PartialEq, PartialOrd, ApproxEq)]
196#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
197#[repr(transparent)]
198pub struct LongitudeOfAscendingNode(Angle);
199
200impl LongitudeOfAscendingNode {
201 pub const fn try_new(
203 longitude_of_ascending_node: Angle,
204 ) -> Result<LongitudeOfAscendingNode, LongitudeOfAscendingNodeError> {
205 let node = longitude_of_ascending_node.as_f64();
206 if node < 0.0 || node > TAU {
207 return Err(LongitudeOfAscendingNodeError(longitude_of_ascending_node));
208 }
209 Ok(LongitudeOfAscendingNode(longitude_of_ascending_node))
210 }
211
212 pub const fn as_f64(&self) -> f64 {
214 self.0.as_f64()
215 }
216}
217
218impl Display for LongitudeOfAscendingNode {
219 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220 self.0.fmt(f)
221 }
222}
223
224#[derive(Debug, Clone, Error)]
226#[error("argument of periapsis must be between 0 and 360 deg but was {0}")]
227pub struct ArgumentOfPeriapsisError(Angle);
228
229#[derive(Debug, Default, Clone, Copy, PartialEq, PartialOrd, ApproxEq)]
231#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
232#[repr(transparent)]
233pub struct ArgumentOfPeriapsis(Angle);
234
235impl ArgumentOfPeriapsis {
236 pub const fn try_new(
238 argument_of_periapsis: Angle,
239 ) -> Result<ArgumentOfPeriapsis, ArgumentOfPeriapsisError> {
240 let arg = argument_of_periapsis.as_f64();
241 if arg < 0.0 || arg > TAU {
242 return Err(ArgumentOfPeriapsisError(argument_of_periapsis));
243 }
244 Ok(ArgumentOfPeriapsis(argument_of_periapsis))
245 }
246
247 pub const fn as_f64(&self) -> f64 {
249 self.0.as_f64()
250 }
251}
252
253impl Display for ArgumentOfPeriapsis {
254 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255 self.0.fmt(f)
256 }
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, ApproxEq)]
261#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
262pub struct Keplerian {
263 semi_major_axis: SemiMajorAxis,
264 eccentricity: Eccentricity,
265 inclination: Inclination,
266 longitude_of_ascending_node: LongitudeOfAscendingNode,
267 argument_of_periapsis: ArgumentOfPeriapsis,
268 true_anomaly: TrueAnomaly,
269}
270
271impl Keplerian {
272 pub fn new(
274 semi_major_axis: SemiMajorAxis,
275 eccentricity: Eccentricity,
276 inclination: Inclination,
277 longitude_of_ascending_node: LongitudeOfAscendingNode,
278 argument_of_periapsis: ArgumentOfPeriapsis,
279 true_anomaly: TrueAnomaly,
280 ) -> Self {
281 Self {
282 semi_major_axis,
283 eccentricity,
284 inclination,
285 longitude_of_ascending_node,
286 argument_of_periapsis,
287 true_anomaly,
288 }
289 }
290
291 pub fn builder() -> KeplerianBuilder {
293 KeplerianBuilder::default()
294 }
295
296 pub fn semi_major_axis(&self) -> SemiMajorAxis {
298 self.semi_major_axis
299 }
300
301 pub fn eccentricity(&self) -> Eccentricity {
303 self.eccentricity
304 }
305
306 pub fn inclination(&self) -> Inclination {
308 self.inclination
309 }
310
311 pub fn longitude_of_ascending_node(&self) -> LongitudeOfAscendingNode {
313 self.longitude_of_ascending_node
314 }
315
316 pub fn argument_of_periapsis(&self) -> ArgumentOfPeriapsis {
318 self.argument_of_periapsis
319 }
320
321 pub fn true_anomaly(&self) -> TrueAnomaly {
323 self.true_anomaly
324 }
325
326 pub fn semi_parameter(&self) -> Distance {
328 if self.eccentricity.is_circular() {
329 self.semi_major_axis
330 } else {
331 Distance::new(self.semi_major_axis.as_f64() * (1.0 - self.eccentricity.0.powi(2)))
332 }
333 }
334
335 pub fn to_perifocal(&self, grav_param: GravitationalParameter) -> (DVec3, DVec3) {
337 let ecc = self.eccentricity.as_f64();
338 let mu = grav_param.as_f64();
339 let semiparameter = self.semi_parameter().as_f64();
340 let (sin_nu, cos_nu) = self.true_anomaly.as_angle().sin_cos();
341 let sqrt_mu_p = (mu / semiparameter).sqrt();
342
343 let pos = DVec3::new(cos_nu, sin_nu, 0.0) * (semiparameter / (1.0 + ecc * cos_nu));
344 let vel = DVec3::new(-sin_nu, ecc + cos_nu, 0.0) * sqrt_mu_p;
345
346 (pos, vel)
347 }
348
349 pub fn to_cartesian(&self, grav_param: GravitationalParameter) -> Cartesian {
351 let (pos, vel) = self.to_perifocal(grav_param);
352 let rot = self.longitude_of_ascending_node.0.rotation_z().transpose()
353 * self.inclination.0.rotation_x().transpose()
354 * self.argument_of_periapsis.0.rotation_z().transpose();
355 Cartesian::from_vecs(rot * pos, rot * vel)
356 }
357
358 pub fn orbital_period(&self, grav_param: GravitationalParameter) -> Option<TimeDelta> {
360 if !self.eccentricity().is_circular_or_elliptic() {
361 return None;
362 }
363 let mu = grav_param.as_f64();
364 let a = self.semi_major_axis.as_f64();
365 Some(TimeDelta::from_seconds_f64(TAU * (a.powf(3.0) / mu).sqrt()))
366 }
367
368 pub fn iter_trace(
370 &self,
371 grav_param: GravitationalParameter,
372 n: usize,
373 ) -> impl Iterator<Item = Cartesian> {
374 assert!(self.eccentricity().is_circular_or_elliptic());
375 Linspace::new(-PI, PI, n).map(move |ecc| {
376 let true_anomaly = EccentricAnomaly::new(ecc.rad()).to_true(self.eccentricity);
377 Keplerian {
378 true_anomaly,
379 ..*self
380 }
381 .to_cartesian(grav_param)
382 })
383 }
384
385 pub fn trace(&self, grav_param: GravitationalParameter, n: usize) -> Option<Vec<Cartesian>> {
387 if !self.eccentricity().is_circular_or_elliptic() {
388 return None;
389 }
390 Some(self.iter_trace(grav_param, n).collect())
391 }
392}
393
394impl Cartesian {
395 pub fn eccentricity_vector(&self, grav_param: GravitationalParameter) -> DVec3 {
397 let mu = grav_param.as_f64();
398 let r = self.position();
399 let v = self.velocity();
400
401 let rm = r.length();
402 let v2 = v.dot(v);
403 let rv = r.dot(v);
404
405 ((v2 - mu / rm) * r - rv * v) / mu
406 }
407
408 pub fn to_keplerian(&self, grav_param: GravitationalParameter) -> Keplerian {
410 let r = self.position();
411 let v = self.velocity();
412 let mu = grav_param.as_f64();
413
414 let rm = r.length();
415 let vm = v.length();
416 let h = r.cross(v);
417 let hm = h.length();
418 let node = DVec3::Z.cross(h);
419 let e = self.eccentricity_vector(grav_param);
420 let eccentricity = Eccentricity(e.length());
421 let inclination = h.angle_between(DVec3::Z).rad();
422
423 let equatorial = approx_eq!(inclination, Angle::ZERO, atol <= 1e-8);
424 let circular = eccentricity.is_circular();
425
426 let semi_major_axis = if circular {
427 hm.powi(2) / mu
428 } else {
429 -mu / (2.0 * (vm.powi(2) / 2.0 - mu / rm))
430 };
431
432 let (longitude_of_ascending_node, argument_of_periapsis, true_anomaly) =
433 if equatorial && !circular {
434 (
435 Angle::ZERO,
436 e.azimuth(),
437 TrueAnomaly::new(Angle::from_atan2(h.dot(e.cross(r)) / hm, r.dot(e))),
438 )
439 } else if !equatorial && circular {
440 (
441 node.azimuth(),
442 Angle::ZERO,
443 TrueAnomaly::new(Angle::from_atan2(r.dot(h.cross(node)) / hm, r.dot(node))),
444 )
445 } else if equatorial && circular {
446 (Angle::ZERO, Angle::ZERO, TrueAnomaly::new(r.azimuth()))
447 } else {
448 let true_anomaly = if semi_major_axis > 0.0 {
449 let e_se = r.dot(v) / (mu * semi_major_axis).sqrt();
450 let e_ce = (rm * vm.powi(2)) / mu - 1.0;
451 EccentricAnomaly::new(Angle::from_atan2(e_se, e_ce)).to_true(eccentricity)
452 } else {
453 let e_sh = r.dot(v) / (-mu * semi_major_axis).sqrt();
454 let e_ch = (rm * vm.powi(2)) / mu - 1.0;
455 EccentricAnomaly::new((((e_ch + e_sh) / (e_ch - e_sh)).ln() / 2.0).rad())
456 .to_true(eccentricity)
457 };
458 let px = r.dot(node);
459 let py = r.dot(h.cross(node)) / hm;
460 (
461 node.azimuth(),
462 Angle::from_atan2(py, px) - true_anomaly.as_angle(),
463 true_anomaly,
464 )
465 };
466
467 Keplerian {
468 semi_major_axis: semi_major_axis.m(),
469 eccentricity,
470 inclination: Inclination(inclination),
471 longitude_of_ascending_node: LongitudeOfAscendingNode(
472 longitude_of_ascending_node.mod_two_pi(),
473 ),
474 argument_of_periapsis: ArgumentOfPeriapsis(argument_of_periapsis.mod_two_pi()),
475 true_anomaly,
476 }
477 }
478}
479
480#[derive(Debug, Clone, Error)]
482pub enum KeplerianError {
483 #[error(transparent)]
485 NegativeEccentricity(#[from] NegativeEccentricityError),
486 #[error(
488 "{} semi-major axis ({semi_major_axis}) for {} eccentricity ({eccentricity})",
489 if .semi_major_axis.as_f64().signum() == -1.0 {"negative"} else {"positive"},
490 .eccentricity.orbit_type()
491 )]
492 InvalidShape {
493 semi_major_axis: SemiMajorAxis,
495 eccentricity: Eccentricity,
497 },
498 #[error(
500 "no orbital shape parameters (semi-major axis and eccentricity, radii, or altitudes) were provided"
501 )]
502 MissingShape,
503 #[error(transparent)]
505 InvalidInclination(#[from] InclinationError),
506 #[error(transparent)]
508 InvalidLongitudeOfAscendingNode(#[from] LongitudeOfAscendingNodeError),
509 #[error(transparent)]
511 InvalidArgumentOfPeriapsis(#[from] ArgumentOfPeriapsisError),
512 #[error(transparent)]
514 Anomaly(#[from] AnomalyError),
515}
516
517#[derive(Debug, Default, Clone)]
519pub struct KeplerianBuilder {
520 shape: Option<(
521 SemiMajorAxis,
522 Result<Eccentricity, NegativeEccentricityError>,
523 )>,
524 inclination: Angle,
525 longitude_of_ascending_node: Angle,
526 argument_of_periapsis: Angle,
527 true_anomaly: Option<Angle>,
528 mean_anomaly: Option<Angle>,
529}
530
531impl KeplerianBuilder {
532 pub fn new() -> Self {
534 Self::default()
535 }
536
537 pub fn with_semi_major_axis(
539 mut self,
540 semi_major_axis: SemiMajorAxis,
541 eccentricity: f64,
542 ) -> Self {
543 self.shape = Some((semi_major_axis, Eccentricity::try_new(eccentricity)));
544 self
545 }
546
547 pub fn with_radii(mut self, periapsis_radius: Distance, apoapsis_radius: Distance) -> Self {
549 let rp = periapsis_radius.as_f64();
550 let ra = apoapsis_radius.as_f64();
551 let semi_major_axis = SemiMajorAxis::new((rp + ra) / 2.0);
552
553 let eccentricity = Eccentricity::try_new((ra - rp) / (ra + rp));
554
555 self.shape = Some((semi_major_axis, eccentricity));
556
557 self
558 }
559
560 pub fn with_altitudes(
562 self,
563 periapsis_altitude: Distance,
564 apoapsis_altitude: Distance,
565 mean_radius: Distance,
566 ) -> Self {
567 let rp = periapsis_altitude + mean_radius;
568 let ra = apoapsis_altitude + mean_radius;
569 self.with_radii(rp, ra)
570 }
571
572 pub fn with_inclination(mut self, inclination: Angle) -> Self {
574 self.inclination = inclination;
575 self
576 }
577
578 pub fn with_longitude_of_ascending_node(mut self, longitude_of_ascending_node: Angle) -> Self {
580 self.longitude_of_ascending_node = longitude_of_ascending_node;
581 self
582 }
583
584 pub fn with_argument_of_periapsis(mut self, argument_of_periapsis: Angle) -> Self {
586 self.argument_of_periapsis = argument_of_periapsis;
587 self
588 }
589
590 pub fn with_true_anomaly(mut self, true_anomaly: Angle) -> Self {
592 self.true_anomaly = Some(true_anomaly);
593 self
594 }
595
596 pub fn with_mean_anomaly(mut self, mean_anomaly: Angle) -> Self {
598 self.mean_anomaly = Some(mean_anomaly);
599 self
600 }
601
602 pub fn build(self) -> Result<Keplerian, KeplerianError> {
604 let (semi_major_axis, eccentricity) = self.shape.ok_or(KeplerianError::MissingShape)?;
605
606 let eccentricity = eccentricity?;
607
608 Self::check_shape(semi_major_axis, eccentricity)?;
609
610 let inclination = Inclination::try_new(self.inclination)?;
611 let longitude_of_ascending_node =
612 LongitudeOfAscendingNode::try_new(self.longitude_of_ascending_node)?;
613 let argument_of_periapsis = ArgumentOfPeriapsis::try_new(self.argument_of_periapsis)?;
614
615 let true_anomaly = match self.true_anomaly {
616 Some(true_anomaly) => TrueAnomaly::new(true_anomaly),
617 None => match self.mean_anomaly {
618 Some(mean_anomaly) => MeanAnomaly::new(mean_anomaly).to_true(eccentricity)?,
619 None => TrueAnomaly::new(Angle::ZERO),
620 },
621 };
622
623 Ok(Keplerian {
624 semi_major_axis,
625 eccentricity,
626 inclination,
627 longitude_of_ascending_node,
628 argument_of_periapsis,
629 true_anomaly,
630 })
631 }
632
633 fn check_shape(
634 semi_major_axis: SemiMajorAxis,
635 eccentricity: Eccentricity,
636 ) -> Result<(), KeplerianError> {
637 let ecc = eccentricity.as_f64();
638 let sma = semi_major_axis.as_f64();
639 if (ecc > 1.0 && sma > 0.0) || (ecc < 1.0 && sma < 0.0) {
640 return Err(KeplerianError::InvalidShape {
641 semi_major_axis,
642 eccentricity,
643 });
644 }
645 Ok(())
646 }
647}
648
649#[cfg(test)]
650mod tests {
651 use lox_test_utils::assert_approx_eq;
652
653 use crate::units::VelocityUnits;
654
655 use super::*;
656
657 #[test]
658 fn test_cartesian_to_keplerian_roundtrip() {
659 let mu = GravitationalParameter::km3_per_s2(398600.43550702266f64);
660
661 let cartesian = Cartesian::builder()
662 .position(
663 -0.107622532467967e7.m(),
664 -0.676589636432773e7.m(),
665 -0.332308783350379e6.m(),
666 )
667 .velocity(
668 0.935685775154103e4.mps(),
669 -0.331234775037644e4.mps(),
670 -0.118801577532701e4.mps(),
671 )
672 .build();
673
674 let cartesian1 = cartesian.to_keplerian(mu).to_cartesian(mu);
675
676 assert_approx_eq!(cartesian.position(), cartesian1.position(), rtol <= 1e-8);
677 assert_approx_eq!(cartesian.velocity(), cartesian1.velocity(), rtol <= 1e-6);
678 }
679
680 #[test]
681 fn test_keplerian_builder() {
682 let mu = GravitationalParameter::km3_per_s2(398600.43550702266f64);
683
684 let semi_major_axis = 24464.560.km();
685 let eccentricity = 0.7311;
686 let inclination = 0.122138.rad();
687 let ascending_node = 1.00681.rad();
688 let argument_of_periapsis = 3.10686.rad();
689 let true_anomaly = 0.44369564302687126.rad();
690
691 let k = Keplerian::builder()
692 .with_semi_major_axis(semi_major_axis, eccentricity)
693 .with_inclination(inclination)
694 .with_longitude_of_ascending_node(ascending_node)
695 .with_argument_of_periapsis(argument_of_periapsis)
696 .with_true_anomaly(true_anomaly)
697 .build()
698 .unwrap();
699 let k1 = k.to_cartesian(mu).to_keplerian(mu);
700 assert_approx_eq!(k, k1);
701 }
702}