scistackplotdb 0.1.26__py3-none-any.whl
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/__init__.py +80 -0
- scistackplotdb/endpoint.py +257 -0
- scistackplotdb/hierarchy.py +138 -0
- scistackplotdb/load.py +463 -0
- scistackplotdb/source.py +784 -0
- scistackplotdb/variants.py +225 -0
- scistackplotdb-0.1.26.dist-info/METADATA +142 -0
- scistackplotdb-0.1.26.dist-info/RECORD +9 -0
- scistackplotdb-0.1.26.dist-info/WHEEL +4 -0
scistackplotdb/load.py
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Loading scidb variables into long-format frames.
|
|
3
|
+
|
|
4
|
+
The long format itself is nearly free — schema keys are already ordinary
|
|
5
|
+
columns once a variable is joined to ``_schema``, which is the same shape
|
|
6
|
+
``stat_`` functions receive via ``as_table``. What this module adds is the
|
|
7
|
+
part a flat CSV never needed: attaching branch params as columns, and knowing
|
|
8
|
+
which schema keys a given variable actually occupies.
|
|
9
|
+
|
|
10
|
+
Queries go through ``_fetchall``/``_fetchone`` (never ``_execute(...).fetchall()``
|
|
11
|
+
— see docs/claude on DuckDB fetch locking) and batch the branch-params walk
|
|
12
|
+
rather than asking per record (the N+1 trap).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import pandas as pd
|
|
21
|
+
from scistacklog import Log
|
|
22
|
+
from scistackplot import CODE_FACTOR_PREFIX
|
|
23
|
+
|
|
24
|
+
LAYER = "scistackplotdb"
|
|
25
|
+
|
|
26
|
+
#: Column name given to the producing function's version when a variable holds
|
|
27
|
+
#: records built by more than one version of its function's source. Named like
|
|
28
|
+
#: the stack's other synthetic factor (``source.FIELD_FACTOR`` = ``ColName``)
|
|
29
|
+
#: rather than after a database column, because that is what it is to a reader
|
|
30
|
+
#: of the figure: a condition, not a record attribute.
|
|
31
|
+
#: Owned by scistackplot (``CODE_FACTOR_PREFIX``), re-exported here under the
|
|
32
|
+
#: name this layer's callers already use. The rendering layer decides how a code
|
|
33
|
+
#: axis is presented and defaulted, so it owns the convention; sources conform
|
|
34
|
+
#: rather than each inventing their own prefix.
|
|
35
|
+
VERSION_FACTOR_PREFIX = CODE_FACTOR_PREFIX
|
|
36
|
+
|
|
37
|
+
#: Level for a record whose chain does not include this column's function at
|
|
38
|
+
#: all — a raw save, or a record that reached this schema location by a route
|
|
39
|
+
#: that never ran it. Without a level of its own those rows would hold NaN and
|
|
40
|
+
#: drop silently out of every facet.
|
|
41
|
+
#:
|
|
42
|
+
#: Note this replaced a single ``CodeVersion`` column carrying ``"(raw)"``. One
|
|
43
|
+
#: column per function is what makes a multi-layer chain expressible, and it is
|
|
44
|
+
#: also what lets the level mean the same thing everywhere: ``v2`` in
|
|
45
|
+
#: ``Code:bandpass_filter`` is the same code at every schema location, which a
|
|
46
|
+
#: single merged column could not promise once two functions were in play.
|
|
47
|
+
MISSING_VERSION_LEVEL = "(n/a)"
|
|
48
|
+
|
|
49
|
+
#: Per-row flag for "this record's code version is the newest at ITS OWN schema
|
|
50
|
+
#: location". Not a variant factor — a helper the default pin filters on.
|
|
51
|
+
#:
|
|
52
|
+
#: Pinning has to happen on this rather than on ``Code:fn == "v2"``, and the
|
|
53
|
+
#: difference is not cosmetic. Version ordinals are numbered per function so
|
|
54
|
+
#: their levels mean the same thing everywhere, which means pinning a level
|
|
55
|
+
#: drops every schema location that was never re-run under the newest code —
|
|
56
|
+
#: silently losing subjects from the figure. This flag is resolved per location,
|
|
57
|
+
#: so pinning it keeps each location's own newest record and loses nothing.
|
|
58
|
+
#:
|
|
59
|
+
#: It is also **one flag for the whole chain**, not one per code column. That is
|
|
60
|
+
#: what keeps the default a single checkbox however many layers were edited: an
|
|
61
|
+
#: N-function chain would otherwise need N pins to express "just show me the
|
|
62
|
+
#: current results".
|
|
63
|
+
LATEST_COLUMN = "CodeIsLatest"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class VariableFrame:
|
|
68
|
+
"""A variable's records as a long frame, plus what its columns mean."""
|
|
69
|
+
|
|
70
|
+
name: str
|
|
71
|
+
frame: pd.DataFrame
|
|
72
|
+
#: Schema keys this variable actually occupies (non-null for its records).
|
|
73
|
+
levels: list[str] = field(default_factory=list)
|
|
74
|
+
#: Data column(s) of the variable's table, excluding record_id.
|
|
75
|
+
data_columns: list[str] = field(default_factory=list)
|
|
76
|
+
#: Variant columns attached from the provenance graph — branch params, plus
|
|
77
|
+
#: the producing function's version when there is more than one.
|
|
78
|
+
variant_columns: list[str] = field(default_factory=list)
|
|
79
|
+
#: One entry per variant column saying where it came from:
|
|
80
|
+
#: ``{"column", "kind": "code"|"param", "function", "param"}``.
|
|
81
|
+
#:
|
|
82
|
+
#: Column names encode this already (``Code:bandpass``, ``bandpass.low_hz``),
|
|
83
|
+
#: but only as *scidb's* namespacing conventions. Anything that needed the
|
|
84
|
+
#: producing function — the GUI mapping an axis to its pipeline node, most
|
|
85
|
+
#: obviously — would otherwise re-implement those conventions by splitting
|
|
86
|
+
#: strings, one layer away from where they are defined and first to break
|
|
87
|
+
#: when they change. Carrying the structure costs nothing here, where both
|
|
88
|
+
#: halves are still in hand.
|
|
89
|
+
variant_axes: list[dict] = field(default_factory=list)
|
|
90
|
+
#: Name of the per-row "this is my location's newest code version" flag, or
|
|
91
|
+
#: None when the variable holds only one version. See :data:`LATEST_COLUMN`.
|
|
92
|
+
latest_column: str | None = None
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def value_column(self) -> str:
|
|
96
|
+
return self.data_columns[0] if self.data_columns else self.name
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def schema_keys(db) -> list[str]:
|
|
100
|
+
return list(db._duck.dataset_schema)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def registered_variables(db) -> list[str]:
|
|
104
|
+
rows = db._duck._fetchall("SELECT variable_name FROM _variables ORDER BY variable_name")
|
|
105
|
+
return [row[0] for row in rows]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def table_name_for(db, variable: str) -> str:
|
|
109
|
+
"""
|
|
110
|
+
Resolve a variable's data table.
|
|
111
|
+
|
|
112
|
+
``_registered_types.table_name`` is deliberately NOT unique (see the note
|
|
113
|
+
in ``DatabaseManager._ensure_meta_tables``), so every query below also
|
|
114
|
+
filters on ``_record.type`` — reading a shared table without that filter
|
|
115
|
+
would silently mix two variables' records into one plot.
|
|
116
|
+
"""
|
|
117
|
+
row = db._duck._fetchone(
|
|
118
|
+
"SELECT table_name FROM _registered_types WHERE type_name = ?", [variable]
|
|
119
|
+
)
|
|
120
|
+
return row[0] if row and row[0] else f"{variable}_data"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def data_columns_for(db, variable: str) -> list[str]:
|
|
124
|
+
table = table_name_for(db, variable)
|
|
125
|
+
rows = db._duck._fetchall(
|
|
126
|
+
"SELECT column_name FROM information_schema.columns "
|
|
127
|
+
"WHERE table_name = ? AND column_name != 'record_id' "
|
|
128
|
+
"ORDER BY ordinal_position",
|
|
129
|
+
[table],
|
|
130
|
+
)
|
|
131
|
+
return [row[0] for row in rows]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def sample_value(db, variable: str) -> Any:
|
|
135
|
+
"""
|
|
136
|
+
One value from a variable's data column — enough to classify its shape.
|
|
137
|
+
|
|
138
|
+
Deliberately a value rather than a declared SQL type name: the duckdb
|
|
139
|
+
client's own Python type for a cell (float for a scalar column, list for a
|
|
140
|
+
LIST column) is the ground truth, with no dependency on how DuckDB spells
|
|
141
|
+
list/array types across versions. The GUI's pre-existing
|
|
142
|
+
``_numeric_plot_kind`` made the same call for the same reason.
|
|
143
|
+
"""
|
|
144
|
+
columns = data_columns_for(db, variable)
|
|
145
|
+
if not columns:
|
|
146
|
+
return None
|
|
147
|
+
table = table_name_for(db, variable)
|
|
148
|
+
row = db._duck._fetchone(
|
|
149
|
+
f'SELECT t."{columns[0]}" FROM "{table}" t '
|
|
150
|
+
f"JOIN _record r ON t.record_id = r.record_id "
|
|
151
|
+
f"WHERE r.type = ? AND r.excluded IS DISTINCT FROM TRUE "
|
|
152
|
+
f"AND t.\"{columns[0]}\" IS NOT NULL LIMIT 1",
|
|
153
|
+
[variable],
|
|
154
|
+
)
|
|
155
|
+
return row[0] if row else None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def variable_levels(db, variable: str) -> list[str]:
|
|
159
|
+
"""
|
|
160
|
+
Which schema keys a variable occupies, without loading any data.
|
|
161
|
+
|
|
162
|
+
``describe()`` needs this for every registered variable, and loading each
|
|
163
|
+
one's full frame to find out would mean reading every 1-D array in the
|
|
164
|
+
database just to open the panel. COUNT ignores NULLs, so one row of counts
|
|
165
|
+
says exactly which keys are populated.
|
|
166
|
+
"""
|
|
167
|
+
keys = schema_keys(db)
|
|
168
|
+
if not keys:
|
|
169
|
+
return []
|
|
170
|
+
counts = ", ".join(f'COUNT(s."{key}")' for key in keys)
|
|
171
|
+
row = db._duck._fetchone(
|
|
172
|
+
f"SELECT {counts} FROM _record r "
|
|
173
|
+
f"LEFT JOIN _schema s ON r.schema_id = s.schema_id "
|
|
174
|
+
f"WHERE r.type = ? AND r.excluded IS DISTINCT FROM TRUE",
|
|
175
|
+
[variable],
|
|
176
|
+
)
|
|
177
|
+
if row is None:
|
|
178
|
+
return []
|
|
179
|
+
return [key for key, count in zip(keys, row, strict=True) if count]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def load_variable(db, variable: str, *, with_variants: bool = True) -> VariableFrame:
|
|
183
|
+
"""Load every non-excluded record of ``variable`` as a long frame."""
|
|
184
|
+
with Log.timer("load_variable", layer=LAYER, extra=variable):
|
|
185
|
+
keys = schema_keys(db)
|
|
186
|
+
columns = data_columns_for(db, variable)
|
|
187
|
+
if not columns:
|
|
188
|
+
Log.warn("variable %r has no data columns", variable, layer=LAYER)
|
|
189
|
+
return VariableFrame(name=variable, frame=pd.DataFrame())
|
|
190
|
+
|
|
191
|
+
table = table_name_for(db, variable)
|
|
192
|
+
schema_select = "".join(f', s."{key}"' for key in keys)
|
|
193
|
+
data_select = "".join(f', t."{column}"' for column in columns)
|
|
194
|
+
query = (
|
|
195
|
+
f"SELECT t.record_id{data_select}{schema_select} "
|
|
196
|
+
f'FROM "{table}" t '
|
|
197
|
+
f"JOIN _record r ON t.record_id = r.record_id "
|
|
198
|
+
f"LEFT JOIN _schema s ON r.schema_id = s.schema_id "
|
|
199
|
+
f"WHERE r.type = ? AND r.excluded IS DISTINCT FROM TRUE"
|
|
200
|
+
)
|
|
201
|
+
rows = db._duck._fetchall(query, [variable])
|
|
202
|
+
|
|
203
|
+
frame = pd.DataFrame(
|
|
204
|
+
rows, columns=["record_id", *columns, *keys]
|
|
205
|
+
)
|
|
206
|
+
for key in keys:
|
|
207
|
+
frame[key] = frame[key].map(lambda v: None if v is None else str(v))
|
|
208
|
+
|
|
209
|
+
levels = [key for key in keys if frame[key].notna().any()]
|
|
210
|
+
variant_columns: list[str] = []
|
|
211
|
+
variant_axes: list[dict] = []
|
|
212
|
+
latest_column: str | None = None
|
|
213
|
+
if with_variants and len(frame):
|
|
214
|
+
frame, variant_columns, latest_column, variant_axes = attach_variants(
|
|
215
|
+
db, frame
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
Log.info(
|
|
219
|
+
"loaded %s: %d record(s), levels=%s, variants=%s",
|
|
220
|
+
variable,
|
|
221
|
+
len(frame),
|
|
222
|
+
levels,
|
|
223
|
+
variant_columns or "none",
|
|
224
|
+
layer=LAYER,
|
|
225
|
+
)
|
|
226
|
+
return VariableFrame(
|
|
227
|
+
name=variable,
|
|
228
|
+
frame=frame,
|
|
229
|
+
levels=levels,
|
|
230
|
+
data_columns=columns,
|
|
231
|
+
variant_columns=variant_columns,
|
|
232
|
+
variant_axes=variant_axes,
|
|
233
|
+
latest_column=latest_column,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def attach_variants(
|
|
238
|
+
db, frame: pd.DataFrame
|
|
239
|
+
) -> tuple[pd.DataFrame, list[str], str | None, list[dict]]:
|
|
240
|
+
"""
|
|
241
|
+
Add one column per thing that distinguishes these records, from the
|
|
242
|
+
provenance graph: each branch param, plus the producing function's version.
|
|
243
|
+
|
|
244
|
+
Returns ``(frame, variant_columns, latest_column, variant_axes)`` — the
|
|
245
|
+
third being the name of the :data:`LATEST_COLUMN` flag when versions are in
|
|
246
|
+
play (or None), and the fourth the structured description of each column
|
|
247
|
+
(see :attr:`VariableFrame.variant_axes`).
|
|
248
|
+
|
|
249
|
+
This is the correctness-critical step. A variable produced at two filter
|
|
250
|
+
cutoffs has **two records per schema combination**; without these columns
|
|
251
|
+
those rows look like replicates of one another and get overplotted — a
|
|
252
|
+
figure that is wrong in a way that looks like data. With them, the variant
|
|
253
|
+
is an ordinary factor the user must assign (``roles.validate`` refuses to
|
|
254
|
+
let a multi-level variant sit unassigned).
|
|
255
|
+
|
|
256
|
+
Branch params alone were not enough. Two records produced by **different
|
|
257
|
+
versions of the same function's source** carry identical branch params, so
|
|
258
|
+
they arrived here indistinguishable and were overplotted as replicates —
|
|
259
|
+
precisely the failure this function exists to prevent, reached by the one
|
|
260
|
+
route it did not cover.
|
|
261
|
+
|
|
262
|
+
Nor was the producing function's own version enough, for the same reason one
|
|
263
|
+
hop further out: two records whose producer never changed are still
|
|
264
|
+
different when something *upstream* of it did. ``code_chain`` closes that,
|
|
265
|
+
contributing one ``Code:<fn>`` column per upstream function that genuinely
|
|
266
|
+
holds more than one version. scidb omits single-version functions, so an
|
|
267
|
+
unedited project gets no code columns at all and nothing changes for it.
|
|
268
|
+
|
|
269
|
+
Code columns come **first**: they are the axis a reader most often wants
|
|
270
|
+
pinned, and a stable leading position beats having them appear wherever the
|
|
271
|
+
branch-param iteration order happened to put them.
|
|
272
|
+
|
|
273
|
+
**Only columns that actually distinguish something are attached.** A branch
|
|
274
|
+
param holding the same value on every record is dropped: it cannot separate
|
|
275
|
+
two records, so as a factor it asks the user to choose between one thing,
|
|
276
|
+
and the ``variant`` tag makes it look like a swept parameter that needs a
|
|
277
|
+
decision. Code axes were always filtered this way (scidb omits
|
|
278
|
+
single-version functions); branch params were not, because
|
|
279
|
+
``branch_params_batch`` returns every upstream *constant* regardless of
|
|
280
|
+
whether it varies. Absence counts as a value in that test — a key present on
|
|
281
|
+
some records and missing on others does tell them apart.
|
|
282
|
+
|
|
283
|
+
See ``docs/claude/variant-selection.md`` and
|
|
284
|
+
``docs/claude/function-version-variants.md``.
|
|
285
|
+
"""
|
|
286
|
+
from scidb.provenance_query import variant_identity_batch
|
|
287
|
+
|
|
288
|
+
record_ids = frame["record_id"].tolist()
|
|
289
|
+
ident = variant_identity_batch(db._duck, record_ids)
|
|
290
|
+
|
|
291
|
+
# --- code chain: one column per multi-version upstream function ---
|
|
292
|
+
# Sorted by function name so the column order is a property of the data and
|
|
293
|
+
# not of dict iteration — a saved PlotSpec must keep meaning the same thing.
|
|
294
|
+
fn_names = sorted(
|
|
295
|
+
{name for info in ident.values() for name in info.get("code_chain", {})}
|
|
296
|
+
)
|
|
297
|
+
code_keys: list[str] = []
|
|
298
|
+
axes: list[dict] = []
|
|
299
|
+
for fn_name in fn_names:
|
|
300
|
+
column = f"{VERSION_FACTOR_PREFIX}{fn_name}"
|
|
301
|
+
while column in frame.columns: # never shadow a schema key
|
|
302
|
+
column += "_"
|
|
303
|
+
frame[column] = [
|
|
304
|
+
(ident.get(rid) or {}).get("code_chain", {}).get(
|
|
305
|
+
fn_name, MISSING_VERSION_LEVEL
|
|
306
|
+
)
|
|
307
|
+
for rid in record_ids
|
|
308
|
+
]
|
|
309
|
+
code_keys.append(column)
|
|
310
|
+
axes.append(
|
|
311
|
+
{"column": column, "kind": "code", "function": fn_name, "param": None}
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# --- branch params ---
|
|
315
|
+
candidate_keys: list[str] = []
|
|
316
|
+
for info in ident.values():
|
|
317
|
+
for key in info["branch_params"]:
|
|
318
|
+
if key not in candidate_keys:
|
|
319
|
+
candidate_keys.append(key)
|
|
320
|
+
|
|
321
|
+
param_keys: list[str] = []
|
|
322
|
+
constants: dict[str, Any] = {}
|
|
323
|
+
for key in candidate_keys:
|
|
324
|
+
values = [
|
|
325
|
+
_stringify(ident.get(rid, {}).get("branch_params", {}).get(key))
|
|
326
|
+
for rid in record_ids
|
|
327
|
+
]
|
|
328
|
+
# A constant is not an axis.
|
|
329
|
+
#
|
|
330
|
+
# A variant column exists for exactly one reason: to stop records that
|
|
331
|
+
# DIFFER from being overplotted as replicates. A column holding the same
|
|
332
|
+
# value on every record cannot do that, so offering it as a factor asks
|
|
333
|
+
# the user to choose between one thing — and, being tagged `variant`, it
|
|
334
|
+
# is indistinguishable from a genuinely swept parameter until you count
|
|
335
|
+
# its levels.
|
|
336
|
+
#
|
|
337
|
+
# Code axes have had this guard from the start: scidb's
|
|
338
|
+
# `code_version_ordinals` omits single-version functions, which is why an
|
|
339
|
+
# unedited project gets no `Code:` columns at all. Branch params never
|
|
340
|
+
# got the matching rule, because `branch_params_batch` returns every
|
|
341
|
+
# upstream CONSTANT whether it varies or not — the name promises a branch,
|
|
342
|
+
# the query does not check for one. Measured on a real project
|
|
343
|
+
# (2026-09-11): `filterDelsys.config` and `filterDelsys.Fs` each held ONE
|
|
344
|
+
# level across both records and still demanded a role.
|
|
345
|
+
#
|
|
346
|
+
# **Absence counts as a value.** The test is over the raw list, not over
|
|
347
|
+
# the non-null values, because a key present on some records and missing
|
|
348
|
+
# on others genuinely does tell them apart — dropping it there would
|
|
349
|
+
# reintroduce the very overplotting this function exists to prevent. A
|
|
350
|
+
# key missing everywhere is all-None, one distinct value, and goes.
|
|
351
|
+
#
|
|
352
|
+
# Known gap, pre-existing and deliberately not addressed here: a
|
|
353
|
+
# partially-present key survives with None on the records that lack it,
|
|
354
|
+
# and `FactorInfo.levels` drops nulls, so those rows carry a level-less
|
|
355
|
+
# NaN. Code axes solve this with MISSING_VERSION_LEVEL; branch params
|
|
356
|
+
# have no equivalent sentinel yet.
|
|
357
|
+
if len(set(values)) <= 1:
|
|
358
|
+
constants[key] = values[0] if values else None
|
|
359
|
+
continue
|
|
360
|
+
frame[key] = values
|
|
361
|
+
param_keys.append(key)
|
|
362
|
+
# Branch params are namespaced ``{producing_fn}.{param}`` by
|
|
363
|
+
# `_build_upstream_closure`. A bare key (no dot) is possible in principle
|
|
364
|
+
# and split defensively rather than assumed away.
|
|
365
|
+
function, _, param = key.rpartition(".")
|
|
366
|
+
axes.append(
|
|
367
|
+
{
|
|
368
|
+
"column": key,
|
|
369
|
+
"kind": "param",
|
|
370
|
+
"function": function or None,
|
|
371
|
+
"param": param,
|
|
372
|
+
}
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
keys = code_keys + param_keys
|
|
376
|
+
|
|
377
|
+
if constants:
|
|
378
|
+
# Never silent. These values are real provenance — they are simply not a
|
|
379
|
+
# CHOICE — and a user hunting for the filter cutoff they swept needs to
|
|
380
|
+
# see that this layer looked at it and found one value, rather than that
|
|
381
|
+
# it was never there.
|
|
382
|
+
Log.info(
|
|
383
|
+
"%d constant branch param(s) are not variant axes (one value over "
|
|
384
|
+
"%d record(s)): %s",
|
|
385
|
+
len(constants),
|
|
386
|
+
len(record_ids),
|
|
387
|
+
{key: _truncate(value) for key, value in sorted(constants.items())},
|
|
388
|
+
layer=LAYER,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
if keys:
|
|
392
|
+
counts = {
|
|
393
|
+
column: int(frame[column].astype(str).nunique(dropna=False))
|
|
394
|
+
for column in keys
|
|
395
|
+
}
|
|
396
|
+
# A code axis reporting one level means scidb emitted a single-version
|
|
397
|
+
# function, which `code_version_ordinals` promises not to do. Deliberately
|
|
398
|
+
# NOT filtered here: duplicating that rule would hide the regression
|
|
399
|
+
# instead of surfacing it.
|
|
400
|
+
thin = sorted(
|
|
401
|
+
column for column in code_keys if counts.get(column, 0) <= 1
|
|
402
|
+
)
|
|
403
|
+
if thin:
|
|
404
|
+
Log.warn(
|
|
405
|
+
"code axis/axes %s hold one version — scidb is expected to omit "
|
|
406
|
+
"single-version functions, so this is a provenance bug, not a "
|
|
407
|
+
"plotting one",
|
|
408
|
+
thin,
|
|
409
|
+
layer=LAYER,
|
|
410
|
+
)
|
|
411
|
+
Log.info(
|
|
412
|
+
"variant axis levels over %d record(s): %s",
|
|
413
|
+
len(record_ids),
|
|
414
|
+
counts,
|
|
415
|
+
layer=LAYER,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
latest_column = None
|
|
419
|
+
if code_keys:
|
|
420
|
+
latest_column = LATEST_COLUMN
|
|
421
|
+
while latest_column in frame.columns:
|
|
422
|
+
latest_column += "_"
|
|
423
|
+
# Deliberately NOT appended to `keys`: it is a filter helper, not a
|
|
424
|
+
# condition anyone plots by.
|
|
425
|
+
frame[latest_column] = [
|
|
426
|
+
bool((ident.get(rid) or {}).get("is_latest")) for rid in record_ids
|
|
427
|
+
]
|
|
428
|
+
|
|
429
|
+
Log.info(
|
|
430
|
+
"attached %d code column(s) %s over %d record(s) (%d row(s) current) "
|
|
431
|
+
"— these would otherwise plot as replicates of each other",
|
|
432
|
+
len(code_keys),
|
|
433
|
+
code_keys,
|
|
434
|
+
len(record_ids),
|
|
435
|
+
int(frame[latest_column].sum()),
|
|
436
|
+
layer=LAYER,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
if not keys:
|
|
440
|
+
return frame, [], None, []
|
|
441
|
+
|
|
442
|
+
Log.debug("attached %d variant column(s): %s", len(keys), keys, layer=LAYER)
|
|
443
|
+
return frame, keys, latest_column, axes
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _stringify(value: Any) -> Any:
|
|
447
|
+
if value is None:
|
|
448
|
+
return None
|
|
449
|
+
if isinstance(value, bool):
|
|
450
|
+
return str(value)
|
|
451
|
+
return str(value)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _truncate(value: Any, limit: int = 60) -> Any:
|
|
455
|
+
"""Shorten a value for a log line.
|
|
456
|
+
|
|
457
|
+
A struct-valued branch param stringifies to its whole repr — measured at
|
|
458
|
+
~100 characters for one filter config, and unbounded in principle. Several
|
|
459
|
+
of those on one line buries the key names the line exists to report.
|
|
460
|
+
"""
|
|
461
|
+
if not isinstance(value, str) or len(value) <= limit:
|
|
462
|
+
return value
|
|
463
|
+
return f"{value[: limit - 1]}…"
|