From e52b20c524b87a8920f7c5fdc6bb0c548f01f94c Mon Sep 17 00:00:00 2001 From: Radonirinaunimi Date: Mon, 1 Jun 2026 21:38:47 +0200 Subject: [PATCH 01/10] Init `neopdf` backend implementation --- validphys2/src/validphys/core.py | 6 +- validphys2/src/validphys/neopdfset.py | 107 ++++++ validphys2/src/validphys/pdf_backends.py | 60 +++ .../src/validphys/tests/test_neopdfset.py | 362 ++++++++++++++++++ 4 files changed, 532 insertions(+), 3 deletions(-) create mode 100644 validphys2/src/validphys/neopdfset.py create mode 100644 validphys2/src/validphys/pdf_backends.py create mode 100644 validphys2/src/validphys/tests/test_neopdfset.py diff --git a/validphys2/src/validphys/core.py b/validphys2/src/validphys/core.py index ac680a3c98..8b037e866e 100644 --- a/validphys2/src/validphys/core.py +++ b/validphys2/src/validphys/core.py @@ -23,7 +23,7 @@ # Maybe move the cuts logic to its own module? from validphys import filters, lhaindex from validphys.fkparser import load_fktable, parse_cfactor -from validphys.lhapdfset import LHAPDFSet +from validphys.pdf_backends import make_pdfset from validphys.tableloader import parse_exp_mat from validphys.utils import experiments_to_dataset_inputs, yaml_safe @@ -208,12 +208,12 @@ def _rescale_factor(self): @functools.lru_cache(maxsize=16) def load(self): - return LHAPDFSet(self.name, self.error_type) + return make_pdfset(self.name, self.error_type) @functools.lru_cache(maxsize=2) def load_t0(self): """Load the PDF as a t0 set""" - return LHAPDFSet(self.name, "t0") + return make_pdfset(self.name, "t0") def __str__(self): return self.label diff --git a/validphys2/src/validphys/neopdfset.py b/validphys2/src/validphys/neopdfset.py new file mode 100644 index 0000000000..bf620d9a06 --- /dev/null +++ b/validphys2/src/validphys/neopdfset.py @@ -0,0 +1,107 @@ +""" +Module containing the NeoPDF-backend PDF set compatible with validphys. + +``NeoPDFSet`` has the same public interface as ``LHAPDFSet`` so it can +be used as a drop-in replacement anywhere an ``LHAPDFSet`` is expected. + +Examples +-------- +>>> from validphys.neopdfset import NeoPDFSet +>>> pdf = NeoPDFSet("NNPDF40_nnlo_as_01180", "replicas") +>>> len(pdf.members) +101 +>>> pdf.grid_values([-2, -1, 1, 2, 21], [0.1, 0.5], [10.0, 100.0]).shape +(101, 5, 2, 2) +""" + +import logging + +from neopdf.pdf import PDF as _NeoPDF +import numpy as np + +log = logging.getLogger(__name__) + + +class NeoPDFSet: + """Wrapper around the NeoPDF interpolation library. + + Provides the same interface as ``LHAPDFSet`` so it can be used + interchangeably within validphys and n3fit. + """ + + def __init__(self, name: str, error_type: str): + self._name = name + self._error_type = error_type + self._flavors = None + if self.is_t0: + self._members = [_NeoPDF(name)] + else: + self._members = _NeoPDF.mkPDFs(name) + + @property + def is_t0(self) -> bool: + """Check whether it is a t0 set.""" + return self._error_type == "t0" + + @property + def n_members(self) -> int: + """Return the total members of the PDF set.""" + return len(self.members) + + @property + def members(self): + """Return a given member of the PDF set. + + The special error type t0 returns only member 0. + """ + if self.is_t0: + return self._members[0:1] + return self._members + + @property + def central_member(self): + """Return a reference to member 0.""" + return self._members[0] + + def xfxQ(self, x: float, Q: float, n: int, fl: int) -> float: + """Return the PDF value for a single point for a single member. + + If the flavour is absent from the set, simply return 0.0. + """ + if fl not in self.flavors: + return 0.0 + return self.members[n].xfxQ2(fl, x, Q**2) + + @property + def flavors(self): + """Return the list of particle IDs supported by this PDF set.""" + if self._flavors is None: + self._flavors = self.members[0].pids() + return self._flavors + + def grid_values(self, flavors: np.ndarray, xgrid: np.ndarray, qgrid: np.ndarray) -> np.ndarray: + """Return PDF values on a grid for all the members. + + Parameters + ---------- + flavors: + Array of PDG particle IDs. + xgrid: + Array of x values. + qgrid: + Array of Q values (not Q²). + + Returns + ------- + ndarray of shape ``(members, flavors, len(xgrid), len(qgrid))`` + """ + q2grid = np.asarray(qgrid) ** 2 + xgrid = np.asarray(xgrid) + pids = list(flavors) + nx, nq, nfl = len(xgrid), len(qgrid), len(pids) + + results = [] + for member in self.members: + vals = np.array(member.xfxQ2s(pids, xgrid, q2grid)) + results.append(vals.reshape(nfl, nx, nq)) + return np.array(results) diff --git a/validphys2/src/validphys/pdf_backends.py b/validphys2/src/validphys/pdf_backends.py new file mode 100644 index 0000000000..fb88369dee --- /dev/null +++ b/validphys2/src/validphys/pdf_backends.py @@ -0,0 +1,60 @@ +""" +PDF interpolation backend selector. + +The active backend is controlled by the ``NNPDF_PDF_BACKEND`` environment +variable (default: ``"lhapdf"``). Set it to ``"neopdf"`` to use the +NeoPDF interpolation library instead. + +Both backends expose the same public interface (``LHAPDFSet`` / ``NeoPDFSet``) +so they are interchangeable throughout validphys and n3fit. +""" + +import os + +_BACKEND_ENV_VAR = "NNPDF_PDF_BACKEND" +_VALID_BACKENDS = ("lhapdf", "neopdf") + + +class InvalidPDFBackend(Exception): + pass + + +def _active_backend() -> str: + backend = os.environ.get(_BACKEND_ENV_VAR, "lhapdf").lower() + + if backend not in _VALID_BACKENDS: + raise InvalidPDFBackend( + f"Unknown PDF backend {backend!r} in {_BACKEND_ENV_VAR}. " + f"Valid options are: {_VALID_BACKENDS}" + ) + return backend + + +def make_pdfset(name: str, error_type: str, *, backend: str | None = None): + """Return a PDF set object for *name* using the requested backend. + + Parameters + ---------- + name: + LHAPDF set name (both backends resolve sets by this name). + error_type: + One of ``replicas``, ``symmhessian``, ``hessian``, ``t0`` + backend: + ``lhapdf`` or ``neopdf``. When *None* the value of the + ``NNPDF_PDF_BACKEND`` environment variable is used (default + ``lhapdf``). + + Returns + ------- + ``LHAPDFSet`` or ``NeoPDFSet`` depending on *backend*. + """ + active = backend if backend is not None else _active_backend() + + if active == "neopdf": + from validphys.neopdfset import NeoPDFSet + + return NeoPDFSet(name, error_type) + + from validphys.lhapdfset import LHAPDFSet + + return LHAPDFSet(name, error_type) diff --git a/validphys2/src/validphys/tests/test_neopdfset.py b/validphys2/src/validphys/tests/test_neopdfset.py new file mode 100644 index 0000000000..881984d3ed --- /dev/null +++ b/validphys2/src/validphys/tests/test_neopdfset.py @@ -0,0 +1,362 @@ +""" +Tests for the NeoPDF interpolation backend. +""" + +import importlib + +import numpy as np +import pytest + +# Skip the whole module if neopdf is not importable +neopdf = pytest.importorskip("neopdf", reason="neopdf not installed") + +# In view of making LHAPDF Optional in the future. +try: + import lhapdf as _lhapdf + + _lhapdf.setVerbosity(0) + HAS_LHAPDF = True +except ModuleNotFoundError: + HAS_LHAPDF = False + +requires_lhapdf = pytest.mark.skipif(not HAS_LHAPDF, reason="lhapdf not installed") + +PDF_NAME = "NNPDF40_nnlo_as_01180" + +PIDS = [-5, -4, -3, -2, -1, 1, 2, 3, 4, 5, 21] + +XGRID = np.array([1e-5, 1e-3, 1e-2, 0.1, 0.3, 0.5, 0.7, 0.9]) +QGRID = np.array([1.7, 5.0, 10.0, 100.0]) # GeV + +X_VALUE = 0.1 +Q_VALUE = 10.0 + + +@pytest.fixture(scope="module") +def neo_pdfset(): + from validphys.neopdfset import NeoPDFSet + + return NeoPDFSet(PDF_NAME, "replicas") + + +@pytest.fixture(scope="module") +def neo_pdfset_t0(): + from validphys.neopdfset import NeoPDFSet + + return NeoPDFSet(PDF_NAME, "t0") + + +@pytest.fixture(scope="module") +def lha_pdfset(): + from validphys.lhapdfset import LHAPDFSet + + return LHAPDFSet(PDF_NAME, "replicas") + + +@pytest.fixture(scope="module") +def lha_pdfset_t0(): + from validphys.lhapdfset import LHAPDFSet + + return LHAPDFSet(PDF_NAME, "t0") + + +class TestT0Mode: + """Focused tests on t0 semantics for the NeoPDF backend.""" + + def test_t0_members_slice_length(self, neo_pdfset_t0): + assert len(neo_pdfset_t0.members) == 1 + + def test_t0_central_member_identity(self, neo_pdfset_t0): + assert neo_pdfset_t0.central_member is neo_pdfset_t0.members[0] + + def test_t0_xfxQ_index_zero(self, neo_pdfset_t0): + val = neo_pdfset_t0.xfxQ(X_VALUE, Q_VALUE, n=0, fl=21) + assert isinstance(val, float) + assert np.isfinite(val) + + def test_t0_xfxQ_out_of_range_member_raises(self, neo_pdfset_t0): + """Requesting member index >= 1 in t0 mode must raise IndexError.""" + with pytest.raises(IndexError): + neo_pdfset_t0.xfxQ(X_VALUE, Q_VALUE, n=1, fl=21) + + @requires_lhapdf + def test_t0_xfxQ_agrees_with_lhapdf(self, neo_pdfset_t0, lha_pdfset_t0): + for fl in [21, 1, 2, -1]: + if fl not in neo_pdfset_t0.flavors: + continue + neo_val = neo_pdfset_t0.xfxQ(X_VALUE, Q_VALUE, n=0, fl=fl) + lha_val = lha_pdfset_t0.xfxQ(X_VALUE, Q_VALUE, n=0, fl=fl) + np.testing.assert_equal(neo_val, lha_val) + + +class TestNeoPDFSetInterface: + """The following simpy checks that that ``NeoPDFSet`` exposes the same + interface as ``LHAPDFSet``. + """ + + def test_is_t0_false_for_replicas(self, neo_pdfset): + assert neo_pdfset.is_t0 is False + + def test_is_t0_true_for_t0(self, neo_pdfset_t0): + assert neo_pdfset_t0.is_t0 is True + + def test_n_members_positive(self, neo_pdfset): + assert neo_pdfset.n_members > 0 + + def test_n_members_matches_members_length(self, neo_pdfset): + assert neo_pdfset.n_members == len(neo_pdfset.members) + + def test_t0_has_exactly_one_member(self, neo_pdfset_t0): + assert neo_pdfset_t0.n_members == 1 + + def test_central_member_is_member_zero(self, neo_pdfset): + assert neo_pdfset.central_member is neo_pdfset.members[0] + + def test_flavors_is_list(self, neo_pdfset): + assert isinstance(neo_pdfset.flavors, list) + + def test_flavors_contains_gluon(self, neo_pdfset): + assert 21 in neo_pdfset.flavors + + def test_flavors_contains_quarks(self, neo_pdfset): + for pid in [-2, -1, 1, 2]: + assert pid in neo_pdfset.flavors + + def test_flavors_cached(self, neo_pdfset): + assert neo_pdfset.flavors is neo_pdfset.flavors + + def test_xfxQ_returns_float(self, neo_pdfset): + val = neo_pdfset.xfxQ(X_VALUE, Q_VALUE, n=0, fl=21) + assert isinstance(val, float) + + def test_xfxQ_absent_flavour_returns_zero(self, neo_pdfset): + if 6 not in neo_pdfset.flavors: + assert neo_pdfset.xfxQ(X_VALUE, Q_VALUE, n=0, fl=6) == 0.0 + + def test_grid_values_shape(self, neo_pdfset): + nx, nq, _ = len(XGRID), len(QGRID), len(PIDS) + pids = [p for p in PIDS if p in neo_pdfset.flavors] + result = neo_pdfset.grid_values(np.array(pids), XGRID, QGRID) + assert result.shape == (neo_pdfset.n_members, len(pids), nx, nq) + + def test_grid_values_dtype_is_float(self, neo_pdfset): + pids = [p for p in PIDS if p in neo_pdfset.flavors] + result = neo_pdfset.grid_values(np.array(pids), XGRID, QGRID) + assert np.issubdtype(result.dtype, np.floating) + + def test_grid_values_finite(self, neo_pdfset): + pids = [p for p in PIDS if p in neo_pdfset.flavors] + result = neo_pdfset.grid_values(np.array(pids), XGRID, QGRID) + assert np.all(np.isfinite(result)) + + def test_grid_values_single_x_single_q(self, neo_pdfset): + pids = [21] + result = neo_pdfset.grid_values(np.array(pids), np.array([0.1]), np.array([10.0])) + assert result.shape == (neo_pdfset.n_members, 1, 1, 1) + + def test_t0_grid_values_shape(self, neo_pdfset_t0): + pids = [p for p in PIDS if p in neo_pdfset_t0.flavors] + result = neo_pdfset_t0.grid_values(np.array(pids), XGRID, QGRID) + assert result.shape == (1, len(pids), len(XGRID), len(QGRID)) + + +@requires_lhapdf +class TestNumericalAgreement: + """ + NeoPDF and LHAPDF must produce identical values (bit-for-bit) for every + call that both backends support. + """ + + @staticmethod + def _reorder_lhapdf(arr, nx, nq): + """Convert LHAPDFSet.grid_values output to canonical (…, nx, nq) order.""" + nm, nfl = arr.shape[:2] + return arr.reshape(nm, nfl, nq, nx).swapaxes(-1, -2) + + @pytest.mark.parametrize("fl", [21, 1, -1, 2, -2, 3]) + def test_xfxQ_member0(self, neo_pdfset, lha_pdfset, fl): + if fl not in neo_pdfset.flavors: + pytest.skip(f"pid {fl} not in set") + neo_val = neo_pdfset.xfxQ(X_VALUE, Q_VALUE, n=0, fl=fl) + lha_val = lha_pdfset.xfxQ(X_VALUE, Q_VALUE, n=0, fl=fl) + np.testing.assert_equal(neo_val, lha_val) + + @pytest.mark.parametrize("fl", [21, 2]) + def test_xfxQ_all_members(self, neo_pdfset, lha_pdfset, fl): + if fl not in neo_pdfset.flavors: + pytest.skip(f"pid {fl} not in set") + for n in range(neo_pdfset.n_members): + neo_val = neo_pdfset.xfxQ(X_VALUE, Q_VALUE, n=n, fl=fl) + lha_val = lha_pdfset.xfxQ(X_VALUE, Q_VALUE, n=n, fl=fl) + np.testing.assert_equal(neo_val, lha_val) + + @pytest.mark.parametrize("x", [1e-5, 1e-3, 0.1, 0.5, 0.9]) + @pytest.mark.parametrize("Q", [1.7, 10.0, 100.0]) + def test_xfxQ_gluon_phase_space(self, neo_pdfset, lha_pdfset, x, Q): + neo_val = neo_pdfset.xfxQ(x, Q, n=0, fl=21) + lha_val = lha_pdfset.xfxQ(x, Q, n=0, fl=21) + np.testing.assert_equal(neo_val, lha_val) + + @pytest.mark.parametrize("x", [1e-5, 0.1, 0.9]) + @pytest.mark.parametrize("Q", [1.7, 100.0]) + @pytest.mark.parametrize("fl", [6, -6]) + def test_xfxQ_absent_flavour_zero(self, neo_pdfset, fl, x, Q): + """Absent flavours must return 0.0.""" + assert fl not in neo_pdfset.flavors + assert neo_pdfset.xfxQ(x, Q, n=0, fl=fl) == 0.0 + + def test_grid_values_shape_matches_lhapdf(self, neo_pdfset, lha_pdfset): + pids = np.array([p for p in PIDS if p in neo_pdfset.flavors]) + neo_result = neo_pdfset.grid_values(pids, XGRID, QGRID) + lha_result = lha_pdfset.grid_values(pids, XGRID, QGRID) + assert neo_result.shape == lha_result.shape + + @pytest.mark.parametrize("fl", [21, 1, 2]) + def test_grid_values_single_flavour(self, neo_pdfset, lha_pdfset, fl): + if fl not in neo_pdfset.flavors: + pytest.skip(f"pid {fl} not in set") + pids = np.array([fl]) + neo_result = neo_pdfset.grid_values(pids, XGRID, QGRID) + lha_result = self._reorder_lhapdf( + lha_pdfset.grid_values(pids, XGRID, QGRID), len(XGRID), len(QGRID) + ) + np.testing.assert_array_equal(neo_result, lha_result) + + def test_grid_values_all_flavours(self, neo_pdfset, lha_pdfset): + pids = np.array([p for p in PIDS if p in neo_pdfset.flavors]) + neo_result = neo_pdfset.grid_values(pids, XGRID, QGRID) + lha_result = self._reorder_lhapdf( + lha_pdfset.grid_values(pids, XGRID, QGRID), len(XGRID), len(QGRID) + ) + np.testing.assert_array_equal(neo_result, lha_result) + + def test_grid_values_member0_equals_xfxQ_scalar(self, neo_pdfset): + fl = 21 + x_idx, q_idx = 2, 1 + x = XGRID[x_idx] + Q = QGRID[q_idx] + grid = neo_pdfset.grid_values(np.array([fl]), XGRID, QGRID) + scalar = neo_pdfset.xfxQ(x, Q, n=0, fl=fl) + np.testing.assert_equal(grid[0, 0, x_idx, q_idx], scalar) + + def test_grid_values_shape(self, neo_pdfset): + """Verify the axis convention: result[member, flavour, x, Q].""" + pids = np.array([21, 2]) + result = neo_pdfset.grid_values(pids, XGRID, QGRID) + for m_idx in range(min(3, neo_pdfset.n_members)): + for f_idx, fl in enumerate(pids): + for x_idx, x in enumerate(XGRID): + for q_idx, Q in enumerate(QGRID): + expected = neo_pdfset.xfxQ(x, Q, n=m_idx, fl=fl) + np.testing.assert_equal( + result[m_idx, f_idx, x_idx, q_idx], + expected, + err_msg=f"mismatch at member={m_idx} fl={fl} x={x} Q={Q}", + ) + + def test_t0_central_value(self, neo_pdfset_t0, lha_pdfset_t0): + pids = np.array([p for p in PIDS if p in neo_pdfset_t0.flavors]) + neo_result = neo_pdfset_t0.grid_values(pids, XGRID, QGRID) + lha_result = self._reorder_lhapdf( + lha_pdfset_t0.grid_values(pids, XGRID, QGRID), len(XGRID), len(QGRID) + ) + np.testing.assert_array_equal(neo_result, lha_result) + + def test_t0_same_as_replica_member0(self, neo_pdfset, neo_pdfset_t0): + """t0 set must return the same values as member 0 of the replica set.""" + pids = np.array([p for p in PIDS if p in neo_pdfset.flavors]) + full = neo_pdfset.grid_values(pids, XGRID, QGRID) + t0 = neo_pdfset_t0.grid_values(pids, XGRID, QGRID) + np.testing.assert_array_equal(full[0:1], t0) + + def test_flavors_match_lhapdf(self, neo_pdfset, lha_pdfset): + assert sorted(neo_pdfset.flavors) == sorted(lha_pdfset.flavors) + + def test_t0_flavors_match_lhapdf(self, neo_pdfset_t0, lha_pdfset_t0): + assert sorted(neo_pdfset_t0.flavors) == sorted(lha_pdfset_t0.flavors) + + def test_n_members_matches_lhapdf(self, neo_pdfset, lha_pdfset): + assert neo_pdfset.n_members == lha_pdfset.n_members + + +class TestBackendFactory: + """Verify that ``make_pdfset`` returns the correct class depending on the + requested backend and the ``NNPDF_PDF_BACKEND`` environment variable. + """ + + def test_default_returns_lhapdfset(self, monkeypatch): + monkeypatch.delenv("NNPDF_PDF_BACKEND", raising=False) + from validphys.lhapdfset import LHAPDFSet + from validphys.pdf_backends import make_pdfset + + result = make_pdfset(PDF_NAME, "replicas") + assert isinstance(result, LHAPDFSet) + + def test_explicit_lhapdf_kwarg_returns_lhapdfset(self, monkeypatch): + monkeypatch.delenv("NNPDF_PDF_BACKEND", raising=False) + from validphys.lhapdfset import LHAPDFSet + from validphys.pdf_backends import make_pdfset + + result = make_pdfset(PDF_NAME, "replicas", backend="lhapdf") + assert isinstance(result, LHAPDFSet) + + def test_explicit_neopdf_kwarg_returns_neopdfset(self, monkeypatch): + monkeypatch.delenv("NNPDF_PDF_BACKEND", raising=False) + from validphys.neopdfset import NeoPDFSet + from validphys.pdf_backends import make_pdfset + + result = make_pdfset(PDF_NAME, "replicas", backend="neopdf") + assert isinstance(result, NeoPDFSet) + + def test_env_var_neopdf_returns_neopdfset(self, monkeypatch): + monkeypatch.setenv("NNPDF_PDF_BACKEND", "neopdf") + import validphys.pdf_backends as mod + + importlib.reload(mod) + from validphys.neopdfset import NeoPDFSet + + result = mod.make_pdfset(PDF_NAME, "replicas") + assert isinstance(result, NeoPDFSet) + + def test_env_var_lhapdf_returns_lhapdfset(self, monkeypatch): + monkeypatch.setenv("NNPDF_PDF_BACKEND", "lhapdf") + import validphys.pdf_backends as mod + + importlib.reload(mod) + from validphys.lhapdfset import LHAPDFSet + + result = mod.make_pdfset(PDF_NAME, "replicas") + assert isinstance(result, LHAPDFSet) + + def test_invalid_env_var_raises(self, monkeypatch): + monkeypatch.setenv("NNPDF_PDF_BACKEND", "pdfflow_is_not_valid_here") + from validphys.pdf_backends import make_pdfset + + with pytest.raises(ValueError, match="Unknown PDF backend"): + make_pdfset(PDF_NAME, "replicas") + + def test_kwarg_overrides_env_var(self, monkeypatch): + """The explicit ``backend`` kwarg must win over the env variable.""" + monkeypatch.setenv("NNPDF_PDF_BACKEND", "lhapdf") + from validphys.neopdfset import NeoPDFSet + from validphys.pdf_backends import make_pdfset + + result = make_pdfset(PDF_NAME, "replicas", backend="neopdf") + assert isinstance(result, NeoPDFSet) + + def test_factory_t0_mode_neopdf(self): + from validphys.neopdfset import NeoPDFSet + from validphys.pdf_backends import make_pdfset + + result = make_pdfset(PDF_NAME, "t0", backend="neopdf") + assert isinstance(result, NeoPDFSet) + assert result.is_t0 is True + assert result.n_members == 1 + + @requires_lhapdf + def test_factory_both_backends_same_n_members(self): + from validphys.pdf_backends import make_pdfset + + neo = make_pdfset(PDF_NAME, "replicas", backend="neopdf") + lha = make_pdfset(PDF_NAME, "replicas", backend="lhapdf") + assert neo.n_members == lha.n_members From 7ecfaba96759e7c5ad3fb4faed171661e0e009b2 Mon Sep 17 00:00:00 2001 From: Radonirinaunimi Date: Mon, 1 Jun 2026 21:42:41 +0200 Subject: [PATCH 02/10] Fix exception handling --- validphys2/src/validphys/tests/test_neopdfset.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/validphys2/src/validphys/tests/test_neopdfset.py b/validphys2/src/validphys/tests/test_neopdfset.py index 881984d3ed..6c3c53d8a6 100644 --- a/validphys2/src/validphys/tests/test_neopdfset.py +++ b/validphys2/src/validphys/tests/test_neopdfset.py @@ -330,9 +330,9 @@ def test_env_var_lhapdf_returns_lhapdfset(self, monkeypatch): def test_invalid_env_var_raises(self, monkeypatch): monkeypatch.setenv("NNPDF_PDF_BACKEND", "pdfflow_is_not_valid_here") - from validphys.pdf_backends import make_pdfset + from validphys.pdf_backends import InvalidPDFBackend, make_pdfset - with pytest.raises(ValueError, match="Unknown PDF backend"): + with pytest.raises(InvalidPDFBackend, match="Unknown PDF backend"): make_pdfset(PDF_NAME, "replicas") def test_kwarg_overrides_env_var(self, monkeypatch): From c5271ffcc7edd2cab7c1749883c43f5c9027b1d0 Mon Sep 17 00:00:00 2001 From: Radonirinaunimi Date: Mon, 1 Jun 2026 21:48:22 +0200 Subject: [PATCH 03/10] Add `neopdf` as an optional dependency for the time being --- .github/actions/install_conda_pip/action.yml | 2 +- pyproject.toml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/actions/install_conda_pip/action.yml b/.github/actions/install_conda_pip/action.yml index 5e758408b2..6150e0cbbd 100644 --- a/.github/actions/install_conda_pip/action.yml +++ b/.github/actions/install_conda_pip/action.yml @@ -27,7 +27,7 @@ inputs: nnpdf-extras: required: true description: "Which extras to install" - default: "[qed,tests,hyperopt]" + default: "[qed,tests,hyperopt,neopdf]" runs: diff --git a/pyproject.toml b/pyproject.toml index 42c6c3e07b..025efdcb05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,6 +98,8 @@ fiatlux = {version = "*", optional = true} # without lhapdf pdfflow = {version = "^1.2.1", optional = true} lhapdf-management = {version = "^0.6", optional = true} +# neopdf interpolation backend +neopdf-hep = {version = "*", optional = true} # torch torch = {version = "*", optional = true} # jax @@ -111,6 +113,7 @@ tests = ["pytest", "pytest-mpl", "hypothesis", "pytest-cov"] docs = ["sphinxcontrib-bibtex", "sphinx-rtd-theme", "sphinx", "tabulate"] qed = ["fiatlux"] nolha = ["pdfflow", "lhapdf-management"] +neopdf = ["neopdf-hep"] torch = ["torch"] jax = ["jax"] hyperopt = ["hyperopt", "setuptools"] From 04a773e81985e8b5701da51a65c89421d6a77510 Mon Sep 17 00:00:00 2001 From: Radonirinaunimi Date: Mon, 1 Jun 2026 21:51:03 +0200 Subject: [PATCH 04/10] Set `neopdf` version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 025efdcb05..95c17364fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,7 +99,7 @@ fiatlux = {version = "*", optional = true} pdfflow = {version = "^1.2.1", optional = true} lhapdf-management = {version = "^0.6", optional = true} # neopdf interpolation backend -neopdf-hep = {version = "*", optional = true} +neopdf-hep = {version = "^0.3", optional = true} # torch torch = {version = "*", optional = true} # jax From 6075a863370936c8076d4b7b80906f63152df62d Mon Sep 17 00:00:00 2001 From: Radonirinaunimi Date: Tue, 2 Jun 2026 09:45:27 +0200 Subject: [PATCH 05/10] Address most of review comments --- validphys2/src/validphys/core.py | 6 +- .../src/validphys/lhapdf_compatibility.py | 88 ++++++++-- validphys2/src/validphys/neopdfset.py | 107 ------------- validphys2/src/validphys/pdf_backends.py | 60 ------- .../src/validphys/tests/test_neopdfset.py | 151 ++++++------------ 5 files changed, 133 insertions(+), 279 deletions(-) delete mode 100644 validphys2/src/validphys/neopdfset.py delete mode 100644 validphys2/src/validphys/pdf_backends.py diff --git a/validphys2/src/validphys/core.py b/validphys2/src/validphys/core.py index 8b037e866e..ac680a3c98 100644 --- a/validphys2/src/validphys/core.py +++ b/validphys2/src/validphys/core.py @@ -23,7 +23,7 @@ # Maybe move the cuts logic to its own module? from validphys import filters, lhaindex from validphys.fkparser import load_fktable, parse_cfactor -from validphys.pdf_backends import make_pdfset +from validphys.lhapdfset import LHAPDFSet from validphys.tableloader import parse_exp_mat from validphys.utils import experiments_to_dataset_inputs, yaml_safe @@ -208,12 +208,12 @@ def _rescale_factor(self): @functools.lru_cache(maxsize=16) def load(self): - return make_pdfset(self.name, self.error_type) + return LHAPDFSet(self.name, self.error_type) @functools.lru_cache(maxsize=2) def load_t0(self): """Load the PDF as a t0 set""" - return make_pdfset(self.name, "t0") + return LHAPDFSet(self.name, "t0") def __str__(self): return self.label diff --git a/validphys2/src/validphys/lhapdf_compatibility.py b/validphys2/src/validphys/lhapdf_compatibility.py index 33c355a71e..9c1a0b95a8 100644 --- a/validphys2/src/validphys/lhapdf_compatibility.py +++ b/validphys2/src/validphys/lhapdf_compatibility.py @@ -1,13 +1,17 @@ """ - Module for LHAPDF compatibility backends +Module for LHAPDF compatibility backends - If LHAPDF is installed, the module will transparently hand over everything to LHAPDF - if LHAPDF is not available, it will try to use a combination of the packages - `lhapdf-management` and `pdfflow` - which cover all the features of LHAPDF used during the fit (and likely most of validphys) +If LHAPDF is installed, the module will transparently hand over everything to LHAPDF. +If LHAPDF is not available, it will try to use a combination of the packages + `lhapdf-management` and `pdfflow` +which cover all the features of LHAPDF used during the fit (and likely most of validphys). + +The NeoPDF interpolation library can be selected explicitly by setting the environment +variable ``NNPDF_PDF_BACKEND=neopdf``. """ from functools import cached_property +import os import numpy as np @@ -25,6 +29,13 @@ USING_LHAPDF = False +_BACKEND_ENV_VAR = "NNPDF_PDF_BACKEND" +_VALID_BACKENDS = ("lhapdf", "neopdf") + + +class InvalidPDFBackend(Exception): + pass + class _PDFFlowPDF: """Wrapper around the PDFFlow PDF so that it can be used as an LHAPDF @@ -96,13 +107,54 @@ def xfxQ2(self, a, b, c=None): return self.xfxQ(a, b, np.sqrt(c)) +class _NeoPDFPDF: + """Thin wrapper around a single NeoPDF member exposing the LHAPDF-compatible interface.""" + + def __init__(self, neo_member): + self._member = neo_member + self._pids = neo_member.pids() + + def flavors(self): + return self._pids + + def _xfxQ_all_pid(self, x, q): + scalar_input = np.ndim(x) == 0 and np.ndim(q) == 0 + x = np.atleast_1d(x) + q = np.atleast_1d(q) + vals = np.array( + [ + self._member.xfxQ2_allpids(self._pids, float(xi), float(qi) ** 2) + for xi, qi in zip(x, q) + ] + ) # (n_points, n_pids) + if scalar_input: + return dict(zip(self._pids, vals[0])) + return dict(zip(self._pids, vals.T)) + + def xfxQ(self, a, b, c=None): + if c is None: + return self._xfxQ_all_pid(a, b) + ret_dict = self.xfxQ(b, c) + zeros = np.zeros_like(b) + if isinstance(a, int): + return ret_dict.get(a, zeros) + return np.array([ret_dict.get(i, zeros) for i in a]).T + + def xfxQ2(self, a, b, c=None): + if c is None: + return self.xfxQ(a, np.sqrt(b)) + return self.xfxQ(a, b, np.sqrt(c)) + + def make_pdf(pdf_name, member=None): - """Load a PDF - if member is given, load the single member otherwise, load the entire set as a list + """Load a single member if specified, otherwise load the entire set as a list. - if LHAPDF is provided, it returns LHAPDF PDF instances - otherwise it returns and object which is _compatible_ with LHAPDF - for lhapdf functions for the selected backend + If LHAPDF is provided, it returns LHAPDF PDF instances otherwise it returns and + object which is _compatible_ with LHAPDF for lhapdf functions for the selected + backend. + + The backend can be overridden by setting the ``NNPDF_PDF_BACKEND`` environment + variable to ``neopdf`` to use the NeoPDF interpolation library. Parameters: ----------- @@ -115,6 +167,22 @@ def make_pdf(pdf_name, member=None): -------- list(pdf_sets) """ + backend = os.environ.get(_BACKEND_ENV_VAR, "lhapdf").lower() + + if backend not in _VALID_BACKENDS: + raise InvalidPDFBackend( + f"Unknown PDF backend {backend!r} in {_BACKEND_ENV_VAR}. " + f"Valid options are: {_VALID_BACKENDS}" + ) + + if backend == "neopdf": + from neopdf.pdf import PDF as _NeoPDF + + members = _NeoPDF.mkPDFs(pdf_name) + if member is None: + return [_NeoPDFPDF(m) for m in members] + return [_NeoPDFPDF(members[member])] + if USING_LHAPDF: if member is None: return lhapdf.mkPDFs(pdf_name) diff --git a/validphys2/src/validphys/neopdfset.py b/validphys2/src/validphys/neopdfset.py deleted file mode 100644 index bf620d9a06..0000000000 --- a/validphys2/src/validphys/neopdfset.py +++ /dev/null @@ -1,107 +0,0 @@ -""" -Module containing the NeoPDF-backend PDF set compatible with validphys. - -``NeoPDFSet`` has the same public interface as ``LHAPDFSet`` so it can -be used as a drop-in replacement anywhere an ``LHAPDFSet`` is expected. - -Examples --------- ->>> from validphys.neopdfset import NeoPDFSet ->>> pdf = NeoPDFSet("NNPDF40_nnlo_as_01180", "replicas") ->>> len(pdf.members) -101 ->>> pdf.grid_values([-2, -1, 1, 2, 21], [0.1, 0.5], [10.0, 100.0]).shape -(101, 5, 2, 2) -""" - -import logging - -from neopdf.pdf import PDF as _NeoPDF -import numpy as np - -log = logging.getLogger(__name__) - - -class NeoPDFSet: - """Wrapper around the NeoPDF interpolation library. - - Provides the same interface as ``LHAPDFSet`` so it can be used - interchangeably within validphys and n3fit. - """ - - def __init__(self, name: str, error_type: str): - self._name = name - self._error_type = error_type - self._flavors = None - if self.is_t0: - self._members = [_NeoPDF(name)] - else: - self._members = _NeoPDF.mkPDFs(name) - - @property - def is_t0(self) -> bool: - """Check whether it is a t0 set.""" - return self._error_type == "t0" - - @property - def n_members(self) -> int: - """Return the total members of the PDF set.""" - return len(self.members) - - @property - def members(self): - """Return a given member of the PDF set. - - The special error type t0 returns only member 0. - """ - if self.is_t0: - return self._members[0:1] - return self._members - - @property - def central_member(self): - """Return a reference to member 0.""" - return self._members[0] - - def xfxQ(self, x: float, Q: float, n: int, fl: int) -> float: - """Return the PDF value for a single point for a single member. - - If the flavour is absent from the set, simply return 0.0. - """ - if fl not in self.flavors: - return 0.0 - return self.members[n].xfxQ2(fl, x, Q**2) - - @property - def flavors(self): - """Return the list of particle IDs supported by this PDF set.""" - if self._flavors is None: - self._flavors = self.members[0].pids() - return self._flavors - - def grid_values(self, flavors: np.ndarray, xgrid: np.ndarray, qgrid: np.ndarray) -> np.ndarray: - """Return PDF values on a grid for all the members. - - Parameters - ---------- - flavors: - Array of PDG particle IDs. - xgrid: - Array of x values. - qgrid: - Array of Q values (not Q²). - - Returns - ------- - ndarray of shape ``(members, flavors, len(xgrid), len(qgrid))`` - """ - q2grid = np.asarray(qgrid) ** 2 - xgrid = np.asarray(xgrid) - pids = list(flavors) - nx, nq, nfl = len(xgrid), len(qgrid), len(pids) - - results = [] - for member in self.members: - vals = np.array(member.xfxQ2s(pids, xgrid, q2grid)) - results.append(vals.reshape(nfl, nx, nq)) - return np.array(results) diff --git a/validphys2/src/validphys/pdf_backends.py b/validphys2/src/validphys/pdf_backends.py deleted file mode 100644 index fb88369dee..0000000000 --- a/validphys2/src/validphys/pdf_backends.py +++ /dev/null @@ -1,60 +0,0 @@ -""" -PDF interpolation backend selector. - -The active backend is controlled by the ``NNPDF_PDF_BACKEND`` environment -variable (default: ``"lhapdf"``). Set it to ``"neopdf"`` to use the -NeoPDF interpolation library instead. - -Both backends expose the same public interface (``LHAPDFSet`` / ``NeoPDFSet``) -so they are interchangeable throughout validphys and n3fit. -""" - -import os - -_BACKEND_ENV_VAR = "NNPDF_PDF_BACKEND" -_VALID_BACKENDS = ("lhapdf", "neopdf") - - -class InvalidPDFBackend(Exception): - pass - - -def _active_backend() -> str: - backend = os.environ.get(_BACKEND_ENV_VAR, "lhapdf").lower() - - if backend not in _VALID_BACKENDS: - raise InvalidPDFBackend( - f"Unknown PDF backend {backend!r} in {_BACKEND_ENV_VAR}. " - f"Valid options are: {_VALID_BACKENDS}" - ) - return backend - - -def make_pdfset(name: str, error_type: str, *, backend: str | None = None): - """Return a PDF set object for *name* using the requested backend. - - Parameters - ---------- - name: - LHAPDF set name (both backends resolve sets by this name). - error_type: - One of ``replicas``, ``symmhessian``, ``hessian``, ``t0`` - backend: - ``lhapdf`` or ``neopdf``. When *None* the value of the - ``NNPDF_PDF_BACKEND`` environment variable is used (default - ``lhapdf``). - - Returns - ------- - ``LHAPDFSet`` or ``NeoPDFSet`` depending on *backend*. - """ - active = backend if backend is not None else _active_backend() - - if active == "neopdf": - from validphys.neopdfset import NeoPDFSet - - return NeoPDFSet(name, error_type) - - from validphys.lhapdfset import LHAPDFSet - - return LHAPDFSet(name, error_type) diff --git a/validphys2/src/validphys/tests/test_neopdfset.py b/validphys2/src/validphys/tests/test_neopdfset.py index 6c3c53d8a6..b0fd193517 100644 --- a/validphys2/src/validphys/tests/test_neopdfset.py +++ b/validphys2/src/validphys/tests/test_neopdfset.py @@ -2,7 +2,7 @@ Tests for the NeoPDF interpolation backend. """ -import importlib +import os import numpy as np import pytest @@ -34,16 +34,24 @@ @pytest.fixture(scope="module") def neo_pdfset(): - from validphys.neopdfset import NeoPDFSet + """LHAPDFSet loaded with the NeoPDF backend.""" + from validphys.lhapdfset import LHAPDFSet - return NeoPDFSet(PDF_NAME, "replicas") + os.environ["NNPDF_PDF_BACKEND"] = "neopdf" + pdfset = LHAPDFSet(PDF_NAME, "replicas") + del os.environ["NNPDF_PDF_BACKEND"] + return pdfset @pytest.fixture(scope="module") def neo_pdfset_t0(): - from validphys.neopdfset import NeoPDFSet + """LHAPDFSet in t0 mode loaded with the NeoPDF backend.""" + from validphys.lhapdfset import LHAPDFSet - return NeoPDFSet(PDF_NAME, "t0") + os.environ["NNPDF_PDF_BACKEND"] = "neopdf" + pdfset = LHAPDFSet(PDF_NAME, "t0") + del os.environ["NNPDF_PDF_BACKEND"] + return pdfset @pytest.fixture(scope="module") @@ -167,12 +175,6 @@ class TestNumericalAgreement: call that both backends support. """ - @staticmethod - def _reorder_lhapdf(arr, nx, nq): - """Convert LHAPDFSet.grid_values output to canonical (…, nx, nq) order.""" - nm, nfl = arr.shape[:2] - return arr.reshape(nm, nfl, nq, nx).swapaxes(-1, -2) - @pytest.mark.parametrize("fl", [21, 1, -1, 2, -2, 3]) def test_xfxQ_member0(self, neo_pdfset, lha_pdfset, fl): if fl not in neo_pdfset.flavors: @@ -217,49 +219,29 @@ def test_grid_values_single_flavour(self, neo_pdfset, lha_pdfset, fl): pytest.skip(f"pid {fl} not in set") pids = np.array([fl]) neo_result = neo_pdfset.grid_values(pids, XGRID, QGRID) - lha_result = self._reorder_lhapdf( - lha_pdfset.grid_values(pids, XGRID, QGRID), len(XGRID), len(QGRID) - ) + lha_result = lha_pdfset.grid_values(pids, XGRID, QGRID) np.testing.assert_array_equal(neo_result, lha_result) def test_grid_values_all_flavours(self, neo_pdfset, lha_pdfset): pids = np.array([p for p in PIDS if p in neo_pdfset.flavors]) neo_result = neo_pdfset.grid_values(pids, XGRID, QGRID) - lha_result = self._reorder_lhapdf( - lha_pdfset.grid_values(pids, XGRID, QGRID), len(XGRID), len(QGRID) - ) + lha_result = lha_pdfset.grid_values(pids, XGRID, QGRID) np.testing.assert_array_equal(neo_result, lha_result) def test_grid_values_member0_equals_xfxQ_scalar(self, neo_pdfset): + """With nq=1 the grid cell directly maps to the scalar xfxQ value.""" fl = 21 - x_idx, q_idx = 2, 1 + x_idx = 2 x = XGRID[x_idx] - Q = QGRID[q_idx] - grid = neo_pdfset.grid_values(np.array([fl]), XGRID, QGRID) - scalar = neo_pdfset.xfxQ(x, Q, n=0, fl=fl) - np.testing.assert_equal(grid[0, 0, x_idx, q_idx], scalar) - - def test_grid_values_shape(self, neo_pdfset): - """Verify the axis convention: result[member, flavour, x, Q].""" - pids = np.array([21, 2]) - result = neo_pdfset.grid_values(pids, XGRID, QGRID) - for m_idx in range(min(3, neo_pdfset.n_members)): - for f_idx, fl in enumerate(pids): - for x_idx, x in enumerate(XGRID): - for q_idx, Q in enumerate(QGRID): - expected = neo_pdfset.xfxQ(x, Q, n=m_idx, fl=fl) - np.testing.assert_equal( - result[m_idx, f_idx, x_idx, q_idx], - expected, - err_msg=f"mismatch at member={m_idx} fl={fl} x={x} Q={Q}", - ) + Q_single = np.array([Q_VALUE]) + grid = neo_pdfset.grid_values(np.array([fl]), XGRID, Q_single) + scalar = neo_pdfset.xfxQ(x, Q_VALUE, n=0, fl=fl) + np.testing.assert_equal(grid[0, 0, x_idx, 0], scalar) def test_t0_central_value(self, neo_pdfset_t0, lha_pdfset_t0): pids = np.array([p for p in PIDS if p in neo_pdfset_t0.flavors]) neo_result = neo_pdfset_t0.grid_values(pids, XGRID, QGRID) - lha_result = self._reorder_lhapdf( - lha_pdfset_t0.grid_values(pids, XGRID, QGRID), len(XGRID), len(QGRID) - ) + lha_result = lha_pdfset_t0.grid_values(pids, XGRID, QGRID) np.testing.assert_array_equal(neo_result, lha_result) def test_t0_same_as_replica_member0(self, neo_pdfset, neo_pdfset_t0): @@ -280,83 +262,54 @@ def test_n_members_matches_lhapdf(self, neo_pdfset, lha_pdfset): class TestBackendFactory: - """Verify that ``make_pdfset`` returns the correct class depending on the - requested backend and the ``NNPDF_PDF_BACKEND`` environment variable. + """Verify that ``make_pdf`` in ``lhapdf_compatibility`` selects the correct + backend based on the ``NNPDF_PDF_BACKEND`` environment variable. """ - def test_default_returns_lhapdfset(self, monkeypatch): + def test_default_returns_lhapdf_members(self, monkeypatch): monkeypatch.delenv("NNPDF_PDF_BACKEND", raising=False) - from validphys.lhapdfset import LHAPDFSet - from validphys.pdf_backends import make_pdfset - - result = make_pdfset(PDF_NAME, "replicas") - assert isinstance(result, LHAPDFSet) + from validphys.lhapdf_compatibility import _NeoPDFPDF, make_pdf - def test_explicit_lhapdf_kwarg_returns_lhapdfset(self, monkeypatch): - monkeypatch.delenv("NNPDF_PDF_BACKEND", raising=False) - from validphys.lhapdfset import LHAPDFSet - from validphys.pdf_backends import make_pdfset + members = make_pdf(PDF_NAME) + assert not isinstance(members[0], _NeoPDFPDF) - result = make_pdfset(PDF_NAME, "replicas", backend="lhapdf") - assert isinstance(result, LHAPDFSet) - - def test_explicit_neopdf_kwarg_returns_neopdfset(self, monkeypatch): - monkeypatch.delenv("NNPDF_PDF_BACKEND", raising=False) - from validphys.neopdfset import NeoPDFSet - from validphys.pdf_backends import make_pdfset - - result = make_pdfset(PDF_NAME, "replicas", backend="neopdf") - assert isinstance(result, NeoPDFSet) - - def test_env_var_neopdf_returns_neopdfset(self, monkeypatch): + def test_env_var_neopdf_returns_neopdf_members(self, monkeypatch): monkeypatch.setenv("NNPDF_PDF_BACKEND", "neopdf") - import validphys.pdf_backends as mod - - importlib.reload(mod) - from validphys.neopdfset import NeoPDFSet + from validphys.lhapdf_compatibility import _NeoPDFPDF, make_pdf - result = mod.make_pdfset(PDF_NAME, "replicas") - assert isinstance(result, NeoPDFSet) + members = make_pdf(PDF_NAME) + assert all(isinstance(m, _NeoPDFPDF) for m in members) - def test_env_var_lhapdf_returns_lhapdfset(self, monkeypatch): + def test_env_var_lhapdf_returns_non_neopdf_members(self, monkeypatch): monkeypatch.setenv("NNPDF_PDF_BACKEND", "lhapdf") - import validphys.pdf_backends as mod + from validphys.lhapdf_compatibility import _NeoPDFPDF, make_pdf - importlib.reload(mod) - from validphys.lhapdfset import LHAPDFSet - - result = mod.make_pdfset(PDF_NAME, "replicas") - assert isinstance(result, LHAPDFSet) + members = make_pdf(PDF_NAME) + assert not isinstance(members[0], _NeoPDFPDF) def test_invalid_env_var_raises(self, monkeypatch): monkeypatch.setenv("NNPDF_PDF_BACKEND", "pdfflow_is_not_valid_here") - from validphys.pdf_backends import InvalidPDFBackend, make_pdfset + from validphys.lhapdf_compatibility import InvalidPDFBackend, make_pdf with pytest.raises(InvalidPDFBackend, match="Unknown PDF backend"): - make_pdfset(PDF_NAME, "replicas") + make_pdf(PDF_NAME) - def test_kwarg_overrides_env_var(self, monkeypatch): - """The explicit ``backend`` kwarg must win over the env variable.""" - monkeypatch.setenv("NNPDF_PDF_BACKEND", "lhapdf") - from validphys.neopdfset import NeoPDFSet - from validphys.pdf_backends import make_pdfset + def test_neopdf_single_member_returns_neopdf_member(self, monkeypatch): + monkeypatch.setenv("NNPDF_PDF_BACKEND", "neopdf") + from validphys.lhapdf_compatibility import _NeoPDFPDF, make_pdf - result = make_pdfset(PDF_NAME, "replicas", backend="neopdf") - assert isinstance(result, NeoPDFSet) + members = make_pdf(PDF_NAME, member=0) + assert len(members) == 1 + assert isinstance(members[0], _NeoPDFPDF) - def test_factory_t0_mode_neopdf(self): - from validphys.neopdfset import NeoPDFSet - from validphys.pdf_backends import make_pdfset + @requires_lhapdf + def test_both_backends_return_same_member_count(self, monkeypatch): + from validphys.lhapdf_compatibility import make_pdf - result = make_pdfset(PDF_NAME, "t0", backend="neopdf") - assert isinstance(result, NeoPDFSet) - assert result.is_t0 is True - assert result.n_members == 1 + monkeypatch.setenv("NNPDF_PDF_BACKEND", "neopdf") + neo_members = make_pdf(PDF_NAME) - @requires_lhapdf - def test_factory_both_backends_same_n_members(self): - from validphys.pdf_backends import make_pdfset + monkeypatch.setenv("NNPDF_PDF_BACKEND", "lhapdf") + lha_members = make_pdf(PDF_NAME) - neo = make_pdfset(PDF_NAME, "replicas", backend="neopdf") - lha = make_pdfset(PDF_NAME, "replicas", backend="lhapdf") - assert neo.n_members == lha.n_members + assert len(neo_members) == len(lha_members) From 27b1f83d14a134ff9834f6678ed94bd256d7356e Mon Sep 17 00:00:00 2001 From: Radonirinaunimi Date: Tue, 2 Jun 2026 13:38:04 +0200 Subject: [PATCH 06/10] Point `neopdf` at the LHAPDF data directory --- .github/actions/install_conda_pip/action.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/actions/install_conda_pip/action.yml b/.github/actions/install_conda_pip/action.yml index 6150e0cbbd..c835be6afd 100644 --- a/.github/actions/install_conda_pip/action.yml +++ b/.github/actions/install_conda_pip/action.yml @@ -44,3 +44,7 @@ runs: shell: bash -l {0} run: | pip install -e .${{ inputs.nnpdf-extras }} + - name: Point NeoPDF at the LHAPDF data directory + shell: bash -l {0} + run: | + echo "NEOPDF_DATA_PATH=$(python -c 'import lhapdf; print(lhapdf.paths()[0])')" >> $GITHUB_ENV From c20d23669a041d8544ff84f6f2e5acf107ebef20 Mon Sep 17 00:00:00 2001 From: Radonirinaunimi Date: Tue, 2 Jun 2026 13:47:07 +0200 Subject: [PATCH 07/10] Set `neopdf` as default instead of `nolha` --- .github/actions/install_conda_pip/action.yml | 2 +- pyproject.toml | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/actions/install_conda_pip/action.yml b/.github/actions/install_conda_pip/action.yml index c835be6afd..9596d1a00b 100644 --- a/.github/actions/install_conda_pip/action.yml +++ b/.github/actions/install_conda_pip/action.yml @@ -27,7 +27,7 @@ inputs: nnpdf-extras: required: true description: "Which extras to install" - default: "[qed,tests,hyperopt,neopdf]" + default: "[qed,tests,hyperopt,nolha]" runs: diff --git a/pyproject.toml b/pyproject.toml index 95c17364fd..38164952bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,8 +112,9 @@ pymongo = {version = "<4", optional = true} tests = ["pytest", "pytest-mpl", "hypothesis", "pytest-cov"] docs = ["sphinxcontrib-bibtex", "sphinx-rtd-theme", "sphinx", "tabulate"] qed = ["fiatlux"] -nolha = ["pdfflow", "lhapdf-management"] -neopdf = ["neopdf-hep"] +pdfflow = ["pdfflow", "lhapdf-management"] +neopdf = ["neopdf-hep", "lhapdf-management"] +nolha = ["neopdf-hep", "lhapdf-management"] torch = ["torch"] jax = ["jax"] hyperopt = ["hyperopt", "setuptools"] From 3302b9297817a840b957713936baa98d0fea130f Mon Sep 17 00:00:00 2001 From: Radonirinaunimi Date: Tue, 2 Jun 2026 13:49:12 +0200 Subject: [PATCH 08/10] Define the PDF interpolation backend in the `nnprofile` --- .../src/validphys/lhapdf_compatibility.py | 20 ++++++++++++++++++- .../src/validphys/nnprofile_default.yaml | 3 +++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/validphys2/src/validphys/lhapdf_compatibility.py b/validphys2/src/validphys/lhapdf_compatibility.py index 9c1a0b95a8..35078848c2 100644 --- a/validphys2/src/validphys/lhapdf_compatibility.py +++ b/validphys2/src/validphys/lhapdf_compatibility.py @@ -37,6 +37,24 @@ class InvalidPDFBackend(Exception): pass +def _active_backend(): + """Return the active PDF backend. + + Resolution order (highest priority first): + 1. ``NNPDF_PDF_BACKEND`` environment variable + 2. ``pdf_backend`` key in the NNPDF profile (``nnprofile.yaml``) + """ + backend = os.environ.get(_BACKEND_ENV_VAR) + if backend is None: + try: + from nnpdf_data.utils import get_nnpdf_profile + + backend = get_nnpdf_profile().get("pdf_backend", "lhapdf") + except Exception: + backend = "lhapdf" + return backend.lower() + + class _PDFFlowPDF: """Wrapper around the PDFFlow PDF so that it can be used as an LHAPDF set by validphys @@ -167,7 +185,7 @@ def make_pdf(pdf_name, member=None): -------- list(pdf_sets) """ - backend = os.environ.get(_BACKEND_ENV_VAR, "lhapdf").lower() + backend = _active_backend() if backend not in _VALID_BACKENDS: raise InvalidPDFBackend( diff --git a/validphys2/src/validphys/nnprofile_default.yaml b/validphys2/src/validphys/nnprofile_default.yaml index f09ffe9559..56a3a7e1e9 100644 --- a/validphys2/src/validphys/nnprofile_default.yaml +++ b/validphys2/src/validphys/nnprofile_default.yaml @@ -67,6 +67,9 @@ photon_qed_urls: photon_qed_index: 'photondata.json' +# PDF interpolation backend: "lhapdf" (default) or "neopdf" +pdf_backend: lhapdf + lhapdf_urls: - 'http://lhapdfsets.web.cern.ch/lhapdfsets/current/' nnpdf_pdfs_urls: From 2f91a70be44c2ca5b8bb621b0ce3e670776797e1 Mon Sep 17 00:00:00 2001 From: Radonirinaunimi Date: Tue, 2 Jun 2026 16:31:13 +0200 Subject: [PATCH 09/10] Re-organize the interface --- .github/actions/install_conda_pip/action.yml | 2 +- .github/workflows/all_tests_nnpdf.yml | 6 ++-- .../src/validphys/lhapdf_compatibility.py | 31 ++++++++++--------- .../src/validphys/nnprofile_default.yaml | 2 +- .../src/validphys/tests/test_neopdfset.py | 2 +- 5 files changed, 23 insertions(+), 20 deletions(-) diff --git a/.github/actions/install_conda_pip/action.yml b/.github/actions/install_conda_pip/action.yml index 9596d1a00b..c835be6afd 100644 --- a/.github/actions/install_conda_pip/action.yml +++ b/.github/actions/install_conda_pip/action.yml @@ -27,7 +27,7 @@ inputs: nnpdf-extras: required: true description: "Which extras to install" - default: "[qed,tests,hyperopt,nolha]" + default: "[qed,tests,hyperopt,neopdf]" runs: diff --git a/.github/workflows/all_tests_nnpdf.yml b/.github/workflows/all_tests_nnpdf.yml index d5c29293c9..2e1e929c62 100644 --- a/.github/workflows/all_tests_nnpdf.yml +++ b/.github/workflows/all_tests_nnpdf.yml @@ -119,6 +119,7 @@ jobs: runs-on: ubuntu-latest env: KERAS_BACKEND: torch + NNPDF_PDF_BACKEND: neopdf steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v5 @@ -134,7 +135,7 @@ jobs: # at import on CPU-only Linux + CPython 3.13. Pinning torch<2.12 also # pins triton to 3.6.x. Remove this pin once the upstream issue is # resolved (see https://github.com/nascheme/triton/issues/43). - pip install ".[nolha,torch]" "torch<2.12" + pip install ".[neopdf,torch]" "torch<2.12" # Since there is no LHAPDF in the system, initialize the folder and download pdfsets.index lhapdf-management update --init - name: Test we can run one runcard @@ -157,6 +158,7 @@ jobs: runs-on: ubuntu-latest env: KERAS_BACKEND: jax + NNPDF_PDF_BACKEND: neopdf steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v5 @@ -165,7 +167,7 @@ jobs: - name: Install nnpdf without LHAPDF shell: bash -l {0} run: | - pip install .[nolha,jax] + pip install .[neopdf,jax] # Since there is no LHAPDF in the system, initialize the folder and download pdfsets.index lhapdf-management update --init - name: Test we can run one runcard diff --git a/validphys2/src/validphys/lhapdf_compatibility.py b/validphys2/src/validphys/lhapdf_compatibility.py index 35078848c2..93ec2a1393 100644 --- a/validphys2/src/validphys/lhapdf_compatibility.py +++ b/validphys2/src/validphys/lhapdf_compatibility.py @@ -6,8 +6,9 @@ `lhapdf-management` and `pdfflow` which cover all the features of LHAPDF used during the fit (and likely most of validphys). -The NeoPDF interpolation library can be selected explicitly by setting the environment -variable ``NNPDF_PDF_BACKEND=neopdf``. +The NeoPDF interpolation library can be selected by setting ``pdf_backend: neopdf`` +in the NNPDF profile (``nnprofile.yaml``), or via the ``NNPDF_PDF_BACKEND`` environment +variable which takes precedence over the profile. """ from functools import cached_property @@ -30,7 +31,7 @@ USING_LHAPDF = False _BACKEND_ENV_VAR = "NNPDF_PDF_BACKEND" -_VALID_BACKENDS = ("lhapdf", "neopdf") +_VALID_BACKENDS = ("lhapdf", "pdfflow", "neopdf") class InvalidPDFBackend(Exception): @@ -61,10 +62,12 @@ class _PDFFlowPDF: Takes as input a pdf_meta object (which is a PDFset from lhapdf_management and which knows where the PDF needs to be loaded from) and a single member - Loading the PDF is done in a lazy manner since most of the time only a few members are needed. + Loading the PDF is done in a lazy manner since most of the time only a few + members are needed. - Since PDFFlow is only utilized to load the PDF for interpolation, the import is delayed until - the first call to `mkPDF`. This allows the usage of most of validphys without tensorflow. + Since PDFFlow is only utilized to load the PDF for interpolation, the import + is delayed until the first call to `mkPDF`. This allows the usage of most of + validphys without tensorflow. """ def __init__(self, pdf_meta, member): @@ -188,10 +191,12 @@ def make_pdf(pdf_name, member=None): backend = _active_backend() if backend not in _VALID_BACKENDS: - raise InvalidPDFBackend( - f"Unknown PDF backend {backend!r} in {_BACKEND_ENV_VAR}. " - f"Valid options are: {_VALID_BACKENDS}" - ) + raise InvalidPDFBackend(f"Unknown backend {backend!r}. Options are: {_VALID_BACKENDS}") + + if backend == "lhapdf": + if member is None: + return lhapdf.mkPDFs(pdf_name) + return [lhapdf.mkPDF(pdf_name, member)] if backend == "neopdf": from neopdf.pdf import PDF as _NeoPDF @@ -201,11 +206,7 @@ def make_pdf(pdf_name, member=None): return [_NeoPDFPDF(m) for m in members] return [_NeoPDFPDF(members[member])] - if USING_LHAPDF: - if member is None: - return lhapdf.mkPDFs(pdf_name) - return [lhapdf.mkPDF(pdf_name, member)] - + # backend == "pdfflow" pdf_meta = lhapdf.load_pdf_meta(pdf_name) if member is None: return [_PDFFlowPDF(pdf_meta, m) for m in range(len(pdf_meta))] diff --git a/validphys2/src/validphys/nnprofile_default.yaml b/validphys2/src/validphys/nnprofile_default.yaml index 56a3a7e1e9..a83fdda131 100644 --- a/validphys2/src/validphys/nnprofile_default.yaml +++ b/validphys2/src/validphys/nnprofile_default.yaml @@ -67,7 +67,7 @@ photon_qed_urls: photon_qed_index: 'photondata.json' -# PDF interpolation backend: "lhapdf" (default) or "neopdf" +# PDF interpolation backend: "lhapdf" (default), "pdfflow", or "neopdf" pdf_backend: lhapdf lhapdf_urls: diff --git a/validphys2/src/validphys/tests/test_neopdfset.py b/validphys2/src/validphys/tests/test_neopdfset.py index b0fd193517..7034676512 100644 --- a/validphys2/src/validphys/tests/test_neopdfset.py +++ b/validphys2/src/validphys/tests/test_neopdfset.py @@ -291,7 +291,7 @@ def test_invalid_env_var_raises(self, monkeypatch): monkeypatch.setenv("NNPDF_PDF_BACKEND", "pdfflow_is_not_valid_here") from validphys.lhapdf_compatibility import InvalidPDFBackend, make_pdf - with pytest.raises(InvalidPDFBackend, match="Unknown PDF backend"): + with pytest.raises(InvalidPDFBackend, match="Unknown backend"): make_pdf(PDF_NAME) def test_neopdf_single_member_returns_neopdf_member(self, monkeypatch): From 3886c88488f3631fa1cc25c3b3de69ce970457d1 Mon Sep 17 00:00:00 2001 From: Radonirinaunimi Date: Tue, 2 Jun 2026 17:26:02 +0200 Subject: [PATCH 10/10] Add a separate action to set the `NEOPDF_DATA_PATH` --- .github/actions/install_conda_pip/action.yml | 5 +---- .github/actions/set_neopdf_datapath/action.yml | 12 ++++++++++++ .github/workflows/all_tests_nnpdf.yml | 2 ++ 3 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 .github/actions/set_neopdf_datapath/action.yml diff --git a/.github/actions/install_conda_pip/action.yml b/.github/actions/install_conda_pip/action.yml index c835be6afd..a43477bf76 100644 --- a/.github/actions/install_conda_pip/action.yml +++ b/.github/actions/install_conda_pip/action.yml @@ -44,7 +44,4 @@ runs: shell: bash -l {0} run: | pip install -e .${{ inputs.nnpdf-extras }} - - name: Point NeoPDF at the LHAPDF data directory - shell: bash -l {0} - run: | - echo "NEOPDF_DATA_PATH=$(python -c 'import lhapdf; print(lhapdf.paths()[0])')" >> $GITHUB_ENV + - uses: ./.github/actions/set_neopdf_datapath diff --git a/.github/actions/set_neopdf_datapath/action.yml b/.github/actions/set_neopdf_datapath/action.yml new file mode 100644 index 0000000000..3bda94a787 --- /dev/null +++ b/.github/actions/set_neopdf_datapath/action.yml @@ -0,0 +1,12 @@ +name: Set NEOPDF_DATA_PATH + +# Point NeoPDF at the directory where lhapdf-management stores PDF sets, +# so it reads local files instead of downloading from the LHAPDF servers. + +runs: + using: "composite" + steps: + - name: Point NeoPDF at the LHAPDF data directory + shell: bash -l {0} + run: | + echo "NEOPDF_DATA_PATH=$(python -c 'from lhapdf_management.configuration import environment; print(environment.datapath)')" >> $GITHUB_ENV diff --git a/.github/workflows/all_tests_nnpdf.yml b/.github/workflows/all_tests_nnpdf.yml index 2e1e929c62..1528b38a92 100644 --- a/.github/workflows/all_tests_nnpdf.yml +++ b/.github/workflows/all_tests_nnpdf.yml @@ -138,6 +138,7 @@ jobs: pip install ".[neopdf,torch]" "torch<2.12" # Since there is no LHAPDF in the system, initialize the folder and download pdfsets.index lhapdf-management update --init + - uses: ./.github/actions/set_neopdf_datapath - name: Test we can run one runcard shell: bash -l {0} run: | @@ -170,6 +171,7 @@ jobs: pip install .[neopdf,jax] # Since there is no LHAPDF in the system, initialize the folder and download pdfsets.index lhapdf-management update --init + - uses: ./.github/actions/set_neopdf_datapath - name: Test we can run one runcard shell: bash -l {0} run: |