Skip to main content

rockets_engine/
flight.rs

1use crate::{
2    finite_vec, nonnegative, positive, ControlInput, Error, Guidance, GuidanceContext, Result,
3    Rocket, RocketSample, Vec3,
4};
5
6/// Complete translational state. Motor phase and fuel are derived from time and configuration.
7#[derive(Debug, Clone, Copy, PartialEq)]
8#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
9pub struct FlightState {
10    /// Seconds since the simulation epoch used by the motor schedule.
11    pub time_s: f64,
12    /// World position in metres; Y is up.
13    pub position_m: Vec3,
14    /// World velocity in metres per second.
15    pub velocity_mps: Vec3,
16}
17
18impl FlightState {
19    /// Check a state created directly or restored from a snapshot.
20    pub fn validate(&self) -> Result<()> {
21        nonnegative(self.time_s, "state.time_s")?;
22        if !finite_vec(&self.position_m) || !finite_vec(&self.velocity_mps) {
23            return Err(Error::invalid("state vectors", "must be finite"));
24        }
25        Ok(())
26    }
27}
28
29/// Environmental acceleration hook (gravity, drag, wind, etc.), without motor or controls.
30///
31/// Called at every RK4 stage with the stage's position, velocity, time and current mass.
32/// Implementations must be deterministic and side-effect free for reproducible integration.
33pub trait ForceModel {
34    /// Acceleration in world-frame metres per second squared.
35    fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3>;
36
37    /// Acceleration when the commanded body axis is known, for angle-of-attack-aware
38    /// models (body lift, induced drag). `body_axis` is the solver-resolved unit
39    /// thrust axis at this stage. The default ignores the axis, which keeps every
40    /// orientation-blind model (gravity, plain drag) bit-identical.
41    fn acceleration_with_axis(
42        &self,
43        state: &FlightState,
44        mass_kg: f64,
45        _body_axis: Vec3,
46    ) -> Result<Vec3> {
47        self.acceleration(state, mass_kg)
48    }
49}
50
51impl<F> ForceModel for F
52where
53    F: Fn(&FlightState, f64) -> Result<Vec3>,
54{
55    fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3> {
56        self(state, mass_kg)
57    }
58}
59
60/// Constant gravity with no atmosphere. Use a zero vector for an inertial vacuum.
61#[derive(Debug, Clone, Copy)]
62pub struct UniformGravity(pub Vec3);
63
64impl Default for UniformGravity {
65    fn default() -> Self {
66        Self(Vec3::new(0.0, -9.80665, 0.0))
67    }
68}
69
70impl ForceModel for UniformGravity {
71    fn acceleration(&self, _state: &FlightState, _mass_kg: f64) -> Result<Vec3> {
72        Ok(self.0)
73    }
74}
75
76/// Standard gravitational parameter of Earth, in cubic metres per second squared.
77pub const EARTH_MU_M3_S2: f64 = 3.986_004_418e14;
78
79/// Inverse-square gravity toward a fixed center, for planet-scale flight.
80///
81/// Unlike [`UniformGravity`], both magnitude and direction vary with position,
82/// which matters once trajectories span hundreds of kilometres or climb above
83/// the atmosphere. Positions are read in the caller's frame; place `center_m`
84/// at the attracting body's center (for an Earth-centered inertial coast, the
85/// origin). There is no atmosphere here: pair with an [`AltitudeSwitch`] or a
86/// custom model when drag still applies.
87#[derive(Debug, Clone, Copy)]
88pub struct CentralGravity {
89    mu_m3_s2: f64,
90    center_m: Vec3,
91}
92
93impl CentralGravity {
94    /// A central field with gravitational parameter `mu_m3_s2` about `center_m`.
95    pub fn new(mu_m3_s2: f64, center_m: Vec3) -> Result<Self> {
96        positive(mu_m3_s2, "mu_m3_s2")?;
97        if !finite_vec(&center_m) {
98            return Err(Error::invalid("center_m", "must be finite"));
99        }
100        Ok(Self { mu_m3_s2, center_m })
101    }
102
103    /// Earth's field ([`EARTH_MU_M3_S2`]) about `center_m`.
104    pub fn earth(center_m: Vec3) -> Result<Self> {
105        Self::new(EARTH_MU_M3_S2, center_m)
106    }
107
108    /// Gravitational parameter in m³/s².
109    pub fn mu_m3_s2(&self) -> f64 {
110        self.mu_m3_s2
111    }
112    /// Attracting center in the caller's frame.
113    pub fn center_m(&self) -> Vec3 {
114        self.center_m
115    }
116}
117
118impl ForceModel for CentralGravity {
119    fn acceleration(&self, state: &FlightState, _mass_kg: f64) -> Result<Vec3> {
120        let offset = state.position_m - self.center_m;
121        let distance_squared = offset.norm_squared();
122        if !distance_squared.is_finite() || distance_squared == 0.0 {
123            return Err(Error::NumericalFailure(
124                "central gravity is singular at its center",
125            ));
126        }
127        let acceleration = offset * (-self.mu_m3_s2 / (distance_squared * distance_squared.sqrt()));
128        if !finite_vec(&acceleration) {
129            return Err(Error::NumericalFailure("non-finite central gravity"));
130        }
131        Ok(acceleration)
132    }
133}
134
135/// Routes to one of two force models depending on altitude (position Y).
136///
137/// Use it to bound a dense-atmosphere model: at and below the cutoff, forces
138/// come from e.g. `BallisticForces`; above it, from a vacuum model such as
139/// [`UniformGravity`] or [`CentralGravity`]. The Y coordinate follows the
140/// crate's world-frame convention; with the ballistics adapter it is absolute
141/// altitude above sea level.
142#[derive(Debug, Clone)]
143pub struct AltitudeSwitch<Below, Above> {
144    below: Below,
145    above: Above,
146    cutoff_altitude_m: f64,
147}
148
149impl<Below, Above> AltitudeSwitch<Below, Above> {
150    /// Switch from `below` to `above` at `cutoff_altitude_m` (inclusive below).
151    pub fn new(below: Below, above: Above, cutoff_altitude_m: f64) -> Result<Self> {
152        if !cutoff_altitude_m.is_finite() {
153            return Err(Error::invalid("cutoff_altitude_m", "must be finite"));
154        }
155        Ok(Self {
156            below,
157            above,
158            cutoff_altitude_m,
159        })
160    }
161    /// Model used at and below the cutoff.
162    pub fn below(&self) -> &Below {
163        &self.below
164    }
165    /// Model used above the cutoff.
166    pub fn above(&self) -> &Above {
167        &self.above
168    }
169    /// Handoff altitude in metres.
170    pub fn cutoff_altitude_m(&self) -> f64 {
171        self.cutoff_altitude_m
172    }
173}
174
175impl<Below: ForceModel, Above: ForceModel> ForceModel for AltitudeSwitch<Below, Above> {
176    fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3> {
177        if state.position_m.y <= self.cutoff_altitude_m {
178            self.below.acceleration(state, mass_kg)
179        } else {
180            self.above.acceleration(state, mass_kg)
181        }
182    }
183}
184
185/// Integration accuracy, work budget and external control authority.
186#[derive(Debug, Clone, Copy)]
187pub struct IntegratorSettings {
188    /// Maximum internal RK4 timestep in simulation seconds. Default: 0.01 s.
189    pub max_step_s: f64,
190    /// Maximum internal substeps in a single public call. Default: 100,000.
191    pub max_substeps: usize,
192    /// Magnitude cap for additional steering acceleration in m/s². Default: zero.
193    /// This does not limit changes in the externally supplied thrust-axis orientation.
194    pub max_steering_accel_mps2: f64,
195}
196
197impl Default for IntegratorSettings {
198    fn default() -> Self {
199        Self {
200            max_step_s: 0.01,
201            max_substeps: 100_000,
202            max_steering_accel_mps2: 0.0,
203        }
204    }
205}
206
207impl IntegratorSettings {
208    fn validate(&self) -> Result<()> {
209        positive(self.max_step_s, "max_step_s")?;
210        nonnegative(self.max_steering_accel_mps2, "max_steering_accel_mps2")?;
211        if self.max_substeps == 0 {
212            return Err(Error::invalid("max_substeps", "must be nonzero"));
213        }
214        Ok(())
215    }
216}
217
218/// Result of a successful advancement. An error never overwrites the caller's input state.
219#[derive(Debug, Clone, Copy, PartialEq)]
220pub struct StepResult {
221    /// State at exactly the requested end time.
222    pub state: FlightState,
223    /// Mass and motor output at the end time (burnout has zero thrust).
224    pub rocket: RocketSample,
225    /// Number of internal RK4 steps performed.
226    pub substeps: usize,
227}
228
229/// Stateless RK4 stepper over an immutable vehicle, force model and settings.
230///
231/// Motor ignition, each thrust knot and burnout split integration intervals so no step
232/// straddles a motor discontinuity. Mass is evaluated analytically at every RK stage.
233/// Collision detection and terrain termination belong to the host application.
234#[derive(Debug, Clone)]
235pub struct Simulator<E> {
236    rocket: Rocket,
237    forces: E,
238    settings: IntegratorSettings,
239}
240
241impl<E: ForceModel> Simulator<E> {
242    /// Create a reusable simulator, validating settings before any integration.
243    pub fn new(rocket: Rocket, forces: E, settings: IntegratorSettings) -> Result<Self> {
244        settings.validate()?;
245        Ok(Self {
246            rocket,
247            forces,
248            settings,
249        })
250    }
251
252    /// The vehicle configuration needed alongside a snapshot to resume a flight.
253    pub fn rocket(&self) -> &Rocket {
254        &self.rocket
255    }
256    /// Current environmental force model.
257    pub fn forces(&self) -> &E {
258        &self.forces
259    }
260    /// Access the environment to update externally sampled weather between steps.
261    pub fn forces_mut(&mut self) -> &mut E {
262        &mut self.forces
263    }
264    /// Validated integrator settings.
265    pub fn settings(&self) -> IntegratorSettings {
266        self.settings
267    }
268
269    fn end_time(&self, state: &FlightState, duration_s: f64) -> Result<f64> {
270        state.validate()?;
271        positive(duration_s, "duration_s")?;
272        let end = state.time_s + duration_s;
273        if !end.is_finite() || end <= state.time_s {
274            return Err(Error::invalid(
275                "duration_s",
276                "must advance time to a finite, distinguishable value",
277            ));
278        }
279        if duration_s > self.settings.max_step_s * self.settings.max_substeps as f64 {
280            return Err(Error::StepLimitExceeded {
281                limit: self.settings.max_substeps,
282            });
283        }
284        Ok(end)
285    }
286
287    /// Advance using an externally supplied command, held fixed for the requested duration.
288    /// Call at a fixed cadence to obtain repeatable controller and integration behavior.
289    pub fn step(
290        &self,
291        state: &FlightState,
292        duration_s: f64,
293        control: ControlInput,
294    ) -> Result<StepResult> {
295        let end = self.end_time(state, duration_s)?;
296        let (axis, steering) = self.resolve_control(control)?;
297        let mut current = *state;
298        let mut substeps = 0;
299        let mut grid_index = 1usize;
300        while current.time_s < end {
301            if substeps >= self.settings.max_substeps {
302                return Err(Error::StepLimitExceeded {
303                    limit: self.settings.max_substeps,
304                });
305            }
306            // Anchor to the public start time instead of repeatedly adding dt. This
307            // avoids an extra near-zero step from accumulated floating-point drift.
308            // Motor events split a grid interval without resetting that grid.
309            let grid_boundary =
310                (state.time_s + grid_index as f64 * self.settings.max_step_s).min(end);
311            let mut boundary = grid_boundary;
312            if let Some(event) = self.rocket.next_event_after(current.time_s) {
313                boundary = boundary.min(event);
314            }
315            if boundary <= current.time_s {
316                return Err(Error::NumericalFailure(
317                    "internal timestep cannot advance time",
318                ));
319            }
320            current = self.rk4(current, boundary, axis, steering)?;
321            substeps += 1;
322            if boundary == grid_boundary {
323                grid_index += 1;
324            }
325        }
326        Ok(StepResult {
327            state: current,
328            rocket: self.rocket.sample_unchecked(current.time_s),
329            substeps,
330        })
331    }
332
333    /// Evaluate guidance once, then advance under its held command.
334    /// Invalid state/duration is rejected before calling guidance. A later model error
335    /// leaves the input flight state unchanged but does not roll back the controller.
336    pub fn step_guided<G: Guidance + ?Sized>(
337        &self,
338        state: &FlightState,
339        duration_s: f64,
340        guidance: &mut G,
341    ) -> Result<StepResult> {
342        self.end_time(state, duration_s)?;
343        let command = guidance.command(GuidanceContext {
344            state,
345            rocket: self.rocket.sample_unchecked(state.time_s),
346            step_duration_s: duration_s,
347            max_steering_accel_mps2: self.settings.max_steering_accel_mps2,
348        })?;
349        self.step(state, duration_s, command)
350    }
351
352    fn resolve_control(&self, control: ControlInput) -> Result<(Vec3, Vec3)> {
353        if !finite_vec(&control.thrust_direction)
354            || !finite_vec(&control.steering_acceleration_mps2)
355        {
356            return Err(Error::invalid("control", "all components must be finite"));
357        }
358        // Scale first: ordinary norm() can overflow or underflow for finite vectors.
359        let scale = control.thrust_direction.amax();
360        if scale == 0.0 {
361            return Err(Error::invalid("thrust_direction", "must be nonzero"));
362        }
363        let scaled = control.thrust_direction / scale;
364        let axis = scaled / scaled.norm();
365        let scale = control.steering_acceleration_mps2.amax();
366        let steering = if scale == 0.0 || self.settings.max_steering_accel_mps2 == 0.0 {
367            Vec3::zeros()
368        } else {
369            let scaled = control.steering_acceleration_mps2 / scale;
370            let norm = scaled.norm();
371            if scale > self.settings.max_steering_accel_mps2 / norm {
372                scaled * (self.settings.max_steering_accel_mps2 / norm)
373            } else {
374                control.steering_acceleration_mps2
375            }
376        };
377        Ok((axis, steering))
378    }
379
380    fn derivative(
381        &self,
382        state: FlightState,
383        axis: Vec3,
384        steering: Vec3,
385        stages: &[crate::motor::IntervalStage],
386    ) -> Result<(Vec3, Vec3)> {
387        state
388            .validate()
389            .map_err(|_| Error::NumericalFailure("non-finite intermediate state"))?;
390        let mass = self.rocket.interval_mass(state.time_s, stages);
391        let thrust = self.rocket.interval_thrust(state.time_s, stages);
392        let acceleration = self.forces.acceleration_with_axis(&state, mass, axis)?
393            + axis * (thrust / mass)
394            + steering;
395        if !finite_vec(&acceleration) {
396            return Err(Error::NumericalFailure("non-finite acceleration"));
397        }
398        Ok((state.velocity_mps, acceleration))
399    }
400
401    fn rk4(&self, start: FlightState, end: f64, axis: Vec3, steering: Vec3) -> Result<FlightState> {
402        let dt = end - start.time_s;
403        let stages = self.rocket.interval_stages(start.time_s);
404        let stage = |derivative: (Vec3, Vec3), delta: f64, time_s: f64| FlightState {
405            time_s,
406            position_m: start.position_m + derivative.0 * delta,
407            velocity_mps: start.velocity_mps + derivative.1 * delta,
408        };
409        let midpoint = start.time_s + dt * 0.5;
410        let k1 = self.derivative(start, axis, steering, &stages)?;
411        let k2 = self.derivative(stage(k1, dt * 0.5, midpoint), axis, steering, &stages)?;
412        let k3 = self.derivative(stage(k2, dt * 0.5, midpoint), axis, steering, &stages)?;
413        let k4 = self.derivative(stage(k3, dt, end), axis, steering, &stages)?;
414        let result = FlightState {
415            time_s: end,
416            position_m: start.position_m + (k1.0 + k2.0 * 2.0 + k3.0 * 2.0 + k4.0) * (dt / 6.0),
417            velocity_mps: start.velocity_mps + (k1.1 + k2.1 * 2.0 + k3.1 * 2.0 + k4.1) * (dt / 6.0),
418        };
419        result
420            .validate()
421            .map_err(|_| Error::NumericalFailure("non-finite output state"))?;
422        Ok(result)
423    }
424}