Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-10-08 08:36:03

0001 #!/usr/bin/env python3
0002 """Compare the FullSet G1 reproduction and assemble calibration PDF plot books.
0003 
0004 Run from examples/yall/fullset-g1-repro after:
0005     source env.tcsh
0006     python3 compare_and_report.py
0007 
0008 Uses only Python's standard library plus pdfunite.
0009 """
0010 
0011 from __future__ import annotations
0012 
0013 import argparse
0014 import csv
0015 import json
0016 import math
0017 import os
0018 import re
0019 import shutil
0020 import statistics
0021 import subprocess
0022 import sys
0023 from decimal import Decimal
0024 from pathlib import Path
0025 
0026 COLUMNS = (
0027     "cell_id", "layer", "row", "column", "module",
0028     "ped_mean_h", "ped_sigma_h", "ped_mean_l", "ped_sigma_l",
0029     "mip_scale_h", "mip_width_h", "mip_scale_l", "mip_width_l",
0030     "lghg_corr", "lghg_corr_offset", "hglg_corr",
0031     "hglg_corr_offset_toa", "bc",
0032 )
0033 INT_FIELDS = {"cell_id", "layer", "row", "column", "module", "bc"}
0034 STAGES = ("pedestal", "mip", "refine1", "refine2", "refine3", "refine4", "refine5", "final")
0035 
0036 
0037 def args():
0038     here = Path(__file__).resolve().parent
0039     repo = here.parents[2]
0040     p = argparse.ArgumentParser(description=__doc__)
0041     p.add_argument("--work", type=Path, default=None,
0042                    help="FullSet G1 workflow output directory")
0043     p.add_argument("--reference", type=Path,
0044                    default=repo / "calibrations/TB2026/calib_SPS-H2_FullSetG_1.txt")
0045     p.add_argument("--set-name", default="FullSetG_1")
0046     p.add_argument("--out", type=Path, default=None)
0047     p.add_argument("--no-pdf", action="store_true")
0048     return p.parse_args()
0049 
0050 
0051 def find_work(explicit):
0052     """Find a completed G1 output tree without trusting stale example state."""
0053     if explicit is not None:
0054         return explicit
0055 
0056     expected = "fullset-g1-repro"
0057     final_rel = Path("final/calib_Final_Muon_FullSetG_1_calib.txt")
0058     candidates = []
0059 
0060     # Trust the example-specific variable only when it says it belongs to G1.
0061     if os.environ.get("LFHCAL_EXAMPLE") == expected:
0062         value = os.environ.get("LFHCAL_EXAMPLE_WORK")
0063         if value:
0064             candidates.append(Path(value))
0065 
0066     # A general LFHCAL_WORK is usable only if the expected G1 final product exists.
0067     value = os.environ.get("LFHCAL_WORK")
0068     if value:
0069         candidates.append(Path(value) / expected)
0070 
0071     # BNL bootstrap default.  This also makes the report usable without sourcing
0072     # the LFHCal environment at all.
0073     user = os.environ.get("USER") or os.environ.get("LOGNAME")
0074     if user:
0075         candidates.append(Path(f"/gpfs01/star/scratch/{user}/lfhcal") / expected)
0076 
0077     seen = set()
0078     for candidate in candidates:
0079         candidate = candidate.expanduser()
0080         key = str(candidate)
0081         if key in seen:
0082             continue
0083         seen.add(key)
0084         if (candidate / final_rel).is_file():
0085             return candidate
0086 
0087     tried = "\n  ".join(str(x) for x in candidates) or "(no candidates)"
0088     raise ValueError(
0089         "could not locate a completed FullSet G1 output tree.\n"
0090         "Pass it explicitly, for example:\n"
0091         "  python3 compare_and_report.py "
0092         "--work /gpfs01/star/scratch/$USER/lfhcal/fullset-g1-repro\n"
0093         f"Tried:\n  {tried}"
0094     )
0095 
0096 
0097 def parse_calib(path):
0098     rows = {}
0099     payload = path.read_bytes()
0100     for line_no, line in enumerate(payload.decode("utf-8").splitlines(), 1):
0101         s = line.strip()
0102         if not s or s.startswith(("#", "%")) or s.startswith("RunNr:"):
0103             continue
0104         fields = s.split()
0105         if not fields or not fields[0].isdigit():
0106             continue
0107         if len(fields) != len(COLUMNS):
0108             raise ValueError(f"{path}:{line_no}: expected 18 columns, got {len(fields)}")
0109         row = {}
0110         for name, token in zip(COLUMNS, fields):
0111             value = Decimal(token)
0112             if name in INT_FIELDS:
0113                 if value != int(value):
0114                     raise ValueError(f"{path}:{line_no}: non-integer {name}")
0115                 value = int(value)
0116             row[name] = value
0117         cell = row["cell_id"]
0118         if cell in rows:
0119             raise ValueError(f"{path}: duplicate cell {cell}")
0120         rows[cell] = row
0121     if not rows:
0122         raise ValueError(f"{path}: no calibration records")
0123     return rows, payload
0124 
0125 
0126 def one_calib(directory):
0127     files = sorted(directory.glob("*_calib.txt"))
0128     if len(files) != 1:
0129         raise ValueError(f"expected one *_calib.txt in {directory}, found {len(files)}")
0130     return files[0]
0131 
0132 
0133 def pct(ref, got):
0134     return float(100 * (got - ref) / ref) if ref > 0 and got > 0 else None
0135 
0136 
0137 def percentile(values, q):
0138     if not values:
0139         return None
0140     x = sorted(values)
0141     pos = (len(x) - 1) * q
0142     lo, hi = math.floor(pos), math.ceil(pos)
0143     return x[lo] + (x[hi] - x[lo]) * (pos - lo)
0144 
0145 
0146 def stats(values):
0147     if not values:
0148         return {"n": 0}
0149     a = [abs(x) for x in values]
0150     return {
0151         "n": len(values),
0152         "median_abs_pct": statistics.median(a),
0153         "p95_abs_pct": percentile(a, .95),
0154         "rms_pct": math.sqrt(statistics.mean(x*x for x in values)),
0155         "max_abs_pct": max(a),
0156     }
0157 
0158 
0159 def write_csv(path, rows):
0160     if not rows:
0161         return
0162     fields = list(dict.fromkeys(k for row in rows for k in row))
0163     with path.open("w", newline="", encoding="utf-8") as f:
0164         w = csv.DictWriter(f, fieldnames=fields)
0165         w.writeheader()
0166         w.writerows(rows)
0167 
0168 
0169 def compare(work, reference_path, out, set_name):
0170     ref, ref_bytes = parse_calib(reference_path)
0171     tables = {"reference": ref}
0172     payloads = {"reference": ref_bytes}
0173     paths = {"reference": reference_path}
0174 
0175     for stage in STAGES:
0176         path = one_calib(work / stage)
0177         tables[stage], payloads[stage] = parse_calib(path)
0178         paths[stage] = path
0179 
0180     ids = sorted(ref)
0181     for stage, table in tables.items():
0182         if set(table) != set(ref):
0183             raise ValueError(f"{stage}: cell-ID set differs from reference")
0184         bad = [i for i in ids if any(
0185             table[i][x] != ref[i][x] for x in ("layer", "row", "column", "module")
0186         )]
0187         if bad:
0188             raise ValueError(f"{stage}: geometry mismatch, first cells {bad[:10]}")
0189 
0190     final = tables["final"]
0191     final_rows = []
0192     for i in ids:
0193         a, b = ref[i], final[i]
0194         row = {k: a[k] for k in ("cell_id", "module", "layer", "row", "column")}
0195         for field in ("mip_scale_h", "mip_width_h",
0196                       "ped_mean_h", "ped_sigma_h", "ped_mean_l", "ped_sigma_l"):
0197             row[f"{field}_reference"] = a[field]
0198             row[f"{field}_reproduced"] = b[field]
0199             row[f"{field}_delta"] = b[field] - a[field]
0200             if field.startswith("mip_"):
0201                 row[f"{field}_pct_delta"] = pct(a[field], b[field])
0202         final_rows.append(row)
0203 
0204     def metric(table, field):
0205         d = {i: pct(ref[i][field], table[i][field]) for i in ids}
0206         d = {i: v for i, v in d.items() if v is not None}
0207         s = stats(list(d.values()))
0208         if d:
0209             s["max_pct_cell"] = max(d, key=lambda i: abs(d[i]))
0210             s["exact_equal"] = sum(ref[i][field] == table[i][field] for i in d)
0211             s["within_0.001_pct"] = sum(abs(v) <= .001 for v in d.values())
0212             s["within_0.01_pct"] = sum(abs(v) <= .01 for v in d.values())
0213         return s
0214 
0215     scale = metric(final, "mip_scale_h")
0216     width = metric(final, "mip_width_h")
0217 
0218     stage_rows = []
0219     for stage in ("mip", "refine1", "refine2", "refine3", "refine4", "refine5", "final"):
0220         s = metric(tables[stage], "mip_scale_h")
0221         stage_rows.append({"stage": stage, **s})
0222 
0223     ranked_scale = []
0224     for i in ids:
0225         d = pct(ref[i]["mip_scale_h"], final[i]["mip_scale_h"])
0226         if d is not None:
0227             ranked_scale.append((abs(d), d, i))
0228     ranked_scale.sort(reverse=True)
0229 
0230     unavailable = [i for i in ids if ref[i]["mip_scale_h"] <= 0 or final[i]["mip_scale_h"] <= 0]
0231     restored = [i for i in ids
0232                 if tables["mip"][i]["mip_scale_h"] <= 0
0233                 and tables["refine1"][i]["mip_scale_h"] > 0]
0234     bc_mismatch = [i for i in ids if ref[i]["bc"] != final[i]["bc"]]
0235 
0236     comparison = out / "comparison"
0237     comparison.mkdir(parents=True, exist_ok=True)
0238     write_csv(comparison / "final_comparison.csv", final_rows)
0239     write_csv(comparison / "stage_summary.csv", stage_rows)
0240 
0241     summary = {
0242         "set": set_name,
0243         "reference": str(reference_path),
0244         "reproduced": str(paths["final"]),
0245         "cells": len(ids),
0246         "mip_scale_h": scale,
0247         "mip_width_h": width,
0248         "bc_mismatch_ids": bc_mismatch,
0249         "unavailable_mip_ids": unavailable,
0250         "restored_at_refine1_ids": restored,
0251         "final_identical_to_refine5_bytes": payloads["final"] == payloads["refine5"],
0252     }
0253     (comparison / "summary.json").write_text(json.dumps(summary, indent=2) + "\n")
0254 
0255     lines = [
0256         f"{set_name} calibration comparison",
0257         f"Reference:  {reference_path}",
0258         f"Reproduced: {paths['final']}",
0259         f"Cells: {len(ids)}",
0260         "",
0261         "HG MIP scale",
0262         f"  positive pairs: {scale['n']}",
0263         f"  exact at exported precision: {scale.get('exact_equal', 0)}",
0264         f"  median |relative difference|: {scale['median_abs_pct']:.9f}%",
0265         f"  95th percentile: {scale['p95_abs_pct']:.9f}%",
0266         f"  RMS: {scale['rms_pct']:.9f}%",
0267         f"  maximum: {scale['max_abs_pct']:.9f}% at cell {scale.get('max_pct_cell')}",
0268         f"  within 0.001%: {scale.get('within_0.001_pct', 0)}/{scale['n']}",
0269         f"  within 0.01%: {scale.get('within_0.01_pct', 0)}/{scale['n']}",
0270         "",
0271         "Stored HG MIP width",
0272         f"  median |relative difference|: {width['median_abs_pct']:.9f}%",
0273         f"  maximum: {width['max_abs_pct']:.9f}% at cell {width.get('max_pct_cell')}",
0274         "",
0275         f"BC mismatches: {bc_mismatch}",
0276         f"Unavailable/nonpositive HG MIP cells: {unavailable}",
0277         f"Recovered at refine1: {restored}",
0278         f"Final byte-identical to refine5: {summary['final_identical_to_refine5_bytes']}",
0279         "",
0280         "Convergence vs reference (HG MIP scale)",
0281     ]
0282     for row in stage_rows:
0283         lines.append(
0284             f"  {row['stage']:7s} n={row['n']:3d} "
0285             f"median |d|={row['median_abs_pct']:.9f}% "
0286             f"max |d|={row['max_abs_pct']:.9f}%"
0287         )
0288 
0289     lines += ["", "Largest final HG MIP-scale residuals"]
0290     for _, d, i in ranked_scale[:15]:
0291         a, b = ref[i], final[i]
0292         lines.append(
0293             f"  cell {i:4d}  mod={a['module']} layer={a['layer']} "
0294             f"row={a['row']} col={a['column']}  "
0295             f"ref={float(a['mip_scale_h']):.6f} "
0296             f"repro={float(b['mip_scale_h']):.6f}  "
0297             f"d={d:+.9f}%"
0298         )
0299 
0300     if len(ranked_scale) > 1:
0301         ss = sum(d*d for _, d, _ in ranked_scale)
0302         rms_without_worst = math.sqrt((ss - ranked_scale[0][1]**2) / (len(ranked_scale) - 1))
0303         lines += [
0304             "",
0305             f"HG MIP-scale RMS excluding the single worst cell: {rms_without_worst:.9f}%",
0306         ]
0307 
0308     text = "\n".join(lines) + "\n"
0309     (comparison / "comparison.txt").write_text(text)
0310     print(text, end="")
0311 
0312 
0313 def natural(paths):
0314     def key(path):
0315         return [int(x) if x.isdigit() else x.lower()
0316                 for x in re.split(r"(\d+)", path.name)]
0317     return sorted(paths, key=key)
0318 
0319 
0320 def exact(directory, names):
0321     files, missing = [], []
0322     for name in names:
0323         path = directory / name
0324         (files if path.is_file() else missing).append(path if path.is_file() else name)
0325     return files, missing
0326 
0327 
0328 def mip_inputs(directory, initial):
0329     suffix = "_2nd" if initial else ""
0330     head = [
0331         f"HG_FWHMMip{suffix}.pdf",
0332         f"HG_GaussSigMip{suffix}.pdf",
0333         f"HG_LandMPVMip{suffix}.pdf",
0334         f"HG_LandSigMip{suffix}.pdf",
0335         f"HG_MaxMip{suffix}.pdf",
0336         "HGscaleChi2VsLayer.pdf",
0337     ]
0338     tail = [
0339         "MipTriggXY.pdf",
0340         "MuonTriggers.pdf",
0341         "SNRTriggVsLayer.pdf",
0342         "SuppressionNoise.pdf",
0343         "SuppressionSignal.pdf",
0344     ]
0345     files, missing = exact(directory, head)
0346     mip_layers = natural(directory.glob("MIP_HG_Layer*.pdf"))
0347     if mip_layers:
0348         files += mip_layers
0349     else:
0350         missing.append("MIP_HG_Layer*.pdf")
0351     got, miss = exact(directory, tail)
0352     files += got
0353     missing += miss
0354     trig_layers = natural(directory.glob("TriggPrimitive_Layer*.pdf"))
0355     if trig_layers:
0356         files += trig_layers
0357     else:
0358         missing.append("TriggPrimitive_Layer*.pdf")
0359     return files, missing
0360 
0361 
0362 def unite(output, inputs):
0363     if not inputs:
0364         return
0365     output.parent.mkdir(parents=True, exist_ok=True)
0366     if output.exists():
0367         output.unlink()
0368     if len(inputs) == 1:
0369         shutil.copyfile(inputs[0], output)
0370         return
0371     exe = shutil.which("pdfunite")
0372     if exe:
0373         subprocess.run([exe, *map(str, inputs), str(output)], check=True)
0374         return
0375 
0376     exe = shutil.which("qpdf")
0377     if exe:
0378         subprocess.run(
0379             [exe, "--empty", "--pages", *map(str, inputs), "--", str(output)],
0380             check=True,
0381         )
0382         return
0383 
0384     exe = shutil.which("gs")
0385     if exe:
0386         subprocess.run(
0387             [
0388                 exe, "-q", "-dBATCH", "-dNOPAUSE", "-sDEVICE=pdfwrite",
0389                 f"-sOutputFile={output}", *map(str, inputs),
0390             ],
0391             check=True,
0392         )
0393         return
0394 
0395     raise RuntimeError("no PDF merger found; need pdfunite, qpdf, or gs in PATH")
0396 
0397 
0398 def make_pdfs(work, out, set_name):
0399     report_dir = out / "pdf"
0400     report_dir.mkdir(parents=True, exist_ok=True)
0401     plots = work / "plots"
0402     muon = f"Muon_{set_name}"
0403 
0404     stages = [
0405         ("mip", "Initial", True),
0406         ("refine1", "ImpR", False),
0407         ("refine2", "Imp2R", False),
0408         ("refine3", "Imp3R", False),
0409         ("refine4", "Imp4R", False),
0410         ("refine5", "Imp5R", False),
0411     ]
0412     made = {}
0413     for stage, label, initial in stages:
0414         directory = plots / stage / muon
0415         if not directory.is_dir():
0416             print(f"PDF: skip {stage}; no {directory}", file=sys.stderr)
0417             continue
0418         files, missing = mip_inputs(directory, initial)
0419         if missing:
0420             print(f"PDF: {stage} missing: {', '.join(map(str, missing))}", file=sys.stderr)
0421         if files:
0422             output = report_dir / f"SummaryMipCalibration_{label}_{set_name}.pdf"
0423             unite(output, files)
0424             made[stage] = output
0425             print(f"PDF: wrote {output} from {len(files)} plots")
0426 
0427     if "refine5" in made:
0428         final = report_dir / f"SummaryMipCalibration_Final_{set_name}.pdf"
0429         shutil.copyfile(made["refine5"], final)
0430         made["final"] = final
0431         print(f"PDF: wrote {final}")
0432 
0433     for stage, label in (("pedestal", "Pedestal"), ("transfer", "Transfer")):
0434         root = plots / stage
0435         files = natural(root.rglob("*.pdf")) if root.is_dir() else []
0436         if files:
0437             output = report_dir / f"Summary{label}_{set_name}.pdf"
0438             unite(output, files)
0439             made[stage] = output
0440             print(f"PDF: wrote {output} from {len(files)} plots")
0441 
0442     parts = [made[x] for x in ("pedestal", "transfer", "final") if x in made]
0443     if parts:
0444         output = report_dir / f"CalibrationPlotBook_{set_name}.pdf"
0445         unite(output, parts)
0446         print(f"PDF: wrote {output}")
0447 
0448 
0449 def main():
0450     a = args()
0451     try:
0452         work = find_work(a.work).resolve()
0453         out = (a.out or work / "report").resolve()
0454         reference = a.reference.resolve()
0455         out.mkdir(parents=True, exist_ok=True)
0456         compare(work, reference, out, a.set_name)
0457         if not a.no_pdf:
0458             make_pdfs(work, out, a.set_name)
0459     except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as e:
0460         print(f"ERROR: {e}", file=sys.stderr)
0461         return 1
0462     print(f"Report directory: {out}")
0463     return 0
0464 
0465 
0466 if __name__ == "__main__":
0467     raise SystemExit(main())