Skip to main content

rockets_engine/
model_rocket.rs

1//! Model-rocket conveniences: RASP `.eng` motor files, apogee recovery drag,
2//! and illustrative airframe presets. No new integrator code — these are data
3//! parsers and a force-model wrapper on the existing machinery.
4
5use crate::{Error, Motor, Result, ThrustCurve, ThrustPoint};
6
7/// A parsed RASP motor file (the `.eng` format used by thrustcurve.org and
8/// OpenRocket): a header line of manufacturer, designation, diameter, length,
9/// delays and masses, then time/thrust samples.
10#[derive(Debug, Clone)]
11pub struct MotorFile {
12    /// Manufacturer code, e.g. "AeroTech" or "Estes".
13    pub manufacturer: String,
14    /// Motor designation, e.g. "C6" or "D12".
15    pub designation: String,
16    /// Motor diameter in millimetres.
17    pub diameter_mm: f64,
18    /// Motor length in millimetres.
19    pub length_mm: f64,
20    /// Delay token from the file ("0", "4", "P" for plugged, ...).
21    pub delays: String,
22    /// Propellant mass in kilograms.
23    pub propellant_mass_kg: f64,
24    /// Loaded motor mass (casing included) in kilograms.
25    pub total_mass_kg: f64,
26    /// The parsed thrust curve.
27    pub curve: ThrustCurve,
28}
29
30impl MotorFile {
31    /// Parse RASP text. `;` starts a comment, blank lines are ignored, and the
32    /// first non-comment line must be the header.
33    pub fn parse(text: &str) -> Result<Self> {
34        let mut lines = text
35            .lines()
36            .map(str::trim)
37            .filter(|line| !line.is_empty() && !line.starts_with(';'));
38        let header = lines
39            .next()
40            .ok_or_else(|| Error::invalid("motor file", "no header line"))?;
41        let tokens: Vec<&str> = header.split_whitespace().collect();
42        if tokens.len() < 6 {
43            return Err(Error::invalid(
44                "motor file header",
45                "expected: Designation Diameter Length Delays PropellantMass TotalMass [Mfr]",
46            ));
47        }
48        let number = |token: &str, field: &'static str| -> Result<f64> {
49            token
50                .parse()
51                .map_err(|_| Error::invalid(field, "must be numeric"))
52        };
53        let numeric = |token: &str| token.parse::<f64>().is_ok();
54        // Two layouts: canonical RASP (thrustcurve.org) puts the designation
55        // first and an optional manufacturer last; our legacy inline form
56        // puts the manufacturer first.
57        let (
58            manufacturer,
59            designation,
60            diameter_mm,
61            length_mm,
62            delays,
63            propellant_mass_kg,
64            total_mass_kg,
65            header_len,
66        ) = if numeric(tokens[1]) {
67            // Designation Diameter Length Delays PropellantMass TotalMass [Mfr]
68            (
69                tokens.get(6).map(|s| s.to_string()),
70                tokens[0].to_string(),
71                number(tokens[1], "diameter_mm")?,
72                number(tokens[2], "length_mm")?,
73                tokens[3].to_string(),
74                number(tokens[4], "propellant_mass")?,
75                number(tokens[5], "total_mass")?,
76                6 + usize::from(tokens.len() > 6),
77            )
78        } else {
79            // Mfr Designation Diameter Length [Delays] PropellantMass TotalMass
80            if tokens.len() >= 7 {
81                (
82                    Some(tokens[0].to_string()),
83                    tokens[1].to_string(),
84                    number(tokens[2], "diameter_mm")?,
85                    number(tokens[3], "length_mm")?,
86                    tokens[4].to_string(),
87                    number(tokens[5], "propellant_mass")?,
88                    number(tokens[6], "total_mass")?,
89                    7,
90                )
91            } else {
92                (
93                    Some(tokens[0].to_string()),
94                    tokens[1].to_string(),
95                    number(tokens[2], "diameter_mm")?,
96                    number(tokens[3], "length_mm")?,
97                    String::new(),
98                    number(tokens[4], "propellant_mass")?,
99                    number(tokens[5], "total_mass")?,
100                    6,
101                )
102            }
103        };
104        // The header may share its line with thrust samples (e.g. when a paste
105        // flattens newlines). Treat every token after the header as a flat
106        // stream of time/thrust pairs, independent of original line breaks.
107        let mut stream = tokens[header_len..]
108            .iter()
109            .copied()
110            .chain(lines.flat_map(|line| line.split_whitespace()));
111        let mut points = Vec::new();
112        while let Some(time_tok) = stream.next() {
113            let thrust_tok = stream
114                .next()
115                .ok_or_else(|| Error::invalid("motor file", "row needs time and thrust"))?;
116            let time_s: f64 = time_tok
117                .parse()
118                .map_err(|_| Error::invalid("motor file", "non-numeric time"))?;
119            let thrust_n: f64 = thrust_tok
120                .parse()
121                .map_err(|_| Error::invalid("motor file", "non-numeric thrust"))?;
122            points.push(ThrustPoint { time_s, thrust_n });
123        }
124        if points.len() < 2 {
125            return Err(Error::invalid(
126                "motor file",
127                "needs at least two thrust samples",
128            ));
129        }
130        // RASP curves start at their first sample; thrust is zero at t = 0,
131        // so prepend the origin when the file starts later (real files do).
132        if points[0].time_s > 0.0 {
133            points.insert(
134                0,
135                ThrustPoint {
136                    time_s: 0.0,
137                    thrust_n: 0.0,
138                },
139            );
140        }
141        Ok(Self {
142            manufacturer: manufacturer.unwrap_or_default(),
143            designation,
144            diameter_mm,
145            length_mm,
146            delays,
147            propellant_mass_kg,
148            total_mass_kg,
149            curve: ThrustCurve::new(points)?,
150        })
151    }
152
153    /// Read and parse a `.eng` file from disk (native builds).
154    #[cfg(not(target_arch = "wasm32"))]
155    pub fn from_path(path: impl AsRef<std::path::Path>) -> Result<Self> {
156        let text = std::fs::read_to_string(path.as_ref())
157            .map_err(|e| Error::Model(format!("could not read motor file: {e}")))?;
158        Self::parse(&text)
159    }
160
161    /// Build a scheduled motor from the parsed curve and propellant mass.
162    pub fn motor(&self, ignition_time_s: f64) -> Result<Motor> {
163        Motor::new(self.curve.clone(), self.propellant_mass_kg, ignition_time_s)
164    }
165}
166
167/// The built-in motor names accepted by [`motor_by_name`].
168pub const MOTOR_NAMES: &[&str] = &[
169    "estes:a8",
170    "estes:b6",
171    "estes:c6",
172    "estes:d12",
173    "aerotech:g80",
174];
175
176/// A built-in motor curve by lowercase `mfr:designation` name.
177///
178/// Curve data: RASP files from thrustcurve.org, made from NAR published
179/// static-test data (provenance in the embedded headers).
180pub fn motor_by_name(name: &str) -> Option<MotorFile> {
181    let text = match name {
182        "estes:a8" => ESTES_A8,
183        "estes:b6" => ESTES_B6,
184        "estes:c6" => ESTES_C6,
185        "estes:d12" => ESTES_D12,
186        "aerotech:g80" => AEROTECH_G80,
187        _ => return None,
188    };
189    MotorFile::parse(text).ok()
190}
191
192// Curve data: thrustcurve.org simfiles 5f4294d20002e900000004ea (D12, made
193// from NAR published data, 2000-10-03).
194const ESTES_D12: &str = "\
195D12 24 70 0-3-5-7 0.0211 0.0426 Estes
1960.049 2.569
1970.116 9.369
1980.184 17.275
1990.237 24.258
2000.282 29.73
2010.297 27.01
2020.311 22.589
2030.322 17.99
2040.348 14.126
2050.386 12.099
2060.442 10.808
2070.546 9.876
2080.718 9.306
2090.879 9.105
2101.066 8.901
2111.257 8.698
2121.436 8.31
2131.59 8.294
2141.612 4.613
2151.65 0";
216
217// Curve data: thrustcurve.org simfiles 69eb2379c7d63000021d8c29 (C6).
218const ESTES_C6: &str = "\
219C6 18 70 0-3-5-7 0.0108 0.0231 E
2200.031 0.946
2210.092 4.826
2220.139 9.936
2230.192 14.09
2240.209 11.446
2250.231 7.381
2260.248 6.151
2270.292 5.489
2280.37 4.921
2290.475 4.448
2300.671 4.258
2310.702 4.542
2320.723 4.164
2330.85 4.448
2341.063 4.353
2351.211 4.353
2361.242 4.069
2371.303 4.258
2381.468 4.353
2391.656 4.448
2401.821 4.448
2411.834 2.933
2421.847 1.325
2431.86 0";
244
245// Curve data: thrustcurve.org simfiles (A8-0).
246const ESTES_A8: &str = "\
247A8 18 70 0 0.00384 0.01389 E
2480.016 1.08
2490.036 0.293
2500.05 0.259
2510.06 0.357
2520.07 0.419
2530.08 0.355
2540.102 0.781
2550.124 1.443
2560.152 2.966
2570.17 4.426
2580.202 7.16
2590.224 9.051
2600.246 9.555
2610.256 9.317
2620.276 7.958
2630.294 6.108
2640.326 4.235
2650.35 3.813
2660.376 3.997
2670.41 3.926
2680.452 3.724
2690.474 4.046
2700.502 4.092
2710.532 4.146
2720.534 0";
273
274// Curve data: thrustcurve.org simfiles (B6-0).
275const ESTES_B6: &str = "\
276B6-0 18 70 0 0.0056 0.0156 E
2770.036 1.364
2780.064 2.727
2790.082 4.215
2800.111 6.694
2810.135 9.05
2820.146 9.545
2830.172 11.901
2840.181 12.149
2850.191 11.901
2860.211 9.174
2870.239 7.314
2880.264 6.074
2890.275 5.95
2900.333 5.207
2910.394 4.835
2920.445 4.835
2930.556 4.339
2940.667 4.587
2950.723 4.339
2960.78 4.339
2970.793 4.091
2980.812 2.603
2990.833 1.24
3000.857 0";
301
302// Curve data: thrustcurve.org simfiles 5f4294d20002e900000007dd (G80NBT).
303const AEROTECH_G80: &str = "\
304G80NBT 29 123.825 4-7-10 0.069 0.129 AT
3050.013 89.054
3060.018 101.584
3070.029 105.388
3080.047 102.927
3090.104 100.018
3100.19 102.255
3110.268 104.94
3120.306 104.269
3130.352 105.835
3140.41 104.94
3150.432 106.73
3160.512 105.612
3170.563 106.954
3180.591 104.493
3190.62 106.283
3200.715 103.15
3210.76 103.374
3220.806 101.36
3230.848 102.032
3241.2 88.383
3251.243 77.866
3261.269 57.952
3271.302 44.079
3281.329 34.011
3291.367 26.403
3301.398 22.823
3311.433 20.585
3321.48 21.704
3331.508 20.809
3341.584 13.201
3351.604 11.635
3361.652 11.188
3371.683 5.37
3381.701 0";
339
340#[cfg(feature = "ballistics")]
341pub use recovery::{Airframe, RecoveryForces};
342
343#[cfg(feature = "ballistics")]
344mod recovery {
345    use crate::{
346        ballistics::{Air, Atmosphere, BallisticForces},
347        FlightState, ForceModel, Result, Vec3, WeathercockParams,
348    };
349
350    /// Apogee recovery: a stateless force-model wrapper that swaps the inner
351    /// model for recovery drag once the vehicle is descending (vy <= 0) and
352    /// the simulation time is at or past `deploy_after_s`. Recovery drag
353    /// replaces the inner acceleration entirely except for gravity:
354    /// `a = g − (q·CdA/m)·v̂` evaluated against local air. Terminal velocity
355    /// `sqrt(2·m·g/(ρ·CdA))` emerges naturally.
356    ///
357    /// Because deployment is a pure function of the current state, the wrapper
358    /// is deterministic and side-effect free, as `ForceModel` requires.
359    #[derive(Debug, Clone)]
360    pub struct RecoveryForces<F> {
361        inner: F,
362        air: Air,
363        wind_mps: Vec3,
364        wind_sock: Option<ballistics_engine::wind::WindSock>,
365        wind_field: Option<crate::ballistics::SharedWindField>,
366        turbulence: Option<crate::ballistics::Turbulence>,
367        recovery_cd_area_m2: f64,
368        deploy_after_s: f64,
369        main: Option<(f64, f64)>,
370    }
371
372    impl<F> RecoveryForces<F> {
373        /// `recovery_cd_area_m2` is the parachute's Cd·A product in m².
374        /// `deploy_after_s` is an absolute simulation-time gate: deployment
375        /// happens on the first descending state at or after it (0 = at
376        /// apogee). `atmosphere` and `wind_mps` should match the inner model's
377        /// air; pass its atmosphere zones too, through
378        /// [`Self::with_atmosphere_zones`].
379        pub fn new(
380            inner: F,
381            recovery_cd_area_m2: f64,
382            deploy_after_s: f64,
383            atmosphere: Atmosphere,
384            wind_mps: Vec3,
385        ) -> Result<Self> {
386            if !recovery_cd_area_m2.is_finite() || recovery_cd_area_m2 <= 0.0 {
387                return Err(crate::Error::invalid(
388                    "recovery_cd_area_m2",
389                    "must be finite and positive",
390                ));
391            }
392            if !deploy_after_s.is_finite() || deploy_after_s < 0.0 {
393                return Err(crate::Error::invalid(
394                    "deploy_after_s",
395                    "must be finite and nonnegative",
396                ));
397            }
398            Ok(Self {
399                inner,
400                air: Air::new(atmosphere)?,
401                wind_mps,
402                wind_sock: None,
403                wind_field: None,
404                turbulence: None,
405                recovery_cd_area_m2,
406                deploy_after_s,
407                main: None,
408            })
409        }
410
411        /// Dual deployment: once the vehicle is recovering and at or below
412        /// `altitude_m`, recovery drag uses the main parachute's Cd·A
413        /// instead of the drogue's. Pure function of state, like the
414        /// deployment gate itself. `altitude_m` is compared with position Y
415        /// in the world frame, which with `BallisticForces` is altitude above
416        /// sea level, not height above the launch pad.
417        pub fn with_main(mut self, cd_area_m2: f64, altitude_m: f64) -> Result<Self> {
418            if !cd_area_m2.is_finite() || cd_area_m2 <= 0.0 {
419                return Err(crate::Error::invalid(
420                    "main_cd_area_m2",
421                    "must be finite and positive",
422                ));
423            }
424            if !altitude_m.is_finite() {
425                return Err(crate::Error::invalid("main_altitude_m", "must be finite"));
426            }
427            self.main = Some((cd_area_m2, altitude_m));
428            Ok(self)
429        }
430
431        /// Read the same station-referenced atmosphere zones as the inner
432        /// model (see [`BallisticForces::with_atmosphere_zones`]): with the
433        /// same ladder, recovery drag uses the drag kernel's air at every
434        /// position. The atmosphere passed to [`Self::new`] must be
435        /// `Standard`.
436        pub fn with_atmosphere_zones(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
437            self.air = self.air.with_zones(zones)?;
438            Ok(self)
439        }
440
441        /// Add the same deterministic frozen-field turbulence as the force
442        /// model: equal `sigma`/`tau`/`seed` gives identical gusts, so the
443        /// descent drifts through the same air the ascent climbed through.
444        pub fn with_turbulence(mut self, sigma_mps: f64, tau_s: f64, seed: u64) -> Result<Self> {
445            self.turbulence = Some(crate::ballistics::Turbulence::new(sigma_mps, tau_s, seed)?);
446            Ok(self)
447        }
448
449        fn cd_area(&self, state: &FlightState) -> f64 {
450            match self.main {
451                Some((cd_area, altitude_m)) if state.position_m.y <= altitude_m => cd_area,
452                _ => self.recovery_cd_area_m2,
453            }
454        }
455
456        /// Sample downrange-segmented wind like the force model does.
457        pub fn with_wind_segments(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
458            let segments: Vec<ballistics_engine::wind::WindSegment> = zones
459                .into_iter()
460                .map(|(speed_mps, angle_deg, until_m, vertical_mps)| {
461                    ballistics_engine::wind::WindSegment {
462                        speed_kmh: speed_mps * 3.6,
463                        angle_deg,
464                        until_m,
465                        vertical_mps,
466                    }
467                })
468                .collect();
469            self.wind_sock = Some(
470                ballistics_engine::wind::WindSock::try_new(segments)
471                    .map_err(|e| crate::Error::Model(format!("invalid wind segments: {e:?}")))?,
472            );
473            Ok(self)
474        }
475
476        /// Sample the same winds-engine field as the force model.
477        pub fn with_wind_field(
478            mut self,
479            field: std::sync::Arc<dyn winds_engine::WindField + Send + Sync>,
480        ) -> Result<Self> {
481            self.wind_field = Some(crate::ballistics::SharedWindField(field));
482            Ok(self)
483        }
484
485        fn effective_wind(&self, state: &FlightState) -> Vec3 {
486            let base = match &self.wind_field {
487                Some(field) => {
488                    let w = field.0.wind(
489                        state.time_s,
490                        [state.position_m.x, state.position_m.y, state.position_m.z],
491                    );
492                    Vec3::new(w.x_mps, w.y_mps, w.z_mps)
493                }
494                None => match &self.wind_sock {
495                    Some(sock) => sock.vector_for_range_stateless(state.position_m.x),
496                    None => self.wind_mps,
497                },
498            };
499            match &self.turbulence {
500                Some(t) => base + t.gust_vec(state.time_s),
501                None => base,
502            }
503        }
504
505        fn recovery_accel(&self, state: &FlightState, mass_kg: f64) -> Vec3 {
506            let v_air = state.velocity_mps - self.effective_wind(state);
507            let speed = v_air.norm();
508            let g = Vec3::new(0.0, -ballistics_engine::constants::G_ACCEL_MPS2, 0.0);
509            if speed < 1e-12 {
510                return g;
511            }
512            let (rho, _) = self.air.at(state.position_m);
513            let q = 0.5 * rho * speed * speed;
514            g - v_air * (q * self.cd_area(state) / (speed * mass_kg))
515        }
516
517        fn recovering(&self, state: &FlightState) -> bool {
518            // Strictly descending: vy == 0 (pad, exact apogee) is not yet a
519            // descent, so a standing wind at t = 0 cannot trip the chute.
520            state.velocity_mps.y < 0.0 && state.time_s >= self.deploy_after_s
521        }
522    }
523
524    impl<F: ForceModel> ForceModel for RecoveryForces<F> {
525        fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3> {
526            if !self.recovering(state) {
527                return self.inner.acceleration(state, mass_kg);
528            }
529            Ok(self.recovery_accel(state, mass_kg))
530        }
531
532        fn acceleration_with_axis(
533            &self,
534            state: &FlightState,
535            mass_kg: f64,
536            body_axis: Vec3,
537        ) -> Result<Vec3> {
538            if !self.recovering(state) {
539                return self.inner.acceleration_with_axis(state, mass_kg, body_axis);
540            }
541            Ok(self.recovery_accel(state, mass_kg))
542        }
543    }
544
545    /// Illustrative airframe parameters for common model-rocket shapes.
546    /// These are generic, not measured data for any particular kit.
547    #[derive(Debug, Clone)]
548    pub struct Airframe {
549        /// Body diameter in metres.
550        pub diameter_m: f64,
551        /// Axial drag coefficient vs Mach.
552        pub cd_vs_mach: Vec<(f64, f64)>,
553        /// Normal-force slope per radian vs Mach.
554        pub cna_vs_mach: Vec<(f64, f64)>,
555        /// Centre of pressure, metres aft of nose, vs Mach.
556        pub cp_vs_mach: Vec<(f64, f64)>,
557        /// Dry-structure CG, metres aft of nose.
558        pub cg_dry_m: f64,
559        /// Propellant CG, metres aft of nose.
560        pub cg_prop_m: f64,
561        /// Scalar pitch inertia about the CG, kg·m².
562        pub pitch_inertia_kg_m2: f64,
563        /// Induced-drag factor per radian² vs Mach.
564        pub induced_k_vs_mach: Vec<(f64, f64)>,
565        /// Recovery parachute Cd·A in m².
566        pub recovery_cd_area_m2: f64,
567    }
568
569    impl Airframe {
570        /// A generic 24 mm sport rocket (Estes-alpha-sized), illustrative only.
571        pub fn sport_24mm() -> Self {
572            Self {
573                diameter_m: 0.024,
574                cd_vs_mach: vec![(0.0, 0.4), (0.8, 0.45), (1.2, 0.65), (3.0, 0.5)],
575                cna_vs_mach: vec![(0.0, 12.0), (1.0, 14.0), (3.0, 10.0)],
576                cp_vs_mach: vec![(0.0, 0.19), (1.0, 0.2), (3.0, 0.18)],
577                cg_dry_m: 0.12,
578                cg_prop_m: 0.16,
579                pitch_inertia_kg_m2: 0.004,
580                // Tilt model: induced-drag factor equals the normal-force slope.
581                induced_k_vs_mach: vec![(0.0, 12.0), (1.0, 14.0), (3.0, 10.0)],
582                recovery_cd_area_m2: 0.25,
583            }
584        }
585
586        /// A generic 54 mm high-power rocket, illustrative only.
587        pub fn high_power_54mm() -> Self {
588            Self {
589                diameter_m: 0.054,
590                cd_vs_mach: vec![(0.0, 0.35), (0.8, 0.4), (1.2, 0.6), (3.0, 0.45)],
591                cna_vs_mach: vec![(0.0, 16.0), (1.0, 18.0), (3.0, 14.0)],
592                cp_vs_mach: vec![(0.0, 0.42), (1.0, 0.45), (3.0, 0.4)],
593                cg_dry_m: 0.28,
594                cg_prop_m: 0.36,
595                pitch_inertia_kg_m2: 0.03,
596                // Tilt model: induced-drag factor equals the normal-force slope.
597                induced_k_vs_mach: vec![(0.0, 16.0), (1.0, 18.0), (3.0, 14.0)],
598                recovery_cd_area_m2: 0.8,
599            }
600        }
601
602        /// Drag + atmosphere forces for this airframe in the given wind.
603        pub fn forces(&self, wind_mps: Vec3) -> Result<BallisticForces> {
604            BallisticForces::new(
605                self.diameter_m,
606                self.cd_vs_mach.clone(),
607                Atmosphere::default(),
608                wind_mps,
609            )?
610            .with_body_lift(self.cna_vs_mach.clone(), self.induced_k_vs_mach.clone())
611        }
612
613        /// Weathercock parameters for this airframe (CP/CG included).
614        pub fn weathercock_params(&self) -> Result<WeathercockParams> {
615            WeathercockParams::new(
616                self.cna_vs_mach.clone(),
617                self.cp_vs_mach.clone(),
618                self.cg_dry_m,
619                self.cg_prop_m,
620                self.diameter_m,
621                self.pitch_inertia_kg_m2,
622                0.2,
623            )
624        }
625
626        /// Look up a preset by CLI name (`sport24` or `hp54`).
627        pub fn by_name(name: &str) -> Option<Self> {
628            match name {
629                "sport24" => Some(Self::sport_24mm()),
630                "hp54" => Some(Self::high_power_54mm()),
631                _ => None,
632            }
633        }
634    }
635}