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, restart or staging in v0.1.
150#[derive(Debug, Clone)]
151pub struct Motor {
152    curve: ThrustCurve,
153    propellant_mass_kg: f64,
154    ignition_time_s: f64,
155    event_times_s: Vec<f64>,
156}
157
158impl Motor {
159    /// Schedule a motor relative to the same simulation epoch as [`crate::FlightState`].
160    pub fn new(curve: ThrustCurve, propellant_mass_kg: f64, ignition_time_s: f64) -> Result<Self> {
161        positive(propellant_mass_kg, "propellant_mass_kg")?;
162        nonnegative(ignition_time_s, "ignition_time_s")?;
163        positive(
164            curve.total_impulse_ns() / propellant_mass_kg,
165            "effective exhaust velocity",
166        )?;
167        let event_times_s: Vec<_> = curve
168            .points
169            .iter()
170            .map(|p| ignition_time_s + p.time_s)
171            .collect();
172        if event_times_s.iter().any(|t| !t.is_finite())
173            || event_times_s.windows(2).any(|p| p[1] <= p[0])
174        {
175            return Err(Error::invalid(
176                "motor schedule",
177                "event times must be finite and distinguishable",
178            ));
179        }
180        Ok(Self {
181            curve,
182            propellant_mass_kg,
183            ignition_time_s,
184            event_times_s,
185        })
186    }
187
188    /// The immutable thrust curve.
189    pub fn curve(&self) -> &ThrustCurve {
190        &self.curve
191    }
192    /// Initial propellant mass in kilograms.
193    pub fn propellant_mass_kg(&self) -> f64 {
194        self.propellant_mass_kg
195    }
196    /// Ignition time relative to the flight epoch.
197    pub fn ignition_time_s(&self) -> f64 {
198        self.ignition_time_s
199    }
200    /// Exact scheduled burnout time relative to the flight epoch.
201    pub fn burnout_time_s(&self) -> f64 {
202        *self.event_times_s.last().unwrap()
203    }
204
205    /// Get phase, force and fuel at a finite nonnegative simulation time.
206    pub fn sample(&self, time_s: f64) -> Result<MotorSample> {
207        nonnegative(time_s, "time_s")?;
208        Ok(self.sample_unchecked(time_s))
209    }
210
211    pub(crate) fn sample_unchecked(&self, time_s: f64) -> MotorSample {
212        if time_s < self.ignition_time_s {
213            return MotorSample {
214                phase: MotorPhase::AwaitingIgnition,
215                thrust_n: 0.0,
216                propellant_mass_kg: self.propellant_mass_kg,
217            };
218        }
219        if time_s >= self.burnout_time_s() {
220            return MotorSample {
221                phase: MotorPhase::BurnedOut,
222                thrust_n: 0.0,
223                propellant_mass_kg: 0.0,
224            };
225        }
226        let elapsed = time_s - self.ignition_time_s;
227        let fraction =
228            (self.curve.impulse(elapsed) / self.curve.total_impulse_ns()).clamp(0.0, 1.0);
229        MotorSample {
230            phase: MotorPhase::Burning,
231            thrust_n: self.curve.thrust(elapsed),
232            propellant_mass_kg: self.propellant_mass_kg * (1.0 - fraction),
233        }
234    }
235
236    pub(crate) fn next_event_after(&self, time_s: f64) -> Option<f64> {
237        self.event_times_s
238            .get(self.event_times_s.partition_point(|t| *t <= time_s))
239            .copied()
240    }
241
242    // Called only within an interval split at every motor event. Keeping the same
243    // phase through all four RK stages gives the correct one-sided endpoint force.
244    pub(crate) fn interval_thrust(&self, time_s: f64, burning: bool) -> f64 {
245        if !burning {
246            return 0.0;
247        }
248        self.curve
249            .thrust((time_s - self.ignition_time_s).clamp(0.0, self.curve.duration_s()))
250    }
251}
252
253/// Vehicle dry mass and its optional attached motor.
254#[derive(Debug, Clone)]
255pub struct Rocket {
256    dry_mass_kg: f64,
257    motor: Option<Motor>,
258}
259
260/// Motor output and total current vehicle mass.
261#[derive(Debug, Clone, Copy, PartialEq)]
262pub struct RocketSample {
263    /// Dry mass plus remaining propellant, in kilograms.
264    pub mass_kg: f64,
265    /// Current motor output.
266    pub motor: MotorSample,
267}
268
269impl Rocket {
270    /// Dry mass includes the empty motor casing. `None` creates a ballistic body.
271    pub fn new(dry_mass_kg: f64, motor: Option<Motor>) -> Result<Self> {
272        positive(dry_mass_kg, "dry_mass_kg")?;
273        positive(
274            dry_mass_kg + motor.as_ref().map_or(0.0, |m| m.propellant_mass_kg),
275            "wet mass",
276        )?;
277        Ok(Self { dry_mass_kg, motor })
278    }
279    /// Mass after all propellant is spent.
280    pub fn dry_mass_kg(&self) -> f64 {
281        self.dry_mass_kg
282    }
283    /// Read-only access to the motor, if present.
284    pub fn motor(&self) -> Option<&Motor> {
285        self.motor.as_ref()
286    }
287    /// Evaluate mass and motor output at a finite nonnegative simulation time.
288    pub fn sample(&self, time_s: f64) -> Result<RocketSample> {
289        nonnegative(time_s, "time_s")?;
290        Ok(self.sample_unchecked(time_s))
291    }
292    pub(crate) fn sample_unchecked(&self, time_s: f64) -> RocketSample {
293        let motor = self.motor.as_ref().map_or(
294            MotorSample {
295                phase: MotorPhase::Unpowered,
296                thrust_n: 0.0,
297                propellant_mass_kg: 0.0,
298            },
299            |m| m.sample_unchecked(time_s),
300        );
301        RocketSample {
302            mass_kg: self.dry_mass_kg + motor.propellant_mass_kg,
303            motor,
304        }
305    }
306}