diff --git a/CHANGELOG.md b/CHANGELOG.md index ba03ab8..7b2b713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added further `LHAPDF` compatibility layers ([#93](https://github.com/QCDLab/neopdf/pull/93)) + ## [0.3.3] - 12/05/2026 ### Added diff --git a/docs/design-and-features.md b/docs/design-and-features.md index 87917e8..817fe1e 100644 --- a/docs/design-and-features.md +++ b/docs/design-and-features.md @@ -199,6 +199,21 @@ into diverse computational workflows: Key functions like `xfxQ2()`, `alphasQ2()`, and `mkPDF()` maintain the same signatures as LHAPDF, ensuring compatibility with existing analysis codes. +The following symbols have an exact match with their LHAPDF counterparts. + +*Module-level:* +`setVerbosity()`, `verbosity()`, `getPDFSet()`, `mkPDF()`, `mkPDFs()`, +`availablePDFSets()`, `paths()`, `setPaths()`, `pathsAppend()`, `pathsPrepend()`. + +*`PDFSet` class:* +`name`, `size`, `description`, `errorType`, `lhapdfID`, `mkPDF()`, `mkPDFs()`, `info()`. + +*`PDF` class:* +`xfxQ()`, `xfxQ2()`, `alphasQ()`, `alphasQ2()`, `flavors()`, `hasFlavor()`, +`inRangeX()`, `inRangeQ()`, `inRangeQ2()`, `inRangeXQ()`, `inRangeXQ2()`, +`memberID`, `lhapdfID`, `orderQCD`, `xMin`, `xMax`, `q2Min`, `q2Max`, +`quarkMass()`, `quarkThreshold()`, `description`, `set`. + This compatibility is crucial for the physics community, as it allows for immediate adoption without requiring extensive code rewrites or validation efforts. diff --git a/neopdf/src/interpolator.rs b/neopdf/src/interpolator.rs index 241f7bf..46b8e9f 100644 --- a/neopdf/src/interpolator.rs +++ b/neopdf/src/interpolator.rs @@ -97,18 +97,18 @@ impl InterpolationConfig { match dims { (false, false, false, false, false) => Self::TwoD, - (true, false, false, false, false) => Self::ThreeDNucleons, - (false, true, false, false, false) => Self::ThreeDAlphas, - (false, false, true, false, false) => Self::ThreeDXi, - (false, false, false, true, false) => Self::ThreeDDelta, - (false, false, false, false, true) => Self::ThreeDKt, - (true, true, false, false, false) => Self::FourDNucleonsAlphas, - (true, false, false, false, true) => Self::FourDNucleonsKt, - (false, true, false, false, true) => Self::FourDAlphasKt, - (false, false, true, true, false) => Self::FourDXiDelta, - (false, false, true, true, true) => Self::FiveD, - (true, false, true, true, true) => Self::SixD, - (true, true, true, true, true) => Self::SevenD, + (true, false, false, false, false) => Self::ThreeDNucleons, + (false, true, false, false, false) => Self::ThreeDAlphas, + (false, false, true, false, false) => Self::ThreeDXi, + (false, false, false, true, false) => Self::ThreeDDelta, + (false, false, false, false, true ) => Self::ThreeDKt, + (true, true, false, false, false) => Self::FourDNucleonsAlphas, + (true, false, false, false, true ) => Self::FourDNucleonsKt, + (false, true, false, false, true ) => Self::FourDAlphasKt, + (false, false, true, true, false) => Self::FourDXiDelta, + (false, false, true, true, true ) => Self::FiveD, + (true, false, true, true, true ) => Self::SixD, + (true, true, true, true, true ) => Self::SevenD, _ => panic!( "Unsupported dimension combination: nucleons={}, alphas={}, xis={}, deltas={}, kts={}", n_nucleons, n_alphas, n_xis, n_deltas, n_kts diff --git a/neopdf_pyapi/src/lib.rs b/neopdf_pyapi/src/lib.rs index 37f8cb5..a060bf3 100644 --- a/neopdf_pyapi/src/lib.rs +++ b/neopdf_pyapi/src/lib.rs @@ -3,6 +3,10 @@ #![allow(unsafe_op_in_unsafe_fn)] use pyo3::prelude::*; +use std::sync::atomic::{AtomicI32, Ordering}; + +use crate::pdf::{PyLoaderMethod, PyPDF, PyPDFSet}; +use ::neopdf::manage::ManageData; /// Python bindings for the `converter` module. pub mod converter; @@ -21,10 +25,99 @@ pub mod uncertainty; /// Python bindings for the `writer` module. pub mod writer; +static VERBOSITY: AtomicI32 = AtomicI32::new(0); + +/// Set the `NeoPDF` verbosity level (no-op, exists for LHAPDF API compatibility). +#[pyfunction] +#[pyo3(name = "setVerbosity")] +fn set_verbosity(level: i32) { + VERBOSITY.store(level, Ordering::Relaxed); +} + +/// Return the current verbosity level. +#[pyfunction] +fn verbosity() -> i32 { + VERBOSITY.load(Ordering::Relaxed) +} + +/// Return a `PDFSet` object for the named set (LHAPDF-compatible). +#[pyfunction] +#[pyo3(name = "getPDFSet")] +fn get_pdf_set(setname: &str) -> PyPDFSet { + PyPDFSet::new(setname) +} + +/// Load a single PDF member by set name and member index (LHAPDF-compatible). +#[pyfunction] +#[pyo3(name = "mkPDF")] +#[pyo3(signature = (pdf_name, member = 0))] +fn mk_pdf(pdf_name: &str, member: usize) -> PyPDF { + PyPDF::new(pdf_name, member) +} + +/// Load all members of a PDF set (LHAPDF-compatible). +#[pyfunction] +#[pyo3(name = "mkPDFs")] +fn mk_pdfs(pdf_name: &str) -> Vec { + PyPDF::mkpdfs(pdf_name, &PyLoaderMethod::Parallel) +} + +/// Return a list of PDF set names available in the current data path. +#[pyfunction] +#[pyo3(name = "availablePDFSets")] +fn available_pdf_sets() -> Vec { + let data_path = ManageData::get_data_path(); + let mut sets = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&data_path) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + if path.join(format!("{name}.info")).exists() { + sets.push(name); + } + } + } + } + sets.sort(); + sets +} + +/// Return the list of data paths searched for PDF sets. +#[pyfunction] +fn paths() -> Vec { + vec![ManageData::get_data_path().to_string_lossy().into_owned()] +} + +/// No-op: path management is controlled via the `NEOPDF_DATA_PATH` env var. +#[pyfunction] +#[pyo3(name = "setPaths")] +fn set_paths(_paths: Vec) {} + +/// No-op: path management is controlled via the `NEOPDF_DATA_PATH` env var. +#[pyfunction] +#[pyo3(name = "pathsAppend")] +fn paths_append(_path: String) {} + +/// No-op: path management is controlled via the `NEOPDF_DATA_PATH` env var. +#[pyfunction] +#[pyo3(name = "pathsPrepend")] +fn paths_prepend(_path: String) {} + +/// Return the NeoPDF version string (LHAPDF-compatible callable). +#[pyfunction] +fn version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + /// `PyO3` Python module that contains all exposed classes from Rust. #[pymodule] fn neopdf(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add("version", env!("CARGO_PKG_VERSION"))?; + m.add("__version__", env!("CARGO_PKG_VERSION"))?; pdf::register(m)?; metadata::register(m)?; converter::register(m)?; @@ -33,5 +126,16 @@ fn neopdf(m: &Bound<'_, PyModule>) -> PyResult<()> { parser::register(m)?; writer::register(m)?; uncertainty::register(m)?; + m.add_function(wrap_pyfunction!(set_verbosity, m)?)?; + m.add_function(wrap_pyfunction!(verbosity, m)?)?; + m.add_function(wrap_pyfunction!(get_pdf_set, m)?)?; + m.add_function(wrap_pyfunction!(mk_pdf, m)?)?; + m.add_function(wrap_pyfunction!(mk_pdfs, m)?)?; + m.add_function(wrap_pyfunction!(available_pdf_sets, m)?)?; + m.add_function(wrap_pyfunction!(paths, m)?)?; + m.add_function(wrap_pyfunction!(set_paths, m)?)?; + m.add_function(wrap_pyfunction!(paths_append, m)?)?; + m.add_function(wrap_pyfunction!(paths_prepend, m)?)?; + m.add_function(wrap_pyfunction!(version, m)?)?; Ok(()) } diff --git a/neopdf_pyapi/src/manage.rs b/neopdf_pyapi/src/manage.rs index f5fd557..c664fe4 100644 --- a/neopdf_pyapi/src/manage.rs +++ b/neopdf_pyapi/src/manage.rs @@ -3,7 +3,7 @@ use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; /// Python wrapper for the `PdfSetFormat` enum. -#[pyclass(name = "PdfSetFormat")] +#[pyclass(from_py_object, name = "PdfSetFormat")] #[derive(Clone)] pub enum PyPdfSetFormat { /// LHAPDF format (standard PDF set format used by LHAPDF). diff --git a/neopdf_pyapi/src/metadata.rs b/neopdf_pyapi/src/metadata.rs index 8ca7f5d..37dc420 100644 --- a/neopdf_pyapi/src/metadata.rs +++ b/neopdf_pyapi/src/metadata.rs @@ -3,7 +3,7 @@ use pyo3::prelude::*; use neopdf::metadata::{InterpolatorType, MetaData, SetType}; /// The type of the set. -#[pyclass(eq, eq_int, name = "SetType")] +#[pyclass(eq, eq_int, from_py_object, name = "SetType")] #[derive(Clone, PartialEq, Eq)] pub enum PySetType { /// Parton Distribution Function. @@ -31,7 +31,7 @@ impl From<&PySetType> for SetType { } /// The interpolation method used for the grid. -#[pyclass(eq, eq_int, name = "InterpolatorType")] +#[pyclass(eq, eq_int, from_py_object, name = "InterpolatorType")] #[derive(Clone, PartialEq, Eq)] pub enum PyInterpolatorType { /// Bilinear interpolation strategy. @@ -83,7 +83,7 @@ impl From<&PyInterpolatorType> for InterpolatorType { } /// Physical Parameters of the PDF set. -#[pyclass(name = "PhysicsParameters")] +#[pyclass(from_py_object, name = "PhysicsParameters")] #[derive(Debug, Clone)] pub struct PyPhysicsParameters { pub(crate) flavor_scheme: String, @@ -198,7 +198,7 @@ impl Default for PyPhysicsParameters { } /// Grid metadata. -#[pyclass(name = "MetaData")] +#[pyclass(from_py_object, name = "MetaData")] #[derive(Debug, Clone)] #[repr(transparent)] pub struct PyMetaData { diff --git a/neopdf_pyapi/src/pdf.rs b/neopdf_pyapi/src/pdf.rs index 66fe7ff..3df1fa3 100644 --- a/neopdf_pyapi/src/pdf.rs +++ b/neopdf_pyapi/src/pdf.rs @@ -3,6 +3,7 @@ use pyo3::prelude::*; use std::sync::Mutex; use neopdf::gridpdf::ForcePositive; +use neopdf::metadata::MetaData; use neopdf::pdf::PDF; use super::gridpdf::PySubGrid; @@ -10,9 +11,10 @@ use super::metadata::PyMetaData; // Type aliases type LazyType = Result>; +type EnumeratedLazy = Box + Send>; /// Python wrapper for the `ForcePositive` enum. -#[pyclass(name = "ForcePositive")] +#[pyclass(from_py_object, name = "ForcePositive")] #[derive(Clone)] pub enum PyForcePositive { /// If the calculated PDF value is negative, it is forced to 0. @@ -44,7 +46,7 @@ impl From<&ForcePositive> for PyForcePositive { } /// Methods to load all the PDF members for a given set. -#[pyclass(name = "LoaderMethod")] +#[pyclass(from_py_object, name = "LoaderMethod")] #[derive(Clone)] pub enum PyLoaderMethod { /// Load the members in parallel using multi-threads. @@ -69,7 +71,7 @@ impl PyForcePositive { } /// This enum contains the different parameters that a grid can depend on. -#[pyclass(name = "GridParams")] +#[pyclass(from_py_object, name = "GridParams")] #[derive(Clone)] pub enum PyGridParams { /// The nucleon mass number A. @@ -84,13 +86,124 @@ pub enum PyGridParams { Q2, } +/// LHAPDF-compatible interface to a PDF set. +/// +/// Provides the same API as `lhapdf.PDFSet`. +#[pyclass(name = "PDFSet")] +pub struct PyPDFSet { + name: String, + meta: MetaData, +} + +#[pymethods] +impl PyPDFSet { + /// Create a new `PDFSet` for a given set name. + /// + /// Parameters + /// ---------- + /// name : str + /// The name of the PDF set. + #[new] + #[must_use] + pub fn new(name: &str) -> Self { + let meta = PDF::load(name, 0).metadata().clone(); + Self { + name: name.to_string(), + meta, + } + } + + /// The name of the PDF set. + #[getter] + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + /// Total number of members (central value + error members). + #[getter] + #[must_use] + pub const fn size(&self) -> u32 { + self.meta.num_members + } + + /// Human-readable description of the PDF set. + #[getter] + #[must_use] + pub fn description(&self) -> &str { + &self.meta.set_desc + } + + /// Error type string (e.g. `replicas`, `hessian`). + #[getter] + #[must_use] + #[pyo3(name = "errorType")] + pub fn error_type(&self) -> &str { + &self.meta.error_type + } + + /// LHAPDF ID for member 0 of this set. + #[getter] + #[must_use] + #[pyo3(name = "lhapdfID")] + pub const fn lhapdf_id(&self) -> u32 { + self.meta.set_index + } + + /// Return the metadata for this set as a `MetaData` object. + #[must_use] + pub fn info(&self) -> PyMetaData { + PyMetaData { + meta: self.meta.clone(), + } + } + + /// Load a single PDF member by index. + /// + /// Parameters + /// ---------- + /// member : int + /// Member index. Defaults to 0 (central value). + #[must_use] + #[pyo3(signature = (member = 0))] + #[pyo3(name = "mkPDF")] + pub fn mk_pdf(&self, member: usize) -> PyPDF { + PyPDF::new(&self.name, member) + } + + /// Load all members of this PDF set. + /// + /// Parameters + /// ---------- + /// method : `LoaderMethod` + /// Loading strategy. Defaults to `Parallel`. + #[must_use] + #[pyo3(signature = (method = &PyLoaderMethod::Parallel))] + #[pyo3(name = "mkPDFs")] + pub fn mk_pdfs(&self, method: &PyLoaderMethod) -> Vec { + PyPDF::mkpdfs(&self.name, method) + } + + fn __repr__(&self) -> String { + format!( + "", + self.name, self.meta.num_members + ) + } + + const fn __len__(&self) -> usize { + self.meta.num_members as usize + } +} + /// Python wrapper for the `neopdf::pdf::PDF` struct. /// /// This class provides a Python-friendly interface to the core PDF /// interpolation functionalities of the `neopdf` Rust library. #[pyclass(name = "LazyPDFs")] pub struct PyLazyPDFs { - iter: Mutex + Send>>, + iter: Mutex, + pdf_name: String, } #[pymethods] @@ -102,9 +215,14 @@ impl PyLazyPDFs { #[allow(clippy::needless_pass_by_value)] fn __next__(slf: PyRefMut<'_, Self>) -> PyResult> { let mut iter = slf.iter.lock().unwrap(); + let pdf_name = slf.pdf_name.clone(); match iter.next() { - Some(Ok(pdf)) => Ok(Some(PyPDF { pdf })), - Some(Err(e)) => Err(pyo3::exceptions::PyValueError::new_err(e.to_string())), + Some((member, Ok(pdf))) => Ok(Some(PyPDF { + pdf, + pdf_name, + member, + })), + Some((_, Err(e))) => Err(pyo3::exceptions::PyValueError::new_err(e.to_string())), None => Ok(None), } } @@ -115,9 +233,10 @@ impl PyLazyPDFs { /// This class provides a Python-friendly interface to the core PDF /// interpolation functionalities of the `neopdf` Rust library. #[pyclass(name = "PDF")] -#[repr(transparent)] pub struct PyPDF { pub(crate) pdf: PDF, + pub(crate) pdf_name: String, + pub(crate) member: usize, } #[pymethods] @@ -144,6 +263,8 @@ impl PyPDF { pub fn new(pdf_name: &str, member: usize) -> Self { Self { pdf: PDF::load(pdf_name, member), + pdf_name: pdf_name.to_string(), + member, } } @@ -189,8 +310,13 @@ impl PyPDF { #[staticmethod] #[pyo3(name = "mkPDF_lhaid")] pub fn mkpdf_lhaid(lhaid: u32) -> Self { + let pdf = PDF::load_by_lhaid(lhaid); + let member = lhaid.saturating_sub(pdf.metadata().set_index) as usize; + Self { - pdf: PDF::load_by_lhaid(lhaid), + pdf, + pdf_name: String::new(), + member, } } @@ -211,6 +337,8 @@ impl PyPDF { pub fn mkpdf_lhapdf_file(path: &str) -> Self { Self { pdf: PDF::load_lhapdf_by_file(path), + pdf_name: path.to_string(), + member: 0, } } @@ -240,7 +368,12 @@ impl PyPDF { loader_method(pdf_name) .into_iter() - .map(move |pdfobj| Self { pdf: pdfobj }) + .enumerate() + .map(|(i, pdfobj)| Self { + pdf: pdfobj, + pdf_name: pdf_name.to_string(), + member: i, + }) .collect() } @@ -262,7 +395,8 @@ impl PyPDF { #[pyo3(name = "mkPDFs_lazy")] pub fn mkpdfs_lazy(pdf_name: &str) -> PyLazyPDFs { PyLazyPDFs { - iter: Mutex::new(Box::new(PDF::load_pdfs_lazy(pdf_name))), + iter: Mutex::new(Box::new(PDF::load_pdfs_lazy(pdf_name).enumerate())), + pdf_name: pdf_name.to_string(), } } @@ -612,6 +746,176 @@ impl PyPDF { meta: self.pdf.metadata().clone(), } } + + // ------------------ LHAPDF-compatible API ------------------ + + /// Evaluate xf(x, Q) for a single flavor. + #[must_use] + #[pyo3(name = "xfxQ")] + pub fn xfxq(&self, id: i32, x: f64, q: f64) -> f64 { + self.xfxq2(id, x, q * q) + } + + /// Evaluate alpha_s(Q). + #[must_use] + #[pyo3(name = "alphasQ")] + pub fn alphas_q(&self, q: f64) -> f64 { + self.alphas_q2(q * q) + } + + /// Return the list of available flavor PIDs (LHAPDF name for `pids`). + #[must_use] + pub fn flavors(&self) -> Vec { + self.pdf.metadata().flavors.clone() + } + + /// Return `True` if the given PID is available in this set. + #[must_use] + #[pyo3(name = "hasFlavor")] + pub fn has_flavor(&self, id: i32) -> bool { + self.pdf.metadata().flavors.contains(&id) + } + + /// Return `True` if `x` lies within the grid x-range. + #[must_use] + #[pyo3(name = "inRangeX")] + pub fn in_range_x(&self, x: f64) -> bool { + let r = self.pdf.param_ranges().x; + x >= r.min && x <= r.max + } + + /// Return `True` if `Q` (not Q²) lies within the grid Q-range. + #[must_use] + #[pyo3(name = "inRangeQ")] + pub fn in_range_q(&self, q: f64) -> bool { + let r = self.pdf.param_ranges().q2; + let q2 = q * q; + q2 >= r.min && q2 <= r.max + } + + /// Return `True` if `Q²` lies within the grid Q²-range. + #[must_use] + #[pyo3(name = "inRangeQ2")] + pub fn in_range_q2(&self, q2: f64) -> bool { + let r = self.pdf.param_ranges().q2; + q2 >= r.min && q2 <= r.max + } + + /// Return `True` if both `x` and `Q` are within their respective ranges. + #[must_use] + #[pyo3(name = "inRangeXQ")] + pub fn in_range_xq(&self, x: f64, q: f64) -> bool { + self.in_range_x(x) && self.in_range_q(q) + } + + /// Return `True` if both `x` and `Q²` are within their respective ranges. + #[must_use] + #[pyo3(name = "inRangeXQ2")] + pub fn in_range_xq2(&self, x: f64, q2: f64) -> bool { + self.in_range_x(x) && self.in_range_q2(q2) + } + + /// Index of this member within its PDF set (0 = central value). + #[getter] + #[must_use] + #[pyo3(name = "memberID")] + pub const fn member_id(&self) -> usize { + self.member + } + + /// LHAPDF ID of this specific member (set base ID + member index). + #[getter] + #[must_use] + #[pyo3(name = "lhapdfID")] + pub fn lhapdf_id(&self) -> u32 { + self.pdf.metadata().set_index + self.member as u32 + } + + /// QCD perturbative order used for this PDF set. + #[getter] + #[must_use] + #[pyo3(name = "orderQCD")] + pub fn order_qcd(&self) -> u32 { + self.pdf.metadata().order_qcd + } + + /// Maximum x value in the grid (camelCase LHAPDF alias). + #[getter] + #[must_use] + #[pyo3(name = "xMax")] + pub fn x_max_lhapdf(&self) -> f64 { + self.x_max() + } + + /// Minimum x value in the grid (camelCase LHAPDF alias). + #[getter] + #[must_use] + #[pyo3(name = "xMin")] + pub fn x_min_lhapdf(&self) -> f64 { + self.x_min() + } + + /// Maximum Q² value in the grid (camelCase LHAPDF alias). + #[getter] + #[must_use] + #[pyo3(name = "q2Max")] + pub fn q2_max_lhapdf(&self) -> f64 { + self.q2_max() + } + + /// Minimum Q² value in the grid (camelCase LHAPDF alias). + #[getter] + #[must_use] + #[pyo3(name = "q2Min")] + pub fn q2_min_lhapdf(&self) -> f64 { + self.q2_min() + } + + /// Pole mass of the quark with the given flavor ID. + /// + /// Returns 0.0 for unknown flavor IDs. + #[must_use] + #[pyo3(name = "quarkMass")] + pub fn quark_mass(&self, id: i32) -> f64 { + let m = self.pdf.metadata(); + match id.abs() { + 1 => m.m_down, + 2 => m.m_up, + 3 => m.m_strange, + 4 => m.m_charm, + 5 => m.m_bottom, + 6 => m.m_top, + _ => 0.0, + } + } + + /// Flavor threshold for the given quark ID (equal to the quark mass). + #[must_use] + #[pyo3(name = "quarkThreshold")] + pub fn quark_threshold(&self, id: i32) -> f64 { + self.quark_mass(id) + } + + /// Human-readable description of this PDF set. + #[getter] + #[must_use] + pub fn description(&self) -> String { + self.pdf.metadata().set_desc.clone() + } + + /// Return the `PDFSet` this member belongs to. + /// + /// Only available when the PDF was loaded by name; returns `None` when + /// loaded via LHAID or file path. + #[getter] + #[must_use] + pub fn set(&self) -> Option { + if self.pdf_name.is_empty() { + None + } else { + Some(PyPDFSet::new(&self.pdf_name)) + } + } } /// Registers the `pdf` submodule with the parent Python module. @@ -642,9 +946,11 @@ pub fn register(parent_module: &Bound<'_, PyModule>) -> PyResult<()> { "import sys; sys.modules['neopdf.pdf'] = m" ); m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; + parent_module.add_class::()?; parent_module.add_submodule(&m) } diff --git a/neopdf_pyapi/src/uncertainty.rs b/neopdf_pyapi/src/uncertainty.rs index 1f1e7f5..a1ac91c 100644 --- a/neopdf_pyapi/src/uncertainty.rs +++ b/neopdf_pyapi/src/uncertainty.rs @@ -2,7 +2,7 @@ use numpy::{PyArray1, PyArrayMethods}; use pyo3::prelude::*; /// Python wrapper for the NeoPDF `Uncertainty` struct. -#[pyclass(name = "Uncertainty")] +#[pyclass(from_py_object, name = "Uncertainty")] #[derive(Clone, Debug)] pub struct PyUncertainty { /// Central value. diff --git a/neopdf_pyapi/tests/test_lhapdf_compatibility.py b/neopdf_pyapi/tests/test_lhapdf_compatibility.py new file mode 100644 index 0000000..29c3776 --- /dev/null +++ b/neopdf_pyapi/tests/test_lhapdf_compatibility.py @@ -0,0 +1,287 @@ +import numpy as np +import pytest +import lhapdf +import neopdf + +PDF_SET = "NNPDF40_nnlo_as_01180" +MEMBER = 7 + + +@pytest.fixture(scope="module") +def neo_ps(): + return neopdf.getPDFSet(PDF_SET) + + +@pytest.fixture(scope="module") +def lha_ps(): + return lhapdf.getPDFSet(PDF_SET) + + +@pytest.fixture(scope="module") +def neo_p(): + return neopdf.mkPDF(PDF_SET, MEMBER) + + +@pytest.fixture(scope="module") +def lha_p(): + return lhapdf.mkPDF(PDF_SET, MEMBER) + + +class TestModuleLevelFunctions: + def test_verbosity_roundtrip(self): + neopdf.setVerbosity(3) + assert neopdf.verbosity() == 3 + neopdf.setVerbosity(0) + assert neopdf.verbosity() == 0 + + def test_get_pdf_set_returns_pdfset(self): + ps = neopdf.getPDFSet(PDF_SET) + assert isinstance(ps, neopdf.PDFSet) + + def test_mk_pdf_returns_pdf(self): + p = neopdf.mkPDF(PDF_SET, 0) + assert isinstance(p, neopdf.pdf.PDF) + + def test_mk_pdf_default_member(self): + p = neopdf.mkPDF(PDF_SET) + assert p.memberID == 0 + + def test_mk_pdfs_length_matches_lhapdf(self): + neo = neopdf.mkPDFs(PDF_SET) + lha = lhapdf.mkPDFs(PDF_SET) + assert len(neo) == len(lha) + + def test_available_pdf_sets_is_list_of_str(self): + sets = neopdf.availablePDFSets() + assert isinstance(sets, list) + assert all(isinstance(s, str) for s in sets) + + def test_available_pdf_sets_contains_test_set(self): + assert PDF_SET in neopdf.availablePDFSets() + + def test_paths_returns_list_of_str(self): + ps = neopdf.paths() + assert isinstance(ps, list) + assert len(ps) >= 1 + assert all(isinstance(p, str) for p in ps) + + def test_set_paths_no_op(self): + neopdf.setPaths(["/tmp/fake"]) + + def test_paths_append_no_op(self): + neopdf.pathsAppend("/tmp/fake") + + def test_paths_prepend_no_op(self): + neopdf.pathsPrepend("/tmp/fake") + + +class TestPDFSetClass: + def test_name(self, neo_ps, lha_ps): + assert neo_ps.name == lha_ps.name + + def test_size(self, neo_ps, lha_ps): + assert neo_ps.size == lha_ps.size + + def test_lhapdf_id(self, neo_ps, lha_ps): + assert neo_ps.lhapdfID == lha_ps.lhapdfID + + def test_error_type(self, neo_ps, lha_ps): + assert neo_ps.errorType == lha_ps.errorType + + def test_description_non_empty(self, neo_ps): + assert len(neo_ps.description) > 0 + + def test_len(self, neo_ps, lha_ps): + assert len(neo_ps) == len(lha_ps) + + def test_mkpdf_member_id(self, neo_ps, lha_ps): + neo_p = neo_ps.mkPDF(MEMBER) + lha_p = lha_ps.mkPDF(MEMBER) + assert neo_p.memberID == lha_p.memberID + + def test_mkpdf_lhapdf_id(self, neo_ps, lha_ps): + neo_p = neo_ps.mkPDF(MEMBER) + lha_p = lha_ps.mkPDF(MEMBER) + assert neo_p.lhapdfID == lha_p.lhapdfID + + def test_mkpdfs_count(self, neo_ps, lha_ps): + neo_members = neo_ps.mkPDFs() + lha_members = lha_ps.mkPDFs() + assert len(neo_members) == len(lha_members) + + def test_mkpdfs_member_ids(self, neo_ps): + members = neo_ps.mkPDFs() + for i, m in enumerate(members): + assert m.memberID == i + + +@pytest.mark.parametrize( + "x,q,pid", + [(1e-4, 10.0, 21), (0.1, 100.0, 1), (0.5, 1000.0, -5)], +) +class TestPDFXfxQ: + def test_xfxq_equals_xfxq2(self, x, q, pid, neo_p): + """xfxQ(id, x, Q) must equal xfxQ2(id, x, Q²).""" + assert neo_p.xfxQ(pid, x, q) == neo_p.xfxQ2(pid, x, q * q) + + def test_xfxq_matches_lhapdf(self, x, q, pid, neo_p, lha_p): + np.testing.assert_equal(neo_p.xfxQ(pid, x, q), lha_p.xfxQ(pid, x, q)) + + +class TestPDFAlphaS: + @pytest.mark.parametrize("q", [2.0, 10.0, 91.2, 1000.0]) + def test_alphasq_equals_alphasq2(self, q, neo_p): + assert neo_p.alphasQ(q) == neo_p.alphasQ2(q * q) + + @pytest.mark.parametrize("q", [2.0, 10.0, 91.2, 1000.0]) + def test_alphasq_matches_lhapdf(self, q, neo_p, lha_p): + np.testing.assert_equal(neo_p.alphasQ(q), lha_p.alphasQ(q)) + + +class TestPDFIdentity: + def test_member_id(self, neo_p, lha_p): + assert neo_p.memberID == lha_p.memberID + + def test_lhapdf_id(self, neo_p, lha_p): + assert neo_p.lhapdfID == lha_p.lhapdfID + + def test_order_qcd(self, neo_p, lha_p): + assert neo_p.orderQCD == lha_p.orderQCD + + def test_quark_mass_charm(self, neo_p, lha_p): + np.testing.assert_allclose(neo_p.quarkMass(4), lha_p.quarkMass(4)) + + def test_quark_mass_bottom(self, neo_p, lha_p): + np.testing.assert_allclose(neo_p.quarkMass(5), lha_p.quarkMass(5)) + + def test_quark_threshold_equals_mass(self, neo_p): + assert neo_p.quarkThreshold(4) == neo_p.quarkMass(4) + assert neo_p.quarkThreshold(5) == neo_p.quarkMass(5) + + def test_x_max(self, neo_p, lha_p): + np.testing.assert_allclose(neo_p.xMax, lha_p.xMax) + + def test_q2_min(self, neo_p, lha_p): + np.testing.assert_allclose(neo_p.q2Min, lha_p.q2Min) + + def test_q2_max(self, neo_p, lha_p): + np.testing.assert_allclose(neo_p.q2Max, lha_p.q2Max) + + +class TestPDFFlavors: + def test_flavors_match_lhapdf(self, neo_p, lha_p): + assert sorted(neo_p.flavors()) == sorted(lha_p.flavors()) + + def test_has_flavor_gluon(self, neo_p, lha_p): + assert neo_p.hasFlavor(21) == lha_p.hasFlavor(21) + + def test_has_flavor_unknown(self, neo_p, lha_p): + assert neo_p.hasFlavor(99) == lha_p.hasFlavor(99) + + @pytest.mark.parametrize("pid", [-5, -4, -3, -2, -1, 1, 2, 3, 4, 5, 21]) + def test_has_flavor_all_standard(self, pid, neo_p): + assert neo_p.hasFlavor(pid) is True + + +class TestPDFRangeChecks: + @pytest.mark.parametrize( + "x,q2,expected", + [ + (1e-4, 100.0, True), + (0.5, 1e4, True), + (2.0, 100.0, False), + (1e-4, 1e13, False), + ], + ) + def test_in_range_xq2_matches_lhapdf(self, x, q2, expected, neo_p, lha_p): + assert neo_p.inRangeXQ2(x, q2) == lha_p.inRangeXQ2(x, q2) + + def test_in_range_xq_matches_lhapdf(self, neo_p, lha_p): + for x, q in [(1e-4, 10.0), (0.5, 100.0), (2.0, 10.0)]: + assert neo_p.inRangeXQ(x, q) == lha_p.inRangeXQ(x, q) + + def test_in_range_x_matches_lhapdf(self, neo_p, lha_p): + for x in [1e-6, 0.1, 0.9, 1.5]: + assert neo_p.inRangeX(x) == lha_p.inRangeX(x) + + def test_in_range_q2_matches_lhapdf(self, neo_p, lha_p): + for q2 in [1.0, 100.0, 1e10, 1e13]: + assert neo_p.inRangeQ2(q2) == lha_p.inRangeQ2(q2) + + def test_in_range_q_matches_lhapdf(self, neo_p, lha_p): + for q in [1.0, 10.0, 1e5, 1e7]: + assert neo_p.inRangeQ(q) == lha_p.inRangeQ(q) + + +class TestPDFSetProperty: + def test_set_property_not_none(self, neo_p): + assert neo_p.set is not None + + def test_set_property_is_pdfset(self, neo_p): + assert isinstance(neo_p.set, neopdf.PDFSet) + + def test_set_property_name(self, neo_p): + assert neo_p.set.name == PDF_SET + + def test_set_property_none_when_loaded_by_lhaid(self): + from neopdf.pdf import PDF + + p = PDF.mkPDF_lhaid(14400) + assert p.set is None + + +def _compute_xfxQ2_central(backend, pdfname, pid, x, q2): + pdf = backend.mkPDF(pdfname, 0) + return pdf.xfxQ2(pid, x, q2) + + +def _collect_all_member_values(backend, pdfname, pid, x, q2): + pdfs = backend.mkPDFs(pdfname) + return [p.xfxQ2(pid, x, q2) for p in pdfs] + + +def _inspect_set(backend, pdfname): + ps = backend.getPDFSet(pdfname) + return { + "name": ps.name, + "size": ps.size, + "lhapdfID": ps.lhapdfID, + "errorType": ps.errorType, + } + + +def _check_kinematics(backend, pdfname, x, q): + pdf = backend.mkPDF(pdfname, 0) + return pdf.inRangeXQ(x, q) + + +@pytest.mark.parametrize( + "pid,x,q2", + [(21, 1e-4, 100.0), (1, 0.1, 1e4), (-3, 0.3, 1e6)], +) +class TestBackendSwap: + def test_central_member_xfxq2(self, pid, x, q2): + lha_val = _compute_xfxQ2_central(lhapdf, PDF_SET, pid, x, q2) + neo_val = _compute_xfxQ2_central(neopdf, PDF_SET, pid, x, q2) + np.testing.assert_equal(neo_val, lha_val) + + def test_all_member_values(self, pid, x, q2): + lha_vals = _collect_all_member_values(lhapdf, PDF_SET, pid, x, q2) + neo_vals = _collect_all_member_values(neopdf, PDF_SET, pid, x, q2) + np.testing.assert_array_equal(neo_vals, lha_vals) + + +class TestBackendSwapMetadata: + def test_set_metadata(self): + lha_meta = _inspect_set(lhapdf, PDF_SET) + neo_meta = _inspect_set(neopdf, PDF_SET) + assert neo_meta == lha_meta + + def test_kinematics_in_range(self): + for x, q in [(1e-3, 10.0), (0.5, 300.0), (2.0, 10.0)]: + assert _check_kinematics( + neopdf, + PDF_SET, + x, + q, + ) == _check_kinematics(lhapdf, PDF_SET, x, q)