1use 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
31pub const WATER_VAPOUR_MOLECULAR_WEIGHT_KG_PER_KMOL: f64 = 18.015_28;
34
35const CELSIUS_ZERO_K: f64 = 273.15;
37
38pub 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
56pub 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
69pub(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
81pub 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
105pub(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 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 #[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 #[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 #[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 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 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}