Skip to main content

rockets_engine/
ballistics.rs

1//! Adapter to ballistics-engine 0.48's public derivative kernel.
2//!
3//! Uses the body's own Cd-versus-Mach curve, fixed diameter, standard gravity and
4//! a level world frame. SI mass and its imperial mirror are updated at every stage,
5//! so aerodynamic deceleration responds to fuel consumption. Modified point-mass
6//! effects — spin/Magnus with yaw of repose, tip-off yaw and Coriolis — are
7//! opt-in via the `with_*` builders; reference-BC and postprocessing effects
8//! (Litz spin drift) stay disabled.
9
10use crate::{finite_vec, positive, Error, FlightState, ForceModel, Result, Vec3};
11use ballistics_engine::{
12    atmosphere::{calculate_air_density_cimp, AtmoSock},
13    constants::GRAINS_TO_KG,
14    derivatives::compute_derivatives,
15    drag::DragTable,
16    wind::{WindSegment, WindSock},
17    BallisticInputs,
18};
19
20/// Atmospheric inputs supported by the upstream derivative kernel.
21#[derive(Debug, Clone, Copy)]
22pub enum Atmosphere {
23    /// Station measurements; the upstream model applies its local altitude lapse.
24    /// Humidity affects station density; this derivative path uses dry-air sound speed.
25    Standard {
26        /// Elevation of the weather station above sea level, in metres.
27        station_altitude_m: f64,
28        /// Station temperature in degrees Celsius (greater than absolute zero).
29        temperature_c: f64,
30        /// Positive station pressure in hectopascals.
31        pressure_hpa: f64,
32        /// Relative humidity as a percentage in [0, 100].
33        humidity_percent: f64,
34    },
35    /// Constant density and sound speed, independent of position.
36    /// Upstream's direct-mode sentinel requires 0 <= density < 2 and sound speed > 200.
37    Uniform {
38        /// Air density in kilograms per cubic metre; zero disables drag.
39        density_kg_m3: f64,
40        /// Sound speed in metres per second.
41        speed_of_sound_mps: f64,
42    },
43}
44
45impl Default for Atmosphere {
46    fn default() -> Self {
47        Self::Standard {
48            station_altitude_m: 0.0,
49            temperature_c: 15.0,
50            pressure_hpa: 1013.25,
51            humidity_percent: 0.0,
52        }
53    }
54}
55
56impl Atmosphere {
57    pub(crate) fn parameters(self) -> Result<(f64, f64, f64, f64)> {
58        match self {
59            Self::Standard {
60                station_altitude_m,
61                temperature_c,
62                pressure_hpa,
63                humidity_percent,
64            } => {
65                if !station_altitude_m.is_finite()
66                    || !temperature_c.is_finite()
67                    || temperature_c <= -273.15
68                    || !humidity_percent.is_finite()
69                    || !(0.0..=100.0).contains(&humidity_percent)
70                {
71                    return Err(Error::invalid("standard atmosphere", "requires finite altitude, temperature above absolute zero and humidity in [0, 100]"));
72                }
73                positive(pressure_hpa, "pressure_hpa")?;
74                let ratio =
75                    calculate_air_density_cimp(temperature_c, pressure_hpa, humidity_percent)
76                        / 1.225;
77                positive(ratio, "station density ratio")?;
78                Ok((station_altitude_m, temperature_c, pressure_hpa, ratio))
79            }
80            Self::Uniform {
81                density_kg_m3,
82                speed_of_sound_mps,
83            } => {
84                if !density_kg_m3.is_finite()
85                    || !(0.0..2.0).contains(&density_kg_m3)
86                    || !speed_of_sound_mps.is_finite()
87                    || speed_of_sound_mps <= 200.0
88                {
89                    return Err(Error::invalid(
90                        "uniform atmosphere",
91                        "upstream requires density in [0, 2) and sound speed > 200 m/s",
92                    ));
93                }
94                Ok((density_kg_m3, speed_of_sound_mps, 0.0, 0.0))
95            }
96        }
97    }
98}
99
100/// Local air density (kg/m³) and sound speed (m/s) for an atmosphere and its
101/// kernel parameter tuple, shared with the reduced-order dynamics models.
102pub(crate) fn local_air(
103    atmosphere: Atmosphere,
104    atmos_params: (f64, f64, f64, f64),
105    altitude_m: f64,
106) -> (f64, f64) {
107    match atmosphere {
108        Atmosphere::Standard { .. } => {
109            let (base_alt, temp_c, press_hpa, ratio) = atmos_params;
110            ballistics_engine::atmosphere::get_local_atmosphere(
111                altitude_m, base_alt, temp_c, press_hpa, ratio,
112            )
113        }
114        Atmosphere::Uniform { .. } => {
115            let (density, speed_of_sound, _, _) = atmos_params;
116            (density, speed_of_sound)
117        }
118    }
119}
120
121/// Validated Mach table with nonnegative values, shared by the lift and
122/// weathercock parameter curves.
123pub(crate) fn mach_table(pairs: Vec<(f64, f64)>, name: &'static str) -> Result<DragTable> {
124    if pairs.len() < 2 {
125        return Err(Error::invalid(name, "needs at least two Mach points"));
126    }
127    for (i, (mach, value)) in pairs.iter().enumerate() {
128        if !mach.is_finite() || *mach < 0.0 {
129            return Err(Error::invalid(name, "Mach must be finite and nonnegative"));
130        }
131        if i > 0 && *mach <= pairs[i - 1].0 {
132            return Err(Error::invalid(name, "Mach must strictly ascend"));
133        }
134        if !value.is_finite() || *value < 0.0 {
135            return Err(Error::invalid(
136                name,
137                "values must be finite and nonnegative",
138            ));
139        }
140    }
141    let (mach_values, cd_values) = pairs.into_iter().unzip();
142    Ok(DragTable::new(mach_values, cd_values))
143}
144
145/// Gravity and aerodynamic drag delegated to ballistics-engine's force kernel.
146#[derive(Debug, Clone)]
147pub struct BallisticForces {
148    inputs: BallisticInputs,
149    atmos_params: (f64, f64, f64, f64),
150    atmosphere: Atmosphere,
151    wind_mps: Vec3,
152    turbulence: Option<Turbulence>,
153    omega_rps: Option<Vec3>,
154    body_lift: Option<BodyLift>,
155    atmo_sock: Option<AtmoSock>,
156    wind_sock: Option<WindSock>,
157}
158
159/// Number of sinusoid components per axis in the turbulence gust model.
160const TURBULENCE_COMPONENTS: usize = 8;
161
162/// Deterministic frozen-field turbulence: a multisine gust per axis with a
163/// pink (1/f power) spectrum, fully fixed by its seed. Stateless, so RK4
164/// substeps, snapshots and replays all see identical wind at identical times.
165#[derive(Debug, Clone)]
166pub(crate) struct Turbulence {
167    /// Per-axis (angular frequency rad/s, amplitude m/s, phase rad) components.
168    axes: [[(f64, f64, f64); TURBULENCE_COMPONENTS]; 3],
169}
170
171impl Turbulence {
172    /// SplitMix64 finalizer as a stateless hash → [0, 1).
173    fn hash01(mut z: u64) -> f64 {
174        z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
175        z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
176        let z = z ^ (z >> 31);
177        (z >> 11) as f64 / (1u64 << 53) as f64
178    }
179
180    /// Gusts with RMS `sigma_mps` per axis and energy concentrated near the
181    /// period `tau_s` (frequencies log-spaced over [1/(10τ), 10/τ]).
182    pub(crate) fn new(sigma_mps: f64, tau_s: f64, seed: u64) -> Result<Self> {
183        if !sigma_mps.is_finite() || sigma_mps < 0.0 {
184            return Err(Error::invalid("turbulence sigma", "must be nonnegative"));
185        }
186        if !tau_s.is_finite() || tau_s <= 0.0 {
187            return Err(Error::invalid("turbulence tau", "must be positive"));
188        }
189        let lo = 0.1 / tau_s;
190        let ratio = 100.0_f64;
191        let step = ratio.powf(1.0 / (TURBULENCE_COMPONENTS - 1) as f64);
192        let norm: f64 = (0..TURBULENCE_COMPONENTS)
193            .map(|i| 1.0 / (lo * step.powi(i as i32)))
194            .sum();
195        let scale = (2.0 * sigma_mps * sigma_mps / norm).sqrt();
196        let mut axes = [[(0.0, 0.0, 0.0); TURBULENCE_COMPONENTS]; 3];
197        for (axis, comps) in axes.iter_mut().enumerate() {
198            for (i, comp) in comps.iter_mut().enumerate() {
199                let omega = lo * step.powi(i as i32);
200                let amplitude = scale / omega.sqrt();
201                let phase = 2.0
202                    * std::f64::consts::PI
203                    * Self::hash01(
204                        seed ^ 0x9E3779B97F4A7C15u64.wrapping_mul(1 + axis as u64 * 64 + i as u64),
205                    );
206                *comp = (omega, amplitude, phase);
207            }
208        }
209        Ok(Self { axes })
210    }
211
212    pub(crate) fn gust_vec(&self, t: f64) -> Vec3 {
213        Vec3::new(
214            self.axes[0]
215                .iter()
216                .map(|(w, a, p)| a * (w * t + p).sin())
217                .sum(),
218            self.axes[1]
219                .iter()
220                .map(|(w, a, p)| a * (w * t + p).sin())
221                .sum(),
222            self.axes[2]
223                .iter()
224                .map(|(w, a, p)| a * (w * t + p).sin())
225                .sum(),
226        )
227    }
228}
229
230/// Validated angle-of-attack aerodynamics: normal-force slope and induced-drag
231/// factor, each piecewise-linear in Mach via the upstream table type.
232#[derive(Debug, Clone)]
233struct BodyLift {
234    /// Normal force coefficient slope per radian of angle of attack, vs Mach.
235    cna_per_rad: DragTable,
236    /// Induced drag increment per radian squared (Cd += k·α²), vs Mach.
237    induced_k_per_rad2: DragTable,
238}
239
240/// Earth rotation rate, rad/s (McCoy-level precision, matching the upstream kernel).
241const OMEGA_EARTH_RPS: f64 = 7.2921159e-5;
242
243impl BallisticForces {
244    /// Build using the vehicle's actual Cd curve, not a G-model reference curve or BC.
245    /// At least two ascending, nonnegative Mach values and positive Cd values are required.
246    /// Wind is a world-frame air velocity vector (positive X is a tailwind).
247    pub fn new(
248        diameter_m: f64,
249        cd_vs_mach: Vec<(f64, f64)>,
250        atmosphere: Atmosphere,
251        wind_mps: Vec3,
252    ) -> Result<Self> {
253        positive(diameter_m, "diameter_m")?;
254        let diameter_in = diameter_m / 0.0254;
255        positive(diameter_in * diameter_in, "diameter area")?;
256        if !finite_vec(&wind_mps) {
257            return Err(Error::invalid("wind_mps", "must be finite"));
258        }
259        let (mach, cd) = cd_vs_mach.into_iter().unzip();
260        let drag = DragTable::try_new(mach, cd).map_err(Error::Model)?;
261        let inputs = BallisticInputs {
262            bullet_diameter: diameter_m,
263            caliber_inches: diameter_in,
264            bullet_length: diameter_m,
265            custom_drag_table: Some(drag),
266            custom_drag_reference: false,
267            altitude: 0.0,
268            shooting_angle: 0.0,
269            enable_advanced_effects: false,
270            enable_magnus: false,
271            enable_coriolis: false,
272            use_enhanced_spin_drift: false,
273            tipoff_yaw: 0.0,
274            ..BallisticInputs::default()
275        };
276        Ok(Self {
277            inputs,
278            atmos_params: atmosphere.parameters()?,
279            atmosphere,
280            wind_mps,
281            turbulence: None,
282            omega_rps: None,
283            body_lift: None,
284            atmo_sock: None,
285            wind_sock: None,
286        })
287    }
288
289    /// Apply a positive multiplier to the entire Cd curve. Default is 1.0.
290    pub fn with_drag_scale(mut self, scale: f64) -> Result<Self> {
291        positive(scale, "drag scale")?;
292        if self
293            .inputs
294            .custom_drag_table
295            .as_ref()
296            .unwrap()
297            .cd_values
298            .iter()
299            .any(|cd| !(cd * scale).is_finite())
300        {
301            return Err(Error::invalid("drag scale", "scaled Cd must remain finite"));
302        }
303        self.inputs.cd_scale = scale;
304        Ok(self)
305    }
306
307    /// Change uniform world-frame wind between public integration steps.
308    pub fn set_wind(&mut self, wind_mps: Vec3) -> Result<()> {
309        if !finite_vec(&wind_mps) {
310            return Err(Error::invalid("wind_mps", "must be finite"));
311        }
312        self.wind_mps = wind_mps;
313        Ok(())
314    }
315
316    /// Modified point-mass spin: enable the kernel's Magnus force and yaw of
317    /// repose for a spinning body. `twist_rate_in` is the effective twist in
318    /// inches per turn — rifling language upstream; for a fin-canted rocket it
319    /// is the roll rate the cant produces per unit velocity. `body_length_m` is
320    /// the full airframe length (the stability correlations need it; [`Self::new`]
321    /// only knows the diameter). `right_hand` selects the roll direction.
322    ///
323    /// The correlations are McCoy bullet shapes, so treat magnitudes as
324    /// approximate for blunt or finned bodies; signs and scaling are physical.
325    pub fn with_spin(
326        mut self,
327        twist_rate_in: f64,
328        body_length_m: f64,
329        right_hand: bool,
330    ) -> Result<Self> {
331        positive(twist_rate_in, "twist_rate_in")?;
332        positive(body_length_m, "body_length_m")?;
333        self.inputs.twist_rate = twist_rate_in;
334        self.inputs.is_twist_right = right_hand;
335        self.inputs.bullet_length = body_length_m;
336        self.inputs.enable_magnus = true;
337        Ok(self)
338    }
339
340    /// Modified point-mass Coriolis deflection, for planet-scale arcs.
341    /// `latitude_deg` is the launch latitude in [-90, 90]; `shot_azimuth_deg` is
342    /// the compass bearing fired along, 0 = north, 90 = east. Do not combine
343    /// with an inertial-frame [`crate::CentralGravity`] model: Coriolis is the
344    /// rotating-frame term and would double-count Earth rotation.
345    pub fn with_coriolis(mut self, latitude_deg: f64, shot_azimuth_deg: f64) -> Result<Self> {
346        if !latitude_deg.is_finite() || !(-90.0..=90.0).contains(&latitude_deg) {
347            return Err(Error::invalid(
348                "latitude_deg",
349                "must be finite and in [-90, 90]",
350            ));
351        }
352        if !shot_azimuth_deg.is_finite() {
353            return Err(Error::invalid("shot_azimuth_deg", "must be finite"));
354        }
355        let lat = latitude_deg.to_radians();
356        let az = shot_azimuth_deg.to_radians();
357        self.omega_rps = Some(Vec3::new(
358            OMEGA_EARTH_RPS * lat.cos() * az.cos(),  // X: downrange
359            OMEGA_EARTH_RPS * lat.sin(),             // Y: vertical
360            -OMEGA_EARTH_RPS * lat.cos() * az.sin(), // Z: lateral
361        ));
362        self.inputs.latitude = Some(latitude_deg);
363        self.inputs.shot_azimuth = az;
364        self.inputs.enable_coriolis = true;
365        Ok(self)
366    }
367
368    /// Launch tip-off: an initial yaw in radians that decays exponentially with
369    /// downrange distance over `decay_distance_m`. Models the rail/launcher
370    /// disturbance; it perturbs the effective angle of attack early in flight.
371    pub fn with_tipoff_yaw(mut self, yaw_rad: f64, decay_distance_m: f64) -> Result<Self> {
372        if !yaw_rad.is_finite() {
373            return Err(Error::invalid("tipoff yaw_rad", "must be finite"));
374        }
375        positive(decay_distance_m, "tipoff decay_distance_m")?;
376        self.inputs.tipoff_yaw = yaw_rad;
377        self.inputs.tipoff_decay_distance = decay_distance_m;
378        Ok(self)
379    }
380
381    /// Angle-of-attack aerodynamics without attitude integration: a quasi-static
382    /// normal force and induced drag from the commanded axis's angle to the
383    /// relative wind.
384    ///
385    /// `cna_vs_mach` is the normal-force coefficient slope per radian of angle of
386    /// attack (body + fins) versus Mach; `induced_k_vs_mach` adds Cd += k·α².
387    /// Both need at least two strictly ascending Mach points and nonnegative,
388    /// finite values. The angle of attack is measured each RK stage between the
389    /// solver-resolved thrust axis and the wind-relative velocity, so a guided
390    /// turn at angle of attack both turns harder (body lift) and pays drag for it.
391    /// Reference area derives from the diameter passed to [`Self::new`].
392    pub fn with_body_lift(
393        mut self,
394        cna_vs_mach: Vec<(f64, f64)>,
395        induced_k_vs_mach: Vec<(f64, f64)>,
396    ) -> Result<Self> {
397        let table = |pairs: Vec<(f64, f64)>, name: &'static str| -> Result<DragTable> {
398            mach_table(pairs, name)
399        };
400        self.body_lift = Some(BodyLift {
401            cna_per_rad: table(cna_vs_mach, "cna_vs_mach")?,
402            induced_k_per_rad2: table(induced_k_vs_mach, "induced_k_vs_mach")?,
403        });
404        Ok(self)
405    }
406
407    /// Change the effective twist between public steps, for a caller-integrated
408    /// roll model (see [`crate::RollDynamics`]). Pair with [`Self::with_spin`]
409    /// for the body length and handedness; the rate must stay positive.
410    pub fn set_twist(&mut self, twist_rate_in: f64) -> Result<()> {
411        positive(twist_rate_in, "twist_rate_in")?;
412        self.inputs.twist_rate = twist_rate_in;
413        Ok(())
414    }
415
416    /// Downrange-segmented atmosphere (upstream `AtmoSock`): station-referenced
417    /// zones, each `(temperature_c, pressure_hpa, humidity_percent, until_downrange_m)`.
418    /// The zone is selected by downrange distance `position.x`; the base atmosphere
419    /// must be [`Atmosphere::Standard`], and each zone's humidity applies to its
420    /// station density while this derivative path uses dry-air sound speed.
421    pub fn with_atmosphere_zones(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
422        if !matches!(self.atmosphere, Atmosphere::Standard { .. }) {
423            return Err(Error::invalid(
424                "atmosphere zones",
425                "require the Standard atmosphere, not Uniform",
426            ));
427        }
428        if zones.is_empty() {
429            return Err(Error::invalid(
430                "atmosphere zones",
431                "needs at least one zone",
432            ));
433        }
434        for (temp_c, pressure_hpa, humidity, until_m) in &zones {
435            if !temp_c.is_finite()
436                || !pressure_hpa.is_finite()
437                || *pressure_hpa <= 0.0
438                || !humidity.is_finite()
439                || !(0.0..=100.0).contains(humidity)
440                || !until_m.is_finite()
441                || *until_m <= 0.0
442            {
443                return Err(Error::invalid(
444                    "atmosphere zone",
445                    "needs finite temperature, positive pressure, humidity in [0, 100] and positive until_m",
446                ));
447            }
448        }
449        self.atmo_sock = Some(AtmoSock::new(zones));
450        Ok(self)
451    }
452
453    /// Downrange-segmented wind (upstream `WindSock`). Each zone is
454    /// `(speed_mps, angle_deg, until_downrange_m, vertical_mps)`, with the angle
455    /// following the upstream wind-FROM convention (0° = headwind, 90° = from the
456    /// right). Vertical is positive updraft. Zones are evaluated statelessly at
457    /// each stage's `position.x`, so wind can change mid-integration.
458    /// Overrides the uniform wind set in [`Self::new`]/[`Self::set_wind`].
459    pub fn with_wind_segments(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
460        if zones.is_empty() {
461            return Err(Error::invalid(
462                "wind segments",
463                "needs at least one segment",
464            ));
465        }
466        let segments: Vec<WindSegment> = zones
467            .into_iter()
468            .map(
469                |(speed_mps, angle_deg, until_m, vertical_mps)| WindSegment {
470                    speed_kmh: speed_mps * 3.6,
471                    angle_deg,
472                    until_m,
473                    vertical_mps,
474                },
475            )
476            .collect();
477        let sock = WindSock::try_new(segments)
478            .map_err(|e| Error::Model(format!("invalid wind segments: {e:?}")))?;
479        self.wind_sock = Some(sock);
480        Ok(self)
481    }
482
483    /// Add deterministic frozen-field turbulence: multisine gusts with a
484    /// pink spectrum, RMS `sigma_mps` per axis and energy concentrated near
485    /// the period `tau_s`. `seed` fixes the phases (dispersion reseeds per
486    /// run), so runs are exactly reproducible.
487    pub fn with_turbulence(mut self, sigma_mps: f64, tau_s: f64, seed: u64) -> Result<Self> {
488        self.turbulence = Some(Turbulence::new(sigma_mps, tau_s, seed)?);
489        Ok(self)
490    }
491
492    /// Wind at a stage: segmented downrange wind if configured, else the
493    /// uniform vector; plus frozen-field turbulence if configured.
494    fn effective_wind(&self, state: &FlightState) -> Vec3 {
495        let base = match &self.wind_sock {
496            Some(sock) => sock.vector_for_range_stateless(state.position_m.x),
497            None => self.wind_mps,
498        };
499        match &self.turbulence {
500            Some(t) => base + t.gust_vec(state.time_s),
501            None => base,
502        }
503    }
504
505    /// Local air density (kg/m³) and sound speed (m/s) at a geometric altitude,
506    /// consistent with the atmosphere handed to the kernel.
507    fn local_air(&self, altitude_m: f64) -> (f64, f64) {
508        local_air(self.atmosphere, self.atmos_params, altitude_m)
509    }
510
511    /// Quasi-static AoA force increment: normal force toward the body-axis side of
512    /// the velocity, plus induced drag along it. Zero when aligned or in still air.
513    fn body_lift_accel(
514        &self,
515        state: &FlightState,
516        mass_kg: f64,
517        wind_mps: Vec3,
518        body_axis: Vec3,
519    ) -> Result<Vec3> {
520        let Some(lift) = &self.body_lift else {
521            return Ok(Vec3::zeros());
522        };
523        let v_air = state.velocity_mps - wind_mps;
524        let speed = v_air.norm();
525        // Degenerate cases: no relative wind, or a zero/ill-posed axis → no increment.
526        if speed < 1e-12 || body_axis.amax() == 0.0 {
527            return Ok(Vec3::zeros());
528        }
529        let v_hat = v_air / speed;
530        let axis = body_axis / body_axis.norm();
531        // Angle of attack from the axis to the relative wind.
532        let perp = axis - axis.dot(&v_hat) * v_hat;
533        let sin_alpha = perp.norm();
534        let cos_alpha = axis.dot(&v_hat).clamp(-1.0, 1.0);
535        let alpha = sin_alpha.atan2(cos_alpha);
536        if alpha < 1e-12 {
537            return Ok(Vec3::zeros());
538        }
539        let (rho, sound) = self.local_air(state.position_m.y);
540        if rho <= 0.0 || sound <= 0.0 {
541            return Ok(Vec3::zeros());
542        }
543        let mach = speed / sound;
544        let q = 0.5 * rho * speed * speed;
545        let area = std::f64::consts::FRAC_PI_4 * self.inputs.bullet_diameter.powi(2);
546        let normal_force = q * area * lift.cna_per_rad.interpolate(mach) * alpha;
547        let induced_drag = q * area * lift.induced_k_per_rad2.interpolate(mach) * alpha * alpha;
548        let accel = (normal_force * (perp / sin_alpha) - induced_drag * v_hat) / mass_kg;
549        if !finite_vec(&accel) {
550            return Err(Error::NumericalFailure(
551                "body lift returned non-finite acceleration",
552            ));
553        }
554        Ok(accel)
555    }
556
557    /// Axis-aware counterpart to [`Self::acceleration_in_wind`], for wrappers that
558    /// sample their own wind but want the body-lift increment.
559    pub fn acceleration_with_axis_in_wind(
560        &self,
561        state: &FlightState,
562        mass_kg: f64,
563        wind_mps: Vec3,
564        body_axis: Vec3,
565    ) -> Result<Vec3> {
566        let base = self.acceleration_in_wind(state, mass_kg, wind_mps)?;
567        Ok(base + self.body_lift_accel(state, mass_kg, wind_mps, body_axis)?)
568    }
569
570    /// Evaluate using externally sampled wind, for a spatial or time-varying force wrapper.
571    pub fn acceleration_in_wind(
572        &self,
573        state: &FlightState,
574        mass_kg: f64,
575        wind_mps: Vec3,
576    ) -> Result<Vec3> {
577        state.validate()?;
578        positive(mass_kg, "mass_kg")?;
579        if !finite_vec(&wind_mps) {
580            return Err(Error::invalid("wind_mps", "must be finite"));
581        }
582        let mut inputs = self.inputs.clone();
583        inputs.bullet_mass = mass_kg;
584        inputs.weight_grains = mass_kg / GRAINS_TO_KG;
585        positive(inputs.weight_grains, "mass in grains")?;
586        let derivatives = compute_derivatives(
587            state.position_m,
588            state.velocity_mps,
589            &inputs,
590            wind_mps,
591            self.atmos_params,
592            inputs.bc_value,
593            self.omega_rps,
594            state.time_s,
595            self.atmo_sock.as_ref(),
596        );
597        let acceleration = Vec3::new(derivatives[3], derivatives[4], derivatives[5]);
598        if !finite_vec(&acceleration) {
599            return Err(Error::NumericalFailure(
600                "ballistics force model returned non-finite acceleration",
601            ));
602        }
603        Ok(acceleration)
604    }
605}
606
607impl ForceModel for BallisticForces {
608    fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3> {
609        self.acceleration_in_wind(state, mass_kg, self.effective_wind(state))
610    }
611
612    fn acceleration_with_axis(
613        &self,
614        state: &FlightState,
615        mass_kg: f64,
616        body_axis: Vec3,
617    ) -> Result<Vec3> {
618        self.acceleration_with_axis_in_wind(state, mass_kg, self.effective_wind(state), body_axis)
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625
626    #[test]
627    fn turbulence_rms_matches_sigma() {
628        let gust = Turbulence::new(2.5, 2.0, 42).unwrap();
629        // Long-run statistics per axis: mean ~0, RMS ~ sigma.
630        for axis in 0..3 {
631            let samples: Vec<f64> = (0..20_000)
632                .map(|i| gust.gust_vec(i as f64 * 0.05)[axis])
633                .collect();
634            let mean = samples.iter().sum::<f64>() / samples.len() as f64;
635            let var =
636                samples.iter().map(|s| (s - mean).powi(2)).sum::<f64>() / samples.len() as f64;
637            assert!(mean.abs() < 0.1, "axis {axis} mean {mean}");
638            assert!(
639                (var.sqrt() - 2.5).abs() / 2.5 < 0.05,
640                "axis {axis} rms {}",
641                var.sqrt()
642            );
643        }
644    }
645
646    #[test]
647    fn turbulence_is_deterministic_per_seed() {
648        let a = Turbulence::new(1.0, 2.0, 7).unwrap();
649        let b = Turbulence::new(1.0, 2.0, 7).unwrap();
650        let c = Turbulence::new(1.0, 2.0, 8).unwrap();
651        for i in 0..100 {
652            let t = i as f64 * 0.1;
653            assert_eq!(a.gust_vec(t), b.gust_vec(t));
654        }
655        assert_ne!(a.gust_vec(3.3), c.gust_vec(3.3));
656    }
657
658    #[test]
659    fn turbulence_validates_its_numbers() {
660        assert!(Turbulence::new(-1.0, 2.0, 0).is_err());
661        assert!(Turbulence::new(1.0, 0.0, 0).is_err());
662        assert!(Turbulence::new(f64::NAN, 2.0, 0).is_err());
663    }
664
665    #[test]
666    fn turbulence_adds_to_the_effective_wind() {
667        let forces = BallisticForces::new(
668            0.05,
669            vec![(0.0, 0.4), (3.0, 0.4)],
670            Atmosphere::default(),
671            Vec3::new(3.0, 0.0, 0.0),
672        )
673        .unwrap()
674        .with_turbulence(2.0, 2.0, 42)
675        .unwrap();
676        let state = FlightState {
677            time_s: 1.7,
678            position_m: Vec3::zeros(),
679            velocity_mps: Vec3::zeros(),
680        };
681        let expected = Vec3::new(3.0, 0.0, 0.0) + forces.turbulence.as_ref().unwrap().gust_vec(1.7);
682        assert_eq!(forces.effective_wind(&state), expected);
683    }
684}