Skip to main content

simular/scenarios/
satellite.rs

1//! Satellite orbital mechanics scenarios.
2//!
3//! Implements Keplerian orbital mechanics with:
4//! - Two-body gravitational dynamics
5//! - Orbital element conversions
6//! - Common orbit types (LEO, GEO, polar, etc.)
7
8use crate::domains::physics::CentralForceField;
9use crate::engine::state::{SimState, Vec3};
10use serde::{Deserialize, Serialize};
11
12/// Standard gravitational parameter for Earth (m³/s²).
13pub const EARTH_MU: f64 = 3.986_004_418e14;
14
15/// Earth equatorial radius (m).
16pub const EARTH_RADIUS: f64 = 6_378_137.0;
17
18/// Classical orbital elements (Keplerian elements).
19#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
20pub struct OrbitalElements {
21    /// Semi-major axis (m).
22    pub a: f64,
23    /// Eccentricity (0 = circular, 0-1 = elliptical).
24    pub e: f64,
25    /// Inclination (radians).
26    pub i: f64,
27    /// Right ascension of ascending node (radians).
28    pub raan: f64,
29    /// Argument of periapsis (radians).
30    pub omega: f64,
31    /// True anomaly (radians).
32    pub nu: f64,
33}
34
35impl OrbitalElements {
36    /// Create circular orbit at given altitude and inclination.
37    #[must_use]
38    pub fn circular(altitude: f64, inclination: f64) -> Self {
39        Self {
40            a: EARTH_RADIUS + altitude,
41            e: 0.0,
42            i: inclination,
43            raan: 0.0,
44            omega: 0.0,
45            nu: 0.0,
46        }
47    }
48
49    /// Create Low Earth Orbit (LEO) at default altitude.
50    #[must_use]
51    pub fn leo() -> Self {
52        Self::circular(400_000.0, 0.0)
53    }
54
55    /// Create Geostationary Earth Orbit (GEO).
56    #[must_use]
57    pub fn geo() -> Self {
58        Self::circular(35_786_000.0, 0.0)
59    }
60
61    /// Create sun-synchronous orbit.
62    #[must_use]
63    pub fn sun_synchronous(altitude: f64) -> Self {
64        // Sun-synchronous inclination depends on altitude
65        // For ~500km altitude, it's approximately 97.4 degrees
66        let inclination = 97.4_f64.to_radians();
67        Self::circular(altitude, inclination)
68    }
69
70    /// Create polar orbit.
71    #[must_use]
72    pub fn polar(altitude: f64) -> Self {
73        Self::circular(altitude, std::f64::consts::FRAC_PI_2)
74    }
75
76    /// Convert to Cartesian state vectors (position, velocity).
77    #[must_use]
78    #[allow(clippy::many_single_char_names)] // Standard orbital mechanics notation
79    pub fn to_cartesian(&self) -> (Vec3, Vec3) {
80        let mu = EARTH_MU;
81
82        // Semi-latus rectum
83        let p = self.a * (1.0 - self.e * self.e);
84
85        // Distance from focus
86        let r_mag = p / (1.0 + self.e * self.nu.cos());
87
88        // Position in perifocal frame
89        let r_pqw = Vec3::new(r_mag * self.nu.cos(), r_mag * self.nu.sin(), 0.0);
90
91        // Velocity in perifocal frame
92        let h = (mu * p).sqrt();
93        let v_pqw = Vec3::new(
94            -mu / h * self.nu.sin(),
95            mu / h * (self.e + self.nu.cos()),
96            0.0,
97        );
98
99        // Rotation matrices
100        let (sin_raan, cos_raan) = self.raan.sin_cos();
101        let (sin_i, cos_i) = self.i.sin_cos();
102        let (sin_omega, cos_omega) = self.omega.sin_cos();
103
104        // Transform to inertial frame (ECI)
105        let transform = |v: &Vec3| -> Vec3 {
106            let x = (cos_raan * cos_omega - sin_raan * sin_omega * cos_i) * v.x
107                + (-cos_raan * sin_omega - sin_raan * cos_omega * cos_i) * v.y;
108            let y = (sin_raan * cos_omega + cos_raan * sin_omega * cos_i) * v.x
109                + (-sin_raan * sin_omega + cos_raan * cos_omega * cos_i) * v.y;
110            let z = sin_omega * sin_i * v.x + cos_omega * sin_i * v.y;
111            Vec3::new(x, y, z)
112        };
113
114        (transform(&r_pqw), transform(&v_pqw))
115    }
116
117    /// Get orbital period (seconds).
118    #[must_use]
119    pub fn period(&self) -> f64 {
120        2.0 * std::f64::consts::PI * (self.a.powi(3) / EARTH_MU).sqrt()
121    }
122
123    /// Get specific orbital energy (J/kg).
124    #[must_use]
125    pub fn energy(&self) -> f64 {
126        -EARTH_MU / (2.0 * self.a)
127    }
128
129    /// Get periapsis altitude (m).
130    #[must_use]
131    pub fn periapsis_altitude(&self) -> f64 {
132        self.a * (1.0 - self.e) - EARTH_RADIUS
133    }
134
135    /// Get apoapsis altitude (m).
136    #[must_use]
137    pub fn apoapsis_altitude(&self) -> f64 {
138        self.a * (1.0 + self.e) - EARTH_RADIUS
139    }
140}
141
142impl Default for OrbitalElements {
143    fn default() -> Self {
144        Self::leo()
145    }
146}
147
148/// Satellite configuration.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct SatelliteConfig {
151    /// Satellite mass (kg).
152    pub mass: f64,
153    /// Drag coefficient.
154    pub cd: f64,
155    /// Cross-sectional area (m²).
156    pub area: f64,
157    /// Orbital elements.
158    pub orbit: OrbitalElements,
159}
160
161impl Default for SatelliteConfig {
162    fn default() -> Self {
163        Self {
164            mass: 1000.0,
165            cd: 2.2,
166            area: 10.0,
167            orbit: OrbitalElements::leo(),
168        }
169    }
170}
171
172/// Satellite scenario for orbital simulations.
173#[derive(Debug, Clone)]
174pub struct SatelliteScenario {
175    config: SatelliteConfig,
176}
177
178impl SatelliteScenario {
179    /// Create a new satellite scenario.
180    #[must_use]
181    pub fn new(config: SatelliteConfig) -> Self {
182        Self { config }
183    }
184
185    /// Create scenario for LEO satellite.
186    #[must_use]
187    pub fn leo(mass: f64) -> Self {
188        Self::new(SatelliteConfig {
189            mass,
190            orbit: OrbitalElements::leo(),
191            ..Default::default()
192        })
193    }
194
195    /// Create scenario for GEO satellite.
196    #[must_use]
197    pub fn geo(mass: f64) -> Self {
198        Self::new(SatelliteConfig {
199            mass,
200            orbit: OrbitalElements::geo(),
201            ..Default::default()
202        })
203    }
204
205    /// Initialize simulation state for the satellite.
206    #[must_use]
207    pub fn init_state(&self) -> SimState {
208        let (position, velocity) = self.config.orbit.to_cartesian();
209
210        let mut state = SimState::new();
211        state.add_body(self.config.mass, position, velocity);
212        state
213    }
214
215    /// Create force field for this scenario.
216    #[must_use]
217    pub fn create_force_field(&self) -> CentralForceField {
218        CentralForceField::new(EARTH_MU, Vec3::zero())
219    }
220
221    /// Get orbital period (seconds).
222    #[must_use]
223    pub fn period(&self) -> f64 {
224        self.config.orbit.period()
225    }
226
227    /// Get orbital energy.
228    #[must_use]
229    pub fn energy(&self) -> f64 {
230        self.config.orbit.energy()
231    }
232
233    /// Get configuration.
234    #[must_use]
235    pub const fn config(&self) -> &SatelliteConfig {
236        &self.config
237    }
238}
239
240#[cfg(test)]
241#[allow(clippy::unwrap_used, clippy::expect_used)]
242mod tests {
243    use super::*;
244    use crate::domains::physics::ForceField;
245
246    #[test]
247    fn test_orbital_elements_circular() {
248        let orbit = OrbitalElements::circular(400_000.0, 0.0);
249
250        assert!(orbit.e.abs() < f64::EPSILON);
251        assert!((orbit.a - (EARTH_RADIUS + 400_000.0)).abs() < 1.0);
252    }
253
254    #[test]
255    fn test_orbital_elements_leo() {
256        let orbit = OrbitalElements::leo();
257
258        // LEO at ~400km
259        assert!((orbit.periapsis_altitude() - 400_000.0).abs() < 1.0);
260    }
261
262    #[test]
263    fn test_orbital_elements_default() {
264        let orbit = OrbitalElements::default();
265        assert_eq!(orbit.a, OrbitalElements::leo().a);
266    }
267
268    #[test]
269    fn test_orbital_elements_geo() {
270        let orbit = OrbitalElements::geo();
271
272        // GEO period is one sidereal day (~86164 seconds), not solar day (86400s)
273        // Sidereal day = 23h 56m 4s = 86164 seconds
274        let period = orbit.period();
275        assert!(
276            (period - 86164.0).abs() < 100.0,
277            "GEO period {} should be ~86164s",
278            period
279        );
280    }
281
282    #[test]
283    fn test_orbital_elements_sun_synchronous() {
284        let orbit = OrbitalElements::sun_synchronous(500_000.0);
285        // Sun-sync inclination is ~97.4 degrees
286        assert!(orbit.i > 1.6 && orbit.i < 1.8);
287    }
288
289    #[test]
290    fn test_orbital_elements_polar() {
291        let orbit = OrbitalElements::polar(600_000.0);
292        assert!((orbit.i - std::f64::consts::FRAC_PI_2).abs() < 0.01);
293    }
294
295    #[test]
296    fn test_orbital_elements_clone() {
297        let orbit = OrbitalElements::leo();
298        let cloned = orbit.clone();
299        assert!((cloned.a - orbit.a).abs() < f64::EPSILON);
300    }
301
302    #[test]
303    fn test_orbital_elements_to_cartesian() {
304        let orbit = OrbitalElements::circular(400_000.0, 0.0);
305        let (pos, vel) = orbit.to_cartesian();
306
307        // Position magnitude should equal semi-major axis for circular orbit at nu=0
308        let r = pos.magnitude();
309        assert!((r - orbit.a).abs() < 1.0, "r={}, a={}", r, orbit.a);
310
311        // Velocity should be circular velocity: v = sqrt(mu/r)
312        let v = vel.magnitude();
313        let v_circular = (EARTH_MU / orbit.a).sqrt();
314        assert!(
315            (v - v_circular).abs() < 10.0,
316            "v={}, v_circ={}",
317            v,
318            v_circular
319        );
320    }
321
322    #[test]
323    fn test_orbital_elements_to_cartesian_inclined() {
324        let orbit = OrbitalElements {
325            a: EARTH_RADIUS + 400_000.0,
326            e: 0.0,
327            i: 0.5, // ~28 degrees
328            raan: 0.0,
329            omega: 0.0,
330            nu: 0.0,
331        };
332        let (pos, _vel) = orbit.to_cartesian();
333        // Should have a z component due to inclination
334        assert!(pos.z.abs() < f64::EPSILON); // At nu=0, z should be 0 for circular orbit
335    }
336
337    #[test]
338    fn test_orbital_elements_energy() {
339        let orbit = OrbitalElements::leo();
340        let energy = orbit.energy();
341        // Energy should be negative (bound orbit)
342        assert!(energy < 0.0);
343    }
344
345    #[test]
346    fn test_orbital_elements_periapsis_apoapsis() {
347        let orbit = OrbitalElements {
348            a: EARTH_RADIUS + 1_000_000.0, // Higher altitude for valid orbit with e=0.1
349            e: 0.1,
350            i: 0.0,
351            raan: 0.0,
352            omega: 0.0,
353            nu: 0.0,
354        };
355        let peri = orbit.periapsis_altitude();
356        let apo = orbit.apoapsis_altitude();
357
358        // Apoapsis > periapsis for elliptical orbit
359        assert!(apo > peri);
360        // Both should be positive for orbit above Earth
361        assert!(peri > 0.0);
362    }
363
364    #[test]
365    fn test_satellite_config_default() {
366        let config = SatelliteConfig::default();
367        assert!(config.mass > 0.0);
368        assert!(config.cd > 0.0);
369        assert!(config.area > 0.0);
370    }
371
372    #[test]
373    fn test_satellite_config_clone() {
374        let config = SatelliteConfig::default();
375        let cloned = config.clone();
376        assert!((cloned.mass - config.mass).abs() < f64::EPSILON);
377    }
378
379    #[test]
380    fn test_satellite_scenario_init() {
381        let scenario = SatelliteScenario::leo(1000.0);
382        let state = scenario.init_state();
383
384        assert_eq!(state.num_bodies(), 1);
385
386        // Check the satellite is at the right altitude
387        let pos = state.positions()[0];
388        let altitude = pos.magnitude() - EARTH_RADIUS;
389        assert!(
390            (altitude - 400_000.0).abs() < 1000.0,
391            "altitude={}",
392            altitude
393        );
394    }
395
396    #[test]
397    fn test_satellite_scenario_new() {
398        let config = SatelliteConfig {
399            mass: 500.0,
400            ..Default::default()
401        };
402        let scenario = SatelliteScenario::new(config);
403        assert!((scenario.config().mass - 500.0).abs() < f64::EPSILON);
404    }
405
406    #[test]
407    fn test_satellite_scenario_geo() {
408        let scenario = SatelliteScenario::geo(2000.0);
409        let state = scenario.init_state();
410        assert_eq!(state.num_bodies(), 1);
411    }
412
413    #[test]
414    fn test_satellite_scenario_clone() {
415        let scenario = SatelliteScenario::leo(1000.0);
416        let cloned = scenario.clone();
417        assert!((cloned.period() - scenario.period()).abs() < f64::EPSILON);
418    }
419
420    #[test]
421    fn test_satellite_scenario_period() {
422        let scenario = SatelliteScenario::leo(1000.0);
423        let period = scenario.period();
424        // LEO period ~92 minutes
425        assert!(period > 5000.0 && period < 6000.0);
426    }
427
428    #[test]
429    fn test_satellite_scenario_energy() {
430        let scenario = SatelliteScenario::leo(1000.0);
431        let energy = scenario.energy();
432        assert!(energy < 0.0);
433    }
434
435    #[test]
436    fn test_satellite_force_field() {
437        let scenario = SatelliteScenario::leo(1000.0);
438        let field = scenario.create_force_field();
439
440        // Acceleration should point toward center
441        let pos = Vec3::new(EARTH_RADIUS + 400_000.0, 0.0, 0.0);
442        let acc = field.acceleration(&pos, 1000.0);
443
444        // Should point in -x direction
445        assert!(acc.x < 0.0);
446        assert!(acc.y.abs() < f64::EPSILON);
447        assert!(acc.z.abs() < f64::EPSILON);
448    }
449
450    #[test]
451    fn test_orbital_period_leo() {
452        let orbit = OrbitalElements::circular(400_000.0, 0.0);
453        let period = orbit.period();
454
455        // LEO period is approximately 92 minutes = 5520 seconds
456        assert!(period > 5000.0 && period < 6000.0, "LEO period={}", period);
457    }
458}
459
460#[cfg(test)]
461mod proptests {
462    use super::*;
463    use proptest::prelude::*;
464
465    proptest! {
466        /// Orbital period increases with semi-major axis (Kepler's 3rd law).
467        #[test]
468        fn prop_period_increases_with_altitude(
469            alt1 in 200_000.0f64..1_000_000.0,
470            alt2 in 200_000.0f64..1_000_000.0,
471        ) {
472            let orbit1 = OrbitalElements::circular(alt1, 0.0);
473            let orbit2 = OrbitalElements::circular(alt2, 0.0);
474
475            let period1 = orbit1.period();
476            let period2 = orbit2.period();
477
478            // Higher altitude -> longer period
479            if alt1 > alt2 {
480                prop_assert!(period1 > period2, "P({})={} should > P({})={}", alt1, period1, alt2, period2);
481            } else if alt1 < alt2 {
482                prop_assert!(period1 < period2, "P({})={} should < P({})={}", alt1, period1, alt2, period2);
483            }
484        }
485
486        /// Orbital energy is negative for bound orbits.
487        #[test]
488        fn prop_bound_orbit_negative_energy(
489            altitude in 200_000.0f64..35_786_000.0,
490            mass in 100.0f64..10000.0,
491        ) {
492            let config = SatelliteConfig {
493                mass,
494                orbit: OrbitalElements::circular(altitude, 0.0),
495                ..Default::default()
496            };
497            let scenario = SatelliteScenario::new(config);
498            let energy = scenario.energy();
499            prop_assert!(energy < 0.0, "Bound orbit energy={} should be negative", energy);
500        }
501
502        /// Eccentricity is in valid range [0, 1) for elliptical orbits.
503        #[test]
504        fn prop_eccentricity_valid(
505            ecc in 0.0f64..0.99,
506        ) {
507            prop_assert!(ecc >= 0.0);
508            prop_assert!(ecc < 1.0);
509        }
510
511        /// Circular orbit has zero eccentricity.
512        #[test]
513        fn prop_circular_orbit_zero_eccentricity(
514            altitude in 200_000.0f64..1_000_000.0,
515        ) {
516            let orbit = OrbitalElements::circular(altitude, 0.0);
517            prop_assert!(orbit.e.abs() < 1e-10, "e={}", orbit.e);
518        }
519
520        /// Semi-major axis must be positive.
521        #[test]
522        fn prop_sma_positive(
523            altitude in 200_000.0f64..35_786_000.0,
524        ) {
525            let orbit = OrbitalElements::circular(altitude, 0.0);
526            prop_assert!(orbit.a > 0.0);
527            prop_assert!(orbit.a > EARTH_RADIUS, "a={} < Re", orbit.a);
528        }
529    }
530}