1use 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
64pub const ERA5_GRAVITY_M_S2: f64 = 9.80665;
66
67#[derive(Debug, Clone, PartialEq, Error)]
69#[non_exhaustive]
70pub enum Era5Error {
71 #[error(transparent)]
73 NetCdf(#[from] NetCdfError),
74 #[error("the file has no `{name}` variable")]
76 MissingVariable {
77 name: String,
79 },
80 #[error("`{variable}` has dimensions {found:?}; expected {expected}")]
82 Dimensions {
83 variable: String,
85 found: Vec<String>,
87 expected: String,
89 },
90 #[error("`{variable}` is in `{units}`; expected {expected}")]
92 Units {
93 variable: String,
95 units: String,
97 expected: String,
99 },
100 #[error("the time axis cannot be read: {reason}")]
102 Time {
103 reason: String,
105 },
106 #[error(
108 "the launch time ({requested} s after 1970) is outside the file's times ({first} to {last})"
109 )]
110 OutsideTimes {
111 requested: f64,
113 first: f64,
115 last: f64,
117 },
118 #[error("the site's {axis} ({value}°) is outside the file's grid ({first}° to {last}°)")]
120 OutsideGrid {
121 axis: &'static str,
123 value: f64,
125 first: f64,
127 last: f64,
129 },
130 #[error("the `{axis}` axis is empty")]
132 EmptyAxis {
133 axis: String,
135 },
136 #[error("`{axis}` is missing its value at index {index}")]
138 MissingCoordinate {
139 axis: String,
141 index: usize,
143 },
144 #[error("the `{axis}` axis is not strictly monotonic")]
146 NotMonotonic {
147 axis: String,
149 },
150 #[error("`{variable}` is missing at {pressure_pa} Pa near the site")]
152 MissingValue {
153 variable: String,
155 pressure_pa: f64,
157 },
158 #[error("{what} is {value}, which is not usable")]
160 Domain {
161 what: &'static str,
163 value: f64,
165 },
166 #[error(transparent)]
168 Atmos(#[from] AtmosError),
169}
170
171#[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 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 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 Ok(UtcTime {
229 unix_s: whole as f64 + second,
230 })
231 }
232
233 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
266fn 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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
280pub struct Era5Request {
281 pub latitude_deg: f64,
283 pub longitude_deg: f64,
285 pub time: UtcTime,
287}
288
289#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
291pub struct Era5Level {
292 pub pressure_pa: f64,
294 pub geopotential_height_m: f64,
296 pub height_msl_m: f64,
298 pub temperature_k: f64,
300 pub wind_east_m_s: f64,
302 pub wind_north_m_s: f64,
304}
305
306#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
308pub struct Era5Profile {
309 pub request: Era5Request,
311 pub times: Vec<(UtcTime, f64)>,
313 pub levels: Vec<Era5Level>,
315 pub unread: Vec<String>,
317}
318
319const 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
355fn 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
377fn axis(variable: &Variable) -> Result<Vec<f64>, Era5Error> {
379 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
408fn 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
425fn 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 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
507fn 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 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 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 let (first, last) = (times[0], times[times.len() - 1]);
569 let Some((t1, t2, a1, a2, span)) = bracket(×, 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 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 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 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 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 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
748pub 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 if angle >= TAU { 0.0 } else { angle + 0.0 }
757}
758
759#[cfg(test)]
760mod tests;