File indexing completed on 2026-11-09 10:47:15
0001 """Standalone MCParticle -> simulated tracking-hit tables (NumPy/Awkward/Uproot/pandas).
0002
0003 read_mc_tracking_hits() returns a dict with particles, hits and surfaces DataFrames
0004 and metadata. For large inputs, use iter_mc_tracking_hits() and reduce each block.
0005 Particles with no hit are retained. No assumption of one particle/event is made.
0006 Only direct hit.particle references are followed; secondary ancestry is not added.
0007
0008 Join particles/hits on KEY. A detector-layer key is (hit_collection, system,
0009 layer), NOT layer alone. It identifies a detector layer, not an ACTS surface;
0010 decoded module/sensor fields and the full CellID retain finer segmentation.
0011 If a readout has no system/layer field, its table value is NA; use its other
0012 decoded fields (e.g. cell_sector/cell_module) instead of inventing a layer ID.
0013
0014 CellID layouts are read from PODIO frame metadata. If absent, pass encodings
0015 from the matching geometry: {readout_name: DD4hep_bitfield_description}.
0016 load_readout_encodings(xml_files) reads these descriptions without DD4hep.
0017 No project modules, PODIO, ROOT or geometry libraries are imported here.
0018 """
0019 from pathlib import Path
0020 import xml.etree.ElementTree as ET
0021
0022 import awkward as ak
0023 import numpy as np
0024 import pandas as pd
0025 import uproot
0026
0027 KEY = ['file_id', 'event_entry', 'mc_index']
0028 SURFACE_KEY = ['hit_collection', 'system', 'layer']
0029
0030
0031 def load_readout_encodings(xml_files):
0032 """Read <readout><id> layouts from the actual XMLs used by the simulation.
0033
0034 Pass the included detector XMLs, not all alternative geometry variants.
0035 This reads descriptions only; it does not expand XML includes or expressions.
0036 """
0037 layouts = {}
0038 for filename in xml_files:
0039 for readout in ET.parse(filename).getroot().iter('readout'):
0040 description = readout.findtext('id')
0041 if description is None:
0042 continue
0043 name, description = readout.attrib['name'], description.strip()
0044 if name in layouts and layouts[name] != description:
0045 raise ValueError(f'Conflicting readout layouts for {name}')
0046 layouts[name] = description
0047 return layouts
0048
0049
0050 def _cell_fields(description):
0051 """Parse DD4hep field offsets/signed widths, checking layout consistency."""
0052 fields, offset, used = {}, 0, 0
0053 for token in description.split(','):
0054 parts = token.strip().split(':')
0055 if len(parts) not in (2, 3):
0056 raise ValueError(f'Invalid CellID field: {token}')
0057 name, width = parts[0], int(parts[-1])
0058 if len(parts) == 3:
0059 offset = int(parts[1])
0060 size = abs(width)
0061 if not name or not 0 < size <= 64 or offset < 0 or offset+size > 64:
0062 raise ValueError(f'Invalid CellID field: {token}')
0063 mask = ((1 << size)-1) << offset
0064 if name in fields or used & mask:
0065 raise ValueError(f'Duplicate/overlapping CellID field: {token}')
0066 fields[name] = (offset, width)
0067 used |= mask
0068 offset += size
0069 return fields
0070
0071
0072 def decode_cell_ids(cells, description):
0073 """Decode DD4hep name:width / name:offset:width fields, including signed ones."""
0074 cells = np.asarray(cells)
0075 if cells.dtype.kind not in 'iu':
0076 raise TypeError('CellIDs must be exact integers')
0077 cells = cells.astype(np.uint64, copy=False)
0078 result = {}
0079 for name, (offset, width) in _cell_fields(description).items():
0080 size = abs(width)
0081 values = (cells >> np.uint64(offset)) & np.uint64((1 << size)-1)
0082 if width < 0:
0083 sign = np.uint64(1 << (size-1))
0084 values = ((values ^ sign)-sign).view(np.int64)
0085
0086
0087 result[name] = pd.array(values, dtype='Int64' if width < 0 else 'UInt64')
0088 return result
0089
0090
0091 def _field(record, name):
0092 matches = [key for key in record.fields if key == name or key.endswith('.'+name)]
0093 if len(matches) != 1:
0094 raise ValueError('Cannot resolve metadata field: '+name)
0095 return ak.to_list(record[matches[0]])
0096
0097
0098 def _frame_encodings(root):
0099 layouts = {}
0100 for name in ('metadata', 'meta', 'runs'):
0101 if name not in root or 'GPStringKeys' not in root[name]:
0102 continue
0103 keys = ak.to_list(root[name]['GPStringKeys'].array(entry_stop=1))
0104 values = ak.to_list(root[name]['GPStringValues'].array(entry_stop=1))
0105 if not keys:
0106 continue
0107 for key, value in zip(keys[0], values[0]):
0108 if key.endswith('__CellIDEncoding'):
0109 description = value if isinstance(value, str) else value[0]
0110 collection = key[:-len('__CellIDEncoding')]
0111 if collection in layouts and layouts[collection] != description:
0112 raise ValueError('Conflicting stored encoding: '+collection)
0113 layouts[collection] = description
0114 return layouts
0115
0116
0117 def _column_name(field):
0118 names = {'cellID': 'cell_id', 'eDep': 'energy_deposit_GeV',
0119 'pathLength': 'path_length_mm', 'time': 'time_ns',
0120 'PDG': 'pdg', 'generatorStatus': 'generator_status',
0121 'simulatorStatus': 'simulator_status', 'mass': 'mass_GeV', 'charge': 'charge_e'}
0122 for axis in 'xyz':
0123 names['position.'+axis] = axis+'_mm'
0124 names['momentum.'+axis] = 'p'+axis+'_GeV'
0125 names['vertex.'+axis] = 'vertex_'+axis+'_mm'
0126 names['endpoint.'+axis] = 'endpoint_'+axis+'_mm'
0127 names['momentumAtEndpoint.'+axis] = 'endpoint_p'+axis+'_GeV'
0128 return names.get(field, field.replace('.', '_'))
0129
0130
0131 def _surface_table(hits):
0132 """Catalogue observed detector layers; unhit geometry is not inferred."""
0133 if hits.empty:
0134 return pd.DataFrame(columns=SURFACE_KEY+['n_hits', 'n_particles'])
0135 grouped = hits.groupby(SURFACE_KEY, observed=True, dropna=False)
0136 counts = grouped.size().rename('n_hits')
0137 particles = hits.drop_duplicates(SURFACE_KEY+KEY).groupby(SURFACE_KEY, observed=True, dropna=False).size()
0138 return pd.concat([counts, particles.rename('n_particles')], axis=1).reset_index()
0139
0140
0141 def iter_mc_tracking_hits(filename, *, collections=None, primary_only=True, pdgs=None,
0142 eta_range=None, p_range=None, encodings=None, stop=None, step_size=5000):
0143 """Yield dicts of linked DataFrames, reading each selected branch once/block.
0144
0145 MC selections use generatorStatus==1 when primary_only=True, optional exact
0146 signed PDG codes, and half-open [low,high) eta / |p| [GeV] ranges. All hits
0147 directly linked to those particles are returned; there are no hit/fit cuts.
0148 Every stored scalar SimTrackerHit field is retained, plus decoded CellID
0149 fields. MC parent/daughter relation arrays are not materialized.
0150 """
0151 if step_size < 1 or (stop is not None and stop < 1):
0152 raise ValueError('step_size and stop must be positive')
0153 for bounds in (eta_range, p_range):
0154 if bounds is not None and (len(bounds) != 2 or np.any(np.isnan(bounds)) or bounds[0] >= bounds[1]):
0155 raise ValueError('Selection ranges must be increasing [low,high) pairs')
0156 pdgs = None if pdgs is None else tuple(int(value) for value in np.atleast_1d(pdgs))
0157 source = Path(filename).resolve()
0158 with uproot.open(source, array_cache=None) as root:
0159 tree = root['events']
0160 info = root['podio_metadata']['events___CollectionTypeInfo'].array()[0]
0161 catalogue = {name: {'collection_id': int(cid), 'type': kind, 'subset': bool(subset)}
0162 for name, cid, kind, subset in zip(_field(info, 'name'), _field(info, 'collectionID'),
0163 _field(info, 'dataType'), _field(info, 'isSubset'))}
0164 available = [name for name, row in catalogue.items()
0165 if row['type'] == 'edm4hep::SimTrackerHitCollection' and not row['subset']]
0166 selected = available if collections is None else ([collections] if isinstance(collections, str) else list(collections))
0167 if not selected or len(set(selected)) != len(selected) or any(name not in available for name in selected):
0168 raise ValueError(f'Choose distinct owning SimTrackerHit collections from {available}')
0169 layouts = _frame_encodings(root)
0170 for name, description in (encodings or {}).items():
0171 if name in layouts and _cell_fields(layouts[name]) != _cell_fields(description):
0172 raise ValueError('Provided/stored encoding mismatch: '+name)
0173 layouts[name] = description
0174 for name in selected:
0175 if name not in layouts:
0176 raise ValueError(f'No CellID encoding for {name}; pass encodings from the matching geometry')
0177 _cell_fields(layouts[name])
0178 file_id = str(root.file.uuid)
0179 mc_id = catalogue['MCParticles']['collection_id']
0180 branch_maps = {name: {key.removeprefix(name+'.'): tree[name][key] for key in tree[name].keys()}
0181 for name in ['MCParticles', *selected, *['_'+name+'_particle' for name in selected]]}
0182 stop_entry = tree.num_entries if stop is None else min(stop, tree.num_entries)
0183 metadata = {'source': str(source), 'file_id': file_id, 'source_bytes': source.stat().st_size,
0184 'source_mtime_ns': source.stat().st_mtime_ns, 'entries': tree.num_entries,
0185 'collections': {name: catalogue[name] for name in ['MCParticles', *selected]},
0186 'encodings': {name: layouts[name] for name in selected},
0187 'selection': {'primary_only': primary_only, 'pdgs': None if pdgs is None else list(pdgs),
0188 'eta_range': eta_range, 'p_range_GeV': p_range},
0189 'units': 'momenta/energy GeV; positions/path length mm; times ns; angles rad',
0190 'particle_key': KEY, 'surface_key': SURFACE_KEY,
0191 'hit_fields': {name: {_column_name(field): field for field in branch_maps[name]} for name in selected}}
0192
0193 def read(name, field):
0194 return branch_maps[name][field].array(entry_start=start, entry_stop=end)
0195
0196 for start in range(0, stop_entry, step_size):
0197 end = min(start+step_size, stop_entry)
0198 pdg = read('MCParticles', 'PDG')
0199 sizes = ak.to_numpy(ak.num(pdg))
0200 offsets = np.r_[0, np.cumsum(sizes)]
0201 mc_index = ak.to_numpy(ak.flatten(ak.local_index(pdg)))
0202 mc_events = np.repeat(np.arange(end-start), sizes)
0203 mc = {_column_name(field): ak.to_numpy(ak.flatten(pdg if field == 'PDG' else read('MCParticles', field)))
0204 for field in branch_maps['MCParticles'] if not field.endswith(('_begin', '_end'))}
0205 p = np.sqrt(sum(mc['p'+axis+'_GeV'].astype(float)**2 for axis in 'xyz'))
0206 with np.errstate(divide='ignore', invalid='ignore'):
0207 eta = np.arcsinh(mc['pz_GeV']/np.hypot(mc['px_GeV'], mc['py_GeV']))
0208 keep_mc = (mc['generator_status'] == 1) if primary_only else np.ones(len(p), bool)
0209 if pdgs is not None:
0210 keep_mc &= np.isin(mc['pdg'], list(pdgs))
0211 for values, bounds in ((eta, eta_range), (p, p_range)):
0212 if bounds is not None:
0213 keep_mc &= (values >= bounds[0]) & (values < bounds[1])
0214 particles = pd.DataFrame({name: values[keep_mc] for name, values in mc.items()})
0215 particles['file_id'] = pd.Categorical([file_id]*len(particles), categories=[file_id])
0216 particles['event_entry'] = (mc_events+start)[keep_mc]
0217 particles['mc_index'] = mc_index[keep_mc]
0218 particles['mc_collection_id'] = np.full(len(particles), mc_id, dtype=np.uint32)
0219 particles['p_GeV'], particles['eta'] = p[keep_mc], eta[keep_mc]
0220 particles['phi_rad'] = np.arctan2(mc['py_GeV'], mc['px_GeV'])[keep_mc]
0221 counts = np.zeros(len(p), dtype=np.int64)
0222 frames = []
0223 for name in selected:
0224 cells = read(name, 'cellID')
0225 hit_sizes = ak.to_numpy(ak.num(cells))
0226 events = np.repeat(np.arange(end-start), hit_sizes)
0227 relation = '_'+name+'_particle'
0228 target_index, target_id = read(relation, 'index'), read(relation, 'collectionID')
0229 if not (np.array_equal(ak.num(target_index), hit_sizes) and np.array_equal(ak.num(target_id), hit_sizes)):
0230 raise ValueError('Hit/reference counts differ: '+name)
0231 indices, ids = ak.to_numpy(ak.flatten(target_index)), ak.to_numpy(ak.flatten(target_id))
0232 if np.any(ids != mc_id) or np.any((indices < 0) | (indices >= sizes[events])):
0233 raise ValueError('Invalid hit -> MCParticle ObjectID: '+name)
0234 target = offsets[events]+indices
0235 keep = keep_mc[target]
0236 counts += np.bincount(target[keep], minlength=len(p))
0237 data = {}
0238 for field in branch_maps[name]:
0239 values = cells if field == 'cellID' else read(name, field)
0240 if not np.array_equal(ak.num(values), hit_sizes):
0241 raise ValueError('Non-scalar hit attribute or inconsistent counts: '+name+'.'+field)
0242 data[_column_name(field)] = ak.to_numpy(ak.flatten(values))[keep]
0243 hits = pd.DataFrame(data)
0244 hits['cell_id'] = hits.cell_id.astype(np.uint64)
0245 hits['file_id'] = pd.Categorical([file_id]*len(hits), categories=[file_id])
0246 hits['event_entry'] = events[keep]+start
0247 hits['mc_index'] = indices[keep]
0248 hits['mc_collection_id'] = ids[keep].astype(np.uint32)
0249 hits['hit_collection'] = pd.Categorical([name]*len(hits), categories=selected)
0250 hits['hit_collection_id'] = np.full(len(hits), catalogue[name]['collection_id'], dtype=np.uint32)
0251 hits['hit_index'] = ak.to_numpy(ak.flatten(ak.local_index(cells)))[keep]
0252 for field, values in decode_cell_ids(hits.cell_id.to_numpy(), layouts[name]).items():
0253 hits[field if field in ('system', 'layer') else 'cell_'+field] = values
0254 for field in ('system', 'layer'):
0255 if field not in hits:
0256 hits[field] = pd.array([pd.NA]*len(hits), dtype='UInt64')
0257 frames.append(hits)
0258 particles['n_hits'] = counts[keep_mc]
0259 hits = pd.concat(frames, ignore_index=True)
0260 yield {'particles': particles, 'hits': hits, 'surfaces': _surface_table(hits),
0261 'metadata': dict(metadata, entry_range=[start, end])}
0262
0263
0264 def read_mc_tracking_hits(filename, **options):
0265 """Return tables for one file/prefix. Use the iterator for bounded-memory batches."""
0266 blocks = list(iter_mc_tracking_hits(filename, **options))
0267 if not blocks:
0268 raise ValueError('Input has no event entries')
0269 particles = pd.concat([block['particles'] for block in blocks], ignore_index=True)
0270 hits = pd.concat([block['hits'] for block in blocks], ignore_index=True)
0271 metadata = dict(blocks[0]['metadata'], entry_range=[0, blocks[-1]['metadata']['entry_range'][1]])
0272 return {'particles': particles, 'hits': hits, 'surfaces': _surface_table(hits), 'metadata': metadata}