Skip to main content

volas_core/column/
transform.rs

1//! `Column` element-wise transforms — cumulatives, abs/round/clip,
2//! shift/diff, fillna/ffill, and the conditional `select`.
3
4use super::*;
5
6impl Column {
7    /// Cumulative sum (pandas `cumsum`, skipna), dtype-preserving (`bool` -> int64
8    /// counting trues). Missing values are skipped and stay missing (NA in → NA out).
9    pub fn cumsum(&self) -> Result<Column> {
10        match self {
11            Column::Bool(v, val) => {
12                let iv = widen_i64(v);
13                let out = if val.has_nulls() {
14                    cum_valid(&iv, val, 0, i64::wrapping_add)
15                } else {
16                    stats::cumsum(&iv)
17                };
18                Ok(Column::i64_with(out, val.clone()))
19            }
20            Column::I64(v, val) => Ok(cum(v, val, stats::cumsum, i64::wrapping_add)),
21            Column::I32(v, val) => Ok(cum(v, val, stats::cumsum, i32::wrapping_add)),
22            _ => numeric_dispatch!(self, v => Numeric::into_column(stats::cumsum(v))),
23        }
24    }
25
26    /// Cumulative maximum (pandas `cummax`), dtype-preserving (`bool` running OR).
27    /// Missing values are skipped and stay missing.
28    pub fn cummax(&self) -> Result<Column> {
29        match self {
30            Column::Bool(v, val) => {
31                let out = if val.has_nulls() {
32                    cum_valid(v, val, false, |a, b| a || b)
33                } else {
34                    bool_running(v, true)
35                };
36                Ok(Column::bool_with(out, val.clone()))
37            }
38            Column::I64(v, val) => Ok(cum(v, val, stats::cummax, |a, x| if x > a { x } else { a })),
39            Column::I32(v, val) => Ok(cum(v, val, stats::cummax, |a, x| if x > a { x } else { a })),
40            // running extreme is order-based, so str (lexical) / datetime (instant)
41            // are valid, like min/max — not a numeric-only op.
42            Column::Str(..) | Column::Datetime(..) => self.cum_extreme(true),
43            _ => numeric_dispatch!(self, v => Numeric::into_column(stats::cummax(v))),
44        }
45    }
46
47    /// Cumulative minimum (pandas `cummin`), dtype-preserving (`bool` running AND).
48    /// Missing values are skipped and stay missing.
49    pub fn cummin(&self) -> Result<Column> {
50        match self {
51            Column::Bool(v, val) => {
52                let out = if val.has_nulls() {
53                    cum_valid(v, val, true, |a, b| a && b)
54                } else {
55                    bool_running(v, false)
56                };
57                Ok(Column::bool_with(out, val.clone()))
58            }
59            Column::I64(v, val) => Ok(cum(v, val, stats::cummin, |a, x| if x < a { x } else { a })),
60            Column::I32(v, val) => Ok(cum(v, val, stats::cummin, |a, x| if x < a { x } else { a })),
61            Column::Str(..) | Column::Datetime(..) => self.cum_extreme(false),
62            _ => numeric_dispatch!(self, v => Numeric::into_column(stats::cummin(v))),
63        }
64    }
65
66    /// Cumulative product (pandas `cumprod`), dtype-preserving (`bool` -> int64).
67    /// Missing values are skipped and stay missing.
68    pub fn cumprod(&self) -> Result<Column> {
69        match self {
70            Column::Bool(v, val) => {
71                let iv = widen_i64(v);
72                let out = if val.has_nulls() {
73                    cum_valid(&iv, val, 1, i64::wrapping_mul)
74                } else {
75                    stats::cumprod(&iv)
76                };
77                Ok(Column::i64_with(out, val.clone()))
78            }
79            Column::I64(v, val) => Ok(cum(v, val, stats::cumprod, i64::wrapping_mul)),
80            Column::I32(v, val) => Ok(cum(v, val, stats::cumprod, i32::wrapping_mul)),
81            _ => numeric_dispatch!(self, v => Numeric::into_column(stats::cumprod(v))),
82        }
83    }
84
85    /// Element-wise absolute value (pandas `abs`), dtype-preserving. `abs` of a
86    /// missing (`NaN`) stays missing; `abs(i64::MIN)` wraps to `i64::MIN` (pandas);
87    /// a `bool` column is unchanged (`abs(bool) == bool`).
88    pub fn abs(&self) -> Result<Column> {
89        if matches!(self, Column::Bool(_, _)) {
90            return Ok(self.clone());
91        }
92        let validity = self.validity().cloned().unwrap_or_default();
93        let out = numeric_dispatch!(self, v => Numeric::into_column(stats::abs(v)))?;
94        Ok(out.with_validity(validity))
95    }
96
97    /// Round to `decimals` places (pandas `round`), dtype-preserving: banker's
98    /// (half-to-even) for floats, and for ints an identity at `decimals >= 0` or a
99    /// banker's round to the nearest power-of-ten multiple at negative `decimals`.
100    pub fn round(&self, decimals: i32) -> Result<Column> {
101        match self {
102            Column::F64(v) => Ok(Column::f64(
103                v.iter().map(|&x| round_f64(x, decimals)).collect(),
104            )),
105            Column::F32(v) => Ok(Column::f32(
106                v.iter()
107                    .map(|&x| round_f64(x as f64, decimals) as f32)
108                    .collect(),
109            )),
110            Column::I64(v, val) => Ok(Column::i64_with(
111                v.iter().map(|&x| round_i64(x, decimals)).collect(),
112                val.clone(),
113            )),
114            Column::I32(v, val) => Ok(Column::i32_with(
115                v.iter()
116                    .map(|&x| round_i64(x as i64, decimals) as i32)
117                    .collect(),
118                val.clone(),
119            )),
120            Column::Bool(_, _) => Ok(self.clone()), // round(bool) == bool (pandas no-op)
121            other => Err(VolasError::DType(format!(
122                "cannot round a {} column",
123                other.dtype()
124            ))),
125        }
126    }
127
128    /// Clamp to `[lower, upper]` (either bound optional), pandas `clip`. Stays in
129    /// the column dtype when every present bound fits it losslessly; otherwise (an
130    /// int column with a non-integral bound) promotes to float, matching pandas.
131    pub fn clip(&self, lower: Option<f64>, upper: Option<f64>) -> Result<Column> {
132        match self {
133            // bool stays bool (pandas): a True lower bound forces all true, a
134            // False upper bound forces all false, otherwise unchanged.
135            Column::Bool(v, val) => {
136                return Ok(Column::bool_with(clip_bool(v, lower, upper), val.clone()))
137            }
138            Column::F64(_) | Column::F32(_) | Column::I64(_, _) | Column::I32(_, _) => {}
139            other => {
140                return Err(VolasError::DType(format!(
141                    "cannot clip a {} column",
142                    other.dtype()
143                )))
144            }
145        }
146        let bound_fits = |b: Option<f64>| b.is_none_or(|x| fits(self.dtype(), x));
147        // Stay in dtype when every present bound fits it losslessly (a float dtype
148        // always does); an int column with a non-integral bound promotes to float.
149        let stay = self.dtype().is_float() || (bound_fits(lower) && bound_fits(upper));
150        if stay {
151            let validity = self.validity().cloned().unwrap_or_default();
152            numeric_dispatch!(self, v => Numeric::into_column(clip_vec(v, lower, upper)))
153                .map(|c| c.with_validity(validity))
154        } else {
155            // int -> f64 promotion: to_f64_vec already maps a missing value to NaN
156            Ok(Column::f64(clip_vec(&self.to_f64_vec(), lower, upper)))
157        }
158    }
159
160    /// Shift values by `n` periods (pandas `shift`), dtype-preserving for every
161    /// dtype (`str` included). Vacated cells become missing (`NaN` for float,
162    /// `volas.NA` for int/bool/str, `NaT` for datetime); a shifted-in value keeps
163    /// its own missingness.
164    pub fn shift(&self, n: isize) -> Column {
165        let len = self.len();
166        // Validity of the result: a cell is present when its source cell exists and
167        // was itself present. Only the nullable (int/bool) variants build it; the
168        // value buffers shift with a single `memcpy` via `shift_fill`.
169        let nulls = || {
170            Validity::from_valid_iter(
171                len,
172                (0..len).map(|i| {
173                    let s = i as isize - n;
174                    s >= 0 && (s as usize) < len && self.is_valid(s as usize)
175                }),
176            )
177        };
178        match self {
179            Column::F64(v) => Column::f64(shift_fill(v, n, f64::NAN)),
180            Column::F32(v) => Column::f32(shift_fill(v, n, f32::NAN)),
181            Column::I64(v, _) => Column::i64_with(shift_fill(v, n, 0), nulls()),
182            Column::I32(v, _) => Column::i32_with(shift_fill(v, n, 0), nulls()),
183            Column::Bool(v, _) => Column::bool_with(shift_fill(v, n, false), nulls()),
184            Column::Datetime(v) => Column::datetime(shift_fill(v, n, i64::MIN)),
185            Column::Str(v, _) => {
186                let shifted = (0..len)
187                    .map(|i| {
188                        let s = i as isize - n;
189                        if s >= 0 && (s as usize) < len {
190                            v.get(s as usize)
191                        } else {
192                            "" // placeholder, masked by the gap validity
193                        }
194                    })
195                    .collect();
196                Column::Str(shifted, nulls())
197            }
198        }
199    }
200
201    /// Discrete difference `x[i] - x[i-n]` (pandas `diff`), dtype-preserving: the
202    /// first `n` cells (the shift gap) are missing. `diff` **is** subtraction, so it
203    /// inherits [`binary`](Self::binary)'s rules: int stays int (NA gap), bool-bool
204    /// subtraction is unsupported, and `str` / `datetime` raise — a datetime
205    /// difference is a `timedelta64`, the open `O3` decision, not a float64-of-ns.
206    pub fn diff(&self, n: isize) -> Result<Column> {
207        match self {
208            Column::F64(v) => Ok(Column::f64(diff_kernel(v, n, f64::NAN))),
209            Column::F32(v) => Ok(Column::f32(diff_kernel(v, n, f32::NAN))),
210            // int -> int diff; bool -> "bool subtraction unsupported"; str/datetime
211            // -> require_numeric error (all enforced by `binary`).
212            _ => self.binary(&self.shift(n), BinOp::Sub),
213        }
214    }
215
216    /// Replace missing cells with the constant `value` (pandas `fillna`),
217    /// dtype-preserving when `value` fits the dtype, else promoting an int column
218    /// to float (a non-integral fill).
219    pub fn fillna(&self, value: f64) -> Result<Column> {
220        if self.null_count() == 0 {
221            return Ok(self.clone());
222        }
223        let len = self.len();
224        Ok(match self {
225            Column::F64(v) => Column::f64(
226                v.iter()
227                    .map(|&x| if x.is_nan() { value } else { x })
228                    .collect(),
229            ),
230            Column::F32(v) => Column::f32(
231                v.iter()
232                    .map(|&x| if x.is_nan() { value as f32 } else { x })
233                    .collect(),
234            ),
235            Column::I64(v, val) => match i64::try_from_f64(value) {
236                Some(iv) => Column::i64(
237                    (0..len)
238                        .map(|i| if val.is_valid(i) { v[i] } else { iv })
239                        .collect(),
240                ),
241                None => Column::f64(
242                    (0..len)
243                        .map(|i| if val.is_valid(i) { v[i] as f64 } else { value })
244                        .collect(),
245                ),
246            },
247            Column::I32(v, val) => match i32::try_from_f64(value) {
248                Some(iv) => Column::i32(
249                    (0..len)
250                        .map(|i| if val.is_valid(i) { v[i] } else { iv })
251                        .collect(),
252                ),
253                None => Column::f64(
254                    (0..len)
255                        .map(|i| if val.is_valid(i) { v[i] as f64 } else { value })
256                        .collect(),
257                ),
258            },
259            // a 0/1 fill keeps bool; a non-0/1 fill promotes the (numeric-family)
260            // bool column to float.
261            Column::Bool(v, val) if value == 0.0 || value == 1.0 => Column::bool(
262                (0..len)
263                    .map(|i| if val.is_valid(i) { v[i] } else { value != 0.0 })
264                    .collect(),
265            ),
266            Column::Bool(..) => Column::f64(
267                self.to_f64_vec()
268                    .iter()
269                    .map(|&x| if x.is_nan() { value } else { x })
270                    .collect(),
271            ),
272            // A numeric fill cannot apply to a non-numeric column: volas has no
273            // `object` dtype to hold a mixed string/number or datetime/number
274            // column, and the old f64 funnel silently turned valid strings into the
275            // fill and lost the datetime dtype. Reject it.
276            Column::Str(..) | Column::Datetime(..) => {
277                return Err(VolasError::DType(format!(
278                    "cannot fill a {} column with the numeric value {value} (volas has \
279                     no object dtype); drop or select the missing rows instead",
280                    self.dtype(),
281                )))
282            }
283        })
284    }
285
286    /// Forward-fill (`forward = true`, pandas `ffill`) or backward-fill (`bfill`)
287    /// missing cells from the nearest present value in that direction,
288    /// dtype-preserving; leading / trailing cells with no source stay missing.
289    pub fn fill_dir(&self, forward: bool) -> Column {
290        if self.null_count() == 0 {
291            return self.clone();
292        }
293        let len = self.len();
294        // For each position, the source index of the value to carry in, or `None`.
295        let mut src = vec![None; len];
296        let mut last: Option<usize> = None;
297        for k in 0..len {
298            let i = if forward { k } else { len - 1 - k };
299            if self.is_valid(i) {
300                last = Some(i);
301            }
302            src[i] = last;
303        }
304        let validity = Validity::from_valid_iter(len, src.iter().map(|s| s.is_some()));
305        match self {
306            Column::F64(v) => {
307                Column::f64(src.iter().map(|s| s.map_or(f64::NAN, |j| v[j])).collect())
308            }
309            Column::F32(v) => {
310                Column::f32(src.iter().map(|s| s.map_or(f32::NAN, |j| v[j])).collect())
311            }
312            Column::I64(v, _) => Column::i64_with(
313                src.iter().map(|s| s.map_or(0, |j| v[j])).collect(),
314                validity,
315            ),
316            Column::I32(v, _) => Column::i32_with(
317                src.iter().map(|s| s.map_or(0, |j| v[j])).collect(),
318                validity,
319            ),
320            Column::Bool(v, _) => Column::bool_with(
321                src.iter().map(|s| s.is_some_and(|j| v[j])).collect(),
322                validity,
323            ),
324            Column::Datetime(v) => {
325                Column::datetime(src.iter().map(|s| s.map_or(i64::MIN, |j| v[j])).collect())
326            }
327            // str carries missing too: gather the carried value (empty placeholder
328            // for an unfilled cell, which `validity` then marks NA), like int/bool.
329            Column::Str(v, _) => Column::Str(
330                src.iter().map(|s| s.map_or("", |j| v.get(j))).collect(),
331                validity,
332            ),
333        }
334    }
335
336    /// `where` / `mask` core: pick `self` where `cond` is true, else `other`,
337    /// producing `target` dtype (the caller resolves keep-vs-promote so the fill's
338    /// value/type is accounted for). Picks i64 natively when `target` is `I64`
339    /// (no f64 round-trip, so large ints stay exact). Equal lengths assumed.
340    pub fn select(&self, cond: &[bool], other: &Column, target: DType) -> Result<Column> {
341        match target {
342            DType::I64 => Ok(Column::i64_with(
343                stats::select(cond, &self.as_i64_vec()?, &other.as_i64_vec()?),
344                self.select_nulls(cond, other),
345            )),
346            DType::I32 => Ok(Column::i32_with(
347                stats::select(cond, &self.as_i32_vec()?, &other.as_i32_vec()?),
348                self.select_nulls(cond, other),
349            )),
350            DType::F32 => Ok(Column::f32(stats::select(
351                cond,
352                &self.to_f32_vec(),
353                &other.to_f32_vec(),
354            ))),
355            // bool ∘ bool stays bool (pandas keeps a bool result when the fill is
356            // also bool); `bool` isn't `Numeric`, so the pick is inlined.
357            DType::Bool => {
358                let (a, b) = (self.as_bool_vec()?, other.as_bool_vec()?);
359                Ok(Column::bool_with(
360                    (0..cond.len())
361                        .map(|i| if cond[i] { a[i] } else { b[i] })
362                        .collect(),
363                    self.select_nulls(cond, other),
364                ))
365            }
366            // str / datetime stay in their dtype (a default `other` of NA keeps the
367            // kept values and marks the rest missing) instead of an f64 funnel that
368            // turned every kept string into NaN.
369            DType::Utf8 => {
370                let (a, b) = (self.as_str_vec()?, other.as_str_vec()?);
371                Ok(Column::str_with(
372                    (0..cond.len())
373                        .map(|i| if cond[i] { a[i].clone() } else { b[i].clone() })
374                        .collect(),
375                    self.select_nulls(cond, other),
376                ))
377            }
378            DType::Datetime => {
379                let (a, b) = (self.as_datetime_vec()?, other.as_datetime_vec()?);
380                Ok(Column::datetime(
381                    (0..cond.len())
382                        .map(|i| if cond[i] { a[i] } else { b[i] })
383                        .collect(),
384                ))
385            }
386            _ => Ok(Column::f64(stats::select(
387                cond,
388                &self.to_f64_vec(),
389                &other.to_f64_vec(),
390            ))),
391        }
392    }
393
394    /// Picked validity for `select`: position `i` is present when the *chosen*
395    /// side (`self` where `cond`, else `other`) is present there.
396    fn select_nulls(&self, cond: &[bool], other: &Column) -> Validity {
397        Validity::from_valid_iter(
398            cond.len(),
399            (0..cond.len()).map(|i| {
400                if cond[i] {
401                    self.is_valid(i)
402                } else {
403                    other.is_valid(i)
404                }
405            }),
406        )
407    }
408}