1use serde::{Deserialize, Serialize};
34
35use crate::error::MotorError;
36
37pub const NFPA_1125_THRESHOLD: f64 = 0.05;
39
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
46#[serde(try_from = "CurveData", into = "CurveData")]
47pub struct ThrustCurve {
48 times_s: Vec<f64>,
50 thrusts_n: Vec<f64>,
52 impulse_ns: Vec<f64>,
54}
55
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
58#[serde(deny_unknown_fields)]
59struct CurveData {
60 times_s: Vec<f64>,
61 thrusts_n: Vec<f64>,
62}
63
64impl TryFrom<CurveData> for ThrustCurve {
65 type Error = MotorError;
66
67 fn try_from(data: CurveData) -> Result<Self, Self::Error> {
68 Self::new(data.times_s, data.thrusts_n)
69 }
70}
71
72impl From<ThrustCurve> for CurveData {
73 fn from(curve: ThrustCurve) -> Self {
74 Self {
75 times_s: curve.times_s,
76 thrusts_n: curve.thrusts_n,
77 }
78 }
79}
80
81impl ThrustCurve {
82 pub fn new(times_s: Vec<f64>, thrusts_n: Vec<f64>) -> Result<Self, MotorError> {
95 if times_s.len() != thrusts_n.len() || times_s.is_empty() {
96 return Err(MotorError::Inconsistent(format!(
97 "a thrust curve needs matching, non-empty columns; got {} times and {} thrusts",
98 times_s.len(),
99 thrusts_n.len()
100 )));
101 }
102 for (&t, &f) in times_s.iter().zip(&thrusts_n) {
103 if !(t.is_finite() && t >= 0.0) {
104 return Err(MotorError::Domain {
105 what: "thrust-curve time (s)",
106 value: t,
107 });
108 }
109 if !(f.is_finite() && f >= 0.0) {
110 return Err(MotorError::Domain {
111 what: "thrust-curve thrust (N)",
112 value: f,
113 });
114 }
115 }
116 if let Some(index) = times_s.windows(2).position(|w| w[1] < w[0]) {
117 return Err(MotorError::TimesDecreasing {
118 index: index + 1,
119 time_s: times_s[index + 1],
120 });
121 }
122 if !thrusts_n.iter().any(|&f| f > 0.0) {
123 return Err(MotorError::NoThrust);
124 }
125 let (mut times_s, mut thrusts_n) = (times_s, thrusts_n);
126 if times_s[0] > 0.0 {
127 times_s.insert(0, 0.0);
128 thrusts_n.insert(0, 0.0);
129 }
130 let mut impulse_ns = Vec::with_capacity(times_s.len());
131 let mut total = 0.0;
132 impulse_ns.push(total);
133 for i in 1..times_s.len() {
134 total += 0.5 * (thrusts_n[i - 1] + thrusts_n[i]) * (times_s[i] - times_s[i - 1]);
135 impulse_ns.push(total);
136 }
137 if !total.is_finite() {
138 return Err(MotorError::Domain {
139 what: "total impulse (N·s)",
140 value: total,
141 });
142 }
143 let curve = Self {
144 times_s,
145 thrusts_n,
146 impulse_ns,
147 };
148 if !(total > 0.0 && curve.burn_time_s() > 0.0) {
151 return Err(MotorError::NoThrust);
152 }
153 Ok(curve)
154 }
155
156 pub fn times_s(&self) -> &[f64] {
158 &self.times_s
159 }
160
161 pub fn thrusts_n(&self) -> &[f64] {
163 &self.thrusts_n
164 }
165
166 pub fn end_time_s(&self) -> f64 {
168 self.times_s.last().copied().unwrap_or(0.0)
169 }
170
171 pub fn peak_thrust_n(&self) -> f64 {
175 self.delivered()
176 .map(|i| self.thrusts_n[i])
177 .fold(0.0, f64::max)
178 }
179
180 fn is_delivered(&self, i: usize) -> bool {
182 let t = &self.times_s;
183 (i + 1 < t.len() && t[i] < t[i + 1]) || (i > 0 && t[i - 1] < t[i])
184 }
185
186 fn delivered(&self) -> impl DoubleEndedIterator<Item = usize> + '_ {
188 (0..self.times_s.len()).filter(|&i| self.is_delivered(i))
189 }
190
191 pub fn total_impulse_ns(&self) -> f64 {
193 self.impulse_ns.last().copied().unwrap_or(0.0)
194 }
195
196 pub fn thrust_n(&self, t: f64) -> f64 {
202 if t.is_nan() {
203 return f64::NAN;
204 }
205 match self.segment(t) {
206 Some(i) => {
207 let (t0, t1) = (self.times_s[i], self.times_s[i + 1]);
208 let (f0, f1) = (self.thrusts_n[i], self.thrusts_n[i + 1]);
209 f0 + (f1 - f0) * (t - t0) / (t1 - t0)
210 }
211 None => 0.0,
212 }
213 }
214
215 pub fn impulse_ns(&self, t: f64) -> f64 {
219 if t.is_nan() {
220 return f64::NAN;
221 }
222 if t <= 0.0 {
223 return 0.0;
224 }
225 match self.segment(t) {
226 Some(i) => {
227 let (t0, t1) = (self.times_s[i], self.times_s[i + 1]);
228 let (f0, f1) = (self.thrusts_n[i], self.thrusts_n[i + 1]);
229 let dt = t - t0;
230 let f = f0 + (f1 - f0) * dt / (t1 - t0);
231 self.impulse_ns[i] + 0.5 * (f0 + f) * dt
232 }
233 None => self.total_impulse_ns(),
234 }
235 }
236
237 pub fn burn_window_s(&self) -> (f64, f64) {
241 let level = NFPA_1125_THRESHOLD * self.peak_thrust_n();
242 let n = self.times_s.len();
243 let above = |&i: &usize| self.thrusts_n[i] >= level;
244 let first = self.delivered().find(above).unwrap_or(0);
250 let start = if first == 0 {
251 self.times_s[0]
252 } else {
253 self.crossing(first - 1, level)
254 };
255 let last = self.delivered().rev().find(above).unwrap_or(n - 1);
256 let end = if last == n - 1 {
257 self.times_s[n - 1]
258 } else {
259 self.crossing(last, level)
260 };
261 (start, end)
262 }
263
264 pub fn burn_time_s(&self) -> f64 {
266 let (start, end) = self.burn_window_s();
267 end - start
268 }
269
270 pub fn average_thrust_n(&self) -> f64 {
272 self.total_impulse_ns() / self.burn_time_s()
273 }
274
275 fn segment(&self, t: f64) -> Option<usize> {
279 if !(t >= 0.0 && t < self.end_time_s()) {
280 return None;
281 }
282 Some(self.times_s.partition_point(|&ti| ti <= t) - 1)
284 }
285
286 fn crossing(&self, i: usize, level: f64) -> f64 {
288 let (t0, t1) = (self.times_s[i], self.times_s[i + 1]);
289 let (f0, f1) = (self.thrusts_n[i], self.thrusts_n[i + 1]);
290 if f1 == f0 {
291 return t0;
292 }
293 (t0 + (t1 - t0) * (level - f0) / (f1 - f0)).clamp(t0, t1)
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use proptest::prelude::*;
300
301 use super::*;
302
303 fn triangle() -> ThrustCurve {
304 ThrustCurve::new(vec![0.0, 1.0, 2.0], vec![0.0, 100.0, 0.0]).unwrap()
305 }
306
307 #[test]
308 fn triangle_impulse_burn_time_and_average() {
309 let curve = triangle();
310 assert_eq!(curve.total_impulse_ns(), 100.0);
311 assert_eq!(curve.peak_thrust_n(), 100.0);
312 assert_eq!(curve.thrust_n(0.5), 50.0);
313 assert_eq!(curve.thrust_n(1.5), 50.0);
314 assert_eq!(curve.impulse_ns(1.0), 50.0);
315 assert_eq!(curve.impulse_ns(0.5), 12.5);
316 assert_eq!(curve.impulse_ns(1.5), 87.5);
317 let (start, end) = curve.burn_window_s();
319 assert!((start - 0.05).abs() < 1e-15 && (end - 1.95).abs() < 1e-15);
320 assert!((curve.burn_time_s() - 1.9).abs() < 1e-15);
321 assert!((curve.average_thrust_n() - 100.0 / 1.9).abs() < 1e-12);
322 }
323
324 #[test]
325 fn thrust_is_zero_outside_the_curve_and_nan_propagates() {
326 let curve = triangle();
327 assert_eq!(curve.thrust_n(-1.0), 0.0);
328 assert_eq!(curve.thrust_n(2.0), 0.0);
329 assert_eq!(curve.thrust_n(3.0), 0.0);
330 assert_eq!(curve.impulse_ns(-1.0), 0.0);
331 assert_eq!(curve.impulse_ns(5.0), 100.0);
332 assert!(curve.thrust_n(f64::NAN).is_nan());
333 assert!(curve.impulse_ns(f64::NAN).is_nan());
334 }
335
336 #[test]
337 fn late_first_sample_starts_from_an_implicit_origin() {
338 let curve = ThrustCurve::new(vec![0.5, 1.0], vec![10.0, 0.0]).unwrap();
339 assert_eq!(curve.times_s(), &[0.0, 0.5, 1.0]);
340 assert_eq!(curve.thrusts_n(), &[0.0, 10.0, 0.0]);
341 assert_eq!(curve.total_impulse_ns(), 5.0);
342 assert_eq!(curve.thrust_n(0.25), 5.0);
343 }
344
345 #[test]
346 fn thrust_at_ignition_starts_the_burn_window_at_zero() {
347 let curve = ThrustCurve::new(vec![0.0, 1.0, 1.1], vec![20.0, 20.0, 0.0]).unwrap();
348 let (start, end) = curve.burn_window_s();
349 assert_eq!(start, 0.0);
350 assert!((end - 1.095).abs() < 1e-12);
351 let cut = ThrustCurve::new(vec![0.0, 1.0], vec![20.0, 20.0]).unwrap();
353 assert_eq!(cut.burn_window_s(), (0.0, 1.0));
354 assert_eq!(cut.thrust_n(1.0 - 1e-12), 20.0);
356 assert_eq!(cut.thrust_n(1.0), 0.0);
357 }
358
359 #[test]
360 fn equal_times_make_a_step() {
361 let curve = ThrustCurve::new(
363 vec![0.0, 1.0, 1.0, 2.0, 2.0, 3.0],
364 vec![10.0, 10.0, 0.0, 0.0, 6.0, 0.0],
365 )
366 .unwrap();
367 assert_eq!(curve.thrust_n(0.999), 10.0);
368 assert_eq!(curve.thrust_n(1.0), 0.0);
369 assert_eq!(curve.thrust_n(1.5), 0.0);
370 assert_eq!(curve.thrust_n(2.0), 6.0);
371 assert_eq!(curve.thrust_n(2.5), 3.0);
372 assert_eq!(curve.total_impulse_ns(), 13.0);
373 assert_eq!(curve.impulse_ns(1.0), 10.0);
374 assert_eq!(curve.impulse_ns(2.0), 10.0);
375 let (start, end) = curve.burn_window_s();
376 assert_eq!(start, 0.0);
377 assert!((end - (3.0 - 0.5 / 6.0)).abs() < 1e-12);
379 let cut = ThrustCurve::new(vec![0.0, 1.0, 1.0], vec![10.0, 10.0, 0.0]).unwrap();
381 assert_eq!(cut.thrust_n(1.0), 0.0);
382 assert_eq!(cut.burn_window_s(), (0.0, 1.0));
383 }
384
385 #[test]
386 fn rejects_bad_samples() {
387 let bad = [
388 (vec![], vec![]),
389 (vec![0.0, 1.0], vec![1.0]),
390 (vec![0.0, 2.0, 1.0], vec![0.0, 5.0, 0.0]),
391 (vec![-0.1, 1.0], vec![5.0, 0.0]),
392 (vec![0.0, 1.0], vec![-5.0, 0.0]),
393 (vec![0.0, f64::NAN], vec![5.0, 0.0]),
394 (vec![0.0, 1.0], vec![f64::INFINITY, 0.0]),
395 (vec![0.0, 1.0], vec![0.0, 0.0]),
396 (vec![0.0], vec![5.0]),
398 (vec![0.5, 0.5], vec![0.0, 100.0]),
399 (vec![0.0, 0.0], vec![5.0, 5.0]),
400 (
402 vec![0.0, 1.0, 1.0, 1.0, 2.0],
403 vec![0.0, 0.0, 100.0, 0.0, 0.0],
404 ),
405 ];
406 for (times, thrusts) in bad {
407 assert!(
408 ThrustCurve::new(times.clone(), thrusts.clone()).is_err(),
409 "{times:?} {thrusts:?}"
410 );
411 }
412 assert_eq!(
413 ThrustCurve::new(vec![0.0, 2.0, 1.0], vec![0.0, 5.0, 0.0]),
414 Err(MotorError::TimesDecreasing {
415 index: 2,
416 time_s: 1.0
417 })
418 );
419 }
420
421 #[test]
422 fn an_undelivered_spike_neither_sets_the_peak_nor_blocks_the_curve() {
423 let curve = ThrustCurve::new(
427 vec![0.0, 1.0, 1.0, 1.0, 2.0],
428 vec![10.0, 10.0, 1000.0, 10.0, 0.0],
429 )
430 .unwrap();
431 assert_eq!(curve.thrust_n(1.0), 10.0);
432 assert_eq!(curve.peak_thrust_n(), 10.0);
433 assert_eq!(curve.total_impulse_ns(), 15.0);
434 let (start, end) = curve.burn_window_s();
435 assert_eq!(start, 0.0);
436 assert!((end - 1.95).abs() < 1e-12);
438 assert!((curve.average_thrust_n() - 15.0 / 1.95).abs() < 1e-12);
439 let trickle = ThrustCurve::new(
442 vec![0.0, 1.0, 1.0, 1.0, 2.0],
443 vec![0.0, 0.0, 100.0, 0.0, 1.0],
444 )
445 .unwrap();
446 assert_eq!(trickle.peak_thrust_n(), 1.0);
447 let (start, end) = trickle.burn_window_s();
448 assert!((start - 1.05).abs() < 1e-12 && end == 2.0);
449 }
450
451 #[test]
452 fn a_milder_undelivered_spike_neither_inflates_the_peak_nor_shortens_the_burn() {
453 let curve = ThrustCurve::new(
455 vec![0.0, 1.0, 1.0, 1.0, 2.0],
456 vec![0.0, 100.0, 400.0, 100.0, 0.0],
457 )
458 .unwrap();
459 assert_eq!(curve.peak_thrust_n(), 100.0);
460 assert_eq!(curve.total_impulse_ns(), 100.0);
461 let (start, end) = curve.burn_window_s();
463 assert!((start - 0.05).abs() < 1e-15 && (end - 1.95).abs() < 1e-15);
464 }
465
466 #[test]
467 fn crossings_skip_undelivered_samples() {
468 let early = ThrustCurve::new(
471 vec![0.0, 0.0, 0.0, 1.0, 2.0],
472 vec![0.0, 50.0, 0.0, 100.0, 0.0],
473 )
474 .unwrap();
475 let (start, end) = early.burn_window_s();
476 assert!((start - 0.05).abs() < 1e-15 && (end - 1.95).abs() < 1e-15);
477 let late = ThrustCurve::new(
479 vec![0.0, 1.0, 2.0, 2.0, 2.0],
480 vec![0.0, 100.0, 0.0, 50.0, 0.0],
481 )
482 .unwrap();
483 let (start, end) = late.burn_window_s();
484 assert!((start - 0.05).abs() < 1e-15 && (end - 1.95).abs() < 1e-15);
485 let step = ThrustCurve::new(
488 vec![0.0, 1.0, 1.0, 1.0, 2.0],
489 vec![0.0, 0.0, 30.0, 20.0, 0.0],
490 )
491 .unwrap();
492 assert_eq!(step.peak_thrust_n(), 20.0);
493 assert_eq!(step.burn_window_s().0, 1.0);
494 }
495
496 #[test]
497 fn serde_round_trips_and_rechecks() {
498 let curve = ThrustCurve::new(vec![0.1, 0.3, 1.7], vec![12.5, 30.25, 0.0]).unwrap();
499 let json = serde_json::to_string(&curve).unwrap();
500 assert_eq!(serde_json::from_str::<ThrustCurve>(&json).unwrap(), curve);
501 let bad = r#"{"times_s":[0.0,1.0],"thrusts_n":[0.0,0.0]}"#;
502 assert!(serde_json::from_str::<ThrustCurve>(bad).is_err());
503 }
504
505 fn curves() -> impl Strategy<Value = ThrustCurve> {
506 prop::collection::vec((1e-3..1.0f64, 0.0..5000.0f64), 1..40).prop_filter_map(
507 "needs positive thrust",
508 |steps| {
509 let mut t = 0.0;
510 let (times, thrusts): (Vec<f64>, Vec<f64>) = steps
511 .into_iter()
512 .map(|(dt, f)| {
513 t += dt;
514 (t, f)
515 })
516 .unzip();
517 ThrustCurve::new(times, thrusts).ok()
518 },
519 )
520 }
521
522 fn stepped_curves() -> impl Strategy<Value = ThrustCurve> {
525 let gap = prop_oneof![
526 Just(0.0),
527 Just(0.0),
528 1e-3..1.0f64,
529 1e-3..1.0f64,
530 1e-3..1.0f64
531 ];
532 prop::collection::vec((gap, 0.0..5000.0f64), 1..40).prop_filter_map(
533 "needs impulse and a burn time",
534 |steps| {
535 let mut t = 0.0;
536 let (times, thrusts): (Vec<f64>, Vec<f64>) = steps
537 .into_iter()
538 .map(|(dt, f)| {
539 t += dt;
540 (t, f)
541 })
542 .unzip();
543 ThrustCurve::new(times, thrusts).ok()
544 },
545 )
546 }
547
548 fn evaluated(curve: &ThrustCurve) -> Vec<(f64, f64)> {
551 let end = curve.end_time_s();
552 let mut times: Vec<f64> = (0..=4000).map(|k| end * f64::from(k) / 4000.0).collect();
553 for &t in curve.times_s() {
554 times.push(t);
555 if t > 0.0 {
556 times.push(t.next_down());
557 }
558 }
559 times.into_iter().map(|t| (t, curve.thrust_n(t))).collect()
560 }
561
562 proptest! {
563 #[test]
564 fn peak_is_the_most_the_evaluator_delivers(curve in stepped_curves()) {
565 let peak = curve.peak_thrust_n();
566 let most = evaluated(&curve).into_iter().map(|(_, f)| f).fold(0.0, f64::max);
567 prop_assert!(most <= peak * (1.0 + 1e-12), "evaluator {most} above peak {peak}");
570 prop_assert!(most >= peak * (1.0 - 1e-9), "peak {peak} never delivered (max {most})");
571 }
572
573 #[test]
574 fn stepped_burn_window_holds_every_time_above_the_level(curve in stepped_curves()) {
575 let (start, end) = curve.burn_window_s();
576 prop_assert!(0.0 <= start && start < end && end <= curve.end_time_s());
577 let level = NFPA_1125_THRESHOLD * curve.peak_thrust_n();
578 for (t, f) in evaluated(&curve) {
579 prop_assert!(
580 f <= level * (1.0 + 1e-9) || (start <= t && t <= end),
581 "{f} N at {t} s is outside the window {start}..{end} (level {level})"
582 );
583 }
584 }
585
586 #[test]
587 fn impulse_integrates_thrust_exactly(curve in curves(), u in 0.0..1.0f64, v in 0.0..1.0f64) {
588 let end = curve.end_time_s();
589 let (a, b) = if u < v { (u * end, v * end) } else { (v * end, u * end) };
590 let (ia, ib) = (curve.impulse_ns(a), curve.impulse_ns(b));
591 prop_assert!(ib >= ia);
592 let mut knots = vec![a];
594 knots.extend(curve.times_s().iter().copied().filter(|&t| t > a && t < b));
595 knots.push(b);
596 let quadrature: f64 = knots
597 .windows(2)
598 .map(|w| {
599 let m = 0.5 * (w[0] + w[1]);
600 (w[1] - w[0]) / 6.0
601 * (curve.thrust_n(w[0]) + 4.0 * curve.thrust_n(m) + right(&curve, w[1]))
602 })
603 .sum();
604 let scale = curve.total_impulse_ns().max(1e-9);
605 prop_assert!((ib - ia - quadrature).abs() <= 1e-9 * scale);
606 }
607
608 #[test]
609 fn burn_window_is_inside_the_curve(curve in curves()) {
610 let (start, end) = curve.burn_window_s();
611 prop_assert!(0.0 <= start && start <= end && end <= curve.end_time_s());
612 let level = NFPA_1125_THRESHOLD * curve.peak_thrust_n();
613 prop_assert!(curve.times_s().iter().zip(curve.thrusts_n())
614 .all(|(&t, &f)| f < level || (start <= t && t <= end)));
615 }
616 }
617
618 fn right(curve: &ThrustCurve, t: f64) -> f64 {
621 if t >= curve.end_time_s() {
622 curve.thrusts_n().last().copied().unwrap_or(0.0)
623 } else {
624 curve.thrust_n(t)
625 }
626 }
627}