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/io/sources.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""Data sources: where branch arrays come from.
|
|
2
|
+
|
|
3
|
+
Two implementations of the :class:`Source` protocol are provided:
|
|
4
|
+
|
|
5
|
+
* :class:`FileSource` reads ROOT files with uproot. It accepts single paths,
|
|
6
|
+
glob patterns, lists of either, remote URLs, and ``"file.root:tree"``
|
|
7
|
+
shorthand, and works with both ``TTree`` and ``RNTuple`` objects.
|
|
8
|
+
* :class:`ArraySource` wraps data that is already in memory (a mapping of
|
|
9
|
+
arrays, an Awkward record array, or a NumPy structured array).
|
|
10
|
+
|
|
11
|
+
Everything downstream of this module (expressions, selection, histogramming,
|
|
12
|
+
plotting) only sees the protocol, so it is independent of file I/O.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import glob
|
|
18
|
+
import os
|
|
19
|
+
from collections.abc import Mapping, Sequence
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from os import PathLike
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Protocol, runtime_checkable
|
|
24
|
+
|
|
25
|
+
import awkward as ak
|
|
26
|
+
import numpy as np
|
|
27
|
+
import uproot
|
|
28
|
+
|
|
29
|
+
from rootfig.errors import SourceError
|
|
30
|
+
|
|
31
|
+
__all__ = ["ArraySource", "FileSource", "FilesLike", "Source", "as_source", "resolve_files"]
|
|
32
|
+
|
|
33
|
+
FilesLike = str | PathLike[str] | Sequence[str | PathLike[str]]
|
|
34
|
+
"""A path, glob pattern, URL, ``"path:tree"`` string, or a sequence of those."""
|
|
35
|
+
|
|
36
|
+
_REMOTE_PREFIXES = ("root://", "http://", "https://", "s3://", "gs://", "xrootd://")
|
|
37
|
+
_TREE_CLASSNAMES = ("TTree", "TNtuple", "TNtupleD", "TChain")
|
|
38
|
+
_RNTUPLE_MARKER = "RNTuple"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@runtime_checkable
|
|
42
|
+
class Source(Protocol):
|
|
43
|
+
"""Anything that can list branch names and return branch arrays.
|
|
44
|
+
|
|
45
|
+
Implementations must be cheap to construct; opening files or reading data
|
|
46
|
+
should happen inside :meth:`branches` and :meth:`arrays`.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def branches(self) -> list[str]:
|
|
50
|
+
"""Return the names of all available branches."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
def arrays(self, branches: Sequence[str]) -> dict[str, ak.Array]:
|
|
54
|
+
"""Read the given branches and return them as Awkward arrays of equal length."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
def describe(self) -> str:
|
|
58
|
+
"""Return a short human-readable description used in labels and messages."""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
# Implementations should also provide ``num_entries() -> int`` (the number of
|
|
62
|
+
# events); rootfig falls back to reading one branch when it is absent.
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --------------------------------------------------------------------------------------
|
|
66
|
+
# Files
|
|
67
|
+
# --------------------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_remote(path: str) -> bool:
|
|
71
|
+
return path.startswith(_REMOTE_PREFIXES)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _split_tree(path: str) -> tuple[str, str | None]:
|
|
75
|
+
"""Split ``"file.root:tree"`` into ``("file.root", "tree")``.
|
|
76
|
+
|
|
77
|
+
Only the last colon is considered and only when the part before it ends
|
|
78
|
+
with ``.root`` (case-insensitive), so URLs such as ``root://host//file.root``
|
|
79
|
+
are left intact. The tree part may name a tree inside a directory
|
|
80
|
+
(``"file.root:dir/events"``).
|
|
81
|
+
"""
|
|
82
|
+
head, sep, tail = path.rpartition(":")
|
|
83
|
+
if sep and head.lower().endswith(".root") and tail:
|
|
84
|
+
return head, tail
|
|
85
|
+
return path, None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def resolve_files(files: FilesLike) -> tuple[tuple[str, ...], str | None]:
|
|
89
|
+
"""Expand ``files`` into a sorted tuple of concrete paths and an optional tree name.
|
|
90
|
+
|
|
91
|
+
Parameters
|
|
92
|
+
----------
|
|
93
|
+
files
|
|
94
|
+
A path, glob pattern, remote URL, ``"path:tree"`` string, or a sequence
|
|
95
|
+
of any of these. Local glob patterns are expanded and sorted so results
|
|
96
|
+
are deterministic regardless of filesystem order.
|
|
97
|
+
|
|
98
|
+
Returns
|
|
99
|
+
-------
|
|
100
|
+
tuple
|
|
101
|
+
``(paths, tree)`` where ``tree`` is the tree name found in ``"path:tree"``
|
|
102
|
+
strings (all must agree) or ``None``.
|
|
103
|
+
|
|
104
|
+
Raises
|
|
105
|
+
------
|
|
106
|
+
SourceError
|
|
107
|
+
If a pattern matches nothing, a local file does not exist, or the
|
|
108
|
+
embedded tree names disagree.
|
|
109
|
+
"""
|
|
110
|
+
if isinstance(files, str | PathLike):
|
|
111
|
+
items: list[str] = [os.fspath(files)]
|
|
112
|
+
else:
|
|
113
|
+
items = [os.fspath(f) for f in files]
|
|
114
|
+
if not items:
|
|
115
|
+
msg = "no input files given"
|
|
116
|
+
raise SourceError(msg)
|
|
117
|
+
|
|
118
|
+
paths: list[str] = []
|
|
119
|
+
trees: set[str] = set()
|
|
120
|
+
for item in items:
|
|
121
|
+
path, tree = _split_tree(item)
|
|
122
|
+
if tree is not None:
|
|
123
|
+
trees.add(tree)
|
|
124
|
+
if _is_remote(path):
|
|
125
|
+
paths.append(path)
|
|
126
|
+
continue
|
|
127
|
+
expanded = os.path.expanduser(path)
|
|
128
|
+
if glob.has_magic(expanded):
|
|
129
|
+
matches = sorted(glob.glob(expanded))
|
|
130
|
+
if not matches:
|
|
131
|
+
msg = f"no files match pattern {path!r}"
|
|
132
|
+
raise SourceError(msg)
|
|
133
|
+
paths.extend(matches)
|
|
134
|
+
else:
|
|
135
|
+
if not Path(expanded).is_file():
|
|
136
|
+
msg = f"file not found: {path!r}"
|
|
137
|
+
raise SourceError(msg)
|
|
138
|
+
paths.append(expanded)
|
|
139
|
+
|
|
140
|
+
if len(trees) > 1:
|
|
141
|
+
msg = f"conflicting tree names in file specification: {sorted(trees)}"
|
|
142
|
+
raise SourceError(msg)
|
|
143
|
+
return tuple(paths), (trees.pop() if trees else None)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _strip_cycle(key: str) -> str:
|
|
147
|
+
return key.rsplit(";", 1)[0]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _leaf_names(keys: Sequence[str]) -> list[str]:
|
|
151
|
+
"""Branch names as users write them: sub-branches by their own (dotted) name, no parents.
|
|
152
|
+
|
|
153
|
+
uproot lists split object branches of a ``TTree`` as ``Parent/Parent.field``
|
|
154
|
+
plus the parent itself, and the nested fields of an ``RNTuple`` as
|
|
155
|
+
``Parent.field`` plus ``Parent``; only the leaves hold arrays that can be
|
|
156
|
+
histogrammed.
|
|
157
|
+
"""
|
|
158
|
+
parents = {key.rsplit("/", 1)[0] for key in keys if "/" in key}
|
|
159
|
+
leaves = [key.rsplit("/", 1)[-1] for key in keys if key not in parents]
|
|
160
|
+
records = {leaf for leaf in leaves if any(other.startswith(f"{leaf}.") for other in leaves)}
|
|
161
|
+
return [leaf for leaf in leaves if leaf not in records]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _extract_nested(data: Mapping[str, Any], name: str) -> ak.Array | None:
|
|
165
|
+
"""Return the field ``Parent.field.sub`` from a record array read as ``Parent``."""
|
|
166
|
+
parts = name.split(".")
|
|
167
|
+
if parts[0] not in data:
|
|
168
|
+
return None
|
|
169
|
+
array = data[parts[0]]
|
|
170
|
+
for part in parts[1:]:
|
|
171
|
+
if part not in getattr(array, "fields", ()):
|
|
172
|
+
return None
|
|
173
|
+
array = array[part]
|
|
174
|
+
return ak.Array(array)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _is_tree_class(classname: str) -> bool:
|
|
178
|
+
return classname.startswith(_TREE_CLASSNAMES) or _RNTUPLE_MARKER in classname
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _detect_tree(path: str) -> str:
|
|
182
|
+
"""Return the name of the only tree-like object in ``path`` or raise."""
|
|
183
|
+
with uproot.open(path) as file:
|
|
184
|
+
classnames: dict[str, str] = file.classnames(recursive=True)
|
|
185
|
+
trees = sorted({_strip_cycle(k) for k, cls in classnames.items() if _is_tree_class(cls)})
|
|
186
|
+
if len(trees) == 1:
|
|
187
|
+
return trees[0]
|
|
188
|
+
if not trees:
|
|
189
|
+
found = sorted({f"{_strip_cycle(k)} ({cls})" for k, cls in classnames.items()})
|
|
190
|
+
msg = f"no TTree or RNTuple found in {path!r}; objects present: {found}"
|
|
191
|
+
raise SourceError(msg)
|
|
192
|
+
msg = f"several trees found in {path!r}: {trees}. Pass tree=... to choose one"
|
|
193
|
+
raise SourceError(msg)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True)
|
|
197
|
+
class FileSource:
|
|
198
|
+
"""Branch arrays read from one or more ROOT files with uproot.
|
|
199
|
+
|
|
200
|
+
Parameters
|
|
201
|
+
----------
|
|
202
|
+
files
|
|
203
|
+
See :data:`FilesLike`. Globs are expanded and sorted at construction.
|
|
204
|
+
tree
|
|
205
|
+
Name of the ``TTree``/``RNTuple``. If omitted, the files are inspected
|
|
206
|
+
and the tree is auto-detected when there is exactly one.
|
|
207
|
+
entry_start, entry_stop
|
|
208
|
+
Restrict reading to a range of entries counted across all files, in the
|
|
209
|
+
same way as :func:`uproot.concatenate`. Useful for quick looks at large
|
|
210
|
+
inputs.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
files: tuple[str, ...]
|
|
214
|
+
tree: str | None = None
|
|
215
|
+
entry_start: int | None = None
|
|
216
|
+
entry_stop: int | None = None
|
|
217
|
+
_cache: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
218
|
+
|
|
219
|
+
def __init__(
|
|
220
|
+
self,
|
|
221
|
+
files: FilesLike,
|
|
222
|
+
tree: str | None = None,
|
|
223
|
+
*,
|
|
224
|
+
entry_start: int | None = None,
|
|
225
|
+
entry_stop: int | None = None,
|
|
226
|
+
) -> None:
|
|
227
|
+
paths, embedded_tree = resolve_files(files)
|
|
228
|
+
if tree is not None and embedded_tree is not None and tree != embedded_tree:
|
|
229
|
+
msg = f"tree {tree!r} conflicts with {embedded_tree!r} given in the file path"
|
|
230
|
+
raise SourceError(msg)
|
|
231
|
+
object.__setattr__(self, "files", paths)
|
|
232
|
+
object.__setattr__(self, "tree", tree if tree is not None else embedded_tree)
|
|
233
|
+
object.__setattr__(self, "entry_start", entry_start)
|
|
234
|
+
object.__setattr__(self, "entry_stop", entry_stop)
|
|
235
|
+
object.__setattr__(self, "_cache", {})
|
|
236
|
+
|
|
237
|
+
# -- Source protocol --------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
def resolved_tree(self) -> str:
|
|
240
|
+
"""Return the tree name, auto-detecting it from the first file if needed."""
|
|
241
|
+
if self.tree is not None:
|
|
242
|
+
return self.tree
|
|
243
|
+
if "tree" not in self._cache:
|
|
244
|
+
self._cache["tree"] = _detect_tree(self.files[0])
|
|
245
|
+
return str(self._cache["tree"])
|
|
246
|
+
|
|
247
|
+
def branches(self) -> list[str]:
|
|
248
|
+
"""Return the branch (or RNTuple field) names of the tree in the first file.
|
|
249
|
+
|
|
250
|
+
The result is cached on the instance; the file is opened only once.
|
|
251
|
+
"""
|
|
252
|
+
if "branches" in self._cache:
|
|
253
|
+
return list(self._cache["branches"])
|
|
254
|
+
tree = self.resolved_tree()
|
|
255
|
+
try:
|
|
256
|
+
with uproot.open(self.files[0]) as file:
|
|
257
|
+
obj = file[tree]
|
|
258
|
+
keys = list(obj.keys())
|
|
259
|
+
self._cache["rntuple"] = _RNTUPLE_MARKER in type(obj).__name__
|
|
260
|
+
except uproot.KeyInFileError as exc:
|
|
261
|
+
msg = f"tree {tree!r} not found in {self.files[0]!r}"
|
|
262
|
+
raise SourceError(msg) from exc
|
|
263
|
+
names = _leaf_names(keys)
|
|
264
|
+
self._cache["branches"] = names
|
|
265
|
+
return list(names)
|
|
266
|
+
|
|
267
|
+
def _is_rntuple(self) -> bool:
|
|
268
|
+
if "rntuple" not in self._cache:
|
|
269
|
+
self.branches()
|
|
270
|
+
return bool(self._cache["rntuple"])
|
|
271
|
+
|
|
272
|
+
def num_entries(self) -> int:
|
|
273
|
+
"""Return the number of entries (all files, honouring the entry range)."""
|
|
274
|
+
tree = self.resolved_tree()
|
|
275
|
+
if "num_entries" not in self._cache:
|
|
276
|
+
total = 0
|
|
277
|
+
for path in self.files:
|
|
278
|
+
with uproot.open(path) as file:
|
|
279
|
+
total += int(file[tree].num_entries)
|
|
280
|
+
self._cache["num_entries"] = total
|
|
281
|
+
total = int(self._cache["num_entries"])
|
|
282
|
+
start, stop, _ = slice(self.entry_start, self.entry_stop).indices(total)
|
|
283
|
+
return max(stop - start, 0)
|
|
284
|
+
|
|
285
|
+
def read_scalar(self, key: str) -> float:
|
|
286
|
+
"""Sum a number stored under ``key`` in every file.
|
|
287
|
+
|
|
288
|
+
Accepts histograms (their total content including flow bins, e.g. a
|
|
289
|
+
sum-of-weights histogram) and ``TParameter`` objects (their value).
|
|
290
|
+
Typical use: the number of generated events written by a framework.
|
|
291
|
+
"""
|
|
292
|
+
total = 0.0
|
|
293
|
+
for path in self.files:
|
|
294
|
+
with uproot.open(path) as file:
|
|
295
|
+
try:
|
|
296
|
+
obj = file[key]
|
|
297
|
+
except uproot.KeyInFileError as exc:
|
|
298
|
+
msg = f"object {key!r} not found in {path!r}"
|
|
299
|
+
raise SourceError(msg) from exc
|
|
300
|
+
if hasattr(obj, "axes") and hasattr(obj, "values"): # a histogram
|
|
301
|
+
total += float(np.sum(obj.values(flow=True)))
|
|
302
|
+
elif hasattr(obj, "member") and "fVal" in getattr(obj, "members", {}):
|
|
303
|
+
total += float(obj.member("fVal")) # a TParameter
|
|
304
|
+
else:
|
|
305
|
+
msg = f"object {key!r} in {path!r} ({type(obj).__name__}) holds no number"
|
|
306
|
+
raise SourceError(msg)
|
|
307
|
+
return total
|
|
308
|
+
|
|
309
|
+
def arrays(self, branches: Sequence[str]) -> dict[str, ak.Array]:
|
|
310
|
+
"""Read ``branches`` from all files and concatenate them."""
|
|
311
|
+
tree = self.resolved_tree()
|
|
312
|
+
if not branches:
|
|
313
|
+
return {}
|
|
314
|
+
wanted = set(branches)
|
|
315
|
+
# A TTree presents split object branches by their full dotted leaf name; an
|
|
316
|
+
# RNTuple presents nested fields by their short name, one level at a time, and
|
|
317
|
+
# returns the parent as a record array. For RNTuples let every level through and
|
|
318
|
+
# pick the nested field out of the record afterwards.
|
|
319
|
+
parts = (
|
|
320
|
+
{part for name in wanted for part in name.split(".")} if self._is_rntuple() else set()
|
|
321
|
+
)
|
|
322
|
+
try:
|
|
323
|
+
# Select by name rather than by expression so that dotted sub-branch names
|
|
324
|
+
# (``Collection.field.x`` in EDM4hep/podio files) and odd characters work.
|
|
325
|
+
data = uproot.concatenate(
|
|
326
|
+
[{path: tree} for path in self.files],
|
|
327
|
+
filter_name=lambda name: name in wanted or name in parts,
|
|
328
|
+
entry_start=self.entry_start,
|
|
329
|
+
entry_stop=self.entry_stop,
|
|
330
|
+
library="ak",
|
|
331
|
+
how=dict,
|
|
332
|
+
)
|
|
333
|
+
except uproot.KeyInFileError as exc:
|
|
334
|
+
msg = f"could not read {tree!r} from {self.files}: {exc}"
|
|
335
|
+
raise SourceError(msg) from exc
|
|
336
|
+
result: dict[str, ak.Array] = {}
|
|
337
|
+
missing: list[str] = []
|
|
338
|
+
for name in branches:
|
|
339
|
+
array = ak.Array(data[name]) if name in data else _extract_nested(data, name)
|
|
340
|
+
if array is None:
|
|
341
|
+
missing.append(name)
|
|
342
|
+
else:
|
|
343
|
+
result[name] = array
|
|
344
|
+
if missing:
|
|
345
|
+
# RNTuple reads silently drop unknown fields; report them explicitly.
|
|
346
|
+
msg = f"branches {missing} not found in tree {tree!r}"
|
|
347
|
+
raise SourceError(msg)
|
|
348
|
+
return result
|
|
349
|
+
|
|
350
|
+
def describe(self) -> str:
|
|
351
|
+
"""Return e.g. ``'events.root'`` or ``'3 files (run*.root)'``."""
|
|
352
|
+
if len(self.files) == 1:
|
|
353
|
+
return Path(self.files[0]).name
|
|
354
|
+
return f"{len(self.files)} files ({Path(self.files[0]).name}, ...)"
|
|
355
|
+
|
|
356
|
+
@property
|
|
357
|
+
def default_label(self) -> str:
|
|
358
|
+
"""A legend label derived from the first file's stem."""
|
|
359
|
+
return Path(self.files[0]).stem
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# --------------------------------------------------------------------------------------
|
|
363
|
+
# In-memory arrays
|
|
364
|
+
# --------------------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@dataclass(frozen=True)
|
|
368
|
+
class ArraySource:
|
|
369
|
+
"""Branch arrays already in memory.
|
|
370
|
+
|
|
371
|
+
Parameters
|
|
372
|
+
----------
|
|
373
|
+
data
|
|
374
|
+
A mapping from branch name to array-like, an Awkward record array, or a
|
|
375
|
+
NumPy structured array. All columns must have the same length.
|
|
376
|
+
entry_start, entry_stop
|
|
377
|
+
Keep only this range of entries (Python slice semantics).
|
|
378
|
+
"""
|
|
379
|
+
|
|
380
|
+
data: Mapping[str, ak.Array]
|
|
381
|
+
|
|
382
|
+
def __init__(
|
|
383
|
+
self,
|
|
384
|
+
data: Mapping[str, Any] | ak.Array | np.ndarray,
|
|
385
|
+
*,
|
|
386
|
+
entry_start: int | None = None,
|
|
387
|
+
entry_stop: int | None = None,
|
|
388
|
+
) -> None:
|
|
389
|
+
columns = _to_columns(data)
|
|
390
|
+
lengths = {name: len(array) for name, array in columns.items()}
|
|
391
|
+
if len(set(lengths.values())) > 1:
|
|
392
|
+
msg = f"all arrays must have the same length, got {lengths}"
|
|
393
|
+
raise SourceError(msg)
|
|
394
|
+
if entry_start is not None or entry_stop is not None:
|
|
395
|
+
columns = {name: array[entry_start:entry_stop] for name, array in columns.items()}
|
|
396
|
+
object.__setattr__(self, "data", columns)
|
|
397
|
+
|
|
398
|
+
def branches(self) -> list[str]:
|
|
399
|
+
"""Return the column names."""
|
|
400
|
+
return list(self.data)
|
|
401
|
+
|
|
402
|
+
def arrays(self, branches: Sequence[str]) -> dict[str, ak.Array]:
|
|
403
|
+
"""Return the requested columns."""
|
|
404
|
+
missing = [b for b in branches if b not in self.data]
|
|
405
|
+
if missing:
|
|
406
|
+
msg = f"columns {missing} not found; available: {sorted(self.data)}"
|
|
407
|
+
raise SourceError(msg)
|
|
408
|
+
return {name: self.data[name] for name in branches}
|
|
409
|
+
|
|
410
|
+
def num_entries(self) -> int:
|
|
411
|
+
"""Return the number of events (length of the arrays)."""
|
|
412
|
+
return len(next(iter(self.data.values()))) if self.data else 0
|
|
413
|
+
|
|
414
|
+
def describe(self) -> str:
|
|
415
|
+
"""Return a short description of the in-memory data."""
|
|
416
|
+
n = len(next(iter(self.data.values()))) if self.data else 0
|
|
417
|
+
return f"in-memory arrays ({n} events, {len(self.data)} columns)"
|
|
418
|
+
|
|
419
|
+
@property
|
|
420
|
+
def default_label(self) -> str:
|
|
421
|
+
"""A generic legend label for in-memory data."""
|
|
422
|
+
return "arrays"
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _to_columns(data: object) -> dict[str, ak.Array]:
|
|
426
|
+
if isinstance(data, Mapping):
|
|
427
|
+
if not data:
|
|
428
|
+
msg = "empty mapping given as data source"
|
|
429
|
+
raise SourceError(msg)
|
|
430
|
+
return {str(k): _as_ak(v) for k, v in data.items()}
|
|
431
|
+
if isinstance(data, ak.Array):
|
|
432
|
+
if not data.fields:
|
|
433
|
+
msg = "Awkward array must be a record array with named fields"
|
|
434
|
+
raise SourceError(msg)
|
|
435
|
+
return {name: data[name] for name in data.fields}
|
|
436
|
+
if isinstance(data, np.ndarray):
|
|
437
|
+
if data.dtype.names is None:
|
|
438
|
+
msg = "NumPy array must be a structured array with named fields"
|
|
439
|
+
raise SourceError(msg)
|
|
440
|
+
return {name: ak.Array(data[name]) for name in data.dtype.names}
|
|
441
|
+
msg = f"unsupported in-memory data of type {type(data).__name__}"
|
|
442
|
+
raise SourceError(msg)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _as_ak(value: Any) -> ak.Array:
|
|
446
|
+
if isinstance(value, ak.Array):
|
|
447
|
+
return value
|
|
448
|
+
try:
|
|
449
|
+
return ak.Array(value)
|
|
450
|
+
except Exception as exc:
|
|
451
|
+
msg = f"cannot convert {type(value).__name__} to an Awkward array"
|
|
452
|
+
raise SourceError(msg) from exc
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# --------------------------------------------------------------------------------------
|
|
456
|
+
# Dispatch
|
|
457
|
+
# --------------------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def as_source(
|
|
461
|
+
data: Any,
|
|
462
|
+
*,
|
|
463
|
+
tree: str | None = None,
|
|
464
|
+
entry_start: int | None = None,
|
|
465
|
+
entry_stop: int | None = None,
|
|
466
|
+
) -> Source:
|
|
467
|
+
"""Turn user input into a :class:`Source`.
|
|
468
|
+
|
|
469
|
+
Strings, paths, and sequences of them become a :class:`FileSource`;
|
|
470
|
+
mappings, Awkward record arrays and NumPy structured arrays become an
|
|
471
|
+
:class:`ArraySource`; existing sources are returned unchanged.
|
|
472
|
+
"""
|
|
473
|
+
if isinstance(data, FileSource | ArraySource):
|
|
474
|
+
return data
|
|
475
|
+
if isinstance(data, str | PathLike):
|
|
476
|
+
return FileSource(data, tree, entry_start=entry_start, entry_stop=entry_stop)
|
|
477
|
+
if isinstance(data, Mapping | ak.Array | np.ndarray):
|
|
478
|
+
return ArraySource(data, entry_start=entry_start, entry_stop=entry_stop)
|
|
479
|
+
if isinstance(data, Sequence) and all(isinstance(f, str | PathLike) for f in data):
|
|
480
|
+
return FileSource(data, tree, entry_start=entry_start, entry_stop=entry_stop)
|
|
481
|
+
if isinstance(data, Source):
|
|
482
|
+
return data
|
|
483
|
+
msg = (
|
|
484
|
+
f"cannot interpret {type(data).__name__} as a data source; expected file path(s), "
|
|
485
|
+
"a mapping of arrays, an Awkward record array, or a rootfig Source"
|
|
486
|
+
)
|
|
487
|
+
raise SourceError(msg)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Declarative descriptions of what to plot: samples, variables, cuts, binning, style."""
|
|
2
|
+
|
|
3
|
+
from rootfig.model.binning import Axis, Bins, RangeSpec, auto_range, log_bins, resolve_axis
|
|
4
|
+
from rootfig.model.cuts import Cut, CutLike, as_cut
|
|
5
|
+
from rootfig.model.samples import Sample, SampleLike, as_samples
|
|
6
|
+
from rootfig.model.style import Style, StyleLike, as_style
|
|
7
|
+
from rootfig.model.variables import Variable, as_variable
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Axis",
|
|
11
|
+
"Bins",
|
|
12
|
+
"Cut",
|
|
13
|
+
"CutLike",
|
|
14
|
+
"RangeSpec",
|
|
15
|
+
"Sample",
|
|
16
|
+
"SampleLike",
|
|
17
|
+
"Style",
|
|
18
|
+
"StyleLike",
|
|
19
|
+
"Variable",
|
|
20
|
+
"as_cut",
|
|
21
|
+
"as_samples",
|
|
22
|
+
"as_style",
|
|
23
|
+
"as_variable",
|
|
24
|
+
"auto_range",
|
|
25
|
+
"log_bins",
|
|
26
|
+
"resolve_axis",
|
|
27
|
+
]
|