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