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}