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}