Skip to main content

hpr_io/era5/
mod.rs

1//! ERA5 pressure-level files: the atmosphere over a launch site at launch time, as a
2//! [`SoundingProfile`].
3//!
4//! ERA5 is ECMWF's global reanalysis, hourly on a 0.25° grid, distributed by the Copernicus
5//! Climate Data Store (Hersbach et al., "The ERA5 global reanalysis", *Q. J. R. Meteorol. Soc.*
6//! 146 (2020) 1999–2049). A pressure-level file holds, on each pressure level `p`, the
7//! geopotential `z` (m² s⁻²), temperature `t` (K) and the wind's east and north components `u`
8//! and `v` (m s⁻¹), on a grid of latitude, longitude and time. This module reads such a file in
9//! the netCDF classic formats ([`crate::netcdf`]); a netCDF-4 file from the current Climate Data
10//! Store is converted first ([`crate::netcdf::CONVERSION`]).
11//!
12//! **At the site**, each value on each level is interpolated bilinearly in latitude and
13//! longitude (in degrees) between the four grid points around the site, as RocketPy 1.13 does
14//! (`rocketpy/tools.py`, `bilinear_interpolation`, MIT):
15//!
16//! ```text
17//! f = [f₁₁ (x₂ − x)(y₂ − y) + f₂₁ (x − x₁)(y₂ − y) + f₁₂ (x₂ − x)(y − y₁) + f₂₂ (x − x₁)(y − y₁)]
18//!     / ((x₂ − x₁)(y₂ − y₁))
19//! ```
20//!
21//! with `x` the latitude and `y` the longitude.
22//!
23//! **At launch time**, the two hourly fields around it are weighted linearly in time; a launch
24//! on the hour takes that hour's field alone. RocketPy takes the nearest hour instead.
25//!
26//! **Heights.** ERA5's geopotential height is `Z = z/g₀`, with `g₀ = 9.80665 m s⁻²` fixed in
27//! ECMWF's model. "Geometric height is not represented in ERA5", and ECMWF suggests
28//! `h = R Z/(R − Z)`, "neglecting horizontal variations in the Earth's gravitational
29//! acceleration" (ECMWF Knowledge Base, "ERA5: compute pressure and geopotential on model levels,
30//! geopotential height and geometric height", captured 2026-09-26). RocketPy does that. hpr
31//! instead reads `Z` as a WMO geopotential height, as for any sounding, and takes the geometric
32//! height by WMO-No. 8 eqs. 12.15–12.16 at the site's latitude
33//! ([`geometric_from_wmo_geopotential_m`]), the relation [`SoundingProfile`] inverts.
34//!
35//! Both are approximations. Suppose the model's ground lies at true height `h_s` with geopotential
36//! `g₀ h_s`, as reading surface geopotential over `g₀` as the ground's height takes it (ECMWF does
37//! not say how the model builds it, so this is an assumption), and gravity above it falls off as
38//! WMO's formula has it. To first order hpr's reading is then off by `h_s (g₀/γ_s − 1) + h_s²/R`,
39//! the same at every height, and ECMWF's by `h_s²/R − (h − h_s)(g₀/γ_s − 1)`, which grows with the
40//! height above the model's ground (plus `h²(1/R_e − 1/R)` when ECMWF's radius `R_e` is not WMO's
41//! `R`). Neither is always the smaller. Exactly, with RocketPy's `R_e` (the WGS 84 ellipsoid's
42//! distance from the center at the site): for a model ground at 407 m at 47.2° N hpr's is −0.04 m.
43//! For one 1400 m up at 33° N hpr's is 1.88 m, and ECMWF's is 0.31 m at the ground and −3.07 m
44//! 3 km above it; ECMWF's is the smaller up to 1.95 km above that ground.
45//! hpr keeps WMO's because it is the rule [`SoundingProfile`] uses for every sounding, so a level's
46//! geopotential round-trips. The two readings differ by `g₀/γ_s(φ) − 1` of the height, −1.58e-4 at
47//! 47.21° N and +3.43e-4 at 41.78° N (−0.69 m at 4.4 km and +1.45 m at 4.2 km on the tests' files).
48//!
49//! **What it leaves out.** Humidity is not read, so the air is dry: at 20 °C and 50% relative
50//! humidity dry air is about 0.4% denser than the real air. Between and beyond the levels the
51//! profile is [`SoundingProfile`]'s: hydrostatic between levels and the offset standard atmosphere
52//! above the highest level, where RocketPy holds every value at the end level's.
53
54use std::f64::consts::TAU;
55
56use hpr_atmos::profile::geometric_from_wmo_geopotential_m;
57use hpr_atmos::wind::WindInterpolation;
58use hpr_atmos::{AtmosError, SoundingLevel, SoundingProfile};
59use serde::{Deserialize, Serialize};
60use thiserror::Error;
61
62use crate::netcdf::{NetCdf, NetCdfError, Variable};
63
64/// ERA5's `g₀`, which divides geopotential into geopotential height, m s⁻².
65pub const ERA5_GRAVITY_M_S2: f64 = 9.80665;
66
67/// Why an ERA5 profile could not be read.
68#[derive(Debug, Clone, PartialEq, Error)]
69#[non_exhaustive]
70pub enum Era5Error {
71    /// The netCDF file could not be read.
72    #[error(transparent)]
73    NetCdf(#[from] NetCdfError),
74    /// A variable the profile needs is not in the file.
75    #[error("the file has no `{name}` variable")]
76    MissingVariable {
77        /// The name, or the alternatives, looked for.
78        name: String,
79    },
80    /// A variable's dimensions are not the ones ERA5 writes.
81    #[error("`{variable}` has dimensions {found:?}; expected {expected}")]
82    Dimensions {
83        /// The variable.
84        variable: String,
85        /// Its dimensions: the first eight, then how many more there are.
86        found: Vec<String>,
87        /// What was expected.
88        expected: String,
89    },
90    /// A variable's units are not the ones expected.
91    #[error("`{variable}` is in `{units}`; expected {expected}")]
92    Units {
93        /// The variable.
94        variable: String,
95        /// Its units attribute, empty if absent.
96        units: String,
97        /// What was expected.
98        expected: String,
99    },
100    /// The time variable's units or calendar cannot be read.
101    #[error("the time axis cannot be read: {reason}")]
102    Time {
103        /// Why.
104        reason: String,
105    },
106    /// The launch time is outside the file's times.
107    #[error(
108        "the launch time ({requested} s after 1970) is outside the file's times ({first} to {last})"
109    )]
110    OutsideTimes {
111        /// The launch time, seconds since 1970-01-01T00:00Z.
112        requested: f64,
113        /// The first time in the file.
114        first: f64,
115        /// The last time in the file.
116        last: f64,
117    },
118    /// The site is outside the file's grid.
119    #[error("the site's {axis} ({value}°) is outside the file's grid ({first}° to {last}°)")]
120    OutsideGrid {
121        /// `latitude` or `longitude`.
122        axis: &'static str,
123        /// The site's coordinate.
124        value: f64,
125        /// The grid's first value.
126        first: f64,
127        /// The grid's last value.
128        last: f64,
129    },
130    /// A coordinate axis has no values.
131    #[error("the `{axis}` axis is empty")]
132    EmptyAxis {
133        /// The axis.
134        axis: String,
135    },
136    /// A coordinate value is missing (a fill value).
137    #[error("`{axis}` is missing its value at index {index}")]
138    MissingCoordinate {
139        /// The axis.
140        axis: String,
141        /// The position along it.
142        index: usize,
143    },
144    /// A coordinate axis is not strictly monotonic.
145    #[error("the `{axis}` axis is not strictly monotonic")]
146    NotMonotonic {
147        /// The axis.
148        axis: String,
149    },
150    /// A value the profile needs is missing (a fill value) in the file.
151    #[error("`{variable}` is missing at {pressure_pa} Pa near the site")]
152    MissingValue {
153        /// The variable.
154        variable: String,
155        /// The level.
156        pressure_pa: f64,
157    },
158    /// A request value is not usable.
159    #[error("{what} is {value}, which is not usable")]
160    Domain {
161        /// What the value is.
162        what: &'static str,
163        /// The value.
164        value: f64,
165    },
166    /// The atmosphere could not be built from the levels.
167    #[error(transparent)]
168    Atmos(#[from] AtmosError),
169}
170
171/// An instant in Coordinated Universal Time, as seconds since 1970-01-01T00:00:00Z (leap seconds
172/// not counted, as in POSIX time).
173#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
174#[serde(try_from = "f64", into = "f64")]
175pub struct UtcTime {
176    unix_s: f64,
177}
178
179impl UtcTime {
180    /// The instant `seconds` after 1970-01-01T00:00:00Z.
181    ///
182    /// # Errors
183    ///
184    /// [`Era5Error::Domain`] if `seconds` is not finite.
185    pub fn from_unix_seconds(seconds: f64) -> Result<Self, Era5Error> {
186        if !seconds.is_finite() {
187            return Err(Era5Error::Domain {
188                what: "the time (s)",
189                value: seconds,
190            });
191        }
192        Ok(UtcTime { unix_s: seconds })
193    }
194
195    /// The instant at a date and time of the Gregorian calendar, in UTC.
196    ///
197    /// # Errors
198    ///
199    /// [`Era5Error::Domain`] for a month outside 1 to 12, a day not in the month, an hour past
200    /// 23, a minute past 59, or a second outside `[0, 60)`.
201    pub fn from_civil(
202        year: i32,
203        month: u32,
204        day: u32,
205        hour: u32,
206        minute: u32,
207        second: f64,
208    ) -> Result<Self, Era5Error> {
209        let bad = |what, value: f64| Err(Era5Error::Domain { what, value });
210        if !(1..=12).contains(&month) {
211            return bad("the month", f64::from(month));
212        }
213        if day == 0 || day > days_in_month(year, month) {
214            return bad("the day of the month", f64::from(day));
215        }
216        if hour > 23 {
217            return bad("the hour", f64::from(hour));
218        }
219        if minute > 59 {
220            return bad("the minute", f64::from(minute));
221        }
222        if !(0.0..60.0).contains(&second) {
223            return bad("the second", second);
224        }
225        let days = days_from_civil(i64::from(year), month, day);
226        let whole = days * 86_400 + i64::from(hour) * 3600 + i64::from(minute) * 60;
227        // Exact: every whole second of five million years fits in an f64's 53 bits.
228        Ok(UtcTime {
229            unix_s: whole as f64 + second,
230        })
231    }
232
233    /// Seconds since 1970-01-01T00:00:00Z.
234    pub fn unix_seconds(self) -> f64 {
235        self.unix_s
236    }
237}
238
239impl TryFrom<f64> for UtcTime {
240    type Error = Era5Error;
241
242    fn try_from(seconds: f64) -> Result<Self, Era5Error> {
243        UtcTime::from_unix_seconds(seconds)
244    }
245}
246
247impl From<UtcTime> for f64 {
248    fn from(time: UtcTime) -> f64 {
249        time.unix_s
250    }
251}
252
253fn is_leap(year: i32) -> bool {
254    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
255}
256
257fn days_in_month(year: i32, month: u32) -> u32 {
258    match month {
259        2 if is_leap(year) => 29,
260        2 => 28,
261        4 | 6 | 9 | 11 => 30,
262        _ => 31,
263    }
264}
265
266/// Days from 1970-01-01 to a date of the proleptic Gregorian calendar: H. Hinnant,
267/// "chrono-Compatible Low-Level Date Algorithms", `days_from_civil`.
268fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
269    let y = if month <= 2 { year - 1 } else { year };
270    let era = y.div_euclid(400);
271    let year_of_era = y.rem_euclid(400);
272    let m = i64::from(month);
273    let day_of_year = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + i64::from(day) - 1;
274    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
275    era * 146_097 + day_of_era - 719_468
276}
277
278/// Where and when to read the atmosphere.
279#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
280pub struct Era5Request {
281    /// Geodetic latitude of the site, degrees north.
282    pub latitude_deg: f64,
283    /// Longitude of the site, degrees east (either `[-180, 180]` or `[0, 360)`).
284    pub longitude_deg: f64,
285    /// The launch time.
286    pub time: UtcTime,
287}
288
289/// One pressure level at the site and time.
290#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
291pub struct Era5Level {
292    /// The level's pressure, Pa.
293    pub pressure_pa: f64,
294    /// Geopotential height `Z = z/g₀`, gpm.
295    pub geopotential_height_m: f64,
296    /// Geometric height above mean sea level from `Z` at the site's latitude (WMO-No. 8), m.
297    pub height_msl_m: f64,
298    /// Temperature, K.
299    pub temperature_k: f64,
300    /// The wind's east component `u`, m/s.
301    pub wind_east_m_s: f64,
302    /// The wind's north component `v`, m/s.
303    pub wind_north_m_s: f64,
304}
305
306/// The atmosphere over a site at a time, read from an ERA5 pressure-level file.
307#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
308pub struct Era5Profile {
309    /// What was asked for.
310    pub request: Era5Request,
311    /// The file's times used and their weights: one on the hour, else the two around it.
312    pub times: Vec<(UtcTime, f64)>,
313    /// The levels, lowest (highest pressure) first.
314    pub levels: Vec<Era5Level>,
315    /// Variables on the same grid that were not read, such as humidity.
316    pub unread: Vec<String>,
317}
318
319/// The time variable's and pressure variable's names: the current Climate Data Store's first,
320/// then the older one's.
321const TIME_NAMES: [&str; 2] = ["valid_time", "time"];
322const LEVEL_NAMES: [&str; 2] = ["pressure_level", "level"];
323
324fn find<'a>(file: &'a NetCdf, names: &[&str]) -> Result<&'a Variable, Era5Error> {
325    names
326        .iter()
327        .find_map(|name| file.variable(name))
328        .ok_or_else(|| Era5Error::MissingVariable {
329            name: names.join("` or `"),
330        })
331}
332
333fn units(variable: &Variable) -> String {
334    variable
335        .attribute("units")
336        .and_then(|a| a.values.text())
337        .unwrap_or("")
338        .trim()
339        .to_owned()
340}
341
342fn check_units(variable: &Variable, accepted: &[&str]) -> Result<(), Era5Error> {
343    let found = units(variable);
344    if accepted.contains(&found.as_str()) {
345        Ok(())
346    } else {
347        Err(Era5Error::Units {
348            variable: variable.name.clone(),
349            units: found,
350            expected: format!("`{}`", accepted.join("` or `")),
351        })
352    }
353}
354
355/// A variable's dimension names, for an error: the first few and a count of the rest, so a file
356/// that names one long dimension on many axes can't make its error text grow as the square of its
357/// size.
358fn names_of(variable: &Variable) -> Vec<String> {
359    const SHOWN: usize = 8;
360    let mut names: Vec<String> = variable
361        .dimensions
362        .iter()
363        .take(SHOWN)
364        .map(|d| d.to_string())
365        .collect();
366    if let Some(rest) = variable
367        .dimensions
368        .len()
369        .checked_sub(SHOWN)
370        .filter(|&n| n > 0)
371    {
372        names.push(format!("and {rest} more"));
373    }
374    names
375}
376
377/// A one-dimensional coordinate variable's unpacked values.
378fn axis(variable: &Variable) -> Result<Vec<f64>, Era5Error> {
379    // A coordinate variable lies along the dimension of its own name (CF Conventions 1.11 §1.2),
380    // so the data variables' dimensions of that name are indexed by it.
381    if !matches!(&variable.dimensions[..], [only] if **only == *variable.name) {
382        return Err(Era5Error::Dimensions {
383            variable: variable.name.clone(),
384            found: names_of(variable),
385            expected: format!("[\"{}\"]", variable.name),
386        });
387    }
388    if variable.values.is_empty() {
389        return Err(Era5Error::EmptyAxis {
390            axis: variable.name.clone(),
391        });
392    }
393    let packing = variable.packing()?;
394    (0..variable.values.len())
395        .map(|index| {
396            variable
397                .values
398                .get(index)
399                .and_then(|stored| packing.unpack(stored))
400                .ok_or_else(|| Era5Error::MissingCoordinate {
401                    axis: variable.name.clone(),
402                    index,
403                })
404        })
405        .collect()
406}
407
408/// Two indices around `x` on a strictly monotonic axis and their weights' numerators and common
409/// denominator: `f(x) = (a₁ f[i₁] + a₂ f[i₂])/span`. A point on the axis takes that point alone.
410fn bracket(values: &[f64], x: f64) -> Option<(usize, usize, f64, f64, f64)> {
411    if let Some(i) = values.iter().position(|&v| v == x) {
412        return Some((i, i, 1.0, 0.0, 1.0));
413    }
414    values.windows(2).enumerate().find_map(|(i, pair)| {
415        let (x1, x2) = (pair[0], pair[1]);
416        let inside = (x1 < x && x < x2) || (x2 < x && x < x1);
417        inside.then(|| (i, i + 1, x2 - x, x - x1, x2 - x1))
418    })
419}
420
421fn strictly_monotonic(values: &[f64]) -> bool {
422    values.windows(2).all(|w| w[0] < w[1]) || values.windows(2).all(|w| w[0] > w[1])
423}
424
425/// Seconds per unit and the epoch, in seconds after 1970, of CF time units
426/// `"<unit> since <date>[ <time>][ <zone>]"` (CF Conventions 1.11 §4.4).
427fn time_units(units: &str, calendar: Option<&str>) -> Result<(f64, f64), Era5Error> {
428    let bad = |reason: String| Era5Error::Time { reason };
429    let calendar = calendar.map(str::to_ascii_lowercase);
430    let proleptic = match calendar.as_deref() {
431        None | Some("standard" | "gregorian") => false,
432        Some("proleptic_gregorian") => true,
433        Some(other) => return Err(bad(format!("calendar `{other}` is not read"))),
434    };
435    let mut words = units.split_whitespace();
436    let unit = words.next().unwrap_or("").to_ascii_lowercase();
437    let seconds = match unit.as_str() {
438        "seconds" | "second" | "secs" | "sec" | "s" => 1.0,
439        "minutes" | "minute" | "mins" | "min" => 60.0,
440        "hours" | "hour" | "hrs" | "hr" | "h" => 3600.0,
441        "days" | "day" | "d" => 86_400.0,
442        _ => return Err(bad(format!("units `{units}` are not a time since a date"))),
443    };
444    if words.next().map(str::to_ascii_lowercase).as_deref() != Some("since") {
445        return Err(bad(format!("units `{units}` are not a time since a date")));
446    }
447    let rest: Vec<&str> = words.collect();
448    let (date, clock, zone) = match rest.as_slice() {
449        [date] => match date.split_once('T') {
450            Some((d, t)) => (d.to_owned(), t.to_owned(), None),
451            None => ((*date).to_owned(), "0:0:0".to_owned(), None),
452        },
453        [date, clock] => ((*date).to_owned(), (*clock).to_owned(), None),
454        [date, clock, zone] => ((*date).to_owned(), (*clock).to_owned(), Some(*zone)),
455        _ => return Err(bad(format!("units `{units}` have no reference date"))),
456    };
457    let (clock, zone) = match clock.strip_suffix('Z') {
458        Some(clock) => (clock.to_owned(), Some("Z")),
459        None => (clock, zone),
460    };
461    if !matches!(
462        zone,
463        None | Some("UTC" | "Z" | "+00:00" | "+0000" | "+00" | "00:00")
464    ) {
465        return Err(bad(format!("reference time zone in `{units}` is not UTC")));
466    }
467    let number = |text: &str| text.parse::<f64>().ok();
468    let date: Vec<Option<f64>> = date.splitn(3, '-').map(number).collect();
469    let clock: Vec<Option<f64>> = clock.splitn(3, ':').map(number).collect();
470    let (Some(year), Some(month), Some(day)) = (
471        date.first().copied().flatten(),
472        date.get(1).copied().flatten(),
473        date.get(2).copied().flatten(),
474    ) else {
475        return Err(bad(format!(
476            "reference date in `{units}` is not YYYY-MM-DD"
477        )));
478    };
479    let field = |i: usize| clock.get(i).copied().unwrap_or(Some(0.0));
480    let (Some(hour), Some(minute), Some(second)) = (field(0), field(1), field(2)) else {
481        return Err(bad(format!("reference time in `{units}` is not hh:mm:ss")));
482    };
483    let whole = |x: f64| x.fract() == 0.0 && x.abs() < 1e6;
484    if ![year, month, day, hour, minute].into_iter().all(whole) {
485        return Err(bad(format!(
486            "reference date in `{units}` is not whole numbers"
487        )));
488    }
489    // The standard calendar is Julian before 1582-10-15; only the Gregorian part is read.
490    if !proleptic && (year, month, day) < (1582.0, 10.0, 15.0) {
491        return Err(bad(format!(
492            "reference date in `{units}` falls in the standard calendar's Julian part"
493        )));
494    }
495    let epoch = UtcTime::from_civil(
496        year as i32,
497        month as u32,
498        day as u32,
499        hour as u32,
500        minute as u32,
501        second,
502    )
503    .map_err(|e| bad(format!("reference date in `{units}`: {e}")))?;
504    Ok((seconds, epoch.unix_seconds()))
505}
506
507/// The dimension positions of a four-dimensional data variable: time, level, latitude,
508/// longitude.
509fn positions(variable: &Variable, names: [&str; 4]) -> Result<[usize; 4], Era5Error> {
510    let error = || Era5Error::Dimensions {
511        variable: variable.name.clone(),
512        found: names_of(variable),
513        expected: format!("{names:?} in any order"),
514    };
515    if variable.dimensions.len() != 4 {
516        return Err(error());
517    }
518    let mut out = [0; 4];
519    for (slot, name) in out.iter_mut().zip(names) {
520        *slot = variable
521            .dimensions
522            .iter()
523            .position(|d| **d == *name)
524            .ok_or_else(error)?;
525    }
526    Ok(out)
527}
528
529impl Era5Profile {
530    /// Reads the atmosphere at `request` from an ERA5 pressure-level file.
531    ///
532    /// # Errors
533    ///
534    /// [`Era5Error`]: a variable missing or with other dimensions or units, a time axis whose
535    /// units or calendar cannot be read, a site or time outside the file, a missing value around
536    /// the site, or a non-finite coordinate.
537    pub fn read(file: &NetCdf, request: Era5Request) -> Result<Self, Era5Error> {
538        for (what, value) in [
539            ("the latitude (deg)", request.latitude_deg),
540            ("the longitude (deg)", request.longitude_deg),
541        ] {
542            if !value.is_finite() {
543                return Err(Era5Error::Domain { what, value });
544            }
545        }
546        if request.latitude_deg.abs() > 90.0 {
547            return Err(Era5Error::Domain {
548                what: "the latitude (deg)",
549                value: request.latitude_deg,
550            });
551        }
552
553        // Time.
554        let time = find(file, &TIME_NAMES)?;
555        let calendar = time.attribute("calendar").and_then(|a| a.values.text());
556        let (unit_s, epoch_s) = time_units(&units(time), calendar)?;
557        let times: Vec<f64> = axis(time)?
558            .into_iter()
559            .map(|t| epoch_s + t * unit_s)
560            .collect();
561        if !times.windows(2).all(|w| w[0] < w[1]) {
562            return Err(Era5Error::NotMonotonic {
563                axis: time.name.clone(),
564            });
565        }
566        let t = request.time.unix_seconds();
567        // `axis` refuses an empty axis.
568        let (first, last) = (times[0], times[times.len() - 1]);
569        let Some((t1, t2, a1, a2, span)) = bracket(&times, t) else {
570            return Err(Era5Error::OutsideTimes {
571                requested: t,
572                first,
573                last,
574            });
575        };
576        let mut time_weights = vec![(t1, a1 / span)];
577        if t2 != t1 {
578            time_weights.push((t2, a2 / span));
579        }
580
581        // Levels.
582        let level = find(file, &LEVEL_NAMES)?;
583        let level_scale = match units(level).as_str() {
584            "millibars" | "millibar" | "mbar" | "hPa" => 100.0,
585            "Pa" => 1.0,
586            other => {
587                return Err(Era5Error::Units {
588                    variable: level.name.clone(),
589                    units: other.to_owned(),
590                    expected: "`hPa`, `millibars` or `Pa`".into(),
591                });
592            }
593        };
594        let pressures: Vec<f64> = axis(level)?.iter().map(|p| p * level_scale).collect();
595
596        // The site on the grid.
597        let latitude = find(file, &["latitude"])?;
598        let longitude = find(file, &["longitude"])?;
599        check_units(latitude, &["degrees_north"])?;
600        check_units(longitude, &["degrees_east"])?;
601        let lats = axis(latitude)?;
602        let lons = axis(longitude)?;
603        for (name, values) in [("latitude", &lats), ("longitude", &lons)] {
604            if !strictly_monotonic(values) {
605                return Err(Era5Error::NotMonotonic { axis: name.into() });
606            }
607        }
608        let outside = |axis, value, values: &[f64]| Era5Error::OutsideGrid {
609            axis,
610            value,
611            first: values.first().copied().unwrap_or(f64::NAN),
612            last: values.last().copied().unwrap_or(f64::NAN),
613        };
614        let x = request.latitude_deg;
615        let (i1, i2, xa, xb, xspan) =
616            bracket(&lats, x).ok_or_else(|| outside("latitude", x, &lats))?;
617        // The site's longitude, turned by whole turns into the grid's range.
618        let (low, high) = lons
619            .iter()
620            .fold((f64::INFINITY, f64::NEG_INFINITY), |(l, h), &v| {
621                (l.min(v), h.max(v))
622            });
623        let y = [0.0, -360.0, 360.0]
624            .into_iter()
625            .map(|turn| request.longitude_deg + turn)
626            .find(|y| (low..=high).contains(y))
627            .ok_or_else(|| outside("longitude", request.longitude_deg, &lons))?;
628        let (j1, j2, ya, yb, yspan) =
629            bracket(&lons, y).ok_or_else(|| outside("longitude", y, &lons))?;
630
631        let names = [
632            time.name.as_str(),
633            level.name.as_str(),
634            "latitude",
635            "longitude",
636        ];
637        // `f` at every level: bilinear at each time used, weighted in time.
638        let field = |name: &str, accepted: &[&str]| -> Result<Vec<f64>, Era5Error> {
639            let variable = find(file, &[name])?;
640            check_units(variable, accepted)?;
641            let at = positions(variable, names)?;
642            let packing = variable.packing()?;
643            let mut out = Vec::with_capacity(pressures.len());
644            for (k, &pressure_pa) in pressures.iter().enumerate() {
645                let value = |ti: usize, i: usize, j: usize| -> Result<f64, Era5Error> {
646                    let mut index = [0u64; 4];
647                    for (slot, n) in at.into_iter().zip([ti, k, i, j]) {
648                        index[slot] = n as u64;
649                    }
650                    variable
651                        .offset(&index)
652                        .and_then(|o| variable.values.get(o))
653                        .and_then(|stored| packing.unpack(stored))
654                        .ok_or_else(|| Era5Error::MissingValue {
655                            variable: name.to_owned(),
656                            pressure_pa,
657                        })
658                };
659                let mut total = 0.0;
660                for &(ti, weight) in &time_weights {
661                    let f11 = value(ti, i1, j1)?;
662                    let f21 = value(ti, i2, j1)?;
663                    let f12 = value(ti, i1, j2)?;
664                    let f22 = value(ti, i2, j2)?;
665                    let f = (f11 * xa * ya + f21 * xb * ya + f12 * xa * yb + f22 * xb * yb)
666                        / (xspan * yspan);
667                    total += weight * f;
668                }
669                out.push(total);
670            }
671            Ok(out)
672        };
673        let z = field("z", &["m**2 s**-2", "m2 s-2"])?;
674        let t_k = field("t", &["K"])?;
675        let u = field("u", &["m s**-1", "m s-1"])?;
676        let v = field("v", &["m s**-1", "m s-1"])?;
677
678        let latitude_rad = request.latitude_deg.to_radians();
679        let mut levels = Vec::with_capacity(pressures.len());
680        for k in 0..pressures.len() {
681            let geopotential_height_m = z[k] / ERA5_GRAVITY_M_S2;
682            levels.push(Era5Level {
683                pressure_pa: pressures[k],
684                geopotential_height_m,
685                height_msl_m: geometric_from_wmo_geopotential_m(
686                    geopotential_height_m,
687                    latitude_rad,
688                )?,
689                temperature_k: t_k[k],
690                wind_east_m_s: u[k],
691                wind_north_m_s: v[k],
692            });
693        }
694        levels.sort_by(|a, b| b.pressure_pa.total_cmp(&a.pressure_pa));
695
696        let unread = file
697            .variables
698            .iter()
699            .filter(|var| var.dimensions.len() == 4 && !["z", "t", "u", "v"].contains(&&*var.name))
700            .map(|var| var.name.clone())
701            .collect();
702        let times = time_weights
703            .into_iter()
704            .map(|(i, w)| (UtcTime { unix_s: times[i] }, w))
705            .collect();
706        Ok(Era5Profile {
707            request,
708            times,
709            levels,
710            unread,
711        })
712    }
713
714    /// The profile as an atmosphere: every level's height, temperature, pressure and wind, dry,
715    /// at the site's latitude, with the wind interpolated as `wind_interpolation`.
716    ///
717    /// # Errors
718    ///
719    /// [`AtmosError`] as [`SoundingProfile::new`] raises it, for example for heights that do not
720    /// increase from level to level.
721    pub fn sounding(
722        &self,
723        wind_interpolation: WindInterpolation,
724    ) -> Result<SoundingProfile, AtmosError> {
725        let levels = self
726            .levels
727            .iter()
728            .map(|level| SoundingLevel {
729                height_msl_m: level.height_msl_m,
730                temperature_k: level.temperature_k,
731                pressure_pa: Some(level.pressure_pa),
732                relative_humidity: None,
733                wind_speed_m_s: Some(level.wind_east_m_s.hypot(level.wind_north_m_s)),
734                wind_direction_from_rad: Some(direction_from_rad(
735                    level.wind_east_m_s,
736                    level.wind_north_m_s,
737                )),
738            })
739            .collect();
740        SoundingProfile::new(
741            levels,
742            self.request.latitude_deg.to_radians(),
743            wind_interpolation,
744        )
745    }
746}
747
748/// The direction a wind of east and north components `u` and `v` blows from, clockwise from true
749/// north, in `[0, 2π)`; 0 for a calm.
750pub fn direction_from_rad(u: f64, v: f64) -> f64 {
751    if u == 0.0 && v == 0.0 {
752        return 0.0;
753    }
754    let angle = (-u).atan2(-v).rem_euclid(TAU);
755    // `+ 0.0` turns a -0 (a wind from due north) into 0.
756    if angle >= TAU { 0.0 } else { angle + 0.0 }
757}
758
759#[cfg(test)]
760mod tests;