Skip to main content

volas_core/
dataframe.rs

1//! DataFrame: ordered, named columns sharing a single row index.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use crate::column::Column;
7use crate::error::{Result, VolasError};
8use crate::index::{Index, IndexKind};
9use crate::series::Series;
10
11/// Metadata for a materialized (cached) directive column: the directive that
12/// produced it, its lookback, and how many leading rows currently hold valid
13/// values. After an `append`, the new rows are stale (NaN) and `valid_rows` lags
14/// `height` until `fulfill` recomputes the tail.
15#[derive(Clone, Debug)]
16pub struct ComputedMeta {
17    /// The (canonical) directive string.
18    pub directive: String,
19    /// The directive's lookback (warm-up rows).
20    pub lookback: usize,
21    /// Rows `[0, valid_rows)` currently hold valid values.
22    pub valid_rows: usize,
23    /// Carried recursive state for an O(new-rows) append resume: a small,
24    /// fixed-size per-indicator vector capturing the internal recursive state as
25    /// of the last valid row (`valid_rows - 1`), so an `append`/`fulfill` can
26    /// continue the recursion over only the new rows, bit-identical to a fresh
27    /// full recompute. `None` when the directive has no resume implementation (it
28    /// then falls back to the correct full recompute) or the state is unknown
29    /// (e.g. after a slice that did not reach the parent's `valid_rows`).
30    pub state: Option<Vec<f64>>,
31    /// The original-frame row that THIS (possibly sliced) frame's row 0 maps to.
32    /// `0` for a freshly-computed column; a contiguous slice from `start` bumps it
33    /// by `start`. It lets an absolute-position indicator (the index family —
34    /// maxindex/minindex/minmaxindex) keep emitting ABSOLUTE positions after a
35    /// head-dropping slice: a sub-frame position `p` is original row `p + origin`,
36    /// matching the verbatim-carried (original-absolute) head. Recursive *value*
37    /// indicators ignore it (their state is offset-free).
38    pub origin: usize,
39}
40
41/// A 2-D, column-oriented, time-indexed table. All columns share one index and
42/// have equal length (`height`).
43#[derive(Clone, Debug)]
44pub struct DataFrame {
45    // Schema (names + lookup) is `Arc`-shared so a frame clone / same-schema
46    // derivation (slice / take / mask / astype) is an O(1) refcount bump, not a
47    // rebuild of the name strings + hash map (copy-on-write on mutation).
48    names: Arc<Vec<String>>,
49    columns: Vec<Column>,
50    name_to_idx: Arc<HashMap<String, usize>>,
51    index: Arc<Index>,
52    height: usize,
53    /// Column-name aliases (`alias -> source name`), resolved on lookup. Shared
54    /// via `Arc` (cheap clone) and carried through derived frames.
55    aliases: Arc<HashMap<String, String>>,
56    /// Materialized directive columns (name -> meta). Tracked so `fulfill` can
57    /// incrementally recompute their tail after an append. Carried through
58    /// `clone` / `append`; dropped by shape-changing ops (slice/select/…), where
59    /// the columns become plain data.
60    computed: HashMap<String, ComputedMeta>,
61}
62
63impl DataFrame {
64    /// Construct a frame from parallel `names` / `columns`, validating shape.
65    pub fn new(names: Vec<String>, columns: Vec<Column>, index: Option<Index>) -> Result<Self> {
66        if names.len() != columns.len() {
67            return Err(VolasError::Shape(format!(
68                "{} names but {} columns",
69                names.len(),
70                columns.len()
71            )));
72        }
73        let height = columns.first().map(|c| c.len()).unwrap_or(0);
74        for (n, c) in names.iter().zip(&columns) {
75            if c.len() != height {
76                return Err(VolasError::Shape(format!(
77                    "column \"{}\" has length {} but frame height is {}",
78                    n,
79                    c.len(),
80                    height
81                )));
82            }
83        }
84        let index = match index {
85            Some(ix) => {
86                if ix.len() != height {
87                    return Err(VolasError::Shape(format!(
88                        "index length {} != frame height {}",
89                        ix.len(),
90                        height
91                    )));
92                }
93                ix
94            }
95            None => Index::range(height),
96        };
97        let mut name_to_idx = HashMap::with_capacity(names.len());
98        for (i, n) in names.iter().enumerate() {
99            name_to_idx.insert(n.clone(), i);
100        }
101        Ok(DataFrame {
102            names: Arc::new(names),
103            columns,
104            name_to_idx: Arc::new(name_to_idx),
105            index: Arc::new(index),
106            height,
107            aliases: Arc::new(HashMap::new()),
108            computed: HashMap::new(),
109        })
110    }
111
112    /// Number of rows.
113    pub fn height(&self) -> usize {
114        self.height
115    }
116
117    /// Number of columns.
118    pub fn width(&self) -> usize {
119        self.columns.len()
120    }
121
122    /// Column names in order.
123    pub fn names(&self) -> &[String] {
124        &self.names
125    }
126
127    /// The shared row index.
128    pub fn index(&self) -> &Arc<Index> {
129        &self.index
130    }
131
132    /// Columns in order.
133    pub fn columns(&self) -> &[Column] {
134        &self.columns
135    }
136
137    /// Resolve a name through the alias map (`alias -> source`, else itself).
138    fn resolve<'a>(&'a self, name: &'a str) -> &'a str {
139        self.aliases.get(name).map(String::as_str).unwrap_or(name)
140    }
141
142    /// Position of a column by name (alias-aware).
143    pub fn column_pos(&self, name: &str) -> Option<usize> {
144        self.name_to_idx.get(self.resolve(name)).copied()
145    }
146
147    /// Whether a column exists (alias-aware).
148    pub fn has_column(&self, name: &str) -> bool {
149        self.name_to_idx.contains_key(self.resolve(name))
150    }
151
152    /// Define a column alias (`as_name -> src_name`), returning a new frame.
153    /// Errors if `as_name` is already a real column, or `src_name` does not exist.
154    pub fn with_alias(&self, as_name: &str, src_name: &str) -> Result<DataFrame> {
155        if self.name_to_idx.contains_key(as_name) {
156            return Err(VolasError::Value(format!(
157                "column \"{as_name}\" already exists"
158            )));
159        }
160        if self.column_pos(src_name).is_none() {
161            return Err(VolasError::Value(format!(
162                "column \"{src_name}\" not exists"
163            )));
164        }
165        let mut aliases = (*self.aliases).clone();
166        aliases.insert(as_name.to_string(), src_name.to_string());
167        let mut df = self.clone();
168        df.aliases = Arc::new(aliases);
169        Ok(df)
170    }
171
172    /// Build a frame that **shares this frame's schema** (names + lookup +
173    /// aliases, all `Arc`-cloned) over freshly derived `columns` / `index` — for
174    /// the same-shape derivations (slice / take / mask / astype), with no
175    /// name-string or hash-map rebuild. Computed-column status is dropped; the
176    /// caller re-attaches it where the derivation preserves it (a contiguous slice).
177    fn same_schema(&self, columns: Vec<Column>, index: Index) -> DataFrame {
178        let height = columns.first().map_or(0, |c| c.len());
179        DataFrame {
180            names: Arc::clone(&self.names),
181            name_to_idx: Arc::clone(&self.name_to_idx),
182            columns,
183            index: Arc::new(index),
184            height,
185            aliases: Arc::clone(&self.aliases),
186            computed: HashMap::new(),
187        }
188    }
189
190    /// Gather rows by position into a new frame (carries aliases).
191    pub fn take(&self, positions: &[usize]) -> DataFrame {
192        let columns: Vec<Column> = self.columns.iter().map(|c| c.take(positions)).collect();
193        self.same_schema(columns, self.index.take(positions))
194    }
195
196    /// Borrow a column by name.
197    pub fn column(&self, name: &str) -> Result<&Column> {
198        self.column_pos(name)
199            .map(|i| &self.columns[i])
200            .ok_or_else(|| VolasError::ColumnNotFound(name.to_string()))
201    }
202
203    /// Extract a column as a [`Series`] sharing this frame's index.
204    pub fn series(&self, name: &str) -> Result<Series> {
205        let col = self.column(name)?.clone();
206        Ok(Series::new(
207            Some(name.to_string()),
208            col,
209            Arc::clone(&self.index),
210        ))
211    }
212
213    /// Add a new column or replace an existing one (must match `height`, unless
214    /// the frame currently has no columns).
215    pub fn set_column(&mut self, name: &str, col: Column) -> Result<()> {
216        if self.columns.is_empty() {
217            self.height = col.len();
218            if self.index.len() != self.height {
219                self.index = Arc::new(Index::range(self.height));
220            }
221        } else if col.len() != self.height {
222            return Err(VolasError::Shape(format!(
223                "new column \"{}\" has length {} but frame height is {}",
224                name,
225                col.len(),
226                self.height
227            )));
228        }
229        match self.column_pos(name) {
230            Some(i) => self.columns[i] = col,
231            None => {
232                Arc::make_mut(&mut self.name_to_idx).insert(name.to_string(), self.columns.len());
233                Arc::make_mut(&mut self.names).push(name.to_string());
234                self.columns.push(col);
235            }
236        }
237        Ok(())
238    }
239
240    /// Move a column out of the frame and use it as the row index (pandas
241    /// `set_index`). The column is removed; its values become the index
242    /// (datetime / int64 — see [`Index::from_column`]).
243    pub fn set_index(&self, name: &str) -> Result<DataFrame> {
244        let pos = self
245            .column_pos(name)
246            .ok_or_else(|| VolasError::ColumnNotFound(name.to_string()))?;
247        // Record the source column's name on the index (pandas keeps it, so
248        // `reset_index` can restore the original column label).
249        let index = Index::from_column(&self.columns[pos])?.with_name(Some(name.to_string()));
250        let mut names = (*self.names).clone();
251        let mut columns = self.columns.clone();
252        names.remove(pos);
253        columns.remove(pos);
254        let mut df = DataFrame::new(names, columns, Some(index))?;
255        df.aliases = Arc::clone(&self.aliases);
256        Ok(df)
257    }
258
259    /// Change the DatetimeIndex's **display / matching** timezone without moving
260    /// any instant (pandas `tz_convert`): stored UTC ns are unchanged; only how
261    /// they render and how bare-string `.loc` matches changes. Returns a new frame
262    /// (columns shared). Errors if the index is not a DatetimeIndex.
263    pub fn tz_convert(&self, tz: crate::tz::Tz) -> Result<DataFrame> {
264        match self.index.kind() {
265            IndexKind::Datetime(_, cur) => {
266                // A naive axis is an unanchored wall-clock — there is no source
267                // zone to convert FROM, so converting it would silently relabel
268                // wrong instants. Anchor with tz_localize first (pandas parity).
269                if !cur.is_aware() {
270                    return Err(VolasError::DType(
271                        "cannot tz_convert a tz-naive DatetimeIndex; use tz_localize to anchor it first"
272                            .into(),
273                    ));
274                }
275                let mut df = self.clone();
276                df.index = Arc::new((*self.index).clone().with_tz(tz));
277                Ok(df)
278            }
279            _ => Err(VolasError::DType(
280                "tz_convert requires a DatetimeIndex".into(),
281            )),
282        }
283    }
284
285    /// Tag the DatetimeIndex's zone directly, without the naive-axis guard of
286    /// [`Self::tz_convert`]. For importers (`from_pandas`) whose instants are
287    /// ALREADY true UTC and arrive carrying their zone — not a user-facing API.
288    pub fn set_index_tz(&self, tz: crate::tz::Tz) -> Result<DataFrame> {
289        match self.index.kind() {
290            IndexKind::Datetime(_, _) => {
291                let mut df = self.clone();
292                df.index = Arc::new((*self.index).clone().with_tz(tz));
293                Ok(df)
294            }
295            _ => Err(VolasError::DType(
296                "set_index_tz requires a DatetimeIndex".into(),
297            )),
298        }
299    }
300
301    /// Reinterpret the index's **wall-clock** as `tz` (pandas `tz_localize`): each
302    /// instant is recomputed so the displayed wall-clock is unchanged but now
303    /// correct for `tz`. Use this when data was ingested without a tz and you need
304    /// to attach the right one. Returns a new frame. Errors if the index is not a
305    /// DatetimeIndex or a wall-clock does not exist in `tz` (a DST spring-forward
306    /// gap).
307    pub fn tz_localize(&self, tz: crate::tz::Tz) -> Result<DataFrame> {
308        let (values, cur) = match self.index.kind() {
309            IndexKind::Datetime(v, cur) => (v.clone(), *cur),
310            _ => {
311                return Err(VolasError::DType(
312                    "tz_localize requires a DatetimeIndex".into(),
313                ))
314            }
315        };
316        // Localize anchors an UNanchored wall-clock; an already-aware axis must
317        // use tz_convert (re-localizing would silently reinterpret instants).
318        if cur.is_aware() {
319            return Err(VolasError::DType(format!(
320                "index is already tz-aware ({}); use tz_convert",
321                cur.name()
322            )));
323        }
324        let mut shifted = Vec::with_capacity(values.len());
325        for ns in values {
326            let (y, mo, d, h, mi, s) = cur.civil_parts(ns);
327            let new = tz
328                .wall_to_utc_ns(y as i32, mo as u32, d as u32, h as u32, mi as u32, s as u32)
329                .ok_or_else(|| {
330                    VolasError::Value(format!(
331                        "wall-clock {y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{s:02} does not exist in {} (or is DST-ambiguous)",
332                        tz.name()
333                    ))
334                })?;
335            shifted.push(new);
336        }
337        let mut df = self.clone();
338        // tz_localize moves the instants but keeps the index identity (and name).
339        df.index = Arc::new(Index::datetime(shifted, tz).with_name(self.index.name().map(String::from)));
340        Ok(df)
341    }
342
343    /// Select a subset of columns into a new frame sharing this index.
344    pub fn select(&self, names: &[String]) -> Result<DataFrame> {
345        let mut columns = Vec::with_capacity(names.len());
346        for n in names {
347            columns.push(self.column(n)?.clone());
348        }
349        let mut name_to_idx = HashMap::with_capacity(names.len());
350        for (i, n) in names.iter().enumerate() {
351            name_to_idx.insert(n.clone(), i);
352        }
353        Ok(DataFrame {
354            names: Arc::new(names.to_vec()),
355            columns,
356            name_to_idx: Arc::new(name_to_idx),
357            index: Arc::clone(&self.index),
358            height: self.height,
359            aliases: Arc::clone(&self.aliases),
360            computed: HashMap::new(),
361        })
362    }
363
364    /// A `[start, end)` row slice.
365    ///
366    /// Deliberately a **value copy** (each column's window is copied), not a
367    /// zero-copy view into the parent buffer: a slice is an independent frame, so
368    /// slicing the recent tail of a long history does not pin the whole history
369    /// alive — the right default for a live system. (A view would be ~1.5x faster
370    /// here but would retain the parent's full buffer; we keep the safer copy.)
371    pub fn slice(&self, start: usize, end: usize) -> DataFrame {
372        let start = start.min(self.height);
373        let end = end.max(start).min(self.height);
374        let len = end - start;
375        let columns: Vec<Column> = self.columns.iter().map(|c| c.slice(start, end)).collect();
376        let mut df = self.same_schema(columns, self.index.slice(start, end));
377        // SP-9: carry cached-directive columns *as continuable computed columns*
378        // through a contiguous slice. The cached values are already correct (they
379        // were computed with full history) and are carried verbatim; we re-tag the
380        // `ComputedMeta` cursor so a later `append` refreshes the tail incrementally
381        // — re-deriving it from the retained raw columns over a `lookback` window,
382        // exactly as a non-sliced frame would (the engine re-warms from raw data,
383        // never from cached output, so composite recursive indicators continue
384        // correctly too). This is only sound when the slice keeps at least
385        // `lookback` warm-up rows; a shorter slice would re-warm from its own start
386        // (a seed that is *not* `lookback` rows back) and silently diverge, so there
387        // we drop the computed status and the column stays plain data (honest:
388        // values correct, but not continuable). Non-contiguous derivations
389        // (`take` / `filter_mask`) go through `DataFrame::new` and already drop it.
390        for (name, meta) in &self.computed {
391            if len >= meta.lookback {
392                let valid = meta.valid_rows.saturating_sub(start).min(len);
393                // Carry the recursive state only when this slice's END reaches the
394                // parent's `valid_rows`: the captured state is the internal state as
395                // of the parent row `valid_rows - 1`, which is THIS sub-frame's last
396                // valid row exactly when `start + len >= valid_rows` (so `valid` ==
397                // the parent's last-valid offset). A shorter slice (end before
398                // `valid_rows`) would leave the state attached to a row the sub-frame
399                // no longer ends on, so we drop it (the column stays correct via the
400                // full-recompute fallback, just not O(new-rows) continuable).
401                let carried = if end >= meta.valid_rows {
402                    meta.state.clone()
403                } else {
404                    None
405                };
406                df.computed.insert(
407                    name.clone(),
408                    ComputedMeta {
409                        directive: meta.directive.clone(),
410                        lookback: meta.lookback,
411                        valid_rows: valid,
412                        state: carried,
413                        // This sub-frame's row 0 is the parent's row `start`, so its
414                        // origin shifts by `start` (an absolute-index resume adds it
415                        // back to stay original-absolute, matching the carried head).
416                        origin: meta.origin + start,
417                    },
418                );
419            }
420        }
421        df
422    }
423
424    /// Filter rows by a boolean mask.
425    pub fn filter_mask(&self, mask: &[bool]) -> Result<DataFrame> {
426        if mask.len() != self.height {
427            return Err(VolasError::Shape(format!(
428                "boolean mask length {} != frame height {}",
429                mask.len(),
430                self.height
431            )));
432        }
433        let idx: Vec<usize> = mask
434            .iter()
435            .enumerate()
436            .filter_map(|(i, &b)| if b { Some(i) } else { None })
437            .collect();
438        let columns: Vec<Column> = self.columns.iter().map(|c| c.take(&idx)).collect();
439        Ok(self.same_schema(columns, self.index.take(&idx)))
440    }
441
442    /// Append the rows of `other` (matched by column name) in place. Columns of
443    /// `self` absent from `other` are NaN-padded (so a frame with materialized
444    /// directive columns can take raw bars; `fulfill` then refreshes them).
445    /// Computed-column metadata is retained, leaving the new rows stale.
446    pub fn append(&mut self, other: &DataFrame) -> Result<()> {
447        let oh = other.height;
448        // Iterate by position to avoid cloning every column name and then
449        // re-hashing it back into this same frame on the live append path.
450        for pos in 0..self.names.len() {
451            let n = &self.names[pos];
452            if let Some(other_pos) = other.column_pos(n) {
453                self.columns[pos].append(&other.columns[other_pos])?;
454            } else {
455                // column `n` is missing from `other` — pad the new rows.
456                if self.computed.contains_key(n) {
457                    // A cached directive (F64 indicator / Bool mask): a cheap stale
458                    // placeholder (NaN / `false`); `fulfill` recomputes and overwrites
459                    // the appended tail, so a dense placeholder keeps validity simple.
460                    self.columns[pos].append_missing(oh)?;
461                } else {
462                    // A plain column keeps its data semantics: pad with dtype-preserving
463                    // NA (int / bool / str grow the validity bitmap; datetime -> NaT;
464                    // float -> NaN), never upcasting the dtype or erroring.
465                    self.columns[pos].append_na(oh);
466                }
467            }
468        }
469        Arc::make_mut(&mut self.index).extend(&other.index)?;
470        self.height += oh;
471        Ok(())
472    }
473
474    /// Assign `values` into column position `col` at the given row `positions`
475    /// (copy-on-write via [`Arc::make_mut`]). `values` is broadcast when it has
476    /// length 1, otherwise its length must equal `positions.len()`. This backs
477    /// `df.loc[...] = `, `df.iloc[...] = `, `df.at[...] = ` and `df.iat[...] = `.
478    ///
479    /// Dtype handling is delegated to [`Column::scatter`], the single assignment
480    /// primitive shared with the Series and boolean-mask surfaces: it **keeps the
481    /// target column's dtype** and updates its validity (a write into an existing NA
482    /// cell makes it present; a missing / `NaN` source marks the cell NA without
483    /// widening an int column to float; a present non-integral value into an int
484    /// column is a lossy error).
485    ///
486    /// A manual write into a cached directive column **drops its computed status**
487    /// (it becomes plain data) so a later `fulfill` can never silently clobber the
488    /// override.
489    pub fn assign_positions(
490        &mut self,
491        col: usize,
492        positions: &[usize],
493        values: &Column,
494    ) -> Result<()> {
495        if col >= self.columns.len() {
496            return Err(VolasError::Shape(format!(
497                "column position {col} is out of range (width {})",
498                self.columns.len()
499            )));
500        }
501        let n = positions.len();
502        if values.len() != 1 && values.len() != n {
503            return Err(VolasError::Shape(format!(
504                "cannot assign {} values to {n} selected rows",
505                values.len()
506            )));
507        }
508        for &p in positions {
509            if p >= self.height {
510                return Err(VolasError::Shape(format!(
511                    "row position {p} is out of range (height {})",
512                    self.height
513                )));
514            }
515        }
516        self.columns[col] = self.columns[col].scatter(positions, values)?;
517        self.invalidate_computed_on_write_at(col);
518        Ok(())
519    }
520
521    /// Rename columns (pandas `rename(columns=...)`), returning a new frame.
522    /// Names not in `mapping` are kept; columns and index are shared (cheap).
523    pub fn rename(&self, mapping: &HashMap<String, String>) -> Result<DataFrame> {
524        let names: Vec<String> = self
525            .names
526            .iter()
527            .map(|n| mapping.get(n).cloned().unwrap_or_else(|| n.clone()))
528            .collect();
529        let mut df = DataFrame::new(names, self.columns.clone(), Some((*self.index).clone()))?;
530        df.aliases = Arc::clone(&self.aliases);
531        Ok(df)
532    }
533
534    /// Cast the named columns to new dtypes (pandas `astype`), returning a new
535    /// frame. Untouched columns are shared (cheap).
536    pub fn astype(&self, mapping: &HashMap<String, crate::dtype::DType>) -> Result<DataFrame> {
537        let mut columns = self.columns.clone();
538        for (name, dtype) in mapping {
539            let pos = self
540                .column_pos(name)
541                .ok_or_else(|| VolasError::ColumnNotFound(name.clone()))?;
542            columns[pos] = self.columns[pos].cast(*dtype)?;
543        }
544        Ok(self.same_schema(columns, (*self.index).clone()))
545    }
546
547    /// Value equality (pandas `DataFrame.equals`): same column names + order,
548    /// same index *labels*, and value-equal columns (`NaN == NaN`). The index
549    /// *name* is metadata and is ignored, matching pandas (`.equals` ignores it).
550    pub fn equals(&self, other: &DataFrame) -> bool {
551        self.height == other.height
552            && self.names == other.names
553            && self.index.label_eq(&other.index)
554            && self
555                .columns
556                .iter()
557                .zip(&other.columns)
558                .all(|(a, b)| a.equals(b))
559    }
560
561    /// Flatten to a row-major (C-order) 2-D `f64` buffer for NumPy export,
562    /// returning `(data, height, width)`. Each column is materialized through the
563    /// validity-aware `to_f64_vec`, so a missing cell (int/bool NA, datetime NaT,
564    /// str) exports as `NaN` — not the raw placeholder — matching the 1-D
565    /// `Series` export and `pandas` `Int64.to_numpy()`.
566    pub fn to_row_major_f64(&self) -> (Vec<f64>, usize, usize) {
567        let h = self.height;
568        let w = self.columns.len();
569        let mut out = vec![0.0f64; h * w];
570        for (j, c) in self.columns.iter().enumerate() {
571            let col = c.to_f64_vec();
572            for i in 0..h {
573                out[i * w + j] = col[i];
574            }
575        }
576        (out, h, w)
577    }
578
579    /// Flatten to a row-major (C-order) 2-D `i64` buffer for an **exact** integer
580    /// (or `datetime64[ns]`) NumPy export, returning `(data, height, width)`. A
581    /// datetime column contributes its raw epoch-ns — so sub-2⁵³ ns and `NaT`
582    /// (which stays `i64::MIN`, the datetime64 sentinel) survive, unlike the
583    /// `to_row_major_f64` channel — and an `i64` column its exact value (no
584    /// float round-trip past 2⁵³). A float column truncates toward zero. A `str`
585    /// column has no integer meaning; the export boundary rejects it before
586    /// calling this, so it contributes a `0` placeholder it never reaches.
587    pub fn to_row_major_i64(&self) -> (Vec<i64>, usize, usize) {
588        let h = self.height;
589        let w = self.columns.len();
590        let mut out = vec![0i64; h * w];
591        for (j, c) in self.columns.iter().enumerate() {
592            for i in 0..h {
593                out[i * w + j] = match c {
594                    Column::Datetime(v) => v[i],
595                    Column::I64(v, _) => v[i],
596                    Column::I32(v, _) => v[i] as i64,
597                    Column::Bool(v, _) => v[i] as i64,
598                    Column::F64(v) => v[i] as i64,
599                    Column::F32(v) => v[i] as i64,
600                    Column::Str(..) => 0,
601                };
602            }
603        }
604        (out, h, w)
605    }
606}
607
608mod computed;
609
610#[cfg(test)]
611mod tests;