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/__init__.py +97 -0
- rootfig/_typing.py +21 -0
- rootfig/api.py +1297 -0
- rootfig/errors.py +73 -0
- rootfig/expressions/__init__.py +34 -0
- rootfig/expressions/functions.py +151 -0
- rootfig/expressions/parser.py +462 -0
- rootfig/histograms/__init__.py +67 -0
- rootfig/histograms/build.py +197 -0
- rootfig/histograms/cutflow.py +195 -0
- rootfig/histograms/efficiency.py +214 -0
- rootfig/histograms/normalize.py +124 -0
- rootfig/histograms/pipeline.py +186 -0
- rootfig/histograms/ratio.py +161 -0
- rootfig/histograms/stats.py +183 -0
- rootfig/io/__init__.py +12 -0
- rootfig/io/sources.py +487 -0
- rootfig/model/__init__.py +27 -0
- rootfig/model/binning.py +237 -0
- rootfig/model/cuts.py +85 -0
- rootfig/model/samples.py +284 -0
- rootfig/model/style.py +144 -0
- rootfig/model/units.py +107 -0
- rootfig/model/variables.py +95 -0
- rootfig/plotting/__init__.py +78 -0
- rootfig/plotting/annotations.py +165 -0
- rootfig/plotting/correlation.py +93 -0
- rootfig/plotting/figure.py +458 -0
- rootfig/plotting/hist1d.py +421 -0
- rootfig/plotting/hist2d.py +59 -0
- rootfig/plotting/points.py +78 -0
- rootfig/plotting/ratio.py +163 -0
- rootfig/plotting/result.py +131 -0
- rootfig/plotting/style.py +308 -0
- rootfig/py.typed +0 -0
- rootfig/selection/__init__.py +23 -0
- rootfig/selection/columns.py +479 -0
- rootfig-0.1.0.dist-info/METADATA +202 -0
- rootfig-0.1.0.dist-info/RECORD +41 -0
- rootfig-0.1.0.dist-info/WHEEL +4 -0
- rootfig-0.1.0.dist-info/licenses/LICENSE +21 -0
rootfig/model/binning.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Binning specifications and their resolution into ``hist`` axes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Literal, TypeAlias
|
|
8
|
+
|
|
9
|
+
import hist
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from rootfig.errors import BinningError
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from rootfig.model.variables import Variable
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ROBUST_THRESHOLD",
|
|
19
|
+
"Axis",
|
|
20
|
+
"Bins",
|
|
21
|
+
"RangeSpec",
|
|
22
|
+
"auto_range",
|
|
23
|
+
"log_bins",
|
|
24
|
+
"resolve_axis",
|
|
25
|
+
"validate_bins",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
Axis: TypeAlias = hist.axis.Regular | hist.axis.Variable
|
|
29
|
+
"""The histogram axis types rootfig produces."""
|
|
30
|
+
|
|
31
|
+
Bins: TypeAlias = int | tuple[int, float, float] | Sequence[float] | np.ndarray | Axis
|
|
32
|
+
"""Accepted binning specifications.
|
|
33
|
+
|
|
34
|
+
* ``int`` - number of equal-width bins; the range is taken from ``range`` or
|
|
35
|
+
inferred from the data.
|
|
36
|
+
* ``(n, low, high)`` - ``n`` equal-width bins between ``low`` and ``high``.
|
|
37
|
+
* a sequence of edges - variable-width bins.
|
|
38
|
+
* a :class:`hist.axis.Regular` or :class:`hist.axis.Variable` - used as is.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
RangeSpec: TypeAlias = tuple[float, float] | Literal["auto", "robust"] | None
|
|
42
|
+
"""How to choose the histogram range when ``bins`` is an integer.
|
|
43
|
+
|
|
44
|
+
* ``(low, high)`` - explicit.
|
|
45
|
+
* ``"auto"`` (default) - the finite minimum and maximum over all samples.
|
|
46
|
+
* ``"robust"`` - like ``"auto"`` but ignoring outliers far from the bulk of
|
|
47
|
+
the data (see :func:`auto_range`), padded by 5 percent; useful when
|
|
48
|
+
sentinel values such as ``-999`` would otherwise dominate the range.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# --------------------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def log_bins(n: int, low: float, high: float) -> np.ndarray:
|
|
56
|
+
"""Return ``n + 1`` logarithmically spaced bin edges between ``low`` and ``high``.
|
|
57
|
+
|
|
58
|
+
Examples
|
|
59
|
+
--------
|
|
60
|
+
>>> log_bins(3, 1, 1000).tolist()
|
|
61
|
+
[1.0, 10.0, 100.0, 1000.0]
|
|
62
|
+
"""
|
|
63
|
+
if n < 1:
|
|
64
|
+
msg = f"number of bins must be positive, got {n}"
|
|
65
|
+
raise BinningError(msg)
|
|
66
|
+
if not (0 < low < high):
|
|
67
|
+
msg = f"log bins need 0 < low < high, got low={low}, high={high}"
|
|
68
|
+
raise BinningError(msg)
|
|
69
|
+
return np.geomspace(low, high, n + 1)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def validate_bins(bins: Bins, range_: RangeSpec) -> None:
|
|
73
|
+
"""Raise :class:`BinningError` if ``bins``/``range_`` are not a valid specification."""
|
|
74
|
+
if isinstance(bins, hist.axis.Regular | hist.axis.Variable):
|
|
75
|
+
return
|
|
76
|
+
if isinstance(bins, bool):
|
|
77
|
+
msg = "bins must be an int, (n, low, high), edges, or a hist axis, got a bool"
|
|
78
|
+
raise BinningError(msg)
|
|
79
|
+
if isinstance(bins, int):
|
|
80
|
+
if bins < 1:
|
|
81
|
+
msg = f"number of bins must be positive, got {bins}"
|
|
82
|
+
raise BinningError(msg)
|
|
83
|
+
if isinstance(range_, tuple):
|
|
84
|
+
_validate_range(range_)
|
|
85
|
+
elif range_ not in ("auto", "robust", None):
|
|
86
|
+
msg = f"range must be (low, high), 'auto', or 'robust', got {range_!r}"
|
|
87
|
+
raise BinningError(msg)
|
|
88
|
+
return
|
|
89
|
+
if isinstance(bins, tuple) and len(bins) == 3 and isinstance(bins[0], int):
|
|
90
|
+
if bins[0] < 1:
|
|
91
|
+
msg = f"number of bins must be positive, got {bins[0]}"
|
|
92
|
+
raise BinningError(msg)
|
|
93
|
+
_validate_range((bins[1], bins[2]))
|
|
94
|
+
return
|
|
95
|
+
edges = _edges_from(bins)
|
|
96
|
+
if len(edges) < 2:
|
|
97
|
+
msg = "bin edges must contain at least two values"
|
|
98
|
+
raise BinningError(msg)
|
|
99
|
+
if not np.all(np.diff(edges) > 0):
|
|
100
|
+
msg = "bin edges must be strictly increasing"
|
|
101
|
+
raise BinningError(msg)
|
|
102
|
+
if not np.all(np.isfinite(edges)):
|
|
103
|
+
msg = "bin edges must be finite"
|
|
104
|
+
raise BinningError(msg)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _validate_range(range_: tuple[float, float]) -> None:
|
|
108
|
+
if len(range_) != 2: # runtime guard for untyped callers
|
|
109
|
+
msg = f"range must have two values (low, high), got {range_!r}" # type: ignore[unreachable]
|
|
110
|
+
raise BinningError(msg)
|
|
111
|
+
low, high = range_
|
|
112
|
+
if not (math.isfinite(low) and math.isfinite(high)):
|
|
113
|
+
msg = f"range must be finite, got {range_!r}"
|
|
114
|
+
raise BinningError(msg)
|
|
115
|
+
if not low < high:
|
|
116
|
+
msg = f"range must satisfy low < high, got {range_!r}"
|
|
117
|
+
raise BinningError(msg)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _edges_from(bins: Any) -> np.ndarray:
|
|
121
|
+
try:
|
|
122
|
+
edges = np.asarray(bins, dtype=float)
|
|
123
|
+
except (TypeError, ValueError) as exc:
|
|
124
|
+
msg = f"cannot interpret {bins!r} as bin edges"
|
|
125
|
+
raise BinningError(msg) from exc
|
|
126
|
+
if edges.ndim != 1:
|
|
127
|
+
msg = f"bin edges must be one-dimensional, got shape {edges.shape}"
|
|
128
|
+
raise BinningError(msg)
|
|
129
|
+
return edges
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
ROBUST_THRESHOLD = 10.0
|
|
133
|
+
"""Modified z-score (in units of the median absolute deviation) beyond which values are
|
|
134
|
+
ignored by ``range="robust"``. Generous enough to keep physical tails, strict enough to
|
|
135
|
+
drop sentinel values such as ``-999``."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def auto_range(
|
|
139
|
+
arrays: Sequence[np.ndarray],
|
|
140
|
+
*,
|
|
141
|
+
mode: Literal["auto", "robust"] = "auto",
|
|
142
|
+
) -> tuple[float, float]:
|
|
143
|
+
"""Choose a histogram range covering the finite values of all ``arrays``.
|
|
144
|
+
|
|
145
|
+
``mode="auto"`` uses the overall minimum and maximum, with the upper edge
|
|
146
|
+
nudged up so the maximum value lands inside the last bin. ``mode="robust"``
|
|
147
|
+
first discards outliers whose modified z-score (``0.6745 * |x - median| /
|
|
148
|
+
MAD``) exceeds :data:`ROBUST_THRESHOLD`, which removes sentinel values such
|
|
149
|
+
as ``-999`` without trimming ordinary tails, and pads the result by 5
|
|
150
|
+
percent of its span. A degenerate range (all values equal) is widened
|
|
151
|
+
symmetrically; if there are no finite values at all, ``(0.0, 1.0)`` is
|
|
152
|
+
returned.
|
|
153
|
+
"""
|
|
154
|
+
values = [np.asarray(a, dtype=float).ravel() for a in arrays if len(a)]
|
|
155
|
+
finite = [v[np.isfinite(v)] for v in values]
|
|
156
|
+
finite = [v for v in finite if v.size]
|
|
157
|
+
if not finite:
|
|
158
|
+
return (0.0, 1.0)
|
|
159
|
+
combined = np.concatenate(finite)
|
|
160
|
+
if mode == "robust":
|
|
161
|
+
kept = _reject_outliers(combined)
|
|
162
|
+
low, high = float(kept.min()), float(kept.max())
|
|
163
|
+
pad = 0.05 * (high - low)
|
|
164
|
+
low, high = low - pad, high + pad
|
|
165
|
+
else:
|
|
166
|
+
low, high = float(combined.min()), float(combined.max())
|
|
167
|
+
span = high - low
|
|
168
|
+
high = high + (span * 1e-3 if span > 0 else 0.0)
|
|
169
|
+
if not high > low:
|
|
170
|
+
width = abs(low) * 0.1 if low != 0 else 0.5
|
|
171
|
+
low, high = low - width, high + width
|
|
172
|
+
return (low, high)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _reject_outliers(values: np.ndarray, threshold: float = ROBUST_THRESHOLD) -> np.ndarray:
|
|
176
|
+
median = np.median(values)
|
|
177
|
+
deviation = np.abs(values - median)
|
|
178
|
+
mad = np.median(deviation)
|
|
179
|
+
if mad <= 0:
|
|
180
|
+
# More than half the values are identical (e.g. all zero): fall back to the
|
|
181
|
+
# mean absolute deviation, and if that is zero too, keep everything.
|
|
182
|
+
mad = float(deviation.mean())
|
|
183
|
+
if mad <= 0:
|
|
184
|
+
return values
|
|
185
|
+
score = 0.6745 * deviation / mad
|
|
186
|
+
kept = values[score <= threshold]
|
|
187
|
+
return kept if kept.size else values
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def resolve_axis(
|
|
191
|
+
variable: Variable,
|
|
192
|
+
data: Sequence[np.ndarray] = (),
|
|
193
|
+
*,
|
|
194
|
+
name: str = "x",
|
|
195
|
+
) -> Axis:
|
|
196
|
+
"""Turn a :class:`Variable`'s binning into a concrete ``hist`` axis.
|
|
197
|
+
|
|
198
|
+
Parameters
|
|
199
|
+
----------
|
|
200
|
+
variable
|
|
201
|
+
The variable whose ``bins`` and ``range`` are interpreted.
|
|
202
|
+
data
|
|
203
|
+
Flat arrays of values (one per sample) used to infer the range when
|
|
204
|
+
``bins`` is an integer and ``range`` is ``"auto"``/``"robust"``.
|
|
205
|
+
name
|
|
206
|
+
Axis name stored in the histogram.
|
|
207
|
+
|
|
208
|
+
Raises
|
|
209
|
+
------
|
|
210
|
+
BinningError
|
|
211
|
+
If the range must be inferred but no data was given.
|
|
212
|
+
"""
|
|
213
|
+
bins = variable.bins
|
|
214
|
+
label = variable.axis_label
|
|
215
|
+
if isinstance(bins, hist.axis.Regular | hist.axis.Variable):
|
|
216
|
+
axis = bins
|
|
217
|
+
if not axis.label:
|
|
218
|
+
axis.label = label
|
|
219
|
+
return axis
|
|
220
|
+
if isinstance(bins, int) and not isinstance(bins, bool):
|
|
221
|
+
if isinstance(variable.range, tuple):
|
|
222
|
+
low, high = variable.range
|
|
223
|
+
else:
|
|
224
|
+
if not data:
|
|
225
|
+
msg = (
|
|
226
|
+
f"cannot infer a range for {variable.expression!r} without data; "
|
|
227
|
+
"pass range=(low, high) or bins=(n, low, high)"
|
|
228
|
+
)
|
|
229
|
+
raise BinningError(msg)
|
|
230
|
+
mode: Literal["auto", "robust"] = "robust" if variable.range == "robust" else "auto"
|
|
231
|
+
low, high = auto_range(data, mode=mode)
|
|
232
|
+
return hist.axis.Regular(bins, low, high, name=name, label=label)
|
|
233
|
+
if isinstance(bins, tuple) and len(bins) == 3 and isinstance(bins[0], int):
|
|
234
|
+
n, low, high = bins
|
|
235
|
+
return hist.axis.Regular(int(n), float(low), float(high), name=name, label=label)
|
|
236
|
+
edges = _edges_from(bins)
|
|
237
|
+
return hist.axis.Variable(edges, name=name, label=label)
|
rootfig/model/cuts.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Composable selection expressions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TypeAlias
|
|
7
|
+
|
|
8
|
+
from rootfig.expressions import Expression, parse
|
|
9
|
+
|
|
10
|
+
__all__ = ["Cut", "CutLike", "as_cut"]
|
|
11
|
+
|
|
12
|
+
# --------------------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Cut:
|
|
17
|
+
"""A selection expression that composes with ``&``, ``|`` and ``~``.
|
|
18
|
+
|
|
19
|
+
Plain strings are accepted wherever a ``Cut`` is; the class exists so
|
|
20
|
+
selections can be built up and named in analysis scripts::
|
|
21
|
+
|
|
22
|
+
base = rf.Cut("nMuon >= 2", label="2 muons")
|
|
23
|
+
signal_region = base & "abs(Muon_eta) < 2.4" & ~rf.Cut("has_bjet")
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
expression
|
|
28
|
+
A rootfig expression producing booleans (see :mod:`rootfig.expressions`).
|
|
29
|
+
label
|
|
30
|
+
Optional short description for legends or logging.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
expression: str
|
|
34
|
+
label: str | None = None
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
parse(self.expression) # validate early
|
|
38
|
+
|
|
39
|
+
def __str__(self) -> str:
|
|
40
|
+
return self.expression
|
|
41
|
+
|
|
42
|
+
def parsed(self) -> Expression:
|
|
43
|
+
"""Return the parsed :class:`~rootfig.expressions.Expression`."""
|
|
44
|
+
return parse(self.expression)
|
|
45
|
+
|
|
46
|
+
def _combine(self, other: CutLike, op: str) -> Cut:
|
|
47
|
+
rhs = Cut(other) if isinstance(other, str) else other
|
|
48
|
+
label = None
|
|
49
|
+
if self.label and rhs.label:
|
|
50
|
+
label = f"{self.label} {op} {rhs.label}"
|
|
51
|
+
return Cut(f"({self.expression}) {op} ({rhs.expression})", label=label)
|
|
52
|
+
|
|
53
|
+
def __and__(self, other: CutLike) -> Cut:
|
|
54
|
+
return self._combine(other, "&")
|
|
55
|
+
|
|
56
|
+
def __rand__(self, other: CutLike) -> Cut:
|
|
57
|
+
return (
|
|
58
|
+
Cut(other)._combine(self, "&") if isinstance(other, str) else other._combine(self, "&")
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def __or__(self, other: CutLike) -> Cut:
|
|
62
|
+
return self._combine(other, "|")
|
|
63
|
+
|
|
64
|
+
def __ror__(self, other: CutLike) -> Cut:
|
|
65
|
+
return (
|
|
66
|
+
Cut(other)._combine(self, "|") if isinstance(other, str) else other._combine(self, "|")
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def __invert__(self) -> Cut:
|
|
70
|
+
label = f"not {self.label}" if self.label else None
|
|
71
|
+
return Cut(f"~({self.expression})", label=label)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
CutLike: TypeAlias = str | Cut
|
|
75
|
+
"""Anything accepted as a selection."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def as_cut(selection: CutLike | None) -> Cut | None:
|
|
79
|
+
"""Coerce a string or ``Cut`` to a ``Cut`` (``None`` passes through)."""
|
|
80
|
+
if selection is None or isinstance(selection, Cut):
|
|
81
|
+
return selection
|
|
82
|
+
if isinstance(selection, str):
|
|
83
|
+
return Cut(selection)
|
|
84
|
+
msg = f"selection must be a string or Cut, got {type(selection).__name__}" # type: ignore[unreachable]
|
|
85
|
+
raise TypeError(msg)
|
rootfig/model/samples.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""The :class:`Sample` description of a dataset."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import math
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass, fields
|
|
9
|
+
from os import PathLike
|
|
10
|
+
from typing import Any, Literal, TypeAlias
|
|
11
|
+
|
|
12
|
+
import awkward as ak
|
|
13
|
+
|
|
14
|
+
from rootfig.errors import LuminosityError, SourceError
|
|
15
|
+
from rootfig.io import FileSource, Source, as_source
|
|
16
|
+
from rootfig.model.cuts import Cut, CutLike, as_cut
|
|
17
|
+
from rootfig.model.units import cross_section_pb, luminosity_fb
|
|
18
|
+
|
|
19
|
+
__all__ = ["HistType", "Sample", "SampleLike", "as_samples"]
|
|
20
|
+
|
|
21
|
+
HistType: TypeAlias = Literal["step", "fill", "errorbar", "band"]
|
|
22
|
+
"""How a histogram is drawn: outline, filled area, points with error bars, or a band."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, init=False)
|
|
26
|
+
class Sample:
|
|
27
|
+
"""A dataset: where it comes from, how it is labelled, and how it is drawn.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
data
|
|
32
|
+
File path(s), glob pattern(s), ``"path:tree"`` strings, a mapping of
|
|
33
|
+
arrays, an Awkward record array, or an existing
|
|
34
|
+
:class:`~rootfig.io.Source`.
|
|
35
|
+
tree
|
|
36
|
+
Tree name for file inputs. Auto-detected when the file holds exactly one
|
|
37
|
+
``TTree``/``RNTuple``.
|
|
38
|
+
label
|
|
39
|
+
Legend label. Defaults to the file stem.
|
|
40
|
+
selection
|
|
41
|
+
A selection applied to this sample only, combined with the selection
|
|
42
|
+
given to :func:`rootfig.plot` with ``&``.
|
|
43
|
+
weight
|
|
44
|
+
Expression for per-event (or per-object) weights, e.g. ``"mc_weight"``
|
|
45
|
+
or ``"mc_weight * pileup_sf"``. Combined multiplicatively with the
|
|
46
|
+
weight given to :func:`rootfig.plot`.
|
|
47
|
+
is_data
|
|
48
|
+
Mark as observed data: drawn as black points with error bars, excluded
|
|
49
|
+
from stacks, and used as the numerator of data/MC ratios.
|
|
50
|
+
color
|
|
51
|
+
Matplotlib colour. Defaults to the style's colour cycle.
|
|
52
|
+
histtype
|
|
53
|
+
How to draw this sample; see :data:`HistType`. Defaults depend on the
|
|
54
|
+
plot type.
|
|
55
|
+
scale
|
|
56
|
+
Constant multiplied into every weight.
|
|
57
|
+
xsec
|
|
58
|
+
Cross section of the process, in pb or as a string with a unit
|
|
59
|
+
(``"1.2 fb"``). With a luminosity given to :func:`rootfig.plot`
|
|
60
|
+
(``lumi=``), every weight is multiplied by ``xsec * lumi / ngen`` so
|
|
61
|
+
that the histogram is the expected yield.
|
|
62
|
+
ngen
|
|
63
|
+
Number of generated events the cross section refers to: a number, the
|
|
64
|
+
name of an object in the file(s) holding it (a sum-of-weights histogram
|
|
65
|
+
or a ``TParameter`` such as FCCAnalyses' ``"eventsProcessed"``), or
|
|
66
|
+
``None`` to use the number of entries in the source.
|
|
67
|
+
entry_start, entry_stop
|
|
68
|
+
Read only this range of entries (counted across files).
|
|
69
|
+
|
|
70
|
+
Examples
|
|
71
|
+
--------
|
|
72
|
+
>>> import rootfig as rf
|
|
73
|
+
>>> sig = rf.Sample("sig.root", tree="events", label="Signal", weight="mc_w")
|
|
74
|
+
>>> bkg = rf.Sample(["bkg_*.root"], tree="events", label="Background", color="gray")
|
|
75
|
+
>>> data = rf.Sample("data.root", tree="events", label="Data", is_data=True)
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
source: Source
|
|
79
|
+
label: str
|
|
80
|
+
selection: Cut | None = None
|
|
81
|
+
weight: str | None = None
|
|
82
|
+
is_data: bool = False
|
|
83
|
+
color: str | None = None
|
|
84
|
+
histtype: HistType | None = None
|
|
85
|
+
scale: float = 1.0
|
|
86
|
+
xsec: float | str | None = None
|
|
87
|
+
ngen: float | str | None = None
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
data: Any,
|
|
92
|
+
*,
|
|
93
|
+
tree: str | None = None,
|
|
94
|
+
label: str | None = None,
|
|
95
|
+
selection: CutLike | None = None,
|
|
96
|
+
weight: str | None = None,
|
|
97
|
+
is_data: bool = False,
|
|
98
|
+
color: str | None = None,
|
|
99
|
+
histtype: HistType | None = None,
|
|
100
|
+
scale: float = 1.0,
|
|
101
|
+
xsec: float | str | None = None,
|
|
102
|
+
ngen: float | str | None = None,
|
|
103
|
+
entry_start: int | None = None,
|
|
104
|
+
entry_stop: int | None = None,
|
|
105
|
+
) -> None:
|
|
106
|
+
source = as_source(data, tree=tree, entry_start=entry_start, entry_stop=entry_stop)
|
|
107
|
+
if label is None:
|
|
108
|
+
label = getattr(source, "default_label", None) or source.describe()
|
|
109
|
+
if weight is not None and not weight.strip():
|
|
110
|
+
weight = None
|
|
111
|
+
object.__setattr__(self, "source", source)
|
|
112
|
+
object.__setattr__(self, "label", label)
|
|
113
|
+
object.__setattr__(self, "selection", as_cut(selection))
|
|
114
|
+
object.__setattr__(self, "weight", weight)
|
|
115
|
+
object.__setattr__(self, "is_data", is_data)
|
|
116
|
+
object.__setattr__(self, "color", color)
|
|
117
|
+
object.__setattr__(self, "histtype", histtype)
|
|
118
|
+
if not math.isfinite(scale):
|
|
119
|
+
msg = f"sample {label!r}: scale must be a finite number, got {scale!r}"
|
|
120
|
+
raise ValueError(msg)
|
|
121
|
+
object.__setattr__(self, "scale", float(scale))
|
|
122
|
+
if xsec is not None:
|
|
123
|
+
cross_section_pb(xsec) # validate early
|
|
124
|
+
if isinstance(ngen, int | float) and not math.isfinite(ngen):
|
|
125
|
+
msg = f"sample {label!r}: ngen must be a finite number, got {ngen!r}"
|
|
126
|
+
raise LuminosityError(msg)
|
|
127
|
+
object.__setattr__(self, "xsec", xsec)
|
|
128
|
+
object.__setattr__(self, "ngen", ngen)
|
|
129
|
+
|
|
130
|
+
def __repr__(self) -> str:
|
|
131
|
+
parts = [repr(self.source.describe()), f"label={self.label!r}"]
|
|
132
|
+
if self.selection is not None:
|
|
133
|
+
parts.append(f"selection={self.selection.expression!r}")
|
|
134
|
+
if self.weight is not None:
|
|
135
|
+
parts.append(f"weight={self.weight!r}")
|
|
136
|
+
if self.is_data:
|
|
137
|
+
parts.append("is_data=True")
|
|
138
|
+
if self.xsec is not None:
|
|
139
|
+
parts.append(f"xsec={self.xsec!r}")
|
|
140
|
+
return f"Sample({', '.join(parts)})"
|
|
141
|
+
|
|
142
|
+
def generated_events(self) -> float:
|
|
143
|
+
"""Return the number of generated events ``ngen`` refers to (see the class docstring)."""
|
|
144
|
+
if isinstance(self.ngen, int | float):
|
|
145
|
+
return float(self.ngen)
|
|
146
|
+
if isinstance(self.ngen, str):
|
|
147
|
+
reader = getattr(self.source, "read_scalar", None)
|
|
148
|
+
if not callable(reader):
|
|
149
|
+
msg = f"sample {self.label!r}: ngen={self.ngen!r} needs a file source"
|
|
150
|
+
raise LuminosityError(msg)
|
|
151
|
+
return float(reader(self.ngen))
|
|
152
|
+
counter = getattr(self.source, "num_entries", None)
|
|
153
|
+
if callable(counter):
|
|
154
|
+
return float(counter())
|
|
155
|
+
first = self.source.branches()[:1]
|
|
156
|
+
return float(len(next(iter(self.source.arrays(first).values()))))
|
|
157
|
+
|
|
158
|
+
def lumi_scale(self, lumi: float | str | None) -> float:
|
|
159
|
+
"""Factor turning weights into expected yields at ``lumi``; 1 without a cross section.
|
|
160
|
+
|
|
161
|
+
``xsec`` (pb) times ``lumi`` (fb^-1, or a string with a unit) divided by
|
|
162
|
+
:meth:`generated_events`.
|
|
163
|
+
"""
|
|
164
|
+
if self.xsec is None:
|
|
165
|
+
return 1.0
|
|
166
|
+
if lumi is None:
|
|
167
|
+
msg = (
|
|
168
|
+
f"sample {self.label!r} has a cross section ({self.xsec!r}) but no luminosity "
|
|
169
|
+
"was given; pass lumi=... (in fb^-1, or a string such as '10.8 ab^-1')"
|
|
170
|
+
)
|
|
171
|
+
raise LuminosityError(msg)
|
|
172
|
+
ngen = self.generated_events()
|
|
173
|
+
if not (math.isfinite(ngen) and ngen > 0):
|
|
174
|
+
msg = f"sample {self.label!r}: the number of generated events is {ngen}"
|
|
175
|
+
raise LuminosityError(msg)
|
|
176
|
+
factor = cross_section_pb(self.xsec) * 1e3 * luminosity_fb(lumi) / ngen
|
|
177
|
+
if not math.isfinite(factor):
|
|
178
|
+
msg = (
|
|
179
|
+
f"sample {self.label!r}: xsec * lumi / ngen = {factor} is not finite "
|
|
180
|
+
f"(xsec={self.xsec!r}, lumi={lumi!r}, ngen={ngen})"
|
|
181
|
+
)
|
|
182
|
+
raise LuminosityError(msg)
|
|
183
|
+
return factor
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def files(self) -> tuple[str, ...]:
|
|
187
|
+
"""The resolved input files, or an empty tuple for in-memory sources."""
|
|
188
|
+
return self.source.files if isinstance(self.source, FileSource) else ()
|
|
189
|
+
|
|
190
|
+
def with_(self, **changes: Any) -> Sample:
|
|
191
|
+
"""Return a copy with the given fields replaced, e.g. ``sample.with_(label="B")``."""
|
|
192
|
+
known = {f.name for f in fields(self)}
|
|
193
|
+
unknown = set(changes) - known
|
|
194
|
+
if unknown:
|
|
195
|
+
msg = f"unknown Sample field(s) {sorted(unknown)}; valid fields: {sorted(known)}"
|
|
196
|
+
raise TypeError(msg)
|
|
197
|
+
if "selection" in changes:
|
|
198
|
+
changes["selection"] = as_cut(changes["selection"])
|
|
199
|
+
clone = copy.copy(self)
|
|
200
|
+
for name, value in changes.items():
|
|
201
|
+
object.__setattr__(clone, name, value)
|
|
202
|
+
return clone
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
SampleLike: TypeAlias = Sample | str | Sequence[Any] | Any
|
|
206
|
+
"""A :class:`Sample`, file specification(s), or in-memory arrays."""
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def as_samples(
|
|
210
|
+
data: Any,
|
|
211
|
+
*,
|
|
212
|
+
tree: str | None = None,
|
|
213
|
+
labels: str | Sequence[str] | None = None,
|
|
214
|
+
entry_start: int | None = None,
|
|
215
|
+
entry_stop: int | None = None,
|
|
216
|
+
) -> list[Sample]:
|
|
217
|
+
"""Normalise the ``data`` argument of :func:`rootfig.plot` into a list of samples.
|
|
218
|
+
|
|
219
|
+
* A :class:`Sample` gives one sample.
|
|
220
|
+
* A single path/glob/mapping/array gives one sample.
|
|
221
|
+
* A list gives one sample per element. Elements may mix ``Sample`` objects
|
|
222
|
+
with raw file specifications; each raw element becomes its own sample,
|
|
223
|
+
so ``["sig.root", "bkg.root"]`` produces two samples. To combine several
|
|
224
|
+
files into *one* sample wrap them in a ``Sample`` or pass a glob.
|
|
225
|
+
* A mapping from label to file specification, in-memory arrays or
|
|
226
|
+
``Sample`` gives one labelled sample per entry (a ``Sample`` keeps its
|
|
227
|
+
selection, weight and cross section and only takes the label). A mapping
|
|
228
|
+
whose values are columns (arrays or lists of numbers) is one in-memory
|
|
229
|
+
sample instead.
|
|
230
|
+
|
|
231
|
+
Raises
|
|
232
|
+
------
|
|
233
|
+
SourceError
|
|
234
|
+
If ``labels`` does not match the number of samples.
|
|
235
|
+
"""
|
|
236
|
+
if isinstance(data, Sample):
|
|
237
|
+
samples = [data]
|
|
238
|
+
elif isinstance(data, Mapping) and _looks_like_label_map(data):
|
|
239
|
+
samples = [
|
|
240
|
+
value.with_(label=key)
|
|
241
|
+
if isinstance(value, Sample)
|
|
242
|
+
else Sample(value, tree=tree, label=key, entry_start=entry_start, entry_stop=entry_stop)
|
|
243
|
+
for key, value in data.items()
|
|
244
|
+
]
|
|
245
|
+
elif isinstance(data, list | tuple):
|
|
246
|
+
if not data:
|
|
247
|
+
msg = "no samples given"
|
|
248
|
+
raise SourceError(msg)
|
|
249
|
+
samples = [
|
|
250
|
+
item
|
|
251
|
+
if isinstance(item, Sample)
|
|
252
|
+
else Sample(item, tree=tree, entry_start=entry_start, entry_stop=entry_stop)
|
|
253
|
+
for item in data
|
|
254
|
+
]
|
|
255
|
+
else:
|
|
256
|
+
samples = [Sample(data, tree=tree, entry_start=entry_start, entry_stop=entry_stop)]
|
|
257
|
+
|
|
258
|
+
if labels is not None:
|
|
259
|
+
label_list = [labels] if isinstance(labels, str) else list(labels)
|
|
260
|
+
if len(label_list) != len(samples):
|
|
261
|
+
msg = f"got {len(label_list)} labels for {len(samples)} samples"
|
|
262
|
+
raise SourceError(msg)
|
|
263
|
+
samples = [s.with_(label=lab) for s, lab in zip(samples, label_list, strict=True)]
|
|
264
|
+
return samples
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _looks_like_label_map(data: Mapping[Any, Any]) -> bool:
|
|
268
|
+
"""Distinguish ``{"Signal": "sig.root"}`` from a mapping of column arrays.
|
|
269
|
+
|
|
270
|
+
Values that describe a dataset (paths, lists of paths, ``Sample`` objects,
|
|
271
|
+
mappings of arrays, Awkward record arrays) make a label map; anything else
|
|
272
|
+
(NumPy arrays, lists of numbers, flat Awkward arrays) is a column.
|
|
273
|
+
"""
|
|
274
|
+
return bool(data) and all(isinstance(k, str) and _is_dataset_spec(v) for k, v in data.items())
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _is_dataset_spec(value: Any) -> bool:
|
|
278
|
+
if isinstance(value, str | PathLike | Sample | Mapping):
|
|
279
|
+
return True
|
|
280
|
+
if isinstance(value, ak.Array):
|
|
281
|
+
return bool(value.fields)
|
|
282
|
+
if isinstance(value, list | tuple):
|
|
283
|
+
return bool(value) and all(isinstance(item, str | PathLike) for item in value)
|
|
284
|
+
return False
|