winds_engine/sounding.rs
1//! Radiosonde soundings in the crate's line-based interchange format.
2//!
3//! The format is deliberately trivial so any archive (IEM RAOB JSON, UWyo
4//! text lists, SHARPpy exports) can be converted offline with a one-liner:
5//!
6//! ```text
7//! # station: KMPX
8//! # valid: 2026-10-23T00:00:00Z
9//! # columns: altitude_m speed_mps direction_from_deg
10//! 287.0 0.0000 0.0
11//! 305.0 3.0867 125.0
12//! ```
13//!
14//! Blank lines and `#` comments are ignored; `# station:` and `# valid:`
15//! header keys are carried along. Directions are wind-FROM in the frame
16//! convention of [`ProfileField`]: 0° = headwind, 90° = from the right.
17
18use crate::{Error, ProfileField, ProfileLevel, Result, WindFieldError};
19
20/// One measured level.
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct SoundingLevel {
23 /// Height above mean sea level, metres.
24 pub altitude_m: f64,
25 /// Measured wind speed, m/s.
26 pub speed_mps: f64,
27 /// Measured direction the air comes FROM, degrees.
28 pub direction_from_deg: f64,
29}
30
31/// A parsed sounding.
32#[derive(Debug, Clone, PartialEq)]
33pub struct Sounding {
34 /// Station identifier, if the header carried one.
35 pub station: Option<String>,
36 /// Observation time string, if the header carried one (kept opaque:
37 /// this crate never parses clocks).
38 pub valid: Option<String>,
39 /// Measured levels in file order.
40 pub levels: Vec<SoundingLevel>,
41}
42
43impl Sounding {
44 /// Parse the interchange format.
45 pub fn parse(text: &str) -> Result<Self> {
46 let mut station = None;
47 let mut valid = None;
48 let mut levels = Vec::new();
49 for (number, line) in text.lines().enumerate() {
50 let line = line.trim();
51 if line.is_empty() {
52 continue;
53 }
54 if let Some(comment) = line.strip_prefix('#') {
55 let comment = comment.trim();
56 if let Some(value) = comment.strip_prefix("station:") {
57 station = Some(value.trim().to_string());
58 } else if let Some(value) = comment.strip_prefix("valid:") {
59 valid = Some(value.trim().to_string());
60 }
61 continue;
62 }
63 let fields: Vec<&str> = line.split_whitespace().collect();
64 if fields.len() != 3 {
65 return Err(Error::invalid(
66 "sounding",
67 format!(
68 "line {}: expected 'altitude_m speed_mps direction_from_deg'",
69 number + 1
70 ),
71 ));
72 }
73 let parse = |token: &str| -> Result<f64> {
74 token.parse().map_err(|_| {
75 Error::invalid(
76 "sounding",
77 format!("line {}: '{token}' is not numeric", number + 1),
78 )
79 })
80 };
81 levels.push(SoundingLevel {
82 altitude_m: parse(fields[0])?,
83 speed_mps: parse(fields[1])?,
84 direction_from_deg: parse(fields[2])?,
85 });
86 }
87 if levels.is_empty() {
88 return Err(Error::invalid("sounding", "no levels"));
89 }
90 Ok(Self {
91 station,
92 valid,
93 levels,
94 })
95 }
96
97 /// The lowest measured altitude, metres above mean sea level. In a standard sounding this
98 /// is the station's surface report, so it is the station's elevation. `None` when there
99 /// are no levels.
100 pub fn surface_altitude_m(&self) -> Option<f64> {
101 self.levels.iter().map(|l| l.altitude_m).reduce(f64::min)
102 }
103
104 /// The levels as a validated [`ProfileField`] (sorted and checked). Heights stay in metres
105 /// above mean sea level, as measured, so the field matches a frame whose y is altitude above
106 /// sea level (rockets-engine's) with no conversion, wherever that frame's ground sits.
107 ///
108 /// Convert datums in one place only, never in these levels as well: wrap
109 /// the field in [`HeightOffset`](crate::HeightOffset) (offset = the
110 /// height of the caller's y = 0 in this field's datum), or read a
111 /// ground-relative field such as [`LogProfile`](crate::LogProfile) or
112 /// [`EkmanProfile`](crate::EkmanProfile) at `y - ground elevation` at its
113 /// single call site. This field, being above sea level, needs no offset
114 /// in a sea-level frame.
115 pub fn to_profile_field(&self) -> std::result::Result<ProfileField, WindFieldError> {
116 ProfileField::new(
117 self.levels
118 .iter()
119 .map(|l| ProfileLevel {
120 altitude_m: l.altitude_m,
121 speed_mps: l.speed_mps,
122 direction_from_deg: l.direction_from_deg,
123 })
124 .collect(),
125 )
126 }
127}