1use std::borrow::Cow;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ForecastTimeUnit {
11 Minute,
12 Hour,
13 Day,
14 Month,
15 Year,
16 Decade,
17 Normal,
18 Century,
19 ThreeHours,
20 SixHours,
21 TwelveHours,
22 QuarterHour,
23 HalfHour,
24 Second,
25}
26
27impl ForecastTimeUnit {
28 pub fn from_grib1_code(code: u8) -> Option<Self> {
29 Some(match code {
30 0 => Self::Minute,
31 1 => Self::Hour,
32 2 => Self::Day,
33 3 => Self::Month,
34 4 => Self::Year,
35 5 => Self::Decade,
36 6 => Self::Normal,
37 7 => Self::Century,
38 10 => Self::ThreeHours,
39 11 => Self::SixHours,
40 12 => Self::TwelveHours,
41 13 => Self::QuarterHour,
42 14 => Self::HalfHour,
43 254 => Self::Second,
44 _ => return None,
45 })
46 }
47
48 pub fn from_grib2_code(code: u8) -> Option<Self> {
49 Some(match code {
50 0 => Self::Minute,
51 1 => Self::Hour,
52 2 => Self::Day,
53 3 => Self::Month,
54 4 => Self::Year,
55 5 => Self::Decade,
56 6 => Self::Normal,
57 7 => Self::Century,
58 10 => Self::ThreeHours,
59 11 => Self::SixHours,
60 12 => Self::TwelveHours,
61 13 => Self::Second,
62 _ => return None,
63 })
64 }
65
66 pub fn from_edition_and_code(edition: u8, code: u8) -> Option<Self> {
67 match edition {
68 1 => Self::from_grib1_code(code),
69 2 => Self::from_grib2_code(code),
70 _ => None,
71 }
72 }
73
74 fn seconds_per_unit(self) -> Option<i64> {
75 Some(match self {
76 Self::Minute => 60,
77 Self::Hour => 60 * 60,
78 Self::Day => 24 * 60 * 60,
79 Self::ThreeHours => 3 * 60 * 60,
80 Self::SixHours => 6 * 60 * 60,
81 Self::TwelveHours => 12 * 60 * 60,
82 Self::QuarterHour => 15 * 60,
83 Self::HalfHour => 30 * 60,
84 Self::Second => 1,
85 Self::Month | Self::Year | Self::Decade | Self::Normal | Self::Century => {
86 return None;
87 }
88 })
89 }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub struct ReferenceTime {
95 pub year: u16,
96 pub month: u8,
97 pub day: u8,
98 pub hour: u8,
99 pub minute: u8,
100 pub second: u8,
101}
102
103impl ReferenceTime {
104 pub fn checked_add_forecast_time_unit(
108 &self,
109 unit: ForecastTimeUnit,
110 value: u32,
111 ) -> Option<Self> {
112 let seconds_per_unit = unit.seconds_per_unit()?;
113 let base = self.seconds_since_epoch()?;
114 let delta = i64::from(value).checked_mul(seconds_per_unit)?;
115 Self::from_seconds_since_epoch(base.checked_add(delta)?)
116 }
117
118 pub fn checked_add_forecast_time_by_edition(
123 &self,
124 edition: u8,
125 unit: u8,
126 value: u32,
127 ) -> Option<Self> {
128 let unit = ForecastTimeUnit::from_edition_and_code(edition, unit)?;
129 self.checked_add_forecast_time_unit(unit, value)
130 }
131
132 pub fn checked_add_forecast_time(&self, unit: u8, value: u32) -> Option<Self> {
137 let unit = ForecastTimeUnit::from_grib2_code(unit)?;
138 self.checked_add_forecast_time_unit(unit, value)
139 }
140
141 fn seconds_since_epoch(&self) -> Option<i64> {
142 if !(1..=12).contains(&self.month)
143 || self.day == 0
144 || self.day > days_in_month(self.year, self.month)
145 || self.hour > 23
146 || self.minute > 59
147 || self.second > 59
148 {
149 return None;
150 }
151
152 let days = days_from_civil(self.year, self.month, self.day)?;
153 let seconds =
154 i64::from(self.hour) * 60 * 60 + i64::from(self.minute) * 60 + i64::from(self.second);
155 days.checked_mul(24 * 60 * 60)?.checked_add(seconds)
156 }
157
158 fn from_seconds_since_epoch(seconds: i64) -> Option<Self> {
159 let days = seconds.div_euclid(24 * 60 * 60);
160 let seconds_of_day = seconds.rem_euclid(24 * 60 * 60);
161 let (year, month, day) = civil_from_days(days)?;
162
163 Some(Self {
164 year,
165 month,
166 day,
167 hour: (seconds_of_day / (60 * 60)) as u8,
168 minute: ((seconds_of_day % (60 * 60)) / 60) as u8,
169 second: (seconds_of_day % 60) as u8,
170 })
171 }
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum ParameterTableSource {
177 Grib1 { table_version: u8 },
179 Wmo,
181 Local {
183 center_id: u16,
184 subcenter_id: u16,
185 local_table_version: u8,
186 },
187 UnknownLocal {
189 center_id: u16,
190 subcenter_id: u16,
191 local_table_version: u8,
192 },
193 Unknown,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct Parameter {
200 pub discipline: Option<u8>,
201 pub category: Option<u8>,
202 pub table_version: Option<u8>,
203 pub number: u8,
204 pub short_name: Cow<'static, str>,
205 pub description: Cow<'static, str>,
206 pub source: ParameterTableSource,
207}
208
209impl Parameter {
210 pub fn new_grib1(
211 table_version: u8,
212 number: u8,
213 short_name: &'static str,
214 description: &'static str,
215 ) -> Self {
216 Self {
217 discipline: None,
218 category: None,
219 table_version: Some(table_version),
220 number,
221 short_name: Cow::Borrowed(short_name),
222 description: Cow::Borrowed(description),
223 source: ParameterTableSource::Grib1 { table_version },
224 }
225 }
226
227 pub fn new_grib2(
228 discipline: u8,
229 category: u8,
230 number: u8,
231 short_name: &'static str,
232 description: &'static str,
233 ) -> Self {
234 let source = if short_name == "unknown" && description == "Unknown parameter" {
235 ParameterTableSource::Unknown
236 } else {
237 ParameterTableSource::Wmo
238 };
239 Self::new_grib2_with_source(
240 discipline,
241 category,
242 number,
243 short_name,
244 description,
245 source,
246 )
247 }
248
249 pub fn new_grib2_with_source<S, D>(
250 discipline: u8,
251 category: u8,
252 number: u8,
253 short_name: S,
254 description: D,
255 source: ParameterTableSource,
256 ) -> Self
257 where
258 S: Into<Cow<'static, str>>,
259 D: Into<Cow<'static, str>>,
260 {
261 Self {
262 discipline: Some(discipline),
263 category: Some(category),
264 table_version: None,
265 number,
266 short_name: short_name.into(),
267 description: description.into(),
268 source,
269 }
270 }
271}
272
273fn days_in_month(year: u16, month: u8) -> u8 {
274 match month {
275 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
276 4 | 6 | 9 | 11 => 30,
277 2 if is_leap_year(year) => 29,
278 2 => 28,
279 _ => 0,
280 }
281}
282
283fn is_leap_year(year: u16) -> bool {
284 year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
285}
286
287fn days_from_civil(year: u16, month: u8, day: u8) -> Option<i64> {
288 let month = i64::from(month);
289 let day = i64::from(day);
290 if !(1..=12).contains(&(month as u8)) {
291 return None;
292 }
293
294 let year = i64::from(year) - if month <= 2 { 1 } else { 0 };
295 let era = if year >= 0 { year } else { year - 399 } / 400;
296 let year_of_era = year - era * 400;
297 let month_prime = month + if month > 2 { -3 } else { 9 };
298 let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
299 let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
300 Some(era * 146_097 + day_of_era - 719_468)
301}
302
303fn civil_from_days(days_since_epoch: i64) -> Option<(u16, u8, u8)> {
304 let z = days_since_epoch + 719_468;
305 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
306 let day_of_era = z - era * 146_097;
307 let year_of_era =
308 (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
309 let year = year_of_era + era * 400;
310 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
311 let month_prime = (5 * day_of_year + 2) / 153;
312 let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
313 let month = month_prime + if month_prime < 10 { 3 } else { -9 };
314 let year = year + if month <= 2 { 1 } else { 0 };
315
316 if !(0..=i64::from(u16::MAX)).contains(&year) {
317 return None;
318 }
319
320 Some((year as u16, month as u8, day as u8))
321}
322
323#[cfg(test)]
324mod tests {
325 use super::{ForecastTimeUnit, ReferenceTime};
326
327 #[test]
328 fn adds_forecast_hours_across_day_boundary() {
329 let valid = ReferenceTime {
330 year: 2026,
331 month: 3,
332 day: 20,
333 hour: 18,
334 minute: 0,
335 second: 0,
336 }
337 .checked_add_forecast_time(11, 2)
338 .unwrap();
339
340 assert_eq!(
341 valid,
342 ReferenceTime {
343 year: 2026,
344 month: 3,
345 day: 21,
346 hour: 6,
347 minute: 0,
348 second: 0,
349 }
350 );
351 }
352
353 #[test]
354 fn adds_forecast_days_across_leap_day() {
355 let valid = ReferenceTime {
356 year: 2024,
357 month: 2,
358 day: 28,
359 hour: 12,
360 minute: 30,
361 second: 0,
362 }
363 .checked_add_forecast_time(2, 2)
364 .unwrap();
365
366 assert_eq!(
367 valid,
368 ReferenceTime {
369 year: 2024,
370 month: 3,
371 day: 1,
372 hour: 12,
373 minute: 30,
374 second: 0,
375 }
376 );
377 }
378
379 #[test]
380 fn rejects_unsupported_forecast_units() {
381 assert!(ReferenceTime {
382 year: 2026,
383 month: 3,
384 day: 20,
385 hour: 12,
386 minute: 0,
387 second: 0,
388 }
389 .checked_add_forecast_time(3, 1)
390 .is_none());
391 }
392
393 #[test]
394 fn decodes_edition_specific_forecast_units() {
395 assert_eq!(
396 ForecastTimeUnit::from_grib1_code(13),
397 Some(ForecastTimeUnit::QuarterHour)
398 );
399 assert_eq!(
400 ForecastTimeUnit::from_grib2_code(13),
401 Some(ForecastTimeUnit::Second)
402 );
403 assert_eq!(
404 ForecastTimeUnit::from_grib1_code(254),
405 Some(ForecastTimeUnit::Second)
406 );
407 }
408
409 #[test]
410 fn adds_grib1_quarter_hours_by_edition() {
411 let valid = ReferenceTime {
412 year: 2026,
413 month: 3,
414 day: 20,
415 hour: 12,
416 minute: 0,
417 second: 0,
418 }
419 .checked_add_forecast_time_by_edition(1, 13, 2)
420 .unwrap();
421
422 assert_eq!(
423 valid,
424 ReferenceTime {
425 year: 2026,
426 month: 3,
427 day: 20,
428 hour: 12,
429 minute: 30,
430 second: 0,
431 }
432 );
433 }
434
435 #[test]
436 fn adds_semantic_second_units() {
437 let valid = ReferenceTime {
438 year: 2026,
439 month: 3,
440 day: 20,
441 hour: 12,
442 minute: 0,
443 second: 0,
444 }
445 .checked_add_forecast_time_unit(ForecastTimeUnit::Second, 30)
446 .unwrap();
447
448 assert_eq!(
449 valid,
450 ReferenceTime {
451 year: 2026,
452 month: 3,
453 day: 20,
454 hour: 12,
455 minute: 0,
456 second: 30,
457 }
458 );
459 }
460}