Skip to main content

hpr_atmos/
moist.rs

1//! Moist air: saturation vapour pressure, and the density and speed of sound of humid air.
2//!
3//! Air is treated as an ideal mixture of dry air (the 1976 standard's `M₀`, `γ = 1.4`) and water
4//! vapour (see `docs/physics/atmosphere.md`):
5//!
6//! ```text
7//! x_v = e / p                                         vapour mole fraction (Dalton)
8//! M   = (1 − x_v) M₀ + x_v M_v                        mixture molar mass
9//! ρ   = p M / (R* T)  =  p / (R_d T_v)                density
10//! T_v = T / (1 − x_v (1 − M_v/M₀))                    virtual temperature (WMO-No. 8 eq. 12.18)
11//! γ   = C_p / (C_p − R*),  C_p = (1 − x_v) (7/2) R* + x_v 4 R*
12//! a   = (γ R* T / M)^½
13//! ```
14//!
15//! Water vapour's molar heat capacity is taken as `4 R*`, the rigid nonlinear molecule
16//! (33.26 J/(mol·K)); the ideal-gas value near 300 K is about 1% higher, which moves `a` by 0.009%
17//! at 30 °C and saturation. Viscosity stays dry air's: by Wilke's mixing rule, water vapour
18//! lowers it by 2.1% at 30 °C and saturation, which changes turbulent skin friction by about
19//! 0.4%.
20//!
21//! Saturation vapour pressure over liquid water follows WMO-No. 8, *Guide to Instruments and
22//! Methods of Observation*, Vol. I (2023), Annex 4.B, eq. 4.B.1, at every temperature: radiosonde
23//! relative humidity is always reported relative to water, even below 0 °C.
24
25use crate::air::AirState;
26use crate::error::{AtmosError, finite, positive};
27use crate::ussa76::{
28    GAS_CONSTANT_J_PER_KMOL_K, SEA_LEVEL_MOLECULAR_WEIGHT_KG_PER_KMOL, sutherland_viscosity_pa_s,
29};
30
31/// Molar mass of water vapour `M_v`, kg/kmol (A. Picard et al., "Revised formula for the density
32/// of moist air (CIPM-2007)", *Metrologia* 45, 149–155 (2008), section 2.1).
33pub const WATER_VAPOUR_MOLECULAR_WEIGHT_KG_PER_KMOL: f64 = 18.015_28;
34
35/// 0 °C in kelvin.
36const CELSIUS_ZERO_K: f64 = 273.15;
37
38/// Saturation vapour pressure of pure water vapour over a plane surface of liquid water, Pa, at
39/// temperature `temperature_k`. WMO-No. 8 (2023), Vol. I, Annex 4.B, eq. 4.B.1, with `t` in °C:
40///
41/// ```text
42/// e_w(t) = 6.112 exp(17.62 t / (243.12 + t))  hPa
43/// ```
44///
45/// WMO states it for −45 °C to 60 °C. Colder air holds so little vapour (1.9 Pa at −60 °C) that
46/// continuing the formula below its range changes density negligibly. The formula vanishes as `t`
47/// approaches −243.12 °C, so at and below that it returns zero.
48pub fn saturation_vapour_pressure_pa(temperature_k: f64) -> f64 {
49    let t = temperature_k - CELSIUS_ZERO_K;
50    if t <= -243.12 {
51        return 0.0;
52    }
53    611.2 * (17.62 * t / (243.12 + t)).exp()
54}
55
56/// The vapour pressure, Pa, of air at `temperature_k` with relative humidity
57/// `relative_humidity` (a fraction, relative to liquid water): `e = U e_w(T)`.
58///
59/// # Errors
60///
61/// [`AtmosError::Domain`] if the temperature is not finite and positive, or the relative
62/// humidity is outside `[0, 1]`.
63pub fn vapour_pressure_pa(temperature_k: f64, relative_humidity: f64) -> Result<f64, AtmosError> {
64    let t = positive("temperature (K)", temperature_k)?;
65    let u = check_relative_humidity(relative_humidity)?;
66    Ok(u * saturation_vapour_pressure_pa(t))
67}
68
69/// Checks a relative humidity fraction is in `[0, 1]`.
70pub(crate) fn check_relative_humidity(relative_humidity: f64) -> Result<f64, AtmosError> {
71    let u = finite("relative humidity", relative_humidity)?;
72    if !(0.0..=1.0).contains(&u) {
73        return Err(AtmosError::Domain {
74            what: "relative humidity (fraction)",
75            value: u,
76        });
77    }
78    Ok(u)
79}
80
81/// The state of moist air at `temperature_k` and `pressure_pa` with water vapour at partial
82/// pressure `vapour_pressure_pa`. The vapour mole fraction is capped at 1.
83///
84/// # Errors
85///
86/// [`AtmosError::Domain`] if the temperature or pressure is not finite and positive, or the
87/// vapour pressure is negative or not finite.
88pub fn moist_air(
89    temperature_k: f64,
90    pressure_pa: f64,
91    vapour_pressure_pa: f64,
92) -> Result<AirState, AtmosError> {
93    let t = positive("temperature (K)", temperature_k)?;
94    let p = positive("pressure (Pa)", pressure_pa)?;
95    let e = finite("vapour pressure (Pa)", vapour_pressure_pa)?;
96    if e < 0.0 {
97        return Err(AtmosError::Domain {
98            what: "vapour pressure (Pa)",
99            value: e,
100        });
101    }
102    Ok(moist_air_unchecked(t, p, e))
103}
104
105/// [`moist_air`] for inputs already checked.
106pub(crate) fn moist_air_unchecked(t: f64, p: f64, e: f64) -> AirState {
107    let x = (e / p).min(1.0);
108    let molar_mass = (1.0 - x) * SEA_LEVEL_MOLECULAR_WEIGHT_KG_PER_KMOL
109        + x * WATER_VAPOUR_MOLECULAR_WEIGHT_KG_PER_KMOL;
110    // Molar heat capacities in units of R*: 7/2 for dry air (γ = 1.4), 4 for water vapour.
111    let cp = (1.0 - x) * 3.5 + x * 4.0;
112    let gamma = cp / (cp - 1.0);
113    AirState {
114        temperature_k: t,
115        pressure_pa: p,
116        density_kg_m3: p * molar_mass / (GAS_CONSTANT_J_PER_KMOL_K * t),
117        speed_of_sound_m_s: (gamma * GAS_CONSTANT_J_PER_KMOL_K * t / molar_mass).sqrt(),
118        dynamic_viscosity_pa_s: sutherland_viscosity_pa_s(t),
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use serde::Deserialize;
125
126    use super::*;
127    use crate::ussa76::Ussa76;
128
129    /// Eq. 4.B.1 evaluated separately at a few temperatures (see
130    /// `validation/oracles/atmosphere/moist_air.py`, which prints these).
131    #[test]
132    fn saturation_vapour_pressure_follows_wmo_4_b_1() {
133        for (t_c, expected_hpa) in [(0.0, 6.112), (20.0, 23.325_960_2), (-40.0, 0.190_212_012)] {
134            let e = saturation_vapour_pressure_pa(273.15 + t_c) / 100.0;
135            assert!((e - expected_hpa).abs() < 1e-8 * expected_hpa, "{t_c}: {e}");
136        }
137        assert_eq!(saturation_vapour_pressure_pa(30.0), 0.0);
138        assert!(saturation_vapour_pressure_pa(30.1) >= 0.0);
139    }
140
141    /// Dry air is the 1976 standard's air: same density and speed of sound below 80 km.
142    #[test]
143    fn dry_air_is_the_standard_atmosphere() {
144        let standard = Ussa76::standard();
145        for z in [0.0, 5_000.0, 30_000.0] {
146            let reference = standard.sample(z).unwrap().air;
147            let dry = moist_air(reference.temperature_k, reference.pressure_pa, 0.0).unwrap();
148            assert!((dry.density_kg_m3 / reference.density_kg_m3 - 1.0).abs() < 1e-14);
149            assert!((dry.speed_of_sound_m_s / reference.speed_of_sound_m_s - 1.0).abs() < 1e-14);
150            assert_eq!(dry.dynamic_viscosity_pa_s, reference.dynamic_viscosity_pa_s);
151        }
152    }
153
154    #[derive(Deserialize)]
155    struct Fixture {
156        cases: Vec<Case>,
157    }
158
159    #[derive(Deserialize)]
160    struct Case {
161        temperature_k: f64,
162        pressure_pa: f64,
163        relative_humidity: f64,
164        cipm_2007_density_kg_m3: f64,
165    }
166
167    /// Humid air is lighter, and the ideal-mixture density agrees with the CIPM-2007 formula
168    /// (with its real-gas compressibility) to 0.05% over CIPM-2007's range, 15–27 °C and
169    /// 600–1100 hPa, from dry to saturated. The largest difference is 0.047%: the size of the
170    /// documented simplifications (ideal gas, no enhancement factor, the 1976 dry-air constants),
171    /// so this is a regression bound on them, not an accuracy target. Reference values from
172    /// `validation/oracles/atmosphere/moist_air.py`.
173    #[test]
174    fn humid_density_agrees_with_cipm_2007() {
175        let fixture: Fixture = serde_json::from_str(include_str!(
176            "../../../validation/fixtures/atmosphere/cipm-2007-moist-air-density.json"
177        ))
178        .unwrap();
179        assert!(fixture.cases.len() >= 12);
180        for case in &fixture.cases {
181            let e = vapour_pressure_pa(case.temperature_k, case.relative_humidity).unwrap();
182            let air = moist_air(case.temperature_k, case.pressure_pa, e).unwrap();
183            let error = air.density_kg_m3 / case.cipm_2007_density_kg_m3 - 1.0;
184            assert!(
185                error.abs() < 5e-4,
186                "T = {} K, p = {} Pa, RH = {}: {error:e}",
187                case.temperature_k,
188                case.pressure_pa,
189                case.relative_humidity
190            );
191        }
192    }
193
194    #[test]
195    fn humidity_lowers_density_and_raises_the_speed_of_sound() {
196        let (t, p) = (303.15, 101_325.0);
197        let dry = moist_air(t, p, 0.0).unwrap();
198        let wet = moist_air(t, p, vapour_pressure_pa(t, 1.0).unwrap()).unwrap();
199        let density_change = wet.density_kg_m3 / dry.density_kg_m3 - 1.0;
200        let sound_change = wet.speed_of_sound_m_s / dry.speed_of_sound_m_s - 1.0;
201        // x_v = 42.4/1013.25 = 0.0419: density down by x_v (1 − M_v/M₀) = 1.6%, and the speed of
202        // sound up by about 0.6%.
203        assert!(
204            (-0.0165..-0.0155).contains(&density_change),
205            "{density_change}"
206        );
207        assert!((0.005..0.007).contains(&sound_change), "{sound_change}");
208    }
209
210    #[test]
211    fn invalid_inputs_are_rejected() {
212        assert!(vapour_pressure_pa(300.0, 1.01).is_err());
213        assert!(vapour_pressure_pa(300.0, -0.01).is_err());
214        assert!(vapour_pressure_pa(0.0, 0.5).is_err());
215        assert!(moist_air(300.0, 0.0, 0.0).is_err());
216        assert!(moist_air(300.0, 1e5, -1.0).is_err());
217        assert!(moist_air(f64::NAN, 1e5, 0.0).is_err());
218        // Vapour pressure above the total pressure is capped at pure vapour.
219        let steam = moist_air(400.0, 1_000.0, 5_000.0).unwrap();
220        let expected = 1_000.0 * WATER_VAPOUR_MOLECULAR_WEIGHT_KG_PER_KMOL
221            / (GAS_CONSTANT_J_PER_KMOL_K * 400.0);
222        assert!((steam.density_kg_m3 - expected).abs() < 1e-15);
223    }
224}