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/xaxis.py ADDED
@@ -0,0 +1,183 @@
1
+ """
2
+ Composing a nested categorical x axis.
3
+
4
+ "Stim and sham side by side, each split by session" is one axis carrying two
5
+ factors. This module turns the observed combinations of those factors into a
6
+ flat sequence of **leaf positions** plus the **spans** the higher layers cover,
7
+ and nothing else — no frames, no colours, no rendering.
8
+
9
+ That purity is the point, and it is the same bargain
10
+ :func:`~scistackplot.reduce.plan_layout` makes for the facet grid: the two
11
+ renderers draw brackets from the spans rather than each deriving them, ``codegen``
12
+ emits the resolved order rather than replaying the rules, and the whole
13
+ arrangement is testable from label lists.
14
+
15
+ Separation between groups is **spacer categories** — unique labels with no data.
16
+ That is a deliberate choice over numeric offsets: it keeps the axis categorical,
17
+ so box, violin, bar and strip all position themselves exactly as they already do
18
+ in both backends. Numeric positions would give finer control over gap widths at
19
+ the cost of re-implementing every trace type's placement.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass, field
25
+ from typing import Any, Sequence
26
+
27
+ #: Joins a leaf's layer values into the key the frame is matched on. Chosen to
28
+ #: be unlikely in real level names; a level containing it still works, since the
29
+ #: key is only ever built and compared by this module, never parsed.
30
+ LEAF_SEPARATOR = "␟"
31
+
32
+ #: Prefix marking a spacer category. Spacers hold no data and draw no tick.
33
+ SPACER_PREFIX = " gap"
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class XGroup:
38
+ """One higher-layer label and the leaf positions it covers.
39
+
40
+ ``start``/``end`` are inclusive indices into :attr:`XPlan.order`, which is
41
+ what lets a renderer centre a label over its group and draw a bracket
42
+ beneath it without knowing anything about the data.
43
+ """
44
+
45
+ label: str
46
+ #: 0 is the outermost layer. The leaf layer never appears here — it is the
47
+ #: tick labels.
48
+ depth: int
49
+ start: int
50
+ end: int
51
+
52
+ @property
53
+ def centre(self) -> float:
54
+ return (self.start + self.end) / 2.0
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class XPlan:
59
+ """A composed nested axis."""
60
+
61
+ #: Leaf keys in drawn order, spacers included. Positions are indices here.
62
+ order: list[str] = field(default_factory=list)
63
+ #: Tick label per entry; "" for a spacer.
64
+ tick_labels: list[str] = field(default_factory=list)
65
+ #: Spans of every layer above the leaf, outermost first.
66
+ groups: list[XGroup] = field(default_factory=list)
67
+ #: How many factors share the axis.
68
+ n_layers: int = 1
69
+
70
+ @property
71
+ def depth(self) -> int:
72
+ """Label rows below the axis, excluding the tick labels themselves."""
73
+ return max((group.depth for group in self.groups), default=-1) + 1
74
+
75
+ def position_of(self, key: str) -> int | None:
76
+ try:
77
+ return self.order.index(key)
78
+ except ValueError:
79
+ return None
80
+
81
+
82
+ def leaf_key(values: Sequence[Any]) -> str:
83
+ """The key identifying one leaf position, from its layers' values."""
84
+ return LEAF_SEPARATOR.join(str(value) for value in values)
85
+
86
+
87
+ def is_spacer(key: str) -> bool:
88
+ return str(key).startswith(SPACER_PREFIX)
89
+
90
+
91
+ def plan_x_axis(
92
+ combinations: Sequence[Sequence[Any]],
93
+ layer_orders: Sequence[Sequence[Any]],
94
+ ) -> XPlan:
95
+ """Lay out the leaves for the observed ``combinations``.
96
+
97
+ ``combinations`` are the layer-value tuples that actually occur in the
98
+ data — **not** the Cartesian product of the layers. Real designs are ragged
99
+ (a sham subject with no post session) and reserving a position for a
100
+ combination nobody ran leaves a hole in the axis that reads as missing data.
101
+
102
+ ``layer_orders`` gives each layer's declared level order, so the axis obeys
103
+ the same ordering rule as everything else here: zero-padded IDs sort
104
+ ``01, 02, … 10``, and a legend does not reshuffle when a filter removes a
105
+ level's last row.
106
+
107
+ Gaps scale with the boundary's depth — a change of outer group opens a wider
108
+ gap than a change of the layer just above the leaf — which is what makes
109
+ three levels of nesting readable rather than a uniform picket fence.
110
+ """
111
+ layer_orders = [list(order) for order in layer_orders]
112
+ n_layers = max(1, len(layer_orders))
113
+ combos = [tuple(combo) for combo in combinations]
114
+ if not combos:
115
+ return XPlan(n_layers=n_layers)
116
+
117
+ ranks = [
118
+ {str(level): position for position, level in enumerate(order)}
119
+ for order in layer_orders
120
+ ]
121
+
122
+ def sort_key(combo: tuple) -> tuple:
123
+ key: list = []
124
+ for depth, value in enumerate(combo):
125
+ lookup = ranks[depth] if depth < len(ranks) else {}
126
+ # Levels the declared order never mentioned sort after the ones it
127
+ # did, in a stable way — never dropped.
128
+ key.extend(
129
+ (0, lookup[str(value)])
130
+ if str(value) in lookup
131
+ else (1, str(value))
132
+ )
133
+ return tuple(key)
134
+
135
+ ordered = sorted(dict.fromkeys(combos), key=sort_key)
136
+
137
+ order: list[str] = []
138
+ tick_labels: list[str] = []
139
+ # Where each group currently being accumulated started, keyed by depth.
140
+ open_groups: dict[int, tuple[str, int]] = {}
141
+ groups: list[XGroup] = []
142
+ spacer_count = 0
143
+ previous: tuple | None = None
144
+
145
+ for combo in ordered:
146
+ prefix = combo[:-1]
147
+ if previous is not None:
148
+ changed = [
149
+ depth
150
+ for depth in range(len(prefix))
151
+ if str(prefix[depth]) != str(previous[depth])
152
+ ]
153
+ if changed:
154
+ # Close every group at or below the shallowest change, then open
155
+ # fresh ones. One spacer per closed layer: an outer boundary
156
+ # closes more layers, so its gap is wider.
157
+ shallowest = min(changed)
158
+ for depth in sorted(open_groups, reverse=True):
159
+ if depth >= shallowest:
160
+ label, start = open_groups.pop(depth)
161
+ groups.append(
162
+ XGroup(label, depth, start, len(order) - 1)
163
+ )
164
+ for _ in range(len(prefix) - shallowest):
165
+ order.append(f"{SPACER_PREFIX}{spacer_count}")
166
+ tick_labels.append("")
167
+ spacer_count += 1
168
+
169
+ for depth, value in enumerate(prefix):
170
+ if depth not in open_groups:
171
+ open_groups[depth] = (str(value), len(order))
172
+
173
+ order.append(leaf_key(combo))
174
+ tick_labels.append(str(combo[-1]))
175
+ previous = combo
176
+
177
+ for depth, (label, start) in open_groups.items():
178
+ groups.append(XGroup(label, depth, start, len(order) - 1))
179
+
180
+ groups.sort(key=lambda group: (group.depth, group.start))
181
+ return XPlan(
182
+ order=order, tick_labels=tick_labels, groups=groups, n_layers=n_layers
183
+ )
@@ -0,0 +1,451 @@
1
+ """
2
+ Y-axis limits, split by a chosen set of factors.
3
+
4
+ The question this answers is not "what is the range of this panel" — that is
5
+ easy, and it is what every panel already knew. It is **"what range should this
6
+ panel share with which other panels"**, and that is a property of the whole
7
+ figure set rather than of any one figure.
8
+
9
+ Which makes it awkward, because the interactive path deliberately builds one
10
+ figure at a time (``reduce.resolve_one``): a scope of ``[]`` — one range for
11
+ every panel of every figure — needs data from figures nobody has asked for.
12
+ Building them to find out would cost exactly what Stage 3 just removed.
13
+
14
+ So the limits are computed **off the table, not off the figures**, before any
15
+ reduction happens, in one pass:
16
+
17
+ * the drawn extent of a line/scatter/box/violin is the raw extent of the data,
18
+ so for those it is a min/max over the unexploded arrays — 48 rows of numpy
19
+ work for a figure set that would otherwise be 17 million;
20
+ * a band or a bar draws ``centre ± spread``, which genuinely needs the
21
+ reduction — but only its extremes, so it is one groupby on the sample index,
22
+ with no panel frames, no series keys and no sorting.
23
+
24
+ Both cover the whole fan-out at once, which is the point: paging through thirty
25
+ subjects must not recompute anything.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import Any, Iterable
31
+
32
+ import numpy as np
33
+ import pandas as pd
34
+ from scistacklog import Log
35
+
36
+ from .shape import Shape
37
+ from .spec import ErrorBand, PlotKind, PlotSpec, Role, Statistic
38
+ from .table import LongTable
39
+
40
+ LAYER = "scistackplot"
41
+
42
+ #: Fraction of the data range left as breathing room at each end. Matches what
43
+ #: ``reduce._shared_limits`` has always done, so switching to a scope does not
44
+ #: silently re-pad every existing figure.
45
+ PAD_FRACTION = 0.05
46
+
47
+ #: The key of the single group when nothing separates the limits.
48
+ GLOBAL_KEY: tuple = ()
49
+
50
+
51
+ def eligible_scope(
52
+ scope: Iterable[str],
53
+ roles: dict[str, Role],
54
+ table: LongTable,
55
+ ) -> list[str]:
56
+ """The requested scope, less anything that cannot separate a y axis.
57
+
58
+ Only ITERATE (one figure per level) and FACET (one panel per level) split
59
+ panels apart. A COLOR or FREE factor lives *inside* a panel, so asking to
60
+ separate limits by it asks one axis for two ranges — there is no figure that
61
+ could satisfy it.
62
+
63
+ Dropped entries are logged rather than raised on: a scope is a user's
64
+ checkbox state, and a factor that was FACET a moment ago and is COLOR now
65
+ should quietly stop separating limits instead of replacing the figure with
66
+ an error. The GUI offers only eligible factors anyway; this is the guard for
67
+ a spec that arrives from TOML, from an older session, or from a role the
68
+ user has since changed.
69
+ """
70
+ wanted = list(dict.fromkeys(scope))
71
+ if not wanted:
72
+ return []
73
+
74
+ allowed = {
75
+ name
76
+ for name, role in roles.items()
77
+ if role in (Role.ITERATE, Role.FACET) and table.has_factor(name)
78
+ }
79
+ kept = [name for name in wanted if name in allowed]
80
+
81
+ dropped = [name for name in wanted if name not in allowed]
82
+ if dropped:
83
+ Log.warn(
84
+ "y-limit scope ignores %s: only factors that separate PANELS "
85
+ "(iterate, facet) can separate y limits — %s. Limits will be shared "
86
+ "across %s.",
87
+ dropped,
88
+ {name: str(roles.get(name, "no role")) for name in dropped},
89
+ dropped,
90
+ layer=LAYER,
91
+ )
92
+ return kept
93
+
94
+
95
+ def limits_by_scope(
96
+ table: LongTable,
97
+ spec: PlotSpec,
98
+ scope: list[str],
99
+ ) -> dict[tuple, tuple[float, float]]:
100
+ """``{scope values: (low, high)}`` for every group the scope names.
101
+
102
+ ``scope`` must already be :func:`eligible_scope`-filtered. An empty scope
103
+ returns exactly one entry, keyed ``()`` — one range for the whole dataset.
104
+
105
+ The frame is whatever the caller hands over, which for the resolve path is
106
+ the **post-variant, post-filter** table: limits must describe what will be
107
+ drawn, so a variant selection that removes half the data has to move them.
108
+ """
109
+ frame = table.frame
110
+ measure = spec.y_measure
111
+ if measure not in frame.columns or frame.empty:
112
+ return {}
113
+
114
+ present = [name for name in scope if name in frame.columns]
115
+ if _needs_reduction(spec):
116
+ extents = _aggregated_extents(frame, spec, table, present)
117
+ else:
118
+ extents = _raw_extents(frame, measure, present)
119
+
120
+ limits = {key: _padded(low, high) for key, (low, high) in extents.items()}
121
+ Log.debug(
122
+ "y limits over %s: %d group(s)",
123
+ present or "the whole dataset",
124
+ len(limits),
125
+ layer=LAYER,
126
+ )
127
+ return limits
128
+
129
+
130
+ def limits_for(
131
+ limits: dict[tuple, tuple[float, float]],
132
+ key: dict[str, Any],
133
+ scope: list[str],
134
+ y_axis,
135
+ ) -> tuple[float, float] | None:
136
+ """The limits one panel draws: its group's range, with overrides applied.
137
+
138
+ ``key`` is everything identifying the panel — its figure's ITERATE values
139
+ merged with its own FACET values — and the scope picks the part that
140
+ matters. A panel whose group is missing (a combination the data does not
141
+ hold) falls back to the global range if there is one, because a panel with
142
+ no limits at all silently autoscales and would be the one panel on the page
143
+ that cannot be compared with the others.
144
+
145
+ An override end always wins; both ends override means the data is never
146
+ consulted, which is what lets a manual range be set on an empty figure.
147
+ """
148
+ if y_axis.is_manual:
149
+ # _ordered here too: BOTH ends typed by hand is exactly the case where a
150
+ # swapped pair reaches an axis untouched, because this path never
151
+ # consults the data and so never passed through the ordering below.
152
+ return _ordered(float(y_axis.minimum), float(y_axis.maximum))
153
+
154
+ group = tuple(key.get(name) for name in scope)
155
+ found = limits.get(group)
156
+ if found is None and scope:
157
+ found = limits.get(GLOBAL_KEY)
158
+ if found is None:
159
+ # Nothing computed and no global fallback: with one end pinned there is
160
+ # still nothing to pin it against, so let the renderer autoscale rather
161
+ # than invent the other end.
162
+ return None
163
+
164
+ low, high = found
165
+ if y_axis.minimum is not None:
166
+ low = float(y_axis.minimum)
167
+ if y_axis.maximum is not None:
168
+ high = float(y_axis.maximum)
169
+ return _ordered(low, high)
170
+
171
+
172
+ def describe(scope: list[str], y_axis) -> str:
173
+ """One line naming the rule, for the panel and the log.
174
+
175
+ A number on an axis that cannot be traced to a rule is indistinguishable
176
+ from a bug — "why is this 0.61?" has to have an answer the user can read.
177
+ """
178
+ if y_axis.is_manual:
179
+ return "set by hand"
180
+ rule = "the same everywhere" if not scope else f"per {', '.join(scope)}"
181
+ if y_axis.minimum is not None:
182
+ rule += ", floor set by hand"
183
+ if y_axis.maximum is not None:
184
+ rule += ", ceiling set by hand"
185
+ return rule
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Extents
190
+ # ---------------------------------------------------------------------------
191
+
192
+
193
+ def _needs_reduction(spec: PlotSpec) -> bool:
194
+ """Whether the drawn extent differs from the raw extent.
195
+
196
+ Only for the kinds that draw a summary: a band or a bar draws
197
+ ``centre ± spread``, which can sit outside the data (a mean plus an SD) and
198
+ inside it (a mean of anything). Everything else draws the observations, or
199
+ box statistics that lie within them.
200
+ """
201
+ return spec.kind in (PlotKind.BAND, PlotKind.BAR) and (
202
+ spec.aggregate.error is not ErrorBand.NONE
203
+ )
204
+
205
+
206
+ def _raw_extents(
207
+ frame: pd.DataFrame, measure: str, scope: list[str]
208
+ ) -> dict[tuple, tuple[float, float]]:
209
+ """Min/max per group, **without exploding anything**.
210
+
211
+ A 1-D measure is one array per cell, and the extent of a group of arrays is
212
+ the extent of their per-cell extents. Computing that cell by cell is 24
213
+ numpy reductions over arrays that already exist, where exploding first would
214
+ build 8.9 million rows to answer the same question.
215
+ """
216
+ values = frame[measure]
217
+ lows, highs = _cell_extents(values)
218
+
219
+ usable = ~(np.isnan(lows) | np.isnan(highs))
220
+ if not usable.any():
221
+ return {}
222
+
223
+ if not scope:
224
+ return {GLOBAL_KEY: (float(lows[usable].min()), float(highs[usable].max()))}
225
+
226
+ keys = _scope_keys(frame, scope)
227
+ extents: dict[tuple, tuple[float, float]] = {}
228
+ for key, low, high, ok in zip(keys, lows, highs, usable, strict=True):
229
+ if not ok:
230
+ continue
231
+ seen = extents.get(key)
232
+ extents[key] = (
233
+ (low, high) if seen is None else (min(seen[0], low), max(seen[1], high))
234
+ )
235
+ # The global entry always exists as a fallback for a panel whose own group
236
+ # has no data (`limits_for`), and it is free: these are the same numbers.
237
+ if extents:
238
+ extents[GLOBAL_KEY] = (
239
+ min(low for low, _ in extents.values()),
240
+ max(high for _, high in extents.values()),
241
+ )
242
+ return extents
243
+
244
+
245
+ def _cell_extents(values: pd.Series) -> tuple[np.ndarray, np.ndarray]:
246
+ """Per-row (min, max) of a measure column that may hold scalars or arrays."""
247
+ numeric = pd.to_numeric(values, errors="coerce")
248
+ if not numeric.isna().all():
249
+ # A plain scalar column: its own values are the extents, and this is one
250
+ # vectorized cast rather than a Python loop.
251
+ column = numeric.to_numpy(dtype=float, na_value=np.nan)
252
+ return column, column
253
+
254
+ lows = np.full(len(values), np.nan)
255
+ highs = np.full(len(values), np.nan)
256
+ for position, value in enumerate(values.to_numpy()):
257
+ array = _as_array(value)
258
+ # An all-NaN cell stays NaN rather than going through nanmin, which
259
+ # warns and returns NaN anyway — the caller drops it either way, and a
260
+ # RuntimeWarning per empty trial is noise in a real run.
261
+ if array is None or not array.size or np.isnan(array).all():
262
+ continue
263
+ lows[position] = np.nanmin(array)
264
+ highs[position] = np.nanmax(array)
265
+ return lows, highs
266
+
267
+
268
+ def _as_array(value: Any) -> np.ndarray | None:
269
+ """One cell as floats, or None when it holds nothing numeric."""
270
+ if value is None or isinstance(value, (str, bytes)):
271
+ return None
272
+ if isinstance(value, (list, tuple, np.ndarray)):
273
+ try:
274
+ return np.asarray(value, dtype="float64")
275
+ except (TypeError, ValueError):
276
+ return None
277
+ try:
278
+ return np.asarray([float(value)])
279
+ except (TypeError, ValueError):
280
+ return None
281
+
282
+
283
+ def _aggregated_extents(
284
+ frame: pd.DataFrame,
285
+ spec: PlotSpec,
286
+ table: LongTable,
287
+ scope: list[str],
288
+ ) -> dict[tuple, tuple[float, float]]:
289
+ """Extents of ``centre ± spread`` — the band a BAND/BAR actually draws.
290
+
291
+ This is the expensive branch and it is deliberately narrow: it reduces to
292
+ the same statistic ``reduce._summarize`` does, grouped by everything that
293
+ separates an x position, and then takes extremes. No panel frames, no series
294
+ keys, no sorting, and one pass for the whole fan-out rather than one per
295
+ figure.
296
+ """
297
+ measure = spec.y_measure
298
+ working = frame
299
+ index_column = spec.index_column or table.index_column
300
+
301
+ if table.shape_of(measure) is Shape.SERIES_1D and not table.measure(measure).exploded:
302
+ working, index_column = _explode_for_limits(frame, measure, index_column)
303
+
304
+ # What separates one drawn point from another: the scope (which separates
305
+ # panels), plus the x position and the colour within a panel.
306
+ grouping = [
307
+ name
308
+ for name in dict.fromkeys(
309
+ [
310
+ *scope,
311
+ *[n for n, r in spec.roles.items() if r in (Role.X, Role.COLOR)],
312
+ *( [index_column] if index_column else [] ),
313
+ ]
314
+ )
315
+ if name in working.columns
316
+ ]
317
+ values = pd.to_numeric(working[measure], errors="coerce")
318
+ working = working.assign(**{measure: values}).dropna(subset=[measure])
319
+ if working.empty:
320
+ return {}
321
+ if not grouping:
322
+ centre, low, high = _summary_bounds(working[measure], spec)
323
+ return {GLOBAL_KEY: (float(min(low, centre)), float(max(high, centre)))}
324
+
325
+ grouped = working.groupby(grouping, dropna=False, sort=False)[measure]
326
+ centre = grouped.median() if spec.aggregate.statistic is Statistic.MEDIAN else grouped.mean()
327
+ low, high = _spread(grouped, centre, spec)
328
+
329
+ bounds = pd.DataFrame({"low": low, "high": high}).reset_index()
330
+ if not scope:
331
+ return {GLOBAL_KEY: (float(bounds["low"].min()), float(bounds["high"].max()))}
332
+
333
+ by_scope = bounds.groupby(scope, dropna=False, sort=False)
334
+ extents = {
335
+ _as_key(key): (float(part["low"].min()), float(part["high"].max()))
336
+ for key, part in by_scope
337
+ }
338
+ if extents:
339
+ extents[GLOBAL_KEY] = (
340
+ min(low for low, _ in extents.values()),
341
+ max(high for _, high in extents.values()),
342
+ )
343
+ return extents
344
+
345
+
346
+ def _explode_for_limits(
347
+ frame: pd.DataFrame, measure: str, index_column: str | None
348
+ ) -> tuple[pd.DataFrame, str]:
349
+ """A minimal explode: the measure, the index, and the factors — nothing else.
350
+
351
+ Deliberately not ``reduce._explode_1d``: that one logs at INFO (it is the
352
+ headline cost of a resolve) and raises when the index column already exists.
353
+ Here an existing index column just means the caller pre-exploded.
354
+ """
355
+ column = index_column or "index"
356
+ if column in frame.columns:
357
+ return frame, column
358
+ working = frame.copy()
359
+ working[column] = working[measure].map(
360
+ lambda v: list(range(len(v))) if isinstance(v, (list, tuple, np.ndarray)) else []
361
+ )
362
+ return working.explode([measure, column], ignore_index=True), column
363
+
364
+
365
+ def _spread(grouped, centre, spec: PlotSpec):
366
+ """``(low, high)`` per group — the same definitions ``reduce._summarize`` uses.
367
+
368
+ Kept deliberately parallel to that function: two definitions of an error
369
+ band would put the limits and the drawing at odds, and the symptom would be
370
+ a band clipped by its own axis.
371
+ """
372
+ error = spec.aggregate.error
373
+ if error is ErrorBand.IQR:
374
+ return grouped.quantile(0.25), grouped.quantile(0.75)
375
+ sd = grouped.std(ddof=1).fillna(0.0)
376
+ count = grouped.count()
377
+ if error is ErrorBand.SD:
378
+ spread = sd
379
+ elif error is ErrorBand.SEM:
380
+ spread = sd / np.sqrt(count.where(count > 0, 1))
381
+ else: # CI95
382
+ spread = 1.96 * sd / np.sqrt(count.where(count > 0, 1))
383
+ return centre - spread, centre + spread
384
+
385
+
386
+ def _summary_bounds(values: pd.Series, spec: PlotSpec):
387
+ """The ungrouped case: one centre and one band over everything."""
388
+ centre = (
389
+ values.median() if spec.aggregate.statistic is Statistic.MEDIAN else values.mean()
390
+ )
391
+ error = spec.aggregate.error
392
+ if error is ErrorBand.IQR:
393
+ return centre, values.quantile(0.25), values.quantile(0.75)
394
+ sd = values.std(ddof=1)
395
+ sd = 0.0 if pd.isna(sd) else sd
396
+ count = max(len(values), 1)
397
+ if error is ErrorBand.SD:
398
+ spread = sd
399
+ elif error is ErrorBand.SEM:
400
+ spread = sd / np.sqrt(count)
401
+ else:
402
+ spread = 1.96 * sd / np.sqrt(count)
403
+ return centre, centre - spread, centre + spread
404
+
405
+
406
+ # ---------------------------------------------------------------------------
407
+ # Keys and padding
408
+ # ---------------------------------------------------------------------------
409
+
410
+
411
+ def _scope_keys(frame: pd.DataFrame, scope: list[str]) -> list[tuple]:
412
+ """One key tuple per row, matching what a panel's ``key`` will produce."""
413
+ columns = [frame[name].to_numpy() for name in scope]
414
+ return list(zip(*columns, strict=True)) if len(columns) > 1 else [
415
+ (value,) for value in columns[0]
416
+ ]
417
+
418
+
419
+ def _as_key(key: Any) -> tuple:
420
+ return key if isinstance(key, tuple) else (key,)
421
+
422
+
423
+ def _ordered(low: float, high: float) -> tuple[float, float]:
424
+ """Bounds in the order an axis wants them.
425
+
426
+ A hand-typed minimum above the computed maximum is a typo, not an inverted
427
+ axis: matplotlib would silently flip the axis and the figure would read
428
+ upside down with nothing to say why.
429
+
430
+ Swapping is reported rather than done quietly — the user typed one of those
431
+ two numbers and the figure is about to disagree with it, so the log is the
432
+ only place that can say which way round the axis actually ended up.
433
+ """
434
+ if low <= high:
435
+ return (low, high)
436
+ Log.warn(
437
+ "y limits arrived inverted (%s above %s) — drawing them the other way "
438
+ "round rather than flipping the axis",
439
+ low,
440
+ high,
441
+ layer=LAYER,
442
+ )
443
+ return (high, low)
444
+
445
+
446
+ def _padded(low: float, high: float) -> tuple[float, float]:
447
+ if low == high:
448
+ pad = abs(low) * PAD_FRACTION or 1.0
449
+ return (low - pad, high + pad)
450
+ pad = (high - low) * PAD_FRACTION
451
+ return (low - pad, high + pad)