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 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 #[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 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 #[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 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 #[derive(Debug, Clone)]
222 pub struct Airframe {
223 pub diameter_m: f64,
225 pub cd_vs_mach: Vec<(f64, f64)>,
227 pub cna_vs_mach: Vec<(f64, f64)>,
229 pub cp_vs_mach: Vec<(f64, f64)>,
231 pub cg_dry_m: f64,
233 pub cg_prop_m: f64,
235 pub pitch_inertia_kg_m2: f64,
237 pub induced_k_vs_mach: Vec<(f64, f64)>,
239 pub recovery_cd_area_m2: f64,
241 }
242
243 impl Airframe {
244 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 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 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 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 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}