rootfig 0.1.0__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.
rootfig/api.py ADDED
@@ -0,0 +1,1297 @@
1
+ """The high-level API: :func:`plot`, :func:`histogram`, :func:`load` and friends.
2
+
3
+ Everything here is a thin orchestration of the lower layers
4
+ (:mod:`rootfig.io`, :mod:`rootfig.expressions`, :mod:`rootfig.selection`,
5
+ :mod:`rootfig.histograms`, :mod:`rootfig.plotting`), which remain usable on
6
+ their own.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import warnings
12
+ from collections.abc import Mapping, Sequence
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ import awkward as ak
17
+ import matplotlib.pyplot as plt
18
+ import numpy as np
19
+
20
+ from rootfig._typing import FloatArray, Hist
21
+ from rootfig.errors import RootfigWarning, SelectionError, SourceError
22
+ from rootfig.expressions import parse
23
+ from rootfig.histograms import (
24
+ SIGNIFICANCE_KINDS,
25
+ CutflowTable,
26
+ Histogram,
27
+ NormalizeSpec,
28
+ ProfileStatistic,
29
+ RatioUncertainty,
30
+ SignificanceKind,
31
+ Summary,
32
+ as_weight_storage,
33
+ build_histograms,
34
+ build_histograms_2d,
35
+ combined_selection,
36
+ correlation_matrix,
37
+ describe_table,
38
+ load_columns,
39
+ read_arrays,
40
+ significance,
41
+ )
42
+ from rootfig.histograms import cutflow as cutflow_of
43
+ from rootfig.histograms import efficiency as efficiency_of
44
+ from rootfig.histograms import normalize as normalize_histogram
45
+ from rootfig.histograms import profile as profile_of
46
+ from rootfig.histograms import summarize as summarize_columns
47
+ from rootfig.model import (
48
+ Bins,
49
+ CutLike,
50
+ RangeSpec,
51
+ Sample,
52
+ StyleLike,
53
+ Variable,
54
+ as_cut,
55
+ as_samples,
56
+ as_style,
57
+ as_variable,
58
+ resolve_axis,
59
+ )
60
+ from rootfig.model.samples import HistType
61
+ from rootfig.plotting import (
62
+ AxesLike,
63
+ FlowSpec,
64
+ Plot,
65
+ add_experiment_label,
66
+ add_legend,
67
+ add_stats_box,
68
+ apply_xbreak,
69
+ break_segments,
70
+ color_cycle,
71
+ draw_correlation,
72
+ draw_efficiencies,
73
+ draw_hist2d,
74
+ draw_histograms,
75
+ draw_profiles,
76
+ draw_ratio_panel,
77
+ draw_significance_panel,
78
+ envelope,
79
+ finish_axes,
80
+ fold_flow_bins,
81
+ label_flow_bins,
82
+ legend_location,
83
+ make_figure,
84
+ overlay_artists,
85
+ raise_ylim_above,
86
+ show_flow_bins,
87
+ style_context,
88
+ ylabel_for,
89
+ )
90
+ from rootfig.selection import Columns, NonFinitePolicy, boolean_mask, depth_of
91
+
92
+ __all__ = [
93
+ "SummaryTable",
94
+ "correlation",
95
+ "histogram",
96
+ "histograms",
97
+ "load",
98
+ "plot",
99
+ "plot2d",
100
+ "plot_histograms",
101
+ "summarize",
102
+ ]
103
+
104
+
105
+ # --------------------------------------------------------------------------------------
106
+ # Data access
107
+ # --------------------------------------------------------------------------------------
108
+
109
+
110
+ def load(
111
+ data: Any,
112
+ expressions: str | Sequence[str] | Mapping[str, str] | None = None,
113
+ *,
114
+ tree: str | None = None,
115
+ selection: CutLike | None = None,
116
+ entry_start: int | None = None,
117
+ entry_stop: int | None = None,
118
+ ) -> ak.Array:
119
+ """Read branches (or evaluate expressions) into an Awkward record array.
120
+
121
+ Parameters
122
+ ----------
123
+ data
124
+ File path(s), glob, ``"path:tree"``, a :class:`~rootfig.model.Sample`,
125
+ or in-memory arrays.
126
+ expressions
127
+ Branch names or expressions to evaluate. A mapping gives the output
128
+ field names explicitly (``{"pt": "Muon_pt / 1000"}``). ``None`` reads
129
+ every branch.
130
+ tree
131
+ Tree name when ``data`` is a file specification.
132
+ selection
133
+ An event-level boolean expression; events failing it are dropped. A
134
+ per-object selection raises :class:`~rootfig.errors.SelectionError`
135
+ (apply object cuts inside the expressions instead, e.g.
136
+ ``"Muon_pt[Muon_pt > 20]"``), and so does a numeric one (an integer flag
137
+ would otherwise be taken as an index array; write ``"flag != 0"``).
138
+ entry_start, entry_stop
139
+ Entry range to read (ignored for a ``Sample``, which carries its own).
140
+
141
+ Returns
142
+ -------
143
+ awkward.Array
144
+ A record array with one field per expression.
145
+ """
146
+ sample = _single_sample(data, tree=tree, entry_start=entry_start, entry_stop=entry_stop)
147
+ available = sample.source.branches()
148
+ if expressions is None:
149
+ fields: dict[str, str] = {name: f"`{name}`" for name in available}
150
+ elif isinstance(expressions, str):
151
+ fields = {expressions: expressions}
152
+ elif isinstance(expressions, Mapping):
153
+ fields = dict(expressions)
154
+ else:
155
+ fields = {e: e for e in expressions}
156
+ if not fields:
157
+ msg = "no expressions to load"
158
+ raise SourceError(msg)
159
+
160
+ parsed = {name: parse(text) for name, text in fields.items()}
161
+ cut = combined_selection(sample, selection)
162
+ arrays, n_events = read_arrays(sample, [*parsed.values(), *([cut.parsed()] if cut else [])])
163
+ result = {
164
+ name: expression.evaluate(arrays, length=n_events) for name, expression in parsed.items()
165
+ }
166
+ if cut is not None:
167
+ mask = boolean_mask(cut.parsed(), arrays, length=n_events)
168
+ if depth_of(mask) != 1:
169
+ msg = (
170
+ f"selection {cut.expression!r} is per-object; load() only supports per-event "
171
+ "selections. Reduce it with any()/all()/count() or apply it inside the "
172
+ "expressions, e.g. 'Muon_pt[Muon_pt > 20]'"
173
+ )
174
+ raise SelectionError(msg)
175
+ result = {name: array[mask] for name, array in result.items()}
176
+ return ak.Array(result)
177
+
178
+
179
+ # --------------------------------------------------------------------------------------
180
+ # Histograms
181
+ # --------------------------------------------------------------------------------------
182
+
183
+
184
+ def histograms(
185
+ data: Any,
186
+ variable: str | Variable,
187
+ *,
188
+ tree: str | None = None,
189
+ selection: CutLike | None = None,
190
+ weight: str | None = None,
191
+ lumi: float | str | None = None,
192
+ bins: Bins | None = None,
193
+ range: RangeSpec = None,
194
+ label: str | Sequence[str] | None = None,
195
+ normalize: NormalizeSpec = None,
196
+ nonfinite: NonFinitePolicy = "drop",
197
+ ) -> list[Histogram]:
198
+ """Fill one :class:`~rootfig.histograms.Histogram` per sample with shared binning.
199
+
200
+ See :func:`plot` for the meaning of the arguments; this function stops
201
+ before drawing.
202
+ """
203
+ samples = as_samples(data, tree=tree, labels=label)
204
+ var = as_variable(variable, bins=bins, range=range)
205
+ hists = build_histograms(
206
+ samples, var, selection=selection, weight=weight, lumi=lumi, nonfinite=nonfinite
207
+ )
208
+ if normalize is None or normalize is False:
209
+ return hists
210
+ return [normalize_histogram(h, normalize) for h in hists]
211
+
212
+
213
+ def histogram(
214
+ data: Any,
215
+ variable: str | Variable,
216
+ *,
217
+ tree: str | None = None,
218
+ selection: CutLike | None = None,
219
+ weight: str | None = None,
220
+ lumi: float | str | None = None,
221
+ bins: Bins | None = None,
222
+ range: RangeSpec = None,
223
+ normalize: NormalizeSpec = None,
224
+ nonfinite: NonFinitePolicy = "drop",
225
+ ) -> Hist:
226
+ """Fill a single histogram and return it as a plain ``hist.Hist``.
227
+
228
+ Examples
229
+ --------
230
+ >>> h = rf.histogram(
231
+ ... "events.root", "Muon_pt", tree="events", selection="Muon_pt > 20", bins=(50, 0, 200)
232
+ ... ) # doctest: +SKIP
233
+ >>> h.values().sum() # doctest: +SKIP
234
+ """
235
+ results = histograms(
236
+ data,
237
+ variable,
238
+ tree=tree,
239
+ selection=selection,
240
+ weight=weight,
241
+ lumi=lumi,
242
+ bins=bins,
243
+ range=range,
244
+ normalize=normalize,
245
+ nonfinite=nonfinite,
246
+ )
247
+ if len(results) != 1:
248
+ msg = f"histogram() takes a single sample, got {len(results)}; use histograms() instead"
249
+ raise SourceError(msg)
250
+ return results[0].hist
251
+
252
+
253
+ # --------------------------------------------------------------------------------------
254
+ # 1D plots
255
+ # --------------------------------------------------------------------------------------
256
+
257
+
258
+ def plot(
259
+ data: Any,
260
+ variable: str | Variable,
261
+ *,
262
+ tree: str | None = None,
263
+ selection: CutLike | None = None,
264
+ weight: str | None = None,
265
+ lumi: float | str | None = None,
266
+ bins: Bins | None = None,
267
+ range: RangeSpec = None,
268
+ label: str | Sequence[str] | None = None,
269
+ observed: Any = None,
270
+ xlabel: str | None = None,
271
+ ylabel: str | None = None,
272
+ unit: str | None = None,
273
+ title: str | None = None,
274
+ normalize: NormalizeSpec = None,
275
+ stack: bool = False,
276
+ ratio: RatioSpec = False,
277
+ ratio_ylim: tuple[float, float] | None = None,
278
+ ratio_label: str | None = None,
279
+ ratio_uncertainty: RatioUncertainty | None = None,
280
+ logx: bool | None = None,
281
+ logy: bool = False,
282
+ flow: FlowSpec = "hint",
283
+ histtype: HistType | None = None,
284
+ errorbars: bool | None = None,
285
+ xlim: tuple[float, float] | None = None,
286
+ ylim: tuple[float | None, float | None] | None = None,
287
+ xbreak: tuple[float, float] | None = None,
288
+ legend: bool | str | None = None,
289
+ stats: bool | str = False,
290
+ text: str | Sequence[str] | None = None,
291
+ style: StyleLike = None,
292
+ figsize: tuple[float, float] | None = None,
293
+ ax: AxesLike = None,
294
+ nonfinite: NonFinitePolicy = "drop",
295
+ save: str | None = None,
296
+ ) -> Plot:
297
+ """Histogram a variable from one or more samples and draw it.
298
+
299
+ This is the ``TTree::Draw`` of rootfig: read only the branches needed,
300
+ evaluate the expressions, apply the selection with the documented
301
+ per-event/per-object semantics, fill ``hist.Hist`` objects with a binning
302
+ shared by all samples, and render them with mplhep.
303
+
304
+ Parameters
305
+ ----------
306
+ data
307
+ What to plot: a file path or glob, ``"path:tree"``, a list of those (one
308
+ sample each), a ``{label: files}`` mapping, one or more
309
+ :class:`~rootfig.model.Sample` objects, or in-memory arrays (a mapping
310
+ of arrays or an Awkward record array).
311
+ variable
312
+ Branch name or expression (see :mod:`rootfig.expressions`), or a
313
+ :class:`~rootfig.model.Variable` carrying binning and labels.
314
+ tree
315
+ Tree name for file inputs; auto-detected when a file holds one tree.
316
+ selection
317
+ Boolean expression or :class:`~rootfig.model.Cut` applied before
318
+ filling. Per-object selections mask objects of jagged variables;
319
+ per-event selections drop events.
320
+ weight
321
+ Weight expression, e.g. ``"mc_weight * sf"``; per-event weights are
322
+ broadcast onto objects.
323
+ lumi
324
+ Integrated luminosity to scale simulated samples to, in fb^-1 or as a
325
+ string with a unit (``"10.8 ab^-1"``). Applies to samples with a cross
326
+ section (``Sample(xsec=..., ngen=...)``): their weights are multiplied
327
+ by ``xsec * lumi / ngen``. Also written into the label unless the style
328
+ already has a luminosity.
329
+ bins
330
+ Binning: an ``int`` (range inferred from the data), ``(n, low, high)``,
331
+ bin edges, or a ``hist`` axis. Overrides the ``Variable``'s binning.
332
+ range
333
+ Range for integer ``bins``: ``(low, high)``, ``"auto"`` or ``"robust"``.
334
+ label
335
+ Legend label(s) for samples given as plain files.
336
+ observed
337
+ A sample of observed data (or the file(s) for one) drawn as black points,
338
+ excluded from stacks and used as numerator of the ratio.
339
+ xlabel, ylabel, unit, title
340
+ Axis labels; defaults come from the variable, the normalisation and the
341
+ bin width (``Events / 2 GeV``).
342
+ normalize
343
+ ``True``/``"unity"`` (sum to one), ``"density"``, ``"width"`` (divide by
344
+ bin width) or a number to normalise to.
345
+ stack
346
+ Stack the non-data samples.
347
+ ratio
348
+ ``True`` for a ratio panel (data / total for stacks or data plots,
349
+ otherwise each sample over the first), a sample label to use as the
350
+ reference, or a significance panel: ``"significance"`` (``S/sqrt(B)``),
351
+ ``"s/sqrt(b)"`` or ``"s/sqrt(s+b)"``, where the signal is the last
352
+ non-data sample (the top of a stack) and the background the sum of the
353
+ others; ``("s/sqrt(b)", "Signal")`` names the signal sample.
354
+ ratio_ylim, ratio_label, ratio_uncertainty
355
+ Ratio panel range, y label, and uncertainty treatment
356
+ (``"propagate"`` or ``"numerator"`` with a reference band).
357
+ logx, logy
358
+ Logarithmic axes. ``logx=None`` (default) follows the ``Variable``'s
359
+ ``log`` flag; ``True``/``False`` override it.
360
+ flow
361
+ Under/overflow display: ``"hint"`` (arrows), ``"show"`` (extra bins),
362
+ ``"sum"`` (added to the edge bins, also for ratios and limits), ``"none"``.
363
+ histtype
364
+ Default drawing style for non-data samples: ``"step"``, ``"fill"``,
365
+ ``"errorbar"`` or ``"band"``.
366
+ errorbars
367
+ Draw statistical error bars on non-data histograms.
368
+ xlim, ylim
369
+ Axis limits; ``ylim`` entries may be ``None`` to keep the automatic value.
370
+ xbreak
371
+ ``(a, b)`` to cut the x axis: the range between ``a`` and ``b`` is
372
+ removed and the two remaining segments are drawn side by side with a
373
+ break mark (e.g. a peak and a far tail, or a sentinel region). Works
374
+ with ratio panels; not with ``ax=``.
375
+ legend
376
+ ``False`` to suppress, or a matplotlib location string.
377
+ stats
378
+ Add a box with entries, mean and standard deviation per sample;
379
+ ``True`` or a location string.
380
+ text
381
+ Extra text line(s) drawn with the experiment label.
382
+ style
383
+ :class:`~rootfig.model.Style`, an experiment name (``"ATLAS"``, ...),
384
+ or ``None`` for the neutral rootfig style.
385
+ figsize
386
+ Figure size in inches.
387
+ ax
388
+ Draw into existing axes (``Axes`` or ``(main, ratio)`` pair).
389
+ nonfinite
390
+ ``"drop"`` (warn) or ``"error"`` for ``nan``/``inf`` values.
391
+ save
392
+ Path to save the figure to (also returned in the :class:`Plot`).
393
+
394
+ Returns
395
+ -------
396
+ Plot
397
+ The figure, axes, histograms and ratios.
398
+ """
399
+ samples = as_samples(data, tree=tree, labels=label)
400
+ if observed is not None:
401
+ observed_samples = [
402
+ s if s.is_data else s.with_(is_data=True) for s in as_samples(observed, tree=tree)
403
+ ]
404
+ samples = [*samples, *observed_samples]
405
+ var = as_variable(variable, bins=bins, range=range, label=xlabel, unit=unit)
406
+ hists = build_histograms(
407
+ samples, var, selection=selection, weight=weight, lumi=lumi, nonfinite=nonfinite
408
+ )
409
+ resolved_style = _style_for(style, text, lumi)
410
+ return plot_histograms(
411
+ hists,
412
+ variable=var,
413
+ ylabel=ylabel,
414
+ title=title,
415
+ normalize=normalize,
416
+ stack=stack,
417
+ ratio=ratio,
418
+ ratio_ylim=ratio_ylim,
419
+ ratio_label=ratio_label,
420
+ ratio_uncertainty=ratio_uncertainty,
421
+ logx=logx,
422
+ logy=logy,
423
+ flow=flow,
424
+ histtype=histtype,
425
+ errorbars=errorbars,
426
+ xlim=xlim,
427
+ ylim=ylim,
428
+ xbreak=xbreak,
429
+ legend=legend,
430
+ stats=stats,
431
+ style=resolved_style,
432
+ figsize=figsize,
433
+ ax=ax,
434
+ save=save,
435
+ )
436
+
437
+
438
+ def plot_histograms(
439
+ hists: Sequence[Histogram | Hist],
440
+ *,
441
+ variable: Variable | None = None,
442
+ labels: Sequence[str] | None = None,
443
+ xlabel: str | None = None,
444
+ ylabel: str | None = None,
445
+ title: str | None = None,
446
+ normalize: NormalizeSpec = None,
447
+ stack: bool = False,
448
+ ratio: RatioSpec = False,
449
+ ratio_ylim: tuple[float, float] | None = None,
450
+ ratio_label: str | None = None,
451
+ ratio_uncertainty: RatioUncertainty | None = None,
452
+ logx: bool | None = None,
453
+ logy: bool = False,
454
+ flow: FlowSpec = "hint",
455
+ histtype: HistType | None = None,
456
+ errorbars: bool | None = None,
457
+ xlim: tuple[float, float] | None = None,
458
+ ylim: tuple[float | None, float | None] | None = None,
459
+ xbreak: tuple[float, float] | None = None,
460
+ legend: bool | str | None = None,
461
+ stats: bool | str = False,
462
+ style: StyleLike = None,
463
+ figsize: tuple[float, float] | None = None,
464
+ ax: AxesLike = None,
465
+ save: str | None = None,
466
+ ) -> Plot:
467
+ """Draw already-filled histograms (``hist.Hist`` or :class:`~rootfig.histograms.Histogram`).
468
+
469
+ Accepts the same drawing options as :func:`plot`. Plain ``hist.Hist``
470
+ objects are labelled from ``labels`` (or numbered) and converted to
471
+ ``Weight`` storage if they have a plain count storage; mark data by passing
472
+ :class:`~rootfig.histograms.Histogram` objects with ``is_data=True``.
473
+ Overlaid histograms may have different binnings; stacks, ratio panels and
474
+ ``flow="show"`` need identical bin edges.
475
+ """
476
+ if logx is None:
477
+ logx = variable.log if variable is not None else False
478
+ histograms_ = _wrap_hists(hists, labels)
479
+ if not histograms_:
480
+ msg = "no histograms to draw"
481
+ raise ValueError(msg)
482
+ if any(h.ndim != 1 for h in histograms_):
483
+ msg = "plot_histograms() draws one-dimensional histograms; use plot2d() for 2D"
484
+ raise ValueError(msg)
485
+ if normalize is not None and normalize is not False:
486
+ histograms_ = [_normalize_for_plot(h, normalize) for h in histograms_]
487
+ resolved_style = as_style(style)
488
+ if legend is not None:
489
+ resolved_style = resolved_style.with_(legend=legend)
490
+
491
+ # The y label quotes the bin width of the histogram as filled, before flow bins are added.
492
+ label_widths = histograms_[0].widths
493
+ flow_shown = (False, False)
494
+ if flow == "show":
495
+ if xbreak is not None and xlim is None:
496
+ msg = "xbreak cannot be combined with flow='show'; pass xlim as well"
497
+ raise ValueError(msg)
498
+ # Done here rather than in mplhep so every histogram, the ratio panel and the
499
+ # x range agree on the extra bins (mplhep adds them per histogram).
500
+ histograms_, flow_shown = show_flow_bins(histograms_)
501
+ flow = "none"
502
+ elif flow == "sum":
503
+ # Fold once, up front, so ratios, bands and limits see the same bins as the drawing.
504
+ histograms_ = fold_flow_bins(histograms_)
505
+ flow = "none"
506
+
507
+ reference_hist = histograms_[0]
508
+ outer: tuple[float, float] | None = xlim or (
509
+ float(reference_hist.edges[0]),
510
+ float(reference_hist.edges[-1]),
511
+ )
512
+ if logx and outer is not None and outer[0] <= 0:
513
+ positive = reference_hist.edges[reference_hist.edges > 0]
514
+ outer = (float(positive[0]), outer[1]) if positive.size else None
515
+ segments = None
516
+ break_widths = None
517
+ if xbreak is not None:
518
+ assert outer is not None
519
+ left_range, right_range, break_widths = break_segments(outer, xbreak, logx=logx)
520
+ segments = (left_range, right_range)
521
+
522
+ with style_context(resolved_style) as st:
523
+ want_ratio = bool(ratio)
524
+ layout = make_figure(
525
+ st, ratio=want_ratio, ax=ax, figsize=figsize, break_widths=break_widths
526
+ )
527
+ drawn = None
528
+ for axis in layout.main_axes:
529
+ drawn = draw_histograms(
530
+ histograms_,
531
+ axis,
532
+ style=st,
533
+ stack=stack,
534
+ histtype=histtype,
535
+ errorbars=errorbars,
536
+ flow=flow,
537
+ )
538
+ assert drawn is not None
539
+ color_of = dict(zip(map(id, histograms_), drawn.histogram_colors, strict=True))
540
+ has_data = any(h.is_data for h in histograms_)
541
+ add_experiment_label(layout.main, st, has_data=has_data)
542
+
543
+ per_object = any(h.stats is not None and h.stats.per_object for h in histograms_)
544
+ unit = variable.unit if variable is not None else None
545
+ y_label = ylabel or ylabel_for(
546
+ normalization=histograms_[0].normalization,
547
+ unit=unit,
548
+ widths=label_widths,
549
+ per_object=per_object,
550
+ )
551
+ x_label = xlabel or (variable.axis_label if variable is not None else None)
552
+ if x_label is None:
553
+ x_label = reference_hist.axis.label or ""
554
+ data_low = drawn.ymin_positive if logy else drawn.ymin
555
+ for index, axis in enumerate(layout.main_axes):
556
+ finish_axes(
557
+ axis,
558
+ data_range=(data_low, drawn.ymax),
559
+ xlabel=None,
560
+ ylabel=y_label if index == 0 else None,
561
+ xlim=outer,
562
+ ylim=ylim,
563
+ logx=logx,
564
+ logy=logy,
565
+ )
566
+ # mplhep labels the axis from the hist; the label goes on one axes only
567
+ axis.set_xlabel("")
568
+ if title:
569
+ layout.main.set_title(title)
570
+ legend_loc = "upper right" if (stats and st.legend is True) else None
571
+ legend_artist = add_legend(layout.legend_axes, st, loc=legend_loc)
572
+ if stats:
573
+ add_stats_box(
574
+ layout.legend_axes,
575
+ histograms_,
576
+ loc=stats if isinstance(stats, str) else "auto",
577
+ colors=drawn.colors,
578
+ legend=legend_artist,
579
+ )
580
+ if ylim is None or ylim[1] is None:
581
+ # keep legend, labels and text boxes clear of the histograms
582
+ floating = legend_location(st) == "best" and legend_loc is None
583
+ obstacles = overlay_artists(layout.legend_axes, None if floating else legend_artist)
584
+ if layout.legend_axes is not layout.main:
585
+ obstacles += overlay_artists(layout.main, None)
586
+ env_edges, env_heights = envelope(histograms_, stack=stack)
587
+ raise_ylim_above(
588
+ layout.main_axes,
589
+ obstacles,
590
+ edges=env_edges,
591
+ heights=env_heights,
592
+ logy=logy,
593
+ floating=[legend_artist] if floating and legend_artist is not None else [],
594
+ )
595
+
596
+ ratios = []
597
+ significance_spec = _significance_spec(ratio)
598
+ if layout.ratio is not None and significance_spec is not None:
599
+ kind, signal_label = significance_spec
600
+ signal_h, background_h = _significance_setup(histograms_, signal_label)
601
+ sig_result = significance(signal_h.hist, background_h.hist, kind=kind)
602
+ ratios = [sig_result]
603
+ for index, axis in enumerate(layout.ratio_axes):
604
+ draw_significance_panel(
605
+ sig_result,
606
+ axis,
607
+ kind=kind,
608
+ color=drawn.colors.get(signal_h.label),
609
+ ylim=ratio_ylim,
610
+ ylabel=ratio_label if index == 0 else "",
611
+ )
612
+ if logx:
613
+ axis.set_xscale("log")
614
+ if outer is not None:
615
+ axis.set_xlim(*outer)
616
+ elif layout.ratio is not None:
617
+ assert not isinstance(ratio, tuple) # tuples are significance specs, handled above
618
+ numerators, reference, uncertainty = _ratio_setup(
619
+ histograms_, ratio, stack=stack, uncertainty=ratio_uncertainty
620
+ )
621
+ for index, axis in enumerate(layout.ratio_axes):
622
+ ratios = draw_ratio_panel(
623
+ numerators,
624
+ reference,
625
+ axis,
626
+ style=st,
627
+ uncertainty=uncertainty,
628
+ colors=[color_of.get(id(h), h.color or "black") for h in numerators],
629
+ ylim=ratio_ylim,
630
+ ylabel=ratio_label if index == 0 else "",
631
+ )
632
+ if logx:
633
+ axis.set_xscale("log")
634
+ if outer is not None:
635
+ axis.set_xlim(*outer)
636
+ layout.xlabel_axes.set_xlabel(x_label, loc="right")
637
+ if any(flow_shown):
638
+ label_flow_bins(
639
+ layout.xlabel_axes, reference_hist.edges, under=flow_shown[0], over=flow_shown[1]
640
+ )
641
+ if segments is not None:
642
+ assert layout.main_right is not None
643
+ apply_xbreak(layout.main, layout.main_right, *segments)
644
+ if layout.ratio is not None and layout.ratio_right is not None:
645
+ apply_xbreak(layout.ratio, layout.ratio_right, *segments)
646
+
647
+ result = Plot(
648
+ fig=layout.fig,
649
+ ax=layout.main,
650
+ ratio_ax=layout.ratio,
651
+ ax_right=layout.main_right,
652
+ ratio_ax_right=layout.ratio_right,
653
+ histograms=list(histograms_),
654
+ ratios=ratios,
655
+ variable=variable,
656
+ )
657
+ if save:
658
+ result.save(save)
659
+ return result
660
+
661
+
662
+ # --------------------------------------------------------------------------------------
663
+ # 2D plots
664
+ # --------------------------------------------------------------------------------------
665
+
666
+
667
+ def plot2d(
668
+ data: Any,
669
+ x: str | Variable,
670
+ y: str | Variable,
671
+ *,
672
+ tree: str | None = None,
673
+ selection: CutLike | None = None,
674
+ weight: str | None = None,
675
+ lumi: float | str | None = None,
676
+ bins: Bins | tuple[Bins, Bins] | None = None,
677
+ normalize: NormalizeSpec = None,
678
+ logz: bool = False,
679
+ logx: bool | None = None,
680
+ logy: bool | None = None,
681
+ cmap: str | Any = "viridis",
682
+ colorbar: bool = True,
683
+ zlabel: str | None = None,
684
+ title: str | None = None,
685
+ text: str | Sequence[str] | None = None,
686
+ style: StyleLike = None,
687
+ figsize: tuple[float, float] | None = None,
688
+ ax: AxesLike = None,
689
+ nonfinite: NonFinitePolicy = "drop",
690
+ save: str | None = None,
691
+ ) -> Plot:
692
+ """Draw a two-dimensional histogram of ``y`` versus ``x`` for one sample.
693
+
694
+ ``x`` and ``y`` must have the same structure (both per-event, or both
695
+ per-object from the same collection). ``bins`` applies to both axes unless
696
+ it is a pair of binning specifications, one per axis; per-axis ranges,
697
+ labels and logarithmic scales (``logx``/``logy`` default to the variables'
698
+ ``log`` flags) are best given through :class:`~rootfig.model.Variable`
699
+ objects.
700
+ """
701
+ sample = _single_sample(data, tree=tree)
702
+ x_bins, y_bins = _split_bins(bins)
703
+ var_x = as_variable(x, bins=x_bins)
704
+ var_y = as_variable(y, bins=y_bins)
705
+ logx = var_x.log if logx is None else logx
706
+ logy = var_y.log if logy is None else logy
707
+ [histogram_] = build_histograms_2d(
708
+ [sample], var_x, var_y, selection=selection, weight=weight, lumi=lumi, nonfinite=nonfinite
709
+ )
710
+ if normalize is not None and normalize is not False:
711
+ histogram_ = _normalize_for_plot(histogram_, normalize)
712
+ resolved_style = _style_for(style, text, lumi)
713
+ with style_context(resolved_style) as st:
714
+ default_size = st.figsize or figsize
715
+ if default_size is None:
716
+ width, height = plt.rcParams["figure.figsize"]
717
+ default_size = (width * 1.15, height)
718
+ layout = make_figure(st, ratio=False, ax=ax, figsize=default_size)
719
+ fig, main_ax = layout.fig, layout.main
720
+ draw_hist2d(
721
+ histogram_,
722
+ main_ax,
723
+ logz=logz,
724
+ cmap=cmap,
725
+ colorbar=colorbar,
726
+ zlabel=zlabel or (histogram_.normalization or "Events"),
727
+ )
728
+ if logx:
729
+ main_ax.set_xscale("log")
730
+ if logy:
731
+ main_ax.set_yscale("log")
732
+ if title:
733
+ main_ax.set_title(title)
734
+ add_experiment_label(main_ax, st, has_data=sample.is_data)
735
+ result = Plot(fig=fig, ax=main_ax, histograms=[histogram_], variable=var_x)
736
+ if save:
737
+ result.save(save)
738
+ return result
739
+
740
+
741
+ # --------------------------------------------------------------------------------------
742
+ # Statistics
743
+ # --------------------------------------------------------------------------------------
744
+
745
+
746
+ @dataclass(frozen=True)
747
+ class SummaryTable:
748
+ """Summary statistics for several variables and samples.
749
+
750
+ ``str(table)`` gives an aligned text table; :meth:`get` returns a single
751
+ :class:`~rootfig.histograms.Summary`.
752
+ """
753
+
754
+ rows: tuple[tuple[str, str, Summary], ...]
755
+ """``(sample label, variable expression, summary)`` triples."""
756
+
757
+ def get(self, variable: str, sample: str | None = None) -> Summary:
758
+ """Return the summary for ``variable`` (and ``sample``, if several)."""
759
+ matches = [
760
+ s
761
+ for label, var, s in self.rows
762
+ if var == variable and (sample is None or label == sample)
763
+ ]
764
+ if not matches:
765
+ msg = f"no summary for variable {variable!r}" + (
766
+ f" and sample {sample!r}" if sample else ""
767
+ )
768
+ raise KeyError(msg)
769
+ if len(matches) > 1:
770
+ msg = f"several samples have variable {variable!r}; pass sample=..."
771
+ raise KeyError(msg)
772
+ return matches[0]
773
+
774
+ @property
775
+ def samples(self) -> list[str]:
776
+ """Distinct sample labels in order of appearance."""
777
+ return list(dict.fromkeys(label for label, _, _ in self.rows))
778
+
779
+ @property
780
+ def variables(self) -> list[str]:
781
+ """Distinct variable expressions in order of appearance."""
782
+ return list(dict.fromkeys(var for _, var, _ in self.rows))
783
+
784
+ def __str__(self) -> str:
785
+ multi = len(self.samples) > 1
786
+ entries = [(f"{label}: {var}" if multi else var, s) for label, var, s in self.rows]
787
+ return describe_table(entries)
788
+
789
+
790
+ def summarize(
791
+ data: Any,
792
+ variables: str | Variable | Sequence[str | Variable],
793
+ *,
794
+ tree: str | None = None,
795
+ selection: CutLike | None = None,
796
+ weight: str | None = None,
797
+ lumi: float | str | None = None,
798
+ label: str | Sequence[str] | None = None,
799
+ nonfinite: NonFinitePolicy = "drop",
800
+ ) -> SummaryTable:
801
+ """Compute entries, mean, standard deviation, skewness, ... for variables and samples.
802
+
803
+ Examples
804
+ --------
805
+ >>> table = rf.summarize(
806
+ ... "events.root", ["MET", "Muon_pt"], tree="events", selection="nMuon > 0"
807
+ ... ) # doctest: +SKIP
808
+ >>> print(table) # doctest: +SKIP
809
+ >>> table.get("MET").mean # doctest: +SKIP
810
+ """
811
+ samples = as_samples(data, tree=tree, labels=label)
812
+ var_list = [variables] if isinstance(variables, str | Variable) else list(variables)
813
+ rows: list[tuple[str, str, Summary]] = []
814
+ for sample in samples:
815
+ for var in var_list:
816
+ columns = load_columns(
817
+ sample, [var], selection=selection, weight=weight, lumi=lumi, nonfinite=nonfinite
818
+ )
819
+ rows.append((sample.label, as_variable(var).expression, summarize_columns(columns)))
820
+ return SummaryTable(tuple(rows))
821
+
822
+
823
+ def correlation(
824
+ data: Any,
825
+ variables: Sequence[str | Variable],
826
+ *,
827
+ tree: str | None = None,
828
+ selection: CutLike | None = None,
829
+ weight: str | None = None,
830
+ lumi: float | str | None = None,
831
+ labels: Sequence[str] | None = None,
832
+ percent: bool = False,
833
+ cmap: str | Any = "RdBu_r",
834
+ annotate: bool = True,
835
+ title: str | None = None,
836
+ style: StyleLike = None,
837
+ figsize: tuple[float, float] | None = None,
838
+ ax: AxesLike = None,
839
+ nonfinite: NonFinitePolicy = "drop",
840
+ save: str | None = None,
841
+ ) -> Plot:
842
+ """Draw the correlation matrix of several variables for one sample.
843
+
844
+ All variables must share the same structure (all per-event, or all
845
+ per-object from one collection). The matrix is available as
846
+ ``Plot.matrix``.
847
+ """
848
+ sample = _single_sample(data, tree=tree)
849
+ var_list = [as_variable(v) for v in variables]
850
+ if len(var_list) < 2:
851
+ msg = "correlation() needs at least two variables"
852
+ raise SelectionError(msg)
853
+ columns: Columns = load_columns(
854
+ sample, var_list, selection=selection, weight=weight, lumi=lumi, nonfinite=nonfinite
855
+ )
856
+ matrix: FloatArray = correlation_matrix(columns)
857
+ tick_labels = (
858
+ list(labels) if labels is not None else [v.label or v.expression for v in var_list]
859
+ )
860
+ with style_context(style) as st:
861
+ size = figsize or st.figsize
862
+ if size is None:
863
+ side = max(4.5, 0.75 * len(var_list) + 2.5)
864
+ size = (side * 1.15, side)
865
+ layout = make_figure(st, ratio=False, ax=ax, figsize=size)
866
+ fig, main_ax = layout.fig, layout.main
867
+ draw_correlation(
868
+ matrix, tick_labels, main_ax, cmap=cmap, annotate=annotate, percent=percent
869
+ )
870
+ main_ax.set_title(title if title is not None else f"{sample.label}: correlation")
871
+ result = Plot(fig=fig, ax=main_ax, matrix=matrix)
872
+ if save:
873
+ result.save(save)
874
+ return result
875
+
876
+
877
+ # --------------------------------------------------------------------------------------
878
+ # Cut flows, efficiencies and profiles
879
+ # --------------------------------------------------------------------------------------
880
+
881
+
882
+ def cutflow(
883
+ data: Any,
884
+ cuts: Sequence[CutLike],
885
+ *,
886
+ tree: str | None = None,
887
+ weight: str | None = None,
888
+ lumi: float | str | None = None,
889
+ label: str | Sequence[str] | None = None,
890
+ nonfinite: NonFinitePolicy = "drop",
891
+ ) -> CutflowTable:
892
+ """Count events and weighted yields after each successive cut, per sample.
893
+
894
+ The first row holds all events (after the sample's own selection, if any);
895
+ every further row applies one more cut. Per-object cuts pass an event when
896
+ any object passes. ``weight``, ``lumi`` and ``nonfinite`` work as in
897
+ :func:`plot`: events with a ``nan``/``inf`` weight are excluded from all
898
+ steps with a warning, or raise for ``nonfinite="error"``.
899
+
900
+ Examples
901
+ --------
902
+ >>> table = rf.cutflow(
903
+ ... [sig, bkg], ["nMuon >= 2", rf.Cut("MET > 50", label="MET"), "any(Jet_btag > 0.8)"]
904
+ ... ) # doctest: +SKIP
905
+ >>> print(table) # doctest: +SKIP
906
+ >>> table.get("Signal").efficiencies # doctest: +SKIP
907
+ """
908
+ samples = as_samples(data, tree=tree, labels=label)
909
+ return CutflowTable(
910
+ tuple(cutflow_of(s, cuts, weight=weight, lumi=lumi, nonfinite=nonfinite) for s in samples)
911
+ )
912
+
913
+
914
+ def efficiency(
915
+ data: Any,
916
+ variable: str | Variable,
917
+ *,
918
+ passed: CutLike,
919
+ tree: str | None = None,
920
+ selection: CutLike | None = None,
921
+ weight: str | None = None,
922
+ lumi: float | str | None = None,
923
+ bins: Bins | None = None,
924
+ range: RangeSpec = None,
925
+ label: str | Sequence[str] | None = None,
926
+ xlabel: str | None = None,
927
+ ylabel: str | None = None,
928
+ unit: str | None = None,
929
+ title: str | None = None,
930
+ logx: bool | None = None,
931
+ xlim: tuple[float, float] | None = None,
932
+ ylim: tuple[float | None, float | None] | None = None,
933
+ legend: bool | str | None = None,
934
+ text: str | Sequence[str] | None = None,
935
+ style: StyleLike = None,
936
+ figsize: tuple[float, float] | None = None,
937
+ ax: AxesLike = None,
938
+ z: float = 1.0,
939
+ nonfinite: NonFinitePolicy = "drop",
940
+ save: str | None = None,
941
+ ) -> Plot:
942
+ """Plot the fraction of entries passing ``passed`` as a function of ``variable``.
943
+
944
+ For every sample two histograms are filled with the same binning, all
945
+ entries satisfying ``selection`` (the denominator) and those also
946
+ satisfying ``passed`` (the numerator); the ratio is drawn as points with
947
+ Wilson score intervals (``z`` standard deviations, effective entries for
948
+ weighted samples; see :func:`rootfig.histograms.efficiency` for the
949
+ treatment of negative weights). The :class:`~rootfig.histograms.Efficiency`
950
+ objects are returned in ``Plot.efficiencies``.
951
+
952
+ Examples
953
+ --------
954
+ >>> rf.efficiency(
955
+ ... "reco.root", "TrueMuon_pt", passed="TrueMuon_matched", bins=(20, 0, 100)
956
+ ... ) # doctest: +SKIP
957
+ """
958
+ samples = as_samples(data, tree=tree, labels=label)
959
+ var = as_variable(variable, bins=bins, range=range, label=xlabel, unit=unit)
960
+ logx = var.log if logx is None else logx
961
+ totals = build_histograms(
962
+ samples, var, selection=selection, weight=weight, lumi=lumi, nonfinite=nonfinite
963
+ )
964
+ fixed = var.with_(bins=totals[0].axis) # same binning for the numerators
965
+ pass_cut = as_cut(passed)
966
+ if pass_cut is None:
967
+ msg = "efficiency() needs a 'passed' selection"
968
+ raise ValueError(msg)
969
+ base = as_cut(selection)
970
+ numerator_cut = pass_cut if base is None else base & pass_cut
971
+ passes = build_histograms(
972
+ samples, fixed, selection=numerator_cut, weight=weight, lumi=lumi, nonfinite=nonfinite
973
+ )
974
+ efficiencies = [
975
+ efficiency_of(p.hist, t.hist, z=z, label=sample.label)
976
+ for p, t, sample in zip(passes, totals, samples, strict=True)
977
+ ]
978
+ resolved_style = _style_for(style, text, lumi)
979
+ if legend is not None:
980
+ resolved_style = resolved_style.with_(legend=legend)
981
+ with style_context(resolved_style) as st:
982
+ layout = make_figure(st, ratio=False, ax=ax, figsize=figsize)
983
+ cycle = iter(color_cycle(len(samples), st))
984
+ colors = [s.color or next(cycle) for s in samples]
985
+ low, high = draw_efficiencies(efficiencies, layout.main, style=st, colors=colors)
986
+ outer = xlim or (float(fixed.bins.edges[0]), float(fixed.bins.edges[-1])) # type: ignore[union-attr]
987
+ finish_axes(
988
+ layout.main,
989
+ data_range=(low, high),
990
+ xlabel=fixed.axis_label,
991
+ ylabel=ylabel or "Efficiency",
992
+ xlim=outer,
993
+ ylim=ylim,
994
+ logx=logx,
995
+ logy=False,
996
+ headroom=1.08,
997
+ )
998
+ if title:
999
+ layout.main.set_title(title)
1000
+ add_experiment_label(layout.main, st, has_data=any(s.is_data for s in samples))
1001
+ legend_artist = add_legend(layout.main, st)
1002
+ if ylim is None or ylim[1] is None:
1003
+ edges = efficiencies[0].edges
1004
+ heights = np.nanmax(
1005
+ np.vstack([np.nan_to_num(e.upper, nan=0.0) for e in efficiencies]), axis=0
1006
+ )
1007
+ floating = legend_location(st) == "best"
1008
+ raise_ylim_above(
1009
+ [layout.main],
1010
+ overlay_artists(layout.main, None if floating else legend_artist),
1011
+ edges=edges,
1012
+ heights=heights,
1013
+ logy=False,
1014
+ floating=[legend_artist] if floating and legend_artist is not None else [],
1015
+ )
1016
+ result = Plot(
1017
+ fig=layout.fig,
1018
+ ax=layout.main,
1019
+ histograms=list(passes),
1020
+ variable=fixed,
1021
+ efficiencies=efficiencies,
1022
+ )
1023
+ if save:
1024
+ result.save(save)
1025
+ return result
1026
+
1027
+
1028
+ def profile(
1029
+ data: Any,
1030
+ x: str | Variable,
1031
+ y: str | Variable,
1032
+ *,
1033
+ statistic: ProfileStatistic = "mean",
1034
+ tree: str | None = None,
1035
+ selection: CutLike | None = None,
1036
+ weight: str | None = None,
1037
+ lumi: float | str | None = None,
1038
+ bins: Bins | None = None,
1039
+ range: RangeSpec = None,
1040
+ label: str | Sequence[str] | None = None,
1041
+ xlabel: str | None = None,
1042
+ ylabel: str | None = None,
1043
+ unit: str | None = None,
1044
+ title: str | None = None,
1045
+ logx: bool | None = None,
1046
+ logy: bool | None = None,
1047
+ xlim: tuple[float, float] | None = None,
1048
+ ylim: tuple[float | None, float | None] | None = None,
1049
+ legend: bool | str | None = None,
1050
+ text: str | Sequence[str] | None = None,
1051
+ style: StyleLike = None,
1052
+ figsize: tuple[float, float] | None = None,
1053
+ ax: AxesLike = None,
1054
+ nonfinite: NonFinitePolicy = "drop",
1055
+ save: str | None = None,
1056
+ ) -> Plot:
1057
+ """Plot the mean (or standard deviation) of ``y`` in bins of ``x``, per sample.
1058
+
1059
+ ``statistic="mean"`` draws the weighted mean with its standard error, the
1060
+ profile histogram of ROOT; ``"std"`` draws the standard deviation with its
1061
+ error, the usual resolution-versus-variable plot (make ``y`` the residual,
1062
+ e.g. ``"(reco_pt - true_pt) / true_pt"``). ``x`` and ``y`` must have the same
1063
+ structure (both per-event or both per-object of one collection). ``xlabel``
1064
+ and ``unit`` describe the x axis; ``logx``/``logy`` default to the
1065
+ variables' ``log`` flags. With negative weights a bin whose weighted
1066
+ variance is negative has no standard deviation (``nan``). The
1067
+ :class:`~rootfig.histograms.Profile` objects are returned in ``Plot.profiles``.
1068
+
1069
+ Examples
1070
+ --------
1071
+ >>> rf.profile(
1072
+ ... "reco.root",
1073
+ ... "true_pt",
1074
+ ... "(reco_pt - true_pt) / true_pt",
1075
+ ... statistic="std",
1076
+ ... bins=(20, 0, 100),
1077
+ ... unit="GeV",
1078
+ ... ) # doctest: +SKIP
1079
+ """
1080
+ samples = as_samples(data, tree=tree, labels=label)
1081
+ var_x = as_variable(x, bins=bins, range=range, label=xlabel, unit=unit)
1082
+ var_y = as_variable(y)
1083
+ logx = var_x.log if logx is None else logx
1084
+ logy = var_y.log if logy is None else logy
1085
+ columns = [
1086
+ load_columns(
1087
+ s, [var_x, var_y], selection=selection, weight=weight, lumi=lumi, nonfinite=nonfinite
1088
+ )
1089
+ for s in samples
1090
+ ]
1091
+ axis = resolve_axis(var_x, [c.arrays[0] for c in columns], name=var_x.safe_name)
1092
+ edges = np.asarray(axis.edges, dtype=float)
1093
+ profiles = [
1094
+ profile_of(
1095
+ c.arrays[0], c.arrays[1], edges, weights=c.weights, statistic=statistic, label=s.label
1096
+ )
1097
+ for c, s in zip(columns, samples, strict=True)
1098
+ ]
1099
+ resolved_style = _style_for(style, text, lumi)
1100
+ if legend is not None:
1101
+ resolved_style = resolved_style.with_(legend=legend)
1102
+ if ylabel is None:
1103
+ ylabel = var_y.axis_label if statistic == "mean" else f"Std. dev. of {var_y.axis_label}"
1104
+ with style_context(resolved_style) as st:
1105
+ layout = make_figure(st, ratio=False, ax=ax, figsize=figsize)
1106
+ cycle = iter(color_cycle(len(samples), st))
1107
+ colors = [s.color or next(cycle) for s in samples]
1108
+ low, high = draw_profiles(profiles, layout.main, style=st, colors=colors)
1109
+ finish_axes(
1110
+ layout.main,
1111
+ data_range=(low, high),
1112
+ xlabel=var_x.with_(bins=axis).axis_label,
1113
+ ylabel=ylabel,
1114
+ xlim=xlim or (float(edges[0]), float(edges[-1])),
1115
+ ylim=ylim,
1116
+ logx=logx,
1117
+ logy=logy,
1118
+ headroom=1.25,
1119
+ ymin_linear="auto",
1120
+ )
1121
+ if title:
1122
+ layout.main.set_title(title)
1123
+ add_experiment_label(layout.main, st, has_data=any(s.is_data for s in samples))
1124
+ legend_artist = add_legend(layout.main, st)
1125
+ if ylim is None or ylim[1] is None:
1126
+ heights = np.nanmax(
1127
+ np.vstack(
1128
+ [
1129
+ np.nan_to_num(pr.values + np.nan_to_num(pr.errors), nan=-np.inf)
1130
+ for pr in profiles
1131
+ ]
1132
+ ),
1133
+ axis=0,
1134
+ )
1135
+ heights = np.where(np.isfinite(heights), heights, 0.0)
1136
+ floating = legend_location(st) == "best"
1137
+ raise_ylim_above(
1138
+ [layout.main],
1139
+ overlay_artists(layout.main, None if floating else legend_artist),
1140
+ edges=edges,
1141
+ heights=heights,
1142
+ logy=logy,
1143
+ floating=[legend_artist] if floating and legend_artist is not None else [],
1144
+ )
1145
+ result = Plot(fig=layout.fig, ax=layout.main, variable=var_x, profiles=profiles)
1146
+ if save:
1147
+ result.save(save)
1148
+ return result
1149
+
1150
+
1151
+ # --------------------------------------------------------------------------------------
1152
+ # Helpers
1153
+ # --------------------------------------------------------------------------------------
1154
+
1155
+
1156
+ def _single_sample(
1157
+ data: Any,
1158
+ *,
1159
+ tree: str | None,
1160
+ entry_start: int | None = None,
1161
+ entry_stop: int | None = None,
1162
+ ) -> Sample:
1163
+ samples = as_samples(data, tree=tree, entry_start=entry_start, entry_stop=entry_stop)
1164
+ if len(samples) != 1:
1165
+ msg = f"expected a single sample, got {len(samples)}"
1166
+ raise SourceError(msg)
1167
+ return samples[0]
1168
+
1169
+
1170
+ def _style_for(
1171
+ style: StyleLike, text: str | Sequence[str] | None, lumi: float | str | None = None
1172
+ ) -> Any:
1173
+ """Resolve ``style`` and add free text lines and the luminosity used for scaling."""
1174
+ resolved = as_style(style)
1175
+ if lumi is not None and resolved.lumi is None:
1176
+ resolved = resolved.with_(lumi=lumi)
1177
+ if text is None:
1178
+ return resolved
1179
+ existing = list(resolved.text_lines)
1180
+ extra = [text] if isinstance(text, str) else list(text)
1181
+ return resolved.with_(text=[*existing, *extra])
1182
+
1183
+
1184
+ def _significance_spec(ratio: RatioSpec) -> tuple[SignificanceKind, str | None] | None:
1185
+ """``(kind, signal label)`` when ``ratio`` asks for a significance panel, else ``None``."""
1186
+ if isinstance(ratio, tuple):
1187
+ kind, label = ratio
1188
+ if kind not in SIGNIFICANCE_KINDS:
1189
+ msg = f"ratio=({kind!r}, ...) must use one of {SIGNIFICANCE_KINDS}"
1190
+ raise ValueError(msg)
1191
+ return ("s/sqrt(b)" if kind == "significance" else kind, label) # type: ignore[return-value]
1192
+ if isinstance(ratio, str) and ratio in SIGNIFICANCE_KINDS:
1193
+ return ("s/sqrt(b)" if ratio == "significance" else ratio, None) # type: ignore[return-value]
1194
+ return None
1195
+
1196
+
1197
+ def _significance_setup(
1198
+ hists: Sequence[Histogram], signal_label: str | None
1199
+ ) -> tuple[Histogram, Histogram]:
1200
+ """Pick the signal histogram and sum the other non-data ones into the background."""
1201
+ mc = [h for h in hists if not h.is_data]
1202
+ if len(mc) < 2:
1203
+ msg = "a significance panel needs at least two non-data histograms (signal and background)"
1204
+ raise ValueError(msg)
1205
+ if signal_label is None:
1206
+ signal = mc[-1]
1207
+ else:
1208
+ matches = [h for h in mc if h.label == signal_label]
1209
+ if not matches:
1210
+ msg = f"signal {signal_label!r} is not one of {[h.label for h in mc]}"
1211
+ raise ValueError(msg)
1212
+ signal = matches[0]
1213
+ others = [h for h in mc if h is not signal]
1214
+ total = others[0].hist.copy()
1215
+ for h in others[1:]:
1216
+ total = total + h.hist
1217
+ return signal, Histogram(total, label="Background", normalization=others[0].normalization)
1218
+
1219
+
1220
+ RatioSpec = bool | str | tuple[str, str]
1221
+ """What ``ratio=`` accepts: a flag, a reference label, a significance kind, or (kind, signal)."""
1222
+
1223
+
1224
+ def _wrap_hists(hists: Sequence[Histogram | Hist], labels: Sequence[str] | None) -> list[Histogram]:
1225
+ if labels is not None and len(labels) != len(hists):
1226
+ msg = f"got {len(labels)} labels for {len(hists)} histograms"
1227
+ raise ValueError(msg)
1228
+ wrapped: list[Histogram] = []
1229
+ for index, item in enumerate(hists):
1230
+ if isinstance(item, Histogram):
1231
+ wrapped.append(item if labels is None else item.with_(label=labels[index]))
1232
+ continue
1233
+ if labels is not None:
1234
+ label = labels[index]
1235
+ else:
1236
+ axis_name = item.axes[0].name if item.ndim == 1 else ""
1237
+ label = axis_name or f"hist {index + 1}"
1238
+ wrapped.append(Histogram(as_weight_storage(item), label=str(label)))
1239
+ return wrapped
1240
+
1241
+
1242
+ def _normalize_for_plot(histogram_: Histogram, spec: NormalizeSpec) -> Histogram:
1243
+ if histogram_.normalization is not None:
1244
+ warnings.warn(
1245
+ f"histogram {histogram_.label!r} is already normalised ({histogram_.normalization}); "
1246
+ "normalising again",
1247
+ RootfigWarning,
1248
+ stacklevel=3,
1249
+ )
1250
+ return normalize_histogram(histogram_, spec)
1251
+
1252
+
1253
+ def _ratio_setup(
1254
+ hists: Sequence[Histogram],
1255
+ ratio: bool | str,
1256
+ *,
1257
+ stack: bool,
1258
+ uncertainty: RatioUncertainty | None,
1259
+ ) -> tuple[list[Histogram], Histogram, RatioUncertainty]:
1260
+ data = [h for h in hists if h.is_data]
1261
+ mc = [h for h in hists if not h.is_data]
1262
+ if isinstance(ratio, str):
1263
+ matches = [h for h in hists if h.label == ratio]
1264
+ if not matches:
1265
+ msg = f"ratio reference {ratio!r} is not one of {[h.label for h in hists]}"
1266
+ raise ValueError(msg)
1267
+ reference = matches[0]
1268
+ numerators = [h for h in hists if h is not reference]
1269
+ default_uncertainty: RatioUncertainty = (
1270
+ "numerator" if data and reference in mc else "propagate"
1271
+ )
1272
+ return numerators, reference, uncertainty or default_uncertainty
1273
+ if stack and mc:
1274
+ total = mc[0].hist.copy()
1275
+ for h in mc[1:]:
1276
+ total = total + h.hist
1277
+ reference = Histogram(total, label="Total", normalization=mc[0].normalization)
1278
+ numerators = data if data else []
1279
+ if not numerators:
1280
+ msg = "ratio=True with stack=True needs an observed data sample (observed=...)"
1281
+ raise ValueError(msg)
1282
+ return numerators, reference, uncertainty or "numerator"
1283
+ if data and mc:
1284
+ return data, mc[0], uncertainty or "numerator"
1285
+ if len(hists) < 2:
1286
+ msg = "a ratio panel needs at least two histograms"
1287
+ raise ValueError(msg)
1288
+ return list(hists[1:]), hists[0], uncertainty or "propagate"
1289
+
1290
+
1291
+ def _split_bins(bins: Bins | tuple[Bins, Bins] | None) -> tuple[Any, Any]:
1292
+ """Interpret ``bins`` for two axes: a pair of specifications, or one spec for both."""
1293
+ if bins is None:
1294
+ return None, None
1295
+ if isinstance(bins, tuple) and len(bins) == 2:
1296
+ return bins[0], bins[1]
1297
+ return bins, bins