scistackplot 0.1.36__tar.gz → 0.1.38__tar.gz
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-0.1.36 → scistackplot-0.1.38}/PKG-INFO +2 -2
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/aliases.py +21 -2
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/capability.py +22 -8
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/codegen.py +14 -1
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/reduce.py +1 -1
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/render/mpl.py +146 -22
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/render/plotly_.py +32 -10
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/spec.py +29 -1
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/variants.py +279 -18
- {scistackplot-0.1.36 → scistackplot-0.1.38}/.gitignore +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/README.md +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/pyproject.toml +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/__init__.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/autosize.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/cell.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/colors.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/compare.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/custom.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/dedup.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/diffbars.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/export.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/figsize.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/figure_file.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/framesize.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/groups.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/numeric.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/panels.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/paper.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/presets.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/reducer.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/render/__init__.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/render/base.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/resolved.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/restore.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/roles.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/series_stats.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/shape.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/sources/__init__.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/sources/base.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/sources/csv.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/sources/frame.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/spaghetti.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/table.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/textsize.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/ticklabels.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/weights.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/xaxis.py +0 -0
- {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/ylimits.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: scistackplot
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.38
|
|
4
4
|
Summary: Spec-driven plotting for long-format scientific data — standalone, GUI-friendly
|
|
5
5
|
Author: SciStack Contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -19,7 +19,7 @@ Classifier: Typing :: Typed
|
|
|
19
19
|
Requires-Python: >=3.10
|
|
20
20
|
Requires-Dist: numpy>=1.21
|
|
21
21
|
Requires-Dist: pandas>=1.5
|
|
22
|
-
Requires-Dist: scistacklog==0.1.
|
|
22
|
+
Requires-Dist: scistacklog==0.1.38
|
|
23
23
|
Provides-Extra: dev
|
|
24
24
|
Requires-Dist: matplotlib>=3.6; extra == 'dev'
|
|
25
25
|
Requires-Dist: plotly>=5.0; extra == 'dev'
|
|
@@ -156,11 +156,21 @@ class DisplayText:
|
|
|
156
156
|
"""A panel's facet values, the way ``Panel.title`` joins them."""
|
|
157
157
|
return " · ".join(self.level(factor, value) for factor, value in key.items())
|
|
158
158
|
|
|
159
|
-
def figure_title(self, key: dict[str, Any]) -> str:
|
|
160
|
-
"""An ITERATE figure's ``name=value`` title.
|
|
159
|
+
def figure_title(self, key: dict[str, Any], table: "LongTable | None" = None) -> str:
|
|
160
|
+
"""An ITERATE figure's ``name=value`` title.
|
|
161
|
+
|
|
162
|
+
**A variant factor with ONE level is left out** (user, 2026-10-15):
|
|
163
|
+
one series needs no name in the title, and the legend/Variants row
|
|
164
|
+
already says which variant it is — ``Variant=CosmedData · load_cbrace
|
|
165
|
+
v2`` over a figure that could not be anything else is noise. Only the
|
|
166
|
+
TITLE: ``figure_key`` keeps the entry, because save-all file names and
|
|
167
|
+
the endpoint's ``for_each`` keys are built from it. A single-level
|
|
168
|
+
schema key (``subject=03``) stays — it says which figure this is.
|
|
169
|
+
"""
|
|
161
170
|
return ", ".join(
|
|
162
171
|
f"{self.name(factor, factor)}={self.level(factor, value)}"
|
|
163
172
|
for factor, value in key.items()
|
|
173
|
+
if not _lone_variant(table, factor)
|
|
164
174
|
)
|
|
165
175
|
|
|
166
176
|
def x_plan(self, plan):
|
|
@@ -208,6 +218,15 @@ def _raw(value: Any) -> str:
|
|
|
208
218
|
# ---------------------------------------------------------------------------
|
|
209
219
|
|
|
210
220
|
|
|
221
|
+
def _lone_variant(table: LongTable | None, factor: str) -> bool:
|
|
222
|
+
"""A variant factor (``Variant``, ``Code:*``) holding a single level —
|
|
223
|
+
the rule :meth:`DisplayText.figure_title` leaves out of a title."""
|
|
224
|
+
if table is None or not table.has_factor(factor):
|
|
225
|
+
return False
|
|
226
|
+
info = table.factor(factor)
|
|
227
|
+
return bool(info.is_variant) and len(info.levels) == 1
|
|
228
|
+
|
|
229
|
+
|
|
211
230
|
def display_text(spec: PlotSpec, table: LongTable) -> DisplayText:
|
|
212
231
|
"""The plot's aliases over the project's, for ``table``'s factors.
|
|
213
232
|
|
|
@@ -717,8 +717,10 @@ def variant_summary(spec: PlotSpec, table: LongTable) -> dict:
|
|
|
717
717
|
import pandas as pd
|
|
718
718
|
|
|
719
719
|
from .variants import (
|
|
720
|
+
VariantSet,
|
|
720
721
|
auto_label,
|
|
721
722
|
defined_sets,
|
|
723
|
+
effective_selection,
|
|
722
724
|
is_stated,
|
|
723
725
|
label_variable,
|
|
724
726
|
resolve_selection,
|
|
@@ -731,9 +733,14 @@ def variant_summary(spec: PlotSpec, table: LongTable) -> dict:
|
|
|
731
733
|
names = [f.name for f in table.variant_factors if f.name in frame.columns]
|
|
732
734
|
|
|
733
735
|
sets = []
|
|
734
|
-
# With nothing selected — no rows, or none filled in yet —
|
|
735
|
-
#
|
|
736
|
-
|
|
736
|
+
# With nothing selected — no rows, or none filled in yet — the figure draws
|
|
737
|
+
# what an EMPTY row selects (the source default: current code), so that is
|
|
738
|
+
# what is "on screen" here too. Measured by the same owner, `row_mask`.
|
|
739
|
+
kept_mask = (
|
|
740
|
+
row_mask(table, spec, VariantSet())
|
|
741
|
+
if not defined_sets(spec.variant_sets)
|
|
742
|
+
else pd.Series(False, index=frame.index)
|
|
743
|
+
)
|
|
737
744
|
claimed = pd.Series(False, index=frame.index)
|
|
738
745
|
for index, variant in enumerate(spec.variant_sets):
|
|
739
746
|
# "Says something", which row 0 always does — it is the figure's
|
|
@@ -743,9 +750,7 @@ def variant_summary(spec: PlotSpec, table: LongTable) -> dict:
|
|
|
743
750
|
# and folds to nothing, because one series needs no factor.
|
|
744
751
|
defined = is_stated(variant, index)
|
|
745
752
|
if defined:
|
|
746
|
-
mask = row_mask(
|
|
747
|
-
frame, spec, variant, latest_column=table.latest_column
|
|
748
|
-
)
|
|
753
|
+
mask = row_mask(table, spec, variant)
|
|
749
754
|
# First-match-wins, mirroring apply_variant_sets: the count shown
|
|
750
755
|
# must be the number of rows this variant contributes to the figure,
|
|
751
756
|
# not the number it would match on its own.
|
|
@@ -772,8 +777,13 @@ def variant_summary(spec: PlotSpec, table: LongTable) -> dict:
|
|
|
772
777
|
index=index,
|
|
773
778
|
variable=label_variable(variant, index, spec.y_measure),
|
|
774
779
|
latest_column=table.latest_column,
|
|
780
|
+
frozen=None if variant.follow_latest else variant.frozen,
|
|
775
781
|
),
|
|
776
782
|
"explicit_name": variant.name,
|
|
783
|
+
# A reopened saved plot's versions, and whether the row follows
|
|
784
|
+
# latest instead (docs/claude/variant-row-selection.md).
|
|
785
|
+
"frozen": {c: list(v) for c, v in variant.frozen.items()},
|
|
786
|
+
"follow_latest": variant.follow_latest,
|
|
777
787
|
"selection": dict(variant.selection),
|
|
778
788
|
# None means the primary measure; the GUI shows that as the
|
|
779
789
|
# dropdown's default rather than inventing a name for it.
|
|
@@ -784,7 +794,9 @@ def variant_summary(spec: PlotSpec, table: LongTable) -> dict:
|
|
|
784
794
|
# per row because that is where the fix is (pin a version, or
|
|
785
795
|
# split the row), and because the column itself is no longer
|
|
786
796
|
# offered as a factor.
|
|
787
|
-
"spans": spanned_code_axes(
|
|
797
|
+
"spans": spanned_code_axes(
|
|
798
|
+
frame[fresh], effective_selection(variant, table), table
|
|
799
|
+
)
|
|
788
800
|
if defined
|
|
789
801
|
else {},
|
|
790
802
|
# What this selection actually resolved to, and — when it
|
|
@@ -803,7 +815,9 @@ def variant_summary(spec: PlotSpec, table: LongTable) -> dict:
|
|
|
803
815
|
# I get" is nothing.
|
|
804
816
|
"resolved": _jsonable_selection(
|
|
805
817
|
resolve_selection(
|
|
806
|
-
frame,
|
|
818
|
+
frame,
|
|
819
|
+
effective_selection(variant, table),
|
|
820
|
+
latest_column=table.latest_column,
|
|
807
821
|
)
|
|
808
822
|
)
|
|
809
823
|
if defined
|
|
@@ -2855,7 +2855,20 @@ def _script_inputs(spec: PlotSpec) -> tuple[str, list[str]]:
|
|
|
2855
2855
|
)
|
|
2856
2856
|
else:
|
|
2857
2857
|
lines.append(f"{param} = df")
|
|
2858
|
-
|
|
2858
|
+
selection = dict(variant.selection)
|
|
2859
|
+
if variant.frozen and not variant.follow_latest:
|
|
2860
|
+
# A reopened saved plot's versions (variants.frozen_mask). The
|
|
2861
|
+
# frozen columns replace the row's own keys for them; several
|
|
2862
|
+
# ordinals export as "any of" — the per-location "highest of"
|
|
2863
|
+
# needs provenance a flat table does not carry.
|
|
2864
|
+
selection.update(
|
|
2865
|
+
{
|
|
2866
|
+
column: levels[0] if len(levels) == 1 else list(levels)
|
|
2867
|
+
for column, levels in variant.frozen.items()
|
|
2868
|
+
if levels
|
|
2869
|
+
}
|
|
2870
|
+
)
|
|
2871
|
+
for column, value in selection.items():
|
|
2859
2872
|
if isinstance(value, str) and value == LATEST:
|
|
2860
2873
|
lines.append(
|
|
2861
2874
|
f"# NOTE: {column!r} asked for the latest version, which "
|
|
@@ -2776,7 +2776,7 @@ def _labels_for(
|
|
|
2776
2776
|
|
|
2777
2777
|
title = style.title
|
|
2778
2778
|
if title is None and figure_key:
|
|
2779
|
-
title = text.figure_title(figure_key)
|
|
2779
|
+
title = text.figure_title(figure_key, table)
|
|
2780
2780
|
|
|
2781
2781
|
def named(factor: str) -> str:
|
|
2782
2782
|
return text.name(factor, table.factor(factor).display)
|
|
@@ -9,6 +9,9 @@ expected to reproduce.
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from contextlib import contextmanager
|
|
12
15
|
from dataclasses import dataclass, field, replace
|
|
13
16
|
from typing import Any
|
|
14
17
|
|
|
@@ -72,9 +75,48 @@ from .base import (
|
|
|
72
75
|
|
|
73
76
|
LAYER = "scistackplot"
|
|
74
77
|
|
|
78
|
+
#: One matplotlib figure at a time, process-wide. The GUI answers every RPC
|
|
79
|
+
#: on its own thread, and a render sets text sizes through ``rc_context`` —
|
|
80
|
+
#: GLOBAL rcParams, saved on entry and restored on exit — so two renders
|
|
81
|
+
#: overlapping (a pane resize while the previous resolve still lays out;
|
|
82
|
+
#: scidb.log 2026-10-15 12:23:50, three at once) read each other's sizes and
|
|
83
|
+
#: the last to exit can leave the wrong ones behind. Re-entrant: a layout
|
|
84
|
+
#: decision settles the auto text size, whose trials render.
|
|
85
|
+
_MPL_LOCK = threading.RLock()
|
|
86
|
+
|
|
87
|
+
#: A wait for the lock longer than this is logged (INFO): a slow figure is
|
|
88
|
+
#: then told apart from a figure queued behind another one.
|
|
89
|
+
_LOCK_WAIT_LOG_S = 0.25
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@contextmanager
|
|
93
|
+
def _one_figure_at_a_time(what: str):
|
|
94
|
+
t0 = time.perf_counter()
|
|
95
|
+
with _MPL_LOCK:
|
|
96
|
+
waited = time.perf_counter() - t0
|
|
97
|
+
if waited > _LOCK_WAIT_LOG_S:
|
|
98
|
+
Log.info(
|
|
99
|
+
"%s waited %.2fs for another figure's matplotlib layout to finish "
|
|
100
|
+
"(matplotlib lays out one figure at a time)",
|
|
101
|
+
what,
|
|
102
|
+
waited,
|
|
103
|
+
layer=LAYER,
|
|
104
|
+
)
|
|
105
|
+
yield
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _artist_count(fig) -> int:
|
|
109
|
+
"""Every artist a layout pass walks: each axes' children, the figure's own."""
|
|
110
|
+
return len(fig.get_children()) + sum(len(ax.get_children()) for ax in fig.axes)
|
|
111
|
+
|
|
75
112
|
|
|
76
113
|
def render(resolved: ResolvedPlot):
|
|
77
114
|
"""Draw ``resolved`` and return the Figure (caller owns closing it)."""
|
|
115
|
+
with _one_figure_at_a_time(f"render_mpl({resolved.kind})"):
|
|
116
|
+
return _render(resolved)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _render(resolved: ResolvedPlot):
|
|
78
120
|
import matplotlib
|
|
79
121
|
|
|
80
122
|
if matplotlib.get_backend().lower() not in ("agg", "template"):
|
|
@@ -88,7 +130,18 @@ def render(resolved: ResolvedPlot):
|
|
|
88
130
|
# An auto text size is chosen before anything is drawn (autosize owns
|
|
89
131
|
# it; a trial layout arrives here already settled, so this never recurses).
|
|
90
132
|
resolved = settle(resolved)
|
|
91
|
-
with Log.timer("render_mpl", layer=LAYER, extra=str(resolved.kind)):
|
|
133
|
+
with Log.timer("render_mpl", layer=LAYER, extra=str(resolved.kind)) as timer:
|
|
134
|
+
# Consecutive laps rather than nested phases: the body is one long
|
|
135
|
+
# sequence, and which pass of it is slow is the question a slow
|
|
136
|
+
# figure asks (scidb.log 2026-10-15: 6.6 s with no breakdown).
|
|
137
|
+
mark = time.perf_counter()
|
|
138
|
+
|
|
139
|
+
def lap(name: str) -> None:
|
|
140
|
+
nonlocal mark
|
|
141
|
+
now = time.perf_counter()
|
|
142
|
+
timer.phases.append((name, now - mark))
|
|
143
|
+
mark = now
|
|
144
|
+
|
|
92
145
|
style = resolved.spec.style
|
|
93
146
|
# rc_context, not rcParams: text reads the size at CREATION, so this
|
|
94
147
|
# has to be open while every label, tick and legend entry is made —
|
|
@@ -129,7 +182,12 @@ def render(resolved: ResolvedPlot):
|
|
|
129
182
|
# panel was drawn last.
|
|
130
183
|
sharey=shares_y_axis(resolved),
|
|
131
184
|
)
|
|
185
|
+
lap("subplots")
|
|
132
186
|
|
|
187
|
+
# Marks and overlay alternate per panel: summed apart, so the
|
|
188
|
+
# overlay's share is its own phase.
|
|
189
|
+
marks_s = sample_s = 0.0
|
|
190
|
+
sample_runs_drawn = sample_artists = 0
|
|
133
191
|
used: set[tuple[int, int]] = set()
|
|
134
192
|
# (row, col) -> panel, so the cosmetics pass can ask a CELL for its
|
|
135
193
|
# panel's limits. It walks the grid rather than the panel list (blank
|
|
@@ -160,16 +218,25 @@ def render(resolved: ResolvedPlot):
|
|
|
160
218
|
used.add((row, col))
|
|
161
219
|
at_cell[(row, col)] = panel
|
|
162
220
|
panel_axes[index] = ax
|
|
221
|
+
t0 = time.perf_counter()
|
|
163
222
|
if resolved.custom is not None:
|
|
164
223
|
# The user's plot_ function draws this panel's marks; the
|
|
165
224
|
# layout around them is this renderer's (custom.py).
|
|
166
225
|
resolved.custom.draw_panel(ax, panel, resolved)
|
|
167
226
|
else:
|
|
168
227
|
_draw_panel(ax, panel.frame, resolved)
|
|
169
|
-
|
|
228
|
+
t1 = time.perf_counter()
|
|
229
|
+
runs, drawn = _draw_sample(ax, panel, resolved)
|
|
230
|
+
sample_runs_drawn += runs
|
|
231
|
+
sample_artists += drawn
|
|
232
|
+
marks_s += t1 - t0
|
|
233
|
+
sample_s += time.perf_counter() - t1
|
|
170
234
|
# No subplot caption: a faceted panel is named by its y-axis title
|
|
171
235
|
# instead (base.panel_y_title), which buys back the row of vertical
|
|
172
236
|
# space a title costs in every row of the grid.
|
|
237
|
+
timer.phases.append(("marks", marks_s))
|
|
238
|
+
timer.phases.append(("sample_overlay", sample_s))
|
|
239
|
+
mark = time.perf_counter()
|
|
173
240
|
|
|
174
241
|
# Blank out grid cells no panel landed in (a wrapped grid's remainder).
|
|
175
242
|
for row in range(n_rows):
|
|
@@ -178,6 +245,21 @@ def render(resolved: ResolvedPlot):
|
|
|
178
245
|
axes[row][col].set_visible(False)
|
|
179
246
|
|
|
180
247
|
_apply_axes_cosmetics(fig, axes, resolved, n_rows, n_cols, at_cell)
|
|
248
|
+
lap("cosmetics")
|
|
249
|
+
# What every layout pass below walks. Thousands here is the cause
|
|
250
|
+
# of a slow figure, whatever the row count says (the overlay drew
|
|
251
|
+
# one artist per run until 2026-10-15).
|
|
252
|
+
Log.info(
|
|
253
|
+
"drew %d artist(s) over %d panel(s)%s",
|
|
254
|
+
_artist_count(fig),
|
|
255
|
+
len(resolved.panels),
|
|
256
|
+
(
|
|
257
|
+
f"; sample overlay {sample_runs_drawn} run(s) as {sample_artists} artist(s)"
|
|
258
|
+
if sample_runs_drawn
|
|
259
|
+
else ""
|
|
260
|
+
),
|
|
261
|
+
layer=LAYER,
|
|
262
|
+
)
|
|
181
263
|
|
|
182
264
|
if resolved.labels.title:
|
|
183
265
|
fig.suptitle(resolved.labels.title)
|
|
@@ -204,13 +286,16 @@ def render(resolved: ResolvedPlot):
|
|
|
204
286
|
rect = _arrange(fig, labelled, resolved, legend)
|
|
205
287
|
fig.tight_layout(rect=rect)
|
|
206
288
|
setattr(fig, LEGEND_ATTR, legend.describe() if legend is not None else None)
|
|
289
|
+
lap("legend_and_layout")
|
|
207
290
|
# After a layout pass: the room each label has is the laid-out
|
|
208
291
|
# panel width (spec/images/graph1.png, graph2.png).
|
|
209
292
|
_fit_x_labels(fig, [ax for _, _, ax in labelled], resolved, rect)
|
|
293
|
+
lap("x_labels")
|
|
210
294
|
# Difference bars need the laid-out panel heights (their sizes are
|
|
211
295
|
# points), so they come after every other layout pass, and before
|
|
212
296
|
# the reach is measured (a raised top can widen the tick numbers).
|
|
213
297
|
_draw_difference_bars(fig, panel_axes, resolved, rect)
|
|
298
|
+
lap("difference_bars")
|
|
214
299
|
# Last, on the final layout: what the preview sizes its margins
|
|
215
300
|
# and cell gaps from. Hidden y titles are named next to it, so a
|
|
216
301
|
# gap that did not shrink can be explained from scidb.log (the
|
|
@@ -223,7 +308,9 @@ def render(resolved: ResolvedPlot):
|
|
|
223
308
|
if hidden:
|
|
224
309
|
Log.debug("y titles hidden: %s", "; ".join(hidden), layer=LAYER)
|
|
225
310
|
setattr(fig, GRID_REACH_ATTR, _grid_reach(fig, axes, n_rows, n_cols))
|
|
311
|
+
lap("grid_reach")
|
|
226
312
|
setattr(fig, AXIS_TEXT_ATTR, _axis_text(fig, axes, n_rows, n_cols, resolved))
|
|
313
|
+
lap("axis_text")
|
|
227
314
|
return fig
|
|
228
315
|
|
|
229
316
|
|
|
@@ -491,7 +578,7 @@ def _draw_distribution(ax, frame, resolved, *, violin: bool) -> None:
|
|
|
491
578
|
ax.set_xticklabels([resolved.text.x_tick(v) for v in order])
|
|
492
579
|
|
|
493
580
|
|
|
494
|
-
def _draw_sample(ax, panel, resolved: ResolvedPlot) ->
|
|
581
|
+
def _draw_sample(ax, panel, resolved: ResolvedPlot) -> tuple[int, int]:
|
|
495
582
|
"""The "Show sample" overlay: one point per row of ``panel.sample`` inside
|
|
496
583
|
the mark it belongs to, joined into a line per identity when
|
|
497
584
|
``ResolvedPlot.sample_join`` says so.
|
|
@@ -503,18 +590,36 @@ def _draw_sample(ax, panel, resolved: ResolvedPlot) -> None:
|
|
|
503
590
|
(``ResolvedPlot.sample_color``, the legend lists the levels —
|
|
504
591
|
``_sample_legend_handles``), or each point its mark's, with a line that
|
|
505
592
|
crosses mark colours drawn neutral under points painted one by one.
|
|
506
|
-
Drawn after the marks, on top.
|
|
593
|
+
Drawn after the marks, on top. Joined runs are one line each; lone
|
|
594
|
+
points are one scatter per colour per panel. Returns ``(runs, artists)``
|
|
595
|
+
for the render's log line.
|
|
507
596
|
"""
|
|
508
597
|
sample = getattr(panel, "sample", None)
|
|
509
598
|
if sample is None or sample.empty or sample_dropped_reason(panel, resolved):
|
|
510
|
-
return
|
|
599
|
+
return 0, 0
|
|
511
600
|
weight = sample_weight(resolved.spec.style)
|
|
512
|
-
|
|
601
|
+
runs = sample_runs(sample, resolved)
|
|
602
|
+
# Lone points (a points-only overlay, or a one-point run of a joined
|
|
603
|
+
# one) gathered per colour and drawn as ONE scatter each, after the
|
|
604
|
+
# lines: one artist per run made a figure whose innermost tick is a
|
|
605
|
+
# between-subject factor (group) draw ~1400 scatters, and every layout
|
|
606
|
+
# pass walks every artist — 6.6 s per render, 46 s with the auto text
|
|
607
|
+
# size's trials (scidb.log 2026-10-15 12:21). Same points, same paint.
|
|
608
|
+
lone: dict[str, tuple[list[float], list[float]]] = {}
|
|
609
|
+
artists = 0
|
|
610
|
+
for run in runs:
|
|
513
611
|
positions = sample_positions(run.rows, resolved, run.identity)
|
|
514
612
|
order = np.argsort(positions, kind="stable")
|
|
515
613
|
values = run.rows[resolved.encoding.y].to_numpy(dtype=float)[order]
|
|
516
614
|
joined = resolved.sample_join and len(run.rows) > 1
|
|
517
|
-
if joined
|
|
615
|
+
if not joined:
|
|
616
|
+
for x, y, i in zip(positions[order], values, order):
|
|
617
|
+
xs, ys = lone.setdefault(run.point_colors[i], ([], []))
|
|
618
|
+
xs.append(float(x))
|
|
619
|
+
ys.append(float(y))
|
|
620
|
+
continue
|
|
621
|
+
artists += 2 if not run.uniform else 1
|
|
622
|
+
if run.uniform:
|
|
518
623
|
ax.plot(
|
|
519
624
|
positions[order],
|
|
520
625
|
values,
|
|
@@ -528,18 +633,17 @@ def _draw_sample(ax, panel, resolved: ResolvedPlot) -> None:
|
|
|
528
633
|
zorder=3,
|
|
529
634
|
)
|
|
530
635
|
continue
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
)
|
|
636
|
+
# The points sit on marks of different colours: the line (the
|
|
637
|
+
# run, zorder 3) in the neutral colour, the points on top of it
|
|
638
|
+
# each in its own mark's colour (gid: part of the line's run).
|
|
639
|
+
ax.plot(
|
|
640
|
+
positions[order],
|
|
641
|
+
values,
|
|
642
|
+
color=run.line_color,
|
|
643
|
+
alpha=SAMPLE_ALPHA,
|
|
644
|
+
linewidth=weight.line_pt,
|
|
645
|
+
zorder=3,
|
|
646
|
+
)
|
|
543
647
|
ax.scatter(
|
|
544
648
|
positions[order],
|
|
545
649
|
values,
|
|
@@ -548,10 +652,23 @@ def _draw_sample(ax, panel, resolved: ResolvedPlot) -> None:
|
|
|
548
652
|
alpha=SAMPLE_ALPHA,
|
|
549
653
|
edgecolors=SAMPLE_EDGE_COLOR,
|
|
550
654
|
linewidths=0.5,
|
|
551
|
-
zorder=3.1
|
|
552
|
-
gid=SAMPLE_LINE_POINTS_GID
|
|
655
|
+
zorder=3.1,
|
|
656
|
+
gid=SAMPLE_LINE_POINTS_GID,
|
|
553
657
|
)
|
|
658
|
+
for color, (xs, ys) in lone.items():
|
|
659
|
+
ax.scatter(
|
|
660
|
+
xs,
|
|
661
|
+
ys,
|
|
662
|
+
s=weight.marker_area,
|
|
663
|
+
color=color,
|
|
664
|
+
alpha=SAMPLE_ALPHA,
|
|
665
|
+
edgecolors=SAMPLE_EDGE_COLOR,
|
|
666
|
+
linewidths=0.5,
|
|
667
|
+
zorder=3,
|
|
668
|
+
)
|
|
669
|
+
artists += len(lone)
|
|
554
670
|
_sample_legend_handles(ax, resolved)
|
|
671
|
+
return len(runs), artists
|
|
555
672
|
|
|
556
673
|
|
|
557
674
|
def _sample_legend_handles(ax, resolved: ResolvedPlot) -> None:
|
|
@@ -1791,7 +1908,9 @@ def layout_decisions(
|
|
|
1791
1908
|
style = resolved.spec.style
|
|
1792
1909
|
width = float(width_in or style.width)
|
|
1793
1910
|
height = float(height_in or style.height)
|
|
1794
|
-
with
|
|
1911
|
+
with _one_figure_at_a_time("layout_decisions"), Log.timer(
|
|
1912
|
+
"layout_decisions", layer=LAYER, extra=f"{width:.2f}x{height:.2f}in"
|
|
1913
|
+
):
|
|
1795
1914
|
# Auto text is chosen at the size being decided (the pane's, in Fit
|
|
1796
1915
|
# pane), keyed on the caller's object so the preview's later
|
|
1797
1916
|
# render_plotly and a Save of the same resolve reuse it.
|
|
@@ -1830,6 +1949,11 @@ def layout_report(resolved: ResolvedPlot):
|
|
|
1830
1949
|
measurements the auto text size judges a reference by. Renders, reads
|
|
1831
1950
|
what the export already records on the figure, measures the drawn tick
|
|
1832
1951
|
labels and y titles, closes it."""
|
|
1952
|
+
with _one_figure_at_a_time("layout_report"):
|
|
1953
|
+
return _layout_report(resolved)
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
def _layout_report(resolved: ResolvedPlot):
|
|
1833
1957
|
import matplotlib.pyplot as plt
|
|
1834
1958
|
|
|
1835
1959
|
from ..autosize import LayoutReport
|
|
@@ -698,7 +698,14 @@ def _sample_traces(
|
|
|
698
698
|
listed = overlay_in_legend(resolved.spec, resolved.sample_color)
|
|
699
699
|
weight = sample_weight(resolved.spec.style)
|
|
700
700
|
traces: list[dict] = []
|
|
701
|
-
|
|
701
|
+
# Lone points (not joined: a points-only overlay, or a one-point run)
|
|
702
|
+
# of one legend group and colour share ONE markers trace — one trace per
|
|
703
|
+
# run was ~1400 traces when the innermost tick is a between-subject
|
|
704
|
+
# factor (scidb.log 2026-10-15), each one an SVG group in the browser.
|
|
705
|
+
# Same points, same legend group (hiding a level still hides them).
|
|
706
|
+
lone: dict[tuple[str, str], dict] = {}
|
|
707
|
+
runs = sample_runs(sample, resolved)
|
|
708
|
+
for run in runs:
|
|
702
709
|
level, rows, identity = run.level, run.rows, run.identity
|
|
703
710
|
# The overlay's own colour key when it has one, else the mark's
|
|
704
711
|
# colour — none when the run crosses the marks' colours.
|
|
@@ -714,22 +721,36 @@ def _sample_traces(
|
|
|
714
721
|
levels = [
|
|
715
722
|
str(v).replace(LEAF_SEPARATOR, " · ") for v in rows[resolved.encoding.x].to_numpy()
|
|
716
723
|
]
|
|
724
|
+
xs = [None if np.isnan(v) else float(v) for v in positions[order]]
|
|
725
|
+
ys = _values(rows[resolved.encoding.y].iloc[order])
|
|
726
|
+
custom = [f"{levels[i]}<br>{hover[i]}" if hover[i] else levels[i] for i in order]
|
|
727
|
+
name = label if own_color else (str(identity) if identity is not None else label)
|
|
728
|
+
joined_run = resolved.sample_join and len(rows) > 1
|
|
729
|
+
batch = lone.get((legend_group, run.line_color)) if not joined_run and run.uniform else None
|
|
730
|
+
if batch is not None:
|
|
731
|
+
batch["x"].extend(xs)
|
|
732
|
+
batch["y"].extend(ys)
|
|
733
|
+
batch["customdata"].extend(custom)
|
|
734
|
+
if batch["name"] != name:
|
|
735
|
+
# Several identities in one trace: name the trace by its level.
|
|
736
|
+
batch["name"] = label
|
|
737
|
+
continue
|
|
717
738
|
show_legend = listed and legend_on and legend_group not in seen
|
|
718
739
|
if show_legend:
|
|
719
740
|
seen.add(legend_group)
|
|
720
741
|
traces.append(
|
|
721
742
|
{
|
|
722
743
|
"type": "scatter",
|
|
723
|
-
"mode": "lines+markers" if
|
|
724
|
-
"name":
|
|
744
|
+
"mode": "lines+markers" if joined_run else "markers",
|
|
745
|
+
"name": name,
|
|
725
746
|
"legendgroup": legend_group,
|
|
726
747
|
"showlegend": show_legend,
|
|
727
748
|
# After every mark entry whatever panel first drew it.
|
|
728
749
|
"legendrank": 2000,
|
|
729
750
|
"xaxis": x_axis,
|
|
730
751
|
"yaxis": y_axis,
|
|
731
|
-
"x":
|
|
732
|
-
"y":
|
|
752
|
+
"x": xs,
|
|
753
|
+
"y": ys,
|
|
733
754
|
"marker": {
|
|
734
755
|
# One colour, or each point its own mark's when the
|
|
735
756
|
# run crosses the marks' colours (base.sample_runs).
|
|
@@ -742,23 +763,24 @@ def _sample_traces(
|
|
|
742
763
|
"line": {"color": run.line_color, "width": weight.line_pt},
|
|
743
764
|
"opacity": SAMPLE_ALPHA,
|
|
744
765
|
# Hover-only (see `_level_hover` on why not `text`).
|
|
745
|
-
"customdata":
|
|
746
|
-
f"{levels[i]}<br>{hover[i]}" if hover[i] else levels[i] for i in order
|
|
747
|
-
],
|
|
766
|
+
"customdata": custom,
|
|
748
767
|
"hovertemplate": "%{customdata}<br>%{y}<extra>" + label + "</extra>",
|
|
749
768
|
}
|
|
750
769
|
)
|
|
770
|
+
if not joined_run and run.uniform:
|
|
771
|
+
lone[(legend_group, run.line_color)] = traces[-1]
|
|
751
772
|
# The one line that tells "no lines" apart: joined runs vs one-point runs
|
|
752
773
|
# (a run is one identity inside one bracket — base.sample_runs — never
|
|
753
774
|
# split by the marks' colour), and what decided the join.
|
|
754
775
|
joined = sum(1 for t in traces if t["mode"] == "lines+markers")
|
|
755
776
|
Log.debug(
|
|
756
|
-
"sample overlay panel %s: %d run(s), %d joined, %d single-point; "
|
|
777
|
+
"sample overlay panel %s: %d run(s) as %d trace(s), %d joined, %d single-point; "
|
|
757
778
|
"join=%s (%s), own colour=%r, mark colour=%r",
|
|
758
779
|
panel.title or "unfaceted",
|
|
780
|
+
len(runs),
|
|
759
781
|
len(traces),
|
|
760
782
|
joined,
|
|
761
|
-
sum(1 for
|
|
783
|
+
sum(1 for run in runs if len(run.rows) == 1),
|
|
762
784
|
resolved.sample_join,
|
|
763
785
|
resolved.sample_join_reason,
|
|
764
786
|
resolved.sample_color,
|
|
@@ -776,13 +776,36 @@ class VariantSet:
|
|
|
776
776
|
#: would claim every ``Force`` row too, drawing it once per variant. Naming
|
|
777
777
|
#: the variable makes "not applicable here" and "stale" distinguishable.
|
|
778
778
|
variable: str | None = None
|
|
779
|
+
#: Code versions this row is FROZEN to: ``{"Code:fn": ["v1", ...]}``. Per
|
|
780
|
+
#: schema location the row keeps the highest of these ordinals, so a save
|
|
781
|
+
#: spanning v1/v2 across locations reopens exactly as drawn.
|
|
782
|
+
#:
|
|
783
|
+
#: Filled in by ``scistackplotdb.saved`` when a saved plot is reopened,
|
|
784
|
+
#: translated from the function HASHES stored at save time (ordinals
|
|
785
|
+
#: renumber after a real delete; hashes do not). Plain data so this
|
|
786
|
+
#: package stays scidb-free. A frozen column overrides the row's own
|
|
787
|
+
#: selection key for that column and its latest flag — "current" AND
|
|
788
|
+
#: "v1" would select nothing. See docs/claude/variant-row-selection.md.
|
|
789
|
+
frozen: dict[str, list[str]] = field(default_factory=dict)
|
|
790
|
+
#: True: this row always draws the current code and a saved plot never
|
|
791
|
+
#: freezes it. Set by answering "switch to latest" when a saved plot finds
|
|
792
|
+
#: newer data (``scistackplotdb.saved.follow_latest_saved_plot``).
|
|
793
|
+
follow_latest: bool = False
|
|
779
794
|
|
|
780
795
|
def to_dict(self) -> dict:
|
|
781
|
-
|
|
796
|
+
out = {
|
|
782
797
|
"name": self.name,
|
|
783
798
|
"selection": dict(self.selection),
|
|
784
799
|
"variable": self.variable,
|
|
785
800
|
}
|
|
801
|
+
# Written only when set, so the common row (and every generated
|
|
802
|
+
# docstring, plan-cache diff and stored spec that holds one) reads
|
|
803
|
+
# exactly as it did before these existed.
|
|
804
|
+
if self.frozen:
|
|
805
|
+
out["frozen"] = {column: list(levels) for column, levels in self.frozen.items()}
|
|
806
|
+
if self.follow_latest:
|
|
807
|
+
out["follow_latest"] = True
|
|
808
|
+
return out
|
|
786
809
|
|
|
787
810
|
@classmethod
|
|
788
811
|
def from_dict(cls, raw: dict) -> "VariantSet":
|
|
@@ -790,6 +813,11 @@ class VariantSet:
|
|
|
790
813
|
name=raw.get("name"),
|
|
791
814
|
selection=dict(raw.get("selection") or {}),
|
|
792
815
|
variable=raw.get("variable"),
|
|
816
|
+
frozen={
|
|
817
|
+
str(column): [str(level) for level in (levels or [])]
|
|
818
|
+
for column, levels in (raw.get("frozen") or {}).items()
|
|
819
|
+
},
|
|
820
|
+
follow_latest=bool(raw.get("follow_latest", False)),
|
|
793
821
|
)
|
|
794
822
|
|
|
795
823
|
|
|
@@ -289,22 +289,208 @@ def variant_set_mask(
|
|
|
289
289
|
return mask
|
|
290
290
|
|
|
291
291
|
|
|
292
|
-
|
|
292
|
+
#: Where a row's effective selection came from — the third field of every
|
|
293
|
+
#: ``log_effective_selections`` entry, so a log reader can tell "the user said
|
|
294
|
+
#: this" from "nothing was said, so the source's current records".
|
|
295
|
+
SOURCE_STATED = "stated"
|
|
296
|
+
SOURCE_DEFAULT = "source default"
|
|
297
|
+
SOURCE_FROZEN = "frozen"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _names_function_choice(selection: dict[str, Any], latest_column: str | None) -> bool:
|
|
301
|
+
"""Whether a selection already says which CODE to draw: a code/run axis,
|
|
302
|
+
or the source's latest flag."""
|
|
303
|
+
return any(
|
|
304
|
+
is_function_axis(key) or (latest_column is not None and key == latest_column)
|
|
305
|
+
for key in selection
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def active_frozen(variant: VariantSet, table: LongTable) -> dict[str, list[str]]:
|
|
310
|
+
"""The row's frozen columns that this table can apply.
|
|
311
|
+
|
|
312
|
+
``follow_latest`` wins over a freeze: the user asked for current code.
|
|
313
|
+
A frozen column the frame does not carry (the function is back to one
|
|
314
|
+
version, so there is no axis) is ignored — there is nothing to choose
|
|
315
|
+
between, and ``scistackplotdb.saved`` only writes columns that exist.
|
|
316
|
+
"""
|
|
317
|
+
if variant.follow_latest or not variant.frozen:
|
|
318
|
+
return {}
|
|
319
|
+
columns = table.frame.columns
|
|
320
|
+
return {
|
|
321
|
+
column: list(levels)
|
|
322
|
+
for column, levels in variant.frozen.items()
|
|
323
|
+
if column in columns and levels
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def effective_selection_with_source(
|
|
328
|
+
variant: VariantSet, table: LongTable
|
|
329
|
+
) -> tuple[dict[str, Any], str]:
|
|
330
|
+
"""``(selection, source)`` — what this row's selection really is.
|
|
331
|
+
|
|
332
|
+
The single answer to "which code does a row draw?", consumed by
|
|
333
|
+
:func:`row_mask` (and so by the figure AND the row counts/banner),
|
|
334
|
+
:func:`_answered`, and the no-rows path. Until 2026-10-15 an empty row 0
|
|
335
|
+
meant "the source default" to the figure and "everything" to the banner,
|
|
336
|
+
which reported pooling the figure never did. See
|
|
337
|
+
``docs/claude/variant-row-selection.md``.
|
|
338
|
+
|
|
339
|
+
* **frozen** — a reopened saved plot's code versions. The frozen columns
|
|
340
|
+
and the latest flag leave the selection (``frozen_mask`` answers them;
|
|
341
|
+
"current" AND "v1" would select nothing).
|
|
342
|
+
* **stated** — the selection already names a code/run axis or the latest
|
|
343
|
+
flag, or the source has no default: used as written. Pooling versions
|
|
344
|
+
is reachable only this way, by naming them.
|
|
345
|
+
* **source default** — the selection says nothing about code, so the
|
|
346
|
+
source's ``default_pin`` (the per-location latest flag) is added. A row
|
|
347
|
+
that only says ``low_hz=20``, or nothing at all, draws current code —
|
|
348
|
+
never every version at once.
|
|
349
|
+
"""
|
|
350
|
+
return _effective(
|
|
351
|
+
variant.selection,
|
|
352
|
+
active_frozen(variant, table),
|
|
353
|
+
latest_column=table.latest_column,
|
|
354
|
+
default_pin=table.default_pin,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _effective(
|
|
359
|
+
raw: dict[str, Any],
|
|
360
|
+
frozen: dict[str, list[str]],
|
|
361
|
+
*,
|
|
362
|
+
latest_column: str | None,
|
|
363
|
+
default_pin: dict[str, Any] | None,
|
|
364
|
+
) -> tuple[dict[str, Any], str]:
|
|
365
|
+
"""The rule itself, with the table's facts passed in — shared by the
|
|
366
|
+
table-bound reader above and by :func:`export_selection`, which has no
|
|
367
|
+
table, so the preview and the exported code cannot apply two rules."""
|
|
368
|
+
selection = dict(raw)
|
|
369
|
+
if frozen:
|
|
370
|
+
for column in frozen:
|
|
371
|
+
selection.pop(column, None)
|
|
372
|
+
if latest_column:
|
|
373
|
+
selection.pop(latest_column, None)
|
|
374
|
+
return selection, SOURCE_FROZEN
|
|
375
|
+
pin = dict(default_pin or {})
|
|
376
|
+
if not pin or _names_function_choice(selection, latest_column):
|
|
377
|
+
return selection, SOURCE_STATED
|
|
378
|
+
return {**pin, **selection}, SOURCE_DEFAULT
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def export_selection(
|
|
382
|
+
variant: VariantSet,
|
|
383
|
+
*,
|
|
384
|
+
latest_column: str | None,
|
|
385
|
+
default_pin: dict[str, Any] | None,
|
|
386
|
+
) -> dict[str, Any]:
|
|
387
|
+
"""The selection exported code loads one row through — what the preview
|
|
388
|
+
applies, spelled for a ``Variant(...)`` wrapper.
|
|
389
|
+
|
|
390
|
+
``latest_column`` and ``default_pin`` are the TABLE's (the same facts
|
|
391
|
+
:func:`effective_selection_with_source` reads), so the export adds the
|
|
392
|
+
latest flag exactly when the preview does. A table with no default — a
|
|
393
|
+
single-generation variable, a CSV — exports the row as written, as the preview draws it. A freeze comes back as the frozen
|
|
394
|
+
ordinal(s) on their ``Code:`` columns.
|
|
395
|
+
|
|
396
|
+
Known limit: a freeze of SEVERAL ordinals is "per location, the highest of
|
|
397
|
+
these" in the preview (:func:`frozen_mask`); a ``Variant`` list is "any
|
|
398
|
+
of". The WARN below says so in the log whenever that happens.
|
|
399
|
+
"""
|
|
400
|
+
frozen = (
|
|
401
|
+
{}
|
|
402
|
+
if variant.follow_latest
|
|
403
|
+
else {column: list(levels) for column, levels in variant.frozen.items() if levels}
|
|
404
|
+
)
|
|
405
|
+
selection, _source = _effective(
|
|
406
|
+
variant.selection,
|
|
407
|
+
frozen,
|
|
408
|
+
latest_column=latest_column,
|
|
409
|
+
default_pin=default_pin,
|
|
410
|
+
)
|
|
411
|
+
for column, levels in frozen.items():
|
|
412
|
+
selection[column] = levels[0] if len(levels) == 1 else list(levels)
|
|
413
|
+
if len(levels) > 1:
|
|
414
|
+
Log.warn(
|
|
415
|
+
"export: %s is frozen to %s — the preview keeps the highest of "
|
|
416
|
+
"these per schema location, the exported Variant(...) keeps any "
|
|
417
|
+
"of them; re-save the plot on one version for an exact export",
|
|
418
|
+
column,
|
|
419
|
+
levels,
|
|
420
|
+
layer=LAYER,
|
|
421
|
+
)
|
|
422
|
+
return selection
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def effective_selection(variant: VariantSet, table: LongTable) -> dict[str, Any]:
|
|
426
|
+
"""The selection half of :func:`effective_selection_with_source`."""
|
|
427
|
+
return effective_selection_with_source(variant, table)[0]
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _ordinal_rank(value: str) -> int:
|
|
431
|
+
match = _ORDINAL.match(value)
|
|
432
|
+
return int(match.group(1)) if match else 0
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def frozen_mask(
|
|
293
436
|
frame: pd.DataFrame,
|
|
437
|
+
frozen: dict[str, list[str]],
|
|
438
|
+
table: LongTable,
|
|
439
|
+
*,
|
|
440
|
+
within: pd.Series | None = None,
|
|
441
|
+
) -> pd.Series:
|
|
442
|
+
"""Rows a freeze keeps: per frozen column, only the frozen ordinals, and
|
|
443
|
+
within each schema location only the HIGHEST of them present.
|
|
444
|
+
|
|
445
|
+
That is "latest as of the save" reproduced from ordinals alone: a save
|
|
446
|
+
made while subject 01 was on v2 and subject 02 still on v1 froze
|
|
447
|
+
``[v1, v2]``; reopened after a v3 run, 01 draws v2 and 02 draws v1 — not
|
|
448
|
+
both at 01, which a plain "any of" would pool.
|
|
449
|
+
|
|
450
|
+
``within`` restricts which rows compete (the row's selection and variable
|
|
451
|
+
have already narrowed them), so the per-location best is picked among
|
|
452
|
+
rows the row could draw at all. Locations are the table's schema levels
|
|
453
|
+
plus the stacked ``Variable`` column, so two variables never compete.
|
|
454
|
+
"""
|
|
455
|
+
within = within if within is not None else pd.Series(True, index=frame.index)
|
|
456
|
+
mask = pd.Series(True, index=frame.index)
|
|
457
|
+
keys = [key for key in table.schema_levels if key in frame.columns]
|
|
458
|
+
if VARIABLE_COLUMN in frame.columns:
|
|
459
|
+
keys.append(VARIABLE_COLUMN)
|
|
460
|
+
for column, ordinals in frozen.items():
|
|
461
|
+
if column not in frame.columns:
|
|
462
|
+
continue
|
|
463
|
+
values = frame[column].astype(str)
|
|
464
|
+
allowed = values.isin({str(level) for level in ordinals})
|
|
465
|
+
rank = values.map(_ordinal_rank).where(allowed & within & mask, -1)
|
|
466
|
+
if keys:
|
|
467
|
+
best = rank.groupby(
|
|
468
|
+
[frame[key].astype(str) for key in keys], sort=False
|
|
469
|
+
).transform("max")
|
|
470
|
+
else:
|
|
471
|
+
best = pd.Series(rank.max(), index=frame.index)
|
|
472
|
+
mask &= allowed & (rank == best)
|
|
473
|
+
return mask
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def row_mask(
|
|
477
|
+
table: LongTable,
|
|
294
478
|
spec: PlotSpec,
|
|
295
479
|
variant: VariantSet,
|
|
296
|
-
*,
|
|
297
|
-
latest_column: str | None = None,
|
|
298
480
|
) -> pd.Series:
|
|
299
|
-
"""Rows one variant ROW claims: its selection,
|
|
481
|
+
"""Rows one variant ROW claims: its effective selection, its freeze, within
|
|
482
|
+
its own variable.
|
|
300
483
|
|
|
301
484
|
The single definition, called by :func:`apply_variant_sets` (which builds
|
|
302
485
|
the figure) and by ``capability.variant_summary`` (which reports how many
|
|
303
|
-
rows each variant matched
|
|
304
|
-
|
|
305
|
-
:func:`variant_set_mask` is public.
|
|
486
|
+
rows each variant matched, and whether it spans versions). Those two
|
|
487
|
+
disagreeing put a "mixes v1 | v2" banner above a figure drawing v2 only
|
|
488
|
+
(2026-10-15) — the same reason :func:`variant_set_mask` is public.
|
|
306
489
|
"""
|
|
307
|
-
|
|
490
|
+
frame = table.frame
|
|
491
|
+
mask = variant_set_mask(
|
|
492
|
+
frame, effective_selection(variant, table), latest_column=table.latest_column
|
|
493
|
+
)
|
|
308
494
|
if VARIABLE_COLUMN in frame.columns:
|
|
309
495
|
# A row claims only its own variable's rows. Without this, a row
|
|
310
496
|
# pinning `Code:filterEMG=v1` names nothing about a second variable —
|
|
@@ -314,6 +500,9 @@ def row_mask(
|
|
|
314
500
|
mask &= frame[VARIABLE_COLUMN].astype(str) == (
|
|
315
501
|
variant.variable or spec.y_measure
|
|
316
502
|
)
|
|
503
|
+
frozen = active_frozen(variant, table)
|
|
504
|
+
if frozen:
|
|
505
|
+
mask &= frozen_mask(frame, frozen, table, within=mask)
|
|
317
506
|
return mask
|
|
318
507
|
|
|
319
508
|
|
|
@@ -323,6 +512,7 @@ def auto_label(
|
|
|
323
512
|
index: int = 0,
|
|
324
513
|
variable: str | None = None,
|
|
325
514
|
latest_column: str | None = None,
|
|
515
|
+
frozen: dict[str, list[str]] | None = None,
|
|
326
516
|
) -> str:
|
|
327
517
|
"""The name a variant gets until the user types over it.
|
|
328
518
|
|
|
@@ -342,9 +532,17 @@ def auto_label(
|
|
|
342
532
|
An empty selection with no variable is ``(not set)``, not "all variants":
|
|
343
533
|
an unfilled row is inert (:func:`defined_sets`), so a label promising every
|
|
344
534
|
variant would describe a row that contributes nothing.
|
|
535
|
+
|
|
536
|
+
``frozen`` (a reopened saved plot's versions) is spelled ``load_cbrace v1
|
|
537
|
+
(saved)``, and replaces the latest flag and the frozen columns' own
|
|
538
|
+
selection keys, exactly as :func:`effective_selection_with_source` does —
|
|
539
|
+
"current · load_cbrace v1" would describe something the row does not draw.
|
|
345
540
|
"""
|
|
346
541
|
parts: list[str] = []
|
|
542
|
+
frozen = {column: levels for column, levels in (frozen or {}).items() if levels}
|
|
347
543
|
for column, value in selection.items():
|
|
544
|
+
if frozen and (column in frozen or (latest_column and column == latest_column)):
|
|
545
|
+
continue
|
|
348
546
|
text = _level_text(value)
|
|
349
547
|
if latest_column and column == latest_column:
|
|
350
548
|
# A False flag is "not the current code", which is a real thing to
|
|
@@ -357,6 +555,8 @@ def auto_label(
|
|
|
357
555
|
# function is usually obvious from context in a legend, the
|
|
358
556
|
# parameter never is.
|
|
359
557
|
parts.append(f"{column.rsplit('.', 1)[-1]}={text}")
|
|
558
|
+
for column, levels in frozen.items():
|
|
559
|
+
parts.append(f"{function_of_axis(column)} {_level_text(levels)} (saved)")
|
|
360
560
|
|
|
361
561
|
if variable:
|
|
362
562
|
return f"{variable} · {' · '.join(parts)}" if parts else variable
|
|
@@ -392,11 +592,18 @@ def label_variable(
|
|
|
392
592
|
"""
|
|
393
593
|
if variant_set.variable:
|
|
394
594
|
return variant_set.variable
|
|
395
|
-
if variant_set.selection or index == 0:
|
|
595
|
+
if variant_set.selection or variant_set.frozen or index == 0:
|
|
396
596
|
return primary
|
|
397
597
|
return None
|
|
398
598
|
|
|
399
599
|
|
|
600
|
+
def says_something(variant_set: VariantSet) -> bool:
|
|
601
|
+
"""A selection, a variable, or a frozen pin — any of them is a statement
|
|
602
|
+
about the data. A freeze especially: a reopened saved plot's empty row 0
|
|
603
|
+
with ``frozen={"Code:f": ["v1"]}`` must not fall back to "current"."""
|
|
604
|
+
return bool(variant_set.selection or variant_set.variable or variant_set.frozen)
|
|
605
|
+
|
|
606
|
+
|
|
400
607
|
def is_stated(variant_set: VariantSet, index: int) -> bool:
|
|
401
608
|
"""Whether this row says anything — what the panel's "not set" tag reads.
|
|
402
609
|
|
|
@@ -407,7 +614,7 @@ def is_stated(variant_set: VariantSet, index: int) -> bool:
|
|
|
407
614
|
empty row 0 states "the primary measure" and needs no ``Variant`` factor to
|
|
408
615
|
express, so it is stated but not a defined set.
|
|
409
616
|
"""
|
|
410
|
-
return index == 0 or
|
|
617
|
+
return index == 0 or says_something(variant_set)
|
|
411
618
|
|
|
412
619
|
|
|
413
620
|
def set_name(
|
|
@@ -429,6 +636,7 @@ def set_name(
|
|
|
429
636
|
index=index,
|
|
430
637
|
variable=label_variable(variant_set, index, primary),
|
|
431
638
|
latest_column=latest_column,
|
|
639
|
+
frozen=None if variant_set.follow_latest else variant_set.frozen,
|
|
432
640
|
)
|
|
433
641
|
|
|
434
642
|
|
|
@@ -462,7 +670,7 @@ def defined_sets(sets: list[VariantSet]) -> list[VariantSet]:
|
|
|
462
670
|
With no concrete row anywhere there is nothing to fold: one unnarrowed
|
|
463
671
|
series needs no ``Variant`` factor to express.
|
|
464
672
|
"""
|
|
465
|
-
if not any(variant
|
|
673
|
+
if not any(says_something(variant) for variant in sets):
|
|
466
674
|
return []
|
|
467
675
|
return [
|
|
468
676
|
variant for index, variant in enumerate(sets) if is_stated(variant, index)
|
|
@@ -529,12 +737,17 @@ def _answered(table: LongTable, sets: list[VariantSet]) -> set[str]:
|
|
|
529
737
|
# WITHIN a variable and never across.
|
|
530
738
|
per_variant: list[set[str]] = []
|
|
531
739
|
for variant in sets:
|
|
740
|
+
# The EFFECTIVE selection (and the freeze), not the raw one: what a row
|
|
741
|
+
# answers is what it draws, by the same owner `row_mask` uses.
|
|
532
742
|
per_variant.append(
|
|
533
743
|
set(
|
|
534
744
|
resolve_selection(
|
|
535
|
-
frame,
|
|
745
|
+
frame,
|
|
746
|
+
effective_selection(variant, table),
|
|
747
|
+
latest_column=table.latest_column,
|
|
536
748
|
)
|
|
537
749
|
)
|
|
750
|
+
| set(active_frozen(variant, table))
|
|
538
751
|
)
|
|
539
752
|
return answered | set.intersection(*per_variant)
|
|
540
753
|
|
|
@@ -744,10 +957,20 @@ def _apply_default_pin(table: LongTable) -> LongTable:
|
|
|
744
957
|
record and its superseded whole-table record together, 17 rows where there
|
|
745
958
|
should be one. An unnamed plot now reads what an unnamed load reads.
|
|
746
959
|
|
|
960
|
+
The selection comes from :func:`effective_selection_with_source` for an
|
|
961
|
+
empty row — the SAME owner a stated row goes through, so the figure and
|
|
962
|
+
the row counts/banner (``capability.variant_summary``) cannot disagree
|
|
963
|
+
about what an empty row draws again (2026-10-15).
|
|
964
|
+
|
|
965
|
+
The function axes the pin answered **leave the factor list**, as they do
|
|
966
|
+
for any defined row (:func:`_answered`). Left in, a ``Code:fn`` column
|
|
967
|
+
whose rows were already filtered to one version got no role, defaulted to
|
|
968
|
+
ITERATE, and titled the figure ``Code:fn=v2``.
|
|
969
|
+
|
|
747
970
|
No pin (one generation, a CSV source) leaves the table untouched, so a
|
|
748
971
|
project with nothing superseded behaves exactly as before.
|
|
749
972
|
"""
|
|
750
|
-
pin =
|
|
973
|
+
pin, _source = effective_selection_with_source(VariantSet(), table)
|
|
751
974
|
frame = table.frame
|
|
752
975
|
if not pin:
|
|
753
976
|
Log.info(
|
|
@@ -758,18 +981,23 @@ def _apply_default_pin(table: LongTable) -> LongTable:
|
|
|
758
981
|
return table
|
|
759
982
|
mask = variant_set_mask(frame, pin, latest_column=table.latest_column)
|
|
760
983
|
kept = int(mask.sum())
|
|
984
|
+
answered = [
|
|
985
|
+
factor.name for factor in table.factors if is_function_axis(factor.name)
|
|
986
|
+
]
|
|
761
987
|
Log.info(
|
|
762
988
|
"variant selection: no variant rows — the source default %s keeps %d of "
|
|
763
|
-
"%d row(s)%s",
|
|
989
|
+
"%d row(s)%s; answers %s",
|
|
764
990
|
pin,
|
|
765
991
|
kept,
|
|
766
992
|
len(frame),
|
|
767
993
|
"" if kept == len(frame) else f" ({len(frame) - kept} superseded row(s) dropped)",
|
|
994
|
+
answered or "no function axis",
|
|
768
995
|
layer=LAYER,
|
|
769
996
|
)
|
|
770
|
-
if
|
|
997
|
+
factors = [f for f in table.factors if f.name not in answered]
|
|
998
|
+
if kept == len(frame) and not answered:
|
|
771
999
|
return table
|
|
772
|
-
return replace(table, frame=frame[mask].copy())
|
|
1000
|
+
return replace(table, frame=frame[mask].copy(), factors=factors)
|
|
773
1001
|
|
|
774
1002
|
|
|
775
1003
|
def apply_variant_sets(spec: PlotSpec, table: LongTable) -> LongTable:
|
|
@@ -805,7 +1033,7 @@ def apply_variant_sets(spec: PlotSpec, table: LongTable) -> LongTable:
|
|
|
805
1033
|
counts: list[int] = []
|
|
806
1034
|
|
|
807
1035
|
for name, variant in zip(names, sets, strict=True):
|
|
808
|
-
mask = row_mask(
|
|
1036
|
+
mask = row_mask(table, spec, variant)
|
|
809
1037
|
fresh = mask & assigned.isna()
|
|
810
1038
|
counts.append(int(fresh.sum()))
|
|
811
1039
|
assigned[fresh] = name
|
|
@@ -813,7 +1041,9 @@ def apply_variant_sets(spec: PlotSpec, table: LongTable) -> LongTable:
|
|
|
813
1041
|
# Code axes leave the factor list unconditionally, so a variant that
|
|
814
1042
|
# quietly straddles two versions has to say so here — this is the log
|
|
815
1043
|
# half of what the GUI shows on the row.
|
|
816
|
-
spans = spanned_code_axes(
|
|
1044
|
+
spans = spanned_code_axes(
|
|
1045
|
+
frame[fresh], effective_selection(variant, table), table
|
|
1046
|
+
)
|
|
817
1047
|
if spans:
|
|
818
1048
|
Log.warn(
|
|
819
1049
|
"variant %r was built by MORE THAN ONE version of the code — "
|
|
@@ -858,5 +1088,36 @@ def apply_variant_sets(spec: PlotSpec, table: LongTable) -> LongTable:
|
|
|
858
1088
|
dict(zip(names, counts, strict=True)),
|
|
859
1089
|
layer=LAYER,
|
|
860
1090
|
)
|
|
1091
|
+
log_effective_selections(names, sets, counts, table)
|
|
861
1092
|
|
|
862
1093
|
return replace(table, frame=kept, factors=factors)
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def log_effective_selections(
|
|
1097
|
+
names: list[str],
|
|
1098
|
+
sets: list[VariantSet],
|
|
1099
|
+
counts: list[int],
|
|
1100
|
+
table: LongTable,
|
|
1101
|
+
) -> None:
|
|
1102
|
+
"""One INFO naming, per row, what it REALLY selected and why.
|
|
1103
|
+
|
|
1104
|
+
``name: stated|source default|frozen <selection> [frozen <cols>] -> N
|
|
1105
|
+
row(s)``. The diagnostic for the 2026-10-15 class of bug: if a banner or
|
|
1106
|
+
a count ever disagrees with the figure again, this line shows which
|
|
1107
|
+
selection the figure applied, beside whatever the other reader claims.
|
|
1108
|
+
"""
|
|
1109
|
+
parts = []
|
|
1110
|
+
for name, variant, count in zip(names, sets, counts, strict=True):
|
|
1111
|
+
selection, source = effective_selection_with_source(variant, table)
|
|
1112
|
+
frozen = active_frozen(variant, table)
|
|
1113
|
+
parts.append(
|
|
1114
|
+
f"{name!r}: {source} {selection}"
|
|
1115
|
+
+ (f" frozen {frozen}" if frozen else "")
|
|
1116
|
+
+ (" (follow latest)" if variant.follow_latest else "")
|
|
1117
|
+
+ f" -> {count} row(s)"
|
|
1118
|
+
)
|
|
1119
|
+
Log.info(
|
|
1120
|
+
"variant rows' effective selections: %s",
|
|
1121
|
+
"; ".join(parts),
|
|
1122
|
+
layer=LAYER,
|
|
1123
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|