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)
101fn 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.
187pub fn resolve_station_conditions(
188    temperature_c: f64,
189    pressure_hpa: f64,
190    altitude_m: f64,
191) -> (f64, f64) {
192    let temp_override = resolve_station_temperature(temperature_c, altitude_m);
193    let press_override = resolve_station_pressure(pressure_hpa, altitude_m);
194    let (std_temp_k, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
195    let temp_c = temp_override.unwrap_or(std_temp_k - 273.15);
196    let pressure_hpa = press_override.unwrap_or(std_pressure_pa / 100.0);
197    (temp_c, pressure_hpa)
198}
199
200/// Enhanced atmospheric calculation with ICAO Standard Atmosphere.
201///
202/// # Arguments
203/// * `altitude_m` - Altitude in meters
204/// * `temp_override_c` - Temperature override in Celsius (None for standard)
205/// * `press_override_hpa` - Pressure override in hPa (None for standard)
206/// * `humidity_percent` - Humidity percentage (0-100)
207///
208/// # Returns
209/// Tuple of (air_density_kg_m3, speed_of_sound_mps)
210pub fn calculate_atmosphere(
211    altitude_m: f64,
212    temp_override_c: Option<f64>,
213    press_override_hpa: Option<f64>,
214    humidity_percent: f64,
215) -> (f64, f64) {
216    // Get standard atmosphere conditions or use overrides
217    let (temp_k, pressure_pa) = if temp_override_c.is_some() && press_override_hpa.is_some() {
218        // Both overrides provided
219        (
220            temp_override_c.unwrap() + 273.15,
221            press_override_hpa.unwrap() * 100.0,
222        )
223    } else {
224        // Get ICAO standard conditions
225        let (std_temp_k, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
226
227        let final_temp_k = if let Some(temp_c) = temp_override_c {
228            temp_c + 273.15
229        } else {
230            std_temp_k
231        };
232
233        let final_pressure_pa = if let Some(press_hpa) = press_override_hpa {
234            press_hpa * 100.0
235        } else {
236            std_pressure_pa
237        };
238
239        (final_temp_k, final_pressure_pa)
240    };
241
242    // Humidity clamp shared by the CIPM density and the moist speed of sound.
243    let humidity_clamped = humidity_percent.clamp(0.0, 100.0);
244    let temp_c = temp_k - 273.15;
245
246    // Density: CIPM-2007 is the single canonical humid-air density model. Every solver
247    // (cli_api / monte_carlo / ffi / fast_trajectory) reaches this one formula through
248    // calculate_atmosphere, so there is no second (Arden-Buck ideal-gas) density path to drift.
249    let density = calculate_air_density_cimp(temp_c, pressure_pa / 100.0, humidity_clamped);
250
251    // Speed of sound uses an ideal-gas moist-air mixture approximation, first order in the
252    // water-vapor mole fraction. Extracted into `moist_speed_of_sound` so the integrators can
253    // share it; its vapor pressure comes from the SAME IAPWS saturation formula
254    // (`enhanced_saturation_vapor_pressure`) + CIPM enhancement factor that the density above
255    // uses. Only the vapor fraction is shared; the acoustic relation remains ideal-gas.
256    let speed_of_sound = moist_speed_of_sound(temp_k, pressure_pa, humidity_clamped);
257
258    (density, speed_of_sound)
259}
260
261/// Speed of sound using an ideal-gas moist-air mixture approximation, first order in the
262/// water-vapor mole fraction.
263///
264/// The water-vapor mole fraction is derived from the SAME IAPWS saturation vapor pressure
265/// (`enhanced_saturation_vapor_pressure`) and CIPM-2007 enhancement factor used by
266/// [`calculate_air_density_cimp`], so a single vapor formula feeds both density and c. Only the
267/// vapor fraction is shared with the CIPM path; the acoustic relation itself remains ideal-gas.
268/// The mixture applies first-order humidity corrections to the dry-air heat-capacity ratio and
269/// gas constant, then evaluates `sqrt(gamma * R * T)`. It is not Cramer's full real-gas
270/// polynomial and does not include Cramer's pressure or carbon-dioxide terms.
271///
272/// # Arguments
273/// * `temp_k` - Temperature in Kelvin
274/// * `pressure_pa` - Total (station) pressure in Pa
275/// * `humidity_percent` - Relative humidity percentage (0-100)
276///
277/// # Returns
278/// Speed of sound in m/s
279pub fn moist_speed_of_sound(temp_k: f64, pressure_pa: f64, humidity_percent: f64) -> f64 {
280    let humidity_clamped = humidity_percent.clamp(0.0, 100.0);
281    let temp_c = temp_k - 273.15;
282
283    // Water-vapor partial pressure p_v = RH * f * p_sv, matching CIPM's x_v exactly. p_sv is in
284    // hPa (enhanced_saturation_vapor_pressure returns hPa), so convert to Pa before forming the
285    // mole fraction against the Pa total pressure.
286    let p_sv_hpa = enhanced_saturation_vapor_pressure(temp_k);
287    let f = enhanced_enhancement_factor(pressure_pa, temp_c);
288    let vapor_pressure_pa = humidity_clamped / 100.0 * f * p_sv_hpa * 100.0;
289
290    // Cap the mole fraction at the physical maximum of 1 and guard pressure_pa == 0 (a 0 hPa
291    // override would otherwise give +Inf -> NaN speed of sound).
292    let mole_fraction_vapor = (vapor_pressure_pa / pressure_pa.max(f64::MIN_POSITIVE)).min(1.0);
293
294    // Heat-capacity ratio and gas constant for moist air (mole-fraction coefficients). 0.378 is
295    // the dry-air molecular-weight ratio (0.6078 would belong to specific humidity, not mole
296    // fraction).
297    let gamma_moist = GAMMA * (1.0 - mole_fraction_vapor * 0.062);
298    let r_moist = R_AIR * (1.0 + 0.378 * mole_fraction_vapor);
299
300    (gamma_moist * r_moist * temp_k).sqrt()
301}
302
303/// Enhanced air density calculation using CIPM formula with ICAO atmosphere.
304///
305/// # Arguments
306/// * `temp_c` - Temperature in Celsius
307/// * `pressure_hpa` - Pressure in hPa
308/// * `humidity_percent` - Humidity percentage (0-100)
309///
310/// # Returns
311/// Air density in kg/m³
312pub fn calculate_air_density_cimp(temp_c: f64, pressure_hpa: f64, humidity_percent: f64) -> f64 {
313    let t_k = temp_c + 273.15;
314
315    // Enhanced saturation vapor pressure calculation
316    let p_sv = enhanced_saturation_vapor_pressure(t_k);
317
318    let pressure_pa = pressure_hpa * 100.0;
319
320    // Enhanced enhancement factor with temperature dependence. CIPM constants use Pa.
321    let f = enhanced_enhancement_factor(pressure_pa, temp_c);
322
323    // Vapor pressure with clamping. p_sv is in hPa (enhanced_saturation_vapor_pressure
324    // returns hPa — its critical-pressure constant is 220640 hPa), so p_v is in hPa too.
325    let p_v = humidity_percent.clamp(0.0, 100.0) / 100.0 * f * p_sv;
326
327    // Convert the vapor pressure to Pa BEFORE forming the mole fraction: the divisor below
328    // is in Pa. Dividing the hPa p_v by the Pa total made x_v 100x too small, which erased
329    // the humidity term and returned essentially dry-air density (e.g. 15 C / 1013.25 hPa /
330    // 50% RH gave ~1.2254 instead of the CIPM-2007 moist value ~1.2211 — moist air is
331    // LIGHTER than dry air).
332    let p_v_pa = p_v * 100.0;
333
334    // Floor the pressure divisor (mirrors calculate_atmosphere): a 0 hPa pressure would
335    // otherwise make x_v = +Inf -> NaN density. No-op for all valid (>0) pressures.
336    let p_pa = pressure_pa.max(f64::MIN_POSITIVE);
337
338    // Mole fraction of water vapor (capped at the physical maximum of 1)
339    let x_v = (p_v_pa / p_pa).min(1.0);
340
341    // Enhanced compressibility factor. CIPM virial constants use Pa.
342    let z = enhanced_compressibility_factor(p_pa, t_k, x_v);
343
344    // Calculate density with enhanced precision
345    // Note: parentheses are important here for correct operator precedence
346    ((p_pa * M_A) / (z * R * t_k)) * (1.0 - x_v * (1.0 - M_V / M_A))
347}
348
349/// Enhanced saturation vapor pressure calculation.
350/// Uses the IAPWS-IF97 formulation for high precision.
351#[inline(always)]
352fn enhanced_saturation_vapor_pressure(t_k: f64) -> f64 {
353    // IAPWS-IF97 coefficients for better accuracy
354    const A: [f64; 6] = [
355        -7.85951783,
356        1.84408259,
357        -11.7866497,
358        22.6807411,
359        -15.9618719,
360        1.80122502,
361    ];
362
363    // Ensure temperature is positive and reasonable
364    let t_k_safe = t_k.max(173.15); // -100°C minimum
365
366    let tau = 1.0 - t_k_safe / 647.096; // Critical temperature of water
367    let ln_p_ratio = (647.096 / t_k_safe)
368        * (A[0] * tau
369            + A[1] * tau.powf(1.5)
370            + A[2] * tau.powf(3.0)
371            + A[3] * tau.powf(3.5)
372            + A[4] * tau.powf(4.0)
373            + A[5] * tau.powf(7.5));
374
375    220640.0 * ln_p_ratio.exp() // Critical pressure in hPa (22.064 MPa)
376}
377
378/// CIPM-2007 enhancement factor `f = alpha + beta*p + gamma*t^2` (p in Pa, t in Celsius).
379#[inline(always)]
380fn enhanced_enhancement_factor(p: f64, t: f64) -> f64 {
381    const ALPHA: f64 = 1.00062;
382    const BETA: f64 = 3.14e-8;
383    const GAMMA: f64 = 5.6e-7;
384
385    ALPHA + BETA * p + GAMMA * t * t
386}
387
388/// CIPM-2007 compressibility factor `Z` (virial expansion, second order in `p/T`).
389#[inline(always)]
390fn enhanced_compressibility_factor(p: f64, t_k: f64, x_v: f64) -> f64 {
391    // CIPM-2007 molar virial coefficients (p in Pa, t in Celsius).
392    const A0: f64 = 1.58123e-6;
393    const A1: f64 = -2.9331e-8;
394    const A2: f64 = 1.1043e-10;
395    const B0: f64 = 5.707e-6;
396    const B1: f64 = -2.051e-8;
397    const C0: f64 = 1.9898e-4;
398    const C1: f64 = -2.376e-6;
399    const D: f64 = 1.83e-11;
400    const E: f64 = -0.765e-8;
401
402    // Ensure temperature is positive
403    let t_k_safe = t_k.max(173.15); // -100°C minimum
404    let t = t_k_safe - 273.15;
405    let p_t = p / t_k_safe;
406
407    let z_second_order =
408        1.0 - p_t * (A0 + A1 * t + A2 * t * t + (B0 + B1 * t) * x_v + (C0 + C1 * t) * x_v * x_v);
409
410    let z_third_order = p_t * p_t * (D + E * x_v * x_v);
411
412    z_second_order + z_third_order
413}
414
415/// Convert an `(x, y)` position in the shot-aligned frame to true world altitude.
416///
417/// The engine rotates gravity by `shooting_angle_rad`, so shot-frame X follows the inclined
418/// line of fire and Y is perpendicular to it in the vertical plane. Atmosphere lookup needs the
419/// world-vertical projection of that position, added to the station altitude.
420#[inline]
421pub(crate) fn shot_frame_altitude(
422    base_altitude_m: f64,
423    downrange_m: f64,
424    shot_y_m: f64,
425    shooting_angle_rad: f64,
426) -> f64 {
427    base_altitude_m
428        + downrange_m * shooting_angle_rad.sin()
429        + shot_y_m * shooting_angle_rad.cos()
430}
431
432/// Enhanced local atmospheric calculation with variable lapse rates.
433///
434/// # Arguments
435/// * `altitude_m` - Query geometric altitude above mean sea level in meters
436/// * `base_alt` - Base geometric altitude above mean sea level in meters
437/// * `base_temp_c` - Base temperature in Celsius
438/// * `base_press_hpa` - Base pressure in hPa
439/// * `base_ratio` - Base density ratio
440///
441/// # Returns
442/// Tuple of (air_density_kg_m3, speed_of_sound_mps)
443pub fn get_local_atmosphere(
444    altitude_m: f64,
445    base_alt: f64,
446    base_temp_c: f64,
447    base_press_hpa: f64,
448    base_ratio: f64,
449) -> (f64, f64) {
450    let (temp_k, _pressure_pa, density) =
451        local_temp_pressure_density(altitude_m, base_alt, base_temp_c, base_press_hpa, base_ratio);
452
453    // Dry speed of sound. 401.874 ~ gamma * R_air; kept exactly for back-compat with existing
454    // callers (get_local_atmosphere_humid uses the precise moist formula instead).
455    let speed_of_sound = (temp_k * 401.874).sqrt();
456
457    (density, speed_of_sound)
458}
459
460/// Humidity-aware companion to [`get_local_atmosphere`]: identical local density, but the speed
461/// of sound is the moist-air value ([`moist_speed_of_sound`]) evaluated at the LOCAL temperature
462/// and pressure.
463///
464/// [`get_local_atmosphere`] is intentionally left unchanged (dry speed of sound) for
465/// API/back-compat; call this variant only where a real relative humidity is available.
466///
467/// # Arguments
468/// * `altitude_m` - Query geometric altitude above mean sea level in meters
469/// * `base_alt` - Base (station) geometric altitude above mean sea level in meters
470/// * `base_temp_c` - Base temperature in Celsius
471/// * `base_press_hpa` - Base pressure in hPa
472/// * `base_ratio` - Base density ratio (density / 1.225)
473/// * `humidity_percent` - Relative humidity percentage (0-100)
474///
475/// # Returns
476/// Tuple of (air_density_kg_m3, moist_speed_of_sound_mps)
477pub fn get_local_atmosphere_humid(
478    altitude_m: f64,
479    base_alt: f64,
480    base_temp_c: f64,
481    base_press_hpa: f64,
482    base_ratio: f64,
483    humidity_percent: f64,
484) -> (f64, f64) {
485    let (temp_k, pressure_pa, density) =
486        local_temp_pressure_density(altitude_m, base_alt, base_temp_c, base_press_hpa, base_ratio);
487    (density, moist_speed_of_sound(temp_k, pressure_pa, humidity_percent))
488}
489
490/// Shared local temperature / pressure / density computation for [`get_local_atmosphere`] and
491/// [`get_local_atmosphere_humid`]. Returns `(local_temp_k, local_pressure_pa, density_kg_m3)`.
492#[inline]
493fn local_temp_pressure_density(
494    altitude_m: f64,
495    base_alt: f64,
496    base_temp_c: f64,
497    base_press_hpa: f64,
498    base_ratio: f64,
499) -> (f64, f64, f64) {
500    let base_temp_k = base_temp_c + 273.15;
501
502    // A non-finite endpoint would make the boundary walk fail to advance. The
503    // previous single-column formula also produced non-finite outputs here.
504    if !altitude_m.is_finite() || !base_alt.is_finite() {
505        return (f64::NAN, f64::NAN, f64::NAN);
506    }
507
508    let base_geopotential_m = geometric_to_geopotential_height_m(base_alt);
509    let target_geopotential_m = geometric_to_geopotential_height_m(altitude_m);
510    let (temp_k, pressure_hpa) = integrate_local_atmosphere_layers(
511        base_geopotential_m,
512        target_geopotential_m,
513        base_temp_k,
514        base_press_hpa,
515    );
516
517    // Enhanced density calculation
518    let density_ratio = base_ratio * (base_temp_k * pressure_hpa) / (base_press_hpa * temp_k);
519    let density = density_ratio * 1.225;
520
521    (temp_k, pressure_hpa * 100.0, density)
522}
523
524/// Carry arbitrary station temperature and pressure through each crossed ICAO
525/// layer in geopotential-height coordinates. The layer table supplies lapse rates and boundaries
526/// only; station deviations from the standard atmosphere remain anchored at the converted base.
527fn integrate_local_atmosphere_layers(
528    base_geopotential_m: f64,
529    target_geopotential_m: f64,
530    mut temp_k: f64,
531    mut pressure_hpa: f64,
532) -> (f64, f64) {
533    let mut current_alt = base_geopotential_m;
534
535    if target_geopotential_m > current_alt {
536        while current_alt < target_geopotential_m {
537            // At an exact boundary, ascent starts in the higher layer.
538            let layer_index = ICAO_LAYERS
539                .iter()
540                .rposition(|layer| current_alt >= layer.base_altitude)
541                .unwrap_or(0);
542            let segment_end = ICAO_LAYERS
543                .get(layer_index + 1)
544                .map_or(target_geopotential_m, |next| {
545                    target_geopotential_m.min(next.base_altitude)
546                });
547            (temp_k, pressure_hpa) = integrate_local_atmosphere_segment(
548                temp_k,
549                pressure_hpa,
550                segment_end - current_alt,
551                ICAO_LAYERS[layer_index].lapse_rate,
552            );
553            current_alt = segment_end;
554        }
555    } else {
556        while current_alt > target_geopotential_m {
557            // At an exact boundary, descent starts in the lower layer. The
558            // strict comparison is what makes a cross-layer round trip reversible.
559            let layer_index = ICAO_LAYERS
560                .iter()
561                .rposition(|layer| current_alt > layer.base_altitude)
562                .unwrap_or(0);
563            let segment_end = if layer_index == 0 {
564                target_geopotential_m
565            } else {
566                target_geopotential_m.max(ICAO_LAYERS[layer_index].base_altitude)
567            };
568            (temp_k, pressure_hpa) = integrate_local_atmosphere_segment(
569                temp_k,
570                pressure_hpa,
571                segment_end - current_alt,
572                ICAO_LAYERS[layer_index].lapse_rate,
573            );
574            current_alt = segment_end;
575        }
576    }
577
578    (temp_k, pressure_hpa)
579}
580
581#[inline]
582fn integrate_local_atmosphere_segment(
583    base_temp_k: f64,
584    base_pressure_hpa: f64,
585    height_diff: f64,
586    lapse_rate: f64,
587) -> (f64, f64) {
588    let temp_k = base_temp_k + lapse_rate * height_diff;
589    let pressure_hpa = if lapse_rate.abs() < 1e-10 {
590        base_pressure_hpa * (-G_ACCEL_MPS2 * height_diff / (R_AIR * base_temp_k)).exp()
591    } else {
592        let temp_ratio = temp_k / base_temp_k;
593        base_pressure_hpa * temp_ratio.powf(-G_ACCEL_MPS2 / (lapse_rate * R_AIR))
594    };
595
596    (temp_k, pressure_hpa)
597}
598
599/// Determine local lapse rate based on altitude and atmospheric layer.
600#[cfg(test)]
601#[inline(always)]
602fn determine_local_lapse_rate(altitude_m: f64) -> f64 {
603    // Find the current atmospheric layer to get appropriate lapse rate
604    let layer = ICAO_LAYERS
605        .iter()
606        .rev()
607        .find(|layer| altitude_m >= layer.base_altitude)
608        .unwrap_or(&ICAO_LAYERS[0]);
609
610    layer.lapse_rate
611}
612
613/// Direct atmosphere calculation for simple cases.
614///
615/// # Arguments
616/// * `density` - Pre-computed air density
617/// * `speed_of_sound` - Pre-computed speed of sound
618///
619/// # Returns
620/// Tuple of (air_density, speed_of_sound) - just passes through the values
621#[inline(always)]
622pub fn get_direct_atmosphere(density: f64, speed_of_sound: f64) -> (f64, f64) {
623    (density, speed_of_sound)
624}
625
626/// Legacy function name for backwards compatibility
627pub fn calculate_air_density_cipm(temp_c: f64, pressure_hpa: f64, humidity_percent: f64) -> f64 {
628    calculate_air_density_cimp(temp_c, pressure_hpa, humidity_percent)
629}
630
631/// A single downrange-referenced atmosphere zone:
632/// `(temp_c, pressure_hpa, humidity_percent, until_distance_m)`.
633///
634/// The T/P/H are the STATION-REFERENCED conditions (defined at the shooter base altitude) that
635/// apply from the previous segment's threshold out to `until_distance_m`. This mirrors
636/// [`crate::wind::WindSegment`]'s `(speed, angle, until_distance)` shape so the two segmented
637/// models compose the same way (wind by X, atmosphere by X, altitude lapse by Y).
638pub type AtmoSegment = (f64, f64, f64, f64);
639
640/// Downrange-segmented atmosphere handler (MBA-1137), the density analogue of
641/// [`crate::wind::WindSock`].
642///
643/// Holds a set of station-referenced atmosphere zones ordered by their `until_distance_m`
644/// threshold and answers a stateless downrange lookup ([`AtmoSock::atmo_for_range`]).
645///
646/// The zone T/P/H are the base (shooter-altitude) conditions for that stretch of range; the
647/// solver swaps them into the same local-atmosphere altitude-lapse pipeline that a single-station
648/// solve uses, so the downrange (X) zone and the vertical (Y) altitude lapse compose orthogonally
649/// without double-counting (the zone sets the base tuple, the lapse multiplies on top of it).
650#[derive(Debug, Clone)]
651pub struct AtmoSock {
652    /// Zones sorted ascending by `until_distance_m` (segment slot 3).
653    segments: Vec<AtmoSegment>,
654}
655
656impl AtmoSock {
657    /// Create a new `AtmoSock` from station-referenced atmosphere zones.
658    ///
659    /// Each segment is `(temp_c, pressure_hpa, humidity_percent, until_distance_m)`. Segments are
660    /// sorted by `until_distance_m`, with NaN thresholds ordered last.
661    pub fn new(mut segments: Vec<AtmoSegment>) -> Self {
662        segments.sort_by(|a, b| match (a.3.is_nan(), b.3.is_nan()) {
663            (true, true) => Ordering::Equal,
664            (true, false) => Ordering::Greater,
665            (false, true) => Ordering::Less,
666            (false, false) => a.3.partial_cmp(&b.3).unwrap(),
667        });
668        AtmoSock { segments }
669    }
670
671    /// True when this sock carries no zones (a lookup falls back to sea-level ISA).
672    pub fn is_empty(&self) -> bool {
673        self.segments.is_empty()
674    }
675
676    /// Stateless downrange lookup of the active zone's `(temp_c, pressure_hpa, humidity_percent)`.
677    ///
678    /// Selection matches [`crate::wind::WindSock::vector_for_range_stateless`]: the first segment
679    /// whose `until_distance_m` STRICTLY exceeds `downrange_m` wins (thresholds are upper-exclusive).
680    /// Unlike wind — which returns zero past the last threshold — the LAST zone is used for any
681    /// distance at or beyond the final threshold (there is no "zero atmosphere"). An empty sock
682    /// returns the sea-level ISA reference `(15 C, 1013.25 hPa, 0% RH)`.
683    ///
684    /// This is stateless and safe for numerical integration (the same X may be queried repeatedly
685    /// or out of order across RK substeps).
686    pub fn atmo_for_range(&self, downrange_m: f64) -> (f64, f64, f64) {
687        if self.segments.is_empty() {
688            return (15.0, 1013.25, 0.0); // sea-level ISA fallback
689        }
690        // NaN X can't be ordered; use the first (nearest) zone deterministically.
691        if downrange_m.is_nan() {
692            let s = self.segments[0];
693            return (s.0, s.1, s.2);
694        }
695        for seg in &self.segments {
696            if downrange_m < seg.3 {
697                return (seg.0, seg.1, seg.2);
698            }
699        }
700        // Beyond the final threshold: hold the last zone (no zeroing).
701        let last = self.segments[self.segments.len() - 1];
702        (last.0, last.1, last.2)
703    }
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709
710    #[test]
711    fn inclined_shot_frame_position_maps_to_world_altitude() {
712        let base_altitude = 100.0;
713        let downrange = 1_000.0;
714        let shot_y = 10.0;
715        let angle = std::f64::consts::FRAC_PI_6;
716        let expected = base_altitude + downrange * angle.sin() + shot_y * angle.cos();
717
718        let actual = shot_frame_altitude(base_altitude, downrange, shot_y, angle);
719        assert!(
720            (actual - expected).abs() < 1e-12,
721            "30-degree shot at x=1000/y=10 should be at {expected} m, got {actual} m"
722        );
723        assert_eq!(
724            shot_frame_altitude(base_altitude, downrange, shot_y, 0.0),
725            base_altitude + shot_y,
726            "flat-fire altitude must remain byte-identical"
727        );
728        let downhill = shot_frame_altitude(base_altitude, downrange, shot_y, -angle);
729        let expected_downhill = base_altitude - downrange * angle.sin() + shot_y * angle.cos();
730        assert!((downhill - expected_downhill).abs() < 1e-12);
731    }
732
733    // ---- MBA-1136: CIPM-2007 as the single canonical humid-air density ----
734
735    /// Gate 1: dry sea-level (15 C, 1013.25 hPa, 0% RH) must stay at the ISA reference — density
736    /// 1.225 +- 0.002 kg/m^3 and speed of sound 340.3 +- 0.6 m/s. CIPM-2007 at 0% RH reduces to
737    /// dry-air ideal gas to within rounding, so this is essentially unchanged from the pre-CIPM
738    /// baseline (baseline was 1.225012 / 340.294; now 1.225521 / 340.294 — the tiny density bump
739    /// is CIPM compressibility + exact molar mass, the speed of sound is bit-identical).
740    #[test]
741    fn test_mba1136_dry_sea_level_reference() {
742        let (density, sos) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 0.0);
743        assert!(
744            (density - 1.225).abs() < 0.002,
745            "dry sea-level density {density} not within 1.225 +- 0.002"
746        );
747        assert!(
748            (sos - 340.3).abs() < 0.6,
749            "dry sea-level speed of sound {sos} not within 340.3 +- 0.6"
750        );
751    }
752
753    /// Gate 2: humid air (15 C, 1013.25 hPa, 50% RH) is the CIPM-2007 value (~1.2211 +- 0.002),
754    /// STRICTLY lighter than dry air at the same T/P, with a speed of sound slightly ABOVE dry.
755    #[test]
756    fn test_mba1136_humid_lighter_than_dry() {
757        let (dry_rho, dry_sos) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 0.0);
758        let (moist_rho, moist_sos) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 50.0);
759
760        assert!(
761            (moist_rho - 1.2211).abs() < 0.002,
762            "50% RH density {moist_rho} not within CIPM 1.2211 +- 0.002"
763        );
764        assert!(
765            moist_rho < dry_rho,
766            "moist air ({moist_rho}) must be lighter than dry ({dry_rho})"
767        );
768        assert!(
769            moist_sos > dry_sos,
770            "moist speed of sound ({moist_sos}) must exceed dry ({dry_sos})"
771        );
772    }
773
774    /// Gate 3: density is monotone-decreasing in humidity (100% RH < 50% RH < 0% RH).
775    #[test]
776    fn test_mba1136_density_monotone_in_humidity() {
777        let (rho_0, _) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 0.0);
778        let (rho_50, _) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 50.0);
779        let (rho_100, _) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 100.0);
780        assert!(
781            rho_100 < rho_50 && rho_50 < rho_0,
782            "humidity monotonicity violated: 100%={rho_100}, 50%={rho_50}, 0%={rho_0}"
783        );
784    }
785
786    /// rank 28: `calculate_atmosphere`'s density is exactly `calculate_air_density_cimp` — there
787    /// is a single canonical humid-air density path (no separate Arden-Buck ideal-gas density).
788    #[test]
789    fn test_mba1136_atmosphere_density_is_cipm() {
790        for (t, p, h) in [
791            (15.0, 1013.25, 0.0),
792            (15.0, 1013.25, 50.0),
793            (30.0, 1000.0, 80.0),
794            (-10.0, 1020.0, 20.0),
795        ] {
796            let (density, _) = calculate_atmosphere(0.0, Some(t), Some(p), h);
797            let cipm = calculate_air_density_cimp(t, p, h);
798            assert_eq!(
799                density, cipm,
800                "calculate_atmosphere density must equal CIPM at {t}C/{p}hPa/{h}%"
801            );
802        }
803    }
804
805    /// rank 9: the extracted `moist_speed_of_sound` is exactly what `calculate_atmosphere`
806    /// returns (behavior-identical extraction), across dry and humid conditions.
807    #[test]
808    fn test_mba1136_moist_speed_of_sound_extraction() {
809        for (t, p, h) in [
810            (15.0, 1013.25, 0.0),
811            (15.0, 1013.25, 50.0),
812            (25.0, 900.0, 100.0),
813        ] {
814            let (_, sos) = calculate_atmosphere(0.0, Some(t), Some(p), h);
815            let extracted = moist_speed_of_sound(t + 273.15, p * 100.0, h);
816            assert_eq!(
817                sos, extracted,
818                "extracted moist_speed_of_sound must match calculate_atmosphere at {t}C/{p}hPa/{h}%"
819            );
820        }
821    }
822
823    /// rank 9: local-atmosphere reference values (base: 500 m, 10 C, 950 hPa, ratio 1.05).
824    /// The 1500 m values include the geometric-to-geopotential height conversion.
825    #[test]
826    fn test_mba1136_get_local_atmosphere_reference() {
827        let (d0, c0) = get_local_atmosphere(500.0, 500.0, 10.0, 950.0, 1.05);
828        assert!(
829            (d0 - 1.286250000000).abs() < 1e-9,
830            "local density@500m drifted: {d0}"
831        );
832        assert!(
833            (c0 - 337.328657395129).abs() < 1e-9,
834            "local sos@500m drifted: {c0}"
835        );
836        let (d1, c1) = get_local_atmosphere(1500.0, 500.0, 10.0, 950.0, 1.05);
837        assert!(
838            (d1 - 1.165238292559).abs() < 1e-9,
839            "local density@1500m drifted: {d1}"
840        );
841        assert!(
842            (c1 - 333.435546617978).abs() < 1e-9,
843            "local sos@1500m drifted: {c1}"
844        );
845    }
846
847    #[test]
848    fn fractional_station_altitude_is_not_quantized() {
849        let station_altitude_m = 500.25;
850        let station_temp_c = 10.0;
851        let station_pressure_hpa = 950.0;
852        let station_density_ratio = 1.05;
853
854        let (density, speed_of_sound) = get_local_atmosphere(
855            station_altitude_m,
856            station_altitude_m,
857            station_temp_c,
858            station_pressure_hpa,
859            station_density_ratio,
860        );
861
862        let expected_density = station_density_ratio * 1.225;
863        let expected_speed_of_sound = ((station_temp_c + 273.15) * 401.874).sqrt();
864        assert!((density - expected_density).abs() < 1e-12);
865        assert!((speed_of_sound - expected_speed_of_sound).abs() < 1e-12);
866    }
867
868    /// rank 9: `get_local_atmosphere_humid` returns the SAME density as `get_local_atmosphere`,
869    /// and at 0% RH its speed of sound reduces to the dry value (within the 401.874-vs-gamma*R
870    /// constant rounding). At real humidity the speed of sound exceeds the dry value.
871    #[test]
872    fn test_mba1136_get_local_atmosphere_humid() {
873        let (d_dry, c_dry) = get_local_atmosphere(1500.0, 500.0, 10.0, 950.0, 1.05);
874        let (d_h0, c_h0) = get_local_atmosphere_humid(1500.0, 500.0, 10.0, 950.0, 1.05, 0.0);
875        assert_eq!(d_dry, d_h0, "humid variant must not change density");
876        assert!(
877            (c_h0 - c_dry).abs() < 1e-3,
878            "0% RH humid sos {c_h0} should match dry sos {c_dry}"
879        );
880        let (_, c_h80) = get_local_atmosphere_humid(1500.0, 500.0, 10.0, 950.0, 1.05, 80.0);
881        assert!(c_h80 > c_dry, "humid sos {c_h80} should exceed dry {c_dry}");
882    }
883
884    #[test]
885    fn test_icao_standard_atmosphere() {
886        // Test sea level
887        let (temp, press) = calculate_icao_standard_atmosphere(0.0);
888        assert!((temp - 288.15).abs() < 0.01);
889        assert!((press - 101325.0).abs() < 1.0);
890
891        // The table's 11 km tropopause base is geopotential height; convert it back to the
892        // geometric altitude accepted by the public atmosphere contract.
893        let geometric_tropopause_m =
894            GEOPOTENTIAL_EARTH_RADIUS_M * 11000.0 / (GEOPOTENTIAL_EARTH_RADIUS_M - 11000.0);
895        let (temp_11km, press_11km) = calculate_icao_standard_atmosphere(geometric_tropopause_m);
896        assert!((temp_11km - 216.65).abs() < 0.01);
897        assert!((press_11km - 22632.1).abs() < 1.0);
898
899        // Test stratosphere
900        let (temp_25km, _) = calculate_icao_standard_atmosphere(25000.0);
901        assert!(temp_25km > 216.65); // Temperature increases in stratosphere
902    }
903
904    #[test]
905    fn standard_atmosphere_extends_below_sea_level() {
906        let altitude_m = -430.0;
907        let (temp_k, pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
908
909        assert!((temp_k - 290.945_189_079_054).abs() < 1e-9);
910        assert!((pressure_pa - 106_598.763_552_437).abs() < 0.1);
911
912        let (station_temp_c, station_pressure_hpa) =
913            resolve_station_conditions(15.0, 1013.25, altitude_m);
914        assert!((station_temp_c - 17.795_189_079_054).abs() < 1e-9);
915        assert!((station_pressure_hpa - 1_065.987_635_524).abs() < 1e-6);
916
917        let (sea_density, _) = calculate_atmosphere(0.0, None, None, 0.0);
918        let (below_sea_density, _) = calculate_atmosphere(altitude_m, None, None, 0.0);
919        assert!((below_sea_density - 1.276_908_642_79).abs() < 1e-6);
920        assert!(below_sea_density > sea_density * 1.04);
921
922        assert_eq!(
923            calculate_icao_standard_atmosphere(-6000.0),
924            calculate_icao_standard_atmosphere(MIN_GEOMETRIC_ALTITUDE_M)
925        );
926    }
927
928    #[test]
929    fn standard_atmosphere_converts_geometric_to_geopotential_height() {
930        let cases: [(f64, f64, f64); 3] = [
931            (10_000.0, 223.252_092_647_979, 26_499.901_600_244),
932            (30_000.0, 226.509_083_611_330, 1_197.032_108_466),
933            (84_000.0, 190.841_043_736_102, 0.531_525_514_935),
934        ];
935        for (geometric_m, expected_temp_k, expected_pressure_pa) in cases {
936            let (temp_k, pressure_pa) = calculate_icao_standard_atmosphere(geometric_m);
937            let pressure_tolerance = expected_pressure_pa.max(1.0_f64) * 1e-6;
938
939            assert!((temp_k - expected_temp_k).abs() < 1e-9);
940            assert!((pressure_pa - expected_pressure_pa).abs() < pressure_tolerance);
941        }
942    }
943
944    #[test]
945    fn local_atmosphere_walks_icao_layers_continuously() {
946        for altitude_m in [
947            10999.0, 11000.0, 11001.0, 11050.0, 19999.0, 20000.0, 20001.0, 25000.0,
948        ] {
949            let (local_temp_k, local_pressure_pa, _) =
950                local_temp_pressure_density(altitude_m, 0.0, 15.0, 1013.25, 1.0);
951            let (standard_temp_k, standard_pressure_pa) =
952                calculate_icao_standard_atmosphere(altitude_m);
953
954            assert!(
955                (local_temp_k - standard_temp_k).abs() < 1e-9,
956                "local temperature diverged from ICAO at {altitude_m} m: local={local_temp_k}, standard={standard_temp_k}"
957            );
958            assert!(
959                ((local_pressure_pa - standard_pressure_pa) / standard_pressure_pa).abs() < 5e-5,
960                "local pressure diverged from ICAO at {altitude_m} m: local={local_pressure_pa}, standard={standard_pressure_pa}"
961            );
962        }
963
964        let (density_below, sound_below) =
965            get_local_atmosphere(10999.0, 0.0, 15.0, 1013.25, 1.0);
966        let (density_above, sound_above) =
967            get_local_atmosphere(11001.0, 0.0, 15.0, 1013.25, 1.0);
968        assert!(
969            (density_below - density_above).abs() < 0.001,
970            "density jumped across 11 km: below={density_below}, above={density_above}"
971        );
972        assert!(
973            (sound_below - sound_above).abs() < 0.1,
974            "speed of sound jumped across 11 km: below={sound_below}, above={sound_above}"
975        );
976    }
977
978    #[test]
979    fn local_atmosphere_preserves_nonstandard_station_offset_and_round_trips() {
980        let base_alt = 7500.0;
981        let base_temp_c = 5.0;
982        let base_pressure_hpa = 410.0;
983        let base_ratio = 0.72;
984
985        let (high_temp_k, high_pressure_pa, high_density) = local_temp_pressure_density(
986            25000.0,
987            base_alt,
988            base_temp_c,
989            base_pressure_hpa,
990            base_ratio,
991        );
992        assert!((high_temp_k - 260.244_615_053_376).abs() < 1e-9);
993        assert!((high_pressure_pa / 100.0 - 40.964_358_485_456).abs() < 1e-9);
994        assert!((high_density - 0.094_186_400_274).abs() < 1e-9);
995
996        let (back_temp_k, back_pressure_pa, back_density) = local_temp_pressure_density(
997            base_alt,
998            25000.0,
999            high_temp_k - 273.15,
1000            high_pressure_pa / 100.0,
1001            high_density / 1.225,
1002        );
1003        assert!((back_temp_k - (base_temp_c + 273.15)).abs() < 1e-9);
1004        assert!((back_pressure_pa / 100.0 - base_pressure_hpa).abs() < 1e-8);
1005        assert!((back_density - base_ratio * 1.225).abs() < 1e-9);
1006    }
1007
1008    #[test]
1009    fn test_enhanced_atmosphere_sea_level() {
1010        let (density, speed) = calculate_atmosphere(0.0, None, None, 0.0);
1011        assert!((density - 1.225).abs() < 0.01);
1012        assert!((speed - 340.0).abs() < 1.0);
1013    }
1014
1015    #[test]
1016    fn test_resolve_station_pressure_contract() {
1017        // Default sea-level pressure + real altitude => derive from altitude (None).
1018        assert_eq!(resolve_station_pressure(1013.25, 2000.0), None);
1019        // 29.92 inHg ≈ 1013.21 hPa is also treated as the default (within tolerance).
1020        assert_eq!(resolve_station_pressure(1013.21, 2000.0), None);
1021        // An explicit, non-default station pressure is authoritative (Some, used directly).
1022        assert_eq!(resolve_station_pressure(850.0, 2000.0), Some(850.0));
1023        // At/near sea level the default is used directly (no derivation needed).
1024        assert_eq!(resolve_station_pressure(1013.25, 0.0), Some(1013.25));
1025    }
1026
1027    #[test]
1028    fn test_altitude_affects_density_with_default_pressure() {
1029        // Regression: with the default pressure, altitude MUST lower density (previously the
1030        // air-density path ignored altitude whenever pressure was the sea-level default).
1031        let press = resolve_station_pressure(1013.25, 0.0);
1032        let (rho_sea, _) = calculate_atmosphere(0.0, Some(15.0), press, 50.0);
1033        let press_alt = resolve_station_pressure(1013.25, 2000.0);
1034        let (rho_2km, _) = calculate_atmosphere(2000.0, Some(15.0), press_alt, 50.0);
1035        assert!(
1036            rho_2km < rho_sea * 0.9,
1037            "density at 2000 m ({rho_2km}) should be well below sea level ({rho_sea})"
1038        );
1039
1040        // But an explicit station pressure stays authoritative (no altitude double-count):
1041        // density with an explicit pressure is independent of the altitude field.
1042        let p = resolve_station_pressure(900.0, 2000.0);
1043        let (rho_a, _) = calculate_atmosphere(2000.0, Some(15.0), p, 50.0);
1044        let (rho_b, _) = calculate_atmosphere(0.0, Some(15.0), p, 50.0);
1045        assert!(
1046            (rho_a - rho_b).abs() < 1e-9,
1047            "explicit pressure must ignore altitude"
1048        );
1049    }
1050
1051    #[test]
1052    fn test_resolve_station_temperature_contract() {
1053        // Default 15 C + real altitude => derive ICAO lapse temperature (None).
1054        assert_eq!(resolve_station_temperature(15.0, 2000.0), None);
1055        // An explicit, non-default temperature is authoritative (Some, used directly).
1056        assert_eq!(resolve_station_temperature(-5.0, 2000.0), Some(-5.0));
1057        assert_eq!(resolve_station_temperature(30.0, 2000.0), Some(30.0));
1058        // At/near sea level the default is used directly (no derivation needed).
1059        assert_eq!(resolve_station_temperature(15.0, 0.0), Some(15.0));
1060    }
1061
1062    #[test]
1063    fn test_altitude_only_default_matches_full_icao_standard() {
1064        // Regression: resolving BOTH temperature and pressure for an altitude-only query (defaults
1065        // left in place) must equal the fully-standard atmosphere at that altitude — i.e. altitude
1066        // now drives temperature (ICAO lapse) AND pressure, not just pressure. Validated against
1067        // py_ballisticcalc to ~0.04%. Previously the air held 15 C, leaving density ~7% too thin
1068        // (warm) at 3 km.
1069        for alt in [1000.0, 2000.0, 2500.0, 3000.0] {
1070            let t = resolve_station_temperature(15.0, alt);
1071            let p = resolve_station_pressure(1013.25, alt);
1072            let (rho_resolved, _) = calculate_atmosphere(alt, t, p, 0.0);
1073            let (rho_std, _) = calculate_atmosphere(alt, None, None, 0.0);
1074            assert!(
1075                (rho_resolved - rho_std).abs() < 1e-9,
1076                "alt {alt}: altitude-only default density {rho_resolved} should equal the full \
1077                 ICAO standard {rho_std}"
1078            );
1079            // And it must be denser than the old temperature-held-at-15C behavior (colder = denser).
1080            let (rho_warm, _) = calculate_atmosphere(alt, Some(15.0), p, 0.0);
1081            assert!(
1082                rho_resolved > rho_warm,
1083                "alt {alt}: lapse-temperature density {rho_resolved} should exceed 15 C-held {rho_warm}"
1084            );
1085        }
1086    }
1087
1088    #[test]
1089    fn test_enhanced_atmosphere_with_humidity() {
1090        let (density_dry, speed_dry) = calculate_atmosphere(0.0, None, None, 0.0);
1091        let (density_humid, speed_humid) = calculate_atmosphere(0.0, None, None, 80.0);
1092
1093        // Humid air should be less dense
1094        assert!(density_humid < density_dry);
1095        // Humid air should have slightly higher speed of sound
1096        assert!(speed_humid > speed_dry);
1097    }
1098
1099    #[test]
1100    fn test_enhanced_atmosphere_stratosphere() {
1101        // Test in stratosphere where temperature increases
1102        let (density_20km, speed_20km) = calculate_atmosphere(20000.0, None, None, 0.0);
1103        let (density_30km, speed_30km) = calculate_atmosphere(30000.0, None, None, 0.0);
1104
1105        // Density should decrease with altitude
1106        assert!(density_30km < density_20km);
1107        // Speed of sound should increase due to temperature increase
1108        assert!(speed_30km > speed_20km);
1109    }
1110
1111    #[test]
1112    fn test_enhanced_cimp_density() {
1113        let density = calculate_air_density_cimp(15.0, 1013.25, 0.0);
1114        assert!((density - 1.225).abs() < 0.01);
1115
1116        // Test with humidity
1117        let density_humid = calculate_air_density_cimp(15.0, 1013.25, 50.0);
1118        assert!(density_humid < density);
1119    }
1120
1121    #[test]
1122    fn test_cipm_moist_air_matches_python_reference() {
1123        // Regression for the hPa/Pa mole-fraction slip: p_v (hPa) was divided by the total
1124        // pressure in Pa, making x_v 100x too small and erasing the humidity effect entirely.
1125        // Reference values computed with the validated Python implementation
1126        // (ballistics.physics.atmosphere_icao.calculate_air_density_cipm_icao), same cases as
1127        // the Flask suite's tests/test_atmosphere.py::TestCalculateAirDensityCIPM. Tolerance
1128        // 0.1% (matches that suite's Rust-vs-Python assertion).
1129        let cases = [
1130            (15.0, 1013.25, 50.0, 1.221125867723075),
1131            (30.0, 1000.0, 80.0, 1.1344071877123691),
1132            (-10.0, 1020.0, 20.0, 1.3500610713710515),
1133        ];
1134        for (temp_c, pressure_hpa, humidity_pct, expected) in cases {
1135            let density = calculate_air_density_cipm(temp_c, pressure_hpa, humidity_pct);
1136            let rel_err = ((density - expected) / expected).abs();
1137            assert!(
1138                rel_err < 1e-3,
1139                "CIPM density at {temp_c} C / {pressure_hpa} hPa / {humidity_pct}% RH: \
1140                 got {density}, expected {expected} (rel err {rel_err:.2e} >= 1e-3)"
1141            );
1142        }
1143
1144        // Moist air must be materially lighter than dry air at the same temp/pressure
1145        // (the broken version returned a difference of only ~4e-5 kg/m^3).
1146        let dry = calculate_air_density_cipm(15.0, 1013.25, 0.0);
1147        let moist = calculate_air_density_cipm(15.0, 1013.25, 50.0);
1148        assert!(
1149            dry - moist > 3e-3,
1150            "humidity effect too small: dry {dry} vs 50% RH {moist}"
1151        );
1152    }
1153
1154    #[test]
1155    fn test_variable_lapse_rates() {
1156        // Test that lapse rates change appropriately with altitude
1157        let lapse_tropo = determine_local_lapse_rate(5000.0);
1158        let lapse_strato = determine_local_lapse_rate(25000.0);
1159
1160        assert!((lapse_tropo - (-0.0065)).abs() < 0.0001);
1161        assert!(lapse_strato > 0.0); // Positive lapse rate in stratosphere
1162    }
1163
1164    // ---- MBA-1137: AtmoSock stateless downrange lookup (mirrors the WindSock tests) ----
1165
1166    #[test]
1167    fn test_atmo_sock_empty_falls_back_to_isa() {
1168        let sock = AtmoSock::new(vec![]);
1169        assert!(sock.is_empty());
1170        // Empty sock returns the sea-level ISA reference regardless of distance.
1171        assert_eq!(sock.atmo_for_range(0.0), (15.0, 1013.25, 0.0));
1172        assert_eq!(sock.atmo_for_range(500.0), (15.0, 1013.25, 0.0));
1173    }
1174
1175    #[test]
1176    fn test_atmo_sock_single_segment_holds_beyond_last() {
1177        // One zone until 100 m; it must apply BOTH before and beyond the threshold (unlike wind,
1178        // which zeroes past the last segment — atmosphere holds the last zone).
1179        let sock = AtmoSock::new(vec![(25.0, 1000.0, 30.0, 100.0)]);
1180        assert_eq!(sock.atmo_for_range(50.0), (25.0, 1000.0, 30.0));
1181        assert_eq!(sock.atmo_for_range(100.0), (25.0, 1000.0, 30.0)); // beyond last -> hold
1182        assert_eq!(sock.atmo_for_range(5000.0), (25.0, 1000.0, 30.0));
1183    }
1184
1185    #[test]
1186    fn test_atmo_sock_boundary_is_upper_exclusive() {
1187        // A zone's until_distance_m is exclusive: a query exactly at the boundary rolls to the
1188        // next zone (mirrors WindSock::test_wind_sock_boundary_is_upper_exclusive).
1189        let sock = AtmoSock::new(vec![
1190            (30.0, 1010.0, 80.0, 100.0), // hot/humid near zone
1191            (-5.0, 900.0, 10.0, 200.0),  // cold/thin far zone
1192        ]);
1193        // Just below 100 m -> first zone.
1194        assert_eq!(sock.atmo_for_range(99.999), (30.0, 1010.0, 80.0));
1195        // Exactly 100 m -> second zone.
1196        assert_eq!(sock.atmo_for_range(100.0), (-5.0, 900.0, 10.0));
1197        // Beyond the last boundary -> hold the last zone (NOT zeroed).
1198        assert_eq!(sock.atmo_for_range(200.0), (-5.0, 900.0, 10.0));
1199        assert_eq!(sock.atmo_for_range(1e6), (-5.0, 900.0, 10.0));
1200    }
1201
1202    #[test]
1203    fn test_atmo_sock_sorts_unordered_segments() {
1204        // Segments supplied out of order are sorted by until_distance so the lookup is monotone.
1205        let sock = AtmoSock::new(vec![
1206            (-5.0, 900.0, 10.0, 200.0),
1207            (30.0, 1010.0, 80.0, 100.0),
1208        ]);
1209        assert_eq!(sock.atmo_for_range(50.0), (30.0, 1010.0, 80.0));
1210        assert_eq!(sock.atmo_for_range(150.0), (-5.0, 900.0, 10.0));
1211    }
1212
1213    #[test]
1214    fn test_atmo_sock_orders_nan_thresholds_last() {
1215        let positive_nan = f64::from_bits(0x7ff8_0000_0000_0001);
1216        let negative_nan = f64::from_bits(0xfff8_0000_0000_0002);
1217        let sock = AtmoSock::new(vec![
1218            (97.0, 997.0, 97.0, positive_nan),
1219            (30.0, 1030.0, 30.0, 300.0),
1220            (98.0, 998.0, 98.0, negative_nan),
1221            (10.0, 1010.0, 10.0, 100.0),
1222            (99.0, 999.0, 99.0, f64::NAN),
1223            (20.0, 1020.0, 20.0, 200.0),
1224        ]);
1225
1226        let thresholds: Vec<_> = sock.segments.iter().map(|segment| segment.3).collect();
1227        assert_eq!(&thresholds[..3], &[100.0, 200.0, 300.0]);
1228        assert!(thresholds[3..].iter().all(|threshold| threshold.is_nan()));
1229        assert_eq!(sock.atmo_for_range(f64::NAN), (10.0, 1010.0, 10.0));
1230        assert_eq!(sock.atmo_for_range(350.0), (99.0, 999.0, 99.0));
1231    }
1232
1233    #[test]
1234    fn test_atmo_sock_nan_uses_first_zone() {
1235        let sock = AtmoSock::new(vec![
1236            (30.0, 1010.0, 80.0, 100.0),
1237            (-5.0, 900.0, 10.0, 200.0),
1238        ]);
1239        // NaN can't be ordered; deterministically use the nearest (first) zone rather than panic.
1240        assert_eq!(sock.atmo_for_range(f64::NAN), (30.0, 1010.0, 80.0));
1241    }
1242}