Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/decaylanguage/data/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ all generic particle decays.
Lark parser grammar definition file for parsing .dec decay files.


``descriptor.lark``
------------------
Lark parser grammar definition file for parsing decay descriptors, e.g. with ``DecayChain.from_string``.
Note that alternative ``.lark`` files can be provided.
See an example in ``tests/data/descriptor_alt.lark`` and ``tests/decay/test_descriptor.py``.


``MintDalitzSpecialParticles.fwf``
----------------------------------

Expand Down
31 changes: 31 additions & 0 deletions src/decaylanguage/data/descriptor.lark
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (c) 2018-2026, Eduardo Rodrigues and Henry Schreiner.
Comment thread
eduardo-rodrigues marked this conversation as resolved.
//
// Distributed under the 3-clause BSD license, see accompanying file LICENSE
// or https://github.com/scikit-hep/decaylanguage for details.
%import common.WS_INLINE

start: _ws decay _ws

decay: particle _ws ARROW _ws daughters

daughters: daughter (_sep daughter)* _ws

daughter: particle
| sub_decay

particle: PHEAD psuffix*
sub_decay: LPAR _ws decay RPAR

// Terminals
ARROW.2: "->"
LPAR: "("
RPAR: ")"

psuffix: PCHUNK | pgroup_paren
pgroup_paren: "(" _ws PCHUNK _ws ")"

