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::{
13        calculate_air_density_cimp, get_local_atmosphere, reduce_qnh_to_station_pressure, AtmoSock,
14    },
15    constants::GRAINS_TO_KG,
16    derivatives::compute_derivatives,
17    drag::DragTable,
18    wind::{WindSegment, WindSock},
19    BallisticInputs,
20};
21
22/// Atmospheric inputs supported by the upstream derivative kernel.
23#[derive(Debug, Clone, Copy, PartialEq)]
24pub enum Atmosphere {
25    /// Station measurements; the upstream model applies its local altitude lapse.
26    /// Humidity affects station density; this derivative path uses dry-air sound speed.
27    Standard {
28        /// Elevation of the weather station above sea level, in metres.
29        station_altitude_m: f64,
30        /// Station temperature in degrees Celsius (greater than absolute zero).
31        temperature_c: f64,
32        /// Positive station pressure in hectopascals.
33        pressure_hpa: f64,
34        /// Relative humidity as a percentage in [0, 100].
35        humidity_percent: f64,
36    },
37    /// Constant density and sound speed, independent of position.
38    /// Upstream's direct-mode sentinel requires 0 <= density < 2 and sound speed > 200.
39    Uniform {
40        /// Air density in kilograms per cubic metre; zero disables drag.
41        density_kg_m3: f64,
42        /// Sound speed in metres per second.
43        speed_of_sound_mps: f64,
44    },
45}
46
47impl Default for Atmosphere {
48    fn default() -> Self {
49        Self::Standard {
50            station_altitude_m: 0.0,
51            temperature_c: 15.0,
52            pressure_hpa: 1013.25,
53            humidity_percent: 0.0,
54        }
55    }
56}
57
58/// Lowest and highest station altitudes [`Atmosphere::icao_standard`] takes, in
59/// metres above sea level: the ICAO troposphere, where one lapse rate holds.
60pub(crate) const ICAO_STATION_RANGE_M: (f64, f64) = (-1000.0, 11000.0);
61
62/// Earth radius upstream's ICAO table uses to turn geometric altitude into
63/// geopotential height.
64const GEOPOTENTIAL_EARTH_RADIUS_M: f64 = 6_356_766.0;
65
66impl Atmosphere {
67    /// A dry station at `station_altitude_m` above sea level reading the ICAO
68    /// standard atmosphere for that altitude. Air at every altitude is then
69    /// the standard atmosphere's, whatever the station altitude, so a launch
70    /// site at elevation E flies through standard air with its station (and
71    /// any atmosphere zones, which are readings at the station) at E.
72    ///
73    /// At 0 m this equals [`Atmosphere::default`] exactly. The altitude must
74    /// be finite and within the troposphere, -1000 to 11000 m. Temperature
75    /// follows the ICAO lapse of 6.5 K per kilometre of geopotential height;
76    /// pressure is upstream's `reduce_qnh_to_station_pressure` of the
77    /// 1013.25 hPa standard sea-level pressure.
78    pub fn icao_standard(station_altitude_m: f64) -> Result<Self> {
79        let (lowest, highest) = ICAO_STATION_RANGE_M;
80        if !station_altitude_m.is_finite() || !(lowest..=highest).contains(&station_altitude_m) {
81            return Err(Error::invalid(
82                "station_altitude_m",
83                "must be finite and within the ICAO troposphere, -1000 to 11000 m",
84            ));
85        }
86        let geopotential_m = GEOPOTENTIAL_EARTH_RADIUS_M * station_altitude_m
87            / (GEOPOTENTIAL_EARTH_RADIUS_M + station_altitude_m);
88        let temperature_k = 288.15 - 0.0065 * geopotential_m;
89        Ok(Self::Standard {
90            station_altitude_m,
91            temperature_c: temperature_k - 273.15,
92            pressure_hpa: reduce_qnh_to_station_pressure(1013.25, station_altitude_m),
93            humidity_percent: 0.0,
94        })
95    }
96
97    pub(crate) fn parameters(self) -> Result<(f64, f64, f64, f64)> {
98        match self {
99            Self::Standard {
100                station_altitude_m,
101                temperature_c,
102                pressure_hpa,
103                humidity_percent,
104            } => {
105                if !station_altitude_m.is_finite()
106                    || !temperature_c.is_finite()
107                    || temperature_c <= -273.15
108                    || !humidity_percent.is_finite()
109                    || !(0.0..=100.0).contains(&humidity_percent)
110                {
111                    return Err(Error::invalid("standard atmosphere", "requires finite altitude, temperature above absolute zero and humidity in [0, 100]"));
112                }
113                positive(pressure_hpa, "pressure_hpa")?;
114                let ratio =
115                    calculate_air_density_cimp(temperature_c, pressure_hpa, humidity_percent)
116                        / 1.225;
117                positive(ratio, "station density ratio")?;
118                Ok((station_altitude_m, temperature_c, pressure_hpa, ratio))
119            }
120            Self::Uniform {
121                density_kg_m3,
122                speed_of_sound_mps,
123            } => {
124                if !density_kg_m3.is_finite()
125                    || !(0.0..2.0).contains(&density_kg_m3)
126                    || !speed_of_sound_mps.is_finite()
127                    || speed_of_sound_mps <= 200.0
128                {
129                    return Err(Error::invalid(
130                        "uniform atmosphere",
131                        "upstream requires density in [0, 2) and sound speed > 200 m/s",
132                    ));
133                }
134                Ok((density_kg_m3, speed_of_sound_mps, 0.0, 0.0))
135            }
136        }
137    }
138}
139
140/// Local air density (kg/m³) and sound speed (m/s) for an atmosphere and its
141/// kernel parameter tuple, at a geometric altitude above sea level.
142fn local_air(
143    atmosphere: Atmosphere,
144    atmos_params: (f64, f64, f64, f64),
145    altitude_m: f64,
146) -> (f64, f64) {
147    match atmosphere {
148        Atmosphere::Standard { .. } => {
149            let (base_alt, temp_c, press_hpa, ratio) = atmos_params;
150            get_local_atmosphere(altitude_m, base_alt, temp_c, press_hpa, ratio)
151        }
152        Atmosphere::Uniform { .. } => {
153            let (density, speed_of_sound, _, _) = atmos_params;
154            (density, speed_of_sound)
155        }
156    }
157}
158
159/// One atmosphere and its optional downrange zones: the air-at-position lookup
160/// every force reads, so drag, body lift, recovery drag and the weathercock all
161/// see the same air.
162///
163/// [`Air::at`] reproduces the upstream 0.48.0 `compute_derivatives` lookup that
164/// the drag kernel runs on the same atmosphere tuple and zone ladder.
165#[derive(Debug, Clone)]
166pub(crate) struct Air {
167    atmosphere: Atmosphere,
168    params: (f64, f64, f64, f64),
169    zones: Option<AtmoSock>,
170}
171
172impl Air {
173    pub(crate) fn new(atmosphere: Atmosphere) -> Result<Self> {
174        Ok(Self {
175            params: atmosphere.parameters()?,
176            atmosphere,
177            zones: None,
178        })
179    }
180
181    /// Attach station-referenced zones, each `(temperature_c, pressure_hpa,
182    /// humidity_percent, until_downrange_m)`; see
183    /// [`BallisticForces::with_atmosphere_zones`] for the contract.
184    pub(crate) fn with_zones(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
185        if !matches!(self.atmosphere, Atmosphere::Standard { .. }) {
186            return Err(Error::invalid(
187                "atmosphere zones",
188                "require the Standard atmosphere, not Uniform",
189            ));
190        }
191        if zones.is_empty() {
192            return Err(Error::invalid(
193                "atmosphere zones",
194                "needs at least one zone",
195            ));
196        }
197        for (temp_c, pressure_hpa, humidity, until_m) in &zones {
198            if !temp_c.is_finite()
199                || !pressure_hpa.is_finite()
200                || *pressure_hpa <= 0.0
201                || !humidity.is_finite()
202                || !(0.0..=100.0).contains(humidity)
203                || !until_m.is_finite()
204                || *until_m <= 0.0
205            {
206                return Err(Error::invalid(
207                    "atmosphere zone",
208                    "needs finite temperature, positive pressure, humidity in [0, 100] and positive until_m",
209                ));
210            }
211        }
212        self.zones = Some(AtmoSock::new(zones));
213        Ok(self)
214    }
215
216    /// The kernel's atmosphere tuple.
217    pub(crate) fn params(&self) -> (f64, f64, f64, f64) {
218        self.params
219    }
220
221    /// The zone ladder handed to the kernel, if any.
222    pub(crate) fn zones(&self) -> Option<&AtmoSock> {
223        self.zones.as_ref()
224    }
225
226    /// Air density (kg/m³) and sound speed (m/s) at a world position, with Y
227    /// the altitude above sea level and X the downrange distance.
228    ///
229    /// As upstream does: a `Uniform` atmosphere returns its constants. A
230    /// `Standard` one lapses its station reading from the station altitude to
231    /// Y; with zones, the zone selected by X (`AtmoSock::atmo_for_range`)
232    /// replaces the station temperature and pressure, and its density ratio
233    /// (CIPM density at the zone's temperature, pressure and humidity, over
234    /// 1.225) replaces the station's, before that same lapse. Density matches
235    /// the kernel's bit for bit. Sound speed is `get_local_atmosphere`'s
236    /// dry-air value, as these forces have always used; the kernel's own
237    /// moist formula at zero humidity agrees with it to about 4e-7.
238    pub(crate) fn at(&self, position_m: Vec3) -> (f64, f64) {
239        match (self.atmosphere, &self.zones) {
240            (Atmosphere::Standard { .. }, Some(sock)) => {
241                let (temp_c, pressure_hpa, humidity) = sock.atmo_for_range(position_m.x);
242                let ratio = calculate_air_density_cimp(temp_c, pressure_hpa, humidity) / 1.225;
243                get_local_atmosphere(position_m.y, self.params.0, temp_c, pressure_hpa, ratio)
244            }
245            _ => local_air(self.atmosphere, self.params, position_m.y),
246        }
247    }
248}
249
250/// Validated Mach table with nonnegative values, shared by the lift and
251/// weathercock parameter curves.
252pub(crate) fn mach_table(pairs: Vec<(f64, f64)>, name: &'static str) -> Result<DragTable> {
253    if pairs.len() < 2 {
254        return Err(Error::invalid(name, "needs at least two Mach points"));
255    }
256    for (i, (mach, value)) in pairs.iter().enumerate() {
257        if !mach.is_finite() || *mach < 0.0 {
258            return Err(Error::invalid(name, "Mach must be finite and nonnegative"));
259        }
260        if i > 0 && *mach <= pairs[i - 1].0 {
261            return Err(Error::invalid(name, "Mach must strictly ascend"));
262        }
263        if !value.is_finite() || *value < 0.0 {
264            return Err(Error::invalid(
265                name,
266                "values must be finite and nonnegative",
267            ));
268        }
269    }
270    let (mach_values, cd_values) = pairs.into_iter().unzip();
271    Ok(DragTable::new(mach_values, cd_values))
272}
273
274/// Gravity and aerodynamic drag delegated to ballistics-engine's force kernel.
275#[derive(Debug, Clone)]
276pub struct BallisticForces {
277    inputs: BallisticInputs,
278    air: Air,
279    wind_mps: Vec3,
280    /// An arbitrary wind field (winds-engine), sampled per state; overrides
281    /// the uniform vector and any zone ladder.
282    wind_field: Option<SharedWindField>,
283    turbulence: Option<Turbulence>,
284    omega_rps: Option<Vec3>,
285    body_lift: Option<BodyLift>,
286    wind_sock: Option<WindSock>,
287}
288
289/// A winds-engine field shared (Arc) between the force model, the
290/// weathercock and the recovery descent, with `Debug` and `Clone` so the
291/// containing models keep their derives.
292#[derive(Clone)]
293pub(crate) struct SharedWindField(
294    pub(crate) std::sync::Arc<dyn winds_engine::WindField + Send + Sync>,
295);
296
297impl std::fmt::Debug for SharedWindField {
298    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299        f.write_str("SharedWindField(..)")
300    }
301}
302
303/// Number of sinusoid components per axis in the turbulence gust model.
304const TURBULENCE_COMPONENTS: usize = 8;
305
306/// Deterministic frozen-field turbulence: a multisine gust per axis with a
307/// pink (1/f power) spectrum, fully fixed by its seed. Stateless, so RK4
308/// substeps, snapshots and replays all see identical wind at identical times.
309#[derive(Debug, Clone)]
310pub(crate) struct Turbulence {
311    /// Per-axis (angular frequency rad/s, amplitude m/s, phase rad) components.
312    axes: [[(f64, f64, f64); TURBULENCE_COMPONENTS]; 3],
313}
314
315impl Turbulence {
316    /// SplitMix64 finalizer as a stateless hash → [0, 1).
317    fn hash01(mut z: u64) -> f64 {
318        z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
319        z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
320        let z = z ^ (z >> 31);
321        (z >> 11) as f64 / (1u64 << 53) as f64
322    }
323
324    /// Gusts with RMS `sigma_mps` per axis and energy concentrated near the
325    /// period `tau_s` (frequencies log-spaced over [1/(10τ), 10/τ]).
326    pub(crate) fn new(sigma_mps: f64, tau_s: f64, seed: u64) -> Result<Self> {
327        if !sigma_mps.is_finite() || sigma_mps < 0.0 {
328            return Err(Error::invalid("turbulence sigma", "must be nonnegative"));
329        }
330        if !tau_s.is_finite() || tau_s <= 0.0 {
331            return Err(Error::invalid("turbulence tau", "must be positive"));
332        }
333        let lo = 0.1 / tau_s;
334        let ratio = 100.0_f64;
335        let step = ratio.powf(1.0 / (TURBULENCE_COMPONENTS - 1) as f64);
336        let norm: f64 = (0..TURBULENCE_COMPONENTS)
337            .map(|i| 1.0 / (lo * step.powi(i as i32)))
338            .sum();
339        let scale = (2.0 * sigma_mps * sigma_mps / norm).sqrt();
340        let mut axes = [[(0.0, 0.0, 0.0); TURBULENCE_COMPONENTS]; 3];
341        for (axis, comps) in axes.iter_mut().enumerate() {
342            for (i, comp) in comps.iter_mut().enumerate() {
343                let omega = lo * step.powi(i as i32);
344                let amplitude = scale / omega.sqrt();
345                let phase = 2.0
346                    * std::f64::consts::PI
347                    * Self::hash01(
348                        seed ^ 0x9E3779B97F4A7C15u64.wrapping_mul(1 + axis as u64 * 64 + i as u64),
349                    );
350                *comp = (omega, amplitude, phase);
351            }
352        }
353        Ok(Self { axes })
354    }
355
356    pub(crate) fn gust_vec(&self, t: f64) -> Vec3 {
357        Vec3::new(
358            self.axes[0]
359                .iter()
360                .map(|(w, a, p)| a * (w * t + p).sin())
361                .sum(),
362            self.axes[1]
363                .iter()
364                .map(|(w, a, p)| a * (w * t + p).sin())
365                .sum(),
366            self.axes[2]
367                .iter()
368                .map(|(w, a, p)| a * (w * t + p).sin())
369                .sum(),
370        )
371    }
372}
373
374/// Validated angle-of-attack aerodynamics: normal-force slope and induced-drag
375/// factor, each piecewise-linear in Mach via the upstream table type.
376#[derive(Debug, Clone)]
377struct BodyLift {
378    /// Normal force coefficient slope per radian of angle of attack, vs Mach.
379    cna_per_rad: DragTable,
380    /// Induced drag increment per radian squared (Cd += k·α²), vs Mach.
381    induced_k_per_rad2: DragTable,
382}
383
384/// Earth rotation rate, rad/s (McCoy-level precision, matching the upstream kernel).
385const OMEGA_EARTH_RPS: f64 = 7.2921159e-5;
386
387impl BallisticForces {
388    /// Build using the vehicle's actual Cd curve, not a G-model reference curve or BC.
389    /// At least two ascending, nonnegative Mach values and positive Cd values are required.
390    /// Wind is a world-frame air velocity vector (positive X is a tailwind).
391    pub fn new(
392        diameter_m: f64,
393        cd_vs_mach: Vec<(f64, f64)>,
394        atmosphere: Atmosphere,
395        wind_mps: Vec3,
396    ) -> Result<Self> {
397        positive(diameter_m, "diameter_m")?;
398        let diameter_in = diameter_m / 0.0254;
399        positive(diameter_in * diameter_in, "diameter area")?;
400        if !finite_vec(&wind_mps) {
401            return Err(Error::invalid("wind_mps", "must be finite"));
402        }
403        let (mach, cd) = cd_vs_mach.into_iter().unzip();
404        let drag = DragTable::try_new(mach, cd).map_err(Error::Model)?;
405        let inputs = BallisticInputs {
406            bullet_diameter: diameter_m,
407            caliber_inches: diameter_in,
408            bullet_length: diameter_m,
409            custom_drag_table: Some(drag),
410            custom_drag_reference: false,
411            altitude: 0.0,
412            shooting_angle: 0.0,
413            enable_advanced_effects: false,
414            enable_magnus: false,
415            enable_coriolis: false,
416            use_enhanced_spin_drift: false,
417            tipoff_yaw: 0.0,
418            ..BallisticInputs::default()
419        };
420        Ok(Self {
421            inputs,
422            air: Air::new(atmosphere)?,
423            wind_mps,
424            wind_field: None,
425            turbulence: None,
426            omega_rps: None,
427            body_lift: None,
428            wind_sock: None,
429        })
430    }
431
432    /// Apply a positive multiplier to the entire Cd curve. Default is 1.0.
433    pub fn with_drag_scale(mut self, scale: f64) -> Result<Self> {
434        positive(scale, "drag scale")?;
435        if self
436            .inputs
437            .custom_drag_table
438            .as_ref()
439            .unwrap()
440            .cd_values
441            .iter()
442            .any(|cd| !(cd * scale).is_finite())
443        {
444            return Err(Error::invalid("drag scale", "scaled Cd must remain finite"));
445        }
446        self.inputs.cd_scale = scale;
447        Ok(self)
448    }
449
450    /// Change uniform world-frame wind between public integration steps.
451    pub fn set_wind(&mut self, wind_mps: Vec3) -> Result<()> {
452        if !finite_vec(&wind_mps) {
453            return Err(Error::invalid("wind_mps", "must be finite"));
454        }
455        self.wind_mps = wind_mps;
456        Ok(())
457    }
458
459    /// Modified point-mass spin: enable the kernel's Magnus force and yaw of
460    /// repose for a spinning body. `twist_rate_in` is the effective twist in
461    /// inches per turn — rifling language upstream; for a fin-canted rocket it
462    /// is the roll rate the cant produces per unit velocity. `body_length_m` is
463    /// the full airframe length (the stability correlations need it; [`Self::new`]
464    /// only knows the diameter). `right_hand` selects the roll direction.
465    ///
466    /// The correlations are McCoy bullet shapes, so treat magnitudes as
467    /// approximate for blunt or finned bodies; signs and scaling are physical.
468    pub fn with_spin(
469        mut self,
470        twist_rate_in: f64,
471        body_length_m: f64,
472        right_hand: bool,
473    ) -> Result<Self> {
474        positive(twist_rate_in, "twist_rate_in")?;
475        positive(body_length_m, "body_length_m")?;
476        self.inputs.twist_rate = twist_rate_in;
477        self.inputs.is_twist_right = right_hand;
478        self.inputs.bullet_length = body_length_m;
479        self.inputs.enable_magnus = true;
480        Ok(self)
481    }
482
483    /// Modified point-mass Coriolis deflection, for planet-scale arcs.
484    /// `latitude_deg` is the launch latitude in [-90, 90]; `shot_azimuth_deg` is
485    /// the compass bearing fired along, 0 = north, 90 = east. Do not combine
486    /// with an inertial-frame [`crate::CentralGravity`] model: Coriolis is the
487    /// rotating-frame term and would double-count Earth rotation.
488    pub fn with_coriolis(mut self, latitude_deg: f64, shot_azimuth_deg: f64) -> Result<Self> {
489        if !latitude_deg.is_finite() || !(-90.0..=90.0).contains(&latitude_deg) {
490            return Err(Error::invalid(
491                "latitude_deg",
492                "must be finite and in [-90, 90]",
493            ));
494        }
495        if !shot_azimuth_deg.is_finite() {
496            return Err(Error::invalid("shot_azimuth_deg", "must be finite"));
497        }
498        let lat = latitude_deg.to_radians();
499        let az = shot_azimuth_deg.to_radians();
500        self.omega_rps = Some(Vec3::new(
501            OMEGA_EARTH_RPS * lat.cos() * az.cos(),  // X: downrange
502            OMEGA_EARTH_RPS * lat.sin(),             // Y: vertical
503            -OMEGA_EARTH_RPS * lat.cos() * az.sin(), // Z: lateral
504        ));
505        self.inputs.latitude = Some(latitude_deg);
506        self.inputs.shot_azimuth = az;
507        self.inputs.enable_coriolis = true;
508        Ok(self)
509    }
510
511    /// Launch tip-off: an initial yaw in radians that decays exponentially with
512    /// downrange distance over `decay_distance_m`. Models the rail/launcher
513    /// disturbance; it perturbs the effective angle of attack early in flight.
514    pub fn with_tipoff_yaw(mut self, yaw_rad: f64, decay_distance_m: f64) -> Result<Self> {
515        if !yaw_rad.is_finite() {
516            return Err(Error::invalid("tipoff yaw_rad", "must be finite"));
517        }
518        positive(decay_distance_m, "tipoff decay_distance_m")?;
519        self.inputs.tipoff_yaw = yaw_rad;
520        self.inputs.tipoff_decay_distance = decay_distance_m;
521        Ok(self)
522    }
523
524    /// Angle-of-attack aerodynamics without attitude integration: a quasi-static
525    /// normal force and induced drag from the commanded axis's angle to the
526    /// relative wind.
527    ///
528    /// `cna_vs_mach` is the normal-force coefficient slope per radian of angle of
529    /// attack (body + fins) versus Mach; `induced_k_vs_mach` adds Cd += k·α².
530    /// Both need at least two strictly ascending Mach points and nonnegative,
531    /// finite values. The angle of attack is measured each RK stage between the
532    /// solver-resolved thrust axis and the wind-relative velocity, so a guided
533    /// turn at angle of attack both turns harder (body lift) and pays drag for it.
534    /// Reference area derives from the diameter passed to [`Self::new`].
535    pub fn with_body_lift(
536        mut self,
537        cna_vs_mach: Vec<(f64, f64)>,
538        induced_k_vs_mach: Vec<(f64, f64)>,
539    ) -> Result<Self> {
540        let table = |pairs: Vec<(f64, f64)>, name: &'static str| -> Result<DragTable> {
541            mach_table(pairs, name)
542        };
543        self.body_lift = Some(BodyLift {
544            cna_per_rad: table(cna_vs_mach, "cna_vs_mach")?,
545            induced_k_per_rad2: table(induced_k_vs_mach, "induced_k_vs_mach")?,
546        });
547        Ok(self)
548    }
549
550    /// Change the effective twist between public steps, for a caller-integrated
551    /// roll model (see [`crate::RollDynamics`]). Pair with [`Self::with_spin`]
552    /// for the body length and handedness; the rate must stay positive.
553    pub fn set_twist(&mut self, twist_rate_in: f64) -> Result<()> {
554        positive(twist_rate_in, "twist_rate_in")?;
555        self.inputs.twist_rate = twist_rate_in;
556        Ok(())
557    }
558
559    /// Downrange-segmented atmosphere (upstream `AtmoSock`): station-referenced
560    /// zones, each `(temperature_c, pressure_hpa, humidity_percent, until_downrange_m)`.
561    /// Each zone is a reading taken at the station altitude: the zone selected
562    /// by downrange distance `position.x` replaces the station reading, and the
563    /// lapse from the station altitude to Y applies on top. The base atmosphere
564    /// must be [`Atmosphere::Standard`], and each zone's humidity applies to its
565    /// station density while this derivative path uses dry-air sound speed.
566    /// Drag, spin and body lift all read the zoned air; give
567    /// [`crate::RecoveryForces`] and [`crate::Weathercock`] the same ladder
568    /// through their own `with_atmosphere_zones`.
569    pub fn with_atmosphere_zones(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
570        self.air = self.air.with_zones(zones)?;
571        Ok(self)
572    }
573
574    /// Downrange-segmented wind (upstream `WindSock`). Each zone is
575    /// `(speed_mps, angle_deg, until_downrange_m, vertical_mps)`, with the angle
576    /// following the upstream wind-FROM convention (0° = headwind, 90° = from the
577    /// right). Vertical is positive updraft. Zones are evaluated statelessly at
578    /// each stage's `position.x`, so wind can change mid-integration.
579    /// Overrides the uniform wind set in [`Self::new`]/[`Self::set_wind`].
580    pub fn with_wind_segments(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
581        if zones.is_empty() {
582            return Err(Error::invalid(
583                "wind segments",
584                "needs at least one segment",
585            ));
586        }
587        let segments: Vec<WindSegment> = zones
588            .into_iter()
589            .map(
590                |(speed_mps, angle_deg, until_m, vertical_mps)| WindSegment {
591                    speed_kmh: speed_mps * 3.6,
592                    angle_deg,
593                    until_m,
594                    vertical_mps,
595                },
596            )
597            .collect();
598        let sock = WindSock::try_new(segments)
599            .map_err(|e| Error::Model(format!("invalid wind segments: {e:?}")))?;
600        self.wind_sock = Some(sock);
601        Ok(self)
602    }
603
604    /// Add deterministic frozen-field turbulence: multisine gusts with a
605    /// pink spectrum, RMS `sigma_mps` per axis and energy concentrated near
606    /// the period `tau_s`. `seed` fixes the phases (dispersion reseeds per
607    /// run), so runs are exactly reproducible.
608    pub fn with_turbulence(mut self, sigma_mps: f64, tau_s: f64, seed: u64) -> Result<Self> {
609        self.turbulence = Some(Turbulence::new(sigma_mps, tau_s, seed)?);
610        Ok(self)
611    }
612
613    /// Sample an arbitrary winds-engine field instead of the uniform vector
614    /// or zone ladder. Shared as an `Arc` so the weathercock and the
615    /// recovery descent can sample the same air. Stateless and
616    /// deterministic, so integration stays reproducible.
617    pub fn with_wind_field(
618        mut self,
619        field: std::sync::Arc<dyn winds_engine::WindField + Send + Sync>,
620    ) -> Result<Self> {
621        self.wind_field = Some(SharedWindField(field));
622        Ok(self)
623    }
624
625    /// Wind at a stage: the configured field if any, else segmented
626    /// downrange wind if configured, else the uniform vector; plus
627    /// frozen-field turbulence if configured.
628    fn effective_wind(&self, state: &FlightState) -> Vec3 {
629        let base = match &self.wind_field {
630            Some(field) => {
631                let w = field.0.wind(
632                    state.time_s,
633                    [state.position_m.x, state.position_m.y, state.position_m.z],
634                );
635                Vec3::new(w.x_mps, w.y_mps, w.z_mps)
636            }
637            None => match &self.wind_sock {
638                Some(sock) => sock.vector_for_range_stateless(state.position_m.x),
639                None => self.wind_mps,
640            },
641        };
642        match &self.turbulence {
643            Some(t) => base + t.gust_vec(state.time_s),
644            None => base,
645        }
646    }
647
648    /// Quasi-static AoA force increment: normal force toward the body-axis side of
649    /// the velocity, plus induced drag along it. Zero when aligned or in still air.
650    fn body_lift_accel(
651        &self,
652        state: &FlightState,
653        mass_kg: f64,
654        wind_mps: Vec3,
655        body_axis: Vec3,
656    ) -> Result<Vec3> {
657        let Some(lift) = &self.body_lift else {
658            return Ok(Vec3::zeros());
659        };
660        let v_air = state.velocity_mps - wind_mps;
661        let speed = v_air.norm();
662        // Degenerate cases: no relative wind, or a zero/ill-posed axis → no increment.
663        if speed < 1e-12 || body_axis.amax() == 0.0 {
664            return Ok(Vec3::zeros());
665        }
666        let v_hat = v_air / speed;
667        let axis = body_axis / body_axis.norm();
668        // Angle of attack from the axis to the relative wind.
669        let perp = axis - axis.dot(&v_hat) * v_hat;
670        let sin_alpha = perp.norm();
671        let cos_alpha = axis.dot(&v_hat).clamp(-1.0, 1.0);
672        let alpha = sin_alpha.atan2(cos_alpha);
673        if alpha < 1e-12 {
674            return Ok(Vec3::zeros());
675        }
676        let (rho, sound) = self.air.at(state.position_m);
677        if rho <= 0.0 || sound <= 0.0 {
678            return Ok(Vec3::zeros());
679        }
680        let mach = speed / sound;
681        let q = 0.5 * rho * speed * speed;
682        let area = std::f64::consts::FRAC_PI_4 * self.inputs.bullet_diameter.powi(2);
683        let normal_force = q * area * lift.cna_per_rad.interpolate(mach) * alpha;
684        let induced_drag = q * area * lift.induced_k_per_rad2.interpolate(mach) * alpha * alpha;
685        let accel = (normal_force * (perp / sin_alpha) - induced_drag * v_hat) / mass_kg;
686        if !finite_vec(&accel) {
687            return Err(Error::NumericalFailure(
688                "body lift returned non-finite acceleration",
689            ));
690        }
691        Ok(accel)
692    }
693
694    /// Axis-aware counterpart to [`Self::acceleration_in_wind`], for wrappers that
695    /// sample their own wind but want the body-lift increment.
696    pub fn acceleration_with_axis_in_wind(
697        &self,
698        state: &FlightState,
699        mass_kg: f64,
700        wind_mps: Vec3,
701        body_axis: Vec3,
702    ) -> Result<Vec3> {
703        let base = self.acceleration_in_wind(state, mass_kg, wind_mps)?;
704        Ok(base + self.body_lift_accel(state, mass_kg, wind_mps, body_axis)?)
705    }
706
707    /// Evaluate using externally sampled wind, for a spatial or time-varying force wrapper.
708    pub fn acceleration_in_wind(
709        &self,
710        state: &FlightState,
711        mass_kg: f64,
712        wind_mps: Vec3,
713    ) -> Result<Vec3> {
714        state.validate()?;
715        positive(mass_kg, "mass_kg")?;
716        if !finite_vec(&wind_mps) {
717            return Err(Error::invalid("wind_mps", "must be finite"));
718        }
719        let mut inputs = self.inputs.clone();
720        inputs.bullet_mass = mass_kg;
721        inputs.weight_grains = mass_kg / GRAINS_TO_KG;
722        positive(inputs.weight_grains, "mass in grains")?;
723        let derivatives = compute_derivatives(
724            state.position_m,
725            state.velocity_mps,
726            &inputs,
727            wind_mps,
728            self.air.params(),
729            inputs.bc_value,
730            self.omega_rps,
731            state.time_s,
732            self.air.zones(),
733        );
734        let acceleration = Vec3::new(derivatives[3], derivatives[4], derivatives[5]);
735        if !finite_vec(&acceleration) {
736            return Err(Error::NumericalFailure(
737                "ballistics force model returned non-finite acceleration",
738            ));
739        }
740        Ok(acceleration)
741    }
742}
743
744impl ForceModel for BallisticForces {
745    fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3> {
746        self.acceleration_in_wind(state, mass_kg, self.effective_wind(state))
747    }
748
749    fn acceleration_with_axis(
750        &self,
751        state: &FlightState,
752        mass_kg: f64,
753        body_axis: Vec3,
754    ) -> Result<Vec3> {
755        self.acceleration_with_axis_in_wind(state, mass_kg, self.effective_wind(state), body_axis)
756    }
757}
758
759#[cfg(test)]
760mod tests {
761    use super::*;
762
763    #[test]
764    fn turbulence_rms_matches_sigma() {
765        let gust = Turbulence::new(2.5, 2.0, 42).unwrap();
766        // Long-run statistics per axis: mean ~0, RMS ~ sigma.
767        for axis in 0..3 {
768            let samples: Vec<f64> = (0..20_000)
769                .map(|i| gust.gust_vec(i as f64 * 0.05)[axis])
770                .collect();
771            let mean = samples.iter().sum::<f64>() / samples.len() as f64;
772            let var =
773                samples.iter().map(|s| (s - mean).powi(2)).sum::<f64>() / samples.len() as f64;
774            assert!(mean.abs() < 0.1, "axis {axis} mean {mean}");
775            assert!(
776                (var.sqrt() - 2.5).abs() / 2.5 < 0.05,
777                "axis {axis} rms {}",
778                var.sqrt()
779            );
780        }
781    }
782
783    #[test]
784    fn turbulence_is_deterministic_per_seed() {
785        let a = Turbulence::new(1.0, 2.0, 7).unwrap();
786        let b = Turbulence::new(1.0, 2.0, 7).unwrap();
787        let c = Turbulence::new(1.0, 2.0, 8).unwrap();
788        for i in 0..100 {
789            let t = i as f64 * 0.1;
790            assert_eq!(a.gust_vec(t), b.gust_vec(t));
791        }
792        assert_ne!(a.gust_vec(3.3), c.gust_vec(3.3));
793    }
794
795    #[test]
796    fn turbulence_validates_its_numbers() {
797        assert!(Turbulence::new(-1.0, 2.0, 0).is_err());
798        assert!(Turbulence::new(1.0, 0.0, 0).is_err());
799        assert!(Turbulence::new(f64::NAN, 2.0, 0).is_err());
800    }
801
802    fn pad_station() -> Atmosphere {
803        Atmosphere::Standard {
804            station_altitude_m: 1500.0,
805            temperature_c: 5.25,
806            pressure_hpa: 845.6,
807            humidity_percent: 30.0,
808        }
809    }
810
811    const POSITIONS: [(f64, f64); 6] = [
812        (0.0, 0.0),
813        (50.0, 1500.0),
814        (99.0, 1800.0),
815        (500.0, 4000.0),
816        (5_000.0, 12_000.0),
817        (50_000.0, -200.0),
818    ];
819
820    #[test]
821    fn air_without_zones_is_the_station_lapse() {
822        // Byte-identical to what recovery, the weathercock and body lift read
823        // before the shared lookup: get_local_atmosphere from the station.
824        for atmosphere in [Atmosphere::default(), pad_station()] {
825            let (base_alt, temp_c, press_hpa, ratio) = atmosphere.parameters().unwrap();
826            let air = Air::new(atmosphere).unwrap();
827            for (x, y) in POSITIONS {
828                assert_eq!(
829                    air.at(Vec3::new(x, y, 7.0)),
830                    get_local_atmosphere(y, base_alt, temp_c, press_hpa, ratio)
831                );
832            }
833        }
834        let uniform = Air::new(Atmosphere::Uniform {
835            density_kg_m3: 0.9,
836            speed_of_sound_mps: 330.0,
837        })
838        .unwrap();
839        assert_eq!(uniform.at(Vec3::new(10.0, 9000.0, 0.0)), (0.9, 330.0));
840    }
841
842    #[test]
843    fn a_zone_reading_replaces_the_station_reading() {
844        // A zone carrying a reading reads exactly like a station with that
845        // reading at the same altitude, and the zone is chosen by X.
846        let low = Atmosphere::Standard {
847            station_altitude_m: 1500.0,
848            temperature_c: -5.0,
849            pressure_hpa: 700.0,
850            humidity_percent: 50.0,
851        };
852        let zoned = Air::new(pad_station())
853            .unwrap()
854            .with_zones(vec![(5.25, 845.6, 30.0, 100.0), (-5.0, 700.0, 50.0, 1e6)])
855            .unwrap();
856        let near = Air::new(pad_station()).unwrap();
857        let far = Air::new(low).unwrap();
858        for (x, y) in POSITIONS {
859            let p = Vec3::new(x, y, 0.0);
860            let expected = if x < 100.0 { near.at(p) } else { far.at(p) };
861            assert_eq!(zoned.at(p), expected, "at x {x}, y {y}");
862        }
863    }
864
865    #[test]
866    fn air_density_is_the_drag_kernels_density() {
867        // With a Mach-independent Cd the kernel's drag depends on the air only
868        // through its density, so the zoned kernel must match a kernel handed
869        // the lookup's density directly, at every position and zone.
870        let cd = vec![(0.0, 0.4), (5.0, 0.4)];
871        let zones = vec![(5.25, 845.6, 30.0, 100.0), (-5.0, 700.0, 50.0, 1e6)];
872        let zoned = BallisticForces::new(0.05, cd.clone(), pad_station(), Vec3::zeros())
873            .unwrap()
874            .with_atmosphere_zones(zones.clone())
875            .unwrap();
876        let air = Air::new(pad_station()).unwrap().with_zones(zones).unwrap();
877        for (x, y) in POSITIONS {
878            let state = FlightState {
879                time_s: 0.0,
880                position_m: Vec3::new(x, y, 0.0),
881                velocity_mps: Vec3::new(150.0, 40.0, 0.0),
882            };
883            let (density_kg_m3, speed_of_sound_mps) = air.at(state.position_m);
884            let direct = BallisticForces::new(
885                0.05,
886                cd.clone(),
887                Atmosphere::Uniform {
888                    density_kg_m3,
889                    speed_of_sound_mps,
890                },
891                Vec3::zeros(),
892            )
893            .unwrap();
894            assert_eq!(
895                zoned.acceleration_in_wind(&state, 0.3, Vec3::zeros()),
896                direct.acceleration_in_wind(&state, 0.3, Vec3::zeros()),
897                "at x {x}, y {y}"
898            );
899        }
900    }
901
902    #[test]
903    fn icao_standard_at_sea_level_is_the_default() {
904        assert_eq!(Atmosphere::icao_standard(0.0), Ok(Atmosphere::default()));
905    }
906
907    #[test]
908    fn icao_standard_matches_upstreams_standard_atmosphere() {
909        // Upstream's resolver turns a default (15 °C, 1013.25 hPa) reading at a
910        // real altitude into its ICAO-table values; above its 1 m sentinel the
911        // constructor must agree with it.
912        use ballistics_engine::atmosphere::resolve_station_conditions;
913        for altitude in [1.5, 10.0, 100.0, 1500.0, 4000.0, 8848.0, 10_999.0, -400.0] {
914            let Ok(Atmosphere::Standard {
915                station_altitude_m,
916                temperature_c,
917                pressure_hpa,
918                humidity_percent,
919            }) = Atmosphere::icao_standard(altitude)
920            else {
921                panic!("standard station at {altitude} m");
922            };
923            let (t, p) = resolve_station_conditions(15.0, 1013.25, altitude);
924            assert_eq!(station_altitude_m, altitude);
925            assert_eq!(humidity_percent, 0.0);
926            assert!(
927                (temperature_c - t).abs() < 1e-9,
928                "{altitude} m: {temperature_c} vs {t}"
929            );
930            assert!(
931                (pressure_hpa - p).abs() < 1e-9 * p,
932                "{altitude} m: {pressure_hpa} vs {p}"
933            );
934        }
935    }
936
937    #[test]
938    fn standard_air_is_the_same_whatever_the_station_altitude() {
939        // A standard station at 1500 m and one at sea level describe one
940        // atmosphere. Temperature, so sound speed, is the same at every
941        // altitude; density differs by the constant 2.5e-5 by which the
942        // station's CIPM density departs from upstream's ideal-gas lapse.
943        let pad = Air::new(Atmosphere::icao_standard(1500.0).unwrap()).unwrap();
944        let sea = Air::new(Atmosphere::default()).unwrap();
945        for y in [0.0, 1500.0, 3000.0, 10_000.0, 15_000.0] {
946            let (rho_pad, c_pad) = pad.at(Vec3::new(0.0, y, 0.0));
947            let (rho_sea, c_sea) = sea.at(Vec3::new(0.0, y, 0.0));
948            assert!((rho_pad - rho_sea).abs() < 3e-5 * rho_sea, "rho at {y} m");
949            assert!((c_pad - c_sea).abs() < 1e-12 * c_sea, "sound at {y} m");
950        }
951    }
952
953    #[test]
954    fn icao_standard_refuses_altitudes_outside_the_troposphere() {
955        for altitude in [f64::NAN, f64::INFINITY, -1000.5, 11_000.5] {
956            assert!(Atmosphere::icao_standard(altitude).is_err(), "{altitude}");
957        }
958    }
959
960    #[test]
961    fn wind_field_is_sampled_per_state() {
962        let profile = winds_engine::ProfileField::new(vec![
963            winds_engine::ProfileLevel {
964                altitude_m: 0.0,
965                speed_mps: 0.0,
966                direction_from_deg: 0.0,
967            },
968            winds_engine::ProfileLevel {
969                altitude_m: 1000.0,
970                speed_mps: 10.0,
971                direction_from_deg: 0.0,
972            },
973        ])
974        .unwrap();
975        let forces = BallisticForces::new(
976            0.05,
977            vec![(0.0, 0.4), (3.0, 0.4)],
978            Atmosphere::default(),
979            Vec3::zeros(),
980        )
981        .unwrap()
982        .with_wind_field(std::sync::Arc::new(profile))
983        .unwrap();
984        let state = FlightState {
985            time_s: 1.0,
986            position_m: Vec3::new(0.0, 500.0, 0.0),
987            velocity_mps: Vec3::zeros(),
988        };
989        // FROM 0° blows toward −X; 5 m/s at 500 m altitude.
990        let wind = forces.effective_wind(&state);
991        assert!((wind.x + 5.0).abs() < 1e-9, "wind {wind:?}");
992        assert!(wind.y.abs() < 1e-12 && wind.z.abs() < 1e-9);
993    }
994
995    #[test]
996    fn turbulence_adds_to_the_effective_wind() {
997        let forces = BallisticForces::new(
998            0.05,
999            vec![(0.0, 0.4), (3.0, 0.4)],
1000            Atmosphere::default(),
1001            Vec3::new(3.0, 0.0, 0.0),
1002        )
1003        .unwrap()
1004        .with_turbulence(2.0, 2.0, 42)
1005        .unwrap();
1006        let state = FlightState {
1007            time_s: 1.7,
1008            position_m: Vec3::zeros(),
1009            velocity_mps: Vec3::zeros(),
1010        };
1011        let expected = Vec3::new(3.0, 0.0, 0.0) + forces.turbulence.as_ref().unwrap().gust_vec(1.7);
1012        assert_eq!(forces.effective_wind(&state), expected);
1013    }
1014}