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::{Air, Atmosphere, BallisticForces},
347 FlightState, ForceModel, Result, Vec3, WeathercockParams,
348 };
349
350 #[derive(Debug, Clone)]
360 pub struct RecoveryForces<F> {
361 inner: F,
362 air: Air,
363 wind_mps: Vec3,
364 wind_sock: Option<ballistics_engine::wind::WindSock>,
365 wind_field: Option<crate::ballistics::SharedWindField>,
366 turbulence: Option<crate::ballistics::Turbulence>,
367 recovery_cd_area_m2: f64,
368 deploy_after_s: f64,
369 main: Option<(f64, f64)>,
370 }
371
372 impl<F> RecoveryForces<F> {
373 pub fn new(
380 inner: F,
381 recovery_cd_area_m2: f64,
382 deploy_after_s: f64,
383 atmosphere: Atmosphere,
384 wind_mps: Vec3,
385 ) -> Result<Self> {
386 if !recovery_cd_area_m2.is_finite() || recovery_cd_area_m2 <= 0.0 {
387 return Err(crate::Error::invalid(
388 "recovery_cd_area_m2",
389 "must be finite and positive",
390 ));
391 }
392 if !deploy_after_s.is_finite() || deploy_after_s < 0.0 {
393 return Err(crate::Error::invalid(
394 "deploy_after_s",
395 "must be finite and nonnegative",
396 ));
397 }
398 Ok(Self {
399 inner,
400 air: Air::new(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> {
418 if !cd_area_m2.is_finite() || cd_area_m2 <= 0.0 {
419 return Err(crate::Error::invalid(
420 "main_cd_area_m2",
421 "must be finite and positive",
422 ));
423 }
424 if !altitude_m.is_finite() {
425 return Err(crate::Error::invalid("main_altitude_m", "must be finite"));
426 }
427 self.main = Some((cd_area_m2, altitude_m));
428 Ok(self)
429 }
430
431 pub fn with_atmosphere_zones(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
437 self.air = self.air.with_zones(zones)?;
438 Ok(self)
439 }
440
441 pub fn with_turbulence(mut self, sigma_mps: f64, tau_s: f64, seed: u64) -> Result<Self> {
445 self.turbulence = Some(crate::ballistics::Turbulence::new(sigma_mps, tau_s, seed)?);
446 Ok(self)
447 }
448
449 fn cd_area(&self, state: &FlightState) -> f64 {
450 match self.main {
451 Some((cd_area, altitude_m)) if state.position_m.y <= altitude_m => cd_area,
452 _ => self.recovery_cd_area_m2,
453 }
454 }
455
456 pub fn with_wind_segments(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
458 let segments: Vec<ballistics_engine::wind::WindSegment> = zones
459 .into_iter()
460 .map(|(speed_mps, angle_deg, until_m, vertical_mps)| {
461 ballistics_engine::wind::WindSegment {
462 speed_kmh: speed_mps * 3.6,
463 angle_deg,
464 until_m,
465 vertical_mps,
466 }
467 })
468 .collect();
469 self.wind_sock = Some(
470 ballistics_engine::wind::WindSock::try_new(segments)
471 .map_err(|e| crate::Error::Model(format!("invalid wind segments: {e:?}")))?,
472 );
473 Ok(self)
474 }
475
476 pub fn with_wind_field(
478 mut self,
479 field: std::sync::Arc<dyn winds_engine::WindField + Send + Sync>,
480 ) -> Result<Self> {
481 self.wind_field = Some(crate::ballistics::SharedWindField(field));
482 Ok(self)
483 }
484
485 fn effective_wind(&self, state: &FlightState) -> Vec3 {
486 let base = match &self.wind_field {
487 Some(field) => {
488 let w = field.0.wind(
489 state.time_s,
490 [state.position_m.x, state.position_m.y, state.position_m.z],
491 );
492 Vec3::new(w.x_mps, w.y_mps, w.z_mps)
493 }
494 None => match &self.wind_sock {
495 Some(sock) => sock.vector_for_range_stateless(state.position_m.x),
496 None => self.wind_mps,
497 },
498 };
499 match &self.turbulence {
500 Some(t) => base + t.gust_vec(state.time_s),
501 None => base,
502 }
503 }
504
505 fn recovery_accel(&self, state: &FlightState, mass_kg: f64) -> Vec3 {
506 let v_air = state.velocity_mps - self.effective_wind(state);
507 let speed = v_air.norm();
508 let g = Vec3::new(0.0, -ballistics_engine::constants::G_ACCEL_MPS2, 0.0);
509 if speed < 1e-12 {
510 return g;
511 }
512 let (rho, _) = self.air.at(state.position_m);
513 let q = 0.5 * rho * speed * speed;
514 g - v_air * (q * self.cd_area(state) / (speed * mass_kg))
515 }
516
517 fn recovering(&self, state: &FlightState) -> bool {
518 state.velocity_mps.y < 0.0 && state.time_s >= self.deploy_after_s
521 }
522 }
523
524 impl<F: ForceModel> ForceModel for RecoveryForces<F> {
525 fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3> {
526 if !self.recovering(state) {
527 return self.inner.acceleration(state, mass_kg);
528 }
529 Ok(self.recovery_accel(state, mass_kg))
530 }
531
532 fn acceleration_with_axis(
533 &self,
534 state: &FlightState,
535 mass_kg: f64,
536 body_axis: Vec3,
537 ) -> Result<Vec3> {
538 if !self.recovering(state) {
539 return self.inner.acceleration_with_axis(state, mass_kg, body_axis);
540 }
541 Ok(self.recovery_accel(state, mass_kg))
542 }
543 }
544
545 #[derive(Debug, Clone)]
548 pub struct Airframe {
549 pub diameter_m: f64,
551 pub cd_vs_mach: Vec<(f64, f64)>,
553 pub cna_vs_mach: Vec<(f64, f64)>,
555 pub cp_vs_mach: Vec<(f64, f64)>,
557 pub cg_dry_m: f64,
559 pub cg_prop_m: f64,
561 pub pitch_inertia_kg_m2: f64,
563 pub induced_k_vs_mach: Vec<(f64, f64)>,
565 pub recovery_cd_area_m2: f64,
567 }
568
569 impl Airframe {
570 pub fn sport_24mm() -> Self {
572 Self {
573 diameter_m: 0.024,
574 cd_vs_mach: vec![(0.0, 0.4), (0.8, 0.45), (1.2, 0.65), (3.0, 0.5)],
575 cna_vs_mach: vec![(0.0, 12.0), (1.0, 14.0), (3.0, 10.0)],
576 cp_vs_mach: vec![(0.0, 0.19), (1.0, 0.2), (3.0, 0.18)],
577 cg_dry_m: 0.12,
578 cg_prop_m: 0.16,
579 pitch_inertia_kg_m2: 0.004,
580 induced_k_vs_mach: vec![(0.0, 12.0), (1.0, 14.0), (3.0, 10.0)],
582 recovery_cd_area_m2: 0.25,
583 }
584 }
585
586 pub fn high_power_54mm() -> Self {
588 Self {
589 diameter_m: 0.054,
590 cd_vs_mach: vec![(0.0, 0.35), (0.8, 0.4), (1.2, 0.6), (3.0, 0.45)],
591 cna_vs_mach: vec![(0.0, 16.0), (1.0, 18.0), (3.0, 14.0)],
592 cp_vs_mach: vec![(0.0, 0.42), (1.0, 0.45), (3.0, 0.4)],
593 cg_dry_m: 0.28,
594 cg_prop_m: 0.36,
595 pitch_inertia_kg_m2: 0.03,
596 induced_k_vs_mach: vec![(0.0, 16.0), (1.0, 18.0), (3.0, 14.0)],
598 recovery_cd_area_m2: 0.8,
599 }
600 }
601
602 pub fn forces(&self, wind_mps: Vec3) -> Result<BallisticForces> {
604 BallisticForces::new(
605 self.diameter_m,
606 self.cd_vs_mach.clone(),
607 Atmosphere::default(),
608 wind_mps,
609 )?
610 .with_body_lift(self.cna_vs_mach.clone(), self.induced_k_vs_mach.clone())
611 }
612
613 pub fn weathercock_params(&self) -> Result<WeathercockParams> {
615 WeathercockParams::new(
616 self.cna_vs_mach.clone(),
617 self.cp_vs_mach.clone(),
618 self.cg_dry_m,
619 self.cg_prop_m,
620 self.diameter_m,
621 self.pitch_inertia_kg_m2,
622 0.2,
623 )
624 }
625
626 pub fn by_name(name: &str) -> Option<Self> {
628 match name {
629 "sport24" => Some(Self::sport_24mm()),
630 "hp54" => Some(Self::high_power_54mm()),
631 _ => None,
632 }
633 }
634 }
635}