1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::f64::consts::{PI, TAU};
7
8pub mod prelude;
9
10pub const GRAVITATIONAL_CONSTANT: f64 = 6.674_30e-11;
15
16pub const STANDARD_GRAVITY: f64 = 9.806_65;
21
22fn finite(value: f64) -> Option<f64> {
23 value.is_finite().then_some(value)
24}
25
26fn all_finite(values: &[f64]) -> bool {
27 values.iter().all(|value| value.is_finite())
28}
29
30#[must_use]
45pub fn gravitational_parameter(source_mass: f64) -> Option<f64> {
46 if source_mass < 0.0 || !source_mass.is_finite() {
47 return None;
48 }
49
50 finite(GRAVITATIONAL_CONSTANT * source_mass)
51}
52
53#[must_use]
60pub fn source_mass_from_gravitational_parameter(mu: f64) -> Option<f64> {
61 if mu < 0.0 || !mu.is_finite() {
62 return None;
63 }
64
65 finite(mu / GRAVITATIONAL_CONSTANT)
66}
67
68#[must_use]
85pub fn circular_orbital_speed(mu: f64, orbital_radius: f64) -> Option<f64> {
86 if mu < 0.0 || orbital_radius <= 0.0 || !all_finite(&[mu, orbital_radius]) {
87 return None;
88 }
89
90 finite((mu / orbital_radius).sqrt())
91}
92
93#[must_use]
110pub fn circular_orbital_period(mu: f64, orbital_radius: f64) -> Option<f64> {
111 if mu <= 0.0 || orbital_radius <= 0.0 || !all_finite(&[mu, orbital_radius]) {
112 return None;
113 }
114
115 finite(TAU * (orbital_radius.powi(3) / mu).sqrt())
116}
117
118#[must_use]
125pub fn orbital_radius_from_period(mu: f64, period: f64) -> Option<f64> {
126 if mu <= 0.0 || period <= 0.0 || !all_finite(&[mu, period]) {
127 return None;
128 }
129
130 let ratio = period / TAU;
131
132 finite((mu * ratio.powi(2)).cbrt())
133}
134
135#[must_use]
142pub fn orbital_radius_from_circular_speed(mu: f64, speed: f64) -> Option<f64> {
143 if mu < 0.0 || speed <= 0.0 || !all_finite(&[mu, speed]) {
144 return None;
145 }
146
147 finite(mu / speed.powi(2))
148}
149
150#[must_use]
165pub fn semi_major_axis_from_apsides(periapsis_radius: f64, apoapsis_radius: f64) -> Option<f64> {
166 if periapsis_radius <= 0.0
167 || apoapsis_radius <= 0.0
168 || apoapsis_radius < periapsis_radius
169 || !all_finite(&[periapsis_radius, apoapsis_radius])
170 {
171 return None;
172 }
173
174 finite(periapsis_radius.midpoint(apoapsis_radius))
175}
176
177#[must_use]
184pub fn eccentricity_from_apsides(periapsis_radius: f64, apoapsis_radius: f64) -> Option<f64> {
185 if periapsis_radius <= 0.0
186 || apoapsis_radius <= 0.0
187 || apoapsis_radius < periapsis_radius
188 || !all_finite(&[periapsis_radius, apoapsis_radius])
189 {
190 return None;
191 }
192
193 let eccentricity = (apoapsis_radius - periapsis_radius) / (apoapsis_radius + periapsis_radius);
194
195 if !(0.0..1.0).contains(&eccentricity) {
196 return None;
197 }
198
199 finite(eccentricity)
200}
201
202#[must_use]
209pub fn periapsis_from_semi_major_axis_eccentricity(
210 semi_major_axis: f64,
211 eccentricity: f64,
212) -> Option<f64> {
213 if semi_major_axis <= 0.0 || !semi_major_axis.is_finite() || !(0.0..1.0).contains(&eccentricity)
214 {
215 return None;
216 }
217
218 finite(semi_major_axis * (1.0 - eccentricity))
219}
220
221#[must_use]
228pub fn apoapsis_from_semi_major_axis_eccentricity(
229 semi_major_axis: f64,
230 eccentricity: f64,
231) -> Option<f64> {
232 if semi_major_axis <= 0.0 || !semi_major_axis.is_finite() || !(0.0..1.0).contains(&eccentricity)
233 {
234 return None;
235 }
236
237 finite(semi_major_axis * (1.0 + eccentricity))
238}
239
240#[must_use]
256pub fn vis_viva_speed(mu: f64, orbital_radius: f64, semi_major_axis: f64) -> Option<f64> {
257 if mu < 0.0
258 || orbital_radius <= 0.0
259 || semi_major_axis <= 0.0
260 || !all_finite(&[mu, orbital_radius, semi_major_axis])
261 {
262 return None;
263 }
264
265 let radicand = mu * ((2.0 / orbital_radius) - (1.0 / semi_major_axis));
266
267 if radicand < 0.0 || !radicand.is_finite() {
268 return None;
269 }
270
271 finite(radicand.max(0.0).sqrt())
272}
273
274#[must_use]
278pub fn periapsis_speed(mu: f64, periapsis_radius: f64, apoapsis_radius: f64) -> Option<f64> {
279 semi_major_axis_from_apsides(periapsis_radius, apoapsis_radius)
280 .and_then(|semi_major_axis| vis_viva_speed(mu, periapsis_radius, semi_major_axis))
281}
282
283#[must_use]
287pub fn apoapsis_speed(mu: f64, periapsis_radius: f64, apoapsis_radius: f64) -> Option<f64> {
288 semi_major_axis_from_apsides(periapsis_radius, apoapsis_radius)
289 .and_then(|semi_major_axis| vis_viva_speed(mu, apoapsis_radius, semi_major_axis))
290}
291
292#[must_use]
299pub fn elliptical_orbital_period(mu: f64, semi_major_axis: f64) -> Option<f64> {
300 if mu <= 0.0 || semi_major_axis <= 0.0 || !all_finite(&[mu, semi_major_axis]) {
301 return None;
302 }
303
304 finite(TAU * (semi_major_axis.powi(3) / mu).sqrt())
305}
306
307#[must_use]
314pub fn escape_speed(mu: f64, distance: f64) -> Option<f64> {
315 if mu < 0.0 || distance <= 0.0 || !all_finite(&[mu, distance]) {
316 return None;
317 }
318
319 finite((2.0 * mu / distance).sqrt())
320}
321
322#[must_use]
329pub fn specific_orbital_energy(speed: f64, mu: f64, distance: f64) -> Option<f64> {
330 if mu < 0.0 || distance <= 0.0 || !all_finite(&[speed, mu, distance]) {
331 return None;
332 }
333
334 finite((speed.powi(2) / 2.0) - (mu / distance))
335}
336
337#[must_use]
344pub fn semi_major_axis_from_specific_energy(mu: f64, specific_energy: f64) -> Option<f64> {
345 if mu <= 0.0 || specific_energy >= 0.0 || !all_finite(&[mu, specific_energy]) {
346 return None;
347 }
348
349 finite(-mu / (2.0 * specific_energy))
350}
351
352#[must_use]
359pub fn orbital_radius_from_altitude(body_radius: f64, altitude: f64) -> Option<f64> {
360 if body_radius <= 0.0 || altitude < 0.0 || !all_finite(&[body_radius, altitude]) {
361 return None;
362 }
363
364 finite(body_radius + altitude)
365}
366
367#[must_use]
374pub fn altitude_from_orbital_radius(body_radius: f64, orbital_radius: f64) -> Option<f64> {
375 if body_radius <= 0.0
376 || orbital_radius < body_radius
377 || !all_finite(&[body_radius, orbital_radius])
378 {
379 return None;
380 }
381
382 finite(orbital_radius - body_radius)
383}
384
385#[must_use]
392pub fn hohmann_transfer_semi_major_axis(radius_initial: f64, radius_final: f64) -> Option<f64> {
393 if radius_initial <= 0.0 || radius_final <= 0.0 || !all_finite(&[radius_initial, radius_final])
394 {
395 return None;
396 }
397
398 finite(radius_initial.midpoint(radius_final))
399}
400
401#[must_use]
408pub fn hohmann_transfer_time(mu: f64, radius_initial: f64, radius_final: f64) -> Option<f64> {
409 if mu <= 0.0 || !mu.is_finite() {
410 return None;
411 }
412
413 hohmann_transfer_semi_major_axis(radius_initial, radius_final)
414 .and_then(|semi_major_axis| finite(PI * (semi_major_axis.powi(3) / mu).sqrt()))
415}
416
417#[must_use]
424pub fn hohmann_delta_v_1(mu: f64, radius_initial: f64, radius_final: f64) -> Option<f64> {
425 if mu <= 0.0 || radius_initial <= 0.0 || radius_final <= 0.0 {
426 return None;
427 }
428 if !all_finite(&[mu, radius_initial, radius_final]) {
429 return None;
430 }
431
432 let circular_speed = (mu / radius_initial).sqrt();
433 let transfer_factor = ((2.0 * radius_final) / (radius_initial + radius_final)).sqrt() - 1.0;
434
435 finite(circular_speed * transfer_factor)
436}
437
438#[must_use]
445pub fn hohmann_delta_v_2(mu: f64, radius_initial: f64, radius_final: f64) -> Option<f64> {
446 if mu <= 0.0 || radius_initial <= 0.0 || radius_final <= 0.0 {
447 return None;
448 }
449 if !all_finite(&[mu, radius_initial, radius_final]) {
450 return None;
451 }
452
453 let circular_speed = (mu / radius_final).sqrt();
454 let transfer_factor = 1.0 - ((2.0 * radius_initial) / (radius_initial + radius_final)).sqrt();
455
456 finite(circular_speed * transfer_factor)
457}
458
459#[must_use]
471pub fn hohmann_total_delta_v(mu: f64, radius_initial: f64, radius_final: f64) -> Option<f64> {
472 let delta_v_1 = hohmann_delta_v_1(mu, radius_initial, radius_final)?;
473 let delta_v_2 = hohmann_delta_v_2(mu, radius_initial, radius_final)?;
474
475 finite(delta_v_1.abs() + delta_v_2.abs())
476}
477
478#[derive(Debug, Clone, Copy, PartialEq)]
480pub struct CentralBody {
481 pub mass: f64,
483 pub radius: Option<f64>,
485}
486
487impl CentralBody {
488 #[must_use]
492 pub fn new(mass: f64) -> Option<Self> {
493 if mass < 0.0 || !mass.is_finite() {
494 return None;
495 }
496
497 Some(Self { mass, radius: None })
498 }
499
500 #[must_use]
505 pub fn with_radius(mass: f64, radius: f64) -> Option<Self> {
506 if mass < 0.0 || !mass.is_finite() || radius <= 0.0 || !radius.is_finite() {
507 return None;
508 }
509
510 Some(Self {
511 mass,
512 radius: Some(radius),
513 })
514 }
515
516 #[must_use]
518 pub fn gravitational_parameter(&self) -> Option<f64> {
519 gravitational_parameter(self.mass)
520 }
521
522 #[must_use]
526 pub fn orbital_radius_from_altitude(&self, altitude: f64) -> Option<f64> {
527 self.radius
528 .and_then(|radius| orbital_radius_from_altitude(radius, altitude))
529 }
530
531 #[must_use]
544 pub fn circular_orbital_speed_at_radius(&self, orbital_radius: f64) -> Option<f64> {
545 self.gravitational_parameter()
546 .and_then(|mu| circular_orbital_speed(mu, orbital_radius))
547 }
548
549 #[must_use]
551 pub fn circular_orbital_period_at_radius(&self, orbital_radius: f64) -> Option<f64> {
552 self.gravitational_parameter()
553 .and_then(|mu| circular_orbital_period(mu, orbital_radius))
554 }
555
556 #[must_use]
558 pub fn escape_speed_at_radius(&self, distance: f64) -> Option<f64> {
559 self.gravitational_parameter()
560 .and_then(|mu| escape_speed(mu, distance))
561 }
562}
563
564#[derive(Debug, Clone, Copy, PartialEq)]
566pub struct EllipticalOrbit {
567 pub mu: f64,
569 pub periapsis_radius: f64,
571 pub apoapsis_radius: f64,
573}
574
575impl EllipticalOrbit {
576 #[must_use]
581 pub fn new(mu: f64, periapsis_radius: f64, apoapsis_radius: f64) -> Option<Self> {
582 if mu <= 0.0 || !mu.is_finite() {
583 return None;
584 }
585
586 semi_major_axis_from_apsides(periapsis_radius, apoapsis_radius).map(|_| Self {
587 mu,
588 periapsis_radius,
589 apoapsis_radius,
590 })
591 }
592
593 #[must_use]
595 pub fn semi_major_axis(&self) -> Option<f64> {
596 semi_major_axis_from_apsides(self.periapsis_radius, self.apoapsis_radius)
597 }
598
599 #[must_use]
601 pub fn eccentricity(&self) -> Option<f64> {
602 eccentricity_from_apsides(self.periapsis_radius, self.apoapsis_radius)
603 }
604
605 #[must_use]
617 pub fn period(&self) -> Option<f64> {
618 self.semi_major_axis()
619 .and_then(|semi_major_axis| elliptical_orbital_period(self.mu, semi_major_axis))
620 }
621
622 #[must_use]
624 pub fn periapsis_speed(&self) -> Option<f64> {
625 periapsis_speed(self.mu, self.periapsis_radius, self.apoapsis_radius)
626 }
627
628 #[must_use]
630 pub fn apoapsis_speed(&self) -> Option<f64> {
631 apoapsis_speed(self.mu, self.periapsis_radius, self.apoapsis_radius)
632 }
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638
639 fn approx_eq(left: f64, right: f64, tolerance: f64) -> bool {
640 (left - right).abs() <= tolerance
641 }
642
643 #[test]
644 fn gravitational_parameter_matches_constant() {
645 assert_eq!(gravitational_parameter(1.0), Some(GRAVITATIONAL_CONSTANT));
646 assert_eq!(gravitational_parameter(-1.0), None);
647 }
648
649 #[test]
650 fn source_mass_from_gravitational_parameter_round_trips() {
651 assert!(
652 source_mass_from_gravitational_parameter(GRAVITATIONAL_CONSTANT)
653 .is_some_and(|value| approx_eq(value, 1.0, 1.0e-12))
654 );
655 assert_eq!(source_mass_from_gravitational_parameter(-1.0), None);
656 }
657
658 #[test]
659 fn circular_orbital_speed_handles_valid_and_invalid_inputs() {
660 assert!(
661 circular_orbital_speed(398_600_441_800_000.0, 6_371_000.0)
662 .is_some_and(|value| approx_eq(value, 7_909.8, 1.0))
663 );
664 assert_eq!(circular_orbital_speed(1.0, 0.0), None);
665 }
666
667 #[test]
668 fn circular_orbital_period_handles_valid_and_invalid_inputs() {
669 assert!(
670 circular_orbital_period(398_600_441_800_000.0, 6_371_000.0)
671 .is_some_and(|value| value.is_finite() && value > 0.0)
672 );
673 assert_eq!(circular_orbital_period(0.0, 6_371_000.0), None);
674 }
675
676 #[test]
677 fn orbital_radius_from_period_handles_valid_and_invalid_inputs() {
678 assert!(
679 orbital_radius_from_period(398_600_441_800_000.0, 5_400.0)
680 .is_some_and(|value| value.is_finite() && value > 0.0)
681 );
682 assert_eq!(orbital_radius_from_period(398_600_441_800_000.0, 0.0), None);
683 }
684
685 #[test]
686 fn orbital_radius_from_circular_speed_handles_valid_and_invalid_inputs() {
687 assert_eq!(orbital_radius_from_circular_speed(100.0, 10.0), Some(1.0));
688 assert_eq!(orbital_radius_from_circular_speed(100.0, 0.0), None);
689 }
690
691 #[test]
692 fn semi_major_axis_from_apsides_handles_valid_and_invalid_inputs() {
693 assert_eq!(semi_major_axis_from_apsides(10.0, 20.0), Some(15.0));
694 assert_eq!(semi_major_axis_from_apsides(20.0, 10.0), None);
695 }
696
697 #[test]
698 fn eccentricity_from_apsides_handles_circular_and_elliptical_orbits() {
699 assert!(
700 eccentricity_from_apsides(10.0, 20.0).is_some_and(|value| approx_eq(
701 value,
702 1.0 / 3.0,
703 1.0e-12
704 ))
705 );
706 assert_eq!(eccentricity_from_apsides(10.0, 10.0), Some(0.0));
707 }
708
709 #[test]
710 fn apsides_from_semi_major_axis_and_eccentricity_round_trip() {
711 assert!(
712 periapsis_from_semi_major_axis_eccentricity(15.0, 1.0 / 3.0)
713 .is_some_and(|value| approx_eq(value, 10.0, 1.0e-12))
714 );
715 assert!(
716 apoapsis_from_semi_major_axis_eccentricity(15.0, 1.0 / 3.0)
717 .is_some_and(|value| approx_eq(value, 20.0, 1.0e-12))
718 );
719 assert_eq!(periapsis_from_semi_major_axis_eccentricity(15.0, 1.0), None);
720 }
721
722 #[test]
723 fn vis_viva_speed_handles_valid_and_invalid_inputs() {
724 assert!(
725 vis_viva_speed(100.0, 10.0, 15.0).is_some_and(|value| value.is_finite() && value > 0.0)
726 );
727 assert_eq!(vis_viva_speed(100.0, 0.0, 15.0), None);
728 }
729
730 #[test]
731 fn apsis_speeds_handle_valid_and_invalid_inputs() {
732 assert!(
733 periapsis_speed(100.0, 10.0, 20.0)
734 .is_some_and(|value| value.is_finite() && value > 0.0)
735 );
736 assert!(
737 apoapsis_speed(100.0, 10.0, 20.0).is_some_and(|value| value.is_finite() && value > 0.0)
738 );
739 assert_eq!(periapsis_speed(100.0, 20.0, 10.0), None);
740 }
741
742 #[test]
743 fn elliptical_orbital_period_handles_valid_and_invalid_inputs() {
744 assert!(
745 elliptical_orbital_period(100.0, 15.0)
746 .is_some_and(|value| value.is_finite() && value > 0.0)
747 );
748 assert_eq!(elliptical_orbital_period(0.0, 15.0), None);
749 }
750
751 #[test]
752 fn escape_speed_handles_valid_and_invalid_inputs() {
753 assert_eq!(escape_speed(100.0, 2.0), Some(10.0));
754 assert_eq!(escape_speed(100.0, 0.0), None);
755 }
756
757 #[test]
758 fn specific_orbital_energy_handles_valid_and_invalid_inputs() {
759 assert_eq!(specific_orbital_energy(10.0, 100.0, 10.0), Some(40.0));
760 assert_eq!(specific_orbital_energy(10.0, 100.0, 0.0), None);
761 }
762
763 #[test]
764 fn semi_major_axis_from_specific_energy_handles_valid_and_invalid_inputs() {
765 assert_eq!(
766 semi_major_axis_from_specific_energy(100.0, -5.0),
767 Some(10.0)
768 );
769 assert_eq!(semi_major_axis_from_specific_energy(100.0, 0.0), None);
770 }
771
772 #[test]
773 fn altitude_and_radius_helpers_round_trip() {
774 assert_eq!(
775 orbital_radius_from_altitude(6_371_000.0, 400_000.0),
776 Some(6_771_000.0)
777 );
778 assert_eq!(orbital_radius_from_altitude(6_371_000.0, -1.0), None);
779 assert_eq!(
780 altitude_from_orbital_radius(6_371_000.0, 6_771_000.0),
781 Some(400_000.0)
782 );
783 assert_eq!(altitude_from_orbital_radius(6_371_000.0, 6_000_000.0), None);
784 }
785
786 #[test]
787 fn hohmann_helpers_handle_valid_inputs() {
788 assert_eq!(hohmann_transfer_semi_major_axis(10.0, 20.0), Some(15.0));
789 assert!(
790 hohmann_transfer_time(100.0, 10.0, 20.0)
791 .is_some_and(|value| value.is_finite() && value > 0.0)
792 );
793 assert!(hohmann_delta_v_1(100.0, 10.0, 20.0).is_some_and(f64::is_finite));
794 assert!(hohmann_delta_v_2(100.0, 10.0, 20.0).is_some_and(f64::is_finite));
795 assert!(
796 hohmann_total_delta_v(100.0, 10.0, 20.0)
797 .is_some_and(|value| value.is_finite() && value >= 0.0)
798 );
799 }
800
801 #[test]
802 fn central_body_delegates_to_public_helpers() {
803 assert_eq!(
804 CentralBody::new(1.0).and_then(|body| body.gravitational_parameter()),
805 Some(GRAVITATIONAL_CONSTANT)
806 );
807 assert!(
808 CentralBody::with_radius(5.972e24, 6.371e6)
809 .and_then(|body| body.orbital_radius_from_altitude(400_000.0))
810 .is_some_and(|value| approx_eq(value, 6.771e6, 1.0e-6))
811 );
812 assert!(
813 CentralBody::with_radius(5.972e24, 6.371e6)
814 .and_then(|body| body.circular_orbital_speed_at_radius(6.771e6))
815 .is_some_and(|value| value.is_finite() && value > 0.0)
816 );
817 assert_eq!(CentralBody::new(-1.0), None);
818 assert_eq!(CentralBody::with_radius(1.0, 0.0), None);
819 }
820
821 #[test]
822 fn elliptical_orbit_delegates_to_public_helpers() {
823 let orbit = EllipticalOrbit::new(100.0, 10.0, 20.0);
824
825 assert_eq!(orbit.and_then(|value| value.semi_major_axis()), Some(15.0));
826 assert!(
827 orbit
828 .and_then(|value| value.eccentricity())
829 .is_some_and(|value| approx_eq(value, 1.0 / 3.0, 1.0e-12))
830 );
831 assert!(
832 orbit
833 .and_then(|value| value.period())
834 .is_some_and(|value| value.is_finite() && value > 0.0)
835 );
836 assert!(
837 orbit
838 .and_then(|value| value.periapsis_speed())
839 .is_some_and(|value| value.is_finite() && value > 0.0)
840 );
841 assert!(
842 orbit
843 .and_then(|value| value.apoapsis_speed())
844 .is_some_and(|value| value.is_finite() && value > 0.0)
845 );
846 assert_eq!(EllipticalOrbit::new(100.0, 20.0, 10.0), None);
847 }
848}