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.
@@ -0,0 +1,458 @@
1
+ """
2
+ matplotlib renderer — the export and pipeline path.
3
+
4
+ Returns a ``matplotlib.figure.Figure``, which is exactly what a scidb
5
+ ``plot_`` endpoint must return (the framework saves and closes it). This is
6
+ also the renderer whose output the generated seaborn/matplotlib code is
7
+ expected to reproduce.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+ from scistacklog import Log
17
+
18
+ from ..resolved import ResolvedPlot
19
+ from ..spec import PlotKind
20
+ from .base import (
21
+ color_groups,
22
+ grid_shape,
23
+ is_categorical_x,
24
+ legend_levels,
25
+ palette_for,
26
+ panel_position,
27
+ panel_y_limits,
28
+ panel_y_title,
29
+ shares_y_axis,
30
+ shows_legend,
31
+ shows_x_labels,
32
+ shows_y_labels,
33
+ x_positions,
34
+ )
35
+
36
+ LAYER = "scistackplot"
37
+
38
+
39
+ def render(resolved: ResolvedPlot):
40
+ """Draw ``resolved`` and return the Figure (caller owns closing it)."""
41
+ import matplotlib
42
+
43
+ if matplotlib.get_backend().lower() not in ("agg", "template"):
44
+ # Rendering happens inside a server process and inside for_each; a GUI
45
+ # backend there either warns or blocks. Agg is the only safe default.
46
+ matplotlib.use("Agg", force=False)
47
+ import matplotlib.pyplot as plt
48
+
49
+ with Log.timer("render_mpl", layer=LAYER, extra=str(resolved.kind)):
50
+ n_rows, n_cols = grid_shape(resolved)
51
+ style = resolved.spec.style
52
+ fig, axes = plt.subplots(
53
+ n_rows,
54
+ n_cols,
55
+ figsize=(style.width, style.height),
56
+ squeeze=False,
57
+ sharex=resolved.spec.facet.share_x,
58
+ # DERIVED, not configured. matplotlib's sharey ties the axes
59
+ # together, so one panel's autoscale drags every other panel with
60
+ # it — exactly wrong once `y_axis.scope` asks for per-panel ranges,
61
+ # and the set_ylim below would be silently overruled by whichever
62
+ # panel was drawn last.
63
+ sharey=shares_y_axis(resolved),
64
+ )
65
+
66
+ used: set[tuple[int, int]] = set()
67
+ # (row, col) -> panel, so the cosmetics pass can ask a CELL for its
68
+ # panel's limits. It walks the grid rather than the panel list (blank
69
+ # cells need hiding too), and the two orders are not the same.
70
+ at_cell: dict[tuple[int, int], Any] = {}
71
+ for index, panel in enumerate(resolved.panels):
72
+ row, col = panel_position(resolved, index)
73
+ if (row, col) in used or not (0 <= row < n_rows and 0 <= col < n_cols):
74
+ # reduce._assign_grid guarantees one panel per cell inside the
75
+ # reported grid. If that ever breaks, say so — the old silent
76
+ # clamp drew two panels onto one axes, which looks like bad data
77
+ # rather than a layout bug.
78
+ Log.warn(
79
+ "panel %r wants cell (%d,%d) in a %dx%d grid, which is "
80
+ "occupied or out of range — clamping",
81
+ panel.title,
82
+ row,
83
+ col,
84
+ n_rows,
85
+ n_cols,
86
+ layer=LAYER,
87
+ )
88
+ row = min(max(row, 0), n_rows - 1)
89
+ col = min(max(col, 0), n_cols - 1)
90
+ ax = axes[row][col]
91
+ used.add((row, col))
92
+ at_cell[(row, col)] = panel
93
+ _draw_panel(ax, panel.frame, resolved)
94
+ # No subplot caption: a faceted panel is named by its y-axis title
95
+ # instead (base.panel_y_title), which buys back the row of vertical
96
+ # space a title costs in every row of the grid.
97
+
98
+ # Blank out grid cells no panel landed in (a wrapped grid's remainder).
99
+ for row in range(n_rows):
100
+ for col in range(n_cols):
101
+ if (row, col) not in used:
102
+ axes[row][col].set_visible(False)
103
+
104
+ _apply_axes_cosmetics(fig, axes, resolved, n_rows, n_cols, at_cell)
105
+
106
+ if resolved.labels.title:
107
+ fig.suptitle(resolved.labels.title)
108
+ # tight_layout is told how much width the legend took. A FIGURE legend
109
+ # is invisible to tight_layout, so laying the axes out across the whole
110
+ # width put the legend on top of the rightmost panels in the exported
111
+ # PNG while the interactive plotly view kept it outside — the same
112
+ # figure reading two different ways depending on how you looked at it.
113
+ reserved = _apply_legend(fig, resolved)
114
+ fig.tight_layout(rect=(0.0, 0.0, 1.0 - reserved, 1.0))
115
+ return fig
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+
120
+
121
+ def _draw_panel(ax, frame: pd.DataFrame, resolved: ResolvedPlot) -> None:
122
+ if frame.empty:
123
+ ax.text(
124
+ 0.5,
125
+ 0.5,
126
+ "no data",
127
+ ha="center",
128
+ va="center",
129
+ transform=ax.transAxes,
130
+ color="#888888",
131
+ )
132
+ return
133
+
134
+ kind = resolved.kind
135
+ if kind is PlotKind.HEATMAP:
136
+ _draw_heatmap(ax, frame, resolved)
137
+ elif kind in (PlotKind.SCATTER, PlotKind.STRIP):
138
+ _draw_points(ax, frame, resolved, jitter=kind is PlotKind.STRIP)
139
+ elif kind is PlotKind.LINE:
140
+ _draw_lines(ax, frame, resolved)
141
+ elif kind is PlotKind.BAND:
142
+ _draw_band(ax, frame, resolved)
143
+ elif kind is PlotKind.BAR:
144
+ _draw_bars(ax, frame, resolved)
145
+ elif kind in (PlotKind.BOX, PlotKind.VIOLIN):
146
+ _draw_distribution(ax, frame, resolved, violin=kind is PlotKind.VIOLIN)
147
+
148
+
149
+ def _draw_points(ax, frame, resolved, *, jitter: bool) -> None:
150
+ style = resolved.spec.style
151
+ for index, (level, subset) in enumerate(color_groups(frame, resolved)):
152
+ positions, _ = x_positions(subset[resolved.encoding.x], resolved)
153
+ if jitter and is_categorical_x(resolved):
154
+ rng = np.random.default_rng(abs(hash(str(level))) % (2**32))
155
+ positions = positions + rng.uniform(-0.15, 0.15, size=len(positions))
156
+ ax.scatter(
157
+ positions,
158
+ subset[resolved.encoding.y].to_numpy(dtype=float),
159
+ s=style.marker_size,
160
+ alpha=style.alpha,
161
+ color=palette_for(resolved, level, index),
162
+ label=str(level) if level is not None else None,
163
+ )
164
+
165
+
166
+ def _draw_lines(ax, frame, resolved) -> None:
167
+ style = resolved.spec.style
168
+ series_column = resolved.encoding.series
169
+ for index, (level, subset) in enumerate(color_groups(frame, resolved)):
170
+ color = palette_for(resolved, level, index)
171
+ if series_column and series_column in subset.columns:
172
+ series_groups = list(subset.groupby(series_column, sort=False))
173
+ else:
174
+ series_groups = [(None, subset)]
175
+ for position, (_, line_rows) in enumerate(series_groups):
176
+ positions, _ = x_positions(line_rows[resolved.encoding.x], resolved)
177
+ ax.plot(
178
+ positions,
179
+ line_rows[resolved.encoding.y].to_numpy(dtype=float),
180
+ color=color,
181
+ alpha=style.alpha,
182
+ linewidth=1.4,
183
+ # Only the first line of a colour group carries the legend entry,
184
+ # otherwise a 200-trial plot produces a 200-entry legend.
185
+ label=str(level) if (level is not None and position == 0) else None,
186
+ )
187
+
188
+
189
+ def _draw_band(ax, frame, resolved) -> None:
190
+ encoding = resolved.encoding
191
+ for index, (level, subset) in enumerate(color_groups(frame, resolved)):
192
+ color = palette_for(resolved, level, index)
193
+ positions, _ = x_positions(subset[encoding.x], resolved)
194
+ centre = subset[encoding.y].to_numpy(dtype=float)
195
+ ax.plot(
196
+ positions,
197
+ centre,
198
+ color=color,
199
+ linewidth=1.8,
200
+ label=str(level) if level is not None else None,
201
+ )
202
+ if encoding.has_error:
203
+ ax.fill_between(
204
+ positions,
205
+ subset[encoding.y_low].to_numpy(dtype=float),
206
+ subset[encoding.y_high].to_numpy(dtype=float),
207
+ color=color,
208
+ alpha=0.22,
209
+ linewidth=0,
210
+ )
211
+
212
+
213
+ def _draw_bars(ax, frame, resolved) -> None:
214
+ encoding = resolved.encoding
215
+ groups = color_groups(frame, resolved)
216
+ n_groups = max(len(groups), 1)
217
+ width = 0.8 / n_groups
218
+
219
+ for index, (level, subset) in enumerate(groups):
220
+ positions, ticks = x_positions(subset[encoding.x], resolved)
221
+ offset = (index - (n_groups - 1) / 2) * width
222
+ centre = subset[encoding.y].to_numpy(dtype=float)
223
+ error = None
224
+ if encoding.has_error:
225
+ low = subset[encoding.y_low].to_numpy(dtype=float)
226
+ high = subset[encoding.y_high].to_numpy(dtype=float)
227
+ error = np.vstack([centre - low, high - centre])
228
+ ax.bar(
229
+ positions + offset,
230
+ centre,
231
+ width=width,
232
+ yerr=error,
233
+ capsize=3,
234
+ color=palette_for(resolved, level, index),
235
+ alpha=resolved.spec.style.alpha,
236
+ label=str(level) if level is not None else None,
237
+ )
238
+ if ticks is not None:
239
+ ax.set_xticks(range(len(ticks)))
240
+ ax.set_xticklabels(ticks)
241
+
242
+
243
+ def _draw_distribution(ax, frame, resolved, *, violin: bool) -> None:
244
+ """Box or violin, dodged by colour level when one is assigned."""
245
+ encoding = resolved.encoding
246
+ groups = color_groups(frame, resolved)
247
+ n_groups = max(len(groups), 1)
248
+ width = 0.8 / n_groups
249
+ order = resolved.x_order or sorted(frame[encoding.x].dropna().unique().tolist(), key=str)
250
+
251
+ for index, (level, subset) in enumerate(groups):
252
+ offset = (index - (n_groups - 1) / 2) * width
253
+ datasets: list[np.ndarray] = []
254
+ positions: list[float] = []
255
+ for slot, level_value in enumerate(order):
256
+ values = subset[subset[encoding.x].astype(str) == str(level_value)][
257
+ encoding.y
258
+ ].to_numpy(dtype=float)
259
+ values = values[~np.isnan(values)]
260
+ if values.size:
261
+ datasets.append(values)
262
+ positions.append(slot + offset)
263
+ if not datasets:
264
+ continue
265
+
266
+ color = palette_for(resolved, level, index)
267
+ if violin:
268
+ parts = ax.violinplot(
269
+ datasets, positions=positions, widths=width * 0.9, showmeans=True
270
+ )
271
+ for body in parts["bodies"]:
272
+ body.set_facecolor(color)
273
+ body.set_alpha(0.55)
274
+ else:
275
+ drawn = ax.boxplot(
276
+ datasets,
277
+ positions=positions,
278
+ widths=width * 0.85,
279
+ patch_artist=True,
280
+ manage_ticks=False,
281
+ )
282
+ for box in drawn["boxes"]:
283
+ box.set_facecolor(color)
284
+ box.set_alpha(0.6)
285
+ if level is not None:
286
+ # Boxes carry no legend handle of their own; a proxy patch does.
287
+ ax.plot([], [], color=color, linewidth=6, label=str(level))
288
+
289
+ ax.set_xticks(range(len(order)))
290
+ ax.set_xticklabels([str(v) for v in order])
291
+
292
+
293
+ def _draw_heatmap(ax, frame, resolved) -> None:
294
+ matrix = frame[resolved.encoding.z].iloc[0]
295
+ image = ax.imshow(np.asarray(matrix, dtype=float), aspect="auto", origin="lower")
296
+ ax.figure.colorbar(image, ax=ax, fraction=0.046, pad=0.04)
297
+
298
+
299
+ def _apply_axes_cosmetics(fig, axes, resolved: ResolvedPlot, n_rows, n_cols, at_cell) -> None:
300
+ style = resolved.spec.style
301
+ for row in range(n_rows):
302
+ for col in range(n_cols):
303
+ ax = axes[row][col]
304
+ if not ax.get_visible():
305
+ continue
306
+ # ONE rule decides both the axis title and the tick labels (see
307
+ # base.shows_x_labels). They used to drift: the title followed
308
+ # "nothing below" while the categorical block further down
309
+ # re-applied set_xticklabels to every panel, so tick labels came
310
+ # back everywhere.
311
+ bottom = shows_x_labels(resolved, row, col)
312
+ leftmost = shows_y_labels(resolved, row, col)
313
+ panel = at_cell.get((row, col))
314
+ ax.set_xlabel(resolved.labels.x if bottom else "")
315
+ # The facet values, when this is a facet — see base.panel_y_title.
316
+ ax.set_ylabel(panel_y_title(resolved, panel, leftmost=leftmost))
317
+ if style.log_x:
318
+ ax.set_xscale("log")
319
+ if style.log_y:
320
+ ax.set_yscale("log")
321
+ limits = panel_y_limits(resolved, panel) if panel else resolved.y_limits
322
+ if limits and resolved.kind is not PlotKind.HEATMAP:
323
+ ax.set_ylim(*limits)
324
+ if resolved.x_plan:
325
+ # A nested axis is keyed by composed leaf keys the user must
326
+ # never see: ticks show the innermost layer, and the layers
327
+ # above it become brackets under the axis.
328
+ plan = resolved.x_plan
329
+ ax.set_xticks(range(len(plan.order)))
330
+ ax.set_xticklabels(plan.tick_labels)
331
+ if bottom:
332
+ _draw_x_groups(ax, plan)
333
+ elif is_categorical_x(resolved) and resolved.kind in (
334
+ PlotKind.SCATTER,
335
+ PlotKind.STRIP,
336
+ ):
337
+ order = [str(v) for v in (resolved.x_order or [])]
338
+ ax.set_xticks(range(len(order)))
339
+ ax.set_xticklabels(order)
340
+
341
+ # tick_params, NOT set_visible() on the Text objects: with
342
+ # sharex/sharey, get_xticklabels() regenerates the tick list and the
343
+ # new labels take their visibility from the axis's labelbottom
344
+ # param — so per-Text visibility silently reverts. This must also
345
+ # come LAST, after every set_xticklabels above (here and in the
346
+ # _draw_* helpers), so the rule wins rather than being overwritten.
347
+ # rotation=0 alongside it: panel content stays upright at every grid
348
+ # size, matching the plotly path's tickangle (a figure must not read
349
+ # differently just because it gained a facet).
350
+ ax.tick_params(labelbottom=bottom, labelleft=leftmost)
351
+ ax.tick_params(axis="x", rotation=0)
352
+
353
+
354
+ #: Height of one nested-group label row, as a fraction of the axes height.
355
+ X_GROUP_ROW = 0.07
356
+
357
+
358
+ def _draw_x_groups(ax, plan) -> None:
359
+ """Label and bracket each higher x layer beneath the tick labels.
360
+
361
+ Blended coordinates — x in DATA space (leaf positions are data positions on
362
+ a categorical axis) and y in AXES space (a fixed distance below the axis
363
+ regardless of the measure's range). The alternative, data coordinates for
364
+ both, would put the brackets at a y that moves with the data.
365
+ """
366
+ from matplotlib.transforms import blended_transform_factory
367
+
368
+ transform = blended_transform_factory(ax.transData, ax.transAxes)
369
+ for group in plan.groups:
370
+ rows_below = plan.depth - group.depth
371
+ y = -0.10 - X_GROUP_ROW * rows_below
372
+ ax.plot(
373
+ [group.start - 0.35, group.end + 0.35],
374
+ [y + 0.02, y + 0.02],
375
+ transform=transform,
376
+ color="#888888",
377
+ linewidth=0.8,
378
+ clip_on=False,
379
+ )
380
+ ax.text(
381
+ group.centre,
382
+ y,
383
+ group.label,
384
+ transform=transform,
385
+ ha="center",
386
+ va="top",
387
+ fontsize=9,
388
+ clip_on=False,
389
+ )
390
+
391
+
392
+ #: Breathing room between the panels and the legend strip, as a fraction of the
393
+ #: figure width.
394
+ LEGEND_PAD = 0.02
395
+
396
+ #: Widest the legend strip may get, however long the level names are: past this
397
+ #: the labels have eaten the figure, and truncating the panels is worse than
398
+ #: truncating the legend.
399
+ MAX_LEGEND_FRACTION = 0.4
400
+
401
+
402
+ def _apply_legend(fig, resolved: ResolvedPlot) -> float:
403
+ """
404
+ Draw the legend to the right of the panels; return the width it claimed.
405
+
406
+ The return value is a fraction of the figure width, for ``tight_layout``'s
407
+ ``rect`` — see the call site. Zero means no legend was drawn, which is the
408
+ answer for a single colour level (``base.shows_legend``) as well as for no
409
+ colour at all.
410
+ """
411
+ if not shows_legend(resolved):
412
+ Log.debug(
413
+ "legend omitted: %d colour level(s) drawn for %r",
414
+ len(legend_levels(resolved)),
415
+ resolved.labels.color,
416
+ layer=LAYER,
417
+ )
418
+ return 0.0
419
+ # Every VISIBLE axes, not just the first: with facets, a level can be
420
+ # absent from panel 1 and present in panel 5, and reading one panel's
421
+ # handles would drop it from the legend of a figure that draws it.
422
+ unique: dict[str, Any] = {}
423
+ for ax in fig.axes:
424
+ if not ax.get_visible():
425
+ continue
426
+ handles, labels = ax.get_legend_handles_labels()
427
+ for handle, label in zip(handles, labels, strict=False):
428
+ unique.setdefault(label, handle)
429
+ if not unique:
430
+ return 0.0
431
+
432
+ legend = fig.legend(
433
+ unique.values(),
434
+ unique.keys(),
435
+ title=resolved.labels.color,
436
+ loc="center right",
437
+ frameon=False,
438
+ )
439
+ return _legend_width_fraction(fig, legend, unique.keys(), resolved.labels.color)
440
+
441
+
442
+ def _legend_width_fraction(fig, legend, labels, title) -> float:
443
+ """How much of the figure's width the legend needs, measured if possible."""
444
+ figure_width = fig.get_size_inches()[0] or 1.0
445
+ try:
446
+ inches = legend.get_window_extent(fig.canvas.get_renderer()).width / fig.dpi
447
+ except Exception: # a backend without a usable renderer
448
+ # Estimate rather than reserve nothing: a wrong-by-a-little strip still
449
+ # keeps the legend off the panels, an unmeasured one does not.
450
+ longest = max((len(str(text)) for text in [*labels, title or ""]), default=0)
451
+ inches = 0.55 + 0.085 * longest
452
+ Log.debug(
453
+ "legend width not measurable; estimating %.2fin from %d labels",
454
+ inches,
455
+ len(list(labels)),
456
+ layer=LAYER,
457
+ )
458
+ return min(MAX_LEGEND_FRACTION, inches / figure_width + LEGEND_PAD)