File indexing completed on 2026-08-30 08:17:27
0001
0002 """
0003 ml_momentum_calibration_v4.py
0004
0005 Successor to ml_momentum_calibration_reso.py. Five structural changes:
0006
0007 TASK 1 -- DIRECT KFPARTICLE NTUPLE INPUT (+ candidate-count option)
0008 --kshort / --lam accept either a KFParticle nTuple ROOT file or a legacy
0009 CSV (detected by extension). ROOT files are read with uproot; tree name
0010 defaults to "DecayTree" (--tree). Branch names follow KFParticle_nTuple.cc
0011 (coresoftware): per daughter i in {1, 2}
0012 track_i_pT, track_i_pseudorapidity, track_i_phi, track_i_charge,
0013 track_i_PDG_ID, track_i_pTErr, track_i_Covariance[21]
0014 and per mother (prefix auto-detected, K_S0 / Lambda0)
0015 <res>_mass, <res>_pT, <res>_pseudorapidity, <res>_phi.
0016 --max_candidates N randomly subsamples each species to N candidates after
0017 the selection (0 = use all; seeded by --subsample_seed).
0018 CSV input carries no covariance, so it forces --reso_model absolute.
0019
0020 TASK 2 -- CURVATURE FEATURE AND CURVATURE-MULTIPLICATIVE, UNCAPPED CORRECTION
0021 Stage-1 input feature is unsigned curvature u = 1/pT, normalised with
0022 ROBUST QUANTILES (median and half the 16-84 span) because with the pT cut
0023 removed (task 4) the u distribution has a long high-u tail that would
0024 dominate a mean/std normalisation. u_n is clamped to +-U_CLAMP as a
0025 numerical guard only; the clamp makes kappa constant in curvature outside
0026 it, and the fraction of training tracks hitting it is written to
0027 summary.txt.
0028 Output convention:
0029 (1/pT)_corr = (1/pT) * kappa_curv
0030 kappa_curv = 1 + eps(u, eta, phi) + q * delta(u, eta, phi)
0031 so the pT multiplier applied downstream is 1/kappa_curv. The even/odd
0032 decomposition is exact in curvature space; q enters only through the
0033 +q*delta output term, never as a network input. eps and delta are
0034 UNCAPPED (no tanh, no S_CAP): the size of the correction is learned, not
0035 constrained. The only guard is numerical, kappa_curv >= KAPPA_GUARD,
0036 which prevents a momentum sign flip during early optimisation; its hit
0037 fraction must be 0 at convergence (reported, warned on).
0038 The normalisation constants (u_ref, u_scale, U_CLAMP) are persisted inside
0039 model.pt and stamped into kappa_lookup.csv; --load_stage1 restores them
0040 and FAILS LOUDLY on a checkpoint that predates this convention.
0041
0042 TASK 3 -- DAUGHTER-TRACK COVARIANCE => STAGE 2 IS A COVARIANCE CALIBRATION
0043 track_i_Covariance is the packed lower triangle of the symmetric
0044 covariance over (x, y, z, px, py, pz), idx(a,b) = a(a+1)/2 + b for a >= b
0045 (momentum block 9/13/14/18/19/20). Length 21 = 6x6 state; length 28 =
0046 7x7 with E appended -- the momentum-block indices are identical; the
0047 detected layout is printed. Per track,
0048 sigma^2(pT) = ( px^2 Vxx + 2 px py Vxy + py^2 Vyy ) / pT^2
0049 r_cov = sigma(pT) / pT
0050 Note sigma(1/pT)/(1/pT) = sigma(pT)/pT to first order, so r_cov is
0051 simultaneously the fractional curvature resolution -- consistent with the
0052 rest of the script working in curvature. Candidates with non-finite
0053 entries, non-positive Vxx/Vyy, or r_cov outside (1e-5, 1) are dropped and
0054 counted per category. sqrt(V(px,px)-projection) is cross-checked against
0055 track_i_pTErr; a bulk disagreement beyond ~20% aborts (packing convention
0056 wrong => everything downstream invalid).
0057
0058 --reso_model {absolute, pull}, default pull.
0059 absolute: r_i^2 = (a(eta,phi)/beta_i)^2 + (b(eta,phi)*pT_i)^2 (v2)
0060 pull: r_i = s(eta_i, phi_i) * pT_i^{u_p} * r_cov,i
0061 with s = S_SCALE*softplus(.) initialised at 1 and u_p a single global
0062 exponent initialised at 0.
0063
0064 INTERPRETATION CHANGE (explicit, per request; also stamped into
0065 reso_lookup.csv and summary.txt when pull is active):
0066 1. ResoNet no longer measures the momentum resolution. In `absolute`,
0067 a and b ARE the detector resolution, measured from the peak widths.
0068 In `pull`, the absolute scale of r is inherited from the KFParticle
0069 covariance; what is fitted is s(eta,phi) and u_p, i.e. the
0070 MISCALIBRATION of the reported covariance. Perfect calibration is
0071 s == 1, u_p == 0.
0072 2. s mixes two effects that cannot be separated here: (i) genuine
0073 resolution the Kalman hit-error/material model misses, and (ii)
0074 systematic mis-estimation of the covariance by the fit itself.
0075 3. The stored covariance is POST-VERTEX-CONSTRAINT
0076 (m_extrapolateTracksToSV_nTuple defaults true): the constraint
0077 shrinks the momentum covariance relative to the raw track fit, so s
0078 is defined relative to CONSTRAINED covariances. Applying s to raw
0079 SvtxTrack covariances is wrong by the (unmeasured) constraint
0080 shrinkage factor. The exported field is only valid together with
0081 the same covariance the producer used.
0082 4. s at low pT and the floor c are partially degenerate (rho(s,c) is
0083 reported from the reduced global fit, as rho(a,c) was in absolute).
0084 5. reso_lookup.csv exports (eta, phi, s) plus u_p in the header -- NOT
0085 an evaluated r grid: r depends on the per-track covariance, which a
0086 lookup cannot carry. The consumer rebuilds
0087 r = s(eta,phi) * pT^{u_p} * r_cov from its own covariance.
0088 summary.txt reports the median and 16/84 quantiles of r_pred/r_cov per
0089 resonance group -- the headline factor by which KFParticle's reported
0090 momentum uncertainty is wrong -- and fig8 gains a raw-covariance pull
0091 overlay (s = 1, u_p = 0, no floor) whose core width says the same thing
0092 before calibration.
0093
0094 TASK 4 -- NO pT CUT ON DAUGHTER TRACKS
0095 The fiducial selection keeps only |eta| < 1.5, a pT > 1 MeV sanity floor,
0096 finiteness, and the mass windows. All pT-dependent internals (loss-bin
0097 edges, kappa-map slices, lookup grid, plot ranges) are derived from data
0098 quantiles at run time instead of a hard-coded (0.15, 3) GeV range.
0099
0100 TASK 5 -- KAPPA MAPS PRESENTED IN 1/pT
0101 fig6 (charge-even eps, charge-odd delta) and fig7 (final per-charge pT
0102 multiplier) are sliced uniformly in curvature 1/pT across the measured
0103 range, each panel labelled "1/pT = X GeV^-1 (pT = Y GeV)". The lookup
0104 grid is likewise uniform in 1/pT and carries both columns.
0105
0106 STAGE 2 MODEL (shared machinery, unchanged from the anchor)
0107 sigma_m^2 = J1^2 r1^2 + J2^2 r2^2 + c^2,
0108 J_i = dm/dln(pT_i) = ( E_j |p_i|^2 / E_i - p_i . p_j ) / m (exact, not
0109 leg-symmetric), window-normalised core+tail Gaussian mixture NLL with
0110 mu fixed to M_PDG, per-group tail (f, k), global floor c (or one per
0111 resonance with --c_per_resonance). Stage 1 is frozen before stage 2.
0112 Convergence: the global scalars (c, u_p, f, k) get 5x the field learning
0113 rate, and an LBFGS polish with the field frozen follows the Adam epochs
0114 (Adam stalls along the c <-> (s, u_p) soft direction).
0115
0116 INJECTION CAMPAIGN (mandatory go/no-go, --inject / --inject_only)
0117 Toys are isotropic two-body decays of resonances resampled from the data
0118 (truth mass exactly M_PDG), same eta/mass-window selection, kappa == 1.
0119 pull: each toy leg draws r_cov from the DATA r_cov distribution of
0120 the matching leg; truth smearing is s0 * pT^{u0} * r_cov plus
0121 the mass floor c0. Go/no-go: (s0, u0) recovered within the
0122 Hessian errors of the reduced global fit + pull closure.
0123 absolute: the v2 (a0, b0) recovery test.
0124 A PASS certifies recovery under the model's own assumptions only.
0125
0126 NOTE ON CHECKPOINTS: model.pt is a dict {state_dict, norm, convention}.
0127 Checkpoints from any earlier version (bare state_dict, log(pT) feature,
0128 tanh-capped output) are rejected by --load_stage1. Retrain stage 1 and
0129 refit stage 2; a stage-2 refit against an old stage 1 is not a valid
0130 configuration.
0131 """
0132
0133 import argparse
0134 import math
0135 import os
0136 import sys
0137 import numpy as np
0138 import pandas as pd
0139 import torch
0140 import torch.nn as nn
0141 import torch.nn.functional as F
0142 from scipy.optimize import curve_fit
0143 import matplotlib
0144 matplotlib.use("Agg")
0145 import matplotlib.pyplot as plt
0146
0147
0148 PION_MASS = 0.13957039
0149 PROTON_MASS = 0.93827209
0150 M_K0S = 0.497611
0151 M_LAMBDA = 1.115683
0152
0153 ETA_RANGE = (-1.5, 1.5)
0154 PT_SANITY = 1e-3
0155
0156 KAPPA_GUARD = 0.05
0157
0158 U_CLAMP = 6.0
0159
0160
0161
0162 WIN = {"K0s": (0.42, 0.58), "Lambda": (1.09, 1.145), "Lambdabar": (1.09, 1.145)}
0163 MPDG = {"K0s": M_K0S, "Lambda": M_LAMBDA, "Lambdabar": M_LAMBDA}
0164 SIG0 = {"K0s": 0.010, "Lambda": 0.004, "Lambdabar": 0.004}
0165
0166
0167 A_SCALE = 0.05
0168 B_SCALE = 0.05
0169 S_SCALE = 2.0
0170 C_INIT = 0.0015
0171 F_MAX = 0.30
0172 K_MIN, K_MAX = 1.2, 4.0
0173 RCOV_VALID = (1e-5, 1.0)
0174
0175 MOTHER_PREFIXES = ("K_S0", "Lambda0")
0176
0177 LOG_SQRT_2PI = 0.5 * math.log(2.0 * math.pi)
0178 SQRT2 = math.sqrt(2.0)
0179
0180 torch.manual_seed(17)
0181 np.random.seed(17)
0182
0183
0184
0185 def cov_pack_index(a, b):
0186 """Packed lower-triangle index for a symmetric matrix, a >= b."""
0187 if a < b:
0188 a, b = b, a
0189 return a * (a + 1) // 2 + b
0190
0191
0192 def unpack_cov6(arr21):
0193 """Generic (21,) -> (6, 6) symmetric unpack from the index formula."""
0194 M = np.empty((6, 6), dtype=float)
0195 for a in range(6):
0196 for b in range(a + 1):
0197 M[a, b] = M[b, a] = arr21[cov_pack_index(a, b)]
0198 return M
0199
0200
0201 def _selftest_cov_packing():
0202 """Round-trip a synthetic symmetric 6x6 through the packing formula."""
0203 rng = np.random.default_rng(7)
0204 S = rng.standard_normal((6, 6))
0205 S = 0.5 * (S + S.T)
0206 packed = np.array([S[a, b] for a in range(6) for b in range(a + 1)])
0207 assert packed.size == 21
0208 assert np.allclose(unpack_cov6(packed), S), "cov packing self-test FAILED"
0209
0210
0211 _selftest_cov_packing()
0212
0213
0214 def cov_pt_frac_sigma(cov, phi):
0215 """r_cov-numerator: sigma(pT) from the packed covariance, per track.
0216
0217 sigma^2(pT) = (px^2 Vxx + 2 px py Vxy + py^2 Vyy) / pT^2
0218 = cos^2(phi) Vxx + 2 sin cos Vxy + sin^2(phi) Vyy.
0219 Momentum-block indices 9/13/14 hold for both the 21-entry (6x6 state) and
0220 28-entry (7x7 with E) layouts, because px, py stay at positions 3, 4.
0221 Returns (sigma_pT, layout_string).
0222 """
0223 ncol = cov.shape[1]
0224 if ncol == 21:
0225 layout = "6x6 (x,y,z,px,py,pz)"
0226 elif ncol == 28:
0227 layout = "7x7 (x,y,z,px,py,pz,E)"
0228 else:
0229 raise RuntimeError(f"Covariance branch has {ncol} entries; expected 21 or 28")
0230 ixx, ixy, iyy = (cov_pack_index(3, 3), cov_pack_index(4, 3),
0231 cov_pack_index(4, 4))
0232 Vxx, Vxy, Vyy = cov[:, ixx], cov[:, ixy], cov[:, iyy]
0233 c, s = np.cos(phi), np.sin(phi)
0234 var = c * c * Vxx + 2.0 * s * c * Vxy + s * s * Vyy
0235 return np.sqrt(np.maximum(var, 0.0)), layout, Vxx, Vyy
0236
0237
0238
0239 def _read_root_species(path, tree_name, need_pdg, max_candidates=0, rng=None, label=""):
0240 """Read one KFParticle nTuple. Returns a dict of numpy arrays.
0241
0242 If max_candidates is non-zero, select candidates while iterating over ROOT
0243 chunks instead of materialising the full multi-GB tree before subsampling.
0244 """
0245 import uproot
0246 t = uproot.open(path)[tree_name]
0247 keys = set(t.keys())
0248
0249 mother = next((p for p in MOTHER_PREFIXES if f"{p}_mass" in keys), None)
0250 if mother is None:
0251 cand = sorted({k[:-5] for k in keys
0252 if k.endswith("_mass") and not k.startswith("track_")})
0253 if len(cand) != 1:
0254 raise RuntimeError(f"{path}: cannot identify mother prefix among {cand}")
0255 mother = cand[0]
0256
0257 want = {f"{mother}_{x}": f"c{x}" for x in ("mass", "pT", "pseudorapidity", "phi")}
0258 for i in (1, 2):
0259 for x in ("pT", "pseudorapidity", "phi"):
0260 want[f"track_{i}_{x}"] = f"t{i}_{x}"
0261 opt = {}
0262 for i in (1, 2):
0263 for x in ("charge", "PDG_ID", "pTErr", "Covariance"):
0264 b = f"track_{i}_{x}"
0265 if b in keys:
0266 opt[b] = f"t{i}_{x}"
0267 missing = [b for b in want if b not in keys]
0268 if missing:
0269 raise RuntimeError(f"{path}: missing branches {missing}")
0270 if need_pdg and "track_2_PDG_ID" not in keys:
0271 raise RuntimeError(f"{path}: Lambda file needs track_i_PDG_ID branches")
0272
0273 branches = list(want) + list(opt)
0274
0275 def convert(raw):
0276 out = {"mother": mother}
0277 for b, name in {**want, **opt}.items():
0278 a = raw[b]
0279 if a.dtype == object:
0280 a = np.stack(a)
0281 out[name] = np.asarray(a)
0282 return out
0283
0284 if not max_candidates:
0285 print(f"[{label}] reading {t.num_entries} ROOT entries from {path}", flush=True)
0286 return convert(t.arrays(branches, library="np"))
0287
0288 if rng is None:
0289 rng = np.random.default_rng(42)
0290 chunks = []
0291 n_keep = 0
0292 n_seen = 0
0293 print(f"[{label}] reading ROOT chunks until {max_candidates} selected candidates", flush=True)
0294 for raw in t.iterate(branches, library="np", step_size="100 MB"):
0295 d = convert(raw)
0296 n_chunk = d["cmass"].size
0297 n_seen += n_chunk
0298
0299 if label == "K0s":
0300 lo, hi = 0.40, 0.60
0301 else:
0302 lo, hi = 1.08, 1.15
0303 m = np.isfinite(d["cmass"])
0304 for i in (1, 2):
0305 for x in ("pT", "pseudorapidity", "phi"):
0306 m &= np.isfinite(d[f"t{i}_{x}"])
0307 m &= (d["t1_pT"] > PT_SANITY) & (d["t2_pT"] > PT_SANITY)
0308 m &= (np.abs(d["t1_pseudorapidity"]) < ETA_RANGE[1])
0309 m &= (np.abs(d["t2_pseudorapidity"]) < ETA_RANGE[1])
0310 m &= (d["cmass"] > lo) & (d["cmass"] < hi)
0311 idx = np.flatnonzero(m)
0312
0313 need = max_candidates - n_keep
0314 if idx.size > need:
0315 idx = rng.choice(idx, size=need, replace=False)
0316 idx.sort()
0317 chunks.append({k: (v[idx] if isinstance(v, np.ndarray) else v) for k, v in d.items()})
0318 n_keep += idx.size
0319 print(f"[{label}] scanned {n_seen} / {t.num_entries} entries, kept {n_keep}", flush=True)
0320 if n_keep >= max_candidates:
0321 break
0322
0323 if not chunks:
0324 return convert(t.arrays(branches, entry_stop=0, library="np"))
0325 out = {"mother": mother}
0326 for k in chunks[0]:
0327 if isinstance(chunks[0][k], np.ndarray):
0328 out[k] = np.concatenate([c[k] for c in chunks])
0329 return out
0330
0331
0332 def _read_csv_species(path):
0333 df = pd.read_csv(path)
0334 df.columns = [c.strip() for c in df.columns]
0335 mother = next((p for p in MOTHER_PREFIXES if f"{p}_mass" in df.columns), None)
0336 if mother is None:
0337 raise RuntimeError(f"{path}: no recognised mother prefix in columns")
0338 out = {"mother": mother}
0339 for x in ("mass", "pT", "pseudorapidity", "phi"):
0340 out[f"c{x}"] = df[f"{mother}_{x}"].to_numpy(float)
0341 for i in (1, 2):
0342 for x in ("pT", "pseudorapidity", "phi"):
0343 out[f"t{i}_{x}"] = df[f"track_{i}_{x}"].to_numpy(float)
0344 if f"track_{i}_PDG_ID" in df.columns:
0345 out[f"t{i}_PDG_ID"] = df[f"track_{i}_PDG_ID"].to_numpy()
0346 return out
0347
0348
0349 def _species_selection(d, mass_lo, mass_hi, has_cov, label):
0350 """Eta acceptance + pT sanity + mass window + covariance validity.
0351
0352 NO daughter pT cut (task 4): only a 1 MeV sanity floor.
0353 Rejection categories are counted and printed.
0354 """
0355 n0 = d["cmass"].size
0356 counts = {}
0357
0358 def apply(mask, name):
0359 counts[name] = int((~mask).sum())
0360 return mask
0361
0362 m = np.ones(n0, bool)
0363 fin = np.ones(n0, bool)
0364 for i in (1, 2):
0365 for x in ("pT", "pseudorapidity", "phi"):
0366 fin &= np.isfinite(d[f"t{i}_{x}"])
0367 fin &= np.isfinite(d["cmass"])
0368 m &= apply(fin, "non-finite kinematics")
0369 pts = (d["t1_pT"] > PT_SANITY) & (d["t2_pT"] > PT_SANITY)
0370 m &= apply(pts, f"pT <= {PT_SANITY} GeV sanity")
0371 eta = (np.abs(d["t1_pseudorapidity"]) < ETA_RANGE[1]) & \
0372 (np.abs(d["t2_pseudorapidity"]) < ETA_RANGE[1])
0373 m &= apply(eta, "|eta| acceptance")
0374 win = (d["cmass"] > mass_lo) & (d["cmass"] < mass_hi)
0375 m &= apply(win, "mass window")
0376
0377 if has_cov:
0378 for i in (1, 2):
0379 cov = d[f"t{i}_Covariance"].astype(float)
0380 sig, layout, Vxx, Vyy = cov_pt_frac_sigma(cov, d[f"t{i}_phi"])
0381 if i == 1:
0382 print(f"[{label}] covariance layout: {layout}")
0383 rcov = sig / np.maximum(d[f"t{i}_pT"], 1e-12)
0384 ok_fin = np.isfinite(sig) & np.isfinite(Vxx) & np.isfinite(Vyy)
0385 ok_pos = (Vxx > 0) & (Vyy > 0)
0386 ok_rng = (rcov > RCOV_VALID[0]) & (rcov < RCOV_VALID[1])
0387 m &= apply(ok_fin, f"track_{i} cov non-finite")
0388 m &= apply(ok_pos, f"track_{i} cov diag <= 0")
0389 m &= apply(ok_rng, f"track_{i} r_cov outside {RCOV_VALID}")
0390 d[f"t{i}_rcov"] = rcov
0391 d[f"t{i}_sigpt"] = sig
0392
0393 for name, ncut in counts.items():
0394 if ncut:
0395 print(f"[{label}] rejected {ncut:7d} : {name}")
0396 print(f"[{label}] selected {int(m.sum())} / {n0} candidates "
0397 f"(no daughter pT cut)")
0398 return {k: (v[m] if isinstance(v, np.ndarray) else v) for k, v in d.items()}
0399
0400
0401 def _pterr_crosscheck(d, label):
0402 """sqrt(cov projection) vs the stored pTErr branch; abort on bulk mismatch."""
0403 for i in (1, 2):
0404 if f"t{i}_pTErr" not in d or f"t{i}_sigpt" not in d:
0405 continue
0406 pterr = d[f"t{i}_pTErr"].astype(float)
0407 ok = np.isfinite(pterr) & (pterr > 0)
0408 if ok.sum() < 100:
0409 continue
0410 ratio = d[f"t{i}_sigpt"][ok] / pterr[ok]
0411 med = float(np.median(ratio))
0412 frac_bad = float(np.mean(np.abs(ratio - 1.0) > 0.2))
0413 print(f"[{label}] track_{i}: median sigma_pT(cov)/pTErr = {med:.4f}, "
0414 f"frac |ratio-1|>20% = {frac_bad:.3f}")
0415 if abs(med - 1.0) > 0.2:
0416 sys.exit(f"[{label}] FATAL: covariance-projected sigma(pT) disagrees "
0417 f"with pTErr by >20% on the bulk -- packing convention is "
0418 f"wrong; everything downstream would be invalid.")
0419
0420
0421 def _subsample(d, n_max, rng, label):
0422 n = d["cmass"].size
0423 if n_max and n > n_max:
0424 idx = rng.choice(n, size=n_max, replace=False)
0425 idx.sort()
0426 d = {k: (v[idx] if isinstance(v, np.ndarray) else v) for k, v in d.items()}
0427 print(f"[{label}] subsampled {n} -> {n_max} candidates (--max_candidates)")
0428 return d
0429
0430
0431 def load(args):
0432 """Returns (K, L, meta). meta carries has_cov, the curvature-feature
0433 normalisation, the loss-bin pT edges and the data-driven plot ranges."""
0434 rng = np.random.default_rng(args.subsample_seed)
0435
0436 def read(path, need_pdg):
0437 if path.endswith(".csv"):
0438 return _read_csv_species(path), False
0439 d = _read_root_species(path, args.tree, need_pdg,
0440 max_candidates=args.max_candidates,
0441 rng=rng, label=("Lambda" if need_pdg else "K0s"))
0442 return d, ("t1_Covariance" in d and "t2_Covariance" in d)
0443
0444 dK, covK = read(args.kshort, need_pdg=False)
0445 dL, covL = read(args.lam, need_pdg=True)
0446 has_cov = covK and covL
0447 if args.reso_model == "pull" and not has_cov:
0448 sys.exit("--reso_model pull requires Covariance branches in BOTH inputs "
0449 "(ROOT ntuples). CSV input carries none: use --reso_model absolute.")
0450
0451 dK = _species_selection(dK, 0.40, 0.60, covK, "K0s")
0452 dL = _species_selection(dL, 1.08, 1.15, covL, "Lambda")
0453 if has_cov:
0454 _pterr_crosscheck(dK, "K0s")
0455 _pterr_crosscheck(dL, "Lambda")
0456 dK = _subsample(dK, args.max_candidates, rng, "K0s")
0457 dL = _subsample(dL, args.max_candidates, rng, "Lambda")
0458
0459 T = lambda x: torch.tensor(np.ascontiguousarray(x), dtype=torch.float64)
0460
0461
0462 if "t1_charge" in dK:
0463 q1K = T(np.sign(dK["t1_charge"]).astype(float))
0464 q2K = T(np.sign(dK["t2_charge"]).astype(float))
0465 else:
0466 q1K = torch.full((dK["cmass"].size,), -1.0, dtype=torch.float64)
0467 q2K = torch.full((dK["cmass"].size,), +1.0, dtype=torch.float64)
0468
0469 K = dict(pt1=T(dK["t1_pT"]), eta1=T(dK["t1_pseudorapidity"]), phi1=T(dK["t1_phi"]),
0470 pt2=T(dK["t2_pT"]), eta2=T(dK["t2_pseudorapidity"]), phi2=T(dK["t2_phi"]),
0471 q1=q1K, q2=q2K, m1=PION_MASS, m2=PION_MASS, M=M_K0S,
0472 mass=T(dK["cmass"]), cpt=T(dK["cpT"]),
0473 ceta=T(dK["cpseudorapidity"]), cphi=T(dK["cphi"]))
0474 qpi = torch.tensor(np.sign(dL["t1_PDG_ID"]).astype(float), dtype=torch.float64)
0475 qp = torch.tensor(np.sign(dL["t2_PDG_ID"]).astype(float), dtype=torch.float64)
0476 L = dict(pt1=T(dL["t1_pT"]), eta1=T(dL["t1_pseudorapidity"]), phi1=T(dL["t1_phi"]),
0477 pt2=T(dL["t2_pT"]), eta2=T(dL["t2_pseudorapidity"]), phi2=T(dL["t2_phi"]),
0478 q1=qpi, q2=qp, m1=PION_MASS, m2=PROTON_MASS, M=M_LAMBDA,
0479 mass=T(dL["cmass"]),
0480 is_lam=torch.tensor(dL["t2_PDG_ID"] > 0, dtype=torch.bool),
0481 cpt=T(dL["cpT"]), ceta=T(dL["cpseudorapidity"]), cphi=T(dL["cphi"]))
0482 if has_cov:
0483 for S, d in ((K, dK), (L, dL)):
0484 S["rcov1"] = T(d["t1_rcov"])
0485 S["rcov2"] = T(d["t2_rcov"])
0486
0487
0488 all_pt = np.concatenate([dK["t1_pT"], dK["t2_pT"], dL["t1_pT"], dL["t2_pT"]])
0489 u = 1.0 / all_pt
0490 q16, q50, q84 = np.quantile(u, [0.16, 0.50, 0.84])
0491 u_ref = float(q50)
0492 u_scale = float(max(0.5 * (q84 - q16), 1e-3))
0493 u_lo, u_hi = np.quantile(u, [0.005, 0.995])
0494 pt_lo, pt_hi = np.quantile(all_pt, [0.005, 0.995])
0495 pt_lo = min(float(pt_lo), float(args.lookup_pt_min))
0496 pt_hi = max(float(pt_hi), float(args.lookup_pt_max))
0497 u_lo = 1.0 / pt_hi
0498 u_hi = 1.0 / pt_lo
0499 pt_edges = np.unique(np.quantile(all_pt, np.linspace(0.0, 1.0, 7)))
0500 if pt_edges.size < 4:
0501 pt_edges = np.linspace(pt_lo, pt_hi, 7)
0502 pt_slices = semi_even_pt_grid(pt_lo, pt_hi, args.kappa_slices)
0503 curv_slices = 1.0 / pt_slices
0504
0505 meta = dict(has_cov=has_cov,
0506 norm=dict(u_ref=u_ref, u_scale=u_scale, u_clamp=U_CLAMP),
0507 pt_edges=pt_edges, pt_slices=pt_slices, curv_slices=curv_slices,
0508 pt_plot=(float(pt_lo), float(pt_hi)),
0509 curv_plot=(float(u_lo), float(u_hi)))
0510 print(f"[norm] curvature feature: u_ref = {u_ref:.4f} GeV^-1, "
0511 f"u_scale = {u_scale:.4f} GeV^-1, clamp +-{U_CLAMP}")
0512 print(f"[bins] daughter pT loss-bin edges (data quantiles): "
0513 f"{np.array2string(pt_edges, precision=3)}")
0514 return K, L, meta
0515
0516
0517
0518 CONVENTION_TAG = "curvature_mult_uncapped_v4"
0519
0520
0521 class KappaNet(nn.Module):
0522 """x = (u_n, eta_n, sin phi, cos phi) -> (eps, delta), UNCAPPED.
0523
0524 u = 1/pT (unsigned curvature); u_n = clamp((u - u_ref)/u_scale, +-U_CLAMP).
0525 The clamp is a numerical guard: outside it kappa is constant in curvature.
0526 Convention: (1/pT)_corr = (1/pT) * (1 + eps + q*delta); kappa() returns
0527 the pT MULTIPLIER 1/(1+eps+q*delta) so call sites keep pT_corr = kappa*pT.
0528 q enters only through the output combination -> exact even/odd split in
0529 curvature space. Near-identity init; the annealed L2 prior in train() is
0530 the only soft pull to identity and vanishes at the last epoch.
0531 """
0532
0533 def __init__(self, norm, hidden=48, layers=3):
0534 super().__init__()
0535 self.u_ref = float(norm["u_ref"])
0536 self.u_scale = float(norm["u_scale"])
0537 self.u_clamp = float(norm["u_clamp"])
0538 dims = [4] + [hidden] * layers + [2]
0539 seq = []
0540 for i in range(len(dims) - 1):
0541 seq.append(nn.Linear(dims[i], dims[i + 1]))
0542 if i < len(dims) - 2:
0543 seq.append(nn.SiLU())
0544 self.net = nn.Sequential(*seq)
0545 with torch.no_grad():
0546 self.net[-1].weight *= 0.01
0547 self.net[-1].bias.zero_()
0548
0549 def norm_dict(self):
0550 return dict(u_ref=self.u_ref, u_scale=self.u_scale, u_clamp=self.u_clamp)
0551
0552 def features(self, pt, eta, phi):
0553 u = 1.0 / pt.clamp(min=1e-6)
0554 u_n = ((u - self.u_ref) / self.u_scale).clamp(-self.u_clamp, self.u_clamp)
0555 return torch.stack([u_n, eta / 1.0, torch.sin(phi), torch.cos(phi)], -1)
0556
0557 def u_clamp_frac(self, pt):
0558 with torch.no_grad():
0559 u = 1.0 / pt.clamp(min=1e-6)
0560 u_n = (u - self.u_ref) / self.u_scale
0561 return float((u_n.abs() >= self.u_clamp).double().mean())
0562
0563 def eps_delta(self, pt, eta, phi):
0564 out = self.net(self.features(pt, eta, phi))
0565 return out[..., 0], out[..., 1]
0566
0567 def kappa(self, pt, eta, phi, q):
0568 """Returns (pT multiplier, eps, delta); pT_corr = multiplier * pT."""
0569 eps, dlt = self.eps_delta(pt, eta, phi)
0570 kcurv = (1.0 + eps + q * dlt).clamp(min=KAPPA_GUARD)
0571 return 1.0 / kcurv, eps, dlt
0572
0573
0574 class IdentityKappa(nn.Module):
0575 """kappa == 1; used to run stage 2 on injection toys."""
0576
0577 def kappa(self, pt, eta, phi, q):
0578 z = torch.zeros_like(pt)
0579 return torch.ones_like(pt), z, z
0580
0581
0582 def save_stage1(model, path):
0583 torch.save({"state_dict": model.state_dict(),
0584 "norm": model.norm_dict(),
0585 "convention": CONVENTION_TAG}, path)
0586
0587
0588 def load_stage1(path, hidden):
0589 ckpt = torch.load(path, weights_only=False)
0590 if not (isinstance(ckpt, dict) and ckpt.get("convention") == CONVENTION_TAG):
0591 sys.exit(f"--load_stage1 {path}: checkpoint predates the uncapped "
0592 f"curvature convention '{CONVENTION_TAG}' (or is a bare "
0593 f"state_dict). It would load silently with WRONG semantics. "
0594 f"Retrain stage 1.")
0595 model = KappaNet(ckpt["norm"], hidden=hidden).double()
0596 model.load_state_dict(ckpt["state_dict"])
0597 return model
0598
0599
0600
0601 def inv_mass(pt1, eta1, phi1, m1, pt2, eta2, phi2, m2):
0602 px1, py1, pz1 = pt1 * torch.cos(phi1), pt1 * torch.sin(phi1), pt1 * torch.sinh(eta1)
0603 px2, py2, pz2 = pt2 * torch.cos(phi2), pt2 * torch.sin(phi2), pt2 * torch.sinh(eta2)
0604 e1 = torch.sqrt(px1**2 + py1**2 + pz1**2 + m1**2)
0605 e2 = torch.sqrt(px2**2 + py2**2 + pz2**2 + m2**2)
0606 m2v = (e1 + e2)**2 - (px1 + px2)**2 - (py1 + py2)**2 - (pz1 + pz2)**2
0607 return torch.sqrt(m2v.clamp(min=1e-12))
0608
0609
0610 def _p4(pt, eta, phi, m):
0611 px, py, pz = pt * torch.cos(phi), pt * torch.sin(phi), pt * torch.sinh(eta)
0612 p2 = px * px + py * py + pz * pz
0613 return px, py, pz, torch.sqrt(p2 + m * m), p2
0614
0615
0616 def mass_jacobians(pt1, eta1, phi1, m1, pt2, eta2, phi2, m2):
0617 """(m, J1, J2), J_i = dm/dln(pT_i) at fixed (eta, phi); not leg-symmetric."""
0618 px1, py1, pz1, E1, p1sq = _p4(pt1, eta1, phi1, m1)
0619 px2, py2, pz2, E2, p2sq = _p4(pt2, eta2, phi2, m2)
0620 msq = (E1 + E2)**2 - (px1 + px2)**2 - (py1 + py2)**2 - (pz1 + pz2)**2
0621 m = torch.sqrt(msq.clamp(min=1e-12))
0622 pdot = px1 * px2 + py1 * py2 + pz1 * pz2
0623 J1 = (E2 * p1sq / E1 - pdot) / m
0624 J2 = (E1 * p2sq / E2 - pdot) / m
0625 return m, J1, J2
0626
0627
0628 def beta_of(pt, eta, m):
0629 p = pt * torch.cosh(eta)
0630 return p / torch.sqrt(p * p + m * m)
0631
0632
0633
0634 def gauss_lin(x, N, mu, sig, a, b):
0635 return N * np.exp(-0.5 * ((x - mu) / sig)**2) + a + b * x
0636
0637
0638 def fit_peak(masses, mu0, sig0, lo, hi, nbins=100, weights=None):
0639 """Gaussian+linear fit with floating peak. Returns (mu, sig, popt)."""
0640 h, edges = np.histogram(masses, bins=nbins, range=(lo, hi), weights=weights)
0641 c = 0.5 * (edges[:-1] + edges[1:])
0642 p0 = [h.max() - np.median(h), mu0, sig0, np.median(h), 0.0]
0643 try:
0644 popt, _ = curve_fit(gauss_lin, c, h, p0=p0,
0645 sigma=np.sqrt(np.maximum(h, 1)), maxfev=20000)
0646 if not (lo < popt[1] < hi) or not (0.2 * sig0 < abs(popt[2]) < 5 * sig0):
0647 raise RuntimeError
0648 except Exception:
0649 popt = p0
0650 return popt[1], abs(popt[2]), popt
0651
0652
0653 def signal_weights(masses, popt):
0654 s = popt[0] * np.exp(-0.5 * ((masses - popt[1]) / popt[2])**2)
0655 b = np.maximum(popt[3] + popt[4] * masses, 0.0)
0656 w = s / np.maximum(s + b, 1e-9)
0657 return np.clip(w, 0.0, 1.0)
0658
0659
0660
0661 def semi_even_pt_grid(pt_min, pt_max, n):
0662 """Human-readable pT grid: dense below 3 GeV, sparse at the high end."""
0663 anchors = np.array([0.10, 0.25, 0.50, 0.75, 1.00, 1.25, 1.50, 1.75,
0664 2.00, 2.25, 2.50, 2.75, 3.00, 4.00, 5.00], dtype=float)
0665 lo, hi = float(pt_min), float(pt_max)
0666 vals = anchors[(anchors >= lo) & (anchors <= hi)]
0667 vals = np.unique(np.concatenate(([lo], vals, [hi])))
0668 if vals.size == n:
0669 return vals
0670 q = np.linspace(0.0, 1.0, n)
0671 return np.interp(q, np.linspace(0.0, 1.0, vals.size), vals)
0672
0673
0674 def bin_index(pt, eta, phi, pt_edges, n_eta, n_phi):
0675 ipt = torch.bucketize(pt, pt_edges) - 1
0676 ipt = ipt.clamp(0, len(pt_edges) - 2)
0677 ieta = ((eta - ETA_RANGE[0]) / (ETA_RANGE[1] - ETA_RANGE[0]) * n_eta).long().clamp(0, n_eta - 1)
0678 iphi = ((phi + np.pi) / (2 * np.pi) * n_phi).long().clamp(0, n_phi - 1)
0679 return (ipt * n_eta + ieta) * n_phi + iphi, (len(pt_edges) - 1) * n_eta * n_phi
0680
0681
0682 def binned_mean_sq(idx, nbins, values, weights, min_w=5.0):
0683 """sum_b w_b * mean_b^2, weighted means via scatter-add (differentiable)."""
0684 sw = torch.zeros(nbins, dtype=values.dtype).index_add_(0, idx, weights)
0685 swv = torch.zeros(nbins, dtype=values.dtype).index_add_(0, idx, weights * values)
0686 mask = sw > min_w
0687 mean = swv[mask] / sw[mask]
0688 return (sw[mask] * mean**2).sum() / sw[mask].sum().clamp(min=1e-9)
0689
0690
0691 def corrected_mass(model, S):
0692 k1, e1, d1 = model.kappa(S["pt1"], S["eta1"], S["phi1"], S["q1"])
0693 k2, e2, d2 = model.kappa(S["pt2"], S["eta2"], S["phi2"], S["q2"])
0694 m = inv_mass(k1 * S["pt1"], S["eta1"], S["phi1"], S["m1"],
0695 k2 * S["pt2"], S["eta2"], S["phi2"], S["m2"])
0696 reg = (e1**2 + d1**2 + e2**2 + d2**2).mean()
0697 return m, (k1, k2), reg
0698
0699
0700
0701 def train(args, K, L, meta):
0702 model = KappaNet(meta["norm"], hidden=args.hidden).double()
0703 opt = torch.optim.Adam(model.parameters(), lr=args.lr, weight_decay=1e-5)
0704
0705 pt_edges = torch.tensor(meta["pt_edges"], dtype=torch.float64)
0706 N_ETA, N_PHI = 10, 8
0707
0708 def refresh_weights():
0709 with torch.no_grad():
0710 mK, _, _ = corrected_mass(model, K)
0711 mL, _, _ = corrected_mass(model, L)
0712 muK, sK, pK = fit_peak(mK.numpy(), M_K0S, 0.010, 0.42, 0.58)
0713 muL, sL, pL = fit_peak(mL.numpy(), M_LAMBDA, 0.004, 1.09, 1.145)
0714 K["w"] = torch.tensor(signal_weights(mK.numpy(), pK))
0715 L["w"] = torch.tensor(signal_weights(mL.numpy(), pL))
0716 return (muK, sK), (muL, sL)
0717
0718 (muK0, _), (muL0, _) = refresh_weights()
0719 print(f"raw peaks: K0s {muK0*1e3:8.3f} MeV (PDG {M_K0S*1e3:.3f}), "
0720 f"Lambda {muL0*1e3:8.3f} MeV (PDG {M_LAMBDA*1e3:.3f})")
0721
0722 for ep in range(args.epochs):
0723 opt.zero_grad()
0724 loss = torch.tensor(0.0, dtype=torch.float64)
0725 prior_w = args.lam_prior * (1.0 - ep / max(args.epochs - 1, 1))
0726
0727 for S in (K, L):
0728 m, _, reg = corrected_mass(model, S)
0729 pull = (m - S["M"]) / (0.010 if S is K else 0.004)
0730 w = S["w"]
0731 for (pt, eta, phi, q) in ((S["pt1"], S["eta1"], S["phi1"], S["q1"]),
0732 (S["pt2"], S["eta2"], S["phi2"], S["q2"])):
0733 idx, nb = bin_index(pt, eta, phi, pt_edges, N_ETA, N_PHI)
0734 idx = idx + ((q > 0).long() * nb)
0735 loss = loss + binned_mean_sq(idx, 2 * nb, pull, w)
0736 loss = loss + prior_w * reg
0737
0738 mL, _, _ = corrected_mass(model, L)
0739 pullL = (mL - M_LAMBDA) / 0.004
0740 idx, nb = bin_index(L["pt1"], L["eta1"], L["phi1"], pt_edges, N_ETA, N_PHI)
0741 sel_l, sel_b = L["is_lam"], ~L["is_lam"]
0742 wl = L["w"]
0743 swl = torch.zeros(nb, dtype=torch.float64).index_add_(0, idx[sel_l], wl[sel_l])
0744 swvl = torch.zeros(nb, dtype=torch.float64).index_add_(0, idx[sel_l], (wl * pullL)[sel_l])
0745 swb = torch.zeros(nb, dtype=torch.float64).index_add_(0, idx[sel_b], wl[sel_b])
0746 swvb = torch.zeros(nb, dtype=torch.float64).index_add_(0, idx[sel_b], (wl * pullL)[sel_b])
0747 mk = (swl > 5) & (swb > 5)
0748 split = swvl[mk] / swl[mk] - swvb[mk] / swb[mk]
0749 wsum = (swl[mk] * swb[mk]) / (swl[mk] + swb[mk])
0750 loss = loss + args.lam_split * (wsum * split**2).sum() / wsum.sum().clamp(min=1e-9)
0751
0752 k1, _, _ = model.kappa(K["pt1"], K["eta1"], K["phi1"], K["q1"])
0753 k2, _, _ = model.kappa(K["pt2"], K["eta2"], K["phi2"], K["q2"])
0754 p1, p2 = k1 * K["pt1"], k2 * K["pt2"]
0755 alpha = (p1 - p2) / (p1 + p2)
0756 idxc, nbc = bin_index(K["cpt"], K["ceta"], K["cphi"],
0757 pt_edges, N_ETA, N_PHI)
0758 loss = loss + args.lam_alpha * binned_mean_sq(idxc, nbc, alpha, K["w"])
0759
0760 loss.backward()
0761 opt.step()
0762
0763 if ep % args.refresh == args.refresh - 1:
0764 (muK, _), (muL, _) = refresh_weights()
0765 print(f"ep {ep+1:4d} loss {loss.item():9.5f} "
0766 f"K0s peak {muK*1e3:8.3f} Lam peak {muL*1e3:8.3f} MeV")
0767
0768 return model
0769
0770
0771
0772 class ResoNet(nn.Module):
0773 """(eta, sin phi, cos phi) -> fields, mode-dependent.
0774
0775 absolute: two heads (a, b), r^2 = (a/beta)^2 + (b pT)^2 -- a MEASUREMENT
0776 of the resolution.
0777 pull: one head s(eta, phi), r = s * pT^{u_p} * r_cov -- a CALIBRATION
0778 of the covariance-reported resolution (s == 1, u_p == 0 means
0779 the tracker's errors are right).
0780 Deliberately NOT a function of pT (identifiability); species enters only
0781 through beta (absolute) or through r_cov itself (pull).
0782 """
0783
0784 def __init__(self, mode, hidden=32, layers=3):
0785 super().__init__()
0786 self.mode = mode
0787 n_out = 2 if mode == "absolute" else 1
0788 dims = [3] + [hidden] * layers + [n_out]
0789 seq = []
0790 for i in range(len(dims) - 1):
0791 seq.append(nn.Linear(dims[i], dims[i + 1]))
0792 if i < len(dims) - 2:
0793 seq.append(nn.SiLU())
0794 self.net = nn.Sequential(*seq)
0795 with torch.no_grad():
0796 self.net[-1].weight *= 0.01
0797 if mode == "absolute":
0798
0799 self.net[-1].bias.fill_(-1.443)
0800 else:
0801
0802 self.net[-1].bias.fill_(math.log(math.expm1(1.0 / S_SCALE)))
0803
0804 def features(self, eta, phi):
0805 return torch.stack([eta / 1.0, torch.sin(phi), torch.cos(phi)], -1)
0806
0807 def ab(self, eta, phi):
0808 out = self.net(self.features(eta, phi))
0809 return A_SCALE * F.softplus(out[..., 0]), B_SCALE * F.softplus(out[..., 1])
0810
0811 def s(self, eta, phi):
0812 out = self.net(self.features(eta, phi))
0813 return S_SCALE * F.softplus(out[..., 0])
0814
0815
0816 class ResoGlobals(nn.Module):
0817 """Floor c (1 or per-resonance), per-group (f, k), and the pull exponent u_p."""
0818
0819 def __init__(self, n_groups, n_c=1):
0820 super().__init__()
0821 c_raw0 = math.log(math.expm1(C_INIT))
0822 self.c_raw = nn.Parameter(torch.full((n_c,), c_raw0, dtype=torch.float64))
0823 self.f_raw = nn.Parameter(torch.full((n_groups,), -2.0, dtype=torch.float64))
0824 self.k_raw = nn.Parameter(torch.zeros(n_groups, dtype=torch.float64))
0825 self.up = nn.Parameter(torch.zeros(1, dtype=torch.float64))
0826
0827 def c(self, i=0):
0828 return F.softplus(self.c_raw[i])
0829
0830 def f(self, i):
0831 return F_MAX * torch.sigmoid(self.f_raw[i])
0832
0833 def k(self, i):
0834 return K_MIN + (K_MAX - K_MIN) * torch.sigmoid(self.k_raw[i])
0835
0836 def u_p(self):
0837 return self.up[0]
0838
0839
0840 def r_terms(a, b, pt, beta):
0841 return a / beta, b * pt
0842
0843
0844 def sigma_mass_ab(R, a1, b1, a2, b2, c):
0845 t1a, t1b = r_terms(a1, b1, R["pt1"], R["beta1"])
0846 t2a, t2b = r_terms(a2, b2, R["pt2"], R["beta2"])
0847 r1sq = t1a**2 + t1b**2
0848 r2sq = t2a**2 + t2b**2
0849 return torch.sqrt((R["J1"]**2 * r1sq + R["J2"]**2 * r2sq + c**2).clamp(min=1e-12))
0850
0851
0852 def pull_r(R, leg, s, up):
0853 return s * R[f"pt{leg}"].clamp(min=1e-6)**up * R[f"rcov{leg}"]
0854
0855
0856 def sigma_mass_pull(R, s1, s2, up, c):
0857 r1 = pull_r(R, 1, s1, up)
0858 r2 = pull_r(R, 2, s2, up)
0859 return torch.sqrt((R["J1"]**2 * r1**2 + R["J2"]**2 * r2**2 + c**2).clamp(min=1e-12))
0860
0861
0862 def sigma_mass_net(R, net, glob, c):
0863 if net.mode == "absolute":
0864 a1, b1 = net.ab(R["eta1"], R["phi1"])
0865 a2, b2 = net.ab(R["eta2"], R["phi2"])
0866 return sigma_mass_ab(R, a1, b1, a2, b2, c)
0867 s1 = net.s(R["eta1"], R["phi1"])
0868 s2 = net.s(R["eta2"], R["phi2"])
0869 return sigma_mass_pull(R, s1, s2, glob.u_p(), c)
0870
0871
0872 def log_mixture_pdf(m, M, sigma, f, k, lo, hi):
0873 """ln of the window-normalised core+tail Gaussian mixture."""
0874 s1, s2 = sigma, k * sigma
0875 lg1 = -0.5 * ((m - M) / s1)**2 - torch.log(s1) - LOG_SQRT_2PI
0876 lg2 = -0.5 * ((m - M) / s2)**2 - torch.log(s2) - LOG_SQRT_2PI
0877 lp = torch.logaddexp(torch.log1p(-f) + lg1, torch.log(f) + lg2)
0878 frac = lambda s: 0.5 * (torch.erf((hi - M) / (s * SQRT2))
0879 - torch.erf((lo - M) / (s * SQRT2)))
0880 Z = (1.0 - f) * frac(s1) + f * frac(s2)
0881 return lp - torch.log(Z.clamp(min=1e-12))
0882
0883
0884 def width_nll_sum(R, M, sigma, w, f, k, lo, hi):
0885 return -(w * log_mixture_pdf(R["m"], M, sigma, f, k, lo, hi)).sum()
0886
0887
0888
0889 def prepare_reso_inputs(model, S):
0890 """Freeze the scale, precompute everything stage 2 needs (constant with
0891 KappaNet frozen). Jacobians and beta use the CORRECTED momenta. r_cov is
0892 carried through unchanged: it is a fractional resolution, invariant under
0893 the kappa scale to first order (and identical in pT and curvature)."""
0894 with torch.no_grad():
0895 k1, _, _ = model.kappa(S["pt1"], S["eta1"], S["phi1"], S["q1"])
0896 k2, _, _ = model.kappa(S["pt2"], S["eta2"], S["phi2"], S["q2"])
0897 pt1, pt2 = k1 * S["pt1"], k2 * S["pt2"]
0898 m, J1, J2 = mass_jacobians(pt1, S["eta1"], S["phi1"], S["m1"],
0899 pt2, S["eta2"], S["phi2"], S["m2"])
0900 if "dm" in S:
0901 m = m + S["dm"]
0902 R = dict(m=m, J1=J1, J2=J2,
0903 pt1=pt1, eta1=S["eta1"], phi1=S["phi1"],
0904 pt2=pt2, eta2=S["eta2"], phi2=S["phi2"],
0905 beta1=beta_of(pt1, S["eta1"], S["m1"]),
0906 beta2=beta_of(pt2, S["eta2"], S["m2"]),
0907 cpt=S["cpt"], ceta=S["ceta"], cphi=S["cphi"],
0908 m1=S["m1"], m2=S["m2"])
0909 for key in ("rcov1", "rcov2"):
0910 if key in S:
0911 R[key] = S[key]
0912 return R
0913
0914
0915 def subset_R(R, sel):
0916 out = {}
0917 for k, v in R.items():
0918 out[k] = v[sel] if torch.is_tensor(v) else v
0919 return out
0920
0921
0922 def build_groups(model, K, L, pure_signal=False):
0923 """Three groups: K0s, Lambda, Lambdabar (own tail params; shared ResoNet)."""
0924 RK = prepare_reso_inputs(model, K)
0925 RL = prepare_reso_inputs(model, L)
0926
0927 if pure_signal:
0928 wK = torch.ones_like(RK["m"])
0929 wL = torch.ones_like(RL["m"])
0930 else:
0931 _, _, pK = fit_peak(RK["m"].numpy(), M_K0S, SIG0["K0s"], *WIN["K0s"])
0932 _, _, pL = fit_peak(RL["m"].numpy(), M_LAMBDA, SIG0["Lambda"], *WIN["Lambda"])
0933 wK = torch.tensor(signal_weights(RK["m"].numpy(), pK))
0934 wL = torch.tensor(signal_weights(RL["m"].numpy(), pL))
0935
0936 isl = L["is_lam"]
0937 groups = [
0938 dict(name="K0s", R=RK, w=wK, c_idx=0),
0939 dict(name="Lambda", R=subset_R(RL, isl), w=wL[isl], c_idx=0),
0940 dict(name="Lambdabar", R=subset_R(RL, ~isl), w=wL[~isl], c_idx=0),
0941 ]
0942 for g in groups:
0943 g["M"] = MPDG[g["name"]]
0944 g["lo"], g["hi"] = WIN[g["name"]]
0945 return groups
0946
0947
0948 def set_c_indices(groups, per_resonance):
0949 if not per_resonance:
0950 for g in groups:
0951 g["c_idx"] = 0
0952 return 1
0953 for g in groups:
0954 g["c_idx"] = 0 if g["name"] == "K0s" else 1
0955 return 2
0956
0957
0958
0959 def train_resolution(groups, args, n_c=1, verbose=True):
0960 net = ResoNet(args.reso_model, hidden=args.reso_hidden,
0961 layers=args.reso_layers).double()
0962 glob = ResoGlobals(len(groups), n_c=n_c).double()
0963
0964 opt = torch.optim.Adam([
0965 {"params": net.parameters(), "lr": args.reso_lr},
0966 {"params": glob.parameters(), "lr": 5.0 * args.reso_lr},
0967 ], weight_decay=0.0)
0968 wtot = sum(float(g["w"].sum()) for g in groups)
0969
0970 def total_nll():
0971 nll = torch.tensor(0.0, dtype=torch.float64)
0972 for gi, g in enumerate(groups):
0973 sig = sigma_mass_net(g["R"], net, glob, glob.c(g["c_idx"]))
0974 nll = nll + width_nll_sum(g["R"], g["M"], sig, g["w"],
0975 glob.f(gi), glob.k(gi), g["lo"], g["hi"])
0976 return nll
0977
0978 for ep in range(args.reso_epochs):
0979 opt.zero_grad()
0980 loss = total_nll() / wtot
0981 loss.backward()
0982 opt.step()
0983 if verbose and ep % max(args.reso_epochs // 10, 1) == 0:
0984 with torch.no_grad():
0985 cs = " ".join(f"c{i}={float(glob.c(i))*1e3:.3f}" for i in range(n_c))
0986 extra = (f" u_p={float(glob.u_p()):+.4f}"
0987 if args.reso_model == "pull" else "")
0988 fs = " ".join(f"{g['name'][:4]}: f={float(glob.f(gi)):.3f} "
0989 f"k={float(glob.k(gi)):.2f}"
0990 for gi, g in enumerate(groups))
0991 print(f" reso ep {ep+1:4d} -lnL/w {loss.item():10.5f} "
0992 f"{cs} MeV{extra} {fs}")
0993
0994
0995 for p in net.parameters():
0996 p.requires_grad_(False)
0997 with torch.no_grad():
0998 best = float(total_nll())
0999 state0 = {k: v.clone() for k, v in glob.state_dict().items()}
1000 lb = torch.optim.LBFGS(list(glob.parameters()), lr=0.5, max_iter=100,
1001 tolerance_grad=1e-10, line_search_fn="strong_wolfe")
1002
1003 def closure():
1004 lb.zero_grad()
1005 v = total_nll()
1006 v.backward()
1007 return v
1008 try:
1009 lb.step(closure)
1010 except Exception as e:
1011 print(f" [train_resolution] LBFGS polish skipped: {e}")
1012 with torch.no_grad():
1013 v_pol = float(total_nll())
1014 if not np.isfinite(v_pol) or v_pol > best:
1015 glob.load_state_dict(state0)
1016 print(" [train_resolution] LBFGS polish rejected (no improvement)")
1017 else:
1018 print(f" [train_resolution] LBFGS polish: nll {best:.3f} -> {v_pol:.3f}")
1019 for p in net.parameters():
1020 p.requires_grad_(True)
1021 return net, glob
1022
1023
1024
1025 def fit_global_reduced(groups, mode, n_c=1, steps=3000, lr=0.02):
1026 """Reduced fit with constant fields, for a Hessian covariance.
1027
1028 absolute: theta = (ln a, ln b, c..., f..., k...)
1029 pull: theta = (ln s, u_p, c..., f..., k...)
1030 Purpose is the injection go/no-go ('recovered within uncertainties');
1031 ResoNet itself carries no error bars.
1032 """
1033 ng = len(groups)
1034 if mode == "absolute":
1035 head0 = [math.log(0.0106), math.log(0.0106)]
1036 else:
1037 head0 = [0.0, 0.0]
1038 theta0 = torch.tensor(head0 +
1039 [math.log(math.expm1(C_INIT))] * n_c +
1040 [-2.0] * ng + [0.0] * ng, dtype=torch.float64)
1041
1042 def unpack(t):
1043
1044 if mode == "absolute":
1045 h1, h2 = torch.exp(t[0].clamp(-12, 2)), torch.exp(t[1].clamp(-12, 2))
1046 else:
1047 h1, h2 = torch.exp(t[0].clamp(-6, 6)), t[1].clamp(-3, 3)
1048 c = F.softplus(t[2:2 + n_c])
1049 f = F_MAX * torch.sigmoid(t[2 + n_c:2 + n_c + ng])
1050 k = K_MIN + (K_MAX - K_MIN) * torch.sigmoid(t[2 + n_c + ng:])
1051 return h1, h2, c, f, k
1052
1053 def nll(t):
1054 h1, h2, c, f, k = unpack(t)
1055 tot = torch.tensor(0.0, dtype=torch.float64)
1056 for gi, g in enumerate(groups):
1057 R = g["R"]
1058 if mode == "absolute":
1059 sig = sigma_mass_ab(R, h1, h2, h1, h2, c[g["c_idx"]])
1060 else:
1061 sig = sigma_mass_pull(R, h1, h1, h2, c[g["c_idx"]])
1062 tot = tot + width_nll_sum(R, g["M"], sig, g["w"], f[gi], k[gi],
1063 g["lo"], g["hi"])
1064 return tot
1065
1066 t = theta0.clone().requires_grad_(True)
1067 opt = torch.optim.Adam([t], lr=lr)
1068 best, t_best = float("inf"), theta0.clone()
1069 for _ in range(steps):
1070 opt.zero_grad()
1071 v = nll(t)
1072 v.backward()
1073 opt.step()
1074 fv = float(v.detach())
1075 if np.isfinite(fv) and fv < best:
1076 best, t_best = fv, t.detach().clone()
1077
1078 lb = torch.optim.LBFGS([t], lr=0.5, max_iter=200,
1079 tolerance_grad=1e-10, line_search_fn="strong_wolfe")
1080
1081 def closure():
1082 lb.zero_grad()
1083 v = nll(t)
1084 v.backward()
1085 return v
1086 try:
1087 lb.step(closure)
1088 except Exception as e:
1089 print(f" [fit_global_reduced] LBFGS polish skipped: {e}")
1090 with torch.no_grad():
1091 v_pol = float(nll(t)) if torch.isfinite(t).all() else float("inf")
1092 if not np.isfinite(v_pol) or v_pol > best:
1093 with torch.no_grad():
1094 t.copy_(t_best)
1095 else:
1096 best = v_pol
1097
1098 td = t.detach()
1099 H = torch.autograd.functional.hessian(nll, td)
1100 if not torch.isfinite(H).all():
1101 print(" [fit_global_reduced] Hessian non-finite at the polished point; "
1102 "retrying at the Adam-best point")
1103 td = t_best.clone()
1104 H = torch.autograd.functional.hessian(nll, td)
1105 pd, ev = False, None
1106 cov = torch.full_like(H, float("nan"))
1107 if torch.isfinite(H).all():
1108 try:
1109 ev = torch.linalg.eigvalsh(0.5 * (H + H.T))
1110 tol = 1e-8 * float(ev.abs().max())
1111 pd = bool((ev > -tol).all())
1112 cov = torch.linalg.inv(H) if pd else torch.linalg.pinv(H)
1113 except Exception as e:
1114 print(f" [fit_global_reduced] eigen-decomposition failed: {e}")
1115 else:
1116 print(" [fit_global_reduced] Hessian contains non-finite entries")
1117 if not torch.isfinite(cov).all():
1118 print(" [fit_global_reduced] covariance not usable; errors reported as nan")
1119 cov = torch.full_like(H, float("nan"))
1120 if ev is not None and not pd:
1121 print(f" [fit_global_reduced] Hessian not positive definite "
1122 f"(min eig {float(ev.min()):.3e}) -- fit is not at a minimum")
1123
1124 def _err(i):
1125 v = float(cov[i, i])
1126 return math.sqrt(v) if np.isfinite(v) and v > 0 else float("nan")
1127
1128 def _rho(i, j):
1129 d = math.sqrt(float(cov[i, i]) * float(cov[j, j]))
1130 return float(cov[i, j]) / d if np.isfinite(d) and d > 0 else float("nan")
1131
1132 h1, h2, c, f, k = unpack(td)
1133 out = dict(pos_def=pd, c=c.detach().numpy(), f=f.detach().numpy(),
1134 k=k.detach().numpy(), nll=float(nll(td)))
1135 if mode == "absolute":
1136 out.update(a=float(h1), b=float(h2),
1137 sa=float(h1) * _err(0), sb=float(h2) * _err(1),
1138 rho_ab=_rho(0, 1), rho_ac=_rho(0, 2))
1139 else:
1140 out.update(s=float(h1), u_p=float(h2),
1141 ss=float(h1) * _err(0), su=_err(1),
1142 rho_su=_rho(0, 1), rho_sc=_rho(0, 2))
1143 return out
1144
1145
1146
1147 def export_lookup(model, meta, path):
1148 """kappa is the pT MULTIPLIER (pT_corr = kappa * pT); the grid is uniform
1149 in 1/pT (task 5) and both columns are exported. Normalisation constants
1150 are stamped so the map is reproducible at deployment time."""
1151 pts = semi_even_pt_grid(meta["pt_plot"][0], meta["pt_plot"][1], 60)
1152 curvs = 1.0 / pts
1153 etas = np.linspace(*ETA_RANGE, 13)
1154 phis = np.linspace(-np.pi, np.pi, 25)
1155 rows = []
1156 with torch.no_grad():
1157 for q in (-1.0, 1.0):
1158 U, E, Fm = np.meshgrid(curvs, etas, phis, indexing="ij")
1159 P = 1.0 / U
1160 t = lambda a: torch.tensor(a.ravel(), dtype=torch.float64)
1161 kap, eps, dlt = model.kappa(t(P), t(E), t(Fm),
1162 torch.full((P.size,), q, dtype=torch.float64))
1163 rows.append(np.column_stack([np.full(P.size, q), P.ravel(), U.ravel(),
1164 E.ravel(), Fm.ravel(), kap.numpy(),
1165 (1.0 / kap).numpy(),
1166 eps.numpy(), dlt.numpy()]))
1167 with open(path, "w") as fh:
1168 fh.write("# convention: (1/pT)_corr = (1/pT) * kappa_curv, "
1169 "kappa_curv = 1 + eps + q*delta (uncapped)\n")
1170 fh.write("# kappa is the pT multiplier consumed downstream: "
1171 "pT_corr = kappa * pT = pT / kappa_curv\n")
1172 fh.write(f"# feature norm: u_ref = {model.u_ref:.6f} GeV^-1, "
1173 f"u_scale = {model.u_scale:.6f} GeV^-1, "
1174 f"u_clamp = {model.u_clamp:.1f} (kappa constant in 1/pT "
1175 f"outside the clamp)\n")
1176 fh.write(f"# convention tag: {CONVENTION_TAG}\n")
1177 fh.write("# CAVEAT: trained on vertex-constrained daughter kinematics; "
1178 "deployed on raw SvtxTrackMap tracks (calibration/deployment "
1179 "mismatch is documented in the analysis note)\n")
1180 fh.write("q,pT,curv,eta,phi,kappa,kappa_curv,eps,delta\n")
1181 np.savetxt(fh, np.vstack(rows), delimiter=",", fmt="%.6f")
1182
1183
1184 PULL_INTERPRETATION = [
1185 "PULL MODEL: s and u_p are a CALIBRATION of the covariance-reported",
1186 "resolution, r = s(eta,phi) * pT^{u_p} * r_cov -- not a measurement of the",
1187 "resolution itself. s==1, u_p==0 means the tracker's errors are correct.",
1188 "s mixes (i) resolution missed by the Kalman hit-error/material model and",
1189 "(ii) covariance mis-estimation; nothing here separates them.",
1190 "The stored covariance is POST-VERTEX-CONSTRAINT: s is defined relative to",
1191 "constrained covariances and must NOT be applied to raw SvtxTrack",
1192 "covariances (unmeasured constraint-shrinkage factor).",
1193 "s at low pT is partially degenerate with the floor c: see rho(s,c).",
1194 "No r grid is exported: r depends on the per-track covariance; consumers",
1195 "rebuild r = s(eta,phi) * pT^{u_p} * r_cov from their own covariance.",
1196 ]
1197
1198
1199 def export_reso_lookup(net, glob, groups, path, n_c=1):
1200 """absolute: (eta, phi, a, b) fields. pull: (eta, phi, s) plus u_p in the
1201 header. Never an evaluated r grid (species/covariance dependence)."""
1202 etas = np.linspace(*ETA_RANGE, 25)
1203 phis = np.linspace(-np.pi, np.pi, 25)
1204 E, P = np.meshgrid(etas, phis, indexing="ij")
1205 t = lambda x: torch.tensor(x.ravel(), dtype=torch.float64)
1206 hdr = []
1207 with torch.no_grad():
1208 if net.mode == "absolute":
1209 a, b = net.ab(t(E), t(P))
1210 cols = np.column_stack([E.ravel(), P.ravel(), a.numpy(), b.numpy()])
1211 colhdr = "eta,phi,a,b"
1212 hdr.append("model: r^2 = (a(eta,phi)/beta)^2 + (b(eta,phi)*pT)^2 ; "
1213 "sigma_m^2 = J1^2 r1^2 + J2^2 r2^2 + c^2")
1214 hdr.append("a dimensionless (enters as a/beta); b in 1/GeV; c in GeV")
1215 else:
1216 s = net.s(t(E), t(P))
1217 cols = np.column_stack([E.ravel(), P.ravel(), s.numpy()])
1218 colhdr = "eta,phi,s"
1219 hdr.append("model: r = s(eta,phi) * pT^{u_p} * r_cov ; "
1220 "sigma_m^2 = J1^2 r1^2 + J2^2 r2^2 + c^2")
1221 hdr.append(f"u_p = {float(glob.u_p()):+.6f}")
1222 hdr.extend(PULL_INTERPRETATION)
1223 for i in range(n_c):
1224 hdr.append(f"c[{i}] = {float(glob.c(i)):.6e} GeV")
1225 for gi, g in enumerate(groups):
1226 hdr.append(f"tail {g['name']}: f = {float(glob.f(gi)):.4f}, "
1227 f"k = {float(glob.k(gi)):.4f}")
1228 with open(path, "w") as fh:
1229 for h in hdr:
1230 fh.write("# " + h + "\n")
1231 fh.write(colhdr + "\n")
1232 np.savetxt(fh, cols, delimiter=",", fmt="%.6e")
1233
1234
1235 def _savefig(fig, outdir, name):
1236 fig.tight_layout()
1237 fig.savefig(os.path.join(outdir, name + ".png"), dpi=130)
1238 fig.savefig(os.path.join(outdir, name + ".pdf"))
1239 plt.close(fig)
1240
1241
1242 def export_mass_histograms_root(K, L, mK, mL, outdir):
1243 """Write ROOT TH1D mass spectra before/after stage-1 correction."""
1244 import uproot
1245
1246 isl = L["is_lam"].numpy()
1247 specs = [
1248 ("kshort", K["mass"].numpy(), mK, (0.45, 0.55)),
1249 ("lambda", L["mass"].numpy()[isl], mL[isl], (1.09, 1.15)),
1250 ("anti_lambda", L["mass"].numpy()[~isl], mL[~isl], (1.09, 1.15)),
1251 ]
1252 path = os.path.join(outdir, "stage1_mass_histograms.root")
1253 with uproot.recreate(path) as f:
1254 for name, raw, cor, rng in specs:
1255 edges = np.linspace(rng[0], rng[1], 51)
1256 h_raw, _ = np.histogram(raw, bins=edges)
1257 h_cor, _ = np.histogram(cor, bins=edges)
1258 f[f"{name}_mass_raw"] = (h_raw.astype(np.float64), edges)
1259 f[f"{name}_mass_corrected"] = (h_cor.astype(np.float64), edges)
1260 print(f"wrote stage-1 raw/corrected mass histograms to {path}")
1261
1262
1263 def plot_mass_1d(K, L, mK, mL, outdir):
1264 """Figure 1: raw vs corrected 1D mass, K0s / Lambda / Lambdabar."""
1265 isl = L["is_lam"].numpy()
1266 fig, ax = plt.subplots(1, 3, figsize=(15, 4.2))
1267 for a, raw, cor, pdg, rng, t in (
1268 (ax[0], K["mass"].numpy(), mK, M_K0S, (0.42, 0.58), "K0s"),
1269 (ax[1], L["mass"].numpy()[isl], mL[isl], M_LAMBDA, (1.09, 1.145), "Lambda"),
1270 (ax[2], L["mass"].numpy()[~isl], mL[~isl], M_LAMBDA, (1.09, 1.145), "Lambdabar")):
1271 a.hist(raw, bins=80, range=rng, histtype="step", label="raw")
1272 a.hist(cor, bins=80, range=rng, histtype="step", label="corrected")
1273 a.axvline(pdg, ls="--", c="k", lw=0.8, label="PDG")
1274 a.set_title(t); a.set_xlabel("m [GeV]"); a.set_ylabel("candidates / bin")
1275 a.legend(fontsize=8)
1276 fig.suptitle("Invariant mass: raw vs corrected")
1277 _savefig(fig, outdir, "fig1_mass_1d")
1278
1279
1280 def plot_mass_vs_kin_2d(K, L, mK, mL, outdir, kind, pt_plot):
1281 """Figures 2/3: 2D histogram of mass (y) vs candidate pT or eta (x)."""
1282 isl = L["is_lam"].numpy()
1283 if kind == "pt":
1284 xK, xL = K["cpt"].numpy(), L["cpt"].numpy()
1285 xrange, xlabel = (0.0, 1.3 * pt_plot[1]), r"candidate $p_T$ [GeV]"
1286 figname = "fig2_mass_vs_pt_2d"
1287 else:
1288 xK, xL = K["ceta"].numpy(), L["ceta"].numpy()
1289 xrange, xlabel = (-2.5, 2.5), "candidate eta"
1290 figname = "fig3_mass_vs_eta_2d"
1291
1292 rows = [
1293 ("K0s", xK, K["mass"].numpy(), mK, (0.42, 0.58), M_K0S),
1294 ("Lambda", xL[isl], L["mass"].numpy()[isl], mL[isl], (1.09, 1.145), M_LAMBDA),
1295 ("Lambdabar", xL[~isl], L["mass"].numpy()[~isl], mL[~isl], (1.09, 1.145), M_LAMBDA),
1296 ]
1297 fig, ax = plt.subplots(3, 2, figsize=(10, 12))
1298 for i, (name, x, mraw, mcor, mrange, pdg) in enumerate(rows):
1299 for j, (m, lab) in enumerate(((mraw, "raw"), (mcor, "corrected"))):
1300 a = ax[i, j]
1301 h = a.hist2d(x, m, bins=[60, 70], range=[xrange, mrange],
1302 cmap="viridis", cmin=1)
1303 a.axhline(pdg, ls="--", c="w", lw=1.0)
1304 a.set_title(f"{name} ({lab})")
1305 a.set_xlabel(xlabel); a.set_ylabel("m [GeV]")
1306 cb = fig.colorbar(h[3], ax=a)
1307 cb.set_label("candidates / bin")
1308 fig.suptitle(f"Invariant mass vs candidate {kind}: raw vs corrected")
1309 _savefig(fig, outdir, figname)
1310
1311
1312 def plot_alpha_vs_eta_1d(K, k1, k2, outdir):
1313 """Figure 4: signal-weighted <alpha> vs candidate eta, raw vs corrected."""
1314 p1r, p2r = K["pt1"].numpy(), K["pt2"].numpy()
1315 p1c, p2c = (k1 * K["pt1"]).numpy(), (k2 * K["pt2"]).numpy()
1316 eta_c = K["ceta"].numpy(); w = K["w"].numpy()
1317 eb = np.linspace(*ETA_RANGE, 13)
1318 fig, ax = plt.subplots(figsize=(6, 4.5))
1319 for lab, a1, a2 in (("raw", p1r, p2r), ("corrected", p1c, p2c)):
1320 al = (a1 - a2) / (a1 + a2)
1321 num, _ = np.histogram(eta_c, eb, weights=w * al)
1322 den, _ = np.histogram(eta_c, eb, weights=w)
1323 ax.plot(0.5 * (eb[:-1] + eb[1:]), num / np.maximum(den, 1e-9), "o-", label=lab)
1324 ax.axhline(0, c="k", lw=0.8)
1325 ax.set_xlabel("K0s candidate eta")
1326 ax.set_ylabel(r"$\langle\alpha\rangle$ (signal-weighted)")
1327 ax.set_title(r"K0s daughter $p_T$ asymmetry vs eta")
1328 ax.legend()
1329 _savefig(fig, outdir, "fig4_alpha_vs_eta_1d")
1330
1331
1332 def plot_alpha_vs_mass_2d(K, mK, k1, k2, outdir):
1333 """Figure 5: daughter pT asymmetry alpha (x) vs K0s mass (y)."""
1334 p1r, p2r = K["pt1"].numpy(), K["pt2"].numpy()
1335 p1c, p2c = (k1 * K["pt1"]).numpy(), (k2 * K["pt2"]).numpy()
1336 alpha_raw = (p1r - p2r) / (p1r + p2r)
1337 alpha_cor = (p1c - p2c) / (p1c + p2c)
1338 mraw = K["mass"].numpy()
1339 arange, mrange = (-1, 1), (0.4, 0.6)
1340 fig, ax = plt.subplots(1, 2, figsize=(11, 4.6))
1341 for a, al, m, lab in ((ax[0], alpha_raw, mraw, "raw"),
1342 (ax[1], alpha_cor, mK, "corrected")):
1343 h = a.hist2d(al, m, bins=[100, 100], range=[arange, mrange],
1344 cmap="viridis", cmin=1)
1345 a.axhline(M_K0S, ls="--", c="w", lw=1.0)
1346 a.axvline(0.0, ls=":", c="w", lw=0.8)
1347 a.set_title(f"K0s ({lab})")
1348 a.set_xlabel(r"$\alpha=(p_T^{\pi^-}-p_T^{\pi^+})/(p_T^{\pi^-}+p_T^{\pi^+})$")
1349 a.set_ylabel("m [GeV]")
1350 cb = fig.colorbar(h[3], ax=a)
1351 cb.set_label("candidates / bin")
1352 fig.suptitle("K0s daughter pT asymmetry vs invariant mass")
1353 _savefig(fig, outdir, "fig5_alpha_vs_mass_2d")
1354
1355
1356 def _slice_title(u):
1357 return f"1/pT = {u:.2f} GeV$^{{-1}}$ (pT = {1.0/u:.2f} GeV)"
1358
1359
1360 def _plot_map_grid(maps, curv_slices, outdir, name, suptitle, cmap, vmin, vmax,
1361 colorbar_label, title_prefix=""):
1362 ee = np.linspace(*ETA_RANGE, 50); pp = np.linspace(-np.pi, np.pi, 50)
1363 n = len(curv_slices); ncols = int(np.ceil(np.sqrt(n)));
1364 nrows = int(np.ceil(n / ncols))
1365 fig, ax = plt.subplots(nrows, ncols, figsize=(4.6 * ncols, 4.1 * nrows))
1366 ax = np.atleast_2d(ax)
1367 for idx, u in enumerate(curv_slices):
1368 r, c = divmod(idx, ncols)
1369 a = ax[r, c]
1370 im = a.pcolormesh(ee, pp, maps[idx].T, shading="auto", cmap=cmap,
1371 vmin=vmin, vmax=vmax)
1372 a.set_title(title_prefix + _slice_title(u), fontsize=9)
1373 a.set_xlabel("eta"); a.set_ylabel("phi")
1374 cb = fig.colorbar(im, ax=a)
1375 cb.set_label(colorbar_label)
1376 for idx in range(n, nrows * ncols):
1377 r, c = divmod(idx, ncols); ax[r, c].axis("off")
1378 fig.suptitle(suptitle)
1379 _savefig(fig, outdir, name)
1380
1381
1382 def plot_kappa_maps_vs_curv(model, outdir, curv_slices):
1383 """Figure 6: charge-even (eps) and charge-odd (delta) curvature-scale maps,
1384 sliced UNIFORMLY IN 1/pT (task 5), each panel labelled with both 1/pT and
1385 the equivalent pT. These are the exact even/odd fields of the convention
1386 (1/pT)_corr = (1/pT)(1 + eps + q*delta), read directly off the network."""
1387 ee = np.linspace(*ETA_RANGE, 50); pp = np.linspace(-np.pi, np.pi, 50)
1388 E, P = np.meshgrid(ee, pp, indexing="ij")
1389 t = lambda a: torch.tensor(a.ravel(), dtype=torch.float64)
1390 evens, odds = [], []
1391 with torch.no_grad():
1392 for u in curv_slices:
1393 eps, dlt = model.eps_delta(t(np.full(E.size, 1.0 / u)), t(E), t(P))
1394 evens.append(eps.numpy().reshape(E.shape))
1395 odds.append(dlt.numpy().reshape(E.shape))
1396 vmax_e = max(1e-4, max(np.abs(z).max() for z in evens))
1397 vmax_o = max(1e-4, max(np.abs(z).max() for z in odds))
1398
1399 _plot_map_grid(evens, curv_slices, outdir, "fig6a_kappa_even_maps_vs_pt",
1400 "Charge-even curvature-scale maps across pT",
1401 "RdBu_r", -vmax_e, vmax_e,
1402 r"$\epsilon$ (charge-even fractional $1/p_T$ scale)")
1403 _plot_map_grid(odds, curv_slices, outdir, "fig6b_kappa_odd_maps_vs_pt",
1404 "Charge-odd curvature-scale maps across pT",
1405 "RdBu_r", -vmax_o, vmax_o,
1406 r"$\delta$ (charge-odd fractional $1/p_T$ scale)")
1407
1408
1409 def plot_kappa_final_vs_curv(model, outdir, curv_slices):
1410 """Figure 7: deployed pT multiplier kappa_q = 1/(1+eps+q*delta) per charge,
1411 sliced uniformly in 1/pT (task 5), shared color scale."""
1412 ee = np.linspace(*ETA_RANGE, 50); pp = np.linspace(-np.pi, np.pi, 50)
1413 E, P = np.meshgrid(ee, pp, indexing="ij")
1414 t = lambda a: torch.tensor(a.ravel(), dtype=torch.float64)
1415 kp_maps, km_maps = [], []
1416 with torch.no_grad():
1417 for u in curv_slices:
1418 kp, _, _ = model.kappa(t(np.full(E.size, 1.0 / u)), t(E), t(P),
1419 torch.ones(E.size, dtype=torch.float64))
1420 km, _, _ = model.kappa(t(np.full(E.size, 1.0 / u)), t(E), t(P),
1421 -torch.ones(E.size, dtype=torch.float64))
1422 kp_maps.append(kp.numpy().reshape(E.shape))
1423 km_maps.append(km.numpy().reshape(E.shape))
1424 dev = max(1e-4, max(np.abs(z - 1.0).max() for z in kp_maps + km_maps))
1425
1426 _plot_map_grid(kp_maps, curv_slices, outdir, "fig7a_kappa_qplus_maps_vs_pt",
1427 r"Final $p_T$ scale correction for positive tracks",
1428 "RdBu_r", 1 - dev, 1 + dev,
1429 r"$\kappa(q{=}{+}1)$ final $p_T$ multiplier",
1430 title_prefix="q = +1, ")
1431 _plot_map_grid(km_maps, curv_slices, outdir, "fig7b_kappa_qminus_maps_vs_pt",
1432 r"Final $p_T$ scale correction for negative tracks",
1433 "RdBu_r", 1 - dev, 1 + dev,
1434 r"$\kappa(q{=}{-}1)$ final $p_T$ multiplier",
1435 title_prefix="q = -1, ")
1436
1437
1438
1439 def core_width(x, w, rng=(-6, 6), nbins=120, fit_range=3.0, min_w=200.0):
1440 """Weighted Gaussian-core width of a pull distribution (tail-insensitive).
1441
1442 Mildly biased high (tail leakage under |pull| < fit_range): a perfectly
1443 closing fit reads ~2-4% above 1 for f ~ 0.02, k ~ 2. Interpret residual
1444 structure vs pT/eta/phi, not the absolute offset.
1445 """
1446 if np.sum(w) < min_w:
1447 return np.nan, np.nan
1448 h, edges = np.histogram(x, bins=nbins, range=rng, weights=w)
1449 c = 0.5 * (edges[:-1] + edges[1:])
1450 sel = np.abs(c) < fit_range
1451 g = lambda t, N, mu, s, b0: N * np.exp(-0.5 * ((t - mu) / s)**2) + b0
1452 try:
1453 popt, _ = curve_fit(g, c[sel], h[sel],
1454 p0=[h[sel].max(), 0.0, 1.0, 0.0], maxfev=20000)
1455 s = abs(popt[2])
1456 if not (0.1 < s < 5.0) or not (-2 < popt[1] < 2):
1457 raise RuntimeError
1458 except Exception:
1459 return np.nan, np.nan
1460 return popt[1], abs(popt[2])
1461
1462
1463 def weighted_rms(x, w):
1464 mu = np.sum(w * x) / np.sum(w)
1465 return math.sqrt(np.sum(w * (x - mu)**2) / np.sum(w))
1466
1467
1468 def _quantile_edges(x, w, n):
1469 q = np.linspace(0, 1, n + 1)
1470 return np.unique(np.quantile(x, q))
1471
1472
1473 def plot_pull_closure(groups, net, glob, outdir):
1474 """Figure 8 (primary go/no-go): pull = (m - M_PDG) / sigma_m.
1475
1476 In pull mode a RAW-COVARIANCE overlay is added: the pull computed with
1477 s = 1, u_p = 0 and no floor -- its core width is the single number saying
1478 how wrong the shipped covariances are (subject to the SV-constraint
1479 caveat). Deviations of the calibrated core from 1 are a mis-modelled
1480 resolution field, NOT a scale problem (mu is fixed to PDG; fig1).
1481 """
1482 pulls, ws, cs, raws = {}, {}, {}, {}
1483 with torch.no_grad():
1484 for gi, g in enumerate(groups):
1485 sig = sigma_mass_net(g["R"], net, glob, glob.c(g["c_idx"]))
1486 pulls[g["name"]] = ((g["R"]["m"] - g["M"]) / sig).numpy()
1487 ws[g["name"]] = g["w"].numpy()
1488 cs[g["name"]] = (g["R"]["cpt"].numpy(), g["R"]["ceta"].numpy(),
1489 g["R"]["cphi"].numpy())
1490 if net.mode == "pull":
1491 one = torch.ones_like(g["R"]["m"])
1492 sig0 = sigma_mass_pull(g["R"], one, one,
1493 torch.tensor(0.0, dtype=torch.float64),
1494 torch.tensor(0.0, dtype=torch.float64))
1495 raws[g["name"]] = ((g["R"]["m"] - g["M"]) / sig0).numpy()
1496
1497 fig, ax = plt.subplots(2, 3, figsize=(15.5, 9))
1498 raw_cores = {}
1499 for i, g in enumerate(groups):
1500 n = g["name"]
1501 a = ax[0, i]
1502 h, edges, _ = a.hist(pulls[n], bins=120, range=(-6, 6), weights=ws[n],
1503 histtype="step", color="C0", label="calibrated")
1504 if n in raws:
1505 a.hist(raws[n], bins=120, range=(-6, 6), weights=ws[n],
1506 histtype="step", color="C2", ls="--",
1507 label="raw covariance (s=1, u_p=0, no floor)")
1508 raw_cores[n] = core_width(raws[n], ws[n])[1]
1509 mu, s = core_width(pulls[n], ws[n])
1510 if np.isfinite(s):
1511 c = 0.5 * (edges[:-1] + edges[1:])
1512 amp = h[np.argmin(np.abs(c - mu))]
1513 a.plot(c, amp * np.exp(-0.5 * ((c - mu) / s)**2), "r-", lw=1.2,
1514 label=f"core fit $\\sigma$={s:.3f}")
1515 rms = weighted_rms(pulls[n], ws[n])
1516 a.axvline(0, c="k", lw=0.7)
1517 ttl = f"{n}: core {s:.3f}, RMS {rms:.3f}"
1518 if n in raw_cores and np.isfinite(raw_cores[n]):
1519 ttl += f", raw-cov core {raw_cores[n]:.3f}"
1520 a.set_title(ttl, fontsize=9)
1521 a.set_xlabel(r"pull $=(m-M_{PDG})/\sigma_m$")
1522 a.set_ylabel("weighted candidates / bin")
1523 a.legend(fontsize=7)
1524
1525 for j, (lab, ix, rng) in enumerate((("candidate $p_T$ [GeV]", 0, None),
1526 ("candidate $\\eta$", 1, ETA_RANGE),
1527 ("candidate $\\phi$", 2, (-np.pi, np.pi)))):
1528 a = ax[1, j]
1529 for g in groups:
1530 n = g["name"]
1531 x = cs[n][ix]
1532 edges = _quantile_edges(x, ws[n], 10) if rng is None else np.linspace(*rng, 11)
1533 xs, ss = [], []
1534 for lo, hi in zip(edges[:-1], edges[1:]):
1535 sel = (x >= lo) & (x < hi)
1536 if sel.sum() < 50:
1537 continue
1538 _, s = core_width(pulls[n][sel], ws[n][sel])
1539 xs.append(0.5 * (lo + hi)); ss.append(s)
1540 a.plot(xs, ss, "o-", ms=3, label=n)
1541 a.axhline(1.0, c="k", ls="--", lw=0.8)
1542 a.set_ylim(0.6, 1.6)
1543 a.set_xlabel(lab)
1544 a.set_ylabel("core pull width")
1545 a.legend(fontsize=8)
1546 fig.suptitle("Pull closure: core width must be 1.0, globally and in every bin")
1547 _savefig(fig, outdir, "fig8_pull_closure")
1548 out = {g["name"]: (core_width(pulls[g["name"]], ws[g["name"]])[1],
1549 weighted_rms(pulls[g["name"]], ws[g["name"]]))
1550 for g in groups}
1551 return out, raw_cores
1552
1553
1554 def plot_width_overlay(groups, net, glob, outdir):
1555 """Figure 9: fitted Gaussian core width of the corrected mass peak vs the
1556 predicted median sigma_m, per (candidate pT, eta) bin. fig8 is the
1557 unbiased test (per-bin mixture over sigma_m biases this one slightly)."""
1558 fig, ax = plt.subplots(3, 2, figsize=(11, 12))
1559 with torch.no_grad():
1560 for i, g in enumerate(groups):
1561 n = g["name"]
1562 sig = sigma_mass_net(g["R"], net, glob, glob.c(g["c_idx"])).numpy()
1563 m = g["R"]["m"].numpy(); w = g["w"].numpy()
1564 for j, (xv, lab, rng) in enumerate(
1565 ((g["R"]["cpt"].numpy(), r"candidate $p_T$ [GeV]", None),
1566 (g["R"]["ceta"].numpy(), r"candidate $\eta$", ETA_RANGE))):
1567 edges = _quantile_edges(xv, w, 10) if rng is None else np.linspace(*rng, 11)
1568 xs, fit, pred = [], [], []
1569 for lo, hi in zip(edges[:-1], edges[1:]):
1570 sel = (xv >= lo) & (xv < hi)
1571 if w[sel].sum() < 200:
1572 continue
1573 _, sfit, _ = fit_peak(m[sel], g["M"], SIG0[n], g["lo"], g["hi"],
1574 weights=w[sel])
1575 xs.append(0.5 * (lo + hi))
1576 fit.append(sfit * 1e3)
1577 pred.append(np.median(sig[sel]) * 1e3)
1578 a = ax[i, j]
1579 a.plot(xs, fit, "o-", ms=3, label="fitted core width")
1580 a.plot(xs, pred, "s--", ms=3, label=r"median predicted $\sigma_m$")
1581 a.set_xlabel(lab); a.set_ylabel(r"$\sigma_m$ [MeV]")
1582 a.set_title(n); a.legend(fontsize=8)
1583 fig.suptitle("Mass-peak core width vs predicted resolution")
1584 _savefig(fig, outdir, "fig9_width_overlay")
1585
1586
1587 def plot_r_vs_pt(groups, net, glob, outdir, pt_plot):
1588 """Figure 10: per-track fractional resolution vs pT.
1589
1590 absolute: the field prediction decomposed into a/beta and b*pT at eta
1591 references (as v2), pion vs proton hypothesis.
1592 pull: per-leg binned medians of raw r_cov and calibrated
1593 r = s*pT^{u_p}*r_cov, split into pion legs and proton legs.
1594 """
1595 if net.mode == "absolute":
1596 pts = np.geomspace(max(pt_plot[0], 5e-2), pt_plot[1], 120)
1597 tp = torch.tensor(pts, dtype=torch.float64)
1598 eta_ref = (-1.0, 0.0, 1.0)
1599 fig, ax = plt.subplots(1, len(eta_ref), figsize=(5.2 * len(eta_ref), 4.4),
1600 squeeze=False)
1601 with torch.no_grad():
1602 c_mev = float(glob.c(0)) * 1e3
1603 for i, eta in enumerate(eta_ref):
1604 a_f, b_f = net.ab(torch.tensor([eta], dtype=torch.float64),
1605 torch.tensor([0.0], dtype=torch.float64))
1606 a_f, b_f = float(a_f), float(b_f)
1607 axx = ax[0, i]
1608 for mass, name, col in ((PION_MASS, r"$\pi$", "C0"),
1609 (PROTON_MASS, "p", "C3")):
1610 beta = beta_of(tp, torch.full_like(tp, eta), mass).numpy()
1611 t_ms = a_f / beta
1612 r = np.sqrt(t_ms**2 + (b_f * pts)**2)
1613 axx.plot(pts, 1e2 * r, "-", c=col, label=f"{name}: total")
1614 axx.plot(pts, 1e2 * t_ms, ":", c=col, lw=1.0,
1615 label=f"{name}: $a/\\beta$")
1616 axx.plot(pts, 1e2 * b_f * pts, "--", c="k", lw=1.0, label=r"$b\,p_T$")
1617 axx.set_xscale("log"); axx.set_xlabel(r"$p_T$ [GeV]")
1618 axx.set_ylabel(r"$\sigma(p_T)/p_T$ [%]")
1619 axx.set_title(f"$\\eta$ = {eta:+.1f}: a = {a_f:.4f}, "
1620 f"b = {b_f:.4f} GeV$^{{-1}}$, c = {c_mev:.2f} MeV")
1621 axx.legend(fontsize=7); axx.grid(alpha=0.25)
1622 fig.suptitle("Per-track fractional momentum resolution (absolute model)")
1623 else:
1624 legs = {"pion": [], "proton": []}
1625 with torch.no_grad():
1626 up = glob.u_p()
1627 for g in groups:
1628 R, w = g["R"], g["w"]
1629 for leg in (1, 2):
1630 kind = "proton" if (R["m2"] == PROTON_MASS and leg == 2) else "pion"
1631 s = net.s(R[f"eta{leg}"], R[f"phi{leg}"])
1632 rp = pull_r(R, leg, s, up)
1633 legs[kind].append((R[f"pt{leg}"].numpy(),
1634 R[f"rcov{leg}"].numpy(), rp.numpy(),
1635 w.numpy()))
1636 fig, ax = plt.subplots(1, 2, figsize=(11, 4.6))
1637 for axx, kind in zip(ax, ("pion", "proton")):
1638 pt = np.concatenate([x[0] for x in legs[kind]])
1639 rc = np.concatenate([x[1] for x in legs[kind]])
1640 rp = np.concatenate([x[2] for x in legs[kind]])
1641 edges = np.unique(np.quantile(pt, np.linspace(0, 1, 13)))
1642 xs, med_c, med_p = [], [], []
1643 for lo, hi in zip(edges[:-1], edges[1:]):
1644 sel = (pt >= lo) & (pt < hi)
1645 if sel.sum() < 100:
1646 continue
1647 xs.append(0.5 * (lo + hi))
1648 med_c.append(1e2 * np.median(rc[sel]))
1649 med_p.append(1e2 * np.median(rp[sel]))
1650 axx.plot(xs, med_c, "s--", ms=3, c="C2", label=r"median raw $r_{cov}$")
1651 axx.plot(xs, med_p, "o-", ms=3, c="C0",
1652 label=r"median calibrated $s\,p_T^{u_p}\,r_{cov}$")
1653 axx.set_xlabel(r"track $p_T$ [GeV]")
1654 axx.set_ylabel(r"$\sigma(p_T)/p_T$ [%]")
1655 axx.set_title(f"{kind} legs")
1656 axx.legend(fontsize=8); axx.grid(alpha=0.25)
1657 fig.suptitle("Covariance-reported vs calibrated per-track resolution "
1658 "(pull model)")
1659 _savefig(fig, outdir, "fig10_r_vs_pt")
1660
1661
1662 def plot_ab_maps(net, outdir):
1663 """Figure 11 (absolute mode): (eta, phi) maps of the two fields."""
1664 ee = np.linspace(*ETA_RANGE, 60); pp = np.linspace(-np.pi, np.pi, 60)
1665 E, P = np.meshgrid(ee, pp, indexing="ij")
1666 t = lambda x: torch.tensor(x.ravel(), dtype=torch.float64)
1667 with torch.no_grad():
1668 a, b = net.ab(t(E), t(P))
1669 a = a.numpy().reshape(E.shape); b = b.numpy().reshape(E.shape)
1670 fig, ax = plt.subplots(1, 2, figsize=(12.5, 4.8))
1671 for axx, z, lab, ttl in (
1672 (ax[0], a, "MS-like fractional resolution coefficient "
1673 r"(dimensionless, enters as $a/\beta$)", r"$a(\eta,\phi)$"),
1674 (ax[1], b, r"curvature term coefficient [1/GeV], enters as $b\,p_T$",
1675 r"$b(\eta,\phi)$")):
1676 im = axx.pcolormesh(ee, pp, z.T, shading="auto", cmap="viridis")
1677 axx.set_xlabel(r"$\eta$"); axx.set_ylabel(r"$\phi$"); axx.set_title(ttl)
1678 cb = fig.colorbar(im, ax=axx); cb.set_label(lab, fontsize=8)
1679 fig.suptitle("Resolution fields (charge-even by construction)")
1680 _savefig(fig, outdir, "fig11_ab_maps")
1681
1682
1683 def plot_r_maps_vs_curv(net, glob, outdir, curv_slices):
1684 """Figure 12 (absolute mode): r(eta, phi) maps across 1/pT, pi vs p."""
1685 ee = np.linspace(*ETA_RANGE, 50); pp = np.linspace(-np.pi, np.pi, 50)
1686 E, P = np.meshgrid(ee, pp, indexing="ij")
1687 t = lambda a: torch.tensor(a.ravel(), dtype=torch.float64)
1688 n = len(curv_slices); ncols = 4
1689 nrows_each = int(np.ceil(n / ncols))
1690
1691 with torch.no_grad():
1692 a_f, b_f = net.ab(t(E), t(P))
1693 a_f, b_f = a_f.numpy(), b_f.numpy()
1694
1695 r_pi, r_p = [], []
1696 for u in curv_slices:
1697 pt = 1.0 / u
1698 beta_pi = beta_of(torch.tensor(np.full(E.size, pt)), t(E), PION_MASS).numpy()
1699 beta_p = beta_of(torch.tensor(np.full(E.size, pt)), t(E), PROTON_MASS).numpy()
1700 r_pi.append((1e2 * np.sqrt((a_f / beta_pi)**2 + (b_f * pt)**2)).reshape(E.shape))
1701 r_p.append((1e2 * np.sqrt((a_f / beta_p)**2 + (b_f * pt)**2)).reshape(E.shape))
1702 vmax_pi = max(z.max() for z in r_pi)
1703 vmax_p = max(z.max() for z in r_p)
1704
1705 fig, ax = plt.subplots(2 * nrows_each, ncols,
1706 figsize=(4.6 * ncols, 4.1 * 2 * nrows_each))
1707 for block, (maps, vmax, lab) in enumerate((
1708 (r_pi, vmax_pi, r"$r=\sigma(p_T)/p_T$ [%] (pion hypothesis)"),
1709 (r_p, vmax_p, r"$r=\sigma(p_T)/p_T$ [%] (proton hypothesis)"))):
1710 for idx, u in enumerate(curv_slices):
1711 r, c = divmod(idx, ncols)
1712 a = ax[block * nrows_each + r, c]
1713 im = a.pcolormesh(ee, pp, maps[idx].T, shading="auto", cmap="viridis",
1714 vmin=0, vmax=vmax)
1715 a.set_title(("$\\pi$, " if block == 0 else "p, ") + _slice_title(u),
1716 fontsize=9)
1717 a.set_xlabel("eta"); a.set_ylabel("phi")
1718 cb = fig.colorbar(im, ax=a)
1719 cb.set_label(lab)
1720 for idx in range(n, nrows_each * ncols):
1721 r, c = divmod(idx, ncols); ax[block * nrows_each + r, c].axis("off")
1722 fig.suptitle(r"Per-track fractional momentum resolution $r(\eta,\phi)$ "
1723 r"across 1/pT, by mass hypothesis")
1724 _savefig(fig, outdir, "fig12_r_maps_vs_curv")
1725
1726
1727 def plot_calibration_factor(net, glob, outdir):
1728 """Figure 13 (pull mode): s(eta, phi) map plus 1D profiles.
1729
1730 Read-off: 'the tracker under/over-estimates sigma(pT) by factor s'."""
1731 ee = np.linspace(*ETA_RANGE, 60); pp = np.linspace(-np.pi, np.pi, 60)
1732 E, P = np.meshgrid(ee, pp, indexing="ij")
1733 t = lambda x: torch.tensor(x.ravel(), dtype=torch.float64)
1734 with torch.no_grad():
1735 s = net.s(t(E), t(P)).numpy().reshape(E.shape)
1736 up = float(glob.u_p())
1737 fig, ax = plt.subplots(1, 3, figsize=(16, 4.6))
1738 im = ax[0].pcolormesh(ee, pp, s.T, shading="auto", cmap="viridis")
1739 ax[0].set_xlabel(r"$\eta$"); ax[0].set_ylabel(r"$\phi$")
1740 ax[0].set_title(rf"$s(\eta,\phi)$ ($u_p$ = {up:+.4f})")
1741 cb = fig.colorbar(im, ax=ax[0])
1742 cb.set_label("covariance calibration factor s (s = 1: errors correct)")
1743 ax[1].plot(ee, s.mean(axis=1), "o-", ms=3)
1744 ax[1].axhline(1.0, c="k", ls="--", lw=0.8)
1745 ax[1].set_xlabel(r"$\eta$"); ax[1].set_ylabel(r"$\langle s\rangle_\phi$")
1746 ax[1].set_title("phi-averaged profile")
1747 ax[2].plot(pp, s.mean(axis=0), "o-", ms=3)
1748 ax[2].axhline(1.0, c="k", ls="--", lw=0.8)
1749 ax[2].set_xlabel(r"$\phi$"); ax[2].set_ylabel(r"$\langle s\rangle_\eta$")
1750 ax[2].set_title("eta-averaged profile")
1751 fig.suptitle("Covariance calibration field: r = s(eta,phi) * pT^{u_p} * r_cov")
1752 _savefig(fig, outdir, "fig13_calibration_factor")
1753
1754
1755
1756 def diagnostics(model, K, L, meta, outdir):
1757 with torch.no_grad():
1758 mK, _, _ = corrected_mass(model, K)
1759 mL, _, _ = corrected_mass(model, L)
1760 k1, e1, d1 = model.kappa(K["pt1"], K["eta1"], K["phi1"], K["q1"])
1761 k2, e2, d2 = model.kappa(K["pt2"], K["eta2"], K["phi2"], K["q2"])
1762 mK, mL = mK.numpy(), mL.numpy()
1763 isl = L["is_lam"].numpy()
1764
1765 lines = []
1766 for name, raw, cor, pdg, lo, hi, s0 in (
1767 ("K0s", K["mass"].numpy(), mK, M_K0S, 0.42, 0.58, 0.010),
1768 ("Lambda", L["mass"].numpy()[isl], mL[isl], M_LAMBDA, 1.09, 1.145, 0.004),
1769 ("Lambdabar", L["mass"].numpy()[~isl], mL[~isl], M_LAMBDA, 1.09, 1.145, 0.004)):
1770 mu_r, s_r, _ = fit_peak(raw, pdg, s0, lo, hi)
1771 mu_c, s_c, _ = fit_peak(cor, pdg, s0, lo, hi)
1772 lines.append(f"{name:10s} raw peak {mu_r*1e3:9.3f} MeV -> corrected "
1773 f"{mu_c*1e3:9.3f} MeV (PDG {pdg*1e3:.3f}, "
1774 f"pull {(mu_c-pdg)*1e3:+.3f} MeV)")
1775 e_all = np.concatenate([e1.numpy(), e2.numpy()])
1776 d_all = np.concatenate([d1.numpy(), d2.numpy()])
1777 kc = np.concatenate([(1.0 + e1 + K["q1"] * d1).numpy(),
1778 (1.0 + e2 + K["q2"] * d2).numpy()])
1779 lines.append(f"{'fields':10s} eps mean {e_all.mean():+.5f} rms {e_all.std():.5f} "
1780 f"max|.| {np.abs(e_all).max():.5f}; "
1781 f"delta mean {d_all.mean():+.5f} rms {d_all.std():.5f} "
1782 f"max|.| {np.abs(d_all).max():.5f} (uncapped)")
1783 guard_frac = np.mean(kc <= KAPPA_GUARD + 1e-12)
1784 lines.append(f"{'guard':10s} kappa_curv <= {KAPPA_GUARD} fraction = "
1785 f"{guard_frac:.2e} (numerical guard, must be 0)")
1786 if guard_frac > 0:
1787 lines.append(f"{'WARNING':10s} numerical guard active on data -- the "
1788 f"converged model is not trustworthy; investigate.")
1789 all_pt = torch.cat([K["pt1"], K["pt2"], L["pt1"], L["pt2"]])
1790 uc = model.u_clamp_frac(all_pt)
1791 lines.append(f"{'feature':10s} u = 1/pT, u_ref = {model.u_ref:.4f}, "
1792 f"u_scale = {model.u_scale:.4f} GeV^-1; |u_n| >= "
1793 f"{model.u_clamp:.1f} clamp fraction = {uc:.2e} "
1794 f"(kappa constant in 1/pT beyond the clamp)")
1795 txt = "\n".join(lines)
1796 print(txt)
1797 with open(os.path.join(outdir, "summary.txt"), "w") as f:
1798 f.write(txt + "\n")
1799
1800 export_mass_histograms_root(K, L, mK, mL, outdir)
1801 plot_mass_1d(K, L, mK, mL, outdir)
1802 plot_mass_vs_kin_2d(K, L, mK, mL, outdir, "pt", meta["pt_plot"])
1803 plot_mass_vs_kin_2d(K, L, mK, mL, outdir, "eta", meta["pt_plot"])
1804 plot_alpha_vs_eta_1d(K, k1, k2, outdir)
1805 plot_alpha_vs_mass_2d(K, mK, k1, k2, outdir)
1806 plot_kappa_maps_vs_curv(model, outdir, meta["curv_slices"])
1807 plot_kappa_final_vs_curv(model, outdir, meta["curv_slices"])
1808
1809
1810 def resolution_diagnostics(net, glob, groups, outdir, meta, n_c=1, scalars=None):
1811 pw, raw_cores = plot_pull_closure(groups, net, glob, outdir)
1812 plot_width_overlay(groups, net, glob, outdir)
1813 plot_r_vs_pt(groups, net, glob, outdir, meta["pt_plot"])
1814 if net.mode == "absolute":
1815 plot_ab_maps(net, outdir)
1816 plot_r_maps_vs_curv(net, glob, outdir, meta["curv_slices"])
1817 else:
1818 plot_calibration_factor(net, glob, outdir)
1819
1820 lines = ["", f"--- stage 2: momentum resolution ({net.mode} model) ---"]
1821 if net.mode == "pull":
1822 lines += ["# " + s for s in PULL_INTERPRETATION]
1823 for gi, g in enumerate(groups):
1824 core, rms = pw[g["name"]]
1825 with torch.no_grad():
1826 fg, kg = float(glob.f(gi)), float(glob.k(gi))
1827 exp_rms = math.sqrt(1 - fg + fg * kg**2)
1828 line = (f"{g['name']:10s} pull core width {core:6.4f} RMS {rms:6.4f} "
1829 f"(expected RMS from tail {exp_rms:5.3f}) "
1830 f"f = {fg:6.4f}, k = {kg:5.3f}")
1831 if g["name"] in raw_cores and np.isfinite(raw_cores[g["name"]]):
1832 line += f" raw-cov pull core {raw_cores[g['name']]:6.4f}"
1833 lines.append(line)
1834 with torch.no_grad():
1835 for i in range(n_c):
1836 lines.append(f"{'floor':10s} c[{i}] = {float(glob.c(i))*1e3:7.4f} MeV")
1837 with torch.no_grad():
1838 if net.mode == "absolute":
1839 aa, bb = [], []
1840 for g in groups:
1841 for leg in ("1", "2"):
1842 a, b = net.ab(g["R"]["eta" + leg], g["R"]["phi" + leg])
1843 aa.append(a.numpy()); bb.append(b.numpy())
1844 aa = np.concatenate(aa); bb = np.concatenate(bb)
1845 lines.append(f"{'fields':10s} track-averaged a = {aa.mean():.5f} "
1846 f"(spread {aa.std():.5f}), b = {bb.mean():.5f} "
1847 f"(spread {bb.std():.5f}) GeV^-1")
1848 else:
1849 up = glob.u_p()
1850 lines.append(f"{'exponent':10s} u_p = {float(up):+.5f} "
1851 f"(0 = no residual pT shape on top of r_cov)")
1852 for g in groups:
1853 R, ratio = g["R"], []
1854 for leg in (1, 2):
1855 s = net.s(R[f"eta{leg}"], R[f"phi{leg}"])
1856 ratio.append((pull_r(R, leg, s, up) /
1857 R[f"rcov{leg}"]).numpy())
1858 ratio = np.concatenate(ratio)
1859 q16, q50, q84 = np.quantile(ratio, [0.16, 0.50, 0.84])
1860 lines.append(f"{g['name']:10s} r_pred/r_cov median {q50:.4f} "
1861 f"[16%, 84%] = [{q16:.4f}, {q84:.4f}] "
1862 f"<- factor by which the reported momentum "
1863 f"uncertainty is wrong")
1864 if scalars is not None:
1865 if net.mode == "absolute":
1866 lines.append(f"{'reduced':10s} constant-field fit: "
1867 f"a = {scalars['a']:.5f} +- {scalars['sa']:.5f}, "
1868 f"b = {scalars['b']:.5f} +- {scalars['sb']:.5f}; "
1869 f"rho(a,b) = {scalars['rho_ab']:+.3f}, "
1870 f"rho(a,c) = {scalars['rho_ac']:+.3f}"
1871 f"{'' if scalars['pos_def'] else ' [Hessian NOT pos.def.]'}")
1872 else:
1873 lines.append(f"{'reduced':10s} constant-field fit: "
1874 f"s = {scalars['s']:.5f} +- {scalars['ss']:.5f}, "
1875 f"u_p = {scalars['u_p']:+.5f} +- {scalars['su']:.5f}; "
1876 f"rho(s,u_p) = {scalars['rho_su']:+.3f}, "
1877 f"rho(s,c) = {scalars['rho_sc']:+.3f}"
1878 f"{'' if scalars['pos_def'] else ' [Hessian NOT pos.def.]'}")
1879 lines.append(f"{'':10s} NB: on data this reduced fit is misspecified "
1880 f"(the field really depends on eta, phi); central values "
1881 f"are an occupancy-weighted compromise and only the "
1882 f"injection-toy errors are strictly interpretable.")
1883 txt = "\n".join(lines)
1884 print(txt)
1885 with open(os.path.join(outdir, "summary.txt"), "a") as f:
1886 f.write(txt + "\n")
1887
1888
1889
1890 def _two_body_toy(M, m1, m2, cpt, ceta, cphi, rng):
1891 """Isotropic two-body decay boosted to the lab; truth mass exactly M."""
1892 pstar = math.sqrt((M**2 - (m1 + m2)**2) * (M**2 - (m1 - m2)**2)) / (2 * M)
1893 ct = rng.uniform(-1, 1, cpt.size)
1894 st = np.sqrt(1 - ct**2)
1895 ph = rng.uniform(-np.pi, np.pi, cpt.size)
1896 p1 = np.stack([pstar * st * np.cos(ph), pstar * st * np.sin(ph), pstar * ct], -1)
1897 p2 = -p1
1898 E1s = math.sqrt(pstar**2 + m1**2)
1899 E2s = math.sqrt(pstar**2 + m2**2)
1900
1901 pc = np.stack([cpt * np.cos(cphi), cpt * np.sin(cphi), cpt * np.sinh(ceta)], -1)
1902 Ec = np.sqrt((pc**2).sum(-1) + M**2)
1903 bv = pc / Ec[:, None]
1904 g = Ec / M
1905 out = []
1906 for ps, Es in ((p1, E1s), (p2, E2s)):
1907 bp = (bv * ps).sum(-1)
1908 p = ps + (g**2 / (g + 1) * bp + g * Es)[:, None] * bv
1909 pt = np.hypot(p[:, 0], p[:, 1])
1910 eta = np.arcsinh(p[:, 2] / np.maximum(pt, 1e-12))
1911 phi = np.arctan2(p[:, 1], p[:, 0])
1912 out.append((pt, eta, phi))
1913 return out
1914
1915
1916 def run_injection(args, K, L, meta, outdir):
1917 """Mandatory go/no-go. pull mode: recover (s0, u0) drawn against r_cov
1918 resampled from data. absolute mode: recover (a0, b0)."""
1919 os.makedirs(outdir, exist_ok=True)
1920 rng = np.random.default_rng(args.inject_seed)
1921 mode = args.reso_model
1922 n = args.inject_n
1923
1924 def rcov_pool(S, leg):
1925 return S[f"rcov{leg}"].numpy()
1926
1927 def make(Sdata, M, m1, m2):
1928 idx = rng.integers(0, Sdata["cpt"].numel(), n)
1929 cpt = Sdata["cpt"].numpy()[idx]
1930 ceta = Sdata["ceta"].numpy()[idx]
1931 cphi = Sdata["cphi"].numpy()[idx]
1932 (pt1, eta1, phi1), (pt2, eta2, phi2) = _two_body_toy(M, m1, m2,
1933 cpt, ceta, cphi, rng)
1934 d = dict(cpt=cpt, ceta=ceta, cphi=cphi, M=M, m1=m1, m2=m2,
1935 eta1=eta1, phi1=phi1, eta2=eta2, phi2=phi2)
1936 if mode == "pull":
1937 rc1 = rng.choice(rcov_pool(Sdata, 1), n)
1938 rc2 = rng.choice(rcov_pool(Sdata, 2), n)
1939 r1 = args.inject_s0 * pt1**args.inject_u0 * rc1
1940 r2 = args.inject_s0 * pt2**args.inject_u0 * rc2
1941 d["rcov1"], d["rcov2"] = rc1, rc2
1942 else:
1943 def r_ab(pt, eta, m):
1944 beta = beta_of(torch.tensor(pt), torch.tensor(eta), m).numpy()
1945 return np.sqrt((args.inject_a0 / beta)**2 + (args.inject_b0 * pt)**2)
1946 r1 = r_ab(pt1, eta1, m1)
1947 r2 = r_ab(pt2, eta2, m2)
1948 d["pt1"] = np.maximum(pt1 * (1 + r1 * rng.standard_normal(n)), 1e-3)
1949 d["pt2"] = np.maximum(pt2 * (1 + r2 * rng.standard_normal(n)), 1e-3)
1950 d["dm"] = args.inject_c0 * rng.standard_normal(n)
1951 return d
1952
1953 T = lambda x: torch.tensor(x, dtype=torch.float64)
1954
1955 def to_S(d):
1956 S = dict(pt1=T(d["pt1"]), eta1=T(d["eta1"]), phi1=T(d["phi1"]),
1957 pt2=T(d["pt2"]), eta2=T(d["eta2"]), phi2=T(d["phi2"]),
1958 q1=torch.zeros(n, dtype=torch.float64),
1959 q2=torch.zeros(n, dtype=torch.float64),
1960 m1=d["m1"], m2=d["m2"], M=d["M"], dm=T(d["dm"]),
1961 cpt=T(d["cpt"]), ceta=T(d["ceta"]), cphi=T(d["cphi"]))
1962 for key in ("rcov1", "rcov2"):
1963 if key in d:
1964 S[key] = T(d[key])
1965 return S
1966
1967 SK = to_S(make(K, M_K0S, PION_MASS, PION_MASS))
1968 SL = to_S(make(L, M_LAMBDA, PION_MASS, PROTON_MASS))
1969 SL["is_lam"] = torch.tensor(rng.random(n) < 0.5)
1970
1971 idk = IdentityKappa()
1972
1973 def fiducial(S, lo, hi):
1974 """Eta acceptance + mass window; NO pT cut, matching the data path."""
1975 with torch.no_grad():
1976 m, _, _ = mass_jacobians(S["pt1"], S["eta1"], S["phi1"], S["m1"],
1977 S["pt2"], S["eta2"], S["phi2"], S["m2"])
1978 m = m + S["dm"]
1979 sel = ((S["pt1"] > PT_SANITY) & (S["pt2"] > PT_SANITY) &
1980 (S["eta1"].abs() < ETA_RANGE[1]) & (S["eta2"].abs() < ETA_RANGE[1]) &
1981 (m > lo) & (m < hi))
1982 return {k: (v[sel] if torch.is_tensor(v) else v) for k, v in S.items()}
1983
1984 SK = fiducial(SK, *WIN["K0s"])
1985 SL = fiducial(SL, *WIN["Lambda"])
1986 print(f"[inject] toy after selection: {SK['pt1'].numel()} K0s, "
1987 f"{SL['pt1'].numel()} Lambda(+bar)")
1988
1989 groups = build_groups(idk, SK, SL, pure_signal=True)
1990 n_c = set_c_indices(groups, args.c_per_resonance)
1991
1992 net, glob = train_resolution(groups, args, n_c=n_c)
1993 scal = fit_global_reduced(groups, mode, n_c=n_c, steps=args.inject_scalar_steps)
1994
1995 resolution_diagnostics(net, glob, groups, outdir, meta, n_c=n_c, scalars=scal)
1996
1997 if mode == "pull":
1998 pa = (scal["s"] - args.inject_s0) / max(scal["ss"], 1e-12)
1999 pb = (scal["u_p"] - args.inject_u0) / max(scal["su"], 1e-12)
2000 rec = [f"injected s0 = {args.inject_s0:.5f} u0 = {args.inject_u0:+.5f} "
2001 f"c0 = {args.inject_c0*1e3:.4f} MeV",
2002 f"recovered s = {scal['s']:.5f} +- {scal['ss']:.5f} pull {pa:+.2f}",
2003 f"recovered u_p = {scal['u_p']:+.5f} +- {scal['su']:.5f} pull {pb:+.2f}",
2004 f"recovered c = {', '.join(f'{x*1e3:.4f}' for x in scal['c'])} MeV "
2005 f"(injected {args.inject_c0*1e3:.4f}; rho(s,c) = {scal['rho_sc']:+.3f})"]
2006 else:
2007 pa = (scal["a"] - args.inject_a0) / max(scal["sa"], 1e-12)
2008 pb = (scal["b"] - args.inject_b0) / max(scal["sb"], 1e-12)
2009 rec = [f"injected a0 = {args.inject_a0:.5f} b0 = {args.inject_b0:.5f} GeV^-1 "
2010 f"c0 = {args.inject_c0*1e3:.4f} MeV",
2011 f"recovered a = {scal['a']:.5f} +- {scal['sa']:.5f} pull {pa:+.2f}",
2012 f"recovered b = {scal['b']:.5f} +- {scal['sb']:.5f} pull {pb:+.2f}",
2013 f"recovered c = {', '.join(f'{x*1e3:.4f}' for x in scal['c'])} MeV "
2014 f"(injected {args.inject_c0*1e3:.4f}; rho(a,c) = {scal['rho_ac']:+.3f})"]
2015
2016 with torch.no_grad():
2017 pw = {}
2018 for g in groups:
2019 sig = sigma_mass_net(g["R"], net, glob, glob.c(g["c_idx"]))
2020 pl = ((g["R"]["m"] - g["M"]) / sig).numpy()
2021 pw[g["name"]] = core_width(pl, g["w"].numpy())[1]
2022
2023 lines = ["", f"--- injection campaign (go/no-go, {mode} model) ---"] + rec + \
2024 ["pull core widths: " + ", ".join(f"{k} {v:.4f}" for k, v in pw.items())]
2025 ok = (abs(pa) < 3 and abs(pb) < 3 and
2026 all(np.isfinite(v) and abs(v - 1) < 0.05 for v in pw.values()))
2027 lines.append(f"VERDICT: {'PASS' if ok else 'FAIL'}")
2028 txt = "\n".join(lines)
2029 print(txt)
2030 with open(os.path.join(outdir, "summary.txt"), "a") as f:
2031 f.write(txt + "\n")
2032 return ok
2033
2034
2035
2036 def main():
2037 ap = argparse.ArgumentParser()
2038 ap.add_argument("--kshort", default="kshort_kfparticle.root",
2039 help="K0s KFParticle nTuple (.root) or legacy CSV")
2040 ap.add_argument("--lam", default="lambda_kfparticle.root",
2041 help="Lambda KFParticle nTuple (.root) or legacy CSV")
2042 ap.add_argument("--tree", default="DecayTree", help="TTree name in the ROOT files")
2043 ap.add_argument("--max_candidates", type=int, default=0,
2044 help="use at most N candidates per species (0 = all), "
2045 "random subsample")
2046 ap.add_argument("--subsample_seed", type=int, default=42)
2047 ap.add_argument("--outdir", default="calib_out")
2048 ap.add_argument("--lookup_pt_min", type=float, default=0.10,
2049 help="lower pT [GeV] covered by kappa lookup and kappa-map diagnostics")
2050 ap.add_argument("--lookup_pt_max", type=float, default=5.0,
2051 help="upper pT [GeV] covered by kappa lookup and kappa-map diagnostics")
2052 ap.add_argument("--kappa_slices", type=int, default=25,
2053 help="number of curvature slices in fig6/fig7 kappa diagnostics")
2054
2055 ap.add_argument("--epochs", type=int, default=400)
2056 ap.add_argument("--lr", type=float, default=2e-3)
2057 ap.add_argument("--hidden", type=int, default=48)
2058 ap.add_argument("--refresh", type=int, default=50, help="weight-refresh cadence")
2059 ap.add_argument("--lam_prior", type=float, default=0.05,
2060 help="L2(eps,delta) weight at epoch 0; annealed linearly to 0")
2061 ap.add_argument("--lam_split", type=float, default=1.0)
2062 ap.add_argument("--lam_alpha", type=float, default=2.0)
2063 ap.add_argument("--load_stage1", default=None,
2064 help="existing model.pt (must carry the v4 convention tag; "
2065 "older checkpoints are rejected)")
2066
2067 ap.add_argument("--no_reso", action="store_true", help="stage 1 only", default=False)
2068 ap.add_argument("--reso_model", choices=("absolute", "pull"), default="pull",
2069 help="absolute: measure (a,b) fields from peak widths; "
2070 "pull: calibrate the KFParticle covariance, "
2071 "r = s(eta,phi)*pT^u_p*r_cov (needs Covariance branches)")
2072 ap.add_argument("--reso_epochs", type=int, default=300)
2073 ap.add_argument("--reso_lr", type=float, default=2e-3)
2074 ap.add_argument("--reso_hidden", type=int, default=32)
2075 ap.add_argument("--reso_layers", type=int, default=3)
2076 ap.add_argument("--c_per_resonance", action="store_true",
2077 help="separate mass-width floor for K0s and Lambda")
2078 ap.add_argument("--reso_scalar_steps", type=int, default=1500,
2079 help="reduced global-scalar fit steps (for Hessian errors)")
2080
2081 ap.add_argument("--inject", action="store_true",
2082 help="append the go/no-go toy campaign after the data pass")
2083 ap.add_argument("--inject_only", action="store_true",
2084 help="run the toy campaign and skip the data pass")
2085 ap.add_argument("--inject_n", type=int, default=200000)
2086 ap.add_argument("--inject_s0", type=float, default=1.30,
2087 help="pull mode: injected covariance miscalibration factor")
2088 ap.add_argument("--inject_u0", type=float, default=0.0,
2089 help="pull mode: injected pT exponent")
2090 ap.add_argument("--inject_a0", type=float, default=0.010,
2091 help="absolute mode: injected MS-like coefficient")
2092 ap.add_argument("--inject_b0", type=float, default=0.012,
2093 help="absolute mode: injected curvature coefficient [1/GeV]")
2094 ap.add_argument("--inject_c0", type=float, default=0.0015,
2095 help="injected mass-width floor [GeV]")
2096 ap.add_argument("--inject_seed", type=int, default=1234)
2097 ap.add_argument("--inject_scalar_steps", type=int, default=1500)
2098 args = ap.parse_args()
2099
2100 os.makedirs(args.outdir, exist_ok=True)
2101 K, L, meta = load(args)
2102 print(f"loaded {len(K['mass'])} K0s, {len(L['mass'])} Lambda candidates")
2103
2104 if args.inject or args.inject_only:
2105 ok = run_injection(args, K, L, meta, os.path.join(args.outdir, "injection"))
2106 if not ok:
2107 print("[inject] FAILED -- do not deploy the resolution map. "
2108 "Debug before running on data.")
2109 if args.inject_only:
2110 return
2111
2112
2113 if args.load_stage1:
2114 model = load_stage1(args.load_stage1, hidden=args.hidden)
2115 print(f"loaded stage-1 model from {args.load_stage1} "
2116 f"(convention '{CONVENTION_TAG}' verified)")
2117 with torch.no_grad():
2118 mK, _, _ = corrected_mass(model, K)
2119 mL, _, _ = corrected_mass(model, L)
2120 _, _, pK = fit_peak(mK.numpy(), M_K0S, 0.010, 0.42, 0.58)
2121 _, _, pL = fit_peak(mL.numpy(), M_LAMBDA, 0.004, 1.09, 1.145)
2122 K["w"] = torch.tensor(signal_weights(mK.numpy(), pK))
2123 L["w"] = torch.tensor(signal_weights(mL.numpy(), pL))
2124 else:
2125 model = train(args, K, L, meta)
2126 save_stage1(model, os.path.join(args.outdir, "model.pt"))
2127
2128 export_lookup(model, meta, os.path.join(args.outdir, "kappa_lookup.csv"))
2129 diagnostics(model, K, L, meta, args.outdir)
2130
2131 if args.no_reso:
2132 print(f"outputs written to {args.outdir}/ (stage 1 only)")
2133 return
2134
2135
2136 for p in model.parameters():
2137 p.requires_grad_(False)
2138 model.eval()
2139
2140 groups = build_groups(model, K, L, pure_signal=False)
2141 n_c = set_c_indices(groups, args.c_per_resonance)
2142 print(f"stage 2 ({args.reso_model}): {len(groups)} resonance groups, "
2143 f"{n_c} floor parameter(s)")
2144 net, glob = train_resolution(groups, args, n_c=n_c)
2145 scal = fit_global_reduced(groups, args.reso_model, n_c=n_c,
2146 steps=args.reso_scalar_steps)
2147
2148 torch.save({"reso_net": net.state_dict(), "globals": glob.state_dict(),
2149 "mode": args.reso_model, "n_c": n_c,
2150 "groups": [g["name"] for g in groups]},
2151 os.path.join(args.outdir, "reso_model.pt"))
2152 export_reso_lookup(net, glob, groups,
2153 os.path.join(args.outdir, "reso_lookup.csv"), n_c=n_c)
2154 resolution_diagnostics(net, glob, groups, args.outdir, meta,
2155 n_c=n_c, scalars=scal)
2156
2157 print(f"outputs written to {args.outdir}/")
2158
2159
2160 if __name__ == "__main__":
2161 main()