ATLAS Offline Software
Loading...
Searching...
No Matches
buffers.py
Go to the documentation of this file.
1# Copyright (C) 2002-2026 CERN for the benefit of the ATLAS collaboration
2#
3# @author Giordon Stark
4
5# Buffer extraction and reconstruction utilities for PythonToolHandle.
6# Generalizes the pattern from test/muon_eff_sf_example_PHYSLITE.py into
7# reusable functions that work with any columnar CP tool.
8
9import itertools
10
11import awkward as ak
12import numpy as np
13
14from ColumnarToolWrapperPython.python_tool_handle import (
15 ColumnAccessMode,
16 invalid_link_value,
17 sg_key,
18)
19
20
22 """Return the inner-most singly-jagged ListOffsetArray of ``array``.
23
24 For a ``var * var * T`` input (e.g. NumTrkPt500), returns a view whose
25 layout is a ``ListOffsetArray`` with ``NumpyArray`` content — i.e. the
26 per-particle list structure collapsed across the full event range.
27 Use ``result.layout.offsets.data`` for cumulative per-particle inner
28 offsets and ``result.layout.content.data`` for the flat numeric buffer.
29
30 Solution from Peter Fackeldey: traverse the layout with ``ak.transform``
31 and capture the deepest singly-jagged node.
32 """
33 layout = array.layout
34 layout_depth = layout.purelist_depth
35
36 is_branched, _ = layout.branch_depth
37 if is_branched:
38 raise ValueError(
39 f"{layout} has branching, cannot extract inner-most ListOffsetArray"
40 )
41
42 def _is_singly_jagged(lay):
43 return (
44 isinstance(lay, ak.contents.ListOffsetArray)
45 and isinstance(lay.content, ak.contents.NumpyArray)
46 )
47
48 found = None
49
50 def _capture(lay, depth, **_kwargs):
51 nonlocal found
52 if depth == (layout_depth - 1) and _is_singly_jagged(lay):
53 found = lay.materialize()
54
55 ak.transform(_capture, layout, return_value="none")
56
57 if found is None:
58 raise ValueError(
59 "Did not find a singly-jagged ListOffsetArray at the inner-most depth"
60 )
61 return ak.Array(found)
62
63
65 """Strip the trailing ``.data`` suffix from a nested-vector column name."""
66 return name[:-5] if name.endswith(".data") else name
67
68
70 """Whether a data column is a (sole-target) element link column."""
71 return bool(col.sole_link_target_name)
72
73
75 """Recover the StoreGate container name from a branch-prefix name.
76
77 Branch prefixes append ``AuxDyn`` (dynamic variables) or ``Aux``
78 (static variables) to the StoreGate key the container was recorded
79 under, e.g. ``"InDetTrackParticlesAuxDyn"`` -> ``"InDetTrackParticles"``.
80 Unrenamed (canonical) container names pass through unchanged.
81 """
82 if name.endswith("AuxDyn"):
83 return name[: -len("AuxDyn")]
84 if name.endswith("Aux"):
85 return name[: -len("Aux")]
86 return name
87
88
90 """Expected m_persKey value for a link column's target container.
91
92 The persistent key stored for an element link is the StoreGate hash of
93 the target container's name and CLID (see ``sg_key``). The StoreGate
94 name is recovered from the (possibly renamed) target container name by
95 stripping a trailing ``AuxDyn``/``Aux`` branch-prefix suffix.
96
97 Returns None when the column is not a sole-target link column or the
98 target container's CLID is not known.
99 """
100 clid = getattr(col, "sole_link_target_clid", 0)
101 if not col.sole_link_target_name or not clid:
102 return None
103 return sg_key(_sg_container_name(col.sole_link_target_name), clid)
104
105
106def link_key_map(columns):
107 """Map m_persKey value -> target container name over a tool's link columns.
108
109 Useful for reverse-resolving the m_persKey values stored in a file to
110 the (possibly renamed) container names the tool reads:
111
112 link_key_map(tool.columns).get(pers_key)
113
114 Columns without link metadata or without a known target CLID are
115 skipped.
116 """
117 result = {}
118 for col in columns:
119 key = expected_link_key(col)
120 if key is not None:
121 result[key] = col.sole_link_target_name
122 return result
123
124
126 """Build a cumulative uint64 offset array from per-row counts."""
127 offsets = np.zeros(len(counts) + 1, dtype=np.uint64)
128 offsets[1:] = np.cumsum(counts)
129 return offsets
130
131
132def _link_index_and_key(events, base, column_name):
133 """Return the (m_persIndex, m_persKey) jagged arrays for a link branch.
134
135 PHYSLITE ElementLink branches read with uproot appear either as two
136 top-level fields ``{base}.m_persIndex`` / ``{base}.m_persKey`` (when the
137 sub-branches were requested directly) or as a single record-typed field
138 ``{base}`` whose subfields are named either ``m_persIndex`` /
139 ``m_persKey`` (vector-of-links branches) or with the full dotted prefix
140 (scalar link parent branches).
141 """
142 fields = set(ak.fields(events))
143 index_field = f"{base}.m_persIndex"
144 key_field = f"{base}.m_persKey"
145 if index_field in fields and key_field in fields:
146 return events[index_field], events[key_field]
147 if base in fields:
148 subfields = set(ak.fields(events[base]))
149 for prefix in ("", f"{base}."):
150 if f"{prefix}m_persIndex" in subfields and f"{prefix}m_persKey" in subfields:
151 return (
152 events[base][f"{prefix}m_persIndex"],
153 events[base][f"{prefix}m_persKey"],
154 )
155 raise RuntimeError(
156 f"Cannot find link fields for column '{column_name}': expected "
157 f"'{index_field}' and '{key_field}' fields in the input array "
158 f"(read branch '{base}' with uproot)"
159 )
160
161
162def _convert_links(index, key, target_offsets, col):
163 """Convert per-event link indices to global offsets into the target.
164
165 Mirrors ``LinkColumnVector::addLink``/``addSplitLink`` in
166 ColumnarTestFixtures/Root/ColumnarPhysliteTest.cxx: a stored
167 ``(m_persKey == 0, m_persIndex == 0)`` pair (the Athena persistent
168 null encoding) or ``m_persIndex == 0xFFFFFFFF`` (the standalone
169 ``ElementLinkBase::isDefault`` encoding) marks a null link, which
170 becomes ``invalid_link_value``; every other link is offset by the
171 target container's per-event start and bounds-checked against the
172 per-event end. The m_persKey value itself is not validated against the
173 target container (see the sg_key binding for computing expected keys).
174
175 ``index``/``key`` may be jagged at depth 2 (one link per object) or
176 depth 3 (a vector of links per object); the per-event ``target_offsets``
177 broadcast down either structure.
178 """
179 starts = np.asarray(target_offsets)[:-1]
180 ends = np.asarray(target_offsets)[1:]
181 global_index = ak.values_astype(index, np.uint64) + starts
182 valid = ~(((key == 0) & (index == 0)) | (index == 0xFFFFFFFF))
183 out_of_range = valid & (global_index >= ends)
184 if ak.any(out_of_range, axis=None):
185 msg = (
186 f"link index out of range for column '{col.name}' "
187 f"targeting '{col.sole_link_target_name}'"
188 )
189 found = sorted(
190 {int(k) for k in ak.flatten(key[out_of_range], axis=None).to_list()}
191 )
192 msg += f" (m_persKey of offending links: {[hex(k) for k in found]}"
193 expected = expected_link_key(col)
194 if expected is not None:
195 msg += f", expected 0x{expected:08x}"
196 raise RuntimeError(msg + ")")
197 return ak.where(valid, global_index, np.uint64(invalid_link_value))
198
199
200def _convert_scalar_link_column(events, col, target_offsets):
201 """Flatten a one-link-per-object column to a uint64 global-offset buffer."""
202 index, key = _link_index_and_key(events, col.name, col.name)
203 converted = _convert_links(index, key, target_offsets, col)
204 return np.ascontiguousarray(
205 ak.to_numpy(ak.flatten(converted, axis=1)), dtype=np.uint64
206 )
207
208
209def _convert_vector_link_column(events, col, target_offsets):
210 """Convert a vector-of-links column to (nested offsets, data) buffers.
211
212 The returned offsets have one entry per object plus one (the nested
213 ``.offset`` column); the data buffer holds the flattened uint64 global
214 offsets in object order.
215 """
216 base = _branch_name_for_column(col.name)
217 index, key = _link_index_and_key(events, base, col.name)
218 converted = _convert_links(index, key, target_offsets, col)
219 counts = ak.to_numpy(ak.flatten(ak.num(converted, axis=2), axis=1))
220 offsets = _offsets_from_counts(counts)
221 data = np.ascontiguousarray(
222 ak.to_numpy(ak.flatten(converted, axis=None)), dtype=np.uint64
223 )
224 return offsets, data
225
226
227def classify_columns(columns):
228 """Group ColumnInfo objects by container and role.
229
230 Parameters
231 ----------
232 columns:
233 Iterable of ColumnInfo objects as returned by PythonToolHandle.columns.
234
235 Returns
236 -------
237 dict
238 Keyed by container offset name (e.g. "EventInfo", "Muons"). Each value
239 is a dict with:
240
241 - ``"offset"``: the ColumnInfo for this container's offset column
242 - ``"inputs"``: list of input ColumnInfo belonging to this container
243 - ``"outputs"``: list of output ColumnInfo belonging to this container
244 - ``"nested_offsets"``: dict of name -> ColumnInfo for offset columns
245 that are children of this container (e.g. "Muons.NumTrkPt500.offset")
246
247 Notes
248 -----
249 Container offsets have ``is_offset=True`` and an ``offset_name`` of either
250 ``''`` (root, e.g. "EventInfo") or the name of another container offset
251 (e.g. "Muons" has ``offset_name="EventInfo"``). Nested-vector offsets also
252 have ``is_offset=True`` but their name contains a dot; they are stored under
253 ``"nested_offsets"`` of their parent container rather than as top-level keys.
254 """
255 # Separate offset columns from data columns
256 offset_cols = {col.name: col for col in columns if col.is_offset}
257 data_cols = [col for col in columns if not col.is_offset]
258
259 # Determine which offset columns are "container" offsets vs nested-vector
260 # offsets. A container offset is one whose offset_name is either '' (root)
261 # or points to another container offset. For MuonEffSF: EventInfo
262 # (offset_name='') and Muons (offset_name='EventInfo') are both containers.
263 # A nested-vector offset (e.g. "Particles.NumTrkPt500.offset") has a dotted
264 # name and its offset_name points to a container; it is stored under that
265 # container's "nested_offsets" as a dict with offset/inputs/outputs.
266 container_offsets = {}
267 nested_offsets_by_container = {}
268
269 for name, col in offset_cols.items():
270 parent = col.offset_name
271 if parent == "" or parent in offset_cols:
272 if "." in name:
273 # Nested-vector offset — goes under its parent container
274 nested_offsets_by_container.setdefault(parent, {})[name] = {
275 "offset": col,
276 "inputs": [],
277 "outputs": [],
278 }
279 else:
280 container_offsets[name] = col
281 else:
282 # offset_name not found in any offset column — treat as root
283 container_offsets[name] = col
284
285 # Build the classified dict
286 classified = {
287 name: {
288 "offset": col,
289 "inputs": [],
290 "outputs": [],
291 "nested_offsets": nested_offsets_by_container.get(name, {}),
292 }
293 for name, col in container_offsets.items()
294 }
295
296 # Reverse map: nested_offset_name -> parent container name, for routing
297 # data columns whose offset_name points to a nested offset.
298 nested_to_container = {
299 nested_name: container
300 for container, nested_map in nested_offsets_by_container.items()
301 for nested_name in nested_map
302 }
303
304 # Assign data columns to their container or nested-offset bucket
305 for col in data_cols:
306 target = col.offset_name
307 is_output = col.access_mode == ColumnAccessMode.output
308
309 if target in classified:
310 bucket = classified[target]
311 elif target in nested_to_container:
312 container = nested_to_container[target]
313 bucket = classified[container]["nested_offsets"][target]
314 else:
315 # Shouldn't happen with well-formed tool output
316 continue
317
318 if is_output:
319 bucket["outputs"].append(col)
320 else:
321 bucket["inputs"].append(col)
322
323 return classified
324
325
326def resolve_optional_columns(classified, events):
327 """Remove optional input columns absent from events.
328
329 Checks each optional input column against ``ak.fields(events)`` and drops
330 it if not present. Returns a new dict (shallow copy per container); the
331 original classified dict is not modified.
332
333 Parameters
334 ----------
335 classified:
336 Output of ``classify_columns``.
337 events:
338 An ak.Array whose fields are checked for optional column presence.
339
340 Returns
341 -------
342 dict
343 Same structure as ``classify_columns`` output, with absent optional
344 columns removed from each container's ``"inputs"`` list.
345 """
346 available = set(ak.fields(events))
347 result = {}
348 for container, info in classified.items():
349 result[container] = dict(info)
350 result[container]["inputs"] = [
351 col
352 for col in info["inputs"]
353 if not col.is_optional or _column_is_present(col, available)
354 ]
355 return result
356
357
358def _column_is_present(col, available):
359 """Whether a column's branch fields are present in the input array.
360
361 Link columns are stored as ``m_persKey``/``m_persIndex`` branch fields
362 rather than under the column name itself.
363 """
364 if _is_link_column(col):
365 base = _branch_name_for_column(col.name)
366 return base in available or f"{base}.m_persIndex" in available
367 return col.name in available
368
369
370def extract_buffers(events, classified):
371 """Extract flat numpy buffers from an awkward array.
372
373 Returns a dict mapping column name -> numpy array, covering all container
374 offsets, nested-vector offsets, and input data columns. Output column
375 buffers are not included (allocate_outputs handles those).
376
377 Parameters
378 ----------
379 events:
380 An ak.Array (real or zero-length after typetracer conversion).
381 classified:
382 Output of classify_columns or resolve_optional_columns.
383 """
384 buffers = {}
385 num_events = int(ak.num(events, axis=0))
386 synthesized_offsets = set()
387 # Link columns are converted after the loop, once every container's
388 # offsets are known. Entries are (offset_owner, col): the nested offset
389 # name for vector links, the container name for scalar links.
390 deferred_links = []
391
392 for container_name, info in classified.items():
393 nested_offsets = info["nested_offsets"]
394
395 # Nested-vector inputs: extract inner offsets and data via the
396 # inner-most ListOffsetArray helper before processing flat inputs.
397 for nested_offset_name, nested in nested_offsets.items():
398 for col in nested["inputs"]:
399 if col.is_variant_link:
400 raise NotImplementedError(
401 f"variant link columns are not supported "
402 f"(column '{col.name}')"
403 )
404 if _is_link_column(col):
405 deferred_links.append((nested_offset_name, col))
406 continue
407 base = _branch_name_for_column(col.name)
408 inner = _inner_most_list_offset_array(events[base])
409 raw_offsets = np.asarray(inner.layout.offsets.data)
410 start = int(raw_offsets[0])
411 end = int(raw_offsets[-1])
412 # ROOT baskets can provide a larger raw buffer than the
413 # offsets reference — normalize to [0, end-start] range.
414 buffers[nested_offset_name] = np.ascontiguousarray(
415 raw_offsets - start, dtype=np.uint64
416 )
417 buffers[col.name] = np.ascontiguousarray(
418 inner.layout.content.data[start:end]
419 )
420
421 for col in info["inputs"]:
422 if col.is_variant_link:
423 raise NotImplementedError(
424 f"variant link columns are not supported (column '{col.name}')"
425 )
426 link_inputs = [col for col in info["inputs"] if _is_link_column(col)]
427 flat_inputs = [col for col in info["inputs"] if not _is_link_column(col)]
428 deferred_links.extend((container_name, col) for col in link_inputs)
429
430 if not flat_inputs and not nested_offsets and not link_inputs:
431 # No inputs at all — synthesize an offset so outputs can be sized
432 buffers[container_name] = np.array([0, num_events], dtype=np.uint64)
433 synthesized_offsets.add(container_name)
434 continue
435
436 if not flat_inputs:
437 # No regular flat inputs: derive the container offset from the
438 # per-event structure of a nested-vector field or a link branch.
439 any_nested_input = next(
440 (col for nested in nested_offsets.values() for col in nested["inputs"]),
441 None,
442 )
443 if any_nested_input is not None:
444 base = _branch_name_for_column(any_nested_input.name)
445 jagged = events[base]
446 elif link_inputs:
447 jagged, _key = _link_index_and_key(
448 events, link_inputs[0].name, link_inputs[0].name
449 )
450 else:
451 jagged = None
452 if jagged is not None:
453 buffers[container_name] = _offsets_from_counts(
454 ak.to_numpy(ak.num(jagged, axis=1))
455 )
456 else:
457 buffers[container_name] = np.array([0, num_events], dtype=np.uint64)
458 synthesized_offsets.add(container_name)
459 continue
460
461 # Group flat input columns by their offset_name, then zip + to_buffers
462 # each group. This is the same pattern as the original example script.
463 sorted_cols = sorted(flat_inputs, key=lambda c: c.offset_name)
464
465 for offset_name, cols_iter in itertools.groupby(
466 sorted_cols, key=lambda c: c.offset_name
467 ):
468 cols = list(cols_iter)
469 unzipped = {col.name: events[col.name] for col in cols}
470 zipped = ak.zip(unzipped)
471
472 # NB: form_key not crucial, but helpful for debugging
473 form, length, raw_buffers = ak.to_buffers(
474 zipped, form_key=f"{offset_name}{{id}}"
475 )
476
477 if isinstance(form, ak.forms.RecordForm):
478 # EventInfo-like: one record per event.
479 # Use ak.to_numpy per field instead of walking raw_buffers:
480 # when events is masked/indexed, form.content(field) is an
481 # IndexedForm and the "-data" key doesn't exist in raw_buffers.
482 buffers[container_name] = np.array(
483 [0, length], dtype=np.uint64
484 )
485 for col in cols:
486 buffers[col.name] = ak.to_numpy(events[col.name])
487 elif isinstance(form, ak.forms.ListOffsetForm):
488 # Particle container: extract offsets, cast to uint64
489 # for the C++ side
490 offset_key = next(
491 key for key in raw_buffers if key.endswith("-offsets")
492 )
493 buffers[container_name] = np.asarray(
494 raw_buffers[offset_key]
495 ).astype(np.uint64)
496
497 # Data buffers from the inner RecordForm
498 inner = form.content
499 for field in inner.fields:
500 buffers[field] = np.asarray(
501 raw_buffers[f"{inner.content(field).form_key}-data"]
502 )
503 else:
504 raise RuntimeError(
505 f"Cannot handle form {type(form)} for "
506 f"container {container_name}"
507 )
508
509 # Convert link columns now that every container's offsets are known
510 for offset_owner, col in deferred_links:
511 target = col.sole_link_target_name
512 target_offsets = buffers.get(target)
513 if target_offsets is None or target in synthesized_offsets:
514 raise RuntimeError(
515 f"link column '{col.name}' targets container '{target}', "
516 "whose offsets could not be derived from the tool's input columns"
517 )
518 if col.name.endswith(".data"):
519 nested_link_offsets, data = _convert_vector_link_column(
520 events, col, target_offsets
521 )
522 buffers[offset_owner] = nested_link_offsets
523 buffers[col.name] = data
524 else:
525 buffers[col.name] = _convert_scalar_link_column(
526 events, col, target_offsets
527 )
528
529 return buffers
530
531
532def allocate_outputs(classified, buffer_dict):
533 """Allocate zero-filled numpy arrays for each output column.
534
535 Sizes each output array using ``offsets[-1]`` of the referenced offset
536 buffer. Arrays are added into ``buffer_dict`` in-place and also returned.
537
538 Parameters
539 ----------
540 classified:
541 Output of ``classify_columns`` or ``resolve_optional_columns``.
542 buffer_dict:
543 Dict of column name -> numpy array, as returned by ``extract_buffers``.
544 Modified in-place to include the newly allocated output arrays.
545
546 Returns
547 -------
548 dict
549 Mapping of output column name -> zero-filled numpy array (same objects
550 also inserted into ``buffer_dict``).
551 """
552 nested_offset_names = {
553 nested_name
554 for info in classified.values()
555 for nested_name in info["nested_offsets"]
556 }
557 output_buffers = {}
558 for _container_name, info in classified.items():
559 for col in info["outputs"]:
560 if col.offset_name in nested_offset_names:
561 raise NotImplementedError(
562 f"Nested-vector output columns are not supported "
563 f"(column '{col.name}' has nested offset '{col.offset_name}')"
564 )
565 offset_data = buffer_dict.get(col.offset_name)
566 if offset_data is None:
567 msg = (
568 f"Cannot find offset buffer '{col.offset_name}' "
569 f"needed for output column '{col.name}'"
570 )
571 raise RuntimeError(msg)
572 size = int(offset_data[-1])
573 arr = np.zeros(size, dtype=col.dtype)
574 output_buffers[col.name] = arr
575 buffer_dict[col.name] = arr
576 return output_buffers
577
578
579def reconstruct_output(classified, buffer_dict, num_events):
580 """Build an awkward array from output column buffers.
581
582 Parameters
583 ----------
584 classified:
585 Output of ``classify_columns`` or ``resolve_optional_columns``.
586 buffer_dict:
587 Dict of column name -> numpy array, containing both offset buffers and
588 the output arrays populated by ``allocate_outputs`` and ``call()``.
589 num_events:
590 Number of events (outer axis length of the returned array).
591
592 Returns
593 -------
594 ak.Array
595 Record array with one field per output column, each a variable-length
596 list of per-particle values (i.e. ``var * dtype``).
597 """
598 form_fields = []
599 form_contents = []
600 out_buffers = {}
601
602 # node0 = RecordArray; each output column needs a pair of nodes
603 node_index = 1
604 for _container_name, info in classified.items():
605 for col in info["outputs"]:
606 node_offset = f"node{2 * node_index}"
607 node_data = f"node{2 * node_index + 1}"
608 node_index += 1
609
610 form_fields.append(col.name)
611 form_contents.append(
612 {
613 "class": "ListOffsetArray",
614 "offsets": "i64",
615 "content": {
616 "class": "NumpyArray",
617 "primitive": col.dtype,
618 "form_key": node_data,
619 },
620 "form_key": node_offset,
621 }
622 )
623
624 out_buffers[f"{node_data}-data"] = buffer_dict[col.name]
625 out_buffers[f"{node_offset}-offsets"] = buffer_dict[col.offset_name]
626
627 form = {
628 "class": "RecordArray",
629 "fields": form_fields,
630 "contents": form_contents,
631 "form_key": "node0",
632 }
633
634 return ak.from_buffers(form, num_events, out_buffers)
STL class.
reconstruct_output(classified, buffer_dict, num_events)
Definition buffers.py:579
_link_index_and_key(events, base, column_name)
Definition buffers.py:132
link_key_map(columns)
Definition buffers.py:106
resolve_optional_columns(classified, events)
Definition buffers.py:326
expected_link_key(col)
Definition buffers.py:89
_convert_scalar_link_column(events, col, target_offsets)
Definition buffers.py:200
_convert_vector_link_column(events, col, target_offsets)
Definition buffers.py:209
_sg_container_name(name)
Definition buffers.py:74
_convert_links(index, key, target_offsets, col)
Definition buffers.py:162
_is_link_column(col)
Definition buffers.py:69
_branch_name_for_column(name)
Definition buffers.py:64
_offsets_from_counts(counts)
Definition buffers.py:125
_column_is_present(col, available)
Definition buffers.py:358
extract_buffers(events, classified)
Definition buffers.py:370
_inner_most_list_offset_array(array)
Definition buffers.py:21
allocate_outputs(classified, buffer_dict)
Definition buffers.py:532
classify_columns(columns)
Definition buffers.py:227