Skip to main content

grib_core/
metadata.rs

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