From a6099949c3cc68d1a69c93526f115788b795d50a Mon Sep 17 00:00:00 2001 From: Clemens Schmid Date: Wed, 26 Aug 2026 13:54:57 +0200 Subject: [PATCH 1/4] Add explicit reflection profile constructors and expose anisotropic PV through them --- news/expose-aniso-pv.rst | 25 +++++ .../powderpatterndiffraction_ext.cpp | 14 +++ src/extensions/reflectionprofile_ext.cpp | 45 ++++++++ src/pyobjcryst/reflectionprofile.py | 30 ++++- tests/test_reflectionprofile.py | 105 +++++++++++++++++- 5 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 news/expose-aniso-pv.rst diff --git a/news/expose-aniso-pv.rst b/news/expose-aniso-pv.rst new file mode 100644 index 0000000..7e89792 --- /dev/null +++ b/news/expose-aniso-pv.rst @@ -0,0 +1,25 @@ +**Added:** + +* Add explicitely constructable reflection profiles +* Expose objcryst's anisotropic Pseudo-Voigt profile +* Add PowderPatternDiffraction.SetProfile + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/extensions/powderpatterndiffraction_ext.cpp b/src/extensions/powderpatterndiffraction_ext.cpp index d29b822..def3afa 100644 --- a/src/extensions/powderpatterndiffraction_ext.cpp +++ b/src/extensions/powderpatterndiffraction_ext.cpp @@ -25,12 +25,24 @@ #include #include +#include #include namespace bp = boost::python; using namespace boost::python; using namespace ObjCryst; +namespace +{ + +void SetProfileCopy(PowderPatternDiffraction &pdiff, + const ReflectionProfile &profile) +{ + pdiff.SetProfile(profile.CreateCopy()); +} + +} // namespace + void wrap_powderpatterndiffraction() { @@ -60,6 +72,8 @@ void wrap_powderpatterndiffraction() (ReflectionProfile& (PowderPatternDiffraction::*)()) &PowderPatternDiffraction::GetProfile, return_internal_reference<>()) + .def("SetProfile", &SetProfileCopy, bp::arg("profile"), + "Install an independent copy of a reflection profile.") .def("SetExtractionMode", &PowderPatternDiffraction::SetExtractionMode, (bp::arg("extract")=true, bp::arg("init")=false)) diff --git a/src/extensions/reflectionprofile_ext.cpp b/src/extensions/reflectionprofile_ext.cpp index c734fbf..e72bc26 100644 --- a/src/extensions/reflectionprofile_ext.cpp +++ b/src/extensions/reflectionprofile_ext.cpp @@ -17,7 +17,10 @@ * *****************************************************************************/ +#define BOOST_PYTHON_MAX_ARITY 20 + #include +#include #include #include #undef B0 @@ -113,6 +116,8 @@ void wrap_reflectionprofile() (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("IsAnisotropic", &ReflectionProfile::IsAnisotropic, + "Return whether the profile depends on the reflection indices.") .def("XMLOutput", pure_virtual((void (ReflectionProfile::*)(ostream &, int) const) & ReflectionProfile::XMLOutput), (bp::arg("os"), bp::arg("indent")), @@ -121,4 +126,44 @@ void wrap_reflectionprofile() pure_virtual((void (ReflectionProfile::*)(istream &, const XMLCrystTag &))&ReflectionProfile::XMLInput), (bp::arg("is"), bp::arg("tag")), "Load ReflectionProfile parameters from an XML stream and tag."); + + class_ >( + "ReflectionProfilePseudoVoigt", init<>()) + .def(init(bp::arg("old"))) + .def("CreateCopy", &ReflectionProfilePseudoVoigt::CreateCopy, + return_value_policy(), + "Return an independent copy of this profile.") + .def("SetProfilePar", &ReflectionProfilePseudoVoigt::SetProfilePar, + (bp::arg("fwhmCagliotiW"), bp::arg("fwhmCagliotiU") = 0, + bp::arg("fwhmCagliotiV") = 0, bp::arg("eta0") = 0.5, + bp::arg("eta1") = 0), + "Set the isotropic pseudo-Voigt profile parameters."); + + class_ >( + "ReflectionProfilePseudoVoigtAnisotropic", init<>()) + .def(init( + bp::arg("old"))) + .def("CreateCopy", + &ReflectionProfilePseudoVoigtAnisotropic::CreateCopy, + return_value_policy(), + "Return an independent copy of this profile.") + .def( + "SetProfilePar", + &ReflectionProfilePseudoVoigtAnisotropic::SetProfilePar, + (bp::arg("fwhmCagliotiW"), bp::arg("fwhmCagliotiU") = 0, + bp::arg("fwhmCagliotiV") = 0, bp::arg("fwhmGaussP") = 0, + bp::arg("fwhmLorentzX") = 0, + bp::arg("fwhmLorentzY") = 0, + bp::arg("fwhmLorentzGammaHH") = 0, + bp::arg("fwhmLorentzGammaKK") = 0, + bp::arg("fwhmLorentzGammaLL") = 0, + bp::arg("fwhmLorentzGammaHK") = 0, + bp::arg("fwhmLorentzGammaHL") = 0, + bp::arg("fwhmLorentzGammaKL") = 0, + bp::arg("pseudoVoigtEta0") = 0, + bp::arg("pseudoVoigtEta1") = 0, + bp::arg("asymA0") = 1, bp::arg("asymA1") = 0, + bp::arg("asymA2") = 0), + "Set the anisotropic pseudo-Voigt profile parameters."); } diff --git a/src/pyobjcryst/reflectionprofile.py b/src/pyobjcryst/reflectionprofile.py index c749b57..c88f4b6 100644 --- a/src/pyobjcryst/reflectionprofile.py +++ b/src/pyobjcryst/reflectionprofile.py @@ -20,6 +20,22 @@ ``XMLOutput`` / ``XMLInput`` and ``CreateCopy``. ``GetProfile`` accepts a Python sequence or numpy array for ``x``. +Concrete isotropic and anisotropic pseudo-Voigt profiles can be installed +with ``PowderPatternDiffraction.SetProfile``. The diffraction component +stores an independent copy, so one configured profile can be reused as a +template for multiple phases:: + + from pyobjcryst.reflectionprofile import ( + ReflectionProfilePseudoVoigtAnisotropic, + ) + + profile = ReflectionProfilePseudoVoigtAnisotropic() + profile.GetPar("W").SetValue(1e-6) + profile.GetPar("G_HH").SetValue(2e-6) + + for pdiff in powder_pattern.get_crystalline_components(): + pdiff.SetProfile(profile) + Example ------- @@ -48,6 +64,16 @@ y_broader = profile.GetProfile(window, xcenter, 1, 0, 0) """ -__all__ = ["ReflectionProfile", "ReflectionProfileType"] +__all__ = [ + "ReflectionProfile", + "ReflectionProfilePseudoVoigt", + "ReflectionProfilePseudoVoigtAnisotropic", + "ReflectionProfileType", +] -from pyobjcryst._pyobjcryst import ReflectionProfile, ReflectionProfileType +from pyobjcryst._pyobjcryst import ( + ReflectionProfile, + ReflectionProfilePseudoVoigt, + ReflectionProfilePseudoVoigtAnisotropic, + ReflectionProfileType, +) diff --git a/tests/test_reflectionprofile.py b/tests/test_reflectionprofile.py index a826e51..2a32540 100644 --- a/tests/test_reflectionprofile.py +++ b/tests/test_reflectionprofile.py @@ -7,6 +7,11 @@ from pyobjcryst.powderpattern import PowderPattern from pyobjcryst.refinableobj import RefinableObj +from pyobjcryst.reflectionprofile import ( + ReflectionProfile, + ReflectionProfilePseudoVoigt, + ReflectionProfilePseudoVoigtAnisotropic, +) class TestReflectionProfile(unittest.TestCase): @@ -19,17 +24,113 @@ def prepare_fixture(self, loadcifdata): def setUp(self): """Set up a ReflectionProfile instance for testing.""" x = np.linspace(0, 40, 1000) - c = self.loadcifdata("paracetamol.cif") + self.crystal = 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.ppd = self.pp.AddPowderPatternDiffraction(self.crystal) self.profile = self.ppd.GetProfile() + def test_concrete_pseudo_voigt_profiles(self): + """Concrete isotropic and anisotropic profiles are constructible.""" + isotropic = ReflectionProfilePseudoVoigt() + anisotropic = ReflectionProfilePseudoVoigtAnisotropic() + + self.assertIsInstance(isotropic, ReflectionProfile) + self.assertIsInstance(anisotropic, ReflectionProfile) + self.assertFalse(isotropic.IsAnisotropic()) + self.assertTrue(anisotropic.IsAnisotropic()) + + expected = { + "U", + "V", + "W", + "P", + "X", + "Y", + "G_HH", + "G_KK", + "G_LL", + "G_HK", + "G_HL", + "G_KL", + "Eta0", + "Eta1", + "Asym0", + "Asym1", + "Asym2", + } + self.assertEqual( + expected, + { + anisotropic.GetPar(i).GetName() + for i in range(anisotropic.GetNbPar()) + }, + ) + + def test_anisotropic_set_profile_par(self): + """SetProfilePar assigns P and retains symmetric default asymmetry.""" + profile = ReflectionProfilePseudoVoigtAnisotropic() + profile.SetProfilePar(1e-6, fwhmGaussP=2e-6) + + self.assertAlmostEqual(profile.GetPar("W").GetValue(), 1e-6) + self.assertAlmostEqual(profile.GetPar("P").GetValue(), 2e-6) + self.assertAlmostEqual(profile.GetPar("Asym0").GetValue(), 1.0) + + def test_anisotropic_profile_depends_on_hkl(self): + """Anisotropic Lorentz coefficients produce direction dependence.""" + profile = ReflectionProfilePseudoVoigtAnisotropic() + profile.SetProfilePar( + 1e-6, + fwhmLorentzGammaHH=1e-3, + fwhmLorentzGammaKK=2e-3, + pseudoVoigtEta0=1, + ) + center = 0.5 + x = np.linspace(center - 0.1, center + 0.1, 401) + + h00 = profile.GetProfile(x, center, 1, 0, 0) + zero_k0 = profile.GetProfile(x, center, 0, 1, 0) + + self.assertFalse(np.allclose(h00, zero_k0)) + + def test_set_profile_copies_reusable_template(self): + """One profile template can safely initialize multiple phases.""" + second = self.pp.AddPowderPatternDiffraction(self.crystal) + template = ReflectionProfilePseudoVoigtAnisotropic() + template.GetPar("W").SetValue(1e-6) + template.GetPar("G_HH").SetValue(2e-6) + + for pdiff in (self.ppd, second): + pdiff.SetProfile(template) + + first_profile = self.ppd.GetProfile() + second_profile = second.GetProfile() + self.assertIsInstance( + first_profile, ReflectionProfilePseudoVoigtAnisotropic + ) + self.assertIsInstance( + second_profile, ReflectionProfilePseudoVoigtAnisotropic + ) + self.assertAlmostEqual(first_profile.GetPar("W").GetValue(), 1e-6) + self.assertAlmostEqual(second_profile.GetPar("G_HH").GetValue(), 2e-6) + + first_profile.GetPar("W").SetValue(3e-6) + self.assertAlmostEqual(template.GetPar("W").GetValue(), 1e-6) + self.assertAlmostEqual(second_profile.GetPar("W").GetValue(), 1e-6) + + def test_set_profile_accepts_temporary(self): + """A temporary concrete profile is safely copied into the phase.""" + self.ppd.SetProfile(ReflectionProfilePseudoVoigt()) + profile = self.ppd.GetProfile() + + self.assertIsInstance(profile, ReflectionProfilePseudoVoigt) + self.assertFalse(profile.IsAnisotropic()) + def test_get_computed_profile(self): """Sample a profile slice and verify broadening lowers the peak height.""" From c0875e79a5d014cd6770f4ff1efff74ae274b0d2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:15:10 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit hooks --- tests/test_reflectionprofile.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/test_reflectionprofile.py b/tests/test_reflectionprofile.py index 2a32540..c58067e 100644 --- a/tests/test_reflectionprofile.py +++ b/tests/test_reflectionprofile.py @@ -36,7 +36,8 @@ def setUp(self): self.profile = self.ppd.GetProfile() def test_concrete_pseudo_voigt_profiles(self): - """Concrete isotropic and anisotropic profiles are constructible.""" + """Concrete isotropic and anisotropic profiles are + constructible.""" isotropic = ReflectionProfilePseudoVoigt() anisotropic = ReflectionProfilePseudoVoigtAnisotropic() @@ -73,7 +74,8 @@ def test_concrete_pseudo_voigt_profiles(self): ) def test_anisotropic_set_profile_par(self): - """SetProfilePar assigns P and retains symmetric default asymmetry.""" + """SetProfilePar assigns P and retains symmetric default + asymmetry.""" profile = ReflectionProfilePseudoVoigtAnisotropic() profile.SetProfilePar(1e-6, fwhmGaussP=2e-6) @@ -82,7 +84,8 @@ def test_anisotropic_set_profile_par(self): self.assertAlmostEqual(profile.GetPar("Asym0").GetValue(), 1.0) def test_anisotropic_profile_depends_on_hkl(self): - """Anisotropic Lorentz coefficients produce direction dependence.""" + """Anisotropic Lorentz coefficients produce direction + dependence.""" profile = ReflectionProfilePseudoVoigtAnisotropic() profile.SetProfilePar( 1e-6, @@ -99,7 +102,8 @@ def test_anisotropic_profile_depends_on_hkl(self): self.assertFalse(np.allclose(h00, zero_k0)) def test_set_profile_copies_reusable_template(self): - """One profile template can safely initialize multiple phases.""" + """One profile template can safely initialize multiple + phases.""" second = self.pp.AddPowderPatternDiffraction(self.crystal) template = ReflectionProfilePseudoVoigtAnisotropic() template.GetPar("W").SetValue(1e-6) @@ -124,7 +128,8 @@ def test_set_profile_copies_reusable_template(self): self.assertAlmostEqual(second_profile.GetPar("W").GetValue(), 1e-6) def test_set_profile_accepts_temporary(self): - """A temporary concrete profile is safely copied into the phase.""" + """A temporary concrete profile is safely copied into the + phase.""" self.ppd.SetProfile(ReflectionProfilePseudoVoigt()) profile = self.ppd.GetProfile() From eaa93fa206f99bb937e62f8718347c8eea178920 Mon Sep 17 00:00:00 2001 From: Clemens Schmid Date: Wed, 26 Aug 2026 14:16:49 +0200 Subject: [PATCH 3/4] fix news typo --- news/expose-aniso-pv.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/news/expose-aniso-pv.rst b/news/expose-aniso-pv.rst index 7e89792..9e3f099 100644 --- a/news/expose-aniso-pv.rst +++ b/news/expose-aniso-pv.rst @@ -1,6 +1,6 @@ **Added:** -* Add explicitely constructable reflection profiles +* Add explicitly constructable reflection profiles * Expose objcryst's anisotropic Pseudo-Voigt profile * Add PowderPatternDiffraction.SetProfile From c568b92490deb46ab4d57090cb93b0ff45ed81a5 Mon Sep 17 00:00:00 2001 From: Clemens Schmid Date: Wed, 26 Aug 2026 14:26:56 +0200 Subject: [PATCH 4/4] Adapt test for aniso PV --- tests/test_reflectionprofile.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_reflectionprofile.py b/tests/test_reflectionprofile.py index c58067e..c304e44 100644 --- a/tests/test_reflectionprofile.py +++ b/tests/test_reflectionprofile.py @@ -74,13 +74,15 @@ def test_concrete_pseudo_voigt_profiles(self): ) def test_anisotropic_set_profile_par(self): - """SetProfilePar assigns P and retains symmetric default + """SetProfilePar assigns widths and retains symmetric default asymmetry.""" profile = ReflectionProfilePseudoVoigtAnisotropic() - profile.SetProfilePar(1e-6, fwhmGaussP=2e-6) + profile.SetProfilePar(1e-6, fwhmLorentzX=2e-6) + profile.GetPar("P").SetValue(3e-6) self.assertAlmostEqual(profile.GetPar("W").GetValue(), 1e-6) - self.assertAlmostEqual(profile.GetPar("P").GetValue(), 2e-6) + self.assertAlmostEqual(profile.GetPar("X").GetValue(), 2e-6) + self.assertAlmostEqual(profile.GetPar("P").GetValue(), 3e-6) self.assertAlmostEqual(profile.GetPar("Asym0").GetValue(), 1.0) def test_anisotropic_profile_depends_on_hkl(self):