Research-Stack/4-Infrastructure/shim/particle_physics_lut.py
Brandon Schneider 73b2c3ba32 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.
2026-05-28 19:26:27 -05:00

323 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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}/')