1use std::sync::Arc;
9
10use arrow::array::{ArrayRef, AsArray, Float64Array, TimestampMicrosecondArray};
11use arrow::compute::cast as arrow_cast;
12pub use arrow::compute::kernels::temporal::DatePart;
13use arrow::compute::kernels::temporal::date_part;
14use arrow::datatypes::{DataType, Float64Type, TimeUnit, TimestampMicrosecondType};
15
16use crate::compute::cf::decode_cf;
17use crate::error::{Result, nd_err};
18use crate::{Attributes, NdArrowArray};
19
20pub const UNITS: &str = "units";
22pub const CALENDAR: &str = "calendar";
24
25pub(crate) const MICROS_PER_SECOND: i64 = 1_000_000;
26pub(crate) const MICROS_PER_DAY: i64 = 86_400 * MICROS_PER_SECOND;
27
28pub fn timestamp_type() -> DataType {
30 DataType::Timestamp(TimeUnit::Microsecond, None)
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct CfTimeUnits {
36 pub step_micros: i64,
38 pub reference_micros: i64,
40}
41
42impl CfTimeUnits {
43 pub fn parse(units: &str) -> Result<Self> {
45 let invalid = || nd_err!(InvalidAttribute, "invalid CF time units '{units}'");
46 let (unit, reference) = units.split_once(" since ").ok_or_else(invalid)?;
47 let step_micros = match unit.trim().to_ascii_lowercase().as_str() {
48 "microseconds" | "microsecond" | "us" => 1,
49 "milliseconds" | "millisecond" | "ms" | "msec" => 1_000,
50 "seconds" | "second" | "secs" | "sec" | "s" => MICROS_PER_SECOND,
51 "minutes" | "minute" | "mins" | "min" => 60 * MICROS_PER_SECOND,
52 "hours" | "hour" | "hrs" | "hr" | "h" => 3_600 * MICROS_PER_SECOND,
53 "days" | "day" | "d" => MICROS_PER_DAY,
54 "weeks" | "week" => 7 * MICROS_PER_DAY,
55 _ => return Err(invalid()),
56 };
57 let reference_micros = parse_datetime(reference.trim()).ok_or_else(invalid)?;
58 Ok(Self {
59 step_micros,
60 reference_micros,
61 })
62 }
63}
64
65pub(crate) fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
67 let year = if month <= 2 { year - 1 } else { year };
68 let era = year.div_euclid(400);
69 let yoe = year - era * 400;
70 let month = month as i64;
71 let doy = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day as i64 - 1;
72 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
73 era * 146_097 + doe - 719_468
74}
75
76pub(crate) fn civil_from_days(days: i64) -> (i64, u32, u32) {
78 let z = days + 719_468;
79 let era = z.div_euclid(146_097);
80 let doe = z - era * 146_097;
81 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
82 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
83 let mp = (5 * doy + 2) / 153;
84 let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
85 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
86 let year = yoe + era * 400 + i64::from(month <= 2);
87 (year, month, day)
88}
89
90fn parse_datetime(text: &str) -> Option<i64> {
92 let text = text.trim();
93 let (date, rest) = match text.find(['T', ' ']) {
94 Some(i) => (&text[..i], text[i + 1..].trim()),
95 None => (text, ""),
96 };
97 let negative_year = date.starts_with('-');
98 let mut parts = date.trim_start_matches('-').splitn(3, '-');
99 let year: i64 = parts.next()?.parse().ok()?;
100 let year = if negative_year { -year } else { year };
101 let month: u32 = parts.next().map_or(Some(1), |m| m.parse().ok())?;
102 let day: u32 = parts.next().map_or(Some(1), |d| d.parse().ok())?;
103 if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
104 return None;
105 }
106
107 let mut micros = days_from_civil(year, month, day) * MICROS_PER_DAY;
108 let rest = rest.trim_end_matches("UTC").trim();
109 if rest.is_empty() {
110 return Some(micros);
111 }
112 let (time, zone) = match rest.find(['Z', '+', '-']) {
113 Some(i) => (&rest[..i], rest[i..].trim()),
114 None => (rest, ""),
115 };
116 let mut fields = time.trim().split(':');
117 let hour: i64 = fields.next()?.trim().parse().ok()?;
118 let minute: i64 = fields.next().map_or(Some(0), |m| m.parse().ok())?;
119 let seconds: f64 = fields.next().map_or(Some(0.0), |s| s.parse().ok())?;
120 micros += hour * 3_600 * MICROS_PER_SECOND
121 + minute * 60 * MICROS_PER_SECOND
122 + (seconds * MICROS_PER_SECOND as f64).round() as i64;
123
124 if let Some(offset) = zone.strip_prefix(['+', '-']) {
125 let sign = if zone.starts_with('-') { -1 } else { 1 };
126 let digits: String = offset.chars().filter(char::is_ascii_digit).collect();
127 let (h, m) = match digits.len() {
128 1 | 2 => (digits.parse::<i64>().ok()?, 0),
129 4 => (
130 digits[..2].parse::<i64>().ok()?,
131 digits[2..].parse::<i64>().ok()?,
132 ),
133 _ => return None,
134 };
135 micros -= sign * (h * 3_600 + m * 60) * MICROS_PER_SECOND;
136 } else if !(zone.is_empty() || zone == "Z") {
137 return None;
138 }
139 Some(micros)
140}
141
142fn check_calendar(attrs: &Attributes) -> Result<()> {
143 match attrs
144 .get_str(CALENDAR)
145 .map(str::to_ascii_lowercase)
146 .as_deref()
147 {
148 None | Some("standard" | "gregorian" | "proleptic_gregorian") => Ok(()),
149 Some(other) => Err(nd_err!(NotSupported, "CF calendar '{other}'")),
150 }
151}
152
153pub fn decode_cf_time(a: &NdArrowArray) -> Result<(NdArrowArray, Attributes)> {
159 let units = a
160 .attributes()
161 .get_str(UNITS)
162 .ok_or_else(|| nd_err!(InvalidAttribute, "time variable has no 'units' attribute"))?;
163 let parsed = CfTimeUnits::parse(units)?;
164 check_calendar(a.attributes())?;
165 let (masked, _) = decode_cf(a)?;
166
167 let numbers = arrow_cast(masked.values().as_ref(), &DataType::Float64)?;
168 let numbers = numbers.as_primitive::<Float64Type>();
169 let stamps: TimestampMicrosecondArray = numbers
170 .iter()
171 .map(|v| {
172 v.map(|v| parsed.reference_micros + (v * parsed.step_micros as f64).round() as i64)
173 })
174 .collect();
175
176 let mut kept = Attributes::new();
177 let mut moved = Attributes::new();
178 for (key, value) in masked.attributes().iter() {
179 let target = if key == UNITS || key == CALENDAR {
180 &mut moved
181 } else {
182 &mut kept
183 };
184 target.try_insert_array(key, value.clone())?;
185 }
186 Ok((
187 NdArrowArray::try_new(Arc::new(stamps), a.dims().clone())?.with_attributes(kept),
188 moved,
189 ))
190}
191
192pub fn encode_cf_time(a: &NdArrowArray, units: &str) -> Result<NdArrowArray> {
195 let parsed = CfTimeUnits::parse(units)?;
196 let stamps = to_micros(a)?;
197 let numbers: Float64Array = stamps
198 .iter()
199 .map(|v| v.map(|v| (v - parsed.reference_micros) as f64 / parsed.step_micros as f64))
200 .collect();
201 let attrs = a.attributes().clone().with(UNITS, units);
202 Ok(NdArrowArray::try_new(Arc::new(numbers), a.dims().clone())?.with_attributes(attrs))
203}
204
205pub(crate) fn to_micros(a: &NdArrowArray) -> Result<TimestampMicrosecondArray> {
206 if !matches!(
207 a.data_type(),
208 DataType::Timestamp(_, _) | DataType::Date32 | DataType::Date64
209 ) {
210 return Err(nd_err!(
211 InvalidArgument,
212 "expected timestamps, got {}",
213 a.data_type()
214 ));
215 }
216 let cast = arrow_cast(a.values().as_ref(), ×tamp_type())?;
217 Ok(cast.as_primitive::<TimestampMicrosecondType>().clone())
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum TimePeriod {
223 Year,
225 Month,
227 Day,
229 Hour,
231 Minute,
233 Second,
235}
236
237pub fn floor_time(a: &NdArrowArray, period: TimePeriod) -> Result<NdArrowArray> {
240 let stamps = to_micros(a)?;
241 let fixed = |step: i64| move |v: i64| v.div_euclid(step) * step;
242 let out: TimestampMicrosecondArray = match period {
243 TimePeriod::Second => stamps.unary(fixed(MICROS_PER_SECOND)),
244 TimePeriod::Minute => stamps.unary(fixed(60 * MICROS_PER_SECOND)),
245 TimePeriod::Hour => stamps.unary(fixed(3_600 * MICROS_PER_SECOND)),
246 TimePeriod::Day => stamps.unary(fixed(MICROS_PER_DAY)),
247 TimePeriod::Month | TimePeriod::Year => stamps.unary(|v| {
248 let (year, month, _) = civil_from_days(v.div_euclid(MICROS_PER_DAY));
249 let month = if period == TimePeriod::Year { 1 } else { month };
250 days_from_civil(year, month, 1) * MICROS_PER_DAY
251 }),
252 };
253 Ok(NdArrowArray::try_new(Arc::new(out), a.dims().clone())?
254 .with_attributes(a.attributes().clone()))
255}
256
257pub fn time_component(a: &NdArrowArray, part: DatePart) -> Result<NdArrowArray> {
259 let stamps: ArrayRef = Arc::new(to_micros(a)?);
260 let out = date_part(stamps.as_ref(), part)?;
261 NdArrowArray::try_new(out, a.dims().clone())
262}
263
264#[cfg(test)]
265mod tests {
266 use arrow::array::{Float32Array, Int32Array};
267 use arrow::datatypes::Int32Type;
268
269 use super::*;
270 use crate::Dimensions;
271 use crate::compute::cf::FILL_VALUE;
272
273 fn micros(year: i64, month: u32, day: u32, hour: i64) -> i64 {
274 days_from_civil(year, month, day) * MICROS_PER_DAY + hour * 3_600 * MICROS_PER_SECOND
275 }
276
277 #[test]
278 fn civil_conversion_round_trips() {
279 assert_eq!(days_from_civil(1970, 1, 1), 0);
280 assert_eq!(days_from_civil(2000, 3, 1), 11_017);
281 for days in [-1_000_000, -719_468, -1, 0, 59, 60, 11_016, 1_000_000] {
282 let (y, m, d) = civil_from_days(days);
283 assert_eq!(days_from_civil(y, m, d), days);
284 }
285 }
286
287 #[test]
288 fn parses_cf_units() {
289 let u = CfTimeUnits::parse("days since 1950-01-01").unwrap();
290 assert_eq!(u.step_micros, MICROS_PER_DAY);
291 assert_eq!(u.reference_micros, micros(1950, 1, 1, 0));
292 let h = CfTimeUnits::parse("hours since 2000-1-1 06:30:00.5 +01:00").unwrap();
293 assert_eq!(
294 h.reference_micros,
295 micros(2000, 1, 1, 5) + 30 * 60 * MICROS_PER_SECOND + 500_000
296 );
297 assert!(CfTimeUnits::parse("days after 1950-01-01").is_err());
298 assert!(CfTimeUnits::parse("fortnights since 1950-01-01").is_err());
299 assert!(CfTimeUnits::parse("seconds since 1970-01-01T00:00:00Z").is_ok());
300 }
301
302 #[test]
303 fn decode_and_encode_round_trip() {
304 let a = NdArrowArray::try_new(
305 Arc::new(Float32Array::from(vec![0.0, 1.5, -999.0])),
306 Dimensions::try_new([("time", 3)]).unwrap(),
307 )
308 .unwrap()
309 .with_attributes(
310 Attributes::new()
311 .with(UNITS, "days since 1950-01-01")
312 .with(CALENDAR, "standard")
313 .with(FILL_VALUE, -999.0f32)
314 .with("long_name", "time"),
315 );
316 let (decoded, encoding) = decode_cf_time(&a).unwrap();
317 assert_eq!(decoded.data_type(), ×tamp_type());
318 let v: Vec<Option<i64>> = decoded
319 .values()
320 .as_primitive::<TimestampMicrosecondType>()
321 .iter()
322 .collect();
323 assert_eq!(
324 v,
325 vec![
326 Some(micros(1950, 1, 1, 0)),
327 Some(micros(1950, 1, 2, 12)),
328 None
329 ]
330 );
331 assert_eq!(decoded.attributes().get_str("long_name"), Some("time"));
332 assert_eq!(encoding.get_str(UNITS), Some("days since 1950-01-01"));
333
334 let encoded = encode_cf_time(&decoded, "days since 1950-01-01").unwrap();
335 let back: Vec<Option<f64>> = encoded
336 .values()
337 .as_primitive::<Float64Type>()
338 .iter()
339 .collect();
340 assert_eq!(back, vec![Some(0.0), Some(1.5), None]);
341 }
342
343 #[test]
344 fn rejects_other_calendars() {
345 let a = NdArrowArray::try_new(
346 Arc::new(Int32Array::from(vec![0])),
347 Dimensions::try_new([("time", 1)]).unwrap(),
348 )
349 .unwrap()
350 .with_attributes(
351 Attributes::new()
352 .with(UNITS, "days since 2000-01-01")
353 .with(CALENDAR, "noleap"),
354 );
355 assert!(decode_cf_time(&a).is_err());
356 }
357
358 #[test]
359 fn floor_and_components() {
360 let stamps = TimestampMicrosecondArray::from(vec![
361 micros(2021, 3, 15, 13),
362 micros(1969, 12, 31, 23),
363 ]);
364 let a = NdArrowArray::try_new(
365 Arc::new(stamps),
366 Dimensions::try_new([("time", 2)]).unwrap(),
367 )
368 .unwrap();
369 let floor = |p| -> Vec<i64> {
370 floor_time(&a, p)
371 .unwrap()
372 .values()
373 .as_primitive::<TimestampMicrosecondType>()
374 .values()
375 .to_vec()
376 };
377 assert_eq!(
378 floor(TimePeriod::Month),
379 vec![micros(2021, 3, 1, 0), micros(1969, 12, 1, 0)]
380 );
381 assert_eq!(
382 floor(TimePeriod::Year),
383 vec![micros(2021, 1, 1, 0), micros(1969, 1, 1, 0)]
384 );
385 assert_eq!(
386 floor(TimePeriod::Day),
387 vec![micros(2021, 3, 15, 0), micros(1969, 12, 31, 0)]
388 );
389 let month = time_component(&a, DatePart::Month).unwrap();
390 assert_eq!(
391 month.values().as_primitive::<Int32Type>().values(),
392 &[3, 12]
393 );
394 }
395}