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: Mfr Designation Diameter Length Delays PropellantMass TotalMass",
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 (delays, propellant_mass_kg, total_mass_kg) = if tokens.len() >= 7 {
54            (
55                tokens[4].to_string(),
56                number(tokens[5], "propellant_mass")?,
57                number(tokens[6], "total_mass")?,
58            )
59        } else {
60            (
61                String::new(),
62                number(tokens[4], "propellant_mass")?,
63                number(tokens[5], "total_mass")?,
64            )
65        };
66        let diameter_mm = number(tokens[2], "diameter_mm")?;
67        let length_mm = number(tokens[3], "length_mm")?;
68        // The header may share its line with thrust samples (e.g. when a paste
69        // flattens newlines). Treat every token after the header as a flat
70        // stream of time/thrust pairs, independent of original line breaks.
71        let header_len = if tokens.len() >= 7 { 7 } else { 6 };
72        let mut stream = tokens[header_len..]
73            .iter()
74            .copied()
75            .chain(lines.flat_map(|line| line.split_whitespace()));
76        let mut points = Vec::new();
77        while let Some(time_tok) = stream.next() {
78            let thrust_tok = stream
79                .next()
80                .ok_or_else(|| Error::invalid("motor file", "row needs time and thrust"))?;
81            let time_s: f64 = time_tok
82                .parse()
83                .map_err(|_| Error::invalid("motor file", "non-numeric time"))?;
84            let thrust_n: f64 = thrust_tok
85                .parse()
86                .map_err(|_| Error::invalid("motor file", "non-numeric thrust"))?;
87            points.push(ThrustPoint { time_s, thrust_n });
88        }
89        if points.len() < 2 {
90            return Err(Error::invalid(
91                "motor file",
92                "needs at least two thrust samples",
93            ));
94        }
95        Ok(Self {
96            manufacturer: tokens[0].to_string(),
97            designation: tokens[1].to_string(),
98            diameter_mm,
99            length_mm,
100            delays,
101            propellant_mass_kg,
102            total_mass_kg,
103            curve: ThrustCurve::new(points)?,
104        })
105    }
106
107    /// Read and parse a `.eng` file from disk (native builds).
108    #[cfg(not(target_arch = "wasm32"))]
109    pub fn from_path(path: impl AsRef<std::path::Path>) -> Result<Self> {
110        let text = std::fs::read_to_string(path.as_ref())
111            .map_err(|e| Error::Model(format!("could not read motor file: {e}")))?;
112        Self::parse(&text)
113    }
114
115    /// Build a scheduled motor from the parsed curve and propellant mass.
116    pub fn motor(&self, ignition_time_s: f64) -> Result<Motor> {
117        Motor::new(self.curve.clone(), self.propellant_mass_kg, ignition_time_s)
118    }
119}
120
121#[cfg(feature = "ballistics")]
122pub use recovery::{Airframe, RecoveryForces};
123
124#[cfg(feature = "ballistics")]
125mod recovery {
126    use crate::{
127        ballistics::{local_air, Atmosphere, BallisticForces},
128        FlightState, ForceModel, Result, Vec3, WeathercockParams,
129    };
130
131    /// Apogee recovery: a stateless force-model wrapper that swaps the inner
132    /// model for recovery drag once the vehicle is descending (vy <= 0) and
133    /// the simulation time is at or past `deploy_after_s`. Recovery drag
134    /// replaces the inner acceleration entirely except for gravity:
135    /// `a = g − (q·CdA/m)·v̂` evaluated against local air. Terminal velocity
136    /// `sqrt(2·m·g/(ρ·CdA))` emerges naturally.
137    ///
138    /// Because deployment is a pure function of the current state, the wrapper
139    /// is deterministic and side-effect free, as `ForceModel` requires.
140    #[derive(Debug, Clone)]
141    pub struct RecoveryForces<F> {
142        inner: F,
143        atmosphere: Atmosphere,
144        atmos_params: (f64, f64, f64, f64),
145        wind_mps: Vec3,
146        wind_sock: Option<ballistics_engine::wind::WindSock>,
147        turbulence: Option<crate::ballistics::Turbulence>,
148        recovery_cd_area_m2: f64,
149        deploy_after_s: f64,
150        main: Option<(f64, f64)>,
151    }
152
153    impl<F> RecoveryForces<F> {
154        /// `recovery_cd_area_m2` is the parachute's Cd·A product in m².
155        /// `deploy_after_s` is an absolute simulation-time gate: deployment
156        /// happens on the first descending state at or after it (0 = at
157        /// apogee). `wind_mps` should match the inner model's uniform wind.
158        pub fn new(
159            inner: F,
160            recovery_cd_area_m2: f64,
161            deploy_after_s: f64,
162            atmosphere: Atmosphere,
163            wind_mps: Vec3,
164        ) -> Result<Self> {
165            if !recovery_cd_area_m2.is_finite() || recovery_cd_area_m2 <= 0.0 {
166                return Err(crate::Error::invalid(
167                    "recovery_cd_area_m2",
168                    "must be finite and positive",
169                ));
170            }
171            if !deploy_after_s.is_finite() || deploy_after_s < 0.0 {
172                return Err(crate::Error::invalid(
173                    "deploy_after_s",
174                    "must be finite and nonnegative",
175                ));
176            }
177            Ok(Self {
178                inner,
179                atmos_params: atmosphere.parameters()?,
180                atmosphere,
181                wind_mps,
182                wind_sock: None,
183                turbulence: None,
184                recovery_cd_area_m2,
185                deploy_after_s,
186                main: None,
187            })
188        }
189
190        /// Dual deployment: once the vehicle is recovering and at or below
191        /// `altitude_m`, recovery drag uses the main parachute's Cd·A
192        /// instead of the drogue's. Pure function of state, like the
193        /// deployment gate itself.
194        pub fn with_main(mut self, cd_area_m2: f64, altitude_m: f64) -> Result<Self> {
195            if !cd_area_m2.is_finite() || cd_area_m2 <= 0.0 {
196                return Err(crate::Error::invalid(
197                    "main_cd_area_m2",
198                    "must be finite and positive",
199                ));
200            }
201            if !altitude_m.is_finite() {
202                return Err(crate::Error::invalid("main_altitude_m", "must be finite"));
203            }
204            self.main = Some((cd_area_m2, altitude_m));
205            Ok(self)
206        }
207
208        /// Add the same deterministic frozen-field turbulence as the force
209        /// model: equal `sigma`/`tau`/`seed` gives identical gusts, so the
210        /// descent drifts through the same air the ascent climbed through.
211        pub fn with_turbulence(mut self, sigma_mps: f64, tau_s: f64, seed: u64) -> Result<Self> {
212            self.turbulence = Some(crate::ballistics::Turbulence::new(sigma_mps, tau_s, seed)?);
213            Ok(self)
214        }
215
216        fn cd_area(&self, state: &FlightState) -> f64 {
217            match self.main {
218                Some((cd_area, altitude_m)) if state.position_m.y <= altitude_m => cd_area,
219                _ => self.recovery_cd_area_m2,
220            }
221        }
222
223        /// Sample downrange-segmented wind like the force model does.
224        pub fn with_wind_segments(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
225            let segments: Vec<ballistics_engine::wind::WindSegment> = zones
226                .into_iter()
227                .map(|(speed_mps, angle_deg, until_m, vertical_mps)| {
228                    ballistics_engine::wind::WindSegment {
229                        speed_kmh: speed_mps * 3.6,
230                        angle_deg,
231                        until_m,
232                        vertical_mps,
233                    }
234                })
235                .collect();
236            self.wind_sock = Some(
237                ballistics_engine::wind::WindSock::try_new(segments)
238                    .map_err(|e| crate::Error::Model(format!("invalid wind segments: {e:?}")))?,
239            );
240            Ok(self)
241        }
242
243        fn effective_wind(&self, state: &FlightState) -> Vec3 {
244            let base = match &self.wind_sock {
245                Some(sock) => sock.vector_for_range_stateless(state.position_m.x),
246                None => self.wind_mps,
247            };
248            match &self.turbulence {
249                Some(t) => base + t.gust_vec(state.time_s),
250                None => base,
251            }
252        }
253
254        fn recovery_accel(&self, state: &FlightState, mass_kg: f64) -> Vec3 {
255            let v_air = state.velocity_mps - self.effective_wind(state);
256            let speed = v_air.norm();
257            let g = Vec3::new(0.0, -ballistics_engine::constants::G_ACCEL_MPS2, 0.0);
258            if speed < 1e-12 {
259                return g;
260            }
261            let (rho, _) = local_air(self.atmosphere, self.atmos_params, state.position_m.y);
262            let q = 0.5 * rho * speed * speed;
263            g - v_air * (q * self.cd_area(state) / (speed * mass_kg))
264        }
265
266        fn recovering(&self, state: &FlightState) -> bool {
267            // Strictly descending: vy == 0 (pad, exact apogee) is not yet a
268            // descent, so a standing wind at t = 0 cannot trip the chute.
269            state.velocity_mps.y < 0.0 && state.time_s >= self.deploy_after_s
270        }
271    }
272
273    impl<F: ForceModel> ForceModel for RecoveryForces<F> {
274        fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3> {
275            if !self.recovering(state) {
276                return self.inner.acceleration(state, mass_kg);
277            }
278            Ok(self.recovery_accel(state, mass_kg))
279        }
280
281        fn acceleration_with_axis(
282            &self,
283            state: &FlightState,
284            mass_kg: f64,
285            body_axis: Vec3,
286        ) -> Result<Vec3> {
287            if !self.recovering(state) {
288                return self.inner.acceleration_with_axis(state, mass_kg, body_axis);
289            }
290            Ok(self.recovery_accel(state, mass_kg))
291        }
292    }
293
294    /// Illustrative airframe parameters for common model-rocket shapes.
295    /// These are generic, not measured data for any particular kit.
296    #[derive(Debug, Clone)]
297    pub struct Airframe {
298        /// Body diameter in metres.
299        pub diameter_m: f64,
300        /// Axial drag coefficient vs Mach.
301        pub cd_vs_mach: Vec<(f64, f64)>,
302        /// Normal-force slope per radian vs Mach.
303        pub cna_vs_mach: Vec<(f64, f64)>,
304        /// Centre of pressure, metres aft of nose, vs Mach.
305        pub cp_vs_mach: Vec<(f64, f64)>,
306        /// Dry-structure CG, metres aft of nose.
307        pub cg_dry_m: f64,
308        /// Propellant CG, metres aft of nose.
309        pub cg_prop_m: f64,
310        /// Scalar pitch inertia about the CG, kg·m².
311        pub pitch_inertia_kg_m2: f64,
312        /// Induced-drag factor per radian² vs Mach.
313        pub induced_k_vs_mach: Vec<(f64, f64)>,
314        /// Recovery parachute Cd·A in m².
315        pub recovery_cd_area_m2: f64,
316    }
317
318    impl Airframe {
319        /// A generic 24 mm sport rocket (Estes-alpha-sized), illustrative only.
320        pub fn sport_24mm() -> Self {
321            Self {
322                diameter_m: 0.024,
323                cd_vs_mach: vec![(0.0, 0.4), (0.8, 0.45), (1.2, 0.65), (3.0, 0.5)],
324                cna_vs_mach: vec![(0.0, 12.0), (1.0, 14.0), (3.0, 10.0)],
325                cp_vs_mach: vec![(0.0, 0.19), (1.0, 0.2), (3.0, 0.18)],
326                cg_dry_m: 0.12,
327                cg_prop_m: 0.16,
328                pitch_inertia_kg_m2: 0.004,
329                // Tilt model: induced-drag factor equals the normal-force slope.
330                induced_k_vs_mach: vec![(0.0, 12.0), (1.0, 14.0), (3.0, 10.0)],
331                recovery_cd_area_m2: 0.25,
332            }
333        }
334
335        /// A generic 54 mm high-power rocket, illustrative only.
336        pub fn high_power_54mm() -> Self {
337            Self {
338                diameter_m: 0.054,
339                cd_vs_mach: vec![(0.0, 0.35), (0.8, 0.4), (1.2, 0.6), (3.0, 0.45)],
340                cna_vs_mach: vec![(0.0, 16.0), (1.0, 18.0), (3.0, 14.0)],
341                cp_vs_mach: vec![(0.0, 0.42), (1.0, 0.45), (3.0, 0.4)],
342                cg_dry_m: 0.28,
343                cg_prop_m: 0.36,
344                pitch_inertia_kg_m2: 0.03,
345                // Tilt model: induced-drag factor equals the normal-force slope.
346                induced_k_vs_mach: vec![(0.0, 16.0), (1.0, 18.0), (3.0, 14.0)],
347                recovery_cd_area_m2: 0.8,
348            }
349        }
350
351        /// Drag + atmosphere forces for this airframe in the given wind.
352        pub fn forces(&self, wind_mps: Vec3) -> Result<BallisticForces> {
353            BallisticForces::new(
354                self.diameter_m,
355                self.cd_vs_mach.clone(),
356                Atmosphere::default(),
357                wind_mps,
358            )?
359            .with_body_lift(self.cna_vs_mach.clone(), self.induced_k_vs_mach.clone())
360        }
361
362        /// Weathercock parameters for this airframe (CP/CG included).
363        pub fn weathercock_params(&self) -> Result<WeathercockParams> {
364            WeathercockParams::new(
365                self.cna_vs_mach.clone(),
366                self.cp_vs_mach.clone(),
367                self.cg_dry_m,
368                self.cg_prop_m,
369                self.diameter_m,
370                self.pitch_inertia_kg_m2,
371                0.2,
372            )
373        }
374
375        /// Look up a preset by CLI name (`sport24` or `hp54`).
376        pub fn by_name(name: &str) -> Option<Self> {
377            match name {
378                "sport24" => Some(Self::sport_24mm()),
379                "hp54" => Some(Self::high_power_54mm()),
380                _ => None,
381            }
382        }
383    }
384}