diff --git a/src/decaylanguage/data/README.rst b/src/decaylanguage/data/README.rst index fead28c7..2b75e60f 100644 --- a/src/decaylanguage/data/README.rst +++ b/src/decaylanguage/data/README.rst @@ -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`` ---------------------------------- diff --git a/src/decaylanguage/data/descriptor.lark b/src/decaylanguage/data/descriptor.lark new file mode 100644 index 00000000..199e2678 --- /dev/null +++ b/src/decaylanguage/data/descriptor.lark @@ -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. +%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* diff --git a/src/decaylanguage/decay/decay.py b/src/decaylanguage/decay/decay.py index cce4e5e9..191cfdcf 100644 --- a/src/decaylanguage/decay/decay.py +++ b/src/decaylanguage/decay/decay.py @@ -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 @@ -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: """ @@ -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): + 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 @@ -816,6 +919,72 @@ def from_dict(cls, decay_chain_dict: DecayChainDict) -> Self: return cls(mother, decay_modes) + @classmethod + def from_string( + cls, + descriptor: str, + *, + grammar_file: str | Path | None = None, + ) -> 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 + Path to a custom Lark grammar file for descriptor parsing. + The default grammar file is ``decaylanguage/data/descriptor.lark``. + + Returns + ------- + 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. @@ -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__}'") diff --git a/tests/data/descriptor_alt.lark b/tests/data/descriptor_alt.lark new file mode 100644 index 00000000..dc470d4f --- /dev/null +++ b/tests/data/descriptor_alt.lark @@ -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_+*'~̄-]*/ + +%import common.WS +%ignore WS diff --git a/tests/decay/test_descriptor.py b/tests/decay/test_descriptor.py index a7b4e592..2aeb5092 100644 --- a/tests/decay/test_descriptor.py +++ b/tests/decay/test_descriptor.py @@ -5,6 +5,9 @@ from __future__ import annotations +import copy +from pathlib import Path + import pytest from decaylanguage import DecayChain, DecayMode @@ -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)", + "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)", + ], +) +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