PHEAD: /[A-Za-z0-9~][A-Za-z0-9\/\-+*_.'~]*/
PCHUNK: /[A-Za-z0-9\/\-+*_.'~]+/

_sep: WS_INLINE+
_ws: WS_INLINE*
177 changes: 177 additions & 0 deletions src/decaylanguage/decay/decay.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@
from collections.abc import Collection, Iterator, Sequence
from copy import deepcopy
from itertools import product
from pathlib import Path
from typing import Any, NoReturn, TypedDict

from lark import Lark, LarkError, Token, Transformer
from particle import PDGID, ParticleNotFound
from particle.converters import EvtGenName2PDGIDBiMap
from particle.exceptions import MatchingIDNotFound

from .. import data
from .._compat.typing import Self
from ..utils import DescriptorFormat, charge_conjugate_name

Expand Down Expand Up @@ -462,6 +465,18 @@ def __repr__(self) -> str:
def __str__(self) -> str:
return repr(self)

def __eq__(self, other: object) -> bool:
if not isinstance(other, DecayMode):
return NotImplemented
return (
self.bf == other.bf
and self.daughters == other.daughters
and self.metadata == other.metadata
)

def __hash__(self) -> int:
raise TypeError(f"unhashable type: '{type(self).__name__}'")


def _has_no_subdecay(ds: list[Any]) -> bool:
"""
Expand Down Expand Up @@ -752,6 +767,94 @@ def _expand_decay_modes(
return expanded_modes


class _DescriptorTreeToDict(Transformer): # type: ignore[misc]
"""Map a parsed descriptor tree into a ``DecayChainDict`` structure."""

def start(self, items: list[Any]) -> DecayChainDict:
# start: decay
return typing.cast(DecayChainDict, items[0])

def particle(self, items: list[Any]) -> str:
# particle: PHEAD psuffix*
# Rebuild full particle name from head + suffixes
return "".join(self._item_text(i) for i in items if not self._is_ws(i))

def psuffix(self, items: list[Any]) -> str:
# psuffix: PCHUNK | pgroup_paren
# Return the only significant piece
for item in items:
if not self._is_ws(item):
return self._item_text(item)
return ""

def pgroup_paren(self, items: list[Any]) -> str:
# pgroup_paren: "(" _ws PCHUNK _ws ")"
inner = "".join(
self._item_text(i)
for i in items
if not self._is_ws(i) and not self._is_paren_token(i)
)
return f"({inner})"

def daughter(self, items: list[Any]) -> str | DecayChainDict:
# daughter: particle | sub_decay
return typing.cast(str | DecayChainDict, items[0])

def daughters(self, items: list[Any]) -> list[str | DecayChainDict]:
# daughters: daughter+
return [
item
for item in items
if not isinstance(item, Token) and isinstance(item, (str, dict))
]

def sub_decay(self, items: list[Any]) -> DecayChainDict:
# sub_decay: LPAR decay RPAR
for item in items:
if isinstance(item, dict):
Comment thread
eduardo-rodrigues marked this conversation as resolved.
return item
raise ValueError("Malformed sub-decay in parse tree")

def decay(self, items: list[Any]) -> DecayChainDict:
# decay: particle ARROW daughters
mother: str | None = None
daughters: list[str | DecayChainDict] | None = None

for item in items:
if isinstance(item, Token):
# Ignore punctuation and whitespace tokens.
continue
if isinstance(item, str) and mother is None:
mother = item
elif isinstance(item, list):
daughters = item

if not mother or not daughters:
raise ValueError(
f"Malformed decay in parse tree. Parsing result: {mother=}, {daughters=}"
)

mode: DecayModeDict = {
"bf": 1.0,
"fs": daughters,
"model": "",
"model_params": "",
}
return {mother: [mode]}

@staticmethod
def _is_ws(item: Any) -> bool:
return isinstance(item, Token) and item.type == "WS_INLINE"

@staticmethod
def _is_paren_token(item: Any) -> bool:
return isinstance(item, Token) and item.type in {"LPAR", "RPAR"}

@staticmethod
def _item_text(item: Any) -> str:
return str(item.value) if isinstance(item, Token) else str(item)


class DecayChain:
"""
Class holding a particle (single) decay chain, which is typically a top-level decay
Expand Down Expand Up @@ -816,6 +919,72 @@ def from_dict(cls, decay_chain_dict: DecayChainDict) -> Self:

return cls(mother, decay_modes)

@classmethod
def from_string(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At some point it would make sense to "synchronise" this from_string function with the existing to_string one, since they should effectively be the "mirror of each other". Else one would name this function to from_descriptor. WDYT?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pytest test_from_string_to_string demonstrates they mirror eachother in the specific case of using the default grammar.

What if this function is renamed from_descriptor, then from_string just invokes from_descriptor with the default grammar?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that sounds good to me.

cls,
descriptor: str,
*,
grammar_file: str | Path | None = None,
Comment thread
eduardo-rodrigues marked this conversation as resolved.
) -> Self:
"""
Construct a ``DecayChain`` by parsing a descriptor string.

Parameters
----------
descriptor : str
The decay descriptor string, e.g.
``"D*+ -> (D0 -> K+ pi-) pi+"``.
grammar_file : str or Path, optional
Comment thread
admorris marked this conversation as resolved.
Path to a custom Lark grammar file for descriptor parsing.
The default grammar file is ``decaylanguage/data/descriptor.lark``.

Returns
Comment thread
admorris marked this conversation as resolved.
-------
DecayChain

Raises
------
ValueError
If the descriptor string is malformed.

Examples
--------
>>> dc = DecayChain.from_string("D0 -> (K_S0 -> pi+ pi-) (pi0 -> gamma gamma)")
>>> dc.mother
'D0'
>>> len(dc.decays)
3
"""

# Load the grammar
if grammar_file is None:
# Use the default descriptor grammar from the data package
grammar = data.basepath.joinpath("descriptor.lark").read_text(
encoding="utf-8"
)
else:
# Use custom grammar file
if isinstance(grammar_file, str):
grammar_file = Path(grammar_file)
grammar = grammar_file.read_text(encoding="utf-8")

# Parse the descriptor
try:
parser = Lark(grammar, parser="lalr", transformer=None, lexer="auto")
tree = parser.parse(descriptor)
except LarkError as e:
raise ValueError(f"Failed to parse descriptor '{descriptor}': {e}") from e

# Transform to a DecayChainDict
try:
decay_chain_dict = _DescriptorTreeToDict().transform(tree)
except Exception as e:
raise ValueError(
f"Failed to transform parse tree into decay chain: {e}"
) from e

return cls.from_dict(decay_chain_dict)

def top_level_decay(self) -> DecayMode:
"""
Return the top-level decay as a ``DecayMode`` instance.
Expand Down Expand Up @@ -1064,3 +1233,11 @@ def __repr__(self) -> str:

def __str__(self) -> str:
return repr(self)

def __eq__(self, other: object) -> bool:
if not isinstance(other, DecayChain):
return NotImplemented
return self.mother == other.mother and self.decays == other.decays

def __hash__(self) -> int:
raise TypeError(f"unhashable type: '{type(self).__name__}'")
31 changes: 31 additions & 0 deletions tests/data/descriptor_alt.lark
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (c) 2018-2026, Eduardo Rodrigues and Henry Schreiner.
//
// Distributed under the 3-clause BSD license, see accompanying file LICENSE
// or https://github.com/scikit-hep/decaylanguage for details.

// Alternative decay descriptor parsing grammar demonstrating different parentheses and arrows

start: decay

decay: particle ARROW daughters

daughters: daughter+

daughter: particle
| sub_decay

particle: PARTICLE

sub_decay: LPAR decay RPAR

// Terminals
ARROW: "=>"
LPAR: "{"
RPAR: "}"

// Particle names start with alphanumeric/underscore and can then include
// common descriptor suffix symbols such as +, -, *, ', and ~.
PARTICLE: /[A-Za-z0-9_][A-Za-z0-9_+*'~̄-]*/
Comment thread
admorris marked this conversation as resolved.

%import common.WS
%ignore WS
80 changes: 66 additions & 14 deletions tests/decay/test_descriptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

from __future__ import annotations

import copy
from pathlib import Path

import pytest

from decaylanguage import DecayChain, DecayMode
Expand All @@ -21,27 +24,76 @@
dm9b = DecayMode(0.154, "pi+ pi- pi0") # phi


EXAMPLES = [
(
DecayChain("D0", {"D0": dm2, "K_S0": dm3, "pi0": dm4}),
"D0 -> (K_S0 -> pi+ pi-) (pi0 -> gamma gamma)",
),
(
DecayChain("D*+", {"D*+": dm1, "D0": dm2, "K_S0": dm3, "pi0": dm4}),
"D*+ -> (D0 -> (K_S0 -> pi+ pi-) (pi0 -> gamma gamma)) pi+",
),
(
DecayChain("B0", {"B0": dm5, "D-": dm6, "tau+": dm7}),
"B0 -> (D- -> K+ pi- pi-) (tau+ -> anti-nu_tau pi+ pi+ pi-) nu_tau",
),
(
DecayChain("B_s0", {"B_s0": dm8, "phi": dm9a, "phi'": dm9b}),
"B_s0 -> (phi -> K+ K-) (phi' -> pi+ pi- pi0)",
),
]


@pytest.mark.parametrize(("dc", "expected"), EXAMPLES)
def test_descriptor_formatter(dc: DecayChain, expected: str):
descriptor = dc.to_string()
assert descriptor == expected


@pytest.mark.parametrize(("expected", "desc"), EXAMPLES)
def test_descriptor_parser(expected: DecayChain, desc: str):
result = DecayChain.from_string(desc)
# Branching fractions are not deduced from the descriptor
# We set them explicitly to 1.0 as a known difference
expected = copy.deepcopy(expected)
for mode in expected.decays.values():
mode.bf = 1.0
assert result == expected


@pytest.mark.parametrize(
("dc", "expected"),
("conventional", "alternative"),
[
(
DecayChain("D0", {"D0": dm2, "K_S0": dm3, "pi0": dm4}),
"D0 -> (K_S0 -> pi+ pi-) (pi0 -> gamma gamma)",
),
(
DecayChain("D*+", {"D*+": dm1, "D0": dm2, "K_S0": dm3, "pi0": dm4}),
"D*+ -> (D0 -> (K_S0 -> pi+ pi-) (pi0 -> gamma gamma)) pi+",
"D*+ => {D0 => {K_S0 => pi+ pi-} {pi0 => gamma gamma}} pi+",
),
(
DecayChain("B0", {"B0": dm5, "D-": dm6, "tau+": dm7}),
"B0 -> (D- -> K+ pi- pi-) (tau+ -> anti-nu_tau pi+ pi+ pi-) nu_tau",
),
(
DecayChain("B_s0", {"B_s0": dm8, "phi": dm9a, "phi'": dm9b}),
"B_s0 -> (phi -> K+ K-) (phi' -> pi+ pi- pi0)",
"B0 => {D- => K+ pi- pi-} {tau+ => anti-nu_tau pi+ pi+ pi-} nu_tau",
),
],
)
def test_descriptor(dc: DecayChain, expected: str):
descriptor = dc.to_string()
assert descriptor == expected
def test_descriptor_parser_alternative(conventional: str, alternative: str):
result = DecayChain.from_string(
alternative,
grammar_file=Path(__file__).parent.parent / "data" / "descriptor_alt.lark",
)
expected = DecayChain.from_string(conventional)
assert result == expected


@pytest.mark.parametrize(
"descriptor",
[
"D0 -> (K_S0 -> pi+ pi-) (pi0 -> gamma gamma)",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"D0 -> (K_S0 -> pi+ pi-) (pi0 -> gamma gamma)",
"D0 -> K- pi+",
"D0 -> (K_S0 -> pi+ pi-) (pi0 -> gamma gamma)",

"D*+ -> (D0 -> (K_S0 -> pi+ pi-) (pi0 -> gamma gamma)) pi+",
"B0 -> (D- -> K+ pi- pi-) (tau+ -> anti-nu_tau pi+ pi+ pi-) nu_tau",
"B_s0 -> (phi -> K+ K-) (phi' -> pi+ pi- pi0)",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add here "B_s0 -> (phi -> K+ K-) (phi -> K+ K-)" as per my comment above on the limitation being a thing of the past.

],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
],
"B_s0 -> (phi -> K+ K-) (phi -> K+ K-)",
],

)
def test_from_string_to_string(descriptor: str):
dc = DecayChain.from_string(descriptor)
# Verify that we can round-trip: string -> chain -> string
result = dc.to_string()
assert result == descriptor