2"""Neutrino-flux reweighting and energy-distribution comparison for SND@LHC.
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.
10 w(E) = flux_fastsim(E) / flux_DPMJET(E)
12where each flux is the sum of a light-hadron (pi/K) and a heavy-flavour
15 flux_DPMJET = light_DPMJET + charm_DPMJET (the reference / denominator)
16 flux_fastsim = light_EPOS + charm_POWHEG (the target / numerator)
18Multiplying every DPMJET event by w(E) therefore reproduces the fast-sim
19prediction, without re-running the simulation:
21 DPMJET x w(E) == fast-sim (light + charm)
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.
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``).
37ROOT files: standard SND@LHC digitised output holding the ``cbmsim`` tree with
38the ``MCTrack`` branch, from which the incoming (primary) neutrino energy is
43Every input is read from YAML; nothing analysis-specific is hard-coded.
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
52 # diagnostic ratio-fit plots for one or more reweighter configs
53 python3 reweighting_function.py -m reweight -rc Config_epos.yaml
55 # full comparison: fit w(E), read ROOT energies, overlay and save
56 python3 reweighting_function.py -c Analysis.yaml -rc Config_epos.yaml
58 # everything taken from the run YAML, no extra arguments
59 python3 reweighting_function.py -c Analysis.yaml
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
77import matplotlib.pyplot
as plt
98 from tqdm.auto
import tqdm
100 def tqdm(x, **kwargs):
105 """Derive the reweighting function w(E) = fast-sim / DPMJET from flux files.
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.
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
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
127 PID = {
"nue": 12,
"numu": 14,
"nutau": 16}
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",
142 """Load the YAML config and prepare the default (uniform) bin edges.
144 Adaptive edges, if requested, are computed later in
145 :meth:`compute_ratio` because they depend on the selected events.
149 self.
cfg[
"energy_min"], self.
cfg[
"energy_max"], num=self.
cfg[
"bins"]
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}")
164 """Build variable-width bin edges with a minimum of effective entries.
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
170 N_eff = (sum w)^2 / sum w^2 >= min_neff
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.
181 Edges of the fine starting grid.
183 Per-fine-bin sum of weights and sum of weights squared, DPMJET side.
185 The same for the fast-sim side.
187 Required effective entries per output bin.
192 The merged bin edges (always closed at ``fine[-1]``).
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)
208 """Parse the reweighter YAML into a flat dict of settings.
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
214 with open(yaml_path)
as f:
215 raw = yaml.safe_load(f)
or {}
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", {})
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"),
232 "flavour": raw.get(
"flavour",
"mu"),
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)),
238 "bin_mode": eb.get(
"mode",
"uniform"),
239 "fine_bins": int(eb.get(
"fine_bins", 400)),
240 "min_neff": float(eb.get(
"min_neff", 10.0)),
243 "fit_min": float(fr.get(
"min", 0.0)),
244 "fit_max": float(fr.get(
"max", 4000.0)),
247 "degrees": [int(d)
for d
in raw.get(
"polynomial_degrees", [1, 2, 3])],
252 "rel_err_max": float(raw.get(
"rel_err_max", 3.0)),
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"))),
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)),
271 "cut_scale": float(cut.get(
"unit_scale", 1.0)),
273 "cut_enabled": bool(cut.get(
"enabled",
True)),
278 "weight_dpm": bool(wt.get(
"dpm",
True)),
279 "weight_fastsim": bool(wt.get(
"fastsim",
True)),
282 "outpath": out.get(
"path",
"./output"),
283 "label": out.get(
"label",
"EPOS+POWHEG"),
290 """Read one flux text file into a DataFrame with named columns.
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.
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)
299 """Boolean mask for the fiducial (detector acceptance) selection.
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.
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"]
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"])
317 """Print how many rows survive each selection stage for one file.
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.
323 Returns the number of surviving rows.
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} "
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"])
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}")
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']}]")
347 def _hist(self, df, mask, use_weight=True):
348 """Weighted energy histogram of the selected rows, with its error.
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.
354 Returns ``(counts, sigma)``.
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))
366 """Build the fast-sim / DPMJET ratio versus energy.
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.
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).
377 Populates ``E``, ``w``, ``sig``, ``H_DPM``, ``s_DPM``, ``H_LC``,
378 ``s_LC`` and returns ``self`` so calls can be chained.
380 pid = self.
PID[self.
cfg[
"flavour"]]
388 print(f
"Selection report ({self.cfg['label']}, flavour={self.cfg['flavour']}):")
393 if (n_ld + n_cd) == 0
or (n_lf + n_cf) == 0:
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.")
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)
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)
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)
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})")
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)
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"])
437 self.
s_LC = np.sqrt(s_L_fs ** 2 + s_C_fs ** 2)
442 R = np.zeros_like(self.
H_DPM)
443 sR = np.zeros_like(self.
H_DPM)
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)
451 self.
E, self.
w, self.
sig = centers, R, sR
455 """Boolean mask selecting which ratio bins enter the polynomial fit.
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
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]
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"])
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)
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)")
485 """Least-squares polynomial fit weighted by 1/sigma.
487 Returns ``(poly, chi2, chi2/ndf)`` where ``poly`` is a ``np.poly1d``
488 with coefficients ordered highest power first.
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
496 """Return the reweighting polynomial w(E) for one degree.
498 Computes the ratio on first use. Evaluate the result per event as
499 ``poly(E)``; it is only meaningful inside ``fit_range``.
501 Returns ``(poly, chi2red)``. Raises ``ValueError`` if the selection
502 leaves fewer points than the degree requires.
507 n_fit = int(mask.sum())
508 if n_fit < degree + 1:
510 f
"Not enough fit points ({n_fit}) for degree {degree}")
512 self.
E[mask], self.
w[mask], self.
sig[mask], degree=degree
521 """Format w(E) as a LaTeX string for the plot's statistics box."""
523 for p, coef
in zip(range(deg, -1, -1), poly.coefficients):
525 terms.append(rf
"({coef:.3e})")
527 terms.append(rf
"({coef:.3e})\,E")
529 terms.append(rf
"({coef:.3e})\,E^{p}")
530 return r"$w(E) = " +
" + ".join(terms) +
r"$"
533 """Write the diagnostic ratio plot for one polynomial degree.
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.
540 Returns the path of the saved PDF.
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]
546 E_plot_fit = np.linspace(c[
"fit_min"], c[
"fit_max"], 300)
547 E_plot_all = np.linspace(E.min(), E.max(), 400)
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")
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)
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")
565 stats_text =
"\n".join([
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",
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))
577 outfile = os.path.join(c[
"outpath"],
578 f
"reweight_poly_deg{deg}_{c['flavour']}.pdf")
587 """Fit every degree in ``polynomial_degrees`` and save one plot each.
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
594 os.makedirs(self.
cfg[
"outpath"], exist_ok=
True)
599 n_fit = int(fit_mask.sum())
601 for deg
in self.
cfg[
"degrees"]:
603 print(f
"[skip] deg={deg}: only {n_fit} fit points (need {deg + 1})")
606 self.
E[fit_mask], self.
w[fit_mask], self.
sig[fit_mask], degree=deg
608 print(
"Saved:", self.
_make_plot(deg, poly, chi2red, fit_mask))
610 print(
"Output dir:", self.
cfg[
"outpath"])
619NU_PID = {
"e": 12,
"mu": 14,
"tau": 16}
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 {}
629 luminosity_per_file=100.0):
630 """Return the factor scaling the simulated sample to the target luminosity.
632 The automatic value is the luminosity ratio L_data / L_MC,
634 target_luminosity / (number_files * luminosity_per_file)
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.
640 auto = target_luminosity / (number_files * luminosity_per_file)
641 if isinstance(norm, str)
and norm.strip().lower() ==
"auto":
644 return norm
if norm > 0
else auto
648 """Fit w(E) for each fast-sim variant and assemble the curves to draw.
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.
654 Returns a list of ``(label, weight_poly | None, color, linestyle)`` with the
655 unweighted DPMJET reference (``weight_poly`` None) first.
657 curves = [(reference.get(
"label",
"DPMJET (reference)"),
659 reference.get(
"color",
"black"),
660 reference.get(
"linestyle",
"-"))]
661 for entry
in fastsim_configs:
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",
"--")))
678 """Build the list of ROOT files to read.
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>``.
685 return [os.path.join(data_dir, str(i), pattern)
for i
in range(n_files)]
686 return [os.path.join(data_dir, pattern)]
690 """Chain the given ROOT files into one TChain (pyROOT only)."""
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}')")
700 """Energy of the primary neutrino in the current entry, or None.
702 The incoming neutrino is the MCTrack whose |pdg| matches the requested
703 flavour and which has no mother (MotherId == -1).
705 for trk
in tree.MCTrack:
706 if abs(trk.GetPdgCode())
in pids
and trk.GetMotherId() == -1:
707 return trk.GetEnergy()
712 """Read per-event neutrino energies [GeV] with pyROOT.
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.
717 pids =
set(NU_PID.values())
if flavour ==
"all" else {NU_PID[flavour]}
719 n_total = chain.GetEntries()
720 n = n_total
if n_events
is None else min(n_events, n_total)
723 for i
in tqdm(range(n), desc=
"reading neutrino energy (pyROOT)"):
728 arr = np.asarray(energies, dtype=float)
729 print(f
"Extracted {arr.size} neutrino energies (flavour={flavour})")
733 """Locate the MCTrack member branches whatever the exact prefix is.
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.
739 want = [
"fPdgCode",
"fMotherId",
"fPx",
"fPy",
"fPz"]
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)])
751 """Read per-event neutrino energies [GeV] with uproot, without ROOT.
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.
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]}
762 for path
in tqdm(files, desc=
"reading neutrino energy (uproot)"):
764 tree = uproot.open(f
"{path}:{tree_name}")
765 except Exception
as exc:
766 print(f
" [skip] {path}: {exc}")
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"]]
777 is_nu = ak.zeros_like(mom, dtype=bool)
779 is_nu = is_nu | (abs(pdg) == pid)
780 sel = is_nu & (mom == -1)
782 E = np.sqrt(px ** 2 + py ** 2 + pz ** 2)
783 first = ak.firsts(E[sel])
784 first = first[~ak.is_none(first)]
785 out.append(ak.to_numpy(first))
787 arr = np.concatenate(out)
if out
else np.array([], dtype=float)
788 print(f
"Extracted {arr.size} neutrino energies (flavour={flavour})")
793 """Read the DPMJET neutrino energies using the requested ROOT backend.
795 ``ri`` is the ``root_input`` block of the run YAML. ``reader`` is "auto"
796 (pyROOT when importable, else uproot), "pyroot" or "uproot".
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")
804 reader =
"pyroot" if ROOT
is not None else "uproot"
806 if reader ==
"pyroot":
808 raise RuntimeError(
"reader=pyroot but ROOT is unavailable. Source the "
809 "SND@LHC setup, or use reader: uproot "
810 "(pip install uproot awkward).")
812 if reader ==
"uproot":
814 raise ValueError(f
"unknown reader: {reader!r} (use auto/pyroot/uproot)")
821 bins=100, hist_range=(0, 2000), title=
None):
822 """Overlay the DPMJET energy spectrum with each reweighted fast-sim curve.
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.
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.
833 Returns a dict of ``{label: integral}``.
836 title =
"Energy Distribution: DPMJET vs fast-sim (light + charm) reweighted"
838 plt.figure(figsize=(12, 7))
841 for label, weight_poly, color, linestyle
in curves:
842 weights =
None if weight_poly
is None else weight_poly(dpm)
845 counts, edges = np.histogram(
846 dpm, bins=bins, range=hist_range, weights=weights
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
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})")
857 plt.xlabel(
"Energy GeV")
858 plt.ylabel(f
"Events / {(hist_range[1] - hist_range[0]) / bins} GeV")
864 os.makedirs(os.path.dirname(os.path.abspath(outfile)), exist_ok=
True)
865 plt.savefig(outfile, dpi=150)
867 print(
"Saved:", outfile)
869 print(
"\nIntegrals (sum(counts)*bin_width, normalized):")
870 for label, val
in integrals.items():
871 print(f
"{label:45s}: {val:.3f}")
881DEFAULT_COLORS = [
"tab:blue",
"tab:red",
"tab:green",
"tab:orange",
"tab:purple"]
882DEFAULT_STYLES = [
"--",
"-.",
":"]
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)]
894 """Parse the command line and run the requested mode.
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.
903 Command-line options override the corresponding run-YAML fields; anything
904 not given falls back to the YAML.
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,
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()
931 except FileNotFoundError:
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.")
939 mode = args.mode
or get_cfg().get(
"mode",
"compare")
941 if mode ==
"reweight":
942 paths = args.reweight_config
or [get_cfg()[
"reweight_only"][
"config"]]
949 ri = cfg[
"root_input"]
950 rw = cfg.get(
"reweight", {})
951 plot = cfg.get(
"plot", {})
952 degree = int(rw.get(
"degree", 2))
955 if args.reweight_config
else cfg[
"fastsim_configs"])
961 reader = args.reader
or ri.get(
"reader",
"auto")
964 raise SystemExit(
"No energies extracted -- check files / branch names.")
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)),
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"),
982if __name__ ==
"__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($
_require(d, key, section)
__init__(self, config_path="config.yaml")
_report_selection(self, name, df, pid)
_make_plot(self, deg, poly, chi2red, fit_mask)
_load_config(self, yaml_path)
_poly_equation_text(poly, deg)
_fit_mask(self, verbose=False)
_adaptive_edges(fine, dsw, dsw2, lsw, lsw2, min_neff)
fit_poly_weighted(x, y, sigma, degree=1)
_hist(self, df, mask, use_weight=True)
dpm_file_list(data_dir, n_files, pattern, indexed=True)
_find_mctrack_branches(tree)
resolve_normalization(norm, number_files, target_luminosity=68.6, luminosity_per_file=100.0)
load_analysis_config(path)
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)
_incoming_neutrino_energy(tree, pids)
build_fastsim_curves(fastsim_configs, reference, degree)
fastsim_entries_from_args(paths)
read_dpm_energies(ri, reader="auto")