1use crate::domains::physics::ForceField;
9use crate::engine::state::{SimState, Vec3};
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct StageConfig {
15 pub dry_mass: f64,
17 pub fuel_mass: f64,
19 pub thrust: f64,
21 pub isp: f64,
23 pub cd: f64,
25 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#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct RocketConfig {
45 pub stage1: StageConfig,
47 pub stage2: Option<StageConfig>,
49 pub separation_altitude: f64,
51 pub initial_position: Vec3,
53 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85pub enum StageSeparation {
86 Stage1Active,
88 Separating,
90 Stage2Active,
92 Complete,
94}
95
96#[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 #[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 #[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 #[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 #[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 pub fn consume_fuel(&mut self, dt: f64) {
177 const G0: f64 = 9.80665; 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 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 pub fn complete_separation(&mut self) {
209 if self.stage == StageSeparation::Separating {
210 self.stage = StageSeparation::Stage2Active;
211 }
212 }
213
214 #[must_use]
216 pub const fn current_stage(&self) -> StageSeparation {
217 self.stage
218 }
219
220 #[must_use]
222 pub const fn config(&self) -> &RocketConfig {
223 &self.config
224 }
225
226 #[must_use]
228 pub const fn fuel_remaining_stage1(&self) -> f64 {
229 self.fuel_remaining_1
230 }
231
232 #[must_use]
234 pub const fn fuel_remaining_stage2(&self) -> f64 {
235 self.fuel_remaining_2
236 }
237}
238
239#[derive(Debug, Clone)]
241pub struct AtmosphericModel {
242 pub rho_0: f64,
244 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 #[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#[derive(Debug, Clone)]
271pub struct RocketForceField {
272 pub g_surface: f64,
274 pub earth_radius: f64,
276 pub atmosphere: AtmosphericModel,
278 pub thrust: f64,
280 pub mass: f64,
282 pub cd: f64,
284 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 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 let r = self.earth_radius + altitude;
318 let g = self.g_surface * (self.earth_radius / r).powi(2);
319
320 let thrust_acc = self.thrust / self.mass;
322
323 let rho = self.atmosphere.density(altitude);
326 let drag_factor = 0.5 * rho * self.cd * self.area / self.mass;
327
328 Vec3::new(0.0, 0.0, thrust_acc - g - drag_factor)
330 }
331
332 fn potential(&self, position: &Vec3, mass: f64) -> f64 {
333 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, ..Default::default()
428 };
429 let mut scenario = RocketScenario::new(config);
430
431 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 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 scenario.check_separation(100000.0);
485 scenario.complete_separation();
486 let stage2_mass = scenario.total_mass();
487
488 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 assert!(!scenario.check_separation(50.0));
502 assert_eq!(scenario.current_stage(), StageSeparation::Stage1Active);
503
504 assert!(scenario.check_separation(100.0));
506 assert_eq!(scenario.current_stage(), StageSeparation::Separating);
507
508 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 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 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 assert!((atm.density(0.0) - 1.225).abs() < 0.01);
546
547 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 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 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 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 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 #[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 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 #[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 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 #[test]
678 fn prop_specific_impulse(
679 isp in 200.0f64..500.0,
680 mass_flow in 10.0f64..100.0,
681 ) {
682 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 #[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 #[test]
703 fn prop_delta_v_tsiolkovsky(
704 isp in 200.0f64..450.0,
705 mass_ratio in 1.5f64..10.0,
706 ) {
707 let g0 = 9.81;
709 let delta_v = isp * g0 * mass_ratio.ln();
710
711 prop_assert!(delta_v > 0.0);
712 let delta_v_higher = isp * g0 * (mass_ratio * 1.1).ln();
714 prop_assert!(delta_v_higher > delta_v);
715 }
716 }
717}