Skip to main content

grib_core/
metadata.rs

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