1use crate::error::{Error, Result};
4
5use std::borrow::Cow;
6
7#[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#[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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum ParameterTableSource {
198 Grib1 { table_version: u8 },
200 Wmo,
202 Local {
204 center_id: u16,
205 subcenter_id: u16,
206 local_table_version: u8,
207 },
208 UnknownLocal {
210 center_id: u16,
211 subcenter_id: u16,
212 local_table_version: u8,
213 },
214 Unknown,
216}
217
218#[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}