use std::cmp::Ordering;
#[derive(Debug, Clone)]
struct AtmosphereLayer {
base_altitude: f64,
base_temperature: f64,
base_pressure: f64,
lapse_rate: f64,
}
const G_ACCEL_MPS2: f64 = 9.80665;
const R_AIR: f64 = 287.0531; const GAMMA: f64 = 1.4; const GEOPOTENTIAL_EARTH_RADIUS_M: f64 = 6_356_766.0;
const MIN_GEOMETRIC_ALTITUDE_M: f64 = -5000.0;
const MAX_GEOMETRIC_ALTITUDE_M: f64 = 84000.0;
const MIN_STANDARD_GEOPOTENTIAL_HEIGHT_M: f64 = -5000.0;
const MAX_STANDARD_GEOPOTENTIAL_HEIGHT_M: f64 = 84000.0;
const R: f64 = 8.314472; const M_A: f64 = 28.96546e-3; const M_V: f64 = 18.01528e-3;
const ICAO_LAYERS: &[AtmosphereLayer] = &[
AtmosphereLayer {
base_altitude: 0.0,
base_temperature: 288.15, base_pressure: 101325.0, lapse_rate: -0.0065, },
AtmosphereLayer {
base_altitude: 11000.0,
base_temperature: 216.65, base_pressure: 22632.1, lapse_rate: 0.0, },
AtmosphereLayer {
base_altitude: 20000.0,
base_temperature: 216.65, base_pressure: 5474.89, lapse_rate: 0.001, },
AtmosphereLayer {
base_altitude: 32000.0,
base_temperature: 228.65, base_pressure: 868.02, lapse_rate: 0.0028, },
AtmosphereLayer {
base_altitude: 47000.0,
base_temperature: 270.65, base_pressure: 110.91, lapse_rate: 0.0, },
AtmosphereLayer {
base_altitude: 51000.0,
base_temperature: 270.65, base_pressure: 66.94, lapse_rate: -0.0028, },
AtmosphereLayer {
base_altitude: 71000.0,
base_temperature: 214.65, base_pressure: 3.96, lapse_rate: -0.002, },
];
pub(crate) fn calculate_icao_standard_atmosphere(altitude_m: f64) -> (f64, f64) {
let geometric_altitude = altitude_m.clamp(MIN_GEOMETRIC_ALTITUDE_M, MAX_GEOMETRIC_ALTITUDE_M);
let geopotential_height = geometric_to_geopotential_height_m(geometric_altitude).clamp(
MIN_STANDARD_GEOPOTENTIAL_HEIGHT_M,
MAX_STANDARD_GEOPOTENTIAL_HEIGHT_M,
);
let layer = ICAO_LAYERS
.iter()
.rev()
.find(|layer| geopotential_height >= layer.base_altitude)
.unwrap_or(&ICAO_LAYERS[0]);
let height_diff = geopotential_height - layer.base_altitude;
let temperature = layer.base_temperature + layer.lapse_rate * height_diff;
let pressure = if layer.lapse_rate.abs() < 1e-10 {
layer.base_pressure * (-G_ACCEL_MPS2 * height_diff / (R_AIR * layer.base_temperature)).exp()
} else {
let temp_ratio = temperature / layer.base_temperature;
layer.base_pressure * temp_ratio.powf(-G_ACCEL_MPS2 / (layer.lapse_rate * R_AIR))
};
(temperature, pressure)
}
#[inline]
fn geometric_to_geopotential_height_m(geometric_altitude_m: f64) -> f64 {
GEOPOTENTIAL_EARTH_RADIUS_M * geometric_altitude_m
/ (GEOPOTENTIAL_EARTH_RADIUS_M + geometric_altitude_m)
}
pub fn resolve_station_pressure(pressure_hpa: f64, altitude_m: f64) -> Option<f64> {
const SEA_LEVEL_HPA: f64 = 1013.25;
if (pressure_hpa - SEA_LEVEL_HPA).abs() < 0.5 && altitude_m.abs() > 1.0 {
None } else {
Some(pressure_hpa) }
}
pub fn resolve_station_temperature(temperature_c: f64, altitude_m: f64) -> Option<f64> {
const SEA_LEVEL_TEMP_C: f64 = 15.0;
if (temperature_c - SEA_LEVEL_TEMP_C).abs() < 0.1 && altitude_m.abs() > 1.0 {
None } else {
Some(temperature_c) }
}
pub fn resolve_station_conditions(
temperature_c: f64,
pressure_hpa: f64,
altitude_m: f64,
) -> (f64, f64) {
resolve_station_conditions_with_pressure_mode(
temperature_c,
pressure_hpa,
altitude_m,
PressureReferenceMode::Absolute,
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum PressureReferenceMode {
#[default]
Absolute,
Qnh,
}
pub fn reduce_qnh_to_station_pressure(qnh_hpa: f64, altitude_m: f64) -> f64 {
let geometric_altitude = altitude_m.clamp(MIN_GEOMETRIC_ALTITUDE_M, MAX_GEOMETRIC_ALTITUDE_M);
let geopotential_height = geometric_to_geopotential_height_m(geometric_altitude).clamp(
MIN_STANDARD_GEOPOTENTIAL_HEIGHT_M,
MAX_STANDARD_GEOPOTENTIAL_HEIGHT_M,
);
let layer = &ICAO_LAYERS[0]; let temp_ratio = 1.0 + layer.lapse_rate * geopotential_height / layer.base_temperature;
let exponent = -G_ACCEL_MPS2 / (layer.lapse_rate * R_AIR);
qnh_hpa * temp_ratio.powf(exponent)
}
pub fn resolve_station_pressure_with_mode(
pressure_hpa: f64,
altitude_m: f64,
mode: PressureReferenceMode,
) -> Option<f64> {
match mode {
PressureReferenceMode::Absolute => resolve_station_pressure(pressure_hpa, altitude_m),
PressureReferenceMode::Qnh => {
Some(reduce_qnh_to_station_pressure(pressure_hpa, altitude_m))
}
}
}
pub fn resolve_station_conditions_with_pressure_mode(
temperature_c: f64,
pressure_hpa: f64,
altitude_m: f64,
pressure_mode: PressureReferenceMode,
) -> (f64, f64) {
let temp_override = resolve_station_temperature(temperature_c, altitude_m);
let press_override =
resolve_station_pressure_with_mode(pressure_hpa, altitude_m, pressure_mode);
let (std_temp_k, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
let temp_c = temp_override.unwrap_or(std_temp_k - 273.15);
let pressure_hpa = press_override.unwrap_or(std_pressure_pa / 100.0);
(temp_c, pressure_hpa)
}
const DA_NWS_SEA_LEVEL_HPA: f64 = 1013.25;
const DA_NWS_PRESSURE_ALT_FT: f64 = 145_366.45;
const DA_NWS_EXPONENT: f64 = 0.190_284;
const DA_NWS_SEA_LEVEL_TEMP_F: f64 = 59.0;
const DA_NWS_LAPSE_F_PER_1000FT: f64 = 3.57;
const DA_FT_TO_M: f64 = 0.3048;
pub fn resolve_atmosphere_for_density_altitude(
density_altitude_m: f64,
explicit_temperature_c: Option<f64>,
) -> (f64, f64, f64) {
let density_altitude_ft = density_altitude_m / DA_FT_TO_M;
const K: f64 = 120.0 * 5.0 / 9.0;
let (pressure_alt_ft, temperature_c) = match explicit_temperature_c {
Some(temp_c) => {
let temp_f = temp_c * 9.0 / 5.0 + 32.0;
let denom = 1.0 + K * DA_NWS_LAPSE_F_PER_1000FT / 1000.0;
let pressure_alt_ft =
(density_altitude_ft - K * (temp_f - DA_NWS_SEA_LEVEL_TEMP_F)) / denom;
(pressure_alt_ft, temp_c)
}
None => {
let pressure_alt_ft = density_altitude_ft;
let isa_temp_f =
DA_NWS_SEA_LEVEL_TEMP_F - pressure_alt_ft / 1000.0 * DA_NWS_LAPSE_F_PER_1000FT;
let isa_temp_c = (isa_temp_f - 32.0) * 5.0 / 9.0;
(pressure_alt_ft, isa_temp_c)
}
};
let pressure_hpa = DA_NWS_SEA_LEVEL_HPA
* (1.0 - pressure_alt_ft / DA_NWS_PRESSURE_ALT_FT).powf(1.0 / DA_NWS_EXPONENT);
let altitude_m = pressure_alt_ft * DA_FT_TO_M;
(altitude_m, temperature_c, pressure_hpa)
}
pub fn calculate_atmosphere(
altitude_m: f64,
temp_override_c: Option<f64>,
press_override_hpa: Option<f64>,
humidity_percent: f64,
) -> (f64, f64) {
let combined_overrides = temp_override_c.zip(press_override_hpa);
let (temp_k, pressure_pa) = if let Some((temp_c, pressure_hpa)) = combined_overrides {
(temp_c + 273.15, pressure_hpa * 100.0)
} else {
let (std_temp_k, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
let final_temp_k = if let Some(temp_c) = temp_override_c {
temp_c + 273.15
} else {
std_temp_k
};
let final_pressure_pa = if let Some(press_hpa) = press_override_hpa {
press_hpa * 100.0
} else {
std_pressure_pa
};
(final_temp_k, final_pressure_pa)
};
let humidity_clamped = humidity_percent.clamp(0.0, 100.0);
let temp_c = temp_k - 273.15;
let density = calculate_air_density_cimp(temp_c, pressure_pa / 100.0, humidity_clamped);
let speed_of_sound = moist_speed_of_sound(temp_k, pressure_pa, humidity_clamped);
(density, speed_of_sound)
}
pub fn moist_speed_of_sound(temp_k: f64, pressure_pa: f64, humidity_percent: f64) -> f64 {
let humidity_clamped = humidity_percent.clamp(0.0, 100.0);
let temp_c = temp_k - 273.15;
let p_sv_hpa = enhanced_saturation_vapor_pressure(temp_k);
let f = enhanced_enhancement_factor(pressure_pa, temp_c);
let vapor_pressure_pa = humidity_clamped / 100.0 * f * p_sv_hpa * 100.0;
let mole_fraction_vapor = (vapor_pressure_pa / pressure_pa.max(f64::MIN_POSITIVE)).min(1.0);
let gamma_moist = GAMMA * (1.0 - mole_fraction_vapor * 0.062);
let r_moist = R_AIR * (1.0 + 0.378 * mole_fraction_vapor);
(gamma_moist * r_moist * temp_k).sqrt()
}
pub fn calculate_air_density_cimp(temp_c: f64, pressure_hpa: f64, humidity_percent: f64) -> f64 {
let t_k = temp_c + 273.15;
let p_sv = enhanced_saturation_vapor_pressure(t_k);
let pressure_pa = pressure_hpa * 100.0;
let f = enhanced_enhancement_factor(pressure_pa, temp_c);
let p_v = humidity_percent.clamp(0.0, 100.0) / 100.0 * f * p_sv;
let p_v_pa = p_v * 100.0;
let p_pa = pressure_pa.max(f64::MIN_POSITIVE);
let x_v = (p_v_pa / p_pa).min(1.0);
let z = enhanced_compressibility_factor(p_pa, t_k, x_v);
((p_pa * M_A) / (z * R * t_k)) * (1.0 - x_v * (1.0 - M_V / M_A))
}
#[inline(always)]
fn enhanced_saturation_vapor_pressure(t_k: f64) -> f64 {
const A: [f64; 6] = [
-7.85951783,
1.84408259,
-11.7866497,
22.6807411,
-15.9618719,
1.80122502,
];
let t_k_safe = t_k.max(173.15);
let tau = 1.0 - t_k_safe / 647.096; let ln_p_ratio = (647.096 / t_k_safe)
* (A[0] * tau
+ A[1] * tau.powf(1.5)
+ A[2] * tau.powf(3.0)
+ A[3] * tau.powf(3.5)
+ A[4] * tau.powf(4.0)
+ A[5] * tau.powf(7.5));
220640.0 * ln_p_ratio.exp() }
#[inline(always)]
fn enhanced_enhancement_factor(p: f64, t: f64) -> f64 {
const ALPHA: f64 = 1.00062;
const BETA: f64 = 3.14e-8;
const GAMMA: f64 = 5.6e-7;
ALPHA + BETA * p + GAMMA * t * t
}
#[inline(always)]
fn enhanced_compressibility_factor(p: f64, t_k: f64, x_v: f64) -> f64 {
const A0: f64 = 1.58123e-6;
const A1: f64 = -2.9331e-8;
const A2: f64 = 1.1043e-10;
const B0: f64 = 5.707e-6;
const B1: f64 = -2.051e-8;
const C0: f64 = 1.9898e-4;
const C1: f64 = -2.376e-6;
const D: f64 = 1.83e-11;
const E: f64 = -0.765e-8;
let t_k_safe = t_k.max(173.15); let t = t_k_safe - 273.15;
let p_t = p / t_k_safe;
let z_second_order =
1.0 - p_t * (A0 + A1 * t + A2 * t * t + (B0 + B1 * t) * x_v + (C0 + C1 * t) * x_v * x_v);
let z_third_order = p_t * p_t * (D + E * x_v * x_v);
z_second_order + z_third_order
}
#[inline]
pub(crate) fn shot_frame_altitude(
base_altitude_m: f64,
downrange_m: f64,
shot_y_m: f64,
shooting_angle_rad: f64,
) -> f64 {
base_altitude_m
+ downrange_m * shooting_angle_rad.sin()
+ shot_y_m * shooting_angle_rad.cos()
}
pub fn get_local_atmosphere(
altitude_m: f64,
base_alt: f64,
base_temp_c: f64,
base_press_hpa: f64,
base_ratio: f64,
) -> (f64, f64) {
let (temp_k, _pressure_pa, density) =
local_temp_pressure_density(altitude_m, base_alt, base_temp_c, base_press_hpa, base_ratio);
let speed_of_sound = (temp_k * 401.874).sqrt();
(density, speed_of_sound)
}
pub fn get_local_atmosphere_humid(
altitude_m: f64,
base_alt: f64,
base_temp_c: f64,
base_press_hpa: f64,
base_ratio: f64,
humidity_percent: f64,
) -> (f64, f64) {
let (temp_k, pressure_pa, density) =
local_temp_pressure_density(altitude_m, base_alt, base_temp_c, base_press_hpa, base_ratio);
(density, moist_speed_of_sound(temp_k, pressure_pa, humidity_percent))
}
#[inline]
fn local_temp_pressure_density(
altitude_m: f64,
base_alt: f64,
base_temp_c: f64,
base_press_hpa: f64,
base_ratio: f64,
) -> (f64, f64, f64) {
let base_temp_k = base_temp_c + 273.15;
if !altitude_m.is_finite() || !base_alt.is_finite() {
return (f64::NAN, f64::NAN, f64::NAN);
}
let base_geopotential_m = geometric_to_geopotential_height_m(base_alt);
let target_geopotential_m = geometric_to_geopotential_height_m(altitude_m);
let (temp_k, pressure_hpa) = integrate_local_atmosphere_layers(
base_geopotential_m,
target_geopotential_m,
base_temp_k,
base_press_hpa,
);
let density_ratio = base_ratio * (base_temp_k * pressure_hpa) / (base_press_hpa * temp_k);
let density = density_ratio * 1.225;
(temp_k, pressure_hpa * 100.0, density)
}
fn integrate_local_atmosphere_layers(
base_geopotential_m: f64,
target_geopotential_m: f64,
mut temp_k: f64,
mut pressure_hpa: f64,
) -> (f64, f64) {
let mut current_alt = base_geopotential_m;
if target_geopotential_m > current_alt {
while current_alt < target_geopotential_m {
let layer_index = ICAO_LAYERS
.iter()
.rposition(|layer| current_alt >= layer.base_altitude)
.unwrap_or(0);
let segment_end = ICAO_LAYERS
.get(layer_index + 1)
.map_or(target_geopotential_m, |next| {
target_geopotential_m.min(next.base_altitude)
});
(temp_k, pressure_hpa) = integrate_local_atmosphere_segment(
temp_k,
pressure_hpa,
segment_end - current_alt,
ICAO_LAYERS[layer_index].lapse_rate,
);
current_alt = segment_end;
}
} else {
while current_alt > target_geopotential_m {
let layer_index = ICAO_LAYERS
.iter()
.rposition(|layer| current_alt > layer.base_altitude)
.unwrap_or(0);
let segment_end = if layer_index == 0 {
target_geopotential_m
} else {
target_geopotential_m.max(ICAO_LAYERS[layer_index].base_altitude)
};
(temp_k, pressure_hpa) = integrate_local_atmosphere_segment(
temp_k,
pressure_hpa,
segment_end - current_alt,
ICAO_LAYERS[layer_index].lapse_rate,
);
current_alt = segment_end;
}
}
(temp_k, pressure_hpa)
}
#[inline]
fn integrate_local_atmosphere_segment(
base_temp_k: f64,
base_pressure_hpa: f64,
height_diff: f64,
lapse_rate: f64,
) -> (f64, f64) {
let temp_k = base_temp_k + lapse_rate * height_diff;
let pressure_hpa = if lapse_rate.abs() < 1e-10 {
base_pressure_hpa * (-G_ACCEL_MPS2 * height_diff / (R_AIR * base_temp_k)).exp()
} else {
let temp_ratio = temp_k / base_temp_k;
base_pressure_hpa * temp_ratio.powf(-G_ACCEL_MPS2 / (lapse_rate * R_AIR))
};
(temp_k, pressure_hpa)
}
#[cfg(test)]
#[inline(always)]
fn determine_local_lapse_rate(altitude_m: f64) -> f64 {
let layer = ICAO_LAYERS
.iter()
.rev()
.find(|layer| altitude_m >= layer.base_altitude)
.unwrap_or(&ICAO_LAYERS[0]);
layer.lapse_rate
}
#[inline(always)]
pub fn get_direct_atmosphere(density: f64, speed_of_sound: f64) -> (f64, f64) {
(density, speed_of_sound)
}
pub fn calculate_air_density_cipm(temp_c: f64, pressure_hpa: f64, humidity_percent: f64) -> f64 {
calculate_air_density_cimp(temp_c, pressure_hpa, humidity_percent)
}
pub type AtmoSegment = (f64, f64, f64, f64);
#[derive(Debug, Clone)]
pub struct AtmoSock {
segments: Vec<AtmoSegment>,
}
impl AtmoSock {
pub fn new(mut segments: Vec<AtmoSegment>) -> Self {
segments.sort_by(|a, b| match (a.3.is_nan(), b.3.is_nan()) {
(true, true) => Ordering::Equal,
(true, false) => Ordering::Greater,
(false, true) => Ordering::Less,
(false, false) => a.3.partial_cmp(&b.3).unwrap(),
});
AtmoSock { segments }
}
pub fn is_empty(&self) -> bool {
self.segments.is_empty()
}
pub fn atmo_for_range(&self, downrange_m: f64) -> (f64, f64, f64) {
if self.segments.is_empty() {
return (15.0, 1013.25, 0.0); }
if downrange_m.is_nan() {
let s = self.segments[0];
return (s.0, s.1, s.2);
}
for seg in &self.segments {
if downrange_m < seg.3 {
return (seg.0, seg.1, seg.2);
}
}
let last = self.segments[self.segments.len() - 1];
(last.0, last.1, last.2)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inclined_shot_frame_position_maps_to_world_altitude() {
let base_altitude = 100.0;
let downrange = 1_000.0;
let shot_y = 10.0;
let angle = std::f64::consts::FRAC_PI_6;
let expected = base_altitude + downrange * angle.sin() + shot_y * angle.cos();
let actual = shot_frame_altitude(base_altitude, downrange, shot_y, angle);
assert!(
(actual - expected).abs() < 1e-12,
"30-degree shot at x=1000/y=10 should be at {expected} m, got {actual} m"
);
assert_eq!(
shot_frame_altitude(base_altitude, downrange, shot_y, 0.0),
base_altitude + shot_y,
"flat-fire altitude must remain byte-identical"
);
let downhill = shot_frame_altitude(base_altitude, downrange, shot_y, -angle);
let expected_downhill = base_altitude - downrange * angle.sin() + shot_y * angle.cos();
assert!((downhill - expected_downhill).abs() < 1e-12);
}
#[test]
fn test_mba1136_dry_sea_level_reference() {
let (density, sos) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 0.0);
assert!(
(density - 1.225).abs() < 0.002,
"dry sea-level density {density} not within 1.225 +- 0.002"
);
assert!(
(sos - 340.3).abs() < 0.6,
"dry sea-level speed of sound {sos} not within 340.3 +- 0.6"
);
}
#[test]
fn test_mba1136_humid_lighter_than_dry() {
let (dry_rho, dry_sos) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 0.0);
let (moist_rho, moist_sos) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 50.0);
assert!(
(moist_rho - 1.2211).abs() < 0.002,
"50% RH density {moist_rho} not within CIPM 1.2211 +- 0.002"
);
assert!(
moist_rho < dry_rho,
"moist air ({moist_rho}) must be lighter than dry ({dry_rho})"
);
assert!(
moist_sos > dry_sos,
"moist speed of sound ({moist_sos}) must exceed dry ({dry_sos})"
);
}
#[test]
fn test_mba1136_density_monotone_in_humidity() {
let (rho_0, _) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 0.0);
let (rho_50, _) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 50.0);
let (rho_100, _) = calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 100.0);
assert!(
rho_100 < rho_50 && rho_50 < rho_0,
"humidity monotonicity violated: 100%={rho_100}, 50%={rho_50}, 0%={rho_0}"
);
}
#[test]
fn test_mba1136_atmosphere_density_is_cipm() {
for (t, p, h) in [
(15.0, 1013.25, 0.0),
(15.0, 1013.25, 50.0),
(30.0, 1000.0, 80.0),
(-10.0, 1020.0, 20.0),
] {
let (density, _) = calculate_atmosphere(0.0, Some(t), Some(p), h);
let cipm = calculate_air_density_cimp(t, p, h);
assert_eq!(
density, cipm,
"calculate_atmosphere density must equal CIPM at {t}C/{p}hPa/{h}%"
);
}
}
#[test]
fn test_mba1136_moist_speed_of_sound_extraction() {
for (t, p, h) in [
(15.0, 1013.25, 0.0),
(15.0, 1013.25, 50.0),
(25.0, 900.0, 100.0),
] {
let (_, sos) = calculate_atmosphere(0.0, Some(t), Some(p), h);
let extracted = moist_speed_of_sound(t + 273.15, p * 100.0, h);
assert_eq!(
sos, extracted,
"extracted moist_speed_of_sound must match calculate_atmosphere at {t}C/{p}hPa/{h}%"
);
}
}
#[test]
fn test_mba1136_get_local_atmosphere_reference() {
let (d0, c0) = get_local_atmosphere(500.0, 500.0, 10.0, 950.0, 1.05);
assert!(
(d0 - 1.286250000000).abs() < 1e-9,
"local density@500m drifted: {d0}"
);
assert!(
(c0 - 337.328657395129).abs() < 1e-9,
"local sos@500m drifted: {c0}"
);
let (d1, c1) = get_local_atmosphere(1500.0, 500.0, 10.0, 950.0, 1.05);
assert!(
(d1 - 1.165238292559).abs() < 1e-9,
"local density@1500m drifted: {d1}"
);
assert!(
(c1 - 333.435546617978).abs() < 1e-9,
"local sos@1500m drifted: {c1}"
);
}
#[test]
fn fractional_station_altitude_is_not_quantized() {
let station_altitude_m = 500.25;
let station_temp_c = 10.0;
let station_pressure_hpa = 950.0;
let station_density_ratio = 1.05;
let (density, speed_of_sound) = get_local_atmosphere(
station_altitude_m,
station_altitude_m,
station_temp_c,
station_pressure_hpa,
station_density_ratio,
);
let expected_density = station_density_ratio * 1.225;
let expected_speed_of_sound = ((station_temp_c + 273.15) * 401.874).sqrt();
assert!((density - expected_density).abs() < 1e-12);
assert!((speed_of_sound - expected_speed_of_sound).abs() < 1e-12);
}
#[test]
fn test_mba1136_get_local_atmosphere_humid() {
let (d_dry, c_dry) = get_local_atmosphere(1500.0, 500.0, 10.0, 950.0, 1.05);
let (d_h0, c_h0) = get_local_atmosphere_humid(1500.0, 500.0, 10.0, 950.0, 1.05, 0.0);
assert_eq!(d_dry, d_h0, "humid variant must not change density");
assert!(
(c_h0 - c_dry).abs() < 1e-3,
"0% RH humid sos {c_h0} should match dry sos {c_dry}"
);
let (_, c_h80) = get_local_atmosphere_humid(1500.0, 500.0, 10.0, 950.0, 1.05, 80.0);
assert!(c_h80 > c_dry, "humid sos {c_h80} should exceed dry {c_dry}");
}
#[test]
fn test_icao_standard_atmosphere() {
let (temp, press) = calculate_icao_standard_atmosphere(0.0);
assert!((temp - 288.15).abs() < 0.01);
assert!((press - 101325.0).abs() < 1.0);
let geometric_tropopause_m =
GEOPOTENTIAL_EARTH_RADIUS_M * 11000.0 / (GEOPOTENTIAL_EARTH_RADIUS_M - 11000.0);
let (temp_11km, press_11km) = calculate_icao_standard_atmosphere(geometric_tropopause_m);
assert!((temp_11km - 216.65).abs() < 0.01);
assert!((press_11km - 22632.1).abs() < 1.0);
let (temp_25km, _) = calculate_icao_standard_atmosphere(25000.0);
assert!(temp_25km > 216.65); }
#[test]
fn standard_atmosphere_extends_below_sea_level() {
let altitude_m = -430.0;
let (temp_k, pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
assert!((temp_k - 290.945_189_079_054).abs() < 1e-9);
assert!((pressure_pa - 106_598.763_552_437).abs() < 0.1);
let (station_temp_c, station_pressure_hpa) =
resolve_station_conditions(15.0, 1013.25, altitude_m);
assert!((station_temp_c - 17.795_189_079_054).abs() < 1e-9);
assert!((station_pressure_hpa - 1_065.987_635_524).abs() < 1e-6);
let (sea_density, _) = calculate_atmosphere(0.0, None, None, 0.0);
let (below_sea_density, _) = calculate_atmosphere(altitude_m, None, None, 0.0);
assert!((below_sea_density - 1.276_908_642_79).abs() < 1e-6);
assert!(below_sea_density > sea_density * 1.04);
assert_eq!(
calculate_icao_standard_atmosphere(-6000.0),
calculate_icao_standard_atmosphere(MIN_GEOMETRIC_ALTITUDE_M)
);
}
#[test]
fn standard_atmosphere_converts_geometric_to_geopotential_height() {
let cases: [(f64, f64, f64); 3] = [
(10_000.0, 223.252_092_647_979, 26_499.901_600_244),
(30_000.0, 226.509_083_611_330, 1_197.032_108_466),
(84_000.0, 190.841_043_736_102, 0.531_525_514_935),
];
for (geometric_m, expected_temp_k, expected_pressure_pa) in cases {
let (temp_k, pressure_pa) = calculate_icao_standard_atmosphere(geometric_m);
let pressure_tolerance = expected_pressure_pa.max(1.0_f64) * 1e-6;
assert!((temp_k - expected_temp_k).abs() < 1e-9);
assert!((pressure_pa - expected_pressure_pa).abs() < pressure_tolerance);
}
}
#[test]
fn local_atmosphere_walks_icao_layers_continuously() {
for altitude_m in [
10999.0, 11000.0, 11001.0, 11050.0, 19999.0, 20000.0, 20001.0, 25000.0,
] {
let (local_temp_k, local_pressure_pa, _) =
local_temp_pressure_density(altitude_m, 0.0, 15.0, 1013.25, 1.0);
let (standard_temp_k, standard_pressure_pa) =
calculate_icao_standard_atmosphere(altitude_m);
assert!(
(local_temp_k - standard_temp_k).abs() < 1e-9,
"local temperature diverged from ICAO at {altitude_m} m: local={local_temp_k}, standard={standard_temp_k}"
);
assert!(
((local_pressure_pa - standard_pressure_pa) / standard_pressure_pa).abs() < 5e-5,
"local pressure diverged from ICAO at {altitude_m} m: local={local_pressure_pa}, standard={standard_pressure_pa}"
);
}
let (density_below, sound_below) =
get_local_atmosphere(10999.0, 0.0, 15.0, 1013.25, 1.0);
let (density_above, sound_above) =
get_local_atmosphere(11001.0, 0.0, 15.0, 1013.25, 1.0);
assert!(
(density_below - density_above).abs() < 0.001,
"density jumped across 11 km: below={density_below}, above={density_above}"
);
assert!(
(sound_below - sound_above).abs() < 0.1,
"speed of sound jumped across 11 km: below={sound_below}, above={sound_above}"
);
}
#[test]
fn local_atmosphere_preserves_nonstandard_station_offset_and_round_trips() {
let base_alt = 7500.0;
let base_temp_c = 5.0;
let base_pressure_hpa = 410.0;
let base_ratio = 0.72;
let (high_temp_k, high_pressure_pa, high_density) = local_temp_pressure_density(
25000.0,
base_alt,
base_temp_c,
base_pressure_hpa,
base_ratio,
);
assert!((high_temp_k - 260.244_615_053_376).abs() < 1e-9);
assert!((high_pressure_pa / 100.0 - 40.964_358_485_456).abs() < 1e-9);
assert!((high_density - 0.094_186_400_274).abs() < 1e-9);
let (back_temp_k, back_pressure_pa, back_density) = local_temp_pressure_density(
base_alt,
25000.0,
high_temp_k - 273.15,
high_pressure_pa / 100.0,
high_density / 1.225,
);
assert!((back_temp_k - (base_temp_c + 273.15)).abs() < 1e-9);
assert!((back_pressure_pa / 100.0 - base_pressure_hpa).abs() < 1e-8);
assert!((back_density - base_ratio * 1.225).abs() < 1e-9);
}
#[test]
fn test_enhanced_atmosphere_sea_level() {
let (density, speed) = calculate_atmosphere(0.0, None, None, 0.0);
assert!((density - 1.225).abs() < 0.01);
assert!((speed - 340.0).abs() < 1.0);
}
#[test]
fn test_resolve_station_pressure_contract() {
assert_eq!(resolve_station_pressure(1013.25, 2000.0), None);
assert_eq!(resolve_station_pressure(1013.21, 2000.0), None);
assert_eq!(resolve_station_pressure(850.0, 2000.0), Some(850.0));
assert_eq!(resolve_station_pressure(1013.25, 0.0), Some(1013.25));
}
#[test]
fn qnh_reduction_matches_hand_computed_value_at_nonzero_altitude() {
let reduced = reduce_qnh_to_station_pressure(1030.0, 1500.0);
assert!(
(reduced - 859.575_312_392_644_7).abs() < 1e-9,
"reduced={reduced}"
);
assert!(reduced < 1030.0);
}
#[test]
fn qnh_reduction_is_identity_at_sea_level() {
assert_eq!(reduce_qnh_to_station_pressure(1030.0, 0.0), 1030.0);
assert_eq!(reduce_qnh_to_station_pressure(950.5, 0.0), 950.5);
}
#[test]
fn qnh_sea_level_standard_matches_icao_standard_atmosphere_at_every_altitude() {
for altitude_m in [0.0, 500.0, 2000.0, 4500.0] {
let (_, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
let reduced = reduce_qnh_to_station_pressure(1013.25, altitude_m);
assert!(
(reduced - std_pressure_pa / 100.0).abs() < 1e-9,
"altitude={altitude_m} reduced={reduced} std={}",
std_pressure_pa / 100.0
);
}
}
#[test]
fn resolve_station_pressure_with_mode_absolute_matches_resolve_station_pressure() {
for (pressure_hpa, altitude_m) in
[(1013.25, 2000.0), (1013.21, 2000.0), (850.0, 2000.0), (1013.25, 0.0)]
{
assert_eq!(
resolve_station_pressure_with_mode(
pressure_hpa,
altitude_m,
PressureReferenceMode::Absolute
),
resolve_station_pressure(pressure_hpa, altitude_m)
);
}
}
#[test]
fn resolve_station_pressure_with_mode_qnh_bypasses_the_default_sentinel() {
let qnh = 1030.0;
let altitude_m = 138.078_300_203_223_02;
let reduced = reduce_qnh_to_station_pressure(qnh, altitude_m);
assert!(
(reduced - 1013.25).abs() < 0.5,
"test fixture must land inside the sentinel band; reduced={reduced}"
);
assert_eq!(
resolve_station_pressure_with_mode(qnh, altitude_m, PressureReferenceMode::Qnh),
Some(reduced)
);
assert_eq!(resolve_station_pressure(reduced, altitude_m), None);
let (_, std_pressure_pa) = calculate_icao_standard_atmosphere(altitude_m);
assert!(
(std_pressure_pa / 100.0 - reduced).abs() > 10.0,
"fixture must show the sentinel misfire actually changes the answer materially"
);
}
#[test]
fn resolve_station_conditions_with_pressure_mode_absolute_matches_resolve_station_conditions()
{
for (temp_c, pressure_hpa, altitude_m) in
[(15.0, 1013.25, 2000.0), (-5.0, 850.0, 2000.0), (15.0, 1013.25, 0.0)]
{
assert_eq!(
resolve_station_conditions_with_pressure_mode(
temp_c,
pressure_hpa,
altitude_m,
PressureReferenceMode::Absolute
),
resolve_station_conditions(temp_c, pressure_hpa, altitude_m)
);
}
}
#[test]
fn resolve_station_conditions_with_pressure_mode_qnh_reduces_pressure_only() {
let (temp_c, pressure_hpa) = resolve_station_conditions_with_pressure_mode(
-5.0,
1030.0,
1500.0,
PressureReferenceMode::Qnh,
);
assert_eq!(temp_c, -5.0);
assert!((pressure_hpa - 859.575_312_392_644_7).abs() < 1e-9);
}
#[test]
fn test_altitude_affects_density_with_default_pressure() {
let press = resolve_station_pressure(1013.25, 0.0);
let (rho_sea, _) = calculate_atmosphere(0.0, Some(15.0), press, 50.0);
let press_alt = resolve_station_pressure(1013.25, 2000.0);
let (rho_2km, _) = calculate_atmosphere(2000.0, Some(15.0), press_alt, 50.0);
assert!(
rho_2km < rho_sea * 0.9,
"density at 2000 m ({rho_2km}) should be well below sea level ({rho_sea})"
);
let p = resolve_station_pressure(900.0, 2000.0);
let (rho_a, _) = calculate_atmosphere(2000.0, Some(15.0), p, 50.0);
let (rho_b, _) = calculate_atmosphere(0.0, Some(15.0), p, 50.0);
assert!(
(rho_a - rho_b).abs() < 1e-9,
"explicit pressure must ignore altitude"
);
}
#[test]
fn test_resolve_station_temperature_contract() {
assert_eq!(resolve_station_temperature(15.0, 2000.0), None);
assert_eq!(resolve_station_temperature(-5.0, 2000.0), Some(-5.0));
assert_eq!(resolve_station_temperature(30.0, 2000.0), Some(30.0));
assert_eq!(resolve_station_temperature(15.0, 0.0), Some(15.0));
}
#[test]
fn test_altitude_only_default_matches_full_icao_standard() {
for alt in [1000.0, 2000.0, 2500.0, 3000.0] {
let t = resolve_station_temperature(15.0, alt);
let p = resolve_station_pressure(1013.25, alt);
let (rho_resolved, _) = calculate_atmosphere(alt, t, p, 0.0);
let (rho_std, _) = calculate_atmosphere(alt, None, None, 0.0);
assert!(
(rho_resolved - rho_std).abs() < 1e-9,
"alt {alt}: altitude-only default density {rho_resolved} should equal the full \
ICAO standard {rho_std}"
);
let (rho_warm, _) = calculate_atmosphere(alt, Some(15.0), p, 0.0);
assert!(
rho_resolved > rho_warm,
"alt {alt}: lapse-temperature density {rho_resolved} should exceed 15 C-held {rho_warm}"
);
}
}
#[test]
fn test_enhanced_atmosphere_with_humidity() {
let (density_dry, speed_dry) = calculate_atmosphere(0.0, None, None, 0.0);
let (density_humid, speed_humid) = calculate_atmosphere(0.0, None, None, 80.0);
assert!(density_humid < density_dry);
assert!(speed_humid > speed_dry);
}
#[test]
fn test_enhanced_atmosphere_stratosphere() {
let (density_20km, speed_20km) = calculate_atmosphere(20000.0, None, None, 0.0);
let (density_30km, speed_30km) = calculate_atmosphere(30000.0, None, None, 0.0);
assert!(density_30km < density_20km);
assert!(speed_30km > speed_20km);
}
#[test]
fn test_enhanced_cimp_density() {
let density = calculate_air_density_cimp(15.0, 1013.25, 0.0);
assert!((density - 1.225).abs() < 0.01);
let density_humid = calculate_air_density_cimp(15.0, 1013.25, 50.0);
assert!(density_humid < density);
}
#[test]
fn test_cipm_moist_air_matches_python_reference() {
let cases = [
(15.0, 1013.25, 50.0, 1.221125867723075),
(30.0, 1000.0, 80.0, 1.1344071877123691),
(-10.0, 1020.0, 20.0, 1.3500610713710515),
];
for (temp_c, pressure_hpa, humidity_pct, expected) in cases {
let density = calculate_air_density_cipm(temp_c, pressure_hpa, humidity_pct);
let rel_err = ((density - expected) / expected).abs();
assert!(
rel_err < 1e-3,
"CIPM density at {temp_c} C / {pressure_hpa} hPa / {humidity_pct}% RH: \
got {density}, expected {expected} (rel err {rel_err:.2e} >= 1e-3)"
);
}
let dry = calculate_air_density_cipm(15.0, 1013.25, 0.0);
let moist = calculate_air_density_cipm(15.0, 1013.25, 50.0);
assert!(
dry - moist > 3e-3,
"humidity effect too small: dry {dry} vs 50% RH {moist}"
);
}
#[test]
fn test_variable_lapse_rates() {
let lapse_tropo = determine_local_lapse_rate(5000.0);
let lapse_strato = determine_local_lapse_rate(25000.0);
assert!((lapse_tropo - (-0.0065)).abs() < 0.0001);
assert!(lapse_strato > 0.0); }
#[test]
fn test_atmo_sock_empty_falls_back_to_isa() {
let sock = AtmoSock::new(vec![]);
assert!(sock.is_empty());
assert_eq!(sock.atmo_for_range(0.0), (15.0, 1013.25, 0.0));
assert_eq!(sock.atmo_for_range(500.0), (15.0, 1013.25, 0.0));
}
#[test]
fn test_atmo_sock_single_segment_holds_beyond_last() {
let sock = AtmoSock::new(vec![(25.0, 1000.0, 30.0, 100.0)]);
assert_eq!(sock.atmo_for_range(50.0), (25.0, 1000.0, 30.0));
assert_eq!(sock.atmo_for_range(100.0), (25.0, 1000.0, 30.0)); assert_eq!(sock.atmo_for_range(5000.0), (25.0, 1000.0, 30.0));
}
#[test]
fn test_atmo_sock_boundary_is_upper_exclusive() {
let sock = AtmoSock::new(vec![
(30.0, 1010.0, 80.0, 100.0), (-5.0, 900.0, 10.0, 200.0), ]);
assert_eq!(sock.atmo_for_range(99.999), (30.0, 1010.0, 80.0));
assert_eq!(sock.atmo_for_range(100.0), (-5.0, 900.0, 10.0));
assert_eq!(sock.atmo_for_range(200.0), (-5.0, 900.0, 10.0));
assert_eq!(sock.atmo_for_range(1e6), (-5.0, 900.0, 10.0));
}
#[test]
fn test_atmo_sock_sorts_unordered_segments() {
let sock = AtmoSock::new(vec![
(-5.0, 900.0, 10.0, 200.0),
(30.0, 1010.0, 80.0, 100.0),
]);
assert_eq!(sock.atmo_for_range(50.0), (30.0, 1010.0, 80.0));
assert_eq!(sock.atmo_for_range(150.0), (-5.0, 900.0, 10.0));
}
#[test]
fn test_atmo_sock_orders_nan_thresholds_last() {
let positive_nan = f64::from_bits(0x7ff8_0000_0000_0001);
let negative_nan = f64::from_bits(0xfff8_0000_0000_0002);
let sock = AtmoSock::new(vec![
(97.0, 997.0, 97.0, positive_nan),
(30.0, 1030.0, 30.0, 300.0),
(98.0, 998.0, 98.0, negative_nan),
(10.0, 1010.0, 10.0, 100.0),
(99.0, 999.0, 99.0, f64::NAN),
(20.0, 1020.0, 20.0, 200.0),
]);
let thresholds: Vec<_> = sock.segments.iter().map(|segment| segment.3).collect();
assert_eq!(&thresholds[..3], &[100.0, 200.0, 300.0]);
assert!(thresholds[3..].iter().all(|threshold| threshold.is_nan()));
assert_eq!(sock.atmo_for_range(f64::NAN), (10.0, 1010.0, 10.0));
assert_eq!(sock.atmo_for_range(350.0), (99.0, 999.0, 99.0));
}
#[test]
fn test_atmo_sock_nan_uses_first_zone() {
let sock = AtmoSock::new(vec![
(30.0, 1010.0, 80.0, 100.0),
(-5.0, 900.0, 10.0, 200.0),
]);
assert_eq!(sock.atmo_for_range(f64::NAN), (30.0, 1010.0, 80.0));
}
#[test]
fn density_altitude_default_temp_pressure_alt_equals_density_alt() {
for da_ft in [0.0_f64, 1000.0, 3000.0, 7500.0, -500.0] {
let da_m = da_ft * DA_FT_TO_M;
let (altitude_m, temp_c, pressure_hpa) =
resolve_atmosphere_for_density_altitude(da_m, None);
assert!(
(altitude_m - da_m).abs() < 1e-6,
"da_ft={da_ft}: altitude_m {altitude_m} should equal da_m {da_m}"
);
assert!(pressure_hpa > 0.0 && pressure_hpa < 1100.0);
assert!(temp_c.is_finite());
}
let (alt0, temp0, press0) = resolve_atmosphere_for_density_altitude(0.0, None);
assert!(alt0.abs() < 1e-9);
assert!((temp0 - 15.0).abs() < 1e-6);
assert!((press0 - 1013.25).abs() < 1e-6);
}
#[test]
fn density_altitude_pressure_decreases_monotonically() {
let (_, _, p0) = resolve_atmosphere_for_density_altitude(0.0, None);
let (_, _, p1) = resolve_atmosphere_for_density_altitude(1000.0, None);
let (_, _, p2) = resolve_atmosphere_for_density_altitude(3000.0, None);
assert!(p1 < p0);
assert!(p2 < p1);
}
#[test]
fn density_altitude_explicit_temperature_overrides_isa_default_but_honors_density() {
let da_m = 1000.0 * DA_FT_TO_M;
let (default_alt_m, default_temp_c, default_pressure_hpa) =
resolve_atmosphere_for_density_altitude(da_m, None);
let hot_temp_c = default_temp_c + 20.0;
let (hot_alt_m, hot_temp_out, hot_pressure_hpa) =
resolve_atmosphere_for_density_altitude(da_m, Some(hot_temp_c));
assert_eq!(hot_temp_out, hot_temp_c, "explicit temperature must be honored exactly");
assert!(
hot_alt_m < default_alt_m,
"hotter station temp at same DA should imply a lower pressure altitude: \
hot={hot_alt_m} default={default_alt_m}"
);
assert!(hot_pressure_hpa > default_pressure_hpa);
let cold_temp_c = default_temp_c - 20.0;
let (cold_alt_m, cold_temp_out, cold_pressure_hpa) =
resolve_atmosphere_for_density_altitude(da_m, Some(cold_temp_c));
assert_eq!(cold_temp_out, cold_temp_c);
assert!(cold_alt_m > default_alt_m);
assert!(cold_pressure_hpa < default_pressure_hpa);
}
#[test]
fn density_altitude_round_trips_through_hand_derived_forward_formula() {
fn forward_density_altitude_ft(pressure_hpa: f64, temp_c: f64) -> f64 {
let pressure_alt_ft =
145_366.45 * (1.0 - (pressure_hpa / 1013.25).powf(0.190_284));
let isa_temp_f = 59.0 - (pressure_alt_ft / 1000.0) * 3.57;
let temp_f = temp_c * 9.0 / 5.0 + 32.0;
pressure_alt_ft + (120.0 * 5.0 / 9.0) * (temp_f - isa_temp_f)
}
for da_ft in [0.0_f64, 500.0, 2500.0, 6000.0] {
let da_m = da_ft * DA_FT_TO_M;
let (_, temp_c, pressure_hpa) = resolve_atmosphere_for_density_altitude(da_m, None);
let round_tripped_ft = forward_density_altitude_ft(pressure_hpa, temp_c);
assert!(
(round_tripped_ft - da_ft).abs() < 1e-6,
"da_ft={da_ft}: round-tripped {round_tripped_ft} (temp_c={temp_c}, pressure_hpa={pressure_hpa})"
);
let explicit_temp_c = 25.0;
let (_, temp_out, pressure_hpa_explicit) =
resolve_atmosphere_for_density_altitude(da_m, Some(explicit_temp_c));
assert_eq!(temp_out, explicit_temp_c);
let round_tripped_explicit_ft =
forward_density_altitude_ft(pressure_hpa_explicit, temp_out);
assert!(
(round_tripped_explicit_ft - da_ft).abs() < 1e-6,
"da_ft={da_ft} (explicit {explicit_temp_c}C): round-tripped {round_tripped_explicit_ft}"
);
}
}
}