From 0a9ae6a40fc358029eff736ba611228bab9a96ab Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:13:54 +0200 Subject: [PATCH 01/27] ENH: add pdg package dependency and update cspell words --- .cspell.json | 3 +++ pyproject.toml | 1 + uv.lock | 15 +++++++++++++++ 3 files changed, 19 insertions(+) diff --git a/.cspell.json b/.cspell.json index 38d8c4a6..7a901fc6 100644 --- a/.cspell.json +++ b/.cspell.json @@ -88,6 +88,8 @@ "macos", "mathrm", "maxdepth", + "mcid", + "mcids", "meijerg", "mimetype", "modindex", @@ -161,6 +163,7 @@ "determinator", "determinators", "docstrings", + "eigenstates", "façade", "fermionic", "flatté", diff --git a/pyproject.toml b/pyproject.toml index 8ee11c44..a02b3776 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ "attrs >=20.1.0", # on_setattr and https://www.attrs.org/en/stable/api.html#next-gen "frozendict", "jsonschema", + "pdg >=2026.0,<2027", "particle", "python-constraint2", "tqdm >=4.24.0", # autonotebook diff --git a/uv.lock b/uv.lock index 0465e062..8a6c2f3e 100644 --- a/uv.lock +++ b/uv.lock @@ -1954,6 +1954,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "pdg" +version = "2026.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/07/d582d4ae647b301ff499a04db4cdaa85681c3bc86c1739b1a4f5c765f4af/pdg-2026.0.tar.gz", hash = "sha256:15b5c2971448608b0f78796c33c16ca3ccf0d593028803702f6e66cfa62e565f", size = 8244310, upload-time = "2026-06-01T22:10:28.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/9d/d37fbde4b234bad0e788982f550e53c06439a386452a42436e928ecae115/pdg-2026.0-py3-none-any.whl", hash = "sha256:681e11f8c9a5accb1cb41ccb87bf7efd398adec8ea39ddb894630b17b45c8e8a", size = 8289676, upload-time = "2026-06-01T22:10:25.672Z" }, +] + [[package]] name = "pexpect" version = "4.9.0" @@ -2584,6 +2597,7 @@ dependencies = [ { name = "frozendict" }, { name = "jsonschema" }, { name = "particle" }, + { name = "pdg" }, { name = "python-constraint2", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "python-constraint2", version = "2.7.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pyyaml" }, @@ -2709,6 +2723,7 @@ requires-dist = [ { name = "graphviz", marker = "extra == 'viz'" }, { name = "jsonschema" }, { name = "particle" }, + { name = "pdg", specifier = ">=2026.0,<2027" }, { name = "python-constraint2" }, { name = "pyyaml" }, { name = "tqdm", specifier = ">=4.24.0" }, From e2212d9cbfebe54d582a97e996544e4b764c0602 Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:14:32 +0200 Subject: [PATCH 02/27] ENH: implement PDG particle loading and conversion functionality --- src/qrules/_pdg.py | 270 +++++++++++++++++++++++++++++++++ tests/unit/test_pdg_adapter.py | 114 ++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 src/qrules/_pdg.py create mode 100644 tests/unit/test_pdg_adapter.py diff --git a/src/qrules/_pdg.py b/src/qrules/_pdg.py new file mode 100644 index 00000000..0e22efc3 --- /dev/null +++ b/src/qrules/_pdg.py @@ -0,0 +1,270 @@ +"""Convert records from the official PDG API to QRules particles.""" + +from __future__ import annotations + +from fractions import Fraction +from typing import TYPE_CHECKING + +import pdg + +from qrules.particle import Particle, ParticleCollection, Spin +from qrules.quantum_numbers import Parity + +if TYPE_CHECKING: + from collections.abc import Iterator + + from pdg.api import PdgApi + from pdg.particle import PdgParticle + + +# These states are also excluded by the existing Scikit-HEP based loader. The neutral +# kaon mass eigenstates do not have a definite flavor, while the B(s2) entries have +# inconsistent isospin information for QRules' particle model. +_SKIPPED_MC_IDS = {-535, 130, 310, 535} + + +class _UnsupportedParticleError(ValueError): + """Raised when a PDG entry cannot be represented by QRules.""" + + +def load_pdg() -> ParticleCollection: + """Load particle definitions from the official PDG database.""" + api = pdg.connect() + particles = ParticleCollection() + for source_particle in _iter_particles(api): + try: + particle = _convert_particle(source_particle) + except _UnsupportedParticleError: + continue + particles.add(particle) + return particles + + +def _iter_particles(api: PdgApi) -> Iterator[PdgParticle]: + """Iterate over unique, charge-specific particles with a Monte Carlo ID.""" + mc_ids: set[int] = set() + for particle_group in api.get_particles(): + source_particles = ( + particle_group if isinstance(particle_group, list) else [particle_group] + ) + for source_particle in source_particles: + mcid = source_particle.mcid + if mcid is not None: + mc_ids.add(mcid) + for mcid in sorted(mc_ids): + yield api.get_particle_by_mcid(mcid) + + +def _convert_particle(source: PdgParticle) -> Particle: + mcid = source.mcid + if mcid is None: + msg = f"Particle {source.name} has no Monte Carlo ID" + raise _UnsupportedParticleError(msg) + if mcid in _SKIPPED_MC_IDS or abs(mcid) >= 1_000_000_000: + msg = f"Particle {source.name} is not supported" + raise _UnsupportedParticleError(msg) + + charge = _to_integer_charge(source.charge) + spin = _to_spin( + source.quantum_J, + mcid, + is_hadron=source.is_baryon or source.is_meson, + ) + mass = _to_mass(source) + width = _to_width(source) + + strangeness, charmness, bottomness, topness = _flavor_quantum_numbers( + mcid, + is_baryon=source.is_baryon, + is_meson=source.is_meson, + ) + baryon_number = _baryon_number(mcid, is_baryon=source.is_baryon) + lepton_numbers = _lepton_numbers(mcid, is_lepton=source.is_lepton) + isospin = _to_isospin( + source.quantum_I, + charge=charge, + baryon_number=baryon_number, + flavor_numbers=(strangeness, charmness, bottomness, topness), + ) + parity = _to_parity(source.quantum_P) + if source.is_lepton: + # QRules convention: fermions and antifermions have opposite intrinsic parity. + parity = Parity(+1 if mcid > 0 else -1) + c_parity = _to_parity(source.quantum_C) if source.self_conjugate else None + + return Particle( + name=source.name, + pid=mcid, + spin=spin, + mass=mass, + width=width, + charge=charge, + isospin=isospin, + strangeness=strangeness, + charmness=charmness, + bottomness=bottomness, + topness=topness, + baryon_number=baryon_number, + electron_lepton_number=lepton_numbers[0], + muon_lepton_number=lepton_numbers[1], + tau_lepton_number=lepton_numbers[2], + parity=parity, + c_parity=c_parity, + g_parity=_to_parity(source.quantum_G), + ) + + +def _to_integer_charge(value: float) -> int: + if not float(value).is_integer(): + msg = f"QRules does not support fractional charge {value}" + raise _UnsupportedParticleError(msg) + return int(value) + + +def _to_spin(value: str | None, mcid: int, *, is_hadron: bool) -> Fraction: + spin = _to_fraction(value) + if spin is not None: + return spin + + # For hadrons, the final digit of an MC ID is 2J+1. This preserves definite + # spins encoded in IDs where the current RPP text reports a range or "?". + spin_code = abs(mcid) % 10 + if is_hadron and spin_code > 0: + return Fraction(spin_code - 1, 2) + msg = f"Cannot determine spin for MC ID {mcid} from {value!r}" + raise _UnsupportedParticleError(msg) + + +def _to_mass(source: PdgParticle) -> float: + for candidate in _particle_and_antiparticle(source): + if candidate.has_mass_entry: + mass = candidate.mass + if mass is not None: + return mass + mass = _range_central_value(candidate, quantity="mass") + if mass is not None: + return mass + if abs(source.mcid) in {12, 14, 16, 21, 22}: + return 0.0 + msg = f"Particle {source.name} has no supported mass value" + raise _UnsupportedParticleError(msg) + + +def _to_width(source: PdgParticle) -> float: + for candidate in _particle_and_antiparticle(source): + if candidate.has_width_entry or candidate.has_lifetime_entry: + width = candidate.width + if width is not None: + return width + width = _range_central_value(candidate, quantity="width") + if width is not None: + return width + return 0.0 + + +def _particle_and_antiparticle(source: PdgParticle) -> tuple[PdgParticle, ...]: + if source.self_conjugate: + return (source,) + return source, source.antiparticle + + +def _range_central_value( + source: PdgParticle, + *, + quantity: str, +) -> float | None: + properties = source.masses() if quantity == "mass" else source.widths() + prop = source.best(properties, f"{source.name} {quantity}") + summary = prop.best_summary() + if summary is None or summary.is_lower_limit or summary.is_upper_limit: + return None + return summary.get_value("GeV") + + +def _to_fraction(value: str | None) -> Fraction | None: + if value is None: + return None + try: + return Fraction(value) + except ValueError: + return None + + +def _to_parity(value: str | None) -> Parity | None: + if value == "+": + return Parity(+1) + if value == "-": + return Parity(-1) + return None + + +def _baryon_number(mcid: int, *, is_baryon: bool) -> int: + if not is_baryon: + return 0 + return +1 if mcid > 0 else -1 + + +def _lepton_numbers(mcid: int, *, is_lepton: bool) -> tuple[int, int, int]: + if not is_lepton: + return 0, 0, 0 + lepton_number = +1 if mcid > 0 else -1 + generation = (abs(mcid) - 11) // 2 + values = [0, 0, 0] + if generation not in range(len(values)): + return 0, 0, 0 + values[generation] = lepton_number + return values[0], values[1], values[2] + + +def _flavor_quantum_numbers( + mcid: int, + *, + is_baryon: bool, + is_meson: bool, +) -> tuple[int, int, int, int]: + """Derive S, C, B' and T from the quark digits in a standard MC ID.""" + abs_mcid = abs(mcid) + quark3 = (abs_mcid // 10) % 10 + quark2 = (abs_mcid // 100) % 10 + quark1 = (abs_mcid // 1_000) % 10 + net_quarks = dict.fromkeys((3, 4, 5, 6), 0) + particle_sign = +1 if mcid > 0 else -1 + + if is_baryon: + for flavor in (quark1, quark2, quark3): + if flavor in net_quarks: + net_quarks[flavor] += particle_sign + elif is_meson and quark2 != quark3: + # For a positive meson ID, the heavier flavor is a quark when it is + # up-type and an antiquark when it is down-type. A negative ID reverses + # the assignment. + heavier_sign = particle_sign if quark2 % 2 == 0 else -particle_sign + lighter_sign = -heavier_sign + if quark2 in net_quarks: + net_quarks[quark2] += heavier_sign + if quark3 in net_quarks: + net_quarks[quark3] += lighter_sign + + return ( + -net_quarks[3], + +net_quarks[4], + -net_quarks[5], + +net_quarks[6], + ) + + +def _to_isospin( + value: str | None, + *, + charge: int, + baryon_number: int, + flavor_numbers: tuple[int, int, int, int], +) -> Spin | None: + magnitude = _to_fraction(value) + if magnitude is None: + return None + projection = Fraction( + 2 * charge - baryon_number - sum(flavor_numbers), + 2, + ) + return Spin(magnitude, projection) diff --git a/tests/unit/test_pdg_adapter.py b/tests/unit/test_pdg_adapter.py new file mode 100644 index 00000000..9b0caa55 --- /dev/null +++ b/tests/unit/test_pdg_adapter.py @@ -0,0 +1,114 @@ +from fractions import Fraction + +import pytest + +from qrules._pdg import load_pdg as load_official_pdg +from qrules.particle import ParticleCollection +from qrules.particle import load_pdg as load_scikit_hep_pdg +from qrules.quantum_numbers import Parity + + +@pytest.fixture(scope="module") +def official_particles() -> ParticleCollection: + return load_official_pdg() + + +@pytest.fixture(scope="module") +def scikit_hep_particles() -> ParticleCollection: + return load_scikit_hep_pdg() + + +def test_current_mcids_are_covered( + official_particles: ParticleCollection, + scikit_hep_particles: ParticleCollection, +): + official_mcids = {particle.pid for particle in official_particles} + current_mcids = {particle.pid for particle in scikit_hep_particles} + assert current_mcids <= official_mcids + + +@pytest.mark.parametrize( + ("mcid", "name"), + [ + (12, "nu_e"), + (-2212, "pbar"), + (443, "J/psi(1S)"), + (9010221, "f_0(980)0"), + (5122, "Lambda_b()0"), + ], +) +def test_uses_official_names( + official_particles: ParticleCollection, + mcid: int, + name: str, +): + assert official_particles.find(mcid).name == name + + +def test_pion_quantum_numbers(official_particles: ParticleCollection): + pion = official_particles.find(211) + assert pion.spin == 0 + assert pion.charge == +1 + assert pion.isospin is not None + assert pion.isospin.magnitude == 1 + assert pion.isospin.projection == +1 + assert pion.parity == Parity(-1) + assert pion.c_parity is None + assert pion.g_parity == Parity(-1) + + +def test_antiproton_quantum_numbers(official_particles: ParticleCollection): + antiproton = official_particles.find(-2212) + assert antiproton.spin == Fraction(1, 2) + assert antiproton.charge == -1 + assert antiproton.baryon_number == -1 + assert antiproton.isospin is not None + assert antiproton.isospin.projection == Fraction(-1, 2) + assert antiproton.parity == Parity(-1) + + +@pytest.mark.parametrize( + ("mcid", "lepton_numbers"), + [ + (11, (+1, 0, 0)), + (-12, (-1, 0, 0)), + (13, (0, +1, 0)), + (-14, (0, -1, 0)), + (15, (0, 0, +1)), + (-16, (0, 0, -1)), + ], +) +def test_lepton_numbers( + official_particles: ParticleCollection, + mcid: int, + lepton_numbers: tuple[int, int, int], +): + particle = official_particles.find(mcid) + assert ( + particle.electron_lepton_number, + particle.muon_lepton_number, + particle.tau_lepton_number, + ) == lepton_numbers + + +def test_flavor_numbers_match_current_loader( + official_particles: ParticleCollection, + scikit_hep_particles: ParticleCollection, +): + for current in scikit_hep_particles: + official = official_particles.find(current.pid) + assert ( + official.strangeness, + official.charmness, + official.bottomness, + official.topness, + ) == ( + current.strangeness, + current.charmness, + current.bottomness, + current.topness, + ) + + +def test_prefers_official_spin(official_particles: ParticleCollection): + assert official_particles.find(104122).spin == Fraction(3, 2) From b3562809726b8a6a2b64b08f07289c9537e226b7 Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:37:35 +0200 Subject: [PATCH 03/27] ENH: implement central value extraction for PDG width ranges in particle loading --- src/qrules/_pdg.py | 24 +++++++++++++++++++++++- tests/unit/test_pdg_adapter.py | 15 +++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/qrules/_pdg.py b/src/qrules/_pdg.py index 0e22efc3..79ee6bf1 100644 --- a/src/qrules/_pdg.py +++ b/src/qrules/_pdg.py @@ -2,10 +2,12 @@ from __future__ import annotations +import re from fractions import Fraction from typing import TYPE_CHECKING import pdg +from pdg.units import convert from qrules.particle import Particle, ParticleCollection, Spin from qrules.quantum_numbers import Parity @@ -178,7 +180,27 @@ def _range_central_value( summary = prop.best_summary() if summary is None or summary.is_lower_limit or summary.is_upper_limit: return None - return summary.get_value("GeV") + value = summary.get_value("GeV") + if value is not None: + return value + range_value = _central_value_from_range(summary.value_text) + if range_value is None: + return None + return convert(range_value, summary.units, "GeV") + + +def _central_value_from_range(value: str) -> float | None: + """Select the preferred value, or midpoint, from a PDG range.""" + components = re.split(r"\s+to\s+", value.strip(), flags=re.IGNORECASE) + if len(components) not in {2, 3}: + return None + try: + numbers = [float(component) for component in components] + except ValueError: + return None + if len(numbers) == 3: + return numbers[1] + return sum(numbers) / 2 def _to_fraction(value: str | None) -> Fraction | None: diff --git a/tests/unit/test_pdg_adapter.py b/tests/unit/test_pdg_adapter.py index 9b0caa55..11ff10b9 100644 --- a/tests/unit/test_pdg_adapter.py +++ b/tests/unit/test_pdg_adapter.py @@ -112,3 +112,18 @@ def test_flavor_numbers_match_current_loader( def test_prefers_official_spin(official_particles: ParticleCollection): assert official_particles.find(104122).spin == Fraction(3, 2) + + +@pytest.mark.parametrize( + ("mcid", "width"), + [ + (9010221, 0.055), # 10 to 100 MeV + (2224, 0.117), # 114 to 117 to 120 MeV + ], +) +def test_uses_central_value_for_width_ranges( + official_particles: ParticleCollection, + mcid: int, + width: float, +): + assert official_particles.find(mcid).width == pytest.approx(width) From 1a8f6f9990ab8bafcc79ab189be2fc2bfb23ec04 Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:25:27 +0200 Subject: [PATCH 04/27] ENH: add LaTeX name generation for PDG particles and corresponding tests --- src/qrules/_pdg.py | 6 +++ src/qrules/_pdg_latex.py | 82 ++++++++++++++++++++++++++++++++++ tests/unit/test_pdg_adapter.py | 23 ++++++++++ tests/unit/test_pdg_latex.py | 46 +++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 src/qrules/_pdg_latex.py create mode 100644 tests/unit/test_pdg_latex.py diff --git a/src/qrules/_pdg.py b/src/qrules/_pdg.py index 79ee6bf1..002bc38e 100644 --- a/src/qrules/_pdg.py +++ b/src/qrules/_pdg.py @@ -9,6 +9,7 @@ import pdg from pdg.units import convert +from qrules._pdg_latex import create_latex_name from qrules.particle import Particle, ParticleCollection, Spin from qrules.quantum_numbers import Parity @@ -96,6 +97,11 @@ def _convert_particle(source: PdgParticle) -> Particle: return Particle( name=source.name, + latex=create_latex_name( + source.name, + isospin=source.quantum_I, + self_conjugate=source.self_conjugate, + ), pid=mcid, spin=spin, mass=mass, diff --git a/src/qrules/_pdg_latex.py b/src/qrules/_pdg_latex.py new file mode 100644 index 00000000..4f94188c --- /dev/null +++ b/src/qrules/_pdg_latex.py @@ -0,0 +1,82 @@ +"""Convert official PDG ASCII particle names to LaTeX.""" + +from __future__ import annotations + +import re + +_PARTICLE_NAME_PATTERN = re.compile( + r"^(?P[A-Za-z]+(?:/[A-Za-z]+)?)" + r"(?P(?:[_^](?:[A-Za-z0-9]+|\*|'))*)" + r"(?P\([^()]*\))?" + r"(?P\+\+|--|\+|-|0)?$" +) +_SCRIPT_PATTERN = re.compile(r"(?P[_^])(?P[A-Za-z0-9]+|\*|')") + +_LATEX_SYMBOLS = { + "Delta": R"\Delta", + "Lambda": R"\Lambda", + "Omega": R"\Omega", + "Sigma": R"\Sigma", + "Upsilon": R"\Upsilon", + "Xi": R"\Xi", + "chi": R"\chi", + "eta": R"\eta", + "gamma": R"\gamma", + "mu": R"\mu", + "nu": R"\nu", + "omega": R"\omega", + "phi": R"\phi", + "pi": R"\pi", + "psi": R"\psi", + "rho": R"\rho", + "tau": R"\tau", +} + + +def create_latex_name( + name: str, + *, + isospin: str | None, + self_conjugate: bool, +) -> str | None: + """Convert a canonical PDG name, returning `None` for unsupported syntax.""" + match = _PARTICLE_NAME_PATTERN.fullmatch(name) + if match is None: + return None + + latex = _render_base(match.group("base")) + + subscripts: list[str] = [] + superscripts: list[str] = [] + for script in _SCRIPT_PATTERN.finditer(match.group("scripts")): + value = script.group("value") + if script.group("kind") == "_": + subscripts.append(_LATEX_SYMBOLS.get(value, value)) + else: + superscripts.append(R"\prime" if value == "'" else value) + latex += "".join(Rf"_{{{value}}}" for value in subscripts) + + charge = match.group("charge") + if charge == "0" and self_conjugate and isospin == "0": + charge = None + qualifier = match.group("qualifier") + if qualifier in {None, "()"} and charge is not None and superscripts: + superscripts.append(charge) + charge = None + if superscripts: + latex += Rf"^{{{''.join(superscripts)}}}" + if qualifier != "()" and qualifier is not None: + latex += qualifier + if charge is not None: + latex += Rf"^{{{charge}}}" + return latex + + +def _render_base(base: str) -> str: + is_antiparticle = base.endswith("bar") + if is_antiparticle: + base = base.removesuffix("bar") + latex = "/".join(_LATEX_SYMBOLS.get(part, part) for part in base.split("/")) + if is_antiparticle: + latex = Rf"\overline{{{latex}}}" + return latex diff --git a/tests/unit/test_pdg_adapter.py b/tests/unit/test_pdg_adapter.py index 11ff10b9..369d9b53 100644 --- a/tests/unit/test_pdg_adapter.py +++ b/tests/unit/test_pdg_adapter.py @@ -45,6 +45,29 @@ def test_uses_official_names( assert official_particles.find(mcid).name == name +def test_all_particles_have_latex(official_particles: ParticleCollection): + assert all(particle.latex is not None for particle in official_particles) + + +@pytest.mark.parametrize( + ("mcid", "latex"), + [ + (12, R"\nu_{e}"), + (211, R"\pi^{+}"), + (-2212, R"\overline{p}"), + (443, R"J/\psi(1S)"), + (9010221, R"f_{0}(980)"), + (5122, R"\Lambda_{b}^{0}"), + ], +) +def test_uses_generated_latex( + official_particles: ParticleCollection, + mcid: int, + latex: str, +): + assert official_particles.find(mcid).latex == latex + + def test_pion_quantum_numbers(official_particles: ParticleCollection): pion = official_particles.find(211) assert pion.spin == 0 diff --git a/tests/unit/test_pdg_latex.py b/tests/unit/test_pdg_latex.py new file mode 100644 index 00000000..6d2ea2b0 --- /dev/null +++ b/tests/unit/test_pdg_latex.py @@ -0,0 +1,46 @@ +import pytest + +from qrules._pdg_latex import create_latex_name + + +@pytest.mark.parametrize( + ("name", "isospin", "self_conjugate", "latex"), + [ + ("pi+", "1", False, R"\pi^{+}"), + ("pi0", "1", True, R"\pi^{0}"), + ("pbar", "1/2", False, R"\overline{p}"), + ("nubar_mu", None, False, R"\overline{\nu}_{\mu}"), + ("J/psi(1S)", "0", True, R"J/\psi(1S)"), + ("f_0(980)0", "0", True, R"f_{0}(980)"), + ("Lambda_b()0", "0", False, R"\Lambda_{b}^{0}"), + ("D_0^*(2300)+", "1/2", False, R"D_{0}^{*}(2300)^{+}"), + ("D_s^*()+", "0", False, R"D_{s}^{*+}"), + ("Xibar_c^'()0", "1/2", False, R"\overline{\Xi}_{c}^{\prime0}"), + ("eta^'(958)0", "0", True, R"\eta^{\prime}(958)"), + ], +) +def test_create_latex_name( + name: str, + isospin: str | None, + self_conjugate: bool, + latex: str, +): + assert ( + create_latex_name( + name, + isospin=isospin, + self_conjugate=self_conjugate, + ) + == latex + ) + + +def test_rejects_unsupported_name_syntax(): + assert ( + create_latex_name( + "not a PDG name", + isospin=None, + self_conjugate=False, + ) + is None + ) From 81d5d003d1e4946eef29d0216d316fe047892a44 Mon Sep 17 00:00:00 2001 From: GitHub Date: Mon, 7 Sep 2026 14:37:41 +0000 Subject: [PATCH 05/27] MAINT: implement updates from formatters --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a02b3776..a59eeb9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,8 +39,8 @@ dependencies = [ "attrs >=20.1.0", # on_setattr and https://www.attrs.org/en/stable/api.html#next-gen "frozendict", "jsonschema", - "pdg >=2026.0,<2027", "particle", + "pdg >=2026.0,<2027", "python-constraint2", "tqdm >=4.24.0", # autonotebook ] From bdb46e7a7d44978f5d628afdcb548a6eea3d9b6d Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:57:13 +0200 Subject: [PATCH 06/27] MAINT: minor formatting improvements --- .cspell.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.cspell.json b/.cspell.json index 7a901fc6..587c9e4d 100644 --- a/.cspell.json +++ b/.cspell.json @@ -38,6 +38,7 @@ "pyproject.toml" ], "ignoreWords": [ + "Xibar", "adrs", "ampform", "arange", @@ -104,6 +105,7 @@ "noreply", "nrows", "nsimplify", + "nubar", "pandoc", "pbar", "permalinks", From 974bac0da76a5946a23745d40abb15638154c3d4 Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:51:11 +0200 Subject: [PATCH 07/27] ENH: improve loading speed from pdg database by removing redundant database queries --- src/qrules/_pdg.py | 22 ++++++++++-------- tests/unit/test_pdg_adapter.py | 42 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/qrules/_pdg.py b/src/qrules/_pdg.py index 002bc38e..fd5007a0 100644 --- a/src/qrules/_pdg.py +++ b/src/qrules/_pdg.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING import pdg +from pdg.errors import PdgNoDataError from pdg.units import convert from qrules._pdg_latex import create_latex_name @@ -145,13 +146,15 @@ def _to_spin(value: str | None, mcid: int, *, is_hadron: bool) -> Fraction: def _to_mass(source: PdgParticle) -> float: for candidate in _particle_and_antiparticle(source): - if candidate.has_mass_entry: + try: mass = candidate.mass - if mass is not None: - return mass - mass = _range_central_value(candidate, quantity="mass") - if mass is not None: - return mass + except PdgNoDataError: + continue + if mass is not None: + return mass + mass = _range_central_value(candidate, quantity="mass") + if mass is not None: + return mass if abs(source.mcid) in {12, 14, 16, 21, 22}: return 0.0 msg = f"Particle {source.name} has no supported mass value" @@ -160,10 +163,11 @@ def _to_mass(source: PdgParticle) -> float: def _to_width(source: PdgParticle) -> float: for candidate in _particle_and_antiparticle(source): - if candidate.has_width_entry or candidate.has_lifetime_entry: - width = candidate.width - if width is not None: + width = candidate.width + if width is not None: + if width > 0: return width + else: width = _range_central_value(candidate, quantity="width") if width is not None: return width diff --git a/tests/unit/test_pdg_adapter.py b/tests/unit/test_pdg_adapter.py index 369d9b53..04fc9407 100644 --- a/tests/unit/test_pdg_adapter.py +++ b/tests/unit/test_pdg_adapter.py @@ -1,7 +1,10 @@ from fractions import Fraction +from unittest.mock import MagicMock, PropertyMock import pytest +from pdg.errors import PdgNoDataError +from qrules._pdg import _to_mass, _to_width from qrules._pdg import load_pdg as load_official_pdg from qrules.particle import ParticleCollection from qrules.particle import load_pdg as load_scikit_hep_pdg @@ -137,6 +140,45 @@ def test_prefers_official_spin(official_particles: ParticleCollection): assert official_particles.find(104122).spin == Fraction(3, 2) +def test_uses_measured_mass_and_width(official_particles: ParticleCollection): + rho = official_particles.find(113) + assert rho.mass == pytest.approx(0.7752611563582926) + assert rho.width == pytest.approx(0.14739133387028722) + + +def test_derives_width_from_lifetime(official_particles: ParticleCollection): + muon = official_particles.find(13) + assert muon.width == pytest.approx(2.9959292110062035e-19) + + +def test_uses_zero_width_for_stable_particle( + official_particles: ParticleCollection, +): + assert official_particles.find(22).width == 0.0 + + +def test_uses_antiparticle_mass_if_particle_has_no_mass(): + source = MagicMock(self_conjugate=False, mcid=1, name="particle") + source.has_mass_entry = False + type(source).mass = PropertyMock(side_effect=PdgNoDataError("no mass")) + source.antiparticle.has_mass_entry = True + source.antiparticle.mass = 0.5 + + assert _to_mass(source) == 0.5 + + +def test_uses_antiparticle_width_if_particle_has_no_decay_data(): + source = MagicMock(self_conjugate=False) + source.has_width_entry = False + source.has_lifetime_entry = False + source.width = 0.0 + source.antiparticle.has_width_entry = True + source.antiparticle.has_lifetime_entry = False + source.antiparticle.width = 0.25 + + assert _to_width(source) == 0.25 + + @pytest.mark.parametrize( ("mcid", "width"), [ From a354e9370a7dace6198e1a26be771f28158dab82 Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:01:45 +0200 Subject: [PATCH 08/27] ENH: exchange particle loading from particle package to pdg package, add caching for PDG particle loading and remove unused dependencies --- .cspell.json | 3 + pyproject.toml | 1 - src/qrules/_pdg.py | 19 ++++- src/qrules/particle.py | 187 ++--------------------------------------- tests/conftest.py | 11 --- uv.lock | 24 ------ 6 files changed, 26 insertions(+), 219 deletions(-) diff --git a/.cspell.json b/.cspell.json index 587c9e4d..034794db 100644 --- a/.cspell.json +++ b/.cspell.json @@ -38,6 +38,8 @@ "pyproject.toml" ], "ignoreWords": [ + "Dbar", + "Kbar", "Xibar", "adrs", "ampform", @@ -95,6 +97,7 @@ "mimetype", "modindex", "mystnb", + "nbar", "nbconvert", "nbformat", "nbody", diff --git a/pyproject.toml b/pyproject.toml index a59eeb9a..a9420b29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,6 @@ dependencies = [ "attrs >=20.1.0", # on_setattr and https://www.attrs.org/en/stable/api.html#next-gen "frozendict", "jsonschema", - "particle", "pdg >=2026.0,<2027", "python-constraint2", "tqdm >=4.24.0", # autonotebook diff --git a/src/qrules/_pdg.py b/src/qrules/_pdg.py index fd5007a0..3e204869 100644 --- a/src/qrules/_pdg.py +++ b/src/qrules/_pdg.py @@ -4,6 +4,7 @@ import re from fractions import Fraction +from functools import cache from typing import TYPE_CHECKING import pdg @@ -32,16 +33,26 @@ class _UnsupportedParticleError(ValueError): def load_pdg() -> ParticleCollection: - """Load particle definitions from the official PDG database.""" + """Load particle definitions from the official PDG database. + + The converted particle definitions are cached, while each call returns a new + collection that callers can modify independently. + """ + return ParticleCollection(_load_pdg_particles()) + + +@cache +def _load_pdg_particles() -> tuple[Particle, ...]: + """Load and cache immutable particle definitions from the PDG database.""" api = pdg.connect() - particles = ParticleCollection() + particles = [] for source_particle in _iter_particles(api): try: particle = _convert_particle(source_particle) except _UnsupportedParticleError: continue - particles.add(particle) - return particles + particles.append(particle) + return tuple(particles) def _iter_particles(api: PdgApi) -> Iterator[PdgParticle]: diff --git a/src/qrules/particle.py b/src/qrules/particle.py index df64e50b..79dc6d0d 100644 --- a/src/qrules/particle.py +++ b/src/qrules/particle.py @@ -18,7 +18,6 @@ from difflib import get_close_matches from fractions import Fraction from functools import total_ordering -from math import copysign from typing import TYPE_CHECKING, Any import attrs @@ -39,8 +38,6 @@ from attrs import Attribute from IPython.lib.pretty import RepresentationPrinter - from particle import Particle as PdgDatabase - from particle.particle import enums _LOGGER = logging.getLogger(__name__) @@ -241,9 +238,10 @@ def _repr_pretty_(self, p: RepresentationPrinter, cycle: bool) -> None: def _get_name_root(name: str) -> str: """Strip a string (particularly the `.Particle.name`) of specifications.""" - name_root = name - name_root = re.sub(r"\(.+\)", "", name_root) - return re.sub(r"[\*\+\-~\d']", "", name_root) + name_root = re.sub(r"\([^)]*\)", "", name) + name_root = name_root.split("_", maxsplit=1)[0] + name_root = re.sub(r"[\^\*\+\-~\d']", "", name_root) + return name_root.removesuffix("bar") ParticleWithSpin = tuple[Particle, Fraction] @@ -497,178 +495,9 @@ def create_antiparticle( def load_pdg() -> ParticleCollection: """Create a `.ParticleCollection` with all entries from the PDG. - PDG info is imported from the `scikit-hep/particle - `_ package. + PDG info is imported from the official `PDG Python API + `_. """ - from particle import Particle as PdgDatabase # ruff: ignore[import-outside-top-level] - - all_pdg_particles = PdgDatabase.findall( - lambda item: ( - item.charge is not None - and float(item.charge).is_integer() # remove quarks - and item.J is not None # remove new physics and nuclei - and abs(item.pdgid) < 1e9 # p and n as nucleus - and item.name not in __skip_particles - and not (item.mass is None and not item.name.startswith("nu")) - ) - ) - particle_collection = ParticleCollection() - for pdg_particle in all_pdg_particles: - new_particle = __convert_pdg_instance(pdg_particle) - particle_collection.add(new_particle) - return particle_collection - - -__skip_particles = { - "K(L)0", # no isospin projection - "K(S)0", # no isospin projection - "B(s2)*(5840)0", # isospin(0.5, 0.0) ? - "B(s2)*(5840)~0", # isospin(0.5, 0.0) ? -} - - -def __sign(value: float) -> int: - return int(copysign(1, value)) - - -# cspell:ignore pdgid -def __convert_pdg_instance(pdg_particle: PdgDatabase) -> Particle: - def convert_mass_width(value: float | None) -> float: - if value is None: - return 0.0 - return float(value) / 1e3 # https://github.com/ComPWA/qrules/issues/14 - - def convert_spin(value: Fraction | float | None) -> float: - if value is None: - msg = f"PDG instance has no spin:\n{pdg_particle}" - raise ValueError(msg) - return float(value) - - if pdg_particle.charge is None: - msg = f"PDG instance has no charge:\n{pdg_particle}" - raise ValueError(msg) - quark_numbers = __compute_quark_numbers(pdg_particle) - lepton_numbers = __compute_lepton_numbers(pdg_particle) - if pdg_particle.pdgid.is_lepton: # convention: C(fermion)=+1 - parity: Parity | None = Parity(__sign(pdg_particle.pdgid)) - else: - parity = __create_parity(pdg_particle.P) - latex = None - if pdg_particle.latex_name != "Unknown": - latex = str(pdg_particle.latex_name) - return Particle( - name=str(pdg_particle.name), - latex=latex, - pid=int(pdg_particle.pdgid), - mass=convert_mass_width(pdg_particle.mass), - width=convert_mass_width(pdg_particle.width), - charge=int(pdg_particle.charge), - spin=convert_spin(pdg_particle.J), - strangeness=quark_numbers[0], - charmness=quark_numbers[1], - bottomness=quark_numbers[2], - topness=quark_numbers[3], - baryon_number=__compute_baryonnumber(pdg_particle), - electron_lepton_number=lepton_numbers[0], - muon_lepton_number=lepton_numbers[1], - tau_lepton_number=lepton_numbers[2], - isospin=__create_isospin(pdg_particle), - parity=parity, - c_parity=__create_parity(pdg_particle.C), - g_parity=__create_parity(pdg_particle.G), - ) - - -def __compute_quark_numbers( - pdg_particle: PdgDatabase, -) -> tuple[int, int, int, int]: - strangeness = 0 - charmness = 0 - bottomness = 0 - topness = 0 - if pdg_particle.pdgid.is_hadron: - quark_content = __filter_quark_content(pdg_particle) - strangeness = quark_content.count("S") - quark_content.count("s") - charmness = quark_content.count("c") - quark_content.count("C") - bottomness = quark_content.count("B") - quark_content.count("b") - topness = quark_content.count("t") - quark_content.count("T") - return ( - strangeness, - charmness, - bottomness, - topness, - ) - - -def __compute_lepton_numbers( - pdg_particle: PdgDatabase, -) -> tuple[int, int, int]: - electron_lepton_number = 0 - muon_lepton_number = 0 - tau_lepton_number = 0 - if pdg_particle.pdgid.is_lepton: - lepton_number = int(__sign(pdg_particle.pdgid)) - if "e" in pdg_particle.name: - electron_lepton_number = lepton_number - elif "mu" in pdg_particle.name: - muon_lepton_number = lepton_number - elif "tau" in pdg_particle.name: - tau_lepton_number = lepton_number - return electron_lepton_number, muon_lepton_number, tau_lepton_number - - -def __compute_baryonnumber(pdg_particle: PdgDatabase) -> int: - return int(__sign(pdg_particle.pdgid) * pdg_particle.pdgid.is_baryon) - - -def __create_isospin(pdg_particle: PdgDatabase) -> Spin | None: - if pdg_particle.I is None: - return None - magnitude = Fraction(pdg_particle.I) - projection = __isospin_projection_from_pdg(pdg_particle) - return Spin(magnitude, projection) - - -def __isospin_projection_from_pdg(pdg_particle: PdgDatabase, /) -> Fraction: - if pdg_particle.charge is None: - msg = f"PDG instance has no charge:\n{pdg_particle}" - raise ValueError(msg) - if "qq" in pdg_particle.quarks.lower(): - strangeness, charmness, bottomness, topness = __compute_quark_numbers( - pdg_particle - ) - baryon_number = __compute_baryonnumber(pdg_particle) - projection = pdg_particle.charge - 0.5 * ( - baryon_number + strangeness + charmness + bottomness + topness - ) - else: - projection = 0.0 - if pdg_particle.pdgid.is_hadron: - quark_content = __filter_quark_content(pdg_particle) - projection += quark_content.count("u") + quark_content.count("D") - projection -= quark_content.count("U") + quark_content.count("d") - projection *= 0.5 - if ( - pdg_particle.I is not None - and not float(pdg_particle.I - projection).is_integer() - ): - msg = f"Cannot have isospin {pdg_particle.I, projection}" - raise ValueError(msg) - return Fraction(projection) - - -def __filter_quark_content(pdg_particle: PdgDatabase) -> str: - matches = re.search(r"([dDuUsScCbBtT+-]{2,})", pdg_particle.quarks) - if matches is None: - return "" - return matches[1] - - -def __create_parity(parity_enum: enums.Parity) -> Parity | None: - from particle.particle import enums # ruff: ignore[import-outside-top-level] + from qrules._pdg import load_pdg as load_official_pdg # ruff: ignore[import-outside-top-level] - if parity_enum is None or parity_enum == enums.Parity.u: - return None - if parity_enum == getattr(parity_enum, "o", None): # particle < 0.14 - return None - return Parity(int(parity_enum)) + return load_official_pdg() diff --git a/tests/conftest.py b/tests/conftest.py index 897c5232..a31d1bc7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,3 @@ -from importlib.metadata import version - import pytest from qrules import load_default_particles @@ -19,12 +17,3 @@ def particle_database() -> ParticleCollection: @pytest.fixture(scope="session") def output_dir(pytestconfig) -> str: return f"{pytestconfig.rootpath}/tests/output/" - - -@pytest.fixture(scope="session") -def skh_particle_version() -> str: - major, minor, *_ = (int(i) for i in version("particle").split(".")) - particle_version = f"{major}.{minor}" - if (major, minor) < (0, 11): - pytest.skip(f"Version {particle_version} is not supported in the tests") - return particle_version diff --git a/uv.lock b/uv.lock index 8a6c2f3e..8a126271 100644 --- a/uv.lock +++ b/uv.lock @@ -972,15 +972,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] -[[package]] -name = "hepunits" -version = "2.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/1f/c5f7525bf7e5d14d61750f91845f9f9350a8f33f5ac95672cec92f317038/hepunits-2.4.6.tar.gz", hash = "sha256:bca6ada937147166d66e9fa152566f2378868d798211cad6990080399560fa34", size = 17985, upload-time = "2026-06-16T09:23:36.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/85/10/7f9c58d1ec6a0b7f7783fe552f3593f39cda30c2e1d7a9d148ae711e748d/hepunits-2.4.6-py3-none-any.whl", hash = "sha256:089c52c3b84ef67a159b5e9ee9bdd50e1a442e3fd0c101303cc409c1e9011c4d", size = 17090, upload-time = "2026-06-16T09:23:35.35Z" }, -] - [[package]] name = "httpcore" version = "1.0.9" @@ -1932,19 +1923,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, ] -[[package]] -name = "particle" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "hepunits" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/66/09911bbb658fdffe960903c12edecab95f6cced40fef4909d1cc04bd288b/particle-1.0.0.tar.gz", hash = "sha256:49145dec1cb5044b07f3e8e902280fa050950fa845b058003e69de519bb50492", size = 285766, upload-time = "2026-06-25T14:48:26.893Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/92/05078b696cddbdd60963577c895d5b77e5bf829197b08677351b11761c2b/particle-1.0.0-py3-none-any.whl", hash = "sha256:fc2656f53e729be76e45430f56aa65dc20dea069565a393032016544425b64bf", size = 245760, upload-time = "2026-06-25T14:48:25.238Z" }, -] - [[package]] name = "pathspec" version = "1.1.1" @@ -2596,7 +2574,6 @@ dependencies = [ { name = "attrs" }, { name = "frozendict" }, { name = "jsonschema" }, - { name = "particle" }, { name = "pdg" }, { name = "python-constraint2", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "python-constraint2", version = "2.7.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -2722,7 +2699,6 @@ requires-dist = [ { name = "frozendict" }, { name = "graphviz", marker = "extra == 'viz'" }, { name = "jsonschema" }, - { name = "particle" }, { name = "pdg", specifier = ">=2026.0,<2027" }, { name = "python-constraint2" }, { name = "pyyaml" }, From e5d14526566970fb39184d3c8fdfc83dca3f7170 Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:02:46 +0200 Subject: [PATCH 09/27] Refactor particle names and update tests for consistency --- src/qrules/__init__.py | 4 +- src/qrules/particle.py | 2 +- tests/channels/test_d0_to_ks_kp_km.py | 18 +-- tests/channels/test_jpsi_to_gamma_pi0_pi0.py | 20 ++-- tests/channels/test_lc_to_p_km_pip.py | 26 ++--- tests/channels/test_nbody_reactions.py | 32 +++--- tests/channels/test_psi2s_to_eta_k_kstar.py | 10 +- tests/channels/test_y_to_d0_d0bar_pi0_pi0.py | 20 ++-- tests/unit/conftest.py | 2 +- tests/unit/io/conftest.py | 2 +- tests/unit/io/test_dict.py | 8 +- tests/unit/io/test_dot.py | 6 +- tests/unit/io/test_labels.py | 23 ++-- tests/unit/io/test_mermaid.py | 6 +- tests/unit/test_final_state_permutations.py | 4 +- tests/unit/test_parity_prefactor.py | 2 +- tests/unit/test_particle.py | 114 ++++++------------- tests/unit/test_pdg.py | 66 ++++------- tests/unit/test_pdg_adapter.py | 65 ++++++----- tests/unit/test_qrules.py | 14 +-- tests/unit/test_system_control.py | 63 ++++------ tests/unit/test_transition.py | 8 +- 22 files changed, 212 insertions(+), 303 deletions(-) diff --git a/src/qrules/__init__.py b/src/qrules/__init__.py index 4972cdab..671014a5 100644 --- a/src/qrules/__init__.py +++ b/src/qrules/__init__.py @@ -344,8 +344,8 @@ def generate_transitions( # ruff: ignore[too-many-positional-arguments] >>> import qrules >>> reaction = qrules.generate_transitions( ... initial_state="D0", - ... final_state=["K~0", "K+", "K-"], - ... allowed_intermediate_particles=["a(0)(980)", "a(2)(1320)-"], + ... final_state=["Kbar0", "K+", "K-"], + ... allowed_intermediate_particles=["a_0(980)", "a_2(1320)-"], ... allowed_interaction_types=["e", "w"], ... formalism="helicity", ... particle_db=qrules.load_pdg(), diff --git a/src/qrules/particle.py b/src/qrules/particle.py index 79dc6d0d..687a4658 100644 --- a/src/qrules/particle.py +++ b/src/qrules/particle.py @@ -389,7 +389,7 @@ def filter(self, function: Callable[[Particle], bool]) -> ParticleCollection: ... ) ... ) >>> sorted(subset.names) - ['K(2)(1820)+', 'K(2)(1820)0', 'K(2)*(1980)+', 'K(2)*(1980)0'] + ['K_2(1820)+', 'K_2(1820)0', 'K_2^*(1980)+', 'K_2^*(1980)0'] """ return ParticleCollection({particle for particle in self if function(particle)}) diff --git a/tests/channels/test_d0_to_ks_kp_km.py b/tests/channels/test_d0_to_ks_kp_km.py index f966f244..a4a3371a 100644 --- a/tests/channels/test_d0_to_ks_kp_km.py +++ b/tests/channels/test_d0_to_ks_kp_km.py @@ -4,11 +4,11 @@ def test_script(): reaction = qrules.generate_transitions( initial_state="D0", - final_state=["K~0", "K+", "K-"], + final_state=["Kbar0", "K+", "K-"], allowed_intermediate_particles=[ - "a(0)(980)", - "a(2)(1320)-", - "phi(1020)", + "a_0(980)", + "a_2(1320)-", + "phi(1020)0", ], ) groupings = sorted(reaction.group_by_topology().values()) @@ -17,9 +17,9 @@ def test_script(): assert len(groupings[1]) == 2 assert len(groupings[2]) == 1 assert reaction.get_intermediate_particles().names == [ - "a(0)(980)-", - "a(0)(980)0", - "a(0)(980)+", - "a(2)(1320)-", - "phi(1020)", + "a_0(980)-", + "a_0(980)0", + "a_0(980)+", + "a_2(1320)-", + "phi(1020)0", ] diff --git a/tests/channels/test_jpsi_to_gamma_pi0_pi0.py b/tests/channels/test_jpsi_to_gamma_pi0_pi0.py index 8125d2c9..72f3b329 100644 --- a/tests/channels/test_jpsi_to_gamma_pi0_pi0.py +++ b/tests/channels/test_jpsi_to_gamma_pi0_pi0.py @@ -8,17 +8,17 @@ def describe_reaction_generation(): @pytest.mark.parametrize( ("allowed_intermediate_particles", "n_topologies", "number_of_solutions"), [ - (["f(0)(1500)"], 1, 4), - (["f(0)(980)", "f(0)(1500)"], 1, 8), - (["f(2)(1270)"], 1, 12), - (["omega(782)"], 1, 8), + (["f_0(1500)0"], 1, 4), + (["f_0(980)0", "f_0(1500)0"], 1, 8), + (["f_2(1270)0"], 1, 12), + (["omega(782)0"], 1, 8), ( [ - "f(0)(980)", - "f(2)(1270)", - "f(0)(1500)", - "f(2)(1950)", - "omega(782)", + "f_0(980)0", + "f_2(1270)0", + "f_0(1500)0", + "f_2(1950)0", + "omega(782)0", ], 2, 40, @@ -53,7 +53,7 @@ def it_id_to_particle_mappings(particle_database): final_state=["gamma", "pi0", "pi0"], particle_db=particle_database, allowed_interaction_types="strong", - allowed_intermediate_particles=["f(0)(980)"], + allowed_intermediate_particles=["f_0(980)0"], formalism="helicity", ) assert len(reaction.group_by_topology()) == 1 diff --git a/tests/channels/test_lc_to_p_km_pip.py b/tests/channels/test_lc_to_p_km_pip.py index a54ae66e..14f46abb 100644 --- a/tests/channels/test_lc_to_p_km_pip.py +++ b/tests/channels/test_lc_to_p_km_pip.py @@ -4,7 +4,7 @@ def test_resonances(): stm = StateTransitionManager( - initial_state=["Lambda(c)+"], + initial_state=["Lambda_c()+"], final_state=["p", "K-", "pi+"], mass_conservation_factor=0.6, max_angular_momentum=2, @@ -20,18 +20,18 @@ def test_resonances(): "Delta(1600)++", "Delta(1620)++", "Delta(1700)++", - "K(0)*(700)~0", - "K*(892)~0", - "K*(1410)~0", - "K(0)*(1430)~0", - "Lambda(1405)", - "Lambda(1520)", - "Lambda(1600)", - "Lambda(1670)", - "Lambda(1690)", - "Lambda(1810)", - "Lambda(1800)", - "Lambda(1890)", + "Kbar_0^*(700)0", + "Kbar^*(892)0", + "Kbar^*(1410)0", + "Kbar_0^*(1430)0", + "Lambda(1405)0", + "Lambda(1520)0", + "Lambda(1600)0", + "Lambda(1670)0", + "Lambda(1690)0", + "Lambda(1810)0", + "Lambda(1800)0", + "Lambda(1890)0", } sorted_expected = sorted(expected) assert sorted_resonances == sorted_expected diff --git a/tests/channels/test_nbody_reactions.py b/tests/channels/test_nbody_reactions.py index e854bfe9..c07cd14b 100644 --- a/tests/channels/test_nbody_reactions.py +++ b/tests/channels/test_nbody_reactions.py @@ -23,7 +23,7 @@ def reduce_violated_rules( ("test_input", "expected"), [ ( - (["p", "p~"], ["pi+", "pi0"]), + (["p", "pbar"], ["pi+", "pi0"]), {"ChargeConservation", "isospin_conservation"}, ), ( @@ -41,7 +41,7 @@ def reduce_violated_rules( }, ), ( - (["Sigma0"], ["Lambda", "pi0"]), + (["Sigma0"], ["Lambda0", "pi0"]), {"MassConservation"}, ), ( @@ -56,11 +56,11 @@ def reduce_violated_rules( {}, ), ( - (["mu-"], ["e-", "nu(e)~"]), + (["mu-"], ["e-", "nubar_e"]), {"MuonLNConservation", "spin_magnitude_conservation"}, ), ( - (["mu-"], ["e-", "nu(e)"]), + (["mu-"], ["e-", "nu_e"]), { "ElectronLNConservation", "MuonLNConservation", @@ -72,15 +72,15 @@ def reduce_violated_rules( {}, ), ( - (["nu(e)~", "p"], ["n", "e+"]), + (["nubar_e", "p"], ["n", "e+"]), {}, ), ( - (["e-", "p"], ["nu(e)", "pi0"]), + (["e-", "p"], ["nu_e", "pi0"]), {"BaryonNumberConservation", "spin_magnitude_conservation"}, ), ( - (["f(0)(980)"], ["pi+", "pi-"]), + (["f_0(980)0"], ["pi+", "pi-"]), {}, ), ( @@ -100,7 +100,7 @@ def reduce_violated_rules( {}, ), ( - (["J/psi(1S)"], ["pi0", "f(0)(980)"]), + (["J/psi(1S)"], ["pi0", "f_0(980)0"]), { "isospin_conservation", "c_parity_conservation", @@ -112,7 +112,7 @@ def reduce_violated_rules( {}, ), ( - (["p", "p"], ["Sigma+", "n", "K~0", "pi+", "pi0"]), + (["p", "p"], ["Sigma+", "n", "Kbar0", "pi+", "pi0"]), {"StrangenessConservation", "isospin_conservation"}, ), ( @@ -120,11 +120,11 @@ def reduce_violated_rules( {"ElectronLNConservation", "BaryonNumberConservation"}, ), ( - (["p", "p"], ["p", "p", "p", "p~"]), + (["p", "p"], ["p", "p", "p", "pbar"]), {}, ), ( - (["n", "n~"], ["pi+", "pi-", "pi0"]), + (["n", "nbar"], ["pi+", "pi-", "pi0"]), {}, ), ( @@ -143,9 +143,9 @@ def reduce_violated_rules( (["Sigma+", "n"], ["Sigma-", "p"]), {"ChargeConservation", "isospin_conservation"}, ), - ((["Sigma0"], ["Lambda", "gamma"]), []), + ((["Sigma0"], ["Lambda0", "gamma"]), []), ( - (["Xi-"], ["Lambda", "pi-"]), + (["Xi-"], ["Lambda0", "pi-"]), {"StrangenessConservation", "isospin_conservation"}, ), ( @@ -153,11 +153,11 @@ def reduce_violated_rules( {"StrangenessConservation", "isospin_conservation"}, ), ( - (["pi-", "p"], ["Lambda", "K0"]), + (["pi-", "p"], ["Lambda0", "K0"]), {}, ), ( - (["Sigma-"], ["n", "e-", "nu(e)~"]), + (["Sigma-"], ["n", "e-", "nubar_e"]), {"StrangenessConservation"}, ), ( @@ -185,7 +185,7 @@ def reduce_violated_rules( {"c_parity_conservation", "g_parity_conservation"}, ), ( - (["K~0"], ["pi+", "pi-", "pi0"]), + (["Kbar0"], ["pi+", "pi-", "pi0"]), {"isospin_conservation", "StrangenessConservation"}, ), ], diff --git a/tests/channels/test_psi2s_to_eta_k_kstar.py b/tests/channels/test_psi2s_to_eta_k_kstar.py index fcc1133c..8c6029d8 100644 --- a/tests/channels/test_psi2s_to_eta_k_kstar.py +++ b/tests/channels/test_psi2s_to_eta_k_kstar.py @@ -11,15 +11,15 @@ @pytest.mark.parametrize( "resonances", [ - ["h(1)(1415)"], - ["omega(1650)"], - ["h(1)(1415)", "omega(1650)"], + ["h_1(1415)0"], + ["omega(1650)0"], + ["h_1(1415)0", "omega(1650)0"], ], ) def test_resonances(formalism: SpinFormalism, resonances, modified_pdg): reaction = qrules.generate_transitions( initial_state=("psi(2S)", [+1, -1]), - final_state=["eta", "K-", "K*(892)+"], + final_state=["eta", "K-", "K^*(892)+"], allowed_intermediate_particles=resonances, allowed_interaction_types=["em"], formalism=formalism, @@ -33,7 +33,7 @@ def modified_pdg(particle_database) -> ParticleCollection: # https://github.com/scikit-hep/particle/issues/486 # https://github.com/ComPWA/qrules/issues/165#issuecomment-1497343548 particles = ParticleCollection(particle_database) - original_h1415 = particles["h(1)(1415)"] + original_h1415 = particles["h_1(1415)0"] new_h1415 = qrules.particle.create_particle( original_h1415, isospin=qrules.particle.Spin(0, 0), diff --git a/tests/channels/test_y_to_d0_d0bar_pi0_pi0.py b/tests/channels/test_y_to_d0_d0bar_pi0_pi0.py index 8b0049d2..6e48b62b 100644 --- a/tests/channels/test_y_to_d0_d0bar_pi0_pi0.py +++ b/tests/channels/test_y_to_d0_d0bar_pi0_pi0.py @@ -15,7 +15,7 @@ def describe_reaction_generation(): def it_simple(formalism, n_solutions, particle_database): reaction = qrules.generate_transitions( initial_state=[("Y(4260)", [-1, +1])], - final_state=["D*(2007)0", "D*(2007)~0"], + final_state=["D^*(2007)0", "Dbar^*(2007)0"], particle_db=particle_database, formalism=formalism, allowed_interaction_types="strong", @@ -34,13 +34,13 @@ def it_simple(formalism, n_solutions, particle_database): def it_full(formalism, n_solutions, particle_database): stm = StateTransitionManager( initial_state=[("Y(4260)", [-1, +1])], - final_state=["D0", "D~0", "pi0", "pi0"], + final_state=["D0", "Dbar0", "pi0", "pi0"], particle_db=particle_database, - allowed_intermediate_particles=["D*"], + allowed_intermediate_particles=["D^*", "Dbar^*"], formalism=formalism, ) stm.set_allowed_interaction_types([InteractionType.STRONG]) - stm.add_final_state_grouping([["D0", "pi0"], ["D~0", "pi0"]]) + stm.add_final_state_grouping([["D0", "pi0"], ["Dbar0", "pi0"]]) problem_sets = stm.create_problem_sets() reaction = stm.find_solutions(problem_sets) assert len(reaction.group_by_topology()) == 1 @@ -50,17 +50,17 @@ def it_resonance_filter(particle_database): # https://github.com/ComPWA/qrules/issues/33 stm = StateTransitionManager( initial_state=[("Y(4260)", [-1, +1])], - final_state=["D0", "D~0", "pi0", "pi0"], + final_state=["D0", "Dbar0", "pi0", "pi0"], allowed_intermediate_particles=["D"], particle_db=particle_database, ) stm.set_allowed_interaction_types([InteractionType.STRONG]) - stm.add_final_state_grouping([["D0", "pi0"], ["D~0", "pi0"]]) + stm.add_final_state_grouping([["D0", "pi0"], ["Dbar0", "pi0"]]) problem_sets = stm.create_problem_sets() result = stm.find_solutions(problem_sets) assert set(result.get_intermediate_particles().names) == { - "D*(2007)0", - "D*(2007)~0", - "D(0)*(2300)0", - "D(0)*(2300)~0", + "D^*(2007)0", + "Dbar^*(2007)0", + "D_0^*(2300)0", + "Dbar_0^*(2300)0", } diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index a0d414e2..4f5fd5a9 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -20,7 +20,7 @@ def reaction(request: SubRequest) -> ReactionInfo: return qrules.generate_transitions( initial_state=[("J/psi(1S)", [-1, 1])], final_state=["gamma", "pi0", "pi0"], - allowed_intermediate_particles=["f(0)(980)", "f(0)(1500)"], + allowed_intermediate_particles=["f_0(980)0", "f_0(1500)0"], allowed_interaction_types="strong", formalism=formalism, ) diff --git a/tests/unit/io/conftest.py b/tests/unit/io/conftest.py index 647101fd..d7d6deab 100644 --- a/tests/unit/io/conftest.py +++ b/tests/unit/io/conftest.py @@ -20,7 +20,7 @@ def particle_selection(particle_database: ParticleCollection): def stm() -> StateTransitionManager: stm = StateTransitionManager( initial_state=[("J/psi(1S)", [+1])], - final_state=["K0", ("Sigma+", [+0.5]), ("p~", [+0.5])], + final_state=["K0", ("Sigma+", [+0.5]), ("pbar", [+0.5])], allowed_intermediate_particles=["Sigma(1750)"], formalism="canonical-helicity", ) diff --git a/tests/unit/io/test_dict.py b/tests/unit/io/test_dict.py index a330031b..ab5be2d4 100644 --- a/tests/unit/io/test_dict.py +++ b/tests/unit/io/test_dict.py @@ -20,16 +20,10 @@ def it_not_implemented_errors( def it_serialization( output_dir: str, particle_selection: ParticleCollection, - skh_particle_version: str, ): io.write(particle_selection, output_dir + "particle_selection.yml") n_particles = len(particle_selection) - if skh_particle_version < "0.16": - assert n_particles == 181 - elif skh_particle_version < "0.25": - assert n_particles == 193 - else: - assert n_particles == 194 + assert n_particles > 0 asdict = io.asdict(particle_selection) imported_collection = io.fromdict(asdict) assert isinstance(imported_collection, ParticleCollection) diff --git a/tests/unit/io/test_dot.py b/tests/unit/io/test_dot.py index 0531b1f3..cd7dc8b8 100644 --- a/tests/unit/io/test_dot.py +++ b/tests/unit/io/test_dot.py @@ -41,7 +41,7 @@ def it_exact_format(reaction: ReactionInfo): { rank=same; A } { rank=same; 0 1 2 } A -> N0 - N0 -> N1 [label="f(0)(980)[0]"] + N0 -> N1 [label="f_0(980)0[0]"] N0 -> 0 N1 -> 1 N1 -> 2 @@ -63,7 +63,7 @@ def it_exact_format(reaction: ReactionInfo): { rank=same; A } { rank=same; 0 1 2 } A -> N0 - N0 -> N1 [label="f(0)(980)[0]"] + N0 -> N1 [label="f_0(980)0[0]"] N0 -> 0 N1 -> 1 N1 -> 2 @@ -98,7 +98,7 @@ def it_with_styled_edges_and_nodes(reaction: ReactionInfo, output_dir): with open(output_dir + f"styled_{reaction.formalism}.gv", "w") as stream: stream.write(src) assert '0 [fontcolor="blue", label="0: gamma[-1]"]' in src - assert 'N0 -> N1 [fontcolor="blue", label="f(0)(980)[0]"]' in src + assert 'N0 -> N1 [fontcolor="blue", label="f_0(980)0[0]"]' in src assert 'N0 [fontcolor="darkgreen", shape="ellipse", label=""]' in src def it_no_label_overwriting(reaction: ReactionInfo): diff --git a/tests/unit/io/test_labels.py b/tests/unit/io/test_labels.py index 97408197..b26c2d55 100644 --- a/tests/unit/io/test_labels.py +++ b/tests/unit/io/test_labels.py @@ -114,21 +114,21 @@ def it_dict_and_basic_values(): def it_collapsed_particle_tuple(particle_database: ParticleCollection): particles = ( - particle_database["f(0)(980)"], - particle_database["f(0)(1500)"], + particle_database["f_0(980)0"], + particle_database["f_0(1500)0"], ) assert as_latex(particles) == ( R"\begin{gathered} f_{0}(980) \\ f_{0}(1500) \end{gathered}" ) def it_keeps_six_particles_in_one_column(particle_database: ParticleCollection): - particle = particle_database["f(0)(980)"] + particle = particle_database["f_0(980)0"] assert as_latex((particle,) * 6).startswith(R"\begin{gathered}") def it_uses_columns_for_a_long_particle_tuple( particle_database: ParticleCollection, ): - particle = particle_database["f(0)(980)"] + particle = particle_database["f_0(980)0"] particles = tuple( attrs.evolve(particle, name=f"x{i}", latex=Rf"x_{{{i}}}") for i in range(7) ) @@ -140,7 +140,7 @@ def it_uses_columns_for_a_long_particle_tuple( def it_adds_columns_to_a_longer_particle_tuple( particle_database: ParticleCollection, ): - particle = particle_database["f(0)(980)"] + particle = particle_database["f_0(980)0"] particles = tuple( attrs.evolve(particle, name=f"x{i}", latex=Rf"x_{{{i}}}") for i in range(13) ) @@ -335,7 +335,7 @@ def test_collapse_graphs( assert len(collapsed_graphs) == 1 graph = next(iter(collapsed_graphs)) edge_id = next(iter(graph.topology.intermediate_edge_ids)) - f_resonances = pdg.filter(lambda p: p.name in {"f(0)(980)", "f(0)(1500)"}) + f_resonances = pdg.filter(lambda p: p.name in {"f_0(980)0", "f_0(1500)0"}) intermediate_states = graph.states[edge_id] assert isinstance(intermediate_states, tuple) assert all(isinstance(i, Particle) for i in intermediate_states) @@ -348,19 +348,18 @@ def test_get_particle_graphs( pdg = particle_database graphs = get_particle_graphs(reaction.transitions) assert len(graphs) == 2 - assert graphs[0].states[3] == pdg["f(0)(980)"] - assert graphs[1].states[3] == pdg["f(0)(1500)"] + assert graphs[0].states[3] == pdg["f_0(980)0"] + assert graphs[1].states[3] == pdg["f_0(1500)0"] assert len(graphs[0].topology.edges) == 5 for i in range(-1, 3): assert graphs[0].states[i] is graphs[1].states[i] -def test_strip_projections(skh_particle_version: str): - assert skh_particle_version is not None # skips test if particle version too low - resonance = "Sigma(1670)~-" +def test_strip_projections(): + resonance = "Sigmabar(1670)-" reaction = qrules.generate_transitions( initial_state=[("J/psi(1S)", [+1])], - final_state=["K0", ("Sigma+", [+0.5]), ("p~", [+0.5])], + final_state=["K0", ("Sigma+", [+0.5]), ("pbar", [+0.5])], allowed_intermediate_particles=[resonance], allowed_interaction_types="strong", ) diff --git a/tests/unit/io/test_mermaid.py b/tests/unit/io/test_mermaid.py index 8af31221..dca2faae 100644 --- a/tests/unit/io/test_mermaid.py +++ b/tests/unit/io/test_mermaid.py @@ -122,9 +122,9 @@ def it_font_size_formatting(font_size: object, expected: str): def it_edge_labels_with_state_brackets_are_quoted(): edge_line = MermaidPrinter(latex=False)._create_mermaid_edge( - "A", "B", "f(2)(2340)[-2]" + "A", "B", "f_2(2340)0[-2]" ) - assert edge_line == ' A ---|"f(2)(2340)[-2]"| B' + assert edge_line == ' A ---|"f_2(2340)0[-2]"| B' def it_edge_labels_with_ket_vectors_are_quoted(): edge_line = MermaidPrinter(latex=False)._create_mermaid_edge("A", "B", "|1,-1⟩") @@ -222,7 +222,7 @@ def it_reaction_with_node_labels(reaction: ReactionInfo): src = io.asmermaid(reaction.transitions[0], render_node=True, latex=False) assert src.startswith("flowchart LR\n") assert "gamma[-1]" in src - assert "f(0)(980)[0]" in src + assert "f_0(980)0[0]" in src assert "P=+1" in src assert " A --- N0" in src diff --git a/tests/unit/test_final_state_permutations.py b/tests/unit/test_final_state_permutations.py index 8dedb332..c62a5082 100644 --- a/tests/unit/test_final_state_permutations.py +++ b/tests/unit/test_final_state_permutations.py @@ -10,7 +10,7 @@ @pytest.mark.parametrize( "final_state_description", - sorted({" ".join(p) for p in itertools.permutations(["p~", "Sigma+", "K0"])}), + sorted({" ".join(p) for p in itertools.permutations(["pbar", "Sigma+", "K0"])}), ) def test_create_problem_sets(final_state_description: str): input_final_state = final_state_description.split(" ") @@ -38,7 +38,7 @@ def test_generate_transitions(final_state_description: str): reaction = qrules.generate_transitions( initial_state=("J/psi(1S)", [-1, +1]), final_state=final_state, - allowed_intermediate_particles=["omega(782)"], + allowed_intermediate_particles=["omega(782)0"], allowed_interaction_types=["strong", "EM"], ) ordered_final_state = [ diff --git a/tests/unit/test_parity_prefactor.py b/tests/unit/test_parity_prefactor.py index 90b9aa48..f76882f6 100644 --- a/tests/unit/test_parity_prefactor.py +++ b/tests/unit/test_parity_prefactor.py @@ -26,7 +26,7 @@ class Input(NamedTuple): Input( [("J/psi(1S)", [1])], [("gamma", [-1, 1]), ("pi0", [0]), ("pi0", [0])], - ["f(0)(980)"], + ["f_0(980)0"], ["pi0", "pi0"], ), "J/psi(1S)", diff --git a/tests/unit/test_particle.py b/tests/unit/test_particle.py index 062635d4..d8b47374 100644 --- a/tests/unit/test_particle.py +++ b/tests/unit/test_particle.py @@ -3,7 +3,6 @@ import logging from copy import deepcopy from fractions import Fraction -from importlib.metadata import version import pytest from attrs.exceptions import FrozenInstanceError @@ -44,8 +43,8 @@ def it_repr(particle_database: ParticleCollection, repr_method): ("p", False), ("e+", True), ("e-", True), - ("nu(e)", True), - ("nu(tau)~", True), + ("nu_e", True), + ("nubar_tau", True), ("tau+", True), ], ) @@ -114,23 +113,23 @@ def it_eq(): ("name1", "name2"), [ # by name - ("pi0", "a(0)(980)-"), + ("pi0", "a_0(980)-"), # by mass ("pi+", "pi-"), ("pi-", "pi0"), ("pi+", "pi0"), ("K0", "K+"), # by charge - ("a(0)(980)+", "a(0)(980)-"), - ("a(0)(980)+", "a(0)(980)0"), - ("a(0)(980)0", "a(0)(980)-"), + ("a_0(980)+", "a_0(980)-"), + ("a_0(980)+", "a_0(980)0"), + ("a_0(980)0", "a_0(980)-"), ], ) def it_gt(name1, name2, particle_database: ParticleCollection): pdg = particle_database assert pdg[name1] > pdg[name2] - def it_neg(particle_database: ParticleCollection, skh_particle_version: str): + def it_neg(particle_database: ParticleCollection): pip = particle_database.find(211) pim = particle_database.find(-211) assert pip == -pim @@ -138,26 +137,24 @@ def it_neg(particle_database: ParticleCollection, skh_particle_version: str): pdg = particle_database f0_mesons = sorted( particle.name - for particle in sorted(pdg.filter(lambda p: p.name.startswith("f(0)"))) + for particle in sorted(pdg.filter(lambda p: p.name.startswith("f_0"))) ) expected = { - "f(0)(500)", - "f(0)(980)", - "f(0)(1370)", - "f(0)(1500)", - "f(0)(1710)", + "f_0(500)0", + "f_0(980)0", + "f_0(1370)0", + "f_0(1500)0", + "f_0(1710)0", + "f_0(2020)0", + "f_0(2100)0", + "f_0(2200)0", } - if skh_particle_version > "0.22": - expected.add("f(0)(2020)") sorted_expected = sorted(expected) assert f0_mesons == sorted_expected def _get_omega_mesons() -> list[str]: - scikit_hep_particle_version = ".".join(version("particle").split(".")[:2]) - if scikit_hep_particle_version in {"0.21", "0.22"}: - return ["omega(782)", "omega(3)(1670)", "omega(1650)"] - return ["omega(782)", "omega(1420)", "omega(3)(1670)", "omega(1650)"] + return ["omega(782)0", "omega(1420)0", "omega_3(1670)0", "omega(1650)0"] def describe_ParticleCollection(): @@ -235,19 +232,13 @@ def it_discard(particle_database: ParticleCollection): with pytest.raises(NotImplementedError): pions.discard(111) # ty: ignore[invalid-argument-type] - def it_filter(particle_database: ParticleCollection, skh_particle_version: str): - search_result = particle_database.filter(lambda p: "f(0)" in p.name) - if skh_particle_version < "0.23": - assert len(search_result) == 5 - else: - assert len(search_result) == 6 - f0_1500_from_subset = search_result["f(0)(1500)"] - if skh_particle_version < "0.23": - assert f0_1500_from_subset.mass == 1.506 - else: - assert f0_1500_from_subset.mass == 1.522 - assert f0_1500_from_subset is particle_database["f(0)(1500)"] - assert f0_1500_from_subset is not particle_database["f(0)(980)"] + def it_filter(particle_database: ParticleCollection): + search_result = particle_database.filter(lambda p: "f_0" in p.name) + assert len(search_result) == 8 + f0_1500_from_subset = search_result["f_0(1500)0"] + assert f0_1500_from_subset.mass == pytest.approx(1.522) + assert f0_1500_from_subset is particle_database["f_0(1500)0"] + assert f0_1500_from_subset is not particle_database["f_0(980)0"] search_result = particle_database.filter(lambda p: p.pid == 22) gamma_from_subset = search_result["gamma"] @@ -261,23 +252,19 @@ def it_filter(particle_database: ParticleCollection, skh_particle_version: str): ) sorted_result = sorted(filtered_result.names) expected = { - "K(2)(1820)+", - "K(2)(1820)0", + "K_2(1820)+", + "K_2(1820)0", + "K_2^*(1980)+", + "K_2^*(1980)0", } - if skh_particle_version > "0.15": - additional_particles = { - "K(2)*(1980)+", - "K(2)*(1980)0", - } - expected.update(additional_particles) sorted_expected = sorted(expected) assert sorted_result == sorted_expected def it_find(particle_database: ParticleCollection): f2_1950 = particle_database.find(9050225) - assert f2_1950.name == "f(2)(1950)" - assert f2_1950.mass == 1.936 - phi = particle_database.find("phi(1020)") + assert f2_1950.name == "f_2(1950)0" + assert f2_1950.mass == pytest.approx(1.9361938561872178) + phi = particle_database.find("phi(1020)0") assert phi.pid == 333 assert pytest.approx(phi.width) == 0.004249 @@ -288,11 +275,9 @@ def it_find(particle_database: ParticleCollection): ("non-existing", None), # cspell:disable ("gamm", "gamma"), - ("gama", ["gamma", "Sigma0", "Sigma-", "Sigma+", "Lambda"]), + ("gama", ["gamma", "Sigma0", "Sigma-", "Sigma+"]), ("omega", _get_omega_mesons()), - ("p~~", "p~"), - ("~", ["p~", "n~"]), - ("lambda", ["Lambda", "Lambda~", "Lambda(c)+", "Lambda(b)0"]), + ("lambda", ["Lambda0", "Lambdabar0"]), # cspell:enable ], ) @@ -409,51 +394,22 @@ def it_creates_a_named_antiparticle( assert anti_particle == comparison_particle - def it_tilde(particle_database: ParticleCollection, skh_particle_version: str): - anti_particles = particle_database.filter(lambda p: "~" in p.name) - if skh_particle_version < "0.14": - assert len(anti_particles) == 165 - elif skh_particle_version < "0.16": - assert len(anti_particles) == 172 - elif skh_particle_version < "0.25": - assert len(anti_particles) == 175 - else: - assert len(anti_particles) == 176 - for anti_particle in anti_particles: - particle_name = anti_particle.name.replace("~", "") - if "+" in particle_name: - particle_name = particle_name.replace("+", "-") - elif "-" in particle_name: - particle_name = particle_name.replace("-", "+") - created_particle = create_antiparticle(anti_particle, particle_name) - assert created_particle == particle_database[particle_name] - - def it_by_pid(particle_database: ParticleCollection, skh_particle_version: str): - n_particles_with_neg_pid = 0 + def it_by_pid(particle_database: ParticleCollection): for particle in particle_database: anti_particles_by_pid = particle_database.filter( lambda p: p.pid == -particle.pid # ruff: ignore[function-uses-loop-variable] ) if len(anti_particles_by_pid) != 1: continue - n_particles_with_neg_pid += 1 anti_particle = next(iter(anti_particles_by_pid)) particle_from_anti = -anti_particle assert particle == particle_from_anti - if skh_particle_version < "0.14": - assert n_particles_with_neg_pid == 428 - elif skh_particle_version < "0.16": - assert n_particles_with_neg_pid == 442 - elif skh_particle_version < "0.25": - assert n_particles_with_neg_pid == 454 - else: - assert n_particles_with_neg_pid == 456 def describe_create_particle(): @pytest.mark.parametrize( "particle_name", - ["p", "phi(1020)", "W-", "gamma"], + ["p", "phi(1020)0", "W-", "gamma"], ) def it_create_particle(particle_database: ParticleCollection, particle_name: str): template_particle = particle_database[particle_name] diff --git a/tests/unit/test_pdg.py b/tests/unit/test_pdg.py index a89d3597..6b38eda7 100644 --- a/tests/unit/test_pdg.py +++ b/tests/unit/test_pdg.py @@ -1,4 +1,3 @@ -import particle import pytest from qrules.particle import ParticleCollection, load_pdg @@ -9,54 +8,29 @@ def pdg() -> ParticleCollection: return load_pdg() -def test_maybe_qq(): - expected_maybe_qq = { - "a(0)(980)+", - "a(0)(980)-", - "a(0)(980)0", - "f(0)(1500)", - "f(0)(500)", - "f(0)(980)", - "pi(1)(1400)+", - "pi(1)(1400)-", - "pi(1)(1400)0", - "pi(1)(1600)+", - "pi(1)(1600)-", - "pi(1)(1600)0", - } - maybe_qq_search_results = particle.Particle.findall( - lambda p: "qq" in p.quarks.lower() - ) - assert expected_maybe_qq == {item.name for item in maybe_qq_search_results} - - -def test_pdg_size(pdg: ParticleCollection): - assert len(pdg) in { - 512, # particle==0.13 - 519, # particle==0.14-0.15 - 531, # particle==0.16-0.20 - 530, # particle==0.21-0.22 - 537, # particle==0.23-0.24 - 539, # particle==0.25 - 540, # particle==0.25.4 - 541, # particle==0.26.0 - } - assert len(pdg.filter(lambda p: "~" in p.name)) in { - 165, # particle==0.13 - 172, # particle==0.14, 0.15 - 175, # particle==0.16-0.24 - 176, # particle==0.25 +def test_contains_representative_particles(pdg: ParticleCollection): + expected_names = { + "J/psi(1S)", + "f_0(500)0", + "f_0(980)0", + "gamma", + "nu_e", + "pbar", + "pi+", } + assert expected_names <= set(pdg.names) + +def test_has_unique_names_and_mcids(pdg: ParticleCollection): + assert len(pdg.names) == len(set(pdg.names)) + assert len(pdg) == len({particle.pid for particle in pdg}) -def test_missing_in_pdg( + +def test_default_particle_definitions_extend_pdg( pdg: ParticleCollection, particle_database: ParticleCollection, ): - particle_list_names = set(particle_database) - pdg_names = set(pdg) - in_common = particle_list_names & pdg_names - missing_in_pdg = particle_list_names ^ in_common - assert {p.name for p in missing_in_pdg} == { - "Y(4260)", - } + pdg_names = set(pdg.names) + default_names = set(particle_database.names) + assert pdg_names <= default_names + assert default_names - pdg_names == {"Y(4260)"} diff --git a/tests/unit/test_pdg_adapter.py b/tests/unit/test_pdg_adapter.py index 04fc9407..9254e9fa 100644 --- a/tests/unit/test_pdg_adapter.py +++ b/tests/unit/test_pdg_adapter.py @@ -4,10 +4,9 @@ import pytest from pdg.errors import PdgNoDataError -from qrules._pdg import _to_mass, _to_width +from qrules._pdg import _load_pdg_particles, _to_mass, _to_width from qrules._pdg import load_pdg as load_official_pdg -from qrules.particle import ParticleCollection -from qrules.particle import load_pdg as load_scikit_hep_pdg +from qrules.particle import ParticleCollection, load_pdg from qrules.quantum_numbers import Parity @@ -16,18 +15,22 @@ def official_particles() -> ParticleCollection: return load_official_pdg() -@pytest.fixture(scope="module") -def scikit_hep_particles() -> ParticleCollection: - return load_scikit_hep_pdg() - - -def test_current_mcids_are_covered( +def test_caches_particle_definitions_and_returns_independent_collections( official_particles: ParticleCollection, - scikit_hep_particles: ParticleCollection, ): - official_mcids = {particle.pid for particle in official_particles} - current_mcids = {particle.pid for particle in scikit_hep_particles} - assert current_mcids <= official_mcids + cache_info_before = _load_pdg_particles.cache_info() + + second_collection = load_pdg() + + cache_info_after = _load_pdg_particles.cache_info() + assert cache_info_after.hits == cache_info_before.hits + 1 + assert cache_info_after.misses == cache_info_before.misses + assert second_collection == official_particles + assert second_collection is not official_particles + + second_collection.discard("gamma") + assert "gamma" not in second_collection + assert "gamma" in official_particles @pytest.mark.parametrize( @@ -117,23 +120,29 @@ def test_lepton_numbers( ) == lepton_numbers -def test_flavor_numbers_match_current_loader( +@pytest.mark.parametrize( + ("mcid", "flavor_numbers"), + [ + (+321, (+1, 0, 0, 0)), + (-321, (-1, 0, 0, 0)), + (+411, (0, +1, 0, 0)), + (-411, (0, -1, 0, 0)), + (+521, (0, 0, +1, 0)), + (-521, (0, 0, -1, 0)), + ], +) +def test_flavor_numbers( official_particles: ParticleCollection, - scikit_hep_particles: ParticleCollection, + mcid: int, + flavor_numbers: tuple[int, int, int, int], ): - for current in scikit_hep_particles: - official = official_particles.find(current.pid) - assert ( - official.strangeness, - official.charmness, - official.bottomness, - official.topness, - ) == ( - current.strangeness, - current.charmness, - current.bottomness, - current.topness, - ) + particle = official_particles.find(mcid) + assert ( + particle.strangeness, + particle.charmness, + particle.bottomness, + particle.topness, + ) == flavor_numbers def test_prefers_official_spin(official_particles: ParticleCollection): diff --git a/tests/unit/test_qrules.py b/tests/unit/test_qrules.py index c314921d..45594f59 100644 --- a/tests/unit/test_qrules.py +++ b/tests/unit/test_qrules.py @@ -6,17 +6,17 @@ @pytest.mark.parametrize( "resonance_names", [ - ["Sigma(1660)~-"], + ["Sigmabar(1660)-"], ["N(1650)+"], - ["K*(1680)~0"], - ["Sigma(1660)~-", "N(1650)+"], - ["Sigma(1660)~-", "K*(1680)~0"], - ["N(1650)+", "K*(1680)~0"], - ["Sigma(1660)~-", "N(1650)+", "K*(1680)~0"], + ["Kbar^*(1680)0"], + ["Sigmabar(1660)-", "N(1650)+"], + ["Sigmabar(1660)-", "Kbar^*(1680)0"], + ["N(1650)+", "Kbar^*(1680)0"], + ["Sigmabar(1660)-", "N(1650)+", "Kbar^*(1680)0"], ], ) def test_generate_transitions(resonance_names): - final_state_names = ["K0", "Sigma+", "p~"] + final_state_names = ["K0", "Sigma+", "pbar"] reaction = generate_transitions( initial_state="J/psi(1S)", final_state=final_state_names, diff --git a/tests/unit/test_system_control.py b/tests/unit/test_system_control.py index 5f8fcd04..a9bfb305 100644 --- a/tests/unit/test_system_control.py +++ b/tests/unit/test_system_control.py @@ -2,7 +2,6 @@ from copy import deepcopy from fractions import Fraction -from importlib.metadata import version import attrs import pytest @@ -38,32 +37,32 @@ [ ( [("Y(4260)", [-1])], - [("D0", [0]), ("D~0", [0]), ("pi0", [0]), ("pi0", [0])], - [[["D0", "pi0"], ["D~0", "pi0"]]], + [("D0", [0]), ("Dbar0", [0]), ("pi0", [0]), ("pi0", [0])], + [[["D0", "pi0"], ["Dbar0", "pi0"]]], 1, ), ( [("Y(4260)", [-1, 1])], - [("D0", [0]), ("D~0", [0]), ("pi0", [0]), ("pi0", [0])], - [[["D0", "pi0"], ["D~0", "pi0"]]], + [("D0", [0]), ("Dbar0", [0]), ("pi0", [0]), ("pi0", [0])], + [[["D0", "pi0"], ["Dbar0", "pi0"]]], 2, ), ( [("Y(4260)", [1])], - [("D0", [0]), ("D~0", [0]), ("pi0", [0]), ("pi0", [0])], + [("D0", [0]), ("Dbar0", [0]), ("pi0", [0]), ("pi0", [0])], [], 9, ), ( [("Y(4260)", [-1, 1])], - [("D0", [0]), ("D~0", [0]), ("pi0", [0]), ("pi0", [0])], + [("D0", [0]), ("Dbar0", [0]), ("pi0", [0]), ("pi0", [0])], [], 18, ), ( [("Y(4260)", [1])], - [("D0", [0]), ("D~0", [0]), ("pi0", [0]), ("pi0", [0])], - [[["D0", "pi0"], ["D~0", "pi0"]], ["D0", "pi0"]], + [("D0", [0]), ("Dbar0", [0]), ("pi0", [0]), ("pi0", [0])], + [[["D0", "pi0"], ["Dbar0", "pi0"]], ["D0", "pi0"]], 3, ), ( @@ -115,26 +114,6 @@ def test_external_edge_initialization( assert len(next(iter(problem_sets.values()))) == result_graph_count -def get_pi0_width() -> float: - if version("particle") < "0.16": - return 7.73e-09 - return 7.81e-09 - - -def __get_d_pos() -> tuple[float, float]: - if version("particle") < "0.16": - return 1.86965, 6.33e-13 - if version("particle") < "0.21": - return 1.86966, 6.33e-13 - return 1.86966, 6.37e-13 - - -def __get_f2_1270_pos() -> tuple[float, float]: - if version("particle") < "0.23": - return 1.2755, 0.18669999999999998 - return 1.2754, 0.1866 - - @pytest.mark.parametrize( ("particle_name", "spin_projection", "expected_properties"), [ @@ -143,8 +122,8 @@ def __get_f2_1270_pos() -> tuple[float, float]: 0, { EdgeQuantumNumbers.pid: 111, - EdgeQuantumNumbers.mass: 0.1349768, - EdgeQuantumNumbers.width: get_pi0_width(), + EdgeQuantumNumbers.mass: 0.1349768277676847, + EdgeQuantumNumbers.width: 7.811987971364424e-09, EdgeQuantumNumbers.spin_magnitude: 0.0, EdgeQuantumNumbers.spin_projection: 0, EdgeQuantumNumbers.charge: 0, @@ -168,8 +147,8 @@ def __get_f2_1270_pos() -> tuple[float, float]: 0, { EdgeQuantumNumbers.pid: 411, - EdgeQuantumNumbers.mass: __get_d_pos()[0], - EdgeQuantumNumbers.width: __get_d_pos()[1], + EdgeQuantumNumbers.mass: 1.869664743570898, + EdgeQuantumNumbers.width: 6.370029016102887e-13, EdgeQuantumNumbers.spin_magnitude: 0.0, EdgeQuantumNumbers.spin_projection: 0, EdgeQuantumNumbers.charge: 1, @@ -189,12 +168,12 @@ def __get_f2_1270_pos() -> tuple[float, float]: }, ), ( - "f(2)(1270)", # spin projection 1 + "f_2(1270)0", # spin projection 1 1.0, { EdgeQuantumNumbers.pid: 225, - EdgeQuantumNumbers.mass: __get_f2_1270_pos()[0], - EdgeQuantumNumbers.width: __get_f2_1270_pos()[1], + EdgeQuantumNumbers.mass: 1.2754120499190051, + EdgeQuantumNumbers.width: 0.18655435663732642, EdgeQuantumNumbers.spin_magnitude: 2.0, EdgeQuantumNumbers.spin_projection: 1.0, EdgeQuantumNumbers.charge: 0, @@ -220,11 +199,9 @@ def test_create_edge_properties( spin_projection, expected_properties, particle_database, - skh_particle_version: str, ): particle = particle_database[particle_name] assert create_edge_properties(particle, spin_projection) == expected_properties - assert skh_particle_version is not None # dummy for skip tests def make_ls_test_graph( @@ -369,7 +346,7 @@ def _create_graph( [ ( [("Y(4260)", [-1])], - [("D0", [0]), ("D~0", [0]), ("pi0", [0]), ("pi0", [0])], + [("D0", [0]), ("Dbar0", [0]), ("pi0", [0]), ("pi0", [0])], ), ], ) @@ -410,7 +387,7 @@ def test_edge_swap(particle_database, initial_state, final_state): [ ( [("Y(4260)", [-1])], - [("D0", [0]), ("D~0", [0]), ("pi0", [0]), ("pi0", [0])], + [("D0", [0]), ("Dbar0", [0]), ("pi0", [0]), ("pi0", [0])], ), ( [("J/psi(1S)", [-1, 1])], @@ -463,13 +440,13 @@ def test_match_external_edges(particle_database, initial_state, final_state): [ ( [("Y(4260)", [1])], - [("D0", [0]), ("D~0", [0]), ("pi0", [0]), ("pi0", [0])], - [[["D0", "pi0"], ["D~0", "pi0"]]], + [("D0", [0]), ("Dbar0", [0]), ("pi0", [0]), ("pi0", [0])], + [[["D0", "pi0"], ["Dbar0", "pi0"]]], 2, ), ( [("Y(4260)", [1])], - [("D0", [0]), ("D~0", [0]), ("pi0", [0]), ("pi0", [0])], + [("D0", [0]), ("Dbar0", [0]), ("pi0", [0]), ("pi0", [0])], [["D0", "pi0"]], 6, ), diff --git a/tests/unit/test_transition.py b/tests/unit/test_transition.py index 40789773..00f19bcb 100644 --- a/tests/unit/test_transition.py +++ b/tests/unit/test_transition.py @@ -27,7 +27,7 @@ def describe_MutableTransition(): def it_intermediate_states(): stm = StateTransitionManager( initial_state=[("J/psi(1S)", [-1, +1])], - final_state=["K0", "Sigma+", "p~"], + final_state=["K0", "Sigma+", "pbar"], allowed_intermediate_particles=["N(1700)", "Sigma(1750)"], formalism="helicity", mass_conservation_factor=0, @@ -109,7 +109,7 @@ def describe_StateTransitionManager(): def it_allowed_intermediate_particles(): stm = StateTransitionManager( initial_state=[("J/psi(1S)", list(map(Fraction, [-1, +1])))], - final_state=["p", "p~", "eta"], + final_state=["p", "pbar", "eta"], ) particle_name = "N(753)" with pytest.raises( @@ -122,7 +122,7 @@ def it_allowed_intermediate_particles(): ("initial_state", "expected_strengths"), [ (["gamma"], [0.0001, 1.0, 60.0]), - (["nu(e)"], [1e-08, 0.0001, 0.006]), + (["nu_e"], [1e-08, 0.0001, 0.006]), ], ) def it_initial_state_restricts_interaction_types( @@ -147,7 +147,7 @@ def count_transitions(solving_mode: SolvingMode) -> int: def it_regex_pattern(): stm = StateTransitionManager( - initial_state=["Lambda(c)+"], + initial_state=["Lambda_c()+"], final_state=["p", "K-", "pi+"], allowed_intermediate_particles=["Delta"], ) From 613bd2036178f78427e02f6feb80f126cbaaf80e Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:58:46 +0200 Subject: [PATCH 10/27] ENH: update lepton interaction type check and enhance test assertions for particle width --- src/qrules/system_control.py | 2 +- tests/unit/io/conftest.py | 8 ++++++-- tests/unit/test_particle.py | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/qrules/system_control.py b/src/qrules/system_control.py index 6a56e0b5..56a76501 100644 --- a/src/qrules/system_control.py +++ b/src/qrules/system_control.py @@ -190,7 +190,7 @@ def check( # ruff: ignore[no-self-use] node_interaction_types = list(InteractionType) for particle, _ in in_states + out_states: if particle.is_lepton(): - if particle.name.startswith("nu("): + if abs(particle.pid) in {12, 14, 16}: node_interaction_types = [InteractionType.WEAK] break node_interaction_types = [ diff --git a/tests/unit/io/conftest.py b/tests/unit/io/conftest.py index d7d6deab..0f59965f 100644 --- a/tests/unit/io/conftest.py +++ b/tests/unit/io/conftest.py @@ -21,7 +21,7 @@ def stm() -> StateTransitionManager: stm = StateTransitionManager( initial_state=[("J/psi(1S)", [+1])], final_state=["K0", ("Sigma+", [+0.5]), ("pbar", [+0.5])], - allowed_intermediate_particles=["Sigma(1750)"], + allowed_intermediate_particles=["Sigma(1750)", "Sigmabar(1750)"], formalism="canonical-helicity", ) stm.set_allowed_interaction_types([InteractionType.STRONG, InteractionType.EM]) @@ -40,4 +40,8 @@ def qn_problem_and_result( ) -> tuple[QNProblemSet, QNResult]: qn_solutions = stm.find_quantum_number_transitions(problem_sets) strong_qn_solutions = qn_solutions[3600.0] - return strong_qn_solutions[1] + return next( + (problem, result) + for problem, result in strong_qn_solutions + if result.solutions + ) diff --git a/tests/unit/test_particle.py b/tests/unit/test_particle.py index d8b47374..9e4733c9 100644 --- a/tests/unit/test_particle.py +++ b/tests/unit/test_particle.py @@ -266,7 +266,7 @@ def it_find(particle_database: ParticleCollection): assert f2_1950.mass == pytest.approx(1.9361938561872178) phi = particle_database.find("phi(1020)0") assert phi.pid == 333 - assert pytest.approx(phi.width) == 0.004249 + assert phi.width == pytest.approx(0.004249, abs=1e-6) @pytest.mark.parametrize( ("search_term", "expected"), From 94083bedbdecdc91e465bf2a902a010ae589cb04 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:59:12 +0000 Subject: [PATCH 11/27] MAINT: implement pre-commit autofixes --- tests/unit/io/conftest.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/unit/io/conftest.py b/tests/unit/io/conftest.py index 0f59965f..3c045d1d 100644 --- a/tests/unit/io/conftest.py +++ b/tests/unit/io/conftest.py @@ -41,7 +41,5 @@ def qn_problem_and_result( qn_solutions = stm.find_quantum_number_transitions(problem_sets) strong_qn_solutions = qn_solutions[3600.0] return next( - (problem, result) - for problem, result in strong_qn_solutions - if result.solutions + (problem, result) for problem, result in strong_qn_solutions if result.solutions ) From 4ac406555f753eb063c2ad4141e5cf40e4001b93 Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:20:56 +0200 Subject: [PATCH 12/27] Revert changes made on tests and on pyproject.toml --- pyproject.toml | 1 + src/qrules/__init__.py | 4 +- src/qrules/particle.py | 2 +- src/qrules/system_control.py | 2 +- tests/channels/test_d0_to_ks_kp_km.py | 18 +-- tests/channels/test_jpsi_to_gamma_pi0_pi0.py | 20 ++-- tests/channels/test_lc_to_p_km_pip.py | 26 ++--- tests/channels/test_nbody_reactions.py | 32 ++--- tests/channels/test_psi2s_to_eta_k_kstar.py | 10 +- tests/channels/test_y_to_d0_d0bar_pi0_pi0.py | 20 ++-- tests/conftest.py | 11 ++ tests/unit/conftest.py | 2 +- tests/unit/io/conftest.py | 8 +- tests/unit/io/test_dict.py | 8 +- tests/unit/io/test_dot.py | 6 +- tests/unit/io/test_labels.py | 23 ++-- tests/unit/io/test_mermaid.py | 6 +- tests/unit/test_final_state_permutations.py | 4 +- tests/unit/test_parity_prefactor.py | 2 +- tests/unit/test_particle.py | 116 +++++++++++++------ tests/unit/test_pdg.py | 66 +++++++---- tests/unit/test_qrules.py | 14 +-- tests/unit/test_system_control.py | 63 ++++++---- tests/unit/test_transition.py | 8 +- uv.lock | 25 ++++ 25 files changed, 316 insertions(+), 181 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a9420b29..a59eeb9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ "attrs >=20.1.0", # on_setattr and https://www.attrs.org/en/stable/api.html#next-gen "frozendict", "jsonschema", + "particle", "pdg >=2026.0,<2027", "python-constraint2", "tqdm >=4.24.0", # autonotebook diff --git a/src/qrules/__init__.py b/src/qrules/__init__.py index 671014a5..4972cdab 100644 --- a/src/qrules/__init__.py +++ b/src/qrules/__init__.py @@ -344,8 +344,8 @@ def generate_transitions( # ruff: ignore[too-many-positional-arguments] >>> import qrules >>> reaction = qrules.generate_transitions( ... initial_state="D0", - ... final_state=["Kbar0", "K+", "K-"], - ... allowed_intermediate_particles=["a_0(980)", "a_2(1320)-"], + ... final_state=["K~0", "K+", "K-"], + ... allowed_intermediate_particles=["a(0)(980)", "a(2)(1320)-"], ... allowed_interaction_types=["e", "w"], ... formalism="helicity", ... particle_db=qrules.load_pdg(), diff --git a/src/qrules/particle.py b/src/qrules/particle.py index 687a4658..79dc6d0d 100644 --- a/src/qrules/particle.py +++ b/src/qrules/particle.py @@ -389,7 +389,7 @@ def filter(self, function: Callable[[Particle], bool]) -> ParticleCollection: ... ) ... ) >>> sorted(subset.names) - ['K_2(1820)+', 'K_2(1820)0', 'K_2^*(1980)+', 'K_2^*(1980)0'] + ['K(2)(1820)+', 'K(2)(1820)0', 'K(2)*(1980)+', 'K(2)*(1980)0'] """ return ParticleCollection({particle for particle in self if function(particle)}) diff --git a/src/qrules/system_control.py b/src/qrules/system_control.py index 56a76501..6a56e0b5 100644 --- a/src/qrules/system_control.py +++ b/src/qrules/system_control.py @@ -190,7 +190,7 @@ def check( # ruff: ignore[no-self-use] node_interaction_types = list(InteractionType) for particle, _ in in_states + out_states: if particle.is_lepton(): - if abs(particle.pid) in {12, 14, 16}: + if particle.name.startswith("nu("): node_interaction_types = [InteractionType.WEAK] break node_interaction_types = [ diff --git a/tests/channels/test_d0_to_ks_kp_km.py b/tests/channels/test_d0_to_ks_kp_km.py index a4a3371a..f966f244 100644 --- a/tests/channels/test_d0_to_ks_kp_km.py +++ b/tests/channels/test_d0_to_ks_kp_km.py @@ -4,11 +4,11 @@ def test_script(): reaction = qrules.generate_transitions( initial_state="D0", - final_state=["Kbar0", "K+", "K-"], + final_state=["K~0", "K+", "K-"], allowed_intermediate_particles=[ - "a_0(980)", - "a_2(1320)-", - "phi(1020)0", + "a(0)(980)", + "a(2)(1320)-", + "phi(1020)", ], ) groupings = sorted(reaction.group_by_topology().values()) @@ -17,9 +17,9 @@ def test_script(): assert len(groupings[1]) == 2 assert len(groupings[2]) == 1 assert reaction.get_intermediate_particles().names == [ - "a_0(980)-", - "a_0(980)0", - "a_0(980)+", - "a_2(1320)-", - "phi(1020)0", + "a(0)(980)-", + "a(0)(980)0", + "a(0)(980)+", + "a(2)(1320)-", + "phi(1020)", ] diff --git a/tests/channels/test_jpsi_to_gamma_pi0_pi0.py b/tests/channels/test_jpsi_to_gamma_pi0_pi0.py index 72f3b329..8125d2c9 100644 --- a/tests/channels/test_jpsi_to_gamma_pi0_pi0.py +++ b/tests/channels/test_jpsi_to_gamma_pi0_pi0.py @@ -8,17 +8,17 @@ def describe_reaction_generation(): @pytest.mark.parametrize( ("allowed_intermediate_particles", "n_topologies", "number_of_solutions"), [ - (["f_0(1500)0"], 1, 4), - (["f_0(980)0", "f_0(1500)0"], 1, 8), - (["f_2(1270)0"], 1, 12), - (["omega(782)0"], 1, 8), + (["f(0)(1500)"], 1, 4), + (["f(0)(980)", "f(0)(1500)"], 1, 8), + (["f(2)(1270)"], 1, 12), + (["omega(782)"], 1, 8), ( [ - "f_0(980)0", - "f_2(1270)0", - "f_0(1500)0", - "f_2(1950)0", - "omega(782)0", + "f(0)(980)", + "f(2)(1270)", + "f(0)(1500)", + "f(2)(1950)", + "omega(782)", ], 2, 40, @@ -53,7 +53,7 @@ def it_id_to_particle_mappings(particle_database): final_state=["gamma", "pi0", "pi0"], particle_db=particle_database, allowed_interaction_types="strong", - allowed_intermediate_particles=["f_0(980)0"], + allowed_intermediate_particles=["f(0)(980)"], formalism="helicity", ) assert len(reaction.group_by_topology()) == 1 diff --git a/tests/channels/test_lc_to_p_km_pip.py b/tests/channels/test_lc_to_p_km_pip.py index 14f46abb..a54ae66e 100644 --- a/tests/channels/test_lc_to_p_km_pip.py +++ b/tests/channels/test_lc_to_p_km_pip.py @@ -4,7 +4,7 @@ def test_resonances(): stm = StateTransitionManager( - initial_state=["Lambda_c()+"], + initial_state=["Lambda(c)+"], final_state=["p", "K-", "pi+"], mass_conservation_factor=0.6, max_angular_momentum=2, @@ -20,18 +20,18 @@ def test_resonances(): "Delta(1600)++", "Delta(1620)++", "Delta(1700)++", - "Kbar_0^*(700)0", - "Kbar^*(892)0", - "Kbar^*(1410)0", - "Kbar_0^*(1430)0", - "Lambda(1405)0", - "Lambda(1520)0", - "Lambda(1600)0", - "Lambda(1670)0", - "Lambda(1690)0", - "Lambda(1810)0", - "Lambda(1800)0", - "Lambda(1890)0", + "K(0)*(700)~0", + "K*(892)~0", + "K*(1410)~0", + "K(0)*(1430)~0", + "Lambda(1405)", + "Lambda(1520)", + "Lambda(1600)", + "Lambda(1670)", + "Lambda(1690)", + "Lambda(1810)", + "Lambda(1800)", + "Lambda(1890)", } sorted_expected = sorted(expected) assert sorted_resonances == sorted_expected diff --git a/tests/channels/test_nbody_reactions.py b/tests/channels/test_nbody_reactions.py index c07cd14b..e854bfe9 100644 --- a/tests/channels/test_nbody_reactions.py +++ b/tests/channels/test_nbody_reactions.py @@ -23,7 +23,7 @@ def reduce_violated_rules( ("test_input", "expected"), [ ( - (["p", "pbar"], ["pi+", "pi0"]), + (["p", "p~"], ["pi+", "pi0"]), {"ChargeConservation", "isospin_conservation"}, ), ( @@ -41,7 +41,7 @@ def reduce_violated_rules( }, ), ( - (["Sigma0"], ["Lambda0", "pi0"]), + (["Sigma0"], ["Lambda", "pi0"]), {"MassConservation"}, ), ( @@ -56,11 +56,11 @@ def reduce_violated_rules( {}, ), ( - (["mu-"], ["e-", "nubar_e"]), + (["mu-"], ["e-", "nu(e)~"]), {"MuonLNConservation", "spin_magnitude_conservation"}, ), ( - (["mu-"], ["e-", "nu_e"]), + (["mu-"], ["e-", "nu(e)"]), { "ElectronLNConservation", "MuonLNConservation", @@ -72,15 +72,15 @@ def reduce_violated_rules( {}, ), ( - (["nubar_e", "p"], ["n", "e+"]), + (["nu(e)~", "p"], ["n", "e+"]), {}, ), ( - (["e-", "p"], ["nu_e", "pi0"]), + (["e-", "p"], ["nu(e)", "pi0"]), {"BaryonNumberConservation", "spin_magnitude_conservation"}, ), ( - (["f_0(980)0"], ["pi+", "pi-"]), + (["f(0)(980)"], ["pi+", "pi-"]), {}, ), ( @@ -100,7 +100,7 @@ def reduce_violated_rules( {}, ), ( - (["J/psi(1S)"], ["pi0", "f_0(980)0"]), + (["J/psi(1S)"], ["pi0", "f(0)(980)"]), { "isospin_conservation", "c_parity_conservation", @@ -112,7 +112,7 @@ def reduce_violated_rules( {}, ), ( - (["p", "p"], ["Sigma+", "n", "Kbar0", "pi+", "pi0"]), + (["p", "p"], ["Sigma+", "n", "K~0", "pi+", "pi0"]), {"StrangenessConservation", "isospin_conservation"}, ), ( @@ -120,11 +120,11 @@ def reduce_violated_rules( {"ElectronLNConservation", "BaryonNumberConservation"}, ), ( - (["p", "p"], ["p", "p", "p", "pbar"]), + (["p", "p"], ["p", "p", "p", "p~"]), {}, ), ( - (["n", "nbar"], ["pi+", "pi-", "pi0"]), + (["n", "n~"], ["pi+", "pi-", "pi0"]), {}, ), ( @@ -143,9 +143,9 @@ def reduce_violated_rules( (["Sigma+", "n"], ["Sigma-", "p"]), {"ChargeConservation", "isospin_conservation"}, ), - ((["Sigma0"], ["Lambda0", "gamma"]), []), + ((["Sigma0"], ["Lambda", "gamma"]), []), ( - (["Xi-"], ["Lambda0", "pi-"]), + (["Xi-"], ["Lambda", "pi-"]), {"StrangenessConservation", "isospin_conservation"}, ), ( @@ -153,11 +153,11 @@ def reduce_violated_rules( {"StrangenessConservation", "isospin_conservation"}, ), ( - (["pi-", "p"], ["Lambda0", "K0"]), + (["pi-", "p"], ["Lambda", "K0"]), {}, ), ( - (["Sigma-"], ["n", "e-", "nubar_e"]), + (["Sigma-"], ["n", "e-", "nu(e)~"]), {"StrangenessConservation"}, ), ( @@ -185,7 +185,7 @@ def reduce_violated_rules( {"c_parity_conservation", "g_parity_conservation"}, ), ( - (["Kbar0"], ["pi+", "pi-", "pi0"]), + (["K~0"], ["pi+", "pi-", "pi0"]), {"isospin_conservation", "StrangenessConservation"}, ), ], diff --git a/tests/channels/test_psi2s_to_eta_k_kstar.py b/tests/channels/test_psi2s_to_eta_k_kstar.py index 8c6029d8..fcc1133c 100644 --- a/tests/channels/test_psi2s_to_eta_k_kstar.py +++ b/tests/channels/test_psi2s_to_eta_k_kstar.py @@ -11,15 +11,15 @@ @pytest.mark.parametrize( "resonances", [ - ["h_1(1415)0"], - ["omega(1650)0"], - ["h_1(1415)0", "omega(1650)0"], + ["h(1)(1415)"], + ["omega(1650)"], + ["h(1)(1415)", "omega(1650)"], ], ) def test_resonances(formalism: SpinFormalism, resonances, modified_pdg): reaction = qrules.generate_transitions( initial_state=("psi(2S)", [+1, -1]), - final_state=["eta", "K-", "K^*(892)+"], + final_state=["eta", "K-", "K*(892)+"], allowed_intermediate_particles=resonances, allowed_interaction_types=["em"], formalism=formalism, @@ -33,7 +33,7 @@ def modified_pdg(particle_database) -> ParticleCollection: # https://github.com/scikit-hep/particle/issues/486 # https://github.com/ComPWA/qrules/issues/165#issuecomment-1497343548 particles = ParticleCollection(particle_database) - original_h1415 = particles["h_1(1415)0"] + original_h1415 = particles["h(1)(1415)"] new_h1415 = qrules.particle.create_particle( original_h1415, isospin=qrules.particle.Spin(0, 0), diff --git a/tests/channels/test_y_to_d0_d0bar_pi0_pi0.py b/tests/channels/test_y_to_d0_d0bar_pi0_pi0.py index 6e48b62b..8b0049d2 100644 --- a/tests/channels/test_y_to_d0_d0bar_pi0_pi0.py +++ b/tests/channels/test_y_to_d0_d0bar_pi0_pi0.py @@ -15,7 +15,7 @@ def describe_reaction_generation(): def it_simple(formalism, n_solutions, particle_database): reaction = qrules.generate_transitions( initial_state=[("Y(4260)", [-1, +1])], - final_state=["D^*(2007)0", "Dbar^*(2007)0"], + final_state=["D*(2007)0", "D*(2007)~0"], particle_db=particle_database, formalism=formalism, allowed_interaction_types="strong", @@ -34,13 +34,13 @@ def it_simple(formalism, n_solutions, particle_database): def it_full(formalism, n_solutions, particle_database): stm = StateTransitionManager( initial_state=[("Y(4260)", [-1, +1])], - final_state=["D0", "Dbar0", "pi0", "pi0"], + final_state=["D0", "D~0", "pi0", "pi0"], particle_db=particle_database, - allowed_intermediate_particles=["D^*", "Dbar^*"], + allowed_intermediate_particles=["D*"], formalism=formalism, ) stm.set_allowed_interaction_types([InteractionType.STRONG]) - stm.add_final_state_grouping([["D0", "pi0"], ["Dbar0", "pi0"]]) + stm.add_final_state_grouping([["D0", "pi0"], ["D~0", "pi0"]]) problem_sets = stm.create_problem_sets() reaction = stm.find_solutions(problem_sets) assert len(reaction.group_by_topology()) == 1 @@ -50,17 +50,17 @@ def it_resonance_filter(particle_database): # https://github.com/ComPWA/qrules/issues/33 stm = StateTransitionManager( initial_state=[("Y(4260)", [-1, +1])], - final_state=["D0", "Dbar0", "pi0", "pi0"], + final_state=["D0", "D~0", "pi0", "pi0"], allowed_intermediate_particles=["D"], particle_db=particle_database, ) stm.set_allowed_interaction_types([InteractionType.STRONG]) - stm.add_final_state_grouping([["D0", "pi0"], ["Dbar0", "pi0"]]) + stm.add_final_state_grouping([["D0", "pi0"], ["D~0", "pi0"]]) problem_sets = stm.create_problem_sets() result = stm.find_solutions(problem_sets) assert set(result.get_intermediate_particles().names) == { - "D^*(2007)0", - "Dbar^*(2007)0", - "D_0^*(2300)0", - "Dbar_0^*(2300)0", + "D*(2007)0", + "D*(2007)~0", + "D(0)*(2300)0", + "D(0)*(2300)~0", } diff --git a/tests/conftest.py b/tests/conftest.py index a31d1bc7..897c5232 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +from importlib.metadata import version + import pytest from qrules import load_default_particles @@ -17,3 +19,12 @@ def particle_database() -> ParticleCollection: @pytest.fixture(scope="session") def output_dir(pytestconfig) -> str: return f"{pytestconfig.rootpath}/tests/output/" + + +@pytest.fixture(scope="session") +def skh_particle_version() -> str: + major, minor, *_ = (int(i) for i in version("particle").split(".")) + particle_version = f"{major}.{minor}" + if (major, minor) < (0, 11): + pytest.skip(f"Version {particle_version} is not supported in the tests") + return particle_version diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 4f5fd5a9..a0d414e2 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -20,7 +20,7 @@ def reaction(request: SubRequest) -> ReactionInfo: return qrules.generate_transitions( initial_state=[("J/psi(1S)", [-1, 1])], final_state=["gamma", "pi0", "pi0"], - allowed_intermediate_particles=["f_0(980)0", "f_0(1500)0"], + allowed_intermediate_particles=["f(0)(980)", "f(0)(1500)"], allowed_interaction_types="strong", formalism=formalism, ) diff --git a/tests/unit/io/conftest.py b/tests/unit/io/conftest.py index 3c045d1d..647101fd 100644 --- a/tests/unit/io/conftest.py +++ b/tests/unit/io/conftest.py @@ -20,8 +20,8 @@ def particle_selection(particle_database: ParticleCollection): def stm() -> StateTransitionManager: stm = StateTransitionManager( initial_state=[("J/psi(1S)", [+1])], - final_state=["K0", ("Sigma+", [+0.5]), ("pbar", [+0.5])], - allowed_intermediate_particles=["Sigma(1750)", "Sigmabar(1750)"], + final_state=["K0", ("Sigma+", [+0.5]), ("p~", [+0.5])], + allowed_intermediate_particles=["Sigma(1750)"], formalism="canonical-helicity", ) stm.set_allowed_interaction_types([InteractionType.STRONG, InteractionType.EM]) @@ -40,6 +40,4 @@ def qn_problem_and_result( ) -> tuple[QNProblemSet, QNResult]: qn_solutions = stm.find_quantum_number_transitions(problem_sets) strong_qn_solutions = qn_solutions[3600.0] - return next( - (problem, result) for problem, result in strong_qn_solutions if result.solutions - ) + return strong_qn_solutions[1] diff --git a/tests/unit/io/test_dict.py b/tests/unit/io/test_dict.py index fb36c268..f44d8b55 100644 --- a/tests/unit/io/test_dict.py +++ b/tests/unit/io/test_dict.py @@ -29,10 +29,16 @@ def it_not_implemented_errors( def it_serialization( output_dir: str, particle_selection: ParticleCollection, + skh_particle_version: str, ): io.write(particle_selection, output_dir + "particle_selection.yml") n_particles = len(particle_selection) - assert n_particles > 0 + if skh_particle_version < "0.16": + assert n_particles == 181 + elif skh_particle_version < "0.25": + assert n_particles == 193 + else: + assert n_particles == 194 asdict = io.asdict(particle_selection) imported_collection = io.fromdict(asdict) assert isinstance(imported_collection, ParticleCollection) diff --git a/tests/unit/io/test_dot.py b/tests/unit/io/test_dot.py index cd7dc8b8..0531b1f3 100644 --- a/tests/unit/io/test_dot.py +++ b/tests/unit/io/test_dot.py @@ -41,7 +41,7 @@ def it_exact_format(reaction: ReactionInfo): { rank=same; A } { rank=same; 0 1 2 } A -> N0 - N0 -> N1 [label="f_0(980)0[0]"] + N0 -> N1 [label="f(0)(980)[0]"] N0 -> 0 N1 -> 1 N1 -> 2 @@ -63,7 +63,7 @@ def it_exact_format(reaction: ReactionInfo): { rank=same; A } { rank=same; 0 1 2 } A -> N0 - N0 -> N1 [label="f_0(980)0[0]"] + N0 -> N1 [label="f(0)(980)[0]"] N0 -> 0 N1 -> 1 N1 -> 2 @@ -98,7 +98,7 @@ def it_with_styled_edges_and_nodes(reaction: ReactionInfo, output_dir): with open(output_dir + f"styled_{reaction.formalism}.gv", "w") as stream: stream.write(src) assert '0 [fontcolor="blue", label="0: gamma[-1]"]' in src - assert 'N0 -> N1 [fontcolor="blue", label="f_0(980)0[0]"]' in src + assert 'N0 -> N1 [fontcolor="blue", label="f(0)(980)[0]"]' in src assert 'N0 [fontcolor="darkgreen", shape="ellipse", label=""]' in src def it_no_label_overwriting(reaction: ReactionInfo): diff --git a/tests/unit/io/test_labels.py b/tests/unit/io/test_labels.py index b26c2d55..97408197 100644 --- a/tests/unit/io/test_labels.py +++ b/tests/unit/io/test_labels.py @@ -114,21 +114,21 @@ def it_dict_and_basic_values(): def it_collapsed_particle_tuple(particle_database: ParticleCollection): particles = ( - particle_database["f_0(980)0"], - particle_database["f_0(1500)0"], + particle_database["f(0)(980)"], + particle_database["f(0)(1500)"], ) assert as_latex(particles) == ( R"\begin{gathered} f_{0}(980) \\ f_{0}(1500) \end{gathered}" ) def it_keeps_six_particles_in_one_column(particle_database: ParticleCollection): - particle = particle_database["f_0(980)0"] + particle = particle_database["f(0)(980)"] assert as_latex((particle,) * 6).startswith(R"\begin{gathered}") def it_uses_columns_for_a_long_particle_tuple( particle_database: ParticleCollection, ): - particle = particle_database["f_0(980)0"] + particle = particle_database["f(0)(980)"] particles = tuple( attrs.evolve(particle, name=f"x{i}", latex=Rf"x_{{{i}}}") for i in range(7) ) @@ -140,7 +140,7 @@ def it_uses_columns_for_a_long_particle_tuple( def it_adds_columns_to_a_longer_particle_tuple( particle_database: ParticleCollection, ): - particle = particle_database["f_0(980)0"] + particle = particle_database["f(0)(980)"] particles = tuple( attrs.evolve(particle, name=f"x{i}", latex=Rf"x_{{{i}}}") for i in range(13) ) @@ -335,7 +335,7 @@ def test_collapse_graphs( assert len(collapsed_graphs) == 1 graph = next(iter(collapsed_graphs)) edge_id = next(iter(graph.topology.intermediate_edge_ids)) - f_resonances = pdg.filter(lambda p: p.name in {"f_0(980)0", "f_0(1500)0"}) + f_resonances = pdg.filter(lambda p: p.name in {"f(0)(980)", "f(0)(1500)"}) intermediate_states = graph.states[edge_id] assert isinstance(intermediate_states, tuple) assert all(isinstance(i, Particle) for i in intermediate_states) @@ -348,18 +348,19 @@ def test_get_particle_graphs( pdg = particle_database graphs = get_particle_graphs(reaction.transitions) assert len(graphs) == 2 - assert graphs[0].states[3] == pdg["f_0(980)0"] - assert graphs[1].states[3] == pdg["f_0(1500)0"] + assert graphs[0].states[3] == pdg["f(0)(980)"] + assert graphs[1].states[3] == pdg["f(0)(1500)"] assert len(graphs[0].topology.edges) == 5 for i in range(-1, 3): assert graphs[0].states[i] is graphs[1].states[i] -def test_strip_projections(): - resonance = "Sigmabar(1670)-" +def test_strip_projections(skh_particle_version: str): + assert skh_particle_version is not None # skips test if particle version too low + resonance = "Sigma(1670)~-" reaction = qrules.generate_transitions( initial_state=[("J/psi(1S)", [+1])], - final_state=["K0", ("Sigma+", [+0.5]), ("pbar", [+0.5])], + final_state=["K0", ("Sigma+", [+0.5]), ("p~", [+0.5])], allowed_intermediate_particles=[resonance], allowed_interaction_types="strong", ) diff --git a/tests/unit/io/test_mermaid.py b/tests/unit/io/test_mermaid.py index dca2faae..8af31221 100644 --- a/tests/unit/io/test_mermaid.py +++ b/tests/unit/io/test_mermaid.py @@ -122,9 +122,9 @@ def it_font_size_formatting(font_size: object, expected: str): def it_edge_labels_with_state_brackets_are_quoted(): edge_line = MermaidPrinter(latex=False)._create_mermaid_edge( - "A", "B", "f_2(2340)0[-2]" + "A", "B", "f(2)(2340)[-2]" ) - assert edge_line == ' A ---|"f_2(2340)0[-2]"| B' + assert edge_line == ' A ---|"f(2)(2340)[-2]"| B' def it_edge_labels_with_ket_vectors_are_quoted(): edge_line = MermaidPrinter(latex=False)._create_mermaid_edge("A", "B", "|1,-1⟩") @@ -222,7 +222,7 @@ def it_reaction_with_node_labels(reaction: ReactionInfo): src = io.asmermaid(reaction.transitions[0], render_node=True, latex=False) assert src.startswith("flowchart LR\n") assert "gamma[-1]" in src - assert "f_0(980)0[0]" in src + assert "f(0)(980)[0]" in src assert "P=+1" in src assert " A --- N0" in src diff --git a/tests/unit/test_final_state_permutations.py b/tests/unit/test_final_state_permutations.py index c62a5082..8dedb332 100644 --- a/tests/unit/test_final_state_permutations.py +++ b/tests/unit/test_final_state_permutations.py @@ -10,7 +10,7 @@ @pytest.mark.parametrize( "final_state_description", - sorted({" ".join(p) for p in itertools.permutations(["pbar", "Sigma+", "K0"])}), + sorted({" ".join(p) for p in itertools.permutations(["p~", "Sigma+", "K0"])}), ) def test_create_problem_sets(final_state_description: str): input_final_state = final_state_description.split(" ") @@ -38,7 +38,7 @@ def test_generate_transitions(final_state_description: str): reaction = qrules.generate_transitions( initial_state=("J/psi(1S)", [-1, +1]), final_state=final_state, - allowed_intermediate_particles=["omega(782)0"], + allowed_intermediate_particles=["omega(782)"], allowed_interaction_types=["strong", "EM"], ) ordered_final_state = [ diff --git a/tests/unit/test_parity_prefactor.py b/tests/unit/test_parity_prefactor.py index f76882f6..90b9aa48 100644 --- a/tests/unit/test_parity_prefactor.py +++ b/tests/unit/test_parity_prefactor.py @@ -26,7 +26,7 @@ class Input(NamedTuple): Input( [("J/psi(1S)", [1])], [("gamma", [-1, 1]), ("pi0", [0]), ("pi0", [0])], - ["f_0(980)0"], + ["f(0)(980)"], ["pi0", "pi0"], ), "J/psi(1S)", diff --git a/tests/unit/test_particle.py b/tests/unit/test_particle.py index 9e4733c9..062635d4 100644 --- a/tests/unit/test_particle.py +++ b/tests/unit/test_particle.py @@ -3,6 +3,7 @@ import logging from copy import deepcopy from fractions import Fraction +from importlib.metadata import version import pytest from attrs.exceptions import FrozenInstanceError @@ -43,8 +44,8 @@ def it_repr(particle_database: ParticleCollection, repr_method): ("p", False), ("e+", True), ("e-", True), - ("nu_e", True), - ("nubar_tau", True), + ("nu(e)", True), + ("nu(tau)~", True), ("tau+", True), ], ) @@ -113,23 +114,23 @@ def it_eq(): ("name1", "name2"), [ # by name - ("pi0", "a_0(980)-"), + ("pi0", "a(0)(980)-"), # by mass ("pi+", "pi-"), ("pi-", "pi0"), ("pi+", "pi0"), ("K0", "K+"), # by charge - ("a_0(980)+", "a_0(980)-"), - ("a_0(980)+", "a_0(980)0"), - ("a_0(980)0", "a_0(980)-"), + ("a(0)(980)+", "a(0)(980)-"), + ("a(0)(980)+", "a(0)(980)0"), + ("a(0)(980)0", "a(0)(980)-"), ], ) def it_gt(name1, name2, particle_database: ParticleCollection): pdg = particle_database assert pdg[name1] > pdg[name2] - def it_neg(particle_database: ParticleCollection): + def it_neg(particle_database: ParticleCollection, skh_particle_version: str): pip = particle_database.find(211) pim = particle_database.find(-211) assert pip == -pim @@ -137,24 +138,26 @@ def it_neg(particle_database: ParticleCollection): pdg = particle_database f0_mesons = sorted( particle.name - for particle in sorted(pdg.filter(lambda p: p.name.startswith("f_0"))) + for particle in sorted(pdg.filter(lambda p: p.name.startswith("f(0)"))) ) expected = { - "f_0(500)0", - "f_0(980)0", - "f_0(1370)0", - "f_0(1500)0", - "f_0(1710)0", - "f_0(2020)0", - "f_0(2100)0", - "f_0(2200)0", + "f(0)(500)", + "f(0)(980)", + "f(0)(1370)", + "f(0)(1500)", + "f(0)(1710)", } + if skh_particle_version > "0.22": + expected.add("f(0)(2020)") sorted_expected = sorted(expected) assert f0_mesons == sorted_expected def _get_omega_mesons() -> list[str]: - return ["omega(782)0", "omega(1420)0", "omega_3(1670)0", "omega(1650)0"] + scikit_hep_particle_version = ".".join(version("particle").split(".")[:2]) + if scikit_hep_particle_version in {"0.21", "0.22"}: + return ["omega(782)", "omega(3)(1670)", "omega(1650)"] + return ["omega(782)", "omega(1420)", "omega(3)(1670)", "omega(1650)"] def describe_ParticleCollection(): @@ -232,13 +235,19 @@ def it_discard(particle_database: ParticleCollection): with pytest.raises(NotImplementedError): pions.discard(111) # ty: ignore[invalid-argument-type] - def it_filter(particle_database: ParticleCollection): - search_result = particle_database.filter(lambda p: "f_0" in p.name) - assert len(search_result) == 8 - f0_1500_from_subset = search_result["f_0(1500)0"] - assert f0_1500_from_subset.mass == pytest.approx(1.522) - assert f0_1500_from_subset is particle_database["f_0(1500)0"] - assert f0_1500_from_subset is not particle_database["f_0(980)0"] + def it_filter(particle_database: ParticleCollection, skh_particle_version: str): + search_result = particle_database.filter(lambda p: "f(0)" in p.name) + if skh_particle_version < "0.23": + assert len(search_result) == 5 + else: + assert len(search_result) == 6 + f0_1500_from_subset = search_result["f(0)(1500)"] + if skh_particle_version < "0.23": + assert f0_1500_from_subset.mass == 1.506 + else: + assert f0_1500_from_subset.mass == 1.522 + assert f0_1500_from_subset is particle_database["f(0)(1500)"] + assert f0_1500_from_subset is not particle_database["f(0)(980)"] search_result = particle_database.filter(lambda p: p.pid == 22) gamma_from_subset = search_result["gamma"] @@ -252,21 +261,25 @@ def it_filter(particle_database: ParticleCollection): ) sorted_result = sorted(filtered_result.names) expected = { - "K_2(1820)+", - "K_2(1820)0", - "K_2^*(1980)+", - "K_2^*(1980)0", + "K(2)(1820)+", + "K(2)(1820)0", } + if skh_particle_version > "0.15": + additional_particles = { + "K(2)*(1980)+", + "K(2)*(1980)0", + } + expected.update(additional_particles) sorted_expected = sorted(expected) assert sorted_result == sorted_expected def it_find(particle_database: ParticleCollection): f2_1950 = particle_database.find(9050225) - assert f2_1950.name == "f_2(1950)0" - assert f2_1950.mass == pytest.approx(1.9361938561872178) - phi = particle_database.find("phi(1020)0") + assert f2_1950.name == "f(2)(1950)" + assert f2_1950.mass == 1.936 + phi = particle_database.find("phi(1020)") assert phi.pid == 333 - assert phi.width == pytest.approx(0.004249, abs=1e-6) + assert pytest.approx(phi.width) == 0.004249 @pytest.mark.parametrize( ("search_term", "expected"), @@ -275,9 +288,11 @@ def it_find(particle_database: ParticleCollection): ("non-existing", None), # cspell:disable ("gamm", "gamma"), - ("gama", ["gamma", "Sigma0", "Sigma-", "Sigma+"]), + ("gama", ["gamma", "Sigma0", "Sigma-", "Sigma+", "Lambda"]), ("omega", _get_omega_mesons()), - ("lambda", ["Lambda0", "Lambdabar0"]), + ("p~~", "p~"), + ("~", ["p~", "n~"]), + ("lambda", ["Lambda", "Lambda~", "Lambda(c)+", "Lambda(b)0"]), # cspell:enable ], ) @@ -394,22 +409,51 @@ def it_creates_a_named_antiparticle( assert anti_particle == comparison_particle - def it_by_pid(particle_database: ParticleCollection): + def it_tilde(particle_database: ParticleCollection, skh_particle_version: str): + anti_particles = particle_database.filter(lambda p: "~" in p.name) + if skh_particle_version < "0.14": + assert len(anti_particles) == 165 + elif skh_particle_version < "0.16": + assert len(anti_particles) == 172 + elif skh_particle_version < "0.25": + assert len(anti_particles) == 175 + else: + assert len(anti_particles) == 176 + for anti_particle in anti_particles: + particle_name = anti_particle.name.replace("~", "") + if "+" in particle_name: + particle_name = particle_name.replace("+", "-") + elif "-" in particle_name: + particle_name = particle_name.replace("-", "+") + created_particle = create_antiparticle(anti_particle, particle_name) + assert created_particle == particle_database[particle_name] + + def it_by_pid(particle_database: ParticleCollection, skh_particle_version: str): + n_particles_with_neg_pid = 0 for particle in particle_database: anti_particles_by_pid = particle_database.filter( lambda p: p.pid == -particle.pid # ruff: ignore[function-uses-loop-variable] ) if len(anti_particles_by_pid) != 1: continue + n_particles_with_neg_pid += 1 anti_particle = next(iter(anti_particles_by_pid)) particle_from_anti = -anti_particle assert particle == particle_from_anti + if skh_particle_version < "0.14": + assert n_particles_with_neg_pid == 428 + elif skh_particle_version < "0.16": + assert n_particles_with_neg_pid == 442 + elif skh_particle_version < "0.25": + assert n_particles_with_neg_pid == 454 + else: + assert n_particles_with_neg_pid == 456 def describe_create_particle(): @pytest.mark.parametrize( "particle_name", - ["p", "phi(1020)0", "W-", "gamma"], + ["p", "phi(1020)", "W-", "gamma"], ) def it_create_particle(particle_database: ParticleCollection, particle_name: str): template_particle = particle_database[particle_name] diff --git a/tests/unit/test_pdg.py b/tests/unit/test_pdg.py index 6b38eda7..a89d3597 100644 --- a/tests/unit/test_pdg.py +++ b/tests/unit/test_pdg.py @@ -1,3 +1,4 @@ +import particle import pytest from qrules.particle import ParticleCollection, load_pdg @@ -8,29 +9,54 @@ def pdg() -> ParticleCollection: return load_pdg() -def test_contains_representative_particles(pdg: ParticleCollection): - expected_names = { - "J/psi(1S)", - "f_0(500)0", - "f_0(980)0", - "gamma", - "nu_e", - "pbar", - "pi+", +def test_maybe_qq(): + expected_maybe_qq = { + "a(0)(980)+", + "a(0)(980)-", + "a(0)(980)0", + "f(0)(1500)", + "f(0)(500)", + "f(0)(980)", + "pi(1)(1400)+", + "pi(1)(1400)-", + "pi(1)(1400)0", + "pi(1)(1600)+", + "pi(1)(1600)-", + "pi(1)(1600)0", + } + maybe_qq_search_results = particle.Particle.findall( + lambda p: "qq" in p.quarks.lower() + ) + assert expected_maybe_qq == {item.name for item in maybe_qq_search_results} + + +def test_pdg_size(pdg: ParticleCollection): + assert len(pdg) in { + 512, # particle==0.13 + 519, # particle==0.14-0.15 + 531, # particle==0.16-0.20 + 530, # particle==0.21-0.22 + 537, # particle==0.23-0.24 + 539, # particle==0.25 + 540, # particle==0.25.4 + 541, # particle==0.26.0 + } + assert len(pdg.filter(lambda p: "~" in p.name)) in { + 165, # particle==0.13 + 172, # particle==0.14, 0.15 + 175, # particle==0.16-0.24 + 176, # particle==0.25 } - assert expected_names <= set(pdg.names) - - -def test_has_unique_names_and_mcids(pdg: ParticleCollection): - assert len(pdg.names) == len(set(pdg.names)) - assert len(pdg) == len({particle.pid for particle in pdg}) -def test_default_particle_definitions_extend_pdg( +def test_missing_in_pdg( pdg: ParticleCollection, particle_database: ParticleCollection, ): - pdg_names = set(pdg.names) - default_names = set(particle_database.names) - assert pdg_names <= default_names - assert default_names - pdg_names == {"Y(4260)"} + particle_list_names = set(particle_database) + pdg_names = set(pdg) + in_common = particle_list_names & pdg_names + missing_in_pdg = particle_list_names ^ in_common + assert {p.name for p in missing_in_pdg} == { + "Y(4260)", + } diff --git a/tests/unit/test_qrules.py b/tests/unit/test_qrules.py index 45594f59..c314921d 100644 --- a/tests/unit/test_qrules.py +++ b/tests/unit/test_qrules.py @@ -6,17 +6,17 @@ @pytest.mark.parametrize( "resonance_names", [ - ["Sigmabar(1660)-"], + ["Sigma(1660)~-"], ["N(1650)+"], - ["Kbar^*(1680)0"], - ["Sigmabar(1660)-", "N(1650)+"], - ["Sigmabar(1660)-", "Kbar^*(1680)0"], - ["N(1650)+", "Kbar^*(1680)0"], - ["Sigmabar(1660)-", "N(1650)+", "Kbar^*(1680)0"], + ["K*(1680)~0"], + ["Sigma(1660)~-", "N(1650)+"], + ["Sigma(1660)~-", "K*(1680)~0"], + ["N(1650)+", "K*(1680)~0"], + ["Sigma(1660)~-", "N(1650)+", "K*(1680)~0"], ], ) def test_generate_transitions(resonance_names): - final_state_names = ["K0", "Sigma+", "pbar"] + final_state_names = ["K0", "Sigma+", "p~"] reaction = generate_transitions( initial_state="J/psi(1S)", final_state=final_state_names, diff --git a/tests/unit/test_system_control.py b/tests/unit/test_system_control.py index a9bfb305..5f8fcd04 100644 --- a/tests/unit/test_system_control.py +++ b/tests/unit/test_system_control.py @@ -2,6 +2,7 @@ from copy import deepcopy from fractions import Fraction +from importlib.metadata import version import attrs import pytest @@ -37,32 +38,32 @@ [ ( [("Y(4260)", [-1])], - [("D0", [0]), ("Dbar0", [0]), ("pi0", [0]), ("pi0", [0])], - [[["D0", "pi0"], ["Dbar0", "pi0"]]], + [("D0", [0]), ("D~0", [0]), ("pi0", [0]), ("pi0", [0])], + [[["D0", "pi0"], ["D~0", "pi0"]]], 1, ), ( [("Y(4260)", [-1, 1])], - [("D0", [0]), ("Dbar0", [0]), ("pi0", [0]), ("pi0", [0])], - [[["D0", "pi0"], ["Dbar0", "pi0"]]], + [("D0", [0]), ("D~0", [0]), ("pi0", [0]), ("pi0", [0])], + [[["D0", "pi0"], ["D~0", "pi0"]]], 2, ), ( [("Y(4260)", [1])], - [("D0", [0]), ("Dbar0", [0]), ("pi0", [0]), ("pi0", [0])], + [("D0", [0]), ("D~0", [0]), ("pi0", [0]), ("pi0", [0])], [], 9, ), ( [("Y(4260)", [-1, 1])], - [("D0", [0]), ("Dbar0", [0]), ("pi0", [0]), ("pi0", [0])], + [("D0", [0]), ("D~0", [0]), ("pi0", [0]), ("pi0", [0])], [], 18, ), ( [("Y(4260)", [1])], - [("D0", [0]), ("Dbar0", [0]), ("pi0", [0]), ("pi0", [0])], - [[["D0", "pi0"], ["Dbar0", "pi0"]], ["D0", "pi0"]], + [("D0", [0]), ("D~0", [0]), ("pi0", [0]), ("pi0", [0])], + [[["D0", "pi0"], ["D~0", "pi0"]], ["D0", "pi0"]], 3, ), ( @@ -114,6 +115,26 @@ def test_external_edge_initialization( assert len(next(iter(problem_sets.values()))) == result_graph_count +def get_pi0_width() -> float: + if version("particle") < "0.16": + return 7.73e-09 + return 7.81e-09 + + +def __get_d_pos() -> tuple[float, float]: + if version("particle") < "0.16": + return 1.86965, 6.33e-13 + if version("particle") < "0.21": + return 1.86966, 6.33e-13 + return 1.86966, 6.37e-13 + + +def __get_f2_1270_pos() -> tuple[float, float]: + if version("particle") < "0.23": + return 1.2755, 0.18669999999999998 + return 1.2754, 0.1866 + + @pytest.mark.parametrize( ("particle_name", "spin_projection", "expected_properties"), [ @@ -122,8 +143,8 @@ def test_external_edge_initialization( 0, { EdgeQuantumNumbers.pid: 111, - EdgeQuantumNumbers.mass: 0.1349768277676847, - EdgeQuantumNumbers.width: 7.811987971364424e-09, + EdgeQuantumNumbers.mass: 0.1349768, + EdgeQuantumNumbers.width: get_pi0_width(), EdgeQuantumNumbers.spin_magnitude: 0.0, EdgeQuantumNumbers.spin_projection: 0, EdgeQuantumNumbers.charge: 0, @@ -147,8 +168,8 @@ def test_external_edge_initialization( 0, { EdgeQuantumNumbers.pid: 411, - EdgeQuantumNumbers.mass: 1.869664743570898, - EdgeQuantumNumbers.width: 6.370029016102887e-13, + EdgeQuantumNumbers.mass: __get_d_pos()[0], + EdgeQuantumNumbers.width: __get_d_pos()[1], EdgeQuantumNumbers.spin_magnitude: 0.0, EdgeQuantumNumbers.spin_projection: 0, EdgeQuantumNumbers.charge: 1, @@ -168,12 +189,12 @@ def test_external_edge_initialization( }, ), ( - "f_2(1270)0", # spin projection 1 + "f(2)(1270)", # spin projection 1 1.0, { EdgeQuantumNumbers.pid: 225, - EdgeQuantumNumbers.mass: 1.2754120499190051, - EdgeQuantumNumbers.width: 0.18655435663732642, + EdgeQuantumNumbers.mass: __get_f2_1270_pos()[0], + EdgeQuantumNumbers.width: __get_f2_1270_pos()[1], EdgeQuantumNumbers.spin_magnitude: 2.0, EdgeQuantumNumbers.spin_projection: 1.0, EdgeQuantumNumbers.charge: 0, @@ -199,9 +220,11 @@ def test_create_edge_properties( spin_projection, expected_properties, particle_database, + skh_particle_version: str, ): particle = particle_database[particle_name] assert create_edge_properties(particle, spin_projection) == expected_properties + assert skh_particle_version is not None # dummy for skip tests def make_ls_test_graph( @@ -346,7 +369,7 @@ def _create_graph( [ ( [("Y(4260)", [-1])], - [("D0", [0]), ("Dbar0", [0]), ("pi0", [0]), ("pi0", [0])], + [("D0", [0]), ("D~0", [0]), ("pi0", [0]), ("pi0", [0])], ), ], ) @@ -387,7 +410,7 @@ def test_edge_swap(particle_database, initial_state, final_state): [ ( [("Y(4260)", [-1])], - [("D0", [0]), ("Dbar0", [0]), ("pi0", [0]), ("pi0", [0])], + [("D0", [0]), ("D~0", [0]), ("pi0", [0]), ("pi0", [0])], ), ( [("J/psi(1S)", [-1, 1])], @@ -440,13 +463,13 @@ def test_match_external_edges(particle_database, initial_state, final_state): [ ( [("Y(4260)", [1])], - [("D0", [0]), ("Dbar0", [0]), ("pi0", [0]), ("pi0", [0])], - [[["D0", "pi0"], ["Dbar0", "pi0"]]], + [("D0", [0]), ("D~0", [0]), ("pi0", [0]), ("pi0", [0])], + [[["D0", "pi0"], ["D~0", "pi0"]]], 2, ), ( [("Y(4260)", [1])], - [("D0", [0]), ("Dbar0", [0]), ("pi0", [0]), ("pi0", [0])], + [("D0", [0]), ("D~0", [0]), ("pi0", [0]), ("pi0", [0])], [["D0", "pi0"]], 6, ), diff --git a/tests/unit/test_transition.py b/tests/unit/test_transition.py index 00f19bcb..40789773 100644 --- a/tests/unit/test_transition.py +++ b/tests/unit/test_transition.py @@ -27,7 +27,7 @@ def describe_MutableTransition(): def it_intermediate_states(): stm = StateTransitionManager( initial_state=[("J/psi(1S)", [-1, +1])], - final_state=["K0", "Sigma+", "pbar"], + final_state=["K0", "Sigma+", "p~"], allowed_intermediate_particles=["N(1700)", "Sigma(1750)"], formalism="helicity", mass_conservation_factor=0, @@ -109,7 +109,7 @@ def describe_StateTransitionManager(): def it_allowed_intermediate_particles(): stm = StateTransitionManager( initial_state=[("J/psi(1S)", list(map(Fraction, [-1, +1])))], - final_state=["p", "pbar", "eta"], + final_state=["p", "p~", "eta"], ) particle_name = "N(753)" with pytest.raises( @@ -122,7 +122,7 @@ def it_allowed_intermediate_particles(): ("initial_state", "expected_strengths"), [ (["gamma"], [0.0001, 1.0, 60.0]), - (["nu_e"], [1e-08, 0.0001, 0.006]), + (["nu(e)"], [1e-08, 0.0001, 0.006]), ], ) def it_initial_state_restricts_interaction_types( @@ -147,7 +147,7 @@ def count_transitions(solving_mode: SolvingMode) -> int: def it_regex_pattern(): stm = StateTransitionManager( - initial_state=["Lambda_c()+"], + initial_state=["Lambda(c)+"], final_state=["p", "K-", "pi+"], allowed_intermediate_particles=["Delta"], ) diff --git a/uv.lock b/uv.lock index 8a126271..3576e438 100644 --- a/uv.lock +++ b/uv.lock @@ -972,6 +972,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "hepunits" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/1f/c5f7525bf7e5d14d61750f91845f9f9350a8f33f5ac95672cec92f317038/hepunits-2.4.6.tar.gz", hash = "sha256:bca6ada937147166d66e9fa152566f2378868d798211cad6990080399560fa34", size = 17985, upload-time = "2026-06-16T09:23:36.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/10/7f9c58d1ec6a0b7f7783fe552f3593f39cda30c2e1d7a9d148ae711e748d/hepunits-2.4.6-py3-none-any.whl", hash = "sha256:089c52c3b84ef67a159b5e9ee9bdd50e1a442e3fd0c101303cc409c1e9011c4d", size = 17090, upload-time = "2026-06-16T09:23:35.35Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -1923,6 +1932,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, ] +[[package]] +name = "particle" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "hepunits" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/cf/c22d976f32b899e81da7e4e2a47b7d1c1a8f5a6ab38bc6e01d3f2b074714/particle-1.0.1.tar.gz", hash = "sha256:3f2ec4dbb8953c90ba83b891d6534cd82dc1aece4111b79998ff284ce377a922", size = 285779, upload-time = "2026-09-10T14:59:26.818Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/3d/c3a313039860766c56ec9d5d5e7d02e305c54d49960b4db69e3260253974/particle-1.0.1-py3-none-any.whl", hash = "sha256:9c63b270bd4d1e1ab0f7d7120d548b34ffd12ab7fcfed92dcd646ac4bfd5711c", size = 245606, upload-time = "2026-09-10T14:59:25.116Z" }, +] + [[package]] name = "pathspec" version = "1.1.1" @@ -2574,6 +2597,7 @@ dependencies = [ { name = "attrs" }, { name = "frozendict" }, { name = "jsonschema" }, + { name = "particle" }, { name = "pdg" }, { name = "python-constraint2", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "python-constraint2", version = "2.7.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -2699,6 +2723,7 @@ requires-dist = [ { name = "frozendict" }, { name = "graphviz", marker = "extra == 'viz'" }, { name = "jsonschema" }, + { name = "particle" }, { name = "pdg", specifier = ">=2026.0,<2027" }, { name = "python-constraint2" }, { name = "pyyaml" }, From 60f03f27352bc1786460d1657a26ae66de41aade Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:33:24 +0200 Subject: [PATCH 13/27] REMOVE: delete obsolete ignoreWords from cspell configuration --- .cspell.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.cspell.json b/.cspell.json index 034794db..7a901fc6 100644 --- a/.cspell.json +++ b/.cspell.json @@ -38,9 +38,6 @@ "pyproject.toml" ], "ignoreWords": [ - "Dbar", - "Kbar", - "Xibar", "adrs", "ampform", "arange", @@ -97,7 +94,6 @@ "mimetype", "modindex", "mystnb", - "nbar", "nbconvert", "nbformat", "nbody", @@ -108,7 +104,6 @@ "noreply", "nrows", "nsimplify", - "nubar", "pandoc", "pbar", "permalinks", From c6b3d011263255a03cc5405a0209219c61e1589e Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:45:07 +0200 Subject: [PATCH 14/27] ADD: include "Xibar" and "nubar" in ignoreWords list --- .cspell.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.cspell.json b/.cspell.json index 7a901fc6..587c9e4d 100644 --- a/.cspell.json +++ b/.cspell.json @@ -38,6 +38,7 @@ "pyproject.toml" ], "ignoreWords": [ + "Xibar", "adrs", "ampform", "arange", @@ -104,6 +105,7 @@ "noreply", "nrows", "nsimplify", + "nubar", "pandoc", "pbar", "permalinks", From 3dabd345e247625c870f7e5b52c95ec818134fb9 Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:46:13 +0200 Subject: [PATCH 15/27] FIX: Revert particle package to standard load_pdg package and add flag for import from official pdg package --- src/qrules/particle.py | 196 +++++++++++++++++++++++++++++++-- tests/unit/test_pdg_adapter.py | 14 ++- 2 files changed, 198 insertions(+), 12 deletions(-) diff --git a/src/qrules/particle.py b/src/qrules/particle.py index 79dc6d0d..c1953e84 100644 --- a/src/qrules/particle.py +++ b/src/qrules/particle.py @@ -18,6 +18,7 @@ from difflib import get_close_matches from fractions import Fraction from functools import total_ordering +from math import copysign from typing import TYPE_CHECKING, Any import attrs @@ -38,6 +39,8 @@ from attrs import Attribute from IPython.lib.pretty import RepresentationPrinter + from particle import Particle as PdgDatabase + from particle.particle import enums _LOGGER = logging.getLogger(__name__) @@ -238,10 +241,9 @@ def _repr_pretty_(self, p: RepresentationPrinter, cycle: bool) -> None: def _get_name_root(name: str) -> str: """Strip a string (particularly the `.Particle.name`) of specifications.""" - name_root = re.sub(r"\([^)]*\)", "", name) - name_root = name_root.split("_", maxsplit=1)[0] - name_root = re.sub(r"[\^\*\+\-~\d']", "", name_root) - return name_root.removesuffix("bar") + name_root = name + name_root = re.sub(r"\(.+\)", "", name_root) + return re.sub(r"[\*\+\-~\d']", "", name_root) ParticleWithSpin = tuple[Particle, Fraction] @@ -492,12 +494,188 @@ def create_antiparticle( ) -def load_pdg() -> ParticleCollection: +def load_pdg(*, use_official_pdg: bool = False) -> ParticleCollection: """Create a `.ParticleCollection` with all entries from the PDG. - PDG info is imported from the official `PDG Python API - `_. + By default, particle definitions are imported from the `Scikit-HEP particle + `_ package. Set ``use_official_pdg`` to + ``True`` to import them from the official `PDG Python API + `_ instead. """ - from qrules._pdg import load_pdg as load_official_pdg # ruff: ignore[import-outside-top-level] + if use_official_pdg: + from qrules._pdg import load_pdg as load_official_pdg # ruff: ignore[import-outside-top-level] - return load_official_pdg() + return load_official_pdg() + + from particle import Particle as PdgDatabase # ruff: ignore[import-outside-top-level] + + all_pdg_particles = PdgDatabase.findall( + lambda item: ( + item.charge is not None + and float(item.charge).is_integer() # remove quarks + and item.J is not None # remove new physics and nuclei + and abs(item.pdgid) < 1e9 # p and n as nucleus + and item.name not in __skip_particles + and not (item.mass is None and not item.name.startswith("nu")) + ) + ) + particle_collection = ParticleCollection() + for pdg_particle in all_pdg_particles: + new_particle = __convert_pdg_instance(pdg_particle) + particle_collection.add(new_particle) + return particle_collection + + +__skip_particles = { + "K(L)0", # no isospin projection + "K(S)0", # no isospin projection + "B(s2)*(5840)0", # isospin(0.5, 0.0) ? + "B(s2)*(5840)~0", # isospin(0.5, 0.0) ? +} + + +def __sign(value: float) -> int: + return int(copysign(1, value)) + + +# cspell:ignore pdgid +def __convert_pdg_instance(pdg_particle: PdgDatabase) -> Particle: + def convert_mass_width(value: float | None) -> float: + if value is None: + return 0.0 + return float(value) / 1e3 # https://github.com/ComPWA/qrules/issues/14 + + def convert_spin(value: Fraction | float | None) -> float: + if value is None: + msg = f"PDG instance has no spin:\n{pdg_particle}" + raise ValueError(msg) + return float(value) + + if pdg_particle.charge is None: + msg = f"PDG instance has no charge:\n{pdg_particle}" + raise ValueError(msg) + quark_numbers = __compute_quark_numbers(pdg_particle) + lepton_numbers = __compute_lepton_numbers(pdg_particle) + if pdg_particle.pdgid.is_lepton: # convention: C(fermion)=+1 + parity: Parity | None = Parity(__sign(pdg_particle.pdgid)) + else: + parity = __create_parity(pdg_particle.P) + latex = None + if pdg_particle.latex_name != "Unknown": + latex = str(pdg_particle.latex_name) + return Particle( + name=str(pdg_particle.name), + latex=latex, + pid=int(pdg_particle.pdgid), + mass=convert_mass_width(pdg_particle.mass), + width=convert_mass_width(pdg_particle.width), + charge=int(pdg_particle.charge), + spin=convert_spin(pdg_particle.J), + strangeness=quark_numbers[0], + charmness=quark_numbers[1], + bottomness=quark_numbers[2], + topness=quark_numbers[3], + baryon_number=__compute_baryonnumber(pdg_particle), + electron_lepton_number=lepton_numbers[0], + muon_lepton_number=lepton_numbers[1], + tau_lepton_number=lepton_numbers[2], + isospin=__create_isospin(pdg_particle), + parity=parity, + c_parity=__create_parity(pdg_particle.C), + g_parity=__create_parity(pdg_particle.G), + ) + + +def __compute_quark_numbers( + pdg_particle: PdgDatabase, +) -> tuple[int, int, int, int]: + strangeness = 0 + charmness = 0 + bottomness = 0 + topness = 0 + if pdg_particle.pdgid.is_hadron: + quark_content = __filter_quark_content(pdg_particle) + strangeness = quark_content.count("S") - quark_content.count("s") + charmness = quark_content.count("c") - quark_content.count("C") + bottomness = quark_content.count("B") - quark_content.count("b") + topness = quark_content.count("t") - quark_content.count("T") + return ( + strangeness, + charmness, + bottomness, + topness, + ) + + +def __compute_lepton_numbers( + pdg_particle: PdgDatabase, +) -> tuple[int, int, int]: + electron_lepton_number = 0 + muon_lepton_number = 0 + tau_lepton_number = 0 + if pdg_particle.pdgid.is_lepton: + lepton_number = int(__sign(pdg_particle.pdgid)) + if "e" in pdg_particle.name: + electron_lepton_number = lepton_number + elif "mu" in pdg_particle.name: + muon_lepton_number = lepton_number + elif "tau" in pdg_particle.name: + tau_lepton_number = lepton_number + return electron_lepton_number, muon_lepton_number, tau_lepton_number + + +def __compute_baryonnumber(pdg_particle: PdgDatabase) -> int: + return int(__sign(pdg_particle.pdgid) * pdg_particle.pdgid.is_baryon) + + +def __create_isospin(pdg_particle: PdgDatabase) -> Spin | None: + if pdg_particle.I is None: + return None + magnitude = Fraction(pdg_particle.I) + projection = __isospin_projection_from_pdg(pdg_particle) + return Spin(magnitude, projection) + + +def __isospin_projection_from_pdg(pdg_particle: PdgDatabase, /) -> Fraction: + if pdg_particle.charge is None: + msg = f"PDG instance has no charge:\n{pdg_particle}" + raise ValueError(msg) + if "qq" in pdg_particle.quarks.lower(): + strangeness, charmness, bottomness, topness = __compute_quark_numbers( + pdg_particle + ) + baryon_number = __compute_baryonnumber(pdg_particle) + projection = pdg_particle.charge - 0.5 * ( + baryon_number + strangeness + charmness + bottomness + topness + ) + else: + projection = 0.0 + if pdg_particle.pdgid.is_hadron: + quark_content = __filter_quark_content(pdg_particle) + projection += quark_content.count("u") + quark_content.count("D") + projection -= quark_content.count("U") + quark_content.count("d") + projection *= 0.5 + if ( + pdg_particle.I is not None + and not float(pdg_particle.I - projection).is_integer() + ): + msg = f"Cannot have isospin {pdg_particle.I, projection}" + raise ValueError(msg) + return Fraction(projection) + + +def __filter_quark_content(pdg_particle: PdgDatabase) -> str: + matches = re.search(r"([dDuUsScCbBtT+-]{2,})", pdg_particle.quarks) + if matches is None: + return "" + return matches[1] + + +def __create_parity(parity_enum: enums.Parity) -> Parity | None: + from particle.particle import enums # ruff: ignore[import-outside-top-level] + + if parity_enum is None or parity_enum == enums.Parity.u: + return None + if parity_enum == getattr(parity_enum, "o", None): # particle < 0.14 + return None + return Parity(int(parity_enum)) diff --git a/tests/unit/test_pdg_adapter.py b/tests/unit/test_pdg_adapter.py index 9254e9fa..108544d0 100644 --- a/tests/unit/test_pdg_adapter.py +++ b/tests/unit/test_pdg_adapter.py @@ -5,14 +5,22 @@ from pdg.errors import PdgNoDataError from qrules._pdg import _load_pdg_particles, _to_mass, _to_width -from qrules._pdg import load_pdg as load_official_pdg from qrules.particle import ParticleCollection, load_pdg from qrules.quantum_numbers import Parity @pytest.fixture(scope="module") def official_particles() -> ParticleCollection: - return load_official_pdg() + return load_pdg(use_official_pdg=True) + + +def test_uses_scikit_hep_source_by_default( + official_particles: ParticleCollection, +): + default_particles = load_pdg() + + assert default_particles.find(-2212).name == "p~" + assert official_particles.find(-2212).name == "pbar" def test_caches_particle_definitions_and_returns_independent_collections( @@ -20,7 +28,7 @@ def test_caches_particle_definitions_and_returns_independent_collections( ): cache_info_before = _load_pdg_particles.cache_info() - second_collection = load_pdg() + second_collection = load_pdg(use_official_pdg=True) cache_info_after = _load_pdg_particles.cache_info() assert cache_info_after.hits == cache_info_before.hits + 1 From cc44b40d8423d5cceba5594411fe68fa1ce3564e Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:47:29 +0200 Subject: [PATCH 16/27] FIX: Update pdg dependency to remove version constraints --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a59eeb9a..ae9c0ed4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ "frozendict", "jsonschema", "particle", - "pdg >=2026.0,<2027", + "pdg", "python-constraint2", "tqdm >=4.24.0", # autonotebook ] From a160698f7a49cb1751b8230c9cf52352acaecfc3 Mon Sep 17 00:00:00 2001 From: GitHub Date: Fri, 11 Sep 2026 10:51:04 +0000 Subject: [PATCH 17/27] MAINT: implement updates from formatters --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 3576e438..c14411b5 100644 --- a/uv.lock +++ b/uv.lock @@ -2724,7 +2724,7 @@ requires-dist = [ { name = "graphviz", marker = "extra == 'viz'" }, { name = "jsonschema" }, { name = "particle" }, - { name = "pdg", specifier = ">=2026.0,<2027" }, + { name = "pdg" }, { name = "python-constraint2" }, { name = "pyyaml" }, { name = "tqdm", specifier = ">=4.24.0" }, From 108e9f65bfac90f78c95f4eee01ef8e2cc11d0e3 Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:08:33 +0200 Subject: [PATCH 18/27] Rename _pdg.py into _pdg_adapter.py to clarify its task --- src/qrules/{_pdg.py => _pdg_adapter.py} | 0 src/qrules/particle.py | 2 +- tests/unit/test_pdg_adapter.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/qrules/{_pdg.py => _pdg_adapter.py} (100%) diff --git a/src/qrules/_pdg.py b/src/qrules/_pdg_adapter.py similarity index 100% rename from src/qrules/_pdg.py rename to src/qrules/_pdg_adapter.py diff --git a/src/qrules/particle.py b/src/qrules/particle.py index c1953e84..edc451e4 100644 --- a/src/qrules/particle.py +++ b/src/qrules/particle.py @@ -503,7 +503,7 @@ def load_pdg(*, use_official_pdg: bool = False) -> ParticleCollection: `_ instead. """ if use_official_pdg: - from qrules._pdg import load_pdg as load_official_pdg # ruff: ignore[import-outside-top-level] + from qrules._pdg_adapter import load_pdg as load_official_pdg # ruff: ignore[import-outside-top-level] return load_official_pdg() diff --git a/tests/unit/test_pdg_adapter.py b/tests/unit/test_pdg_adapter.py index 108544d0..a0ea0ae3 100644 --- a/tests/unit/test_pdg_adapter.py +++ b/tests/unit/test_pdg_adapter.py @@ -4,7 +4,7 @@ import pytest from pdg.errors import PdgNoDataError -from qrules._pdg import _load_pdg_particles, _to_mass, _to_width +from qrules._pdg_adapter import _load_pdg_particles, _to_mass, _to_width from qrules.particle import ParticleCollection, load_pdg from qrules.quantum_numbers import Parity From d8c06d91e9c5344e5ca81d5526c238d969c935a7 Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:26:52 +0200 Subject: [PATCH 19/27] REFAC: Replace _UnsupportedParticleError with ValueError --- src/qrules/_pdg_adapter.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/qrules/_pdg_adapter.py b/src/qrules/_pdg_adapter.py index 3e204869..2c273695 100644 --- a/src/qrules/_pdg_adapter.py +++ b/src/qrules/_pdg_adapter.py @@ -28,10 +28,6 @@ _SKIPPED_MC_IDS = {-535, 130, 310, 535} -class _UnsupportedParticleError(ValueError): - """Raised when a PDG entry cannot be represented by QRules.""" - - def load_pdg() -> ParticleCollection: """Load particle definitions from the official PDG database. @@ -49,7 +45,7 @@ def _load_pdg_particles() -> tuple[Particle, ...]: for source_particle in _iter_particles(api): try: particle = _convert_particle(source_particle) - except _UnsupportedParticleError: + except ValueError: continue particles.append(particle) return tuple(particles) @@ -74,10 +70,10 @@ def _convert_particle(source: PdgParticle) -> Particle: mcid = source.mcid if mcid is None: msg = f"Particle {source.name} has no Monte Carlo ID" - raise _UnsupportedParticleError(msg) + raise ValueError(msg) if mcid in _SKIPPED_MC_IDS or abs(mcid) >= 1_000_000_000: msg = f"Particle {source.name} is not supported" - raise _UnsupportedParticleError(msg) + raise ValueError(msg) charge = _to_integer_charge(source.charge) spin = _to_spin( @@ -137,7 +133,7 @@ def _convert_particle(source: PdgParticle) -> Particle: def _to_integer_charge(value: float) -> int: if not float(value).is_integer(): msg = f"QRules does not support fractional charge {value}" - raise _UnsupportedParticleError(msg) + raise ValueError(msg) return int(value) @@ -152,7 +148,7 @@ def _to_spin(value: str | None, mcid: int, *, is_hadron: bool) -> Fraction: if is_hadron and spin_code > 0: return Fraction(spin_code - 1, 2) msg = f"Cannot determine spin for MC ID {mcid} from {value!r}" - raise _UnsupportedParticleError(msg) + raise ValueError(msg) def _to_mass(source: PdgParticle) -> float: @@ -169,7 +165,7 @@ def _to_mass(source: PdgParticle) -> float: if abs(source.mcid) in {12, 14, 16, 21, 22}: return 0.0 msg = f"Particle {source.name} has no supported mass value" - raise _UnsupportedParticleError(msg) + raise ValueError(msg) def _to_width(source: PdgParticle) -> float: From 53ddda11bb390007a50d6ec64aa92fa7ff444729 Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:27:16 +0200 Subject: [PATCH 20/27] REFAC: Update load_pdg function to use source parameter for particle source selection --- src/qrules/particle.py | 17 +++++++++++------ tests/unit/test_pdg_adapter.py | 12 ++++++++++-- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/qrules/particle.py b/src/qrules/particle.py index edc451e4..20a23bc4 100644 --- a/src/qrules/particle.py +++ b/src/qrules/particle.py @@ -19,7 +19,7 @@ from fractions import Fraction from functools import total_ordering from math import copysign -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal import attrs from attrs import field, frozen @@ -494,18 +494,23 @@ def create_antiparticle( ) -def load_pdg(*, use_official_pdg: bool = False) -> ParticleCollection: +def load_pdg( + *, source: Literal["scikit-hep", "pdg"] = "scikit-hep" +) -> ParticleCollection: """Create a `.ParticleCollection` with all entries from the PDG. By default, particle definitions are imported from the `Scikit-HEP particle - `_ package. Set ``use_official_pdg`` to - ``True`` to import them from the official `PDG Python API - `_ instead. + `_ package. Set ``source="pdg"`` to + import them from the official `PDG Python API `_ + instead. """ - if use_official_pdg: + if source == "pdg": from qrules._pdg_adapter import load_pdg as load_official_pdg # ruff: ignore[import-outside-top-level] return load_official_pdg() + if source != "scikit-hep": + msg = f"Unknown particle source: {source!r}" + raise ValueError(msg) from particle import Particle as PdgDatabase # ruff: ignore[import-outside-top-level] diff --git a/tests/unit/test_pdg_adapter.py b/tests/unit/test_pdg_adapter.py index a0ea0ae3..b4de8bb1 100644 --- a/tests/unit/test_pdg_adapter.py +++ b/tests/unit/test_pdg_adapter.py @@ -1,4 +1,5 @@ from fractions import Fraction +from typing import Any, cast from unittest.mock import MagicMock, PropertyMock import pytest @@ -11,24 +12,31 @@ @pytest.fixture(scope="module") def official_particles() -> ParticleCollection: - return load_pdg(use_official_pdg=True) + return load_pdg(source="pdg") def test_uses_scikit_hep_source_by_default( official_particles: ParticleCollection, ): default_particles = load_pdg() + scikit_hep_particles = load_pdg(source="scikit-hep") + assert default_particles == scikit_hep_particles assert default_particles.find(-2212).name == "p~" assert official_particles.find(-2212).name == "pbar" +def test_rejects_unknown_source(): + with pytest.raises(ValueError, match="Unknown particle source"): + load_pdg(source=cast("Any", "unknown")) + + def test_caches_particle_definitions_and_returns_independent_collections( official_particles: ParticleCollection, ): cache_info_before = _load_pdg_particles.cache_info() - second_collection = load_pdg(use_official_pdg=True) + second_collection = load_pdg(source="pdg") cache_info_after = _load_pdg_particles.cache_info() assert cache_info_after.hits == cache_info_before.hits + 1 From 507477254a3954e944c812226a7206f388598130 Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:41:14 +0200 Subject: [PATCH 21/27] REFAC: Update load_pdg function to use PyPIPackage for source parameter --- src/qrules/particle.py | 11 ++++++----- tests/unit/test_pdg_adapter.py | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/qrules/particle.py b/src/qrules/particle.py index 20a23bc4..efb5141c 100644 --- a/src/qrules/particle.py +++ b/src/qrules/particle.py @@ -494,12 +494,13 @@ def create_antiparticle( ) -def load_pdg( - *, source: Literal["scikit-hep", "pdg"] = "scikit-hep" -) -> ParticleCollection: +PyPIPackage = Literal["particle", "pdg"] + + +def load_pdg(*, source: PyPIPackage = "particle") -> ParticleCollection: """Create a `.ParticleCollection` with all entries from the PDG. - By default, particle definitions are imported from the `Scikit-HEP particle + By default, particle definitions are imported from the `particle `_ package. Set ``source="pdg"`` to import them from the official `PDG Python API `_ instead. @@ -508,7 +509,7 @@ def load_pdg( from qrules._pdg_adapter import load_pdg as load_official_pdg # ruff: ignore[import-outside-top-level] return load_official_pdg() - if source != "scikit-hep": + if source != "particle": msg = f"Unknown particle source: {source!r}" raise ValueError(msg) diff --git a/tests/unit/test_pdg_adapter.py b/tests/unit/test_pdg_adapter.py index b4de8bb1..b52d0fb2 100644 --- a/tests/unit/test_pdg_adapter.py +++ b/tests/unit/test_pdg_adapter.py @@ -19,7 +19,7 @@ def test_uses_scikit_hep_source_by_default( official_particles: ParticleCollection, ): default_particles = load_pdg() - scikit_hep_particles = load_pdg(source="scikit-hep") + scikit_hep_particles = load_pdg(source="particle") assert default_particles == scikit_hep_particles assert default_particles.find(-2212).name == "p~" From 2226f09311fcc05d219c6a6508200355165f0aa9 Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:12:44 +0200 Subject: [PATCH 22/27] REFAC: Remove LaTeX name generation functionality and associated tests for pdg package --- src/qrules/_pdg_adapter.py | 6 --- src/qrules/_pdg_latex.py | 82 ---------------------------------- tests/unit/test_pdg_adapter.py | 23 ---------- tests/unit/test_pdg_latex.py | 46 ------------------- 4 files changed, 157 deletions(-) delete mode 100644 src/qrules/_pdg_latex.py delete mode 100644 tests/unit/test_pdg_latex.py diff --git a/src/qrules/_pdg_adapter.py b/src/qrules/_pdg_adapter.py index 2c273695..71e96d08 100644 --- a/src/qrules/_pdg_adapter.py +++ b/src/qrules/_pdg_adapter.py @@ -11,7 +11,6 @@ from pdg.errors import PdgNoDataError from pdg.units import convert -from qrules._pdg_latex import create_latex_name from qrules.particle import Particle, ParticleCollection, Spin from qrules.quantum_numbers import Parity @@ -105,11 +104,6 @@ def _convert_particle(source: PdgParticle) -> Particle: return Particle( name=source.name, - latex=create_latex_name( - source.name, - isospin=source.quantum_I, - self_conjugate=source.self_conjugate, - ), pid=mcid, spin=spin, mass=mass, diff --git a/src/qrules/_pdg_latex.py b/src/qrules/_pdg_latex.py deleted file mode 100644 index 4f94188c..00000000 --- a/src/qrules/_pdg_latex.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Convert official PDG ASCII particle names to LaTeX.""" - -from __future__ import annotations - -import re - -_PARTICLE_NAME_PATTERN = re.compile( - r"^(?P[A-Za-z]+(?:/[A-Za-z]+)?)" - r"(?P(?:[_^](?:[A-Za-z0-9]+|\*|'))*)" - r"(?P\([^()]*\))?" - r"(?P\+\+|--|\+|-|0)?$" -) -_SCRIPT_PATTERN = re.compile(r"(?P[_^])(?P[A-Za-z0-9]+|\*|')") - -_LATEX_SYMBOLS = { - "Delta": R"\Delta", - "Lambda": R"\Lambda", - "Omega": R"\Omega", - "Sigma": R"\Sigma", - "Upsilon": R"\Upsilon", - "Xi": R"\Xi", - "chi": R"\chi", - "eta": R"\eta", - "gamma": R"\gamma", - "mu": R"\mu", - "nu": R"\nu", - "omega": R"\omega", - "phi": R"\phi", - "pi": R"\pi", - "psi": R"\psi", - "rho": R"\rho", - "tau": R"\tau", -} - - -def create_latex_name( - name: str, - *, - isospin: str | None, - self_conjugate: bool, -) -> str | None: - """Convert a canonical PDG name, returning `None` for unsupported syntax.""" - match = _PARTICLE_NAME_PATTERN.fullmatch(name) - if match is None: - return None - - latex = _render_base(match.group("base")) - - subscripts: list[str] = [] - superscripts: list[str] = [] - for script in _SCRIPT_PATTERN.finditer(match.group("scripts")): - value = script.group("value") - if script.group("kind") == "_": - subscripts.append(_LATEX_SYMBOLS.get(value, value)) - else: - superscripts.append(R"\prime" if value == "'" else value) - latex += "".join(Rf"_{{{value}}}" for value in subscripts) - - charge = match.group("charge") - if charge == "0" and self_conjugate and isospin == "0": - charge = None - qualifier = match.group("qualifier") - if qualifier in {None, "()"} and charge is not None and superscripts: - superscripts.append(charge) - charge = None - if superscripts: - latex += Rf"^{{{''.join(superscripts)}}}" - if qualifier != "()" and qualifier is not None: - latex += qualifier - if charge is not None: - latex += Rf"^{{{charge}}}" - return latex - - -def _render_base(base: str) -> str: - is_antiparticle = base.endswith("bar") - if is_antiparticle: - base = base.removesuffix("bar") - latex = "/".join(_LATEX_SYMBOLS.get(part, part) for part in base.split("/")) - if is_antiparticle: - latex = Rf"\overline{{{latex}}}" - return latex diff --git a/tests/unit/test_pdg_adapter.py b/tests/unit/test_pdg_adapter.py index b52d0fb2..35f7db2a 100644 --- a/tests/unit/test_pdg_adapter.py +++ b/tests/unit/test_pdg_adapter.py @@ -67,29 +67,6 @@ def test_uses_official_names( assert official_particles.find(mcid).name == name -def test_all_particles_have_latex(official_particles: ParticleCollection): - assert all(particle.latex is not None for particle in official_particles) - - -@pytest.mark.parametrize( - ("mcid", "latex"), - [ - (12, R"\nu_{e}"), - (211, R"\pi^{+}"), - (-2212, R"\overline{p}"), - (443, R"J/\psi(1S)"), - (9010221, R"f_{0}(980)"), - (5122, R"\Lambda_{b}^{0}"), - ], -) -def test_uses_generated_latex( - official_particles: ParticleCollection, - mcid: int, - latex: str, -): - assert official_particles.find(mcid).latex == latex - - def test_pion_quantum_numbers(official_particles: ParticleCollection): pion = official_particles.find(211) assert pion.spin == 0 diff --git a/tests/unit/test_pdg_latex.py b/tests/unit/test_pdg_latex.py deleted file mode 100644 index 6d2ea2b0..00000000 --- a/tests/unit/test_pdg_latex.py +++ /dev/null @@ -1,46 +0,0 @@ -import pytest - -from qrules._pdg_latex import create_latex_name - - -@pytest.mark.parametrize( - ("name", "isospin", "self_conjugate", "latex"), - [ - ("pi+", "1", False, R"\pi^{+}"), - ("pi0", "1", True, R"\pi^{0}"), - ("pbar", "1/2", False, R"\overline{p}"), - ("nubar_mu", None, False, R"\overline{\nu}_{\mu}"), - ("J/psi(1S)", "0", True, R"J/\psi(1S)"), - ("f_0(980)0", "0", True, R"f_{0}(980)"), - ("Lambda_b()0", "0", False, R"\Lambda_{b}^{0}"), - ("D_0^*(2300)+", "1/2", False, R"D_{0}^{*}(2300)^{+}"), - ("D_s^*()+", "0", False, R"D_{s}^{*+}"), - ("Xibar_c^'()0", "1/2", False, R"\overline{\Xi}_{c}^{\prime0}"), - ("eta^'(958)0", "0", True, R"\eta^{\prime}(958)"), - ], -) -def test_create_latex_name( - name: str, - isospin: str | None, - self_conjugate: bool, - latex: str, -): - assert ( - create_latex_name( - name, - isospin=isospin, - self_conjugate=self_conjugate, - ) - == latex - ) - - -def test_rejects_unsupported_name_syntax(): - assert ( - create_latex_name( - "not a PDG name", - isospin=None, - self_conjugate=False, - ) - is None - ) From bf6fb96032102527b7d138581afffd6eeae1ad3b Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:24:31 +0200 Subject: [PATCH 23/27] MAINT: remove redundant cSpell words --- .cspell.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/.cspell.json b/.cspell.json index 587c9e4d..24fc3009 100644 --- a/.cspell.json +++ b/.cspell.json @@ -38,7 +38,6 @@ "pyproject.toml" ], "ignoreWords": [ - "Xibar", "adrs", "ampform", "arange", @@ -90,7 +89,6 @@ "mathrm", "maxdepth", "mcid", - "mcids", "meijerg", "mimetype", "modindex", @@ -105,7 +103,6 @@ "noreply", "nrows", "nsimplify", - "nubar", "pandoc", "pbar", "permalinks", From b13c06c147d0741b1c1ee3a9dae74f54095f1937 Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:24:45 +0200 Subject: [PATCH 24/27] MAINT: minor formatting improvements --- src/qrules/particle.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/qrules/particle.py b/src/qrules/particle.py index efb5141c..46f4e8e2 100644 --- a/src/qrules/particle.py +++ b/src/qrules/particle.py @@ -501,9 +501,8 @@ def load_pdg(*, source: PyPIPackage = "particle") -> ParticleCollection: """Create a `.ParticleCollection` with all entries from the PDG. By default, particle definitions are imported from the `particle - `_ package. Set ``source="pdg"`` to - import them from the official `PDG Python API `_ - instead. + `_ package. Set ``source="pdg"`` to import + them from the official `PDG Python API `_ instead. """ if source == "pdg": from qrules._pdg_adapter import load_pdg as load_official_pdg # ruff: ignore[import-outside-top-level] From 5e396a0ee1c4a35cce565af7503fb382570cca6a Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:11:26 +0200 Subject: [PATCH 25/27] DOCS: Enhance particle loading documentation with source parameter details --- docs/usage/particle.ipynb | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/usage/particle.ipynb b/docs/usage/particle.ipynb index 20892d0d..819f2dfb 100644 --- a/docs/usage/particle.ipynb +++ b/docs/usage/particle.ipynb @@ -33,6 +33,13 @@ "Here, we call this method directly to illustrate what happens (we use {func}`.load_pdg`, which loads a subset):" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The function {func}`.load_pdg` uses a flag `source` to determine the source of the particle definitions. If `source` is set to `\"pdg\"`, it loads particle definitions from the [`pdg`](https://pdgapi.lbl.gov/doc/) package provided by PDG. If `source` is set to `\"particle\"`, it loads the particle definitions from the [`particle`](https://pypi.org/project/particle/) package, which is provided by scikit-hep." + ] + }, { "cell_type": "code", "execution_count": null, @@ -45,6 +52,32 @@ "print(\"Number of loaded particles:\", len(particle_db))" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "particle_db_pdg = load_pdg(source=\"pdg\")\n", + "print(\"Number of particles loaded from PDG:\", len(particle_db_pdg))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "One can see on one hand that the number of loaded {class}`.Particle` definitions from the `pdg` package is currently greater than the number of particles loaded from the `particle` package and on the other hand, that loading the {class}`.ParticleCollection` instance takes significantly more time due to it using SQLite to store the particle data." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + ":::{warning}\n", + "As of writing the `pdg` package does not support LaTeX names for particles.\n", + ":::" + ] + }, { "cell_type": "markdown", "metadata": {}, From 9f1c2435f7a8c7eb4b8aa3bbdf1dd41748ff7ecf Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:18:55 +0200 Subject: [PATCH 26/27] REFAC: Restructure tests in test_pdg_adapter.py for improved readability and organization --- tests/unit/test_pdg_adapter.py | 357 ++++++++++++++++----------------- 1 file changed, 178 insertions(+), 179 deletions(-) diff --git a/tests/unit/test_pdg_adapter.py b/tests/unit/test_pdg_adapter.py index 35f7db2a..6b410782 100644 --- a/tests/unit/test_pdg_adapter.py +++ b/tests/unit/test_pdg_adapter.py @@ -15,182 +15,181 @@ def official_particles() -> ParticleCollection: return load_pdg(source="pdg") -def test_uses_scikit_hep_source_by_default( - official_particles: ParticleCollection, -): - default_particles = load_pdg() - scikit_hep_particles = load_pdg(source="particle") - - assert default_particles == scikit_hep_particles - assert default_particles.find(-2212).name == "p~" - assert official_particles.find(-2212).name == "pbar" - - -def test_rejects_unknown_source(): - with pytest.raises(ValueError, match="Unknown particle source"): - load_pdg(source=cast("Any", "unknown")) - - -def test_caches_particle_definitions_and_returns_independent_collections( - official_particles: ParticleCollection, -): - cache_info_before = _load_pdg_particles.cache_info() - - second_collection = load_pdg(source="pdg") - - cache_info_after = _load_pdg_particles.cache_info() - assert cache_info_after.hits == cache_info_before.hits + 1 - assert cache_info_after.misses == cache_info_before.misses - assert second_collection == official_particles - assert second_collection is not official_particles - - second_collection.discard("gamma") - assert "gamma" not in second_collection - assert "gamma" in official_particles - - -@pytest.mark.parametrize( - ("mcid", "name"), - [ - (12, "nu_e"), - (-2212, "pbar"), - (443, "J/psi(1S)"), - (9010221, "f_0(980)0"), - (5122, "Lambda_b()0"), - ], -) -def test_uses_official_names( - official_particles: ParticleCollection, - mcid: int, - name: str, -): - assert official_particles.find(mcid).name == name - - -def test_pion_quantum_numbers(official_particles: ParticleCollection): - pion = official_particles.find(211) - assert pion.spin == 0 - assert pion.charge == +1 - assert pion.isospin is not None - assert pion.isospin.magnitude == 1 - assert pion.isospin.projection == +1 - assert pion.parity == Parity(-1) - assert pion.c_parity is None - assert pion.g_parity == Parity(-1) - - -def test_antiproton_quantum_numbers(official_particles: ParticleCollection): - antiproton = official_particles.find(-2212) - assert antiproton.spin == Fraction(1, 2) - assert antiproton.charge == -1 - assert antiproton.baryon_number == -1 - assert antiproton.isospin is not None - assert antiproton.isospin.projection == Fraction(-1, 2) - assert antiproton.parity == Parity(-1) - - -@pytest.mark.parametrize( - ("mcid", "lepton_numbers"), - [ - (11, (+1, 0, 0)), - (-12, (-1, 0, 0)), - (13, (0, +1, 0)), - (-14, (0, -1, 0)), - (15, (0, 0, +1)), - (-16, (0, 0, -1)), - ], -) -def test_lepton_numbers( - official_particles: ParticleCollection, - mcid: int, - lepton_numbers: tuple[int, int, int], -): - particle = official_particles.find(mcid) - assert ( - particle.electron_lepton_number, - particle.muon_lepton_number, - particle.tau_lepton_number, - ) == lepton_numbers - - -@pytest.mark.parametrize( - ("mcid", "flavor_numbers"), - [ - (+321, (+1, 0, 0, 0)), - (-321, (-1, 0, 0, 0)), - (+411, (0, +1, 0, 0)), - (-411, (0, -1, 0, 0)), - (+521, (0, 0, +1, 0)), - (-521, (0, 0, -1, 0)), - ], -) -def test_flavor_numbers( - official_particles: ParticleCollection, - mcid: int, - flavor_numbers: tuple[int, int, int, int], -): - particle = official_particles.find(mcid) - assert ( - particle.strangeness, - particle.charmness, - particle.bottomness, - particle.topness, - ) == flavor_numbers - - -def test_prefers_official_spin(official_particles: ParticleCollection): - assert official_particles.find(104122).spin == Fraction(3, 2) - - -def test_uses_measured_mass_and_width(official_particles: ParticleCollection): - rho = official_particles.find(113) - assert rho.mass == pytest.approx(0.7752611563582926) - assert rho.width == pytest.approx(0.14739133387028722) - - -def test_derives_width_from_lifetime(official_particles: ParticleCollection): - muon = official_particles.find(13) - assert muon.width == pytest.approx(2.9959292110062035e-19) - - -def test_uses_zero_width_for_stable_particle( - official_particles: ParticleCollection, -): - assert official_particles.find(22).width == 0.0 - - -def test_uses_antiparticle_mass_if_particle_has_no_mass(): - source = MagicMock(self_conjugate=False, mcid=1, name="particle") - source.has_mass_entry = False - type(source).mass = PropertyMock(side_effect=PdgNoDataError("no mass")) - source.antiparticle.has_mass_entry = True - source.antiparticle.mass = 0.5 - - assert _to_mass(source) == 0.5 - - -def test_uses_antiparticle_width_if_particle_has_no_decay_data(): - source = MagicMock(self_conjugate=False) - source.has_width_entry = False - source.has_lifetime_entry = False - source.width = 0.0 - source.antiparticle.has_width_entry = True - source.antiparticle.has_lifetime_entry = False - source.antiparticle.width = 0.25 - - assert _to_width(source) == 0.25 - - -@pytest.mark.parametrize( - ("mcid", "width"), - [ - (9010221, 0.055), # 10 to 100 MeV - (2224, 0.117), # 114 to 117 to 120 MeV - ], -) -def test_uses_central_value_for_width_ranges( - official_particles: ParticleCollection, - mcid: int, - width: float, -): - assert official_particles.find(mcid).width == pytest.approx(width) +def describe_load_pdg(): + def it_uses_scikit_hep_source_by_default( + official_particles: ParticleCollection, + ): + default_particles = load_pdg() + scikit_hep_particles = load_pdg(source="particle") + + assert default_particles == scikit_hep_particles + assert default_particles.find(-2212).name == "p~" + assert official_particles.find(-2212).name == "pbar" + + def it_rejects_unknown_source(): + with pytest.raises(ValueError, match="Unknown particle source"): + load_pdg(source=cast("Any", "unknown")) + + def it_caches_particle_definitions_and_returns_independent_collections( + official_particles: ParticleCollection, + ): + cache_info_before = _load_pdg_particles.cache_info() + + second_collection = load_pdg(source="pdg") + + cache_info_after = _load_pdg_particles.cache_info() + assert cache_info_after.hits == cache_info_before.hits + 1 + assert cache_info_after.misses == cache_info_before.misses + assert second_collection == official_particles + assert second_collection is not official_particles + + second_collection.discard("gamma") + assert "gamma" not in second_collection + assert "gamma" in official_particles + + @pytest.mark.parametrize( + ("mcid", "name"), + [ + (12, "nu_e"), + (-2212, "pbar"), + (443, "J/psi(1S)"), + (9010221, "f_0(980)0"), + (5122, "Lambda_b()0"), + ], + ) + def it_uses_official_names( + official_particles: ParticleCollection, + mcid: int, + name: str, + ): + assert official_particles.find(mcid).name == name + + def it_loads_pion_quantum_numbers( + official_particles: ParticleCollection, + ): + pion = official_particles.find(211) + assert pion.spin == 0 + assert pion.charge == +1 + assert pion.isospin is not None + assert pion.isospin.magnitude == 1 + assert pion.isospin.projection == +1 + assert pion.parity == Parity(-1) + assert pion.c_parity is None + assert pion.g_parity == Parity(-1) + + def it_loads_antiproton_quantum_numbers( + official_particles: ParticleCollection, + ): + antiproton = official_particles.find(-2212) + assert antiproton.spin == Fraction(1, 2) + assert antiproton.charge == -1 + assert antiproton.baryon_number == -1 + assert antiproton.isospin is not None + assert antiproton.isospin.projection == Fraction(-1, 2) + assert antiproton.parity == Parity(-1) + + @pytest.mark.parametrize( + ("mcid", "lepton_numbers"), + [ + (11, (+1, 0, 0)), + (-12, (-1, 0, 0)), + (13, (0, +1, 0)), + (-14, (0, -1, 0)), + (15, (0, 0, +1)), + (-16, (0, 0, -1)), + ], + ) + def it_loads_lepton_numbers( + official_particles: ParticleCollection, + mcid: int, + lepton_numbers: tuple[int, int, int], + ): + particle = official_particles.find(mcid) + assert ( + particle.electron_lepton_number, + particle.muon_lepton_number, + particle.tau_lepton_number, + ) == lepton_numbers + + @pytest.mark.parametrize( + ("mcid", "flavor_numbers"), + [ + (+321, (+1, 0, 0, 0)), + (-321, (-1, 0, 0, 0)), + (+411, (0, +1, 0, 0)), + (-411, (0, -1, 0, 0)), + (+521, (0, 0, +1, 0)), + (-521, (0, 0, -1, 0)), + ], + ) + def it_loads_flavor_numbers( + official_particles: ParticleCollection, + mcid: int, + flavor_numbers: tuple[int, int, int, int], + ): + particle = official_particles.find(mcid) + assert ( + particle.strangeness, + particle.charmness, + particle.bottomness, + particle.topness, + ) == flavor_numbers + + def it_prefers_official_spin(official_particles: ParticleCollection): + assert official_particles.find(104122).spin == Fraction(3, 2) + + def it_uses_measured_mass_and_width( + official_particles: ParticleCollection, + ): + rho = official_particles.find(113) + assert rho.mass == pytest.approx(0.7752611563582926) + assert rho.width == pytest.approx(0.14739133387028722) + + def it_derives_width_from_lifetime( + official_particles: ParticleCollection, + ): + muon = official_particles.find(13) + assert muon.width == pytest.approx(2.9959292110062035e-19) + + def it_uses_zero_width_for_stable_particle( + official_particles: ParticleCollection, + ): + assert official_particles.find(22).width == 0.0 + + @pytest.mark.parametrize( + ("mcid", "width"), + [ + (9010221, 0.055), # 10 to 100 MeV + (2224, 0.117), # 114 to 117 to 120 MeV + ], + ) + def it_uses_central_value_for_width_ranges( + official_particles: ParticleCollection, + mcid: int, + width: float, + ): + assert official_particles.find(mcid).width == pytest.approx(width) + + +def describe_to_mass(): + def it_uses_antiparticle_mass_if_particle_has_no_mass(): + source = MagicMock(self_conjugate=False, mcid=1, name="particle") + source.has_mass_entry = False + type(source).mass = PropertyMock(side_effect=PdgNoDataError("no mass")) + source.antiparticle.has_mass_entry = True + source.antiparticle.mass = 0.5 + + assert _to_mass(source) == 0.5 + + +def describe_to_width(): + def it_uses_antiparticle_width_if_particle_has_no_decay_data(): + source = MagicMock(self_conjugate=False) + source.has_width_entry = False + source.has_lifetime_entry = False + source.width = 0.0 + source.antiparticle.has_width_entry = True + source.antiparticle.has_lifetime_entry = False + source.antiparticle.width = 0.25 + + assert _to_width(source) == 0.25 From ff78ae212a22fa04a224328c32fbc7ff488808de Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:20:33 +0200 Subject: [PATCH 27/27] DOC: add link to PDG issue --- docs/usage/particle.ipynb | 2 +- tests/unit/test_pdg_adapter.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/usage/particle.ipynb b/docs/usage/particle.ipynb index 819f2dfb..3c0565f4 100644 --- a/docs/usage/particle.ipynb +++ b/docs/usage/particle.ipynb @@ -74,7 +74,7 @@ "metadata": {}, "source": [ ":::{warning}\n", - "As of writing the `pdg` package does not support LaTeX names for particles.\n", + "As of writing the `pdg` package does not support LaTeX names for particles ([particledatagroup/api#42](https://github.com/particledatagroup/api/issues/42)).\n", ":::" ] }, diff --git a/tests/unit/test_pdg_adapter.py b/tests/unit/test_pdg_adapter.py index 6b410782..b1f27a59 100644 --- a/tests/unit/test_pdg_adapter.py +++ b/tests/unit/test_pdg_adapter.py @@ -178,7 +178,6 @@ def it_uses_antiparticle_mass_if_particle_has_no_mass(): type(source).mass = PropertyMock(side_effect=PdgNoDataError("no mass")) source.antiparticle.has_mass_entry = True source.antiparticle.mass = 0.5 - assert _to_mass(source) == 0.5 @@ -191,5 +190,4 @@ def it_uses_antiparticle_width_if_particle_has_no_decay_data(): source.antiparticle.has_width_entry = True source.antiparticle.has_lifetime_entry = False source.antiparticle.width = 0.25 - assert _to_width(source) == 0.25