Skip to main content

volas_core/column/
cast.rs

1//! `Column` dtype casting and datetime conversion (`astype` / `to_datetime`).
2
3use super::*;
4
5impl Column {
6    /// Parse this column into a [`Column::Datetime`] (epoch ns). `Str` cells are
7    /// parsed via [`datetime::parse_ns`]; an already-`Datetime` column is shared
8    /// back (cheap). Errors on an unparseable cell or an unsupported dtype.
9    pub fn to_datetime(&self) -> Result<Column> {
10        self.to_datetime_tz(crate::tz::Tz::Utc)
11    }
12
13    /// Parse a string column to a UTC `Datetime` column, interpreting **naive**
14    /// strings in `tz` (offset-aware strings are absolute; an existing `Datetime`
15    /// column is already UTC and returned as-is). The tz is then attached to the
16    /// *index* (storage stays UTC).
17    pub fn to_datetime_tz(&self, tz: crate::tz::Tz) -> Result<Column> {
18        match self {
19            Column::Datetime(_) => Ok(self.clone()),
20            Column::Str(v, val) => {
21                let mut out = Vec::with_capacity(v.len());
22                for (i, s) in v.iter().enumerate() {
23                    // A missing (NA) string cell — or a present but empty/blank one —
24                    // parses to NaT, matching read_csv(parse_dates) and pandas
25                    // `to_datetime("")`. A non-empty unparseable string still errors.
26                    if !val.is_valid(i) || s.trim().is_empty() {
27                        out.push(i64::MIN);
28                        continue;
29                    }
30                    let ns = datetime::parse_ns_in_tz(s, tz).ok_or_else(|| {
31                        VolasError::Value(format!("could not parse datetime {s:?}"))
32                    })?;
33                    out.push(ns);
34                }
35                Ok(Column::datetime(out))
36            }
37            other => Err(VolasError::DType(format!(
38                "cannot parse a {} column as datetime",
39                other.dtype()
40            ))),
41        }
42    }
43
44    /// Interpret a numeric (epoch) column as a UTC `Datetime` column, scaling by
45    /// `unit` (`"s"` / `"ms"` / `"us"` / `"ns"`). Float epochs are **truncated** to
46    /// the whole `unit` (matching a NumPy / pandas `astype('datetime64[unit]')`
47    /// cast). The robust ingestion path for exchange APIs that return numeric
48    /// timestamps.
49    pub fn epoch_to_datetime(&self, unit: &str) -> Result<Column> {
50        self.epoch_to_datetime_with(unit, |x| datetime::epoch_to_ns(x as i64, unit))
51    }
52
53    /// Like [`epoch_to_datetime`](Self::epoch_to_datetime) but **rounds** float
54    /// epochs to the nearest nanosecond, preserving sub-`unit` fractions (matching
55    /// `pandas.to_datetime(..., unit=...)`). Identical for integer columns.
56    pub fn epoch_to_datetime_rounded(&self, unit: &str) -> Result<Column> {
57        self.epoch_to_datetime_with(unit, |x| datetime::epoch_to_ns_f64(x, unit))
58    }
59
60    /// Shared epoch → `Datetime` conversion; `f64_to_ns` chooses how float epochs
61    /// map to nanoseconds (truncate vs round). Integers always scale exactly.
62    fn epoch_to_datetime_with(
63        &self,
64        unit: &str,
65        f64_to_ns: impl Fn(f64) -> Option<i64>,
66    ) -> Result<Column> {
67        match self {
68            // A missing input maps to `NaT` (i64::MIN), not 1970 / an error: read
69            // the i64 validity bit, and treat a float `NaN` as missing.
70            Column::I64(v, val) => v
71                .iter()
72                .enumerate()
73                .map(|(i, &x)| {
74                    if !val.is_valid(i) {
75                        Ok(i64::MIN)
76                    } else {
77                        datetime::epoch_to_ns(x, unit).ok_or_else(|| {
78                            VolasError::Value(format!(
79                            "could not convert epoch with unit {unit:?}: unknown unit or value out of nanosecond range"
80                        ))
81                        })
82                    }
83                })
84                .collect::<Result<Vec<_>>>()
85                .map(Column::datetime),
86            Column::F64(v) => v
87                .iter()
88                .map(|&x| {
89                    if x.is_nan() {
90                        Ok(i64::MIN)
91                    } else {
92                        f64_to_ns(x).ok_or_else(|| {
93                            VolasError::Value(format!(
94                            "could not convert epoch with unit {unit:?}: unknown unit or value out of nanosecond range"
95                        ))
96                        })
97                    }
98                })
99                .collect::<Result<Vec<_>>>()
100                .map(Column::datetime),
101            other => Err(VolasError::DType(format!(
102                "cannot read a {} column as an epoch timestamp (need int64 / float64)",
103                other.dtype()
104            ))),
105        }
106    }
107
108    /// Cast to another dtype (best-effort, pandas `astype`-like). A no-op when
109    /// already the target dtype.
110    pub fn cast(&self, to: DType) -> Result<Column> {
111        if self.dtype() == to {
112            return Ok(self.clone());
113        }
114        // An int/bool source to an int/bool target keeps its missing cells and
115        // converts present values exactly (no f64 round-trip; range-checked).
116        if matches!(self, Column::I64(..) | Column::I32(..) | Column::Bool(..))
117            && matches!(to, DType::I64 | DType::I32 | DType::Bool)
118        {
119            return self.cast_int_bool(to);
120        }
121        // `astype` is an EXPLICIT cast, so a str source to a numeric target parses
122        // each cell rather than funnelling to a silent all-NaN (`to_f64_vec` reads
123        // a str column as NaN): a valid numeric string converts, an empty/missing
124        // cell becomes NA, any other non-empty string raises (contract C4).
125        if let Column::Str(v, val) = self {
126            if matches!(to, DType::F64 | DType::F32 | DType::I64 | DType::I32) {
127                return Self::cast_str_to_numeric(v, val, to);
128            }
129        }
130        // D5: a datetime -> float cast is a lossy reinterpret of the i64 epoch-ns
131        // (values past 2^53 quantize to ~256ns), so reject it (pandas does too).
132        // `astype('int64')` gives the exact ns; an explicit `to_numpy(dtype=...)`
133        // export is the deliberate opt-in lossy channel.
134        if matches!(self, Column::Datetime(_)) && matches!(to, DType::F64 | DType::F32) {
135            return Err(VolasError::DType(
136                "cannot cast a datetime column to float (lossy past 2^53 ns); \
137                 use astype('int64') for the exact epoch nanoseconds"
138                    .to_string(),
139            ));
140        }
141        // Epoch nanoseconds never fit int32 (except meaningless near-1970
142        // instants), so a datetime -> int32 cast is rejected like the float one.
143        if matches!(self, Column::Datetime(_)) && to == DType::I32 {
144            return Err(VolasError::DType(
145                "cannot cast a datetime column to int32 (epoch nanoseconds do not fit); \
146                 use astype('int64') for the exact epoch nanoseconds"
147                    .to_string(),
148            ));
149        }
150        match to {
151            DType::F64 => Ok(Column::f64(self.to_f64_vec())),
152            DType::I64 => match self {
153                Column::F64(v) => {
154                    // pandas raises (IntCastingNaNError) rather than silently
155                    // turning NaN -> 0 / inf -> i64::MAX, which corrupts data.
156                    if let Some(x) = v.iter().copied().find(|x| !x.is_finite()) {
157                        return Err(VolasError::Value(format!(
158                            "cannot convert non-finite value ({x}) to int64 (NaN / inf); \
159                             fill or drop it first"
160                        )));
161                    }
162                    Ok(Column::i64(v.iter().map(|&x| x as i64).collect()))
163                }
164                // F32 truncates like F64 (consistent with F32 -> I32), finite-checked.
165                Column::F32(v) => {
166                    if let Some(x) = v.iter().copied().find(|x| !x.is_finite()) {
167                        return Err(VolasError::Value(format!(
168                            "cannot convert non-finite value ({x}) to int64 (NaN / inf); \
169                             fill or drop it first"
170                        )));
171                    }
172                    Ok(Column::i64(v.iter().map(|&x| x as i64).collect()))
173                }
174                // int/bool sources are handled by `cast_int_bool` above.
175                // The exact epoch nanoseconds; a NaT cell stays missing in the
176                // int64 validity (# C2) instead of surfacing the i64::MIN sentinel
177                // as a value (pandas raises here; volas's native-NA int carries it).
178                Column::Datetime(v) => {
179                    let validity = Validity::from_valid_iter(
180                        v.len(),
181                        (0..v.len()).map(|i| self.is_valid(i)),
182                    );
183                    Ok(Column::i64_with(v.to_vec(), validity))
184                }
185                // unreachable: int/bool go through cast_int_bool, str through
186                // cast_str_to_numeric, and F64/F32/Datetime are handled above.
187                _ => unreachable!("non-numeric int64 cast handled earlier"), // LCOV_EXCL_LINE
188            },
189            // numeric -> bool is pandas-nullable truthiness (owner ruling
190            // 2026-06-12): nonzero -> True (incl ±inf), zero -> False, and a
191            // missing float cell (NaN) STAYS missing — never numpy's
192            // NaN-is-truthy footgun. int sources go through `cast_int_bool`.
193            DType::Bool => match self {
194                Column::F64(v) => Ok(Column::bool_with(
195                    v.iter().map(|&x| x != 0.0).collect(),
196                    Validity::from_valid_iter(v.len(), v.iter().map(|x| !x.is_nan())),
197                )),
198                Column::F32(v) => Ok(Column::bool_with(
199                    v.iter().map(|&x| x != 0.0).collect(),
200                    Validity::from_valid_iter(v.len(), v.iter().map(|x| !x.is_nan())),
201                )),
202                // str -> bool has no parse policy (owner ruling: error), and a
203                // datetime has no truthiness.
204                other => Err(VolasError::DType(format!(
205                    "cannot cast a {} column to bool",
206                    other.dtype()
207                ))),
208            },
209            DType::F32 => Ok(Column::f32(
210                self.to_f64_vec().iter().map(|&x| x as f32).collect(),
211            )),
212            DType::I32 => self
213                .to_f64_vec()
214                .iter()
215                .map(|&x| {
216                    i32::try_from_f64(x).ok_or_else(|| {
217                        VolasError::Value(format!(
218                            "cannot convert {x} to int32 (non-finite / non-integral / out of range)"
219                        ))
220                    })
221                })
222                .collect::<Result<Vec<_>>>()
223                .map(Column::i32),
224            // F4: EVERY source's missing cells stay missing in the str target —
225            // including the in-band sentinels (float NaN, datetime NaT), which the
226            // raw bitmap doesn't cover. Without this, stringify materialises the
227            // sentinel as a literal 'NaN'/'NaT' value (isna -> False), a C2/C4
228            // placeholder collapse. The unified is_valid covers all three NA forms.
229            DType::Utf8 => {
230                let n = self.len();
231                let validity = crate::column::Validity::from_valid_iter(
232                    n,
233                    (0..n).map(|i| self.is_valid(i)),
234                );
235                Ok(Column::str_with(self.to_string_vec(), validity))
236            }
237            DType::Datetime => self.to_datetime(),
238        }
239    }
240
241    /// Cast an int/bool column to another int/bool dtype, carrying its validity (a
242    /// missing cell stays missing) and converting present values exactly (no f64
243    /// round-trip). Only a present, out-of-range value (a large `i64` into `i32`)
244    /// errors; a missing cell never does.
245    fn cast_int_bool(&self, to: DType) -> Result<Column> {
246        let validity = self.validity().cloned().unwrap_or_default();
247        let narrow_i32 = |v: &[i64]| -> Result<Vec<i32>> {
248            v.iter()
249                .enumerate()
250                .map(|(i, &x)| {
251                    if validity.is_valid(i) {
252                        i32::try_from(x).map_err(|_| {
253                            VolasError::Value(format!("cannot convert {x} to int32 (out of range)"))
254                        })
255                    } else {
256                        Ok(0)
257                    }
258                })
259                .collect()
260        };
261        match (self, to) {
262            (Column::I64(v, _), DType::I32) => Ok(Column::i32_with(narrow_i32(v)?, validity)),
263            (Column::I64(v, _), DType::Bool) => Ok(Column::bool_with(
264                v.iter().map(|&x| x != 0).collect(),
265                validity,
266            )),
267            (Column::I32(v, _), DType::I64) => Ok(Column::i64_with(
268                v.iter().map(|&x| x as i64).collect(),
269                validity,
270            )),
271            (Column::I32(v, _), DType::Bool) => Ok(Column::bool_with(
272                v.iter().map(|&x| x != 0).collect(),
273                validity,
274            )),
275            (Column::Bool(v, _), DType::I64) => Ok(Column::i64_with(
276                v.iter().map(|&b| b as i64).collect(),
277                validity,
278            )),
279            (Column::Bool(v, _), DType::I32) => Ok(Column::i32_with(
280                v.iter().map(|&b| b as i32).collect(),
281                validity,
282            )),
283            _ => unreachable!("same-dtype is handled in cast"), // LCOV_EXCL_LINE
284        }
285    }
286
287    /// Parse a str column into a numeric target for explicit `astype` (Q2): a
288    /// present, non-empty cell parses (int target -> int literal, so `"1.5"`
289    /// raises; float target -> float literal), an empty / whitespace / missing
290    /// cell becomes NA, and any other non-empty string raises with the offending
291    /// value. Never a silent NaN (contract C4). `to` is numeric (caller guard).
292    fn cast_str_to_numeric(v: &StrBuffer, val: &Validity, to: DType) -> Result<Column> {
293        // The cells we parse: present AND not blank. Blank / missing cells are NA
294        // in the result (an int target carries this in its validity; a float
295        // target writes NaN).
296        let parse_me: Vec<bool> = (0..v.len())
297            .map(|i| val.is_valid(i) && !v.get(i).trim().is_empty())
298            .collect();
299        let bad = |i: usize, target: &str| {
300            VolasError::Value(format!(
301                "cannot convert string {:?} to {target} (astype)",
302                v.get(i)
303            ))
304        };
305        match to {
306            DType::F64 | DType::F32 => {
307                let mut out = vec![f64::NAN; v.len()];
308                for (i, slot) in out.iter_mut().enumerate() {
309                    if parse_me[i] {
310                        *slot = v.get(i).trim().parse::<f64>().map_err(|_| bad(i, "float64"))?;
311                    }
312                }
313                Ok(if to == DType::F32 {
314                    Column::f32(out.iter().map(|&x| x as f32).collect())
315                } else {
316                    Column::f64(out)
317                })
318            }
319            // int targets: parse an integer literal (no funnel, so "1.5" raises
320            // rather than truncating), carrying the blank/missing cells as NA.
321            _ => {
322                let validity = Validity::from_valid_iter(v.len(), parse_me.iter().copied());
323                let mut out = vec![0i64; v.len()];
324                for (i, slot) in out.iter_mut().enumerate() {
325                    if parse_me[i] {
326                        *slot = v.get(i).trim().parse::<i64>().map_err(|_| bad(i, "int64"))?;
327                    }
328                }
329                if to == DType::I32 {
330                    let narrowed = out
331                        .iter()
332                        .enumerate()
333                        .map(|(i, &x)| {
334                            if parse_me[i] {
335                                i32::try_from(x).map_err(|_| bad(i, "int32"))
336                            } else {
337                                Ok(0)
338                            }
339                        })
340                        .collect::<Result<Vec<i32>>>()?;
341                    Ok(Column::i32_with(narrowed, validity))
342                } else {
343                    Ok(Column::i64_with(out, validity))
344                }
345            }
346        }
347    }
348}