Skip to main content

rockets_engine/
guidance.rs

1use crate::{FlightState, Result, RocketSample, Vec3};
2
3/// World-frame controls held constant for one caller-requested simulation step.
4#[derive(Debug, Clone, Copy, PartialEq)]
5pub struct ControlInput {
6    /// Nonzero thrust-axis vector. The solver normalizes it before applying thrust.
7    /// This is an externally supplied orientation, not an instantaneous attitude solver.
8    pub thrust_direction: Vec3,
9    /// Additional acceleration requested by an external steering/actuator model.
10    /// Its magnitude is capped by [`crate::IntegratorSettings::max_steering_accel_mps2`].
11    /// It can act during coast, allowing callers to model fins or other control authority.
12    pub steering_acceleration_mps2: Vec3,
13}
14
15impl ControlInput {
16    /// Specify a thrust axis with no additional steering acceleration.
17    /// Inputs are validated when the simulator steps.
18    pub fn along(thrust_direction: Vec3) -> Self {
19        Self {
20            thrust_direction,
21            steering_acceleration_mps2: Vec3::zeros(),
22        }
23    }
24}
25
26impl Default for ControlInput {
27    fn default() -> Self {
28        Self::along(Vec3::x())
29    }
30}
31
32/// Snapshot supplied once to guidance for each [`crate::Simulator::step_guided`] call.
33#[derive(Debug, Clone, Copy)]
34pub struct GuidanceContext<'a> {
35    /// State at the start of the requested step.
36    pub state: &'a FlightState,
37    /// Mass and motor phase at the start of the requested step.
38    pub rocket: RocketSample,
39    /// Caller-requested duration, not the smaller internal RK4 timestep.
40    pub step_duration_s: f64,
41    /// Solver-enforced cap on additional steering acceleration.
42    pub max_steering_accel_mps2: f64,
43}
44
45/// Application-owned controller hook, independent of entities, targets and render timing.
46///
47/// Controllers are called once per public step, never inside RK4 stages. The command
48/// is then held constant over every internal substep. Stateful controllers should save
49/// their own state alongside [`FlightState`] and use a fixed update cadence for replay.
50/// Controller mutations are not rolled back if subsequent integration fails.
51pub trait Guidance {
52    /// Produce a finite control command, or explain why control cannot be computed.
53    fn command(&mut self, context: GuidanceContext<'_>) -> Result<ControlInput>;
54}
55
56impl<F> Guidance for F
57where
58    F: FnMut(GuidanceContext<'_>) -> Result<ControlInput>,
59{
60    fn command(&mut self, context: GuidanceContext<'_>) -> Result<ControlInput> {
61        self(context)
62    }
63}
64
65/// Owned, type-erased guidance handle for heterogeneous decorator chains.
66/// A newtype rather than `Box<dyn Guidance>` so it cannot collide with the
67/// blanket closure implementations.
68pub struct BoxedGuidance(pub Box<dyn Guidance>);
69
70impl std::fmt::Debug for BoxedGuidance {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        f.debug_struct("BoxedGuidance").finish_non_exhaustive()
73    }
74}
75
76impl Guidance for BoxedGuidance {
77    fn command(&mut self, context: GuidanceContext<'_>) -> Result<ControlInput> {
78        self.0.command(context)
79    }
80}
81
82/// Unguided orientation: thrust always follows the specified fixed world axis.
83/// This does not simulate passive body alignment or attitude dynamics.
84#[derive(Debug, Clone, Copy)]
85pub struct FixedDirection(pub Vec3);
86
87impl Guidance for FixedDirection {
88    fn command(&mut self, _context: GuidanceContext<'_>) -> Result<ControlInput> {
89        Ok(ControlInput::along(self.0))
90    }
91}
92
93/// Passive gravity-turn orientation: thrust follows the current velocity vector.
94/// At rest (or at an exact apex with zero horizontal speed) the fallback axis is
95/// used until there is a measurable direction of motion.
96#[derive(Debug, Clone, Copy)]
97pub struct ProgradeDirection {
98    /// Axis used while the speed is too small to define a direction.
99    pub fallback_axis: Vec3,
100}
101
102impl ProgradeDirection {
103    /// Fall back to this axis while stationary. Typically the launch direction.
104    pub fn new(fallback_axis: Vec3) -> Self {
105        Self { fallback_axis }
106    }
107}
108
109impl Default for ProgradeDirection {
110    fn default() -> Self {
111        Self::new(Vec3::y())
112    }
113}
114
115impl Guidance for ProgradeDirection {
116    fn command(&mut self, context: GuidanceContext<'_>) -> Result<ControlInput> {
117        let v = context.state.velocity_mps;
118        let axis = if v.norm() > 1e-9 {
119            v / v.norm()
120        } else {
121            self.fallback_axis
122        };
123        Ok(ControlInput::along(axis))
124    }
125}
126
127/// A fixed thrust-axis misalignment, applied to any inner guidance command.
128/// Models the small, constant boresight error that is the dominant dispersion
129/// source for unguided rockets: `tilt_rad` is the angular offset magnitude and
130/// `clock_rad` its orientation about the commanded axis. Zero tilt is
131/// bit-identical to the inner command.
132#[derive(Debug, Clone, Copy)]
133pub struct ThrustMisalignment<G> {
134    inner: G,
135    tilt_rad: f64,
136    clock_rad: f64,
137}
138
139impl<G> ThrustMisalignment<G> {
140    /// Wrap an inner guidance source. `tilt_rad >= 0`; `clock_rad` measured
141    /// about the commanded axis from an arbitrary but deterministic reference.
142    pub fn new(inner: G, tilt_rad: f64, clock_rad: f64) -> Result<Self> {
143        if !tilt_rad.is_finite() || tilt_rad < 0.0 || !clock_rad.is_finite() {
144            return Err(crate::Error::invalid(
145                "thrust misalignment",
146                "tilt must be finite and nonnegative, clock finite",
147            ));
148        }
149        Ok(Self {
150            inner,
151            tilt_rad,
152            clock_rad,
153        })
154    }
155}
156
157impl<G: Guidance> Guidance for ThrustMisalignment<G> {
158    fn command(&mut self, context: GuidanceContext<'_>) -> Result<ControlInput> {
159        let command = self.inner.command(context)?;
160        if self.tilt_rad == 0.0 {
161            return Ok(command);
162        }
163        let axis = command.thrust_direction.normalize();
164        // Deterministic orthonormal basis about the commanded axis.
165        let seed = if axis.z.abs() < 0.9 {
166            Vec3::z()
167        } else {
168            Vec3::y()
169        };
170        let u = axis.cross(&seed).normalize();
171        let v = axis.cross(&u).normalize();
172        let tilt_axis = u * self.clock_rad.cos() + v * self.clock_rad.sin();
173        let misaligned = axis * self.tilt_rad.cos() + tilt_axis * self.tilt_rad.sin();
174        Ok(ControlInput {
175            thrust_direction: misaligned,
176            steering_acceleration_mps2: command.steering_acceleration_mps2,
177        })
178    }
179}
180
181/// First-order actuator lag on the steering acceleration, as a stateful
182/// guidance decorator. The thrust axis passes through untouched; the steering
183/// vector relaxes toward the inner command with time constant `tau_s` using an
184/// exact discrete update `current += (target - current)·(1 − exp(−dt/τ))`.
185/// Save/restore [`Self::current`] alongside the flight state for replay.
186#[derive(Debug, Clone, Copy)]
187pub struct SteeringLag<G> {
188    inner: G,
189    tau_s: f64,
190    current: Vec3,
191}
192
193impl<G> SteeringLag<G> {
194    /// `tau_s` is the actuator time constant in seconds and must be positive.
195    pub fn new(inner: G, tau_s: f64) -> Result<Self> {
196        if !tau_s.is_finite() || tau_s <= 0.0 {
197            return Err(crate::Error::invalid(
198                "steering lag tau_s",
199                "must be finite and positive",
200            ));
201        }
202        Ok(Self {
203            inner,
204            tau_s,
205            current: Vec3::zeros(),
206        })
207    }
208    /// Current lagged steering vector (the state to snapshot).
209    pub fn current(&self) -> Vec3 {
210        self.current
211    }
212    /// Restore a previously snapshotted steering vector.
213    pub fn set_current(&mut self, current: Vec3) {
214        self.current = current;
215    }
216}
217
218impl<G: Guidance> Guidance for SteeringLag<G> {
219    fn command(&mut self, context: GuidanceContext<'_>) -> Result<ControlInput> {
220        let mut command = self.inner.command(context)?;
221        let weight = 1.0 - (-context.step_duration_s / self.tau_s).exp();
222        self.current += (command.steering_acceleration_mps2 - self.current) * weight;
223        command.steering_acceleration_mps2 = self.current;
224        Ok(command)
225    }
226}