Skip to content

Commit e68a987

Browse files
authored
FIX: correct serialized intensity (#209)
* DOC: remove obsolete serialization validation warning * DX: assert full intensity checksum in notebook * DX: test parity recoupling and form factor conventions * FIX: correct serialized chain amplitude construction * FIX: normalize serialized Blatt-Weisskopf form factors * FIX: use per-state reference subsystems for alignment
1 parent 9fbd596 commit e68a987

5 files changed

Lines changed: 172 additions & 38 deletions

File tree

docs/serialization.ipynb

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -773,9 +773,7 @@
773773
"cell_type": "markdown",
774774
"metadata": {},
775775
"source": [
776-
":::{error}\n",
777-
"The following serves as a numerical check on whether the amplitude model has been deserialized correctly. For now, this is not the case, see [ComPWA/ampform-dpd#133](https://github.com/ComPWA/ampform-dpd/issues/133) for updates.\n",
778-
":::"
776+
"The following checks whether the full amplitude model has been deserialized correctly."
779777
]
780778
},
781779
{
@@ -819,15 +817,49 @@
819817
},
820818
"outputs": [],
821819
"source": [
820+
"theta31 = next(symbol for symbol in MODEL.variables if str(symbol) == \"theta_31\")\n",
821+
"cos_theta31_expr = sp.cos(MODEL.variables[theta31]).xreplace(MODEL.parameter_defaults)\n",
822+
"sigma1 = next(s for s in cos_theta31_expr.free_symbols if str(s) == \"sigma1\")\n",
823+
"sigma2 = next(s for s in cos_theta31_expr.free_symbols if str(s) == \"sigma2\")\n",
824+
"z = sp.Symbol(\"z\", real=True)\n",
825+
"s1_expr = sp.solve(sp.Eq(cos_theta31_expr, z), sigma1)[0]\n",
822826
"array = []\n",
823827
"for point_name, expected in checksums.items():\n",
824828
" parameters = checksum_points[point_name]\n",
825-
" s1 = parameters[\"m_31_2\"] ** 2\n",
826829
" s2 = parameters[\"m_31\"] ** 2\n",
827-
" computed = intensity_funcs[3]({\"sigma1\": s1, \"sigma2\": s2})\n",
830+
" s1 = s1_expr.xreplace({z: parameters[\"cos_theta_31\"], sigma2: s2})\n",
831+
" computed = intensity_funcs[3]({\"sigma1\": float(s1), \"sigma2\": s2})\n",
828832
" status = label_diff(expected - computed)\n",
829833
" array.append((INTENSITY_NAME, point_name, computed, expected, status))\n",
830-
"pd.DataFrame(array, columns=[\"Distribution\", \"Point\", \"Computed\", \"Expected\", \"Status\"])"
834+
"intensity_validation = pd.DataFrame(\n",
835+
" array,\n",
836+
" columns=[\"Distribution\", \"Point\", \"Computed\", \"Expected\", \"Status\"],\n",
837+
")\n",
838+
"intensity_validation"
839+
]
840+
},
841+
{
842+
"cell_type": "code",
843+
"execution_count": null,
844+
"metadata": {
845+
"jupyter": {
846+
"source_hidden": true
847+
},
848+
"mystnb": {
849+
"code_prompt_show": "Assert that the intensity matches the reference values"
850+
},
851+
"tags": [
852+
"hide-input"
853+
]
854+
},
855+
"outputs": [],
856+
"source": [
857+
"np.testing.assert_allclose(\n",
858+
" intensity_validation[\"Computed\"].astype(float).to_numpy(),\n",
859+
" intensity_validation[\"Expected\"].to_numpy(),\n",
860+
" atol=1e-10,\n",
861+
" rtol=0,\n",
862+
")"
831863
]
832864
},
833865
{
@@ -944,12 +976,8 @@
944976
"jupyter": {
945977
"source_hidden": true
946978
},
947-
"mystnb": {
948-
"code_prompt_show": "Dalitz plot is not yet correct"
949-
},
950979
"tags": [
951-
"hide-input",
952-
"hide-output"
980+
"hide-input"
953981
]
954982
},
955983
"outputs": [],

