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/reduce.py ADDED
@@ -0,0 +1,1475 @@
1
+ """
2
+ ``resolve(spec, table)`` — turn a spec plus data into renderer-ready panels.
3
+
4
+ Everything semantic happens here: filtering, variant handling, exploding 1-D
5
+ measures, collapsing AGGREGATE factors, fanning out ITERATE factors into
6
+ separate figures, faceting, and summarizing replicates into a statistic with an
7
+ error band. Renderers below this line only translate.
8
+
9
+ Two reductions are easy to confuse, so they are named apart deliberately:
10
+
11
+ * **AGGREGATE (a role)** collapses a factor — "average over trials" — and
12
+ removes it from the data before anything is drawn.
13
+ * **Summarizing (a plot kind)** turns whatever replicate rows remain at each x
14
+ position into a centre and an error band. This is what BAR and BAND do.
15
+
16
+ You can have either, both, or neither.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import threading
23
+ from dataclasses import dataclass, field, replace
24
+ from typing import Any, Iterable
25
+
26
+ import numpy as np
27
+ import pandas as pd
28
+ from scistacklog import Log
29
+
30
+ from .resolved import COLOR, SERIES, X, Y, Y_HIGH, Y_LOW, Z
31
+ from .resolved import Encoding, Labels, Panel, ResolvedPlot
32
+ from .roles import complete_roles, fanout_keys, iterate_ancestors, validate
33
+ from .shape import Shape
34
+ from .spec import (
35
+ ErrorBand,
36
+ Matcher,
37
+ PlotKind,
38
+ PlotSpec,
39
+ Role,
40
+ Statistic,
41
+ grid_shape_for,
42
+ )
43
+ from .table import LongTable, natural_sort_key
44
+ from .xaxis import LEAF_SEPARATOR, XPlan, plan_x_axis
45
+ from .ylimits import eligible_scope, limits_by_scope, limits_for
46
+ from .groups import apply_level_groups
47
+ from .variants import apply_variant_sets, strip_answered_roles
48
+
49
+ LAYER = "scistackplot"
50
+
51
+ #: Default index column name created when a 1-D measure is exploded.
52
+ DEFAULT_INDEX_COLUMN = "index"
53
+
54
+ #: Joins the factor values identifying one polyline (``__series``). Only ever
55
+ #: built and compared, never parsed. Two levels whose text contains it can
56
+ #: compose to the same key and be drawn as one line — inherited from the join
57
+ #: this replaced, and not something a separator choice can rule out.
58
+ SERIES_SEPARATOR = " | "
59
+
60
+ #: How a missing factor value appears in a composed key. It needs SOME text:
61
+ #: a row whose subject is null is still a row, and the alternative — pandas 3's
62
+ #: `astype(str)` preserving NA — is a TypeError in the middle of a figure.
63
+ MISSING_LEVEL_TEXT = "nan"
64
+
65
+ #: Rows per figure above which the GUI path downsamples before serializing.
66
+ #: 1-D data over hundreds of trials is megabytes, and it crosses the webview
67
+ #: boundary on every interaction. Export never downsamples (max_points=None).
68
+ MAX_TRANSPORT_POINTS = 20_000
69
+
70
+ #: Spec fields that change how a figure LOOKS but not what data goes into it.
71
+ #: Excluded from the plan cache key, which is the whole reason the cache pays
72
+ #: off: these are the controls a user drags, and none of them should re-filter,
73
+ #: re-fold variants or re-group anything.
74
+ #:
75
+ #: Everything else is included by omission, on purpose. A field added to
76
+ #: ``PlotSpec`` later lands in the key automatically, so the worst a forgotten
77
+ #: update can do is miss the cache — never serve a plan built for a different
78
+ #: question.
79
+ #:
80
+ #: ``y_axis`` is deliberately NOT here: the plan carries the fan-out's computed
81
+ #: y limits (``_Plan.y_limits``), so a changed scope has to build a new one. It
82
+ #: is a checkbox and two boxes rather than a dragged slider, so the re-plan is
83
+ #: per click, not per frame.
84
+ _PLAN_IRRELEVANT_FIELDS = ("kind", "facet", "style")
85
+
86
+ #: How many plans are kept. Two: the pattern being served is narrow — the panel
87
+ #: re-resolves the SAME data repeatedly while the user changes how it is drawn.
88
+ #:
89
+ #: Cheap to hold, because the frames in a plan are the NESTED ones. Exploding
90
+ #: moved into the figures (``_Plan.explode``), so a plan for a 1-D measure keeps
91
+ #: the 24-row frame rather than the 17-million-sample one. Had these two changes
92
+ #: landed the other way round, this cache would have been the memory problem.
93
+ _PLAN_CACHE_ENTRIES = 2
94
+
95
+ #: ``(id(table), spec key) -> (table, plan)``.
96
+ #:
97
+ #: The table is kept in the VALUE, not just the key, and that is load-bearing:
98
+ #: a strong reference pins the object so CPython cannot recycle its ``id`` onto
99
+ #: a different table and serve this plan for it. (The same id-reuse trap the
100
+ #: GUI's source cache documents.) Lookups still verify identity before
101
+ #: returning, so the pinning is belt-and-braces rather than the only guard.
102
+ _plan_cache: dict[tuple, tuple[LongTable, "_Plan"]] = {}
103
+ _plan_cache_lock = threading.Lock()
104
+
105
+
106
+ def _plan_cache_key(spec: PlotSpec, table: LongTable) -> "tuple | None":
107
+ """A hashable identity for "this spec's data question, against this table".
108
+
109
+ Returns None when the spec cannot be serialized, which disables caching for
110
+ that call rather than failing it — a plan that cannot be keyed is still a
111
+ perfectly good plan.
112
+ """
113
+ try:
114
+ raw = spec.to_dict()
115
+ for field_name in _PLAN_IRRELEVANT_FIELDS:
116
+ raw.pop(field_name, None)
117
+ return (id(table), json.dumps(raw, sort_keys=True, default=str))
118
+ except Exception: # pragma: no cover - a spec that will fail louder later
119
+ return None
120
+
121
+
122
+ def clear_plan_cache() -> None:
123
+ """Drop every cached plan.
124
+
125
+ The data underneath a plan cannot change without the table object changing
126
+ too (sources rebuild tables rather than mutating them), so this exists for
127
+ tests and for callers that would rather not hold the frames.
128
+ """
129
+ with _plan_cache_lock:
130
+ _plan_cache.clear()
131
+
132
+
133
+ def resolve(
134
+ spec: PlotSpec,
135
+ table: LongTable,
136
+ *,
137
+ max_points: int | None = None,
138
+ narrate: bool = False,
139
+ on_figure=None,
140
+ ) -> list[ResolvedPlot]:
141
+ """
142
+ Reduce ``spec`` against ``table``.
143
+
144
+ Returns one :class:`ResolvedPlot` per combination of the spec's ITERATE
145
+ factors — the interactive equivalent of the pipeline's ``for_each`` fan-out
146
+ over iterated schema keys. The two must always produce the same figure set;
147
+ ``tests/test_fanout_parity.py`` asserts it.
148
+
149
+ ``narrate`` logs each figure and each phase **while it runs** rather than
150
+ only on exit — for the interactive save, where one figure is minutes of work
151
+ and the silence was indistinguishable from a hang. It is opt-in, not implied
152
+ by ``max_points=None``: a pipeline endpoint also resolves at full resolution,
153
+ once per iteration, and a 500-iteration run does not want eight lines each.
154
+
155
+ ``on_figure(position, total, label)`` is called **as each figure starts**,
156
+ for a caller reporting progress to a user. Before, not after: a fan-out of
157
+ two figures that reports only completions is silent for the entire first
158
+ figure, which is the half of the time the user is actually waiting.
159
+ """
160
+ with Log.timer(
161
+ "resolve", layer=LAYER, extra=str(spec.kind), live=narrate
162
+ ) as timing:
163
+ with timing.phase("plan"):
164
+ plan = _plan(spec, table)
165
+
166
+ total = len(plan.groups)
167
+ Log.info(
168
+ "resolving %s of %r: %d figure(s) over %s%s",
169
+ spec.kind,
170
+ plan.spec.y_measure,
171
+ total,
172
+ plan.iterate or "no fan-out",
173
+ "" if max_points else " at full resolution (no downsampling)",
174
+ layer=LAYER,
175
+ )
176
+
177
+ figures: list[ResolvedPlot] = []
178
+ for position, (key, group) in enumerate(plan.groups, start=1):
179
+ timing.note("figure %d/%d: %s", position, total, _figure_label(key) or "-")
180
+ if on_figure is not None:
181
+ on_figure(position, total, _figure_label(key))
182
+ figures.append(
183
+ _build_figure(
184
+ group,
185
+ plan.spec,
186
+ plan.table,
187
+ plan.roles,
188
+ plan.shape,
189
+ plan.index_column,
190
+ figure_key=key,
191
+ max_points=max_points,
192
+ explode=plan.explode,
193
+ narrate=narrate,
194
+ y_scope=plan.y_scope,
195
+ y_limits=plan.y_limits,
196
+ )
197
+ )
198
+ for figure in figures:
199
+ figure.fanout_notes = plan.notes
200
+
201
+ Log.info(
202
+ "resolved %s of %r: %d figure(s) over %s, %d panel(s), %d row(s)",
203
+ spec.kind,
204
+ plan.spec.y_measure,
205
+ len(figures),
206
+ plan.iterate or "no fan-out",
207
+ sum(len(f.panels) for f in figures),
208
+ sum(f.row_count for f in figures),
209
+ layer=LAYER,
210
+ )
211
+ return figures
212
+
213
+
214
+ @dataclass
215
+ class _Plan:
216
+ """Everything decided before any figure is built.
217
+
218
+ Split out so one figure of a fan-out can be built without building the
219
+ others — every step here is shared by all of them, and every step below is
220
+ per figure.
221
+ """
222
+
223
+ spec: PlotSpec
224
+ table: LongTable
225
+ roles: dict
226
+ shape: Shape
227
+ index_column: str | None
228
+ #: Whether each figure must explode its own 1-D rows into samples.
229
+ #:
230
+ #: The explode used to happen once, HERE, over the whole frame — and that
231
+ #: made a fan-out pay for every figure in it on every resolve. The
232
+ #: 2026-09-11 log shows the cost plainly: ``exploded 1-D measure
233
+ #: 'FilteredEMG': 48 row(s) -> 17119200 sample(s)`` immediately followed by
234
+ #: ``downsampled 8906400 row(s)`` — half the samples were built for a figure
235
+ #: the panel was not showing and threw away. At 30 subjects it is 30x.
236
+ #:
237
+ #: So the frames below stay nested and each figure explodes its own group.
238
+ #: Grouping first is safe because the fan-out keys are ordinary factor
239
+ #: columns, present and unchanged before the explode; exploding only ever
240
+ #: multiplies rows WITHIN a group.
241
+ explode: bool
242
+ iterate: list[str]
243
+ notes: list[str]
244
+ #: ``(figure_key, frame)`` per figure, IN ORDER. The frames are views, and
245
+ #: still nested: the expensive per-figure work (exploding, aggregating,
246
+ #: panels, downsampling) has not happened yet.
247
+ groups: list[tuple[dict, "pd.DataFrame"]]
248
+ #: The y-limit scope, after ineligible factors were dropped.
249
+ y_scope: list[str] = field(default_factory=list)
250
+ #: ``{scope values: (low, high)}`` for the WHOLE fan-out, computed here
251
+ #: rather than per figure — a scope of ``[]`` means one range across every
252
+ #: figure, including the ones ``resolve_one`` never builds. Computed off the
253
+ #: table (see :mod:`scistackplot.ylimits`), so knowing it costs a numpy pass
254
+ #: over 48 rows rather than a reduction of 17 million.
255
+ y_limits: dict = field(default_factory=dict)
256
+
257
+ @property
258
+ def labels(self) -> list[str]:
259
+ """Every figure's label — knowable without building any of them."""
260
+ return [
261
+ ", ".join(f"{k}={v}" for k, v in key.items()) for key, _ in self.groups
262
+ ]
263
+
264
+
265
+ def _plan(spec: PlotSpec, table: LongTable) -> _Plan:
266
+ """Resolve everything up to, but not including, per-figure work.
267
+
268
+ Memoized. Every control change re-resolves, and most of them — plot kind,
269
+ grid shape, colours — do not change a single row of what is planned here,
270
+ yet each one re-folded the variants, re-ran the filters and re-grouped the
271
+ frame. Concurrent duplicates made it worse: the 2026-09-11 log shows four
272
+ resolves in flight at once, each redoing all of it.
273
+
274
+ A cached plan is shared, never copied, so the same rule the tables rely on
275
+ applies here too: nothing downstream mutates it. ``_build_figure`` takes the
276
+ group frames and builds new ones (explode, collapse, downsample, panels).
277
+ """
278
+ key = _plan_cache_key(spec, table)
279
+ if key is not None:
280
+ with _plan_cache_lock:
281
+ hit = _plan_cache.get(key)
282
+ # Identity re-checked under the assumption that ids can be recycled;
283
+ # the strong reference held below should make this impossible.
284
+ if hit is not None and hit[0] is table:
285
+ Log.debug("plan cache: hit (%d entries)", len(_plan_cache), layer=LAYER)
286
+ return _with_presentation(hit[1], spec)
287
+
288
+ plan = _build_plan(spec, table)
289
+
290
+ if key is not None:
291
+ with _plan_cache_lock:
292
+ _plan_cache[key] = (table, plan)
293
+ while len(_plan_cache) > _PLAN_CACHE_ENTRIES:
294
+ _plan_cache.pop(next(iter(_plan_cache)))
295
+ return _with_presentation(plan, spec)
296
+
297
+
298
+ def _with_presentation(plan: "_Plan", spec: PlotSpec) -> "_Plan":
299
+ """Put the look-only fields back onto a plan's spec.
300
+
301
+ Necessary because ``_build_figure`` reads ``plan.spec`` — not the caller's
302
+ spec — for the plot kind, the facet grid and the style. Those are exactly
303
+ the fields the cache key ignores, so without this a cached plan would render
304
+ the FIRST kind it was built with and switching from lines to a box plot
305
+ would silently do nothing.
306
+
307
+ Driven off the same constant as the key, so the two cannot drift: a field
308
+ excluded from the key is, by construction, restored here.
309
+ """
310
+ changed = {
311
+ name: getattr(spec, name)
312
+ for name in _PLAN_IRRELEVANT_FIELDS
313
+ if getattr(plan.spec, name) != getattr(spec, name)
314
+ }
315
+ if not changed:
316
+ return plan
317
+ return replace(plan, spec=replace(plan.spec, **changed))
318
+
319
+
320
+ def _build_plan(spec: PlotSpec, table: LongTable) -> _Plan:
321
+ """:func:`_plan` without the cache — always does the full work."""
322
+ # Named variants become a ``Variant`` factor BEFORE anything else looks
323
+ # at the table, so validation, roles, faceting and rendering all see one
324
+ # ordinary factor rather than each needing a variant special case.
325
+ base = table
326
+ table = apply_variant_sets(spec, table)
327
+ # Derived grouping factors, after the variants have claimed their
328
+ # columns. Both are derived tables built BEFORE validation and role
329
+ # completion, so nothing below this line knows either factor was
330
+ # synthesized (docs/claude/synthetic-factors.md).
331
+ table = apply_level_groups(spec, table)
332
+ spec = strip_answered_roles(spec, base, table)
333
+ validate(spec, table)
334
+ roles = complete_roles(spec, table)
335
+
336
+ frame = table.frame
337
+ frame = apply_filters(frame, spec)
338
+ _warn_if_pooling_variants(spec, table, roles)
339
+
340
+ y_measure = spec.y_measure
341
+ shape = table.shape_of(y_measure)
342
+ index_column = spec.index_column or table.index_column or DEFAULT_INDEX_COLUMN
343
+
344
+ # Whether each figure has to explode its own rows. Deliberately NOT done
345
+ # here — see `_Plan.explode`.
346
+ explode = shape is Shape.SERIES_1D and not table.measure(y_measure).exploded
347
+ if shape is not Shape.SERIES_1D:
348
+ index_column = None
349
+
350
+ Log.debug(
351
+ "resolve: measure=%s shape=%s kind=%s rows=%d roles=%s",
352
+ y_measure,
353
+ shape,
354
+ spec.kind,
355
+ len(frame),
356
+ {k: str(v) for k, v in roles.items()},
357
+ layer=LAYER,
358
+ )
359
+
360
+ # A factor can be absent from the frame if it was filtered to nothing;
361
+ # grouping by it would raise rather than degrade. (An ITERATE factor is
362
+ # never aggregated away — a factor carries one role — so moving the
363
+ # collapse into the figures below does not widen what this can miss.)
364
+ iterate = [name for name in fanout_keys(spec, table) if name in frame.columns]
365
+ if iterate:
366
+ groups = [
367
+ (dict(zip(iterate, key_values, strict=True)), group)
368
+ for key_values, group in _ordered_groups(frame, iterate, table)
369
+ ]
370
+ else:
371
+ groups = [({}, frame)]
372
+
373
+ # Computed HERE, over the filtered frame, for the whole fan-out at once —
374
+ # a scope of [] means one range across every figure, including the ones
375
+ # `resolve_one` deliberately never builds. Off the table rather than off the
376
+ # figures, so it stays a numpy pass over 48 rows (see `ylimits`).
377
+ y_scope = eligible_scope(spec.y_axis.scope, roles, table)
378
+ scoped = replace(table, frame=frame)
379
+ y_limits = (
380
+ {}
381
+ if spec.y_axis.is_manual
382
+ else limits_by_scope(scoped, spec, y_scope)
383
+ )
384
+
385
+ return _Plan(
386
+ spec=spec,
387
+ table=table,
388
+ roles=roles,
389
+ shape=shape,
390
+ index_column=index_column,
391
+ explode=explode,
392
+ iterate=iterate,
393
+ notes=_fanout_notes(spec, table),
394
+ groups=groups,
395
+ y_scope=y_scope,
396
+ y_limits=y_limits,
397
+ )
398
+
399
+
400
+ def resolve_one(
401
+ spec: PlotSpec,
402
+ table: LongTable,
403
+ index: int,
404
+ *,
405
+ max_points: int | None = None,
406
+ narrate: bool = False,
407
+ ) -> tuple[ResolvedPlot, list[str], int]:
408
+ """Build ONE figure of a fan-out. Returns ``(figure, every label, index)``.
409
+
410
+ The interactive panel shows one figure at a time and serializes only that
411
+ one, but it was reducing all of them to get there: ``resolve`` builds every
412
+ figure, and building a figure is where the cost is — panels, aggregation and
413
+ downsampling over the whole group. A two-figure fan-out therefore cost twice
414
+ what the user was looking at, every time any control moved.
415
+
416
+ Nothing is lost by deferring the rest. The fan-out's SIZE and LABELS come
417
+ from the group keys (:attr:`_Plan.labels`), which are known as soon as the
418
+ frame is grouped — the labels never depended on the figures being built.
419
+
420
+ ``index`` is clamped rather than rejected: the fan-out shrinks whenever a
421
+ filter or a variant selection narrows the data, and the panel's cursor is a
422
+ moment behind the spec it is already re-resolving. An out-of-range index is
423
+ a normal transient.
424
+
425
+ ``narrate`` is opt-in, exactly as in :func:`resolve`.
426
+ """
427
+ with Log.timer(
428
+ "resolve_one", layer=LAYER, extra=str(spec.kind), live=narrate
429
+ ) as timing:
430
+ with timing.phase("plan"):
431
+ plan = _plan(spec, table)
432
+ position = max(0, min(int(index), len(plan.groups) - 1))
433
+ key, group = plan.groups[position]
434
+ if narrate:
435
+ Log.info(
436
+ "resolving %s of %r: figure %d of %d (%s) at full resolution "
437
+ "(no downsampling)",
438
+ spec.kind,
439
+ plan.spec.y_measure,
440
+ position + 1,
441
+ len(plan.groups),
442
+ _figure_label(key) or "no fan-out",
443
+ layer=LAYER,
444
+ )
445
+ figure = _build_figure(
446
+ group,
447
+ plan.spec,
448
+ plan.table,
449
+ plan.roles,
450
+ plan.shape,
451
+ plan.index_column,
452
+ figure_key=key,
453
+ max_points=max_points,
454
+ explode=plan.explode,
455
+ narrate=narrate,
456
+ y_scope=plan.y_scope,
457
+ y_limits=plan.y_limits,
458
+ )
459
+ figure.fanout_notes = plan.notes
460
+ Log.info(
461
+ "resolved %s of %r: figure %d of %d over %s, %d panel(s), %d row(s) "
462
+ "(%d figure(s) not built)",
463
+ spec.kind,
464
+ plan.spec.y_measure,
465
+ position + 1,
466
+ len(plan.groups),
467
+ plan.iterate or "no fan-out",
468
+ len(figure.panels),
469
+ figure.row_count,
470
+ len(plan.groups) - 1,
471
+ layer=LAYER,
472
+ )
473
+ return figure, plan.labels, position
474
+
475
+
476
+ def _fanout_notes(spec: PlotSpec, table: LongTable) -> list[str]:
477
+ """What the fan-out did that the user did not literally ask for.
478
+
479
+ Only the ancestor promotion, for now. It must be *said*: a user who asked
480
+ for one figure per trial and silently received one per subject-and-trial
481
+ would count the figures and think something was broken.
482
+
483
+ Computed from the roles **as declared** — ``complete_roles`` has already
484
+ applied the promotion, so asking the promoted roles what was promoted
485
+ returns nothing at all.
486
+ """
487
+ declared = complete_roles(spec, table, promote=False)
488
+ promoted = iterate_ancestors(declared, table)
489
+ if not promoted:
490
+ return []
491
+ asked = [name for name, role in declared.items() if role is Role.ITERATE]
492
+ note = (
493
+ f"Also showing one figure per {', '.join(promoted)}: "
494
+ f"{', '.join(asked)} is nested under {'them' if len(promoted) > 1 else 'it'}, "
495
+ f"so a figure per {asked[-1] if asked else 'key'} alone would pool "
496
+ f"unrelated observations."
497
+ )
498
+ Log.info("fan-out promoted %s to ITERATE", promoted, layer=LAYER)
499
+ return [note]
500
+
501
+
502
+ # ---------------------------------------------------------------------------
503
+ # Stage helpers
504
+ # ---------------------------------------------------------------------------
505
+
506
+
507
+ def apply_filters(frame: pd.DataFrame, spec: PlotSpec) -> pd.DataFrame:
508
+ """Rows surviving ``spec.filters``.
509
+
510
+ Public because the GUI's pickers report "3 of 12 selected" and that readout
511
+ has to be measured with exactly the rule the figure uses — the same reason
512
+ ``variants.row_mask`` is shared. A count the figure disagrees with is worse
513
+ than no count.
514
+
515
+ Level membership is compared **as text**, matching ``variant_set_mask``: a
516
+ selection crosses JSON as strings while the column may hold ``01`` (string)
517
+ or ``1`` (int) depending on the source, and a silently empty figure is the
518
+ worst possible answer to a picker the user just clicked.
519
+ """
520
+ if not spec.filters:
521
+ return frame
522
+ mask = pd.Series(True, index=frame.index)
523
+ for flt in spec.filters:
524
+ if flt.column not in frame.columns:
525
+ # A spec outlives the table it was written against — a filter naming
526
+ # a column this table lacks is stale, not fatal.
527
+ Log.warn(
528
+ "filter on unknown column %r ignored", flt.column, layer=LAYER
529
+ )
530
+ continue
531
+ column = frame[flt.column]
532
+ before = int(mask.sum())
533
+ if flt.include is not None:
534
+ as_text = column.astype(str)
535
+ mask &= as_text.isin({str(v) for v in flt.include})
536
+ if flt.exclude is not None:
537
+ as_text = column.astype(str)
538
+ mask &= ~as_text.isin({str(v) for v in flt.exclude})
539
+ if flt.minimum is not None:
540
+ mask &= pd.to_numeric(column, errors="coerce") >= flt.minimum
541
+ if flt.maximum is not None:
542
+ mask &= pd.to_numeric(column, errors="coerce") <= flt.maximum
543
+ # Per column, so an empty figure names the filter that emptied it
544
+ # rather than only the total.
545
+ Log.debug(
546
+ "filter on %s: %d -> %d row(s)",
547
+ flt.column,
548
+ before,
549
+ int(mask.sum()),
550
+ layer=LAYER,
551
+ )
552
+ filtered = frame[mask]
553
+ Log.info(
554
+ "filters kept %d of %d row(s)", len(filtered), len(frame), layer=LAYER
555
+ )
556
+ return filtered
557
+
558
+
559
+ def _warn_if_pooling_variants(
560
+ spec: PlotSpec, table: LongTable, roles: dict[str, Role]
561
+ ) -> None:
562
+ """Say so, loudly, when a figure combines pipeline variants.
563
+
564
+ Pooling is now something the user asks for by assigning the factor
565
+ 'aggregate' or 'free' in Factors (``roles.validate`` refuses it by
566
+ accident, never on purpose) — but a pooled figure looks EXACTLY like an
567
+ unpooled one, so the request still has to leave a trace. This is the
568
+ warning the deleted ``variant_policy='pool'`` branch used to emit; the
569
+ switch moved, the trace did not.
570
+ """
571
+ pooled = [
572
+ f.name
573
+ for f in table.variant_factors
574
+ if len(f.levels) > 1
575
+ and roles.get(f.name, Role.FREE) in (Role.FREE, Role.AGGREGATE)
576
+ ]
577
+ if pooled:
578
+ Log.warn(
579
+ "pooling variant factor(s) %s — results from different pipeline "
580
+ "variants are being combined, as this spec asks",
581
+ pooled,
582
+ layer=LAYER,
583
+ )
584
+
585
+
586
+ def _explode_1d(
587
+ frame: pd.DataFrame, measure: str, index_column: str
588
+ ) -> tuple[pd.DataFrame, str]:
589
+ """
590
+ Turn one array per row into one row per sample, adding an index column.
591
+
592
+ The index is positional (0..n-1 within each original row). A source that
593
+ knows a real axis — time in seconds, percent of gait cycle — supplies it as
594
+ an ordinary column and sets ``LongTable.index_column``, in which case the
595
+ measure arrives already exploded and this never runs.
596
+ """
597
+ if index_column in frame.columns:
598
+ # Caller supplied a real axis but left the arrays nested: unusual, but
599
+ # exploding would misalign it, so refuse loudly rather than corrupt.
600
+ raise ValueError(
601
+ f"Cannot explode 1-D measure {measure!r}: column {index_column!r} "
602
+ f"already exists. Set LongTable.index_column and pre-explode, or "
603
+ f"choose a different PlotSpec.index_column."
604
+ )
605
+
606
+ working = frame.copy()
607
+ working[index_column] = working[measure].map(
608
+ lambda v: list(range(len(v))) if _is_sequence(v) else []
609
+ )
610
+ exploded = working.explode([measure, index_column], ignore_index=True)
611
+ exploded = exploded.dropna(subset=[measure])
612
+ exploded[measure] = pd.to_numeric(exploded[measure], errors="coerce")
613
+ exploded[index_column] = pd.to_numeric(exploded[index_column], errors="coerce")
614
+
615
+ # INFO, not DEBUG: this is the dominant cost of a resolve and the one number
616
+ # that explains a slow panel. A 12-field struct of 1-D arrays goes from a
617
+ # 24-row frame to several million here, and nothing caches the result.
618
+ # Without this line at INFO the only visible trace is the downsample
619
+ # warning, which reports the figure's rows rather than the frame's.
620
+ #
621
+ # Now emitted once per FIGURE BUILT rather than once per resolve (see
622
+ # `_Plan.explode`), which is the point: a fan-out whose figures are not
623
+ # being looked at should produce no line here at all, and a second line
624
+ # appearing is a fan-out actually being rendered, not waste.
625
+ Log.info(
626
+ "exploded 1-D measure %r: %d row(s) -> %d sample(s) (x%d)",
627
+ measure,
628
+ len(frame),
629
+ len(exploded),
630
+ round(len(exploded) / len(frame)) if len(frame) else 0,
631
+ layer=LAYER,
632
+ )
633
+ return exploded, index_column
634
+
635
+
636
+ def _is_sequence(value: Any) -> bool:
637
+ return isinstance(value, (list, tuple, np.ndarray))
638
+
639
+
640
+ def _collapse_aggregates(
641
+ frame: pd.DataFrame,
642
+ spec: PlotSpec,
643
+ roles: dict[str, Role],
644
+ index_column: str | None,
645
+ ) -> pd.DataFrame:
646
+ """Average the measures over every AGGREGATE factor's levels."""
647
+ aggregated = [name for name, role in roles.items() if role is Role.AGGREGATE]
648
+ if not aggregated:
649
+ return frame
650
+
651
+ keep = [
652
+ name
653
+ for name, role in roles.items()
654
+ if role is not Role.AGGREGATE and name in frame.columns
655
+ ]
656
+ if index_column and index_column in frame.columns:
657
+ keep.append(index_column)
658
+
659
+ measures = [m for m in spec.measures if m in frame.columns]
660
+ if not keep:
661
+ # Everything collapses to a single value.
662
+ collapsed = frame[measures].mean().to_frame().T
663
+ else:
664
+ collapsed = (
665
+ frame.groupby(keep, dropna=False, sort=False)[measures]
666
+ .mean()
667
+ .reset_index()
668
+ )
669
+
670
+ Log.debug(
671
+ "aggregate over %s: %d -> %d row(s)",
672
+ aggregated,
673
+ len(frame),
674
+ len(collapsed),
675
+ layer=LAYER,
676
+ )
677
+ return collapsed
678
+
679
+
680
+ # ---------------------------------------------------------------------------
681
+ # Figure construction
682
+ # ---------------------------------------------------------------------------
683
+
684
+
685
+ def _figure_label(figure_key: dict[str, Any]) -> str:
686
+ """``subject=03, pass=1`` — the same text ``ResolvedPlot.figure_label``
687
+ produces, available before the figure exists so the log can name what it is
688
+ working on rather than what it finished."""
689
+ return ", ".join(f"{k}={v}" for k, v in figure_key.items())
690
+
691
+
692
+ def _build_figure(
693
+ frame: pd.DataFrame,
694
+ spec: PlotSpec,
695
+ table: LongTable,
696
+ roles: dict[str, Role],
697
+ shape: Shape,
698
+ index_column: str | None,
699
+ *,
700
+ figure_key: dict[str, Any],
701
+ max_points: int | None,
702
+ explode: bool = False,
703
+ narrate: bool = False,
704
+ y_scope: list[str] | None = None,
705
+ y_limits: dict | None = None,
706
+ ) -> ResolvedPlot:
707
+ # ``narrate`` makes this function announce each phase as it starts. A
708
+ # full-resolution figure is minutes of work (scidb.log 2026-09-11: 1543s for
709
+ # two of them), and until the phases said so while they ran, the only
710
+ # evidence available was one summary line that a timed-out save never
711
+ # reached. The interactive path leaves it off: at 20k rows the narration
712
+ # would outnumber the work.
713
+ with Log.timer(
714
+ "build_figure",
715
+ layer=LAYER,
716
+ extra=_figure_label(figure_key) or str(spec.kind),
717
+ live=narrate,
718
+ ) as timing:
719
+ # This figure's own rows, expanded and collapsed here rather than once
720
+ # over the whole fan-out (see `_Plan.explode`). The order is fixed:
721
+ # aggregating a 1-D measure averages sample-by-sample, so the index
722
+ # column has to exist before the collapse groups on it.
723
+ if explode:
724
+ with timing.phase("explode", extra=f"{len(frame)} row(s)"):
725
+ frame, index_column = _explode_1d(frame, spec.y_measure, index_column)
726
+ with timing.phase("collapse_aggregates"):
727
+ frame = _collapse_aggregates(frame, spec, roles, index_column)
728
+
729
+ color = _role_holder(roles, Role.COLOR)
730
+ # Several factors may share the x axis, nested. `x_layers` is only the
731
+ # ORDER; membership is the roles dict, and `ordered_x_layers` reconciles
732
+ # them so neither control can produce a spec the other rejects.
733
+ x_layers = [
734
+ name
735
+ for name in spec.ordered_x_layers(roles)
736
+ if name in frame.columns
737
+ ]
738
+ x_factor = x_layers[0] if x_layers else None
739
+ # Several factors may be faceted at once; their combined levels are the
740
+ # panels, and FacetOptions decides how those panels are arranged.
741
+ facet_names = [
742
+ name
743
+ for name, role in roles.items()
744
+ if role is Role.FACET and name in frame.columns
745
+ ]
746
+
747
+ original_rows = len(frame)
748
+ if max_points is not None and original_rows > max_points:
749
+ with timing.phase("downsample"):
750
+ frame = _downsample(frame, max_points, index_column)
751
+
752
+ panels: list[Panel] = []
753
+
754
+ with timing.phase("facet_groups"):
755
+ if facet_names:
756
+ groups = _ordered_groups(frame, facet_names, table)
757
+ else:
758
+ groups = [((), frame)]
759
+
760
+ # The dominant phase at full resolution, and the one worth a per-panel
761
+ # heartbeat: a figure that stops here has stopped in a specific panel.
762
+ with timing.phase("panel_frames", extra=f"{len(groups)} panel(s)"):
763
+ for position, (key_values, group) in enumerate(groups, start=1):
764
+ key = dict(zip(facet_names, key_values, strict=True))
765
+ timing.note(
766
+ "panel %d/%d (%s): %d row(s)",
767
+ position,
768
+ len(groups),
769
+ ", ".join(str(v) for v in key.values()) or "unfaceted",
770
+ len(group),
771
+ )
772
+ panel_frame = _panel_frame(
773
+ group, spec, table, shape, x_layers, color, index_column
774
+ )
775
+ panels.append(
776
+ Panel(
777
+ frame=panel_frame,
778
+ key=key,
779
+ # The panel's OWN limits, from the figure's key plus its
780
+ # own facet values: the scope decides which of those two
781
+ # actually separate anything.
782
+ y_limits=limits_for(
783
+ y_limits or {},
784
+ {**figure_key, **key},
785
+ y_scope or [],
786
+ spec.y_axis,
787
+ ),
788
+ )
789
+ )
790
+
791
+ with timing.phase("grid_layout"):
792
+ n_rows, n_cols, row_labels, col_labels, layout_notes = _assign_grid(
793
+ panels, spec.facet
794
+ )
795
+
796
+ encoding = _encoding_for(spec.kind, color, shape)
797
+ labels = _labels_for(spec, table, x_layers, color, index_column, figure_key)
798
+
799
+ # A nested axis is composed once, here, from labels only — so both
800
+ # renderers draw the same brackets and codegen can replay the result
801
+ # instead of re-deriving it (same bargain as plan_layout).
802
+ x_plan = (
803
+ _plan_nested_x(panels, table, x_layers) if len(x_layers) > 1 else None
804
+ )
805
+
806
+ # The FIGURE's limits: the panels' own, when they all agree. Not a
807
+ # second computation — every consumer that reads one number per figure
808
+ # (the GUI, `render.base.shared_y_limits`) has to get the same answer
809
+ # the panels got, and `None` here now means "the panels differ", which
810
+ # is exactly when a renderer must stop sharing an axis.
811
+ figure_limits = _figure_limits(panels)
812
+
813
+ return ResolvedPlot(
814
+ kind=spec.kind,
815
+ panels=panels,
816
+ encoding=encoding,
817
+ labels=labels,
818
+ spec=spec,
819
+ figure_key=figure_key,
820
+ x_order=(
821
+ list(x_plan.order)
822
+ if x_plan
823
+ else (_level_order(table, x_factor, panels, X) if x_factor else None)
824
+ ),
825
+ x_plan=x_plan,
826
+ color_order=_level_order(table, color, panels, COLOR) if color else None,
827
+ grid_rows=n_rows,
828
+ grid_cols=n_cols,
829
+ row_labels=row_labels,
830
+ col_labels=col_labels,
831
+ layout_notes=layout_notes,
832
+ y_limits=figure_limits,
833
+ y_scope=list(y_scope or []),
834
+ downsampled_from=(
835
+ original_rows if max_points and original_rows > max_points else None
836
+ ),
837
+ )
838
+
839
+
840
+ def _panel_frame(
841
+ group: pd.DataFrame,
842
+ spec: PlotSpec,
843
+ table: LongTable,
844
+ shape: Shape,
845
+ x_layers: list[str],
846
+ color: str | None,
847
+ index_column: str | None,
848
+ ) -> pd.DataFrame:
849
+ """Build the canonical ``__x``/``__y``/… frame the renderers consume."""
850
+ y_measure = spec.y_measure
851
+ x_factor = x_layers[0] if x_layers else None
852
+
853
+ if shape is Shape.MATRIX_2D:
854
+ return _matrix_frame(group, y_measure)
855
+
856
+ out = pd.DataFrame(index=group.index)
857
+
858
+ # --- x --------------------------------------------------------------
859
+ if spec.x_measure is not None:
860
+ out[X] = pd.to_numeric(group[spec.x_measure], errors="coerce")
861
+ elif shape is Shape.SERIES_1D and index_column and index_column in group.columns:
862
+ out[X] = pd.to_numeric(group[index_column], errors="coerce")
863
+ elif len(x_layers) > 1:
864
+ # Nested axis: one position per COMBINATION of the layers, identified
865
+ # by a composed key. The layer values stay as their own columns too, so
866
+ # `plan_x_axis` can order them and the renderers can label the groups.
867
+ out[X] = _composed_key(group, x_layers, LEAF_SEPARATOR)
868
+ for name in x_layers:
869
+ out[name] = group[name].values
870
+ elif x_factor:
871
+ out[X] = group[x_factor].values
872
+ else:
873
+ # No x at all: a single categorical position, matching the proof of
874
+ # concept's "Observation" fallback when no tick factor was chosen.
875
+ out[X] = ""
876
+
877
+ out[Y] = pd.to_numeric(group[y_measure], errors="coerce")
878
+
879
+ if color:
880
+ out[COLOR] = group[color].values
881
+
882
+ # --- one polyline per replicate combination --------------------------
883
+ if spec.kind is PlotKind.LINE:
884
+ series_cols = [
885
+ name
886
+ for name in table.factor_names
887
+ if name in group.columns and name != x_factor
888
+ ]
889
+ if series_cols:
890
+ out[SERIES] = _composed_key(group, series_cols, SERIES_SEPARATOR)
891
+ else:
892
+ out[SERIES] = ""
893
+
894
+ out = out.dropna(subset=[Y])
895
+
896
+ # --- summarize replicates into centre + error ------------------------
897
+ if spec.kind in (PlotKind.BAR, PlotKind.BAND):
898
+ out = _summarize(out, spec, color)
899
+
900
+ if spec.kind is PlotKind.LINE or spec.kind is PlotKind.BAND:
901
+ out = out.sort_values(X, kind="stable")
902
+
903
+ return out.reset_index(drop=True)
904
+
905
+
906
+ def _composed_key(
907
+ frame: pd.DataFrame, columns: list[str], separator: str
908
+ ) -> np.ndarray:
909
+ """One string per row, composed from ``columns`` — **per distinct
910
+ combination, never per row**.
911
+
912
+ This is the identity of a polyline (which replicate is this sample part of?)
913
+ and of a nested-x leaf (which combination of layers is this tick?). Both are
914
+ categorical facts about the row's FACTORS, and a 1-D measure explodes into
915
+ hundreds of thousands of samples that all share them: a 24-row frame of EMG
916
+ traces becomes 8.9 million rows carrying 24 distinct answers.
917
+
918
+ The previous implementation asked each row: ``group[cols].astype(str).agg("
919
+ | ".join, axis=1)`` is a Python call **per row**, and the interactive path
920
+ never paid it because ``_downsample`` runs first (20 015 rows instead of
921
+ 8 906 400). At full resolution — the save path, and only the save path — one
922
+ figure took ~770s (scidb.log 2026-09-11, 1543s for two).
923
+
924
+ So: factorize each column (one C-level hash pass), fold the codes together
925
+ into a dense combination id, build the text ONCE per distinct combination,
926
+ and take. The Python loop below runs over combinations — 24 of them, not 8.9
927
+ million — and everything touching every row is a numpy/pandas primitive.
928
+
929
+ The strings are identical to the join it replaces — by construction, since
930
+ the text still comes from pandas' own ``astype(str)``, just applied to the
931
+ levels. The one deliberate difference is a **missing** factor value, which
932
+ the old form could not survive at all under pandas 3 (see below): here it is
933
+ a level like any other (``use_na_sentinel=False``, rather than a -1 sentinel
934
+ that would index the text backwards), named ``MISSING_LEVEL_TEXT``.
935
+ """
936
+ rows = len(frame)
937
+ if not columns:
938
+ return np.full(rows, "", dtype=object)
939
+
940
+ # Dense combination id per row, rebuilt column by column. Re-factorizing
941
+ # after each fold keeps the id in [0, distinct_so_far) — without it the
942
+ # composed key is a product of level counts and overflows int64 on a wide
943
+ # enough frame.
944
+ combined = np.zeros(rows, dtype=np.int64)
945
+ labels: list[str] = [""]
946
+
947
+ for position, column in enumerate(columns):
948
+ # The Series, not `.to_numpy()`: pandas factorizes an arrow-backed
949
+ # string column in place, where materializing it as objects would build
950
+ # 8.9 million Python strings on the way to counting 24 of them.
951
+ codes, uniques = pd.factorize(frame[column], use_na_sentinel=False)
952
+ width = max(len(uniques), 1)
953
+ combined, keys = pd.factorize(combined * width + codes)
954
+
955
+ # `.astype(str)`, not `str(value)` — on the LEVELS rather than the rows.
956
+ # Delegating to pandas is what keeps the text identical to the join this
957
+ # replaces for every dtype it renders differently from Python (a numpy
958
+ # scalar, an extension dtype).
959
+ #
960
+ # Except for missing values, where there is nothing to be identical to:
961
+ # pandas 3's `astype(str)` PRESERVES NA rather than writing "nan", so the
962
+ # join this replaces raised `TypeError: sequence item: expected str` on
963
+ # any factor column with a gap in it. A missing level is a level here.
964
+ text = list(pd.Index(uniques).astype(str).fillna(MISSING_LEVEL_TEXT))
965
+ labels = [
966
+ text[code]
967
+ if position == 0
968
+ else labels[previous] + separator + text[code]
969
+ for previous, code in (divmod(int(key), width) for key in keys)
970
+ ]
971
+
972
+ return np.asarray(labels, dtype=object)[combined]
973
+
974
+
975
+ def _matrix_frame(group: pd.DataFrame, measure: str) -> pd.DataFrame:
976
+ """One row holding the (possibly averaged) matrix for a heatmap panel."""
977
+ matrices = [np.asarray(v, dtype=float) for v in group[measure] if _is_sequence(v)]
978
+ if not matrices:
979
+ return pd.DataFrame({Z: []})
980
+ shapes = {m.shape for m in matrices}
981
+ if len(shapes) > 1:
982
+ Log.warn(
983
+ "heatmap: %d matrices with differing shapes %s — using the first",
984
+ len(matrices),
985
+ sorted(shapes),
986
+ layer=LAYER,
987
+ )
988
+ stacked = matrices[0]
989
+ elif len(matrices) > 1:
990
+ Log.debug("heatmap: averaging %d matrices", len(matrices), layer=LAYER)
991
+ stacked = np.mean(np.stack(matrices), axis=0)
992
+ else:
993
+ stacked = matrices[0]
994
+ return pd.DataFrame({Z: [stacked]})
995
+
996
+
997
+ def _summarize(frame: pd.DataFrame, spec: PlotSpec, color: str | None) -> pd.DataFrame:
998
+ """Collapse replicate rows at each x (and colour) into centre + error."""
999
+ group_cols = [X] + ([COLOR] if color else [])
1000
+ grouped = frame.groupby(group_cols, dropna=False, sort=False)[Y]
1001
+
1002
+ statistic = spec.aggregate.statistic
1003
+ centre = grouped.median() if statistic is Statistic.MEDIAN else grouped.mean()
1004
+
1005
+ error = spec.aggregate.error
1006
+ if error is ErrorBand.IQR:
1007
+ low = grouped.quantile(0.25)
1008
+ high = grouped.quantile(0.75)
1009
+ elif error is ErrorBand.NONE:
1010
+ low = centre
1011
+ high = centre
1012
+ else:
1013
+ sd = grouped.std(ddof=1).fillna(0.0)
1014
+ count = grouped.count()
1015
+ if error is ErrorBand.SD:
1016
+ spread = sd
1017
+ elif error is ErrorBand.SEM:
1018
+ spread = sd / np.sqrt(count.where(count > 0, 1))
1019
+ else: # CI95
1020
+ spread = 1.96 * sd / np.sqrt(count.where(count > 0, 1))
1021
+ low = centre - spread
1022
+ high = centre + spread
1023
+
1024
+ out = pd.concat(
1025
+ {Y: centre, Y_LOW: low, Y_HIGH: high}, axis=1
1026
+ ).reset_index()
1027
+ return out
1028
+
1029
+
1030
+ def _downsample(
1031
+ frame: pd.DataFrame, max_points: int, index_column: str | None
1032
+ ) -> pd.DataFrame:
1033
+ """
1034
+ Reduce row count for transport.
1035
+
1036
+ Striding (rather than random sampling) preserves the visual shape of 1-D
1037
+ traces, which is the case that actually gets big. The caller records the
1038
+ original size on the ResolvedPlot so the GUI can say so.
1039
+ """
1040
+ stride = max(1, len(frame) // max_points)
1041
+ reduced = frame.iloc[::stride]
1042
+ Log.warn(
1043
+ "downsampled %d row(s) to %d for transport (stride=%d)",
1044
+ len(frame),
1045
+ len(reduced),
1046
+ stride,
1047
+ layer=LAYER,
1048
+ )
1049
+ return reduced
1050
+
1051
+
1052
+ # ---------------------------------------------------------------------------
1053
+ # Ordering, encoding, labels
1054
+ # ---------------------------------------------------------------------------
1055
+
1056
+
1057
+ def _role_holder(roles: dict[str, Role], role: Role) -> str | None:
1058
+ for name, assigned in roles.items():
1059
+ if assigned is role:
1060
+ return name
1061
+ return None
1062
+
1063
+
1064
+ def _ordered_groups(
1065
+ frame: pd.DataFrame, columns: list[str], table: LongTable
1066
+ ) -> list[tuple[tuple, pd.DataFrame]]:
1067
+ """
1068
+ Group by ``columns`` in the factors' declared level order.
1069
+
1070
+ Declared order matters: zero-padded schema keys ("01", "02", … "10") sort
1071
+ lexicographically into 1, 10, 2 under pandas' default, which is a visible
1072
+ bug on an axis and in a facet strip. ``LongTable`` carries the real order.
1073
+ """
1074
+ present = [c for c in columns if c in frame.columns]
1075
+ if not present:
1076
+ return [((), frame)]
1077
+
1078
+ groups = {key: group for key, group in frame.groupby(present, dropna=False, sort=False)}
1079
+ ordered_keys = sorted(
1080
+ groups.keys(),
1081
+ key=lambda key: tuple(
1082
+ _level_rank(table, column, value)
1083
+ for column, value in zip(present, _as_tuple(key), strict=True)
1084
+ ),
1085
+ )
1086
+ return [(_as_tuple(key), groups[key]) for key in ordered_keys]
1087
+
1088
+
1089
+ def _as_tuple(key: Any) -> tuple:
1090
+ return key if isinstance(key, tuple) else (key,)
1091
+
1092
+
1093
+ def _level_rank(table: LongTable, column: str, value: Any) -> tuple:
1094
+ """Position of ``value`` in the factor's declared levels, else natural sort."""
1095
+ try:
1096
+ levels = table.factor(column).levels
1097
+ except KeyError:
1098
+ return (1,) + natural_sort_key(value)
1099
+ for position, level in enumerate(levels):
1100
+ if level == value or str(level) == str(value):
1101
+ return (0, position)
1102
+ return (1,) + natural_sort_key(value)
1103
+
1104
+
1105
+ def _plan_nested_x(
1106
+ panels: list[Panel], table: LongTable, x_layers: list[str]
1107
+ ) -> XPlan:
1108
+ """Compose the nested axis from the combinations the panels actually hold.
1109
+
1110
+ Observed combinations, never the Cartesian product: real designs are ragged
1111
+ — a sham group with no post session — and reserving a position for a
1112
+ combination nobody ran leaves a hole that reads as missing data.
1113
+
1114
+ Taken across ALL panels so a faceted figure shares one axis; a panel missing
1115
+ a combination gets a gap in the same place as its neighbours rather than a
1116
+ differently-shaped axis.
1117
+ """
1118
+ combinations: list[tuple] = []
1119
+ for panel in panels:
1120
+ present = [name for name in x_layers if name in panel.frame.columns]
1121
+ if len(present) != len(x_layers) or panel.frame.empty:
1122
+ continue
1123
+ combinations.extend(
1124
+ panel.frame[x_layers].astype(str).drop_duplicates().itertuples(
1125
+ index=False, name=None
1126
+ )
1127
+ )
1128
+ layer_orders = [
1129
+ [str(level) for level in _factor_levels(table, name)] for name in x_layers
1130
+ ]
1131
+ return plan_x_axis(combinations, layer_orders)
1132
+
1133
+
1134
+ def _factor_levels(table: LongTable, name: str) -> list[Any]:
1135
+ try:
1136
+ return table.factor(name).levels
1137
+ except KeyError:
1138
+ return []
1139
+
1140
+
1141
+ def _level_order(
1142
+ table: LongTable, column: str | None, panels: list[Panel], frame_column: str
1143
+ ) -> list[Any] | None:
1144
+ if not column:
1145
+ return None
1146
+ present: list[Any] = []
1147
+ for panel in panels:
1148
+ if frame_column in panel.frame.columns:
1149
+ present.extend(panel.frame[frame_column].dropna().unique().tolist())
1150
+ unique = list(dict.fromkeys(present))
1151
+ return sorted(unique, key=lambda v: _level_rank(table, column, v))
1152
+
1153
+
1154
+ @dataclass(frozen=True)
1155
+ class GridPlan:
1156
+ """Where each labelled panel sits, and how big the grid ended up."""
1157
+
1158
+ #: (row, col) per input label, in the same order.
1159
+ cells: list[tuple[int, int]]
1160
+ n_rows: int
1161
+ n_cols: int
1162
+ row_labels: list[str]
1163
+ col_labels: list[str]
1164
+ notes: list[str]
1165
+
1166
+ @property
1167
+ def fills_row_major(self) -> bool:
1168
+ """
1169
+ True when the occupied cells are a gapless left-to-right, top-to-bottom
1170
+ prefix of the grid — the panels may be in any ORDER, but there are no
1171
+ holes. That is exactly the case seaborn's ``col_wrap`` + ``col_order``
1172
+ can reproduce, so ``codegen`` asks before claiming the exported figure
1173
+ matches the preview.
1174
+ """
1175
+ return sorted(self.cells) == [
1176
+ divmod(index, self.n_cols) for index in range(len(self.cells))
1177
+ ]
1178
+
1179
+ def labels_in_grid_order(self, labels: list[str]) -> list[str]:
1180
+ """``labels`` re-ordered the way the grid reads: row by row."""
1181
+ return [label for _, label in sorted(zip(self.cells, labels, strict=True))]
1182
+
1183
+
1184
+ def plan_layout(labels: list[str], facet) -> GridPlan:
1185
+ """
1186
+ Decide the facet grid from panel labels alone — no data involved.
1187
+
1188
+ Separate from :func:`_assign_grid` so the same placement can be replayed by
1189
+ ``codegen`` (to emit a matching ``col_order``) and asserted in tests without
1190
+ building frames. The rules below are the whole layout contract.
1191
+
1192
+ The grid is ``n_rows x n_cols`` (see ``spec.grid_shape_for``: naming one
1193
+ dimension computes the other). Each row and column slot may carry a matcher
1194
+ that claims the panels whose label it matches — which is what makes a layout
1195
+ describable ("left column = names starting with L") and therefore reusable
1196
+ across variables, rather than a hand-arrangement of one figure. Blank slots
1197
+ take whatever is left over, in resolution order.
1198
+
1199
+ Two invariants, both of them things the user has been bitten by:
1200
+
1201
+ * **No cell is ever claimed twice.** Every placement goes through
1202
+ ``occupied``; a panel whose ruled cell is taken spills to the next free
1203
+ cell (growing the grid if it must) and says so in the returned notes.
1204
+ Two panels in one cell means two plotly axis pairs with an identical
1205
+ domain, i.e. traces drawn on top of each other.
1206
+ * **Nothing is dropped.** A panel matching nothing is free to take any
1207
+ remaining cell, and the grid grows a trailing "other" row/column if there
1208
+ is none. Silently losing a muscle because a pattern had a typo is the
1209
+ worst possible failure here.
1210
+
1211
+ """
1212
+ if not labels:
1213
+ return GridPlan(cells=[], n_rows=1, n_cols=1, row_labels=[], col_labels=[], notes=[])
1214
+
1215
+ # Slot POSITION is meaningful, so the blanks stay in the list: rules[1] is
1216
+ # row 2 whether or not row 1 was filled in. Blank matchers never match (see
1217
+ # Matcher.is_blank), so an unset slot claims nothing and simply receives
1218
+ # whatever is still unplaced.
1219
+ row_slots, col_slots = list(facet.rows), list(facet.cols)
1220
+ notes: list[str] = []
1221
+
1222
+ n_rows, n_cols = grid_shape_for(len(labels), facet.n_rows, facet.n_cols)
1223
+ # A pinned dimension wins over leftover slots: shrinking a 4-row grid to 2
1224
+ # must not be undone by the two rules the user had already written into
1225
+ # rows 3 and 4. They stay in the spec (re-widening brings them back) but
1226
+ # they are not rows. An unpinned dimension does the opposite — it widens to
1227
+ # hold every slot that was written.
1228
+ if facet.n_rows:
1229
+ row_slots = row_slots[:n_rows]
1230
+ else:
1231
+ n_rows = max(n_rows, len(row_slots))
1232
+ if facet.n_cols:
1233
+ col_slots = col_slots[:n_cols]
1234
+ else:
1235
+ n_cols = max(n_cols, len(col_slots))
1236
+
1237
+ has_row_rules = any(not m.is_blank for m in row_slots)
1238
+ has_col_rules = any(not m.is_blank for m in col_slots)
1239
+ row_slot = [_match_index(row_slots, label) for label in labels]
1240
+ col_slot = [_match_index(col_slots, label) for label in labels]
1241
+
1242
+ occupied: dict[tuple[int, int], str] = {}
1243
+ cells: list[tuple[int, int] | None] = [None] * len(labels)
1244
+ spilled: list[int] = []
1245
+
1246
+ def claim(index: int, row: int, col: int) -> bool:
1247
+ if (row, col) in occupied:
1248
+ return False
1249
+ occupied[(row, col)] = labels[index]
1250
+ cells[index] = (row, col)
1251
+ return True
1252
+
1253
+ # Pass A: both axes ruled — the panel has an exact address.
1254
+ for index, (row, col) in enumerate(zip(row_slot, col_slot, strict=True)):
1255
+ if row is None or col is None:
1256
+ continue
1257
+ if not claim(index, row, col):
1258
+ spilled.append(index)
1259
+ notes.append(
1260
+ f"{labels[index]!r} and {occupied[(row, col)]!r} both match row "
1261
+ f"{row + 1} and column {col + 1}; {labels[index]!r} was moved to "
1262
+ f"the next free cell."
1263
+ )
1264
+
1265
+ # Pass B: one axis ruled — first FREE cell along the other, so panels stack
1266
+ # down a ruled column / flow across a ruled row without ever colliding.
1267
+ for index, (row, col) in enumerate(zip(row_slot, col_slot, strict=True)):
1268
+ if (row is None) == (col is None):
1269
+ continue
1270
+ if row is None:
1271
+ free = next((r for r in range(n_rows) if (r, col) not in occupied), None)
1272
+ placed = free is not None and claim(index, free, col)
1273
+ else:
1274
+ free = next((c for c in range(n_cols) if (row, c) not in occupied), None)
1275
+ placed = free is not None and claim(index, row, free)
1276
+ if not placed:
1277
+ spilled.append(index)
1278
+ axis = "column" if row is None else "row"
1279
+ notes.append(
1280
+ f"{labels[index]!r} matches a {axis} rule but that {axis} is "
1281
+ f"full; it was moved to the next free cell."
1282
+ )
1283
+
1284
+ # Pass C: unconstrained panels fill what is left, in resolution order. With
1285
+ # no rules at all this is the plain wrapped flow.
1286
+ free_cells = (
1287
+ (r, c) for r in range(n_rows) for c in range(n_cols) if (r, c) not in occupied
1288
+ )
1289
+ for index, (row, col) in enumerate(zip(row_slot, col_slot, strict=True)):
1290
+ if row is not None or col is not None:
1291
+ continue
1292
+ cell = next(free_cells, None)
1293
+ if cell is None:
1294
+ spilled.append(index)
1295
+ else:
1296
+ claim(index, *cell)
1297
+
1298
+ # Pass D: everything that could not take its own cell. The grid grows rather
1299
+ # than letting two panels share one.
1300
+ for index in spilled:
1301
+ row, col = _first_free_cell(occupied, n_rows, n_cols)
1302
+ if row >= n_rows:
1303
+ n_rows = row + 1
1304
+ notes.append(
1305
+ f"The grid grew to {n_rows} rows so that {labels[index]!r} could "
1306
+ f"have a cell of its own."
1307
+ )
1308
+ claim(index, row, col)
1309
+
1310
+ # Shrink a dimension the user did NOT pin down to what the panels actually
1311
+ # used: "2 rows of muscles" should not leave a trailing empty column just
1312
+ # because the starting estimate was wider. A pinned dimension is honoured
1313
+ # as given — the user asked for that much room.
1314
+ placed = [cell for cell in cells if cell is not None]
1315
+ if facet.n_rows is None and placed:
1316
+ n_rows = max(len(row_slots), max(row for row, _ in placed) + 1)
1317
+ if facet.n_cols is None and placed:
1318
+ n_cols = max(len(col_slots), max(col for _, col in placed) + 1)
1319
+
1320
+ # Anything past the declared slots holds panels no rule claimed. Label it,
1321
+ # so a typo in a pattern shows up as an "other" column rather than as a
1322
+ # muscle mysteriously sitting on the end.
1323
+ row_labels = _rule_labels(row_slots)
1324
+ col_labels = _rule_labels(col_slots)
1325
+ if has_row_rules and n_rows > len(row_slots):
1326
+ row_labels += ["other"] * (n_rows - len(row_slots))
1327
+ notes.append("Some panels matched no row rule — see the 'other' row(s).")
1328
+ if has_col_rules and n_cols > len(col_slots):
1329
+ col_labels += ["other"] * (n_cols - len(col_slots))
1330
+ notes.append("Some panels matched no column rule — see the 'other' column(s).")
1331
+
1332
+ return GridPlan(
1333
+ # Every pass above ends by claiming a cell, so None is unreachable —
1334
+ # but a panel with no cell would be a panel the renderer never draws.
1335
+ cells=[(0, 0) if cell is None else cell for cell in cells],
1336
+ n_rows=n_rows,
1337
+ n_cols=n_cols,
1338
+ row_labels=row_labels,
1339
+ col_labels=col_labels,
1340
+ notes=notes,
1341
+ )
1342
+
1343
+
1344
+ def _assign_grid(panels, facet) -> tuple[int, int, list[str], list[str], list[str]]:
1345
+ """
1346
+ Place every panel in the grid, and report its shape.
1347
+
1348
+ Thin wrapper over :func:`plan_layout` — the placement decision is made from
1349
+ panel labels alone so that ``codegen`` can replay it, and applied to the
1350
+ Panel objects here.
1351
+
1352
+ Returns ``(n_rows, n_cols, row_labels, col_labels, notes)``.
1353
+ """
1354
+ plan = plan_layout([panel.title for panel in panels], facet)
1355
+ for panel, (row, col) in zip(panels, plan.cells, strict=True):
1356
+ panel.grid_row, panel.grid_col = row, col
1357
+
1358
+ for note in plan.notes:
1359
+ Log.warn("facet layout: %s", note, layer=LAYER)
1360
+ Log.debug(
1361
+ "facet grid %dx%d from %d row rule(s), %d column rule(s): %s",
1362
+ plan.n_rows,
1363
+ plan.n_cols,
1364
+ len(facet.row_rules),
1365
+ len(facet.col_rules), # non-blank only: the rules the user actually wrote
1366
+ ", ".join(f"{p.title or '?'}@({p.grid_row},{p.grid_col})" for p in panels),
1367
+ layer=LAYER,
1368
+ )
1369
+ return plan.n_rows, plan.n_cols, plan.row_labels, plan.col_labels, plan.notes
1370
+
1371
+
1372
+ def _first_free_cell(
1373
+ occupied: dict[tuple[int, int], str], n_rows: int, n_cols: int
1374
+ ) -> tuple[int, int]:
1375
+ """
1376
+ First unoccupied cell in row-major order, growing past the last row when the
1377
+ grid is full — the caller widens the grid rather than overlap two panels.
1378
+ """
1379
+ for row in range(n_rows):
1380
+ for col in range(n_cols):
1381
+ if (row, col) not in occupied:
1382
+ return row, col
1383
+ return n_rows, 0
1384
+
1385
+
1386
+ def _match_index(rules: list[Matcher], label: str) -> int | None:
1387
+ """First rule that matches, or None. First match wins — order is the tie-break."""
1388
+ for index, rule in enumerate(rules):
1389
+ if rule.matches(label):
1390
+ return index
1391
+ return None
1392
+
1393
+
1394
+ def _rule_labels(rules: list[Matcher]) -> list[str]:
1395
+ return [rule.display for rule in rules]
1396
+
1397
+
1398
+ def _encoding_for(kind: PlotKind, color: str | None, shape: Shape) -> Encoding:
1399
+ if shape is Shape.MATRIX_2D:
1400
+ return Encoding(x=None, y=None, z=Z)
1401
+ return Encoding(
1402
+ x=X,
1403
+ y=Y,
1404
+ color=COLOR if color else None,
1405
+ y_low=Y_LOW if kind in (PlotKind.BAR, PlotKind.BAND) else None,
1406
+ y_high=Y_HIGH if kind in (PlotKind.BAR, PlotKind.BAND) else None,
1407
+ series=SERIES if kind is PlotKind.LINE else None,
1408
+ )
1409
+
1410
+
1411
+ def _labels_for(
1412
+ spec: PlotSpec,
1413
+ table: LongTable,
1414
+ x_layers: list[str],
1415
+ color: str | None,
1416
+ index_column: str | None,
1417
+ figure_key: dict[str, Any],
1418
+ ) -> Labels:
1419
+ style = spec.style
1420
+ if style.x_label:
1421
+ x_label = style.x_label
1422
+ elif spec.x_measure:
1423
+ x_label = table.measure(spec.x_measure).display
1424
+ elif len(x_layers) > 1:
1425
+ # Nested: the layers read outermost-last, matching how the rows of
1426
+ # labels stack under the axis (leaf ticks nearest the plot).
1427
+ x_label = " / ".join(
1428
+ table.factor(name).display for name in reversed(x_layers)
1429
+ )
1430
+ elif x_layers:
1431
+ x_label = table.factor(x_layers[0]).display
1432
+ elif index_column:
1433
+ x_label = index_column
1434
+ else:
1435
+ x_label = ""
1436
+
1437
+ y_label = style.y_label or table.measure(spec.y_measure).display
1438
+
1439
+ title = style.title
1440
+ if title is None and figure_key:
1441
+ title = ", ".join(f"{k}={v}" for k, v in figure_key.items())
1442
+
1443
+ return Labels(
1444
+ x=x_label,
1445
+ y=y_label,
1446
+ color=table.factor(color).display if color else None,
1447
+ title=title,
1448
+ )
1449
+
1450
+
1451
+ def _figure_limits(panels: list[Panel]) -> tuple[float, float] | None:
1452
+ """The figure's y limits, or None when its panels do not share one range.
1453
+
1454
+ Read off the panels rather than recomputed, so the figure-level number and
1455
+ the panel-level ones cannot disagree. ``None`` is meaningful: it is how a
1456
+ renderer learns it must give each panel its own axis instead of sharing one
1457
+ (``render.base.shares_y_axis``).
1458
+ """
1459
+ if not panels:
1460
+ return None
1461
+ first = panels[0].y_limits
1462
+ if first is None:
1463
+ return None
1464
+ return first if all(panel.y_limits == first for panel in panels) else None
1465
+
1466
+
1467
+ def unique_values(frame: pd.DataFrame, column: str) -> list[Any]:
1468
+ """Distinct values of a column, natural-sorted. Used by sources for levels."""
1469
+ if column not in frame.columns:
1470
+ return []
1471
+ return sorted(frame[column].dropna().unique().tolist(), key=natural_sort_key)
1472
+
1473
+
1474
+ def iter_columns(names: Iterable[str]) -> list[str]:
1475
+ return [n for n in names if n]