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        recovery_cd_area_m2: f64,
147        deploy_after_s: f64,
148    }
149
150    impl<F> RecoveryForces<F> {
151        /// `recovery_cd_area_m2` is the parachute's Cd·A product in m².
152        /// `deploy_after_s` is an absolute simulation-time gate: deployment
153        /// happens on the first descending state at or after it (0 = at
154        /// apogee). `wind_mps` should match the inner model's uniform wind.
155        pub fn new(
156            inner: F,
157            recovery_cd_area_m2: f64,
158            deploy_after_s: f64,
159            atmosphere: Atmosphere,
160            wind_mps: Vec3,
161        ) -> Result<Self> {
162            if !recovery_cd_area_m2.is_finite() || recovery_cd_area_m2 <= 0.0 {
163                return Err(crate::Error::invalid(
164                    "recovery_cd_area_m2",
165                    "must be finite and positive",
166                ));
167            }
168            if !deploy_after_s.is_finite() || deploy_after_s < 0.0 {
169                return Err(crate::Error::invalid(
170                    "deploy_after_s",
171                    "must be finite and nonnegative",
172                ));
173            }
174            Ok(Self {
175                inner,
176                atmos_params: atmosphere.parameters()?,
177                atmosphere,
178                wind_mps,
179                recovery_cd_area_m2,
180                deploy_after_s,
181            })
182        }
183
184        fn recovery_accel(&self, state: &FlightState, mass_kg: f64) -> Vec3 {
185            let v_air = state.velocity_mps - self.wind_mps;
186            let speed = v_air.norm();
187            let g = Vec3::new(0.0, -ballistics_engine::constants::G_ACCEL_MPS2, 0.0);
188            if speed < 1e-12 {
189                return g;
190            }
191            let (rho, _) = local_air(self.atmosphere, self.atmos_params, state.position_m.y);
192            let q = 0.5 * rho * speed * speed;
193            g - v_air * (q * self.recovery_cd_area_m2 / (speed * mass_kg))
194        }
195
196        fn recovering(&self, state: &FlightState) -> bool {
197            state.velocity_mps.y <= 0.0 && state.time_s >= self.deploy_after_s
198        }
199    }
200
201    impl<F: ForceModel> ForceModel for RecoveryForces<F> {
202        fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3> {
203            if !self.recovering(state) {
204                return self.inner.acceleration(state, mass_kg);
205            }
206            Ok(self.recovery_accel(state, mass_kg))
207        }
208
209        fn acceleration_with_axis(
210            &self,
211            state: &FlightState,
212            mass_kg: f64,
213            body_axis: Vec3,
214        ) -> Result<Vec3> {
215            if !self.recovering(state) {
216                return self.inner.acceleration_with_axis(state, mass_kg, body_axis);
217            }
218            Ok(self.recovery_accel(state, mass_kg))
219        }
220    }
221
222    /// Illustrative airframe parameters for common model-rocket shapes.
223    /// These are generic, not measured data for any particular kit.
224    #[derive(Debug, Clone)]
225    pub struct Airframe {
226        /// Body diameter in metres.
227        pub diameter_m: f64,
228        /// Axial drag coefficient vs Mach.
229        pub cd_vs_mach: Vec<(f64, f64)>,
230        /// Normal-force slope per radian vs Mach.
231        pub cna_vs_mach: Vec<(f64, f64)>,
232        /// Centre of pressure, metres aft of nose, vs Mach.
233        pub cp_vs_mach: Vec<(f64, f64)>,
234        /// Dry-structure CG, metres aft of nose.
235        pub cg_dry_m: f64,
236        /// Propellant CG, metres aft of nose.
237        pub cg_prop_m: f64,
238        /// Scalar pitch inertia about the CG, kg·m².
239        pub pitch_inertia_kg_m2: f64,
240        /// Induced-drag factor per radian² vs Mach.
241        pub induced_k_vs_mach: Vec<(f64, f64)>,
242        /// Recovery parachute Cd·A in m².
243        pub recovery_cd_area_m2: f64,
244    }
245
246    impl Airframe {
247        /// A generic 24 mm sport rocket (Estes-alpha-sized), illustrative only.
248        pub fn sport_24mm() -> Self {
249            Self {
250                diameter_m: 0.024,
251                cd_vs_mach: vec![(0.0, 0.4), (0.8, 0.45), (1.2, 0.65), (3.0, 0.5)],
252                cna_vs_mach: vec![(0.0, 12.0), (1.0, 14.0), (3.0, 10.0)],
253                cp_vs_mach: vec![(0.0, 0.19), (1.0, 0.2), (3.0, 0.18)],
254                cg_dry_m: 0.12,
255                cg_prop_m: 0.16,
256                pitch_inertia_kg_m2: 0.004,
257                induced_k_vs_mach: vec![(0.0, 0.0), (1.0, 0.3), (3.0, 0.6)],
258                recovery_cd_area_m2: 0.25,
259            }
260        }
261
262        /// A generic 54 mm high-power rocket, illustrative only.
263        pub fn high_power_54mm() -> Self {
264            Self {
265                diameter_m: 0.054,
266                cd_vs_mach: vec![(0.0, 0.35), (0.8, 0.4), (1.2, 0.6), (3.0, 0.45)],
267                cna_vs_mach: vec![(0.0, 16.0), (1.0, 18.0), (3.0, 14.0)],
268                cp_vs_mach: vec![(0.0, 0.42), (1.0, 0.45), (3.0, 0.4)],
269                cg_dry_m: 0.28,
270                cg_prop_m: 0.36,
271                pitch_inertia_kg_m2: 0.03,
272                induced_k_vs_mach: vec![(0.0, 0.0), (1.0, 0.3), (3.0, 0.6)],
273                recovery_cd_area_m2: 0.8,
274            }
275        }
276
277        /// Drag + atmosphere forces for this airframe in the given wind.
278        pub fn forces(&self, wind_mps: Vec3) -> Result<BallisticForces> {
279            BallisticForces::new(
280                self.diameter_m,
281                self.cd_vs_mach.clone(),
282                Atmosphere::default(),
283                wind_mps,
284            )?
285            .with_body_lift(self.cna_vs_mach.clone(), self.induced_k_vs_mach.clone())
286        }
287
288        /// Weathercock parameters for this airframe (CP/CG included).
289        pub fn weathercock_params(&self) -> Result<WeathercockParams> {
290            WeathercockParams::new(
291                self.cna_vs_mach.clone(),
292                self.cp_vs_mach.clone(),
293                self.cg_dry_m,
294                self.cg_prop_m,
295                self.diameter_m,
296                self.pitch_inertia_kg_m2,
297                0.2,
298            )
299        }
300
301        /// Look up a preset by CLI name (`sport24` or `hp54`).
302        pub fn by_name(name: &str) -> Option<Self> {
303            match name {
304                "sport24" => Some(Self::sport_24mm()),
305                "hp54" => Some(Self::high_power_54mm()),
306                _ => None,
307            }
308        }
309    }
310}