1use std::str::FromStr;
12use std::sync::Arc;
13
14use arrow::array::{TimestampMicrosecondArray, new_null_array};
15
16use crate::NdArrowArray;
17use crate::compute::reduce::{Reduction, reduce};
18use crate::compute::shape::{Selector, Slice, concat, expand_dims, isel};
19use crate::compute::time::{
20 MICROS_PER_DAY, MICROS_PER_SECOND, TimePeriod, civil_from_days, days_from_civil, to_micros,
21};
22use crate::error::{Result, nd_err};
23
24pub const MAX_BINS: usize = u32::MAX as usize;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct Frequency {
30 step: u32,
31 period: TimePeriod,
32}
33
34impl Frequency {
35 pub fn new(step: u32, period: TimePeriod) -> Result<Self> {
37 if step == 0 {
38 return Err(nd_err!(
39 InvalidArgument,
40 "frequency step must be at least 1"
41 ));
42 }
43 Ok(Self { step, period })
44 }
45
46 pub fn parse(text: &str) -> Result<Self> {
51 let text = text.trim();
52 let split = text
53 .find(|c: char| !c.is_ascii_digit())
54 .unwrap_or(text.len());
55 let (digits, alias) = text.split_at(split);
56 let step = match digits {
57 "" => 1,
58 _ => digits
59 .parse()
60 .map_err(|_| nd_err!(InvalidArgument, "invalid frequency step in '{text}'"))?,
61 };
62 let period = match alias {
63 "YS" => TimePeriod::Year,
64 "MS" => TimePeriod::Month,
65 "D" => TimePeriod::Day,
66 "h" => TimePeriod::Hour,
67 "min" => TimePeriod::Minute,
68 "s" => TimePeriod::Second,
69 _ => {
70 return Err(nd_err!(
71 InvalidArgument,
72 "unsupported frequency '{text}': use YS, MS, D, h, min or s"
73 ));
74 }
75 };
76 Self::new(step, period)
77 }
78
79 pub fn step(&self) -> u32 {
81 self.step
82 }
83
84 pub fn period(&self) -> TimePeriod {
86 self.period
87 }
88}
89
90impl FromStr for Frequency {
91 type Err = crate::NdError;
92
93 fn from_str(text: &str) -> Result<Self> {
94 Self::parse(text)
95 }
96}
97
98pub fn resample(
107 a: &NdArrowArray,
108 dim: &str,
109 times: &NdArrowArray,
110 freq: Frequency,
111 op: Reduction,
112) -> Result<(NdArrowArray, NdArrowArray)> {
113 let axis = a.dims().axis(dim)?;
114 let n = a.dims().as_slice()[axis].size();
115 if times.rank() != 1 || times.len() != n {
116 return Err(nd_err!(
117 InvalidArgument,
118 "resample times must be 1-D with {n} values along '{dim}'"
119 ));
120 }
121 let stamps = to_micros(times)?;
122 let (bin_of, labels) = assign_bins(&stamps, freq)?;
123
124 let mut offsets = vec![0usize; labels.len() + 1];
126 for bin in bin_of.iter().flatten() {
127 offsets[bin + 1] += 1;
128 }
129 for b in 0..labels.len() {
130 offsets[b + 1] += offsets[b];
131 }
132 let mut cursor = offsets.clone();
133 let mut positions = vec![0isize; offsets[labels.len()]];
134 for (position, bin) in bin_of.iter().enumerate() {
135 if let Some(bin) = bin {
136 positions[cursor[*bin]] = position as isize;
137 cursor[*bin] += 1;
138 }
139 }
140
141 let mut parts: Vec<Option<NdArrowArray>> = Vec::with_capacity(labels.len());
142 for b in 0..labels.len() {
143 let members = &positions[offsets[b]..offsets[b + 1]];
144 let (Some(&first), Some(&last)) = (members.first(), members.last()) else {
145 parts.push(None);
146 continue;
147 };
148 let selector = if (last - first) as usize + 1 == members.len() {
149 Selector::Slice(Slice::range(first, last + 1))
150 } else {
151 Selector::Take(members.to_vec())
152 };
153 let group = isel(a, &[(dim, selector)])?;
154 let reduced = reduce(&group, Some(&[dim]), op)?;
155 parts.push(Some(expand_dims(&reduced, dim, axis)?));
156 }
157
158 let template = parts
159 .iter()
160 .flatten()
161 .next()
162 .ok_or_else(|| nd_err!(InvalidArgument, "resample times are all null"))?;
163 let empty = NdArrowArray::try_new(
164 new_null_array(template.data_type(), template.len()),
165 template.dims().clone(),
166 )?;
167 let parts: Vec<&NdArrowArray> = parts.iter().map(|p| p.as_ref().unwrap_or(&empty)).collect();
168 let result = concat(&parts, dim)?;
169
170 let labels = NdArrowArray::try_new(
171 Arc::new(TimestampMicrosecondArray::from(labels)),
172 crate::Dimensions::try_new([(dim, parts.len())])?,
173 )?
174 .with_attributes(times.attributes().clone());
175 Ok((result, labels))
176}
177
178fn assign_bins(
180 stamps: &TimestampMicrosecondArray,
181 freq: Frequency,
182) -> Result<(Vec<Option<usize>>, Vec<i64>)> {
183 let (Some(min), Some(max)) = (arrow::compute::min(stamps), arrow::compute::max(stamps)) else {
184 return Err(nd_err!(InvalidArgument, "resample times are all null"));
185 };
186 let step = i128::from(freq.step);
187 let unit = match freq.period {
188 TimePeriod::Second => Some(MICROS_PER_SECOND),
189 TimePeriod::Minute => Some(60 * MICROS_PER_SECOND),
190 TimePeriod::Hour => Some(3_600 * MICROS_PER_SECOND),
191 TimePeriod::Day => Some(MICROS_PER_DAY),
192 TimePeriod::Month | TimePeriod::Year => None,
193 };
194 let scheme = match unit {
195 Some(unit) => Bins::Fixed {
196 origin: i128::from(min.div_euclid(MICROS_PER_DAY) * MICROS_PER_DAY),
197 width: step * i128::from(unit),
198 },
199 None => {
200 let first = month_of(min);
201 let year = freq.period == TimePeriod::Year;
202 Bins::Calendar {
203 base: if year {
204 first - first.rem_euclid(12)
205 } else {
206 first
207 },
208 months: if year { 12 * step } else { step },
209 }
210 }
211 };
212 let index = |t: i64| scheme.index(t);
213 let label = |k: i128| scheme.label(k);
214
215 let first = index(min);
217 let count = usize::try_from(index(max) - first + 1)
218 .ok()
219 .filter(|&c| c <= MAX_BINS)
220 .ok_or_else(|| nd_err!(InvalidArgument, "resample needs more than {MAX_BINS} bins"))?;
221 let bins = stamps
222 .iter()
223 .map(|t| t.map(|t| (index(t) - first) as usize))
224 .collect();
225 let labels = (0..count as i128).map(|k| label(first + k)).collect();
226 Ok((bins, labels))
227}
228
229enum Bins {
231 Fixed { origin: i128, width: i128 },
232 Calendar { base: i128, months: i128 },
233}
234
235impl Bins {
236 fn index(&self, t: i64) -> i128 {
238 match *self {
239 Bins::Fixed { origin, width } => (i128::from(t) - origin) / width,
240 Bins::Calendar { base, months } => (month_of(t) - base) / months,
241 }
242 }
243
244 fn label(&self, k: i128) -> i64 {
246 match *self {
247 Bins::Fixed { origin, width } => (origin + k * width) as i64,
248 Bins::Calendar { base, months } => {
249 let month = base + k * months;
250 let (year, month) = (month.div_euclid(12), month.rem_euclid(12) as u32 + 1);
251 days_from_civil(year as i64, month, 1) * MICROS_PER_DAY
252 }
253 }
254 }
255}
256
257fn month_of(t: i64) -> i128 {
259 let (year, month, _) = civil_from_days(t.div_euclid(MICROS_PER_DAY));
260 i128::from(year) * 12 + i128::from(month) - 1
261}
262
263#[cfg(test)]
264mod tests {
265 use arrow::array::{AsArray, Float64Array, Int32Array};
266 use arrow::datatypes::{Float64Type, Int64Type, TimestampMicrosecondType};
267
268 use super::*;
269 use crate::{Attributes, Dimensions};
270
271 fn at(year: i64, month: u32, day: u32, hour: i64) -> i64 {
272 days_from_civil(year, month, day) * MICROS_PER_DAY + hour * 3_600 * MICROS_PER_SECOND
273 }
274
275 fn time_axis(stamps: Vec<Option<i64>>) -> NdArrowArray {
276 let n = stamps.len();
277 NdArrowArray::try_new(
278 Arc::new(TimestampMicrosecondArray::from(stamps)),
279 Dimensions::try_new([("time", n)]).unwrap(),
280 )
281 .unwrap()
282 }
283
284 fn f64s(a: &NdArrowArray) -> Vec<Option<f64>> {
285 a.values().as_primitive::<Float64Type>().iter().collect()
286 }
287
288 fn stamps(a: &NdArrowArray) -> Vec<i64> {
289 a.values()
290 .as_primitive::<TimestampMicrosecondType>()
291 .values()
292 .to_vec()
293 }
294
295 fn hourly() -> (NdArrowArray, NdArrowArray) {
297 let times = time_axis(vec![
298 Some(at(2021, 1, 1, 1)),
299 Some(at(2021, 1, 1, 2)),
300 Some(at(2021, 1, 1, 5)),
301 Some(at(2021, 1, 3, 23)),
302 Some(at(2021, 1, 4, 0)),
303 ]);
304 let values = NdArrowArray::try_new(
305 Arc::new(Float64Array::from(vec![
306 Some(1.0),
307 None,
308 Some(2.0),
309 Some(4.0),
310 Some(8.0),
311 ])),
312 times.dims().clone(),
313 )
314 .unwrap();
315 (values, times)
316 }
317
318 #[test]
319 fn daily_bins_match_xarray() {
320 let (values, times) = hourly();
321 let freq = Frequency::parse("1D").unwrap();
322 let (sum, labels) = resample(&values, "time", ×, freq, Reduction::Sum).unwrap();
323 assert_eq!(f64s(&sum), vec![Some(3.0), None, Some(4.0), Some(8.0)]);
324 assert_eq!(
325 stamps(&labels),
326 vec![
327 at(2021, 1, 1, 0),
328 at(2021, 1, 2, 0),
329 at(2021, 1, 3, 0),
330 at(2021, 1, 4, 0)
331 ]
332 );
333 let (mean, _) = resample(&values, "time", ×, freq, Reduction::Mean).unwrap();
334 assert_eq!(f64s(&mean), vec![Some(1.5), None, Some(4.0), Some(8.0)]);
335 let (count, _) = resample(&values, "time", ×, freq, Reduction::Count).unwrap();
336 let count: Vec<Option<i64>> = count.values().as_primitive::<Int64Type>().iter().collect();
337 assert_eq!(count, vec![Some(2), None, Some(1), Some(1)]);
338
339 let two_days = Frequency::parse("2D").unwrap();
340 let (sum, _) = resample(&values, "time", ×, two_days, Reduction::Sum).unwrap();
341 assert_eq!(f64s(&sum), vec![Some(3.0), Some(12.0)]);
342 }
343
344 #[test]
345 fn fixed_bins_start_at_midnight_of_the_first_day() {
346 let times = time_axis(vec![Some(at(1969, 12, 31, 23)), Some(at(1970, 1, 1, 1))]);
347 let values = NdArrowArray::try_new(
348 Arc::new(Float64Array::from(vec![1.0, 2.0])),
349 times.dims().clone(),
350 )
351 .unwrap();
352 let freq = Frequency::parse("7h").unwrap();
353 let (sum, labels) = resample(&values, "time", ×, freq, Reduction::Sum).unwrap();
354 assert_eq!(f64s(&sum), vec![Some(3.0)]);
355 assert_eq!(stamps(&labels), vec![at(1969, 12, 31, 21)]);
356 }
357
358 #[test]
359 fn calendar_bins_match_xarray() {
360 let times = time_axis(vec![
361 Some(at(2020, 11, 15, 0)),
362 Some(at(2021, 2, 10, 0)),
363 Some(at(2021, 3, 1, 0)),
364 Some(at(2022, 7, 4, 0)),
365 ]);
366 let values = NdArrowArray::try_new(
367 Arc::new(Float64Array::from(vec![1.0, 2.0, 3.0, 4.0])),
368 times.dims().clone(),
369 )
370 .unwrap();
371 let sum = |freq: &str| {
372 let freq = Frequency::parse(freq).unwrap();
373 let (sum, labels) = resample(&values, "time", ×, freq, Reduction::Sum).unwrap();
374 (f64s(&sum), stamps(&labels))
375 };
376 let (v, l) = sum("3MS");
377 assert_eq!(
378 v,
379 vec![Some(1.0), Some(5.0), None, None, None, None, Some(4.0)]
380 );
381 assert_eq!(l[0], at(2020, 11, 1, 0));
382 assert_eq!(l[1], at(2021, 2, 1, 0));
383 let (v, l) = sum("YS");
384 assert_eq!(v, vec![Some(1.0), Some(5.0), Some(4.0)]);
385 assert_eq!(l[2], at(2022, 1, 1, 0));
386 let (v, l) = sum("2YS");
387 assert_eq!(v, vec![Some(6.0), Some(4.0)]);
388 assert_eq!(l, vec![at(2020, 1, 1, 0), at(2022, 1, 1, 0)]);
389 }
390
391 #[test]
392 fn unsorted_and_null_times_on_a_grid() {
393 let times = time_axis(vec![Some(at(2021, 1, 2, 6)), None, Some(at(2021, 1, 1, 6))])
394 .with_attributes(Attributes::new().with("long_name", "time"));
395 let values = NdArrowArray::try_new(
396 Arc::new(Int32Array::from_iter_values(0..6)),
397 Dimensions::try_new([("y", 2), ("time", 3)]).unwrap(),
398 )
399 .unwrap();
400 let freq = Frequency::new(1, TimePeriod::Day).unwrap();
401 let (sum, labels) = resample(&values, "time", ×, freq, Reduction::Sum).unwrap();
402 assert_eq!(sum.dims().names(), vec!["y", "time"]);
403 assert_eq!(
404 sum.values().as_primitive::<Int64Type>().values(),
405 &[2, 0, 5, 3]
406 );
407 assert_eq!(labels.attributes().get_str("long_name"), Some("time"));
408 }
409
410 #[test]
411 fn invalid_input_rejected() {
412 assert!(Frequency::parse("M").is_err());
413 assert!(Frequency::parse("0D").is_err());
414 assert_eq!(
415 "15min".parse::<Frequency>().unwrap(),
416 Frequency::new(15, TimePeriod::Minute).unwrap()
417 );
418 let times = time_axis(vec![None, None]);
419 let values = NdArrowArray::try_new(
420 Arc::new(Float64Array::from(vec![1.0, 2.0])),
421 times.dims().clone(),
422 )
423 .unwrap();
424 let freq = Frequency::parse("D").unwrap();
425 assert!(resample(&values, "time", ×, freq, Reduction::Sum).is_err());
426 let seconds = time_axis(vec![Some(i64::MIN / 2), Some(i64::MAX / 2)]);
427 let freq = Frequency::parse("s").unwrap();
428 assert!(resample(&values, "time", &seconds, freq, Reduction::Sum).is_err());
429 }
430}