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