1use crate::domains::physics::CentralForceField;
9use crate::engine::state::{SimState, Vec3};
10use serde::{Deserialize, Serialize};
11
12pub const EARTH_MU: f64 = 3.986_004_418e14;
14
15pub const EARTH_RADIUS: f64 = 6_378_137.0;
17
18#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
20pub struct OrbitalElements {
21 pub a: f64,
23 pub e: f64,
25 pub i: f64,
27 pub raan: f64,
29 pub omega: f64,
31 pub nu: f64,
33}
34
35impl OrbitalElements {
36 #[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 #[must_use]
51 pub fn leo() -> Self {
52 Self::circular(400_000.0, 0.0)
53 }
54
55 #[must_use]
57 pub fn geo() -> Self {
58 Self::circular(35_786_000.0, 0.0)
59 }
60
61 #[must_use]
63 pub fn sun_synchronous(altitude: f64) -> Self {
64 let inclination = 97.4_f64.to_radians();
67 Self::circular(altitude, inclination)
68 }
69
70 #[must_use]
72 pub fn polar(altitude: f64) -> Self {
73 Self::circular(altitude, std::f64::consts::FRAC_PI_2)
74 }
75
76 #[must_use]
78 #[allow(clippy::many_single_char_names)] pub fn to_cartesian(&self) -> (Vec3, Vec3) {
80 let mu = EARTH_MU;
81
82 let p = self.a * (1.0 - self.e * self.e);
84
85 let r_mag = p / (1.0 + self.e * self.nu.cos());
87
88 let r_pqw = Vec3::new(r_mag * self.nu.cos(), r_mag * self.nu.sin(), 0.0);
90
91 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 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 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 #[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 #[must_use]
125 pub fn energy(&self) -> f64 {
126 -EARTH_MU / (2.0 * self.a)
127 }
128
129 #[must_use]
131 pub fn periapsis_altitude(&self) -> f64 {
132 self.a * (1.0 - self.e) - EARTH_RADIUS
133 }
134
135 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct SatelliteConfig {
151 pub mass: f64,
153 pub cd: f64,
155 pub area: f64,
157 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#[derive(Debug, Clone)]
174pub struct SatelliteScenario {
175 config: SatelliteConfig,
176}
177
178impl SatelliteScenario {
179 #[must_use]
181 pub fn new(config: SatelliteConfig) -> Self {
182 Self { config }
183 }
184
185 #[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 #[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 #[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 #[must_use]
217 pub fn create_force_field(&self) -> CentralForceField {
218 CentralForceField::new(EARTH_MU, Vec3::zero())
219 }
220
221 #[must_use]
223 pub fn period(&self) -> f64 {
224 self.config.orbit.period()
225 }
226
227 #[must_use]
229 pub fn energy(&self) -> f64 {
230 self.config.orbit.energy()
231 }
232
233 #[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 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 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 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 let r = pos.magnitude();
309 assert!((r - orbit.a).abs() < 1.0, "r={}, a={}", r, orbit.a);
310
311 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, raan: 0.0,
329 omega: 0.0,
330 nu: 0.0,
331 };
332 let (pos, _vel) = orbit.to_cartesian();
333 assert!(pos.z.abs() < f64::EPSILON); }
336
337 #[test]
338 fn test_orbital_elements_energy() {
339 let orbit = OrbitalElements::leo();
340 let energy = orbit.energy();
341 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, 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 assert!(apo > peri);
360 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 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 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 let pos = Vec3::new(EARTH_RADIUS + 400_000.0, 0.0, 0.0);
442 let acc = field.acceleration(&pos, 1000.0);
443
444 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 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 #[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 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 #[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 #[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 #[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 #[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}