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,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
+ ]