Skip to main content

rockets_engine/
dynamics.rs

1//! Reduced-order body dynamics: a second-order weathercock filter and a scalar
2//! roll channel. These are a few integrated scalars, not a 6-DOF attitude model:
3//! no quaternion, no inertia tensor, no gyroscopic coupling.
4//!
5//! The weathercock lives in the guidance layer, so it composes with the existing
6//! integrator unchanged: its filtered axis becomes the commanded thrust axis,
7//! and `with_body_lift` closes the loop through the resulting angle of attack.
8
9use crate::{
10    ballistics::{local_air, mach_table, Atmosphere},
11    ControlInput, Error, Guidance, GuidanceContext, Result, Vec3,
12};
13use ballistics_engine::drag::DragTable;
14
15/// Aerodynamic and inertial parameters for the weathercock filter.
16#[derive(Debug, Clone)]
17pub struct WeathercockParams {
18    cna_per_rad: DragTable,
19    cp_m: DragTable,
20    cg_dry_m: f64,
21    cg_propellant_m: f64,
22    diameter_m: f64,
23    pitch_inertia_kg_m2: f64,
24    damping_ratio: f64,
25}
26
27impl WeathercockParams {
28    /// `cna_vs_mach`: normal-force slope per radian (as in body lift).
29    /// `cp_vs_mach`: centre of pressure, metres aft of the nose reference.
30    /// `cg_dry_m`/`cg_propellant_m`: two-point CG model — the dry structure's
31    /// and the propellant's centres of gravity along the same reference. The
32    /// instantaneous CG is mass-weighted from the current propellant fraction.
33    /// `pitch_inertia_kg_m2`: scalar pitch/yaw inertia about the CG.
34    /// `damping_ratio`: ζ of the second-order response (0 = undamped).
35    pub fn new(
36        cna_vs_mach: Vec<(f64, f64)>,
37        cp_vs_mach: Vec<(f64, f64)>,
38        cg_dry_m: f64,
39        cg_propellant_m: f64,
40        diameter_m: f64,
41        pitch_inertia_kg_m2: f64,
42        damping_ratio: f64,
43    ) -> Result<Self> {
44        if !cg_dry_m.is_finite() || !cg_propellant_m.is_finite() {
45            return Err(Error::invalid("cg", "CG references must be finite"));
46        }
47        if !diameter_m.is_finite() || diameter_m <= 0.0 {
48            return Err(Error::invalid("diameter_m", "must be finite and positive"));
49        }
50        if !pitch_inertia_kg_m2.is_finite() || pitch_inertia_kg_m2 <= 0.0 {
51            return Err(Error::invalid(
52                "pitch_inertia_kg_m2",
53                "must be finite and positive",
54            ));
55        }
56        if !damping_ratio.is_finite() || damping_ratio < 0.0 {
57            return Err(Error::invalid(
58                "damping_ratio",
59                "must be finite and nonnegative",
60            ));
61        }
62        Ok(Self {
63            cna_per_rad: mach_table(cna_vs_mach, "cna_vs_mach")?,
64            cp_m: mach_table(cp_vs_mach, "cp_vs_mach")?,
65            cg_dry_m,
66            cg_propellant_m,
67            diameter_m,
68            pitch_inertia_kg_m2,
69            damping_ratio,
70        })
71    }
72}
73
74/// Second-order weathercock: the body axis relaxes toward the relative wind
75/// with natural frequency ωₙ² = q·A·Cnα·(CP−CG)/I and exponential damping
76/// 2ζωₙ. Positive static margin (CP aft of CG) is stable; negative margin
77/// diverges, as it should. The axis and rate are snapshot-able state, updated
78/// once per public step with an explicit first-order-in-dt scheme, so choose
79/// the public cadence well below the axis's natural period.
80#[derive(Debug, Clone)]
81pub struct Weathercock {
82    params: WeathercockParams,
83    atmosphere: Atmosphere,
84    atmos_params: (f64, f64, f64, f64),
85    wind_mps: Vec3,
86    axis: Vec3,
87    rate: Vec3,
88}
89
90impl Weathercock {
91    /// `initial_axis` is the pad orientation (normalized at construction).
92    /// `atmosphere` and `wind_mps` should match the force model's air.
93    pub fn new(
94        params: WeathercockParams,
95        initial_axis: Vec3,
96        atmosphere: Atmosphere,
97        wind_mps: Vec3,
98    ) -> Result<Self> {
99        if initial_axis.amax() == 0.0 || !initial_axis.iter().all(|x| x.is_finite()) {
100            return Err(Error::invalid("initial_axis", "must be finite and nonzero"));
101        }
102        if !wind_mps.iter().all(|x| x.is_finite()) {
103            return Err(Error::invalid("wind_mps", "must be finite"));
104        }
105        Ok(Self {
106            atmos_params: atmosphere.parameters()?,
107            params,
108            atmosphere,
109            wind_mps,
110            axis: initial_axis.normalize(),
111            rate: Vec3::zeros(),
112        })
113    }
114
115    /// Current body axis (unit vector) — snapshot state for replay.
116    pub fn axis(&self) -> Vec3 {
117        self.axis
118    }
119    /// Current angular-rate vector, perpendicular to the axis.
120    pub fn rate(&self) -> Vec3 {
121        self.rate
122    }
123    /// Restore a previously snapshotted axis/rate pair.
124    pub fn set_state(&mut self, axis: Vec3, rate: Vec3) -> Result<()> {
125        if axis.amax() == 0.0 || !axis.iter().all(|x| x.is_finite()) {
126            return Err(Error::invalid("axis", "must be finite and nonzero"));
127        }
128        self.axis = axis.normalize();
129        self.rate = rate;
130        Ok(())
131    }
132
133    fn cg_m(&self, context: &GuidanceContext<'_>) -> f64 {
134        let total = context.rocket.mass_kg;
135        let prop = context.rocket.motor.propellant_mass_kg;
136        if total <= 0.0 {
137            return self.params.cg_dry_m;
138        }
139        (self.params.cg_dry_m * (total - prop) + self.params.cg_propellant_m * prop) / total
140    }
141}
142
143impl Guidance for Weathercock {
144    fn command(&mut self, context: GuidanceContext<'_>) -> Result<ControlInput> {
145        let dt = context.step_duration_s;
146        let v_air = context.state.velocity_mps - self.wind_mps;
147        let speed = v_air.norm();
148        if speed < 1e-9 {
149            // No relative wind: no restoring torque, hold the current axis.
150            return Ok(ControlInput::along(self.axis));
151        }
152        let target = v_air / speed;
153        let (rho, sound) = local_air(
154            self.atmosphere,
155            self.atmos_params,
156            context.state.position_m.y,
157        );
158        let mach = speed / sound;
159        let q = 0.5 * rho * speed * speed;
160        let area = std::f64::consts::FRAC_PI_4 * self.params.diameter_m.powi(2);
161        let cna = self.params.cna_per_rad.interpolate(mach);
162        let margin = self.params.cp_m.interpolate(mach) - self.cg_m(&context);
163        let omega_sq = q * area * cna * margin / self.params.pitch_inertia_kg_m2;
164
165        // Restoring (or diverging) angular acceleration about axis × target.
166        // axis' = axis + (axis×target)×axis·dt rotates toward the target.
167        let cross = self.axis.cross(&target);
168        let anti_parallel = cross.norm() < 1e-12 && self.axis.dot(&target) < 0.0;
169        let cross = if anti_parallel && self.rate.norm() < 1e-12 {
170            // Exact inversion is an unstable equilibrium; seed a deterministic
171            // perturbation so the flip starts instead of sticking numerically.
172            let seed = if self.axis.z.abs() < 0.9 {
173                Vec3::z()
174            } else {
175                Vec3::y()
176            };
177            self.axis.cross(&seed).normalize() * 1e-6
178        } else {
179            cross
180        };
181        self.rate += cross * (omega_sq * dt);
182        let omega = omega_sq.abs().sqrt();
183        if omega > 0.0 {
184            self.rate *= (-2.0 * self.params.damping_ratio * omega * dt).exp();
185        }
186        // Keep the rate perpendicular, then rotate the axis by rate·dt.
187        self.rate -= self.axis * self.axis.dot(&self.rate);
188        let delta = self.rate.cross(&self.axis) * dt;
189        self.axis = (self.axis + delta).normalize();
190        Ok(ControlInput::along(self.axis))
191    }
192}
193
194/// Scalar roll channel: roll rate driven by fin cant and opposed by roll
195/// damping. Drive torque = q·A·d·Clδ·δ, damping torque = q·A·d²·Clp·ω/(2V).
196/// Equilibrium rate = 2·Clδ·δ·V/(d·Clp). Feed the resulting effective twist
197/// (`speed / rate`, in inches per turn) to `BallisticForces::set_twist`
198/// between public steps.
199#[derive(Debug, Clone, Copy)]
200pub struct RollDynamics {
201    rate_rad_s: f64,
202    cant_rad: f64,
203    cl_delta: f64,
204    clp: f64,
205    inertia_kg_m2: f64,
206    diameter_m: f64,
207}
208
209impl RollDynamics {
210    /// `cant_rad`: fin cant angle. `cl_delta`: roll-drive slope per radian of
211    /// cant. `clp`: roll-damping coefficient (positive = damping).
212    /// `inertia_kg_m2`: roll inertia about the body axis.
213    pub fn new(
214        cant_rad: f64,
215        cl_delta: f64,
216        clp: f64,
217        inertia_kg_m2: f64,
218        diameter_m: f64,
219    ) -> Result<Self> {
220        if !cant_rad.is_finite() || !cl_delta.is_finite() || !clp.is_finite() || clp <= 0.0 {
221            return Err(Error::invalid(
222                "roll parameters",
223                "cant and cl_delta must be finite, clp finite and positive",
224            ));
225        }
226        if !inertia_kg_m2.is_finite() || inertia_kg_m2 <= 0.0 {
227            return Err(Error::invalid(
228                "roll inertia_kg_m2",
229                "must be finite and positive",
230            ));
231        }
232        if !diameter_m.is_finite() || diameter_m <= 0.0 {
233            return Err(Error::invalid("diameter_m", "must be finite and positive"));
234        }
235        Ok(Self {
236            rate_rad_s: 0.0,
237            cant_rad,
238            cl_delta,
239            clp,
240            inertia_kg_m2,
241            diameter_m,
242        })
243    }
244
245    /// Current roll rate in rad/s (snapshot state).
246    pub fn rate_rad_s(&self) -> f64 {
247        self.rate_rad_s
248    }
249    /// Restore a previously snapshotted roll rate.
250    pub fn set_rate_rad_s(&mut self, rate: f64) {
251        self.rate_rad_s = rate;
252    }
253
254    /// Advance one public step given dynamic pressure (Pa) and airspeed (m/s).
255    pub fn update(&mut self, dt_s: f64, q_pa: f64, speed_mps: f64) {
256        let d = self.diameter_m;
257        let area = std::f64::consts::FRAC_PI_4 * d * d;
258        let drive = q_pa * area * d * self.cl_delta * self.cant_rad;
259        let damping =
260            q_pa * area * d * d * self.clp * self.rate_rad_s / (2.0 * speed_mps.max(1e-9));
261        self.rate_rad_s += (drive - damping) / self.inertia_kg_m2 * dt_s;
262    }
263
264    /// Equilibrium roll rate for the current cant and airspeed, rad/s.
265    pub fn equilibrium_rate(&self, speed_mps: f64) -> f64 {
266        2.0 * self.cl_delta * self.cant_rad * speed_mps / (self.diameter_m * self.clp)
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crate::{FlightState, MotorPhase, MotorSample, RocketSample};
274
275    fn params(margin_cp: f64) -> WeathercockParams {
276        WeathercockParams::new(
277            vec![(0.0, 2.0), (3.0, 2.0)],
278            vec![(0.0, margin_cp), (3.0, margin_cp)],
279            0.3,
280            0.3,
281            0.1,
282            0.05,
283            0.3,
284        )
285        .unwrap()
286    }
287
288    fn context(state: &FlightState) -> GuidanceContext<'_> {
289        GuidanceContext {
290            state,
291            rocket: RocketSample {
292                mass_kg: 1.0,
293                motor: MotorSample {
294                    phase: MotorPhase::Unpowered,
295                    thrust_n: 0.0,
296                    propellant_mass_kg: 0.0,
297                },
298            },
299            step_duration_s: 0.01,
300            max_steering_accel_mps2: 0.0,
301        }
302    }
303
304    #[test]
305    fn stable_body_weathercocks_toward_the_wind() {
306        let params = params(0.6); // CP 0.6, CG 0.3: stable margin
307        let mut w = Weathercock::new(
308            params,
309            Vec3::new(1.0, 0.1, 0.0),
310            Atmosphere::default(),
311            Vec3::zeros(),
312        )
313        .unwrap();
314        let state = FlightState {
315            time_s: 0.0,
316            position_m: Vec3::zeros(),
317            velocity_mps: Vec3::new(100.0, 0.0, 0.0),
318        };
319        for _ in 0..500 {
320            w.command(context(&state)).unwrap();
321        }
322        let dot = w.axis().dot(&Vec3::x());
323        assert!(dot > 0.999, "should align with the wind, dot {dot}");
324    }
325
326    #[test]
327    fn unstable_body_diverges() {
328        let params = params(0.1); // CP 0.1, CG 0.3: negative margin
329        let mut w = Weathercock::new(
330            params,
331            Vec3::new(1.0, 0.01, 0.0),
332            Atmosphere::default(),
333            Vec3::zeros(),
334        )
335        .unwrap();
336        let state = FlightState {
337            time_s: 0.0,
338            position_m: Vec3::zeros(),
339            velocity_mps: Vec3::new(100.0, 0.0, 0.0),
340        };
341        for _ in 0..500 {
342            w.command(context(&state)).unwrap();
343        }
344        assert!(w.axis().dot(&Vec3::x()) < 0.9, "should tumble away");
345    }
346
347    #[test]
348    fn aligned_axis_stays_aligned() {
349        let params = params(0.6);
350        let mut w =
351            Weathercock::new(params, Vec3::x(), Atmosphere::default(), Vec3::zeros()).unwrap();
352        let state = FlightState {
353            time_s: 0.0,
354            position_m: Vec3::zeros(),
355            velocity_mps: Vec3::new(100.0, 0.0, 0.0),
356        };
357        for _ in 0..100 {
358            w.command(context(&state)).unwrap();
359        }
360        assert!((w.axis() - Vec3::x()).norm() < 1e-12);
361    }
362
363    #[test]
364    fn roll_reaches_the_cant_equilibrium() {
365        let mut roll = RollDynamics::new(0.02, 1.0, 1.0, 0.001, 0.1).unwrap();
366        let q = 5000.0;
367        let v = 200.0;
368        // Roll time constant here is ~1 s; integrate 10 s.
369        for _ in 0..10000 {
370            roll.update(0.001, q, v);
371        }
372        let expected = roll.equilibrium_rate(v);
373        assert!(
374            (roll.rate_rad_s() - expected).abs() / expected.abs() < 0.01,
375            "rate {} vs equilibrium {expected}",
376            roll.rate_rad_s()
377        );
378    }
379
380    #[test]
381    fn uncanted_roll_decays_to_zero() {
382        let mut roll = RollDynamics::new(0.0, 1.0, 1.0, 0.001, 0.1).unwrap();
383        roll.set_rate_rad_s(50.0);
384        for _ in 0..10000 {
385            roll.update(0.001, 5000.0, 200.0);
386        }
387        assert!(roll.rate_rad_s().abs() < 0.01);
388    }
389
390    #[test]
391    fn weathercock_snapshot_restores_identically() {
392        let make = || {
393            Weathercock::new(
394                params(0.6),
395                Vec3::new(1.0, 0.1, 0.0),
396                Atmosphere::default(),
397                Vec3::zeros(),
398            )
399            .unwrap()
400        };
401        let state = FlightState {
402            time_s: 0.0,
403            position_m: Vec3::zeros(),
404            velocity_mps: Vec3::new(100.0, 0.0, 0.0),
405        };
406        let mut a = make();
407        for _ in 0..50 {
408            a.command(context(&state)).unwrap();
409        }
410        let (axis, rate) = (a.axis(), a.rate());
411        let mut b = make();
412        b.set_state(axis, rate).unwrap();
413        for _ in 0..50 {
414            let ca = a.command(context(&state)).unwrap();
415            let cb = b.command(context(&state)).unwrap();
416            // set_state re-normalizes the restored axis, which can shift one ULP.
417            assert!(
418                (ca.thrust_direction - cb.thrust_direction).norm() < 1e-12,
419                "{ca:?} vs {cb:?}"
420            );
421        }
422    }
423
424    #[test]
425    fn weathercock_params_validate() {
426        assert!(WeathercockParams::new(
427            vec![(0.0, 2.0)],
428            vec![(0.0, 0.6), (3.0, 0.6)],
429            0.3,
430            0.3,
431            0.1,
432            0.05,
433            0.3
434        )
435        .is_err());
436        assert!(WeathercockParams::new(
437            vec![(0.0, 2.0), (3.0, 2.0)],
438            vec![(0.0, 0.6), (3.0, 0.6)],
439            0.3,
440            0.3,
441            0.1,
442            0.0,
443            0.3
444        )
445        .is_err());
446        assert!(WeathercockParams::new(
447            vec![(0.0, 2.0), (3.0, 2.0)],
448            vec![(0.0, 0.6), (3.0, 0.6)],
449            0.3,
450            0.3,
451            0.1,
452            0.05,
453            -0.1
454        )
455        .is_err());
456    }
457
458    #[test]
459    fn roll_params_validate() {
460        assert!(RollDynamics::new(0.02, 1.0, 0.0, 0.001, 0.1).is_err());
461        assert!(RollDynamics::new(0.02, 1.0, 1.0, 0.0, 0.1).is_err());
462        assert!(RollDynamics::new(0.02, 1.0, 1.0, 0.001, 0.0).is_err());
463    }
464
465    #[test]
466    fn underdamped_response_oscillates_near_the_natural_frequency() {
467        // A stable, undamped body released off-wind should oscillate at roughly
468        // omega_n. Measure sign changes of the off-wind component over 1 s.
469        let undamped = WeathercockParams::new(
470            vec![(0.0, 2.0), (3.0, 2.0)],
471            vec![(0.0, 0.6), (3.0, 0.6)],
472            0.3,
473            0.3,
474            0.1,
475            0.05,
476            0.0,
477        )
478        .unwrap();
479        let mut w = Weathercock::new(
480            undamped,
481            Vec3::new(1.0, 0.1, 0.0),
482            Atmosphere::default(),
483            Vec3::zeros(),
484        )
485        .unwrap();
486        let state = FlightState {
487            time_s: 0.0,
488            position_m: Vec3::zeros(),
489            velocity_mps: Vec3::new(100.0, 0.0, 0.0),
490        };
491        let steps = 100; // context.step_duration_s = 0.01, so 1 second total
492        let mut previous_sign = 1.0_f64;
493        let mut crossings = 0;
494        for _ in 0..steps {
495            w.command(context(&state)).unwrap();
496            let off = w.axis().y;
497            if off.signum() != previous_sign && off != 0.0 {
498                crossings += 1;
499                previous_sign = off.signum();
500            }
501        }
502        let q = 0.5 * 1.225 * 100.0 * 100.0;
503        let area = std::f64::consts::FRAC_PI_4 * 0.1 * 0.1;
504        let omega_n = (q * area * 2.0 * (0.6 - 0.3) / 0.05).sqrt();
505        let elapsed = steps as f64 * 0.01;
506        let expected_crossings = omega_n * elapsed / std::f64::consts::PI;
507        let ratio = crossings as f64 / expected_crossings;
508        assert!(
509            (0.75..=1.3).contains(&ratio),
510            "crossings {crossings} vs expected {expected_crossings:.1} (ratio {ratio})"
511        );
512    }
513}