Skip to main content

npls1_python/
lib.rs

1//! Python bindings for the `npls1` crate (N-PLS1 regression).
2//! Built for pyo3 0.29 / numpy 0.29.
3
4use ndarray::{Array1, Array3};
5use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray3};
6use pyo3::exceptions::{PyRuntimeError, PyValueError};
7use pyo3::prelude::*;
8use pyo3::types::PyDict;
9
10use npls1::{Npls as RustNpls, NplsError};
11
12/// Convert a Rust `NplsError` into an appropriate Python exception.
13fn map_err(e: NplsError) -> PyErr {
14    match e {
15        NplsError::LinalgError(msg) => PyRuntimeError::new_err(format!("LinalgError: {msg}")),
16        NplsError::ValueError(msg) => PyValueError::new_err(msg),
17        NplsError::NotFitted => PyRuntimeError::new_err(
18            "Model has not been fitted yet. Call fit() before predict().",
19        ),
20    }
21}
22
23/// N-PLS1 (N-way Partial Least Squares) regressor — Rust-backed.
24#[pyclass(name = "Npls")]
25pub struct PyNpls {
26    inner: RustNpls,
27}
28
29#[pymethods]
30impl PyNpls {
31    #[new]
32    #[pyo3(signature = (
33        n_components,
34        a,
35        derivative_rang = None,
36        norm_func = None,
37        crash_norm_name = None,
38        crash_norm_value = None,
39        excitation_wavelenth = None,
40        emission_wavelenth = None,
41    ))]
42    #[allow(clippy::too_many_arguments)]
43    fn new(
44        n_components: usize,
45        a: f64,
46        derivative_rang: Option<Vec<usize>>,
47        norm_func: Option<Vec<String>>,
48        crash_norm_name: Option<String>,
49        crash_norm_value: Option<f64>,
50        excitation_wavelenth: Option<PyReadonlyArray1<f64>>,
51        emission_wavelenth: Option<PyReadonlyArray1<f64>>,
52    ) -> PyResult<Self> {
53        let deriv = derivative_rang.unwrap_or_default();
54        let norms = norm_func.unwrap_or_default();
55
56        let use_snr = !deriv.is_empty()
57            || crash_norm_name.is_some()
58            || excitation_wavelenth.is_some()
59            || emission_wavelenth.is_some();
60
61        let inner = if use_snr {
62            let exc = match excitation_wavelenth {
63                Some(arr) => arr.as_array().to_owned(),
64                None => Array1::zeros(1),
65            };
66            let emi = match emission_wavelenth {
67                Some(arr) => arr.as_array().to_owned(),
68                None => Array1::zeros(1),
69            };
70            RustNpls::with_snr(
71                n_components,
72                a,
73                deriv,
74                norms,
75                crash_norm_name,
76                crash_norm_value,
77                exc,
78                emi,
79            )
80        } else {
81            RustNpls::new(n_components, a)
82        };
83
84        Ok(PyNpls { inner })
85    }
86
87    /// Fit the model. `xtrain`: (n_samples, n_emission, n_excitation); `ytrain`: (n_samples,).
88    fn fit<'py>(
89        mut slf: PyRefMut<'py, Self>,
90        xtrain: PyReadonlyArray3<'py, f64>,
91        ytrain: PyReadonlyArray1<'py, f64>,
92    ) -> PyResult<PyRefMut<'py, Self>> {
93        let x: Array3<f64> = xtrain.as_array().to_owned();
94        let y: Array1<f64> = ytrain.as_array().to_owned();
95        slf.inner.fit(&x, &y).map_err(map_err)?;
96        Ok(slf)
97    }
98
99    /// Predict target values. `xtest`: (n_samples, n_emission, n_excitation).
100    fn predict<'py>(
101        &self,
102        py: Python<'py>,
103        xtest: PyReadonlyArray3<'py, f64>,
104    ) -> PyResult<Bound<'py, PyArray1<f64>>> {
105        let x: Array3<f64> = xtest.as_array().to_owned();
106        let preds = self.inner.predict(&x).map_err(map_err)?;
107        Ok(preds.into_pyarray(py))
108    }
109
110    // ── read-only accessors ─────────────────────────────────────────
111
112    #[getter]
113    fn n_components(&self) -> usize {
114        self.inner.n_components
115    }
116
117    #[getter]
118    fn a(&self) -> f64 {
119        self.inner.a
120    }
121
122    #[getter]
123    fn train_error(&self) -> Option<f64> {
124        self.inner.train_error
125    }
126
127    #[getter]
128    fn w_k<'py>(&self, py: Python<'py>) -> Option<Vec<Bound<'py, PyArray2<f64>>>> {
129        self.inner
130            .w_k
131            .as_ref()
132            .map(|vec| vec.iter().map(|m| m.clone().into_pyarray(py)).collect())
133    }
134
135    #[getter]
136    fn w_i<'py>(&self, py: Python<'py>) -> Option<Vec<Bound<'py, PyArray2<f64>>>> {
137        self.inner
138            .w_i
139            .as_ref()
140            .map(|vec| vec.iter().map(|m| m.clone().into_pyarray(py)).collect())
141    }
142
143    #[getter]
144    fn bf_array<'py>(&self, py: Python<'py>) -> Option<Vec<Bound<'py, PyArray2<f64>>>> {
145        self.inner
146            .bf_array
147            .as_ref()
148            .map(|vec| vec.iter().map(|m| m.clone().into_pyarray(py)).collect())
149    }
150
151    #[getter]
152    fn snr_emission<'py>(&self, py: Python<'py>) -> PyResult<Option<Vec<Bound<'py, PyDict>>>> {
153        snr_to_py(py, &self.inner.snr_emission)
154    }
155
156    #[getter]
157    fn snr_excitation<'py>(&self, py: Python<'py>) -> PyResult<Option<Vec<Bound<'py, PyDict>>>> {
158        snr_to_py(py, &self.inner.snr_excitation)
159    }
160
161    fn __repr__(&self) -> String {
162        format!(
163            "Npls(n_components={}, a={}, fitted={})",
164            self.inner.n_components,
165            self.inner.a,
166            self.inner.bf_array.is_some()
167        )
168    }
169}
170
171/// Convert SNR results into a list of Python dicts.
172fn snr_to_py<'py>(
173    py: Python<'py>,
174    snr: &Option<Vec<npls1::SnrResponse>>,
175) -> PyResult<Option<Vec<Bound<'py, PyDict>>>> {
176    match snr {
177        None => Ok(None),
178        Some(list) => {
179            let mut out = Vec::with_capacity(list.len());
180            for comp in list {
181                let d = PyDict::new(py);
182                for (k, v) in comp.iter() {
183                    d.set_item(k, v.clone())?;
184                }
185                out.push(d);
186            }
187            Ok(Some(out))
188        }
189    }
190}
191
192/// The Python module `npls1_python`.
193#[pymodule]
194fn npls1_python(m: &Bound<'_, PyModule>) -> PyResult<()> {
195    m.add_class::<PyNpls>()?;
196    m.add("__version__", env!("CARGO_PKG_VERSION"))?;
197    Ok(())
198}