Back to home page

EIC code displayed by LXR

 
 

    


Warning, file /snippets/Tracking/BackgroundStudy/extract_generator_hepmc.py was not indexed or was modified since last indexation (in which case cross-reference links may be missing, inaccurate or erroneous).

0001 #!/usr/bin/env python3
0002 """Recover generator particles from an EDM4hep npsim output file.
0003 Shujie Li with Codex, 2026.
0004 
0005 The input file contains both the original generator record and particles
0006 created later by Geant4 interactions in detector material.  This script keeps
0007 only MCParticles for which BITCreatedInSimulation (simulatorStatus bit 30) is
0008 not set, rebuilds their parent/daughter graph, and writes a HepMC3 ASCII file
0009 that can be used as a new npsim input.
0010 
0011 Dependencies: numpy, awkward, uproot, pyhepmc. No local modules or detector
0012 geometry are needed. Input positions/time are mm/ns; momentum/mass are GeV.
0013 
0014 Example:
0015     python extract_generator_hepmc.py \
0016         hepmc/edm4hep/sample.edm4hep.root \
0017         hepmc/sample.hepmc3
0018 """
0019 
0020 import argparse
0021 import math
0022 from pathlib import Path
0023 import sys
0024 
0025 import awkward as ak
0026 import numpy as np
0027 import pyhepmc
0028 import uproot
0029 
0030 
0031 CREATED_IN_SIMULATION_BIT = np.uint32(1 << 30)
0032 SPEED_OF_LIGHT_MM_PER_NS = 299.792458
0033 
0034 BRANCHES = {
0035     "event_number": "EventHeader/EventHeader.eventNumber",
0036     "pdg": "MCParticles/MCParticles.PDG",
0037     "generator_status": "MCParticles/MCParticles.generatorStatus",
0038     "simulator_status": "MCParticles/MCParticles.simulatorStatus",
0039     "mass": "MCParticles/MCParticles.mass",
0040     "time": "MCParticles/MCParticles.time",
0041     "vertex_x": "MCParticles/MCParticles.vertex.x",
0042     "vertex_y": "MCParticles/MCParticles.vertex.y",
0043     "vertex_z": "MCParticles/MCParticles.vertex.z",
0044     "endpoint_x": "MCParticles/MCParticles.endpoint.x",
0045     "endpoint_y": "MCParticles/MCParticles.endpoint.y",
0046     "endpoint_z": "MCParticles/MCParticles.endpoint.z",
0047     "momentum_x": "MCParticles/MCParticles.momentum.x",
0048     "momentum_y": "MCParticles/MCParticles.momentum.y",
0049     "momentum_z": "MCParticles/MCParticles.momentum.z",
0050     "parents_begin": "MCParticles/MCParticles.parents_begin",
0051     "parents_end": "MCParticles/MCParticles.parents_end",
0052     "daughters_begin": "MCParticles/MCParticles.daughters_begin",
0053     "daughters_end": "MCParticles/MCParticles.daughters_end",
0054     "parent_index": "_MCParticles_parents/_MCParticles_parents.index",
0055     "daughter_index": "_MCParticles_daughters/_MCParticles_daughters.index",
0056 }
0057 
0058 
0059 def event_array(arrays, branch_name):
0060     """Return one event's jagged branch as a NumPy array."""
0061     return np.asarray(ak.to_numpy(arrays[branch_name][0]))
0062 
0063 
0064 def source_relations(begin, end, relation_indices, source_indices, source_set):
0065     """Return retained relation indices for each retained particle."""
0066     relations = {}
0067     number_of_particles = len(begin)
0068 
0069     for particle_index in source_indices:
0070         relation_begin = int(begin[particle_index])
0071         relation_end = int(end[particle_index])
0072         if not 0 <= relation_begin <= relation_end <= len(relation_indices):
0073             raise ValueError(
0074                 f"particle {particle_index} has an invalid relation range "
0075                 f"[{relation_begin}, {relation_end})"
0076             )
0077 
0078         retained_relations = []
0079         for related_index in relation_indices[relation_begin:relation_end]:
0080             related_index = int(related_index)
0081             if not 0 <= related_index < number_of_particles:
0082                 raise ValueError(
0083                     f"particle {particle_index} refers to invalid particle "
0084                     f"index {related_index}"
0085                 )
0086             if related_index in source_set:
0087                 if related_index == particle_index:
0088                     raise ValueError(f"particle {particle_index} refers to itself")
0089                 retained_relations.append(related_index)
0090 
0091         if len(set(retained_relations)) != len(retained_relations):
0092             raise ValueError(f"particle {particle_index} has duplicate relations")
0093         # Relation order has no physical meaning. Canonical ordering ensures
0094         # children with the same parents are grouped into the same vertex.
0095         relations[int(particle_index)] = tuple(sorted(retained_relations))
0096 
0097     return relations
0098 
0099 
0100 def build_hepmc_event(arrays, entry_number):
0101     """Convert one uproot event into a source-only HepMC3 GenEvent."""
0102     pdg = event_array(arrays, BRANCHES["pdg"])
0103     generator_status = event_array(arrays, BRANCHES["generator_status"])
0104     simulator_status = event_array(arrays, BRANCHES["simulator_status"])
0105     mass = event_array(arrays, BRANCHES["mass"])
0106     time = event_array(arrays, BRANCHES["time"])
0107     vertex_x = event_array(arrays, BRANCHES["vertex_x"])
0108     vertex_y = event_array(arrays, BRANCHES["vertex_y"])
0109     vertex_z = event_array(arrays, BRANCHES["vertex_z"])
0110     endpoint_x = event_array(arrays, BRANCHES["endpoint_x"])
0111     endpoint_y = event_array(arrays, BRANCHES["endpoint_y"])
0112     endpoint_z = event_array(arrays, BRANCHES["endpoint_z"])
0113     momentum_x = event_array(arrays, BRANCHES["momentum_x"])
0114     momentum_y = event_array(arrays, BRANCHES["momentum_y"])
0115     momentum_z = event_array(arrays, BRANCHES["momentum_z"])
0116 
0117     # Bit 30 marks particles made by detector simulation.  uint32 avoids
0118     # signed-integer surprises when testing the high status bit.
0119     source_mask = (
0120         simulator_status.astype(np.uint32) & CREATED_IN_SIMULATION_BIT
0121     ) == 0
0122     source_indices = np.flatnonzero(source_mask)
0123     source_set = set(source_indices.tolist())
0124 
0125     parents = source_relations(
0126         event_array(arrays, BRANCHES["parents_begin"]),
0127         event_array(arrays, BRANCHES["parents_end"]),
0128         event_array(arrays, BRANCHES["parent_index"]),
0129         source_indices,
0130         source_set,
0131     )
0132     daughters = source_relations(
0133         event_array(arrays, BRANCHES["daughters_begin"]),
0134         event_array(arrays, BRANCHES["daughters_end"]),
0135         event_array(arrays, BRANCHES["daughter_index"]),
0136         source_indices,
0137         source_set,
0138     )
0139 
0140     # A valid HepMC graph records each relation in both directions.  Stop on
0141     # inconsistent input rather than silently producing a different event.
0142     for child_index in source_indices:
0143         for parent_index in parents[int(child_index)]:
0144             if int(child_index) not in daughters[parent_index]:
0145                 raise ValueError(
0146                     f"parent/daughter mismatch between particles "
0147                     f"{parent_index} and {child_index}"
0148                 )
0149         for daughter_index in daughters[int(child_index)]:
0150             if int(child_index) not in parents[daughter_index]:
0151                 raise ValueError(
0152                     f"parent/daughter mismatch between particles "
0153                     f"{child_index} and {daughter_index}"
0154                 )
0155 
0156     event_number_array = event_array(arrays, BRANCHES["event_number"])
0157     event_number = (
0158         int(event_number_array[0]) if len(event_number_array) else entry_number
0159     )
0160     event = pyhepmc.GenEvent(pyhepmc.Units.GEV, pyhepmc.Units.MM)
0161     event.event_number = event_number
0162 
0163     # Keep the original MCParticle order.  This makes direct comparisons to
0164     # the EDM4hep source subset straightforward.
0165     particles = {}
0166     for particle_index in source_indices:
0167         particle_index = int(particle_index)
0168         px = float(momentum_x[particle_index])
0169         py = float(momentum_y[particle_index])
0170         pz = float(momentum_z[particle_index])
0171         particle_mass = float(mass[particle_index])
0172         energy = math.sqrt(
0173             max(0.0, px * px + py * py + pz * pz + particle_mass * particle_mass)
0174         )
0175 
0176         particle = pyhepmc.GenParticle(
0177             pyhepmc.FourVector(px, py, pz, energy),
0178             int(pdg[particle_index]),
0179             int(generator_status[particle_index]),
0180         )
0181         particle.generated_mass = particle_mass
0182         event.add_particle(particle)
0183         particles[particle_index] = particle
0184 
0185     # Children with the same parent set share one HepMC interaction vertex.
0186     # Use the parent endpoint as the canonical vertex position. EDM4hep files
0187     # can store children from one vertex with sub-micron rounding differences
0188     # in their production positions.
0189     children_by_parent_set = {}
0190     for child_index in source_indices:
0191         child_index = int(child_index)
0192         parent_set = parents[child_index]
0193         if parent_set:
0194             children_by_parent_set.setdefault(parent_set, []).append(child_index)
0195 
0196     assigned_parents = set()
0197     for parent_set, child_indices in children_by_parent_set.items():
0198         if assigned_parents.intersection(parent_set):
0199             raise ValueError(
0200                 f"parents {parent_set} would belong to multiple end vertices"
0201             )
0202         assigned_parents.update(parent_set)
0203         first_parent = parent_set[0]
0204         parent_endpoint = np.array(
0205             [
0206                 float(endpoint_x[first_parent]),
0207                 float(endpoint_y[first_parent]),
0208                 float(endpoint_z[first_parent]),
0209             ]
0210         )
0211 
0212         # The child whose stored production position is closest to the parent
0213         # endpoint also supplies the vertex time. Other children can carry
0214         # small, correlated position/time rounding shifts.
0215         vertex_distances = [
0216             np.linalg.norm(
0217                 np.array(
0218                     [
0219                         float(vertex_x[child_index]),
0220                         float(vertex_y[child_index]),
0221                         float(vertex_z[child_index]),
0222                     ]
0223                 )
0224                 - parent_endpoint
0225             )
0226             for child_index in child_indices
0227         ]
0228         reference_child = child_indices[int(np.argmin(vertex_distances))]
0229         position = (
0230             float(parent_endpoint[0]),
0231             float(parent_endpoint[1]),
0232             float(parent_endpoint[2]),
0233             float(time[reference_child]) * SPEED_OF_LIGHT_MM_PER_NS,
0234         )
0235 
0236         # Multiple incoming parents must agree on their common endpoint.
0237         for parent_index in parent_set[1:]:
0238             other_parent_endpoint = (
0239                 float(endpoint_x[parent_index]),
0240                 float(endpoint_y[parent_index]),
0241                 float(endpoint_z[parent_index]),
0242             )
0243             if not np.allclose(
0244                 other_parent_endpoint, parent_endpoint, rtol=0.0, atol=1.0e-6
0245             ):
0246                 raise ValueError(
0247                     f"parents {parent_set} have different endpoint positions"
0248                 )
0249 
0250         vertex = pyhepmc.GenVertex(pyhepmc.FourVector(*position))
0251         event.add_vertex(vertex)
0252         for parent_index in parent_set:
0253             vertex.add_particle_in(particles[parent_index])
0254         for child_index in child_indices:
0255             vertex.add_particle_out(particles[child_index])
0256 
0257     # Leave parentless particles at HepMC's implicit root at (0, 0, 0, 0).
0258     # This extractor does not build explicit source vertices, so reject
0259     # nonzero source positions/times rather than silently discarding them.
0260     for particle_index in source_indices:
0261         particle_index = int(particle_index)
0262         if parents[particle_index]:
0263             continue
0264         position = (
0265             float(vertex_x[particle_index]),
0266             float(vertex_y[particle_index]),
0267             float(vertex_z[particle_index]),
0268             float(time[particle_index]) * SPEED_OF_LIGHT_MM_PER_NS,
0269         )
0270         if not np.allclose(position, 0.0, rtol=0.0, atol=1.0e-9):
0271             raise ValueError(
0272                 f"parentless particle {particle_index} has nonzero production "
0273                 f"position {position}; explicit source vertices are not "
0274                 "supported by this extractor"
0275             )
0276 
0277     return event, len(pdg), len(source_indices)
0278 
0279 
0280 def parse_arguments():
0281     parser = argparse.ArgumentParser(description=__doc__)
0282     parser.add_argument("input_file", type=Path, help="npsim EDM4hep ROOT file")
0283     parser.add_argument("output_file", type=Path, help="output HepMC3 ASCII file")
0284     parser.add_argument(
0285         "--max-events",
0286         type=int,
0287         default=None,
0288         help="extract only the first N events (useful for a smoke test)",
0289     )
0290     return parser.parse_args()
0291 
0292 
0293 def main():
0294     args = parse_arguments()
0295     input_file = args.input_file.resolve()
0296     output_file = args.output_file.resolve()
0297 
0298     if not input_file.is_file():
0299         raise FileNotFoundError(f"input file does not exist: {input_file}")
0300     if output_file.exists():
0301         raise FileExistsError(f"refusing to overwrite existing file: {output_file}")
0302     if not output_file.parent.is_dir():
0303         raise FileNotFoundError(
0304             f"output directory does not exist: {output_file.parent}"
0305         )
0306     if args.max_events is not None and args.max_events <= 0:
0307         raise ValueError("--max-events must be a positive integer")
0308 
0309     partial_file = output_file.with_name(output_file.name + ".partial")
0310     if partial_file.exists():
0311         raise FileExistsError(f"refusing to overwrite partial file: {partial_file}")
0312 
0313     # Every event is read once; disabling the array cache keeps memory bounded
0314     # by one event even for large background samples.
0315     with uproot.open(input_file, array_cache=None) as root_file:
0316         events = root_file["events"]
0317         number_of_events = events.num_entries
0318         if args.max_events is not None:
0319             number_of_events = min(number_of_events, args.max_events)
0320 
0321         total_particles = 0
0322         total_source_particles = 0
0323         # Reserve the partial filename exclusively. Publish the final filename
0324         # only after the writer closes successfully; retain failures for review.
0325         with partial_file.open("x"):
0326             pass
0327         try:
0328             with pyhepmc.open(partial_file, "w", format="hepmc3") as writer:
0329                 for entry_number in range(number_of_events):
0330                     arrays = events.arrays(
0331                         list(BRANCHES.values()),
0332                         entry_start=entry_number,
0333                         entry_stop=entry_number + 1,
0334                         library="ak",
0335                     )
0336                     event, particle_count, source_count = build_hepmc_event(
0337                         arrays, entry_number
0338                     )
0339                     writer.write(event)
0340                     total_particles += particle_count
0341                     total_source_particles += source_count
0342 
0343                     print(
0344                         f"\rExtracted event {entry_number + 1}/{number_of_events}",
0345                         end="",
0346                         flush=True,
0347                     )
0348             if output_file.exists():
0349                 raise FileExistsError(f"refusing to overwrite: {output_file}")
0350             partial_file.rename(output_file)
0351         except Exception:
0352             print(f"\nIncomplete output retained at {partial_file}", file=sys.stderr)
0353             raise
0354 
0355     removed_particles = total_particles - total_source_particles
0356     print(
0357         f"\nWrote {number_of_events} events and {total_source_particles:,} "
0358         f"source particles to {output_file}"
0359     )
0360     print(
0361         f"Excluded {removed_particles:,} particles with "
0362         "BITCreatedInSimulation set"
0363     )
0364 
0365 
0366 if __name__ == "__main__":
0367     main()