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.
@@ -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
+ )