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
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Which plot kinds are available, and which one to pick by default.
|
|
3
|
+
|
|
4
|
+
This is the single rule behind two of the requirements that look separate:
|
|
5
|
+
"different data types get different default plots", and "iterating over a
|
|
6
|
+
higher schema level unlocks more summative plot types". Both fall out of one
|
|
7
|
+
observation — a distribution needs replicates, and replicates exist only when
|
|
8
|
+
some factor is left FREE (not mapped to a channel, not collapsed).
|
|
9
|
+
|
|
10
|
+
The GUI must render only what ``available_plots`` returns. Plot policy lives
|
|
11
|
+
here, not in TypeScript (CLAUDE.md NOTE 3).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import replace
|
|
17
|
+
|
|
18
|
+
from .shape import Shape
|
|
19
|
+
from .spec import PlotKind, PlotSpec, Role
|
|
20
|
+
from .table import CODE_FACTOR_PREFIX, LongTable
|
|
21
|
+
|
|
22
|
+
#: Kinds that summarize several rows per x position into one mark.
|
|
23
|
+
DISTRIBUTION_KINDS = (PlotKind.BOX, PlotKind.VIOLIN, PlotKind.BAR, PlotKind.BAND)
|
|
24
|
+
|
|
25
|
+
#: What each role is CALLED, per measure shape.
|
|
26
|
+
#:
|
|
27
|
+
#: The role names are the library's vocabulary; these are the user's. Two of
|
|
28
|
+
#: them read as nonsense on 1-D data under the generic wording — "Average over"
|
|
29
|
+
#: and "Replicates" describe what happens to a table, and what the user is
|
|
30
|
+
#: looking at is a set of traces. Reported by the backend rather than hardcoded
|
|
31
|
+
#: in the panel so the words and the behaviour stay together (CLAUDE.md NOTE 3).
|
|
32
|
+
#:
|
|
33
|
+
#: Only the entries that differ from :data:`_ROLE_LABELS` need listing.
|
|
34
|
+
_ROLE_LABELS_BY_SHAPE: dict[Shape, dict[Role, str]] = {
|
|
35
|
+
Shape.SERIES_1D: {
|
|
36
|
+
Role.AGGREGATE: "Average into one line",
|
|
37
|
+
Role.FREE: "One line each",
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#: The order the dropdown lists roles in — NOT ``Role``'s declaration order,
|
|
42
|
+
#: which is grouped by what each role does to the data and puts "Separate
|
|
43
|
+
#: figures" first. This is the order a user reaches for: the channels that
|
|
44
|
+
#: place data on the page, then the two that reduce it.
|
|
45
|
+
ROLE_ORDER: tuple[Role, ...] = (
|
|
46
|
+
Role.X,
|
|
47
|
+
Role.COLOR,
|
|
48
|
+
Role.FACET,
|
|
49
|
+
Role.ITERATE,
|
|
50
|
+
Role.AGGREGATE,
|
|
51
|
+
Role.FREE,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
#: The roles the **Factors** control offers. X is absent on purpose.
|
|
55
|
+
#:
|
|
56
|
+
#: "Which factors group the x axis, and in what order" is one question with two
|
|
57
|
+
#: halves, and it used to be asked in two places that could not see each other:
|
|
58
|
+
#: a per-factor dropdown for membership, and a separate "X grouping" list for
|
|
59
|
+
#: the order — which only appeared once two factors already held X, so it was
|
|
60
|
+
#: unreachable until the user had found the dropdown first. Both halves now live
|
|
61
|
+
#: in the Grouping section, and the x axis is chosen in exactly one place.
|
|
62
|
+
FACTOR_ROLE_ORDER: tuple[Role, ...] = tuple(r for r in ROLE_ORDER if r is not Role.X)
|
|
63
|
+
|
|
64
|
+
_ROLE_LABELS: dict[Role, str] = {
|
|
65
|
+
Role.X: "X axis",
|
|
66
|
+
Role.COLOR: "Color",
|
|
67
|
+
Role.FACET: "Facet",
|
|
68
|
+
Role.ITERATE: "Separate figures",
|
|
69
|
+
Role.AGGREGATE: "Average over",
|
|
70
|
+
Role.FREE: "Replicates",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_ROLE_HINTS: dict[Role, str] = {
|
|
74
|
+
Role.X: "Position along the x axis",
|
|
75
|
+
Role.COLOR: "One coloured series per level",
|
|
76
|
+
Role.FACET: "One subplot per level — arrange them under Layout",
|
|
77
|
+
Role.ITERATE: "One whole figure per level",
|
|
78
|
+
Role.AGGREGATE: "Collapse this factor to its mean",
|
|
79
|
+
Role.FREE: "Keep as repeated observations",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
_ROLE_HINTS_BY_SHAPE: dict[Shape, dict[Role, str]] = {
|
|
83
|
+
Shape.SERIES_1D: {
|
|
84
|
+
Role.AGGREGATE: "Average these traces together, sample by sample",
|
|
85
|
+
Role.FREE: "Draw one trace per level — and what a mean ± error band "
|
|
86
|
+
"is computed from",
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def role_label(role: Role, shape: Shape) -> str:
|
|
92
|
+
"""What to call this role for a measure of this shape."""
|
|
93
|
+
return _ROLE_LABELS_BY_SHAPE.get(shape, {}).get(role) or _ROLE_LABELS[role]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def role_hint(role: Role, shape: Shape) -> str:
|
|
97
|
+
"""The one-line explanation under the label."""
|
|
98
|
+
return _ROLE_HINTS_BY_SHAPE.get(shape, {}).get(role) or _ROLE_HINTS[role]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _validation_error(spec: PlotSpec, table: LongTable) -> str | None:
|
|
102
|
+
"""``validate``'s complaint about this spec, or None if it has none."""
|
|
103
|
+
from .roles import RoleError, validate
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
validate(spec, table)
|
|
107
|
+
except RoleError as exc:
|
|
108
|
+
return str(exc)
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def role_options(
|
|
113
|
+
spec: PlotSpec,
|
|
114
|
+
table: LongTable,
|
|
115
|
+
factor: str,
|
|
116
|
+
*,
|
|
117
|
+
roles: "tuple[Role, ...] | None" = None,
|
|
118
|
+
) -> list[dict]:
|
|
119
|
+
"""Every role for one factor: its label, whether it is legal, and why not.
|
|
120
|
+
|
|
121
|
+
**Derived by asking** :func:`~scistackplot.roles.validate`, one candidate
|
|
122
|
+
spec per role, rather than by restating its rules. That is the whole design:
|
|
123
|
+
the panel used to offer all six roles unconditionally while ``validate``
|
|
124
|
+
refused several of them, so a user could pick an option and be told it was
|
|
125
|
+
impossible — X on a 1-D measure, a second factor on COLOR, X when an
|
|
126
|
+
``x_measure`` already supplies the axis. Any rule expressed here in parallel
|
|
127
|
+
would be a second copy free to drift from the one that actually decides.
|
|
128
|
+
Asking cannot drift, and a new rule in ``validate`` reaches the panel for
|
|
129
|
+
free.
|
|
130
|
+
|
|
131
|
+
Cheap enough to do per factor: ``validate`` compares names, shapes and
|
|
132
|
+
roles and never touches the frame.
|
|
133
|
+
|
|
134
|
+
A spec that is ALREADY invalid for an unrelated reason (the panel shows
|
|
135
|
+
that as its own error) must not make every role look forbidden, so a role
|
|
136
|
+
counts as unavailable only when it fails for a reason the spec does not
|
|
137
|
+
already have.
|
|
138
|
+
|
|
139
|
+
``roles`` limits which to report; the default is all of them in
|
|
140
|
+
:data:`ROLE_ORDER`.
|
|
141
|
+
"""
|
|
142
|
+
shape = table.shape_of(spec.y_measure)
|
|
143
|
+
base_error = _validation_error(spec, table)
|
|
144
|
+
|
|
145
|
+
options = []
|
|
146
|
+
for role in roles if roles is not None else ROLE_ORDER:
|
|
147
|
+
candidate = replace(spec, roles={**spec.roles, factor: role})
|
|
148
|
+
error = _validation_error(candidate, table)
|
|
149
|
+
blocked = error is not None and error != base_error
|
|
150
|
+
options.append(
|
|
151
|
+
{
|
|
152
|
+
"role": str(role),
|
|
153
|
+
"label": role_label(role, shape),
|
|
154
|
+
"hint": role_hint(role, shape),
|
|
155
|
+
"available": not blocked,
|
|
156
|
+
# validate's own message, which always names the one-line fix.
|
|
157
|
+
"reason": error if blocked else None,
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
return options
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def has_replicates(roles: dict[str, Role]) -> bool:
|
|
164
|
+
"""
|
|
165
|
+
True when some factor's levels survive as multiple rows per plotted cell.
|
|
166
|
+
|
|
167
|
+
AGGREGATE deliberately does not count: it collapses its factor to a mean
|
|
168
|
+
*before* plotting, so it removes replicates rather than providing them.
|
|
169
|
+
Its purpose is noise reduction ("average over trials"), after which a
|
|
170
|
+
remaining FREE factor (e.g. subject) is what supplies the distribution.
|
|
171
|
+
"""
|
|
172
|
+
return any(role is Role.FREE for role in roles.values())
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def available_plots(
|
|
176
|
+
shape: Shape,
|
|
177
|
+
roles: dict[str, Role],
|
|
178
|
+
*,
|
|
179
|
+
has_x_measure: bool = False,
|
|
180
|
+
) -> list[PlotKind]:
|
|
181
|
+
"""Plot kinds that can be rendered for this shape and role assignment."""
|
|
182
|
+
if shape is Shape.MATRIX_2D:
|
|
183
|
+
return [PlotKind.HEATMAP]
|
|
184
|
+
|
|
185
|
+
if has_x_measure:
|
|
186
|
+
# x comes from a second measure: a relational scatter, optionally with
|
|
187
|
+
# a connecting line when the x measure is ordered.
|
|
188
|
+
return [PlotKind.SCATTER, PlotKind.LINE]
|
|
189
|
+
|
|
190
|
+
replicates = has_replicates(roles)
|
|
191
|
+
|
|
192
|
+
if shape is Shape.SERIES_1D:
|
|
193
|
+
kinds = [PlotKind.LINE]
|
|
194
|
+
if replicates:
|
|
195
|
+
kinds.append(PlotKind.BAND)
|
|
196
|
+
return kinds
|
|
197
|
+
|
|
198
|
+
if shape is Shape.SCALAR:
|
|
199
|
+
kinds = [PlotKind.SCATTER, PlotKind.STRIP]
|
|
200
|
+
if replicates:
|
|
201
|
+
kinds.extend([PlotKind.BOX, PlotKind.VIOLIN, PlotKind.BAR])
|
|
202
|
+
return kinds
|
|
203
|
+
|
|
204
|
+
return []
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def default_plot(
|
|
208
|
+
shape: Shape,
|
|
209
|
+
roles: dict[str, Role],
|
|
210
|
+
*,
|
|
211
|
+
has_x_measure: bool = False,
|
|
212
|
+
) -> PlotKind | None:
|
|
213
|
+
"""
|
|
214
|
+
The kind to select when a table is first opened.
|
|
215
|
+
|
|
216
|
+
scalar → scatter, or box once there are replicates to distribute;
|
|
217
|
+
1-D → one line per observation, or a mean line with a shaded error region
|
|
218
|
+
once there are replicates; 2-D → heatmap.
|
|
219
|
+
"""
|
|
220
|
+
kinds = available_plots(shape, roles, has_x_measure=has_x_measure)
|
|
221
|
+
if not kinds:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
replicates = has_replicates(roles)
|
|
225
|
+
if has_x_measure:
|
|
226
|
+
return PlotKind.SCATTER
|
|
227
|
+
if shape is Shape.SERIES_1D:
|
|
228
|
+
return PlotKind.BAND if replicates else PlotKind.LINE
|
|
229
|
+
if shape is Shape.SCALAR:
|
|
230
|
+
return PlotKind.BOX if replicates else PlotKind.SCATTER
|
|
231
|
+
return kinds[0]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def why_unavailable(kind: PlotKind, shape: Shape, roles: dict[str, Role]) -> str | None:
|
|
235
|
+
"""
|
|
236
|
+
Explain a kind's absence, for GUI tooltips on disabled options.
|
|
237
|
+
|
|
238
|
+
Returns None when the kind IS available.
|
|
239
|
+
"""
|
|
240
|
+
if kind in available_plots(shape, roles):
|
|
241
|
+
return None
|
|
242
|
+
if shape is Shape.MATRIX_2D:
|
|
243
|
+
return "2-D measures render as a heatmap."
|
|
244
|
+
if kind in DISTRIBUTION_KINDS and not has_replicates(roles):
|
|
245
|
+
return (
|
|
246
|
+
"Needs replicates: leave at least one factor 'free' (unassigned) so "
|
|
247
|
+
"each x position has several values to summarize."
|
|
248
|
+
)
|
|
249
|
+
if kind is PlotKind.BAND and shape is not Shape.SERIES_1D:
|
|
250
|
+
return "Error bands apply to 1-D measures."
|
|
251
|
+
if kind is PlotKind.LINE and shape is Shape.SCALAR:
|
|
252
|
+
return "Lines need a 1-D measure or a second measure for the x axis."
|
|
253
|
+
return f"Not available for a {shape} measure."
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def capabilities(spec: PlotSpec, table: LongTable) -> dict:
|
|
257
|
+
"""
|
|
258
|
+
The full JSON-serializable capability report for the GUI.
|
|
259
|
+
|
|
260
|
+
One call gives the panel everything it needs to render its controls:
|
|
261
|
+
which kinds are selectable, why the others are not, and what the default
|
|
262
|
+
would be for the current role assignment.
|
|
263
|
+
"""
|
|
264
|
+
from .groups import apply_level_groups
|
|
265
|
+
from .roles import complete_roles
|
|
266
|
+
from .variants import apply_variant_sets, strip_answered_roles
|
|
267
|
+
|
|
268
|
+
# The kinds and roles reported must be the ones the figure will actually be
|
|
269
|
+
# built with, so the derived table (named variants folded into a ``Variant``
|
|
270
|
+
# factor) is what they are computed against — exactly as ``resolve`` does,
|
|
271
|
+
# stale-role drop included, or the panel would offer a role selector for a
|
|
272
|
+
# factor the render is about to reject.
|
|
273
|
+
derived = apply_level_groups(spec, apply_variant_sets(spec, table))
|
|
274
|
+
spec = strip_answered_roles(spec, table, derived)
|
|
275
|
+
roles = complete_roles(spec, derived)
|
|
276
|
+
shape = derived.shape_of(spec.y_measure)
|
|
277
|
+
has_x_measure = spec.x_measure is not None
|
|
278
|
+
allowed = available_plots(shape, roles, has_x_measure=has_x_measure)
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
"shape": str(shape),
|
|
282
|
+
"has_replicates": has_replicates(roles),
|
|
283
|
+
"default": str(default_plot(shape, roles, has_x_measure=has_x_measure) or ""),
|
|
284
|
+
"available": [str(k) for k in allowed],
|
|
285
|
+
"kinds": [
|
|
286
|
+
{
|
|
287
|
+
"kind": str(kind),
|
|
288
|
+
"available": kind in allowed,
|
|
289
|
+
"reason": why_unavailable(kind, shape, roles),
|
|
290
|
+
}
|
|
291
|
+
for kind in PlotKind
|
|
292
|
+
],
|
|
293
|
+
"roles": {name: str(role) for name, role in roles.items()},
|
|
294
|
+
"factors": factor_summary(spec, derived),
|
|
295
|
+
"grouping": grouping_summary(spec, derived),
|
|
296
|
+
"variants": variant_summary(spec, table),
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def factors_menu(spec: PlotSpec, table: LongTable, factor: str) -> list[dict]:
|
|
301
|
+
"""What the **Factors** dropdown lists for one factor.
|
|
302
|
+
|
|
303
|
+
:func:`role_options` restricted to :data:`FACTOR_ROLE_ORDER`, plus X **only
|
|
304
|
+
when this factor already holds it**. A ``<select>`` whose value is not among
|
|
305
|
+
its options renders blank, and a scalar table opens with one factor on X by
|
|
306
|
+
default (``roles.default_roles``) — so dropping X unconditionally would
|
|
307
|
+
empty the control for exactly the factor the user is most likely to look at
|
|
308
|
+
first.
|
|
309
|
+
|
|
310
|
+
That X is then reported **unavailable even though it is perfectly legal**,
|
|
311
|
+
which is the one place this report says something ``validate`` does not.
|
|
312
|
+
The difference is deliberate and is presentation, not validity: X is listed
|
|
313
|
+
here so the control can display its own value, while *setting* it belongs
|
|
314
|
+
to the Grouping section. Keeping it selectable in both places is how the
|
|
315
|
+
two controls would start disagreeing — the thing merging them was meant to
|
|
316
|
+
stop.
|
|
317
|
+
"""
|
|
318
|
+
roles = (
|
|
319
|
+
(Role.X, *FACTOR_ROLE_ORDER)
|
|
320
|
+
if spec.roles.get(factor) is Role.X
|
|
321
|
+
else FACTOR_ROLE_ORDER
|
|
322
|
+
)
|
|
323
|
+
options = role_options(spec, table, factor, roles=roles)
|
|
324
|
+
for option in options:
|
|
325
|
+
if option["role"] == str(Role.X):
|
|
326
|
+
option["available"] = False
|
|
327
|
+
option["reason"] = (
|
|
328
|
+
"The x axis is grouped in the Grouping section — untick this "
|
|
329
|
+
"factor there to take it off the axis."
|
|
330
|
+
)
|
|
331
|
+
return options
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def grouping_summary(spec: PlotSpec, table: LongTable) -> dict:
|
|
335
|
+
"""Whether the x axis can be grouped by factors, and how it is grouped now.
|
|
336
|
+
|
|
337
|
+
A factor on the x axis IS a categorical grouping: ``xaxis.plan_x_axis``
|
|
338
|
+
turns the observed level combinations into leaf positions with spacer
|
|
339
|
+
categories between groups, which is what lets box, violin, bar and strip
|
|
340
|
+
place themselves exactly as they already do. A continuous x never comes
|
|
341
|
+
from a factor — it comes from ``x_measure``, or for 1-D data from the
|
|
342
|
+
within-observation index — which is why this is offered for SCALAR measures
|
|
343
|
+
and refused, with a reason, for everything else.
|
|
344
|
+
"""
|
|
345
|
+
from .spec import MAX_X_LAYERS
|
|
346
|
+
|
|
347
|
+
shape = table.shape_of(spec.y_measure)
|
|
348
|
+
reason = None
|
|
349
|
+
if spec.x_measure is not None:
|
|
350
|
+
reason = (
|
|
351
|
+
f"{spec.x_measure!r} already supplies the x axis, so it is a "
|
|
352
|
+
f"measured value rather than groups of records."
|
|
353
|
+
)
|
|
354
|
+
elif shape is Shape.SERIES_1D:
|
|
355
|
+
reason = (
|
|
356
|
+
"This measure is 1-D: its x axis is the within-observation index "
|
|
357
|
+
"(time, or percent of cycle). Separate the groups with colour or "
|
|
358
|
+
"facets instead."
|
|
359
|
+
)
|
|
360
|
+
elif shape is Shape.MATRIX_2D:
|
|
361
|
+
reason = "This measure is 2-D: a heatmap's axes come from the matrix."
|
|
362
|
+
elif shape is not Shape.SCALAR:
|
|
363
|
+
reason = f"Grouping the x axis needs a scalar measure; this one is {shape}."
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
"available": reason is None,
|
|
367
|
+
"reason": reason,
|
|
368
|
+
# Membership and order reconciled the same way the figure does it, so
|
|
369
|
+
# the control cannot show an order the renderer disagrees with.
|
|
370
|
+
"layers": spec.ordered_x_layers(),
|
|
371
|
+
"max_layers": MAX_X_LAYERS,
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def factor_summary(spec: PlotSpec, derived: LongTable) -> list[dict]:
|
|
376
|
+
"""Every factor the panel renders, plus which of its levels survive filters.
|
|
377
|
+
|
|
378
|
+
``levels`` is what the table holds; ``selected`` is what ``spec.filters``
|
|
379
|
+
leaves, measured through :func:`~scistackplot.reduce.apply_filters` — the
|
|
380
|
+
function the figure itself uses. That shared rule is the point: a picker
|
|
381
|
+
reading "3 of 12 selected" beside a figure built from a different 3 would be
|
|
382
|
+
worse than showing no count at all.
|
|
383
|
+
|
|
384
|
+
A filter that empties a factor is reported honestly as zero selected. It is
|
|
385
|
+
a legitimate state to be in while clicking, and ``resolve`` renders the
|
|
386
|
+
empty figure rather than raising.
|
|
387
|
+
|
|
388
|
+
``roles`` is what the panel's Factors dropdown renders: each role labelled
|
|
389
|
+
for this measure's shape, flagged available or not, and carrying
|
|
390
|
+
``validate``'s own message when not (:func:`role_options`). X is not among
|
|
391
|
+
them — see :data:`FACTOR_ROLE_ORDER` — so ``x_available``/``x_reason``
|
|
392
|
+
report separately whether this factor may group the x axis, which is the
|
|
393
|
+
Grouping section's question.
|
|
394
|
+
"""
|
|
395
|
+
from .reduce import apply_filters
|
|
396
|
+
|
|
397
|
+
factors = derived.describe()["factors"]
|
|
398
|
+
for entry in factors:
|
|
399
|
+
name = entry["name"]
|
|
400
|
+
entry["roles"] = factors_menu(spec, derived, name)
|
|
401
|
+
on_x = role_options(spec, derived, name, roles=(Role.X,))[0]
|
|
402
|
+
entry["x_available"] = on_x["available"]
|
|
403
|
+
entry["x_reason"] = on_x["reason"]
|
|
404
|
+
|
|
405
|
+
if not spec.filters:
|
|
406
|
+
# Nothing filtered: everything is selected, and no frame scan is needed
|
|
407
|
+
# on the common path.
|
|
408
|
+
for entry in factors:
|
|
409
|
+
entry["selected"] = list(entry["levels"])
|
|
410
|
+
return factors
|
|
411
|
+
|
|
412
|
+
kept = apply_filters(derived.frame, spec)
|
|
413
|
+
for entry in factors:
|
|
414
|
+
name = entry["name"]
|
|
415
|
+
surviving = (
|
|
416
|
+
set(kept[name].astype(str)) if name in kept.columns else set()
|
|
417
|
+
)
|
|
418
|
+
entry["selected"] = [
|
|
419
|
+
level for level in entry["levels"] if str(level) in surviving
|
|
420
|
+
]
|
|
421
|
+
return factors
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def variant_summary(spec: PlotSpec, table: LongTable) -> dict:
|
|
425
|
+
"""The variant picker's whole data model, and the combination readout.
|
|
426
|
+
|
|
427
|
+
Three things, all measured against the same frame the renderer will use:
|
|
428
|
+
|
|
429
|
+
``sets``
|
|
430
|
+
One entry per named variant — its label (the user's, or the auto one it
|
|
431
|
+
would carry), the selection it holds, and **how many rows it actually
|
|
432
|
+
matched**. That last number is the one that catches real mistakes: a
|
|
433
|
+
variant selecting a combination nobody ever ran is indistinguishable
|
|
434
|
+
from a working one until the series silently fails to appear.
|
|
435
|
+
``factors``
|
|
436
|
+
Every variant axis in the data with its levels and its ``origin``, so
|
|
437
|
+
the popup can map an axis to the pipeline node that produced it without
|
|
438
|
+
parsing ``Code:`` or ``fn.param`` out of a column name. Taken from the
|
|
439
|
+
table BEFORE selection, deliberately: an axis a set has already pinned
|
|
440
|
+
is precisely the one the user needs to be able to re-open and change.
|
|
441
|
+
``total_combinations`` / ``selected_combinations``
|
|
442
|
+
How much of the variant space the current selection covers.
|
|
443
|
+
|
|
444
|
+
**Why the counts are measured, not computed.** Multiplying level counts
|
|
445
|
+
would be wrong in two ways that matter. The default selection is on
|
|
446
|
+
``CodeIsLatest``, which is deliberately *not* a variant factor, so a purely
|
|
447
|
+
combinatorial count would report every combination as selected while the
|
|
448
|
+
figure showed half of them. And real data is ragged: a location never re-run
|
|
449
|
+
under the newest code has no row for that combination, so the Cartesian
|
|
450
|
+
product overstates what exists. Both numbers therefore come from the frame,
|
|
451
|
+
through the same :func:`~scistackplot.variants.variant_set_mask` the
|
|
452
|
+
renderer applies — a readout the figure could disagree with would be worse
|
|
453
|
+
than none.
|
|
454
|
+
|
|
455
|
+
``selected_combinations`` of 0 is a legitimate state to display (the user
|
|
456
|
+
has deselected everything); it is ``roles.validate``'s job to refuse
|
|
457
|
+
rendering it, not this function's to hide it.
|
|
458
|
+
"""
|
|
459
|
+
import pandas as pd
|
|
460
|
+
|
|
461
|
+
from .variants import (
|
|
462
|
+
auto_label,
|
|
463
|
+
defined_sets,
|
|
464
|
+
is_stated,
|
|
465
|
+
label_variable,
|
|
466
|
+
resolve_selection,
|
|
467
|
+
row_mask,
|
|
468
|
+
set_name,
|
|
469
|
+
spanned_code_axes,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
frame = table.frame
|
|
473
|
+
names = [f.name for f in table.variant_factors if f.name in frame.columns]
|
|
474
|
+
|
|
475
|
+
sets = []
|
|
476
|
+
# With nothing selected — no rows, or none filled in yet — every row is on
|
|
477
|
+
# screen, which is what the figure is showing too.
|
|
478
|
+
kept_mask = pd.Series(not defined_sets(spec.variant_sets), index=frame.index)
|
|
479
|
+
claimed = pd.Series(False, index=frame.index)
|
|
480
|
+
for index, variant in enumerate(spec.variant_sets):
|
|
481
|
+
# "Says something", which row 0 always does — it is the figure's
|
|
482
|
+
# subject whether or not it narrows anything (`variants.is_stated`).
|
|
483
|
+
# NOT the same question as `defined_sets`, which decides whether the
|
|
484
|
+
# figure needs a `Variant` factor at all: a lone empty row 0 is stated
|
|
485
|
+
# and folds to nothing, because one series needs no factor.
|
|
486
|
+
defined = is_stated(variant, index)
|
|
487
|
+
if defined:
|
|
488
|
+
mask = row_mask(
|
|
489
|
+
frame, spec, variant, latest_column=table.latest_column
|
|
490
|
+
)
|
|
491
|
+
# First-match-wins, mirroring apply_variant_sets: the count shown
|
|
492
|
+
# must be the number of rows this variant contributes to the figure,
|
|
493
|
+
# not the number it would match on its own.
|
|
494
|
+
fresh = mask & ~claimed
|
|
495
|
+
claimed |= fresh
|
|
496
|
+
kept_mask |= fresh
|
|
497
|
+
else:
|
|
498
|
+
# An unfilled row is inert — it claims nothing and changes nothing,
|
|
499
|
+
# so it must not be reported as having matched nothing either.
|
|
500
|
+
fresh = pd.Series(False, index=frame.index)
|
|
501
|
+
sets.append(
|
|
502
|
+
{
|
|
503
|
+
"name": set_name(
|
|
504
|
+
variant,
|
|
505
|
+
index,
|
|
506
|
+
primary=spec.y_measure,
|
|
507
|
+
latest_column=table.latest_column,
|
|
508
|
+
),
|
|
509
|
+
# What the name BOX shows as its placeholder. Built from the
|
|
510
|
+
# same rule as `name`, minus the user's override — the box has
|
|
511
|
+
# to keep following the selection while it is being edited.
|
|
512
|
+
"auto_label": auto_label(
|
|
513
|
+
variant.selection,
|
|
514
|
+
index=index,
|
|
515
|
+
variable=label_variable(variant, index, spec.y_measure),
|
|
516
|
+
latest_column=table.latest_column,
|
|
517
|
+
),
|
|
518
|
+
"explicit_name": variant.name,
|
|
519
|
+
"selection": dict(variant.selection),
|
|
520
|
+
# None means the primary measure; the GUI shows that as the
|
|
521
|
+
# dropdown's default rather than inventing a name for it.
|
|
522
|
+
"variable": variant.variable,
|
|
523
|
+
"defined": defined,
|
|
524
|
+
"row_count": int(fresh.sum()),
|
|
525
|
+
# Code axes this variant leaves open and disagrees on. Reported
|
|
526
|
+
# per row because that is where the fix is (pin a version, or
|
|
527
|
+
# split the row), and because the column itself is no longer
|
|
528
|
+
# offered as a factor.
|
|
529
|
+
"spans": spanned_code_axes(frame[fresh], variant.selection, table)
|
|
530
|
+
if defined
|
|
531
|
+
else {},
|
|
532
|
+
# What this selection actually resolved to, and — when it
|
|
533
|
+
# resolved to nothing — what it could have selected instead.
|
|
534
|
+
#
|
|
535
|
+
# A pin is applied blindly (`variants.default_selection`), so a
|
|
536
|
+
# combination nobody ever ran selects zero rows and draws an
|
|
537
|
+
# empty figure from controls that look correctly filled in.
|
|
538
|
+
# "Empty" on its own is indistinguishable from a broken panel;
|
|
539
|
+
# the attempted combination beside the available ones is what
|
|
540
|
+
# turns it into something the user can act on in one step.
|
|
541
|
+
#
|
|
542
|
+
# Computed only for a defined row that matched nothing: on the
|
|
543
|
+
# common path this is pure cost, and the answer ("what else is
|
|
544
|
+
# there") is only ever interesting when the answer to "what did
|
|
545
|
+
# I get" is nothing.
|
|
546
|
+
"resolved": _jsonable_selection(
|
|
547
|
+
resolve_selection(
|
|
548
|
+
frame, variant.selection, latest_column=table.latest_column
|
|
549
|
+
)
|
|
550
|
+
)
|
|
551
|
+
if defined
|
|
552
|
+
else {},
|
|
553
|
+
"available": _available_combinations(frame, spec, variant, table)
|
|
554
|
+
if defined and not fresh.any()
|
|
555
|
+
else [],
|
|
556
|
+
}
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
if not names:
|
|
560
|
+
return {
|
|
561
|
+
"sets": sets,
|
|
562
|
+
"factors": [],
|
|
563
|
+
"total_combinations": 0,
|
|
564
|
+
"selected_combinations": 0,
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
kept = frame[kept_mask]
|
|
568
|
+
as_text = frame[names].astype(str)
|
|
569
|
+
total = len(as_text.drop_duplicates())
|
|
570
|
+
selected = len(kept[names].astype(str).drop_duplicates()) if len(kept) else 0
|
|
571
|
+
|
|
572
|
+
factors = []
|
|
573
|
+
for factor in table.variant_factors:
|
|
574
|
+
if factor.name not in frame.columns:
|
|
575
|
+
continue
|
|
576
|
+
levels = [str(level) for level in factor.levels]
|
|
577
|
+
surviving = set(kept[factor.name].astype(str)) if len(kept) else set()
|
|
578
|
+
factors.append(
|
|
579
|
+
{
|
|
580
|
+
"name": factor.name,
|
|
581
|
+
"levels": levels,
|
|
582
|
+
"selected": [level for level in levels if level in surviving],
|
|
583
|
+
# Code axes read differently from experimental conditions —
|
|
584
|
+
# one is usually pinned, the other usually faceted — so the GUI
|
|
585
|
+
# needs to tell them apart without parsing the name itself.
|
|
586
|
+
"is_code": factor.name.startswith(CODE_FACTOR_PREFIX),
|
|
587
|
+
"origin": factor.origin,
|
|
588
|
+
}
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
return {
|
|
592
|
+
"sets": sets,
|
|
593
|
+
"factors": factors,
|
|
594
|
+
"total_combinations": total,
|
|
595
|
+
"selected_combinations": selected,
|
|
596
|
+
"latest_column": table.latest_column,
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
#: Variant combinations offered when a selection matched nothing. A list long
|
|
601
|
+
#: enough to find the near miss in, short enough to read without scrolling —
|
|
602
|
+
#: and bounded, because a ragged project can hold hundreds.
|
|
603
|
+
AVAILABLE_COMBINATION_LIMIT = 12
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _jsonable_selection(selection: dict) -> dict:
|
|
607
|
+
"""A resolved selection as plain JSON.
|
|
608
|
+
|
|
609
|
+
``resolve_selection`` hands back whatever the frame holds — numpy scalars,
|
|
610
|
+
booleans, lists — and this crosses a JSON-RPC boundary.
|
|
611
|
+
"""
|
|
612
|
+
from .table import _jsonable
|
|
613
|
+
|
|
614
|
+
def one(value):
|
|
615
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
616
|
+
return [_jsonable(item) for item in value]
|
|
617
|
+
return _jsonable(value)
|
|
618
|
+
|
|
619
|
+
return {key: one(value) for key, value in selection.items()}
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _available_combinations(frame, spec, variant, table) -> list[dict]:
|
|
623
|
+
"""Variant combinations that DO exist, for a selection that matched none.
|
|
624
|
+
|
|
625
|
+
Scoped to the row's own variable, because that is what the row is asking
|
|
626
|
+
about: offering ``FilteredEMG``'s combinations to a row that plots
|
|
627
|
+
``RawEMG`` would send the user to fix the wrong thing.
|
|
628
|
+
|
|
629
|
+
Ordered by the axes' declared level order so the list reads the same way
|
|
630
|
+
twice, and capped at :data:`AVAILABLE_COMBINATION_LIMIT` — a ragged project
|
|
631
|
+
can hold hundreds, and a wall of them answers nothing that the first dozen
|
|
632
|
+
does not.
|
|
633
|
+
"""
|
|
634
|
+
from .table import natural_sort_key
|
|
635
|
+
from .variants import VARIABLE_COLUMN
|
|
636
|
+
|
|
637
|
+
names = [f.name for f in table.variant_factors if f.name in frame.columns]
|
|
638
|
+
if not names:
|
|
639
|
+
return []
|
|
640
|
+
|
|
641
|
+
rows = frame
|
|
642
|
+
if VARIABLE_COLUMN in frame.columns:
|
|
643
|
+
wanted = variant.variable or spec.y_measure
|
|
644
|
+
rows = frame[frame[VARIABLE_COLUMN].astype(str) == wanted]
|
|
645
|
+
if rows.empty:
|
|
646
|
+
return []
|
|
647
|
+
|
|
648
|
+
combos = rows[names].astype(str).drop_duplicates()
|
|
649
|
+
ordered = sorted(
|
|
650
|
+
(tuple(row) for row in combos.itertuples(index=False)),
|
|
651
|
+
key=lambda values: tuple(natural_sort_key(v) for v in values),
|
|
652
|
+
)
|
|
653
|
+
return [
|
|
654
|
+
dict(zip(names, values, strict=True))
|
|
655
|
+
for values in ordered[:AVAILABLE_COMBINATION_LIMIT]
|
|
656
|
+
]
|