Skip to main content

openmeteo_rs/response/
descriptor.rs

1/// Decomposed variable identity inspired by Open-Meteo's FlatBuffers schema.
2#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
3#[non_exhaustive]
4pub struct VariableDescriptor {
5    /// Original flattened JSON/API name.
6    pub api_name: String,
7    /// Logical variable.
8    pub variable: Variable,
9    /// Altitude in metres, where applicable.
10    pub altitude: Option<i16>,
11    /// Pressure level in hPa, where applicable.
12    pub pressure_level: Option<i16>,
13    /// Soil depth start in centimetres, where applicable.
14    pub depth: Option<i16>,
15    /// Soil depth end in centimetres, where applicable.
16    pub depth_to: Option<i16>,
17    /// Aggregation suffix, where applicable.
18    pub aggregation: Option<crate::Aggregation>,
19    /// Model token stripped from model-suffixed JSON columns, where applicable.
20    pub model: Option<String>,
21    /// Previous-runs day offset for API names ending in `_previous_dayN`.
22    pub previous_day: Option<u8>,
23    /// Ensemble member number for API names containing `_memberNN`.
24    pub ensemble_member: Option<u16>,
25}
26
27impl VariableDescriptor {
28    pub(crate) fn from_api_name(api_name: &str) -> Self {
29        let (base_name, model) = strip_model_suffix(api_name);
30        let (base_name, previous_day) = previous_day_from_api_name(base_name);
31        let (base_name, ensemble_member) = ensemble_member_from_api_name(base_name);
32        let mut descriptor = Self {
33            api_name: api_name.to_owned(),
34            variable: variable_from_api_name(base_name),
35            altitude: altitude_from_api_name(base_name),
36            pressure_level: pressure_level_from_api_name(base_name),
37            depth: None,
38            depth_to: None,
39            aggregation: aggregation_from_api_name(base_name),
40            model: model.map(str::to_owned),
41            previous_day,
42            ensemble_member,
43        };
44
45        if let Some((from, to)) = soil_moisture_depths(base_name) {
46            descriptor.depth = Some(from);
47            descriptor.depth_to = Some(to);
48        } else if let Some(depth) = soil_temperature_depth(base_name) {
49            descriptor.depth = Some(depth);
50        }
51
52        descriptor
53    }
54}
55
56/// Logical weather variable.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
58#[non_exhaustive]
59pub enum Variable {
60    /// Temperature.
61    Temperature,
62    /// Relative humidity.
63    RelativeHumidity,
64    /// Dew point.
65    DewPoint,
66    /// Apparent temperature.
67    ApparentTemperature,
68    /// Wet-bulb temperature.
69    WetBulbTemperature,
70    /// Mean sea-level pressure.
71    PressureMsl,
72    /// Surface pressure.
73    SurfacePressure,
74    /// Cloud cover.
75    CloudCover,
76    /// Low cloud cover.
77    CloudCoverLow,
78    /// Mid cloud cover.
79    CloudCoverMid,
80    /// High cloud cover.
81    CloudCoverHigh,
82    /// Precipitation.
83    Precipitation,
84    /// Precipitation hours.
85    PrecipitationHours,
86    /// Probability of precipitation.
87    PrecipitationProbability,
88    /// Rain.
89    Rain,
90    /// Showers.
91    Showers,
92    /// Snowfall.
93    Snowfall,
94    /// Snowfall water equivalent.
95    SnowfallWaterEquivalent,
96    /// Snowfall height.
97    SnowfallHeight,
98    /// Snow depth.
99    SnowDepth,
100    /// WMO weather code.
101    WeatherCode,
102    /// Wind speed.
103    WindSpeed,
104    /// Wind direction.
105    WindDirection,
106    /// Wind gust.
107    WindGust,
108    /// Soil temperature.
109    SoilTemperature,
110    /// Soil moisture.
111    SoilMoisture,
112    /// Geopotential height.
113    GeopotentialHeight,
114    /// Visibility.
115    Visibility,
116    /// Evapotranspiration.
117    Evapotranspiration,
118    /// FAO reference evapotranspiration.
119    Et0FaoEvapotranspiration,
120    /// Vapour pressure deficit.
121    VapourPressureDeficit,
122    /// Day/night flag.
123    IsDay,
124    /// Daylight duration.
125    DaylightDuration,
126    /// Sunshine duration.
127    SunshineDuration,
128    /// UV index.
129    UvIndex,
130    /// Clear-sky UV index.
131    UvIndexClearSky,
132    /// Total column integrated water vapour.
133    TotalColumnIntegratedWaterVapour,
134    /// Shortwave solar radiation.
135    ShortwaveRadiation,
136    /// Instantaneous shortwave solar radiation.
137    ShortwaveRadiationInstant,
138    /// Direct solar radiation.
139    DirectRadiation,
140    /// Instantaneous direct solar radiation.
141    DirectRadiationInstant,
142    /// Direct normal irradiance.
143    DirectNormalIrradiance,
144    /// Instantaneous direct normal irradiance.
145    DirectNormalIrradianceInstant,
146    /// Diffuse solar radiation.
147    DiffuseRadiation,
148    /// Instantaneous diffuse solar radiation.
149    DiffuseRadiationInstant,
150    /// Global tilted irradiance.
151    GlobalTiltedIrradiance,
152    /// Instantaneous global tilted irradiance.
153    GlobalTiltedIrradianceInstant,
154    /// Terrestrial solar radiation.
155    TerrestrialRadiation,
156    /// Instantaneous terrestrial solar radiation.
157    TerrestrialRadiationInstant,
158    /// Convective available potential energy.
159    Cape,
160    /// Lifted index.
161    LiftedIndex,
162    /// Convective inhibition.
163    ConvectiveInhibition,
164    /// Freezing level height.
165    FreezingLevelHeight,
166    /// Atmospheric boundary layer height.
167    BoundaryLayerHeight,
168    /// Growing degree days.
169    GrowingDegreeDays,
170    /// Leaf wetness probability.
171    LeafWetnessProbability,
172    /// Lightning potential index.
173    LightningPotential,
174    /// Updraft.
175    Updraft,
176    /// Significant, wind, or swell wave height.
177    WaveHeight,
178    /// Significant, wind, or swell wave direction.
179    WaveDirection,
180    /// Significant, wind, or swell wave period.
181    WavePeriod,
182    /// Significant, wind, or swell wave peak period.
183    WavePeakPeriod,
184    /// Sea level height above mean sea level.
185    SeaLevelHeightMsl,
186    /// River discharge.
187    RiverDischarge,
188    /// Sea surface temperature.
189    SeaSurfaceTemperature,
190    /// Ocean-current velocity.
191    OceanCurrentVelocity,
192    /// Ocean-current direction.
193    OceanCurrentDirection,
194    /// Inverted-barometer sea-level contribution.
195    InvertBarometerHeight,
196    /// Particulate matter.
197    ParticulateMatter,
198    /// Carbon monoxide.
199    CarbonMonoxide,
200    /// Carbon dioxide.
201    CarbonDioxide,
202    /// Nitrogen dioxide.
203    NitrogenDioxide,
204    /// Sulphur dioxide.
205    SulphurDioxide,
206    /// Ozone.
207    Ozone,
208    /// Aerosol optical depth.
209    AerosolOpticalDepth,
210    /// Dust.
211    Dust,
212    /// Ammonia.
213    Ammonia,
214    /// Methane.
215    Methane,
216    /// Pollen concentration.
217    Pollen,
218    /// Air quality index.
219    AirQualityIndex,
220    /// Formaldehyde.
221    Formaldehyde,
222    /// Glyoxal.
223    Glyoxal,
224    /// Volatile organic compounds.
225    VolatileOrganicCompounds,
226    /// Wildfire PM10.
227    Pm10Wildfires,
228    /// Peroxyacyl nitrates.
229    PeroxyacylNitrates,
230    /// Secondary inorganic aerosol.
231    SecondaryInorganicAerosol,
232    /// Elementary carbon.
233    ElementaryCarbon,
234    /// Organic matter.
235    OrganicMatter,
236    /// Sea-salt aerosol.
237    SeaSaltAerosol,
238    /// Nitrogen monoxide.
239    NitrogenMonoxide,
240    /// Timestamp-valued sun event.
241    SunEvent,
242    /// Unknown or currently untyped variable.
243    Unknown,
244}
245
246fn variable_from_api_name(api_name: &str) -> Variable {
247    match api_name {
248        "weather_code" => return Variable::WeatherCode,
249        "is_day" => return Variable::IsDay,
250        "sunrise" | "sunset" => return Variable::SunEvent,
251        _ => {}
252    }
253
254    PREFIX_VARIABLES
255        .iter()
256        .find(|(prefix, _)| api_name.starts_with(prefix))
257        .map(|(_, variable)| *variable)
258        .unwrap_or(Variable::Unknown)
259}
260
261const PREFIX_VARIABLES: &[(&str, Variable)] = &[
262    (
263        "total_column_integrated_water_vapour",
264        Variable::TotalColumnIntegratedWaterVapour,
265    ),
266    (
267        "direct_normal_irradiance_instant",
268        Variable::DirectNormalIrradianceInstant,
269    ),
270    (
271        "global_tilted_irradiance_instant",
272        Variable::GlobalTiltedIrradianceInstant,
273    ),
274    (
275        "snowfall_water_equivalent",
276        Variable::SnowfallWaterEquivalent,
277    ),
278    (
279        "et0_fao_evapotranspiration",
280        Variable::Et0FaoEvapotranspiration,
281    ),
282    (
283        "precipitation_probability",
284        Variable::PrecipitationProbability,
285    ),
286    (
287        "terrestrial_radiation_instant",
288        Variable::TerrestrialRadiationInstant,
289    ),
290    (
291        "shortwave_radiation_instant",
292        Variable::ShortwaveRadiationInstant,
293    ),
294    ("direct_normal_irradiance", Variable::DirectNormalIrradiance),
295    ("direct_radiation_instant", Variable::DirectRadiationInstant),
296    (
297        "diffuse_radiation_instant",
298        Variable::DiffuseRadiationInstant,
299    ),
300    ("global_tilted_irradiance", Variable::GlobalTiltedIrradiance),
301    ("leaf_wetness_probability", Variable::LeafWetnessProbability),
302    ("relative_humidity_", Variable::RelativeHumidity),
303    ("vapour_pressure_deficit", Variable::VapourPressureDeficit),
304    ("terrestrial_radiation", Variable::TerrestrialRadiation),
305    ("convective_inhibition", Variable::ConvectiveInhibition),
306    ("freezing_level_height", Variable::FreezingLevelHeight),
307    ("boundary_layer_height", Variable::BoundaryLayerHeight),
308    ("growing_degree_days", Variable::GrowingDegreeDays),
309    ("wet_bulb_temperature", Variable::WetBulbTemperature),
310    ("precipitation_hours", Variable::PrecipitationHours),
311    ("shortwave_radiation", Variable::ShortwaveRadiation),
312    ("diffuse_radiation", Variable::DiffuseRadiation),
313    ("lightning_potential", Variable::LightningPotential),
314    ("soil_temperature_", Variable::SoilTemperature),
315    ("apparent_temperature", Variable::ApparentTemperature),
316    ("daylight_duration", Variable::DaylightDuration),
317    ("sunshine_duration", Variable::SunshineDuration),
318    ("direct_radiation", Variable::DirectRadiation),
319    ("geopotential_height_", Variable::GeopotentialHeight),
320    ("surface_pressure", Variable::SurfacePressure),
321    ("precipitation", Variable::Precipitation),
322    ("uv_index_clear_sky", Variable::UvIndexClearSky),
323    ("soil_moisture_", Variable::SoilMoisture),
324    ("cloud_cover_low", Variable::CloudCoverLow),
325    ("cloud_cover_mid", Variable::CloudCoverMid),
326    ("cloud_cover_high", Variable::CloudCoverHigh),
327    ("temperature_", Variable::Temperature),
328    ("pressure_msl", Variable::PressureMsl),
329    ("snowfall_height", Variable::SnowfallHeight),
330    ("wind_direction_", Variable::WindDirection),
331    ("evapotranspiration", Variable::Evapotranspiration),
332    ("cloud_cover", Variable::CloudCover),
333    ("wind_speed_", Variable::WindSpeed),
334    ("wind_gusts_", Variable::WindGust),
335    ("snow_depth", Variable::SnowDepth),
336    ("dew_point_", Variable::DewPoint),
337    ("visibility", Variable::Visibility),
338    ("uv_index", Variable::UvIndex),
339    ("snowfall", Variable::Snowfall),
340    ("showers", Variable::Showers),
341    ("lifted_index", Variable::LiftedIndex),
342    ("updraft", Variable::Updraft),
343    ("rain", Variable::Rain),
344    ("cape", Variable::Cape),
345    ("secondary_swell_wave_peak_period", Variable::WavePeakPeriod),
346    ("tertiary_swell_wave_peak_period", Variable::WavePeakPeriod),
347    ("wind_wave_peak_period", Variable::WavePeakPeriod),
348    ("swell_wave_peak_period", Variable::WavePeakPeriod),
349    ("secondary_swell_wave_direction", Variable::WaveDirection),
350    ("tertiary_swell_wave_direction", Variable::WaveDirection),
351    ("wind_wave_direction", Variable::WaveDirection),
352    ("swell_wave_direction", Variable::WaveDirection),
353    ("secondary_swell_wave_height", Variable::WaveHeight),
354    ("tertiary_swell_wave_height", Variable::WaveHeight),
355    ("wind_wave_height", Variable::WaveHeight),
356    ("swell_wave_height", Variable::WaveHeight),
357    ("secondary_swell_wave_period", Variable::WavePeriod),
358    ("tertiary_swell_wave_period", Variable::WavePeriod),
359    ("wind_wave_period", Variable::WavePeriod),
360    ("swell_wave_period", Variable::WavePeriod),
361    ("wave_peak_period", Variable::WavePeakPeriod),
362    ("wave_direction", Variable::WaveDirection),
363    ("wave_height", Variable::WaveHeight),
364    ("wave_period", Variable::WavePeriod),
365    ("sea_level_height_msl", Variable::SeaLevelHeightMsl),
366    ("river_discharge", Variable::RiverDischarge),
367    ("sea_surface_temperature", Variable::SeaSurfaceTemperature),
368    ("ocean_current_velocity", Variable::OceanCurrentVelocity),
369    ("ocean_current_direction", Variable::OceanCurrentDirection),
370    ("invert_barometer_height", Variable::InvertBarometerHeight),
371    ("european_aqi", Variable::AirQualityIndex),
372    ("us_aqi", Variable::AirQualityIndex),
373    ("pm2_5_total_organic_matter", Variable::OrganicMatter),
374    ("pm10_wildfires", Variable::Pm10Wildfires),
375    ("pm10", Variable::ParticulateMatter),
376    ("pm2_5", Variable::ParticulateMatter),
377    ("carbon_monoxide", Variable::CarbonMonoxide),
378    ("carbon_dioxide", Variable::CarbonDioxide),
379    ("nitrogen_dioxide", Variable::NitrogenDioxide),
380    ("sulphur_dioxide", Variable::SulphurDioxide),
381    ("ozone", Variable::Ozone),
382    ("aerosol_optical_depth", Variable::AerosolOpticalDepth),
383    ("dust", Variable::Dust),
384    ("ammonia", Variable::Ammonia),
385    ("methane", Variable::Methane),
386    ("alder_pollen", Variable::Pollen),
387    ("birch_pollen", Variable::Pollen),
388    ("grass_pollen", Variable::Pollen),
389    ("mugwort_pollen", Variable::Pollen),
390    ("olive_pollen", Variable::Pollen),
391    ("ragweed_pollen", Variable::Pollen),
392    ("formaldehyde", Variable::Formaldehyde),
393    ("glyoxal", Variable::Glyoxal),
394    (
395        "non_methane_volatile_organic_compounds",
396        Variable::VolatileOrganicCompounds,
397    ),
398    ("peroxyacyl_nitrates", Variable::PeroxyacylNitrates),
399    (
400        "secondary_inorganic_aerosol",
401        Variable::SecondaryInorganicAerosol,
402    ),
403    ("residential_elementary_carbon", Variable::ElementaryCarbon),
404    ("total_elementary_carbon", Variable::ElementaryCarbon),
405    ("sea_salt_aerosol", Variable::SeaSaltAerosol),
406    ("nitrogen_monoxide", Variable::NitrogenMonoxide),
407];
408
409fn altitude_from_api_name(api_name: &str) -> Option<i16> {
410    dimension_number(api_name, 'm')
411}
412
413fn pressure_level_from_api_name(api_name: &str) -> Option<i16> {
414    dimension_number(api_name, 'h')
415}
416
417fn dimension_number(api_name: &str, unit_marker: char) -> Option<i16> {
418    let bytes = api_name.as_bytes();
419    let mut index = 0;
420
421    while index < bytes.len() {
422        if !bytes[index].is_ascii_digit() {
423            index += 1;
424            continue;
425        }
426
427        let start = index;
428        while index < bytes.len() && bytes[index].is_ascii_digit() {
429            index += 1;
430        }
431
432        if unit_marker == 'm' {
433            if bytes.get(index) == Some(&b'm')
434                && !matches!(bytes.get(index + 1), Some(next) if next.is_ascii_alphabetic())
435            {
436                return api_name[start..index].parse().ok();
437            }
438        } else if unit_marker == 'h'
439            && bytes.get(index) == Some(&b'h')
440            && bytes.get(index + 1) == Some(&b'P')
441            && bytes.get(index + 2) == Some(&b'a')
442        {
443            return api_name[start..index].parse().ok();
444        }
445    }
446
447    None
448}
449
450fn aggregation_from_api_name(api_name: &str) -> Option<crate::Aggregation> {
451    if api_name.ends_with("_min") {
452        Some(crate::Aggregation::Minimum)
453    } else if api_name.ends_with("_max") {
454        Some(crate::Aggregation::Maximum)
455    } else if api_name.ends_with("_mean") {
456        Some(crate::Aggregation::Mean)
457    } else if api_name.ends_with("_median") || api_name.ends_with("_p50") {
458        Some(crate::Aggregation::Median)
459    } else if api_name.ends_with("_sum") {
460        Some(crate::Aggregation::Sum)
461    } else if api_name.ends_with("_p10") {
462        Some(crate::Aggregation::P10)
463    } else if api_name.ends_with("_p25") {
464        Some(crate::Aggregation::P25)
465    } else if api_name.ends_with("_p75") {
466        Some(crate::Aggregation::P75)
467    } else if api_name.ends_with("_p90") {
468        Some(crate::Aggregation::P90)
469    } else if api_name.ends_with("_dominant") {
470        Some(crate::Aggregation::Dominant)
471    } else {
472        None
473    }
474}
475
476fn strip_model_suffix(api_name: &str) -> (&str, Option<&str>) {
477    for &suffix in MODEL_SUFFIXES {
478        if let Some(base) = api_name.strip_suffix(suffix)
479            && let Some(base) = base.strip_suffix('_')
480        {
481            return (base, Some(suffix));
482        }
483    }
484
485    (api_name, None)
486}
487
488const MODEL_SUFFIXES: &[&str] = &[
489    "ecmwf_seasonal_ensemble_mean_seamless",
490    "ecmwf_seasonal_seamless",
491    "ecmwf_ec46_ensemble_mean",
492    "ecmwf_seas5_ensemble_mean",
493    "ecmwf_ec46",
494    "ecmwf_seas5",
495    "ecmwf_ifs_analysis_long_window",
496    "meteofrance_seamless",
497    "gfs_seamless",
498    "ecmwf_ifs025",
499    "icon_seamless",
500    "best_match",
501    "era5_seamless",
502    "era5_ensemble",
503    "era5_land",
504    "ecmwf_ifs",
505    "cerra",
506    "era5",
507    "satellite_radiation_seamless",
508    "dwd_sis_europe_africa_v4",
509    "eumetsat_lsa_saf_msg",
510    "eumetsat_lsa_saf_iodc",
511    "eumetsat_sarah3",
512    "jma_jaxa_himawari",
513    "jma_jaxa_mtg_fci",
514    "icon_seamless_eps",
515    "icon_global_eps",
516    "icon_eu_eps",
517    "icon_d2_eps",
518    "ncep_gefs_seamless",
519    "ncep_gefs025",
520    "ncep_gefs05",
521    "ncep_aigefs025",
522    "ecmwf_ifs025_ensemble",
523    "ecmwf_aifs025_ensemble",
524    "gem_global_ensemble",
525    "bom_access_global_ensemble",
526    "ukmo_global_ensemble_20km",
527    "ukmo_uk_ensemble_2km",
528    "meteoswiss_icon_ch1_ensemble",
529    "meteoswiss_icon_ch2_ensemble",
530    "CMCC_CM2_VHR4",
531    "EC_Earth3P_HR",
532    "FGOALS_f3_H",
533    "HiRAM_SIT_HR",
534    "MPI_ESM1_2_XR",
535    "MRI_AGCM3_2_S",
536    "NICAM16_8S",
537    "seamless_v4",
538    "forecast_v4",
539    "consolidated_v4",
540    "seamless_v3",
541    "forecast_v3",
542    "consolidated_v3",
543    "ncep_hgefs025_ensemble_mean",
544];
545
546fn soil_temperature_depth(api_name: &str) -> Option<i16> {
547    let rest = api_name.strip_prefix("soil_temperature_")?;
548    let depth = rest.strip_suffix("cm")?;
549    depth.parse().ok()
550}
551
552fn soil_moisture_depths(api_name: &str) -> Option<(i16, i16)> {
553    let rest = api_name.strip_prefix("soil_moisture_")?;
554    let rest = rest.strip_suffix("cm")?;
555    let (from, to) = rest.split_once("_to_")?;
556    let from = from.parse().ok()?;
557    let to = to.parse().ok()?;
558    Some((from, to))
559}
560
561fn previous_day_from_api_name(api_name: &str) -> (&str, Option<u8>) {
562    let Some((base, day)) = api_name.rsplit_once("_previous_day") else {
563        return (api_name, None);
564    };
565    let Ok(day) = day.parse() else {
566        return (api_name, None);
567    };
568    (base, Some(day))
569}
570
571fn ensemble_member_from_api_name(api_name: &str) -> (&str, Option<u16>) {
572    let Some((base, member)) = api_name.rsplit_once("_member") else {
573        return (api_name, None);
574    };
575    let digits = member
576        .as_bytes()
577        .iter()
578        .take_while(|byte| byte.is_ascii_digit())
579        .count();
580    if digits == 0 || !matches!(member.as_bytes().get(digits), None | Some(b'_')) {
581        return (api_name, None);
582    }
583    let Ok(member) = member[..digits].parse() else {
584        return (api_name, None);
585    };
586    (base, Some(member))
587}
588
589#[cfg(test)]
590mod tests {
591    use super::PREFIX_VARIABLES;
592    use super::{MODEL_SUFFIXES, VariableDescriptor};
593    use crate::query::AsApiStr;
594    use crate::variables::{
595        ArchiveModel, ClimateModel, EnsembleModel, FloodModel, SatelliteRadiationModel,
596        SeasonalModel, WeatherModel,
597    };
598    use crate::{Aggregation, Variable};
599
600    #[test]
601    fn prefix_variable_rules_do_not_shadow_more_specific_rules() {
602        for (index, (prefix, _)) in PREFIX_VARIABLES.iter().enumerate() {
603            for (later, _) in &PREFIX_VARIABLES[index + 1..] {
604                assert!(
605                    !later.starts_with(prefix),
606                    "prefix rule {prefix:?} shadows later, more-specific rule {later:?}"
607                );
608            }
609        }
610    }
611
612    #[test]
613    fn model_suffixes_cover_first_class_model_tokens() {
614        for token in [
615            WeatherModel::BestMatch.as_api_str(),
616            WeatherModel::GfsSeamless.as_api_str(),
617            WeatherModel::EcmwfIfs025.as_api_str(),
618            WeatherModel::IconSeamless.as_api_str(),
619            WeatherModel::MeteofranceSeamless.as_api_str(),
620            ArchiveModel::BestMatch.as_api_str(),
621            ArchiveModel::Era5Seamless.as_api_str(),
622            ArchiveModel::Era5.as_api_str(),
623            ArchiveModel::Era5Land.as_api_str(),
624            ArchiveModel::Era5Ensemble.as_api_str(),
625            ArchiveModel::EcmwfIfs.as_api_str(),
626            ArchiveModel::EcmwfIfsAnalysisLongWindow.as_api_str(),
627            ArchiveModel::Cerra.as_api_str(),
628            EnsembleModel::IconSeamlessEps.as_api_str(),
629            EnsembleModel::IconGlobalEps.as_api_str(),
630            EnsembleModel::IconEuEps.as_api_str(),
631            EnsembleModel::IconD2Eps.as_api_str(),
632            EnsembleModel::NcepGefsSeamless.as_api_str(),
633            EnsembleModel::NcepGefs025.as_api_str(),
634            EnsembleModel::NcepGefs05.as_api_str(),
635            EnsembleModel::NcepAigefs025.as_api_str(),
636            EnsembleModel::EcmwfIfs025Ensemble.as_api_str(),
637            EnsembleModel::EcmwfAifs025Ensemble.as_api_str(),
638            EnsembleModel::GemGlobalEnsemble.as_api_str(),
639            EnsembleModel::BomAccessGlobalEnsemble.as_api_str(),
640            EnsembleModel::UkmoGlobalEnsemble20km.as_api_str(),
641            EnsembleModel::UkmoUkEnsemble2km.as_api_str(),
642            EnsembleModel::MeteoswissIconCh1Ensemble.as_api_str(),
643            EnsembleModel::MeteoswissIconCh2Ensemble.as_api_str(),
644            SeasonalModel::EcmwfSeasonalSeamless.as_api_str(),
645            SeasonalModel::EcmwfSeas5.as_api_str(),
646            SeasonalModel::EcmwfEc46.as_api_str(),
647            SeasonalModel::EcmwfSeasonalEnsembleMeanSeamless.as_api_str(),
648            SeasonalModel::EcmwfSeas5EnsembleMean.as_api_str(),
649            SeasonalModel::EcmwfEc46EnsembleMean.as_api_str(),
650            ClimateModel::CmccCm2Vhr4.as_api_str(),
651            ClimateModel::EcEarth3PHr.as_api_str(),
652            ClimateModel::FgoalsF3H.as_api_str(),
653            ClimateModel::HiramSitHr.as_api_str(),
654            ClimateModel::MpiEsm12Xr.as_api_str(),
655            ClimateModel::MriAgcm32S.as_api_str(),
656            ClimateModel::Nicam168S.as_api_str(),
657            SatelliteRadiationModel::SatelliteRadiationSeamless.as_api_str(),
658            SatelliteRadiationModel::DwdSisEuropeAfricaV4.as_api_str(),
659            SatelliteRadiationModel::EumetsatLsaSafMsg.as_api_str(),
660            SatelliteRadiationModel::EumetsatLsaSafIodc.as_api_str(),
661            SatelliteRadiationModel::EumetsatSarah3.as_api_str(),
662            SatelliteRadiationModel::JmaJaxaHimawari.as_api_str(),
663            SatelliteRadiationModel::JmaJaxaMtgFci.as_api_str(),
664            SatelliteRadiationModel::NcepHgefs025EnsembleMean.as_api_str(),
665            FloodModel::GlofasV4Seamless.as_api_str(),
666            FloodModel::GlofasV4Forecast.as_api_str(),
667            FloodModel::GlofasV4Consolidated.as_api_str(),
668            FloodModel::GlofasV3Seamless.as_api_str(),
669            FloodModel::GlofasV3Forecast.as_api_str(),
670            FloodModel::GlofasV3Consolidated.as_api_str(),
671        ] {
672            assert!(
673                MODEL_SUFFIXES.contains(&token.as_ref()),
674                "MODEL_SUFFIXES is missing {token}"
675            );
676        }
677    }
678
679    #[test]
680    fn model_suffixes_do_not_shadow_later_longer_suffixes() {
681        for (index, suffix) in MODEL_SUFFIXES.iter().enumerate() {
682            for later in &MODEL_SUFFIXES[index + 1..] {
683                assert!(
684                    !later.ends_with(suffix),
685                    "model suffix {suffix:?} shadows later suffix {later:?}"
686                );
687            }
688        }
689    }
690
691    #[test]
692    fn aggregation_suffixes_round_trip() {
693        for aggregation in [
694            Aggregation::Minimum,
695            Aggregation::Maximum,
696            Aggregation::Mean,
697            Aggregation::Median,
698            Aggregation::Sum,
699            Aggregation::P10,
700            Aggregation::P25,
701            Aggregation::P75,
702            Aggregation::P90,
703            Aggregation::Dominant,
704        ] {
705            let descriptor = VariableDescriptor::from_api_name(&format!(
706                "river_discharge{}",
707                aggregation.suffix()
708            ));
709            assert_eq!(descriptor.variable, Variable::RiverDischarge);
710            assert_eq!(descriptor.aggregation, Some(aggregation));
711        }
712
713        let p50 = VariableDescriptor::from_api_name("river_discharge_p50");
714        assert_eq!(p50.variable, Variable::RiverDischarge);
715        assert_eq!(p50.aggregation, Some(Aggregation::Median));
716    }
717
718    #[test]
719    fn model_suffix_is_preserved_as_descriptor_dimension() {
720        let descriptor = VariableDescriptor::from_api_name("temperature_2m_icon_seamless_eps");
721
722        assert_eq!(descriptor.api_name, "temperature_2m_icon_seamless_eps");
723        assert_eq!(descriptor.variable, Variable::Temperature);
724        assert_eq!(descriptor.altitude, Some(2));
725        assert_eq!(descriptor.model.as_deref(), Some("icon_seamless_eps"));
726
727        let member = VariableDescriptor::from_api_name("temperature_2m_member01_icon_seamless_eps");
728        assert_eq!(member.variable, Variable::Temperature);
729        assert_eq!(member.ensemble_member, Some(1));
730        assert_eq!(member.model.as_deref(), Some("icon_seamless_eps"));
731
732        let flood = VariableDescriptor::from_api_name("river_discharge_p75_seamless_v4");
733        assert_eq!(flood.variable, Variable::RiverDischarge);
734        assert_eq!(flood.aggregation, Some(Aggregation::P75));
735        assert_eq!(flood.model.as_deref(), Some("seamless_v4"));
736    }
737}