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 recovery_cd_area_m2: f64,
147 deploy_after_s: f64,
148 }
149
150 impl<F> RecoveryForces<F> {
151 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 #[derive(Debug, Clone)]
225 pub struct Airframe {
226 pub diameter_m: f64,
228 pub cd_vs_mach: Vec<(f64, f64)>,
230 pub cna_vs_mach: Vec<(f64, f64)>,
232 pub cp_vs_mach: Vec<(f64, f64)>,
234 pub cg_dry_m: f64,
236 pub cg_prop_m: f64,
238 pub pitch_inertia_kg_m2: f64,
240 pub induced_k_vs_mach: Vec<(f64, f64)>,
242 pub recovery_cd_area_m2: f64,
244 }
245
246 impl Airframe {
247 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 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 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 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 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}