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/__init__.py +239 -0
- scistackplot/capability.py +656 -0
- scistackplot/codegen.py +886 -0
- scistackplot/groups.py +110 -0
- scistackplot/reduce.py +1475 -0
- scistackplot/render/__init__.py +26 -0
- scistackplot/render/base.py +262 -0
- scistackplot/render/mpl.py +458 -0
- scistackplot/render/plotly_.py +537 -0
- scistackplot/resolved.py +240 -0
- scistackplot/roles.py +413 -0
- scistackplot/shape.py +106 -0
- scistackplot/sources/__init__.py +7 -0
- scistackplot/sources/base.py +256 -0
- scistackplot/sources/csv.py +67 -0
- scistackplot/sources/frame.py +45 -0
- scistackplot/spec.py +698 -0
- scistackplot/table.py +328 -0
- scistackplot/variants.py +743 -0
- scistackplot/xaxis.py +183 -0
- scistackplot/ylimits.py +451 -0
- scistackplot-0.1.26.dist-info/METADATA +212 -0
- scistackplot-0.1.26.dist-info/RECORD +24 -0
- scistackplot-0.1.26.dist-info/WHEEL +4 -0
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)
|