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