1#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
3#[non_exhaustive]
4pub struct VariableDescriptor {
5 pub api_name: String,
7 pub variable: Variable,
9 pub altitude: Option<i16>,
11 pub pressure_level: Option<i16>,
13 pub depth: Option<i16>,
15 pub depth_to: Option<i16>,
17 pub aggregation: Option<crate::Aggregation>,
19 pub model: Option<String>,
21 pub previous_day: Option<u8>,
23 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
58#[non_exhaustive]
59pub enum Variable {
60 Temperature,
62 RelativeHumidity,
64 DewPoint,
66 ApparentTemperature,
68 WetBulbTemperature,
70 PressureMsl,
72 SurfacePressure,
74 CloudCover,
76 CloudCoverLow,
78 CloudCoverMid,
80 CloudCoverHigh,
82 Precipitation,
84 PrecipitationHours,
86 PrecipitationProbability,
88 Rain,
90 Showers,
92 Snowfall,
94 SnowfallWaterEquivalent,
96 SnowfallHeight,
98 SnowDepth,
100 WeatherCode,
102 WindSpeed,
104 WindDirection,
106 WindGust,
108 SoilTemperature,
110 SoilMoisture,
112 GeopotentialHeight,
114 Visibility,
116 Evapotranspiration,
118 Et0FaoEvapotranspiration,
120 VapourPressureDeficit,
122 IsDay,
124 DaylightDuration,
126 SunshineDuration,
128 UvIndex,
130 UvIndexClearSky,
132 TotalColumnIntegratedWaterVapour,
134 ShortwaveRadiation,
136 ShortwaveRadiationInstant,
138 DirectRadiation,
140 DirectRadiationInstant,
142 DirectNormalIrradiance,
144 DirectNormalIrradianceInstant,
146 DiffuseRadiation,
148 DiffuseRadiationInstant,
150 GlobalTiltedIrradiance,
152 GlobalTiltedIrradianceInstant,
154 TerrestrialRadiation,
156 TerrestrialRadiationInstant,
158 Cape,
160 LiftedIndex,
162 ConvectiveInhibition,
164 FreezingLevelHeight,
166 BoundaryLayerHeight,
168 GrowingDegreeDays,
170 LeafWetnessProbability,
172 LightningPotential,
174 Updraft,
176 WaveHeight,
178 WaveDirection,
180 WavePeriod,
182 WavePeakPeriod,
184 SeaLevelHeightMsl,
186 RiverDischarge,
188 SeaSurfaceTemperature,
190 OceanCurrentVelocity,
192 OceanCurrentDirection,
194 InvertBarometerHeight,
196 ParticulateMatter,
198 CarbonMonoxide,
200 CarbonDioxide,
202 NitrogenDioxide,
204 SulphurDioxide,
206 Ozone,
208 AerosolOpticalDepth,
210 Dust,
212 Ammonia,
214 Methane,
216 Pollen,
218 AirQualityIndex,
220 Formaldehyde,
222 Glyoxal,
224 VolatileOrganicCompounds,
226 Pm10Wildfires,
228 PeroxyacylNitrates,
230 SecondaryInorganicAerosol,
232 ElementaryCarbon,
234 OrganicMatter,
236 SeaSaltAerosol,
238 NitrogenMonoxide,
240 SunEvent,
242 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}