hpr_net/wyoming.rs
1//! Weather-balloon soundings from the University of Wyoming's radiosonde archive, turned into a
2//! [`SoundingProfile`].
3//!
4//! A radiosonde is an instrument package carried up by a weather balloon, usually at 00 and
5//! 12 UTC, from hundreds of stations worldwide. It measures pressure, temperature and humidity,
6//! and its drift gives the wind. The [University of Wyoming][uwyo] serves the archive of these
7//! soundings. It is a measurement, not a forecast, but only at the station and the time of the
8//! flight, which can be a hundred kilometers and some hours from a launch.
9//!
10//! A [`WyomingRequest`] names a station (its WMO number, such as `72364` for Santa Teresa, New
11//! Mexico) and the sounding's nominal hour, and asks for the comma-separated text, one row per
12//! level, with these columns (among others) in these units:
13//!
14//! | column | unit | read as |
15//! |---|---|---|
16//! | `time` | `YYYY-MM-DD HH:MM:SS` UTC | the release time, from the first row |
17//! | `latitude`, `longitude` | degrees | the release point, from the first row |
18//! | `pressure_hPa` | hPa | pressure |
19//! | `geopotential height_m` | geopotential m | height above sea level |
20//! | `temperature_C` | °C | temperature |
21//! | `relative humidity_%` | % | over liquid water (the file has `humidity wrt ice_%` too) |
22//! | `wind direction_degree`, `wind speed_m/s` | °, m/s | the wind, the direction it blows from |
23//!
24//! A column in any other unit is refused, not converted. Two versions of most soundings are
25//! served ([`WyomingVersion`]): the coded message stations send (WMO FM 35, "TEMP"), with the
26//! standard pressure levels and the significant levels between them, about 200 rows; and the
27//! BUFR file (WMO's binary format, as the archive decodes it), a row a second, about 6,000.
28//!
29//! [`WyomingSounding::parse`] first finds the rows that fit. A row with a pressure, height and
30//! temperature **fits** the row before it when its height above that row is the layer's thickness
31//! by the hypsometric equation (from the two pressures and virtual temperatures, WMO-No. 8 eqs.
32//! 12.17 and 12.18), within 5% ([`THICKNESS_SHARE`]) plus what rounding the pressures can move it
33//! plus 30 m ([`THICKNESS_SLACK_M`]). Rows are kept from the longest chain from the ground in
34//! which each row fits the one before it, skipping at most [`MAX_MISFITS`] rows at a time (rows
35//! with no temperature, or one out of bounds, can't be checked, and aren't counted); of
36//! equally long chains, the one whose layers miss by the smallest total share of their
37//! allowances. A row missing only its wind or humidity can be in the chain, so a gap in the wind
38//! doesn't widen the layers checked. A row not in the chain is left out
39//! ([`DropReason::Thickness`]); in the recordings every row fits the row before it, to within 1 m
40//! beyond rounding. Then it keeps:
41//!
42//! - **The ground**, the first row: the pressure, temperature, humidity and wind at the station
43//! when the balloon was released. Nothing before it checks it, so a chain without it that beats
44//! every chain with it, starting among the rows it could reach, refuses the answer
45//! ([`WyomingError::GroundMisfit`]). Bad rows right after a good ground that fit the rows above
46//! them can beat it the same way, and refuse the answer: two BUFR rows 31 m high do it.
47//! - **One row of each run of rows in the chain with the same pressure**, the middle one. BUFR's
48//! pressures are rounded to 0.1 hPa, and high up the balloon climbs tens of meters while the
49//! pressure falls that much, so runs of rows share a pressure; the rounded value is the pressure
50//! at about the middle of its run.
51//! - **Each such row that lies above the last row kept**, higher and at a lower pressure.
52//!
53//! A row missing a value (the last row often has no wind) is dropped, and so is one with a value
54//! no air on Earth has ([`DropReason::OutOfRange`] lists the bounds). [`WyomingSounding::dropped`]
55//! lists each row left out, with its reason.
56//!
57//! So a row with a bad pressure or height, a pressure missing a digit, say, is left out, not kept
58//! to hide the good rows after it; rows that fall or stay at one height, a balloon coming down, fit
59//! and are left out however many. More than [`MAX_MISFITS`] rows after the chain's end refuse the
60//! answer ([`WyomingError::Misfit`]): the end, or all of them, are wrong (a block of heights 1 km
61//! off), or a long run of rows with no temperature leaves a layer too thick for its two ends'
62//! temperatures to give. Not caught: a wrong wind, humidity or temperature (the check doesn't use
63//! the wind, humidity moves it by a few percent, and on layers under about 100 m any temperature
64//! within the bounds fits), a height error within the allowance, a row whose pressure and height
65//! are both wrong yet fit each other, and a bad ground that fits the row after it or has only one
66//! row after it. A block of bad rows that fit their neighbours can be kept, and good rows beside
67//! them left out instead, no more than the block holds. A row, or a short block, kept a little too
68//! high leaves out the good rows just above it, which now lie below it: in the tests, at most 2
69//! other levels of a coded message and 10 other rows of a BUFR file (8 for one row).
70//!
71//! Heights are geopotential meters (the column says so), converted to geometric heights at the
72//! first row's latitude with WMO-No. 8 (2023) eqs. 12.15 and 12.16
73//! ([`hpr_atmos::profile::geometric_from_wmo_geopotential_m`]); the [atmosphere page][atmos]
74//! explains why. That is the latitude the profile uses for its hydrostatics. The balloon drifts;
75//! converting at the latitude it reached instead would move a height by about 0.8 m per degree
76//! of drift at 10 km, 2.5 m at 30 km. A relative humidity above 100%, which radiosondes report in
77//! cloud, is kept as recorded and taken as 100% in [`WyomingSounding::sounding`], as the
78//! [atmosphere's decision record][adr-004] asks.
79//!
80//! [`fetch`] asks a [`Client`] for the URL, so the answer comes from the cache when it can, and
81//! offline from the cache only; an answer that doesn't parse is never cached. Show
82//! [`ATTRIBUTION`] (it is on every [`Fetched`]) wherever the sounding is shown.
83//!
84//! **How far to trust it:** the profile gives back every row it keeps as recorded (the tests). A
85//! radiosonde's own errors are small next to how far the air can change between the station and
86//! the launch, and nothing here measures that. The [guide page][guide] says more.
87//!
88//! ```
89//! use hpr_atmos::WindInterpolation;
90//! use hpr_net::wyoming::WyomingSounding;
91//!
92//! // Santa Teresa, New Mexico, 21 June 2025, 12 UTC: the coded message's 228 rows.
93//! let body = include_bytes!("../tests/fixtures/replay/wyoming-72364-fm35.csv");
94//! let sounding = WyomingSounding::parse(body)?;
95//! assert_eq!(sounding.levels.len(), 227); // the last row has no wind
96//! let air = sounding.sounding(WindInterpolation::SpeedDirection)?;
97//! let at_5_km = air.sample(5_000.0)?.air;
98//! // Between the rows at 570 hPa (4,852 m) and 557 hPa (5,035 m) of geopotential height.
99//! assert!((at_5_km.pressure_pa - 55_950.0).abs() < 100.0);
100//! # Ok::<(), Box<dyn std::error::Error>>(())
101//! ```
102//!
103//! [uwyo]: https://weather.uwyo.edu/upperair/sounding.shtml
104//! [adr-004]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-004-atmosphere-wind-turbulence-and-the-seeded-generator-2026-09-17
105//! [atmos]: https://nrdptel.github.io/hpr-sim/physics/atmosphere.html
106//! [guide]: https://nrdptel.github.io/hpr-sim/soundings.html
107
108use std::f64::consts::TAU;
109
110use hpr_atmos::moist::{WATER_VAPOUR_MOLECULAR_WEIGHT_KG_PER_KMOL, saturation_vapour_pressure_pa};
111use hpr_atmos::profile::geometric_from_wmo_geopotential_m;
112use hpr_atmos::ussa76::{DRY_AIR_GAS_CONSTANT_J_PER_KG_K, SEA_LEVEL_MOLECULAR_WEIGHT_KG_PER_KMOL};
113use hpr_atmos::{AtmosError, SoundingLevel, SoundingProfile, WindInterpolation};
114use hpr_core::gravity::STANDARD_GRAVITY_MPS2;
115use serde::{Deserialize, Serialize};
116
117use crate::civil::{date_hour, unix_day_start};
118use crate::{Client, Fetched, NetError, Source, Transport};
119
120/// The archive's address.
121pub const ENDPOINT: &str = "https://weather.uwyo.edu/wsgi/sounding";
122
123/// The credit shown wherever a sounding is shown. The archive states no license; the soundings
124/// are the stations' observations, which weather services exchange freely (WMO Resolution 40).
125pub const ATTRIBUTION: &str = "Sounding from the University of Wyoming's radiosonde archive";
126
127/// How long after its nominal hour a sounding may still be filling in, s: a day. The archive's
128/// copy can grow for some hours after the flight, as a station's later messages arrive.
129pub const SETTLE_S: u64 = 86_400;
130
131/// How long an answer stays fresh while its sounding may still be filling in, s: an hour.
132pub const YOUNG_TTL_S: u64 = 3_600;
133
134/// How long an answer fetched after its sounding settled stays fresh, s: 30 days.
135pub const SETTLED_TTL_S: u64 = 30 * 86_400;
136
137/// The most rows with a pressure, height and temperature within the bounds the chain of rows that
138/// fit may skip at a time, and the most that may follow its end. More after its end means the end,
139/// or all of them, are wrong (a block of heights 1 km off), and the answer is refused. In a BUFR
140/// file 10 rows are about 10 s of the balloon's climb, some 50 m; in a coded message they can span
141/// kilometers.
142pub const MAX_MISFITS: usize = 10;
143
144/// The share of a layer's hypsometric thickness a row's height may miss it by, beyond rounding
145/// and [`THICKNESS_SLACK_M`]: 5%, for a layer whose inner rows have no temperature, which the mean
146/// of its two ends' temperatures gives less well. A judgment, not a measurement: no row in the
147/// recordings needs any share.
148pub const THICKNESS_SHARE: f64 = 0.05;
149
150/// The meters a row's height may miss its layer's thickness by, beyond the share and the pressures'
151/// rounding: 30 m. A coded message's heights from 500 hPa up are rounded to 10 m; a height 30 m
152/// off misplaces its level by as much as a pressure error of 0.33% to 0.55% (the air's scale
153/// height, the climb over which pressure falls by a factor of e, is 5.5 to 9 km), so the check is
154/// for gross errors, not for these.
155pub const THICKNESS_SLACK_M: f64 = 30.0;
156
157/// The most rows an answer may have. The archive's BUFR files have about 6,000.
158pub const MAX_ROWS: usize = 100_000;
159
160/// Bounds outside which a value is impossible on Earth, and its row is dropped as out of range:
161/// pressure (the highest sea-level pressure recorded is about 1,084 hPa; 0.1 hPa is about 65 km up
162/// in the 1976 standard atmosphere, above the height bound), temperature, geopotential height (the
163/// Dead Sea's shore is at about −430 m) and wind speed.
164const MIN_PRESSURE_HPA: f64 = 0.1;
165const MAX_PRESSURE_HPA: f64 = 1_200.0;
166const MIN_TEMPERATURE_C: f64 = -150.0;
167const MAX_TEMPERATURE_C: f64 = 80.0;
168const MIN_HEIGHT_M: f64 = -1_000.0;
169const MAX_HEIGHT_M: f64 = 60_000.0;
170const MAX_WIND_M_S: f64 = 300.0;
171
172/// The most columns a header may have. The archive's has 13.
173const MAX_COLUMNS: usize = 64;
174
175/// The columns read, with the units the parser requires.
176const COLUMNS: [(Column, &str, &str); 9] = [
177 (Column::Time, "time", ""),
178 (Column::Longitude, "longitude", ""),
179 (Column::Latitude, "latitude", ""),
180 (Column::Pressure, "pressure", "hPa"),
181 (Column::Height, "geopotential height", "m"),
182 (Column::Temperature, "temperature", "C"),
183 (Column::Humidity, "relative humidity", "%"),
184 (Column::Direction, "wind direction", "degree"),
185 (Column::Speed, "wind speed", "m/s"),
186];
187
188/// Seconds in an hour.
189const HOUR_S: i64 = 3_600;
190/// The first second of the year 10000, past which a date has no four-digit year.
191const YEAR_10000_S: i64 = 253_402_300_800;
192/// The longest station name or quoted text an error repeats, in characters.
193const QUOTE_CHARS: usize = 40;
194
195/// Which version of a sounding to ask for.
196#[non_exhaustive]
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
198pub enum WyomingVersion {
199 /// The coded message the station sends (WMO FM 35, "TEMP"): the standard pressure levels and
200 /// the significant levels between them, about 200 rows, pressures to 1 hPa (0.1 hPa above
201 /// 100 hPa).
202 Fm35,
203 /// The BUFR file, where the station sends one: a row a second, about 6,000, pressures to
204 /// 0.1 hPa.
205 Bufr,
206}
207
208impl WyomingVersion {
209 /// The URL's name for it.
210 fn query(self) -> &'static str {
211 match self {
212 Self::Fm35 => "FM35",
213 Self::Bufr => "BUFR",
214 }
215 }
216}
217
218/// What to ask the archive for: a station, the sounding's nominal hour and its version.
219#[non_exhaustive]
220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
221pub struct WyomingRequest {
222 /// The station's WMO number (such as `72364`), or the name the archive knows it by.
223 pub station: String,
224 /// The sounding's nominal time, a whole UTC hour (usually 00 or 12), seconds since the Unix
225 /// epoch. The balloon is released about an hour before.
226 pub time_unix_s: i64,
227 /// Which version to ask for.
228 pub version: WyomingVersion,
229 /// Another server with the same interface, instead of [`ENDPOINT`]: an address with no query
230 /// (`?`) or fragment (`#`), to which the request's own query is added.
231 pub endpoint: Option<String>,
232}
233
234impl WyomingRequest {
235 /// A request for `station`'s coded-message sounding at the whole hour `time_unix_s`.
236 #[must_use]
237 pub fn new(station: impl Into<String>, time_unix_s: i64) -> Self {
238 Self {
239 station: station.into(),
240 time_unix_s,
241 version: WyomingVersion::Fm35,
242 endpoint: None,
243 }
244 }
245
246 /// A request for the latest 00 or 12 UTC sounding at or before `launch_unix_s` (seconds since
247 /// the Unix epoch): the one a launch at that time would have had. Near its nominal hour a
248 /// sounding may still be filling in; [`WyomingRequest::source`] keeps such an answer fresh
249 /// for an hour only.
250 #[must_use]
251 pub fn latest_before(station: impl Into<String>, launch_unix_s: i64) -> Self {
252 let half_day_s = 12 * HOUR_S;
253 let time_unix_s = launch_unix_s
254 .div_euclid(half_day_s)
255 .saturating_mul(half_day_s);
256 Self::new(station, time_unix_s)
257 }
258
259 /// The URL: `…?datetime=YYYY-MM-DD%20HH:00:00&id=<station>&type=TEXT:CSV&src=<version>`.
260 ///
261 /// The URL is the cache key, and it holds no coordinates: the caller names the station, and
262 /// nothing here picks one from a site's latitude and longitude. A site kept in radians can't
263 /// change it.
264 ///
265 /// # Errors
266 /// [`WyomingError::Request`] when the station is empty, longer than 16 characters or not
267 /// letters and digits, the time is not a whole hour from 1970 to 9999, or the endpoint is
268 /// empty or has a `?` or `#`.
269 pub fn url(&self) -> Result<String, WyomingError> {
270 let refuse = |what, value: &str| WyomingError::Request {
271 what,
272 value: cut(value),
273 };
274 let station = &self.station;
275 if station.is_empty()
276 || station.len() > 16
277 || !station.bytes().all(|b| b.is_ascii_alphanumeric())
278 {
279 return Err(refuse("station", station));
280 }
281 if !(0..YEAR_10000_S).contains(&self.time_unix_s) || self.time_unix_s % HOUR_S != 0 {
282 return Err(refuse("time (s since 1970)", &self.time_unix_s.to_string()));
283 }
284 let endpoint = match &self.endpoint {
285 Some(endpoint) if endpoint.is_empty() || endpoint.contains(['?', '#']) => {
286 return Err(refuse("endpoint", endpoint));
287 }
288 Some(endpoint) => endpoint.as_str(),
289 None => ENDPOINT,
290 };
291 let (year, month, day, hour) = date_hour(self.time_unix_s);
292 Ok(format!(
293 "{endpoint}?datetime={year:04}-{month:02}-{day:02}%20{hour:02}:00:00&id={station}\
294 &type=TEXT:CSV&src={}",
295 self.version.query()
296 ))
297 }
298
299 /// The cache's view of the source at `now_s` (seconds since the Unix epoch): the archive's
300 /// name, [`ATTRIBUTION`], and how long a cached answer stays fresh.
301 ///
302 /// Until [`SETTLE_S`] after the nominal hour the sounding may still be filling in, so an
303 /// answer stays fresh for [`YOUNG_TTL_S`]. After that, only an answer fetched after the
304 /// sounding settled is fresh, for up to [`SETTLED_TTL_S`]: a copy fetched while it was young
305 /// is fetched again online (and still served offline, marked stale).
306 #[must_use]
307 pub fn source(&self, now_s: u64) -> Source {
308 let settled_s = u64::try_from(self.time_unix_s)
309 .unwrap_or(0)
310 .saturating_add(SETTLE_S);
311 let ttl_s = if now_s < settled_s {
312 YOUNG_TTL_S
313 } else {
314 // Fresh when `now − fetched < now − settled`, that is, fetched after it settled.
315 (now_s - settled_s).min(SETTLED_TTL_S)
316 };
317 Source {
318 name: "University of Wyoming soundings".to_owned(),
319 attribution: ATTRIBUTION.to_owned(),
320 ttl_s,
321 }
322 }
323}
324
325/// One level of a sounding, as recorded, in SI units.
326#[non_exhaustive]
327#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
328pub struct WyomingLevel {
329 /// Pressure, Pa.
330 pub pressure_pa: f64,
331 /// Geopotential height above sea level as recorded, geopotential m.
332 pub geopotential_height_m: f64,
333 /// Geometric height above sea level, m (WMO-No. 8 eqs. 12.15 and 12.16 at the first row's
334 /// latitude).
335 pub height_msl_m: f64,
336 /// Temperature, K.
337 pub temperature_k: f64,
338 /// Relative humidity over liquid water as recorded, a fraction; it can exceed 1 in cloud.
339 pub relative_humidity: f64,
340 /// Wind speed, m/s.
341 pub wind_speed_m_s: f64,
342 /// Direction the wind blows from, clockwise from true north, rad, in `[0, 2π)`.
343 pub wind_direction_from_rad: f64,
344}
345
346/// Why a row was left out of the profile.
347#[non_exhaustive]
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
349pub enum DropReason {
350 /// A value is missing: the pressure, height, temperature, humidity, or either half of the
351 /// wind.
352 NoData,
353 /// A value is out of range: a pressure below 0.1 hPa or above 1,200 hPa, a temperature
354 /// outside −150 to 80 °C, a height outside −1 to 60 km, a wind speed below zero or above
355 /// 300 m/s, a relative humidity below zero, a direction outside 0° to 360°, or a height
356 /// with no geometric height.
357 OutOfRange,
358 /// It is not the middle row of its run of rows that fit with the same pressure, or the run is
359 /// at the ground's pressure.
360 SamePressure,
361 /// Its height is not above the last row kept, or its pressure not below it.
362 NotAbove,
363 /// It is not in the chain of rows that fit: its height above the row before is not the
364 /// thickness the two rows' pressures and temperatures give (see [`THICKNESS_SHARE`]), or a
365 /// longer or closer chain passes it by. Its pressure, height or temperature is likely wrong;
366 /// but a good row beside bad ones that fit, within their allowances, can be passed by instead.
367 Thickness,
368}
369
370/// A row left out of the profile, and why.
371#[non_exhaustive]
372#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
373pub struct DroppedLevel {
374 /// Its line in the answer, counting the header as line 1.
375 pub line: usize,
376 /// Why it was dropped.
377 pub reason: DropReason,
378}
379
380/// A sounding read from the archive's answer: the ground and the levels above it.
381#[non_exhaustive]
382#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
383pub struct WyomingSounding {
384 /// Where the balloon was released, degrees north, from the first row: the station, or the
385 /// sonde's own position at release in a BUFR file.
386 pub latitude_deg: f64,
387 /// Where the balloon was released, degrees east, from the first row.
388 pub longitude_deg: f64,
389 /// When the balloon was released, seconds since the Unix epoch (UTC), from the first row.
390 pub release_unix_s: i64,
391 /// The levels kept, the ground first.
392 pub levels: Vec<WyomingLevel>,
393 /// The rows left out, in order.
394 pub dropped: Vec<DroppedLevel>,
395}
396
397impl WyomingSounding {
398 /// Reads the archive's comma-separated answer.
399 ///
400 /// # Errors
401 /// - [`WyomingError::Missing`] when a column is absent (an answer that is not the archive's
402 /// text has no header to find), and [`WyomingError::Units`] when one is in another unit.
403 /// - [`WyomingError::Row`] when the header has more than 64 columns or a column twice, there
404 /// are more than [`MAX_ROWS`] rows, a row has the wrong number of fields, or a field is not
405 /// a number (or, in the first row, the time is not a date).
406 /// - [`WyomingError::NoGround`] when there is no row, or the first is missing a value or has
407 /// one out of range.
408 /// - [`WyomingError::GroundMisfit`] when a chain of rows that fit, without the ground, beats
409 /// every chain with it.
410 /// - [`WyomingError::Misfit`] when more than [`MAX_MISFITS`] rows follow the end of the
411 /// chain of rows that fit.
412 pub fn parse(body: &[u8]) -> Result<Self, WyomingError> {
413 let text = std::str::from_utf8(body).map_err(|_| WyomingError::Missing {
414 field: "header".to_owned(),
415 })?;
416 let mut lines = text.lines().enumerate();
417 let header = lines
418 .next()
419 .map(|(_, line)| line)
420 .ok_or_else(|| WyomingError::Missing {
421 field: "header".to_owned(),
422 })?;
423 let names: Vec<&str> = header.splitn(MAX_COLUMNS + 1, ',').map(str::trim).collect();
424 if names.len() > MAX_COLUMNS {
425 return Err(WyomingError::Row {
426 line: 1,
427 reason: format!("more than {MAX_COLUMNS} columns"),
428 });
429 }
430 let mut index = [0_usize; COLUMNS.len()];
431 for &(column, name, unit) in &COLUMNS {
432 index[column as usize] = find_column(&names, name, unit)?;
433 }
434 let col = |column: Column| index[column as usize];
435
436 let mut rows = lines.filter(|(_, line)| !line.trim().is_empty()).peekable();
437 let Some(&(first, first_line)) = rows.peek() else {
438 return Err(WyomingError::NoGround { line: 2 });
439 };
440 let fields = split_row(first_line, names.len(), first + 1)?;
441 let position = |column, what| number(&fields, col(column), first + 1, what);
442 let (Some(latitude_deg), Some(longitude_deg)) = (
443 position(Column::Latitude, "latitude")?,
444 position(Column::Longitude, "longitude")?,
445 ) else {
446 return Err(WyomingError::NoGround { line: first + 1 });
447 };
448 let time = fields[col(Column::Time)];
449 let release_unix_s = parse_time(time).ok_or_else(|| WyomingError::Row {
450 line: first + 1,
451 reason: format!("the time {:?} is not a date", cut(time)),
452 })?;
453 let latitude_rad = latitude_deg.to_radians();
454
455 // Every row read: its line, its pressure, height and temperature when it has them, and
456 // its level, or why it has none.
457 let mut read: Vec<(usize, Option<Thermo>, Result<WyomingLevel, DropReason>)> = Vec::new();
458 for (i, line) in rows {
459 let line_no = i + 1;
460 if read.len() == MAX_ROWS {
461 return Err(WyomingError::Row {
462 line: line_no,
463 reason: format!("more than {MAX_ROWS} rows"),
464 });
465 }
466 let fields = split_row(line, names.len(), line_no)?;
467 let value = |column: Column, what: &str| number(&fields, col(column), line_no, what);
468 let values = (
469 value(Column::Pressure, "pressure")?,
470 value(Column::Height, "height")?,
471 value(Column::Temperature, "temperature")?,
472 value(Column::Humidity, "humidity")?,
473 value(Column::Speed, "wind speed")?,
474 value(Column::Direction, "wind direction")?,
475 );
476 let level = match values {
477 (Some(p), Some(z), Some(t), Some(rh), Some(speed), Some(direction)) => {
478 level(p, z, t, rh, speed, direction, latitude_rad)
479 }
480 _ => Err(DropReason::NoData),
481 };
482 if read.is_empty() && level.is_err() {
483 return Err(WyomingError::NoGround { line: line_no });
484 }
485 let thermo = match values {
486 (Some(p), Some(z), Some(t), rh, _, _) => thermo(p, z, t, rh),
487 _ => None,
488 };
489 read.push((line_no, thermo, level));
490 }
491 let Some(&(ground_line, Some(_), Ok(ground))) = read.first() else {
492 return Err(WyomingError::NoGround { line: first + 1 });
493 };
494 // An answer whose pressures of 100 hPa or more are all whole numbers is a coded message's,
495 // rounded there to 1 hPa; the rest are to 0.1 hPa.
496 let coarse = read.iter().all(|(_, thermo, _)| {
497 thermo
498 .is_none_or(|t| t.pressure_pa < 10_000.0 || (t.pressure_pa / 100.0).fract() == 0.0)
499 });
500
501 // Which rows fit: the longest chain of rows with a pressure, height and temperature from
502 // the ground, each fitting the one before it, skipping at most MAX_MISFITS at a time.
503 let with_thermo: Vec<(usize, Thermo)> = read
504 .iter()
505 .enumerate()
506 .filter_map(|(i, (_, thermo, _))| thermo.map(|t| (i, t)))
507 .collect();
508 let rows: Vec<Thermo> = with_thermo.iter().map(|&(_, t)| t).collect();
509 let line = |k: usize| read[with_thermo[k].0].0;
510 let from_ground = chains(&rows, coarse, |k| k == 0);
511 // Nothing before the ground checks it: a better chain starting after it, among the rows it
512 // could reach, says it is the odd one out, and it can't be left out.
513 let without_ground = chains(&rows, coarse, |k| (1..=MAX_MISFITS + 1).contains(&k));
514 let last = longest(&from_ground);
515 let other = longest(&without_ground);
516 if without_ground[other].is_some_and(|link| link.beats(from_ground[last])) {
517 let first = walk(&without_ground, other)
518 .last()
519 .copied()
520 .unwrap_or(other);
521 return Err(WyomingError::GroundMisfit {
522 line: ground_line,
523 first: line(first),
524 });
525 }
526 if rows.len() - 1 - last > MAX_MISFITS {
527 return Err(WyomingError::Misfit {
528 after: line(last),
529 line: line(last + 1),
530 });
531 }
532 let mut fit = vec![false; read.len()];
533 for k in walk(&from_ground, last) {
534 fit[with_thermo[k].0] = true;
535 }
536
537 // Of each run of complete rows that fit with the same pressure, all but the middle one;
538 // a run at the ground's pressure, all of it. Other rows between them don't end a run.
539 let complete = |i: usize| match read[i].2 {
540 Ok(level) if fit[i] => Some(level),
541 _ => None,
542 };
543 let mut same_pressure = vec![false; read.len()];
544 let mut start = 1;
545 while start < read.len() {
546 let Some(first) = complete(start) else {
547 start += 1;
548 continue;
549 };
550 let mut members = vec![start];
551 let mut next = start + 1;
552 while next < read.len() {
553 match complete(next) {
554 Some(level) if level.pressure_pa == first.pressure_pa => members.push(next),
555 Some(_) => break,
556 None => {}
557 }
558 next += 1;
559 }
560 let middle = (members.len() - 1) / 2;
561 let at_ground = first.pressure_pa == ground.pressure_pa;
562 for (k, &i) in members.iter().enumerate() {
563 same_pressure[i] = k != middle || at_ground;
564 }
565 start = members[members.len() - 1] + 1;
566 }
567
568 let mut levels = vec![ground];
569 let mut dropped = Vec::new();
570 for (i, &(line, thermo, level)) in read.iter().enumerate().skip(1) {
571 let reason = match level {
572 _ if thermo.is_some() && !fit[i] => DropReason::Thickness,
573 Err(reason) => reason,
574 Ok(_) if same_pressure[i] => DropReason::SamePressure,
575 Ok(level) => {
576 let under = levels[levels.len() - 1];
577 if level.height_msl_m > under.height_msl_m
578 && level.pressure_pa < under.pressure_pa
579 {
580 levels.push(level);
581 continue;
582 }
583 DropReason::NotAbove
584 }
585 };
586 dropped.push(DroppedLevel { line, reason });
587 }
588 Ok(Self {
589 latitude_deg,
590 longitude_deg,
591 release_unix_s,
592 levels,
593 dropped,
594 })
595 }
596
597 /// The sounding as an atmosphere with its wind: every level kept, each with its pressure,
598 /// temperature, relative humidity (above 100% taken as 100%) and wind.
599 ///
600 /// # Errors
601 /// What [`SoundingProfile::new`] refuses. [`WyomingSounding::parse`] keeps only levels within
602 /// physical bounds, and [`fetch`] checks the profile too, so an answer that fails here is
603 /// never cached.
604 pub fn sounding(
605 &self,
606 wind_interpolation: WindInterpolation,
607 ) -> Result<SoundingProfile, AtmosError> {
608 let levels = self
609 .levels
610 .iter()
611 .map(|l| SoundingLevel {
612 height_msl_m: l.height_msl_m,
613 temperature_k: l.temperature_k,
614 pressure_pa: Some(l.pressure_pa),
615 relative_humidity: Some(l.relative_humidity.min(1.0)),
616 wind_speed_m_s: Some(l.wind_speed_m_s),
617 wind_direction_from_rad: Some(l.wind_direction_from_rad),
618 })
619 .collect();
620 SoundingProfile::new(levels, self.latitude_deg.to_radians(), wind_interpolation)
621 }
622}
623
624/// Fetches `request` through `client` and reads it.
625///
626/// The answer comes from the client's cache while fresh ([`WyomingRequest::source`] at `now_s`,
627/// seconds since the Unix epoch); offline, from the cache only. The [`Fetched`] says which,
628/// carries [`ATTRIBUTION`] and holds the body. Only an answer that parses and makes a sounding is
629/// cached ([`Client::fetch_checked`]): one that doesn't never takes a good copy's place, and
630/// online a stale good copy is returned instead, with the reason. A sounding the archive doesn't
631/// have is an HTTP error (404; 400 for a BUFR file a station doesn't send), which the transport
632/// reports.
633///
634/// # Errors
635/// [`WyomingError::Request`] for a bad request; [`WyomingError::Net`] when the fetch fails, or
636/// with [`NetError::Refused`] naming what [`WyomingSounding::parse`] or
637/// [`WyomingSounding::sounding`] refused when the only answer there is doesn't pass them.
638pub fn fetch<T: Transport>(
639 client: &Client<T>,
640 request: &WyomingRequest,
641 now_s: u64,
642) -> Result<(WyomingSounding, Fetched), WyomingError> {
643 let url = request.url()?;
644 let check = |body: &[u8]| {
645 // A sounding the profile would refuse is refused here too, so it is never cached.
646 let sounding = WyomingSounding::parse(body).map_err(|e| e.to_string())?;
647 sounding
648 .sounding(WindInterpolation::SpeedDirection)
649 .map(drop)
650 .map_err(|e| e.to_string())
651 };
652 let fetched = client.fetch_checked(&request.source(now_s), &url, now_s, check)?;
653 let sounding = WyomingSounding::parse(&fetched.body)?;
654 Ok((sounding, fetched))
655}
656
657/// Why a Wyoming request or answer was refused.
658#[non_exhaustive]
659#[derive(Debug, thiserror::Error)]
660pub enum WyomingError {
661 /// A request field is outside its range.
662 #[error("the request's {what} is out of range: {value}")]
663 Request {
664 /// The field.
665 what: &'static str,
666 /// Its value, cut to 40 characters.
667 value: String,
668 },
669 /// The fetch failed.
670 #[error(transparent)]
671 Net(#[from] NetError),
672 /// A column is absent.
673 #[error("the Wyoming answer has no {field} column")]
674 Missing {
675 /// The column, or `header` when there is no header line.
676 field: String,
677 },
678 /// A column is in a unit other than the one required.
679 #[error("the Wyoming answer gives {field} in {found:?}, not {expected:?}")]
680 Units {
681 /// The column.
682 field: &'static str,
683 /// The unit it came in, cut to 40 characters.
684 found: String,
685 /// The unit required.
686 expected: &'static str,
687 },
688 /// A line can't be read.
689 #[error("line {line} of the Wyoming answer: {reason}")]
690 Row {
691 /// The line, counting the header as line 1.
692 line: usize,
693 /// What is wrong with it.
694 reason: String,
695 },
696 /// There is no ground: no row, or a first row missing a value or with one out of range.
697 #[error("the Wyoming answer has no usable ground level (line {line})")]
698 NoGround {
699 /// The line of the first row, counting the header as line 1.
700 line: usize,
701 },
702 /// A chain of rows that fit, starting after the ground among the rows it could reach, beats
703 /// every chain from the ground: more rows, or as many whose layers fit more closely. The
704 /// ground is taken as wrong, and it can't be left out.
705 #[error(
706 "the Wyoming answer's ground (line {line}) doesn't fit the rows after it, which fit each \
707 other (from line {first})"
708 )]
709 GroundMisfit {
710 /// The ground's line, counting the header as line 1.
711 line: usize,
712 /// The first row of that chain.
713 first: usize,
714 },
715 /// More than [`MAX_MISFITS`] rows follow the end of the chain of rows that fit: its end, or all
716 /// of them, are wrong, or rows with no temperature leave a layer too thick to check.
717 #[error(
718 "the Wyoming answer's line {after} and the more than {MAX_MISFITS} rows from line {line} \
719 on don't fit each other: the hypsometric thickness between them is wrong"
720 )]
721 Misfit {
722 /// The chain's end, counting the header as line 1.
723 after: usize,
724 /// The first row with a pressure, height and temperature within the bounds after it.
725 line: usize,
726 },
727}
728
729/// A row's pressure, geopotential height and virtual temperature: what the thickness check needs.
730#[derive(Clone, Copy)]
731struct Thermo {
732 pressure_pa: f64,
733 geopotential_height_m: f64,
734 virtual_temperature_k: f64,
735}
736
737/// A row's [`Thermo`], when its pressure, height and temperature are within the bounds; with no
738/// humidity, or one below zero, the air is taken as dry.
739fn thermo(
740 pressure_hpa: f64,
741 geopotential_height_m: f64,
742 temperature_c: f64,
743 humidity_pct: Option<f64>,
744) -> Option<Thermo> {
745 if !(MIN_PRESSURE_HPA..=MAX_PRESSURE_HPA).contains(&pressure_hpa)
746 || !(MIN_HEIGHT_M..=MAX_HEIGHT_M).contains(&geopotential_height_m)
747 || !(MIN_TEMPERATURE_C..=MAX_TEMPERATURE_C).contains(&temperature_c)
748 {
749 return None;
750 }
751 let pressure_pa = pressure_hpa * 100.0;
752 let humidity = humidity_pct.filter(|h| *h >= 0.0).unwrap_or(0.0) / 100.0;
753 Some(Thermo {
754 pressure_pa,
755 geopotential_height_m,
756 virtual_temperature_k: virtual_temperature_k(temperature_c + 273.15, humidity, pressure_pa),
757 })
758}
759
760/// The best chain of rows ending at a row: how many rows it holds, the sum of its layers' miss
761/// shares (see `miss_share`), and the row before, as indices into the rows checked.
762#[derive(Clone, Copy)]
763struct Link {
764 rows: usize,
765 share: f64,
766 before: Option<usize>,
767}
768
769impl Link {
770 /// Whether this chain beats `other`: more rows, or as many whose layers fit more closely.
771 fn beats(self, other: Option<Link>) -> bool {
772 other.is_none_or(|o| self.rows > o.rows || (self.rows == o.rows && self.share < o.share))
773 }
774}
775
776/// For each row, the best chain ending at it in which every row fits the one before it and at
777/// most [`MAX_MISFITS`] rows are skipped at a time, or `None` if no such chain reaches it. A chain
778/// may begin at row `k` when `start(k)`. Each row looks at most `MAX_MISFITS + 1` rows back, so
779/// this is linear in the rows.
780fn chains(rows: &[Thermo], coarse: bool, start: impl Fn(usize) -> bool) -> Vec<Option<Link>> {
781 let mut best: Vec<Option<Link>> = Vec::with_capacity(rows.len());
782 for (k, row) in rows.iter().enumerate() {
783 let mut link = start(k).then_some(Link {
784 rows: 1,
785 share: 0.0,
786 before: None,
787 });
788 for j in (k.saturating_sub(MAX_MISFITS + 1)..k).rev() {
789 let Some(from) = best[j] else { continue };
790 if !fits(&rows[j], row, coarse) {
791 continue;
792 }
793 let candidate = Link {
794 rows: from.rows + 1,
795 share: from.share + miss_share(&rows[j], row, coarse),
796 before: Some(j),
797 };
798 if candidate.beats(link) {
799 link = Some(candidate);
800 }
801 }
802 best.push(link);
803 }
804 best
805}
806
807/// The row the best of `chains` ends at: the first of the best, or 0 if none has any.
808fn longest(chains: &[Option<Link>]) -> usize {
809 let mut best: Option<(usize, Link)> = None;
810 for (k, link) in chains.iter().enumerate() {
811 if let Some(link) = *link
812 && link.beats(best.map(|(_, b)| b))
813 {
814 best = Some((k, link));
815 }
816 }
817 best.map_or(0, |(k, _)| k)
818}
819
820/// The rows of the chain ending at `k`, from `k` back to its first.
821fn walk(chains: &[Option<Link>], k: usize) -> Vec<usize> {
822 let mut rows = Vec::new();
823 let mut at = chains[k].map(|_| k);
824 while let Some(k) = at {
825 rows.push(k);
826 at = chains[k].and_then(|link| link.before);
827 }
828 rows
829}
830
831/// Whether `upper`'s geopotential height above `lower` (below it, if negative) is the thickness
832/// the hypsometric equation gives the layer between their pressures, with the layer's mean virtual
833/// temperature `T̄_v` (WMO-No. 8 (2023), Vol. I, eqs. 12.17 and 12.18):
834///
835/// ```text
836/// ΔZ = (R_d T̄_v / g₀) ln(p_bottom / p_top)
837/// ```
838///
839/// It may miss by [`THICKNESS_SHARE`] of `|ΔZ|`, plus what rounding each pressure can move it
840/// (half its step, over the pressure, times `R_d T̄_v / g₀`), plus [`THICKNESS_SLACK_M`]. The
841/// step is 1 hPa for a pressure of 100 hPa or more in a `coarse` answer (a coded message's), else
842/// 0.1 hPa.
843fn fits(lower: &Thermo, upper: &Thermo, coarse: bool) -> bool {
844 miss_share(lower, upper, coarse) <= 1.0
845}
846
847/// How far `upper`'s height misses the thickness from `lower`, as a share of what [`fits`] allows:
848/// at most 1 when it fits; infinite when it can't be worked out.
849fn miss_share(lower: &Thermo, upper: &Thermo, coarse: bool) -> f64 {
850 let scale_height_m = DRY_AIR_GAS_CONSTANT_J_PER_KG_K
851 * 0.5
852 * (lower.virtual_temperature_k + upper.virtual_temperature_k)
853 / STANDARD_GRAVITY_MPS2;
854 let thickness_m = scale_height_m * (lower.pressure_pa / upper.pressure_pa).ln();
855 let half_step_pa = |p: f64| {
856 if coarse && p >= 10_000.0 { 50.0 } else { 5.0 }
857 };
858 let rounding_m = scale_height_m
859 * (half_step_pa(lower.pressure_pa) / lower.pressure_pa
860 + half_step_pa(upper.pressure_pa) / upper.pressure_pa);
861 let miss_m = upper.geopotential_height_m - lower.geopotential_height_m - thickness_m;
862 let allowance_m = THICKNESS_SHARE * thickness_m.abs() + rounding_m + THICKNESS_SLACK_M;
863 let share = miss_m.abs() / allowance_m;
864 if share.is_finite() {
865 share
866 } else {
867 f64::INFINITY
868 }
869}
870
871/// The virtual temperature, K, of air at `temperature_k` with relative humidity `humidity` (a
872/// fraction over liquid water, taken as at most 1) and pressure `pressure_pa`, as
873/// `hpr_atmos::moist` has it: `T_v = T / (1 − x_v (1 − M_v/M₀))`, with the vapour's share of the
874/// pressure `x_v = e/p` at most 1.
875fn virtual_temperature_k(temperature_k: f64, humidity: f64, pressure_pa: f64) -> f64 {
876 let vapour_pa = humidity.min(1.0) * saturation_vapour_pressure_pa(temperature_k);
877 let share = (vapour_pa / pressure_pa).min(1.0);
878 let ratio = WATER_VAPOUR_MOLECULAR_WEIGHT_KG_PER_KMOL / SEA_LEVEL_MOLECULAR_WEIGHT_KG_PER_KMOL;
879 temperature_k / (1.0 - share * (1.0 - ratio))
880}
881
882/// A column the parser reads, as an index into the parser's table of column positions.
883#[derive(Clone, Copy)]
884enum Column {
885 Time,
886 Longitude,
887 Latitude,
888 Pressure,
889 Height,
890 Temperature,
891 Humidity,
892 Direction,
893 Speed,
894}
895
896/// A row's values as a level, or why they make none.
897fn level(
898 pressure_hpa: f64,
899 geopotential_height_m: f64,
900 temperature_c: f64,
901 humidity_pct: f64,
902 wind_speed_m_s: f64,
903 direction_deg: f64,
904 latitude_rad: f64,
905) -> Result<WyomingLevel, DropReason> {
906 let temperature_k = temperature_c + 273.15;
907 if !(MIN_PRESSURE_HPA..=MAX_PRESSURE_HPA).contains(&pressure_hpa)
908 || !(MIN_TEMPERATURE_C..=MAX_TEMPERATURE_C).contains(&temperature_c)
909 || !(MIN_HEIGHT_M..=MAX_HEIGHT_M).contains(&geopotential_height_m)
910 || humidity_pct < 0.0
911 || !(0.0..=MAX_WIND_M_S).contains(&wind_speed_m_s)
912 || !(0.0..=360.0).contains(&direction_deg)
913 {
914 return Err(DropReason::OutOfRange);
915 }
916 let height_msl_m = geometric_from_wmo_geopotential_m(geopotential_height_m, latitude_rad)
917 .map_err(|_| DropReason::OutOfRange)?;
918 Ok(WyomingLevel {
919 pressure_pa: pressure_hpa * 100.0,
920 geopotential_height_m,
921 height_msl_m,
922 temperature_k,
923 relative_humidity: humidity_pct / 100.0,
924 wind_speed_m_s,
925 wind_direction_from_rad: wrap_direction(direction_deg.to_radians()),
926 })
927}
928
929/// The index of the column `name` (a header is `name_unit`, or `name` alone), checking its unit
930/// and that it appears once.
931fn find_column<'a>(
932 names: &[&'a str],
933 name: &'static str,
934 unit: &'static str,
935) -> Result<usize, WyomingError> {
936 let split = |header: &'a str| header.rsplit_once('_').unwrap_or((header, ""));
937 let mut found = names
938 .iter()
939 .enumerate()
940 .filter(|(_, header)| split(header).0 == name);
941 let Some((i, header)) = found.next() else {
942 return Err(WyomingError::Missing {
943 field: name.to_owned(),
944 });
945 };
946 if found.next().is_some() {
947 return Err(WyomingError::Row {
948 line: 1,
949 reason: format!("two {name} columns"),
950 });
951 }
952 let found_unit = split(header).1;
953 if found_unit != unit {
954 return Err(WyomingError::Units {
955 field: name,
956 found: cut(found_unit),
957 expected: unit,
958 });
959 }
960 Ok(i)
961}
962
963/// A row's fields, which must number as many as the header's. At most one more than that is
964/// split off, so a hostile row costs no more than a good one.
965fn split_row(line: &str, columns: usize, line_no: usize) -> Result<Vec<&str>, WyomingError> {
966 let fields: Vec<&str> = line.splitn(columns + 1, ',').map(str::trim).collect();
967 if fields.len() != columns {
968 let found = if fields.len() > columns {
969 format!("more than {columns}")
970 } else {
971 fields.len().to_string()
972 };
973 return Err(WyomingError::Row {
974 line: line_no,
975 reason: format!("{found} fields, not {columns}"),
976 });
977 }
978 Ok(fields)
979}
980
981/// A field as a finite number, or `None` when it is empty.
982fn number(
983 fields: &[&str],
984 index: usize,
985 line_no: usize,
986 what: &str,
987) -> Result<Option<f64>, WyomingError> {
988 let text = fields[index];
989 if text.is_empty() {
990 return Ok(None);
991 }
992 match text.parse::<f64>() {
993 Ok(value) if value.is_finite() => Ok(Some(value)),
994 _ => Err(WyomingError::Row {
995 line: line_no,
996 reason: format!("the {what} {:?} is not a number", cut(text)),
997 }),
998 }
999}
1000
1001/// `YYYY-MM-DD HH:MM:SS` as seconds since the Unix epoch, UTC.
1002fn parse_time(text: &str) -> Option<i64> {
1003 let (date, time) = text.split_once(' ')?;
1004 let mut date = date.split('-');
1005 let mut time = time.split(':');
1006 let next = |parts: &mut std::str::Split<'_, char>, digits: usize| {
1007 let part = parts.next()?;
1008 (part.len() == digits && part.bytes().all(|b| b.is_ascii_digit()))
1009 .then(|| part.parse::<i64>().ok())
1010 .flatten()
1011 };
1012 let (year, month, day) = (
1013 next(&mut date, 4)?,
1014 next(&mut date, 2)?,
1015 next(&mut date, 2)?,
1016 );
1017 let (hour, minute, second) = (
1018 next(&mut time, 2)?,
1019 next(&mut time, 2)?,
1020 next(&mut time, 2)?,
1021 );
1022 if date.next().is_some() || time.next().is_some() || hour > 23 || minute > 59 || second > 59 {
1023 return None;
1024 }
1025 Some(unix_day_start(year, month, day)? + hour * HOUR_S + minute * 60 + second)
1026}
1027
1028/// Text for an error message, cut to [`QUOTE_CHARS`] characters.
1029fn cut(text: &str) -> String {
1030 let mut cut: String = text.chars().take(QUOTE_CHARS).collect();
1031 if text.chars().nth(QUOTE_CHARS).is_some() {
1032 cut.push('…');
1033 }
1034 cut
1035}
1036
1037/// An angle in `[0, 2π)`. `rem_euclid` alone returns 2π for a tiny negative angle.
1038fn wrap_direction(angle_rad: f64) -> f64 {
1039 let wrapped = angle_rad.rem_euclid(TAU);
1040 if wrapped >= TAU { 0.0 } else { wrapped }
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045 use super::*;
1046
1047 /// A row at `pressure_hpa`, `height_m` and `virtual_k`.
1048 fn at(pressure_hpa: f64, height_m: f64, virtual_k: f64) -> Thermo {
1049 Thermo {
1050 pressure_pa: pressure_hpa * 100.0,
1051 geopotential_height_m: height_m,
1052 virtual_temperature_k: virtual_k,
1053 }
1054 }
1055
1056 /// Dry air at 288.15 K from 1000 to 900 hPa: `R_d T / g₀ = 8,434.5 m` and the thickness
1057 /// 888.665 m. Rounded to 1 hPa the pressures can move it 8.903 m, so the allowance is
1058 /// 44.433 + 8.903 + 30 = 83.336 m; rounded to 0.1 hPa (1000.5 to 900.5 hPa, thickness
1059 /// 888.197 m), 75.300 m. Worked out by hand from the equation, not by the code.
1060 #[test]
1061 fn a_layer_fits_within_its_allowance() {
1062 let lower = at(1_000.0, 0.0, 288.15);
1063 for (thickness, allowance, coarse) in [(888.665, 83.336, true), (888.197, 75.300, false)] {
1064 let (lower, upper_hpa) = if coarse {
1065 (lower, 900.0)
1066 } else {
1067 (at(1_000.5, 0.0, 288.15), 900.5)
1068 };
1069 for sign in [1.0, -1.0] {
1070 let height = |miss: f64| thickness + sign * miss;
1071 let inside = at(upper_hpa, height(allowance - 0.01), 288.15);
1072 let outside = at(upper_hpa, height(allowance + 0.01), 288.15);
1073 assert!(fits(&lower, &inside, coarse), "{coarse} {sign}");
1074 assert!(!fits(&lower, &outside, coarse), "{coarse} {sign}");
1075 }
1076 }
1077 // A coarse answer's step is 1 hPa only at 100 hPa or more: at 50 to 45 hPa, 0.1 hPa.
1078 let high = at(50.0, 20_000.0, 288.15);
1079 let thickness = 8_434.49 * (50.0_f64 / 45.0).ln();
1080 let allowance = 0.05 * thickness + 8_434.49 * (5.0 / 5_000.0 + 5.0 / 4_500.0) + 30.0;
1081 let upper = at(45.0, 20_000.0 + thickness + allowance + 0.1, 288.15);
1082 assert!(!fits(&high, &upper, true));
1083 // The layer's mean: 288.15 K below and 278.15 K above give 8,288.16 m and 873.245 m, with
1084 // an allowance of 82.411 m.
1085 let cooler = |height: f64| at(900.0, height, 278.15);
1086 assert!(fits(&lower, &cooler(873.245 + 82.40), true));
1087 assert!(!fits(&lower, &cooler(873.245 + 82.42), true));
1088 // Downward, as a falling balloon's rows are: the same layer, the other way.
1089 assert!(fits(&at(900.0, 888.665, 288.15), &lower, true));
1090 }
1091
1092 /// The guide's worked example, from the coded message's rows at 557 hPa (5,035 m, −1.7 °C,
1093 /// 79%) and 549 hPa (−2.5 °C, 81%): `T_v` 272.238 and 271.420 K, `R_d T̄_v / g₀` 7,956.79 m,
1094 /// thickness 115.109 m and allowance 5.755 + 14.389 + 30 = 50.145 m, by hand. Taken as dry,
1095 /// the thickness would be 0.33 m less.
1096 #[test]
1097 fn the_guides_example_fits_as_worked() -> Result<(), &'static str> {
1098 let lower = thermo(557.0, 5_035.0, -1.7, Some(79.0)).ok_or("lower")?;
1099 assert!((lower.virtual_temperature_k - 272.238).abs() < 1e-3);
1100 let upper = |height: f64| thermo(549.0, height, -2.5, Some(81.0)).ok_or("upper");
1101 assert!((upper(0.0)?.virtual_temperature_k - 271.420).abs() < 1e-3);
1102 for sign in [1.0, -1.0] {
1103 let height = |miss: f64| 5_035.0 + 115.109 + sign * miss;
1104 assert!(fits(&lower, &upper(height(50.135))?, true), "{sign}");
1105 assert!(!fits(&lower, &upper(height(50.155))?, true), "{sign}");
1106 }
1107 // A 557 hPa row with a digit lost, 57 hPa at 5,035 m, misses the 570 hPa row at 4,852 m by
1108 // 18.4 km.
1109 let below = thermo(570.0, 4_852.0, -0.5, Some(75.0)).ok_or("below")?;
1110 let lost = thermo(57.0, 5_035.0, -1.7, Some(79.0)).ok_or("lost")?;
1111 assert!(!fits(&below, &lost, true));
1112 Ok(())
1113 }
1114
1115 /// Saturated air at 30 °C: `T_v` is 308.081 K at 1000 hPa and 308.638 K at 900 hPa (WMO-No. 8
1116 /// eq. 12.18 with eq. 4.B.1's 4,233.8 Pa), by hand. A humidity above 100% is taken as 100%;
1117 /// vapour at more than the air's pressure counts as all of it, so `T_v` stays finite.
1118 #[test]
1119 fn virtual_temperature_follows_the_humidity() {
1120 for (pressure_pa, expected) in [(100_000.0, 308.081), (90_000.0, 308.638)] {
1121 let t_v = virtual_temperature_k(303.15, 1.0, pressure_pa);
1122 assert!((t_v - expected).abs() < 1e-3, "{t_v}");
1123 assert_eq!(virtual_temperature_k(303.15, 1.03, pressure_pa), t_v);
1124 }
1125 assert_eq!(virtual_temperature_k(288.15, 0.0, 100_000.0), 288.15);
1126 let ratio =
1127 WATER_VAPOUR_MOLECULAR_WEIGHT_KG_PER_KMOL / SEA_LEVEL_MOLECULAR_WEIGHT_KG_PER_KMOL;
1128 let all_vapour = virtual_temperature_k(333.15, 1.0, 1_000.0);
1129 assert!((all_vapour - 333.15 / ratio).abs() < 1e-9, "{all_vapour}");
1130 }
1131
1132 #[test]
1133 fn values_at_the_bounds_are_kept_and_past_them_dropped() {
1134 let at = |p: f64, z: f64, t: f64, speed: f64| level(p, z, t, 50.0, speed, 180.0, 0.5);
1135 for (p, z, t, speed) in [
1136 (0.1, 5_000.0, -10.0, 10.0),
1137 (1_200.0, 5_000.0, -10.0, 10.0),
1138 (500.0, -1_000.0, -10.0, 10.0),
1139 (500.0, 60_000.0, -10.0, 10.0),
1140 (500.0, 5_000.0, -150.0, 10.0),
1141 (500.0, 5_000.0, 80.0, 10.0),
1142 (500.0, 5_000.0, -10.0, 0.0),
1143 (500.0, 5_000.0, -10.0, 300.0),
1144 ] {
1145 assert!(at(p, z, t, speed).is_ok(), "{p} {z} {t} {speed}");
1146 }
1147 for (p, z, t, speed) in [
1148 (0.099, 5_000.0, -10.0, 10.0),
1149 (1_200.1, 5_000.0, -10.0, 10.0),
1150 (500.0, -1_000.1, -10.0, 10.0),
1151 (500.0, 60_000.1, -10.0, 10.0),
1152 (500.0, 5_000.0, -150.1, 10.0),
1153 (500.0, 5_000.0, 80.1, 10.0),
1154 (500.0, 5_000.0, -10.0, -0.1),
1155 (500.0, 5_000.0, -10.0, 300.1),
1156 ] {
1157 assert_eq!(
1158 at(p, z, t, speed),
1159 Err(DropReason::OutOfRange),
1160 "{p} {z} {t} {speed}"
1161 );
1162 }
1163 }
1164
1165 #[test]
1166 fn url_names_the_hour_station_and_version() -> Result<(), WyomingError> {
1167 let mut request = WyomingRequest::new("72364", 1_750_507_200);
1168 assert_eq!(
1169 request.url()?,
1170 "https://weather.uwyo.edu/wsgi/sounding?datetime=2025-06-21%2012:00:00&id=72364\
1171 &type=TEXT:CSV&src=FM35"
1172 );
1173 request.version = WyomingVersion::Bufr;
1174 request.endpoint = Some("http://127.0.0.1:8080/s".to_owned());
1175 assert_eq!(
1176 request.url()?,
1177 "http://127.0.0.1:8080/s?datetime=2025-06-21%2012:00:00&id=72364&type=TEXT:CSV\
1178 &src=BUFR"
1179 );
1180 Ok(())
1181 }
1182
1183 #[test]
1184 fn url_refuses_each_bad_field() {
1185 let bad = |request: WyomingRequest, field: &str| {
1186 assert!(
1187 matches!(request.url(), Err(WyomingError::Request { what, .. }) if what == field),
1188 "{request:?}"
1189 );
1190 };
1191 let at = 1_750_507_200;
1192 for station in ["", "72 364", "72364&x=1", "Ω", "12345678901234567"] {
1193 bad(WyomingRequest::new(station, at), "station");
1194 }
1195 for time in [
1196 at + 1,
1197 at + 1_800,
1198 -HOUR_S,
1199 YEAR_10000_S,
1200 i64::MAX,
1201 i64::MIN,
1202 ] {
1203 bad(WyomingRequest::new("72364", time), "time (s since 1970)");
1204 }
1205 for endpoint in ["", "https://proxy/s?key=abc", "https://proxy/s#top"] {
1206 let mut request = WyomingRequest::new("72364", at);
1207 request.endpoint = Some(endpoint.to_owned());
1208 bad(request, "endpoint");
1209 }
1210 assert!(WyomingRequest::new("1234567890123456", at).url().is_ok());
1211 assert!(
1212 WyomingRequest::new("72364", YEAR_10000_S - HOUR_S)
1213 .url()
1214 .is_ok()
1215 );
1216 }
1217
1218 #[test]
1219 fn latest_before_picks_the_last_00_or_12_utc() {
1220 let noon = 1_750_507_200; // 2025-06-21 12:00 UTC
1221 for (launch, expected) in [
1222 (noon, noon),
1223 (noon + 1, noon),
1224 (noon + 12 * HOUR_S - 1, noon),
1225 (noon + 12 * HOUR_S, noon + 12 * HOUR_S),
1226 (noon - 1, noon - 12 * HOUR_S),
1227 ] {
1228 assert_eq!(
1229 WyomingRequest::latest_before("72364", launch).time_unix_s,
1230 expected
1231 );
1232 }
1233 // The extremes saturate rather than overflow, and the URL refuses them.
1234 for launch in [i64::MIN, i64::MAX] {
1235 let request = WyomingRequest::latest_before("72364", launch);
1236 assert!(request.url().is_err(), "{request:?}");
1237 }
1238 }
1239
1240 #[test]
1241 fn times_parse_strictly() {
1242 assert_eq!(parse_time("2025-06-21 11:02:00"), Some(1_750_503_720));
1243 assert_eq!(parse_time("1970-01-01 00:00:00"), Some(0));
1244 for bad in [
1245 "",
1246 "2025-06-21",
1247 "2025-06-21T11:02:00",
1248 "2025-06-21 11:02",
1249 "2025-06-21 11:02:00:00",
1250 "2025-6-21 11:02:00",
1251 "2025-06-21 24:00:00",
1252 "2025-06-21 11:60:00",
1253 "2025-06-21 11:02:60",
1254 "2025-02-29 11:02:00",
1255 "2025-06-21 +1:02:00",
1256 "+025-06-21 11:02:00",
1257 ] {
1258 assert_eq!(parse_time(bad), None, "{bad}");
1259 }
1260 }
1261
1262 #[test]
1263 fn quotes_are_cut() {
1264 assert_eq!(cut("m/s"), "m/s");
1265 assert_eq!(cut(&"x".repeat(QUOTE_CHARS)), "x".repeat(QUOTE_CHARS));
1266 let long = cut(&"x".repeat(1_000));
1267 assert_eq!(long.chars().count(), QUOTE_CHARS + 1);
1268 assert!(long.ends_with('…'));
1269 }
1270
1271 /// Young, an answer is fresh for an hour; settled, only one fetched after it settled, for up
1272 /// to 30 days.
1273 #[test]
1274 fn freshness_follows_the_soundings_age() {
1275 let noon = 1_750_507_200_u64;
1276 let request = WyomingRequest::new("72364", 1_750_507_200);
1277 let source = request.source(noon + 3_600);
1278 assert_eq!(
1279 (source.ttl_s, source.attribution.as_str()),
1280 (YOUNG_TTL_S, ATTRIBUTION)
1281 );
1282 let settled = noon + SETTLE_S;
1283 assert_eq!(request.source(settled - 1).ttl_s, YOUNG_TTL_S);
1284 assert_eq!(request.source(settled).ttl_s, 0);
1285 assert_eq!(request.source(settled + 7_200).ttl_s, 7_200);
1286 assert_eq!(request.source(settled + 365 * 86_400).ttl_s, SETTLED_TTL_S);
1287 // A request before 1970, which `url` refuses, still has a source.
1288 assert_eq!(
1289 WyomingRequest::new("72364", -1).source(0).ttl_s,
1290 YOUNG_TTL_S
1291 );
1292 }
1293}