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 crate::gravity::Gravity;
7use std::cmp::Ordering;
8
9/// ICAO Standard Atmosphere layer definitions
10#[derive(Debug, Clone)]
11struct AtmosphereLayer {
12 /// Base geopotential height of this layer (m)
13 base_altitude: f64,
14 /// Base temperature at layer start (K)
15 base_temperature: f64,
16 /// Base pressure at layer start (Pa)
17 base_pressure: f64,
18 /// Temperature lapse rate (K/m)
19 lapse_rate: f64,
20}
21
22/// ICAO Standard Atmosphere constants
23// ICAO reference quantities are defined using standard Earth gravity, independently of the
24// acceleration chosen for an internal simulation.
25const G_ACCEL_MPS2: f64 = crate::constants::G_ACCEL_MPS2;
26const R_AIR: f64 = 287.0531; // Specific gas constant for dry air (J/(kg·K))
27const GAMMA: f64 = 1.4; // Heat capacity ratio for air
28const GEOPOTENTIAL_EARTH_RADIUS_M: f64 = 6_356_766.0;
29const MIN_GEOMETRIC_ALTITUDE_M: f64 = -5000.0;
30const MAX_GEOMETRIC_ALTITUDE_M: f64 = 84000.0;
31const MIN_STANDARD_GEOPOTENTIAL_HEIGHT_M: f64 = -5000.0;
32const MAX_STANDARD_GEOPOTENTIAL_HEIGHT_M: f64 = 84000.0;
33
34/// CIPM constants for precise air density calculation
35const R: f64 = 8.314472; // Universal gas constant
36const M_A: f64 = 28.96546e-3; // Molar mass of dry air (kg/mol)
37const M_V: f64 = 18.01528e-3; // Molar mass of water vapor (kg/mol)
38
39/// ICAO Standard Atmosphere layer data up to 84 km
40/// Pressures calculated using barometric formula between layers
41const ICAO_LAYERS: &[AtmosphereLayer] = &[
42 // Troposphere (-5 - 11 km; the sea-level base extrapolates smoothly below 0 m)
43 AtmosphereLayer {
44 base_altitude: 0.0,
45 base_temperature: 288.15, // 15°C
46 base_pressure: 101325.0, // 1013.25 hPa
47 lapse_rate: -0.0065, // -6.5 K/km
48 },
49 // Tropopause (11 - 20 km)
50 AtmosphereLayer {
51 base_altitude: 11000.0,
52 base_temperature: 216.65, // -56.5°C
53 base_pressure: 22632.1, // 226.32 hPa
54 lapse_rate: 0.0, // Isothermal
55 },
56 // Stratosphere 1 (20 - 32 km)
57 AtmosphereLayer {
58 base_altitude: 20000.0,
59 base_temperature: 216.65, // -56.5°C
60 base_pressure: 5474.89, // 54.75 hPa
61 lapse_rate: 0.001, // +1 K/km
62 },
63 // Stratosphere 2 (32 - 47 km)
64 AtmosphereLayer {
65 base_altitude: 32000.0,
66 base_temperature: 228.65, // -44.5°C
67 base_pressure: 868.02, // 8.68 hPa
68 lapse_rate: 0.0028, // +2.8 K/km
69 },
70 // Stratopause (47 - 51 km)
71 AtmosphereLayer {
72 base_altitude: 47000.0,
73 base_temperature: 270.65, // -2.5°C
74 base_pressure: 110.91, // 1.11 hPa
75 lapse_rate: 0.0, // Isothermal
76 },
77 // Mesosphere 1 (51 - 71 km)
78 AtmosphereLayer {
79 base_altitude: 51000.0,
80 base_temperature: 270.65, // -2.5°C
81 base_pressure: 66.94, // 0.67 hPa
82 lapse_rate: -0.0028, // -2.8 K/km
83 },
84 // Mesosphere 2 (71 - 84 km)
85 AtmosphereLayer {
86 base_altitude: 71000.0,
87 base_temperature: 214.65, // -58.5°C
88 base_pressure: 3.96, // 0.04 hPa
89 lapse_rate: -0.002, // -2.0 K/km
90 },
91];
92
93/// Calculate ICAO Standard Atmosphere conditions at any altitude.
94///
95/// This function implements the full ICAO Standard Atmosphere model with all
96/// atmospheric layers up to 84 km altitude.
97///
98/// # Arguments
99/// * `altitude_m` - Geometric altitude above mean sea level in meters (-5000 to 84000; values
100/// outside are clamped). It is converted to geopotential height before layer evaluation.
101///
102/// # Returns
103/// Tuple of (temperature_k, pressure_pa)
104pub(crate) fn calculate_icao_standard_atmosphere(altitude_m: f64) -> (f64, f64) {
105 let geometric_altitude = altitude_m.clamp(MIN_GEOMETRIC_ALTITUDE_M, MAX_GEOMETRIC_ALTITUDE_M);
106 let geopotential_height = geometric_to_geopotential_height_m(geometric_altitude).clamp(
107 MIN_STANDARD_GEOPOTENTIAL_HEIGHT_M,
108 MAX_STANDARD_GEOPOTENTIAL_HEIGHT_M,
109 );
110
111 // Find the appropriate atmospheric layer
112 let layer = ICAO_LAYERS
113 .iter()
114 .rev()
115 .find(|layer| geopotential_height >= layer.base_altitude)
116 .unwrap_or(&ICAO_LAYERS[0]);
117
118 let height_diff = geopotential_height - layer.base_altitude;
119 let temperature = layer.base_temperature + layer.lapse_rate * height_diff;
120
121 let pressure = if layer.lapse_rate.abs() < 1e-10 {
122 // Isothermal layer
123 layer.base_pressure * (-G_ACCEL_MPS2 * height_diff / (R_AIR * layer.base_temperature)).exp()
124 } else {
125 // Non-isothermal layer
126 let temp_ratio = temperature / layer.base_temperature;
127 layer.base_pressure * temp_ratio.powf(-G_ACCEL_MPS2 / (layer.lapse_rate * R_AIR))
128 };
129
130 (temperature, pressure)
131}
132
133/// Convert physical geometric altitude to the geopotential height used by ICAO layer tables.
134#[inline]
135fn geometric_to_geopotential_height_m(geometric_altitude_m: f64) -> f64 {
136 GEOPOTENTIAL_EARTH_RADIUS_M * geometric_altitude_m
137 / (GEOPOTENTIAL_EARTH_RADIUS_M + geometric_altitude_m)
138}
139
140/// Whether a pressure lies inside the legacy default sentinel's band, within 0.5 hPa of the
141/// 1013.25 hPa sea-level standard. [`resolve_station_pressure`] reads an unmarked value there
142/// as omitted at any altitude above 1 m.
143pub(crate) fn in_legacy_default_pressure_band(pressure_hpa: f64) -> bool {
144 const SEA_LEVEL_HPA: f64 = 1013.25;
145 (pressure_hpa - SEA_LEVEL_HPA).abs() < 0.5
146}
147
148/// Whether a temperature lies inside the legacy default sentinel's band, within 0.1 °C of the
149/// 15 °C sea-level standard. [`resolve_station_temperature`] reads an unmarked value there as
150/// omitted at any altitude above 1 m.
151pub(crate) fn in_legacy_default_temperature_band(temperature_c: f64) -> bool {
152 const SEA_LEVEL_TEMP_C: f64 = 15.0;
153 (temperature_c - SEA_LEVEL_TEMP_C).abs() < 0.1
154}
155
156/// Resolve the station-pressure override for an air-density calculation, by the legacy
157/// default-sentinel convention.
158///
159/// Presence-blind legacy convention: a bare `f64` cannot say whether it was supplied, so a
160/// sea-level default at a real altitude is read as omitted. The rule:
161/// * A pressure within 0.5 hPa of the 1013.25 hPa sea-level standard at an altitude above 1 m
162/// is read as "omitted, standard atmosphere at this altitude": `None`, so
163/// [`calculate_atmosphere`] derives the station pressure from altitude (ICAO standard)
164/// instead of flying sea-level density there.
165/// * Any other value is returned as `Some` and used as the STATION pressure, so altitude is
166/// not double-counted.
167///
168/// The ±0.5 hPa tolerance covers the `29.92 inHg ≈ 1013.21 hPa` conversion, and `>1 m` avoids
169/// triggering at sea level. A real reading inside that band (QNH 1031 hPa at 150 m reduces to
170/// 1012.86 hPa) is indistinguishable from the default here. A caller that knows presence uses
171/// [`resolve_station_conditions_with_presence`], which flies a supplied value as given.
172pub fn resolve_station_pressure(pressure_hpa: f64, altitude_m: f64) -> Option<f64> {
173 if in_legacy_default_pressure_band(pressure_hpa) && altitude_m.abs() > 1.0 {
174 None // pressure left at default + real altitude → derive station pressure from altitude
175 } else {
176 Some(pressure_hpa) // explicit station pressure is authoritative
177 }
178}
179
180/// Resolve the temperature override for an air-density calculation, by the legacy
181/// default-sentinel convention, mirroring [`resolve_station_pressure`].
182///
183/// Presence-blind legacy convention: a bare `f64` cannot say whether it was supplied, so a
184/// sea-level default at a real altitude is read as omitted.
185/// * A temperature within 0.1 °C of the 15 °C sea-level standard at an altitude above 1 m is
186/// read as "omitted, standard atmosphere at this altitude": `None`, so
187/// [`calculate_atmosphere`] applies the ICAO lapse-rate temperature for that altitude
188/// (≈ −6.5 °C/km).
189/// * Any other value is returned as `Some` and used as given.
190///
191/// Without the omitted case, `--altitude` with the default temperature held the air at 15 °C,
192/// which under-estimates density by ~2.4% at 1 km up to ~7% at 3 km versus the standard
193/// atmosphere (validated against py_ballisticcalc). The 0.1 °C tolerance matches the
194/// `59 °F = 15.0 °C` default exactly, and `>1 m` avoids triggering at sea level. A real 15 °C
195/// reading at altitude is indistinguishable from the default here; a caller that knows presence
196/// uses [`resolve_station_conditions_with_presence`].
197pub fn resolve_station_temperature(temperature_c: f64, altitude_m: f64) -> Option<f64> {
198 if in_legacy_default_temperature_band(temperature_c) && altitude_m.abs() > 1.0 {
199 None // temperature left at default + real altitude → derive ICAO lapse temperature
200 } else {
201 Some(temperature_c) // explicit temperature is authoritative
202 }
203}
204
205/// Return the station temperature and pressure that [`calculate_atmosphere`] will use after
206/// applying the default-at-altitude resolution rules.
207///
208/// A thin, byte-identical delegate to
209/// [`resolve_station_conditions_with_pressure_mode`] at [`PressureReferenceMode::Absolute`]
210/// (MBA-1397) — kept as a separate name/signature so no existing caller needs to change.
211///
212/// Presence-blind: both values go through the legacy default sentinels
213/// ([`resolve_station_temperature`], [`resolve_station_pressure`]). See
214/// [`resolve_station_conditions_with_presence`].
215pub fn resolve_station_conditions(
216 temperature_c: f64,
217 pressure_hpa: f64,
218 altitude_m: f64,
219) -> (f64, f64) {
220 resolve_station_conditions_with_pressure_mode(
221 temperature_c,
222 pressure_hpa,
223 altitude_m,
224 PressureReferenceMode::Absolute,
225 )
226}
227
228/// Whether a station-pressure input is already absolute (station) pressure, or a sea-level-
229/// corrected altimeter setting (QNH / "barometer" reading) that must be reduced to station
230/// pressure at the shooter's altitude before use (MBA-1397).
231///
232/// Kestrel, AB Mobile, Shooter, JBM, and Hornady 4DOF all let the user declare which one they
233/// are entering. Previously this engine only supported [`Absolute`](Self::Absolute) — an
234/// entered sea-level-corrected barometer/METAR value at a real altitude was silently treated
235/// as station pressure, over-stating air density and under-stating drop.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
237#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
238pub enum PressureReferenceMode {
239 /// The input is already absolute station pressure — today's meaning, and the default, so
240 /// every existing caller that never sets this is byte-identical to pre-MBA-1397 behavior.
241 #[default]
242 Absolute,
243 /// The input is a sea-level-corrected altimeter setting (QNH) and must be reduced to
244 /// station pressure at the target altitude via [`reduce_qnh_to_station_pressure`].
245 Qnh,
246}
247
248/// Reduce a sea-level-corrected altimeter setting (QNH) to the station pressure at
249/// `altitude_m` (MBA-1397), using the altimeter-setting relation (MBA-1605):
250///
251/// `station = QNH * (1 + (L*h/T0) * (P0/QNH)^n)^(1/n)`, with `n = -L*R/g`
252///
253/// for the ICAO troposphere's lapse rate `L` (-0.0065 K/m), sea-level temperature `T0`
254/// (288.15 K) and sea-level pressure `P0` (1013.25 hPa), so `n` = 0.190263. This is what an
255/// altimeter setting means: an altimeter set to QNH reads field elevation on the ground, so the
256/// station pressure's ISA pressure altitude is the QNH's own pressure altitude plus `h`. Written
257/// that way it is the additive form `station = P0 * ((QNH/P0)^n + L*h/T0)^(1/n)`; the
258/// algebraically equal form above is the one evaluated, because it returns QNH bit-exactly at
259/// `h = 0`.
260///
261/// This is the altimeter-setting relation on ICAO constants with geopotential height. It has the
262/// shape of the NWS/ASOS altimeter-setting algorithm but is not that algorithm, which uses its
263/// own rounded constants (`n` = 0.190284 and `k` = 8.4228807e-5, `P0^n * 0.0065/T0` at 288 K),
264/// adds the field elevation as entered rather than its geopotential height, and subtracts a
265/// 0.3 hPa sensor offset. Even without that offset, NWS/ASOS gives a station pressure about
266/// 0.1 hPa lower than this function at 1500 m and 0.3 to 0.45 hPa lower at 4000 to 5000 m (QNH
267/// 950 to 1050 hPa).
268///
269/// Before 0.49.0 this function scaled the standard pressure ratio by QNH,
270/// `station = QNH * (1 + L*h/T0)^(1/n)`, and called that the altimeter-setting reduction. It is
271/// not: the two agree only at QNH = 1013.25 hPa or at `h = 0`, and the ratio form ran 0.49 hPa
272/// low for a QNH of 1030 hPa at 1500 m.
273///
274/// `h` is GEOPOTENTIAL height, not the geometric altitude the caller supplies — this function
275/// converts the one to the other first (`geometric_to_geopotential_height_m`), as ISA requires.
276/// Substituting geometric altitude directly into that expression reproduces the reduction only
277/// to about 0.04 hPa at 1500 m, which is why the worked values below are not what the bare
278/// formula gives.
279///
280/// Reuses the SAME troposphere layer (`ICAO_LAYERS[0]`) and physical constants
281/// (`G_ACCEL_MPS2`, `R_AIR`) that `calculate_icao_standard_atmosphere` (private to this crate)
282/// uses for its own non-isothermal-layer pressure formula, rather than re-hardcoding the
283/// lapse rate / sea-level temperature / exponent a second time. `altitude_m` is clamped and
284/// converted to geopotential height exactly like `calculate_icao_standard_atmosphere`, and
285/// `(P0/QNH)^n` is exactly 1 at QNH = 1013.25 hPa. At QNH = 1013.25 hPa the reduction therefore
286/// equals the ICAO standard station pressure to rounding, but not bit for bit at every altitude
287/// (for example 1012.0492647136854 against 1012.0492647136847 at 10 m; the two agree exactly at
288/// 150 m). Callers that must stay bit-identical for an omitted pressure keep the mode they
289/// already used for it.
290///
291/// The reduction is only physically meaningful within the troposphere (the altitude range
292/// real shooting takes place in); it is not re-derived per-layer for higher altitudes. No
293/// station pressure exists once the QNH's pressure altitude plus `h` reaches `T0/-L`
294/// (44,331 m, where the troposphere's lapse rate reaches absolute zero), nor for a non-positive
295/// QNH; there this returns NaN, which callers refuse as a non-finite pressure.
296pub fn reduce_qnh_to_station_pressure(qnh_hpa: f64, altitude_m: f64) -> f64 {
297 let geometric_altitude = altitude_m.clamp(MIN_GEOMETRIC_ALTITUDE_M, MAX_GEOMETRIC_ALTITUDE_M);
298 let geopotential_height = geometric_to_geopotential_height_m(geometric_altitude).clamp(
299 MIN_STANDARD_GEOPOTENTIAL_HEIGHT_M,
300 MAX_STANDARD_GEOPOTENTIAL_HEIGHT_M,
301 );
302 let layer = &ICAO_LAYERS[0]; // troposphere: the only layer the altimeter-setting relation covers
303 let height_term = layer.lapse_rate * geopotential_height / layer.base_temperature;
304 let exponent = -G_ACCEL_MPS2 / (layer.lapse_rate * R_AIR); // 1/n
305 let sea_level_hpa = layer.base_pressure / 100.0;
306 let setting_term = (sea_level_hpa / qnh_hpa).powf(exponent.recip()); // (P0/QNH)^n
307 qnh_hpa * (1.0 + height_term * setting_term).powf(exponent)
308}
309
310/// Mode-aware counterpart of [`resolve_station_pressure`] (MBA-1397). Does NOT change that
311/// function's signature or behavior — [`PressureReferenceMode::Absolute`] is a pure
312/// passthrough to it — so this is a new function alongside the original, not a replacement.
313///
314/// [`PressureReferenceMode::Qnh`] reduces `pressure_hpa` (interpreted as a QNH) to station
315/// pressure via [`reduce_qnh_to_station_pressure`] and returns it as `Some(..)`
316/// UNCONDITIONALLY, bypassing the default-sentinel ambiguity `resolve_station_pressure` must
317/// use to infer omission from a bare `f64`. This matters: a QNH the caller explicitly declared
318/// should never be silently re-interpreted as an omitted default merely because the REDUCED
319/// number happens to land within the sentinel's tolerance of the 1013.25 hPa sea-level
320/// constant (a real, reachable case — e.g. a high-pressure day's QNH reducing to ~1013 hPa at
321/// a few hundred meters of elevation).
322pub fn resolve_station_pressure_with_mode(
323 pressure_hpa: f64,
324 altitude_m: f64,
325 mode: PressureReferenceMode,
326) -> Option<f64> {
327 match mode {
328 PressureReferenceMode::Absolute => resolve_station_pressure(pressure_hpa, altitude_m),
329 PressureReferenceMode::Qnh => {
330 Some(reduce_qnh_to_station_pressure(pressure_hpa, altitude_m))
331 }
332 }
333}
334
335/// Mode-aware counterpart of [`resolve_station_conditions`] (MBA-1397): identical for
336/// [`PressureReferenceMode::Absolute`] (byte-identical delegate — [`resolve_station_conditions`]
337/// itself now forwards here), and reduces an explicit QNH pressure to station pressure via
338/// [`resolve_station_pressure_with_mode`] for [`PressureReferenceMode::Qnh`] before applying
339/// the same standard-atmosphere fallback used when either input is left at its sea-level
340/// default.
341///
342/// Presence-blind: this is [`resolve_station_conditions_with_presence`] with
343/// [`StationAtmospherePresence::NEITHER`], bit for bit. The temperature sentinel runs in BOTH
344/// arms, so a typed 15 °C at altitude flies the ICAO lapse temperature here even with a
345/// declared QNH; only the pressure sentinel is bypassed in the `Qnh` arm. A caller that knows
346/// which values were supplied uses [`resolve_station_conditions_with_presence`].
347pub fn resolve_station_conditions_with_pressure_mode(
348 temperature_c: f64,
349 pressure_hpa: f64,
350 altitude_m: f64,
351 pressure_mode: PressureReferenceMode,
352) -> (f64, f64) {
353 resolve_station_conditions_with_presence(
354 temperature_c,
355 pressure_hpa,
356 altitude_m,
357 pressure_mode,
358 StationAtmospherePresence::NEITHER,
359 )
360}
361
362/// Which of a station temperature / pressure pair the caller actually supplied (0.49.0).
363///
364/// A field that is not supplied is resolved by the pre-0.49.0 default-sentinel rule
365/// ([`resolve_station_temperature`] / [`resolve_station_pressure_with_mode`]): the ICAO
366/// standard at |altitude| > 1 m when the value is the sea-level default, the value itself
367/// otherwise. That makes [`Self::NEITHER`] exactly the old behaviour of every presence-blind
368/// API, which is why it is the `Default`, and makes a supplied field authoritative whatever
369/// its value: a typed 15 °C, 59 °F, 1013.25 hPa or 29.92 inHg at altitude flies as typed.
370///
371/// Pass it to [`resolve_station_conditions_with_presence`], or to one of the
372/// `*_with_station_presence` twins of the solver, zero, Monte Carlo, lead, hold-curve and
373/// truing entry points.
374#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
375pub struct StationAtmospherePresence {
376 /// The station temperature was a reading the caller supplied.
377 pub temperature_supplied: bool,
378 /// The station pressure (or the QNH it is reduced from) was a reading the caller supplied.
379 pub pressure_supplied: bool,
380}
381
382impl StationAtmospherePresence {
383 /// Neither value was supplied: the pre-0.49.0 default-sentinel rule, bit for bit.
384 pub const NEITHER: Self = Self::new(false, false);
385 /// Both values were supplied: both fly as given (a declared QNH after its reduction).
386 pub const BOTH: Self = Self::new(true, true);
387
388 /// Presence from two flags.
389 pub const fn new(temperature_supplied: bool, pressure_supplied: bool) -> Self {
390 Self {
391 temperature_supplied,
392 pressure_supplied,
393 }
394 }
395
396 /// Presence of a temperature reading and a pressure reading.
397 pub const fn from_readings(temperature: StationReading, pressure: StationReading) -> Self {
398 Self::new(temperature.supplied, pressure.supplied)
399 }
400}
401
402/// A station temperature or pressure on a presence-carrying request (0.49.0): the value the
403/// engine is handed and whether the caller supplied it.
404///
405/// On the wire it is a bare JSON number when supplied and ABSENT when omitted. The type's own
406/// `Deserialize` rejects a JSON `null`; the truing DTOs (`TruingModelInputsV1`,
407/// `WindTruingRequest`) read a `null` field as omitted, as the `card.*` requests do. An omitted
408/// reading carries the surface's legacy default (59 °F / 29.92 inHg on
409/// the truing DTOs, the metric CLI's 1013.25 hPa expressed in inHg, and so on), so it resolves
410/// exactly as that default did before 0.49.0. Use [`Self::omitted`] for that, never a bare
411/// `Option`: at |altitude| <= 1 m an omitted value flies as itself, so the default it carries
412/// is part of the result.
413#[derive(Debug, Clone, Copy, PartialEq)]
414pub struct StationReading {
415 /// The value handed to the engine.
416 pub value: f64,
417 /// Whether the caller supplied it. `false` means the legacy default-sentinel rule decides.
418 pub supplied: bool,
419}
420
421impl StationReading {
422 /// A reading the caller supplied: it flies as given.
423 pub const fn supplied(value: f64) -> Self {
424 Self {
425 value,
426 supplied: true,
427 }
428 }
429
430 /// No reading: `default_value` is the surface's legacy default, resolved by the
431 /// pre-0.49.0 rule (ICAO standard at a real altitude when it is the sea-level default).
432 pub const fn omitted(default_value: f64) -> Self {
433 Self {
434 value: default_value,
435 supplied: false,
436 }
437 }
438
439 /// `true` when no reading was supplied. Used as `skip_serializing_if`.
440 pub fn is_omitted(&self) -> bool {
441 !self.supplied
442 }
443}
444
445impl serde::Serialize for StationReading {
446 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
447 serializer.serialize_f64(self.value)
448 }
449}
450
451impl<'de> serde::Deserialize<'de> for StationReading {
452 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
453 <f64 as serde::Deserialize>::deserialize(deserializer).map(Self::supplied)
454 }
455}
456
457/// Resolve the station temperature and pressure a solve flies, honouring which of them the
458/// caller supplied (0.49.0). This is the presence-aware resolver every surface that knows
459/// presence goes through.
460///
461/// For each field:
462/// * supplied: returned as given; a pressure declared as QNH ([`PressureReferenceMode::Qnh`])
463/// is first reduced to station pressure with [`reduce_qnh_to_station_pressure`];
464/// * omitted: resolved exactly as before 0.49.0, through [`resolve_station_temperature`] /
465/// [`resolve_station_pressure_with_mode`], then the ICAO standard at `altitude_m` when the
466/// value is the sea-level default at a real altitude.
467///
468/// With [`StationAtmospherePresence::NEITHER`] the result is bit-identical to
469/// [`resolve_station_conditions_with_pressure_mode`] (which now delegates here). With
470/// [`StationAtmospherePresence::BOTH`] in [`PressureReferenceMode::Absolute`] it returns the
471/// pair unchanged.
472pub fn resolve_station_conditions_with_presence(
473 temperature_c: f64,
474 pressure_hpa: f64,
475 altitude_m: f64,
476 pressure_mode: PressureReferenceMode,
477 presence: StationAtmospherePresence,
478) -> (f64, f64) {
479 let temp_override = if presence.temperature_supplied {
480 Some(temperature_c)
481 } else {
482 resolve_station_temperature(temperature_c, altitude_m)
483 };
484 let press_override = if presence.pressure_supplied {
485 Some(match pressure_mode {
486 PressureReferenceMode::Absolute => pressure_hpa,
487 PressureReferenceMode::Qnh => reduce_qnh_to_station_pressure(pressure_hpa, altitude_m),
488 })
489 } else {
490 resolve_station_pressure_with_mode(pressure_hpa, altitude_m, pressure_mode)
491 };
492 if let (Some(temp_c), Some(pressure_hpa)) = (temp_override, press_override) {
493 // Value-neutral fast path: the ICAO evaluation below would be discarded.
494 return (temp_c, pressure_hpa);
495 }
496 let (std_temp_k, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
497 (
498 temp_override.unwrap_or(std_temp_k - 273.15),
499 press_override.unwrap_or(std_pressure_pa / 100.0),
500 )
501}
502
503// ---------------------------------------------------------------------------------------
504// Station-pressure plausibility
505// ---------------------------------------------------------------------------------------
506
507/// The lowest ratio of station pressure to the ICAO standard pressure at the station's
508/// altitude that [`station_pressure_plausibility`] treats as weather. See that function for
509/// where the band comes from.
510pub const STATION_PRESSURE_MIN_PLAUSIBLE_RATIO: f64 = 0.85;
511/// The highest ratio of station pressure to the ICAO standard pressure at the station's
512/// altitude that [`station_pressure_plausibility`] treats as weather.
513pub const STATION_PRESSURE_MAX_PLAUSIBLE_RATIO: f64 = 1.08;
514
515/// How an explicit absolute station pressure compares with the ICAO standard pressure at the
516/// altitude it was sent with. Returned by [`station_pressure_plausibility`].
517#[derive(Debug, Clone, Copy, PartialEq)]
518pub struct StationPressurePlausibility {
519 /// The ICAO standard pressure at the altitude, in pascals.
520 pub icao_standard_pa: f64,
521 /// The station pressure divided by `icao_standard_pa`.
522 pub ratio: f64,
523 /// Whether `ratio` lies outside [`STATION_PRESSURE_MIN_PLAUSIBLE_RATIO`] to
524 /// [`STATION_PRESSURE_MAX_PLAUSIBLE_RATIO`] (both edges inside), or is not finite.
525 pub implausible: bool,
526}
527
528/// Compare an explicit absolute station pressure, in pascals, with the ICAO standard pressure
529/// at `altitude_m`, and say whether the two can describe the same station.
530///
531/// An explicit absolute pressure is authoritative wherever the engine takes one, so a pressure
532/// that belongs to some other altitude is flown as it was sent. The two common cases are a
533/// sea-level figure sent at a real elevation: a 101325 Pa placeholder, or a weather report's
534/// altimeter setting (QNH) sent as station pressure instead of as a QNH. At Cusco's 3310.128 m
535/// a 101325 Pa placeholder is 1.503 times the standard pressure there. The reverse, a true
536/// station reading sent with the wrong altitude (omitted, or in the wrong unit), shows up as a
537/// ratio far from 1 too. Callers use this to warn; nothing here changes a pressure.
538///
539/// The band, [`STATION_PRESSURE_MIN_PLAUSIBLE_RATIO`] to
540/// [`STATION_PRESSURE_MAX_PLAUSIBLE_RATIO`], is wide enough that recorded weather never leaves
541/// it. The WMO's table of weather and climate extremes (as of 31 July 2025) lists these
542/// sea-level pressure records:
543///
544/// - 870 hPa in the eye of Typhoon Tip, 12 October 1979, the lowest (tornadoes excluded);
545/// - 1083.8 hPa at Agata, Russia, 31 December 1968, the highest at a station below 750 m;
546/// - 1089.1 hPa at Tosontsengel, Mongolia, 30 December 2004, the highest above 750 m.
547///
548/// Against the 1013.25 hPa standard those are 0.859, 1.070 and 1.075. They are sea-level
549/// figures. At a station above sea level the high extreme moves toward standard, because the
550/// record highs come from cold air: reduced to sea level through a column denser than the
551/// standard one, a station pressure nearer standard becomes the record sea-level figure. Cold
552/// air moves the low side the other way. Under a cold column an ordinary sea-level pressure
553/// puts a high station further below standard than the sea-level figure is: a 3000 m station
554/// under a 243 K column reads 0.948 times the ICAO standard there with 1013.25 hPa at sea
555/// level, and 0.917 with 980 hPa. Both stay well above 0.85.
556///
557/// The band catches gross mismatches, not every placeholder: 101325 Pa stays inside it up to
558/// about 645 m, where it is already 8% above standard.
559///
560/// This is for absolute station pressure only. A QNH is a sea-level figure by declaration, so
561/// comparing it with the standard at the station's altitude would say nothing about it.
562pub fn station_pressure_plausibility(
563 station_pressure_pa: f64,
564 altitude_m: f64,
565) -> StationPressurePlausibility {
566 let (_, icao_standard_pa) = calculate_icao_standard_atmosphere(altitude_m);
567 let ratio = station_pressure_pa / icao_standard_pa;
568 let implausible = !(STATION_PRESSURE_MIN_PLAUSIBLE_RATIO
569 ..=STATION_PRESSURE_MAX_PLAUSIBLE_RATIO)
570 .contains(&ratio);
571 StationPressurePlausibility {
572 icao_standard_pa,
573 ratio,
574 implausible,
575 }
576}
577
578// ---------------------------------------------------------------------------------------
579// MBA-1366: density altitude as a direct atmosphere input
580// ---------------------------------------------------------------------------------------
581
582/// NWS pressure-altitude and FAA-rule constants — the ONE copy.
583///
584/// Both directions of the density-altitude model now live in this module: the forward
585/// [`faa_rule_density_altitude_ft`] (the engine's reported density altitude) and its exact
586/// inverse [`resolve_atmosphere_for_density_altitude`] (MBA-1366, the DA entry mode). They
587/// used to live apart, with these constants duplicated here because the forward formula sat
588/// in `pdf_dope_card` — first in the `ballistics` binary, then (from 0.33.0's Task 10) in
589/// this library but behind `#[cfg(feature = "pdf")]`, which this unconditional module could
590/// not import across without breaking the `--no-default-features` wasm32 build CI
591/// type-checks.
592///
593/// The fix was to move the formula DOWN rather than import it UP: `atmosphere` is
594/// unconditional, so the forward function lives here and
595/// `pdf_dope_card::calculate_density_altitude` now delegates to it, bit for bit. That leaves
596/// no second copy to drift, and it is what lets the `atmosphere.density_altitude` bridge
597/// command exist in every build with the bridge — including wasm32, which has no PDF card.
598///
599/// `src/main.rs`'s `density_altitude_round_trips_through_the_dope_card_formula*` tests still
600/// pin forward and inverse together, now through a single set of constants.
601const DA_NWS_SEA_LEVEL_HPA: f64 = 1013.25;
602const DA_NWS_PRESSURE_ALT_FT: f64 = 145_366.45;
603const DA_NWS_EXPONENT: f64 = 0.190_284;
604const DA_NWS_SEA_LEVEL_TEMP_F: f64 = 59.0;
605const DA_NWS_LAPSE_F_PER_1000FT: f64 = 3.57;
606/// The FAA rule of thumb, "120 ft of density altitude per °C away from standard", expressed
607/// per °F because the NWS constants above are Fahrenheit.
608const DA_FAA_RULE_FT_PER_DEG_F: f64 = 120.0 * 5.0 / 9.0;
609const DA_FT_TO_M: f64 = 0.3048;
610
611/// NWS pressure altitude, feet, from STATION pressure in hPa.
612///
613/// The published NWS station-pressure equation
614/// (<https://www.weather.gov/media/epz/wxcalc/pressureAltitude.pdf>):
615///
616/// ```text
617/// PA_ft = 145366.45 * (1 - (P_hPa / 1013.25)^0.190284)
618/// ```
619///
620/// Station pressure, never an altimeter setting (MBA-643): a QNH already corrects to sea
621/// level and would report the pressure altitude of the sea rather than of the firing point.
622pub fn nws_pressure_altitude_ft(station_pressure_hpa: f64) -> f64 {
623 DA_NWS_PRESSURE_ALT_FT
624 * (1.0 - (station_pressure_hpa / DA_NWS_SEA_LEVEL_HPA).powf(DA_NWS_EXPONENT))
625}
626
627/// Density altitude by the FAA rule of thumb, feet: NWS pressure altitude plus 120 ft for
628/// every °C the station is away from ISA temperature at that pressure altitude. **Humidity is
629/// not an input.**
630///
631/// ```text
632/// DA_ft = PA_ft + (120 * 5/9) * (T_F - ISA_T_F(PA)) ISA_T_F(PA) = 59 - 3.57 * PA_ft/1000
633/// ```
634///
635/// This is the density altitude the engine has always reported —
636/// `pdf_dope_card::calculate_density_altitude` delegates to it — and the one
637/// [`resolve_atmosphere_for_density_altitude`], the density-altitude ENTRY mode, inverts
638/// exactly.
639///
640/// It stays humidity-free for compatibility, not out of necessity. Every card printed to date
641/// shows this figure, and the entry mode that inverts it has shipped since MBA-1366 taking a
642/// temperature but no humidity (the run's humidity is applied separately, afterwards). A
643/// humidity-aware forward could be inverted just as well given the run's humidity, the way
644/// temperature already is; it would change every printed card and what an entered density
645/// altitude resolves to.
646///
647/// It is not the National Weather Service's density altitude either, despite the NWS pressure
648/// altitude inside it: the NWS calculator uses virtual temperature, so it includes humidity
649/// and tracks [`density_matched_altitude_m`] to within a few meters.
650///
651/// And it is an approximation. The rule is a straight line where the real dependence curves,
652/// and 120 ft/°C is slightly steeper than the exact slope at sea level (118.6 ft/°C for an
653/// ideal gas, 119.0 for the CIPM density [`density_matched_altitude_m`] uses), so
654/// it drifts from the exact figure as the temperature moves away from standard FOR THE
655/// PRESSURE ALTITUDE: about +22 m at 30 °C and 1013.25 hPa in completely dry air. See
656/// [`density_matched_altitude_m`] for the exact, humidity-inclusive figure, and why the bridge
657/// reports both.
658pub fn faa_rule_density_altitude_ft(station_pressure_hpa: f64, temperature_f: f64) -> f64 {
659 let pressure_alt = nws_pressure_altitude_ft(station_pressure_hpa);
660
661 // ISA temperature at pressure altitude (lapse rate: 3.57°F per 1000 ft)
662 let isa_temp_f = DA_NWS_SEA_LEVEL_TEMP_F - (pressure_alt / 1000.0) * DA_NWS_LAPSE_F_PER_1000FT;
663
664 pressure_alt + DA_FAA_RULE_FT_PER_DEG_F * (temperature_f - isa_temp_f)
665}
666
667/// ISA troposphere constants for [`density_matched_altitude_m`].
668const ISA_SEA_LEVEL_TEMP_K: f64 = 288.15;
669const ISA_LAPSE_K_PER_M: f64 = 0.0065;
670
671/// The reference air that [`density_matched_altitude_m`] calls sea level: ICAO standard sea
672/// level, perfectly dry, evaluated with the SAME CIPM-2007 model as the air being described.
673const DENSITY_MATCH_REFERENCE_TEMP_C: f64 = 15.0;
674const DENSITY_MATCH_REFERENCE_PRESSURE_HPA: f64 = 1013.25;
675
676/// The altitude in the ICAO Standard Atmosphere whose standard density equals the ACTUAL
677/// density of this air, humidity included — density altitude by its textbook definition.
678///
679/// ```text
680/// ratio = rho_CIPM(T, P, RH) / rho_CIPM(15 C, 1013.25 hPa, 0%)
681/// h_m = (T0 / L) * (1 - ratio^(1/n)) T0 = 288.15 K, L = 0.0065 K/m,
682/// n = g0 / (R L) - 1 (= 4.25588)
683/// ```
684///
685/// # Why this exists alongside [`faa_rule_density_altitude_ft`]
686///
687/// Two different quantities are both called "density altitude" in the field, and reporting
688/// them under one name is how an external user came to ask why the engine read 0 m where
689/// another tool read 36 m at ICAO standard with 50% humidity (2026-09). The FAA-rule figure
690/// is humidity-free for compatibility (see its docs). This one answers the question a shooter
691/// usually means — "which ISA altitude has the air density I actually have" — and so it must
692/// include humidity: water vapor (18 g/mol) is lighter than dry air (~29 g/mol), so humid air
693/// is thinner.
694///
695/// At 15 °C, 1013.25 hPa and 50% RH this reads 33.1 m where the FAA-rule figure reads 0. But
696/// the difference is NOT purely humidity, and callers must not present it that way: the FAA
697/// rule also drifts from this exact figure whenever the temperature is not standard for the
698/// pressure altitude, so the two disagree by about 22 m at 30 °C in perfectly dry air.
699///
700/// # Anchoring
701///
702/// Density comes from [`calculate_atmosphere`] — the one CIPM-2007 path every solver uses —
703/// and the ratio is taken against that SAME model's density for ICAO standard dry sea level,
704/// not against the textbook 1.225 kg/m³. The model is accurate, but it is not built to
705/// reproduce 1.225 exactly (its own anchor test allows ±0.002), and at sea level 0.002 kg/m³
706/// is roughly 17 m of altitude. Anchoring to the model makes ICAO standard dry air read
707/// exactly 0 and makes the humidity effect the model's own.
708///
709/// # Scope
710///
711/// The closed-form troposphere, with no geopotential correction — the same plain-height
712/// convention as the FAA-rule figure and the DA entry mode. That covers every shooting
713/// condition on Earth, but the arithmetic does not know where it stops: above 11 km it keeps
714/// answering, wrongly. Callers bound it; the bridge command refuses conditions outside
715/// -5 km..11 km (`crate::atmosphere_service`).
716pub fn density_matched_altitude_m(
717 temperature_c: f64,
718 station_pressure_hpa: f64,
719 humidity_percent: f64,
720) -> f64 {
721 let (density, _) = calculate_atmosphere(
722 0.0,
723 Some(temperature_c),
724 Some(station_pressure_hpa),
725 humidity_percent,
726 );
727 let (reference, _) = calculate_atmosphere(
728 0.0,
729 Some(DENSITY_MATCH_REFERENCE_TEMP_C),
730 Some(DENSITY_MATCH_REFERENCE_PRESSURE_HPA),
731 0.0,
732 );
733 let exponent = G_ACCEL_MPS2 / (R_AIR * ISA_LAPSE_K_PER_M) - 1.0;
734 (ISA_SEA_LEVEL_TEMP_K / ISA_LAPSE_K_PER_M) * (1.0 - (density / reference).powf(1.0 / exponent))
735}
736
737/// Back-solve an ISA-equivalent station altitude/temperature/pressure from a directly-declared
738/// density altitude (MBA-1366) — the single-value atmosphere entry mode Shooter, Nosler, AB
739/// Analytics, Ballistic AE, and TRASOL all support as an alternative to altitude + pressure +
740/// temperature.
741///
742/// This is the exact algebraic inverse of the FAA-rule density-altitude model this engine
743/// already uses to REPORT density altitude ([`faa_rule_density_altitude_ft`], which
744/// `pdf_dope_card::calculate_density_altitude` delegates to):
745///
746/// ```text
747/// pressure_alt_ft = 145366.45 * (1 - (station_hpa / 1013.25)^0.190284)
748/// isa_temp_f = 59.0 - pressure_alt_ft / 1000.0 * 3.57
749/// density_alt_ft = pressure_alt_ft + (120*5/9) * (station_temp_f - isa_temp_f)
750/// ```
751///
752/// Solved for `station_hpa` given a target `density_alt_ft` and a station temperature `K = 120
753/// * 5/9` is the "120 ft per °C" density-altitude correction rule, expressed in ft per °F):
754///
755/// ```text
756/// pressure_alt_ft = (density_alt_ft - K*(station_temp_f - 59.0)) / (1 + K*3.57/1000.0)
757/// station_hpa = 1013.25 * (1 - pressure_alt_ft/145366.45)^(1/0.190284)
758/// ```
759///
760/// # Temperature precedence (MBA-1366)
761/// * `explicit_temperature_c == None`: ISA-at-density-altitude is the default. Algebraically
762/// this collapses `pressure_alt_ft` to EXACTLY `density_alt_ft`: the correction term above is
763/// zero by construction whenever the station temperature equals the ISA temperature at that
764/// pressure altitude, which is self-consistently true when temperature is left at ISA — i.e.
765/// omitting an explicit temperature is equivalent to typing `--altitude <density_altitude>`
766/// with no `--temperature`/`--pressure` override.
767/// * `explicit_temperature_c == Some(t)`: `t` wins outright (returned unchanged as
768/// `temperature_c`, never re-derived) and the station pressure is re-solved so the resulting
769/// density altitude still reproduces the input exactly. The FAA-rule density altitude is
770/// honored either way, but the DENSITY is not: the rule is a straight-line approximation, not
771/// a curve of equal density, so the ISA-default case rebuilds different air — at 30 °C and
772/// 1013.25 hPa, a rebuilt 11.4 °C / 949 hPa, 0.3% denser, with a speed of sound 3% lower. Only
773/// the real temperature reproduces the real air.
774///
775/// # Height convention
776/// The FAA-rule formula being inverted ([`faa_rule_density_altitude_ft`]) performs no
777/// geopotential correction — it treats its altitude as a plain height (the DOPE card, which
778/// delegates to it, leaves its own `_altitude_ft` parameter unused for that reason). Consistent with that, the `altitude_m` this returns is GEOMETRIC altitude
779/// — the same convention every other engine altitude input uses (`--altitude`,
780/// [`AtmosphericConditions::altitude`](crate::cli_api::AtmosphericConditions::altitude)) —
781/// rather than being run through `geometric_to_geopotential_height_m` a second time here; that
782/// conversion is `calculate_icao_standard_atmosphere`'s job (both private to this crate — see
783/// their doc comments elsewhere in this file), applied once this altitude re-enters the normal
784/// altitude-lapse pipeline (this is what "back-solve an ISA-equivalent atmosphere" means —
785/// unlike [`get_direct_atmosphere`], which would freeze speed of sound and bypass that pipeline
786/// entirely).
787///
788/// # Interaction with [`PressureReferenceMode`] (QNH)
789/// Density altitude supersedes pressure outright — callers must not also run a declared
790/// `--pressure`/QNH value through [`resolve_station_pressure_with_mode`] when a density
791/// altitude is present; see the CLI/WASM call sites, which emit a note to that effect.
792///
793/// # Returns
794/// `(altitude_m, temperature_c, pressure_hpa)`, meant to be written directly into
795/// [`crate::cli_api::AtmosphericConditions`] (and the paired `BallisticInputs` environment
796/// fields, so powder-temperature sensitivity and the moist speed of sound both see the real
797/// resolved temperature) via the Authoritative
798/// (`TrajectorySolver::new_with_resolved_station_atmosphere`) constructor.
799pub fn resolve_atmosphere_for_density_altitude(
800 density_altitude_m: f64,
801 explicit_temperature_c: Option<f64>,
802) -> (f64, f64, f64) {
803 let density_altitude_ft = density_altitude_m / DA_FT_TO_M;
804 const K: f64 = DA_FAA_RULE_FT_PER_DEG_F;
805
806 let (pressure_alt_ft, temperature_c) = match explicit_temperature_c {
807 Some(temp_c) => {
808 let temp_f = temp_c * 9.0 / 5.0 + 32.0;
809 let denom = 1.0 + K * DA_NWS_LAPSE_F_PER_1000FT / 1000.0;
810 let pressure_alt_ft =
811 (density_altitude_ft - K * (temp_f - DA_NWS_SEA_LEVEL_TEMP_F)) / denom;
812 (pressure_alt_ft, temp_c)
813 }
814 None => {
815 // ISA-at-DA default: the correction term vanishes by construction (see doc
816 // comment above), so the pressure altitude IS the density altitude exactly.
817 let pressure_alt_ft = density_altitude_ft;
818 let isa_temp_f =
819 DA_NWS_SEA_LEVEL_TEMP_F - pressure_alt_ft / 1000.0 * DA_NWS_LAPSE_F_PER_1000FT;
820 let isa_temp_c = (isa_temp_f - 32.0) * 5.0 / 9.0;
821 (pressure_alt_ft, isa_temp_c)
822 }
823 };
824
825 let pressure_hpa = DA_NWS_SEA_LEVEL_HPA
826 * (1.0 - pressure_alt_ft / DA_NWS_PRESSURE_ALT_FT).powf(1.0 / DA_NWS_EXPONENT);
827 let altitude_m = pressure_alt_ft * DA_FT_TO_M;
828
829 (altitude_m, temperature_c, pressure_hpa)
830}
831
832/// Enhanced atmospheric calculation with ICAO Standard Atmosphere.
833///
834/// # Arguments
835/// * `altitude_m` - Altitude in meters
836/// * `temp_override_c` - Temperature override in Celsius (None for standard)
837/// * `press_override_hpa` - Pressure override in hPa (None for standard)
838/// * `humidity_percent` - Humidity percentage (0-100)
839///
840/// # Returns
841/// Tuple of (air_density_kg_m3, speed_of_sound_mps)
842pub fn calculate_atmosphere(
843 altitude_m: f64,
844 temp_override_c: Option<f64>,
845 press_override_hpa: Option<f64>,
846 humidity_percent: f64,
847) -> (f64, f64) {
848 // Get standard atmosphere conditions or use overrides
849 let combined_overrides = temp_override_c.zip(press_override_hpa);
850 let (temp_k, pressure_pa) = if let Some((temp_c, pressure_hpa)) = combined_overrides {
851 // Both overrides provided
852 (temp_c + 273.15, pressure_hpa * 100.0)
853 } else {
854 // Get ICAO standard conditions
855 let (std_temp_k, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
856
857 let final_temp_k = if let Some(temp_c) = temp_override_c {
858 temp_c + 273.15
859 } else {
860 std_temp_k
861 };
862
863 let final_pressure_pa = if let Some(press_hpa) = press_override_hpa {
864 press_hpa * 100.0
865 } else {
866 std_pressure_pa
867 };
868
869 (final_temp_k, final_pressure_pa)
870 };
871
872 // Humidity clamp shared by the CIPM density and the moist speed of sound.
873 let humidity_clamped = humidity_percent.clamp(0.0, 100.0);
874 let temp_c = temp_k - 273.15;
875
876 // Density: CIPM-2007 is the single canonical humid-air density model. Every solver
877 // (cli_api / monte_carlo / ffi / fast_trajectory) reaches this one formula through
878 // calculate_atmosphere, so there is no second (Arden-Buck ideal-gas) density path to drift.
879 let density = calculate_air_density_cimp(temp_c, pressure_pa / 100.0, humidity_clamped);
880
881 // Speed of sound uses an ideal-gas moist-air mixture approximation, first order in the
882 // water-vapor mole fraction. Extracted into `moist_speed_of_sound` so the integrators can
883 // share it; its vapor pressure comes from the SAME IAPWS saturation formula
884 // (`enhanced_saturation_vapor_pressure`) + CIPM enhancement factor that the density above
885 // uses. Only the vapor fraction is shared; the acoustic relation remains ideal-gas.
886 let speed_of_sound = moist_speed_of_sound(temp_k, pressure_pa, humidity_clamped);
887
888 (density, speed_of_sound)
889}
890
891/// Speed of sound using an ideal-gas moist-air mixture approximation, first order in the
892/// water-vapor mole fraction.
893///
894/// The water-vapor mole fraction is derived from the SAME IAPWS saturation vapor pressure
895/// (`enhanced_saturation_vapor_pressure`) and CIPM-2007 enhancement factor used by
896/// [`calculate_air_density_cimp`], so a single vapor formula feeds both density and c. Only the
897/// vapor fraction is shared with the CIPM path; the acoustic relation itself remains ideal-gas.
898/// The mixture applies first-order humidity corrections to the dry-air heat-capacity ratio and
899/// gas constant, then evaluates `sqrt(gamma * R * T)`. It is not Cramer's full real-gas
900/// polynomial and does not include Cramer's pressure or carbon-dioxide terms.
901///
902/// # Arguments
903/// * `temp_k` - Temperature in Kelvin
904/// * `pressure_pa` - Total (station) pressure in Pa
905/// * `humidity_percent` - Relative humidity percentage (0-100)
906///
907/// # Returns
908/// Speed of sound in m/s
909pub fn moist_speed_of_sound(temp_k: f64, pressure_pa: f64, humidity_percent: f64) -> f64 {
910 let humidity_clamped = humidity_percent.clamp(0.0, 100.0);
911 let temp_c = temp_k - 273.15;
912
913 // Water-vapor partial pressure p_v = RH * f * p_sv, matching CIPM's x_v exactly. p_sv is in
914 // hPa (enhanced_saturation_vapor_pressure returns hPa), so convert to Pa before forming the
915 // mole fraction against the Pa total pressure.
916 let p_sv_hpa = enhanced_saturation_vapor_pressure(temp_k);
917 let f = enhanced_enhancement_factor(pressure_pa, temp_c);
918 let vapor_pressure_pa = humidity_clamped / 100.0 * f * p_sv_hpa * 100.0;
919
920 // Cap the mole fraction at the physical maximum of 1 and guard pressure_pa == 0 (a 0 hPa
921 // override would otherwise give +Inf -> NaN speed of sound).
922 let mole_fraction_vapor = (vapor_pressure_pa / pressure_pa.max(f64::MIN_POSITIVE)).min(1.0);
923
924 // Heat-capacity ratio and gas constant for moist air (mole-fraction coefficients). 0.378 is
925 // the dry-air molecular-weight ratio (0.6078 would belong to specific humidity, not mole
926 // fraction).
927 let gamma_moist = GAMMA * (1.0 - mole_fraction_vapor * 0.062);
928 let r_moist = R_AIR * (1.0 + 0.378 * mole_fraction_vapor);
929
930 (gamma_moist * r_moist * temp_k).sqrt()
931}
932
933/// Enhanced air density calculation using CIPM formula with ICAO atmosphere.
934///
935/// # Arguments
936/// * `temp_c` - Temperature in Celsius
937/// * `pressure_hpa` - Pressure in hPa
938/// * `humidity_percent` - Humidity percentage (0-100)
939///
940/// # Returns
941/// Air density in kg/m³
942pub fn calculate_air_density_cimp(temp_c: f64, pressure_hpa: f64, humidity_percent: f64) -> f64 {
943 let t_k = temp_c + 273.15;
944
945 // Enhanced saturation vapor pressure calculation
946 let p_sv = enhanced_saturation_vapor_pressure(t_k);
947
948 let pressure_pa = pressure_hpa * 100.0;
949
950 // Enhanced enhancement factor with temperature dependence. CIPM constants use Pa.
951 let f = enhanced_enhancement_factor(pressure_pa, temp_c);
952
953 // Floor the pressure divisor (mirrors calculate_atmosphere): a 0 hPa pressure would
954 // otherwise make x_v = +Inf -> NaN density. No-op for all valid (>0) pressures.
955 let p_pa = pressure_pa.max(f64::MIN_POSITIVE);
956
957 // Mole fraction of water vapor, capped at the physical maximum of 1. The cap is silent:
958 // air whose vapor pressure would exceed its total pressure does not exist, and callers
959 // that must refuse it rather than compute pure steam test
960 // `water_vapor_mole_fraction_uncapped` first (the bridge's `atmosphere.density_altitude`).
961 let x_v = vapor_mole_fraction(p_sv, f, humidity_percent, p_pa).min(1.0);
962
963 // Enhanced compressibility factor. CIPM virial constants use Pa.
964 let z = enhanced_compressibility_factor(p_pa, t_k, x_v);
965
966 // Calculate density with enhanced precision
967 // Note: parentheses are important here for correct operator precedence
968 ((p_pa * M_A) / (z * R * t_k)) * (1.0 - x_v * (1.0 - M_V / M_A))
969}
970
971/// CIPM-2007 mole fraction of water vapor, `x_v = RH * f * p_sv / p`, UNCAPPED: the one
972/// formula [`calculate_air_density_cimp`] uses (it caps the result at 1). A value at or above 1
973/// means the stated humidity would need more vapor pressure than the air has total pressure.
974pub(crate) fn water_vapor_mole_fraction_uncapped(
975 temp_c: f64,
976 pressure_hpa: f64,
977 humidity_percent: f64,
978) -> f64 {
979 let t_k = temp_c + 273.15;
980 let p_sv = enhanced_saturation_vapor_pressure(t_k);
981 let pressure_pa = pressure_hpa * 100.0;
982 let f = enhanced_enhancement_factor(pressure_pa, temp_c);
983 vapor_mole_fraction(
984 p_sv,
985 f,
986 humidity_percent,
987 pressure_pa.max(f64::MIN_POSITIVE),
988 )
989}
990
991#[inline(always)]
992fn vapor_mole_fraction(p_sv_hpa: f64, f: f64, humidity_percent: f64, p_pa: f64) -> f64 {
993 // Vapor pressure with clamping. p_sv is in hPa (enhanced_saturation_vapor_pressure
994 // returns hPa — its critical-pressure constant is 220640 hPa), so p_v is in hPa too.
995 let p_v = humidity_percent.clamp(0.0, 100.0) / 100.0 * f * p_sv_hpa;
996
997 // Convert the vapor pressure to Pa BEFORE forming the mole fraction: the divisor below
998 // is in Pa. Dividing the hPa p_v by the Pa total made x_v 100x too small, which erased
999 // the humidity term and returned essentially dry-air density (e.g. 15 C / 1013.25 hPa /
1000 // 50% RH gave ~1.2254 instead of the CIPM-2007 moist value ~1.2216 — moist air is
1001 // LIGHTER than dry air).
1002 let p_v_pa = p_v * 100.0;
1003 p_v_pa / p_pa
1004}
1005
1006/// Enhanced saturation vapor pressure calculation.
1007/// Uses the IAPWS-IF97 formulation for high precision.
1008#[inline(always)]
1009fn enhanced_saturation_vapor_pressure(t_k: f64) -> f64 {
1010 // IAPWS-IF97 coefficients for better accuracy
1011 const A: [f64; 6] = [
1012 -7.85951783,
1013 1.84408259,
1014 -11.7866497,
1015 22.6807411,
1016 -15.9618719,
1017 1.80122502,
1018 ];
1019
1020 // Ensure temperature is positive and reasonable
1021 let t_k_safe = t_k.max(173.15); // -100°C minimum
1022
1023 let tau = 1.0 - t_k_safe / 647.096; // Critical temperature of water
1024 let ln_p_ratio = (647.096 / t_k_safe)
1025 * (A[0] * tau
1026 + A[1] * tau.powf(1.5)
1027 + A[2] * tau.powf(3.0)
1028 + A[3] * tau.powf(3.5)
1029 + A[4] * tau.powf(4.0)
1030 + A[5] * tau.powf(7.5));
1031
1032 220640.0 * ln_p_ratio.exp() // Critical pressure in hPa (22.064 MPa)
1033}
1034
1035/// CIPM-2007 enhancement factor `f = alpha + beta*p + gamma*t^2` (p in Pa, t in Celsius).
1036#[inline(always)]
1037fn enhanced_enhancement_factor(p: f64, t: f64) -> f64 {
1038 const ALPHA: f64 = 1.00062;
1039 const BETA: f64 = 3.14e-8;
1040 const GAMMA: f64 = 5.6e-7;
1041
1042 ALPHA + BETA * p + GAMMA * t * t
1043}
1044
1045/// CIPM-2007 compressibility factor `Z` (virial expansion, second order in `p/T`).
1046#[inline(always)]
1047fn enhanced_compressibility_factor(p: f64, t_k: f64, x_v: f64) -> f64 {
1048 // CIPM-2007 molar virial coefficients (p in Pa, t in Celsius).
1049 const A0: f64 = 1.58123e-6;
1050 const A1: f64 = -2.9331e-8;
1051 const A2: f64 = 1.1043e-10;
1052 const B0: f64 = 5.707e-6;
1053 const B1: f64 = -2.051e-8;
1054 const C0: f64 = 1.9898e-4;
1055 const C1: f64 = -2.376e-6;
1056 const D: f64 = 1.83e-11;
1057 const E: f64 = -0.765e-8;
1058
1059 // Ensure temperature is positive
1060 let t_k_safe = t_k.max(173.15); // -100°C minimum
1061 let t = t_k_safe - 273.15;
1062 let p_t = p / t_k_safe;
1063
1064 let z_second_order =
1065 1.0 - p_t * (A0 + A1 * t + A2 * t * t + (B0 + B1 * t) * x_v + (C0 + C1 * t) * x_v * x_v);
1066
1067 let z_third_order = p_t * p_t * (D + E * x_v * x_v);
1068
1069 z_second_order + z_third_order
1070}
1071
1072/// Convert an `(x, y)` position in the shot-aligned frame to true world altitude.
1073///
1074/// The engine rotates gravity by `shooting_angle_rad`, so shot-frame X follows the inclined
1075/// line of fire and Y is perpendicular to it in the vertical plane. Atmosphere lookup needs the
1076/// world-vertical projection of that position, added to the station altitude.
1077#[inline]
1078pub(crate) fn shot_frame_altitude(
1079 base_altitude_m: f64,
1080 downrange_m: f64,
1081 shot_y_m: f64,
1082 shooting_angle_rad: f64,
1083) -> f64 {
1084 base_altitude_m + downrange_m * shooting_angle_rad.sin() + shot_y_m * shooting_angle_rad.cos()
1085}
1086
1087/// Enhanced local atmospheric calculation with variable lapse rates.
1088///
1089/// # Arguments
1090/// * `altitude_m` - Query geometric altitude above mean sea level in meters
1091/// * `base_alt` - Base geometric altitude above mean sea level in meters
1092/// * `base_temp_c` - Base temperature in Celsius
1093/// * `base_press_hpa` - Base pressure in hPa
1094/// * `base_ratio` - Base density ratio
1095///
1096/// # Returns
1097/// Tuple of (air_density_kg_m3, speed_of_sound_mps)
1098pub fn get_local_atmosphere(
1099 altitude_m: f64,
1100 base_alt: f64,
1101 base_temp_c: f64,
1102 base_press_hpa: f64,
1103 base_ratio: f64,
1104) -> (f64, f64) {
1105 let (temp_k, _pressure_pa, density) = local_temp_pressure_density(
1106 altitude_m,
1107 base_alt,
1108 base_temp_c,
1109 base_press_hpa,
1110 base_ratio,
1111 );
1112
1113 // Dry speed of sound. 401.874 ~ gamma * R_air; kept exactly for back-compat with existing
1114 // callers (get_local_atmosphere_humid uses the precise moist formula instead).
1115 let speed_of_sound = (temp_k * 401.874).sqrt();
1116
1117 (density, speed_of_sound)
1118}
1119
1120/// Humidity-aware companion to [`get_local_atmosphere`]: identical local density, but the speed
1121/// of sound is the moist-air value ([`moist_speed_of_sound`]) evaluated at the LOCAL temperature
1122/// and pressure.
1123///
1124/// [`get_local_atmosphere`] is intentionally left unchanged (dry speed of sound) for
1125/// API/back-compat; call this variant only where a real relative humidity is available.
1126///
1127/// # Arguments
1128/// * `altitude_m` - Query geometric altitude above mean sea level in meters
1129/// * `base_alt` - Base (station) geometric altitude above mean sea level in meters
1130/// * `base_temp_c` - Base temperature in Celsius
1131/// * `base_press_hpa` - Base pressure in hPa
1132/// * `base_ratio` - Base density ratio (density / 1.225)
1133/// * `humidity_percent` - Relative humidity percentage (0-100)
1134///
1135/// # Returns
1136/// Tuple of (air_density_kg_m3, moist_speed_of_sound_mps)
1137pub fn get_local_atmosphere_humid(
1138 altitude_m: f64,
1139 base_alt: f64,
1140 base_temp_c: f64,
1141 base_press_hpa: f64,
1142 base_ratio: f64,
1143 humidity_percent: f64,
1144) -> (f64, f64) {
1145 get_local_atmosphere_humid_with_gravity(
1146 altitude_m,
1147 base_alt,
1148 base_temp_c,
1149 base_press_hpa,
1150 base_ratio,
1151 humidity_percent,
1152 Gravity::EARTH,
1153 )
1154}
1155
1156#[allow(clippy::too_many_arguments)]
1157pub(crate) fn get_local_atmosphere_humid_with_gravity(
1158 altitude_m: f64,
1159 base_alt: f64,
1160 base_temp_c: f64,
1161 base_press_hpa: f64,
1162 base_ratio: f64,
1163 humidity_percent: f64,
1164 gravity: Gravity,
1165) -> (f64, f64) {
1166 let (temp_k, pressure_pa, density) = local_temp_pressure_density_with_gravity(
1167 altitude_m,
1168 base_alt,
1169 base_temp_c,
1170 base_press_hpa,
1171 base_ratio,
1172 gravity,
1173 );
1174 (
1175 density,
1176 moist_speed_of_sound(temp_k, pressure_pa, humidity_percent),
1177 )
1178}
1179
1180/// Shared local temperature / pressure / density computation for [`get_local_atmosphere`] and
1181/// [`get_local_atmosphere_humid`]. Returns `(local_temp_k, local_pressure_pa, density_kg_m3)`.
1182#[inline]
1183fn local_temp_pressure_density(
1184 altitude_m: f64,
1185 base_alt: f64,
1186 base_temp_c: f64,
1187 base_press_hpa: f64,
1188 base_ratio: f64,
1189) -> (f64, f64, f64) {
1190 local_temp_pressure_density_with_gravity(
1191 altitude_m,
1192 base_alt,
1193 base_temp_c,
1194 base_press_hpa,
1195 base_ratio,
1196 Gravity::EARTH,
1197 )
1198}
1199
1200fn local_temp_pressure_density_with_gravity(
1201 altitude_m: f64,
1202 base_alt: f64,
1203 base_temp_c: f64,
1204 base_press_hpa: f64,
1205 base_ratio: f64,
1206 gravity: Gravity,
1207) -> (f64, f64, f64) {
1208 let base_temp_k = base_temp_c + 273.15;
1209
1210 // A non-finite endpoint would make the boundary walk fail to advance. The
1211 // previous single-column formula also produced non-finite outputs here.
1212 if !altitude_m.is_finite() || !base_alt.is_finite() {
1213 return (f64::NAN, f64::NAN, f64::NAN);
1214 }
1215
1216 let base_geopotential_m = geometric_to_geopotential_height_m(base_alt);
1217 let target_geopotential_m = geometric_to_geopotential_height_m(altitude_m);
1218 let (temp_k, pressure_hpa) = integrate_local_atmosphere_layers(
1219 base_geopotential_m,
1220 target_geopotential_m,
1221 base_temp_k,
1222 base_press_hpa,
1223 gravity,
1224 );
1225
1226 // Enhanced density calculation
1227 let density_ratio = base_ratio * (base_temp_k * pressure_hpa) / (base_press_hpa * temp_k);
1228 let density = density_ratio * 1.225;
1229
1230 (temp_k, pressure_hpa * 100.0, density)
1231}
1232
1233/// Carry arbitrary station temperature and pressure through each crossed ICAO
1234/// layer in geopotential-height coordinates. The layer table supplies lapse rates and boundaries
1235/// only; station deviations from the standard atmosphere remain anchored at the converted base.
1236fn integrate_local_atmosphere_layers(
1237 base_geopotential_m: f64,
1238 target_geopotential_m: f64,
1239 mut temp_k: f64,
1240 mut pressure_hpa: f64,
1241 gravity: Gravity,
1242) -> (f64, f64) {
1243 let mut current_alt = base_geopotential_m;
1244
1245 if target_geopotential_m > current_alt {
1246 while current_alt < target_geopotential_m {
1247 // At an exact boundary, ascent starts in the higher layer.
1248 let layer_index = ICAO_LAYERS
1249 .iter()
1250 .rposition(|layer| current_alt >= layer.base_altitude)
1251 .unwrap_or(0);
1252 let segment_end = ICAO_LAYERS
1253 .get(layer_index + 1)
1254 .map_or(target_geopotential_m, |next| {
1255 target_geopotential_m.min(next.base_altitude)
1256 });
1257 (temp_k, pressure_hpa) = integrate_local_atmosphere_segment(
1258 temp_k,
1259 pressure_hpa,
1260 segment_end - current_alt,
1261 ICAO_LAYERS[layer_index].lapse_rate,
1262 gravity,
1263 );
1264 current_alt = segment_end;
1265 }
1266 } else {
1267 while current_alt > target_geopotential_m {
1268 // At an exact boundary, descent starts in the lower layer. The
1269 // strict comparison is what makes a cross-layer round trip reversible.
1270 let layer_index = ICAO_LAYERS
1271 .iter()
1272 .rposition(|layer| current_alt > layer.base_altitude)
1273 .unwrap_or(0);
1274 let segment_end = if layer_index == 0 {
1275 target_geopotential_m
1276 } else {
1277 target_geopotential_m.max(ICAO_LAYERS[layer_index].base_altitude)
1278 };
1279 (temp_k, pressure_hpa) = integrate_local_atmosphere_segment(
1280 temp_k,
1281 pressure_hpa,
1282 segment_end - current_alt,
1283 ICAO_LAYERS[layer_index].lapse_rate,
1284 gravity,
1285 );
1286 current_alt = segment_end;
1287 }
1288 }
1289
1290 (temp_k, pressure_hpa)
1291}
1292
1293#[inline]
1294fn integrate_local_atmosphere_segment(
1295 base_temp_k: f64,
1296 base_pressure_hpa: f64,
1297 height_diff: f64,
1298 lapse_rate: f64,
1299 gravity: Gravity,
1300) -> (f64, f64) {
1301 let temp_k = base_temp_k + lapse_rate * height_diff;
1302 let pressure_hpa = if lapse_rate.abs() < 1e-10 {
1303 base_pressure_hpa * (-gravity.magnitude() * height_diff / (R_AIR * base_temp_k)).exp()
1304 } else {
1305 let temp_ratio = temp_k / base_temp_k;
1306 base_pressure_hpa * temp_ratio.powf(-gravity.magnitude() / (lapse_rate * R_AIR))
1307 };
1308
1309 (temp_k, pressure_hpa)
1310}
1311
1312#[cfg(test)]
1313mod uniform_gravity_tests {
1314 use super::*;
1315
1316 #[test]
1317 fn uniform_gravity_controls_hydrostatic_pressure_without_moving_the_station_anchor() {
1318 for magnitude in [0.0, 1.62, 9.80665, 20.0] {
1319 let gravity = Gravity::new(magnitude).unwrap();
1320 let (temperature, pressure) =
1321 integrate_local_atmosphere_segment(280.0, 900.0, 100.0, 0.0, gravity);
1322 assert_eq!(temperature, 280.0);
1323 assert!(
1324 (pressure / 900.0 - (-magnitude * 100.0 / (R_AIR * 280.0)).exp()).abs() < 1e-14
1325 );
1326 let station =
1327 local_temp_pressure_density_with_gravity(300.0, 300.0, 20.0, 900.0, 0.9, gravity);
1328 assert_eq!(
1329 station,
1330 local_temp_pressure_density(300.0, 300.0, 20.0, 900.0, 0.9)
1331 );
1332 }
1333 // Exercise both the lapse and isothermal branches while crossing layer boundaries.
1334 let (_, pressure, _) = local_temp_pressure_density_with_gravity(
1335 25_000.0,
1336 0.0,
1337 20.0,
1338 900.0,
1339 0.9,
1340 Gravity::new(0.0).unwrap(),
1341 );
1342 assert_eq!(pressure, 90_000.0);
1343 }
1344}
1345
1346/// Determine local lapse rate based on altitude and atmospheric layer.
1347#[cfg(test)]
1348#[inline(always)]
1349fn determine_local_lapse_rate(altitude_m: f64) -> f64 {
1350 // Find the current atmospheric layer to get appropriate lapse rate
1351 let layer = ICAO_LAYERS
1352 .iter()
1353 .rev()
1354 .find(|layer| altitude_m >= layer.base_altitude)
1355 .unwrap_or(&ICAO_LAYERS[0]);
1356
1357 layer.lapse_rate
1358}
1359
1360/// Direct atmosphere calculation for simple cases.
1361///
1362/// # Arguments
1363/// * `density` - Pre-computed air density
1364/// * `speed_of_sound` - Pre-computed speed of sound
1365///
1366/// # Returns
1367/// Tuple of (air_density, speed_of_sound) - just passes through the values
1368#[inline(always)]
1369pub fn get_direct_atmosphere(density: f64, speed_of_sound: f64) -> (f64, f64) {
1370 (density, speed_of_sound)
1371}
1372
1373/// Legacy function name for backwards compatibility
1374pub fn calculate_air_density_cipm(temp_c: f64, pressure_hpa: f64, humidity_percent: f64) -> f64 {
1375 calculate_air_density_cimp(temp_c, pressure_hpa, humidity_percent)
1376}
1377
1378/// A single downrange-referenced atmosphere zone:
1379/// `(temp_c, pressure_hpa, humidity_percent, until_distance_m)`.
1380///
1381/// The T/P/H are the STATION-REFERENCED conditions (defined at the shooter base altitude) that
1382/// apply from the previous segment's threshold out to `until_distance_m`. This mirrors
1383/// [`crate::wind::WindSegment`]'s `(speed, angle, until_distance)` shape so the two segmented
1384/// models compose the same way (wind by X, atmosphere by X, altitude lapse by Y).
1385pub type AtmoSegment = (f64, f64, f64, f64);
1386
1387/// Downrange-segmented atmosphere handler (MBA-1137), the density analogue of
1388/// [`crate::wind::WindSock`].
1389///
1390/// Holds a set of station-referenced atmosphere zones ordered by their `until_distance_m`
1391/// threshold and answers a stateless downrange lookup ([`AtmoSock::atmo_for_range`]).
1392///
1393/// The zone T/P/H are the base (shooter-altitude) conditions for that stretch of range; the
1394/// solver swaps them into the same local-atmosphere altitude-lapse pipeline that a single-station
1395/// solve uses, so the downrange (X) zone and the vertical (Y) altitude lapse compose orthogonally
1396/// without double-counting (the zone sets the base tuple, the lapse multiplies on top of it).
1397#[derive(Debug, Clone)]
1398pub struct AtmoSock {
1399 /// Zones sorted ascending by `until_distance_m` (segment slot 3).
1400 segments: Vec<AtmoSegment>,
1401}
1402
1403impl AtmoSock {
1404 /// Create a new `AtmoSock` from station-referenced atmosphere zones.
1405 ///
1406 /// Each segment is `(temp_c, pressure_hpa, humidity_percent, until_distance_m)`. Segments are
1407 /// sorted by `until_distance_m`, with NaN thresholds ordered last.
1408 pub fn new(mut segments: Vec<AtmoSegment>) -> Self {
1409 segments.sort_by(|a, b| match (a.3.is_nan(), b.3.is_nan()) {
1410 (true, true) => Ordering::Equal,
1411 (true, false) => Ordering::Greater,
1412 (false, true) => Ordering::Less,
1413 (false, false) => a.3.partial_cmp(&b.3).unwrap(),
1414 });
1415 AtmoSock { segments }
1416 }
1417
1418 /// True when this sock carries no zones (a lookup falls back to sea-level ISA).
1419 pub fn is_empty(&self) -> bool {
1420 self.segments.is_empty()
1421 }
1422
1423 /// Stateless downrange lookup of the active zone's `(temp_c, pressure_hpa, humidity_percent)`.
1424 ///
1425 /// Selection matches [`crate::wind::WindSock::vector_for_range_stateless`]: the first segment
1426 /// whose `until_distance_m` STRICTLY exceeds `downrange_m` wins (thresholds are upper-exclusive).
1427 /// Unlike wind — which returns zero past the last threshold — the LAST zone is used for any
1428 /// distance at or beyond the final threshold (there is no "zero atmosphere"). An empty sock
1429 /// returns the sea-level ISA reference `(15 C, 1013.25 hPa, 0% RH)`.
1430 ///
1431 /// This is stateless and safe for numerical integration (the same X may be queried repeatedly
1432 /// or out of order across RK substeps).
1433 pub fn atmo_for_range(&self, downrange_m: f64) -> (f64, f64, f64) {
1434 if self.segments.is_empty() {
1435 return (15.0, 1013.25, 0.0); // sea-level ISA fallback
1436 }
1437 // NaN X can't be ordered; use the first (nearest) zone deterministically.
1438 if downrange_m.is_nan() {
1439 let s = self.segments[0];
1440 return (s.0, s.1, s.2);
1441 }
1442 for seg in &self.segments {
1443 if downrange_m < seg.3 {
1444 return (seg.0, seg.1, seg.2);
1445 }
1446 }
1447 // Beyond the final threshold: hold the last zone (no zeroing).
1448 let last = self.segments[self.segments.len() - 1];
1449 (last.0, last.1, last.2)
1450 }
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455 use super::*;
1456
1457 #[test]
1458 fn inclined_shot_frame_position_maps_to_world_altitude() {
1459 let base_altitude = 100.0;
1460 let downrange = 1_000.0;
1461 let shot_y = 10.0;
1462 let angle = std::f64::consts::FRAC_PI_6;
1463 let expected = base_altitude + downrange * angle.sin() + shot_y * angle.cos();
1464
1465 let actual = shot_frame_altitude(base_altitude, downrange, shot_y, angle);
1466 assert!(
1467 (actual - expected).abs() < 1e-12,
1468 "30-degree shot at x=1000/y=10 should be at {expected} m, got {actual} m"
1469 );
1470 assert_eq!(
1471 shot_frame_altitude(base_altitude, downrange, shot_y, 0.0),
1472 base_altitude + shot_y,
1473 "flat-fire altitude must remain byte-identical"
1474 );
1475 let downhill = shot_frame_altitude(base_altitude, downrange, shot_y, -angle);
1476 let expected_downhill = base_altitude - downrange * angle.sin() + shot_y * angle.cos();
1477 assert!((downhill - expected_downhill).abs() < 1e-12);
1478 }
1479
1480 // ---- MBA-1136: CIPM-2007 as the single canonical humid-air density ----
1481
1482 /// Gate 1: dry sea-level (15 C, 1013.25 hPa, 0% RH) must stay at the ISA reference — density
1483 /// 1.225 +- 0.002 kg/m^3 and speed of sound 340.3 +- 0.6 m/s. CIPM-2007 at 0% RH reduces to
1484 /// dry-air ideal gas to within rounding, so this is essentially unchanged from the pre-CIPM
1485 /// baseline (baseline was 1.225012 / 340.294; now 1.225521 / 340.294 — the tiny density bump
1486 /// is CIPM compressibility + exact molar mass, the speed of sound is bit-identical).
1487 #[test]
1488 fn test_mba1136_dry_sea_level_reference() {
1489 let (density, sos) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 0.0);
1490 assert!(
1491 (density - 1.225).abs() < 0.002,
1492 "dry sea-level density {density} not within 1.225 +- 0.002"
1493 );
1494 assert!(
1495 (sos - 340.3).abs() < 0.6,
1496 "dry sea-level speed of sound {sos} not within 340.3 +- 0.6"
1497 );
1498 }
1499
1500 /// Gate 2: humid air (15 C, 1013.25 hPa, 50% RH) is the CIPM-2007 value (~1.2211 +- 0.002),
1501 /// STRICTLY lighter than dry air at the same T/P, with a speed of sound slightly ABOVE dry.
1502 #[test]
1503 fn test_mba1136_humid_lighter_than_dry() {
1504 let (dry_rho, dry_sos) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 0.0);
1505 let (moist_rho, moist_sos) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 50.0);
1506
1507 assert!(
1508 (moist_rho - 1.2211).abs() < 0.002,
1509 "50% RH density {moist_rho} not within CIPM 1.2211 +- 0.002"
1510 );
1511 assert!(
1512 moist_rho < dry_rho,
1513 "moist air ({moist_rho}) must be lighter than dry ({dry_rho})"
1514 );
1515 assert!(
1516 moist_sos > dry_sos,
1517 "moist speed of sound ({moist_sos}) must exceed dry ({dry_sos})"
1518 );
1519 }
1520
1521 /// Gate 3: density is monotone-decreasing in humidity (100% RH < 50% RH < 0% RH).
1522 #[test]
1523 fn test_mba1136_density_monotone_in_humidity() {
1524 let (rho_0, _) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 0.0);
1525 let (rho_50, _) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 50.0);
1526 let (rho_100, _) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 100.0);
1527 assert!(
1528 rho_100 < rho_50 && rho_50 < rho_0,
1529 "humidity monotonicity violated: 100%={rho_100}, 50%={rho_50}, 0%={rho_0}"
1530 );
1531 }
1532
1533 /// rank 28: `calculate_atmosphere`'s density is exactly `calculate_air_density_cimp` — there
1534 /// is a single canonical humid-air density path (no separate Arden-Buck ideal-gas density).
1535 #[test]
1536 fn test_mba1136_atmosphere_density_is_cipm() {
1537 for (t, p, h) in [
1538 (15.0, 1013.25, 0.0),
1539 (15.0, 1013.25, 50.0),
1540 (30.0, 1000.0, 80.0),
1541 (-10.0, 1020.0, 20.0),
1542 ] {
1543 let (density, _) = calculate_atmosphere(0.0, Some(t), Some(p), h);
1544 let cipm = calculate_air_density_cimp(t, p, h);
1545 assert_eq!(
1546 density, cipm,
1547 "calculate_atmosphere density must equal CIPM at {t}C/{p}hPa/{h}%"
1548 );
1549 }
1550 }
1551
1552 /// rank 9: the extracted `moist_speed_of_sound` is exactly what `calculate_atmosphere`
1553 /// returns (behavior-identical extraction), across dry and humid conditions.
1554 #[test]
1555 fn test_mba1136_moist_speed_of_sound_extraction() {
1556 for (t, p, h) in [
1557 (15.0, 1013.25, 0.0),
1558 (15.0, 1013.25, 50.0),
1559 (25.0, 900.0, 100.0),
1560 ] {
1561 let (_, sos) = calculate_atmosphere(0.0, Some(t), Some(p), h);
1562 let extracted = moist_speed_of_sound(t + 273.15, p * 100.0, h);
1563 assert_eq!(
1564 sos, extracted,
1565 "extracted moist_speed_of_sound must match calculate_atmosphere at {t}C/{p}hPa/{h}%"
1566 );
1567 }
1568 }
1569
1570 /// rank 9: local-atmosphere reference values (base: 500 m, 10 C, 950 hPa, ratio 1.05).
1571 /// The 1500 m values include the geometric-to-geopotential height conversion.
1572 #[test]
1573 fn test_mba1136_get_local_atmosphere_reference() {
1574 let (d0, c0) = get_local_atmosphere(500.0, 500.0, 10.0, 950.0, 1.05);
1575 assert!(
1576 (d0 - 1.286250000000).abs() < 1e-9,
1577 "local density@500m drifted: {d0}"
1578 );
1579 assert!(
1580 (c0 - 337.328657395129).abs() < 1e-9,
1581 "local sos@500m drifted: {c0}"
1582 );
1583 let (d1, c1) = get_local_atmosphere(1500.0, 500.0, 10.0, 950.0, 1.05);
1584 assert!(
1585 (d1 - 1.165238292559).abs() < 1e-9,
1586 "local density@1500m drifted: {d1}"
1587 );
1588 assert!(
1589 (c1 - 333.435546617978).abs() < 1e-9,
1590 "local sos@1500m drifted: {c1}"
1591 );
1592 }
1593
1594 #[test]
1595 fn fractional_station_altitude_is_not_quantized() {
1596 let station_altitude_m = 500.25;
1597 let station_temp_c = 10.0;
1598 let station_pressure_hpa = 950.0;
1599 let station_density_ratio = 1.05;
1600
1601 let (density, speed_of_sound) = get_local_atmosphere(
1602 station_altitude_m,
1603 station_altitude_m,
1604 station_temp_c,
1605 station_pressure_hpa,
1606 station_density_ratio,
1607 );
1608
1609 let expected_density = station_density_ratio * 1.225;
1610 let expected_speed_of_sound = ((station_temp_c + 273.15) * 401.874).sqrt();
1611 assert!((density - expected_density).abs() < 1e-12);
1612 assert!((speed_of_sound - expected_speed_of_sound).abs() < 1e-12);
1613 }
1614
1615 /// rank 9: `get_local_atmosphere_humid` returns the SAME density as `get_local_atmosphere`,
1616 /// and at 0% RH its speed of sound reduces to the dry value (within the 401.874-vs-gamma*R
1617 /// constant rounding). At real humidity the speed of sound exceeds the dry value.
1618 #[test]
1619 fn test_mba1136_get_local_atmosphere_humid() {
1620 let (d_dry, c_dry) = get_local_atmosphere(1500.0, 500.0, 10.0, 950.0, 1.05);
1621 let (d_h0, c_h0) = get_local_atmosphere_humid(1500.0, 500.0, 10.0, 950.0, 1.05, 0.0);
1622 assert_eq!(d_dry, d_h0, "humid variant must not change density");
1623 assert!(
1624 (c_h0 - c_dry).abs() < 1e-3,
1625 "0% RH humid sos {c_h0} should match dry sos {c_dry}"
1626 );
1627 let (_, c_h80) = get_local_atmosphere_humid(1500.0, 500.0, 10.0, 950.0, 1.05, 80.0);
1628 assert!(c_h80 > c_dry, "humid sos {c_h80} should exceed dry {c_dry}");
1629 }
1630
1631 #[test]
1632 fn test_icao_standard_atmosphere() {
1633 // Test sea level
1634 let (temp, press) = calculate_icao_standard_atmosphere(0.0);
1635 assert!((temp - 288.15).abs() < 0.01);
1636 assert!((press - 101325.0).abs() < 1.0);
1637
1638 // The table's 11 km tropopause base is geopotential height; convert it back to the
1639 // geometric altitude accepted by the public atmosphere contract.
1640 let geometric_tropopause_m =
1641 GEOPOTENTIAL_EARTH_RADIUS_M * 11000.0 / (GEOPOTENTIAL_EARTH_RADIUS_M - 11000.0);
1642 let (temp_11km, press_11km) = calculate_icao_standard_atmosphere(geometric_tropopause_m);
1643 assert!((temp_11km - 216.65).abs() < 0.01);
1644 assert!((press_11km - 22632.1).abs() < 1.0);
1645
1646 // Test stratosphere
1647 let (temp_25km, _) = calculate_icao_standard_atmosphere(25000.0);
1648 assert!(temp_25km > 216.65); // Temperature increases in stratosphere
1649 }
1650
1651 #[test]
1652 fn standard_atmosphere_extends_below_sea_level() {
1653 let altitude_m = -430.0;
1654 let (temp_k, pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
1655
1656 assert!((temp_k - 290.945_189_079_054).abs() < 1e-9);
1657 assert!((pressure_pa - 106_598.763_552_437).abs() < 0.1);
1658
1659 let (station_temp_c, station_pressure_hpa) =
1660 resolve_station_conditions(15.0, 1013.25, altitude_m);
1661 assert!((station_temp_c - 17.795_189_079_054).abs() < 1e-9);
1662 assert!((station_pressure_hpa - 1_065.987_635_524).abs() < 1e-6);
1663
1664 let (sea_density, _) = calculate_atmosphere(0.0, None, None, 0.0);
1665 let (below_sea_density, _) = calculate_atmosphere(altitude_m, None, None, 0.0);
1666 assert!((below_sea_density - 1.276_908_642_79).abs() < 1e-6);
1667 assert!(below_sea_density > sea_density * 1.04);
1668
1669 assert_eq!(
1670 calculate_icao_standard_atmosphere(-6000.0),
1671 calculate_icao_standard_atmosphere(MIN_GEOMETRIC_ALTITUDE_M)
1672 );
1673 }
1674
1675 #[test]
1676 fn standard_atmosphere_converts_geometric_to_geopotential_height() {
1677 let cases: [(f64, f64, f64); 3] = [
1678 (10_000.0, 223.252_092_647_979, 26_499.901_600_244),
1679 (30_000.0, 226.509_083_611_330, 1_197.032_108_466),
1680 (84_000.0, 190.841_043_736_102, 0.531_525_514_935),
1681 ];
1682 for (geometric_m, expected_temp_k, expected_pressure_pa) in cases {
1683 let (temp_k, pressure_pa) = calculate_icao_standard_atmosphere(geometric_m);
1684 let pressure_tolerance = expected_pressure_pa.max(1.0_f64) * 1e-6;
1685
1686 assert!((temp_k - expected_temp_k).abs() < 1e-9);
1687 assert!((pressure_pa - expected_pressure_pa).abs() < pressure_tolerance);
1688 }
1689 }
1690
1691 #[test]
1692 fn local_atmosphere_walks_icao_layers_continuously() {
1693 for altitude_m in [
1694 10999.0, 11000.0, 11001.0, 11050.0, 19999.0, 20000.0, 20001.0, 25000.0,
1695 ] {
1696 let (local_temp_k, local_pressure_pa, _) =
1697 local_temp_pressure_density(altitude_m, 0.0, 15.0, 1013.25, 1.0);
1698 let (standard_temp_k, standard_pressure_pa) =
1699 calculate_icao_standard_atmosphere(altitude_m);
1700
1701 assert!(
1702 (local_temp_k - standard_temp_k).abs() < 1e-9,
1703 "local temperature diverged from ICAO at {altitude_m} m: local={local_temp_k}, standard={standard_temp_k}"
1704 );
1705 assert!(
1706 ((local_pressure_pa - standard_pressure_pa) / standard_pressure_pa).abs() < 5e-5,
1707 "local pressure diverged from ICAO at {altitude_m} m: local={local_pressure_pa}, standard={standard_pressure_pa}"
1708 );
1709 }
1710
1711 let (density_below, sound_below) = get_local_atmosphere(10999.0, 0.0, 15.0, 1013.25, 1.0);
1712 let (density_above, sound_above) = get_local_atmosphere(11001.0, 0.0, 15.0, 1013.25, 1.0);
1713 assert!(
1714 (density_below - density_above).abs() < 0.001,
1715 "density jumped across 11 km: below={density_below}, above={density_above}"
1716 );
1717 assert!(
1718 (sound_below - sound_above).abs() < 0.1,
1719 "speed of sound jumped across 11 km: below={sound_below}, above={sound_above}"
1720 );
1721 }
1722
1723 #[test]
1724 fn local_atmosphere_preserves_nonstandard_station_offset_and_round_trips() {
1725 let base_alt = 7500.0;
1726 let base_temp_c = 5.0;
1727 let base_pressure_hpa = 410.0;
1728 let base_ratio = 0.72;
1729
1730 let (high_temp_k, high_pressure_pa, high_density) = local_temp_pressure_density(
1731 25000.0,
1732 base_alt,
1733 base_temp_c,
1734 base_pressure_hpa,
1735 base_ratio,
1736 );
1737 assert!((high_temp_k - 260.244_615_053_376).abs() < 1e-9);
1738 assert!((high_pressure_pa / 100.0 - 40.964_358_485_456).abs() < 1e-9);
1739 assert!((high_density - 0.094_186_400_274).abs() < 1e-9);
1740
1741 let (back_temp_k, back_pressure_pa, back_density) = local_temp_pressure_density(
1742 base_alt,
1743 25000.0,
1744 high_temp_k - 273.15,
1745 high_pressure_pa / 100.0,
1746 high_density / 1.225,
1747 );
1748 assert!((back_temp_k - (base_temp_c + 273.15)).abs() < 1e-9);
1749 assert!((back_pressure_pa / 100.0 - base_pressure_hpa).abs() < 1e-8);
1750 assert!((back_density - base_ratio * 1.225).abs() < 1e-9);
1751 }
1752
1753 #[test]
1754 fn test_enhanced_atmosphere_sea_level() {
1755 let (density, speed) = calculate_atmosphere(0.0, None, None, 0.0);
1756 assert!((density - 1.225).abs() < 0.01);
1757 assert!((speed - 340.0).abs() < 1.0);
1758 }
1759
1760 /// The band's edges are inside it, in both directions. At sea level the ICAO standard is
1761 /// exactly 101325 Pa, and 1.08 and 0.85 times it are exact in binary, so the division
1762 /// lands on the constants themselves rather than next to them.
1763 #[test]
1764 fn station_pressure_plausibility_band_edges_are_inside_the_band() {
1765 let at_sea_level = |pa| station_pressure_plausibility(pa, 0.0);
1766 assert_eq!(at_sea_level(101_325.0).icao_standard_pa, 101_325.0);
1767
1768 let high_edge = at_sea_level(109_431.0);
1769 assert_eq!(high_edge.ratio, STATION_PRESSURE_MAX_PLAUSIBLE_RATIO);
1770 assert!(!high_edge.implausible, "{high_edge:?}");
1771 assert!(at_sea_level(109_432.0).implausible);
1772
1773 let low_edge = at_sea_level(86_126.25);
1774 assert_eq!(low_edge.ratio, STATION_PRESSURE_MIN_PLAUSIBLE_RATIO);
1775 assert!(!low_edge.implausible, "{low_edge:?}");
1776 assert!(at_sea_level(86_126.0).implausible);
1777
1778 // Away from sea level the edges hold to a part in a billion either side.
1779 for altitude_m in [-400.0, 1500.0, 3310.128] {
1780 let (_, icao_pa) = calculate_icao_standard_atmosphere(altitude_m);
1781 for (ratio, implausible) in [
1782 (STATION_PRESSURE_MAX_PLAUSIBLE_RATIO * (1.0 - 1e-9), false),
1783 (STATION_PRESSURE_MAX_PLAUSIBLE_RATIO * (1.0 + 1e-9), true),
1784 (STATION_PRESSURE_MIN_PLAUSIBLE_RATIO * (1.0 + 1e-9), false),
1785 (STATION_PRESSURE_MIN_PLAUSIBLE_RATIO * (1.0 - 1e-9), true),
1786 ] {
1787 let p = station_pressure_plausibility(ratio * icao_pa, altitude_m);
1788 assert_eq!(p.icao_standard_pa, icao_pa);
1789 assert_eq!(
1790 p.implausible, implausible,
1791 "{altitude_m} m, ratio {ratio}: {p:?}"
1792 );
1793 }
1794 }
1795
1796 assert!(station_pressure_plausibility(f64::NAN, 0.0).implausible);
1797 }
1798
1799 /// The record sea-level pressures the band is justified by stay inside it, and the reported
1800 /// placeholder at Cusco does not.
1801 #[test]
1802 fn station_pressure_plausibility_passes_record_weather_and_flags_a_placeholder() {
1803 for record_hpa in [870.0, 1083.8, 1089.1] {
1804 let p = station_pressure_plausibility(record_hpa * 100.0, 0.0);
1805 assert!(!p.implausible, "{record_hpa} hPa: {p:?}");
1806 }
1807
1808 let placeholder = station_pressure_plausibility(101_325.0, 3310.128);
1809 assert!(placeholder.implausible, "{placeholder:?}");
1810 assert!((placeholder.ratio - 1.503).abs() < 5e-4, "{placeholder:?}");
1811 // A real Cusco station reading is close to standard.
1812 assert!(!station_pressure_plausibility(68_830.0, 3310.128).implausible);
1813 // The same reading at an omitted (0 m) altitude is far below standard.
1814 assert!(station_pressure_plausibility(68_830.0, 0.0).implausible);
1815 // 101325 Pa leaves the band between 640 m and 650 m.
1816 assert!(!station_pressure_plausibility(101_325.0, 640.0).implausible);
1817 assert!(station_pressure_plausibility(101_325.0, 650.0).implausible);
1818 }
1819
1820 #[test]
1821 fn test_resolve_station_pressure_contract() {
1822 // Default sea-level pressure + real altitude => derive from altitude (None).
1823 assert_eq!(resolve_station_pressure(1013.25, 2000.0), None);
1824 // 29.92 inHg ≈ 1013.21 hPa is also treated as the default (within tolerance).
1825 assert_eq!(resolve_station_pressure(1013.21, 2000.0), None);
1826 // An explicit, non-default station pressure is authoritative (Some, used directly).
1827 assert_eq!(resolve_station_pressure(850.0, 2000.0), Some(850.0));
1828 // At/near sea level the default is used directly (no derivation needed).
1829 assert_eq!(resolve_station_pressure(1013.25, 0.0), Some(1013.25));
1830 }
1831
1832 #[test]
1833 fn qnh_reduction_matches_hand_computed_value_at_nonzero_altitude() {
1834 // Hand-computed (Python, double precision) from the altimeter-setting relation.
1835 // NOTE the geopotential step — 1500 m geometric is 1499.646 m geopotential, and it is
1836 // the geopotential height that enters the formula:
1837 // h_geo = 6356766*1500/(6356766+1500) = 1499.6461... m
1838 // n = 1/5.255875601466713 = 0.19026325503612...
1839 // station = 1030.0 * (1 - (0.0065*h_geo/288.15)*(1013.25/1030.0)^n)^(1/n)
1840 // = 860.0681114332783 hPa
1841 // The ratio form this replaced, 1030.0 * (1 - 0.0065*h_geo/288.15)^5.2558756, gave
1842 // 859.5753123926447 hPa: 0.49 hPa low, because it scales the standard pressure ratio
1843 // by QNH instead of adding the station height to the QNH's pressure altitude.
1844 let reduced = reduce_qnh_to_station_pressure(1030.0, 1500.0);
1845 assert!(
1846 (reduced - 860.068_111_433_278_3).abs() < 1e-9,
1847 "reduced={reduced}"
1848 );
1849 // The reduction must strictly lower the pressure versus the raw QNH input.
1850 assert!(reduced < 1030.0);
1851 }
1852
1853 #[test]
1854 fn qnh_reduction_is_identity_at_sea_level() {
1855 // At h=0 the geopotential height is 0, so the ratio is exactly 1.0 and QNH passes
1856 // through unchanged (bit-exact: 1.0f64.powf(x) == 1.0).
1857 assert_eq!(reduce_qnh_to_station_pressure(1030.0, 0.0), 1030.0);
1858 assert_eq!(reduce_qnh_to_station_pressure(950.5, 0.0), 950.5);
1859 }
1860
1861 #[test]
1862 fn qnh_sea_level_standard_matches_icao_standard_atmosphere_at_every_altitude() {
1863 // A QNH of exactly 1013.25 hPa (the sea-level standard) must reduce to precisely the
1864 // ICAO standard station pressure at any altitude -- this is what keeps the omitted-
1865 // pressure default byte-identical whether the caller declares Absolute or Qnh.
1866 for altitude_m in [0.0, 500.0, 2000.0, 4500.0] {
1867 let (_, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
1868 let reduced = reduce_qnh_to_station_pressure(1013.25, altitude_m);
1869 assert!(
1870 (reduced - std_pressure_pa / 100.0).abs() < 1e-9,
1871 "altitude={altitude_m} reduced={reduced} std={}",
1872 std_pressure_pa / 100.0
1873 );
1874 }
1875 }
1876
1877 /// The doc comment's comparison with the NWS/ASOS altimeter-setting algorithm, checked:
1878 /// with that algorithm's constants (n = 0.190284, k = 8.4228807e-5, the field elevation as
1879 /// entered) and without its 0.3 hPa sensor offset, its station pressure is about 0.1 hPa
1880 /// below this function's at 1500 m and 0.3 to 0.45 hPa below at 4000 to 5000 m, over QNH
1881 /// 950 to 1050 hPa.
1882 #[test]
1883 fn qnh_reduction_differs_from_nws_asos_by_the_documented_amounts() {
1884 let nws_station_hpa = |qnh_hpa: f64, elevation_m: f64| {
1885 (qnh_hpa.powf(0.190284) - 8.4228807e-5 * elevation_m).powf(1.0 / 0.190284)
1886 };
1887 for (altitude_m, low, high) in [
1888 (1500.0, 0.09, 0.11),
1889 (4000.0, 0.3, 0.45),
1890 (5000.0, 0.3, 0.45),
1891 ] {
1892 for qnh_hpa in [950.0, 980.0, 1013.25, 1030.0, 1050.0] {
1893 let gap = reduce_qnh_to_station_pressure(qnh_hpa, altitude_m)
1894 - nws_station_hpa(qnh_hpa, altitude_m);
1895 assert!(
1896 (low..=high).contains(&gap),
1897 "QNH {qnh_hpa} at {altitude_m} m: this function is {gap} hPa above NWS/ASOS"
1898 );
1899 }
1900 }
1901 }
1902
1903 #[test]
1904 fn qnh_reduction_adds_the_station_height_to_the_qnhs_pressure_altitude() {
1905 // What an altimeter setting means: an altimeter set to QNH reads field elevation on
1906 // the ground, so the station pressure's ISA pressure altitude is the QNH's own pressure
1907 // altitude plus the station's (geopotential) height. The troposphere's pressure
1908 // altitude is H(p) = (T0/-L) * (1 - (p/P0)^n), n = -L*R/g.
1909 let layer = &ICAO_LAYERS[0];
1910 let n = -layer.lapse_rate * R_AIR / G_ACCEL_MPS2;
1911 let p0_hpa = layer.base_pressure / 100.0;
1912 let pressure_altitude_m = |pressure_hpa: f64| {
1913 layer.base_temperature / -layer.lapse_rate * (1.0 - (pressure_hpa / p0_hpa).powf(n))
1914 };
1915 // The unit-test pin, Cusco (SPZO), La Paz (SLLP), low and high settings, a standard
1916 // setting, and a station below sea level.
1917 for (qnh_hpa, altitude_m) in [
1918 (1030.0, 1500.0),
1919 (1033.0, 3310.0),
1920 (1038.0, 4061.0),
1921 (1000.0, 2000.0),
1922 (980.0, 1500.0),
1923 (1040.0, 4500.0),
1924 (1013.25, 3000.0),
1925 (1030.0, -400.0),
1926 ] {
1927 let station_hpa = reduce_qnh_to_station_pressure(qnh_hpa, altitude_m);
1928 let added_m = pressure_altitude_m(station_hpa) - pressure_altitude_m(qnh_hpa);
1929 let height_m = geometric_to_geopotential_height_m(altitude_m);
1930 assert!(
1931 (added_m - height_m).abs() < 1e-6,
1932 "QNH {qnh_hpa} hPa at {altitude_m} m: station {station_hpa} hPa adds \
1933 {added_m} m of pressure altitude, expected {height_m} m"
1934 );
1935 }
1936 }
1937
1938 #[test]
1939 fn qnh_reduction_is_nan_where_no_station_pressure_exists() {
1940 // A station pressure exists only while the QNH's pressure altitude plus the station
1941 // height stays below T0/-L (44,331 m, where the troposphere's lapse rate reaches
1942 // absolute zero). Past that the result is NaN, which every caller's finite-pressure
1943 // check refuses, rather than a near-vacuum number that would be flown as real air.
1944 assert!(reduce_qnh_to_station_pressure(500.0, 40_000.0).is_nan());
1945 assert!(reduce_qnh_to_station_pressure(-1030.0, 1500.0).is_nan());
1946 // Everywhere real shooting happens the reduction is finite and positive, lowers the
1947 // pressure above sea level and raises it below.
1948 for qnh_step in 0..=24 {
1949 let qnh_hpa = 850.0 + 10.0 * f64::from(qnh_step);
1950 for altitude_step in 0..=20 {
1951 let altitude_m = -1000.0 + 500.0 * f64::from(altitude_step);
1952 let station_hpa = reduce_qnh_to_station_pressure(qnh_hpa, altitude_m);
1953 assert!(
1954 station_hpa.is_finite() && station_hpa > 0.0,
1955 "QNH {qnh_hpa} hPa at {altitude_m} m gave {station_hpa} hPa"
1956 );
1957 assert_eq!(
1958 station_hpa.partial_cmp(&qnh_hpa),
1959 0.0.partial_cmp(&altitude_m),
1960 "QNH {qnh_hpa} hPa at {altitude_m} m gave {station_hpa} hPa"
1961 );
1962 }
1963 }
1964 }
1965
1966 #[test]
1967 fn resolve_station_pressure_with_mode_absolute_matches_resolve_station_pressure() {
1968 // PressureReferenceMode::Absolute must be byte-identical to resolve_station_pressure
1969 // for every case that function's own contract test exercises.
1970 for (pressure_hpa, altitude_m) in [
1971 (1013.25, 2000.0),
1972 (1013.21, 2000.0),
1973 (850.0, 2000.0),
1974 (1013.25, 0.0),
1975 ] {
1976 assert_eq!(
1977 resolve_station_pressure_with_mode(
1978 pressure_hpa,
1979 altitude_m,
1980 PressureReferenceMode::Absolute
1981 ),
1982 resolve_station_pressure(pressure_hpa, altitude_m)
1983 );
1984 }
1985 }
1986
1987 #[test]
1988 fn resolve_station_pressure_with_mode_qnh_bypasses_the_default_sentinel() {
1989 // Constructed so the REDUCED value coincidentally lands within the absolute-mode
1990 // sentinel's +/-0.5 hPa tolerance of 1013.25 hPa at a real (>1 m) altitude -- exactly
1991 // the case that would silently discard an explicit reading under the old,
1992 // mode-blind resolve_station_pressure.
1993 let qnh = 1030.0;
1994 let altitude_m = 138.078_300_203_223_02;
1995 let reduced = reduce_qnh_to_station_pressure(qnh, altitude_m);
1996 assert!(
1997 (reduced - 1013.25).abs() < 0.5,
1998 "test fixture must land inside the sentinel band; reduced={reduced}"
1999 );
2000
2001 // Qnh mode must still return Some(reduced) -- never None -- regardless of the
2002 // coincidence, and must equal the reduction, not a derived-from-altitude value.
2003 assert_eq!(
2004 resolve_station_pressure_with_mode(qnh, altitude_m, PressureReferenceMode::Qnh),
2005 Some(reduced)
2006 );
2007
2008 // Sanity: the OLD absolute-mode function, given that same reduced number directly,
2009 // WOULD have discarded it (returned None, deriving ICAO-standard pressure instead) --
2010 // demonstrating why Qnh mode must never be routed back through the plain sentinel
2011 // check on its output.
2012 assert_eq!(resolve_station_pressure(reduced, altitude_m), None);
2013 let (_, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
2014 assert!(
2015 (std_pressure_pa / 100.0 - reduced).abs() > 10.0,
2016 "fixture must show the sentinel misfire actually changes the answer materially"
2017 );
2018 }
2019
2020 #[test]
2021 fn resolve_station_conditions_with_pressure_mode_absolute_matches_resolve_station_conditions() {
2022 for (temp_c, pressure_hpa, altitude_m) in [
2023 (15.0, 1013.25, 2000.0),
2024 (-5.0, 850.0, 2000.0),
2025 (15.0, 1013.25, 0.0),
2026 ] {
2027 assert_eq!(
2028 resolve_station_conditions_with_pressure_mode(
2029 temp_c,
2030 pressure_hpa,
2031 altitude_m,
2032 PressureReferenceMode::Absolute
2033 ),
2034 resolve_station_conditions(temp_c, pressure_hpa, altitude_m)
2035 );
2036 }
2037 }
2038
2039 #[test]
2040 fn resolve_station_conditions_with_pressure_mode_qnh_reduces_pressure_only() {
2041 // -5.0 C is not the sea-level default sentinel (15.0 C), so it stays authoritative --
2042 // isolating that only the pressure resolution differs between the two modes.
2043 let (temp_c, pressure_hpa) = resolve_station_conditions_with_pressure_mode(
2044 -5.0,
2045 1030.0,
2046 1500.0,
2047 PressureReferenceMode::Qnh,
2048 );
2049 // Temperature resolution is untouched by pressure mode: an explicit, non-default
2050 // temperature stays authoritative exactly as resolve_station_conditions would give.
2051 assert_eq!(temp_c, -5.0);
2052 assert!((pressure_hpa - 860.068_111_433_278_3).abs() < 1e-9);
2053 }
2054
2055 #[test]
2056 fn test_altitude_affects_density_with_default_pressure() {
2057 // Regression: with the default pressure, altitude MUST lower density (previously the
2058 // air-density path ignored altitude whenever pressure was the sea-level default).
2059 let press = resolve_station_pressure(1013.25, 0.0);
2060 let (rho_sea, _) = calculate_atmosphere(0.0, Some(15.0), press, 50.0);
2061 let press_alt = resolve_station_pressure(1013.25, 2000.0);
2062 let (rho_2km, _) = calculate_atmosphere(2000.0, Some(15.0), press_alt, 50.0);
2063 assert!(
2064 rho_2km < rho_sea * 0.9,
2065 "density at 2000 m ({rho_2km}) should be well below sea level ({rho_sea})"
2066 );
2067
2068 // But an explicit station pressure stays authoritative (no altitude double-count):
2069 // density with an explicit pressure is independent of the altitude field.
2070 let p = resolve_station_pressure(900.0, 2000.0);
2071 let (rho_a, _) = calculate_atmosphere(2000.0, Some(15.0), p, 50.0);
2072 let (rho_b, _) = calculate_atmosphere(0.0, Some(15.0), p, 50.0);
2073 assert!(
2074 (rho_a - rho_b).abs() < 1e-9,
2075 "explicit pressure must ignore altitude"
2076 );
2077 }
2078
2079 #[test]
2080 fn test_resolve_station_temperature_contract() {
2081 // Default 15 C + real altitude => derive ICAO lapse temperature (None).
2082 assert_eq!(resolve_station_temperature(15.0, 2000.0), None);
2083 // An explicit, non-default temperature is authoritative (Some, used directly).
2084 assert_eq!(resolve_station_temperature(-5.0, 2000.0), Some(-5.0));
2085 assert_eq!(resolve_station_temperature(30.0, 2000.0), Some(30.0));
2086 // At/near sea level the default is used directly (no derivation needed).
2087 assert_eq!(resolve_station_temperature(15.0, 0.0), Some(15.0));
2088 }
2089
2090 #[test]
2091 fn test_altitude_only_default_matches_full_icao_standard() {
2092 // Regression: resolving BOTH temperature and pressure for an altitude-only query (defaults
2093 // left in place) must equal the fully-standard atmosphere at that altitude — i.e. altitude
2094 // now drives temperature (ICAO lapse) AND pressure, not just pressure. Validated against
2095 // py_ballisticcalc to ~0.04%. Previously the air held 15 C, leaving density ~7% too thin
2096 // (warm) at 3 km.
2097 for alt in [1000.0, 2000.0, 2500.0, 3000.0] {
2098 let t = resolve_station_temperature(15.0, alt);
2099 let p = resolve_station_pressure(1013.25, alt);
2100 let (rho_resolved, _) = calculate_atmosphere(alt, t, p, 0.0);
2101 let (rho_std, _) = calculate_atmosphere(alt, None, None, 0.0);
2102 assert!(
2103 (rho_resolved - rho_std).abs() < 1e-9,
2104 "alt {alt}: altitude-only default density {rho_resolved} should equal the full \
2105 ICAO standard {rho_std}"
2106 );
2107 // And it must be denser than the old temperature-held-at-15C behavior (colder = denser).
2108 let (rho_warm, _) = calculate_atmosphere(alt, Some(15.0), p, 0.0);
2109 assert!(
2110 rho_resolved > rho_warm,
2111 "alt {alt}: lapse-temperature density {rho_resolved} should exceed 15 C-held {rho_warm}"
2112 );
2113 }
2114 }
2115
2116 #[test]
2117 fn test_enhanced_atmosphere_with_humidity() {
2118 let (density_dry, speed_dry) = calculate_atmosphere(0.0, None, None, 0.0);
2119 let (density_humid, speed_humid) = calculate_atmosphere(0.0, None, None, 80.0);
2120
2121 // Humid air should be less dense
2122 assert!(density_humid < density_dry);
2123 // Humid air should have slightly higher speed of sound
2124 assert!(speed_humid > speed_dry);
2125 }
2126
2127 #[test]
2128 fn test_enhanced_atmosphere_stratosphere() {
2129 // Test in stratosphere where temperature increases
2130 let (density_20km, speed_20km) = calculate_atmosphere(20000.0, None, None, 0.0);
2131 let (density_30km, speed_30km) = calculate_atmosphere(30000.0, None, None, 0.0);
2132
2133 // Density should decrease with altitude
2134 assert!(density_30km < density_20km);
2135 // Speed of sound should increase due to temperature increase
2136 assert!(speed_30km > speed_20km);
2137 }
2138
2139 #[test]
2140 fn test_enhanced_cimp_density() {
2141 let density = calculate_air_density_cimp(15.0, 1013.25, 0.0);
2142 assert!((density - 1.225).abs() < 0.01);
2143
2144 // Test with humidity
2145 let density_humid = calculate_air_density_cimp(15.0, 1013.25, 50.0);
2146 assert!(density_humid < density);
2147 }
2148
2149 #[test]
2150 fn test_cipm_moist_air_matches_python_reference() {
2151 // Regression for the hPa/Pa mole-fraction slip: p_v (hPa) was divided by the total
2152 // pressure in Pa, making x_v 100x too small and erasing the humidity effect entirely.
2153 // Reference values computed with the validated Python implementation
2154 // (ballistics.physics.atmosphere_icao.calculate_air_density_cipm_icao), same cases as
2155 // the Flask suite's tests/test_atmosphere.py::TestCalculateAirDensityCIPM. Tolerance
2156 // 0.1% (matches that suite's Rust-vs-Python assertion).
2157 let cases = [
2158 (15.0, 1013.25, 50.0, 1.221125867723075),
2159 (30.0, 1000.0, 80.0, 1.1344071877123691),
2160 (-10.0, 1020.0, 20.0, 1.3500610713710515),
2161 ];
2162 for (temp_c, pressure_hpa, humidity_pct, expected) in cases {
2163 let density = calculate_air_density_cipm(temp_c, pressure_hpa, humidity_pct);
2164 let rel_err = ((density - expected) / expected).abs();
2165 assert!(
2166 rel_err < 1e-3,
2167 "CIPM density at {temp_c} C / {pressure_hpa} hPa / {humidity_pct}% RH: \
2168 got {density}, expected {expected} (rel err {rel_err:.2e} >= 1e-3)"
2169 );
2170 }
2171
2172 // Moist air must be materially lighter than dry air at the same temp/pressure
2173 // (the broken version returned a difference of only ~4e-5 kg/m^3).
2174 let dry = calculate_air_density_cipm(15.0, 1013.25, 0.0);
2175 let moist = calculate_air_density_cipm(15.0, 1013.25, 50.0);
2176 assert!(
2177 dry - moist > 3e-3,
2178 "humidity effect too small: dry {dry} vs 50% RH {moist}"
2179 );
2180 }
2181
2182 #[test]
2183 fn test_variable_lapse_rates() {
2184 // Test that lapse rates change appropriately with altitude
2185 let lapse_tropo = determine_local_lapse_rate(5000.0);
2186 let lapse_strato = determine_local_lapse_rate(25000.0);
2187
2188 assert!((lapse_tropo - (-0.0065)).abs() < 0.0001);
2189 assert!(lapse_strato > 0.0); // Positive lapse rate in stratosphere
2190 }
2191
2192 // ---- MBA-1137: AtmoSock stateless downrange lookup (mirrors the WindSock tests) ----
2193
2194 #[test]
2195 fn test_atmo_sock_empty_falls_back_to_isa() {
2196 let sock = AtmoSock::new(vec![]);
2197 assert!(sock.is_empty());
2198 // Empty sock returns the sea-level ISA reference regardless of distance.
2199 assert_eq!(sock.atmo_for_range(0.0), (15.0, 1013.25, 0.0));
2200 assert_eq!(sock.atmo_for_range(500.0), (15.0, 1013.25, 0.0));
2201 }
2202
2203 #[test]
2204 fn test_atmo_sock_single_segment_holds_beyond_last() {
2205 // One zone until 100 m; it must apply BOTH before and beyond the threshold (unlike wind,
2206 // which zeroes past the last segment — atmosphere holds the last zone).
2207 let sock = AtmoSock::new(vec![(25.0, 1000.0, 30.0, 100.0)]);
2208 assert_eq!(sock.atmo_for_range(50.0), (25.0, 1000.0, 30.0));
2209 assert_eq!(sock.atmo_for_range(100.0), (25.0, 1000.0, 30.0)); // beyond last -> hold
2210 assert_eq!(sock.atmo_for_range(5000.0), (25.0, 1000.0, 30.0));
2211 }
2212
2213 #[test]
2214 fn test_atmo_sock_boundary_is_upper_exclusive() {
2215 // A zone's until_distance_m is exclusive: a query exactly at the boundary rolls to the
2216 // next zone (mirrors WindSock::test_wind_sock_boundary_is_upper_exclusive).
2217 let sock = AtmoSock::new(vec![
2218 (30.0, 1010.0, 80.0, 100.0), // hot/humid near zone
2219 (-5.0, 900.0, 10.0, 200.0), // cold/thin far zone
2220 ]);
2221 // Just below 100 m -> first zone.
2222 assert_eq!(sock.atmo_for_range(99.999), (30.0, 1010.0, 80.0));
2223 // Exactly 100 m -> second zone.
2224 assert_eq!(sock.atmo_for_range(100.0), (-5.0, 900.0, 10.0));
2225 // Beyond the last boundary -> hold the last zone (NOT zeroed).
2226 assert_eq!(sock.atmo_for_range(200.0), (-5.0, 900.0, 10.0));
2227 assert_eq!(sock.atmo_for_range(1e6), (-5.0, 900.0, 10.0));
2228 }
2229
2230 #[test]
2231 fn test_atmo_sock_sorts_unordered_segments() {
2232 // Segments supplied out of order are sorted by until_distance so the lookup is monotone.
2233 let sock = AtmoSock::new(vec![
2234 (-5.0, 900.0, 10.0, 200.0),
2235 (30.0, 1010.0, 80.0, 100.0),
2236 ]);
2237 assert_eq!(sock.atmo_for_range(50.0), (30.0, 1010.0, 80.0));
2238 assert_eq!(sock.atmo_for_range(150.0), (-5.0, 900.0, 10.0));
2239 }
2240
2241 #[test]
2242 fn test_atmo_sock_orders_nan_thresholds_last() {
2243 let positive_nan = f64::from_bits(0x7ff8_0000_0000_0001);
2244 let negative_nan = f64::from_bits(0xfff8_0000_0000_0002);
2245 let sock = AtmoSock::new(vec![
2246 (97.0, 997.0, 97.0, positive_nan),
2247 (30.0, 1030.0, 30.0, 300.0),
2248 (98.0, 998.0, 98.0, negative_nan),
2249 (10.0, 1010.0, 10.0, 100.0),
2250 (99.0, 999.0, 99.0, f64::NAN),
2251 (20.0, 1020.0, 20.0, 200.0),
2252 ]);
2253
2254 let thresholds: Vec<_> = sock.segments.iter().map(|segment| segment.3).collect();
2255 assert_eq!(&thresholds[..3], &[100.0, 200.0, 300.0]);
2256 assert!(thresholds[3..].iter().all(|threshold| threshold.is_nan()));
2257 assert_eq!(sock.atmo_for_range(f64::NAN), (10.0, 1010.0, 10.0));
2258 assert_eq!(sock.atmo_for_range(350.0), (99.0, 999.0, 99.0));
2259 }
2260
2261 #[test]
2262 fn test_atmo_sock_nan_uses_first_zone() {
2263 let sock = AtmoSock::new(vec![
2264 (30.0, 1010.0, 80.0, 100.0),
2265 (-5.0, 900.0, 10.0, 200.0),
2266 ]);
2267 // NaN can't be ordered; deterministically use the nearest (first) zone rather than panic.
2268 assert_eq!(sock.atmo_for_range(f64::NAN), (30.0, 1010.0, 80.0));
2269 }
2270
2271 // ---- MBA-1366: density altitude as a direct atmosphere input ----
2272
2273 /// With no explicit temperature, the resolved altitude must equal the input density
2274 /// altitude EXACTLY (this is the algebraic identity documented on
2275 /// `resolve_atmosphere_for_density_altitude`: the correction term vanishes when station
2276 /// temperature is left at ISA).
2277 #[test]
2278 fn density_altitude_default_temp_pressure_alt_equals_density_alt() {
2279 for da_ft in [0.0_f64, 1000.0, 3000.0, 7500.0, -500.0] {
2280 let da_m = da_ft * DA_FT_TO_M;
2281 let (altitude_m, temp_c, pressure_hpa) =
2282 resolve_atmosphere_for_density_altitude(da_m, None);
2283 assert!(
2284 (altitude_m - da_m).abs() < 1e-6,
2285 "da_ft={da_ft}: altitude_m {altitude_m} should equal da_m {da_m}"
2286 );
2287 // Sanity: pressure must be a plausible station pressure (strictly decreasing with DA).
2288 assert!(pressure_hpa > 0.0 && pressure_hpa < 1100.0);
2289 assert!(temp_c.is_finite());
2290 }
2291 // At DA=0 the result must be exactly the sea-level ISA reference.
2292 let (alt0, temp0, press0) = resolve_atmosphere_for_density_altitude(0.0, None);
2293 assert!(alt0.abs() < 1e-9);
2294 assert!((temp0 - 15.0).abs() < 1e-6);
2295 assert!((press0 - 1013.25).abs() < 1e-6);
2296 }
2297
2298 /// Higher density altitude at the ISA default must yield a lower station pressure (thinner
2299 /// air), matching the physical meaning of density altitude.
2300 #[test]
2301 fn density_altitude_pressure_decreases_monotonically() {
2302 let (_, _, p0) = resolve_atmosphere_for_density_altitude(0.0, None);
2303 let (_, _, p1) = resolve_atmosphere_for_density_altitude(1000.0, None);
2304 let (_, _, p2) = resolve_atmosphere_for_density_altitude(3000.0, None);
2305 assert!(p1 < p0);
2306 assert!(p2 < p1);
2307 }
2308
2309 /// An explicit temperature must be honored EXACTLY (never re-derived), while the implied
2310 /// pressure altitude (and therefore the resolved altitude/pressure) differs from the
2311 /// ISA-default case -- proving density is still honored (a different pressure/altitude
2312 /// combination that reproduces the SAME density altitude at the warmer/colder temperature).
2313 #[test]
2314 fn density_altitude_explicit_temperature_overrides_isa_default_but_honors_density() {
2315 let da_m = 1000.0 * DA_FT_TO_M;
2316 let (default_alt_m, default_temp_c, default_pressure_hpa) =
2317 resolve_atmosphere_for_density_altitude(da_m, None);
2318
2319 // A HOTTER-than-ISA explicit station temperature at the same density altitude implies a
2320 // LOWER pressure altitude: hot (less dense) air already accounts for some of the
2321 // "thinness" that defines the density altitude, so less physical elevation is needed to
2322 // reach the rest of it (equivalently: the correction term subtracts from DA before the
2323 // pressure inversion, since a warmer station reads MORE dense-feeling per unit of actual
2324 // elevation than ISA does).
2325 let hot_temp_c = default_temp_c + 20.0;
2326 let (hot_alt_m, hot_temp_out, hot_pressure_hpa) =
2327 resolve_atmosphere_for_density_altitude(da_m, Some(hot_temp_c));
2328 assert_eq!(
2329 hot_temp_out, hot_temp_c,
2330 "explicit temperature must be honored exactly"
2331 );
2332 assert!(
2333 hot_alt_m < default_alt_m,
2334 "hotter station temp at same DA should imply a lower pressure altitude: \
2335 hot={hot_alt_m} default={default_alt_m}"
2336 );
2337 assert!(hot_pressure_hpa > default_pressure_hpa);
2338
2339 // A COLDER-than-ISA explicit temperature implies a HIGHER pressure altitude.
2340 let cold_temp_c = default_temp_c - 20.0;
2341 let (cold_alt_m, cold_temp_out, cold_pressure_hpa) =
2342 resolve_atmosphere_for_density_altitude(da_m, Some(cold_temp_c));
2343 assert_eq!(cold_temp_out, cold_temp_c);
2344 assert!(cold_alt_m > default_alt_m);
2345 assert!(cold_pressure_hpa < default_pressure_hpa);
2346 }
2347
2348 /// Round trip using ONLY this crate's own math (the forward NWS pressure-altitude formula,
2349 /// re-derived independently in the test rather than reusing the production function) -- an
2350 /// implementation-independent algebraic sanity check. The REAL cross-crate round trip
2351 /// against the production `pdf_dope_card::calculate_density_altitude` forward function
2352 /// lives in `src/main.rs` (that function is CLI-binary-only and unreachable from this
2353 /// library crate).
2354 #[test]
2355 fn density_altitude_round_trips_through_hand_derived_forward_formula() {
2356 fn forward_density_altitude_ft(pressure_hpa: f64, temp_c: f64) -> f64 {
2357 let pressure_alt_ft = 145_366.45 * (1.0 - (pressure_hpa / 1013.25).powf(0.190_284));
2358 let isa_temp_f = 59.0 - (pressure_alt_ft / 1000.0) * 3.57;
2359 let temp_f = temp_c * 9.0 / 5.0 + 32.0;
2360 pressure_alt_ft + (120.0 * 5.0 / 9.0) * (temp_f - isa_temp_f)
2361 }
2362
2363 for da_ft in [0.0_f64, 500.0, 2500.0, 6000.0] {
2364 let da_m = da_ft * DA_FT_TO_M;
2365 // Default (ISA-at-DA) branch.
2366 let (_, temp_c, pressure_hpa) = resolve_atmosphere_for_density_altitude(da_m, None);
2367 let round_tripped_ft = forward_density_altitude_ft(pressure_hpa, temp_c);
2368 assert!(
2369 (round_tripped_ft - da_ft).abs() < 1e-6,
2370 "da_ft={da_ft}: round-tripped {round_tripped_ft} (temp_c={temp_c}, pressure_hpa={pressure_hpa})"
2371 );
2372
2373 // Explicit-temperature branch.
2374 let explicit_temp_c = 25.0;
2375 let (_, temp_out, pressure_hpa_explicit) =
2376 resolve_atmosphere_for_density_altitude(da_m, Some(explicit_temp_c));
2377 assert_eq!(temp_out, explicit_temp_c);
2378 let round_tripped_explicit_ft =
2379 forward_density_altitude_ft(pressure_hpa_explicit, temp_out);
2380 assert!(
2381 (round_tripped_explicit_ft - da_ft).abs() < 1e-6,
2382 "da_ft={da_ft} (explicit {explicit_temp_c}C): round-tripped {round_tripped_explicit_ft}"
2383 );
2384 }
2385 }
2386}
2387
2388/// Station-presence resolution (0.49.0): a supplied station reading is never read as omitted.
2389#[cfg(test)]
2390mod station_presence_tests {
2391 use super::*;
2392
2393 /// The body of `resolve_station_conditions_with_pressure_mode` exactly as it stood at
2394 /// ab4eb0c, before presence existed. It calls only the primitives this change leaves
2395 /// alone, so comparing against it cannot pass by comparing the new code with itself.
2396 fn legacy_resolve_ab4eb0c(
2397 temperature_c: f64,
2398 pressure_hpa: f64,
2399 altitude_m: f64,
2400 pressure_mode: PressureReferenceMode,
2401 ) -> (f64, f64) {
2402 let temp_override = resolve_station_temperature(temperature_c, altitude_m);
2403 let press_override =
2404 resolve_station_pressure_with_mode(pressure_hpa, altitude_m, pressure_mode);
2405 let (std_temp_k, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
2406 let temp_c = temp_override.unwrap_or(std_temp_k - 273.15);
2407 let pressure_hpa = press_override.unwrap_or(std_pressure_pa / 100.0);
2408 (temp_c, pressure_hpa)
2409 }
2410
2411 const TEMPERATURES_C: [f64; 10] = [
2412 -40.0, 14.89, 14.9, 14.95, 15.0, 15.05, 15.0999, 15.1, 15.2, 30.0,
2413 ];
2414 const PRESSURES_HPA: [f64; 11] = [
2415 1012.7,
2416 1012.75,
2417 1012.8562053925472,
2418 1013.0,
2419 1013.2079,
2420 1013.25,
2421 1013.5,
2422 1013.75,
2423 1014.0,
2424 850.0,
2425 1031.0,
2426 ];
2427 const ALTITUDES_M: [f64; 13] = [
2428 -2.0, -1.0, -0.5, 0.0, 0.5, 1.0, 1.0000001, 3.0, 10.0, 150.0, 1500.0, 1524.0, 5000.0,
2429 ];
2430 const MODES: [PressureReferenceMode; 2] =
2431 [PressureReferenceMode::Absolute, PressureReferenceMode::Qnh];
2432
2433 fn assert_bits(actual: (f64, f64), expected: (f64, f64), context: &str) {
2434 assert_eq!(
2435 actual.0.to_bits(),
2436 expected.0.to_bits(),
2437 "temperature {context}: {actual:?} vs {expected:?}"
2438 );
2439 assert_eq!(
2440 actual.1.to_bits(),
2441 expected.1.to_bits(),
2442 "pressure {context}: {actual:?} vs {expected:?}"
2443 );
2444 }
2445
2446 #[test]
2447 fn neither_is_bit_identical_to_ab4eb0c() {
2448 for t in TEMPERATURES_C {
2449 for p in PRESSURES_HPA {
2450 for alt in ALTITUDES_M {
2451 for mode in MODES {
2452 let expected = legacy_resolve_ab4eb0c(t, p, alt, mode);
2453 let context = format!("({t}, {p}, {alt}, {mode:?})");
2454 assert_bits(
2455 resolve_station_conditions_with_presence(
2456 t,
2457 p,
2458 alt,
2459 mode,
2460 StationAtmospherePresence::NEITHER,
2461 ),
2462 expected,
2463 &context,
2464 );
2465 assert_bits(
2466 resolve_station_conditions_with_pressure_mode(t, p, alt, mode),
2467 expected,
2468 &context,
2469 );
2470 }
2471 }
2472 }
2473 }
2474
2475 // Six points captured at ab4eb0c, so a change to a shared primitive cannot pass
2476 // unseen through the verbatim copy above.
2477 let pinned = [
2478 (
2479 (15.05, 1013.0, 150.0, PressureReferenceMode::Absolute),
2480 // captured at ab4eb0c
2481 (
2482 f64::from_bits(0x402c0ccfd0c49b20),
2483 f64::from_bits(0x408f1ae13f434f1f),
2484 ),
2485 ),
2486 (
2487 (15.0, 1013.25, 1500.0, PressureReferenceMode::Absolute),
2488 // captured at ab4eb0c
2489 (
2490 f64::from_bits(0x4015025af8d136c0),
2491 f64::from_bits(0x408a6cc6358cefbd),
2492 ),
2493 ),
2494 (
2495 (15.0, 1013.25, 10.0, PressureReferenceMode::Qnh),
2496 // captured at ab4eb0c
2497 (
2498 f64::from_bits(0x402ddeb85559de00),
2499 f64::from_bits(0x408fa064e4e5f101),
2500 ),
2501 ),
2502 (
2503 (15.0, 1013.2079, 1524.0, PressureReferenceMode::Qnh),
2504 // captured at ab4eb0c
2505 (
2506 f64::from_bits(0x401462aff47e8bc0),
2507 f64::from_bits(0x408a58982cf0204f),
2508 ),
2509 ),
2510 (
2511 (
2512 14.95,
2513 1012.8562053925472,
2514 1.0000001,
2515 PressureReferenceMode::Absolute,
2516 ),
2517 // captured at ab4eb0c
2518 (
2519 f64::from_bits(0x402dfcac08345a20),
2520 f64::from_bits(0x408fa909fb979713),
2521 ),
2522 ),
2523 (
2524 (15.0, 1013.25, 3.0, PressureReferenceMode::Absolute),
2525 // captured at ab4eb0c
2526 (
2527 f64::from_bits(0x402df60418e281e0),
2528 f64::from_bits(0x408fa71e04fe2409),
2529 ),
2530 ),
2531 ];
2532 for ((t, p, alt, mode), expected) in pinned {
2533 let context = format!("pinned ({t}, {p}, {alt}, {mode:?})");
2534 assert_bits(
2535 resolve_station_conditions_with_presence(
2536 t,
2537 p,
2538 alt,
2539 mode,
2540 StationAtmospherePresence::NEITHER,
2541 ),
2542 expected,
2543 &context,
2544 );
2545 assert_bits(
2546 resolve_station_conditions_with_pressure_mode(t, p, alt, mode),
2547 expected,
2548 &context,
2549 );
2550 }
2551 }
2552
2553 #[test]
2554 fn both_returns_the_supplied_pair() {
2555 for t in TEMPERATURES_C {
2556 for p in PRESSURES_HPA {
2557 for alt in ALTITUDES_M {
2558 let context = format!("({t}, {p}, {alt})");
2559 assert_bits(
2560 resolve_station_conditions_with_presence(
2561 t,
2562 p,
2563 alt,
2564 PressureReferenceMode::Absolute,
2565 StationAtmospherePresence::BOTH,
2566 ),
2567 (t, p),
2568 &context,
2569 );
2570 assert_bits(
2571 resolve_station_conditions_with_presence(
2572 t,
2573 p,
2574 alt,
2575 PressureReferenceMode::Qnh,
2576 StationAtmospherePresence::BOTH,
2577 ),
2578 (t, reduce_qnh_to_station_pressure(p, alt)),
2579 &context,
2580 );
2581 }
2582 }
2583 }
2584 // The headline repro: QNH 1031 hPa at 150 m reduces into the band and still flies.
2585 let (_, p) = resolve_station_conditions_with_presence(
2586 20.0,
2587 1031.0,
2588 150.0,
2589 PressureReferenceMode::Qnh,
2590 StationAtmospherePresence::BOTH,
2591 );
2592 assert_eq!(p.to_bits(), 1012.8562053925472_f64.to_bits(), "{p:?}");
2593 }
2594
2595 #[test]
2596 fn presence_is_per_field() {
2597 let (icao_t_k, icao_p_pa) = calculate_icao_standard_atmosphere(1500.0);
2598 let temperature_only = resolve_station_conditions_with_presence(
2599 15.0,
2600 1013.25,
2601 1500.0,
2602 PressureReferenceMode::Absolute,
2603 StationAtmospherePresence::new(true, false),
2604 );
2605 assert_bits(temperature_only, (15.0, icao_p_pa / 100.0), "(true, false)");
2606 let pressure_only = resolve_station_conditions_with_presence(
2607 15.0,
2608 1013.0,
2609 1500.0,
2610 PressureReferenceMode::Absolute,
2611 StationAtmospherePresence::new(false, true),
2612 );
2613 assert_bits(pressure_only, (icao_t_k - 273.15, 1013.0), "(false, true)");
2614 }
2615
2616 #[test]
2617 fn presence_constructors_agree() {
2618 assert_eq!(
2619 StationAtmospherePresence::default(),
2620 StationAtmospherePresence::NEITHER
2621 );
2622 assert_eq!(
2623 StationAtmospherePresence::NEITHER,
2624 StationAtmospherePresence::new(false, false)
2625 );
2626 assert_eq!(
2627 StationAtmospherePresence::BOTH,
2628 StationAtmospherePresence::new(true, true)
2629 );
2630 assert_eq!(
2631 StationAtmospherePresence::from_readings(
2632 StationReading::supplied(15.0),
2633 StationReading::omitted(1013.25)
2634 ),
2635 StationAtmospherePresence::new(true, false)
2636 );
2637 assert_eq!(
2638 StationAtmospherePresence::from_readings(
2639 StationReading::omitted(59.0),
2640 StationReading::supplied(29.92)
2641 ),
2642 StationAtmospherePresence::new(false, true)
2643 );
2644 assert!(StationReading::omitted(29.92).is_omitted());
2645 assert!(!StationReading::supplied(29.92).is_omitted());
2646 assert_eq!(
2647 StationReading::omitted(29.92).value.to_bits(),
2648 29.92_f64.to_bits()
2649 );
2650 }
2651
2652 #[derive(Debug, serde::Serialize, serde::Deserialize)]
2653 #[serde(deny_unknown_fields)]
2654 struct Probe {
2655 #[serde(
2656 default = "probe_default",
2657 skip_serializing_if = "StationReading::is_omitted"
2658 )]
2659 pressure_inhg: StationReading,
2660 }
2661
2662 fn probe_default() -> StationReading {
2663 StationReading::omitted(29.92)
2664 }
2665
2666 #[test]
2667 fn station_reading_serde() {
2668 let absent: Probe = serde_json::from_str("{}").unwrap();
2669 assert!(!absent.pressure_inhg.supplied);
2670 assert_eq!(absent.pressure_inhg.value.to_bits(), 29.92_f64.to_bits());
2671
2672 let present: Probe = serde_json::from_str(r#"{"pressure_inhg": 29.92}"#).unwrap();
2673 assert!(present.pressure_inhg.supplied);
2674 assert_eq!(present.pressure_inhg.value.to_bits(), 29.92_f64.to_bits());
2675
2676 assert_eq!(serde_json::to_string(&absent).unwrap(), "{}");
2677 assert_eq!(
2678 serde_json::to_string(&present).unwrap(),
2679 r#"{"pressure_inhg":29.92}"#
2680 );
2681
2682 assert!(serde_json::from_str::<Probe>(r#"{"pressure_inhg": null}"#).is_err());
2683 assert!(serde_json::from_str::<Probe>(r#"{"pressure_inhg": "29.92"}"#).is_err());
2684 }
2685}