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.
@@ -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()