Skip to main content

hpr_atmos/
profile.rs

1//! Custom atmospheres from soundings or forecasts: temperature, pressure, humidity and wind at
2//! levels, interpolated in height, with the offset standard atmosphere beyond them.
3//!
4//! **Geopotential height.** A profile knows its latitude `φ`, and works in WMO geopotential height
5//! `Z(z, φ)` ([`wmo_geopotential_from_geometric_m`]), whose gravity is the normal gravity at that
6//! latitude. Pressure then falls hydrostatically with the local gravity, which is up to 0.27%
7//! from the standard's `g₀` at the equator and poles.
8//!
9//! **Between two levels** `i` and `i + 1`, interpolation runs in `Z`, at fraction
10//! `t = (Z − Z_i)/(Z_{i+1} − Z_i)`:
11//!
12//! ```text
13//! T = T_i + t (T_{i+1} − T_i)
14//! U = U_i + t (U_{i+1} − U_i)                                      relative humidity
15//! ln P = ln P_i + ln(P_{i+1}/P_i) · ln(T/T_i) / ln(T_{i+1}/T_i)
16//! ```
17//!
18//! The pressure form is exact for a dry hydrostatic layer whose temperature is linear in
19//! geopotential height, as every layer of the standard atmosphere is, and it passes through both
20//! levels' pressures whatever the data. When the temperatures are equal it is log-linear.
21//!
22//! **Missing pressures** above the lowest level are filled in hydrostatically from the level
23//! below, with virtual temperature linear in geopotential height (the hypsometric equation, as in
24//! WMO-No. 8 (2023), Vol. I, eqs. 12.17 and 12.18):
25//!
26//! ```text
27//! ln(P_{i+1}/P_i) = −(g₀/R_d) (Z_{i+1} − Z_i) · ln(T_v,i+1/T_v,i) / (T_v,i+1 − T_v,i)
28//! ```
29//!
30//! **Beyond the levels** the profile continues as the 1976 standard atmosphere, offset to pass
31//! through the end level's temperature and pressure ([`Ussa76::anchored`]) and evaluated at the
32//! same geopotential height (the standard's geopotential `H` set equal to `Z`), and flags the
33//! sample.
34//! Below the lowest level the lowest level's relative humidity is held. Above the highest, its
35//! vapour mole fraction is held, capped at saturation, so a humid top does not put water into the
36//! cold stratosphere. The continued pressure is the dry standard's, so in humid air it falls up to
37//! `x_v (1 − M_v/M₀)` (about 1.6% at 30 °C and saturation) faster than hydrostatic balance; the
38//! samples are flagged.
39//!
40//! Heights are geometric above mean sea level. Soundings and forecasts usually report WMO
41//! geopotential height; convert it with [`geometric_from_wmo_geopotential_m`].
42
43use hpr_core::interp::Side;
44use serde::{Deserialize, Serialize};
45
46use crate::air::{AirSample, Atmosphere};
47use crate::error::{AtmosError, finite, positive};
48use crate::moist::{check_relative_humidity, moist_air_unchecked, saturation_vapour_pressure_pa};
49use crate::ussa76::{DRY_AIR_GAS_CONSTANT_J_PER_KG_K, Ussa76, geometric_from_geopotential_m};
50use crate::wind::{LayeredWind, WindInterpolation, WindLevel};
51use hpr_core::gravity::STANDARD_GRAVITY_MPS2;
52
53/// Iterations of the hydrostatic fill with humidity: the virtual temperature depends on the
54/// pressure being solved for only through `e/p`, a few percent at most, so each iteration gains
55/// about two digits.
56const FILL_ITERATIONS: usize = 8;
57
58/// One level of a sounding or forecast profile.
59#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct SoundingLevel {
62    /// Geometric height above mean sea level, m.
63    pub height_msl_m: f64,
64    /// Temperature, K.
65    pub temperature_k: f64,
66    /// Pressure, Pa. Required on the lowest level; filled in hydrostatically where omitted above.
67    #[serde(default)]
68    pub pressure_pa: Option<f64>,
69    /// Relative humidity with respect to liquid water, as a fraction in `[0, 1]`. Give it on every
70    /// level or on none; without it the air is dry.
71    #[serde(default)]
72    pub relative_humidity: Option<f64>,
73    /// Wind speed, m/s. Give it with a direction, on every level or on none.
74    #[serde(default)]
75    pub wind_speed_m_s: Option<f64>,
76    /// Direction the wind blows from, clockwise from true north, rad.
77    #[serde(default)]
78    pub wind_direction_from_rad: Option<f64>,
79}
80
81/// An atmosphere built from a sounding or a forecast profile.
82///
83/// It serializes as its levels as given, its latitude and its wind interpolation, and rebuilds
84/// (and re-checks) everything else when deserialized.
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86#[serde(try_from = "SoundingProfileData", into = "SoundingProfileData")]
87pub struct SoundingProfile {
88    levels: Vec<SoundingLevel>,
89    latitude_rad: f64,
90    wind_interpolation: WindInterpolation,
91    /// `γ_s(φ)/γ₄₅` and `R(φ)` of WMO-No. 8 eq. 12.16 at the profile's latitude.
92    gravity_ratio: f64,
93    radius_m: f64,
94    heights_m: Vec<f64>,
95    /// WMO geopotential height of each level, gpm.
96    geopotentials_m: Vec<f64>,
97    temperatures_k: Vec<f64>,
98    pressures_pa: Vec<f64>,
99    /// Relative humidity per level; empty for dry air.
100    humidities: Vec<f64>,
101    below: Ussa76,
102    above: Ussa76,
103    /// Vapour mole fraction at the highest level.
104    top_vapour_fraction: f64,
105    wind: Option<LayeredWind>,
106}
107
108#[derive(Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110struct SoundingProfileData {
111    levels: Vec<SoundingLevel>,
112    latitude_rad: f64,
113    #[serde(default)]
114    wind_interpolation: WindInterpolation,
115    /// The program that wrote the file, which `hpr weather --output` names beside the profile
116    /// (ADR-174): read past, as it says nothing about the air.
117    #[serde(default, skip_serializing)]
118    #[expect(
119        dead_code,
120        reason = "read only so that the key is not refused as unknown"
121    )]
122    tool: Option<serde::de::IgnoredAny>,
123}
124
125impl TryFrom<SoundingProfileData> for SoundingProfile {
126    type Error = AtmosError;
127
128    fn try_from(data: SoundingProfileData) -> Result<Self, AtmosError> {
129        SoundingProfile::new(data.levels, data.latitude_rad, data.wind_interpolation)
130    }
131}
132
133impl From<SoundingProfile> for SoundingProfileData {
134    fn from(profile: SoundingProfile) -> Self {
135        SoundingProfileData {
136            levels: profile.levels,
137            latitude_rad: profile.latitude_rad,
138            wind_interpolation: profile.wind_interpolation,
139            tool: None,
140        }
141    }
142}
143
144/// Vapour pressure (Pa) for relative humidity `u` (none for dry air) at temperature `t`.
145fn vapour(humidity: Option<f64>, t: f64) -> f64 {
146    humidity.map_or(0.0, |u| u * saturation_vapour_pressure_pa(t))
147}
148
149/// Virtual temperature `T / (1 − x_v (1 − M_v/M₀))` from the moist-air state's density.
150fn virtual_temperature(t: f64, p: f64, e: f64) -> f64 {
151    let air = moist_air_unchecked(t, p, e);
152    p / (DRY_AIR_GAS_CONSTANT_J_PER_KG_K * air.density_kg_m3)
153}
154
155/// `ln(b/a)/(b − a)`, the reciprocal log-mean of two positive numbers, without cancellation when
156/// they are close.
157fn inverse_log_mean(a: f64, b: f64) -> f64 {
158    let r = (b - a) / a;
159    if r == 0.0 {
160        1.0 / a
161    } else {
162        r.ln_1p() / (r * a)
163    }
164}
165
166impl SoundingProfile {
167    /// A profile from `levels`, lowest first, measured at geodetic latitude `latitude_rad`, whose
168    /// wind (if the levels give one) interpolates as `wind_interpolation`.
169    ///
170    /// # Errors
171    ///
172    /// - [`AtmosError::NoLevels`] with no levels.
173    /// - [`AtmosError::HeightsNotIncreasing`] unless heights strictly increase.
174    /// - [`AtmosError::MissingBasePressure`] if the lowest level has no pressure.
175    /// - [`AtmosError::PressureNotDecreasing`] if a given pressure is not below the pressure of
176    ///   the level beneath it.
177    /// - [`AtmosError::IncompleteColumn`] if humidity or wind is given on some levels but not all,
178    ///   or a wind speed comes without its direction.
179    /// - [`AtmosError::Domain`] for a non-positive temperature or pressure, a relative humidity
180    ///   outside `[0, 1]`, a negative wind speed, a latitude beyond ±90°, a non-finite value, or
181    ///   end levels whose offset standard atmosphere is out of range.
182    pub fn new(
183        levels: Vec<SoundingLevel>,
184        latitude_rad: f64,
185        wind_interpolation: WindInterpolation,
186    ) -> Result<Self, AtmosError> {
187        let (gamma_s, radius_m) = wmo_gravity_and_radius(latitude_rad)?;
188        let gravity_ratio = gamma_s / GAMMA_45_MPS2;
189        let Some(first) = levels.first() else {
190            return Err(AtmosError::NoLevels);
191        };
192        let has_humidity = first.relative_humidity.is_some();
193        let has_wind = first.wind_speed_m_s.is_some() || first.wind_direction_from_rad.is_some();
194        if first.pressure_pa.is_none() {
195            return Err(AtmosError::MissingBasePressure);
196        }
197
198        let n = levels.len();
199        let mut heights_m = Vec::with_capacity(n);
200        let mut geopotentials_m = Vec::with_capacity(n);
201        let mut temperatures_k = Vec::with_capacity(n);
202        let mut humidities = Vec::with_capacity(if has_humidity { n } else { 0 });
203        let mut wind_levels = Vec::with_capacity(if has_wind { n } else { 0 });
204        for (index, level) in levels.iter().enumerate() {
205            let z = finite("sounding level height (m)", level.height_msl_m)?;
206            if heights_m.last().is_some_and(|&previous| z <= previous) {
207                return Err(AtmosError::HeightsNotIncreasing { index });
208            }
209            heights_m.push(z);
210            geopotentials_m.push(wmo_geopotential(gravity_ratio, radius_m, z)?);
211            temperatures_k.push(positive("sounding temperature (K)", level.temperature_k)?);
212            if let Some(p) = level.pressure_pa {
213                positive("sounding pressure (Pa)", p)?;
214            }
215            match (has_humidity, level.relative_humidity) {
216                (true, Some(u)) => humidities.push(check_relative_humidity(u)?),
217                (false, None) => {}
218                _ => {
219                    return Err(AtmosError::IncompleteColumn {
220                        index,
221                        column: "relative humidity",
222                    });
223                }
224            }
225            match (
226                has_wind,
227                level.wind_speed_m_s,
228                level.wind_direction_from_rad,
229            ) {
230                (true, Some(speed), Some(direction)) => wind_levels.push(WindLevel {
231                    height_msl_m: z,
232                    speed_m_s: speed,
233                    direction_from_rad: direction,
234                }),
235                (false, None, None) => {}
236                _ => {
237                    return Err(AtmosError::IncompleteColumn {
238                        index,
239                        column: "wind speed and direction",
240                    });
241                }
242            }
243        }
244
245        let humidity_at = |i: usize| humidities.get(i).copied();
246        let mut pressures_pa: Vec<f64> = Vec::with_capacity(n);
247        for (i, level) in levels.iter().enumerate() {
248            let pressure = match (level.pressure_pa, pressures_pa.last()) {
249                (Some(p), Some(&below)) if p >= below => {
250                    return Err(AtmosError::PressureNotDecreasing {
251                        index: i,
252                        pressure_pa: p,
253                        below_pa: below,
254                    });
255                }
256                (Some(p), _) => p,
257                (None, Some(&below)) => {
258                    let (t0, t1) = (temperatures_k[i - 1], temperatures_k[i]);
259                    let dh = geopotentials_m[i] - geopotentials_m[i - 1];
260                    let tv0 = virtual_temperature(t0, below, vapour(humidity_at(i - 1), t0));
261                    let k = STANDARD_GRAVITY_MPS2 / DRY_AIR_GAS_CONSTANT_J_PER_KG_K * dh;
262                    let mut p = below * (-k * inverse_log_mean(t0, t1)).exp();
263                    if has_humidity {
264                        for _ in 0..FILL_ITERATIONS {
265                            let tv1 = virtual_temperature(t1, p, vapour(humidity_at(i), t1));
266                            p = below * (-k * inverse_log_mean(tv0, tv1)).exp();
267                        }
268                    }
269                    positive("filled sounding pressure (Pa)", p)?
270                }
271                // The first level's pressure was checked above.
272                (None, None) => return Err(AtmosError::MissingBasePressure),
273            };
274            pressures_pa.push(pressure);
275        }
276
277        let last = n - 1;
278        let below = Ussa76::anchored(
279            geometric_from_geopotential_m(geopotentials_m[0])?,
280            temperatures_k[0],
281            pressures_pa[0],
282        )?;
283        let above = Ussa76::anchored(
284            geometric_from_geopotential_m(geopotentials_m[last])?,
285            temperatures_k[last],
286            pressures_pa[last],
287        )?;
288        let top_vapour_fraction =
289            (vapour(humidity_at(last), temperatures_k[last]) / pressures_pa[last]).min(1.0);
290        let wind = if has_wind {
291            Some(LayeredWind::new(wind_levels, wind_interpolation)?)
292        } else {
293            None
294        };
295        Ok(SoundingProfile {
296            levels,
297            latitude_rad,
298            wind_interpolation,
299            gravity_ratio,
300            radius_m,
301            heights_m,
302            geopotentials_m,
303            temperatures_k,
304            pressures_pa,
305            humidities,
306            below,
307            above,
308            top_vapour_fraction,
309            wind,
310        })
311    }
312
313    /// The levels as given.
314    pub fn levels(&self) -> &[SoundingLevel] {
315        &self.levels
316    }
317
318    /// The geodetic latitude the profile was measured at, rad.
319    pub fn latitude_rad(&self) -> f64 {
320        self.latitude_rad
321    }
322
323    /// The pressure at each level, Pa, with omitted ones filled in.
324    pub fn pressures_pa(&self) -> &[f64] {
325        &self.pressures_pa
326    }
327
328    /// The profile's wind, if its levels give one.
329    pub fn wind(&self) -> Option<&LayeredWind> {
330        self.wind.as_ref()
331    }
332
333    /// The air at geometric height `height_msl_m` (see [`Atmosphere::air`]).
334    ///
335    /// # Errors
336    ///
337    /// [`AtmosError::Domain`] if the height is not finite, is at or below `−R(φ)`, or is so far
338    /// above the Earth that the standard atmosphere has no height for its geopotential.
339    pub fn sample(&self, height_msl_m: f64) -> Result<AirSample, AtmosError> {
340        let z = finite("height (m)", height_msl_m)?;
341        let n = self.heights_m.len();
342        let humidity = |i: usize| self.humidities.get(i).copied();
343        let geopotential = wmo_geopotential(self.gravity_ratio, self.radius_m, z)?;
344        if z < self.heights_m[0] {
345            let air = self
346                .below
347                .sample(geometric_from_geopotential_m(geopotential)?)?
348                .air;
349            let e = vapour(humidity(0), air.temperature_k);
350            return Ok(AirSample {
351                air: moist_air_unchecked(air.temperature_k, air.pressure_pa, e),
352                extrapolated: Some(Side::Below),
353            });
354        }
355        if z > self.heights_m[n - 1] {
356            let air = self
357                .above
358                .sample(geometric_from_geopotential_m(geopotential)?)?
359                .air;
360            let e = (self.top_vapour_fraction * air.pressure_pa)
361                .min(saturation_vapour_pressure_pa(air.temperature_k));
362            return Ok(AirSample {
363                air: moist_air_unchecked(air.temperature_k, air.pressure_pa, e),
364                extrapolated: Some(Side::Above),
365            });
366        }
367        // First level strictly above z, in 1..=n.
368        let upper = self.heights_m.partition_point(|&h| h <= z);
369        if upper >= n {
370            let t = self.temperatures_k[n - 1];
371            let e = vapour(humidity(n - 1), t);
372            return Ok(AirSample {
373                air: moist_air_unchecked(t, self.pressures_pa[n - 1], e),
374                extrapolated: None,
375            });
376        }
377        let i = upper - 1;
378        let (z0, z1) = (self.geopotentials_m[i], self.geopotentials_m[upper]);
379        let fraction = ((geopotential - z0) / (z1 - z0)).clamp(0.0, 1.0);
380        let (t0, t1) = (self.temperatures_k[i], self.temperatures_k[upper]);
381        let t = t0 + fraction * (t1 - t0);
382        let (p0, p1) = (self.pressures_pa[i], self.pressures_pa[upper]);
383        let r = (t1 - t0) / t0;
384        let shape = if r == 0.0 {
385            fraction
386        } else {
387            (fraction * r).ln_1p() / r.ln_1p()
388        };
389        let p = p0 * ((p1 / p0).ln() * shape).exp();
390        let e = match (humidity(i), humidity(upper)) {
391            (Some(u0), Some(u1)) => (u0 + fraction * (u1 - u0)) * saturation_vapour_pressure_pa(t),
392            _ => 0.0,
393        };
394        Ok(AirSample {
395            air: moist_air_unchecked(t, p, e),
396            extrapolated: None,
397        })
398    }
399}
400
401impl Atmosphere for SoundingProfile {
402    fn air(&self, height_msl_m: f64) -> Result<AirSample, AtmosError> {
403        self.sample(height_msl_m)
404    }
405}
406
407/// `γ₄₅ = 9.80665 m/s²`, the gravity that defines the geopotential meter.
408const GAMMA_45_MPS2: f64 = STANDARD_GRAVITY_MPS2;
409
410/// Eq. 12.15 with `γ_s(φ)/γ₄₅` and `R(φ)` already evaluated.
411fn wmo_geopotential(gravity_ratio: f64, radius_m: f64, height_m: f64) -> Result<f64, AtmosError> {
412    if height_m <= -radius_m {
413        return Err(AtmosError::Domain {
414            what: "geometric height (m)",
415            value: height_m,
416        });
417    }
418    Ok(gravity_ratio * radius_m * height_m / (radius_m + height_m))
419}
420
421/// `γ_s(φ)` and `R(φ)` of WMO-No. 8 (2023), Vol. I, eq. 12.16.
422fn wmo_gravity_and_radius(latitude_rad: f64) -> Result<(f64, f64), AtmosError> {
423    let phi = finite("latitude (rad)", latitude_rad)?;
424    if phi.abs() > std::f64::consts::FRAC_PI_2 {
425        return Err(AtmosError::Domain {
426            what: "latitude (rad)",
427            value: phi,
428        });
429    }
430    let s2 = phi.sin().powi(2);
431    let gamma_s = 9.780_325 * (1.0 + 0.001_931_85 * s2) / (1.0 - 0.006_694_35 * s2).sqrt();
432    let radius_m = 6_378_137.0 / (1.006_803 - 0.006_706 * s2);
433    Ok((gamma_s, radius_m))
434}
435
436/// WMO geopotential height `Z` (gpm) of geometric height `height_msl_m` at geodetic latitude
437/// `latitude_rad`, by WMO-No. 8 (2023), Vol. I, eqs. 12.15 and 12.16 (after Mahoney):
438///
439/// ```text
440/// Z = (γ_s(φ)/γ₄₅) · R(φ) z / (R(φ) + z)
441/// γ_s(φ) = 9.780325 (1 + 0.00193185 sin²φ) / (1 − 0.00669435 sin²φ)^½
442/// R(φ) = 6378.137 km / (1.006803 − 0.006706 sin²φ)
443/// ```
444///
445/// WMO puts 30 km at 29.7785 km of geopotential at the equator and 29.932 km at 80° N.
446///
447/// # Errors
448///
449/// [`AtmosError::Domain`] if a value is not finite, the latitude is beyond ±90°, or the height
450/// is at or below `−R(φ)`.
451pub fn wmo_geopotential_from_geometric_m(
452    height_msl_m: f64,
453    latitude_rad: f64,
454) -> Result<f64, AtmosError> {
455    let (gamma_s, radius) = wmo_gravity_and_radius(latitude_rad)?;
456    let z = finite("geometric height (m)", height_msl_m)?;
457    wmo_geopotential(gamma_s / GAMMA_45_MPS2, radius, z)
458}
459
460/// Geometric height above mean sea level of WMO geopotential height `geopotential_height_m`
461/// (gpm) at latitude `latitude_rad`: the inverse of [`wmo_geopotential_from_geometric_m`],
462/// `z = R Z′ / (R − Z′)` with `Z′ = Z γ₄₅/γ_s(φ)`.
463///
464/// # Errors
465///
466/// [`AtmosError::Domain`] if a value is not finite, the latitude is beyond ±90°, or `Z′` is at
467/// or above `R(φ)`.
468pub fn geometric_from_wmo_geopotential_m(
469    geopotential_height_m: f64,
470    latitude_rad: f64,
471) -> Result<f64, AtmosError> {
472    let (gamma_s, radius) = wmo_gravity_and_radius(latitude_rad)?;
473    let scaled =
474        finite("geopotential height (gpm)", geopotential_height_m)? * GAMMA_45_MPS2 / gamma_s;
475    if scaled >= radius {
476        return Err(AtmosError::Domain {
477            what: "geopotential height (gpm)",
478            value: geopotential_height_m,
479        });
480    }
481    Ok(radius * scaled / (radius - scaled))
482}
483
484#[cfg(test)]
485mod tests {
486    use hpr_core::gravity::NormalGravity;
487
488    use super::*;
489    use crate::moist::moist_air;
490    use crate::wind::Wind;
491
492    /// Spaceport America's latitude, for tests that don't care which latitude.
493    const LATITUDE_RAD: f64 = 32.99 * std::f64::consts::PI / 180.0;
494
495    fn level(z: f64, t: f64, p: Option<f64>, u: Option<f64>) -> SoundingLevel {
496        SoundingLevel {
497            height_msl_m: z,
498            temperature_k: t,
499            pressure_pa: p,
500            relative_humidity: u,
501            wind_speed_m_s: None,
502            wind_direction_from_rad: None,
503        }
504    }
505
506    fn profile_at(levels: Vec<SoundingLevel>, latitude_rad: f64) -> SoundingProfile {
507        SoundingProfile::new(levels, latitude_rad, WindInterpolation::default()).unwrap()
508    }
509
510    /// The standard atmosphere's geometric height at the WMO geopotential of `z`: where the
511    /// standard's air is the air a profile at `latitude_rad` continues with.
512    fn standard_height(z: f64, latitude_rad: f64) -> f64 {
513        geometric_from_geopotential_m(wmo_geopotential_from_geometric_m(z, latitude_rad).unwrap())
514            .unwrap()
515    }
516
517    /// `P(z₁)` by integrating `dP/dz = −ρ g` from `(z₀, P₀)` with the profile's own interpolated
518    /// temperature and humidity, by the second-order midpoint method on 20 000 steps. Gravity is
519    /// WGS 84 normal gravity at the profile's latitude (`hpr_core`'s Taylor form, independent of
520    /// the WMO geopotential formula), and density is evaluated at the running pressure, so the
521    /// integral uses neither the profile's geopotential nor its interpolated pressure.
522    fn integrate_pressure(profile: &SoundingProfile, z0: f64, p0: f64, z1: f64) -> f64 {
523        let gravity = NormalGravity::wgs84();
524        let steps = 20_000;
525        let dz = (z1 - z0) / f64::from(steps);
526        let vapour_pressure = |z: f64| {
527            // e from the profile's air: ρ R_d T / p = 1 − x_v (1 − M_v/M₀).
528            let air = profile.sample(z).unwrap().air;
529            let deficit = 1.0
530                - air.density_kg_m3 * DRY_AIR_GAS_CONSTANT_J_PER_KG_K * air.temperature_k
531                    / air.pressure_pa;
532            let x = deficit
533                / (1.0
534                    - crate::moist::WATER_VAPOUR_MOLECULAR_WEIGHT_KG_PER_KMOL
535                        / crate::ussa76::SEA_LEVEL_MOLECULAR_WEIGHT_KG_PER_KMOL);
536            (air.temperature_k, x * air.pressure_pa)
537        };
538        let weight = |z: f64, p: f64| {
539            let (t, e) = vapour_pressure(z);
540            let g = gravity.taylor_mps2(profile.latitude_rad(), z).unwrap();
541            moist_air_unchecked(t, p, e.max(0.0)).density_kg_m3 * g
542        };
543        let mut p = p0;
544        for k in 0..steps {
545            let z = z0 + f64::from(k) * dz;
546            let half = p - 0.5 * dz * weight(z, p);
547            p -= dz * weight(z + 0.5 * dz, half);
548        }
549        p
550    }
551
552    /// Loft lesson L5: a sounding's measured temperatures (here a hot, dry-adiabatic afternoon
553    /// with an inversion aloft) replace the standard lapse from the field up, humidity lowers the
554    /// density, and the omitted pressures aloft follow hydrostatically from the field pressure.
555    #[test]
556    fn sounding_temperature_overrides_standard_lapse() {
557        let field = level(1400.0, 308.15, Some(85_500.0), Some(0.2));
558        let profile = profile_at(
559            vec![
560                field,
561                level(2400.0, 300.15, None, Some(0.15)),
562                level(3400.0, 301.15, None, Some(0.10)),
563            ],
564            LATITUDE_RAD,
565        );
566        let standard_lapse = Ussa76::anchored(1400.0, 308.15, 85_500.0).unwrap();
567
568        // Temperature follows the sounding: 8 K/km down to 2400 m, then a 1 K inversion. 2900 m is
569        // halfway in geometric height and 4e-5 past halfway in geopotential height.
570        let mid = profile.sample(2900.0).unwrap();
571        assert_eq!(mid.extrapolated, None);
572        let h = |z| wmo_geopotential_from_geometric_m(z, LATITUDE_RAD).unwrap();
573        let fraction = (h(2900.0) - h(2400.0)) / (h(3400.0) - h(2400.0));
574        assert!((fraction - 0.5 - 3.9e-5).abs() < 1e-6);
575        assert!((mid.air.temperature_k - (300.15 + fraction)).abs() < 1e-12);
576        let top = profile.sample(3400.0).unwrap().air;
577        assert!((top.temperature_k - 301.15).abs() < 1e-12);
578        let lapse_top = standard_lapse.sample(3400.0).unwrap().air;
579        assert!((lapse_top.temperature_k - (308.15 - 6.5 * 2.0)).abs() < 0.05);
580        assert!(top.temperature_k - lapse_top.temperature_k > 5.0);
581
582        // The filled pressures are hydrostatic for the sounding's temperatures and humidity. The
583        // fill takes virtual temperature linear in geopotential height, while the profile
584        // interpolates temperature and relative humidity linearly; vapour pressure is exponential
585        // in temperature, so the two differ by about 0.06 K mid-layer here, and the pressures by
586        // 1.1e-5 (a tenth of a meter of height). Dry air agrees to 2e-8 (next test).
587        for (z, p) in [
588            (2400.0, profile.pressures_pa()[1]),
589            (3400.0, profile.pressures_pa()[2]),
590        ] {
591            let integrated = integrate_pressure(&profile, 1400.0, 85_500.0, z);
592            assert!(
593                ((p - integrated) / integrated).abs() < 2e-5,
594                "{z}: {p} vs {integrated}"
595            );
596        }
597
598        // Humid air is lighter than dry air at the same temperature and pressure.
599        let dry = moist_air(top.temperature_k, top.pressure_pa, 0.0).unwrap();
600        let lighter = 1.0 - top.density_kg_m3 / dry.density_kg_m3;
601        assert!((0.0015..0.0035).contains(&lighter), "{lighter}");
602        // And the sounding's density at 3400 m differs from the standard-lapse guess by over 1%.
603        assert!((top.density_kg_m3 / lapse_top.density_kg_m3 - 1.0).abs() > 0.01);
604    }
605
606    /// Dry, the fill and the interpolation make the same assumption, so they agree with an
607    /// integration under WGS 84 normal gravity at the equator, mid-latitudes and the pole. The
608    /// bound is WMO eq. 12.16's rounded constants: its surface gravity is 3.4e-8 (equator) to
609    /// 5.2e-8 (pole) below WGS 84's, which over `ΔZ/H_s ≈ 0.23` is about 1e-8 in pressure.
610    #[test]
611    fn filled_pressures_are_hydrostatic_for_dry_air() {
612        for latitude_deg in [0.0_f64, 32.99, 90.0] {
613            let profile = profile_at(
614                vec![
615                    level(1400.0, 308.15, Some(85_500.0), None),
616                    level(2400.0, 300.15, None, None),
617                    level(3400.0, 301.15, None, None),
618                ],
619                latitude_deg.to_radians(),
620            );
621            for (z, p) in [
622                (2400.0, profile.pressures_pa()[1]),
623                (3400.0, profile.pressures_pa()[2]),
624            ] {
625                let integrated = integrate_pressure(&profile, 1400.0, 85_500.0, z);
626                assert!(
627                    ((p - integrated) / integrated).abs() < 2e-8,
628                    "{latitude_deg}° {z}: {p} vs {integrated}"
629                );
630            }
631        }
632    }
633
634    /// Gravity is 0.53% stronger at the poles than at the equator, so the same sounding's pressure
635    /// falls faster there. Over 2 km, about 0.23 scale heights at 293 K, that is
636    /// 0.53% × 0.23 = 0.12% lower.
637    #[test]
638    fn pressure_falls_faster_where_gravity_is_stronger() {
639        let levels = vec![
640            level(1400.0, 300.0, Some(85_500.0), None),
641            level(3400.0, 287.0, None, None),
642        ];
643        let equator = profile_at(levels.clone(), 0.0).pressures_pa()[1];
644        let pole = profile_at(levels, std::f64::consts::FRAC_PI_2).pressures_pa()[1];
645        let difference = pole / equator - 1.0;
646        assert!((-0.0014..-0.0011).contains(&difference), "{difference}");
647    }
648
649    /// Levels taken from the standard atmosphere, placed at the WMO heights of its geopotential
650    /// levels, reproduce it between them, because both are linear in geopotential: temperature to
651    /// rounding, and pressure and density to 1e-12.
652    #[test]
653    fn standard_levels_reproduce_the_standard_between_them() {
654        let standard = Ussa76::standard();
655        for latitude_deg in [0.0_f64, 45.0, 80.0] {
656            let latitude = latitude_deg.to_radians();
657            let heights: Vec<f64> = [0.0, 5_000.0, 11_000.0, 15_000.0, 20_000.0, 26_000.0]
658                .iter()
659                .map(|&geopotential| geometric_from_wmo_geopotential_m(geopotential, latitude))
660                .collect::<Result<_, _>>()
661                .unwrap();
662            let levels = heights
663                .iter()
664                .map(|&z| {
665                    let air = standard.sample(standard_height(z, latitude)).unwrap().air;
666                    level(z, air.temperature_k, Some(air.pressure_pa), None)
667                })
668                .collect();
669            let profile = profile_at(levels, latitude);
670            for z in heights
671                .iter()
672                .copied()
673                .chain([2_500.0, 8_000.0, 13_000.0, 17_500.0, 23_000.0])
674            {
675                let ours = profile.sample(z).unwrap().air;
676                let reference = standard.sample(standard_height(z, latitude)).unwrap().air;
677                assert!(
678                    (ours.temperature_k - reference.temperature_k).abs() < 1e-10,
679                    "{latitude_deg}° {z}"
680                );
681                for (a, b) in [
682                    (ours.pressure_pa, reference.pressure_pa),
683                    (ours.density_kg_m3, reference.density_kg_m3),
684                ] {
685                    assert!(
686                        ((a - b) / b).abs() < 1e-12,
687                        "{latitude_deg}° {z}: {a} vs {b}"
688                    );
689                }
690            }
691        }
692    }
693
694    /// A dry fill from sea level to the tropopause base (11 km of geopotential) gives the
695    /// standard's base pressure.
696    #[test]
697    fn hydrostatic_fill_matches_the_standard() {
698        let tropopause = geometric_from_wmo_geopotential_m(11_000.0, LATITUDE_RAD).unwrap();
699        let profile = profile_at(
700            vec![
701                level(0.0, 288.15, Some(101_325.0), None),
702                level(tropopause, 216.65, None, None),
703            ],
704            LATITUDE_RAD,
705        );
706        let p = profile.pressures_pa()[1];
707        let reference = Ussa76::standard()
708            .sample(geometric_from_geopotential_m(11_000.0).unwrap())
709            .unwrap()
710            .air
711            .pressure_pa;
712        assert!(
713            ((p - reference) / reference).abs() < 1e-12,
714            "{p} vs {reference}"
715        );
716    }
717
718    #[test]
719    fn beyond_the_levels_follows_the_offset_standard() {
720        let profile = profile_at(
721            vec![
722                level(500.0, 295.0, Some(95_000.0), Some(0.6)),
723                level(2_000.0, 288.0, Some(80_000.0), Some(1.0)),
724            ],
725            LATITUDE_RAD,
726        );
727        let top = profile.sample(2_000.0).unwrap().air;
728        let just_above = profile.sample(2_000.001).unwrap();
729        assert_eq!(just_above.extrapolated, Some(Side::Above));
730        assert!((just_above.air.temperature_k - top.temperature_k).abs() < 1e-4);
731        assert!((just_above.air.pressure_pa / top.pressure_pa - 1.0).abs() < 1e-6);
732        assert!((just_above.air.density_kg_m3 / top.density_kg_m3 - 1.0).abs() < 1e-5);
733        // Above, the standard lapse (6.5 K per geopotential km) continues from the top level.
734        let aloft = profile.sample(6_000.0).unwrap().air;
735        let h = |z| wmo_geopotential_from_geometric_m(z, LATITUDE_RAD).unwrap();
736        let expected = 288.0 - 0.0065 * (h(6_000.0) - h(2_000.0));
737        assert!((aloft.temperature_k - expected).abs() < 1e-9);
738        // The saturated top's vapour is capped at saturation in the cold air aloft.
739        let e_sat = saturation_vapour_pressure_pa(aloft.temperature_k);
740        let capped = moist_air(aloft.temperature_k, aloft.pressure_pa, e_sat).unwrap();
741        assert!((aloft.density_kg_m3 / capped.density_kg_m3 - 1.0).abs() < 1e-14);
742        // The continuation is the dry standard's pressure, so it is hydrostatic with the local
743        // gravity for dry air (to WMO's rounded gravity constants). With humid air it falls a
744        // little faster than hydrostatic: here, saturated at 288 K, 2.7e-4 low after 300 m.
745        let dry = profile_at(
746            vec![
747                level(500.0, 295.0, Some(95_000.0), None),
748                level(2_000.0, 288.0, Some(80_000.0), None),
749            ],
750            LATITUDE_RAD,
751        );
752        let integrated = integrate_pressure(&dry, 2_000.0, 80_000.0, 2_300.0);
753        let continued = dry.sample(2_300.0).unwrap().air.pressure_pa;
754        assert!(
755            (continued / integrated - 1.0).abs() < 1e-8,
756            "{continued} {integrated}"
757        );
758        let humid_integrated = integrate_pressure(&profile, 2_000.0, 80_000.0, 2_300.0);
759        let humid_continued = profile.sample(2_300.0).unwrap().air.pressure_pa;
760        let low = humid_continued / humid_integrated - 1.0;
761        assert!((-3e-4..-2.5e-4).contains(&low), "{low}");
762
763        let below = profile.sample(0.0).unwrap();
764        assert_eq!(below.extrapolated, Some(Side::Below));
765        assert!(below.air.pressure_pa > 95_000.0 && below.air.temperature_k > 295.0);
766        assert!(profile.sample(f64::NAN).is_err());
767    }
768
769    #[test]
770    fn a_single_level_is_an_anchored_standard_atmosphere() {
771        let profile = profile_at(
772            vec![level(1_000.0, 280.0, Some(90_000.0), None)],
773            LATITUDE_RAD,
774        );
775        let anchored =
776            Ussa76::anchored(standard_height(1_000.0, LATITUDE_RAD), 280.0, 90_000.0).unwrap();
777        assert_eq!(profile.sample(1_000.0).unwrap().extrapolated, None);
778        for z in [0.0, 3_000.0] {
779            let ours = profile.sample(z).unwrap().air;
780            let reference = anchored
781                .sample(standard_height(z, LATITUDE_RAD))
782                .unwrap()
783                .air;
784            assert!((ours.density_kg_m3 / reference.density_kg_m3 - 1.0).abs() < 1e-14);
785        }
786    }
787
788    #[test]
789    fn wind_columns_make_a_layered_wind() {
790        let mut low = level(0.0, 290.0, Some(100_000.0), None);
791        low.wind_speed_m_s = Some(3.0);
792        low.wind_direction_from_rad = Some(1.0);
793        let mut high = level(1_000.0, 283.0, None, None);
794        high.wind_speed_m_s = Some(9.0);
795        high.wind_direction_from_rad = Some(2.0);
796        let profile =
797            SoundingProfile::new(vec![low, high], LATITUDE_RAD, WindInterpolation::Components)
798                .unwrap();
799        let wind = profile.wind().unwrap();
800        assert_eq!(wind.interpolation(), WindInterpolation::Components);
801        assert_eq!(wind.levels().len(), 2);
802        assert!(wind.wind(500.0).is_ok());
803        let dry = profile_at(vec![level(0.0, 290.0, Some(100_000.0), None)], LATITUDE_RAD);
804        assert!(dry.wind().is_none());
805    }
806
807    #[test]
808    fn invalid_profiles_are_rejected() {
809        let ok = level(0.0, 290.0, Some(100_000.0), Some(0.5));
810        let build =
811            |levels| SoundingProfile::new(levels, LATITUDE_RAD, WindInterpolation::default());
812        assert!(matches!(build(vec![]), Err(AtmosError::NoLevels)));
813        assert!(matches!(
814            build(vec![level(0.0, 290.0, None, None)]),
815            Err(AtmosError::MissingBasePressure)
816        ));
817        assert!(matches!(
818            build(vec![ok, level(0.0, 280.0, None, Some(0.5))]),
819            Err(AtmosError::HeightsNotIncreasing { index: 1 })
820        ));
821        assert!(matches!(
822            build(vec![ok, level(100.0, 280.0, None, None)]),
823            Err(AtmosError::IncompleteColumn { index: 1, .. })
824        ));
825        let mut windless_direction = level(100.0, 280.0, None, Some(0.5));
826        windless_direction.wind_direction_from_rad = Some(0.0);
827        assert!(matches!(
828            build(vec![ok, windless_direction]),
829            Err(AtmosError::IncompleteColumn { index: 1, .. })
830        ));
831        // A pressure in hPa typed as Pa: the 850 at 1500 m is not below 101 325 Pa.
832        assert!(matches!(
833            build(vec![
834                level(0.0, 290.0, Some(101_325.0), None),
835                level(1_500.0, 280.0, Some(850.0), None),
836                level(3_000.0, 270.0, Some(70_000.0), None),
837            ]),
838            Err(AtmosError::PressureNotDecreasing { index: 2, .. })
839        ));
840        assert!(matches!(
841            build(vec![
842                level(0.0, 290.0, Some(101_325.0), None),
843                level(1_500.0, 280.0, Some(101_325.0), None),
844            ]),
845            Err(AtmosError::PressureNotDecreasing { index: 1, .. })
846        ));
847        // A given pressure above a filled one is caught too.
848        assert!(matches!(
849            build(vec![
850                level(0.0, 290.0, Some(101_325.0), None),
851                level(1_000.0, 283.0, None, None),
852                level(2_000.0, 276.0, Some(95_000.0), None),
853            ]),
854            Err(AtmosError::PressureNotDecreasing { index: 2, .. })
855        ));
856        assert!(build(vec![level(0.0, 290.0, Some(100_000.0), Some(1.5))]).is_err());
857        assert!(build(vec![level(0.0, -1.0, Some(100_000.0), None)]).is_err());
858        assert!(build(vec![level(0.0, 290.0, Some(0.0), None)]).is_err());
859        assert!(build(vec![level(f64::NAN, 290.0, Some(1e5), None)]).is_err());
860        let levels = vec![level(0.0, 290.0, Some(1e5), None)];
861        for latitude in [2.0, f64::NAN] {
862            assert!(
863                SoundingProfile::new(levels.clone(), latitude, WindInterpolation::default())
864                    .is_err()
865            );
866        }
867    }
868
869    #[test]
870    fn profiles_round_trip_through_json() {
871        let json = r#"{"latitude_rad":0.5758,"levels":[
872            {"height_msl_m":1400.0,"temperature_k":300.0,"pressure_pa":86000.0,
873             "relative_humidity":0.3,"wind_speed_m_s":4.0,"wind_direction_from_rad":3.0},
874            {"height_msl_m":3000.0,"temperature_k":290.0,
875             "relative_humidity":0.2,"wind_speed_m_s":10.0,"wind_direction_from_rad":3.5}
876        ]}"#;
877        let profile: SoundingProfile = serde_json::from_str(json).unwrap();
878        assert_eq!(
879            profile.wind_interpolation,
880            WindInterpolation::SpeedDirection
881        );
882        assert_eq!(profile.latitude_rad(), 0.5758);
883        let back: SoundingProfile =
884            serde_json::from_str(&serde_json::to_string(&profile).unwrap()).unwrap();
885        assert_eq!(back, profile);
886        let unknown = r#"{"latitude_rad":0.0,"levels":[{"height_msl_m":0.0,"temperature_k":300.0,"pressure_pa":1e5,"dew_point_k":280.0}]}"#;
887        assert!(serde_json::from_str::<SoundingProfile>(unknown).is_err());
888        let no_latitude =
889            r#"{"levels":[{"height_msl_m":0.0,"temperature_k":300.0,"pressure_pa":1e5}]}"#;
890        assert!(serde_json::from_str::<SoundingProfile>(no_latitude).is_err());
891    }
892
893    /// A profile `hpr weather --output` wrote opens with the program that wrote it, which reading
894    /// passes over: the same profile, and nothing written back.
895    #[test]
896    fn a_profile_reads_past_the_tool_that_wrote_it() {
897        let json = r#"{"tool":{"name":"hpr-sim","version":"0.1.0","designation":"FS · SW · TOOL 005"},
898            "latitude_rad":0.5758,"levels":[
899            {"height_msl_m":1400.0,"temperature_k":300.0,"pressure_pa":86000.0}
900        ]}"#;
901        let profile: SoundingProfile = serde_json::from_str(json).unwrap();
902        let bare = r#"{"latitude_rad":0.5758,"levels":[
903            {"height_msl_m":1400.0,"temperature_k":300.0,"pressure_pa":86000.0}
904        ]}"#;
905        assert_eq!(profile, serde_json::from_str(bare).unwrap());
906        assert!(!serde_json::to_string(&profile).unwrap().contains("tool"));
907        // Every other key at the top is still refused, a near miss of `tool` too.
908        for key in ["tools", "latitude_deg"] {
909            let unknown = bare.replacen('{', &format!("{{\"{key}\":{{}},"), 1);
910            assert!(
911                serde_json::from_str::<SoundingProfile>(&unknown).is_err(),
912                "{unknown}"
913            );
914        }
915    }
916
917    /// WMO-No. 8 (2023), Vol. I, p. 416: 30 km geometric is 29.7785 km of geopotential at the
918    /// equator and 29.932 km at 80° N.
919    #[test]
920    fn wmo_geopotential_matches_the_guide() {
921        let equator = wmo_geopotential_from_geometric_m(30_000.0, 0.0).unwrap();
922        assert!((equator - 29_778.5).abs() <= 0.05, "{equator}");
923        let north = wmo_geopotential_from_geometric_m(30_000.0, 80_f64.to_radians()).unwrap();
924        assert!((north - 29_932.0).abs() <= 0.5, "{north}");
925        for latitude in [-90.0_f64, -33.0, 0.0, 32.9, 45.0, 89.0] {
926            for z in [-400.0, 0.0, 1_500.0, 12_000.0, 40_000.0] {
927                let gpm = wmo_geopotential_from_geometric_m(z, latitude.to_radians()).unwrap();
928                let back = geometric_from_wmo_geopotential_m(gpm, latitude.to_radians()).unwrap();
929                assert!((back - z).abs() < 1e-8, "{latitude} {z}");
930            }
931        }
932        assert!(wmo_geopotential_from_geometric_m(0.0, 2.0).is_err());
933        assert!(geometric_from_wmo_geopotential_m(1e8, 0.0).is_err());
934    }
935}