SND@LHC Software
Loading...
Searching...
No Matches
reweighting_function.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Neutrino-flux reweighting and energy-distribution comparison for SND@LHC.
3
4Overview
5--------
6Hadron-production models disagree about the neutrino flux reaching SND@LHC.
7This module derives an energy-dependent weight w(E) that maps one model onto
8another, and applies it to a simulated sample.
9
10 w(E) = flux_fastsim(E) / flux_DPMJET(E)
11
12where each flux is the sum of a light-hadron (pi/K) and a heavy-flavour
13(charm) contribution:
14
15 flux_DPMJET = light_DPMJET + charm_DPMJET (the reference / denominator)
16 flux_fastsim = light_EPOS + charm_POWHEG (the target / numerator)
17
18Multiplying every DPMJET event by w(E) therefore reproduces the fast-sim
19prediction, without re-running the simulation:
20
21 DPMJET x w(E) == fast-sim (light + charm)
22
23Two capabilities
24----------------
251. ``FluxReweighter`` -- builds the ratio from the flux text files, fits a
26 polynomial w(E), and writes a diagnostic plot per polynomial degree.
272. ``main()`` in "compare" mode -- reads neutrino energies straight from
28 SND@LHC ROOT files (``cbmsim`` tree), applies w(E), and overlays the
29 DPMJET reference with each reweighted fast-sim prediction.
30
31Inputs
32------
33Flux text files: whitespace-separated, one neutrino per row, no header, with
34the columns listed in ``FluxReweighter.COLS``. POWHEG files carry one extra
35trailing column (``iEvent``).
36
37ROOT files: standard SND@LHC digitised output holding the ``cbmsim`` tree with
38the ``MCTrack`` branch, from which the incoming (primary) neutrino energy is
39taken.
40
41Configuration
42-------------
43Every input is read from YAML; nothing analysis-specific is hard-coded.
44
45 Analysis.yaml run settings: ROOT input, which fast-sim variants to
46 overlay, normalisation, plot styling
47 Config_epos.yaml one per fast-sim variant: flux file paths, flavour,
48 binning, fit range, cuts, weights, output label
49
50Usage
51-----
52 # diagnostic ratio-fit plots for one or more reweighter configs
53 python3 reweighting_function.py -m reweight -rc Config_epos.yaml
54
55 # full comparison: fit w(E), read ROOT energies, overlay and save
56 python3 reweighting_function.py -c Analysis.yaml -rc Config_epos.yaml
57
58 # everything taken from the run YAML, no extra arguments
59 python3 reweighting_function.py -c Analysis.yaml
60
61As a library:
62
63 from reweighting_function import FluxReweighter
64 poly, chi2red = FluxReweighter("Config_epos.yaml").fit(degree=2)
65 weights = poly(energies) # w(E) evaluated per event
66
67"""
68
69import os
70import argparse
71
72import yaml
73import numpy as np
74import pandas as pd
75import matplotlib
76matplotlib.use("Agg") # headless backend so plots always save
77import matplotlib.pyplot as plt
78
79# ROOT is only present in the SND environment (cvmfs). Guard the import so the
80# reweighting/plotting half stays importable and testable elsewhere.
81try:
82 import ROOT
83except ImportError:
84 ROOT = None
85
86# uproot + awkward are a pip-installable, framework-free alternative reader
87# (pip install uproot awkward) that works in a plain venv without pyROOT.
88try:
89 import uproot
90except ImportError:
91 uproot = None
92try:
93 import awkward as ak
94except ImportError:
95 ak = None
96
97try:
98 from tqdm.auto import tqdm
99except ImportError: # progress bars are optional
100 def tqdm(x, **kwargs):
101 return x
102
103
105 """Derive the reweighting function w(E) = fast-sim / DPMJET from flux files.
106
107 The class histograms the four input samples in neutrino energy, forms the
108 ratio bin by bin with propagated errors, and fits a polynomial to it. All
109 settings come from a YAML config passed to the constructor.
110
111 Typical use
112 -----------
113 rw = FluxReweighter("Config_epos.yaml")
114 rw.run() # diagnostic plot per degree
115 poly, chi2red = rw.fit(2) # or just the deg-2 weight function
116
117 Attributes populated by :meth:`compute_ratio`
118 ---------------------------------------------
119 E : bin centres [GeV]
120 w : the ratio fast-sim / DPMJET per bin (this is w(E) sampled)
121 sig : uncertainty on the ratio per bin
122 H_DPM : summed DPMJET histogram (light + charm), s_DPM its error
123 H_LC : summed fast-sim histogram (light + charm), s_LC its error
124 """
125
126 #: PDG codes of the neutrino flavours (matched on |pdg|, so nu and nubar).
127 PID = {"nue": 12, "numu": 14, "nutau": 16}
128
129 #: Column layout of the flux text files, in order. POWHEG files append a
130 #: trailing 'iEvent' column, added on the fly in :meth:`read_files`.
131 COLS = [
132 "Neutrino", "Parent",
133 "x in m", "y in m", "z in m",
134 "x angle in rad", "y angle in rad",
135 "E in GeV", "weight in pb",
136 ]
137
138 # ------------------------------------------------------------------ #
139 # Construction / config
140 # ------------------------------------------------------------------ #
141 def __init__(self, config_path="config.yaml"):
142 """Load the YAML config and prepare the default (uniform) bin edges.
143
144 Adaptive edges, if requested, are computed later in
145 :meth:`compute_ratio` because they depend on the selected events.
146 """
147 self.cfg = self._load_config(config_path)
148 self.bins_E = np.linspace(
149 self.cfg["energy_min"], self.cfg["energy_max"], num=self.cfg["bins"]
150 )
151 # filled in by compute_ratio()
152 self.E = self.w = self.sig = None
153 self.H_DPM = self.s_DPM = self.H_LC = self.s_LC = None
154
155 @staticmethod
156 def _require(d, key, section):
157 """Return a mandatory config value, or raise naming the missing field."""
158 if d is None or key not in d or d[key] is None:
159 raise ValueError(f"Missing required config field: {section}.{key}")
160 return d[key]
161
162 @staticmethod
163 def _adaptive_edges(fine, dsw, dsw2, lsw, lsw2, min_neff):
164 """Build variable-width bin edges with a minimum of effective entries.
165
166 Starting from a fine uniform grid, fine bins are accumulated left to
167 right and a bin is closed as soon as *both* the DPMJET and the fast-sim
168 side reach
169
170 N_eff = (sum w)^2 / sum w^2 >= min_neff
171
172 N_eff is the number of unweighted events that would give the same
173 statistical power, so this guarantees no bin is dominated by a handful
174 of large-weight events -- the usual failure mode at high energy, where
175 a single charm event with a huge weight otherwise makes the ratio jump
176 and its error explode.
177
178 Parameters
179 ----------
180 fine : ndarray
181 Edges of the fine starting grid.
182 dsw, dsw2 : ndarray
183 Per-fine-bin sum of weights and sum of weights squared, DPMJET side.
184 lsw, lsw2 : ndarray
185 The same for the fast-sim side.
186 min_neff : float
187 Required effective entries per output bin.
188
189 Returns
190 -------
191 ndarray
192 The merged bin edges (always closed at ``fine[-1]``).
193 """
194 edges = [fine[0]]
195 adsw = adsw2 = alsw = alsw2 = 0.0
196 for i in range(len(fine) - 1):
197 adsw += dsw[i]; adsw2 += dsw2[i]
198 alsw += lsw[i]; alsw2 += lsw2[i]
199 nd = (adsw * adsw / adsw2) if adsw2 > 0 else 0.0
200 nl = (alsw * alsw / alsw2) if alsw2 > 0 else 0.0
201 if min(nd, nl) >= min_neff:
202 edges.append(fine[i + 1]); adsw = adsw2 = alsw = alsw2 = 0.0
203 if edges[-1] != fine[-1]:
204 edges.append(fine[-1])
205 return np.array(edges)
206
207 def _load_config(self, yaml_path):
208 """Parse the reweighter YAML into a flat dict of settings.
209
210 Only the four input paths are mandatory; every other key has a default
211 so a minimal config stays valid. See Config_epos.yaml for the meaning
212 of each field.
213 """
214 with open(yaml_path) as f:
215 raw = yaml.safe_load(f) or {}
216
217 inp = raw.get("inputs", {})
218 eb = raw.get("energy_binning", {})
219 fr = raw.get("fit_range", {})
220 cut = raw.get("cuts", {})
221 out = raw.get("output", {})
222 wt = raw.get("weights", {})
223
224 return {
225 # --- input flux files (mandatory) ---
226 "light_dpm": self._require(inp, "light_dpm", "inputs"),
227 "charm_dpm": self._require(inp, "charm_dpm", "inputs"),
228 "light_fastsim": self._require(inp, "light_fastsim", "inputs"),
229 "charm_fastsim": self._require(inp, "charm_fastsim", "inputs"),
230
231 # --- neutrino flavour entering the ratio ---
232 "flavour": raw.get("flavour", "mu"),
233
234 # --- histogram binning in energy ---
235 "energy_min": float(eb.get("min", 0.0)),
236 "energy_max": float(eb.get("max", 4000.0)),
237 "bins": int(eb.get("bins", 51)), # uniform mode: n edges
238 "bin_mode": eb.get("mode", "uniform"), # "uniform" | "adaptive"
239 "fine_bins": int(eb.get("fine_bins", 400)), # adaptive: start grid
240 "min_neff": float(eb.get("min_neff", 10.0)),# adaptive: entries/bin
241
242 # --- energy window the polynomial is fitted over ---
243 "fit_min": float(fr.get("min", 0.0)),
244 "fit_max": float(fr.get("max", 4000.0)),
245
246 # --- one diagnostic plot per degree ---
247 "degrees": [int(d) for d in raw.get("polynomial_degrees", [1, 2, 3])],
248
249 # --- quality gates on which bins enter the fit ---
250 # Relative-error cap: drops bins whose sigma/ratio is too large.
251 # This is the unbiased gate -- prefer it over the ratio window.
252 "rel_err_max": float(raw.get("rel_err_max", 3.0)),
253 # Optional window on the ratio VALUE. Keep it symmetric about the
254 # data: a one-sided cap removes up-fluctuations only and biases the
255 # fit low. None disables that side.
256 "ratio_min": (None if raw.get("ratio_min") is None
257 else float(raw.get("ratio_min"))),
258 "ratio_max": (None if raw.get("ratio_max") is None
259 else float(raw.get("ratio_max"))),
260
261 # --- Selection cut ---
262 "cut_energy_min": float(cut.get("energy_min", 10.0)),
263 "cut_x_col": cut.get("x_column", "x in m"),
264 "cut_y_col": cut.get("y_column", "y in m"),
265 "cut_x_min": float(cut.get("x_min", -46.0)),
266 "cut_x_max": float(cut.get("x_max", -7.0)),
267 "cut_y_min": float(cut.get("y_min", 15.0)),
268 "cut_y_max": float(cut.get("y_max", 54.0)),
269 # x/y are multiplied by this before the bounds are applied, so cm
270 # bounds can be used with metre columns (unit_scale: 100).
271 "cut_scale": float(cut.get("unit_scale", 1.0)),
272 # Set false when the inputs already passed the cut-based analysis.
273 "cut_enabled": bool(cut.get("enabled", True)),
274
275 # --- whether 'weight in pb' is applied when histogramming ---
276 # Both default to true. Disabling one side only puts numerator and
277 # denominator on different scales and breaks the ratio's meaning.
278 "weight_dpm": bool(wt.get("dpm", True)),
279 "weight_fastsim": bool(wt.get("fastsim", True)),
280
281 # --- output ---
282 "outpath": out.get("path", "./output"),
283 "label": out.get("label", "EPOS+POWHEG"),
284 }
285
286 # ------------------------------------------------------------------ #
287 # Data helpers
288 # ------------------------------------------------------------------ #
289 def read_files(self, path):
290 """Read one flux text file into a DataFrame with named columns.
291
292 POWHEG samples carry an extra trailing ``iEvent`` column; this is
293 detected from the file name so the column names line up either way.
294 """
295 cols = self.COLS + ["iEvent"] if "powheg" in path.lower() else self.COLS
296 return pd.read_csv(path, sep=r"\s+", header=None, names=cols)
297
298 def base_cut(self, df):
299 """Boolean mask for the fiducial (detector acceptance) selection.
300
301 Applies a minimum energy and an x/y box, with the x/y columns scaled by
302 ``cuts.unit_scale`` first. Returns all-true when ``cuts.enabled`` is
303 false, i.e. when the inputs already went through the cut-based analysis.
304 """
305 c = self.cfg
306 if not c["cut_enabled"]:
307 return pd.Series(True, index=df.index)
308 xs = df[c["cut_x_col"]] * c["cut_scale"]
309 ys = df[c["cut_y_col"]] * c["cut_scale"]
310 return (
311 (df["E in GeV"] > c["cut_energy_min"]) &
312 xs.between(c["cut_x_min"], c["cut_x_max"]) &
313 ys.between(c["cut_y_min"], c["cut_y_max"])
314 )
315
316 def _report_selection(self, name, df, pid):
317 """Print how many rows survive each selection stage for one file.
318
319 Printed before the ratio is built. If a stage removes everything the
320 actual x/y data ranges are shown next to the cut bounds, which makes a
321 unit mismatch (cut in cm, data in metres) immediately obvious.
322
323 Returns the number of surviving rows.
324 """
325 c = self.cfg
326 fl = (df["Neutrino"].abs() == pid)
327 if not c["cut_enabled"]:
328 print(f" [{name:13s}] N={len(df):>7} | flavour={int(fl.sum()):>7} "
329 f"| cuts disabled")
330 return int(fl.sum())
331 e = fl & (df["E in GeV"] > c["cut_energy_min"])
332 xs = df[c["cut_x_col"]] * c["cut_scale"]
333 ys = df[c["cut_y_col"]] * c["cut_scale"]
334 x = e & xs.between(c["cut_x_min"], c["cut_x_max"])
335 y = x & ys.between(c["cut_y_min"], c["cut_y_max"])
336 n = int(y.sum())
337 print(f" [{name:13s}] N={len(df):>7} | flavour={int(fl.sum()):>7} "
338 f"| +E>{c['cut_energy_min']:g}={int(e.sum()):>7} "
339 f"| +x={int(x.sum()):>7} | +y={n:>7}")
340 if n == 0:
341 print(f" 0 survive -> {c['cut_x_col']}*{c['cut_scale']:g} range "
342 f"[{xs.min():.3g}, {xs.max():.3g}] vs cut [{c['cut_x_min']}, {c['cut_x_max']}]; "
343 f"{c['cut_y_col']}*{c['cut_scale']:g} range "
344 f"[{ys.min():.3g}, {ys.max():.3g}] vs cut [{c['cut_y_min']}, {c['cut_y_max']}]")
345 return n
346
347 def _hist(self, df, mask, use_weight=True):
348 """Weighted energy histogram of the selected rows, with its error.
349
350 Each event contributes its own ``weight in pb`` (or 1.0 when
351 ``use_weight`` is false). The error is the standard weighted-histogram
352 one, sqrt(sum w^2) per bin.
353
354 Returns ``(counts, sigma)``.
355 """
356 x = df.loc[mask, "E in GeV"]
357 w = df.loc[mask, "weight in pb"] if use_weight else pd.Series(1.0, index=x.index)
358 counts, _ = np.histogram(x, bins=self.bins_E, weights=w)
359 var, _ = np.histogram(x, bins=self.bins_E, weights=w ** 2)
360 return counts.astype(float), np.sqrt(var.astype(float))
361
362 # ------------------------------------------------------------------ #
363 # Core computation
364 # ------------------------------------------------------------------ #
365 def compute_ratio(self):
366 """Build the fast-sim / DPMJET ratio versus energy.
367
368 Steps: read the four files, print the selection report, optionally
369 derive adaptive bin edges, histogram each sample, sum light + charm on
370 each side, and divide.
371
372 Light and charm are histogrammed separately and the histograms added,
373 which is equivalent to weighting every event individually and filling
374 one histogram -- each event always keeps its own ``weight in pb``. The
375 errors are combined in quadrature, sqrt(s_light^2 + s_charm^2).
376
377 Populates ``E``, ``w``, ``sig``, ``H_DPM``, ``s_DPM``, ``H_LC``,
378 ``s_LC`` and returns ``self`` so calls can be chained.
379 """
380 pid = self.PID[self.cfg["flavour"]]
381
382 df_ld = self.read_files(self.cfg["light_dpm"])
383 df_cd = self.read_files(self.cfg["charm_dpm"])
384 df_lf = self.read_files(self.cfg["light_fastsim"])
385 df_cf = self.read_files(self.cfg["charm_fastsim"])
386
387 # Selection breakdown per file; also reveals unit mismatches.
388 print(f"Selection report ({self.cfg['label']}, flavour={self.cfg['flavour']}):")
389 n_ld = self._report_selection("light_dpm", df_ld, pid)
390 n_cd = self._report_selection("charm_dpm", df_cd, pid)
391 n_lf = self._report_selection("light_fastsim", df_lf, pid)
392 n_cf = self._report_selection("charm_fastsim", df_cf, pid)
393 if (n_ld + n_cd) == 0 or (n_lf + n_cf) == 0:
394 raise ValueError(
395 "Selection leaves no events on one side of the ratio "
396 f"(DPMJET={n_ld + n_cd}, fast-sim={n_lf + n_cf}). See the report "
397 "above: the most common cause is the x/y fiducial cut being in "
398 "different units than the data (cut in cm, 'x in m' column in "
399 "metres). Fix via cuts.unit_scale (e.g. 100 for m->cm) or by "
400 "putting the bounds in the data's units.")
401
402 # Optional adaptive binning. Uses the same flavour + fiducial selection
403 # as the histograms below, and overwrites self.bins_E before filling.
404 if self.cfg["bin_mode"] == "adaptive":
405 fine = np.linspace(self.cfg["energy_min"], self.cfg["energy_max"],
406 self.cfg["fine_bins"] + 1)
407
408 def _fine(df, use_w):
409 """Sum of w and w^2 per fine bin for one selected sample."""
410 m = (df["Neutrino"].abs() == pid) & self.base_cut(df)
411 E = df.loc[m, "E in GeV"].to_numpy()
412 w = (df.loc[m, "weight in pb"].to_numpy()
413 if use_w else np.ones(int(m.sum())))
414 sw, _ = np.histogram(E, bins=fine, weights=w)
415 sw2, _ = np.histogram(E, bins=fine, weights=w ** 2)
416 return sw.astype(float), sw2.astype(float)
417
418 wd, wf = self.cfg["weight_dpm"], self.cfg["weight_fastsim"]
419 aLd = _fine(df_ld, wd); aCd = _fine(df_cd, wd)
420 aLf = _fine(df_lf, wf); aCf = _fine(df_cf, wf)
421 self.bins_E = self._adaptive_edges(
422 fine, aLd[0] + aCd[0], aLd[1] + aCd[1],
423 aLf[0] + aCf[0], aLf[1] + aCf[1], self.cfg["min_neff"])
424 print(f"adaptive binning -> {len(self.bins_E) - 1} bins "
425 f"(min_neff={self.cfg['min_neff']:g})")
426
427 # Denominator: DPMJET total = light + charm.
428 H_L_dpm, s_L_dpm = self._hist(df_ld, (df_ld["Neutrino"].abs() == pid) & self.base_cut(df_ld), use_weight=self.cfg["weight_dpm"])
429 H_C_dpm, s_C_dpm = self._hist(df_cd, (df_cd["Neutrino"].abs() == pid) & self.base_cut(df_cd), use_weight=self.cfg["weight_dpm"])
430 self.H_DPM = H_L_dpm + H_C_dpm
431 self.s_DPM = np.sqrt(s_L_dpm ** 2 + s_C_dpm ** 2)
432
433 # Numerator: fast-sim total = light (EPOS) + charm (POWHEG).
434 H_L_fs, s_L_fs = self._hist(df_lf, (df_lf["Neutrino"].abs() == pid) & self.base_cut(df_lf), use_weight=self.cfg["weight_fastsim"])
435 H_C_fs, s_C_fs = self._hist(df_cf, (df_cf["Neutrino"].abs() == pid) & self.base_cut(df_cf), use_weight=self.cfg["weight_fastsim"])
436 self.H_LC = H_L_fs + H_C_fs
437 self.s_LC = np.sqrt(s_L_fs ** 2 + s_C_fs ** 2)
438
439 # Ratio with relative errors added in quadrature. Bins empty on either
440 # side are left at zero and are excluded from the fit by _fit_mask().
441 centers = 0.5 * (self.bins_E[:-1] + self.bins_E[1:])
442 R = np.zeros_like(self.H_DPM)
443 sR = np.zeros_like(self.H_DPM)
444
445 m = (self.H_DPM > 0) & (self.H_LC > 0)
446 R[m] = self.H_LC[m] / self.H_DPM[m]
447 rel2_num = (self.s_LC[m] / self.H_LC[m]) ** 2
448 rel2_den = (self.s_DPM[m] / self.H_DPM[m]) ** 2
449 sR[m] = R[m] * np.sqrt(rel2_num + rel2_den)
450
451 self.E, self.w, self.sig = centers, R, sR
452 return self
453
454 def _fit_mask(self, verbose=False):
455 """Boolean mask selecting which ratio bins enter the polynomial fit.
456
457 A bin is kept when it lies inside ``fit_range``, has a non-zero ratio
458 and error, passes the relative-error cap, and falls inside the optional
459 ratio window. With ``verbose`` the number of bins removed by the ratio
460 window is printed.
461 """
462 c = self.cfg
463 rel_err = np.zeros_like(self.w)
464 nz = (self.w > 0) & (self.sig > 0)
465 rel_err[nz] = self.sig[nz] / self.w[nz]
466
467 base = (
468 (self.E >= c["fit_min"]) & (self.E <= c["fit_max"]) &
469 (self.w > 0) & (self.sig > 0) &
470 (rel_err <= c["rel_err_max"])
471 )
472
473 rmin = -np.inf if c["ratio_min"] is None else c["ratio_min"]
474 rmax = np.inf if c["ratio_max"] is None else c["ratio_max"]
475 within = (self.w >= rmin) & (self.w <= rmax)
476
477 if verbose and (c["ratio_min"] is not None or c["ratio_max"] is not None):
478 dropped = int((base & ~within).sum())
479 print(f" ratio window [{rmin:g}, {rmax:g}]: dropped {dropped} of "
480 f"{int(base.sum())} otherwise-eligible bin(s)")
481 return base & within
482
483 @staticmethod
484 def fit_poly_weighted(x, y, sigma, degree=1):
485 """Least-squares polynomial fit weighted by 1/sigma.
486
487 Returns ``(poly, chi2, chi2/ndf)`` where ``poly`` is a ``np.poly1d``
488 with coefficients ordered highest power first.
489 """
490 poly = np.poly1d(np.polyfit(x, y, deg=degree, w=1.0 / sigma))
491 chi2 = np.sum(((y - poly(x)) / sigma) ** 2)
492 ndof = max(len(x) - (degree + 1), 1)
493 return poly, chi2, chi2 / ndof
494
495 def fit(self, degree):
496 """Return the reweighting polynomial w(E) for one degree.
497
498 Computes the ratio on first use. Evaluate the result per event as
499 ``poly(E)``; it is only meaningful inside ``fit_range``.
500
501 Returns ``(poly, chi2red)``. Raises ``ValueError`` if the selection
502 leaves fewer points than the degree requires.
503 """
504 if self.E is None:
505 self.compute_ratio()
506 mask = self._fit_mask(verbose=True)
507 n_fit = int(mask.sum())
508 if n_fit < degree + 1:
509 raise ValueError(
510 f"Not enough fit points ({n_fit}) for degree {degree}")
511 poly, _chi2, chi2red = self.fit_poly_weighted(
512 self.E[mask], self.w[mask], self.sig[mask], degree=degree
513 )
514 return poly, chi2red
515
516 # ------------------------------------------------------------------ #
517 # Plotting
518 # ------------------------------------------------------------------ #
519 @staticmethod
520 def _poly_equation_text(poly, deg):
521 """Format w(E) as a LaTeX string for the plot's statistics box."""
522 terms = []
523 for p, coef in zip(range(deg, -1, -1), poly.coefficients):
524 if p == 0:
525 terms.append(rf"({coef:.3e})")
526 elif p == 1:
527 terms.append(rf"({coef:.3e})\,E")
528 else:
529 terms.append(rf"({coef:.3e})\,E^{p}")
530 return r"$w(E) = " + " + ".join(terms) + r"$"
531
532 def _make_plot(self, deg, poly, chi2red, fit_mask):
533 """Write the diagnostic ratio plot for one polynomial degree.
534
535 Shows every ratio bin faintly, the subset entering the fit with error
536 bars, the fitted curve inside ``fit_range`` and its extrapolation
537 outside, plus a statistics box. Comparing the two point sets shows at a
538 glance what the quality gates removed.
539
540 Returns the path of the saved PDF.
541 """
542 c = self.cfg
543 E, w, sig = self.E, self.w, self.sig
544 E_fit, w_fit, sig_fit = E[fit_mask], w[fit_mask], sig[fit_mask]
545
546 E_plot_fit = np.linspace(c["fit_min"], c["fit_max"], 300)
547 E_plot_all = np.linspace(E.min(), E.max(), 400)
548
549 fig, ax = plt.subplots(figsize=(9, 5.8))
550 ax.errorbar(E, w, yerr=sig, fmt="o", ms=4, alpha=0.25, label="all data")
551 ax.errorbar(E_fit, w_fit, yerr=sig_fit, fmt="o", ms=5, capsize=3, label="used in fit")
552 ax.plot(E_plot_fit, poly(E_plot_fit), "-", lw=2.5, label=f"poly deg={deg} (fit range)")
553 ax.plot(E_plot_all, poly(E_plot_all), "--", lw=1.8, alpha=0.6, label="poly extrapolation")
554
555 ax.axvline(c["fit_min"], color="grey", ls=":", lw=1)
556 ax.axvline(c["fit_max"], color="grey", ls=":", lw=1)
557 ax.axhline(1.0, color="grey", ls="--", lw=1) # ratio = 1: models agree
558
559 ax.set_xlabel("Energy [GeV]")
560 ax.set_ylabel("ratio")
561 ax.set_title(c["label"])
562 ax.grid(True, which="both", alpha=0.3)
563 ax.legend(fontsize="small", loc="lower left")
564
565 stats_text = "\n".join([
566 rf"deg = {deg}",
567 rf"$\chi^2$/ndf = {chi2red:.2f}",
568 rf"$N_\mathrm{{points}}$ = {len(E_fit)}",
569 rf"$E_\mathrm{{fit}}$: {c['fit_min']:.0f}-{c['fit_max']:.0f} GeV",
570 self._poly_equation_text(poly, deg),
571 ])
572 ax.text(0.98, 0.95, stats_text, transform=ax.transAxes,
573 ha="right", va="top", fontsize=10,
574 bbox=dict(boxstyle="round", facecolor="white", alpha=0.85))
575
576 fig.tight_layout()
577 outfile = os.path.join(c["outpath"],
578 f"reweight_poly_deg{deg}_{c['flavour']}.pdf")
579 fig.savefig(outfile)
580 plt.close(fig)
581 return outfile
582
583 # ------------------------------------------------------------------ #
584 # Driver
585 # ------------------------------------------------------------------ #
586 def run(self):
587 """Fit every degree in ``polynomial_degrees`` and save one plot each.
588
589 This is the "reweight" mode entry point: it is the diagnostic pass used
590 to choose a degree and check the binning and quality gates. Degrees
591 with too few surviving points are skipped with a message. Returns
592 ``self``.
593 """
594 os.makedirs(self.cfg["outpath"], exist_ok=True) # create BEFORE saving
595 if self.E is None:
596 self.compute_ratio()
597
598 fit_mask = self._fit_mask(verbose=True)
599 n_fit = int(fit_mask.sum())
600
601 for deg in self.cfg["degrees"]:
602 if n_fit < deg + 1:
603 print(f"[skip] deg={deg}: only {n_fit} fit points (need {deg + 1})")
604 continue
605 poly, chi2, chi2red = self.fit_poly_weighted(
606 self.E[fit_mask], self.w[fit_mask], self.sig[fit_mask], degree=deg
607 )
608 print("Saved:", self._make_plot(deg, poly, chi2red, fit_mask))
609
610 print("Output dir:", self.cfg["outpath"])
611 return self
612
613
614# ==========================================================================
615# Run-level configuration (Analysis.yaml)
616# ==========================================================================
617
618#: PDG codes used when selecting the incoming neutrino in the ROOT files.
619NU_PID = {"e": 12, "mu": 14, "tau": 16}
620
621
623 """Load the top-level run YAML (Analysis.yaml) into a dict."""
624 with open(path) as f:
625 return yaml.safe_load(f) or {}
626
627
628def resolve_normalization(norm, number_files, target_luminosity=68.6,
629 luminosity_per_file=100.0):
630 """Return the factor scaling the simulated sample to the target luminosity.
631
632 The automatic value is the luminosity ratio L_data / L_MC,
633
634 target_luminosity / (number_files * luminosity_per_file)
635
636 with each input file representing ``luminosity_per_file`` [fb^-1] and
637 ``number_files`` of them merged. Passing a number instead of ``"auto"``
638 uses it verbatim; ``"auto"`` or a non-positive value uses the formula.
639 """
640 auto = target_luminosity / (number_files * luminosity_per_file)
641 if isinstance(norm, str) and norm.strip().lower() == "auto":
642 return auto
643 norm = float(norm)
644 return norm if norm > 0 else auto
645
646
647def build_fastsim_curves(fastsim_configs, reference, degree):
648 """Fit w(E) for each fast-sim variant and assemble the curves to draw.
649
650 One ``FluxReweighter`` is run per config, so the weights come from the fit
651 and are never hard-coded. The fitted coefficients and chi2/ndf are printed
652 for the record, and each curve is labelled from its own config.
653
654 Returns a list of ``(label, weight_poly | None, color, linestyle)`` with the
655 unweighted DPMJET reference (``weight_poly`` None) first.
656 """
657 curves = [(reference.get("label", "DPMJET (reference)"),
658 None,
659 reference.get("color", "black"),
660 reference.get("linestyle", "-"))]
661 for entry in fastsim_configs:
662 rw = FluxReweighter(entry["config"])
663 poly, chi2red = rw.fit(degree=degree)
664 label = rw.cfg["label"]
665 coeffs = np.array2string(poly.coefficients, precision=4, separator=", ")
666 print(f"{label}: fast-sim(light+charm)/DPMJET deg={degree} "
667 f"coeffs(high->low)={coeffs} chi2/ndf={chi2red:.2f}")
668 curves.append((label, poly,
669 entry.get("color", "tab:blue"),
670 entry.get("linestyle", "--")))
671 return curves
672
673
674# ==========================================================================
675# Reading energies from ROOT (SND@LHC cbmsim tree)
676# ==========================================================================
677def dpm_file_list(data_dir, n_files, pattern, indexed=True):
678 """Build the list of ROOT files to read.
679
680 ``indexed=True`` expects the per-job layout ``<data_dir>/<i>/<pattern>``
681 for i in range(n_files); ``indexed=False`` a single merged file at
682 ``<data_dir>/<pattern>``.
683 """
684 if indexed:
685 return [os.path.join(data_dir, str(i), pattern) for i in range(n_files)]
686 return [os.path.join(data_dir, pattern)]
687
688
689def build_chain(files, tree_name="cbmsim"):
690 """Chain the given ROOT files into one TChain (pyROOT only)."""
691 if ROOT is None:
692 raise RuntimeError("ROOT is not available in this environment.")
693 chain = ROOT.TChain(tree_name)
694 added = sum(bool(chain.Add(f)) for f in files)
695 print(f"Chained {added}/{len(files)} files (tree '{tree_name}')")
696 return chain
697
698
700 """Energy of the primary neutrino in the current entry, or None.
701
702 The incoming neutrino is the MCTrack whose |pdg| matches the requested
703 flavour and which has no mother (MotherId == -1).
704 """
705 for trk in tree.MCTrack:
706 if abs(trk.GetPdgCode()) in pids and trk.GetMotherId() == -1:
707 return trk.GetEnergy()
708 return None
709
710
711def get_energies_pyroot(files, flavour="mu", tree_name="cbmsim", n_events=None):
712 """Read per-event neutrino energies [GeV] with pyROOT.
713
714 Loops the chained ``cbmsim`` tree entry by entry. Returns a 1-D array with
715 one energy per event that contains a matching primary neutrino.
716 """
717 pids = set(NU_PID.values()) if flavour == "all" else {NU_PID[flavour]}
718 chain = build_chain(files, tree_name)
719 n_total = chain.GetEntries()
720 n = n_total if n_events is None else min(n_events, n_total)
721
722 energies = []
723 for i in tqdm(range(n), desc="reading neutrino energy (pyROOT)"):
724 chain.GetEntry(i)
725 E = _incoming_neutrino_energy(chain, pids)
726 if E is not None:
727 energies.append(E)
728 arr = np.asarray(energies, dtype=float)
729 print(f"Extracted {arr.size} neutrino energies (flavour={flavour})")
730 return arr
731
733 """Locate the MCTrack member branches whatever the exact prefix is.
734
735 Different productions store them as ``MCTrack.fPdgCode``,
736 ``MCTrack/fPdgCode`` and so on, so the names are matched by suffix.
737 Returns a dict mapping the member name to the branch key found.
738 """
739 want = ["fPdgCode", "fMotherId", "fPx", "fPy", "fPz"]
740 keys = tree.keys()
741 found = {}
742 for w in want:
743 cands = ([k for k in keys if "MCTrack" in k and k.endswith("." + w)]
744 or [k for k in keys if "MCTrack" in k and k.endswith(w)]
745 or [k for k in keys if k.endswith(w)])
746 if cands:
747 found[w] = cands[0]
748 return found
749
750def get_energies_uproot(files, flavour="mu", tree_name="cbmsim", n_events=None):
751 """Read per-event neutrino energies [GeV] with uproot, without ROOT.
752
753 Selects the incoming neutrino (|pdg| matching the flavour, fMotherId == -1)
754 and, neutrinos being effectively massless, takes E = |p|. Unreadable files
755 are skipped with a message.
756 """
757 if uproot is None or ak is None:
758 raise RuntimeError("uproot/awkward not available -- pip install uproot awkward")
759 pids = set(NU_PID.values()) if flavour == "all" else {NU_PID[flavour]}
760
761 out = []
762 for path in tqdm(files, desc="reading neutrino energy (uproot)"):
763 try:
764 tree = uproot.open(f"{path}:{tree_name}")
765 except Exception as exc: # noqa: BLE001
766 print(f" [skip] {path}: {exc}")
767 continue
768 br = _find_mctrack_branches(tree)
769 if len(br) < 5:
770 raise RuntimeError(
771 "Could not find MCTrack momentum/pdg branches. Branches seen: "
772 f"{tree.keys()[:40]} ... adjust _find_mctrack_branches().")
773 a = tree.arrays(list(br.values()), entry_stop=n_events, library="ak")
774 pdg, mom = a[br["fPdgCode"]], a[br["fMotherId"]]
775 px, py, pz = a[br["fPx"]], a[br["fPy"]], a[br["fPz"]]
776
777 is_nu = ak.zeros_like(mom, dtype=bool)
778 for pid in pids:
779 is_nu = is_nu | (abs(pdg) == pid)
780 sel = is_nu & (mom == -1)
781
782 E = np.sqrt(px ** 2 + py ** 2 + pz ** 2)
783 first = ak.firsts(E[sel]) # first primary nu per event
784 first = first[~ak.is_none(first)]
785 out.append(ak.to_numpy(first))
786
787 arr = np.concatenate(out) if out else np.array([], dtype=float)
788 print(f"Extracted {arr.size} neutrino energies (flavour={flavour})")
789 return arr
790
791
792def read_dpm_energies(ri, reader="auto"):
793 """Read the DPMJET neutrino energies using the requested ROOT backend.
794
795 ``ri`` is the ``root_input`` block of the run YAML. ``reader`` is "auto"
796 (pyROOT when importable, else uproot), "pyroot" or "uproot".
797 """
798 files = dpm_file_list(ri["data_dir"], int(ri["number_files"]),
799 ri["pattern"], indexed=ri.get("indexed", True))
800 tree_name = ri.get("tree_name", "cbmsim")
801 flavour = ri.get("flavour", "mu")
802
803 if reader == "auto":
804 reader = "pyroot" if ROOT is not None else "uproot"
805
806 if reader == "pyroot":
807 if ROOT is None:
808 raise RuntimeError("reader=pyroot but ROOT is unavailable. Source the "
809 "SND@LHC setup, or use reader: uproot "
810 "(pip install uproot awkward).")
811 return get_energies_pyroot(files, flavour, tree_name)
812 if reader == "uproot":
813 return get_energies_uproot(files, flavour, tree_name)
814 raise ValueError(f"unknown reader: {reader!r} (use auto/pyroot/uproot)")
815
816
817# ==========================================================================
818# Comparison plot
819# ==========================================================================
820def make_comparison_plot(dpm, curves, normalized_factor, outfile,
821 bins=100, hist_range=(0, 2000), title=None):
822 """Overlay the DPMJET energy spectrum with each reweighted fast-sim curve.
823
824 The same array of DPMJET energies is histogrammed once per curve. The
825 reference is unweighted; every other curve applies its w(E) per event, so
826 ``DPMJET x w(E)`` is the fast-sim prediction. The binning here is
827 independent of the binning used to derive w(E): the weight is evaluated
828 from the polynomial at each event's energy, not looked up by bin.
829
830 The integral quoted in each legend entry is the normalised area under the
831 histogram, i.e. the expected yield at the target luminosity.
832
833 Returns a dict of ``{label: integral}``.
834 """
835 if title is None:
836 title = "Energy Distribution: DPMJET vs fast-sim (light + charm) reweighted"
837
838 plt.figure(figsize=(12, 7))
839 integrals = {}
840
841 for label, weight_poly, color, linestyle in curves:
842 weights = None if weight_poly is None else weight_poly(dpm)
843
844 # Histogram used for the integral only (not drawn).
845 counts, edges = np.histogram(
846 dpm, bins=bins, range=hist_range, weights=weights
847 )
848 bin_width = edges[1] - edges[0]
849 integral = (normalized_factor * np.sum(counts) * bin_width
850 * (bins / (hist_range[1] - hist_range[0])))
851 integrals[label] = integral
852
853 plt.hist(dpm, bins=bins, range=hist_range, histtype="step",
854 weights=weights, color=color, linestyle=linestyle,
855 label=f"{label} (\u222bfde \u2248 {integral:.1f})")
856
857 plt.xlabel("Energy GeV")
858 plt.ylabel(f"Events / {(hist_range[1] - hist_range[0]) / bins} GeV")
859 plt.title(title)
860 plt.legend()
861 plt.grid(alpha=0.3)
862 plt.tight_layout()
863
864 os.makedirs(os.path.dirname(os.path.abspath(outfile)), exist_ok=True)
865 plt.savefig(outfile, dpi=150)
866 plt.close()
867 print("Saved:", outfile)
868
869 print("\nIntegrals (sum(counts)*bin_width, normalized):")
870 for label, val in integrals.items():
871 print(f"{label:45s}: {val:.3f}")
872 return integrals
873
874
875# ==========================================================================
876# Command line entry point
877# ==========================================================================
878
879#: Fallback styling when fast-sim configs are given on the command line and so
880#: carry no colour/linestyle of their own.
881DEFAULT_COLORS = ["tab:blue", "tab:red", "tab:green", "tab:orange", "tab:purple"]
882DEFAULT_STYLES = ["--", "-.", ":"]
883
884
886 """Turn bare config paths from ``-rc`` into styled fast-sim entries."""
887 return [{"config": path,
888 "color": DEFAULT_COLORS[i % len(DEFAULT_COLORS)],
889 "linestyle": DEFAULT_STYLES[i % len(DEFAULT_STYLES)]}
890 for i, path in enumerate(paths)]
891
892
893def main():
894 """Parse the command line and run the requested mode.
895
896 Modes
897 -----
898 reweight : fit each reweighter config and save its diagnostic plots. Needs
899 no run YAML when both ``-m reweight`` and ``-rc`` are given.
900 compare : fit w(E), read the DPMJET energies from ROOT, and save the
901 overlay. Needs the run YAML for the ROOT input and plot settings.
902
903 Command-line options override the corresponding run-YAML fields; anything
904 not given falls back to the YAML.
905 """
906 p = argparse.ArgumentParser(
907 description="Flux reweighting + energy-distribution comparison (YAML driven)")
908 p.add_argument("-c", "--config", default="analysis.yaml",
909 help="Top-level analysis YAML with the run settings")
910 p.add_argument("-rc", "--reweight-config", nargs="+", default=None,
911 metavar="YAML",
912 help="FluxReweighter config file(s). In compare mode these are "
913 "the fast-sim variants (overrides fastsim_configs in the "
914 "run YAML); in reweight mode each is fit and plotted.")
915 p.add_argument("-m", "--mode", choices=["compare", "reweight"], default=None,
916 help="Override the 'mode' set in the run YAML")
917 p.add_argument("--reader", choices=["auto", "pyroot", "uproot"], default=None,
918 help="ROOT backend: auto (default), pyroot, or uproot "
919 "(pip install uproot awkward; no ROOT framework needed)")
920 args = p.parse_args()
921
922 # The run YAML is loaded lazily, so 'reweight' mode with -rc works in a
923 # directory that has no Analysis.yaml at all.
924 _cfg = {}
925
926 def get_cfg():
927 nonlocal _cfg
928 if not _cfg:
929 try:
930 _cfg = load_analysis_config(args.config)
931 except FileNotFoundError:
932 raise SystemExit(
933 f"Run config '{args.config}' not found. It's needed for "
934 "compare mode (and to read 'mode'/'reweight_only' when not "
935 "given on the CLI). Pass -c <file>, or in reweight mode give "
936 "-m reweight with -rc <config(s)> so no run YAML is required.")
937 return _cfg
938
939 mode = args.mode or get_cfg().get("mode", "compare")
940
941 if mode == "reweight":
942 paths = args.reweight_config or [get_cfg()["reweight_only"]["config"]]
943 for path in paths:
944 FluxReweighter(path).run()
945 return
946
947 # --- compare mode ---
948 cfg = get_cfg()
949 ri = cfg["root_input"]
950 rw = cfg.get("reweight", {})
951 plot = cfg.get("plot", {})
952 degree = int(rw.get("degree", 2))
953
954 fastsim = (fastsim_entries_from_args(args.reweight_config)
955 if args.reweight_config else cfg["fastsim_configs"])
956
957 # 1) reweighting polynomials, fitted by FluxReweighter
958 curves = build_fastsim_curves(fastsim, cfg.get("reference", {}), degree=degree)
959
960 # 2) DPMJET neutrino energies from ROOT (pyROOT or uproot)
961 reader = args.reader or ri.get("reader", "auto")
962 dpm = read_dpm_energies(ri, reader=reader)
963 if dpm.size == 0:
964 raise SystemExit("No energies extracted -- check files / branch names.")
965
966 # 3) overlay DPMJET with each reweighted fast-sim curve
968 plot.get("normalization", "auto"),
969 int(ri["number_files"]),
970 target_luminosity=float(plot.get("target_luminosity", 68.6)),
971 luminosity_per_file=float(plot.get("luminosity_per_file", 100.0)),
972 )
974 dpm, curves, normalized_factor=norm,
975 outfile=plot.get("outfile", "energy_comparison.pdf"),
976 bins=int(plot.get("bins", 100)),
977 hist_range=(0, float(plot.get("energy_max", 2000.0))),
978 title=plot.get("title"),
979 )
980
981
982if __name__ == "__main__":
983 main()
set(INCLUDE_DIRECTORIES ${SYSTEM_INCLUDE_DIRECTORIES} ${VMC_INCLUDE_DIRS} ${CMAKE_SOURCE_DIR}/shipdata ${CMAKE_SOURCE_DIR}/shipLHC ${CMAKE_SOURCE_DIR}/analysis/cuts ${CMAKE_SOURCE_DIR}/analysis/tools ${FMT_INCLUDE_DIR}) include_directories($
Definition CMakeLists.txt:1
__init__(self, config_path="config.yaml")
_make_plot(self, deg, poly, chi2red, fit_mask)
_adaptive_edges(fine, dsw, dsw2, lsw, lsw2, min_neff)
_hist(self, df, mask, use_weight=True)
dpm_file_list(data_dir, n_files, pattern, indexed=True)
resolve_normalization(norm, number_files, target_luminosity=68.6, luminosity_per_file=100.0)
get_energies_pyroot(files, flavour="mu", tree_name="cbmsim", n_events=None)
build_chain(files, tree_name="cbmsim")
get_energies_uproot(files, flavour="mu", tree_name="cbmsim", n_events=None)
make_comparison_plot(dpm, curves, normalized_factor, outfile, bins=100, hist_range=(0, 2000), title=None)
build_fastsim_curves(fastsim_configs, reference, degree)
read_dpm_energies(ri, reader="auto")