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 meteorological FROM (see
16//! [`Wind::from_speed_direction`](crate::Wind::from_speed_direction)).
17
18use crate::field::{ProfileField, ProfileLevel};
19use crate::{Error, Result};
20
21/// One measured level.
22#[derive(Debug, Clone, Copy, PartialEq)]
23pub struct SoundingLevel {
24    /// Height above mean sea level, metres.
25    pub altitude_m: f64,
26    /// Measured wind speed, m/s.
27    pub speed_mps: f64,
28    /// Measured direction the air comes FROM, degrees.
29    pub direction_from_deg: f64,
30}
31
32/// A parsed sounding.
33#[derive(Debug, Clone, PartialEq)]
34pub struct Sounding {
35    /// Station identifier, if the header carried one.
36    pub station: Option<String>,
37    /// Observation time string, if the header carried one (kept opaque:
38    /// this crate never parses clocks).
39    pub valid: Option<String>,
40    /// Measured levels in file order.
41    pub levels: Vec<SoundingLevel>,
42}
43
44impl Sounding {
45    /// Parse the interchange format.
46    pub fn parse(text: &str) -> Result<Self> {
47        let mut station = None;
48        let mut valid = None;
49        let mut levels = Vec::new();
50        for (number, line) in text.lines().enumerate() {
51            let line = line.trim();
52            if line.is_empty() {
53                continue;
54            }
55            if let Some(comment) = line.strip_prefix('#') {
56                let comment = comment.trim();
57                if let Some(value) = comment.strip_prefix("station:") {
58                    station = Some(value.trim().to_string());
59                } else if let Some(value) = comment.strip_prefix("valid:") {
60                    valid = Some(value.trim().to_string());
61                }
62                continue;
63            }
64            let fields: Vec<&str> = line.split_whitespace().collect();
65            if fields.len() != 3 {
66                return Err(Error::invalid(
67                    "sounding",
68                    format!(
69                        "line {}: expected 'altitude_m speed_mps direction_from_deg'",
70                        number + 1
71                    ),
72                ));
73            }
74            let parse = |token: &str| -> Result<f64> {
75                token.parse().map_err(|_| {
76                    Error::invalid(
77                        "sounding",
78                        format!("line {}: '{token}' is not numeric", number + 1),
79                    )
80                })
81            };
82            levels.push(SoundingLevel {
83                altitude_m: parse(fields[0])?,
84                speed_mps: parse(fields[1])?,
85                direction_from_deg: parse(fields[2])?,
86            });
87        }
88        if levels.is_empty() {
89            return Err(Error::invalid("sounding", "no levels"));
90        }
91        Ok(Self {
92            station,
93            valid,
94            levels,
95        })
96    }
97
98    /// The lowest measured altitude, metres above mean sea level. In a standard sounding this
99    /// is the station's surface report, so it is the station's elevation. `None` when there
100    /// are no levels.
101    pub fn surface_altitude_m(&self) -> Option<f64> {
102        self.levels.iter().map(|l| l.altitude_m).reduce(f64::min)
103    }
104
105    /// The levels as a validated [`ProfileField`] (sorted and checked). Heights stay in metres
106    /// above mean sea level, as measured, so the field matches a frame whose y is altitude above
107    /// sea level (rockets-engine's) with no conversion, wherever that frame's ground sits.
108    ///
109    /// Convert datums in one place only, never in these levels as well. This crate's staged
110    /// types have no offset wrapper: ballistics-engine's `HeightOffset` (from its next release)
111    /// wraps only ballistics-engine's own `WindField`, not this crate's (the two crates' wind
112    /// types differ; see the [crate docs](crate)). So a caller in a sea-level frame reads a
113    /// ground-relative field such as [`LogProfile`](crate::analytic::LogProfile) or
114    /// [`EkmanProfile`](crate::analytic::EkmanProfile) at `y - ground elevation` at its single
115    /// call site, or builds ballistics-engine's `ProfileField`/`LogProfile`/`EkmanProfile` from
116    /// the same values and wraps those in `HeightOffset`. This field, being above sea level,
117    /// needs no offset in a sea-level frame.
118    pub fn to_profile_field(&self) -> Result<ProfileField> {
119        ProfileField::new(
120            self.levels
121                .iter()
122                .map(|l| ProfileLevel {
123                    altitude_m: l.altitude_m,
124                    speed_mps: l.speed_mps,
125                    direction_from_deg: l.direction_from_deg,
126                })
127                .collect(),
128        )
129    }
130}