1use 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
86pub 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
91pub const ATTRIBUTION: &str = "Weather data by Open-Meteo.com (CC BY 4.0)";
93
94const 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
103const 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
113const HOUR_S: i64 = 3_600;
115
116const YEAR_10000_S: i64 = 253_402_300_800;
118
119#[non_exhaustive]
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122pub enum OpenMeteoApi {
123 Forecast,
125 HistoricalForecast,
127}
128
129impl OpenMeteoApi {
130 #[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 #[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#[non_exhaustive]
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
155pub struct OpenMeteoRequest {
156 pub latitude_deg: f64,
158 pub longitude_deg: f64,
160 pub time_unix_s: i64,
162 pub api: OpenMeteoApi,
164 pub model: Option<String>,
167 pub endpoint: Option<String>,
170}
171
172impl OpenMeteoRequest {
173 #[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 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 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 #[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#[non_exhaustive]
264#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
265pub struct OpenMeteoSurface {
266 pub height_msl_m: f64,
268 pub pressure_pa: f64,
270 pub temperature_k: f64,
272 pub relative_humidity: f64,
274 pub wind_speed_m_s: f64,
276 pub wind_direction_from_rad: f64,
278}
279
280#[non_exhaustive]
282#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
283pub struct OpenMeteoLevel {
284 pub pressure_pa: f64,
286 pub geopotential_height_m: f64,
288 pub height_msl_m: f64,
290 pub temperature_k: f64,
292 pub relative_humidity: f64,
294 pub wind_speed_m_s: f64,
296 pub wind_direction_from_rad: f64,
298}
299
300#[non_exhaustive]
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
303pub enum DropReason {
304 BelowGround,
306 NoData,
308 Humidity,
310}
311
312#[non_exhaustive]
314#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
315pub struct DroppedLevel {
316 pub pressure_pa: f64,
318 pub reason: DropReason,
320}
321
322#[non_exhaustive]
324#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
325pub struct OpenMeteoProfile {
326 pub latitude_deg: f64,
328 pub longitude_deg: f64,
330 pub time_unix_s: i64,
332 pub hours: Vec<(i64, f64)>,
335 pub surface: OpenMeteoSurface,
337 pub levels: Vec<OpenMeteoLevel>,
339 pub dropped: Vec<DroppedLevel>,
341}
342
343impl OpenMeteoProfile {
344 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 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 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
499pub 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 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#[non_exhaustive]
532#[derive(Debug, thiserror::Error)]
533pub enum OpenMeteoError {
534 #[error("the request's {what} is out of range: {value}")]
536 Request {
537 what: &'static str,
539 value: String,
541 },
542 #[error(transparent)]
544 Net(#[from] NetError),
545 #[error("the Open-Meteo response is not JSON: {0}")]
547 Json(String),
548 #[error("Open-Meteo refused the request: {reason}")]
553 Server {
554 reason: String,
556 },
557 #[error("the Open-Meteo response has no usable {field}")]
559 Missing {
560 field: String,
562 },
563 #[error("the Open-Meteo response gives {field} in {found:?}, not {expected:?}")]
565 Units {
566 field: String,
568 found: String,
570 expected: &'static str,
572 },
573 #[error("{time_unix_s} s is outside the Open-Meteo response's hours, {first_s} to {last_s} s")]
575 TimeOutside {
576 time_unix_s: i64,
578 first_s: i64,
580 last_s: i64,
582 },
583 #[error("the Open-Meteo response's {field} is out of range at the launch time: {value}")]
585 OutOfRange {
586 field: String,
588 value: f64,
590 },
591 #[error("the Open-Meteo response has no {field} at the launch time")]
593 NoSurface {
594 field: String,
596 },
597 #[error(transparent)]
599 Atmos(#[from] AtmosError),
600}
601
602struct Series<'a> {
604 hourly: &'a Value,
605 units: &'a Value,
606 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 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 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 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 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 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 Some((_, _, w)) => a + w * (b - a),
712 }))
713 }
714
715 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 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
753fn 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
764fn 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 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 #[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 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 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}