Warning, /detector_benchmarks/benchmarks/backwards_ecal/backwards_ecal_dis.org is written in an unsupported language. File is not indexed.
0001 #+TITLE: ePIC EEEMCal benchmark in DIS events
0002 #+AUTHOR: detector_benchmarks contributors
0003 #+OPTIONS: d:t
0004
0005 This analysis measures the response of the backward electromagnetic
0006 calorimeter to the scattered electron in full DIS events. Unlike the
0007 single-particle benchmark, the highest-energy cluster in the event is not
0008 necessarily produced by the electron. We therefore select the scattered
0009 electron from =MCScatteredElectrons= and match the reconstructed cluster
0010 nearest to its endpoint on the EEEMCal surface.
0011
0012 The analysis is deliberately independent of how the input sample was
0013 produced. The benchmark workflow runs it on five reconstructed 10x100 GeV
0014 neutral-current DIS files with minimum $Q^2=1$ GeV$^2$. It reuses the DIS
0015 simulation defined by =tracking_performances_dis= and performs an EEEMCal-
0016 specific reconstruction that writes the collections needed below.
0017
0018 The same program can later be run on signal-only and beam-background-overlay
0019 samples. Comparison plots can then be made from the two saved =summary.json=
0020 and =distributions.npz= files without repeating the event processing.
0021
0022 Run the wired benchmark with:
0023
0024 #+begin_example
0025 snakemake --cores 1 backwards_ecal_dis_run_locally
0026 #+end_example
0027
0028 To analyze another already-reconstructed sample manually, generate the Python
0029 program and provide a file list:
0030
0031 #+begin_example
0032 snakemake benchmarks/backwards_ecal/backwards_ecal_dis.org2py.py
0033 env \
0034 INPUT_FILE_LIST=/path/to/dis_files.list \
0035 OUTPUT_DIR=results/backwards_ecal_dis/my_sample \
0036 SAMPLE_LABEL="DIS, 10x100 GeV" \
0037 python benchmarks/backwards_ecal/backwards_ecal_dis.org2py.py
0038 #+end_example
0039
0040 The file list must contain one EDM4eic ROOT file per line. =MATCH_RADIUS_MM=
0041 and =ENERGY_BIN_EDGES= may be overridden in the environment. The default
0042 50 mm matching radius is intentionally recorded in the output and should be
0043 reviewed using the matching-distance plot before it becomes a fixed benchmark
0044 requirement.
0045
0046 * Setup
0047
0048 #+begin_src jupyter-python
0049 import json
0050 import os
0051 from pathlib import Path
0052
0053 import awkward as ak
0054 import matplotlib
0055 matplotlib.use("Agg")
0056 import matplotlib.pyplot as plt
0057 import numpy as np
0058 import scipy.optimize
0059 import scipy.stats
0060 import uproot
0061
0062
0063 INPUT_FILE_LIST = Path(os.environ["INPUT_FILE_LIST"])
0064 OUTPUT_DIR = Path(os.environ.get("OUTPUT_DIR", "results/backwards_ecal_dis/manual"))
0065 SAMPLE_LABEL = os.environ.get("SAMPLE_LABEL", "DIS")
0066 MATCH_RADIUS_MM = float(os.environ.get("MATCH_RADIUS_MM", "50"))
0067 ENERGY_BIN_EDGES = np.asarray([
0068 float(value)
0069 for value in os.environ.get(
0070 "ENERGY_BIN_EDGES", "0.5,1,2,3,4,5,6,8,10,15,20"
0071 ).split(",")
0072 ])
0073
0074 ETA_MIN = float(os.environ.get("ETA_MIN", "-3.0"))
0075 ETA_MAX = float(os.environ.get("ETA_MAX", "-1.8"))
0076 ENDPOINT_Z_MIN_MM = float(os.environ.get("ENDPOINT_Z_MIN_MM", "-1900"))
0077 ENDPOINT_Z_MAX_MM = float(os.environ.get("ENDPOINT_Z_MAX_MM", "-1700"))
0078
0079 OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
0080
0081
0082 def read_file_list(path):
0083 with path.open() as stream:
0084 files = [
0085 line.strip()
0086 for line in stream
0087 if line.strip() and not line.lstrip().startswith("#")
0088 ]
0089 if not files:
0090 raise RuntimeError(f"No input files found in {path}")
0091 return files
0092
0093
0094 files = read_file_list(INPUT_FILE_LIST)
0095 branches = [
0096 "MCScatteredElectrons_objIdx.index",
0097 "MCParticles.momentum.*",
0098 "MCParticles.endpoint.*",
0099 "EcalEndcapNClusters.energy",
0100 "EcalEndcapNClusters.position.*",
0101 ]
0102 #+end_src
0103
0104 * Object selection
0105
0106 The =MCScatteredElectrons= collection is a subset collection, represented in
0107 the ROOT file by indices into =MCParticles=. We retain candidates in the
0108 backward acceptance and, if more than one remains, use the one with the most
0109 negative longitudinal momentum. This reproduces the selection used in the
0110 original EEEMCal DIS study.
0111
0112 The endpoint requirement ensures that the selected electron actually reaches
0113 the EEEMCal. The closest reconstructed cluster is accepted only inside the
0114 configured matching radius. Events without such a cluster remain in the
0115 efficiency denominator but not in the response or residual distributions.
0116
0117 #+begin_src jupyter-python
0118 def reduce_length_one(array):
0119 """Reduce a zero-or-one element jagged array without using ak.firsts."""
0120 return ak.max(array, axis=-1, mask_identity=True)
0121
0122
0123 def select_scattered_electron(events):
0124 indices = events["MCScatteredElectrons_objIdx.index"]
0125 px = events["MCParticles.momentum.x"][indices]
0126 py = events["MCParticles.momentum.y"][indices]
0127 pz = events["MCParticles.momentum.z"][indices]
0128
0129 momentum = np.sqrt(px * px + py * py + pz * pz)
0130 eta = np.arcsinh(pz / np.sqrt(px * px + py * py))
0131 accepted = (eta > ETA_MIN) & (eta < ETA_MAX)
0132 accepted_pz = ak.mask(pz, accepted)
0133 best = ak.argmin(
0134 accepted_pz, axis=-1, keepdims=True, mask_identity=True
0135 )
0136 selected_indices = indices[best]
0137
0138 def mc_value(name):
0139 return reduce_length_one(events[name][selected_indices])
0140
0141 return {
0142 "energy": reduce_length_one(momentum[best]),
0143 "eta": reduce_length_one(eta[best]),
0144 "endpoint_x": mc_value("MCParticles.endpoint.x"),
0145 "endpoint_y": mc_value("MCParticles.endpoint.y"),
0146 "endpoint_z": mc_value("MCParticles.endpoint.z"),
0147 }
0148
0149
0150 def match_cluster(events, electron):
0151 cluster_x = events["EcalEndcapNClusters.position.x"]
0152 cluster_y = events["EcalEndcapNClusters.position.y"]
0153 distance = np.sqrt(
0154 (cluster_x - electron["endpoint_x"]) ** 2
0155 + (cluster_y - electron["endpoint_y"]) ** 2
0156 )
0157 closest = ak.argmin(distance, axis=-1, keepdims=True, mask_identity=True)
0158
0159 return {
0160 "energy": reduce_length_one(events["EcalEndcapNClusters.energy"][closest]),
0161 "x": reduce_length_one(cluster_x[closest]),
0162 "y": reduce_length_one(cluster_y[closest]),
0163 "distance": reduce_length_one(distance[closest]),
0164 }
0165 #+end_src
0166
0167 * Event processing
0168
0169 Only one scalar record per selected electron is retained, so processing is
0170 performed in chunks and does not require all EDM data to fit in memory.
0171
0172 #+begin_src jupyter-python
0173 selected_chunks = []
0174 matched_chunks = []
0175 total_events = 0
0176
0177 sources = {path: "events" for path in files}
0178 for events in uproot.iterate(
0179 sources,
0180 filter_name=branches,
0181 step_size="100 MB",
0182 library="ak",
0183 ):
0184 total_events += len(events)
0185 electron = select_scattered_electron(events)
0186 cluster = match_cluster(events, electron)
0187
0188 endpoint_accepted = (
0189 (electron["endpoint_z"] > ENDPOINT_Z_MIN_MM)
0190 & (electron["endpoint_z"] < ENDPOINT_Z_MAX_MM)
0191 )
0192 matched = endpoint_accepted & (cluster["distance"] < MATCH_RADIUS_MM)
0193
0194 selected_chunks.append({
0195 key: ak.to_numpy(ak.drop_none(ak.mask(value, endpoint_accepted)))
0196 for key, value in electron.items()
0197 })
0198 matched_chunks.append({
0199 "truth_energy": ak.to_numpy(
0200 ak.drop_none(ak.mask(electron["energy"], matched))
0201 ),
0202 "truth_eta": ak.to_numpy(
0203 ak.drop_none(ak.mask(electron["eta"], matched))
0204 ),
0205 "response": ak.to_numpy(
0206 ak.drop_none(ak.mask(cluster["energy"] / electron["energy"], matched))
0207 ),
0208 "dx": ak.to_numpy(
0209 ak.drop_none(
0210 ak.mask(cluster["x"] - electron["endpoint_x"], matched)
0211 )
0212 ),
0213 "dy": ak.to_numpy(
0214 ak.drop_none(
0215 ak.mask(cluster["y"] - electron["endpoint_y"], matched)
0216 )
0217 ),
0218 "distance": ak.to_numpy(
0219 ak.drop_none(ak.mask(cluster["distance"], endpoint_accepted))
0220 ),
0221 })
0222
0223
0224 def concatenate(chunks, key):
0225 arrays = [chunk[key] for chunk in chunks if len(chunk[key])]
0226 return np.concatenate(arrays) if arrays else np.asarray([], dtype=float)
0227
0228
0229 truth_energy_selected = concatenate(selected_chunks, "energy")
0230 truth_eta_selected = concatenate(selected_chunks, "eta")
0231 truth_energy_matched = concatenate(matched_chunks, "truth_energy")
0232 truth_eta_matched = concatenate(matched_chunks, "truth_eta")
0233 response = concatenate(matched_chunks, "response")
0234 dx = concatenate(matched_chunks, "dx")
0235 dy = concatenate(matched_chunks, "dy")
0236 closest_distance = concatenate(matched_chunks, "distance")
0237
0238 np.savez_compressed(
0239 OUTPUT_DIR / "distributions.npz",
0240 truth_energy_selected=truth_energy_selected,
0241 truth_eta_selected=truth_eta_selected,
0242 truth_energy_matched=truth_energy_matched,
0243 truth_eta_matched=truth_eta_matched,
0244 response=response,
0245 dx=dx,
0246 dy=dy,
0247 closest_distance=closest_distance,
0248 energy_bin_edges=ENERGY_BIN_EDGES,
0249 )
0250 #+end_src
0251
0252 * Resolution summaries
0253
0254 Energy resolution follows the single-particle benchmark convention: fit the
0255 =E/p= distribution with a Crystal Ball function and convert its FWHM to an
0256 equivalent Gaussian sigma. Position resolution is the half-width of the
0257 central 68% interval, as in the position-resolution addition to that
0258 benchmark. A bin with insufficient entries or a failed fit is reported as
0259 =null= in JSON rather than aborting the full analysis.
0260
0261 #+begin_src jupyter-python
0262 def crystal_ball_resolution(values):
0263 values = values[np.isfinite(values)]
0264 if len(values) < 30:
0265 return np.nan
0266
0267 counts, edges = np.histogram(values, bins=110, range=(0.0, 1.10))
0268 centers = 0.5 * (edges[:-1] + edges[1:])
0269
0270 def model(x, normalization, beta, m, location, scale):
0271 return normalization * scipy.stats.crystalball.pdf(
0272 x, beta, m, loc=location, scale=scale
0273 )
0274
0275 fit_slice = slice(5, None)
0276 location0 = centers[fit_slice][np.argmax(counts[fit_slice])]
0277 parameters0 = (max(np.sum(counts[10:]) * 0.02, 1.0), 2.0, 3.0, location0, 0.02)
0278 try:
0279 parameters, _ = scipy.optimize.curve_fit(
0280 model,
0281 centers[fit_slice],
0282 counts[fit_slice],
0283 p0=parameters0,
0284 bounds=([0.0, 0.05, 1.01, 0.1, 0.001], [np.inf, 20.0, 100.0, 1.1, 0.5]),
0285 maxfev=20000,
0286 )
0287 location = parameters[3]
0288 grid = np.linspace(0.0, 1.1, 10000)
0289 curve = model(grid, *parameters)
0290 above_half_maximum = grid[curve >= 0.5 * np.max(curve)]
0291 if location <= 0.0 or len(above_half_maximum) < 2:
0292 return np.nan
0293 fwhm = above_half_maximum[-1] - above_half_maximum[0]
0294 return fwhm / (2.0 * np.sqrt(2.0 * np.log(2.0))) / location
0295 except (RuntimeError, ValueError):
0296 return np.nan
0297
0298
0299 def central_68_half_width(values):
0300 values = values[np.isfinite(values)]
0301 if len(values) < 20:
0302 return np.nan
0303 low, high = np.quantile(values, [0.16, 0.84])
0304 return 0.5 * (high - low)
0305
0306
0307 def optional_number(value):
0308 return float(value) if np.isfinite(value) else None
0309
0310
0311 bin_centers = 0.5 * (ENERGY_BIN_EDGES[:-1] + ENERGY_BIN_EDGES[1:])
0312 energy_resolution = []
0313 position_resolution_x = []
0314 position_resolution_y = []
0315 matching_efficiency = []
0316 selected_counts = []
0317 matched_counts = []
0318
0319 for low, high in zip(ENERGY_BIN_EDGES[:-1], ENERGY_BIN_EDGES[1:]):
0320 selected_in_bin = (truth_energy_selected >= low) & (truth_energy_selected < high)
0321 matched_in_bin = (truth_energy_matched >= low) & (truth_energy_matched < high)
0322 denominator = int(np.count_nonzero(selected_in_bin))
0323 numerator = int(np.count_nonzero(matched_in_bin))
0324
0325 selected_counts.append(denominator)
0326 matched_counts.append(numerator)
0327 matching_efficiency.append(numerator / denominator if denominator else np.nan)
0328 energy_resolution.append(crystal_ball_resolution(response[matched_in_bin]))
0329 position_resolution_x.append(central_68_half_width(dx[matched_in_bin]))
0330 position_resolution_y.append(central_68_half_width(dy[matched_in_bin]))
0331
0332 summary = {
0333 "sample_label": SAMPLE_LABEL,
0334 "input_file_list": str(INPUT_FILE_LIST),
0335 "number_of_files": len(files),
0336 "number_of_events": total_events,
0337 "number_of_endpoint_selected_electrons": len(truth_energy_selected),
0338 "number_of_matched_electrons": len(truth_energy_matched),
0339 "selection": {
0340 "eta": [ETA_MIN, ETA_MAX],
0341 "endpoint_z_mm": [ENDPOINT_Z_MIN_MM, ENDPOINT_Z_MAX_MM],
0342 "match_radius_mm": MATCH_RADIUS_MM,
0343 },
0344 "energy_bin_edges_GeV": ENERGY_BIN_EDGES.tolist(),
0345 "selected_counts": selected_counts,
0346 "matched_counts": matched_counts,
0347 "matching_efficiency": [optional_number(x) for x in matching_efficiency],
0348 "energy_resolution": [optional_number(x) for x in energy_resolution],
0349 "position_resolution_x_mm": [optional_number(x) for x in position_resolution_x],
0350 "position_resolution_y_mm": [optional_number(x) for x in position_resolution_y],
0351 }
0352
0353 with (OUTPUT_DIR / "summary.json").open("w") as stream:
0354 json.dump(summary, stream, indent=2)
0355 #+end_src
0356
0357 * Plots
0358
0359 #+begin_src jupyter-python
0360 fig, axes = plt.subplots(1, 3, figsize=(12, 3.5))
0361
0362 axes[0].hist(response, bins=110, range=(0.0, 1.1), histtype="step")
0363 axes[0].set_xlabel(r"$E_{cluster}/p_{truth}$")
0364 axes[0].set_ylabel("Events")
0365
0366 axes[1].hist(dx, bins=200, range=(-50.0, 50.0), histtype="step", label="x")
0367 axes[1].hist(dy, bins=200, range=(-50.0, 50.0), histtype="step", label="y")
0368 axes[1].set_xlabel("Cluster - endpoint [mm]")
0369 axes[1].set_ylabel("Events")
0370 axes[1].legend()
0371
0372 axes[2].hist(closest_distance, bins=200, range=(0.0, 200.0), histtype="step")
0373 axes[2].axvline(MATCH_RADIUS_MM, color="black", linestyle="--", label="match cut")
0374 axes[2].set_xlabel("Closest cluster distance [mm]")
0375 axes[2].set_ylabel("Endpoint-selected events")
0376 axes[2].legend()
0377
0378 fig.suptitle(SAMPLE_LABEL)
0379 fig.tight_layout()
0380 fig.savefig(OUTPUT_DIR / "distributions.png", dpi=150)
0381 fig.savefig(OUTPUT_DIR / "distributions.pdf")
0382 plt.close(fig)
0383
0384 fig, axes = plt.subplots(1, 3, figsize=(12, 3.5))
0385
0386 axes[0].plot(bin_centers, 100.0 * np.asarray(energy_resolution), marker="o")
0387 axes[0].set_xlabel("Truth electron energy [GeV]")
0388 axes[0].set_ylabel(r"$\sigma_E/E$ from FWHM [%]")
0389
0390 axes[1].plot(bin_centers, position_resolution_x, marker="o", label="x")
0391 axes[1].plot(bin_centers, position_resolution_y, marker="o", label="y")
0392 axes[1].set_xlabel("Truth electron energy [GeV]")
0393 axes[1].set_ylabel("Central 68% half-width [mm]")
0394 axes[1].legend()
0395
0396 axes[2].plot(bin_centers, matching_efficiency, marker="o")
0397 axes[2].set_xlabel("Truth electron energy [GeV]")
0398 axes[2].set_ylabel("Cluster matching efficiency")
0399 axes[2].set_ylim(0.0, 1.05)
0400
0401 fig.suptitle(SAMPLE_LABEL)
0402 fig.tight_layout()
0403 fig.savefig(OUTPUT_DIR / "performance.png", dpi=150)
0404 fig.savefig(OUTPUT_DIR / "performance.pdf")
0405 plt.close(fig)
0406
0407 print(json.dumps(summary, indent=2))
0408 #+end_src