\(\Lambda_b^0 \to p K^- \gamma\)
This page demonstrates deserialization and evaluation of an amplitude model for the decay \(\Lambda_b^0 \to p K^- \gamma\) . The resonant structure was studied using proton-proton collision data recorded at centre-of-mass energies of \(7\) , \(8\) , and \(13\) TeV with the LHCb detector, INSPIRE-HEP 2765817 .
Import Python libraries
from copy import deepcopy
from pathlib import Path
import json
import logging
import jax.numpy as jnp
import matplotlib.pyplot as plt
import pandas as pd
import sympy as sp
from ampform.dynamics.form_factor import BreakupMomentumSquared
from ampform_dpd import DefinedExpression
from ampform_dpd.decay import FinalStateID, State, ThreeBodyDecay
from ampform_dpd.io import aslatex, cached, perform_cached_lambdify
from ampform_dpd.io.serialization import amplitude as serialization_amplitude
from ampform_dpd.io.serialization.amplitude import formulate
from ampform_dpd.io.serialization.decay import get_final_state, to_decay
from ampform_dpd.io.serialization.dynamics import (
formulate_dynamics,
to_mandelstam_symbol,
to_mass_symbol,
)
from ampform_dpd.io.serialization.format import (
get_decay_chains,
get_function_definition,
)
from IPython.display import Math
from matplotlib_inline.backend_inline import set_matplotlib_formats
from mpl_toolkits.axes_grid1 import make_axes_locatable # cspell:ignore mpl_toolkits
THIS_DIR = Path("." ).absolute()
logging.getLogger("ampform.sympy" ).setLevel(logging.ERROR)
set_matplotlib_formats("svg" )
with open (THIS_DIR.parent.parent / "models" / "lb2pkg-lhcb-2765817.json" ) as f:
MODEL_DEFINITION = json.load(f)
Name-to-LaTeX converter
def to_latex(name: str ) -> str :
latex = {
"Lb" : R"\Lambda_b^0" ,
"g" : R"\gamma" ,
"K" : "K^-" ,
"p" : "p" ,
}.get(name)
if latex is not None :
return latex
if name.startswith("LNR" ):
return R"\Lambda_\mathrm {NR} "
if name.startswith("L" ):
return Rf"\Lambda( { name[1 :]} )"
return name
DECAY = to_decay(MODEL_DEFINITION, to_latex= to_latex)
Math(aslatex(DECAY, with_jp= True ))
\(\displaystyle \begin{array}{c}
\Lambda_b^0\left[J=\frac{1}{2}\right] \to \left(\Lambda(1405)\left[J=\frac{1}{2}\right] \to p\left[J=\frac{1}{2}\right] K^-\left[J=0\right]\right) \gamma\left[J=1\right] \\
\Lambda_b^0\left[J=\frac{1}{2}\right] \to \left(\Lambda(1520)\left[J=\frac{3}{2}\right] \to p\left[J=\frac{1}{2}\right] K^-\left[J=0\right]\right) \gamma\left[J=1\right] \\
\Lambda_b^0\left[J=\frac{1}{2}\right] \to \left(\Lambda(1600)\left[J=\frac{1}{2}\right] \to p\left[J=\frac{1}{2}\right] K^-\left[J=0\right]\right) \gamma\left[J=1\right] \\
\Lambda_b^0\left[J=\frac{1}{2}\right] \to \left(\Lambda(1670)\left[J=\frac{1}{2}\right] \to p\left[J=\frac{1}{2}\right] K^-\left[J=0\right]\right) \gamma\left[J=1\right] \\
\Lambda_b^0\left[J=\frac{1}{2}\right] \to \left(\Lambda(1690)\left[J=\frac{3}{2}\right] \to p\left[J=\frac{1}{2}\right] K^-\left[J=0\right]\right) \gamma\left[J=1\right] \\
\Lambda_b^0\left[J=\frac{1}{2}\right] \to \left(\Lambda(1800)\left[J=\frac{1}{2}\right] \to p\left[J=\frac{1}{2}\right] K^-\left[J=0\right]\right) \gamma\left[J=1\right] \\
\Lambda_b^0\left[J=\frac{1}{2}\right] \to \left(\Lambda(1810)\left[J=\frac{1}{2}\right] \to p\left[J=\frac{1}{2}\right] K^-\left[J=0\right]\right) \gamma\left[J=1\right] \\
\Lambda_b^0\left[J=\frac{1}{2}\right] \to \left(\Lambda(1820)\left[J=\frac{5}{2}\right] \to p\left[J=\frac{1}{2}\right] K^-\left[J=0\right]\right) \gamma\left[J=1\right] \\
\Lambda_b^0\left[J=\frac{1}{2}\right] \to \left(\Lambda(1830)\left[J=\frac{5}{2}\right] \to p\left[J=\frac{1}{2}\right] K^-\left[J=0\right]\right) \gamma\left[J=1\right] \\
\Lambda_b^0\left[J=\frac{1}{2}\right] \to \left(\Lambda(1890)\left[J=\frac{3}{2}\right] \to p\left[J=\frac{1}{2}\right] K^-\left[J=0\right]\right) \gamma\left[J=1\right] \\
\Lambda_b^0\left[J=\frac{1}{2}\right] \to \left(\Lambda(2100)\left[J=\frac{7}{2}\right] \to p\left[J=\frac{1}{2}\right] K^-\left[J=0\right]\right) \gamma\left[J=1\right] \\
\Lambda_b^0\left[J=\frac{1}{2}\right] \to \left(\Lambda(2110)\left[J=\frac{5}{2}\right] \to p\left[J=\frac{1}{2}\right] K^-\left[J=0\right]\right) \gamma\left[J=1\right] \\
\Lambda_b^0\left[J=\frac{1}{2}\right] \to \left(\Lambda(2350)\left[J=\frac{9}{2}\right] \to p\left[J=\frac{1}{2}\right] K^-\left[J=0\right]\right) \gamma\left[J=1\right] \\
\Lambda_b^0\left[J=\frac{1}{2}\right] \to \left(\Lambda_\mathrm{NR}\left[J=\frac{3}{2}\right] \to p\left[J=\frac{1}{2}\right] K^-\left[J=0\right]\right) \gamma\left[J=1\right] \\
\end{array}\)
Dynamics
The model contains Breit-Wigner and multichannel Breit-Wigner propagators, as well as a constant non-resonant term. The latter is serialized as a zeroth-order polynomial.
CHAIN_DEFS = get_decay_chains(MODEL_DEFINITION)
chains_by_propagator = {
propagator["parametrization" ]: chain
for chain in CHAIN_DEFS
for propagator in chain["propagators" ]
}
L1520_BW = formulate_dynamics(chains_by_propagator["L1520_BW" ], MODEL_DEFINITION)
Math(aslatex(L1520_BW))
\(\displaystyle \begin{array}{rcl}
\mathcal{R}^\mathrm{BW}_{L=2}\left(\sigma_{3}; m_{L1520}, \Gamma_{L1520}\right) &=& \mathcal{R}^\mathrm{BW}\left(\sigma_{3}; m_{L1520}, \Gamma_{1520}\left(\sigma_{3}\right)\right) \\
m_{L1520} &=& 1.519 \\
\Gamma_{L1520} &=& 0.016 \\
m_{1} &=& 0.938 \\
m_{2} &=& 0.493 \\
R_\mathrm{res} &=& 1.5 \\
\end{array}\)
L1405_Flatte = formulate_dynamics(
chains_by_propagator["L1405_Flatte" ],
MODEL_DEFINITION,
)
Math(aslatex(L1405_Flatte))
\(\displaystyle \begin{array}{rcl}
\mathcal{R}^\mathrm{BW}_\mathrm{multi}\left(\sigma_{3}; \Gamma_{L1405}, \Gamma_{L1405}^\text{ch. 2}\right) &=& \mathcal{R}^\mathrm{BW}_{L=0}\left(\sigma_{3}; m_{L1405}, \Gamma^\text{ch}\left(\sigma_{3}; m_{L1405}, \Gamma_{L1405}\right) + \Gamma^\text{ch}\left(\sigma_{3}; m_{L1405}, \Gamma_{L1405}^\text{ch. 2}\right)\right) \\
m_{L1405} &=& 1.405 \\
\Gamma_{L1405} &=& 0.24941478752959237 \\
m_{1} &=& 0.938 \\
m_{2} &=& 0.493 \\
R_{L1405} &=& 0 \\
m_{a,2} &=& 1.197 \\
m_{b,2} &=& 0.14 \\
\Gamma_{L1405}^\text{ch. 2} &=& 0.24941478752959237 \\
\end{array}\)
Construct amplitude model
The current Python preview API needs two small compatibility adapters for this older LS-coupled model. They preserve a unique symbol for every serialized LS coupling and implement the serialized MomentumPower and constant Polynomial functions.
Compatibility adapters for LS-coupled models
def prepare_ls_model(model_definition: dict ) -> dict :
model_definition = deepcopy(model_definition)
chains = get_decay_chains(model_definition)
for chain_idx, chain in enumerate (chains):
for vertex in chain["vertices" ]:
if "helicities" in vertex:
continue
has_isobar = any (isinstance (item, list ) for item in vertex["node" ])
vertex["helicities" ] = [str (chain_idx), "0" ] if has_isobar else ["0" , "0" ]
return model_definition
def get_ls_child_spins(model_definition, chain_idx, vertex_idx):
chain = get_decay_chains(model_definition)[chain_idx]
vertex = chain["vertices" ][vertex_idx]
final_state = get_final_state(model_definition)
resonance_spin = sp.Rational(chain["propagators" ][0 ]["spin" ])
return tuple (
final_state[item].spin if isinstance (item, int ) else resonance_spin
for item in vertex["node" ]
)
def formulate_polynomial(propagator, resonance, model):
definition = get_function_definition(propagator["parametrization" ], model)
variable = to_mandelstam_symbol(propagator["node" ])
expression = sum (
coefficient * variable** power
for power, coefficient in enumerate (definition["coefficients" ])
)
return DefinedExpression(expression= expression)
_original_formulate_form_factor = serialization_amplitude.formulate_form_factor
def formulate_form_factor(vertex, model):
function_name = vertex.get("formfactor" )
if not function_name:
return DefinedExpression()
definition = get_function_definition(function_name, model)
if definition["type" ] != "MomentumPower" :
return _original_formulate_form_factor(vertex, model)
node = vertex["node" ]
if all (isinstance (item, int ) for item in node):
s = to_mandelstam_symbol(node)
m1, m2 = (to_mass_symbol(item) for item in node)
else :
s = to_mandelstam_symbol(node) ** 2
m1, m2 = (to_mass_symbol(item) for item in node)
m1 = sp.sqrt(m1)
power = sp.Rational(definition["l" ], 2 )
return DefinedExpression(expression= BreakupMomentumSquared(s, m1, m2) ** power)
serialization_amplitude._get_child_spins = get_ls_child_spins
serialization_amplitude.formulate_form_factor = formulate_form_factor
LS_MODEL_DEFINITION = prepare_ls_model(MODEL_DEFINITION)
ADDITIONAL_BUILDERS = {"Polynomial" : formulate_polynomial}
model = formulate(
LS_MODEL_DEFINITION,
cleanup_summations= True ,
to_latex= to_latex,
additional_builders= ADDITIONAL_BUILDERS,
)
Validation
The table compares all serialized checkpoints with the Python implementation. The marks โ๐ขโ, โ๐กโ, and โ๐ดโ indicate an absolute difference of \(<10^{-10}\) , \(<10^{-2}\) , or \(\ge10^{-2}\) , respectively.
Validation helpers
checksum_points = {
point["name" ]: {parameter["name" ]: parameter["value" ] for parameter in point["parameters" ]}
for point in MODEL_DEFINITION["parameter_points" ]
}
def to_number(value: float | str ) -> float | complex :
if isinstance (value, str ):
value = complex (value.replace(" " , "" ).replace("i" , "j" ))
number = complex (value)
return number.real if number.imag == 0 else number
def round_number(value: float | complex , digits: int = 6 ) -> float | complex :
number = complex (value)
if number.imag == 0 :
return round (number.real, digits)
return complex (round (number.real, digits), round (number.imag, digits))
def label_diff(difference: complex ) -> str :
absolute_difference = abs (difference)
if absolute_difference < 1e-10 :
return "๐ข"
if absolute_difference < 1e-2 :
return "๐ก"
return "๐ด"
def create_intensity_function(
model,
subsystem: int ,
resonance_name: str | None = None ,
):
invariant = next (s for s in model.invariants if str (s) == f"sigma { subsystem} " )
intensity_expr = cached.xreplace(cached.unfold(model), model.variables)
parameter_defaults = select_resonance_parameters(model, resonance_name)
intensity_expr = cached.xreplace(intensity_expr, parameter_defaults)
invariant_expr = model.invariants[invariant].xreplace(model.masses).doit()
intensity_expr = cached.doit(intensity_expr.xreplace({invariant: invariant_expr}))
return cached.lambdify(intensity_expr, backend= "jax" )
def select_resonance_parameters(model, resonance_name: str | None ):
if resonance_name is None :
return model.parameter_defaults
return {
symbol: value
if not str (symbol).startswith("c^" ) or resonance_name in str (symbol)
else 0
for symbol, value in model.parameter_defaults.items()
}
def evaluate_intensity(model, point: dict [str , float ], pair: tuple [int , int ]) -> float :
i, j = pair
k, * _ = {1 , 2 , 3 } - set (pair)
invariants = {str (s): s for s in model.invariants}
sigma_j = invariants[f"sigma { j} " ]
sigma_k = invariants[f"sigma { k} " ]
angle_expr = next (
expression
for angle, expression in model.variables.items()
if str (angle) == f"theta_ { i}{ j} "
)
sigma_k_value = point[f"m_ { i}{ j} " ] ** 2
equation = sp.Eq(
sp.cos(angle_expr).xreplace(model.parameter_defaults),
point[f"cos_theta_ { i}{ j} " ],
)
sigma_j_value = sp.solve(equation.subs(sigma_k, sigma_k_value), sigma_j)[0 ]
function = create_intensity_function(model, subsystem= i)
value = function({str (sigma_j): float (sigma_j_value), str (sigma_k): sigma_k_value})
return float (jnp.real(value))
Compute every serialized checkpoint
validation_results = []
for checksum in MODEL_DEFINITION["misc" ]["amplitude_model_checksums" ]:
distribution_name = checksum["distribution" ]
point = checksum_points[checksum["point" ]]
reference_value = to_number(checksum["value" ])
chain = chains_by_propagator.get(distribution_name)
if chain is None :
computed_value = evaluate_intensity(model, point, pair= (1 , 2 ))
else :
dynamics = formulate_dynamics(
chain,
MODEL_DEFINITION,
additional_definitions= ADDITIONAL_BUILDERS,
)
expression = dynamics.expression.doit()
variables = expression.free_symbols - dynamics.parameters.keys()
if variables:
function = perform_cached_lambdify(
expression,
parameters= dynamics.parameters,
)
variable = next (iter (variables))
computed_value = function({str (variable): next (iter (point.values()))})
else :
computed_value = complex (expression.xreplace(dynamics.parameters))
difference = abs (reference_value - computed_value)
validation_results.append({
"Distribution" : distribution_name,
"Point" : checksum["point" ],
"Reference" : round_number(reference_value),
"Computed" : round_number(computed_value),
"Difference" : difference,
"Status" : label_diff(reference_value - computed_value),
})
pd.DataFrame(validation_results, dtype= object )
0
default_model
validation_point1
645.687778
644.103426
1.584352
๐ด
1
default_model
validation_point2
33.313132
33.314165
0.001032
๐ก
2
default_model
validation_point3
38.286729
38.287889
0.00116
๐ก
3
L1405_Flatte
validation_point_m12sq
(-0.740198+0.236368j)
(-0.775611+0.176283j)
0.06974548265443277
๐ด
4
L1890_BW
validation_point_m12sq
(2.198693+1.036641j)
(2.198693+1.036641j)
0.0
๐ข
5
L1800_BW
validation_point_m12sq
(0.311734+2.7742j)
(0.311734+2.7742j)
4.577566798522237e-16
๐ข
6
L1520_BW
validation_point_m12sq
(-0.643171+0.553948j)
(-0.643171+0.553948j)
2.482534153247273e-16
๐ข
7
L2110_BW
validation_point_m12sq
(0.854068+0.041564j)
(0.854068+0.041564j)
1.2738115944464653e-16
๐ข
8
L1680_BW
validation_point_m12sq
(-2.46133+0.361098j)
(-2.46133+0.361098j)
1.1102230246251565e-16
๐ข
9
L2350_BW
validation_point_m12sq
(0.43057+0.000197j)
(0.43057+0.000197j)
1.0028870095490916e-18
๐ข
10
L1600_BW
validation_point_m12sq
(-0.629331+0.766337j)
(-0.629331+0.766337j)
2.220446049250313e-16
๐ข
11
L1810_BW
validation_point_m12sq
(0.106436+5.093986j)
(0.106436+5.093986j)
8.899114524108741e-16
๐ข
12
L1820_BW
validation_point_m12sq
(4.647408+4.443945j)
(4.647408+4.443945j)
4.440892098500626e-15
๐ข
13
L1830_BW
validation_point_m12sq
(3.824528+3.82775j)
(3.824528+3.82775j)
1.7763568394002505e-15
๐ข
14
L2100_BW
validation_point_m12sq
(0.826225+0.013532j)
(0.826225+0.013532j)
1.5612511283791264e-17
๐ข
15
L1690_BW
validation_point_m12sq
(-1.859729+1.396126j)
(-1.859729+1.396126j)
1.4043333874306805e-15
๐ข
16
LNR30_NR
validation_point_m12sq
1.0
1.0
0.0
๐ข
Visualization
Dalitz plot
The Dalitz plot shows the intensity up to \(m_{pK^-}=2.5\ \mathrm{GeV}\) . The plotting range includes a margin below the physical threshold so that the left phase-space boundary remains visible, as in the Julia implementation.
Configure the Dalitz plot
x_subsystem, y_subsystem = 3 , 2
eliminated_subsystem, * _ = {1 , 2 , 3 } - {x_subsystem, y_subsystem}
resolution = 250
x_min, x_max = 1.9 , 2.5 ** 2
y_min, y_max = 5 , 27
X, Y = jnp.meshgrid(
jnp.linspace(x_min, x_max, resolution),
jnp.linspace(y_min, y_max, resolution),
)
intensity_function = create_intensity_function(model, subsystem= eliminated_subsystem)
intensities = jnp.real(intensity_function({
f"sigma { x_subsystem} " : X,
f"sigma { y_subsystem} " : Y,
}))
normalized_intensities = intensities / jnp.nansum(intensities)
def get_decay_products(
decay: ThreeBodyDecay,
subsystem: FinalStateID,
) -> tuple [State, State]:
return tuple (state for state in decay.final_state.values() if state.index != subsystem)
sigma_labels = {
subsystem: Rf"$\sigma_ { subsystem} = M^2\left( { ' ' . join(p.latex for p in get_decay_products(DECAY, subsystem))} \right)$"
for subsystem in (1 , 2 , 3 )
}
# cspell:ignore bbox edgecolor facecolor fontset fontsize labelcolor mathtext ncol
# cspell:ignore savefig sharey startswith STIX wspace xtick ytick
plt.rcParams.update({
"axes.edgecolor" : "#808080" ,
"axes.facecolor" : "none" ,
"axes.labelcolor" : "#808080" ,
"figure.facecolor" : "none" ,
"font.family" : "serif" ,
"font.serif" : ["STIXGeneral" , "DejaVu Serif" ],
"font.size" : 18 ,
"mathtext.fontset" : "stix" ,
"savefig.transparent" : True ,
"text.color" : "#808080" ,
"xtick.color" : "#808080" ,
"ytick.color" : "#808080" ,
})
Render the Dalitz plot
fig, ax = plt.subplots(figsize= (10 , 8 ))
mesh = ax.pcolormesh(X, Y, normalized_intensities, rasterized= True )
ax.contour(
X,
Y,
jnp.isfinite(intensities),
levels= [0.5 ],
colors= "black" ,
linewidths= 3 ,
)
ax.set_xlabel(sigma_labels[x_subsystem])
ax.set_ylabel(sigma_labels[y_subsystem])
divider = make_axes_locatable(ax)
colorbar_axes = divider.append_axes("right" , size= "5%" , pad= 0.1 )
colorbar = fig.colorbar(mesh, cax= colorbar_axes)
colorbar.ax.set_ylabel("Normalized intensity (a.u.)" )
plt.show()
Mass projection
The projection onto the \(pK^-\) mass is shown as a black line. The colored curves show the incoherent contribution from each \(\Lambda\) resonance.
Render the proton-kaon mass projection
resonance_names = sorted ({chain["name" ] for chain in CHAIN_DEFS})
mass_values = jnp.sqrt(X[0 ])
minimum_mass = float (
(
sp.Symbol("m1" , nonnegative= True ) + sp.Symbol("m2" , nonnegative= True )
).xreplace(model.masses)
)
physical_region = mass_values >= minimum_mass
mass_values = mass_values[physical_region]
def project_to_pk(intensity_values):
projection = jnp.nansum(intensity_values, axis= 0 )
return mass_values * projection[physical_region]
total_projection = project_to_pk(intensities)
normalization = jnp.trapezoid(total_projection, mass_values)
fig, ax = plt.subplots(figsize= (12 , 7 ))
ax.plot(
mass_values,
total_projection / normalization,
color= "#202020" ,
lw= 3 ,
label= "Total" ,
)
for resonance_name in resonance_names:
component_function = create_intensity_function(
model,
subsystem= eliminated_subsystem,
resonance_name= to_latex(resonance_name),
)
component = jnp.real(component_function({"sigma2" : Y, "sigma3" : X}))
component = jnp.where(jnp.isnan(intensities), jnp.nan, component)
label = Rf"$ { to_latex(resonance_name)} $"
ax.plot(
mass_values,
project_to_pk(component) / normalization,
label= label,
)
ax.set_xlabel(R"$m_{pK^-}$ [GeV]" )
ax.set_ylabel("Normalized intensity (a.u.)" )
ax.set_xlim(minimum_mass, 2.5 )
ax.set_ylim(bottom= 0 )
ax.legend(fontsize= "small" , loc= "upper right" , ncol= 3 )
plt.show()