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 levels as a validated [`ProfileField`] (sorted and checked).
99 pub fn to_profile_field(&self) -> Result<ProfileField> {
100 ProfileField::new(
101 self.levels
102 .iter()
103 .map(|l| ProfileLevel {
104 altitude_m: l.altitude_m,
105 speed_mps: l.speed_mps,
106 direction_from_deg: l.direction_from_deg,
107 })
108 .collect(),
109 )
110 }
111}