Back to home page

EIC code displayed by LXR

 
 

    


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

0001 """Shared helpers used by the standalone energy-response reproduction."""
0002 
0003 import os
0004 import sys
0005 from dataclasses import dataclass
0006 from typing import Callable, Optional
0007 
0008 import matplotlib.pyplot as plt
0009 import numpy as np
0010 import pandas as pd
0011 from scipy.optimize import curve_fit
0012 
0013 LOAD_CHUNK_SIZE = 1_000_000
0014 nETruthBins = 30
0015 nERecoBins = 150
0016 
0017 
0018 def applyPlotStyle():
0019     """Apply 2× scaling to matplotlib font and line sizes for readability."""
0020     plt.rcParams.update({
0021         'axes.titlesize': 24, 'axes.labelsize': 22,
0022         'xtick.labelsize': 20, 'ytick.labelsize': 20,
0023         'legend.fontsize': 16, 'figure.titlesize': 18,
0024         'lines.linewidth': 1.3, 'lines.markersize': 7.8,
0025     })
0026 
0027 
0028 def crystal_ball(x, A, mu, sigma, alpha, n):
0029     """Evaluate the un-normalized Crystal Ball model."""
0030     x = np.array(x)
0031     t = (x - mu) / sigma
0032     abs_alpha = np.abs(alpha)
0033     a = (n / abs_alpha) ** n * np.exp(-0.5 * abs_alpha ** 2)
0034     b = n / abs_alpha - abs_alpha
0035     result = np.zeros_like(t)
0036     gaussian = t < abs_alpha
0037     result[gaussian] = np.exp(-0.5 * t[gaussian] ** 2)
0038     result[~gaussian] = a * (b + t[~gaussian]) ** (-n)
0039     return A * result
0040 
0041 
0042 def loadData(path, pdg, columns, chunksize=LOAD_CHUNK_SIZE):
0043     """Yield filtered DataFrame chunks from a space-separated data file.
0044 
0045     Each chunk is filtered to status >= 0 and the requested PDG value; empty
0046     chunks are skipped so downstream histogram accumulation can stream safely.
0047     """
0048     reader = pd.read_csv(path, sep=' ', names=columns, chunksize=chunksize)
0049     for raw_chunk in reader:
0050         chunk = raw_chunk[(raw_chunk['status'] >= 0) & (raw_chunk['PDG'] == pdg)]
0051         if len(chunk):
0052             yield chunk
0053 
0054 
0055 def buildSliceHistogramFromChunks(chunkIter, xcol, ycol, xbins, ybins,
0056                                   xTransform=None, yTransform=None):
0057     """Stream-accumulate a 2D histogram from DataFrame chunks.
0058 
0059     Optional transforms replace the named columns and are useful for derived
0060     quantities. Returns centers, histogram, and Poisson-style errors.
0061     """
0062     hist = np.zeros((len(xbins) - 1, len(ybins) - 1))
0063     for chunk in chunkIter:
0064         x = xTransform(chunk) if xTransform is not None else chunk[xcol].to_numpy()
0065         y = yTransform(chunk) if yTransform is not None else chunk[ycol].to_numpy()
0066         h, _, _ = np.histogram2d(x, y, bins=(xbins, ybins))
0067         hist += h
0068     err = hist / np.sqrt(hist)
0069     xcenters = 0.5 * (xbins[1:] + xbins[:-1])
0070     ycenters = 0.5 * (ybins[1:] + ybins[:-1])
0071     return xcenters, ycenters, hist, err
0072 
0073 
0074 def plotThetaDistribution(ax, thetaCounts, bins):
0075     """Draw the theta-distribution side panel from accumulated bin counts."""
0076     ax.stairs(thetaCounts, bins)
0077     ax.set_xlabel(r'$\theta$ (deg)')
0078 
0079 
0080 def p0CrystalBall(ycenters, histSlice):
0081     """Build robust initial Crystal Ball parameters for one histogram slice."""
0082     cdf = np.cumsum(histSlice)
0083     cdf = cdf / cdf[-1]
0084     mu0 = float(np.interp(0.5, cdf, ycenters))
0085     y_low = float(np.interp(0.1587, cdf, ycenters))
0086     y_high = float(np.interp(0.8413, cdf, ycenters))
0087     sigma0 = 0.5 * (y_high - y_low)
0088     if sigma0 <= 0 or np.isnan(sigma0):
0089         yrange = ycenters[-1] - ycenters[0]
0090         sigma0 = yrange / 6.0 if yrange > 0 else 1.0
0091     return [np.max(histSlice), mu0, sigma0, 1.5, 2.0]
0092 
0093 
0094 def boundsCrystalBallPositive(ycenters, histSlice, p0):
0095     """Return positive-peak Crystal Ball parameter bounds."""
0096     return (
0097         [0.1 * p0[0], 0.5 * p0[1], 0.1 * p0[2], 0.1, 1.01],
0098         [5 * p0[0], max(p0[1], ycenters[-1]), 5 * p0[2], 10, 100],
0099     )
0100 
0101 
0102 def maskPositive(xcenter, ycenters, histSlice):
0103     """Keep positive-count bins."""
0104     return histSlice > 0
0105 
0106 
0107 def maskPositiveAboveThreshold(thresholdFrac, lowXCutoff):
0108     """Make the historical positive-response slice mask."""
0109     def _mask(xcenter, ycenters, histSlice):
0110         if xcenter < lowXCutoff:
0111             return histSlice > 0
0112         threshold = thresholdFrac * max(ycenters)
0113         return (histSlice > 0) & (ycenters > threshold)
0114     return _mask
0115 
0116 
0117 @dataclass
0118 class SliceResult:
0119     """Per-slice center and width estimates returned by a reducer."""
0120     center: float
0121     centerErr: float
0122     width: float
0123     widthErr: float
0124     render: Optional[Callable] = None
0125     method: str = 'fit'
0126 
0127 
0128 def reduceSlices(xcenters, ycenters, hist, err, reducer, sliceMask=None, outDir=None):
0129     """Reduce every x-slice and return centers, errors, widths, and errors.
0130 
0131     Slices with too few usable bins receive NaNs. If `outDir` is supplied, a
0132     reducer's optional render callback writes one diagnostic image per slice.
0133     """
0134     if sliceMask is None:
0135         sliceMask = maskPositive
0136     centers, center_errors, widths, width_errors = [], [], [], []
0137     for i, (hist_slice, err_slice) in enumerate(zip(hist, err)):
0138         selected = sliceMask(xcenters[i], ycenters, hist_slice)
0139         hm = hist_slice[selected]
0140         em = err_slice[selected]
0141         ym = ycenters[selected]
0142         result = reducer(ym, hm, em, ycenters) if len(hm) > 3 and np.sum(hm) > 0 else None
0143         if result is None:
0144             centers.append(np.nan); center_errors.append(np.nan)
0145             widths.append(np.nan); width_errors.append(np.nan)
0146             continue
0147         centers.append(result.center); center_errors.append(result.centerErr)
0148         widths.append(result.width); width_errors.append(result.widthErr)
0149         if outDir is not None and result.render is not None:
0150             fig, ax = plt.subplots(figsize=(7, 5))
0151             result.render(ax)
0152             fig.tight_layout()
0153             fig.savefig(os.path.join(outDir, 'img_%d.png' % i))
0154             plt.close(fig)
0155     return (np.array(centers), np.array(center_errors),
0156             np.array(widths), np.array(width_errors))
0157 
0158 
0159 def _mode_and_crossing(ycenters, histSlice, heightFrac):
0160     """Estimate histogram mode and half-width at a chosen height fraction."""
0161     ycenters, histSlice = np.array(ycenters), np.array(histSlice)
0162     peak_index = np.argmax(histSlice)
0163     mode = float(ycenters[peak_index])
0164     height = heightFrac * float(histSlice[peak_index])
0165     left = np.nan
0166     for i in range(peak_index - 1, -1, -1):
0167         if histSlice[i] <= height:
0168             denom = histSlice[i + 1] - histSlice[i]
0169             frac = (height - histSlice[i]) / denom if denom else 0.5
0170             left = ycenters[i] + np.clip(frac, 0, 1) * (ycenters[i + 1] - ycenters[i])
0171             break
0172     right = np.nan
0173     for i in range(peak_index + 1, len(histSlice)):
0174         if histSlice[i] <= height:
0175             denom = histSlice[i] - histSlice[i - 1]
0176             frac = (height - histSlice[i - 1]) / denom if denom else 0.5
0177             right = ycenters[i - 1] + np.clip(frac, 0, 1) * (ycenters[i] - ycenters[i - 1])
0178             break
0179     width = (right - left) / 2 if np.isfinite(left) and np.isfinite(right) else np.nan
0180     return mode, width, left, right, height
0181 
0182 
0183 def _bootstrap_mode_crossing(ycenters, histSlice, n=200):
0184     """Estimate mode/HWHM uncertainties with deterministic multinomial bootstrap."""
0185     total = int(round(np.sum(histSlice)))
0186     if total < 5:
0187         return np.nan, np.nan
0188     rng = np.random.default_rng(12345)
0189     probabilities = histSlice / histSlice.sum()
0190     centers, widths = [], []
0191     for _ in range(n):
0192         result = _mode_and_crossing(ycenters, rng.multinomial(total, probabilities), 0.5)
0193         centers.append(result[0])
0194         if np.isfinite(result[1]):
0195             widths.append(result[1])
0196     center_error = np.std(centers, ddof=1) if len(centers) >= 2 else np.nan
0197     width_error = np.std(widths, ddof=1) if len(widths) >= 10 else np.nan
0198     return center_error, width_error
0199 
0200 
0201 def curveFitReducer(fitFunc, p0Func, boundsFunc=None):
0202     """Create a per-slice Crystal Ball fit reducer with historical fallback.
0203 
0204     Fits use scipy's `curve_fit`; low-statistics, failed, or physically
0205     implausible fits fall back to a mode plus HWHM estimate with bootstrap
0206     uncertainties, matching the parent pipeline's behavior.
0207     """
0208     def reduce_one(ycMasked, histMasked, errMasked, ycFull):
0209         total = np.sum(histMasked)
0210         if total < 20:
0211             return fallback(ycMasked, histMasked, errMasked, ycFull, 'low_stats')
0212         p0 = p0Func(ycMasked, histMasked)
0213         kwargs = dict(sigma=errMasked, absolute_sigma=True, p0=p0, maxfev=5000)
0214         if boundsFunc is not None:
0215             kwargs['bounds'] = boundsFunc(ycFull, histMasked, p0)
0216         try:
0217             popt, pcov = curve_fit(fitFunc, ycMasked, histMasked, **kwargs)
0218         except Exception as exc:
0219             return fallback(ycMasked, histMasked, errMasked, ycFull, f'fit exception {type(exc).__name__}')
0220         yrange = ycFull[-1] - ycFull[0]
0221         diag = np.diag(pcov)
0222         reason = None
0223         if popt[2] <= 0 or popt[2] > yrange:
0224             reason = 'invalid sigma'
0225         elif popt[1] < ycFull[0] or popt[1] > ycFull[-1]:
0226             reason = 'invalid mu'
0227         elif np.any(~np.isfinite(diag)) or np.any(diag < 0):
0228             reason = 'invalid covariance'
0229         elif pcov[1, 1] > yrange ** 2 or pcov[2, 2] > yrange ** 2:
0230             reason = 'uncertain fit'
0231         if reason:
0232             return fallback(ycMasked, histMasked, errMasked, ycFull, reason)
0233         return SliceResult(float(popt[1]), float(np.sqrt(pcov[1, 1])),
0234                            float(popt[2]), float(np.sqrt(pcov[2, 2])), method=fitFunc.__name__)
0235 
0236     def fallback(yc, histSlice, errSlice, ycFull, reason):
0237         """Return the historical mode/HWHM fallback result."""
0238         print(f'[curveFitReducer] slice fallback -> mode+HWHM: {reason}', file=sys.stderr)
0239         mode, width, left, right, height = _mode_and_crossing(yc, histSlice, 0.5)
0240         center_error, width_error = _bootstrap_mode_crossing(yc, histSlice)
0241         return SliceResult(float(mode), center_error, float(width) if np.isfinite(width) else np.nan,
0242                            width_error, method='mode_hwhm')
0243 
0244     return reduce_one