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.