Skip to main content

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}