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