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 std::cmp::Ordering;
7
8/// ICAO Standard Atmosphere layer definitions
9#[derive(Debug, Clone)]
10struct AtmosphereLayer {
11    /// Base geopotential height of this layer (m)
12    base_altitude: f64,
13    /// Base temperature at layer start (K)
14    base_temperature: f64,
15    /// Base pressure at layer start (Pa)
16    base_pressure: f64,
17    /// Temperature lapse rate (K/m)
18    lapse_rate: f64,
19}
20
21/// ICAO Standard Atmosphere constants
22const G_ACCEL_MPS2: f64 = 9.80665;
23const R_AIR: f64 = 287.0531; // Specific gas constant for dry air (J/(kg·K))
24const GAMMA: f64 = 1.4; // Heat capacity ratio for air
25const GEOPOTENTIAL_EARTH_RADIUS_M: f64 = 6_356_766.0;
26const MIN_GEOMETRIC_ALTITUDE_M: f64 = -5000.0;
27const MAX_GEOMETRIC_ALTITUDE_M: f64 = 84000.0;
28const MIN_STANDARD_GEOPOTENTIAL_HEIGHT_M: f64 = -5000.0;
29const MAX_STANDARD_GEOPOTENTIAL_HEIGHT_M: f64 = 84000.0;
30
31/// CIPM constants for precise air density calculation
32const R: f64 = 8.314472; // Universal gas constant
33const M_A: f64 = 28.96546e-3; // Molar mass of dry air (kg/mol)
34const M_V: f64 = 18.01528e-3; // Molar mass of water vapor (kg/mol)
35
36/// ICAO Standard Atmosphere layer data up to 84 km
37/// Pressures calculated using barometric formula between layers
38const ICAO_LAYERS: &[AtmosphereLayer] = &[
39    // Troposphere (-5 - 11 km; the sea-level base extrapolates smoothly below 0 m)
40    AtmosphereLayer {
41        base_altitude: 0.0,
42        base_temperature: 288.15, // 15°C
43        base_pressure: 101325.0,  // 1013.25 hPa
44        lapse_rate: -0.0065,      // -6.5 K/km
45    },
46    // Tropopause (11 - 20 km)
47    AtmosphereLayer {
48        base_altitude: 11000.0,
49        base_temperature: 216.65, // -56.5°C
50        base_pressure: 22632.1,   // 226.32 hPa
51        lapse_rate: 0.0,          // Isothermal
52    },
53    // Stratosphere 1 (20 - 32 km)
54    AtmosphereLayer {
55        base_altitude: 20000.0,
56        base_temperature: 216.65, // -56.5°C
57        base_pressure: 5474.89,   // 54.75 hPa
58        lapse_rate: 0.001,        // +1 K/km
59    },
60    // Stratosphere 2 (32 - 47 km)
61    AtmosphereLayer {
62        base_altitude: 32000.0,
63        base_temperature: 228.65, // -44.5°C
64        base_pressure: 868.02,    // 8.68 hPa
65        lapse_rate: 0.0028,       // +2.8 K/km
66    },
67    // Stratopause (47 - 51 km)
68    AtmosphereLayer {
69        base_altitude: 47000.0,
70        base_temperature: 270.65, // -2.5°C
71        base_pressure: 110.91,    // 1.11 hPa
72        lapse_rate: 0.0,          // Isothermal
73    },
74    // Mesosphere 1 (51 - 71 km)
75    AtmosphereLayer {
76        base_altitude: 51000.0,
77        base_temperature: 270.65, // -2.5°C
78        base_pressure: 66.94,     // 0.67 hPa
79        lapse_rate: -0.0028,      // -2.8 K/km
80    },
81    // Mesosphere 2 (71 - 84 km)
82    AtmosphereLayer {
83        base_altitude: 71000.0,
84        base_temperature: 214.65, // -58.5°C
85        base_pressure: 3.96,      // 0.04 hPa
86        lapse_rate: -0.002,       // -2.0 K/km
87    },
88];
89
90/// Calculate ICAO Standard Atmosphere conditions at any altitude.
91///
92/// This function implements the full ICAO Standard Atmosphere model with all
93/// atmospheric layers up to 84 km altitude.
94///
95/// # Arguments
96/// * `altitude_m` - Geometric altitude above mean sea level in meters (-5000 to 84000; values
97///   outside are clamped). It is converted to geopotential height before layer evaluation.
98///
99/// # Returns
100/// Tuple of (temperature_k, pressure_pa)
101pub(crate) fn calculate_icao_standard_atmosphere(altitude_m: f64) -> (f64, f64) {
102    let geometric_altitude = altitude_m.clamp(MIN_GEOMETRIC_ALTITUDE_M, MAX_GEOMETRIC_ALTITUDE_M);
103    let geopotential_height = geometric_to_geopotential_height_m(geometric_altitude).clamp(
104        MIN_STANDARD_GEOPOTENTIAL_HEIGHT_M,
105        MAX_STANDARD_GEOPOTENTIAL_HEIGHT_M,
106    );
107
108    // Find the appropriate atmospheric layer
109    let layer = ICAO_LAYERS
110        .iter()
111        .rev()
112        .find(|layer| geopotential_height >= layer.base_altitude)
113        .unwrap_or(&ICAO_LAYERS[0]);
114
115    let height_diff = geopotential_height - layer.base_altitude;
116    let temperature = layer.base_temperature + layer.lapse_rate * height_diff;
117
118    let pressure = if layer.lapse_rate.abs() < 1e-10 {
119        // Isothermal layer
120        layer.base_pressure * (-G_ACCEL_MPS2 * height_diff / (R_AIR * layer.base_temperature)).exp()
121    } else {
122        // Non-isothermal layer
123        let temp_ratio = temperature / layer.base_temperature;
124        layer.base_pressure * temp_ratio.powf(-G_ACCEL_MPS2 / (layer.lapse_rate * R_AIR))
125    };
126
127    (temperature, pressure)
128}
129
130/// Convert physical geometric altitude to the geopotential height used by ICAO layer tables.
131#[inline]
132fn geometric_to_geopotential_height_m(geometric_altitude_m: f64) -> f64 {
133    GEOPOTENTIAL_EARTH_RADIUS_M * geometric_altitude_m
134        / (GEOPOTENTIAL_EARTH_RADIUS_M + geometric_altitude_m)
135}
136
137/// Resolve the station-pressure override for an air-density calculation.
138///
139/// Altitude and pressure are redundant inputs for density. The rule:
140/// * An explicitly-supplied pressure is the authoritative STATION pressure (already
141///   altitude-reduced); it is returned as `Some` and used directly, so altitude is NOT
142///   double-counted.
143/// * When pressure is left at the sea-level standard default (≈1013.25 hPa) while a real
144///   altitude is given, the caller meant "standard atmosphere at this altitude": return
145///   `None` so [`calculate_atmosphere`] derives the station pressure from altitude (ICAO
146///   standard) instead of silently using sea-level density.
147///
148/// Without this, `--altitude` with the default pressure produced sea-level density (altitude
149/// had no effect on drag). The ±0.5 hPa tolerance covers the `29.92 inHg ≈ 1013.21 hPa`
150/// conversion, and `>1 m` avoids triggering at sea level. (Mirrors the existing
151/// `pressure != 29.92` "user override" sentinel used elsewhere in the CLI.)
152pub fn resolve_station_pressure(pressure_hpa: f64, altitude_m: f64) -> Option<f64> {
153    const SEA_LEVEL_HPA: f64 = 1013.25;
154    if (pressure_hpa - SEA_LEVEL_HPA).abs() < 0.5 && altitude_m.abs() > 1.0 {
155        None // pressure left at default + real altitude → derive station pressure from altitude
156    } else {
157        Some(pressure_hpa) // explicit station pressure is authoritative
158    }
159}
160
161/// Resolve the temperature override for an air-density calculation, mirroring
162/// [`resolve_station_pressure`].
163///
164/// * An explicitly-supplied temperature is authoritative (returned as `Some`).
165/// * When temperature is left at the sea-level standard default (15 °C) while a real altitude
166///   is given, the caller meant "standard atmosphere at this altitude": return `None` so
167///   [`calculate_atmosphere`] applies the ICAO lapse-rate temperature for that altitude
168///   (≈ −6.5 °C/km).
169///
170/// Without this, `--altitude` with the default temperature held the air at 15 °C, which
171/// under-estimates density (warm air is thinner) by ~2.4% at 1 km up to ~7% at 3 km versus the
172/// standard atmosphere — validated against py_ballisticcalc, which derives both temperature and
173/// pressure from altitude. The 0.1 °C tolerance matches the `59 °F = 15.0 °C` default exactly,
174/// and `>1 m` avoids triggering at sea level. A shooter at a genuinely non-standard temperature
175/// at altitude should pass an explicit temperature (same contract as station pressure).
176pub fn resolve_station_temperature(temperature_c: f64, altitude_m: f64) -> Option<f64> {
177    const SEA_LEVEL_TEMP_C: f64 = 15.0;
178    if (temperature_c - SEA_LEVEL_TEMP_C).abs() < 0.1 && altitude_m.abs() > 1.0 {
179        None // temperature left at default + real altitude → derive ICAO lapse temperature
180    } else {
181        Some(temperature_c) // explicit temperature is authoritative
182    }
183}
184
185/// Return the station temperature and pressure that [`calculate_atmosphere`] will use after
186/// applying the default-at-altitude resolution rules.
187///
188/// A thin, byte-identical delegate to
189/// [`resolve_station_conditions_with_pressure_mode`] at [`PressureReferenceMode::Absolute`]
190/// (MBA-1397) — kept as a separate name/signature so no existing caller needs to change.
191pub fn resolve_station_conditions(
192    temperature_c: f64,
193    pressure_hpa: f64,
194    altitude_m: f64,
195) -> (f64, f64) {
196    resolve_station_conditions_with_pressure_mode(
197        temperature_c,
198        pressure_hpa,
199        altitude_m,
200        PressureReferenceMode::Absolute,
201    )
202}
203
204/// Whether a station-pressure input is already absolute (station) pressure, or a sea-level-
205/// corrected altimeter setting (QNH / "barometer" reading) that must be reduced to station
206/// pressure at the shooter's altitude before use (MBA-1397).
207///
208/// Kestrel, AB Mobile, Shooter, JBM, and Hornady 4DOF all let the user declare which one they
209/// are entering. Previously this engine only supported [`Absolute`](Self::Absolute) — an
210/// entered sea-level-corrected barometer/METAR value at a real altitude was silently treated
211/// as station pressure, over-stating air density and under-stating drop.
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
213#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
214pub enum PressureReferenceMode {
215    /// The input is already absolute station pressure — today's meaning, and the default, so
216    /// every existing caller that never sets this is byte-identical to pre-MBA-1397 behavior.
217    #[default]
218    Absolute,
219    /// The input is a sea-level-corrected altimeter setting (QNH) and must be reduced to
220    /// station pressure at the target altitude via [`reduce_qnh_to_station_pressure`].
221    Qnh,
222}
223
224/// Reduce a sea-level-corrected altimeter setting (QNH) to the station pressure at
225/// `altitude_m` (MBA-1397), using the inverse of the ICAO troposphere barometric formula:
226///
227/// `station = QNH * (1 + L*h/T0)^(-g/(L*R))`
228///
229/// which, for the troposphere's published lapse rate `L` and sea-level temperature `T0`, is
230/// exactly the standard ICAO Doc 7488 / NOAA altimeter-setting reduction
231/// `station = QNH * (1 - 0.0065*h/288.15)^5.25588`, where `h` is GEOPOTENTIAL height, not
232/// the geometric altitude the caller supplies — this function converts the one to the other
233/// first (`geometric_to_geopotential_height_m`), as ISA requires. Substituting geometric
234/// altitude directly into that expression reproduces the reduction only to about 0.04 hPa
235/// at 1500 m, which is why the worked values below are not what the bare formula gives.
236///
237/// Reuses the SAME troposphere layer (`ICAO_LAYERS[0]`) and physical constants
238/// (`G_ACCEL_MPS2`, `R_AIR`) that `calculate_icao_standard_atmosphere` (private to this crate)
239/// uses for its own non-isothermal-layer pressure formula, rather than re-hardcoding the
240/// lapse rate / sea-level temperature / exponent a second time — this is that same formula,
241/// solved for a caller-given sea-level pressure instead of the fixed 1013.25 hPa standard.
242/// `altitude_m` is clamped and converted to geopotential height exactly like
243/// `calculate_icao_standard_atmosphere`, so a QNH of exactly 1013.25 hPa reduces to precisely
244/// the ICAO standard station pressure at every altitude (this is what makes the
245/// omitted-pressure default byte-identical under either [`PressureReferenceMode`]).
246///
247/// The reduction is only physically meaningful within the troposphere (the altitude range
248/// real shooting takes place in); it is not re-derived per-layer for higher altitudes.
249pub fn reduce_qnh_to_station_pressure(qnh_hpa: f64, altitude_m: f64) -> f64 {
250    let geometric_altitude = altitude_m.clamp(MIN_GEOMETRIC_ALTITUDE_M, MAX_GEOMETRIC_ALTITUDE_M);
251    let geopotential_height = geometric_to_geopotential_height_m(geometric_altitude).clamp(
252        MIN_STANDARD_GEOPOTENTIAL_HEIGHT_M,
253        MAX_STANDARD_GEOPOTENTIAL_HEIGHT_M,
254    );
255    let layer = &ICAO_LAYERS[0]; // troposphere: the only layer the altimeter-setting reduction covers
256    let temp_ratio = 1.0 + layer.lapse_rate * geopotential_height / layer.base_temperature;
257    let exponent = -G_ACCEL_MPS2 / (layer.lapse_rate * R_AIR);
258    qnh_hpa * temp_ratio.powf(exponent)
259}
260
261/// Mode-aware counterpart of [`resolve_station_pressure`] (MBA-1397). Does NOT change that
262/// function's signature or behavior — [`PressureReferenceMode::Absolute`] is a pure
263/// passthrough to it — so this is a new function alongside the original, not a replacement.
264///
265/// [`PressureReferenceMode::Qnh`] reduces `pressure_hpa` (interpreted as a QNH) to station
266/// pressure via [`reduce_qnh_to_station_pressure`] and returns it as `Some(..)`
267/// UNCONDITIONALLY, bypassing the default-sentinel ambiguity `resolve_station_pressure` must
268/// use to infer omission from a bare `f64`. This matters: a QNH the caller explicitly declared
269/// should never be silently re-interpreted as an omitted default merely because the REDUCED
270/// number happens to land within the sentinel's tolerance of the 1013.25 hPa sea-level
271/// constant (a real, reachable case — e.g. a high-pressure day's QNH reducing to ~1013 hPa at
272/// a few hundred meters of elevation).
273pub fn resolve_station_pressure_with_mode(
274    pressure_hpa: f64,
275    altitude_m: f64,
276    mode: PressureReferenceMode,
277) -> Option<f64> {
278    match mode {
279        PressureReferenceMode::Absolute => resolve_station_pressure(pressure_hpa, altitude_m),
280        PressureReferenceMode::Qnh => {
281            Some(reduce_qnh_to_station_pressure(pressure_hpa, altitude_m))
282        }
283    }
284}
285
286/// Mode-aware counterpart of [`resolve_station_conditions`] (MBA-1397): identical for
287/// [`PressureReferenceMode::Absolute`] (byte-identical delegate — [`resolve_station_conditions`]
288/// itself now forwards here), and reduces an explicit QNH pressure to station pressure via
289/// [`resolve_station_pressure_with_mode`] for [`PressureReferenceMode::Qnh`] before applying
290/// the same standard-atmosphere fallback used when either input is left at its sea-level
291/// default.
292pub fn resolve_station_conditions_with_pressure_mode(
293    temperature_c: f64,
294    pressure_hpa: f64,
295    altitude_m: f64,
296    pressure_mode: PressureReferenceMode,
297) -> (f64, f64) {
298    let temp_override = resolve_station_temperature(temperature_c, altitude_m);
299    let press_override =
300        resolve_station_pressure_with_mode(pressure_hpa, altitude_m, pressure_mode);
301    let (std_temp_k, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
302    let temp_c = temp_override.unwrap_or(std_temp_k - 273.15);
303    let pressure_hpa = press_override.unwrap_or(std_pressure_pa / 100.0);
304    (temp_c, pressure_hpa)
305}
306
307// ---------------------------------------------------------------------------------------
308// MBA-1366: density altitude as a direct atmosphere input
309// ---------------------------------------------------------------------------------------
310
311/// NWS pressure-altitude and FAA-rule constants — the ONE copy.
312///
313/// Both directions of the density-altitude model now live in this module: the forward
314/// [`faa_rule_density_altitude_ft`] (the engine's reported density altitude) and its exact
315/// inverse [`resolve_atmosphere_for_density_altitude`] (MBA-1366, the DA entry mode). They
316/// used to live apart, with these constants duplicated here because the forward formula sat
317/// in `pdf_dope_card` — first in the `ballistics` binary, then (from 0.33.0's Task 10) in
318/// this library but behind `#[cfg(feature = "pdf")]`, which this unconditional module could
319/// not import across without breaking the `--no-default-features` wasm32 build CI
320/// type-checks.
321///
322/// The fix was to move the formula DOWN rather than import it UP: `atmosphere` is
323/// unconditional, so the forward function lives here and
324/// `pdf_dope_card::calculate_density_altitude` now delegates to it, bit for bit. That leaves
325/// no second copy to drift, and it is what lets the `atmosphere.density_altitude` bridge
326/// command exist in every build with the bridge — including wasm32, which has no PDF card.
327///
328/// `src/main.rs`'s `density_altitude_round_trips_through_the_dope_card_formula*` tests still
329/// pin forward and inverse together, now through a single set of constants.
330const DA_NWS_SEA_LEVEL_HPA: f64 = 1013.25;
331const DA_NWS_PRESSURE_ALT_FT: f64 = 145_366.45;
332const DA_NWS_EXPONENT: f64 = 0.190_284;
333const DA_NWS_SEA_LEVEL_TEMP_F: f64 = 59.0;
334const DA_NWS_LAPSE_F_PER_1000FT: f64 = 3.57;
335/// The FAA rule of thumb, "120 ft of density altitude per °C away from standard", expressed
336/// per °F because the NWS constants above are Fahrenheit.
337const DA_FAA_RULE_FT_PER_DEG_F: f64 = 120.0 * 5.0 / 9.0;
338const DA_FT_TO_M: f64 = 0.3048;
339
340/// NWS pressure altitude, feet, from STATION pressure in hPa.
341///
342/// The published NWS station-pressure equation
343/// (<https://www.weather.gov/media/epz/wxcalc/pressureAltitude.pdf>):
344///
345/// ```text
346/// PA_ft = 145366.45 * (1 - (P_hPa / 1013.25)^0.190284)
347/// ```
348///
349/// Station pressure, never an altimeter setting (MBA-643): a QNH already corrects to sea
350/// level and would report the pressure altitude of the sea rather than of the firing point.
351pub fn nws_pressure_altitude_ft(station_pressure_hpa: f64) -> f64 {
352    DA_NWS_PRESSURE_ALT_FT
353        * (1.0 - (station_pressure_hpa / DA_NWS_SEA_LEVEL_HPA).powf(DA_NWS_EXPONENT))
354}
355
356/// Density altitude by the FAA rule of thumb, feet: NWS pressure altitude plus 120 ft for
357/// every °C the station is away from ISA temperature at that pressure altitude. **Humidity is
358/// not an input.**
359///
360/// ```text
361/// DA_ft = PA_ft + (120 * 5/9) * (T_F - ISA_T_F(PA))      ISA_T_F(PA) = 59 - 3.57 * PA_ft/1000
362/// ```
363///
364/// This is the density altitude the engine has always reported —
365/// `pdf_dope_card::calculate_density_altitude` delegates to it — and the one
366/// [`resolve_atmosphere_for_density_altitude`], the density-altitude ENTRY mode, inverts
367/// exactly.
368///
369/// It stays humidity-free for compatibility, not out of necessity. Every card printed to date
370/// shows this figure, and the entry mode that inverts it has shipped since MBA-1366 taking a
371/// temperature but no humidity (the run's humidity is applied separately, afterwards). A
372/// humidity-aware forward could be inverted just as well given the run's humidity, the way
373/// temperature already is; it would change every printed card and what an entered density
374/// altitude resolves to.
375///
376/// It is not the National Weather Service's density altitude either, despite the NWS pressure
377/// altitude inside it: the NWS calculator uses virtual temperature, so it includes humidity
378/// and tracks [`density_matched_altitude_m`] to within a few meters.
379///
380/// And it is an approximation. The rule is a straight line where the real dependence curves,
381/// and 120 ft/°C is slightly steeper than the exact slope at sea level (118.6 ft/°C for an
382/// ideal gas, 119.0 for the CIPM density [`density_matched_altitude_m`] uses), so
383/// it drifts from the exact figure as the temperature moves away from standard FOR THE
384/// PRESSURE ALTITUDE: about +22 m at 30 °C and 1013.25 hPa in completely dry air. See
385/// [`density_matched_altitude_m`] for the exact, humidity-inclusive figure, and why the bridge
386/// reports both.
387pub fn faa_rule_density_altitude_ft(station_pressure_hpa: f64, temperature_f: f64) -> f64 {
388    let pressure_alt = nws_pressure_altitude_ft(station_pressure_hpa);
389
390    // ISA temperature at pressure altitude (lapse rate: 3.57°F per 1000 ft)
391    let isa_temp_f = DA_NWS_SEA_LEVEL_TEMP_F - (pressure_alt / 1000.0) * DA_NWS_LAPSE_F_PER_1000FT;
392
393    pressure_alt + DA_FAA_RULE_FT_PER_DEG_F * (temperature_f - isa_temp_f)
394}
395
396/// ISA troposphere constants for [`density_matched_altitude_m`].
397const ISA_SEA_LEVEL_TEMP_K: f64 = 288.15;
398const ISA_LAPSE_K_PER_M: f64 = 0.0065;
399
400/// The reference air that [`density_matched_altitude_m`] calls sea level: ICAO standard sea
401/// level, perfectly dry, evaluated with the SAME CIPM-2007 model as the air being described.
402const DENSITY_MATCH_REFERENCE_TEMP_C: f64 = 15.0;
403const DENSITY_MATCH_REFERENCE_PRESSURE_HPA: f64 = 1013.25;
404
405/// The altitude in the ICAO Standard Atmosphere whose standard density equals the ACTUAL
406/// density of this air, humidity included — density altitude by its textbook definition.
407///
408/// ```text
409/// ratio = rho_CIPM(T, P, RH) / rho_CIPM(15 C, 1013.25 hPa, 0%)
410/// h_m   = (T0 / L) * (1 - ratio^(1/n))      T0 = 288.15 K, L = 0.0065 K/m,
411///                                            n  = g0 / (R L) - 1  (= 4.25588)
412/// ```
413///
414/// # Why this exists alongside [`faa_rule_density_altitude_ft`]
415///
416/// Two different quantities are both called "density altitude" in the field, and reporting
417/// them under one name is how an external user came to ask why the engine read 0 m where
418/// another tool read 36 m at ICAO standard with 50% humidity (2026-09). The FAA-rule figure
419/// is humidity-free for compatibility (see its docs). This one answers the question a shooter
420/// usually means — "which ISA altitude has the air density I actually have" — and so it must
421/// include humidity: water vapor (18 g/mol) is lighter than dry air (~29 g/mol), so humid air
422/// is thinner.
423///
424/// At 15 °C, 1013.25 hPa and 50% RH this reads 33.1 m where the FAA-rule figure reads 0. But
425/// the difference is NOT purely humidity, and callers must not present it that way: the FAA
426/// rule also drifts from this exact figure whenever the temperature is not standard for the
427/// pressure altitude, so the two disagree by about 22 m at 30 °C in perfectly dry air.
428///
429/// # Anchoring
430///
431/// Density comes from [`calculate_atmosphere`] — the one CIPM-2007 path every solver uses —
432/// and the ratio is taken against that SAME model's density for ICAO standard dry sea level,
433/// not against the textbook 1.225 kg/m³. The model is accurate, but it is not built to
434/// reproduce 1.225 exactly (its own anchor test allows ±0.002), and at sea level 0.002 kg/m³
435/// is roughly 17 m of altitude. Anchoring to the model makes ICAO standard dry air read
436/// exactly 0 and makes the humidity effect the model's own.
437///
438/// # Scope
439///
440/// The closed-form troposphere, with no geopotential correction — the same plain-height
441/// convention as the FAA-rule figure and the DA entry mode. That covers every shooting
442/// condition on Earth, but the arithmetic does not know where it stops: above 11 km it keeps
443/// answering, wrongly. Callers bound it; the bridge command refuses conditions outside
444/// -5 km..11 km (`crate::atmosphere_service`).
445pub fn density_matched_altitude_m(
446    temperature_c: f64,
447    station_pressure_hpa: f64,
448    humidity_percent: f64,
449) -> f64 {
450    let (density, _) = calculate_atmosphere(
451        0.0,
452        Some(temperature_c),
453        Some(station_pressure_hpa),
454        humidity_percent,
455    );
456    let (reference, _) = calculate_atmosphere(
457        0.0,
458        Some(DENSITY_MATCH_REFERENCE_TEMP_C),
459        Some(DENSITY_MATCH_REFERENCE_PRESSURE_HPA),
460        0.0,
461    );
462    let exponent = G_ACCEL_MPS2 / (R_AIR * ISA_LAPSE_K_PER_M) - 1.0;
463    (ISA_SEA_LEVEL_TEMP_K / ISA_LAPSE_K_PER_M) * (1.0 - (density / reference).powf(1.0 / exponent))
464}
465
466/// Back-solve an ISA-equivalent station altitude/temperature/pressure from a directly-declared
467/// density altitude (MBA-1366) — the single-value atmosphere entry mode Shooter, Nosler, AB
468/// Analytics, Ballistic AE, and TRASOL all support as an alternative to altitude + pressure +
469/// temperature.
470///
471/// This is the exact algebraic inverse of the FAA-rule density-altitude model this engine
472/// already uses to REPORT density altitude ([`faa_rule_density_altitude_ft`], which
473/// `pdf_dope_card::calculate_density_altitude` delegates to):
474///
475/// ```text
476/// pressure_alt_ft = 145366.45 * (1 - (station_hpa / 1013.25)^0.190284)
477/// isa_temp_f      = 59.0 - pressure_alt_ft / 1000.0 * 3.57
478/// density_alt_ft  = pressure_alt_ft + (120*5/9) * (station_temp_f - isa_temp_f)
479/// ```
480///
481/// Solved for `station_hpa` given a target `density_alt_ft` and a station temperature `K = 120
482/// * 5/9` is the "120 ft per °C" density-altitude correction rule, expressed in ft per °F):
483///
484/// ```text
485/// pressure_alt_ft = (density_alt_ft - K*(station_temp_f - 59.0)) / (1 + K*3.57/1000.0)
486/// station_hpa     = 1013.25 * (1 - pressure_alt_ft/145366.45)^(1/0.190284)
487/// ```
488///
489/// # Temperature precedence (MBA-1366)
490/// * `explicit_temperature_c == None`: ISA-at-density-altitude is the default. Algebraically
491///   this collapses `pressure_alt_ft` to EXACTLY `density_alt_ft`: the correction term above is
492///   zero by construction whenever the station temperature equals the ISA temperature at that
493///   pressure altitude, which is self-consistently true when temperature is left at ISA — i.e.
494///   omitting an explicit temperature is equivalent to typing `--altitude <density_altitude>`
495///   with no `--temperature`/`--pressure` override.
496/// * `explicit_temperature_c == Some(t)`: `t` wins outright (returned unchanged as
497///   `temperature_c`, never re-derived) and the station pressure is re-solved so the resulting
498///   density altitude still reproduces the input exactly. The FAA-rule density altitude is
499///   honored either way, but the DENSITY is not: the rule is a straight-line approximation, not
500///   a curve of equal density, so the ISA-default case rebuilds different air — at 30 °C and
501///   1013.25 hPa, a rebuilt 11.4 °C / 949 hPa, 0.3% denser, with a speed of sound 3% lower. Only
502///   the real temperature reproduces the real air.
503///
504/// # Height convention
505/// The FAA-rule formula being inverted ([`faa_rule_density_altitude_ft`]) performs no
506/// geopotential correction — it treats its altitude as a plain height (the DOPE card, which
507/// delegates to it, leaves its own `_altitude_ft` parameter unused for that reason). Consistent with that, the `altitude_m` this returns is GEOMETRIC altitude
508/// — the same convention every other engine altitude input uses (`--altitude`,
509/// [`AtmosphericConditions::altitude`](crate::cli_api::AtmosphericConditions::altitude)) —
510/// rather than being run through `geometric_to_geopotential_height_m` a second time here; that
511/// conversion is `calculate_icao_standard_atmosphere`'s job (both private to this crate — see
512/// their doc comments elsewhere in this file), applied once this altitude re-enters the normal
513/// altitude-lapse pipeline (this is what "back-solve an ISA-equivalent atmosphere" means —
514/// unlike [`get_direct_atmosphere`], which would freeze speed of sound and bypass that pipeline
515/// entirely).
516///
517/// # Interaction with [`PressureReferenceMode`] (QNH)
518/// Density altitude supersedes pressure outright — callers must not also run a declared
519/// `--pressure`/QNH value through [`resolve_station_pressure_with_mode`] when a density
520/// altitude is present; see the CLI/WASM call sites, which emit a note to that effect.
521///
522/// # Returns
523/// `(altitude_m, temperature_c, pressure_hpa)`, meant to be written directly into
524/// [`crate::cli_api::AtmosphericConditions`] (and the paired `BallisticInputs` environment
525/// fields, so powder-temperature sensitivity and the moist speed of sound both see the real
526/// resolved temperature) via the Authoritative
527/// (`TrajectorySolver::new_with_resolved_station_atmosphere`) constructor.
528pub fn resolve_atmosphere_for_density_altitude(
529    density_altitude_m: f64,
530    explicit_temperature_c: Option<f64>,
531) -> (f64, f64, f64) {
532    let density_altitude_ft = density_altitude_m / DA_FT_TO_M;
533    const K: f64 = DA_FAA_RULE_FT_PER_DEG_F;
534
535    let (pressure_alt_ft, temperature_c) = match explicit_temperature_c {
536        Some(temp_c) => {
537            let temp_f = temp_c * 9.0 / 5.0 + 32.0;
538            let denom = 1.0 + K * DA_NWS_LAPSE_F_PER_1000FT / 1000.0;
539            let pressure_alt_ft =
540                (density_altitude_ft - K * (temp_f - DA_NWS_SEA_LEVEL_TEMP_F)) / denom;
541            (pressure_alt_ft, temp_c)
542        }
543        None => {
544            // ISA-at-DA default: the correction term vanishes by construction (see doc
545            // comment above), so the pressure altitude IS the density altitude exactly.
546            let pressure_alt_ft = density_altitude_ft;
547            let isa_temp_f =
548                DA_NWS_SEA_LEVEL_TEMP_F - pressure_alt_ft / 1000.0 * DA_NWS_LAPSE_F_PER_1000FT;
549            let isa_temp_c = (isa_temp_f - 32.0) * 5.0 / 9.0;
550            (pressure_alt_ft, isa_temp_c)
551        }
552    };
553
554    let pressure_hpa = DA_NWS_SEA_LEVEL_HPA
555        * (1.0 - pressure_alt_ft / DA_NWS_PRESSURE_ALT_FT).powf(1.0 / DA_NWS_EXPONENT);
556    let altitude_m = pressure_alt_ft * DA_FT_TO_M;
557
558    (altitude_m, temperature_c, pressure_hpa)
559}
560
561/// Enhanced atmospheric calculation with ICAO Standard Atmosphere.
562///
563/// # Arguments
564/// * `altitude_m` - Altitude in meters
565/// * `temp_override_c` - Temperature override in Celsius (None for standard)
566/// * `press_override_hpa` - Pressure override in hPa (None for standard)
567/// * `humidity_percent` - Humidity percentage (0-100)
568///
569/// # Returns
570/// Tuple of (air_density_kg_m3, speed_of_sound_mps)
571pub fn calculate_atmosphere(
572    altitude_m: f64,
573    temp_override_c: Option<f64>,
574    press_override_hpa: Option<f64>,
575    humidity_percent: f64,
576) -> (f64, f64) {
577    // Get standard atmosphere conditions or use overrides
578    let combined_overrides = temp_override_c.zip(press_override_hpa);
579    let (temp_k, pressure_pa) = if let Some((temp_c, pressure_hpa)) = combined_overrides {
580        // Both overrides provided
581        (temp_c + 273.15, pressure_hpa * 100.0)
582    } else {
583        // Get ICAO standard conditions
584        let (std_temp_k, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
585
586        let final_temp_k = if let Some(temp_c) = temp_override_c {
587            temp_c + 273.15
588        } else {
589            std_temp_k
590        };
591
592        let final_pressure_pa = if let Some(press_hpa) = press_override_hpa {
593            press_hpa * 100.0
594        } else {
595            std_pressure_pa
596        };
597
598        (final_temp_k, final_pressure_pa)
599    };
600
601    // Humidity clamp shared by the CIPM density and the moist speed of sound.
602    let humidity_clamped = humidity_percent.clamp(0.0, 100.0);
603    let temp_c = temp_k - 273.15;
604
605    // Density: CIPM-2007 is the single canonical humid-air density model. Every solver
606    // (cli_api / monte_carlo / ffi / fast_trajectory) reaches this one formula through
607    // calculate_atmosphere, so there is no second (Arden-Buck ideal-gas) density path to drift.
608    let density = calculate_air_density_cimp(temp_c, pressure_pa / 100.0, humidity_clamped);
609
610    // Speed of sound uses an ideal-gas moist-air mixture approximation, first order in the
611    // water-vapor mole fraction. Extracted into `moist_speed_of_sound` so the integrators can
612    // share it; its vapor pressure comes from the SAME IAPWS saturation formula
613    // (`enhanced_saturation_vapor_pressure`) + CIPM enhancement factor that the density above
614    // uses. Only the vapor fraction is shared; the acoustic relation remains ideal-gas.
615    let speed_of_sound = moist_speed_of_sound(temp_k, pressure_pa, humidity_clamped);
616
617    (density, speed_of_sound)
618}
619
620/// Speed of sound using an ideal-gas moist-air mixture approximation, first order in the
621/// water-vapor mole fraction.
622///
623/// The water-vapor mole fraction is derived from the SAME IAPWS saturation vapor pressure
624/// (`enhanced_saturation_vapor_pressure`) and CIPM-2007 enhancement factor used by
625/// [`calculate_air_density_cimp`], so a single vapor formula feeds both density and c. Only the
626/// vapor fraction is shared with the CIPM path; the acoustic relation itself remains ideal-gas.
627/// The mixture applies first-order humidity corrections to the dry-air heat-capacity ratio and
628/// gas constant, then evaluates `sqrt(gamma * R * T)`. It is not Cramer's full real-gas
629/// polynomial and does not include Cramer's pressure or carbon-dioxide terms.
630///
631/// # Arguments
632/// * `temp_k` - Temperature in Kelvin
633/// * `pressure_pa` - Total (station) pressure in Pa
634/// * `humidity_percent` - Relative humidity percentage (0-100)
635///
636/// # Returns
637/// Speed of sound in m/s
638pub fn moist_speed_of_sound(temp_k: f64, pressure_pa: f64, humidity_percent: f64) -> f64 {
639    let humidity_clamped = humidity_percent.clamp(0.0, 100.0);
640    let temp_c = temp_k - 273.15;
641
642    // Water-vapor partial pressure p_v = RH * f * p_sv, matching CIPM's x_v exactly. p_sv is in
643    // hPa (enhanced_saturation_vapor_pressure returns hPa), so convert to Pa before forming the
644    // mole fraction against the Pa total pressure.
645    let p_sv_hpa = enhanced_saturation_vapor_pressure(temp_k);
646    let f = enhanced_enhancement_factor(pressure_pa, temp_c);
647    let vapor_pressure_pa = humidity_clamped / 100.0 * f * p_sv_hpa * 100.0;
648
649    // Cap the mole fraction at the physical maximum of 1 and guard pressure_pa == 0 (a 0 hPa
650    // override would otherwise give +Inf -> NaN speed of sound).
651    let mole_fraction_vapor = (vapor_pressure_pa / pressure_pa.max(f64::MIN_POSITIVE)).min(1.0);
652
653    // Heat-capacity ratio and gas constant for moist air (mole-fraction coefficients). 0.378 is
654    // the dry-air molecular-weight ratio (0.6078 would belong to specific humidity, not mole
655    // fraction).
656    let gamma_moist = GAMMA * (1.0 - mole_fraction_vapor * 0.062);
657    let r_moist = R_AIR * (1.0 + 0.378 * mole_fraction_vapor);
658
659    (gamma_moist * r_moist * temp_k).sqrt()
660}
661
662/// Enhanced air density calculation using CIPM formula with ICAO atmosphere.
663///
664/// # Arguments
665/// * `temp_c` - Temperature in Celsius
666/// * `pressure_hpa` - Pressure in hPa
667/// * `humidity_percent` - Humidity percentage (0-100)
668///
669/// # Returns
670/// Air density in kg/m³
671pub fn calculate_air_density_cimp(temp_c: f64, pressure_hpa: f64, humidity_percent: f64) -> f64 {
672    let t_k = temp_c + 273.15;
673
674    // Enhanced saturation vapor pressure calculation
675    let p_sv = enhanced_saturation_vapor_pressure(t_k);
676
677    let pressure_pa = pressure_hpa * 100.0;
678
679    // Enhanced enhancement factor with temperature dependence. CIPM constants use Pa.
680    let f = enhanced_enhancement_factor(pressure_pa, temp_c);
681
682    // Floor the pressure divisor (mirrors calculate_atmosphere): a 0 hPa pressure would
683    // otherwise make x_v = +Inf -> NaN density. No-op for all valid (>0) pressures.
684    let p_pa = pressure_pa.max(f64::MIN_POSITIVE);
685
686    // Mole fraction of water vapor, capped at the physical maximum of 1. The cap is silent:
687    // air whose vapor pressure would exceed its total pressure does not exist, and callers
688    // that must refuse it rather than compute pure steam test
689    // `water_vapor_mole_fraction_uncapped` first (the bridge's `atmosphere.density_altitude`).
690    let x_v = vapor_mole_fraction(p_sv, f, humidity_percent, p_pa).min(1.0);
691
692    // Enhanced compressibility factor. CIPM virial constants use Pa.
693    let z = enhanced_compressibility_factor(p_pa, t_k, x_v);
694
695    // Calculate density with enhanced precision
696    // Note: parentheses are important here for correct operator precedence
697    ((p_pa * M_A) / (z * R * t_k)) * (1.0 - x_v * (1.0 - M_V / M_A))
698}
699
700/// CIPM-2007 mole fraction of water vapor, `x_v = RH * f * p_sv / p`, UNCAPPED: the one
701/// formula [`calculate_air_density_cimp`] uses (it caps the result at 1). A value at or above 1
702/// means the stated humidity would need more vapor pressure than the air has total pressure.
703pub(crate) fn water_vapor_mole_fraction_uncapped(
704    temp_c: f64,
705    pressure_hpa: f64,
706    humidity_percent: f64,
707) -> f64 {
708    let t_k = temp_c + 273.15;
709    let p_sv = enhanced_saturation_vapor_pressure(t_k);
710    let pressure_pa = pressure_hpa * 100.0;
711    let f = enhanced_enhancement_factor(pressure_pa, temp_c);
712    vapor_mole_fraction(
713        p_sv,
714        f,
715        humidity_percent,
716        pressure_pa.max(f64::MIN_POSITIVE),
717    )
718}
719
720#[inline(always)]
721fn vapor_mole_fraction(p_sv_hpa: f64, f: f64, humidity_percent: f64, p_pa: f64) -> f64 {
722    // Vapor pressure with clamping. p_sv is in hPa (enhanced_saturation_vapor_pressure
723    // returns hPa — its critical-pressure constant is 220640 hPa), so p_v is in hPa too.
724    let p_v = humidity_percent.clamp(0.0, 100.0) / 100.0 * f * p_sv_hpa;
725
726    // Convert the vapor pressure to Pa BEFORE forming the mole fraction: the divisor below
727    // is in Pa. Dividing the hPa p_v by the Pa total made x_v 100x too small, which erased
728    // the humidity term and returned essentially dry-air density (e.g. 15 C / 1013.25 hPa /
729    // 50% RH gave ~1.2254 instead of the CIPM-2007 moist value ~1.2216 — moist air is
730    // LIGHTER than dry air).
731    let p_v_pa = p_v * 100.0;
732    p_v_pa / p_pa
733}
734
735/// Enhanced saturation vapor pressure calculation.
736/// Uses the IAPWS-IF97 formulation for high precision.
737#[inline(always)]
738fn enhanced_saturation_vapor_pressure(t_k: f64) -> f64 {
739    // IAPWS-IF97 coefficients for better accuracy
740    const A: [f64; 6] = [
741        -7.85951783,
742        1.84408259,
743        -11.7866497,
744        22.6807411,
745        -15.9618719,
746        1.80122502,
747    ];
748
749    // Ensure temperature is positive and reasonable
750    let t_k_safe = t_k.max(173.15); // -100°C minimum
751
752    let tau = 1.0 - t_k_safe / 647.096; // Critical temperature of water
753    let ln_p_ratio = (647.096 / t_k_safe)
754        * (A[0] * tau
755            + A[1] * tau.powf(1.5)
756            + A[2] * tau.powf(3.0)
757            + A[3] * tau.powf(3.5)
758            + A[4] * tau.powf(4.0)
759            + A[5] * tau.powf(7.5));
760
761    220640.0 * ln_p_ratio.exp() // Critical pressure in hPa (22.064 MPa)
762}
763
764/// CIPM-2007 enhancement factor `f = alpha + beta*p + gamma*t^2` (p in Pa, t in Celsius).
765#[inline(always)]
766fn enhanced_enhancement_factor(p: f64, t: f64) -> f64 {
767    const ALPHA: f64 = 1.00062;
768    const BETA: f64 = 3.14e-8;
769    const GAMMA: f64 = 5.6e-7;
770
771    ALPHA + BETA * p + GAMMA * t * t
772}
773
774/// CIPM-2007 compressibility factor `Z` (virial expansion, second order in `p/T`).
775#[inline(always)]
776fn enhanced_compressibility_factor(p: f64, t_k: f64, x_v: f64) -> f64 {
777    // CIPM-2007 molar virial coefficients (p in Pa, t in Celsius).
778    const A0: f64 = 1.58123e-6;
779    const A1: f64 = -2.9331e-8;
780    const A2: f64 = 1.1043e-10;
781    const B0: f64 = 5.707e-6;
782    const B1: f64 = -2.051e-8;
783    const C0: f64 = 1.9898e-4;
784    const C1: f64 = -2.376e-6;
785    const D: f64 = 1.83e-11;
786    const E: f64 = -0.765e-8;
787
788    // Ensure temperature is positive
789    let t_k_safe = t_k.max(173.15); // -100°C minimum
790    let t = t_k_safe - 273.15;
791    let p_t = p / t_k_safe;
792
793    let z_second_order =
794        1.0 - p_t * (A0 + A1 * t + A2 * t * t + (B0 + B1 * t) * x_v + (C0 + C1 * t) * x_v * x_v);
795
796    let z_third_order = p_t * p_t * (D + E * x_v * x_v);
797
798    z_second_order + z_third_order
799}
800
801/// Convert an `(x, y)` position in the shot-aligned frame to true world altitude.
802///
803/// The engine rotates gravity by `shooting_angle_rad`, so shot-frame X follows the inclined
804/// line of fire and Y is perpendicular to it in the vertical plane. Atmosphere lookup needs the
805/// world-vertical projection of that position, added to the station altitude.
806#[inline]
807pub(crate) fn shot_frame_altitude(
808    base_altitude_m: f64,
809    downrange_m: f64,
810    shot_y_m: f64,
811    shooting_angle_rad: f64,
812) -> f64 {
813    base_altitude_m + downrange_m * shooting_angle_rad.sin() + shot_y_m * shooting_angle_rad.cos()
814}
815
816/// Enhanced local atmospheric calculation with variable lapse rates.
817///
818/// # Arguments
819/// * `altitude_m` - Query geometric altitude above mean sea level in meters
820/// * `base_alt` - Base geometric altitude above mean sea level in meters
821/// * `base_temp_c` - Base temperature in Celsius
822/// * `base_press_hpa` - Base pressure in hPa
823/// * `base_ratio` - Base density ratio
824///
825/// # Returns
826/// Tuple of (air_density_kg_m3, speed_of_sound_mps)
827pub fn get_local_atmosphere(
828    altitude_m: f64,
829    base_alt: f64,
830    base_temp_c: f64,
831    base_press_hpa: f64,
832    base_ratio: f64,
833) -> (f64, f64) {
834    let (temp_k, _pressure_pa, density) = local_temp_pressure_density(
835        altitude_m,
836        base_alt,
837        base_temp_c,
838        base_press_hpa,
839        base_ratio,
840    );
841
842    // Dry speed of sound. 401.874 ~ gamma * R_air; kept exactly for back-compat with existing
843    // callers (get_local_atmosphere_humid uses the precise moist formula instead).
844    let speed_of_sound = (temp_k * 401.874).sqrt();
845
846    (density, speed_of_sound)
847}
848
849/// Humidity-aware companion to [`get_local_atmosphere`]: identical local density, but the speed
850/// of sound is the moist-air value ([`moist_speed_of_sound`]) evaluated at the LOCAL temperature
851/// and pressure.
852///
853/// [`get_local_atmosphere`] is intentionally left unchanged (dry speed of sound) for
854/// API/back-compat; call this variant only where a real relative humidity is available.
855///
856/// # Arguments
857/// * `altitude_m` - Query geometric altitude above mean sea level in meters
858/// * `base_alt` - Base (station) geometric altitude above mean sea level in meters
859/// * `base_temp_c` - Base temperature in Celsius
860/// * `base_press_hpa` - Base pressure in hPa
861/// * `base_ratio` - Base density ratio (density / 1.225)
862/// * `humidity_percent` - Relative humidity percentage (0-100)
863///
864/// # Returns
865/// Tuple of (air_density_kg_m3, moist_speed_of_sound_mps)
866pub fn get_local_atmosphere_humid(
867    altitude_m: f64,
868    base_alt: f64,
869    base_temp_c: f64,
870    base_press_hpa: f64,
871    base_ratio: f64,
872    humidity_percent: f64,
873) -> (f64, f64) {
874    let (temp_k, pressure_pa, density) = local_temp_pressure_density(
875        altitude_m,
876        base_alt,
877        base_temp_c,
878        base_press_hpa,
879        base_ratio,
880    );
881    (
882        density,
883        moist_speed_of_sound(temp_k, pressure_pa, humidity_percent),
884    )
885}
886
887/// Shared local temperature / pressure / density computation for [`get_local_atmosphere`] and
888/// [`get_local_atmosphere_humid`]. Returns `(local_temp_k, local_pressure_pa, density_kg_m3)`.
889#[inline]
890fn local_temp_pressure_density(
891    altitude_m: f64,
892    base_alt: f64,
893    base_temp_c: f64,
894    base_press_hpa: f64,
895    base_ratio: f64,
896) -> (f64, f64, f64) {
897    let base_temp_k = base_temp_c + 273.15;
898
899    // A non-finite endpoint would make the boundary walk fail to advance. The
900    // previous single-column formula also produced non-finite outputs here.
901    if !altitude_m.is_finite() || !base_alt.is_finite() {
902        return (f64::NAN, f64::NAN, f64::NAN);
903    }
904
905    let base_geopotential_m = geometric_to_geopotential_height_m(base_alt);
906    let target_geopotential_m = geometric_to_geopotential_height_m(altitude_m);
907    let (temp_k, pressure_hpa) = integrate_local_atmosphere_layers(
908        base_geopotential_m,
909        target_geopotential_m,
910        base_temp_k,
911        base_press_hpa,
912    );
913
914    // Enhanced density calculation
915    let density_ratio = base_ratio * (base_temp_k * pressure_hpa) / (base_press_hpa * temp_k);
916    let density = density_ratio * 1.225;
917
918    (temp_k, pressure_hpa * 100.0, density)
919}
920
921/// Carry arbitrary station temperature and pressure through each crossed ICAO
922/// layer in geopotential-height coordinates. The layer table supplies lapse rates and boundaries
923/// only; station deviations from the standard atmosphere remain anchored at the converted base.
924fn integrate_local_atmosphere_layers(
925    base_geopotential_m: f64,
926    target_geopotential_m: f64,
927    mut temp_k: f64,
928    mut pressure_hpa: f64,
929) -> (f64, f64) {
930    let mut current_alt = base_geopotential_m;
931
932    if target_geopotential_m > current_alt {
933        while current_alt < target_geopotential_m {
934            // At an exact boundary, ascent starts in the higher layer.
935            let layer_index = ICAO_LAYERS
936                .iter()
937                .rposition(|layer| current_alt >= layer.base_altitude)
938                .unwrap_or(0);
939            let segment_end = ICAO_LAYERS
940                .get(layer_index + 1)
941                .map_or(target_geopotential_m, |next| {
942                    target_geopotential_m.min(next.base_altitude)
943                });
944            (temp_k, pressure_hpa) = integrate_local_atmosphere_segment(
945                temp_k,
946                pressure_hpa,
947                segment_end - current_alt,
948                ICAO_LAYERS[layer_index].lapse_rate,
949            );
950            current_alt = segment_end;
951        }
952    } else {
953        while current_alt > target_geopotential_m {
954            // At an exact boundary, descent starts in the lower layer. The
955            // strict comparison is what makes a cross-layer round trip reversible.
956            let layer_index = ICAO_LAYERS
957                .iter()
958                .rposition(|layer| current_alt > layer.base_altitude)
959                .unwrap_or(0);
960            let segment_end = if layer_index == 0 {
961                target_geopotential_m
962            } else {
963                target_geopotential_m.max(ICAO_LAYERS[layer_index].base_altitude)
964            };
965            (temp_k, pressure_hpa) = integrate_local_atmosphere_segment(
966                temp_k,
967                pressure_hpa,
968                segment_end - current_alt,
969                ICAO_LAYERS[layer_index].lapse_rate,
970            );
971            current_alt = segment_end;
972        }
973    }
974
975    (temp_k, pressure_hpa)
976}
977
978#[inline]
979fn integrate_local_atmosphere_segment(
980    base_temp_k: f64,
981    base_pressure_hpa: f64,
982    height_diff: f64,
983    lapse_rate: f64,
984) -> (f64, f64) {
985    let temp_k = base_temp_k + lapse_rate * height_diff;
986    let pressure_hpa = if lapse_rate.abs() < 1e-10 {
987        base_pressure_hpa * (-G_ACCEL_MPS2 * height_diff / (R_AIR * base_temp_k)).exp()
988    } else {
989        let temp_ratio = temp_k / base_temp_k;
990        base_pressure_hpa * temp_ratio.powf(-G_ACCEL_MPS2 / (lapse_rate * R_AIR))
991    };
992
993    (temp_k, pressure_hpa)
994}
995
996/// Determine local lapse rate based on altitude and atmospheric layer.
997#[cfg(test)]
998#[inline(always)]
999fn determine_local_lapse_rate(altitude_m: f64) -> f64 {
1000    // Find the current atmospheric layer to get appropriate lapse rate
1001    let layer = ICAO_LAYERS
1002        .iter()
1003        .rev()
1004        .find(|layer| altitude_m >= layer.base_altitude)
1005        .unwrap_or(&ICAO_LAYERS[0]);
1006
1007    layer.lapse_rate
1008}
1009
1010/// Direct atmosphere calculation for simple cases.
1011///
1012/// # Arguments
1013/// * `density` - Pre-computed air density
1014/// * `speed_of_sound` - Pre-computed speed of sound
1015///
1016/// # Returns
1017/// Tuple of (air_density, speed_of_sound) - just passes through the values
1018#[inline(always)]
1019pub fn get_direct_atmosphere(density: f64, speed_of_sound: f64) -> (f64, f64) {
1020    (density, speed_of_sound)
1021}
1022
1023/// Legacy function name for backwards compatibility
1024pub fn calculate_air_density_cipm(temp_c: f64, pressure_hpa: f64, humidity_percent: f64) -> f64 {
1025    calculate_air_density_cimp(temp_c, pressure_hpa, humidity_percent)
1026}
1027
1028/// A single downrange-referenced atmosphere zone:
1029/// `(temp_c, pressure_hpa, humidity_percent, until_distance_m)`.
1030///
1031/// The T/P/H are the STATION-REFERENCED conditions (defined at the shooter base altitude) that
1032/// apply from the previous segment's threshold out to `until_distance_m`. This mirrors
1033/// [`crate::wind::WindSegment`]'s `(speed, angle, until_distance)` shape so the two segmented
1034/// models compose the same way (wind by X, atmosphere by X, altitude lapse by Y).
1035pub type AtmoSegment = (f64, f64, f64, f64);
1036
1037/// Downrange-segmented atmosphere handler (MBA-1137), the density analogue of
1038/// [`crate::wind::WindSock`].
1039///
1040/// Holds a set of station-referenced atmosphere zones ordered by their `until_distance_m`
1041/// threshold and answers a stateless downrange lookup ([`AtmoSock::atmo_for_range`]).
1042///
1043/// The zone T/P/H are the base (shooter-altitude) conditions for that stretch of range; the
1044/// solver swaps them into the same local-atmosphere altitude-lapse pipeline that a single-station
1045/// solve uses, so the downrange (X) zone and the vertical (Y) altitude lapse compose orthogonally
1046/// without double-counting (the zone sets the base tuple, the lapse multiplies on top of it).
1047#[derive(Debug, Clone)]
1048pub struct AtmoSock {
1049    /// Zones sorted ascending by `until_distance_m` (segment slot 3).
1050    segments: Vec<AtmoSegment>,
1051}
1052
1053impl AtmoSock {
1054    /// Create a new `AtmoSock` from station-referenced atmosphere zones.
1055    ///
1056    /// Each segment is `(temp_c, pressure_hpa, humidity_percent, until_distance_m)`. Segments are
1057    /// sorted by `until_distance_m`, with NaN thresholds ordered last.
1058    pub fn new(mut segments: Vec<AtmoSegment>) -> Self {
1059        segments.sort_by(|a, b| match (a.3.is_nan(), b.3.is_nan()) {
1060            (true, true) => Ordering::Equal,
1061            (true, false) => Ordering::Greater,
1062            (false, true) => Ordering::Less,
1063            (false, false) => a.3.partial_cmp(&b.3).unwrap(),
1064        });
1065        AtmoSock { segments }
1066    }
1067
1068    /// True when this sock carries no zones (a lookup falls back to sea-level ISA).
1069    pub fn is_empty(&self) -> bool {
1070        self.segments.is_empty()
1071    }
1072
1073    /// Stateless downrange lookup of the active zone's `(temp_c, pressure_hpa, humidity_percent)`.
1074    ///
1075    /// Selection matches [`crate::wind::WindSock::vector_for_range_stateless`]: the first segment
1076    /// whose `until_distance_m` STRICTLY exceeds `downrange_m` wins (thresholds are upper-exclusive).
1077    /// Unlike wind — which returns zero past the last threshold — the LAST zone is used for any
1078    /// distance at or beyond the final threshold (there is no "zero atmosphere"). An empty sock
1079    /// returns the sea-level ISA reference `(15 C, 1013.25 hPa, 0% RH)`.
1080    ///
1081    /// This is stateless and safe for numerical integration (the same X may be queried repeatedly
1082    /// or out of order across RK substeps).
1083    pub fn atmo_for_range(&self, downrange_m: f64) -> (f64, f64, f64) {
1084        if self.segments.is_empty() {
1085            return (15.0, 1013.25, 0.0); // sea-level ISA fallback
1086        }
1087        // NaN X can't be ordered; use the first (nearest) zone deterministically.
1088        if downrange_m.is_nan() {
1089            let s = self.segments[0];
1090            return (s.0, s.1, s.2);
1091        }
1092        for seg in &self.segments {
1093            if downrange_m < seg.3 {
1094                return (seg.0, seg.1, seg.2);
1095            }
1096        }
1097        // Beyond the final threshold: hold the last zone (no zeroing).
1098        let last = self.segments[self.segments.len() - 1];
1099        (last.0, last.1, last.2)
1100    }
1101}
1102
1103#[cfg(test)]
1104mod tests {
1105    use super::*;
1106
1107    #[test]
1108    fn inclined_shot_frame_position_maps_to_world_altitude() {
1109        let base_altitude = 100.0;
1110        let downrange = 1_000.0;
1111        let shot_y = 10.0;
1112        let angle = std::f64::consts::FRAC_PI_6;
1113        let expected = base_altitude + downrange * angle.sin() + shot_y * angle.cos();
1114
1115        let actual = shot_frame_altitude(base_altitude, downrange, shot_y, angle);
1116        assert!(
1117            (actual - expected).abs() < 1e-12,
1118            "30-degree shot at x=1000/y=10 should be at {expected} m, got {actual} m"
1119        );
1120        assert_eq!(
1121            shot_frame_altitude(base_altitude, downrange, shot_y, 0.0),
1122            base_altitude + shot_y,
1123            "flat-fire altitude must remain byte-identical"
1124        );
1125        let downhill = shot_frame_altitude(base_altitude, downrange, shot_y, -angle);
1126        let expected_downhill = base_altitude - downrange * angle.sin() + shot_y * angle.cos();
1127        assert!((downhill - expected_downhill).abs() < 1e-12);
1128    }
1129
1130    // ---- MBA-1136: CIPM-2007 as the single canonical humid-air density ----
1131
1132    /// Gate 1: dry sea-level (15 C, 1013.25 hPa, 0% RH) must stay at the ISA reference — density
1133    /// 1.225 +- 0.002 kg/m^3 and speed of sound 340.3 +- 0.6 m/s. CIPM-2007 at 0% RH reduces to
1134    /// dry-air ideal gas to within rounding, so this is essentially unchanged from the pre-CIPM
1135    /// baseline (baseline was 1.225012 / 340.294; now 1.225521 / 340.294 — the tiny density bump
1136    /// is CIPM compressibility + exact molar mass, the speed of sound is bit-identical).
1137    #[test]
1138    fn test_mba1136_dry_sea_level_reference() {
1139        let (density, sos) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 0.0);
1140        assert!(
1141            (density - 1.225).abs() < 0.002,
1142            "dry sea-level density {density} not within 1.225 +- 0.002"
1143        );
1144        assert!(
1145            (sos - 340.3).abs() < 0.6,
1146            "dry sea-level speed of sound {sos} not within 340.3 +- 0.6"
1147        );
1148    }
1149
1150    /// Gate 2: humid air (15 C, 1013.25 hPa, 50% RH) is the CIPM-2007 value (~1.2211 +- 0.002),
1151    /// STRICTLY lighter than dry air at the same T/P, with a speed of sound slightly ABOVE dry.
1152    #[test]
1153    fn test_mba1136_humid_lighter_than_dry() {
1154        let (dry_rho, dry_sos) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 0.0);
1155        let (moist_rho, moist_sos) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 50.0);
1156
1157        assert!(
1158            (moist_rho - 1.2211).abs() < 0.002,
1159            "50% RH density {moist_rho} not within CIPM 1.2211 +- 0.002"
1160        );
1161        assert!(
1162            moist_rho < dry_rho,
1163            "moist air ({moist_rho}) must be lighter than dry ({dry_rho})"
1164        );
1165        assert!(
1166            moist_sos > dry_sos,
1167            "moist speed of sound ({moist_sos}) must exceed dry ({dry_sos})"
1168        );
1169    }
1170
1171    /// Gate 3: density is monotone-decreasing in humidity (100% RH < 50% RH < 0% RH).
1172    #[test]
1173    fn test_mba1136_density_monotone_in_humidity() {
1174        let (rho_0, _) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 0.0);
1175        let (rho_50, _) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 50.0);
1176        let (rho_100, _) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 100.0);
1177        assert!(
1178            rho_100 < rho_50 && rho_50 < rho_0,
1179            "humidity monotonicity violated: 100%={rho_100}, 50%={rho_50}, 0%={rho_0}"
1180        );
1181    }
1182
1183    /// rank 28: `calculate_atmosphere`'s density is exactly `calculate_air_density_cimp` — there
1184    /// is a single canonical humid-air density path (no separate Arden-Buck ideal-gas density).
1185    #[test]
1186    fn test_mba1136_atmosphere_density_is_cipm() {
1187        for (t, p, h) in [
1188            (15.0, 1013.25, 0.0),
1189            (15.0, 1013.25, 50.0),
1190            (30.0, 1000.0, 80.0),
1191            (-10.0, 1020.0, 20.0),
1192        ] {
1193            let (density, _) = calculate_atmosphere(0.0, Some(t), Some(p), h);
1194            let cipm = calculate_air_density_cimp(t, p, h);
1195            assert_eq!(
1196                density, cipm,
1197                "calculate_atmosphere density must equal CIPM at {t}C/{p}hPa/{h}%"
1198            );
1199        }
1200    }
1201
1202    /// rank 9: the extracted `moist_speed_of_sound` is exactly what `calculate_atmosphere`
1203    /// returns (behavior-identical extraction), across dry and humid conditions.
1204    #[test]
1205    fn test_mba1136_moist_speed_of_sound_extraction() {
1206        for (t, p, h) in [
1207            (15.0, 1013.25, 0.0),
1208            (15.0, 1013.25, 50.0),
1209            (25.0, 900.0, 100.0),
1210        ] {
1211            let (_, sos) = calculate_atmosphere(0.0, Some(t), Some(p), h);
1212            let extracted = moist_speed_of_sound(t + 273.15, p * 100.0, h);
1213            assert_eq!(
1214                sos, extracted,
1215                "extracted moist_speed_of_sound must match calculate_atmosphere at {t}C/{p}hPa/{h}%"
1216            );
1217        }
1218    }
1219
1220    /// rank 9: local-atmosphere reference values (base: 500 m, 10 C, 950 hPa, ratio 1.05).
1221    /// The 1500 m values include the geometric-to-geopotential height conversion.
1222    #[test]
1223    fn test_mba1136_get_local_atmosphere_reference() {
1224        let (d0, c0) = get_local_atmosphere(500.0, 500.0, 10.0, 950.0, 1.05);
1225        assert!(
1226            (d0 - 1.286250000000).abs() < 1e-9,
1227            "local density@500m drifted: {d0}"
1228        );
1229        assert!(
1230            (c0 - 337.328657395129).abs() < 1e-9,
1231            "local sos@500m drifted: {c0}"
1232        );
1233        let (d1, c1) = get_local_atmosphere(1500.0, 500.0, 10.0, 950.0, 1.05);
1234        assert!(
1235            (d1 - 1.165238292559).abs() < 1e-9,
1236            "local density@1500m drifted: {d1}"
1237        );
1238        assert!(
1239            (c1 - 333.435546617978).abs() < 1e-9,
1240            "local sos@1500m drifted: {c1}"
1241        );
1242    }
1243
1244    #[test]
1245    fn fractional_station_altitude_is_not_quantized() {
1246        let station_altitude_m = 500.25;
1247        let station_temp_c = 10.0;
1248        let station_pressure_hpa = 950.0;
1249        let station_density_ratio = 1.05;
1250
1251        let (density, speed_of_sound) = get_local_atmosphere(
1252            station_altitude_m,
1253            station_altitude_m,
1254            station_temp_c,
1255            station_pressure_hpa,
1256            station_density_ratio,
1257        );
1258
1259        let expected_density = station_density_ratio * 1.225;
1260        let expected_speed_of_sound = ((station_temp_c + 273.15) * 401.874).sqrt();
1261        assert!((density - expected_density).abs() < 1e-12);
1262        assert!((speed_of_sound - expected_speed_of_sound).abs() < 1e-12);
1263    }
1264
1265    /// rank 9: `get_local_atmosphere_humid` returns the SAME density as `get_local_atmosphere`,
1266    /// and at 0% RH its speed of sound reduces to the dry value (within the 401.874-vs-gamma*R
1267    /// constant rounding). At real humidity the speed of sound exceeds the dry value.
1268    #[test]
1269    fn test_mba1136_get_local_atmosphere_humid() {
1270        let (d_dry, c_dry) = get_local_atmosphere(1500.0, 500.0, 10.0, 950.0, 1.05);
1271        let (d_h0, c_h0) = get_local_atmosphere_humid(1500.0, 500.0, 10.0, 950.0, 1.05, 0.0);
1272        assert_eq!(d_dry, d_h0, "humid variant must not change density");
1273        assert!(
1274            (c_h0 - c_dry).abs() < 1e-3,
1275            "0% RH humid sos {c_h0} should match dry sos {c_dry}"
1276        );
1277        let (_, c_h80) = get_local_atmosphere_humid(1500.0, 500.0, 10.0, 950.0, 1.05, 80.0);
1278        assert!(c_h80 > c_dry, "humid sos {c_h80} should exceed dry {c_dry}");
1279    }
1280
1281    #[test]
1282    fn test_icao_standard_atmosphere() {
1283        // Test sea level
1284        let (temp, press) = calculate_icao_standard_atmosphere(0.0);
1285        assert!((temp - 288.15).abs() < 0.01);
1286        assert!((press - 101325.0).abs() < 1.0);
1287
1288        // The table's 11 km tropopause base is geopotential height; convert it back to the
1289        // geometric altitude accepted by the public atmosphere contract.
1290        let geometric_tropopause_m =
1291            GEOPOTENTIAL_EARTH_RADIUS_M * 11000.0 / (GEOPOTENTIAL_EARTH_RADIUS_M - 11000.0);
1292        let (temp_11km, press_11km) = calculate_icao_standard_atmosphere(geometric_tropopause_m);
1293        assert!((temp_11km - 216.65).abs() < 0.01);
1294        assert!((press_11km - 22632.1).abs() < 1.0);
1295
1296        // Test stratosphere
1297        let (temp_25km, _) = calculate_icao_standard_atmosphere(25000.0);
1298        assert!(temp_25km > 216.65); // Temperature increases in stratosphere
1299    }
1300
1301    #[test]
1302    fn standard_atmosphere_extends_below_sea_level() {
1303        let altitude_m = -430.0;
1304        let (temp_k, pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
1305
1306        assert!((temp_k - 290.945_189_079_054).abs() < 1e-9);
1307        assert!((pressure_pa - 106_598.763_552_437).abs() < 0.1);
1308
1309        let (station_temp_c, station_pressure_hpa) =
1310            resolve_station_conditions(15.0, 1013.25, altitude_m);
1311        assert!((station_temp_c - 17.795_189_079_054).abs() < 1e-9);
1312        assert!((station_pressure_hpa - 1_065.987_635_524).abs() < 1e-6);
1313
1314        let (sea_density, _) = calculate_atmosphere(0.0, None, None, 0.0);
1315        let (below_sea_density, _) = calculate_atmosphere(altitude_m, None, None, 0.0);
1316        assert!((below_sea_density - 1.276_908_642_79).abs() < 1e-6);
1317        assert!(below_sea_density > sea_density * 1.04);
1318
1319        assert_eq!(
1320            calculate_icao_standard_atmosphere(-6000.0),
1321            calculate_icao_standard_atmosphere(MIN_GEOMETRIC_ALTITUDE_M)
1322        );
1323    }
1324
1325    #[test]
1326    fn standard_atmosphere_converts_geometric_to_geopotential_height() {
1327        let cases: [(f64, f64, f64); 3] = [
1328            (10_000.0, 223.252_092_647_979, 26_499.901_600_244),
1329            (30_000.0, 226.509_083_611_330, 1_197.032_108_466),
1330            (84_000.0, 190.841_043_736_102, 0.531_525_514_935),
1331        ];
1332        for (geometric_m, expected_temp_k, expected_pressure_pa) in cases {
1333            let (temp_k, pressure_pa) = calculate_icao_standard_atmosphere(geometric_m);
1334            let pressure_tolerance = expected_pressure_pa.max(1.0_f64) * 1e-6;
1335
1336            assert!((temp_k - expected_temp_k).abs() < 1e-9);
1337            assert!((pressure_pa - expected_pressure_pa).abs() < pressure_tolerance);
1338        }
1339    }
1340
1341    #[test]
1342    fn local_atmosphere_walks_icao_layers_continuously() {
1343        for altitude_m in [
1344            10999.0, 11000.0, 11001.0, 11050.0, 19999.0, 20000.0, 20001.0, 25000.0,
1345        ] {
1346            let (local_temp_k, local_pressure_pa, _) =
1347                local_temp_pressure_density(altitude_m, 0.0, 15.0, 1013.25, 1.0);
1348            let (standard_temp_k, standard_pressure_pa) =
1349                calculate_icao_standard_atmosphere(altitude_m);
1350
1351            assert!(
1352                (local_temp_k - standard_temp_k).abs() < 1e-9,
1353                "local temperature diverged from ICAO at {altitude_m} m: local={local_temp_k}, standard={standard_temp_k}"
1354            );
1355            assert!(
1356                ((local_pressure_pa - standard_pressure_pa) / standard_pressure_pa).abs() < 5e-5,
1357                "local pressure diverged from ICAO at {altitude_m} m: local={local_pressure_pa}, standard={standard_pressure_pa}"
1358            );
1359        }
1360
1361        let (density_below, sound_below) = get_local_atmosphere(10999.0, 0.0, 15.0, 1013.25, 1.0);
1362        let (density_above, sound_above) = get_local_atmosphere(11001.0, 0.0, 15.0, 1013.25, 1.0);
1363        assert!(
1364            (density_below - density_above).abs() < 0.001,
1365            "density jumped across 11 km: below={density_below}, above={density_above}"
1366        );
1367        assert!(
1368            (sound_below - sound_above).abs() < 0.1,
1369            "speed of sound jumped across 11 km: below={sound_below}, above={sound_above}"
1370        );
1371    }
1372
1373    #[test]
1374    fn local_atmosphere_preserves_nonstandard_station_offset_and_round_trips() {
1375        let base_alt = 7500.0;
1376        let base_temp_c = 5.0;
1377        let base_pressure_hpa = 410.0;
1378        let base_ratio = 0.72;
1379
1380        let (high_temp_k, high_pressure_pa, high_density) = local_temp_pressure_density(
1381            25000.0,
1382            base_alt,
1383            base_temp_c,
1384            base_pressure_hpa,
1385            base_ratio,
1386        );
1387        assert!((high_temp_k - 260.244_615_053_376).abs() < 1e-9);
1388        assert!((high_pressure_pa / 100.0 - 40.964_358_485_456).abs() < 1e-9);
1389        assert!((high_density - 0.094_186_400_274).abs() < 1e-9);
1390
1391        let (back_temp_k, back_pressure_pa, back_density) = local_temp_pressure_density(
1392            base_alt,
1393            25000.0,
1394            high_temp_k - 273.15,
1395            high_pressure_pa / 100.0,
1396            high_density / 1.225,
1397        );
1398        assert!((back_temp_k - (base_temp_c + 273.15)).abs() < 1e-9);
1399        assert!((back_pressure_pa / 100.0 - base_pressure_hpa).abs() < 1e-8);
1400        assert!((back_density - base_ratio * 1.225).abs() < 1e-9);
1401    }
1402
1403    #[test]
1404    fn test_enhanced_atmosphere_sea_level() {
1405        let (density, speed) = calculate_atmosphere(0.0, None, None, 0.0);
1406        assert!((density - 1.225).abs() < 0.01);
1407        assert!((speed - 340.0).abs() < 1.0);
1408    }
1409
1410    #[test]
1411    fn test_resolve_station_pressure_contract() {
1412        // Default sea-level pressure + real altitude => derive from altitude (None).
1413        assert_eq!(resolve_station_pressure(1013.25, 2000.0), None);
1414        // 29.92 inHg ≈ 1013.21 hPa is also treated as the default (within tolerance).
1415        assert_eq!(resolve_station_pressure(1013.21, 2000.0), None);
1416        // An explicit, non-default station pressure is authoritative (Some, used directly).
1417        assert_eq!(resolve_station_pressure(850.0, 2000.0), Some(850.0));
1418        // At/near sea level the default is used directly (no derivation needed).
1419        assert_eq!(resolve_station_pressure(1013.25, 0.0), Some(1013.25));
1420    }
1421
1422    #[test]
1423    fn qnh_reduction_matches_hand_computed_value_at_nonzero_altitude() {
1424        // Hand-computed (Python, double precision) from the ICAO inverse-barometric formula.
1425        // NOTE the geopotential step — 1500 m geometric is 1499.646 m geopotential, and it is
1426        // the geopotential height that enters the exponent:
1427        //   h_geo   = 6356766*1500/(6356766+1500)                    = 1499.6461... m
1428        //   station = 1030.0 * (1 - 0.0065*h_geo/288.15)^5.255875601466713
1429        //           = 859.5753123926447 hPa
1430        // Feeding the raw 1500 m in instead yields 859.5378567 hPa, so a reader checking this
1431        // pin against the bare formula would wrongly conclude the implementation is off.
1432        let reduced = reduce_qnh_to_station_pressure(1030.0, 1500.0);
1433        assert!(
1434            (reduced - 859.575_312_392_644_7).abs() < 1e-9,
1435            "reduced={reduced}"
1436        );
1437        // The reduction must strictly lower the pressure versus the raw QNH input.
1438        assert!(reduced < 1030.0);
1439    }
1440
1441    #[test]
1442    fn qnh_reduction_is_identity_at_sea_level() {
1443        // At h=0 the geopotential height is 0, so the ratio is exactly 1.0 and QNH passes
1444        // through unchanged (bit-exact: 1.0f64.powf(x) == 1.0).
1445        assert_eq!(reduce_qnh_to_station_pressure(1030.0, 0.0), 1030.0);
1446        assert_eq!(reduce_qnh_to_station_pressure(950.5, 0.0), 950.5);
1447    }
1448
1449    #[test]
1450    fn qnh_sea_level_standard_matches_icao_standard_atmosphere_at_every_altitude() {
1451        // A QNH of exactly 1013.25 hPa (the sea-level standard) must reduce to precisely the
1452        // ICAO standard station pressure at any altitude -- this is what keeps the omitted-
1453        // pressure default byte-identical whether the caller declares Absolute or Qnh.
1454        for altitude_m in [0.0, 500.0, 2000.0, 4500.0] {
1455            let (_, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
1456            let reduced = reduce_qnh_to_station_pressure(1013.25, altitude_m);
1457            assert!(
1458                (reduced - std_pressure_pa / 100.0).abs() < 1e-9,
1459                "altitude={altitude_m} reduced={reduced} std={}",
1460                std_pressure_pa / 100.0
1461            );
1462        }
1463    }
1464
1465    #[test]
1466    fn resolve_station_pressure_with_mode_absolute_matches_resolve_station_pressure() {
1467        // PressureReferenceMode::Absolute must be byte-identical to resolve_station_pressure
1468        // for every case that function's own contract test exercises.
1469        for (pressure_hpa, altitude_m) in [
1470            (1013.25, 2000.0),
1471            (1013.21, 2000.0),
1472            (850.0, 2000.0),
1473            (1013.25, 0.0),
1474        ] {
1475            assert_eq!(
1476                resolve_station_pressure_with_mode(
1477                    pressure_hpa,
1478                    altitude_m,
1479                    PressureReferenceMode::Absolute
1480                ),
1481                resolve_station_pressure(pressure_hpa, altitude_m)
1482            );
1483        }
1484    }
1485
1486    #[test]
1487    fn resolve_station_pressure_with_mode_qnh_bypasses_the_default_sentinel() {
1488        // Constructed so the REDUCED value coincidentally lands within the absolute-mode
1489        // sentinel's +/-0.5 hPa tolerance of 1013.25 hPa at a real (>1 m) altitude -- exactly
1490        // the case that would silently discard an explicit reading under the old,
1491        // mode-blind resolve_station_pressure.
1492        let qnh = 1030.0;
1493        let altitude_m = 138.078_300_203_223_02;
1494        let reduced = reduce_qnh_to_station_pressure(qnh, altitude_m);
1495        assert!(
1496            (reduced - 1013.25).abs() < 0.5,
1497            "test fixture must land inside the sentinel band; reduced={reduced}"
1498        );
1499
1500        // Qnh mode must still return Some(reduced) -- never None -- regardless of the
1501        // coincidence, and must equal the reduction, not a derived-from-altitude value.
1502        assert_eq!(
1503            resolve_station_pressure_with_mode(qnh, altitude_m, PressureReferenceMode::Qnh),
1504            Some(reduced)
1505        );
1506
1507        // Sanity: the OLD absolute-mode function, given that same reduced number directly,
1508        // WOULD have discarded it (returned None, deriving ICAO-standard pressure instead) --
1509        // demonstrating why Qnh mode must never be routed back through the plain sentinel
1510        // check on its output.
1511        assert_eq!(resolve_station_pressure(reduced, altitude_m), None);
1512        let (_, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
1513        assert!(
1514            (std_pressure_pa / 100.0 - reduced).abs() > 10.0,
1515            "fixture must show the sentinel misfire actually changes the answer materially"
1516        );
1517    }
1518
1519    #[test]
1520    fn resolve_station_conditions_with_pressure_mode_absolute_matches_resolve_station_conditions() {
1521        for (temp_c, pressure_hpa, altitude_m) in [
1522            (15.0, 1013.25, 2000.0),
1523            (-5.0, 850.0, 2000.0),
1524            (15.0, 1013.25, 0.0),
1525        ] {
1526            assert_eq!(
1527                resolve_station_conditions_with_pressure_mode(
1528                    temp_c,
1529                    pressure_hpa,
1530                    altitude_m,
1531                    PressureReferenceMode::Absolute
1532                ),
1533                resolve_station_conditions(temp_c, pressure_hpa, altitude_m)
1534            );
1535        }
1536    }
1537
1538    #[test]
1539    fn resolve_station_conditions_with_pressure_mode_qnh_reduces_pressure_only() {
1540        // -5.0 C is not the sea-level default sentinel (15.0 C), so it stays authoritative --
1541        // isolating that only the pressure resolution differs between the two modes.
1542        let (temp_c, pressure_hpa) = resolve_station_conditions_with_pressure_mode(
1543            -5.0,
1544            1030.0,
1545            1500.0,
1546            PressureReferenceMode::Qnh,
1547        );
1548        // Temperature resolution is untouched by pressure mode: an explicit, non-default
1549        // temperature stays authoritative exactly as resolve_station_conditions would give.
1550        assert_eq!(temp_c, -5.0);
1551        assert!((pressure_hpa - 859.575_312_392_644_7).abs() < 1e-9);
1552    }
1553
1554    #[test]
1555    fn test_altitude_affects_density_with_default_pressure() {
1556        // Regression: with the default pressure, altitude MUST lower density (previously the
1557        // air-density path ignored altitude whenever pressure was the sea-level default).
1558        let press = resolve_station_pressure(1013.25, 0.0);
1559        let (rho_sea, _) = calculate_atmosphere(0.0, Some(15.0), press, 50.0);
1560        let press_alt = resolve_station_pressure(1013.25, 2000.0);
1561        let (rho_2km, _) = calculate_atmosphere(2000.0, Some(15.0), press_alt, 50.0);
1562        assert!(
1563            rho_2km < rho_sea * 0.9,
1564            "density at 2000 m ({rho_2km}) should be well below sea level ({rho_sea})"
1565        );
1566
1567        // But an explicit station pressure stays authoritative (no altitude double-count):
1568        // density with an explicit pressure is independent of the altitude field.
1569        let p = resolve_station_pressure(900.0, 2000.0);
1570        let (rho_a, _) = calculate_atmosphere(2000.0, Some(15.0), p, 50.0);
1571        let (rho_b, _) = calculate_atmosphere(0.0, Some(15.0), p, 50.0);
1572        assert!(
1573            (rho_a - rho_b).abs() < 1e-9,
1574            "explicit pressure must ignore altitude"
1575        );
1576    }
1577
1578    #[test]
1579    fn test_resolve_station_temperature_contract() {
1580        // Default 15 C + real altitude => derive ICAO lapse temperature (None).
1581        assert_eq!(resolve_station_temperature(15.0, 2000.0), None);
1582        // An explicit, non-default temperature is authoritative (Some, used directly).
1583        assert_eq!(resolve_station_temperature(-5.0, 2000.0), Some(-5.0));
1584        assert_eq!(resolve_station_temperature(30.0, 2000.0), Some(30.0));
1585        // At/near sea level the default is used directly (no derivation needed).
1586        assert_eq!(resolve_station_temperature(15.0, 0.0), Some(15.0));
1587    }
1588
1589    #[test]
1590    fn test_altitude_only_default_matches_full_icao_standard() {
1591        // Regression: resolving BOTH temperature and pressure for an altitude-only query (defaults
1592        // left in place) must equal the fully-standard atmosphere at that altitude — i.e. altitude
1593        // now drives temperature (ICAO lapse) AND pressure, not just pressure. Validated against
1594        // py_ballisticcalc to ~0.04%. Previously the air held 15 C, leaving density ~7% too thin
1595        // (warm) at 3 km.
1596        for alt in [1000.0, 2000.0, 2500.0, 3000.0] {
1597            let t = resolve_station_temperature(15.0, alt);
1598            let p = resolve_station_pressure(1013.25, alt);
1599            let (rho_resolved, _) = calculate_atmosphere(alt, t, p, 0.0);
1600            let (rho_std, _) = calculate_atmosphere(alt, None, None, 0.0);
1601            assert!(
1602                (rho_resolved - rho_std).abs() < 1e-9,
1603                "alt {alt}: altitude-only default density {rho_resolved} should equal the full \
1604                 ICAO standard {rho_std}"
1605            );
1606            // And it must be denser than the old temperature-held-at-15C behavior (colder = denser).
1607            let (rho_warm, _) = calculate_atmosphere(alt, Some(15.0), p, 0.0);
1608            assert!(
1609                rho_resolved > rho_warm,
1610                "alt {alt}: lapse-temperature density {rho_resolved} should exceed 15 C-held {rho_warm}"
1611            );
1612        }
1613    }
1614
1615    #[test]
1616    fn test_enhanced_atmosphere_with_humidity() {
1617        let (density_dry, speed_dry) = calculate_atmosphere(0.0, None, None, 0.0);
1618        let (density_humid, speed_humid) = calculate_atmosphere(0.0, None, None, 80.0);
1619
1620        // Humid air should be less dense
1621        assert!(density_humid < density_dry);
1622        // Humid air should have slightly higher speed of sound
1623        assert!(speed_humid > speed_dry);
1624    }
1625
1626    #[test]
1627    fn test_enhanced_atmosphere_stratosphere() {
1628        // Test in stratosphere where temperature increases
1629        let (density_20km, speed_20km) = calculate_atmosphere(20000.0, None, None, 0.0);
1630        let (density_30km, speed_30km) = calculate_atmosphere(30000.0, None, None, 0.0);
1631
1632        // Density should decrease with altitude
1633        assert!(density_30km < density_20km);
1634        // Speed of sound should increase due to temperature increase
1635        assert!(speed_30km > speed_20km);
1636    }
1637
1638    #[test]
1639    fn test_enhanced_cimp_density() {
1640        let density = calculate_air_density_cimp(15.0, 1013.25, 0.0);
1641        assert!((density - 1.225).abs() < 0.01);
1642
1643        // Test with humidity
1644        let density_humid = calculate_air_density_cimp(15.0, 1013.25, 50.0);
1645        assert!(density_humid < density);
1646    }
1647
1648    #[test]
1649    fn test_cipm_moist_air_matches_python_reference() {
1650        // Regression for the hPa/Pa mole-fraction slip: p_v (hPa) was divided by the total
1651        // pressure in Pa, making x_v 100x too small and erasing the humidity effect entirely.
1652        // Reference values computed with the validated Python implementation
1653        // (ballistics.physics.atmosphere_icao.calculate_air_density_cipm_icao), same cases as
1654        // the Flask suite's tests/test_atmosphere.py::TestCalculateAirDensityCIPM. Tolerance
1655        // 0.1% (matches that suite's Rust-vs-Python assertion).
1656        let cases = [
1657            (15.0, 1013.25, 50.0, 1.221125867723075),
1658            (30.0, 1000.0, 80.0, 1.1344071877123691),
1659            (-10.0, 1020.0, 20.0, 1.3500610713710515),
1660        ];
1661        for (temp_c, pressure_hpa, humidity_pct, expected) in cases {
1662            let density = calculate_air_density_cipm(temp_c, pressure_hpa, humidity_pct);
1663            let rel_err = ((density - expected) / expected).abs();
1664            assert!(
1665                rel_err < 1e-3,
1666                "CIPM density at {temp_c} C / {pressure_hpa} hPa / {humidity_pct}% RH: \
1667                 got {density}, expected {expected} (rel err {rel_err:.2e} >= 1e-3)"
1668            );
1669        }
1670
1671        // Moist air must be materially lighter than dry air at the same temp/pressure
1672        // (the broken version returned a difference of only ~4e-5 kg/m^3).
1673        let dry = calculate_air_density_cipm(15.0, 1013.25, 0.0);
1674        let moist = calculate_air_density_cipm(15.0, 1013.25, 50.0);
1675        assert!(
1676            dry - moist > 3e-3,
1677            "humidity effect too small: dry {dry} vs 50% RH {moist}"
1678        );
1679    }
1680
1681    #[test]
1682    fn test_variable_lapse_rates() {
1683        // Test that lapse rates change appropriately with altitude
1684        let lapse_tropo = determine_local_lapse_rate(5000.0);
1685        let lapse_strato = determine_local_lapse_rate(25000.0);
1686
1687        assert!((lapse_tropo - (-0.0065)).abs() < 0.0001);
1688        assert!(lapse_strato > 0.0); // Positive lapse rate in stratosphere
1689    }
1690
1691    // ---- MBA-1137: AtmoSock stateless downrange lookup (mirrors the WindSock tests) ----
1692
1693    #[test]
1694    fn test_atmo_sock_empty_falls_back_to_isa() {
1695        let sock = AtmoSock::new(vec![]);
1696        assert!(sock.is_empty());
1697        // Empty sock returns the sea-level ISA reference regardless of distance.
1698        assert_eq!(sock.atmo_for_range(0.0), (15.0, 1013.25, 0.0));
1699        assert_eq!(sock.atmo_for_range(500.0), (15.0, 1013.25, 0.0));
1700    }
1701
1702    #[test]
1703    fn test_atmo_sock_single_segment_holds_beyond_last() {
1704        // One zone until 100 m; it must apply BOTH before and beyond the threshold (unlike wind,
1705        // which zeroes past the last segment — atmosphere holds the last zone).
1706        let sock = AtmoSock::new(vec![(25.0, 1000.0, 30.0, 100.0)]);
1707        assert_eq!(sock.atmo_for_range(50.0), (25.0, 1000.0, 30.0));
1708        assert_eq!(sock.atmo_for_range(100.0), (25.0, 1000.0, 30.0)); // beyond last -> hold
1709        assert_eq!(sock.atmo_for_range(5000.0), (25.0, 1000.0, 30.0));
1710    }
1711
1712    #[test]
1713    fn test_atmo_sock_boundary_is_upper_exclusive() {
1714        // A zone's until_distance_m is exclusive: a query exactly at the boundary rolls to the
1715        // next zone (mirrors WindSock::test_wind_sock_boundary_is_upper_exclusive).
1716        let sock = AtmoSock::new(vec![
1717            (30.0, 1010.0, 80.0, 100.0), // hot/humid near zone
1718            (-5.0, 900.0, 10.0, 200.0),  // cold/thin far zone
1719        ]);
1720        // Just below 100 m -> first zone.
1721        assert_eq!(sock.atmo_for_range(99.999), (30.0, 1010.0, 80.0));
1722        // Exactly 100 m -> second zone.
1723        assert_eq!(sock.atmo_for_range(100.0), (-5.0, 900.0, 10.0));
1724        // Beyond the last boundary -> hold the last zone (NOT zeroed).
1725        assert_eq!(sock.atmo_for_range(200.0), (-5.0, 900.0, 10.0));
1726        assert_eq!(sock.atmo_for_range(1e6), (-5.0, 900.0, 10.0));
1727    }
1728
1729    #[test]
1730    fn test_atmo_sock_sorts_unordered_segments() {
1731        // Segments supplied out of order are sorted by until_distance so the lookup is monotone.
1732        let sock = AtmoSock::new(vec![
1733            (-5.0, 900.0, 10.0, 200.0),
1734            (30.0, 1010.0, 80.0, 100.0),
1735        ]);
1736        assert_eq!(sock.atmo_for_range(50.0), (30.0, 1010.0, 80.0));
1737        assert_eq!(sock.atmo_for_range(150.0), (-5.0, 900.0, 10.0));
1738    }
1739
1740    #[test]
1741    fn test_atmo_sock_orders_nan_thresholds_last() {
1742        let positive_nan = f64::from_bits(0x7ff8_0000_0000_0001);
1743        let negative_nan = f64::from_bits(0xfff8_0000_0000_0002);
1744        let sock = AtmoSock::new(vec![
1745            (97.0, 997.0, 97.0, positive_nan),
1746            (30.0, 1030.0, 30.0, 300.0),
1747            (98.0, 998.0, 98.0, negative_nan),
1748            (10.0, 1010.0, 10.0, 100.0),
1749            (99.0, 999.0, 99.0, f64::NAN),
1750            (20.0, 1020.0, 20.0, 200.0),
1751        ]);
1752
1753        let thresholds: Vec<_> = sock.segments.iter().map(|segment| segment.3).collect();
1754        assert_eq!(&thresholds[..3], &[100.0, 200.0, 300.0]);
1755        assert!(thresholds[3..].iter().all(|threshold| threshold.is_nan()));
1756        assert_eq!(sock.atmo_for_range(f64::NAN), (10.0, 1010.0, 10.0));
1757        assert_eq!(sock.atmo_for_range(350.0), (99.0, 999.0, 99.0));
1758    }
1759
1760    #[test]
1761    fn test_atmo_sock_nan_uses_first_zone() {
1762        let sock = AtmoSock::new(vec![
1763            (30.0, 1010.0, 80.0, 100.0),
1764            (-5.0, 900.0, 10.0, 200.0),
1765        ]);
1766        // NaN can't be ordered; deterministically use the nearest (first) zone rather than panic.
1767        assert_eq!(sock.atmo_for_range(f64::NAN), (30.0, 1010.0, 80.0));
1768    }
1769
1770    // ---- MBA-1366: density altitude as a direct atmosphere input ----
1771
1772    /// With no explicit temperature, the resolved altitude must equal the input density
1773    /// altitude EXACTLY (this is the algebraic identity documented on
1774    /// `resolve_atmosphere_for_density_altitude`: the correction term vanishes when station
1775    /// temperature is left at ISA).
1776    #[test]
1777    fn density_altitude_default_temp_pressure_alt_equals_density_alt() {
1778        for da_ft in [0.0_f64, 1000.0, 3000.0, 7500.0, -500.0] {
1779            let da_m = da_ft * DA_FT_TO_M;
1780            let (altitude_m, temp_c, pressure_hpa) =
1781                resolve_atmosphere_for_density_altitude(da_m, None);
1782            assert!(
1783                (altitude_m - da_m).abs() < 1e-6,
1784                "da_ft={da_ft}: altitude_m {altitude_m} should equal da_m {da_m}"
1785            );
1786            // Sanity: pressure must be a plausible station pressure (strictly decreasing with DA).
1787            assert!(pressure_hpa > 0.0 && pressure_hpa < 1100.0);
1788            assert!(temp_c.is_finite());
1789        }
1790        // At DA=0 the result must be exactly the sea-level ISA reference.
1791        let (alt0, temp0, press0) = resolve_atmosphere_for_density_altitude(0.0, None);
1792        assert!(alt0.abs() < 1e-9);
1793        assert!((temp0 - 15.0).abs() < 1e-6);
1794        assert!((press0 - 1013.25).abs() < 1e-6);
1795    }
1796
1797    /// Higher density altitude at the ISA default must yield a lower station pressure (thinner
1798    /// air), matching the physical meaning of density altitude.
1799    #[test]
1800    fn density_altitude_pressure_decreases_monotonically() {
1801        let (_, _, p0) = resolve_atmosphere_for_density_altitude(0.0, None);
1802        let (_, _, p1) = resolve_atmosphere_for_density_altitude(1000.0, None);
1803        let (_, _, p2) = resolve_atmosphere_for_density_altitude(3000.0, None);
1804        assert!(p1 < p0);
1805        assert!(p2 < p1);
1806    }
1807
1808    /// An explicit temperature must be honored EXACTLY (never re-derived), while the implied
1809    /// pressure altitude (and therefore the resolved altitude/pressure) differs from the
1810    /// ISA-default case -- proving density is still honored (a different pressure/altitude
1811    /// combination that reproduces the SAME density altitude at the warmer/colder temperature).
1812    #[test]
1813    fn density_altitude_explicit_temperature_overrides_isa_default_but_honors_density() {
1814        let da_m = 1000.0 * DA_FT_TO_M;
1815        let (default_alt_m, default_temp_c, default_pressure_hpa) =
1816            resolve_atmosphere_for_density_altitude(da_m, None);
1817
1818        // A HOTTER-than-ISA explicit station temperature at the same density altitude implies a
1819        // LOWER pressure altitude: hot (less dense) air already accounts for some of the
1820        // "thinness" that defines the density altitude, so less physical elevation is needed to
1821        // reach the rest of it (equivalently: the correction term subtracts from DA before the
1822        // pressure inversion, since a warmer station reads MORE dense-feeling per unit of actual
1823        // elevation than ISA does).
1824        let hot_temp_c = default_temp_c + 20.0;
1825        let (hot_alt_m, hot_temp_out, hot_pressure_hpa) =
1826            resolve_atmosphere_for_density_altitude(da_m, Some(hot_temp_c));
1827        assert_eq!(
1828            hot_temp_out, hot_temp_c,
1829            "explicit temperature must be honored exactly"
1830        );
1831        assert!(
1832            hot_alt_m < default_alt_m,
1833            "hotter station temp at same DA should imply a lower pressure altitude: \
1834             hot={hot_alt_m} default={default_alt_m}"
1835        );
1836        assert!(hot_pressure_hpa > default_pressure_hpa);
1837
1838        // A COLDER-than-ISA explicit temperature implies a HIGHER pressure altitude.
1839        let cold_temp_c = default_temp_c - 20.0;
1840        let (cold_alt_m, cold_temp_out, cold_pressure_hpa) =
1841            resolve_atmosphere_for_density_altitude(da_m, Some(cold_temp_c));
1842        assert_eq!(cold_temp_out, cold_temp_c);
1843        assert!(cold_alt_m > default_alt_m);
1844        assert!(cold_pressure_hpa < default_pressure_hpa);
1845    }
1846
1847    /// Round trip using ONLY this crate's own math (the forward NWS pressure-altitude formula,
1848    /// re-derived independently in the test rather than reusing the production function) -- an
1849    /// implementation-independent algebraic sanity check. The REAL cross-crate round trip
1850    /// against the production `pdf_dope_card::calculate_density_altitude` forward function
1851    /// lives in `src/main.rs` (that function is CLI-binary-only and unreachable from this
1852    /// library crate).
1853    #[test]
1854    fn density_altitude_round_trips_through_hand_derived_forward_formula() {
1855        fn forward_density_altitude_ft(pressure_hpa: f64, temp_c: f64) -> f64 {
1856            let pressure_alt_ft = 145_366.45 * (1.0 - (pressure_hpa / 1013.25).powf(0.190_284));
1857            let isa_temp_f = 59.0 - (pressure_alt_ft / 1000.0) * 3.57;
1858            let temp_f = temp_c * 9.0 / 5.0 + 32.0;
1859            pressure_alt_ft + (120.0 * 5.0 / 9.0) * (temp_f - isa_temp_f)
1860        }
1861
1862        for da_ft in [0.0_f64, 500.0, 2500.0, 6000.0] {
1863            let da_m = da_ft * DA_FT_TO_M;
1864            // Default (ISA-at-DA) branch.
1865            let (_, temp_c, pressure_hpa) = resolve_atmosphere_for_density_altitude(da_m, None);
1866            let round_tripped_ft = forward_density_altitude_ft(pressure_hpa, temp_c);
1867            assert!(
1868                (round_tripped_ft - da_ft).abs() < 1e-6,
1869                "da_ft={da_ft}: round-tripped {round_tripped_ft} (temp_c={temp_c}, pressure_hpa={pressure_hpa})"
1870            );
1871
1872            // Explicit-temperature branch.
1873            let explicit_temp_c = 25.0;
1874            let (_, temp_out, pressure_hpa_explicit) =
1875                resolve_atmosphere_for_density_altitude(da_m, Some(explicit_temp_c));
1876            assert_eq!(temp_out, explicit_temp_c);
1877            let round_tripped_explicit_ft =
1878                forward_density_altitude_ft(pressure_hpa_explicit, temp_out);
1879            assert!(
1880                (round_tripped_explicit_ft - da_ft).abs() < 1e-6,
1881                "da_ft={da_ft} (explicit {explicit_temp_c}C): round-tripped {round_tripped_explicit_ft}"
1882            );
1883        }
1884    }
1885}