Skip to main content

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}