plot3 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
plot3/table.py ADDED
@@ -0,0 +1,775 @@
1
+ """Multi-backend table adapters: pandas, polars, tidy3, NumPy arrays.
2
+
3
+ Design rules
4
+ ------------
5
+ * Keep the original table type as long as possible (do not normalize at input).
6
+ * Stats / transforms run in the table backend (``count_by``, ``group_keys``, …).
7
+ * Rendering only needs plain arrays: call ``materialize_columns`` /
8
+ ``to_arrays`` at the build boundary for the selected columns.
9
+
10
+ Backends
11
+ --------
12
+ * ``"pandas"`` — ``pandas.DataFrame``
13
+ * ``"polars"`` — ``polars.DataFrame`` or ``polars.LazyFrame``
14
+ * ``"tidy"`` — tidy3 ``TidyFrame`` (resolved to polars lazily)
15
+ * ``"array"`` — :class:`ArrayTable` over a 2D NumPy array (columns ``\"0\"``…)
16
+ * plain mappings / records fall back to a pandas DataFrame
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any, Literal
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+
26
+ Backend = Literal["pandas", "polars", "tidy", "array"]
27
+
28
+ _POLARS_TYPES: tuple[type, ...] | None = None
29
+ _POLARS_READY = False
30
+
31
+
32
+ class ArrayTable:
33
+ """Columnar view of a 2D NumPy array for ``ggplot`` / table adapters.
34
+
35
+ Columns are named ``\"0\"``, ``\"1\"``, … so aesthetics can use integer
36
+ positions::
37
+
38
+ ggplot(points, aes(x=0, y=1, z=2, colour=3)) + geom_point3d()
39
+
40
+ 1D inputs are expanded by :func:`as_table` to ``[index, values]`` so
41
+ ``aes(x=0, y=1)`` plots the series against its row index.
42
+ """
43
+
44
+ __slots__ = ("_data",)
45
+
46
+ def __init__(self, data: np.ndarray):
47
+ arr = np.asarray(data)
48
+ if arr.ndim != 2:
49
+ raise ValueError(
50
+ f"ArrayTable requires a 2D array, got shape {arr.shape}"
51
+ )
52
+ if arr.shape[1] < 1:
53
+ raise ValueError("ArrayTable requires at least one column")
54
+ # Keep a contiguous copy only when needed; do not force float.
55
+ self._data = np.ascontiguousarray(arr)
56
+
57
+ @property
58
+ def values(self) -> np.ndarray:
59
+ return self._data
60
+
61
+ @property
62
+ def shape(self) -> tuple[int, int]:
63
+ return int(self._data.shape[0]), int(self._data.shape[1])
64
+
65
+ @property
66
+ def columns(self) -> list[str]:
67
+ return [str(i) for i in range(self._data.shape[1])]
68
+
69
+ def __len__(self) -> int:
70
+ return int(self._data.shape[0])
71
+
72
+ def column(self, name: str | int) -> np.ndarray:
73
+ """Return a 1D view/copy of column *name* (``\"0\"`` or ``0``)."""
74
+ return self._data[:, _array_col_index(name, self._data.shape[1])]
75
+
76
+ def select(self, cols: list[str]) -> "ArrayTable":
77
+ idxs = [_array_col_index(c, self._data.shape[1]) for c in cols]
78
+ return ArrayTable(self._data[:, idxs])
79
+
80
+ def take_rows(self, indices: np.ndarray) -> "ArrayTable":
81
+ return ArrayTable(self._data[np.asarray(indices, dtype=np.intp)])
82
+
83
+ def subsample(self, step: int) -> "ArrayTable":
84
+ step = max(1, int(step))
85
+ if step == 1:
86
+ return self
87
+ return ArrayTable(self._data[::step])
88
+
89
+
90
+ def _array_col_index(name: str | int, n_cols: int) -> int:
91
+ try:
92
+ idx = int(name)
93
+ except (TypeError, ValueError) as e:
94
+ raise KeyError(
95
+ f"array columns are positional (\"0\", \"1\", …); got {name!r}"
96
+ ) from e
97
+ if idx < 0 or idx >= n_cols:
98
+ raise KeyError(
99
+ f"column(s) not in DataFrame: {[str(name)]} "
100
+ f"(array has {n_cols} column(s) indexed 0..{n_cols - 1})"
101
+ )
102
+ return idx
103
+
104
+
105
+ def _polars_types() -> tuple[type, ...] | None:
106
+ """Lazy import of polars frame types (optional dependency)."""
107
+ global _POLARS_TYPES, _POLARS_READY
108
+ if _POLARS_READY:
109
+ return _POLARS_TYPES
110
+ _POLARS_READY = True
111
+ try:
112
+ import polars as pl
113
+
114
+ _POLARS_TYPES = (pl.DataFrame, pl.LazyFrame)
115
+ except ImportError:
116
+ _POLARS_TYPES = None
117
+ return _POLARS_TYPES
118
+
119
+
120
+ def _is_polars(data: Any) -> bool:
121
+ types = _polars_types()
122
+ return types is not None and isinstance(data, types)
123
+
124
+
125
+ def _is_tidy(data: Any) -> bool:
126
+ """Duck-type tidy3.TidyFrame without importing tidy3."""
127
+ if data is None:
128
+ return False
129
+ cls = type(data)
130
+ mod = getattr(cls, "__module__", "") or ""
131
+ name = cls.__name__
132
+ if name == "TidyFrame" and ("tidy3" in mod or mod == "tidy3"):
133
+ return True
134
+ # Fallback: tidy frames expose backend + to_polars / collect
135
+ if name == "TidyFrame" and hasattr(data, "to_polars") and hasattr(data, "columns"):
136
+ return True
137
+ return False
138
+
139
+
140
+ def _is_array_table(data: Any) -> bool:
141
+ return isinstance(data, ArrayTable)
142
+
143
+
144
+ def _is_numpy_array(data: Any) -> bool:
145
+ return isinstance(data, np.ndarray)
146
+
147
+
148
+ def detect_backend(data: Any) -> Backend:
149
+ """Return the table backend name for *data*."""
150
+ if isinstance(data, pd.DataFrame):
151
+ return "pandas"
152
+ if _is_array_table(data):
153
+ return "array"
154
+ if _is_numpy_array(data):
155
+ # Raw ndarray should have been wrapped by as_table; still detect.
156
+ return "array"
157
+ if _is_polars(data):
158
+ return "polars"
159
+ if _is_tidy(data):
160
+ return "tidy"
161
+ raise TypeError(
162
+ f"unsupported table type {type(data).__name__!r}; "
163
+ "expected pandas DataFrame, polars DataFrame/LazyFrame, "
164
+ "tidy3 TidyFrame, NumPy ndarray, or a mapping/records constructor"
165
+ )
166
+
167
+
168
+ def as_table(data: Any) -> Any:
169
+ """Keep known table types; wrap arrays; wrap plain constructors as pandas.
170
+
171
+ * pandas / polars / tidy3 / :class:`ArrayTable` — returned unchanged
172
+ (ndarrays are normalized into :class:`ArrayTable`).
173
+ * 1D ndarray — expanded to columns ``0`` = row index, ``1`` = values
174
+ * 2D ndarray — columns ``0`` … ``k-1``
175
+ * mappings / records — ``pandas.DataFrame``
176
+ """
177
+ if data is None:
178
+ return None
179
+ if isinstance(data, pd.DataFrame):
180
+ return data
181
+ if _is_array_table(data):
182
+ return data
183
+ if _is_numpy_array(data):
184
+ return _normalize_ndarray(data)
185
+ if _is_polars(data) or _is_tidy(data):
186
+ return data
187
+ try:
188
+ detect_backend(data)
189
+ return data
190
+ except TypeError:
191
+ pass
192
+ return pd.DataFrame(data)
193
+
194
+
195
+ def _normalize_ndarray(data: np.ndarray) -> ArrayTable:
196
+ arr = np.asarray(data)
197
+ if arr.ndim == 0:
198
+ raise TypeError("ggplot() does not accept 0-D NumPy arrays")
199
+ if arr.ndim == 1:
200
+ n = int(arr.shape[0])
201
+ # index + values → aes(x=0, y=1)
202
+ stacked = np.column_stack(
203
+ [
204
+ np.arange(n, dtype=np.float64),
205
+ np.asarray(arr, dtype=np.float64),
206
+ ]
207
+ )
208
+ return ArrayTable(stacked)
209
+ if arr.ndim == 2:
210
+ return ArrayTable(arr)
211
+ raise TypeError(
212
+ f"ggplot() NumPy arrays must be 1D or 2D, got shape {arr.shape}"
213
+ )
214
+
215
+
216
+ def _as_array_table(data: Any) -> ArrayTable:
217
+ if isinstance(data, ArrayTable):
218
+ return data
219
+ if isinstance(data, np.ndarray):
220
+ return _normalize_ndarray(data)
221
+ raise TypeError(f"expected ArrayTable or ndarray, got {type(data).__name__}")
222
+
223
+
224
+ def get_columns(data: Any) -> list[str]:
225
+ """Column names as ``list[str]``."""
226
+ backend = detect_backend(data)
227
+ if backend == "pandas":
228
+ return [str(c) for c in data.columns]
229
+ if backend == "array":
230
+ return _as_array_table(data).columns
231
+ if backend == "polars":
232
+ import polars as pl
233
+
234
+ if isinstance(data, pl.LazyFrame):
235
+ return list(data.collect_schema().names())
236
+ return list(data.columns)
237
+ # tidy
238
+ cols = getattr(data, "columns", None)
239
+ if cols is not None:
240
+ return list(cols)
241
+ return list(data.to_polars().columns)
242
+
243
+
244
+ def n_rows(data: Any) -> int:
245
+ """Number of rows (may materialize a count for lazy frames)."""
246
+ backend = detect_backend(data)
247
+ if backend == "pandas":
248
+ return int(len(data))
249
+ if backend == "array":
250
+ return len(_as_array_table(data))
251
+ if backend == "polars":
252
+ import polars as pl
253
+
254
+ if isinstance(data, pl.LazyFrame):
255
+ return int(data.select(pl.len()).collect().item())
256
+ return int(data.height)
257
+ # tidy
258
+ return int(len(data))
259
+
260
+
261
+ class ColumnNotFound(KeyError):
262
+ """A column named in ``aes()`` (or a facet) that the data does not have.
263
+
264
+ The message lists the data's columns and the nearest match, so a typo
265
+ reads as one.
266
+ """
267
+
268
+ def __init__(self, missing, data: Any = None):
269
+ import difflib
270
+
271
+ self.missing = [str(m) for m in missing]
272
+ try:
273
+ names = [str(c) for c in get_columns(data)] if data is not None else []
274
+ except Exception:
275
+ names = []
276
+ parts = []
277
+ for name in self.missing:
278
+ text = f"No column {name!r} in the data."
279
+ guess = difflib.get_close_matches(name, names, n=1)
280
+ if guess:
281
+ text += f" Did you mean {guess[0]!r}?"
282
+ elif any(ch in name for ch in " +-*/()^<>=!"):
283
+ text += (
284
+ " As an expression, aes() reads columns, numbers, + - * / ^, "
285
+ "comparisons, and log, log10, log2, exp, sqrt, abs, round, "
286
+ "floor, ceiling, factor, as.numeric, ifelse, pmin, pmax, "
287
+ "mean, median, sd, min, max, sum."
288
+ )
289
+ parts.append(text)
290
+ if names:
291
+ shown = ", ".join(names[:20]) + (", …" if len(names) > 20 else "")
292
+ parts.append(f"The columns are: {shown}.")
293
+ super().__init__(" ".join(parts))
294
+
295
+ def __str__(self) -> str:
296
+ return str(self.args[0])
297
+
298
+
299
+ def has_column(data: Any, name: str) -> bool:
300
+ name = str(name)
301
+ if detect_backend(data) == "array":
302
+ try:
303
+ _array_col_index(name, _as_array_table(data).shape[1])
304
+ return True
305
+ except KeyError:
306
+ return False
307
+ return name in get_columns(data)
308
+
309
+
310
+ def resolve_polars(data: Any):
311
+ """Materialize tidy / LazyFrame to an eager polars DataFrame."""
312
+ import polars as pl
313
+
314
+ backend = detect_backend(data)
315
+ if backend == "tidy":
316
+ to_polars = getattr(data, "to_polars", None)
317
+ if callable(to_polars):
318
+ return to_polars()
319
+ collect = getattr(data, "collect", None)
320
+ if callable(collect):
321
+ out = collect(as_="polars")
322
+ if isinstance(out, pl.DataFrame):
323
+ return out
324
+ raise TypeError("tidy frame could not be resolved to polars")
325
+ if backend == "polars":
326
+ if isinstance(data, pl.LazyFrame):
327
+ return data.collect()
328
+ return data
329
+ raise TypeError("resolve_polars() expects a polars or tidy table")
330
+
331
+
332
+ def _polars_to_pandas(frame) -> pd.DataFrame:
333
+ """Convert a polars DataFrame without requiring pyarrow."""
334
+ try:
335
+ return frame.to_pandas()
336
+ except ModuleNotFoundError:
337
+ # pyarrow missing — build column-by-column from Python/NumPy values
338
+ data = {}
339
+ for name in frame.columns:
340
+ series = frame.get_column(name)
341
+ try:
342
+ data[name] = series.to_numpy(allow_copy=True)
343
+ except Exception:
344
+ data[name] = series.to_list()
345
+ return pd.DataFrame(data)
346
+
347
+
348
+ def as_pandas(data: Any) -> pd.DataFrame:
349
+ """Escape hatch: full conversion to pandas (for non-migrated stats)."""
350
+ backend = detect_backend(data)
351
+ if backend == "pandas":
352
+ return data
353
+ if backend == "array":
354
+ at = _as_array_table(data)
355
+ return pd.DataFrame(
356
+ {c: at.column(c) for c in at.columns},
357
+ copy=False,
358
+ )
359
+ if backend == "polars":
360
+ return _polars_to_pandas(resolve_polars(data))
361
+ # tidy
362
+ to_pandas = getattr(data, "to_pandas", None)
363
+ if callable(to_pandas):
364
+ try:
365
+ out = to_pandas()
366
+ if isinstance(out, pd.DataFrame):
367
+ return out
368
+ except ModuleNotFoundError:
369
+ pass
370
+ return _polars_to_pandas(resolve_polars(data))
371
+
372
+
373
+ def select_cols(data: Any, cols: list[str]):
374
+ """Project columns; return same backend type when possible."""
375
+ cols = [str(c) for c in cols]
376
+ missing = [c for c in cols if not has_column(data, c)]
377
+ if missing:
378
+ raise ColumnNotFound(missing, data)
379
+ backend = detect_backend(data)
380
+ if backend == "pandas":
381
+ return data.loc[:, list(cols)]
382
+ if backend == "array":
383
+ return _as_array_table(data).select(cols)
384
+ if backend == "polars":
385
+ import polars as pl
386
+
387
+ if isinstance(data, pl.LazyFrame):
388
+ return data.select(cols)
389
+ return data.select(cols)
390
+ # tidy — project via polars materialization of selected columns
391
+ to_polars = getattr(data, "to_polars", None)
392
+ if callable(to_polars):
393
+ try:
394
+ return to_polars(columns=cols)
395
+ except TypeError:
396
+ return resolve_polars(data).select(cols)
397
+ return resolve_polars(data).select(cols)
398
+
399
+
400
+ def subsample_rows(data: Any, step: int):
401
+ """Deterministic row stride (for ``coord_3d(max_points=…)``)."""
402
+ step = max(1, int(step))
403
+ if step == 1:
404
+ return data
405
+ backend = detect_backend(data)
406
+ if backend == "pandas":
407
+ return data.iloc[::step].copy()
408
+ if backend == "array":
409
+ return _as_array_table(data).subsample(step)
410
+ if backend == "polars":
411
+ import polars as pl
412
+
413
+ frame = resolve_polars(data)
414
+ return frame.with_row_index("_plot3_i").filter(
415
+ (pl.col("_plot3_i") % step) == 0
416
+ ).drop("_plot3_i")
417
+ # tidy → polars subsample (keeps plot path on polars)
418
+ frame = resolve_polars(data)
419
+ import polars as pl
420
+
421
+ return frame.with_row_index("_plot3_i").filter(
422
+ (pl.col("_plot3_i") % step) == 0
423
+ ).drop("_plot3_i")
424
+
425
+
426
+ def count_by(data: Any, x: str):
427
+ """Count rows by discrete *x*; return a table with columns ``[x, "y"]``.
428
+
429
+ * pandas → ``groupby(...).size()``
430
+ * polars → ``group_by(...).len()``
431
+ * array → NumPy unique + counts (order of first appearance)
432
+ * tidy → collect to polars, then the polars path
433
+ """
434
+ x = str(x)
435
+ if not has_column(data, x):
436
+ raise ColumnNotFound([x], data)
437
+ backend = detect_backend(data)
438
+ if backend == "pandas":
439
+ return (
440
+ data.groupby(x, dropna=False, observed=True, sort=False)
441
+ .size()
442
+ .rename("y")
443
+ .reset_index()
444
+ )
445
+ if backend == "array":
446
+ col = _as_array_table(data).column(x)
447
+ # Preserve first-appearance order (like polars maintain_order).
448
+ # Convert to list of hashables for unique.
449
+ if col.dtype.kind in "fc":
450
+ # float: use object round-trip carefully; keep raw values
451
+ keys: list[Any] = []
452
+ counts: list[int] = []
453
+ index: dict[Any, int] = {}
454
+ for v in col.tolist():
455
+ # NaN is not equal to itself
456
+ if isinstance(v, float) and np.isnan(v):
457
+ key = ("__nan__",)
458
+ else:
459
+ key = v
460
+ if key not in index:
461
+ index[key] = len(keys)
462
+ keys.append(v)
463
+ counts.append(0)
464
+ counts[index[key]] += 1
465
+ return pd.DataFrame({x: keys, "y": counts})
466
+ uniq, inv = np.unique(col, return_inverse=True)
467
+ # np.unique sorts — rebuild appearance order
468
+ first = {}
469
+ order = []
470
+ for i, v in enumerate(col.tolist()):
471
+ if v not in first:
472
+ first[v] = i
473
+ order.append(v)
474
+ count_map = {u: int(c) for u, c in zip(uniq.tolist(), np.bincount(inv))}
475
+ return pd.DataFrame({x: order, "y": [count_map[v] for v in order]})
476
+
477
+ import polars as pl
478
+
479
+ if backend == "tidy":
480
+ frame = resolve_polars(data)
481
+ elif isinstance(data, pl.LazyFrame):
482
+ frame = data.collect()
483
+ else:
484
+ frame = data
485
+
486
+ return (
487
+ frame.group_by(x, maintain_order=True)
488
+ .len()
489
+ .rename({"len": "y"})
490
+ )
491
+
492
+
493
+ def materialize_columns(data: Any, cols: list[str]) -> pd.DataFrame:
494
+ """Select *cols*, drop rows with any NA, return a pandas frame.
495
+
496
+ This is the **render boundary**: only the columns needed for encoding
497
+ are converted. The original plot data stays in its native backend.
498
+ """
499
+ cols = list(dict.fromkeys(str(c) for c in cols))
500
+ missing = [c for c in cols if not has_column(data, c)]
501
+ if missing:
502
+ raise ColumnNotFound(missing, data)
503
+ backend = detect_backend(data)
504
+ if backend == "pandas":
505
+ return data.loc[:, cols].dropna()
506
+ if backend == "array":
507
+ at = _as_array_table(data)
508
+ frame = pd.DataFrame({c: at.column(c) for c in cols}, copy=False)
509
+ return frame.dropna()
510
+ import polars as pl
511
+
512
+ if backend == "tidy":
513
+ frame = resolve_polars(data).select(cols)
514
+ elif isinstance(data, pl.LazyFrame):
515
+ frame = data.select(cols).collect()
516
+ else:
517
+ frame = data.select(cols)
518
+ # drop_nulls on any of the selected columns
519
+ frame = frame.drop_nulls(subset=cols)
520
+ return _polars_to_pandas(frame)
521
+
522
+
523
+ def to_arrays(data: Any, cols: list[str]) -> dict[str, np.ndarray]:
524
+ """Extract selected columns as NumPy arrays (after dropna on those cols)."""
525
+ frame = materialize_columns(data, cols)
526
+ out: dict[str, np.ndarray] = {}
527
+ for c in cols:
528
+ s = frame[c]
529
+ if pd.api.types.is_numeric_dtype(s):
530
+ out[c] = s.to_numpy(dtype=np.float64, copy=False)
531
+ else:
532
+ out[c] = s.to_numpy(copy=False)
533
+ return out
534
+
535
+
536
+ def unique_levels(data: Any, col: str) -> list[Any]:
537
+ """Discrete levels in order of appearance (for facets / violin)."""
538
+ col = str(col)
539
+ if not has_column(data, col):
540
+ raise ColumnNotFound([col], data)
541
+ backend = detect_backend(data)
542
+ if backend == "pandas":
543
+ s = data[col]
544
+ if isinstance(s.dtype, pd.CategoricalDtype):
545
+ levels = [c for c in s.cat.categories if (s == c).any()]
546
+ if s.isna().any():
547
+ levels = list(levels) + [pd.NA]
548
+ return list(levels)
549
+ return list(dict.fromkeys(s.tolist()))
550
+ if backend == "array":
551
+ series = _as_array_table(data).column(col)
552
+ seen: list[Any] = []
553
+ for v in series.tolist():
554
+ if isinstance(v, float) and np.isnan(v):
555
+ if not any(isinstance(x, float) and np.isnan(x) for x in seen):
556
+ seen.append(v)
557
+ elif v not in seen:
558
+ seen.append(v)
559
+ return seen
560
+ import polars as pl
561
+
562
+ if backend == "tidy":
563
+ frame = resolve_polars(data)
564
+ elif isinstance(data, pl.LazyFrame):
565
+ frame = data.collect()
566
+ else:
567
+ frame = data
568
+
569
+ series = frame.get_column(col)
570
+ # preserve appearance order
571
+ seen = []
572
+ for v in series.to_list():
573
+ if v not in seen:
574
+ if v is None:
575
+ if None not in seen:
576
+ seen.append(None)
577
+ else:
578
+ seen.append(v)
579
+ return seen
580
+
581
+
582
+ def filter_equal(data: Any, col: str, value: Any):
583
+ """Rows where ``col == value`` (``value is None`` → nulls). Same backend."""
584
+ col = str(col)
585
+ backend = detect_backend(data)
586
+ if backend == "pandas":
587
+ if value is None or (isinstance(value, float) and np.isnan(value)) or pd.isna(value):
588
+ return data.loc[data[col].isna()].copy()
589
+ return data.loc[data[col] == value].copy()
590
+ if backend == "array":
591
+ at = _as_array_table(data)
592
+ series = at.column(col)
593
+ if value is None or (isinstance(value, float) and np.isnan(value)):
594
+ mask = ~np.isfinite(series) if series.dtype.kind == "f" else np.zeros(len(at), dtype=bool)
595
+ if series.dtype.kind == "f":
596
+ mask = np.isnan(series.astype(np.float64, copy=False))
597
+ else:
598
+ mask = series == value
599
+ return at.take_rows(np.flatnonzero(mask))
600
+ import polars as pl
601
+
602
+ if backend == "tidy":
603
+ frame = resolve_polars(data)
604
+ elif isinstance(data, pl.LazyFrame):
605
+ frame = data.collect()
606
+ else:
607
+ frame = data
608
+ if value is None:
609
+ return frame.filter(pl.col(col).is_null())
610
+ return frame.filter(pl.col(col) == value)
611
+
612
+
613
+ def _eager_polars(data: Any):
614
+ """Eager polars DataFrame from polars or tidy input."""
615
+ import polars as pl
616
+
617
+ backend = detect_backend(data)
618
+ if backend == "tidy":
619
+ return resolve_polars(data)
620
+ if isinstance(data, pl.LazyFrame):
621
+ return data.collect()
622
+ return data
623
+
624
+
625
+ def numeric_array(data: Any, col: str, *, dropna: bool = True) -> np.ndarray:
626
+ """Extract *col* as ``float64`` (non-numeric → NaN).
627
+
628
+ This is the preferred path for stats that operate on plain arrays
629
+ (histogram, KDE, isosurface samples) without converting the whole table.
630
+ """
631
+ col = str(col)
632
+ if not has_column(data, col):
633
+ raise ColumnNotFound([col], data)
634
+ backend = detect_backend(data)
635
+ if backend == "pandas":
636
+ arr = pd.to_numeric(data[col], errors="coerce").to_numpy(dtype=np.float64)
637
+ elif backend == "array":
638
+ raw = _as_array_table(data).column(col)
639
+ if raw.dtype.kind in "biufc":
640
+ arr = np.asarray(raw, dtype=np.float64)
641
+ else:
642
+ arr = pd.to_numeric(pd.Series(raw), errors="coerce").to_numpy(
643
+ dtype=np.float64
644
+ )
645
+ else:
646
+ import polars as pl
647
+
648
+ frame = _eager_polars(data)
649
+ series = frame.get_column(col)
650
+ # Cast loosely to float; strings / mixed become null.
651
+ try:
652
+ series = series.cast(pl.Float64, strict=False)
653
+ except Exception:
654
+ vals = []
655
+ for v in series.to_list():
656
+ try:
657
+ vals.append(float(v) if v is not None else np.nan)
658
+ except (TypeError, ValueError):
659
+ vals.append(np.nan)
660
+ arr = np.asarray(vals, dtype=np.float64)
661
+ else:
662
+ arr = series.to_numpy()
663
+ if arr.dtype != np.float64:
664
+ arr = np.asarray(arr, dtype=np.float64)
665
+ # polars may yield object array for some null encodings
666
+ if arr.dtype == object:
667
+ arr = pd.to_numeric(pd.Series(arr), errors="coerce").to_numpy(
668
+ dtype=np.float64
669
+ )
670
+ if dropna:
671
+ return arr[np.isfinite(arr)]
672
+ return arr
673
+
674
+
675
+ def group_pieces(
676
+ data: Any, group_cols: list[str]
677
+ ) -> list[tuple[tuple[Any, ...], Any]]:
678
+ """Split *data* by *group_cols* in order of first appearance.
679
+
680
+ Returns ``[(key_tuple, subgroup), ...]``. Subgroups keep the pandas
681
+ backend when the source is pandas; polars/tidy sources yield polars
682
+ ``DataFrame`` pieces (tidy is resolved first); arrays yield ArrayTable.
683
+ """
684
+ group_cols = [str(c) for c in group_cols]
685
+ if not group_cols:
686
+ return [((), data)]
687
+ missing = [c for c in group_cols if not has_column(data, c)]
688
+ if missing:
689
+ raise ColumnNotFound(missing, data)
690
+ backend = detect_backend(data)
691
+ if backend == "pandas":
692
+ out: list[tuple[tuple[Any, ...], Any]] = []
693
+ for key, piece in data.groupby(
694
+ group_cols, dropna=False, observed=True, sort=False
695
+ ):
696
+ key_tuple = key if isinstance(key, tuple) else (key,)
697
+ out.append((tuple(key_tuple), piece))
698
+ return out
699
+ if backend == "array":
700
+ at = _as_array_table(data)
701
+ # Build group keys from selected columns; preserve appearance order.
702
+ n = len(at)
703
+ if n == 0:
704
+ return []
705
+ key_cols = [at.column(c) for c in group_cols]
706
+ keys_list: list[tuple[Any, ...]] = []
707
+ buckets: dict[tuple[Any, ...], list[int]] = {}
708
+ for i in range(n):
709
+ parts = []
710
+ for col in key_cols:
711
+ v = col[i]
712
+ if isinstance(v, (float, np.floating)) and np.isnan(v):
713
+ parts.append(None)
714
+ else:
715
+ # unbox numpy scalars
716
+ parts.append(v.item() if isinstance(v, np.generic) else v)
717
+ key_t = tuple(parts)
718
+ if key_t not in buckets:
719
+ buckets[key_t] = []
720
+ keys_list.append(key_t)
721
+ buckets[key_t].append(i)
722
+ return [
723
+ (key, at.take_rows(np.asarray(buckets[key], dtype=np.intp)))
724
+ for key in keys_list
725
+ ]
726
+
727
+ frame = _eager_polars(data)
728
+ out = []
729
+ for key, piece in frame.group_by(group_cols, maintain_order=True):
730
+ if not isinstance(key, tuple):
731
+ key_tuple = (key,)
732
+ else:
733
+ key_tuple = tuple(key)
734
+ out.append((key_tuple, piece))
735
+ return out
736
+
737
+
738
+ def category_labels(data: Any, col: str) -> list[str]:
739
+ """Stable category labels for a discrete column (violin x, etc.).
740
+
741
+ Prefer categorical dtype ordering when present; otherwise first-appearance
742
+ order of stringified values.
743
+ """
744
+ col = str(col)
745
+ if not has_column(data, col):
746
+ raise ColumnNotFound([col], data)
747
+ backend = detect_backend(data)
748
+ from plot3.scales import ordered_levels
749
+
750
+ if backend == "pandas":
751
+ s = data[col]
752
+ if isinstance(s.dtype, pd.CategoricalDtype):
753
+ return [str(c) for c in s.cat.categories]
754
+ return ["None" if v is None else str(v) for v in ordered_levels(s.tolist())]
755
+ if backend == "array":
756
+ values = _as_array_table(data).column(col).tolist()
757
+ return ["None" if v is None else str(v) for v in ordered_levels(values)]
758
+
759
+ import polars as pl
760
+
761
+ frame = _eager_polars(data)
762
+ series = frame.get_column(col)
763
+ dtype = series.dtype
764
+ if dtype == pl.Categorical or dtype == pl.Enum:
765
+ cats = series.cat.get_categories().to_list()
766
+ return [str(c) for c in cats]
767
+ return ["None" if v is None else str(v) for v in ordered_levels(series.to_list())]
768
+
769
+
770
+ def require_columns(data: Any, cols: list[str]) -> None:
771
+ """Raise ``KeyError`` if any of *cols* is missing."""
772
+ cols = [str(c) for c in cols]
773
+ missing = [c for c in cols if not has_column(data, c)]
774
+ if missing:
775
+ raise ColumnNotFound(missing, data)