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