codexhydrogen-bubble-chamber-simulated-invariant-mass.py
hydrogen-bubble-chamber-simulated-invariant-mass.py
"""Invented two-daughter mass simulation, not historical data.
Python 3.14, NumPy 2.3.5, Matplotlib 3.10.7. No SciPy.
All numerical mass/momentum variables use GeV with c=1.
"""
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
DAUGHTER_MASS = .14
LOW, HIGH = .50, 1.10
CENTER, SCALE = .80, .020
N_SIGNAL, N_BACKGROUND = 700, 2400
MOMENTUM_NOISE = .012
def invariant_mass(momentum):
"""momentum has shape (events, daughters, three Cartesian components)."""
energies = np.sqrt(DAUGHTER_MASS**2 + np.sum(momentum**2, axis=2))
squared = np.sum(energies, axis=1)**2 - np.sum(np.sum(momentum, axis=1)**2, axis=1)
assert np.all(squared > 0)
return np.sqrt(squared)
def sample(seed=1968):
rng = np.random.default_rng(seed)
# Exact inverse-CDF sampling of the stipulated truncated Cauchy.
a = np.arctan((LOW-CENTER)/SCALE)
z = np.arctan((HIGH-CENTER)/SCALE)
signal = CENTER + SCALE*np.tan(rng.uniform(a, z, N_SIGNAL))
# t in [0,1] has density (4/3)(1-t/2), declining by a factor of two.
# Inverting F(t)=(4/3)(t-t^2/4) gives t=2-sqrt(4-3u).
t = 2 - np.sqrt(4 - 3*rng.random(N_BACKGROUND))
background = LOW + (HIGH-LOW)*t
truth = np.concatenate((background, signal))
direction = rng.normal(size=(len(truth), 3))
direction /= np.linalg.norm(direction, axis=1)[:, None]
magnitude = np.sqrt((truth/2)**2-DAUGHTER_MASS**2)
p = magnitude[:, None]*direction
momenta = np.stack((p, -p), axis=1)
exact = invariant_mass(momenta)
np.testing.assert_allclose(exact, truth, rtol=2e-15, atol=2e-15)
noisy = invariant_mass(momenta + rng.normal(0, MOMENTUM_NOISE, momenta.shape))
return exact, noisy
def background_expectation(edges):
t = (edges-LOW)/(HIGH-LOW)
cdf = (4/3)*(t-t*t/4)
return N_BACKGROUND*np.diff(cdf)
def main():
plt.rcParams.update({"font.size": 11, "font.family": "DejaVu Sans"})
exact, noisy = sample()
edges = np.linspace(LOW, HIGH, 41)
fig, axes = plt.subplots(1, 2, figsize=(11.4, 5.2), dpi=120, sharey=True, facecolor="white")
fig.suptitle("Invented resonance + smooth background: same 3100 generated pairs", fontsize=15, y=.96)
for ax, values, title in zip(axes, [exact, noisy], ["Exact stipulated momenta", "After chosen momentum noise"]):
ax.hist(values, edges, histtype="step", color="#1e6091", lw=1.7, label="all simulated pairs")
ax.hist(values[:N_BACKGROUND], edges, histtype="stepfilled", color="#e1b266", alpha=.48,
label="known simulated background")
ax.axvline(CENTER, color="#266f48", ls=":", lw=1.3)
ax.set(xlabel=r"reconstructed invariant mass $M$ (GeV/$c^2$)", title=title,
xlim=(LOW, HIGH), ylim=(0, 240))
ax.grid(axis="y", alpha=.18)
ax.spines[["top", "right"]].set_visible(False)
axes[0].stairs(background_expectation(edges), edges, color="#a15d13", lw=1.5,
label="smooth input expectation")
axes[0].set_ylabel(r"pairs per 0.015 GeV/$c^2$")
axes[0].legend(loc="upper left", fontsize=8.5, frameon=False)
axes[1].text(.515, 222, r"independent component noise:"+"\n"+r"$\sigma_p=0.012$ GeV/$c$", fontsize=10, va="top")
axes[1].text(.515, 170, "The peak broadens;\nits width is not only a lifetime.", fontsize=10, va="top")
fig.text(.5, .035, "All counts and distribution parameters stipulated; no historical measurements. Noise can move pairs outside the plotted range.",
ha="center", fontsize=10)
fig.subplots_adjust(left=.07, right=.98, bottom=.17, top=.84, wspace=.12)
fig.savefig(Path(__file__).stem + ".png", facecolor="white")
plt.close(fig)
print({"generated": len(exact), "background": N_BACKGROUND, "signal": N_SIGNAL,
"noisy_in_display_range": int(np.sum((noisy>=LOW)&(noisy<=HIGH))),
"signal_noise_mass_rms": float(np.sqrt(np.mean((noisy[N_BACKGROUND:]-exact[N_BACKGROUND:])**2)))})
if __name__ == "__main__":
main()