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