-
Notifications
You must be signed in to change notification settings - Fork 18
Add support for parsing decay descriptors #573
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6f244c1
88636a1
42faba4
6a0d00c
5c0f238
463783f
0b3a0f1
4e27b8c
c8eb6de
ba90ce9
d97e34d
faf7f65
9447a1b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
| %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* | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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): | ||
|
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 | ||
|
|
@@ -816,6 +919,72 @@ def from_dict(cls, decay_chain_dict: DecayChainDict) -> Self: | |
|
|
||
| return cls(mother, decay_modes) | ||
|
|
||
| @classmethod | ||
| def from_string( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. At some point it would make sense to "synchronise" this
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The pytest What if this function is renamed
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
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 | ||
|
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 | ||
|
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. | ||
|
|
@@ -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__}'") | ||
| 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_+*'~̄-]*/ | ||
|
admorris marked this conversation as resolved.
|
||
|
|
||
| %import common.WS | ||
| %ignore WS | ||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -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)", | ||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
| "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)", | ||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add here |
||||||||
| ], | ||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
| ) | ||||||||
| 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 | ||||||||
Uh oh!
There was an error while loading. Please reload this page.