diff --git a/.cspell.json b/.cspell.json index 38d8c4a6..24fc3009 100644 --- a/.cspell.json +++ b/.cspell.json @@ -88,6 +88,7 @@ "macos", "mathrm", "maxdepth", + "mcid", "meijerg", "mimetype", "modindex", @@ -161,6 +162,7 @@ "determinator", "determinators", "docstrings", + "eigenstates", "façade", "fermionic", "flatté", diff --git a/docs/usage/particle.ipynb b/docs/usage/particle.ipynb index 20892d0d..3c0565f4 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 ([particledatagroup/api#42](https://github.com/particledatagroup/api/issues/42)).\n", + ":::" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/pyproject.toml b/pyproject.toml index 8ee11c44..ae9c0ed4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "frozendict", "jsonschema", "particle", + "pdg", "python-constraint2", "tqdm >=4.24.0", # autonotebook ] diff --git a/src/qrules/_pdg_adapter.py b/src/qrules/_pdg_adapter.py new file mode 100644 index 00000000..71e96d08 --- /dev/null +++ b/src/qrules/_pdg_adapter.py @@ -0,0 +1,303 @@ +"""Convert records from the official PDG API to QRules particles.""" + +from __future__ import annotations + +import re +from fractions import Fraction +from functools import cache +from typing import TYPE_CHECKING + +import pdg +from pdg.errors import PdgNoDataError +from pdg.units import convert + +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} + + +def load_pdg() -> ParticleCollection: + """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 = [] + for source_particle in _iter_particles(api): + try: + particle = _convert_particle(source_particle) + except ValueError: + continue + particles.append(particle) + return tuple(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 ValueError(msg) + if mcid in _SKIPPED_MC_IDS or abs(mcid) >= 1_000_000_000: + msg = f"Particle {source.name} is not supported" + raise ValueError(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 ValueError(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 ValueError(msg) + + +def _to_mass(source: PdgParticle) -> float: + for candidate in _particle_and_antiparticle(source): + try: + mass = candidate.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" + raise ValueError(msg) + + +def _to_width(source: PdgParticle) -> float: + for candidate in _particle_and_antiparticle(source): + 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 + 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 + 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: + 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/src/qrules/particle.py b/src/qrules/particle.py index df64e50b..46f4e8e2 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,12 +494,24 @@ def create_antiparticle( ) -def load_pdg() -> ParticleCollection: +PyPIPackage = Literal["particle", "pdg"] + + +def load_pdg(*, source: PyPIPackage = "particle") -> ParticleCollection: """Create a `.ParticleCollection` with all entries from the PDG. - PDG info is imported from the `scikit-hep/particle - `_ package. + By default, particle definitions are imported from the `particle + `_ 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] + + return load_official_pdg() + if source != "particle": + msg = f"Unknown particle source: {source!r}" + raise ValueError(msg) + from particle import Particle as PdgDatabase # ruff: ignore[import-outside-top-level] all_pdg_particles = PdgDatabase.findall( diff --git a/tests/unit/test_pdg_adapter.py b/tests/unit/test_pdg_adapter.py new file mode 100644 index 00000000..b1f27a59 --- /dev/null +++ b/tests/unit/test_pdg_adapter.py @@ -0,0 +1,193 @@ +from fractions import Fraction +from typing import Any, cast +from unittest.mock import MagicMock, PropertyMock + +import pytest +from pdg.errors import PdgNoDataError + +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 + + +@pytest.fixture(scope="module") +def official_particles() -> ParticleCollection: + return load_pdg(source="pdg") + + +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 diff --git a/uv.lock b/uv.lock index 0465e062..c14411b5 100644 --- a/uv.lock +++ b/uv.lock @@ -1934,15 +1934,16 @@ wheels = [ [[package]] name = "particle" -version = "1.0.0" +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/c3/66/09911bbb658fdffe960903c12edecab95f6cced40fef4909d1cc04bd288b/particle-1.0.0.tar.gz", hash = "sha256:49145dec1cb5044b07f3e8e902280fa050950fa845b058003e69de519bb50492", size = 285766, upload-time = "2026-06-25T14:48:26.893Z" } +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/a8/92/05078b696cddbdd60963577c895d5b77e5bf829197b08677351b11761c2b/particle-1.0.0-py3-none-any.whl", hash = "sha256:fc2656f53e729be76e45430f56aa65dc20dea069565a393032016544425b64bf", size = 245760, upload-time = "2026-06-25T14:48:25.238Z" }, + { 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]] @@ -1954,6 +1955,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 +2598,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 +2724,7 @@ requires-dist = [ { name = "graphviz", marker = "extra == 'viz'" }, { name = "jsonschema" }, { name = "particle" }, + { name = "pdg" }, { name = "python-constraint2" }, { name = "pyyaml" }, { name = "tqdm", specifier = ">=4.24.0" },