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
0065
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
0117
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()