src/ampform_dpd/io/serialization/amplitude.py

Lines changed: 81 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,25 @@
4242
ParityVertex,
4343
get_decay_chains,
4444
get_distribution_def,
45-
get_reference_topology,
4645
)
4746
from ampform_dpd.spin import create_spin_range
4847

4948
if TYPE_CHECKING:
5049
from collections.abc import Callable
5150

52-
from ampform_dpd.decay import FinalStateID
51+
from ampform_dpd.decay import FinalStateID, State, StateID
5352
from ampform_dpd.io.serialization.format import ModelDefinition
5453

54+
_REFERENCE_SUBSYSTEMS: dict[StateID, FinalStateID] = {0: 1, 1: 1, 2: 2, 3: 3}
55+
"""Reference subsystem to use for the alignment rotation of each state.
56+
57+
Each final state is aligned with respect to its own subsystem and the initial state with
58+
respect to subsystem 1. This is the convention of `ThreeBodyDecays.jl
59+
<https://github.com/mmikhasenko/ThreeBodyDecays.jl>`_, which the serialization format
60+
follows, and it is used instead of the single reference topology that the serialized
61+
model declares.
62+
"""
63+
5564

5665
def formulate( # ruff: ignore[too-many-locals]
5766
model: ModelDefinition,
@@ -124,36 +133,46 @@ def formulate_chain_amplitude( # ruff: ignore[too-many-locals, too-many-positio
124133
to_latex: Callable[[str], str] = identity_function,
125134
additional_builders: dict[str, PropagatorDynamicsBuilder] | None = None,
126135
) -> dict[sp.Basic, complex | float | sp.Expr]:
136+
r"""Formulate the amplitude for one decay chain of a serialized model.
137+
138+
This is the serialization counterpart of
139+
`.DalitzPlotDecompositionBuilder.formulate_subsystem_amplitude`: the couplings and
140+
dynamics are read from the model definition instead of being generated, but the
141+
phase conventions, the Kronecker delta over the production helicities, and the sum
142+
over the resonance helicity :math:`\lambda_R` are the same. The two implementations
143+
have to be kept in sync.
144+
"""
127145
chain_defs = get_decay_chains(model)
128146
chain_definition = chain_defs[chain_idx]
129-
# -----------------------
130147
dynamics = formulate_dynamics(
131148
chain_definition, model, to_latex, additional_builders
132149
)
133150
for vertex in chain_definition["vertices"]:
134151
dynamics *= formulate_form_factor(vertex, model)
135-
# -----------------------
136152
weight, weight_val = _get_weight(chain_definition, to_latex)
137-
# -----------------------
138-
(i, λi_val), (j, λj_val) = _get_decay_product_helicities(chain_definition)
153+
i, j = _get_decay_product_ids(chain_definition)
139154
θij, θij_expr = formulate_scattering_angle(i, j)
140155
jR = sp.Rational(chain_definition["propagators"][0]["spin"]) # ruff: ignore[non-lowercase-variable-in-function]
141-
R_node, λR_val = _get_resonance_helicity(chain_definition) # ruff: ignore[non-lowercase-variable-in-function]
142-
λR = _get_helicity_symbol(R_node)
143-
# -----------------------
156+
λR = _get_helicity_symbol(_get_resonance_node(chain_definition))
144157
A = _generate_amplitude_index_bases()
145-
subsystem_id = get_spectator_id(chain_definition["topology"])
158+
spectator_id = get_spectator_id(chain_definition["topology"])
159+
states = get_states(model)
160+
helicities = (λ0, λ1, λ2, λ3)
146161
h_prod = formulate_recoupling(model, chain_idx, vertex_idx=0)
147162
h_dec = formulate_recoupling(model, chain_idx, vertex_idx=1)
148-
amplitude_expression = (
163+
chain_amplitude = (
149164
weight
165+
* sp.sqrt(2 * jR + 1)
166+
* _formulate_phase_factor(states[spectator_id], helicities[spectator_id])
167+
* _formulate_phase_factor(states[j], helicities[j])
168+
* δ(λ0, λR - helicities[spectator_id])
150169
* h_prod
151170
* h_dec
152-
* Wigner.d(jR, λR, λi_val - λj_val, θij)
171+
* Wigner.d(jR, λR, helicities[i] - helicities[j], θij)
153172
* dynamics.expression
154173
)
155-
amplitude_expression = amplitude_expression.subs({λR: λR_val})
156-
amplitude_symbol = A[subsystem_id][λ0, λ1, λ2, λ3]
174+
amplitude_expression = PoolSum(chain_amplitude, (λR, create_spin_range(jR)))
175+
amplitude_symbol = A[spectator_id][λ0, λ1, λ2, λ3]
157176
return {
158177
amplitude_symbol: amplitude_expression,
159178
weight: weight_val,
@@ -162,6 +181,34 @@ def formulate_chain_amplitude( # ruff: ignore[too-many-locals, too-many-positio
162181
}
163182

164183

184+
def _formulate_phase_factor(state: State, helicity: sp.Rational | sp.Symbol) -> sp.Expr:
185+
r"""Formulate the :math:`(-1)^{j-\lambda}` phase factor of a state."""
186+
return (-1) ** (state.spin - helicity)
187+
188+
189+
def _get_decay_product_ids(
190+
chain_definition: DecayChain,
191+
) -> tuple[FinalStateID, FinalStateID]:
192+
"""Get the IDs of the two decay products, ignoring their serialized helicities.
193+
194+
The helicity values from `._get_decay_product_helicities` are not substituted into
195+
the chain amplitude: it is summed over all allowed helicities instead.
196+
"""
197+
(i, _), (j, _) = _get_decay_product_helicities(chain_definition)
198+
return cast("FinalStateID", i), cast("FinalStateID", j)
199+
200+
201+
def _get_resonance_node(
202+
chain_definition: DecayChain,
203+
) -> tuple[FinalStateID, FinalStateID]:
204+
"""Get the node of the resonance, ignoring its serialized helicity.
205+
206+
See `._get_decay_product_ids` for why the helicity value is discarded.
207+
"""
208+
node, _ = _get_resonance_helicity(chain_definition)
209+
return node
210+
211+
165212
def _get_decay_product_helicities(
166213
chain_definition: DecayChain,
167214
) -> tuple[tuple[int, sp.Rational], tuple[int, sp.Rational]]:
@@ -187,28 +234,38 @@ def formulate_aligned_amplitude(
187234
λ2: sp.Rational | sp.Symbol,
188235
λ3: sp.Rational | sp.Symbol,
189236
) -> tuple[PoolSum, dict[sp.Symbol, sp.Expr]]:
190-
reference_topology = get_reference_topology(model)
191-
reference_subsystem = get_spectator_id(reference_topology)
192-
wigner_generator = _AlignmentWignerGenerator(reference_subsystem)
237+
generators = {
238+
subsystem_id: _AlignmentWignerGenerator(subsystem_id)
239+
for subsystem_id in sorted(set(_REFERENCE_SUBSYSTEMS.values()))
240+
}
241+
wigner_generators = {
242+
rotated_state: generators[subsystem_id]
243+
for rotated_state, subsystem_id in _REFERENCE_SUBSYSTEMS.items()
244+
}
193245
_λ0, _λ1, _λ2, _λ3 = sp.symbols(R"\lambda_(:4)^{\prime}", rational=True)
194246
states = get_states(model)
195247
j0, j1, j2, j3 = (states[i].spin for i in sorted(states))
196248
A = _generate_amplitude_index_bases()
197249
amp_expr = PoolSum(
198250
sum(
199251
A[k][_λ0, _λ1, _λ2, _λ3]
200-
* wigner_generator(j0, λ0, _λ0, rotated_state=0, aligned_subsystem=k)
201-
* wigner_generator(j1, _λ1, λ1, rotated_state=1, aligned_subsystem=k)
202-
* wigner_generator(j2, _λ2, λ2, rotated_state=2, aligned_subsystem=k)
203-
* wigner_generator(j3, _λ3, λ3, rotated_state=3, aligned_subsystem=k)
252+
* wigner_generators[0](j0, λ0, _λ0, rotated_state=0, aligned_subsystem=k)
253+
* wigner_generators[1](j1, _λ1, λ1, rotated_state=1, aligned_subsystem=k)
254+
* wigner_generators[2](j2, _λ2, λ2, rotated_state=2, aligned_subsystem=k)
255+
* wigner_generators[3](j3, _λ3, λ3, rotated_state=3, aligned_subsystem=k)
204256
for k in get_existing_subsystem_ids(model)
205257
),
206258
(_λ0, create_spin_range(j0)),
207259
(_λ1, create_spin_range(j1)),
208260
(_λ2, create_spin_range(j2)),
209261
(_λ3, create_spin_range(j3)),
210262
)
211-
return amp_expr, wigner_generator.angle_definitions
263+
angle_definitions = {
264+
symbol: expression
265+
for generator in generators.values()
266+
for symbol, expression in generator.angle_definitions.items()
267+
}
268+
return amp_expr, angle_definitions
212269

213270

214271
def _get_weight(
@@ -386,6 +443,8 @@ class ParityRecoupling(sp.Expr):
386443

387444
def evaluate(self) -> sp.Expr:
388445
λa, λb, λa0, λb0, f = self.args
446+
if λa0 == 0 and λb0 == 0:
447+
return δ(λa, λa0) * δ(λb, λb0)
389448
return δ(λa, λa0) * δ(λb, λb0) + f * δ(λa, -λa0) * δ(λb, -λb0) # ty: ignore[unsupported-operator]
390449

391450

src/ampform_dpd/io/serialization/dynamics.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing import TYPE_CHECKING, Protocol, TypeVar, cast
55

66
import sympy as sp
7-
from ampform.dynamics.form_factor import FormFactor
7+
from ampform.dynamics.form_factor import FormFactor, SphericalHankel1
88
from sympy.parsing.sympy_parser import parse_expr
99

1010
from ampform_dpd import DefinedExpression
@@ -82,16 +82,21 @@ def formulate_form_factor(vertex: Vertex, model: ModelDefinition) -> DefinedExpr
8282
function_type = function_definition["type"]
8383
if function_type == "BlattWeisskopf":
8484
node = vertex["node"]
85-
s = to_mandelstam_symbol(node)
86-
m1, m2 = (to_mass_symbol(i) for i in node)
8785
if all(isinstance(i, int) for i in node):
86+
s = to_mandelstam_symbol(node)
87+
m1, m2 = (to_mass_symbol(i) for i in node)
8888
meson_radius = sp.Symbol(R"R_\mathrm{res}", nonnegative=True)
8989
else:
90+
parent_mass = to_mandelstam_symbol(node)
91+
isobar_invariant, m2 = (to_mass_symbol(i) for i in node)
92+
s = parent_mass**2
93+
m1 = sp.sqrt(isobar_invariant)
9094
initial_state = get_initial_state(model)
9195
meson_radius = sp.Symbol(f"R_{{{initial_state.latex}}}", nonnegative=True)
9296
angular_momentum = int(function_definition["l"])
9397
return DefinedExpression(
94-
expression=FormFactor(s, m1, m2, angular_momentum, meson_radius), # ty: ignore[invalid-argument-type]
98+
expression=FormFactor(s, m1, m2, angular_momentum, meson_radius) # ty: ignore[invalid-argument-type]
99+
/ _blatt_weisskopf_normalization(angular_momentum),
95100
parameters={
96101
meson_radius: function_definition["radius"],
97102
},
@@ -100,6 +105,24 @@ def formulate_form_factor(vertex: Vertex, model: ModelDefinition) -> DefinedExpr
100105
raise NotImplementedError(msg)
101106

102107

108+
def _blatt_weisskopf_normalization(angular_momentum: int) -> sp.Expr:
109+
r"""Undo the normalization of AmpForm's `~ampform.dynamics.form_factor.FormFactor`.
110+
111+
AmpForm normalizes its Blatt--Weisskopf factor to one at :math:`z=1`, whereas the
112+
serialization format uses the unnormalized convention, so the form factor has to be
113+
divided by this value.
114+
115+
>>> _blatt_weisskopf_normalization(0)
116+
1
117+
>>> _blatt_weisskopf_normalization(1)
118+
sqrt(2)
119+
>>> _blatt_weisskopf_normalization(2)
120+
sqrt(13)
121+
"""
122+
hankel = SphericalHankel1(sp.Integer(angular_momentum), sp.Integer(1))
123+
return sp.Abs(hankel.doit())
124+
125+
103126
def formulate_generic_function(
104127
propagator: Propagator, resonance: str, model: ModelDefinition
105128
) -> DefinedExpression:

tests/io_serialization/test_amplitude.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import sympy as sp
77

88
from ampform_dpd.io.serialization.amplitude import (
9+
ParityRecoupling,
910
_get_decay_product_helicities,
1011
_get_final_state_helicities,
1112
_get_resonance_helicity,
@@ -60,3 +61,8 @@ def test_get_weight(model_definition: ModelDefinition):
6061
symbol, value = _get_weight(chain_defs[0])
6162
assert symbol.name == R"c^{L1405[1/2]}_{\frac{1}{2}, 0, 0}"
6263
assert value == pytest.approx(7.38649400481717 + 1.971018433257411j)
64+
65+
66+
def test_parity_recoupling_does_not_duplicate_zero_helicities():
67+
recoupling = ParityRecoupling(λa=0, λb=0, λa0=0, λb0=0, f=1) # ty: ignore[unknown-argument]
68+
assert recoupling.doit() == 1

tests/io_serialization/test_dynamics.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22

33
import pytest
44
import sympy as sp
5+
from ampform.dynamics.form_factor import FormFactor
56

6-
from ampform_dpd.io.serialization.dynamics import formulate_dynamics
7+
from ampform_dpd.io.serialization.dynamics import (
8+
formulate_dynamics,
9+
formulate_form_factor,
10+
)
711
from ampform_dpd.io.serialization.format import (
812
DecayChain,
913
GenericFunctionDefinition,
@@ -52,3 +56,17 @@ def test_formulate_bugg_lineshapes(model_definition: ModelDefinition, chain_id:
5256
dynamics = formulate_dynamics(chain, model_definition)
5357

5458
assert dynamics.expression.free_symbols == {sp.Symbol("sigma1", nonnegative=True)}
59+
60+
61+
def test_formulate_form_factor_uses_serialized_normalization(
62+
model_definition: ModelDefinition,
63+
):
64+
vertex = get_decay_chains(model_definition)[2]["vertices"][0]
65+
form_factor = formulate_form_factor(vertex, model_definition)
66+
assert sp.sqrt(2) * form_factor.expression == FormFactor(
67+
s=sp.Symbol("m0", nonnegative=True) ** 2, # ty: ignore[unknown-argument]
68+
m1=sp.sqrt(sp.Symbol("sigma2", nonnegative=True)), # ty: ignore[unknown-argument]
69+
m2=sp.Symbol("m2", nonnegative=True), # ty: ignore[unknown-argument]
70+
angular_momentum=1, # ty: ignore[unknown-argument]
71+
meson_radius=sp.Symbol("R_{Lc}", nonnegative=True), # ty: ignore[unknown-argument]
72+
)

0 commit comments

Comments
 (0)