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;