scistackplot 0.1.35__tar.gz → 0.1.37__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.35 → scistackplot-0.1.37}/PKG-INFO +2 -2
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/aliases.py +21 -2
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/capability.py +22 -8
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/codegen.py +14 -1
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/reduce.py +1 -1
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/render/mpl.py +146 -22
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/render/plotly_.py +32 -10
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/spec.py +29 -1
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/variants.py +280 -18
- {scistackplot-0.1.35 → scistackplot-0.1.37}/.gitignore +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/README.md +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/pyproject.toml +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/__init__.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/autosize.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/cell.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/colors.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/compare.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/custom.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/dedup.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/diffbars.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/export.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/figsize.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/figure_file.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/framesize.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/groups.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/numeric.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/panels.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/paper.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/presets.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/reducer.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/render/__init__.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/render/base.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/resolved.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/restore.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/roles.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/series_stats.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/shape.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/sources/__init__.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/sources/base.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/sources/csv.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/sources/frame.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/spaghetti.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/table.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/textsize.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/ticklabels.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/weights.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/src/scistackplot/xaxis.py +0 -0
- {scistackplot-0.1.35 → scistackplot-0.1.37}/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.37
|
|
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.37
|
|
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,209 @@ 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
|
+
stacked multi-variable table, a single-generation variable — exports the
|
|
394
|
+
row as written, as the preview draws it. A freeze comes back as the frozen
|
|
395
|
+
ordinal(s) on their ``Code:`` columns.
|
|
396
|
+
|
|
397
|
+
Known limit: a freeze of SEVERAL ordinals is "per location, the highest of
|
|
398
|
+
these" in the preview (:func:`frozen_mask`); a ``Variant`` list is "any
|
|
399
|
+
of". The WARN below says so in the log whenever that happens.
|
|
400
|
+
"""
|
|
401
|
+
frozen = (
|
|
402
|
+
{}
|
|
403
|
+
if variant.follow_latest
|
|
404
|
+
else {column: list(levels) for column, levels in variant.frozen.items() if levels}
|
|
405
|
+
)
|
|
406
|
+
selection, _source = _effective(
|
|
407
|
+
variant.selection,
|
|
408
|
+
frozen,
|
|
409
|
+
latest_column=latest_column,
|
|
410
|
+
default_pin=default_pin,
|
|
411
|
+
)
|
|
412
|
+
for column, levels in frozen.items():
|
|
413
|
+
selection[column] = levels[0] if len(levels) == 1 else list(levels)
|
|
414
|
+
if len(levels) > 1:
|
|
415
|
+
Log.warn(
|
|
416
|
+
"export: %s is frozen to %s — the preview keeps the highest of "
|
|
417
|
+
"these per schema location, the exported Variant(...) keeps any "
|
|
418
|
+
"of them; re-save the plot on one version for an exact export",
|
|
419
|
+
column,
|
|
420
|
+
levels,
|
|
421
|
+
layer=LAYER,
|
|
422
|
+
)
|
|
423
|
+
return selection
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def effective_selection(variant: VariantSet, table: LongTable) -> dict[str, Any]:
|
|
427
|
+
"""The selection half of :func:`effective_selection_with_source`."""
|
|
428
|
+
return effective_selection_with_source(variant, table)[0]
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _ordinal_rank(value: str) -> int:
|
|
432
|
+
match = _ORDINAL.match(value)
|
|
433
|
+
return int(match.group(1)) if match else 0
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def frozen_mask(
|
|
293
437
|
frame: pd.DataFrame,
|
|
438
|
+
frozen: dict[str, list[str]],
|
|
439
|
+
table: LongTable,
|
|
440
|
+
*,
|
|
441
|
+
within: pd.Series | None = None,
|
|
442
|
+
) -> pd.Series:
|
|
443
|
+
"""Rows a freeze keeps: per frozen column, only the frozen ordinals, and
|
|
444
|
+
within each schema location only the HIGHEST of them present.
|
|
445
|
+
|
|
446
|
+
That is "latest as of the save" reproduced from ordinals alone: a save
|
|
447
|
+
made while subject 01 was on v2 and subject 02 still on v1 froze
|
|
448
|
+
``[v1, v2]``; reopened after a v3 run, 01 draws v2 and 02 draws v1 — not
|
|
449
|
+
both at 01, which a plain "any of" would pool.
|
|
450
|
+
|
|
451
|
+
``within`` restricts which rows compete (the row's selection and variable
|
|
452
|
+
have already narrowed them), so the per-location best is picked among
|
|
453
|
+
rows the row could draw at all. Locations are the table's schema levels
|
|
454
|
+
plus the stacked ``Variable`` column, so two variables never compete.
|
|
455
|
+
"""
|
|
456
|
+
within = within if within is not None else pd.Series(True, index=frame.index)
|
|
457
|
+
mask = pd.Series(True, index=frame.index)
|
|
458
|
+
keys = [key for key in table.schema_levels if key in frame.columns]
|
|
459
|
+
if VARIABLE_COLUMN in frame.columns:
|
|
460
|
+
keys.append(VARIABLE_COLUMN)
|
|
461
|
+
for column, ordinals in frozen.items():
|
|
462
|
+
if column not in frame.columns:
|
|
463
|
+
continue
|
|
464
|
+
values = frame[column].astype(str)
|
|
465
|
+
allowed = values.isin({str(level) for level in ordinals})
|
|
466
|
+
rank = values.map(_ordinal_rank).where(allowed & within & mask, -1)
|
|
467
|
+
if keys:
|
|
468
|
+
best = rank.groupby(
|
|
469
|
+
[frame[key].astype(str) for key in keys], sort=False
|
|
470
|
+
).transform("max")
|
|
471
|
+
else:
|
|
472
|
+
best = pd.Series(rank.max(), index=frame.index)
|
|
473
|
+
mask &= allowed & (rank == best)
|
|
474
|
+
return mask
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def row_mask(
|
|
478
|
+
table: LongTable,
|
|
294
479
|
spec: PlotSpec,
|
|
295
480
|
variant: VariantSet,
|
|
296
|
-
*,
|
|
297
|
-
latest_column: str | None = None,
|
|
298
481
|
) -> pd.Series:
|
|
299
|
-
"""Rows one variant ROW claims: its selection,
|
|
482
|
+
"""Rows one variant ROW claims: its effective selection, its freeze, within
|
|
483
|
+
its own variable.
|
|
300
484
|
|
|
301
485
|
The single definition, called by :func:`apply_variant_sets` (which builds
|
|
302
486
|
the figure) and by ``capability.variant_summary`` (which reports how many
|
|
303
|
-
rows each variant matched
|
|
304
|
-
|
|
305
|
-
:func:`variant_set_mask` is public.
|
|
487
|
+
rows each variant matched, and whether it spans versions). Those two
|
|
488
|
+
disagreeing put a "mixes v1 | v2" banner above a figure drawing v2 only
|
|
489
|
+
(2026-10-15) — the same reason :func:`variant_set_mask` is public.
|
|
306
490
|
"""
|
|
307
|
-
|
|
491
|
+
frame = table.frame
|
|
492
|
+
mask = variant_set_mask(
|
|
493
|
+
frame, effective_selection(variant, table), latest_column=table.latest_column
|
|
494
|
+
)
|
|
308
495
|
if VARIABLE_COLUMN in frame.columns:
|
|
309
496
|
# A row claims only its own variable's rows. Without this, a row
|
|
310
497
|
# pinning `Code:filterEMG=v1` names nothing about a second variable —
|
|
@@ -314,6 +501,9 @@ def row_mask(
|
|
|
314
501
|
mask &= frame[VARIABLE_COLUMN].astype(str) == (
|
|
315
502
|
variant.variable or spec.y_measure
|
|
316
503
|
)
|
|
504
|
+
frozen = active_frozen(variant, table)
|
|
505
|
+
if frozen:
|
|
506
|
+
mask &= frozen_mask(frame, frozen, table, within=mask)
|
|
317
507
|
return mask
|
|
318
508
|
|
|
319
509
|
|
|
@@ -323,6 +513,7 @@ def auto_label(
|
|
|
323
513
|
index: int = 0,
|
|
324
514
|
variable: str | None = None,
|
|
325
515
|
latest_column: str | None = None,
|
|
516
|
+
frozen: dict[str, list[str]] | None = None,
|
|
326
517
|
) -> str:
|
|
327
518
|
"""The name a variant gets until the user types over it.
|
|
328
519
|
|
|
@@ -342,9 +533,17 @@ def auto_label(
|
|
|
342
533
|
An empty selection with no variable is ``(not set)``, not "all variants":
|
|
343
534
|
an unfilled row is inert (:func:`defined_sets`), so a label promising every
|
|
344
535
|
variant would describe a row that contributes nothing.
|
|
536
|
+
|
|
537
|
+
``frozen`` (a reopened saved plot's versions) is spelled ``load_cbrace v1
|
|
538
|
+
(saved)``, and replaces the latest flag and the frozen columns' own
|
|
539
|
+
selection keys, exactly as :func:`effective_selection_with_source` does —
|
|
540
|
+
"current · load_cbrace v1" would describe something the row does not draw.
|
|
345
541
|
"""
|
|
346
542
|
parts: list[str] = []
|
|
543
|
+
frozen = {column: levels for column, levels in (frozen or {}).items() if levels}
|
|
347
544
|
for column, value in selection.items():
|
|
545
|
+
if frozen and (column in frozen or (latest_column and column == latest_column)):
|
|
546
|
+
continue
|
|
348
547
|
text = _level_text(value)
|
|
349
548
|
if latest_column and column == latest_column:
|
|
350
549
|
# A False flag is "not the current code", which is a real thing to
|
|
@@ -357,6 +556,8 @@ def auto_label(
|
|
|
357
556
|
# function is usually obvious from context in a legend, the
|
|
358
557
|
# parameter never is.
|
|
359
558
|
parts.append(f"{column.rsplit('.', 1)[-1]}={text}")
|
|
559
|
+
for column, levels in frozen.items():
|
|
560
|
+
parts.append(f"{function_of_axis(column)} {_level_text(levels)} (saved)")
|
|
360
561
|
|
|
361
562
|
if variable:
|
|
362
563
|
return f"{variable} · {' · '.join(parts)}" if parts else variable
|
|
@@ -392,11 +593,18 @@ def label_variable(
|
|
|
392
593
|
"""
|
|
393
594
|
if variant_set.variable:
|
|
394
595
|
return variant_set.variable
|
|
395
|
-
if variant_set.selection or index == 0:
|
|
596
|
+
if variant_set.selection or variant_set.frozen or index == 0:
|
|
396
597
|
return primary
|
|
397
598
|
return None
|
|
398
599
|
|
|
399
600
|
|
|
601
|
+
def says_something(variant_set: VariantSet) -> bool:
|
|
602
|
+
"""A selection, a variable, or a frozen pin — any of them is a statement
|
|
603
|
+
about the data. A freeze especially: a reopened saved plot's empty row 0
|
|
604
|
+
with ``frozen={"Code:f": ["v1"]}`` must not fall back to "current"."""
|
|
605
|
+
return bool(variant_set.selection or variant_set.variable or variant_set.frozen)
|
|
606
|
+
|
|
607
|
+
|
|
400
608
|
def is_stated(variant_set: VariantSet, index: int) -> bool:
|
|
401
609
|
"""Whether this row says anything — what the panel's "not set" tag reads.
|
|
402
610
|
|
|
@@ -407,7 +615,7 @@ def is_stated(variant_set: VariantSet, index: int) -> bool:
|
|
|
407
615
|
empty row 0 states "the primary measure" and needs no ``Variant`` factor to
|
|
408
616
|
express, so it is stated but not a defined set.
|
|
409
617
|
"""
|
|
410
|
-
return index == 0 or
|
|
618
|
+
return index == 0 or says_something(variant_set)
|
|
411
619
|
|
|
412
620
|
|
|
413
621
|
def set_name(
|
|
@@ -429,6 +637,7 @@ def set_name(
|
|
|
429
637
|
index=index,
|
|
430
638
|
variable=label_variable(variant_set, index, primary),
|
|
431
639
|
latest_column=latest_column,
|
|
640
|
+
frozen=None if variant_set.follow_latest else variant_set.frozen,
|
|
432
641
|
)
|
|
433
642
|
|
|
434
643
|
|
|
@@ -462,7 +671,7 @@ def defined_sets(sets: list[VariantSet]) -> list[VariantSet]:
|
|
|
462
671
|
With no concrete row anywhere there is nothing to fold: one unnarrowed
|
|
463
672
|
series needs no ``Variant`` factor to express.
|
|
464
673
|
"""
|
|
465
|
-
if not any(variant
|
|
674
|
+
if not any(says_something(variant) for variant in sets):
|
|
466
675
|
return []
|
|
467
676
|
return [
|
|
468
677
|
variant for index, variant in enumerate(sets) if is_stated(variant, index)
|
|
@@ -529,12 +738,17 @@ def _answered(table: LongTable, sets: list[VariantSet]) -> set[str]:
|
|
|
529
738
|
# WITHIN a variable and never across.
|
|
530
739
|
per_variant: list[set[str]] = []
|
|
531
740
|
for variant in sets:
|
|
741
|
+
# The EFFECTIVE selection (and the freeze), not the raw one: what a row
|
|
742
|
+
# answers is what it draws, by the same owner `row_mask` uses.
|
|
532
743
|
per_variant.append(
|
|
533
744
|
set(
|
|
534
745
|
resolve_selection(
|
|
535
|
-
frame,
|
|
746
|
+
frame,
|
|
747
|
+
effective_selection(variant, table),
|
|
748
|
+
latest_column=table.latest_column,
|
|
536
749
|
)
|
|
537
750
|
)
|
|
751
|
+
| set(active_frozen(variant, table))
|
|
538
752
|
)
|
|
539
753
|
return answered | set.intersection(*per_variant)
|
|
540
754
|
|
|
@@ -744,10 +958,20 @@ def _apply_default_pin(table: LongTable) -> LongTable:
|
|
|
744
958
|
record and its superseded whole-table record together, 17 rows where there
|
|
745
959
|
should be one. An unnamed plot now reads what an unnamed load reads.
|
|
746
960
|
|
|
961
|
+
The selection comes from :func:`effective_selection_with_source` for an
|
|
962
|
+
empty row — the SAME owner a stated row goes through, so the figure and
|
|
963
|
+
the row counts/banner (``capability.variant_summary``) cannot disagree
|
|
964
|
+
about what an empty row draws again (2026-10-15).
|
|
965
|
+
|
|
966
|
+
The function axes the pin answered **leave the factor list**, as they do
|
|
967
|
+
for any defined row (:func:`_answered`). Left in, a ``Code:fn`` column
|
|
968
|
+
whose rows were already filtered to one version got no role, defaulted to
|
|
969
|
+
ITERATE, and titled the figure ``Code:fn=v2``.
|
|
970
|
+
|
|
747
971
|
No pin (one generation, a CSV source) leaves the table untouched, so a
|
|
748
972
|
project with nothing superseded behaves exactly as before.
|
|
749
973
|
"""
|
|
750
|
-
pin =
|
|
974
|
+
pin, _source = effective_selection_with_source(VariantSet(), table)
|
|
751
975
|
frame = table.frame
|
|
752
976
|
if not pin:
|
|
753
977
|
Log.info(
|
|
@@ -758,18 +982,23 @@ def _apply_default_pin(table: LongTable) -> LongTable:
|
|
|
758
982
|
return table
|
|
759
983
|
mask = variant_set_mask(frame, pin, latest_column=table.latest_column)
|
|
760
984
|
kept = int(mask.sum())
|
|
985
|
+
answered = [
|
|
986
|
+
factor.name for factor in table.factors if is_function_axis(factor.name)
|
|
987
|
+
]
|
|
761
988
|
Log.info(
|
|
762
989
|
"variant selection: no variant rows — the source default %s keeps %d of "
|
|
763
|
-
"%d row(s)%s",
|
|
990
|
+
"%d row(s)%s; answers %s",
|
|
764
991
|
pin,
|
|
765
992
|
kept,
|
|
766
993
|
len(frame),
|
|
767
994
|
"" if kept == len(frame) else f" ({len(frame) - kept} superseded row(s) dropped)",
|
|
995
|
+
answered or "no function axis",
|
|
768
996
|
layer=LAYER,
|
|
769
997
|
)
|
|
770
|
-
if
|
|
998
|
+
factors = [f for f in table.factors if f.name not in answered]
|
|
999
|
+
if kept == len(frame) and not answered:
|
|
771
1000
|
return table
|
|
772
|
-
return replace(table, frame=frame[mask].copy())
|
|
1001
|
+
return replace(table, frame=frame[mask].copy(), factors=factors)
|
|
773
1002
|
|
|
774
1003
|
|
|
775
1004
|
def apply_variant_sets(spec: PlotSpec, table: LongTable) -> LongTable:
|
|
@@ -805,7 +1034,7 @@ def apply_variant_sets(spec: PlotSpec, table: LongTable) -> LongTable:
|
|
|
805
1034
|
counts: list[int] = []
|
|
806
1035
|
|
|
807
1036
|
for name, variant in zip(names, sets, strict=True):
|
|
808
|
-
mask = row_mask(
|
|
1037
|
+
mask = row_mask(table, spec, variant)
|
|
809
1038
|
fresh = mask & assigned.isna()
|
|
810
1039
|
counts.append(int(fresh.sum()))
|
|
811
1040
|
assigned[fresh] = name
|
|
@@ -813,7 +1042,9 @@ def apply_variant_sets(spec: PlotSpec, table: LongTable) -> LongTable:
|
|
|
813
1042
|
# Code axes leave the factor list unconditionally, so a variant that
|
|
814
1043
|
# quietly straddles two versions has to say so here — this is the log
|
|
815
1044
|
# half of what the GUI shows on the row.
|
|
816
|
-
spans = spanned_code_axes(
|
|
1045
|
+
spans = spanned_code_axes(
|
|
1046
|
+
frame[fresh], effective_selection(variant, table), table
|
|
1047
|
+
)
|
|
817
1048
|
if spans:
|
|
818
1049
|
Log.warn(
|
|
819
1050
|
"variant %r was built by MORE THAN ONE version of the code — "
|
|
@@ -858,5 +1089,36 @@ def apply_variant_sets(spec: PlotSpec, table: LongTable) -> LongTable:
|
|
|
858
1089
|
dict(zip(names, counts, strict=True)),
|
|
859
1090
|
layer=LAYER,
|
|
860
1091
|
)
|
|
1092
|
+
log_effective_selections(names, sets, counts, table)
|
|
861
1093
|
|
|
862
1094
|
return replace(table, frame=kept, factors=factors)
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def log_effective_selections(
|
|
1098
|
+
names: list[str],
|
|
1099
|
+
sets: list[VariantSet],
|
|
1100
|
+
counts: list[int],
|
|
1101
|
+
table: LongTable,
|
|
1102
|
+
) -> None:
|
|
1103
|
+
"""One INFO naming, per row, what it REALLY selected and why.
|
|
1104
|
+
|
|
1105
|
+
``name: stated|source default|frozen <selection> [frozen <cols>] -> N
|
|
1106
|
+
row(s)``. The diagnostic for the 2026-10-15 class of bug: if a banner or
|
|
1107
|
+
a count ever disagrees with the figure again, this line shows which
|
|
1108
|
+
selection the figure applied, beside whatever the other reader claims.
|
|
1109
|
+
"""
|
|
1110
|
+
parts = []
|
|
1111
|
+
for name, variant, count in zip(names, sets, counts, strict=True):
|
|
1112
|
+
selection, source = effective_selection_with_source(variant, table)
|
|
1113
|
+
frozen = active_frozen(variant, table)
|
|
1114
|
+
parts.append(
|
|
1115
|
+
f"{name!r}: {source} {selection}"
|
|
1116
|
+
+ (f" frozen {frozen}" if frozen else "")
|
|
1117
|
+
+ (" (follow latest)" if variant.follow_latest else "")
|
|
1118
|
+
+ f" -> {count} row(s)"
|
|
1119
|
+
)
|
|
1120
|
+
Log.info(
|
|
1121
|
+
"variant rows' effective selections: %s",
|
|
1122
|
+
"; ".join(parts),
|
|
1123
|
+
layer=LAYER,
|
|
1124
|
+
)
|
|
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
|