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
6/// ICAO Standard Atmosphere layer definitions
7#[derive(Debug, Clone)]
8struct AtmosphereLayer {
9    /// Base altitude of this layer (m)
10    base_altitude: f64,
11    /// Base temperature at layer start (K)
12    base_temperature: f64,
13    /// Base pressure at layer start (Pa)
14    base_pressure: f64,
15    /// Temperature lapse rate (K/m)
16    lapse_rate: f64,
17}
18
19/// ICAO Standard Atmosphere constants
20const G_ACCEL_MPS2: f64 = 9.80665;
21const R_AIR: f64 = 287.0531; // Specific gas constant for dry air (J/(kg·K))
22const GAMMA: f64 = 1.4; // Heat capacity ratio for air
23
24/// CIPM constants for precise air density calculation
25const R: f64 = 8.314472; // Universal gas constant
26const M_A: f64 = 28.96546e-3; // Molar mass of dry air (kg/mol)
27const M_V: f64 = 18.01528e-3; // Molar mass of water vapor (kg/mol)
28
29/// ICAO Standard Atmosphere layer data up to 84 km
30/// Pressures calculated using barometric formula between layers
31const ICAO_LAYERS: &[AtmosphereLayer] = &[
32    // Troposphere (0 - 11 km)
33    AtmosphereLayer {
34        base_altitude: 0.0,
35        base_temperature: 288.15, // 15°C
36        base_pressure: 101325.0,  // 1013.25 hPa
37        lapse_rate: -0.0065,      // -6.5 K/km
38    },
39    // Tropopause (11 - 20 km)
40    AtmosphereLayer {
41        base_altitude: 11000.0,
42        base_temperature: 216.65, // -56.5°C
43        base_pressure: 22632.1,   // 226.32 hPa
44        lapse_rate: 0.0,          // Isothermal
45    },
46    // Stratosphere 1 (20 - 32 km)
47    AtmosphereLayer {
48        base_altitude: 20000.0,
49        base_temperature: 216.65, // -56.5°C
50        base_pressure: 5474.89,   // 54.75 hPa
51        lapse_rate: 0.001,        // +1 K/km
52    },
53    // Stratosphere 2 (32 - 47 km)
54    AtmosphereLayer {
55        base_altitude: 32000.0,
56        base_temperature: 228.65, // -44.5°C
57        base_pressure: 868.02,    // 8.68 hPa
58        lapse_rate: 0.0028,       // +2.8 K/km
59    },
60    // Stratopause (47 - 51 km)
61    AtmosphereLayer {
62        base_altitude: 47000.0,
63        base_temperature: 270.65, // -2.5°C
64        base_pressure: 110.91,    // 1.11 hPa
65        lapse_rate: 0.0,          // Isothermal
66    },
67    // Mesosphere 1 (51 - 71 km)
68    AtmosphereLayer {
69        base_altitude: 51000.0,
70        base_temperature: 270.65, // -2.5°C
71        base_pressure: 66.94,     // 0.67 hPa
72        lapse_rate: -0.0028,      // -2.8 K/km
73    },
74    // Mesosphere 2 (71 - 84 km)
75    AtmosphereLayer {
76        base_altitude: 71000.0,
77        base_temperature: 214.65, // -58.5°C
78        base_pressure: 3.96,      // 0.04 hPa
79        lapse_rate: -0.002,       // -2.0 K/km
80    },
81];
82
83/// Calculate ICAO Standard Atmosphere conditions at any altitude.
84///
85/// This function implements the full ICAO Standard Atmosphere model with all
86/// atmospheric layers up to 84 km altitude.
87///
88/// # Arguments
89/// * `altitude_m` - Altitude in meters (0 to 84000)
90///
91/// # Returns
92/// Tuple of (temperature_k, pressure_pa)
93fn calculate_icao_standard_atmosphere(altitude_m: f64) -> (f64, f64) {
94    // Clamp altitude to valid range
95    let altitude = altitude_m.clamp(0.0, 84000.0);
96
97    // Find the appropriate atmospheric layer
98    let layer = ICAO_LAYERS
99        .iter()
100        .rev()
101        .find(|layer| altitude >= layer.base_altitude)
102        .unwrap_or(&ICAO_LAYERS[0]);
103
104    let height_diff = altitude - layer.base_altitude;
105    let temperature = layer.base_temperature + layer.lapse_rate * height_diff;
106
107    let pressure = if layer.lapse_rate.abs() < 1e-10 {
108        // Isothermal layer
109        layer.base_pressure * (-G_ACCEL_MPS2 * height_diff / (R_AIR * layer.base_temperature)).exp()
110    } else {
111        // Non-isothermal layer
112        let temp_ratio = temperature / layer.base_temperature;
113        layer.base_pressure * temp_ratio.powf(-G_ACCEL_MPS2 / (layer.lapse_rate * R_AIR))
114    };
115
116    (temperature, pressure)
117}
118
119/// Resolve the station-pressure override for an air-density calculation.
120///
121/// Altitude and pressure are redundant inputs for density. The rule:
122/// * An explicitly-supplied pressure is the authoritative STATION pressure (already
123///   altitude-reduced); it is returned as `Some` and used directly, so altitude is NOT
124///   double-counted.
125/// * When pressure is left at the sea-level standard default (≈1013.25 hPa) while a real
126///   altitude is given, the caller meant "standard atmosphere at this altitude": return
127///   `None` so [`calculate_atmosphere`] derives the station pressure from altitude (ICAO
128///   standard) instead of silently using sea-level density.
129///
130/// Without this, `--altitude` with the default pressure produced sea-level density (altitude
131/// had no effect on drag). The ±0.5 hPa tolerance covers the `29.92 inHg ≈ 1013.21 hPa`
132/// conversion, and `>1 m` avoids triggering at sea level. (Mirrors the existing
133/// `pressure != 29.92` "user override" sentinel used elsewhere in the CLI.)
134pub fn resolve_station_pressure(pressure_hpa: f64, altitude_m: f64) -> Option<f64> {
135    const SEA_LEVEL_HPA: f64 = 1013.25;
136    if (pressure_hpa - SEA_LEVEL_HPA).abs() < 0.5 && altitude_m.abs() > 1.0 {
137        None // pressure left at default + real altitude → derive station pressure from altitude
138    } else {
139        Some(pressure_hpa) // explicit station pressure is authoritative
140    }
141}
142
143/// Resolve the temperature override for an air-density calculation, mirroring
144/// [`resolve_station_pressure`].
145///
146/// * An explicitly-supplied temperature is authoritative (returned as `Some`).
147/// * When temperature is left at the sea-level standard default (15 °C) while a real altitude
148///   is given, the caller meant "standard atmosphere at this altitude": return `None` so
149///   [`calculate_atmosphere`] applies the ICAO lapse-rate temperature for that altitude
150///   (≈ −6.5 °C/km).
151///
152/// Without this, `--altitude` with the default temperature held the air at 15 °C, which
153/// under-estimates density (warm air is thinner) by ~2.4% at 1 km up to ~7% at 3 km versus the
154/// standard atmosphere — validated against py_ballisticcalc, which derives both temperature and
155/// pressure from altitude. The 0.1 °C tolerance matches the `59 °F = 15.0 °C` default exactly,
156/// and `>1 m` avoids triggering at sea level. A shooter at a genuinely non-standard temperature
157/// at altitude should pass an explicit temperature (same contract as station pressure).
158pub fn resolve_station_temperature(temperature_c: f64, altitude_m: f64) -> Option<f64> {
159    const SEA_LEVEL_TEMP_C: f64 = 15.0;
160    if (temperature_c - SEA_LEVEL_TEMP_C).abs() < 0.1 && altitude_m.abs() > 1.0 {
161        None // temperature left at default + real altitude → derive ICAO lapse temperature
162    } else {
163        Some(temperature_c) // explicit temperature is authoritative
164    }
165}
166
167/// Return the station temperature and pressure that [`calculate_atmosphere`] will use after
168/// applying the default-at-altitude resolution rules.
169pub fn resolve_station_conditions(
170    temperature_c: f64,
171    pressure_hpa: f64,
172    altitude_m: f64,
173) -> (f64, f64) {
174    let temp_override = resolve_station_temperature(temperature_c, altitude_m);
175    let press_override = resolve_station_pressure(pressure_hpa, altitude_m);
176    let (std_temp_k, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
177    let temp_c = temp_override.unwrap_or(std_temp_k - 273.15);
178    let pressure_hpa = press_override.unwrap_or(std_pressure_pa / 100.0);
179    (temp_c, pressure_hpa)
180}
181
182/// Enhanced atmospheric calculation with ICAO Standard Atmosphere.
183///
184/// # Arguments
185/// * `altitude_m` - Altitude in meters
186/// * `temp_override_c` - Temperature override in Celsius (None for standard)
187/// * `press_override_hpa` - Pressure override in hPa (None for standard)
188/// * `humidity_percent` - Humidity percentage (0-100)
189///
190/// # Returns
191/// Tuple of (air_density_kg_m3, speed_of_sound_mps)
192pub fn calculate_atmosphere(
193    altitude_m: f64,
194    temp_override_c: Option<f64>,
195    press_override_hpa: Option<f64>,
196    humidity_percent: f64,
197) -> (f64, f64) {
198    // Get standard atmosphere conditions or use overrides
199    let (temp_k, pressure_pa) = if temp_override_c.is_some() && press_override_hpa.is_some() {
200        // Both overrides provided
201        (
202            temp_override_c.unwrap() + 273.15,
203            press_override_hpa.unwrap() * 100.0,
204        )
205    } else {
206        // Get ICAO standard conditions
207        let (std_temp_k, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
208
209        let final_temp_k = if let Some(temp_c) = temp_override_c {
210            temp_c + 273.15
211        } else {
212            std_temp_k
213        };
214
215        let final_pressure_pa = if let Some(press_hpa) = press_override_hpa {
216            press_hpa * 100.0
217        } else {
218            std_pressure_pa
219        };
220
221        (final_temp_k, final_pressure_pa)
222    };
223
224    // Humidity clamp shared by the CIPM density and the moist speed of sound.
225    let humidity_clamped = humidity_percent.clamp(0.0, 100.0);
226    let temp_c = temp_k - 273.15;
227
228    // Density: CIPM-2007 is the single canonical humid-air density model. Every solver
229    // (cli_api / monte_carlo / ffi / fast_trajectory) reaches this one formula through
230    // calculate_atmosphere, so there is no second (Arden-Buck ideal-gas) density path to drift.
231    let density = calculate_air_density_cimp(temp_c, pressure_pa / 100.0, humidity_clamped);
232
233    // Speed of sound in moist air (Cramer, 1993). Extracted into `moist_speed_of_sound` so the
234    // integrators can share it; its vapor pressure comes from the SAME IAPWS saturation formula
235    // (`enhanced_saturation_vapor_pressure`) + CIPM enhancement factor that the density above
236    // uses, so ONE vapor formula feeds both density and c.
237    let speed_of_sound = moist_speed_of_sound(temp_k, pressure_pa, humidity_clamped);
238
239    (density, speed_of_sound)
240}
241
242/// Speed of sound in moist air (Cramer, 1993).
243///
244/// The water-vapor mole fraction is derived from the SAME IAPWS saturation vapor pressure
245/// (`enhanced_saturation_vapor_pressure`) and CIPM-2007 enhancement factor used by
246/// [`calculate_air_density_cimp`], so a single vapor formula feeds both density and c.
247///
248/// # Arguments
249/// * `temp_k` - Temperature in Kelvin
250/// * `pressure_pa` - Total (station) pressure in Pa
251/// * `humidity_percent` - Relative humidity percentage (0-100)
252///
253/// # Returns
254/// Speed of sound in m/s
255pub fn moist_speed_of_sound(temp_k: f64, pressure_pa: f64, humidity_percent: f64) -> f64 {
256    let humidity_clamped = humidity_percent.clamp(0.0, 100.0);
257    let temp_c = temp_k - 273.15;
258
259    // Water-vapor partial pressure p_v = RH * f * p_sv, matching CIPM's x_v exactly. p_sv is in
260    // hPa (enhanced_saturation_vapor_pressure returns hPa), so convert to Pa before forming the
261    // mole fraction against the Pa total pressure.
262    let p_sv_hpa = enhanced_saturation_vapor_pressure(temp_k);
263    let f = enhanced_enhancement_factor(pressure_pa, temp_c);
264    let vapor_pressure_pa = humidity_clamped / 100.0 * f * p_sv_hpa * 100.0;
265
266    // Cap the mole fraction at the physical maximum of 1 and guard pressure_pa == 0 (a 0 hPa
267    // override would otherwise give +Inf -> NaN speed of sound).
268    let mole_fraction_vapor = (vapor_pressure_pa / pressure_pa.max(f64::MIN_POSITIVE)).min(1.0);
269
270    // Heat-capacity ratio and gas constant for moist air (mole-fraction coefficients). 0.378 is
271    // the dry-air molecular-weight ratio (0.6078 would belong to specific humidity, not mole
272    // fraction).
273    let gamma_moist = GAMMA * (1.0 - mole_fraction_vapor * 0.062);
274    let r_moist = R_AIR * (1.0 + 0.378 * mole_fraction_vapor);
275
276    (gamma_moist * r_moist * temp_k).sqrt()
277}
278
279/// Enhanced air density calculation using CIPM formula with ICAO atmosphere.
280///
281/// # Arguments
282/// * `temp_c` - Temperature in Celsius
283/// * `pressure_hpa` - Pressure in hPa
284/// * `humidity_percent` - Humidity percentage (0-100)
285///
286/// # Returns
287/// Air density in kg/m³
288pub fn calculate_air_density_cimp(temp_c: f64, pressure_hpa: f64, humidity_percent: f64) -> f64 {
289    let t_k = temp_c + 273.15;
290
291    // Enhanced saturation vapor pressure calculation
292    let p_sv = enhanced_saturation_vapor_pressure(t_k);
293
294    let pressure_pa = pressure_hpa * 100.0;
295
296    // Enhanced enhancement factor with temperature dependence. CIPM constants use Pa.
297    let f = enhanced_enhancement_factor(pressure_pa, temp_c);
298
299    // Vapor pressure with clamping. p_sv is in hPa (enhanced_saturation_vapor_pressure
300    // returns hPa — its critical-pressure constant is 220640 hPa), so p_v is in hPa too.
301    let p_v = humidity_percent.clamp(0.0, 100.0) / 100.0 * f * p_sv;
302
303    // Convert the vapor pressure to Pa BEFORE forming the mole fraction: the divisor below
304    // is in Pa. Dividing the hPa p_v by the Pa total made x_v 100x too small, which erased
305    // the humidity term and returned essentially dry-air density (e.g. 15 C / 1013.25 hPa /
306    // 50% RH gave ~1.2254 instead of the CIPM-2007 moist value ~1.2211 — moist air is
307    // LIGHTER than dry air).
308    let p_v_pa = p_v * 100.0;
309
310    // Floor the pressure divisor (mirrors calculate_atmosphere): a 0 hPa pressure would
311    // otherwise make x_v = +Inf -> NaN density. No-op for all valid (>0) pressures.
312    let p_pa = pressure_pa.max(f64::MIN_POSITIVE);
313
314    // Mole fraction of water vapor (capped at the physical maximum of 1)
315    let x_v = (p_v_pa / p_pa).min(1.0);
316
317    // Enhanced compressibility factor. CIPM virial constants use Pa.
318    let z = enhanced_compressibility_factor(p_pa, t_k, x_v);
319
320    // Calculate density with enhanced precision
321    // Note: parentheses are important here for correct operator precedence
322    ((p_pa * M_A) / (z * R * t_k)) * (1.0 - x_v * (1.0 - M_V / M_A))
323}
324
325/// Enhanced saturation vapor pressure calculation.
326/// Uses the IAPWS-IF97 formulation for high precision.
327#[inline(always)]
328fn enhanced_saturation_vapor_pressure(t_k: f64) -> f64 {
329    // IAPWS-IF97 coefficients for better accuracy
330    const A: [f64; 6] = [
331        -7.85951783,
332        1.84408259,
333        -11.7866497,
334        22.6807411,
335        -15.9618719,
336        1.80122502,
337    ];
338
339    // Ensure temperature is positive and reasonable
340    let t_k_safe = t_k.max(173.15); // -100°C minimum
341
342    let tau = 1.0 - t_k_safe / 647.096; // Critical temperature of water
343    let ln_p_ratio = (647.096 / t_k_safe)
344        * (A[0] * tau
345            + A[1] * tau.powf(1.5)
346            + A[2] * tau.powf(3.0)
347            + A[3] * tau.powf(3.5)
348            + A[4] * tau.powf(4.0)
349            + A[5] * tau.powf(7.5));
350
351    220640.0 * ln_p_ratio.exp() // Critical pressure in hPa (22.064 MPa)
352}
353
354/// CIPM-2007 enhancement factor `f = alpha + beta*p + gamma*t^2` (p in Pa, t in Celsius).
355#[inline(always)]
356fn enhanced_enhancement_factor(p: f64, t: f64) -> f64 {
357    const ALPHA: f64 = 1.00062;
358    const BETA: f64 = 3.14e-8;
359    const GAMMA: f64 = 5.6e-7;
360
361    ALPHA + BETA * p + GAMMA * t * t
362}
363
364/// CIPM-2007 compressibility factor `Z` (virial expansion, second order in `p/T`).
365#[inline(always)]
366fn enhanced_compressibility_factor(p: f64, t_k: f64, x_v: f64) -> f64 {
367    // CIPM-2007 molar virial coefficients (p in Pa, t in Celsius).
368    const A0: f64 = 1.58123e-6;
369    const A1: f64 = -2.9331e-8;
370    const A2: f64 = 1.1043e-10;
371    const B0: f64 = 5.707e-6;
372    const B1: f64 = -2.051e-8;
373    const C0: f64 = 1.9898e-4;
374    const C1: f64 = -2.376e-6;
375    const D: f64 = 1.83e-11;
376    const E: f64 = -0.765e-8;
377
378    // Ensure temperature is positive
379    let t_k_safe = t_k.max(173.15); // -100°C minimum
380    let t = t_k_safe - 273.15;
381    let p_t = p / t_k_safe;
382
383    let z_second_order =
384        1.0 - p_t * (A0 + A1 * t + A2 * t * t + (B0 + B1 * t) * x_v + (C0 + C1 * t) * x_v * x_v);
385
386    let z_third_order = p_t * p_t * (D + E * x_v * x_v);
387
388    z_second_order + z_third_order
389}
390
391/// Enhanced local atmospheric calculation with variable lapse rates.
392///
393/// # Arguments
394/// * `altitude_m` - Altitude in meters
395/// * `base_alt` - Base altitude for calculation
396/// * `base_temp_c` - Base temperature in Celsius
397/// * `base_press_hpa` - Base pressure in hPa
398/// * `base_ratio` - Base density ratio
399///
400/// # Returns
401/// Tuple of (air_density_kg_m3, speed_of_sound_mps)
402pub fn get_local_atmosphere(
403    altitude_m: f64,
404    base_alt: f64,
405    base_temp_c: f64,
406    base_press_hpa: f64,
407    base_ratio: f64,
408) -> (f64, f64) {
409    let (temp_k, _pressure_pa, density) =
410        local_temp_pressure_density(altitude_m, base_alt, base_temp_c, base_press_hpa, base_ratio);
411
412    // Dry speed of sound. 401.874 ~ gamma * R_air; kept exactly for back-compat with existing
413    // callers (get_local_atmosphere_humid uses the precise moist formula instead).
414    let speed_of_sound = (temp_k * 401.874).sqrt();
415
416    (density, speed_of_sound)
417}
418
419/// Humidity-aware companion to [`get_local_atmosphere`]: identical local density, but the speed
420/// of sound is the moist-air value ([`moist_speed_of_sound`]) evaluated at the LOCAL temperature
421/// and pressure.
422///
423/// [`get_local_atmosphere`] is intentionally left unchanged (dry speed of sound) for
424/// API/back-compat; call this variant only where a real relative humidity is available.
425///
426/// # Arguments
427/// * `altitude_m` - Query altitude in meters
428/// * `base_alt` - Base (station) altitude in meters
429/// * `base_temp_c` - Base temperature in Celsius
430/// * `base_press_hpa` - Base pressure in hPa
431/// * `base_ratio` - Base density ratio (density / 1.225)
432/// * `humidity_percent` - Relative humidity percentage (0-100)
433///
434/// # Returns
435/// Tuple of (air_density_kg_m3, moist_speed_of_sound_mps)
436pub fn get_local_atmosphere_humid(
437    altitude_m: f64,
438    base_alt: f64,
439    base_temp_c: f64,
440    base_press_hpa: f64,
441    base_ratio: f64,
442    humidity_percent: f64,
443) -> (f64, f64) {
444    let (temp_k, pressure_pa, density) =
445        local_temp_pressure_density(altitude_m, base_alt, base_temp_c, base_press_hpa, base_ratio);
446    (density, moist_speed_of_sound(temp_k, pressure_pa, humidity_percent))
447}
448
449/// Shared local temperature / pressure / density computation for [`get_local_atmosphere`] and
450/// [`get_local_atmosphere_humid`]. Returns `(local_temp_k, local_pressure_pa, density_kg_m3)`.
451#[inline]
452fn local_temp_pressure_density(
453    altitude_m: f64,
454    base_alt: f64,
455    base_temp_c: f64,
456    base_press_hpa: f64,
457    base_ratio: f64,
458) -> (f64, f64, f64) {
459    // Round altitude to the nearest meter for caching in Python
460    let altitude_m_rounded = altitude_m.round();
461    let height_diff = altitude_m_rounded - base_alt;
462
463    // Determine appropriate lapse rate based on altitude
464    let lapse_rate = determine_local_lapse_rate(altitude_m_rounded);
465
466    // Calculate temperature with variable lapse rate
467    let temp_c = base_temp_c + lapse_rate * height_diff;
468    let temp_k = temp_c + 273.15;
469    let base_temp_k = base_temp_c + 273.15;
470
471    // Calculate pressure using barometric formula
472    let pressure_hpa = if lapse_rate.abs() < 1e-10 {
473        // Isothermal atmosphere
474        base_press_hpa * (-G_ACCEL_MPS2 * height_diff / (R_AIR * base_temp_k)).exp()
475    } else {
476        // Non-isothermal atmosphere
477        let temp_ratio = temp_k / base_temp_k;
478        base_press_hpa * temp_ratio.powf(-G_ACCEL_MPS2 / (lapse_rate * R_AIR))
479    };
480
481    // Enhanced density calculation
482    let density_ratio = base_ratio * (base_temp_k * pressure_hpa) / (base_press_hpa * temp_k);
483    let density = density_ratio * 1.225;
484
485    (temp_k, pressure_hpa * 100.0, density)
486}
487
488/// Determine local lapse rate based on altitude and atmospheric layer.
489#[inline(always)]
490fn determine_local_lapse_rate(altitude_m: f64) -> f64 {
491    // Find the current atmospheric layer to get appropriate lapse rate
492    let layer = ICAO_LAYERS
493        .iter()
494        .rev()
495        .find(|layer| altitude_m >= layer.base_altitude)
496        .unwrap_or(&ICAO_LAYERS[0]);
497
498    layer.lapse_rate
499}
500
501/// Direct atmosphere calculation for simple cases.
502///
503/// # Arguments
504/// * `density` - Pre-computed air density
505/// * `speed_of_sound` - Pre-computed speed of sound
506///
507/// # Returns
508/// Tuple of (air_density, speed_of_sound) - just passes through the values
509#[inline(always)]
510pub fn get_direct_atmosphere(density: f64, speed_of_sound: f64) -> (f64, f64) {
511    (density, speed_of_sound)
512}
513
514/// Legacy function name for backwards compatibility
515pub fn calculate_air_density_cipm(temp_c: f64, pressure_hpa: f64, humidity_percent: f64) -> f64 {
516    calculate_air_density_cimp(temp_c, pressure_hpa, humidity_percent)
517}
518
519/// A single downrange-referenced atmosphere zone:
520/// `(temp_c, pressure_hpa, humidity_percent, until_distance_m)`.
521///
522/// The T/P/H are the STATION-REFERENCED conditions (defined at the shooter base altitude) that
523/// apply from the previous segment's threshold out to `until_distance_m`. This mirrors
524/// [`crate::wind::WindSegment`]'s `(speed, angle, until_distance)` shape so the two segmented
525/// models compose the same way (wind by X, atmosphere by X, altitude lapse by Y).
526pub type AtmoSegment = (f64, f64, f64, f64);
527
528/// Downrange-segmented atmosphere handler (MBA-1137), the density analogue of
529/// [`crate::wind::WindSock`].
530///
531/// Holds a set of station-referenced atmosphere zones ordered by their `until_distance_m`
532/// threshold and answers a stateless downrange lookup ([`AtmoSock::atmo_for_range`]).
533///
534/// The zone T/P/H are the base (shooter-altitude) conditions for that stretch of range; the
535/// solver swaps them into the SAME `get_local_atmosphere` altitude-lapse pipeline that a
536/// single-station solve uses, so the downrange (X) zone and the vertical (Y) altitude lapse
537/// compose orthogonally without double-counting (the zone sets the base tuple, the lapse
538/// multiplies on top of it).
539#[derive(Debug, Clone)]
540pub struct AtmoSock {
541    /// Zones sorted ascending by `until_distance_m` (segment slot 3).
542    segments: Vec<AtmoSegment>,
543}
544
545impl AtmoSock {
546    /// Create a new `AtmoSock` from station-referenced atmosphere zones.
547    ///
548    /// Each segment is `(temp_c, pressure_hpa, humidity_percent, until_distance_m)`. Segments are
549    /// sorted by `until_distance_m` (NaN thresholds are ordered last, matching `WindSock::new`).
550    pub fn new(mut segments: Vec<AtmoSegment>) -> Self {
551        // Sort by until_distance, treating NaN as greater than any value (mirrors WindSock::new).
552        segments.sort_by(|a, b| a.3.partial_cmp(&b.3).unwrap_or(std::cmp::Ordering::Greater));
553        AtmoSock { segments }
554    }
555
556    /// True when this sock carries no zones (a lookup falls back to sea-level ISA).
557    pub fn is_empty(&self) -> bool {
558        self.segments.is_empty()
559    }
560
561    /// Stateless downrange lookup of the active zone's `(temp_c, pressure_hpa, humidity_percent)`.
562    ///
563    /// Selection matches [`crate::wind::WindSock::vector_for_range_stateless`]: the first segment
564    /// whose `until_distance_m` STRICTLY exceeds `downrange_m` wins (thresholds are upper-exclusive).
565    /// Unlike wind — which returns zero past the last threshold — the LAST zone is used for any
566    /// distance at or beyond the final threshold (there is no "zero atmosphere"). An empty sock
567    /// returns the sea-level ISA reference `(15 C, 1013.25 hPa, 0% RH)`.
568    ///
569    /// This is stateless and safe for numerical integration (the same X may be queried repeatedly
570    /// or out of order across RK substeps).
571    pub fn atmo_for_range(&self, downrange_m: f64) -> (f64, f64, f64) {
572        if self.segments.is_empty() {
573            return (15.0, 1013.25, 0.0); // sea-level ISA fallback
574        }
575        // NaN X can't be ordered; use the first (nearest) zone deterministically.
576        if downrange_m.is_nan() {
577            let s = self.segments[0];
578            return (s.0, s.1, s.2);
579        }
580        for seg in &self.segments {
581            if downrange_m < seg.3 {
582                return (seg.0, seg.1, seg.2);
583            }
584        }
585        // Beyond the final threshold: hold the last zone (no zeroing).
586        let last = self.segments[self.segments.len() - 1];
587        (last.0, last.1, last.2)
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594
595    // ---- MBA-1136: CIPM-2007 as the single canonical humid-air density ----
596
597    /// Gate 1: dry sea-level (15 C, 1013.25 hPa, 0% RH) must stay at the ISA reference — density
598    /// 1.225 +- 0.002 kg/m^3 and speed of sound 340.3 +- 0.6 m/s. CIPM-2007 at 0% RH reduces to
599    /// dry-air ideal gas to within rounding, so this is essentially unchanged from the pre-CIPM
600    /// baseline (baseline was 1.225012 / 340.294; now 1.225521 / 340.294 — the tiny density bump
601    /// is CIPM compressibility + exact molar mass, the speed of sound is bit-identical).
602    #[test]
603    fn test_mba1136_dry_sea_level_reference() {
604        let (density, sos) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 0.0);
605        assert!(
606            (density - 1.225).abs() < 0.002,
607            "dry sea-level density {density} not within 1.225 +- 0.002"
608        );
609        assert!(
610            (sos - 340.3).abs() < 0.6,
611            "dry sea-level speed of sound {sos} not within 340.3 +- 0.6"
612        );
613    }
614
615    /// Gate 2: humid air (15 C, 1013.25 hPa, 50% RH) is the CIPM-2007 value (~1.2211 +- 0.002),
616    /// STRICTLY lighter than dry air at the same T/P, with a speed of sound slightly ABOVE dry.
617    #[test]
618    fn test_mba1136_humid_lighter_than_dry() {
619        let (dry_rho, dry_sos) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 0.0);
620        let (moist_rho, moist_sos) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 50.0);
621
622        assert!(
623            (moist_rho - 1.2211).abs() < 0.002,
624            "50% RH density {moist_rho} not within CIPM 1.2211 +- 0.002"
625        );
626        assert!(
627            moist_rho < dry_rho,
628            "moist air ({moist_rho}) must be lighter than dry ({dry_rho})"
629        );
630        assert!(
631            moist_sos > dry_sos,
632            "moist speed of sound ({moist_sos}) must exceed dry ({dry_sos})"
633        );
634    }
635
636    /// Gate 3: density is monotone-decreasing in humidity (100% RH < 50% RH < 0% RH).
637    #[test]
638    fn test_mba1136_density_monotone_in_humidity() {
639        let (rho_0, _) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 0.0);
640        let (rho_50, _) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 50.0);
641        let (rho_100, _) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 100.0);
642        assert!(
643            rho_100 < rho_50 && rho_50 < rho_0,
644            "humidity monotonicity violated: 100%={rho_100}, 50%={rho_50}, 0%={rho_0}"
645        );
646    }
647
648    /// rank 28: `calculate_atmosphere`'s density is exactly `calculate_air_density_cimp` — there
649    /// is a single canonical humid-air density path (no separate Arden-Buck ideal-gas density).
650    #[test]
651    fn test_mba1136_atmosphere_density_is_cipm() {
652        for (t, p, h) in [
653            (15.0, 1013.25, 0.0),
654            (15.0, 1013.25, 50.0),
655            (30.0, 1000.0, 80.0),
656            (-10.0, 1020.0, 20.0),
657        ] {
658            let (density, _) = calculate_atmosphere(0.0, Some(t), Some(p), h);
659            let cipm = calculate_air_density_cimp(t, p, h);
660            assert_eq!(
661                density, cipm,
662                "calculate_atmosphere density must equal CIPM at {t}C/{p}hPa/{h}%"
663            );
664        }
665    }
666
667    /// rank 9: the extracted `moist_speed_of_sound` is exactly what `calculate_atmosphere`
668    /// returns (behavior-identical extraction), across dry and humid conditions.
669    #[test]
670    fn test_mba1136_moist_speed_of_sound_extraction() {
671        for (t, p, h) in [
672            (15.0, 1013.25, 0.0),
673            (15.0, 1013.25, 50.0),
674            (25.0, 900.0, 100.0),
675        ] {
676            let (_, sos) = calculate_atmosphere(0.0, Some(t), Some(p), h);
677            let extracted = moist_speed_of_sound(t + 273.15, p * 100.0, h);
678            assert_eq!(
679                sos, extracted,
680                "extracted moist_speed_of_sound must match calculate_atmosphere at {t}C/{p}hPa/{h}%"
681            );
682        }
683    }
684
685    /// rank 9: `get_local_atmosphere` is behavior-locked after the shared-helper refactor.
686    /// Reference values captured from the pre-refactor implementation
687    /// (base: 500 m, 10 C, 950 hPa, ratio 1.05).
688    #[test]
689    fn test_mba1136_get_local_atmosphere_unchanged() {
690        let (d0, c0) = get_local_atmosphere(500.0, 500.0, 10.0, 950.0, 1.05);
691        assert!((d0 - 1.286250000000).abs() < 1e-9, "local density@500m drifted: {d0}");
692        assert!((c0 - 337.328657395129).abs() < 1e-9, "local sos@500m drifted: {c0}");
693        let (d1, c1) = get_local_atmosphere(1500.0, 500.0, 10.0, 950.0, 1.05);
694        assert!((d1 - 1.165201643681).abs() < 1e-9, "local density@1500m drifted: {d1}");
695        assert!((c1 - 333.434314520866).abs() < 1e-9, "local sos@1500m drifted: {c1}");
696    }
697
698    /// rank 9: `get_local_atmosphere_humid` returns the SAME density as `get_local_atmosphere`,
699    /// and at 0% RH its speed of sound reduces to the dry value (within the 401.874-vs-gamma*R
700    /// constant rounding). At real humidity the speed of sound exceeds the dry value.
701    #[test]
702    fn test_mba1136_get_local_atmosphere_humid() {
703        let (d_dry, c_dry) = get_local_atmosphere(1500.0, 500.0, 10.0, 950.0, 1.05);
704        let (d_h0, c_h0) = get_local_atmosphere_humid(1500.0, 500.0, 10.0, 950.0, 1.05, 0.0);
705        assert_eq!(d_dry, d_h0, "humid variant must not change density");
706        assert!(
707            (c_h0 - c_dry).abs() < 1e-3,
708            "0% RH humid sos {c_h0} should match dry sos {c_dry}"
709        );
710        let (_, c_h80) = get_local_atmosphere_humid(1500.0, 500.0, 10.0, 950.0, 1.05, 80.0);
711        assert!(c_h80 > c_dry, "humid sos {c_h80} should exceed dry {c_dry}");
712    }
713
714    #[test]
715    fn test_icao_standard_atmosphere() {
716        // Test sea level
717        let (temp, press) = calculate_icao_standard_atmosphere(0.0);
718        assert!((temp - 288.15).abs() < 0.01);
719        assert!((press - 101325.0).abs() < 1.0);
720
721        // Test tropopause
722        let (temp_11km, press_11km) = calculate_icao_standard_atmosphere(11000.0);
723        assert!((temp_11km - 216.65).abs() < 0.01);
724        assert!(press_11km < 101325.0);
725
726        // Test stratosphere
727        let (temp_25km, _) = calculate_icao_standard_atmosphere(25000.0);
728        assert!(temp_25km > 216.65); // Temperature increases in stratosphere
729    }
730
731    #[test]
732    fn test_enhanced_atmosphere_sea_level() {
733        let (density, speed) = calculate_atmosphere(0.0, None, None, 0.0);
734        assert!((density - 1.225).abs() < 0.01);
735        assert!((speed - 340.0).abs() < 1.0);
736    }
737
738    #[test]
739    fn test_resolve_station_pressure_contract() {
740        // Default sea-level pressure + real altitude => derive from altitude (None).
741        assert_eq!(resolve_station_pressure(1013.25, 2000.0), None);
742        // 29.92 inHg ≈ 1013.21 hPa is also treated as the default (within tolerance).
743        assert_eq!(resolve_station_pressure(1013.21, 2000.0), None);
744        // An explicit, non-default station pressure is authoritative (Some, used directly).
745        assert_eq!(resolve_station_pressure(850.0, 2000.0), Some(850.0));
746        // At/near sea level the default is used directly (no derivation needed).
747        assert_eq!(resolve_station_pressure(1013.25, 0.0), Some(1013.25));
748    }
749
750    #[test]
751    fn test_altitude_affects_density_with_default_pressure() {
752        // Regression: with the default pressure, altitude MUST lower density (previously the
753        // air-density path ignored altitude whenever pressure was the sea-level default).
754        let press = resolve_station_pressure(1013.25, 0.0);
755        let (rho_sea, _) = calculate_atmosphere(0.0, Some(15.0), press, 50.0);
756        let press_alt = resolve_station_pressure(1013.25, 2000.0);
757        let (rho_2km, _) = calculate_atmosphere(2000.0, Some(15.0), press_alt, 50.0);
758        assert!(
759            rho_2km < rho_sea * 0.9,
760            "density at 2000 m ({rho_2km}) should be well below sea level ({rho_sea})"
761        );
762
763        // But an explicit station pressure stays authoritative (no altitude double-count):
764        // density with an explicit pressure is independent of the altitude field.
765        let p = resolve_station_pressure(900.0, 2000.0);
766        let (rho_a, _) = calculate_atmosphere(2000.0, Some(15.0), p, 50.0);
767        let (rho_b, _) = calculate_atmosphere(0.0, Some(15.0), p, 50.0);
768        assert!(
769            (rho_a - rho_b).abs() < 1e-9,
770            "explicit pressure must ignore altitude"
771        );
772    }
773
774    #[test]
775    fn test_resolve_station_temperature_contract() {
776        // Default 15 C + real altitude => derive ICAO lapse temperature (None).
777        assert_eq!(resolve_station_temperature(15.0, 2000.0), None);
778        // An explicit, non-default temperature is authoritative (Some, used directly).
779        assert_eq!(resolve_station_temperature(-5.0, 2000.0), Some(-5.0));
780        assert_eq!(resolve_station_temperature(30.0, 2000.0), Some(30.0));
781        // At/near sea level the default is used directly (no derivation needed).
782        assert_eq!(resolve_station_temperature(15.0, 0.0), Some(15.0));
783    }
784
785    #[test]
786    fn test_altitude_only_default_matches_full_icao_standard() {
787        // Regression: resolving BOTH temperature and pressure for an altitude-only query (defaults
788        // left in place) must equal the fully-standard atmosphere at that altitude — i.e. altitude
789        // now drives temperature (ICAO lapse) AND pressure, not just pressure. Validated against
790        // py_ballisticcalc to ~0.04%. Previously the air held 15 C, leaving density ~7% too thin
791        // (warm) at 3 km.
792        for alt in [1000.0, 2000.0, 2500.0, 3000.0] {
793            let t = resolve_station_temperature(15.0, alt);
794            let p = resolve_station_pressure(1013.25, alt);
795            let (rho_resolved, _) = calculate_atmosphere(alt, t, p, 0.0);
796            let (rho_std, _) = calculate_atmosphere(alt, None, None, 0.0);
797            assert!(
798                (rho_resolved - rho_std).abs() < 1e-9,
799                "alt {alt}: altitude-only default density {rho_resolved} should equal the full \
800                 ICAO standard {rho_std}"
801            );
802            // And it must be denser than the old temperature-held-at-15C behavior (colder = denser).
803            let (rho_warm, _) = calculate_atmosphere(alt, Some(15.0), p, 0.0);
804            assert!(
805                rho_resolved > rho_warm,
806                "alt {alt}: lapse-temperature density {rho_resolved} should exceed 15 C-held {rho_warm}"
807            );
808        }
809    }
810
811    #[test]
812    fn test_enhanced_atmosphere_with_humidity() {
813        let (density_dry, speed_dry) = calculate_atmosphere(0.0, None, None, 0.0);
814        let (density_humid, speed_humid) = calculate_atmosphere(0.0, None, None, 80.0);
815
816        // Humid air should be less dense
817        assert!(density_humid < density_dry);
818        // Humid air should have slightly higher speed of sound
819        assert!(speed_humid > speed_dry);
820    }
821
822    #[test]
823    fn test_enhanced_atmosphere_stratosphere() {
824        // Test in stratosphere where temperature increases
825        let (density_20km, speed_20km) = calculate_atmosphere(20000.0, None, None, 0.0);
826        let (density_30km, speed_30km) = calculate_atmosphere(30000.0, None, None, 0.0);
827
828        // Density should decrease with altitude
829        assert!(density_30km < density_20km);
830        // Speed of sound should increase due to temperature increase
831        assert!(speed_30km > speed_20km);
832    }
833
834    #[test]
835    fn test_enhanced_cimp_density() {
836        let density = calculate_air_density_cimp(15.0, 1013.25, 0.0);
837        assert!((density - 1.225).abs() < 0.01);
838
839        // Test with humidity
840        let density_humid = calculate_air_density_cimp(15.0, 1013.25, 50.0);
841        assert!(density_humid < density);
842    }
843
844    #[test]
845    fn test_cipm_moist_air_matches_python_reference() {
846        // Regression for the hPa/Pa mole-fraction slip: p_v (hPa) was divided by the total
847        // pressure in Pa, making x_v 100x too small and erasing the humidity effect entirely.
848        // Reference values computed with the validated Python implementation
849        // (ballistics.physics.atmosphere_icao.calculate_air_density_cipm_icao), same cases as
850        // the Flask suite's tests/test_atmosphere.py::TestCalculateAirDensityCIPM. Tolerance
851        // 0.1% (matches that suite's Rust-vs-Python assertion).
852        let cases = [
853            (15.0, 1013.25, 50.0, 1.221125867723075),
854            (30.0, 1000.0, 80.0, 1.1344071877123691),
855            (-10.0, 1020.0, 20.0, 1.3500610713710515),
856        ];
857        for (temp_c, pressure_hpa, humidity_pct, expected) in cases {
858            let density = calculate_air_density_cipm(temp_c, pressure_hpa, humidity_pct);
859            let rel_err = ((density - expected) / expected).abs();
860            assert!(
861                rel_err < 1e-3,
862                "CIPM density at {temp_c} C / {pressure_hpa} hPa / {humidity_pct}% RH: \
863                 got {density}, expected {expected} (rel err {rel_err:.2e} >= 1e-3)"
864            );
865        }
866
867        // Moist air must be materially lighter than dry air at the same temp/pressure
868        // (the broken version returned a difference of only ~4e-5 kg/m^3).
869        let dry = calculate_air_density_cipm(15.0, 1013.25, 0.0);
870        let moist = calculate_air_density_cipm(15.0, 1013.25, 50.0);
871        assert!(
872            dry - moist > 3e-3,
873            "humidity effect too small: dry {dry} vs 50% RH {moist}"
874        );
875    }
876
877    #[test]
878    fn test_variable_lapse_rates() {
879        // Test that lapse rates change appropriately with altitude
880        let lapse_tropo = determine_local_lapse_rate(5000.0);
881        let lapse_strato = determine_local_lapse_rate(25000.0);
882
883        assert!((lapse_tropo - (-0.0065)).abs() < 0.0001);
884        assert!(lapse_strato > 0.0); // Positive lapse rate in stratosphere
885    }
886
887    // ---- MBA-1137: AtmoSock stateless downrange lookup (mirrors the WindSock tests) ----
888
889    #[test]
890    fn test_atmo_sock_empty_falls_back_to_isa() {
891        let sock = AtmoSock::new(vec![]);
892        assert!(sock.is_empty());
893        // Empty sock returns the sea-level ISA reference regardless of distance.
894        assert_eq!(sock.atmo_for_range(0.0), (15.0, 1013.25, 0.0));
895        assert_eq!(sock.atmo_for_range(500.0), (15.0, 1013.25, 0.0));
896    }
897
898    #[test]
899    fn test_atmo_sock_single_segment_holds_beyond_last() {
900        // One zone until 100 m; it must apply BOTH before and beyond the threshold (unlike wind,
901        // which zeroes past the last segment — atmosphere holds the last zone).
902        let sock = AtmoSock::new(vec![(25.0, 1000.0, 30.0, 100.0)]);
903        assert_eq!(sock.atmo_for_range(50.0), (25.0, 1000.0, 30.0));
904        assert_eq!(sock.atmo_for_range(100.0), (25.0, 1000.0, 30.0)); // beyond last -> hold
905        assert_eq!(sock.atmo_for_range(5000.0), (25.0, 1000.0, 30.0));
906    }
907
908    #[test]
909    fn test_atmo_sock_boundary_is_upper_exclusive() {
910        // A zone's until_distance_m is exclusive: a query exactly at the boundary rolls to the
911        // next zone (mirrors WindSock::test_wind_sock_boundary_is_upper_exclusive).
912        let sock = AtmoSock::new(vec![
913            (30.0, 1010.0, 80.0, 100.0), // hot/humid near zone
914            (-5.0, 900.0, 10.0, 200.0),  // cold/thin far zone
915        ]);
916        // Just below 100 m -> first zone.
917        assert_eq!(sock.atmo_for_range(99.999), (30.0, 1010.0, 80.0));
918        // Exactly 100 m -> second zone.
919        assert_eq!(sock.atmo_for_range(100.0), (-5.0, 900.0, 10.0));
920        // Beyond the last boundary -> hold the last zone (NOT zeroed).
921        assert_eq!(sock.atmo_for_range(200.0), (-5.0, 900.0, 10.0));
922        assert_eq!(sock.atmo_for_range(1e6), (-5.0, 900.0, 10.0));
923    }
924
925    #[test]
926    fn test_atmo_sock_sorts_unordered_segments() {
927        // Segments supplied out of order are sorted by until_distance so the lookup is monotone.
928        let sock = AtmoSock::new(vec![
929            (-5.0, 900.0, 10.0, 200.0),
930            (30.0, 1010.0, 80.0, 100.0),
931        ]);
932        assert_eq!(sock.atmo_for_range(50.0), (30.0, 1010.0, 80.0));
933        assert_eq!(sock.atmo_for_range(150.0), (-5.0, 900.0, 10.0));
934    }
935
936    #[test]
937    fn test_atmo_sock_nan_uses_first_zone() {
938        let sock = AtmoSock::new(vec![
939            (30.0, 1010.0, 80.0, 100.0),
940            (-5.0, 900.0, 10.0, 200.0),
941        ]);
942        // NaN can't be ordered; deterministically use the nearest (first) zone rather than panic.
943        assert_eq!(sock.atmo_for_range(f64::NAN), (30.0, 1010.0, 80.0));
944    }
945}