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