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/spec.py
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
"""
|
|
2
|
+
``PlotSpec`` — the serializable description of a plot.
|
|
3
|
+
|
|
4
|
+
This is the load-bearing object of the whole package. The interactive GUI does
|
|
5
|
+
not produce pictures; it produces a ``PlotSpec``, and everything downstream
|
|
6
|
+
(resolution, rendering, code generation, and the pipeline ``plot_`` endpoint)
|
|
7
|
+
is a pure function of one. Keeping it small, serializable, and diffable is what
|
|
8
|
+
lets an inherently visual tool participate in a reproducible pipeline.
|
|
9
|
+
|
|
10
|
+
See ``docs/claude/plotting-library-design.md`` for the reasoning.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
from dataclasses import asdict, dataclass, field, replace
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Role(str, Enum):
|
|
23
|
+
"""
|
|
24
|
+
What a factor column does in a plot.
|
|
25
|
+
|
|
26
|
+
Every factor carries exactly **one** role. That invariant is the whole
|
|
27
|
+
reason this enum exists: the R/Shiny proof of concept spread the same
|
|
28
|
+
information across four independent widgets and spent most of its length
|
|
29
|
+
keeping them consistent with each other via ``setdiff``.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
ITERATE = "iterate" # separate FIGURE per level (fan-out)
|
|
33
|
+
X = "x" # x-axis position
|
|
34
|
+
COLOR = "color" # one series/hue per level
|
|
35
|
+
FACET = "facet" # one subplot per level
|
|
36
|
+
AGGREGATE = "aggregate" # collapse: average across this factor's levels
|
|
37
|
+
FREE = "free" # keep as replicate rows -> distributions
|
|
38
|
+
|
|
39
|
+
def __str__(self) -> str:
|
|
40
|
+
return self.value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
#: Roles that accept at most ONE factor. FACET is deliberately absent: several
|
|
44
|
+
#: factors may be faceted at once, and how their combined levels are arranged
|
|
45
|
+
#: into rows and columns is a layout decision (FacetOptions.rows/cols), not a
|
|
46
|
+
#: property of which factor was assigned where. The old FACET_ROW/FACET_COL
|
|
47
|
+
#: pair forced that decision into the role and still could not express
|
|
48
|
+
#: "arrange these 13 muscles as left/right x muscle group".
|
|
49
|
+
#:
|
|
50
|
+
#: X left for the same reason: "stim and sham side by side, each split by
|
|
51
|
+
#: session" is one axis carrying two factors, nested. Which factor is the outer
|
|
52
|
+
#: grouping is an ORDER (``PlotSpec.x_layers``), not a different role.
|
|
53
|
+
SINGLE_ASSIGNMENT_ROLES = (Role.COLOR,)
|
|
54
|
+
|
|
55
|
+
#: How many factors may share the x axis. Three is not arbitrary: a fourth
|
|
56
|
+
#: level of nesting cannot be read off an axis, and the label stack below the
|
|
57
|
+
#: plot grows taller than the plot.
|
|
58
|
+
MAX_X_LAYERS = 3
|
|
59
|
+
|
|
60
|
+
#: Roles that leave a factor's levels as multiple rows in one cell, i.e. that
|
|
61
|
+
#: can produce a distribution. AGGREGATE is NOT here: it collapses first.
|
|
62
|
+
REPLICATE_ROLES = (Role.FREE,)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PlotKind(str, Enum):
|
|
66
|
+
"""The visual form of the plot."""
|
|
67
|
+
|
|
68
|
+
SCATTER = "scatter" # one marker per row
|
|
69
|
+
STRIP = "strip" # scatter with categorical jitter
|
|
70
|
+
LINE = "line" # one polyline per series (1-D measures)
|
|
71
|
+
BOX = "box" # distribution per x position
|
|
72
|
+
VIOLIN = "violin" # distribution per x position, density
|
|
73
|
+
BAR = "bar" # statistic per x position, with error bar
|
|
74
|
+
BAND = "band" # statistic line + shaded error region (1-D)
|
|
75
|
+
HEATMAP = "heatmap" # 2-D matrix
|
|
76
|
+
|
|
77
|
+
def __str__(self) -> str:
|
|
78
|
+
return self.value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Statistic(str, Enum):
|
|
82
|
+
MEAN = "mean"
|
|
83
|
+
MEDIAN = "median"
|
|
84
|
+
|
|
85
|
+
def __str__(self) -> str:
|
|
86
|
+
return self.value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ErrorBand(str, Enum):
|
|
90
|
+
NONE = "none"
|
|
91
|
+
SD = "sd"
|
|
92
|
+
SEM = "sem"
|
|
93
|
+
CI95 = "ci95"
|
|
94
|
+
IQR = "iqr"
|
|
95
|
+
|
|
96
|
+
def __str__(self) -> str:
|
|
97
|
+
return self.value
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class MatchOp(str, Enum):
|
|
101
|
+
"""How a facet-layout rule tests a panel's label."""
|
|
102
|
+
|
|
103
|
+
STARTS_WITH = "starts_with"
|
|
104
|
+
ENDS_WITH = "ends_with"
|
|
105
|
+
CONTAINS = "contains"
|
|
106
|
+
NOT_CONTAINS = "not_contains"
|
|
107
|
+
EQUALS = "equals"
|
|
108
|
+
REGEX = "regex"
|
|
109
|
+
|
|
110
|
+
def __str__(self) -> str:
|
|
111
|
+
return self.value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class Matcher:
|
|
116
|
+
"""
|
|
117
|
+
One row (or column) of a facet grid, defined by what its panels look like.
|
|
118
|
+
|
|
119
|
+
Naming a grid by *rules* rather than by position is what makes a layout
|
|
120
|
+
reusable: "left column = names starting with L" holds for any muscle set,
|
|
121
|
+
any subject, any variable, so the same arrangement can later be saved as a
|
|
122
|
+
preset and applied to a different plot.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
op: MatchOp = MatchOp.CONTAINS
|
|
126
|
+
value: str = ""
|
|
127
|
+
#: Shown as the row/column header. Defaults to the value.
|
|
128
|
+
label: str | None = None
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def display(self) -> str:
|
|
132
|
+
"""Row/column header. An unset slot has no header, not the op's name."""
|
|
133
|
+
if self.is_blank:
|
|
134
|
+
return self.label or ""
|
|
135
|
+
return self.label or self.value
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def is_blank(self) -> bool:
|
|
139
|
+
"""
|
|
140
|
+
An unset slot. The grid has a fixed number of row/column slots, so most
|
|
141
|
+
of them are empty most of the time, and an empty slot must mean
|
|
142
|
+
"whatever is left over, in order" — never "everything".
|
|
143
|
+
"""
|
|
144
|
+
return not self.value
|
|
145
|
+
|
|
146
|
+
def matches(self, text: str) -> bool:
|
|
147
|
+
if self.is_blank:
|
|
148
|
+
# Without this, CONTAINS "" matches every panel ('' in text) and the
|
|
149
|
+
# first blank slot swallows the whole grid; NOT_CONTAINS "" is the
|
|
150
|
+
# same bug inverted.
|
|
151
|
+
return False
|
|
152
|
+
text = "" if text is None else str(text)
|
|
153
|
+
needle = self.value
|
|
154
|
+
if self.op is MatchOp.STARTS_WITH:
|
|
155
|
+
return text.startswith(needle)
|
|
156
|
+
if self.op is MatchOp.ENDS_WITH:
|
|
157
|
+
return text.endswith(needle)
|
|
158
|
+
if self.op is MatchOp.CONTAINS:
|
|
159
|
+
return needle in text
|
|
160
|
+
if self.op is MatchOp.NOT_CONTAINS:
|
|
161
|
+
return needle not in text
|
|
162
|
+
if self.op is MatchOp.EQUALS:
|
|
163
|
+
return text == needle
|
|
164
|
+
if self.op is MatchOp.REGEX:
|
|
165
|
+
import re
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
return re.search(needle, text) is not None
|
|
169
|
+
except re.error:
|
|
170
|
+
# An invalid pattern must not take the whole figure down while
|
|
171
|
+
# the user is still typing it.
|
|
172
|
+
return False
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True)
|
|
177
|
+
class Filter:
|
|
178
|
+
"""A row filter applied before anything else."""
|
|
179
|
+
|
|
180
|
+
column: str
|
|
181
|
+
#: Keep rows whose value is in this list (categorical include).
|
|
182
|
+
include: list[Any] | None = None
|
|
183
|
+
#: Drop rows whose value is in this list.
|
|
184
|
+
exclude: list[Any] | None = None
|
|
185
|
+
#: Numeric range, inclusive; either bound may be None.
|
|
186
|
+
minimum: float | None = None
|
|
187
|
+
maximum: float | None = None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True)
|
|
191
|
+
class LevelGroup:
|
|
192
|
+
"""A factor derived by bucketing another factor's levels.
|
|
193
|
+
|
|
194
|
+
For a ``session`` key whose values are ``pre, post1, post2, post3``, this is
|
|
195
|
+
how "baseline vs post" becomes a factor you can colour or facet by without
|
|
196
|
+
editing any data. A key whose levels *already are* the groups needs none of
|
|
197
|
+
this — it is a factor with a role today.
|
|
198
|
+
|
|
199
|
+
Kept as a spec field rather than a column somewhere so it travels with the
|
|
200
|
+
figure: the same bucketing is part of what the plot means, and a reader
|
|
201
|
+
re-opening the spec sees the definition rather than an unexplained column.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
#: The new factor's name, e.g. ``"Phase"``.
|
|
205
|
+
name: str
|
|
206
|
+
#: The existing factor whose levels are being bucketed, e.g. ``"session"``.
|
|
207
|
+
source: str
|
|
208
|
+
#: ``{level: group label}``. Levels are matched as text.
|
|
209
|
+
mapping: dict[str, str] = field(default_factory=dict)
|
|
210
|
+
#: What happens to levels the mapping does not name. ``None`` DROPS those
|
|
211
|
+
#: rows — "just these two groups, ignore the rest" — and any other value is
|
|
212
|
+
#: the bucket they land in. There is no third option where they stay
|
|
213
|
+
#: unlabelled: a NaN group silently becomes its own series.
|
|
214
|
+
unmatched: str | None = None
|
|
215
|
+
|
|
216
|
+
def to_dict(self) -> dict:
|
|
217
|
+
return {
|
|
218
|
+
"name": self.name,
|
|
219
|
+
"source": self.source,
|
|
220
|
+
"mapping": dict(self.mapping),
|
|
221
|
+
"unmatched": self.unmatched,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
@classmethod
|
|
225
|
+
def from_dict(cls, raw: dict) -> "LevelGroup":
|
|
226
|
+
return cls(
|
|
227
|
+
name=raw["name"],
|
|
228
|
+
source=raw["source"],
|
|
229
|
+
mapping=dict(raw.get("mapping") or {}),
|
|
230
|
+
unmatched=raw.get("unmatched"),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass(frozen=True)
|
|
235
|
+
class Aggregation:
|
|
236
|
+
statistic: Statistic = Statistic.MEAN
|
|
237
|
+
error: ErrorBand = ErrorBand.SD
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass(frozen=True)
|
|
241
|
+
class FacetOptions:
|
|
242
|
+
"""
|
|
243
|
+
How faceted panels are arranged: a grid of a known size, plus one ordering
|
|
244
|
+
rule per row and per column slot.
|
|
245
|
+
|
|
246
|
+
``n_rows``/``n_cols`` size the grid; setting either one computes the other
|
|
247
|
+
from the panel count (:func:`grid_shape_for`), so the user names a width and
|
|
248
|
+
the height follows. Each slot may then carry a :class:`Matcher` that claims
|
|
249
|
+
the panels whose label it matches, which is what lets an EMG figure read as
|
|
250
|
+
left/right x muscle group instead of an arbitrary 4-wide flow. Blank slots
|
|
251
|
+
take whatever is left over, in order.
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
#: Grid size. None means "compute me from the other one and the panel count".
|
|
255
|
+
n_rows: int | None = None
|
|
256
|
+
n_cols: int | None = None
|
|
257
|
+
#: One entry per row / column slot; blank entries are unset (see Matcher.is_blank).
|
|
258
|
+
rows: list[Matcher] = field(default_factory=list)
|
|
259
|
+
cols: list[Matcher] = field(default_factory=list)
|
|
260
|
+
share_x: bool = True
|
|
261
|
+
# NOTE: there is no `share_y`. It moved to `PlotSpec.y_axis` (:class:`YAxis`)
|
|
262
|
+
# and became a question with more than two answers — "which factors separate
|
|
263
|
+
# the limits" rather than "do the facets share them". `share_y=True` is
|
|
264
|
+
# `YAxis(scope=[])` and `share_y=False` is a scope naming every panel
|
|
265
|
+
# factor; keeping both would be two controls deciding one thing.
|
|
266
|
+
|
|
267
|
+
@property
|
|
268
|
+
def row_rules(self) -> list[Matcher]:
|
|
269
|
+
"""Row slots that actually claim panels."""
|
|
270
|
+
return [m for m in self.rows if not m.is_blank]
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def col_rules(self) -> list[Matcher]:
|
|
274
|
+
return [m for m in self.cols if not m.is_blank]
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def has_rules(self) -> bool:
|
|
278
|
+
return bool(self.row_rules or self.col_rules)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@dataclass(frozen=True)
|
|
282
|
+
class YAxis:
|
|
283
|
+
"""What the y axis spans — and, more importantly, **what separates spans**.
|
|
284
|
+
|
|
285
|
+
The old control was ``FacetOptions.share_y``: one boolean, all the facets of
|
|
286
|
+
a figure share limits or they do not, with figures always scaled to
|
|
287
|
+
themselves. That cannot express the two things a reader actually needs —
|
|
288
|
+
"every plot in this study on one scale, so I can compare them" and
|
|
289
|
+
"autoscale this panel, so I can see its shape" — let alone anything between.
|
|
290
|
+
|
|
291
|
+
So limits are **split by a set of factors**:
|
|
292
|
+
|
|
293
|
+
================================ ==========================================
|
|
294
|
+
``scope`` one limit per…
|
|
295
|
+
================================ ==========================================
|
|
296
|
+
``[]`` the whole dataset — every panel, every
|
|
297
|
+
figure, one range
|
|
298
|
+
``["subject"]`` subject; all that subject's facets share it
|
|
299
|
+
``["subject", "ColName"]`` subject AND facet — true per-panel
|
|
300
|
+
autoscale
|
|
301
|
+
================================ ==========================================
|
|
302
|
+
|
|
303
|
+
Only factors that **separate panels** may appear: ITERATE (a factor per
|
|
304
|
+
figure) and FACET (a factor per subplot). A COLOR or FREE factor lives
|
|
305
|
+
*within* a panel, so splitting on it would ask one axis to have two ranges;
|
|
306
|
+
:func:`~scistackplot.ylimits.eligible_scope` drops those and says so rather
|
|
307
|
+
than failing or silently obeying.
|
|
308
|
+
|
|
309
|
+
``minimum``/``maximum`` override whatever ``scope`` computed, independently:
|
|
310
|
+
a floor of 0 with a computed ceiling is a normal thing to want. They are
|
|
311
|
+
spelled out rather than ``min``/``max`` because those shadow builtins in
|
|
312
|
+
every comprehension that touches them.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
#: Panel factors that separate limits. Empty means one range for everything.
|
|
316
|
+
scope: list[str] = field(default_factory=list)
|
|
317
|
+
minimum: float | None = None
|
|
318
|
+
maximum: float | None = None
|
|
319
|
+
|
|
320
|
+
@property
|
|
321
|
+
def is_manual(self) -> bool:
|
|
322
|
+
"""Whether BOTH ends are pinned — the case that needs no data at all."""
|
|
323
|
+
return self.minimum is not None and self.maximum is not None
|
|
324
|
+
|
|
325
|
+
def to_dict(self) -> dict:
|
|
326
|
+
return {
|
|
327
|
+
"scope": list(self.scope),
|
|
328
|
+
"minimum": self.minimum,
|
|
329
|
+
"maximum": self.maximum,
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
@classmethod
|
|
333
|
+
def from_dict(cls, raw: dict) -> "YAxis":
|
|
334
|
+
return cls(
|
|
335
|
+
scope=list(raw.get("scope") or []),
|
|
336
|
+
minimum=_as_float(raw.get("minimum")),
|
|
337
|
+
maximum=_as_float(raw.get("maximum")),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _as_float(value: Any) -> float | None:
|
|
342
|
+
"""A y-limit bound, or None for "compute it".
|
|
343
|
+
|
|
344
|
+
An empty box in the GUI arrives as ``""``, and ``float("")`` raises — which
|
|
345
|
+
would make clearing a limit an error rather than the way you ask for the
|
|
346
|
+
computed one.
|
|
347
|
+
"""
|
|
348
|
+
if value is None or value == "":
|
|
349
|
+
return None
|
|
350
|
+
try:
|
|
351
|
+
return float(value)
|
|
352
|
+
except (TypeError, ValueError):
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@dataclass(frozen=True)
|
|
357
|
+
class VariantSet:
|
|
358
|
+
"""One named variant: a label, and the variant coordinates it selects.
|
|
359
|
+
|
|
360
|
+
This is the unit the Plot Studio's Variants section edits — one row, one
|
|
361
|
+
:class:`VariantSet` — and the unit a scientist writes by hand as
|
|
362
|
+
``scistackplotdb.variant_set("baseline", Variant(X, code_version="v1"))``.
|
|
363
|
+
|
|
364
|
+
``selection`` is keyed by **frame column** (``"Code:bandpass"``,
|
|
365
|
+
``"bandpass.low_hz"``) rather than by a ``scidb.Variant`` object, and that
|
|
366
|
+
is deliberate: a spec has to survive JSON-RPC and the docstring round trip
|
|
367
|
+
(``codegen.extract_spec``), and this package must keep working with no scidb
|
|
368
|
+
installed at all — the CSV source depends on that. Translating a
|
|
369
|
+
``scidb.Variant`` into these keys is scistackplotdb's job, in the layer that
|
|
370
|
+
knows how scidb namespaces branch params.
|
|
371
|
+
|
|
372
|
+
A value may be:
|
|
373
|
+
|
|
374
|
+
* a level (``"20"``) or a list of them (``["20", "50"]`` — "any of these",
|
|
375
|
+
the same subcube rule the popup's checkboxes produce);
|
|
376
|
+
* ``"latest"`` on a code axis, resolved against the data rather than
|
|
377
|
+
hard-coded to an ordinal — see :func:`~scistackplot.variants.resolve_selection`.
|
|
378
|
+
|
|
379
|
+
``name`` may be None, meaning "call me whatever my selection says". The GUI
|
|
380
|
+
keeps it None until the user types over the auto label, so a label stays
|
|
381
|
+
honest while the selection is still being edited.
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
name: str | None = None
|
|
385
|
+
selection: dict[str, Any] = field(default_factory=dict)
|
|
386
|
+
#: Which variable this row draws from. ``None`` means the plot's primary
|
|
387
|
+
#: measure (``PlotSpec.measures[0]``).
|
|
388
|
+
#:
|
|
389
|
+
#: This is what makes "plot Raw against Filtered" the same feature as "plot
|
|
390
|
+
#: v1 against v2": a row is one series, and a series is a variable plus a
|
|
391
|
+
#: region of that variable's variant space. Rows over different variables
|
|
392
|
+
#: stack into the same ``Variant`` factor, so they take a colour or a facet
|
|
393
|
+
#: like any other level, and the export already had the shape for it — one
|
|
394
|
+
#: ``for_each`` input per row (see ``codegen.variant_params``).
|
|
395
|
+
#:
|
|
396
|
+
#: It also removes an ambiguity that would otherwise be a silent wrong
|
|
397
|
+
#: figure. ``resolve_selection`` drops selection keys naming a column the
|
|
398
|
+
#: frame lacks — right for a stale spec, but with two variables in one frame
|
|
399
|
+
#: a row pinning ``Code:filterEMG=v1`` names nothing about ``Force`` and
|
|
400
|
+
#: would claim every ``Force`` row too, drawing it once per variant. Naming
|
|
401
|
+
#: the variable makes "not applicable here" and "stale" distinguishable.
|
|
402
|
+
variable: str | None = None
|
|
403
|
+
|
|
404
|
+
def to_dict(self) -> dict:
|
|
405
|
+
return {
|
|
406
|
+
"name": self.name,
|
|
407
|
+
"selection": dict(self.selection),
|
|
408
|
+
"variable": self.variable,
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
@classmethod
|
|
412
|
+
def from_dict(cls, raw: dict) -> "VariantSet":
|
|
413
|
+
return cls(
|
|
414
|
+
name=raw.get("name"),
|
|
415
|
+
selection=dict(raw.get("selection") or {}),
|
|
416
|
+
variable=raw.get("variable"),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@dataclass(frozen=True)
|
|
421
|
+
class StyleOptions:
|
|
422
|
+
palette: str | None = None
|
|
423
|
+
width: float = 8.0
|
|
424
|
+
height: float = 6.0
|
|
425
|
+
log_x: bool = False
|
|
426
|
+
log_y: bool = False
|
|
427
|
+
title: str | None = None
|
|
428
|
+
x_label: str | None = None
|
|
429
|
+
y_label: str | None = None
|
|
430
|
+
marker_size: float = 36.0
|
|
431
|
+
alpha: float = 0.85
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@dataclass(frozen=True)
|
|
435
|
+
class PlotSpec:
|
|
436
|
+
"""
|
|
437
|
+
A complete, serializable plot description.
|
|
438
|
+
|
|
439
|
+
``measures[0]`` is the y measure. Plotting several variables together is
|
|
440
|
+
:attr:`variant_sets`' job — one row per series — not a longer ``measures``
|
|
441
|
+
list: rows stack into the ``Variant`` factor, and a factor can take a role.
|
|
442
|
+
"""
|
|
443
|
+
|
|
444
|
+
measures: list[str]
|
|
445
|
+
#: Variable supplying the x axis of a relational (x–y) plot, or None.
|
|
446
|
+
#:
|
|
447
|
+
#: A field of its own rather than ``measures[1]``, because it is a different
|
|
448
|
+
#: operation from an overlaid series and the positional form hid that. An x
|
|
449
|
+
#: measure is a **wide join** — one x value per row of y — while overlaid
|
|
450
|
+
#: variables **stack long** into one value column. Conflating them meant
|
|
451
|
+
#: "the second measure" silently meant one or the other depending on
|
|
452
|
+
#: context. When set, no factor may hold ``Role.X``.
|
|
453
|
+
x_measure: str | None = None
|
|
454
|
+
roles: dict[str, Role] = field(default_factory=dict)
|
|
455
|
+
#: Order of the factors sharing the x axis, **outermost first**.
|
|
456
|
+
#:
|
|
457
|
+
#: Membership is the roles dict (who holds ``Role.X``); this is only the
|
|
458
|
+
#: order they nest in, so assigning a role can never produce an invalid
|
|
459
|
+
#: spec — a name here that no longer holds X is ignored, and an X-holder
|
|
460
|
+
#: missing from here is appended. :meth:`ordered_x_layers` is the one place
|
|
461
|
+
#: those two are reconciled.
|
|
462
|
+
x_layers: list[str] = field(default_factory=list)
|
|
463
|
+
kind: PlotKind = PlotKind.SCATTER
|
|
464
|
+
aggregate: Aggregation = field(default_factory=Aggregation)
|
|
465
|
+
index_column: str | None = None
|
|
466
|
+
facet: FacetOptions = field(default_factory=FacetOptions)
|
|
467
|
+
#: What the y axis spans, and what separates spans. See :class:`YAxis`.
|
|
468
|
+
y_axis: YAxis = field(default_factory=YAxis)
|
|
469
|
+
style: StyleOptions = field(default_factory=StyleOptions)
|
|
470
|
+
filters: list[Filter] = field(default_factory=list)
|
|
471
|
+
#: Variables joined in as FACTORS rather than plotted — a subject-level
|
|
472
|
+
#: ``Condition`` holding stim/sham, say. They classify as CATEGORICAL and so
|
|
473
|
+
#: are rightly refused as measures; as factors they take a role like any
|
|
474
|
+
#: other and give you the grouping the data already records.
|
|
475
|
+
factor_variables: list[str] = field(default_factory=list)
|
|
476
|
+
#: Factors derived by bucketing another factor's levels.
|
|
477
|
+
level_groups: list[LevelGroup] = field(default_factory=list)
|
|
478
|
+
#: Named variants to plot — one entry per row of the GUI's Variants section.
|
|
479
|
+
#:
|
|
480
|
+
#: One entry is a pin: the figure shows that variant and nothing else.
|
|
481
|
+
#: Several entries are a **comparison**: a synthetic ``Variant`` factor
|
|
482
|
+
#: appears with one level per entry, and it takes a role like any other
|
|
483
|
+
#: factor (colour, facet, separate figures). That is the difference between
|
|
484
|
+
#: "show me the current results" and "show me v1 against v3", expressed by
|
|
485
|
+
#: adding a row rather than by a different control.
|
|
486
|
+
#:
|
|
487
|
+
#: Empty means no selection at all — every variant in the data, each variant
|
|
488
|
+
#: factor still needing a role of its own (``validate`` refuses to pool
|
|
489
|
+
#: them silently).
|
|
490
|
+
variant_sets: list[VariantSet] = field(default_factory=list)
|
|
491
|
+
|
|
492
|
+
# ---- convenience accessors ------------------------------------------
|
|
493
|
+
|
|
494
|
+
@property
|
|
495
|
+
def y_measure(self) -> str:
|
|
496
|
+
return self.measures[0]
|
|
497
|
+
|
|
498
|
+
def variant_variables(self) -> list[str]:
|
|
499
|
+
"""Every variable this spec plots, primary first, in row order.
|
|
500
|
+
|
|
501
|
+
The union a source has to load and stack. A row without a ``variable``
|
|
502
|
+
draws from the primary measure, so the primary is always present.
|
|
503
|
+
"""
|
|
504
|
+
names = list(self.measures[:1])
|
|
505
|
+
for variant in self.variant_sets:
|
|
506
|
+
if variant.variable and variant.variable not in names:
|
|
507
|
+
names.append(variant.variable)
|
|
508
|
+
return names
|
|
509
|
+
|
|
510
|
+
def ordered_x_layers(self, roles: dict[str, Role] | None = None) -> list[str]:
|
|
511
|
+
"""The factors on x, outermost first.
|
|
512
|
+
|
|
513
|
+
Reconciles two sources that are edited independently — which factors
|
|
514
|
+
hold ``Role.X`` (a dropdown per factor) and what order they nest in (a
|
|
515
|
+
list the user reorders). Names that no longer hold X are dropped, and
|
|
516
|
+
X-holders the order never mentioned are appended in declaration order,
|
|
517
|
+
so neither widget can put the spec in a state the other rejects.
|
|
518
|
+
|
|
519
|
+
``roles`` defaults to the spec's own; pass completed roles when the
|
|
520
|
+
table may have defaulted some.
|
|
521
|
+
"""
|
|
522
|
+
holders = [
|
|
523
|
+
name
|
|
524
|
+
for name, role in (roles if roles is not None else self.roles).items()
|
|
525
|
+
if role is Role.X
|
|
526
|
+
]
|
|
527
|
+
ordered = [name for name in self.x_layers if name in holders]
|
|
528
|
+
ordered.extend(name for name in holders if name not in ordered)
|
|
529
|
+
return ordered
|
|
530
|
+
|
|
531
|
+
def factors_with_role(self, role: Role) -> list[str]:
|
|
532
|
+
"""Factors carrying ``role``, in the spec's declared order."""
|
|
533
|
+
return [name for name, r in self.roles.items() if r == role]
|
|
534
|
+
|
|
535
|
+
def first_with_role(self, role: Role) -> str | None:
|
|
536
|
+
found = self.factors_with_role(role)
|
|
537
|
+
return found[0] if found else None
|
|
538
|
+
|
|
539
|
+
@property
|
|
540
|
+
def iterate_factors(self) -> list[str]:
|
|
541
|
+
return self.factors_with_role(Role.ITERATE)
|
|
542
|
+
|
|
543
|
+
@property
|
|
544
|
+
def replicate_factors(self) -> list[str]:
|
|
545
|
+
"""Factors whose levels survive as multiple rows per cell."""
|
|
546
|
+
return [n for n, r in self.roles.items() if r in REPLICATE_ROLES]
|
|
547
|
+
|
|
548
|
+
def with_roles(self, **roles: Role) -> "PlotSpec":
|
|
549
|
+
"""Return a copy with role assignments merged in (for tests/GUI edits)."""
|
|
550
|
+
merged = dict(self.roles)
|
|
551
|
+
merged.update(roles)
|
|
552
|
+
return replace(self, roles=merged)
|
|
553
|
+
|
|
554
|
+
# ---- serialization ---------------------------------------------------
|
|
555
|
+
|
|
556
|
+
def to_dict(self) -> dict:
|
|
557
|
+
raw = asdict(self)
|
|
558
|
+
raw["roles"] = {k: str(v) for k, v in self.roles.items()}
|
|
559
|
+
raw["kind"] = str(self.kind)
|
|
560
|
+
raw["aggregate"] = {
|
|
561
|
+
"statistic": str(self.aggregate.statistic),
|
|
562
|
+
"error": str(self.aggregate.error),
|
|
563
|
+
}
|
|
564
|
+
raw["facet"] = {
|
|
565
|
+
"n_rows": self.facet.n_rows,
|
|
566
|
+
"n_cols": self.facet.n_cols,
|
|
567
|
+
"share_x": self.facet.share_x,
|
|
568
|
+
"rows": [_matcher_to_dict(m) for m in self.facet.rows],
|
|
569
|
+
"cols": [_matcher_to_dict(m) for m in self.facet.cols],
|
|
570
|
+
}
|
|
571
|
+
raw["y_axis"] = self.y_axis.to_dict()
|
|
572
|
+
raw["variant_sets"] = [s.to_dict() for s in self.variant_sets]
|
|
573
|
+
raw["level_groups"] = [g.to_dict() for g in self.level_groups]
|
|
574
|
+
raw["x_layers"] = list(self.x_layers)
|
|
575
|
+
# TOML has no null; drop empty optionals so a round trip is stable.
|
|
576
|
+
return _drop_nulls(raw)
|
|
577
|
+
|
|
578
|
+
@classmethod
|
|
579
|
+
def from_dict(cls, raw: dict) -> "PlotSpec":
|
|
580
|
+
agg = raw.get("aggregate") or {}
|
|
581
|
+
return cls(
|
|
582
|
+
measures=list(raw["measures"]),
|
|
583
|
+
x_measure=raw.get("x_measure"),
|
|
584
|
+
roles={k: Role(v) for k, v in (raw.get("roles") or {}).items()},
|
|
585
|
+
x_layers=list(raw.get("x_layers") or []),
|
|
586
|
+
kind=PlotKind(raw.get("kind", PlotKind.SCATTER)),
|
|
587
|
+
aggregate=Aggregation(
|
|
588
|
+
statistic=Statistic(agg.get("statistic", Statistic.MEAN)),
|
|
589
|
+
error=ErrorBand(agg.get("error", ErrorBand.SD)),
|
|
590
|
+
),
|
|
591
|
+
index_column=raw.get("index_column"),
|
|
592
|
+
facet=_facet_from_dict(raw.get("facet") or {}),
|
|
593
|
+
y_axis=YAxis.from_dict(raw.get("y_axis") or {}),
|
|
594
|
+
style=StyleOptions(**(raw.get("style") or {})),
|
|
595
|
+
filters=[Filter(**f) for f in (raw.get("filters") or [])],
|
|
596
|
+
factor_variables=list(raw.get("factor_variables") or []),
|
|
597
|
+
level_groups=[
|
|
598
|
+
LevelGroup.from_dict(g) for g in (raw.get("level_groups") or [])
|
|
599
|
+
],
|
|
600
|
+
variant_sets=[
|
|
601
|
+
VariantSet.from_dict(s) for s in (raw.get("variant_sets") or [])
|
|
602
|
+
],
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
def to_json(self, *, indent: int | None = 2) -> str:
|
|
606
|
+
return json.dumps(self.to_dict(), indent=indent, sort_keys=True)
|
|
607
|
+
|
|
608
|
+
@classmethod
|
|
609
|
+
def from_json(cls, text: str) -> "PlotSpec":
|
|
610
|
+
return cls.from_dict(json.loads(text))
|
|
611
|
+
|
|
612
|
+
def to_toml(self) -> str:
|
|
613
|
+
try:
|
|
614
|
+
import tomli_w
|
|
615
|
+
except ImportError as exc: # pragma: no cover - environment dependent
|
|
616
|
+
raise ImportError(
|
|
617
|
+
"Writing a PlotSpec as TOML needs 'tomli-w' "
|
|
618
|
+
"(pip install scistackplot[dev]). PlotSpec.to_json() is "
|
|
619
|
+
"always available."
|
|
620
|
+
) from exc
|
|
621
|
+
return tomli_w.dumps(self.to_dict())
|
|
622
|
+
|
|
623
|
+
@classmethod
|
|
624
|
+
def from_toml(cls, text: str) -> "PlotSpec":
|
|
625
|
+
try:
|
|
626
|
+
import tomllib
|
|
627
|
+
except ImportError: # pragma: no cover - Python 3.10
|
|
628
|
+
try:
|
|
629
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
630
|
+
except ImportError as exc:
|
|
631
|
+
raise ImportError(
|
|
632
|
+
"Reading a PlotSpec from TOML needs Python 3.11+ "
|
|
633
|
+
"(tomllib) or the 'tomli' package."
|
|
634
|
+
) from exc
|
|
635
|
+
return cls.from_dict(tomllib.loads(text))
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def grid_shape_for(
|
|
639
|
+
n_panels: int, n_rows: int | None = None, n_cols: int | None = None
|
|
640
|
+
) -> tuple[int, int]:
|
|
641
|
+
"""
|
|
642
|
+
The subplot grid for ``n_panels`` panels, given whatever the user pinned.
|
|
643
|
+
|
|
644
|
+
One function, called from ``reduce`` and reported back to the GUI, so that
|
|
645
|
+
"I said 2 columns, where did 3 rows come from?" has exactly one answer.
|
|
646
|
+
Naming one dimension computes the other; naming neither falls back to a
|
|
647
|
+
roughly square grid at most 4 wide, and a handful of panels stay in a single
|
|
648
|
+
horizontal row.
|
|
649
|
+
|
|
650
|
+
Placement may still grow the result (see ``reduce._assign_grid``) rather than
|
|
651
|
+
let two panels share a cell.
|
|
652
|
+
"""
|
|
653
|
+
count = max(1, int(n_panels))
|
|
654
|
+
rows = int(n_rows) if n_rows and n_rows > 0 else None
|
|
655
|
+
cols = int(n_cols) if n_cols and n_cols > 0 else None
|
|
656
|
+
|
|
657
|
+
if rows and cols:
|
|
658
|
+
return rows, cols
|
|
659
|
+
if cols:
|
|
660
|
+
return math.ceil(count / cols), cols
|
|
661
|
+
if rows:
|
|
662
|
+
return rows, math.ceil(count / rows)
|
|
663
|
+
|
|
664
|
+
# Neither pinned: a 13-field struct wants a grid, 3 muscles want one row.
|
|
665
|
+
auto_cols = min(4, math.ceil(math.sqrt(count))) if count > 3 else count
|
|
666
|
+
auto_cols = max(1, auto_cols)
|
|
667
|
+
return math.ceil(count / auto_cols), auto_cols
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _matcher_to_dict(matcher: Matcher) -> dict:
|
|
671
|
+
return {"op": str(matcher.op), "value": matcher.value, "label": matcher.label}
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _facet_from_dict(raw: dict) -> FacetOptions:
|
|
675
|
+
return FacetOptions(
|
|
676
|
+
n_rows=raw.get("n_rows"),
|
|
677
|
+
n_cols=raw.get("n_cols"),
|
|
678
|
+
rows=[_matcher_from_dict(m) for m in (raw.get("rows") or [])],
|
|
679
|
+
cols=[_matcher_from_dict(m) for m in (raw.get("cols") or [])],
|
|
680
|
+
share_x=raw.get("share_x", True),
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _matcher_from_dict(raw: dict) -> Matcher:
|
|
685
|
+
return Matcher(
|
|
686
|
+
op=MatchOp(raw.get("op", MatchOp.CONTAINS)),
|
|
687
|
+
value=raw.get("value", ""),
|
|
688
|
+
label=raw.get("label"),
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _drop_nulls(obj: Any) -> Any:
|
|
693
|
+
"""Recursively drop None values so JSON and TOML round-trip identically."""
|
|
694
|
+
if isinstance(obj, dict):
|
|
695
|
+
return {k: _drop_nulls(v) for k, v in obj.items() if v is not None}
|
|
696
|
+
if isinstance(obj, list):
|
|
697
|
+
return [_drop_nulls(v) for v in obj]
|
|
698
|
+
return obj
|