Halo analysis¶
The "halo" is the long-tail population of particles outside the Gaussian core. Halo dominates aperture losses in real linacs — even though halo carries < 1 % of the beam current, it dominates activation budgets by 1000×. HELIX provides several halo diagnostics.
Kurtosis halo parameter¶
The simplest halo measure is the dimensionless kurtosis-based halo
parameter h (computed in linac_gen/diagnostics/moments.py):
Recorded as halo_x and halo_y per s-step. With this definition a
Gaussian profile gives h = 2 (its ⟨x⁴⟩/⟨x²⟩² kurtosis is 3), and
flatter, tail-free distributions land below the Gaussian value:
| h | Meaning |
|---|---|
| ≈ 2 | pure Gaussian (matched) |
| < 2 | sub-Gaussian / compact (uniform ≈ 0.8, KV ⇒ 1.0) |
| noticeably > 2 | halo / super-Gaussian tails |
| ≫ 2 (e.g. > 3) | strong halo |
A growing h trace means halo is being produced (mismatch, RF nonlinearity, SC filamentation).
From halo to losses¶
Beyond h, the question is usually "where do the halo particles
actually hit the pipe?". HELIX answers that with per-particle loss
bookkeeping: every particle that exceeds the local aperture is logged
with its (s, x, y, energy, element) in Beam.loss_table, and the
recorder's transmission array shows the cumulative survival vs s.
See Aperture and losses for the API and plots.
Maximum-excursion (envelope of all surviving particles)¶
x_max[i] and y_max[i] record the most-extreme position of any
surviving particle at every s. Plotting x_max(s) against the
local aperture shows whether you have margin against worst-case
particle losses:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from linac_gen.core.config import BeamConfig
from linac_gen.core.lattice import Lattice
from linac_gen.core.simulation import Simulation
from linac_gen.distributions.factory import create_beam
from linac_gen.elements.drift import Drift
from linac_gen.elements.quadrupole import Quadrupole
# A small run with a halo-carrying thermal beam:
lattice = Lattice()
for c in range(2):
lattice.add(Quadrupole(name=f"QF_{c}", length=100.0, gradient=+10.0))
lattice.add(Drift(name=f"D1_{c}", length=200.0))
lattice.add(Quadrupole(name=f"QD_{c}", length=100.0, gradient=-10.0))
lattice.add(Drift(name=f"D2_{c}", length=200.0))
beam = create_beam(BeamConfig(n_particles=2000, distribution="thermal"),
seed=5)
results = Simulation(lattice, beam).run()
fig, ax = plt.subplots()
ax.plot(results.s, results.x_max, label="max x [mm]")
# overlay aperture
ax.set_xlabel("s [mm]"); ax.set_ylabel("excursion [mm]")
Visualising halo content¶
For PIP-II-realistic distributions with a halo population, the
thermal distribution
is the right starting point. After tracking, plot the (x, x')
phase space at end of lattice:
xs = beam.particles[beam.alive_mask, 0]
xps = beam.particles[beam.alive_mask, 1]
plt.scatter(xs, xps, s=0.5, alpha=0.3)
plt.xlabel("x [mm]"); plt.ylabel("x' [mrad]")
plt.close("all")
The core appears as a dense ellipse; halo as scattered points extending well beyond.
Cross-references¶
- Aperture profile — converting halo into loss numbers per element.
- Distributions → thermal.
- Recorder fields.