diff --git a/.codespell/ignore_words.txt b/.codespell/ignore_words.txt index 00659c1..2dd64f3 100644 --- a/.codespell/ignore_words.txt +++ b/.codespell/ignore_words.txt @@ -15,3 +15,6 @@ regist ;; src/pyobjcryst/crystal.py:548 ;; alabelstyle parameter inFront + +;; tests/test_reflectionprofile.py: unittest assertions flagged as typos +assertin diff --git a/news/79.rst b/news/79.rst new file mode 100644 index 0000000..301c33b --- /dev/null +++ b/news/79.rst @@ -0,0 +1,25 @@ +**Added:** + +* Exposed `ReflectionProfile` methods (`GetProfile`, `GetFullProfileWidth`, `XMLOutput`, `XMLInput`) via Python bindings. Added unit tests. +* The binding to `ReflectionProfile.GetProfile(x, xcenter, h, k, l)` accepts python sequences / `numpy` arrays for the `x` argument, thanks to the helper function `assignCrystVector`. + +**Changed:** + +* None. + +**Deprecated:** + +* None. + +**Removed:** + +* None. + +**Fixed:** + +* Building with `pip install .` now uses `sysconfig` to locate `ObjCryst++`` libraries outside conda environments. +* Missing definition of `ScatteringData` in `powderpatterndiffraction_ext.ccp` + +**Security:** + +* None. diff --git a/setup.py b/setup.py index 243ed54..26c8f7d 100644 --- a/setup.py +++ b/setup.py @@ -10,6 +10,7 @@ import glob import os import sys +import sysconfig from ctypes.util import find_library from pathlib import Path @@ -21,7 +22,7 @@ def get_boost_libraries(): # the names we'll search for - major, minor = sys.version_info[:2] + major, minor = sys.version_info.major, sys.version_info.minor candidates = [ f"boost_python{major}{minor}", f"boost_python{major}", @@ -50,19 +51,22 @@ def get_boost_libraries(): def get_env_config(): conda_prefix = os.environ.get("CONDA_PREFIX") - if not conda_prefix: - raise EnvironmentError( - "CONDA_PREFIX environment variable is not set. " - "Please activate your conda environment before running setup.py." - ) - if os.name == "nt": - inc = Path(conda_prefix) / "Library" / "include" - lib = Path(conda_prefix) / "Library" / "lib" - else: - inc = Path(conda_prefix) / "include" - lib = Path(conda_prefix) / "lib" - - return {"include_dirs": [str(inc)], "library_dirs": [str(lib)]} + if conda_prefix: + if os.name == "nt": + inc = Path(conda_prefix) / "Library" / "include" + lib = Path(conda_prefix) / "Library" / "lib" + else: + inc = Path(conda_prefix) / "include" + lib = Path(conda_prefix) / "lib" + return {"include_dirs": [str(inc)], "library_dirs": [str(lib)]} + + # no conda env: fallback to system/venv Python include/lib dirs + py_inc = sysconfig.get_paths().get("include") + libdir = sysconfig.get_config_var("LIBDIR") or "/usr/lib" + return { + "include_dirs": [p for p in [py_inc] if p], + "library_dirs": [libdir], + } def create_extensions(): diff --git a/src/extensions/powderpatterndiffraction_ext.cpp b/src/extensions/powderpatterndiffraction_ext.cpp index dbb39ab..50ca04b 100644 --- a/src/extensions/powderpatterndiffraction_ext.cpp +++ b/src/extensions/powderpatterndiffraction_ext.cpp @@ -25,6 +25,7 @@ #include #include +#include namespace bp = boost::python; using namespace boost::python; diff --git a/src/extensions/reflectionprofile_ext.cpp b/src/extensions/reflectionprofile_ext.cpp index d6c7eb6..c734fbf 100644 --- a/src/extensions/reflectionprofile_ext.cpp +++ b/src/extensions/reflectionprofile_ext.cpp @@ -1,27 +1,29 @@ /***************************************************************************** -* -* pyobjcryst -* -* File coded by: Vincent Favre-Nicolin -* -* See AUTHORS.txt for a list of people who contributed. -* See LICENSE.txt for license information. -* -****************************************************************************** -* -* boost::python bindings to ObjCryst::ReflectionProfile. -* -* Changes from ObjCryst::ReflectionProfile -* -* Other Changes -* -*****************************************************************************/ + * + * pyobjcryst + * + * File coded by: Vincent Favre-Nicolin + * + * See AUTHORS.txt for a list of people who contributed. + * See LICENSE.txt for license information. + * + ****************************************************************************** + * + * boost::python bindings to ObjCryst::ReflectionProfile. + * + * Changes from ObjCryst::ReflectionProfile + * + * Other Changes + * + *****************************************************************************/ #include #include #include #undef B0 +#include "helpers.hpp" // assignCrystVector helper for numpy/sequence inputs + #include #include @@ -30,59 +32,93 @@ namespace bp = boost::python; using namespace boost::python; using namespace ObjCryst; -namespace { - -class ReflectionProfileWrap : - public ReflectionProfile, public wrapper +namespace { - public: + class ReflectionProfileWrap : public ReflectionProfile, public wrapper + { + public: // Pure virtual functions - ReflectionProfile* CreateCopy() const + ReflectionProfile *CreateCopy() const { return this->get_override("CreateCopy")(); } CrystVector_REAL GetProfile( - const CrystVector_REAL& x, const REAL xcenter, - const REAL h, const REAL k, const REAL l) const + const CrystVector_REAL &x, const REAL xcenter, + const REAL h, const REAL k, const REAL l) const { bp::override f = this->get_override("GetProfile"); return f(x, xcenter, h, k, l); } REAL GetFullProfileWidth( - const REAL relativeIntensity, const REAL xcenter, - const REAL h, const REAL k, const REAL l) + const REAL relativeIntensity, const REAL xcenter, + const REAL h, const REAL k, const REAL l) { bp::override f = this->get_override("GetFullProfileWidth"); return f(relativeIntensity, xcenter, h, k, l); } - void XMLOutput(ostream& os, int indent) const + void XMLOutput(ostream &os, int indent) const { bp::override f = this->get_override("XMLOutput"); f(os, indent); } - void XMLInput(istream& is, const XMLCrystTag& tag) + void XMLInput(istream &is, const XMLCrystTag &tag) { - bp::override f = this->get_override("GetProfile"); + bp::override f = this->get_override("XMLInput"); f(is, tag); } -}; + }; -} // namespace + // Accept python sequences/ndarrays for x and forward to the C++ API. + CrystVector_REAL _GetProfile( + const ReflectionProfile &rp, bp::object x, const REAL xcenter, + const REAL h, const REAL k, const REAL l) + { + CrystVector_REAL cvx; + assignCrystVector(cvx, x); + return rp.GetProfile(cvx, xcenter, h, k, l); + } +} // namespace void wrap_reflectionprofile() { class_, boost::noncopyable>( - "ReflectionProfile") - // TODO add pure_virtual bindings to the remaining public methods + "ReflectionProfile") .def("CreateCopy", - pure_virtual(&ReflectionProfile::CreateCopy), - return_value_policy()) - ; + pure_virtual(&ReflectionProfile::CreateCopy), + (return_value_policy()), + "Return a new ReflectionProfile instance copied from this one.") + // Two overloads for GetProfile: + // - Native CrystVector signature (for C++ callers / already-converted vectors). + // - Python-friendly wrapper that accepts sequences/ndarrays and converts them. + .def( + "GetProfile", + pure_virtual((CrystVector_REAL (ReflectionProfile::*)(const CrystVector_REAL &, REAL, REAL, REAL, REAL) const) & ReflectionProfile::GetProfile), + (bp::arg("x"), bp::arg("xcenter"), bp::arg("h"), + bp::arg("k"), bp::arg("l")), + "Compute the profile values at positions `x` for reflection (h, k, l) centered at `xcenter`.") + .def( + "GetProfile", &_GetProfile, + (bp::arg("x"), bp::arg("xcenter"), bp::arg("h"), bp::arg("k"), + bp::arg("l")), + "Compute the profile values at positions `x` (sequence/ndarray accepted) for reflection (h, k, l) centered at `xcenter`.") + .def("GetFullProfileWidth", + pure_virtual((REAL (ReflectionProfile::*)(const REAL, const REAL, const REAL, const REAL, const REAL) const) & ReflectionProfile::GetFullProfileWidth), + (bp::arg("relativeIntensity"), bp::arg("xcenter"), + bp::arg("h"), bp::arg("k"), bp::arg("l")), + "Return the full profile width at a given relative intensity for reflection (h, k, l) around `xcenter`.") + .def("XMLOutput", + pure_virtual((void (ReflectionProfile::*)(ostream &, int) const) & ReflectionProfile::XMLOutput), + (bp::arg("os"), bp::arg("indent")), + "Write this ReflectionProfile as XML to a file-like object. `indent` controls indentation depth.") + .def("XMLInput", + pure_virtual((void (ReflectionProfile::*)(istream &, const XMLCrystTag &))&ReflectionProfile::XMLInput), + (bp::arg("is"), bp::arg("tag")), + "Load ReflectionProfile parameters from an XML stream and tag."); } diff --git a/tests/test_reflectionprofile.py b/tests/test_reflectionprofile.py new file mode 100644 index 0000000..9ca604c --- /dev/null +++ b/tests/test_reflectionprofile.py @@ -0,0 +1,128 @@ +"""Unit tests for pyobjcryst.reflectionprofile bindings. + +TODO: +- ReflectionProfile.GetProfile +- ReflectionProfile.GetFullProfileWidth +- ReflectionProfile.XMLOutput / XMLInput +- ReflectionProfile.CreateCopy +""" + +import unittest + +import numpy as np +import pytest + +from pyobjcryst.powderpattern import PowderPattern +from pyobjcryst.refinableobj import RefinableObj + + +class TestReflectionProfile(unittest.TestCase): + """Tests for ReflectionProfile methods.""" + + @pytest.fixture(autouse=True) + def prepare_fixture(self, loadcifdata): + self.loadcifdata = loadcifdata + + def setUp(self): + """Set up a ReflectionProfile instance for testing.""" + x = np.linspace(0, 40, 1000) + c = self.loadcifdata("paracetamol.cif") + + self.pp = PowderPattern() + self.pp.SetWavelength(0.7) + self.pp.SetPowderPatternX(np.deg2rad(x)) + self.pp.SetPowderPatternObs(np.ones_like(x)) + + self.ppd = self.pp.AddPowderPatternDiffraction(c) + + self.profile = self.ppd.GetProfile() + + def test_get_computed_profile(self): + """Sample a profile slice and verify broadening lowers the peak + height.""" + x = self.pp.GetPowderPatternX() + hkl = (1, 0, 0) + window = x[100:200] + xcenter = float(window[len(window) // 2]) + + prof_default = self.profile.GetProfile(window, xcenter, *hkl) + self.assertEqual(len(prof_default), len(window)) + self.assertGreater(prof_default.max(), 0) + + # broaden and ensure the peak height drops while shape changes + self.profile.GetPar("W").SetValue(0.05) + prof_broader = self.profile.GetProfile(window, xcenter, *hkl) + + self.assertFalse(np.allclose(prof_default, prof_broader)) + self.assertLess(prof_broader.max(), prof_default.max()) + self.assertEqual(len(prof_default), len(prof_broader)) + + def test_get_profile_width(self): + """Ensure full-width increases when W increases.""" + xcenter = float( + self.pp.GetPowderPatternX()[len(self.pp.GetPowderPatternX()) // 4] + ) + width_default = self.profile.GetFullProfileWidth(0.5, xcenter, 1, 0, 0) + self.assertGreater(width_default, 0) + + self.profile.GetPar("W").SetValue(0.05) + width_broader = self.profile.GetFullProfileWidth(0.5, xcenter, 1, 0, 0) + self.assertGreater(width_broader, width_default) + + def test_create_copy(self): + """Ensure copy returns an independent profile with identical + initial params.""" + copy = self.profile.CreateCopy() + + self.assertIsNot(copy, self.profile) + self.assertEqual(copy.GetClassName(), self.profile.GetClassName()) + + eta0_original = self.profile.GetPar("Eta0").GetValue() + eta0_copy = copy.GetPar("Eta0").GetValue() + self.assertAlmostEqual(eta0_copy, eta0_original) + + self.profile.GetPar("Eta0").SetValue(eta0_original + 0.1) + copy.GetPar("Eta0").SetValue(eta0_copy + 0.2) + + self.assertAlmostEqual( + copy.GetPar("Eta0").GetValue(), eta0_original + 0.2 + ) + self.assertAlmostEqual( + self.profile.GetPar("Eta0").GetValue(), eta0_original + 0.1 + ) + + def test_xml_input(self): + """Ensure XMLInput restores parameters previously serialized + with xml().""" + xml_state = self.profile.xml() + eta0_original = self.profile.GetPar("Eta0").GetValue() + + self.profile.GetPar("Eta0").SetValue(eta0_original + 0.3) + self.assertNotAlmostEqual( + self.profile.GetPar("Eta0").GetValue(), eta0_original + ) + + RefinableObj.XMLInput(self.profile, xml_state) + self.assertAlmostEqual( + self.profile.GetPar("Eta0").GetValue(), eta0_original + ) + + def test_xml_output(self): + """Ensure XMLOutput emits parameter tags and the expected root + element.""" + xml_state = self.profile.xml() + + self.assertIn("