Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .codespell/ignore_words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ regist
;; src/pyobjcryst/crystal.py:548
;; alabelstyle parameter
inFront

;; tests/test_reflectionprofile.py: unittest assertions flagged as typos
assertin
25 changes: 25 additions & 0 deletions news/79.rst
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 18 additions & 14 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import glob
import os
import sys
import sysconfig
from ctypes.util import find_library
from pathlib import Path

Expand All @@ -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}",
Expand Down Expand Up @@ -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():
Expand Down
1 change: 1 addition & 0 deletions src/extensions/powderpatterndiffraction_ext.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

#include <ObjCryst/ObjCryst/General.h>
#include <ObjCryst/ObjCryst/PowderPattern.h>
#include <ObjCryst/ObjCryst/ScatteringData.h>

namespace bp = boost::python;
using namespace boost::python;
Expand Down
110 changes: 73 additions & 37 deletions src/extensions/reflectionprofile_ext.cpp
Original file line number Diff line number Diff line change
@@ -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 <boost/python/class.hpp>
#include <boost/python/manage_new_object.hpp>
#include <boost/python/pure_virtual.hpp>
#undef B0

#include "helpers.hpp" // assignCrystVector helper for numpy/sequence inputs

#include <iostream>

#include <ObjCryst/ObjCryst/ReflectionProfile.h>
Expand All @@ -30,59 +32,93 @@ namespace bp = boost::python;
using namespace boost::python;
using namespace ObjCryst;

namespace {

class ReflectionProfileWrap :
public ReflectionProfile, public wrapper<ReflectionProfile>
namespace
{
public:

class ReflectionProfileWrap : public ReflectionProfile, public wrapper<ReflectionProfile>
{
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_<ReflectionProfileWrap, bases<RefinableObj>, boost::noncopyable>(
"ReflectionProfile")
// TODO add pure_virtual bindings to the remaining public methods
"ReflectionProfile")
.def("CreateCopy",
pure_virtual(&ReflectionProfile::CreateCopy),
return_value_policy<manage_new_object>())
;
pure_virtual(&ReflectionProfile::CreateCopy),
(return_value_policy<manage_new_object>()),
"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.");
}
128 changes: 128 additions & 0 deletions tests/test_reflectionprofile.py
Original file line number Diff line number Diff line change
@@ -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("<ReflectionProfile", xml_state)
for par_name in ("U", "V", "W", "Eta0"):
self.assertIn(f'Name="{par_name}"', xml_state)

import io

buf = io.StringIO()
RefinableObj.XMLOutput(self.profile, buf, 0)
xml_from_stream = buf.getvalue()
self.assertTrue(xml_from_stream.startswith("<ReflectionProfile"))


if __name__ == "__main__":
unittest.main()