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/resolved.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""
|
|
2
|
+
``ResolvedPlot`` — a spec plus data, reduced to exactly what a renderer draws.
|
|
3
|
+
|
|
4
|
+
This intermediate is the reason the interactive plotly view and the exported
|
|
5
|
+
matplotlib figure cannot drift apart. Compiling ``PlotSpec`` straight to each
|
|
6
|
+
renderer would mean writing the aggregation, the error-band definition, and the
|
|
7
|
+
facet ordering twice, in two libraries, and discovering the divergence in a
|
|
8
|
+
figure rather than in a test. Here all of that happens once, above the renderer
|
|
9
|
+
split; the renderers become dumb translators of panel frames plus encodings,
|
|
10
|
+
and the semantics are tested against golden ``ResolvedPlot`` fixtures with no
|
|
11
|
+
rendering involved.
|
|
12
|
+
|
|
13
|
+
It is also the integration point for a future MATLAB renderer: a third backend
|
|
14
|
+
is a new leaf, not a redesign.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import pandas as pd
|
|
23
|
+
|
|
24
|
+
from .spec import PlotKind, PlotSpec
|
|
25
|
+
|
|
26
|
+
#: Canonical column names inside a panel frame. Renderers address these, never
|
|
27
|
+
#: the user's original column names, so a renderer never needs the spec to know
|
|
28
|
+
#: which column is the x axis.
|
|
29
|
+
X = "__x"
|
|
30
|
+
Y = "__y"
|
|
31
|
+
Y_LOW = "__y_low"
|
|
32
|
+
Y_HIGH = "__y_high"
|
|
33
|
+
COLOR = "__color"
|
|
34
|
+
SERIES = "__series"
|
|
35
|
+
Z = "__z"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Encoding:
|
|
40
|
+
"""Which canonical columns are populated, and what they mean."""
|
|
41
|
+
|
|
42
|
+
x: str | None = X
|
|
43
|
+
y: str | None = Y
|
|
44
|
+
color: str | None = None
|
|
45
|
+
y_low: str | None = None
|
|
46
|
+
y_high: str | None = None
|
|
47
|
+
series: str | None = None
|
|
48
|
+
z: str | None = None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def has_error(self) -> bool:
|
|
52
|
+
return self.y_low is not None and self.y_high is not None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Labels:
|
|
57
|
+
x: str = ""
|
|
58
|
+
y: str = ""
|
|
59
|
+
color: str | None = None
|
|
60
|
+
title: str | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class Panel:
|
|
65
|
+
"""One subplot: a tidy frame plus the facet values that identify it."""
|
|
66
|
+
|
|
67
|
+
frame: pd.DataFrame
|
|
68
|
+
#: Where this panel sits in the grid. Assigned once, in ``reduce``, so the
|
|
69
|
+
#: renderers never have to re-derive a layout (and never disagree about it).
|
|
70
|
+
grid_row: int = 0
|
|
71
|
+
grid_col: int = 0
|
|
72
|
+
#: All facet values keyed by factor name (empty when unfaceted).
|
|
73
|
+
key: dict[str, Any] = field(default_factory=dict)
|
|
74
|
+
#: This panel's y range, or None to autoscale.
|
|
75
|
+
#:
|
|
76
|
+
#: **The authority** — ``ResolvedPlot.y_limits`` is derived from these and
|
|
77
|
+
#: exists only for the case where they all agree. Per panel rather than per
|
|
78
|
+
#: figure because ``PlotSpec.y_axis.scope`` may separate limits by a FACET
|
|
79
|
+
#: factor, which is what "autoscale each panel" means.
|
|
80
|
+
y_limits: tuple[float, float] | None = None
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def title(self) -> str:
|
|
84
|
+
"""
|
|
85
|
+
What names this panel: the facet VALUES only.
|
|
86
|
+
|
|
87
|
+
The key is already obvious from the figure — every panel in a grid is
|
|
88
|
+
faceted by the same factor, so repeating "ColName=" on all 13 subplots
|
|
89
|
+
is noise. (``ResolvedPlot.figure_label`` keeps ``key=value``: there the
|
|
90
|
+
figures are separate files and the key is not otherwise visible.)
|
|
91
|
+
|
|
92
|
+
Renderers draw this as the panel's **y-axis title**, not as a caption
|
|
93
|
+
above it (``render.base.panel_y_title``) — the axis title is room the
|
|
94
|
+
panel already spends, so the name costs the grid no height. The property
|
|
95
|
+
keeps its name because it is also the panel's identity in logs, in
|
|
96
|
+
``to_dict`` and in the GUI.
|
|
97
|
+
"""
|
|
98
|
+
return " · ".join(str(v) for v in self.key.values())
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class ResolvedPlot:
|
|
103
|
+
"""
|
|
104
|
+
One figure, fully reduced.
|
|
105
|
+
|
|
106
|
+
``resolve()`` returns a LIST of these — one per combination of the spec's
|
|
107
|
+
ITERATE factors, which is the interactive equivalent of the pipeline's
|
|
108
|
+
``for_each`` fan-out over iterated schema keys.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
kind: PlotKind
|
|
112
|
+
panels: list[Panel]
|
|
113
|
+
encoding: Encoding
|
|
114
|
+
labels: Labels
|
|
115
|
+
spec: PlotSpec
|
|
116
|
+
#: The ITERATE factor values that select this figure out of the fan-out.
|
|
117
|
+
figure_key: dict[str, Any] = field(default_factory=dict)
|
|
118
|
+
x_order: list[Any] | None = None
|
|
119
|
+
#: Set when several factors share the x axis: the composed leaf order plus
|
|
120
|
+
#: the spans each higher layer covers. ``x_order`` mirrors ``x_plan.order``
|
|
121
|
+
#: so every existing consumer keeps working; renderers read this only to
|
|
122
|
+
#: draw the group labels and brackets beneath the ticks.
|
|
123
|
+
x_plan: Any = None
|
|
124
|
+
color_order: list[Any] | None = None
|
|
125
|
+
#: Subplot grid shape, decided in ``reduce`` from FacetOptions.
|
|
126
|
+
grid_rows: int = 1
|
|
127
|
+
grid_cols: int = 1
|
|
128
|
+
#: Headers for rule-defined rows/columns (empty when the panels just flow).
|
|
129
|
+
row_labels: list[str] = field(default_factory=list)
|
|
130
|
+
col_labels: list[str] = field(default_factory=list)
|
|
131
|
+
#: Human-readable notes about placement decisions the user did not ask for —
|
|
132
|
+
#: a panel that spilled out of its ruled cell, a grid that had to grow, a
|
|
133
|
+
#: panel that matched no rule. The layout never silently disobeys a rule;
|
|
134
|
+
#: it says what it did instead. Surfaced in the GUI's Layout section.
|
|
135
|
+
layout_notes: list[str] = field(default_factory=list)
|
|
136
|
+
#: The whole figure's y range — set only when every panel shares it, and
|
|
137
|
+
#: ``None`` when they differ. Derived from the panels in ``reduce``, never
|
|
138
|
+
#: computed separately, so the two can never disagree. A renderer reads the
|
|
139
|
+
#: None as "give each panel its own axis" (``render.base.shares_y_axis``).
|
|
140
|
+
y_limits: tuple[float, float] | None = None
|
|
141
|
+
#: The factors that separated the limits, after ineligible ones were
|
|
142
|
+
#: dropped. Echoed back so the GUI can say WHY the axis reads as it does.
|
|
143
|
+
y_scope: list[str] = field(default_factory=list)
|
|
144
|
+
#: Set when the data was reduced for transport (see reduce.MAX_TRANSPORT_POINTS).
|
|
145
|
+
downsampled_from: int | None = None
|
|
146
|
+
#: Notes about the FIGURE SET rather than about this figure's layout — at
|
|
147
|
+
#: present, schema keys promoted to ITERATE because a nested key iterates.
|
|
148
|
+
#: Identical on every figure of a fan-out (it describes the fan-out), which
|
|
149
|
+
#: is why the GUI reads it from the first one.
|
|
150
|
+
fanout_notes: list[str] = field(default_factory=list)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def figure_label(self) -> str:
|
|
154
|
+
"""Human-readable identifier for this figure within the fan-out."""
|
|
155
|
+
if not self.figure_key:
|
|
156
|
+
return self.labels.title or ""
|
|
157
|
+
return ", ".join(f"{k}={v}" for k, v in self.figure_key.items())
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def row_count(self) -> int:
|
|
161
|
+
return sum(len(p.frame) for p in self.panels)
|
|
162
|
+
|
|
163
|
+
def to_dict(self) -> dict:
|
|
164
|
+
"""JSON-serializable form — used by the GUI transport and by tests."""
|
|
165
|
+
return {
|
|
166
|
+
"kind": str(self.kind),
|
|
167
|
+
"figure_key": {k: _jsonable(v) for k, v in self.figure_key.items()},
|
|
168
|
+
"figure_label": self.figure_label,
|
|
169
|
+
"encoding": {
|
|
170
|
+
"x": self.encoding.x,
|
|
171
|
+
"y": self.encoding.y,
|
|
172
|
+
"color": self.encoding.color,
|
|
173
|
+
"y_low": self.encoding.y_low,
|
|
174
|
+
"y_high": self.encoding.y_high,
|
|
175
|
+
"series": self.encoding.series,
|
|
176
|
+
"z": self.encoding.z,
|
|
177
|
+
},
|
|
178
|
+
"labels": {
|
|
179
|
+
"x": self.labels.x,
|
|
180
|
+
"y": self.labels.y,
|
|
181
|
+
"color": self.labels.color,
|
|
182
|
+
"title": self.labels.title,
|
|
183
|
+
},
|
|
184
|
+
"grid": {
|
|
185
|
+
"rows": self.grid_rows,
|
|
186
|
+
"cols": self.grid_cols,
|
|
187
|
+
"row_labels": list(self.row_labels),
|
|
188
|
+
"col_labels": list(self.col_labels),
|
|
189
|
+
"layout_notes": list(self.layout_notes),
|
|
190
|
+
},
|
|
191
|
+
"x_order": [_jsonable(v) for v in (self.x_order or [])] or None,
|
|
192
|
+
"x_groups": [
|
|
193
|
+
{
|
|
194
|
+
"label": group.label,
|
|
195
|
+
"depth": group.depth,
|
|
196
|
+
"start": group.start,
|
|
197
|
+
"end": group.end,
|
|
198
|
+
}
|
|
199
|
+
for group in (self.x_plan.groups if self.x_plan else [])
|
|
200
|
+
],
|
|
201
|
+
"color_order": [_jsonable(v) for v in (self.color_order or [])] or None,
|
|
202
|
+
"y_limits": list(self.y_limits) if self.y_limits else None,
|
|
203
|
+
"y_scope": list(self.y_scope),
|
|
204
|
+
"downsampled_from": self.downsampled_from,
|
|
205
|
+
"fanout_notes": list(self.fanout_notes),
|
|
206
|
+
"panels": [
|
|
207
|
+
{
|
|
208
|
+
"key": {k: _jsonable(v) for k, v in panel.key.items()},
|
|
209
|
+
"title": panel.title,
|
|
210
|
+
"grid_row": panel.grid_row,
|
|
211
|
+
"grid_col": panel.grid_col,
|
|
212
|
+
# Per panel, because the figure-level value is absent
|
|
213
|
+
# exactly when the panels differ — which is the case the
|
|
214
|
+
# interactive view most needs to draw correctly.
|
|
215
|
+
"y_limits": list(panel.y_limits) if panel.y_limits else None,
|
|
216
|
+
"rows": _frame_records(panel.frame),
|
|
217
|
+
}
|
|
218
|
+
for panel in self.panels
|
|
219
|
+
],
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _frame_records(frame: pd.DataFrame) -> list[dict]:
|
|
224
|
+
return [
|
|
225
|
+
{key: _jsonable(value) for key, value in record.items()}
|
|
226
|
+
for record in frame.to_dict(orient="records")
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _jsonable(value: Any) -> Any:
|
|
231
|
+
if value is None or isinstance(value, (str, bool)):
|
|
232
|
+
return value
|
|
233
|
+
if isinstance(value, (int, float)):
|
|
234
|
+
return value
|
|
235
|
+
# numpy scalars and arrays, pandas NA, Timestamps, ...
|
|
236
|
+
if hasattr(value, "tolist"):
|
|
237
|
+
return value.tolist()
|
|
238
|
+
if pd.isna(value):
|
|
239
|
+
return None
|
|
240
|
+
return str(value)
|
scistackplot/roles.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Role assignment: defaults, completion, and validation.
|
|
3
|
+
|
|
4
|
+
Every factor carries exactly one :class:`~scistackplot.spec.Role`. Enforcing
|
|
5
|
+
that here — once, in the library — is what lets the GUI be a thin renderer of
|
|
6
|
+
whatever ``capability.available_plots`` returns instead of re-deriving the
|
|
7
|
+
invariant in TypeScript.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
|
|
14
|
+
from .shape import Shape
|
|
15
|
+
from .spec import MAX_X_LAYERS, SINGLE_ASSIGNMENT_ROLES, PlotSpec, Role
|
|
16
|
+
from .table import LongTable
|
|
17
|
+
from .variants import VARIABLE_COLUMN, VARIANT_FACTOR
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RoleError(ValueError):
|
|
21
|
+
"""An invalid role assignment. Message names the one-line fix."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def default_roles(table: LongTable, measure: str | None = None) -> dict[str, Role]:
|
|
25
|
+
"""
|
|
26
|
+
A reasonable starting assignment for a freshly opened table.
|
|
27
|
+
|
|
28
|
+
Mirrors the proof of concept's opening state (it preselected a factor for
|
|
29
|
+
the x axis and left the rest available) but adds one thing it had no
|
|
30
|
+
concept of: a variant factor defaults to COLOR so that two pipeline
|
|
31
|
+
variants are visibly separated on first render rather than silently
|
|
32
|
+
overplotted.
|
|
33
|
+
"""
|
|
34
|
+
measure = measure or (table.measure_names[0] if table.measures else None)
|
|
35
|
+
shape = table.shape_of(measure) if measure else Shape.UNKNOWN
|
|
36
|
+
|
|
37
|
+
roles: dict[str, Role] = {}
|
|
38
|
+
variants = [f for f in table.factors if f.is_variant and len(f.levels) > 1]
|
|
39
|
+
fields = [f for f in table.factors if f.is_field]
|
|
40
|
+
plain = [f for f in table.factors if not f.is_variant and not f.is_field]
|
|
41
|
+
|
|
42
|
+
# A struct/dict variable's fields are parallel quantities (13 muscles, say),
|
|
43
|
+
# not levels of one condition: one subplot each, never one overplotted axis.
|
|
44
|
+
for factor in fields:
|
|
45
|
+
roles[factor.name] = Role.FACET
|
|
46
|
+
|
|
47
|
+
if variants:
|
|
48
|
+
roles[variants[0].name] = Role.COLOR
|
|
49
|
+
# FACET, not FREE, for the rest. A variant left FREE is pooled, which
|
|
50
|
+
# `validate` refuses outright — so defaulting extras to FREE handed the
|
|
51
|
+
# user an error instead of a plot the moment a table carried two variant
|
|
52
|
+
# factors at once (e.g. a filter cutoff AND two versions of the
|
|
53
|
+
# producing function's source). Faceting keeps them separated, which is
|
|
54
|
+
# the same promise COLOR makes for the first one.
|
|
55
|
+
for extra in variants[1:]:
|
|
56
|
+
roles[extra.name] = Role.FACET
|
|
57
|
+
|
|
58
|
+
# For 1-D measures the x axis is the within-observation index, so no factor
|
|
59
|
+
# takes X; the leading factor becomes the colour channel instead.
|
|
60
|
+
if shape is Shape.SERIES_1D:
|
|
61
|
+
for factor in plain:
|
|
62
|
+
if Role.COLOR not in roles.values():
|
|
63
|
+
roles[factor.name] = Role.COLOR
|
|
64
|
+
else:
|
|
65
|
+
roles[factor.name] = Role.FREE
|
|
66
|
+
else:
|
|
67
|
+
for position, factor in enumerate(plain):
|
|
68
|
+
if position == 0:
|
|
69
|
+
roles[factor.name] = Role.X
|
|
70
|
+
elif Role.COLOR not in roles.values():
|
|
71
|
+
roles[factor.name] = Role.COLOR
|
|
72
|
+
else:
|
|
73
|
+
roles[factor.name] = Role.FREE
|
|
74
|
+
|
|
75
|
+
return roles
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def complete_roles(
|
|
79
|
+
spec: PlotSpec, table: LongTable, *, promote: bool = True
|
|
80
|
+
) -> dict[str, Role]:
|
|
81
|
+
"""
|
|
82
|
+
Every factor in ``table`` mapped to a role.
|
|
83
|
+
|
|
84
|
+
Factors the spec doesn't mention default to FREE — they stay in the frame
|
|
85
|
+
as replicate rows, which is the conservative choice: it never silently
|
|
86
|
+
drops or averages data the user didn't ask to drop or average.
|
|
87
|
+
|
|
88
|
+
The one exception is the synthetic ``Variant`` factor once it has more than
|
|
89
|
+
one level, which defaults to COLOR. FREE is not the conservative choice
|
|
90
|
+
*there*: it would overplot two variants the user has just gone to the
|
|
91
|
+
trouble of naming, which is the exact failure this whole feature exists to
|
|
92
|
+
prevent — and ``validate`` would refuse it a moment later anyway, so the
|
|
93
|
+
alternative is an error message instead of the figure they asked for. Same
|
|
94
|
+
reasoning as ``default_roles`` giving the first variant factor COLOR.
|
|
95
|
+
|
|
96
|
+
Finally, schema keys nested above an iterated key are promoted to ITERATE
|
|
97
|
+
(:func:`iterate_ancestors`). It happens **here**, rather than only where the
|
|
98
|
+
fan-out is built, so that every consumer sees one consistent assignment: if
|
|
99
|
+
``reduce`` fanned out over a key that ``capability`` still believed was FREE,
|
|
100
|
+
the panel would advertise a distribution for figures holding a single
|
|
101
|
+
observation. ``promote=False`` returns the roles as declared, which is what
|
|
102
|
+
reporting the promotion needs.
|
|
103
|
+
"""
|
|
104
|
+
roles = {name: role for name, role in spec.roles.items() if table.has_factor(name)}
|
|
105
|
+
for factor in table.factors:
|
|
106
|
+
if (
|
|
107
|
+
factor.name == VARIANT_FACTOR
|
|
108
|
+
and factor.name not in roles
|
|
109
|
+
and len(factor.levels) > 1
|
|
110
|
+
):
|
|
111
|
+
taken = set(roles.values())
|
|
112
|
+
roles[factor.name] = (
|
|
113
|
+
Role.COLOR if Role.COLOR not in taken else Role.FACET
|
|
114
|
+
)
|
|
115
|
+
continue
|
|
116
|
+
if factor.name == VARIABLE_COLUMN and factor.name not in roles:
|
|
117
|
+
# FREE would pool two different QUANTITIES — EMG drawn as a
|
|
118
|
+
# replicate of force — which `validate` refuses outright, so
|
|
119
|
+
# defaulting to it would hand the user an error instead of a
|
|
120
|
+
# figure. FACET is the shape the request came in as: "two mean +
|
|
121
|
+
# error band plots", one panel each.
|
|
122
|
+
#
|
|
123
|
+
# Its own branch because `Variable` is not a variant factor
|
|
124
|
+
# (ScidbSource appends it to `factors`, never to
|
|
125
|
+
# `variant_factors`), so the clause above does not reach it.
|
|
126
|
+
roles[factor.name] = Role.FACET
|
|
127
|
+
continue
|
|
128
|
+
roles.setdefault(factor.name, Role.FREE)
|
|
129
|
+
|
|
130
|
+
if promote:
|
|
131
|
+
for name in iterate_ancestors(roles, table):
|
|
132
|
+
roles[name] = Role.ITERATE
|
|
133
|
+
return roles
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def iterate_ancestors(roles: dict[str, Role], table: LongTable) -> list[str]:
|
|
137
|
+
"""
|
|
138
|
+
Schema keys that must iterate because a key nested under them does.
|
|
139
|
+
|
|
140
|
+
"One figure per trial" is never really one figure per trial: with a schema
|
|
141
|
+
of ``[subject, trial]``, trial 1 belongs to a subject, and a figure holding
|
|
142
|
+
every subject's trial 1 pools unrelated observations. So an iterated key
|
|
143
|
+
iterates its ancestors too — the fan-out becomes one figure per
|
|
144
|
+
(subject, trial), which is also what makes stepping past subject 1's last
|
|
145
|
+
trial roll over to subject 2's first.
|
|
146
|
+
|
|
147
|
+
Promoted **only from FREE**. A user who assigned an ancestor a channel meant
|
|
148
|
+
it: ``subject=colour, trial=separate figures`` is a legitimate figure (every
|
|
149
|
+
subject coloured, one figure per trial) and must survive. FREE is the one
|
|
150
|
+
role that would silently pool, and pooling is the mistake this prevents;
|
|
151
|
+
``AGGREGATE`` remains the way to say "average the subjects away" on purpose.
|
|
152
|
+
|
|
153
|
+
Pure, and called twice per resolve — once by :func:`complete_roles` to apply
|
|
154
|
+
it and once by ``reduce._fanout_notes`` to report it — rather than threading
|
|
155
|
+
the result through as state. The reporting call must pass **unpromoted**
|
|
156
|
+
roles (``complete_roles(..., promote=False)``): read back off its own output
|
|
157
|
+
this returns nothing, because every ancestor is ITERATE by then, and the
|
|
158
|
+
note explaining a fan-out four times the expected size would never appear.
|
|
159
|
+
"""
|
|
160
|
+
if not table.schema_levels:
|
|
161
|
+
return []
|
|
162
|
+
promoted: list[str] = []
|
|
163
|
+
for name, role in roles.items():
|
|
164
|
+
if role is not Role.ITERATE or name not in table.schema_levels:
|
|
165
|
+
continue
|
|
166
|
+
for ancestor in table.schema_levels[: table.schema_levels.index(name)]:
|
|
167
|
+
if (
|
|
168
|
+
table.has_factor(ancestor)
|
|
169
|
+
and roles.get(ancestor, Role.FREE) is Role.FREE
|
|
170
|
+
and ancestor not in promoted
|
|
171
|
+
):
|
|
172
|
+
promoted.append(ancestor)
|
|
173
|
+
return promoted
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def fanout_keys(spec: PlotSpec, table: LongTable) -> list[str]:
|
|
177
|
+
"""
|
|
178
|
+
The factors this spec fans out over, **in the order the figures run**.
|
|
179
|
+
|
|
180
|
+
Ordered by the table's declared factor order — for a scidb source that is
|
|
181
|
+
variants, then schema keys outermost-first — and NOT by the order the roles
|
|
182
|
+
dict happens to hold. Dict order is whichever role the user clicked first,
|
|
183
|
+
so assigning ``trial`` before ``subject`` produced a trial-major fan-out:
|
|
184
|
+
figures ordered trial-1-subject-1, trial-1-subject-2, …, which makes the
|
|
185
|
+
exported ``PathOutput`` template and the panel's next/previous arrows both
|
|
186
|
+
run in an order nobody asked for.
|
|
187
|
+
|
|
188
|
+
One definition, used by ``reduce.resolve`` (the interactive fan-out) and by
|
|
189
|
+
``scistackplotdb.endpoint`` (the ``for_each`` iteration keys), because those
|
|
190
|
+
two disagreeing is this layer's worst failure — see
|
|
191
|
+
``scistackplotdb/tests/test_fanout_parity.py``.
|
|
192
|
+
"""
|
|
193
|
+
roles = complete_roles(spec, table)
|
|
194
|
+
order = table.factor_names
|
|
195
|
+
names = [
|
|
196
|
+
name
|
|
197
|
+
for name, role in roles.items()
|
|
198
|
+
if role is Role.ITERATE and table.has_factor(name)
|
|
199
|
+
]
|
|
200
|
+
return sorted(names, key=order.index)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def validate(spec: PlotSpec, table: LongTable) -> None:
|
|
204
|
+
"""Raise :class:`RoleError` if the spec cannot be resolved against the table."""
|
|
205
|
+
# --- measures exist -------------------------------------------------
|
|
206
|
+
if not spec.measures:
|
|
207
|
+
raise RoleError("PlotSpec.measures is empty — name at least a y measure.")
|
|
208
|
+
if len(spec.measures) > 1:
|
|
209
|
+
raise RoleError(
|
|
210
|
+
f"PlotSpec.measures names one y measure; got {spec.measures}. "
|
|
211
|
+
f"To plot several variables together, add a variant row per "
|
|
212
|
+
f"variable (VariantSet(variable=...)) — they stack into the "
|
|
213
|
+
f"'Variant' factor and can take a colour or a facet. For an x-y "
|
|
214
|
+
f"plot, set x_measure."
|
|
215
|
+
)
|
|
216
|
+
for measure in [*spec.measures, spec.x_measure]:
|
|
217
|
+
if measure is not None and measure not in table.measure_names:
|
|
218
|
+
raise RoleError(
|
|
219
|
+
f"Measure {measure!r} is not in the table. "
|
|
220
|
+
f"Available measures: {table.measure_names}"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# --- roles name real factors ----------------------------------------
|
|
224
|
+
unknown = [name for name in spec.roles if not table.has_factor(name)]
|
|
225
|
+
if unknown:
|
|
226
|
+
raise RoleError(
|
|
227
|
+
f"Roles assigned to unknown factor(s) {unknown}. "
|
|
228
|
+
f"Table factors: {table.factor_names}"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# --- single-assignment channels -------------------------------------
|
|
232
|
+
for role in SINGLE_ASSIGNMENT_ROLES:
|
|
233
|
+
holders = spec.factors_with_role(role)
|
|
234
|
+
if len(holders) > 1:
|
|
235
|
+
raise RoleError(
|
|
236
|
+
f"Role {role} accepts one factor but got {holders}. "
|
|
237
|
+
f"Move all but one to 'facet' or 'free'."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
shape = table.shape_of(spec.y_measure)
|
|
241
|
+
|
|
242
|
+
# --- x-axis ownership ------------------------------------------------
|
|
243
|
+
x_layers = spec.ordered_x_layers()
|
|
244
|
+
if len(x_layers) > MAX_X_LAYERS:
|
|
245
|
+
raise RoleError(
|
|
246
|
+
f"At most {MAX_X_LAYERS} factors can share the x axis; got "
|
|
247
|
+
f"{len(x_layers)}: {x_layers}. A fourth level of nesting cannot be "
|
|
248
|
+
f"read off an axis — move one to 'color', a facet role, or "
|
|
249
|
+
f"'separate figures'."
|
|
250
|
+
)
|
|
251
|
+
if len(x_layers) > 1 and shape is not Shape.SCALAR:
|
|
252
|
+
raise RoleError(
|
|
253
|
+
f"Nested x grouping needs a categorical axis, but measure "
|
|
254
|
+
f"{spec.y_measure!r} is {shape} — its x axis is "
|
|
255
|
+
f"{'its within-observation index' if shape is Shape.SERIES_1D else 'the matrix itself'}. "
|
|
256
|
+
f"Group a 1-D measure with 'color' and facets instead."
|
|
257
|
+
)
|
|
258
|
+
x_holder = spec.first_with_role(Role.X)
|
|
259
|
+
if spec.x_measure is not None and x_holder is not None:
|
|
260
|
+
raise RoleError(
|
|
261
|
+
f"Measure {spec.x_measure!r} already supplies the x axis, so factor "
|
|
262
|
+
f"{x_holder!r} cannot also hold role 'x'. Give it 'color', a facet "
|
|
263
|
+
f"role, or 'free'."
|
|
264
|
+
)
|
|
265
|
+
if shape is Shape.SERIES_1D and x_holder is not None and spec.x_measure is None:
|
|
266
|
+
raise RoleError(
|
|
267
|
+
f"Measure {spec.y_measure!r} is 1-D, so the x axis is its "
|
|
268
|
+
f"within-observation index ({spec.index_column or 'index'}); factor "
|
|
269
|
+
f"{x_holder!r} cannot hold role 'x'. Give it 'color', a facet role, "
|
|
270
|
+
f"or 'free'."
|
|
271
|
+
)
|
|
272
|
+
if shape is Shape.MATRIX_2D and x_holder is not None:
|
|
273
|
+
raise RoleError(
|
|
274
|
+
f"Measure {spec.y_measure!r} is 2-D (heatmap); its axes come from the "
|
|
275
|
+
f"matrix itself, so factor {x_holder!r} cannot hold role 'x'."
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# --- variants must not be pooled by ACCIDENT --------------------------
|
|
279
|
+
#
|
|
280
|
+
# Pooling variants is legal and sometimes exactly right: five variants of a
|
|
281
|
+
# 1-D measure averaged into one trace (AGGREGATE), or left as replicates so
|
|
282
|
+
# BAND can draw a mean ± error across them (FREE). What must never happen is
|
|
283
|
+
# pooling nobody asked for — two pipelines' results silently read as
|
|
284
|
+
# replicates of one condition.
|
|
285
|
+
#
|
|
286
|
+
# The difference is visible in the spec: a role the user chose is in
|
|
287
|
+
# ``spec.roles``; a role nobody chose is filled in by ``complete_roles``. So
|
|
288
|
+
# the test is on the DEFAULTED ones only. (This replaces ``variant_policy``,
|
|
289
|
+
# which was a second switch for the same decision — the state where the
|
|
290
|
+
# policy said "facet" and a factor was explicitly set to "free" had no
|
|
291
|
+
# defensible meaning.)
|
|
292
|
+
assigned = complete_roles(spec, table)
|
|
293
|
+
pooled = [
|
|
294
|
+
f.name
|
|
295
|
+
for f in table.variant_factors
|
|
296
|
+
if len(f.levels) > 1
|
|
297
|
+
and f.name not in spec.roles
|
|
298
|
+
and assigned.get(f.name, Role.FREE) in (Role.FREE, Role.AGGREGATE)
|
|
299
|
+
]
|
|
300
|
+
if pooled:
|
|
301
|
+
raise RoleError(
|
|
302
|
+
f"Variant factor(s) {pooled} would be pooled: their levels are "
|
|
303
|
+
f"different pipeline variants, not replicates, so averaging or "
|
|
304
|
+
f"overplotting them silently mixes results. Assign them "
|
|
305
|
+
f"'color'/'facet'/'iterate', select the variants you want with "
|
|
306
|
+
f"PlotSpec.variant_sets, or — to pool them deliberately — set them "
|
|
307
|
+
f"to 'aggregate' or 'free' yourself."
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
# --- variables must never be pooled at all ----------------------------
|
|
311
|
+
#
|
|
312
|
+
# `Variable` is a factor only when the figure draws more than one (see
|
|
313
|
+
# `variants._answered`), and then it must SEPARATE them. Averaging EMG with
|
|
314
|
+
# force, or overplotting them as replicates of each other, is not a figure
|
|
315
|
+
# anyone wants — unlike variants of one variable, which is why this is a
|
|
316
|
+
# flat refusal where the rule above is an opt-in.
|
|
317
|
+
if table.has_factor(VARIABLE_COLUMN):
|
|
318
|
+
variable_role = assigned.get(VARIABLE_COLUMN, Role.FREE)
|
|
319
|
+
if variable_role in (Role.FREE, Role.AGGREGATE):
|
|
320
|
+
raise RoleError(
|
|
321
|
+
f"{VARIABLE_COLUMN!r} cannot hold role {variable_role}: its "
|
|
322
|
+
f"levels are different variables, so averaging or overplotting "
|
|
323
|
+
f"them combines unrelated quantities. Give it "
|
|
324
|
+
f"'color'/'facet'/'iterate'/'x' to keep them apart."
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# --- 1-D needs an index ---------------------------------------------
|
|
328
|
+
if shape is Shape.SERIES_1D and spec.index_column:
|
|
329
|
+
if (
|
|
330
|
+
spec.index_column not in table.frame.columns
|
|
331
|
+
and spec.index_column != table.index_column
|
|
332
|
+
):
|
|
333
|
+
raise RoleError(
|
|
334
|
+
f"index_column {spec.index_column!r} is neither a column of the "
|
|
335
|
+
f"table nor its declared index column ({table.index_column!r})."
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def default_spec(table: LongTable, measure: str | None = None) -> PlotSpec:
|
|
340
|
+
"""
|
|
341
|
+
The spec a table opens on: default roles, the matching default kind, and a
|
|
342
|
+
facet grid wide enough for however many fields the measure has.
|
|
343
|
+
|
|
344
|
+
Lives here rather than in the GUI so the panel and a library caller open on
|
|
345
|
+
the same figure (CLAUDE.md NOTE 3).
|
|
346
|
+
"""
|
|
347
|
+
from .capability import default_plot
|
|
348
|
+
from .spec import FacetOptions, PlotKind, VariantSet, grid_shape_for
|
|
349
|
+
|
|
350
|
+
from .variants import apply_variant_sets, default_selection
|
|
351
|
+
|
|
352
|
+
measure = measure or (table.measure_names[0] if table.measures else None)
|
|
353
|
+
if measure is None:
|
|
354
|
+
raise RoleError("This table has no measures to plot.")
|
|
355
|
+
|
|
356
|
+
# Open on ONE variant, as a single row — ALWAYS, even when there is nothing
|
|
357
|
+
# to pin.
|
|
358
|
+
#
|
|
359
|
+
# Every variant axis is pinned, not just the code ones: latest body, first
|
|
360
|
+
# parameter value (`variants.default_selection` owns the rule and states it
|
|
361
|
+
# in full). Pinning only "current code" left a swept parameter unanswered,
|
|
362
|
+
# so `default_roles` found a multi-level variant factor with no role and put
|
|
363
|
+
# it on COLOUR — a variable produced at 5 cutoffs opened as 5 overlaid
|
|
364
|
+
# series before the user had said anything at all. Comparing is what a
|
|
365
|
+
# second row is for.
|
|
366
|
+
#
|
|
367
|
+
# The row is seeded unconditionally because it is what the Variants section
|
|
368
|
+
# SHOWS: a project with no variant axes at all used to open with an empty
|
|
369
|
+
# list, so the section said nothing about the variable being plotted and
|
|
370
|
+
# the user's first row appeared only once they added a second (the GUI was
|
|
371
|
+
# seeding row 0 lazily, in TypeScript — a NOTE 3 violation this removes).
|
|
372
|
+
#
|
|
373
|
+
# It carries no `variable`: None means "the primary measure", which keeps
|
|
374
|
+
# the row INERT while its selection is empty (`defined_sets`). Naming the
|
|
375
|
+
# measure explicitly would make every table grow a one-level `Variant`
|
|
376
|
+
# factor that says nothing.
|
|
377
|
+
#
|
|
378
|
+
# The name is left to `set_name`, which builds it from the variable and the
|
|
379
|
+
# selection — "FilteredEMG", or "FilteredEMG · current" once there are code
|
|
380
|
+
# versions to be current among. The old hardcoded "current" named the pin
|
|
381
|
+
# after the least interesting thing about it.
|
|
382
|
+
variant_sets = [VariantSet(selection=default_selection(table))]
|
|
383
|
+
|
|
384
|
+
# Roles describe the table AS RESOLVED, so they are derived after the
|
|
385
|
+
# variants are chosen — never before. Defaulting against the undecided table
|
|
386
|
+
# put a role on a `Code:<fn>` factor that the opening variant then answered,
|
|
387
|
+
# and `validate` calls a role on a missing factor an unknown factor and
|
|
388
|
+
# refuses to draw anything. (`strip_answered_roles` catches the same thing
|
|
389
|
+
# arriving from a saved spec; this stops it being created here at all.)
|
|
390
|
+
resolved = apply_variant_sets(
|
|
391
|
+
PlotSpec(measures=[measure], variant_sets=variant_sets), table
|
|
392
|
+
)
|
|
393
|
+
roles = default_roles(resolved, measure)
|
|
394
|
+
kind = default_plot(resolved.shape_of(measure), roles) or PlotKind.SCATTER
|
|
395
|
+
|
|
396
|
+
# A 13-muscle struct wants a grid, not a 13-wide strip of subplots. The
|
|
397
|
+
# arithmetic lives in grid_shape_for so the panel, the renderer and this
|
|
398
|
+
# default cannot disagree about what "auto" means. Only the width is pinned:
|
|
399
|
+
# leaving n_rows open lets the height follow the panel count if the data
|
|
400
|
+
# gains a field.
|
|
401
|
+
facet_panels = math.prod(
|
|
402
|
+
[len(f.levels) for f in resolved.factors if roles.get(f.name) is Role.FACET]
|
|
403
|
+
or [0]
|
|
404
|
+
)
|
|
405
|
+
_, n_cols = grid_shape_for(facet_panels) if facet_panels > 1 else (1, None)
|
|
406
|
+
|
|
407
|
+
return PlotSpec(
|
|
408
|
+
measures=[measure],
|
|
409
|
+
roles=roles,
|
|
410
|
+
kind=kind,
|
|
411
|
+
facet=FacetOptions(n_cols=n_cols),
|
|
412
|
+
variant_sets=variant_sets,
|
|
413
|
+
)
|