Skip to main content

use_orbit/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4//! Orbital mechanics helpers.
5
6use core::f64::consts::{PI, TAU};
7
8pub mod prelude;
9
10/// Newtonian constant of gravitation, in cubic meters per kilogram second squared.
11///
12/// This crate keeps the value locally as a convenience for orbital helpers.
13/// Broader physical constants belong in the top-level `use-constants` set.
14pub const GRAVITATIONAL_CONSTANT: f64 = 6.674_30e-11;
15
16/// Conventional standard gravity, in meters per second squared.
17///
18/// This crate keeps the value locally as a convenience for orbital helpers.
19/// Broader physical constants belong in the top-level `use-constants` set.
20pub 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/// Computes the standard gravitational parameter from a source mass.
31///
32/// Formula: `μ = G * M`
33///
34/// Returns `None` when `source_mass` is negative, when the input is not finite, or when the
35/// result is not finite.
36///
37/// # Examples
38///
39/// ```rust
40/// use use_orbit::{GRAVITATIONAL_CONSTANT, gravitational_parameter};
41///
42/// assert_eq!(gravitational_parameter(1.0), Some(GRAVITATIONAL_CONSTANT));
43/// ```
44#[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/// Computes the source mass from a standard gravitational parameter.
54///
55/// Formula: `M = μ / G`
56///
57/// Returns `None` when `mu` is negative, when the input is not finite, or when the result is not
58/// finite.
59#[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/// Computes the speed for a circular orbit at a radius around a body with gravitational parameter.
69///
70/// Formula: `v = sqrt(μ / r)`
71///
72/// Returns `None` when `mu` is negative, when `orbital_radius` is less than or equal to zero,
73/// or when the input or result is not finite.
74///
75/// # Examples
76///
77/// ```rust
78/// use use_orbit::circular_orbital_speed;
79///
80/// let speed = circular_orbital_speed(398_600_441_800_000.0, 6_371_000.0);
81///
82/// assert!(speed.is_some_and(|value| (value - 7_909.8).abs() < 1.0));
83/// ```
84#[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/// Computes the orbital period for a circular orbit.
94///
95/// Formula: `T = 2π * sqrt(r³ / μ)`
96///
97/// Returns `None` when `mu` is less than or equal to zero, when `orbital_radius` is less than or
98/// equal to zero, or when the input or result is not finite.
99///
100/// # Examples
101///
102/// ```rust
103/// use use_orbit::circular_orbital_period;
104///
105/// let period = circular_orbital_period(398_600_441_800_000.0, 6_371_000.0);
106///
107/// assert!(period.is_some_and(|value| value.is_finite() && value > 0.0));
108/// ```
109#[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/// Computes the circular orbital radius from an orbital period.
119///
120/// Formula: `r = cbrt(μ * (T / 2π)²)`
121///
122/// Returns `None` when `mu` is less than or equal to zero, when `period` is less than or equal
123/// to zero, or when the input or result is not finite.
124#[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/// Computes the circular orbital radius from circular speed.
136///
137/// Formula: `r = μ / v²`
138///
139/// Returns `None` when `mu` is negative, when `speed` is less than or equal to zero, or when the
140/// input or result is not finite.
141#[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/// Computes the semi-major axis from periapsis and apoapsis radii.
151///
152/// Formula: `a = (r_p + r_a) / 2`
153///
154/// Returns `None` when either radius is less than or equal to zero, when `apoapsis_radius` is
155/// less than `periapsis_radius`, or when the input or result is not finite.
156///
157/// # Examples
158///
159/// ```rust
160/// use use_orbit::semi_major_axis_from_apsides;
161///
162/// assert_eq!(semi_major_axis_from_apsides(10.0, 20.0), Some(15.0));
163/// ```
164#[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/// Computes eccentricity from periapsis and apoapsis radii.
178///
179/// Formula: `e = (r_a - r_p) / (r_a + r_p)`
180///
181/// Returns `None` when either radius is less than or equal to zero, when `apoapsis_radius` is
182/// less than `periapsis_radius`, or when the input or result is not finite.
183#[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/// Computes periapsis radius from semi-major axis and eccentricity.
203///
204/// Formula: `r_p = a * (1 - e)`
205///
206/// Returns `None` when `semi_major_axis` is less than or equal to zero, when `eccentricity` is
207/// outside `[0.0, 1.0)`, or when the input or result is not finite.
208#[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/// Computes apoapsis radius from semi-major axis and eccentricity.
222///
223/// Formula: `r_a = a * (1 + e)`
224///
225/// Returns `None` when `semi_major_axis` is less than or equal to zero, when `eccentricity` is
226/// outside `[0.0, 1.0)`, or when the input or result is not finite.
227#[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/// Computes orbital speed from the vis-viva equation.
241///
242/// Formula: `v = sqrt(μ * (2/r - 1/a))`
243///
244/// Returns `None` when `mu` is negative, when `orbital_radius` or `semi_major_axis` is less than
245/// or equal to zero, when the value under the square root is negative, or when the input or
246/// result is not finite.
247///
248/// # Examples
249///
250/// ```rust
251/// use use_orbit::vis_viva_speed;
252///
253/// assert!(vis_viva_speed(100.0, 10.0, 15.0).is_some_and(|value| value > 0.0));
254/// ```
255#[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/// Computes speed at periapsis using the vis-viva equation.
275///
276/// Returns `None` when the apsides are invalid or when the derived speed is invalid.
277#[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/// Computes speed at apoapsis using the vis-viva equation.
284///
285/// Returns `None` when the apsides are invalid or when the derived speed is invalid.
286#[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/// Computes the orbital period for an elliptical orbit.
293///
294/// Formula: `T = 2π * sqrt(a³ / μ)`
295///
296/// Returns `None` when `mu` is less than or equal to zero, when `semi_major_axis` is less than
297/// or equal to zero, or when the input or result is not finite.
298#[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/// Computes escape speed from a distance around a body with gravitational parameter.
308///
309/// Formula: `v_escape = sqrt(2μ / r)`
310///
311/// Returns `None` when `mu` is negative, when `distance` is less than or equal to zero, or when
312/// the input or result is not finite.
313#[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/// Computes specific orbital energy.
323///
324/// Formula: `ε = v² / 2 - μ / r`
325///
326/// Returns `None` when `mu` is negative, when `distance` is less than or equal to zero, or when
327/// the input or result is not finite.
328#[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/// Computes the semi-major axis for a bound orbit from specific orbital energy.
338///
339/// Formula: `a = -μ / (2ε)`
340///
341/// Returns `None` when `mu` is less than or equal to zero, when `specific_energy` is greater than
342/// or equal to zero, or when the input or result is not finite.
343#[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/// Computes orbital radius from body radius and altitude.
353///
354/// Formula: `r = R + h`
355///
356/// Returns `None` when `body_radius` is less than or equal to zero, when `altitude` is negative,
357/// or when the input or result is not finite.
358#[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/// Computes altitude from body radius and orbital radius.
368///
369/// Formula: `h = r - R`
370///
371/// Returns `None` when `body_radius` is less than or equal to zero, when `orbital_radius` is
372/// less than `body_radius`, or when the input or result is not finite.
373#[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/// Computes the semi-major axis of a Hohmann transfer ellipse.
386///
387/// Formula: `a_transfer = (r1 + r2) / 2`
388///
389/// Returns `None` when either radius is less than or equal to zero, or when the input or result
390/// is not finite.
391#[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/// Computes the transfer time for a Hohmann transfer.
402///
403/// Formula: `t_transfer = π * sqrt(a_transfer³ / μ)`
404///
405/// Returns `None` when `mu` is less than or equal to zero, when either radius is less than or
406/// equal to zero, or when the input or result is not finite.
407#[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/// Computes the first burn for a Hohmann transfer.
418///
419/// Formula: `Δv1 = sqrt(μ/r1) * (sqrt(2r2 / (r1 + r2)) - 1)`
420///
421/// Returns `None` when `mu` is less than or equal to zero, when either radius is less than or
422/// equal to zero, or when the input or result is not finite.
423#[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/// Computes the second burn for a Hohmann transfer.
439///
440/// Formula: `Δv2 = sqrt(μ/r2) * (1 - sqrt(2r1 / (r1 + r2)))`
441///
442/// Returns `None` when `mu` is less than or equal to zero, when either radius is less than or
443/// equal to zero, or when the input or result is not finite.
444#[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/// Computes the total scalar delta-v magnitude for a Hohmann transfer.
460///
461/// Returns `None` when either component burn is invalid.
462///
463/// # Examples
464///
465/// ```rust
466/// use use_orbit::hohmann_total_delta_v;
467///
468/// assert!(hohmann_total_delta_v(100.0, 10.0, 20.0).is_some_and(|value| value >= 0.0));
469/// ```
470#[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/// Mass and optional radius for a central body used in orbital calculations.
479#[derive(Debug, Clone, Copy, PartialEq)]
480pub struct CentralBody {
481    /// Mass in kilograms.
482    pub mass: f64,
483    /// Optional radius in meters.
484    pub radius: Option<f64>,
485}
486
487impl CentralBody {
488    /// Creates a central body with mass and no stored radius.
489    ///
490    /// Returns `None` when `mass` is negative or not finite.
491    #[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    /// Creates a central body with mass and radius.
501    ///
502    /// Returns `None` when `mass` is negative or not finite, or when `radius` is less than or
503    /// equal to zero or not finite.
504    #[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    /// Computes the body's standard gravitational parameter.
517    #[must_use]
518    pub fn gravitational_parameter(&self) -> Option<f64> {
519        gravitational_parameter(self.mass)
520    }
521
522    /// Computes orbital radius from the stored body radius and an altitude.
523    ///
524    /// Returns `None` when the body has no stored radius or when the inputs are invalid.
525    #[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    /// Computes circular orbital speed at a radius around this body.
532    ///
533    /// # Examples
534    ///
535    /// ```rust
536    /// use use_orbit::CentralBody;
537    ///
538    /// let earth = CentralBody::with_radius(5.972e24, 6.371e6);
539    /// let speed = earth.and_then(|body| body.circular_orbital_speed_at_radius(6_771_000.0));
540    ///
541    /// assert!(speed.is_some_and(|value| value > 7_600.0));
542    /// ```
543    #[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    /// Computes circular orbital period at a radius around this body.
550    #[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    /// Computes escape speed at a radius around this body.
557    #[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/// Elliptical orbit state described by a gravitational parameter and apsides.
565#[derive(Debug, Clone, Copy, PartialEq)]
566pub struct EllipticalOrbit {
567    /// Central-body gravitational parameter in cubic meters per second squared.
568    pub mu: f64,
569    /// Periapsis radius in meters.
570    pub periapsis_radius: f64,
571    /// Apoapsis radius in meters.
572    pub apoapsis_radius: f64,
573}
574
575impl EllipticalOrbit {
576    /// Creates an elliptical orbit from a gravitational parameter and apsides.
577    ///
578    /// Returns `None` when `mu` is less than or equal to zero or not finite, or when the apsides
579    /// are invalid.
580    #[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    /// Computes the semi-major axis.
594    #[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    /// Computes eccentricity.
600    #[must_use]
601    pub fn eccentricity(&self) -> Option<f64> {
602        eccentricity_from_apsides(self.periapsis_radius, self.apoapsis_radius)
603    }
604
605    /// Computes the orbital period.
606    ///
607    /// # Examples
608    ///
609    /// ```rust
610    /// use use_orbit::EllipticalOrbit;
611    ///
612    /// let orbit = EllipticalOrbit::new(100.0, 10.0, 20.0);
613    ///
614    /// assert!(orbit.and_then(|value| value.period()).is_some_and(|period| period > 0.0));
615    /// ```
616    #[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    /// Computes speed at periapsis.
623    #[must_use]
624    pub fn periapsis_speed(&self) -> Option<f64> {
625        periapsis_speed(self.mu, self.periapsis_radius, self.apoapsis_radius)
626    }
627
628    /// Computes speed at apoapsis.
629    #[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}