File indexing completed on 2026-10-27 08:41:17
0001 import logging
0002 import math
0003 from collections import defaultdict
0004
0005 import awkward as ak
0006
0007 from pyrobird.dex import make_dex, PIECE_VERSION
0008 from pyrobird.mc_particles import (mc_particles_to_trajectories,
0009 DEFAULT_MC_STEP_TIME, DEFAULT_MC_MAX_POINTS)
0010
0011 """
0012 Converts EDM4hep simulation output (ddsim / DD4hep) to Firebird DEX format.
0013
0014 Supported collections:
0015 vector<edm4hep::SimTrackerHitData> - simulated tracker hits, converted to BoxHit groups
0016 vector<edm4hep::MCParticleData> - MC particles, used to build MC-truth trajectories
0017 connecting the sim hits of each particle
0018 ('mc_trajectories'), and straight vertex->endpoint
0019 lines for every particle ('mc_particles',
0020 see pyrobird.mc_particles)
0021
0022 SimTrackerHitData branch layout (edm4hep >= 0.99; older versions use 'EDep' instead of 'eDep'
0023 and name the particle relation '_<Collection>_MCParticle' instead of '_<Collection>_particle'):
0024 'SiBarrelHits': 'vector<edm4hep::SimTrackerHitData>',
0025 'SiBarrelHits/SiBarrelHits.cellID': 'uint64_t[]',
0026 'SiBarrelHits/SiBarrelHits.eDep': 'float[]',
0027 'SiBarrelHits/SiBarrelHits.time': 'float[]',
0028 'SiBarrelHits/SiBarrelHits.pathLength': 'float[]',
0029 'SiBarrelHits/SiBarrelHits.quality': 'int32_t[]',
0030 'SiBarrelHits/SiBarrelHits.position.x': 'double[]',
0031 'SiBarrelHits/SiBarrelHits.position.y': 'double[]',
0032 'SiBarrelHits/SiBarrelHits.position.z': 'double[]',
0033 'SiBarrelHits/SiBarrelHits.momentum.x': 'float[]',
0034 'SiBarrelHits/SiBarrelHits.momentum.y': 'float[]',
0035 'SiBarrelHits/SiBarrelHits.momentum.z': 'float[]',
0036 '_SiBarrelHits_particle': 'vector<podio::ObjectID>',
0037 '_SiBarrelHits_particle/_SiBarrelHits_particle.index': 'int32_t[]',
0038 '_SiBarrelHits_particle/_SiBarrelHits_particle.collectionID': 'uint32_t[]',
0039
0040 Sim hits carry no positionError/timeError/edepError, so BoxHit dimensions use a fixed,
0041 user-configurable box size and errors are written as 0.
0042 """
0043
0044 logger = logging.getLogger(__name__)
0045
0046
0047 DEFAULT_HIT_BOX_SIZE = 2.0
0048
0049
0050
0051
0052
0053 DEFAULT_TRAJECTORY_EXCLUDED_COLLECTIONS = ("DIRCBarHits", "DRICHHits", "PFRICHHits")
0054
0055
0056 def get_sim_hit_branches(tree):
0057 """Returns names of all vector<edm4hep::SimTrackerHitData> branches in the tree"""
0058 typenames = tree.typenames(recursive=False, full_paths=True,
0059 filter_typename="vector<edm4hep::SimTrackerHitData>")
0060 return list(typenames.keys())
0061
0062
0063 def detect_file_type(tree):
0064 """
0065 Detects the data model of an opened 'events' tree by its branch types.
0066
0067 Returns 'edm4eic' if reconstructed edm4eic::TrackerHitData collections are present
0068 (eicrecon files also contain sim hits, reconstruction wins), 'edm4hep' if only
0069 edm4hep::SimTrackerHitData collections are present.
0070
0071 Raises
0072 ------
0073 ValueError
0074 If neither collection type is found in the tree.
0075 """
0076 if tree.typenames(recursive=False, full_paths=True, filter_typename="vector<edm4eic::TrackerHitData>"):
0077 return "edm4eic"
0078 if tree.typenames(recursive=False, full_paths=True, filter_typename="vector<edm4hep::SimTrackerHitData>"):
0079 return "edm4hep"
0080 raise ValueError("Cannot detect file type: no 'vector<edm4eic::TrackerHitData>' or "
0081 "'vector<edm4hep::SimTrackerHitData>' branches found in 'events' tree")
0082
0083
0084 def _get_field_array(tree, full_branch, entry_start, entry_stop):
0085 """Reads a jagged field branch and returns it flattened as a python list"""
0086 return ak.flatten(tree[full_branch].array(entry_start=entry_start, entry_stop=entry_stop)).to_list()
0087
0088
0089 def _find_particle_relation_branch(tree, branch_name):
0090 """Finds the hit->MCParticle relation branch name, or None if missing.
0091
0092 edm4hep >= 0.99 names it '_<Collection>_particle', older versions '_<Collection>_MCParticle'.
0093 """
0094 keys = set(tree.keys(recursive=False))
0095 for suffix in ("particle", "MCParticle"):
0096 candidate = f"_{branch_name}_{suffix}"
0097 if candidate in keys:
0098 return candidate
0099 return None
0100
0101
0102 def _find_edep_field(tree, branch_name):
0103 """Returns the energy deposit field name: 'eDep' (edm4hep >= 0.99) or 'EDep' (older)"""
0104 keys = set(tree[branch_name].keys())
0105 for field in ("eDep", "EDep"):
0106 if f"{branch_name}.{field}" in keys:
0107 return field
0108 return None
0109
0110
0111 def _get_mc_collection_id(tree, mc_branch):
0112 """
0113 Looks up the podio collectionID of `mc_branch` from the file's podio_metadata tree.
0114 Returns None if the metadata is unavailable (e.g. trimmed test files).
0115 """
0116 try:
0117 metadata_tree = tree.file["podio_metadata"]
0118 info_branch = "events___CollectionTypeInfo"
0119 ids = metadata_tree[f"{info_branch}/{info_branch}.collectionID"].array().tolist()[0]
0120 names = metadata_tree[f"{info_branch}/{info_branch}.name"].array().tolist()[0]
0121 for coll_id, name in zip(ids, names):
0122 if name == mc_branch:
0123 return coll_id
0124 except Exception as ex:
0125 logger.debug(f"podio_metadata not available for collectionID check: {ex}")
0126 return None
0127
0128
0129 def sim_tracker_hits_to_box_hits(tree, branch_name, entry_start, entry_stop=None,
0130 box_size=DEFAULT_HIT_BOX_SIZE):
0131 """Converts vector<edm4hep::SimTrackerHitData> to BoxHit format dictionary.
0132
0133 Sim hits have no positionError, so all boxes use a fixed `box_size` [mm] cube
0134 and time/energy errors are written as 0.
0135 """
0136
0137
0138 if entry_stop is None:
0139 entry_stop = entry_start + 1
0140
0141 def get_field_array(field):
0142 return _get_field_array(tree, f"{branch_name}/{branch_name}.{field}", entry_start, entry_stop)
0143
0144 pos_x = get_field_array("position.x")
0145 pos_y = get_field_array("position.y")
0146 pos_z = get_field_array("position.z")
0147 time = get_field_array("time")
0148
0149 edep_field = _find_edep_field(tree, branch_name)
0150 if edep_field:
0151 edep = get_field_array(edep_field)
0152 else:
0153 logger.warning(f"No eDep/EDep field found in '{branch_name}', writing 0 energy deposit")
0154 edep = [0.0] * len(pos_x)
0155
0156
0157
0158 count = len(pos_x)
0159 pos = []
0160 for i in range(count):
0161 pos.extend((pos_x[i], pos_y[i], pos_z[i]))
0162
0163 piece = {
0164 "name": branch_name,
0165 "type": "BoxHit",
0166 "version": PIECE_VERSION,
0167 "origin": {"type": "edm4hep::SimTrackerHitData", "name": branch_name},
0168 "count": count,
0169 "columns": {
0170 "pos": pos,
0171 "dim": [box_size] * (3 * count),
0172 "time": time,
0173 "edep": edep,
0174 },
0175 }
0176 return piece
0177
0178
0179 def sim_hits_to_trajectories(tree, hit_branch_names, entry_start, entry_stop=None,
0180 mc_branch="MCParticles", min_hits=2,
0181 prepend_vertex=True, append_endpoint=False):
0182 """
0183 Builds MC-truth trajectories connecting edm4hep::SimTrackerHitData hits.
0184
0185 Hits from all `hit_branch_names` collections are pooled, grouped by their associated
0186 MCParticle and sorted by time. Each particle with at least `min_hits` hits becomes one
0187 trajectory in a single 'PointTrajectory' group.
0188
0189 Parameters
0190 ----------
0191 tree - uproot 'events' tree
0192 hit_branch_names - SimTrackerHitData collection names to pool hits from
0193 entry_start - first entry to process
0194 entry_stop - one past last entry (defaults to entry_start + 1, i.e. a single event)
0195 mc_branch - name of the MCParticle collection the hit relations point to
0196 min_hits - particles with fewer hits are skipped (their hits stay visible as boxes)
0197 prepend_vertex - add MCParticles.vertex (with the particle creation time) as point 0
0198 append_endpoint - add MCParticles.endpoint as the last point; edm4hep stores no endpoint
0199 time, so the point reuses the last hit's time
0200 """
0201 if entry_stop is None:
0202 entry_stop = entry_start + 1
0203
0204
0205
0206 result = {
0207 "name": "McHitTrajectories",
0208 "type": "PointTrajectory",
0209 "version": PIECE_VERSION,
0210 "origin": {"type": ["edm4hep::SimTrackerHitData", "edm4hep::MCParticleData"],
0211 "collections": list(hit_branch_names)},
0212 "count": 0,
0213 "columns": {},
0214 "pointColumns": ["x", "y", "z", "t", "dx", "dy", "dz", "dt"],
0215 "points": []
0216 }
0217
0218 if mc_branch not in tree.keys(recursive=False):
0219 logger.warning(f"'{mc_branch}' collection not found, no trajectories are built")
0220 return result
0221
0222 def get_mc_field(field):
0223 return _get_field_array(tree, f"{mc_branch}/{mc_branch}.{field}", entry_start, entry_stop)
0224
0225 mc_pdg = get_mc_field("PDG")
0226 mc_charge = get_mc_field("charge")
0227 mc_time = get_mc_field("time")
0228 mc_mom_x = get_mc_field("momentum.x")
0229 mc_mom_y = get_mc_field("momentum.y")
0230 mc_mom_z = get_mc_field("momentum.z")
0231 mc_vtx_x = get_mc_field("vertex.x")
0232 mc_vtx_y = get_mc_field("vertex.y")
0233 mc_vtx_z = get_mc_field("vertex.z")
0234 mc_end_x = get_mc_field("endpoint.x") if append_endpoint else []
0235 mc_end_y = get_mc_field("endpoint.y") if append_endpoint else []
0236 mc_end_z = get_mc_field("endpoint.z") if append_endpoint else []
0237
0238
0239
0240
0241 mc_collection_id = _get_mc_collection_id(tree, mc_branch)
0242
0243
0244
0245 hits_by_particle = defaultdict(list)
0246 for branch_name in hit_branch_names:
0247 relation_branch = _find_particle_relation_branch(tree, branch_name)
0248 if relation_branch is None:
0249 logger.warning(f"No MCParticle relation branch found for '{branch_name}', "
0250 f"its hits are skipped in trajectory building")
0251 continue
0252
0253 def get_field_array(field):
0254 return _get_field_array(tree, f"{branch_name}/{branch_name}.{field}", entry_start, entry_stop)
0255
0256 pos_x = get_field_array("position.x")
0257 pos_y = get_field_array("position.y")
0258 pos_z = get_field_array("position.z")
0259 time = get_field_array("time")
0260 mc_index = _get_field_array(tree, f"{relation_branch}/{relation_branch}.index", entry_start, entry_stop)
0261 coll_ids = _get_field_array(tree, f"{relation_branch}/{relation_branch}.collectionID", entry_start, entry_stop)
0262
0263 for i in range(len(pos_x)):
0264 index = mc_index[i]
0265 if index < 0 or index >= len(mc_pdg):
0266 continue
0267 if mc_collection_id is not None and coll_ids[i] != mc_collection_id:
0268 logger.warning(f"Hit in '{branch_name}' points to an unexpected MCParticle "
0269 f"collection (collectionID={coll_ids[i]}), skipping it")
0270 continue
0271 hits_by_particle[index].append((time[i], pos_x[i], pos_y[i], pos_z[i]))
0272
0273 columns = {name: [] for name in ("pdg", "charge", "px", "py", "pz", "p", "mc_index")}
0274 all_points = []
0275 for index in sorted(hits_by_particle.keys()):
0276 particle_hits = hits_by_particle[index]
0277 if len(particle_hits) < min_hits:
0278 continue
0279 particle_hits.sort(key=lambda hit: hit[0])
0280
0281
0282 points = []
0283 if prepend_vertex:
0284 points.append([mc_vtx_x[index], mc_vtx_y[index], mc_vtx_z[index], mc_time[index], 0, 0, 0, 0])
0285 for hit_time, x, y, z in particle_hits:
0286 points.append([x, y, z, hit_time, 0, 0, 0, 0])
0287 if append_endpoint:
0288
0289 last_time = particle_hits[-1][0]
0290 points.append([mc_end_x[index], mc_end_y[index], mc_end_z[index], last_time, 0, 0, 0, 0])
0291
0292 momentum = math.sqrt(mc_mom_x[index] ** 2 + mc_mom_y[index] ** 2 + mc_mom_z[index] ** 2)
0293 columns["pdg"].append(mc_pdg[index])
0294 columns["charge"].append(mc_charge[index])
0295 columns["px"].append(mc_mom_x[index])
0296 columns["py"].append(mc_mom_y[index])
0297 columns["pz"].append(mc_mom_z[index])
0298 columns["p"].append(momentum)
0299 columns["mc_index"].append(index)
0300 all_points.append(points)
0301
0302 result["count"] = len(all_points)
0303 result["columns"] = columns if all_points else {}
0304 result["points"] = all_points
0305 return result
0306
0307
0308 def edm4hep_entry_to_dict(tree, entry_index, custom_name=None, collections=None,
0309 box_size=DEFAULT_HIT_BOX_SIZE,
0310 prepend_vertex=True, append_endpoint=False,
0311 trajectory_excluded_collections=DEFAULT_TRAJECTORY_EXCLUDED_COLLECTIONS,
0312 mc_step_time=DEFAULT_MC_STEP_TIME, mc_max_points=DEFAULT_MC_MAX_POINTS):
0313
0314 components = []
0315
0316 if not collections:
0317 collections = [
0318 "tracker_hits",
0319 "mc_trajectories",
0320 "mc_particles",
0321 ]
0322
0323 hit_branches = get_sim_hit_branches(tree)
0324
0325
0326 if "tracker_hits" in collections:
0327 for branch_name in hit_branches:
0328 piece = sim_tracker_hits_to_box_hits(tree, branch_name, entry_index, box_size=box_size)
0329
0330 if piece["count"] > 0:
0331 components.append(piece)
0332
0333
0334 if "mc_trajectories" in collections:
0335 trajectory_branches = [b for b in hit_branches if b not in trajectory_excluded_collections]
0336 trajectory_piece = sim_hits_to_trajectories(tree, trajectory_branches, entry_index,
0337 prepend_vertex=prepend_vertex,
0338 append_endpoint=append_endpoint)
0339 if trajectory_piece["count"] > 0:
0340 components.append(trajectory_piece)
0341
0342
0343 if "mc_particles" in collections:
0344 mc_piece = mc_particles_to_trajectories(tree, entry_index,
0345 step_time=mc_step_time, max_points=mc_max_points)
0346 if mc_piece["count"] > 0:
0347 components.append(mc_piece)
0348
0349 entry = {
0350 "id": custom_name if custom_name else entry_index,
0351 "pieces": components
0352 }
0353
0354 return entry
0355
0356
0357 def edm4hep_to_dex_dict(tree, event_ids, origin_info=None, collections=None,
0358 box_size=DEFAULT_HIT_BOX_SIZE,
0359 prepend_vertex=True, append_endpoint=False,
0360 trajectory_excluded_collections=DEFAULT_TRAJECTORY_EXCLUDED_COLLECTIONS,
0361 mc_step_time=DEFAULT_MC_STEP_TIME, mc_max_points=DEFAULT_MC_MAX_POINTS):
0362 event_data = []
0363
0364 if isinstance(event_ids, int):
0365 event_ids = [event_ids]
0366
0367 for entry_id in event_ids:
0368 event_data.append(edm4hep_entry_to_dict(
0369 tree, entry_id, custom_name=None, collections=collections,
0370 box_size=box_size, prepend_vertex=prepend_vertex, append_endpoint=append_endpoint,
0371 trajectory_excluded_collections=trajectory_excluded_collections,
0372 mc_step_time=mc_step_time, mc_max_points=mc_max_points))
0373
0374 return make_dex(event_data, origin_info)