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