From 72845bb716f79eca27a1ebd757e81a7f33667631 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Wed, 28 Oct 2026 13:35:08 -0400 Subject: [PATCH 1/8] mild test refactor for readability and pre-commit fix --- .pre-commit-config.yaml | 2 -- tests/test_profiles.py | 10 ++++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a9fa4c7..f10e6c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,3 @@ -default_language_version: - python: python3.13 ci: autofix_commit_msg: | [pre-commit.ci] auto fixes from pre-commit hooks diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 5d03a66..4c6bffd 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -227,12 +227,10 @@ def test_update_all_parameters( 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 - } + actual_pyobjcryst_values = {} + for pyobjcryst_par_name in expected_pyobjcryst_values: + parameter = installed_profile.GetPar(pyobjcryst_par_name) + actual_pyobjcryst_values[pyobjcryst_par_name] = parameter.GetHumanValue() assert actual_pyobjcryst_values == pytest.approx( expected_pyobjcryst_values ) From 365f1c6e26d3ce3f2d7b2236dd296eb1263a221c Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Wed, 28 Oct 2026 13:35:39 -0400 Subject: [PATCH 2/8] minor fix --- tests/test_profiles.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 4c6bffd..b7fe199 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -230,7 +230,9 @@ def test_update_all_parameters( actual_pyobjcryst_values = {} for pyobjcryst_par_name in expected_pyobjcryst_values: parameter = installed_profile.GetPar(pyobjcryst_par_name) - actual_pyobjcryst_values[pyobjcryst_par_name] = parameter.GetHumanValue() + actual_pyobjcryst_values[pyobjcryst_par_name] = ( + parameter.GetHumanValue() + ) assert actual_pyobjcryst_values == pytest.approx( expected_pyobjcryst_values ) From bfcd3186ee1e60251bb634cfacdc8c5df1024c52 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Wed, 28 Oct 2026 14:43:06 -0400 Subject: [PATCH 3/8] feat: add Parameter targets and a settings counter to Component --- news/component-targets-and-settings.rst | 24 +++++ src/diffpy/cmirietveld/component.py | 134 +++++++++++++++++++---- tests/conftest.py | 16 +++ tests/test_component.py | 138 ++++++++++++++++++++++++ 4 files changed, 289 insertions(+), 23 deletions(-) create mode 100644 news/component-targets-and-settings.rst diff --git a/news/component-targets-and-settings.rst b/news/component-targets-and-settings.rst new file mode 100644 index 0000000..4d909b1 --- /dev/null +++ b/news/component-targets-and-settings.rst @@ -0,0 +1,24 @@ +**Added:** + +* Add ``Component.settings_version`` and ``Component.mark_settings_changed``, so a generator can run ``install`` again after a setting of a component changes. +* Add a ``target`` argument to ``Component.create_parameters_from`` and ``Component.push_parameters``, so one component can configure several pyobjcryst objects, such as a pattern and its radiation. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/cmirietveld/component.py b/src/diffpy/cmirietveld/component.py index de5cb74..f98425d 100644 --- a/src/diffpy/cmirietveld/component.py +++ b/src/diffpy/cmirietveld/component.py @@ -33,15 +33,86 @@ class Component(ParameterSet): they configure. They do not keep a reference to the pyobjcryst objects they are given, so one component can serve several generators. + A component has two kinds of configuration: + + - **Parameters** are the values a fit can refine, such as a zero shift + or a peak width. They are created from a pyobjcryst model with + ``create_parameters_from``, and ``update`` writes them into the + model with ``push_parameters`` before every calculation. A component + that configures more than one pyobjcryst object, such as a pattern + and its radiation, creates the Parameters of each object under its + own target and pushes them one target at a time. + - **Settings** are the configuration that is not refined, such as the + radiation type or the x-ray tube. ``install`` applies them. A method + that changes a setting calls ``mark_settings_changed``, so every + generator holding the component runs ``install`` again before its + next calculation. + Parameters ---------- name : str The name of the component. It must be a valid attribute name. + + Examples + -------- + An instrument whose ``zero`` belongs to the pattern and whose + ``wavelength`` belongs to the pattern's radiation: + + >>> class MyInstrument(Component): + ... def __init__(self): + ... super().__init__("instrument") + ... self.create_parameters_from( + ... PowderPattern(), {"zero": "Zero"}, target="pattern" + ... ) + ... self.create_parameters_from( + ... Radiation(), + ... {"wavelength": "Wavelength"}, + ... target="radiation", + ... ) + ... + ... def update(self, pattern, diffraction): + ... self.push_parameters(pattern, target="pattern") + ... self.push_parameters( + ... pattern.GetRadiation(), target="radiation" + ... ) """ def __init__(self, name): ParameterSet.__init__(self, name) self._refinable_par_names = {} + self._settings_version = 0 + + @property + def settings_version(self): + """The number of times the settings of the component changed. + + A generator compares it with the value it last installed, and + runs ``install`` again when it differs. It starts at 0, and only + ``mark_settings_changed`` changes it: a change to a Parameter + value is not a settings change. + """ + return self._settings_version + + def mark_settings_changed(self): + """Record that a setting of the component changed. + + Settings are the configuration of a component that is not a + Parameter, such as the radiation type of an instrument. Call + this from every method that changes a setting. It bumps + ``settings_version`` and notifies the component's observers, so + every generator holding the component drops its cached pattern + and runs ``install`` again before its next calculation. + + Examples + -------- + A setting method of an instrument: + + >>> def set_xray_tube(self, element): + ... self._xray_tube = element + ... self.mark_settings_changed() + """ + self._settings_version += 1 + self._flush(other=(self,)) def install(self, pattern, diffraction): """Set the model up in one generator. @@ -73,7 +144,7 @@ def update(self, pattern, diffraction): """ raise NotImplementedError - def create_parameters_from(self, refinable_obj, names): + def create_parameters_from(self, refinable_obj, names, target="default"): """Create the Parameters of the component from a pyobjcryst model. @@ -86,9 +157,9 @@ def create_parameters_from(self, refinable_obj, 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 out of refinement scripts. The mapping is stored under + ``target``, so ``push_parameters`` writes each value back to the + same ``RefinablePar``. Parameters ---------- @@ -96,8 +167,13 @@ def create_parameters_from(self, refinable_obj, names): A fresh instance of the pyobjcryst model. names : dict of str to str The ``RefinablePar`` name for each Parameter name, for example - ``{"eta0": "Eta0"}``. Only the named ``RefinablePar``s get a - Parameter. + ``{"eta0": "Eta0"}``. Only the named ``RefinablePar`` objects + get a Parameter. + target : str, optional + The name of the pyobjcryst object the Parameters belong to + (default "default"). A component that configures more than one + pyobjcryst object, such as a pattern and its radiation, creates + the Parameters of each object under its own target. Raises ------ @@ -134,13 +210,14 @@ def create_parameters_from(self, refinable_obj, names): refinable_par.GetHumanMin(), refinable_par.GetHumanMax() ) self.add_parameter(parameter) - self._refinable_par_names.update(names) + self._refinable_par_names.setdefault(target, {}).update(names) - def push_parameters(self, refinable_obj): + def push_parameters(self, refinable_obj, target="default"): """Push the Parameter values into a pyobjcryst model. - Each value is written to the ``RefinablePar`` that - ``create_parameters_from`` mapped it to. + Each value of the target's Parameters is written to the + ``RefinablePar`` that ``create_parameters_from`` mapped it to. The + Parameters of other targets are neither checked nor pushed. A value outside the limits of its ``RefinablePar`` raises an error instead of being clamped, because ObjCryst would otherwise use a @@ -150,30 +227,41 @@ def push_parameters(self, refinable_obj): 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. + All the values of the target are checked before any is pushed, + so an error leaves the model unchanged rather than half updated. + A component with several targets pushes them one at a time, so an + error in a later target leaves the earlier targets' objects + updated. The calculation stops with the error, and the next one + pushes every value again. Parameters ---------- refinable_obj : pyobjcryst.refinableobj.RefinableObj The pyobjcryst model to update. + target : str, optional + The target whose Parameters are pushed (default "default"), as + given to ``create_parameters_from``. Only that target's values + are checked and pushed. Raises ------ ValueError - If a Parameter value is outside the limits of its - ``RefinablePar``. + If the component has no Parameters for the target, or a + Parameter value is outside the limits of its ``RefinablePar``. """ - 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() + if target not in self._refinable_par_names: + raise ValueError( + f"The component '{self.name}' has no Parameters for the " + f"target '{target}'. Please use one of its targets: " + f"{', '.join(self._refinable_par_names)}." ) - } + refinable_par_names = self._refinable_par_names[target] + values = {} + refinable_pars = {} + for parameter_name, refinable_par_name in refinable_par_names.items(): + values[parameter_name] = self.get(parameter_name).value + refinable_par = refinable_obj.GetPar(refinable_par_name) + refinable_pars[parameter_name] = refinable_par for parameter_name, refinable_par in refinable_pars.items(): if not refinable_par.IsLimited(): continue diff --git a/tests/conftest.py b/tests/conftest.py index 8f5667c..505004a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,6 +43,22 @@ def calculate(self): return calculated_pattern +class NotificationCounter: + """An observer that counts the notifications it receives.""" + + def __init__(self): + self.count = 0 + + def __call__(self, semaphores): + self.count += 1 + + +@pytest.fixture +def notification_counter(): + notification_counter = NotificationCounter() + return notification_counter + + @pytest.fixture def pbso4_pattern(): pbso4_pattern = PbSO4Pattern() diff --git a/tests/test_component.py b/tests/test_component.py index cf155ad..7e2b1a9 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -1,6 +1,8 @@ import re import pytest +from pyobjcryst.powderpattern import PowderPattern +from pyobjcryst.radiation import Radiation from pyobjcryst.reflectionprofile import ReflectionProfilePseudoVoigt from diffpy.cmirietveld.component import Component @@ -190,3 +192,139 @@ def test_push_parameters_outside_narrowed_bounds(pseudo_voigt_component): actual_value = installed_profile.GetPar("W").GetHumanValue() expected_value = 0.5 assert actual_value == pytest.approx(expected_value) + + +@pytest.mark.parametrize( + "pushed_targets, expected_zero, expected_wavelength", + [ + # Test that push_parameters writes only the Parameters of the + # target it is given, into the object it is given. The component + # has two Parameters from two pyobjcryst objects: zero, from a + # PowderPattern's Zero, under the target "pattern", and wavelength, + # from a Radiation's Wavelength, under the target "radiation". Both + # values are changed, the listed targets are pushed ("pattern" into + # a new pattern, "radiation" into that pattern's radiation), and + # both values are read back from the pattern. A target that isn't + # pushed keeps ObjCryst's defaults, Zero = 0.0 and Wavelength = 1.0. + # C1: Only the pattern target is pushed into the pattern. + # Expected: The pattern's Zero has the new value, and the + # radiation's Wavelength keeps its default, 1.0. + (["pattern"], 0.1, 1.0), + # C2: Only the radiation target is pushed into the radiation. + # Expected: The radiation's Wavelength has the new value, and the + # pattern's Zero keeps its default, 0.0. + (["radiation"], 0.0, 1.5406), + # C3: Both targets are pushed, each into its own object, as an + # instrument's update will do. + # Expected: Both have the new values. + (["pattern", "radiation"], 0.1, 1.5406), + ], +) +def test_push_parameters_with_targets( + pushed_targets, expected_zero, expected_wavelength +): + # Create one Parameter for each target, from fresh pyobjcryst objects + component = Component("instrument") + component.create_parameters_from( + PowderPattern(), {"zero": "Zero"}, target="pattern" + ) + component.create_parameters_from( + Radiation(), {"wavelength": "Wavelength"}, target="radiation" + ) + # Change both values + component.get("zero").set_value(0.1) + component.get("wavelength").set_value(1.5406) + # Push each listed target into its own object of a new pattern + pattern = PowderPattern() + refinable_objs = {"pattern": pattern, "radiation": pattern.GetRadiation()} + for target in pushed_targets: + component.push_parameters(refinable_objs[target], target=target) + # Read both values back from the pattern + actual_zero = pattern.GetPar("Zero").GetHumanValue() + actual_wavelength = ( + pattern.GetRadiation().GetPar("Wavelength").GetHumanValue() + ) + assert actual_zero == pytest.approx(expected_zero) + assert actual_wavelength == pytest.approx(expected_wavelength) + + +def test_push_parameters_unknown_target(): + # Test pushing a target the component has no Parameters for + # Expected: A ValueError names the target and lists the component's + # targets, instead of pushing nothing. Targets are case-sensitive. + + # Create an "instrument" component with one Parameter for + # each of two targets, "pattern" and "radiation". + component = Component("instrument") + component.create_parameters_from( + PowderPattern(), {"zero": "Zero"}, target="pattern" + ) + component.create_parameters_from( + Radiation(), {"wavelength": "Wavelength"}, target="radiation" + ) + expected_msg = ( + "The component 'instrument' has no Parameters for the target " + "'not_a_target'. Please use one of its targets: pattern, radiation." + ) + with pytest.raises(ValueError, match=re.escape(expected_msg)): + component.push_parameters(Radiation(), target="not_a_target") + + +def test_push_parameters_other_target_outside_limits(): + # Test pushing one target while a Parameter of another target is + # outside its limits + # Expected: Only the pushed target's values are checked, so the + # pattern's Zero gets the new value, even though wavelength, 100.0, is + # outside the Wavelength limits [0.05, 20]. + # Create one Parameter for each target, from fresh pyobjcryst objects + component = Component("instrument") + component.create_parameters_from( + PowderPattern(), {"zero": "Zero"}, target="pattern" + ) + component.create_parameters_from( + Radiation(), {"wavelength": "Wavelength"}, target="radiation" + ) + # Change zero, and set wavelength outside its limits + component.get("zero").set_value(0.1) + component.get("wavelength").set_value(100.0) + # Push only the "pattern" target into a new pattern + pattern = PowderPattern() + component.push_parameters(pattern, target="pattern") + actual_zero = pattern.GetPar("Zero").GetHumanValue() + expected_zero = 0.1 + assert actual_zero == pytest.approx(expected_zero) + + +def test_mark_settings_changed(notification_counter): + # Test that each settings change bumps the settings counter and + # notifies the component's observers, as a generator holding it is + # Expected: The counter starts at 0 and counts each change, and the + # observer is notified once per change. + component = Component("instrument") + component.add_observer(notification_counter) + # Read the counter before any change + actual_counts = [(component.settings_version, notification_counter.count)] + # Change the settings twice + component.mark_settings_changed() + actual_counts.append( + (component.settings_version, notification_counter.count) + ) + component.mark_settings_changed() + actual_counts.append( + (component.settings_version, notification_counter.count) + ) + expected_counts = [(0, 0), (1, 1), (2, 2)] + assert actual_counts == expected_counts + + +def test_settings_version_after_parameter_change(): + # Test that changing a Parameter value is not a settings change + # Expected: settings_version stays 0, because Parameter values reach + # ObjCryst through update before every calculation, so no generator + # needs to run install again. + component = Component("instrument") + component.create_parameters_from(PowderPattern(), {"zero": "Zero"}) + component.get("zero").set_value(0.1) + actual_settings_version = component.settings_version + expected_settings_version = 0 + assert actual_settings_version == expected_settings_version From 06107ba86af94f6fc95e4675dd4521d4ab47a2d5 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Wed, 28 Oct 2026 18:00:45 -0400 Subject: [PATCH 4/8] add instrument tests --- tests/test_instruments.py | 715 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 715 insertions(+) create mode 100644 tests/test_instruments.py diff --git a/tests/test_instruments.py b/tests/test_instruments.py new file mode 100644 index 0000000..27ef0a1 --- /dev/null +++ b/tests/test_instruments.py @@ -0,0 +1,715 @@ +import re + +import numpy as np +import pytest +from pyobjcryst.radiation import RadiationType + +from diffpy.cmirietveld.instruments import ConstantWavelengthInstrument +from diffpy.srfit.exceptions import SrFitError + +# ObjCryst limits the 2theta corrections to +-0.05 rad, in degrees +TWO_THETA_CORRECTION_LIMIT = np.degrees(0.05) +# ObjCryst's tabulated Cu tube wavelengths, in angstroms. ObjCryst +# stores them in single precision, so they aren't 1.540560 and +# 1.544390 exactly +CU_KALPHA1 = 1.540560007095337 +CU_KALPHA2 = 1.5443899631500244 +CU_MEAN_WAVELENGTH = 1.5418366591135662 +# ObjCryst's Cu tube mean wavelength for a Kalpha2/Kalpha1 ratio of 0.4 +CU_MEAN_WAVELENGTH_RATIO_0_4 = 1.5416542802538191 + + +def test_parameter_names(): + # Test the name and Parameters of the instrument + # Expected: It is named instrument and has the 2theta corrections, the + # absorption and the wavelength. + instrument = ConstantWavelengthInstrument() + actual_name = instrument.name + actual_parameter_names = sorted(instrument.get_names()) + expected_name = "instrument" + expected_parameter_names = [ + "displacement", + "mu_r", + "transparency", + "wavelength", + "zero", + ] + assert actual_name == expected_name + assert actual_parameter_names == expected_parameter_names + + +@pytest.mark.parametrize( + "parameter_name, expected_value, expected_bounds", + [ + # Test that the pattern Parameters take ObjCryst's defaults and + # limits + # C1: The zero shift is created from the pattern's Zero. + # Expected: Its value is 0 and its bounds are +-0.05 rad, in degrees. + ( + "zero", + 0.0, + [-TWO_THETA_CORRECTION_LIMIT, TWO_THETA_CORRECTION_LIMIT], + ), + # C2: The displacement is created from the pattern's 2ThetaDispl. + # Expected: Its value is 0 and its bounds are +-0.05 rad, in degrees. + ( + "displacement", + 0.0, + [-TWO_THETA_CORRECTION_LIMIT, TWO_THETA_CORRECTION_LIMIT], + ), + # C3: The transparency is created from the pattern's 2ThetaTransp. + # Expected: Its value is 0 and its bounds are +-0.05 rad, in degrees. + ( + "transparency", + 0.0, + [-TWO_THETA_CORRECTION_LIMIT, TWO_THETA_CORRECTION_LIMIT], + ), + # C4: The absorption is created from the pattern's MuR. + # Expected: Its value is 0 and its bounds are [0, 1000]. + ("mu_r", 0.0, [0.0, 1000.0]), + ], +) +def test_pattern_parameter_values_and_bounds( + parameter_name, expected_value, expected_bounds +): + instrument = ConstantWavelengthInstrument() + parameter = instrument.get(parameter_name) + actual_value = parameter.value + actual_bounds = list(parameter.bounds) + assert actual_value == pytest.approx(expected_value) + assert actual_bounds == pytest.approx(expected_bounds) + + +def test_wavelength_before_set(): + # Test the wavelength of a new instrument + # Expected: It has no value, because no dataset uses ObjCryst's default + # of 1 angstrom, and its bounds are the Wavelength limits [0.05, 20]. + instrument = ConstantWavelengthInstrument() + actual_value = instrument.wavelength.value + actual_bounds = list(instrument.wavelength.bounds) + expected_value = None + expected_bounds = [0.05, 20.0] + assert actual_value == expected_value + assert actual_bounds == pytest.approx(expected_bounds) + + +def test_validate_before_wavelength_set(): + # Test validating an instrument before a wavelength is set, as srfit + # does before a fit + # Expected: An SrFitError says that the wavelength is missing and how + # to set it. + instrument = ConstantWavelengthInstrument() + expected_msg = ( + "The instrument has no wavelength. Please set one with " + "set_wavelength, set_xray_source or set_xray_wavelengths." + ) + with pytest.raises(SrFitError, match=re.escape(expected_msg)): + instrument._validate() + + +@pytest.mark.parametrize( + "setter_name, setter_args, expected_wavelength", + [ + # Test that each way of setting the wavelength sets the wavelength + # Parameter, so the instrument validates + # C1: A single wavelength is set. + # Expected: The wavelength is that wavelength. + ("set_wavelength", [1.5406], 1.5406), + # C2: A Cu tube is set. + # Expected: The wavelength is the intensity-weighted mean of + # ObjCryst's Cu Kalpha1 and Kalpha2, as ObjCryst stores it. + ("set_xray_source", ["Cu"], CU_MEAN_WAVELENGTH), + # C3: The Cu Kalpha1 and Kalpha2 wavelengths are set explicitly. + # Expected: The wavelength is their intensity-weighted mean, + # Kalpha1 + (Kalpha2 - Kalpha1) * 0.5 / 1.5. + ( + "set_xray_wavelengths", + [CU_KALPHA1, CU_KALPHA2], + CU_MEAN_WAVELENGTH, + ), + # C4: A Cu tube is set with a Kalpha2/Kalpha1 ratio of 0.4. + # Expected: The wavelength is the mean weighted by that ratio, + # Kalpha1 + (Kalpha2 - Kalpha1) * 0.4 / 1.4, as ObjCryst stores it. + ("set_xray_source", ["Cu", 0.4], CU_MEAN_WAVELENGTH_RATIO_0_4), + # C5: The Cu Kalpha1 and Kalpha2 wavelengths are set explicitly + # with a Kalpha2/Kalpha1 ratio of 0.4. + # Expected: The wavelength is the mean weighted by that ratio. + ( + "set_xray_wavelengths", + [CU_KALPHA1, CU_KALPHA2, 0.4], + CU_MEAN_WAVELENGTH_RATIO_0_4, + ), + ], +) +def test_wavelength_after_setter( + setter_name, setter_args, expected_wavelength +): + instrument = ConstantWavelengthInstrument() + getattr(instrument, setter_name)(*setter_args) + actual_wavelength = instrument.wavelength.value + assert actual_wavelength == pytest.approx(expected_wavelength) + instrument._validate() + + +@pytest.mark.parametrize( + "setter_name, setter_args", + [ + # Test that each setting method is a settings change, so every + # generator holding the instrument runs install again + # C1: The radiation type is set. + # Expected: settings_version goes from 0 to 1. + ("set_radiation_type", ["N"]), + # C2: A single wavelength is set, which also sets the wavelength + # type. + # Expected: settings_version goes from 0 to 1. + ("set_wavelength", [1.5406]), + # C3: A Cu tube is set. + # Expected: settings_version goes from 0 to 1. + ("set_xray_source", ["Cu"]), + # C4: The Kalpha1 and Kalpha2 wavelengths are set explicitly. + # Expected: settings_version goes from 0 to 1. + ("set_xray_wavelengths", [CU_KALPHA1, CU_KALPHA2]), + # C5: The polarization rate is set. + # Expected: settings_version goes from 0 to 1. + ("set_polarization", [0.95]), + ], +) +def test_settings_version_after_setter(setter_name, setter_args): + instrument = ConstantWavelengthInstrument() + getattr(instrument, setter_name)(*setter_args) + actual_settings_version = instrument.settings_version + expected_settings_version = 1 + assert actual_settings_version == expected_settings_version + + +@pytest.mark.parametrize( + "parameter_name, pyobjcryst_par_name, new_value", + [ + # Test that update pushes the pattern Parameters into the pattern + # C1: The zero shift is changed. + # Expected: The pattern equals one with Zero set directly. + ("zero", "Zero", 0.05), + # C2: The displacement is changed. + # Expected: The pattern equals one with 2ThetaDispl set directly. + ("displacement", "2ThetaDispl", 0.05), + # C3: The transparency is changed. + # Expected: The pattern equals one with 2ThetaTransp set directly. + ("transparency", "2ThetaTransp", 0.05), + # C4: The cylinder absorption is set to 1. + # Expected: The pattern equals one with MuR set directly. + ("mu_r", "MuR", 1.0), + ], +) +def test_update_pattern_parameters( + pbso4_pattern, + other_pbso4_pattern, + parameter_name, + pyobjcryst_par_name, + new_value, +): + # Install the instrument at 1.5406 angstroms, change a Parameter and + # push it with update + instrument = ConstantWavelengthInstrument() + instrument.set_wavelength(1.5406) + instrument.install(pbso4_pattern.pattern, pbso4_pattern.diffraction) + instrument.get(parameter_name).set_value(new_value) + instrument.update(pbso4_pattern.pattern, pbso4_pattern.diffraction) + actual_pattern = pbso4_pattern.calculate() + # On a second pattern, already at 1.5406 angstroms, set the same value + # directly in pyobjcryst + other_pbso4_pattern.pattern.GetPar(pyobjcryst_par_name).SetHumanValue( + new_value + ) + expected_pattern = other_pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + + +def test_update_wavelength(pbso4_pattern, other_pbso4_pattern): + # Test that update pushes the wavelength into the pattern's radiation + # Expected: After the wavelength is changed from 1.5406 to 1.6 + # angstroms, the pattern equals one with the Radiation's Wavelength set + # directly. + # Install the instrument at 1.5406 angstroms, then refine the + # wavelength to 1.6 angstroms + instrument = ConstantWavelengthInstrument() + instrument.set_wavelength(1.5406) + instrument.install(pbso4_pattern.pattern, pbso4_pattern.diffraction) + instrument.wavelength.set_value(1.6) + instrument.update(pbso4_pattern.pattern, pbso4_pattern.diffraction) + actual_pattern = pbso4_pattern.calculate() + # On a second pattern, set the Wavelength directly in pyobjcryst + radiation = other_pbso4_pattern.pattern.GetRadiation() + radiation.GetPar("Wavelength").SetHumanValue(1.6) + expected_pattern = other_pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + + +def test_install_shared_instrument(pbso4_pattern, other_pbso4_pattern): + # Test one instrument shared by two patterns, as the generators of the + # phases of one dataset share it + # Expected: Both patterns get the instrument's radiation type, + # wavelength and zero shift. + # Set up one neutron instrument with a zero shift + instrument = ConstantWavelengthInstrument() + instrument.set_radiation_type("N") + instrument.set_wavelength(1.909) + instrument.zero.set_value(0.05) + shared_patterns = [pbso4_pattern, other_pbso4_pattern] + # Install and update it in both patterns before reading either, so a + # change to the second pattern that reaches the first is caught + for shared_pattern in shared_patterns: + instrument.install(shared_pattern.pattern, shared_pattern.diffraction) + instrument.update(shared_pattern.pattern, shared_pattern.diffraction) + actual_radiation_types = [] + actual_wavelengths = [] + actual_zeros = [] + for shared_pattern in shared_patterns: + radiation = shared_pattern.pattern.GetRadiation() + actual_radiation_types.append(radiation.GetRadiationType()) + actual_wavelengths.append(radiation.GetWavelength()) + zero = shared_pattern.pattern.GetPar("Zero").GetHumanValue() + actual_zeros.append(zero) + expected_radiation_types = [ + RadiationType.RAD_NEUTRON, + RadiationType.RAD_NEUTRON, + ] + expected_wavelengths = [1.909, 1.909] + expected_zeros = [0.05, 0.05] + assert actual_radiation_types == expected_radiation_types + assert actual_wavelengths == pytest.approx(expected_wavelengths) + assert actual_zeros == pytest.approx(expected_zeros) + + +def test_install_neutron_radiation(pbso4_pattern, other_pbso4_pattern): + # Test installing neutron radiation + # Expected: The pattern equals a pyobjcryst pattern set directly to + # neutrons at 1.909 angstroms. + # Install a neutron instrument + instrument = ConstantWavelengthInstrument() + instrument.set_radiation_type("N") + instrument.set_wavelength(1.909) + instrument.install(pbso4_pattern.pattern, pbso4_pattern.diffraction) + instrument.update(pbso4_pattern.pattern, pbso4_pattern.diffraction) + actual_pattern = pbso4_pattern.calculate() + # On a second pattern, set neutrons directly in pyobjcryst + other_pbso4_pattern.pattern.SetRadiationType(RadiationType.RAD_NEUTRON) + other_pbso4_pattern.pattern.SetWavelength(1.909) + expected_pattern = other_pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + + +@pytest.mark.parametrize( + "radiation_type, wavelength, expected_radiation_type", + [ + # Test that install sets the radiation type of the pattern + # C1: Neutrons at 1.909 angstroms are set. + # Expected: The pattern's radiation is neutrons. + ("N", 1.909, RadiationType.RAD_NEUTRON), + # C2: Electrons at 0.0536 angstroms, those of a 50 kV beam, are + # set. Faster electrons are below ObjCryst's wavelength limit of + # 0.05 angstroms. + # Expected: The pattern's radiation is electrons. + ("E", 0.0536, RadiationType.RAD_ELECTRON), + ], +) +def test_install_radiation_type( + pbso4_pattern, radiation_type, wavelength, expected_radiation_type +): + instrument = ConstantWavelengthInstrument() + instrument.set_radiation_type(radiation_type) + instrument.set_wavelength(wavelength) + instrument.install(pbso4_pattern.pattern, pbso4_pattern.diffraction) + radiation = pbso4_pattern.pattern.GetRadiation() + actual_radiation_type = radiation.GetRadiationType() + assert actual_radiation_type == expected_radiation_type + + +def test_install_neutrons_after_tube(pbso4_pattern, other_pbso4_pattern): + # Test switching an installed x-ray tube instrument to neutrons + # Expected: The pattern equals a pyobjcryst pattern set directly to + # neutrons at 1.909 angstroms, with no doublet left from the tube. + # Install and calculate with a Cu tube + instrument = ConstantWavelengthInstrument() + instrument.set_xray_source("Cu") + instrument.install(pbso4_pattern.pattern, pbso4_pattern.diffraction) + instrument.update(pbso4_pattern.pattern, pbso4_pattern.diffraction) + pbso4_pattern.calculate() + # Switch to a single neutron wavelength, then install and calculate + # again + instrument.set_wavelength(1.909) + instrument.set_radiation_type("N") + instrument.install(pbso4_pattern.pattern, pbso4_pattern.diffraction) + instrument.update(pbso4_pattern.pattern, pbso4_pattern.diffraction) + actual_pattern = pbso4_pattern.calculate() + # On a second pattern, set neutrons directly in pyobjcryst + other_pbso4_pattern.pattern.SetRadiationType(RadiationType.RAD_NEUTRON) + other_pbso4_pattern.pattern.SetWavelength(1.909) + expected_pattern = other_pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + + +def test_install_polarization_ratio(pbso4_pattern, other_pbso4_pattern): + # Test the polarization factor that a polarization rate gives + # Expected: With a Cu tube, a rate f = 0.95 changes the strongest + # PbSO4 peak, relative to an unpolarized beam (f = 0), by + # (1 + A cos^2 2theta) / (1 + A) / ((1 + cos^2 2theta) / 2), with + # A = (1 - f) / (1 + f) and 2theta that peak's position. (The peak is + # at 29.69 degrees, and the ratio is 1.133.) + # Calculate the pattern of an unpolarized beam + unpolarized_instrument = ConstantWavelengthInstrument() + unpolarized_instrument.set_xray_source("Cu") + unpolarized_instrument.install( + pbso4_pattern.pattern, pbso4_pattern.diffraction + ) + unpolarized_instrument.update( + pbso4_pattern.pattern, pbso4_pattern.diffraction + ) + unpolarized_pattern = pbso4_pattern.calculate() + # Calculate the pattern of a beam with f = 0.95 on a second pattern + polarized_instrument = ConstantWavelengthInstrument() + polarized_instrument.set_xray_source("Cu") + polarized_instrument.set_polarization(0.95) + polarized_instrument.install( + other_pbso4_pattern.pattern, other_pbso4_pattern.diffraction + ) + polarized_instrument.update( + other_pbso4_pattern.pattern, other_pbso4_pattern.diffraction + ) + polarized_pattern = other_pbso4_pattern.calculate() + # Compare the strongest peak with the polarization formula + peak_index = np.argmax(unpolarized_pattern) + peak_two_theta = np.degrees( + pbso4_pattern.pattern.GetPowderPatternX()[peak_index] + ) + cos_squared = np.cos(np.radians(peak_two_theta)) ** 2 + polarization_a = (1 - 0.95) / (1 + 0.95) + actual_ratio = polarized_pattern[peak_index] / ( + unpolarized_pattern[peak_index] + ) + expected_ratio = ( + (1 + polarization_a * cos_squared) + / (1 + polarization_a) + / ((1 + cos_squared) / 2) + ) + assert actual_ratio == pytest.approx(expected_ratio, rel=1e-3) + + +def test_install_after_polarization_change(pbso4_pattern, other_pbso4_pattern): + # Test installing again after only the polarization rate changed, + # which ObjCryst doesn't recalculate on its own + # Expected: The pattern equals a pyobjcryst pattern built with a Cu + # tube and f = 0.95 from the start. + # Install and calculate with an unpolarized Cu tube + instrument = ConstantWavelengthInstrument() + instrument.set_xray_source("Cu") + instrument.install(pbso4_pattern.pattern, pbso4_pattern.diffraction) + instrument.update(pbso4_pattern.pattern, pbso4_pattern.diffraction) + pbso4_pattern.calculate() + # Change only the polarization rate, then install and calculate again + instrument.set_polarization(0.95) + instrument.install(pbso4_pattern.pattern, pbso4_pattern.diffraction) + instrument.update(pbso4_pattern.pattern, pbso4_pattern.diffraction) + actual_pattern = pbso4_pattern.calculate() + # On a second pattern, build the Cu tube with f = 0.95 directly + other_pbso4_pattern.pattern.SetWavelength("Cu", 0.5) + other_pbso4_pattern.pattern.GetRadiation().SetLinearPolarRate(0.95) + expected_pattern = other_pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + + +def test_install_tube_set_after_polarization( + pbso4_pattern, other_pbso4_pattern +): + # Test setting the tube after the polarization rate, since ObjCryst + # resets the rate when a tube is set + # Expected: The pattern still has the rate, f = 0.95: it equals a + # pyobjcryst pattern with the Cu tube set first and the rate second. + # Set the polarization first and the tube second, then install + instrument = ConstantWavelengthInstrument() + instrument.set_polarization(0.95) + instrument.set_xray_source("Cu") + instrument.install(pbso4_pattern.pattern, pbso4_pattern.diffraction) + instrument.update(pbso4_pattern.pattern, pbso4_pattern.diffraction) + actual_pattern = pbso4_pattern.calculate() + # On a second pattern, set the Cu tube and then the rate directly + other_pbso4_pattern.pattern.SetWavelength("Cu", 0.5) + other_pbso4_pattern.pattern.GetRadiation().SetLinearPolarRate(0.95) + expected_pattern = other_pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + + +@pytest.mark.parametrize( + "polarization_rate", + [ + # Test that the polarization rates at the edges of [-1, 1] are + # accepted and installed + # C1: The rate is -1. + # Expected: The pattern's radiation has a rate of -1. + -1.0, + # C2: The rate is 1. + # Expected: The pattern's radiation has a rate of 1. + 1.0, + ], +) +def test_install_polarization_rate_at_range_edges( + pbso4_pattern, polarization_rate +): + instrument = ConstantWavelengthInstrument() + instrument.set_xray_source("Cu") + instrument.set_polarization(polarization_rate) + instrument.install(pbso4_pattern.pattern, pbso4_pattern.diffraction) + radiation = pbso4_pattern.pattern.GetRadiation() + actual_polarization_rate = radiation.GetLinearPolarRate() + expected_polarization_rate = polarization_rate + assert actual_polarization_rate == pytest.approx( + expected_polarization_rate + ) + + +def test_install_single_wavelength_after_tube( + pbso4_pattern, other_pbso4_pattern +): + # Test switching from a tube to a single wavelength, since ObjCryst + # keeps the tube's Kalpha1/Kalpha2 doublet after SetWavelength(float) + # Expected: The pattern equals a single-wavelength pyobjcryst pattern + # at 1.5406 angstroms, not a doublet. + # Install and calculate with a Cu tube + instrument = ConstantWavelengthInstrument() + instrument.set_xray_source("Cu") + instrument.install(pbso4_pattern.pattern, pbso4_pattern.diffraction) + instrument.update(pbso4_pattern.pattern, pbso4_pattern.diffraction) + pbso4_pattern.calculate() + # Switch to a single wavelength, then install and calculate again + instrument.set_wavelength(1.5406) + instrument.install(pbso4_pattern.pattern, pbso4_pattern.diffraction) + instrument.update(pbso4_pattern.pattern, pbso4_pattern.diffraction) + actual_pattern = pbso4_pattern.calculate() + # The second pattern was built at a single wavelength of 1.5406 + expected_pattern = other_pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + + +@pytest.mark.parametrize( + "kalpha2_ratio", + [ + # Test installing a Cu tube + # C1: The Kalpha2/Kalpha1 ratio is the default, 0.5. + # Expected: The pattern equals a pyobjcryst pattern set directly to + # the Cu tube with that ratio. + 0.5, + # C2: The ratio is 0.4, which ObjCryst would clamp to 0.5 if it + # were set through its RefinablePar. + # Expected: The pattern equals a pyobjcryst pattern set directly to + # the Cu tube with a ratio of 0.4. + 0.4, + ], +) +def test_install_xray_source( + pbso4_pattern, other_pbso4_pattern, kalpha2_ratio +): + # Install the Cu tube + instrument = ConstantWavelengthInstrument() + instrument.set_xray_source("Cu", kalpha2_ratio) + instrument.install(pbso4_pattern.pattern, pbso4_pattern.diffraction) + instrument.update(pbso4_pattern.pattern, pbso4_pattern.diffraction) + actual_pattern = pbso4_pattern.calculate() + # On a second pattern, set the Cu tube directly in pyobjcryst + other_pbso4_pattern.pattern.SetWavelength("Cu", kalpha2_ratio) + expected_pattern = other_pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + + +@pytest.mark.parametrize( + "kalpha2_ratio", + [ + # Test setting the Kalpha1 and Kalpha2 wavelengths explicitly + # C1: ObjCryst's Cu wavelengths are set with the default + # Kalpha2/Kalpha1 ratio, 0.5. + # Expected: The pattern equals a pyobjcryst pattern set directly to + # the Cu tube with that ratio. + 0.5, + # C2: ObjCryst's Cu wavelengths are set with a ratio of 0.4. + # Expected: The pattern equals a pyobjcryst pattern set directly to + # the Cu tube with a ratio of 0.4. + 0.4, + ], +) +def test_install_xray_wavelengths( + pbso4_pattern, other_pbso4_pattern, kalpha2_ratio +): + # Install the explicit wavelengths + instrument = ConstantWavelengthInstrument() + instrument.set_xray_wavelengths(CU_KALPHA1, CU_KALPHA2, kalpha2_ratio) + instrument.install(pbso4_pattern.pattern, pbso4_pattern.diffraction) + instrument.update(pbso4_pattern.pattern, pbso4_pattern.diffraction) + actual_pattern = pbso4_pattern.calculate() + # On a second pattern, set the Cu tube directly in pyobjcryst + other_pbso4_pattern.pattern.SetWavelength("Cu", kalpha2_ratio) + expected_pattern = other_pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + + +def test_set_xray_source_unknown_element(): + # Test setting a tube whose element ObjCryst has no wavelengths for + # Expected: A ValueError names the element and how to set the + # wavelengths instead, rather than ObjCryst's printed warning. + instrument = ConstantWavelengthInstrument() + expected_msg = ( + "ObjCryst has no x-ray tube wavelengths for 'Xx'. Please use one of " + "the anode elements it has, 'Ag', 'Co', 'Cr', 'Cu', 'Fe' or 'Mo', or " + "set the wavelengths with set_xray_wavelengths." + ) + with pytest.raises(ValueError, match=re.escape(expected_msg)): + instrument.set_xray_source("Xx") + + +@pytest.mark.parametrize( + "setter_name, setter_args, expected_msg", + [ + # Test setting a wavelength outside ObjCryst's Wavelength limits, + # [0.05, 20] angstroms, which ObjCryst would clamp + # C1: A single wavelength above 20 angstroms is set. + # Expected: A ValueError names the wavelength and the limits. + ( + "set_wavelength", + [25.0], + "The wavelength 25.0 angstroms is outside ObjCryst's limits of " + "0.05 to 20 angstroms. Please give a wavelength within these " + "limits.", + ), + # C2: The 0.0251 angstroms of a 200 kV electron beam, below 0.05 + # angstroms, is set. + # Expected: A ValueError names the wavelength and the limits. + ( + "set_wavelength", + [0.0251], + "The wavelength 0.0251 angstroms is outside ObjCryst's limits of " + "0.05 to 20 angstroms. Please give a wavelength within these " + "limits.", + ), + # C3: Kalpha1 and Kalpha2 wavelengths above 20 angstroms are set. + # Expected: A ValueError names Kalpha1, the first wavelength + # outside the limits. + ( + "set_xray_wavelengths", + [25.0, 25.004], + "The wavelength 25.0 angstroms is outside ObjCryst's limits of " + "0.05 to 20 angstroms. Please give a wavelength within these " + "limits.", + ), + ], +) +def test_set_wavelength_outside_limits(setter_name, setter_args, expected_msg): + instrument = ConstantWavelengthInstrument() + with pytest.raises(ValueError, match=re.escape(expected_msg)): + getattr(instrument, setter_name)(*setter_args) + + +def test_set_xray_wavelengths_kalpha2_not_longer(): + # Test setting a Kalpha2 that isn't longer than Kalpha1, which would + # make ObjCryst's Kalpha2 - Kalpha1 negative + # Expected: A ValueError says that the wavelengths are swapped. + instrument = ConstantWavelengthInstrument() + expected_msg = ( + "Kalpha2, 1.54056 angstroms, is not longer than Kalpha1, 1.54439 " + "angstroms. Please give the wavelengths in the order kalpha1, " + "kalpha2." + ) + with pytest.raises(ValueError, match=re.escape(expected_msg)): + instrument.set_xray_wavelengths(1.544390, 1.540560) + + +@pytest.mark.parametrize( + "setter_name, setter_args", + [ + # Test setting a Kalpha1/Kalpha2 doublet while the radiation type + # is neutrons, since a doublet is x-ray tube radiation + # C1: A Cu tube is set. + # Expected: A ValueError says that a doublet needs x-rays. + ("set_xray_source", ["Cu"]), + # C2: The Cu Kalpha1 and Kalpha2 wavelengths are set explicitly. + # Expected: A ValueError says that a doublet needs x-rays. + ("set_xray_wavelengths", [CU_KALPHA1, CU_KALPHA2]), + ], +) +def test_set_doublet_with_neutron_radiation(setter_name, setter_args): + instrument = ConstantWavelengthInstrument() + instrument.set_radiation_type("N") + expected_msg = ( + "A Kalpha1/Kalpha2 doublet needs x-rays, but the radiation type is " + "'N'. Please set the radiation type to 'X' first, or set a single " + "wavelength with set_wavelength." + ) + with pytest.raises(ValueError, match=re.escape(expected_msg)): + getattr(instrument, setter_name)(*setter_args) + + +@pytest.mark.parametrize( + "radiation_type, expected_msg", + [ + # Test setting a radiation type other than x-rays while a + # Kalpha1/Kalpha2 doublet is set + # C1: Neutrons are set after a Cu tube. + # Expected: A ValueError says to set a single wavelength first. + ( + "N", + "The radiation type 'N' can't be used with the Kalpha1/Kalpha2 " + "doublet that is set, which needs x-rays. Please set a single " + "wavelength with set_wavelength first.", + ), + # C2: Electrons are set after a Cu tube. + # Expected: A ValueError says to set a single wavelength first. + ( + "E", + "The radiation type 'E' can't be used with the Kalpha1/Kalpha2 " + "doublet that is set, which needs x-rays. Please set a single " + "wavelength with set_wavelength first.", + ), + ], +) +def test_set_radiation_type_with_doublet(radiation_type, expected_msg): + instrument = ConstantWavelengthInstrument() + instrument.set_xray_source("Cu") + with pytest.raises(ValueError, match=re.escape(expected_msg)): + instrument.set_radiation_type(radiation_type) + + +def test_set_radiation_type_unknown_type(): + # Test setting a radiation type that isn't x-rays, neutrons or + # electrons + # Expected: A ValueError names the type and lists the supported ones. + instrument = ConstantWavelengthInstrument() + expected_msg = ( + "'Q' is not a supported radiation type. Please use 'X' for x-rays, " + "'N' for neutrons or 'E' for electrons." + ) + with pytest.raises(ValueError, match=re.escape(expected_msg)): + instrument.set_radiation_type("Q") + + +@pytest.mark.parametrize( + "polarization_rate, expected_msg", + [ + # Test setting a polarization rate outside [-1, 1] + # C1: The rate is above 1. + # Expected: A ValueError names the rate and how to convert a GSAS + # polarization. + ( + 2.0, + "The polarization rate 2.0 is outside [-1, 1]. Please give " + "ObjCryst's rate f; for a GSAS polarization P, f = 2P - 1.", + ), + # C2: The rate is below -1. + # Expected: A ValueError names the rate and how to convert a GSAS + # polarization. + ( + -1.5, + "The polarization rate -1.5 is outside [-1, 1]. Please give " + "ObjCryst's rate f; for a GSAS polarization P, f = 2P - 1.", + ), + ], +) +def test_set_polarization_outside_range(polarization_rate, expected_msg): + instrument = ConstantWavelengthInstrument() + with pytest.raises(ValueError, match=re.escape(expected_msg)): + instrument.set_polarization(polarization_rate) From 949ea4b3dc144698f3aa24c715ff76f6aa42d7ec Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Wed, 28 Oct 2026 18:01:16 -0400 Subject: [PATCH 5/8] feat: add constant wavelength instrument class --- news/constant-wavelength-instrument.rst | 23 ++ src/diffpy/cmirietveld/component.py | 4 +- src/diffpy/cmirietveld/instruments.py | 272 +++++++++++++++++++++++- 3 files changed, 290 insertions(+), 9 deletions(-) create mode 100644 news/constant-wavelength-instrument.rst diff --git a/news/constant-wavelength-instrument.rst b/news/constant-wavelength-instrument.rst new file mode 100644 index 0000000..75b15fd --- /dev/null +++ b/news/constant-wavelength-instrument.rst @@ -0,0 +1,23 @@ +**Added:** + +* Add ``ConstantWavelengthInstrument``, the instrument of a constant-wavelength x-ray, neutron or electron diffractometer, with refinable ``zero``, ``displacement``, ``transparency``, ``mu_r`` and ``wavelength``, and settings for the radiation type, an x-ray source's Kalpha1/Kalpha2 doublet and the polarization rate. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/cmirietveld/component.py b/src/diffpy/cmirietveld/component.py index f98425d..a6892fc 100644 --- a/src/diffpy/cmirietveld/component.py +++ b/src/diffpy/cmirietveld/component.py @@ -107,8 +107,8 @@ def mark_settings_changed(self): -------- A setting method of an instrument: - >>> def set_xray_tube(self, element): - ... self._xray_tube = element + >>> def set_xray_source(self, element): + ... self._xray_source = element ... self.mark_settings_changed() """ self._settings_version += 1 diff --git a/src/diffpy/cmirietveld/instruments.py b/src/diffpy/cmirietveld/instruments.py index ad1047c..488200a 100644 --- a/src/diffpy/cmirietveld/instruments.py +++ b/src/diffpy/cmirietveld/instruments.py @@ -24,7 +24,17 @@ "TimeOfFlightInstrument", ] +from pyobjcryst.powderpattern import PowderPattern +from pyobjcryst.radiation import RadiationType, WavelengthType + from diffpy.cmirietveld.component import Component +from diffpy.srfit.exceptions import SrFitError + +RADIATION_TYPES = { + "X": RadiationType.RAD_XRAY, + "N": RadiationType.RAD_NEUTRON, + "E": RadiationType.RAD_ELECTRON, +} class Instrument(Component): @@ -43,6 +53,14 @@ class ConstantWavelengthInstrument(Instrument): """Instrument of a constant-wavelength x-ray or neutron diffractometer. + The wavelength has no default, because no dataset uses ObjCryst's + default of 1 angstrom. Set it with ``set_wavelength``, + ``set_xray_source`` or ``set_xray_wavelengths`` before calculating a + pattern. The radiation type, the Kalpha1/Kalpha2 doublet and the + polarization rate are settings, applied by ``install``. A doublet is + x-ray tube radiation, so it can only be set with the radiation type + "X". + Managed Parameters ------------------ zero @@ -54,8 +72,41 @@ class ConstantWavelengthInstrument(Instrument): mu_r The cylinder absorption of the sample, the product of the linear absorption coefficient and the radius. + wavelength + The wavelength in angstroms, the intensity-weighted mean for a + Kalpha1/Kalpha2 doublet. It is refinable, for example to + calibrate an instrument against a standard. """ + def __init__(self): + Instrument.__init__(self) + pattern = PowderPattern() + radiation = pattern.GetRadiation() + self.create_parameters_from( + pattern, + { + "zero": "Zero", + "displacement": "2ThetaDispl", + "transparency": "2ThetaTransp", + "mu_r": "MuR", + }, + target="pattern", + ) + self.create_parameters_from( + radiation, {"wavelength": "Wavelength"}, target="radiation" + ) + self.wavelength.set_value(None) + wavelength_par = radiation.GetPar("Wavelength") + self._wavelength_limits = ( + wavelength_par.GetHumanMin(), + wavelength_par.GetHumanMax(), + ) + self._radiation_type = "X" + self._xray_source = None + self._kalpha_wavelengths = None + self._kalpha2_ratio = 0.5 + self._polarization_rate = 0.0 + def set_radiation_type(self, radiation_type): """Set the radiation type. @@ -64,31 +115,140 @@ def set_radiation_type(self, radiation_type): radiation_type : str The radiation type: "X" for x-rays, "N" for neutrons or "E" for electrons. + + Raises + ------ + ValueError + If the radiation type is not one of "X", "N" or "E", or it is + "N" or "E" while a Kalpha1/Kalpha2 doublet is set. """ - raise NotImplementedError + if radiation_type not in RADIATION_TYPES: + raise ValueError( + f"'{radiation_type}' is not a supported radiation type. " + "Please use 'X' for x-rays, 'N' for neutrons or 'E' for " + "electrons." + ) + has_doublet = ( + self._xray_source is not None + or self._kalpha_wavelengths is not None + ) + if radiation_type != "X" and has_doublet: + raise ValueError( + f"The radiation type '{radiation_type}' can't be used with " + "the Kalpha1/Kalpha2 doublet that is set, which needs x-rays. " + "Please set a single wavelength with set_wavelength first." + ) + self._radiation_type = radiation_type + self.mark_settings_changed() def set_wavelength(self, wavelength): """Set a single wavelength. + This sets the ``wavelength`` Parameter and replaces a + Kalpha1/Kalpha2 doublet set before. ObjCryst limits the wavelength + to 0.05 to 20 angstroms, which leaves out the electrons of beams + faster than about 50 kV. + Parameters ---------- wavelength : float The wavelength in angstroms. + + Raises + ------ + ValueError + If the wavelength is outside ObjCryst's limits of 0.05 to 20 + angstroms. """ - raise NotImplementedError + self._check_wavelength_limits(wavelength) + self._xray_source = None + self._kalpha_wavelengths = None + self.wavelength.set_value(wavelength) + self.mark_settings_changed() - def set_xray_tube(self, element, kalpha2_ratio=0.5): + def set_xray_source(self, element, kalpha2_ratio=0.5): """Set the wavelengths of an x-ray tube's Kalpha1/Kalpha2 doublet. + The wavelengths are ObjCryst's tabulated ones for the anode + element. The ``wavelength`` Parameter is set to their + intensity-weighted mean. + Parameters ---------- element : str - The anode element, for example "Cu" or "Mo". + The anode element: "Ag", "Co", "Cr", "Cu", "Fe" or "Mo", the + ones ObjCryst has wavelengths for. kalpha2_ratio : float, optional The intensity ratio of Kalpha2 to Kalpha1 (default 0.5). + + Raises + ------ + ValueError + If ObjCryst has no tabulated wavelengths for the element, or + the radiation type is not "X". """ - raise NotImplementedError + self._check_xray_radiation() + # ObjCryst only prints a warning for an unknown element and keeps + # its wavelength, so an unchanged wavelength means it was unknown + tube_pattern = PowderPattern() + tube_radiation = tube_pattern.GetRadiation() + default_wavelength = tube_radiation.GetWavelength() + tube_pattern.SetWavelength(element, kalpha2_ratio) + mean_wavelength = tube_radiation.GetWavelength() + if mean_wavelength == default_wavelength: + raise ValueError( + f"ObjCryst has no x-ray tube wavelengths for '{element}'. " + "Please use one of the anode elements it has, 'Ag', 'Co', " + "'Cr', 'Cu', 'Fe' or 'Mo', or set the wavelengths with " + "set_xray_wavelengths." + ) + self._xray_source = element + self._kalpha_wavelengths = None + self._kalpha2_ratio = kalpha2_ratio + self.wavelength.set_value(mean_wavelength) + self.mark_settings_changed() + + def set_xray_wavelengths(self, kalpha1, kalpha2, kalpha2_ratio=0.5): + """Set the wavelengths of a Kalpha1/Kalpha2 doublet explicitly. + + This replaces ObjCryst's tabulated tube wavelengths, for example + with the calibrated ones of an instrument parameter file. The + ``wavelength`` Parameter is set to their intensity-weighted mean. + + Parameters + ---------- + kalpha1 : float + The Kalpha1 wavelength in angstroms. + kalpha2 : float + The Kalpha2 wavelength in angstroms. + kalpha2_ratio : float, optional + The intensity ratio of Kalpha2 to Kalpha1 (default 0.5). + + Raises + ------ + ValueError + If a wavelength is outside ObjCryst's limits of 0.05 to 20 + angstroms, Kalpha2 is not longer than Kalpha1, or the + radiation type is not "X". + """ + self._check_xray_radiation() + self._check_wavelength_limits(kalpha1) + self._check_wavelength_limits(kalpha2) + if kalpha2 <= kalpha1: + raise ValueError( + f"Kalpha2, {kalpha2} angstroms, is not longer than Kalpha1, " + f"{kalpha1} angstroms. Please give the wavelengths in the " + "order kalpha1, kalpha2." + ) + mean_wavelength = kalpha1 + (kalpha2 - kalpha1) * kalpha2_ratio / ( + 1 + kalpha2_ratio + ) + self._xray_source = None + self._kalpha_wavelengths = (kalpha1, kalpha2) + self._kalpha2_ratio = kalpha2_ratio + self.wavelength.set_value(mean_wavelength) + self.mark_settings_changed() def set_polarization(self, polarization_rate): """Set the polarization rate of the x-ray beam. @@ -96,9 +256,107 @@ def set_polarization(self, polarization_rate): Parameters ---------- polarization_rate : float - The polarization rate used by ObjCryst, from -1 to 1. + The polarization rate f used by ObjCryst, from -1 to 1. An + unpolarized beam has f = 0. For a GSAS-style polarization P, + f = 2P - 1. + + Raises + ------ + ValueError + If the rate is outside [-1, 1]. """ - raise NotImplementedError + if not -1 <= polarization_rate <= 1: + raise ValueError( + f"The polarization rate {polarization_rate} is outside " + "[-1, 1]. Please give ObjCryst's rate f; for a GSAS " + "polarization P, f = 2P - 1." + ) + self._polarization_rate = polarization_rate + self.mark_settings_changed() + + def install(self, pattern, diffraction): + """Set the radiation, wavelengths and polarization of one + generator. + + The radiation type is set first, then the wavelength or the + Kalpha1/Kalpha2 doublet, then the polarization rate, and the + pattern is prepared last. ObjCryst resets the rate when a tube is + set, and only recalculates for a new rate after the wavelength is + set again, so this order is what makes a change to any setting + take effect. The ``wavelength`` value itself is 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. + """ + radiation = pattern.GetRadiation() + pattern.SetRadiationType(RADIATION_TYPES[self._radiation_type]) + if self._xray_source is not None: + pattern.SetWavelength(self._xray_source, self._kalpha2_ratio) + elif self._kalpha_wavelengths is not None: + # Only SetWavelength(element, ratio) sets a ratio other than + # 0.5, so a tube sets up the doublet, and its Kalpha2 - Kalpha1 + # is replaced + kalpha1, kalpha2 = self._kalpha_wavelengths + pattern.SetWavelength("Cu", self._kalpha2_ratio) + delta_lambda_par = radiation.GetPar("XRayTubeDeltaLambda") + delta_lambda_par.SetHumanValue(kalpha2 - kalpha1) + else: + pattern.SetWavelength(self.wavelength.value) + # SetWavelength(wavelength) keeps a doublet set before + radiation.SetWavelengthType( + WavelengthType.WAVELENGTH_MONOCHROMATIC + ) + radiation.SetLinearPolarRate(self._polarization_rate) + pattern.Prepare() + + def update(self, pattern, diffraction): + """Push the Parameter values into the pattern and its radiation. + + 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(pattern, target="pattern") + self.push_parameters(pattern.GetRadiation(), target="radiation") + + def _validate(self): + if self.wavelength.value is None: + raise SrFitError( + "The instrument has no wavelength. Please set one with " + "set_wavelength, set_xray_source or set_xray_wavelengths." + ) + Instrument._validate(self) + + def _check_xray_radiation(self): + if self._radiation_type != "X": + raise ValueError( + "A Kalpha1/Kalpha2 doublet needs x-rays, but the radiation " + f"type is '{self._radiation_type}'. Please set the radiation " + "type to 'X' first, or set a single wavelength with " + "set_wavelength." + ) + + def _check_wavelength_limits(self, wavelength): + lower_limit, upper_limit = self._wavelength_limits + if not lower_limit <= wavelength <= upper_limit: + raise ValueError( + f"The wavelength {wavelength} angstroms is outside " + f"ObjCryst's limits of {lower_limit:g} to {upper_limit:g} " + "angstroms. Please give a wavelength within these limits." + ) class TimeOfFlightInstrument(Instrument): From b94babdb357f9833056a5d005e5f9598f6d7de69 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Thu, 29 Oct 2026 13:13:18 -0400 Subject: [PATCH 6/8] add warnings for wavelengths changing or being unintentionally reset during refinements --- news/constant-wavelength-instrument.rst | 1 + src/diffpy/cmirietveld/instruments.py | 137 +++++++++- tests/test_instruments.py | 328 ++++++++++++++++++++++++ 3 files changed, 459 insertions(+), 7 deletions(-) diff --git a/news/constant-wavelength-instrument.rst b/news/constant-wavelength-instrument.rst index 75b15fd..c658dd0 100644 --- a/news/constant-wavelength-instrument.rst +++ b/news/constant-wavelength-instrument.rst @@ -1,6 +1,7 @@ **Added:** * Add ``ConstantWavelengthInstrument``, the instrument of a constant-wavelength x-ray, neutron or electron diffractometer, with refinable ``zero``, ``displacement``, ``transparency``, ``mu_r`` and ``wavelength``, and settings for the radiation type, an x-ray source's Kalpha1/Kalpha2 doublet and the polarization rate. +* Warn when a ``ConstantWavelengthInstrument`` setter replaces a refined wavelength or a Kalpha1/Kalpha2 doublet, or when a polarization rate has no effect because the radiation isn't x-rays. **Changed:** diff --git a/src/diffpy/cmirietveld/instruments.py b/src/diffpy/cmirietveld/instruments.py index 488200a..2ee8695 100644 --- a/src/diffpy/cmirietveld/instruments.py +++ b/src/diffpy/cmirietveld/instruments.py @@ -24,6 +24,8 @@ "TimeOfFlightInstrument", ] +import warnings + from pyobjcryst.powderpattern import PowderPattern from pyobjcryst.radiation import RadiationType, WavelengthType @@ -61,6 +63,10 @@ class ConstantWavelengthInstrument(Instrument): x-ray tube radiation, so it can only be set with the radiation type "X". + The wavelength setters warn when they discard something the user may + still expect: a wavelength refined or set by hand since the last + setter call, or a Kalpha1/Kalpha2 doublet set by another setter. + Managed Parameters ------------------ zero @@ -106,6 +112,7 @@ def __init__(self): self._kalpha_wavelengths = None self._kalpha2_ratio = 0.5 self._polarization_rate = 0.0 + self._last_set_wavelength = None def set_radiation_type(self, radiation_type): """Set the radiation type. @@ -121,6 +128,12 @@ def set_radiation_type(self, radiation_type): ValueError If the radiation type is not one of "X", "N" or "E", or it is "N" or "E" while a Kalpha1/Kalpha2 doublet is set. + + Warns + ----- + UserWarning + If it is "N" or "E" while a polarization rate other than 0 + is set, since ObjCryst only applies the rate to x-rays. """ if radiation_type not in RADIATION_TYPES: raise ValueError( @@ -138,6 +151,9 @@ def set_radiation_type(self, radiation_type): "the Kalpha1/Kalpha2 doublet that is set, which needs x-rays. " "Please set a single wavelength with set_wavelength first." ) + self._warn_if_polarization_unused( + self._polarization_rate, radiation_type + ) self._radiation_type = radiation_type self.mark_settings_changed() @@ -159,14 +175,38 @@ def set_wavelength(self, wavelength): ValueError If the wavelength is outside ObjCryst's limits of 0.05 to 20 angstroms. + + Warns + ----- + UserWarning + If it replaces a wavelength refined or set by hand since the + last setter call, or a Kalpha1/Kalpha2 doublet. """ self._check_wavelength_limits(wavelength) + if self._xray_source is not None: + warnings.warn( + "set_wavelength replaced the " + f"{self._xray_source} Kalpha1/Kalpha2 doublet with a " + "single wavelength. To use the doublet again, call " + "set_xray_source.", + UserWarning, + stacklevel=2, + ) + elif self._kalpha_wavelengths is not None: + warnings.warn( + "set_wavelength replaced the explicit Kalpha1/Kalpha2 " + "doublet with a single wavelength. To use the doublet " + "again, call set_xray_wavelengths.", + UserWarning, + stacklevel=2, + ) + self._warn_if_wavelength_replaced("set_wavelength", wavelength) self._xray_source = None self._kalpha_wavelengths = None - self.wavelength.set_value(wavelength) + self._set_wavelength_value(wavelength) self.mark_settings_changed() - def set_xray_source(self, element, kalpha2_ratio=0.5): + def set_xray_source(self, element, kalpha2_ratio=None): """Set the wavelengths of an x-ray tube's Kalpha1/Kalpha2 doublet. @@ -180,15 +220,25 @@ def set_xray_source(self, element, kalpha2_ratio=0.5): The anode element: "Ag", "Co", "Cr", "Cu", "Fe" or "Mo", the ones ObjCryst has wavelengths for. kalpha2_ratio : float, optional - The intensity ratio of Kalpha2 to Kalpha1 (default 0.5). + The intensity ratio of Kalpha2 to Kalpha1. If it isn't given, + the ratio set before is kept, which is 0.5 until one is given. Raises ------ ValueError If ObjCryst has no tabulated wavelengths for the element, or the radiation type is not "X". + + Warns + ----- + UserWarning + If it replaces a wavelength refined or set by hand since the + last setter call, or a doublet set with + ``set_xray_wavelengths``. """ self._check_xray_radiation() + if kalpha2_ratio is None: + kalpha2_ratio = self._kalpha2_ratio # ObjCryst only prints a warning for an unknown element and keeps # its wavelength, so an unchanged wavelength means it was unknown tube_pattern = PowderPattern() @@ -203,13 +253,22 @@ def set_xray_source(self, element, kalpha2_ratio=0.5): "'Cr', 'Cu', 'Fe' or 'Mo', or set the wavelengths with " "set_xray_wavelengths." ) + if self._kalpha_wavelengths is not None: + warnings.warn( + "set_xray_source replaced the explicit Kalpha1/Kalpha2 " + f"doublet with the {element} doublet. To use the explicit " + "wavelengths again, call set_xray_wavelengths.", + UserWarning, + stacklevel=2, + ) + self._warn_if_wavelength_replaced("set_xray_source", mean_wavelength) self._xray_source = element self._kalpha_wavelengths = None self._kalpha2_ratio = kalpha2_ratio - self.wavelength.set_value(mean_wavelength) + self._set_wavelength_value(mean_wavelength) self.mark_settings_changed() - def set_xray_wavelengths(self, kalpha1, kalpha2, kalpha2_ratio=0.5): + def set_xray_wavelengths(self, kalpha1, kalpha2, kalpha2_ratio=None): """Set the wavelengths of a Kalpha1/Kalpha2 doublet explicitly. This replaces ObjCryst's tabulated tube wavelengths, for example @@ -223,7 +282,8 @@ def set_xray_wavelengths(self, kalpha1, kalpha2, kalpha2_ratio=0.5): kalpha2 : float The Kalpha2 wavelength in angstroms. kalpha2_ratio : float, optional - The intensity ratio of Kalpha2 to Kalpha1 (default 0.5). + The intensity ratio of Kalpha2 to Kalpha1. If it isn't given, + the ratio set before is kept, which is 0.5 until one is given. Raises ------ @@ -231,6 +291,12 @@ def set_xray_wavelengths(self, kalpha1, kalpha2, kalpha2_ratio=0.5): If a wavelength is outside ObjCryst's limits of 0.05 to 20 angstroms, Kalpha2 is not longer than Kalpha1, or the radiation type is not "X". + + Warns + ----- + UserWarning + If it replaces a wavelength refined or set by hand since the + last setter call, or a doublet set with ``set_xray_source``. """ self._check_xray_radiation() self._check_wavelength_limits(kalpha1) @@ -241,18 +307,35 @@ def set_xray_wavelengths(self, kalpha1, kalpha2, kalpha2_ratio=0.5): f"{kalpha1} angstroms. Please give the wavelengths in the " "order kalpha1, kalpha2." ) + if kalpha2_ratio is None: + kalpha2_ratio = self._kalpha2_ratio mean_wavelength = kalpha1 + (kalpha2 - kalpha1) * kalpha2_ratio / ( 1 + kalpha2_ratio ) + if self._xray_source is not None: + warnings.warn( + "set_xray_wavelengths replaced the " + f"{self._xray_source} Kalpha1/Kalpha2 doublet with the " + f"explicit wavelengths. To use the {self._xray_source} " + "doublet again, call set_xray_source.", + UserWarning, + stacklevel=2, + ) + self._warn_if_wavelength_replaced( + "set_xray_wavelengths", mean_wavelength + ) self._xray_source = None self._kalpha_wavelengths = (kalpha1, kalpha2) self._kalpha2_ratio = kalpha2_ratio - self.wavelength.set_value(mean_wavelength) + self._set_wavelength_value(mean_wavelength) self.mark_settings_changed() def set_polarization(self, polarization_rate): """Set the polarization rate of the x-ray beam. + ObjCryst only applies the rate to x-rays, so it has no effect + with neutrons or electrons. + Parameters ---------- polarization_rate : float @@ -264,6 +347,11 @@ def set_polarization(self, polarization_rate): ------ ValueError If the rate is outside [-1, 1]. + + Warns + ----- + UserWarning + If the rate is not 0 and the radiation type is "N" or "E". """ if not -1 <= polarization_rate <= 1: raise ValueError( @@ -271,6 +359,9 @@ def set_polarization(self, polarization_rate): "[-1, 1]. Please give ObjCryst's rate f; for a GSAS " "polarization P, f = 2P - 1." ) + self._warn_if_polarization_unused( + polarization_rate, self._radiation_type + ) self._polarization_rate = polarization_rate self.mark_settings_changed() @@ -340,6 +431,38 @@ def _validate(self): ) Instrument._validate(self) + def _set_wavelength_value(self, wavelength): + self.wavelength.set_value(wavelength) + self._last_set_wavelength = wavelength + + def _warn_if_wavelength_replaced(self, setter_name, new_wavelength): + current_wavelength = self.wavelength.value + if current_wavelength is None: + return + if current_wavelength == self._last_set_wavelength: + return + warnings.warn( + f"{setter_name} replaced the wavelength " + f"{current_wavelength:g} angstroms, which was refined or set " + f"by hand, with {new_wavelength:g} angstroms. To keep " + f"{current_wavelength:g} angstroms, set instrument.wavelength " + f"again after {setter_name}.", + UserWarning, + stacklevel=3, + ) + + def _warn_if_polarization_unused(self, polarization_rate, radiation_type): + if polarization_rate == 0 or radiation_type == "X": + return + warnings.warn( + f"The polarization rate {polarization_rate} has no effect with " + f"the radiation type '{radiation_type}', because ObjCryst only " + "applies it to x-rays. Please set a polarization rate only for " + "x-ray data.", + UserWarning, + stacklevel=3, + ) + def _check_xray_radiation(self): if self._radiation_type != "X": raise ValueError( diff --git a/tests/test_instruments.py b/tests/test_instruments.py index 27ef0a1..49cf81a 100644 --- a/tests/test_instruments.py +++ b/tests/test_instruments.py @@ -1,4 +1,5 @@ import re +import warnings import numpy as np import pytest @@ -182,6 +183,263 @@ def test_settings_version_after_setter(setter_name, setter_args): assert actual_settings_version == expected_settings_version +@pytest.mark.parametrize( + "setter_name, first_args, refined_wavelength, second_args, expected_msg", + [ + # Test that a setter warns when it replaces a wavelength refined + # since it was last called, so a calibration isn't lost without the + # user knowing + # C1: A user sets the wavelength to the synchrotron's wavelength of + # 0.4592 angstroms, refines it to 0.45903 against a calibrant, + # then reruns the setup cell of their script, which sets 0.4592 + # again. + # Expected: A UserWarning says that the refined 0.45903 was + # replaced by 0.4592, and how to keep it. + ( + "set_wavelength", + [0.4592], + 0.45903, + [0.4592], + "set_wavelength replaced the wavelength 0.45903 angstroms, which " + "was refined or set by hand, with 0.4592 angstroms. To keep " + "0.45903 angstroms, set instrument.wavelength again after " + "set_wavelength.", + ), + # C2: A user sets a Cu tube, refines the wavelength to 1.5422 + # angstroms against a calibrant, then sets the tube again with a + # different Kalpha2/Kalpha1 ratio. + # Expected: A UserWarning says that the refined 1.5422 was replaced + # by the tube's mean for that ratio, 1.54175, and how to keep it. + ( + "set_xray_source", + ["Cu"], + 1.5422, + ["Cu", 0.45], + "set_xray_source replaced the wavelength 1.5422 angstroms, which " + "was refined or set by hand, with 1.54175 angstroms. To keep " + "1.5422 angstroms, set instrument.wavelength again after " + "set_xray_source.", + ), + # C3: A user sets their own Kalpha1 and Kalpha2 wavelengths, refines + # the wavelength to 1.5422 angstroms against a calibrant, then + # reruns the setup cell of their script, which sets the same + # wavelengths again. + # Expected: A UserWarning says that the refined 1.5422 was replaced + # by the mean of the two wavelengths, 1.54184, and how to keep it. + ( + "set_xray_wavelengths", + [CU_KALPHA1, CU_KALPHA2], + 1.5422, + [CU_KALPHA1, CU_KALPHA2], + "set_xray_wavelengths replaced the wavelength 1.5422 angstroms, " + "which was refined or set by hand, with 1.54184 angstroms. To " + "keep 1.5422 angstroms, set instrument.wavelength again after " + "set_xray_wavelengths.", + ), + ], +) +def test_setter_warns_wavelength_replaced( + setter_name, first_args, refined_wavelength, second_args, expected_msg +): + # Set up the instrument's wavelength + instrument = ConstantWavelengthInstrument() + getattr(instrument, setter_name)(*first_args) + # Refine the wavelength against the calibrant, here set by hand + instrument.wavelength.set_value(refined_wavelength) + # Call the setter again + with pytest.warns(UserWarning, match=re.escape(expected_msg)): + getattr(instrument, setter_name)(*second_args) + + +@pytest.mark.parametrize( + "first_setter_name, first_args, second_setter_name, second_args, " + "expected_msg", + [ + # Test that a setter warns when it replaces a Kalpha1/Kalpha2 + # doublet set by another setter, so the doublet isn't lost without + # the user knowing + # C1: A user sets a Cu tube, then changes their mind and sets a + # single wavelength of 1.5406 angstroms. + # Expected: A UserWarning says that the Cu doublet was replaced by + # a single wavelength, and how to use the doublet again. + ( + "set_xray_source", + ["Cu"], + "set_wavelength", + [1.5406], + "set_wavelength replaced the Cu Kalpha1/Kalpha2 doublet with a " + "single wavelength. To use the doublet again, call " + "set_xray_source.", + ), + # C2: A user sets their own Kalpha1 and Kalpha2 wavelengths, then + # changes their mind and sets a single wavelength of 1.5406 + # angstroms. + # Expected: A UserWarning says that the doublet was replaced by a + # single wavelength, and how to use the doublet again. + ( + "set_xray_wavelengths", + [CU_KALPHA1, CU_KALPHA2], + "set_wavelength", + [1.5406], + "set_wavelength replaced the explicit Kalpha1/Kalpha2 doublet " + "with a single wavelength. To use the doublet again, call " + "set_xray_wavelengths.", + ), + # C3: A user sets a Cu tube, then sets their own Kalpha1 and Kalpha2 + # wavelengths instead. + # Expected: A UserWarning says that the Cu doublet was replaced, + # and how to use it again. + ( + "set_xray_source", + ["Cu"], + "set_xray_wavelengths", + [CU_KALPHA1, CU_KALPHA2], + "set_xray_wavelengths replaced the Cu Kalpha1/Kalpha2 doublet " + "with the explicit wavelengths. To use the Cu doublet again, " + "call set_xray_source.", + ), + # C4: A user sets their own Kalpha1 and Kalpha2 wavelengths, then + # copies in code from another script that sets a Cu tube. + # Expected: A UserWarning says that their wavelengths were replaced + # by the Cu doublet, and how to use them again. + ( + "set_xray_wavelengths", + [CU_KALPHA1, CU_KALPHA2], + "set_xray_source", + ["Cu"], + "set_xray_source replaced the explicit Kalpha1/Kalpha2 doublet " + "with the Cu doublet. To use the explicit wavelengths again, call " + "set_xray_wavelengths.", + ), + ], +) +def test_setter_warns_doublet_replaced( + first_setter_name, + first_args, + second_setter_name, + second_args, + expected_msg, +): + instrument = ConstantWavelengthInstrument() + getattr(instrument, first_setter_name)(*first_args) + with pytest.warns(UserWarning, match=re.escape(expected_msg)): + getattr(instrument, second_setter_name)(*second_args) + + +@pytest.mark.parametrize( + "first_setter_name, first_args, second_setter_name, second_args, " + "expected_msg", + [ + # Test that a polarization rate warns when it has no effect, since + # ObjCryst only applies it to x-rays + # C1: A user sets up a neutron instrument, then copies in a + # polarization rate of 0.95 from their x-ray script. + # Expected: A UserWarning says that the rate does nothing with + # neutrons. + ( + "set_radiation_type", + ["N"], + "set_polarization", + [0.95], + "The polarization rate 0.95 has no effect with the radiation type " + "'N', because ObjCryst only applies it to x-rays. Please set a " + "polarization rate only for x-ray data.", + ), + # C2: A user sets a polarization rate of 0.95 for their x-ray data, + # then switches the instrument to neutrons. + # Expected: A UserWarning says that the rate now does nothing. + ( + "set_polarization", + [0.95], + "set_radiation_type", + ["N"], + "The polarization rate 0.95 has no effect with the radiation type " + "'N', because ObjCryst only applies it to x-rays. Please set a " + "polarization rate only for x-ray data.", + ), + # C3: A user sets a polarization rate of 0.95, then switches the + # instrument to electrons. + # Expected: A UserWarning says that the rate now does nothing. + ( + "set_polarization", + [0.95], + "set_radiation_type", + ["E"], + "The polarization rate 0.95 has no effect with the radiation type " + "'E', because ObjCryst only applies it to x-rays. Please set a " + "polarization rate only for x-ray data.", + ), + ], +) +def test_polarization_warns_without_xrays( + first_setter_name, + first_args, + second_setter_name, + second_args, + expected_msg, +): + instrument = ConstantWavelengthInstrument() + getattr(instrument, first_setter_name)(*first_args) + with pytest.warns(UserWarning, match=re.escape(expected_msg)): + getattr(instrument, second_setter_name)(*second_args) + + +@pytest.mark.parametrize( + "first_setter_name, first_args, second_setter_name, second_args", + [ + # Test that the setters stay silent when a user changes their own + # setup before refining, so the warnings only appear when something + # the user may still expect is lost + # C1: A user sets a Cu tube, then switches to a Mo tube before + # refining. + # Expected: No warning, because the wavelength replaced is the Cu + # tube's, not a refined one. + ("set_xray_source", ["Cu"], "set_xray_source", ["Mo"]), + # C2: A user sets the synchrotron's wavelength of 0.4592 angstroms, + # then corrects it to 0.45917 before refining. + # Expected: No warning, because the wavelength replaced is the one + # the user set. + ("set_wavelength", [0.4592], "set_wavelength", [0.45917]), + # C3: A user sets a single wavelength of 1.5406 angstroms, then + # switches to a Cu tube. + # Expected: No warning, because no doublet is replaced. + ("set_wavelength", [1.5406], "set_xray_source", ["Cu"]), + # C4: A user sets a single wavelength of 1.5406 angstroms, then + # switches to their own Kalpha1 and Kalpha2 wavelengths. + # Expected: No warning, because no doublet is replaced. + ( + "set_wavelength", + [1.5406], + "set_xray_wavelengths", + [CU_KALPHA1, CU_KALPHA2], + ), + # C5: A user sets a Cu tube, then a polarization rate of 0.95. + # Expected: No warning, because the rate applies to x-rays. + ("set_xray_source", ["Cu"], "set_polarization", [0.95]), + # C6: A user sets up a neutron instrument and leaves the + # polarization at 0. + # Expected: No warning, because a rate of 0 changes nothing. + ("set_radiation_type", ["N"], "set_polarization", [0.0]), + ], +) +def test_setter_without_warning( + first_setter_name, first_args, second_setter_name, second_args +): + # Record every warning from the first setter call, which replaces no + # wavelength, and from the second + instrument = ConstantWavelengthInstrument() + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.simplefilter("always") + getattr(instrument, first_setter_name)(*first_args) + getattr(instrument, second_setter_name)(*second_args) + actual_warning_messages = [] + for caught_warning in caught_warnings: + if issubclass(caught_warning.category, UserWarning): + actual_warning_messages.append(str(caught_warning.message)) + expected_warning_messages = [] + assert actual_warning_messages == expected_warning_messages + + @pytest.mark.parametrize( "parameter_name, pyobjcryst_par_name, new_value", [ @@ -324,6 +582,9 @@ def test_install_radiation_type( assert actual_radiation_type == expected_radiation_type +# The doublet-replaced warning is tested in +# test_setter_warns_doublet_replaced +@pytest.mark.filterwarnings("ignore::UserWarning") def test_install_neutrons_after_tube(pbso4_pattern, other_pbso4_pattern): # Test switching an installed x-ray tube instrument to neutrons # Expected: The pattern equals a pyobjcryst pattern set directly to @@ -466,6 +727,9 @@ def test_install_polarization_rate_at_range_edges( ) +# The doublet-replaced warning is tested in +# test_setter_warns_doublet_replaced +@pytest.mark.filterwarnings("ignore::UserWarning") def test_install_single_wavelength_after_tube( pbso4_pattern, other_pbso4_pattern ): @@ -549,6 +813,70 @@ def test_install_xray_wavelengths( assert np.allclose(actual_pattern, expected_pattern) +# The doublet-replaced warnings of C3 and C4 are tested in +# test_setter_warns_doublet_replaced +@pytest.mark.filterwarnings("ignore::UserWarning") +@pytest.mark.parametrize( + "first_setter_name, first_args, second_setter_name, second_args, " + "expected_kalpha2_ratio", + [ + # Test that the Kalpha2/Kalpha1 ratio is kept when a user changes + # the doublet without giving a ratio, so it doesn't fall back to 0.5 + # without the user knowing + # C1: A user sets a Cu tube, then switches to a Mo tube, without + # giving a ratio either time. + # Expected: The ratio is 0.5, the starting ratio. + ("set_xray_source", ["Cu"], "set_xray_source", ["Mo"], 0.5), + # C2: A user sets a Cu tube with a ratio of 0.45, then switches to a + # Mo tube. + # Expected: The ratio is still 0.45. + ("set_xray_source", ["Cu", 0.45], "set_xray_source", ["Mo"], 0.45), + # C3: A user sets a Cu tube with a ratio of 0.45, then switches to + # their own Kalpha1 and Kalpha2 wavelengths. + # Expected: The ratio is still 0.45. + ( + "set_xray_source", + ["Cu", 0.45], + "set_xray_wavelengths", + [CU_KALPHA1, CU_KALPHA2], + 0.45, + ), + # C4: A user sets their own Kalpha1 and Kalpha2 wavelengths with a + # ratio of 0.45, then switches to a Cu tube. + # Expected: The ratio is still 0.45. + ( + "set_xray_wavelengths", + [CU_KALPHA1, CU_KALPHA2, 0.45], + "set_xray_source", + ["Cu"], + 0.45, + ), + # C5: A user sets a Cu tube with a ratio of 0.45, then sets it again + # with a ratio of 0.4. + # Expected: The ratio is 0.4, the one given last. + ("set_xray_source", ["Cu", 0.45], "set_xray_source", ["Cu", 0.4], 0.4), + ], +) +def test_install_kalpha2_ratio_kept( + pbso4_pattern, + first_setter_name, + first_args, + second_setter_name, + second_args, + expected_kalpha2_ratio, +): + # Set the first doublet, then change it + instrument = ConstantWavelengthInstrument() + getattr(instrument, first_setter_name)(*first_args) + getattr(instrument, second_setter_name)(*second_args) + # Install it and read the ratio ObjCryst uses + instrument.install(pbso4_pattern.pattern, pbso4_pattern.diffraction) + radiation = pbso4_pattern.pattern.GetRadiation() + ratio_par = radiation.GetPar("XRayTubeAlpha2Alpha1Ratio") + actual_kalpha2_ratio = ratio_par.GetHumanValue() + assert actual_kalpha2_ratio == pytest.approx(expected_kalpha2_ratio) + + def test_set_xray_source_unknown_element(): # Test setting a tube whose element ObjCryst has no wavelengths for # Expected: A ValueError names the element and how to set the From 4dc01511bd751e183b82ec7a3cb5d87430cd0bc9 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Thu, 29 Oct 2026 13:13:42 -0400 Subject: [PATCH 7/8] ci: change ci to get pyobjcryst from main --- .github/workflows/tests-on-pr.yml | 1 + requirements/conda.txt | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests-on-pr.yml b/.github/workflows/tests-on-pr.yml index 3e817d5..c41c403 100644 --- a/.github/workflows/tests-on-pr.yml +++ b/.github/workflows/tests-on-pr.yml @@ -11,5 +11,6 @@ jobs: project: diffpy.cmirietveld c_extension: false headless: false + run: python -m pip install git+https://github.com/diffpy/pyobjcryst.git@main --no-deps secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/requirements/conda.txt b/requirements/conda.txt index f69c4a0..4fe1a65 100644 --- a/requirements/conda.txt +++ b/requirements/conda.txt @@ -1,3 +1,5 @@ numpy diffpy.srfit -pyobjcryst +libobjcryst>=2026.3.0 +libboost-devel +libboost-python From 2ce0be8c3079e83f34ea158e5bb4ce566df0b005 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Thu, 29 Oct 2026 15:02:56 -0400 Subject: [PATCH 8/8] pin pyobjcryst to a commit rather than main --- .github/workflows/tests-on-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests-on-pr.yml b/.github/workflows/tests-on-pr.yml index c41c403..f4f7972 100644 --- a/.github/workflows/tests-on-pr.yml +++ b/.github/workflows/tests-on-pr.yml @@ -11,6 +11,6 @@ jobs: project: diffpy.cmirietveld c_extension: false headless: false - run: python -m pip install git+https://github.com/diffpy/pyobjcryst.git@main --no-deps + run: python -m pip install git+https://github.com/diffpy/pyobjcryst.git@d6a1279a26b9b1e6e303d103935b4469c5a62e41 --no-deps secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}