Skip to main content

rockets_engine/
motor.rs

1use crate::{nonnegative, positive, Error, Result};
2
3/// One measured or synthetic thrust sample, relative to motor ignition.
4#[derive(Debug, Clone, Copy, PartialEq)]
5pub struct ThrustPoint {
6    /// Seconds since ignition; the first point must be at zero.
7    pub time_s: f64,
8    /// Nonnegative force in newtons.
9    pub thrust_n: f64,
10}
11
12/// Validated, piecewise-linear thrust curve with exact piecewise-quadratic impulse.
13#[derive(Debug, Clone)]
14pub struct ThrustCurve {
15    points: Vec<ThrustPoint>,
16    cumulative_impulse_ns: Vec<f64>,
17}
18
19impl ThrustCurve {
20    /// Construct from at least two strictly time-ordered samples, starting at zero.
21    /// Total impulse must be positive. Endpoint thrust need not be zero; the integrator
22    /// explicitly handles the discontinuities at ignition and burnout.
23    pub fn new(points: Vec<ThrustPoint>) -> Result<Self> {
24        if points.len() < 2 || points[0].time_s != 0.0 {
25            return Err(Error::invalid(
26                "thrust curve",
27                "needs at least two points starting at time zero",
28            ));
29        }
30        let mut impulse = vec![0.0];
31        for (i, point) in points.iter().enumerate() {
32            nonnegative(point.time_s, "thrust point time_s")?;
33            nonnegative(point.thrust_n, "thrust point thrust_n")?;
34            if i > 0 {
35                let previous = points[i - 1];
36                let dt = point.time_s - previous.time_s;
37                positive(dt, "thrust curve time spacing")?;
38                let area = (previous.thrust_n * 0.5 + point.thrust_n * 0.5) * dt;
39                let total = impulse[i - 1] + area;
40                if !total.is_finite() {
41                    return Err(Error::invalid(
42                        "thrust curve",
43                        "total impulse must be finite",
44                    ));
45                }
46                impulse.push(total);
47            }
48        }
49        positive(*impulse.last().unwrap(), "total impulse")?;
50        Ok(Self {
51            points,
52            cumulative_impulse_ns: impulse,
53        })
54    }
55
56    /// Read-only access to the validated samples.
57    pub fn points(&self) -> &[ThrustPoint] {
58        &self.points
59    }
60
61    /// Time of the last sample, in seconds after ignition.
62    pub fn duration_s(&self) -> f64 {
63        self.points.last().unwrap().time_s
64    }
65
66    /// Integral of thrust over the entire curve, in newton seconds.
67    pub fn total_impulse_ns(&self) -> f64 {
68        *self.cumulative_impulse_ns.last().unwrap()
69    }
70
71    /// Interpolated thrust. Returns zero outside the closed curve interval.
72    /// At the final sample this returns the left-limit force; [`Motor::sample`]
73    /// reports zero thrust at and after burnout instead.
74    pub fn thrust_at(&self, elapsed_s: f64) -> Result<f64> {
75        if !elapsed_s.is_finite() {
76            return Err(Error::invalid("elapsed_s", "must be finite"));
77        }
78        Ok(self.thrust(elapsed_s))
79    }
80
81    /// Exact integrated impulse through this time, clamped to the curve interval.
82    pub fn impulse_at(&self, elapsed_s: f64) -> Result<f64> {
83        if !elapsed_s.is_finite() {
84            return Err(Error::invalid("elapsed_s", "must be finite"));
85        }
86        Ok(self.impulse(elapsed_s))
87    }
88
89    fn segment(&self, elapsed_s: f64) -> usize {
90        self.points
91            .partition_point(|p| p.time_s <= elapsed_s)
92            .saturating_sub(1)
93            .min(self.points.len() - 2)
94    }
95
96    pub(crate) fn thrust(&self, elapsed_s: f64) -> f64 {
97        if elapsed_s < 0.0 || elapsed_s > self.duration_s() {
98            return 0.0;
99        }
100        let i = self.segment(elapsed_s);
101        let a = self.points[i];
102        let b = self.points[i + 1];
103        let fraction = (elapsed_s - a.time_s) / (b.time_s - a.time_s);
104        a.thrust_n * (1.0 - fraction) + b.thrust_n * fraction
105    }
106
107    fn impulse(&self, elapsed_s: f64) -> f64 {
108        if elapsed_s <= 0.0 {
109            return 0.0;
110        }
111        if elapsed_s >= self.duration_s() {
112            return self.total_impulse_ns();
113        }
114        let i = self.segment(elapsed_s);
115        let area = (self.points[i].thrust_n * 0.5 + self.thrust(elapsed_s) * 0.5)
116            * (elapsed_s - self.points[i].time_s);
117        (self.cumulative_impulse_ns[i] + area).min(self.total_impulse_ns())
118    }
119}
120
121/// Motor lifecycle. A spent motor stays attached and contributes to dry mass.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
124pub enum MotorPhase {
125    /// The vehicle has no motor.
126    Unpowered,
127    /// Waiting for the scheduled ignition time.
128    AwaitingIgnition,
129    /// Inside the burn schedule, including any zero-thrust sections of the curve.
130    Burning,
131    /// Burn schedule complete; no thrust or propellant remains.
132    BurnedOut,
133}
134
135/// Instantaneous motor output, derived entirely from configuration and simulation time.
136#[derive(Debug, Clone, Copy, PartialEq)]
137pub struct MotorSample {
138    /// Current lifecycle phase.
139    pub phase: MotorPhase,
140    /// Thrust in newtons, before applying its commanded direction.
141    pub thrust_n: f64,
142    /// Remaining consumable propellant in kilograms.
143    pub propellant_mass_kg: f64,
144}
145
146/// A single, prescribed motor burn with constant effective exhaust velocity.
147///
148/// Mass flow is proportional to thrust: consumed mass = initial propellant mass ×
149/// delivered impulse / total impulse. There is no throttle or restart; multiple
150/// burns are modeled as multiple [`Stage`]s on [`Rocket::staged`].
151#[derive(Debug, Clone)]
152pub struct Motor {
153    curve: ThrustCurve,
154    propellant_mass_kg: f64,
155    ignition_time_s: f64,
156    event_times_s: Vec<f64>,
157}
158
159impl Motor {
160    /// Schedule a motor relative to the same simulation epoch as [`crate::FlightState`].
161    pub fn new(curve: ThrustCurve, propellant_mass_kg: f64, ignition_time_s: f64) -> Result<Self> {
162        positive(propellant_mass_kg, "propellant_mass_kg")?;
163        nonnegative(ignition_time_s, "ignition_time_s")?;
164        positive(
165            curve.total_impulse_ns() / propellant_mass_kg,
166            "effective exhaust velocity",
167        )?;
168        let event_times_s: Vec<_> = curve
169            .points
170            .iter()
171            .map(|p| ignition_time_s + p.time_s)
172            .collect();
173        if event_times_s.iter().any(|t| !t.is_finite())
174            || event_times_s.windows(2).any(|p| p[1] <= p[0])
175        {
176            return Err(Error::invalid(
177                "motor schedule",
178                "event times must be finite and distinguishable",
179            ));
180        }
181        Ok(Self {
182            curve,
183            propellant_mass_kg,
184            ignition_time_s,
185            event_times_s,
186        })
187    }
188
189    /// The immutable thrust curve.
190    pub fn curve(&self) -> &ThrustCurve {
191        &self.curve
192    }
193    /// Initial propellant mass in kilograms.
194    pub fn propellant_mass_kg(&self) -> f64 {
195        self.propellant_mass_kg
196    }
197    /// Ignition time relative to the flight epoch.
198    pub fn ignition_time_s(&self) -> f64 {
199        self.ignition_time_s
200    }
201    /// Exact scheduled burnout time relative to the flight epoch.
202    pub fn burnout_time_s(&self) -> f64 {
203        *self.event_times_s.last().unwrap()
204    }
205
206    /// Get phase, force and fuel at a finite nonnegative simulation time.
207    pub fn sample(&self, time_s: f64) -> Result<MotorSample> {
208        nonnegative(time_s, "time_s")?;
209        Ok(self.sample_unchecked(time_s))
210    }
211
212    pub(crate) fn sample_unchecked(&self, time_s: f64) -> MotorSample {
213        if time_s < self.ignition_time_s {
214            return MotorSample {
215                phase: MotorPhase::AwaitingIgnition,
216                thrust_n: 0.0,
217                propellant_mass_kg: self.propellant_mass_kg,
218            };
219        }
220        if time_s >= self.burnout_time_s() {
221            return MotorSample {
222                phase: MotorPhase::BurnedOut,
223                thrust_n: 0.0,
224                propellant_mass_kg: 0.0,
225            };
226        }
227        let elapsed = time_s - self.ignition_time_s;
228        let fraction =
229            (self.curve.impulse(elapsed) / self.curve.total_impulse_ns()).clamp(0.0, 1.0);
230        MotorSample {
231            phase: MotorPhase::Burning,
232            thrust_n: self.curve.thrust(elapsed),
233            propellant_mass_kg: self.propellant_mass_kg * (1.0 - fraction),
234        }
235    }
236
237    // Called only within an interval split at every motor event. Keeping the same
238    // phase through all four RK stages gives the correct one-sided endpoint force.
239    pub(crate) fn interval_thrust(&self, time_s: f64, burning: bool) -> f64 {
240        if !burning {
241            return 0.0;
242        }
243        self.curve
244            .thrust((time_s - self.ignition_time_s).clamp(0.0, self.curve.duration_s()))
245    }
246}
247
248/// One separable stage: structural mass carried until a scheduled separation,
249/// plus an optional motor with its own burn schedule.
250///
251/// Separation is right-continuous: at exactly `separation_time_s` the stage's
252/// dry mass, motor and unspent propellant leave the vehicle and its motor stops
253/// contributing thrust. A `None` separation keeps the stage attached for the
254/// whole flight, so its empty casing remains part of the vehicle mass.
255#[derive(Debug, Clone)]
256pub struct Stage {
257    dry_mass_kg: f64,
258    motor: Option<Motor>,
259    separation_time_s: Option<f64>,
260}
261
262impl Stage {
263    /// A stage needs nonnegative dry mass and at least one of mass or a motor.
264    /// Separation may fall before, during or after the motor's burn: dropping a
265    /// burning stage (hot staging) ends its thrust, and dropping an unlit stage
266    /// removes its unspent propellant along with the casing.
267    pub fn new(
268        dry_mass_kg: f64,
269        motor: Option<Motor>,
270        separation_time_s: Option<f64>,
271    ) -> Result<Self> {
272        nonnegative(dry_mass_kg, "stage dry_mass_kg")?;
273        if dry_mass_kg == 0.0 && motor.is_none() {
274            return Err(Error::invalid(
275                "stage",
276                "needs a positive dry mass or a motor",
277            ));
278        }
279        if let Some(t) = separation_time_s {
280            nonnegative(t, "stage separation_time_s")?;
281        }
282        Ok(Self {
283            dry_mass_kg,
284            motor,
285            separation_time_s,
286        })
287    }
288
289    /// Structural mass dropped at separation, in kilograms.
290    pub fn dry_mass_kg(&self) -> f64 {
291        self.dry_mass_kg
292    }
293    /// The stage's motor, if it has one.
294    pub fn motor(&self) -> Option<&Motor> {
295        self.motor.as_ref()
296    }
297    /// Scheduled separation time relative to the flight epoch; `None` never separates.
298    pub fn separation_time_s(&self) -> Option<f64> {
299        self.separation_time_s
300    }
301
302    fn attached(&self, time_s: f64) -> bool {
303        self.separation_time_s.is_none_or(|s| time_s < s)
304    }
305}
306
307/// Base structural mass plus an ordered set of stages.
308///
309/// Total mass is analytic in simulation time: the base mass, the dry mass of
310/// every attached stage and the remaining propellant of every attached motor.
311/// Thrust is the sum over attached, currently burning motors, so serial and
312/// parallel (strap-on) staging share one model. Motor knots, ignitions,
313/// burnouts and separations all split the integration grid.
314#[derive(Debug, Clone)]
315pub struct Rocket {
316    base_mass_kg: f64,
317    stages: Vec<Stage>,
318    event_times_s: Vec<f64>,
319}
320
321/// Motor output and total current vehicle mass.
322///
323/// For a staged vehicle the motor fields are aggregates over every attached
324/// motor: summed thrust, summed remaining propellant and a combined phase
325/// (`Burning` if any attached motor burns, else `AwaitingIgnition` if any is
326/// still scheduled, else `BurnedOut` if any motor existed, else `Unpowered`).
327#[derive(Debug, Clone, Copy, PartialEq)]
328pub struct RocketSample {
329    /// Base mass plus attached stage dry mass and remaining propellant, in kilograms.
330    pub mass_kg: f64,
331    /// Aggregated motor output across attached stages.
332    pub motor: MotorSample,
333}
334
335impl Rocket {
336    /// A single-stage vehicle whose optional motor burns once and stays attached.
337    /// Dry mass includes the empty motor casing. `None` creates a ballistic body.
338    pub fn new(dry_mass_kg: f64, motor: Option<Motor>) -> Result<Self> {
339        let stages = motor
340            .map(|m| Stage {
341                dry_mass_kg: 0.0,
342                motor: Some(m),
343                separation_time_s: None,
344            })
345            .into_iter()
346            .collect();
347        Self::assemble(dry_mass_kg, stages)
348    }
349
350    /// A multi-stage vehicle. `base_mass_kg` is the structure that never
351    /// separates (payload, bus, final casing) and must be positive.
352    pub fn staged(base_mass_kg: f64, stages: Vec<Stage>) -> Result<Self> {
353        Self::assemble(base_mass_kg, stages)
354    }
355
356    fn assemble(base_mass_kg: f64, stages: Vec<Stage>) -> Result<Self> {
357        positive(base_mass_kg, "base_mass_kg")?;
358        let mut event_times_s = Vec::new();
359        for stage in &stages {
360            if let Some(motor) = &stage.motor {
361                event_times_s.extend_from_slice(&motor.event_times_s);
362            }
363            if let Some(t) = stage.separation_time_s {
364                event_times_s.push(t);
365            }
366        }
367        event_times_s.sort_by(f64::total_cmp);
368        Ok(Self {
369            base_mass_kg,
370            stages,
371            event_times_s,
372        })
373    }
374
375    /// Mass that never separates: for [`Rocket::new`] the full dry mass including
376    /// the motor casing; for [`Rocket::staged`] the final mass after every stage
377    /// has separated and all propellant is spent.
378    pub fn dry_mass_kg(&self) -> f64 {
379        self.base_mass_kg
380    }
381    /// The single permanent motor of a [`Rocket::new`] vehicle. Staged vehicles
382    /// use [`Rocket::stages`]; this returns `Some` only for the single-motor shape.
383    pub fn motor(&self) -> Option<&Motor> {
384        match self.stages.as_slice() {
385            [Stage {
386                motor: Some(m),
387                separation_time_s: None,
388                ..
389            }] => Some(m),
390            _ => None,
391        }
392    }
393    /// All stages in the order supplied at construction.
394    pub fn stages(&self) -> &[Stage] {
395        &self.stages
396    }
397    /// Evaluate mass and aggregated motor output at a finite nonnegative time.
398    pub fn sample(&self, time_s: f64) -> Result<RocketSample> {
399        nonnegative(time_s, "time_s")?;
400        Ok(self.sample_unchecked(time_s))
401    }
402    pub(crate) fn sample_unchecked(&self, time_s: f64) -> RocketSample {
403        let mut mass_kg = self.base_mass_kg;
404        let mut thrust_n = 0.0;
405        let mut propellant_mass_kg = 0.0;
406        let mut any_motor = false;
407        let mut any_burning = false;
408        let mut any_awaiting = false;
409        for stage in &self.stages {
410            if !stage.attached(time_s) {
411                continue;
412            }
413            mass_kg += stage.dry_mass_kg;
414            if let Some(motor) = &stage.motor {
415                any_motor = true;
416                let sample = motor.sample_unchecked(time_s);
417                thrust_n += sample.thrust_n;
418                propellant_mass_kg += sample.propellant_mass_kg;
419                match sample.phase {
420                    MotorPhase::Burning => any_burning = true,
421                    MotorPhase::AwaitingIgnition => any_awaiting = true,
422                    _ => {}
423                }
424            }
425        }
426        let phase = if !any_motor {
427            MotorPhase::Unpowered
428        } else if any_burning {
429            MotorPhase::Burning
430        } else if any_awaiting {
431            MotorPhase::AwaitingIgnition
432        } else {
433            MotorPhase::BurnedOut
434        };
435        RocketSample {
436            mass_kg: mass_kg + propellant_mass_kg,
437            motor: MotorSample {
438                phase,
439                thrust_n,
440                propellant_mass_kg,
441            },
442        }
443    }
444
445    pub(crate) fn next_event_after(&self, time_s: f64) -> Option<f64> {
446        self.event_times_s
447            .get(self.event_times_s.partition_point(|t| *t <= time_s))
448            .copied()
449    }
450
451    /// Per-stage state frozen at an integration interval's start. Intervals
452    /// never straddle a motor event or a separation, so these flags hold
453    /// through all four RK stages and yield the correct one-sided mass and
454    /// force at discontinuities: at a separation the closing RK stage still
455    /// sees the departing stage's mass and thrust (the left limit), and the
456    /// next interval opens without them (the right limit).
457    pub(crate) fn interval_stages(&self, time_s: f64) -> Vec<IntervalStage> {
458        self.stages
459            .iter()
460            .map(|stage| {
461                let attached = stage.attached(time_s);
462                let burning = attached
463                    && stage
464                        .motor
465                        .as_ref()
466                        .is_some_and(|m| m.sample_unchecked(time_s).phase == MotorPhase::Burning);
467                IntervalStage { attached, burning }
468            })
469            .collect()
470    }
471
472    /// Vehicle mass at `time_s` under interval-frozen attachment: base mass,
473    /// attached stages' dry mass and their motors' remaining propellant.
474    /// Propellant varies within the interval; attachment does not.
475    pub(crate) fn interval_mass(&self, time_s: f64, stages: &[IntervalStage]) -> f64 {
476        self.base_mass_kg
477            + self
478                .stages
479                .iter()
480                .zip(stages)
481                .map(|(stage, &flags)| {
482                    if !flags.attached {
483                        0.0
484                    } else {
485                        stage.dry_mass_kg
486                            + stage
487                                .motor
488                                .as_ref()
489                                .map_or(0.0, |m| m.sample_unchecked(time_s).propellant_mass_kg)
490                    }
491                })
492                .sum::<f64>()
493    }
494
495    /// Total thrust at `time_s` under the interval-start burning flags.
496    pub(crate) fn interval_thrust(&self, time_s: f64, stages: &[IntervalStage]) -> f64 {
497        self.stages
498            .iter()
499            .zip(stages)
500            .map(|(stage, &flags)| {
501                if flags.burning {
502                    stage
503                        .motor
504                        .as_ref()
505                        .map_or(0.0, |m| m.interval_thrust(time_s, true))
506                } else {
507                    0.0
508                }
509            })
510            .sum()
511    }
512}
513
514/// Interval-frozen attachment and burning state for one stage; see
515/// [`Rocket::interval_stages`].
516#[derive(Debug, Clone, Copy)]
517pub(crate) struct IntervalStage {
518    pub(crate) attached: bool,
519    pub(crate) burning: bool,
520}