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: Designation Diameter Length Delays PropellantMass TotalMass [Mfr]",
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 numeric = |token: &str| token.parse::<f64>().is_ok();
54 let (
58 manufacturer,
59 designation,
60 diameter_mm,
61 length_mm,
62 delays,
63 propellant_mass_kg,
64 total_mass_kg,
65 header_len,
66 ) = if numeric(tokens[1]) {
67 (
69 tokens.get(6).map(|s| s.to_string()),
70 tokens[0].to_string(),
71 number(tokens[1], "diameter_mm")?,
72 number(tokens[2], "length_mm")?,
73 tokens[3].to_string(),
74 number(tokens[4], "propellant_mass")?,
75 number(tokens[5], "total_mass")?,
76 6 + usize::from(tokens.len() > 6),
77 )
78 } else {
79 if tokens.len() >= 7 {
81 (
82 Some(tokens[0].to_string()),
83 tokens[1].to_string(),
84 number(tokens[2], "diameter_mm")?,
85 number(tokens[3], "length_mm")?,
86 tokens[4].to_string(),
87 number(tokens[5], "propellant_mass")?,
88 number(tokens[6], "total_mass")?,
89 7,
90 )
91 } else {
92 (
93 Some(tokens[0].to_string()),
94 tokens[1].to_string(),
95 number(tokens[2], "diameter_mm")?,
96 number(tokens[3], "length_mm")?,
97 String::new(),
98 number(tokens[4], "propellant_mass")?,
99 number(tokens[5], "total_mass")?,
100 6,
101 )
102 }
103 };
104 let mut stream = tokens[header_len..]
108 .iter()
109 .copied()
110 .chain(lines.flat_map(|line| line.split_whitespace()));
111 let mut points = Vec::new();
112 while let Some(time_tok) = stream.next() {
113 let thrust_tok = stream
114 .next()
115 .ok_or_else(|| Error::invalid("motor file", "row needs time and thrust"))?;
116 let time_s: f64 = time_tok
117 .parse()
118 .map_err(|_| Error::invalid("motor file", "non-numeric time"))?;
119 let thrust_n: f64 = thrust_tok
120 .parse()
121 .map_err(|_| Error::invalid("motor file", "non-numeric thrust"))?;
122 points.push(ThrustPoint { time_s, thrust_n });
123 }
124 if points.len() < 2 {
125 return Err(Error::invalid(
126 "motor file",
127 "needs at least two thrust samples",
128 ));
129 }
130 if points[0].time_s > 0.0 {
133 points.insert(
134 0,
135 ThrustPoint {
136 time_s: 0.0,
137 thrust_n: 0.0,
138 },
139 );
140 }
141 Ok(Self {
142 manufacturer: manufacturer.unwrap_or_default(),
143 designation,
144 diameter_mm,
145 length_mm,
146 delays,
147 propellant_mass_kg,
148 total_mass_kg,
149 curve: ThrustCurve::new(points)?,
150 })
151 }
152
153 #[cfg(not(target_arch = "wasm32"))]
155 pub fn from_path(path: impl AsRef<std::path::Path>) -> Result<Self> {
156 let text = std::fs::read_to_string(path.as_ref())
157 .map_err(|e| Error::Model(format!("could not read motor file: {e}")))?;
158 Self::parse(&text)
159 }
160
161 pub fn motor(&self, ignition_time_s: f64) -> Result<Motor> {
163 Motor::new(self.curve.clone(), self.propellant_mass_kg, ignition_time_s)
164 }
165}
166
167pub const MOTOR_NAMES: &[&str] = &[
169 "estes:a8",
170 "estes:b6",
171 "estes:c6",
172 "estes:d12",
173 "aerotech:g80",
174];
175
176pub fn motor_by_name(name: &str) -> Option<MotorFile> {
181 let text = match name {
182 "estes:a8" => ESTES_A8,
183 "estes:b6" => ESTES_B6,
184 "estes:c6" => ESTES_C6,
185 "estes:d12" => ESTES_D12,
186 "aerotech:g80" => AEROTECH_G80,
187 _ => return None,
188 };
189 MotorFile::parse(text).ok()
190}
191
192const ESTES_D12: &str = "\
195D12 24 70 0-3-5-7 0.0211 0.0426 Estes
1960.049 2.569
1970.116 9.369
1980.184 17.275
1990.237 24.258
2000.282 29.73
2010.297 27.01
2020.311 22.589
2030.322 17.99
2040.348 14.126
2050.386 12.099
2060.442 10.808
2070.546 9.876
2080.718 9.306
2090.879 9.105
2101.066 8.901
2111.257 8.698
2121.436 8.31
2131.59 8.294
2141.612 4.613
2151.65 0";
216
217const ESTES_C6: &str = "\
219C6 18 70 0-3-5-7 0.0108 0.0231 E
2200.031 0.946
2210.092 4.826
2220.139 9.936
2230.192 14.09
2240.209 11.446
2250.231 7.381
2260.248 6.151
2270.292 5.489
2280.37 4.921
2290.475 4.448
2300.671 4.258
2310.702 4.542
2320.723 4.164
2330.85 4.448
2341.063 4.353
2351.211 4.353
2361.242 4.069
2371.303 4.258
2381.468 4.353
2391.656 4.448
2401.821 4.448
2411.834 2.933
2421.847 1.325
2431.86 0";
244
245const ESTES_A8: &str = "\
247A8 18 70 0 0.00384 0.01389 E
2480.016 1.08
2490.036 0.293
2500.05 0.259
2510.06 0.357
2520.07 0.419
2530.08 0.355
2540.102 0.781
2550.124 1.443
2560.152 2.966
2570.17 4.426
2580.202 7.16
2590.224 9.051
2600.246 9.555
2610.256 9.317
2620.276 7.958
2630.294 6.108
2640.326 4.235
2650.35 3.813
2660.376 3.997
2670.41 3.926
2680.452 3.724
2690.474 4.046
2700.502 4.092
2710.532 4.146
2720.534 0";
273
274const ESTES_B6: &str = "\
276B6-0 18 70 0 0.0056 0.0156 E
2770.036 1.364
2780.064 2.727
2790.082 4.215
2800.111 6.694
2810.135 9.05
2820.146 9.545
2830.172 11.901
2840.181 12.149
2850.191 11.901
2860.211 9.174
2870.239 7.314
2880.264 6.074
2890.275 5.95
2900.333 5.207
2910.394 4.835
2920.445 4.835
2930.556 4.339
2940.667 4.587
2950.723 4.339
2960.78 4.339
2970.793 4.091
2980.812 2.603
2990.833 1.24
3000.857 0";
301
302const AEROTECH_G80: &str = "\
304G80NBT 29 123.825 4-7-10 0.069 0.129 AT
3050.013 89.054
3060.018 101.584
3070.029 105.388
3080.047 102.927
3090.104 100.018
3100.19 102.255
3110.268 104.94
3120.306 104.269
3130.352 105.835
3140.41 104.94
3150.432 106.73
3160.512 105.612
3170.563 106.954
3180.591 104.493
3190.62 106.283
3200.715 103.15
3210.76 103.374
3220.806 101.36
3230.848 102.032
3241.2 88.383
3251.243 77.866
3261.269 57.952
3271.302 44.079
3281.329 34.011
3291.367 26.403
3301.398 22.823
3311.433 20.585
3321.48 21.704
3331.508 20.809
3341.584 13.201
3351.604 11.635
3361.652 11.188
3371.683 5.37
3381.701 0";
339
340#[cfg(feature = "ballistics")]
341pub use recovery::{Airframe, RecoveryForces};
342
343#[cfg(feature = "ballistics")]
344mod recovery {
345 use crate::{
346 ballistics::{local_air, Atmosphere, BallisticForces},
347 FlightState, ForceModel, Result, Vec3, WeathercockParams,
348 };
349
350 #[derive(Debug, Clone)]
360 pub struct RecoveryForces<F> {
361 inner: F,
362 atmosphere: Atmosphere,
363 atmos_params: (f64, f64, f64, f64),
364 wind_mps: Vec3,
365 wind_sock: Option<ballistics_engine::wind::WindSock>,
366 wind_field: Option<crate::ballistics::SharedWindField>,
367 turbulence: Option<crate::ballistics::Turbulence>,
368 recovery_cd_area_m2: f64,
369 deploy_after_s: f64,
370 main: Option<(f64, f64)>,
371 }
372
373 impl<F> RecoveryForces<F> {
374 pub fn new(
379 inner: F,
380 recovery_cd_area_m2: f64,
381 deploy_after_s: f64,
382 atmosphere: Atmosphere,
383 wind_mps: Vec3,
384 ) -> Result<Self> {
385 if !recovery_cd_area_m2.is_finite() || recovery_cd_area_m2 <= 0.0 {
386 return Err(crate::Error::invalid(
387 "recovery_cd_area_m2",
388 "must be finite and positive",
389 ));
390 }
391 if !deploy_after_s.is_finite() || deploy_after_s < 0.0 {
392 return Err(crate::Error::invalid(
393 "deploy_after_s",
394 "must be finite and nonnegative",
395 ));
396 }
397 Ok(Self {
398 inner,
399 atmos_params: atmosphere.parameters()?,
400 atmosphere,
401 wind_mps,
402 wind_sock: None,
403 wind_field: None,
404 turbulence: None,
405 recovery_cd_area_m2,
406 deploy_after_s,
407 main: None,
408 })
409 }
410
411 pub fn with_main(mut self, cd_area_m2: f64, altitude_m: f64) -> Result<Self> {
416 if !cd_area_m2.is_finite() || cd_area_m2 <= 0.0 {
417 return Err(crate::Error::invalid(
418 "main_cd_area_m2",
419 "must be finite and positive",
420 ));
421 }
422 if !altitude_m.is_finite() {
423 return Err(crate::Error::invalid("main_altitude_m", "must be finite"));
424 }
425 self.main = Some((cd_area_m2, altitude_m));
426 Ok(self)
427 }
428
429 pub fn with_turbulence(mut self, sigma_mps: f64, tau_s: f64, seed: u64) -> Result<Self> {
433 self.turbulence = Some(crate::ballistics::Turbulence::new(sigma_mps, tau_s, seed)?);
434 Ok(self)
435 }
436
437 fn cd_area(&self, state: &FlightState) -> f64 {
438 match self.main {
439 Some((cd_area, altitude_m)) if state.position_m.y <= altitude_m => cd_area,
440 _ => self.recovery_cd_area_m2,
441 }
442 }
443
444 pub fn with_wind_segments(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
446 let segments: Vec<ballistics_engine::wind::WindSegment> = zones
447 .into_iter()
448 .map(|(speed_mps, angle_deg, until_m, vertical_mps)| {
449 ballistics_engine::wind::WindSegment {
450 speed_kmh: speed_mps * 3.6,
451 angle_deg,
452 until_m,
453 vertical_mps,
454 }
455 })
456 .collect();
457 self.wind_sock = Some(
458 ballistics_engine::wind::WindSock::try_new(segments)
459 .map_err(|e| crate::Error::Model(format!("invalid wind segments: {e:?}")))?,
460 );
461 Ok(self)
462 }
463
464 pub fn with_wind_field(
466 mut self,
467 field: std::sync::Arc<dyn winds_engine::WindField + Send + Sync>,
468 ) -> Result<Self> {
469 self.wind_field = Some(crate::ballistics::SharedWindField(field));
470 Ok(self)
471 }
472
473 fn effective_wind(&self, state: &FlightState) -> Vec3 {
474 let base = match &self.wind_field {
475 Some(field) => {
476 let w = field.0.wind(
477 state.time_s,
478 [state.position_m.x, state.position_m.y, state.position_m.z],
479 );
480 Vec3::new(w.x_mps, w.y_mps, w.z_mps)
481 }
482 None => match &self.wind_sock {
483 Some(sock) => sock.vector_for_range_stateless(state.position_m.x),
484 None => self.wind_mps,
485 },
486 };
487 match &self.turbulence {
488 Some(t) => base + t.gust_vec(state.time_s),
489 None => base,
490 }
491 }
492
493 fn recovery_accel(&self, state: &FlightState, mass_kg: f64) -> Vec3 {
494 let v_air = state.velocity_mps - self.effective_wind(state);
495 let speed = v_air.norm();
496 let g = Vec3::new(0.0, -ballistics_engine::constants::G_ACCEL_MPS2, 0.0);
497 if speed < 1e-12 {
498 return g;
499 }
500 let (rho, _) = local_air(self.atmosphere, self.atmos_params, state.position_m.y);
501 let q = 0.5 * rho * speed * speed;
502 g - v_air * (q * self.cd_area(state) / (speed * mass_kg))
503 }
504
505 fn recovering(&self, state: &FlightState) -> bool {
506 state.velocity_mps.y < 0.0 && state.time_s >= self.deploy_after_s
509 }
510 }
511
512 impl<F: ForceModel> ForceModel for RecoveryForces<F> {
513 fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3> {
514 if !self.recovering(state) {
515 return self.inner.acceleration(state, mass_kg);
516 }
517 Ok(self.recovery_accel(state, mass_kg))
518 }
519
520 fn acceleration_with_axis(
521 &self,
522 state: &FlightState,
523 mass_kg: f64,
524 body_axis: Vec3,
525 ) -> Result<Vec3> {
526 if !self.recovering(state) {
527 return self.inner.acceleration_with_axis(state, mass_kg, body_axis);
528 }
529 Ok(self.recovery_accel(state, mass_kg))
530 }
531 }
532
533 #[derive(Debug, Clone)]
536 pub struct Airframe {
537 pub diameter_m: f64,
539 pub cd_vs_mach: Vec<(f64, f64)>,
541 pub cna_vs_mach: Vec<(f64, f64)>,
543 pub cp_vs_mach: Vec<(f64, f64)>,
545 pub cg_dry_m: f64,
547 pub cg_prop_m: f64,
549 pub pitch_inertia_kg_m2: f64,
551 pub induced_k_vs_mach: Vec<(f64, f64)>,
553 pub recovery_cd_area_m2: f64,
555 }
556
557 impl Airframe {
558 pub fn sport_24mm() -> Self {
560 Self {
561 diameter_m: 0.024,
562 cd_vs_mach: vec![(0.0, 0.4), (0.8, 0.45), (1.2, 0.65), (3.0, 0.5)],
563 cna_vs_mach: vec![(0.0, 12.0), (1.0, 14.0), (3.0, 10.0)],
564 cp_vs_mach: vec![(0.0, 0.19), (1.0, 0.2), (3.0, 0.18)],
565 cg_dry_m: 0.12,
566 cg_prop_m: 0.16,
567 pitch_inertia_kg_m2: 0.004,
568 induced_k_vs_mach: vec![(0.0, 12.0), (1.0, 14.0), (3.0, 10.0)],
570 recovery_cd_area_m2: 0.25,
571 }
572 }
573
574 pub fn high_power_54mm() -> Self {
576 Self {
577 diameter_m: 0.054,
578 cd_vs_mach: vec![(0.0, 0.35), (0.8, 0.4), (1.2, 0.6), (3.0, 0.45)],
579 cna_vs_mach: vec![(0.0, 16.0), (1.0, 18.0), (3.0, 14.0)],
580 cp_vs_mach: vec![(0.0, 0.42), (1.0, 0.45), (3.0, 0.4)],
581 cg_dry_m: 0.28,
582 cg_prop_m: 0.36,
583 pitch_inertia_kg_m2: 0.03,
584 induced_k_vs_mach: vec![(0.0, 16.0), (1.0, 18.0), (3.0, 14.0)],
586 recovery_cd_area_m2: 0.8,
587 }
588 }
589
590 pub fn forces(&self, wind_mps: Vec3) -> Result<BallisticForces> {
592 BallisticForces::new(
593 self.diameter_m,
594 self.cd_vs_mach.clone(),
595 Atmosphere::default(),
596 wind_mps,
597 )?
598 .with_body_lift(self.cna_vs_mach.clone(), self.induced_k_vs_mach.clone())
599 }
600
601 pub fn weathercock_params(&self) -> Result<WeathercockParams> {
603 WeathercockParams::new(
604 self.cna_vs_mach.clone(),
605 self.cp_vs_mach.clone(),
606 self.cg_dry_m,
607 self.cg_prop_m,
608 self.diameter_m,
609 self.pitch_inertia_kg_m2,
610 0.2,
611 )
612 }
613
614 pub fn by_name(name: &str) -> Option<Self> {
616 match name {
617 "sport24" => Some(Self::sport_24mm()),
618 "hp54" => Some(Self::high_power_54mm()),
619 _ => None,
620 }
621 }
622 }
623}