scistackplotdb 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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scistackplotdb
3
- Version: 0.1.35
3
+ Version: 0.1.37
4
4
  Summary: scidb-backed plotting: load variables into long tables and plot them with scistackplot
5
5
  Author: SciStack Contributors
6
6
  License-Expression: MIT
@@ -17,10 +17,10 @@ Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Topic :: Scientific/Engineering :: Visualization
18
18
  Classifier: Typing :: Typed
19
19
  Requires-Python: >=3.10
20
- Requires-Dist: sciduckdb==0.1.35
21
- Requires-Dist: scistack-db==0.1.35
22
- Requires-Dist: scistacklog==0.1.35
23
- Requires-Dist: scistackplot==0.1.35
20
+ Requires-Dist: sciduckdb==0.1.37
21
+ Requires-Dist: scistack-db==0.1.37
22
+ Requires-Dist: scistacklog==0.1.37
23
+ Requires-Dist: scistackplot==0.1.37
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: matplotlib>=3.6; extra == 'dev'
26
26
  Requires-Dist: pytest-cov>=4.0; extra == 'dev'
@@ -57,6 +57,7 @@ from .saved import (
57
57
  SavedPlotExists,
58
58
  SavedPlotInfo,
59
59
  check_name,
60
+ follow_latest_saved_plot,
60
61
  hide_saved_plot,
61
62
  list_saved_plots,
62
63
  load_saved_plot,
@@ -97,6 +98,7 @@ __all__ = [
97
98
  "rename_saved_plot",
98
99
  "hide_saved_plot",
99
100
  "saved_plot_history",
101
+ "follow_latest_saved_plot",
100
102
  "check_name",
101
103
  "SavedPlot",
102
104
  "SavedPlotInfo",
@@ -81,6 +81,7 @@ def generate_endpoint(
81
81
  function_source = generate_plot_function(spec, table, function_name=name)
82
82
  foreach_source = _foreach_call(
83
83
  spec=spec,
84
+ table=table,
84
85
  function_name=name,
85
86
  input_variable=input_variable,
86
87
  x_variable=x_variable,
@@ -140,6 +141,29 @@ def _pin_literal(value) -> str:
140
141
  return repr(list(value) if isinstance(value, tuple) else value)
141
142
 
142
143
 
144
+ def row_expression(input_variable: str, variant_set, table=None) -> str:
145
+ """One variant ROW as a ``Variant(...)`` load — through the row's
146
+ EFFECTIVE selection (``scistackplot.variants.export_selection``), so the
147
+ exported code loads what the preview drew: current code for a row that
148
+ names none, the frozen version for a reopened saved plot.
149
+
150
+ Grouping variables keep calling :func:`variant_expression` with their raw
151
+ selection: an empty grouping pin means "nothing filtered", not "current".
152
+ """
153
+ from types import SimpleNamespace
154
+
155
+ from scistackplot.variants import export_selection
156
+
157
+ # The TABLE's default, exactly what the preview applied: a stacked
158
+ # multi-variable table has none, so its rows export as written.
159
+ selection = export_selection(
160
+ variant_set,
161
+ latest_column=(table.latest_column if table is not None else None) or LATEST_COLUMN,
162
+ default_pin=table.default_pin if table is not None else None,
163
+ )
164
+ return variant_expression(input_variable, SimpleNamespace(selection=selection))
165
+
166
+
143
167
  def variant_expression(input_variable: str, variant_set) -> str:
144
168
  """A ``Variant(...)`` call selecting one named variant's records.
145
169
 
@@ -191,7 +215,7 @@ def variant_expression(input_variable: str, variant_set) -> str:
191
215
  return expression
192
216
 
193
217
 
194
- def _single_variant_expression(input_variable: str, spec) -> str:
218
+ def _single_variant_expression(input_variable: str, spec, table=None) -> str:
195
219
  """The lone variant's pin, or the bare variable when nothing is selected.
196
220
 
197
221
  The row may name its own variable, in which case that is what the endpoint
@@ -200,12 +224,13 @@ def _single_variant_expression(input_variable: str, spec) -> str:
200
224
  sets = defined_sets(spec.variant_sets)
201
225
  if len(sets) != 1:
202
226
  return input_variable
203
- return variant_expression(sets[0].variable or input_variable, sets[0])
227
+ return row_expression(sets[0].variable or input_variable, sets[0], table)
204
228
 
205
229
 
206
230
  def _foreach_call(
207
231
  *,
208
232
  spec: PlotSpec,
233
+ table: LongTable | None = None,
209
234
  function_name: str,
210
235
  input_variable: str,
211
236
  x_variable: str | None,
@@ -223,14 +248,14 @@ def _foreach_call(
223
248
  # the wrong variant's selection.
224
249
  inputs = [
225
250
  f' "{generated.param}": '
226
- f"{variant_expression(generated.variable, variant)},"
251
+ f"{row_expression(generated.variable, variant, table)},"
227
252
  for generated, variant in zip(
228
253
  variant_inputs, defined_sets(spec.variant_sets), strict=True
229
254
  )
230
255
  ]
231
256
  table_inputs = [generated.param for generated in variant_inputs]
232
257
  else:
233
- pinned = _single_variant_expression(input_variable, spec)
258
+ pinned = _single_variant_expression(input_variable, spec, table)
234
259
  inputs = [f' "df": {pinned},']
235
260
  table_inputs = ["df"]
236
261
  if x_variable:
@@ -24,6 +24,8 @@ import pandas as pd
24
24
  from scistacklog import Log
25
25
  from scistackplot import CODE_FACTOR_PREFIX, RUN_FACTOR_PREFIX
26
26
  from scistackplot.framesize import format_extent, frame_extent
27
+ from scistackplot.shape import Shape, classify_value
28
+ from scistackplot.table import natural_sort_key
27
29
 
28
30
  LAYER = "scistackplotdb"
29
31
 
@@ -425,7 +427,8 @@ def load_variable(
425
427
  ) as timer:
426
428
  with timer.phase("column_metadata"):
427
429
  keys = schema_keys(db)
428
- columns = data_columns_for(db, variable)
430
+ column_types = data_column_types_for(db, variable)
431
+ columns = list(column_types)
429
432
  if not columns:
430
433
  Log.warn("variable %r has no data columns", variable, layer=LAYER)
431
434
  return VariableFrame(name=variable, frame=pd.DataFrame())
@@ -513,6 +516,9 @@ def load_variable(
513
516
  variant_columns or "none",
514
517
  layer=LAYER,
515
518
  )
519
+ if columns and len(frame):
520
+ with timer.phase("field_coverage"):
521
+ _log_field_coverage(variable, frame, column_types, levels)
516
522
  if len(frame) > 1:
517
523
  with timer.phase("identical_content"):
518
524
  _log_identical_content(db, variable, frame, keys)
@@ -527,6 +533,157 @@ def load_variable(
527
533
  )
528
534
 
529
535
 
536
+ #: A scalar field that holds a number in fewer than this share of its
537
+ #: records is WARNed, with where its numbers are.
538
+ SPARSE_FIELD_SHARE = 0.5
539
+
540
+ #: How many distinct levels of a key, and how many text examples, a coverage
541
+ #: line names before it says "+N more".
542
+ _COVERAGE_LEVELS_SHOWN = 6
543
+
544
+
545
+ @dataclass(frozen=True)
546
+ class FieldCoverage:
547
+ """What one scalar data column holds, record by record: how many cells
548
+ are a number, NULL/NaN, text (with a few examples), or something else."""
549
+
550
+ column: str
551
+ declared_type: str
552
+ records: int
553
+ numeric: int
554
+ null: int
555
+ text: int
556
+ other: int
557
+ text_examples: tuple[str, ...] = ()
558
+
559
+ @property
560
+ def missing(self) -> int:
561
+ return self.records - self.numeric
562
+
563
+ def describe(self) -> str:
564
+ parts = [f"{self.null} NULL"] if self.null else []
565
+ if self.text:
566
+ examples = ", ".join(repr(t) for t in self.text_examples)
567
+ parts.append(f"{self.text} text (e.g. {examples})")
568
+ if self.other:
569
+ parts.append(f"{self.other} other")
570
+ return (
571
+ f"{self.column} ({self.declared_type}) {self.numeric}/{self.records} numeric"
572
+ + (f" ({', '.join(parts)})" if parts else "")
573
+ )
574
+
575
+
576
+ def field_coverage(
577
+ frame: pd.DataFrame, column_types: dict[str, str]
578
+ ) -> tuple[list[FieldCoverage], pd.DataFrame]:
579
+ """Per scalar column of ``frame``: what its cells hold. Array (1-D/2-D)
580
+ columns are skipped — a signal is not "missing a number". Also returns a
581
+ boolean frame, one column per covered field, True where the cell is a
582
+ number (for :func:`_log_field_coverage` to say WHERE the numbers are)."""
583
+
584
+ out: list[FieldCoverage] = []
585
+ is_number = pd.DataFrame(index=frame.index)
586
+ for column, declared in column_types.items():
587
+ if column not in frame.columns:
588
+ continue
589
+ values = frame[column]
590
+ if pd.api.types.is_numeric_dtype(values) and not pd.api.types.is_bool_dtype(values):
591
+ mask = values.notna()
592
+ out.append(
593
+ FieldCoverage(column, declared, len(values), int(mask.sum()), int((~mask).sum()), 0, 0)
594
+ )
595
+ is_number[column] = mask.to_numpy()
596
+ continue
597
+ numeric = null = text = other = 0
598
+ arrays = False
599
+ examples: list[str] = []
600
+ mask = []
601
+ for value in values.to_numpy():
602
+ if value is None or (isinstance(value, float) and value != value):
603
+ null += 1
604
+ mask.append(False)
605
+ elif classify_value(value) is Shape.SCALAR:
606
+ numeric += 1
607
+ mask.append(True)
608
+ elif isinstance(value, str):
609
+ text += 1
610
+ mask.append(False)
611
+ if value not in examples and len(examples) < 3:
612
+ examples.append(value)
613
+ elif isinstance(value, (list, tuple, np.ndarray)):
614
+ arrays = True
615
+ break
616
+ else:
617
+ other += 1
618
+ mask.append(False)
619
+ if arrays:
620
+ continue
621
+ out.append(
622
+ FieldCoverage(column, declared, len(values), numeric, null, text, other, tuple(examples))
623
+ )
624
+ is_number[column] = mask
625
+ return out, is_number
626
+
627
+
628
+ def _levels_text(values: pd.Series) -> str:
629
+ levels = sorted({str(v) for v in values if v is not None}, key=natural_sort_key)
630
+ shown = levels[:_COVERAGE_LEVELS_SHOWN]
631
+ more = len(levels) - len(shown)
632
+ return "{" + ", ".join(shown) + (f", +{more} more" if more else "") + "}"
633
+
634
+
635
+ def _log_field_coverage(
636
+ variable: str, frame: pd.DataFrame, column_types: dict[str, str], levels: list[str]
637
+ ) -> None:
638
+ """INFO: every scalar field that is not a number in every record. WARN,
639
+ per field, when fewer than :data:`SPARSE_FIELD_SHARE` of its records hold
640
+ one — naming the levels those records sit at, because a plot of such a
641
+ field shows only them and reads as a broken grouping ("Dist_m showed one
642
+ group/one subject": 6 of 714 records held a number, scidb.log
643
+ 2026-10-15). The plot is right; the data is what to look at.
644
+
645
+ Never raises: a diagnostic must not break a load.
646
+ """
647
+ try:
648
+ coverage, is_number = field_coverage(frame, column_types)
649
+ gaps = [c for c in coverage if c.missing]
650
+ if not gaps:
651
+ return
652
+ Log.info(
653
+ "field coverage of %s: %d of %d scalar field(s) are not a number in every "
654
+ "record — %s",
655
+ variable,
656
+ len(gaps),
657
+ len(coverage),
658
+ "; ".join(c.describe() for c in gaps),
659
+ layer=LAYER,
660
+ )
661
+ for c in gaps:
662
+ if c.numeric >= SPARSE_FIELD_SHARE * c.records:
663
+ continue
664
+ where = frame.loc[is_number[c.column].to_numpy(dtype=bool)]
665
+ located = ", ".join(
666
+ f"{key} ∈ {_levels_text(where[key])}"
667
+ for key in levels
668
+ if key in where.columns
669
+ )
670
+ Log.warn(
671
+ "%s.%s holds a number in only %d of %d record(s) — %s. A plot of it can "
672
+ "show only those records%s. If the source has values for the others, "
673
+ "they were not saved as numbers: check what the loader wrote for "
674
+ "this column.",
675
+ variable,
676
+ c.column,
677
+ c.numeric,
678
+ c.records,
679
+ c.describe(),
680
+ f", at {located}" if c.numeric and located else "",
681
+ layer=LAYER,
682
+ )
683
+ except Exception as exc: # noqa: BLE001 — diagnostic only
684
+ Log.debug("field coverage of %s failed: %s", variable, exc, layer=LAYER)
685
+
686
+
530
687
  def _log_identical_content(db, variable: str, frame: pd.DataFrame, keys) -> None:
531
688
  """Warn when records of ``variable`` hold byte-identical payloads, naming
532
689
  the schema keys that vary across each duplicate group.
@@ -0,0 +1,686 @@
1
+ """
2
+ Saved plots: named, per-variable Plot Studio figures kept in the project
3
+ database.
4
+
5
+ A saved plot is **display intent**. It changes nothing a run computes, so it is
6
+ not an aspect of the GUI's intent store (docs/claude/intent-and-fact.md §1).
7
+ It lives here, in the layer that knows both the database and the spec, so a
8
+ script can reopen a figure the GUI saved::
9
+
10
+ from scistackplotdb import list_saved_plots, load_saved_plot
11
+ saved = load_saved_plot(db, list_saved_plots(db, "StepLength")[0].plot_id)
12
+ render(ScidbSource(db).get_table(saved.spec.variant_variables()), saved.spec)
13
+
14
+ **One table, append-only.** ``_saved_plot`` holds one row per *version*.
15
+ Saving under a name that is already showing appends a version to that plot;
16
+ the newest is what the list shows and what opens. Nothing is ever deleted
17
+ (feedback_never_delete_mark_hidden): "remove" sets ``hidden`` on every
18
+ version of the plot, and the rows stay.
19
+
20
+ ``plot_id`` is the plot's identity, stable across renames. A name is a label.
21
+ Keying by name would make rename an update of an identity column, and would
22
+ make "a new plot reusing a removed plot's name" silently inherit its history.
23
+
24
+ **The stored spec is always the current format.** :func:`save_plot` reads the
25
+ incoming spec strictly, so what lands in the table is exactly what this
26
+ version writes. Old formats are only ever *read*, by
27
+ :func:`scistackplot.restore_spec`. The stored copy is never rewritten on
28
+ open; only an explicit save writes. See ``.claude/plan-saved-plots.md``.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from dataclasses import dataclass, field
34
+ from typing import Any, Callable
35
+
36
+ from scistacklog import Log
37
+
38
+ from ._versioned import MAX_NAME_LENGTH, VersionedStore # noqa: F401 (re-exported)
39
+
40
+ LAYER = "scistackplotdb"
41
+
42
+ TABLE = "_saved_plot"
43
+
44
+ #: The envelope's layout version. Written on every save and logged on every
45
+ #: load; nothing branches on it (salvage, never migrate).
46
+ ENVELOPE_FORMAT = 1
47
+
48
+
49
+ class SavedPlotError(ValueError):
50
+ """A save, rename or load that cannot be done as asked."""
51
+
52
+
53
+ class SavedPlotExists(SavedPlotError):
54
+ """``save_plot(overwrite=False)`` found ANOTHER visible plot with that name.
55
+
56
+ ``existing`` is that plot, so the caller can ask the user and retry with
57
+ ``overwrite=True``. The name comparison stays here, where names are
58
+ normalised, instead of in each caller.
59
+ """
60
+
61
+ def __init__(self, existing: "SavedPlotInfo"):
62
+ super().__init__(
63
+ f"{existing.variable} already has a saved plot named "
64
+ f"{existing.name!r} (version {existing.version})."
65
+ )
66
+ self.existing = existing
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class SavedPlotInfo:
71
+ """One version of a saved plot, without its contents: a list row."""
72
+
73
+ plot_id: str
74
+ variable: str
75
+ name: str
76
+ version: int
77
+ saved_at: str
78
+ hidden: bool = False
79
+
80
+ def to_dict(self) -> dict:
81
+ return {
82
+ "plot_id": self.plot_id,
83
+ "variable": self.variable,
84
+ "name": self.name,
85
+ "version": self.version,
86
+ "saved_at": self.saved_at,
87
+ "hidden": self.hidden,
88
+ }
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class SavedPlot:
93
+ """A saved plot, opened: the restored spec plus what did not survive."""
94
+
95
+ info: SavedPlotInfo
96
+ spec: Any # scistackplot.PlotSpec
97
+ #: The panel's own view settings (preview mode, aspect choice, figure
98
+ #: index), stored verbatim. The frontend owns their meaning and reads them
99
+ #: as leniently as the spec is read here.
100
+ view: dict = field(default_factory=dict)
101
+ #: ``scistackplot.RestoreNote`` s from restoring the spec and, when a table
102
+ #: was given, from reconciling it with today's data.
103
+ notes: list = field(default_factory=list)
104
+ #: The envelope format this version was written in.
105
+ stored_format: Any = None
106
+ #: Code versions in today's data NEWER than the ones this plot was saved
107
+ #: with: ``[{row, column, function, frozen, newer, newest, locations}]``
108
+ #: (:func:`thaw_code`). Non-empty is the GUI's cue to ask "switch to
109
+ #: latest?". Empty for a plot that follows latest or recorded no version.
110
+ newer: list = field(default_factory=list)
111
+
112
+ def to_dict(self) -> dict:
113
+ return {
114
+ **self.info.to_dict(),
115
+ "spec": self.spec.to_dict(),
116
+ "view": dict(self.view),
117
+ "notes": [note.to_dict() for note in self.notes],
118
+ "stored_format": self.stored_format,
119
+ "newer": [dict(entry) for entry in self.newer],
120
+ }
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Table
125
+ # ---------------------------------------------------------------------------
126
+
127
+ #: The rules (names, versions, hiding, never deleting) live in `_versioned`,
128
+ #: shared with presets so the two stores cannot drift apart.
129
+ STORE = VersionedStore(
130
+ table=TABLE,
131
+ id_column="plot_id",
132
+ scope_column="variable",
133
+ noun="saved plot",
134
+ tag="saved_plot",
135
+ error=SavedPlotError,
136
+ )
137
+
138
+
139
+ def ensure_table(db) -> None:
140
+ """Create ``_saved_plot`` if absent. Called by every write."""
141
+ STORE.ensure_table(db)
142
+
143
+
144
+ def _table_exists(db) -> bool:
145
+ """Reads never create the table: listing an untouched database must not
146
+ write to it."""
147
+ return STORE.table_exists(db)
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Writes
152
+ # ---------------------------------------------------------------------------
153
+
154
+
155
+ def save_plot(
156
+ db,
157
+ variable: str,
158
+ name: str,
159
+ spec,
160
+ view: dict | None = None,
161
+ *,
162
+ overwrite: bool = True,
163
+ current_plot_id: str | None = None,
164
+ table=None,
165
+ table_for: Callable[[Any], Any] | None = None,
166
+ ) -> SavedPlotInfo:
167
+ """Save *spec* under *name* for *variable*, returning the new version.
168
+
169
+ **Freezes the code it drew.** Given the table the plot draws from
170
+ (*table*, or *table_for* to load it from the spec), every variant row not
171
+ following latest records the function hashes of its rows
172
+ (:func:`freeze_code`), so the plot reopens on that version after the code
173
+ changes. Without a table nothing is frozen and the plot reopens on current
174
+ code; that is logged.
175
+
176
+ A visible plot of that name gets a new version, so the previous one is
177
+ kept as history. Otherwise a new plot is started, even when a removed
178
+ (hidden) plot had that name, because a new plot does not inherit an old
179
+ plot's history.
180
+
181
+ With ``overwrite=False``, adding a version to an existing plot is allowed
182
+ only when that plot is *current_plot_id* (the one the user has open).
183
+ Saving onto any OTHER plot's name raises :class:`SavedPlotExists` so the
184
+ caller can ask first.
185
+
186
+ *spec* is a ``PlotSpec`` or its dict. A dict is read **strictly**: the
187
+ panel always sends the current format, and a spec that does not parse is
188
+ a bug to surface, not something to store.
189
+ """
190
+ from scistackplot import PlotSpec, __version__ as plot_version
191
+
192
+ variable = _check_text(variable, "variable")
193
+ name = check_name(name)
194
+ if isinstance(spec, dict):
195
+ try:
196
+ spec = PlotSpec.from_dict(spec)
197
+ except Exception as exc:
198
+ raise SavedPlotError(f"The plot's settings could not be read: {exc}") from exc
199
+ if view is None:
200
+ view = {}
201
+ if not isinstance(view, dict):
202
+ raise SavedPlotError(f"view must be a table of settings, got {type(view).__name__}")
203
+
204
+ if table is None and table_for is not None:
205
+ try:
206
+ table = table_for(spec)
207
+ except Exception as exc:
208
+ Log.warn(
209
+ "[saved_plot] %s / %r: the data could not be loaded to freeze its "
210
+ "code version (%s); saving unfrozen",
211
+ variable,
212
+ name,
213
+ exc,
214
+ layer=LAYER,
215
+ )
216
+ if table is not None:
217
+ frozen_code = freeze_code(db, spec, table)
218
+ else:
219
+ frozen_code = {}
220
+ Log.info(
221
+ "[saved_plot] %s / %r: no table given, so no code version is frozen "
222
+ "(the plot reopens on current code)",
223
+ variable,
224
+ name,
225
+ layer=LAYER,
226
+ )
227
+
228
+ envelope = {
229
+ "format": ENVELOPE_FORMAT,
230
+ "spec": _strip_frozen(spec).to_dict(),
231
+ "view": view,
232
+ "saved_with": {"scistackplot": plot_version},
233
+ FROZEN_KEY: frozen_code,
234
+ }
235
+ row, _new = STORE.save(
236
+ db,
237
+ variable,
238
+ name,
239
+ envelope,
240
+ overwrite=overwrite,
241
+ current_id=current_plot_id,
242
+ on_clash=lambda clash: SavedPlotExists(_info(clash)),
243
+ )
244
+ return _info(row)
245
+
246
+
247
+ def rename_saved_plot(db, plot_id: str, new_name: str) -> SavedPlotInfo:
248
+ """Rename a plot (every version). Refused when another visible plot of
249
+ the same variable already has that name."""
250
+ return _info(STORE.rename(db, plot_id, new_name))
251
+
252
+
253
+ def hide_saved_plot(db, plot_id: str, hidden: bool = True) -> SavedPlotInfo:
254
+ """Remove a plot from the list, keeping every row. ``hidden=False``
255
+ brings it back."""
256
+ return _info(STORE.set_hidden(db, plot_id, hidden))
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # Reads
261
+ # ---------------------------------------------------------------------------
262
+
263
+
264
+ def list_saved_plots(db, variable: str, *, include_hidden: bool = False) -> list[SavedPlotInfo]:
265
+ """The newest version of each saved plot of *variable*, newest first."""
266
+ return [_info(row) for row in STORE.list_rows(db, variable, include_hidden=include_hidden)]
267
+
268
+
269
+ def saved_plot_history(db, plot_id: str) -> list[SavedPlotInfo]:
270
+ """Every version of one plot, newest first."""
271
+ return [_info(row) for row in STORE.history(db, plot_id)]
272
+
273
+
274
+ def load_saved_plot(
275
+ db,
276
+ plot_id: str,
277
+ *,
278
+ version: int | None = None,
279
+ table=None,
280
+ table_for: Callable[[Any], Any] | None = None,
281
+ ) -> SavedPlot:
282
+ """Open a saved plot (its newest version unless *version* is given).
283
+
284
+ The spec is read with :func:`scistackplot.restore_spec`, so a plot saved
285
+ by an older version of the plotting layer still opens. Settings that no
286
+ longer exist or no longer parse come back as notes.
287
+
288
+ To also reconcile the spec with today's data, pass either *table* (the
289
+ ``LongTable`` the plot will draw from) or *table_for*, a callable that
290
+ loads that table from the RESTORED spec. The table depends on the spec
291
+ (its variant rows and factor variables decide what gets loaded), so a
292
+ caller that cannot know the spec in advance hands over the loader. The
293
+ GUI does this with its cached source. A loader that raises does not fail
294
+ the open: the plot opens unreconciled, with a note saying why.
295
+
296
+ Never rewrites the stored copy: a later build may salvage more of it.
297
+ """
298
+ from scistackplot import NoteKind, RestoreNote, reconcile, restore_spec
299
+
300
+ row, text = STORE.load(db, plot_id, version)
301
+ info = _info(row)
302
+ envelope = STORE.parse_envelope(
303
+ text, f"{info.variable} / {info.name!r} version {info.version}"
304
+ )
305
+
306
+ restored = restore_spec(envelope.get("spec"), fallback_measure=info.variable)
307
+ spec, notes = restored.spec, list(restored.notes)
308
+ if table is None and table_for is not None:
309
+ try:
310
+ table = table_for(spec)
311
+ except Exception as exc:
312
+ Log.warn(
313
+ "[saved_plot] %s / %r: today's data could not be loaded for "
314
+ "reconciling (%s); opening unreconciled",
315
+ info.variable,
316
+ info.name,
317
+ exc,
318
+ layer=LAYER,
319
+ )
320
+ notes.append(
321
+ RestoreNote(
322
+ "",
323
+ NoteKind.NOT_IN_DATA,
324
+ f"today's data could not be loaded, so the settings were "
325
+ f"not checked against it: {exc}",
326
+ )
327
+ )
328
+ newer: list = []
329
+ frozen_code = envelope.get(FROZEN_KEY)
330
+ if table is not None and isinstance(frozen_code, dict) and frozen_code:
331
+ # Before reconcile, so it checks the roles against the table the
332
+ # FROZEN rows derive (docs/claude/variant-row-selection.md).
333
+ spec, newer, thaw_notes = thaw_code(
334
+ db, spec, table, frozen_code, label=f"{info.variable} / {info.name!r}"
335
+ )
336
+ notes.extend(thaw_notes)
337
+ elif not frozen_code:
338
+ # Saved before freezing existed, or with no table: opens on current
339
+ # code with no prompt. Nothing is back-filled (no migrations).
340
+ Log.info(
341
+ "[saved_plot] %s / %r: no frozen code version stored; opening on "
342
+ "current code",
343
+ info.variable,
344
+ info.name,
345
+ layer=LAYER,
346
+ )
347
+ if table is not None:
348
+ reconciled = reconcile(spec, table)
349
+ spec, notes = reconciled.spec, notes + list(reconciled.notes)
350
+
351
+ view = envelope.get("view")
352
+ if not isinstance(view, dict):
353
+ Log.warn(
354
+ "[saved_plot] %s / %r: stored view settings are not a table (%r); "
355
+ "opening with the panel's defaults",
356
+ info.variable,
357
+ info.name,
358
+ view,
359
+ layer=LAYER,
360
+ )
361
+ view = {}
362
+
363
+ stored_format = envelope.get("format")
364
+ Log.info(
365
+ "[saved_plot] opened %s / %r version %d (envelope format %r, saved with %s): "
366
+ "%d note(s)",
367
+ info.variable,
368
+ info.name,
369
+ info.version,
370
+ stored_format,
371
+ envelope.get("saved_with"),
372
+ len(notes),
373
+ layer=LAYER,
374
+ )
375
+ return SavedPlot(
376
+ info=info,
377
+ spec=spec,
378
+ view=view,
379
+ notes=notes,
380
+ stored_format=stored_format,
381
+ newer=newer,
382
+ )
383
+
384
+
385
+ # ---------------------------------------------------------------------------
386
+ # Helpers
387
+ # ---------------------------------------------------------------------------
388
+
389
+
390
+ def check_name(name: Any) -> str:
391
+ """A saved plot's name, trimmed, or :class:`SavedPlotError` saying why not.
392
+
393
+ The one statement of what a name may be (``VersionedStore.check_name``);
394
+ the GUI asks the backend rather than repeating the rule.
395
+ """
396
+ return STORE.check_name(name)
397
+
398
+
399
+ def _check_text(value: Any, what: str) -> str:
400
+ if not isinstance(value, str) or not value.strip():
401
+ raise SavedPlotError(f"A saved plot needs a {what}.")
402
+ return value.strip()
403
+
404
+
405
+ def _info(row) -> SavedPlotInfo:
406
+ plot_id, variable, name, version, saved_at, hidden = row
407
+ return SavedPlotInfo(
408
+ plot_id=str(plot_id),
409
+ variable=str(variable),
410
+ name=str(name),
411
+ version=int(version),
412
+ saved_at=str(saved_at),
413
+ hidden=bool(hidden),
414
+ )
415
+
416
+
417
+ # ---------------------------------------------------------------------------
418
+ # Frozen code versions (docs/claude/variant-row-selection.md)
419
+ # ---------------------------------------------------------------------------
420
+ #
421
+ # A saved plot remembers WHICH CODE drew it. At save time every variant row
422
+ # that does not follow latest records the function hashes of the rows it drew
423
+ # (``frozen_code`` in the envelope). On open, those hashes become today's
424
+ # ordinals in ``VariantSet.frozen`` — so the figure reopens on the version it
425
+ # was saved with — and anything newer is reported as ``SavedPlot.newer`` for
426
+ # the GUI's "switch to latest?" prompt. Hashes, not ordinals, are stored
427
+ # because ordinals renumber after a real Variants-popup delete.
428
+
429
+ #: Envelope key: ``{str(row_index): {function_name: [function_hash, ...]}}``.
430
+ FROZEN_KEY = "frozen_code"
431
+
432
+
433
+ def _row_masks(spec, table) -> list[tuple[int, Any]]:
434
+ """``[(row_index, mask)]`` — the rows each variant row DRAWS, by the same
435
+ owner and first-match-wins order the figure uses
436
+ (``scistackplot.variants.row_mask`` / ``apply_variant_sets``).
437
+
438
+ With no row concrete, the figure draws an empty row's effective selection;
439
+ that is frozen as row 0 (synthesised on open if the spec has none).
440
+ """
441
+ import pandas as pd
442
+ from scistackplot.spec import VariantSet
443
+ from scistackplot.variants import defined_sets, is_stated, row_mask
444
+
445
+ frame = table.frame
446
+ if not defined_sets(spec.variant_sets):
447
+ first = spec.variant_sets[0] if spec.variant_sets else VariantSet()
448
+ return [(0, row_mask(table, spec, first))]
449
+ claimed = pd.Series(False, index=frame.index)
450
+ out = []
451
+ for index, variant in enumerate(spec.variant_sets):
452
+ if not is_stated(variant, index):
453
+ continue
454
+ fresh = row_mask(table, spec, variant) & ~claimed
455
+ claimed |= fresh
456
+ out.append((index, fresh))
457
+ return out
458
+
459
+
460
+ def freeze_code(db, spec, table) -> dict[str, dict[str, list[str]]]:
461
+ """The envelope's ``frozen_code``: per variant row not following latest,
462
+ every function in the drawn records' chains and the hash(es) it ran.
463
+
464
+ Works with ONE version too (the usual state at save time): there is no
465
+ ``Code:`` column then, but the chain still names the hash, and that is
466
+ what lets the plot reopen on it after a second version appears.
467
+ """
468
+ from scidb.provenance_query import code_versions_batch
469
+
470
+ frame = table.frame
471
+ if "record_id" not in frame.columns:
472
+ Log.warn(
473
+ "[saved_plot] %r: the table has no record_id column, so no code "
474
+ "version can be frozen — the plot will reopen on current code",
475
+ getattr(table, "name", None),
476
+ layer=LAYER,
477
+ )
478
+ return {}
479
+ rows = _row_masks(spec, table)
480
+ follow = {
481
+ index for index, variant in enumerate(spec.variant_sets) if variant.follow_latest
482
+ }
483
+ wanted = {index: mask for index, mask in rows if index not in follow}
484
+ record_ids = sorted(
485
+ {
486
+ str(rid)
487
+ for mask in wanted.values()
488
+ for rid in frame.loc[mask, "record_id"].dropna().unique()
489
+ }
490
+ )
491
+ chains = code_versions_batch(db._duck, record_ids) if record_ids else {}
492
+ frozen: dict[str, dict[str, list[str]]] = {}
493
+ for index, mask in wanted.items():
494
+ by_fn: dict[str, set] = {}
495
+ for rid in frame.loc[mask, "record_id"].dropna().unique():
496
+ for fn_name, fn_hash in (chains.get(str(rid)) or {}).items():
497
+ by_fn.setdefault(fn_name, set()).add(fn_hash)
498
+ if by_fn:
499
+ frozen[str(index)] = {fn: sorted(hashes) for fn, hashes in sorted(by_fn.items())}
500
+ Log.info(
501
+ "[saved_plot] froze code for %d row(s) over %d record(s): %s%s",
502
+ len(frozen),
503
+ len(record_ids),
504
+ {
505
+ index: {fn: [h[:12] for h in hashes] for fn, hashes in fns.items()}
506
+ for index, fns in frozen.items()
507
+ },
508
+ f" (rows following latest: {sorted(follow)})" if follow else "",
509
+ layer=LAYER,
510
+ )
511
+ return frozen
512
+
513
+
514
+ def _strip_frozen(spec):
515
+ """The spec as stored: ``frozen`` ordinals are derived on open from the
516
+ envelope's hashes, never stored (an ordinal can renumber)."""
517
+ from dataclasses import replace
518
+
519
+ return replace(
520
+ spec,
521
+ variant_sets=[replace(v, frozen={}) for v in spec.variant_sets],
522
+ )
523
+
524
+
525
+ def _code_columns(table) -> dict[str, str]:
526
+ """``{function_name: column}`` for every code axis the table carries."""
527
+ out = {}
528
+ for factor in table.factors:
529
+ origin = factor.origin or {}
530
+ if origin.get("kind") == "code" and origin.get("function"):
531
+ out[origin["function"]] = factor.name
532
+ return out
533
+
534
+
535
+ def thaw_code(db, spec, table, frozen_code: dict, *, label: str = ""):
536
+ """``(spec, newer, notes)`` — apply a stored freeze to today's data.
537
+
538
+ For each frozen row and function that is CURRENTLY an axis (two or more
539
+ versions, so a ``Code:`` column), the stored hashes become today's
540
+ ordinals in ``VariantSet.frozen``. A single-version function needs
541
+ nothing. ``newer`` lists, per row and function, the versions present in
542
+ the data that are newer than every frozen one — what the GUI asks about.
543
+
544
+ A frozen hash that no longer exists (its version was deleted) is NOT
545
+ silently replaced: it gets a note, and the row falls back to the
546
+ remaining frozen versions or, with none left, to current code — which the
547
+ note says, and ``newer`` offers to make permanent.
548
+ """
549
+ from dataclasses import replace
550
+
551
+ from scidb.provenance_query import function_versions
552
+ from scistackplot import NoteKind, RestoreNote
553
+ from scistackplot.spec import VariantSet
554
+ from scistackplot.variants import _ordinal_rank
555
+
556
+ if not frozen_code:
557
+ return spec, [], []
558
+ columns = _code_columns(table)
559
+ fn_names = sorted({fn for fns in frozen_code.values() for fn in fns})
560
+ versions = function_versions(db._duck, fn_names)
561
+ sets = list(spec.variant_sets)
562
+ newer: list[dict] = []
563
+ notes: list = []
564
+ frame = table.frame
565
+ levels = [key for key in table.schema_levels if key in frame.columns]
566
+
567
+ for raw_index, fns in sorted(frozen_code.items(), key=lambda kv: int(kv[0])):
568
+ index = int(raw_index)
569
+ if index >= len(sets):
570
+ if index == 0 and not sets:
571
+ sets.append(VariantSet())
572
+ else:
573
+ continue
574
+ variant = sets[index]
575
+ if variant.follow_latest:
576
+ continue
577
+ frozen: dict[str, list[str]] = {}
578
+ for fn_name, hashes in fns.items():
579
+ by_hash = {v["function_hash"]: v["version"] for v in versions.get(fn_name, [])}
580
+ ordinals = [by_hash[h] for h in hashes if h in by_hash]
581
+ missing = [h for h in hashes if h not in by_hash]
582
+ if missing:
583
+ notes.append(
584
+ RestoreNote(
585
+ f"variant_sets[{index}]",
586
+ NoteKind.NOT_IN_DATA,
587
+ f"the {fn_name} version(s) this plot was saved with "
588
+ f"({', '.join(h[:12] for h in missing)}) no longer exist"
589
+ + ("" if ordinals else "; showing the current version instead"),
590
+ )
591
+ )
592
+ column = columns.get(fn_name)
593
+ if column is None:
594
+ continue # one version today: nothing to choose between
595
+ if ordinals:
596
+ frozen[column] = sorted(set(ordinals), key=_ordinal_rank)
597
+ present = frame[column].dropna().astype(str)
598
+ top = max((_ordinal_rank(o) for o in ordinals), default=0)
599
+ later = sorted(
600
+ {level for level in present.unique() if _ordinal_rank(level) > top},
601
+ key=_ordinal_rank,
602
+ )
603
+ if later:
604
+ at = frame[frame[column].astype(str).isin(later)]
605
+ newer.append(
606
+ {
607
+ "row": index,
608
+ "column": column,
609
+ "function": fn_name,
610
+ "frozen": list(frozen.get(column, [])),
611
+ "newer": later,
612
+ "newest": later[-1],
613
+ "locations": int(len(at[levels].drop_duplicates())) if levels else int(len(at)),
614
+ }
615
+ )
616
+ if frozen:
617
+ sets[index] = replace(variant, frozen=frozen)
618
+
619
+ Log.info(
620
+ "[saved_plot] %s: frozen code -> %s; newer: %s",
621
+ label,
622
+ {i: v.frozen for i, v in enumerate(sets) if v.frozen} or "nothing to pin",
623
+ [f"row {n['row']} {n['function']} {n['frozen']} < {n['newer']}" for n in newer]
624
+ or "none",
625
+ layer=LAYER,
626
+ )
627
+ return replace(spec, variant_sets=sets), newer, notes
628
+
629
+
630
+ def follow_latest_saved_plot(db, plot_id: str, *, table=None) -> SavedPlotInfo:
631
+ """Answer "switch to latest" for a saved plot: append a version whose rows
632
+ follow latest code from now on (never frozen again, never asked again).
633
+
634
+ Each row drops its frozen pins AND its own code choices (``Code:``/``Run:``
635
+ keys and the latest flag) — a row still saying ``Code:f = v1`` would keep
636
+ drawing v1. What remains (branch params, the variable) is kept, and the
637
+ row then draws current code by the effective-selection rule. Appends, as
638
+ any re-save does; older versions stay.
639
+ """
640
+ from dataclasses import replace
641
+
642
+ from scistackplot import restore_spec
643
+ from scistackplot.spec import VariantSet
644
+ from scistackplot.variants import is_function_axis
645
+
646
+ from .load import LATEST_COLUMN
647
+
648
+ row, text = STORE.load(db, plot_id, None)
649
+ info = _info(row)
650
+ envelope = STORE.parse_envelope(text, f"{info.variable} / {info.name!r}")
651
+ spec = restore_spec(envelope.get("spec"), fallback_measure=info.variable).spec
652
+ sets = list(spec.variant_sets) or [VariantSet()]
653
+ sets = [
654
+ replace(
655
+ variant,
656
+ frozen={},
657
+ follow_latest=True,
658
+ selection={
659
+ key: value
660
+ for key, value in variant.selection.items()
661
+ if not is_function_axis(key) and key != LATEST_COLUMN
662
+ },
663
+ )
664
+ for variant in sets
665
+ ]
666
+ spec = replace(spec, variant_sets=sets)
667
+ view = envelope.get("view") if isinstance(envelope.get("view"), dict) else {}
668
+ new_info = save_plot(
669
+ db,
670
+ info.variable,
671
+ info.name,
672
+ spec,
673
+ view,
674
+ overwrite=True,
675
+ current_plot_id=plot_id,
676
+ table=table,
677
+ )
678
+ Log.info(
679
+ "[saved_plot] %s / %r now follows latest code (version %d -> %d)",
680
+ info.variable,
681
+ info.name,
682
+ info.version,
683
+ new_info.version,
684
+ layer=LAYER,
685
+ )
686
+ return new_info
@@ -1,350 +0,0 @@
1
- """
2
- Saved plots: named, per-variable Plot Studio figures kept in the project
3
- database.
4
-
5
- A saved plot is **display intent**. It changes nothing a run computes, so it is
6
- not an aspect of the GUI's intent store (docs/claude/intent-and-fact.md §1).
7
- It lives here, in the layer that knows both the database and the spec, so a
8
- script can reopen a figure the GUI saved::
9
-
10
- from scistackplotdb import list_saved_plots, load_saved_plot
11
- saved = load_saved_plot(db, list_saved_plots(db, "StepLength")[0].plot_id)
12
- render(ScidbSource(db).get_table(saved.spec.variant_variables()), saved.spec)
13
-
14
- **One table, append-only.** ``_saved_plot`` holds one row per *version*.
15
- Saving under a name that is already showing appends a version to that plot;
16
- the newest is what the list shows and what opens. Nothing is ever deleted
17
- (feedback_never_delete_mark_hidden): "remove" sets ``hidden`` on every
18
- version of the plot, and the rows stay.
19
-
20
- ``plot_id`` is the plot's identity, stable across renames. A name is a label.
21
- Keying by name would make rename an update of an identity column, and would
22
- make "a new plot reusing a removed plot's name" silently inherit its history.
23
-
24
- **The stored spec is always the current format.** :func:`save_plot` reads the
25
- incoming spec strictly, so what lands in the table is exactly what this
26
- version writes. Old formats are only ever *read*, by
27
- :func:`scistackplot.restore_spec`. The stored copy is never rewritten on
28
- open; only an explicit save writes. See ``.claude/plan-saved-plots.md``.
29
- """
30
-
31
- from __future__ import annotations
32
-
33
- from dataclasses import dataclass, field
34
- from typing import Any, Callable
35
-
36
- from scistacklog import Log
37
-
38
- from ._versioned import MAX_NAME_LENGTH, VersionedStore # noqa: F401 (re-exported)
39
-
40
- LAYER = "scistackplotdb"
41
-
42
- TABLE = "_saved_plot"
43
-
44
- #: The envelope's layout version. Written on every save and logged on every
45
- #: load; nothing branches on it (salvage, never migrate).
46
- ENVELOPE_FORMAT = 1
47
-
48
-
49
- class SavedPlotError(ValueError):
50
- """A save, rename or load that cannot be done as asked."""
51
-
52
-
53
- class SavedPlotExists(SavedPlotError):
54
- """``save_plot(overwrite=False)`` found ANOTHER visible plot with that name.
55
-
56
- ``existing`` is that plot, so the caller can ask the user and retry with
57
- ``overwrite=True``. The name comparison stays here, where names are
58
- normalised, instead of in each caller.
59
- """
60
-
61
- def __init__(self, existing: "SavedPlotInfo"):
62
- super().__init__(
63
- f"{existing.variable} already has a saved plot named "
64
- f"{existing.name!r} (version {existing.version})."
65
- )
66
- self.existing = existing
67
-
68
-
69
- @dataclass(frozen=True)
70
- class SavedPlotInfo:
71
- """One version of a saved plot, without its contents: a list row."""
72
-
73
- plot_id: str
74
- variable: str
75
- name: str
76
- version: int
77
- saved_at: str
78
- hidden: bool = False
79
-
80
- def to_dict(self) -> dict:
81
- return {
82
- "plot_id": self.plot_id,
83
- "variable": self.variable,
84
- "name": self.name,
85
- "version": self.version,
86
- "saved_at": self.saved_at,
87
- "hidden": self.hidden,
88
- }
89
-
90
-
91
- @dataclass(frozen=True)
92
- class SavedPlot:
93
- """A saved plot, opened: the restored spec plus what did not survive."""
94
-
95
- info: SavedPlotInfo
96
- spec: Any # scistackplot.PlotSpec
97
- #: The panel's own view settings (preview mode, aspect choice, figure
98
- #: index), stored verbatim. The frontend owns their meaning and reads them
99
- #: as leniently as the spec is read here.
100
- view: dict = field(default_factory=dict)
101
- #: ``scistackplot.RestoreNote`` s from restoring the spec and, when a table
102
- #: was given, from reconciling it with today's data.
103
- notes: list = field(default_factory=list)
104
- #: The envelope format this version was written in.
105
- stored_format: Any = None
106
-
107
- def to_dict(self) -> dict:
108
- return {
109
- **self.info.to_dict(),
110
- "spec": self.spec.to_dict(),
111
- "view": dict(self.view),
112
- "notes": [note.to_dict() for note in self.notes],
113
- "stored_format": self.stored_format,
114
- }
115
-
116
-
117
- # ---------------------------------------------------------------------------
118
- # Table
119
- # ---------------------------------------------------------------------------
120
-
121
- #: The rules (names, versions, hiding, never deleting) live in `_versioned`,
122
- #: shared with presets so the two stores cannot drift apart.
123
- STORE = VersionedStore(
124
- table=TABLE,
125
- id_column="plot_id",
126
- scope_column="variable",
127
- noun="saved plot",
128
- tag="saved_plot",
129
- error=SavedPlotError,
130
- )
131
-
132
-
133
- def ensure_table(db) -> None:
134
- """Create ``_saved_plot`` if absent. Called by every write."""
135
- STORE.ensure_table(db)
136
-
137
-
138
- def _table_exists(db) -> bool:
139
- """Reads never create the table: listing an untouched database must not
140
- write to it."""
141
- return STORE.table_exists(db)
142
-
143
-
144
- # ---------------------------------------------------------------------------
145
- # Writes
146
- # ---------------------------------------------------------------------------
147
-
148
-
149
- def save_plot(
150
- db,
151
- variable: str,
152
- name: str,
153
- spec,
154
- view: dict | None = None,
155
- *,
156
- overwrite: bool = True,
157
- current_plot_id: str | None = None,
158
- ) -> SavedPlotInfo:
159
- """Save *spec* under *name* for *variable*, returning the new version.
160
-
161
- A visible plot of that name gets a new version, so the previous one is
162
- kept as history. Otherwise a new plot is started, even when a removed
163
- (hidden) plot had that name, because a new plot does not inherit an old
164
- plot's history.
165
-
166
- With ``overwrite=False``, adding a version to an existing plot is allowed
167
- only when that plot is *current_plot_id* (the one the user has open).
168
- Saving onto any OTHER plot's name raises :class:`SavedPlotExists` so the
169
- caller can ask first.
170
-
171
- *spec* is a ``PlotSpec`` or its dict. A dict is read **strictly**: the
172
- panel always sends the current format, and a spec that does not parse is
173
- a bug to surface, not something to store.
174
- """
175
- from scistackplot import PlotSpec, __version__ as plot_version
176
-
177
- variable = _check_text(variable, "variable")
178
- name = check_name(name)
179
- if isinstance(spec, dict):
180
- try:
181
- spec = PlotSpec.from_dict(spec)
182
- except Exception as exc:
183
- raise SavedPlotError(f"The plot's settings could not be read: {exc}") from exc
184
- if view is None:
185
- view = {}
186
- if not isinstance(view, dict):
187
- raise SavedPlotError(f"view must be a table of settings, got {type(view).__name__}")
188
-
189
- envelope = {
190
- "format": ENVELOPE_FORMAT,
191
- "spec": spec.to_dict(),
192
- "view": view,
193
- "saved_with": {"scistackplot": plot_version},
194
- }
195
- row, _new = STORE.save(
196
- db,
197
- variable,
198
- name,
199
- envelope,
200
- overwrite=overwrite,
201
- current_id=current_plot_id,
202
- on_clash=lambda clash: SavedPlotExists(_info(clash)),
203
- )
204
- return _info(row)
205
-
206
-
207
- def rename_saved_plot(db, plot_id: str, new_name: str) -> SavedPlotInfo:
208
- """Rename a plot (every version). Refused when another visible plot of
209
- the same variable already has that name."""
210
- return _info(STORE.rename(db, plot_id, new_name))
211
-
212
-
213
- def hide_saved_plot(db, plot_id: str, hidden: bool = True) -> SavedPlotInfo:
214
- """Remove a plot from the list, keeping every row. ``hidden=False``
215
- brings it back."""
216
- return _info(STORE.set_hidden(db, plot_id, hidden))
217
-
218
-
219
- # ---------------------------------------------------------------------------
220
- # Reads
221
- # ---------------------------------------------------------------------------
222
-
223
-
224
- def list_saved_plots(db, variable: str, *, include_hidden: bool = False) -> list[SavedPlotInfo]:
225
- """The newest version of each saved plot of *variable*, newest first."""
226
- return [_info(row) for row in STORE.list_rows(db, variable, include_hidden=include_hidden)]
227
-
228
-
229
- def saved_plot_history(db, plot_id: str) -> list[SavedPlotInfo]:
230
- """Every version of one plot, newest first."""
231
- return [_info(row) for row in STORE.history(db, plot_id)]
232
-
233
-
234
- def load_saved_plot(
235
- db,
236
- plot_id: str,
237
- *,
238
- version: int | None = None,
239
- table=None,
240
- table_for: Callable[[Any], Any] | None = None,
241
- ) -> SavedPlot:
242
- """Open a saved plot (its newest version unless *version* is given).
243
-
244
- The spec is read with :func:`scistackplot.restore_spec`, so a plot saved
245
- by an older version of the plotting layer still opens. Settings that no
246
- longer exist or no longer parse come back as notes.
247
-
248
- To also reconcile the spec with today's data, pass either *table* (the
249
- ``LongTable`` the plot will draw from) or *table_for*, a callable that
250
- loads that table from the RESTORED spec. The table depends on the spec
251
- (its variant rows and factor variables decide what gets loaded), so a
252
- caller that cannot know the spec in advance hands over the loader. The
253
- GUI does this with its cached source. A loader that raises does not fail
254
- the open: the plot opens unreconciled, with a note saying why.
255
-
256
- Never rewrites the stored copy: a later build may salvage more of it.
257
- """
258
- from scistackplot import NoteKind, RestoreNote, reconcile, restore_spec
259
-
260
- row, text = STORE.load(db, plot_id, version)
261
- info = _info(row)
262
- envelope = STORE.parse_envelope(
263
- text, f"{info.variable} / {info.name!r} version {info.version}"
264
- )
265
-
266
- restored = restore_spec(envelope.get("spec"), fallback_measure=info.variable)
267
- spec, notes = restored.spec, list(restored.notes)
268
- if table is None and table_for is not None:
269
- try:
270
- table = table_for(spec)
271
- except Exception as exc:
272
- Log.warn(
273
- "[saved_plot] %s / %r: today's data could not be loaded for "
274
- "reconciling (%s); opening unreconciled",
275
- info.variable,
276
- info.name,
277
- exc,
278
- layer=LAYER,
279
- )
280
- notes.append(
281
- RestoreNote(
282
- "",
283
- NoteKind.NOT_IN_DATA,
284
- f"today's data could not be loaded, so the settings were "
285
- f"not checked against it: {exc}",
286
- )
287
- )
288
- if table is not None:
289
- reconciled = reconcile(spec, table)
290
- spec, notes = reconciled.spec, notes + list(reconciled.notes)
291
-
292
- view = envelope.get("view")
293
- if not isinstance(view, dict):
294
- Log.warn(
295
- "[saved_plot] %s / %r: stored view settings are not a table (%r); "
296
- "opening with the panel's defaults",
297
- info.variable,
298
- info.name,
299
- view,
300
- layer=LAYER,
301
- )
302
- view = {}
303
-
304
- stored_format = envelope.get("format")
305
- Log.info(
306
- "[saved_plot] opened %s / %r version %d (envelope format %r, saved with %s): "
307
- "%d note(s)",
308
- info.variable,
309
- info.name,
310
- info.version,
311
- stored_format,
312
- envelope.get("saved_with"),
313
- len(notes),
314
- layer=LAYER,
315
- )
316
- return SavedPlot(
317
- info=info, spec=spec, view=view, notes=notes, stored_format=stored_format
318
- )
319
-
320
-
321
- # ---------------------------------------------------------------------------
322
- # Helpers
323
- # ---------------------------------------------------------------------------
324
-
325
-
326
- def check_name(name: Any) -> str:
327
- """A saved plot's name, trimmed, or :class:`SavedPlotError` saying why not.
328
-
329
- The one statement of what a name may be (``VersionedStore.check_name``);
330
- the GUI asks the backend rather than repeating the rule.
331
- """
332
- return STORE.check_name(name)
333
-
334
-
335
- def _check_text(value: Any, what: str) -> str:
336
- if not isinstance(value, str) or not value.strip():
337
- raise SavedPlotError(f"A saved plot needs a {what}.")
338
- return value.strip()
339
-
340
-
341
- def _info(row) -> SavedPlotInfo:
342
- plot_id, variable, name, version, saved_at, hidden = row
343
- return SavedPlotInfo(
344
- plot_id=str(plot_id),
345
- variable=str(variable),
346
- name=str(name),
347
- version=int(version),
348
- saved_at=str(saved_at),
349
- hidden=bool(hidden),
350
- )