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 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(
378 inner: F,
379 recovery_cd_area_m2: f64,
380 deploy_after_s: f64,
381 atmosphere: Atmosphere,
382 wind_mps: Vec3,
383 ) -> Result<Self> {
384 if !recovery_cd_area_m2.is_finite() || recovery_cd_area_m2 <= 0.0 {
385 return Err(crate::Error::invalid(
386 "recovery_cd_area_m2",
387 "must be finite and positive",
388 ));
389 }
390 if !deploy_after_s.is_finite() || deploy_after_s < 0.0 {
391 return Err(crate::Error::invalid(
392 "deploy_after_s",
393 "must be finite and nonnegative",
394 ));
395 }
396 Ok(Self {
397 inner,
398 atmos_params: atmosphere.parameters()?,
399 atmosphere,
400 wind_mps,
401 wind_sock: None,
402 turbulence: None,
403 recovery_cd_area_m2,
404 deploy_after_s,
405 main: None,
406 })
407 }
408
409 pub fn with_main(mut self, cd_area_m2: f64, altitude_m: f64) -> Result<Self> {
414 if !cd_area_m2.is_finite() || cd_area_m2 <= 0.0 {
415 return Err(crate::Error::invalid(
416 "main_cd_area_m2",
417 "must be finite and positive",
418 ));
419 }
420 if !altitude_m.is_finite() {
421 return Err(crate::Error::invalid("main_altitude_m", "must be finite"));
422 }
423 self.main = Some((cd_area_m2, altitude_m));
424 Ok(self)
425 }
426
427 pub fn with_turbulence(mut self, sigma_mps: f64, tau_s: f64, seed: u64) -> Result<Self> {
431 self.turbulence = Some(crate::ballistics::Turbulence::new(sigma_mps, tau_s, seed)?);
432 Ok(self)
433 }
434
435 fn cd_area(&self, state: &FlightState) -> f64 {
436 match self.main {
437 Some((cd_area, altitude_m)) if state.position_m.y <= altitude_m => cd_area,
438 _ => self.recovery_cd_area_m2,
439 }
440 }
441
442 pub fn with_wind_segments(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
444 let segments: Vec<ballistics_engine::wind::WindSegment> = zones
445 .into_iter()
446 .map(|(speed_mps, angle_deg, until_m, vertical_mps)| {
447 ballistics_engine::wind::WindSegment {
448 speed_kmh: speed_mps * 3.6,
449 angle_deg,
450 until_m,
451 vertical_mps,
452 }
453 })
454 .collect();
455 self.wind_sock = Some(
456 ballistics_engine::wind::WindSock::try_new(segments)
457 .map_err(|e| crate::Error::Model(format!("invalid wind segments: {e:?}")))?,
458 );
459 Ok(self)
460 }
461
462 fn effective_wind(&self, state: &FlightState) -> Vec3 {
463 let base = match &self.wind_sock {
464 Some(sock) => sock.vector_for_range_stateless(state.position_m.x),
465 None => self.wind_mps,
466 };
467 match &self.turbulence {
468 Some(t) => base + t.gust_vec(state.time_s),
469 None => base,
470 }
471 }
472
473 fn recovery_accel(&self, state: &FlightState, mass_kg: f64) -> Vec3 {
474 let v_air = state.velocity_mps - self.effective_wind(state);
475 let speed = v_air.norm();
476 let g = Vec3::new(0.0, -ballistics_engine::constants::G_ACCEL_MPS2, 0.0);
477 if speed < 1e-12 {
478 return g;
479 }
480 let (rho, _) = local_air(self.atmosphere, self.atmos_params, state.position_m.y);
481 let q = 0.5 * rho * speed * speed;
482 g - v_air * (q * self.cd_area(state) / (speed * mass_kg))
483 }
484
485 fn recovering(&self, state: &FlightState) -> bool {
486 state.velocity_mps.y < 0.0 && state.time_s >= self.deploy_after_s
489 }
490 }
491
492 impl<F: ForceModel> ForceModel for RecoveryForces<F> {
493 fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3> {
494 if !self.recovering(state) {
495 return self.inner.acceleration(state, mass_kg);
496 }
497 Ok(self.recovery_accel(state, mass_kg))
498 }
499
500 fn acceleration_with_axis(
501 &self,
502 state: &FlightState,
503 mass_kg: f64,
504 body_axis: Vec3,
505 ) -> Result<Vec3> {
506 if !self.recovering(state) {
507 return self.inner.acceleration_with_axis(state, mass_kg, body_axis);
508 }
509 Ok(self.recovery_accel(state, mass_kg))
510 }
511 }
512
513 #[derive(Debug, Clone)]
516 pub struct Airframe {
517 pub diameter_m: f64,
519 pub cd_vs_mach: Vec<(f64, f64)>,
521 pub cna_vs_mach: Vec<(f64, f64)>,
523 pub cp_vs_mach: Vec<(f64, f64)>,
525 pub cg_dry_m: f64,
527 pub cg_prop_m: f64,
529 pub pitch_inertia_kg_m2: f64,
531 pub induced_k_vs_mach: Vec<(f64, f64)>,
533 pub recovery_cd_area_m2: f64,
535 }
536
537 impl Airframe {
538 pub fn sport_24mm() -> Self {
540 Self {
541 diameter_m: 0.024,
542 cd_vs_mach: vec![(0.0, 0.4), (0.8, 0.45), (1.2, 0.65), (3.0, 0.5)],
543 cna_vs_mach: vec![(0.0, 12.0), (1.0, 14.0), (3.0, 10.0)],
544 cp_vs_mach: vec![(0.0, 0.19), (1.0, 0.2), (3.0, 0.18)],
545 cg_dry_m: 0.12,
546 cg_prop_m: 0.16,
547 pitch_inertia_kg_m2: 0.004,
548 induced_k_vs_mach: vec![(0.0, 12.0), (1.0, 14.0), (3.0, 10.0)],
550 recovery_cd_area_m2: 0.25,
551 }
552 }
553
554 pub fn high_power_54mm() -> Self {
556 Self {
557 diameter_m: 0.054,
558 cd_vs_mach: vec![(0.0, 0.35), (0.8, 0.4), (1.2, 0.6), (3.0, 0.45)],
559 cna_vs_mach: vec![(0.0, 16.0), (1.0, 18.0), (3.0, 14.0)],
560 cp_vs_mach: vec![(0.0, 0.42), (1.0, 0.45), (3.0, 0.4)],
561 cg_dry_m: 0.28,
562 cg_prop_m: 0.36,
563 pitch_inertia_kg_m2: 0.03,
564 induced_k_vs_mach: vec![(0.0, 16.0), (1.0, 18.0), (3.0, 14.0)],
566 recovery_cd_area_m2: 0.8,
567 }
568 }
569
570 pub fn forces(&self, wind_mps: Vec3) -> Result<BallisticForces> {
572 BallisticForces::new(
573 self.diameter_m,
574 self.cd_vs_mach.clone(),
575 Atmosphere::default(),
576 wind_mps,
577 )?
578 .with_body_lift(self.cna_vs_mach.clone(), self.induced_k_vs_mach.clone())
579 }
580
581 pub fn weathercock_params(&self) -> Result<WeathercockParams> {
583 WeathercockParams::new(
584 self.cna_vs_mach.clone(),
585 self.cp_vs_mach.clone(),
586 self.cg_dry_m,
587 self.cg_prop_m,
588 self.diameter_m,
589 self.pitch_inertia_kg_m2,
590 0.2,
591 )
592 }
593
594 pub fn by_name(name: &str) -> Option<Self> {
596 match name {
597 "sport24" => Some(Self::sport_24mm()),
598 "hp54" => Some(Self::high_power_54mm()),
599 _ => None,
600 }
601 }
602 }
603}