Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-09-01 09:34:17

0001 #!/usr/bin/env python3
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 # Keep local analysis modules importable when this file is launched directly.
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 # These are the standard tracking-performance eta regions. They are fixed so
0043 # results from different configurations always have directly comparable rows.
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         # A strict-majority source is unique, so this is a many-tracks-to-one-
0116         # particle join. An inner join excludes tracks whose saved truth row is
0117         # unavailable and records that loss in the per-file summary.
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     # eta = -log(tan(theta/2)); reconstructed eta is stored in the trajectory
0150     # chunks, so this recovers the fitted polar angle without reopening ROOT.
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                 # Truth momentum is used on the x axis to avoid a resolution-
0194                 # dependent bin-position bias.
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     # The eighth panel is reserved for a shared legend.
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     # Preserve both the fitted products and enough joined rows to audit them.
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()