Skip to main content

hpr_sim/
dynamics.rs

1//! The equations of motion: a rigid body of varying mass referred to a point fixed in the body.
2//!
3//! Source: the RocketPy technical documentation, "Equations of Motion" v0 (Kane's method with the
4//! Reynolds transport theorem) and v1 (the form solved), RocketPy 1.13.0, MIT; G. H. Ceotto,
5//! R. N. Schmitt, G. F. Alves, L. A. Pezente and B. S. Carmo, "RocketPy: Six Degree-of-Freedom
6//! Rocket Trajectory Simulator", *J. Aerosp. Eng.* 34(6), 2021. The nozzle gyration tensor is
7//! integrated here from the boxed rotational equation of v0 (a uniform jet over the exit disc).
8//! The derivation, the assumptions and every term are in `docs/physics/flight.md`.
9//!
10//! In body axes, with `O` the nose tip, `r` the center of mass from `O`, `m` the mass, `I` the
11//! inertia about `O` and `I_c` about the center of mass, primes body-frame time derivatives and
12//! `ṁ_k ≤ 0` each motor's mass rate with its nozzle exit at `n_k`:
13//!
14//! ```text
15//! T20 = −ω×(ω×m r) + ω×(2 Σ ṁ_k (n_k − r) − 2 m r′) + T − m r″ − 2 ṁ r′ + Σ m̈_k (n_k − r) + W + A
16//! T21 = −ω×(I ω) + (Σ ṁ_k S_k − I′) ω + r×W + M_A + M_T − ω×h − h′
17//! ω̇   = I_c⁻¹ (T21 − r × T20)
18//! a_O = T20/m − ω̇ × r
19//! ```
20//!
21//! `T` is the thrust, `W` the weight with the Coriolis force (both acting at the center of mass),
22//! `A` and `M_A` the aerodynamic force and its moment about `O`, `M_T` the thrust's moment about
23//! `O`, and `S_k = (r_e²/4) diag(1, 1, 2) + |n_k|² 1 − n_k n_kᵀ` the gyration tensor of motor
24//! `k`'s exit disc of radius `r_e` about `O`. `a_O` is `O`'s acceleration relative to the launch
25//! frame, whose rotation enters only through the Coriolis force (normal gravity already holds the
26//! centrifugal term) and not through the rotational equations (at most 7.3e-5 rad/s; the decision
27//! record on rigid-body flight, [ADR-011][adr-011]). `h = Σ_j m_j ρ_j × ρ_j′` is the angular
28//! momentum about `O`, relative to the airframe, of the parts moving along it ([`crate::shifts`]),
29//! at their centers `ρ_j`: zero for a part on the axis, and zero with no part moving.
30//!
31//! [adr-011]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-011-rigid-body-flight-equations-of-motion-aerodynamic-coupling-rail-phases-and-termination-2026-09-17
32
33use hpr_aero::{AeroModel, DragConditions, Flow, MOTOR_POD_SETS};
34use hpr_core::attitude::quaternion_derivative;
35use hpr_core::{DMat3, DQuat, DVec3};
36use hpr_design::Assembly;
37
38use crate::environment::Environment;
39use crate::error::SimError;
40use crate::shifts::Shifts;
41use crate::state::{STATE_LEN, State};
42
43/// The half-width of the central differences for the mass properties' time derivatives, s.
44const MASS_DERIVATIVE_STEP_S: f64 = 1e-4;
45
46/// Airspeeds below this (m/s) produce no aerodynamic force: the angles are undefined at rest.
47const MIN_AIRSPEED_M_S: f64 = 1e-9;
48
49/// Integration intervals shorter than this (s) take no mass-property rates: central differences
50/// over them would be rounding noise, and the motors can't change measurably within them.
51const MIN_DERIVATIVE_INTERVAL_S: f64 = 2e-5;
52
53/// Where the rocket is in its flight, which decides what it is free to do.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
55#[serde(rename_all = "snake_case")]
56#[non_exhaustive]
57pub enum Phase {
58    /// Held on the pad by gravity and friction: the state doesn't change.
59    Pad,
60    /// Guided along the rail: one degree of freedom, no rotation.
61    Rail,
62    /// Free flight: six degrees of freedom.
63    Free,
64    /// Descent under recovery devices: a point mass, with the attitude frozen where it deployed
65    /// (`docs/physics/recovery.md`).
66    Descent,
67}
68
69/// What an evaluation needs besides the time and the state.
70#[derive(Debug, Clone, Copy, PartialEq)]
71pub(crate) struct Conditions {
72    /// Which phase the flight is in, which decides the degrees of freedom and the forces.
73    pub(crate) phase: Phase,
74    /// The integration interval: which motors burn through it, and the bounds the mass-property
75    /// difference stencils stay inside.
76    pub(crate) window: (f64, f64),
77    /// The open recovery devices' drag area, m² (descent phase only).
78    pub(crate) drag_area_m2: f64,
79}
80
81/// The burning motors' cross-section, m², by the base it comes off.
82#[derive(Debug, Clone, Copy, Default)]
83struct BurningAreas {
84    /// In the airframe's aft base.
85    airframe_m2: f64,
86    /// In each pod set holding motor mounts, in [`hpr_design::Layout::motor_pod_sets`]' order,
87    /// over its pods.
88    pods_m2: [f64; MOTOR_POD_SETS],
89}
90
91/// A motor's fixed data for the equations.
92#[derive(Debug, Clone, Copy)]
93struct MotorTerms {
94    /// The nozzle exit in body axes, m.
95    nozzle_m: DVec3,
96    /// The nozzle exit radius, m (zero when the motor doesn't give one).
97    exit_radius_m: f64,
98    /// The motor's cross-section, for power-on base drag, m².
99    area_m2: f64,
100    /// Where its mount's pod set is among those holding motor mounts
101    /// ([`hpr_design::Layout::motor_pod_sets`]), if its mount is in a pod: its area then comes off
102    /// that set's pods' bases, not the airframe's.
103    pod_set: Option<usize>,
104    /// When it lights on the flight's clock, s: infinite for a motor that hasn't a known time.
105    ignition_s: f64,
106    /// The end of its thrust curve on the flight's clock, s: infinite for a motor unlit.
107    burnout_s: f64,
108}
109
110/// The mass properties at a time and their time derivatives.
111#[derive(Debug, Clone, Copy)]
112pub(crate) struct MassState {
113    pub(crate) mass_kg: f64,
114    pub(crate) mass_rate_kg_s: f64,
115    pub(crate) cg_m: DVec3,
116    pub(crate) cg_rate_m_s: DVec3,
117    pub(crate) cg_accel_m_s2: DVec3,
118    pub(crate) inertia_cg: DMat3,
119    pub(crate) inertia_o: DMat3,
120    pub(crate) inertia_o_rate: DMat3,
121    /// The moving parts' angular momentum about `O` relative to the airframe, `h`, kg·m²/s.
122    pub(crate) relative_momentum: DVec3,
123    /// Its body-frame rate, `h′`, N·m.
124    pub(crate) relative_momentum_rate: DVec3,
125}
126
127/// Everything the equations need from a flight at one instant, besides the derivative.
128#[derive(Debug, Clone, Copy)]
129pub(crate) struct Evaluation {
130    pub(crate) derivative: [f64; STATE_LEN],
131    pub(crate) mass: MassState,
132    pub(crate) cg_enu_m: DVec3,
133    pub(crate) cg_velocity_enu_m_s: DVec3,
134    pub(crate) height_above_ground_m: f64,
135    pub(crate) vertical_speed_m_s: f64,
136    pub(crate) airspeed_m_s: f64,
137    pub(crate) mach: f64,
138    pub(crate) angle_of_attack_rad: f64,
139    pub(crate) dynamic_pressure_pa: f64,
140    pub(crate) axial_coefficient: f64,
141    pub(crate) thrust_n: f64,
142    /// Force along the rail less friction, N (rail and pad phases; the liftoff condition).
143    pub(crate) rail_force_n: f64,
144    /// The body origin's acceleration relative to `L`, in `L`, m/s².
145    pub(crate) acceleration_enu_m_s2: DVec3,
146    /// The drag area of the open recovery devices, m².
147    pub(crate) recovery_drag_area_m2: f64,
148}
149
150/// The rocket's models: the whole stack's for a flight, or after a powered separation the
151/// sustainer's.
152#[derive(Debug, Clone)]
153pub(crate) struct Vehicle {
154    pub(crate) assembly: Assembly,
155    pub(crate) aero: AeroModel,
156    /// The first fin set's index among the aerodynamic components.
157    first_fin_index: usize,
158    motors: Vec<MotorTerms>,
159    /// Each motor's ignition time on the flight's clock, `None` while it has no known time.
160    ignition_s: Vec<Option<f64>>,
161    reference_area_m2: f64,
162    /// The parts that move along the airframe.
163    pub(crate) shifts: Shifts,
164}
165
166impl Vehicle {
167    /// The models of `assembly` flying on `aero`, every motor lit at launch.
168    #[cfg(test)]
169    pub(crate) fn new(assembly: Assembly, aero: AeroModel) -> Result<Self, SimError> {
170        let ignition_s = vec![Some(0.0); assembly.motors.len()];
171        Self::lit(assembly, aero, ignition_s)
172    }
173
174    /// The models of `assembly` flying on `aero`, its motors lit at `ignition_s` (one per motor,
175    /// `None` for one with no known time, which stays loaded and gives no thrust).
176    pub(crate) fn lit(
177        assembly: Assembly,
178        aero: AeroModel,
179        ignition_s: Vec<Option<f64>>,
180    ) -> Result<Self, SimError> {
181        if ignition_s.len() != assembly.motors.len() {
182            return Err(SimError::Domain {
183                what: "count of ignition times (one per motor)",
184                value: ignition_s.len() as f64,
185            });
186        }
187        // The aerodynamic model refuses more motor pod sets than the drag tells apart; this check
188        // keeps each motor's place in the list a valid index of [`BurningAreas::pods_m2`] whatever
189        // model it flies on.
190        let motor_pod_sets = assembly.layout.motor_pod_sets();
191        if motor_pod_sets.len() > MOTOR_POD_SETS {
192            return Err(SimError::Domain {
193                what: "count of pod sets holding motor mounts",
194                value: motor_pod_sets.len() as f64,
195            });
196        }
197        let motors = assembly
198            .motors
199            .iter()
200            .zip(&ignition_s)
201            .map(|(placed, ignition)| MotorTerms {
202                nozzle_m: placed.nozzle_m,
203                exit_radius_m: placed
204                    .mounted
205                    .motor
206                    .nozzle()
207                    .map_or(0.0, |nozzle| nozzle.exit_radius_m),
208                area_m2: std::f64::consts::PI * (0.5 * placed.mounted.diameter_m).powi(2),
209                // The assembly places a motor only in a mount it found, so `find` succeeds.
210                pod_set: assembly
211                    .layout
212                    .find(&placed.mount)
213                    .and_then(|(index, _)| assembly.layout.pod_set_of(index))
214                    .and_then(|set| motor_pod_sets.iter().position(|&s| s == set)),
215                ignition_s: ignition.unwrap_or(f64::INFINITY),
216                burnout_s: ignition.map_or(f64::INFINITY, |ignition_s| {
217                    ignition_s + placed.mounted.motor.burnout_time_s()
218                }),
219            })
220            .collect();
221        let reference_area_m2 = aero.reference_area_m2();
222        let first_fin_index = aero.fin_set_start();
223        Ok(Self {
224            assembly,
225            aero,
226            first_fin_index,
227            motors,
228            ignition_s,
229            reference_area_m2,
230            shifts: Shifts::default(),
231        })
232    }
233
234    /// Each motor's ignition time on the flight's clock, `None` while it has no known time.
235    pub(crate) fn ignition_s(&self) -> &[Option<f64>] {
236        &self.ignition_s
237    }
238
239    /// The time the last motor with a known ignition burns out, s (`0` with none).
240    pub(crate) fn burnout_s(&self) -> f64 {
241        self.motors
242            .iter()
243            .map(|m| m.burnout_s)
244            .filter(|t| t.is_finite())
245            .fold(0.0, f64::max)
246    }
247
248    /// The times at which the thrust curves start, have knots or end on the flight's clock,
249    /// sorted, after `0`.
250    pub(crate) fn thrust_knots_s(&self) -> Vec<f64> {
251        let mut times: Vec<f64> = self
252            .assembly
253            .motors
254            .iter()
255            .zip(&self.ignition_s)
256            .filter_map(|(placed, ignition)| ignition.map(|ignition_s| (placed, ignition_s)))
257            .flat_map(|(placed, ignition_s)| {
258                std::iter::once(ignition_s).chain(
259                    placed
260                        .mounted
261                        .motor
262                        .curve()
263                        .times_s()
264                        .iter()
265                        .map(move |t| ignition_s + t),
266                )
267            })
268            .filter(|t| *t > 0.0)
269            .collect();
270        times.sort_by(f64::total_cmp);
271        times.dedup();
272        times
273    }
274
275    /// The mass properties at `t` and their derivatives, from central differences of
276    /// `Assembly::mass_properties` kept inside `window` (the current integration interval), so
277    /// that no difference straddles a thrust-curve knot or a burnout. The parts that move along
278    /// the airframe are then put where they are, with their own rates in closed form
279    /// ([`Shifts::shift_state`]).
280    pub(crate) fn mass_state(&self, t: f64, window: (f64, f64)) -> MassState {
281        let props = |t: f64| {
282            let mp = self.assembly.mass_properties_lit(t, &self.ignition_s);
283            (
284                mp.mass_kg,
285                mp.cg_m,
286                mp.inertia_kg_m2,
287                mp.inertia_about(DVec3::ZERO),
288            )
289        };
290        let (mass_kg, cg_m, inertia_cg, inertia_o) = props(t);
291        let (a, b) = window;
292        let burning = self
293            .motors
294            .iter()
295            .any(|m| a < m.burnout_s && b > m.ignition_s);
296        let mut state = MassState {
297            mass_kg,
298            mass_rate_kg_s: 0.0,
299            cg_m,
300            cg_rate_m_s: DVec3::ZERO,
301            cg_accel_m_s2: DVec3::ZERO,
302            inertia_cg,
303            inertia_o,
304            inertia_o_rate: DMat3::ZERO,
305            relative_momentum: DVec3::ZERO,
306            relative_momentum_rate: DVec3::ZERO,
307        };
308        let mut mass_second_kg_s2 = 0.0;
309        if burning && b - a >= MIN_DERIVATIVE_INTERVAL_S {
310            let h = MASS_DERIVATIVE_STEP_S.min(0.5 * (b - a));
311            let c = t.max(a + h).min(b - h);
312            let (m_minus, r_minus, _, i_minus) = props(c - h);
313            let (m_plus, r_plus, _, i_plus) = props(c + h);
314            let (m_mid, r_mid) = if c == t {
315                (mass_kg, cg_m)
316            } else {
317                let (m, r, _, _) = props(c);
318                (m, r)
319            };
320            let offset = t - c;
321            let m_second = (m_plus - 2.0 * m_mid + m_minus) / (h * h);
322            state.mass_rate_kg_s = (m_plus - m_minus) / (2.0 * h) + offset * m_second;
323            let r_second = (r_plus - 2.0 * r_mid + r_minus) / (h * h);
324            state.cg_rate_m_s = (r_plus - r_minus) / (2.0 * h) + offset * r_second;
325            state.cg_accel_m_s2 = r_second;
326            state.inertia_o_rate = (i_plus - i_minus) * (0.5 / h);
327            mass_second_kg_s2 = m_second;
328        }
329        if !self.shifts.is_empty() {
330            self.shifts.shift_state(&mut state, mass_second_kg_s2, t);
331        }
332        state
333    }
334
335    /// Evaluates the equations of motion at `(t, y)` under `conditions`: the phase, the motors
336    /// burning as its integration interval decides, and the recovery devices open.
337    pub(crate) fn evaluate(
338        &self,
339        environment: &Environment,
340        friction_coefficient: f64,
341        conditions: Conditions,
342        t: f64,
343        y: &[f64; STATE_LEN],
344    ) -> Result<Evaluation, SimError> {
345        let Conditions {
346            phase,
347            window,
348            drag_area_m2: recovery_drag_area_m2,
349        } = conditions;
350        let state = State::from_array(y);
351        let norm = state.attitude.length();
352        if !(norm.is_finite() && norm > 0.0) {
353            return Err(SimError::Domain {
354                what: "attitude quaternion norm",
355                value: norm,
356            });
357        }
358        let q: DQuat = state.attitude / norm;
359        let to_body = q.conjugate();
360        let mass = self.mass_state(t, window);
361        let m = mass.mass_kg;
362        let r = mass.cg_m;
363        let omega = if phase == Phase::Free {
364            state.body_rate_rad_s
365        } else {
366            DVec3::ZERO
367        };
368
369        // Where the center of mass is, and the air there.
370        let v_o = state.velocity_enu_m_s;
371        let cg_enu_m = state.position_enu_m + q.mul_vec3(r);
372        let cg_velocity_enu_m_s = v_o + q.mul_vec3(omega.cross(r) + mass.cg_rate_m_s);
373        let frame = environment.earth.frame();
374        let geodetic = frame.geodetic_from_enu(cg_enu_m)?;
375        let height_above_ground_m = geodetic.height_m - frame.origin().height_m;
376        let up_ecef = DVec3::new(
377            geodetic.latitude_rad.cos() * geodetic.longitude_rad.cos(),
378            geodetic.latitude_rad.cos() * geodetic.longitude_rad.sin(),
379            geodetic.latitude_rad.sin(),
380        );
381        let up_enu = frame.ecef_from_enu_rotation().transpose() * up_ecef;
382        let vertical_speed_m_s = up_enu.dot(cg_velocity_enu_m_s);
383        let height_msl_m = geodetic.height_m - environment.geoid_undulation_m;
384        let air = environment.air_at(height_msl_m)?;
385        let wind_enu = environment.wind_enu_m_s(height_msl_m)?;
386
387        // Weight and the Coriolis force, at the center of mass.
388        let gravity_enu = environment.earth.gravity_enu_mps2(cg_enu_m)?;
389        let coriolis_enu = environment
390            .earth
391            .rotation_acceleration_enu_mps2(cg_velocity_enu_m_s);
392        let weight = to_body.mul_vec3((gravity_enu + coriolis_enu) * m);
393
394        // Thrust and the motors' mass terms. A motor burns during the interval if the interval
395        // ends by its burnout.
396        let (a, b) = window;
397        let pressure_pa = air.pressure_pa;
398        let mut thrust = DVec3::ZERO;
399        let mut thrust_moment = DVec3::ZERO;
400        let mut t03_jet = DVec3::ZERO;
401        let mut t04_jet = DVec3::ZERO;
402        let mut jet_gyration = DMat3::ZERO;
403        // The burning motors' cross-section in the airframe's base, and in each motor pod set's
404        // pods' bases.
405        let mut burning = BurningAreas::default();
406        for (terms, placed) in self.motors.iter().zip(&self.assembly.motors) {
407            if a >= terms.burnout_s || b <= terms.ignition_s {
408                continue;
409            }
410            let motor = &placed.mounted.motor;
411            // On the motor's own clock, from its ignition. Ignitions and burnouts are stop times,
412            // so the motor burns throughout this interval, and a stage evaluated on its ends
413            // (ignition or burnout, where the pressure correction switches) takes the one-sided
414            // limit inside the burn.
415            let (a, b) = (a - terms.ignition_s, b - terms.ignition_s);
416            let t = (t - terms.ignition_s)
417                .max(0.0_f64.next_up())
418                .min(motor.burnout_time_s().next_down());
419            let force = DVec3::Z * motor.thrust_at_pressure_n(t, pressure_pa);
420            thrust += force;
421            thrust_moment += terms.nozzle_m.cross(force);
422            match terms.pod_set {
423                None => burning.airframe_m2 += terms.area_m2,
424                Some(set) => burning.pods_m2[set] += terms.area_m2,
425            }
426            let mdot = -motor.state(t).mass_flow_kg_s;
427            let mddot = if b - a < MIN_DERIVATIVE_INTERVAL_S {
428                0.0
429            } else {
430                let h = MASS_DERIVATIVE_STEP_S.min(0.5 * (b - a));
431                let c = t.max(a + h).min(b - h);
432                -(motor.state(c + h).mass_flow_kg_s - motor.state(c - h).mass_flow_kg_s) / (2.0 * h)
433            };
434            let lever = terms.nozzle_m - r;
435            t03_jet += lever * (2.0 * mdot);
436            t04_jet += lever * mddot;
437            let n = terms.nozzle_m;
438            let disc = 0.25 * terms.exit_radius_m * terms.exit_radius_m;
439            let gyration = DMat3::from_diagonal(DVec3::new(disc, disc, 2.0 * disc))
440                + DMat3::from_diagonal(DVec3::splat(n.length_squared()))
441                - outer(n, n);
442            jet_gyration += gyration * mdot;
443        }
444
445        // Aerodynamics: the airframe in flight, the open canopies during the descent.
446        let aero = if phase == Phase::Descent {
447            canopy_drag(
448                &air,
449                to_body.mul_vec3(cg_velocity_enu_m_s - wind_enu),
450                recovery_drag_area_m2,
451            )
452        } else {
453            self.aerodynamics(&air, to_body.mul_vec3(v_o - wind_enu), omega, r, burning)?
454        };
455
456        // The equations.
457        let forces = aero.force + weight;
458        let t03 = t03_jet - mass.cg_rate_m_s * (2.0 * m);
459        let t04 = thrust - mass.cg_accel_m_s2 * m - mass.cg_rate_m_s * (2.0 * mass.mass_rate_kg_s)
460            + t04_jet;
461        let mut derivative = [0.0; STATE_LEN];
462        let mut acceleration_enu_m_s2 = DVec3::ZERO;
463        let mut rail_force_n = 0.0;
464        match phase {
465            Phase::Free => {
466                let (a_body, omega_dot) = free_motion(
467                    &mass,
468                    omega,
469                    [t03, t04, forces],
470                    jet_gyration,
471                    [r.cross(weight), aero.moment, thrust_moment],
472                )?;
473                acceleration_enu_m_s2 = q.mul_vec3(a_body);
474                let q_dot = quaternion_derivative(state.attitude, omega);
475                derivative = [
476                    v_o.x,
477                    v_o.y,
478                    v_o.z,
479                    acceleration_enu_m_s2.x,
480                    acceleration_enu_m_s2.y,
481                    acceleration_enu_m_s2.z,
482                    q_dot.w,
483                    q_dot.x,
484                    q_dot.y,
485                    q_dot.z,
486                    omega_dot.x,
487                    omega_dot.y,
488                    omega_dot.z,
489                ];
490            }
491            Phase::Descent => {
492                // A point mass: the canopies' drag and the weight, with no rotation. The mass
493                // terms of `T04` stay, so a device that opens while a motor burns still feels the
494                // thrust along the axis it froze at.
495                let t20 = t04 + forces;
496                acceleration_enu_m_s2 = q.mul_vec3(t20 / m);
497                derivative[..3].copy_from_slice(&v_o.to_array());
498                derivative[3..6].copy_from_slice(&acceleration_enu_m_s2.to_array());
499            }
500            Phase::Rail | Phase::Pad => {
501                // No rotation: the rail supplies the moments and the force across the axis.
502                let t20 = t04 + forces;
503                let across = (t20.x * t20.x + t20.y * t20.y).sqrt();
504                rail_force_n = t20.z - friction_coefficient * across;
505                if phase == Phase::Rail {
506                    let along = q.mul_vec3(DVec3::Z);
507                    acceleration_enu_m_s2 = along * (rail_force_n / m);
508                    derivative[..3].copy_from_slice(&v_o.to_array());
509                    derivative[3..6].copy_from_slice(&acceleration_enu_m_s2.to_array());
510                }
511            }
512        }
513
514        Ok(Evaluation {
515            derivative,
516            mass,
517            cg_enu_m,
518            cg_velocity_enu_m_s,
519            height_above_ground_m,
520            vertical_speed_m_s,
521            airspeed_m_s: aero.airspeed_m_s,
522            mach: aero.mach,
523            angle_of_attack_rad: aero.angle_of_attack_rad,
524            dynamic_pressure_pa: aero.dynamic_pressure_pa,
525            axial_coefficient: aero.axial_coefficient,
526            thrust_n: thrust.z,
527            rail_force_n,
528            acceleration_enu_m_s2,
529            recovery_drag_area_m2: if phase == Phase::Descent {
530                recovery_drag_area_m2
531            } else {
532                0.0
533            },
534        })
535    }
536
537    /// The aerodynamic force (body axes) and its moment about the nose tip.
538    ///
539    /// - The axial force is `−q A C_A z_B` from the whole rocket's drag at the center of mass's
540    ///   airspeed; it acts along the axis, so it has no moment about the nose tip.
541    /// - Each component's normal and side force comes from its own local flow: the nose tip's air
542    ///   velocity plus `ω × p` at the component's small-angle center of pressure `p` (at the
543    ///   center of mass's Mach number), which gives the aerodynamic damping in pitch and yaw.
544    ///   `C_N` acts along the crossing air `ŵ`, `C_Y` along `z_B × ŵ`, at the stations their
545    ///   moments give (`docs/physics/frames.md`).
546    /// - Fin sets use `sin α` in place of their model's `α`, so their force vanishes when the air
547    ///   comes from the tail as well as from the nose.
548    /// - With a normal-force table ([`hpr_aero::NormalForceTable`]), the table's normal force at
549    ///   the center of mass's airflow acts at its center of pressure, and each component adds only
550    ///   its force in its local flow less its force in the center of mass's: the damping, which
551    ///   stays hpr's (ADR-032).
552    /// - The rolling moment about `z_B` is `q A d (C_l0 cos α + C_lp p d/2V)` from the fins' cant
553    ///   and the roll rate `p = ω_z` at the center of mass's Mach number
554    ///   ([`hpr_aero::AeroModel::roll`]); the cant's forcing follows the axial flow.
555    fn aerodynamics(
556        &self,
557        air: &hpr_atmos::AirState,
558        air_velocity_o_body: DVec3,
559        omega: DVec3,
560        cg_m: DVec3,
561        burning: BurningAreas,
562    ) -> Result<Aerodynamics, SimError> {
563        let rho = air.density_kg_m3;
564        let sound = air.speed_of_sound_m_s;
565        let area = self.reference_area_m2;
566        let v_cg = air_velocity_o_body + omega.cross(cg_m);
567        let speed = v_cg.length();
568        let mut out = Aerodynamics {
569            airspeed_m_s: speed,
570            mach: speed / sound,
571            ..Aerodynamics::default()
572        };
573        if rho <= 0.0 || speed < MIN_AIRSPEED_M_S {
574            return Ok(out);
575        }
576        let (alpha, roll) = flow_angles(v_cg, speed);
577        out.angle_of_attack_rad = alpha;
578        let q = 0.5 * rho * speed * speed;
579        out.dynamic_pressure_pa = q;
580        let reynolds_per_m = speed / air.kinematic_viscosity_m2_s();
581        let conditions = if burning.airframe_m2 > 0.0 || burning.pods_m2.iter().any(|&a| a > 0.0) {
582            DragConditions::thrusting(reynolds_per_m, burning.airframe_m2)
583                .with_pod_motors(burning.pods_m2)
584        } else {
585            DragConditions::coasting(reynolds_per_m)
586        };
587        let flow = Flow::new(out.mach, alpha, roll);
588        let drag = self.aero.drag(&flow, &conditions)?;
589        out.axial_coefficient = drag.axial_coefficient;
590        out.force = DVec3::new(0.0, 0.0, -q * area * drag.axial_coefficient);
591
592        // The normal force's range, checked once here, before the components' stations.
593        flow.validate()?;
594        // With a normal-force table, the table gives the static normal force at the center of
595        // mass's flow, and each component only the difference its rotation makes: its force in
596        // its own local flow less its force in the center of mass's. That difference is hpr's
597        // pitch and yaw damping, which a table doesn't carry (ADR-032).
598        let table = self.aero.normal_force_table().is_some();
599        if table {
600            let normal = self.aero.normal_force(&flow)?;
601            let across = DVec3::new(roll.cos(), roll.sin(), 0.0);
602            let side = DVec3::Z.cross(across);
603            out.force += across * (normal.coefficient * q * area);
604            out.moment += side * (-normal.moment_m * q * area);
605        }
606        for index in 0..self.aero.component_count() {
607            // A fin set's station moves with Mach, and so does a body's faster than sound. It is
608            // the component's center of pressure where one model carries it, and between the two
609            // models it is not (issue #106).
610            let station = self.aero.component_station_m(index, out.mach)?;
611            let p = DVec3::new(0.0, 0.0, -station);
612            let (force, moment) =
613                self.component_force(index, air_velocity_o_body + omega.cross(p), rho, sound)?;
614            out.force += force;
615            out.moment += moment;
616            if table {
617                let (force, moment) = self.component_force(index, v_cg, rho, sound)?;
618                out.force -= force;
619                out.moment -= moment;
620            }
621        }
622        // Roll about the axis, from the fins' cant and against the roll rate:
623        // `q A d (C_l0 cos α + C_lp p d/2V)`, the damping written as `ρ V A d² C_lp p/4`. The
624        // cant meets the air as it runs along the axis, so its forcing follows the axial flow,
625        // `cos α`: none broadside, reversed tail first, as the fins' normal force follows
626        // `sin α` (ADR-011).
627        let roll = self.aero.roll(out.mach)?;
628        let d = self.aero.reference_diameter_m();
629        out.moment.z += q * area * d * roll.forcing * alpha.cos()
630            + 0.25 * rho * speed * area * d * d * roll.damping * omega.z;
631        Ok(out)
632    }
633}
634
635impl Vehicle {
636    /// Component `index`'s normal and side force (body axes) and their moment about the nose tip,
637    /// in the local air velocity `local` (body axes); zero below [`MIN_AIRSPEED_M_S`].
638    fn component_force(
639        &self,
640        index: usize,
641        local: DVec3,
642        rho: f64,
643        sound: f64,
644    ) -> Result<(DVec3, DVec3), SimError> {
645        let local_speed = local.length();
646        if local_speed < MIN_AIRSPEED_M_S {
647            return Ok((DVec3::ZERO, DVec3::ZERO));
648        }
649        let (alpha_i, roll_i) = flow_angles(local, local_speed);
650        let normal = self
651            .aero
652            .component_normal_force(index, &Flow::new(local_speed / sound, alpha_i, roll_i))?;
653        // Fin normal force follows the crossflow `V sin α`, as the body terms do: the small-angle
654        // slope times `sin α` rather than `α`, so it vanishes for axial flow either way (ADR-011).
655        let fin_scale = if index >= self.first_fin_index && alpha_i > 0.0 {
656            alpha_i.sin() / alpha_i
657        } else {
658            1.0
659        };
660        let q_i = 0.5 * rho * local_speed * local_speed * self.reference_area_m2 * fin_scale;
661        let across = DVec3::new(roll_i.cos(), roll_i.sin(), 0.0);
662        let side = DVec3::Z.cross(across);
663        Ok((
664            (across * normal.coefficient + side * normal.side_coefficient) * q_i,
665            (side * -normal.moment_m + across * normal.side_moment_m) * q_i,
666        ))
667    }
668}
669
670/// The aerodynamic results of one evaluation.
671#[derive(Debug, Clone, Copy, Default)]
672struct Aerodynamics {
673    force: DVec3,
674    moment: DVec3,
675    airspeed_m_s: f64,
676    mach: f64,
677    angle_of_attack_rad: f64,
678    dynamic_pressure_pa: f64,
679    axial_coefficient: f64,
680}
681
682/// The free-flight equations solved for the nose tip's acceleration `a_O` and the angular
683/// acceleration `ω̇`, both in body axes: `T20` from `[T03, T04, F]` (the Coriolis-like term's
684/// vector, the terms along the axis, and the external force), and `T21` from the jets' gyration
685/// and the moments about `O`, less the moving parts' `ω × h + h′`.
686fn free_motion(
687    mass: &MassState,
688    omega: DVec3,
689    [t03, t04, forces]: [DVec3; 3],
690    jet_gyration: DMat3,
691    [weight_moment, aero_moment, thrust_moment]: [DVec3; 3],
692) -> Result<(DVec3, DVec3), SimError> {
693    let (m, r) = (mass.mass_kg, mass.cg_m);
694    let t20 = -omega.cross(omega.cross(r * m)) + omega.cross(t03) + t04 + forces;
695    let mut t21 = -omega.cross(mass.inertia_o * omega)
696        + (jet_gyration - mass.inertia_o_rate) * omega
697        + weight_moment
698        + aero_moment
699        + thrust_moment;
700    if mass.relative_momentum != DVec3::ZERO || mass.relative_momentum_rate != DVec3::ZERO {
701        t21 -= omega.cross(mass.relative_momentum) + mass.relative_momentum_rate;
702    }
703    let determinant = mass.inertia_cg.determinant();
704    if !(determinant.is_finite() && determinant > 0.0) {
705        return Err(SimError::Domain {
706            what: "determinant of the inertia about the center of mass",
707            value: determinant,
708        });
709    }
710    let omega_dot = mass.inertia_cg.inverse() * (t21 - r.cross(t20));
711    Ok((t20 / m - omega_dot.cross(r), omega_dot))
712}
713
714/// The drag of the open recovery devices: `D = −½ ρ (C_D S) |v| v` on the center of mass's air
715/// velocity `air_velocity_cg_body` (body axes), with no moment about it.
716///
717/// Source: Knacke's steady drag on the drag area `C_D S` (`docs/physics/recovery.md`), the same
718/// form RocketPy's parachute phase uses (`flight.py:2770-2774`, MIT). The airframe's own drag is
719/// left out, as RocketPy leaves it out: the rocket's attitude under a canopy is not modeled.
720fn canopy_drag(
721    air: &hpr_atmos::AirState,
722    air_velocity_cg_body: DVec3,
723    drag_area_m2: f64,
724) -> Aerodynamics {
725    let speed = air_velocity_cg_body.length();
726    let rho = air.density_kg_m3;
727    let mut out = Aerodynamics {
728        airspeed_m_s: speed,
729        mach: speed / air.speed_of_sound_m_s,
730        ..Aerodynamics::default()
731    };
732    if rho <= 0.0 || speed < MIN_AIRSPEED_M_S || drag_area_m2 <= 0.0 {
733        return out;
734    }
735    let (alpha, _) = flow_angles(air_velocity_cg_body, speed);
736    out.angle_of_attack_rad = alpha;
737    out.dynamic_pressure_pa = 0.5 * rho * speed * speed;
738    out.force = air_velocity_cg_body * (-0.5 * rho * drag_area_m2 * speed);
739    out
740}
741
742/// The total angle of attack and the flow roll of a body moving at `v` (body axes) through still
743/// air: `α` between `z_B` and `v`, and `φ` the direction the air crosses the body, from `x_B`
744/// toward `y_B`, which is opposite the lateral velocity (`docs/physics/frames.md`).
745fn flow_angles(v: DVec3, _speed: f64) -> (f64, f64) {
746    // `atan2` keeps full precision near 0 and π, where `acos` loses half the digits.
747    let alpha = v.x.hypot(v.y).atan2(v.z);
748    let roll = if v.x == 0.0 && v.y == 0.0 {
749        0.0
750    } else {
751        (-v.y).atan2(-v.x)
752    };
753    (alpha, roll)
754}
755
756/// `u vᵀ`.
757fn outer(u: DVec3, v: DVec3) -> DMat3 {
758    DMat3::from_cols(u * v.x, u * v.y, u * v.z)
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764    use crate::testing::{UniformAir, analytic_environment, design};
765
766    fn valetudo() -> Vehicle {
767        let assembly = design("rocketpy-valetudo").assemble("example").unwrap();
768        let aero = AeroModel::new(&assembly.layout).unwrap();
769        Vehicle::new(assembly, aero).unwrap()
770    }
771
772    /// A motor in a pod burns into its pod's base, not the airframe's (ADR-092): Valetudo with
773    /// two pods, each its own motor tube, takes the pods' two motors apart from its own.
774    #[test]
775    fn a_pod_s_motors_burn_into_the_pod_s_base() {
776        let mut rocket = serde_json::to_value(design("rocketpy-valetudo")).unwrap();
777        let material =
778            serde_json::json!({ "name": "test", "density": { "kind": "bulk", "kg_m3": 1000.0 } });
779        let pods = serde_json::json!({
780            "id": "pods",
781            "part": { "pod_set": { "count": 2, "radial_offset_m": 0.1, "angle_rad": 0.0 } },
782            "position": { "from": "top", "aft_offset_m": 0.8 },
783            "children": [{
784                "id": "pod-tube",
785                "part": { "body_tube": {
786                    "length_m": 0.9, "outer_radius_m": 0.02, "thickness_m": 0.002,
787                    "material": material } },
788                "motor_mount": { "overhang_m": 0.0 }
789            }]
790        });
791        rocket["stages"][0]["components"][1]["children"]
792            .as_array_mut()
793            .unwrap()
794            .push(pods);
795        let mut pod_motor = rocket["configurations"][0]["motors"][0].clone();
796        pod_motor["mount"] = serde_json::json!("pod-tube");
797        rocket["configurations"][0]["motors"]
798            .as_array_mut()
799            .unwrap()
800            .push(pod_motor);
801        let rocket: hpr_design::Rocket = serde_json::from_value(rocket).unwrap();
802        let assembly = rocket.assemble("example").unwrap();
803        let aero = AeroModel::new(&assembly.layout).unwrap();
804        let vehicle = Vehicle::new(assembly, aero).unwrap();
805        let pod_sets: Vec<_> = vehicle.motors.iter().map(|m| m.pod_set).collect();
806        assert_eq!(pod_sets, [None, Some(0), Some(0)]);
807        let area = vehicle.motors[1].area_m2;
808        assert!(area > 0.0 && vehicle.motors[2].area_m2 == area);
809
810        // The engine hands the pods' area to the pods' bases: its axial coefficient is the drag
811        // model's with the two pod motors, and not with them on the airframe's base. A base's drag
812        // is linear in its area until the base runs out, so the pods are narrower than their
813        // motors here, for the two to differ.
814        let air = UniformAir::sea_level().0;
815        let speed = 50.0;
816        let v = DVec3::new(0.0, 0.0, speed);
817        let cg = vehicle.assembly.mass_properties(0.0).cg_m;
818        let axial = |[airframe_m2, pods_m2]: [f64; 2]| {
819            let areas = BurningAreas {
820                airframe_m2,
821                pods_m2: [pods_m2, 0.0, 0.0, 0.0],
822            };
823            vehicle
824                .aerodynamics(&air, v, DVec3::ZERO, cg, areas)
825                .unwrap()
826                .axial_coefficient
827        };
828        let flow = Flow::new(speed / air.speed_of_sound_m_s, 0.0, 0.0);
829        let reynolds_per_m = speed / air.kinematic_viscosity_m2_s();
830        let pods = DragConditions::thrusting(reynolds_per_m, 0.0).with_pod_motors([
831            2.0 * area,
832            0.0,
833            0.0,
834            0.0,
835        ]);
836        let expected = vehicle.aero.drag(&flow, &pods).unwrap().axial_coefficient;
837        assert_eq!(axial([0.0, 2.0 * area]), expected);
838        assert!(std::f64::consts::PI * 0.02_f64.powi(2) < area);
839        assert!((axial([2.0 * area, 0.0]) - expected).abs() > 1e-3);
840    }
841
842    /// Motors in two pod sets burn into their own sets' bases (ADR-168): Valetudo with a pair of
843    /// pods narrower than their motors and a single pod wider than its. Each set's area comes off
844    /// its own pods, so moving area between the sets moves the drag; past [`MOTOR_POD_SETS`] sets
845    /// the aerodynamic model refuses the rocket by name.
846    #[test]
847    fn two_pod_sets_motors_burn_into_their_own_bases() {
848        let material =
849            serde_json::json!({ "name": "test", "density": { "kind": "bulk", "kg_m3": 1000.0 } });
850        let pod_set = |id: &str, count: u32, radius_m: f64, angle_rad: f64| {
851            serde_json::json!({
852                "id": id,
853                "part": { "pod_set": {
854                    "count": count, "radial_offset_m": 0.15, "angle_rad": angle_rad } },
855                "position": { "from": "top", "aft_offset_m": 0.8 },
856                "children": [{
857                    "id": format!("{id}-tube"),
858                    "part": { "body_tube": {
859                        "length_m": 0.9, "outer_radius_m": radius_m, "thickness_m": 0.002,
860                        "material": material } },
861                    "motor_mount": { "overhang_m": 0.0 }
862                }]
863            })
864        };
865        let with_sets = |sets: &[(&str, u32, f64)]| {
866            let mut rocket = serde_json::to_value(design("rocketpy-valetudo")).unwrap();
867            for (k, &(id, count, radius_m)) in sets.iter().enumerate() {
868                rocket["stages"][0]["components"][1]["children"]
869                    .as_array_mut()
870                    .unwrap()
871                    .push(pod_set(id, count, radius_m, 0.3 * k as f64));
872                let mut motor = rocket["configurations"][0]["motors"][0].clone();
873                motor["mount"] = serde_json::json!(format!("{id}-tube"));
874                rocket["configurations"][0]["motors"]
875                    .as_array_mut()
876                    .unwrap()
877                    .push(motor);
878            }
879            serde_json::from_value::<hpr_design::Rocket>(rocket).unwrap()
880        };
881        let rocket = with_sets(&[("pair", 2, 0.02), ("single", 1, 0.06)]);
882        let assembly = rocket.assemble("example").unwrap();
883        let aero = AeroModel::new(&assembly.layout).unwrap();
884        let vehicle = Vehicle::new(assembly, aero).unwrap();
885        let pod_sets: Vec<_> = vehicle.motors.iter().map(|m| m.pod_set).collect();
886        assert_eq!(pod_sets, [None, Some(0), Some(0), Some(1)]);
887        let area = vehicle.motors[1].area_m2;
888        assert!(std::f64::consts::PI * 0.02_f64.powi(2) < area);
889        assert!(area < std::f64::consts::PI * 0.06_f64.powi(2) - area);
890
891        let air = UniformAir::sea_level().0;
892        let speed = 50.0;
893        let v = DVec3::new(0.0, 0.0, speed);
894        let cg = vehicle.assembly.mass_properties(0.0).cg_m;
895        let axial = |pods_m2: [f64; MOTOR_POD_SETS]| {
896            let areas = BurningAreas {
897                airframe_m2: 0.0,
898                pods_m2,
899            };
900            vehicle
901                .aerodynamics(&air, v, DVec3::ZERO, cg, areas)
902                .unwrap()
903                .axial_coefficient
904        };
905        let flow = Flow::new(speed / air.speed_of_sound_m_s, 0.0, 0.0);
906        let reynolds_per_m = speed / air.kinematic_viscosity_m2_s();
907        let split = [2.0 * area, area, 0.0, 0.0];
908        let conditions = DragConditions::thrusting(reynolds_per_m, 0.0).with_pod_motors(split);
909        let expected = vehicle
910            .aero
911            .drag(&flow, &conditions)
912            .unwrap()
913            .axial_coefficient;
914        assert_eq!(axial(split), expected);
915        // All of it in the pair, whose bases are already used up, leaves the single pod's base
916        // whole; all of it in the single pod overfills that base.
917        assert!(axial([3.0 * area, 0.0, 0.0, 0.0]) - expected > 1e-4);
918        assert!((axial([0.0, 3.0 * area, 0.0, 0.0]) - expected).abs() > 1e-4);
919        // The single pod's own area moves its base; more on the pair, already used up, moves
920        // nothing.
921        assert!(axial([2.0 * area, 0.0, 0.0, 0.0]) > expected);
922        assert_eq!(axial([5.0 * area, area, 0.0, 0.0]), expected);
923
924        let five: Vec<(String, u32, f64)> = (0..5).map(|k| (format!("set-{k}"), 1, 0.06)).collect();
925        let five: Vec<(&str, u32, f64)> = five
926            .iter()
927            .map(|(id, n, r)| (id.as_str(), *n, *r))
928            .collect();
929        let layout = with_sets(&five).assemble("example").unwrap().layout;
930        match AeroModel::new(&layout) {
931            Err(hpr_aero::AeroError::Unsupported(why)) => {
932                assert!(why.contains("motor mounts in 5 pod sets"), "{why}");
933            }
934            other => panic!("not refused: {other:?}"),
935        }
936    }
937
938    #[test]
939    fn a_supersonic_flow_takes_the_body_s_terms_at_its_mach_number() {
940        // Faster than sound the nose and the cylinder behind it take the shock-expansion shares at
941        // the flow's Mach number (ADR-034). With no rotation every component sees the same flow,
942        // so the normal force is `q A Σ C_N,i` and its moment `q A Σ C_N,i X_i`, fins at
943        // `sin α/α` (ADR-011).
944        let vehicle = valetudo();
945        let aero = &vehicle.aero;
946        let covered = aero
947            .supersonic_body()
948            .expect("Valetudo's ogive nose is pointed")
949            .covered;
950        let air = UniformAir::sea_level().0;
951        let alpha: f64 = 0.02;
952        let cg = vehicle.assembly.mass_properties(10.0).cg_m;
953        let normal = |mach: f64| {
954            let speed = mach * air.speed_of_sound_m_s;
955            let v = DVec3::new(alpha.sin(), 0.0, alpha.cos()) * speed;
956            let out = vehicle
957                .aerodynamics(&air, v, DVec3::ZERO, cg, BurningAreas::default())
958                .unwrap();
959            let q_area = 0.5 * air.density_kg_m3 * speed * speed * vehicle.reference_area_m2;
960            let (a, roll) = flow_angles(v, speed);
961            let flow = Flow::new(mach, a, roll);
962            let (mut force, mut moment, mut body) = (0.0, 0.0, 0.0);
963            for index in 0..aero.component_count() {
964                let n = aero.component_normal_force(index, &flow).unwrap();
965                let scale = if index >= vehicle.first_fin_index {
966                    a.sin() / a
967                } else {
968                    body += n.coefficient * f64::from(u8::from(index < covered));
969                    1.0
970                };
971                force += n.coefficient * scale;
972                moment += n.moment_m * scale;
973            }
974            let got = (
975                out.force.truncate().length() / q_area,
976                out.moment.truncate().length() / q_area,
977            );
978            (got, (force, moment), body)
979        };
980        for mach in [1.0, 2.0, 3.0] {
981            let (got, want, _) = normal(mach);
982            assert!(
983                (got.0 - want.0).abs() <= 1e-12 * want.0,
984                "Mach {mach}: {got:?} {want:?}"
985            );
986            assert!(
987                (got.1 - want.1).abs() <= 1e-12 * want.1,
988                "Mach {mach}: {got:?} {want:?}"
989            );
990        }
991        // The nose and cylinder lift more at Mach 2 than slender-body theory's Mach-free terms.
992        let (_, _, slender) = normal(1.0);
993        let (_, _, supersonic) = normal(2.0);
994        assert!(supersonic > 1.1 * slender, "{supersonic} vs {slender}");
995    }
996
997    #[test]
998    fn mass_rates_match_the_motor_and_stay_inside_the_interval() {
999        // The mass rate from the differences is the motor's `−F/c`; a window ending just after `t`
1000        // keeps the stencil inside it without losing accuracy.
1001        let vehicle = valetudo();
1002        let motor = &vehicle.assembly.motors[0].mounted.motor;
1003        for (t, window) in [
1004            (1.5, (1.4, 1.6)),
1005            (1.5, (1.0, 1.50001)),
1006            (0.00002, (0.0, 0.1)),
1007        ] {
1008            let state = vehicle.mass_state(t, window);
1009            let expected = -motor.state(t).mass_flow_kg_s;
1010            assert!(
1011                (state.mass_rate_kg_s - expected).abs() <= 1e-6 * expected.abs(),
1012                "t {t}: {} vs {expected}",
1013                state.mass_rate_kg_s
1014            );
1015        }
1016        let coasting = vehicle.mass_state(10.0, (9.0, 11.0));
1017        assert_eq!(coasting.mass_rate_kg_s, 0.0);
1018        assert_eq!(coasting.inertia_o_rate, DMat3::ZERO);
1019    }
1020
1021    #[test]
1022    fn rail_friction_is_coulomb_on_the_force_across_the_rail() {
1023        // In a vacuum on a rail at 60°, friction removes exactly μ g cos E from the acceleration
1024        // along the rail: the weight's share across the rail is the only normal force, and the
1025        // mass terms act along the axis.
1026        let vehicle = valetudo();
1027        let environment = analytic_environment(UniformAir::vacuum(), 9.806_65);
1028        let elevation = std::f64::consts::FRAC_PI_3;
1029        let state = State {
1030            position_enu_m: DVec3::new(0.0, 0.0, 10.0),
1031            velocity_enu_m_s: DVec3::ZERO,
1032            attitude: crate::rail::Rail {
1033                elevation_rad: elevation,
1034                ..crate::rail::Rail::vertical(3.0)
1035            }
1036            .attitude(),
1037            body_rate_rad_s: DVec3::ZERO,
1038        };
1039        let along = |mu: f64| {
1040            vehicle
1041                .evaluate(
1042                    &environment,
1043                    mu,
1044                    Conditions {
1045                        phase: Phase::Rail,
1046                        window: (1.0, 2.0),
1047                        drag_area_m2: 0.0,
1048                    },
1049                    1.5,
1050                    &state.to_array(),
1051                )
1052                .unwrap()
1053        };
1054        let (free, rubbing) = (along(0.0), along(0.3));
1055        let mass = vehicle.mass_state(1.5, (1.0, 2.0)).mass_kg;
1056        let expected = 0.3 * mass * 9.806_65 * elevation.cos();
1057        assert!((free.rail_force_n - rubbing.rail_force_n - expected).abs() < 1e-9 * expected);
1058        let direction = state.unit_attitude().mul_vec3(DVec3::Z);
1059        let difference = free.acceleration_enu_m_s2 - rubbing.acceleration_enu_m_s2;
1060        assert!((difference - direction * (expected / mass)).length() < 1e-12);
1061    }
1062
1063    #[test]
1064    fn jet_damping_matches_the_classical_form() {
1065        // For an axisymmetric rocket turning slowly about a transverse axis in a vacuum, the
1066        // equations about the nose tip reduce to the classical jet-damping result about the center
1067        // of mass: I_c ω̇ = [ṁ (r_e²/4 + l²) − İ_c] ω, with l from the center of mass to the nozzle
1068        // exit and ṁ < 0. Every term on the right is computed here independently of the equations.
1069        let vehicle = valetudo();
1070        let environment = analytic_environment(UniformAir::vacuum(), 0.0);
1071        let placed = &vehicle.assembly.motors[0];
1072        let motor = &placed.mounted.motor;
1073        let exit_radius = motor.nozzle().map_or(0.0, |n| n.exit_radius_m);
1074        assert!(exit_radius > 0.0);
1075        let t = 1.5;
1076        let window = (1.0, 2.0);
1077        let rate = 0.2;
1078        let state = State {
1079            position_enu_m: DVec3::new(0.0, 0.0, 1000.0),
1080            velocity_enu_m_s: DVec3::new(0.0, 0.0, 50.0),
1081            attitude: DQuat::IDENTITY,
1082            body_rate_rad_s: DVec3::new(0.0, rate, 0.0),
1083        };
1084        let evaluation = vehicle
1085            .evaluate(
1086                &environment,
1087                0.0,
1088                Conditions {
1089                    phase: Phase::Free,
1090                    window,
1091                    drag_area_m2: 0.0,
1092                },
1093                t,
1094                &state.to_array(),
1095            )
1096            .unwrap();
1097        let omega_dot = DVec3::from_slice(&evaluation.derivative[10..13]);
1098
1099        let props = vehicle.assembly.mass_properties(t);
1100        let h = 1e-4;
1101        let inertia_rate = (vehicle
1102            .assembly
1103            .mass_properties(t + h)
1104            .inertia_kg_m2
1105            .y_axis
1106            .y
1107            - vehicle
1108                .assembly
1109                .mass_properties(t - h)
1110                .inertia_kg_m2
1111                .y_axis
1112                .y)
1113            / (2.0 * h);
1114        let mdot = -motor.state(t).mass_flow_kg_s;
1115        let lever = placed.nozzle_m.z - props.cg_m.z;
1116        let inertia = props.inertia_kg_m2.y_axis.y;
1117        let expected = (mdot * (0.25 * exit_radius * exit_radius + lever * lever) - inertia_rate)
1118            * rate
1119            / inertia;
1120        assert!(expected < 0.0, "jet damping must damp: {expected}");
1121        assert!(
1122            (omega_dot.y - expected).abs() <= 1e-6 * expected.abs(),
1123            "{} vs {expected}",
1124            omega_dot.y
1125        );
1126        assert!(omega_dot.x.abs() + omega_dot.z.abs() < 1e-12);
1127    }
1128
1129    #[test]
1130    fn flow_angles_follow_the_frames_conventions() {
1131        // Moving along +z_B: no angle of attack.
1132        let (alpha, _) = flow_angles(DVec3::Z, 1.0);
1133        assert_eq!(alpha, 0.0);
1134        // Moving along +z_B and +x_B: the air crosses the body toward −x_B (φ = π).
1135        let v = DVec3::new(1.0, 0.0, 1.0);
1136        let (alpha, roll) = flow_angles(v, v.length());
1137        assert!((alpha - std::f64::consts::FRAC_PI_4).abs() < 1e-15);
1138        assert!((roll.abs() - std::f64::consts::PI).abs() < 1e-15);
1139        // Moving along −y_B only: the air crosses toward +y_B (φ = π/2), α = 90°.
1140        let (alpha, roll) = flow_angles(-DVec3::Y, 1.0);
1141        assert!((alpha - std::f64::consts::FRAC_PI_2).abs() < 1e-15);
1142        assert!((roll - std::f64::consts::FRAC_PI_2).abs() < 1e-15);
1143        // Tail first.
1144        let (alpha, _) = flow_angles(-DVec3::Z, 1.0);
1145        assert!((alpha - std::f64::consts::PI).abs() < 1e-15);
1146    }
1147
1148    #[test]
1149    fn outer_product_is_u_v_transpose() {
1150        let m = outer(DVec3::new(1.0, 2.0, 3.0), DVec3::new(4.0, 5.0, 6.0));
1151        assert_eq!(m.row(1), DVec3::new(8.0, 10.0, 12.0));
1152        assert_eq!(m.col(2), DVec3::new(6.0, 12.0, 18.0));
1153    }
1154}