use std::collections::BTreeMap;
use crate::error::{Error, Result};
use crate::transport::build_cds_url;
pub const ERA5_PRESSURE_LEVELS_DATASET: &str = "reanalysis-era5-pressure-levels";
pub const ERA5_PRESSURE_LEVELS_HPA: [i32; 37] = [
1000, 975, 950, 925, 900, 875, 850, 825, 800, 775, 750, 700, 650, 600, 550, 500, 450, 400, 350, 300, 250, 225, 200, 175, 150, 125, 100, 70, 50, 30, 20, 10, 7, 5, 3, 2, 1,
];
pub const ERA5_VARIABLES: &[(&str, &str)] = &[
("divergence", "d"),
("fraction_of_cloud_cover", "cc"),
("geopotential", "z"),
("ozone_mass_mixing_ratio", "o3"),
("potential_vorticity", "pv"),
("relative_humidity", "r"),
("specific_cloud_ice_water_content", "ciwc"),
("specific_cloud_liquid_water_content", "clwc"),
("specific_humidity", "q"),
("specific_rain_water_content", "crwc"),
("specific_snow_water_content", "cswc"),
("temperature", "t"),
("u_component_of_wind", "u"),
("v_component_of_wind", "v"),
("vertical_velocity", "w"),
("vorticity", "vo"),
];
pub fn all_variables() -> Vec<String> {
ERA5_VARIABLES
.iter()
.map(|(long, _)| (*long).to_string())
.collect()
}
pub fn variable_short_name(cds_name: &str) -> Option<&'static str> {
ERA5_VARIABLES
.iter()
.find(|(long, _)| *long == cds_name)
.map(|(_, short)| *short)
}
pub fn pressure_levels_from_indices(indices: &[usize]) -> Result<Vec<i32>> {
indices
.iter()
.map(|&i| {
if i == 0 || i > ERA5_PRESSURE_LEVELS_HPA.len() {
Err(Error::Format {
format: "era5".to_string(),
detail: format!(
"pressure-level index {i} out of range 1..={}",
ERA5_PRESSURE_LEVELS_HPA.len()
),
})
} else {
Ok(ERA5_PRESSURE_LEVELS_HPA[i - 1])
}
})
.collect()
}
pub fn area_from_bounds(lon: (f64, f64), lat: (f64, f64)) -> [i32; 4] {
let (lon_min, lon_max) = (lon.0.min(lon.1), lon.0.max(lon.1));
let (lat_min, lat_max) = (lat.0.min(lat.1), lat.0.max(lat.1));
[
(lat_max + 1.0).ceil() as i32, (lon_min - 1.0).floor() as i32, (lat_min - 1.0).floor() as i32, (lon_max + 1.0).ceil() as i32, ]
}
pub fn months_in_range(start: (i32, u32), end: (i32, u32)) -> Vec<(i32, u32)> {
let mut out = Vec::new();
let (mut y, mut m) = start;
while (y, m) <= end {
out.push((y, m));
m += 1;
if m > 12 {
m = 1;
y += 1;
}
}
out
}
pub fn days_in_month(year: i32, month: u32) -> Result<Vec<u32>> {
let n = match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap_year(year) => 29,
2 => 28,
_ => {
return Err(Error::Format {
format: "era5".to_string(),
detail: format!("invalid month {month} (expected 1..=12)"),
})
}
};
Ok((1..=n).collect())
}
fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
#[derive(Debug, Clone)]
pub struct Era5PressureLevels {
pub variables: Vec<String>,
pub pressure_levels: Vec<i32>,
pub area: [i32; 4],
}
impl Era5PressureLevels {
pub const DATASET: &'static str = ERA5_PRESSURE_LEVELS_DATASET;
pub fn request_json(&self, year: i32, month: u32, days: &[u32]) -> String {
let mut variable = self.variables.clone();
variable.sort();
variable.dedup();
let mut plevels = self.pressure_levels.clone();
plevels.sort_unstable_by(|a, b| b.cmp(a)); plevels.dedup();
let pressure_level: Vec<String> = plevels.iter().map(|p| p.to_string()).collect();
let mut day_nums = days.to_vec();
day_nums.sort_unstable();
day_nums.dedup();
let day: Vec<String> = day_nums.iter().map(|d| format!("{d:02}")).collect();
let time: Vec<String> = (0..24).map(|h| format!("{h:02}:00")).collect();
let mut req: BTreeMap<&str, serde_json::Value> = BTreeMap::new();
req.insert("product_type", serde_json::json!(["reanalysis"]));
req.insert("variable", serde_json::json!(variable));
req.insert("pressure_level", serde_json::json!(pressure_level));
req.insert("year", serde_json::json!([year.to_string()]));
req.insert("month", serde_json::json!([format!("{month:02}")]));
req.insert("day", serde_json::json!(day));
req.insert("time", serde_json::json!(time));
req.insert("data_format", serde_json::json!("netcdf"));
req.insert("download_format", serde_json::json!("unarchived"));
req.insert("area", serde_json::json!(self.area.to_vec()));
serde_json::to_string(&req).expect("BTreeMap<&str, Value> serializes")
}
pub fn cds_url(&self, year: i32, month: u32, days: &[u32]) -> String {
build_cds_url(Self::DATASET, &self.request_json(year, month, days))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transport::parse_cds_url;
#[test]
fn variable_table_is_sorted_and_complete() {
let vars = all_variables();
assert_eq!(vars.len(), 16);
assert_eq!(vars.first().map(String::as_str), Some("divergence"));
let mut sorted = vars.clone();
sorted.sort();
assert_eq!(vars, sorted, "all_variables() must be sorted");
assert_eq!(variable_short_name("temperature"), Some("t"));
assert_eq!(variable_short_name("vertical_velocity"), Some("w"));
assert_eq!(variable_short_name("not_a_variable"), None);
}
#[test]
fn pressure_level_index_mapping() {
assert_eq!(
pressure_levels_from_indices(&[1, 12, 37]).unwrap(),
vec![1000, 700, 1]
);
assert!(pressure_levels_from_indices(&[0]).is_err());
assert!(pressure_levels_from_indices(&[38]).is_err());
}
#[test]
fn area_padding_and_rounding() {
assert_eq!(
area_from_bounds((-100.4, -80.6), (30.2, 45.8)),
[47, -102, 29, -79]
);
assert_eq!(
area_from_bounds((-80.6, -100.4), (45.8, 30.2)),
[47, -102, 29, -79]
);
}
#[test]
fn months_and_days() {
assert_eq!(
months_in_range((2018, 11), (2019, 2)),
vec![(2018, 11), (2018, 12), (2019, 1), (2019, 2)]
);
assert_eq!(months_in_range((2019, 5), (2019, 5)), vec![(2019, 5)]);
assert!(months_in_range((2019, 6), (2019, 5)).is_empty());
assert_eq!(days_in_month(2020, 2).unwrap().len(), 29); assert_eq!(days_in_month(2019, 2).unwrap().len(), 28);
assert_eq!(days_in_month(2000, 2).unwrap().len(), 29); assert_eq!(days_in_month(1900, 2).unwrap().len(), 28); assert_eq!(days_in_month(2018, 4).unwrap().len(), 30);
assert_eq!(
days_in_month(2018, 1).unwrap(),
(1..=31).collect::<Vec<_>>()
);
assert!(days_in_month(2018, 13).is_err());
}
#[test]
fn request_json_is_canonical_and_faithful() {
let era5 = Era5PressureLevels {
variables: vec!["temperature".into(), "geopotential".into()],
pressure_levels: vec![850, 1000],
area: [50, -130, 20, -60],
};
let json = era5.request_json(2018, 1, &[8, 1]);
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["product_type"], serde_json::json!(["reanalysis"]));
assert_eq!(
v["variable"],
serde_json::json!(["geopotential", "temperature"])
);
assert_eq!(v["pressure_level"], serde_json::json!(["1000", "850"]));
assert_eq!(v["year"], serde_json::json!(["2018"]));
assert_eq!(v["month"], serde_json::json!(["01"]));
assert_eq!(v["day"], serde_json::json!(["01", "08"]));
assert_eq!(v["time"].as_array().unwrap().len(), 24);
assert_eq!(v["time"][0], serde_json::json!("00:00"));
assert_eq!(v["time"][23], serde_json::json!("23:00"));
assert_eq!(v["data_format"], serde_json::json!("netcdf"));
assert_eq!(v["download_format"], serde_json::json!("unarchived"));
assert_eq!(v["area"], serde_json::json!([50, -130, 20, -60]));
}
#[test]
fn request_json_independent_of_caller_order() {
let a = Era5PressureLevels {
variables: vec!["temperature".into(), "geopotential".into()],
pressure_levels: vec![850, 1000],
area: [50, -130, 20, -60],
};
let b = Era5PressureLevels {
variables: vec!["geopotential".into(), "temperature".into()],
pressure_levels: vec![1000, 850],
area: [50, -130, 20, -60],
};
assert_eq!(
a.request_json(2018, 1, &[1, 8]),
b.request_json(2018, 1, &[8, 1])
);
}
#[test]
fn cds_url_is_parseable() {
let era5 = Era5PressureLevels {
variables: vec!["temperature".into()],
pressure_levels: vec![1000],
area: [50, -130, 20, -60],
};
let url = era5.cds_url(2018, 11, &[8]);
assert!(url.starts_with("cds://reanalysis-era5-pressure-levels?"));
let (dataset, request_json) = parse_cds_url(&url).unwrap();
assert_eq!(dataset, Era5PressureLevels::DATASET);
assert_eq!(request_json, era5.request_json(2018, 11, &[8]));
}
}