scistackplot 0.1.43__tar.gz → 0.1.45__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.
Files changed (50) hide show
  1. {scistackplot-0.1.43 → scistackplot-0.1.45}/PKG-INFO +3 -3
  2. {scistackplot-0.1.43 → scistackplot-0.1.45}/pyproject.toml +3 -1
  3. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/cell.py +49 -2
  4. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/reduce.py +7 -1
  5. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/resolved.py +30 -25
  6. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/ylimits.py +3 -1
  7. {scistackplot-0.1.43 → scistackplot-0.1.45}/.gitignore +0 -0
  8. {scistackplot-0.1.43 → scistackplot-0.1.45}/README.md +0 -0
  9. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/__init__.py +0 -0
  10. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/aliases.py +0 -0
  11. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/autosize.py +0 -0
  12. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/capability.py +0 -0
  13. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/codegen.py +0 -0
  14. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/colors.py +0 -0
  15. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/compare.py +0 -0
  16. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/custom.py +0 -0
  17. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/dedup.py +0 -0
  18. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/design.py +0 -0
  19. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/diffbars.py +0 -0
  20. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/export.py +0 -0
  21. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/figsize.py +0 -0
  22. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/figure_file.py +0 -0
  23. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/framesize.py +0 -0
  24. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/groups.py +0 -0
  25. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/numeric.py +0 -0
  26. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/panels.py +0 -0
  27. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/paper.py +0 -0
  28. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/presets.py +0 -0
  29. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/reducer.py +0 -0
  30. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/render/__init__.py +0 -0
  31. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/render/base.py +0 -0
  32. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/render/mpl.py +0 -0
  33. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/render/plotly_.py +0 -0
  34. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/restore.py +0 -0
  35. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/roles.py +0 -0
  36. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/series_stats.py +0 -0
  37. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/shape.py +0 -0
  38. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/sources/__init__.py +0 -0
  39. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/sources/base.py +0 -0
  40. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/sources/csv.py +0 -0
  41. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/sources/frame.py +0 -0
  42. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/spaghetti.py +0 -0
  43. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/spec.py +0 -0
  44. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/spread.py +0 -0
  45. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/table.py +0 -0
  46. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/textsize.py +0 -0
  47. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/ticklabels.py +0 -0
  48. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/variants.py +0 -0
  49. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/weights.py +0 -0
  50. {scistackplot-0.1.43 → scistackplot-0.1.45}/src/scistackplot/xaxis.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scistackplot
3
- Version: 0.1.43
3
+ Version: 0.1.45
4
4
  Summary: Spec-driven plotting for long-format scientific data — standalone, GUI-friendly
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: csv-stats>=0.2.1
20
+ Requires-Dist: csv-stats>=0.4.2
21
21
  Requires-Dist: numpy>=1.21
22
22
  Requires-Dist: pandas>=1.5
23
- Requires-Dist: scistacklog==0.1.43
23
+ Requires-Dist: scistacklog==0.1.45
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: matplotlib>=3.6; extra == 'dev'
26
26
  Requires-Dist: plotly>=5.0; extra == 'dev'
@@ -49,7 +49,9 @@ dependencies = [
49
49
  "numpy>=1.21",
50
50
  # The one owner of every descriptive formula (error bars: spread.py).
51
51
  # Core only: numpy, pandas, scipy. Not a scistack sibling, so not coversioned.
52
- "csv-stats>=0.2.1",
52
+ # 0.4.2: scipy imported lazily, keeping it out of `import scistackplot`
53
+ # (2 s of every first Plot Studio open; tests/test_import_cost.py).
54
+ "csv-stats>=0.4.2",
53
55
  "scistacklog",
54
56
  ]
55
57
 
@@ -44,10 +44,11 @@ from __future__ import annotations
44
44
  import time
45
45
  from dataclasses import replace
46
46
 
47
+ import numpy as np
47
48
  import pandas as pd
48
49
  from scistacklog import Log
49
50
 
