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.
@@ -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)
@@ -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