scistackplot 0.1.26__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scistackplot/__init__.py +239 -0
- scistackplot/capability.py +656 -0
- scistackplot/codegen.py +886 -0
- scistackplot/groups.py +110 -0
- scistackplot/reduce.py +1475 -0
- scistackplot/render/__init__.py +26 -0
- scistackplot/render/base.py +262 -0
- scistackplot/render/mpl.py +458 -0
- scistackplot/render/plotly_.py +537 -0
- scistackplot/resolved.py +240 -0
- scistackplot/roles.py +413 -0
- scistackplot/shape.py +106 -0
- scistackplot/sources/__init__.py +7 -0
- scistackplot/sources/base.py +256 -0
- scistackplot/sources/csv.py +67 -0
- scistackplot/sources/frame.py +45 -0
- scistackplot/spec.py +698 -0
- scistackplot/table.py +328 -0
- scistackplot/variants.py +743 -0
- scistackplot/xaxis.py +183 -0
- scistackplot/ylimits.py +451 -0
- scistackplot-0.1.26.dist-info/METADATA +212 -0
- scistackplot-0.1.26.dist-info/RECORD +24 -0
- scistackplot-0.1.26.dist-info/WHEEL +4 -0
scistackplot/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]
|