50
- from .series_stats import cell_arrays, collapse_cells
51
+ from .series_stats import cell_array, cell_arrays, collapse_cells
51
52
  from .shape import Shape
52
53
  from .spec import SCALAR_KINDS, PlotSpec, Statistic
53
54
  from .table import LongTable
@@ -124,7 +125,11 @@ def apply_cell_collapse(spec: PlotSpec, table: LongTable) -> LongTable:
124
125
  # Kept as NaN rows, never dropped: dropping would change the level
125
126
  # counts the factor summary reports, so "this trial recorded nothing"
126
127
  # would read as "this trial does not exist".
127
- Log.warn(
128
+ #
129
+ # DEBUG: SOME empty cells are ordinary data (78 WARNs in scidb.log
130
+ # 2026-10-19). The case worth a WARN — nothing at all to draw — is
131
+ # `all_nan_warning`'s, over the post-filter rows.
132
+ Log.debug(
128
133
  "%d of %d row(s) of %r collapsed to NaN (empty or all-NaN cells) — "
129
134
  "kept as gaps",
130
135
  empty,
@@ -194,6 +199,48 @@ def _collapse_exploded(
194
199
  return out, len(out), len(frame)
195
200
 
196
201
 
202
+ def _cell_is_nan(value) -> bool:
203
+ """One object-dtype cell holds no number: None, NaN, an empty or all-NaN
204
+ array. A value that is not numeric at all (a string) is NOT NaN — this
205
+ must never warn about data it cannot read."""
206
+ if value is None:
207
+ return True
208
+ if isinstance(value, (np.ndarray, list, tuple)):
209
+ array = cell_array(value)
210
+ return array.size == 0 or bool(np.isnan(array).all())
211
+ try:
212
+ return bool(np.isnan(float(value)))
213
+ except (TypeError, ValueError):
214
+ return False
215
+
216
+
217
+ def all_nan_warning(frame: pd.DataFrame, measure: str) -> str | None:
218
+ """The message when every value of ``measure`` in ``frame`` is NaN, or None.
219
+
220
+ The one owner of "this plot has nothing to draw because the data is all
221
+ NaN" — logged as a WARN and returned for the GUI to show as a popup.
222
+ ``frame`` is the POST-filter frame, so the answer is about what the user
223
+ is plotting, not columns they filtered away. A frame with no rows is a
224
+ different case (filtered to nothing) and returns None. Scalar cells and
225
+ 1-D cells (arrays, empty arrays) are both handled.
226
+ """
227
+ if measure not in frame.columns or frame.empty:
228
+ return None
229
+ column = frame[measure]
230
+ if column.dtype == object:
231
+ all_nan = all(_cell_is_nan(value) for value in column)
232
+ else:
233
+ all_nan = bool(column.isna().all())
234
+ if not all_nan:
235
+ return None
236
+ message = (
237
+ f"Every value of {measure!r} is NaN in the {len(frame)} row(s) being "
238
+ f"plotted — there is nothing to draw. Check the function that saved it."
239
+ )
240
+ Log.warn("all-NaN measure: %s", message, layer=LAYER)
241
+ return message
242
+
243
+
197
244
  def cell_collapse_note(spec: PlotSpec, table: LongTable) -> str | None:
198
245
  """One line naming the collapse, for a figure's notes, or None.
199
246
 
@@ -96,7 +96,7 @@ from .compare import (
96
96
  from .panels import override_for, panel_key_text, unmatched
97
97
  from .ylimits import ExtentMode, eligible_scope, limits_for, panel_factors, spread_bounds
98
98
  from .groups import apply_level_groups
99
- from .cell import apply_cell_collapse, cell_collapse_note, cell_collapses
99
+ from .cell import all_nan_warning, apply_cell_collapse, cell_collapse_note, cell_collapses
100
100
  from .variants import apply_variant_sets, strip_answered_roles
101
101
 
102
102
  LAYER = "scistackplot"
@@ -376,6 +376,7 @@ def resolve(
376
376
  )
377
377
  for figure in figures:
378
378
  figure.fanout_notes = plan.notes
379
+ figure.data_warnings = plan.data_warnings
379
380
  for position, figure in enumerate(figures):
380
381
  figure.difference_siblings = [
381
382
  figures[other] for other in _difference_sibling_indices(plan, position)
@@ -434,6 +435,9 @@ class _Plan:
434
435
  frame: "pd.DataFrame" = field(default_factory=pd.DataFrame)
435
436
  #: The y-limit scope, after ineligible factors were dropped.
436
437
  y_scope: list[str] = field(default_factory=list)
438
+ #: ``cell.all_nan_warning`` over ``frame``. On the plan, not computed per
439
+ #: figure, so a plan-cache HIT still carries it to every figure.
440
+ data_warnings: list[str] = field(default_factory=list)
437
441
  #: ``{scope values: (low, high)}`` for the WHOLE fan-out, computed here
438
442
  #: rather than per figure — a scope of ``[]`` means one range across every
439
443
  #: figure, including the ones ``resolve_one`` never builds. Computed off the
@@ -731,6 +735,7 @@ def _build_plan_timed(spec: PlotSpec, table: LongTable, timer) -> _Plan:
731
735
  explode=explode,
732
736
  iterate=iterate,
733
737
  notes=[collapsed_note] if collapsed_note else [],
738
+ data_warnings=[warning] if (warning := all_nan_warning(frame, y_measure)) else [],
734
739
  groups=groups,
735
740
  frame=frame,
736
741
  y_scope=eligible_scope(spec.y_axis.scope, roles, table),
@@ -811,6 +816,7 @@ def resolve_one(
811
816
  axis_frame=plan.frame,
812
817
  )
813
818
  figure.fanout_notes = plan.notes
819
+ figure.data_warnings = plan.data_warnings
814
820
  siblings = _difference_sibling_indices(plan, position)
815
821
  if siblings:
816
822
  with timing.phase("difference_siblings", extra=f"{len(siblings)} figure(s)"):
@@ -1,5 +1,5 @@
1
1
  """
2
- ``ResolvedPlot`` — a spec plus data, reduced to exactly what a renderer draws.
2
+ ``ResolvedPlot`` — a spec plus data, reduced to exactly what a renderer draws.
3
3
 
4
4
  This intermediate is the reason the interactive plotly view and the exported
5
5
  matplotlib figure cannot drift apart. Compiling ``PlotSpec`` straight to each
@@ -34,23 +34,23 @@ Y_LOW = "__y_low"
34
34
  Y_HIGH = "__y_high"
35
35
  COLOR = "__color"
36
36
  SERIES = "__series"
37
- #: The UNCOLOURED part of a series identity — the grouping layers that are
37
+ #: The UNCOLOURED part of a series identity — the grouping layers that are
38
38
  #: neither the colour nor the sample. A line or band per level of these is
39
39
  #: told apart by a dash style (docs/claude/grouping-and-collapse.md, D4),
40
40
  #: which is what a scientist reads off a printed figure without hovering.
41
41
  DASH = "__dash"
42
42
  #: The overlay's own colour level (``PlotSpec.sample_color``): the shown key
43
- #: that colours a "Show sample" point, beside ``__color`` — the MARK's level,
43
+ #: that colours a "Show sample" point, beside ``__color`` — the MARK's level,
44
44
  #: which paints the point when it has no colour of its own. Two columns
45
45
  #: because the two colourings are independent by construction (a shown key
46
46
  #: is collapsed, a coloured layer groups; no factor is both).
47
47
  SAMPLE_COLOR = "__sample_color"
48
- #: On a spaghetti figure, the id of the LINE an overlay point belongs to —
48
+ #: On a spaghetti figure, the id of the LINE an overlay point belongs to —
49
49
  #: the marks' ``__series`` (the lines layer, plus the units when a collapsed
50
50
  #: key is drawn one line each) recomposed on the overlay rows, so the point
51
51
  #: is placed at its line's ``series_offsets`` shift rather than on the tick.
52
52
  SAMPLE_LINE = "__line"
53
- #: The RUN a joined point belongs to — the bracket a polyline stays inside.
53
+ #: The RUN a joined point belongs to — the bracket a polyline stays inside.
54
54
  #: A line spans the innermost tick only and never crosses a bracket (every
55
55
  #: tick layer above the innermost, ``roles.GroupingLayers.brackets``; user
56
56
  #: decision 2026-09-21), so a spaghetti's marks carry the brackets' values
@@ -60,13 +60,13 @@ SAMPLE_LINE = "__line"
60
60
  #: subject keeps its slot in every bracket. Empty when nothing splits the runs.
61
61
  RUN = "__run"
62
62
  Z = "__z"
63
- #: The one x level a categorical figure has when NO tick layer was chosen —
63
+ #: The one x level a categorical figure has when NO tick layer was chosen —
64
64
  #: a scalar measure grouped by its colour alone, or not grouped at all. The
65
65
  #: mark frame and the overlay frame both write it as their ``__x``
66
66
  #: (``reduce._panel_frame`` / ``reduce._overlay_frame``), and
67
67
  #: ``ResolvedPlot.x_order`` lists it as the axis' single level so both
68
68
  #: renderers read the axis as categorical (``render.base.is_categorical_x``)
69
- #: — a "Show sample" overlay only draws on a categorical axis, and
69
+ #: — a "Show sample" overlay only draws on a categorical axis, and
70
70
  #: ``x_positions`` would coerce the label to NaN on a numeric one.
71
71
  UNLABELLED_X = ""
72
72
 
@@ -118,8 +118,8 @@ class Labels:
118
118
  x: str = ""
119
119
  y: str = ""
120
120
  color: str | None = None
121
- #: What the dash styles tell apart — the uncoloured series layers'
122
- #: display names, outermost first — for the legend's second block.
121
+ #: What the dash styles tell apart — the uncoloured series layers'
122
+ #: display names, outermost first — for the legend's second block.
123
123
  dash: str | None = None
124
124
  #: The overlay's own colour key (``PlotSpec.sample_color``), display
125
125
  #: name, for the legend's block of overlay levels.
@@ -140,14 +140,14 @@ class Panel:
140
140
  key: dict[str, Any] = field(default_factory=dict)
141
141
  #: This panel's y range, or None to autoscale.
142
142
  #:
143
- #: **The authority** — ``ResolvedPlot.y_limits`` is derived from these and
143
+ #: **The authority** — ``ResolvedPlot.y_limits`` is derived from these and
144
144
  #: exists only for the case where they all agree. Per panel rather than per
145
145
  #: figure because ``PlotSpec.y_axis.scope`` may separate limits by a FACET
146
146
  #: factor, which is what "autoscale each panel" means.
147
147
  y_limits: tuple[float, float] | None = None
148
148
  #: "Show sample" overlay rows for this panel, or None: ``__x`` (the
149
149
  #: same leaf key the marks use), ``__y``, ``__color`` when coloured,
150
- #: ``__series`` (the shown keys composed, outermost first — one line's
150
+ #: ``__series`` (the shown keys composed, outermost first — one line's
151
151
  #: identity when joined) and the shown key columns themselves, for hover.
152
152
  #: Built by ``reduce._overlay_frame``; drawn after the marks.
153
153
  sample: pd.DataFrame | None = None
@@ -157,18 +157,18 @@ class Panel:
157
157
  """
158
158
  What names this panel: the facet VALUES only.
159
159
 
160
- The key is already obvious from the figure — every panel in a grid is
160
+ The key is already obvious from the figure — every panel in a grid is
161
161
  faceted by the same factor, so repeating "ColName=" on all 13 subplots
162
162
  is noise. (``ResolvedPlot.figure_label`` keeps ``key=value``: there the
163
163
  figures are separate files and the key is not otherwise visible.)
164
164
 
165
165
  Renderers draw this as the panel's **y-axis title**, not as a caption
166
- above it (``render.base.panel_y_title``) — the axis title is room the
166
+ above it (``render.base.panel_y_title``) — the axis title is room the
167
167
  panel already spends, so the name costs the grid no height. The property
168
168
  keeps its name because it is also the panel's identity in logs, in
169
169
  ``to_dict`` and in the GUI.
170
170
  """
171
- return " · ".join(str(v) for v in self.key.values())
171
+ return " · ".join(str(v) for v in self.key.values())
172
172
 
173
173
 
174
174
  @dataclass
@@ -176,7 +176,7 @@ class ResolvedPlot:
176
176
  """
177
177
  One figure, fully reduced.
178
178
 
179
- ``resolve()`` returns a LIST of these — one per combination of the spec's
179
+ ``resolve()`` returns a LIST of these — one per combination of the spec's
180
180
  ITERATE factors, which is the interactive equivalent of the pipeline's
181
181
  ``for_each`` fan-out over iterated schema keys.
182
182
  """
@@ -195,7 +195,7 @@ class ResolvedPlot:
195
195
  #: draw the group labels and brackets beneath the ticks.
196
196
  x_plan: Any = None
197
197
  #: x levels of the plot (its filtered data, every figure of the fan-out)
198
- #: this figure has no data for, in axis order — a nested leaf as its
198
+ #: this figure has no data for, in axis order — a nested leaf as its
199
199
  #: layer values joined by ``reduce.MISSING_X_JOIN``. Listed whether or not
200
200
  #: ``PlotSpec.show_missing_levels`` draws them, so the GUI can offer the
201
201
  #: toggle exactly when it would change something.
@@ -214,12 +214,12 @@ class ResolvedPlot:
214
214
  #: Headers for rule-defined rows/columns (empty when the panels just flow).
215
215
  row_labels: list[str] = field(default_factory=list)
216
216
  col_labels: list[str] = field(default_factory=list)
217
- #: Human-readable notes about placement decisions the user did not ask for —
217
+ #: Human-readable notes about placement decisions the user did not ask for —
218
218
  #: a panel that spilled out of its ruled cell, a grid that had to grow, a
219
219
  #: panel that matched no rule. The layout never silently disobeys a rule;
220
220
  #: it says what it did instead. Surfaced in the GUI's Layout section.
221
221
  layout_notes: list[str] = field(default_factory=list)
222
- #: The whole figure's y range — set only when every panel shares it, and
222
+ #: The whole figure's y range — set only when every panel shares it, and
223
223
  #: ``None`` when they differ. Derived from the panels in ``reduce``, never
224
224
  #: computed separately, so the two can never disagree. A renderer reads the
225
225
  #: None as "give each panel its own axis" (``render.base.shares_y_axis``).
@@ -229,20 +229,24 @@ class ResolvedPlot:
229
229
  y_scope: list[str] = field(default_factory=list)
230
230
  #: Every factor that separates PANELS, as resolved: the figure's ITERATE
231
231
  #: keys in fan-out order, then its FACET factors. The factors a y-limit
232
- #: scope may name — offered by the GUI from HERE rather than from
232
+ #: scope may name — offered by the GUI from HERE rather than from
233
233
  #: `spec.roles`, because a schema key promoted to ITERATE or a facet the
234
234
  #: table defaulted is a panel factor the spec never mentions.
235
235
  panel_factors: list[str] = field(default_factory=list)
236
236
  #: Set when the data was reduced for transport (see reduce.MAX_TRANSPORT_POINTS).
237
237
  downsampled_from: int | None = None
238
- #: Notes about the FIGURE SET rather than about this figure's layout — at
238
+ #: Notes about the FIGURE SET rather than about this figure's layout — at
239
239
  #: present, schema keys promoted to ITERATE because a nested key iterates.
240
240
  #: Identical on every figure of a fan-out (it describes the fan-out), which
241
241
  #: is why the GUI reads it from the first one.
242
242
  fanout_notes: list[str] = field(default_factory=list)
243
+ #: Problems with the DATA the user should see even if the canvas is out
244
+ #: of view — the GUI shows each as a popup. Like `fanout_notes`, about the
245
+ #: whole figure set; at present only `cell.all_nan_warning`.
246
+ data_warnings: list[str] = field(default_factory=list)
243
247
  #: SPAGHETTI only: the horizontal shift each series (subject) keeps at every
244
248
  #: x position, keyed by its ``SERIES`` id. Decided once per FIGURE, not per
245
- #: panel, so a subject sits at the same offset in every facet — and by one
249
+ #: panel, so a subject sits at the same offset in every facet — and by one
246
250
  #: function (:func:`scistackplot.spaghetti.series_offsets`) so both
247
251
  #: renderers and the generated code place the same marker in the same spot.
248
252
  series_offsets: dict[str, float] = field(default_factory=dict)
@@ -251,7 +255,7 @@ class ResolvedPlot:
251
255
  #: same level in the same style, and the legend can list them.
252
256
  dash_styles: dict[str, str] = field(default_factory=dict)
253
257
  #: "Show sample" (``PlotSpec.show_sample``): the shown keys, outermost
254
- #: first — the identity of one overlay point — empty when no overlay is
258
+ #: first — the identity of one overlay point — empty when no overlay is
255
259
  #: drawn. ``sample_join`` says whether the points sharing that identity
256
260
  #: are joined across x, ``sample_join_reason`` why (``roles.overlay_join``),
257
261
  #: and ``sample_offsets`` the per-identity horizontal shift inside its
@@ -264,7 +268,7 @@ class ResolvedPlot:
264
268
  sample_offsets: dict[str, float] = field(default_factory=dict)
265
269
  #: The shown key colouring the overlay (``roles.overlay_color``), None
266
270
  #: when a point takes its mark's colour; and that key's levels in
267
- #: declared order across the WHOLE figure — the same rule as
271
+ #: declared order across the WHOLE figure — the same rule as
268
272
  #: ``color_order``, so subject 03 is the same colour in every panel
269
273
  #: (``render.base.sample_palette_for`` indexes it, never a panel's own
270
274
  #: enumeration).
@@ -323,7 +327,7 @@ class ResolvedPlot:
323
327
  return sum(len(p.frame) for p in self.panels)
324
328
 
325
329
  def to_dict(self) -> dict:
326
- """JSON-serializable form — used by the GUI transport and by tests."""
330
+ """JSON-serializable form — used by the GUI transport and by tests."""
327
331
  # Lazily: render.base imports this module. The panel's drawn y title
328
332
  # comes from the renderers' own rule, so the GUI shows what is drawn.
329
333
  from .panels import override_for, unmatched
@@ -381,6 +385,7 @@ class ResolvedPlot:
381
385
  ],
382
386
  "downsampled_from": self.downsampled_from,
383
387
  "fanout_notes": list(self.fanout_notes),
388
+ "data_warnings": list(self.data_warnings),
384
389
  "series_offsets": dict(self.series_offsets),
385
390
  "dash_styles": dict(self.dash_styles),
386
391
  "sample": {
@@ -411,7 +416,7 @@ class ResolvedPlot:
411
416
  "grid_row": panel.grid_row,
412
417
  "grid_col": panel.grid_col,
413
418
  # Per panel, because the figure-level value is absent
414
- # exactly when the panels differ — which is the case the
419
+ # exactly when the panels differ — which is the case the
415
420
  # interactive view most needs to draw correctly.
416
421
  "y_limits": list(panel.y_limits) if panel.y_limits else None,
417
422
  "rows": _frame_records(panel.frame),
@@ -185,7 +185,9 @@ def eligible_scope(
185
185
 
186
186
  dropped = [name for name in wanted if name not in allowed]
187
187
  if dropped:
188
- Log.warn(
188
+ # DEBUG, not WARN: the panel shows the eligible scope, and this fired
189
+ # on every resolve of an unchanged plot (31x in scidb.log 2026-10-19).
190
+ Log.debug(
189
191
  "y-limit scope ignores %s: only factors that separate PANELS "
190
192
  "(iterate, facet) can separate y limits — %s. Limits will be shared "
191
193
  "across %s.",
File without changes
File without changes