ballistics_engine/
atmosphere_service.rs

1//! Describe an atmosphere the way a solve resolves it: the service behind the
2//! `atmosphere.density_altitude` bridge command.
3//!
4//! # Why this exists
5//!
6//! Density altitude was reachable only as one field inside a DOPE card, so an embedding app
7//! wanting to show it had to reimplement the formula — and an external Android app did,
8//! then hand-maintained the copy with its own regression tests (2026-09). A copy of a formula
9//! is a copy that drifts. This service answers the question directly, from the same code the
10//! solver and the card use.
11//!
12//! # One atmosphere schema, decoded and resolved one way
13//!
14//! The request carries the SAME `atmosphere` object a solve-json v1 request does
15//! ([`AtmosphereV1`]). It is decoded by the solve's own shape validator and resolved by the
16//! solve's own resolver (`solve_v1::resolve_atmosphere`), so every error the solve would give
17//! for that object comes back identically — same code, same `$.atmosphere.*` path — as do the
18//! ICAO-standard defaults for whatever is omitted (each announced in `assumptions`) and the
19//! QNH-to-station reduction. An app can send the atmosphere it is about to solve with and get
20//! the density altitude of precisely that air.
21//!
22//! # Then checks the solve does not make (yet — MBA-1594)
23//!
24//! The solve's resolver only requires temperature and pressure to be positive, which admits air
25//! that cannot exist: 5 K, a humidity whose vapor pressure exceeds the air's total pressure, a
26//! pressure altitude of 30 km. Those are also exactly what the two likeliest unit mistakes
27//! produce — °C sent as `temperature_k`, hPa sent as `pressure_pa` — so a figure computed from
28//! them is a wrong answer delivered confidently. This service refuses them instead, with the
29//! same `invalid_value` envelope and the path of the input to blame:
30//!
31//! - `temperature_k` outside 173.15–373.15 K (−100 °C to 100 °C), where the engine's
32//!   saturation-vapor and compressibility formulas are defined;
33//! - station pressure whose pressure altitude is outside −5 km to 11 km — the standard
34//!   atmosphere's troposphere, which is all the closed-form arithmetic here covers;
35//! - a relative humidity that would need more vapor pressure than the total pressure;
36//! - either density altitude outside −5 km to 11 km.
37//!
38//! None of these rejects a condition anyone has fired a rifle in.
39//!
40//! # Two density altitudes, by name
41//!
42//! "Density altitude" means two different things in the field, and one number under one name
43//! is how the confusion this module answers arose. The response therefore never offers a bare
44//! `density_altitude`; it offers both, and a caller has to choose:
45//!
46//! - **`faa_rule`** — [`crate::atmosphere::faa_rule_density_altitude_ft`]: NWS pressure
47//!   altitude plus the FAA's "120 ft per °C" rule of thumb. Humidity-free. This is the formula
48//!   the engine's DOPE card header uses, and the exact inverse of the density-altitude ENTRY
49//!   mode.
50//! - **`density_matched`** — [`crate::atmosphere::density_matched_altitude_m`]: the ISA
51//!   altitude whose standard density equals this air's actual density, humidity included. The
52//!   textbook definition, and what humidity-aware tools report — the National Weather
53//!   Service's own density-altitude calculator included.
54//!
55//! They coincide at ICAO standard sea-level dry air; anywhere else a match is coincidence.
56//! They differ by 33.1 m at 15 °C, 1013.25 hPa and 50% RH (humidity), and by about 22 m at
57//! 30 °C in perfectly DRY air (the FAA rule's straight-line approximation). The difference
58//! between them is therefore NOT a humidity correction, and must not be presented as one.
59//!
60//! `air_density_kg_m3` is reported as well: the CIPM-2007 density the solver itself uses at
61//! the muzzle, which answers "how thin is the air" with no definition to argue about.
62
63use serde::{Deserialize, Serialize};
64use serde_json::Value;
65
66use crate::solve_json::{
67    AtmosphereV1, PressureReferenceV1, ResolvedAtmosphereV1, SolveErrorCodeV1,
68    SolveErrorEnvelopeV1, SolveErrorV1, SolveNoticeV1,
69};
70use crate::solve_v1::{KELVIN_OFFSET_C, PASCALS_PER_HECTOPASCAL};
71
72const METERS_PER_FOOT: f64 = 0.3048;
73
74/// −100 °C. The engine's saturation-vapor-pressure and CIPM compressibility formulas clamp
75/// their temperature here, so below it the density would mix a clamped and an unclamped term.
76const MIN_TEMPERATURE_K: f64 = 173.15;
77/// 100 °C. Above the boiling point the saturation-vapor-pressure formula stops describing a
78/// stable atmosphere, and above water's critical point (647 K) it is not defined at all.
79const MAX_TEMPERATURE_K: f64 = 373.15;
80/// The lowest altitude solve-json v1 accepts, and the bottom of the ICAO table.
81const LOWEST_ALTITUDE_M: f64 = -5_000.0;
82/// The top of the standard atmosphere's troposphere: every closed-form altitude here is the
83/// troposphere formula, and it keeps answering, wrongly, above this.
84const TROPOSPHERE_TOP_M: f64 = 11_000.0;
85
86/// `atmosphere.density_altitude` request.
87///
88/// `atmosphere` is exactly the solve-json v1 `atmosphere` object: SI units
89/// (`altitude_m`, `temperature_k`, `pressure_pa`), `relative_humidity` as a FRACTION 0..1,
90/// and an optional `pressure_reference` of `absolute` (the default) or `qnh`. Every field is
91/// optional; an omitted one resolves exactly as a solve would resolve it. Decode with
92/// [`decode_density_altitude_request_v1`] to get the solve's error envelope for a malformed
93/// object.
94#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct DensityAltitudeRequestV1 {
97    pub atmosphere: AtmosphereV1,
98}
99
100/// One altitude in both units a shooter meets it in. Density altitude is conventionally
101/// quoted in feet even by shooters who range in meters, so both travel together rather than
102/// asking every caller to convert.
103#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct AltitudeV1 {
106    pub m: f64,
107    pub ft: f64,
108}
109
110impl AltitudeV1 {
111    fn from_ft(ft: f64) -> Self {
112        Self {
113            m: ft * METERS_PER_FOOT,
114            ft,
115        }
116    }
117
118    fn from_m(m: f64) -> Self {
119        Self {
120            m,
121            ft: m / METERS_PER_FOOT,
122        }
123    }
124}
125
126/// The two density altitudes, each under its own name. See the module docs for why there is
127/// no single `density_altitude` value.
128#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
129#[serde(deny_unknown_fields)]
130pub struct DensityAltitudesV1 {
131    /// NWS pressure altitude plus the FAA 120 ft/°C rule: humidity-free, the formula the DOPE
132    /// card header uses, and the exact inverse of density-altitude entry.
133    pub faa_rule: AltitudeV1,
134    /// ISA altitude of equal actual air density, humidity included.
135    pub density_matched: AltitudeV1,
136}
137
138/// `atmosphere.density_altitude` result.
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140#[serde(deny_unknown_fields)]
141pub struct DensityAltitudeResponseV1 {
142    /// The atmosphere these figures describe, AFTER resolution: `pressure_pa` is station
143    /// pressure (already reduced if a QNH was supplied), and every omitted field carries the
144    /// value it was defaulted to. `pressure_reference` echoes the mode that was SENT, exactly
145    /// as the solve's `resolved_request` does — so this is a report, not a request: re-sent
146    /// with `qnh`, the pressure would be reduced twice.
147    pub atmosphere: ResolvedAtmosphereV1,
148    /// NWS pressure altitude of the resolved station pressure.
149    pub pressure_altitude: AltitudeV1,
150    pub density_altitude: DensityAltitudesV1,
151    /// CIPM-2007 humid-air density, kg/m³ — the density the solver uses at the muzzle.
152    pub air_density_kg_m3: f64,
153    /// Every default applied and any QNH-to-station reduction made while resolving the
154    /// atmosphere, in the solve's own notice format.
155    pub assumptions: Vec<SolveNoticeV1>,
156}
157
158/// Decode an `atmosphere.density_altitude` request from JSON the way `solve` decodes its
159/// `atmosphere`: the solve's own shape validator runs first, so an unknown or unit-suffixed
160/// field, an explicit `null`, a string where a number belongs or an unrecognized
161/// `pressure_reference` is refused with the solve's code at the solve's `$.atmosphere.*` path.
162pub fn decode_density_altitude_request_v1(
163    value: &Value,
164) -> Result<DensityAltitudeRequestV1, SolveErrorEnvelopeV1> {
165    let root = crate::solve_json::require_object(value, "$")?;
166    crate::solve_json::validate_members(root, "$", &["atmosphere"], &["atmosphere"])?;
167    crate::solve_json::validate_atmosphere(&root["atmosphere"])?;
168    serde_json::from_value(value.clone()).map_err(|error| invalid_value("$", error.to_string()))
169}
170
171/// Resolve `request.atmosphere` exactly as a solve would, check that the air it describes can
172/// exist and lies where these formulas hold, then report its pressure altitude, both density
173/// altitudes and its air density.
174///
175/// Errors are the solve's own validation envelope, located at `$.atmosphere.*`.
176pub fn density_altitude_v1(
177    request: &DensityAltitudeRequestV1,
178) -> Result<DensityAltitudeResponseV1, SolveErrorEnvelopeV1> {
179    let mut assumptions = Vec::new();
180    let atmosphere = crate::solve_v1::resolve_atmosphere(&request.atmosphere, &mut assumptions)?;
181
182    // Temperature first: above water's critical point the vapor formula is NaN, which the
183    // vapor check below would otherwise read as "fine". A defaulted temperature is ICAO at an
184    // altitude the solve accepts, always inside this range, so only a supplied one can fail.
185    if !(MIN_TEMPERATURE_K..=MAX_TEMPERATURE_K).contains(&atmosphere.temperature_k) {
186        return Err(invalid_value(
187            "$.atmosphere.temperature_k",
188            format!(
189                "{} K is outside {MIN_TEMPERATURE_K}–{MAX_TEMPERATURE_K} K (−100 °C to 100 °C), \
190                 the range the humid-air model behind these figures is defined for; \
191                 temperature_k is kelvin (°C + 273.15)",
192                echo(atmosphere.temperature_k)
193            ),
194        ));
195    }
196
197    // The same conversions the solve applies before handing the atmosphere to the engine
198    // (solve_v1.rs): Kelvin to Celsius, Pa to hPa, humidity fraction to percent.
199    let temperature_c = atmosphere.temperature_k - KELVIN_OFFSET_C;
200    let temperature_f = temperature_c * 9.0 / 5.0 + 32.0;
201    let station_pressure_hpa = atmosphere.pressure_pa / PASCALS_PER_HECTOPASCAL;
202    let humidity_percent = atmosphere.relative_humidity * 100.0;
203
204    // Pressure before vapor: hPa sent as Pa is the likelier mistake, and at 1013 Pa a warm
205    // humid day would otherwise be blamed on the humidity.
206    let pressure_altitude_ft = crate::atmosphere::nws_pressure_altitude_ft(station_pressure_hpa);
207    let pressure_altitude = AltitudeV1::from_ft(pressure_altitude_ft);
208    if !within_troposphere(pressure_altitude.m) {
209        return Err(pressure_outside_troposphere(
210            &request.atmosphere,
211            &atmosphere,
212            pressure_altitude.m,
213        ));
214    }
215
216    let vapor_fraction = crate::atmosphere::water_vapor_mole_fraction_uncapped(
217        temperature_c,
218        station_pressure_hpa,
219        humidity_percent,
220    );
221    // NaN counts as refused: the temperature bound above should make it unreachable.
222    if vapor_fraction.is_nan() || vapor_fraction >= 1.0 {
223        return Err(invalid_value(
224            "$.atmosphere.relative_humidity",
225            format!(
226                "a relative humidity of {} at {} K needs more water-vapor pressure than the \
227                 air's total pressure of {} Pa; no such air exists",
228                atmosphere.relative_humidity,
229                echo(atmosphere.temperature_k),
230                echo(atmosphere.pressure_pa)
231            ),
232        ));
233    }
234
235    let faa_rule = AltitudeV1::from_ft(crate::atmosphere::faa_rule_density_altitude_ft(
236        station_pressure_hpa,
237        temperature_f,
238    ));
239    let density_matched = AltitudeV1::from_m(crate::atmosphere::density_matched_altitude_m(
240        temperature_c,
241        station_pressure_hpa,
242        humidity_percent,
243    ));
244    let (air_density_kg_m3, _speed_of_sound) = crate::atmosphere::calculate_atmosphere(
245        atmosphere.altitude_m,
246        Some(temperature_c),
247        Some(station_pressure_hpa),
248        humidity_percent,
249    );
250
251    for (name, altitude) in [("faa_rule", faa_rule), ("density_matched", density_matched)] {
252        if !within_troposphere(altitude.m) {
253            return Err(invalid_value(
254                "$.atmosphere",
255                format!(
256                    "the {name} density altitude of these conditions is {:.0} m; density \
257                     altitude is computed only between {LOWEST_ALTITUDE_M} m and \
258                     {TROPOSPHERE_TOP_M} m, the standard atmosphere's troposphere. Check that \
259                     temperature_k is kelvin and pressure_pa is pascals",
260                    altitude.m
261                ),
262            ));
263        }
264    }
265    // Every input above is bounded, so this cannot fire; it is here so that if a bound is ever
266    // loosened, the failure is a refusal rather than `ok: true` with a `null` (serde_json
267    // writes NaN as null) or a negative-zero density.
268    let figures = [
269        pressure_altitude.m,
270        pressure_altitude.ft,
271        faa_rule.m,
272        faa_rule.ft,
273        density_matched.m,
274        density_matched.ft,
275    ];
276    if !(figures.iter().all(|x| x.is_finite())
277        && air_density_kg_m3.is_finite()
278        && air_density_kg_m3 > 0.0)
279    {
280        return Err(invalid_value(
281            "$.atmosphere",
282            "these conditions do not produce a finite, positive air density",
283        ));
284    }
285
286    Ok(DensityAltitudeResponseV1 {
287        atmosphere,
288        pressure_altitude,
289        density_altitude: DensityAltitudesV1 {
290            faa_rule,
291            density_matched,
292        },
293        air_density_kg_m3,
294        assumptions,
295    })
296}
297
298/// The refusal for a station pressure outside the troposphere, blaming the input that is
299/// actually wrong and quoting what the caller SENT rather than a pressure derived from it.
300fn pressure_outside_troposphere(
301    sent: &AtmosphereV1,
302    resolved: &ResolvedAtmosphereV1,
303    pressure_altitude_m: f64,
304) -> SolveErrorEnvelopeV1 {
305    let scope = format!(
306        "is a pressure altitude of {pressure_altitude_m:.0} m; density altitude is computed only \
307         between {LOWEST_ALTITUDE_M} m and {TROPOSPHERE_TOP_M} m, the standard atmosphere's \
308         troposphere"
309    );
310    let station = echo(resolved.pressure_pa);
311    let altitude = echo(resolved.altitude_m);
312    match sent.pressure_pa {
313        Some(qnh) if sent.pressure_reference == Some(PressureReferenceV1::Qnh) => {
314            // A QNH that would itself be a troposphere pressure at sea level is a plausible
315            // reading, so the altitude it was reduced to is what took it out of range (feet
316            // sent as meters, say).
317            let qnh_alone_m =
318                crate::atmosphere::nws_pressure_altitude_ft(qnh / PASCALS_PER_HECTOPASCAL)
319                    * METERS_PER_FOOT;
320            let path = if within_troposphere(qnh_alone_m) {
321                "$.atmosphere.altitude_m"
322            } else {
323                "$.atmosphere.pressure_pa"
324            };
325            invalid_value(
326                path,
327                format!(
328                    "a QNH of {} Pa, reduced to a station pressure of {station} Pa at \
329                     {altitude} m, {scope}. pressure_pa is pascals (hPa × 100) and altitude_m \
330                     is meters",
331                    echo(qnh)
332                ),
333            )
334        }
335        Some(_) => invalid_value(
336            "$.atmosphere.pressure_pa",
337            format!(
338                "a station pressure of {station} Pa {scope}. pressure_pa is pascals (hPa × 100)"
339            ),
340        ),
341        None => invalid_value(
342            "$.atmosphere.altitude_m",
343            format!(
344                "the ICAO standard pressure at {altitude} m, {station} Pa, {scope}. altitude_m \
345                 is meters"
346            ),
347        ),
348    }
349}
350
351/// A caller's number as it should appear in a message: plain for ordinary magnitudes, and in
352/// exponent form for the absurd ones, which `{}` would otherwise print as 300 digits.
353fn echo(value: f64) -> String {
354    if value == 0.0 || (1e-3..1e7).contains(&value.abs()) {
355        format!("{value}")
356    } else {
357        format!("{value:e}")
358    }
359}
360
361fn within_troposphere(altitude_m: f64) -> bool {
362    (LOWEST_ALTITUDE_M..=TROPOSPHERE_TOP_M).contains(&altitude_m)
363}
364
365fn invalid_value(path: &str, message: impl Into<String>) -> SolveErrorEnvelopeV1 {
366    SolveErrorEnvelopeV1::new(
367        SolveErrorV1::new(SolveErrorCodeV1::InvalidValue, message).at_path(path),
368    )
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use serde_json::json;
375
376    fn describe(atmosphere: AtmosphereV1) -> DensityAltitudeResponseV1 {
377        density_altitude_v1(&DensityAltitudeRequestV1 { atmosphere }).expect("valid atmosphere")
378    }
379
380    fn refusal(atmosphere: AtmosphereV1) -> SolveErrorV1 {
381        density_altitude_v1(&DensityAltitudeRequestV1 { atmosphere })
382            .expect_err("atmosphere should be refused")
383            .error
384    }
385
386    fn at(temperature_c: f64, pressure_hpa: f64, relative_humidity: f64) -> AtmosphereV1 {
387        AtmosphereV1 {
388            altitude_m: Some(0.0),
389            temperature_k: Some(temperature_c + KELVIN_OFFSET_C),
390            pressure_pa: Some(pressure_hpa * PASCALS_PER_HECTOPASCAL),
391            relative_humidity: Some(relative_humidity),
392            ..AtmosphereV1::default()
393        }
394    }
395
396    #[test]
397    fn icao_standard_dry_air_is_zero_by_both_definitions() {
398        let r = describe(at(15.0, 1013.25, 0.0));
399        assert!(r.density_altitude.faa_rule.m.abs() < 1e-9, "{r:?}");
400        assert!(r.density_altitude.density_matched.m.abs() < 1e-9, "{r:?}");
401        assert!(r.pressure_altitude.m.abs() < 1e-9, "{r:?}");
402    }
403
404    /// The reported case: 0 m by the FAA rule where a humidity-aware tool read 36 m.
405    /// Density-matched gives 33.1 m; the remaining few meters are that tool's own method.
406    ///
407    /// 33.10 m was recomputed independently of this crate, from the published CIPM-2007
408    /// coefficients and the ISA troposphere, and agreed to 0.01 m (2026-09-27) — so the pin is
409    /// tight on purpose: a drift here is a change to the solver's air density.
410    #[test]
411    fn humidity_moves_the_density_matched_figure_and_not_the_faa_one() {
412        let r = describe(at(15.0, 1013.25, 0.5));
413        assert!(r.density_altitude.faa_rule.m.abs() < 1e-9, "{r:?}");
414        let matched = r.density_altitude.density_matched.m;
415        assert!(
416            (matched - 33.10).abs() < 0.2,
417            "50% RH at ICAO standard should be 33.1 m density-matched, got {matched}"
418        );
419    }
420
421    #[test]
422    fn density_matched_rises_monotonically_with_humidity() {
423        let mut last = f64::NEG_INFINITY;
424        for rh in [0.0, 0.25, 0.5, 0.75, 1.0] {
425            let m = describe(at(15.0, 1013.25, rh))
426                .density_altitude
427                .density_matched
428                .m;
429            assert!(m > last, "RH {rh} gave {m}, not above {last}");
430            last = m;
431        }
432    }
433
434    /// The two definitions part company in DRY air away from standard, which is why their
435    /// difference must never be labeled a humidity correction. Ideal-gas ISA says 30 °C dry at
436    /// 1013.25 hPa is ~525.5 m, CIPM-2007 (whose compressibility factor also moves with
437    /// temperature) 526.9 m, and the FAA rule 548.6 m.
438    #[test]
439    fn the_definitions_differ_in_dry_air_away_from_standard() {
440        let r = describe(at(30.0, 1013.25, 0.0));
441        let faa = r.density_altitude.faa_rule.m;
442        let matched = r.density_altitude.density_matched.m;
443        assert!((faa - 548.64).abs() < 0.05, "FAA rule at 30 C dry: {faa}");
444        assert!(
445            (matched - 526.92).abs() < 0.2,
446            "density-matched at 30 C dry: {matched}"
447        );
448        assert!(
449            faa - matched > 15.0,
450            "expected the FAA rule to read ~22 m high, got {faa} vs {matched}"
451        );
452    }
453
454    /// Pins the "bit-identical to what the DOPE card has always printed" claim against the
455    /// formula as it was written out in `pdf_dope_card` before it moved to `atmosphere` —
456    /// comparing against the function the service calls would test nothing.
457    #[test]
458    fn faa_rule_is_bit_identical_to_the_card_formula_it_replaced() {
459        for (t_c, p_hpa) in [
460            (15.0, 1013.25),
461            (30.0, 1013.25),
462            (15.0, 950.0),
463            (0.0, 1013.25),
464            (-10.0, 850.0),
465            (41.3, 702.7),
466        ] {
467            let r = describe(at(t_c, p_hpa, 0.5));
468            // The formula is what is pinned, so feed it the inputs AFTER the service's own
469            // round trip through kelvin and pascals (41.3 °C does not survive that exactly).
470            let t_c = (t_c + KELVIN_OFFSET_C) - KELVIN_OFFSET_C;
471            let p_hpa = (p_hpa * PASCALS_PER_HECTOPASCAL) / PASCALS_PER_HECTOPASCAL;
472            let t_f = t_c * 9.0 / 5.0 + 32.0;
473            let pressure_alt = 145_366.45 * (1.0 - (p_hpa / 1013.25_f64).powf(0.190_284));
474            let isa_temp_f = 59.0 - (pressure_alt / 1000.0) * 3.57;
475            let expected = pressure_alt + (120.0 * 5.0 / 9.0) * (t_f - isa_temp_f);
476            assert_eq!(
477                r.density_altitude.faa_rule.ft.to_bits(),
478                expected.to_bits(),
479                "{t_c} C {p_hpa} hPa"
480            );
481        }
482    }
483
484    /// `air_density_kg_m3` is the solver's own muzzle density, not a second model.
485    #[test]
486    fn air_density_is_the_solvers_density() {
487        let r = describe(at(15.0, 1013.25, 0.5));
488        let (solver_density, _) =
489            crate::atmosphere::calculate_atmosphere(0.0, Some(15.0), Some(1013.25), 50.0);
490        assert_eq!(r.air_density_kg_m3.to_bits(), solver_density.to_bits());
491        assert!(
492            (r.air_density_kg_m3 - 1.2216).abs() < 0.0002,
493            "{}",
494            r.air_density_kg_m3
495        );
496    }
497
498    /// Humidity is a FRACTION on this wire, as in solve-json v1. 50 means 5000%, and is refused
499    /// with the solve's own path rather than silently read as 50%.
500    #[test]
501    fn humidity_in_percent_is_refused_at_its_path() {
502        let error = refusal(at(15.0, 1013.25, 50.0));
503        assert_eq!(error.path(), Some("$.atmosphere.relative_humidity"));
504    }
505
506    /// Omitting everything is legal, resolves to ICAO standard at sea level with 50% RH, and
507    /// SAYS so — the same four assumptions a solve would announce.
508    #[test]
509    fn omitted_fields_resolve_like_a_solve_and_are_announced() {
510        let r = describe(AtmosphereV1::default());
511        assert_eq!(r.atmosphere.altitude_m, 0.0);
512        assert!((r.atmosphere.temperature_k - 288.15).abs() < 1e-9);
513        assert!((r.atmosphere.pressure_pa - 101_325.0).abs() < 1e-6);
514        assert_eq!(r.atmosphere.relative_humidity, 0.5);
515        let paths: Vec<_> = r
516            .assumptions
517            .iter()
518            .filter_map(|a| a.path.as_deref())
519            .collect();
520        for expected in [
521            "$.atmosphere.altitude_m",
522            "$.atmosphere.temperature_k",
523            "$.atmosphere.pressure_pa",
524            "$.atmosphere.relative_humidity",
525        ] {
526            assert!(
527                paths.contains(&expected),
528                "missing assumption at {expected}: {paths:?}"
529            );
530        }
531    }
532
533    /// A QNH is reduced to station pressure BEFORE either density altitude is computed —
534    /// density altitude of the sea, rather than of the firing point, is the MBA-643 defect.
535    #[test]
536    fn a_qnh_is_reduced_to_station_pressure_first() {
537        let altitude_m = 1500.0;
538        let mut qnh = at(15.0, 1013.25, 0.0);
539        qnh.altitude_m = Some(altitude_m);
540        qnh.pressure_reference = Some(PressureReferenceV1::Qnh);
541        let r = describe(qnh);
542        assert!(
543            r.atmosphere.pressure_pa < 90_000.0,
544            "QNH 1013.25 at 1500 m should reduce to ~845 hPa station, got {} Pa",
545            r.atmosphere.pressure_pa
546        );
547        assert!(
548            (r.pressure_altitude.m - altitude_m).abs() < 30.0,
549            "pressure altitude of a standard QNH should be ~the station altitude: {}",
550            r.pressure_altitude.m
551        );
552        assert!(r
553            .assumptions
554            .iter()
555            .any(|a| a.path.as_deref() == Some("$.atmosphere.pressure_pa")));
556    }
557
558    #[test]
559    fn meters_and_feet_agree() {
560        let r = describe(at(30.0, 950.0, 0.5));
561        for altitude in [
562            r.pressure_altitude,
563            r.density_altitude.faa_rule,
564            r.density_altitude.density_matched,
565        ] {
566            assert!(
567                (altitude.m - altitude.ft * METERS_PER_FOOT).abs() < 1e-9,
568                "{altitude:?}"
569            );
570        }
571    }
572
573    /// °C sent as `temperature_k` is the likeliest temperature mistake, and it used to come
574    /// back `ok` with a density of 23 kg/m³. So did 400 K, and 1e-300 K came back with a
575    /// `null` density.
576    #[test]
577    fn a_temperature_no_air_can_have_is_refused_at_temperature_k() {
578        for kelvin in [15.0, 5.0, 1e-300, 173.0, 373.2, 400.0, 1e300] {
579            let mut atmosphere = at(15.0, 1013.25, 0.5);
580            atmosphere.temperature_k = Some(kelvin);
581            let error = refusal(atmosphere);
582            assert_eq!(
583                error.path(),
584                Some("$.atmosphere.temperature_k"),
585                "{kelvin} K: {error:?}"
586            );
587        }
588    }
589
590    /// hPa sent as `pressure_pa` — a pressure altitude of about 30 km — is refused at the
591    /// pressure, not blamed on the humidity even on a day warm enough that the vapor check
592    /// would also trip.
593    #[test]
594    fn a_pressure_outside_the_troposphere_is_refused_at_pressure_pa() {
595        for pascals in [1013.25, 101.325, 29.92, 1e-300, 250_000.0, 1e300] {
596            let mut atmosphere = at(25.0, 1013.25, 0.9);
597            atmosphere.pressure_pa = Some(pascals);
598            let error = refusal(atmosphere);
599            assert_eq!(
600                error.path(),
601                Some("$.atmosphere.pressure_pa"),
602                "{pascals} Pa: {error:?}"
603            );
604        }
605    }
606
607    /// With the pressure defaulted, a station above the troposphere is the altitude's fault.
608    #[test]
609    fn a_defaulted_pressure_above_the_troposphere_is_refused_at_altitude_m() {
610        let error = refusal(AtmosphereV1 {
611            altitude_m: Some(20_000.0),
612            ..AtmosphereV1::default()
613        });
614        assert_eq!(error.path(), Some("$.atmosphere.altitude_m"));
615    }
616
617    /// A plausible QNH reduced at an impossible altitude (feet sent as meters) is the
618    /// altitude's fault; an implausible QNH is the pressure's. Either way the message quotes
619    /// the QNH the caller sent, not the station pressure derived from it.
620    #[test]
621    fn a_qnh_refusal_blames_the_input_that_is_wrong_and_quotes_what_was_sent() {
622        let mut feet_as_meters = at(15.0, 1013.25, 0.5);
623        feet_as_meters.altitude_m = Some(36_000.0);
624        feet_as_meters.pressure_reference = Some(PressureReferenceV1::Qnh);
625        let error = refusal(feet_as_meters);
626        assert_eq!(error.path(), Some("$.atmosphere.altitude_m"), "{error:?}");
627        assert!(
628            error.message.contains("a QNH of 101325 Pa"),
629            "{}",
630            error.message
631        );
632
633        let mut hpa_as_pa = at(15.0, 1013.25, 0.5);
634        hpa_as_pa.altitude_m = Some(1500.0);
635        hpa_as_pa.pressure_pa = Some(1013.25);
636        hpa_as_pa.pressure_reference = Some(PressureReferenceV1::Qnh);
637        let error = refusal(hpa_as_pa);
638        assert_eq!(error.path(), Some("$.atmosphere.pressure_pa"), "{error:?}");
639        assert!(
640            error.message.contains("a QNH of 1013.25 Pa"),
641            "{}",
642            error.message
643        );
644    }
645
646    /// An absurd input is echoed in exponent form, not as hundreds of digits.
647    #[test]
648    fn absurd_inputs_are_echoed_compactly() {
649        let mut atmosphere = at(15.0, 1013.25, 0.5);
650        atmosphere.temperature_k = Some(1e300);
651        let error = refusal(atmosphere);
652        assert!(error.message.starts_with("1e300 K"), "{}", error.message);
653        assert!(error.message.len() < 300, "{}", error.message);
654    }
655
656    /// Saturated air at 100 °C needs more vapor pressure than a standard sea-level
657    /// atmosphere's total pressure: that air does not exist, and computing it as pure steam is
658    /// not an answer.
659    #[test]
660    fn humidity_beyond_the_total_pressure_is_refused_at_relative_humidity() {
661        let error = refusal(at(100.0, 1013.25, 1.0));
662        assert_eq!(
663            error.path(),
664            Some("$.atmosphere.relative_humidity"),
665            "{error:?}"
666        );
667        // The same air at 90% is merely unusual.
668        describe(at(100.0, 1013.25, 0.9));
669        // And the hottest, wettest sea-level day on record is fine.
670        describe(at(45.0, 1013.25, 1.0));
671    }
672
673    /// Anything accepted is finite, with a positive density: sweep the whole accepted domain.
674    #[test]
675    fn every_accepted_atmosphere_reports_finite_figures() {
676        let mut accepted = 0;
677        for t in (0..=20).map(|i| MIN_TEMPERATURE_K + 10.0 * f64::from(i)) {
678            for p in (0..=16).map(|i| 20_000.0 + 10_000.0 * f64::from(i)) {
679                for rh in [0.0, 0.3, 0.7, 1.0] {
680                    let atmosphere = AtmosphereV1 {
681                        altitude_m: Some(0.0),
682                        temperature_k: Some(t),
683                        pressure_pa: Some(p),
684                        relative_humidity: Some(rh),
685                        ..AtmosphereV1::default()
686                    };
687                    let Ok(r) = density_altitude_v1(&DensityAltitudeRequestV1 { atmosphere })
688                    else {
689                        continue;
690                    };
691                    accepted += 1;
692                    assert!(r.air_density_kg_m3.is_finite() && r.air_density_kg_m3 > 0.0);
693                    for altitude in [
694                        r.pressure_altitude,
695                        r.density_altitude.faa_rule,
696                        r.density_altitude.density_matched,
697                    ] {
698                        assert!(altitude.m.is_finite() && altitude.ft.is_finite(), "{r:?}");
699                        assert!(within_troposphere(altitude.m), "{r:?}");
700                    }
701                }
702            }
703        }
704        assert!(accepted > 300, "only {accepted} accepted");
705    }
706
707    /// Malformed objects are refused by the solve's own shape validator, with its codes and
708    /// paths — the same answer `solve` gives for the same `atmosphere`.
709    #[test]
710    fn malformed_requests_get_the_solves_codes_and_paths() {
711        let cases = [
712            (
713                json!({"atmosphere": {"temperature_c": 30.0}}),
714                SolveErrorCodeV1::UnknownField,
715                "$.atmosphere.temperature_c",
716            ),
717            (
718                json!({"atmosphere": {"temperature_k": null}}),
719                SolveErrorCodeV1::InvalidValue,
720                "$.atmosphere.temperature_k",
721            ),
722            (
723                json!({"atmosphere": {"pressure_pa": "101325"}}),
724                SolveErrorCodeV1::InvalidValue,
725                "$.atmosphere.pressure_pa",
726            ),
727            (
728                json!({"atmosphere": {"pressure_reference": "altimeter"}}),
729                SolveErrorCodeV1::InvalidValue,
730                "$.atmosphere.pressure_reference",
731            ),
732            (json!({}), SolveErrorCodeV1::MissingField, "$.atmosphere"),
733            (
734                json!({"atmosphere": {}, "units": "si"}),
735                SolveErrorCodeV1::UnknownField,
736                "$.units",
737            ),
738            (json!([{}]), SolveErrorCodeV1::InvalidValue, "$"),
739        ];
740        for (request, code, path) in cases {
741            let error = decode_density_altitude_request_v1(&request)
742                .expect_err("malformed")
743                .error;
744            assert_eq!(error.code, code, "{request}: {error:?}");
745            assert_eq!(error.path(), Some(path), "{request}: {error:?}");
746        }
747        assert!(decode_density_altitude_request_v1(&json!({"atmosphere": {}})).is_ok());
748    }
749}