use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::solve_json::{
AtmosphereV1, PressureReferenceV1, ResolvedAtmosphereV1, SolveErrorCodeV1,
SolveErrorEnvelopeV1, SolveErrorV1, SolveNoticeV1,
};
use crate::solve_v1::{KELVIN_OFFSET_C, PASCALS_PER_HECTOPASCAL};
const METERS_PER_FOOT: f64 = 0.3048;
const MIN_TEMPERATURE_K: f64 = 173.15;
const MAX_TEMPERATURE_K: f64 = 373.15;
const LOWEST_ALTITUDE_M: f64 = -5_000.0;
const TROPOSPHERE_TOP_M: f64 = 11_000.0;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DensityAltitudeRequestV1 {
pub atmosphere: AtmosphereV1,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AltitudeV1 {
pub m: f64,
pub ft: f64,
}
impl AltitudeV1 {
fn from_ft(ft: f64) -> Self {
Self {
m: ft * METERS_PER_FOOT,
ft,
}
}
fn from_m(m: f64) -> Self {
Self {
m,
ft: m / METERS_PER_FOOT,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DensityAltitudesV1 {
pub faa_rule: AltitudeV1,
pub density_matched: AltitudeV1,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DensityAltitudeResponseV1 {
pub atmosphere: ResolvedAtmosphereV1,
pub pressure_altitude: AltitudeV1,
pub density_altitude: DensityAltitudesV1,
pub air_density_kg_m3: f64,
pub assumptions: Vec<SolveNoticeV1>,
}
pub fn decode_density_altitude_request_v1(
value: &Value,
) -> Result<DensityAltitudeRequestV1, SolveErrorEnvelopeV1> {
let root = crate::solve_json::require_object(value, "$")?;
crate::solve_json::validate_members(root, "$", &["atmosphere"], &["atmosphere"])?;
crate::solve_json::validate_atmosphere(&root["atmosphere"])?;
serde_json::from_value(value.clone()).map_err(|error| invalid_value("$", error.to_string()))
}
pub fn density_altitude_v1(
request: &DensityAltitudeRequestV1,
) -> Result<DensityAltitudeResponseV1, SolveErrorEnvelopeV1> {
let mut assumptions = Vec::new();
let atmosphere = crate::solve_v1::resolve_atmosphere(&request.atmosphere, &mut assumptions)?;
if !(MIN_TEMPERATURE_K..=MAX_TEMPERATURE_K).contains(&atmosphere.temperature_k) {
return Err(invalid_value(
"$.atmosphere.temperature_k",
format!(
"{} K is outside {MIN_TEMPERATURE_K}–{MAX_TEMPERATURE_K} K (−100 °C to 100 °C), \
the range the humid-air model behind these figures is defined for; \
temperature_k is kelvin (°C + 273.15)",
echo(atmosphere.temperature_k)
),
));
}
let temperature_c = atmosphere.temperature_k - KELVIN_OFFSET_C;
let temperature_f = temperature_c * 9.0 / 5.0 + 32.0;
let station_pressure_hpa = atmosphere.pressure_pa / PASCALS_PER_HECTOPASCAL;
let humidity_percent = atmosphere.relative_humidity * 100.0;
let pressure_altitude_ft = crate::atmosphere::nws_pressure_altitude_ft(station_pressure_hpa);
let pressure_altitude = AltitudeV1::from_ft(pressure_altitude_ft);
if !within_troposphere(pressure_altitude.m) {
return Err(pressure_outside_troposphere(
&request.atmosphere,
&atmosphere,
pressure_altitude.m,
));
}
let vapor_fraction = crate::atmosphere::water_vapor_mole_fraction_uncapped(
temperature_c,
station_pressure_hpa,
humidity_percent,
);
if vapor_fraction.is_nan() || vapor_fraction >= 1.0 {
return Err(invalid_value(
"$.atmosphere.relative_humidity",
format!(
"a relative humidity of {} at {} K needs more water-vapor pressure than the \
air's total pressure of {} Pa; no such air exists",
atmosphere.relative_humidity,
echo(atmosphere.temperature_k),
echo(atmosphere.pressure_pa)
),
));
}
let faa_rule = AltitudeV1::from_ft(crate::atmosphere::faa_rule_density_altitude_ft(
station_pressure_hpa,
temperature_f,
));
let density_matched = AltitudeV1::from_m(crate::atmosphere::density_matched_altitude_m(
temperature_c,
station_pressure_hpa,
humidity_percent,
));
let (air_density_kg_m3, _speed_of_sound) = crate::atmosphere::calculate_atmosphere(
atmosphere.altitude_m,
Some(temperature_c),
Some(station_pressure_hpa),
humidity_percent,
);
for (name, altitude) in [("faa_rule", faa_rule), ("density_matched", density_matched)] {
if !within_troposphere(altitude.m) {
return Err(invalid_value(
"$.atmosphere",
format!(
"the {name} density altitude of these conditions is {:.0} m; density \
altitude is computed only between {LOWEST_ALTITUDE_M} m and \
{TROPOSPHERE_TOP_M} m, the standard atmosphere's troposphere. Check that \
temperature_k is kelvin and pressure_pa is pascals",
altitude.m
),
));
}
}
let figures = [
pressure_altitude.m,
pressure_altitude.ft,
faa_rule.m,
faa_rule.ft,
density_matched.m,
density_matched.ft,
];
if !(figures.iter().all(|x| x.is_finite())
&& air_density_kg_m3.is_finite()
&& air_density_kg_m3 > 0.0)
{
return Err(invalid_value(
"$.atmosphere",
"these conditions do not produce a finite, positive air density",
));
}
Ok(DensityAltitudeResponseV1 {
atmosphere,
pressure_altitude,
density_altitude: DensityAltitudesV1 {
faa_rule,
density_matched,
},
air_density_kg_m3,
assumptions,
})
}
fn pressure_outside_troposphere(
sent: &AtmosphereV1,
resolved: &ResolvedAtmosphereV1,
pressure_altitude_m: f64,
) -> SolveErrorEnvelopeV1 {
let scope = format!(
"is a pressure altitude of {pressure_altitude_m:.0} m; density altitude is computed only \
between {LOWEST_ALTITUDE_M} m and {TROPOSPHERE_TOP_M} m, the standard atmosphere's \
troposphere"
);
let station = echo(resolved.pressure_pa);
let altitude = echo(resolved.altitude_m);
match sent.pressure_pa {
Some(qnh) if sent.pressure_reference == Some(PressureReferenceV1::Qnh) => {
let qnh_alone_m =
crate::atmosphere::nws_pressure_altitude_ft(qnh / PASCALS_PER_HECTOPASCAL)
* METERS_PER_FOOT;
let path = if within_troposphere(qnh_alone_m) {
"$.atmosphere.altitude_m"
} else {
"$.atmosphere.pressure_pa"
};
invalid_value(
path,
format!(
"a QNH of {} Pa, reduced to a station pressure of {station} Pa at \
{altitude} m, {scope}. pressure_pa is pascals (hPa × 100) and altitude_m \
is meters",
echo(qnh)
),
)
}
Some(_) => invalid_value(
"$.atmosphere.pressure_pa",
format!(
"a station pressure of {station} Pa {scope}. pressure_pa is pascals (hPa × 100)"
),
),
None => invalid_value(
"$.atmosphere.altitude_m",
format!(
"the ICAO standard pressure at {altitude} m, {station} Pa, {scope}. altitude_m \
is meters"
),
),
}
}
fn echo(value: f64) -> String {
if value == 0.0 || (1e-3..1e7).contains(&value.abs()) {
format!("{value}")
} else {
format!("{value:e}")
}
}
fn within_troposphere(altitude_m: f64) -> bool {
(LOWEST_ALTITUDE_M..=TROPOSPHERE_TOP_M).contains(&altitude_m)
}
fn invalid_value(path: &str, message: impl Into<String>) -> SolveErrorEnvelopeV1 {
SolveErrorEnvelopeV1::new(
SolveErrorV1::new(SolveErrorCodeV1::InvalidValue, message).at_path(path),
)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn describe(atmosphere: AtmosphereV1) -> DensityAltitudeResponseV1 {
density_altitude_v1(&DensityAltitudeRequestV1 { atmosphere }).expect("valid atmosphere")
}
fn refusal(atmosphere: AtmosphereV1) -> SolveErrorV1 {
density_altitude_v1(&DensityAltitudeRequestV1 { atmosphere })
.expect_err("atmosphere should be refused")
.error
}
fn at(temperature_c: f64, pressure_hpa: f64, relative_humidity: f64) -> AtmosphereV1 {
AtmosphereV1 {
altitude_m: Some(0.0),
temperature_k: Some(temperature_c + KELVIN_OFFSET_C),
pressure_pa: Some(pressure_hpa * PASCALS_PER_HECTOPASCAL),
relative_humidity: Some(relative_humidity),
..AtmosphereV1::default()
}
}
#[test]
fn icao_standard_dry_air_is_zero_by_both_definitions() {
let r = describe(at(15.0, 1013.25, 0.0));
assert!(r.density_altitude.faa_rule.m.abs() < 1e-9, "{r:?}");
assert!(r.density_altitude.density_matched.m.abs() < 1e-9, "{r:?}");
assert!(r.pressure_altitude.m.abs() < 1e-9, "{r:?}");
}
#[test]
fn humidity_moves_the_density_matched_figure_and_not_the_faa_one() {
let r = describe(at(15.0, 1013.25, 0.5));
assert!(r.density_altitude.faa_rule.m.abs() < 1e-9, "{r:?}");
let matched = r.density_altitude.density_matched.m;
assert!(
(matched - 33.10).abs() < 0.2,
"50% RH at ICAO standard should be 33.1 m density-matched, got {matched}"
);
}
#[test]
fn density_matched_rises_monotonically_with_humidity() {
let mut last = f64::NEG_INFINITY;
for rh in [0.0, 0.25, 0.5, 0.75, 1.0] {
let m = describe(at(15.0, 1013.25, rh))
.density_altitude
.density_matched
.m;
assert!(m > last, "RH {rh} gave {m}, not above {last}");
last = m;
}
}
#[test]
fn the_definitions_differ_in_dry_air_away_from_standard() {
let r = describe(at(30.0, 1013.25, 0.0));
let faa = r.density_altitude.faa_rule.m;
let matched = r.density_altitude.density_matched.m;
assert!((faa - 548.64).abs() < 0.05, "FAA rule at 30 C dry: {faa}");
assert!(
(matched - 526.92).abs() < 0.2,
"density-matched at 30 C dry: {matched}"
);
assert!(
faa - matched > 15.0,
"expected the FAA rule to read ~22 m high, got {faa} vs {matched}"
);
}
#[test]
fn faa_rule_is_bit_identical_to_the_card_formula_it_replaced() {
for (t_c, p_hpa) in [
(15.0, 1013.25),
(30.0, 1013.25),
(15.0, 950.0),
(0.0, 1013.25),
(-10.0, 850.0),
(41.3, 702.7),
] {
let r = describe(at(t_c, p_hpa, 0.5));
let t_c = (t_c + KELVIN_OFFSET_C) - KELVIN_OFFSET_C;
let p_hpa = (p_hpa * PASCALS_PER_HECTOPASCAL) / PASCALS_PER_HECTOPASCAL;
let t_f = t_c * 9.0 / 5.0 + 32.0;
let pressure_alt = 145_366.45 * (1.0 - (p_hpa / 1013.25_f64).powf(0.190_284));
let isa_temp_f = 59.0 - (pressure_alt / 1000.0) * 3.57;
let expected = pressure_alt + (120.0 * 5.0 / 9.0) * (t_f - isa_temp_f);
assert_eq!(
r.density_altitude.faa_rule.ft.to_bits(),
expected.to_bits(),
"{t_c} C {p_hpa} hPa"
);
}
}
#[test]
fn air_density_is_the_solvers_density() {
let r = describe(at(15.0, 1013.25, 0.5));
let (solver_density, _) =
crate::atmosphere::calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 50.0);
assert_eq!(r.air_density_kg_m3.to_bits(), solver_density.to_bits());
assert!(
(r.air_density_kg_m3 - 1.2216).abs() < 0.0002,
"{}",
r.air_density_kg_m3
);
}
#[test]
fn humidity_in_percent_is_refused_at_its_path() {
let error = refusal(at(15.0, 1013.25, 50.0));
assert_eq!(error.path(), Some("$.atmosphere.relative_humidity"));
}
#[test]
fn omitted_fields_resolve_like_a_solve_and_are_announced() {
let r = describe(AtmosphereV1::default());
assert_eq!(r.atmosphere.altitude_m, 0.0);
assert!((r.atmosphere.temperature_k - 288.15).abs() < 1e-9);
assert!((r.atmosphere.pressure_pa - 101_325.0).abs() < 1e-6);
assert_eq!(r.atmosphere.relative_humidity, 0.5);
let paths: Vec<_> = r
.assumptions
.iter()
.filter_map(|a| a.path.as_deref())
.collect();
for expected in [
"$.atmosphere.altitude_m",
"$.atmosphere.temperature_k",
"$.atmosphere.pressure_pa",
"$.atmosphere.relative_humidity",
] {
assert!(
paths.contains(&expected),
"missing assumption at {expected}: {paths:?}"
);
}
}
#[test]
fn a_qnh_is_reduced_to_station_pressure_first() {
let altitude_m = 1500.0;
let mut qnh = at(15.0, 1013.25, 0.0);
qnh.altitude_m = Some(altitude_m);
qnh.pressure_reference = Some(PressureReferenceV1::Qnh);
let r = describe(qnh);
assert!(
r.atmosphere.pressure_pa < 90_000.0,
"QNH 1013.25 at 1500 m should reduce to ~845 hPa station, got {} Pa",
r.atmosphere.pressure_pa
);
assert!(
(r.pressure_altitude.m - altitude_m).abs() < 30.0,
"pressure altitude of a standard QNH should be ~the station altitude: {}",
r.pressure_altitude.m
);
assert!(r
.assumptions
.iter()
.any(|a| a.path.as_deref() == Some("$.atmosphere.pressure_pa")));
}
#[test]
fn meters_and_feet_agree() {
let r = describe(at(30.0, 950.0, 0.5));
for altitude in [
r.pressure_altitude,
r.density_altitude.faa_rule,
r.density_altitude.density_matched,
] {
assert!(
(altitude.m - altitude.ft * METERS_PER_FOOT).abs() < 1e-9,
"{altitude:?}"
);
}
}
#[test]
fn a_temperature_no_air_can_have_is_refused_at_temperature_k() {
for kelvin in [15.0, 5.0, 1e-300, 173.0, 373.2, 400.0, 1e300] {
let mut atmosphere = at(15.0, 1013.25, 0.5);
atmosphere.temperature_k = Some(kelvin);
let error = refusal(atmosphere);
assert_eq!(
error.path(),
Some("$.atmosphere.temperature_k"),
"{kelvin} K: {error:?}"
);
}
}
#[test]
fn a_pressure_outside_the_troposphere_is_refused_at_pressure_pa() {
for pascals in [1013.25, 101.325, 29.92, 1e-300, 250_000.0, 1e300] {
let mut atmosphere = at(25.0, 1013.25, 0.9);
atmosphere.pressure_pa = Some(pascals);
let error = refusal(atmosphere);
assert_eq!(
error.path(),
Some("$.atmosphere.pressure_pa"),
"{pascals} Pa: {error:?}"
);
}
}
#[test]
fn a_defaulted_pressure_above_the_troposphere_is_refused_at_altitude_m() {
let error = refusal(AtmosphereV1 {
altitude_m: Some(20_000.0),
..AtmosphereV1::default()
});
assert_eq!(error.path(), Some("$.atmosphere.altitude_m"));
}
#[test]
fn a_qnh_refusal_blames_the_input_that_is_wrong_and_quotes_what_was_sent() {
let mut feet_as_meters = at(15.0, 1013.25, 0.5);
feet_as_meters.altitude_m = Some(36_000.0);
feet_as_meters.pressure_reference = Some(PressureReferenceV1::Qnh);
let error = refusal(feet_as_meters);
assert_eq!(error.path(), Some("$.atmosphere.altitude_m"), "{error:?}");
assert!(
error.message.contains("a QNH of 101325 Pa"),
"{}",
error.message
);
let mut hpa_as_pa = at(15.0, 1013.25, 0.5);
hpa_as_pa.altitude_m = Some(1500.0);
hpa_as_pa.pressure_pa = Some(1013.25);
hpa_as_pa.pressure_reference = Some(PressureReferenceV1::Qnh);
let error = refusal(hpa_as_pa);
assert_eq!(error.path(), Some("$.atmosphere.pressure_pa"), "{error:?}");
assert!(
error.message.contains("a QNH of 1013.25 Pa"),
"{}",
error.message
);
}
#[test]
fn absurd_inputs_are_echoed_compactly() {
let mut atmosphere = at(15.0, 1013.25, 0.5);
atmosphere.temperature_k = Some(1e300);
let error = refusal(atmosphere);
assert!(error.message.starts_with("1e300 K"), "{}", error.message);
assert!(error.message.len() < 300, "{}", error.message);
}
#[test]
fn humidity_beyond_the_total_pressure_is_refused_at_relative_humidity() {
let error = refusal(at(100.0, 1013.25, 1.0));
assert_eq!(
error.path(),
Some("$.atmosphere.relative_humidity"),
"{error:?}"
);
describe(at(100.0, 1013.25, 0.9));
describe(at(45.0, 1013.25, 1.0));
}
#[test]
fn every_accepted_atmosphere_reports_finite_figures() {
let mut accepted = 0;
for t in (0..=20).map(|i| MIN_TEMPERATURE_K + 10.0 * f64::from(i)) {
for p in (0..=16).map(|i| 20_000.0 + 10_000.0 * f64::from(i)) {
for rh in [0.0, 0.3, 0.7, 1.0] {
let atmosphere = AtmosphereV1 {
altitude_m: Some(0.0),
temperature_k: Some(t),
pressure_pa: Some(p),
relative_humidity: Some(rh),
..AtmosphereV1::default()
};
let Ok(r) = density_altitude_v1(&DensityAltitudeRequestV1 { atmosphere })
else {
continue;
};
accepted += 1;
assert!(r.air_density_kg_m3.is_finite() && r.air_density_kg_m3 > 0.0);
for altitude in [
r.pressure_altitude,
r.density_altitude.faa_rule,
r.density_altitude.density_matched,
] {
assert!(altitude.m.is_finite() && altitude.ft.is_finite(), "{r:?}");
assert!(within_troposphere(altitude.m), "{r:?}");
}
}
}
}
assert!(accepted > 300, "only {accepted} accepted");
}
#[test]
fn malformed_requests_get_the_solves_codes_and_paths() {
let cases = [
(
json!({"atmosphere": {"temperature_c": 30.0}}),
SolveErrorCodeV1::UnknownField,
"$.atmosphere.temperature_c",
),
(
json!({"atmosphere": {"temperature_k": null}}),
SolveErrorCodeV1::InvalidValue,
"$.atmosphere.temperature_k",
),
(
json!({"atmosphere": {"pressure_pa": "101325"}}),
SolveErrorCodeV1::InvalidValue,
"$.atmosphere.pressure_pa",
),
(
json!({"atmosphere": {"pressure_reference": "altimeter"}}),
SolveErrorCodeV1::InvalidValue,
"$.atmosphere.pressure_reference",
),
(json!({}), SolveErrorCodeV1::MissingField, "$.atmosphere"),
(
json!({"atmosphere": {}, "units": "si"}),
SolveErrorCodeV1::UnknownField,
"$.units",
),
(json!([{}]), SolveErrorCodeV1::InvalidValue, "$"),
];
for (request, code, path) in cases {
let error = decode_density_altitude_request_v1(&request)
.expect_err("malformed")
.error;
assert_eq!(error.code, code, "{request}: {error:?}");
assert_eq!(error.path(), Some(path), "{request}: {error:?}");
}
assert!(decode_density_altitude_request_v1(&json!({"atmosphere": {}})).is_ok());
}
}