feat: particle physics LUT — 50 years of PDG data as Q16_16 BRAM tables

34 particle masses, 8 decay widths, 11 cross-sections, 8 trigger thresholds,
8 calibration constants — all encoded as Q16_16 integers.

BRAM layout: 4 banks × 256 entries × 32-bit
  Bank 0: particle_masses (electron → upsilon_3S)
  Bank 1: decay_widths (W, Z, Higgs, top, J/ψ, ϒ)
  Bank 2: cross_sections (ttbar, W, Z, Higgs, jets at 13 TeV)
  Bank 3: triggers_calibration (LHC HLT + ECAL/HCAL constants)

Cross-section interpolation: log-log between 7/8/13/14 TeV.
Export: Verilog initial blocks for FPGA BRAM loading.

The LHC trigger system processes 40M events/second using hardware LUTs.
These tables are the same lookup operations — just in Q16_16 fixed-point.
This commit is contained in:
Brandon Schneider 2026-05-28 19:26:27 -05:00
parent 5698d6e54b
commit 73b2c3ba32

View file

@ -0,0 +1,323 @@
"""Particle Physics LUT — Q16_16 lookup tables from 50 years of PDG data.
The LHC trigger system classifies billions of events/second using hardware LUTs.
The Particle Data Group (PDG) maintains precision measurements of every known
particle property. This module encodes the most useful tables as Q16_16 values
suitable for FPGA BRAM storage.
Key insight: particle physics has been doing "computation via lookup tables"
since the 1970s. The PDG tables ARE a LUT for the Standard Model.
Tables encoded:
1. Particle masses (Q16_16 in MeV)
2. Decay widths (Q16_16 in GeV)
3. Cross-sections (Q16_16 in pb)
4. Trigger thresholds (Q16_16 in GeV)
5. Calorimeter calibration constants (Q16_16)
All values are deterministic Q16_16 integers no floats in compute paths.
"""
import struct
from pathlib import Path
# ── Q16_16 Constants ──────────────────────────────────────────────────
Q16_SCALE = 65536
Q16_MAX = 2147483647
Q16_MIN = -2147483648
def _q16(value: float) -> int:
"""Convert float to Q16_16 integer with clamping."""
raw = int(value * Q16_SCALE)
return max(Q16_MIN, min(Q16_MAX, raw))
def _q16_to_float(q: int) -> float:
"""Convert Q16_16 integer to float."""
return q / Q16_SCALE
# ── Particle Mass Table (PDG 2024) ───────────────────────────────────
# Values in MeV, encoded as Q16_16 (so 938.272 → 938.272 * 65536)
# Source: Particle Data Group, https://pdg.lbl.gov
PARTICLE_MASSES_MEV = {
# Leptons
'electron': 0.51099895, # ± 0.00000000015
'muon': 105.6583755, # ± 0.0000023
'tau': 1776.86, # ± 0.12
# Light quarks (MSbar masses)
'up': 2.16, # ± 0.26 (at 2 GeV)
'down': 4.67, # ± 0.29 (at 2 GeV)
'strange': 93.4, # ± 3.4 (at 2 GeV)
# Heavy quarks
'charm': 1270, # ± 20 (MSbar)
'bottom': 4180, # ± 30 (MSbar)
'top': 172690, # ± 300 (pole mass)
# Gauge bosons
'photon': 0, # massless
'W': 80379, # ± 12
'Z': 91187.6, # ± 2.1
'gluon': 0, # massless (confined)
# Higgs
'higgs': 125250, # ± 110
# Light mesons
'pion_charged': 139.57039, # ± 0.00018
'pion_neutral': 134.9768, # ± 0.0005
'kaon_charged': 493.677, # ± 0.016
'kaon_neutral': 497.611, # ± 0.013
'eta': 547.862, # ± 0.017
'eta_prime': 957.78, # ± 0.06
# Light baryons
'proton': 938.27208816, # ± 0.00000029
'neutron': 939.56542052, # ± 0.00000054
'lambda': 1115.683, # ± 0.006
'sigma_plus': 1189.37, # ± 0.07
'sigma_zero': 1192.642, # ± 0.024
'sigma_minus': 1197.449, # ± 0.030
'xi_zero': 1314.86, # ± 0.20
'xi_minus': 1321.71, # ± 0.07
'omega_minus': 1672.45, # ± 0.29
# Charmonium
'J_psi': 3096.900, # ± 0.006
'psi_2S': 3686.097, # ± 0.010
# Bottomonium
'upsilon_1S': 9460.30, # ± 0.26
'upsilon_2S': 10023.26, # ± 0.31
'upsilon_3S': 10355.2, # ± 0.5
}
# Encode as Q16_16 integers
PARTICLE_MASSES_Q16 = {
name: _q16(mass) for name, mass in PARTICLE_MASSES_MEV.items()
}
# ── Decay Width Table (PDG 2024) ─────────────────────────────────────
# Values in GeV, encoded as Q16_16
DECAY_WIDTHS_GEV = {
'W': 2.085, # ± 0.042
'Z': 2.4952, # ± 0.0023
'higgs': 0.00407, # ± 0.00010 (SM prediction)
'top': 1.42, # ± 0.51 (approximate)
'J_psi': 0.0000929, # ± 0.0000017 (keV → GeV: 92.9 keV)
'upsilon_1S': 0.00005402, # ± 0.00000125
'tau': 2.267e-12, # lifetime → width (very small)
'muon': 2.996e-19, # lifetime → width
}
DECAY_WIDTHS_Q16 = {
name: _q16(width) for name, width in DECAY_WIDTHS_GEV.items()
}
# ── Cross-Section Table (representative, in pb) ──────────────────────
# Q16_16 encoded cross-sections at various energies
CROSS_SECTIONS_PB = {
# pp collisions at √s = 13 TeV (LHC Run 2)
'ttbar_13TeV': 830, # ± 40 (top pair production)
'W_incl_13TeV': 20500, # ± 500 (W boson inclusive)
'Z_incl_13TeV': 1870, # ± 50 (Z boson inclusive)
'H_gg_13TeV': 48.6, # ± 2.0 (Higgs via gluon fusion)
'H_vbf_13TeV': 3.78, # ± 0.08 (Higgs via VBF)
'jets_incl_13TeV': 80000000, # 80 Mb (inclusive jets, pT > 30 GeV)
'dijet_13TeV': 10000000, # 10 Mb (dijet, pT > 50 GeV)
# ee collisions at √s = 91.2 GeV (LEP Z pole)
'Z_ee': 2980, # ± 30 (Z → ee)
'Z_mumu': 2980, # ± 30 (Z → μμ)
'Z_tautau': 2980, # ± 30 (Z → ττ)
'Z_had': 1720, # ± 20 (Z → hadrons)
}
CROSS_SECTIONS_Q16 = {
name: _q16(xs) for name, xs in CROSS_SECTIONS_PB.items()
}
# ── Trigger Thresholds (LHC, in GeV) ─────────────────────────────────
TRIGGER_THRESHOLDS_GEV = {
'single_electron': 27, # HLT_Ele27_WPTight_Gsf
'single_muon': 24, # HLT_IsoMu24
'single_photon': 150, # HLT_Photon150
'jet_450': 450, # HLT_PFJet450
'met_120': 120, # PFMET120
'ht_500': 500, # HLT_PFHT500
'dilepton_mll': 12, # M(ee/μμ) > 12 GeV
'bjet_csv': 0.8484, # DeepJet medium WP
}
TRIGGER_THRESHOLDS_Q16 = {
name: _q16(thresh) for name, thresh in TRIGGER_THRESHOLDS_GEV.items()
}
# ── Calorimeter Calibration Constants ────────────────────────────────
# Energy scale factors for ECAL/HCAL (dimensionless, Q16_16)
CALIBRATION_CONSTANTS = {
'ecal_eb': 1.000, # ECAL barrel
'ecal_ee': 1.005, # ECAL endcap (slightly higher)
'hcal_hb': 1.000, # HCAL barrel
'hcal_he': 1.010, # HCAL endcap
'hcal_hf': 1.050, # HF (forward, larger corrections)
'muon_scale': 1.000, # Muon momentum scale
'electron_scale': 1.002, # Electron energy scale
'photon_scale': 1.003, # Photon energy scale
}
CALIBRATION_Q16 = {
name: _q16(cal) for name, cal in CALIBRATION_CONSTANTS.items()
}
# ── BRAM Layout for FPGA ─────────────────────────────────────────────
def generate_bram_layout() -> dict:
"""Generate BRAM layout for particle physics LUTs.
Returns dict mapping BRAM bank to (name, entries, data).
Each bank is 256 entries × 32-bit Q16_16.
"""
banks = {}
# Bank 0: Particle masses (up to 256 entries)
mass_entries = list(PARTICLE_MASSES_Q16.items())[:256]
bank0 = [0] * 256
for i, (name, q16val) in enumerate(mass_entries):
bank0[i] = q16val
banks[0] = ('particle_masses', len(mass_entries), bank0)
# Bank 1: Decay widths
width_entries = list(DECAY_WIDTHS_Q16.items())[:256]
bank1 = [0] * 256
for i, (name, q16val) in enumerate(width_entries):
bank1[i] = q16val
banks[1] = ('decay_widths', len(width_entries), bank1)
# Bank 2: Cross-sections
xs_entries = list(CROSS_SECTIONS_Q16.items())[:256]
bank2 = [0] * 256
for i, (name, q16val) in enumerate(xs_entries):
bank2[i] = q16val
banks[2] = ('cross_sections', len(xs_entries), bank2)
# Bank 3: Trigger thresholds + calibration
trigger_entries = list(TRIGGER_THRESHOLDS_Q16.items())
cal_entries = list(CALIBRATION_Q16.items())
bank3 = [0] * 256
for i, (name, q16val) in enumerate(trigger_entries):
bank3[i] = q16val
for i, (name, q16val) in enumerate(cal_entries):
bank3[len(trigger_entries) + i] = q16val
banks[3] = ('triggers_calibration', len(trigger_entries) + len(cal_entries), bank3)
return banks
def export_bram_init(bank_data: list[int], path: Path):
"""Export BRAM initialization data as Verilog initial block."""
with open(path, 'w') as f:
f.write('// Auto-generated particle physics LUT\n')
f.write('// Q16_16 fixed-point, 256 entries × 32-bit\n\n')
f.write('initial begin\n')
for i, val in enumerate(bank_data):
# Format as 32-bit hex
hexval = val & 0xFFFFFFFF
f.write(f' memory[8\'h{i:02X}] = 32\'h{hexval:08X};\n')
f.write('end\n')
def export_all_bram(output_dir: Path):
"""Export all BRAM initialization files."""
output_dir.mkdir(parents=True, exist_ok=True)
banks = generate_bram_layout()
for bank_id, (name, count, data) in banks.items():
path = output_dir / f'particle_lut_bank{bank_id}_{name}.v'
export_bram_init(data, path)
print(f'Bank {bank_id}: {name} ({count} entries) → {path}')
# ── Cross-Section Lookup (energy-dependent) ───────────────────────────
def interpolate_cross_section(energy_tev: float, process: str) -> int:
"""Interpolate cross-section at given energy. Returns Q16_16.
Uses log-log interpolation between known energy points.
For energies outside the table, uses power-law extrapolation.
"""
# Energy points and corresponding cross-sections (approximate)
energy_points = {
'ttbar': [(7, 165), (8, 230), (13, 830), (14, 930)],
'W': [(7, 12200), (8, 14700), (13, 20500), (14, 22000)],
'Z': [(7, 1200), (8, 1400), (13, 1870), (14, 2000)],
'H_gg': [(7, 15.1), (8, 19.3), (13, 48.6), (14, 54.7)],
}
if process not in energy_points:
return _q16(0)
points = energy_points[process]
if energy_tev <= points[0][0]:
return _q16(points[0][1])
if energy_tev >= points[-1][0]:
return _q16(points[-1][1])
# Log-log interpolation
import math
for i in range(len(points) - 1):
e0, xs0 = points[i]
e1, xs1 = points[i + 1]
if e0 <= energy_tev <= e1:
t = (math.log(energy_tev) - math.log(e0)) / (math.log(e1) - math.log(e0))
xs = xs0 * (xs1 / xs0) ** t
return _q16(xs)
return _q16(0)
# ── CLI ───────────────────────────────────────────────────────────────
if __name__ == '__main__':
print('=== Particle Physics LUT (Q16_16) ===\n')
print('Particle Masses (MeV):')
for name, mass in PARTICLE_MASSES_MEV.items():
q16 = PARTICLE_MASSES_Q16[name]
print(f' {name:<20} {mass:>12.3f} MeV → Q16: {q16:>12d} ({_q16_to_float(q16):.3f} MeV)')
print(f'\nDecay Widths (GeV):')
for name, width in DECAY_WIDTHS_GEV.items():
q16 = DECAY_WIDTHS_Q16[name]
print(f' {name:<20} {width:>12.6f} GeV → Q16: {q16:>12d}')
print(f'\nCross-Sections at 13 TeV (pb):')
for name, xs in CROSS_SECTIONS_PB.items():
q16 = CROSS_SECTIONS_Q16[name]
print(f' {name:<20} {xs:>12.1f} pb → Q16: {q16:>12d}')
print(f'\nTrigger Thresholds (GeV):')
for name, thresh in TRIGGER_THRESHOLDS_GEV.items():
q16 = TRIGGER_THRESHOLDS_Q16[name]
print(f' {name:<20} {thresh:>12.1f} GeV → Q16: {q16:>12d}')
print(f'\nBRAM Layout:')
banks = generate_bram_layout()
for bank_id, (name, count, data) in banks.items():
nonzero = sum(1 for v in data if v != 0)
print(f' Bank {bank_id}: {name} ({count} entries, {nonzero} non-zero)')
# Export
out_dir = Path('/tmp/particle_lut_bram')
export_all_bram(out_dir)
print(f'\nExported to {out_dir}/')