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.
- {scistackplotdb-0.1.36 → scistackplotdb-0.1.38}/PKG-INFO +5 -5
- {scistackplotdb-0.1.36 → scistackplotdb-0.1.38}/src/scistackplotdb/__init__.py +2 -0
- {scistackplotdb-0.1.36 → scistackplotdb-0.1.38}/src/scistackplotdb/endpoint.py +29 -4
- {scistackplotdb-0.1.36 → scistackplotdb-0.1.38}/src/scistackplotdb/load.py +158 -1
- scistackplotdb-0.1.38/src/scistackplotdb/saved.py +686 -0
- {scistackplotdb-0.1.36 → scistackplotdb-0.1.38}/src/scistackplotdb/source.py +38 -3
- scistackplotdb-0.1.36/src/scistackplotdb/saved.py +0 -350
- {scistackplotdb-0.1.36 → scistackplotdb-0.1.38}/.gitignore +0 -0
- {scistackplotdb-0.1.36 → scistackplotdb-0.1.38}/README.md +0 -0
- {scistackplotdb-0.1.36 → scistackplotdb-0.1.38}/pyproject.toml +0 -0
- {scistackplotdb-0.1.36 → scistackplotdb-0.1.38}/src/scistackplotdb/_versioned.py +0 -0
- {scistackplotdb-0.1.36 → scistackplotdb-0.1.38}/src/scistackplotdb/bundle_section.py +0 -0
- {scistackplotdb-0.1.36 → scistackplotdb-0.1.38}/src/scistackplotdb/hierarchy.py +0 -0
- {scistackplotdb-0.1.36 → scistackplotdb-0.1.38}/src/scistackplotdb/presets.py +0 -0
- {scistackplotdb-0.1.36 → scistackplotdb-0.1.38}/src/scistackplotdb/variants.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: scistackplotdb
|
|
3
|
-
Version: 0.1.
|
|
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.
|
|
21
|
-
Requires-Dist: scistack-db==0.1.
|
|
22
|
-
Requires-Dist: scistacklog==0.1.
|
|
23
|
-
Requires-Dist: scistackplot==0.1.
|
|
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
|
|
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"{
|
|
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
|
-
|
|
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.
|