1use crate::{Error, Motor, Result, ThrustCurve, ThrustPoint};
6
7#[derive(Debug, Clone)]
11pub struct MotorFile {
12 pub manufacturer: String,
14 pub designation: String,
16 pub diameter_mm: f64,
18 pub length_mm: f64,
20 pub delays: String,
22 pub propellant_mass_kg: f64,
24 pub total_mass_kg: f64,
26 pub curve: ThrustCurve,
28}
29
30impl MotorFile {
31 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 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 #[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 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 #[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 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 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 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 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 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 #[derive(Debug, Clone)]
297 pub struct Airframe {
298 pub diameter_m: f64,
300 pub cd_vs_mach: Vec<(f64, f64)>,
302 pub cna_vs_mach: Vec<(f64, f64)>,
304 pub cp_vs_mach: Vec<(f64, f64)>,
306 pub cg_dry_m: f64,
308 pub cg_prop_m: f64,
310 pub pitch_inertia_kg_m2: f64,
312 pub induced_k_vs_mach: Vec<(f64, f64)>,
314 pub recovery_cd_area_m2: f64,
316 }
317
318 impl Airframe {
319 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 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 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 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 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 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 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}