Skip to main content

hpr_net/
open_meteo.rs

1//! Weather from Open-Meteo: a launch site's forecast (or a past day's archived forecast) on
2//! pressure levels, turned into a [`SoundingProfile`].
3//!
4//! [Open-Meteo](https://open-meteo.com/en/docs) serves numerical weather models' output as JSON.
5//! Two of its APIs carry winds on pressure levels, which a rocket needs above the surface:
6//!
7//! - the forecast API (`api.open-meteo.com/v1/forecast`), about 16 days ahead and 3 months back;
8//! - the historical-forecast API (`historical-forecast-api.open-meteo.com/v1/forecast`), the same
9//!   forecasts archived, for a past launch. (Its ERA5 archive API has no pressure levels.)
10//!
11//! An [`OpenMeteoRequest`] asks for the two whole hours around the launch time, in UTC, with
12//! these hourly variables:
13//!
14//! | variable | unit asked for | where |
15//! |---|---|---|
16//! | `temperature_2m`, `relative_humidity_2m` | °C, % | 2 m above the ground |
17//! | `surface_pressure` | hPa | the ground |
18//! | `wind_speed_10m`, `wind_direction_10m` | m/s, ° from | 10 m above the ground |
19//! | `temperature_<p>hPa`, `relative_humidity_<p>hPa` | °C, % | each of [`PRESSURE_LEVELS_HPA`] |
20//! | `wind_speed_<p>hPa`, `wind_direction_<p>hPa` | m/s, ° from | each level |
21//! | `geopotential_height_<p>hPa` | geopotential m | each level |
22//!
23//! [`OpenMeteoProfile::parse`] reads the answer, refusing any unit but those, and interpolates
24//! linearly in time between the two hours (the wind by its east and north components). It keeps:
25//!
26//! - **The surface** at the response's `elevation`: the surface pressure there, with the 2 m
27//!   temperature and humidity and the 10 m wind. Placing the 10 m wind at the ground makes it the
28//!   wind on the launch rail, as [Loft lesson L6][l6] asks, rather than a step at the lowest
29//!   pressure level.
30//! - **Each pressure level above the ground.** The models report every level, including those
31//!   below the ground at a high site, where the values are extrapolated. A level is dropped when
32//!   its pressure is not below the surface pressure or its height is not above the elevation.
33//!   A level with no data at either hour is dropped too, and so is one whose relative humidity is
34//!   outside 0 to 100%. [`OpenMeteoProfile::dropped`] lists each, with its reason.
35//!
36//! Heights are read as geopotential meters, as the weather models define them, and converted to
37//! geometric heights at the response's latitude with WMO-No. 8 eq. 12.16
38//! ([`hpr_atmos::profile::geometric_from_wmo_geopotential_m`]), as the [atmosphere page][atmos]
39//! explains. Open-Meteo's documentation calls the variable an altitude above sea level; the
40//! recorded answers' layer thicknesses bear out geopotential meters (the hypsometric check in
41//! `tests/open_meteo.rs`). Directions are the meteorological convention, the direction the wind
42//! blows from, clockwise from north. Relative humidity is taken as over liquid water, which is
43//! what [`SoundingLevel`] means by it. A model that reports it over ice at cold levels shifts the
44//! air's density there by `0.378 (e_w − e_i)/p`: at most 27 Pa of vapour pressure (near −12 °C),
45//! so under 0.03% at 400 hPa. Higher up the air is colder and the gap smaller: about 6 Pa at
46//! −40 °C, 0.08% even at 30 hPa.
47//!
48//! [`fetch`] asks a [`Client`] for the URL, so the answer comes from the cache when it can, and
49//! offline from the cache only; an answer that doesn't parse is never cached. The data is licensed
50//! CC BY 4.0: show [`ATTRIBUTION`] (it is on every [`Fetched`]) wherever the weather is shown.
51//!
52//! **How far to trust it:** the profile gives back every level it keeps as recorded (the tests);
53//! how good the forecast is depends on the weather model, and nothing here measures that. The
54//! [guide page][guide] says more.
55//!
56//! ```
57//! use hpr_atmos::WindInterpolation;
58//! use hpr_net::open_meteo::OpenMeteoProfile;
59//!
60//! // A response recorded from the historical-forecast API: Spaceport America, 2025-06-21,
61//! // 15:00 and 16:00 UTC. Ask for 15:30.
62//! let body = include_bytes!("../tests/fixtures/replay/open-meteo-historical.json");
63//! let profile = OpenMeteoProfile::parse(body, 1_750_519_800)?;
64//! assert_eq!(profile.dropped.len(), 5); // 1000 to 900 hPa lie below the 1,400 m ground.
65//! let air = profile.sounding(WindInterpolation::SpeedDirection)?;
66//! let at_5_km = air.sample(5_000.0)?.air;
67//! assert!((at_5_km.pressure_pa - 55_000.0).abs() < 1_000.0);
68//! # Ok::<(), Box<dyn std::error::Error>>(())
69//! ```
70//!
71//! [l6]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l6
72//! [atmos]: https://nrdptel.github.io/hpr-sim/physics/atmosphere.html
73//! [guide]: https://nrdptel.github.io/hpr-sim/weather.html
74
75use std::f64::consts::TAU;
76
77use hpr_atmos::profile::geometric_from_wmo_geopotential_m;
78use hpr_atmos::{AtmosError, SoundingLevel, SoundingProfile, WindInterpolation};
79use serde::{Deserialize, Serialize};
80use serde_json::Value;
81
82use crate::civil::date_hour;
83use crate::coordinate::coordinate;
84use crate::{Client, Fetched, NetError, Source, Transport};
85
86/// The pressure levels asked for, hPa: all 19 that Open-Meteo's forecast APIs serve.
87pub const PRESSURE_LEVELS_HPA: [u32; 19] = [
88    1000, 975, 950, 925, 900, 850, 800, 700, 600, 500, 400, 300, 250, 200, 150, 100, 70, 50, 30,
89];
90
91/// The credit Open-Meteo's license (CC BY 4.0) asks for, shown wherever its data is shown.
92pub const ATTRIBUTION: &str = "Weather data by Open-Meteo.com (CC BY 4.0)";
93
94/// The hourly surface variables asked for, with the units the parser requires.
95const SURFACE_VARIABLES: [(&str, &str); 5] = [
96    ("temperature_2m", "°C"),
97    ("relative_humidity_2m", "%"),
98    ("surface_pressure", "hPa"),
99    ("wind_speed_10m", "m/s"),
100    ("wind_direction_10m", "°"),
101];
102
103/// The hourly variables asked for on each pressure level, as prefixes of `_<p>hPa`, with the
104/// units the parser requires.
105const LEVEL_VARIABLES: [(&str, &str); 5] = [
106    ("temperature", "°C"),
107    ("relative_humidity", "%"),
108    ("wind_speed", "m/s"),
109    ("wind_direction", "°"),
110    ("geopotential_height", "m"),
111];
112
113/// Seconds in an hour: the forecasts' time step.
114const HOUR_S: i64 = 3_600;
115
116/// The first second of the year 10000, past which a date has no four-digit year.
117const YEAR_10000_S: i64 = 253_402_300_800;
118
119/// Which Open-Meteo API to ask.
120#[non_exhaustive]
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122pub enum OpenMeteoApi {
123    /// The forecast API: about 16 days ahead and 3 months back. A copy stays fresh for an hour.
124    Forecast,
125    /// The historical-forecast API: past forecasts, archived. A copy stays fresh for 30 days.
126    HistoricalForecast,
127}
128
129impl OpenMeteoApi {
130    /// The API's endpoint on Open-Meteo's own servers.
131    #[must_use]
132    pub fn endpoint(self) -> &'static str {
133        match self {
134            Self::Forecast => "https://api.open-meteo.com/v1/forecast",
135            Self::HistoricalForecast => {
136                "https://historical-forecast-api.open-meteo.com/v1/forecast"
137            }
138        }
139    }
140
141    /// How long a cached answer counts as fresh, s. Models run every hour to every six hours, so a
142    /// forecast is refetched after an hour; an archived forecast changes little once written.
143    #[must_use]
144    pub fn ttl_s(self) -> u64 {
145        match self {
146            Self::Forecast => 3_600,
147            Self::HistoricalForecast => 30 * 86_400,
148        }
149    }
150}
151
152/// What to ask Open-Meteo for: a place, a time and an API.
153#[non_exhaustive]
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
155pub struct OpenMeteoRequest {
156    /// Latitude, degrees north, in `[-90, 90]`.
157    pub latitude_deg: f64,
158    /// Longitude, degrees east, in `[-180, 180]`.
159    pub longitude_deg: f64,
160    /// The launch time, seconds since the Unix epoch (UTC), from 1970 to the year 9999.
161    pub time_unix_s: i64,
162    /// The API asked.
163    pub api: OpenMeteoApi,
164    /// A weather model by Open-Meteo's name (for example `"gfs_seamless"`); `None` lets it choose
165    /// the best for the place. Letters, digits and `_` only.
166    pub model: Option<String>,
167    /// Another server's endpoint in place of [`OpenMeteoApi::endpoint`], for a self-hosted
168    /// Open-Meteo; `None` for Open-Meteo's own. It must hold no `?` or `#`.
169    pub endpoint: Option<String>,
170}
171
172impl OpenMeteoRequest {
173    /// A request to Open-Meteo's own servers, with the model left to them.
174    #[must_use]
175    pub fn new(latitude_deg: f64, longitude_deg: f64, time_unix_s: i64, api: OpenMeteoApi) -> Self {
176        Self {
177            latitude_deg,
178            longitude_deg,
179            time_unix_s,
180            api,
181            model: None,
182            endpoint: None,
183        }
184    }
185
186    /// The URL that asks for the two whole hours around the launch time: the hour at or before it
187    /// and the one after.
188    ///
189    /// The URL is the cache key, so it writes each coordinate to 5 decimals, trailing zeros
190    /// dropped and `-0` as `0`, as [`crate::elevation`] does: a site given to 8 decimals or fewer
191    /// and rebuilt from radians (−106.91° can come back as −106.91000000000001) finds its saved
192    /// answer. Rounding moves the point asked for by at most 0.6 m. The finest grids among
193    /// Open-Meteo's models are about 1 km (MET Nordic's; its model list,
194    /// <https://open-meteo.com/en/docs>, read 2026-10-06): the shift is over a thousand times
195    /// smaller than the spacing of the points the answer comes from.
196    ///
197    /// # Errors
198    /// [`OpenMeteoError::Request`] when a field is outside the range its doc gives.
199    pub fn url(&self) -> Result<String, OpenMeteoError> {
200        let refuse = |what: &'static str, value: String| OpenMeteoError::Request { what, value };
201        if !(-90.0..=90.0).contains(&self.latitude_deg) {
202            return Err(refuse("latitude (deg)", self.latitude_deg.to_string()));
203        }
204        if !(-180.0..=180.0).contains(&self.longitude_deg) {
205            return Err(refuse("longitude (deg)", self.longitude_deg.to_string()));
206        }
207        // The hour after the launch hour must still have a four-digit year.
208        if !(0..YEAR_10000_S - HOUR_S).contains(&self.time_unix_s) {
209            return Err(refuse("time (s since 1970)", self.time_unix_s.to_string()));
210        }
211        let endpoint = match &self.endpoint {
212            Some(endpoint) if endpoint.is_empty() || endpoint.contains(['?', '#']) => {
213                return Err(refuse("endpoint", endpoint.clone()));
214            }
215            Some(endpoint) => endpoint.as_str(),
216            None => self.api.endpoint(),
217        };
218        let mut variables: Vec<String> = SURFACE_VARIABLES
219            .iter()
220            .map(|(name, _)| (*name).to_owned())
221            .collect();
222        for p in PRESSURE_LEVELS_HPA {
223            variables.extend(
224                LEVEL_VARIABLES
225                    .iter()
226                    .map(|(name, _)| format!("{name}_{p}hPa")),
227            );
228        }
229        let start = self.time_unix_s.div_euclid(HOUR_S) * HOUR_S;
230        let mut url = format!(
231            "{endpoint}?latitude={}&longitude={}&hourly={}&wind_speed_unit=ms&timeformat=unixtime\
232             &timezone=GMT&start_hour={}&end_hour={}",
233            coordinate(self.latitude_deg),
234            coordinate(self.longitude_deg),
235            variables.join(","),
236            iso_hour(start),
237            iso_hour(start + HOUR_S),
238        );
239        if let Some(model) = &self.model {
240            let plain = |c: char| c.is_ascii_alphanumeric() || c == '_';
241            if model.is_empty() || !model.chars().all(plain) {
242                return Err(refuse("model", model.clone()));
243            }
244            url.push_str("&models=");
245            url.push_str(model);
246        }
247        Ok(url)
248    }
249
250    /// The [`Source`] a [`Client`] caches this request's answer under: Open-Meteo, its
251    /// attribution, and the API's [`OpenMeteoApi::ttl_s`].
252    #[must_use]
253    pub fn source(&self) -> Source {
254        Source {
255            name: "Open-Meteo".to_owned(),
256            attribution: ATTRIBUTION.to_owned(),
257            ttl_s: self.api.ttl_s(),
258        }
259    }
260}
261
262/// The ground under the forecast, at the launch time.
263#[non_exhaustive]
264#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
265pub struct OpenMeteoSurface {
266    /// The response's `elevation`: the ground's height above mean sea level, m.
267    pub height_msl_m: f64,
268    /// Surface pressure, Pa.
269    pub pressure_pa: f64,
270    /// Temperature 2 m above the ground, K.
271    pub temperature_k: f64,
272    /// Relative humidity 2 m above the ground, a fraction.
273    pub relative_humidity: f64,
274    /// Wind speed 10 m above the ground, m/s.
275    pub wind_speed_m_s: f64,
276    /// Direction the 10 m wind blows from, clockwise from true north, rad in `[0, 2π)`.
277    pub wind_direction_from_rad: f64,
278}
279
280/// One pressure level above the ground, at the launch time.
281#[non_exhaustive]
282#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
283pub struct OpenMeteoLevel {
284    /// The level's pressure, Pa.
285    pub pressure_pa: f64,
286    /// Its geopotential height as the response gives it, gpm.
287    pub geopotential_height_m: f64,
288    /// Its geometric height above mean sea level, m (WMO-No. 8 eq. 12.16).
289    pub height_msl_m: f64,
290    /// Temperature, K.
291    pub temperature_k: f64,
292    /// Relative humidity, a fraction.
293    pub relative_humidity: f64,
294    /// Wind speed, m/s.
295    pub wind_speed_m_s: f64,
296    /// Direction the wind blows from, clockwise from true north, rad in `[0, 2π)`.
297    pub wind_direction_from_rad: f64,
298}
299
300/// Why a pressure level was left out of the profile.
301#[non_exhaustive]
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
303pub enum DropReason {
304    /// Its pressure is not below the surface pressure, or its height is not above the ground.
305    BelowGround,
306    /// A value is missing (`null`) at one of the two hours.
307    NoData,
308    /// Its relative humidity is outside 0 to 100%, which a sounding refuses.
309    Humidity,
310}
311
312/// A pressure level left out of the profile, and why.
313#[non_exhaustive]
314#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
315pub struct DroppedLevel {
316    /// The level's pressure, Pa.
317    pub pressure_pa: f64,
318    /// Why it was dropped.
319    pub reason: DropReason,
320}
321
322/// An Open-Meteo response read at one time: the surface and the pressure levels above it.
323#[non_exhaustive]
324#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
325pub struct OpenMeteoProfile {
326    /// The model grid point's latitude, degrees north (near the one asked for).
327    pub latitude_deg: f64,
328    /// The model grid point's longitude, degrees east.
329    pub longitude_deg: f64,
330    /// The time read, seconds since the Unix epoch (UTC).
331    pub time_unix_s: i64,
332    /// The hours interpolated between, with their weights, which sum to 1. One hour, weight 1,
333    /// when the time is on the hour.
334    pub hours: Vec<(i64, f64)>,
335    /// The ground.
336    pub surface: OpenMeteoSurface,
337    /// The pressure levels above the ground, lowest first.
338    pub levels: Vec<OpenMeteoLevel>,
339    /// The pressure levels left out, highest pressure first.
340    pub dropped: Vec<DroppedLevel>,
341}
342
343impl OpenMeteoProfile {
344    /// Reads an Open-Meteo response at `time_unix_s`, interpolating linearly between the hours
345    /// around it.
346    ///
347    /// # Errors
348    /// - [`OpenMeteoError::Json`] when the body is not JSON, [`OpenMeteoError::Server`] when it is
349    ///   Open-Meteo's error answer.
350    /// - [`OpenMeteoError::Missing`] when a field or a variable asked for is absent, and
351    ///   [`OpenMeteoError::Units`] when a variable is in a unit other than the one asked for.
352    /// - [`OpenMeteoError::TimeOutside`] when the time is outside the response's hours.
353    /// - [`OpenMeteoError::NoSurface`] when a surface value is missing at either hour, and
354    ///   [`OpenMeteoError::OutOfRange`] when the surface humidity is outside 0 to 100%.
355    /// - [`OpenMeteoError::Atmos`] when a geopotential height has no geometric height.
356    pub fn parse(body: &[u8], time_unix_s: i64) -> Result<Self, OpenMeteoError> {
357        let root: Value =
358            serde_json::from_slice(body).map_err(|e| OpenMeteoError::Json(e.to_string()))?;
359        if root.get("error").and_then(Value::as_bool) == Some(true) {
360            let reason = root
361                .get("reason")
362                .and_then(Value::as_str)
363                .unwrap_or("no reason given");
364            return Err(OpenMeteoError::Server {
365                reason: reason.to_owned(),
366            });
367        }
368        let latitude_deg = number(&root, "latitude")?;
369        let longitude_deg = number(&root, "longitude")?;
370        let elevation_m = number(&root, "elevation")?;
371        let units = root
372            .get("hourly_units")
373            .ok_or_else(|| missing("hourly_units"))?;
374        let hourly = root.get("hourly").ok_or_else(|| missing("hourly"))?;
375        let series = Series::new(hourly, units, time_unix_s)?;
376        let latitude_rad = latitude_deg.to_radians();
377
378        let surface_value = |name: &str, unit: &'static str| match series.value(name, unit)? {
379            Some(value) => Ok(value),
380            None => Err(OpenMeteoError::NoSurface {
381                field: name.to_owned(),
382            }),
383        };
384        // Name the missing one of the pair before reading them as a wind.
385        surface_value("wind_speed_10m", "m/s")?;
386        surface_value("wind_direction_10m", "°")?;
387        let (wind_speed_m_s, wind_direction_from_rad) =
388            match series.wind("wind_speed_10m", "wind_direction_10m")? {
389                Some(wind) => wind,
390                None => {
391                    return Err(OpenMeteoError::NoSurface {
392                        field: "wind_speed_10m".to_owned(),
393                    });
394                }
395            };
396        let surface = OpenMeteoSurface {
397            height_msl_m: elevation_m,
398            pressure_pa: surface_value("surface_pressure", "hPa")? * 100.0,
399            temperature_k: celsius_to_kelvin(surface_value("temperature_2m", "°C")?),
400            relative_humidity: surface_value("relative_humidity_2m", "%")? / 100.0,
401            wind_speed_m_s,
402            wind_direction_from_rad,
403        };
404        if !(0.0..=1.0).contains(&surface.relative_humidity) {
405            return Err(OpenMeteoError::OutOfRange {
406                field: "relative_humidity_2m".to_owned(),
407                value: surface.relative_humidity * 100.0,
408            });
409        }
410
411        let mut levels = Vec::new();
412        let mut dropped = Vec::new();
413        for p in PRESSURE_LEVELS_HPA {
414            let pressure_pa = f64::from(p) * 100.0;
415            let name = |prefix: &str| format!("{prefix}_{p}hPa");
416            let geopotential = series.value(&name("geopotential_height"), "m")?;
417            let temperature = series.value(&name("temperature"), "°C")?;
418            let humidity = series.value(&name("relative_humidity"), "%")?;
419            let wind = series.wind(&name("wind_speed"), &name("wind_direction"))?;
420            let (Some(geopotential_height_m), Some(temperature_c), Some(humidity), Some(wind)) =
421                (geopotential, temperature, humidity, wind)
422            else {
423                dropped.push(DroppedLevel {
424                    pressure_pa,
425                    reason: DropReason::NoData,
426                });
427                continue;
428            };
429            let height_msl_m =
430                geometric_from_wmo_geopotential_m(geopotential_height_m, latitude_rad)?;
431            if pressure_pa >= surface.pressure_pa || height_msl_m <= surface.height_msl_m {
432                dropped.push(DroppedLevel {
433                    pressure_pa,
434                    reason: DropReason::BelowGround,
435                });
436                continue;
437            }
438            if !(0.0..=100.0).contains(&humidity) {
439                dropped.push(DroppedLevel {
440                    pressure_pa,
441                    reason: DropReason::Humidity,
442                });
443                continue;
444            }
445            levels.push(OpenMeteoLevel {
446                pressure_pa,
447                geopotential_height_m,
448                height_msl_m,
449                temperature_k: celsius_to_kelvin(temperature_c),
450                relative_humidity: humidity / 100.0,
451                wind_speed_m_s: wind.0,
452                wind_direction_from_rad: wind.1,
453            });
454        }
455        Ok(Self {
456            latitude_deg,
457            longitude_deg,
458            time_unix_s,
459            hours: series.hours(),
460            surface,
461            levels,
462            dropped,
463        })
464    }
465
466    /// The profile as an atmosphere with its wind: the surface, then each level above it, every
467    /// one with its pressure, temperature, relative humidity and wind.
468    ///
469    /// # Errors
470    /// What [`SoundingProfile::new`] refuses: heights not increasing, pressures not decreasing
471    /// upward, or a value out of range (such as a relative humidity above 100%).
472    pub fn sounding(
473        &self,
474        wind_interpolation: WindInterpolation,
475    ) -> Result<SoundingProfile, AtmosError> {
476        let s = &self.surface;
477        let surface = SoundingLevel {
478            height_msl_m: s.height_msl_m,
479            temperature_k: s.temperature_k,
480            pressure_pa: Some(s.pressure_pa),
481            relative_humidity: Some(s.relative_humidity),
482            wind_speed_m_s: Some(s.wind_speed_m_s),
483            wind_direction_from_rad: Some(s.wind_direction_from_rad),
484        };
485        let levels = std::iter::once(surface)
486            .chain(self.levels.iter().map(|l| SoundingLevel {
487                height_msl_m: l.height_msl_m,
488                temperature_k: l.temperature_k,
489                pressure_pa: Some(l.pressure_pa),
490                relative_humidity: Some(l.relative_humidity),
491                wind_speed_m_s: Some(l.wind_speed_m_s),
492                wind_direction_from_rad: Some(l.wind_direction_from_rad),
493            }))
494            .collect();
495        SoundingProfile::new(levels, self.latitude_deg.to_radians(), wind_interpolation)
496    }
497}
498
499/// Fetches `request` through `client` and reads it at the request's time.
500///
501/// The answer comes from the client's cache while fresh (see [`OpenMeteoRequest::source`]); offline,
502/// from the cache only. The [`Fetched`] says which, carries [`ATTRIBUTION`] and holds the body.
503/// Only an answer that parses is cached ([`Client::fetch_checked`]): one that doesn't never takes
504/// a good copy's place, and online a stale good copy is returned instead, with the reason.
505///
506/// # Errors
507/// [`OpenMeteoError::Request`] for a bad request; [`OpenMeteoError::Net`] when the fetch fails,
508/// or with [`NetError::Refused`] naming what [`OpenMeteoProfile::parse`] or
509/// [`OpenMeteoProfile::sounding`] refused when the only answer there is doesn't pass them.
510pub fn fetch<T: Transport>(
511    client: &Client<T>,
512    request: &OpenMeteoRequest,
513    now_s: u64,
514) -> Result<(OpenMeteoProfile, Fetched), OpenMeteoError> {
515    let url = request.url()?;
516    let time_s = request.time_unix_s;
517    let check = |body: &[u8]| {
518        // A profile the sounding would refuse is refused here too, so it is never cached.
519        let profile = OpenMeteoProfile::parse(body, time_s).map_err(|e| e.to_string())?;
520        profile
521            .sounding(WindInterpolation::SpeedDirection)
522            .map(drop)
523            .map_err(|e| e.to_string())
524    };
525    let fetched = client.fetch_checked(&request.source(), &url, now_s, check)?;
526    let profile = OpenMeteoProfile::parse(&fetched.body, time_s)?;
527    Ok((profile, fetched))
528}
529
530/// Why an Open-Meteo request or response was refused.
531#[non_exhaustive]
532#[derive(Debug, thiserror::Error)]
533pub enum OpenMeteoError {
534    /// A request field is outside its range.
535    #[error("the request's {what} is out of range: {value}")]
536    Request {
537        /// The field.
538        what: &'static str,
539        /// Its value.
540        value: String,
541    },
542    /// The fetch failed.
543    #[error(transparent)]
544    Net(#[from] NetError),
545    /// The body is not JSON.
546    #[error("the Open-Meteo response is not JSON: {0}")]
547    Json(String),
548    /// The body is Open-Meteo's error answer, from [`OpenMeteoProfile::parse`]. Open-Meteo sends
549    /// it with an HTTP error status, which `Http` reports as [`NetError::Transport`] without the
550    /// body; [`fetch`] reports any answer that doesn't parse as [`NetError::Refused`], with this
551    /// error's message.
552    #[error("Open-Meteo refused the request: {reason}")]
553    Server {
554        /// Its reason.
555        reason: String,
556    },
557    /// A field or variable is absent, or not of the expected type.
558    #[error("the Open-Meteo response has no usable {field}")]
559    Missing {
560        /// The field.
561        field: String,
562    },
563    /// A variable is in a unit other than the one asked for.
564    #[error("the Open-Meteo response gives {field} in {found:?}, not {expected:?}")]
565    Units {
566        /// The variable.
567        field: String,
568        /// The unit it came in.
569        found: String,
570        /// The unit asked for.
571        expected: &'static str,
572    },
573    /// The time is outside the response's hours.
574    #[error("{time_unix_s} s is outside the Open-Meteo response's hours, {first_s} to {last_s} s")]
575    TimeOutside {
576        /// The time asked for.
577        time_unix_s: i64,
578        /// The first hour in the response.
579        first_s: i64,
580        /// The last hour in the response.
581        last_s: i64,
582    },
583    /// A surface value is outside its range at the launch time.
584    #[error("the Open-Meteo response's {field} is out of range at the launch time: {value}")]
585    OutOfRange {
586        /// The variable.
587        field: String,
588        /// Its value, in the response's unit.
589        value: f64,
590    },
591    /// A surface value is missing at the launch time.
592    #[error("the Open-Meteo response has no {field} at the launch time")]
593    NoSurface {
594        /// The variable.
595        field: String,
596    },
597    /// A height or level was refused by the atmosphere.
598    #[error(transparent)]
599    Atmos(#[from] AtmosError),
600}
601
602/// The `hourly` arrays of a response, read at one time.
603struct Series<'a> {
604    hourly: &'a Value,
605    units: &'a Value,
606    /// The index and time of the hour at or before the time, and of the hour after with its
607    /// weight (the before hour's is one minus it); `after` is `None` when the time is on an hour.
608    before: (usize, i64),
609    after: Option<(usize, i64, f64)>,
610}
611
612impl<'a> Series<'a> {
613    fn new(hourly: &'a Value, units: &'a Value, time_unix_s: i64) -> Result<Self, OpenMeteoError> {
614        let unit = units.get("time").and_then(Value::as_str);
615        if unit != Some("unixtime") {
616            return Err(OpenMeteoError::Units {
617                field: "time".to_owned(),
618                found: unit.unwrap_or("nothing").to_owned(),
619                expected: "unixtime",
620            });
621        }
622        let times = hourly
623            .get("time")
624            .and_then(Value::as_array)
625            .ok_or_else(|| missing("hourly.time"))?
626            .iter()
627            .map(|t| t.as_i64().ok_or_else(|| missing("hourly.time")))
628            .collect::<Result<Vec<_>, _>>()?;
629        // Times from 1970 to the year 9999 keep every difference below far from overflow.
630        if times.iter().any(|t| !(0..=YEAR_10000_S).contains(t)) {
631            return Err(missing("hourly.time from 1970 to 9999"));
632        }
633        if times.windows(2).any(|w| w[1] <= w[0]) {
634            return Err(missing("hourly.time in increasing order"));
635        }
636        let (Some(&first_s), Some(&last_s)) = (times.first(), times.last()) else {
637            return Err(missing("hourly.time"));
638        };
639        if !(first_s..=last_s).contains(&time_unix_s) {
640            return Err(OpenMeteoError::TimeOutside {
641                time_unix_s,
642                first_s,
643                last_s,
644            });
645        }
646        // The last hour at or before the time; it exists because the time is not before the first.
647        let i = times.partition_point(|&t| t <= time_unix_s) - 1;
648        let after = if times[i] == time_unix_s {
649            None
650        } else {
651            // Not on the last hour, so another follows.
652            let t1 = times[i + 1];
653            #[expect(
654                clippy::cast_precision_loss,
655                reason = "hour spans and offsets are far below 2^52 s"
656            )]
657            let weight = (time_unix_s - times[i]) as f64 / (t1 - times[i]) as f64;
658            Some((i + 1, t1, weight))
659        };
660        Ok(Self {
661            hourly,
662            units,
663            before: (i, times[i]),
664            after,
665        })
666    }
667
668    fn hours(&self) -> Vec<(i64, f64)> {
669        match self.after {
670            None => vec![(self.before.1, 1.0)],
671            Some((_, t1, w)) => vec![(self.before.1, 1.0 - w), (t1, w)],
672        }
673    }
674
675    /// A variable's values at the one or two hours, after checking its unit; `None` when either
676    /// is `null`.
677    fn raw(&self, name: &str, unit: &'static str) -> Result<Option<(f64, f64)>, OpenMeteoError> {
678        let found = self.units.get(name).and_then(Value::as_str);
679        if found != Some(unit) {
680            return Err(OpenMeteoError::Units {
681                field: name.to_owned(),
682                found: found.unwrap_or("nothing").to_owned(),
683                expected: unit,
684            });
685        }
686        let values = self
687            .hourly
688            .get(name)
689            .and_then(Value::as_array)
690            .ok_or_else(|| missing(name))?;
691        let at = |i: usize| -> Result<Option<f64>, OpenMeteoError> {
692            match values.get(i) {
693                Some(Value::Null) => Ok(None),
694                Some(v) => v.as_f64().map(Some).ok_or_else(|| missing(name)),
695                None => Err(missing(name)),
696            }
697        };
698        let a = at(self.before.0)?;
699        let b = match self.after {
700            Some((j, _, _)) => at(j)?,
701            None => a,
702        };
703        Ok(a.zip(b))
704    }
705
706    /// A variable at the time, linear between the hours.
707    fn value(&self, name: &str, unit: &'static str) -> Result<Option<f64>, OpenMeteoError> {
708        Ok(self.raw(name, unit)?.map(|(a, b)| match self.after {
709            None => a,
710            // Exact when a == b, and never outside [a, b]: 100% humidity stays 100%.
711            Some((_, _, w)) => a + w * (b - a),
712        }))
713    }
714
715    /// A wind's speed (m/s) and direction from (rad in `[0, 2π)`) at the time. Between hours its
716    /// east and north components are interpolated, so a veering wind passes through the shorter
717    /// arc and a reversing one through calm.
718    fn wind(&self, speed: &str, direction: &str) -> Result<Option<(f64, f64)>, OpenMeteoError> {
719        let (Some(s), Some(d)) = (self.raw(speed, "m/s")?, self.raw(direction, "°")?) else {
720            return Ok(None);
721        };
722        Ok(Some(match self.after {
723            None => (s.0, wrap_direction(d.0.to_radians())),
724            Some((_, _, w)) => {
725                let (d0, d1) = (d.0.to_radians(), d.1.to_radians());
726                // The components of the velocity the wind blows toward, east and north.
727                let east = (1.0 - w) * -s.0 * d0.sin() + w * -s.1 * d1.sin();
728                let north = (1.0 - w) * -s.0 * d0.cos() + w * -s.1 * d1.cos();
729                let speed = east.hypot(north);
730                let from = if speed > 0.0 {
731                    wrap_direction((-east).atan2(-north))
732                } else {
733                    0.0
734                };
735                (speed, from)
736            }
737        }))
738    }
739}
740
741fn missing(field: &str) -> OpenMeteoError {
742    OpenMeteoError::Missing {
743        field: field.to_owned(),
744    }
745}
746
747fn number(root: &Value, field: &str) -> Result<f64, OpenMeteoError> {
748    root.get(field)
749        .and_then(Value::as_f64)
750        .ok_or_else(|| missing(field))
751}
752
753/// An angle in `[0, 2π)`. `rem_euclid` alone returns 2π for a tiny negative angle, such as the
754/// `−2.4e-16` that `atan2` gives for a wind from 360°.
755fn wrap_direction(angle_rad: f64) -> f64 {
756    let wrapped = angle_rad.rem_euclid(TAU);
757    if wrapped >= TAU { 0.0 } else { wrapped }
758}
759
760fn celsius_to_kelvin(celsius: f64) -> f64 {
761    celsius + 273.15
762}
763
764/// `YYYY-MM-DDTHH:00` for a whole hour since the Unix epoch, UTC, for times on or after
765/// 1970-01-01.
766fn iso_hour(unix_s: i64) -> String {
767    let (year, month, day, hour) = date_hour(unix_s);
768    format!("{year:04}-{month:02}-{day:02}T{hour:02}:00")
769}
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774
775    #[test]
776    fn iso_hour_matches_known_dates() {
777        assert_eq!(iso_hour(0), "1970-01-01T00:00");
778        assert_eq!(iso_hour(1_750_518_000), "2025-06-21T15:00");
779        // A leap day, and the last hour of a century that is a leap year.
780        assert_eq!(iso_hour(951_782_400), "2000-02-29T00:00");
781        assert_eq!(iso_hour(978_303_600), "2000-12-31T23:00");
782        assert_eq!(iso_hour(YEAR_10000_S - HOUR_S), "9999-12-31T23:00");
783    }
784
785    /// A site rebuilt from radians has other last digits (−106.91° comes back as
786    /// −106.91000000000001); the URL, and so the cache key, is the same. Every longitude in steps
787    /// of 0.01°, with latitudes in steps of 0.005°.
788    #[test]
789    fn the_url_survives_a_round_trip_through_radians() -> Result<(), OpenMeteoError> {
790        let request = |lat: f64, lon: f64| {
791            OpenMeteoRequest::new(lat, lon, 1_750_519_800, OpenMeteoApi::HistoricalForecast).url()
792        };
793        let mut changed = 0;
794        for hundredths in -18_000..=18_000 {
795            let deg = f64::from(hundredths) / 100.0;
796            let again = deg.to_radians().to_degrees();
797            changed += usize::from(again.to_bits() != deg.to_bits());
798            let lat = deg / 2.0;
799            let back = request(lat.to_radians().to_degrees(), again)?;
800            assert_eq!(back, request(lat, deg)?, "{deg}");
801        }
802        // The case is real: thousands of two-decimal longitudes change on the way.
803        assert!(changed > 1_000, "{changed}");
804        let url = request(-0.0, -106.910_000_000_000_01)?;
805        assert!(url.contains("?latitude=0&longitude=-106.91&"), "{url}");
806        Ok(())
807    }
808
809    #[test]
810    fn url_asks_for_the_two_hours_around_the_time() -> Result<(), OpenMeteoError> {
811        let mut request = OpenMeteoRequest::new(
812            32.99,
813            -106.97,
814            1_750_519_800,
815            OpenMeteoApi::HistoricalForecast,
816        );
817        let url = request.url()?;
818        assert!(url.starts_with(
819            "https://historical-forecast-api.open-meteo.com/v1/forecast?latitude=32.99\
820             &longitude=-106.97&hourly=temperature_2m,relative_humidity_2m,surface_pressure,"
821        ));
822        assert!(url.ends_with(
823            "&wind_speed_unit=ms&timeformat=unixtime&timezone=GMT\
824             &start_hour=2025-06-21T15:00&end_hour=2025-06-21T16:00"
825        ));
826        // 5 surface variables and 5 on each of 19 levels.
827        let hourly = url
828            .split("hourly=")
829            .nth(1)
830            .and_then(|s| s.split('&').next());
831        assert_eq!(hourly.map(|h| h.split(',').count()), Some(100));
832
833        request.model = Some("gfs_seamless".into());
834        request.endpoint = Some("http://127.0.0.1:8080/v1/forecast".into());
835        let url = request.url()?;
836        assert!(url.starts_with("http://127.0.0.1:8080/v1/forecast?latitude="));
837        assert!(url.ends_with("&models=gfs_seamless"));
838        Ok(())
839    }
840
841    #[test]
842    fn url_refuses_each_bad_field() {
843        let good = OpenMeteoRequest::new(32.99, -106.97, 0, OpenMeteoApi::Forecast);
844        let cases: [(&str, OpenMeteoRequest); 9] = [
845            (
846                "latitude (deg)",
847                OpenMeteoRequest {
848                    latitude_deg: 90.5,
849                    ..good.clone()
850                },
851            ),
852            (
853                "latitude (deg)",
854                OpenMeteoRequest {
855                    latitude_deg: f64::NAN,
856                    ..good.clone()
857                },
858            ),
859            (
860                "longitude (deg)",
861                OpenMeteoRequest {
862                    longitude_deg: -181.0,
863                    ..good.clone()
864                },
865            ),
866            (
867                "time (s since 1970)",
868                OpenMeteoRequest {
869                    time_unix_s: -1,
870                    ..good.clone()
871                },
872            ),
873            (
874                "time (s since 1970)",
875                OpenMeteoRequest {
876                    time_unix_s: YEAR_10000_S - HOUR_S,
877                    ..good.clone()
878                },
879            ),
880            (
881                "model",
882                OpenMeteoRequest {
883                    model: Some("gfs&x=1".into()),
884                    ..good.clone()
885                },
886            ),
887            (
888                "endpoint",
889                OpenMeteoRequest {
890                    endpoint: Some("http://h/v1#x".into()),
891                    ..good.clone()
892                },
893            ),
894            (
895                "endpoint",
896                OpenMeteoRequest {
897                    endpoint: Some("http://h/?a".into()),
898                    ..good.clone()
899                },
900            ),
901            (
902                "endpoint",
903                OpenMeteoRequest {
904                    endpoint: Some(String::new()),
905                    ..good.clone()
906                },
907            ),
908        ];
909        for (field, request) in cases {
910            match request.url() {
911                Err(OpenMeteoError::Request { what, .. }) => assert_eq!(what, field),
912                other => panic!("{field}: {other:?}"),
913            }
914        }
915        assert!(good.url().is_ok());
916    }
917
918    #[test]
919    fn sources_keep_forecasts_an_hour_and_archives_a_month() {
920        let forecast = OpenMeteoRequest::new(0.0, 0.0, 0, OpenMeteoApi::Forecast).source();
921        let archive = OpenMeteoRequest::new(0.0, 0.0, 0, OpenMeteoApi::HistoricalForecast).source();
922        assert_eq!((forecast.ttl_s, archive.ttl_s), (3_600, 2_592_000));
923        assert_eq!(forecast.attribution, ATTRIBUTION);
924    }
925}