scistackplot 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.
- scistackplot/__init__.py +239 -0
- scistackplot/capability.py +656 -0
- scistackplot/codegen.py +886 -0
- scistackplot/groups.py +110 -0
- scistackplot/reduce.py +1475 -0
- scistackplot/render/__init__.py +26 -0
- scistackplot/render/base.py +262 -0
- scistackplot/render/mpl.py +458 -0
- scistackplot/render/plotly_.py +537 -0
- scistackplot/resolved.py +240 -0
- scistackplot/roles.py +413 -0
- scistackplot/shape.py +106 -0
- scistackplot/sources/__init__.py +7 -0
- scistackplot/sources/base.py +256 -0
- scistackplot/sources/csv.py +67 -0
- scistackplot/sources/frame.py +45 -0
- scistackplot/spec.py +698 -0
- scistackplot/table.py +328 -0
- scistackplot/variants.py +743 -0
- scistackplot/xaxis.py +183 -0
- scistackplot/ylimits.py +451 -0
- scistackplot-0.1.26.dist-info/METADATA +212 -0
- scistackplot-0.1.26.dist-info/RECORD +24 -0
- scistackplot-0.1.26.dist-info/WHEEL +4 -0
scistackplot/codegen.py
ADDED
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Code generation: a ``PlotSpec`` becomes readable seaborn/matplotlib source.
|
|
3
|
+
|
|
4
|
+
Export emits **literal plotting code**, not a call back into this package. The
|
|
5
|
+
alternative — ``return scistackplot.render(df, spec_path)`` — is more compact
|
|
6
|
+
and stays re-editable in the GUI, but it makes every exported pipeline depend
|
|
7
|
+
on this package at runtime and hides the figure's definition behind an opaque
|
|
8
|
+
call. Literal code matches the project's "minimize lock-in" goal and the
|
|
9
|
+
precedent in ``docs/claude/gui-export-to-plain-python.md``.
|
|
10
|
+
|
|
11
|
+
The spec is emitted as a docstring block so the GUI can round-trip it back out
|
|
12
|
+
of a file the user has since hand-edited.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import keyword
|
|
19
|
+
import re
|
|
20
|
+
from typing import NamedTuple
|
|
21
|
+
|
|
22
|
+
from .groups import apply_level_groups
|
|
23
|
+
from .reduce import plan_layout
|
|
24
|
+
from .roles import complete_roles, fanout_keys
|
|
25
|
+
from .shape import Shape
|
|
26
|
+
from .spec import ErrorBand, PlotKind, PlotSpec, Role, Statistic
|
|
27
|
+
from .table import LongTable
|
|
28
|
+
from .ylimits import eligible_scope, limits_by_scope
|
|
29
|
+
from .variants import (
|
|
30
|
+
LATEST,
|
|
31
|
+
VARIANT_FACTOR,
|
|
32
|
+
apply_variant_sets,
|
|
33
|
+
defined_sets,
|
|
34
|
+
set_name,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
#: Marker delimiting the embedded spec inside a generated docstring.
|
|
38
|
+
SPEC_BEGIN = "scistackplot-spec:"
|
|
39
|
+
|
|
40
|
+
_SEABORN_ERRORBAR = {
|
|
41
|
+
ErrorBand.SD: '"sd"',
|
|
42
|
+
ErrorBand.SEM: '"se"',
|
|
43
|
+
ErrorBand.CI95: '("ci", 95)',
|
|
44
|
+
ErrorBand.IQR: '("pi", 50)',
|
|
45
|
+
ErrorBand.NONE: "None",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_SERIES_COLUMN = "_series"
|
|
49
|
+
|
|
50
|
+
#: Column the generated code creates when NO factor holds ``Role.X`` and the
|
|
51
|
+
#: measure is not 1-D: every point sits at one categorical position, which is
|
|
52
|
+
#: exactly what ``reduce._panel_frame`` does (``out[X] = ""``).
|
|
53
|
+
#:
|
|
54
|
+
#: This used to fall back to ``table.factor_names[0]``, which was wrong two ways.
|
|
55
|
+
#: It drew a different figure from the preview for any scalar spec with no x
|
|
56
|
+
#: factor, and — once a nested ITERATE key promotes its ancestors — that first
|
|
57
|
+
#: factor is an iteration key, so it is NOT a column of the frame the endpoint
|
|
58
|
+
#: receives and every combo raised.
|
|
59
|
+
_X_CONSTANT = "Observation"
|
|
60
|
+
|
|
61
|
+
#: Separator joining a nested axis's layer values in generated code. Readable on
|
|
62
|
+
#: purpose — a reader of the exported figure sees "stim · pre" as a tick, where
|
|
63
|
+
#: the interactive path uses an invisible control character it never displays.
|
|
64
|
+
_NESTED_JOIN = " · "
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def generate_plot_function(
|
|
68
|
+
spec: PlotSpec,
|
|
69
|
+
table: LongTable,
|
|
70
|
+
*,
|
|
71
|
+
function_name: str | None = None,
|
|
72
|
+
) -> str:
|
|
73
|
+
"""
|
|
74
|
+
Generate a ``plot_*`` function body for a scidb endpoint.
|
|
75
|
+
|
|
76
|
+
The signature is ``(df, filename)`` — the shape scidb's ``plot_`` contract
|
|
77
|
+
passes — and the function returns a Figure, which the framework saves to
|
|
78
|
+
``filename`` and closes.
|
|
79
|
+
"""
|
|
80
|
+
name = function_name or default_function_name(spec)
|
|
81
|
+
table = apply_level_groups(spec, apply_variant_sets(spec, table))
|
|
82
|
+
roles = complete_roles(spec, table)
|
|
83
|
+
shape = table.shape_of(spec.y_measure)
|
|
84
|
+
|
|
85
|
+
body: list[str] = []
|
|
86
|
+
body.extend(_variant_preamble(spec, table))
|
|
87
|
+
body.extend(_preamble(spec, table, roles, shape))
|
|
88
|
+
body.extend(_plot_call(spec, table, roles, shape))
|
|
89
|
+
|
|
90
|
+
lines = [
|
|
91
|
+
f"def {name}({', '.join(function_params(spec))}):",
|
|
92
|
+
f' """{_docstring(spec, table, roles)}"""',
|
|
93
|
+
" import matplotlib.pyplot as plt",
|
|
94
|
+
" import pandas as pd",
|
|
95
|
+
" import seaborn as sns",
|
|
96
|
+
"",
|
|
97
|
+
]
|
|
98
|
+
lines.extend(f" {line}" if line else "" for line in body)
|
|
99
|
+
return "\n".join(lines) + "\n"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class VariantInput(NamedTuple):
|
|
103
|
+
"""One generated ``for_each`` input: its parameter, label, and variable."""
|
|
104
|
+
|
|
105
|
+
param: str
|
|
106
|
+
label: str
|
|
107
|
+
variable: str
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def variant_params(spec: PlotSpec) -> list[VariantInput]:
|
|
111
|
+
"""One input per named variant row.
|
|
112
|
+
|
|
113
|
+
Counted over :func:`~scistackplot.variants.defined_sets` — a row the user
|
|
114
|
+
added but has not filled in yet selects nothing and must not become an
|
|
115
|
+
input.
|
|
116
|
+
|
|
117
|
+
Empty below two variants: one variant is a *filter*, fully expressed by the
|
|
118
|
+
``Variant(...)`` wrapper on the single ``df`` input, and giving it its own
|
|
119
|
+
parameter would rename ``df`` for no gain.
|
|
120
|
+
|
|
121
|
+
Two or more is a comparison, and it needs one input each. The endpoint
|
|
122
|
+
cannot receive them as one frame and sort them out afterwards: ``as_table``
|
|
123
|
+
hands a function schema keys and data columns only (``scifor``'s
|
|
124
|
+
``_extract_data``), never the branch-param or code-version columns that
|
|
125
|
+
distinguish variants — those exist only in ``scistackplotdb``'s loader. So
|
|
126
|
+
the split has to happen where the *load* happens, one input per variant, and
|
|
127
|
+
the labels are re-attached here.
|
|
128
|
+
|
|
129
|
+
Each row also carries **its own variable**, which is what makes "Raw vs
|
|
130
|
+
Filtered" the same generated shape as "v1 vs v2": two inputs, two
|
|
131
|
+
``Variant(...)`` wrappers, one concat.
|
|
132
|
+
"""
|
|
133
|
+
sets = defined_sets(spec.variant_sets)
|
|
134
|
+
if len(sets) < 2:
|
|
135
|
+
return []
|
|
136
|
+
used: set[str] = set()
|
|
137
|
+
params: list[VariantInput] = []
|
|
138
|
+
for index, variant in enumerate(sets):
|
|
139
|
+
# `latest_column` is the table's to know and there is no table here, so
|
|
140
|
+
# a row pinned to the latest flag slugifies the raw column name. It
|
|
141
|
+
# only affects the generated PARAMETER name, never which rows load.
|
|
142
|
+
label = set_name(variant, index, primary=spec.y_measure)
|
|
143
|
+
base = re.sub(r"[^0-9a-zA-Z]+", "_", label).strip("_").lower() or "variant"
|
|
144
|
+
if base[0].isdigit() or keyword.iskeyword(base):
|
|
145
|
+
base = f"v_{base}"
|
|
146
|
+
candidate, suffix = base, 2
|
|
147
|
+
while candidate in used:
|
|
148
|
+
candidate, suffix = f"{base}_{suffix}", suffix + 1
|
|
149
|
+
used.add(candidate)
|
|
150
|
+
params.append(
|
|
151
|
+
VariantInput(candidate, label, variant.variable or spec.y_measure)
|
|
152
|
+
)
|
|
153
|
+
return params
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def single_variant_variable(spec: PlotSpec) -> str | None:
|
|
157
|
+
"""The lone row's variable when it is not the primary measure.
|
|
158
|
+
|
|
159
|
+
A one-row spec keeps the plain ``df`` input, but that input may still be a
|
|
160
|
+
*different variable* than ``measures[0]`` — in which case its data column
|
|
161
|
+
arrives under that variable's name and has to be renamed into the one the
|
|
162
|
+
plot call uses.
|
|
163
|
+
"""
|
|
164
|
+
sets = defined_sets(spec.variant_sets)
|
|
165
|
+
if len(sets) != 1:
|
|
166
|
+
return None
|
|
167
|
+
variable = sets[0].variable
|
|
168
|
+
return variable if variable and variable != spec.y_measure else None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def group_param(variable: str) -> str:
|
|
172
|
+
"""Parameter name a grouping variable arrives under.
|
|
173
|
+
|
|
174
|
+
Prefixed so it cannot collide with ``df``/``df_x`` or with a variant row's
|
|
175
|
+
parameter, and named after the variable so the generated call reads as what
|
|
176
|
+
it is. Defined here, beside the signature it appears in, so
|
|
177
|
+
``scistackplotdb.endpoint`` and this module cannot disagree about it.
|
|
178
|
+
"""
|
|
179
|
+
slug = re.sub(r"[^0-9a-zA-Z]+", "_", variable).strip("_").lower() or "group"
|
|
180
|
+
return f"group_{slug}"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def function_params(spec: PlotSpec) -> list[str]:
|
|
184
|
+
"""The generated function's signature, in for_each input order."""
|
|
185
|
+
variants = variant_params(spec)
|
|
186
|
+
data = [variant.param for variant in variants] if variants else ["df"]
|
|
187
|
+
groups = [group_param(name) for name in spec.factor_variables]
|
|
188
|
+
return [*data, *groups, "filename"]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _variant_preamble(spec: PlotSpec, table: LongTable) -> list[str]:
|
|
192
|
+
"""Concatenate the per-variant inputs into one labelled ``df``."""
|
|
193
|
+
variants = variant_params(spec)
|
|
194
|
+
y = spec.y_measure
|
|
195
|
+
# Dict/struct variables arrive as one column PER FIELD, so their value
|
|
196
|
+
# column is not named after the variable and there is nothing to rename —
|
|
197
|
+
# the fields align across inputs on their own, and the melt further down
|
|
198
|
+
# turns them into the value column. Emitting the rename anyway would put a
|
|
199
|
+
# line in the user's code referring to a column that is not there.
|
|
200
|
+
melts_fields = bool(table.field_factors)
|
|
201
|
+
if not variants:
|
|
202
|
+
# One row, but possibly of another variable: its data column arrives
|
|
203
|
+
# under that variable's name.
|
|
204
|
+
other = single_variant_variable(spec)
|
|
205
|
+
if not other or melts_fields:
|
|
206
|
+
return []
|
|
207
|
+
return [
|
|
208
|
+
f"# This row plots {other}; the call below reads one value column.",
|
|
209
|
+
f"df = df.rename(columns={{{other!r}: {y!r}}})",
|
|
210
|
+
"",
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
spans_variables = len({variant.variable for variant in variants}) > 1
|
|
214
|
+
lines = [
|
|
215
|
+
"# One input per named variant (each loaded through its own",
|
|
216
|
+
"# Variant(...) filter), labelled and stacked into one frame.",
|
|
217
|
+
]
|
|
218
|
+
if spans_variables and melts_fields:
|
|
219
|
+
lines.append(
|
|
220
|
+
"# These variables store one column per field; the fields align "
|
|
221
|
+
f"across\n # inputs, and the melt below turns them into "
|
|
222
|
+
f"{y!r} + a field factor."
|
|
223
|
+
)
|
|
224
|
+
elif spans_variables:
|
|
225
|
+
# Each variable's data column arrives under its own name; the figure
|
|
226
|
+
# draws ONE value column and tells the rows apart by their label.
|
|
227
|
+
lines.append(
|
|
228
|
+
f"# Each variable's values are renamed into {y!r} so they stack; "
|
|
229
|
+
f"the {VARIANT_FACTOR!r} column is what keeps them apart."
|
|
230
|
+
)
|
|
231
|
+
lines.extend(["df = pd.concat(", " ["])
|
|
232
|
+
for variant in variants:
|
|
233
|
+
frame = variant.param
|
|
234
|
+
if variant.variable != y and not melts_fields:
|
|
235
|
+
frame = f"{frame}.rename(columns={{{variant.variable!r}: {y!r}}})"
|
|
236
|
+
lines.append(
|
|
237
|
+
f" {frame}.assign(**{{{VARIANT_FACTOR!r}: {variant.label!r}}}),"
|
|
238
|
+
)
|
|
239
|
+
lines.extend([" ],", " ignore_index=True,", ")", ""])
|
|
240
|
+
return lines
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def default_function_name(spec: PlotSpec) -> str:
|
|
244
|
+
"""A valid ``plot_``-prefixed identifier derived from the measure name."""
|
|
245
|
+
slug = re.sub(r"[^0-9a-zA-Z]+", "_", spec.y_measure).strip("_").lower()
|
|
246
|
+
return f"plot_{slug or 'figure'}"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def extract_spec(source: str) -> PlotSpec | None:
|
|
250
|
+
"""
|
|
251
|
+
Recover the embedded spec from generated source.
|
|
252
|
+
|
|
253
|
+
Lets the GUI reopen a figure the user has since hand-edited: the code is
|
|
254
|
+
the source of truth for rendering, the embedded spec only for repopulating
|
|
255
|
+
the controls. Returns None when no spec is present.
|
|
256
|
+
"""
|
|
257
|
+
start = source.find(SPEC_BEGIN)
|
|
258
|
+
if start == -1:
|
|
259
|
+
return None
|
|
260
|
+
brace = source.find("{", start)
|
|
261
|
+
if brace == -1:
|
|
262
|
+
return None
|
|
263
|
+
depth = 0
|
|
264
|
+
for position in range(brace, len(source)):
|
|
265
|
+
if source[position] == "{":
|
|
266
|
+
depth += 1
|
|
267
|
+
elif source[position] == "}":
|
|
268
|
+
depth -= 1
|
|
269
|
+
if depth == 0:
|
|
270
|
+
try:
|
|
271
|
+
return PlotSpec.from_json(source[brace : position + 1])
|
|
272
|
+
except (ValueError, KeyError):
|
|
273
|
+
return None
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _docstring(spec: PlotSpec, table: LongTable, roles: dict) -> str:
|
|
281
|
+
# The keys the for_each will actually carry — promoted ancestors included,
|
|
282
|
+
# in schema order — not the ones the spec literally names.
|
|
283
|
+
iterate = fanout_keys(spec, table)
|
|
284
|
+
note = ""
|
|
285
|
+
if iterate:
|
|
286
|
+
note = (
|
|
287
|
+
f"\n\n One figure per {', '.join(iterate)} — these are the "
|
|
288
|
+
f"for_each iteration keys, so they are NOT columns here."
|
|
289
|
+
)
|
|
290
|
+
shape_of_y = table.shape_of(spec.y_measure)
|
|
291
|
+
if _nested_x_layers(spec, table, roles, shape_of_y):
|
|
292
|
+
note += (
|
|
293
|
+
"\n\n The x axis nests "
|
|
294
|
+
f"{' > '.join(_nested_x_layers(spec, table, roles, shape_of_y))}; "
|
|
295
|
+
"seaborn has no\n empty category, so the groups are separated by "
|
|
296
|
+
"ORDER here rather than\n by the gaps the interactive view draws."
|
|
297
|
+
)
|
|
298
|
+
if spec.facet.has_rules and not _seaborn_can_express_layout(spec, table, roles):
|
|
299
|
+
note += (
|
|
300
|
+
"\n\n NOTE: the interactive layout arranged the subplots by "
|
|
301
|
+
"matching\n rules across two grid axes, which seaborn cannot "
|
|
302
|
+
"express — this code\n wraps them in order instead. The spec "
|
|
303
|
+
"below still carries the rules."
|
|
304
|
+
)
|
|
305
|
+
if shape_of_y is not Shape.MATRIX_2D:
|
|
306
|
+
note += _y_limit_plan(spec, table, roles)[2]
|
|
307
|
+
return (
|
|
308
|
+
f"{spec.kind} of {spec.y_measure}. Generated by scistackplot.{note}\n\n"
|
|
309
|
+
f" {SPEC_BEGIN}\n {spec.to_json(indent=None)}\n "
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _preamble(spec, table, roles, shape) -> list[str]:
|
|
314
|
+
"""Melt, filters, 1-D explosion, aggregation — the order resolve() uses."""
|
|
315
|
+
lines: list[str] = []
|
|
316
|
+
|
|
317
|
+
# Grouping variables arrive as their own inputs (a subject-level Condition
|
|
318
|
+
# cannot ride along on a trial-level measure's frame) and are merged back on
|
|
319
|
+
# whatever schema keys they share. The join keys are computed from the
|
|
320
|
+
# frames rather than hard-coded so the generated code stays readable and
|
|
321
|
+
# keeps working if the variable is later saved at a different level.
|
|
322
|
+
for name in spec.factor_variables:
|
|
323
|
+
param = group_param(name)
|
|
324
|
+
lines.extend(
|
|
325
|
+
[
|
|
326
|
+
f"# {name}: one value per {param}'s schema level, broadcast to every row",
|
|
327
|
+
f"_on = [c for c in {param}.columns if c in df.columns]",
|
|
328
|
+
f"df = df.merge({param}.drop_duplicates(subset=_on), "
|
|
329
|
+
f'on=_on, how="left")',
|
|
330
|
+
"",
|
|
331
|
+
]
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# A dict/struct variable arrives at the endpoint as one column per field
|
|
335
|
+
# (scidb's multi_column storage). The interactive path melts it in
|
|
336
|
+
# ScidbSource.get_table, so the generated code has to melt it too — the
|
|
337
|
+
# exported figure must be the previewed figure.
|
|
338
|
+
for field in table.field_factors:
|
|
339
|
+
levels = [str(level) for level in field.levels]
|
|
340
|
+
lines.extend(
|
|
341
|
+
[
|
|
342
|
+
f"# one row per field of {spec.y_measure} "
|
|
343
|
+
f"({len(levels)} field(s))",
|
|
344
|
+
f"_fields = {levels!r}",
|
|
345
|
+
"df = df.melt(",
|
|
346
|
+
" id_vars=[c for c in df.columns if c not in _fields],",
|
|
347
|
+
" value_vars=_fields,",
|
|
348
|
+
f" var_name={field.name!r},",
|
|
349
|
+
f" value_name={spec.y_measure!r},",
|
|
350
|
+
")",
|
|
351
|
+
"",
|
|
352
|
+
]
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
filter_lines: list[str] = []
|
|
356
|
+
for flt in spec.filters:
|
|
357
|
+
if flt.include is not None:
|
|
358
|
+
filter_lines.append(f"df = df[df[{flt.column!r}].isin({list(flt.include)!r})]")
|
|
359
|
+
if flt.exclude is not None:
|
|
360
|
+
filter_lines.append(f"df = df[~df[{flt.column!r}].isin({list(flt.exclude)!r})]")
|
|
361
|
+
if flt.minimum is not None:
|
|
362
|
+
filter_lines.append(f"df = df[df[{flt.column!r}] >= {flt.minimum!r}]")
|
|
363
|
+
if flt.maximum is not None:
|
|
364
|
+
filter_lines.append(f"df = df[df[{flt.column!r}] <= {flt.maximum!r}]")
|
|
365
|
+
if filter_lines:
|
|
366
|
+
lines.extend([*filter_lines, ""])
|
|
367
|
+
|
|
368
|
+
# Derived grouping factors. The interactive path builds these in
|
|
369
|
+
# `groups.apply_level_groups`; the endpoint receives the raw table, so the
|
|
370
|
+
# same mapping has to be emitted here or the exported figure is not the one
|
|
371
|
+
# that was previewed.
|
|
372
|
+
for group in spec.level_groups:
|
|
373
|
+
if not group.name or not group.source:
|
|
374
|
+
continue
|
|
375
|
+
mapping = {str(key): value for key, value in group.mapping.items()}
|
|
376
|
+
lines.append(f"# {group.source} -> {group.name}")
|
|
377
|
+
lines.append(f"_groups = {mapping!r}")
|
|
378
|
+
lines.append(
|
|
379
|
+
f"df[{group.name!r}] = df[{group.source!r}].astype(str).map(_groups)"
|
|
380
|
+
)
|
|
381
|
+
if group.unmatched is None:
|
|
382
|
+
lines.append(f"df = df[df[{group.name!r}].notna()]")
|
|
383
|
+
else:
|
|
384
|
+
lines.append(
|
|
385
|
+
f"df[{group.name!r}] = df[{group.name!r}].fillna({group.unmatched!r})"
|
|
386
|
+
)
|
|
387
|
+
lines.append("")
|
|
388
|
+
|
|
389
|
+
# No factor on x and not a 1-D measure: every point shares one categorical
|
|
390
|
+
# position. The preview builds that column in reduce; the endpoint has to
|
|
391
|
+
# build it too, or seaborn is handed an x that is not in the frame.
|
|
392
|
+
if _x_expression(spec, table, roles, shape) == _X_CONSTANT:
|
|
393
|
+
lines.extend([f"df[{_X_CONSTANT!r}] = \"\"", ""])
|
|
394
|
+
|
|
395
|
+
# Nested x: one position per combination of the layers. The ORDER is
|
|
396
|
+
# emitted as a resolved list rather than re-derived — replaying the nesting
|
|
397
|
+
# rules in generated code would be a second implementation that can drift
|
|
398
|
+
# from the preview (same reason the facet layout emits `col_order`).
|
|
399
|
+
layers = _nested_x_layers(spec, table, roles, shape)
|
|
400
|
+
if layers:
|
|
401
|
+
lines.extend(
|
|
402
|
+
[
|
|
403
|
+
f"# nested x axis: {' > '.join(layers)}",
|
|
404
|
+
f"_layers = {layers!r}",
|
|
405
|
+
f"df[{_X_NESTED!r}] = df[_layers].astype(str).agg("
|
|
406
|
+
f"{_NESTED_JOIN!r}.join, axis=1)",
|
|
407
|
+
"",
|
|
408
|
+
]
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
index_column = spec.index_column or table.index_column or "index"
|
|
412
|
+
if shape is Shape.SERIES_1D and not table.measure(spec.y_measure).exploded:
|
|
413
|
+
y = spec.y_measure
|
|
414
|
+
lines.extend(
|
|
415
|
+
[
|
|
416
|
+
"# 1-D measure: one row per sample",
|
|
417
|
+
f"df[{index_column!r}] = df[{y!r}].map(lambda v: list(range(len(v))))",
|
|
418
|
+
f"df = df.explode([{y!r}, {index_column!r}], ignore_index=True)",
|
|
419
|
+
f"df[{y!r}] = pd.to_numeric(df[{y!r}])",
|
|
420
|
+
f"df[{index_column!r}] = pd.to_numeric(df[{index_column!r}])",
|
|
421
|
+
"",
|
|
422
|
+
]
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
aggregated = [name for name, role in roles.items() if role is Role.AGGREGATE]
|
|
426
|
+
if aggregated:
|
|
427
|
+
keep = [
|
|
428
|
+
name
|
|
429
|
+
for name, role in roles.items()
|
|
430
|
+
if role not in (Role.AGGREGATE, Role.ITERATE)
|
|
431
|
+
]
|
|
432
|
+
if shape is Shape.SERIES_1D:
|
|
433
|
+
keep.append(index_column)
|
|
434
|
+
lines.extend(
|
|
435
|
+
[
|
|
436
|
+
f"# average over {', '.join(aggregated)}",
|
|
437
|
+
f"df = df.groupby({keep!r}, as_index=False)[{spec.y_measure!r}].mean()",
|
|
438
|
+
"",
|
|
439
|
+
]
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
if spec.kind is PlotKind.LINE:
|
|
443
|
+
series_cols = [
|
|
444
|
+
name
|
|
445
|
+
for name, role in roles.items()
|
|
446
|
+
if role not in (Role.ITERATE, Role.AGGREGATE)
|
|
447
|
+
and name != _role_holder(roles, Role.X)
|
|
448
|
+
]
|
|
449
|
+
if series_cols:
|
|
450
|
+
# Vectorized, not `.agg(' | '.join, axis=1)`. That form reads
|
|
451
|
+
# better and costs a Python call PER ROW, which for a 1-D measure is
|
|
452
|
+
# per sample: a 24-row frame of EMG traces is 8.9 million rows once
|
|
453
|
+
# exploded, and the join alone ran for minutes (scidb.log
|
|
454
|
+
# 2026-09-11). Generated endpoints run on exactly that data.
|
|
455
|
+
head, *rest = series_cols
|
|
456
|
+
composed = f"df[{head!r}].astype(str)"
|
|
457
|
+
if rest:
|
|
458
|
+
composed += f".str.cat(df[{rest!r}].astype(str), sep=' | ')"
|
|
459
|
+
lines.extend(
|
|
460
|
+
[
|
|
461
|
+
"# one line per observation",
|
|
462
|
+
f"df[{_SERIES_COLUMN!r}] = {composed}",
|
|
463
|
+
"",
|
|
464
|
+
]
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
return lines
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _facet_layout_args(spec, table: LongTable, facets: list[str]) -> list[str]:
|
|
471
|
+
"""
|
|
472
|
+
seaborn arguments that reproduce the interactive facet arrangement.
|
|
473
|
+
|
|
474
|
+
A single faceted factor is a strip of panels seaborn wraps at ``col_wrap``,
|
|
475
|
+
and the order it wraps them in is ``col_order`` — so a rule-defined layout
|
|
476
|
+
IS expressible whenever the panels fill the grid without holes. Replaying
|
|
477
|
+
``plan_layout`` here (rather than re-deriving an order) is what keeps the
|
|
478
|
+
exported figure identical to the preview; when the arrangement cannot be
|
|
479
|
+
expressed, ``_docstring`` says so instead of quietly differing.
|
|
480
|
+
"""
|
|
481
|
+
if len(facets) != 1:
|
|
482
|
+
return []
|
|
483
|
+
try:
|
|
484
|
+
levels = [str(level) for level in table.factor(facets[0]).levels]
|
|
485
|
+
except KeyError:
|
|
486
|
+
return []
|
|
487
|
+
if not levels:
|
|
488
|
+
return []
|
|
489
|
+
|
|
490
|
+
plan = plan_layout(levels, spec.facet)
|
|
491
|
+
args = []
|
|
492
|
+
if plan.n_cols < len(levels):
|
|
493
|
+
args.append(f"col_wrap={plan.n_cols}")
|
|
494
|
+
if spec.facet.has_rules and plan.fills_row_major:
|
|
495
|
+
args.append(f"col_order={plan.labels_in_grid_order(levels)!r}")
|
|
496
|
+
return args
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _nested_x_args(spec, table: LongTable, roles, shape) -> list[str]:
|
|
500
|
+
"""``order=[...]`` reproducing the composed nested axis.
|
|
501
|
+
|
|
502
|
+
The RESOLVED order, computed by the same :func:`~scistackplot.xaxis.plan_x_axis`
|
|
503
|
+
the preview used, rather than the nesting rules re-applied in generated
|
|
504
|
+
code. A second implementation is a second thing that can drift, and the
|
|
505
|
+
failure would be a re-ordered axis nobody notices.
|
|
506
|
+
|
|
507
|
+
Spacers are dropped: seaborn has no concept of an empty category, so the
|
|
508
|
+
exported figure groups by ordering alone. The docstring says so — the
|
|
509
|
+
interactive view's gaps are the one thing the export cannot reproduce.
|
|
510
|
+
"""
|
|
511
|
+
layers = _nested_x_layers(spec, table, roles, shape)
|
|
512
|
+
if not layers:
|
|
513
|
+
return []
|
|
514
|
+
|
|
515
|
+
from .xaxis import LEAF_SEPARATOR, is_spacer, plan_x_axis
|
|
516
|
+
|
|
517
|
+
frame = table.frame
|
|
518
|
+
present = [name for name in layers if name in frame.columns]
|
|
519
|
+
if len(present) != len(layers):
|
|
520
|
+
return []
|
|
521
|
+
combinations = list(
|
|
522
|
+
frame[layers].astype(str).drop_duplicates().itertuples(index=False, name=None)
|
|
523
|
+
)
|
|
524
|
+
plan = plan_x_axis(
|
|
525
|
+
combinations,
|
|
526
|
+
[[str(level) for level in table.factor(name).levels] for name in layers],
|
|
527
|
+
)
|
|
528
|
+
order = [
|
|
529
|
+
key.replace(LEAF_SEPARATOR, _NESTED_JOIN)
|
|
530
|
+
for key in plan.order
|
|
531
|
+
if not is_spacer(key)
|
|
532
|
+
]
|
|
533
|
+
return [f"order={order!r}"] if order else []
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _seaborn_can_express_layout(spec, table: LongTable, roles) -> bool:
|
|
537
|
+
"""Whether ``_facet_layout_args`` reproduced the rules (see ``_docstring``)."""
|
|
538
|
+
facets = [name for name, role in roles.items() if role is Role.FACET]
|
|
539
|
+
if len(facets) != 1:
|
|
540
|
+
return False
|
|
541
|
+
try:
|
|
542
|
+
levels = [str(level) for level in table.factor(facets[0]).levels]
|
|
543
|
+
except KeyError:
|
|
544
|
+
return False
|
|
545
|
+
return bool(levels) and plan_layout(levels, spec.facet).fills_row_major
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _plot_call(spec, table, roles, shape) -> list[str]:
|
|
549
|
+
if shape is Shape.MATRIX_2D:
|
|
550
|
+
return _heatmap_call(spec)
|
|
551
|
+
|
|
552
|
+
kind = spec.kind
|
|
553
|
+
x = _x_expression(spec, table, roles, shape)
|
|
554
|
+
color = _role_holder(roles, Role.COLOR)
|
|
555
|
+
facets = [name for name, role in roles.items() if role is Role.FACET]
|
|
556
|
+
|
|
557
|
+
args = [f"data=df", f"x={x!r}", f"y={spec.y_measure!r}"]
|
|
558
|
+
if color:
|
|
559
|
+
args.append(f"hue={color!r}")
|
|
560
|
+
if _color_level_count(spec, table, color) < 2:
|
|
561
|
+
# Same rule the renderers apply (render.base.shows_legend): one
|
|
562
|
+
# colour level means the legend restates what every mark on the
|
|
563
|
+
# figure has in common. The exported figure must be the previewed
|
|
564
|
+
# figure, so it has to be decided here too, not just at render time.
|
|
565
|
+
args.append("legend=False")
|
|
566
|
+
# seaborn takes one factor per grid axis: the first faceted factor drives
|
|
567
|
+
# the columns, a second one the rows.
|
|
568
|
+
if facets:
|
|
569
|
+
args.append(f"col={facets[0]!r}")
|
|
570
|
+
if len(facets) > 1:
|
|
571
|
+
args.append(f"row={facets[1]!r}")
|
|
572
|
+
args.extend(_facet_layout_args(spec, table, facets))
|
|
573
|
+
args.extend(_nested_x_args(spec, table, roles, shape))
|
|
574
|
+
|
|
575
|
+
estimator = (
|
|
576
|
+
'"median"' if spec.aggregate.statistic is Statistic.MEDIAN else '"mean"'
|
|
577
|
+
)
|
|
578
|
+
errorbar = _SEABORN_ERRORBAR[spec.aggregate.error]
|
|
579
|
+
|
|
580
|
+
if kind in (PlotKind.BOX, PlotKind.VIOLIN, PlotKind.BAR, PlotKind.STRIP):
|
|
581
|
+
seaborn_kind = {
|
|
582
|
+
PlotKind.BOX: "box",
|
|
583
|
+
PlotKind.VIOLIN: "violin",
|
|
584
|
+
PlotKind.BAR: "bar",
|
|
585
|
+
PlotKind.STRIP: "strip",
|
|
586
|
+
}[kind]
|
|
587
|
+
args.append(f'kind="{seaborn_kind}"')
|
|
588
|
+
if kind is PlotKind.BAR:
|
|
589
|
+
args.append(f"estimator={estimator}")
|
|
590
|
+
args.append(f"errorbar={errorbar}")
|
|
591
|
+
call = "sns.catplot"
|
|
592
|
+
elif kind is PlotKind.SCATTER:
|
|
593
|
+
if _x_is_categorical(spec, table, roles, shape):
|
|
594
|
+
args.extend(['kind="strip"', "jitter=False"])
|
|
595
|
+
call = "sns.catplot"
|
|
596
|
+
else:
|
|
597
|
+
args.append('kind="scatter"')
|
|
598
|
+
call = "sns.relplot"
|
|
599
|
+
elif kind is PlotKind.LINE:
|
|
600
|
+
args.append('kind="line"')
|
|
601
|
+
args.append("estimator=None")
|
|
602
|
+
if any(role is Role.FREE for role in roles.values()):
|
|
603
|
+
args.append(f"units={_SERIES_COLUMN!r}")
|
|
604
|
+
call = "sns.relplot"
|
|
605
|
+
elif kind is PlotKind.BAND:
|
|
606
|
+
args.extend([f'kind="line"', f"estimator={estimator}", f"errorbar={errorbar}"])
|
|
607
|
+
call = "sns.relplot"
|
|
608
|
+
else: # pragma: no cover - every kind is covered above
|
|
609
|
+
args.append('kind="scatter"')
|
|
610
|
+
call = "sns.relplot"
|
|
611
|
+
|
|
612
|
+
style = spec.style
|
|
613
|
+
if style.palette:
|
|
614
|
+
args.append(f"palette={style.palette!r}")
|
|
615
|
+
|
|
616
|
+
# seaborn shares y across facets by DEFAULT, so per-panel autoscale has to
|
|
617
|
+
# be asked for explicitly or the export quietly draws a different figure
|
|
618
|
+
# from the preview. This is the same class of bug the facet `col_order`
|
|
619
|
+
# replay exists to prevent.
|
|
620
|
+
share_y, ylim, _note = _y_limit_plan(spec, table, roles)
|
|
621
|
+
if not share_y:
|
|
622
|
+
args.append('facet_kws={"sharey": False}')
|
|
623
|
+
|
|
624
|
+
lines = [f"g = {call}(", *[f" {arg}," for arg in args], ")"]
|
|
625
|
+
if ylim is not None:
|
|
626
|
+
lines.append(f"g.set(ylim={(float(ylim[0]), float(ylim[1]))!r})")
|
|
627
|
+
# The constant-x column is scaffolding, not a variable anyone measured —
|
|
628
|
+
# reduce._labels_for leaves the label empty in that case, so this must too.
|
|
629
|
+
x_label = style.x_label or ("" if x == _X_CONSTANT else x)
|
|
630
|
+
lines.append(
|
|
631
|
+
f"g.set_axis_labels({x_label!r}, "
|
|
632
|
+
f"{(style.y_label or spec.y_measure)!r})"
|
|
633
|
+
)
|
|
634
|
+
if facets:
|
|
635
|
+
# Same rule as the preview (render.base.panel_y_title): the facet values
|
|
636
|
+
# ARE the panel's y-axis title, and no caption sits above it. seaborn
|
|
637
|
+
# does the opposite by default, so both halves have to be said here or
|
|
638
|
+
# the exported figure spends vertical room the preview gave to the data.
|
|
639
|
+
# A two-factor grid keys axes_dict by (row, col), which is the reverse
|
|
640
|
+
# of the panel key's order — hence the reversed().
|
|
641
|
+
lines.extend(
|
|
642
|
+
[
|
|
643
|
+
'g.set_titles("")',
|
|
644
|
+
"for _key, _ax in g.axes_dict.items():",
|
|
645
|
+
" _values = _key if isinstance(_key, tuple) else (_key,)",
|
|
646
|
+
' _ax.set_ylabel(" · ".join(str(v) for v in reversed(_values)))',
|
|
647
|
+
]
|
|
648
|
+
)
|
|
649
|
+
if style.log_x:
|
|
650
|
+
lines.append('g.set(xscale="log")')
|
|
651
|
+
if style.log_y:
|
|
652
|
+
lines.append('g.set(yscale="log")')
|
|
653
|
+
if style.title:
|
|
654
|
+
lines.append(f"g.figure.suptitle({style.title!r})")
|
|
655
|
+
lines.append(f"g.figure.set_size_inches({style.width}, {style.height})")
|
|
656
|
+
lines.append("return g.figure")
|
|
657
|
+
return lines
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _heatmap_call(spec) -> list[str]:
|
|
661
|
+
return [
|
|
662
|
+
"import numpy as np",
|
|
663
|
+
"",
|
|
664
|
+
f"matrix = np.mean(np.stack([np.asarray(v, dtype=float) "
|
|
665
|
+
f"for v in df[{spec.y_measure!r}]]), axis=0)",
|
|
666
|
+
f"fig, ax = plt.subplots(figsize=({spec.style.width}, {spec.style.height}))",
|
|
667
|
+
'image = ax.imshow(matrix, aspect="auto", origin="lower")',
|
|
668
|
+
"fig.colorbar(image, ax=ax)",
|
|
669
|
+
f"ax.set_title({(spec.style.title or spec.y_measure)!r})",
|
|
670
|
+
"return fig",
|
|
671
|
+
]
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _y_limit_plan(spec: PlotSpec, table: LongTable, roles: dict) -> tuple:
|
|
675
|
+
"""``(share_y, ylim, note)`` — how the endpoint reproduces the y scale.
|
|
676
|
+
|
|
677
|
+
A generated endpoint sees **one iteration's frame**: one figure, and no way
|
|
678
|
+
to look at the others. That single fact decides everything here.
|
|
679
|
+
|
|
680
|
+
* A scope naming every ITERATE factor means limits never cross figures, so
|
|
681
|
+
the figure's own data defines them — which is exactly what seaborn does
|
|
682
|
+
by itself. Nothing to emit.
|
|
683
|
+
* A scope naming FACET factors too means each panel scales to itself:
|
|
684
|
+
``sharey=False``, and again nothing to emit. (seaborn shares y by default,
|
|
685
|
+
so this one has to be said out loud or the export silently differs.)
|
|
686
|
+
* A scope that does NOT name every ITERATE factor means the limits are a
|
|
687
|
+
property of data this endpoint cannot see — the whole point of asking for
|
|
688
|
+
one scale across every subject. That value is computed HERE, at generation
|
|
689
|
+
time, and baked in as a **literal**.
|
|
690
|
+
|
|
691
|
+
**Why a literal rather than scifor's ``share_limits``.** ``for_each`` can
|
|
692
|
+
already coordinate ranges across separately-iterated figures
|
|
693
|
+
(``share_limits={"df": ["subject"]}``), and that maps onto ``scope``
|
|
694
|
+
exactly. It was not used here because the two answer different questions: a
|
|
695
|
+
literal reproduces THE FIGURE THE USER APPROVED, byte for byte, while
|
|
696
|
+
``share_limits`` recomputes from whatever the data holds at run time — so
|
|
697
|
+
adding a subject would silently rescale a recorded artifact, and the export
|
|
698
|
+
would stop matching the preview it was generated from. For a
|
|
699
|
+
lineage-tracked figure, the frozen number is the honest one; the spec in the
|
|
700
|
+
docstring still carries the scope, so regenerating after new data is one
|
|
701
|
+
click and an explicit act. Revisit if a user wants a living endpoint rather
|
|
702
|
+
than a reproducible one.
|
|
703
|
+
"""
|
|
704
|
+
y_axis = spec.y_axis
|
|
705
|
+
if y_axis.is_manual:
|
|
706
|
+
return True, (float(y_axis.minimum), float(y_axis.maximum)), ""
|
|
707
|
+
|
|
708
|
+
scope = eligible_scope(y_axis.scope, roles, table)
|
|
709
|
+
iterate = fanout_keys(spec, table)
|
|
710
|
+
facets = [name for name, role in roles.items() if role is Role.FACET]
|
|
711
|
+
|
|
712
|
+
per_panel = any(name in scope for name in facets)
|
|
713
|
+
spans_figures = not all(name in scope for name in iterate)
|
|
714
|
+
|
|
715
|
+
if not spans_figures:
|
|
716
|
+
# Within one figure: seaborn's own scaling is the right answer, shared
|
|
717
|
+
# across facets or not depending on the scope.
|
|
718
|
+
return not per_panel, None, ""
|
|
719
|
+
|
|
720
|
+
limits = limits_by_scope(table, spec, scope)
|
|
721
|
+
if not limits:
|
|
722
|
+
return not per_panel, None, ""
|
|
723
|
+
|
|
724
|
+
distinct = {value for value in limits.values()}
|
|
725
|
+
if len(distinct) == 1:
|
|
726
|
+
return not per_panel, distinct.pop(), ""
|
|
727
|
+
|
|
728
|
+
# Several groups with different ranges, and the endpoint has only one
|
|
729
|
+
# figure's rows to decide between them. The global range keeps every
|
|
730
|
+
# exported figure comparable, which is what asking for a cross-figure scope
|
|
731
|
+
# was for; say so rather than let the export differ in silence.
|
|
732
|
+
note = (
|
|
733
|
+
"\n\n NOTE: the y limits separate by "
|
|
734
|
+
f"{', '.join(scope)}, which spans figures this endpoint\n cannot "
|
|
735
|
+
"see. The widest range across the whole dataset is used here, so "
|
|
736
|
+
"every\n exported figure stays on one comparable scale."
|
|
737
|
+
)
|
|
738
|
+
lows = [low for low, _ in limits.values()]
|
|
739
|
+
highs = [high for _, high in limits.values()]
|
|
740
|
+
return not per_panel, (min(lows), max(highs)), note
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _color_level_count(spec: PlotSpec, table: LongTable, color: str) -> int:
|
|
744
|
+
"""
|
|
745
|
+
How many colour levels the generated code will actually draw.
|
|
746
|
+
|
|
747
|
+
Filters are applied here because they are applied in the generated
|
|
748
|
+
preamble: filtering a two-level factor down to one must drop the legend in
|
|
749
|
+
the export exactly as it drops it in the preview. An unknown factor keeps
|
|
750
|
+
its legend — omitting one that was wanted is worse than keeping one that
|
|
751
|
+
was not.
|
|
752
|
+
"""
|
|
753
|
+
try:
|
|
754
|
+
levels = [str(level) for level in table.factor(color).levels]
|
|
755
|
+
except KeyError:
|
|
756
|
+
return 2
|
|
757
|
+
for flt in spec.filters:
|
|
758
|
+
if flt.column != color:
|
|
759
|
+
continue
|
|
760
|
+
if flt.include is not None:
|
|
761
|
+
keep = {str(value) for value in flt.include}
|
|
762
|
+
levels = [level for level in levels if level in keep]
|
|
763
|
+
if flt.exclude is not None:
|
|
764
|
+
drop = {str(value) for value in flt.exclude}
|
|
765
|
+
levels = [level for level in levels if level not in drop]
|
|
766
|
+
return len(levels)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
#: Column the generated code builds for a nested x axis.
|
|
770
|
+
_X_NESTED = "_x"
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _nested_x_layers(spec, table, roles, shape) -> list[str]:
|
|
774
|
+
"""The factors sharing the x axis, when there is more than one."""
|
|
775
|
+
if spec.x_measure or shape is not Shape.SCALAR:
|
|
776
|
+
return []
|
|
777
|
+
layers = [name for name in spec.ordered_x_layers(roles) if table.has_factor(name)]
|
|
778
|
+
return layers if len(layers) > 1 else []
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _x_expression(spec, table, roles, shape) -> str:
|
|
782
|
+
if spec.x_measure:
|
|
783
|
+
return spec.x_measure
|
|
784
|
+
if shape is Shape.SERIES_1D:
|
|
785
|
+
return spec.index_column or table.index_column or "index"
|
|
786
|
+
if _nested_x_layers(spec, table, roles, shape):
|
|
787
|
+
return _X_NESTED
|
|
788
|
+
return _role_holder(roles, Role.X) or _X_CONSTANT
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _x_is_categorical(spec, table, roles, shape) -> bool:
|
|
792
|
+
if spec.x_measure or shape is Shape.SERIES_1D:
|
|
793
|
+
return False
|
|
794
|
+
# The constant fallback is a single categorical position, so it is
|
|
795
|
+
# categorical too — the interactive renderers draw it that way.
|
|
796
|
+
return True
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _role_holder(roles: dict[str, Role], role: Role) -> str | None:
|
|
800
|
+
for name, assigned in roles.items():
|
|
801
|
+
if assigned is role:
|
|
802
|
+
return name
|
|
803
|
+
return None
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def generate_script(
|
|
807
|
+
spec: PlotSpec,
|
|
808
|
+
table: LongTable,
|
|
809
|
+
*,
|
|
810
|
+
source_expression: str = 'pd.read_csv("data.csv")',
|
|
811
|
+
function_name: str | None = None,
|
|
812
|
+
) -> str:
|
|
813
|
+
"""
|
|
814
|
+
A complete runnable script — the standalone (no scidb) export.
|
|
815
|
+
|
|
816
|
+
Same generated function, plus the few lines that load a table and save the
|
|
817
|
+
figure, so a CSV user gets something they can run immediately.
|
|
818
|
+
"""
|
|
819
|
+
name = function_name or default_function_name(spec)
|
|
820
|
+
function = generate_plot_function(spec, table, function_name=name)
|
|
821
|
+
call_args, setup = _script_inputs(spec)
|
|
822
|
+
return (
|
|
823
|
+
'"""Generated by scistackplot."""\n'
|
|
824
|
+
"import matplotlib.pyplot as plt\n"
|
|
825
|
+
"import pandas as pd\n"
|
|
826
|
+
"import seaborn as sns\n\n\n"
|
|
827
|
+
f"{function}\n\n"
|
|
828
|
+
'if __name__ == "__main__":\n'
|
|
829
|
+
f" df = {source_expression}\n"
|
|
830
|
+
+ "".join(f" {line}\n" if line else "\n" for line in setup)
|
|
831
|
+
+ f' figure = {name}({call_args}"figure.png")\n'
|
|
832
|
+
' figure.savefig("figure.png", dpi=150, bbox_inches="tight")\n'
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _script_inputs(spec: PlotSpec) -> tuple[str, list[str]]:
|
|
837
|
+
"""The standalone script's per-variant frames.
|
|
838
|
+
|
|
839
|
+
The endpoint path gets its variants from the database, one ``Variant(...)``
|
|
840
|
+
load per input. A script has one flat frame instead, so the same split is
|
|
841
|
+
done here with literal ``pandas`` — the variant columns are ordinary columns
|
|
842
|
+
of whatever was loaded.
|
|
843
|
+
|
|
844
|
+
A ``"latest"`` selection cannot be honoured standalone: which record is
|
|
845
|
+
newest at a schema location is a provenance question, and a CSV carries no
|
|
846
|
+
provenance. Rather than silently pinning nothing, the generated line says so.
|
|
847
|
+
"""
|
|
848
|
+
variants = variant_params(spec)
|
|
849
|
+
if not variants:
|
|
850
|
+
return "df, ", []
|
|
851
|
+
|
|
852
|
+
lines: list[str] = [""]
|
|
853
|
+
for generated, variant in zip(
|
|
854
|
+
variants, defined_sets(spec.variant_sets), strict=True
|
|
855
|
+
):
|
|
856
|
+
param, label = generated.param, generated.label
|
|
857
|
+
lines.append(f"# variant {label!r}")
|
|
858
|
+
if generated.variable != spec.y_measure:
|
|
859
|
+
# A standalone script starts from ONE flat table, so a row drawing
|
|
860
|
+
# on another variable can only mean another column of it.
|
|
861
|
+
lines.append(
|
|
862
|
+
f"{param} = df.rename(columns="
|
|
863
|
+
f"{{{generated.variable!r}: {spec.y_measure!r}}})"
|
|
864
|
+
)
|
|
865
|
+
else:
|
|
866
|
+
lines.append(f"{param} = df")
|
|
867
|
+
for column, value in variant.selection.items():
|
|
868
|
+
if isinstance(value, str) and value == LATEST:
|
|
869
|
+
lines.append(
|
|
870
|
+
f"# NOTE: {column!r} asked for the latest version, which "
|
|
871
|
+
f"needs provenance a flat table does not carry — not applied."
|
|
872
|
+
)
|
|
873
|
+
continue
|
|
874
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
875
|
+
levels = [str(v) for v in value]
|
|
876
|
+
test = f"{param}[{column!r}].astype(str).isin({levels!r})"
|
|
877
|
+
else:
|
|
878
|
+
test = f"{param}[{column!r}].astype(str) == {str(value)!r}"
|
|
879
|
+
lines.append(f"{param} = {param}[{test}]")
|
|
880
|
+
lines.append("")
|
|
881
|
+
return "".join(f"{generated.param}, " for generated in variants), lines
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def spec_json_block(spec: PlotSpec) -> str:
|
|
885
|
+
"""The spec as a pretty JSON block, for writing next to generated code."""
|
|
886
|
+
return json.dumps(spec.to_dict(), indent=2, sort_keys=True)
|