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    /// An arbitrary wind field (winds-engine), sampled per state; overrides
153    /// the uniform vector and any zone ladder.
154    wind_field: Option<SharedWindField>,
155    turbulence: Option<Turbulence>,
156    omega_rps: Option<Vec3>,
157    body_lift: Option<BodyLift>,
158    atmo_sock: Option<AtmoSock>,
159    wind_sock: Option<WindSock>,
160}
161
162/// A winds-engine field shared (Arc) between the force model, the
163/// weathercock and the recovery descent, with `Debug` and `Clone` so the
164/// containing models keep their derives.
165#[derive(Clone)]
166pub(crate) struct SharedWindField(
167    pub(crate) std::sync::Arc<dyn winds_engine::WindField + Send + Sync>,
168);
169
170impl std::fmt::Debug for SharedWindField {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        f.write_str("SharedWindField(..)")
173    }
174}
175
176/// Number of sinusoid components per axis in the turbulence gust model.
177const TURBULENCE_COMPONENTS: usize = 8;
178
179/// Deterministic frozen-field turbulence: a multisine gust per axis with a
180/// pink (1/f power) spectrum, fully fixed by its seed. Stateless, so RK4
181/// substeps, snapshots and replays all see identical wind at identical times.
182#[derive(Debug, Clone)]
183pub(crate) struct Turbulence {
184    /// Per-axis (angular frequency rad/s, amplitude m/s, phase rad) components.
185    axes: [[(f64, f64, f64); TURBULENCE_COMPONENTS]; 3],
186}
187
188impl Turbulence {
189    /// SplitMix64 finalizer as a stateless hash → [0, 1).
190    fn hash01(mut z: u64) -> f64 {
191        z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
192        z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
193        let z = z ^ (z >> 31);
194        (z >> 11) as f64 / (1u64 << 53) as f64
195    }
196
197    /// Gusts with RMS `sigma_mps` per axis and energy concentrated near the
198    /// period `tau_s` (frequencies log-spaced over [1/(10τ), 10/τ]).
199    pub(crate) fn new(sigma_mps: f64, tau_s: f64, seed: u64) -> Result<Self> {
200        if !sigma_mps.is_finite() || sigma_mps < 0.0 {
201            return Err(Error::invalid("turbulence sigma", "must be nonnegative"));
202        }
203        if !tau_s.is_finite() || tau_s <= 0.0 {
204            return Err(Error::invalid("turbulence tau", "must be positive"));
205        }
206        let lo = 0.1 / tau_s;
207        let ratio = 100.0_f64;
208        let step = ratio.powf(1.0 / (TURBULENCE_COMPONENTS - 1) as f64);
209        let norm: f64 = (0..TURBULENCE_COMPONENTS)
210            .map(|i| 1.0 / (lo * step.powi(i as i32)))
211            .sum();
212        let scale = (2.0 * sigma_mps * sigma_mps / norm).sqrt();
213        let mut axes = [[(0.0, 0.0, 0.0); TURBULENCE_COMPONENTS]; 3];
214        for (axis, comps) in axes.iter_mut().enumerate() {
215            for (i, comp) in comps.iter_mut().enumerate() {
216                let omega = lo * step.powi(i as i32);
217                let amplitude = scale / omega.sqrt();
218                let phase = 2.0
219                    * std::f64::consts::PI
220                    * Self::hash01(
221                        seed ^ 0x9E3779B97F4A7C15u64.wrapping_mul(1 + axis as u64 * 64 + i as u64),
222                    );
223                *comp = (omega, amplitude, phase);
224            }
225        }
226        Ok(Self { axes })
227    }
228
229    pub(crate) fn gust_vec(&self, t: f64) -> Vec3 {
230        Vec3::new(
231            self.axes[0]
232                .iter()
233                .map(|(w, a, p)| a * (w * t + p).sin())
234                .sum(),
235            self.axes[1]
236                .iter()
237                .map(|(w, a, p)| a * (w * t + p).sin())
238                .sum(),
239            self.axes[2]
240                .iter()
241                .map(|(w, a, p)| a * (w * t + p).sin())
242                .sum(),
243        )
244    }
245}
246
247/// Validated angle-of-attack aerodynamics: normal-force slope and induced-drag
248/// factor, each piecewise-linear in Mach via the upstream table type.
249#[derive(Debug, Clone)]
250struct BodyLift {
251    /// Normal force coefficient slope per radian of angle of attack, vs Mach.
252    cna_per_rad: DragTable,
253    /// Induced drag increment per radian squared (Cd += k·α²), vs Mach.
254    induced_k_per_rad2: DragTable,
255}
256
257/// Earth rotation rate, rad/s (McCoy-level precision, matching the upstream kernel).
258const OMEGA_EARTH_RPS: f64 = 7.2921159e-5;
259
260impl BallisticForces {
261    /// Build using the vehicle's actual Cd curve, not a G-model reference curve or BC.
262    /// At least two ascending, nonnegative Mach values and positive Cd values are required.
263    /// Wind is a world-frame air velocity vector (positive X is a tailwind).
264    pub fn new(
265        diameter_m: f64,
266        cd_vs_mach: Vec<(f64, f64)>,
267        atmosphere: Atmosphere,
268        wind_mps: Vec3,
269    ) -> Result<Self> {
270        positive(diameter_m, "diameter_m")?;
271        let diameter_in = diameter_m / 0.0254;
272        positive(diameter_in * diameter_in, "diameter area")?;
273        if !finite_vec(&wind_mps) {
274            return Err(Error::invalid("wind_mps", "must be finite"));
275        }
276        let (mach, cd) = cd_vs_mach.into_iter().unzip();
277        let drag = DragTable::try_new(mach, cd).map_err(Error::Model)?;
278        let inputs = BallisticInputs {
279            bullet_diameter: diameter_m,
280            caliber_inches: diameter_in,
281            bullet_length: diameter_m,
282            custom_drag_table: Some(drag),
283            custom_drag_reference: false,
284            altitude: 0.0,
285            shooting_angle: 0.0,
286            enable_advanced_effects: false,
287            enable_magnus: false,
288            enable_coriolis: false,
289            use_enhanced_spin_drift: false,
290            tipoff_yaw: 0.0,
291            ..BallisticInputs::default()
292        };
293        Ok(Self {
294            inputs,
295            atmos_params: atmosphere.parameters()?,
296            atmosphere,
297            wind_mps,
298            wind_field: None,
299            turbulence: None,
300            omega_rps: None,
301            body_lift: None,
302            atmo_sock: None,
303            wind_sock: None,
304        })
305    }
306
307    /// Apply a positive multiplier to the entire Cd curve. Default is 1.0.
308    pub fn with_drag_scale(mut self, scale: f64) -> Result<Self> {
309        positive(scale, "drag scale")?;
310        if self
311            .inputs
312            .custom_drag_table
313            .as_ref()
314            .unwrap()
315            .cd_values
316            .iter()
317            .any(|cd| !(cd * scale).is_finite())
318        {
319            return Err(Error::invalid("drag scale", "scaled Cd must remain finite"));
320        }
321        self.inputs.cd_scale = scale;
322        Ok(self)
323    }
324
325    /// Change uniform world-frame wind between public integration steps.
326    pub fn set_wind(&mut self, wind_mps: Vec3) -> Result<()> {
327        if !finite_vec(&wind_mps) {
328            return Err(Error::invalid("wind_mps", "must be finite"));
329        }
330        self.wind_mps = wind_mps;
331        Ok(())
332    }
333
334    /// Modified point-mass spin: enable the kernel's Magnus force and yaw of
335    /// repose for a spinning body. `twist_rate_in` is the effective twist in
336    /// inches per turn — rifling language upstream; for a fin-canted rocket it
337    /// is the roll rate the cant produces per unit velocity. `body_length_m` is
338    /// the full airframe length (the stability correlations need it; [`Self::new`]
339    /// only knows the diameter). `right_hand` selects the roll direction.
340    ///
341    /// The correlations are McCoy bullet shapes, so treat magnitudes as
342    /// approximate for blunt or finned bodies; signs and scaling are physical.
343    pub fn with_spin(
344        mut self,
345        twist_rate_in: f64,
346        body_length_m: f64,
347        right_hand: bool,
348    ) -> Result<Self> {
349        positive(twist_rate_in, "twist_rate_in")?;
350        positive(body_length_m, "body_length_m")?;
351        self.inputs.twist_rate = twist_rate_in;
352        self.inputs.is_twist_right = right_hand;
353        self.inputs.bullet_length = body_length_m;
354        self.inputs.enable_magnus = true;
355        Ok(self)
356    }
357
358    /// Modified point-mass Coriolis deflection, for planet-scale arcs.
359    /// `latitude_deg` is the launch latitude in [-90, 90]; `shot_azimuth_deg` is
360    /// the compass bearing fired along, 0 = north, 90 = east. Do not combine
361    /// with an inertial-frame [`crate::CentralGravity`] model: Coriolis is the
362    /// rotating-frame term and would double-count Earth rotation.
363    pub fn with_coriolis(mut self, latitude_deg: f64, shot_azimuth_deg: f64) -> Result<Self> {
364        if !latitude_deg.is_finite() || !(-90.0..=90.0).contains(&latitude_deg) {
365            return Err(Error::invalid(
366                "latitude_deg",
367                "must be finite and in [-90, 90]",
368            ));
369        }
370        if !shot_azimuth_deg.is_finite() {
371            return Err(Error::invalid("shot_azimuth_deg", "must be finite"));
372        }
373        let lat = latitude_deg.to_radians();
374        let az = shot_azimuth_deg.to_radians();
375        self.omega_rps = Some(Vec3::new(
376            OMEGA_EARTH_RPS * lat.cos() * az.cos(),  // X: downrange
377            OMEGA_EARTH_RPS * lat.sin(),             // Y: vertical
378            -OMEGA_EARTH_RPS * lat.cos() * az.sin(), // Z: lateral
379        ));
380        self.inputs.latitude = Some(latitude_deg);
381        self.inputs.shot_azimuth = az;
382        self.inputs.enable_coriolis = true;
383        Ok(self)
384    }
385
386    /// Launch tip-off: an initial yaw in radians that decays exponentially with
387    /// downrange distance over `decay_distance_m`. Models the rail/launcher
388    /// disturbance; it perturbs the effective angle of attack early in flight.
389    pub fn with_tipoff_yaw(mut self, yaw_rad: f64, decay_distance_m: f64) -> Result<Self> {
390        if !yaw_rad.is_finite() {
391            return Err(Error::invalid("tipoff yaw_rad", "must be finite"));
392        }
393        positive(decay_distance_m, "tipoff decay_distance_m")?;
394        self.inputs.tipoff_yaw = yaw_rad;
395        self.inputs.tipoff_decay_distance = decay_distance_m;
396        Ok(self)
397    }
398
399    /// Angle-of-attack aerodynamics without attitude integration: a quasi-static
400    /// normal force and induced drag from the commanded axis's angle to the
401    /// relative wind.
402    ///
403    /// `cna_vs_mach` is the normal-force coefficient slope per radian of angle of
404    /// attack (body + fins) versus Mach; `induced_k_vs_mach` adds Cd += k·α².
405    /// Both need at least two strictly ascending Mach points and nonnegative,
406    /// finite values. The angle of attack is measured each RK stage between the
407    /// solver-resolved thrust axis and the wind-relative velocity, so a guided
408    /// turn at angle of attack both turns harder (body lift) and pays drag for it.
409    /// Reference area derives from the diameter passed to [`Self::new`].
410    pub fn with_body_lift(
411        mut self,
412        cna_vs_mach: Vec<(f64, f64)>,
413        induced_k_vs_mach: Vec<(f64, f64)>,
414    ) -> Result<Self> {
415        let table = |pairs: Vec<(f64, f64)>, name: &'static str| -> Result<DragTable> {
416            mach_table(pairs, name)
417        };
418        self.body_lift = Some(BodyLift {
419            cna_per_rad: table(cna_vs_mach, "cna_vs_mach")?,
420            induced_k_per_rad2: table(induced_k_vs_mach, "induced_k_vs_mach")?,
421        });
422        Ok(self)
423    }
424
425    /// Change the effective twist between public steps, for a caller-integrated
426    /// roll model (see [`crate::RollDynamics`]). Pair with [`Self::with_spin`]
427    /// for the body length and handedness; the rate must stay positive.
428    pub fn set_twist(&mut self, twist_rate_in: f64) -> Result<()> {
429        positive(twist_rate_in, "twist_rate_in")?;
430        self.inputs.twist_rate = twist_rate_in;
431        Ok(())
432    }
433
434    /// Downrange-segmented atmosphere (upstream `AtmoSock`): station-referenced
435    /// zones, each `(temperature_c, pressure_hpa, humidity_percent, until_downrange_m)`.
436    /// The zone is selected by downrange distance `position.x`; the base atmosphere
437    /// must be [`Atmosphere::Standard`], and each zone's humidity applies to its
438    /// station density while this derivative path uses dry-air sound speed.
439    pub fn with_atmosphere_zones(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
440        if !matches!(self.atmosphere, Atmosphere::Standard { .. }) {
441            return Err(Error::invalid(
442                "atmosphere zones",
443                "require the Standard atmosphere, not Uniform",
444            ));
445        }
446        if zones.is_empty() {
447            return Err(Error::invalid(
448                "atmosphere zones",
449                "needs at least one zone",
450            ));
451        }
452        for (temp_c, pressure_hpa, humidity, until_m) in &zones {
453            if !temp_c.is_finite()
454                || !pressure_hpa.is_finite()
455                || *pressure_hpa <= 0.0
456                || !humidity.is_finite()
457                || !(0.0..=100.0).contains(humidity)
458                || !until_m.is_finite()
459                || *until_m <= 0.0
460            {
461                return Err(Error::invalid(
462                    "atmosphere zone",
463                    "needs finite temperature, positive pressure, humidity in [0, 100] and positive until_m",
464                ));
465            }
466        }
467        self.atmo_sock = Some(AtmoSock::new(zones));
468        Ok(self)
469    }
470
471    /// Downrange-segmented wind (upstream `WindSock`). Each zone is
472    /// `(speed_mps, angle_deg, until_downrange_m, vertical_mps)`, with the angle
473    /// following the upstream wind-FROM convention (0° = headwind, 90° = from the
474    /// right). Vertical is positive updraft. Zones are evaluated statelessly at
475    /// each stage's `position.x`, so wind can change mid-integration.
476    /// Overrides the uniform wind set in [`Self::new`]/[`Self::set_wind`].
477    pub fn with_wind_segments(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
478        if zones.is_empty() {
479            return Err(Error::invalid(
480                "wind segments",
481                "needs at least one segment",
482            ));
483        }
484        let segments: Vec<WindSegment> = zones
485            .into_iter()
486            .map(
487                |(speed_mps, angle_deg, until_m, vertical_mps)| WindSegment {
488                    speed_kmh: speed_mps * 3.6,
489                    angle_deg,
490                    until_m,
491                    vertical_mps,
492                },
493            )
494            .collect();
495        let sock = WindSock::try_new(segments)
496            .map_err(|e| Error::Model(format!("invalid wind segments: {e:?}")))?;
497        self.wind_sock = Some(sock);
498        Ok(self)
499    }
500
501    /// Add deterministic frozen-field turbulence: multisine gusts with a
502    /// pink spectrum, RMS `sigma_mps` per axis and energy concentrated near
503    /// the period `tau_s`. `seed` fixes the phases (dispersion reseeds per
504    /// run), so runs are exactly reproducible.
505    pub fn with_turbulence(mut self, sigma_mps: f64, tau_s: f64, seed: u64) -> Result<Self> {
506        self.turbulence = Some(Turbulence::new(sigma_mps, tau_s, seed)?);
507        Ok(self)
508    }
509
510    /// Sample an arbitrary winds-engine field instead of the uniform vector
511    /// or zone ladder. Shared as an `Arc` so the weathercock and the
512    /// recovery descent can sample the same air. Stateless and
513    /// deterministic, so integration stays reproducible.
514    pub fn with_wind_field(
515        mut self,
516        field: std::sync::Arc<dyn winds_engine::WindField + Send + Sync>,
517    ) -> Result<Self> {
518        self.wind_field = Some(SharedWindField(field));
519        Ok(self)
520    }
521
522    /// Wind at a stage: the configured field if any, else segmented
523    /// downrange wind if configured, else the uniform vector; plus
524    /// frozen-field turbulence if configured.
525    fn effective_wind(&self, state: &FlightState) -> Vec3 {
526        let base = match &self.wind_field {
527            Some(field) => {
528                let w = field.0.wind(
529                    state.time_s,
530                    [state.position_m.x, state.position_m.y, state.position_m.z],
531                );
532                Vec3::new(w.x_mps, w.y_mps, w.z_mps)
533            }
534            None => match &self.wind_sock {
535                Some(sock) => sock.vector_for_range_stateless(state.position_m.x),
536                None => self.wind_mps,
537            },
538        };
539        match &self.turbulence {
540            Some(t) => base + t.gust_vec(state.time_s),
541            None => base,
542        }
543    }
544
545    /// Local air density (kg/m³) and sound speed (m/s) at a geometric altitude,
546    /// consistent with the atmosphere handed to the kernel.
547    fn local_air(&self, altitude_m: f64) -> (f64, f64) {
548        local_air(self.atmosphere, self.atmos_params, altitude_m)
549    }
550
551    /// Quasi-static AoA force increment: normal force toward the body-axis side of
552    /// the velocity, plus induced drag along it. Zero when aligned or in still air.
553    fn body_lift_accel(
554        &self,
555        state: &FlightState,
556        mass_kg: f64,
557        wind_mps: Vec3,
558        body_axis: Vec3,
559    ) -> Result<Vec3> {
560        let Some(lift) = &self.body_lift else {
561            return Ok(Vec3::zeros());
562        };
563        let v_air = state.velocity_mps - wind_mps;
564        let speed = v_air.norm();
565        // Degenerate cases: no relative wind, or a zero/ill-posed axis → no increment.
566        if speed < 1e-12 || body_axis.amax() == 0.0 {
567            return Ok(Vec3::zeros());
568        }
569        let v_hat = v_air / speed;
570        let axis = body_axis / body_axis.norm();
571        // Angle of attack from the axis to the relative wind.
572        let perp = axis - axis.dot(&v_hat) * v_hat;
573        let sin_alpha = perp.norm();
574        let cos_alpha = axis.dot(&v_hat).clamp(-1.0, 1.0);
575        let alpha = sin_alpha.atan2(cos_alpha);
576        if alpha < 1e-12 {
577            return Ok(Vec3::zeros());
578        }
579        let (rho, sound) = self.local_air(state.position_m.y);
580        if rho <= 0.0 || sound <= 0.0 {
581            return Ok(Vec3::zeros());
582        }
583        let mach = speed / sound;
584        let q = 0.5 * rho * speed * speed;
585        let area = std::f64::consts::FRAC_PI_4 * self.inputs.bullet_diameter.powi(2);
586        let normal_force = q * area * lift.cna_per_rad.interpolate(mach) * alpha;
587        let induced_drag = q * area * lift.induced_k_per_rad2.interpolate(mach) * alpha * alpha;
588        let accel = (normal_force * (perp / sin_alpha) - induced_drag * v_hat) / mass_kg;
589        if !finite_vec(&accel) {
590            return Err(Error::NumericalFailure(
591                "body lift returned non-finite acceleration",
592            ));
593        }
594        Ok(accel)
595    }
596
597    /// Axis-aware counterpart to [`Self::acceleration_in_wind`], for wrappers that
598    /// sample their own wind but want the body-lift increment.
599    pub fn acceleration_with_axis_in_wind(
600        &self,
601        state: &FlightState,
602        mass_kg: f64,
603        wind_mps: Vec3,
604        body_axis: Vec3,
605    ) -> Result<Vec3> {
606        let base = self.acceleration_in_wind(state, mass_kg, wind_mps)?;
607        Ok(base + self.body_lift_accel(state, mass_kg, wind_mps, body_axis)?)
608    }
609
610    /// Evaluate using externally sampled wind, for a spatial or time-varying force wrapper.
611    pub fn acceleration_in_wind(
612        &self,
613        state: &FlightState,
614        mass_kg: f64,
615        wind_mps: Vec3,
616    ) -> Result<Vec3> {
617        state.validate()?;
618        positive(mass_kg, "mass_kg")?;
619        if !finite_vec(&wind_mps) {
620            return Err(Error::invalid("wind_mps", "must be finite"));
621        }
622        let mut inputs = self.inputs.clone();
623        inputs.bullet_mass = mass_kg;
624        inputs.weight_grains = mass_kg / GRAINS_TO_KG;
625        positive(inputs.weight_grains, "mass in grains")?;
626        let derivatives = compute_derivatives(
627            state.position_m,
628            state.velocity_mps,
629            &inputs,
630            wind_mps,
631            self.atmos_params,
632            inputs.bc_value,
633            self.omega_rps,
634            state.time_s,
635            self.atmo_sock.as_ref(),
636        );
637        let acceleration = Vec3::new(derivatives[3], derivatives[4], derivatives[5]);
638        if !finite_vec(&acceleration) {
639            return Err(Error::NumericalFailure(
640                "ballistics force model returned non-finite acceleration",
641            ));
642        }
643        Ok(acceleration)
644    }
645}
646
647impl ForceModel for BallisticForces {
648    fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3> {
649        self.acceleration_in_wind(state, mass_kg, self.effective_wind(state))
650    }
651
652    fn acceleration_with_axis(
653        &self,
654        state: &FlightState,
655        mass_kg: f64,
656        body_axis: Vec3,
657    ) -> Result<Vec3> {
658        self.acceleration_with_axis_in_wind(state, mass_kg, self.effective_wind(state), body_axis)
659    }
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    #[test]
667    fn turbulence_rms_matches_sigma() {
668        let gust = Turbulence::new(2.5, 2.0, 42).unwrap();
669        // Long-run statistics per axis: mean ~0, RMS ~ sigma.
670        for axis in 0..3 {
671            let samples: Vec<f64> = (0..20_000)
672                .map(|i| gust.gust_vec(i as f64 * 0.05)[axis])
673                .collect();
674            let mean = samples.iter().sum::<f64>() / samples.len() as f64;
675            let var =
676                samples.iter().map(|s| (s - mean).powi(2)).sum::<f64>() / samples.len() as f64;
677            assert!(mean.abs() < 0.1, "axis {axis} mean {mean}");
678            assert!(
679                (var.sqrt() - 2.5).abs() / 2.5 < 0.05,
680                "axis {axis} rms {}",
681                var.sqrt()
682            );
683        }
684    }
685
686    #[test]
687    fn turbulence_is_deterministic_per_seed() {
688        let a = Turbulence::new(1.0, 2.0, 7).unwrap();
689        let b = Turbulence::new(1.0, 2.0, 7).unwrap();
690        let c = Turbulence::new(1.0, 2.0, 8).unwrap();
691        for i in 0..100 {
692            let t = i as f64 * 0.1;
693            assert_eq!(a.gust_vec(t), b.gust_vec(t));
694        }
695        assert_ne!(a.gust_vec(3.3), c.gust_vec(3.3));
696    }
697
698    #[test]
699    fn turbulence_validates_its_numbers() {
700        assert!(Turbulence::new(-1.0, 2.0, 0).is_err());
701        assert!(Turbulence::new(1.0, 0.0, 0).is_err());
702        assert!(Turbulence::new(f64::NAN, 2.0, 0).is_err());
703    }
704
705    #[test]
706    fn wind_field_is_sampled_per_state() {
707        let profile = winds_engine::ProfileField::new(vec![
708            winds_engine::ProfileLevel {
709                altitude_m: 0.0,
710                speed_mps: 0.0,
711                direction_from_deg: 0.0,
712            },
713            winds_engine::ProfileLevel {
714                altitude_m: 1000.0,
715                speed_mps: 10.0,
716                direction_from_deg: 0.0,
717            },
718        ])
719        .unwrap();
720        let forces = BallisticForces::new(
721            0.05,
722            vec![(0.0, 0.4), (3.0, 0.4)],
723            Atmosphere::default(),
724            Vec3::zeros(),
725        )
726        .unwrap()
727        .with_wind_field(std::sync::Arc::new(profile))
728        .unwrap();
729        let state = FlightState {
730            time_s: 1.0,
731            position_m: Vec3::new(0.0, 500.0, 0.0),
732            velocity_mps: Vec3::zeros(),
733        };
734        // FROM 0° blows toward −X; 5 m/s at 500 m altitude.
735        let wind = forces.effective_wind(&state);
736        assert!((wind.x + 5.0).abs() < 1e-9, "wind {wind:?}");
737        assert!(wind.y.abs() < 1e-12 && wind.z.abs() < 1e-9);
738    }
739
740    #[test]
741    fn turbulence_adds_to_the_effective_wind() {
742        let forces = BallisticForces::new(
743            0.05,
744            vec![(0.0, 0.4), (3.0, 0.4)],
745            Atmosphere::default(),
746            Vec3::new(3.0, 0.0, 0.0),
747        )
748        .unwrap()
749        .with_turbulence(2.0, 2.0, 42)
750        .unwrap();
751        let state = FlightState {
752            time_s: 1.7,
753            position_m: Vec3::zeros(),
754            velocity_mps: Vec3::zeros(),
755        };
756        let expected = Vec3::new(3.0, 0.0, 0.0) + forces.turbulence.as_ref().unwrap().gust_vec(1.7);
757        assert_eq!(forces.effective_wind(&state), expected);
758    }
759}