Polarimeter vector field#
Import Python libraries
from __future__ import annotations
import itertools
import logging
import os
from typing import TYPE_CHECKING
import jax.numpy as jnp
import matplotlib.pyplot as plt
import numpy as np
import qrules
import sympy as sp
from ampform.sympy import (
PoolSum,
UnevaluatedExpression,
create_expression,
implement_doit_method,
make_commutative,
perform_cached_doit,
)
from attrs import frozen
from IPython.display import HTML, Image, Math, display
from ipywidgets import Button, Combobox, HBox, HTMLMath, Tab, VBox, interactive_output
from matplotlib.colors import LogNorm
from symplot import create_slider
from sympy.core.symbol import Str
from sympy.physics.matrices import msigma
from sympy.physics.quantum.spin import Rotation as Wigner
from tensorwaves.data.transform import SympyDataTransformer
from tensorwaves.function.sympy import create_function, create_parametrized_function
if TYPE_CHECKING:
from qrules.particle import Particle
from tensorwaves.interface import DataSample, ParametrizedFunction
LOGGER = logging.getLogger()
LOGGER.setLevel(logging.ERROR)
STATIC_WEB_PAGE = {"EXECUTE_NB", "READTHEDOCS"}.intersection(os.environ)
PDG = qrules.load_pdg()
def display_definitions(definitions: dict[sp.Symbol, sp.Expr]) -> None:
latex = R"\begin{array}{rcl}" + "\n"
for symbol, expr in definitions.items():
symbol = sp.sympify(symbol)
expr = sp.sympify(expr)
lhs = sp.latex(symbol)
rhs = sp.latex(expr)
latex += Rf" {lhs} & = & {rhs} \\" + "\n"
latex += R"\end{array}"
display(Math(latex))
def display_doit(
expr: UnevaluatedExpression, deep=False, terms_per_line: int = 10
) -> None:
latex = sp.multiline_latex(
lhs=expr,
rhs=expr.doit(deep=deep),
terms_per_line=terms_per_line,
environment="eqnarray",
)
display(Math(latex))
# hack for moving Indexed indices below superscript of the base
def _print_Indexed_latex(self, printer, *args):
base = printer._print(self.base)
indices = ", ".join(map(printer._print, self.indices))
return f"{base}_{{{indices}}}"
sp.Indexed._latex = _print_Indexed_latex
Amplitude model#
The helicity amplitude for the \(\Lambda_c \to p K \pi\) decay reads as a sum of three partial wave series, incorporating \(\Lambda^{**}\) resonances, \(\Delta^{**}\) resonances, and \(K^{**}\) resonances. The particles are ordered as \(\Lambda_c(\mathbf{0}) \to p(\mathbf{1}) \pi(\mathbf{2}) K(\mathbf{3})\).
where \(\zeta^{i}_{j(k)}\) is the Wigner rotation for particle \(i,k\) and chain \(j\). The number in brackets indicates the overall definition of the helicity states \(|1/2,\nu\rangle\) and \(|1/2,\lambda\rangle\) for \(\Lambda_c\) and proton, respectively.
We use the particle-2 convention for the helicity couplings, which leads to the phase factor in the transitions \(\Lambda_c\to K^{**}p\), and \(\Lambda\to K p\):
The helicity couplings in the particle-2 convention obey simple properties with respect to the parity transformation:
It reduced amount of the couplings in the strong decay of isobars. Moreover the magnitude of the couplings cannot be determined separately, therefore, it is set to 1:
The helicity couplings for the \(\Lambda_c^+\) decay are fit parameters. There are four of them for the \(K^{**}\) chain, and two for both the \(\Delta^{**}\) and \(\Lambda^{**}\) chains.
Resonances and LS-scheme#
Define decay product and sub-system IDs
Λc = PDG["Lambda(c)+"]
p = PDG["p"]
K = PDG["K-"]
π = PDG["pi+"]
decay_products = {
1: (π, K),
2: (p, K),
3: (p, π),
}
siblings = {
1: p,
2: π,
3: K,
}
chain_ids = {
1: "K",
2: "L",
3: "D",
}
chain_labels = {
1: "K^{**}",
2: R"\Lambda^{**}",
3: R"\Delta^{**}",
}
Resonance choices and their \(LS\)-couplings are defined as follows:
resonance_names = {
1: ["K*(892)0"],
2: ["Lambda(1520)", "Lambda(1670)"],
3: ["Delta(1232)++"],
}
Compute LS-couplings
@frozen
class Resonance:
particle: Particle
l_R: int
l_Λc: int
@staticmethod
def generate_ls(particle: Particle, chain_id: int) -> Resonance:
LS_prod = generate_ls(Λc, particle, siblings[chain_id], strong=False)
LS_prod = [L for L, S in LS_prod]
LS_dec = generate_ls(particle, *decay_products[chain_id])
LS_dec = [L for L, S in LS_dec]
return Resonance(particle, l_R=min(LS_dec), l_Λc=min(LS_prod))
def generate_ls(
parent: Particle,
child1: Particle,
child2: Particle,
strong: bool = True,
max_L: int = 3,
):
s1 = child1.spin
s2 = child2.spin
s_values = arange(abs(s1 - s2), s1 + s2)
LS_values = set()
for S in s_values:
for L in arange(0, max_L):
if not abs(L - S) <= parent.spin <= L + S:
continue
η0, η1, η2 = [
int(parent.parity),
int(child1.parity),
int(child2.parity),
]
if strong and η0 != η1 * η2 * (-1) ** L:
continue
LS_values.add((L, S))
return sorted(LS_values)
def arange(x1, x2):
spin_range = np.arange(float(x1), +float(x2) + 0.5)
return list(map(sp.Rational, spin_range))
resonance_particles = {
chain_id: [PDG[name] for name in names]
for chain_id, names in resonance_names.items()
}
ls_resonances = {
chain_id: [Resonance.generate_ls(particle, chain_id) for particle in particles]
for chain_id, particles in resonance_particles.items()
}
def jp(particle: Particle):
p = "+" if particle.parity > 0 else "-"
j = sp.Rational(particle.spin)
return Rf"\({j}^{p}\)"
def create_html_table_row(*items, typ="td"):
items = (f"<{typ}>{i}</{typ}>" for i in items)
return "<tr>" + "".join(items) + "</tr>\n"
column_names = [
"resonance",
R"\(j^P\)",
R"\(m\) (MeV)",
R"\(\Gamma_0\) (MeV)",
R"\(l_R\)",
R"\(l_{\Lambda_c}^\mathrm{min}\)",
]
src = "<table>\n"
src += create_html_table_row(*column_names, typ="th")
for chain_id, resonance_list in ls_resonances.items():
child1, child2 = decay_products[chain_id]
for resonance in resonance_list:
src += create_html_table_row(
Rf"\({resonance.particle.latex} \to {child1.latex} {child2.latex}\)",
jp(resonance.particle),
int(1e3 * resonance.particle.mass),
int(1e3 * resonance.particle.width),
resonance.l_R,
resonance.l_Λc,
)
src += "</table>\n"
HTML(src)
resonance | \(j^P\) | \(m\) (MeV) | \(\Gamma_0\) (MeV) | \(l_R\) | \(l_{\Lambda_c}^\mathrm{min}\) |
---|---|---|---|---|---|
\(K^{*}(892)^{0} \to \pi^{+} K^{-}\) | \(1^-\) | 895 | 47 | 1 | 0 |
\(\Lambda(1520) \to p K^{-}\) | \(3/2^-\) | 1519 | 16 | 2 | 1 |
\(\Lambda(1670) \to p K^{-}\) | \(1/2^-\) | 1674 | 30 | 0 | 0 |
\(\Delta(1232)^{++} \to p \pi^{+}\) | \(3/2^+\) | 1232 | 117 | 1 | 1 |
Aligned amplitude#
Show code cell source
A_K = sp.IndexedBase(R"A^K")
A_Λ = sp.IndexedBase(R"A^{\Lambda}")
A_Δ = sp.IndexedBase(R"A^{\Delta}")
half = sp.S.Half
ζ_0_11 = sp.Symbol(R"\zeta^0_{1(1)}", real=True)
ζ_0_21 = sp.Symbol(R"\zeta^0_{2(1)}", real=True)
ζ_0_31 = sp.Symbol(R"\zeta^0_{3(1)}", real=True)
ζ_1_11 = sp.Symbol(R"\zeta^1_{1(1)}", real=True)
ζ_1_21 = sp.Symbol(R"\zeta^1_{2(1)}", real=True)
ζ_1_31 = sp.Symbol(R"\zeta^1_{3(1)}", real=True)
def formulate_aligned_amplitude(λ_Λc, λ_p):
_ν = sp.Symbol(R"\nu^{\prime}", rational=True)
_λ = sp.Symbol(R"\lambda^{\prime}", rational=True)
return PoolSum(
A_K[_ν, _λ] * Wigner.d(half, λ_Λc, _ν, ζ_0_11) * Wigner.d(half, _λ, λ_p, ζ_1_11)
+ A_Λ[_ν, _λ]
* Wigner.d(half, λ_Λc, _ν, ζ_0_21)
* Wigner.d(half, _λ, λ_p, ζ_1_21)
+ A_Δ[_ν, _λ]
* Wigner.d(half, λ_Λc, _ν, ζ_0_31)
* Wigner.d(half, _λ, λ_p, ζ_1_31),
(_λ, [-half, +half]),
(_ν, [-half, +half]),
)
ν = sp.Symbol("nu")
λ = sp.Symbol("lambda")
formulate_aligned_amplitude(λ_Λc=ν, λ_p=λ)
Dynamics#
The lineshape function is factored out of the \(\Lambda_c\) helicity coupling:
The relativistic Breit-Wigner parametrization reads:
with energy-dependent width given by
The form-factor \(F\) is the Blatt-Weisskopf factor with the length factor \(R=5\,\)GeV\(^{-1}\):
The break-up momenta is calculated for every decay chain separately. Using the notations \(0->R(\to ij) k\), one writes:
The momenta with subindex zero are computed for nominal mass of the resonance, \(s=m^2\). The three-argument Källén function reads:
Formulation with SymPy
Define Blatt-Weisskopf form factor
@make_commutative
@implement_doit_method
class BlattWeisskopf(UnevaluatedExpression):
def __new__(cls, z, L, **hints):
return create_expression(cls, z, L, **hints)
def evaluate(self):
z, L = self.args
cases = {
0: 1,
1: 1 / (1 + z**2),
2: 1 / (9 + 3 * z**2 + z**4),
}
return sp.Piecewise(*[
(sp.sqrt(expr), sp.Eq(L, l_val)) for l_val, expr in cases.items()
])
def _latex(self, printer, *args):
z, L = map(printer._print, self.args)
return Rf"F_{{{L}}}\left({z}\right)"
z = sp.Symbol("z", positive=True)
L = sp.Symbol("L", integer=True, nonnegative=True)
latex = sp.multiline_latex(BlattWeisskopf(z, L), BlattWeisskopf(z, L).doit())
Math(latex)
Define Källén function
@make_commutative
@implement_doit_method
class Källén(UnevaluatedExpression):
def __new__(cls, x, y, z, **hints):
return create_expression(cls, x, y, z, **hints)
def evaluate(self) -> sp.Expr:
x, y, z = self.args
return x**2 + y**2 + z**2 - 2 * x * y - 2 * y * z - 2 * z * x
def _latex(self, printer, *args):
x, y, z = map(printer._print, self.args)
return Rf"\lambda\left({x}, {y}, {z}\right)"
x, y, z = sp.symbols("x:z")
display_doit(Källén(x, y, z))
Define break-up momenta p and q
@make_commutative
@implement_doit_method
class P(UnevaluatedExpression):
def __new__(cls, s, mi, mj, **hints):
return create_expression(cls, s, mi, mj, **hints)
def evaluate(self):
s, mi, mj = self.args
return sp.sqrt(Källén(s, mi**2, mj**2)) / (2 * sp.sqrt(s))
def _latex(self, printer, *args):
s = printer._print(self.args[0])
return Rf"p_{{{s}}}"
@make_commutative
@implement_doit_method
class Q(UnevaluatedExpression):
def __new__(cls, s, m0, mk, **hints):
return create_expression(cls, s, m0, mk, **hints)
def evaluate(self):
s, m0, mk = self.args
return sp.sqrt(Källén(s, m0**2, mk**2)) / (2 * m0) # <-- not s!
def _latex(self, printer, *args):
s = printer._print(self.args[0])
return Rf"q_{{{s}}}"
s, m0, mi, mj, mk = sp.symbols("s m0 m_i:k", nonnegative=True)
display_doit(P(s, mi, mj))
display_doit(Q(s, m0, mk))
Show code cell source
R = sp.Symbol("R")
parameter_defaults = {
R: 5, # GeV^{-1} (length factor)
}
@make_commutative
@implement_doit_method
class EnergyDependentWidth(UnevaluatedExpression):
def __new__(cls, s, m0, Γ0, m1, m2, L, R):
return create_expression(cls, s, m0, Γ0, m1, m2, L, R)
def evaluate(self):
s, m0, Γ0, m1, m2, L, R = self.args
p = P(s, m1, m2)
p0 = P(m0**2, m1, m2)
ff = BlattWeisskopf(p * R, L) ** 2
ff0 = BlattWeisskopf(p0 * R, L) ** 2
return sp.Mul(
Γ0,
(p / p0) ** (2 * L + 1),
m0 / sp.sqrt(s),
ff / ff0,
evaluate=False,
)
def _latex(self, printer, *args) -> str:
s = printer._print(self.args[0])
return Rf"\Gamma\left({s}\right)"
l_R = sp.Symbol("l_R", integer=True, positive=True)
m, Γ0, m1, m2 = sp.symbols("m Γ0 m1 m2", nonnegative=True)
display_doit(EnergyDependentWidth(s, m, Γ0, m1, m2, l_R, R))
Define relativistic Breit-Wigner
@make_commutative
@implement_doit_method
class RelativisticBreitWigner(UnevaluatedExpression):
def __new__(cls, s, m0, Γ0, m1, m2, l_R, l_Λc, R):
return create_expression(cls, s, m0, Γ0, m1, m2, l_R, l_Λc, R)
def evaluate(self):
s, m0, Γ0, m1, m2, l_R, l_Λc, R = self.args
q = Q(s, m1, m2)
q0 = Q(m0**2, m1, m2)
p = P(s, m1, m2)
p0 = P(m0**2, m1, m2)
width = EnergyDependentWidth(s, m0, Γ0, m1, m2, l_R, R)
return sp.Mul(
(q / q0) ** l_Λc,
BlattWeisskopf(q * R, l_Λc) / BlattWeisskopf(q0 * R, l_Λc),
1 / (m0**2 - s - sp.I * m0 * width),
(p / p0) ** l_R,
BlattWeisskopf(p * R, l_R) / BlattWeisskopf(p0 * R, l_R),
evaluate=False,
)
def _latex(self, printer, *args) -> str:
s = printer._print(self.args[0])
return Rf"\mathcal{{R}}\left({s}\right)"
l_Λc = sp.Symbol(R"l_{\Lambda_c}", integer=True, positive=True)
display_doit(RelativisticBreitWigner(s, m, Γ0, m1, m2, l_R, l_Λc, R))
Decay chain amplitudes#
Formulate unaligned amplitudes for each chain
def formulate_chain_amplitude(chain_id: int, λ_Λc, λ_p):
resonances = ls_resonances[chain_id]
if chain_id == 1:
return formulate_K_amplitude(λ_Λc, λ_p, resonances)
if chain_id == 2:
return formulate_Λ_amplitude(λ_Λc, λ_p, resonances)
if chain_id == 3:
return formulate_Δ_amplitude(λ_Λc, λ_p, resonances)
raise NotImplementedError
H_prod = sp.IndexedBase(R"\mathcal{H}^\mathrm{production}")
H_dec = sp.IndexedBase(R"\mathcal{H}^\mathrm{decay}")
θ23 = sp.Symbol("theta23", real=True)
θ31 = sp.Symbol("theta31", real=True)
θ12 = sp.Symbol("theta12", real=True)
σ1, σ2, σ3 = sp.symbols("sigma1:4", nonnegative=True)
m1, m2, m3 = sp.symbols(R"m_p m_pi m_K", nonnegative=True)
def formulate_K_amplitude(λ_Λc, λ_p, resonances: list[Resonance]):
τ = sp.Symbol("tau", rational=True)
return sp.Add(*[
PoolSum(
sp.KroneckerDelta(λ_Λc, τ - λ_p)
* H_prod[stringify(res), τ, λ_p]
* formulate_dynamics(res, σ1, m2, m3)
* (-1) ** (half - λ_p)
* Wigner.d(sp.Rational(res.particle.spin), τ, 0, θ23)
* H_dec[stringify(res), 0, 0],
(τ, create_spin_range(res.particle.spin)),
)
for res in resonances
])
def formulate_Λ_amplitude(λ_Λc, λ_p, resonances: list[Resonance]):
τ = sp.Symbol("tau", rational=True)
return sp.Add(*[
PoolSum(
sp.KroneckerDelta(λ_Λc, τ)
* H_prod[stringify(res), τ, 0]
* formulate_dynamics(res, σ2, m1, m3)
* Wigner.d(sp.Rational(res.particle.spin), τ, -λ_p, θ31)
* H_dec[stringify(res), 0, λ_p]
* (-1) ** (half - λ_p),
(τ, create_spin_range(res.particle.spin)),
)
for res in resonances
])
def formulate_Δ_amplitude(λ_Λc, λ_p, resonances: list[Resonance]):
τ = sp.Symbol("tau", rational=True)
return sp.Add(*[
PoolSum(
sp.KroneckerDelta(λ_Λc, τ)
* H_prod[stringify(res), τ, 0]
* formulate_dynamics(res, σ3, m1, m2)
* Wigner.d(sp.Rational(res.particle.spin), τ, λ_p, θ12)
* H_dec[stringify(res), λ_p, 0],
(τ, create_spin_range(res.particle.spin)),
)
for res in resonances
])
def formulate_dynamics(decay: Resonance, s, m1, m2):
l_R = sp.Rational(decay.l_R)
l_Λc = sp.Rational(decay.l_Λc)
mass = sp.Symbol(f"m_{{{decay.particle.latex}}}")
width = sp.Symbol(Rf"\Gamma_{{{decay.particle.latex}}}")
parameter_defaults[mass] = decay.particle.mass
parameter_defaults[width] = decay.particle.width
return RelativisticBreitWigner(s, mass, width, m1, m2, l_R, l_Λc, R)
def stringify(particle: Particle | Resonance) -> Str:
if isinstance(particle, Resonance):
particle = particle.particle
return Str(particle.latex)
def create_spin_range(j):
return arange(-j, +j)
display(
formulate_chain_amplitude(1, ν, λ),
formulate_chain_amplitude(2, ν, λ),
formulate_chain_amplitude(3, ν, λ),
)
Angle definitions#
Angles with repeated lower indices are trivial. The other angles are computed from the invariants and these are the angles with sign
The expressions for the cosine of the positive (anticlockwise) angles, \(\theta_{12}, \theta_{23}, \theta_{13}\) and \(\hat\theta_{1(2)}, \hat\theta_{3(1)}, \zeta^1_{1(3)}\) can be expressed in terms of Mandelstam variables \(\sigma_1, \sigma_2, \sigma_3\) using [Mikhasenko et al., 2020], Appendix A:
Formulate expressions for DPD-angles
m0 = sp.Symbol(R"m_{\Lambda_c}", nonnegative=True)
angles = {
θ12: sp.acos(
(2 * σ3 * (σ2 - m3**2 - m1**2) - (σ3 + m1**2 - m2**2) * (m0**2 - σ3 - m3**2))
/ (sp.sqrt(Källén(m0**2, m3**2, σ3)) * sp.sqrt(Källén(σ3, m1**2, m2**2)))
),
θ23: sp.acos(
(2 * σ1 * (σ3 - m1**2 - m2**2) - (σ1 + m2**2 - m3**2) * (m0**2 - σ1 - m1**2))
/ (sp.sqrt(Källén(m0**2, m1**2, σ1)) * sp.sqrt(Källén(σ1, m2**2, m3**2)))
),
θ31: sp.acos(
(2 * σ2 * (σ1 - m2**2 - m3**2) - (σ2 + m3**2 - m1**2) * (m0**2 - σ2 - m2**2))
/ (sp.sqrt(Källén(m0**2, m2**2, σ2)) * sp.sqrt(Källén(σ2, m3**2, m1**2)))
),
ζ_0_11: sp.S.Zero, # = \hat\theta^0_{1(1)}
ζ_0_21: -sp.acos( # = -\hat\theta^{1(2)}
((m0**2 + m1**2 - σ1) * (m0**2 + m2**2 - σ2) - 2 * m0**2 * (σ3 - m1**2 - m2**2))
/ (sp.sqrt(Källén(m0**2, m2**2, σ2)) * sp.sqrt(Källén(m0**2, σ1, m1**2)))
),
ζ_0_31: sp.acos( # = \hat\theta^{3(1)}
((m0**2 + m3**2 - σ3) * (m0**2 + m1**2 - σ1) - 2 * m0**2 * (σ2 - m3**2 - m1**2))
/ (sp.sqrt(Källén(m0**2, m1**2, σ1)) * sp.sqrt(Källén(m0**2, σ3, m3**2)))
),
ζ_1_11: sp.S.Zero,
ζ_1_21: sp.acos(
(2 * m1**2 * (σ3 - m0**2 - m3**2) + (m0**2 + m1**2 - σ1) * (σ2 - m1**2 - m3**2))
/ (sp.sqrt(Källén(m0**2, m1**2, σ1)) * sp.sqrt(Källén(σ2, m1**2, m3**2)))
),
ζ_1_31: -sp.acos( # = -\zeta^1_{1(3)}
(2 * m1**2 * (σ2 - m0**2 - m2**2) + (m0**2 + m1**2 - σ1) * (σ3 - m1**2 - m2**2))
/ (sp.sqrt(Källén(m0**2, m1**2, σ1)) * sp.sqrt(Källén(σ3, m1**2, m2**2)))
),
}
display_definitions(angles)
where \(m_0\) is the mass of the initial state \(\Lambda_c\) and \(m_1, m_2, m_3\) are the masses of \(p, \pi, K\), respectively:
Define initial and final state masses
masses = {
m0: Λc.mass,
m1: p.mass,
m2: π.mass,
m3: K.mass,
}
parameter_defaults.update(masses)
display_definitions(masses)
Helicity coupling values#
Compute helicity couplings of the decay node
dec_couplings = {}
for res in ls_resonances[1]:
i = stringify(res)
dec_couplings[H_dec[i, 0, 0]] = 1
for res in ls_resonances[2]:
i = stringify(res.particle)
dec_couplings[H_dec[i, 0, half]] = 1
dec_couplings[H_dec[i, 0, -half]] = int(
int(res.particle.parity)
* int(K.parity)
* int(p.parity)
* (-1) ** (res.particle.spin - K.spin - p.spin)
)
for res in ls_resonances[3]:
i = stringify(res.particle)
dec_couplings[H_dec[i, half, 0]] = 1
dec_couplings[H_dec[i, -half, 0]] = int(
int(res.particle.parity)
* int(p.parity)
* int(π.parity)
* (-1) ** (res.particle.spin - p.spin - π.spin)
)
parameter_defaults.update(dec_couplings)
display_definitions(dec_couplings)
Define helicity couplings of the production node
prod_couplings = {
# chain 23:
H_prod[Str("K^{*}(892)^{0}"), 0, -half]: 1,
H_prod[Str("K^{*}(892)^{0}"), -1, -half]: 1 - 1j,
H_prod[Str("K^{*}(892)^{0}"), +1, +half]: -3 - 3j,
H_prod[Str("K^{*}(892)^{0}"), 0, +half]: -1 - 4j,
H_prod[Str("K_{0}^{*}(1430)^{0}"), 0, -half]: 1,
H_prod[Str("K_{0}^{*}(1430)^{0}"), -1, -half]: 1 - 1j,
H_prod[Str("K_{0}^{*}(1430)^{0}"), +1, +half]: -3 - 3j,
H_prod[Str("K_{0}^{*}(1430)^{0}"), 0, +half]: -1 - 4j,
H_prod[Str("K_{2}^{*}(1430)^{0}"), 0, -half]: 1,
H_prod[Str("K_{2}^{*}(1430)^{0}"), -1, -half]: 1 - 1j,
H_prod[Str("K_{2}^{*}(1430)^{0}"), +1, +half]: -3 - 3j,
H_prod[Str("K_{2}^{*}(1430)^{0}"), 0, +half]: -1 - 4j,
#
# chain 31:
H_prod[Str(R"\Lambda(1520)"), +half, 0]: 1.5,
H_prod[Str(R"\Lambda(1520)"), -half, 0]: 0.3,
H_prod[Str(R"\Lambda(1670)"), +half, 0]: -0.5 + 1j,
H_prod[Str(R"\Lambda(1670)"), -half, 0]: -0.3 - 0.1j,
# chain 12:
H_prod[Str(R"\Delta(1232)^{++}"), +half, 0]: -13 + 5j,
H_prod[Str(R"\Delta(1232)^{++}"), -half, 0]: -7 + 3j,
}
display_definitions(prod_couplings)
couplings = dict(dec_couplings)
couplings.update(prod_couplings)
parameter_defaults.update(prod_couplings)
Intensity expression#
Incoherent sum of the amplitudes defined by Aligned amplitude:
Formulate expression for the DPD-aligned intensity
intensity_expr = PoolSum(
sp.Abs(formulate_aligned_amplitude(ν, λ)) ** 2,
(λ, [-half, +half]),
(ν, [-half, +half]),
)
intensity_expr
Remaining free_symbols
are indeed the specific amplitudes as defined by Decay chain amplitudes:
The specific amplitudes from Decay chain amplitudes need to be formulated for each value of \(\nu, \lambda\), so that they can be substituted in the top expression:
Formulate unaligned amplitude expressions
A = {1: A_K, 2: A_Λ, 3: A_Δ}
amp_definitions = {}
for chain_id in chain_ids:
for Λc_heli, p_heli in itertools.product([-half, +half], [-half, +half]):
symbol = A[chain_id][Λc_heli, p_heli]
expr = formulate_chain_amplitude(chain_id, ν, λ)
amp_definitions[symbol] = expr.subs({ν: Λc_heli, λ: p_heli})
display_definitions(amp_definitions)
Assert that all symbols are defined
unfolded_intensity_expr = perform_cached_doit(
perform_cached_doit(intensity_expr).xreplace(amp_definitions)
)
expr = unfolded_intensity_expr.xreplace(angles).doit()
expr = expr.xreplace(parameter_defaults)
assert expr.free_symbols == {σ1, σ2, σ3}
del expr
Polarization sensitivity#
We introduce the polarimeter vector field (polarization sensitivity) of the \(\Lambda_c\) decay. It is defined by three quantities \((\alpha_x,\alpha_y,\alpha_z)\) forming a three-dimensional vector \(\vec\alpha\) dependent on just two decay variables, \(\sigma_1=m_{K\pi}^2\), and \(\sigma_2=m_{pK}^2\).
The polarimeter vector field is computed by averaging the Pauli matrices \(\vec\sigma\) contracted with the \(\Lambda_c^+\) helicity indices given the transition amplitude.
The quantities \(\vec\alpha(m_{K\pi},m_{pK})\) give the model-independent representation of the \(\Lambda_c^+\) decay process. It can be used to study \(\Lambda_c^+\) production polarization using
where \(R_{ij}(\alpha,\beta,\gamma)\) is a three-dimensional rotation matrix:
and \(I_0\) is the averaged decay rate
def to_index(helicity):
"""Symbolic conversion of half-value helicities to Pauli matrix indices."""
# https://github.com/ComPWA/compwa.github.io/pull/129#issuecomment-1096599896
return sp.Piecewise(
(1, sp.LessThan(helicity, 0)),
(0, True),
)
ν_prime = sp.Symbol(R"\nu^{\prime}")
polarimetry_exprs = tuple(
PoolSum(
formulate_aligned_amplitude(ν, λ).conjugate()
* msigma(i)[to_index(ν), to_index(ν_prime)]
* formulate_aligned_amplitude(ν_prime, λ),
(λ, [-half, +half]),
(ν, [-half, +half]),
(ν_prime, [-half, +half]),
)
/ intensity_expr
for i in (1, 2, 3)
)
Unfold polarimeter expressions
unfolded_polarimetry_exprs = tuple(
perform_cached_doit(perform_cached_doit(x).xreplace(amp_definitions))
for x in polarimetry_exprs
)
Properties of the vector \(\vec\alpha\)#
The vector \(\vec \alpha\) introduced in Eq. (3) obeys the following properties:
It is a three-dimensional vector defined in the rest frame of the decaying particle. Particularly, it is transformed as a regular vector in case initial (alignment) configuration change.
The length of the vector is limited by 1: \(|\vec{\alpha}| < 1\)
\(\alpha_y=0\) for the decays of a fermion to a fermions and (pseudo)scalar
Here is the prove of the second statement:
where
and
To constraint the length of the \(\vec\alpha\), one notices \(ab - c \ge 0\). Therefore,
since \(a,b \geq 0\).
Computations with TensorWaves#
Conversion to computational backend#
The full expression tree can be converted to a computational, parametrized function as follows. Note that identify all coupling symbols are interpreted as parameters. The remaining symbols (the angles) become arguments to the function.
free_parameters = {
symbol: value
for symbol, value in parameter_defaults.items()
if (symbol.name.startswith("m_") and symbol not in masses)
or symbol.name.startswith(R"\Gamma_")
or symbol in prod_couplings
}
fixed_parameters = {
symbol: value
for symbol, value in parameter_defaults.items()
if symbol not in free_parameters
}
intensity_func = create_parametrized_function(
unfolded_intensity_expr.xreplace(fixed_parameters),
parameters=free_parameters,
backend="jax",
)
polarimetry_funcs = tuple(
create_parametrized_function(
expr.xreplace(fixed_parameters),
parameters=free_parameters,
backend="jax",
)
for expr in unfolded_polarimetry_exprs
)
Phase space#
The \(\Lambda_c^+ \to p K \pi\) kinematics is fully described by two dynamic variables, \(m_{K\pi}\) and \(m_{pK}\) (see Phase space for a three-body decay). The third Mandelstam variable can be computed from the other two and the masses of the initial and final state:
Show code cell source
computed_σ3 = m0**2 + m1**2 + m2**2 + m3**2 - σ1 - σ2
compute_third_mandelstam = create_function(computed_σ3.subs(masses), backend="jax")
display_definitions({σ3: computed_σ3})
Values for the angles will be computed form the Mandelstam values with a data transformer for the symbolic angle definitions:
kinematic_variables = {
symbol: expression.doit().xreplace(masses).xreplace(fixed_parameters)
for symbol, expression in angles.items()
}
kinematic_variables.update({s: s for s in [σ1, σ2, σ3]}) # include identity
transformer = SympyDataTransformer.from_sympy(kinematic_variables, backend="jax")
We now define phase space over a grid that contains the space in the Dalitz plane that is kinematically ‘available’ to the decay:
m0_val, m1_val, m2_val, m3_val = masses.values()
σ1_min = (m2_val + m3_val) ** 2
σ1_max = (m0_val - m1_val) ** 2
σ2_min = (m1_val + m3_val) ** 2
σ2_max = (m0_val - m2_val) ** 2
def generate_phsp_grid(resolution: int):
x = np.linspace(σ1_min, σ1_max, num=resolution)
y = np.linspace(σ2_min, σ2_max, num=resolution)
X, Y = np.meshgrid(x, y)
Z = compute_third_mandelstam.function(X, Y)
phsp = {"sigma1": X, "sigma2": Y, "sigma3": Z}
return X, Y, transformer(phsp)
X, Y, phsp = generate_phsp_grid(resolution=500)
Intensity distribution#
Finally, all intensities can be computed as follows:
Show code cell source
%config InlineBackend.figure_formats = ['png']
s1_label = R"$\sigma_1=m^2\left(K\pi\right)$"
s2_label = R"$\sigma_2=m^2\left(pK\right)$"
s3_label = R"$\sigma_3=m^2\left(p\pi\right)$"
plt.rc("font", size=15)
fig, ax = plt.subplots(
figsize=(9, 8),
tight_layout=True,
)
ax.set_box_aspect(1)
ax.set_title("Intensity distribution")
ax.set_xlabel(s1_label)
ax.set_ylabel(s2_label)
total_intensities = intensity_func(phsp)
mesh = ax.pcolormesh(X, Y, total_intensities, norm=LogNorm())
fig.colorbar(mesh, ax=ax, fraction=0.05, pad=0.02)
fig.savefig("intensity-distribution.png", dpi=200)
plt.show()
Show code cell source
%config InlineBackend.figure_formats = ['svg']
def compute_sub_function(
func: ParametrizedFunction, phsp: DataSample, non_zero_couplings: str
) -> jnp.ndarray:
zero_couplings = {
par: 0
for par in func.parameters
if par.startswith(R"\mathcal{H}")
if "production" in par
if not any(s in par for s in non_zero_couplings)
}
original_parameters = dict(func.parameters)
func.update_parameters(zero_couplings)
computed_values = func(phsp)
func.update_parameters(original_parameters)
return computed_values
def set_ylim_to_zero(ax):
_, y_max = ax.get_ylim()
ax.set_ylim(0, y_max)
fig, (ax1, ax2) = plt.subplots(
ncols=2,
figsize=(12, 5),
sharey=True,
tight_layout=True,
)
ax1.set_xlabel(s1_label)
ax2.set_xlabel(s2_label)
ax1.set_yticks([])
x = X[0]
y = Y[:, 0]
ax1.fill(x, np.nansum(total_intensities, axis=0), alpha=0.3)
ax2.fill(y, np.nansum(total_intensities, axis=1), alpha=0.3)
for chain_id, chain_name in chain_ids.items():
label = f"${chain_labels[chain_id]}$"
sub_intensities = compute_sub_function(
intensity_func, phsp, non_zero_couplings=[chain_name]
)
ax1.plot(x, np.nansum(sub_intensities, axis=0), label=label)
ax2.plot(y, np.nansum(sub_intensities, axis=1), label=label)
set_ylim_to_zero(ax1)
set_ylim_to_zero(ax2)
ax2.legend()
fig.savefig("intensity-projections.svg")
plt.show()
Fit fractions#
The total decay rate for \(\Lambda_c^+ \to pK\pi\) can be broken into fractions that correspond to the different decay chains and interference terms. The total rate is computed as an integral of the intensity over decay kinematics:
where \(\Phi_0\) is an (irrelevant) constant equal to the flat phase-space integral, \((m_{pK,e}, m_{K\pi,e})\) is a vector of the kinematic variables for the \(e\)-th point in the MC sample.
The conditional argument \(\{\mathcal{H}\}\) indicates dependence of the rate on the value of the couplings. The individual fractions are found by computing the total rate for a subset of couplings set to zero,
where the terms with a single chain index are the rate of the decay chain. The sum of all fractions should give the total rate:
Show code cell source
def integrate_intensity(
intensity_func: ParametrizedFunction,
phsp: DataSample,
non_zero_couplings: list[str] | None = None,
) -> float:
if non_zero_couplings is None:
intensities = intensity_func(phsp)
else:
intensities = compute_sub_function(intensity_func, phsp, non_zero_couplings)
return np.nansum(intensities) / len(intensities)
def compute_interference(
intensity_func: ParametrizedFunction,
phsp: DataSample,
chain1: list[str],
chain2: list[str],
) -> float:
I_interference = integrate_intensity(intensity_func, phsp, chain1 + chain2)
I_chain1 = integrate_intensity(intensity_func, phsp, chain1)
I_chain2 = integrate_intensity(intensity_func, phsp, chain2)
return I_interference - I_chain1 - I_chain2
I_tot = integrate_intensity(intensity_func, phsp)
np.testing.assert_allclose(
I_tot,
integrate_intensity(intensity_func, phsp, ["K", R"\Lambda", R"\Delta"]),
)
I_K = integrate_intensity(intensity_func, phsp, non_zero_couplings=["K"])
I_Λ = integrate_intensity(intensity_func, phsp, non_zero_couplings=["Lambda"])
I_Δ = integrate_intensity(intensity_func, phsp, non_zero_couplings=["Delta"])
I_ΛΔ = compute_interference(intensity_func, phsp, ["Lambda"], ["Delta"])
I_KΔ = compute_interference(intensity_func, phsp, ["K"], ["Delta"])
I_KΛ = compute_interference(intensity_func, phsp, ["K"], ["Lambda"])
np.testing.assert_allclose(I_tot, I_K + I_Λ + I_Δ + I_ΛΔ + I_KΔ + I_KΛ)
Show code cell source
def render_resonance_row(chain_id):
rows = [
(
Rf"\color{{gray}}{{{p.latex}}}",
(
Rf"\color{{gray}}{{{integrate_intensity(intensity_func, phsp, [p.name]) / I_tot:.3f}}}"
),
)
for p in resonance_particles[chain_id]
]
if len(rows) > 1:
return rows
return []
rows = [
R"\hline",
("K^{**}", f"{I_K / I_tot:.3f}"),
*render_resonance_row(chain_id=1),
(R"\Lambda^{**}", f"{I_Λ / I_tot:.3f}"),
*render_resonance_row(chain_id=2),
(R"\Delta^{**}", f"{I_Δ / I_tot:.3f}"),
*render_resonance_row(chain_id=3),
(R"\Delta/\Lambda", f"{I_ΛΔ / I_tot:.3f}"),
(R"K/\Delta", f"{I_KΔ / I_tot:.3f}"),
(R"K/\Lambda", f"{I_KΛ / I_tot:.3f}"),
R"\hline",
(
R"\mathrm{total}",
f"{(I_K + I_Λ + I_Δ + I_ΛΔ + I_KΔ + I_KΛ) / I_tot:.3f}",
),
]
latex = R"\begin{array}{crr}" + "\n"
latex += R"& I_\mathrm{sub}\,/\,I \\" + "\n"
for row in rows:
if row == R"\hline":
latex += R"\hline"
else:
latex += " " + " & ".join(row) + R" \\" + "\n"
latex += R"\end{array}"
Math(latex)
Polarimetry distributions#
Show code cell source
def render_mean(array, plus=True):
array = array.real
mean = f"{np.nanmean(array):.3f}"
std = f"{np.nanstd(array):.3f}"
if plus and float(mean) > 0:
mean = f"+{mean}"
return Rf"{mean} \pm {std}"
latex = R"\begin{array}{cccc}" + "\n"
latex += R"& \bar{|\alpha|} & \bar\alpha_x & \bar\alpha_y & \bar\alpha_z \\" + "\n"
for chain_id, chain_name in chain_ids.items():
latex += f" {chain_labels[chain_id]} & "
x, y, z = tuple(
compute_sub_function(func, phsp, non_zero_couplings=[chain_name])
for func in polarimetry_funcs
)
latex += render_mean(np.sqrt(x**2 + y**2 + z**2), plus=False) + " & "
latex += " & ".join(map(render_mean, [x, y, z]))
latex += R" \\" + "\n"
latex += R"\end{array}"
Math(latex)
Define sliders for the widget
# Slider construction
sliders = {}
for symbol in free_parameters:
if symbol.name.startswith(R"\mathcal{H}"):
real_slider = create_slider(symbol)
imag_slider = create_slider(symbol)
sliders[f"{symbol.name}_real"] = real_slider
sliders[f"{symbol.name}_imag"] = imag_slider
real_slider.description = R"\(\mathrm{Re}\)"
imag_slider.description = R"\(\mathrm{Im}\)"
else:
slider = create_slider(symbol)
sliders[symbol.name] = slider
# Slider ranges
σ3_max = (m0_val - m3_val) ** 2
σ3_min = (m1_val + m2_val) ** 2
for name, slider in sliders.items():
slider.continuous_update = True
slider.step = 0.01
if name.startswith("m_"):
if "K" in name:
slider.min = np.sqrt(σ1_min)
slider.max = np.sqrt(σ1_max)
elif R"\Lambda" in name:
slider.min = np.sqrt(σ2_min)
slider.max = np.sqrt(σ2_max)
elif R"\Delta" in name:
slider.min = np.sqrt(σ3_min)
slider.max = np.sqrt(σ3_max)
elif name.startswith(R"\Gamma_"):
slider.min = 0
slider.max = max(0.5, 2 * slider.value)
elif name.startswith(R"\mathcal{H}"):
slider.min = -15
slider.max = +15
# Slider values
def reset_sliders(click_event):
for symbol, value in free_parameters.items():
if symbol.name.startswith(R"\mathcal{H}"):
set_slider(sliders[symbol.name + "_real"], value)
set_slider(sliders[symbol.name + "_imag"], value)
else:
set_slider(sliders[symbol.name], value)
def set_coupling_to_zero(filter_pattern):
if isinstance(filter_pattern, Combobox):
filter_pattern = filter_pattern.value
for name, _slider in sliders.items():
if not name.startswith(R"\mathcal{H}"):
continue
if filter_pattern not in name:
continue
set_slider(_slider, 0)
def set_slider(slider, value):
if slider.description == R"\(\mathrm{Im}\)":
value = complex(value).imag
else:
value = complex(value).real
n_decimals = -round(np.log10(slider.step))
if slider.value != round(value, n_decimals): # widget performance
slider.value = value
reset_sliders(click_event=None)
reset_button = Button(description="Reset slider values")
reset_button.on_click(reset_sliders)
all_resonances = [r.latex for r_list in resonance_particles.values() for r in r_list]
filter_button = Combobox(
placeholder="Enter coupling filter pattern",
options=all_resonances,
description=R"$\mathcal{H}=0$",
)
filter_button.on_submit(set_coupling_to_zero)
# UI design
latex = {symbol.name: sp.latex(symbol) for symbol in free_parameters}
mass_sliders = [sliders[n] for n in sliders if n.startswith("m_")]
width_sliders = [sliders[n] for n in sliders if n.startswith(R"\Gamma_")]
coupling_sliders = {}
for res_list in resonance_particles.values():
for res in res_list:
coupling_sliders[res.name] = (
[s for n, s in sliders.items() if n.endswith("_real") and res.latex in n],
[s for n, s in sliders.items() if n.endswith("_imag") and res.latex in n],
[
HTMLMath(f"${latex[n[:-5]]}$")
for n in sliders
if n.endswith("_real") and res.latex in n
],
)
slider_tabs = Tab(
children=[
Tab(
children=[
VBox([HBox(s) for s in zip(*pair, strict=True)])
for pair in coupling_sliders.values()
],
titles=tuple(coupling_sliders),
),
VBox([HBox([r, i]) for r, i in zip(mass_sliders, width_sliders, strict=True)]),
],
titles=("Couplings", "Masses and widths"),
)
ui = VBox([slider_tabs, HBox([reset_button, filter_button])])
Create interactive plot
%config InlineBackend.figure_formats = ['png']
fig, axes = plt.subplots(
figsize=(12, 6.2),
ncols=2,
sharey=True,
)
ax1, ax2 = axes
ax1.set_title("Intensity distribution")
ax2.set_title("Polarimeter vector field")
ax1.set_xlabel(Rf"${s1_label[1:-1]}, \alpha_x$")
ax2.set_xlabel(Rf"${s1_label[1:-1]}, \alpha_x$")
ax1.set_ylabel(Rf"${s2_label[1:-1]}, \alpha_z$")
for ax in axes:
ax.set_box_aspect(1)
fig.canvas.toolbar_visible = False
fig.canvas.header_visible = False
fig.canvas.footer_visible = False
mesh = None
quiver = None
Xα, Yα, phsp_α = generate_phsp_grid(resolution=35)
XI, YI, phsp_I = generate_phsp_grid(resolution=200)
def plot3(**kwargs):
global quiver, mesh
kwargs = to_complex_kwargs(**kwargs)
for func in [*list(polarimetry_funcs), intensity_func]:
func.update_parameters(kwargs)
intensity = intensity_func(phsp_I)
αx, αy, αz = tuple(func(phsp_α).real for func in polarimetry_funcs)
abs_α = jnp.sqrt(αx**2 + αy**2 + αz**2)
if mesh is None:
mesh = ax1.pcolormesh(XI, YI, intensity, cmap=plt.cm.Reds)
c_bar = fig.colorbar(mesh, ax=ax1, pad=0.01, fraction=0.0473)
c_bar.ax.set_yticks([])
else:
mesh.set_array(intensity)
if quiver is None:
quiver = ax2.quiver(Xα, Yα, αx, αz, abs_α, cmap=plt.cm.viridis_r, clim=(0, 1))
c_bar = fig.colorbar(quiver, ax=ax2, pad=0.01, fraction=0.0473)
c_bar.ax.set_ylabel(R"$\left|\vec\alpha\right|$")
else:
quiver.set_UVC(αx, αz, abs_α)
fig.canvas.draw_idle()
def to_complex_kwargs(**kwargs):
complex_valued_kwargs = {}
for key, value in dict(kwargs).items():
if key.endswith("real"):
symbol_name = key[:-5]
imag = kwargs[f"{symbol_name}_imag"]
complex_valued_kwargs[symbol_name] = complex(value, imag)
elif key.endswith("imag"):
continue
else:
complex_valued_kwargs[key] = value
return complex_valued_kwargs
output = interactive_output(plot3, controls=sliders)
fig.tight_layout()
display(ui, output)