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