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/// Owned, type-erased force-model handle for heterogeneous model composition.
61/// A newtype rather than `Box<dyn ForceModel>` so it cannot collide with the
62/// blanket closure implementations.
63pub struct BoxedForces(pub Box<dyn ForceModel>);
64
65impl std::fmt::Debug for BoxedForces {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.debug_struct("BoxedForces").finish_non_exhaustive()
68    }
69}
70
71impl ForceModel for BoxedForces {
72    fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3> {
73        self.0.acceleration(state, mass_kg)
74    }
75
76    fn acceleration_with_axis(
77        &self,
78        state: &FlightState,
79        mass_kg: f64,
80        body_axis: Vec3,
81    ) -> Result<Vec3> {
82        self.0.acceleration_with_axis(state, mass_kg, body_axis)
83    }
84}
85
86/// Constant gravity with no atmosphere. Use a zero vector for an inertial vacuum.
87#[derive(Debug, Clone, Copy)]
88pub struct UniformGravity(pub Vec3);
89
90impl Default for UniformGravity {
91    fn default() -> Self {
92        Self(Vec3::new(0.0, -9.80665, 0.0))
93    }
94}
95
96impl ForceModel for UniformGravity {
97    fn acceleration(&self, _state: &FlightState, _mass_kg: f64) -> Result<Vec3> {
98        Ok(self.0)
99    }
100}
101
102/// Standard gravitational parameter of Earth, in cubic metres per second squared.
103pub const EARTH_MU_M3_S2: f64 = 3.986_004_418e14;
104
105/// Inverse-square gravity toward a fixed center, for planet-scale flight.
106///
107/// Unlike [`UniformGravity`], both magnitude and direction vary with position,
108/// which matters once trajectories span hundreds of kilometres or climb above
109/// the atmosphere. Positions are read in the caller's frame; place `center_m`
110/// at the attracting body's center (for an Earth-centered inertial coast, the
111/// origin). There is no atmosphere here: pair with an [`AltitudeSwitch`] (in
112/// a frame where Y is altitude; see its docs) or a custom model when drag
113/// still applies.
114#[derive(Debug, Clone, Copy)]
115pub struct CentralGravity {
116    mu_m3_s2: f64,
117    center_m: Vec3,
118}
119
120impl CentralGravity {
121    /// A central field with gravitational parameter `mu_m3_s2` about `center_m`.
122    pub fn new(mu_m3_s2: f64, center_m: Vec3) -> Result<Self> {
123        positive(mu_m3_s2, "mu_m3_s2")?;
124        if !finite_vec(&center_m) {
125            return Err(Error::invalid("center_m", "must be finite"));
126        }
127        Ok(Self { mu_m3_s2, center_m })
128    }
129
130    /// Earth's field ([`EARTH_MU_M3_S2`]) about `center_m`.
131    pub fn earth(center_m: Vec3) -> Result<Self> {
132        Self::new(EARTH_MU_M3_S2, center_m)
133    }
134
135    /// Gravitational parameter in m³/s².
136    pub fn mu_m3_s2(&self) -> f64 {
137        self.mu_m3_s2
138    }
139    /// Attracting center in the caller's frame.
140    pub fn center_m(&self) -> Vec3 {
141        self.center_m
142    }
143}
144
145impl ForceModel for CentralGravity {
146    fn acceleration(&self, state: &FlightState, _mass_kg: f64) -> Result<Vec3> {
147        let offset = state.position_m - self.center_m;
148        let distance_squared = offset.norm_squared();
149        if !distance_squared.is_finite() || distance_squared == 0.0 {
150            return Err(Error::NumericalFailure(
151                "central gravity is singular at its center",
152            ));
153        }
154        let acceleration = offset * (-self.mu_m3_s2 / (distance_squared * distance_squared.sqrt()));
155        if !finite_vec(&acceleration) {
156            return Err(Error::NumericalFailure("non-finite central gravity"));
157        }
158        Ok(acceleration)
159    }
160}
161
162/// Routes to one of two force models by comparing raw position Y with a cutoff.
163///
164/// Use it to bound a dense-atmosphere model: at and below the cutoff, forces
165/// come from e.g. `BallisticForces`; above it, from a vacuum model such as
166/// [`UniformGravity`] or [`CentralGravity`]. The switch reads Y, and
167/// `BallisticForces` reads Y as altitude above sea level, so it switches on
168/// altitude only in a frame where Y is altitude: the flat frame, or a local
169/// frame with a [`CentralGravity`] center at `(0, -R, 0)` (R the planet
170/// radius), where Y stays near altitude close to the launch point. In an
171/// Earth-centered frame (center at the origin; the `icbm_midcourse` example
172/// starts at Y = R + h) Y is measured from Earth's center, so neither the
173/// cutoff nor `BallisticForces` reads altitude; there, compare the radius
174/// yourself, as that example does for its reentry handoff.
175#[derive(Debug, Clone)]
176pub struct AltitudeSwitch<Below, Above> {
177    below: Below,
178    above: Above,
179    cutoff_altitude_m: f64,
180}
181
182impl<Below, Above> AltitudeSwitch<Below, Above> {
183    /// Switch from `below` to `above` at `cutoff_altitude_m` (inclusive below).
184    /// The cutoff is compared with raw position Y, so it is an altitude only
185    /// in a frame where Y is altitude (see the type docs).
186    pub fn new(below: Below, above: Above, cutoff_altitude_m: f64) -> Result<Self> {
187        if !cutoff_altitude_m.is_finite() {
188            return Err(Error::invalid("cutoff_altitude_m", "must be finite"));
189        }
190        Ok(Self {
191            below,
192            above,
193            cutoff_altitude_m,
194        })
195    }
196    /// Model used at and below the cutoff.
197    pub fn below(&self) -> &Below {
198        &self.below
199    }
200    /// Model used above the cutoff.
201    pub fn above(&self) -> &Above {
202        &self.above
203    }
204    /// Handoff value in metres, compared with raw position Y: an altitude
205    /// only in a frame where Y is altitude (see the type docs).
206    pub fn cutoff_altitude_m(&self) -> f64 {
207        self.cutoff_altitude_m
208    }
209}
210
211impl<Below: ForceModel, Above: ForceModel> ForceModel for AltitudeSwitch<Below, Above> {
212    fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3> {
213        if state.position_m.y <= self.cutoff_altitude_m {
214            self.below.acceleration(state, mass_kg)
215        } else {
216            self.above.acceleration(state, mass_kg)
217        }
218    }
219}
220
221/// Launch-rail constraint: a force-model wrapper that projects environmental
222/// acceleration onto the rail direction while the vehicle is on the rail.
223///
224/// The rail is a segment starting at `origin_m` and running `length_m` along
225/// `direction`. If the initial velocity is zero or along the rail, motion stays
226/// exactly on the rail because every acceleration is collinear with it. After
227/// rail exit the inner model passes through unchanged. Command the thrust axis
228/// along the rail and no steering until exit; pair with
229/// [`crate::ballistics::BallisticForces::with_tipoff_yaw`] for the exit disturbance.
230#[derive(Debug, Clone)]
231pub struct RailForces<F> {
232    inner: F,
233    origin_m: Vec3,
234    direction: Vec3,
235    length_m: f64,
236}
237
238impl<F> RailForces<F> {
239    /// Build a rail. `direction` is normalized at construction and must be
240    /// nonzero and finite; `length_m` must be finite and positive.
241    pub fn new(inner: F, origin_m: Vec3, direction: Vec3, length_m: f64) -> Result<Self> {
242        if !finite_vec(&origin_m) || !finite_vec(&direction) || direction.amax() == 0.0 {
243            return Err(Error::invalid(
244                "rail",
245                "origin must be finite, direction nonzero",
246            ));
247        }
248        positive(length_m, "rail length_m")?;
249        Ok(Self {
250            inner,
251            origin_m,
252            direction: direction.normalize(),
253            length_m,
254        })
255    }
256
257    fn constrain(&self, state: &FlightState, acceleration: Vec3) -> Vec3 {
258        let along = (state.position_m - self.origin_m).dot(&self.direction);
259        if along < self.length_m {
260            self.direction * acceleration.dot(&self.direction)
261        } else {
262            acceleration
263        }
264    }
265}
266
267impl<F: ForceModel> ForceModel for RailForces<F> {
268    fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3> {
269        let a = self.inner.acceleration(state, mass_kg)?;
270        Ok(self.constrain(state, a))
271    }
272
273    fn acceleration_with_axis(
274        &self,
275        state: &FlightState,
276        mass_kg: f64,
277        body_axis: Vec3,
278    ) -> Result<Vec3> {
279        let a = self
280            .inner
281            .acceleration_with_axis(state, mass_kg, body_axis)?;
282        Ok(self.constrain(state, a))
283    }
284}
285
286/// Integration accuracy, work budget and external control authority.
287#[derive(Debug, Clone, Copy)]
288pub struct IntegratorSettings {
289    /// Maximum internal RK4 timestep in simulation seconds. Default: 0.01 s.
290    pub max_step_s: f64,
291    /// Maximum internal substeps in a single public call. Default: 100,000.
292    pub max_substeps: usize,
293    /// Magnitude cap for additional steering acceleration in m/s². Default: zero.
294    /// This does not limit changes in the externally supplied thrust-axis orientation.
295    pub max_steering_accel_mps2: f64,
296}
297
298impl Default for IntegratorSettings {
299    fn default() -> Self {
300        Self {
301            max_step_s: 0.01,
302            max_substeps: 100_000,
303            max_steering_accel_mps2: 0.0,
304        }
305    }
306}
307
308impl IntegratorSettings {
309    fn validate(&self) -> Result<()> {
310        positive(self.max_step_s, "max_step_s")?;
311        nonnegative(self.max_steering_accel_mps2, "max_steering_accel_mps2")?;
312        if self.max_substeps == 0 {
313            return Err(Error::invalid("max_substeps", "must be nonzero"));
314        }
315        Ok(())
316    }
317}
318
319/// Result of a successful advancement. An error never overwrites the caller's input state.
320#[derive(Debug, Clone, Copy, PartialEq)]
321pub struct StepResult {
322    /// State at exactly the requested end time.
323    pub state: FlightState,
324    /// Mass and motor output at the end time (burnout has zero thrust).
325    pub rocket: RocketSample,
326    /// Number of internal RK4 steps performed.
327    pub substeps: usize,
328}
329
330/// Stateless RK4 stepper over an immutable vehicle, force model and settings.
331///
332/// Motor ignition, each thrust knot and burnout split integration intervals so no step
333/// straddles a motor discontinuity. Mass is evaluated analytically at every RK stage.
334/// Collision detection and terrain termination belong to the host application.
335#[derive(Debug, Clone)]
336pub struct Simulator<E> {
337    rocket: Rocket,
338    forces: E,
339    settings: IntegratorSettings,
340}
341
342impl<E: ForceModel> Simulator<E> {
343    /// Create a reusable simulator, validating settings before any integration.
344    pub fn new(rocket: Rocket, forces: E, settings: IntegratorSettings) -> Result<Self> {
345        settings.validate()?;
346        Ok(Self {
347            rocket,
348            forces,
349            settings,
350        })
351    }
352
353    /// The vehicle configuration needed alongside a snapshot to resume a flight.
354    pub fn rocket(&self) -> &Rocket {
355        &self.rocket
356    }
357    /// Current environmental force model.
358    pub fn forces(&self) -> &E {
359        &self.forces
360    }
361    /// Access the environment to update externally sampled weather between steps.
362    pub fn forces_mut(&mut self) -> &mut E {
363        &mut self.forces
364    }
365    /// Validated integrator settings.
366    pub fn settings(&self) -> IntegratorSettings {
367        self.settings
368    }
369
370    fn end_time(&self, state: &FlightState, duration_s: f64) -> Result<f64> {
371        state.validate()?;
372        positive(duration_s, "duration_s")?;
373        let end = state.time_s + duration_s;
374        if !end.is_finite() || end <= state.time_s {
375            return Err(Error::invalid(
376                "duration_s",
377                "must advance time to a finite, distinguishable value",
378            ));
379        }
380        if duration_s > self.settings.max_step_s * self.settings.max_substeps as f64 {
381            return Err(Error::StepLimitExceeded {
382                limit: self.settings.max_substeps,
383            });
384        }
385        Ok(end)
386    }
387
388    /// Advance using an externally supplied command, held fixed for the requested duration.
389    /// Call at a fixed cadence to obtain repeatable controller and integration behavior.
390    pub fn step(
391        &self,
392        state: &FlightState,
393        duration_s: f64,
394        control: ControlInput,
395    ) -> Result<StepResult> {
396        let end = self.end_time(state, duration_s)?;
397        let (axis, steering) = self.resolve_control(control)?;
398        let mut current = *state;
399        let mut substeps = 0;
400        let mut grid_index = 1usize;
401        while current.time_s < end {
402            if substeps >= self.settings.max_substeps {
403                return Err(Error::StepLimitExceeded {
404                    limit: self.settings.max_substeps,
405                });
406            }
407            // Anchor to the public start time instead of repeatedly adding dt. This
408            // avoids an extra near-zero step from accumulated floating-point drift.
409            // Motor events split a grid interval without resetting that grid.
410            let grid_boundary =
411                (state.time_s + grid_index as f64 * self.settings.max_step_s).min(end);
412            let mut boundary = grid_boundary;
413            if let Some(event) = self.rocket.next_event_after(current.time_s) {
414                boundary = boundary.min(event);
415            }
416            if boundary <= current.time_s {
417                return Err(Error::NumericalFailure(
418                    "internal timestep cannot advance time",
419                ));
420            }
421            current = self.rk4(current, boundary, axis, steering)?;
422            substeps += 1;
423            if boundary == grid_boundary {
424                grid_index += 1;
425            }
426        }
427        Ok(StepResult {
428            state: current,
429            rocket: self.rocket.sample_unchecked(current.time_s),
430            substeps,
431        })
432    }
433
434    /// Evaluate guidance once, then advance under its held command.
435    /// Invalid state/duration is rejected before calling guidance. A later model error
436    /// leaves the input flight state unchanged but does not roll back the controller.
437    pub fn step_guided<G: Guidance + ?Sized>(
438        &self,
439        state: &FlightState,
440        duration_s: f64,
441        guidance: &mut G,
442    ) -> Result<StepResult> {
443        self.end_time(state, duration_s)?;
444        let command = guidance.command(GuidanceContext {
445            state,
446            rocket: self.rocket.sample_unchecked(state.time_s),
447            step_duration_s: duration_s,
448            max_steering_accel_mps2: self.settings.max_steering_accel_mps2,
449        })?;
450        self.step(state, duration_s, command)
451    }
452
453    fn resolve_control(&self, control: ControlInput) -> Result<(Vec3, Vec3)> {
454        if !finite_vec(&control.thrust_direction)
455            || !finite_vec(&control.steering_acceleration_mps2)
456        {
457            return Err(Error::invalid("control", "all components must be finite"));
458        }
459        // Scale first: ordinary norm() can overflow or underflow for finite vectors.
460        let scale = control.thrust_direction.amax();
461        if scale == 0.0 {
462            return Err(Error::invalid("thrust_direction", "must be nonzero"));
463        }
464        let scaled = control.thrust_direction / scale;
465        let axis = scaled / scaled.norm();
466        let scale = control.steering_acceleration_mps2.amax();
467        let steering = if scale == 0.0 || self.settings.max_steering_accel_mps2 == 0.0 {
468            Vec3::zeros()
469        } else {
470            let scaled = control.steering_acceleration_mps2 / scale;
471            let norm = scaled.norm();
472            if scale > self.settings.max_steering_accel_mps2 / norm {
473                scaled * (self.settings.max_steering_accel_mps2 / norm)
474            } else {
475                control.steering_acceleration_mps2
476            }
477        };
478        Ok((axis, steering))
479    }
480
481    fn derivative(
482        &self,
483        state: FlightState,
484        axis: Vec3,
485        steering: Vec3,
486        stages: &[crate::motor::IntervalStage],
487    ) -> Result<(Vec3, Vec3)> {
488        state
489            .validate()
490            .map_err(|_| Error::NumericalFailure("non-finite intermediate state"))?;
491        let mass = self.rocket.interval_mass(state.time_s, stages);
492        let thrust = self.rocket.interval_thrust(state.time_s, stages);
493        let acceleration = self.forces.acceleration_with_axis(&state, mass, axis)?
494            + axis * (thrust / mass)
495            + steering;
496        if !finite_vec(&acceleration) {
497            return Err(Error::NumericalFailure("non-finite acceleration"));
498        }
499        Ok((state.velocity_mps, acceleration))
500    }
501
502    fn rk4(&self, start: FlightState, end: f64, axis: Vec3, steering: Vec3) -> Result<FlightState> {
503        let dt = end - start.time_s;
504        let stages = self.rocket.interval_stages(start.time_s);
505        let stage = |derivative: (Vec3, Vec3), delta: f64, time_s: f64| FlightState {
506            time_s,
507            position_m: start.position_m + derivative.0 * delta,
508            velocity_mps: start.velocity_mps + derivative.1 * delta,
509        };
510        let midpoint = start.time_s + dt * 0.5;
511        let k1 = self.derivative(start, axis, steering, &stages)?;
512        let k2 = self.derivative(stage(k1, dt * 0.5, midpoint), axis, steering, &stages)?;
513        let k3 = self.derivative(stage(k2, dt * 0.5, midpoint), axis, steering, &stages)?;
514        let k4 = self.derivative(stage(k3, dt, end), axis, steering, &stages)?;
515        let result = FlightState {
516            time_s: end,
517            position_m: start.position_m + (k1.0 + k2.0 * 2.0 + k3.0 * 2.0 + k4.0) * (dt / 6.0),
518            velocity_mps: start.velocity_mps + (k1.1 + k2.1 * 2.0 + k3.1 * 2.0 + k4.1) * (dt / 6.0),
519        };
520        result
521            .validate()
522            .map_err(|_| Error::NumericalFailure("non-finite output state"))?;
523        Ok(result)
524    }
525}