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.

Hide code cell content
%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):

(1)#\[\Sigma_a(s) = \frac{1}{16\pi^2} \left[ \frac{2q_a}{\sqrt{s}} \log\frac{m_1^2+m_2^2-s+2\sqrt{s}q_a}{2m_1m_2} - \left(m_1^2-m_2^2\right) \left(\frac{1}{s}-\frac{1}{(m_1+m_2)^2}\right) \log\frac{m_1}{m_2} \right]\]

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:

Hide code cell source
z = sp.Symbol("z")
sqrt_expr = ComplexSqrt(z)
Math(aslatex({sqrt_expr: sqrt_expr.get_definition()}))
\[\begin{split}\displaystyle \begin{array}{rcl} \sqrt[\mathrm{c}]{z} &=& \begin{cases} i \sqrt{- z} & \text{for}\: z < 0 \\\sqrt{z} & \text{otherwise} \end{cases} \\ \end{array}\end{split}\]
Hide 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]}))
\[\begin{split}\displaystyle \begin{array}{rcl} \Sigma\left(s\right) &=& \frac{1}{16 \pi^{2}} \left(\frac{2 q_a\left(s\right)}{\sqrt{s}} \log{\left(\frac{m_{1}^{2} + m_{2}^{2} + 2 \sqrt{s} q_a\left(s\right) - s}{2 m_{1} m_{2}} \right)} - \left(m_{1}^{2} - m_{2}^{2}\right) \left(- \frac{1}{\left(m_{1} + m_{2}\right)^{2}} + \frac{1}{s}\right) \log{\left(\frac{m_{1}}{m_{2}} \right)}\right) \\ q_a\left(s\right) &=& \sqrt[\mathrm{c}]{q^2\left(s\right)} \\ \end{array}\end{split}\]

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\):

Hide 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)}")
\[\displaystyle s \to s^{\prime} + 10^{- \epsilon} i\]

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.

Hide code cell source
rho_expr = PhaseSpaceFactorComplex(s, m1, m2)
Math(aslatex({rho_expr: rho_expr.doit(deep=False)}))
\[\begin{split}\displaystyle \begin{array}{rcl} \rho^\mathrm{c}\left(s\right) &=& \frac{\sqrt[\mathrm{c}]{q^2\left(s\right)}}{8 \pi \sqrt{s}} \\ \end{array}\end{split}\]
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.

Hide code cell content
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,
    ),
)
Hide 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()
https://github.com/user-attachments/assets/3454e711-8b7e-4ee4-90a6-d6c30bb9d3f8

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:

(2)#\[ \Sigma_a(s+0i) = \frac{s-s_{\mathrm{thr}_a}}{\pi} \int^\infty_{s_{\mathrm{thr}_a}} \frac{ \rho_a(s')n_a^2(s') }{ (s' - s_{\mathrm{thr}_a})(s'-s-i0) } \mathop{}\!\mathrm{d}s' \]

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\):

Hide 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]}))
\[\begin{split}\displaystyle \begin{array}{rcl} n_a^2\left(s\right) &=& B_{L}^2\left(\frac{q^2\left(s\right)}{q_{0}^{2}}\right) \\ B_{L}^2\left(z\right) &=& \begin{cases} \frac{2 z}{z + 1} & \text{for}\: L = 1 \\\frac{13 z^{2}}{9 z + \left(z - 3\right)^{2}} & \text{for}\: L = 2 \\\frac{277 z^{3}}{z \left(z - 15\right)^{2} + \left(2 z - 5\right) \left(18 z - 45\right)} & \text{for}\: L = 3 \\\frac{12746 z^{4}}{25 z \left(2 z - 21\right)^{2} + \left(z^{2} - 45 z + 105\right)^{2}} & \text{for}\: L = 4 \\\frac{998881 z^{5}}{z^{5} + 15 z^{4} + 315 z^{3} + 6300 z^{2} + 99225 z + 893025} & \text{for}\: L = 5 \\\frac{118394977 z^{6}}{z^{6} + 21 z^{5} + 630 z^{4} + 18900 z^{3} + 496125 z^{2} + 9823275 z + 108056025} & \text{for}\: L = 6 \\\frac{19727003738 z^{7}}{z^{7} + 28 z^{6} + 1134 z^{5} + 47250 z^{4} + 1819125 z^{3} + 58939650 z^{2} + 1404728325 z + 18261468225} & \text{for}\: L = 7 \\\frac{4392846440677 z^{8}}{z^{8} + 36 z^{7} + 1890 z^{6} + 103950 z^{5} + 5457375 z^{4} + 255405150 z^{3} + 9833098275 z^{2} + 273922023375 z + 4108830350625} & \text{for}\: L = 8 \end{cases} \\ \end{array}\end{split}\]

For \(\rho_a\), we use AmpForm’s PhaseSpaceFactor:

Hide code cell source
Math(aslatex({rho_expr: rho_expr.doit(deep=False)}))
\[\begin{split}\displaystyle \begin{array}{rcl} \rho^\mathrm{c}\left(s\right) &=& \frac{\sqrt[\mathrm{c}]{q^2\left(s\right)}}{8 \pi \sqrt{s}} \\ \end{array}\end{split}\]

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
\[\displaystyle \frac{n_a^2\left(s^{\prime}\right) \rho\left(s^{\prime}\right)}{\left(s^{\prime} - \left(m_{1} + m_{2}\right)^{2}\right) \left(- i \epsilon - s + s^{\prime}\right)}\]

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:

s_thr_val = float(s_thr.subs({m1: m1_val, m2: m2_val}))
integral_values = {
    l_val: quad_vec(
        lambda x: integrand_func(
            x,
            s=s_domain,
            L=l_val,
            epsilon=1e-3,
            m1=m1_val,
            m2=m2_val,
            q0=1.0,
        ),
        a=s_thr_val,
        b=np.inf,
    )[0]
    for l_val in tqdm(l_values, desc="Evaluating integrals")
}

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.

sigma = {
    l_val: (s_domain - s_thr_val) / np.pi * integral_values[l_val] for l_val in l_values
}
sigma_scaled = {l_val: 16 * np.pi * sigma[l_val] for l_val in l_values}
Hide 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.

Hide code cell content
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]"
        )
Hide code cell source
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
\[\displaystyle \frac{s - \left(m_{1} + m_{2}\right)^{2}}{\pi} \int\limits_{\left(m_{1} + m_{2}\right)^{2}}^{\infty} \frac{B_{L}^2\left(q^2\left(s^{\prime}\right)\right) \rho\left(s^{\prime}\right)}{\left(s^{\prime} - \left(m_{1} + m_{2}\right)^{2}\right) \left(- i \epsilon - s + s^{\prime}\right)}\, ds^{\prime}\]

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)
Hide 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!

Hide 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()