1use hpr_core::gravity::STANDARD_GRAVITY_MPS2;
40use hpr_core::interp::Side;
41use serde::{Deserialize, Serialize};
42
43use crate::air::{AirSample, AirState, Atmosphere};
44use crate::error::{AtmosError, finite, positive};
45
46pub const GAS_CONSTANT_J_PER_KMOL_K: f64 = 8.314_32e3;
50
51pub const SEA_LEVEL_MOLECULAR_WEIGHT_KG_PER_KMOL: f64 = 28.964_4;
53
54pub const EARTH_RADIUS_M: f64 = 6_356_766.0;
56
57pub const SEA_LEVEL_PRESSURE_PA: f64 = 101_325.0;
59
60pub const SEA_LEVEL_TEMPERATURE_K: f64 = 288.15;
62
63pub const RATIO_OF_SPECIFIC_HEATS: f64 = 1.40;
65
66pub const SUTHERLAND_BETA: f64 = 1.458e-6;
68
69pub const SUTHERLAND_S_K: f64 = 110.4;
73
74pub const DRY_AIR_GAS_CONSTANT_J_PER_KG_K: f64 =
76 GAS_CONSTANT_J_PER_KMOL_K / SEA_LEVEL_MOLECULAR_WEIGHT_KG_PER_KMOL;
77
78pub const MIN_HEIGHT_M: f64 = -5_000.0;
80
81pub const MAX_HEIGHT_M: f64 = 86_000.0;
84
85const HYDROSTATIC_K_PER_M: f64 =
87 STANDARD_GRAVITY_MPS2 * SEA_LEVEL_MOLECULAR_WEIGHT_KG_PER_KMOL / GAS_CONSTANT_J_PER_KMOL_K;
88
89const LAYERS: [(f64, f64); 7] = [
92 (0.0, -0.0065),
93 (11_000.0, 0.0),
94 (20_000.0, 0.001),
95 (32_000.0, 0.0028),
96 (47_000.0, 0.0),
97 (51_000.0, -0.0028),
98 (71_000.0, -0.002),
99];
100
101const MOLECULAR_WEIGHT_RATIO: [f64; 13] = [
103 1.000_000, 0.999_996, 0.999_989, 0.999_971, 0.999_941, 0.999_909, 0.999_870, 0.999_829,
104 0.999_786, 0.999_741, 0.999_694, 0.999_641, 0.999_579,
105];
106
107const MOLECULAR_WEIGHT_TABLE_START_M: f64 = 80_000.0;
109
110const MOLECULAR_WEIGHT_TABLE_STEP_M: f64 = 500.0;
112
113pub fn geopotential_from_geometric_m(geometric_m: f64) -> Result<f64, AtmosError> {
120 let z = finite("geometric altitude (m)", geometric_m)?;
121 if z <= -EARTH_RADIUS_M {
122 return Err(AtmosError::Domain {
123 what: "geometric altitude (m)",
124 value: z,
125 });
126 }
127 Ok(EARTH_RADIUS_M * z / (EARTH_RADIUS_M + z))
128}
129
130pub fn geometric_from_geopotential_m(geopotential_m: f64) -> Result<f64, AtmosError> {
137 let h = finite("geopotential altitude (m')", geopotential_m)?;
138 if h >= EARTH_RADIUS_M {
139 return Err(AtmosError::Domain {
140 what: "geopotential altitude (m')",
141 value: h,
142 });
143 }
144 Ok(EARTH_RADIUS_M * h / (EARTH_RADIUS_M - h))
145}
146
147fn molecular_weight_ratio(z: f64) -> f64 {
150 if z <= MOLECULAR_WEIGHT_TABLE_START_M {
151 return 1.0;
152 }
153 let position = (z - MOLECULAR_WEIGHT_TABLE_START_M) / MOLECULAR_WEIGHT_TABLE_STEP_M;
154 let last = MOLECULAR_WEIGHT_RATIO.len() - 1;
155 if position >= 12.0 {
156 return MOLECULAR_WEIGHT_RATIO[last];
157 }
158 let i = position.floor() as usize;
161 let t = position - position.floor();
162 MOLECULAR_WEIGHT_RATIO[i] + t * (MOLECULAR_WEIGHT_RATIO[i + 1] - MOLECULAR_WEIGHT_RATIO[i])
163}
164
165fn standard_layer(h: f64) -> usize {
168 LAYERS.iter().rposition(|&(base, _)| h >= base).unwrap_or(0)
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
176#[serde(try_from = "Ussa76Data", into = "Ussa76Data")]
177pub struct Ussa76 {
178 temperature_offset_k: f64,
179 sea_level_pressure_pa: f64,
180 base_temperatures_k: [f64; 7],
182 base_pressures_pa: [f64; 7],
184 top_geopotential_m: f64,
186 top_temperature_k: f64,
188 top_pressure_pa: f64,
190}
191
192#[derive(Serialize, Deserialize)]
193#[serde(deny_unknown_fields)]
194struct Ussa76Data {
195 temperature_offset_k: f64,
196 sea_level_pressure_pa: f64,
197}
198
199impl TryFrom<Ussa76Data> for Ussa76 {
200 type Error = AtmosError;
201
202 fn try_from(data: Ussa76Data) -> Result<Self, AtmosError> {
203 Ussa76::with_offset(data.temperature_offset_k, data.sea_level_pressure_pa)
204 }
205}
206
207impl From<Ussa76> for Ussa76Data {
208 fn from(model: Ussa76) -> Self {
209 Ussa76Data {
210 temperature_offset_k: model.temperature_offset_k,
211 sea_level_pressure_pa: model.sea_level_pressure_pa,
212 }
213 }
214}
215
216impl Default for Ussa76 {
217 fn default() -> Self {
218 Ussa76::standard()
219 }
220}
221
222fn coldest_standard_temperature_k() -> f64 {
225 let top = EARTH_RADIUS_M * MAX_HEIGHT_M / (EARTH_RADIUS_M + MAX_HEIGHT_M);
226 let (base, lapse) = LAYERS[6];
227 standard_base_temperatures_k()[6] + lapse * (top - base)
228}
229
230fn standard_base_temperatures_k() -> [f64; 7] {
232 let mut temperatures = [SEA_LEVEL_TEMPERATURE_K; 7];
233 for b in 1..7 {
234 let (base, lapse) = LAYERS[b - 1];
235 temperatures[b] = temperatures[b - 1] + lapse * (LAYERS[b].0 - base);
236 }
237 temperatures
238}
239
240fn layer_pressure(h: f64, base_h: f64, lapse: f64, base_t: f64, base_p: f64) -> f64 {
243 if lapse == 0.0 {
244 base_p * (-HYDROSTATIC_K_PER_M * (h - base_h) / base_t).exp()
245 } else {
246 let t = base_t + lapse * (h - base_h);
247 base_p * (base_t / t).powf(HYDROSTATIC_K_PER_M / lapse)
248 }
249}
250
251impl Ussa76 {
252 pub fn standard() -> Self {
254 Ussa76::build(0.0, SEA_LEVEL_PRESSURE_PA)
255 }
256
257 pub fn with_offset(
266 temperature_offset_k: f64,
267 sea_level_pressure_pa: f64,
268 ) -> Result<Self, AtmosError> {
269 let offset = finite("temperature offset (K)", temperature_offset_k)?;
270 if coldest_standard_temperature_k() + offset <= 0.0 {
271 return Err(AtmosError::Domain {
272 what: "temperature offset (K)",
273 value: offset,
274 });
275 }
276 let p0 = positive("sea-level pressure (Pa)", sea_level_pressure_pa)?;
277 Ok(Ussa76::build(offset, p0))
278 }
279
280 pub fn anchored(
296 height_msl_m: f64,
297 temperature_k: f64,
298 pressure_pa: f64,
299 ) -> Result<Self, AtmosError> {
300 let h = geopotential_from_geometric_m(height_msl_m)?;
301 let temperature = positive("anchor temperature (K)", temperature_k)?;
302 let pressure = positive("anchor pressure (Pa)", pressure_pa)?;
303 let standard = Ussa76::standard();
304 let offset = temperature / molecular_weight_ratio(height_msl_m)
305 - standard.molecular_scale_temperature_k(h);
306 let offset_unit = Ussa76::with_offset(offset, 1.0)?;
308 let unit_pressure = offset_unit.pressure_pa(h);
309 Ussa76::with_offset(offset, pressure / unit_pressure)
310 }
311
312 fn build(offset: f64, p0: f64) -> Self {
313 let mut base_temperatures_k = standard_base_temperatures_k();
314 for t in &mut base_temperatures_k {
315 *t += offset;
316 }
317 let mut base_pressures_pa = [p0; 7];
318 for b in 1..7 {
319 let (base, lapse) = LAYERS[b - 1];
320 base_pressures_pa[b] = layer_pressure(
321 LAYERS[b].0,
322 base,
323 lapse,
324 base_temperatures_k[b - 1],
325 base_pressures_pa[b - 1],
326 );
327 }
328 let top_geopotential_m = EARTH_RADIUS_M * MAX_HEIGHT_M / (EARTH_RADIUS_M + MAX_HEIGHT_M);
329 let (base, lapse) = LAYERS[6];
330 let top_temperature_k = base_temperatures_k[6] + lapse * (top_geopotential_m - base);
331 let top_pressure_pa = layer_pressure(
332 top_geopotential_m,
333 base,
334 lapse,
335 base_temperatures_k[6],
336 base_pressures_pa[6],
337 );
338 Ussa76 {
339 temperature_offset_k: offset,
340 sea_level_pressure_pa: p0,
341 base_temperatures_k,
342 base_pressures_pa,
343 top_geopotential_m,
344 top_temperature_k,
345 top_pressure_pa,
346 }
347 }
348
349 pub fn temperature_offset_k(&self) -> f64 {
351 self.temperature_offset_k
352 }
353
354 pub fn sea_level_pressure_pa(&self) -> f64 {
356 self.sea_level_pressure_pa
357 }
358
359 fn molecular_scale_temperature_k(&self, h: f64) -> f64 {
361 if h > self.top_geopotential_m {
362 return self.top_temperature_k;
363 }
364 let b = standard_layer(h);
365 let (base, lapse) = LAYERS[b];
366 self.base_temperatures_k[b] + lapse * (h - base)
367 }
368
369 fn pressure_pa(&self, h: f64) -> f64 {
371 if h > self.top_geopotential_m {
372 return layer_pressure(
373 h,
374 self.top_geopotential_m,
375 0.0,
376 self.top_temperature_k,
377 self.top_pressure_pa,
378 );
379 }
380 let b = standard_layer(h);
381 let (base, lapse) = LAYERS[b];
382 layer_pressure(
383 h,
384 base,
385 lapse,
386 self.base_temperatures_k[b],
387 self.base_pressures_pa[b],
388 )
389 }
390
391 pub fn sample(&self, height_msl_m: f64) -> Result<AirSample, AtmosError> {
397 let h = geopotential_from_geometric_m(height_msl_m)?;
398 let t_m = self.molecular_scale_temperature_k(h);
399 let pressure = self.pressure_pa(h);
400 let temperature = t_m * molecular_weight_ratio(height_msl_m);
401 let air = AirState {
402 temperature_k: temperature,
403 pressure_pa: pressure,
404 density_kg_m3: pressure / (DRY_AIR_GAS_CONSTANT_J_PER_KG_K * t_m),
405 speed_of_sound_m_s: (RATIO_OF_SPECIFIC_HEATS * DRY_AIR_GAS_CONSTANT_J_PER_KG_K * t_m)
406 .sqrt(),
407 dynamic_viscosity_pa_s: sutherland_viscosity_pa_s(temperature),
408 };
409 let extrapolated = if height_msl_m < MIN_HEIGHT_M {
410 Some(Side::Below)
411 } else if height_msl_m > MAX_HEIGHT_M {
412 Some(Side::Above)
413 } else {
414 None
415 };
416 Ok(AirSample { air, extrapolated })
417 }
418
419 pub fn pressure_altitude_m(&self, pressure_pa: f64) -> Result<f64, AtmosError> {
438 let p = positive("pressure (Pa)", pressure_pa)?;
439 let (base_h, lapse, base_t, base_p) = if p < self.top_pressure_pa {
440 (
441 self.top_geopotential_m,
442 0.0,
443 self.top_temperature_k,
444 self.top_pressure_pa,
445 )
446 } else {
447 let b = self
450 .base_pressures_pa
451 .iter()
452 .rposition(|&base| base >= p)
453 .unwrap_or(0);
454 let (base, lapse) = LAYERS[b];
455 (
456 base,
457 lapse,
458 self.base_temperatures_k[b],
459 self.base_pressures_pa[b],
460 )
461 };
462 Ok(if lapse == 0.0 {
463 base_h - base_t * (p / base_p).ln() / HYDROSTATIC_K_PER_M
464 } else {
465 base_h + base_t / lapse * ((p / base_p).powf(-lapse / HYDROSTATIC_K_PER_M) - 1.0)
466 })
467 }
468}
469
470impl Atmosphere for Ussa76 {
471 fn air(&self, height_msl_m: f64) -> Result<AirSample, AtmosError> {
472 self.sample(height_msl_m)
473 }
474}
475
476pub fn sutherland_viscosity_pa_s(temperature_k: f64) -> f64 {
479 SUTHERLAND_BETA * temperature_k * temperature_k.sqrt() / (temperature_k + SUTHERLAND_S_K)
480}
481
482#[cfg(test)]
483mod tests {
484 use proptest::prelude::*;
485 use serde::Deserialize;
486
487 use super::*;
488
489 #[derive(Deserialize)]
490 struct TableFixture {
491 rows: Vec<Row>,
492 }
493
494 #[derive(Deserialize)]
495 struct Row {
496 geometric_altitude_m: f64,
497 printed: Printed,
498 si: Si,
499 }
500
501 #[derive(Deserialize)]
502 struct Si {
503 geopotential_altitude_m: f64,
504 temperature_k: f64,
505 molecular_scale_temperature_k: f64,
506 pressure_pa: f64,
507 density_kgpm3: f64,
508 speed_of_sound_mps: Option<f64>,
509 dynamic_viscosity_pas: Option<f64>,
510 kinematic_viscosity_m2ps: Option<f64>,
511 }
512
513 #[derive(Deserialize)]
514 struct Printed {
515 geopotential_altitude_m: String,
516 temperature_k: String,
517 molecular_scale_temperature_k: String,
518 pressure_mb: String,
519 density_kgpm3: String,
520 speed_of_sound_mps: Option<String>,
521 dynamic_viscosity_pas: Option<String>,
522 kinematic_viscosity_m2ps: Option<String>,
523 }
524
525 fn table() -> TableFixture {
528 serde_json::from_str(include_str!(
529 "../../../validation/fixtures/atmosphere/ussa76-table-i.json"
530 ))
531 .unwrap()
532 }
533
534 fn parse_printed(text: &str) -> (f64, f64) {
537 let value: f64 = text.parse().unwrap();
538 let (mantissa, exponent) = match text.split_once('E') {
539 Some((m, e)) => (m, e.parse::<i32>().unwrap()),
540 None => (text, 0),
541 };
542 let decimals = mantissa.split_once('.').map_or(0, |(_, d)| d.len());
543 let decimals = i32::try_from(decimals).unwrap();
544 (value, 10f64.powi(exponent - decimals))
545 }
546
547 fn counts_off(computed: f64, printed: &str) -> f64 {
549 let (value, count) = parse_printed(printed);
550 (computed - value).abs() / count
551 }
552
553 fn relative_error(computed: f64, printed: &str) -> f64 {
555 let (value, _) = parse_printed(printed);
556 if value == 0.0 {
557 return computed.abs();
558 }
559 ((computed - value) / value).abs()
560 }
561
562 #[test]
573 fn matches_the_1976_tables_at_32_altitudes() {
574 let model = Ussa76::standard();
575 let rows = table().rows;
576 assert_eq!(rows.len(), 32);
577 let mut compared = 0;
578 for row in &rows {
579 let z = row.geometric_altitude_m;
580 let p = &row.printed;
581 let sample = model.sample(z).unwrap();
582 assert_eq!(sample.extrapolated, None, "{z}");
583 let air = sample.air;
584 let h = geopotential_from_geometric_m(z).unwrap();
585 let t_m = model.molecular_scale_temperature_k(h);
586 let tables_omit_m_over_m0 = (80_000.0..86_000.0).contains(&z) && z > 80_000.0;
587 let label = |what: &str| format!("{what} at {z} m");
588
589 let mut check = |computed: f64, printed: &str, what: &str, one_count: bool| {
590 assert!(relative_error(computed, printed) < 1e-3, "{}", label(what));
591 if one_count {
592 let off = counts_off(computed, printed);
593 assert!(
594 off <= 1.0,
595 "{}: {computed} vs {printed}, {off} counts",
596 label(what)
597 );
598 }
599 compared += 1;
600 };
601
602 check(h, &p.geopotential_altitude_m, "geopotential altitude", true);
603 check(t_m, &p.molecular_scale_temperature_k, "T_M", true);
604 if tables_omit_m_over_m0 {
605 check(t_m, &p.temperature_k, "printed T (= T_M)", true);
606 assert!(relative_error(air.temperature_k, &p.temperature_k) < 4e-4);
607 } else {
608 check(air.temperature_k, &p.temperature_k, "T", true);
609 }
610 check(air.pressure_pa / 100.0, &p.pressure_mb, "P", true);
611 let density_one_count = z != 84_000.0;
612 check(air.density_kg_m3, &p.density_kgpm3, "ρ", density_one_count);
613 if z == 84_000.0 {
614 assert!(counts_off(air.density_kg_m3, &p.density_kgpm3) < 1.5);
615 }
616 if let Some(printed) = &p.speed_of_sound_mps {
617 check(air.speed_of_sound_m_s, printed, "a", true);
618 }
619 if let Some(printed) = &p.dynamic_viscosity_pas {
620 check(
621 air.dynamic_viscosity_pa_s,
622 printed,
623 "μ",
624 !tables_omit_m_over_m0,
625 );
626 if tables_omit_m_over_m0 {
627 let as_printed = sutherland_viscosity_pa_s(t_m);
628 check(as_printed, printed, "μ(T_M)", true);
629 }
630 }
631 if let Some(printed) = &p.kinematic_viscosity_m2ps {
632 let nu = air.kinematic_viscosity_m2_s();
633 check(nu, printed, "ν", !tables_omit_m_over_m0);
634 }
635 }
636 assert_eq!(compared, 32 * 5 + 31 * 3 + 3);
639 }
640
641 #[test]
644 fn fixture_si_values_are_the_printed_values() {
645 for row in table().rows {
646 let (p, si) = (&row.printed, &row.si);
647 let parsed = |text: &str| parse_printed(text).0;
648 let z = row.geometric_altitude_m;
649 assert_eq!(
650 si.geopotential_altitude_m,
651 parsed(&p.geopotential_altitude_m),
652 "{z}"
653 );
654 assert_eq!(si.temperature_k, parsed(&p.temperature_k), "{z}");
655 assert_eq!(
656 si.molecular_scale_temperature_k,
657 parsed(&p.molecular_scale_temperature_k),
658 "{z}"
659 );
660 let (mb, count) = parse_printed(&p.pressure_mb);
661 assert!((si.pressure_pa - 100.0 * mb).abs() <= 1e-6 * count, "{z}");
662 assert_eq!(si.density_kgpm3, parsed(&p.density_kgpm3), "{z}");
663 for (si_value, printed) in [
664 (si.speed_of_sound_mps, &p.speed_of_sound_mps),
665 (si.dynamic_viscosity_pas, &p.dynamic_viscosity_pas),
666 (si.kinematic_viscosity_m2ps, &p.kinematic_viscosity_m2ps),
667 ] {
668 assert_eq!(si_value, printed.as_deref().map(parsed), "{z}");
669 }
670 }
671 }
672
673 #[test]
676 fn geometric_11_km_matches_the_1976_tables() {
677 let air = Ussa76::standard().sample(11_000.0).unwrap().air;
678 assert!(
679 (air.temperature_k - 216.774).abs() < 5e-4,
680 "{}",
681 air.temperature_k
682 );
683 assert!(
684 (air.pressure_pa - 22_699.0).abs() <= 1.0,
685 "{}",
686 air.pressure_pa
687 );
688 assert!((air.pressure_pa - 22_699.96).abs() < 0.01);
689 let base = Ussa76::standard()
691 .sample(geometric_from_geopotential_m(11_000.0).unwrap())
692 .unwrap()
693 .air;
694 assert!((base.temperature_k - 216.65).abs() < 1e-9);
695 assert!(
696 (base.pressure_pa - 22_632.06).abs() < 0.01,
697 "{}",
698 base.pressure_pa
699 );
700 }
701
702 #[test]
705 fn fifty_km_is_270_65_k_and_79_779_pa() {
706 let model = Ussa76::standard();
707 let air = model.sample(50_000.0).unwrap().air;
708 assert!((air.temperature_k - 270.65).abs() < 1e-9);
709 assert!(
710 (air.pressure_pa - 79.779).abs() < 5e-4,
711 "{}",
712 air.pressure_pa
713 );
714 let seventy = model.sample(70_000.0).unwrap().air;
715 assert!(
716 (seventy.temperature_k - 219.585).abs() < 5e-4,
717 "{}",
718 seventy.temperature_k
719 );
720 }
721
722 #[test]
725 fn sea_level_viscosity_is_1_7894e_5() {
726 let mu = Ussa76::standard()
727 .sample(0.0)
728 .unwrap()
729 .air
730 .dynamic_viscosity_pa_s;
731 assert!((mu - 1.7894e-5).abs() <= 0.5e-9, "{mu}");
732 let with_110 = SUTHERLAND_BETA * 288.15_f64.powf(1.5) / (288.15 + 110.0);
733 assert!((with_110 - 1.7912e-5).abs() < 1e-9);
734 }
735
736 #[derive(Deserialize)]
737 struct ConstantsFixture {
738 constants: Constants,
739 layers: Layers,
740 table_8: Table8,
741 }
742
743 #[derive(Deserialize)]
744 struct Constants {
745 gas_constant_jpkmolk: Value,
746 sea_level_molecular_weight_kgpkmol: Value,
747 g0_mps2: Value,
748 earth_radius_m: Value,
749 sea_level_pressure_pa: Value,
750 sea_level_temperature_k: Value,
751 sutherland_beta_kgpsmk12: Value,
752 sutherland_constant_k: Value,
753 ratio_of_specific_heats: Value,
754 kinetic_temperature_86_km_k: Value,
755 }
756
757 #[derive(Deserialize)]
758 struct Value {
759 value: f64,
760 }
761
762 #[derive(Deserialize)]
763 struct Layers {
764 rows: Vec<LayerRow>,
765 }
766
767 #[derive(Deserialize)]
768 struct LayerRow {
769 base_geopotential_km: f64,
770 gradient_kpkm: Option<f64>,
771 }
772
773 #[derive(Deserialize)]
774 struct Table8 {
775 by_geometric: Vec<Table8Row>,
776 }
777
778 #[derive(Deserialize)]
779 struct Table8Row {
780 geometric_altitude_m: f64,
781 m_over_m0: f64,
782 }
783
784 #[test]
786 fn constants_match_the_transcription() {
787 let fixture: ConstantsFixture = serde_json::from_str(include_str!(
788 "../../../validation/fixtures/atmosphere/ussa76-constants.json"
789 ))
790 .unwrap();
791 let c = fixture.constants;
792 assert_eq!(GAS_CONSTANT_J_PER_KMOL_K, c.gas_constant_jpkmolk.value);
793 assert_eq!(
794 SEA_LEVEL_MOLECULAR_WEIGHT_KG_PER_KMOL,
795 c.sea_level_molecular_weight_kgpkmol.value
796 );
797 assert_eq!(STANDARD_GRAVITY_MPS2, c.g0_mps2.value);
798 assert_eq!(EARTH_RADIUS_M, c.earth_radius_m.value);
799 assert_eq!(SEA_LEVEL_PRESSURE_PA, c.sea_level_pressure_pa.value);
800 assert_eq!(SEA_LEVEL_TEMPERATURE_K, c.sea_level_temperature_k.value);
801 assert_eq!(SUTHERLAND_BETA, c.sutherland_beta_kgpsmk12.value);
802 assert_eq!(SUTHERLAND_S_K, c.sutherland_constant_k.value);
803 assert_eq!(RATIO_OF_SPECIFIC_HEATS, c.ratio_of_specific_heats.value);
804
805 let rows = fixture.layers.rows;
806 assert_eq!(rows.len(), LAYERS.len() + 1);
807 for (row, &(base, lapse)) in rows.iter().zip(&LAYERS) {
808 assert!((row.base_geopotential_km * 1000.0 - base).abs() < 1e-9);
809 assert!((row.gradient_kpkm.unwrap() / 1000.0 - lapse).abs() < 1e-15);
810 }
811 assert!((rows[7].base_geopotential_km - 84.852).abs() < 1e-12);
812
813 let table_8 = fixture.table_8.by_geometric;
814 assert_eq!(table_8.len(), MOLECULAR_WEIGHT_RATIO.len());
815 for (i, row) in table_8.iter().enumerate() {
816 let z = MOLECULAR_WEIGHT_TABLE_START_M + MOLECULAR_WEIGHT_TABLE_STEP_M * i as f64;
817 assert_eq!(row.geometric_altitude_m, z);
818 assert_eq!(row.m_over_m0, MOLECULAR_WEIGHT_RATIO[i]);
819 assert_eq!(molecular_weight_ratio(z), row.m_over_m0);
820 }
821
822 let top = Ussa76::standard().sample(MAX_HEIGHT_M).unwrap().air;
826 assert!((top.temperature_k - c.kinetic_temperature_86_km_k.value).abs() <= 1e-4);
827 }
828
829 fn assert_hydrostatic(model: &Ussa76, z: f64) {
832 let dz = 0.5;
833 let above = model.sample(z + dz).unwrap().air.pressure_pa;
834 let below = model.sample(z - dz).unwrap().air.pressure_pa;
835 let air = model.sample(z).unwrap().air;
836 let g = STANDARD_GRAVITY_MPS2 * (EARTH_RADIUS_M / (EARTH_RADIUS_M + z)).powi(2);
837 let gradient = (above - below) / (2.0 * dz);
838 let expected = -air.density_kg_m3 * g;
839 assert!(
840 ((gradient - expected) / expected).abs() < 1e-6,
841 "z = {z}: {gradient} vs {expected}"
842 );
843 }
844
845 #[test]
846 fn standard_is_hydrostatic_in_every_layer() {
847 let model = Ussa76::standard();
848 for z in [
849 -4_000.0, 1_000.0, 15_000.0, 25_000.0, 40_000.0, 49_000.0, 60_000.0, 78_000.0,
850 ] {
851 assert_hydrostatic(&model, z);
852 }
853 }
854
855 #[test]
859 fn pressure_altitude_inverts_the_1976_tables() {
860 let model = Ussa76::standard();
861 for row in table().rows {
862 let p = &row.printed;
863 let (pressure_mb, pressure_count_mb) = parse_printed(&p.pressure_mb);
864 let (h, h_count) = parse_printed(&p.geopotential_altitude_m);
865 let (t_m, _) = parse_printed(&p.molecular_scale_temperature_k);
866 let pressure_pa = 100.0 * pressure_mb;
867 let resolved = 0.5 * h_count
868 + 0.5 * 100.0 * pressure_count_mb * t_m / (pressure_pa * HYDROSTATIC_K_PER_M);
869 let computed = model.pressure_altitude_m(pressure_pa).unwrap();
870 assert!(
871 (computed - h).abs() <= resolved,
872 "{} m: {computed} m′ against {h} m′ printed, {resolved} m′ resolved",
873 row.geometric_altitude_m
874 );
875 }
876 }
877
878 #[test]
881 fn pressure_altitude_is_the_altimeter_formula_in_the_troposphere() {
882 let model = Ussa76::standard();
883 assert_eq!(
884 model.pressure_altitude_m(SEA_LEVEL_PRESSURE_PA).unwrap(),
885 0.0
886 );
887 let exponent = 0.0065 / HYDROSTATIC_K_PER_M;
888 assert!((exponent - 0.190_263).abs() < 5e-7, "{exponent}");
889 assert!((SEA_LEVEL_TEMPERATURE_K / 0.0065 - 44_330.8).abs() < 0.05);
890 for pressure_pa in [100_000.0, 86_444.0, 60_000.0, 30_000.0] {
891 let formula = 44_330.769 * (1.0 - (pressure_pa / SEA_LEVEL_PRESSURE_PA).powf(exponent));
892 let computed = model.pressure_altitude_m(pressure_pa).unwrap();
893 assert!(
894 (computed - formula).abs() < 1e-3,
895 "{pressure_pa} Pa: {computed} vs {formula}"
896 );
897 }
898 let pad = model.pressure_altitude_m(86_000.0).unwrap();
901 let top = model.pressure_altitude_m(58_000.0).unwrap();
902 assert_eq!(
903 format!("{pad:.1} {top:.1} {:.1}", top - pad),
904 "1361.8 4464.4 3102.6"
905 );
906 let warm = Ussa76::with_offset(20.0, SEA_LEVEL_PRESSURE_PA).unwrap();
907 let climbed = warm.pressure_altitude_m(58_000.0).unwrap()
908 - warm.pressure_altitude_m(86_000.0).unwrap();
909 assert_eq!(format!("{climbed:.1}"), "3318.0");
910 assert!((climbed / (top - pad) - 308.15 / 288.15).abs() < 1e-12);
911 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
912 assert!(matches!(
913 model.pressure_altitude_m(bad),
914 Err(AtmosError::Domain {
915 what: "pressure (Pa)",
916 ..
917 })
918 ));
919 }
920 }
921
922 proptest! {
923 #[test]
926 fn pressure_altitude_inverts_the_pressure(
927 offset in -60.0..60.0_f64,
928 p0 in 60_000.0..120_000.0_f64,
929 z in -4_900.0..95_000.0_f64,
930 ) {
931 let model = Ussa76::with_offset(offset, p0).unwrap();
932 let h = geopotential_from_geometric_m(z).unwrap();
933 let back = model.pressure_altitude_m(model.pressure_pa(h)).unwrap();
934 prop_assert!((back - h).abs() < 1e-6 * (1.0 + h.abs() * 1e-3), "{h} m′ back as {back}");
935 }
936
937 #[test]
938 fn offset_atmospheres_are_hydrostatic_and_offset(
939 offset in -60.0..60.0_f64,
940 p0 in 60_000.0..120_000.0_f64,
941 z in -4_000.0..79_000.0_f64,
942 ) {
943 let model = Ussa76::with_offset(offset, p0).unwrap();
944 let at_corner = LAYERS[1..].iter().any(|&(base, _)| {
947 (z - geometric_from_geopotential_m(base).unwrap()).abs() < 1.0
948 });
949 if !at_corner {
950 assert_hydrostatic(&model, z);
951 }
952 let standard = Ussa76::standard().sample(z).unwrap().air;
953 let air = model.sample(z).unwrap().air;
954 prop_assert!((air.temperature_k - standard.temperature_k - offset).abs() < 1e-9);
955 prop_assert!(air.density_kg_m3 > 0.0 && air.speed_of_sound_m_s > 0.0);
956 }
957
958 #[test]
959 fn anchored_atmosphere_passes_through_its_anchor(
960 z in -500.0..5_000.0_f64,
961 temperature in 230.0..330.0_f64,
962 pressure in 50_000.0..108_000.0_f64,
963 ) {
964 let model = Ussa76::anchored(z, temperature, pressure).unwrap();
965 let air = model.sample(z).unwrap().air;
966 prop_assert!((air.temperature_k - temperature).abs() < 1e-9);
967 prop_assert!(((air.pressure_pa - pressure) / pressure).abs() < 1e-12);
968 }
969
970 #[test]
971 fn layers_join_continuously(offset in -60.0..60.0_f64) {
972 let model = Ussa76::with_offset(offset, SEA_LEVEL_PRESSURE_PA).unwrap();
973 for &(base, _) in &LAYERS[1..] {
974 let z = geometric_from_geopotential_m(base).unwrap();
975 let below = model.sample(z - 1e-6).unwrap().air;
976 let above = model.sample(z + 1e-6).unwrap().air;
977 prop_assert!((below.temperature_k - above.temperature_k).abs() < 1e-7);
978 prop_assert!(((below.pressure_pa - above.pressure_pa) / above.pressure_pa).abs() < 1e-9);
979 }
980 }
981
982 #[test]
983 fn geopotential_round_trips(z in -100_000.0..1.0e7_f64) {
984 let h = geopotential_from_geometric_m(z).unwrap();
985 let back = geometric_from_geopotential_m(h).unwrap();
986 prop_assert!((back - z).abs() <= 1e-9 * z.abs().max(1.0));
987 }
988 }
989
990 #[test]
991 fn standard_offset_is_the_standard() {
992 assert_eq!(
993 Ussa76::with_offset(0.0, SEA_LEVEL_PRESSURE_PA).unwrap(),
994 Ussa76::standard()
995 );
996 assert_eq!(Ussa76::default(), Ussa76::standard());
997 let hot = Ussa76::anchored(1400.0, 288.15 - 6.5 * 1.4 + 20.0, 85_000.0).unwrap();
999 assert!((hot.temperature_offset_k() - 20.0).abs() < 0.01);
1000 assert!(hot.sea_level_pressure_pa() > 95_000.0 && hot.sea_level_pressure_pa() < 105_000.0);
1001 }
1002
1003 #[test]
1004 fn extrapolation_is_flagged_and_finite() {
1005 let model = Ussa76::standard();
1006 assert_eq!(model.sample(MIN_HEIGHT_M).unwrap().extrapolated, None);
1007 assert_eq!(model.sample(MAX_HEIGHT_M).unwrap().extrapolated, None);
1008 let low = model.sample(-6_000.0).unwrap();
1009 assert_eq!(low.extrapolated, Some(Side::Below));
1010 assert!(low.air.temperature_k > 320.65 && low.air.pressure_pa > 1.9e5);
1011 let high = model.sample(120_000.0).unwrap();
1012 assert_eq!(high.extrapolated, Some(Side::Above));
1013 assert!(high.air.pressure_pa > 0.0 && high.air.pressure_pa < 0.02);
1014 assert!((high.air.temperature_k - 186.8673).abs() <= 1e-4);
1015 let far = model.sample(1.0e9).unwrap().air;
1017 assert!(far.pressure_pa >= 0.0 && far.density_kg_m3 >= 0.0);
1018 assert!(model.sample(f64::NAN).is_err());
1019 assert!(model.sample(-EARTH_RADIUS_M).is_err());
1020 assert!(geometric_from_geopotential_m(EARTH_RADIUS_M).is_err());
1021 }
1022
1023 #[test]
1024 fn invalid_offsets_are_rejected_and_serde_round_trips() {
1025 assert!(Ussa76::with_offset(-190.0, SEA_LEVEL_PRESSURE_PA).is_err());
1026 assert!(Ussa76::with_offset(f64::NAN, SEA_LEVEL_PRESSURE_PA).is_err());
1027 assert!(Ussa76::with_offset(10.0, 0.0).is_err());
1028 assert!(Ussa76::anchored(0.0, -1.0, 100_000.0).is_err());
1029 assert!(Ussa76::anchored(0.0, 288.0, f64::INFINITY).is_err());
1030 let model = Ussa76::with_offset(12.5, 98_000.0).unwrap();
1031 let json = serde_json::to_string(&model).unwrap();
1032 assert_eq!(
1033 json,
1034 r#"{"temperature_offset_k":12.5,"sea_level_pressure_pa":98000.0}"#
1035 );
1036 assert_eq!(serde_json::from_str::<Ussa76>(&json).unwrap(), model);
1037 let bad = r#"{"temperature_offset_k":-500.0,"sea_level_pressure_pa":98000.0}"#;
1038 assert!(serde_json::from_str::<Ussa76>(bad).is_err());
1039 }
1040}