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/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