Skip to main content

hpr_atmos/
ussa76.rs

1//! The U.S. Standard Atmosphere, 1976, from −5 km to 86 km geometric altitude, with an optional
2//! temperature offset and sea-level pressure.
3//!
4//! Source: *U.S. Standard Atmosphere, 1976*, NOAA-S/T 76-1562 (NOAA, NASA and USAF, Washington,
5//! 1976), part 1, pinned as `us-std-atmosphere-1976`. Equation, table and page numbers below are
6//! that document's. `docs/physics/atmosphere.md` has the details and the tests that pin them.
7//!
8//! **Model.** Below 86 km the atmosphere is a sequence of layers in **geopotential** altitude `H`,
9//! each with a constant gradient `L_M,b` of the molecular-scale temperature `T_M` (Table 4):
10//!
11//! ```text
12//! H   = r₀ Z / (r₀ + Z)                                            (18)
13//! T_M = T_M,b + L_M,b (H − H_b)                                    (23)
14//! P   = P_b [T_M,b / T_M]^(g₀′ M₀ / (R* L_M,b))        L_M,b ≠ 0     (33a)
15//! P   = P_b exp[−g₀′ M₀ (H − H_b) / (R* T_M,b)]        L_M,b = 0     (33b)
16//! ρ   = P M₀ / (R* T_M)                                            (42)
17//! T   = T_M M / M₀                                                 (22)
18//! a   = (γ R* T_M / M₀)^½                                          (50)
19//! μ   = β T^(3/2) / (T + S)                                        (51)
20//! ```
21//!
22//! `Z` is geometric altitude, `T` the kinetic temperature, and `M/M₀` the molecular-weight ratio,
23//! 1 below 80 km and tabulated from 80 to 86 km (Table 8). The printed tables leave `M/M₀` out
24//! below 86 km and print `T = T_M` there (p. 9); this module follows the equations, so from 80 to
25//! 85.5 km its kinetic temperature is up to 0.036% and its viscosity up to 0.031% below the printed
26//! values.
27//!
28//! **Offsets.** [`Ussa76::with_offset`] adds a constant `ΔT` to `T_M` at every geopotential height
29//! and integrates the hydrostatic equation from a chosen sea-level pressure, so pressure and
30//! density stay consistent with the warmer or colder temperature. With `ΔT = 0` and
31//! `P₀ = 101325 Pa` it is the standard itself. [`Ussa76::anchored`] picks `ΔT` and `P₀` to pass
32//! through a measured temperature and pressure at one height, such as the launch site.
33//!
34//! **Outside −5 km to 86 km** the model extends the lowest layer downward and continues
35//! isothermally above 86 km, and flags every such sample as extrapolated. The real standard is
36//! also isothermal (186.87 K) from 86 to 91 km and warms above that, and its composition changes
37//! above 86 km, so pressure and density there are rough.
38
39use hpr_core::gravity::STANDARD_GRAVITY_MPS2;
40use hpr_core::interp::Side;
41use serde::{Deserialize, Serialize};
42
43use crate::air::{AirSample, AirState, Atmosphere};
44use crate::error::{AtmosError, finite, positive};
45
46/// Universal gas constant `R*` as the 1976 standard adopts it, J/(kmol·K) (p. 3; Table 2 on p. 2
47/// misprints the exponent's sign). It is not the current CODATA value (8.314462…e3); the
48/// standard's tables are computed with this one.
49pub const GAS_CONSTANT_J_PER_KMOL_K: f64 = 8.314_32e3;
50
51/// Sea-level mean molecular weight of air `M₀`, kg/kmol (p. 9, eq. 21).
52pub const SEA_LEVEL_MOLECULAR_WEIGHT_KG_PER_KMOL: f64 = 28.964_4;
53
54/// Effective Earth radius `r₀` for geopotential altitude, m (pp. 4 and 8).
55pub const EARTH_RADIUS_M: f64 = 6_356_766.0;
56
57/// Sea-level pressure `P₀`, Pa (Table 2).
58pub const SEA_LEVEL_PRESSURE_PA: f64 = 101_325.0;
59
60/// Sea-level temperature `T₀`, K (Table 2).
61pub const SEA_LEVEL_TEMPERATURE_K: f64 = 288.15;
62
63/// Ratio of specific heats of air `γ` (Table 2).
64pub const RATIO_OF_SPECIFIC_HEATS: f64 = 1.40;
65
66/// Sutherland's constant `β` for the viscosity of air, kg/(s·m·K^½) (Table 2 and p. 19).
67pub const SUTHERLAND_BETA: f64 = 1.458e-6;
68
69/// Sutherland's constant `S` for the viscosity of air, K (p. 19). Table 2 and p. 4 print 110 K,
70/// but the tables are computed with 110.4 K: sea-level viscosity is 1.7894e-5 Pa·s with it and
71/// 1.7912e-5 with 110.
72pub const SUTHERLAND_S_K: f64 = 110.4;
73
74/// Specific gas constant of dry air, `R* / M₀`, J/(kg·K).
75pub const DRY_AIR_GAS_CONSTANT_J_PER_KG_K: f64 =
76    GAS_CONSTANT_J_PER_KMOL_K / SEA_LEVEL_MOLECULAR_WEIGHT_KG_PER_KMOL;
77
78/// Lowest geometric altitude the standard defines, m.
79pub const MIN_HEIGHT_M: f64 = -5_000.0;
80
81/// Highest geometric altitude of the model, m. Above this the standard uses a different
82/// formulation, which this crate does not implement.
83pub const MAX_HEIGHT_M: f64 = 86_000.0;
84
85/// `g₀′ M₀ / R*`, K per geopotential meter: the hydrostatic constant of eqs. 33a and 33b.
86const HYDROSTATIC_K_PER_M: f64 =
87    STANDARD_GRAVITY_MPS2 * SEA_LEVEL_MOLECULAR_WEIGHT_KG_PER_KMOL / GAS_CONSTANT_J_PER_KMOL_K;
88
89/// Layer bases: geopotential altitude `H_b` (m′) and molecular-scale temperature gradient
90/// `L_M,b` (K/m′), Table 4. The seventh base, `H_7 = 84 852 m′`, is the top of the model.
91const LAYERS: [(f64, f64); 7] = [
92    (0.0, -0.0065),
93    (11_000.0, 0.0),
94    (20_000.0, 0.001),
95    (32_000.0, 0.0028),
96    (47_000.0, 0.0),
97    (51_000.0, -0.0028),
98    (71_000.0, -0.002),
99];
100
101/// Molecular-weight ratio `M/M₀` at geometric altitudes 80.0, 80.5, …, 86.0 km (Table 8).
102const MOLECULAR_WEIGHT_RATIO: [f64; 13] = [
103    1.000_000, 0.999_996, 0.999_989, 0.999_971, 0.999_941, 0.999_909, 0.999_870, 0.999_829,
104    0.999_786, 0.999_741, 0.999_694, 0.999_641, 0.999_579,
105];
106
107/// Geometric altitude where Table 8 starts, m.
108const MOLECULAR_WEIGHT_TABLE_START_M: f64 = 80_000.0;
109
110/// Spacing of Table 8, m.
111const MOLECULAR_WEIGHT_TABLE_STEP_M: f64 = 500.0;
112
113/// Geopotential altitude `H` (m′) from geometric altitude `Z` (m), eq. 18, with the standard's
114/// constant `g₀` and effective radius `r₀`. Heights at or below `−r₀` have no geopotential.
115///
116/// # Errors
117///
118/// [`AtmosError::Domain`] if `geometric_m` is not finite or not above `−r₀`.
119pub fn geopotential_from_geometric_m(geometric_m: f64) -> Result<f64, AtmosError> {
120    let z = finite("geometric altitude (m)", geometric_m)?;
121    if z <= -EARTH_RADIUS_M {
122        return Err(AtmosError::Domain {
123            what: "geometric altitude (m)",
124            value: z,
125        });
126    }
127    Ok(EARTH_RADIUS_M * z / (EARTH_RADIUS_M + z))
128}
129
130/// Geometric altitude `Z` (m) from geopotential altitude `H` (m′), the inverse of eq. 18:
131/// `Z = r₀ H / (r₀ − H)`. `H` must be below `r₀`, the geopotential altitude of infinity.
132///
133/// # Errors
134///
135/// [`AtmosError::Domain`] if `geopotential_m` is not finite or not below `r₀`.
136pub fn geometric_from_geopotential_m(geopotential_m: f64) -> Result<f64, AtmosError> {
137    let h = finite("geopotential altitude (m')", geopotential_m)?;
138    if h >= EARTH_RADIUS_M {
139        return Err(AtmosError::Domain {
140            what: "geopotential altitude (m')",
141            value: h,
142        });
143    }
144    Ok(EARTH_RADIUS_M * h / (EARTH_RADIUS_M - h))
145}
146
147/// Molecular-weight ratio `M/M₀` at geometric altitude `z` (m): 1 below 80 km, Table 8 linearly
148/// interpolated from 80 to 86 km, and held at its 86 km value above.
149fn molecular_weight_ratio(z: f64) -> f64 {
150    if z <= MOLECULAR_WEIGHT_TABLE_START_M {
151        return 1.0;
152    }
153    let position = (z - MOLECULAR_WEIGHT_TABLE_START_M) / MOLECULAR_WEIGHT_TABLE_STEP_M;
154    let last = MOLECULAR_WEIGHT_RATIO.len() - 1;
155    if position >= 12.0 {
156        return MOLECULAR_WEIGHT_RATIO[last];
157    }
158    // 0 < position < 12, so the floor is an index from 0 to 11.
159    // Cast: position is in (0, 12), so its floor fits a usize.
160    let i = position.floor() as usize;
161    let t = position - position.floor();
162    MOLECULAR_WEIGHT_RATIO[i] + t * (MOLECULAR_WEIGHT_RATIO[i + 1] - MOLECULAR_WEIGHT_RATIO[i])
163}
164
165/// The index of the layer containing geopotential altitude `h`. Below the first base it is the
166/// first layer; above the 86 km top the caller holds the top values instead.
167fn standard_layer(h: f64) -> usize {
168    LAYERS.iter().rposition(|&(base, _)| h >= base).unwrap_or(0)
169}
170
171/// The U.S. Standard Atmosphere, 1976, optionally offset in temperature and sea-level pressure.
172///
173/// It serializes as its two parameters, `temperature_offset_k` and `sea_level_pressure_pa`, and
174/// re-checks them when deserialized.
175#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
176#[serde(try_from = "Ussa76Data", into = "Ussa76Data")]
177pub struct Ussa76 {
178    temperature_offset_k: f64,
179    sea_level_pressure_pa: f64,
180    /// Molecular-scale temperature at each layer base, offset included, K.
181    base_temperatures_k: [f64; 7],
182    /// Pressure at each layer base, Pa.
183    base_pressures_pa: [f64; 7],
184    /// Geopotential altitude of `Z = 86 km`, m′.
185    top_geopotential_m: f64,
186    /// Molecular-scale temperature at the 86 km top, K.
187    top_temperature_k: f64,
188    /// Pressure at the 86 km top, Pa.
189    top_pressure_pa: f64,
190}
191
192#[derive(Serialize, Deserialize)]
193#[serde(deny_unknown_fields)]
194struct Ussa76Data {
195    temperature_offset_k: f64,
196    sea_level_pressure_pa: f64,
197}
198
199impl TryFrom<Ussa76Data> for Ussa76 {
200    type Error = AtmosError;
201
202    fn try_from(data: Ussa76Data) -> Result<Self, AtmosError> {
203        Ussa76::with_offset(data.temperature_offset_k, data.sea_level_pressure_pa)
204    }
205}
206
207impl From<Ussa76> for Ussa76Data {
208    fn from(model: Ussa76) -> Self {
209        Ussa76Data {
210            temperature_offset_k: model.temperature_offset_k,
211            sea_level_pressure_pa: model.sea_level_pressure_pa,
212        }
213    }
214}
215
216impl Default for Ussa76 {
217    fn default() -> Self {
218        Ussa76::standard()
219    }
220}
221
222/// The lowest standard molecular-scale temperature anywhere in the model: the 86 km top,
223/// `T_M,6 + L_M,6 (H(86 km) − H_6)`, a little above Table 4's 186.946 K at `H_7 = 84.852 km′`.
224fn coldest_standard_temperature_k() -> f64 {
225    let top = EARTH_RADIUS_M * MAX_HEIGHT_M / (EARTH_RADIUS_M + MAX_HEIGHT_M);
226    let (base, lapse) = LAYERS[6];
227    standard_base_temperatures_k()[6] + lapse * (top - base)
228}
229
230/// `T_M,b` of Table 4, from `T₀` and the gradients.
231fn standard_base_temperatures_k() -> [f64; 7] {
232    let mut temperatures = [SEA_LEVEL_TEMPERATURE_K; 7];
233    for b in 1..7 {
234        let (base, lapse) = LAYERS[b - 1];
235        temperatures[b] = temperatures[b - 1] + lapse * (LAYERS[b].0 - base);
236    }
237    temperatures
238}
239
240/// Pressure at geopotential `h` within the layer that starts at `(base_h, base_t, base_p)` with
241/// gradient `lapse`, eqs. 33a and 33b.
242fn layer_pressure(h: f64, base_h: f64, lapse: f64, base_t: f64, base_p: f64) -> f64 {
243    if lapse == 0.0 {
244        base_p * (-HYDROSTATIC_K_PER_M * (h - base_h) / base_t).exp()
245    } else {
246        let t = base_t + lapse * (h - base_h);
247        base_p * (base_t / t).powf(HYDROSTATIC_K_PER_M / lapse)
248    }
249}
250
251impl Ussa76 {
252    /// The standard itself: no offset and `P₀ = 101325 Pa`.
253    pub fn standard() -> Self {
254        Ussa76::build(0.0, SEA_LEVEL_PRESSURE_PA)
255    }
256
257    /// The standard with its molecular-scale temperature offset by `temperature_offset_k` at every
258    /// geopotential height, and hydrostatic pressure from `sea_level_pressure_pa` at `H = 0`.
259    ///
260    /// # Errors
261    ///
262    /// [`AtmosError::Domain`] if the offset is not finite or would make the temperature anywhere
263    /// in the model zero or negative (it must exceed −186.9 K), or if the sea-level pressure is
264    /// not finite and positive.
265    pub fn with_offset(
266        temperature_offset_k: f64,
267        sea_level_pressure_pa: f64,
268    ) -> Result<Self, AtmosError> {
269        let offset = finite("temperature offset (K)", temperature_offset_k)?;
270        if coldest_standard_temperature_k() + offset <= 0.0 {
271            return Err(AtmosError::Domain {
272                what: "temperature offset (K)",
273                value: offset,
274            });
275        }
276        let p0 = positive("sea-level pressure (Pa)", sea_level_pressure_pa)?;
277        Ok(Ussa76::build(offset, p0))
278    }
279
280    /// The offset standard that passes through a measured kinetic `temperature_k` and
281    /// `pressure_pa` at geometric `height_msl_m`: `ΔT` makes the temperature match there, and `P₀`
282    /// scales the pressure profile to match. Launch-site conditions are the usual use.
283    ///
284    /// The offset holds all the way up, which a real hot or cold day does not: anchoring +20 K at
285    /// a 1400 m field (at the standard's field pressure) makes the air 5.7% thinner at 3 km but
286    /// 14% denser at 20 km and 30% denser at 30 km than the standard
287    /// (`validation/oracles/atmosphere/conventions.py`). For flights far above the field, prefer
288    /// a sounding.
289    ///
290    /// # Errors
291    ///
292    /// [`AtmosError::Domain`] if the height is not finite or not above `−r₀`, the temperature or
293    /// pressure is not finite and positive, or the implied offset or sea-level pressure is out of
294    /// range (see [`Ussa76::with_offset`]).
295    pub fn anchored(
296        height_msl_m: f64,
297        temperature_k: f64,
298        pressure_pa: f64,
299    ) -> Result<Self, AtmosError> {
300        let h = geopotential_from_geometric_m(height_msl_m)?;
301        let temperature = positive("anchor temperature (K)", temperature_k)?;
302        let pressure = positive("anchor pressure (Pa)", pressure_pa)?;
303        let standard = Ussa76::standard();
304        let offset = temperature / molecular_weight_ratio(height_msl_m)
305            - standard.molecular_scale_temperature_k(h);
306        // Pressure is proportional to P₀ at a fixed offset, so scale a unit profile.
307        let offset_unit = Ussa76::with_offset(offset, 1.0)?;
308        let unit_pressure = offset_unit.pressure_pa(h);
309        Ussa76::with_offset(offset, pressure / unit_pressure)
310    }
311
312    fn build(offset: f64, p0: f64) -> Self {
313        let mut base_temperatures_k = standard_base_temperatures_k();
314        for t in &mut base_temperatures_k {
315            *t += offset;
316        }
317        let mut base_pressures_pa = [p0; 7];
318        for b in 1..7 {
319            let (base, lapse) = LAYERS[b - 1];
320            base_pressures_pa[b] = layer_pressure(
321                LAYERS[b].0,
322                base,
323                lapse,
324                base_temperatures_k[b - 1],
325                base_pressures_pa[b - 1],
326            );
327        }
328        let top_geopotential_m = EARTH_RADIUS_M * MAX_HEIGHT_M / (EARTH_RADIUS_M + MAX_HEIGHT_M);
329        let (base, lapse) = LAYERS[6];
330        let top_temperature_k = base_temperatures_k[6] + lapse * (top_geopotential_m - base);
331        let top_pressure_pa = layer_pressure(
332            top_geopotential_m,
333            base,
334            lapse,
335            base_temperatures_k[6],
336            base_pressures_pa[6],
337        );
338        Ussa76 {
339            temperature_offset_k: offset,
340            sea_level_pressure_pa: p0,
341            base_temperatures_k,
342            base_pressures_pa,
343            top_geopotential_m,
344            top_temperature_k,
345            top_pressure_pa,
346        }
347    }
348
349    /// The temperature offset `ΔT`, K.
350    pub fn temperature_offset_k(&self) -> f64 {
351        self.temperature_offset_k
352    }
353
354    /// The pressure at `H = 0`, Pa.
355    pub fn sea_level_pressure_pa(&self) -> f64 {
356        self.sea_level_pressure_pa
357    }
358
359    /// Molecular-scale temperature `T_M` at geopotential altitude `h` (m′), offset included.
360    fn molecular_scale_temperature_k(&self, h: f64) -> f64 {
361        if h > self.top_geopotential_m {
362            return self.top_temperature_k;
363        }
364        let b = standard_layer(h);
365        let (base, lapse) = LAYERS[b];
366        self.base_temperatures_k[b] + lapse * (h - base)
367    }
368
369    /// Pressure at geopotential altitude `h` (m′).
370    fn pressure_pa(&self, h: f64) -> f64 {
371        if h > self.top_geopotential_m {
372            return layer_pressure(
373                h,
374                self.top_geopotential_m,
375                0.0,
376                self.top_temperature_k,
377                self.top_pressure_pa,
378            );
379        }
380        let b = standard_layer(h);
381        let (base, lapse) = LAYERS[b];
382        layer_pressure(
383            h,
384            base,
385            lapse,
386            self.base_temperatures_k[b],
387            self.base_pressures_pa[b],
388        )
389    }
390
391    /// The air at geometric height `height_msl_m` (see [`Atmosphere::air`]).
392    ///
393    /// # Errors
394    ///
395    /// [`AtmosError::Domain`] if the height is not finite or not above `−r₀`.
396    pub fn sample(&self, height_msl_m: f64) -> Result<AirSample, AtmosError> {
397        let h = geopotential_from_geometric_m(height_msl_m)?;
398        let t_m = self.molecular_scale_temperature_k(h);
399        let pressure = self.pressure_pa(h);
400        let temperature = t_m * molecular_weight_ratio(height_msl_m);
401        let air = AirState {
402            temperature_k: temperature,
403            pressure_pa: pressure,
404            density_kg_m3: pressure / (DRY_AIR_GAS_CONSTANT_J_PER_KG_K * t_m),
405            speed_of_sound_m_s: (RATIO_OF_SPECIFIC_HEATS * DRY_AIR_GAS_CONSTANT_J_PER_KG_K * t_m)
406                .sqrt(),
407            dynamic_viscosity_pa_s: sutherland_viscosity_pa_s(temperature),
408        };
409        let extrapolated = if height_msl_m < MIN_HEIGHT_M {
410            Some(Side::Below)
411        } else if height_msl_m > MAX_HEIGHT_M {
412            Some(Side::Above)
413        } else {
414            None
415        };
416        Ok(AirSample { air, extrapolated })
417    }
418
419    /// The geopotential altitude `H` (m′) at which this atmosphere's pressure is `pressure_pa`:
420    /// its **pressure altitude**. Of the standard itself, this is what a barometric altimeter
421    /// reads.
422    ///
423    /// It inverts eqs. 33a and 33b in the layer whose base pressures bracket `P`:
424    ///
425    /// ```text
426    /// H = H_b + (T_M,b / L_M,b) [(P / P_b)^(−R* L_M,b / (g₀′ M₀)) − 1]      L_M,b ≠ 0
427    /// H = H_b − (R* T_M,b / (g₀′ M₀)) ln(P / P_b)                         L_M,b = 0
428    /// ```
429    ///
430    /// Above sea-level pressure it extends the lowest layer, and below the 86 km top's it
431    /// continues isothermally, as [`Ussa76::sample`] does. In the troposphere of the standard it
432    /// is the altimeter formula `H = 44330.8 m [1 − (P / 101325 Pa)^0.190263]`.
433    ///
434    /// # Errors
435    ///
436    /// [`AtmosError::Domain`] if the pressure is not finite and positive.
437    pub fn pressure_altitude_m(&self, pressure_pa: f64) -> Result<f64, AtmosError> {
438        let p = positive("pressure (Pa)", pressure_pa)?;
439        let (base_h, lapse, base_t, base_p) = if p < self.top_pressure_pa {
440            (
441                self.top_geopotential_m,
442                0.0,
443                self.top_temperature_k,
444                self.top_pressure_pa,
445            )
446        } else {
447            // Base pressures fall with height, so the layer is the last whose base is at or
448            // above this pressure; above sea-level pressure it is the first.
449            let b = self
450                .base_pressures_pa
451                .iter()
452                .rposition(|&base| base >= p)
453                .unwrap_or(0);
454            let (base, lapse) = LAYERS[b];
455            (
456                base,
457                lapse,
458                self.base_temperatures_k[b],
459                self.base_pressures_pa[b],
460            )
461        };
462        Ok(if lapse == 0.0 {
463            base_h - base_t * (p / base_p).ln() / HYDROSTATIC_K_PER_M
464        } else {
465            base_h + base_t / lapse * ((p / base_p).powf(-lapse / HYDROSTATIC_K_PER_M) - 1.0)
466        })
467    }
468}
469
470impl Atmosphere for Ussa76 {
471    fn air(&self, height_msl_m: f64) -> Result<AirSample, AtmosError> {
472        self.sample(height_msl_m)
473    }
474}
475
476/// Dynamic viscosity of air by Sutherland's law with the 1976 standard's constants, eq. 51:
477/// `μ = β T^(3/2) / (T + S)`, Pa·s, for kinetic temperature `T` in K.
478pub fn sutherland_viscosity_pa_s(temperature_k: f64) -> f64 {
479    SUTHERLAND_BETA * temperature_k * temperature_k.sqrt() / (temperature_k + SUTHERLAND_S_K)
480}
481
482#[cfg(test)]
483mod tests {
484    use proptest::prelude::*;
485    use serde::Deserialize;
486
487    use super::*;
488
489    #[derive(Deserialize)]
490    struct TableFixture {
491        rows: Vec<Row>,
492    }
493
494    #[derive(Deserialize)]
495    struct Row {
496        geometric_altitude_m: f64,
497        printed: Printed,
498        si: Si,
499    }
500
501    #[derive(Deserialize)]
502    struct Si {
503        geopotential_altitude_m: f64,
504        temperature_k: f64,
505        molecular_scale_temperature_k: f64,
506        pressure_pa: f64,
507        density_kgpm3: f64,
508        speed_of_sound_mps: Option<f64>,
509        dynamic_viscosity_pas: Option<f64>,
510        kinematic_viscosity_m2ps: Option<f64>,
511    }
512
513    #[derive(Deserialize)]
514    struct Printed {
515        geopotential_altitude_m: String,
516        temperature_k: String,
517        molecular_scale_temperature_k: String,
518        pressure_mb: String,
519        density_kgpm3: String,
520        speed_of_sound_mps: Option<String>,
521        dynamic_viscosity_pas: Option<String>,
522        kinematic_viscosity_m2ps: Option<String>,
523    }
524
525    /// Table I and Table III of the 1976 standard at 32 geometric altitudes from −2 km to 86 km,
526    /// transcribed from the scan and cross-checked by `validation/oracles/ussa76/tables.py`.
527    fn table() -> TableFixture {
528        serde_json::from_str(include_str!(
529            "../../../validation/fixtures/atmosphere/ussa76-table-i.json"
530        ))
531        .unwrap()
532    }
533
534    /// A printed number and its last printed digit's place value, from text such as `2.2699E+2`
535    /// (value 226.99, count 0.01) or `216.774` (count 0.001).
536    fn parse_printed(text: &str) -> (f64, f64) {
537        let value: f64 = text.parse().unwrap();
538        let (mantissa, exponent) = match text.split_once('E') {
539            Some((m, e)) => (m, e.parse::<i32>().unwrap()),
540            None => (text, 0),
541        };
542        let decimals = mantissa.split_once('.').map_or(0, |(_, d)| d.len());
543        let decimals = i32::try_from(decimals).unwrap();
544        (value, 10f64.powi(exponent - decimals))
545    }
546
547    /// Number of last-digit counts between a computed value and the printed text.
548    fn counts_off(computed: f64, printed: &str) -> f64 {
549        let (value, count) = parse_printed(printed);
550        (computed - value).abs() / count
551    }
552
553    /// Relative difference from the printed value; zero when both are zero (sea level's `H`).
554    fn relative_error(computed: f64, printed: &str) -> f64 {
555        let (value, _) = parse_printed(printed);
556        if value == 0.0 {
557            return computed.abs();
558        }
559        ((computed - value) / value).abs()
560    }
561
562    /// The M1.2 *done when*: the model matches the 1976 tables at 32 altitudes to 0.1%, for
563    /// temperature, pressure, density, speed of sound, dynamic and kinematic viscosity.
564    ///
565    /// It holds a tighter line too: every printed value is within one count of its last digit,
566    /// with two documented exceptions.
567    /// - From 80 to 85.5 km the tables print `T = T_M` and compute viscosity from it (p. 9),
568    ///   where this model applies `M/M₀`. There the printed temperature is checked against `T_M`,
569    ///   the kinetic temperature against the print to 0.04%, and viscosity to 0.1% only.
570    /// - The 84 km density prints `9.6940E-6` where the equations give `9.69387e-6`, 1.3
571    ///   counts; the same row's `ρ/ρ₀` column gives `9.6939e-6`, so the print is off.
572    #[test]
573    fn matches_the_1976_tables_at_32_altitudes() {
574        let model = Ussa76::standard();
575        let rows = table().rows;
576        assert_eq!(rows.len(), 32);
577        let mut compared = 0;
578        for row in &rows {
579            let z = row.geometric_altitude_m;
580            let p = &row.printed;
581            let sample = model.sample(z).unwrap();
582            assert_eq!(sample.extrapolated, None, "{z}");
583            let air = sample.air;
584            let h = geopotential_from_geometric_m(z).unwrap();
585            let t_m = model.molecular_scale_temperature_k(h);
586            let tables_omit_m_over_m0 = (80_000.0..86_000.0).contains(&z) && z > 80_000.0;
587            let label = |what: &str| format!("{what} at {z} m");
588
589            let mut check = |computed: f64, printed: &str, what: &str, one_count: bool| {
590                assert!(relative_error(computed, printed) < 1e-3, "{}", label(what));
591                if one_count {
592                    let off = counts_off(computed, printed);
593                    assert!(
594                        off <= 1.0,
595                        "{}: {computed} vs {printed}, {off} counts",
596                        label(what)
597                    );
598                }
599                compared += 1;
600            };
601
602            check(h, &p.geopotential_altitude_m, "geopotential altitude", true);
603            check(t_m, &p.molecular_scale_temperature_k, "T_M", true);
604            if tables_omit_m_over_m0 {
605                check(t_m, &p.temperature_k, "printed T (= T_M)", true);
606                assert!(relative_error(air.temperature_k, &p.temperature_k) < 4e-4);
607            } else {
608                check(air.temperature_k, &p.temperature_k, "T", true);
609            }
610            check(air.pressure_pa / 100.0, &p.pressure_mb, "P", true);
611            let density_one_count = z != 84_000.0;
612            check(air.density_kg_m3, &p.density_kgpm3, "ρ", density_one_count);
613            if z == 84_000.0 {
614                assert!(counts_off(air.density_kg_m3, &p.density_kgpm3) < 1.5);
615            }
616            if let Some(printed) = &p.speed_of_sound_mps {
617                check(air.speed_of_sound_m_s, printed, "a", true);
618            }
619            if let Some(printed) = &p.dynamic_viscosity_pas {
620                check(
621                    air.dynamic_viscosity_pa_s,
622                    printed,
623                    "μ",
624                    !tables_omit_m_over_m0,
625                );
626                if tables_omit_m_over_m0 {
627                    let as_printed = sutherland_viscosity_pa_s(t_m);
628                    check(as_printed, printed, "μ(T_M)", true);
629                }
630            }
631            if let Some(printed) = &p.kinematic_viscosity_m2ps {
632                let nu = air.kinematic_viscosity_m2_s();
633                check(nu, printed, "ν", !tables_omit_m_over_m0);
634            }
635        }
636        // 32 rows × 5 columns, plus speed of sound and both viscosities on the 31 rows below
637        // 86 km, plus the `T_M`-based viscosity on the 3 rows at 82, 84 and 85 km.
638        assert_eq!(compared, 32 * 5 + 31 * 3 + 3);
639    }
640
641    /// The fixture's SI column is the printed column converted (millibars × 100), so a test
642    /// reading one reads the other. CI never runs the Python cross-check, so this runs here.
643    #[test]
644    fn fixture_si_values_are_the_printed_values() {
645        for row in table().rows {
646            let (p, si) = (&row.printed, &row.si);
647            let parsed = |text: &str| parse_printed(text).0;
648            let z = row.geometric_altitude_m;
649            assert_eq!(
650                si.geopotential_altitude_m,
651                parsed(&p.geopotential_altitude_m),
652                "{z}"
653            );
654            assert_eq!(si.temperature_k, parsed(&p.temperature_k), "{z}");
655            assert_eq!(
656                si.molecular_scale_temperature_k,
657                parsed(&p.molecular_scale_temperature_k),
658                "{z}"
659            );
660            let (mb, count) = parse_printed(&p.pressure_mb);
661            assert!((si.pressure_pa - 100.0 * mb).abs() <= 1e-6 * count, "{z}");
662            assert_eq!(si.density_kgpm3, parsed(&p.density_kgpm3), "{z}");
663            for (si_value, printed) in [
664                (si.speed_of_sound_mps, &p.speed_of_sound_mps),
665                (si.dynamic_viscosity_pas, &p.dynamic_viscosity_pas),
666                (si.kinematic_viscosity_m2ps, &p.kinematic_viscosity_m2ps),
667            ] {
668                assert_eq!(si_value, printed.as_deref().map(parsed), "{z}");
669            }
670        }
671    }
672
673    /// Loft lesson L2: 11 km *geometric* is 216.774 K and 22,699.96 Pa (Table I prints 2.2699E+2
674    /// mb, so within one count), not the 216.65 K and 22,632 Pa of 11 km geopotential.
675    #[test]
676    fn geometric_11_km_matches_the_1976_tables() {
677        let air = Ussa76::standard().sample(11_000.0).unwrap().air;
678        assert!(
679            (air.temperature_k - 216.774).abs() < 5e-4,
680            "{}",
681            air.temperature_k
682        );
683        assert!(
684            (air.pressure_pa - 22_699.0).abs() <= 1.0,
685            "{}",
686            air.pressure_pa
687        );
688        assert!((air.pressure_pa - 22_699.96).abs() < 0.01);
689        // And 11 km geopotential is the tropopause base.
690        let base = Ussa76::standard()
691            .sample(geometric_from_geopotential_m(11_000.0).unwrap())
692            .unwrap()
693            .air;
694        assert!((base.temperature_k - 216.65).abs() < 1e-9);
695        assert!(
696            (base.pressure_pa - 22_632.06).abs() < 0.01,
697            "{}",
698            base.pressure_pa
699        );
700    }
701
702    /// Loft lesson L3: all seven layers, so 50 km is in the isothermal stratopause at 270.65 K and
703    /// 79.779 Pa, and the 32 km lapse does not run on (70 km is 219.585 K, not 335 K).
704    #[test]
705    fn fifty_km_is_270_65_k_and_79_779_pa() {
706        let model = Ussa76::standard();
707        let air = model.sample(50_000.0).unwrap().air;
708        assert!((air.temperature_k - 270.65).abs() < 1e-9);
709        assert!(
710            (air.pressure_pa - 79.779).abs() < 5e-4,
711            "{}",
712            air.pressure_pa
713        );
714        let seventy = model.sample(70_000.0).unwrap().air;
715        assert!(
716            (seventy.temperature_k - 219.585).abs() < 5e-4,
717            "{}",
718            seventy.temperature_k
719        );
720    }
721
722    /// Loft lesson L4: Sutherland's law with the standard's `β = 1.458e-6` and `S = 110.4 K` gives
723    /// 1.7894e-5 Pa·s at sea level (Table III), where `S = 110 K` would give 1.7912e-5.
724    #[test]
725    fn sea_level_viscosity_is_1_7894e_5() {
726        let mu = Ussa76::standard()
727            .sample(0.0)
728            .unwrap()
729            .air
730            .dynamic_viscosity_pa_s;
731        assert!((mu - 1.7894e-5).abs() <= 0.5e-9, "{mu}");
732        let with_110 = SUTHERLAND_BETA * 288.15_f64.powf(1.5) / (288.15 + 110.0);
733        assert!((with_110 - 1.7912e-5).abs() < 1e-9);
734    }
735
736    #[derive(Deserialize)]
737    struct ConstantsFixture {
738        constants: Constants,
739        layers: Layers,
740        table_8: Table8,
741    }
742
743    #[derive(Deserialize)]
744    struct Constants {
745        gas_constant_jpkmolk: Value,
746        sea_level_molecular_weight_kgpkmol: Value,
747        g0_mps2: Value,
748        earth_radius_m: Value,
749        sea_level_pressure_pa: Value,
750        sea_level_temperature_k: Value,
751        sutherland_beta_kgpsmk12: Value,
752        sutherland_constant_k: Value,
753        ratio_of_specific_heats: Value,
754        kinetic_temperature_86_km_k: Value,
755    }
756
757    #[derive(Deserialize)]
758    struct Value {
759        value: f64,
760    }
761
762    #[derive(Deserialize)]
763    struct Layers {
764        rows: Vec<LayerRow>,
765    }
766
767    #[derive(Deserialize)]
768    struct LayerRow {
769        base_geopotential_km: f64,
770        gradient_kpkm: Option<f64>,
771    }
772
773    #[derive(Deserialize)]
774    struct Table8 {
775        by_geometric: Vec<Table8Row>,
776    }
777
778    #[derive(Deserialize)]
779    struct Table8Row {
780        geometric_altitude_m: f64,
781        m_over_m0: f64,
782    }
783
784    /// The constants, Table 4 and Table 8 in the code are the transcribed ones.
785    #[test]
786    fn constants_match_the_transcription() {
787        let fixture: ConstantsFixture = serde_json::from_str(include_str!(
788            "../../../validation/fixtures/atmosphere/ussa76-constants.json"
789        ))
790        .unwrap();
791        let c = fixture.constants;
792        assert_eq!(GAS_CONSTANT_J_PER_KMOL_K, c.gas_constant_jpkmolk.value);
793        assert_eq!(
794            SEA_LEVEL_MOLECULAR_WEIGHT_KG_PER_KMOL,
795            c.sea_level_molecular_weight_kgpkmol.value
796        );
797        assert_eq!(STANDARD_GRAVITY_MPS2, c.g0_mps2.value);
798        assert_eq!(EARTH_RADIUS_M, c.earth_radius_m.value);
799        assert_eq!(SEA_LEVEL_PRESSURE_PA, c.sea_level_pressure_pa.value);
800        assert_eq!(SEA_LEVEL_TEMPERATURE_K, c.sea_level_temperature_k.value);
801        assert_eq!(SUTHERLAND_BETA, c.sutherland_beta_kgpsmk12.value);
802        assert_eq!(SUTHERLAND_S_K, c.sutherland_constant_k.value);
803        assert_eq!(RATIO_OF_SPECIFIC_HEATS, c.ratio_of_specific_heats.value);
804
805        let rows = fixture.layers.rows;
806        assert_eq!(rows.len(), LAYERS.len() + 1);
807        for (row, &(base, lapse)) in rows.iter().zip(&LAYERS) {
808            assert!((row.base_geopotential_km * 1000.0 - base).abs() < 1e-9);
809            assert!((row.gradient_kpkm.unwrap() / 1000.0 - lapse).abs() < 1e-15);
810        }
811        assert!((rows[7].base_geopotential_km - 84.852).abs() < 1e-12);
812
813        let table_8 = fixture.table_8.by_geometric;
814        assert_eq!(table_8.len(), MOLECULAR_WEIGHT_RATIO.len());
815        for (i, row) in table_8.iter().enumerate() {
816            let z = MOLECULAR_WEIGHT_TABLE_START_M + MOLECULAR_WEIGHT_TABLE_STEP_M * i as f64;
817            assert_eq!(row.geometric_altitude_m, z);
818            assert_eq!(row.m_over_m0, MOLECULAR_WEIGHT_RATIO[i]);
819            assert_eq!(molecular_weight_ratio(z), row.m_over_m0);
820        }
821
822        // Eq. 25: the kinetic temperature at 86 km is 186.8673 K, to its last printed digit. (The
823        // standard evaluates it at the rounded H₇ = 84.852 km′ with M₇/M₀ = 0.9995788; this model
824        // uses the exact H(86 km) and Table 8's 0.999579, and lands 8e-5 K away.)
825        let top = Ussa76::standard().sample(MAX_HEIGHT_M).unwrap().air;
826        assert!((top.temperature_k - c.kinetic_temperature_86_km_k.value).abs() <= 1e-4);
827    }
828
829    /// Hydrostatic balance `dP/dZ = −ρ g(Z)` with `g = g₀ (r₀/(r₀ + Z))²` (eq. 17), by central
830    /// differences, for the standard and offset atmospheres alike.
831    fn assert_hydrostatic(model: &Ussa76, z: f64) {
832        let dz = 0.5;
833        let above = model.sample(z + dz).unwrap().air.pressure_pa;
834        let below = model.sample(z - dz).unwrap().air.pressure_pa;
835        let air = model.sample(z).unwrap().air;
836        let g = STANDARD_GRAVITY_MPS2 * (EARTH_RADIUS_M / (EARTH_RADIUS_M + z)).powi(2);
837        let gradient = (above - below) / (2.0 * dz);
838        let expected = -air.density_kg_m3 * g;
839        assert!(
840            ((gradient - expected) / expected).abs() < 1e-6,
841            "z = {z}: {gradient} vs {expected}"
842        );
843    }
844
845    #[test]
846    fn standard_is_hydrostatic_in_every_layer() {
847        let model = Ussa76::standard();
848        for z in [
849            -4_000.0, 1_000.0, 15_000.0, 25_000.0, 40_000.0, 49_000.0, 60_000.0, 78_000.0,
850        ] {
851            assert_hydrostatic(&model, z);
852        }
853    }
854
855    /// The pressure altitude of each printed pressure is the printed geopotential altitude, to
856    /// what the prints resolve: half a count of `H`, plus half a count of `P` carried through
857    /// `dH/dP = −T_M / (P g₀′ M₀ / R*)`.
858    #[test]
859    fn pressure_altitude_inverts_the_1976_tables() {
860        let model = Ussa76::standard();
861        for row in table().rows {
862            let p = &row.printed;
863            let (pressure_mb, pressure_count_mb) = parse_printed(&p.pressure_mb);
864            let (h, h_count) = parse_printed(&p.geopotential_altitude_m);
865            let (t_m, _) = parse_printed(&p.molecular_scale_temperature_k);
866            let pressure_pa = 100.0 * pressure_mb;
867            let resolved = 0.5 * h_count
868                + 0.5 * 100.0 * pressure_count_mb * t_m / (pressure_pa * HYDROSTATIC_K_PER_M);
869            let computed = model.pressure_altitude_m(pressure_pa).unwrap();
870            assert!(
871                (computed - h).abs() <= resolved,
872                "{} m: {computed} m′ against {h} m′ printed, {resolved} m′ resolved",
873                row.geometric_altitude_m
874            );
875        }
876    }
877
878    /// The altimeter formula: in the standard's troposphere the inversion is
879    /// `H = (T₀ / L)[1 − (P / P₀)^(R* L / (g₀′ M₀))]`, 44330.8 m and 0.190263 to six figures.
880    #[test]
881    fn pressure_altitude_is_the_altimeter_formula_in_the_troposphere() {
882        let model = Ussa76::standard();
883        assert_eq!(
884            model.pressure_altitude_m(SEA_LEVEL_PRESSURE_PA).unwrap(),
885            0.0
886        );
887        let exponent = 0.0065 / HYDROSTATIC_K_PER_M;
888        assert!((exponent - 0.190_263).abs() < 5e-7, "{exponent}");
889        assert!((SEA_LEVEL_TEMPERATURE_K / 0.0065 - 44_330.8).abs() < 0.05);
890        for pressure_pa in [100_000.0, 86_444.0, 60_000.0, 30_000.0] {
891            let formula = 44_330.769 * (1.0 - (pressure_pa / SEA_LEVEL_PRESSURE_PA).powf(exponent));
892            let computed = model.pressure_altitude_m(pressure_pa).unwrap();
893            assert!(
894                (computed - formula).abs() < 1e-3,
895                "{pressure_pa} Pa: {computed} vs {formula}"
896            );
897        }
898        // The worked example of `docs/physics/atmosphere.md`: a pad at 86000 Pa and a reading at
899        // 58000 Pa, then the same two pressures on a day 20 K warmer with P₀ unchanged.
900        let pad = model.pressure_altitude_m(86_000.0).unwrap();
901        let top = model.pressure_altitude_m(58_000.0).unwrap();
902        assert_eq!(
903            format!("{pad:.1} {top:.1} {:.1}", top - pad),
904            "1361.8 4464.4 3102.6"
905        );
906        let warm = Ussa76::with_offset(20.0, SEA_LEVEL_PRESSURE_PA).unwrap();
907        let climbed = warm.pressure_altitude_m(58_000.0).unwrap()
908            - warm.pressure_altitude_m(86_000.0).unwrap();
909        assert_eq!(format!("{climbed:.1}"), "3318.0");
910        assert!((climbed / (top - pad) - 308.15 / 288.15).abs() < 1e-12);
911        for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
912            assert!(matches!(
913                model.pressure_altitude_m(bad),
914                Err(AtmosError::Domain {
915                    what: "pressure (Pa)",
916                    ..
917                })
918            ));
919        }
920    }
921
922    proptest! {
923        /// Pressure altitude inverts the pressure at every height, offset or not, extrapolated
924        /// layers included.
925        #[test]
926        fn pressure_altitude_inverts_the_pressure(
927            offset in -60.0..60.0_f64,
928            p0 in 60_000.0..120_000.0_f64,
929            z in -4_900.0..95_000.0_f64,
930        ) {
931            let model = Ussa76::with_offset(offset, p0).unwrap();
932            let h = geopotential_from_geometric_m(z).unwrap();
933            let back = model.pressure_altitude_m(model.pressure_pa(h)).unwrap();
934            prop_assert!((back - h).abs() < 1e-6 * (1.0 + h.abs() * 1e-3), "{h} m′ back as {back}");
935        }
936
937        #[test]
938        fn offset_atmospheres_are_hydrostatic_and_offset(
939            offset in -60.0..60.0_f64,
940            p0 in 60_000.0..120_000.0_f64,
941            z in -4_000.0..79_000.0_f64,
942        ) {
943            let model = Ussa76::with_offset(offset, p0).unwrap();
944            // The central difference spans ±0.5 m; across a layer's base the lapse rate turns, and
945            // a difference over the corner is not the derivative on either side (issue #74).
946            let at_corner = LAYERS[1..].iter().any(|&(base, _)| {
947                (z - geometric_from_geopotential_m(base).unwrap()).abs() < 1.0
948            });
949            if !at_corner {
950                assert_hydrostatic(&model, z);
951            }
952            let standard = Ussa76::standard().sample(z).unwrap().air;
953            let air = model.sample(z).unwrap().air;
954            prop_assert!((air.temperature_k - standard.temperature_k - offset).abs() < 1e-9);
955            prop_assert!(air.density_kg_m3 > 0.0 && air.speed_of_sound_m_s > 0.0);
956        }
957
958        #[test]
959        fn anchored_atmosphere_passes_through_its_anchor(
960            z in -500.0..5_000.0_f64,
961            temperature in 230.0..330.0_f64,
962            pressure in 50_000.0..108_000.0_f64,
963        ) {
964            let model = Ussa76::anchored(z, temperature, pressure).unwrap();
965            let air = model.sample(z).unwrap().air;
966            prop_assert!((air.temperature_k - temperature).abs() < 1e-9);
967            prop_assert!(((air.pressure_pa - pressure) / pressure).abs() < 1e-12);
968        }
969
970        #[test]
971        fn layers_join_continuously(offset in -60.0..60.0_f64) {
972            let model = Ussa76::with_offset(offset, SEA_LEVEL_PRESSURE_PA).unwrap();
973            for &(base, _) in &LAYERS[1..] {
974                let z = geometric_from_geopotential_m(base).unwrap();
975                let below = model.sample(z - 1e-6).unwrap().air;
976                let above = model.sample(z + 1e-6).unwrap().air;
977                prop_assert!((below.temperature_k - above.temperature_k).abs() < 1e-7);
978                prop_assert!(((below.pressure_pa - above.pressure_pa) / above.pressure_pa).abs() < 1e-9);
979            }
980        }
981
982        #[test]
983        fn geopotential_round_trips(z in -100_000.0..1.0e7_f64) {
984            let h = geopotential_from_geometric_m(z).unwrap();
985            let back = geometric_from_geopotential_m(h).unwrap();
986            prop_assert!((back - z).abs() <= 1e-9 * z.abs().max(1.0));
987        }
988    }
989
990    #[test]
991    fn standard_offset_is_the_standard() {
992        assert_eq!(
993            Ussa76::with_offset(0.0, SEA_LEVEL_PRESSURE_PA).unwrap(),
994            Ussa76::standard()
995        );
996        assert_eq!(Ussa76::default(), Ussa76::standard());
997        // A hot day: +20 K at a 1400 m field at 85 kPa.
998        let hot = Ussa76::anchored(1400.0, 288.15 - 6.5 * 1.4 + 20.0, 85_000.0).unwrap();
999        assert!((hot.temperature_offset_k() - 20.0).abs() < 0.01);
1000        assert!(hot.sea_level_pressure_pa() > 95_000.0 && hot.sea_level_pressure_pa() < 105_000.0);
1001    }
1002
1003    #[test]
1004    fn extrapolation_is_flagged_and_finite() {
1005        let model = Ussa76::standard();
1006        assert_eq!(model.sample(MIN_HEIGHT_M).unwrap().extrapolated, None);
1007        assert_eq!(model.sample(MAX_HEIGHT_M).unwrap().extrapolated, None);
1008        let low = model.sample(-6_000.0).unwrap();
1009        assert_eq!(low.extrapolated, Some(Side::Below));
1010        assert!(low.air.temperature_k > 320.65 && low.air.pressure_pa > 1.9e5);
1011        let high = model.sample(120_000.0).unwrap();
1012        assert_eq!(high.extrapolated, Some(Side::Above));
1013        assert!(high.air.pressure_pa > 0.0 && high.air.pressure_pa < 0.02);
1014        assert!((high.air.temperature_k - 186.8673).abs() <= 1e-4);
1015        // Far away, still finite and non-negative.
1016        let far = model.sample(1.0e9).unwrap().air;
1017        assert!(far.pressure_pa >= 0.0 && far.density_kg_m3 >= 0.0);
1018        assert!(model.sample(f64::NAN).is_err());
1019        assert!(model.sample(-EARTH_RADIUS_M).is_err());
1020        assert!(geometric_from_geopotential_m(EARTH_RADIUS_M).is_err());
1021    }
1022
1023    #[test]
1024    fn invalid_offsets_are_rejected_and_serde_round_trips() {
1025        assert!(Ussa76::with_offset(-190.0, SEA_LEVEL_PRESSURE_PA).is_err());
1026        assert!(Ussa76::with_offset(f64::NAN, SEA_LEVEL_PRESSURE_PA).is_err());
1027        assert!(Ussa76::with_offset(10.0, 0.0).is_err());
1028        assert!(Ussa76::anchored(0.0, -1.0, 100_000.0).is_err());
1029        assert!(Ussa76::anchored(0.0, 288.0, f64::INFINITY).is_err());
1030        let model = Ussa76::with_offset(12.5, 98_000.0).unwrap();
1031        let json = serde_json::to_string(&model).unwrap();
1032        assert_eq!(
1033            json,
1034            r#"{"temperature_offset_k":12.5,"sea_level_pressure_pa":98000.0}"#
1035        );
1036        assert_eq!(serde_json::from_str::<Ussa76>(&json).unwrap(), model);
1037        let bad = r#"{"temperature_offset_k":-500.0,"sea_level_pressure_pa":98000.0}"#;
1038        assert!(serde_json::from_str::<Ussa76>(bad).is_err());
1039    }
1040}