Chew-Mandelstam#
This report is an attempt formulate the Chew-Mandelstam function described in PDG2023, §50.3.3 (Resonances), pp.14–15 with SymPy, so that it can be implemented in AmpForm.
Import Python libraries
%config InlineBackend.figure_formats = ['svg']
import inspect
import warnings
from functools import partial
from typing import Any
import black
import matplotlib.pyplot as plt
import mpl_interactions.ipyplot as iplt
import numpy as np
import qrules
import sympy as sp
from ampform.dynamics import (
BlattWeisskopfSquared,
BreakupMomentumSquared,
PhaseSpaceFactor,
PhaseSpaceFactorComplex,
)
from ampform.io import aslatex
from ampform.sympy import unevaluated
from ampform.sympy.math import ComplexSqrt
from IPython.display import Markdown, Math
from ipywidgets import FloatSlider
from scipy.integrate import quad_vec
from sympy.printing.pycode import _unpack_integral_limits
from tqdm.auto import tqdm
warnings.filterwarnings("ignore")
PDG = qrules.load_pdg()
S-wave#
As can be seen in Eq. (50.44) on PDG2023, §Resonances, p.15, the Chew-Mandelstam function \(\Sigma_a\) for a particle \(a\) decaying to particles \(1, 2\) has a simple form for angular momentum \(L=0\) (\(S\)-wave):
The only question is how to deal with negative values for the squared break-up momentum \(q_a^2\). Here, we will use AmpForm’s ComplexSqrt
:
Show code cell source
z = sp.Symbol("z")
sqrt_expr = ComplexSqrt(z)
Math(aslatex({sqrt_expr: sqrt_expr.get_definition()}))
Show code cell source
@unevaluated
class BreakupMomentum(sp.Expr):
s: Any
m1: Any
m2: Any
_latex_repr_ = R"q_a\left({s}\right)"
def evaluate(self) -> sp.Expr:
s, m1, m2 = self.args
q_squared = BreakupMomentumSquared(s, m1, m2)
return ComplexSqrt(q_squared)
@unevaluated
class ChewMandelstamSWave(sp.Expr):
s: Any
m1: Any
m2: Any
_latex_repr_ = R"\Sigma\left({s}\right)"
def evaluate(self) -> sp.Expr:
# evaluate=False in order to keep same style as PDG
s, m1, m2 = self.args
q = BreakupMomentum(s, m1, m2)
left_term = sp.Mul(
2 * q / sp.sqrt(s),
sp.log((m1**2 + m2**2 - s + 2 * sp.sqrt(s) * q) / (2 * m1 * m2)),
evaluate=False,
)
right_term = (m1**2 - m2**2) * (1 / s - 1 / (m1 + m2) ** 2) * sp.log(m1 / m2)
return sp.Mul(
1 / (16 * sp.pi**2),
left_term - right_term,
evaluate=False,
)
s, m1, m2 = sp.symbols("s m1 m2 ")
cm_expr = ChewMandelstamSWave(s, m1, m2)
q_expr = BreakupMomentum(s, m1, m2)
Math(aslatex({e: e.doit(deep=False) for e in [cm_expr, q_expr]}))
It should be noted that this equation is not well-defined along the real axis, that is, for \(\mathrm{Im}(s) = 0\). For this reason, we split \(s\) into a real part \(s'\) with a small imaginary offset (the PDG indicates this with \(s+0i\)). We parametrized this imaginary offset with \(\epsilon\), and for the interactive plot, we do so with a power of \(10\):
Show code cell source
s_prime = sp.Symbol(R"s^{\prime}", real=True)
epsilon = sp.Symbol("epsilon", positive=True)
s_plus = s_prime + sp.I * sp.Pow(10, -epsilon)
Math(Rf"{sp.latex(s)} \to {sp.latex(s_plus)}")
We are now ready to use express the symbolic expressions above as a numerical function. For comparison, we will plot the Chew-Mandelstam function for \(S\)-waves next to AmpForm’s PhaseSpaceFactorComplex
.
Show code cell source
symbols = (s_prime, m1, m2, epsilon)
cm_func = sp.lambdify(symbols, cm_expr.doit().subs(s, s_plus))
rho_func = sp.lambdify(symbols, rho_expr.doit().subs(s, s_plus))
As starting values for the interactive plot, we assume \(\pi\eta\) scattering (just like in the PDG section) and use their masses as values for \(m_1\) and \(m_1\), respectively.
Define sliders and plot range
s_min, s_max = -0.15, 1.4
plot_domain = np.linspace(s_min, s_max, num=500)
m1_val = PDG["pi0"].mass
m2_val = PDG["eta"].mass
sliders = dict(
m1=FloatSlider(
description=str(m1),
min=0,
max=1.2,
value=m1_val,
),
m2=FloatSlider(
description=str(m2),
min=0,
max=1.2,
value=m2_val,
),
epsilon=FloatSlider(
description=str(epsilon),
min=0,
max=8,
value=4,
),
)
Show code cell source
fig, axes = plt.subplots(figsize=(11, 4.5), ncols=2, tight_layout=True)
ax1, ax2 = axes
for ax in axes:
ax.axhline(0, color="black", linewidth=0.5)
real_style = {"label": "Real part", "c": "black", "linestyle": "dashed"}
imag_style = {"label": "Imag part", "c": "red"}
threshold_style = {"label": R"$s_\mathrm{thr}$", "c": "grey", "linewidth": 0.5}
xlim = (plot_domain.min(), plot_domain.max())
ylim = (-1, +1)
y_factor = 16 * np.pi
controls = iplt.axvline(
lambda *args, **kwargs: (kwargs["m1"].value + kwargs["m2"].value) ** 2,
**sliders,
ax=ax1,
**threshold_style,
)
iplt.axvline(
lambda *args, **kwargs: (kwargs["m1"].value + kwargs["m2"].value) ** 2,
controls=controls,
ax=ax2,
**threshold_style,
)
to_values = lambda kwargs: {k: v.value for k, v in kwargs.items()}
iplt.plot(
plot_domain,
lambda *args, **kwargs: (y_factor * 1j * rho_func(*args, **to_values(kwargs))).real,
controls=controls,
ylim=ylim,
xlim=xlim,
alpha=0.7,
ax=ax1,
**real_style,
)
iplt.plot(
plot_domain,
lambda *args, **kwargs: (y_factor * 1j * rho_func(*args, **to_values(kwargs))).imag,
controls=controls,
ylim=ylim,
xlim=xlim,
alpha=0.7,
ax=ax1,
**imag_style,
)
iplt.plot(
plot_domain,
lambda *args, **kwargs: y_factor * cm_func(*args, **to_values(kwargs)).real,
controls=controls,
ylim=ylim,
xlim=xlim,
alpha=0.7,
ax=ax2,
**real_style,
)
iplt.plot(
plot_domain,
lambda *args, **kwargs: y_factor * cm_func(*args, **to_values(kwargs)).imag,
controls=controls,
ylim=ylim,
xlim=xlim,
alpha=0.7,
ax=ax2,
**imag_style,
)
for ax in axes:
ax.legend(loc="lower right")
ax.set_xticks(np.arange(0, 1.21, 0.3))
ax.set_yticks(np.arange(-1, 1.1, 0.5))
ax.set_xlim()
ax.set_xlabel("$s$ (GeV$^2$)")
ax1.set_ylabel(R"$16\pi \; i\rho(s)$")
ax2.set_ylabel(R"$16\pi \; \Sigma(s)$")
ax1.set_title(R"Complex phase space factor $\rho$")
ax2.set_title("Chew-Mandelstam $S$-wave ($L=0$)")
fig.savefig("chew-mandelstam-s-wave.svg")
plt.show()
Tip
Compare the plots above with Figure 50.6 on PDG2023, §Resonances, p.16.
General dispersion integral#
For higher angular momenta, the PDG notes that one has to compute the dispersion integral given by Eq. (50.44) on PDG2023, §Resonances, p.15:
Equation (1) is the analytic solution for \(L=0\).
From Equations (50.33–34) on PDG2023, §Resonances, p.12, it can be deduced that the function \(n_a^2\) is the same as AmpForm’s BlattWeisskopfSquared
(note that this function is normalized, whereas the PDG’s \(F_j\) function has \(1\) in the nominator). For this reason, we simply use BlattWeisskopfSquared
for the definition of \(n_a^2\):
Show code cell source
@unevaluated
class FormFactor(sp.Expr):
s: Any
m1: Any
m2: Any
L: Any
q0: Any = 1
_latex_repr_ = R"n_a^2\left({s}\right)"
def evaluate(self) -> sp.Expr:
s, m1, m2, L, q0 = self.args
q_squared = BreakupMomentumSquared(s, m1, m2)
return BlattWeisskopfSquared(
z=q_squared / (q0**2),
angular_momentum=L,
)
L = sp.Symbol("L", integer=True, positive=True)
q0 = sp.Symbol("q0", real=True)
na_expr = FormFactor(s, m1, m2, L, q0)
bl_expr = BlattWeisskopfSquared(z, L)
Math(aslatex({e: e.doit(deep=False) for e in [na_expr, bl_expr]}))
For \(\rho_a\), we use AmpForm’s PhaseSpaceFactor
:
The symbolic integrand is then formulated as:
s_thr = (m1 + m2) ** 2
integrand = (PhaseSpaceFactor(s_prime, m1, m2) * FormFactor(s_prime, m1, m2, L, q0)) / (
(s_prime - s_thr) * (s_prime - s - epsilon * sp.I)
)
integrand
Next, we lambdify()
this integrand to a numpy
expression so that we can integrate it efficiently:
integrand_func = sp.lambdify(
args=(s_prime, s, L, epsilon, m1, m2, q0),
expr=integrand.doit(),
modules="numpy",
)
Note
Integrals can be expressed symbolically with SymPy, with some caveats. See SymPy integral.
As discussed in TR-016, scipy.integrate.quad()
cannot integrate over complex-valued functions, but scipy.integrate.quad_vec()
can. For comparison, we now compute this integral for a few values of \(L>0\):
s_domain = np.linspace(s_min, s_max, num=50)
max_L = 3
l_values = list(range(1, max_L + 1))
It is handy to store the numerical results of each dispersion integral in a dict
with \(L\) as keys:
Finally, as can be seen from Eq. (2), the resulting values from the integral have to be shifted with a factor \(\frac{s-s_{\mathrm{thr}_a}}{\pi}\) to get \(\Sigma_a\). We also scale the values with \(16\pi\) so that it can be compared with the plot generated in S-wave.
Show code cell source
fig, axes = plt.subplots(
figsize=(5, 2.5 * len(l_values)),
nrows=len(l_values),
sharex=True,
tight_layout=True,
)
fig.suptitle(f"Dispersion integrals for $m_1={m1_val:.2f}, m_2={m2_val:.2f}$")
for ax, l_val in zip(axes, l_values, strict=True):
ax.axhline(0, linewidth=0.5, c="black")
ax.axvline(s_thr_val, **threshold_style)
ax.plot(s_domain, sigma_scaled[l_val].real, **real_style)
ax.plot(s_domain, sigma_scaled[l_val].imag, **imag_style)
ax.set_title(f"$L = {l_val}$")
ax.set_ylabel(R"$16\pi \; \Sigma(s)$")
axes[-1].set_xlabel("$s$ (GeV$^2$)")
axes[0].legend()
fig.savefig("chew-mandelstam-l-non-zero.svg")
plt.show()
SymPy expressions#
In the following, we attempt to implement Equation (2) using SymPy integral.
Symbolic integral definition
class UnevaluatableIntegral(sp.Integral):
abs_tolerance = 1e-5
rel_tolerance = 1e-5
limit = 50
dummify = True
def doit(self, **hints):
args = [arg.doit(**hints) for arg in self.args]
return self.func(*args)
def _numpycode(self, printer, *args):
integration_vars, limits = _unpack_integral_limits(self)
if len(limits) != 1 or len(integration_vars) != 1:
msg = f"Cannot handle {len(limits)}-dimensional integrals"
raise ValueError(msg)
x = integration_vars[0]
a, b = limits[0]
expr = self.args[0]
if self.dummify:
dummy = sp.Dummy()
expr = expr.xreplace({x: dummy})
x = dummy
integrate_func = "quad_vec"
printer.module_imports["scipy.integrate"].add(integrate_func)
return (
f"{integrate_func}(lambda {printer._print(x)}: {printer._print(expr)},"
f" {printer._print(a)}, {printer._print(b)},"
f" epsabs={self.abs_tolerance}, epsrel={self.abs_tolerance},"
f" limit={self.limit})[0]"
)
Definition of the symbolic dispersion integral
def dispersion_integral(
s,
m1,
m2,
angular_momentum,
meson_radius=1,
s_prime=sp.Symbol("x", real=True),
epsilon=sp.Symbol("epsilon", positive=True),
):
s_thr = (m1 + m2) ** 2
q_squared = BreakupMomentumSquared(s_prime, m1, m2)
ff_squared = BlattWeisskopfSquared(
angular_momentum=L, z=q_squared * meson_radius**2
)
phsp_factor = PhaseSpaceFactor(s_prime, m1, m2)
return sp.Mul(
(s - s_thr) / sp.pi,
UnevaluatableIntegral(
(phsp_factor * ff_squared)
/ (s_prime - s_thr)
/ (s_prime - s - sp.I * epsilon),
(s_prime, s_thr, sp.oo),
),
evaluate=False,
)
integral_expr = dispersion_integral(s, m1, m2, angular_momentum=L, s_prime=s_prime)
integral_expr
Warning
We have to keep track of the integration variable (\(s'\) in Equation (2)), so that we don’t run into trouble if we use lambdify()
with common sub-expressions. The problem is that the integration variable should not be extracted as a common sub-expression, otherwise the lambdified scipy.integrate.quad_vec()
expression cannot handle vectorized input.
To keep the function under the integral simple, we substitute angular momentum \(L\) with a definite value before we lambdify:
UnevaluatableIntegral.abs_tolerance = 1e-4
UnevaluatableIntegral.rel_tolerance = 1e-4
integral_s_wave_func = sp.lambdify(
[s, m1, m2, epsilon],
integral_expr.subs(L, 0).doit(),
# integration symbol should not be extracted as common sub-expression!
cse=partial(sp.cse, ignore=[s_prime], list=False),
)
integral_s_wave_func = np.vectorize(integral_s_wave_func)
integral_p_wave_func = sp.lambdify(
[s, m1, m2, epsilon],
integral_expr.subs(L, 1).doit(),
cse=partial(sp.cse, ignore=[s_prime], list=False),
)
integral_p_wave_func = np.vectorize(integral_p_wave_func)
Show code cell source
src = inspect.getsource(integral_s_wave_func.pyfunc)
src = f"""```python
{black.format_str(src, mode=black.FileMode()).strip()}
```"""
Markdown(src)
def _lambdifygenerated(s, m1, m2, epsilon):
x0 = pi ** (-1.0)
x1 = (m1 + m2) ** 2
x2 = -x1
return (
x0
* (s + x2)
* quad_vec(
lambda _Dummy_46: (1 / 16)
* x0
* sqrt((_Dummy_46 + x2) * (_Dummy_46 - (m1 - m2) ** 2) / _Dummy_46)
/ (sqrt(_Dummy_46) * (_Dummy_46 + x2) * (_Dummy_46 - 1j * epsilon - s)),
x1,
PINF,
epsabs=0.0001,
epsrel=0.0001,
limit=50,
)[0]
)
s_values = np.linspace(-0.15, 1.4, num=200)
%time s_wave_values = integral_s_wave_func(s_values, m1_val, m2_val, epsilon=1e-5)
%time p_wave_values = integral_p_wave_func(s_values, m1_val, m2_val, epsilon=1e-5)
CPU times: user 751 ms, sys: 201 ÎĽs, total: 751 ms
Wall time: 752 ms
CPU times: user 266 ms, sys: 0 ns, total: 266 ms
Wall time: 266 ms
Note that the dispersion integral for \(L=0\) indeed reproduces the same shape as in S-wave!
Show code cell source
s_wave_values *= 16 * np.pi
p_wave_values *= 16 * np.pi
s_values = np.linspace(-0.15, 1.4, num=200)
fig, axes = plt.subplots(nrows=2, figsize=(6, 7), sharex=True)
ax1, ax2 = axes
fig.suptitle(f"Symbolic dispersion integrals for $m_1={m1_val:.2f}, m_2={m2_val:.2f}$")
for ax in axes:
ax.axhline(0, linewidth=0.5, c="black")
ax.axvline(s_thr_val, **threshold_style)
ax.set_title(f"$L = {l_val}$")
ax.set_ylabel(R"$16\pi \; \Sigma(s)$")
axes[-1].set_xlabel("$s$ (GeV$^2$)")
ax1.set_title("$S$-wave ($L=0$)")
ax1.plot(s_values, s_wave_values.real, **real_style)
ax1.plot(s_values, s_wave_values.imag, **imag_style)
ax2.set_title("$P$-wave ($L=1$)")
ax2.plot(s_values, p_wave_values.real, **real_style)
ax2.plot(s_values, p_wave_values.imag, **imag_style)
ax1.legend()
fig.tight_layout()
fig.savefig("symbolic-chew-mandelstam.svg")
plt.show()