diff --git a/CODE-OF-CONDUCT.rst b/CODE-OF-CONDUCT.rst
index d0115c8..ee7326f 100644
--- a/CODE-OF-CONDUCT.rst
+++ b/CODE-OF-CONDUCT.rst
@@ -67,7 +67,7 @@ Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
-sbillinge@ucsb.edu and cadenmyers13@columbia.edu. All complaints will be reviewed and investigated promptly and fairly.
+sbillinge@ucsb.edu and cjm2304@columbia.edu. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
diff --git a/README.rst b/README.rst
index b145c35..9522d27 100644
--- a/README.rst
+++ b/README.rst
@@ -129,7 +129,7 @@ Before contributing, please read our `Code of Conduct `_ or email the maintainers ``Simon Billinge(sbillinge@ucsb.edu) and Caden Myers(cadenmyers13@columbia.edu)``.
+For more information on diffpy.cmirietveld please visit the project `web-page `_ or email the maintainers ``Simon Billinge(sbillinge@ucsb.edu) and Caden Myers(cjm2304@columbia.edu)``.
Acknowledgements
----------------
diff --git a/cookiecutter.json b/cookiecutter.json
index 3d0def0..80f07a4 100644
--- a/cookiecutter.json
+++ b/cookiecutter.json
@@ -1,8 +1,8 @@
{
"author_names": "Simon Billinge, Caden Myers",
- "author_emails": "sbillinge@ucsb.edu, cadenmyers13@columbia.edu",
+ "author_emails": "sbillinge@ucsb.edu, cjm2304@columbia.edu",
"maintainer_names": "Simon Billinge, Caden Myers",
- "maintainer_emails": "sbillinge@ucsb.edu, cadenmyers13@columbia.edu",
+ "maintainer_emails": "sbillinge@ucsb.edu, cjm2304@columbia.edu",
"maintainer_github_usernames": "sbillinge",
"contributors": "Caden Myers, members of the Billinge group, and contributors to the diffpy.cmirietveld project",
"project_name": "diffpy.cmirietveld",
diff --git a/news/component-parameters.rst b/news/component-parameters.rst
new file mode 100644
index 0000000..a0489b6
--- /dev/null
+++ b/news/component-parameters.rst
@@ -0,0 +1,23 @@
+**Added:**
+
+* Add ``Component.create_parameters_from``, which creates a component's Parameters from a pyobjcryst model, with the model's default values and its limits as bounds, and ``Component.push_parameters``, which writes the values back and raises a ``ValueError`` for a value outside the limits instead of letting ObjCryst clamp it silently.
+
+**Changed:**
+
+*
+
+**Deprecated:**
+
+*
+
+**Removed:**
+
+*
+
+**Fixed:**
+
+*
+
+**Security:**
+
+*
diff --git a/news/peak-profiles.rst b/news/peak-profiles.rst
new file mode 100644
index 0000000..95fdb42
--- /dev/null
+++ b/news/peak-profiles.rst
@@ -0,0 +1,23 @@
+**Added:**
+
+* Add the ``PseudoVoigtProfile`` and ``TCHProfile`` peak profile components, which create their Parameters from pyobjcryst's ``ReflectionProfilePseudoVoigt`` and ``ReflectionProfilePseudoVoigtTCH`` and configure a pyobjcryst powder pattern with ``install`` and ``update``.
+
+**Changed:**
+
+*
+
+**Deprecated:**
+
+*
+
+**Removed:**
+
+*
+
+**Fixed:**
+
+*
+
+**Security:**
+
+*
diff --git a/pyproject.toml b/pyproject.toml
index 741f640..52491de 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,11 +7,11 @@ name = "diffpy.cmirietveld"
dynamic=['version', 'dependencies']
authors = [
{name='Simon Billinge', email='sbillinge@ucsb.edu'},
- {name='Caden Myers', email='cadenmyers13@columbia.edu'},
+ {name='Caden Myers', email='cjm2304@columbia.edu'},
]
maintainers = [
{name='Simon Billinge', email='sbillinge@ucsb.edu'},
- {name='Caden Myers', email='cadenmyers13@columbia.edu'},
+ {name='Caden Myers', email='cjm2304@columbia.edu'},
]
description = "A pack for diffpy.cmi that allows it to do Rietveld refinement of powder diffraction data"
keywords = ['diffraction', 'PDF', 'X-ray', 'neutron', 'Rietveld']
diff --git a/requirements/conda.txt b/requirements/conda.txt
index eacb3e1..f69c4a0 100644
--- a/requirements/conda.txt
+++ b/requirements/conda.txt
@@ -1,2 +1,3 @@
numpy
diffpy.srfit
+pyobjcryst
diff --git a/requirements/pip.txt b/requirements/pip.txt
index eacb3e1..f69c4a0 100644
--- a/requirements/pip.txt
+++ b/requirements/pip.txt
@@ -1,2 +1,3 @@
numpy
diffpy.srfit
+pyobjcryst
diff --git a/src/diffpy/cmirietveld/component.py b/src/diffpy/cmirietveld/component.py
index fbab91c..de5cb74 100644
--- a/src/diffpy/cmirietveld/component.py
+++ b/src/diffpy/cmirietveld/component.py
@@ -22,6 +22,7 @@
__all__ = ["Component"]
+from diffpy.srfit.fitbase.parameter import Parameter
from diffpy.srfit.fitbase.parameterset import ParameterSet
@@ -38,6 +39,10 @@ class Component(ParameterSet):
The name of the component. It must be a valid attribute name.
"""
+ def __init__(self, name):
+ ParameterSet.__init__(self, name)
+ self._refinable_par_names = {}
+
def install(self, pattern, diffraction):
"""Set the model up in one generator.
@@ -73,20 +78,81 @@ def create_parameters_from(self, refinable_obj, names):
model.
Each Parameter takes the default value of the matching
- ``RefinablePar`` and its limits as bounds.
+ ``RefinablePar`` and its limits as bounds. ObjCryst silently clamps
+ a value outside its limits, so the bounds keep a refinement within
+ the values ObjCryst actually uses.
+
+ The Parameter names are mapped to the ``RefinablePar`` names
+ rather than copied, so the names users see are this package's,
+ independent of ObjCryst's. Some ObjCryst names are not valid
+ Parameter names (``2ThetaDispl``). The mapping keeps ObjCryst
+ parameters out of refinement scripts. The mapping is stored, so
+ ``push_parameters`` writes each value back to the same
+ ``RefinablePar``.
Parameters
----------
refinable_obj : pyobjcryst.refinableobj.RefinableObj
A fresh instance of the pyobjcryst model.
names : dict of str to str
- The ``RefinablePar`` name for each Parameter name.
+ The ``RefinablePar`` name for each Parameter name, for example
+ ``{"eta0": "Eta0"}``. Only the named ``RefinablePar``s get a
+ Parameter.
+
+ Raises
+ ------
+ ValueError
+ If the model has no ``RefinablePar`` with one of the names.
+
+ Examples
+ --------
+ In the ``__init__`` of a peak profile:
+
+ >>> self.create_parameters_from(
+ ... ReflectionProfilePseudoVoigt(),
+ ... {"U": "U", "V": "V", "W": "W", "eta0": "Eta0"},
+ ... )
"""
- raise NotImplementedError
+ available_names = [
+ refinable_obj.GetPar(index).GetName()
+ for index in range(refinable_obj.GetNbPar())
+ ]
+ for refinable_par_name in names.values():
+ if refinable_par_name not in available_names:
+ raise ValueError(
+ f"{type(refinable_obj).__name__} has no refinable "
+ f"parameter named '{refinable_par_name}'. Please use one "
+ f"of its parameter names: {', '.join(available_names)}."
+ )
+ for parameter_name, refinable_par_name in names.items():
+ refinable_par = refinable_obj.GetPar(refinable_par_name)
+ parameter = Parameter(
+ parameter_name, refinable_par.GetHumanValue()
+ )
+ if refinable_par.IsLimited():
+ parameter.bound_range(
+ refinable_par.GetHumanMin(), refinable_par.GetHumanMax()
+ )
+ self.add_parameter(parameter)
+ self._refinable_par_names.update(names)
def push_parameters(self, refinable_obj):
"""Push the Parameter values into a pyobjcryst model.
+ Each value is written to the ``RefinablePar`` that
+ ``create_parameters_from`` mapped it to.
+
+ A value outside the limits of its ``RefinablePar`` raises an error
+ instead of being clamped, because ObjCryst would otherwise use a
+ different value from the one the Parameter reports. The check uses
+ ObjCryst's limits, not the Parameter's bounds: a value outside
+ narrowed bounds is still pushed, and widened bounds don't let a
+ value past a limit. An optimizer run without the recipe's bounds
+ stops with this error the first time it steps past a limit.
+
+ All the values are checked before any is pushed, so an error
+ leaves the model unchanged rather than half updated.
+
Parameters
----------
refinable_obj : pyobjcryst.refinableobj.RefinableObj
@@ -95,9 +161,38 @@ def push_parameters(self, refinable_obj):
Raises
------
ValueError
- If a Parameter value is outside its bounds.
+ If a Parameter value is outside the limits of its
+ ``RefinablePar``.
"""
- raise NotImplementedError
+ values = {
+ parameter_name: self.get(parameter_name).value
+ for parameter_name in self._refinable_par_names
+ }
+ refinable_pars = {
+ parameter_name: refinable_obj.GetPar(refinable_par_name)
+ for parameter_name, refinable_par_name in (
+ self._refinable_par_names.items()
+ )
+ }
+ for parameter_name, refinable_par in refinable_pars.items():
+ if not refinable_par.IsLimited():
+ continue
+ value = values[parameter_name]
+ lower_limit = refinable_par.GetHumanMin()
+ upper_limit = refinable_par.GetHumanMax()
+ if not lower_limit <= value <= upper_limit:
+ used_value = min(max(value, lower_limit), upper_limit)
+ raise ValueError(
+ f"The value of '{parameter_name}', {value}, is outside "
+ f"its limits [{lower_limit}, {upper_limit}]. ObjCryst "
+ f"would silently use {used_value} instead, so the refined "
+ "value would not be the one used to calculate the "
+ "pattern. Please keep the value within the limits, for "
+ "example by passing the recipe's bounds to the optimizer: "
+ "least_squares(..., bounds=recipe.get_bounds_array())."
+ )
+ for parameter_name, refinable_par in refinable_pars.items():
+ refinable_par.SetHumanValue(values[parameter_name])
# End of file
diff --git a/src/diffpy/cmirietveld/profiles.py b/src/diffpy/cmirietveld/profiles.py
index 37930a7..402dcb1 100644
--- a/src/diffpy/cmirietveld/profiles.py
+++ b/src/diffpy/cmirietveld/profiles.py
@@ -21,6 +21,11 @@
"BackToBackExponentialProfile",
]
+from pyobjcryst.reflectionprofile import (
+ ReflectionProfilePseudoVoigt,
+ ReflectionProfilePseudoVoigtTCH,
+)
+
from diffpy.cmirietveld.component import Component
@@ -52,6 +57,62 @@ class PseudoVoigtProfile(PeakProfile):
The peak asymmetry terms.
"""
+ def __init__(self):
+ PeakProfile.__init__(self)
+ self.create_parameters_from(
+ ReflectionProfilePseudoVoigt(),
+ {
+ "U": "U",
+ "V": "V",
+ "W": "W",
+ "eta0": "Eta0",
+ "eta1": "Eta1",
+ "asym0": "Asym0",
+ "asym1": "Asym1",
+ "asym2": "Asym2",
+ },
+ )
+
+ def install(self, pattern, diffraction):
+ """Install a fresh pyobjcryst pseudo-Voigt profile in one
+ generator.
+
+ The diffraction component gets a new
+ ``ReflectionProfilePseudoVoigt``, replacing the profile installed
+ before. Its values are set by ``update``.
+
+ Parameters
+ ----------
+ pattern : pyobjcryst.powderpattern.PowderPattern
+ The pattern of the generator.
+ diffraction : pyobjcryst.powderpattern.PowderPatternDiffraction
+ The diffraction component of the generator's structure.
+ """
+ diffraction.SetProfile(ReflectionProfilePseudoVoigt())
+
+ def update(self, pattern, diffraction):
+ """Push the Parameter values into the installed pseudo-Voigt
+ profile.
+
+ ``SetProfile`` installs a copy, so the values are written into
+ ``diffraction.GetProfile()``, not into the profile given to
+ ``SetProfile``.
+
+ Parameters
+ ----------
+ pattern : pyobjcryst.powderpattern.PowderPattern
+ The pattern of the generator.
+ diffraction : pyobjcryst.powderpattern.PowderPatternDiffraction
+ The diffraction component of the generator's structure.
+
+ Raises
+ ------
+ ValueError
+ If a Parameter value is outside the limits of its
+ ``RefinablePar``.
+ """
+ self.push_parameters(diffraction.GetProfile())
+
class TCHProfile(PeakProfile):
"""Symmetric Thompson-Cox-Hastings pseudo-Voigt peak profile.
@@ -71,6 +132,60 @@ class TCHProfile(PeakProfile):
The mixing of the Gaussian and Lorentzian size broadening.
"""
+ def __init__(self):
+ PeakProfile.__init__(self)
+ self.create_parameters_from(
+ ReflectionProfilePseudoVoigtTCH(),
+ {
+ "U": "U",
+ "V": "V",
+ "W": "W",
+ "X": "X",
+ "Y": "Y",
+ "Z": "Z",
+ "P": "P",
+ "LGmix": "LGmix",
+ },
+ )
+
+ def install(self, pattern, diffraction):
+ """Install a fresh pyobjcryst TCH profile in one generator.
+
+ The diffraction component gets a new
+ ``ReflectionProfilePseudoVoigtTCH``, replacing the profile installed
+ before. Its values are set by ``update``.
+
+ Parameters
+ ----------
+ pattern : pyobjcryst.powderpattern.PowderPattern
+ The pattern of the generator.
+ diffraction : pyobjcryst.powderpattern.PowderPatternDiffraction
+ The diffraction component of the generator's structure.
+ """
+ diffraction.SetProfile(ReflectionProfilePseudoVoigtTCH())
+
+ def update(self, pattern, diffraction):
+ """Push the Parameter values into the installed TCH profile.
+
+ ``SetProfile`` installs a copy, so the values are written into
+ ``diffraction.GetProfile()``, not into the profile given to
+ ``SetProfile``.
+
+ Parameters
+ ----------
+ pattern : pyobjcryst.powderpattern.PowderPattern
+ The pattern of the generator.
+ diffraction : pyobjcryst.powderpattern.PowderPatternDiffraction
+ The diffraction component of the generator's structure.
+
+ Raises
+ ------
+ ValueError
+ If a Parameter value is outside the limits of its
+ ``RefinablePar``.
+ """
+ self.push_parameters(diffraction.GetProfile())
+
class BackToBackExponentialProfile(PeakProfile):
"""Peak profile of a time-of-flight pattern.
diff --git a/tests/conftest.py b/tests/conftest.py
index e3b6313..8f5667c 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,7 +1,67 @@
import json
from pathlib import Path
+import numpy as np
import pytest
+from pyobjcryst.crystal import create_crystal_from_cif
+from pyobjcryst.powderpattern import PowderPattern
+from pyobjcryst.reflectionprofile import ReflectionProfilePseudoVoigt
+
+from diffpy.cmirietveld.component import Component
+
+TESTDATA_DIR = Path(__file__).parent / "testdata"
+PSEUDO_VOIGT_PARAMETER_NAMES = {
+ "U": "U",
+ "V": "V",
+ "W": "W",
+ "eta0": "Eta0",
+ "eta1": "Eta1",
+ "asym0": "Asym0",
+ "asym1": "Asym1",
+ "asym2": "Asym2",
+}
+
+
+class PbSO4Pattern:
+ """A plain pyobjcryst pattern for PbSO4, on a 16-110 degree grid at
+ 1.5406 angstroms, with its diffraction component."""
+
+ def __init__(self):
+ crystal = create_crystal_from_cif(
+ str(TESTDATA_DIR / "PbSO4-Wyckoff.cif")
+ )
+ tth = np.arange(16.0, 110.0, 0.025)
+ self.pattern = PowderPattern()
+ self.pattern.SetWavelength(1.5406)
+ self.pattern.SetPowderPatternX(np.radians(tth))
+ self.pattern.SetPowderPatternObs(np.zeros_like(tth))
+ self.diffraction = self.pattern.AddPowderPatternDiffraction(crystal)
+
+ def calculate(self):
+ self.pattern.Prepare()
+ calculated_pattern = np.array(self.pattern.GetPowderPatternCalc())
+ return calculated_pattern
+
+
+@pytest.fixture
+def pbso4_pattern():
+ pbso4_pattern = PbSO4Pattern()
+ return pbso4_pattern
+
+
+@pytest.fixture
+def other_pbso4_pattern():
+ other_pbso4_pattern = PbSO4Pattern()
+ return other_pbso4_pattern
+
+
+@pytest.fixture
+def pseudo_voigt_component():
+ component = Component("peak_profile")
+ component.create_parameters_from(
+ ReflectionProfilePseudoVoigt(), PSEUDO_VOIGT_PARAMETER_NAMES
+ )
+ return component
@pytest.fixture
diff --git a/tests/test_component.py b/tests/test_component.py
new file mode 100644
index 0000000..cf155ad
--- /dev/null
+++ b/tests/test_component.py
@@ -0,0 +1,192 @@
+import re
+
+import pytest
+from pyobjcryst.reflectionprofile import ReflectionProfilePseudoVoigt
+
+from diffpy.cmirietveld.component import Component
+
+
+def test_create_parameters_from_names(pseudo_voigt_component):
+ # Test that each name in the mapping becomes a Parameter
+ # Expected: The component holds one Parameter per name, and no others.
+ actual_names = sorted(pseudo_voigt_component.get_names())
+ expected_names = [
+ "U",
+ "V",
+ "W",
+ "asym0",
+ "asym1",
+ "asym2",
+ "eta0",
+ "eta1",
+ ]
+ assert actual_names == expected_names
+
+
+@pytest.mark.parametrize(
+ "parameter_name, expected_value, expected_bounds",
+ [
+ # Test that each Parameter takes the RefinablePar's human value
+ # and limits
+ # C1: The Caglioti W term is created from the pseudo-Voigt profile.
+ # Expected: Its value is ObjCryst's default, 0.01 deg^2, and its
+ # bounds are ObjCryst's limits, [0, 1].
+ ("W", 0.01, [0.0, 1.0]),
+ # C2: The Caglioti U term, which can be negative, is created.
+ # Expected: Its value is 0 and its bounds are [-1, 1].
+ ("U", 0.0, [-1.0, 1.0]),
+ # C3: The mixing term eta0 is created under a name that differs
+ # from its RefinablePar name, Eta0.
+ # Expected: Its value is 0.5 and its bounds are [0, 1].
+ ("eta0", 0.5, [0.0, 1.0]),
+ # C4: The asymmetry term asym0, whose lower limit is not zero, is
+ # created.
+ # Expected: Its value is 1 and its bounds are [0.01, 10].
+ ("asym0", 1.0, [0.01, 10.0]),
+ ],
+)
+def test_create_parameters_from_values_and_bounds(
+ pseudo_voigt_component, parameter_name, expected_value, expected_bounds
+):
+ parameter = pseudo_voigt_component.get(parameter_name)
+ actual_value = parameter.value
+ actual_bounds = list(parameter.bounds)
+ assert actual_value == pytest.approx(expected_value)
+ assert actual_bounds == expected_bounds
+
+
+def test_create_parameters_from_unknown_name():
+ # Test creating a Parameter from a RefinablePar the model doesn't have
+ # Expected: A ValueError names the missing RefinablePar and lists the
+ # names the model has.
+ component = Component("peak_profile")
+ expected_msg = (
+ "ReflectionProfilePseudoVoigt has no refinable parameter named "
+ "'nonexistent_name'. Please use one of its parameter names: U, V, "
+ "W, Eta0, Eta1, Asym0, Asym1, Asym2."
+ )
+ bad_input = {"any_name": "nonexistent_name"}
+ with pytest.raises(ValueError, match=re.escape(expected_msg)):
+ component.create_parameters_from(
+ ReflectionProfilePseudoVoigt(), bad_input
+ )
+
+
+@pytest.mark.parametrize(
+ "parameter_name, pyobjcryst_par_name, new_value",
+ [
+ # Test that Parameter values are pushed into a pyobjcryst model
+ # other than the one the Parameters were created from
+ # C1: The Caglioti W term is set inside its limits.
+ # Expected: The W RefinablePar has the new value.
+ ("W", "W", 0.05),
+ # C2: The mixing term eta0 is set under a name that differs from
+ # its RefinablePar name.
+ # Expected: The Eta0 RefinablePar has the new value.
+ ("eta0", "Eta0", 0.3),
+ # C3: The asymmetry term asym0 is set exactly at its upper limit.
+ # Expected: The Asym0 RefinablePar has the new value, because a
+ # value at a limit is inside the limits.
+ ("asym0", "Asym0", 10.0),
+ ],
+)
+def test_push_parameters(
+ pseudo_voigt_component, parameter_name, pyobjcryst_par_name, new_value
+):
+ installed_profile = ReflectionProfilePseudoVoigt()
+ pseudo_voigt_component.get(parameter_name).set_value(new_value)
+ pseudo_voigt_component.push_parameters(installed_profile)
+ actual_value = installed_profile.GetPar(
+ pyobjcryst_par_name
+ ).GetHumanValue()
+ expected_value = new_value
+ assert actual_value == pytest.approx(expected_value)
+
+
+@pytest.mark.parametrize(
+ "parameter_bounds, new_values, invalid_name, invalid_value, "
+ "expected_limits, expected_used_value",
+ [
+ # Test pushing Parameter values when one is outside ObjCryst's
+ # limits
+ # C1: The Caglioti W term is set above its upper limit, with its
+ # bounds left at ObjCryst's limits.
+ # Expected: A ValueError names W, its limits and the value ObjCryst
+ # would use, and the model is unchanged.
+ ({}, {"W": 2.0}, "W", "2.0", "[0.0, 1.0]", "1.0"),
+ # C2: The asymmetry term asym0 is set below its lower limit, with its
+ # bounds left at ObjCryst's limits.
+ # Expected: A ValueError names asym0, its limits and the value
+ # ObjCryst would use, and the model is unchanged.
+ ({}, {"asym0": 0.001}, "asym0", "0.001", "[0.01, 10.0]", "0.01"),
+ # C3: The W term's bounds are widened past ObjCryst's limits to
+ # [0, 5], and W is set to 2, inside the bounds.
+ # Expected: The check uses ObjCryst's limits, not the Parameter's
+ # bounds, so a ValueError names the limits [0, 1], and the model is
+ # unchanged.
+ ({"W": [0.0, 5.0]}, {"W": 2.0}, "W", "2.0", "[0.0, 1.0]", "1.0"),
+ # C4: W, which comes first in the mapping, is set inside its limits,
+ # and asym0 is set above its upper limit, with both bounds left at
+ # ObjCryst's limits.
+ # Expected: A ValueError names asym0, and no value is pushed, not
+ # even W's, so the model is unchanged rather than half updated.
+ (
+ {},
+ {"W": 0.05, "asym0": 20.0},
+ "asym0",
+ "20.0",
+ "[0.01, 10.0]",
+ "10.0",
+ ),
+ ],
+)
+def test_push_parameters_outside_limits(
+ pseudo_voigt_component,
+ parameter_bounds,
+ new_values,
+ invalid_name,
+ invalid_value,
+ expected_limits,
+ expected_used_value,
+):
+ expected_msg = (
+ f"The value of '{invalid_name}', {invalid_value}, is outside its "
+ f"limits {expected_limits}. ObjCryst would silently use "
+ f"{expected_used_value} instead, so the refined value would not be "
+ "the one used to calculate the pattern. Please keep the value "
+ "within the limits, for example by passing the recipe's bounds to "
+ "the optimizer: least_squares(..., bounds=recipe.get_bounds_array())."
+ )
+ installed_profile = ReflectionProfilePseudoVoigt()
+ refinable_par_count = installed_profile.GetNbPar()
+ expected_values = [
+ installed_profile.GetPar(index).GetHumanValue()
+ for index in range(refinable_par_count)
+ ]
+ for parameter_name, bounds in parameter_bounds.items():
+ pseudo_voigt_component.get(parameter_name).bound_range(*bounds)
+ for parameter_name, new_value in new_values.items():
+ pseudo_voigt_component.get(parameter_name).set_value(new_value)
+ with pytest.raises(ValueError, match=re.escape(expected_msg)):
+ pseudo_voigt_component.push_parameters(installed_profile)
+ actual_values = [
+ installed_profile.GetPar(index).GetHumanValue()
+ for index in range(refinable_par_count)
+ ]
+ assert actual_values == expected_values
+
+
+def test_push_parameters_outside_narrowed_bounds(pseudo_voigt_component):
+ # Test that a value outside narrowed bounds but inside ObjCryst's
+ # limits is pushed
+ # Expected: W = 0.5 is outside the narrowed bounds [0, 0.1] but inside
+ # ObjCryst's limits [0, 1], so the W RefinablePar has the new value.
+ # Keeping a fit inside the bounds is the optimizer's job.
+ installed_profile = ReflectionProfilePseudoVoigt()
+ parameter = pseudo_voigt_component.get("W")
+ parameter.bound_range(0.0, 0.1)
+ parameter.set_value(0.5)
+ pseudo_voigt_component.push_parameters(installed_profile)
+ actual_value = installed_profile.GetPar("W").GetHumanValue()
+ expected_value = 0.5
+ assert actual_value == pytest.approx(expected_value)
diff --git a/tests/test_profiles.py b/tests/test_profiles.py
new file mode 100644
index 0000000..5d03a66
--- /dev/null
+++ b/tests/test_profiles.py
@@ -0,0 +1,265 @@
+import numpy as np
+import pytest
+from pyobjcryst.reflectionprofile import (
+ ReflectionProfilePseudoVoigt,
+ ReflectionProfilePseudoVoigtTCH,
+)
+
+from diffpy.cmirietveld.profiles import PseudoVoigtProfile, TCHProfile
+
+
+@pytest.mark.parametrize(
+ "profile_class, expected_names",
+ [
+ # Test the name and Parameters of each peak profile
+ # C1: A pseudo-Voigt profile is created.
+ # Expected: It is named peak_profile and has the Caglioti, mixing
+ # and asymmetry Parameters.
+ (
+ PseudoVoigtProfile,
+ ["U", "V", "W", "asym0", "asym1", "asym2", "eta0", "eta1"],
+ ),
+ # C2: A TCH profile is created.
+ # Expected: It is named peak_profile and has the Gaussian,
+ # Lorentzian and size broadening Parameters.
+ (
+ TCHProfile,
+ ["LGmix", "P", "U", "V", "W", "X", "Y", "Z"],
+ ),
+ ],
+)
+def test_init(profile_class, expected_names):
+ profile = profile_class()
+ actual_name = profile.name
+ actual_names = sorted(profile.get_names())
+ expected_name = "peak_profile"
+ assert actual_name == expected_name
+ assert actual_names == expected_names
+
+
+@pytest.mark.parametrize(
+ "profile_class, initial_pyobjcryst_class, expected_pyobjcryst_class",
+ [
+ # Test that install replaces the installed pyobjcryst profile
+ # C1: A pseudo-Voigt profile is installed where a TCH profile was.
+ # Expected: The installed profile is a pseudo-Voigt profile.
+ (
+ PseudoVoigtProfile,
+ ReflectionProfilePseudoVoigtTCH,
+ ReflectionProfilePseudoVoigt,
+ ),
+ # C2: A TCH profile is installed where a pseudo-Voigt profile was.
+ # Expected: The installed profile is a TCH profile.
+ (
+ TCHProfile,
+ ReflectionProfilePseudoVoigt,
+ ReflectionProfilePseudoVoigtTCH,
+ ),
+ ],
+)
+def test_install(
+ pbso4_pattern,
+ profile_class,
+ initial_pyobjcryst_class,
+ expected_pyobjcryst_class,
+):
+ pbso4_pattern.diffraction.SetProfile(initial_pyobjcryst_class())
+ profile_class().install(pbso4_pattern.pattern, pbso4_pattern.diffraction)
+ actual_pyobjcryst_class = type(pbso4_pattern.diffraction.GetProfile())
+ assert actual_pyobjcryst_class == expected_pyobjcryst_class
+
+
+@pytest.mark.parametrize(
+ "profile_class, parameter_name, new_value",
+ [
+ # Test that installing a profile again is safe
+ # C1: A pseudo-Voigt profile with a changed W is installed and
+ # updated twice.
+ # Expected: The pattern equals the one installed and updated once.
+ (PseudoVoigtProfile, "W", 0.05),
+ # C2: A TCH profile with a changed X is installed and updated twice.
+ # Expected: The pattern equals the one installed and updated once.
+ (TCHProfile, "X", 0.02),
+ ],
+)
+def test_install_twice(
+ pbso4_pattern,
+ other_pbso4_pattern,
+ profile_class,
+ parameter_name,
+ new_value,
+):
+ # Change a Parameter from its default, so the check isn't trivial
+ profile = profile_class()
+ profile.get(parameter_name).set_value(new_value)
+ # Install and update the profile twice on the first pattern
+ profile.install(pbso4_pattern.pattern, pbso4_pattern.diffraction)
+ profile.update(pbso4_pattern.pattern, pbso4_pattern.diffraction)
+ profile.install(pbso4_pattern.pattern, pbso4_pattern.diffraction)
+ profile.update(pbso4_pattern.pattern, pbso4_pattern.diffraction)
+ actual_pattern = pbso4_pattern.calculate()
+ # Install and update the same profile once on a second, fresh pattern
+ profile.install(
+ other_pbso4_pattern.pattern, other_pbso4_pattern.diffraction
+ )
+ profile.update(
+ other_pbso4_pattern.pattern, other_pbso4_pattern.diffraction
+ )
+ expected_pattern = other_pbso4_pattern.calculate()
+ assert np.allclose(actual_pattern, expected_pattern)
+
+
+@pytest.mark.parametrize(
+ "profile_class, pyobjcryst_class, parameter_name, pyobjcryst_par_name, "
+ "new_value",
+ [
+ # Test that update pushes a changed Parameter into the installed
+ # pyobjcryst profile
+ # C1: The pseudo-Voigt W is changed.
+ # Expected: The pattern equals one where W was set directly on an
+ # installed pyobjcryst pseudo-Voigt profile.
+ (PseudoVoigtProfile, ReflectionProfilePseudoVoigt, "W", "W", 0.05),
+ # C2: The pseudo-Voigt eta0 is changed, whose name differs from its
+ # RefinablePar name, Eta0.
+ # Expected: The pattern equals one where Eta0 was set directly.
+ (
+ PseudoVoigtProfile,
+ ReflectionProfilePseudoVoigt,
+ "eta0",
+ "Eta0",
+ 0.3,
+ ),
+ # C3: The TCH Lorentzian width X is changed.
+ # Expected: The pattern equals one where X was set directly on an
+ # installed pyobjcryst TCH profile.
+ (TCHProfile, ReflectionProfilePseudoVoigtTCH, "X", "X", 0.02),
+ ],
+)
+def test_update(
+ pbso4_pattern,
+ other_pbso4_pattern,
+ profile_class,
+ pyobjcryst_class,
+ parameter_name,
+ pyobjcryst_par_name,
+ new_value,
+):
+ # Install the profile, change a Parameter and push it with update
+ profile = profile_class()
+ profile.install(pbso4_pattern.pattern, pbso4_pattern.diffraction)
+ profile.get(parameter_name).set_value(new_value)
+ profile.update(pbso4_pattern.pattern, pbso4_pattern.diffraction)
+ actual_pattern = pbso4_pattern.calculate()
+ # On a second pattern, set the same value directly in pyobjcryst
+ other_pbso4_pattern.diffraction.SetProfile(pyobjcryst_class())
+ installed_profile = other_pbso4_pattern.diffraction.GetProfile()
+ installed_profile.GetPar(pyobjcryst_par_name).SetHumanValue(new_value)
+ expected_pattern = other_pbso4_pattern.calculate()
+ assert np.allclose(actual_pattern, expected_pattern)
+
+
+@pytest.mark.parametrize(
+ "profile_class, new_values, expected_pyobjcryst_values",
+ [
+ # Test that update pushes every Parameter to its own RefinablePar
+ # C1: Every pseudo-Voigt Parameter is set to a distinct value.
+ # Expected: Each value is on the RefinablePar with the matching
+ # ObjCryst name, so no two names are swapped.
+ (
+ PseudoVoigtProfile,
+ {
+ "U": 0.01,
+ "V": -0.02,
+ "W": 0.03,
+ "eta0": 0.4,
+ "eta1": 0.05,
+ "asym0": 1.6,
+ "asym1": 0.07,
+ "asym2": 0.08,
+ },
+ {
+ "U": 0.01,
+ "V": -0.02,
+ "W": 0.03,
+ "Eta0": 0.4,
+ "Eta1": 0.05,
+ "Asym0": 1.6,
+ "Asym1": 0.07,
+ "Asym2": 0.08,
+ },
+ ),
+ # C2: Every TCH Parameter is set to a distinct value.
+ # Expected: Each value is on the RefinablePar with the same name, so
+ # no two names are swapped.
+ (
+ TCHProfile,
+ {
+ "U": 0.01,
+ "V": -0.02,
+ "W": 0.03,
+ "X": 0.04,
+ "Y": -0.05,
+ "Z": 0.06,
+ "P": 0.07,
+ "LGmix": 0.8,
+ },
+ {
+ "U": 0.01,
+ "V": -0.02,
+ "W": 0.03,
+ "X": 0.04,
+ "Y": -0.05,
+ "Z": 0.06,
+ "P": 0.07,
+ "LGmix": 0.8,
+ },
+ ),
+ ],
+)
+def test_update_all_parameters(
+ pbso4_pattern, profile_class, new_values, expected_pyobjcryst_values
+):
+ # Install the profile and give every Parameter its new value
+ profile = profile_class()
+ profile.install(pbso4_pattern.pattern, pbso4_pattern.diffraction)
+ for parameter_name, new_value in new_values.items():
+ profile.get(parameter_name).set_value(new_value)
+ profile.update(pbso4_pattern.pattern, pbso4_pattern.diffraction)
+ # Read every value back from the installed pyobjcryst profile
+ installed_profile = pbso4_pattern.diffraction.GetProfile()
+ actual_pyobjcryst_values = {
+ pyobjcryst_par_name: installed_profile.GetPar(
+ pyobjcryst_par_name
+ ).GetHumanValue()
+ for pyobjcryst_par_name in expected_pyobjcryst_values
+ }
+ assert actual_pyobjcryst_values == pytest.approx(
+ expected_pyobjcryst_values
+ )
+
+
+def test_update_shared_profile(pbso4_pattern, other_pbso4_pattern):
+ # Test one profile installed and updated on two patterns
+ # Expected: A changed W reaches both installed pyobjcryst profiles,
+ # because the profile keeps no reference to either pattern.
+ # Install one profile on both patterns
+ profile = PseudoVoigtProfile()
+ profile.install(pbso4_pattern.pattern, pbso4_pattern.diffraction)
+ profile.install(
+ other_pbso4_pattern.pattern, other_pbso4_pattern.diffraction
+ )
+ # Change W once and push it to both patterns with update
+ profile.get("W").set_value(0.05)
+ profile.update(pbso4_pattern.pattern, pbso4_pattern.diffraction)
+ profile.update(
+ other_pbso4_pattern.pattern, other_pbso4_pattern.diffraction
+ )
+ # Read W back from each pattern's installed pyobjcryst profile
+ actual_values = [
+ pbso4_pattern.diffraction.GetProfile().GetPar("W").GetHumanValue(),
+ other_pbso4_pattern.diffraction.GetProfile()
+ .GetPar("W")
+ .GetHumanValue(),
+ ]
+ expected_values = [0.05, 0.05]
+ assert actual_values == pytest.approx(expected_values)
diff --git a/tests/testdata/PbSO4-Wyckoff.cif b/tests/testdata/PbSO4-Wyckoff.cif
new file mode 100644
index 0000000..e012128
--- /dev/null
+++ b/tests/testdata/PbSO4-Wyckoff.cif
@@ -0,0 +1,53 @@
+
+######################################################################
+data_pbso4-phase
+
+# phase info for PbSO4 follows
+_pd_phase_name PbSO4
+_cell_length_a 8.48
+_cell_length_b 5.398
+_cell_length_c 6.958
+_cell_angle_alpha 90
+_cell_angle_beta 90
+_cell_angle_gamma 90
+_cell_volume 318.503
+_symmetry_cell_setting orthorhombic
+_symmetry_space_group_name_H-M "P n m a"
+loop_
+ _space_group_symop_id
+ _space_group_symop_operation_xyz
+ 1 x,y,z
+ 2 1/2-x,1/2+y,1/2+z
+ 3 x,1/2-y,z
+ 4 1/2-x,-y,1/2+z
+ 5 -x,-y,-z
+ 6 1/2+x,1/2-y,1/2-z
+ 7 -x,1/2+y,-z
+ 8 1/2+x,y,1/2-z
+
+# ATOMIC COORDINATES AND DISPLACEMENT PARAMETERS
+loop_
+ _atom_site_label
+ _atom_site_type_symbol
+ _atom_site_fract_x
+ _atom_site_fract_y
+ _atom_site_fract_z
+ _atom_site_occupancy
+ _atom_site_adp_type
+ _atom_site_U_iso_or_equiv
+ _atom_site_symmetry_multiplicity
+Pb Pb 0.18820 0.25000 0.16700 1.000 Uiso 0.010 4
+S S 0.06300 0.25000 0.68600 1.000 Uiso 0.010 4
+O1 O -0.09500 0.25000 0.60000 1.000 Uiso 0.010 4
+O2 O 0.18100 0.25000 0.54300 1.000 Uiso 0.010 4
+O3 O 0.08500 0.02600 0.80600 1.000 Uiso 0.010 8
+
+loop_ _atom_type_symbol _atom_type_number_in_cell
+ O 16
+ Pb 4
+ S 4
+
+# Note that Z affects _cell_formula_sum and _weight
+_cell_formula_units_Z 4
+_chemical_formula_sum "O4 Pb S"
+_chemical_formula_weight 303.26
diff --git a/tests/testdata/README.rst b/tests/testdata/README.rst
new file mode 100644
index 0000000..585c1f1
--- /dev/null
+++ b/tests/testdata/README.rst
@@ -0,0 +1,10 @@
+Test data
+=========
+
+``PbSO4-Wyckoff.cif`` is copied from the ``tests/testinp`` directory of GSAS-II
+(https://github.com/AdvancedPhotonSource/GSAS-II).
+
+GSAS-II is Copyright 2010, UChicago Argonne, LLC, Operator of Argonne National
+Laboratory. All rights reserved. This product includes software produced by
+UChicago Argonne, LLC under Contract No. DE-AC02-06CH11357 with the Department
+of Energy.