Skip to main content

grib_reader/
metadata.rs

1//! Edition-independent field metadata.
2
3/// Common reference time representation for GRIB fields.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub struct ReferenceTime {
6    pub year: u16,
7    pub month: u8,
8    pub day: u8,
9    pub hour: u8,
10    pub minute: u8,
11    pub second: u8,
12}
13
14impl ReferenceTime {
15    /// Add a GRIB forecast lead using fixed-width Code Table 4.4 units.
16    ///
17    /// Returns `None` for unsupported calendar-dependent units or invalid
18    /// timestamps.
19    pub fn checked_add_forecast_time(&self, unit: u8, value: u32) -> Option<Self> {
20        let seconds_per_unit = match unit {
21            0 => 60,
22            1 => 60 * 60,
23            2 => 24 * 60 * 60,
24            10 => 3 * 60 * 60,
25            11 => 6 * 60 * 60,
26            12 => 12 * 60 * 60,
27            13 => 1,
28            _ => return None,
29        };
30
31        let base = self.seconds_since_epoch()?;
32        let delta = i64::from(value).checked_mul(seconds_per_unit)?;
33        Self::from_seconds_since_epoch(base.checked_add(delta)?)
34    }
35
36    fn seconds_since_epoch(&self) -> Option<i64> {
37        if !(1..=12).contains(&self.month)
38            || self.day == 0
39            || self.day > days_in_month(self.year, self.month)
40            || self.hour > 23
41            || self.minute > 59
42            || self.second > 59
43        {
44            return None;
45        }
46
47        let days = days_from_civil(self.year, self.month, self.day)?;
48        let seconds =
49            i64::from(self.hour) * 60 * 60 + i64::from(self.minute) * 60 + i64::from(self.second);
50        days.checked_mul(24 * 60 * 60)?.checked_add(seconds)
51    }
52
53    fn from_seconds_since_epoch(seconds: i64) -> Option<Self> {
54        let days = seconds.div_euclid(24 * 60 * 60);
55        let seconds_of_day = seconds.rem_euclid(24 * 60 * 60);
56        let (year, month, day) = civil_from_days(days)?;
57
58        Some(Self {
59            year,
60            month,
61            day,
62            hour: (seconds_of_day / (60 * 60)) as u8,
63            minute: ((seconds_of_day % (60 * 60)) / 60) as u8,
64            second: (seconds_of_day % 60) as u8,
65        })
66    }
67}
68
69/// Edition-independent parameter identity.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct Parameter {
72    pub discipline: Option<u8>,
73    pub category: Option<u8>,
74    pub table_version: Option<u8>,
75    pub number: u8,
76    pub short_name: &'static str,
77    pub description: &'static str,
78}
79
80impl Parameter {
81    pub fn new_grib1(
82        table_version: u8,
83        number: u8,
84        short_name: &'static str,
85        description: &'static str,
86    ) -> Self {
87        Self {
88            discipline: None,
89            category: None,
90            table_version: Some(table_version),
91            number,
92            short_name,
93            description,
94        }
95    }
96
97    pub fn new_grib2(
98        discipline: u8,
99        category: u8,
100        number: u8,
101        short_name: &'static str,
102        description: &'static str,
103    ) -> Self {
104        Self {
105            discipline: Some(discipline),
106            category: Some(category),
107            table_version: None,
108            number,
109            short_name,
110            description,
111        }
112    }
113}
114
115fn days_in_month(year: u16, month: u8) -> u8 {
116    match month {
117        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
118        4 | 6 | 9 | 11 => 30,
119        2 if is_leap_year(year) => 29,
120        2 => 28,
121        _ => 0,
122    }
123}
124
125fn is_leap_year(year: u16) -> bool {
126    year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
127}
128
129fn days_from_civil(year: u16, month: u8, day: u8) -> Option<i64> {
130    let month = i64::from(month);
131    let day = i64::from(day);
132    if !(1..=12).contains(&(month as u8)) {
133        return None;
134    }
135
136    let year = i64::from(year) - if month <= 2 { 1 } else { 0 };
137    let era = if year >= 0 { year } else { year - 399 } / 400;
138    let year_of_era = year - era * 400;
139    let month_prime = month + if month > 2 { -3 } else { 9 };
140    let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
141    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
142    Some(era * 146_097 + day_of_era - 719_468)
143}
144
145fn civil_from_days(days_since_epoch: i64) -> Option<(u16, u8, u8)> {
146    let z = days_since_epoch + 719_468;
147    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
148    let day_of_era = z - era * 146_097;
149    let year_of_era =
150        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
151    let year = year_of_era + era * 400;
152    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
153    let month_prime = (5 * day_of_year + 2) / 153;
154    let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
155    let month = month_prime + if month_prime < 10 { 3 } else { -9 };
156    let year = year + if month <= 2 { 1 } else { 0 };
157
158    if !(0..=i64::from(u16::MAX)).contains(&year) {
159        return None;
160    }
161
162    Some((year as u16, month as u8, day as u8))
163}
164
165#[cfg(test)]
166mod tests {
167    use super::ReferenceTime;
168
169    #[test]
170    fn adds_forecast_hours_across_day_boundary() {
171        let valid = ReferenceTime {
172            year: 2026,
173            month: 3,
174            day: 20,
175            hour: 18,
176            minute: 0,
177            second: 0,
178        }
179        .checked_add_forecast_time(11, 2)
180        .unwrap();
181
182        assert_eq!(
183            valid,
184            ReferenceTime {
185                year: 2026,
186                month: 3,
187                day: 21,
188                hour: 6,
189                minute: 0,
190                second: 0,
191            }
192        );
193    }
194
195    #[test]
196    fn adds_forecast_days_across_leap_day() {
197        let valid = ReferenceTime {
198            year: 2024,
199            month: 2,
200            day: 28,
201            hour: 12,
202            minute: 30,
203            second: 0,
204        }
205        .checked_add_forecast_time(2, 2)
206        .unwrap();
207
208        assert_eq!(
209            valid,
210            ReferenceTime {
211                year: 2024,
212                month: 3,
213                day: 1,
214                hour: 12,
215                minute: 30,
216                second: 0,
217            }
218        );
219    }
220
221    #[test]
222    fn rejects_unsupported_forecast_units() {
223        assert!(ReferenceTime {
224            year: 2026,
225            month: 3,
226            day: 20,
227            hour: 12,
228            minute: 0,
229            second: 0,
230        }
231        .checked_add_forecast_time(3, 1)
232        .is_none());
233    }
234}