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