Skip to main content

ballistics_engine/
atmosphere.rs

1//! Enhanced atmospheric calculations for ballistics.
2//!
3//! This module provides Rust-accelerated implementations of atmospheric calculations
4//! with full ICAO Standard Atmosphere support for improved accuracy at all altitudes.
5
6use crate::gravity::Gravity;
7use std::cmp::Ordering;
8
9/// ICAO Standard Atmosphere layer definitions
10#[derive(Debug, Clone)]
11struct AtmosphereLayer {
12    /// Base geopotential height of this layer (m)
13    base_altitude: f64,
14    /// Base temperature at layer start (K)
15    base_temperature: f64,
16    /// Base pressure at layer start (Pa)
17    base_pressure: f64,
18    /// Temperature lapse rate (K/m)
19    lapse_rate: f64,
20}
21
22/// ICAO Standard Atmosphere constants
23// ICAO reference quantities are defined using standard Earth gravity, independently of the
24// acceleration chosen for an internal simulation.
25const G_ACCEL_MPS2: f64 = crate::constants::G_ACCEL_MPS2;
26const R_AIR: f64 = 287.0531; // Specific gas constant for dry air (J/(kg·K))
27const GAMMA: f64 = 1.4; // Heat capacity ratio for air
28const GEOPOTENTIAL_EARTH_RADIUS_M: f64 = 6_356_766.0;
29const MIN_GEOMETRIC_ALTITUDE_M: f64 = -5000.0;
30const MAX_GEOMETRIC_ALTITUDE_M: f64 = 84000.0;
31const MIN_STANDARD_GEOPOTENTIAL_HEIGHT_M: f64 = -5000.0;
32const MAX_STANDARD_GEOPOTENTIAL_HEIGHT_M: f64 = 84000.0;
33
34/// CIPM constants for precise air density calculation
35const R: f64 = 8.314472; // Universal gas constant
36const M_A: f64 = 28.96546e-3; // Molar mass of dry air (kg/mol)
37const M_V: f64 = 18.01528e-3; // Molar mass of water vapor (kg/mol)
38
39/// ICAO Standard Atmosphere layer data up to 84 km
40/// Pressures calculated using barometric formula between layers
41const ICAO_LAYERS: &[AtmosphereLayer] = &[
42    // Troposphere (-5 - 11 km; the sea-level base extrapolates smoothly below 0 m)
43    AtmosphereLayer {
44        base_altitude: 0.0,
45        base_temperature: 288.15, // 15°C
46        base_pressure: 101325.0,  // 1013.25 hPa
47        lapse_rate: -0.0065,      // -6.5 K/km
48    },
49    // Tropopause (11 - 20 km)
50    AtmosphereLayer {
51        base_altitude: 11000.0,
52        base_temperature: 216.65, // -56.5°C
53        base_pressure: 22632.1,   // 226.32 hPa
54        lapse_rate: 0.0,          // Isothermal
55    },
56    // Stratosphere 1 (20 - 32 km)
57    AtmosphereLayer {
58        base_altitude: 20000.0,
59        base_temperature: 216.65, // -56.5°C
60        base_pressure: 5474.89,   // 54.75 hPa
61        lapse_rate: 0.001,        // +1 K/km
62    },
63    // Stratosphere 2 (32 - 47 km)
64    AtmosphereLayer {
65        base_altitude: 32000.0,
66        base_temperature: 228.65, // -44.5°C
67        base_pressure: 868.02,    // 8.68 hPa
68        lapse_rate: 0.0028,       // +2.8 K/km
69    },
70    // Stratopause (47 - 51 km)
71    AtmosphereLayer {
72        base_altitude: 47000.0,
73        base_temperature: 270.65, // -2.5°C
74        base_pressure: 110.91,    // 1.11 hPa
75        lapse_rate: 0.0,          // Isothermal
76    },
77    // Mesosphere 1 (51 - 71 km)
78    AtmosphereLayer {
79        base_altitude: 51000.0,
80        base_temperature: 270.65, // -2.5°C
81        base_pressure: 66.94,     // 0.67 hPa
82        lapse_rate: -0.0028,      // -2.8 K/km
83    },
84    // Mesosphere 2 (71 - 84 km)
85    AtmosphereLayer {
86        base_altitude: 71000.0,
87        base_temperature: 214.65, // -58.5°C
88        base_pressure: 3.96,      // 0.04 hPa
89        lapse_rate: -0.002,       // -2.0 K/km
90    },
91];
92
93/// Calculate ICAO Standard Atmosphere conditions at any altitude.
94///
95/// This function implements the full ICAO Standard Atmosphere model with all
96/// atmospheric layers up to 84 km altitude.
97///
98/// # Arguments
99/// * `altitude_m` - Geometric altitude above mean sea level in meters (-5000 to 84000; values
100///   outside are clamped). It is converted to geopotential height before layer evaluation.
101///
102/// # Returns
103/// Tuple of (temperature_k, pressure_pa)
104pub(crate) fn calculate_icao_standard_atmosphere(altitude_m: f64) -> (f64, f64) {
105    let geometric_altitude = altitude_m.clamp(MIN_GEOMETRIC_ALTITUDE_M, MAX_GEOMETRIC_ALTITUDE_M);
106    let geopotential_height = geometric_to_geopotential_height_m(geometric_altitude).clamp(
107        MIN_STANDARD_GEOPOTENTIAL_HEIGHT_M,
108        MAX_STANDARD_GEOPOTENTIAL_HEIGHT_M,
109    );
110
111    // Find the appropriate atmospheric layer
112    let layer = ICAO_LAYERS
113        .iter()
114        .rev()
115        .find(|layer| geopotential_height >= layer.base_altitude)
116        .unwrap_or(&ICAO_LAYERS[0]);
117
118    let height_diff = geopotential_height - layer.base_altitude;
119    let temperature = layer.base_temperature + layer.lapse_rate * height_diff;
120
121    let pressure = if layer.lapse_rate.abs() < 1e-10 {
122        // Isothermal layer
123        layer.base_pressure * (-G_ACCEL_MPS2 * height_diff / (R_AIR * layer.base_temperature)).exp()
124    } else {
125        // Non-isothermal layer
126        let temp_ratio = temperature / layer.base_temperature;
127        layer.base_pressure * temp_ratio.powf(-G_ACCEL_MPS2 / (layer.lapse_rate * R_AIR))
128    };
129
130    (temperature, pressure)
131}
132
133/// Convert physical geometric altitude to the geopotential height used by ICAO layer tables.
134#[inline]
135fn geometric_to_geopotential_height_m(geometric_altitude_m: f64) -> f64 {
136    GEOPOTENTIAL_EARTH_RADIUS_M * geometric_altitude_m
137        / (GEOPOTENTIAL_EARTH_RADIUS_M + geometric_altitude_m)
138}
139
140/// Whether a pressure lies inside the legacy default sentinel's band, within 0.5 hPa of the
141/// 1013.25 hPa sea-level standard. [`resolve_station_pressure`] reads an unmarked value there
142/// as omitted at any altitude above 1 m.
143pub(crate) fn in_legacy_default_pressure_band(pressure_hpa: f64) -> bool {
144    const SEA_LEVEL_HPA: f64 = 1013.25;
145    (pressure_hpa - SEA_LEVEL_HPA).abs() < 0.5
146}
147
148/// Whether a temperature lies inside the legacy default sentinel's band, within 0.1 °C of the
149/// 15 °C sea-level standard. [`resolve_station_temperature`] reads an unmarked value there as
150/// omitted at any altitude above 1 m.
151pub(crate) fn in_legacy_default_temperature_band(temperature_c: f64) -> bool {
152    const SEA_LEVEL_TEMP_C: f64 = 15.0;
153    (temperature_c - SEA_LEVEL_TEMP_C).abs() < 0.1
154}
155
156/// Resolve the station-pressure override for an air-density calculation, by the legacy
157/// default-sentinel convention.
158///
159/// Presence-blind legacy convention: a bare `f64` cannot say whether it was supplied, so a
160/// sea-level default at a real altitude is read as omitted. The rule:
161/// * A pressure within 0.5 hPa of the 1013.25 hPa sea-level standard at an altitude above 1 m
162///   is read as "omitted, standard atmosphere at this altitude": `None`, so
163///   [`calculate_atmosphere`] derives the station pressure from altitude (ICAO standard)
164///   instead of flying sea-level density there.
165/// * Any other value is returned as `Some` and used as the STATION pressure, so altitude is
166///   not double-counted.
167///
168/// The ±0.5 hPa tolerance covers the `29.92 inHg ≈ 1013.21 hPa` conversion, and `>1 m` avoids
169/// triggering at sea level. A real reading inside that band (QNH 1031 hPa at 150 m reduces to
170/// 1012.86 hPa) is indistinguishable from the default here. A caller that knows presence uses
171/// [`resolve_station_conditions_with_presence`], which flies a supplied value as given.
172pub fn resolve_station_pressure(pressure_hpa: f64, altitude_m: f64) -> Option<f64> {
173    if in_legacy_default_pressure_band(pressure_hpa) && altitude_m.abs() > 1.0 {
174        None // pressure left at default + real altitude → derive station pressure from altitude
175    } else {
176        Some(pressure_hpa) // explicit station pressure is authoritative
177    }
178}
179
180/// Resolve the temperature override for an air-density calculation, by the legacy
181/// default-sentinel convention, mirroring [`resolve_station_pressure`].
182///
183/// Presence-blind legacy convention: a bare `f64` cannot say whether it was supplied, so a
184/// sea-level default at a real altitude is read as omitted.
185/// * A temperature within 0.1 °C of the 15 °C sea-level standard at an altitude above 1 m is
186///   read as "omitted, standard atmosphere at this altitude": `None`, so
187///   [`calculate_atmosphere`] applies the ICAO lapse-rate temperature for that altitude
188///   (≈ −6.5 °C/km).
189/// * Any other value is returned as `Some` and used as given.
190///
191/// Without the omitted case, `--altitude` with the default temperature held the air at 15 °C,
192/// which under-estimates density by ~2.4% at 1 km up to ~7% at 3 km versus the standard
193/// atmosphere (validated against py_ballisticcalc). The 0.1 °C tolerance matches the
194/// `59 °F = 15.0 °C` default exactly, and `>1 m` avoids triggering at sea level. A real 15 °C
195/// reading at altitude is indistinguishable from the default here; a caller that knows presence
196/// uses [`resolve_station_conditions_with_presence`].
197pub fn resolve_station_temperature(temperature_c: f64, altitude_m: f64) -> Option<f64> {
198    if in_legacy_default_temperature_band(temperature_c) && altitude_m.abs() > 1.0 {
199        None // temperature left at default + real altitude → derive ICAO lapse temperature
200    } else {
201        Some(temperature_c) // explicit temperature is authoritative
202    }
203}
204
205/// Return the station temperature and pressure that [`calculate_atmosphere`] will use after
206/// applying the default-at-altitude resolution rules.
207///
208/// A thin, byte-identical delegate to
209/// [`resolve_station_conditions_with_pressure_mode`] at [`PressureReferenceMode::Absolute`]
210/// (MBA-1397) — kept as a separate name/signature so no existing caller needs to change.
211///
212/// Presence-blind: both values go through the legacy default sentinels
213/// ([`resolve_station_temperature`], [`resolve_station_pressure`]). See
214/// [`resolve_station_conditions_with_presence`].
215pub fn resolve_station_conditions(
216    temperature_c: f64,
217    pressure_hpa: f64,
218    altitude_m: f64,
219) -> (f64, f64) {
220    resolve_station_conditions_with_pressure_mode(
221        temperature_c,
222        pressure_hpa,
223        altitude_m,
224        PressureReferenceMode::Absolute,
225    )
226}
227
228/// Whether a station-pressure input is already absolute (station) pressure, or a sea-level-
229/// corrected altimeter setting (QNH / "barometer" reading) that must be reduced to station
230/// pressure at the shooter's altitude before use (MBA-1397).
231///
232/// Kestrel, AB Mobile, Shooter, JBM, and Hornady 4DOF all let the user declare which one they
233/// are entering. Previously this engine only supported [`Absolute`](Self::Absolute) — an
234/// entered sea-level-corrected barometer/METAR value at a real altitude was silently treated
235/// as station pressure, over-stating air density and under-stating drop.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
237#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
238pub enum PressureReferenceMode {
239    /// The input is already absolute station pressure — today's meaning, and the default, so
240    /// every existing caller that never sets this is byte-identical to pre-MBA-1397 behavior.
241    #[default]
242    Absolute,
243    /// The input is a sea-level-corrected altimeter setting (QNH) and must be reduced to
244    /// station pressure at the target altitude via [`reduce_qnh_to_station_pressure`].
245    Qnh,
246}
247
248/// Reduce a sea-level-corrected altimeter setting (QNH) to the station pressure at
249/// `altitude_m` (MBA-1397), using the altimeter-setting relation (MBA-1605):
250///
251/// `station = QNH * (1 + (L*h/T0) * (P0/QNH)^n)^(1/n)`, with `n = -L*R/g`
252///
253/// for the ICAO troposphere's lapse rate `L` (-0.0065 K/m), sea-level temperature `T0`
254/// (288.15 K) and sea-level pressure `P0` (1013.25 hPa), so `n` = 0.190263. This is what an
255/// altimeter setting means: an altimeter set to QNH reads field elevation on the ground, so the
256/// station pressure's ISA pressure altitude is the QNH's own pressure altitude plus `h`. Written
257/// that way it is the additive form `station = P0 * ((QNH/P0)^n + L*h/T0)^(1/n)`; the
258/// algebraically equal form above is the one evaluated, because it returns QNH bit-exactly at
259/// `h = 0`.
260///
261/// This is the altimeter-setting relation on ICAO constants with geopotential height. It has the
262/// shape of the NWS/ASOS altimeter-setting algorithm but is not that algorithm, which uses its
263/// own rounded constants (`n` = 0.190284 and `k` = 8.4228807e-5, `P0^n * 0.0065/T0` at 288 K),
264/// adds the field elevation as entered rather than its geopotential height, and subtracts a
265/// 0.3 hPa sensor offset. Even without that offset, NWS/ASOS gives a station pressure about
266/// 0.1 hPa lower than this function at 1500 m and 0.3 to 0.45 hPa lower at 4000 to 5000 m (QNH
267/// 950 to 1050 hPa).
268///
269/// Before 0.49.0 this function scaled the standard pressure ratio by QNH,
270/// `station = QNH * (1 + L*h/T0)^(1/n)`, and called that the altimeter-setting reduction. It is
271/// not: the two agree only at QNH = 1013.25 hPa or at `h = 0`, and the ratio form ran 0.49 hPa
272/// low for a QNH of 1030 hPa at 1500 m.
273///
274/// `h` is GEOPOTENTIAL height, not the geometric altitude the caller supplies — this function
275/// converts the one to the other first (`geometric_to_geopotential_height_m`), as ISA requires.
276/// Substituting geometric altitude directly into that expression reproduces the reduction only
277/// to about 0.04 hPa at 1500 m, which is why the worked values below are not what the bare
278/// formula gives.
279///
280/// Reuses the SAME troposphere layer (`ICAO_LAYERS[0]`) and physical constants
281/// (`G_ACCEL_MPS2`, `R_AIR`) that `calculate_icao_standard_atmosphere` (private to this crate)
282/// uses for its own non-isothermal-layer pressure formula, rather than re-hardcoding the
283/// lapse rate / sea-level temperature / exponent a second time. `altitude_m` is clamped and
284/// converted to geopotential height exactly like `calculate_icao_standard_atmosphere`, and
285/// `(P0/QNH)^n` is exactly 1 at QNH = 1013.25 hPa. At QNH = 1013.25 hPa the reduction therefore
286/// equals the ICAO standard station pressure to rounding, but not bit for bit at every altitude
287/// (for example 1012.0492647136854 against 1012.0492647136847 at 10 m; the two agree exactly at
288/// 150 m). Callers that must stay bit-identical for an omitted pressure keep the mode they
289/// already used for it.
290///
291/// The reduction is only physically meaningful within the troposphere (the altitude range
292/// real shooting takes place in); it is not re-derived per-layer for higher altitudes. No
293/// station pressure exists once the QNH's pressure altitude plus `h` reaches `T0/-L`
294/// (44,331 m, where the troposphere's lapse rate reaches absolute zero), nor for a non-positive
295/// QNH; there this returns NaN, which callers refuse as a non-finite pressure.
296pub fn reduce_qnh_to_station_pressure(qnh_hpa: f64, altitude_m: f64) -> f64 {
297    let geometric_altitude = altitude_m.clamp(MIN_GEOMETRIC_ALTITUDE_M, MAX_GEOMETRIC_ALTITUDE_M);
298    let geopotential_height = geometric_to_geopotential_height_m(geometric_altitude).clamp(
299        MIN_STANDARD_GEOPOTENTIAL_HEIGHT_M,
300        MAX_STANDARD_GEOPOTENTIAL_HEIGHT_M,
301    );
302    let layer = &ICAO_LAYERS[0]; // troposphere: the only layer the altimeter-setting relation covers
303    let height_term = layer.lapse_rate * geopotential_height / layer.base_temperature;
304    let exponent = -G_ACCEL_MPS2 / (layer.lapse_rate * R_AIR); // 1/n
305    let sea_level_hpa = layer.base_pressure / 100.0;
306    let setting_term = (sea_level_hpa / qnh_hpa).powf(exponent.recip()); // (P0/QNH)^n
307    qnh_hpa * (1.0 + height_term * setting_term).powf(exponent)
308}
309
310/// Mode-aware counterpart of [`resolve_station_pressure`] (MBA-1397). Does NOT change that
311/// function's signature or behavior — [`PressureReferenceMode::Absolute`] is a pure
312/// passthrough to it — so this is a new function alongside the original, not a replacement.
313///
314/// [`PressureReferenceMode::Qnh`] reduces `pressure_hpa` (interpreted as a QNH) to station
315/// pressure via [`reduce_qnh_to_station_pressure`] and returns it as `Some(..)`
316/// UNCONDITIONALLY, bypassing the default-sentinel ambiguity `resolve_station_pressure` must
317/// use to infer omission from a bare `f64`. This matters: a QNH the caller explicitly declared
318/// should never be silently re-interpreted as an omitted default merely because the REDUCED
319/// number happens to land within the sentinel's tolerance of the 1013.25 hPa sea-level
320/// constant (a real, reachable case — e.g. a high-pressure day's QNH reducing to ~1013 hPa at
321/// a few hundred meters of elevation).
322pub fn resolve_station_pressure_with_mode(
323    pressure_hpa: f64,
324    altitude_m: f64,
325    mode: PressureReferenceMode,
326) -> Option<f64> {
327    match mode {
328        PressureReferenceMode::Absolute => resolve_station_pressure(pressure_hpa, altitude_m),
329        PressureReferenceMode::Qnh => {
330            Some(reduce_qnh_to_station_pressure(pressure_hpa, altitude_m))
331        }
332    }
333}
334
335/// Mode-aware counterpart of [`resolve_station_conditions`] (MBA-1397): identical for
336/// [`PressureReferenceMode::Absolute`] (byte-identical delegate — [`resolve_station_conditions`]
337/// itself now forwards here), and reduces an explicit QNH pressure to station pressure via
338/// [`resolve_station_pressure_with_mode`] for [`PressureReferenceMode::Qnh`] before applying
339/// the same standard-atmosphere fallback used when either input is left at its sea-level
340/// default.
341///
342/// Presence-blind: this is [`resolve_station_conditions_with_presence`] with
343/// [`StationAtmospherePresence::NEITHER`], bit for bit. The temperature sentinel runs in BOTH
344/// arms, so a typed 15 °C at altitude flies the ICAO lapse temperature here even with a
345/// declared QNH; only the pressure sentinel is bypassed in the `Qnh` arm. A caller that knows
346/// which values were supplied uses [`resolve_station_conditions_with_presence`].
347pub fn resolve_station_conditions_with_pressure_mode(
348    temperature_c: f64,
349    pressure_hpa: f64,
350    altitude_m: f64,
351    pressure_mode: PressureReferenceMode,
352) -> (f64, f64) {
353    resolve_station_conditions_with_presence(
354        temperature_c,
355        pressure_hpa,
356        altitude_m,
357        pressure_mode,
358        StationAtmospherePresence::NEITHER,
359    )
360}
361
362/// Which of a station temperature / pressure pair the caller actually supplied (0.49.0).
363///
364/// A field that is not supplied is resolved by the pre-0.49.0 default-sentinel rule
365/// ([`resolve_station_temperature`] / [`resolve_station_pressure_with_mode`]): the ICAO
366/// standard at |altitude| > 1 m when the value is the sea-level default, the value itself
367/// otherwise. That makes [`Self::NEITHER`] exactly the old behaviour of every presence-blind
368/// API, which is why it is the `Default`, and makes a supplied field authoritative whatever
369/// its value: a typed 15 °C, 59 °F, 1013.25 hPa or 29.92 inHg at altitude flies as typed.
370///
371/// Pass it to [`resolve_station_conditions_with_presence`], or to one of the
372/// `*_with_station_presence` twins of the solver, zero, Monte Carlo, lead, hold-curve and
373/// truing entry points.
374#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
375pub struct StationAtmospherePresence {
376    /// The station temperature was a reading the caller supplied.
377    pub temperature_supplied: bool,
378    /// The station pressure (or the QNH it is reduced from) was a reading the caller supplied.
379    pub pressure_supplied: bool,
380}
381
382impl StationAtmospherePresence {
383    /// Neither value was supplied: the pre-0.49.0 default-sentinel rule, bit for bit.
384    pub const NEITHER: Self = Self::new(false, false);
385    /// Both values were supplied: both fly as given (a declared QNH after its reduction).
386    pub const BOTH: Self = Self::new(true, true);
387
388    /// Presence from two flags.
389    pub const fn new(temperature_supplied: bool, pressure_supplied: bool) -> Self {
390        Self {
391            temperature_supplied,
392            pressure_supplied,
393        }
394    }
395
396    /// Presence of a temperature reading and a pressure reading.
397    pub const fn from_readings(temperature: StationReading, pressure: StationReading) -> Self {
398        Self::new(temperature.supplied, pressure.supplied)
399    }
400}
401
402/// A station temperature or pressure on a presence-carrying request (0.49.0): the value the
403/// engine is handed and whether the caller supplied it.
404///
405/// On the wire it is a bare JSON number when supplied and ABSENT when omitted. The type's own
406/// `Deserialize` rejects a JSON `null`; the truing DTOs (`TruingModelInputsV1`,
407/// `WindTruingRequest`) read a `null` field as omitted, as the `card.*` requests do. An omitted
408/// reading carries the surface's legacy default (59 °F / 29.92 inHg on
409/// the truing DTOs, the metric CLI's 1013.25 hPa expressed in inHg, and so on), so it resolves
410/// exactly as that default did before 0.49.0. Use [`Self::omitted`] for that, never a bare
411/// `Option`: at |altitude| <= 1 m an omitted value flies as itself, so the default it carries
412/// is part of the result.
413#[derive(Debug, Clone, Copy, PartialEq)]
414pub struct StationReading {
415    /// The value handed to the engine.
416    pub value: f64,
417    /// Whether the caller supplied it. `false` means the legacy default-sentinel rule decides.
418    pub supplied: bool,
419}
420
421impl StationReading {
422    /// A reading the caller supplied: it flies as given.
423    pub const fn supplied(value: f64) -> Self {
424        Self {
425            value,
426            supplied: true,
427        }
428    }
429
430    /// No reading: `default_value` is the surface's legacy default, resolved by the
431    /// pre-0.49.0 rule (ICAO standard at a real altitude when it is the sea-level default).
432    pub const fn omitted(default_value: f64) -> Self {
433        Self {
434            value: default_value,
435            supplied: false,
436        }
437    }
438
439    /// `true` when no reading was supplied. Used as `skip_serializing_if`.
440    pub fn is_omitted(&self) -> bool {
441        !self.supplied
442    }
443}
444
445impl serde::Serialize for StationReading {
446    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
447        serializer.serialize_f64(self.value)
448    }
449}
450
451impl<'de> serde::Deserialize<'de> for StationReading {
452    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
453        <f64 as serde::Deserialize>::deserialize(deserializer).map(Self::supplied)
454    }
455}
456
457/// Resolve the station temperature and pressure a solve flies, honouring which of them the
458/// caller supplied (0.49.0). This is the presence-aware resolver every surface that knows
459/// presence goes through.
460///
461/// For each field:
462/// * supplied: returned as given; a pressure declared as QNH ([`PressureReferenceMode::Qnh`])
463///   is first reduced to station pressure with [`reduce_qnh_to_station_pressure`];
464/// * omitted: resolved exactly as before 0.49.0, through [`resolve_station_temperature`] /
465///   [`resolve_station_pressure_with_mode`], then the ICAO standard at `altitude_m` when the
466///   value is the sea-level default at a real altitude.
467///
468/// With [`StationAtmospherePresence::NEITHER`] the result is bit-identical to
469/// [`resolve_station_conditions_with_pressure_mode`] (which now delegates here). With
470/// [`StationAtmospherePresence::BOTH`] in [`PressureReferenceMode::Absolute`] it returns the
471/// pair unchanged.
472pub fn resolve_station_conditions_with_presence(
473    temperature_c: f64,
474    pressure_hpa: f64,
475    altitude_m: f64,
476    pressure_mode: PressureReferenceMode,
477    presence: StationAtmospherePresence,
478) -> (f64, f64) {
479    let temp_override = if presence.temperature_supplied {
480        Some(temperature_c)
481    } else {
482        resolve_station_temperature(temperature_c, altitude_m)
483    };
484    let press_override = if presence.pressure_supplied {
485        Some(match pressure_mode {
486            PressureReferenceMode::Absolute => pressure_hpa,
487            PressureReferenceMode::Qnh => reduce_qnh_to_station_pressure(pressure_hpa, altitude_m),
488        })
489    } else {
490        resolve_station_pressure_with_mode(pressure_hpa, altitude_m, pressure_mode)
491    };
492    if let (Some(temp_c), Some(pressure_hpa)) = (temp_override, press_override) {
493        // Value-neutral fast path: the ICAO evaluation below would be discarded.
494        return (temp_c, pressure_hpa);
495    }
496    let (std_temp_k, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
497    (
498        temp_override.unwrap_or(std_temp_k - 273.15),
499        press_override.unwrap_or(std_pressure_pa / 100.0),
500    )
501}
502
503// ---------------------------------------------------------------------------------------
504// Station-pressure plausibility
505// ---------------------------------------------------------------------------------------
506
507/// The lowest ratio of station pressure to the ICAO standard pressure at the station's
508/// altitude that [`station_pressure_plausibility`] treats as weather. See that function for
509/// where the band comes from.
510pub const STATION_PRESSURE_MIN_PLAUSIBLE_RATIO: f64 = 0.85;
511/// The highest ratio of station pressure to the ICAO standard pressure at the station's
512/// altitude that [`station_pressure_plausibility`] treats as weather.
513pub const STATION_PRESSURE_MAX_PLAUSIBLE_RATIO: f64 = 1.08;
514
515/// How an explicit absolute station pressure compares with the ICAO standard pressure at the
516/// altitude it was sent with. Returned by [`station_pressure_plausibility`].
517#[derive(Debug, Clone, Copy, PartialEq)]
518pub struct StationPressurePlausibility {
519    /// The ICAO standard pressure at the altitude, in pascals.
520    pub icao_standard_pa: f64,
521    /// The station pressure divided by `icao_standard_pa`.
522    pub ratio: f64,
523    /// Whether `ratio` lies outside [`STATION_PRESSURE_MIN_PLAUSIBLE_RATIO`] to
524    /// [`STATION_PRESSURE_MAX_PLAUSIBLE_RATIO`] (both edges inside), or is not finite.
525    pub implausible: bool,
526}
527
528/// Compare an explicit absolute station pressure, in pascals, with the ICAO standard pressure
529/// at `altitude_m`, and say whether the two can describe the same station.
530///
531/// An explicit absolute pressure is authoritative wherever the engine takes one, so a pressure
532/// that belongs to some other altitude is flown as it was sent. The two common cases are a
533/// sea-level figure sent at a real elevation: a 101325 Pa placeholder, or a weather report's
534/// altimeter setting (QNH) sent as station pressure instead of as a QNH. At Cusco's 3310.128 m
535/// a 101325 Pa placeholder is 1.503 times the standard pressure there. The reverse, a true
536/// station reading sent with the wrong altitude (omitted, or in the wrong unit), shows up as a
537/// ratio far from 1 too. Callers use this to warn; nothing here changes a pressure.
538///
539/// The band, [`STATION_PRESSURE_MIN_PLAUSIBLE_RATIO`] to
540/// [`STATION_PRESSURE_MAX_PLAUSIBLE_RATIO`], is wide enough that recorded weather never leaves
541/// it. The WMO's table of weather and climate extremes (as of 31 July 2025) lists these
542/// sea-level pressure records:
543///
544/// - 870 hPa in the eye of Typhoon Tip, 12 October 1979, the lowest (tornadoes excluded);
545/// - 1083.8 hPa at Agata, Russia, 31 December 1968, the highest at a station below 750 m;
546/// - 1089.1 hPa at Tosontsengel, Mongolia, 30 December 2004, the highest above 750 m.
547///
548/// Against the 1013.25 hPa standard those are 0.859, 1.070 and 1.075. They are sea-level
549/// figures. At a station above sea level the high extreme moves toward standard, because the
550/// record highs come from cold air: reduced to sea level through a column denser than the
551/// standard one, a station pressure nearer standard becomes the record sea-level figure. Cold
552/// air moves the low side the other way. Under a cold column an ordinary sea-level pressure
553/// puts a high station further below standard than the sea-level figure is: a 3000 m station
554/// under a 243 K column reads 0.948 times the ICAO standard there with 1013.25 hPa at sea
555/// level, and 0.917 with 980 hPa. Both stay well above 0.85.
556///
557/// The band catches gross mismatches, not every placeholder: 101325 Pa stays inside it up to
558/// about 645 m, where it is already 8% above standard.
559///
560/// This is for absolute station pressure only. A QNH is a sea-level figure by declaration, so
561/// comparing it with the standard at the station's altitude would say nothing about it.
562pub fn station_pressure_plausibility(
563    station_pressure_pa: f64,
564    altitude_m: f64,
565) -> StationPressurePlausibility {
566    let (_, icao_standard_pa) = calculate_icao_standard_atmosphere(altitude_m);
567    let ratio = station_pressure_pa / icao_standard_pa;
568    let implausible = !(STATION_PRESSURE_MIN_PLAUSIBLE_RATIO
569        ..=STATION_PRESSURE_MAX_PLAUSIBLE_RATIO)
570        .contains(&ratio);
571    StationPressurePlausibility {
572        icao_standard_pa,
573        ratio,
574        implausible,
575    }
576}
577
578// ---------------------------------------------------------------------------------------
579// MBA-1366: density altitude as a direct atmosphere input
580// ---------------------------------------------------------------------------------------
581
582/// NWS pressure-altitude and FAA-rule constants — the ONE copy.
583///
584/// Both directions of the density-altitude model now live in this module: the forward
585/// [`faa_rule_density_altitude_ft`] (the engine's reported density altitude) and its exact
586/// inverse [`resolve_atmosphere_for_density_altitude`] (MBA-1366, the DA entry mode). They
587/// used to live apart, with these constants duplicated here because the forward formula sat
588/// in `pdf_dope_card` — first in the `ballistics` binary, then (from 0.33.0's Task 10) in
589/// this library but behind `#[cfg(feature = "pdf")]`, which this unconditional module could
590/// not import across without breaking the `--no-default-features` wasm32 build CI
591/// type-checks.
592///
593/// The fix was to move the formula DOWN rather than import it UP: `atmosphere` is
594/// unconditional, so the forward function lives here and
595/// `pdf_dope_card::calculate_density_altitude` now delegates to it, bit for bit. That leaves
596/// no second copy to drift, and it is what lets the `atmosphere.density_altitude` bridge
597/// command exist in every build with the bridge — including wasm32, which has no PDF card.
598///
599/// `src/main.rs`'s `density_altitude_round_trips_through_the_dope_card_formula*` tests still
600/// pin forward and inverse together, now through a single set of constants.
601const DA_NWS_SEA_LEVEL_HPA: f64 = 1013.25;
602const DA_NWS_PRESSURE_ALT_FT: f64 = 145_366.45;
603const DA_NWS_EXPONENT: f64 = 0.190_284;
604const DA_NWS_SEA_LEVEL_TEMP_F: f64 = 59.0;
605const DA_NWS_LAPSE_F_PER_1000FT: f64 = 3.57;
606/// The FAA rule of thumb, "120 ft of density altitude per °C away from standard", expressed
607/// per °F because the NWS constants above are Fahrenheit.
608const DA_FAA_RULE_FT_PER_DEG_F: f64 = 120.0 * 5.0 / 9.0;
609const DA_FT_TO_M: f64 = 0.3048;
610
611/// NWS pressure altitude, feet, from STATION pressure in hPa.
612///
613/// The published NWS station-pressure equation
614/// (<https://www.weather.gov/media/epz/wxcalc/pressureAltitude.pdf>):
615///
616/// ```text
617/// PA_ft = 145366.45 * (1 - (P_hPa / 1013.25)^0.190284)
618/// ```
619///
620/// Station pressure, never an altimeter setting (MBA-643): a QNH already corrects to sea
621/// level and would report the pressure altitude of the sea rather than of the firing point.
622pub fn nws_pressure_altitude_ft(station_pressure_hpa: f64) -> f64 {
623    DA_NWS_PRESSURE_ALT_FT
624        * (1.0 - (station_pressure_hpa / DA_NWS_SEA_LEVEL_HPA).powf(DA_NWS_EXPONENT))
625}
626
627/// Density altitude by the FAA rule of thumb, feet: NWS pressure altitude plus 120 ft for
628/// every °C the station is away from ISA temperature at that pressure altitude. **Humidity is
629/// not an input.**
630///
631/// ```text
632/// DA_ft = PA_ft + (120 * 5/9) * (T_F - ISA_T_F(PA))      ISA_T_F(PA) = 59 - 3.57 * PA_ft/1000
633/// ```
634///
635/// This is the density altitude the engine has always reported —
636/// `pdf_dope_card::calculate_density_altitude` delegates to it — and the one
637/// [`resolve_atmosphere_for_density_altitude`], the density-altitude ENTRY mode, inverts
638/// exactly.
639///
640/// It stays humidity-free for compatibility, not out of necessity. Every card printed to date
641/// shows this figure, and the entry mode that inverts it has shipped since MBA-1366 taking a
642/// temperature but no humidity (the run's humidity is applied separately, afterwards). A
643/// humidity-aware forward could be inverted just as well given the run's humidity, the way
644/// temperature already is; it would change every printed card and what an entered density
645/// altitude resolves to.
646///
647/// It is not the National Weather Service's density altitude either, despite the NWS pressure
648/// altitude inside it: the NWS calculator uses virtual temperature, so it includes humidity
649/// and tracks [`density_matched_altitude_m`] to within a few meters.
650///
651/// And it is an approximation. The rule is a straight line where the real dependence curves,
652/// and 120 ft/°C is slightly steeper than the exact slope at sea level (118.6 ft/°C for an
653/// ideal gas, 119.0 for the CIPM density [`density_matched_altitude_m`] uses), so
654/// it drifts from the exact figure as the temperature moves away from standard FOR THE
655/// PRESSURE ALTITUDE: about +22 m at 30 °C and 1013.25 hPa in completely dry air. See
656/// [`density_matched_altitude_m`] for the exact, humidity-inclusive figure, and why the bridge
657/// reports both.
658pub fn faa_rule_density_altitude_ft(station_pressure_hpa: f64, temperature_f: f64) -> f64 {
659    let pressure_alt = nws_pressure_altitude_ft(station_pressure_hpa);
660
661    // ISA temperature at pressure altitude (lapse rate: 3.57°F per 1000 ft)
662    let isa_temp_f = DA_NWS_SEA_LEVEL_TEMP_F - (pressure_alt / 1000.0) * DA_NWS_LAPSE_F_PER_1000FT;
663
664    pressure_alt + DA_FAA_RULE_FT_PER_DEG_F * (temperature_f - isa_temp_f)
665}
666
667/// ISA troposphere constants for [`density_matched_altitude_m`].
668const ISA_SEA_LEVEL_TEMP_K: f64 = 288.15;
669const ISA_LAPSE_K_PER_M: f64 = 0.0065;
670
671/// The reference air that [`density_matched_altitude_m`] calls sea level: ICAO standard sea
672/// level, perfectly dry, evaluated with the SAME CIPM-2007 model as the air being described.
673const DENSITY_MATCH_REFERENCE_TEMP_C: f64 = 15.0;
674const DENSITY_MATCH_REFERENCE_PRESSURE_HPA: f64 = 1013.25;
675
676/// The altitude in the ICAO Standard Atmosphere whose standard density equals the ACTUAL
677/// density of this air, humidity included — density altitude by its textbook definition.
678///
679/// ```text
680/// ratio = rho_CIPM(T, P, RH) / rho_CIPM(15 C, 1013.25 hPa, 0%)
681/// h_m   = (T0 / L) * (1 - ratio^(1/n))      T0 = 288.15 K, L = 0.0065 K/m,
682///                                            n  = g0 / (R L) - 1  (= 4.25588)
683/// ```
684///
685/// # Why this exists alongside [`faa_rule_density_altitude_ft`]
686///
687/// Two different quantities are both called "density altitude" in the field, and reporting
688/// them under one name is how an external user came to ask why the engine read 0 m where
689/// another tool read 36 m at ICAO standard with 50% humidity (2026-09). The FAA-rule figure
690/// is humidity-free for compatibility (see its docs). This one answers the question a shooter
691/// usually means — "which ISA altitude has the air density I actually have" — and so it must
692/// include humidity: water vapor (18 g/mol) is lighter than dry air (~29 g/mol), so humid air
693/// is thinner.
694///
695/// At 15 °C, 1013.25 hPa and 50% RH this reads 33.1 m where the FAA-rule figure reads 0. But
696/// the difference is NOT purely humidity, and callers must not present it that way: the FAA
697/// rule also drifts from this exact figure whenever the temperature is not standard for the
698/// pressure altitude, so the two disagree by about 22 m at 30 °C in perfectly dry air.
699///
700/// # Anchoring
701///
702/// Density comes from [`calculate_atmosphere`] — the one CIPM-2007 path every solver uses —
703/// and the ratio is taken against that SAME model's density for ICAO standard dry sea level,
704/// not against the textbook 1.225 kg/m³. The model is accurate, but it is not built to
705/// reproduce 1.225 exactly (its own anchor test allows ±0.002), and at sea level 0.002 kg/m³
706/// is roughly 17 m of altitude. Anchoring to the model makes ICAO standard dry air read
707/// exactly 0 and makes the humidity effect the model's own.
708///
709/// # Scope
710///
711/// The closed-form troposphere, with no geopotential correction — the same plain-height
712/// convention as the FAA-rule figure and the DA entry mode. That covers every shooting
713/// condition on Earth, but the arithmetic does not know where it stops: above 11 km it keeps
714/// answering, wrongly. Callers bound it; the bridge command refuses conditions outside
715/// -5 km..11 km (`crate::atmosphere_service`).
716pub fn density_matched_altitude_m(
717    temperature_c: f64,
718    station_pressure_hpa: f64,
719    humidity_percent: f64,
720) -> f64 {
721    let (density, _) = calculate_atmosphere(
722        0.0,
723        Some(temperature_c),
724        Some(station_pressure_hpa),
725        humidity_percent,
726    );
727    let (reference, _) = calculate_atmosphere(
728        0.0,
729        Some(DENSITY_MATCH_REFERENCE_TEMP_C),
730        Some(DENSITY_MATCH_REFERENCE_PRESSURE_HPA),
731        0.0,
732    );
733    let exponent = G_ACCEL_MPS2 / (R_AIR * ISA_LAPSE_K_PER_M) - 1.0;
734    (ISA_SEA_LEVEL_TEMP_K / ISA_LAPSE_K_PER_M) * (1.0 - (density / reference).powf(1.0 / exponent))
735}
736
737/// Back-solve an ISA-equivalent station altitude/temperature/pressure from a directly-declared
738/// density altitude (MBA-1366) — the single-value atmosphere entry mode Shooter, Nosler, AB
739/// Analytics, Ballistic AE, and TRASOL all support as an alternative to altitude + pressure +
740/// temperature.
741///
742/// This is the exact algebraic inverse of the FAA-rule density-altitude model this engine
743/// already uses to REPORT density altitude ([`faa_rule_density_altitude_ft`], which
744/// `pdf_dope_card::calculate_density_altitude` delegates to):
745///
746/// ```text
747/// pressure_alt_ft = 145366.45 * (1 - (station_hpa / 1013.25)^0.190284)
748/// isa_temp_f      = 59.0 - pressure_alt_ft / 1000.0 * 3.57
749/// density_alt_ft  = pressure_alt_ft + (120*5/9) * (station_temp_f - isa_temp_f)
750/// ```
751///
752/// Solved for `station_hpa` given a target `density_alt_ft` and a station temperature `K = 120
753/// * 5/9` is the "120 ft per °C" density-altitude correction rule, expressed in ft per °F):
754///
755/// ```text
756/// pressure_alt_ft = (density_alt_ft - K*(station_temp_f - 59.0)) / (1 + K*3.57/1000.0)
757/// station_hpa     = 1013.25 * (1 - pressure_alt_ft/145366.45)^(1/0.190284)
758/// ```
759///
760/// # Temperature precedence (MBA-1366)
761/// * `explicit_temperature_c == None`: ISA-at-density-altitude is the default. Algebraically
762///   this collapses `pressure_alt_ft` to EXACTLY `density_alt_ft`: the correction term above is
763///   zero by construction whenever the station temperature equals the ISA temperature at that
764///   pressure altitude, which is self-consistently true when temperature is left at ISA — i.e.
765///   omitting an explicit temperature is equivalent to typing `--altitude <density_altitude>`
766///   with no `--temperature`/`--pressure` override.
767/// * `explicit_temperature_c == Some(t)`: `t` wins outright (returned unchanged as
768///   `temperature_c`, never re-derived) and the station pressure is re-solved so the resulting
769///   density altitude still reproduces the input exactly. The FAA-rule density altitude is
770///   honored either way, but the DENSITY is not: the rule is a straight-line approximation, not
771///   a curve of equal density, so the ISA-default case rebuilds different air — at 30 °C and
772///   1013.25 hPa, a rebuilt 11.4 °C / 949 hPa, 0.3% denser, with a speed of sound 3% lower. Only
773///   the real temperature reproduces the real air.
774///
775/// # Height convention
776/// The FAA-rule formula being inverted ([`faa_rule_density_altitude_ft`]) performs no
777/// geopotential correction — it treats its altitude as a plain height (the DOPE card, which
778/// delegates to it, leaves its own `_altitude_ft` parameter unused for that reason). Consistent with that, the `altitude_m` this returns is GEOMETRIC altitude
779/// — the same convention every other engine altitude input uses (`--altitude`,
780/// [`AtmosphericConditions::altitude`](crate::cli_api::AtmosphericConditions::altitude)) —
781/// rather than being run through `geometric_to_geopotential_height_m` a second time here; that
782/// conversion is `calculate_icao_standard_atmosphere`'s job (both private to this crate — see
783/// their doc comments elsewhere in this file), applied once this altitude re-enters the normal
784/// altitude-lapse pipeline (this is what "back-solve an ISA-equivalent atmosphere" means —
785/// unlike [`get_direct_atmosphere`], which would freeze speed of sound and bypass that pipeline
786/// entirely).
787///
788/// # Interaction with [`PressureReferenceMode`] (QNH)
789/// Density altitude supersedes pressure outright — callers must not also run a declared
790/// `--pressure`/QNH value through [`resolve_station_pressure_with_mode`] when a density
791/// altitude is present; see the CLI/WASM call sites, which emit a note to that effect.
792///
793/// # Returns
794/// `(altitude_m, temperature_c, pressure_hpa)`, meant to be written directly into
795/// [`crate::cli_api::AtmosphericConditions`] (and the paired `BallisticInputs` environment
796/// fields, so powder-temperature sensitivity and the moist speed of sound both see the real
797/// resolved temperature) via the Authoritative
798/// (`TrajectorySolver::new_with_resolved_station_atmosphere`) constructor.
799pub fn resolve_atmosphere_for_density_altitude(
800    density_altitude_m: f64,
801    explicit_temperature_c: Option<f64>,
802) -> (f64, f64, f64) {
803    let density_altitude_ft = density_altitude_m / DA_FT_TO_M;
804    const K: f64 = DA_FAA_RULE_FT_PER_DEG_F;
805
806    let (pressure_alt_ft, temperature_c) = match explicit_temperature_c {
807        Some(temp_c) => {
808            let temp_f = temp_c * 9.0 / 5.0 + 32.0;
809            let denom = 1.0 + K * DA_NWS_LAPSE_F_PER_1000FT / 1000.0;
810            let pressure_alt_ft =
811                (density_altitude_ft - K * (temp_f - DA_NWS_SEA_LEVEL_TEMP_F)) / denom;
812            (pressure_alt_ft, temp_c)
813        }
814        None => {
815            // ISA-at-DA default: the correction term vanishes by construction (see doc
816            // comment above), so the pressure altitude IS the density altitude exactly.
817            let pressure_alt_ft = density_altitude_ft;
818            let isa_temp_f =
819                DA_NWS_SEA_LEVEL_TEMP_F - pressure_alt_ft / 1000.0 * DA_NWS_LAPSE_F_PER_1000FT;
820            let isa_temp_c = (isa_temp_f - 32.0) * 5.0 / 9.0;
821            (pressure_alt_ft, isa_temp_c)
822        }
823    };
824
825    let pressure_hpa = DA_NWS_SEA_LEVEL_HPA
826        * (1.0 - pressure_alt_ft / DA_NWS_PRESSURE_ALT_FT).powf(1.0 / DA_NWS_EXPONENT);
827    let altitude_m = pressure_alt_ft * DA_FT_TO_M;
828
829    (altitude_m, temperature_c, pressure_hpa)
830}
831
832/// Enhanced atmospheric calculation with ICAO Standard Atmosphere.
833///
834/// # Arguments
835/// * `altitude_m` - Altitude in meters
836/// * `temp_override_c` - Temperature override in Celsius (None for standard)
837/// * `press_override_hpa` - Pressure override in hPa (None for standard)
838/// * `humidity_percent` - Humidity percentage (0-100)
839///
840/// # Returns
841/// Tuple of (air_density_kg_m3, speed_of_sound_mps)
842pub fn calculate_atmosphere(
843    altitude_m: f64,
844    temp_override_c: Option<f64>,
845    press_override_hpa: Option<f64>,
846    humidity_percent: f64,
847) -> (f64, f64) {
848    // Get standard atmosphere conditions or use overrides
849    let combined_overrides = temp_override_c.zip(press_override_hpa);
850    let (temp_k, pressure_pa) = if let Some((temp_c, pressure_hpa)) = combined_overrides {
851        // Both overrides provided
852        (temp_c + 273.15, pressure_hpa * 100.0)
853    } else {
854        // Get ICAO standard conditions
855        let (std_temp_k, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
856
857        let final_temp_k = if let Some(temp_c) = temp_override_c {
858            temp_c + 273.15
859        } else {
860            std_temp_k
861        };
862
863        let final_pressure_pa = if let Some(press_hpa) = press_override_hpa {
864            press_hpa * 100.0
865        } else {
866            std_pressure_pa
867        };
868
869        (final_temp_k, final_pressure_pa)
870    };
871
872    // Humidity clamp shared by the CIPM density and the moist speed of sound.
873    let humidity_clamped = humidity_percent.clamp(0.0, 100.0);
874    let temp_c = temp_k - 273.15;
875
876    // Density: CIPM-2007 is the single canonical humid-air density model. Every solver
877    // (cli_api / monte_carlo / ffi / fast_trajectory) reaches this one formula through
878    // calculate_atmosphere, so there is no second (Arden-Buck ideal-gas) density path to drift.
879    let density = calculate_air_density_cimp(temp_c, pressure_pa / 100.0, humidity_clamped);
880
881    // Speed of sound uses an ideal-gas moist-air mixture approximation, first order in the
882    // water-vapor mole fraction. Extracted into `moist_speed_of_sound` so the integrators can
883    // share it; its vapor pressure comes from the SAME IAPWS saturation formula
884    // (`enhanced_saturation_vapor_pressure`) + CIPM enhancement factor that the density above
885    // uses. Only the vapor fraction is shared; the acoustic relation remains ideal-gas.
886    let speed_of_sound = moist_speed_of_sound(temp_k, pressure_pa, humidity_clamped);
887
888    (density, speed_of_sound)
889}
890
891/// Speed of sound using an ideal-gas moist-air mixture approximation, first order in the
892/// water-vapor mole fraction.
893///
894/// The water-vapor mole fraction is derived from the SAME IAPWS saturation vapor pressure
895/// (`enhanced_saturation_vapor_pressure`) and CIPM-2007 enhancement factor used by
896/// [`calculate_air_density_cimp`], so a single vapor formula feeds both density and c. Only the
897/// vapor fraction is shared with the CIPM path; the acoustic relation itself remains ideal-gas.
898/// The mixture applies first-order humidity corrections to the dry-air heat-capacity ratio and
899/// gas constant, then evaluates `sqrt(gamma * R * T)`. It is not Cramer's full real-gas
900/// polynomial and does not include Cramer's pressure or carbon-dioxide terms.
901///
902/// # Arguments
903/// * `temp_k` - Temperature in Kelvin
904/// * `pressure_pa` - Total (station) pressure in Pa
905/// * `humidity_percent` - Relative humidity percentage (0-100)
906///
907/// # Returns
908/// Speed of sound in m/s
909pub fn moist_speed_of_sound(temp_k: f64, pressure_pa: f64, humidity_percent: f64) -> f64 {
910    let humidity_clamped = humidity_percent.clamp(0.0, 100.0);
911    let temp_c = temp_k - 273.15;
912
913    // Water-vapor partial pressure p_v = RH * f * p_sv, matching CIPM's x_v exactly. p_sv is in
914    // hPa (enhanced_saturation_vapor_pressure returns hPa), so convert to Pa before forming the
915    // mole fraction against the Pa total pressure.
916    let p_sv_hpa = enhanced_saturation_vapor_pressure(temp_k);
917    let f = enhanced_enhancement_factor(pressure_pa, temp_c);
918    let vapor_pressure_pa = humidity_clamped / 100.0 * f * p_sv_hpa * 100.0;
919
920    // Cap the mole fraction at the physical maximum of 1 and guard pressure_pa == 0 (a 0 hPa
921    // override would otherwise give +Inf -> NaN speed of sound).
922    let mole_fraction_vapor = (vapor_pressure_pa / pressure_pa.max(f64::MIN_POSITIVE)).min(1.0);
923
924    // Heat-capacity ratio and gas constant for moist air (mole-fraction coefficients). 0.378 is
925    // the dry-air molecular-weight ratio (0.6078 would belong to specific humidity, not mole
926    // fraction).
927    let gamma_moist = GAMMA * (1.0 - mole_fraction_vapor * 0.062);
928    let r_moist = R_AIR * (1.0 + 0.378 * mole_fraction_vapor);
929
930    (gamma_moist * r_moist * temp_k).sqrt()
931}
932
933/// Enhanced air density calculation using CIPM formula with ICAO atmosphere.
934///
935/// # Arguments
936/// * `temp_c` - Temperature in Celsius
937/// * `pressure_hpa` - Pressure in hPa
938/// * `humidity_percent` - Humidity percentage (0-100)
939///
940/// # Returns
941/// Air density in kg/m³
942pub fn calculate_air_density_cimp(temp_c: f64, pressure_hpa: f64, humidity_percent: f64) -> f64 {
943    let t_k = temp_c + 273.15;
944
945    // Enhanced saturation vapor pressure calculation
946    let p_sv = enhanced_saturation_vapor_pressure(t_k);
947
948    let pressure_pa = pressure_hpa * 100.0;
949
950    // Enhanced enhancement factor with temperature dependence. CIPM constants use Pa.
951    let f = enhanced_enhancement_factor(pressure_pa, temp_c);
952
953    // Floor the pressure divisor (mirrors calculate_atmosphere): a 0 hPa pressure would
954    // otherwise make x_v = +Inf -> NaN density. No-op for all valid (>0) pressures.
955    let p_pa = pressure_pa.max(f64::MIN_POSITIVE);
956
957    // Mole fraction of water vapor, capped at the physical maximum of 1. The cap is silent:
958    // air whose vapor pressure would exceed its total pressure does not exist, and callers
959    // that must refuse it rather than compute pure steam test
960    // `water_vapor_mole_fraction_uncapped` first (the bridge's `atmosphere.density_altitude`).
961    let x_v = vapor_mole_fraction(p_sv, f, humidity_percent, p_pa).min(1.0);
962
963    // Enhanced compressibility factor. CIPM virial constants use Pa.
964    let z = enhanced_compressibility_factor(p_pa, t_k, x_v);
965
966    // Calculate density with enhanced precision
967    // Note: parentheses are important here for correct operator precedence
968    ((p_pa * M_A) / (z * R * t_k)) * (1.0 - x_v * (1.0 - M_V / M_A))
969}
970
971/// CIPM-2007 mole fraction of water vapor, `x_v = RH * f * p_sv / p`, UNCAPPED: the one
972/// formula [`calculate_air_density_cimp`] uses (it caps the result at 1). A value at or above 1
973/// means the stated humidity would need more vapor pressure than the air has total pressure.
974pub(crate) fn water_vapor_mole_fraction_uncapped(
975    temp_c: f64,
976    pressure_hpa: f64,
977    humidity_percent: f64,
978) -> f64 {
979    let t_k = temp_c + 273.15;
980    let p_sv = enhanced_saturation_vapor_pressure(t_k);
981    let pressure_pa = pressure_hpa * 100.0;
982    let f = enhanced_enhancement_factor(pressure_pa, temp_c);
983    vapor_mole_fraction(
984        p_sv,
985        f,
986        humidity_percent,
987        pressure_pa.max(f64::MIN_POSITIVE),
988    )
989}
990
991#[inline(always)]
992fn vapor_mole_fraction(p_sv_hpa: f64, f: f64, humidity_percent: f64, p_pa: f64) -> f64 {
993    // Vapor pressure with clamping. p_sv is in hPa (enhanced_saturation_vapor_pressure
994    // returns hPa — its critical-pressure constant is 220640 hPa), so p_v is in hPa too.
995    let p_v = humidity_percent.clamp(0.0, 100.0) / 100.0 * f * p_sv_hpa;
996
997    // Convert the vapor pressure to Pa BEFORE forming the mole fraction: the divisor below
998    // is in Pa. Dividing the hPa p_v by the Pa total made x_v 100x too small, which erased
999    // the humidity term and returned essentially dry-air density (e.g. 15 C / 1013.25 hPa /
1000    // 50% RH gave ~1.2254 instead of the CIPM-2007 moist value ~1.2216 — moist air is
1001    // LIGHTER than dry air).
1002    let p_v_pa = p_v * 100.0;
1003    p_v_pa / p_pa
1004}
1005
1006/// Enhanced saturation vapor pressure calculation.
1007/// Uses the IAPWS-IF97 formulation for high precision.
1008#[inline(always)]
1009fn enhanced_saturation_vapor_pressure(t_k: f64) -> f64 {
1010    // IAPWS-IF97 coefficients for better accuracy
1011    const A: [f64; 6] = [
1012        -7.85951783,
1013        1.84408259,
1014        -11.7866497,
1015        22.6807411,
1016        -15.9618719,
1017        1.80122502,
1018    ];
1019
1020    // Ensure temperature is positive and reasonable
1021    let t_k_safe = t_k.max(173.15); // -100°C minimum
1022
1023    let tau = 1.0 - t_k_safe / 647.096; // Critical temperature of water
1024    let ln_p_ratio = (647.096 / t_k_safe)
1025        * (A[0] * tau
1026            + A[1] * tau.powf(1.5)
1027            + A[2] * tau.powf(3.0)
1028            + A[3] * tau.powf(3.5)
1029            + A[4] * tau.powf(4.0)
1030            + A[5] * tau.powf(7.5));
1031
1032    220640.0 * ln_p_ratio.exp() // Critical pressure in hPa (22.064 MPa)
1033}
1034
1035/// CIPM-2007 enhancement factor `f = alpha + beta*p + gamma*t^2` (p in Pa, t in Celsius).
1036#[inline(always)]
1037fn enhanced_enhancement_factor(p: f64, t: f64) -> f64 {
1038    const ALPHA: f64 = 1.00062;
1039    const BETA: f64 = 3.14e-8;
1040    const GAMMA: f64 = 5.6e-7;
1041
1042    ALPHA + BETA * p + GAMMA * t * t
1043}
1044
1045/// CIPM-2007 compressibility factor `Z` (virial expansion, second order in `p/T`).
1046#[inline(always)]
1047fn enhanced_compressibility_factor(p: f64, t_k: f64, x_v: f64) -> f64 {
1048    // CIPM-2007 molar virial coefficients (p in Pa, t in Celsius).
1049    const A0: f64 = 1.58123e-6;
1050    const A1: f64 = -2.9331e-8;
1051    const A2: f64 = 1.1043e-10;
1052    const B0: f64 = 5.707e-6;
1053    const B1: f64 = -2.051e-8;
1054    const C0: f64 = 1.9898e-4;
1055    const C1: f64 = -2.376e-6;
1056    const D: f64 = 1.83e-11;
1057    const E: f64 = -0.765e-8;
1058
1059    // Ensure temperature is positive
1060    let t_k_safe = t_k.max(173.15); // -100°C minimum
1061    let t = t_k_safe - 273.15;
1062    let p_t = p / t_k_safe;
1063
1064    let z_second_order =
1065        1.0 - p_t * (A0 + A1 * t + A2 * t * t + (B0 + B1 * t) * x_v + (C0 + C1 * t) * x_v * x_v);
1066
1067    let z_third_order = p_t * p_t * (D + E * x_v * x_v);
1068
1069    z_second_order + z_third_order
1070}
1071
1072/// Convert an `(x, y)` position in the shot-aligned frame to true world altitude.
1073///
1074/// The engine rotates gravity by `shooting_angle_rad`, so shot-frame X follows the inclined
1075/// line of fire and Y is perpendicular to it in the vertical plane. Atmosphere lookup needs the
1076/// world-vertical projection of that position, added to the station altitude.
1077#[inline]
1078pub(crate) fn shot_frame_altitude(
1079    base_altitude_m: f64,
1080    downrange_m: f64,
1081    shot_y_m: f64,
1082    shooting_angle_rad: f64,
1083) -> f64 {
1084    base_altitude_m + downrange_m * shooting_angle_rad.sin() + shot_y_m * shooting_angle_rad.cos()
1085}
1086
1087/// Enhanced local atmospheric calculation with variable lapse rates.
1088///
1089/// # Arguments
1090/// * `altitude_m` - Query geometric altitude above mean sea level in meters
1091/// * `base_alt` - Base geometric altitude above mean sea level in meters
1092/// * `base_temp_c` - Base temperature in Celsius
1093/// * `base_press_hpa` - Base pressure in hPa
1094/// * `base_ratio` - Base density ratio
1095///
1096/// # Returns
1097/// Tuple of (air_density_kg_m3, speed_of_sound_mps)
1098pub fn get_local_atmosphere(
1099    altitude_m: f64,
1100    base_alt: f64,
1101    base_temp_c: f64,
1102    base_press_hpa: f64,
1103    base_ratio: f64,
1104) -> (f64, f64) {
1105    let (temp_k, _pressure_pa, density) = local_temp_pressure_density(
1106        altitude_m,
1107        base_alt,
1108        base_temp_c,
1109        base_press_hpa,
1110        base_ratio,
1111    );
1112
1113    // Dry speed of sound. 401.874 ~ gamma * R_air; kept exactly for back-compat with existing
1114    // callers (get_local_atmosphere_humid uses the precise moist formula instead).
1115    let speed_of_sound = (temp_k * 401.874).sqrt();
1116
1117    (density, speed_of_sound)
1118}
1119
1120/// Humidity-aware companion to [`get_local_atmosphere`]: identical local density, but the speed
1121/// of sound is the moist-air value ([`moist_speed_of_sound`]) evaluated at the LOCAL temperature
1122/// and pressure.
1123///
1124/// [`get_local_atmosphere`] is intentionally left unchanged (dry speed of sound) for
1125/// API/back-compat; call this variant only where a real relative humidity is available.
1126///
1127/// # Arguments
1128/// * `altitude_m` - Query geometric altitude above mean sea level in meters
1129/// * `base_alt` - Base (station) geometric altitude above mean sea level in meters
1130/// * `base_temp_c` - Base temperature in Celsius
1131/// * `base_press_hpa` - Base pressure in hPa
1132/// * `base_ratio` - Base density ratio (density / 1.225)
1133/// * `humidity_percent` - Relative humidity percentage (0-100)
1134///
1135/// # Returns
1136/// Tuple of (air_density_kg_m3, moist_speed_of_sound_mps)
1137pub fn get_local_atmosphere_humid(
1138    altitude_m: f64,
1139    base_alt: f64,
1140    base_temp_c: f64,
1141    base_press_hpa: f64,
1142    base_ratio: f64,
1143    humidity_percent: f64,
1144) -> (f64, f64) {
1145    get_local_atmosphere_humid_with_gravity(
1146        altitude_m,
1147        base_alt,
1148        base_temp_c,
1149        base_press_hpa,
1150        base_ratio,
1151        humidity_percent,
1152        Gravity::EARTH,
1153    )
1154}
1155
1156#[allow(clippy::too_many_arguments)]
1157pub(crate) fn get_local_atmosphere_humid_with_gravity(
1158    altitude_m: f64,
1159    base_alt: f64,
1160    base_temp_c: f64,
1161    base_press_hpa: f64,
1162    base_ratio: f64,
1163    humidity_percent: f64,
1164    gravity: Gravity,
1165) -> (f64, f64) {
1166    let (temp_k, pressure_pa, density) = local_temp_pressure_density_with_gravity(
1167        altitude_m,
1168        base_alt,
1169        base_temp_c,
1170        base_press_hpa,
1171        base_ratio,
1172        gravity,
1173    );
1174    (
1175        density,
1176        moist_speed_of_sound(temp_k, pressure_pa, humidity_percent),
1177    )
1178}
1179
1180/// Shared local temperature / pressure / density computation for [`get_local_atmosphere`] and
1181/// [`get_local_atmosphere_humid`]. Returns `(local_temp_k, local_pressure_pa, density_kg_m3)`.
1182#[inline]
1183fn local_temp_pressure_density(
1184    altitude_m: f64,
1185    base_alt: f64,
1186    base_temp_c: f64,
1187    base_press_hpa: f64,
1188    base_ratio: f64,
1189) -> (f64, f64, f64) {
1190    local_temp_pressure_density_with_gravity(
1191        altitude_m,
1192        base_alt,
1193        base_temp_c,
1194        base_press_hpa,
1195        base_ratio,
1196        Gravity::EARTH,
1197    )
1198}
1199
1200fn local_temp_pressure_density_with_gravity(
1201    altitude_m: f64,
1202    base_alt: f64,
1203    base_temp_c: f64,
1204    base_press_hpa: f64,
1205    base_ratio: f64,
1206    gravity: Gravity,
1207) -> (f64, f64, f64) {
1208    let base_temp_k = base_temp_c + 273.15;
1209
1210    // A non-finite endpoint would make the boundary walk fail to advance. The
1211    // previous single-column formula also produced non-finite outputs here.
1212    if !altitude_m.is_finite() || !base_alt.is_finite() {
1213        return (f64::NAN, f64::NAN, f64::NAN);
1214    }
1215
1216    let base_geopotential_m = geometric_to_geopotential_height_m(base_alt);
1217    let target_geopotential_m = geometric_to_geopotential_height_m(altitude_m);
1218    let (temp_k, pressure_hpa) = integrate_local_atmosphere_layers(
1219        base_geopotential_m,
1220        target_geopotential_m,
1221        base_temp_k,
1222        base_press_hpa,
1223        gravity,
1224    );
1225
1226    // Enhanced density calculation
1227    let density_ratio = base_ratio * (base_temp_k * pressure_hpa) / (base_press_hpa * temp_k);
1228    let density = density_ratio * 1.225;
1229
1230    (temp_k, pressure_hpa * 100.0, density)
1231}
1232
1233/// Carry arbitrary station temperature and pressure through each crossed ICAO
1234/// layer in geopotential-height coordinates. The layer table supplies lapse rates and boundaries
1235/// only; station deviations from the standard atmosphere remain anchored at the converted base.
1236fn integrate_local_atmosphere_layers(
1237    base_geopotential_m: f64,
1238    target_geopotential_m: f64,
1239    mut temp_k: f64,
1240    mut pressure_hpa: f64,
1241    gravity: Gravity,
1242) -> (f64, f64) {
1243    let mut current_alt = base_geopotential_m;
1244
1245    if target_geopotential_m > current_alt {
1246        while current_alt < target_geopotential_m {
1247            // At an exact boundary, ascent starts in the higher layer.
1248            let layer_index = ICAO_LAYERS
1249                .iter()
1250                .rposition(|layer| current_alt >= layer.base_altitude)
1251                .unwrap_or(0);
1252            let segment_end = ICAO_LAYERS
1253                .get(layer_index + 1)
1254                .map_or(target_geopotential_m, |next| {
1255                    target_geopotential_m.min(next.base_altitude)
1256                });
1257            (temp_k, pressure_hpa) = integrate_local_atmosphere_segment(
1258                temp_k,
1259                pressure_hpa,
1260                segment_end - current_alt,
1261                ICAO_LAYERS[layer_index].lapse_rate,
1262                gravity,
1263            );
1264            current_alt = segment_end;
1265        }
1266    } else {
1267        while current_alt > target_geopotential_m {
1268            // At an exact boundary, descent starts in the lower layer. The
1269            // strict comparison is what makes a cross-layer round trip reversible.
1270            let layer_index = ICAO_LAYERS
1271                .iter()
1272                .rposition(|layer| current_alt > layer.base_altitude)
1273                .unwrap_or(0);
1274            let segment_end = if layer_index == 0 {
1275                target_geopotential_m
1276            } else {
1277                target_geopotential_m.max(ICAO_LAYERS[layer_index].base_altitude)
1278            };
1279            (temp_k, pressure_hpa) = integrate_local_atmosphere_segment(
1280                temp_k,
1281                pressure_hpa,
1282                segment_end - current_alt,
1283                ICAO_LAYERS[layer_index].lapse_rate,
1284                gravity,
1285            );
1286            current_alt = segment_end;
1287        }
1288    }
1289
1290    (temp_k, pressure_hpa)
1291}
1292
1293#[inline]
1294fn integrate_local_atmosphere_segment(
1295    base_temp_k: f64,
1296    base_pressure_hpa: f64,
1297    height_diff: f64,
1298    lapse_rate: f64,
1299    gravity: Gravity,
1300) -> (f64, f64) {
1301    let temp_k = base_temp_k + lapse_rate * height_diff;
1302    let pressure_hpa = if lapse_rate.abs() < 1e-10 {
1303        base_pressure_hpa * (-gravity.magnitude() * height_diff / (R_AIR * base_temp_k)).exp()
1304    } else {
1305        let temp_ratio = temp_k / base_temp_k;
1306        base_pressure_hpa * temp_ratio.powf(-gravity.magnitude() / (lapse_rate * R_AIR))
1307    };
1308
1309    (temp_k, pressure_hpa)
1310}
1311
1312#[cfg(test)]
1313mod uniform_gravity_tests {
1314    use super::*;
1315
1316    #[test]
1317    fn uniform_gravity_controls_hydrostatic_pressure_without_moving_the_station_anchor() {
1318        for magnitude in [0.0, 1.62, 9.80665, 20.0] {
1319            let gravity = Gravity::new(magnitude).unwrap();
1320            let (temperature, pressure) =
1321                integrate_local_atmosphere_segment(280.0, 900.0, 100.0, 0.0, gravity);
1322            assert_eq!(temperature, 280.0);
1323            assert!(
1324                (pressure / 900.0 - (-magnitude * 100.0 / (R_AIR * 280.0)).exp()).abs() < 1e-14
1325            );
1326            let station =
1327                local_temp_pressure_density_with_gravity(300.0, 300.0, 20.0, 900.0, 0.9, gravity);
1328            assert_eq!(
1329                station,
1330                local_temp_pressure_density(300.0, 300.0, 20.0, 900.0, 0.9)
1331            );
1332        }
1333        // Exercise both the lapse and isothermal branches while crossing layer boundaries.
1334        let (_, pressure, _) = local_temp_pressure_density_with_gravity(
1335            25_000.0,
1336            0.0,
1337            20.0,
1338            900.0,
1339            0.9,
1340            Gravity::new(0.0).unwrap(),
1341        );
1342        assert_eq!(pressure, 90_000.0);
1343    }
1344}
1345
1346/// Determine local lapse rate based on altitude and atmospheric layer.
1347#[cfg(test)]
1348#[inline(always)]
1349fn determine_local_lapse_rate(altitude_m: f64) -> f64 {
1350    // Find the current atmospheric layer to get appropriate lapse rate
1351    let layer = ICAO_LAYERS
1352        .iter()
1353        .rev()
1354        .find(|layer| altitude_m >= layer.base_altitude)
1355        .unwrap_or(&ICAO_LAYERS[0]);
1356
1357    layer.lapse_rate
1358}
1359
1360/// Direct atmosphere calculation for simple cases.
1361///
1362/// # Arguments
1363/// * `density` - Pre-computed air density
1364/// * `speed_of_sound` - Pre-computed speed of sound
1365///
1366/// # Returns
1367/// Tuple of (air_density, speed_of_sound) - just passes through the values
1368#[inline(always)]
1369pub fn get_direct_atmosphere(density: f64, speed_of_sound: f64) -> (f64, f64) {
1370    (density, speed_of_sound)
1371}
1372
1373/// Legacy function name for backwards compatibility
1374pub fn calculate_air_density_cipm(temp_c: f64, pressure_hpa: f64, humidity_percent: f64) -> f64 {
1375    calculate_air_density_cimp(temp_c, pressure_hpa, humidity_percent)
1376}
1377
1378/// A single downrange-referenced atmosphere zone:
1379/// `(temp_c, pressure_hpa, humidity_percent, until_distance_m)`.
1380///
1381/// The T/P/H are the STATION-REFERENCED conditions (defined at the shooter base altitude) that
1382/// apply from the previous segment's threshold out to `until_distance_m`. This mirrors
1383/// [`crate::wind::WindSegment`]'s `(speed, angle, until_distance)` shape so the two segmented
1384/// models compose the same way (wind by X, atmosphere by X, altitude lapse by Y).
1385pub type AtmoSegment = (f64, f64, f64, f64);
1386
1387/// Downrange-segmented atmosphere handler (MBA-1137), the density analogue of
1388/// [`crate::wind::WindSock`].
1389///
1390/// Holds a set of station-referenced atmosphere zones ordered by their `until_distance_m`
1391/// threshold and answers a stateless downrange lookup ([`AtmoSock::atmo_for_range`]).
1392///
1393/// The zone T/P/H are the base (shooter-altitude) conditions for that stretch of range; the
1394/// solver swaps them into the same local-atmosphere altitude-lapse pipeline that a single-station
1395/// solve uses, so the downrange (X) zone and the vertical (Y) altitude lapse compose orthogonally
1396/// without double-counting (the zone sets the base tuple, the lapse multiplies on top of it).
1397#[derive(Debug, Clone)]
1398pub struct AtmoSock {
1399    /// Zones sorted ascending by `until_distance_m` (segment slot 3).
1400    segments: Vec<AtmoSegment>,
1401}
1402
1403impl AtmoSock {
1404    /// Create a new `AtmoSock` from station-referenced atmosphere zones.
1405    ///
1406    /// Each segment is `(temp_c, pressure_hpa, humidity_percent, until_distance_m)`. Segments are
1407    /// sorted by `until_distance_m`, with NaN thresholds ordered last.
1408    pub fn new(mut segments: Vec<AtmoSegment>) -> Self {
1409        segments.sort_by(|a, b| match (a.3.is_nan(), b.3.is_nan()) {
1410            (true, true) => Ordering::Equal,
1411            (true, false) => Ordering::Greater,
1412            (false, true) => Ordering::Less,
1413            (false, false) => a.3.partial_cmp(&b.3).unwrap(),
1414        });
1415        AtmoSock { segments }
1416    }
1417
1418    /// True when this sock carries no zones (a lookup falls back to sea-level ISA).
1419    pub fn is_empty(&self) -> bool {
1420        self.segments.is_empty()
1421    }
1422
1423    /// Stateless downrange lookup of the active zone's `(temp_c, pressure_hpa, humidity_percent)`.
1424    ///
1425    /// Selection matches [`crate::wind::WindSock::vector_for_range_stateless`]: the first segment
1426    /// whose `until_distance_m` STRICTLY exceeds `downrange_m` wins (thresholds are upper-exclusive).
1427    /// Unlike wind — which returns zero past the last threshold — the LAST zone is used for any
1428    /// distance at or beyond the final threshold (there is no "zero atmosphere"). An empty sock
1429    /// returns the sea-level ISA reference `(15 C, 1013.25 hPa, 0% RH)`.
1430    ///
1431    /// This is stateless and safe for numerical integration (the same X may be queried repeatedly
1432    /// or out of order across RK substeps).
1433    pub fn atmo_for_range(&self, downrange_m: f64) -> (f64, f64, f64) {
1434        if self.segments.is_empty() {
1435            return (15.0, 1013.25, 0.0); // sea-level ISA fallback
1436        }
1437        // NaN X can't be ordered; use the first (nearest) zone deterministically.
1438        if downrange_m.is_nan() {
1439            let s = self.segments[0];
1440            return (s.0, s.1, s.2);
1441        }
1442        for seg in &self.segments {
1443            if downrange_m < seg.3 {
1444                return (seg.0, seg.1, seg.2);
1445            }
1446        }
1447        // Beyond the final threshold: hold the last zone (no zeroing).
1448        let last = self.segments[self.segments.len() - 1];
1449        (last.0, last.1, last.2)
1450    }
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455    use super::*;
1456
1457    #[test]
1458    fn inclined_shot_frame_position_maps_to_world_altitude() {
1459        let base_altitude = 100.0;
1460        let downrange = 1_000.0;
1461        let shot_y = 10.0;
1462        let angle = std::f64::consts::FRAC_PI_6;
1463        let expected = base_altitude + downrange * angle.sin() + shot_y * angle.cos();
1464
1465        let actual = shot_frame_altitude(base_altitude, downrange, shot_y, angle);
1466        assert!(
1467            (actual - expected).abs() < 1e-12,
1468            "30-degree shot at x=1000/y=10 should be at {expected} m, got {actual} m"
1469        );
1470        assert_eq!(
1471            shot_frame_altitude(base_altitude, downrange, shot_y, 0.0),
1472            base_altitude + shot_y,
1473            "flat-fire altitude must remain byte-identical"
1474        );
1475        let downhill = shot_frame_altitude(base_altitude, downrange, shot_y, -angle);
1476        let expected_downhill = base_altitude - downrange * angle.sin() + shot_y * angle.cos();
1477        assert!((downhill - expected_downhill).abs() < 1e-12);
1478    }
1479
1480    // ---- MBA-1136: CIPM-2007 as the single canonical humid-air density ----
1481
1482    /// Gate 1: dry sea-level (15 C, 1013.25 hPa, 0% RH) must stay at the ISA reference — density
1483    /// 1.225 +- 0.002 kg/m^3 and speed of sound 340.3 +- 0.6 m/s. CIPM-2007 at 0% RH reduces to
1484    /// dry-air ideal gas to within rounding, so this is essentially unchanged from the pre-CIPM
1485    /// baseline (baseline was 1.225012 / 340.294; now 1.225521 / 340.294 — the tiny density bump
1486    /// is CIPM compressibility + exact molar mass, the speed of sound is bit-identical).
1487    #[test]
1488    fn test_mba1136_dry_sea_level_reference() {
1489        let (density, sos) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 0.0);
1490        assert!(
1491            (density - 1.225).abs() < 0.002,
1492            "dry sea-level density {density} not within 1.225 +- 0.002"
1493        );
1494        assert!(
1495            (sos - 340.3).abs() < 0.6,
1496            "dry sea-level speed of sound {sos} not within 340.3 +- 0.6"
1497        );
1498    }
1499
1500    /// Gate 2: humid air (15 C, 1013.25 hPa, 50% RH) is the CIPM-2007 value (~1.2211 +- 0.002),
1501    /// STRICTLY lighter than dry air at the same T/P, with a speed of sound slightly ABOVE dry.
1502    #[test]
1503    fn test_mba1136_humid_lighter_than_dry() {
1504        let (dry_rho, dry_sos) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 0.0);
1505        let (moist_rho, moist_sos) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 50.0);
1506
1507        assert!(
1508            (moist_rho - 1.2211).abs() < 0.002,
1509            "50% RH density {moist_rho} not within CIPM 1.2211 +- 0.002"
1510        );
1511        assert!(
1512            moist_rho < dry_rho,
1513            "moist air ({moist_rho}) must be lighter than dry ({dry_rho})"
1514        );
1515        assert!(
1516            moist_sos > dry_sos,
1517            "moist speed of sound ({moist_sos}) must exceed dry ({dry_sos})"
1518        );
1519    }
1520
1521    /// Gate 3: density is monotone-decreasing in humidity (100% RH < 50% RH < 0% RH).
1522    #[test]
1523    fn test_mba1136_density_monotone_in_humidity() {
1524        let (rho_0, _) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 0.0);
1525        let (rho_50, _) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 50.0);
1526        let (rho_100, _) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 100.0);
1527        assert!(
1528            rho_100 < rho_50 && rho_50 < rho_0,
1529            "humidity monotonicity violated: 100%={rho_100}, 50%={rho_50}, 0%={rho_0}"
1530        );
1531    }
1532
1533    /// rank 28: `calculate_atmosphere`'s density is exactly `calculate_air_density_cimp` — there
1534    /// is a single canonical humid-air density path (no separate Arden-Buck ideal-gas density).
1535    #[test]
1536    fn test_mba1136_atmosphere_density_is_cipm() {
1537        for (t, p, h) in [
1538            (15.0, 1013.25, 0.0),
1539            (15.0, 1013.25, 50.0),
1540            (30.0, 1000.0, 80.0),
1541            (-10.0, 1020.0, 20.0),
1542        ] {
1543            let (density, _) = calculate_atmosphere(0.0, Some(t), Some(p), h);
1544            let cipm = calculate_air_density_cimp(t, p, h);
1545            assert_eq!(
1546                density, cipm,
1547                "calculate_atmosphere density must equal CIPM at {t}C/{p}hPa/{h}%"
1548            );
1549        }
1550    }
1551
1552    /// rank 9: the extracted `moist_speed_of_sound` is exactly what `calculate_atmosphere`
1553    /// returns (behavior-identical extraction), across dry and humid conditions.
1554    #[test]
1555    fn test_mba1136_moist_speed_of_sound_extraction() {
1556        for (t, p, h) in [
1557            (15.0, 1013.25, 0.0),
1558            (15.0, 1013.25, 50.0),
1559            (25.0, 900.0, 100.0),
1560        ] {
1561            let (_, sos) = calculate_atmosphere(0.0, Some(t), Some(p), h);
1562            let extracted = moist_speed_of_sound(t + 273.15, p * 100.0, h);
1563            assert_eq!(
1564                sos, extracted,
1565                "extracted moist_speed_of_sound must match calculate_atmosphere at {t}C/{p}hPa/{h}%"
1566            );
1567        }
1568    }
1569
1570    /// rank 9: local-atmosphere reference values (base: 500 m, 10 C, 950 hPa, ratio 1.05).
1571    /// The 1500 m values include the geometric-to-geopotential height conversion.
1572    #[test]
1573    fn test_mba1136_get_local_atmosphere_reference() {
1574        let (d0, c0) = get_local_atmosphere(500.0, 500.0, 10.0, 950.0, 1.05);
1575        assert!(
1576            (d0 - 1.286250000000).abs() < 1e-9,
1577            "local density@500m drifted: {d0}"
1578        );
1579        assert!(
1580            (c0 - 337.328657395129).abs() < 1e-9,
1581            "local sos@500m drifted: {c0}"
1582        );
1583        let (d1, c1) = get_local_atmosphere(1500.0, 500.0, 10.0, 950.0, 1.05);
1584        assert!(
1585            (d1 - 1.165238292559).abs() < 1e-9,
1586            "local density@1500m drifted: {d1}"
1587        );
1588        assert!(
1589            (c1 - 333.435546617978).abs() < 1e-9,
1590            "local sos@1500m drifted: {c1}"
1591        );
1592    }
1593
1594    #[test]
1595    fn fractional_station_altitude_is_not_quantized() {
1596        let station_altitude_m = 500.25;
1597        let station_temp_c = 10.0;
1598        let station_pressure_hpa = 950.0;
1599        let station_density_ratio = 1.05;
1600
1601        let (density, speed_of_sound) = get_local_atmosphere(
1602            station_altitude_m,
1603            station_altitude_m,
1604            station_temp_c,
1605            station_pressure_hpa,
1606            station_density_ratio,
1607        );
1608
1609        let expected_density = station_density_ratio * 1.225;
1610        let expected_speed_of_sound = ((station_temp_c + 273.15) * 401.874).sqrt();
1611        assert!((density - expected_density).abs() < 1e-12);
1612        assert!((speed_of_sound - expected_speed_of_sound).abs() < 1e-12);
1613    }
1614
1615    /// rank 9: `get_local_atmosphere_humid` returns the SAME density as `get_local_atmosphere`,
1616    /// and at 0% RH its speed of sound reduces to the dry value (within the 401.874-vs-gamma*R
1617    /// constant rounding). At real humidity the speed of sound exceeds the dry value.
1618    #[test]
1619    fn test_mba1136_get_local_atmosphere_humid() {
1620        let (d_dry, c_dry) = get_local_atmosphere(1500.0, 500.0, 10.0, 950.0, 1.05);
1621        let (d_h0, c_h0) = get_local_atmosphere_humid(1500.0, 500.0, 10.0, 950.0, 1.05, 0.0);
1622        assert_eq!(d_dry, d_h0, "humid variant must not change density");
1623        assert!(
1624            (c_h0 - c_dry).abs() < 1e-3,
1625            "0% RH humid sos {c_h0} should match dry sos {c_dry}"
1626        );
1627        let (_, c_h80) = get_local_atmosphere_humid(1500.0, 500.0, 10.0, 950.0, 1.05, 80.0);
1628        assert!(c_h80 > c_dry, "humid sos {c_h80} should exceed dry {c_dry}");
1629    }
1630
1631    #[test]
1632    fn test_icao_standard_atmosphere() {
1633        // Test sea level
1634        let (temp, press) = calculate_icao_standard_atmosphere(0.0);
1635        assert!((temp - 288.15).abs() < 0.01);
1636        assert!((press - 101325.0).abs() < 1.0);
1637
1638        // The table's 11 km tropopause base is geopotential height; convert it back to the
1639        // geometric altitude accepted by the public atmosphere contract.
1640        let geometric_tropopause_m =
1641            GEOPOTENTIAL_EARTH_RADIUS_M * 11000.0 / (GEOPOTENTIAL_EARTH_RADIUS_M - 11000.0);
1642        let (temp_11km, press_11km) = calculate_icao_standard_atmosphere(geometric_tropopause_m);
1643        assert!((temp_11km - 216.65).abs() < 0.01);
1644        assert!((press_11km - 22632.1).abs() < 1.0);
1645
1646        // Test stratosphere
1647        let (temp_25km, _) = calculate_icao_standard_atmosphere(25000.0);
1648        assert!(temp_25km > 216.65); // Temperature increases in stratosphere
1649    }
1650
1651    #[test]
1652    fn standard_atmosphere_extends_below_sea_level() {
1653        let altitude_m = -430.0;
1654        let (temp_k, pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
1655
1656        assert!((temp_k - 290.945_189_079_054).abs() < 1e-9);
1657        assert!((pressure_pa - 106_598.763_552_437).abs() < 0.1);
1658
1659        let (station_temp_c, station_pressure_hpa) =
1660            resolve_station_conditions(15.0, 1013.25, altitude_m);
1661        assert!((station_temp_c - 17.795_189_079_054).abs() < 1e-9);
1662        assert!((station_pressure_hpa - 1_065.987_635_524).abs() < 1e-6);
1663
1664        let (sea_density, _) = calculate_atmosphere(0.0, None, None, 0.0);
1665        let (below_sea_density, _) = calculate_atmosphere(altitude_m, None, None, 0.0);
1666        assert!((below_sea_density - 1.276_908_642_79).abs() < 1e-6);
1667        assert!(below_sea_density > sea_density * 1.04);
1668
1669        assert_eq!(
1670            calculate_icao_standard_atmosphere(-6000.0),
1671            calculate_icao_standard_atmosphere(MIN_GEOMETRIC_ALTITUDE_M)
1672        );
1673    }
1674
1675    #[test]
1676    fn standard_atmosphere_converts_geometric_to_geopotential_height() {
1677        let cases: [(f64, f64, f64); 3] = [
1678            (10_000.0, 223.252_092_647_979, 26_499.901_600_244),
1679            (30_000.0, 226.509_083_611_330, 1_197.032_108_466),
1680            (84_000.0, 190.841_043_736_102, 0.531_525_514_935),
1681        ];
1682        for (geometric_m, expected_temp_k, expected_pressure_pa) in cases {
1683            let (temp_k, pressure_pa) = calculate_icao_standard_atmosphere(geometric_m);
1684            let pressure_tolerance = expected_pressure_pa.max(1.0_f64) * 1e-6;
1685
1686            assert!((temp_k - expected_temp_k).abs() < 1e-9);
1687            assert!((pressure_pa - expected_pressure_pa).abs() < pressure_tolerance);
1688        }
1689    }
1690
1691    #[test]
1692    fn local_atmosphere_walks_icao_layers_continuously() {
1693        for altitude_m in [
1694            10999.0, 11000.0, 11001.0, 11050.0, 19999.0, 20000.0, 20001.0, 25000.0,
1695        ] {
1696            let (local_temp_k, local_pressure_pa, _) =
1697                local_temp_pressure_density(altitude_m, 0.0, 15.0, 1013.25, 1.0);
1698            let (standard_temp_k, standard_pressure_pa) =
1699                calculate_icao_standard_atmosphere(altitude_m);
1700
1701            assert!(
1702                (local_temp_k - standard_temp_k).abs() < 1e-9,
1703                "local temperature diverged from ICAO at {altitude_m} m: local={local_temp_k}, standard={standard_temp_k}"
1704            );
1705            assert!(
1706                ((local_pressure_pa - standard_pressure_pa) / standard_pressure_pa).abs() < 5e-5,
1707                "local pressure diverged from ICAO at {altitude_m} m: local={local_pressure_pa}, standard={standard_pressure_pa}"
1708            );
1709        }
1710
1711        let (density_below, sound_below) = get_local_atmosphere(10999.0, 0.0, 15.0, 1013.25, 1.0);
1712        let (density_above, sound_above) = get_local_atmosphere(11001.0, 0.0, 15.0, 1013.25, 1.0);
1713        assert!(
1714            (density_below - density_above).abs() < 0.001,
1715            "density jumped across 11 km: below={density_below}, above={density_above}"
1716        );
1717        assert!(
1718            (sound_below - sound_above).abs() < 0.1,
1719            "speed of sound jumped across 11 km: below={sound_below}, above={sound_above}"
1720        );
1721    }
1722
1723    #[test]
1724    fn local_atmosphere_preserves_nonstandard_station_offset_and_round_trips() {
1725        let base_alt = 7500.0;
1726        let base_temp_c = 5.0;
1727        let base_pressure_hpa = 410.0;
1728        let base_ratio = 0.72;
1729
1730        let (high_temp_k, high_pressure_pa, high_density) = local_temp_pressure_density(
1731            25000.0,
1732            base_alt,
1733            base_temp_c,
1734            base_pressure_hpa,
1735            base_ratio,
1736        );
1737        assert!((high_temp_k - 260.244_615_053_376).abs() < 1e-9);
1738        assert!((high_pressure_pa / 100.0 - 40.964_358_485_456).abs() < 1e-9);
1739        assert!((high_density - 0.094_186_400_274).abs() < 1e-9);
1740
1741        let (back_temp_k, back_pressure_pa, back_density) = local_temp_pressure_density(
1742            base_alt,
1743            25000.0,
1744            high_temp_k - 273.15,
1745            high_pressure_pa / 100.0,
1746            high_density / 1.225,
1747        );
1748        assert!((back_temp_k - (base_temp_c + 273.15)).abs() < 1e-9);
1749        assert!((back_pressure_pa / 100.0 - base_pressure_hpa).abs() < 1e-8);
1750        assert!((back_density - base_ratio * 1.225).abs() < 1e-9);
1751    }
1752
1753    #[test]
1754    fn test_enhanced_atmosphere_sea_level() {
1755        let (density, speed) = calculate_atmosphere(0.0, None, None, 0.0);
1756        assert!((density - 1.225).abs() < 0.01);
1757        assert!((speed - 340.0).abs() < 1.0);
1758    }
1759
1760    /// The band's edges are inside it, in both directions. At sea level the ICAO standard is
1761    /// exactly 101325 Pa, and 1.08 and 0.85 times it are exact in binary, so the division
1762    /// lands on the constants themselves rather than next to them.
1763    #[test]
1764    fn station_pressure_plausibility_band_edges_are_inside_the_band() {
1765        let at_sea_level = |pa| station_pressure_plausibility(pa, 0.0);
1766        assert_eq!(at_sea_level(101_325.0).icao_standard_pa, 101_325.0);
1767
1768        let high_edge = at_sea_level(109_431.0);
1769        assert_eq!(high_edge.ratio, STATION_PRESSURE_MAX_PLAUSIBLE_RATIO);
1770        assert!(!high_edge.implausible, "{high_edge:?}");
1771        assert!(at_sea_level(109_432.0).implausible);
1772
1773        let low_edge = at_sea_level(86_126.25);
1774        assert_eq!(low_edge.ratio, STATION_PRESSURE_MIN_PLAUSIBLE_RATIO);
1775        assert!(!low_edge.implausible, "{low_edge:?}");
1776        assert!(at_sea_level(86_126.0).implausible);
1777
1778        // Away from sea level the edges hold to a part in a billion either side.
1779        for altitude_m in [-400.0, 1500.0, 3310.128] {
1780            let (_, icao_pa) = calculate_icao_standard_atmosphere(altitude_m);
1781            for (ratio, implausible) in [
1782                (STATION_PRESSURE_MAX_PLAUSIBLE_RATIO * (1.0 - 1e-9), false),
1783                (STATION_PRESSURE_MAX_PLAUSIBLE_RATIO * (1.0 + 1e-9), true),
1784                (STATION_PRESSURE_MIN_PLAUSIBLE_RATIO * (1.0 + 1e-9), false),
1785                (STATION_PRESSURE_MIN_PLAUSIBLE_RATIO * (1.0 - 1e-9), true),
1786            ] {
1787                let p = station_pressure_plausibility(ratio * icao_pa, altitude_m);
1788                assert_eq!(p.icao_standard_pa, icao_pa);
1789                assert_eq!(
1790                    p.implausible, implausible,
1791                    "{altitude_m} m, ratio {ratio}: {p:?}"
1792                );
1793            }
1794        }
1795
1796        assert!(station_pressure_plausibility(f64::NAN, 0.0).implausible);
1797    }
1798
1799    /// The record sea-level pressures the band is justified by stay inside it, and the reported
1800    /// placeholder at Cusco does not.
1801    #[test]
1802    fn station_pressure_plausibility_passes_record_weather_and_flags_a_placeholder() {
1803        for record_hpa in [870.0, 1083.8, 1089.1] {
1804            let p = station_pressure_plausibility(record_hpa * 100.0, 0.0);
1805            assert!(!p.implausible, "{record_hpa} hPa: {p:?}");
1806        }
1807
1808        let placeholder = station_pressure_plausibility(101_325.0, 3310.128);
1809        assert!(placeholder.implausible, "{placeholder:?}");
1810        assert!((placeholder.ratio - 1.503).abs() < 5e-4, "{placeholder:?}");
1811        // A real Cusco station reading is close to standard.
1812        assert!(!station_pressure_plausibility(68_830.0, 3310.128).implausible);
1813        // The same reading at an omitted (0 m) altitude is far below standard.
1814        assert!(station_pressure_plausibility(68_830.0, 0.0).implausible);
1815        // 101325 Pa leaves the band between 640 m and 650 m.
1816        assert!(!station_pressure_plausibility(101_325.0, 640.0).implausible);
1817        assert!(station_pressure_plausibility(101_325.0, 650.0).implausible);
1818    }
1819
1820    #[test]
1821    fn test_resolve_station_pressure_contract() {
1822        // Default sea-level pressure + real altitude => derive from altitude (None).
1823        assert_eq!(resolve_station_pressure(1013.25, 2000.0), None);
1824        // 29.92 inHg ≈ 1013.21 hPa is also treated as the default (within tolerance).
1825        assert_eq!(resolve_station_pressure(1013.21, 2000.0), None);
1826        // An explicit, non-default station pressure is authoritative (Some, used directly).
1827        assert_eq!(resolve_station_pressure(850.0, 2000.0), Some(850.0));
1828        // At/near sea level the default is used directly (no derivation needed).
1829        assert_eq!(resolve_station_pressure(1013.25, 0.0), Some(1013.25));
1830    }
1831
1832    #[test]
1833    fn qnh_reduction_matches_hand_computed_value_at_nonzero_altitude() {
1834        // Hand-computed (Python, double precision) from the altimeter-setting relation.
1835        // NOTE the geopotential step — 1500 m geometric is 1499.646 m geopotential, and it is
1836        // the geopotential height that enters the formula:
1837        //   h_geo   = 6356766*1500/(6356766+1500)                    = 1499.6461... m
1838        //   n       = 1/5.255875601466713                            = 0.19026325503612...
1839        //   station = 1030.0 * (1 - (0.0065*h_geo/288.15)*(1013.25/1030.0)^n)^(1/n)
1840        //           = 860.0681114332783 hPa
1841        // The ratio form this replaced, 1030.0 * (1 - 0.0065*h_geo/288.15)^5.2558756, gave
1842        // 859.5753123926447 hPa: 0.49 hPa low, because it scales the standard pressure ratio
1843        // by QNH instead of adding the station height to the QNH's pressure altitude.
1844        let reduced = reduce_qnh_to_station_pressure(1030.0, 1500.0);
1845        assert!(
1846            (reduced - 860.068_111_433_278_3).abs() < 1e-9,
1847            "reduced={reduced}"
1848        );
1849        // The reduction must strictly lower the pressure versus the raw QNH input.
1850        assert!(reduced < 1030.0);
1851    }
1852
1853    #[test]
1854    fn qnh_reduction_is_identity_at_sea_level() {
1855        // At h=0 the geopotential height is 0, so the ratio is exactly 1.0 and QNH passes
1856        // through unchanged (bit-exact: 1.0f64.powf(x) == 1.0).
1857        assert_eq!(reduce_qnh_to_station_pressure(1030.0, 0.0), 1030.0);
1858        assert_eq!(reduce_qnh_to_station_pressure(950.5, 0.0), 950.5);
1859    }
1860
1861    #[test]
1862    fn qnh_sea_level_standard_matches_icao_standard_atmosphere_at_every_altitude() {
1863        // A QNH of exactly 1013.25 hPa (the sea-level standard) must reduce to precisely the
1864        // ICAO standard station pressure at any altitude -- this is what keeps the omitted-
1865        // pressure default byte-identical whether the caller declares Absolute or Qnh.
1866        for altitude_m in [0.0, 500.0, 2000.0, 4500.0] {
1867            let (_, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
1868            let reduced = reduce_qnh_to_station_pressure(1013.25, altitude_m);
1869            assert!(
1870                (reduced - std_pressure_pa / 100.0).abs() < 1e-9,
1871                "altitude={altitude_m} reduced={reduced} std={}",
1872                std_pressure_pa / 100.0
1873            );
1874        }
1875    }
1876
1877    /// The doc comment's comparison with the NWS/ASOS altimeter-setting algorithm, checked:
1878    /// with that algorithm's constants (n = 0.190284, k = 8.4228807e-5, the field elevation as
1879    /// entered) and without its 0.3 hPa sensor offset, its station pressure is about 0.1 hPa
1880    /// below this function's at 1500 m and 0.3 to 0.45 hPa below at 4000 to 5000 m, over QNH
1881    /// 950 to 1050 hPa.
1882    #[test]
1883    fn qnh_reduction_differs_from_nws_asos_by_the_documented_amounts() {
1884        let nws_station_hpa = |qnh_hpa: f64, elevation_m: f64| {
1885            (qnh_hpa.powf(0.190284) - 8.4228807e-5 * elevation_m).powf(1.0 / 0.190284)
1886        };
1887        for (altitude_m, low, high) in [
1888            (1500.0, 0.09, 0.11),
1889            (4000.0, 0.3, 0.45),
1890            (5000.0, 0.3, 0.45),
1891        ] {
1892            for qnh_hpa in [950.0, 980.0, 1013.25, 1030.0, 1050.0] {
1893                let gap = reduce_qnh_to_station_pressure(qnh_hpa, altitude_m)
1894                    - nws_station_hpa(qnh_hpa, altitude_m);
1895                assert!(
1896                    (low..=high).contains(&gap),
1897                    "QNH {qnh_hpa} at {altitude_m} m: this function is {gap} hPa above NWS/ASOS"
1898                );
1899            }
1900        }
1901    }
1902
1903    #[test]
1904    fn qnh_reduction_adds_the_station_height_to_the_qnhs_pressure_altitude() {
1905        // What an altimeter setting means: an altimeter set to QNH reads field elevation on
1906        // the ground, so the station pressure's ISA pressure altitude is the QNH's own pressure
1907        // altitude plus the station's (geopotential) height. The troposphere's pressure
1908        // altitude is H(p) = (T0/-L) * (1 - (p/P0)^n), n = -L*R/g.
1909        let layer = &ICAO_LAYERS[0];
1910        let n = -layer.lapse_rate * R_AIR / G_ACCEL_MPS2;
1911        let p0_hpa = layer.base_pressure / 100.0;
1912        let pressure_altitude_m = |pressure_hpa: f64| {
1913            layer.base_temperature / -layer.lapse_rate * (1.0 - (pressure_hpa / p0_hpa).powf(n))
1914        };
1915        // The unit-test pin, Cusco (SPZO), La Paz (SLLP), low and high settings, a standard
1916        // setting, and a station below sea level.
1917        for (qnh_hpa, altitude_m) in [
1918            (1030.0, 1500.0),
1919            (1033.0, 3310.0),
1920            (1038.0, 4061.0),
1921            (1000.0, 2000.0),
1922            (980.0, 1500.0),
1923            (1040.0, 4500.0),
1924            (1013.25, 3000.0),
1925            (1030.0, -400.0),
1926        ] {
1927            let station_hpa = reduce_qnh_to_station_pressure(qnh_hpa, altitude_m);
1928            let added_m = pressure_altitude_m(station_hpa) - pressure_altitude_m(qnh_hpa);
1929            let height_m = geometric_to_geopotential_height_m(altitude_m);
1930            assert!(
1931                (added_m - height_m).abs() < 1e-6,
1932                "QNH {qnh_hpa} hPa at {altitude_m} m: station {station_hpa} hPa adds \
1933                 {added_m} m of pressure altitude, expected {height_m} m"
1934            );
1935        }
1936    }
1937
1938    #[test]
1939    fn qnh_reduction_is_nan_where_no_station_pressure_exists() {
1940        // A station pressure exists only while the QNH's pressure altitude plus the station
1941        // height stays below T0/-L (44,331 m, where the troposphere's lapse rate reaches
1942        // absolute zero). Past that the result is NaN, which every caller's finite-pressure
1943        // check refuses, rather than a near-vacuum number that would be flown as real air.
1944        assert!(reduce_qnh_to_station_pressure(500.0, 40_000.0).is_nan());
1945        assert!(reduce_qnh_to_station_pressure(-1030.0, 1500.0).is_nan());
1946        // Everywhere real shooting happens the reduction is finite and positive, lowers the
1947        // pressure above sea level and raises it below.
1948        for qnh_step in 0..=24 {
1949            let qnh_hpa = 850.0 + 10.0 * f64::from(qnh_step);
1950            for altitude_step in 0..=20 {
1951                let altitude_m = -1000.0 + 500.0 * f64::from(altitude_step);
1952                let station_hpa = reduce_qnh_to_station_pressure(qnh_hpa, altitude_m);
1953                assert!(
1954                    station_hpa.is_finite() && station_hpa > 0.0,
1955                    "QNH {qnh_hpa} hPa at {altitude_m} m gave {station_hpa} hPa"
1956                );
1957                assert_eq!(
1958                    station_hpa.partial_cmp(&qnh_hpa),
1959                    0.0.partial_cmp(&altitude_m),
1960                    "QNH {qnh_hpa} hPa at {altitude_m} m gave {station_hpa} hPa"
1961                );
1962            }
1963        }
1964    }
1965
1966    #[test]
1967    fn resolve_station_pressure_with_mode_absolute_matches_resolve_station_pressure() {
1968        // PressureReferenceMode::Absolute must be byte-identical to resolve_station_pressure
1969        // for every case that function's own contract test exercises.
1970        for (pressure_hpa, altitude_m) in [
1971            (1013.25, 2000.0),
1972            (1013.21, 2000.0),
1973            (850.0, 2000.0),
1974            (1013.25, 0.0),
1975        ] {
1976            assert_eq!(
1977                resolve_station_pressure_with_mode(
1978                    pressure_hpa,
1979                    altitude_m,
1980                    PressureReferenceMode::Absolute
1981                ),
1982                resolve_station_pressure(pressure_hpa, altitude_m)
1983            );
1984        }
1985    }
1986
1987    #[test]
1988    fn resolve_station_pressure_with_mode_qnh_bypasses_the_default_sentinel() {
1989        // Constructed so the REDUCED value coincidentally lands within the absolute-mode
1990        // sentinel's +/-0.5 hPa tolerance of 1013.25 hPa at a real (>1 m) altitude -- exactly
1991        // the case that would silently discard an explicit reading under the old,
1992        // mode-blind resolve_station_pressure.
1993        let qnh = 1030.0;
1994        let altitude_m = 138.078_300_203_223_02;
1995        let reduced = reduce_qnh_to_station_pressure(qnh, altitude_m);
1996        assert!(
1997            (reduced - 1013.25).abs() < 0.5,
1998            "test fixture must land inside the sentinel band; reduced={reduced}"
1999        );
2000
2001        // Qnh mode must still return Some(reduced) -- never None -- regardless of the
2002        // coincidence, and must equal the reduction, not a derived-from-altitude value.
2003        assert_eq!(
2004            resolve_station_pressure_with_mode(qnh, altitude_m, PressureReferenceMode::Qnh),
2005            Some(reduced)
2006        );
2007
2008        // Sanity: the OLD absolute-mode function, given that same reduced number directly,
2009        // WOULD have discarded it (returned None, deriving ICAO-standard pressure instead) --
2010        // demonstrating why Qnh mode must never be routed back through the plain sentinel
2011        // check on its output.
2012        assert_eq!(resolve_station_pressure(reduced, altitude_m), None);
2013        let (_, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
2014        assert!(
2015            (std_pressure_pa / 100.0 - reduced).abs() > 10.0,
2016            "fixture must show the sentinel misfire actually changes the answer materially"
2017        );
2018    }
2019
2020    #[test]
2021    fn resolve_station_conditions_with_pressure_mode_absolute_matches_resolve_station_conditions() {
2022        for (temp_c, pressure_hpa, altitude_m) in [
2023            (15.0, 1013.25, 2000.0),
2024            (-5.0, 850.0, 2000.0),
2025            (15.0, 1013.25, 0.0),
2026        ] {
2027            assert_eq!(
2028                resolve_station_conditions_with_pressure_mode(
2029                    temp_c,
2030                    pressure_hpa,
2031                    altitude_m,
2032                    PressureReferenceMode::Absolute
2033                ),
2034                resolve_station_conditions(temp_c, pressure_hpa, altitude_m)
2035            );
2036        }
2037    }
2038
2039    #[test]
2040    fn resolve_station_conditions_with_pressure_mode_qnh_reduces_pressure_only() {
2041        // -5.0 C is not the sea-level default sentinel (15.0 C), so it stays authoritative --
2042        // isolating that only the pressure resolution differs between the two modes.
2043        let (temp_c, pressure_hpa) = resolve_station_conditions_with_pressure_mode(
2044            -5.0,
2045            1030.0,
2046            1500.0,
2047            PressureReferenceMode::Qnh,
2048        );
2049        // Temperature resolution is untouched by pressure mode: an explicit, non-default
2050        // temperature stays authoritative exactly as resolve_station_conditions would give.
2051        assert_eq!(temp_c, -5.0);
2052        assert!((pressure_hpa - 860.068_111_433_278_3).abs() < 1e-9);
2053    }
2054
2055    #[test]
2056    fn test_altitude_affects_density_with_default_pressure() {
2057        // Regression: with the default pressure, altitude MUST lower density (previously the
2058        // air-density path ignored altitude whenever pressure was the sea-level default).
2059        let press = resolve_station_pressure(1013.25, 0.0);
2060        let (rho_sea, _) = calculate_atmosphere(0.0, Some(15.0), press, 50.0);
2061        let press_alt = resolve_station_pressure(1013.25, 2000.0);
2062        let (rho_2km, _) = calculate_atmosphere(2000.0, Some(15.0), press_alt, 50.0);
2063        assert!(
2064            rho_2km < rho_sea * 0.9,
2065            "density at 2000 m ({rho_2km}) should be well below sea level ({rho_sea})"
2066        );
2067
2068        // But an explicit station pressure stays authoritative (no altitude double-count):
2069        // density with an explicit pressure is independent of the altitude field.
2070        let p = resolve_station_pressure(900.0, 2000.0);
2071        let (rho_a, _) = calculate_atmosphere(2000.0, Some(15.0), p, 50.0);
2072        let (rho_b, _) = calculate_atmosphere(0.0, Some(15.0), p, 50.0);
2073        assert!(
2074            (rho_a - rho_b).abs() < 1e-9,
2075            "explicit pressure must ignore altitude"
2076        );
2077    }
2078
2079    #[test]
2080    fn test_resolve_station_temperature_contract() {
2081        // Default 15 C + real altitude => derive ICAO lapse temperature (None).
2082        assert_eq!(resolve_station_temperature(15.0, 2000.0), None);
2083        // An explicit, non-default temperature is authoritative (Some, used directly).
2084        assert_eq!(resolve_station_temperature(-5.0, 2000.0), Some(-5.0));
2085        assert_eq!(resolve_station_temperature(30.0, 2000.0), Some(30.0));
2086        // At/near sea level the default is used directly (no derivation needed).
2087        assert_eq!(resolve_station_temperature(15.0, 0.0), Some(15.0));
2088    }
2089
2090    #[test]
2091    fn test_altitude_only_default_matches_full_icao_standard() {
2092        // Regression: resolving BOTH temperature and pressure for an altitude-only query (defaults
2093        // left in place) must equal the fully-standard atmosphere at that altitude — i.e. altitude
2094        // now drives temperature (ICAO lapse) AND pressure, not just pressure. Validated against
2095        // py_ballisticcalc to ~0.04%. Previously the air held 15 C, leaving density ~7% too thin
2096        // (warm) at 3 km.
2097        for alt in [1000.0, 2000.0, 2500.0, 3000.0] {
2098            let t = resolve_station_temperature(15.0, alt);
2099            let p = resolve_station_pressure(1013.25, alt);
2100            let (rho_resolved, _) = calculate_atmosphere(alt, t, p, 0.0);
2101            let (rho_std, _) = calculate_atmosphere(alt, None, None, 0.0);
2102            assert!(
2103                (rho_resolved - rho_std).abs() < 1e-9,
2104                "alt {alt}: altitude-only default density {rho_resolved} should equal the full \
2105                 ICAO standard {rho_std}"
2106            );
2107            // And it must be denser than the old temperature-held-at-15C behavior (colder = denser).
2108            let (rho_warm, _) = calculate_atmosphere(alt, Some(15.0), p, 0.0);
2109            assert!(
2110                rho_resolved > rho_warm,
2111                "alt {alt}: lapse-temperature density {rho_resolved} should exceed 15 C-held {rho_warm}"
2112            );
2113        }
2114    }
2115
2116    #[test]
2117    fn test_enhanced_atmosphere_with_humidity() {
2118        let (density_dry, speed_dry) = calculate_atmosphere(0.0, None, None, 0.0);
2119        let (density_humid, speed_humid) = calculate_atmosphere(0.0, None, None, 80.0);
2120
2121        // Humid air should be less dense
2122        assert!(density_humid < density_dry);
2123        // Humid air should have slightly higher speed of sound
2124        assert!(speed_humid > speed_dry);
2125    }
2126
2127    #[test]
2128    fn test_enhanced_atmosphere_stratosphere() {
2129        // Test in stratosphere where temperature increases
2130        let (density_20km, speed_20km) = calculate_atmosphere(20000.0, None, None, 0.0);
2131        let (density_30km, speed_30km) = calculate_atmosphere(30000.0, None, None, 0.0);
2132
2133        // Density should decrease with altitude
2134        assert!(density_30km < density_20km);
2135        // Speed of sound should increase due to temperature increase
2136        assert!(speed_30km > speed_20km);
2137    }
2138
2139    #[test]
2140    fn test_enhanced_cimp_density() {
2141        let density = calculate_air_density_cimp(15.0, 1013.25, 0.0);
2142        assert!((density - 1.225).abs() < 0.01);
2143
2144        // Test with humidity
2145        let density_humid = calculate_air_density_cimp(15.0, 1013.25, 50.0);
2146        assert!(density_humid < density);
2147    }
2148
2149    #[test]
2150    fn test_cipm_moist_air_matches_python_reference() {
2151        // Regression for the hPa/Pa mole-fraction slip: p_v (hPa) was divided by the total
2152        // pressure in Pa, making x_v 100x too small and erasing the humidity effect entirely.
2153        // Reference values computed with the validated Python implementation
2154        // (ballistics.physics.atmosphere_icao.calculate_air_density_cipm_icao), same cases as
2155        // the Flask suite's tests/test_atmosphere.py::TestCalculateAirDensityCIPM. Tolerance
2156        // 0.1% (matches that suite's Rust-vs-Python assertion).
2157        let cases = [
2158            (15.0, 1013.25, 50.0, 1.221125867723075),
2159            (30.0, 1000.0, 80.0, 1.1344071877123691),
2160            (-10.0, 1020.0, 20.0, 1.3500610713710515),
2161        ];
2162        for (temp_c, pressure_hpa, humidity_pct, expected) in cases {
2163            let density = calculate_air_density_cipm(temp_c, pressure_hpa, humidity_pct);
2164            let rel_err = ((density - expected) / expected).abs();
2165            assert!(
2166                rel_err < 1e-3,
2167                "CIPM density at {temp_c} C / {pressure_hpa} hPa / {humidity_pct}% RH: \
2168                 got {density}, expected {expected} (rel err {rel_err:.2e} >= 1e-3)"
2169            );
2170        }
2171
2172        // Moist air must be materially lighter than dry air at the same temp/pressure
2173        // (the broken version returned a difference of only ~4e-5 kg/m^3).
2174        let dry = calculate_air_density_cipm(15.0, 1013.25, 0.0);
2175        let moist = calculate_air_density_cipm(15.0, 1013.25, 50.0);
2176        assert!(
2177            dry - moist > 3e-3,
2178            "humidity effect too small: dry {dry} vs 50% RH {moist}"
2179        );
2180    }
2181
2182    #[test]
2183    fn test_variable_lapse_rates() {
2184        // Test that lapse rates change appropriately with altitude
2185        let lapse_tropo = determine_local_lapse_rate(5000.0);
2186        let lapse_strato = determine_local_lapse_rate(25000.0);
2187
2188        assert!((lapse_tropo - (-0.0065)).abs() < 0.0001);
2189        assert!(lapse_strato > 0.0); // Positive lapse rate in stratosphere
2190    }
2191
2192    // ---- MBA-1137: AtmoSock stateless downrange lookup (mirrors the WindSock tests) ----
2193
2194    #[test]
2195    fn test_atmo_sock_empty_falls_back_to_isa() {
2196        let sock = AtmoSock::new(vec![]);
2197        assert!(sock.is_empty());
2198        // Empty sock returns the sea-level ISA reference regardless of distance.
2199        assert_eq!(sock.atmo_for_range(0.0), (15.0, 1013.25, 0.0));
2200        assert_eq!(sock.atmo_for_range(500.0), (15.0, 1013.25, 0.0));
2201    }
2202
2203    #[test]
2204    fn test_atmo_sock_single_segment_holds_beyond_last() {
2205        // One zone until 100 m; it must apply BOTH before and beyond the threshold (unlike wind,
2206        // which zeroes past the last segment — atmosphere holds the last zone).
2207        let sock = AtmoSock::new(vec![(25.0, 1000.0, 30.0, 100.0)]);
2208        assert_eq!(sock.atmo_for_range(50.0), (25.0, 1000.0, 30.0));
2209        assert_eq!(sock.atmo_for_range(100.0), (25.0, 1000.0, 30.0)); // beyond last -> hold
2210        assert_eq!(sock.atmo_for_range(5000.0), (25.0, 1000.0, 30.0));
2211    }
2212
2213    #[test]
2214    fn test_atmo_sock_boundary_is_upper_exclusive() {
2215        // A zone's until_distance_m is exclusive: a query exactly at the boundary rolls to the
2216        // next zone (mirrors WindSock::test_wind_sock_boundary_is_upper_exclusive).
2217        let sock = AtmoSock::new(vec![
2218            (30.0, 1010.0, 80.0, 100.0), // hot/humid near zone
2219            (-5.0, 900.0, 10.0, 200.0),  // cold/thin far zone
2220        ]);
2221        // Just below 100 m -> first zone.
2222        assert_eq!(sock.atmo_for_range(99.999), (30.0, 1010.0, 80.0));
2223        // Exactly 100 m -> second zone.
2224        assert_eq!(sock.atmo_for_range(100.0), (-5.0, 900.0, 10.0));
2225        // Beyond the last boundary -> hold the last zone (NOT zeroed).
2226        assert_eq!(sock.atmo_for_range(200.0), (-5.0, 900.0, 10.0));
2227        assert_eq!(sock.atmo_for_range(1e6), (-5.0, 900.0, 10.0));
2228    }
2229
2230    #[test]
2231    fn test_atmo_sock_sorts_unordered_segments() {
2232        // Segments supplied out of order are sorted by until_distance so the lookup is monotone.
2233        let sock = AtmoSock::new(vec![
2234            (-5.0, 900.0, 10.0, 200.0),
2235            (30.0, 1010.0, 80.0, 100.0),
2236        ]);
2237        assert_eq!(sock.atmo_for_range(50.0), (30.0, 1010.0, 80.0));
2238        assert_eq!(sock.atmo_for_range(150.0), (-5.0, 900.0, 10.0));
2239    }
2240
2241    #[test]
2242    fn test_atmo_sock_orders_nan_thresholds_last() {
2243        let positive_nan = f64::from_bits(0x7ff8_0000_0000_0001);
2244        let negative_nan = f64::from_bits(0xfff8_0000_0000_0002);
2245        let sock = AtmoSock::new(vec![
2246            (97.0, 997.0, 97.0, positive_nan),
2247            (30.0, 1030.0, 30.0, 300.0),
2248            (98.0, 998.0, 98.0, negative_nan),
2249            (10.0, 1010.0, 10.0, 100.0),
2250            (99.0, 999.0, 99.0, f64::NAN),
2251            (20.0, 1020.0, 20.0, 200.0),
2252        ]);
2253
2254        let thresholds: Vec<_> = sock.segments.iter().map(|segment| segment.3).collect();
2255        assert_eq!(&thresholds[..3], &[100.0, 200.0, 300.0]);
2256        assert!(thresholds[3..].iter().all(|threshold| threshold.is_nan()));
2257        assert_eq!(sock.atmo_for_range(f64::NAN), (10.0, 1010.0, 10.0));
2258        assert_eq!(sock.atmo_for_range(350.0), (99.0, 999.0, 99.0));
2259    }
2260
2261    #[test]
2262    fn test_atmo_sock_nan_uses_first_zone() {
2263        let sock = AtmoSock::new(vec![
2264            (30.0, 1010.0, 80.0, 100.0),
2265            (-5.0, 900.0, 10.0, 200.0),
2266        ]);
2267        // NaN can't be ordered; deterministically use the nearest (first) zone rather than panic.
2268        assert_eq!(sock.atmo_for_range(f64::NAN), (30.0, 1010.0, 80.0));
2269    }
2270
2271    // ---- MBA-1366: density altitude as a direct atmosphere input ----
2272
2273    /// With no explicit temperature, the resolved altitude must equal the input density
2274    /// altitude EXACTLY (this is the algebraic identity documented on
2275    /// `resolve_atmosphere_for_density_altitude`: the correction term vanishes when station
2276    /// temperature is left at ISA).
2277    #[test]
2278    fn density_altitude_default_temp_pressure_alt_equals_density_alt() {
2279        for da_ft in [0.0_f64, 1000.0, 3000.0, 7500.0, -500.0] {
2280            let da_m = da_ft * DA_FT_TO_M;
2281            let (altitude_m, temp_c, pressure_hpa) =
2282                resolve_atmosphere_for_density_altitude(da_m, None);
2283            assert!(
2284                (altitude_m - da_m).abs() < 1e-6,
2285                "da_ft={da_ft}: altitude_m {altitude_m} should equal da_m {da_m}"
2286            );
2287            // Sanity: pressure must be a plausible station pressure (strictly decreasing with DA).
2288            assert!(pressure_hpa > 0.0 && pressure_hpa < 1100.0);
2289            assert!(temp_c.is_finite());
2290        }
2291        // At DA=0 the result must be exactly the sea-level ISA reference.
2292        let (alt0, temp0, press0) = resolve_atmosphere_for_density_altitude(0.0, None);
2293        assert!(alt0.abs() < 1e-9);
2294        assert!((temp0 - 15.0).abs() < 1e-6);
2295        assert!((press0 - 1013.25).abs() < 1e-6);
2296    }
2297
2298    /// Higher density altitude at the ISA default must yield a lower station pressure (thinner
2299    /// air), matching the physical meaning of density altitude.
2300    #[test]
2301    fn density_altitude_pressure_decreases_monotonically() {
2302        let (_, _, p0) = resolve_atmosphere_for_density_altitude(0.0, None);
2303        let (_, _, p1) = resolve_atmosphere_for_density_altitude(1000.0, None);
2304        let (_, _, p2) = resolve_atmosphere_for_density_altitude(3000.0, None);
2305        assert!(p1 < p0);
2306        assert!(p2 < p1);
2307    }
2308
2309    /// An explicit temperature must be honored EXACTLY (never re-derived), while the implied
2310    /// pressure altitude (and therefore the resolved altitude/pressure) differs from the
2311    /// ISA-default case -- proving density is still honored (a different pressure/altitude
2312    /// combination that reproduces the SAME density altitude at the warmer/colder temperature).
2313    #[test]
2314    fn density_altitude_explicit_temperature_overrides_isa_default_but_honors_density() {
2315        let da_m = 1000.0 * DA_FT_TO_M;
2316        let (default_alt_m, default_temp_c, default_pressure_hpa) =
2317            resolve_atmosphere_for_density_altitude(da_m, None);
2318
2319        // A HOTTER-than-ISA explicit station temperature at the same density altitude implies a
2320        // LOWER pressure altitude: hot (less dense) air already accounts for some of the
2321        // "thinness" that defines the density altitude, so less physical elevation is needed to
2322        // reach the rest of it (equivalently: the correction term subtracts from DA before the
2323        // pressure inversion, since a warmer station reads MORE dense-feeling per unit of actual
2324        // elevation than ISA does).
2325        let hot_temp_c = default_temp_c + 20.0;
2326        let (hot_alt_m, hot_temp_out, hot_pressure_hpa) =
2327            resolve_atmosphere_for_density_altitude(da_m, Some(hot_temp_c));
2328        assert_eq!(
2329            hot_temp_out, hot_temp_c,
2330            "explicit temperature must be honored exactly"
2331        );
2332        assert!(
2333            hot_alt_m < default_alt_m,
2334            "hotter station temp at same DA should imply a lower pressure altitude: \
2335             hot={hot_alt_m} default={default_alt_m}"
2336        );
2337        assert!(hot_pressure_hpa > default_pressure_hpa);
2338
2339        // A COLDER-than-ISA explicit temperature implies a HIGHER pressure altitude.
2340        let cold_temp_c = default_temp_c - 20.0;
2341        let (cold_alt_m, cold_temp_out, cold_pressure_hpa) =
2342            resolve_atmosphere_for_density_altitude(da_m, Some(cold_temp_c));
2343        assert_eq!(cold_temp_out, cold_temp_c);
2344        assert!(cold_alt_m > default_alt_m);
2345        assert!(cold_pressure_hpa < default_pressure_hpa);
2346    }
2347
2348    /// Round trip using ONLY this crate's own math (the forward NWS pressure-altitude formula,
2349    /// re-derived independently in the test rather than reusing the production function) -- an
2350    /// implementation-independent algebraic sanity check. The REAL cross-crate round trip
2351    /// against the production `pdf_dope_card::calculate_density_altitude` forward function
2352    /// lives in `src/main.rs` (that function is CLI-binary-only and unreachable from this
2353    /// library crate).
2354    #[test]
2355    fn density_altitude_round_trips_through_hand_derived_forward_formula() {
2356        fn forward_density_altitude_ft(pressure_hpa: f64, temp_c: f64) -> f64 {
2357            let pressure_alt_ft = 145_366.45 * (1.0 - (pressure_hpa / 1013.25).powf(0.190_284));
2358            let isa_temp_f = 59.0 - (pressure_alt_ft / 1000.0) * 3.57;
2359            let temp_f = temp_c * 9.0 / 5.0 + 32.0;
2360            pressure_alt_ft + (120.0 * 5.0 / 9.0) * (temp_f - isa_temp_f)
2361        }
2362
2363        for da_ft in [0.0_f64, 500.0, 2500.0, 6000.0] {
2364            let da_m = da_ft * DA_FT_TO_M;
2365            // Default (ISA-at-DA) branch.
2366            let (_, temp_c, pressure_hpa) = resolve_atmosphere_for_density_altitude(da_m, None);
2367            let round_tripped_ft = forward_density_altitude_ft(pressure_hpa, temp_c);
2368            assert!(
2369                (round_tripped_ft - da_ft).abs() < 1e-6,
2370                "da_ft={da_ft}: round-tripped {round_tripped_ft} (temp_c={temp_c}, pressure_hpa={pressure_hpa})"
2371            );
2372
2373            // Explicit-temperature branch.
2374            let explicit_temp_c = 25.0;
2375            let (_, temp_out, pressure_hpa_explicit) =
2376                resolve_atmosphere_for_density_altitude(da_m, Some(explicit_temp_c));
2377            assert_eq!(temp_out, explicit_temp_c);
2378            let round_tripped_explicit_ft =
2379                forward_density_altitude_ft(pressure_hpa_explicit, temp_out);
2380            assert!(
2381                (round_tripped_explicit_ft - da_ft).abs() < 1e-6,
2382                "da_ft={da_ft} (explicit {explicit_temp_c}C): round-tripped {round_tripped_explicit_ft}"
2383            );
2384        }
2385    }
2386}
2387
2388/// Station-presence resolution (0.49.0): a supplied station reading is never read as omitted.
2389#[cfg(test)]
2390mod station_presence_tests {
2391    use super::*;
2392
2393    /// The body of `resolve_station_conditions_with_pressure_mode` exactly as it stood at
2394    /// ab4eb0c, before presence existed. It calls only the primitives this change leaves
2395    /// alone, so comparing against it cannot pass by comparing the new code with itself.
2396    fn legacy_resolve_ab4eb0c(
2397        temperature_c: f64,
2398        pressure_hpa: f64,
2399        altitude_m: f64,
2400        pressure_mode: PressureReferenceMode,
2401    ) -> (f64, f64) {
2402        let temp_override = resolve_station_temperature(temperature_c, altitude_m);
2403        let press_override =
2404            resolve_station_pressure_with_mode(pressure_hpa, altitude_m, pressure_mode);
2405        let (std_temp_k, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
2406        let temp_c = temp_override.unwrap_or(std_temp_k - 273.15);
2407        let pressure_hpa = press_override.unwrap_or(std_pressure_pa / 100.0);
2408        (temp_c, pressure_hpa)
2409    }
2410
2411    const TEMPERATURES_C: [f64; 10] = [
2412        -40.0, 14.89, 14.9, 14.95, 15.0, 15.05, 15.0999, 15.1, 15.2, 30.0,
2413    ];
2414    const PRESSURES_HPA: [f64; 11] = [
2415        1012.7,
2416        1012.75,
2417        1012.8562053925472,
2418        1013.0,
2419        1013.2079,
2420        1013.25,
2421        1013.5,
2422        1013.75,
2423        1014.0,
2424        850.0,
2425        1031.0,
2426    ];
2427    const ALTITUDES_M: [f64; 13] = [
2428        -2.0, -1.0, -0.5, 0.0, 0.5, 1.0, 1.0000001, 3.0, 10.0, 150.0, 1500.0, 1524.0, 5000.0,
2429    ];
2430    const MODES: [PressureReferenceMode; 2] =
2431        [PressureReferenceMode::Absolute, PressureReferenceMode::Qnh];
2432
2433    fn assert_bits(actual: (f64, f64), expected: (f64, f64), context: &str) {
2434        assert_eq!(
2435            actual.0.to_bits(),
2436            expected.0.to_bits(),
2437            "temperature {context}: {actual:?} vs {expected:?}"
2438        );
2439        assert_eq!(
2440            actual.1.to_bits(),
2441            expected.1.to_bits(),
2442            "pressure {context}: {actual:?} vs {expected:?}"
2443        );
2444    }
2445
2446    #[test]
2447    fn neither_is_bit_identical_to_ab4eb0c() {
2448        for t in TEMPERATURES_C {
2449            for p in PRESSURES_HPA {
2450                for alt in ALTITUDES_M {
2451                    for mode in MODES {
2452                        let expected = legacy_resolve_ab4eb0c(t, p, alt, mode);
2453                        let context = format!("({t}, {p}, {alt}, {mode:?})");
2454                        assert_bits(
2455                            resolve_station_conditions_with_presence(
2456                                t,
2457                                p,
2458                                alt,
2459                                mode,
2460                                StationAtmospherePresence::NEITHER,
2461                            ),
2462                            expected,
2463                            &context,
2464                        );
2465                        assert_bits(
2466                            resolve_station_conditions_with_pressure_mode(t, p, alt, mode),
2467                            expected,
2468                            &context,
2469                        );
2470                    }
2471                }
2472            }
2473        }
2474
2475        // Six points captured at ab4eb0c, so a change to a shared primitive cannot pass
2476        // unseen through the verbatim copy above.
2477        let pinned = [
2478            (
2479                (15.05, 1013.0, 150.0, PressureReferenceMode::Absolute),
2480                // captured at ab4eb0c
2481                (
2482                    f64::from_bits(0x402c0ccfd0c49b20),
2483                    f64::from_bits(0x408f1ae13f434f1f),
2484                ),
2485            ),
2486            (
2487                (15.0, 1013.25, 1500.0, PressureReferenceMode::Absolute),
2488                // captured at ab4eb0c
2489                (
2490                    f64::from_bits(0x4015025af8d136c0),
2491                    f64::from_bits(0x408a6cc6358cefbd),
2492                ),
2493            ),
2494            (
2495                (15.0, 1013.25, 10.0, PressureReferenceMode::Qnh),
2496                // captured at ab4eb0c
2497                (
2498                    f64::from_bits(0x402ddeb85559de00),
2499                    f64::from_bits(0x408fa064e4e5f101),
2500                ),
2501            ),
2502            (
2503                (15.0, 1013.2079, 1524.0, PressureReferenceMode::Qnh),
2504                // captured at ab4eb0c
2505                (
2506                    f64::from_bits(0x401462aff47e8bc0),
2507                    f64::from_bits(0x408a58982cf0204f),
2508                ),
2509            ),
2510            (
2511                (
2512                    14.95,
2513                    1012.8562053925472,
2514                    1.0000001,
2515                    PressureReferenceMode::Absolute,
2516                ),
2517                // captured at ab4eb0c
2518                (
2519                    f64::from_bits(0x402dfcac08345a20),
2520                    f64::from_bits(0x408fa909fb979713),
2521                ),
2522            ),
2523            (
2524                (15.0, 1013.25, 3.0, PressureReferenceMode::Absolute),
2525                // captured at ab4eb0c
2526                (
2527                    f64::from_bits(0x402df60418e281e0),
2528                    f64::from_bits(0x408fa71e04fe2409),
2529                ),
2530            ),
2531        ];
2532        for ((t, p, alt, mode), expected) in pinned {
2533            let context = format!("pinned ({t}, {p}, {alt}, {mode:?})");
2534            assert_bits(
2535                resolve_station_conditions_with_presence(
2536                    t,
2537                    p,
2538                    alt,
2539                    mode,
2540                    StationAtmospherePresence::NEITHER,
2541                ),
2542                expected,
2543                &context,
2544            );
2545            assert_bits(
2546                resolve_station_conditions_with_pressure_mode(t, p, alt, mode),
2547                expected,
2548                &context,
2549            );
2550        }
2551    }
2552
2553    #[test]
2554    fn both_returns_the_supplied_pair() {
2555        for t in TEMPERATURES_C {
2556            for p in PRESSURES_HPA {
2557                for alt in ALTITUDES_M {
2558                    let context = format!("({t}, {p}, {alt})");
2559                    assert_bits(
2560                        resolve_station_conditions_with_presence(
2561                            t,
2562                            p,
2563                            alt,
2564                            PressureReferenceMode::Absolute,
2565                            StationAtmospherePresence::BOTH,
2566                        ),
2567                        (t, p),
2568                        &context,
2569                    );
2570                    assert_bits(
2571                        resolve_station_conditions_with_presence(
2572                            t,
2573                            p,
2574                            alt,
2575                            PressureReferenceMode::Qnh,
2576                            StationAtmospherePresence::BOTH,
2577                        ),
2578                        (t, reduce_qnh_to_station_pressure(p, alt)),
2579                        &context,
2580                    );
2581                }
2582            }
2583        }
2584        // The headline repro: QNH 1031 hPa at 150 m reduces into the band and still flies.
2585        let (_, p) = resolve_station_conditions_with_presence(
2586            20.0,
2587            1031.0,
2588            150.0,
2589            PressureReferenceMode::Qnh,
2590            StationAtmospherePresence::BOTH,
2591        );
2592        assert_eq!(p.to_bits(), 1012.8562053925472_f64.to_bits(), "{p:?}");
2593    }
2594
2595    #[test]
2596    fn presence_is_per_field() {
2597        let (icao_t_k, icao_p_pa) = calculate_icao_standard_atmosphere(1500.0);
2598        let temperature_only = resolve_station_conditions_with_presence(
2599            15.0,
2600            1013.25,
2601            1500.0,
2602            PressureReferenceMode::Absolute,
2603            StationAtmospherePresence::new(true, false),
2604        );
2605        assert_bits(temperature_only, (15.0, icao_p_pa / 100.0), "(true, false)");
2606        let pressure_only = resolve_station_conditions_with_presence(
2607            15.0,
2608            1013.0,
2609            1500.0,
2610            PressureReferenceMode::Absolute,
2611            StationAtmospherePresence::new(false, true),
2612        );
2613        assert_bits(pressure_only, (icao_t_k - 273.15, 1013.0), "(false, true)");
2614    }
2615
2616    #[test]
2617    fn presence_constructors_agree() {
2618        assert_eq!(
2619            StationAtmospherePresence::default(),
2620            StationAtmospherePresence::NEITHER
2621        );
2622        assert_eq!(
2623            StationAtmospherePresence::NEITHER,
2624            StationAtmospherePresence::new(false, false)
2625        );
2626        assert_eq!(
2627            StationAtmospherePresence::BOTH,
2628            StationAtmospherePresence::new(true, true)
2629        );
2630        assert_eq!(
2631            StationAtmospherePresence::from_readings(
2632                StationReading::supplied(15.0),
2633                StationReading::omitted(1013.25)
2634            ),
2635            StationAtmospherePresence::new(true, false)
2636        );
2637        assert_eq!(
2638            StationAtmospherePresence::from_readings(
2639                StationReading::omitted(59.0),
2640                StationReading::supplied(29.92)
2641            ),
2642            StationAtmospherePresence::new(false, true)
2643        );
2644        assert!(StationReading::omitted(29.92).is_omitted());
2645        assert!(!StationReading::supplied(29.92).is_omitted());
2646        assert_eq!(
2647            StationReading::omitted(29.92).value.to_bits(),
2648            29.92_f64.to_bits()
2649        );
2650    }
2651
2652    #[derive(Debug, serde::Serialize, serde::Deserialize)]
2653    #[serde(deny_unknown_fields)]
2654    struct Probe {
2655        #[serde(
2656            default = "probe_default",
2657            skip_serializing_if = "StationReading::is_omitted"
2658        )]
2659        pressure_inhg: StationReading,
2660    }
2661
2662    fn probe_default() -> StationReading {
2663        StationReading::omitted(29.92)
2664    }
2665
2666    #[test]
2667    fn station_reading_serde() {
2668        let absent: Probe = serde_json::from_str("{}").unwrap();
2669        assert!(!absent.pressure_inhg.supplied);
2670        assert_eq!(absent.pressure_inhg.value.to_bits(), 29.92_f64.to_bits());
2671
2672        let present: Probe = serde_json::from_str(r#"{"pressure_inhg": 29.92}"#).unwrap();
2673        assert!(present.pressure_inhg.supplied);
2674        assert_eq!(present.pressure_inhg.value.to_bits(), 29.92_f64.to_bits());
2675
2676        assert_eq!(serde_json::to_string(&absent).unwrap(), "{}");
2677        assert_eq!(
2678            serde_json::to_string(&present).unwrap(),
2679            r#"{"pressure_inhg":29.92}"#
2680        );
2681
2682        assert!(serde_json::from_str::<Probe>(r#"{"pressure_inhg": null}"#).is_err());
2683        assert!(serde_json::from_str::<Probe>(r#"{"pressure_inhg": "29.92"}"#).is_err());
2684    }
2685}