From c946e8fdfdafac5ed484c006dde6e1d3f51c69f8 Mon Sep 17 00:00:00 2001 From: Weijie Guo Date: Sun, 24 Sep 2023 19:54:54 +0800 Subject: [PATCH] feat: clip supports expr arguments and physical numeric dtype --- .../src/chunked_array/ops/arity.rs | 51 ++++++ crates/polars-core/src/series/ops/round.rs | 64 -------- crates/polars-ops/src/series/ops/clip.rs | 151 ++++++++++++++++++ crates/polars-ops/src/series/ops/mod.rs | 2 + .../polars-plan/src/dsl/function_expr/clip.rs | 14 +- .../polars-plan/src/dsl/function_expr/mod.rs | 16 +- crates/polars-plan/src/dsl/mod.rs | 42 +++-- py-polars/polars/expr/expr.py | 12 +- py-polars/polars/series/series.py | 6 +- py-polars/src/expr/general.rs | 16 +- py-polars/tests/unit/dataframe/test_df.py | 94 ++++++++++- 11 files changed, 350 insertions(+), 118 deletions(-) create mode 100644 crates/polars-ops/src/series/ops/clip.rs diff --git a/crates/polars-core/src/chunked_array/ops/arity.rs b/crates/polars-core/src/chunked_array/ops/arity.rs index 805d8df742ea..8c14a17ec567 100644 --- a/crates/polars-core/src/chunked_array/ops/arity.rs +++ b/crates/polars-core/src/chunked_array/ops/arity.rs @@ -7,6 +7,16 @@ use crate::datatypes::{ArrayCollectIterExt, ArrayFromIter, StaticArray}; use crate::prelude::{ChunkedArray, PolarsDataType}; use crate::utils::{align_chunks_binary, align_chunks_ternary}; +// We need this helper because for<'a> notation can't yet be applied properly +// on the return type. +pub trait TernaryFnMut: FnMut(A1, A2, A3) -> Self::Ret { + type Ret; +} + +impl R> TernaryFnMut for T { + type Ret = R; +} + // We need this helper because for<'a> notation can't yet be applied properly // on the return type. pub trait BinaryFnMut: FnMut(A1, A2) -> Self::Ret { @@ -334,3 +344,44 @@ where }); ChunkedArray::try_from_chunk_iter(ca1.name(), iter) } + +#[inline] +pub fn ternary_elementwise( + ca1: &ChunkedArray, + ca2: &ChunkedArray, + ca3: &ChunkedArray, + mut op: F, +) -> ChunkedArray +where + T: PolarsDataType, + U: PolarsDataType, + G: PolarsDataType, + V: PolarsDataType, + F: for<'a> TernaryFnMut< + Option>, + Option>, + Option>, + >, + V::Array: for<'a> ArrayFromIter< + >, + Option>, + Option>, + >>::Ret, + >, +{ + let (ca1, ca2, ca3) = align_chunks_ternary(ca1, ca2, ca3); + let iter = ca1 + .downcast_iter() + .zip(ca2.downcast_iter()) + .zip(ca3.downcast_iter()) + .map(|((ca1_arr, ca2_arr), ca3_arr)| { + let element_iter = ca1_arr.iter().zip(ca2_arr.iter()).zip(ca3_arr.iter()).map( + |((ca1_opt_val, ca2_opt_val), ca3_opt_val)| { + op(ca1_opt_val, ca2_opt_val, ca3_opt_val) + }, + ); + element_iter.collect_arr() + }); + ChunkedArray::from_chunk_iter(ca1.name(), iter) +} diff --git a/crates/polars-core/src/series/ops/round.rs b/crates/polars-core/src/series/ops/round.rs index edcd3f31cbca..37abe7797941 100644 --- a/crates/polars-core/src/series/ops/round.rs +++ b/crates/polars-core/src/series/ops/round.rs @@ -1,5 +1,4 @@ use num_traits::pow::Pow; -use num_traits::{clamp_max, clamp_min}; use crate::prelude::*; @@ -60,67 +59,4 @@ impl Series { } polars_bail!(opq = ceil, self.dtype()); } - - /// Clamp underlying values to the `min` and `max` values. - pub fn clip(mut self, min: AnyValue<'_>, max: AnyValue<'_>) -> PolarsResult { - if self.dtype().is_numeric() { - macro_rules! apply_clip { - ($pl_type:ty, $ca:expr) => {{ - let min = min - .extract::<<$pl_type as PolarsNumericType>::Native>() - .unwrap(); - let max = max - .extract::<<$pl_type as PolarsNumericType>::Native>() - .unwrap(); - - $ca.apply_mut(|val| val.clamp(min, max)); - }}; - } - let mutable = self._get_inner_mut(); - downcast_as_macro_arg_physical_mut!(mutable, apply_clip); - Ok(self) - } else { - polars_bail!(opq = clip, self.dtype()); - } - } - - /// Clamp underlying values to the `max` value. - pub fn clip_max(mut self, max: AnyValue<'_>) -> PolarsResult { - if self.dtype().is_numeric() { - macro_rules! apply_clip { - ($pl_type:ty, $ca:expr) => {{ - let max = max - .extract::<<$pl_type as PolarsNumericType>::Native>() - .unwrap(); - - $ca.apply_mut(|val| clamp_max(val, max)); - }}; - } - let mutable = self._get_inner_mut(); - downcast_as_macro_arg_physical_mut!(mutable, apply_clip); - Ok(self) - } else { - polars_bail!(opq = clip_max, self.dtype()); - } - } - - /// Clamp underlying values to the `min` value. - pub fn clip_min(mut self, min: AnyValue<'_>) -> PolarsResult { - if self.dtype().is_numeric() { - macro_rules! apply_clip { - ($pl_type:ty, $ca:expr) => {{ - let min = min - .extract::<<$pl_type as PolarsNumericType>::Native>() - .unwrap(); - - $ca.apply_mut(|val| clamp_min(val, min)); - }}; - } - let mutable = self._get_inner_mut(); - downcast_as_macro_arg_physical_mut!(mutable, apply_clip); - Ok(self) - } else { - polars_bail!(opq = clip_min, self.dtype()); - } - } } diff --git a/crates/polars-ops/src/series/ops/clip.rs b/crates/polars-ops/src/series/ops/clip.rs new file mode 100644 index 000000000000..170e7961d6a2 --- /dev/null +++ b/crates/polars-ops/src/series/ops/clip.rs @@ -0,0 +1,151 @@ +use num_traits::{clamp, clamp_max, clamp_min}; +use polars_core::prelude::arity::{binary_elementwise, ternary_elementwise}; +use polars_core::prelude::*; +use polars_core::with_match_physical_numeric_polars_type; + +fn clip_helper( + ca: &ChunkedArray, + min: &ChunkedArray, + max: &ChunkedArray, +) -> ChunkedArray +where + T: PolarsNumericType, + T::Native: PartialOrd, +{ + match (min.len(), max.len()) { + (1, 1) => match (min.get(0), max.get(0)) { + (Some(min), Some(max)) => { + ca.apply_generic(|s| s.map(|s| num_traits::clamp(s, min, max))) + }, + _ => ChunkedArray::::full_null(ca.name(), ca.len()), + }, + (1, _) => match min.get(0) { + Some(min) => binary_elementwise(ca, max, |opt_s, opt_max| match (opt_s, opt_max) { + (Some(s), Some(max)) => Some(clamp(s, min, max)), + _ => None, + }), + _ => ChunkedArray::::full_null(ca.name(), ca.len()), + }, + (_, 1) => match max.get(0) { + Some(max) => binary_elementwise(ca, min, |opt_s, opt_min| match (opt_s, opt_min) { + (Some(s), Some(min)) => Some(clamp(s, min, max)), + _ => None, + }), + _ => ChunkedArray::::full_null(ca.name(), ca.len()), + }, + _ => ternary_elementwise(ca, min, max, |opt_s, opt_min, opt_max| { + match (opt_s, opt_min, opt_max) { + (Some(s), Some(min), Some(max)) => Some(clamp(s, min, max)), + _ => None, + } + }), + } +} + +fn clip_min_max_helper( + ca: &ChunkedArray, + bound: &ChunkedArray, + op: F, +) -> ChunkedArray +where + T: PolarsNumericType, + T::Native: PartialOrd, + F: Fn(T::Native, T::Native) -> T::Native, +{ + match bound.len() { + 1 => match bound.get(0) { + Some(bound) => ca.apply_generic(|s| s.map(|s| op(s, bound))), + _ => ChunkedArray::::full_null(ca.name(), ca.len()), + }, + _ => binary_elementwise(ca, bound, |opt_s, opt_bound| match (opt_s, opt_bound) { + (Some(s), Some(bound)) => Some(op(s, bound)), + _ => None, + }), + } +} + +/// Clamp underlying values to the `min` and `max` values. +pub fn clip(s: &Series, min: &Series, max: &Series) -> PolarsResult { + polars_ensure!(s.dtype().to_physical().is_numeric(), InvalidOperation: "Only physical numeric types are supported."); + + let original_type = s.dtype(); + // cast min & max to the dtype of s first. + let (min, max) = (min.cast(s.dtype())?, max.cast(s.dtype())?); + + let (s, min, max) = ( + s.to_physical_repr(), + min.to_physical_repr(), + max.to_physical_repr(), + ); + + match s.dtype() { + dt if dt.is_numeric() => { + with_match_physical_numeric_polars_type!(s.dtype(), |$T| { + let ca: &ChunkedArray<$T> = s.as_ref().as_ref().as_ref(); + let min: &ChunkedArray<$T> = min.as_ref().as_ref().as_ref(); + let max: &ChunkedArray<$T> = max.as_ref().as_ref().as_ref(); + let out = clip_helper(ca, min, max).into_series(); + if original_type.is_logical(){ + out.cast(original_type) + }else{ + Ok(out) + } + }) + }, + dt => polars_bail!(opq = clippy, dt), + } +} + +/// Clamp underlying values to the `max` value. +pub fn clip_max(s: &Series, max: &Series) -> PolarsResult { + polars_ensure!(s.dtype().to_physical().is_numeric(), InvalidOperation: "Only physical numeric types are supported."); + + let original_type = s.dtype(); + // cast max to the dtype of s first. + let max = max.cast(s.dtype())?; + + let (s, max) = (s.to_physical_repr(), max.to_physical_repr()); + + match s.dtype() { + dt if dt.is_numeric() => { + with_match_physical_numeric_polars_type!(s.dtype(), |$T| { + let ca: &ChunkedArray<$T> = s.as_ref().as_ref().as_ref(); + let max: &ChunkedArray<$T> = max.as_ref().as_ref().as_ref(); + let out = clip_min_max_helper(ca, max, clamp_max).into_series(); + if original_type.is_logical(){ + out.cast(original_type) + }else{ + Ok(out) + } + }) + }, + dt => polars_bail!(opq = clippy_max, dt), + } +} + +/// Clamp underlying values to the `min` value. +pub fn clip_min(s: &Series, min: &Series) -> PolarsResult { + polars_ensure!(s.dtype().to_physical().is_numeric(), InvalidOperation: "Only physical numeric types are supported."); + + let original_type = s.dtype(); + // cast min to the dtype of s first. + let min = min.cast(s.dtype())?; + + let (s, min) = (s.to_physical_repr(), min.to_physical_repr()); + + match s.dtype() { + dt if dt.is_numeric() => { + with_match_physical_numeric_polars_type!(s.dtype(), |$T| { + let ca: &ChunkedArray<$T> = s.as_ref().as_ref().as_ref(); + let min: &ChunkedArray<$T> = min.as_ref().as_ref().as_ref(); + let out = clip_min_max_helper(ca, min, clamp_min).into_series(); + if original_type.is_logical(){ + out.cast(original_type) + }else{ + Ok(out) + } + }) + }, + dt => polars_bail!(opq = clippy_min, dt), + } +} diff --git a/crates/polars-ops/src/series/ops/mod.rs b/crates/polars-ops/src/series/ops/mod.rs index 0d6ba4ce4b55..6805d4d346ee 100644 --- a/crates/polars-ops/src/series/ops/mod.rs +++ b/crates/polars-ops/src/series/ops/mod.rs @@ -2,6 +2,7 @@ mod approx_algo; #[cfg(feature = "approx_unique")] mod approx_unique; mod arg_min_max; +mod clip; #[cfg(feature = "cutqcut")] mod cut; #[cfg(feature = "round_series")] @@ -34,6 +35,7 @@ pub use approx_algo::*; #[cfg(feature = "approx_unique")] pub use approx_unique::*; pub use arg_min_max::ArgAgg; +pub use clip::*; #[cfg(feature = "cutqcut")] pub use cut::*; #[cfg(feature = "round_series")] diff --git a/crates/polars-plan/src/dsl/function_expr/clip.rs b/crates/polars-plan/src/dsl/function_expr/clip.rs index 97ebaf326813..2f643857e1a2 100644 --- a/crates/polars-plan/src/dsl/function_expr/clip.rs +++ b/crates/polars-plan/src/dsl/function_expr/clip.rs @@ -1,14 +1,10 @@ use super::*; -pub(super) fn clip( - s: Series, - min: Option>, - max: Option>, -) -> PolarsResult { - match (min, max) { - (Some(min), Some(max)) => s.clip(min, max), - (Some(min), None) => s.clip_min(min), - (None, Some(max)) => s.clip_max(max), +pub(super) fn clip(s: &[Series], has_min: bool, has_max: bool) -> PolarsResult { + match (has_min, has_max) { + (true, true) => polars_ops::prelude::clip(&s[0], &s[1], &s[2]), + (true, false) => polars_ops::prelude::clip_min(&s[0], &s[1]), + (false, true) => polars_ops::prelude::clip_max(&s[0], &s[1]), _ => unreachable!(), } } diff --git a/crates/polars-plan/src/dsl/function_expr/mod.rs b/crates/polars-plan/src/dsl/function_expr/mod.rs index a5e9a789c7ce..f6b668442b16 100644 --- a/crates/polars-plan/src/dsl/function_expr/mod.rs +++ b/crates/polars-plan/src/dsl/function_expr/mod.rs @@ -135,8 +135,8 @@ pub enum FunctionExpr { DropNans, #[cfg(feature = "round_series")] Clip { - min: Option>, - max: Option>, + has_min: bool, + has_max: bool, }, ListExpr(ListFunction), #[cfg(feature = "dtype-array")] @@ -321,10 +321,10 @@ impl Display for FunctionExpr { ShiftAndFill { .. } => "shift_and_fill", DropNans => "drop_nans", #[cfg(feature = "round_series")] - Clip { min, max } => match (min, max) { - (Some(_), Some(_)) => "clip", - (None, Some(_)) => "clip_max", - (Some(_), None) => "clip_min", + Clip { has_min, has_max } => match (has_min, has_max) { + (true, true) => "clip", + (false, true) => "clip_max", + (true, false) => "clip_min", _ => unreachable!(), }, ListExpr(func) => return write!(f, "{func}"), @@ -543,8 +543,8 @@ impl From for SpecialEq> { }, DropNans => map_owned!(nan::drop_nans), #[cfg(feature = "round_series")] - Clip { min, max } => { - map_owned!(clip::clip, min.clone(), max.clone()) + Clip { has_min, has_max } => { + map_as_slice!(clip::clip, has_min, has_max) }, ListExpr(lf) => { use ListFunction::*; diff --git a/crates/polars-plan/src/dsl/mod.rs b/crates/polars-plan/src/dsl/mod.rs index 955409be0baf..4bd4abf2a0a6 100644 --- a/crates/polars-plan/src/dsl/mod.rs +++ b/crates/polars-plan/src/dsl/mod.rs @@ -838,29 +838,41 @@ impl Expr { /// Clip underlying values to a set boundary. #[cfg(feature = "round_series")] - pub fn clip(self, min: AnyValue<'_>, max: AnyValue<'_>) -> Self { - self.map_private(FunctionExpr::Clip { - min: Some(min.into_static().unwrap()), - max: Some(max.into_static().unwrap()), - }) + pub fn clip(self, min: Expr, max: Expr) -> Self { + self.map_many_private( + FunctionExpr::Clip { + has_min: true, + has_max: true, + }, + &[min, max], + false, + ) } /// Clip underlying values to a set boundary. #[cfg(feature = "round_series")] - pub fn clip_max(self, max: AnyValue<'_>) -> Self { - self.map_private(FunctionExpr::Clip { - min: None, - max: Some(max.into_static().unwrap()), - }) + pub fn clip_max(self, max: Expr) -> Self { + self.map_many_private( + FunctionExpr::Clip { + has_min: false, + has_max: true, + }, + &[max], + false, + ) } /// Clip underlying values to a set boundary. #[cfg(feature = "round_series")] - pub fn clip_min(self, min: AnyValue<'_>) -> Self { - self.map_private(FunctionExpr::Clip { - min: Some(min.into_static().unwrap()), - max: None, - }) + pub fn clip_min(self, min: Expr) -> Self { + self.map_many_private( + FunctionExpr::Clip { + has_min: true, + has_max: false, + }, + &[min], + false, + ) } /// Convert all values to their absolute/positive value. diff --git a/py-polars/polars/expr/expr.py b/py-polars/polars/expr/expr.py index 1c7785a1267e..c79e34ed7670 100644 --- a/py-polars/polars/expr/expr.py +++ b/py-polars/polars/expr/expr.py @@ -7424,11 +7424,11 @@ def kurtosis(self, *, fisher: bool = True, bias: bool = True) -> Self: """ return self._from_pyexpr(self._pyexpr.kurtosis(fisher, bias)) - def clip(self, lower_bound: int | float, upper_bound: int | float) -> Self: + def clip(self, lower_bound: IntoExpr, upper_bound: IntoExpr) -> Self: """ Clip (limit) the values in an array to a `min` and `max` boundary. - Only works for numerical types. + Only works for physical numerical types. If you want to clip other dtypes, consider writing a "when, then, otherwise" expression. See :func:`when` for more information. @@ -7457,9 +7457,11 @@ def clip(self, lower_bound: int | float, upper_bound: int | float) -> Self: └──────┴─────────────┘ """ + lower_bound = parse_as_expression(lower_bound, str_as_lit=True) + upper_bound = parse_as_expression(upper_bound, str_as_lit=True) return self._from_pyexpr(self._pyexpr.clip(lower_bound, upper_bound)) - def clip_min(self, lower_bound: int | float) -> Self: + def clip_min(self, lower_bound: IntoExpr) -> Self: """ Clip (limit) the values in an array to a `min` boundary. @@ -7490,9 +7492,10 @@ def clip_min(self, lower_bound: int | float) -> Self: └──────┴─────────────┘ """ + lower_bound = parse_as_expression(lower_bound, str_as_lit=True) return self._from_pyexpr(self._pyexpr.clip_min(lower_bound)) - def clip_max(self, upper_bound: int | float) -> Self: + def clip_max(self, upper_bound: IntoExpr) -> Self: """ Clip (limit) the values in an array to a `max` boundary. @@ -7523,6 +7526,7 @@ def clip_max(self, upper_bound: int | float) -> Self: └──────┴─────────────┘ """ + upper_bound = parse_as_expression(upper_bound, str_as_lit=True) return self._from_pyexpr(self._pyexpr.clip_max(upper_bound)) def lower_bound(self) -> Self: diff --git a/py-polars/polars/series/series.py b/py-polars/polars/series/series.py index 82ae481eb9b4..19b2c4621724 100644 --- a/py-polars/polars/series/series.py +++ b/py-polars/polars/series/series.py @@ -6101,7 +6101,7 @@ def kurtosis(self, *, fisher: bool = True, bias: bool = True) -> float | None: """ return self._s.kurtosis(fisher, bias) - def clip(self, lower_bound: int | float, upper_bound: int | float) -> Series: + def clip(self, lower_bound: IntoExpr, upper_bound: IntoExpr) -> Series: """ Clip (limit) the values in an array to a `min` and `max` boundary. @@ -6132,7 +6132,7 @@ def clip(self, lower_bound: int | float, upper_bound: int | float) -> Series: """ - def clip_min(self, lower_bound: int | float) -> Series: + def clip_min(self, lower_bound: IntoExpr) -> Series: """ Clip (limit) the values in an array to a `min` boundary. @@ -6148,7 +6148,7 @@ def clip_min(self, lower_bound: int | float) -> Series: """ - def clip_max(self, upper_bound: int | float) -> Series: + def clip_max(self, upper_bound: IntoExpr) -> Series: """ Clip (limit) the values in an array to a `max` boundary. diff --git a/py-polars/src/expr/general.rs b/py-polars/src/expr/general.rs index 102241a7b2db..6f4efebd0179 100644 --- a/py-polars/src/expr/general.rs +++ b/py-polars/src/expr/general.rs @@ -447,20 +447,16 @@ impl PyExpr { self.clone().inner.ceil().into() } - fn clip(&self, py: Python, min: PyObject, max: PyObject) -> Self { - let min = min.extract::>(py).unwrap().0; - let max = max.extract::>(py).unwrap().0; - self.clone().inner.clip(min, max).into() + fn clip(&self, min: Self, max: Self) -> Self { + self.clone().inner.clip(min.inner, max.inner).into() } - fn clip_min(&self, py: Python, min: PyObject) -> Self { - let min = min.extract::>(py).unwrap().0; - self.clone().inner.clip_min(min).into() + fn clip_min(&self, min: Self) -> Self { + self.clone().inner.clip_min(min.inner).into() } - fn clip_max(&self, py: Python, max: PyObject) -> Self { - let max = max.extract::>(py).unwrap().0; - self.clone().inner.clip_max(max).into() + fn clip_max(&self, max: Self) -> Self { + self.clone().inner.clip_max(max.inner).into() } fn abs(&self) -> Self { diff --git a/py-polars/tests/unit/dataframe/test_df.py b/py-polars/tests/unit/dataframe/test_df.py index 9e074dd6b72a..d3dbe40ebbb1 100644 --- a/py-polars/tests/unit/dataframe/test_df.py +++ b/py-polars/tests/unit/dataframe/test_df.py @@ -3519,11 +3519,95 @@ def test_deadlocks_3409() -> None: def test_clip() -> None: - df = pl.DataFrame({"a": [1, 2, 3, 4, 5]}) - assert df.select(pl.col("a").clip(2, 4))["a"].to_list() == [2, 2, 3, 4, 4] - assert pl.Series([1, 2, 3, 4, 5]).clip(2, 4).to_list() == [2, 2, 3, 4, 4] - assert pl.Series([1, 2, 3, 4, 5]).clip_min(3).to_list() == [3, 3, 3, 4, 5] - assert pl.Series([1, 2, 3, 4, 5]).clip_max(3).to_list() == [1, 2, 3, 3, 3] + clip_exprs = [ + pl.col("a").clip(pl.col("min"), pl.col("max")).alias("clip"), + pl.col("a").clip_min(pl.col("min")).alias("clip_min"), + pl.col("a").clip_max(pl.col("max")).alias("clip_max"), + ] + + df = pl.DataFrame( + { + "a": [1, 2, 3, 4, 5], + "min": [0, -1, 4, None, 4], + "max": [2, 1, 8, 5, None], + } + ) + + assert df.select(clip_exprs).to_dict(False) == { + "clip": [1, 1, 4, None, None], + "clip_min": [1, 2, 4, None, 5], + "clip_max": [1, 1, 3, 4, None], + } + + df = pl.DataFrame( + { + "a": [1.0, 2.0, 3.0, 4.0, 5.0], + "min": [0, -1.0, 4.0, None, 4.0], + "max": [2.0, 1.0, 8.0, 5.0, None], + } + ) + + assert df.select(clip_exprs).to_dict(False) == { + "clip": [1.0, 1.0, 4.0, None, None], + "clip_min": [1.0, 2.0, 4.0, None, 5.0], + "clip_max": [1.0, 1.0, 3.0, 4.0, None], + } + + df = pl.DataFrame( + { + "a": [ + datetime(1995, 6, 5, 10, 30), + datetime(1995, 6, 5), + datetime(2023, 10, 20, 18, 30, 6), + None, + datetime(2023, 9, 24), + datetime(2000, 1, 10), + ], + "min": [ + datetime(1995, 6, 5, 10, 29), + datetime(1996, 6, 5), + datetime(2020, 9, 24), + datetime(2020, 1, 1), + None, + datetime(2000, 1, 1), + ], + "max": [ + datetime(1995, 7, 21, 10, 30), + datetime(2000, 1, 1), + datetime(2023, 9, 20, 18, 30, 6), + datetime(2000, 1, 1), + datetime(1993, 3, 13), + None, + ], + } + ) + + assert df.select(clip_exprs).to_dict(False) == { + "clip": [ + datetime(1995, 6, 5, 10, 30), + datetime(1996, 6, 5), + datetime(2023, 9, 20, 18, 30, 6), + None, + None, + None, + ], + "clip_min": [ + datetime(1995, 6, 5, 10, 30), + datetime(1996, 6, 5), + datetime(2023, 10, 20, 18, 30, 6), + None, + None, + datetime(2000, 1, 10), + ], + "clip_max": [ + datetime(1995, 6, 5, 10, 30), + datetime(1995, 6, 5), + datetime(2023, 9, 20, 18, 30, 6), + None, + datetime(1993, 3, 13), + None, + ], + } def test_cum_agg() -> None: