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,80 @@
1
+ """
2
+ scistackplotdb — plot scidb variables.
3
+
4
+ Loads variables into the long format ``scistackplot`` consumes, adds the four
5
+ things a flat table never needed (shape classification, schema-depth joins,
6
+ variants as factors, and a transport budget), and generates pipeline endpoints
7
+ from a finished spec.
8
+
9
+ ::
10
+
11
+ from scidb import configure_database
12
+ from scistackplot import PlotSpec, Role, PlotKind, render
13
+ from scistackplotdb import ScidbSource
14
+
15
+ db = configure_database("experiment.duckdb", ["subject", "session", "trial"])
16
+ source = ScidbSource(db)
17
+
18
+ table = source.get_table(["StepLength"])
19
+ spec = PlotSpec(
20
+ measures=["StepLength"],
21
+ roles={"session": Role.X, "subject": Role.FREE, "trial": Role.FREE},
22
+ kind=PlotKind.BOX,
23
+ )
24
+ figure = render(table, spec)
25
+
26
+ Everything about recording a figure — ``finalized``, artifact stamping,
27
+ ``scidb report`` — belongs to scidb's existing ``plot_`` endpoint machinery and
28
+ is unchanged. See ``docs/claude/plotting-library-design.md``.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from .endpoint import (
34
+ EndpointCode,
35
+ default_output_variable,
36
+ default_path_template,
37
+ generate_endpoint,
38
+ )
39
+ from .hierarchy import join_frames, join_kind, joinable, joined_levels
40
+ from .load import (
41
+ LATEST_COLUMN,
42
+ MISSING_VERSION_LEVEL,
43
+ VERSION_FACTOR_PREFIX,
44
+ VariableFrame,
45
+ attach_variants,
46
+ data_columns_for,
47
+ load_variable,
48
+ registered_variables,
49
+ sample_value,
50
+ schema_keys,
51
+ )
52
+ from .source import ScidbSource
53
+ from .variants import selection_for, variant_graph, variant_set
54
+
55
+ __all__ = [
56
+ "ScidbSource",
57
+ "variant_set",
58
+ "variant_graph",
59
+ "selection_for",
60
+ "VariableFrame",
61
+ "VERSION_FACTOR_PREFIX",
62
+ "MISSING_VERSION_LEVEL",
63
+ "LATEST_COLUMN",
64
+ "load_variable",
65
+ "attach_variants",
66
+ "registered_variables",
67
+ "schema_keys",
68
+ "data_columns_for",
69
+ "sample_value",
70
+ "join_kind",
71
+ "joinable",
72
+ "join_frames",
73
+ "joined_levels",
74
+ "generate_endpoint",
75
+ "EndpointCode",
76
+ "default_output_variable",
77
+ "default_path_template",
78
+ ]
79
+
80
+ __version__ = "0.1.0"
@@ -0,0 +1,257 @@
1
+ """
2
+ Turning a ``PlotSpec`` into a pipeline endpoint.
3
+
4
+ The GUI's "Add to pipeline" produces two things: a ``plot_`` function (generated
5
+ by ``scistackplot.codegen`` — literal seaborn, no runtime dependency on this
6
+ package) and the ``for_each`` call that runs it. This module owns the second
7
+ half, and with it the one translation that has to be exactly right:
8
+
9
+ Role.ITERATE -> a for_each iteration keyword
10
+
11
+ Interactively, ITERATE fans out through a pandas ``groupby`` inside
12
+ ``resolve()``. In the pipeline it fans out through ``for_each`` + ``PathOutput``.
13
+ If those two ever disagree, the exported pipeline is not what the user
14
+ previewed — the worst failure mode this layer has, and what
15
+ ``tests/test_fanout_parity.py`` exists to prevent.
16
+
17
+ Everything else the endpoint needs already exists in scidb: ``finalized``,
18
+ artifact stamping, ``skip_computed`` and ``scidb report`` are untouched.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from dataclasses import dataclass, field
25
+
26
+ from scistacklog import Log
27
+ from scistackplot import LongTable, PlotSpec, default_function_name
28
+ from scistackplot import generate_plot_function
29
+ from scistackplot.codegen import group_param, variant_params
30
+ from scistackplot.roles import fanout_keys
31
+ from scistackplot.variants import defined_sets
32
+
33
+ from .load import LATEST_COLUMN
34
+
35
+ LAYER = "scistackplotdb"
36
+
37
+
38
+ @dataclass
39
+ class EndpointCode:
40
+ """Generated source for one plotting endpoint."""
41
+
42
+ function_name: str
43
+ function_source: str
44
+ foreach_source: str
45
+ iterate_keys: list[str] = field(default_factory=list)
46
+ path_template: str = ""
47
+ output_variable: str = ""
48
+
49
+ @property
50
+ def source(self) -> str:
51
+ """Function and call together, ready to append to a pipeline module."""
52
+ return f"{self.function_source}\n\n{self.foreach_source}"
53
+
54
+
55
+ def generate_endpoint(
56
+ spec: PlotSpec,
57
+ table: LongTable,
58
+ *,
59
+ input_variable: str,
60
+ function_name: str | None = None,
61
+ output_variable: str | None = None,
62
+ path_template: str | None = None,
63
+ finalized: bool = True,
64
+ x_variable: str | None = None,
65
+ ) -> EndpointCode:
66
+ """
67
+ Generate the ``plot_`` function and its ``for_each`` call.
68
+
69
+ ``input_variable`` is the scidb variable type supplying the data;
70
+ ``x_variable`` is the optional second measure for an x–y scatter.
71
+ """
72
+ name = function_name or default_function_name(spec)
73
+ # NOT spec.iterate_factors: the fan-out includes schema keys promoted
74
+ # because a nested key iterates, and runs in schema order. The preview and
75
+ # the generated for_each have to agree on both — that is what
76
+ # tests/test_fanout_parity.py checks.
77
+ iterate_keys = fanout_keys(spec, table)
78
+ output = output_variable or default_output_variable(input_variable)
79
+ template = path_template or default_path_template(name, iterate_keys)
80
+
81
+ function_source = generate_plot_function(spec, table, function_name=name)
82
+ foreach_source = _foreach_call(
83
+ spec=spec,
84
+ function_name=name,
85
+ input_variable=input_variable,
86
+ x_variable=x_variable,
87
+ output_variable=output,
88
+ path_template=template,
89
+ iterate_keys=iterate_keys,
90
+ finalized=finalized,
91
+ )
92
+
93
+ Log.info(
94
+ "generated endpoint %s: input=%s output=%s iterate=%s finalized=%s",
95
+ name,
96
+ input_variable,
97
+ output,
98
+ iterate_keys or "none",
99
+ finalized,
100
+ layer=LAYER,
101
+ )
102
+ return EndpointCode(
103
+ function_name=name,
104
+ function_source=function_source,
105
+ foreach_source=foreach_source,
106
+ iterate_keys=iterate_keys,
107
+ path_template=template,
108
+ output_variable=output,
109
+ )
110
+
111
+
112
+ def default_output_variable(input_variable: str) -> str:
113
+ """``StepLength`` -> ``StepLengthFigure``."""
114
+ return f"{input_variable}Figure"
115
+
116
+
117
+ def default_path_template(function_name: str, iterate_keys: list[str]) -> str:
118
+ """
119
+ Build a PathOutput template that cannot collide.
120
+
121
+ Every ITERATE key goes into the filename. Omitting one would make two
122
+ figures write the same file; for schema keys scidb treats that as
123
+ pre-existing overwrite behavior (no error), and for variants its collision
124
+ guard raises before anything renders. Including them all avoids both.
125
+ """
126
+ slug = re.sub(r"^plot_", "", function_name)
127
+ parts = "".join(f"_{{{key}}}" for key in iterate_keys)
128
+ return f"plots/{slug}{parts}.png"
129
+
130
+
131
+ def variant_expression(input_variable: str, variant_set) -> str:
132
+ """A ``Variant(...)`` call selecting one named variant's records.
133
+
134
+ The inverse of :func:`~scistackplotdb.variants.selection_for`, and the thing
135
+ that makes a variant figure reproducible by hand: what the popup's
136
+ checkboxes and version dropdowns produced comes back out as the same
137
+ wrapper a scientist would have typed.
138
+
139
+ Selections spanning two producing functions **nest** rather than resorting
140
+ to dotted-string kwargs::
141
+
142
+ Variant(Variant(EMG, fn="loadEMG", code_version="v1"), fn="bandpass", low_hz=20)
143
+
144
+ Nesting is documented, merges the two filters (``scidb.variant.Variant``
145
+ handles it explicitly), and keeps every keyword readable — where
146
+ ``**{"__code__.loadEMG": "v1"}`` would leak a reserved namespace into code a
147
+ user is meant to edit.
148
+ """
149
+ from scistackplot import CODE_FACTOR_PREFIX, LATEST
150
+
151
+ by_function: dict[str, dict[str, object]] = {}
152
+ for column, value in (variant_set.selection or {}).items():
153
+ if column.startswith(CODE_FACTOR_PREFIX):
154
+ fn_name = column[len(CODE_FACTOR_PREFIX) :]
155
+ by_function.setdefault(fn_name, {})["code_version"] = value
156
+ elif column == LATEST_COLUMN:
157
+ # The source's "these are the current records" recommendation. scidb
158
+ # spells the same thing `code_version="latest"`, resolved per schema
159
+ # location by the same rule — not "the highest ordinal".
160
+ by_function.setdefault(None, {})["code_version"] = LATEST
161
+ else:
162
+ fn_name, _, param = column.rpartition(".")
163
+ by_function.setdefault(fn_name or None, {})[param] = value
164
+
165
+ expression = input_variable
166
+ for fn_name in sorted(by_function, key=lambda n: (n is None, n or "")):
167
+ arguments = []
168
+ if fn_name:
169
+ arguments.append(f"fn={fn_name!r}")
170
+ arguments.extend(f"{key}={value!r}" for key, value in by_function[fn_name].items())
171
+ expression = f"Variant({expression}, {', '.join(arguments)})"
172
+ return expression
173
+
174
+
175
+ def _single_variant_expression(input_variable: str, spec) -> str:
176
+ """The lone variant's pin, or the bare variable when nothing is selected.
177
+
178
+ The row may name its own variable, in which case that is what the endpoint
179
+ loads — ``input_variable`` is only the default.
180
+ """
181
+ sets = defined_sets(spec.variant_sets)
182
+ if len(sets) != 1:
183
+ return input_variable
184
+ return variant_expression(sets[0].variable or input_variable, sets[0])
185
+
186
+
187
+ def _foreach_call(
188
+ *,
189
+ spec: PlotSpec,
190
+ function_name: str,
191
+ input_variable: str,
192
+ x_variable: str | None,
193
+ output_variable: str,
194
+ path_template: str,
195
+ iterate_keys: list[str],
196
+ finalized: bool,
197
+ ) -> str:
198
+ variant_inputs = variant_params(spec)
199
+ if variant_inputs:
200
+ # One input per named variant, each loaded through its own pin. See
201
+ # `codegen.variant_params` for why this cannot be a single `df`.
202
+ # Zipped against `defined_sets`, not `spec.variant_sets`: unfilled rows
203
+ # produce no input, so indexing the raw list would pair a parameter with
204
+ # the wrong variant's selection.
205
+ inputs = [
206
+ f' "{generated.param}": '
207
+ f"{variant_expression(generated.variable, variant)},"
208
+ for generated, variant in zip(
209
+ variant_inputs, defined_sets(spec.variant_sets), strict=True
210
+ )
211
+ ]
212
+ table_inputs = [generated.param for generated in variant_inputs]
213
+ else:
214
+ pinned = _single_variant_expression(input_variable, spec)
215
+ inputs = [f' "df": {pinned},']
216
+ table_inputs = ["df"]
217
+ if x_variable:
218
+ inputs.append(f' "df_x": {x_variable},')
219
+ table_inputs.append("df_x")
220
+ for group in spec.factor_variables:
221
+ # A grouping variable arrives as its own input and is merged onto the
222
+ # data inside the function: `as_table` hands a function schema keys and
223
+ # data columns only, so a subject-level Condition cannot ride along on
224
+ # the measure's frame.
225
+ inputs.append(f' "{group_param(group)}": {group},')
226
+ table_inputs.append(group_param(group))
227
+ inputs.append(f' "filename": PathOutput("{path_template}"),')
228
+
229
+ lines = [
230
+ "for_each(",
231
+ f" {function_name},",
232
+ " inputs={",
233
+ *inputs,
234
+ " },",
235
+ f" outputs=[{output_variable}],",
236
+ # A plot_ function receives the long-format table (schema keys as
237
+ # columns) — as_table defaults ON only for stat_, so say it explicitly.
238
+ f" as_table={table_inputs!r},",
239
+ f" finalized={finalized},",
240
+ ]
241
+ for key in iterate_keys:
242
+ # [] means "every value present" — the same all-values resolution
243
+ # scifor applies to an empty iteration list.
244
+ lines.append(f" {key}=[],")
245
+ lines.append(")")
246
+ return "\n".join(lines)
247
+
248
+
249
+ def required_declarations(code: EndpointCode) -> list[str]:
250
+ """
251
+ Variable types the generated call needs that may not exist yet.
252
+
253
+ The GUI declares these through the normal entity-declaration path before
254
+ writing the code, so a generated endpoint never references an undeclared
255
+ type.
256
+ """
257
+ return [code.output_variable]
@@ -0,0 +1,138 @@
1
+ """
2
+ Schema-depth joins.
3
+
4
+ Two variables can share a plot only if their schema levels can be joined.
5
+ Because the dataset schema is an ordered, contiguous hierarchy (see
6
+ ``docs/claude/schema-hierarchy-contiguity.md``), a variable's levels are always
7
+ a *prefix* of the schema key list — so the join rule is simple and total:
8
+
9
+ * identical levels → a straight merge on all of them;
10
+ * one a prefix of the other → **broadcast**: the shallower variable's value is
11
+ repeated across every deeper row beneath it (subject-level Mass against
12
+ trial-level Speed);
13
+ * otherwise → refuse, and say why.
14
+
15
+ Answering this question is also what lets the GUI populate its measure list
16
+ honestly instead of offering combinations that cannot be built.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Literal
22
+
23
+ import pandas as pd
24
+ from scistacklog import Log
25
+
26
+ from .load import LATEST_COLUMN, VariableFrame
27
+
28
+ LAYER = "scistackplotdb"
29
+
30
+ JoinKind = Literal["identical", "broadcast", "incompatible"]
31
+
32
+ #: Private carriers for each side's latest-flag across a merge. Never leave
33
+ #: :func:`join_frames` — they are combined into one :data:`LATEST_COLUMN` and
34
+ #: dropped, so nothing downstream has to know a join happened.
35
+ _LEFT_LATEST = "__latest_left"
36
+ _RIGHT_LATEST = "__latest_right"
37
+
38
+
39
+ def join_kind(levels_a: list[str], levels_b: list[str]) -> JoinKind:
40
+ if levels_a == levels_b:
41
+ return "identical"
42
+ shallow, deep = (levels_a, levels_b) if len(levels_a) < len(levels_b) else (levels_b, levels_a)
43
+ if deep[: len(shallow)] == shallow:
44
+ return "broadcast"
45
+ return "incompatible"
46
+
47
+
48
+ def joinable(levels_a: list[str], levels_b: list[str]) -> bool:
49
+ return join_kind(levels_a, levels_b) != "incompatible"
50
+
51
+
52
+ def join_frames(
53
+ left: VariableFrame,
54
+ right: VariableFrame,
55
+ *,
56
+ left_value: str,
57
+ right_value: str,
58
+ ) -> pd.DataFrame:
59
+ """
60
+ Join two variables into one long frame carrying both measures.
61
+
62
+ The merge is on the *shallower* variable's levels, which is exactly what
63
+ broadcasting means: one subject-level row is reused for each of that
64
+ subject's trials.
65
+ """
66
+ kind = join_kind(left.levels, right.levels)
67
+ if kind == "incompatible":
68
+ raise ValueError(
69
+ f"{left.name} (levels {left.levels}) and {right.name} "
70
+ f"(levels {right.levels}) cannot share a plot: neither variable's "
71
+ f"schema levels are a prefix of the other's, so there is no "
72
+ f"unambiguous way to line their records up."
73
+ )
74
+
75
+ shallow_levels = left.levels if len(left.levels) <= len(right.levels) else right.levels
76
+ on = list(shallow_levels)
77
+
78
+ left_frame = left.frame[[*left.levels, left_value, *left.variant_columns]].copy()
79
+ right_frame = right.frame[[*right.levels, right_value, *right.variant_columns]].copy()
80
+
81
+ # Carry each side's "my chain is current" flag through the join under a
82
+ # private name, so the two cannot collide and neither is lost. Before this
83
+ # the flag was simply dropped and a two-measure plot silently fell back to
84
+ # showing every code version — the one case where the pin-latest default
85
+ # quietly stopped applying.
86
+ if left.latest_column:
87
+ left_frame[_LEFT_LATEST] = left.frame[left.latest_column].values
88
+ if right.latest_column:
89
+ right_frame[_RIGHT_LATEST] = right.frame[right.latest_column].values
90
+
91
+ # Variant columns can collide by name when both variables carry the same
92
+ # branch param; suffix the right one so neither is silently dropped.
93
+ overlap = set(left.variant_columns) & set(right.variant_columns)
94
+ if overlap:
95
+ right_frame = right_frame.rename(
96
+ columns={name: f"{name}::{right.name}" for name in overlap}
97
+ )
98
+
99
+ merged = left_frame.merge(right_frame, on=on, how="inner", suffixes=("", "_right"))
100
+
101
+ # A joined row is current only if BOTH of its measures are. Either side
102
+ # being stale makes the pair a comparison across code versions, which is
103
+ # exactly what the flag exists to keep out of the default figure.
104
+ if _LEFT_LATEST in merged.columns or _RIGHT_LATEST in merged.columns:
105
+ combined = None
106
+ for column in (_LEFT_LATEST, _RIGHT_LATEST):
107
+ if column in merged.columns:
108
+ flag = merged[column].astype(bool)
109
+ combined = flag if combined is None else (combined & flag)
110
+ merged[LATEST_COLUMN] = combined
111
+ merged = merged.drop(
112
+ columns=[c for c in (_LEFT_LATEST, _RIGHT_LATEST) if c in merged.columns]
113
+ )
114
+
115
+ Log.info(
116
+ "%s join: %s(%d) x %s(%d) on %s -> %d row(s)",
117
+ kind,
118
+ left.name,
119
+ len(left_frame),
120
+ right.name,
121
+ len(right_frame),
122
+ on,
123
+ len(merged),
124
+ layer=LAYER,
125
+ )
126
+ if kind == "broadcast":
127
+ Log.debug(
128
+ "broadcast: %s is shallower (%s); its values repeat across deeper rows",
129
+ left.name if len(left.levels) <= len(right.levels) else right.name,
130
+ shallow_levels,
131
+ layer=LAYER,
132
+ )
133
+ return merged
134
+
135
+
136
+ def joined_levels(left: VariableFrame, right: VariableFrame) -> list[str]:
137
+ """The level set of the joined frame — always the deeper of the two."""
138
+ return left.levels if len(left.levels) >= len(right.levels) else right.levels