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/source.py
ADDED
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
"""
|
|
2
|
+
``ScidbSource`` — the scidb implementation of scistackplot's ``DataSource``.
|
|
3
|
+
|
|
4
|
+
This is the entire compatibility mechanism: the GUI and every plotting
|
|
5
|
+
function above it talk to the protocol, so the same code path serves a lone CSV
|
|
6
|
+
(``CsvSource``) and a full scidb project. Nothing above this file knows about
|
|
7
|
+
DuckDB, records, or branch params.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import replace
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import pandas as pd
|
|
16
|
+
from scistacklog import Log
|
|
17
|
+
from scistackplot import (
|
|
18
|
+
LongTable,
|
|
19
|
+
Shape,
|
|
20
|
+
classify_column,
|
|
21
|
+
classify_value,
|
|
22
|
+
is_plottable,
|
|
23
|
+
natural_sort_key,
|
|
24
|
+
)
|
|
25
|
+
from scistackplot.sources import BaseSource
|
|
26
|
+
from scistackplot.variants import VARIABLE_COLUMN
|
|
27
|
+
|
|
28
|
+
from .hierarchy import join_frames, joinable, joined_levels
|
|
29
|
+
from .load import (
|
|
30
|
+
LATEST_COLUMN,
|
|
31
|
+
data_columns_for,
|
|
32
|
+
load_variable,
|
|
33
|
+
registered_variables,
|
|
34
|
+
sample_value,
|
|
35
|
+
schema_keys,
|
|
36
|
+
variable_levels,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
LAYER = "scistackplotdb"
|
|
40
|
+
|
|
41
|
+
#: Column name given to a dict/struct variable's field names when they are
|
|
42
|
+
#: melted into long format. Matches the stack's existing vocabulary for "the
|
|
43
|
+
#: name of a data column" — ``scidb.ColName()`` and ``PathOutput("{ColName}")``
|
|
44
|
+
#: (docs/claude/for-columns-iteration.md) — so a plot faceted by field and a
|
|
45
|
+
#: ``for_columns`` run name the same axis the same way.
|
|
46
|
+
FIELD_FACTOR = "ColName"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ScidbSource(BaseSource):
|
|
50
|
+
"""
|
|
51
|
+
Plot data straight out of a scidb database.
|
|
52
|
+
|
|
53
|
+
``db`` is an open ``DatabaseManager``. The caller owns it — in the GUI that
|
|
54
|
+
means the ONE manager the server already holds, never a second connection
|
|
55
|
+
(a second DuckDB handle reintroduces the write-lock contention the MATLAB
|
|
56
|
+
run-ownership work resolved).
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, db, *, name: str | None = None) -> None:
|
|
60
|
+
self._db = db
|
|
61
|
+
# `dataset_db_path`, not `db_path` — DatabaseManager has never had the
|
|
62
|
+
# latter, so this silently fell through to "scidb" for every project.
|
|
63
|
+
self.name = name or str(getattr(db, "dataset_db_path", None) or "scidb")
|
|
64
|
+
self._frames: dict[str, Any] = {}
|
|
65
|
+
self._shapes: dict[str, Shape] = {}
|
|
66
|
+
self._levels: dict[str, list[str]] = {}
|
|
67
|
+
|
|
68
|
+
# ---- description -----------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def describe(self) -> dict:
|
|
71
|
+
"""
|
|
72
|
+
Every plottable variable plus the schema keys, without loading data.
|
|
73
|
+
|
|
74
|
+
Shapes come from one sampled value per variable (a single-row query),
|
|
75
|
+
so opening the panel on a large database stays cheap; the levels come
|
|
76
|
+
from ``_schema``, which is small by construction.
|
|
77
|
+
"""
|
|
78
|
+
with Log.timer("describe", layer=LAYER):
|
|
79
|
+
keys = schema_keys(self._db)
|
|
80
|
+
measures = []
|
|
81
|
+
for variable in registered_variables(self._db):
|
|
82
|
+
shape = self._shape_of(variable)
|
|
83
|
+
columns = data_columns_for(self._db, variable)
|
|
84
|
+
measures.append(
|
|
85
|
+
{
|
|
86
|
+
"name": variable,
|
|
87
|
+
"display": variable,
|
|
88
|
+
"shape": str(shape),
|
|
89
|
+
"exploded": False,
|
|
90
|
+
"plottable": shape
|
|
91
|
+
in (Shape.SCALAR, Shape.SERIES_1D, Shape.MATRIX_2D),
|
|
92
|
+
"columns": columns,
|
|
93
|
+
"levels": self._levels_of(variable),
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
factors = []
|
|
98
|
+
for key in keys:
|
|
99
|
+
levels = self._schema_levels(key) # one query per key, not two
|
|
100
|
+
factors.append(
|
|
101
|
+
{
|
|
102
|
+
"name": key,
|
|
103
|
+
"display": key,
|
|
104
|
+
"levels": levels,
|
|
105
|
+
"level_count": len(levels),
|
|
106
|
+
"is_variant": False,
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
Log.info(
|
|
111
|
+
"describe: %d variable(s), %d schema key(s)",
|
|
112
|
+
len(measures),
|
|
113
|
+
len(factors),
|
|
114
|
+
layer=LAYER,
|
|
115
|
+
)
|
|
116
|
+
return {
|
|
117
|
+
"name": self.name,
|
|
118
|
+
"schema_keys": keys,
|
|
119
|
+
"factors": factors,
|
|
120
|
+
"measures": measures,
|
|
121
|
+
"index_column": None,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
def _shape_of(self, variable: str) -> Shape:
|
|
125
|
+
if variable not in self._shapes:
|
|
126
|
+
self._shapes[variable] = classify_value(sample_value(self._db, variable))
|
|
127
|
+
return self._shapes[variable]
|
|
128
|
+
|
|
129
|
+
def _levels_of(self, variable: str) -> list[str]:
|
|
130
|
+
"""Schema depth WITHOUT loading the variable — describe() asks for every
|
|
131
|
+
registered variable, and loading each frame would read the whole
|
|
132
|
+
database to open the panel."""
|
|
133
|
+
if variable in self._frames:
|
|
134
|
+
return self._frames[variable].levels
|
|
135
|
+
if variable not in self._levels:
|
|
136
|
+
self._levels[variable] = variable_levels(self._db, variable)
|
|
137
|
+
return self._levels[variable]
|
|
138
|
+
|
|
139
|
+
def _schema_levels(self, key: str) -> list[str]:
|
|
140
|
+
rows = self._db._duck._fetchall(
|
|
141
|
+
f'SELECT DISTINCT s."{key}" FROM _schema s WHERE s."{key}" IS NOT NULL'
|
|
142
|
+
)
|
|
143
|
+
values = [str(row[0]) for row in rows]
|
|
144
|
+
return self._ordered(key, values)
|
|
145
|
+
|
|
146
|
+
def _ordered(self, key: str, values: list[str]) -> list[str]:
|
|
147
|
+
"""
|
|
148
|
+
Order a factor's levels.
|
|
149
|
+
|
|
150
|
+
Declared key types decide, not pandas' default: a key declared
|
|
151
|
+
``"numeric"`` sorts numerically, and everything else goes through the
|
|
152
|
+
natural-sort key so zero-padded string IDs ("01", "02", … "10") land in
|
|
153
|
+
the order a reader expects instead of the lexicographic 1, 10, 2.
|
|
154
|
+
"""
|
|
155
|
+
declared = getattr(self._db, "dataset_schema_key_types", None) or {}
|
|
156
|
+
unique = list(dict.fromkeys(values))
|
|
157
|
+
if declared.get(key) == "numeric":
|
|
158
|
+
def numeric_key(value: str):
|
|
159
|
+
try:
|
|
160
|
+
return (0, float(value))
|
|
161
|
+
except (TypeError, ValueError):
|
|
162
|
+
return (1, 0.0)
|
|
163
|
+
|
|
164
|
+
return sorted(unique, key=numeric_key)
|
|
165
|
+
return sorted(unique, key=natural_sort_key)
|
|
166
|
+
|
|
167
|
+
# ---- data ------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def _variable_frame(self, variable: str):
|
|
170
|
+
if variable not in self._frames:
|
|
171
|
+
self._frames[variable] = load_variable(self._db, variable)
|
|
172
|
+
return self._frames[variable]
|
|
173
|
+
|
|
174
|
+
def _build_table(
|
|
175
|
+
self,
|
|
176
|
+
measures: list[str],
|
|
177
|
+
*,
|
|
178
|
+
x_measure: str | None = None,
|
|
179
|
+
factor_variables: list[str] | None = None,
|
|
180
|
+
) -> LongTable:
|
|
181
|
+
"""
|
|
182
|
+
Build the long table for a plot's variables.
|
|
183
|
+
|
|
184
|
+
Called by :meth:`~scistackplot.sources.base.BaseSource.get_table`, which
|
|
185
|
+
memoizes the result — this method always does the full work.
|
|
186
|
+
|
|
187
|
+
``measures`` are the y variables. Several of them **stack**: one value
|
|
188
|
+
column plus a :data:`~scistackplot.variants.VARIABLE_COLUMN` saying
|
|
189
|
+
which variable each row came from, which is what lets a variant row
|
|
190
|
+
claim its own variable's rows and no others.
|
|
191
|
+
|
|
192
|
+
``x_measure`` is the x axis of a relational plot and **joins** instead —
|
|
193
|
+
one x value per row of y, broadcast down the hierarchy when it lives at
|
|
194
|
+
a shallower level (see :mod:`.hierarchy`).
|
|
195
|
+
|
|
196
|
+
``factor_variables`` are variables joined in as **factors** rather than
|
|
197
|
+
plotted: a subject-level ``Condition`` holding stim/sham becomes a
|
|
198
|
+
column every row carries, and takes a role like any other factor.
|
|
199
|
+
|
|
200
|
+
The two are genuinely different operations, which is why they are
|
|
201
|
+
different parameters. Stacking two variables that a wide join would
|
|
202
|
+
have paired gives twice the rows and no pairing; joining two variables
|
|
203
|
+
that should have stacked silently drops every row without a partner.
|
|
204
|
+
"""
|
|
205
|
+
if not measures:
|
|
206
|
+
raise ValueError("get_table needs at least one measure (variable name).")
|
|
207
|
+
if len(measures) > 1 and x_measure is not None:
|
|
208
|
+
raise ValueError(
|
|
209
|
+
f"An x measure pairs with ONE y measure; got y={measures} and "
|
|
210
|
+
f"x={x_measure!r}. Stacked variables have no single value to "
|
|
211
|
+
f"pair each x with."
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
factor_variables = list(factor_variables or [])
|
|
215
|
+
known = registered_variables(self._db)
|
|
216
|
+
requested = [
|
|
217
|
+
*measures,
|
|
218
|
+
*([x_measure] if x_measure else []),
|
|
219
|
+
*factor_variables,
|
|
220
|
+
]
|
|
221
|
+
unknown = [name for name in requested if name not in known]
|
|
222
|
+
if unknown:
|
|
223
|
+
# Same failure shape as the CSV source's unknown-column error, so
|
|
224
|
+
# callers (and the GUI) handle one kind of "no such measure".
|
|
225
|
+
raise KeyError(f"Unknown variable(s) {unknown}. Available: {known}")
|
|
226
|
+
|
|
227
|
+
if len(measures) > 1:
|
|
228
|
+
return self._stacked_table(measures, factor_variables)
|
|
229
|
+
|
|
230
|
+
primary = self._variable_frame(measures[0])
|
|
231
|
+
field_columns: list[str] = []
|
|
232
|
+
|
|
233
|
+
if x_measure is None:
|
|
234
|
+
if len(primary.data_columns) > 1:
|
|
235
|
+
frame, field_factor = self._melt_fields(primary, measures[0])
|
|
236
|
+
field_columns = [field_factor]
|
|
237
|
+
else:
|
|
238
|
+
frame = self._named_frame(primary, measures[0])
|
|
239
|
+
levels = primary.levels
|
|
240
|
+
variant_columns = list(primary.variant_columns)
|
|
241
|
+
variant_axes = list(primary.variant_axes)
|
|
242
|
+
measure_names = [measures[0]]
|
|
243
|
+
# Open on the current code version.
|
|
244
|
+
latest_column = primary.latest_column
|
|
245
|
+
default_pin = {latest_column: True} if latest_column else None
|
|
246
|
+
else:
|
|
247
|
+
secondary = self._variable_frame(x_measure)
|
|
248
|
+
pair = (measures[0], x_measure)
|
|
249
|
+
multi = [
|
|
250
|
+
name
|
|
251
|
+
for name, frame in zip(pair, (primary, secondary), strict=True)
|
|
252
|
+
if len(frame.data_columns) > 1
|
|
253
|
+
]
|
|
254
|
+
if multi:
|
|
255
|
+
raise ValueError(
|
|
256
|
+
f"{multi} store one column per dict/struct field, so there is "
|
|
257
|
+
f"no single value to pair with another measure. Plot one of "
|
|
258
|
+
f"them on its own (its fields become subplots), or save the "
|
|
259
|
+
f"field you want as its own variable."
|
|
260
|
+
)
|
|
261
|
+
# Rename BEFORE joining. Two variables' data columns routinely share
|
|
262
|
+
# a name ("value" is the default), and renaming after the merge
|
|
263
|
+
# cannot separate them — one rename key silently shadows the other
|
|
264
|
+
# and the first measure's column vanishes.
|
|
265
|
+
left = replace(primary, frame=self._named_frame(primary, measures[0]))
|
|
266
|
+
right = replace(secondary, frame=self._named_frame(secondary, x_measure))
|
|
267
|
+
frame = join_frames(
|
|
268
|
+
left,
|
|
269
|
+
right,
|
|
270
|
+
left_value=measures[0],
|
|
271
|
+
right_value=x_measure,
|
|
272
|
+
)
|
|
273
|
+
levels = joined_levels(primary, secondary)
|
|
274
|
+
variant_columns = list(
|
|
275
|
+
dict.fromkeys(primary.variant_columns + secondary.variant_columns)
|
|
276
|
+
)
|
|
277
|
+
variant_axes = list(
|
|
278
|
+
{
|
|
279
|
+
axis["column"]: axis
|
|
280
|
+
for axis in primary.variant_axes + secondary.variant_axes
|
|
281
|
+
}.values()
|
|
282
|
+
)
|
|
283
|
+
measure_names = [measures[0], x_measure]
|
|
284
|
+
# `join_frames` now carries both sides' flags through the merge and
|
|
285
|
+
# ANDs them, so the pin-latest default applies to two-measure plots
|
|
286
|
+
# too. It used to drop the flag, which made a relational scatter the
|
|
287
|
+
# one place the default silently stopped protecting the figure.
|
|
288
|
+
latest_column = LATEST_COLUMN if LATEST_COLUMN in frame.columns else None
|
|
289
|
+
default_pin = {latest_column: True} if latest_column else None
|
|
290
|
+
|
|
291
|
+
frame, group_columns = self._attach_factor_variables(
|
|
292
|
+
frame, levels, factor_variables
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
# Variant columns first, and code versions lead within them (see
|
|
296
|
+
# `attach_variants`). The variants are what a reader has to make a
|
|
297
|
+
# decision about — a schema key is just where the data sits — so they
|
|
298
|
+
# get the top of the factor list rather than whatever position the
|
|
299
|
+
# schema happened to leave them.
|
|
300
|
+
factors = [c for c in variant_columns if c in frame.columns]
|
|
301
|
+
factors.extend(key for key in levels if key in frame.columns)
|
|
302
|
+
factors.extend(group_columns)
|
|
303
|
+
factors.extend(c for c in field_columns if c in frame.columns)
|
|
304
|
+
|
|
305
|
+
level_order = {
|
|
306
|
+
name: self._ordered(name, [str(v) for v in frame[name].dropna().unique()])
|
|
307
|
+
for name in factors
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
table = LongTable.from_frame(
|
|
311
|
+
frame,
|
|
312
|
+
factors=factors,
|
|
313
|
+
measures=measure_names,
|
|
314
|
+
level_order=level_order,
|
|
315
|
+
variant_factors=variant_columns,
|
|
316
|
+
field_factors=field_columns,
|
|
317
|
+
name=measures[0],
|
|
318
|
+
default_pin=default_pin,
|
|
319
|
+
latest_column=latest_column,
|
|
320
|
+
factor_origins={axis["column"]: axis for axis in variant_axes},
|
|
321
|
+
# The variable's own schema depth, outermost first — the nesting
|
|
322
|
+
# that decides which keys a fan-out has to iterate together and in
|
|
323
|
+
# which order (roles.iterate_ancestors / roles.fanout_keys).
|
|
324
|
+
schema_levels=levels,
|
|
325
|
+
)
|
|
326
|
+
Log.debug(
|
|
327
|
+
"get_table(%s): %d row(s), factors=%s",
|
|
328
|
+
measures,
|
|
329
|
+
len(frame),
|
|
330
|
+
factors,
|
|
331
|
+
layer=LAYER,
|
|
332
|
+
)
|
|
333
|
+
return table
|
|
334
|
+
|
|
335
|
+
def _attach_factor_variables(
|
|
336
|
+
self, frame, levels: list[str], factor_variables: list[str]
|
|
337
|
+
):
|
|
338
|
+
"""Join grouping variables onto the frame as ordinary factor columns.
|
|
339
|
+
|
|
340
|
+
A ``Condition`` recorded per subject is broadcast down to every one of
|
|
341
|
+
that subject's rows — the same prefix-merge :mod:`.hierarchy` performs
|
|
342
|
+
for an x measure, and the reason "stim vs sham" needs no new concept
|
|
343
|
+
once the database already records it.
|
|
344
|
+
|
|
345
|
+
A grouping variable's own **variant columns are deliberately dropped**.
|
|
346
|
+
Which version of the code produced a group label is not what the figure
|
|
347
|
+
is comparing, and carrying those columns in would put a variant factor
|
|
348
|
+
on screen that ``roles.validate`` then demands a role for. If a
|
|
349
|
+
variable's variants are the subject, it belongs in a variant row.
|
|
350
|
+
"""
|
|
351
|
+
attached: list[str] = []
|
|
352
|
+
if not factor_variables:
|
|
353
|
+
return frame, attached
|
|
354
|
+
|
|
355
|
+
for name in factor_variables:
|
|
356
|
+
variable = self._variable_frame(name)
|
|
357
|
+
if len(variable.data_columns) > 1:
|
|
358
|
+
raise ValueError(
|
|
359
|
+
f"{name!r} stores one column per dict/struct field, so it "
|
|
360
|
+
f"has no single value to group by."
|
|
361
|
+
)
|
|
362
|
+
if len(variable.levels) > len(levels) or levels[
|
|
363
|
+
: len(variable.levels)
|
|
364
|
+
] != list(variable.levels):
|
|
365
|
+
# Deeper than the data, or a different branch of the schema:
|
|
366
|
+
# merging would multiply rows or match nothing, and either way
|
|
367
|
+
# the figure would be quietly wrong about how many observations
|
|
368
|
+
# it holds.
|
|
369
|
+
raise ValueError(
|
|
370
|
+
f"{name!r} sits at {variable.levels}, which is not a prefix "
|
|
371
|
+
f"of {levels} — there is no unambiguous way to attach one "
|
|
372
|
+
f"of its values to each row. Group by a variable recorded "
|
|
373
|
+
f"at or above the level of the data."
|
|
374
|
+
)
|
|
375
|
+
on = list(variable.levels)
|
|
376
|
+
right = self._named_frame(variable, name)[[*on, name]].drop_duplicates(
|
|
377
|
+
subset=on
|
|
378
|
+
)
|
|
379
|
+
frame = frame.merge(right, on=on, how="left")
|
|
380
|
+
attached.append(name)
|
|
381
|
+
Log.info(
|
|
382
|
+
"attached %r as a factor on %s (%d level(s))",
|
|
383
|
+
name,
|
|
384
|
+
on,
|
|
385
|
+
frame[name].nunique(dropna=True),
|
|
386
|
+
layer=LAYER,
|
|
387
|
+
)
|
|
388
|
+
return frame, attached
|
|
389
|
+
|
|
390
|
+
def _stacked_table(
|
|
391
|
+
self, measures: list[str], factor_variables: list[str] | None = None
|
|
392
|
+
) -> LongTable:
|
|
393
|
+
"""Several variables as ONE measure plus a ``Variable`` column.
|
|
394
|
+
|
|
395
|
+
The long form a multi-series figure needs: Raw and Filtered become rows
|
|
396
|
+
of the same value column, told apart by a column the variant rows then
|
|
397
|
+
consume into the ``Variant`` factor.
|
|
398
|
+
|
|
399
|
+
The value column is named after the **primary** measure, so everything
|
|
400
|
+
downstream — ``spec.y_measure``, the renderers, the generated
|
|
401
|
+
``y=`` argument — keeps working unchanged, and the measure's *label*
|
|
402
|
+
carries every variable's name so the axis does not claim to be one of
|
|
403
|
+
them. The alternative, a neutral column name, would have made
|
|
404
|
+
``spec.measures`` stop naming a real variable.
|
|
405
|
+
"""
|
|
406
|
+
frames = [self._variable_frame(name) for name in measures]
|
|
407
|
+
|
|
408
|
+
# Dict/struct variables stack too, provided they carry the SAME fields:
|
|
409
|
+
# RawEMG and FilteredEMG, both keyed by muscle, are the archetypal
|
|
410
|
+
# "plot these two together" case. Each is melted into one value column
|
|
411
|
+
# plus a shared ``ColName`` factor first, so what stacks is the melted
|
|
412
|
+
# long form — after which nothing downstream can tell the difference
|
|
413
|
+
# between this and two scalar variables.
|
|
414
|
+
multi = [f for f in frames if len(f.data_columns) > 1]
|
|
415
|
+
if multi and len(multi) != len(frames):
|
|
416
|
+
single = [f.name for f in frames if len(f.data_columns) == 1]
|
|
417
|
+
raise ValueError(
|
|
418
|
+
f"{[f.name for f in multi]} store one column per dict/struct "
|
|
419
|
+
f"field, but {single} store a single value — there is no "
|
|
420
|
+
f"correspondence between one number and a set of fields. Plot "
|
|
421
|
+
f"them separately."
|
|
422
|
+
)
|
|
423
|
+
field_columns: list[str] = []
|
|
424
|
+
if multi:
|
|
425
|
+
shared = set(multi[0].data_columns)
|
|
426
|
+
for frame in multi[1:]:
|
|
427
|
+
shared &= set(frame.data_columns)
|
|
428
|
+
if not shared:
|
|
429
|
+
raise ValueError(
|
|
430
|
+
f"{measures} share no fields — "
|
|
431
|
+
+ "; ".join(
|
|
432
|
+
f"{f.name} has {sorted(f.data_columns)}" for f in multi
|
|
433
|
+
)
|
|
434
|
+
+ ". Stacking them would put every field on its own subplot "
|
|
435
|
+
"with a single series, which is not a comparison."
|
|
436
|
+
)
|
|
437
|
+
differing = {
|
|
438
|
+
f.name: sorted(set(f.data_columns) - shared)
|
|
439
|
+
for f in multi
|
|
440
|
+
if set(f.data_columns) != shared
|
|
441
|
+
}
|
|
442
|
+
if differing:
|
|
443
|
+
# Not fatal — the shared fields still compare — but never
|
|
444
|
+
# silent: a muscle missing from one variable would otherwise
|
|
445
|
+
# look like a subplot that simply has less data.
|
|
446
|
+
Log.warn(
|
|
447
|
+
"stacking %s on their %d shared field(s); these appear in "
|
|
448
|
+
"only one variable and are dropped: %s",
|
|
449
|
+
measures,
|
|
450
|
+
len(shared),
|
|
451
|
+
differing,
|
|
452
|
+
layer=LAYER,
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
shapes = {f.name: self._shape_of(f.name) for f in frames}
|
|
456
|
+
distinct = set(shapes.values())
|
|
457
|
+
if len(distinct) > 1:
|
|
458
|
+
raise ValueError(
|
|
459
|
+
f"Variables plotted together must hold the same kind of value; "
|
|
460
|
+
f"got { {k: str(v) for k, v in shapes.items()} }. A scalar and a "
|
|
461
|
+
f"1-D signal have no common axis to share."
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
levels = frames[0].levels
|
|
465
|
+
mismatched = {f.name: f.levels for f in frames if f.levels != levels}
|
|
466
|
+
if mismatched:
|
|
467
|
+
# Deliberately refused rather than broadcast. Broadcasting a
|
|
468
|
+
# subject-level value across that subject's trials would make one
|
|
469
|
+
# observation look like several — fine for an x axis (one x per y,
|
|
470
|
+
# which `x_measure` does) but a silent inflation of n when the rows
|
|
471
|
+
# are the data. Say so instead of guessing.
|
|
472
|
+
raise ValueError(
|
|
473
|
+
f"Variables plotted together must sit at the same schema level; "
|
|
474
|
+
f"{measures[0]} is at {levels} but { mismatched } differ. Plot "
|
|
475
|
+
f"them separately, or pair them with x_measure for a relational "
|
|
476
|
+
f"plot (which broadcasts the shallower one)."
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
primary = measures[0]
|
|
480
|
+
stacked = []
|
|
481
|
+
for frame in frames:
|
|
482
|
+
if multi:
|
|
483
|
+
# Melt to the SHARED fields only, and into the primary's value
|
|
484
|
+
# column, so every variable contributes the same two columns.
|
|
485
|
+
named, field_factor = self._melt_fields(
|
|
486
|
+
frame, primary, fields=sorted(shared)
|
|
487
|
+
)
|
|
488
|
+
if field_factor not in field_columns:
|
|
489
|
+
field_columns.append(field_factor)
|
|
490
|
+
else:
|
|
491
|
+
named = self._named_frame(frame, primary)
|
|
492
|
+
named[VARIABLE_COLUMN] = frame.name
|
|
493
|
+
stacked.append(named)
|
|
494
|
+
combined = pd.concat(stacked, ignore_index=True, sort=False)
|
|
495
|
+
combined, group_columns = self._attach_factor_variables(
|
|
496
|
+
combined, levels, list(factor_variables or [])
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
variant_columns = list(
|
|
500
|
+
dict.fromkeys(c for f in frames for c in f.variant_columns)
|
|
501
|
+
)
|
|
502
|
+
variant_axes = list(
|
|
503
|
+
{axis["column"]: axis for f in frames for axis in f.variant_axes}.values()
|
|
504
|
+
)
|
|
505
|
+
latest_column = (
|
|
506
|
+
LATEST_COLUMN if LATEST_COLUMN in combined.columns else None
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
factors = [c for c in variant_columns if c in combined.columns]
|
|
510
|
+
factors.extend(key for key in levels if key in combined.columns)
|
|
511
|
+
factors.extend(group_columns)
|
|
512
|
+
factors.extend(c for c in field_columns if c in combined.columns)
|
|
513
|
+
factors.append(VARIABLE_COLUMN)
|
|
514
|
+
level_order = {
|
|
515
|
+
name: self._ordered(
|
|
516
|
+
name, [str(v) for v in combined[name].dropna().unique()]
|
|
517
|
+
)
|
|
518
|
+
for name in factors
|
|
519
|
+
}
|
|
520
|
+
# Declared order, not observed: the user listed the variables.
|
|
521
|
+
level_order[VARIABLE_COLUMN] = list(measures)
|
|
522
|
+
|
|
523
|
+
table = LongTable.from_frame(
|
|
524
|
+
combined,
|
|
525
|
+
factors=factors,
|
|
526
|
+
measures=[primary],
|
|
527
|
+
level_order=level_order,
|
|
528
|
+
variant_factors=variant_columns,
|
|
529
|
+
# Marked as fields so `default_roles` gives them one subplot each —
|
|
530
|
+
# 13 muscles overplotted on one axis is not a figure anyone wanted,
|
|
531
|
+
# and that must hold whether one dict variable is plotted or two.
|
|
532
|
+
field_factors=field_columns,
|
|
533
|
+
name=primary,
|
|
534
|
+
# No latest pin: the flag means different things per variable, and
|
|
535
|
+
# the rows the user named are what selects here.
|
|
536
|
+
default_pin=None,
|
|
537
|
+
latest_column=latest_column,
|
|
538
|
+
factor_origins={axis["column"]: axis for axis in variant_axes},
|
|
539
|
+
schema_levels=levels,
|
|
540
|
+
measure_labels={primary: " / ".join(measures)},
|
|
541
|
+
)
|
|
542
|
+
Log.info(
|
|
543
|
+
"stacked %s into one value column: %d row(s), levels=%s",
|
|
544
|
+
measures,
|
|
545
|
+
len(combined),
|
|
546
|
+
levels,
|
|
547
|
+
layer=LAYER,
|
|
548
|
+
)
|
|
549
|
+
return table
|
|
550
|
+
|
|
551
|
+
def variant_graph(self, variable: str, functions: list[str] | None = None) -> dict:
|
|
552
|
+
"""Variant axes and per-function versions for ``variable``.
|
|
553
|
+
|
|
554
|
+
A method rather than a bare function so it reuses this source's frame
|
|
555
|
+
cache: the picker opens over a variable the panel has already loaded,
|
|
556
|
+
and re-reading it to answer "what versions exist" would double the cost
|
|
557
|
+
of opening a dialog.
|
|
558
|
+
"""
|
|
559
|
+
from .variants import variant_graph
|
|
560
|
+
|
|
561
|
+
return variant_graph(self._db, self._variable_frame(variable), functions)
|
|
562
|
+
|
|
563
|
+
def _melt_fields(
|
|
564
|
+
self, variable_frame, measure: str, *, fields: list[str] | None = None
|
|
565
|
+
):
|
|
566
|
+
"""
|
|
567
|
+
Turn a dict/struct variable's columns into ONE measure plus a field
|
|
568
|
+
factor.
|
|
569
|
+
|
|
570
|
+
scidb stores a dict-valued variable in ``multi_column`` mode — one
|
|
571
|
+
DuckDB column per key (13 muscles of an EMG record become 13 columns;
|
|
572
|
+
see docs/claude/multi-column-save-schema.md). Those columns are
|
|
573
|
+
parallel quantities, not separate variables, so melting them into long
|
|
574
|
+
format makes the field name an ordinary factor. It then gets one
|
|
575
|
+
subplot per level by default (``default_roles``), and the user can move
|
|
576
|
+
it to colour or separate figures like any other factor — which beats
|
|
577
|
+
hardcoding subplots into the renderer.
|
|
578
|
+
"""
|
|
579
|
+
frame = variable_frame.frame
|
|
580
|
+
usable, skipped = [], []
|
|
581
|
+
for column in variable_frame.data_columns:
|
|
582
|
+
if is_plottable(classify_column(frame[column])):
|
|
583
|
+
usable.append(column)
|
|
584
|
+
else:
|
|
585
|
+
skipped.append(column)
|
|
586
|
+
if skipped:
|
|
587
|
+
Log.warn(
|
|
588
|
+
"variable %r: %d field(s) are not plottable and were dropped: %s",
|
|
589
|
+
variable_frame.name,
|
|
590
|
+
len(skipped),
|
|
591
|
+
skipped,
|
|
592
|
+
layer=LAYER,
|
|
593
|
+
)
|
|
594
|
+
if not usable:
|
|
595
|
+
raise ValueError(
|
|
596
|
+
f"Variable {variable_frame.name!r} has no plottable fields "
|
|
597
|
+
f"(columns: {variable_frame.data_columns})."
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
if fields is not None:
|
|
601
|
+
# Stacking with another dict variable: melt only the fields they
|
|
602
|
+
# share, so every variable contributes the same ColName levels.
|
|
603
|
+
usable = [column for column in usable if column in set(fields)]
|
|
604
|
+
|
|
605
|
+
id_vars = [c for c in frame.columns if c not in variable_frame.data_columns]
|
|
606
|
+
field_factor = FIELD_FACTOR
|
|
607
|
+
while field_factor in id_vars: # never shadow a schema key
|
|
608
|
+
field_factor += "_"
|
|
609
|
+
|
|
610
|
+
melted = frame.melt(
|
|
611
|
+
id_vars=id_vars,
|
|
612
|
+
value_vars=usable,
|
|
613
|
+
var_name=field_factor,
|
|
614
|
+
value_name=measure,
|
|
615
|
+
)
|
|
616
|
+
Log.info(
|
|
617
|
+
"melted %r: %d field(s) -> %d row(s), field factor %r",
|
|
618
|
+
variable_frame.name,
|
|
619
|
+
len(usable),
|
|
620
|
+
len(melted),
|
|
621
|
+
field_factor,
|
|
622
|
+
layer=LAYER,
|
|
623
|
+
)
|
|
624
|
+
return melted, field_factor
|
|
625
|
+
|
|
626
|
+
def _named_frame(self, variable_frame, measure: str):
|
|
627
|
+
"""The variable's frame with its data column renamed to the measure."""
|
|
628
|
+
return variable_frame.frame.rename(
|
|
629
|
+
columns={self._value_column(variable_frame): measure}
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
def _value_column(self, variable_frame) -> str:
|
|
633
|
+
columns = variable_frame.data_columns
|
|
634
|
+
if not columns:
|
|
635
|
+
raise ValueError(
|
|
636
|
+
f"Variable {variable_frame.name!r} has no data column to plot."
|
|
637
|
+
)
|
|
638
|
+
if len(columns) > 1:
|
|
639
|
+
Log.warn(
|
|
640
|
+
"variable %r has %d data columns %s — plotting the first",
|
|
641
|
+
variable_frame.name,
|
|
642
|
+
len(columns),
|
|
643
|
+
columns,
|
|
644
|
+
layer=LAYER,
|
|
645
|
+
)
|
|
646
|
+
return columns[0]
|
|
647
|
+
|
|
648
|
+
def stackable_with(self, measure: str) -> list[str]:
|
|
649
|
+
"""Variables that can be plotted as another series alongside ``measure``.
|
|
650
|
+
|
|
651
|
+
The offers only. :meth:`stackable_report` is the same computation with
|
|
652
|
+
the refusals kept, for a caller that has to *show* the rejected
|
|
653
|
+
candidates rather than silently omit them.
|
|
654
|
+
"""
|
|
655
|
+
return self.stackable_report(measure)["offered"]
|
|
656
|
+
|
|
657
|
+
def stackable_report(self, measure: str) -> dict:
|
|
658
|
+
"""Variables that can be plotted as another SERIES alongside ``measure``.
|
|
659
|
+
|
|
660
|
+
Stacking needs the same shape (a scalar and a signal share no axis) and
|
|
661
|
+
the same schema level (see :meth:`_stacked_table` on why the shallower
|
|
662
|
+
one is not broadcast).
|
|
663
|
+
|
|
664
|
+
Dict/struct variables stack with each other when they **share fields** —
|
|
665
|
+
RawEMG and FilteredEMG, both keyed by muscle, is the archetypal case —
|
|
666
|
+
and each is melted to those shared fields first. What cannot stack is a
|
|
667
|
+
dict with a plain value: there is no correspondence between one number
|
|
668
|
+
and a set of fields.
|
|
669
|
+
|
|
670
|
+
Distinct from :meth:`joinable_with`, which answers the different
|
|
671
|
+
question of what can supply an x axis.
|
|
672
|
+
|
|
673
|
+
Returns ``{"offered": [...], "rejected": {name: reason}}``. The refusals
|
|
674
|
+
were computed here from the beginning but only ever logged; the variant
|
|
675
|
+
picker draws every variable on the pipeline canvas, so a candidate it
|
|
676
|
+
cannot offer has to say **why** in place rather than be absent. "EMG is
|
|
677
|
+
not clickable" with no reason is indistinguishable from a broken dialog.
|
|
678
|
+
"""
|
|
679
|
+
own_shape = self._shape_of(measure)
|
|
680
|
+
own_levels = self._levels_of(measure)
|
|
681
|
+
own_columns = data_columns_for(self._db, measure)
|
|
682
|
+
own_fields = set(own_columns) if len(own_columns) > 1 else None
|
|
683
|
+
result: list[str] = []
|
|
684
|
+
rejected: dict[str, str] = {}
|
|
685
|
+
for candidate in registered_variables(self._db):
|
|
686
|
+
if candidate == measure:
|
|
687
|
+
continue
|
|
688
|
+
shape = self._shape_of(candidate)
|
|
689
|
+
levels = self._levels_of(candidate)
|
|
690
|
+
columns = data_columns_for(self._db, candidate)
|
|
691
|
+
fields = set(columns) if len(columns) > 1 else None
|
|
692
|
+
if shape is not own_shape:
|
|
693
|
+
rejected[candidate] = f"shape {shape} != {own_shape}"
|
|
694
|
+
elif (fields is None) != (own_fields is None):
|
|
695
|
+
rejected[candidate] = (
|
|
696
|
+
"one is a dict/struct and the other a single value"
|
|
697
|
+
)
|
|
698
|
+
elif fields is not None and not (fields & own_fields):
|
|
699
|
+
rejected[candidate] = (
|
|
700
|
+
f"no shared fields (has {sorted(fields)})"
|
|
701
|
+
)
|
|
702
|
+
elif levels != own_levels:
|
|
703
|
+
rejected[candidate] = f"schema level {levels} != {own_levels}"
|
|
704
|
+
else:
|
|
705
|
+
result.append(candidate)
|
|
706
|
+
# Say why, per candidate. An empty dropdown is indistinguishable from a
|
|
707
|
+
# missing feature, and the three criteria here are strict enough that a
|
|
708
|
+
# variable a user expected to see is the likely case, not the rare one.
|
|
709
|
+
Log.info(
|
|
710
|
+
"stackable_with(%s): %d offered %s; %d rejected %s",
|
|
711
|
+
measure,
|
|
712
|
+
len(result),
|
|
713
|
+
result,
|
|
714
|
+
len(rejected),
|
|
715
|
+
rejected or "",
|
|
716
|
+
layer=LAYER,
|
|
717
|
+
)
|
|
718
|
+
return {"offered": result, "rejected": rejected}
|
|
719
|
+
|
|
720
|
+
def groupable_with(self, measure: str) -> list[str]:
|
|
721
|
+
"""Variables usable as a grouping FACTOR for ``measure``.
|
|
722
|
+
|
|
723
|
+
Anything recorded at or above the measure's level, with one data column
|
|
724
|
+
— a per-subject ``Condition``, a per-session ``Protocol``. Categorical
|
|
725
|
+
ones come first because that is what a group usually is, but numeric
|
|
726
|
+
ones are offered too rather than guessed at: a group coded ``1``/``2``
|
|
727
|
+
is a group, and ``sources/csv.py`` already documents that bare numeric
|
|
728
|
+
IDs are indistinguishable from measurements by shape alone.
|
|
729
|
+
"""
|
|
730
|
+
own = self._levels_of(measure)
|
|
731
|
+
categorical: list[str] = []
|
|
732
|
+
other: list[str] = []
|
|
733
|
+
for candidate in registered_variables(self._db):
|
|
734
|
+
if candidate == measure:
|
|
735
|
+
continue
|
|
736
|
+
if len(data_columns_for(self._db, candidate)) > 1:
|
|
737
|
+
continue
|
|
738
|
+
levels = self._levels_of(candidate)
|
|
739
|
+
if len(levels) > len(own) or own[: len(levels)] != levels:
|
|
740
|
+
continue
|
|
741
|
+
target = (
|
|
742
|
+
categorical
|
|
743
|
+
if self._shape_of(candidate) is Shape.CATEGORICAL
|
|
744
|
+
else other
|
|
745
|
+
)
|
|
746
|
+
target.append(candidate)
|
|
747
|
+
return categorical + other
|
|
748
|
+
|
|
749
|
+
def joinable_with(self, measure: str) -> list[str]:
|
|
750
|
+
"""Variables that can supply an x axis for ``measure``."""
|
|
751
|
+
own = self._levels_of(measure)
|
|
752
|
+
result = []
|
|
753
|
+
for candidate in registered_variables(self._db):
|
|
754
|
+
if candidate == measure:
|
|
755
|
+
continue
|
|
756
|
+
if self._shape_of(candidate) is not Shape.SCALAR:
|
|
757
|
+
continue # an x axis must be scalar
|
|
758
|
+
if len(data_columns_for(self._db, candidate)) > 1:
|
|
759
|
+
continue # a struct has no single value to put on an axis
|
|
760
|
+
if joinable(own, self._levels_of(candidate)):
|
|
761
|
+
result.append(candidate)
|
|
762
|
+
return result
|
|
763
|
+
|
|
764
|
+
def default_measure(self) -> str | None:
|
|
765
|
+
for variable in registered_variables(self._db):
|
|
766
|
+
if self._shape_of(variable) in (Shape.SCALAR, Shape.SERIES_1D):
|
|
767
|
+
return variable
|
|
768
|
+
return None
|
|
769
|
+
|
|
770
|
+
def invalidate(self, variable: str | None = None) -> None:
|
|
771
|
+
"""Drop cached frames after a pipeline run has written new records."""
|
|
772
|
+
if variable is None:
|
|
773
|
+
self._frames.clear()
|
|
774
|
+
self._shapes.clear()
|
|
775
|
+
self._levels.clear()
|
|
776
|
+
else:
|
|
777
|
+
self._frames.pop(variable, None)
|
|
778
|
+
self._shapes.pop(variable, None)
|
|
779
|
+
self._levels.pop(variable, None)
|
|
780
|
+
# Built tables are derived from those frames, so they are stale too —
|
|
781
|
+
# and they are keyed by measure NAMES, which cannot say which variable a
|
|
782
|
+
# stacked table drew from. Dropping all of them is the only answer that
|
|
783
|
+
# is right for the per-variable case as well.
|
|
784
|
+
self.invalidate_tables()
|