Skip to main content

standard_atmosphere/
lib.rs

1//! # standard-atmosphere
2//!
3//! The International Standard Atmosphere (ISA / U.S. Standard Atmosphere 1976), with
4//! the vertical-coordinate conversions weather and aviation tooling actually needs:
5//! pressure to altitude, and flight level to pressure.
6//!
7//! ## Flight levels and pressure levels are the same axis
8//!
9//! Gridded weather lives on pressure levels (500 hPa, 300 hPa, 250 hPa). Aviation lives
10//! on flight levels (FL300, FL350). A flight level is the altitude an altimeter reads
11//! with the standard subscale set to 1013.25 hPa, and an altimeter reports the ISA
12//! pressure-altitude of the ambient static pressure. So an aircraft holding FL300 is
13//! sitting wherever the ambient pressure equals the ISA pressure for 30,000 ft, about
14//! 300.9 hPa. A flight level is therefore, to a very good approximation, a real isobaric
15//! surface: converting a flight level to a pressure level to sample a weather grid is not
16//! an approximation, it is physically what the aircraft is doing.
17//!
18//! ```
19//! use standard_atmosphere as isa;
20//!
21//! // FL300 is the ~301 hPa surface.
22//! let p = isa::pressure_hpa_from_flight_level(300.0);
23//! assert!((p - 300.9).abs() < 0.5);
24//!
25//! // ...and back.
26//! let fl = isa::flight_level_from_pressure_hpa(300.9);
27//! assert!((fl - 300.0).abs() < 0.5);
28//! ```
29//!
30//! ## Model
31//!
32//! The atmosphere is the standard's stack of layers, each with a constant temperature
33//! lapse rate, integrated hydrostatically, from sea level to 71 km geopotential. That
34//! span covers all aviation and tropospheric/stratospheric meteorology. The piecewise
35//! structure matters: a single tropospheric formula used above the 11 km tropopause
36//! (about FL360) is wrong, which is the classic bug this crate avoids.
37//!
38//! Heights are geopotential metres internally, the coordinate the standard is defined in.
39//! [`geometric_from_geopotential`] and [`geopotential_from_geometric`] convert to and from
40//! true geometric altitude when you need it; the difference is about 0.3% at 20 km.
41//!
42//! Units are explicit in every function name: `_pa` pascals, `_hpa` hectopascals
43//! (millibars), `_m` metres, `_ft` feet, `_k` kelvin. SI is the core; the hPa and
44//! flight-level helpers wrap it. No dependencies, no `unsafe`.
45
46#![forbid(unsafe_code)]
47#![warn(missing_docs)]
48
49/// Standard gravitational acceleration (m/s^2).
50pub const G0: f64 = 9.806_65;
51/// Molar mass of dry air (kg/mol), per the 1976 standard.
52pub const M_AIR: f64 = 0.028_964_4;
53/// Universal gas constant used by the 1976 standard (J/(mol*K)).
54pub const R_STAR: f64 = 8.314_32;
55/// Specific gas constant for dry air, `R_STAR / M_AIR` (J/(kg*K)).
56pub const R_AIR: f64 = R_STAR / M_AIR;
57/// Hydrostatic constant `G0 * M_AIR / R_STAR` ("GMR"), in K/m.
58pub const GMR: f64 = G0 * M_AIR / R_STAR;
59/// Effective Earth radius for the geopotential/geometric conversion (m), per the standard.
60pub const EARTH_RADIUS_M: f64 = 6_356_766.0;
61/// Sea-level standard pressure (Pa).
62pub const P0_PA: f64 = 101_325.0;
63/// Sea-level standard temperature (K).
64pub const T0_K: f64 = 288.15;
65
66/// Feet per metre.
67const FT_PER_M: f64 = 1.0 / 0.3048;
68
69/// One atmospheric layer: geopotential base height, base temperature, lapse rate, and
70/// the base pressure (the standard's tabulated value, from hydrostatic integration).
71struct Layer {
72    base_geopotential_m: f64,
73    base_temp_k: f64,
74    lapse_k_per_m: f64,
75    base_pressure_pa: f64,
76}
77
78/// ISA / U.S. Standard Atmosphere 1976 layers, sea level to 71 km geopotential.
79static LAYERS: [Layer; 7] = [
80    Layer { base_geopotential_m:      0.0, base_temp_k: 288.15, lapse_k_per_m: -0.006_5, base_pressure_pa: 101_325.0 },
81    Layer { base_geopotential_m: 11_000.0, base_temp_k: 216.65, lapse_k_per_m:  0.0,     base_pressure_pa:  22_632.06 },
82    Layer { base_geopotential_m: 20_000.0, base_temp_k: 216.65, lapse_k_per_m:  0.001,   base_pressure_pa:   5_474.889 },
83    Layer { base_geopotential_m: 32_000.0, base_temp_k: 228.65, lapse_k_per_m:  0.002_8, base_pressure_pa:     868.018_7 },
84    Layer { base_geopotential_m: 47_000.0, base_temp_k: 270.65, lapse_k_per_m:  0.0,     base_pressure_pa:     110.906_3 },
85    Layer { base_geopotential_m: 51_000.0, base_temp_k: 270.65, lapse_k_per_m: -0.002_8, base_pressure_pa:      66.938_87 },
86    Layer { base_geopotential_m: 71_000.0, base_temp_k: 214.65, lapse_k_per_m: -0.002,   base_pressure_pa:       3.956_420 },
87];
88
89fn layer_for_geopotential(h_m: f64) -> &'static Layer {
90    let mut chosen = &LAYERS[0];
91    for layer in LAYERS.iter() {
92        if h_m >= layer.base_geopotential_m {
93            chosen = layer;
94        } else {
95            break;
96        }
97    }
98    chosen
99}
100
101fn layer_for_pressure(p_pa: f64) -> &'static Layer {
102    // Base pressures descend with altitude; the containing layer is the deepest one
103    // whose base pressure is still at or above p.
104    let mut chosen = &LAYERS[0];
105    for layer in LAYERS.iter() {
106        if p_pa <= layer.base_pressure_pa {
107            chosen = layer;
108        } else {
109            break;
110        }
111    }
112    chosen
113}
114
115/// Temperature at a geopotential altitude (kelvin).
116pub fn temperature_k_from_geopotential_m(h_m: f64) -> f64 {
117    let l = layer_for_geopotential(h_m);
118    l.base_temp_k + l.lapse_k_per_m * (h_m - l.base_geopotential_m)
119}
120
121/// Pressure at a geopotential altitude (pascals).
122pub fn pressure_pa_from_geopotential_m(h_m: f64) -> f64 {
123    let l = layer_for_geopotential(h_m);
124    let dh = h_m - l.base_geopotential_m;
125    if l.lapse_k_per_m.abs() < f64::EPSILON {
126        // Isothermal layer.
127        l.base_pressure_pa * (-GMR * dh / l.base_temp_k).exp()
128    } else {
129        // Constant-lapse layer: P = P_b * (T_b / T)^(GMR / L).
130        let t = l.base_temp_k + l.lapse_k_per_m * dh;
131        l.base_pressure_pa * (l.base_temp_k / t).powf(GMR / l.lapse_k_per_m)
132    }
133}
134
135/// Pressure at a geopotential altitude (hectopascals / millibars).
136pub fn pressure_hpa_from_geopotential_m(h_m: f64) -> f64 {
137    pressure_pa_from_geopotential_m(h_m) / 100.0
138}
139
140/// Geopotential altitude (metres) for a given pressure (pascals). Inverse of
141/// [`pressure_pa_from_geopotential_m`].
142pub fn geopotential_m_from_pressure_pa(p_pa: f64) -> f64 {
143    let l = layer_for_pressure(p_pa);
144    if l.lapse_k_per_m.abs() < f64::EPSILON {
145        l.base_geopotential_m - (l.base_temp_k / GMR) * (p_pa / l.base_pressure_pa).ln()
146    } else {
147        let t = l.base_temp_k * (p_pa / l.base_pressure_pa).powf(-l.lapse_k_per_m / GMR);
148        l.base_geopotential_m + (t - l.base_temp_k) / l.lapse_k_per_m
149    }
150}
151
152/// Air density at a geopotential altitude (kg/m^3), from the ideal gas law.
153pub fn density_kg_m3_from_geopotential_m(h_m: f64) -> f64 {
154    let p = pressure_pa_from_geopotential_m(h_m);
155    let t = temperature_k_from_geopotential_m(h_m);
156    p / (R_AIR * t)
157}
158
159/// Convert geometric altitude (true height above sea level) to geopotential altitude.
160pub fn geopotential_from_geometric(z_m: f64) -> f64 {
161    EARTH_RADIUS_M * z_m / (EARTH_RADIUS_M + z_m)
162}
163
164/// Convert geopotential altitude to geometric altitude (true height above sea level).
165pub fn geometric_from_geopotential(h_m: f64) -> f64 {
166    EARTH_RADIUS_M * h_m / (EARTH_RADIUS_M - h_m)
167}
168
169/// Pressure (hPa) at a pressure-altitude given as a flight level (hundreds of feet).
170///
171/// `FL300` maps to about 300.9 hPa. A flight level is pressure-altitude, which is the
172/// geopotential altitude in the ISA, so this is the pressure level a weather grid should
173/// be sampled at for that flight level. See the crate docs for why that is exact, not an
174/// approximation.
175pub fn pressure_hpa_from_flight_level(fl: f64) -> f64 {
176    let h_m = fl * 100.0 / FT_PER_M; // hundreds of feet -> metres (pressure-altitude == geopotential)
177    pressure_pa_from_geopotential_m(h_m) / 100.0
178}
179
180/// Flight level (hundreds of feet) for a given pressure (hPa). Inverse of
181/// [`pressure_hpa_from_flight_level`].
182pub fn flight_level_from_pressure_hpa(p_hpa: f64) -> f64 {
183    let h_m = geopotential_m_from_pressure_pa(p_hpa * 100.0);
184    h_m * FT_PER_M / 100.0
185}
186
187/// Pressure-altitude in feet for a given pressure (hPa): the value an altimeter set to
188/// the standard 1013.25 hPa would display.
189pub fn pressure_altitude_ft_from_pressure_hpa(p_hpa: f64) -> f64 {
190    geopotential_m_from_pressure_pa(p_hpa * 100.0) * FT_PER_M
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    fn approx(a: f64, b: f64, tol: f64) {
198        assert!((a - b).abs() < tol, "{a} vs {b} (tol {tol})");
199    }
200
201    #[test]
202    fn sea_level_matches_the_standard() {
203        approx(pressure_hpa_from_geopotential_m(0.0), 1013.25, 0.01);
204        approx(temperature_k_from_geopotential_m(0.0), 288.15, 0.01);
205        approx(density_kg_m3_from_geopotential_m(0.0), 1.225, 0.001);
206    }
207
208    #[test]
209    fn flight_levels_are_isobars() {
210        approx(pressure_hpa_from_flight_level(300.0), 300.9, 0.3);
211        approx(pressure_hpa_from_flight_level(350.0), 238.4, 0.3);
212        approx(pressure_hpa_from_flight_level(400.0), 187.5, 0.3);
213    }
214
215    #[test]
216    fn flight_level_round_trips() {
217        for fl in [50.0, 180.0, 300.0, 410.0] {
218            let p = pressure_hpa_from_flight_level(fl);
219            approx(flight_level_from_pressure_hpa(p), fl, 0.01);
220        }
221    }
222
223    #[test]
224    fn known_pressure_heights() {
225        // 500 hPa ~ 5575 m geopotential, 250 hPa ~ 10,363 m: standard reference values.
226        approx(geopotential_m_from_pressure_pa(50_000.0), 5575.0, 5.0);
227        approx(geopotential_m_from_pressure_pa(25_000.0), 10_363.0, 10.0);
228    }
229
230    #[test]
231    fn tropopause_is_piecewise() {
232        // The 11 km tropopause: a single tropospheric formula would diverge above here.
233        approx(pressure_hpa_from_geopotential_m(11_000.0), 226.32, 0.05);
234        approx(temperature_k_from_geopotential_m(11_000.0), 216.65, 0.01);
235        // Into the isothermal stratosphere, temperature holds.
236        approx(temperature_k_from_geopotential_m(18_000.0), 216.65, 0.01);
237    }
238
239    #[test]
240    fn geopotential_geometric_correction_is_small_but_present() {
241        let z = geometric_from_geopotential(11_000.0);
242        assert!(z > 11_000.0 && z - 11_000.0 < 25.0); // about 19 m at 11 km
243        approx(geopotential_from_geometric(z), 11_000.0, 1e-6);
244    }
245}