1use hpr_core::interp::Side;
44use serde::{Deserialize, Serialize};
45
46use crate::air::{AirSample, Atmosphere};
47use crate::error::{AtmosError, finite, positive};
48use crate::moist::{check_relative_humidity, moist_air_unchecked, saturation_vapour_pressure_pa};
49use crate::ussa76::{DRY_AIR_GAS_CONSTANT_J_PER_KG_K, Ussa76, geometric_from_geopotential_m};
50use crate::wind::{LayeredWind, WindInterpolation, WindLevel};
51use hpr_core::gravity::STANDARD_GRAVITY_MPS2;
52
53const FILL_ITERATIONS: usize = 8;
57
58#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct SoundingLevel {
62 pub height_msl_m: f64,
64 pub temperature_k: f64,
66 #[serde(default)]
68 pub pressure_pa: Option<f64>,
69 #[serde(default)]
72 pub relative_humidity: Option<f64>,
73 #[serde(default)]
75 pub wind_speed_m_s: Option<f64>,
76 #[serde(default)]
78 pub wind_direction_from_rad: Option<f64>,
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86#[serde(try_from = "SoundingProfileData", into = "SoundingProfileData")]
87pub struct SoundingProfile {
88 levels: Vec<SoundingLevel>,
89 latitude_rad: f64,
90 wind_interpolation: WindInterpolation,
91 gravity_ratio: f64,
93 radius_m: f64,
94 heights_m: Vec<f64>,
95 geopotentials_m: Vec<f64>,
97 temperatures_k: Vec<f64>,
98 pressures_pa: Vec<f64>,
99 humidities: Vec<f64>,
101 below: Ussa76,
102 above: Ussa76,
103 top_vapour_fraction: f64,
105 wind: Option<LayeredWind>,
106}
107
108#[derive(Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110struct SoundingProfileData {
111 levels: Vec<SoundingLevel>,
112 latitude_rad: f64,
113 #[serde(default)]
114 wind_interpolation: WindInterpolation,
115 #[serde(default, skip_serializing)]
118 #[expect(
119 dead_code,
120 reason = "read only so that the key is not refused as unknown"
121 )]
122 tool: Option<serde::de::IgnoredAny>,
123}
124
125impl TryFrom<SoundingProfileData> for SoundingProfile {
126 type Error = AtmosError;
127
128 fn try_from(data: SoundingProfileData) -> Result<Self, AtmosError> {
129 SoundingProfile::new(data.levels, data.latitude_rad, data.wind_interpolation)
130 }
131}
132
133impl From<SoundingProfile> for SoundingProfileData {
134 fn from(profile: SoundingProfile) -> Self {
135 SoundingProfileData {
136 levels: profile.levels,
137 latitude_rad: profile.latitude_rad,
138 wind_interpolation: profile.wind_interpolation,
139 tool: None,
140 }
141 }
142}
143
144fn vapour(humidity: Option<f64>, t: f64) -> f64 {
146 humidity.map_or(0.0, |u| u * saturation_vapour_pressure_pa(t))
147}
148
149fn virtual_temperature(t: f64, p: f64, e: f64) -> f64 {
151 let air = moist_air_unchecked(t, p, e);
152 p / (DRY_AIR_GAS_CONSTANT_J_PER_KG_K * air.density_kg_m3)
153}
154
155fn inverse_log_mean(a: f64, b: f64) -> f64 {
158 let r = (b - a) / a;
159 if r == 0.0 {
160 1.0 / a
161 } else {
162 r.ln_1p() / (r * a)
163 }
164}
165
166impl SoundingProfile {
167 pub fn new(
183 levels: Vec<SoundingLevel>,
184 latitude_rad: f64,
185 wind_interpolation: WindInterpolation,
186 ) -> Result<Self, AtmosError> {
187 let (gamma_s, radius_m) = wmo_gravity_and_radius(latitude_rad)?;
188 let gravity_ratio = gamma_s / GAMMA_45_MPS2;
189 let Some(first) = levels.first() else {
190 return Err(AtmosError::NoLevels);
191 };
192 let has_humidity = first.relative_humidity.is_some();
193 let has_wind = first.wind_speed_m_s.is_some() || first.wind_direction_from_rad.is_some();
194 if first.pressure_pa.is_none() {
195 return Err(AtmosError::MissingBasePressure);
196 }
197
198 let n = levels.len();
199 let mut heights_m = Vec::with_capacity(n);
200 let mut geopotentials_m = Vec::with_capacity(n);
201 let mut temperatures_k = Vec::with_capacity(n);
202 let mut humidities = Vec::with_capacity(if has_humidity { n } else { 0 });
203 let mut wind_levels = Vec::with_capacity(if has_wind { n } else { 0 });
204 for (index, level) in levels.iter().enumerate() {
205 let z = finite("sounding level height (m)", level.height_msl_m)?;
206 if heights_m.last().is_some_and(|&previous| z <= previous) {
207 return Err(AtmosError::HeightsNotIncreasing { index });
208 }
209 heights_m.push(z);
210 geopotentials_m.push(wmo_geopotential(gravity_ratio, radius_m, z)?);
211 temperatures_k.push(positive("sounding temperature (K)", level.temperature_k)?);
212 if let Some(p) = level.pressure_pa {
213 positive("sounding pressure (Pa)", p)?;
214 }
215 match (has_humidity, level.relative_humidity) {
216 (true, Some(u)) => humidities.push(check_relative_humidity(u)?),
217 (false, None) => {}
218 _ => {
219 return Err(AtmosError::IncompleteColumn {
220 index,
221 column: "relative humidity",
222 });
223 }
224 }
225 match (
226 has_wind,
227 level.wind_speed_m_s,
228 level.wind_direction_from_rad,
229 ) {
230 (true, Some(speed), Some(direction)) => wind_levels.push(WindLevel {
231 height_msl_m: z,
232 speed_m_s: speed,
233 direction_from_rad: direction,
234 }),
235 (false, None, None) => {}
236 _ => {
237 return Err(AtmosError::IncompleteColumn {
238 index,
239 column: "wind speed and direction",
240 });
241 }
242 }
243 }
244
245 let humidity_at = |i: usize| humidities.get(i).copied();
246 let mut pressures_pa: Vec<f64> = Vec::with_capacity(n);
247 for (i, level) in levels.iter().enumerate() {
248 let pressure = match (level.pressure_pa, pressures_pa.last()) {
249 (Some(p), Some(&below)) if p >= below => {
250 return Err(AtmosError::PressureNotDecreasing {
251 index: i,
252 pressure_pa: p,
253 below_pa: below,
254 });
255 }
256 (Some(p), _) => p,
257 (None, Some(&below)) => {
258 let (t0, t1) = (temperatures_k[i - 1], temperatures_k[i]);
259 let dh = geopotentials_m[i] - geopotentials_m[i - 1];
260 let tv0 = virtual_temperature(t0, below, vapour(humidity_at(i - 1), t0));
261 let k = STANDARD_GRAVITY_MPS2 / DRY_AIR_GAS_CONSTANT_J_PER_KG_K * dh;
262 let mut p = below * (-k * inverse_log_mean(t0, t1)).exp();
263 if has_humidity {
264 for _ in 0..FILL_ITERATIONS {
265 let tv1 = virtual_temperature(t1, p, vapour(humidity_at(i), t1));
266 p = below * (-k * inverse_log_mean(tv0, tv1)).exp();
267 }
268 }
269 positive("filled sounding pressure (Pa)", p)?
270 }
271 (None, None) => return Err(AtmosError::MissingBasePressure),
273 };
274 pressures_pa.push(pressure);
275 }
276
277 let last = n - 1;
278 let below = Ussa76::anchored(
279 geometric_from_geopotential_m(geopotentials_m[0])?,
280 temperatures_k[0],
281 pressures_pa[0],
282 )?;
283 let above = Ussa76::anchored(
284 geometric_from_geopotential_m(geopotentials_m[last])?,
285 temperatures_k[last],
286 pressures_pa[last],
287 )?;
288 let top_vapour_fraction =
289 (vapour(humidity_at(last), temperatures_k[last]) / pressures_pa[last]).min(1.0);
290 let wind = if has_wind {
291 Some(LayeredWind::new(wind_levels, wind_interpolation)?)
292 } else {
293 None
294 };
295 Ok(SoundingProfile {
296 levels,
297 latitude_rad,
298 wind_interpolation,
299 gravity_ratio,
300 radius_m,
301 heights_m,
302 geopotentials_m,
303 temperatures_k,
304 pressures_pa,
305 humidities,
306 below,
307 above,
308 top_vapour_fraction,
309 wind,
310 })
311 }
312
313 pub fn levels(&self) -> &[SoundingLevel] {
315 &self.levels
316 }
317
318 pub fn latitude_rad(&self) -> f64 {
320 self.latitude_rad
321 }
322
323 pub fn pressures_pa(&self) -> &[f64] {
325 &self.pressures_pa
326 }
327
328 pub fn wind(&self) -> Option<&LayeredWind> {
330 self.wind.as_ref()
331 }
332
333 pub fn sample(&self, height_msl_m: f64) -> Result<AirSample, AtmosError> {
340 let z = finite("height (m)", height_msl_m)?;
341 let n = self.heights_m.len();
342 let humidity = |i: usize| self.humidities.get(i).copied();
343 let geopotential = wmo_geopotential(self.gravity_ratio, self.radius_m, z)?;
344 if z < self.heights_m[0] {
345 let air = self
346 .below
347 .sample(geometric_from_geopotential_m(geopotential)?)?
348 .air;
349 let e = vapour(humidity(0), air.temperature_k);
350 return Ok(AirSample {
351 air: moist_air_unchecked(air.temperature_k, air.pressure_pa, e),
352 extrapolated: Some(Side::Below),
353 });
354 }
355 if z > self.heights_m[n - 1] {
356 let air = self
357 .above
358 .sample(geometric_from_geopotential_m(geopotential)?)?
359 .air;
360 let e = (self.top_vapour_fraction * air.pressure_pa)
361 .min(saturation_vapour_pressure_pa(air.temperature_k));
362 return Ok(AirSample {
363 air: moist_air_unchecked(air.temperature_k, air.pressure_pa, e),
364 extrapolated: Some(Side::Above),
365 });
366 }
367 let upper = self.heights_m.partition_point(|&h| h <= z);
369 if upper >= n {
370 let t = self.temperatures_k[n - 1];
371 let e = vapour(humidity(n - 1), t);
372 return Ok(AirSample {
373 air: moist_air_unchecked(t, self.pressures_pa[n - 1], e),
374 extrapolated: None,
375 });
376 }
377 let i = upper - 1;
378 let (z0, z1) = (self.geopotentials_m[i], self.geopotentials_m[upper]);
379 let fraction = ((geopotential - z0) / (z1 - z0)).clamp(0.0, 1.0);
380 let (t0, t1) = (self.temperatures_k[i], self.temperatures_k[upper]);
381 let t = t0 + fraction * (t1 - t0);
382 let (p0, p1) = (self.pressures_pa[i], self.pressures_pa[upper]);
383 let r = (t1 - t0) / t0;
384 let shape = if r == 0.0 {
385 fraction
386 } else {
387 (fraction * r).ln_1p() / r.ln_1p()
388 };
389 let p = p0 * ((p1 / p0).ln() * shape).exp();
390 let e = match (humidity(i), humidity(upper)) {
391 (Some(u0), Some(u1)) => (u0 + fraction * (u1 - u0)) * saturation_vapour_pressure_pa(t),
392 _ => 0.0,
393 };
394 Ok(AirSample {
395 air: moist_air_unchecked(t, p, e),
396 extrapolated: None,
397 })
398 }
399}
400
401impl Atmosphere for SoundingProfile {
402 fn air(&self, height_msl_m: f64) -> Result<AirSample, AtmosError> {
403 self.sample(height_msl_m)
404 }
405}
406
407const GAMMA_45_MPS2: f64 = STANDARD_GRAVITY_MPS2;
409
410fn wmo_geopotential(gravity_ratio: f64, radius_m: f64, height_m: f64) -> Result<f64, AtmosError> {
412 if height_m <= -radius_m {
413 return Err(AtmosError::Domain {
414 what: "geometric height (m)",
415 value: height_m,
416 });
417 }
418 Ok(gravity_ratio * radius_m * height_m / (radius_m + height_m))
419}
420
421fn wmo_gravity_and_radius(latitude_rad: f64) -> Result<(f64, f64), AtmosError> {
423 let phi = finite("latitude (rad)", latitude_rad)?;
424 if phi.abs() > std::f64::consts::FRAC_PI_2 {
425 return Err(AtmosError::Domain {
426 what: "latitude (rad)",
427 value: phi,
428 });
429 }
430 let s2 = phi.sin().powi(2);
431 let gamma_s = 9.780_325 * (1.0 + 0.001_931_85 * s2) / (1.0 - 0.006_694_35 * s2).sqrt();
432 let radius_m = 6_378_137.0 / (1.006_803 - 0.006_706 * s2);
433 Ok((gamma_s, radius_m))
434}
435
436pub fn wmo_geopotential_from_geometric_m(
452 height_msl_m: f64,
453 latitude_rad: f64,
454) -> Result<f64, AtmosError> {
455 let (gamma_s, radius) = wmo_gravity_and_radius(latitude_rad)?;
456 let z = finite("geometric height (m)", height_msl_m)?;
457 wmo_geopotential(gamma_s / GAMMA_45_MPS2, radius, z)
458}
459
460pub fn geometric_from_wmo_geopotential_m(
469 geopotential_height_m: f64,
470 latitude_rad: f64,
471) -> Result<f64, AtmosError> {
472 let (gamma_s, radius) = wmo_gravity_and_radius(latitude_rad)?;
473 let scaled =
474 finite("geopotential height (gpm)", geopotential_height_m)? * GAMMA_45_MPS2 / gamma_s;
475 if scaled >= radius {
476 return Err(AtmosError::Domain {
477 what: "geopotential height (gpm)",
478 value: geopotential_height_m,
479 });
480 }
481 Ok(radius * scaled / (radius - scaled))
482}
483
484#[cfg(test)]
485mod tests {
486 use hpr_core::gravity::NormalGravity;
487
488 use super::*;
489 use crate::moist::moist_air;
490 use crate::wind::Wind;
491
492 const LATITUDE_RAD: f64 = 32.99 * std::f64::consts::PI / 180.0;
494
495 fn level(z: f64, t: f64, p: Option<f64>, u: Option<f64>) -> SoundingLevel {
496 SoundingLevel {
497 height_msl_m: z,
498 temperature_k: t,
499 pressure_pa: p,
500 relative_humidity: u,
501 wind_speed_m_s: None,
502 wind_direction_from_rad: None,
503 }
504 }
505
506 fn profile_at(levels: Vec<SoundingLevel>, latitude_rad: f64) -> SoundingProfile {
507 SoundingProfile::new(levels, latitude_rad, WindInterpolation::default()).unwrap()
508 }
509
510 fn standard_height(z: f64, latitude_rad: f64) -> f64 {
513 geometric_from_geopotential_m(wmo_geopotential_from_geometric_m(z, latitude_rad).unwrap())
514 .unwrap()
515 }
516
517 fn integrate_pressure(profile: &SoundingProfile, z0: f64, p0: f64, z1: f64) -> f64 {
523 let gravity = NormalGravity::wgs84();
524 let steps = 20_000;
525 let dz = (z1 - z0) / f64::from(steps);
526 let vapour_pressure = |z: f64| {
527 let air = profile.sample(z).unwrap().air;
529 let deficit = 1.0
530 - air.density_kg_m3 * DRY_AIR_GAS_CONSTANT_J_PER_KG_K * air.temperature_k
531 / air.pressure_pa;
532 let x = deficit
533 / (1.0
534 - crate::moist::WATER_VAPOUR_MOLECULAR_WEIGHT_KG_PER_KMOL
535 / crate::ussa76::SEA_LEVEL_MOLECULAR_WEIGHT_KG_PER_KMOL);
536 (air.temperature_k, x * air.pressure_pa)
537 };
538 let weight = |z: f64, p: f64| {
539 let (t, e) = vapour_pressure(z);
540 let g = gravity.taylor_mps2(profile.latitude_rad(), z).unwrap();
541 moist_air_unchecked(t, p, e.max(0.0)).density_kg_m3 * g
542 };
543 let mut p = p0;
544 for k in 0..steps {
545 let z = z0 + f64::from(k) * dz;
546 let half = p - 0.5 * dz * weight(z, p);
547 p -= dz * weight(z + 0.5 * dz, half);
548 }
549 p
550 }
551
552 #[test]
556 fn sounding_temperature_overrides_standard_lapse() {
557 let field = level(1400.0, 308.15, Some(85_500.0), Some(0.2));
558 let profile = profile_at(
559 vec![
560 field,
561 level(2400.0, 300.15, None, Some(0.15)),
562 level(3400.0, 301.15, None, Some(0.10)),
563 ],
564 LATITUDE_RAD,
565 );
566 let standard_lapse = Ussa76::anchored(1400.0, 308.15, 85_500.0).unwrap();
567
568 let mid = profile.sample(2900.0).unwrap();
571 assert_eq!(mid.extrapolated, None);
572 let h = |z| wmo_geopotential_from_geometric_m(z, LATITUDE_RAD).unwrap();
573 let fraction = (h(2900.0) - h(2400.0)) / (h(3400.0) - h(2400.0));
574 assert!((fraction - 0.5 - 3.9e-5).abs() < 1e-6);
575 assert!((mid.air.temperature_k - (300.15 + fraction)).abs() < 1e-12);
576 let top = profile.sample(3400.0).unwrap().air;
577 assert!((top.temperature_k - 301.15).abs() < 1e-12);
578 let lapse_top = standard_lapse.sample(3400.0).unwrap().air;
579 assert!((lapse_top.temperature_k - (308.15 - 6.5 * 2.0)).abs() < 0.05);
580 assert!(top.temperature_k - lapse_top.temperature_k > 5.0);
581
582 for (z, p) in [
588 (2400.0, profile.pressures_pa()[1]),
589 (3400.0, profile.pressures_pa()[2]),
590 ] {
591 let integrated = integrate_pressure(&profile, 1400.0, 85_500.0, z);
592 assert!(
593 ((p - integrated) / integrated).abs() < 2e-5,
594 "{z}: {p} vs {integrated}"
595 );
596 }
597
598 let dry = moist_air(top.temperature_k, top.pressure_pa, 0.0).unwrap();
600 let lighter = 1.0 - top.density_kg_m3 / dry.density_kg_m3;
601 assert!((0.0015..0.0035).contains(&lighter), "{lighter}");
602 assert!((top.density_kg_m3 / lapse_top.density_kg_m3 - 1.0).abs() > 0.01);
604 }
605
606 #[test]
611 fn filled_pressures_are_hydrostatic_for_dry_air() {
612 for latitude_deg in [0.0_f64, 32.99, 90.0] {
613 let profile = profile_at(
614 vec![
615 level(1400.0, 308.15, Some(85_500.0), None),
616 level(2400.0, 300.15, None, None),
617 level(3400.0, 301.15, None, None),
618 ],
619 latitude_deg.to_radians(),
620 );
621 for (z, p) in [
622 (2400.0, profile.pressures_pa()[1]),
623 (3400.0, profile.pressures_pa()[2]),
624 ] {
625 let integrated = integrate_pressure(&profile, 1400.0, 85_500.0, z);
626 assert!(
627 ((p - integrated) / integrated).abs() < 2e-8,
628 "{latitude_deg}° {z}: {p} vs {integrated}"
629 );
630 }
631 }
632 }
633
634 #[test]
638 fn pressure_falls_faster_where_gravity_is_stronger() {
639 let levels = vec![
640 level(1400.0, 300.0, Some(85_500.0), None),
641 level(3400.0, 287.0, None, None),
642 ];
643 let equator = profile_at(levels.clone(), 0.0).pressures_pa()[1];
644 let pole = profile_at(levels, std::f64::consts::FRAC_PI_2).pressures_pa()[1];
645 let difference = pole / equator - 1.0;
646 assert!((-0.0014..-0.0011).contains(&difference), "{difference}");
647 }
648
649 #[test]
653 fn standard_levels_reproduce_the_standard_between_them() {
654 let standard = Ussa76::standard();
655 for latitude_deg in [0.0_f64, 45.0, 80.0] {
656 let latitude = latitude_deg.to_radians();
657 let heights: Vec<f64> = [0.0, 5_000.0, 11_000.0, 15_000.0, 20_000.0, 26_000.0]
658 .iter()
659 .map(|&geopotential| geometric_from_wmo_geopotential_m(geopotential, latitude))
660 .collect::<Result<_, _>>()
661 .unwrap();
662 let levels = heights
663 .iter()
664 .map(|&z| {
665 let air = standard.sample(standard_height(z, latitude)).unwrap().air;
666 level(z, air.temperature_k, Some(air.pressure_pa), None)
667 })
668 .collect();
669 let profile = profile_at(levels, latitude);
670 for z in heights
671 .iter()
672 .copied()
673 .chain([2_500.0, 8_000.0, 13_000.0, 17_500.0, 23_000.0])
674 {
675 let ours = profile.sample(z).unwrap().air;
676 let reference = standard.sample(standard_height(z, latitude)).unwrap().air;
677 assert!(
678 (ours.temperature_k - reference.temperature_k).abs() < 1e-10,
679 "{latitude_deg}° {z}"
680 );
681 for (a, b) in [
682 (ours.pressure_pa, reference.pressure_pa),
683 (ours.density_kg_m3, reference.density_kg_m3),
684 ] {
685 assert!(
686 ((a - b) / b).abs() < 1e-12,
687 "{latitude_deg}° {z}: {a} vs {b}"
688 );
689 }
690 }
691 }
692 }
693
694 #[test]
697 fn hydrostatic_fill_matches_the_standard() {
698 let tropopause = geometric_from_wmo_geopotential_m(11_000.0, LATITUDE_RAD).unwrap();
699 let profile = profile_at(
700 vec![
701 level(0.0, 288.15, Some(101_325.0), None),
702 level(tropopause, 216.65, None, None),
703 ],
704 LATITUDE_RAD,
705 );
706 let p = profile.pressures_pa()[1];
707 let reference = Ussa76::standard()
708 .sample(geometric_from_geopotential_m(11_000.0).unwrap())
709 .unwrap()
710 .air
711 .pressure_pa;
712 assert!(
713 ((p - reference) / reference).abs() < 1e-12,
714 "{p} vs {reference}"
715 );
716 }
717
718 #[test]
719 fn beyond_the_levels_follows_the_offset_standard() {
720 let profile = profile_at(
721 vec![
722 level(500.0, 295.0, Some(95_000.0), Some(0.6)),
723 level(2_000.0, 288.0, Some(80_000.0), Some(1.0)),
724 ],
725 LATITUDE_RAD,
726 );
727 let top = profile.sample(2_000.0).unwrap().air;
728 let just_above = profile.sample(2_000.001).unwrap();
729 assert_eq!(just_above.extrapolated, Some(Side::Above));
730 assert!((just_above.air.temperature_k - top.temperature_k).abs() < 1e-4);
731 assert!((just_above.air.pressure_pa / top.pressure_pa - 1.0).abs() < 1e-6);
732 assert!((just_above.air.density_kg_m3 / top.density_kg_m3 - 1.0).abs() < 1e-5);
733 let aloft = profile.sample(6_000.0).unwrap().air;
735 let h = |z| wmo_geopotential_from_geometric_m(z, LATITUDE_RAD).unwrap();
736 let expected = 288.0 - 0.0065 * (h(6_000.0) - h(2_000.0));
737 assert!((aloft.temperature_k - expected).abs() < 1e-9);
738 let e_sat = saturation_vapour_pressure_pa(aloft.temperature_k);
740 let capped = moist_air(aloft.temperature_k, aloft.pressure_pa, e_sat).unwrap();
741 assert!((aloft.density_kg_m3 / capped.density_kg_m3 - 1.0).abs() < 1e-14);
742 let dry = profile_at(
746 vec![
747 level(500.0, 295.0, Some(95_000.0), None),
748 level(2_000.0, 288.0, Some(80_000.0), None),
749 ],
750 LATITUDE_RAD,
751 );
752 let integrated = integrate_pressure(&dry, 2_000.0, 80_000.0, 2_300.0);
753 let continued = dry.sample(2_300.0).unwrap().air.pressure_pa;
754 assert!(
755 (continued / integrated - 1.0).abs() < 1e-8,
756 "{continued} {integrated}"
757 );
758 let humid_integrated = integrate_pressure(&profile, 2_000.0, 80_000.0, 2_300.0);
759 let humid_continued = profile.sample(2_300.0).unwrap().air.pressure_pa;
760 let low = humid_continued / humid_integrated - 1.0;
761 assert!((-3e-4..-2.5e-4).contains(&low), "{low}");
762
763 let below = profile.sample(0.0).unwrap();
764 assert_eq!(below.extrapolated, Some(Side::Below));
765 assert!(below.air.pressure_pa > 95_000.0 && below.air.temperature_k > 295.0);
766 assert!(profile.sample(f64::NAN).is_err());
767 }
768
769 #[test]
770 fn a_single_level_is_an_anchored_standard_atmosphere() {
771 let profile = profile_at(
772 vec![level(1_000.0, 280.0, Some(90_000.0), None)],
773 LATITUDE_RAD,
774 );
775 let anchored =
776 Ussa76::anchored(standard_height(1_000.0, LATITUDE_RAD), 280.0, 90_000.0).unwrap();
777 assert_eq!(profile.sample(1_000.0).unwrap().extrapolated, None);
778 for z in [0.0, 3_000.0] {
779 let ours = profile.sample(z).unwrap().air;
780 let reference = anchored
781 .sample(standard_height(z, LATITUDE_RAD))
782 .unwrap()
783 .air;
784 assert!((ours.density_kg_m3 / reference.density_kg_m3 - 1.0).abs() < 1e-14);
785 }
786 }
787
788 #[test]
789 fn wind_columns_make_a_layered_wind() {
790 let mut low = level(0.0, 290.0, Some(100_000.0), None);
791 low.wind_speed_m_s = Some(3.0);
792 low.wind_direction_from_rad = Some(1.0);
793 let mut high = level(1_000.0, 283.0, None, None);
794 high.wind_speed_m_s = Some(9.0);
795 high.wind_direction_from_rad = Some(2.0);
796 let profile =
797 SoundingProfile::new(vec![low, high], LATITUDE_RAD, WindInterpolation::Components)
798 .unwrap();
799 let wind = profile.wind().unwrap();
800 assert_eq!(wind.interpolation(), WindInterpolation::Components);
801 assert_eq!(wind.levels().len(), 2);
802 assert!(wind.wind(500.0).is_ok());
803 let dry = profile_at(vec![level(0.0, 290.0, Some(100_000.0), None)], LATITUDE_RAD);
804 assert!(dry.wind().is_none());
805 }
806
807 #[test]
808 fn invalid_profiles_are_rejected() {
809 let ok = level(0.0, 290.0, Some(100_000.0), Some(0.5));
810 let build =
811 |levels| SoundingProfile::new(levels, LATITUDE_RAD, WindInterpolation::default());
812 assert!(matches!(build(vec![]), Err(AtmosError::NoLevels)));
813 assert!(matches!(
814 build(vec![level(0.0, 290.0, None, None)]),
815 Err(AtmosError::MissingBasePressure)
816 ));
817 assert!(matches!(
818 build(vec![ok, level(0.0, 280.0, None, Some(0.5))]),
819 Err(AtmosError::HeightsNotIncreasing { index: 1 })
820 ));
821 assert!(matches!(
822 build(vec![ok, level(100.0, 280.0, None, None)]),
823 Err(AtmosError::IncompleteColumn { index: 1, .. })
824 ));
825 let mut windless_direction = level(100.0, 280.0, None, Some(0.5));
826 windless_direction.wind_direction_from_rad = Some(0.0);
827 assert!(matches!(
828 build(vec![ok, windless_direction]),
829 Err(AtmosError::IncompleteColumn { index: 1, .. })
830 ));
831 assert!(matches!(
833 build(vec![
834 level(0.0, 290.0, Some(101_325.0), None),
835 level(1_500.0, 280.0, Some(850.0), None),
836 level(3_000.0, 270.0, Some(70_000.0), None),
837 ]),
838 Err(AtmosError::PressureNotDecreasing { index: 2, .. })
839 ));
840 assert!(matches!(
841 build(vec![
842 level(0.0, 290.0, Some(101_325.0), None),
843 level(1_500.0, 280.0, Some(101_325.0), None),
844 ]),
845 Err(AtmosError::PressureNotDecreasing { index: 1, .. })
846 ));
847 assert!(matches!(
849 build(vec![
850 level(0.0, 290.0, Some(101_325.0), None),
851 level(1_000.0, 283.0, None, None),
852 level(2_000.0, 276.0, Some(95_000.0), None),
853 ]),
854 Err(AtmosError::PressureNotDecreasing { index: 2, .. })
855 ));
856 assert!(build(vec![level(0.0, 290.0, Some(100_000.0), Some(1.5))]).is_err());
857 assert!(build(vec![level(0.0, -1.0, Some(100_000.0), None)]).is_err());
858 assert!(build(vec![level(0.0, 290.0, Some(0.0), None)]).is_err());
859 assert!(build(vec![level(f64::NAN, 290.0, Some(1e5), None)]).is_err());
860 let levels = vec![level(0.0, 290.0, Some(1e5), None)];
861 for latitude in [2.0, f64::NAN] {
862 assert!(
863 SoundingProfile::new(levels.clone(), latitude, WindInterpolation::default())
864 .is_err()
865 );
866 }
867 }
868
869 #[test]
870 fn profiles_round_trip_through_json() {
871 let json = r#"{"latitude_rad":0.5758,"levels":[
872 {"height_msl_m":1400.0,"temperature_k":300.0,"pressure_pa":86000.0,
873 "relative_humidity":0.3,"wind_speed_m_s":4.0,"wind_direction_from_rad":3.0},
874 {"height_msl_m":3000.0,"temperature_k":290.0,
875 "relative_humidity":0.2,"wind_speed_m_s":10.0,"wind_direction_from_rad":3.5}
876 ]}"#;
877 let profile: SoundingProfile = serde_json::from_str(json).unwrap();
878 assert_eq!(
879 profile.wind_interpolation,
880 WindInterpolation::SpeedDirection
881 );
882 assert_eq!(profile.latitude_rad(), 0.5758);
883 let back: SoundingProfile =
884 serde_json::from_str(&serde_json::to_string(&profile).unwrap()).unwrap();
885 assert_eq!(back, profile);
886 let unknown = r#"{"latitude_rad":0.0,"levels":[{"height_msl_m":0.0,"temperature_k":300.0,"pressure_pa":1e5,"dew_point_k":280.0}]}"#;
887 assert!(serde_json::from_str::<SoundingProfile>(unknown).is_err());
888 let no_latitude =
889 r#"{"levels":[{"height_msl_m":0.0,"temperature_k":300.0,"pressure_pa":1e5}]}"#;
890 assert!(serde_json::from_str::<SoundingProfile>(no_latitude).is_err());
891 }
892
893 #[test]
896 fn a_profile_reads_past_the_tool_that_wrote_it() {
897 let json = r#"{"tool":{"name":"hpr-sim","version":"0.1.0","designation":"FS · SW · TOOL 005"},
898 "latitude_rad":0.5758,"levels":[
899 {"height_msl_m":1400.0,"temperature_k":300.0,"pressure_pa":86000.0}
900 ]}"#;
901 let profile: SoundingProfile = serde_json::from_str(json).unwrap();
902 let bare = r#"{"latitude_rad":0.5758,"levels":[
903 {"height_msl_m":1400.0,"temperature_k":300.0,"pressure_pa":86000.0}
904 ]}"#;
905 assert_eq!(profile, serde_json::from_str(bare).unwrap());
906 assert!(!serde_json::to_string(&profile).unwrap().contains("tool"));
907 for key in ["tools", "latitude_deg"] {
909 let unknown = bare.replacen('{', &format!("{{\"{key}\":{{}},"), 1);
910 assert!(
911 serde_json::from_str::<SoundingProfile>(&unknown).is_err(),
912 "{unknown}"
913 );
914 }
915 }
916
917 #[test]
920 fn wmo_geopotential_matches_the_guide() {
921 let equator = wmo_geopotential_from_geometric_m(30_000.0, 0.0).unwrap();
922 assert!((equator - 29_778.5).abs() <= 0.05, "{equator}");
923 let north = wmo_geopotential_from_geometric_m(30_000.0, 80_f64.to_radians()).unwrap();
924 assert!((north - 29_932.0).abs() <= 0.5, "{north}");
925 for latitude in [-90.0_f64, -33.0, 0.0, 32.9, 45.0, 89.0] {
926 for z in [-400.0, 0.0, 1_500.0, 12_000.0, 40_000.0] {
927 let gpm = wmo_geopotential_from_geometric_m(z, latitude.to_radians()).unwrap();
928 let back = geometric_from_wmo_geopotential_m(gpm, latitude.to_radians()).unwrap();
929 assert!((back - z).abs() < 1e-8, "{latitude} {z}");
930 }
931 }
932 assert!(wmo_geopotential_from_geometric_m(0.0, 2.0).is_err());
933 assert!(geometric_from_wmo_geopotential_m(1e8, 0.0).is_err());
934 }
935}