codex/hydrogen-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()