Skip to main content

simular/scenarios/
rocket.rs

1//! Rocket launch and stage separation scenarios.
2//!
3//! Implements multi-stage rocket dynamics with:
4//! - Stage separation events
5//! - Atmospheric drag
6//! - Variable mass (fuel consumption)
7
8use crate::domains::physics::ForceField;
9use crate::engine::state::{SimState, Vec3};
10use serde::{Deserialize, Serialize};
11
12/// Configuration for a rocket stage.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct StageConfig {
15    /// Dry mass (kg).
16    pub dry_mass: f64,
17    /// Fuel mass (kg).
18    pub fuel_mass: f64,
19    /// Thrust (N).
20    pub thrust: f64,
21    /// Specific impulse (s).
22    pub isp: f64,
23    /// Drag coefficient.
24    pub cd: f64,
25    /// Cross-sectional area (m²).
26    pub area: f64,
27}
28
29impl Default for StageConfig {
30    fn default() -> Self {
31        Self {
32            dry_mass: 25_000.0,
33            fuel_mass: 100_000.0,
34            thrust: 1_000_000.0,
35            isp: 300.0,
36            cd: 0.3,
37            area: 10.0,
38        }
39    }
40}
41
42/// Rocket configuration.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct RocketConfig {
45    /// First stage configuration.
46    pub stage1: StageConfig,
47    /// Second stage configuration (optional).
48    pub stage2: Option<StageConfig>,
49    /// Separation altitude (m).
50    pub separation_altitude: f64,
51    /// Initial position.
52    pub initial_position: Vec3,
53    /// Initial velocity.
54    pub initial_velocity: Vec3,
55}
56
57impl Default for RocketConfig {
58    fn default() -> Self {
59        Self {
60            stage1: StageConfig {
61                dry_mass: 25_000.0,
62                fuel_mass: 400_000.0,
63                thrust: 7_600_000.0,
64                isp: 282.0,
65                cd: 0.3,
66                area: 10.0,
67            },
68            stage2: Some(StageConfig {
69                dry_mass: 4_000.0,
70                fuel_mass: 110_000.0,
71                thrust: 934_000.0,
72                isp: 348.0,
73                cd: 0.3,
74                area: 10.0,
75            }),
76            separation_altitude: 80_000.0,
77            initial_position: Vec3::zero(),
78            initial_velocity: Vec3::zero(),
79        }
80    }
81}
82
83/// Stage separation event.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85pub enum StageSeparation {
86    /// First stage active.
87    Stage1Active,
88    /// Separation in progress.
89    Separating,
90    /// Second stage active.
91    Stage2Active,
92    /// All stages exhausted.
93    Complete,
94}
95
96/// Rocket scenario for launch simulations.
97#[derive(Debug, Clone)]
98pub struct RocketScenario {
99    config: RocketConfig,
100    stage: StageSeparation,
101    fuel_remaining_1: f64,
102    fuel_remaining_2: f64,
103}
104
105impl RocketScenario {
106    /// Create a new rocket scenario.
107    #[must_use]
108    pub fn new(config: RocketConfig) -> Self {
109        let fuel_remaining_1 = config.stage1.fuel_mass;
110        let fuel_remaining_2 = config.stage2.as_ref().map_or(0.0, |s| s.fuel_mass);
111
112        Self {
113            config,
114            stage: StageSeparation::Stage1Active,
115            fuel_remaining_1,
116            fuel_remaining_2,
117        }
118    }
119
120    /// Initialize simulation state for the rocket.
121    #[must_use]
122    pub fn init_state(&self) -> SimState {
123        let mut state = SimState::new();
124
125        let total_mass = self.total_mass();
126        state.add_body(
127            total_mass,
128            self.config.initial_position,
129            self.config.initial_velocity,
130        );
131
132        state
133    }
134
135    /// Get total current mass.
136    #[must_use]
137    pub fn total_mass(&self) -> f64 {
138        match self.stage {
139            StageSeparation::Stage1Active | StageSeparation::Separating => {
140                self.config.stage1.dry_mass
141                    + self.fuel_remaining_1
142                    + self
143                        .config
144                        .stage2
145                        .as_ref()
146                        .map_or(0.0, |s| s.dry_mass + s.fuel_mass)
147            }
148            StageSeparation::Stage2Active => self
149                .config
150                .stage2
151                .as_ref()
152                .map_or(0.0, |s| s.dry_mass + self.fuel_remaining_2),
153            StageSeparation::Complete => self
154                .config
155                .stage2
156                .as_ref()
157                .map_or(self.config.stage1.dry_mass, |s| s.dry_mass),
158        }
159    }
160
161    /// Get current thrust.
162    #[must_use]
163    pub fn current_thrust(&self) -> f64 {
164        match self.stage {
165            StageSeparation::Stage1Active if self.fuel_remaining_1 > 0.0 => {
166                self.config.stage1.thrust
167            }
168            StageSeparation::Stage2Active if self.fuel_remaining_2 > 0.0 => {
169                self.config.stage2.as_ref().map_or(0.0, |s| s.thrust)
170            }
171            _ => 0.0,
172        }
173    }
174
175    /// Consume fuel for a timestep.
176    pub fn consume_fuel(&mut self, dt: f64) {
177        const G0: f64 = 9.80665; // Standard gravity
178
179        match self.stage {
180            StageSeparation::Stage1Active => {
181                let mass_flow = self.config.stage1.thrust / (self.config.stage1.isp * G0);
182                self.fuel_remaining_1 = (self.fuel_remaining_1 - mass_flow * dt).max(0.0);
183            }
184            StageSeparation::Stage2Active => {
185                if let Some(ref stage2) = self.config.stage2 {
186                    let mass_flow = stage2.thrust / (stage2.isp * G0);
187                    self.fuel_remaining_2 = (self.fuel_remaining_2 - mass_flow * dt).max(0.0);
188                }
189            }
190            _ => {}
191        }
192    }
193
194    /// Check and perform stage separation.
195    pub fn check_separation(&mut self, altitude: f64) -> bool {
196        if self.stage == StageSeparation::Stage1Active
197            && altitude >= self.config.separation_altitude
198            && self.config.stage2.is_some()
199        {
200            self.stage = StageSeparation::Separating;
201            true
202        } else {
203            false
204        }
205    }
206
207    /// Complete stage separation.
208    pub fn complete_separation(&mut self) {
209        if self.stage == StageSeparation::Separating {
210            self.stage = StageSeparation::Stage2Active;
211        }
212    }
213
214    /// Get current stage.
215    #[must_use]
216    pub const fn current_stage(&self) -> StageSeparation {
217        self.stage
218    }
219
220    /// Get configuration.
221    #[must_use]
222    pub const fn config(&self) -> &RocketConfig {
223        &self.config
224    }
225
226    /// Get fuel remaining (stage 1).
227    #[must_use]
228    pub const fn fuel_remaining_stage1(&self) -> f64 {
229        self.fuel_remaining_1
230    }
231
232    /// Get fuel remaining (stage 2).
233    #[must_use]
234    pub const fn fuel_remaining_stage2(&self) -> f64 {
235        self.fuel_remaining_2
236    }
237}
238
239/// Atmospheric model for drag calculation.
240#[derive(Debug, Clone)]
241pub struct AtmosphericModel {
242    /// Sea-level density (kg/m³).
243    pub rho_0: f64,
244    /// Scale height (m).
245    pub scale_height: f64,
246}
247
248impl Default for AtmosphericModel {
249    fn default() -> Self {
250        Self {
251            rho_0: 1.225,
252            scale_height: 8500.0,
253        }
254    }
255}
256
257impl AtmosphericModel {
258    /// Get density at altitude.
259    #[must_use]
260    pub fn density(&self, altitude: f64) -> f64 {
261        if altitude < 0.0 {
262            self.rho_0
263        } else {
264            self.rho_0 * (-altitude / self.scale_height).exp()
265        }
266    }
267}
268
269/// Rocket force field combining gravity, thrust, and drag.
270#[derive(Debug, Clone)]
271pub struct RocketForceField {
272    /// Gravitational acceleration at surface.
273    pub g_surface: f64,
274    /// Earth radius (m).
275    pub earth_radius: f64,
276    /// Atmospheric model.
277    pub atmosphere: AtmosphericModel,
278    /// Current thrust (updated externally).
279    pub thrust: f64,
280    /// Current mass (updated externally).
281    pub mass: f64,
282    /// Drag coefficient.
283    pub cd: f64,
284    /// Cross-sectional area.
285    pub area: f64,
286}
287
288impl Default for RocketForceField {
289    fn default() -> Self {
290        Self {
291            g_surface: 9.80665,
292            earth_radius: 6_371_000.0,
293            atmosphere: AtmosphericModel::default(),
294            thrust: 0.0,
295            mass: 1.0,
296            cd: 0.3,
297            area: 10.0,
298        }
299    }
300}
301
302impl RocketForceField {
303    /// Update thrust and mass.
304    pub fn update(&mut self, thrust: f64, mass: f64, cd: f64, area: f64) {
305        self.thrust = thrust;
306        self.mass = mass;
307        self.cd = cd;
308        self.area = area;
309    }
310}
311
312impl ForceField for RocketForceField {
313    fn acceleration(&self, position: &Vec3, _mass: f64) -> Vec3 {
314        let altitude = position.z;
315
316        // Gravity (inverse square law)
317        let r = self.earth_radius + altitude;
318        let g = self.g_surface * (self.earth_radius / r).powi(2);
319
320        // Thrust (upward)
321        let thrust_acc = self.thrust / self.mass;
322
323        // Drag (opposing velocity direction)
324        // Note: this is simplified - real drag depends on velocity
325        let rho = self.atmosphere.density(altitude);
326        let drag_factor = 0.5 * rho * self.cd * self.area / self.mass;
327
328        // Net acceleration (thrust up, gravity down)
329        Vec3::new(0.0, 0.0, thrust_acc - g - drag_factor)
330    }
331
332    fn potential(&self, position: &Vec3, mass: f64) -> f64 {
333        // Gravitational potential energy
334        let altitude = position.z;
335        let r = self.earth_radius + altitude;
336        -self.g_surface * self.earth_radius * self.earth_radius * mass / r
337    }
338}
339
340#[cfg(test)]
341#[allow(clippy::unwrap_used, clippy::expect_used)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_stage_config_default() {
347        let config = StageConfig::default();
348        assert!(config.dry_mass > 0.0);
349        assert!(config.fuel_mass > 0.0);
350        assert!(config.thrust > 0.0);
351    }
352
353    #[test]
354    fn test_stage_config_clone() {
355        let config = StageConfig::default();
356        let cloned = config.clone();
357        assert!((cloned.thrust - config.thrust).abs() < f64::EPSILON);
358    }
359
360    #[test]
361    fn test_rocket_config_default() {
362        let config = RocketConfig::default();
363        assert!(config.stage1.thrust > 0.0);
364        assert!(config.stage2.is_some());
365        assert!(config.separation_altitude > 0.0);
366    }
367
368    #[test]
369    fn test_rocket_config_clone() {
370        let config = RocketConfig::default();
371        let cloned = config.clone();
372        assert!((cloned.separation_altitude - config.separation_altitude).abs() < f64::EPSILON);
373    }
374
375    #[test]
376    fn test_rocket_scenario_init() {
377        let scenario = RocketScenario::new(RocketConfig::default());
378        let state = scenario.init_state();
379
380        assert_eq!(state.num_bodies(), 1);
381        assert!(scenario.total_mass() > 0.0);
382        assert_eq!(scenario.current_stage(), StageSeparation::Stage1Active);
383    }
384
385    #[test]
386    fn test_rocket_scenario_clone() {
387        let scenario = RocketScenario::new(RocketConfig::default());
388        let cloned = scenario.clone();
389        assert_eq!(cloned.current_stage(), scenario.current_stage());
390    }
391
392    #[test]
393    fn test_rocket_scenario_config() {
394        let config = RocketConfig {
395            separation_altitude: 50000.0,
396            ..Default::default()
397        };
398        let scenario = RocketScenario::new(config);
399        assert!((scenario.config().separation_altitude - 50000.0).abs() < f64::EPSILON);
400    }
401
402    #[test]
403    fn test_rocket_fuel_remaining() {
404        let config = RocketConfig::default();
405        let scenario = RocketScenario::new(config.clone());
406        assert!((scenario.fuel_remaining_stage1() - config.stage1.fuel_mass).abs() < f64::EPSILON);
407        assert!(
408            (scenario.fuel_remaining_stage2() - config.stage2.as_ref().unwrap().fuel_mass).abs()
409                < f64::EPSILON
410        );
411    }
412
413    #[test]
414    fn test_rocket_fuel_consumption() {
415        let mut scenario = RocketScenario::new(RocketConfig::default());
416        let initial_fuel = scenario.fuel_remaining_stage1();
417
418        scenario.consume_fuel(1.0);
419
420        assert!(scenario.fuel_remaining_stage1() < initial_fuel);
421    }
422
423    #[test]
424    fn test_rocket_fuel_consumption_stage2() {
425        let config = RocketConfig {
426            separation_altitude: 0.0, // Immediate separation
427            ..Default::default()
428        };
429        let mut scenario = RocketScenario::new(config);
430
431        // Trigger separation
432        scenario.check_separation(100.0);
433        scenario.complete_separation();
434
435        let initial_fuel = scenario.fuel_remaining_stage2();
436        scenario.consume_fuel(1.0);
437        assert!(scenario.fuel_remaining_stage2() < initial_fuel);
438    }
439
440    #[test]
441    fn test_rocket_current_thrust_stage1() {
442        let scenario = RocketScenario::new(RocketConfig::default());
443        let thrust = scenario.current_thrust();
444        assert!(thrust > 0.0);
445    }
446
447    #[test]
448    fn test_rocket_current_thrust_stage2() {
449        let config = RocketConfig {
450            separation_altitude: 0.0,
451            ..Default::default()
452        };
453        let mut scenario = RocketScenario::new(config);
454
455        // Transition to stage 2
456        scenario.check_separation(100.0);
457        scenario.complete_separation();
458
459        let thrust = scenario.current_thrust();
460        assert!(thrust > 0.0);
461    }
462
463    #[test]
464    fn test_rocket_current_thrust_no_fuel() {
465        let config = RocketConfig {
466            stage1: StageConfig {
467                fuel_mass: 0.0,
468                ..Default::default()
469            },
470            ..Default::default()
471        };
472        let scenario = RocketScenario::new(config);
473        let thrust = scenario.current_thrust();
474        assert!((thrust - 0.0).abs() < f64::EPSILON);
475    }
476
477    #[test]
478    fn test_rocket_total_mass_stages() {
479        let config = RocketConfig::default();
480        let mut scenario = RocketScenario::new(config.clone());
481        let stage1_mass = scenario.total_mass();
482
483        // Transition to stage 2
484        scenario.check_separation(100000.0);
485        scenario.complete_separation();
486        let stage2_mass = scenario.total_mass();
487
488        // Stage 2 should be lighter (no stage 1)
489        assert!(stage2_mass < stage1_mass);
490    }
491
492    #[test]
493    fn test_rocket_stage_separation() {
494        let config = RocketConfig {
495            separation_altitude: 100.0,
496            ..Default::default()
497        };
498        let mut scenario = RocketScenario::new(config);
499
500        // Below separation altitude
501        assert!(!scenario.check_separation(50.0));
502        assert_eq!(scenario.current_stage(), StageSeparation::Stage1Active);
503
504        // At separation altitude
505        assert!(scenario.check_separation(100.0));
506        assert_eq!(scenario.current_stage(), StageSeparation::Separating);
507
508        // Complete separation
509        scenario.complete_separation();
510        assert_eq!(scenario.current_stage(), StageSeparation::Stage2Active);
511    }
512
513    #[test]
514    fn test_rocket_no_stage2() {
515        let config = RocketConfig {
516            stage2: None,
517            ..Default::default()
518        };
519        let mut scenario = RocketScenario::new(config);
520
521        // No separation without stage 2
522        assert!(!scenario.check_separation(100000.0));
523        assert_eq!(scenario.current_stage(), StageSeparation::Stage1Active);
524    }
525
526    #[test]
527    fn test_rocket_complete_separation_when_not_separating() {
528        let mut scenario = RocketScenario::new(RocketConfig::default());
529        // Not separating
530        scenario.complete_separation();
531        assert_eq!(scenario.current_stage(), StageSeparation::Stage1Active);
532    }
533
534    #[test]
535    fn test_stage_separation_eq() {
536        assert_eq!(StageSeparation::Stage1Active, StageSeparation::Stage1Active);
537        assert_ne!(StageSeparation::Stage1Active, StageSeparation::Stage2Active);
538    }
539
540    #[test]
541    fn test_atmospheric_model() {
542        let atm = AtmosphericModel::default();
543
544        // Sea level
545        assert!((atm.density(0.0) - 1.225).abs() < 0.01);
546
547        // Decreases with altitude
548        assert!(atm.density(10000.0) < atm.density(0.0));
549        assert!(atm.density(50000.0) < atm.density(10000.0));
550    }
551
552    #[test]
553    fn test_atmospheric_model_negative_altitude() {
554        let atm = AtmosphericModel::default();
555        // Below sea level returns sea level density
556        assert!((atm.density(-100.0) - 1.225).abs() < f64::EPSILON);
557    }
558
559    #[test]
560    fn test_atmospheric_model_clone() {
561        let atm = AtmosphericModel::default();
562        let cloned = atm.clone();
563        assert!((cloned.rho_0 - atm.rho_0).abs() < f64::EPSILON);
564    }
565
566    #[test]
567    fn test_rocket_force_field() {
568        let field = RocketForceField {
569            thrust: 1_000_000.0,
570            mass: 100_000.0,
571            ..Default::default()
572        };
573
574        let acc = field.acceleration(&Vec3::new(0.0, 0.0, 0.0), 100_000.0);
575
576        // Should have positive z acceleration (thrust > gravity at launch)
577        // thrust/mass = 10 m/s², gravity ≈ 9.8 m/s², so net positive
578        assert!(acc.z > 0.0);
579    }
580
581    #[test]
582    fn test_rocket_force_field_default() {
583        let field = RocketForceField::default();
584        assert!((field.g_surface - 9.80665).abs() < 0.001);
585        assert!(field.earth_radius > 0.0);
586    }
587
588    #[test]
589    fn test_rocket_force_field_update() {
590        let mut field = RocketForceField::default();
591        field.update(1000.0, 500.0, 0.5, 20.0);
592        assert!((field.thrust - 1000.0).abs() < f64::EPSILON);
593        assert!((field.mass - 500.0).abs() < f64::EPSILON);
594        assert!((field.cd - 0.5).abs() < f64::EPSILON);
595        assert!((field.area - 20.0).abs() < f64::EPSILON);
596    }
597
598    #[test]
599    fn test_rocket_force_field_clone() {
600        let field = RocketForceField {
601            thrust: 1000.0,
602            ..Default::default()
603        };
604        let cloned = field.clone();
605        assert!((cloned.thrust - 1000.0).abs() < f64::EPSILON);
606    }
607
608    #[test]
609    fn test_rocket_force_field_potential() {
610        let field = RocketForceField::default();
611        let pos = Vec3::new(0.0, 0.0, 0.0);
612        let pe = field.potential(&pos, 1000.0);
613        // Potential energy should be negative (gravitational)
614        assert!(pe < 0.0);
615    }
616
617    #[test]
618    fn test_rocket_force_field_high_altitude() {
619        let field = RocketForceField {
620            thrust: 0.0,
621            mass: 1000.0,
622            ..Default::default()
623        };
624
625        // At high altitude, gravity should be weaker
626        let acc_surface = field.acceleration(&Vec3::new(0.0, 0.0, 0.0), 1000.0);
627        let acc_high = field.acceleration(&Vec3::new(0.0, 0.0, 100_000.0), 1000.0);
628
629        assert!(acc_high.z.abs() < acc_surface.z.abs());
630    }
631}
632
633#[cfg(test)]
634mod proptests {
635    use super::*;
636    use proptest::prelude::*;
637
638    proptest! {
639        /// Thrust-to-weight ratio determines liftoff capability.
640        #[test]
641        fn prop_twr_liftoff(
642            thrust in 5000.0f64..50000.0,
643            mass in 500.0f64..5000.0,
644        ) {
645            let g = 9.81;
646            let twr = thrust / (mass * g);
647
648            // TWR > 1 means rocket can lift off
649            if twr > 1.0 {
650                let net_accel = thrust / mass - g;
651                prop_assert!(net_accel > 0.0, "TWR={} but no liftoff", twr);
652            }
653        }
654
655        /// Gravity decreases with altitude (inverse square law).
656        #[test]
657        fn prop_gravity_decreases_with_altitude(
658            alt1 in 0.0f64..100_000.0,
659            alt2 in 0.0f64..100_000.0,
660        ) {
661            let field = RocketForceField::default();
662            let pos1 = Vec3::new(0.0, 0.0, alt1);
663            let pos2 = Vec3::new(0.0, 0.0, alt2);
664
665            let acc1 = field.acceleration(&pos1, 1000.0).z.abs();
666            let acc2 = field.acceleration(&pos2, 1000.0).z.abs();
667
668            // Higher altitude -> weaker gravity
669            if alt1 > alt2 {
670                prop_assert!(acc1 <= acc2 + 0.001, "g at {}m = {}, at {}m = {}", alt1, acc1, alt2, acc2);
671            } else if alt1 < alt2 {
672                prop_assert!(acc1 >= acc2 - 0.001, "g at {}m = {}, at {}m = {}", alt1, acc1, alt2, acc2);
673            }
674        }
675
676        /// Specific impulse relates thrust and mass flow rate.
677        #[test]
678        fn prop_specific_impulse(
679            isp in 200.0f64..500.0,
680            mass_flow in 10.0f64..100.0,
681        ) {
682            // Thrust = Isp * g0 * mass_flow
683            let g0 = 9.81;
684            let thrust = isp * g0 * mass_flow;
685
686            prop_assert!(thrust > 0.0);
687            prop_assert!(thrust > isp * mass_flow, "thrust={}", thrust);
688        }
689
690        /// Rocket mass must be positive.
691        #[test]
692        fn prop_mass_positive(
693            dry_mass in 100.0f64..10000.0,
694            fuel_mass in 100.0f64..50000.0,
695        ) {
696            let total_mass = dry_mass + fuel_mass;
697            prop_assert!(total_mass > 0.0);
698            prop_assert!(total_mass > dry_mass);
699        }
700
701        /// Delta-v from Tsiolkovsky rocket equation.
702        #[test]
703        fn prop_delta_v_tsiolkovsky(
704            isp in 200.0f64..450.0,
705            mass_ratio in 1.5f64..10.0,
706        ) {
707            // delta_v = Isp * g0 * ln(mass_ratio)
708            let g0 = 9.81;
709            let delta_v = isp * g0 * mass_ratio.ln();
710
711            prop_assert!(delta_v > 0.0);
712            // Higher mass ratio -> higher delta-v
713            let delta_v_higher = isp * g0 * (mass_ratio * 1.1).ln();
714            prop_assert!(delta_v_higher > delta_v);
715        }
716    }
717}