Skip to main content

grib_core/
metadata.rs

1//! Edition-independent field metadata.
2
3use std::borrow::Cow;
4
5/// Semantic forecast-time units shared across GRIB editions.
6///
7/// Raw unit codes are edition-specific. In particular, GRIB1 code `13` means
8/// quarter-hour while GRIB2 code `13` means second.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ForecastTimeUnit {
11    Minute,
12    Hour,
13    Day,
14    Month,
15    Year,
16    Decade,
17    Normal,
18    Century,
19    ThreeHours,
20    SixHours,
21    TwelveHours,
22    QuarterHour,
23    HalfHour,
24    Second,
25}
26
27impl ForecastTimeUnit {
28    pub fn from_grib1_code(code: u8) -> Option<Self> {
29        Some(match code {
30            0 => Self::Minute,
31            1 => Self::Hour,
32            2 => Self::Day,
33            3 => Self::Month,
34            4 => Self::Year,
35            5 => Self::Decade,
36            6 => Self::Normal,
37            7 => Self::Century,
38            10 => Self::ThreeHours,
39            11 => Self::SixHours,
40            12 => Self::TwelveHours,
41            13 => Self::QuarterHour,
42            14 => Self::HalfHour,
43            254 => Self::Second,
44            _ => return None,
45        })
46    }
47
48    pub fn from_grib2_code(code: u8) -> Option<Self> {
49        Some(match code {
50            0 => Self::Minute,
51            1 => Self::Hour,
52            2 => Self::Day,
53            3 => Self::Month,
54            4 => Self::Year,
55            5 => Self::Decade,
56            6 => Self::Normal,
57            7 => Self::Century,
58            10 => Self::ThreeHours,
59            11 => Self::SixHours,
60            12 => Self::TwelveHours,
61            13 => Self::Second,
62            _ => return None,
63        })
64    }
65
66    pub fn from_edition_and_code(edition: u8, code: u8) -> Option<Self> {
67        match edition {
68            1 => Self::from_grib1_code(code),
69            2 => Self::from_grib2_code(code),
70            _ => None,
71        }
72    }
73
74    fn seconds_per_unit(self) -> Option<i64> {
75        Some(match self {
76            Self::Minute => 60,
77            Self::Hour => 60 * 60,
78            Self::Day => 24 * 60 * 60,
79            Self::ThreeHours => 3 * 60 * 60,
80            Self::SixHours => 6 * 60 * 60,
81            Self::TwelveHours => 12 * 60 * 60,
82            Self::QuarterHour => 15 * 60,
83            Self::HalfHour => 30 * 60,
84            Self::Second => 1,
85            Self::Month | Self::Year | Self::Decade | Self::Normal | Self::Century => {
86                return None;
87            }
88        })
89    }
90}
91
92/// Common reference time representation for GRIB fields.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub struct ReferenceTime {
95    pub year: u16,
96    pub month: u8,
97    pub day: u8,
98    pub hour: u8,
99    pub minute: u8,
100    pub second: u8,
101}
102
103impl ReferenceTime {
104    /// Add a GRIB forecast lead using a semantic forecast-time unit.
105    ///
106    /// Returns `None` for calendar-dependent units or invalid timestamps.
107    pub fn checked_add_forecast_time_unit(
108        &self,
109        unit: ForecastTimeUnit,
110        value: u32,
111    ) -> Option<Self> {
112        let seconds_per_unit = unit.seconds_per_unit()?;
113        let base = self.seconds_since_epoch()?;
114        let delta = i64::from(value).checked_mul(seconds_per_unit)?;
115        Self::from_seconds_since_epoch(base.checked_add(delta)?)
116    }
117
118    /// Add a GRIB forecast lead using raw GRIB edition and unit-code values.
119    ///
120    /// Returns `None` for unsupported edition/code pairs, calendar-dependent
121    /// units, or invalid timestamps.
122    pub fn checked_add_forecast_time_by_edition(
123        &self,
124        edition: u8,
125        unit: u8,
126        value: u32,
127    ) -> Option<Self> {
128        let unit = ForecastTimeUnit::from_edition_and_code(edition, unit)?;
129        self.checked_add_forecast_time_unit(unit, value)
130    }
131
132    /// Add a GRIB forecast lead using raw GRIB2 Code Table 4.4 units.
133    ///
134    /// Returns `None` for unsupported unit codes, calendar-dependent units, or
135    /// invalid timestamps.
136    pub fn checked_add_forecast_time(&self, unit: u8, value: u32) -> Option<Self> {
137        let unit = ForecastTimeUnit::from_grib2_code(unit)?;
138        self.checked_add_forecast_time_unit(unit, value)
139    }
140
141    fn seconds_since_epoch(&self) -> Option<i64> {
142        if !(1..=12).contains(&self.month)
143            || self.day == 0
144            || self.day > days_in_month(self.year, self.month)
145            || self.hour > 23
146            || self.minute > 59
147            || self.second > 59
148        {
149            return None;
150        }
151
152        let days = days_from_civil(self.year, self.month, self.day)?;
153        let seconds =
154            i64::from(self.hour) * 60 * 60 + i64::from(self.minute) * 60 + i64::from(self.second);
155        days.checked_mul(24 * 60 * 60)?.checked_add(seconds)
156    }
157
158    fn from_seconds_since_epoch(seconds: i64) -> Option<Self> {
159        let days = seconds.div_euclid(24 * 60 * 60);
160        let seconds_of_day = seconds.rem_euclid(24 * 60 * 60);
161        let (year, month, day) = civil_from_days(days)?;
162
163        Some(Self {
164            year,
165            month,
166            day,
167            hour: (seconds_of_day / (60 * 60)) as u8,
168            minute: ((seconds_of_day % (60 * 60)) / 60) as u8,
169            second: (seconds_of_day % 60) as u8,
170        })
171    }
172}
173
174/// Source table used to resolve a parameter name.
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum ParameterTableSource {
177    /// GRIB1 parameter table.
178    Grib1 { table_version: u8 },
179    /// WMO GRIB2 Code Table 4.2.
180    Wmo,
181    /// Center-specific GRIB2 local table.
182    Local {
183        center_id: u16,
184        subcenter_id: u16,
185        local_table_version: u8,
186    },
187    /// A GRIB2 local-use code without a matching local table entry.
188    UnknownLocal {
189        center_id: u16,
190        subcenter_id: u16,
191        local_table_version: u8,
192    },
193    /// No WMO or local table entry matched this parameter.
194    Unknown,
195}
196
197/// Edition-independent parameter identity.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct Parameter {
200    pub discipline: Option<u8>,
201    pub category: Option<u8>,
202    pub table_version: Option<u8>,
203    pub number: u8,
204    pub short_name: Cow<'static, str>,
205    pub description: Cow<'static, str>,
206    pub source: ParameterTableSource,
207}
208
209impl Parameter {
210    pub fn new_grib1(
211        table_version: u8,
212        number: u8,
213        short_name: &'static str,
214        description: &'static str,
215    ) -> Self {
216        Self {
217            discipline: None,
218            category: None,
219            table_version: Some(table_version),
220            number,
221            short_name: Cow::Borrowed(short_name),
222            description: Cow::Borrowed(description),
223            source: ParameterTableSource::Grib1 { table_version },
224        }
225    }
226
227    pub fn new_grib2(
228        discipline: u8,
229        category: u8,
230        number: u8,
231        short_name: &'static str,
232        description: &'static str,
233    ) -> Self {
234        let source = if short_name == "unknown" && description == "Unknown parameter" {
235            ParameterTableSource::Unknown
236        } else {
237            ParameterTableSource::Wmo
238        };
239        Self::new_grib2_with_source(
240            discipline,
241            category,
242            number,
243            short_name,
244            description,
245            source,
246        )
247    }
248
249    pub fn new_grib2_with_source<S, D>(
250        discipline: u8,
251        category: u8,
252        number: u8,
253        short_name: S,
254        description: D,
255        source: ParameterTableSource,
256    ) -> Self
257    where
258        S: Into<Cow<'static, str>>,
259        D: Into<Cow<'static, str>>,
260    {
261        Self {
262            discipline: Some(discipline),
263            category: Some(category),
264            table_version: None,
265            number,
266            short_name: short_name.into(),
267            description: description.into(),
268            source,
269        }
270    }
271}
272
273fn days_in_month(year: u16, month: u8) -> u8 {
274    match month {
275        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
276        4 | 6 | 9 | 11 => 30,
277        2 if is_leap_year(year) => 29,
278        2 => 28,
279        _ => 0,
280    }
281}
282
283fn is_leap_year(year: u16) -> bool {
284    year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
285}
286
287fn days_from_civil(year: u16, month: u8, day: u8) -> Option<i64> {
288    let month = i64::from(month);
289    let day = i64::from(day);
290    if !(1..=12).contains(&(month as u8)) {
291        return None;
292    }
293
294    let year = i64::from(year) - if month <= 2 { 1 } else { 0 };
295    let era = if year >= 0 { year } else { year - 399 } / 400;
296    let year_of_era = year - era * 400;
297    let month_prime = month + if month > 2 { -3 } else { 9 };
298    let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
299    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
300    Some(era * 146_097 + day_of_era - 719_468)
301}
302
303fn civil_from_days(days_since_epoch: i64) -> Option<(u16, u8, u8)> {
304    let z = days_since_epoch + 719_468;
305    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
306    let day_of_era = z - era * 146_097;
307    let year_of_era =
308        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
309    let year = year_of_era + era * 400;
310    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
311    let month_prime = (5 * day_of_year + 2) / 153;
312    let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
313    let month = month_prime + if month_prime < 10 { 3 } else { -9 };
314    let year = year + if month <= 2 { 1 } else { 0 };
315
316    if !(0..=i64::from(u16::MAX)).contains(&year) {
317        return None;
318    }
319
320    Some((year as u16, month as u8, day as u8))
321}
322
323#[cfg(test)]
324mod tests {
325    use super::{ForecastTimeUnit, ReferenceTime};
326
327    #[test]
328    fn adds_forecast_hours_across_day_boundary() {
329        let valid = ReferenceTime {
330            year: 2026,
331            month: 3,
332            day: 20,
333            hour: 18,
334            minute: 0,
335            second: 0,
336        }
337        .checked_add_forecast_time(11, 2)
338        .unwrap();
339
340        assert_eq!(
341            valid,
342            ReferenceTime {
343                year: 2026,
344                month: 3,
345                day: 21,
346                hour: 6,
347                minute: 0,
348                second: 0,
349            }
350        );
351    }
352
353    #[test]
354    fn adds_forecast_days_across_leap_day() {
355        let valid = ReferenceTime {
356            year: 2024,
357            month: 2,
358            day: 28,
359            hour: 12,
360            minute: 30,
361            second: 0,
362        }
363        .checked_add_forecast_time(2, 2)
364        .unwrap();
365
366        assert_eq!(
367            valid,
368            ReferenceTime {
369                year: 2024,
370                month: 3,
371                day: 1,
372                hour: 12,
373                minute: 30,
374                second: 0,
375            }
376        );
377    }
378
379    #[test]
380    fn rejects_unsupported_forecast_units() {
381        assert!(ReferenceTime {
382            year: 2026,
383            month: 3,
384            day: 20,
385            hour: 12,
386            minute: 0,
387            second: 0,
388        }
389        .checked_add_forecast_time(3, 1)
390        .is_none());
391    }
392
393    #[test]
394    fn decodes_edition_specific_forecast_units() {
395        assert_eq!(
396            ForecastTimeUnit::from_grib1_code(13),
397            Some(ForecastTimeUnit::QuarterHour)
398        );
399        assert_eq!(
400            ForecastTimeUnit::from_grib2_code(13),
401            Some(ForecastTimeUnit::Second)
402        );
403        assert_eq!(
404            ForecastTimeUnit::from_grib1_code(254),
405            Some(ForecastTimeUnit::Second)
406        );
407    }
408
409    #[test]
410    fn adds_grib1_quarter_hours_by_edition() {
411        let valid = ReferenceTime {
412            year: 2026,
413            month: 3,
414            day: 20,
415            hour: 12,
416            minute: 0,
417            second: 0,
418        }
419        .checked_add_forecast_time_by_edition(1, 13, 2)
420        .unwrap();
421
422        assert_eq!(
423            valid,
424            ReferenceTime {
425                year: 2026,
426                month: 3,
427                day: 20,
428                hour: 12,
429                minute: 30,
430                second: 0,
431            }
432        );
433    }
434
435    #[test]
436    fn adds_semantic_second_units() {
437        let valid = ReferenceTime {
438            year: 2026,
439            month: 3,
440            day: 20,
441            hour: 12,
442            minute: 0,
443            second: 0,
444        }
445        .checked_add_forecast_time_unit(ForecastTimeUnit::Second, 30)
446        .unwrap();
447
448        assert_eq!(
449            valid,
450            ReferenceTime {
451                year: 2026,
452                month: 3,
453                day: 20,
454                hour: 12,
455                minute: 0,
456                second: 30,
457            }
458        );
459    }
460}