scistackplotdb 0.1.36__tar.gz → 0.1.38__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scistackplotdb
3
- Version: 0.1.36
3
+ Version: 0.1.38
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.36
21
- Requires-Dist: scistack-db==0.1.36
22
- Requires-Dist: scistacklog==0.1.36
23
- Requires-Dist: scistackplot==0.1.36
20
+ Requires-Dist: sciduckdb==0.1.38
21
+ Requires-Dist: scistack-db==0.1.38
22
+ Requires-Dist: scistacklog==0.1.38
23
+ Requires-Dist: scistackplot==0.1.38
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 table with
158
+ # none (nothing superseded) exports its rows 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.