Skip to main content

hpr_atmos/
air.rs

1//! The state of the air at a point, and the [`Atmosphere`] trait every atmosphere model implements.
2
3use std::fmt;
4
5use hpr_core::interp::Side;
6use serde::{Deserialize, Serialize};
7
8use crate::error::AtmosError;
9use crate::profile::SoundingProfile;
10use crate::ussa76::Ussa76;
11
12/// Thermodynamic and transport properties of the air at one point.
13#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
14pub struct AirState {
15    /// Kinetic temperature `T`, K.
16    pub temperature_k: f64,
17    /// Static pressure `p`, Pa.
18    pub pressure_pa: f64,
19    /// Density `ρ`, kg/m³ (of the moist air, where a model carries humidity).
20    pub density_kg_m3: f64,
21    /// Speed of sound `a`, m/s.
22    pub speed_of_sound_m_s: f64,
23    /// Dynamic viscosity `μ`, Pa·s.
24    pub dynamic_viscosity_pa_s: f64,
25}
26
27impl AirState {
28    /// Kinematic viscosity `ν = μ/ρ`, m²/s. Infinite where the density is zero.
29    pub fn kinematic_viscosity_m2_s(&self) -> f64 {
30        self.dynamic_viscosity_pa_s / self.density_kg_m3
31    }
32}
33
34/// An [`AirState`] and whether the model extrapolated beyond its data or its defined range to
35/// produce it.
36#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
37pub struct AirSample {
38    /// The air at the requested height.
39    pub air: AirState,
40    /// `Some` when the height was below or above the range the model is defined or tabulated
41    /// over; `None` inside it, ends included.
42    pub extrapolated: Option<Side>,
43}
44
45/// An atmosphere model: the air as a function of height.
46///
47/// **Height datum.** Every atmosphere is queried with the **geometric height above mean sea
48/// level**, in meters. The flight engine's heights are ellipsoidal (`docs/physics/frames.md`), so
49/// it subtracts the geoid undulation `N` at the site first: `H = h − N`.
50pub trait Atmosphere: fmt::Debug + Send + Sync {
51    /// The air at geometric height `height_msl_m` above mean sea level.
52    ///
53    /// # Errors
54    ///
55    /// [`AtmosError::Domain`] if the height is not finite, or is outside the heights the model
56    /// can represent at all (as opposed to heights it extrapolates to, which it flags).
57    fn air(&self, height_msl_m: f64) -> Result<AirSample, AtmosError>;
58}
59
60/// Any of the atmosphere models, tagged by `model` when serialized.
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62#[serde(tag = "model", rename_all = "snake_case")]
63#[non_exhaustive]
64pub enum AtmosphereModel {
65    /// [`Ussa76`], optionally offset.
66    Standard(Ussa76),
67    /// [`SoundingProfile`], boxed because it is far larger than the standard.
68    Sounding(Box<SoundingProfile>),
69}
70
71impl Default for AtmosphereModel {
72    fn default() -> Self {
73        AtmosphereModel::Standard(Ussa76::standard())
74    }
75}
76
77impl Atmosphere for AtmosphereModel {
78    fn air(&self, height_msl_m: f64) -> Result<AirSample, AtmosError> {
79        match self {
80            AtmosphereModel::Standard(model) => model.sample(height_msl_m),
81            AtmosphereModel::Sounding(model) => model.sample(height_msl_m),
82        }
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn atmosphere_models_round_trip_through_json() {
92        let standard = AtmosphereModel::Standard(Ussa76::with_offset(10.0, 99_000.0).unwrap());
93        let json = serde_json::to_string(&standard).unwrap();
94        assert_eq!(
95            json,
96            r#"{"model":"standard","temperature_offset_k":10.0,"sea_level_pressure_pa":99000.0}"#
97        );
98        assert_eq!(
99            serde_json::from_str::<AtmosphereModel>(&json).unwrap(),
100            standard
101        );
102        let sounding = r#"{"model":"sounding","latitude_rad":0.6,"levels":[
103            {"height_msl_m":0.0,"temperature_k":290.0,"pressure_pa":100000.0}]}"#;
104        let model: AtmosphereModel = serde_json::from_str(sounding).unwrap();
105        assert!(matches!(model, AtmosphereModel::Sounding(_)));
106        let air = model.air(0.0).unwrap().air;
107        assert_eq!(air.pressure_pa, 100_000.0);
108        assert_eq!(
109            AtmosphereModel::default().air(0.0).unwrap(),
110            Ussa76::standard().sample(0.0).unwrap()
111        );
112    }
113}