1#[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#[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 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 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 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#[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}