r"""Toy branching cascades (particle showers, parton showers) and a
schematic detector geometry for displaying their final states.
Uses the same natural-unit convention (:math:`\hbar=c=1`) as
:mod:`physicskit.particle.kinematics`.
- :class:`ShowerParticle` -- a node of a branching-cascade tree.
- :func:`simple_shower` -- a generic toy branching cascade (particle
collision -> shower).
- :func:`parton_shower` -- a DGLAP-flavored toy branching cascade with
quark/gluon flavor bookkeeping.
- :func:`flatten_shower`, :func:`shower_leaves` -- tree-traversal helpers.
- :func:`cluster_into_jets` -- group final-state momenta into jets by angle.
- :func:`charged_track_points` -- a charged (or neutral) particle's 2D
trajectory in a uniform axial magnetic field, for a schematic detector
event display.
**Shared branching model** (:func:`simple_shower` and
:func:`parton_shower`): each particle carries a "virtuality" mass (its
:class:`~physicskit.particle.kinematics.FourVector` invariant mass,
standing in for how far off its true mass shell it is); a branching is
computed as an *exact* two-body decay (reusing
:func:`~physicskit.particle.decays.two_body_decay`) in the parent's own
rest frame with an isotropically sampled angle, then boosted back to the
lab frame with :func:`~physicskit.particle.kinematics.boost_generic` --
so energy-momentum is conserved at every vertex to machine precision,
for free, by reusing already-tested kinematics building blocks. Daughter
virtualities shrink by a fixed fraction each generation, and branching
stops once either the energy or the virtuality falls below a cutoff
(the shower's "hadronization scale"). This is a deliberately simplified
stand-in for a real fragmentation function / DGLAP splitting kernel
(which would sample an energy-sharing fraction ``z`` and an evolution
variable from Sudakov form factors) -- but the isotropic rest-frame
angle, combined with the boost back to the lab frame, still produces a
genuinely collimated, energy-ordered cascade (small opening angles for
high-energy branches), a real physical consequence of the boost rather
than something imposed by hand.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
from physicskit.particle.decays import two_body_decay
from physicskit.particle.kinematics import FourVector, boost_generic
__all__ = [
"ShowerParticle",
"simple_shower",
"parton_shower",
"flatten_shower",
"shower_leaves",
"cluster_into_jets",
"charged_track_points",
]
[docs]
@dataclass
class ShowerParticle:
"""One node of a branching-cascade tree.
Attributes
----------
four_vector : FourVector
This particle's lab-frame four-momentum.
generation : int
Branching depth (0 for the initial/primary particle).
flavor : str, default=""
Parton flavor label (``"q"``, ``"qbar"``, ``"g"``); unused (left
empty) by :func:`simple_shower`.
parent : ShowerParticle, optional
The particle this one was produced from (``None`` for the root).
children : list of ShowerParticle
The two daughters, if this particle branched further (empty for
a final-state / leaf particle).
"""
four_vector: FourVector
generation: int
flavor: str = ""
parent: Optional[ShowerParticle] = None
children: list = field(default_factory=list)
@property
def is_leaf(self):
"""Whether this particle is final-state (did not branch further)."""
return len(self.children) == 0
def _split_isotropic(parent_four_vector, m1, m2, rng):
"""Exact two-body split of ``parent_four_vector`` (in its own rest frame,
isotropic angle), boosted back to the lab frame."""
cos_theta = rng.uniform(-1.0, 1.0)
phi = rng.uniform(0.0, 2.0 * np.pi)
p1_rest, p2_rest = two_body_decay(parent_four_vector.mass, m1, m2, cos_theta, phi)
beta_vec = parent_four_vector.p_vec / parent_four_vector.E
return boost_generic(p1_rest, beta_vec), boost_generic(p2_rest, beta_vec)
def _build_shower(E0, m0_fraction, daughter_fraction, E_threshold, max_generations, rng, flavor_rule=None, flavor0=""):
m0 = m0_fraction * E0
p0 = FourVector(E0, 0.0, 0.0, float(np.sqrt(max(E0**2 - m0**2, 0.0))))
root = ShowerParticle(p0, generation=0, flavor=flavor0)
def branch(node):
if node.generation >= max_generations or node.four_vector.E < E_threshold or node.four_vector.mass < 1e-9:
return
m_parent = node.four_vector.mass
m1 = m2 = daughter_fraction * m_parent
if m1 + m2 >= m_parent:
return
p1, p2 = _split_isotropic(node.four_vector, m1, m2, rng)
f1, f2 = flavor_rule(node.flavor, rng) if flavor_rule is not None else ("", "")
c1 = ShowerParticle(p1, node.generation + 1, flavor=f1, parent=node)
c2 = ShowerParticle(p2, node.generation + 1, flavor=f2, parent=node)
node.children = [c1, c2]
branch(c1)
branch(c2)
branch(root)
return root
[docs]
def simple_shower(E0, m0_fraction=0.3, daughter_fraction=0.42, E_threshold=1.0, max_generations=8, rng=None):
r"""A toy branching cascade: a single high-energy particle splitting
repeatedly until its descendants fall below an energy threshold.
Each split is an exact 1-to-2 branching (see the module docstring
for the shared construction); the initial particle is given a toy
"virtuality" :math:`m_0=` ``m0_fraction`` :math:`{}\times E_0`
representing how far off-shell it is when it enters the cascade,
and each generation's daughters inherit a virtuality shrunk by
``daughter_fraction`` (< 0.5, so it always halves-or-better,
guaranteeing the branching stays kinematically allowed).
Parameters
----------
E0 : float
Energy of the initial incident particle (along the beam/z axis).
m0_fraction : float, default=0.3
Initial virtuality as a fraction of ``E0``.
daughter_fraction : float, default=0.42
Fraction of the parent's virtuality inherited by each daughter.
E_threshold : float, default=1.0
Energy below which a particle is treated as final-state (stops branching).
max_generations : int, default=8
Hard cap on branching depth (safety net against numerical edge cases).
rng : numpy.random.Generator, optional
Random number generator; a fresh default one is used if omitted.
Returns
-------
ShowerParticle
The root of the cascade tree (the initial particle; its
descendants are reached via ``.children``).
Examples
--------
>>> import numpy as np
>>> root = simple_shower(100.0, E_threshold=5.0, rng=np.random.default_rng(0))
>>> leaves = shower_leaves(root)
>>> from physicskit.particle.kinematics import invariant_mass
>>> bool(abs(invariant_mass([leaf.four_vector for leaf in leaves]) - root.four_vector.mass) < 1e-6)
True
"""
rng = np.random.default_rng() if rng is None else rng
return _build_shower(E0, m0_fraction, daughter_fraction, E_threshold, max_generations, rng)
[docs]
def parton_shower(E0, flavor0="q", m0_fraction=0.3, daughter_fraction=0.42, E_threshold=1.0, gluon_splitting_prob=0.2, max_generations=10, rng=None):
r"""A toy DGLAP-flavored parton shower: quarks radiate gluons, gluons
split into gluon pairs or quark-antiquark pairs.
Uses the same exact-two-body-split-and-boost construction as
:func:`simple_shower` (see the module docstring), with a simplified
flavor rule standing in for the DGLAP splitting functions
:math:`P_{qq},P_{gg},P_{qg}`:
- a quark or antiquark always radiates a gluon (``q -> q g``,
``qbar -> qbar g``), the only flavor-conserving 1-to-2 splitting
available to it;
- a gluon splits into two gluons with probability
``1-gluon_splitting_prob`` (``g -> g g``) or into a quark-antiquark
pair with probability ``gluon_splitting_prob`` (``g -> q qbar``).
Parameters
----------
E0 : float
Energy of the initial parton.
flavor0 : str, default="q"
Flavor of the initial parton (``"q"``, ``"qbar"``, or ``"g"``).
m0_fraction, daughter_fraction, E_threshold, max_generations, rng
See :func:`simple_shower`.
gluon_splitting_prob : float, default=0.2
Probability that a gluon branching produces a quark-antiquark
pair rather than two gluons.
Returns
-------
ShowerParticle
The root of the shower tree.
Examples
--------
>>> import numpy as np
>>> root = parton_shower(100.0, E_threshold=5.0, rng=np.random.default_rng(0))
>>> leaves = shower_leaves(root)
>>> from physicskit.particle.kinematics import invariant_mass
>>> bool(abs(invariant_mass([leaf.four_vector for leaf in leaves]) - root.four_vector.mass) < 1e-6)
True
"""
rng = np.random.default_rng() if rng is None else rng
def flavor_rule(flavor, rng):
if flavor == "g":
if rng.uniform() < gluon_splitting_prob:
return "q", "qbar"
return "g", "g"
return flavor, "g"
return _build_shower(E0, m0_fraction, daughter_fraction, E_threshold, max_generations, rng, flavor_rule=flavor_rule, flavor0=flavor0)
[docs]
def flatten_shower(root):
"""All nodes of a shower tree (root included), in no particular order.
Parameters
----------
root : ShowerParticle
Returns
-------
list of ShowerParticle
"""
out = []
stack = [root]
while stack:
node = stack.pop()
out.append(node)
stack.extend(node.children)
return out
[docs]
def shower_leaves(root):
"""The final-state (non-branching) particles of a shower tree.
Parameters
----------
root : ShowerParticle
Returns
-------
list of ShowerParticle
"""
return [node for node in flatten_shower(root) if node.is_leaf]
[docs]
def cluster_into_jets(leaves, n_jets=2, n_iter=25, seed=0):
"""Group final-state shower particles into jets by momentum direction.
A minimal, dependency-free k-means clustering on the unit momentum
directions (i.e. by angular proximity) -- a schematic stand-in for a
real sequential jet algorithm (:math:`k_T`, anti-:math:`k_T`, ...),
adequate for visually grouping a toy shower's final state into a
couple of back-to-back jet cones.
Parameters
----------
leaves : sequence of ShowerParticle
Final-state particles, e.g. from :func:`shower_leaves`.
n_jets : int, default=2
Number of jets to form (capped at ``len(leaves)``).
n_iter : int, default=25
Number of k-means (Lloyd) iterations.
seed : int, default=0
Seed for the deterministic random initialization.
Returns
-------
list of list of ShowerParticle
One list of member particles per jet.
Examples
--------
>>> import numpy as np
>>> root = parton_shower(100.0, E_threshold=5.0, rng=np.random.default_rng(0))
>>> jets = cluster_into_jets(shower_leaves(root), n_jets=2)
>>> len(jets) == 2
True
"""
directions = np.array([[leaf.four_vector.px, leaf.four_vector.py, leaf.four_vector.pz] for leaf in leaves])
norm = np.linalg.norm(directions, axis=1, keepdims=True)
norm[norm == 0.0] = 1.0
unit = directions / norm
n_jets = min(n_jets, len(leaves))
rng = np.random.default_rng(seed)
centers = unit[rng.choice(len(unit), size=n_jets, replace=False)].copy()
labels = np.zeros(len(unit), dtype=int)
for _ in range(n_iter):
labels = np.argmax(unit @ centers.T, axis=1)
for k in range(n_jets):
members = unit[labels == k]
if len(members) > 0:
c = members.mean(axis=0)
c_norm = np.linalg.norm(c)
if c_norm > 0:
centers[k] = c / c_norm
return [[leaves[i] for i in range(len(leaves)) if labels[i] == k] for k in range(n_jets)]
[docs]
def charged_track_points(four_vector, charge, B, vertex=(0.0, 0.0), n_points=100, path_length=1.0):
r"""2D transverse-plane trajectory of a particle in a uniform axial magnetic field.
A charged particle with transverse momentum :math:`p_T` follows a
circular arc of radius
.. math::
r = \frac{p_T}{qB}
the standard relation for the curvature of a charged track in a
uniform axial field (see e.g. the Particle Data Group's "Passage of
particles through matter" review); here in schematic natural units
where :math:`q` is in units of the elementary charge and :math:`B`
is scaled so that :math:`r` comes out directly in the detector's
length unit (the familiar practical-unit form is :math:`r[{\rm m}]
=p_T[{\rm GeV}/c]/(0.3\,B[{\rm T}]\,q[e])`). A neutral particle
(``charge=0``) instead follows a straight line.
Parameters
----------
four_vector : FourVector
The particle's four-momentum.
charge : float
Charge, in units of the elementary charge (0 for neutral).
B : float
Magnetic field along :math:`+z`, out of the transverse plane.
The Lorentz force :math:`q\,\mathbf{v}\times\mathbf{B}` then bends
positive charges clockwise (for ``B > 0``) and negative charges
counterclockwise, as seen looking down the :math:`z` axis.
vertex : array_like of shape (2,), default=(0, 0)
Starting point of the track.
n_points : int, default=100
Number of points to sample along the trajectory.
path_length : float, default=1.0
For a neutral track, the length of the straight segment drawn;
for a charged track, the arc length traced out (capped
implicitly by how many points are requested along it).
Returns
-------
ndarray of shape (n_points, 2)
Points along the trajectory, starting at ``vertex`` and moving
off in ``four_vector``'s initial transverse direction.
Examples
--------
>>> from physicskit.particle.kinematics import FourVector
>>> p = FourVector(10.0, 3.0, 0.0, 5.0)
>>> pts = charged_track_points(p, charge=0.0, B=1.0, path_length=2.0)
>>> np.allclose(pts[0], [0.0, 0.0]) and np.allclose(pts[-1], [2.0, 0.0])
True
"""
vertex = np.asarray(vertex, dtype=float)
px, py = four_vector.px, four_vector.py
pT = float(np.hypot(px, py))
if charge == 0.0 or pT == 0.0:
direction = np.array([px, py]) / pT if pT > 0.0 else np.array([1.0, 0.0])
s = np.linspace(0.0, path_length, n_points)
return vertex + np.outer(s, direction)
r = pT / (abs(charge) * B)
phi0 = np.arctan2(py, px)
curve_sign = -float(np.sign(charge * B)) # +1: counterclockwise; q v x B turns q*B > 0 clockwise
center = vertex + r * np.array([-np.sin(phi0), np.cos(phi0)]) * curve_sign
max_angle = min(path_length / r, 2.0 * np.pi)
thetas = np.linspace(0.0, max_angle, n_points)
v0 = vertex - center
cs, sn = np.cos(curve_sign * thetas), np.sin(curve_sign * thetas)
xs = center[0] + v0[0] * cs - v0[1] * sn
ys = center[1] + v0[0] * sn + v0[1] * cs
return np.column_stack([xs, ys])