File indexing completed on 2026-09-01 09:34:17
0001
0002 """Plot DIS momentum and polar-angle resolutions from saved CSV chunks.
0003
0004 This script never reopens reconstructed ROOT files. It joins each good signal
0005 track to its dominant MC particle using ``(event, particle_id)``, calculates
0006 residuals in truth momentum and eta bins, fits Gaussian widths, and writes the
0007 standard seven-eta-panel plots.
0008
0009 Example:
0010
0011 python track2particle_resol.py \
0012 --config epic \
0013 --input-root /path/to/rootfiles \
0014 --chunk-root /path/to/track2particle_output
0015 """
0016
0017 from __future__ import annotations
0018
0019 import argparse
0020 from pathlib import Path
0021 import sys
0022
0023 import numpy as np
0024 import pandas as pd
0025 from matplotlib import pyplot as plt
0026 from matplotlib.backends.backend_pdf import PdfPages
0027
0028
0029
0030 MODULE_DIRECTORY = Path(__file__).resolve().parent
0031 if str(MODULE_DIRECTORY) not in sys.path:
0032 sys.path.insert(0, str(MODULE_DIRECTORY))
0033
0034 import epic_analysis_base as ana
0035 import track2particle as track_analysis
0036
0037
0038 DEFAULT_INPUT_ROOT = Path("rootfiles")
0039 DEFAULT_CHUNK_ROOT = Path("track2particle_output")
0040 DEFAULT_MOMENTUM_BINS = np.array([0.0, 0.5, 1.0, 5.0, 1000.0])
0041
0042
0043
0044 ETA_BIN_RANGES = [
0045 (-3.5, -3.0),
0046 (-3.0, -2.5),
0047 (-2.5, -1.0),
0048 (-1.0, 1.0),
0049 (1.0, 2.5),
0050 (2.5, 3.0),
0051 (3.0, 3.5),
0052 ]
0053
0054 RESOLUTION_SPECS = {
0055 "dp": {
0056 "column": "resol_dp",
0057 "ylabel": r"$\delta p/p$ [%]",
0058 "scale": 1.0,
0059 "default_y_hi": 16.0,
0060 "filename": "tracking_dp_over_p_resolution.png",
0061 },
0062 "theta": {
0063 "column": "resol_theta",
0064 "ylabel": r"$\theta$ [rad]",
0065 "scale": 1.0 / 1000.0,
0066 "default_y_hi": 0.01,
0067 "filename": "tracking_theta_resolution.png",
0068 },
0069 }
0070
0071
0072 def load_matched_tracks(input_files, chunk_root, cuts):
0073 """Load good signal tracks and join each to its dominant MC particle."""
0074 matched_frames = []
0075 status_rows = []
0076
0077 for input_file in input_files:
0078 output_directory = track_analysis.get_output_directory(
0079 input_file, chunk_root
0080 )
0081 manifest_file = output_directory / "manifest.csv"
0082 if not manifest_file.is_file():
0083 raise FileNotFoundError(f"Missing manifest: {manifest_file}")
0084
0085 manifest = pd.read_csv(manifest_file)
0086 trajectories, particles = track_analysis.load_active_chunks(
0087 manifest, manifest_file
0088 )
0089 _, good_signal_tracks = track_analysis.select_valid_tracks(
0090 trajectories, cuts
0091 )
0092
0093 particle_key = ["event", "particle_id"]
0094 if particles.duplicated(particle_key).any():
0095 raise ValueError(f"Particle keys are not unique in {manifest_file}")
0096
0097 truth_columns = [
0098 "event",
0099 "particle_id",
0100 "mom",
0101 "theta",
0102 "eta",
0103 "PDG",
0104 "generatorStatus",
0105 ]
0106 missing_columns = [
0107 column for column in truth_columns if column not in particles
0108 ]
0109 if missing_columns:
0110 raise KeyError(
0111 f"{manifest_file} is missing particle columns "
0112 f"{missing_columns}"
0113 )
0114
0115
0116
0117
0118 matched = good_signal_tracks.merge(
0119 particles[truth_columns],
0120 left_on=["event", "most_common_source"],
0121 right_on=particle_key,
0122 how="inner",
0123 validate="many_to_one",
0124 )
0125 matched["source_file"] = str(input_file)
0126 matched_frames.append(matched)
0127 status_rows.append(
0128 {
0129 "input_file": str(input_file),
0130 "events": len(manifest),
0131 "events_ok": int(manifest["status"].eq("ok").sum()),
0132 "good_signal_tracks": len(good_signal_tracks),
0133 "truth_matched_tracks": len(matched),
0134 }
0135 )
0136
0137 matched_tracks = (
0138 pd.concat(matched_frames, ignore_index=True)
0139 if matched_frames
0140 else pd.DataFrame()
0141 )
0142 return matched_tracks, pd.DataFrame(status_rows)
0143
0144
0145 def calculate_residuals(matched_tracks):
0146 """Calculate reconstructed-minus-truth momentum and theta residuals."""
0147 tracks = matched_tracks.copy()
0148
0149
0150
0151 tracks["reco_theta"] = 2.0 * np.arctan(np.exp(-tracks["reco_eta"]))
0152 tracks["resol_dp"] = (
0153 (tracks["reco_mom"] - tracks["mom"]) / tracks["mom"] * 100.0
0154 )
0155 tracks["resol_theta"] = (
0156 (tracks["reco_theta"] - tracks["theta"]) * 1000.0
0157 )
0158
0159 required = [
0160 "mom",
0161 "eta",
0162 "reco_mom",
0163 "reco_eta",
0164 "resol_dp",
0165 "resol_theta",
0166 ]
0167 finite = np.isfinite(tracks[required]).all(axis=1)
0168 finite &= tracks["mom"] > 0
0169 return tracks[finite].copy()
0170
0171
0172 def summarize_resolution_bins(tracks, momentum_bins, setting):
0173 """Fit residual widths in each truth eta and momentum bin."""
0174 rows = []
0175 for eta_lo, eta_hi in ETA_BIN_RANGES:
0176 eta_selected = tracks[
0177 tracks["eta"].between(eta_lo, eta_hi, inclusive="left")
0178 ]
0179 for momentum_index, (momentum_lo, momentum_hi) in enumerate(
0180 zip(momentum_bins[:-1], momentum_bins[1:])
0181 ):
0182 selected = eta_selected[
0183 (eta_selected["mom"] >= momentum_lo)
0184 & (eta_selected["mom"] < momentum_hi)
0185 ]
0186 row = {
0187 "setting": setting,
0188 "eta_lo": eta_lo,
0189 "eta_hi": eta_hi,
0190 "momentum_bin": momentum_index,
0191 "momentum_lo": momentum_lo,
0192 "momentum_hi": momentum_hi,
0193
0194
0195 "mom_gev": (
0196 selected["mom"].mean() if not selected.empty else np.nan
0197 ),
0198 "n_tracks": len(selected),
0199 }
0200 for column in ("resol_dp", "resol_theta"):
0201 mean, sigma, sigma_error = ana.hist_gaus(
0202 selected[column], ax=None, bins=101
0203 )
0204 row[f"{column}_mean"] = mean
0205 row[f"{column}_sigma"] = sigma
0206 row[f"{column}_sigma_err"] = sigma_error
0207 rows.append(row)
0208 return pd.DataFrame(rows)
0209
0210
0211 def load_pwg_requirements(pwg_file):
0212 """Read an optional PWG requirement table for the momentum plot."""
0213 if pwg_file is None:
0214 return None
0215 if not Path(pwg_file).is_file():
0216 raise FileNotFoundError(f"Missing PWG requirement table: {pwg_file}")
0217 return pd.read_csv(pwg_file, sep=r"\s+", skiprows=1)
0218
0219
0220 def pwg_dp_requirement(pwg_table, eta, momentum):
0221 """Evaluate the PWG dp/p requirement for one eta region."""
0222 if pwg_table is None:
0223 return None
0224 selected = pwg_table[
0225 (pwg_table["eta_lo"] <= eta) & (pwg_table["eta_hi"] > eta)
0226 ]
0227 if selected.empty:
0228 return None
0229 coefficient_a = selected["dp_par1"].iloc[0]
0230 coefficient_b = selected["dp_par2"].iloc[0]
0231 return np.sqrt((coefficient_a * momentum) ** 2 + coefficient_b**2)
0232
0233
0234 def adaptive_y_limits(default_y_hi, plotted_values):
0235 """Choose a power-of-two y range that contains and resolves all points."""
0236 values = np.asarray(plotted_values, dtype=float)
0237 values = values[np.isfinite(values) & (values > 0)]
0238 if values.size == 0:
0239 return -0.05 * default_y_hi, default_y_hi
0240
0241 data_max = float(values.max())
0242 y_hi = float(default_y_hi)
0243 while data_max > y_hi:
0244 y_hi *= 2.0
0245 while data_max <= 0.5 * y_hi:
0246 y_hi /= 2.0
0247 return -0.05 * y_hi, y_hi
0248
0249
0250 def resolution_x_limits(summary):
0251 """Return one x limit per eta panel, including every truth-momentum point."""
0252 limits = []
0253 for eta_lo, eta_hi in ETA_BIN_RANGES:
0254 panel = summary[
0255 np.isclose(summary["eta_lo"], eta_lo)
0256 & np.isclose(summary["eta_hi"], eta_hi)
0257 ]
0258 finite_momentum = panel["mom_gev"].replace(
0259 [np.inf, -np.inf], np.nan
0260 ).dropna()
0261 data_limit = 1.10 * finite_momentum.max() if len(finite_momentum) else 0
0262 limits.append(max(15.0, data_limit))
0263 return limits
0264
0265
0266 def plot_resolution(summary, variable, setting, pwg_table=None):
0267 """Create one standard seven-panel resolution figure."""
0268 spec = RESOLUTION_SPECS[variable]
0269 sigma_column = f"{spec['column']}_sigma"
0270 error_column = f"{spec['column']}_sigma_err"
0271 x_limits = resolution_x_limits(summary)
0272
0273 figure, axes = plt.subplots(2, 4, figsize=(16, 8))
0274 axes = axes.ravel()
0275 legend_handle = None
0276 requirement_handle = None
0277
0278 for panel_index, (eta_lo, eta_hi) in enumerate(ETA_BIN_RANGES):
0279 axis = axes[panel_index]
0280 panel = summary[
0281 np.isclose(summary["eta_lo"], eta_lo)
0282 & np.isclose(summary["eta_hi"], eta_hi)
0283 ].dropna(subset=["mom_gev", sigma_column, error_column])
0284 panel = panel[panel[sigma_column] > 0]
0285
0286 y_values = panel[sigma_column] * spec["scale"]
0287 if not panel.empty:
0288 legend_handle = axis.errorbar(
0289 panel["mom_gev"],
0290 y_values,
0291 yerr=panel[error_column] * spec["scale"],
0292 color="tab:blue",
0293 linestyle="none",
0294 marker="o",
0295 label=setting,
0296 )
0297
0298 if variable == "dp" and pwg_table is not None:
0299 x_line = np.linspace(0.001, x_limits[panel_index], 1000)
0300 y_line = pwg_dp_requirement(pwg_table, eta_lo, x_line)
0301 if y_line is not None:
0302 requirement_handle, = axis.plot(
0303 x_line, y_line, "k--", label="PWG requirement"
0304 )
0305
0306 y_lo, y_hi = adaptive_y_limits(spec["default_y_hi"], y_values)
0307 axis.set_ylim(y_lo, y_hi)
0308 axis.set_xlim(0, 1.05 * x_limits[panel_index])
0309 axis.text(
0310 0.08,
0311 0.9,
0312 f"{eta_lo}<$\\eta$<{eta_hi}",
0313 fontsize=14,
0314 transform=axis.transAxes,
0315 )
0316
0317
0318 axes[-1].axis("off")
0319 handles = [handle for handle in (legend_handle, requirement_handle) if handle]
0320 if handles:
0321 axes[-1].legend(handles=handles, frameon=False, loc="upper left")
0322 for axis in axes[4:7]:
0323 axis.set_xlabel("momentum [GeV/c]")
0324 axes[0].set_ylabel(spec["ylabel"])
0325 axes[4].set_ylabel(spec["ylabel"])
0326 figure.tight_layout()
0327 return figure
0328
0329
0330 def make_resolution_plots(summary, output_directory, setting, pwg_file):
0331 """Write individual PNG plots and a two-page combined PDF."""
0332 output_directory.mkdir(parents=True, exist_ok=True)
0333 pwg_table = load_pwg_requirements(pwg_file)
0334 combined_pdf = output_directory / f"tracking_resolutions_{setting}.pdf"
0335
0336 with PdfPages(combined_pdf) as pdf:
0337 for variable, spec in RESOLUTION_SPECS.items():
0338 figure = plot_resolution(summary, variable, setting, pwg_table)
0339 figure.savefig(output_directory / spec["filename"], dpi=160)
0340 pdf.savefig(figure)
0341 plt.close(figure)
0342 return combined_pdf
0343
0344
0345 def parse_arguments():
0346 """Parse and validate the command-line interface."""
0347 parser = argparse.ArgumentParser(
0348 description="Plot dp/p and theta resolutions from track2particle chunks."
0349 )
0350 parser.add_argument("--config", default="epic")
0351 parser.add_argument("--input-root", type=Path, default=DEFAULT_INPUT_ROOT)
0352 parser.add_argument("--chunk-root", type=Path, default=DEFAULT_CHUNK_ROOT)
0353 parser.add_argument("--output-dir", type=Path, default=None)
0354 parser.add_argument("--file-start", type=int, default=1)
0355 parser.add_argument("--file-stop", type=int, default=100)
0356 parser.add_argument(
0357 "--momentum-bins",
0358 nargs="+",
0359 type=float,
0360 default=DEFAULT_MOMENTUM_BINS.tolist(),
0361 metavar="EDGE",
0362 )
0363 parser.add_argument("--pwg-file", type=Path, default=None)
0364 args = parser.parse_args()
0365
0366 momentum_bins = np.asarray(args.momentum_bins, dtype=float)
0367 if (
0368 len(momentum_bins) < 2
0369 or not np.all(np.isfinite(momentum_bins))
0370 or not np.all(np.diff(momentum_bins) > 0)
0371 ):
0372 parser.error("--momentum-bins must be finite increasing edges")
0373 if args.file_start < 1 or args.file_stop < args.file_start:
0374 parser.error("--file-start/--file-stop define an invalid range")
0375 args.momentum_bins = momentum_bins
0376 return args
0377
0378
0379 def main():
0380 """Load existing chunks, fit resolution widths, and write tables/plots."""
0381 args = parse_arguments()
0382 output_directory = args.output_dir or (
0383 args.chunk_root / "resolution" / args.config
0384 )
0385 output_directory.mkdir(parents=True, exist_ok=True)
0386
0387 input_files = track_analysis.build_input_files(
0388 args.config, args.input_root, args.file_start, args.file_stop
0389 )
0390 matched_tracks, file_summary = load_matched_tracks(
0391 input_files, args.chunk_root, track_analysis.DEFAULT_CUTS
0392 )
0393 tracks = calculate_residuals(matched_tracks)
0394 summary = summarize_resolution_bins(
0395 tracks, args.momentum_bins, args.config
0396 )
0397
0398
0399 file_summary.to_csv(output_directory / "input_file_summary.csv", index=False)
0400 tracks.to_csv(output_directory / "resolution_tracks.csv.gz", index=False)
0401 summary.to_csv(output_directory / "resolution_summary.csv", index=False)
0402 combined_pdf = make_resolution_plots(
0403 summary, output_directory, args.config, args.pwg_file
0404 )
0405
0406 print(f"Input files: {len(input_files)}")
0407 print(
0408 "Events OK/requested: "
0409 f"{file_summary['events_ok'].sum()}/{file_summary['events'].sum()}"
0410 )
0411 print(f"Good signal tracks: {file_summary['good_signal_tracks'].sum()}")
0412 print(f"Joined to saved truth: {file_summary['truth_matched_tracks'].sum()}")
0413 print(f"Finite residual rows: {len(tracks)}")
0414 print(f"Eta bins: {ETA_BIN_RANGES}")
0415 print(f"Momentum bins [GeV]: {args.momentum_bins.tolist()}")
0416 print(
0417 "Successful bin fits: "
0418 f"dp/p={summary['resol_dp_sigma'].notna().sum()}, "
0419 f"theta={summary['resol_theta_sigma'].notna().sum()} "
0420 f"(of {len(summary)})"
0421 )
0422 print(f"Summary table: {output_directory / 'resolution_summary.csv'}")
0423 print(f"Plots: {combined_pdf}")
0424
0425
0426 if __name__ == "__main__":
0427 main()