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    turbulence: Option<crate::ballistics::Turbulence>,
87    axis: Vec3,
88    rate: Vec3,
89}
90
91impl Weathercock {
92    /// `initial_axis` is the pad orientation (normalized at construction).
93    /// `atmosphere` and `wind_mps` should match the force model's air.
94    pub fn new(
95        params: WeathercockParams,
96        initial_axis: Vec3,
97        atmosphere: Atmosphere,
98        wind_mps: Vec3,
99    ) -> Result<Self> {
100        if initial_axis.amax() == 0.0 || !initial_axis.iter().all(|x| x.is_finite()) {
101            return Err(Error::invalid("initial_axis", "must be finite and nonzero"));
102        }
103        if !wind_mps.iter().all(|x| x.is_finite()) {
104            return Err(Error::invalid("wind_mps", "must be finite"));
105        }
106        Ok(Self {
107            atmos_params: atmosphere.parameters()?,
108            params,
109            atmosphere,
110            wind_mps,
111            turbulence: None,
112            axis: initial_axis.normalize(),
113            rate: Vec3::zeros(),
114        })
115    }
116
117    /// Add the same deterministic frozen-field turbulence as the force
118    /// model: with equal `sigma`/`tau`/`seed` the gusts are identical, so
119    /// the axis responds to the same air the drag sees.
120    pub fn with_turbulence(mut self, sigma_mps: f64, tau_s: f64, seed: u64) -> Result<Self> {
121        self.turbulence = Some(crate::ballistics::Turbulence::new(sigma_mps, tau_s, seed)?);
122        Ok(self)
123    }
124
125    /// Current body axis (unit vector) — snapshot state for replay.
126    pub fn axis(&self) -> Vec3 {
127        self.axis
128    }
129    /// Current angular-rate vector, perpendicular to the axis.
130    pub fn rate(&self) -> Vec3 {
131        self.rate
132    }
133    /// Restore a previously snapshotted axis/rate pair.
134    pub fn set_state(&mut self, axis: Vec3, rate: Vec3) -> Result<()> {
135        if axis.amax() == 0.0 || !axis.iter().all(|x| x.is_finite()) {
136            return Err(Error::invalid("axis", "must be finite and nonzero"));
137        }
138        self.axis = axis.normalize();
139        self.rate = rate;
140        Ok(())
141    }
142
143    fn cg_m(&self, context: &GuidanceContext<'_>) -> f64 {
144        let total = context.rocket.mass_kg;
145        let prop = context.rocket.motor.propellant_mass_kg;
146        if total <= 0.0 {
147            return self.params.cg_dry_m;
148        }
149        (self.params.cg_dry_m * (total - prop) + self.params.cg_propellant_m * prop) / total
150    }
151}
152
153impl Guidance for Weathercock {
154    fn command(&mut self, context: GuidanceContext<'_>) -> Result<ControlInput> {
155        let dt = context.step_duration_s;
156        let wind = match &self.turbulence {
157            Some(t) => self.wind_mps + t.gust_vec(context.state.time_s),
158            None => self.wind_mps,
159        };
160        let v_air = context.state.velocity_mps - wind;
161        let speed = v_air.norm();
162        if speed < 1e-9 {
163            // No relative wind: no restoring torque, hold the current axis.
164            return Ok(ControlInput::along(self.axis));
165        }
166        let target = v_air / speed;
167        let (rho, sound) = local_air(
168            self.atmosphere,
169            self.atmos_params,
170            context.state.position_m.y,
171        );
172        let mach = speed / sound;
173        let q = 0.5 * rho * speed * speed;
174        let area = std::f64::consts::FRAC_PI_4 * self.params.diameter_m.powi(2);
175        let cna = self.params.cna_per_rad.interpolate(mach);
176        let margin = self.params.cp_m.interpolate(mach) - self.cg_m(&context);
177        let omega_sq = q * area * cna * margin / self.params.pitch_inertia_kg_m2;
178
179        // Restoring (or diverging) angular acceleration about axis × target.
180        // axis' = axis + (axis×target)×axis·dt rotates toward the target.
181        let cross = self.axis.cross(&target);
182        let anti_parallel = cross.norm() < 1e-12 && self.axis.dot(&target) < 0.0;
183        let cross = if anti_parallel && self.rate.norm() < 1e-12 {
184            // Exact inversion is an unstable equilibrium; seed a deterministic
185            // perturbation so the flip starts instead of sticking numerically.
186            let seed = if self.axis.z.abs() < 0.9 {
187                Vec3::z()
188            } else {
189                Vec3::y()
190            };
191            self.axis.cross(&seed).normalize() * 1e-6
192        } else {
193            cross
194        };
195        self.rate += cross * (omega_sq * dt);
196        let omega = omega_sq.abs().sqrt();
197        if omega > 0.0 {
198            self.rate *= (-2.0 * self.params.damping_ratio * omega * dt).exp();
199        }
200        // Keep the rate perpendicular, then rotate the axis by rate·dt.
201        self.rate -= self.axis * self.axis.dot(&self.rate);
202        let delta = self.rate.cross(&self.axis) * dt;
203        self.axis = (self.axis + delta).normalize();
204        Ok(ControlInput::along(self.axis))
205    }
206}
207
208/// Scalar roll channel: roll rate driven by fin cant and opposed by roll
209/// damping. Drive torque = q·A·d·Clδ·δ, damping torque = q·A·d²·Clp·ω/(2V).
210/// Equilibrium rate = 2·Clδ·δ·V/(d·Clp). Feed the resulting effective twist
211/// (`speed / rate`, in inches per turn) to `BallisticForces::set_twist`
212/// between public steps.
213#[derive(Debug, Clone, Copy)]
214pub struct RollDynamics {
215    rate_rad_s: f64,
216    cant_rad: f64,
217    cl_delta: f64,
218    clp: f64,
219    inertia_kg_m2: f64,
220    diameter_m: f64,
221}
222
223impl RollDynamics {
224    /// `cant_rad`: fin cant angle. `cl_delta`: roll-drive slope per radian of
225    /// cant. `clp`: roll-damping coefficient (positive = damping).
226    /// `inertia_kg_m2`: roll inertia about the body axis.
227    pub fn new(
228        cant_rad: f64,
229        cl_delta: f64,
230        clp: f64,
231        inertia_kg_m2: f64,
232        diameter_m: f64,
233    ) -> Result<Self> {
234        if !cant_rad.is_finite() || !cl_delta.is_finite() || !clp.is_finite() || clp <= 0.0 {
235            return Err(Error::invalid(
236                "roll parameters",
237                "cant and cl_delta must be finite, clp finite and positive",
238            ));
239        }
240        if !inertia_kg_m2.is_finite() || inertia_kg_m2 <= 0.0 {
241            return Err(Error::invalid(
242                "roll inertia_kg_m2",
243                "must be finite and positive",
244            ));
245        }
246        if !diameter_m.is_finite() || diameter_m <= 0.0 {
247            return Err(Error::invalid("diameter_m", "must be finite and positive"));
248        }
249        Ok(Self {
250            rate_rad_s: 0.0,
251            cant_rad,
252            cl_delta,
253            clp,
254            inertia_kg_m2,
255            diameter_m,
256        })
257    }
258
259    /// Current roll rate in rad/s (snapshot state).
260    pub fn rate_rad_s(&self) -> f64 {
261        self.rate_rad_s
262    }
263    /// Restore a previously snapshotted roll rate.
264    pub fn set_rate_rad_s(&mut self, rate: f64) {
265        self.rate_rad_s = rate;
266    }
267
268    /// Advance one public step given dynamic pressure (Pa) and airspeed (m/s).
269    pub fn update(&mut self, dt_s: f64, q_pa: f64, speed_mps: f64) {
270        let d = self.diameter_m;
271        let area = std::f64::consts::FRAC_PI_4 * d * d;
272        let drive = q_pa * area * d * self.cl_delta * self.cant_rad;
273        let damping =
274            q_pa * area * d * d * self.clp * self.rate_rad_s / (2.0 * speed_mps.max(1e-9));
275        self.rate_rad_s += (drive - damping) / self.inertia_kg_m2 * dt_s;
276    }
277
278    /// Equilibrium roll rate for the current cant and airspeed, rad/s.
279    pub fn equilibrium_rate(&self, speed_mps: f64) -> f64 {
280        2.0 * self.cl_delta * self.cant_rad * speed_mps / (self.diameter_m * self.clp)
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::{FlightState, MotorPhase, MotorSample, RocketSample};
288
289    fn params(margin_cp: f64) -> WeathercockParams {
290        WeathercockParams::new(
291            vec![(0.0, 2.0), (3.0, 2.0)],
292            vec![(0.0, margin_cp), (3.0, margin_cp)],
293            0.3,
294            0.3,
295            0.1,
296            0.05,
297            0.3,
298        )
299        .unwrap()
300    }
301
302    fn context(state: &FlightState) -> GuidanceContext<'_> {
303        GuidanceContext {
304            state,
305            rocket: RocketSample {
306                mass_kg: 1.0,
307                motor: MotorSample {
308                    phase: MotorPhase::Unpowered,
309                    thrust_n: 0.0,
310                    propellant_mass_kg: 0.0,
311                },
312            },
313            step_duration_s: 0.01,
314            max_steering_accel_mps2: 0.0,
315        }
316    }
317
318    #[test]
319    fn stable_body_weathercocks_toward_the_wind() {
320        let params = params(0.6); // CP 0.6, CG 0.3: stable margin
321        let mut w = Weathercock::new(
322            params,
323            Vec3::new(1.0, 0.1, 0.0),
324            Atmosphere::default(),
325            Vec3::zeros(),
326        )
327        .unwrap();
328        let state = FlightState {
329            time_s: 0.0,
330            position_m: Vec3::zeros(),
331            velocity_mps: Vec3::new(100.0, 0.0, 0.0),
332        };
333        for _ in 0..500 {
334            w.command(context(&state)).unwrap();
335        }
336        let dot = w.axis().dot(&Vec3::x());
337        assert!(dot > 0.999, "should align with the wind, dot {dot}");
338    }
339
340    #[test]
341    fn unstable_body_diverges() {
342        let params = params(0.1); // CP 0.1, CG 0.3: negative margin
343        let mut w = Weathercock::new(
344            params,
345            Vec3::new(1.0, 0.01, 0.0),
346            Atmosphere::default(),
347            Vec3::zeros(),
348        )
349        .unwrap();
350        let state = FlightState {
351            time_s: 0.0,
352            position_m: Vec3::zeros(),
353            velocity_mps: Vec3::new(100.0, 0.0, 0.0),
354        };
355        for _ in 0..500 {
356            w.command(context(&state)).unwrap();
357        }
358        assert!(w.axis().dot(&Vec3::x()) < 0.9, "should tumble away");
359    }
360
361    #[test]
362    fn aligned_axis_stays_aligned() {
363        let params = params(0.6);
364        let mut w =
365            Weathercock::new(params, Vec3::x(), Atmosphere::default(), Vec3::zeros()).unwrap();
366        let state = FlightState {
367            time_s: 0.0,
368            position_m: Vec3::zeros(),
369            velocity_mps: Vec3::new(100.0, 0.0, 0.0),
370        };
371        for _ in 0..100 {
372            w.command(context(&state)).unwrap();
373        }
374        assert!((w.axis() - Vec3::x()).norm() < 1e-12);
375    }
376
377    #[test]
378    fn roll_reaches_the_cant_equilibrium() {
379        let mut roll = RollDynamics::new(0.02, 1.0, 1.0, 0.001, 0.1).unwrap();
380        let q = 5000.0;
381        let v = 200.0;
382        // Roll time constant here is ~1 s; integrate 10 s.
383        for _ in 0..10000 {
384            roll.update(0.001, q, v);
385        }
386        let expected = roll.equilibrium_rate(v);
387        assert!(
388            (roll.rate_rad_s() - expected).abs() / expected.abs() < 0.01,
389            "rate {} vs equilibrium {expected}",
390            roll.rate_rad_s()
391        );
392    }
393
394    #[test]
395    fn uncanted_roll_decays_to_zero() {
396        let mut roll = RollDynamics::new(0.0, 1.0, 1.0, 0.001, 0.1).unwrap();
397        roll.set_rate_rad_s(50.0);
398        for _ in 0..10000 {
399            roll.update(0.001, 5000.0, 200.0);
400        }
401        assert!(roll.rate_rad_s().abs() < 0.01);
402    }
403
404    #[test]
405    fn weathercock_snapshot_restores_identically() {
406        let make = || {
407            Weathercock::new(
408                params(0.6),
409                Vec3::new(1.0, 0.1, 0.0),
410                Atmosphere::default(),
411                Vec3::zeros(),
412            )
413            .unwrap()
414        };
415        let state = FlightState {
416            time_s: 0.0,
417            position_m: Vec3::zeros(),
418            velocity_mps: Vec3::new(100.0, 0.0, 0.0),
419        };
420        let mut a = make();
421        for _ in 0..50 {
422            a.command(context(&state)).unwrap();
423        }
424        let (axis, rate) = (a.axis(), a.rate());
425        let mut b = make();
426        b.set_state(axis, rate).unwrap();
427        for _ in 0..50 {
428            let ca = a.command(context(&state)).unwrap();
429            let cb = b.command(context(&state)).unwrap();
430            // set_state re-normalizes the restored axis, which can shift one ULP.
431            assert!(
432                (ca.thrust_direction - cb.thrust_direction).norm() < 1e-12,
433                "{ca:?} vs {cb:?}"
434            );
435        }
436    }
437
438    #[test]
439    fn weathercock_params_validate() {
440        assert!(WeathercockParams::new(
441            vec![(0.0, 2.0)],
442            vec![(0.0, 0.6), (3.0, 0.6)],
443            0.3,
444            0.3,
445            0.1,
446            0.05,
447            0.3
448        )
449        .is_err());
450        assert!(WeathercockParams::new(
451            vec![(0.0, 2.0), (3.0, 2.0)],
452            vec![(0.0, 0.6), (3.0, 0.6)],
453            0.3,
454            0.3,
455            0.1,
456            0.0,
457            0.3
458        )
459        .is_err());
460        assert!(WeathercockParams::new(
461            vec![(0.0, 2.0), (3.0, 2.0)],
462            vec![(0.0, 0.6), (3.0, 0.6)],
463            0.3,
464            0.3,
465            0.1,
466            0.05,
467            -0.1
468        )
469        .is_err());
470    }
471
472    #[test]
473    fn roll_params_validate() {
474        assert!(RollDynamics::new(0.02, 1.0, 0.0, 0.001, 0.1).is_err());
475        assert!(RollDynamics::new(0.02, 1.0, 1.0, 0.0, 0.1).is_err());
476        assert!(RollDynamics::new(0.02, 1.0, 1.0, 0.001, 0.0).is_err());
477    }
478
479    #[test]
480    fn underdamped_response_oscillates_near_the_natural_frequency() {
481        // A stable, undamped body released off-wind should oscillate at roughly
482        // omega_n. Measure sign changes of the off-wind component over 1 s.
483        let undamped = WeathercockParams::new(
484            vec![(0.0, 2.0), (3.0, 2.0)],
485            vec![(0.0, 0.6), (3.0, 0.6)],
486            0.3,
487            0.3,
488            0.1,
489            0.05,
490            0.0,
491        )
492        .unwrap();
493        let mut w = Weathercock::new(
494            undamped,
495            Vec3::new(1.0, 0.1, 0.0),
496            Atmosphere::default(),
497            Vec3::zeros(),
498        )
499        .unwrap();
500        let state = FlightState {
501            time_s: 0.0,
502            position_m: Vec3::zeros(),
503            velocity_mps: Vec3::new(100.0, 0.0, 0.0),
504        };
505        let steps = 100; // context.step_duration_s = 0.01, so 1 second total
506        let mut previous_sign = 1.0_f64;
507        let mut crossings = 0;
508        for _ in 0..steps {
509            w.command(context(&state)).unwrap();
510            let off = w.axis().y;
511            if off.signum() != previous_sign && off != 0.0 {
512                crossings += 1;
513                previous_sign = off.signum();
514            }
515        }
516        let q = 0.5 * 1.225 * 100.0 * 100.0;
517        let area = std::f64::consts::FRAC_PI_4 * 0.1 * 0.1;
518        let omega_n = (q * area * 2.0 * (0.6 - 0.3) / 0.05).sqrt();
519        let elapsed = steps as f64 * 0.01;
520        let expected_crossings = omega_n * elapsed / std::f64::consts::PI;
521        let ratio = crossings as f64 / expected_crossings;
522        assert!(
523            (0.75..=1.3).contains(&ratio),
524            "crossings {crossings} vs expected {expected_crossings:.1} (ratio {ratio})"
525        );
526    }
527}