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/variants.py
ADDED
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Named variants: turning a list of selections into a factor you can plot by.
|
|
3
|
+
|
|
4
|
+
A :class:`~scistackplot.spec.VariantSet` names a region of variant space —
|
|
5
|
+
"baseline" is ``Code:bandpass == v1``, "new filter" is ``low_hz in (20, 50)``.
|
|
6
|
+
:func:`apply_variant_sets` collapses however many of those a spec carries into
|
|
7
|
+
**one ordinary factor**, ``Variant``, whose levels are the names. From that
|
|
8
|
+
point on nothing downstream needs to know variants exist: the factor takes a
|
|
9
|
+
role, gets a colour or a facet row, and appears in a legend, exactly like
|
|
10
|
+
``session``.
|
|
11
|
+
|
|
12
|
+
Two decisions here are load-bearing.
|
|
13
|
+
|
|
14
|
+
**Answered columns leave the factor list.** Once "baseline" *means*
|
|
15
|
+
``Code:bandpass == v1``, keeping ``Code:bandpass`` as its own factor states the
|
|
16
|
+
same thing twice — and worse, with two sets it is a two-level variant factor
|
|
17
|
+
nobody assigned, so ``roles.validate`` would refuse the figure the user just
|
|
18
|
+
asked for. The information is not lost; it moved into the name. Which columns
|
|
19
|
+
count as answered is :func:`_answered`, and both of its rules matter.
|
|
20
|
+
|
|
21
|
+
**"latest" resolves against the data, never to a hard-coded ordinal.** See
|
|
22
|
+
:func:`resolve_selection` — this is the difference between a figure that keeps
|
|
23
|
+
every subject and one that silently drops the subjects nobody re-ran.
|
|
24
|
+
|
|
25
|
+
See ``docs/claude/variant-selection.md`` and ``.claude/plan-plot-variant-rows.md``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
from dataclasses import replace
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
import pandas as pd
|
|
35
|
+
from scistacklog import Log
|
|
36
|
+
|
|
37
|
+
from .spec import PlotSpec, VariantSet
|
|
38
|
+
from .table import CODE_FACTOR_PREFIX, FactorInfo, LongTable
|
|
39
|
+
|
|
40
|
+
LAYER = "scistackplot"
|
|
41
|
+
|
|
42
|
+
#: Column (and factor) the named variants collapse into. Named for what it is
|
|
43
|
+
#: to a reader of the figure — the legend says "Variant: baseline", not
|
|
44
|
+
#: "Code:bandpass: v1", which is the whole point of letting the user name it.
|
|
45
|
+
VARIANT_FACTOR = "Variant"
|
|
46
|
+
|
|
47
|
+
#: Bookkeeping column naming which variable each stacked row came from.
|
|
48
|
+
#:
|
|
49
|
+
#: Present only when a spec's rows span more than one variable, and never a
|
|
50
|
+
#: user-facing factor: it is consumed by :func:`apply_variant_sets` exactly like
|
|
51
|
+
#: an answered code axis, because "which variable" is already what the row's
|
|
52
|
+
#: name says. Its whole job is to let a row's mask claim only its own
|
|
53
|
+
#: variable's rows.
|
|
54
|
+
VARIABLE_COLUMN = "Variable"
|
|
55
|
+
|
|
56
|
+
#: A selection value asking for "whatever is current", rather than a named
|
|
57
|
+
#: ordinal. Matches ``scidb.variant.LATEST_VERSION``; duplicated as a literal
|
|
58
|
+
#: rather than imported because this package must work with no scidb installed.
|
|
59
|
+
LATEST = "latest"
|
|
60
|
+
|
|
61
|
+
_ORDINAL = re.compile(r"^v(\d+)$")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_code_axis(column: str) -> bool:
|
|
65
|
+
return column.startswith(CODE_FACTOR_PREFIX)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_selection(
|
|
69
|
+
frame: pd.DataFrame,
|
|
70
|
+
selection: dict[str, Any],
|
|
71
|
+
*,
|
|
72
|
+
latest_column: str | None = None,
|
|
73
|
+
) -> dict[str, Any]:
|
|
74
|
+
"""
|
|
75
|
+
Replace every ``"latest"`` in ``selection`` with something the frame can test.
|
|
76
|
+
|
|
77
|
+
Two resolutions, and which one applies depends on the rest of the set:
|
|
78
|
+
|
|
79
|
+
* **Nothing else pinned** — every code axis in the selection says ``latest``.
|
|
80
|
+
The set filters on ``latest_column``, the per-schema-location flag, and the
|
|
81
|
+
code axes drop out of the selection entirely. This is the important case
|
|
82
|
+
and the reason ``latest`` is not simply "the highest ordinal": a subject
|
|
83
|
+
never re-run under the newest code keeps contributing its own newest
|
|
84
|
+
record instead of vanishing from the figure.
|
|
85
|
+
* **Something else is pinned** to a named ordinal. The location-wise flag is
|
|
86
|
+
no longer usable — a record pinned to ``v1`` is by definition not the
|
|
87
|
+
latest — so the remaining ``latest`` axes resolve to the **highest ordinal
|
|
88
|
+
present in the data**. That does drop locations which never ran it, but
|
|
89
|
+
the user already asked for that by naming a version; the honest thing is
|
|
90
|
+
to say which ordinal it became (the GUI shows ``latest (v3)``), not to
|
|
91
|
+
quietly switch semantics.
|
|
92
|
+
|
|
93
|
+
Keys naming a column the frame does not have are dropped, not treated as
|
|
94
|
+
matching nothing: a spec outlives the table it was written against, and a
|
|
95
|
+
stale key must never silently empty a figure.
|
|
96
|
+
"""
|
|
97
|
+
present = {k: v for k, v in selection.items() if k in frame.columns}
|
|
98
|
+
dropped = [k for k in selection if k not in frame.columns]
|
|
99
|
+
if dropped:
|
|
100
|
+
Log.debug(
|
|
101
|
+
"variant selection ignores %s — not column(s) of this table",
|
|
102
|
+
dropped,
|
|
103
|
+
layer=LAYER,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
latest_axes = [k for k, v in present.items() if _is_latest(v)]
|
|
107
|
+
if not latest_axes:
|
|
108
|
+
return present
|
|
109
|
+
|
|
110
|
+
pinned_elsewhere = any(k not in latest_axes for k in present)
|
|
111
|
+
resolved = {k: v for k, v in present.items() if k not in latest_axes}
|
|
112
|
+
|
|
113
|
+
if not pinned_elsewhere and latest_column and latest_column in frame.columns:
|
|
114
|
+
resolved[latest_column] = True
|
|
115
|
+
return resolved
|
|
116
|
+
|
|
117
|
+
for axis in latest_axes:
|
|
118
|
+
highest = _highest_ordinal(frame[axis])
|
|
119
|
+
if highest is not None:
|
|
120
|
+
resolved[axis] = highest
|
|
121
|
+
return resolved
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _is_latest(value: Any) -> bool:
|
|
125
|
+
return isinstance(value, str) and value == LATEST
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _highest_ordinal(column: pd.Series) -> Any:
|
|
129
|
+
"""The largest ``vN`` in a code column, or None when it holds none.
|
|
130
|
+
|
|
131
|
+
Sorted numerically on the ordinal rather than lexically, so ``v10`` beats
|
|
132
|
+
``v9``. Non-ordinal levels (scidb's ``(n/a)`` for a record whose chain never
|
|
133
|
+
ran this function) are ignored rather than compared against.
|
|
134
|
+
"""
|
|
135
|
+
ordinals = []
|
|
136
|
+
for value in column.dropna().astype(str).unique():
|
|
137
|
+
match = _ORDINAL.match(value)
|
|
138
|
+
if match:
|
|
139
|
+
ordinals.append((int(match.group(1)), value))
|
|
140
|
+
if not ordinals:
|
|
141
|
+
return None
|
|
142
|
+
return max(ordinals)[1]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def default_selection(table: LongTable) -> dict[str, Any]:
|
|
146
|
+
"""The selection a figure opens on: **exactly one variant**.
|
|
147
|
+
|
|
148
|
+
Plotting one thing is the common case; comparing is what you opt into by
|
|
149
|
+
adding a row. Opening on several series is how a figure that nobody asked
|
|
150
|
+
for gets drawn — and worse, how two pipeline variants get read as replicates
|
|
151
|
+
of one condition.
|
|
152
|
+
|
|
153
|
+
The rule is deterministic and per axis, so the same table always opens the
|
|
154
|
+
same way:
|
|
155
|
+
|
|
156
|
+
* **code axes** -> the latest function body;
|
|
157
|
+
* **branch-param axes** -> the first level, in the table's own declared level
|
|
158
|
+
order (``source._ordered``: natural sort, so ``Parameter1`` opens on ``1``
|
|
159
|
+
and zero-padded IDs go ``01, 02, ... 10`` rather than ``1, 10, 2``).
|
|
160
|
+
|
|
161
|
+
Which value a parameter opens on genuinely does not matter — the user
|
|
162
|
+
changes it if they want another — but *stability* does, so it is the level
|
|
163
|
+
order rather than whatever order the rows arrived in.
|
|
164
|
+
|
|
165
|
+
**The pin is applied blindly.** Real data is ragged, so (latest code) x
|
|
166
|
+
(first value) may be a combination nobody ever ran, and this will happily
|
|
167
|
+
select nothing. That is deliberate: a rule that quietly picks a different
|
|
168
|
+
value to avoid an empty figure is no longer a rule anyone can predict. The
|
|
169
|
+
empty figure explains itself instead (``capability.variant_summary`` reports
|
|
170
|
+
``row_count == 0`` and what combinations do exist).
|
|
171
|
+
|
|
172
|
+
**Why the latest FLAG and not** ``Code:<fn> = "latest"``. Both spell "latest"
|
|
173
|
+
but they resolve differently, and only one of them is right here.
|
|
174
|
+
:func:`resolve_selection` treats a selection as "nothing else pinned" only
|
|
175
|
+
when every key in it is a ``"latest"`` axis (``pinned_elsewhere``, line ~116)
|
|
176
|
+
— so the moment a branch param is pinned alongside, a ``"latest"`` code axis
|
|
177
|
+
stops resolving through the per-location flag and becomes the **global
|
|
178
|
+
highest ordinal**. That would silently drop every schema location never
|
|
179
|
+
re-run under the newest code. Pinning the boolean flag keeps the
|
|
180
|
+
per-location meaning no matter what else is in the selection, which is the
|
|
181
|
+
behaviour ``test_pin_keeps_locations_never_rerun_under_the_newest_code``
|
|
182
|
+
exists to protect.
|
|
183
|
+
|
|
184
|
+
The ordinal fallback below therefore only runs for a source that has code
|
|
185
|
+
axes but no flag to go with them — impossible from scidb, which writes the
|
|
186
|
+
two together, but a CSV or a hand-built table may declare variant factors
|
|
187
|
+
without one.
|
|
188
|
+
"""
|
|
189
|
+
# The source's own recommendation comes first and is never overwritten:
|
|
190
|
+
# ``default_pin`` is where a source says which rows are CURRENT, and only
|
|
191
|
+
# scidb can know that. This function's job is to finish the job — pin the
|
|
192
|
+
# axes the source had no opinion about — not to second-guess it.
|
|
193
|
+
#
|
|
194
|
+
# One caveat for a source that pins a code axis to the string ``"latest"``
|
|
195
|
+
# rather than to the flag: adding a branch-param pin beside it flips it to
|
|
196
|
+
# the global highest ordinal (see above). Left alone rather than rewritten,
|
|
197
|
+
# because silently changing what a source asked for is worse than honouring
|
|
198
|
+
# a request it can express and `resolve_selection` documents.
|
|
199
|
+
selection: dict[str, Any] = dict(table.default_pin or {})
|
|
200
|
+
frame = table.frame
|
|
201
|
+
latest = table.latest_column if table.latest_column in frame.columns else None
|
|
202
|
+
if latest:
|
|
203
|
+
selection.setdefault(latest, True)
|
|
204
|
+
|
|
205
|
+
for factor in table.variant_factors:
|
|
206
|
+
if factor.name in selection or factor.name not in frame.columns:
|
|
207
|
+
continue
|
|
208
|
+
if is_code_axis(factor.name):
|
|
209
|
+
if latest:
|
|
210
|
+
continue # already answered, per-location, by the flag
|
|
211
|
+
highest = _highest_ordinal(frame[factor.name])
|
|
212
|
+
if highest is not None:
|
|
213
|
+
selection[factor.name] = highest
|
|
214
|
+
elif factor.levels:
|
|
215
|
+
selection[factor.name] = _level_text(factor.levels[-1])
|
|
216
|
+
continue
|
|
217
|
+
if factor.levels:
|
|
218
|
+
selection[factor.name] = _level_text(factor.levels[0])
|
|
219
|
+
|
|
220
|
+
Log.debug(
|
|
221
|
+
"default selection for %r: %s", table.name, selection, layer=LAYER
|
|
222
|
+
)
|
|
223
|
+
return selection
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def variant_set_mask(
|
|
227
|
+
frame: pd.DataFrame,
|
|
228
|
+
selection: dict[str, Any],
|
|
229
|
+
*,
|
|
230
|
+
latest_column: str | None = None,
|
|
231
|
+
) -> pd.Series:
|
|
232
|
+
"""Rows this selection keeps — the single definition of what a variant selects.
|
|
233
|
+
|
|
234
|
+
Public because the GUI reports *"4 of 24 combinations"* and a per-row count
|
|
235
|
+
per variant, and both have to be measured with exactly the rule the renderer
|
|
236
|
+
will apply. A second implementation that counted differently would put a
|
|
237
|
+
number on screen the figure disagrees with, which is worse than no number.
|
|
238
|
+
|
|
239
|
+
A list/tuple/set value means "any of these" — the subcube rule the popup's
|
|
240
|
+
checkboxes produce, and the same membership semantics
|
|
241
|
+
``scidb.database._match_branch_param`` already gives a list-valued
|
|
242
|
+
``Variant(...)`` kwarg.
|
|
243
|
+
"""
|
|
244
|
+
mask = pd.Series(True, index=frame.index)
|
|
245
|
+
resolved = resolve_selection(frame, selection, latest_column=latest_column)
|
|
246
|
+
for key, value in resolved.items():
|
|
247
|
+
column = frame[key]
|
|
248
|
+
if isinstance(value, bool):
|
|
249
|
+
# The latest flag is a real bool column; comparing it as text would
|
|
250
|
+
# depend on how pandas spells True.
|
|
251
|
+
mask &= column.fillna(False).astype(bool) == value
|
|
252
|
+
continue
|
|
253
|
+
as_text = column.astype(str)
|
|
254
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
255
|
+
mask &= as_text.isin({str(v) for v in value})
|
|
256
|
+
else:
|
|
257
|
+
mask &= as_text == str(value)
|
|
258
|
+
return mask
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def row_mask(
|
|
262
|
+
frame: pd.DataFrame,
|
|
263
|
+
spec: PlotSpec,
|
|
264
|
+
variant: VariantSet,
|
|
265
|
+
*,
|
|
266
|
+
latest_column: str | None = None,
|
|
267
|
+
) -> pd.Series:
|
|
268
|
+
"""Rows one variant ROW claims: its selection, within its own variable.
|
|
269
|
+
|
|
270
|
+
The single definition, called by :func:`apply_variant_sets` (which builds
|
|
271
|
+
the figure) and by ``capability.variant_summary`` (which reports how many
|
|
272
|
+
rows each variant matched). Those two disagreeing would put a number on
|
|
273
|
+
screen the figure contradicts — the same reason
|
|
274
|
+
:func:`variant_set_mask` is public.
|
|
275
|
+
"""
|
|
276
|
+
mask = variant_set_mask(frame, variant.selection, latest_column=latest_column)
|
|
277
|
+
if VARIABLE_COLUMN in frame.columns:
|
|
278
|
+
# A row claims only its own variable's rows. Without this, a row
|
|
279
|
+
# pinning `Code:filterEMG=v1` names nothing about a second variable —
|
|
280
|
+
# `resolve_selection` drops keys the frame lacks, deliberately — so it
|
|
281
|
+
# would match every row of that variable too and draw it once per
|
|
282
|
+
# variant, identically.
|
|
283
|
+
mask &= frame[VARIABLE_COLUMN].astype(str) == (
|
|
284
|
+
variant.variable or spec.y_measure
|
|
285
|
+
)
|
|
286
|
+
return mask
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def auto_label(
|
|
290
|
+
selection: dict[str, Any],
|
|
291
|
+
*,
|
|
292
|
+
index: int = 0,
|
|
293
|
+
variable: str | None = None,
|
|
294
|
+
latest_column: str | None = None,
|
|
295
|
+
) -> str:
|
|
296
|
+
"""The name a variant gets until the user types over it.
|
|
297
|
+
|
|
298
|
+
**The variable comes first, and the selection qualifies it** —
|
|
299
|
+
``RawEMG · low_hz=20``. A row's subject is the variable it draws; the
|
|
300
|
+
selection only narrows it. Naming rows by their selection alone was how two
|
|
301
|
+
rows reading "current" and "latest" ended up saying nothing about which was
|
|
302
|
+
EMG and which was force (observed 2026-09-11, scidb.log 12:26:18).
|
|
303
|
+
|
|
304
|
+
``variable`` is the row's own, or the primary measure when it names none.
|
|
305
|
+
Callers pass it; this function never guesses.
|
|
306
|
+
|
|
307
|
+
``latest_column`` spells the source's "these are the current results" flag
|
|
308
|
+
as **current**. It is a boolean flag rather than a coordinate, and
|
|
309
|
+
``CodeIsLatest=True`` in a legend tells a reader nothing.
|
|
310
|
+
|
|
311
|
+
An empty selection with no variable is ``(not set)``, not "all variants":
|
|
312
|
+
an unfilled row is inert (:func:`defined_sets`), so a label promising every
|
|
313
|
+
variant would describe a row that contributes nothing.
|
|
314
|
+
"""
|
|
315
|
+
parts: list[str] = []
|
|
316
|
+
for column, value in selection.items():
|
|
317
|
+
text = _level_text(value)
|
|
318
|
+
if latest_column and column == latest_column:
|
|
319
|
+
# A False flag is "not the current code", which is a real thing to
|
|
320
|
+
# ask for and must not read as its opposite.
|
|
321
|
+
parts.append("current" if value is True else f"not {latest_column}")
|
|
322
|
+
elif is_code_axis(column):
|
|
323
|
+
parts.append(f"{column[len(CODE_FACTOR_PREFIX):]} {text}")
|
|
324
|
+
else:
|
|
325
|
+
# Branch params arrive namespaced (``bandpass.low_hz``); the
|
|
326
|
+
# function is usually obvious from context in a legend, the
|
|
327
|
+
# parameter never is.
|
|
328
|
+
parts.append(f"{column.rsplit('.', 1)[-1]}={text}")
|
|
329
|
+
|
|
330
|
+
if variable:
|
|
331
|
+
return f"{variable} · {' · '.join(parts)}" if parts else variable
|
|
332
|
+
if not parts:
|
|
333
|
+
return "(not set)" if index == 0 else f"(not set {index + 1})"
|
|
334
|
+
return " · ".join(parts)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _level_text(value: Any) -> str:
|
|
338
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
339
|
+
return "+".join(str(v) for v in value)
|
|
340
|
+
return str(value)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def label_variable(
|
|
344
|
+
variant_set: VariantSet, index: int, primary: str | None
|
|
345
|
+
) -> str | None:
|
|
346
|
+
"""The variable a row is LABELLED by, or None when it is unfilled.
|
|
347
|
+
|
|
348
|
+
Three cases, and the third is why this is a function rather than
|
|
349
|
+
``variant.variable or primary``:
|
|
350
|
+
|
|
351
|
+
* the row names its own variable -> that one;
|
|
352
|
+
* the row says something (a selection), or it is **row 0** -> the primary
|
|
353
|
+
measure, because that is what it plots;
|
|
354
|
+
* otherwise -> None, and the row labels itself ``(not set)``.
|
|
355
|
+
|
|
356
|
+
Row 0 is special because it always exists: ``roles.default_spec`` seeds it
|
|
357
|
+
for every table, and an empty row 0 means "the primary measure, unnarrowed"
|
|
358
|
+
— a complete statement about the figure, not a blank the user forgot to
|
|
359
|
+
fill in. A row the user ADDED and left empty is the blank, and has to keep
|
|
360
|
+
saying so.
|
|
361
|
+
"""
|
|
362
|
+
if variant_set.variable:
|
|
363
|
+
return variant_set.variable
|
|
364
|
+
if variant_set.selection or index == 0:
|
|
365
|
+
return primary
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def is_stated(variant_set: VariantSet, index: int) -> bool:
|
|
370
|
+
"""Whether this row says anything — what the panel's "not set" tag reads.
|
|
371
|
+
|
|
372
|
+
Row 0 always does (see :func:`label_variable`). Beyond it, a row has to
|
|
373
|
+
name a variable or select something; clicking "+" is not a statement.
|
|
374
|
+
|
|
375
|
+
Distinct from :func:`defined_sets`, which decides what the FIGURE does: an
|
|
376
|
+
empty row 0 states "the primary measure" and needs no ``Variant`` factor to
|
|
377
|
+
express, so it is stated but not a defined set.
|
|
378
|
+
"""
|
|
379
|
+
return index == 0 or bool(variant_set.selection) or bool(variant_set.variable)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def set_name(
|
|
383
|
+
variant_set: VariantSet,
|
|
384
|
+
index: int,
|
|
385
|
+
*,
|
|
386
|
+
primary: str | None = None,
|
|
387
|
+
latest_column: str | None = None,
|
|
388
|
+
) -> str:
|
|
389
|
+
"""What this row is CALLED — in the legend, and in the name box.
|
|
390
|
+
|
|
391
|
+
One definition for both, deliberately: a box showing "FilteredEMG" beside a
|
|
392
|
+
legend reading "current" would be two answers to the same question.
|
|
393
|
+
"""
|
|
394
|
+
if variant_set.name:
|
|
395
|
+
return variant_set.name
|
|
396
|
+
return auto_label(
|
|
397
|
+
variant_set.selection,
|
|
398
|
+
index=index,
|
|
399
|
+
variable=label_variable(variant_set, index, primary),
|
|
400
|
+
latest_column=latest_column,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def defined_sets(sets: list[VariantSet]) -> list[VariantSet]:
|
|
405
|
+
"""The variants the figure folds into a ``Variant`` factor.
|
|
406
|
+
|
|
407
|
+
A row the user has added but not filled in is **inert**: it claims no data,
|
|
408
|
+
contributes no level, and decides nothing about which columns the Variants
|
|
409
|
+
section answers. Treating it as "all variants" instead — which is what an
|
|
410
|
+
empty selection means once applied — made adding a row change the figure
|
|
411
|
+
before the user had said anything about it, and un-answered the code axis
|
|
412
|
+
for every *other* row, dropping `Code:<fn>` back into Factors with a
|
|
413
|
+
pooling error attached.
|
|
414
|
+
|
|
415
|
+
Clicking "+" is not a statement about the data. Nothing should happen until
|
|
416
|
+
the row says something.
|
|
417
|
+
|
|
418
|
+
Naming a **variable** is saying something, even with no selection: "also
|
|
419
|
+
plot FilteredEMG, all of it" is a complete instruction, and the row it
|
|
420
|
+
describes is a series the figure has to draw.
|
|
421
|
+
|
|
422
|
+
**Row 0 folds alongside the others once ANY row is concrete**, even while
|
|
423
|
+
it selects nothing itself. It is seeded for every table
|
|
424
|
+
(``roles.default_spec``) and means "the primary measure, unnarrowed" — so
|
|
425
|
+
leaving it out would make adding a second row *replace* the figure with
|
|
426
|
+
that row alone instead of drawing both. The GUI used to paper over this by
|
|
427
|
+
quietly filling in row 0's variable when the user clicked "+", which put a
|
|
428
|
+
rule about what a figure draws in TypeScript; this is that rule, in the one
|
|
429
|
+
place that decides it.
|
|
430
|
+
|
|
431
|
+
With no concrete row anywhere there is nothing to fold: one unnarrowed
|
|
432
|
+
series needs no ``Variant`` factor to express.
|
|
433
|
+
"""
|
|
434
|
+
if not any(variant.selection or variant.variable for variant in sets):
|
|
435
|
+
return []
|
|
436
|
+
return [
|
|
437
|
+
variant for index, variant in enumerate(sets) if is_stated(variant, index)
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _answered(table: LongTable, sets: list[VariantSet]) -> set[str]:
|
|
442
|
+
"""Columns the named variants have already accounted for.
|
|
443
|
+
|
|
444
|
+
These leave the factor list: once "baseline" *means* ``Code:bandpass == v1``,
|
|
445
|
+
keeping the column as its own factor states the same thing twice, and with
|
|
446
|
+
two variants it is an unassigned two-level variant factor that
|
|
447
|
+
``roles.validate`` refuses — rejecting the very comparison the user asked
|
|
448
|
+
for.
|
|
449
|
+
|
|
450
|
+
Two rules, and they differ by axis kind on purpose.
|
|
451
|
+
|
|
452
|
+
**Code axes belong to the Variants section, entirely.** Once any variant is
|
|
453
|
+
defined, every ``Code:<fn>`` column is answered — whether that variant named
|
|
454
|
+
a version, asked for ``latest``, or selected on the chain-wide
|
|
455
|
+
``CodeIsLatest`` flag. "Which version of the code" is the question the
|
|
456
|
+
variant rows exist to answer, so offering the same question again as a
|
|
457
|
+
factor is asking the user to decide the same thing twice, in two places,
|
|
458
|
+
with no way to tell which one wins.
|
|
459
|
+
|
|
460
|
+
That deliberately allows a variant to hold rows built by different ordinals.
|
|
461
|
+
Usually that is exactly right — under ``latest``, subject A on v2 and
|
|
462
|
+
subject B on v1 are each the newest AT THEIR OWN LOCATION, which is what
|
|
463
|
+
"current" means. Where it is *not* obviously right, the row says so rather
|
|
464
|
+
than the column coming back: see :func:`spanned_code_axes`.
|
|
465
|
+
|
|
466
|
+
**Branch-param axes are answered only when EVERY variant answers them** — an
|
|
467
|
+
intersection, not a union. Nothing about "current code" decides which filter
|
|
468
|
+
cutoff to plot, so if one variant pins ``low_hz == 20`` while another leaves
|
|
469
|
+
it open, the second still holds both cutoffs and the user must still say
|
|
470
|
+
what to do with them.
|
|
471
|
+
"""
|
|
472
|
+
sets = defined_sets(sets)
|
|
473
|
+
if not sets:
|
|
474
|
+
return set()
|
|
475
|
+
frame = table.frame
|
|
476
|
+
answered = {column for column in frame.columns if is_code_axis(column)}
|
|
477
|
+
if VARIABLE_COLUMN in frame.columns and (
|
|
478
|
+
# ONE variable overall: the column is constant, so offering it as a
|
|
479
|
+
# factor with a single level is noise.
|
|
480
|
+
frame[VARIABLE_COLUMN].nunique(dropna=False) < 2
|
|
481
|
+
# ONE ROW PER VARIABLE: `Variant`'s levels already ARE the variables,
|
|
482
|
+
# so keeping `Variable` too would encode the same distinction twice —
|
|
483
|
+
# "FilteredEMG vs RawEMG" arriving as both a colour and a facet. This
|
|
484
|
+
# is the common stacking case and the original reason this column was
|
|
485
|
+
# always answered: which variable a row came from is what its NAME
|
|
486
|
+
# says.
|
|
487
|
+
or len({variant.variable for variant in sets}) == len(sets)
|
|
488
|
+
):
|
|
489
|
+
answered.add(VARIABLE_COLUMN)
|
|
490
|
+
# Otherwise — several rows sharing a variable, e.g. three variants each of
|
|
491
|
+
# EMG and force — `Variable` STAYS a factor, and that is load-bearing
|
|
492
|
+
# rather than cosmetic. `Variant` folds all six rows into one flat factor,
|
|
493
|
+
# and `_collapse_aggregates` builds its groupby key from the ROLES, so a
|
|
494
|
+
# column with no role is not in it: averaging `Variant` would average EMG
|
|
495
|
+
# together with force. Keeping `Variable` (defaulted to FACET, and refused
|
|
496
|
+
# FREE/AGGREGATE by `roles.validate`) is what makes variants collapse
|
|
497
|
+
# WITHIN a variable and never across.
|
|
498
|
+
per_variant: list[set[str]] = []
|
|
499
|
+
for variant in sets:
|
|
500
|
+
per_variant.append(
|
|
501
|
+
set(
|
|
502
|
+
resolve_selection(
|
|
503
|
+
frame, variant.selection, latest_column=table.latest_column
|
|
504
|
+
)
|
|
505
|
+
)
|
|
506
|
+
)
|
|
507
|
+
return answered | set.intersection(*per_variant)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
#: Schema locations named per version before a span report starts saying
|
|
511
|
+
#: "and N more". Enough to recognise a pattern ("ah, only subject 01"), few
|
|
512
|
+
#: enough to fit in a banner.
|
|
513
|
+
SPAN_LOCATION_LIMIT = 6
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def spanned_code_axes(
|
|
517
|
+
rows: pd.DataFrame, selection: dict[str, Any], table: LongTable
|
|
518
|
+
) -> dict[str, dict]:
|
|
519
|
+
"""Code axes this variant left open and that its rows disagree on.
|
|
520
|
+
|
|
521
|
+
The honesty mechanism that lets code axes leave the factor list
|
|
522
|
+
unconditionally. A variant whose rows were built by two different versions
|
|
523
|
+
of a function is pooling code versions — and the figure looks exactly like
|
|
524
|
+
one that is not. Rather than resurrecting the column as a factor (asking the
|
|
525
|
+
user to answer in Factors a question they are already answering in
|
|
526
|
+
Variants), the *row* reports it.
|
|
527
|
+
|
|
528
|
+
Returns ``{column: {function, versions, locations, schema_levels,
|
|
529
|
+
truncated}}`` — ``versions`` counts rows per version, ``locations`` names
|
|
530
|
+
the schema locations holding each, capped at
|
|
531
|
+
:data:`SPAN_LOCATION_LIMIT`. Empty when nothing is spanned.
|
|
532
|
+
|
|
533
|
+
**The latest flag is no longer exempt, and that is a deliberate reversal.**
|
|
534
|
+
This function used to return ``{}`` outright whenever the selection resolved
|
|
535
|
+
through the per-location ``CodeIsLatest`` flag, on the reasoning that
|
|
536
|
+
spanning ordinals is what per-location "latest" *means*, so reporting it
|
|
537
|
+
would cry wolf on the most ordinary state there is. That argument is sound
|
|
538
|
+
about *frequency* and wrong about *consequence*: the state it stays silent
|
|
539
|
+
about is a figure whose points were computed by different versions of the
|
|
540
|
+
same function, which is precisely the thing a reader cannot see and cannot
|
|
541
|
+
afford to assume away. The user's call, 2026-09-11 — if the body actually
|
|
542
|
+
used differs between schema locations, say so prominently.
|
|
543
|
+
|
|
544
|
+
So "latest" is now reported like any other unpinned code axis. What makes
|
|
545
|
+
that tolerable rather than noisy is *which locations hold which version*:
|
|
546
|
+
"v1 everywhere except subject 01" is a sentence someone can act on, where a
|
|
547
|
+
bare "pools 2 versions" on the most common state in the system is not.
|
|
548
|
+
|
|
549
|
+
Do not restore the exemption without re-reading
|
|
550
|
+
``docs/claude/plot-variant-rows.md`` §3, which argued for it.
|
|
551
|
+
"""
|
|
552
|
+
from .table import natural_sort_key
|
|
553
|
+
|
|
554
|
+
resolved = resolve_selection(
|
|
555
|
+
rows, selection, latest_column=table.latest_column
|
|
556
|
+
)
|
|
557
|
+
levels_of = [key for key in table.schema_levels if key in rows.columns]
|
|
558
|
+
spans: dict[str, dict] = {}
|
|
559
|
+
|
|
560
|
+
for column in rows.columns:
|
|
561
|
+
if not is_code_axis(column) or column in resolved:
|
|
562
|
+
continue
|
|
563
|
+
present = rows[column].dropna().astype(str)
|
|
564
|
+
if present.nunique() <= 1:
|
|
565
|
+
continue
|
|
566
|
+
|
|
567
|
+
versions: dict[str, int] = {}
|
|
568
|
+
locations: dict[str, list[str]] = {}
|
|
569
|
+
truncated = False
|
|
570
|
+
for version in sorted(present.unique(), key=natural_sort_key):
|
|
571
|
+
at_version = rows.loc[present.index[present == version]]
|
|
572
|
+
versions[version] = int(len(at_version))
|
|
573
|
+
if not levels_of:
|
|
574
|
+
# A CSV, or a table with no schema — the versions are still
|
|
575
|
+
# worth reporting, there is just nowhere to place them.
|
|
576
|
+
locations[version] = []
|
|
577
|
+
continue
|
|
578
|
+
labels = sorted(
|
|
579
|
+
{
|
|
580
|
+
"/".join(str(value) for value in label)
|
|
581
|
+
for label in at_version[levels_of].itertuples(index=False)
|
|
582
|
+
},
|
|
583
|
+
key=natural_sort_key,
|
|
584
|
+
)
|
|
585
|
+
locations[version] = labels[:SPAN_LOCATION_LIMIT]
|
|
586
|
+
truncated = truncated or len(labels) > SPAN_LOCATION_LIMIT
|
|
587
|
+
|
|
588
|
+
spans[column] = {
|
|
589
|
+
"function": column[len(CODE_FACTOR_PREFIX):]
|
|
590
|
+
if column.startswith(CODE_FACTOR_PREFIX)
|
|
591
|
+
else column,
|
|
592
|
+
"versions": versions,
|
|
593
|
+
"locations": locations,
|
|
594
|
+
# What a location string means, outermost first, so a reader knows
|
|
595
|
+
# whether "01/pre/2" is subject/session/trial or something else.
|
|
596
|
+
"schema_levels": levels_of,
|
|
597
|
+
"truncated": truncated,
|
|
598
|
+
}
|
|
599
|
+
return spans
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def describe_span(span: dict) -> str:
|
|
603
|
+
"""One sentence naming the versions and where each one is.
|
|
604
|
+
|
|
605
|
+
Shared so the log warning, the row tag and the figure banner cannot describe
|
|
606
|
+
the same span three different ways — the reason every other count in this
|
|
607
|
+
module goes through one function.
|
|
608
|
+
"""
|
|
609
|
+
parts = []
|
|
610
|
+
for version, count in span["versions"].items():
|
|
611
|
+
where = span["locations"].get(version) or []
|
|
612
|
+
if where:
|
|
613
|
+
listed = ", ".join(where)
|
|
614
|
+
if span["truncated"] and len(where) == SPAN_LOCATION_LIMIT:
|
|
615
|
+
listed += ", …"
|
|
616
|
+
parts.append(f"{version} ({listed})")
|
|
617
|
+
else:
|
|
618
|
+
parts.append(f"{version} ({count} row(s))")
|
|
619
|
+
return f"{span['function']}: {'; '.join(parts)}"
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def strip_answered_roles(
|
|
623
|
+
spec: PlotSpec, table: LongTable, derived: LongTable
|
|
624
|
+
) -> PlotSpec:
|
|
625
|
+
"""Drop roles naming factors the variants answered.
|
|
626
|
+
|
|
627
|
+
A role assigned before a variant claimed its column is not a mistake, it is
|
|
628
|
+
stale — and ``validate`` would call it an unknown factor and refuse to draw
|
|
629
|
+
anything. Two ways in, both ordinary:
|
|
630
|
+
|
|
631
|
+
* ``default_roles`` puts a multi-level ``Code:<fn>`` on COLOUR, and the
|
|
632
|
+
table then opens on the "current" variant, which answers it;
|
|
633
|
+
* the user assigns a code axis to a facet, then adds a variant that pins it.
|
|
634
|
+
|
|
635
|
+
Only names that WERE factors of the undecided table are dropped. A role
|
|
636
|
+
naming something that was never a factor at all is still a typo, and
|
|
637
|
+
``validate`` should still say so.
|
|
638
|
+
"""
|
|
639
|
+
stale = [
|
|
640
|
+
name
|
|
641
|
+
for name in spec.roles
|
|
642
|
+
if table.has_factor(name) and not derived.has_factor(name)
|
|
643
|
+
]
|
|
644
|
+
if not stale:
|
|
645
|
+
return spec
|
|
646
|
+
Log.debug(
|
|
647
|
+
"dropping role(s) %s — the named variants now account for those columns",
|
|
648
|
+
stale,
|
|
649
|
+
layer=LAYER,
|
|
650
|
+
)
|
|
651
|
+
return replace(
|
|
652
|
+
spec, roles={k: v for k, v in spec.roles.items() if k not in stale}
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def apply_variant_sets(spec: PlotSpec, table: LongTable) -> LongTable:
|
|
657
|
+
"""
|
|
658
|
+
Fold ``spec.variant_sets`` into a ``Variant`` factor on a derived table.
|
|
659
|
+
|
|
660
|
+
Returns ``table`` unchanged when the spec names no variants — or none that
|
|
661
|
+
say anything yet (:func:`defined_sets`) — so a project that never edited a
|
|
662
|
+
function or swept a parameter pays nothing and behaves exactly as before,
|
|
663
|
+
and a half-added row changes nothing until it is filled in.
|
|
664
|
+
|
|
665
|
+
Rows matching no set are dropped: the sets are the figure's subject, and a
|
|
666
|
+
row belonging to none of them was not asked for. A row matching several
|
|
667
|
+
goes to the **first** — overlapping selections are legal (``all where
|
|
668
|
+
low_hz=20`` and ``all where code=v1`` genuinely intersect) and duplicating
|
|
669
|
+
the row into both would double-count it in every mean.
|
|
670
|
+
"""
|
|
671
|
+
sets = defined_sets(spec.variant_sets)
|
|
672
|
+
if not sets:
|
|
673
|
+
return table
|
|
674
|
+
|
|
675
|
+
frame = table.frame
|
|
676
|
+
names = [
|
|
677
|
+
set_name(
|
|
678
|
+
s,
|
|
679
|
+
i,
|
|
680
|
+
primary=spec.y_measure,
|
|
681
|
+
latest_column=table.latest_column,
|
|
682
|
+
)
|
|
683
|
+
for i, s in enumerate(sets)
|
|
684
|
+
]
|
|
685
|
+
assigned = pd.Series(pd.NA, index=frame.index, dtype="object")
|
|
686
|
+
counts: list[int] = []
|
|
687
|
+
|
|
688
|
+
for name, variant in zip(names, sets, strict=True):
|
|
689
|
+
mask = row_mask(frame, spec, variant, latest_column=table.latest_column)
|
|
690
|
+
fresh = mask & assigned.isna()
|
|
691
|
+
counts.append(int(fresh.sum()))
|
|
692
|
+
assigned[fresh] = name
|
|
693
|
+
|
|
694
|
+
# Code axes leave the factor list unconditionally, so a variant that
|
|
695
|
+
# quietly straddles two versions has to say so here — this is the log
|
|
696
|
+
# half of what the GUI shows on the row.
|
|
697
|
+
spans = spanned_code_axes(frame[fresh], variant.selection, table)
|
|
698
|
+
if spans:
|
|
699
|
+
Log.warn(
|
|
700
|
+
"variant %r was built by MORE THAN ONE version of the code — "
|
|
701
|
+
"%s. The figure cannot show this; pin a version on the row, or "
|
|
702
|
+
"split it into one variant per version.",
|
|
703
|
+
name,
|
|
704
|
+
" | ".join(describe_span(span) for span in spans.values()),
|
|
705
|
+
layer=LAYER,
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
kept = frame[assigned.notna()].copy()
|
|
709
|
+
kept[VARIANT_FACTOR] = assigned[assigned.notna()]
|
|
710
|
+
|
|
711
|
+
factors = [f for f in table.factors if f.name not in _answered(table, sets)]
|
|
712
|
+
factors.insert(
|
|
713
|
+
0,
|
|
714
|
+
FactorInfo(
|
|
715
|
+
name=VARIANT_FACTOR,
|
|
716
|
+
# Declared order, not the order the data happens to be in: the rows
|
|
717
|
+
# are a list the user arranged, and a legend that reorders itself
|
|
718
|
+
# when a variant loses its last record is disorienting.
|
|
719
|
+
levels=[name for name, count in zip(names, counts, strict=True) if count],
|
|
720
|
+
is_variant=True,
|
|
721
|
+
),
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
empty = [name for name, count in zip(names, counts, strict=True) if not count]
|
|
725
|
+
if empty:
|
|
726
|
+
# Never silent: a variant that matched nothing is either a typo or a
|
|
727
|
+
# pipeline that was never run, and both look identical to "it worked"
|
|
728
|
+
# if the only symptom is a missing series.
|
|
729
|
+
Log.warn(
|
|
730
|
+
"variant(s) %s matched no rows — they contribute nothing to the "
|
|
731
|
+
"figure. Check the selection against what has actually run.",
|
|
732
|
+
empty,
|
|
733
|
+
layer=LAYER,
|
|
734
|
+
)
|
|
735
|
+
Log.info(
|
|
736
|
+
"variant sets kept %d of %d row(s): %s",
|
|
737
|
+
len(kept),
|
|
738
|
+
len(frame),
|
|
739
|
+
dict(zip(names, counts, strict=True)),
|
|
740
|
+
layer=LAYER,
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
return replace(table, frame=kept, factors=factors)
|