Back to home page

EIC code displayed by LXR

 
 

    


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         # Nullable integers keep exact values when different readouts have
0086         # different fields and their tables are concatenated (no float casts).
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}