Skip to main content

hpr_motor/
curve.rs

1//! Thrust curves: thrust against time, and the impulse, burn time and average thrust derived from
2//! them.
3//!
4//! A [`ThrustCurve`] holds samples `(t_i, F_i)` and joins them with straight lines, which is how
5//! the RASP `.eng` and RockSim `.rse` formats are drawn and how RocketPy reads them by default.
6//! When the first sample is after `t = 0`, the curve starts from an implicit `(0, 0)`, as the RASP
7//! format specifies. After the last sample the thrust is zero. Two samples at the same time make a
8//! step: the thrust takes the later sample's value from that time on. Real curves use steps for an
9//! abrupt burnout and where times were rounded (`docs/format/eng.md`).
10//!
11//! **Delivered samples.** A sample is *delivered* when it starts or ends a segment of non-zero
12//! width (`t_i < t_{i+1}` or `t_{i−1} < t_i`). Its thrust is then a value the curve takes: at its
13//! own instant when it starts a segment ([`ThrustCurve::thrust_n`] returns it there), or as the
14//! limit while time rises to its instant when it ends one. A sample strictly inside a run of three
15//! or more equal times (`t_{i−1} = t_i = t_{i+1}`) is never delivered: the thrust steps straight
16//! past it to the run's last value; so is the first sample of a step at the curve's start and the
17//! last of a step at its end, where the thrust is zero outside the curve. The peak and the
18//! burn-time crossings use only delivered samples, so the peak is exactly the least upper bound
19//! of [`ThrustCurve::thrust_n`] over time.
20//!
21//! **Impulse.** The integral of the piecewise-linear thrust is exact by the trapezoid rule:
22//!
23//! ```text
24//! I(t) = Σ ½ (F_{i} + F_{i+1}) (t_{i+1} − t_i)   over the intervals before t, plus the partial one
25//! ```
26//!
27//! **Burn time (NFPA 1125).** From the moment the thrust first reaches 5% of its peak to the
28//! moment it last falls to 5% of its peak, with the crossings found on the straight segments and
29//! the peak taken over delivered samples.
30//! ThrustCurve.org's glossary uses this definition. **Average thrust** is the total impulse over
31//! that burn time. See `docs/physics/motor.md`.
32
33use serde::{Deserialize, Serialize};
34
35use crate::error::MotorError;
36
37/// The fraction of peak thrust that starts and ends the NFPA 1125 burn time.
38pub const NFPA_1125_THRESHOLD: f64 = 0.05;
39
40/// A thrust curve: thrust in newtons against time in seconds from ignition, joined by straight
41/// lines.
42///
43/// Built through [`ThrustCurve::new`], which checks the samples; it serializes as its samples and
44/// re-checks them when deserialized.
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
46#[serde(try_from = "CurveData", into = "CurveData")]
47pub struct ThrustCurve {
48    /// Sample times, s, non-decreasing and starting at 0.
49    times_s: Vec<f64>,
50    /// Thrust at each time, N, non-negative.
51    thrusts_n: Vec<f64>,
52    /// `I(t_i)`, the impulse delivered by each sample time, N·s.
53    impulse_ns: Vec<f64>,
54}
55
56/// The serialized form of a [`ThrustCurve`]: its samples as given.
57#[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    /// Builds a curve from sample times (s) and thrusts (N). A `(0, 0)` sample is prepended when
83    /// the first time is after zero.
84    ///
85    /// # Errors
86    ///
87    /// - [`MotorError::Inconsistent`] if the columns differ in length or are empty.
88    /// - [`MotorError::Domain`] for a time or thrust that is not finite, a negative time, or a
89    ///   negative thrust.
90    /// - [`MotorError::TimesDecreasing`] if a time is before its predecessor. Equal times are a
91    ///   step.
92    /// - [`MotorError::NoThrust`] if the total impulse or the NFPA 1125 burn time is not positive
93    ///   (no positive thrust, or thrust only at a single instant).
94    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        // Zero impulse would make the exhaust velocity zero and the mass flow infinite; zero burn
149        // time would make the average thrust infinite.
150        if !(total > 0.0 && curve.burn_time_s() > 0.0) {
151            return Err(MotorError::NoThrust);
152        }
153        Ok(curve)
154    }
155
156    /// The sample times, s, starting at 0 (including a prepended `(0, 0)`).
157    pub fn times_s(&self) -> &[f64] {
158        &self.times_s
159    }
160
161    /// The thrust at each sample time, N.
162    pub fn thrusts_n(&self) -> &[f64] {
163        &self.thrusts_n
164    }
165
166    /// The last sample time, s. The thrust is zero from here on, this instant included.
167    pub fn end_time_s(&self) -> f64 {
168        self.times_s.last().copied().unwrap_or(0.0)
169    }
170
171    /// The peak thrust, N: the largest delivered sample (see the module docs), which is the least
172    /// upper bound of [`ThrustCurve::thrust_n`] over time. A sample the thrust steps past inside a
173    /// run of three or more equal times does not count.
174    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    /// Whether sample `i` is delivered: it starts or ends a segment of non-zero width.
181    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    /// The indices of the delivered samples, in order.
187    fn delivered(&self) -> impl DoubleEndedIterator<Item = usize> + '_ {
188        (0..self.times_s.len()).filter(|&i| self.is_delivered(i))
189    }
190
191    /// The total impulse `I = ∫ F dt`, N·s.
192    pub fn total_impulse_ns(&self) -> f64 {
193        self.impulse_ns.last().copied().unwrap_or(0.0)
194    }
195
196    /// The thrust at time `t` (s after ignition), N: linear between samples, zero before ignition
197    /// and from the last sample on. A NaN time gives NaN.
198    ///
199    /// Like any step, the end of the curve takes the later value at its own instant: a curve that
200    /// ends above zero thrust gives zero at its last sample time, when all the propellant is gone.
201    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    /// The impulse delivered by time `t`, `I(t) = ∫₀ᵗ F dt`, N·s: exact for the piecewise-linear
216    /// thrust, zero before ignition and the total impulse after the last sample. A NaN time gives
217    /// NaN.
218    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    /// The NFPA 1125 burn window `(start, end)`, s: when the thrust first reaches, and last falls
238    /// to, [`NFPA_1125_THRESHOLD`] of its peak, interpolated on the straight segments. Only
239    /// delivered samples (see the module docs) are compared with the threshold.
240    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        // `new` checks the impulse is positive before asking for the window, so some segment of
245        // non-zero width has a positive end, the delivered peak is positive, and some delivered
246        // sample reaches the level. The segment before the first such sample either has non-zero
247        // width, so its start is delivered and below the level and the crossing is inside it, or
248        // is a step, where the crossing is the step's instant; the same holds after the last one.
249        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    /// The NFPA 1125 burn time, s: the length of [`ThrustCurve::burn_window_s`].
265    pub fn burn_time_s(&self) -> f64 {
266        let (start, end) = self.burn_window_s();
267        end - start
268    }
269
270    /// The average thrust, N: the total impulse over the NFPA 1125 burn time.
271    pub fn average_thrust_n(&self) -> f64 {
272        self.total_impulse_ns() / self.burn_time_s()
273    }
274
275    /// The index `i` of the segment `[t_i, t_{i+1})` holding `t`, or `None` outside
276    /// `[0, end_time)`. Zero-width segments (steps) never hold a time: the last sample at or
277    /// before `t` starts the segment.
278    fn segment(&self, t: f64) -> Option<usize> {
279        if !(t >= 0.0 && t < self.end_time_s()) {
280            return None;
281        }
282        // The first sample is at 0 <= t, so at least one time is <= t.
283        Some(self.times_s.partition_point(|&ti| ti <= t) - 1)
284    }
285
286    /// The time on segment `i` where the thrust equals `level`, which lies between its ends.
287    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        // 5% of the 100 N peak is reached at 0.05 s and left at 1.95 s.
318        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        // A curve that ends above 5% of peak ends its burn at the last sample.
352        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        // It holds its thrust up to the end and steps to zero there, with the propellant gone.
355        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        // A plateau that stops dead at 1 s, then a step up and a ramp down.
362        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        // 5% of 10 N is 0.5 N, last passed on the final ramp at 3 − 0.5/6 s.
378        assert!((end - (3.0 - 0.5 / 6.0)).abs() < 1e-12);
379        // A step down at the end holds its last value only at that instant.
380        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            // Thrust at a single instant: no impulse.
397            (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            // Thrust only at samples the steps skip: no impulse.
401            (
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        // The issue #10 example: the 1000 N sample sits inside a run of three equal times, so the
424        // thrust steps from 10 N to 10 N past it. Counting it made the 5% level 50 N, above
425        // everything the curve delivers, and the curve was refused.
426        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        // 5% of 10 N is 0.5 N, passed on the final ramp at 2 − 0.5/10 s.
437        assert!((end - 1.95).abs() < 1e-12);
438        assert!((curve.average_thrust_n() - 15.0 / 1.95).abs() < 1e-12);
439        // A spike with only a 1% trickle after it, which this test once refused for having no
440        // burn time: the curve delivers the 1 N trickle and nothing else.
441        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        // A triangle to 100 N with a 400 N sample the step skips at its apex.
454        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        // The window of the plain triangle, not the 20 N level's 0.2 s to 1.8 s.
462        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        // A 50 N sample skipped at ignition: the thrust ramps from 0 at t = 0, so the burn starts
469        // on the ramp at 5 N, not at the skipped sample.
470        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        // A 50 N sample skipped at burnout: the burn ends on the ramp down.
478        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        // A step up through a skipped 30 N sample: the burn starts at the step's instant, and
486        // the peak is the 20 N the step lands on.
487        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    /// Curves with steps: about a third of the time gaps are zero, so runs of two, three or more
523    /// equal times occur.
524    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    /// The thrust at a dense grid of times, at every sample time and just before it (where a
549    /// segment's end value is approached): the values a check of the peak compares against.
550    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            // `thrust_n` interpolates, so a value a hair past a segment's end is rounding; and
568            // just before a segment's end it reaches that end's thrust to within rounding.
569            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            // Simpson's rule is exact for the linear pieces, so split [a, b] at the samples.
593            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    /// The thrust approaching `t` from the left. The generated curves have no steps, so this
619    /// differs from [`ThrustCurve::thrust_n`] only from the last sample on.
620    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}