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}