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
|
@@ -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
|