#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
#[non_exhaustive]
pub struct VariableDescriptor {
pub api_name: String,
pub variable: Variable,
pub altitude: Option<i16>,
pub pressure_level: Option<i16>,
pub depth: Option<i16>,
pub depth_to: Option<i16>,
pub aggregation: Option<crate::Aggregation>,
pub model: Option<String>,
pub previous_day: Option<u8>,
pub ensemble_member: Option<u16>,
}
impl VariableDescriptor {
pub(crate) fn from_api_name(api_name: &str) -> Self {
let (base_name, model) = strip_model_suffix(api_name);
let (base_name, previous_day) = previous_day_from_api_name(base_name);
let (base_name, ensemble_member) = ensemble_member_from_api_name(base_name);
let mut descriptor = Self {
api_name: api_name.to_owned(),
variable: variable_from_api_name(base_name),
altitude: altitude_from_api_name(base_name),
pressure_level: pressure_level_from_api_name(base_name),
depth: None,
depth_to: None,
aggregation: aggregation_from_api_name(base_name),
model: model.map(str::to_owned),
previous_day,
ensemble_member,
};
if let Some((from, to)) = soil_moisture_depths(base_name) {
descriptor.depth = Some(from);
descriptor.depth_to = Some(to);
} else if let Some(depth) = soil_temperature_depth(base_name) {
descriptor.depth = Some(depth);
}
descriptor
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
#[non_exhaustive]
pub enum Variable {
Temperature,
RelativeHumidity,
DewPoint,
ApparentTemperature,
WetBulbTemperature,
PressureMsl,
SurfacePressure,
CloudCover,
CloudCoverLow,
CloudCoverMid,
CloudCoverHigh,
Precipitation,
PrecipitationHours,
PrecipitationProbability,
Rain,
Showers,
Snowfall,
SnowfallWaterEquivalent,
SnowfallHeight,
SnowDepth,
WeatherCode,
WindSpeed,
WindDirection,
WindGust,
SoilTemperature,
SoilMoisture,
GeopotentialHeight,
Visibility,
Evapotranspiration,
Et0FaoEvapotranspiration,
VapourPressureDeficit,
IsDay,
DaylightDuration,
SunshineDuration,
UvIndex,
UvIndexClearSky,
TotalColumnIntegratedWaterVapour,
ShortwaveRadiation,
ShortwaveRadiationInstant,
DirectRadiation,
DirectRadiationInstant,
DirectNormalIrradiance,
DirectNormalIrradianceInstant,
DiffuseRadiation,
DiffuseRadiationInstant,
GlobalTiltedIrradiance,
GlobalTiltedIrradianceInstant,
TerrestrialRadiation,
TerrestrialRadiationInstant,
Cape,
LiftedIndex,
ConvectiveInhibition,
FreezingLevelHeight,
BoundaryLayerHeight,
GrowingDegreeDays,
LeafWetnessProbability,
LightningPotential,
Updraft,
WaveHeight,
WaveDirection,
WavePeriod,
WavePeakPeriod,
SeaLevelHeightMsl,
RiverDischarge,
SeaSurfaceTemperature,
OceanCurrentVelocity,
OceanCurrentDirection,
InvertBarometerHeight,
ParticulateMatter,
CarbonMonoxide,
CarbonDioxide,
NitrogenDioxide,
SulphurDioxide,
Ozone,
AerosolOpticalDepth,
Dust,
Ammonia,
Methane,
Pollen,
AirQualityIndex,
Formaldehyde,
Glyoxal,
VolatileOrganicCompounds,
Pm10Wildfires,
PeroxyacylNitrates,
SecondaryInorganicAerosol,
ElementaryCarbon,
OrganicMatter,
SeaSaltAerosol,
NitrogenMonoxide,
SunEvent,
Unknown,
}
fn variable_from_api_name(api_name: &str) -> Variable {
match api_name {
"weather_code" => return Variable::WeatherCode,
"is_day" => return Variable::IsDay,
"sunrise" | "sunset" => return Variable::SunEvent,
_ => {}
}
PREFIX_VARIABLES
.iter()
.find(|(prefix, _)| api_name.starts_with(prefix))
.map(|(_, variable)| *variable)
.unwrap_or(Variable::Unknown)
}
const PREFIX_VARIABLES: &[(&str, Variable)] = &[
(
"total_column_integrated_water_vapour",
Variable::TotalColumnIntegratedWaterVapour,
),
(
"direct_normal_irradiance_instant",
Variable::DirectNormalIrradianceInstant,
),
(
"global_tilted_irradiance_instant",
Variable::GlobalTiltedIrradianceInstant,
),
(
"snowfall_water_equivalent",
Variable::SnowfallWaterEquivalent,
),
(
"et0_fao_evapotranspiration",
Variable::Et0FaoEvapotranspiration,
),
(
"precipitation_probability",
Variable::PrecipitationProbability,
),
(
"terrestrial_radiation_instant",
Variable::TerrestrialRadiationInstant,
),
(
"shortwave_radiation_instant",
Variable::ShortwaveRadiationInstant,
),
("direct_normal_irradiance", Variable::DirectNormalIrradiance),
("direct_radiation_instant", Variable::DirectRadiationInstant),
(
"diffuse_radiation_instant",
Variable::DiffuseRadiationInstant,
),
("global_tilted_irradiance", Variable::GlobalTiltedIrradiance),
("leaf_wetness_probability", Variable::LeafWetnessProbability),
("relative_humidity_", Variable::RelativeHumidity),
("vapour_pressure_deficit", Variable::VapourPressureDeficit),
("terrestrial_radiation", Variable::TerrestrialRadiation),
("convective_inhibition", Variable::ConvectiveInhibition),
("freezing_level_height", Variable::FreezingLevelHeight),
("boundary_layer_height", Variable::BoundaryLayerHeight),
("growing_degree_days", Variable::GrowingDegreeDays),
("wet_bulb_temperature", Variable::WetBulbTemperature),
("precipitation_hours", Variable::PrecipitationHours),
("shortwave_radiation", Variable::ShortwaveRadiation),
("diffuse_radiation", Variable::DiffuseRadiation),
("lightning_potential", Variable::LightningPotential),
("soil_temperature_", Variable::SoilTemperature),
("apparent_temperature", Variable::ApparentTemperature),
("daylight_duration", Variable::DaylightDuration),
("sunshine_duration", Variable::SunshineDuration),
("direct_radiation", Variable::DirectRadiation),
("geopotential_height_", Variable::GeopotentialHeight),
("surface_pressure", Variable::SurfacePressure),
("precipitation", Variable::Precipitation),
("uv_index_clear_sky", Variable::UvIndexClearSky),
("soil_moisture_", Variable::SoilMoisture),
("cloud_cover_low", Variable::CloudCoverLow),
("cloud_cover_mid", Variable::CloudCoverMid),
("cloud_cover_high", Variable::CloudCoverHigh),
("temperature_", Variable::Temperature),
("pressure_msl", Variable::PressureMsl),
("snowfall_height", Variable::SnowfallHeight),
("wind_direction_", Variable::WindDirection),
("evapotranspiration", Variable::Evapotranspiration),
("cloud_cover", Variable::CloudCover),
("wind_speed_", Variable::WindSpeed),
("wind_gusts_", Variable::WindGust),
("snow_depth", Variable::SnowDepth),
("dew_point_", Variable::DewPoint),
("visibility", Variable::Visibility),
("uv_index", Variable::UvIndex),
("snowfall", Variable::Snowfall),
("showers", Variable::Showers),
("lifted_index", Variable::LiftedIndex),
("updraft", Variable::Updraft),
("rain", Variable::Rain),
("cape", Variable::Cape),
("secondary_swell_wave_peak_period", Variable::WavePeakPeriod),
("tertiary_swell_wave_peak_period", Variable::WavePeakPeriod),
("wind_wave_peak_period", Variable::WavePeakPeriod),
("swell_wave_peak_period", Variable::WavePeakPeriod),
("secondary_swell_wave_direction", Variable::WaveDirection),
("tertiary_swell_wave_direction", Variable::WaveDirection),
("wind_wave_direction", Variable::WaveDirection),
("swell_wave_direction", Variable::WaveDirection),
("secondary_swell_wave_height", Variable::WaveHeight),
("tertiary_swell_wave_height", Variable::WaveHeight),
("wind_wave_height", Variable::WaveHeight),
("swell_wave_height", Variable::WaveHeight),
("secondary_swell_wave_period", Variable::WavePeriod),
("tertiary_swell_wave_period", Variable::WavePeriod),
("wind_wave_period", Variable::WavePeriod),
("swell_wave_period", Variable::WavePeriod),
("wave_peak_period", Variable::WavePeakPeriod),
("wave_direction", Variable::WaveDirection),
("wave_height", Variable::WaveHeight),
("wave_period", Variable::WavePeriod),
("sea_level_height_msl", Variable::SeaLevelHeightMsl),
("river_discharge", Variable::RiverDischarge),
("sea_surface_temperature", Variable::SeaSurfaceTemperature),
("ocean_current_velocity", Variable::OceanCurrentVelocity),
("ocean_current_direction", Variable::OceanCurrentDirection),
("invert_barometer_height", Variable::InvertBarometerHeight),
("european_aqi", Variable::AirQualityIndex),
("us_aqi", Variable::AirQualityIndex),
("pm2_5_total_organic_matter", Variable::OrganicMatter),
("pm10_wildfires", Variable::Pm10Wildfires),
("pm10", Variable::ParticulateMatter),
("pm2_5", Variable::ParticulateMatter),
("carbon_monoxide", Variable::CarbonMonoxide),
("carbon_dioxide", Variable::CarbonDioxide),
("nitrogen_dioxide", Variable::NitrogenDioxide),
("sulphur_dioxide", Variable::SulphurDioxide),
("ozone", Variable::Ozone),
("aerosol_optical_depth", Variable::AerosolOpticalDepth),
("dust", Variable::Dust),
("ammonia", Variable::Ammonia),
("methane", Variable::Methane),
("alder_pollen", Variable::Pollen),
("birch_pollen", Variable::Pollen),
("grass_pollen", Variable::Pollen),
("mugwort_pollen", Variable::Pollen),
("olive_pollen", Variable::Pollen),
("ragweed_pollen", Variable::Pollen),
("formaldehyde", Variable::Formaldehyde),
("glyoxal", Variable::Glyoxal),
(
"non_methane_volatile_organic_compounds",
Variable::VolatileOrganicCompounds,
),
("peroxyacyl_nitrates", Variable::PeroxyacylNitrates),
(
"secondary_inorganic_aerosol",
Variable::SecondaryInorganicAerosol,
),
("residential_elementary_carbon", Variable::ElementaryCarbon),
("total_elementary_carbon", Variable::ElementaryCarbon),
("sea_salt_aerosol", Variable::SeaSaltAerosol),
("nitrogen_monoxide", Variable::NitrogenMonoxide),
];
fn altitude_from_api_name(api_name: &str) -> Option<i16> {
dimension_number(api_name, 'm')
}
fn pressure_level_from_api_name(api_name: &str) -> Option<i16> {
dimension_number(api_name, 'h')
}
fn dimension_number(api_name: &str, unit_marker: char) -> Option<i16> {
let bytes = api_name.as_bytes();
let mut index = 0;
while index < bytes.len() {
if !bytes[index].is_ascii_digit() {
index += 1;
continue;
}
let start = index;
while index < bytes.len() && bytes[index].is_ascii_digit() {
index += 1;
}
if unit_marker == 'm' {
if bytes.get(index) == Some(&b'm')
&& !matches!(bytes.get(index + 1), Some(next) if next.is_ascii_alphabetic())
{
return api_name[start..index].parse().ok();
}
} else if unit_marker == 'h'
&& bytes.get(index) == Some(&b'h')
&& bytes.get(index + 1) == Some(&b'P')
&& bytes.get(index + 2) == Some(&b'a')
{
return api_name[start..index].parse().ok();
}
}
None
}
fn aggregation_from_api_name(api_name: &str) -> Option<crate::Aggregation> {
if api_name.ends_with("_min") {
Some(crate::Aggregation::Minimum)
} else if api_name.ends_with("_max") {
Some(crate::Aggregation::Maximum)
} else if api_name.ends_with("_mean") {
Some(crate::Aggregation::Mean)
} else if api_name.ends_with("_median") || api_name.ends_with("_p50") {
Some(crate::Aggregation::Median)
} else if api_name.ends_with("_sum") {
Some(crate::Aggregation::Sum)
} else if api_name.ends_with("_p10") {
Some(crate::Aggregation::P10)
} else if api_name.ends_with("_p25") {
Some(crate::Aggregation::P25)
} else if api_name.ends_with("_p75") {
Some(crate::Aggregation::P75)
} else if api_name.ends_with("_p90") {
Some(crate::Aggregation::P90)
} else if api_name.ends_with("_dominant") {
Some(crate::Aggregation::Dominant)
} else {
None
}
}
fn strip_model_suffix(api_name: &str) -> (&str, Option<&str>) {
for &suffix in MODEL_SUFFIXES {
if let Some(base) = api_name.strip_suffix(suffix)
&& let Some(base) = base.strip_suffix('_')
{
return (base, Some(suffix));
}
}
(api_name, None)
}
const MODEL_SUFFIXES: &[&str] = &[
"ecmwf_seasonal_ensemble_mean_seamless",
"ecmwf_seasonal_seamless",
"ecmwf_ec46_ensemble_mean",
"ecmwf_seas5_ensemble_mean",
"ecmwf_ec46",
"ecmwf_seas5",
"ecmwf_ifs_analysis_long_window",
"meteofrance_seamless",
"gfs_seamless",
"ecmwf_ifs025",
"icon_seamless",
"best_match",
"era5_seamless",
"era5_ensemble",
"era5_land",
"ecmwf_ifs",
"cerra",
"era5",
"satellite_radiation_seamless",
"dwd_sis_europe_africa_v4",
"eumetsat_lsa_saf_msg",
"eumetsat_lsa_saf_iodc",
"eumetsat_sarah3",
"jma_jaxa_himawari",
"jma_jaxa_mtg_fci",
"icon_seamless_eps",
"icon_global_eps",
"icon_eu_eps",
"icon_d2_eps",
"ncep_gefs_seamless",
"ncep_gefs025",
"ncep_gefs05",
"ncep_aigefs025",
"ecmwf_ifs025_ensemble",
"ecmwf_aifs025_ensemble",
"gem_global_ensemble",
"bom_access_global_ensemble",
"ukmo_global_ensemble_20km",
"ukmo_uk_ensemble_2km",
"meteoswiss_icon_ch1_ensemble",
"meteoswiss_icon_ch2_ensemble",
"CMCC_CM2_VHR4",
"EC_Earth3P_HR",
"FGOALS_f3_H",
"HiRAM_SIT_HR",
"MPI_ESM1_2_XR",
"MRI_AGCM3_2_S",
"NICAM16_8S",
"seamless_v4",
"forecast_v4",
"consolidated_v4",
"seamless_v3",
"forecast_v3",
"consolidated_v3",
"ncep_hgefs025_ensemble_mean",
];
fn soil_temperature_depth(api_name: &str) -> Option<i16> {
let rest = api_name.strip_prefix("soil_temperature_")?;
let depth = rest.strip_suffix("cm")?;
depth.parse().ok()
}
fn soil_moisture_depths(api_name: &str) -> Option<(i16, i16)> {
let rest = api_name.strip_prefix("soil_moisture_")?;
let rest = rest.strip_suffix("cm")?;
let (from, to) = rest.split_once("_to_")?;
let from = from.parse().ok()?;
let to = to.parse().ok()?;
Some((from, to))
}
fn previous_day_from_api_name(api_name: &str) -> (&str, Option<u8>) {
let Some((base, day)) = api_name.rsplit_once("_previous_day") else {
return (api_name, None);
};
let Ok(day) = day.parse() else {
return (api_name, None);
};
(base, Some(day))
}
fn ensemble_member_from_api_name(api_name: &str) -> (&str, Option<u16>) {
let Some((base, member)) = api_name.rsplit_once("_member") else {
return (api_name, None);
};
let digits = member
.as_bytes()
.iter()
.take_while(|byte| byte.is_ascii_digit())
.count();
if digits == 0 || !matches!(member.as_bytes().get(digits), None | Some(b'_')) {
return (api_name, None);
}
let Ok(member) = member[..digits].parse() else {
return (api_name, None);
};
(base, Some(member))
}
#[cfg(test)]
mod tests {
use super::PREFIX_VARIABLES;
use super::{MODEL_SUFFIXES, VariableDescriptor};
use crate::query::AsApiStr;
use crate::variables::{
ArchiveModel, ClimateModel, EnsembleModel, FloodModel, SatelliteRadiationModel,
SeasonalModel, WeatherModel,
};
use crate::{Aggregation, Variable};
#[test]
fn prefix_variable_rules_do_not_shadow_more_specific_rules() {
for (index, (prefix, _)) in PREFIX_VARIABLES.iter().enumerate() {
for (later, _) in &PREFIX_VARIABLES[index + 1..] {
assert!(
!later.starts_with(prefix),
"prefix rule {prefix:?} shadows later, more-specific rule {later:?}"
);
}
}
}
#[test]
fn model_suffixes_cover_first_class_model_tokens() {
for token in [
WeatherModel::BestMatch.as_api_str(),
WeatherModel::GfsSeamless.as_api_str(),
WeatherModel::EcmwfIfs025.as_api_str(),
WeatherModel::IconSeamless.as_api_str(),
WeatherModel::MeteofranceSeamless.as_api_str(),
ArchiveModel::BestMatch.as_api_str(),
ArchiveModel::Era5Seamless.as_api_str(),
ArchiveModel::Era5.as_api_str(),
ArchiveModel::Era5Land.as_api_str(),
ArchiveModel::Era5Ensemble.as_api_str(),
ArchiveModel::EcmwfIfs.as_api_str(),
ArchiveModel::EcmwfIfsAnalysisLongWindow.as_api_str(),
ArchiveModel::Cerra.as_api_str(),
EnsembleModel::IconSeamlessEps.as_api_str(),
EnsembleModel::IconGlobalEps.as_api_str(),
EnsembleModel::IconEuEps.as_api_str(),
EnsembleModel::IconD2Eps.as_api_str(),
EnsembleModel::NcepGefsSeamless.as_api_str(),
EnsembleModel::NcepGefs025.as_api_str(),
EnsembleModel::NcepGefs05.as_api_str(),
EnsembleModel::NcepAigefs025.as_api_str(),
EnsembleModel::EcmwfIfs025Ensemble.as_api_str(),
EnsembleModel::EcmwfAifs025Ensemble.as_api_str(),
EnsembleModel::GemGlobalEnsemble.as_api_str(),
EnsembleModel::BomAccessGlobalEnsemble.as_api_str(),
EnsembleModel::UkmoGlobalEnsemble20km.as_api_str(),
EnsembleModel::UkmoUkEnsemble2km.as_api_str(),
EnsembleModel::MeteoswissIconCh1Ensemble.as_api_str(),
EnsembleModel::MeteoswissIconCh2Ensemble.as_api_str(),
SeasonalModel::EcmwfSeasonalSeamless.as_api_str(),
SeasonalModel::EcmwfSeas5.as_api_str(),
SeasonalModel::EcmwfEc46.as_api_str(),
SeasonalModel::EcmwfSeasonalEnsembleMeanSeamless.as_api_str(),
SeasonalModel::EcmwfSeas5EnsembleMean.as_api_str(),
SeasonalModel::EcmwfEc46EnsembleMean.as_api_str(),
ClimateModel::CmccCm2Vhr4.as_api_str(),
ClimateModel::EcEarth3PHr.as_api_str(),
ClimateModel::FgoalsF3H.as_api_str(),
ClimateModel::HiramSitHr.as_api_str(),
ClimateModel::MpiEsm12Xr.as_api_str(),
ClimateModel::MriAgcm32S.as_api_str(),
ClimateModel::Nicam168S.as_api_str(),
SatelliteRadiationModel::SatelliteRadiationSeamless.as_api_str(),
SatelliteRadiationModel::DwdSisEuropeAfricaV4.as_api_str(),
SatelliteRadiationModel::EumetsatLsaSafMsg.as_api_str(),
SatelliteRadiationModel::EumetsatLsaSafIodc.as_api_str(),
SatelliteRadiationModel::EumetsatSarah3.as_api_str(),
SatelliteRadiationModel::JmaJaxaHimawari.as_api_str(),
SatelliteRadiationModel::JmaJaxaMtgFci.as_api_str(),
SatelliteRadiationModel::NcepHgefs025EnsembleMean.as_api_str(),
FloodModel::GlofasV4Seamless.as_api_str(),
FloodModel::GlofasV4Forecast.as_api_str(),
FloodModel::GlofasV4Consolidated.as_api_str(),
FloodModel::GlofasV3Seamless.as_api_str(),
FloodModel::GlofasV3Forecast.as_api_str(),
FloodModel::GlofasV3Consolidated.as_api_str(),
] {
assert!(
MODEL_SUFFIXES.contains(&token.as_ref()),
"MODEL_SUFFIXES is missing {token}"
);
}
}
#[test]
fn model_suffixes_do_not_shadow_later_longer_suffixes() {
for (index, suffix) in MODEL_SUFFIXES.iter().enumerate() {
for later in &MODEL_SUFFIXES[index + 1..] {
assert!(
!later.ends_with(suffix),
"model suffix {suffix:?} shadows later suffix {later:?}"
);
}
}
}
#[test]
fn aggregation_suffixes_round_trip() {
for aggregation in [
Aggregation::Minimum,
Aggregation::Maximum,
Aggregation::Mean,
Aggregation::Median,
Aggregation::Sum,
Aggregation::P10,
Aggregation::P25,
Aggregation::P75,
Aggregation::P90,
Aggregation::Dominant,
] {
let descriptor = VariableDescriptor::from_api_name(&format!(
"river_discharge{}",
aggregation.suffix()
));
assert_eq!(descriptor.variable, Variable::RiverDischarge);
assert_eq!(descriptor.aggregation, Some(aggregation));
}
let p50 = VariableDescriptor::from_api_name("river_discharge_p50");
assert_eq!(p50.variable, Variable::RiverDischarge);
assert_eq!(p50.aggregation, Some(Aggregation::Median));
}
#[test]
fn model_suffix_is_preserved_as_descriptor_dimension() {
let descriptor = VariableDescriptor::from_api_name("temperature_2m_icon_seamless_eps");
assert_eq!(descriptor.api_name, "temperature_2m_icon_seamless_eps");
assert_eq!(descriptor.variable, Variable::Temperature);
assert_eq!(descriptor.altitude, Some(2));
assert_eq!(descriptor.model.as_deref(), Some("icon_seamless_eps"));
let member = VariableDescriptor::from_api_name("temperature_2m_member01_icon_seamless_eps");
assert_eq!(member.variable, Variable::Temperature);
assert_eq!(member.ensemble_member, Some(1));
assert_eq!(member.model.as_deref(), Some("icon_seamless_eps"));
let flood = VariableDescriptor::from_api_name("river_discharge_p75_seamless_v4");
assert_eq!(flood.variable, Variable::RiverDischarge);
assert_eq!(flood.aggregation, Some(Aggregation::P75));
assert_eq!(flood.model.as_deref(), Some("seamless_v4"));
}
}