scistackplot 0.1.26__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.
scistackplot/table.py ADDED
@@ -0,0 +1,328 @@
1
+ """
2
+ ``LongTable`` — a long-format DataFrame plus the column roles a plot needs.
3
+
4
+ Every source (CSV, DataFrame, scidb) hands the rest of the package one of
5
+ these. It answers three questions the raw DataFrame cannot: which columns are
6
+ *factors* (categorical things you can slice by), which are *measures* (the
7
+ numbers being plotted), and — critically — **what order each factor's levels
8
+ go in**.
9
+
10
+ That last one is not cosmetic. Schema keys like ``"01"`` are strings by project
11
+ rule, and pandas' default lexicographic ordering renders them as
12
+ ``"1", "10", "2"`` on a categorical axis: visibly wrong, and wrong in a way
13
+ that looks like a data problem rather than a plotting problem. Sources that
14
+ know better (scidb knows its declared ``schema_key_types``) supply
15
+ ``level_order`` explicitly; everything else falls back to the natural-sort
16
+ heuristic below.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from dataclasses import dataclass, field
23
+ from typing import Any, Iterable
24
+
25
+ import pandas as pd
26
+
27
+ from .shape import Shape, classify_column
28
+
29
+ _NUM_CHUNK = re.compile(r"(\d+)")
30
+
31
+ #: Prefix marking a factor as a **code-version** axis (``Code:bandpass_filter``)
32
+ #: rather than an experimental condition.
33
+ #:
34
+ #: Defined here, in the rendering layer, even though only the scidb-backed source
35
+ #: currently produces such factors: the distinction is about how a factor should
36
+ #: be *presented and defaulted* — a code axis is usually pinned to current, a
37
+ #: condition is usually faceted — and that is this layer's concern. Sources
38
+ #: conform to the convention rather than each inventing their own
39
+ #: (``scistackplotdb.VERSION_FACTOR_PREFIX`` is this constant).
40
+ CODE_FACTOR_PREFIX = "Code:"
41
+
42
+
43
+ def natural_sort_key(value: Any) -> tuple:
44
+ """
45
+ Sort key that orders embedded digit runs numerically.
46
+
47
+ ``"1", "2", "10"`` and ``"s01", "s02", "s10"`` both come out right, while
48
+ non-numeric values still sort stably. Digit runs compare as (0, int) and
49
+ text as (1, str) so the two never compare against each other.
50
+ """
51
+ text = "" if value is None else str(value)
52
+ parts: list[tuple[int, Any]] = []
53
+ for chunk in _NUM_CHUNK.split(text):
54
+ if not chunk:
55
+ continue
56
+ if chunk.isdigit():
57
+ parts.append((0, int(chunk)))
58
+ else:
59
+ parts.append((1, chunk))
60
+ return tuple(parts)
61
+
62
+
63
+ @dataclass
64
+ class FactorInfo:
65
+ name: str
66
+ levels: list[Any]
67
+ #: True when this factor came from pipeline branch params rather than the
68
+ #: dataset schema. The GUI marks these; pooling them means assigning
69
+ #: 'aggregate' or 'free' deliberately, which ``roles.validate`` allows only
70
+ #: when the spec says so rather than by defaulting.
71
+ is_variant: bool = False
72
+ #: True when this factor's levels are the measure's own FIELDS (the keys of
73
+ #: a dict/struct variable, melted into long format) rather than an
74
+ #: experimental condition. Defaults to one subplot per level, because the
75
+ #: fields of a struct are parallel quantities — 13 muscles overplotted on
76
+ #: one axis is not a figure anyone wanted.
77
+ is_field: bool = False
78
+ label: str | None = None
79
+ #: Where a variant factor came from, when the source can say:
80
+ #: ``{"kind": "code"|"param", "function": ..., "param": ...}``.
81
+ #:
82
+ #: Carried so a consumer never has to parse the column name. ``Code:bandpass``
83
+ #: and ``bandpass.low_hz`` are scidb's namespacing conventions, and a GUI (or
84
+ #: any other caller) reconstructing the producing function by splitting on
85
+ #: ``":"`` and ``"."`` would be re-implementing them one layer away from
86
+ #: where they are defined — the first place to break when they change.
87
+ origin: dict[str, Any] | None = None
88
+
89
+ @property
90
+ def display(self) -> str:
91
+ return self.label or self.name
92
+
93
+
94
+ @dataclass
95
+ class MeasureInfo:
96
+ name: str
97
+ shape: Shape
98
+ label: str | None = None
99
+ #: For 1-D measures whose arrays have already been exploded into rows.
100
+ exploded: bool = False
101
+
102
+ @property
103
+ def display(self) -> str:
104
+ return self.label or self.name
105
+
106
+
107
+ @dataclass
108
+ class LongTable:
109
+ """A long-format table with declared column roles."""
110
+
111
+ frame: pd.DataFrame
112
+ factors: list[FactorInfo] = field(default_factory=list)
113
+ measures: list[MeasureInfo] = field(default_factory=list)
114
+ #: Within-observation axis for 1-D data (time, frame, percent). Present as
115
+ #: a column only after the measure has been exploded.
116
+ index_column: str | None = None
117
+ name: str | None = None
118
+ #: A variant selection the SOURCE recommends, or None if it has no opinion.
119
+ #: Sources that can tell which rows are current say so here, and
120
+ #: ``default_spec`` opens on it as the first named variant; the user is free
121
+ #: to rename it, narrow it, or delete the row.
122
+ #:
123
+ #: The point is to keep "which rows are current" with the layer that knows
124
+ #: — a scidb variable whose function was edited holds records from both the
125
+ #: old and the new code, and only scidb can say which is which. A CSV has
126
+ #: no such notion and leaves this None.
127
+ default_pin: dict[str, Any] | None = None
128
+ #: The dataset's schema keys that this table carries, **outermost first**
129
+ #: (``["subject", "session", "trial"]``). Empty for sources with no
130
+ #: hierarchy, which is the honest answer for a CSV.
131
+ #:
132
+ #: Two things need it and neither can derive it from the frame. Nesting:
133
+ #: ``trial`` is meaningless without the ``subject`` it belongs to, so
134
+ #: iterating it iterates that subject too (``roles.iterate_ancestors``).
135
+ #: And ordering: a fan-out has to run subject-major so that stepping past
136
+ #: the last trial of subject 1 rolls over to subject 2's first trial.
137
+ schema_levels: list[str] = field(default_factory=list)
138
+ #: Name of the per-row "my whole code chain is the newest at my own schema
139
+ #: location" flag, when the source attaches one (scidb's ``CodeIsLatest``).
140
+ #:
141
+ #: Needed by name because ``"latest"`` in a variant selection resolves
142
+ #: through it, and it is emphatically **not** the same thing as "the highest
143
+ #: version ordinal": it is per schema location, so a subject never re-run
144
+ #: under the newest code still contributes its own newest record instead of
145
+ #: silently leaving the figure.
146
+ latest_column: str | None = None
147
+
148
+ # ---- lookups ---------------------------------------------------------
149
+
150
+ @property
151
+ def factor_names(self) -> list[str]:
152
+ return [f.name for f in self.factors]
153
+
154
+ @property
155
+ def measure_names(self) -> list[str]:
156
+ return [m.name for m in self.measures]
157
+
158
+ def factor(self, name: str) -> FactorInfo:
159
+ for f in self.factors:
160
+ if f.name == name:
161
+ return f
162
+ raise KeyError(f"No factor named {name!r}. Factors: {self.factor_names}")
163
+
164
+ def measure(self, name: str) -> MeasureInfo:
165
+ for m in self.measures:
166
+ if m.name == name:
167
+ return m
168
+ raise KeyError(f"No measure named {name!r}. Measures: {self.measure_names}")
169
+
170
+ def has_factor(self, name: str) -> bool:
171
+ return any(f.name == name for f in self.factors)
172
+
173
+ def is_schema_key(self, name: str) -> bool:
174
+ """Whether this factor is a dataset schema key rather than a variant,
175
+ a struct field, or anything else a source synthesized."""
176
+ return name in self.schema_levels
177
+
178
+ def shape_of(self, measure: str) -> Shape:
179
+ return self.measure(measure).shape
180
+
181
+ @property
182
+ def variant_factors(self) -> list[FactorInfo]:
183
+ return [f for f in self.factors if f.is_variant]
184
+
185
+ @property
186
+ def field_factors(self) -> list[FactorInfo]:
187
+ return [f for f in self.factors if f.is_field]
188
+
189
+ # ---- construction ----------------------------------------------------
190
+
191
+ @classmethod
192
+ def from_frame(
193
+ cls,
194
+ frame: pd.DataFrame,
195
+ *,
196
+ factors: Iterable[str] | None = None,
197
+ measures: Iterable[str] | None = None,
198
+ level_order: dict[str, list[Any]] | None = None,
199
+ variant_factors: Iterable[str] = (),
200
+ field_factors: Iterable[str] = (),
201
+ index_column: str | None = None,
202
+ name: str | None = None,
203
+ default_pin: dict[str, Any] | None = None,
204
+ latest_column: str | None = None,
205
+ factor_origins: dict[str, dict] | None = None,
206
+ schema_levels: Iterable[str] = (),
207
+ measure_labels: dict[str, str] | None = None,
208
+ ) -> "LongTable":
209
+ """
210
+ Build a LongTable, inferring column roles when they aren't given.
211
+
212
+ Inference mirrors the R proof of concept's ``getFactorColNames``, but
213
+ on shape rather than on R's ``is.factor``: a column that classifies as
214
+ SCALAR or SERIES_1D is a measure, anything else is a factor. Callers
215
+ that know better (every scidb-backed caller does) should pass explicit
216
+ lists — inference is for the standalone CSV path.
217
+ """
218
+ level_order = dict(level_order or {})
219
+ variant_set = set(variant_factors)
220
+ field_set = set(field_factors)
221
+
222
+ if factors is None or measures is None:
223
+ inferred_measures: list[str] = []
224
+ inferred_factors: list[str] = []
225
+ for column in frame.columns:
226
+ if column == index_column:
227
+ continue
228
+ shape = classify_column(frame[column])
229
+ if shape in (Shape.SCALAR, Shape.SERIES_1D, Shape.MATRIX_2D):
230
+ inferred_measures.append(column)
231
+ else:
232
+ inferred_factors.append(column)
233
+ factors = list(factors) if factors is not None else inferred_factors
234
+ measures = list(measures) if measures is not None else inferred_measures
235
+
236
+ factor_infos = []
237
+ for column in factors:
238
+ if column in level_order:
239
+ levels = list(level_order[column])
240
+ else:
241
+ levels = sorted(
242
+ frame[column].dropna().unique().tolist(), key=natural_sort_key
243
+ )
244
+ factor_infos.append(
245
+ FactorInfo(
246
+ name=column,
247
+ levels=levels,
248
+ is_variant=column in variant_set,
249
+ is_field=column in field_set,
250
+ origin=(factor_origins or {}).get(column),
251
+ )
252
+ )
253
+
254
+ measure_infos = [
255
+ MeasureInfo(
256
+ name=column,
257
+ shape=classify_column(frame[column]),
258
+ # A stacked table's value column is named after the primary
259
+ # variable but holds several; the label is how the axis says so
260
+ # without the column name lying about what is in it.
261
+ label=(measure_labels or {}).get(column),
262
+ )
263
+ for column in measures
264
+ ]
265
+
266
+ return cls(
267
+ frame=frame,
268
+ factors=factor_infos,
269
+ measures=measure_infos,
270
+ index_column=index_column,
271
+ name=name,
272
+ # A pin naming a column that isn't here would filter every row away
273
+ # on the first render — drop it rather than produce an empty figure.
274
+ default_pin={
275
+ key: value
276
+ for key, value in (default_pin or {}).items()
277
+ if key in frame.columns
278
+ }
279
+ or None,
280
+ latest_column=latest_column if latest_column in frame.columns else None,
281
+ # Only keys this table actually carries: a variable saved at subject
282
+ # level has no `trial` column, and an ancestor list naming one would
283
+ # promote a factor that cannot be grouped by.
284
+ schema_levels=[key for key in schema_levels if key in frame.columns],
285
+ )
286
+
287
+ def describe(self) -> dict:
288
+ """JSON-serializable summary — what the GUI needs to build its controls."""
289
+ return {
290
+ "name": self.name,
291
+ "row_count": int(len(self.frame)),
292
+ "index_column": self.index_column,
293
+ "factors": [
294
+ {
295
+ "name": f.name,
296
+ "display": f.display,
297
+ "levels": [_jsonable(v) for v in f.levels],
298
+ "level_count": len(f.levels),
299
+ "is_variant": f.is_variant,
300
+ "is_field": f.is_field,
301
+ # A dataset schema key rather than a variant, a struct
302
+ # field, or anything else a source synthesized. Reported
303
+ # rather than left to the consumer to work out by
304
+ # intersecting two lists — which is policy, and policy
305
+ # lives here (CLAUDE.md NOTE 3).
306
+ "is_schema_key": self.is_schema_key(f.name),
307
+ "origin": f.origin,
308
+ }
309
+ for f in self.factors
310
+ ],
311
+ "measures": [
312
+ {
313
+ "name": m.name,
314
+ "display": m.display,
315
+ "shape": str(m.shape),
316
+ "exploded": m.exploded,
317
+ "plottable": m.shape
318
+ in (Shape.SCALAR, Shape.SERIES_1D, Shape.MATRIX_2D),
319
+ }
320
+ for m in self.measures
321
+ ],
322
+ }
323
+
324
+
325
+ def _jsonable(value: Any) -> Any:
326
+ if value is None or isinstance(value, (str, int, float, bool)):
327
+ return value
328
+ return str(value)