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    basis_u: Option<Vec3>,
138}
139
140impl<G> ThrustMisalignment<G> {
141    /// Wrap an inner guidance source. `tilt_rad >= 0`; `clock_rad` measured
142    /// about the commanded axis from an arbitrary but deterministic reference.
143    pub fn new(inner: G, tilt_rad: f64, clock_rad: f64) -> Result<Self> {
144        if !tilt_rad.is_finite() || tilt_rad < 0.0 || !clock_rad.is_finite() {
145            return Err(crate::Error::invalid(
146                "thrust misalignment",
147                "tilt must be finite and nonnegative, clock finite",
148            ));
149        }
150        Ok(Self {
151            inner,
152            tilt_rad,
153            clock_rad,
154            basis_u: None,
155        })
156    }
157
158    /// Current reference-basis vector, for snapshot/replay continuity.
159    pub fn basis_u(&self) -> Option<Vec3> {
160        self.basis_u
161    }
162
163    /// Restore a previously snapshotted reference basis (or `None` to reseed).
164    pub fn set_basis_u(&mut self, basis_u: Option<Vec3>) {
165        self.basis_u = basis_u;
166    }
167}
168
169impl<G: Guidance> Guidance for ThrustMisalignment<G> {
170    fn command(&mut self, context: GuidanceContext<'_>) -> Result<ControlInput> {
171        let command = self.inner.command(context)?;
172        if self.tilt_rad == 0.0 {
173            return Ok(command);
174        }
175        let axis = command.thrust_direction.normalize();
176        // Parallel-transport the previous basis when possible; seed on the
177        // first call or when the old basis degenerates against the new axis.
178        let u = match self.basis_u {
179            Some(previous) => {
180                let transported = previous - axis * axis.dot(&previous);
181                if transported.norm() > 1e-9 {
182                    transported.normalize()
183                } else {
184                    let seed = if axis.z.abs() < 0.9 {
185                        Vec3::z()
186                    } else {
187                        Vec3::y()
188                    };
189                    axis.cross(&seed).normalize()
190                }
191            }
192            None => {
193                let seed = if axis.z.abs() < 0.9 {
194                    Vec3::z()
195                } else {
196                    Vec3::y()
197                };
198                axis.cross(&seed).normalize()
199            }
200        };
201        self.basis_u = Some(u);
202        let v = axis.cross(&u).normalize();
203        let tilt_axis = u * self.clock_rad.cos() + v * self.clock_rad.sin();
204        let misaligned = axis * self.tilt_rad.cos() + tilt_axis * self.tilt_rad.sin();
205        Ok(ControlInput {
206            thrust_direction: misaligned,
207            steering_acceleration_mps2: command.steering_acceleration_mps2,
208        })
209    }
210}
211
212/// First-order actuator lag on the steering acceleration, as a stateful
213/// guidance decorator. The thrust axis passes through untouched; the steering
214/// vector relaxes toward the inner command with time constant `tau_s` using an
215/// exact discrete update `current += (target - current)·(1 − exp(−dt/τ))`.
216/// Save/restore [`Self::current`] alongside the flight state for replay.
217#[derive(Debug, Clone, Copy)]
218pub struct SteeringLag<G> {
219    inner: G,
220    tau_s: f64,
221    current: Vec3,
222}
223
224impl<G> SteeringLag<G> {
225    /// `tau_s` is the actuator time constant in seconds and must be positive.
226    pub fn new(inner: G, tau_s: f64) -> Result<Self> {
227        if !tau_s.is_finite() || tau_s <= 0.0 {
228            return Err(crate::Error::invalid(
229                "steering lag tau_s",
230                "must be finite and positive",
231            ));
232        }
233        Ok(Self {
234            inner,
235            tau_s,
236            current: Vec3::zeros(),
237        })
238    }
239    /// Current lagged steering vector (the state to snapshot).
240    pub fn current(&self) -> Vec3 {
241        self.current
242    }
243    /// Restore a previously snapshotted steering vector.
244    pub fn set_current(&mut self, current: Vec3) {
245        self.current = current;
246    }
247}
248
249impl<G: Guidance> Guidance for SteeringLag<G> {
250    fn command(&mut self, context: GuidanceContext<'_>) -> Result<ControlInput> {
251        let mut command = self.inner.command(context)?;
252        let weight = 1.0 - (-context.step_duration_s / self.tau_s).exp();
253        self.current += (command.steering_acceleration_mps2 - self.current) * weight;
254        command.steering_acceleration_mps2 = self.current;
255        Ok(command)
256    }
257}