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        let mut points = Vec::new();
69        for line in lines {
70            let mut parts = line.split_whitespace();
71            let time_s: f64 = parts
72                .next()
73                .ok_or_else(|| Error::invalid("motor file", "empty data row"))?
74                .parse()
75                .map_err(|_| Error::invalid("motor file", "non-numeric time"))?;
76            let thrust_n: f64 = parts
77                .next()
78                .ok_or_else(|| Error::invalid("motor file", "row needs time and thrust"))?
79                .parse()
80                .map_err(|_| Error::invalid("motor file", "non-numeric thrust"))?;
81            if parts.next().is_some() {
82                return Err(Error::invalid("motor file", "data row has extra columns"));
83            }
84            points.push(ThrustPoint { time_s, thrust_n });
85        }
86        if points.len() < 2 {
87            return Err(Error::invalid(
88                "motor file",
89                "needs at least two thrust samples",
90            ));
91        }
92        Ok(Self {
93            manufacturer: tokens[0].to_string(),
94            designation: tokens[1].to_string(),
95            diameter_mm,
96            length_mm,
97            delays,
98            propellant_mass_kg,
99            total_mass_kg,
100            curve: ThrustCurve::new(points)?,
101        })
102    }
103
104    /// Read and parse a `.eng` file from disk (native builds).
105    #[cfg(not(target_arch = "wasm32"))]
106    pub fn from_path(path: impl AsRef<std::path::Path>) -> Result<Self> {
107        let text = std::fs::read_to_string(path.as_ref())
108            .map_err(|e| Error::Model(format!("could not read motor file: {e}")))?;
109        Self::parse(&text)
110    }
111
112    /// Build a scheduled motor from the parsed curve and propellant mass.
113    pub fn motor(&self, ignition_time_s: f64) -> Result<Motor> {
114        Motor::new(self.curve.clone(), self.propellant_mass_kg, ignition_time_s)
115    }
116}
117
118#[cfg(feature = "ballistics")]
119pub use recovery::{Airframe, RecoveryForces};
120
121#[cfg(feature = "ballistics")]
122mod recovery {
123    use crate::{
124        ballistics::{local_air, Atmosphere, BallisticForces},
125        FlightState, ForceModel, Result, Vec3, WeathercockParams,
126    };
127
128    /// Apogee recovery: a stateless force-model wrapper that swaps the inner
129    /// model for recovery drag once the vehicle is descending (vy <= 0) and
130    /// the simulation time is at or past `deploy_after_s`. Recovery drag
131    /// replaces the inner acceleration entirely except for gravity:
132    /// `a = g − (q·CdA/m)·v̂` evaluated against local air. Terminal velocity
133    /// `sqrt(2·m·g/(ρ·CdA))` emerges naturally.
134    ///
135    /// Because deployment is a pure function of the current state, the wrapper
136    /// is deterministic and side-effect free, as `ForceModel` requires.
137    #[derive(Debug, Clone)]
138    pub struct RecoveryForces<F> {
139        inner: F,
140        atmosphere: Atmosphere,
141        atmos_params: (f64, f64, f64, f64),
142        wind_mps: Vec3,
143        recovery_cd_area_m2: f64,
144        deploy_after_s: f64,
145    }
146
147    impl<F> RecoveryForces<F> {
148        /// `recovery_cd_area_m2` is the parachute's Cd·A product in m².
149        /// `deploy_after_s` is an absolute simulation-time gate: deployment
150        /// happens on the first descending state at or after it (0 = at
151        /// apogee). `wind_mps` should match the inner model's uniform wind.
152        pub fn new(
153            inner: F,
154            recovery_cd_area_m2: f64,
155            deploy_after_s: f64,
156            atmosphere: Atmosphere,
157            wind_mps: Vec3,
158        ) -> Result<Self> {
159            if !recovery_cd_area_m2.is_finite() || recovery_cd_area_m2 <= 0.0 {
160                return Err(crate::Error::invalid(
161                    "recovery_cd_area_m2",
162                    "must be finite and positive",
163                ));
164            }
165            if !deploy_after_s.is_finite() || deploy_after_s < 0.0 {
166                return Err(crate::Error::invalid(
167                    "deploy_after_s",
168                    "must be finite and nonnegative",
169                ));
170            }
171            Ok(Self {
172                inner,
173                atmos_params: atmosphere.parameters()?,
174                atmosphere,
175                wind_mps,
176                recovery_cd_area_m2,
177                deploy_after_s,
178            })
179        }
180
181        fn recovery_accel(&self, state: &FlightState, mass_kg: f64) -> Vec3 {
182            let v_air = state.velocity_mps - self.wind_mps;
183            let speed = v_air.norm();
184            let g = Vec3::new(0.0, -ballistics_engine::constants::G_ACCEL_MPS2, 0.0);
185            if speed < 1e-12 {
186                return g;
187            }
188            let (rho, _) = local_air(self.atmosphere, self.atmos_params, state.position_m.y);
189            let q = 0.5 * rho * speed * speed;
190            g - v_air * (q * self.recovery_cd_area_m2 / (speed * mass_kg))
191        }
192
193        fn recovering(&self, state: &FlightState) -> bool {
194            state.velocity_mps.y <= 0.0 && state.time_s >= self.deploy_after_s
195        }
196    }
197
198    impl<F: ForceModel> ForceModel for RecoveryForces<F> {
199        fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3> {
200            if !self.recovering(state) {
201                return self.inner.acceleration(state, mass_kg);
202            }
203            Ok(self.recovery_accel(state, mass_kg))
204        }
205
206        fn acceleration_with_axis(
207            &self,
208            state: &FlightState,
209            mass_kg: f64,
210            body_axis: Vec3,
211        ) -> Result<Vec3> {
212            if !self.recovering(state) {
213                return self.inner.acceleration_with_axis(state, mass_kg, body_axis);
214            }
215            Ok(self.recovery_accel(state, mass_kg))
216        }
217    }
218
219    /// Illustrative airframe parameters for common model-rocket shapes.
220    /// These are generic, not measured data for any particular kit.
221    #[derive(Debug, Clone)]
222    pub struct Airframe {
223        /// Body diameter in metres.
224        pub diameter_m: f64,
225        /// Axial drag coefficient vs Mach.
226        pub cd_vs_mach: Vec<(f64, f64)>,
227        /// Normal-force slope per radian vs Mach.
228        pub cna_vs_mach: Vec<(f64, f64)>,
229        /// Centre of pressure, metres aft of nose, vs Mach.
230        pub cp_vs_mach: Vec<(f64, f64)>,
231        /// Dry-structure CG, metres aft of nose.
232        pub cg_dry_m: f64,
233        /// Propellant CG, metres aft of nose.
234        pub cg_prop_m: f64,
235        /// Scalar pitch inertia about the CG, kg·m².
236        pub pitch_inertia_kg_m2: f64,
237        /// Induced-drag factor per radian² vs Mach.
238        pub induced_k_vs_mach: Vec<(f64, f64)>,
239        /// Recovery parachute Cd·A in m².
240        pub recovery_cd_area_m2: f64,
241    }
242
243    impl Airframe {
244        /// A generic 24 mm sport rocket (Estes-alpha-sized), illustrative only.
245        pub fn sport_24mm() -> Self {
246            Self {
247                diameter_m: 0.024,
248                cd_vs_mach: vec![(0.0, 0.4), (0.8, 0.45), (1.2, 0.65), (3.0, 0.5)],
249                cna_vs_mach: vec![(0.0, 12.0), (1.0, 14.0), (3.0, 10.0)],
250                cp_vs_mach: vec![(0.0, 0.19), (1.0, 0.2), (3.0, 0.18)],
251                cg_dry_m: 0.12,
252                cg_prop_m: 0.16,
253                pitch_inertia_kg_m2: 0.004,
254                induced_k_vs_mach: vec![(0.0, 0.0), (1.0, 0.3), (3.0, 0.6)],
255                recovery_cd_area_m2: 0.25,
256            }
257        }
258
259        /// A generic 54 mm high-power rocket, illustrative only.
260        pub fn high_power_54mm() -> Self {
261            Self {
262                diameter_m: 0.054,
263                cd_vs_mach: vec![(0.0, 0.35), (0.8, 0.4), (1.2, 0.6), (3.0, 0.45)],
264                cna_vs_mach: vec![(0.0, 16.0), (1.0, 18.0), (3.0, 14.0)],
265                cp_vs_mach: vec![(0.0, 0.42), (1.0, 0.45), (3.0, 0.4)],
266                cg_dry_m: 0.28,
267                cg_prop_m: 0.36,
268                pitch_inertia_kg_m2: 0.03,
269                induced_k_vs_mach: vec![(0.0, 0.0), (1.0, 0.3), (3.0, 0.6)],
270                recovery_cd_area_m2: 0.8,
271            }
272        }
273
274        /// Drag + atmosphere forces for this airframe in the given wind.
275        pub fn forces(&self, wind_mps: Vec3) -> Result<BallisticForces> {
276            BallisticForces::new(
277                self.diameter_m,
278                self.cd_vs_mach.clone(),
279                Atmosphere::default(),
280                wind_mps,
281            )?
282            .with_body_lift(self.cna_vs_mach.clone(), self.induced_k_vs_mach.clone())
283        }
284
285        /// Weathercock parameters for this airframe (CP/CG included).
286        pub fn weathercock_params(&self) -> Result<WeathercockParams> {
287            WeathercockParams::new(
288                self.cna_vs_mach.clone(),
289                self.cp_vs_mach.clone(),
290                self.cg_dry_m,
291                self.cg_prop_m,
292                self.diameter_m,
293                self.pitch_inertia_kg_m2,
294                0.2,
295            )
296        }
297
298        /// Look up a preset by CLI name (`sport24` or `hp54`).
299        pub fn by_name(name: &str) -> Option<Self> {
300            match name {
301                "sport24" => Some(Self::sport_24mm()),
302                "hp54" => Some(Self::high_power_54mm()),
303                _ => None,
304            }
305        }
306    }
307}