Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-09-22 08:04:16

0001 """Truth-cluster integrity diagnostics; particle selection is caller-owned.
0002 
0003 Association indices, not association row order, identify clusters and particles.
0004 Multiple links to the same cluster are deduplicated. Cluster energy is counted
0005 in full, not weighted by association weight: these are association diagnostics,
0006 not a decomposition of shared-cluster energy into particle contributions.
0007 
0008 The ecal_gaps workflow runs this analysis on reconstructed electron samples
0009 and reports zero/one/multiple associated truth clusters versus generated eta.
0010 Summed and largest associated-cluster energy responses and the largest-cluster
0011 fraction are shown separately, so fragmentation is not hidden by an energy sum.
0012 Numerical results are saved in JSON and per-sample NPZ files.
0013 
0014 The reference particle is MCParticles[0] only for these particle-gun samples.
0015 The association helper accepts an arbitrary MCParticles index, allowing future
0016 DIS callers to supply the selected scattered electron. Missing associations are
0017 retained as zero response. Outside a subsystem's acceptance, zero associations
0018 are expected and should not be interpreted as inefficiency.
0019 
0020 Backward/forward truth clusters and merged barrel truth clusters may use
0021 different algorithms. The barrel output is labelled EcalBarrel, not ScFi or
0022 imaging separately. These clean samples establish a baseline; they do not test
0023 whether fragmentation under beam-background overlay has been fixed. No
0024 truth-cluster position is used as a reference for angular resolution here.
0025 """
0026 
0027 import argparse
0028 import glob
0029 import json
0030 from pathlib import Path
0031 
0032 import awkward as ak
0033 import matplotlib
0034 import numpy as np
0035 import uproot
0036 
0037 matplotlib.use("Agg")
0038 import matplotlib.pyplot as plt
0039 
0040 
0041 def associated_cluster_energies(energies, rec_indices, sim_indices, particle_index):
0042     """Return distinct cluster energies associated with one MCParticles index.
0043 
0044     This function also accepts DIS scattered-electron indices selected by a
0045     caller. Invalid links fail explicitly rather than biasing the distributions.
0046     """
0047     if len(rec_indices) != len(sim_indices):
0048         raise ValueError("Association rec/sim arrays have different lengths")
0049     indices = sorted({r for r, s in zip(rec_indices, sim_indices) if s == particle_index})
0050     if any(r < 0 or r >= len(energies) for r in indices):
0051         raise ValueError("Association points outside the truth-cluster collection")
0052     return np.asarray([energies[r] for r in indices], dtype=float)
0053 
0054 
0055 def analyze(paths, subsystem):
0056     collection = f"{subsystem}TruthClusters"
0057     association = f"_{subsystem}TruthClusterAssociations"
0058     branches = ["MCParticles.momentum.*", f"{collection}.energy",
0059                 f"{association}_rec.index", f"{association}_sim.index"]
0060     records = []
0061     for events in uproot.iterate({p: "events" for p in paths},
0062                                  filter_name=branches, step_size="100 MB"):
0063         for event in events:
0064             # Only this selection is particle-gun-specific. DIS callers must
0065             # select a scattered electron instead of assuming MCParticles[0].
0066             particle_index = 0
0067             px, py, pz = [float(event[f"MCParticles.momentum.{c}"][particle_index])
0068                           for c in "xyz"]
0069             pt = np.hypot(px, py)
0070             momentum = np.hypot(pt, pz)
0071             if pt <= 0 or momentum <= 0:
0072                 continue
0073             energies = associated_cluster_energies(
0074                 ak.to_list(event[f"{collection}.energy"]),
0075                 ak.to_list(event[f"{association}_rec.index"]),
0076                 ak.to_list(event[f"{association}_sim.index"]), particle_index)
0077             total = float(np.sum(energies))
0078             largest = float(np.max(energies)) if len(energies) else 0.0
0079             records.append((np.arcsinh(pz / pt), len(energies), total / momentum,
0080                             largest / momentum, largest / total if total > 0 else np.nan))
0081     return np.asarray(records, dtype=float).reshape((-1, 5))
0082 
0083 
0084 def main():
0085     parser = argparse.ArgumentParser(description=__doc__)
0086     parser.add_argument("--detector-config", required=True)
0087     parser.add_argument("--output-dir", type=Path, required=True)
0088     args = parser.parse_args()
0089     args.output_dir.mkdir(parents=True, exist_ok=True)
0090     eta_edges = np.linspace(-4, 4, 41)
0091     centers = (eta_edges[:-1] + eta_edges[1:]) / 2
0092     results = {}
0093     for energy in ["500MeV", "5GeV", "20GeV"]:
0094         paths = sorted(glob.glob(
0095             f"sim_output/ecal_gaps/{args.detector_config}/e-/{energy}/*/*.eicrecon.edm4eic.root"))
0096         if not paths:
0097             raise RuntimeError(f"No reconstructed inputs for {energy}")
0098         for subsystem in ["EcalEndcapN", "EcalBarrel", "EcalEndcapP"]:
0099             data = analyze(paths, subsystem)
0100             key = f"{energy}_{subsystem}"
0101             np.savez_compressed(args.output_dir / f"truth_clusters_{key}.npz",
0102                                 eta=data[:, 0], multiplicity=data[:, 1],
0103                                 summed_response=data[:, 2], largest_response=data[:, 3],
0104                                 largest_fraction=data[:, 4], eta_edges=eta_edges)
0105             counts = np.histogram(data[:, 0], eta_edges)[0]
0106             fractions = []
0107             means = []
0108             for low, high in zip(eta_edges[:-1], eta_edges[1:]):
0109                 rows = data[(data[:, 0] >= low) & (data[:, 0] < high)]
0110                 fractions.append([float(np.mean(condition)) if len(rows) else np.nan
0111                                   for condition in [rows[:, 1] == 0, rows[:, 1] == 1,
0112                                                     rows[:, 1] > 1]])
0113                 means.append([float(np.mean(rows[:, c])) if len(rows) else np.nan
0114                               for c in [2, 3]])
0115             fractions, means = np.asarray(fractions), np.asarray(means)
0116             # Zero-association events are included; zero is not an efficiency
0117             # failure outside a subsystem's acceptance. Compare with hit response.
0118             results[key] = {"counts": counts.tolist(),
0119                             "association_fractions_zero_one_multiple":
0120                                 [[float(v) if np.isfinite(v) else None for v in row]
0121                                  for row in fractions],
0122                             "mean_summed_and_largest_response":
0123                                 [[float(v) if np.isfinite(v) else None for v in row]
0124                                  for row in means]}
0125             fig, axes = plt.subplots(1, 3, figsize=(13, 4))
0126             for i, label in enumerate(["zero", "one", "multiple"]):
0127                 axes[0].plot(centers, fractions[:, i], label=label)
0128             axes[0].set_ylabel("Associated-cluster fraction")
0129             axes[0].set_ylim(0, 1.05)
0130             axes[0].legend()
0131             for i, label in enumerate(["sum", "largest"]):
0132                 axes[1].plot(centers, means[:, i], label=label)
0133             axes[1].set_ylabel("Mean truth-cluster energy / thrown momentum")
0134             axes[1].legend()
0135             axes[2].hist2d(data[:, 0], data[:, 4], bins=[eta_edges, np.linspace(0, 1.01, 51)])
0136             axes[2].set_ylabel("Largest / summed associated energy")
0137             for axis in axes:
0138                 axis.set_xlabel("Thrown electron eta")
0139             fig.suptitle(f"{energy}: {subsystem} truth-cluster diagnostics")
0140             fig.tight_layout()
0141             fig.savefig(args.output_dir / f"truth_clusters_{key}.png", dpi=150)
0142             plt.close(fig)
0143     with (args.output_dir / "truth_clusters_summary.json").open("w") as stream:
0144         json.dump({"eta_edges": eta_edges.tolist(), "results": results,
0145                    "energy_convention": "full energy of distinct associated clusters",
0146                    "particle_selection": "MCParticles index 0 (particle gun)"}, stream, indent=2)
0147 
0148 
0149 if __name__ == "__main__":
0150     main()