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::{local_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        atmosphere: Atmosphere,
363        atmos_params: (f64, f64, f64, f64),
364        wind_mps: Vec3,
365        wind_sock: Option<ballistics_engine::wind::WindSock>,
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). `wind_mps` should match the inner model's uniform wind.
377        pub fn new(
378            inner: F,
379            recovery_cd_area_m2: f64,
380            deploy_after_s: f64,
381            atmosphere: Atmosphere,
382            wind_mps: Vec3,
383        ) -> Result<Self> {
384            if !recovery_cd_area_m2.is_finite() || recovery_cd_area_m2 <= 0.0 {
385                return Err(crate::Error::invalid(
386                    "recovery_cd_area_m2",
387                    "must be finite and positive",
388                ));
389            }
390            if !deploy_after_s.is_finite() || deploy_after_s < 0.0 {
391                return Err(crate::Error::invalid(
392                    "deploy_after_s",
393                    "must be finite and nonnegative",
394                ));
395            }
396            Ok(Self {
397                inner,
398                atmos_params: atmosphere.parameters()?,
399                atmosphere,
400                wind_mps,
401                wind_sock: None,
402                turbulence: None,
403                recovery_cd_area_m2,
404                deploy_after_s,
405                main: None,
406            })
407        }
408
409        /// Dual deployment: once the vehicle is recovering and at or below
410        /// `altitude_m`, recovery drag uses the main parachute's Cd·A
411        /// instead of the drogue's. Pure function of state, like the
412        /// deployment gate itself.
413        pub fn with_main(mut self, cd_area_m2: f64, altitude_m: f64) -> Result<Self> {
414            if !cd_area_m2.is_finite() || cd_area_m2 <= 0.0 {
415                return Err(crate::Error::invalid(
416                    "main_cd_area_m2",
417                    "must be finite and positive",
418                ));
419            }
420            if !altitude_m.is_finite() {
421                return Err(crate::Error::invalid("main_altitude_m", "must be finite"));
422            }
423            self.main = Some((cd_area_m2, altitude_m));
424            Ok(self)
425        }
426
427        /// Add the same deterministic frozen-field turbulence as the force
428        /// model: equal `sigma`/`tau`/`seed` gives identical gusts, so the
429        /// descent drifts through the same air the ascent climbed through.
430        pub fn with_turbulence(mut self, sigma_mps: f64, tau_s: f64, seed: u64) -> Result<Self> {
431            self.turbulence = Some(crate::ballistics::Turbulence::new(sigma_mps, tau_s, seed)?);
432            Ok(self)
433        }
434
435        fn cd_area(&self, state: &FlightState) -> f64 {
436            match self.main {
437                Some((cd_area, altitude_m)) if state.position_m.y <= altitude_m => cd_area,
438                _ => self.recovery_cd_area_m2,
439            }
440        }
441
442        /// Sample downrange-segmented wind like the force model does.
443        pub fn with_wind_segments(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
444            let segments: Vec<ballistics_engine::wind::WindSegment> = zones
445                .into_iter()
446                .map(|(speed_mps, angle_deg, until_m, vertical_mps)| {
447                    ballistics_engine::wind::WindSegment {
448                        speed_kmh: speed_mps * 3.6,
449                        angle_deg,
450                        until_m,
451                        vertical_mps,
452                    }
453                })
454                .collect();
455            self.wind_sock = Some(
456                ballistics_engine::wind::WindSock::try_new(segments)
457                    .map_err(|e| crate::Error::Model(format!("invalid wind segments: {e:?}")))?,
458            );
459            Ok(self)
460        }
461
462        fn effective_wind(&self, state: &FlightState) -> Vec3 {
463            let base = match &self.wind_sock {
464                Some(sock) => sock.vector_for_range_stateless(state.position_m.x),
465                None => self.wind_mps,
466            };
467            match &self.turbulence {
468                Some(t) => base + t.gust_vec(state.time_s),
469                None => base,
470            }
471        }
472
473        fn recovery_accel(&self, state: &FlightState, mass_kg: f64) -> Vec3 {
474            let v_air = state.velocity_mps - self.effective_wind(state);
475            let speed = v_air.norm();
476            let g = Vec3::new(0.0, -ballistics_engine::constants::G_ACCEL_MPS2, 0.0);
477            if speed < 1e-12 {
478                return g;
479            }
480            let (rho, _) = local_air(self.atmosphere, self.atmos_params, state.position_m.y);
481            let q = 0.5 * rho * speed * speed;
482            g - v_air * (q * self.cd_area(state) / (speed * mass_kg))
483        }
484
485        fn recovering(&self, state: &FlightState) -> bool {
486            // Strictly descending: vy == 0 (pad, exact apogee) is not yet a
487            // descent, so a standing wind at t = 0 cannot trip the chute.
488            state.velocity_mps.y < 0.0 && state.time_s >= self.deploy_after_s
489        }
490    }
491
492    impl<F: ForceModel> ForceModel for RecoveryForces<F> {
493        fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3> {
494            if !self.recovering(state) {
495                return self.inner.acceleration(state, mass_kg);
496            }
497            Ok(self.recovery_accel(state, mass_kg))
498        }
499
500        fn acceleration_with_axis(
501            &self,
502            state: &FlightState,
503            mass_kg: f64,
504            body_axis: Vec3,
505        ) -> Result<Vec3> {
506            if !self.recovering(state) {
507                return self.inner.acceleration_with_axis(state, mass_kg, body_axis);
508            }
509            Ok(self.recovery_accel(state, mass_kg))
510        }
511    }
512
513    /// Illustrative airframe parameters for common model-rocket shapes.
514    /// These are generic, not measured data for any particular kit.
515    #[derive(Debug, Clone)]
516    pub struct Airframe {
517        /// Body diameter in metres.
518        pub diameter_m: f64,
519        /// Axial drag coefficient vs Mach.
520        pub cd_vs_mach: Vec<(f64, f64)>,
521        /// Normal-force slope per radian vs Mach.
522        pub cna_vs_mach: Vec<(f64, f64)>,
523        /// Centre of pressure, metres aft of nose, vs Mach.
524        pub cp_vs_mach: Vec<(f64, f64)>,
525        /// Dry-structure CG, metres aft of nose.
526        pub cg_dry_m: f64,
527        /// Propellant CG, metres aft of nose.
528        pub cg_prop_m: f64,
529        /// Scalar pitch inertia about the CG, kg·m².
530        pub pitch_inertia_kg_m2: f64,
531        /// Induced-drag factor per radian² vs Mach.
532        pub induced_k_vs_mach: Vec<(f64, f64)>,
533        /// Recovery parachute Cd·A in m².
534        pub recovery_cd_area_m2: f64,
535    }
536
537    impl Airframe {
538        /// A generic 24 mm sport rocket (Estes-alpha-sized), illustrative only.
539        pub fn sport_24mm() -> Self {
540            Self {
541                diameter_m: 0.024,
542                cd_vs_mach: vec![(0.0, 0.4), (0.8, 0.45), (1.2, 0.65), (3.0, 0.5)],
543                cna_vs_mach: vec![(0.0, 12.0), (1.0, 14.0), (3.0, 10.0)],
544                cp_vs_mach: vec![(0.0, 0.19), (1.0, 0.2), (3.0, 0.18)],
545                cg_dry_m: 0.12,
546                cg_prop_m: 0.16,
547                pitch_inertia_kg_m2: 0.004,
548                // Tilt model: induced-drag factor equals the normal-force slope.
549                induced_k_vs_mach: vec![(0.0, 12.0), (1.0, 14.0), (3.0, 10.0)],
550                recovery_cd_area_m2: 0.25,
551            }
552        }
553
554        /// A generic 54 mm high-power rocket, illustrative only.
555        pub fn high_power_54mm() -> Self {
556            Self {
557                diameter_m: 0.054,
558                cd_vs_mach: vec![(0.0, 0.35), (0.8, 0.4), (1.2, 0.6), (3.0, 0.45)],
559                cna_vs_mach: vec![(0.0, 16.0), (1.0, 18.0), (3.0, 14.0)],
560                cp_vs_mach: vec![(0.0, 0.42), (1.0, 0.45), (3.0, 0.4)],
561                cg_dry_m: 0.28,
562                cg_prop_m: 0.36,
563                pitch_inertia_kg_m2: 0.03,
564                // Tilt model: induced-drag factor equals the normal-force slope.
565                induced_k_vs_mach: vec![(0.0, 16.0), (1.0, 18.0), (3.0, 14.0)],
566                recovery_cd_area_m2: 0.8,
567            }
568        }
569
570        /// Drag + atmosphere forces for this airframe in the given wind.
571        pub fn forces(&self, wind_mps: Vec3) -> Result<BallisticForces> {
572            BallisticForces::new(
573                self.diameter_m,
574                self.cd_vs_mach.clone(),
575                Atmosphere::default(),
576                wind_mps,
577            )?
578            .with_body_lift(self.cna_vs_mach.clone(), self.induced_k_vs_mach.clone())
579        }
580
581        /// Weathercock parameters for this airframe (CP/CG included).
582        pub fn weathercock_params(&self) -> Result<WeathercockParams> {
583            WeathercockParams::new(
584                self.cna_vs_mach.clone(),
585                self.cp_vs_mach.clone(),
586                self.cg_dry_m,
587                self.cg_prop_m,
588                self.diameter_m,
589                self.pitch_inertia_kg_m2,
590                0.2,
591            )
592        }
593
594        /// Look up a preset by CLI name (`sport24` or `hp54`).
595        pub fn by_name(name: &str) -> Option<Self> {
596            match name {
597                "sport24" => Some(Self::sport_24mm()),
598                "hp54" => Some(Self::high_power_54mm()),
599                _ => None,
600            }
601        }
602    }
603}