Skip to main content

hpr_net/
nomads.rs

1//! Weather from NOAA's GFS and RAP forecasts: a small GRIB2 cut from NOMADS' grib filter around a
2//! launch site, turned into a [`SoundingProfile`].
3//!
4//! NOAA's National Centers for Environmental Prediction (NCEP) run two models whose output
5//! [NOMADS](https://nomads.ncep.noaa.gov) serves through a *grib filter*, a web form that cuts
6//! chosen variables, levels and a latitude/longitude box out of a forecast file:
7//!
8//! | model | grid | runs | forecast hours | pressure levels asked for |
9//! |---|---|---|---|---|
10//! | [`NomadsModel::Gfs`], the Global Forecast System | 0.25° latitude/longitude | every 6 h | every hour to 120, every third to 384 | [`GFS_LEVELS_HPA`], 1000 to 10 hPa |
11//! | [`NomadsModel::Rap`], the Rapid Refresh | 13 km Lambert conformal, the contiguous U.S. and nearby Canada and Mexico | every hour | to 21; to 51 from the 03, 09, 15 and 21 UTC runs | [`RAP_LEVELS_HPA`], 1000 to 100 hPa every 25 |
12//!
13//! A [`NomadsRequest`] names the model, the run (its start, the *cycle*), the forecast hour and the
14//! site; [`NomadsRequest::url`] refuses a run or an hour the model doesn't have. Its URL asks for
15//! a box 0.3° each way around the site, which holds the four grid points around it: 9 GFS points
16//! or about 25 RAP points, 147 or 192 fields (the ground's skin temperature, `TMP` at the surface,
17//! comes too and is not used), about 28 or 40 KB. It asks for:
18//!
19//! | variable | GRIB2 parameter (discipline 0) | where |
20//! |---|---|---|
21//! | `HGT`, geopotential height, gpm | category 3, number 5 | the ground (the model's terrain) and each level |
22//! | `PRES`, pressure, Pa | 3, 0 | the ground |
23//! | `TMP`, temperature, K | 0, 0 | 2 m above the ground and each level |
24//! | `RH`, relative humidity, % | 1, 1 | 2 m above the ground and each level |
25//! | `UGRD`, `VGRD`, wind components, m/s | 2, 2 and 2, 3 | 10 m above the ground and each level |
26//!
27//! GRIB2's code table 4.2 fixes each parameter's unit, so there are no units to check.
28//! [`NomadsProfile::parse`] decodes the cut with [`hpr_io::grib2`] and interpolates every field
29//! bilinearly, in the grid's own indices, between the four grid points around the site. It keeps:
30//!
31//! - **The ground**, at the model's terrain height there, with the surface pressure, the 2 m
32//!   temperature and humidity, and the 10 m wind, as [`crate::open_meteo`] does (so the wind on
33//!   the rail is the 10 m wind, [Loft lesson L6][l6]).
34//! - **Each pressure level above the ground**: a level whose pressure is not below the surface
35//!   pressure, or whose height is not above the ground, is dropped, since the models extrapolate
36//!   beneath the terrain. So is one without every variable at every surrounding point.
37//!   [`NomadsProfile::dropped`] lists each with its reason.
38//!
39//! The ground test is made at the site only: a kept level can take weight from a grid point where
40//! it is underground, and so from the model's extrapolation there. In the recorded RAP cut, 850 hPa
41//! takes 13% of its weight from a point whose ground is at 845.8 hPa, about 0.02 K; in steep
42//! terrain it can be more. A cut of more than [`MAX_FIELDS`] fields is refused, and [`fetch`]
43//! refuses one whose grid steps and projection are not the model's ([`NomadsModel::has_grid`]).
44//!
45//! **Winds along the grid.** RAP gives its winds along the Lambert grid's axes, not east and north
46//! (GRIB2 flag table 3.3, bit 5). They are turned to east and north by the angle between the
47//! grid's `y` axis and true north at the site, `θ = n (λ − λ₀)`
48//! ([`hpr_io::grib2::Grid::earth_relative_wind`]); at Spaceport America, 12° west of RAP's central
49//! meridian, `θ` is −5.06°. The components are interpolated first and turned once, at the site:
50//! across one 13 km cell `θ` changes by about 0.06°.
51//!
52//! **Heights** are geopotential meters, which is what GRIB2 defines `HGT` in, converted to
53//! geometric heights at the site's latitude with WMO-No. 8 eq. 12.16
54//! ([`hpr_atmos::profile::geometric_from_wmo_geopotential_m`]), as for Open-Meteo. The terrain
55//! height is also given in gpm and converted the same way: at 1,400 m and 33° N the two differ by
56//! 1.9 m, so if a model's terrain is really a geometric height the ground here sits that far high
57//! ([ADR-081][adr-081]'s caveat, from the ERA5
58//! reader). **Relative humidity** is
59//! taken as over liquid water, which [`SoundingLevel`] means. Whether NCEP's models report it
60//! over ice at cold levels is not settled here; if they do, the density there shifts by under
61//! 0.1% (the bound on [`crate::open_meteo`]). A humidity above 100% is kept as recorded and
62//! clamped to 100% in [`NomadsProfile::sounding`], as [ADR-004][adr-004] (the atmosphere's
63//! design) asks of imported humidity.
64//!
65//! [`fetch`] asks a [`Client`] for the URL, so the answer comes from the cache when it can, and
66//! offline from the cache only; only an answer that decodes into a profile for the run and hour
67//! asked is cached. A run's files don't change once written, so a copy stays fresh 30 days (NOMADS
68//! keeps only recent runs: on 2026-09-30 its filters listed 10 days of GFS and 2 of RAP). The data is a U.S. government work, free of
69//! copyright; [`ATTRIBUTION`] credits it.
70//!
71//! **How far to trust it:** the decoder gives every value and grid point ecCodes does (the tests),
72//! and the profile gives back the interpolated values at every level it keeps. How good a forecast
73//! is depends on the model, and nothing here measures that. The [guide page][guide] says more.
74//!
75//! ```
76//! use hpr_atmos::WindInterpolation;
77//! use hpr_net::nomads::NomadsProfile;
78//!
79//! // A cut recorded from GFS's run of 2026-09-30 00 UTC, hour 18, around Spaceport America.
80//! let body = include_bytes!("../tests/fixtures/replay/nomads-gfs.grib2");
81//! let profile = NomadsProfile::parse(body, 32.99, -106.97)?;
82//! assert_eq!(profile.dropped.len(), 6); // 1000 to 850 hPa lie below the 1,400 m ground.
83//! let air = profile.sounding(WindInterpolation::SpeedDirection)?;
84//! let at_5_km = air.sample(5_000.0)?.air;
85//! assert!((at_5_km.pressure_pa - 55_000.0).abs() < 1_000.0);
86//! # Ok::<(), Box<dyn std::error::Error>>(())
87//! ```
88//!
89//! [l6]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l6
90//! [guide]: https://nrdptel.github.io/hpr-sim/nomads.html
91//! [adr-081]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-081-era5-weather-read-from-netcdf-classic-in-hpr-io-m23-split-a-to-c-2026-09-26
92//! [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
93
94use std::collections::HashMap;
95use std::f64::consts::TAU;
96
97use hpr_atmos::profile::geometric_from_wmo_geopotential_m;
98use hpr_atmos::{AtmosError, SoundingLevel, SoundingProfile, WindInterpolation};
99use hpr_io::grib2::{self, Field, Grib2Error, Grid, Projection};
100use serde::{Deserialize, Serialize};
101
102use crate::civil::{date_hour, unix_day_start};
103use crate::coordinate::{hundredths, units};
104use crate::{Client, Fetched, NetError, Source, Transport};
105
106/// NOMADS' grib filters, whose scripts a request's URL names.
107pub const ENDPOINT: &str = "https://nomads.ncep.noaa.gov/cgi-bin";
108
109/// The credit shown with the data. NCEP's forecasts are U.S. government works, not under
110/// copyright; the credit says where they came from.
111pub const ATTRIBUTION: &str = "Forecast data from NOAA/NCEP (GFS, RAP), via NOMADS";
112
113/// GFS's pressure levels asked for, hPa: every level it has from 1000 to 10 hPa (about 31 km).
114pub const GFS_LEVELS_HPA: [u32; 28] = [
115    1000, 975, 950, 925, 900, 850, 800, 750, 700, 650, 600, 550, 500, 450, 400, 350, 300, 250, 200,
116    150, 100, 70, 50, 40, 30, 20, 15, 10,
117];
118
119/// RAP's pressure levels asked for, hPa: all of them, 1000 to 100 hPa every 25 (about 16 km).
120pub const RAP_LEVELS_HPA: [u32; 37] = [
121    1000, 975, 950, 925, 900, 875, 850, 825, 800, 775, 750, 725, 700, 675, 650, 625, 600, 575, 550,
122    525, 500, 475, 450, 425, 400, 375, 350, 325, 300, 275, 250, 225, 200, 175, 150, 125, 100,
123];
124
125/// The box asked for reaches this far each way from the site, degrees: more than a GFS cell
126/// (0.25°) and, at the latitudes RAP covers, a rotated 13 km RAP cell.
127const BOX_DEG: f64 = 0.3;
128
129/// The most fields a cut may hold: a real one has 147 (GFS) or 192 (RAP). It bounds the work a
130/// hostile answer can ask for.
131pub const MAX_FIELDS: usize = 1_000;
132
133/// A run's file doesn't change once written.
134const TTL_S: u64 = 30 * 86_400;
135
136const HOUR_S: i64 = 3_600;
137
138/// The first second of the year 10000.
139const YEAR_10000_S: i64 = 253_402_300_800;
140
141/// GRIB2 parameters by (category, number) in discipline 0 (code table 4.2).
142const TMP: (u8, u8) = (0, 0);
143const RH: (u8, u8) = (1, 1);
144const UGRD: (u8, u8) = (2, 2);
145const VGRD: (u8, u8) = (2, 3);
146const PRES: (u8, u8) = (3, 0);
147const HGT: (u8, u8) = (3, 5);
148
149/// Fixed surface types (code table 4.5).
150const GROUND: u8 = 1;
151const ISOBARIC: u8 = 100;
152const ABOVE_GROUND: u8 = 103;
153
154/// Which NCEP model to ask for.
155#[non_exhaustive]
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
157pub enum NomadsModel {
158    /// The Global Forecast System on its 0.25° grid (`gfs.tHHz.pgrb2.0p25.fFFF`).
159    Gfs,
160    /// The Rapid Refresh on its 13 km grid 130 over the contiguous United States
161    /// (`rap.tHHz.awp130pgrbfFF.grib2`).
162    Rap,
163}
164
165impl NomadsModel {
166    /// Hours between runs: 6 for GFS (00, 06, 12, 18 UTC), 1 for RAP.
167    #[must_use]
168    pub fn cycle_hours(self) -> i64 {
169        match self {
170            Self::Gfs => 6,
171            Self::Rap => 1,
172        }
173    }
174
175    /// Whether a run starting at `cycle_hour_utc` has a forecast `forecast_hour` hours on: GFS's
176    /// every hour to 120, then every third hour to 384; RAP's every hour to 21, and to 51 from its
177    /// 03, 09, 15 and 21 UTC runs.
178    #[must_use]
179    pub fn has_forecast_hour(self, cycle_hour_utc: u32, forecast_hour: u32) -> bool {
180        match self {
181            Self::Gfs => {
182                forecast_hour <= 120 || (forecast_hour <= 384 && forecast_hour.is_multiple_of(3))
183            }
184            Self::Rap => forecast_hour <= 21 || (forecast_hour <= 51 && cycle_hour_utc % 6 == 3),
185        }
186    }
187
188    /// Whether `grid` has the model's steps and projection: GFS's 0.25° latitude/longitude steps,
189    /// or RAP's grid 130, 13,545 m Lambert conformal cells on a cone tangent at 25° N about 265° E.
190    /// A cut with others, even a self-consistent one, is not this model's answer. Where the grid
191    /// starts is not checked: the filter cuts whole grid points.
192    #[must_use]
193    pub fn has_grid(self, grid: &Grid) -> bool {
194        let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
195        match (self, grid.projection) {
196            (Self::Gfs, Projection::LatLon { di_deg, dj_deg, .. }) => {
197                near(di_deg, 0.25) && near(dj_deg, 0.25)
198            }
199            (
200                Self::Rap,
201                Projection::LambertConformal {
202                    tangent_lat_deg,
203                    orientation_lon_deg,
204                    dx_m,
205                    dy_m,
206                    ..
207                },
208            ) => {
209                near(tangent_lat_deg, 25.0)
210                    && near(orientation_lon_deg, 265.0)
211                    && near(dx_m, 13_545.0)
212                    && near(dy_m, 13_545.0)
213            }
214            _ => false,
215        }
216    }
217
218    /// The pressure levels asked for, hPa, highest pressure first.
219    #[must_use]
220    pub fn levels_hpa(self) -> &'static [u32] {
221        match self {
222            Self::Gfs => &GFS_LEVELS_HPA,
223            Self::Rap => &RAP_LEVELS_HPA,
224        }
225    }
226
227    fn script(self) -> &'static str {
228        match self {
229            Self::Gfs => "filter_gfs_0p25.pl",
230            Self::Rap => "filter_rap.pl",
231        }
232    }
233}
234
235/// What to ask NOMADS for: a model's run, a forecast hour and a site.
236#[non_exhaustive]
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238pub struct NomadsRequest {
239    /// The site's latitude, degrees north, in `[-90, 90]`.
240    pub latitude_deg: f64,
241    /// The site's longitude, degrees east, in `[-180, 180]`.
242    pub longitude_deg: f64,
243    /// The model.
244    pub model: NomadsModel,
245    /// The run's start (its cycle), seconds since the Unix epoch (UTC): a whole multiple of
246    /// [`NomadsModel::cycle_hours`], from 1970 to the year 9999.
247    pub cycle_unix_s: i64,
248    /// Hours after the cycle, one the run has ([`NomadsModel::has_forecast_hour`]).
249    pub forecast_hour: u32,
250    /// Another server's address in place of [`ENDPOINT`], such as a mirror; `None` for NOMADS.
251    /// It must hold no `?` or `#`.
252    pub endpoint: Option<String>,
253}
254
255impl NomadsRequest {
256    /// A request to NOMADS.
257    #[must_use]
258    pub fn new(
259        latitude_deg: f64,
260        longitude_deg: f64,
261        model: NomadsModel,
262        cycle_unix_s: i64,
263        forecast_hour: u32,
264    ) -> Self {
265        Self {
266            latitude_deg,
267            longitude_deg,
268            model,
269            cycle_unix_s,
270            forecast_hour,
271            endpoint: None,
272        }
273    }
274
275    /// The time the forecast is for, the cycle plus the forecast hour, s since the Unix epoch.
276    #[must_use]
277    pub fn valid_unix_s(&self) -> i64 {
278        self.cycle_unix_s
279            .saturating_add(i64::from(self.forecast_hour) * HOUR_S)
280    }
281
282    /// The grib filter's URL for the cut.
283    ///
284    /// The URL is the cache key. It holds no coordinate of the site itself, only the box's edges,
285    /// 0.3° each way from the site, to hundredths of a degree. Each edge is worked out
286    /// from the site written to 5 decimals, as [`crate::elevation`] writes it, and rounded to
287    /// hundredths in decimal, halves away from zero, so a site given to 8 decimals or fewer and
288    /// rebuilt from radians (−106.91° can come back as −106.91000000000001) asks for the same box
289    /// and finds its saved answer. The rounding moves an edge by at most 0.005° (the 5-decimal
290    /// step adds under 1e-5°), so the box still reaches more than 0.29° each way, over a GFS cell.
291    /// The cut is interpolated at the site as given, not as rounded.
292    ///
293    /// # Errors
294    /// [`NomadsError::Request`] when a field is outside the range its doc gives.
295    pub fn url(&self) -> Result<String, NomadsError> {
296        let refuse = |what: &'static str, value: String| NomadsError::Request { what, value };
297        if !(-90.0..=90.0).contains(&self.latitude_deg) {
298            return Err(refuse("latitude (deg)", self.latitude_deg.to_string()));
299        }
300        if !(-180.0..=180.0).contains(&self.longitude_deg) {
301            return Err(refuse("longitude (deg)", self.longitude_deg.to_string()));
302        }
303        let cycle_s = self.model.cycle_hours() * HOUR_S;
304        if !(0..YEAR_10000_S).contains(&self.cycle_unix_s) || self.cycle_unix_s % cycle_s != 0 {
305            return Err(refuse(
306                "cycle (s since 1970)",
307                self.cycle_unix_s.to_string(),
308            ));
309        }
310        let (_, _, _, cycle_hour) = date_hour(self.cycle_unix_s);
311        // `date_hour` gives an hour of the day, 0 to 23.
312        let cycle_hour = u32::try_from(cycle_hour).unwrap_or(0);
313        if !self.model.has_forecast_hour(cycle_hour, self.forecast_hour) {
314            return Err(refuse("forecast hour", self.forecast_hour.to_string()));
315        }
316        let endpoint = match &self.endpoint {
317            Some(endpoint) if endpoint.is_empty() || endpoint.contains(['?', '#']) => {
318                return Err(refuse("endpoint", endpoint.clone()));
319            }
320            Some(endpoint) => endpoint.as_str(),
321            None => ENDPOINT,
322        };
323        let (year, month, day, hour) = date_hour(self.cycle_unix_s);
324        let date = format!("{year:04}{month:02}{day:02}");
325        let (dir, file) = match self.model {
326            NomadsModel::Gfs => (
327                format!("%2Fgfs.{date}%2F{hour:02}%2Fatmos"),
328                format!("gfs.t{hour:02}z.pgrb2.0p25.f{:03}", self.forecast_hour),
329            ),
330            NomadsModel::Rap => (
331                format!("%2Frap.{date}"),
332                format!("rap.t{hour:02}z.awp130pgrbf{:02}.grib2", self.forecast_hour),
333            ),
334        };
335        let mut url = format!(
336            "{endpoint}/{}?dir={dir}&file={file}&var_HGT=on&var_PRES=on&var_RH=on&var_TMP=on\
337             &var_UGRD=on&var_VGRD=on&lev_surface=on&lev_2_m_above_ground=on\
338             &lev_10_m_above_ground=on",
339            self.model.script()
340        );
341        for p in self.model.levels_hpa() {
342            url.push_str(&format!("&lev_{p}_mb=on"));
343        }
344        url.push_str(&subregion(self.latitude_deg, self.longitude_deg));
345        Ok(url)
346    }
347
348    /// The [`Source`] a [`Client`] caches this request's answer under: NOMADS, [`ATTRIBUTION`],
349    /// 30 days.
350    #[must_use]
351    pub fn source(&self) -> Source {
352        Source {
353            name: "NOMADS".to_owned(),
354            attribution: ATTRIBUTION.to_owned(),
355            ttl_s: TTL_S,
356        }
357    }
358}
359
360/// The grib filter's box around a site, `&subregion=&toplat=…&leftlon=…&rightlon=…&bottomlat=…`:
361/// [`BOX_DEG`] each way from the site written to 5 decimals, each edge rounded to hundredths, as
362/// [`NomadsRequest::url`] explains. The site is checked to be within range first.
363fn subregion(latitude_deg: f64, longitude_deg: f64) -> String {
364    let (lat, lon) = (units(latitude_deg), units(longitude_deg));
365    let (half, pole) = (units(BOX_DEG), units(90.0));
366    format!(
367        "&subregion=&toplat={}&leftlon={}&rightlon={}&bottomlat={}",
368        hundredths((lat + half).min(pole)),
369        hundredths(lon - half),
370        hundredths(lon + half),
371        hundredths((lat - half).max(-pole)),
372    )
373}
374
375/// The ground under the forecast, at the site.
376#[non_exhaustive]
377#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
378pub struct NomadsSurface {
379    /// The model's terrain height as the cut gives it, gpm.
380    pub geopotential_height_m: f64,
381    /// Its geometric height above mean sea level, m (WMO-No. 8 eq. 12.16).
382    pub height_msl_m: f64,
383    /// Surface pressure, Pa.
384    pub pressure_pa: f64,
385    /// Temperature 2 m above the ground, K.
386    pub temperature_k: f64,
387    /// Relative humidity 2 m above the ground, a fraction, as recorded (it may pass 1).
388    pub relative_humidity: f64,
389    /// The 10 m wind's east component, m/s.
390    pub wind_east_m_s: f64,
391    /// The 10 m wind's north component, m/s.
392    pub wind_north_m_s: f64,
393}
394
395/// One pressure level above the ground, at the site.
396#[non_exhaustive]
397#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
398pub struct NomadsLevel {
399    /// The level's pressure, Pa.
400    pub pressure_pa: f64,
401    /// Its geopotential height as the cut gives it, gpm.
402    pub geopotential_height_m: f64,
403    /// Its geometric height above mean sea level, m (WMO-No. 8 eq. 12.16).
404    pub height_msl_m: f64,
405    /// Temperature, K.
406    pub temperature_k: f64,
407    /// Relative humidity, a fraction, as recorded (it may pass 1).
408    pub relative_humidity: f64,
409    /// The wind's east component, m/s.
410    pub wind_east_m_s: f64,
411    /// The wind's north component, m/s.
412    pub wind_north_m_s: f64,
413}
414
415/// Why a pressure level was left out of the profile.
416#[non_exhaustive]
417#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
418pub enum DropReason {
419    /// Its pressure is not below the surface pressure, or its height is not above the ground.
420    BelowGround,
421    /// A variable is not in the cut at this level, or has no value at a grid point around the
422    /// site.
423    NoData,
424}
425
426/// A pressure level left out of the profile, and why.
427#[non_exhaustive]
428#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
429pub struct DroppedLevel {
430    /// The level's pressure, Pa.
431    pub pressure_pa: f64,
432    /// Why it was dropped.
433    pub reason: DropReason,
434}
435
436/// A grid point the profile is interpolated from.
437#[non_exhaustive]
438#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
439pub struct GridPoint {
440    /// Its index in the cut's grid.
441    pub index: u64,
442    /// Latitude, degrees north.
443    pub latitude_deg: f64,
444    /// Longitude, degrees east, in `[0, 360)`.
445    pub longitude_deg: f64,
446    /// Its bilinear weight; the four sum to 1.
447    pub weight: f64,
448}
449
450/// A NOMADS cut read at a site: the ground and the pressure levels above it.
451#[non_exhaustive]
452#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
453pub struct NomadsProfile {
454    /// The site's latitude, degrees north.
455    pub latitude_deg: f64,
456    /// The site's longitude, degrees east.
457    pub longitude_deg: f64,
458    /// The run's start, s since the Unix epoch (UTC).
459    pub cycle_unix_s: i64,
460    /// The time the forecast is for, s since the Unix epoch (UTC).
461    pub valid_unix_s: i64,
462    /// The cut's grid.
463    pub grid: Grid,
464    /// The four grid points around the site, with their weights.
465    pub grid_points: [GridPoint; 4],
466    /// The angle the winds were turned by, from the grid's axes to east and north, rad; 0 when
467    /// the cut gives them east and north.
468    pub wind_turn_rad: f64,
469    /// The ground.
470    pub surface: NomadsSurface,
471    /// The pressure levels above the ground, lowest first.
472    pub levels: Vec<NomadsLevel>,
473    /// The pressure levels left out, highest pressure first.
474    pub dropped: Vec<DroppedLevel>,
475}
476
477/// What a field is: its parameter (category, number), the surface it is on (type, and value as
478/// bits so it can key a map), and the second surface of a layer. A whole GFS file holds layers
479/// that share their first surface, such as humidity over sigma 0.44 to 1 and 0.44 to 0.72.
480type Key = ((u8, u8), (u8, Option<u64>), (u8, Option<u64>));
481
482fn key(parameter: (u8, u8), surface: (u8, Option<f64>), second: (u8, Option<f64>)) -> Key {
483    let bits = |(kind, value): (u8, Option<f64>)| (kind, value.map(f64::to_bits));
484    // Type 255 is no second surface, whatever value is written with it.
485    let second = if second.0 == 255 {
486        (255, None)
487    } else {
488        bits(second)
489    };
490    (parameter, bits(surface), second)
491}
492
493/// The cut's fields by what they are, with the bilinear weights at the site.
494struct Cut<'a> {
495    fields: HashMap<Key, Field<'a>>,
496    corners: [(u64, f64); 4],
497}
498
499impl Cut<'_> {
500    fn get(&self, parameter: (u8, u8), surface: u8, value: f64) -> Option<&Field<'_>> {
501        self.fields
502            .get(&key(parameter, (surface, Some(value)), (255, None)))
503    }
504
505    /// The field's value at the site, or `None` when a grid point with weight has none. The four
506    /// points are read in one pass, which a complex-packed field (a whole GFS file's) needs.
507    fn at(&self, field: &Field<'_>) -> Result<Option<f64>, Grib2Error> {
508        let values = field.values_at(&self.corners.map(|(index, _)| index))?;
509        let mut sum = 0.0;
510        for (&(_, weight), value) in self.corners.iter().zip(values) {
511            if weight == 0.0 {
512                continue;
513            }
514            match value {
515                Some(v) => sum += weight * v,
516                None => return Ok(None),
517            }
518        }
519        Ok(Some(sum))
520    }
521
522    fn value(
523        &self,
524        parameter: (u8, u8),
525        surface: u8,
526        value: f64,
527    ) -> Result<Option<f64>, Grib2Error> {
528        match self.get(parameter, surface, value) {
529            Some(field) => self.at(field),
530            None => Ok(None),
531        }
532    }
533}
534
535impl NomadsProfile {
536    /// Reads a NOMADS cut at a site, interpolating bilinearly between the four grid points around
537    /// it.
538    ///
539    /// # Errors
540    /// - [`NomadsError::Grib`] when the body is not GRIB2 the decoder reads.
541    /// - [`NomadsError::Mixed`] when its fields differ in grid, cycle or forecast time, and
542    ///   [`NomadsError::Duplicate`] when a variable is given twice at one level.
543    /// - [`NomadsError::Outside`] when the site is not inside the cut's grid.
544    /// - [`NomadsError::NoSurface`] when a ground value is not in the cut or has no value around
545    ///   the site, and [`NomadsError::Height`] when a height is beyond what the geopotential
546    ///   conversion takes.
547    pub fn parse(body: &[u8], latitude_deg: f64, longitude_deg: f64) -> Result<Self, NomadsError> {
548        // A whole GFS file also holds statistics over an interval (template 4.8), such as
549        // accumulated rain, which a profile doesn't use.
550        let all: Vec<Field<'_>> = grib2::parse(body)?
551            .into_iter()
552            .filter(|f| f.discipline == 0 && f.product.statistics.is_none())
553            .collect();
554        if all.len() > MAX_FIELDS {
555            return Err(NomadsError::TooManyFields { count: all.len() });
556        }
557        let Some(first) = all.first() else {
558            return Err(NomadsError::NoSurface {
559                what: "any meteorological field",
560            });
561        };
562        let grid = first.grid;
563        let reference = first.reference_time;
564        let lead_s = first
565            .product
566            .forecast_time_s()
567            .ok_or(NomadsError::TimeUnit {
568                code: first.product.time_unit,
569            })?;
570        let mut fields = HashMap::with_capacity(all.len());
571        for field in all {
572            if field.grid != grid {
573                return Err(NomadsError::Mixed { what: "grid" });
574            }
575            if field.reference_time != reference {
576                return Err(NomadsError::Mixed { what: "cycle" });
577            }
578            if field.product.forecast_time_s() != Some(lead_s) {
579                return Err(NomadsError::Mixed {
580                    what: "forecast time",
581                });
582            }
583            let p = field.product;
584            let (first, second) = (p.surface, p.second_surface);
585            let k = key(
586                (p.category, p.number),
587                (first.kind, first.value),
588                (second.kind, second.value),
589            );
590            if fields.insert(k, field).is_some() {
591                return Err(NomadsError::Duplicate {
592                    category: p.category,
593                    number: p.number,
594                    surface: p.surface.kind,
595                });
596            }
597        }
598        let cycle_unix_s = unix_day_start(
599            reference.year.into(),
600            reference.month.into(),
601            reference.day.into(),
602        )
603        .ok_or(NomadsError::Mixed { what: "cycle date" })?
604            + i64::from(reference.hour) * HOUR_S
605            + i64::from(reference.minute) * 60
606            + i64::from(reference.second);
607        let corners = corners(&grid, latitude_deg, longitude_deg)?;
608        let grid_points = corners.map(|(index, weight)| {
609            let (lat, lon) = grid.point_deg(index).unwrap_or((f64::NAN, f64::NAN));
610            GridPoint {
611                index,
612                latitude_deg: lat,
613                longitude_deg: lon,
614                weight,
615            }
616        });
617        let cut = Cut { fields, corners };
618        let lat_rad = latitude_deg.to_radians();
619        let height = |gpm: f64| {
620            geometric_from_wmo_geopotential_m(gpm, lat_rad).map_err(|e| NomadsError::Height {
621                geopotential_m: gpm,
622                reason: e.to_string(),
623            })
624        };
625        let wind_turn_rad = if grid.winds_grid_relative {
626            grid.north_to_grid_y_rad(longitude_deg)
627        } else {
628            0.0
629        };
630        let ground = |parameter, surface, value, what| {
631            cut.value(parameter, surface, value)?
632                .ok_or(NomadsError::NoSurface { what })
633        };
634        let surface_gpm = ground(HGT, GROUND, 0.0, "terrain height")?;
635        let (east, north) = grid.earth_relative_wind(
636            ground(UGRD, ABOVE_GROUND, 10.0, "10 m wind, u")?,
637            ground(VGRD, ABOVE_GROUND, 10.0, "10 m wind, v")?,
638            longitude_deg,
639        );
640        let surface = NomadsSurface {
641            geopotential_height_m: surface_gpm,
642            height_msl_m: height(surface_gpm)?,
643            pressure_pa: ground(PRES, GROUND, 0.0, "surface pressure")?,
644            temperature_k: ground(TMP, ABOVE_GROUND, 2.0, "2 m temperature")?,
645            relative_humidity: ground(RH, ABOVE_GROUND, 2.0, "2 m relative humidity")? / 100.0,
646            wind_east_m_s: east,
647            wind_north_m_s: north,
648        };
649        // Every isobaric level with a temperature, highest pressure first; a layer between two
650        // surfaces is not a level.
651        let mut pressures: Vec<f64> = cut
652            .fields
653            .values()
654            .filter(|f| (f.product.category, f.product.number) == TMP)
655            .filter(|f| f.product.surface.kind == ISOBARIC && f.product.second_surface.kind == 255)
656            .filter_map(|f| f.product.surface.value)
657            .collect();
658        pressures.sort_by(|a, b| b.total_cmp(a));
659        let mut levels = Vec::new();
660        let mut dropped = Vec::new();
661        for p in pressures {
662            let read = |parameter| cut.value(parameter, ISOBARIC, p);
663            let (Some(t), Some(gpm), Some(rh), Some(u), Some(v)) =
664                (read(TMP)?, read(HGT)?, read(RH)?, read(UGRD)?, read(VGRD)?)
665            else {
666                dropped.push(DroppedLevel {
667                    pressure_pa: p,
668                    reason: DropReason::NoData,
669                });
670                continue;
671            };
672            let height_msl_m = height(gpm)?;
673            if p >= surface.pressure_pa || height_msl_m <= surface.height_msl_m {
674                dropped.push(DroppedLevel {
675                    pressure_pa: p,
676                    reason: DropReason::BelowGround,
677                });
678                continue;
679            }
680            let (east, north) = grid.earth_relative_wind(u, v, longitude_deg);
681            levels.push(NomadsLevel {
682                pressure_pa: p,
683                geopotential_height_m: gpm,
684                height_msl_m,
685                temperature_k: t,
686                relative_humidity: rh / 100.0,
687                wind_east_m_s: east,
688                wind_north_m_s: north,
689            });
690        }
691        Ok(Self {
692            latitude_deg,
693            longitude_deg,
694            cycle_unix_s,
695            valid_unix_s: cycle_unix_s + lead_s,
696            grid,
697            grid_points,
698            wind_turn_rad,
699            surface,
700            levels,
701            dropped,
702        })
703    }
704
705    /// The profile as an atmosphere with its wind: the ground, then each level above it, with
706    /// relative humidity clamped to `[0, 1]`.
707    ///
708    /// # Errors
709    /// What [`SoundingProfile::new`] refuses: heights not increasing, pressures not decreasing
710    /// upward, or a value out of range.
711    pub fn sounding(
712        &self,
713        wind_interpolation: WindInterpolation,
714    ) -> Result<SoundingProfile, AtmosError> {
715        let s = &self.surface;
716        let level = |height_msl_m, temperature_k, pressure_pa, rh: f64, east: f64, north: f64| {
717            let (speed, from) = speed_direction(east, north);
718            SoundingLevel {
719                height_msl_m,
720                temperature_k,
721                pressure_pa: Some(pressure_pa),
722                relative_humidity: Some(rh.clamp(0.0, 1.0)),
723                wind_speed_m_s: Some(speed),
724                wind_direction_from_rad: Some(from),
725            }
726        };
727        let levels = std::iter::once(level(
728            s.height_msl_m,
729            s.temperature_k,
730            s.pressure_pa,
731            s.relative_humidity,
732            s.wind_east_m_s,
733            s.wind_north_m_s,
734        ))
735        .chain(self.levels.iter().map(|l| {
736            level(
737                l.height_msl_m,
738                l.temperature_k,
739                l.pressure_pa,
740                l.relative_humidity,
741                l.wind_east_m_s,
742                l.wind_north_m_s,
743            )
744        }))
745        .collect();
746        SoundingProfile::new(levels, self.latitude_deg.to_radians(), wind_interpolation)
747    }
748}
749
750/// A wind's speed and the direction it blows from, clockwise from north in `[0, 2π)`, from its
751/// east and north components.
752fn speed_direction(east: f64, north: f64) -> (f64, f64) {
753    let from = (-east).atan2(-north).rem_euclid(TAU);
754    // `rem_euclid` of a tiny negative angle rounds to 2π itself.
755    (east.hypot(north), if from >= TAU { 0.0 } else { from })
756}
757
758/// The four grid points around a place, `(i, j)`, `(i+1, j)`, `(i, j+1)`, `(i+1, j+1)`, with their
759/// bilinear weights.
760fn corners(
761    grid: &Grid,
762    latitude_deg: f64,
763    longitude_deg: f64,
764) -> Result<[(u64, f64); 4], NomadsError> {
765    let outside = || NomadsError::Outside {
766        latitude_deg,
767        longitude_deg,
768    };
769    let (fi, fj) = grid.index_at(latitude_deg, longitude_deg);
770    let (ni, nj) = (f64::from(grid.ni), f64::from(grid.nj));
771    // A grid all the way round the Earth (a whole GFS file's) joins its last column to its first.
772    let round = grid.circles_the_earth();
773    let i_last = if round { ni } else { ni - 1.0 };
774    // The last row or column is a lower corner's `+1`, so a place on it takes the cell before.
775    if !(fi >= 0.0 && fi <= i_last && fj >= 0.0 && fj <= nj - 1.0) || ni < 2.0 || nj < 2.0 {
776        return Err(outside());
777    }
778    let i0 = fi.floor().min(i_last - 1.0);
779    let j0 = fj.floor().min(nj - 2.0);
780    let (wi, wj) = (fi - i0, fj - j0);
781    // In range by the checks above, so the casts are exact; `i0 + 1` is column 0 again on a
782    // grid round the Earth.
783    let index = |i: f64, j: f64| (i as u64) % u64::from(grid.ni) + u64::from(grid.ni) * (j as u64);
784    Ok([
785        (index(i0, j0), (1.0 - wi) * (1.0 - wj)),
786        (index(i0 + 1.0, j0), wi * (1.0 - wj)),
787        (index(i0, j0 + 1.0), (1.0 - wi) * wj),
788        (index(i0 + 1.0, j0 + 1.0), wi * wj),
789    ])
790}
791
792/// Fetches `request` through `client` and reads it at the request's site.
793///
794/// The answer comes from the client's cache while fresh; offline, from the cache only. Only an
795/// answer that decodes, builds a sounding, and is for the cycle and forecast hour asked is cached
796/// ([`Client::fetch_checked`]).
797///
798/// # Errors
799/// [`NomadsError::Request`] for a bad request; [`NomadsError::Net`] when the fetch fails, or with
800/// [`NetError::Refused`] naming what was refused when the only answer there is doesn't pass.
801pub fn fetch<T: Transport>(
802    client: &Client<T>,
803    request: &NomadsRequest,
804    now_s: u64,
805) -> Result<(NomadsProfile, Fetched), NomadsError> {
806    let url = request.url()?;
807    let (lat, lon) = (request.latitude_deg, request.longitude_deg);
808    let (cycle, valid) = (request.cycle_unix_s, request.valid_unix_s());
809    let model = request.model;
810    let check = |body: &[u8]| {
811        let profile = NomadsProfile::parse(body, lat, lon).map_err(|e| e.to_string())?;
812        if !model.has_grid(&profile.grid) {
813            return Err(format!("the cut's grid is not {model:?}'s"));
814        }
815        if profile.cycle_unix_s != cycle || profile.valid_unix_s != valid {
816            return Err(format!(
817                "the cut is the run of {} s for {} s, not the run of {cycle} s for {valid} s",
818                profile.cycle_unix_s, profile.valid_unix_s
819            ));
820        }
821        profile
822            .sounding(WindInterpolation::SpeedDirection)
823            .map(drop)
824            .map_err(|e| e.to_string())
825    };
826    let fetched = client.fetch_checked(&request.source(), &url, now_s, check)?;
827    let profile = NomadsProfile::parse(&fetched.body, lat, lon)?;
828    Ok((profile, fetched))
829}
830
831/// Why a NOMADS request or cut was refused.
832#[non_exhaustive]
833#[derive(Debug, thiserror::Error)]
834pub enum NomadsError {
835    /// A request field is outside its range.
836    #[error("the request's {what} is out of range: {value}")]
837    Request {
838        /// The field.
839        what: &'static str,
840        /// Its value.
841        value: String,
842    },
843    /// The body is not GRIB2 the decoder reads.
844    #[error(transparent)]
845    Grib(#[from] Grib2Error),
846    /// The cut's fields differ in something they must share.
847    #[error("the cut's fields differ in {what}")]
848    Mixed {
849        /// What differs.
850        what: &'static str,
851    },
852    /// The first field's forecast time is in a unit other than those
853    /// [`hpr_io::grib2::Product::forecast_time_s`] converts.
854    #[error("the cut's forecast time is in unit {code} of GRIB2 code table 4.4, which is not read")]
855    TimeUnit {
856        /// The unit's code.
857        code: u8,
858    },
859    /// The cut holds more than [`MAX_FIELDS`] fields.
860    #[error("the cut holds {count} fields, more than {MAX_FIELDS}")]
861    TooManyFields {
862        /// Its meteorological fields.
863        count: usize,
864    },
865    /// A variable is given twice on one surface.
866    #[error("parameter {category}.{number} is given twice on a surface of type {surface}")]
867    Duplicate {
868        /// Its category (code table 4.1).
869        category: u8,
870        /// Its number (code table 4.2).
871        number: u8,
872        /// The surface type (code table 4.5).
873        surface: u8,
874    },
875    /// The site is not inside the cut's grid.
876    #[error("the site ({latitude_deg}°, {longitude_deg}°) is not inside the cut's grid")]
877    Outside {
878        /// The site's latitude, degrees.
879        latitude_deg: f64,
880        /// The site's longitude, degrees.
881        longitude_deg: f64,
882    },
883    /// A ground value is not in the cut, or has no value around the site.
884    #[error("the cut has no {what} at the site")]
885    NoSurface {
886        /// What is missing.
887        what: &'static str,
888    },
889    /// A height is beyond what the geopotential conversion takes.
890    #[error("a geopotential height of {geopotential_m} gpm: {reason}")]
891    Height {
892        /// The height, gpm.
893        geopotential_m: f64,
894        /// Why it was refused.
895        reason: String,
896    },
897    /// The fetch failed, or its answer was refused.
898    #[error(transparent)]
899    Net(#[from] NetError),
900}
901
902#[cfg(test)]
903mod tests {
904    #![allow(
905        clippy::unwrap_used,
906        reason = "tests stop at the failure, as `#[test]` functions may (clippy.toml)"
907    )]
908
909    use super::*;
910
911    fn request(latitude_deg: f64, longitude_deg: f64) -> NomadsRequest {
912        // GFS's 2026-09-30 00 UTC run, hour 18.
913        NomadsRequest::new(
914            latitude_deg,
915            longitude_deg,
916            NomadsModel::Gfs,
917            1_790_726_400,
918            18,
919        )
920    }
921
922    /// A site rebuilt from radians has other last digits (−106.91° comes back as
923    /// −106.91000000000001); the URL, and so the cache key, is the same. Every longitude in steps
924    /// of 0.01°, with latitudes in steps of 0.005° (each edge is worked out on its own axis).
925    #[test]
926    fn the_url_survives_a_round_trip_through_radians() {
927        let mut changed = 0;
928        for hundredths in -18_000..=18_000 {
929            let deg = f64::from(hundredths) / 100.0;
930            let again = deg.to_radians().to_degrees();
931            changed += usize::from(again.to_bits() != deg.to_bits());
932            let lat = deg / 2.0;
933            let back = request(lat.to_radians().to_degrees(), again).url().unwrap();
934            assert_eq!(back, request(lat, deg).url().unwrap(), "{deg}");
935        }
936        // The case is real: thousands of two-decimal longitudes change on the way.
937        assert!(changed > 1_000, "{changed}");
938        let url = request(32.99, -106.910_000_000_000_01).url().unwrap();
939        assert!(
940            url.ends_with("&toplat=33.29&leftlon=-107.21&rightlon=-106.61&bottomlat=32.69"),
941            "{url}"
942        );
943    }
944
945    /// A site given to 3 decimals can put an edge on a half hundredth (32.995 + 0.3); its box is
946    /// the same after a trip through radians. Rounding each binary edge straight to hundredths, as
947    /// the URL once did, flips about 1 in 170 of them (2,103 of 360,001 when measured).
948    #[test]
949    fn half_hundredth_edges_survive_a_round_trip_through_radians() {
950        let mut flips_if_rounded_straight = 0;
951        for thousandths in -180_000..=180_000 {
952            let deg = f64::from(thousandths) / 1_000.0;
953            let again = deg.to_radians().to_degrees();
954            let straight = |d: f64| format!("{:.2}", d + BOX_DEG);
955            flips_if_rounded_straight += usize::from(straight(deg) != straight(again));
956            let lat = deg / 2.0;
957            assert_eq!(
958                subregion(lat.to_radians().to_degrees(), again),
959                subregion(lat, deg),
960                "{deg}"
961            );
962        }
963        assert!(
964            flips_if_rounded_straight > 1_000,
965            "{flips_if_rounded_straight}"
966        );
967        assert_eq!(
968            subregion(32.995, -106.995),
969            "&subregion=&toplat=33.30&leftlon=-107.30&rightlon=-106.70&bottomlat=32.70"
970        );
971        // At a pole the box stops there; at the equator and the meridian no edge is `-0.00`.
972        assert_eq!(
973            subregion(89.9, 0.0),
974            "&subregion=&toplat=90.00&leftlon=-0.30&rightlon=0.30&bottomlat=89.60"
975        );
976        assert_eq!(
977            subregion(-0.3, -0.3),
978            "&subregion=&toplat=0.00&leftlon=-0.60&rightlon=0.00&bottomlat=-0.60"
979        );
980    }
981
982    /// A humidity past saturation is kept in the profile as recorded and clamped in the sounding.
983    #[test]
984    fn humidity_past_saturation_is_clamped_in_the_sounding() {
985        let body = include_bytes!("../tests/fixtures/replay/nomads-gfs.grib2");
986        let mut profile = NomadsProfile::parse(body, 32.99, -106.97).unwrap();
987        profile.surface.relative_humidity = 1.04;
988        profile.levels[3].relative_humidity = 1.2;
989        let air = profile.sounding(WindInterpolation::SpeedDirection).unwrap();
990        let levels = air.levels();
991        assert_eq!(levels[0].relative_humidity, Some(1.0));
992        assert_eq!(levels[4].relative_humidity, Some(1.0));
993        assert_eq!(
994            levels[5].relative_humidity,
995            Some(profile.levels[4].relative_humidity)
996        );
997        assert_eq!(profile.levels[3].relative_humidity, 1.2);
998    }
999}