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