From 696af8217ad4c946241bb219a4e36cd641120cd8 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Mon, 2 Nov 2026 12:10:10 -0500 Subject: [PATCH 1/6] ci: use pypi version of diffpy.cmistructure --- .github/workflows/tests-on-pr.yml | 4 +++- requirements/pip.txt | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests-on-pr.yml b/.github/workflows/tests-on-pr.yml index f4f7972..f6a6019 100644 --- a/.github/workflows/tests-on-pr.yml +++ b/.github/workflows/tests-on-pr.yml @@ -11,6 +11,8 @@ jobs: project: diffpy.cmirietveld c_extension: false headless: false - run: python -m pip install git+https://github.com/diffpy/pyobjcryst.git@d6a1279a26b9b1e6e303d103935b4469c5a62e41 --no-deps + run: | + python -m pip install git+https://github.com/diffpy/pyobjcryst.git@d6a1279a26b9b1e6e303d103935b4469c5a62e41 --no-deps + python -m pip install diffpy.cmistructure --no-deps secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/requirements/pip.txt b/requirements/pip.txt index f69c4a0..65cc1e0 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -1,3 +1,4 @@ numpy diffpy.srfit +diffpy.cmistructure pyobjcryst From 303ccd699d24bae0a2d87864fafba7d7c0b00be1 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Tue, 3 Nov 2026 11:02:13 -0500 Subject: [PATCH 2/6] feat: add powderdiffractiongenerator --- news/powder-diffraction-generator.rst | 24 + .../cmirietveld/powderdiffractiongenerator.py | 282 +++++- tests/conftest.py | 81 +- tests/test_powderdiffractiongenerator.py | 944 ++++++++++++++++++ tests/testdata/README.rst | 4 + tests/testdata/pbso4.stru | 153 +++ 6 files changed, 1465 insertions(+), 23 deletions(-) create mode 100644 news/powder-diffraction-generator.rst create mode 100644 tests/test_powderdiffractiongenerator.py create mode 100644 tests/testdata/pbso4.stru diff --git a/news/powder-diffraction-generator.rst b/news/powder-diffraction-generator.rst new file mode 100644 index 0000000..82bd571 --- /dev/null +++ b/news/powder-diffraction-generator.rst @@ -0,0 +1,24 @@ +**Added:** + +* Add ``PowderDiffractionGenerator``, which calculates one phase's Bragg intensity with pyobjcryst from a structure set with ``set_structure`` (a pyobjcryst ``Crystal``, the path to a CIF file or a ParameterSet), a peak profile set with ``set_peak_profile`` and an instrument set with ``set_instrument``, and a refinable ``scale``. +* Warn when ``set_structure``, ``set_peak_profile`` or ``set_instrument`` replaces a structure or component whose Parameters are used in a fit recipe. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/cmirietveld/powderdiffractiongenerator.py b/src/diffpy/cmirietveld/powderdiffractiongenerator.py index 994f572..a49a99b 100644 --- a/src/diffpy/cmirietveld/powderdiffractiongenerator.py +++ b/src/diffpy/cmirietveld/powderdiffractiongenerator.py @@ -22,8 +22,35 @@ __all__ = ["PowderDiffractionGenerator"] +import warnings +from pathlib import Path + +import numpy as np +from pyobjcryst import ObjCrystException +from pyobjcryst.crystal import Crystal, create_crystal_from_cif +from pyobjcryst.powderpattern import PowderPattern + +from diffpy.cmirietveld.instruments import ( + ConstantWavelengthInstrument, + Instrument, +) +from diffpy.cmirietveld.peak_profiles import PeakProfile, PseudoVoigtProfile +from diffpy.cmistructure import structure_to_parameter_set +from diffpy.cmistructure.basestructureparset import BaseStructureParSet +from diffpy.srfit.exceptions import SrFitError from diffpy.srfit.fitbase import ProfileGenerator +PLACEHOLDER_TTH = np.linspace(5.0, 175.0, 1000) + +COMPONENT_STAGES = { + "instrument": (Instrument, "instrument", "ConstantWavelengthInstrument"), + "peak_profile": ( + PeakProfile, + "peak profile", + "PseudoVoigtProfile or TCHProfile", + ), +} + class PowderDiffractionGenerator(ProfileGenerator): """Calculate the Bragg intensity of one phase of a powder pattern @@ -65,36 +92,122 @@ class PowderDiffractionGenerator(ProfileGenerator): def __init__(self, name="bragg"): ProfileGenerator.__init__(self, name) self.phase = None - - def set_structure(self, structure, name="phase"): + self._pattern = None + self._diffraction = None + self._last_tth = np.empty(0) + self._installed_settings = {} + self._new_parameter("scale", 1.0) + self.add_parameter_set(ConstantWavelengthInstrument()) + self.add_parameter_set(PseudoVoigtProfile()) + + def set_structure(self, structure, name=None): """Set the structure used to calculate the pattern. + The structure is a pyobjcryst Crystal, the path to a CIF file, or a + ParameterSet that adapts a Crystal. A Crystal or a path is adapted + to a new ParameterSet, and a path is first read into a new Crystal. + A ParameterSet is used as it is, with its own name. + + Giving the same ParameterSet to several generators shares the + structure between them. Giving them the same path does not: each + call reads the file again, so each generator refines its own + structure. + Parameters ---------- - structure : pyobjcryst.crystal.Crystal - The crystal structure. + structure : Crystal, pathlib.Path, str or ObjCrystCrystalParSet + The pyobjcryst Crystal, the path to a CIF file holding it, or + the diffpy.cmistructure ParameterSet that adapts it. name : str, optional - The name of the ParameterSet that adapts the structure - (default "phase"). + The name of the ParameterSet made for a Crystal or a path + (default "phase"). With a ParameterSet, it must match the + ParameterSet's own name. + + Raises + ------ + FileNotFoundError + If ``structure`` is a path to a file that does not exist. + ValueError + If ``structure`` is a path to a file that isn't a CIF file + holding a crystal structure, a structure or ParameterSet that + ObjCryst can't calculate with, or a ParameterSet given with a + ``name`` other than its own. + + Warns + ----- + UserWarning + If a Parameter of the replaced structure is a variable of a fit + recipe or constrained in one, since the recipe would refine a + Parameter that no longer changes the pattern. """ - raise NotImplementedError + if isinstance(structure, BaseStructureParSet): + if name is not None and name != structure.name: + raise ValueError( + f"set_structure was given the name '{name}' with a " + "ParameterSet, which keeps its own name, " + f"'{structure.name}'. Please leave out the name, or give " + "it when you create the ParameterSet." + ) + crystal = structure.structure + elif isinstance(structure, (str, Path)): + crystal = _read_crystal_from_cif(Path(structure)) + else: + crystal = structure + if not isinstance(crystal, Crystal): + raise ValueError( + f"set_structure was given a {type(structure).__name__}, " + "which the powder diffraction generator can't calculate " + "with. Please give a pyobjcryst Crystal, the path to a CIF " + "file, or a ParameterSet made from a Crystal." + ) + if isinstance(structure, BaseStructureParSet): + phase = structure + else: + phase = structure_to_parameter_set(name or "phase", crystal) + self._replace_phase(phase, "set_structure") def set_structure_from_parset(self, phase): """Set the structure from an existing ParameterSet. - Passing the same ParameterSet to several generators, including - srfit's PDFGenerator, shares the structure between them. + This is the same as ``set_structure`` with a ParameterSet. It is + kept so the generator has the same methods as + diffpy.cmipdf's PDF generators. Parameters ---------- phase : ObjCrystCrystalParSet - The ParameterSet that adapts a pyobjcryst Crystal. + The diffpy.cmistructure ParameterSet that adapts a pyobjcryst + Crystal. + + Raises + ------ + ValueError + If ``phase`` does not adapt a pyobjcryst Crystal. + + Warns + ----- + UserWarning + If a Parameter of the replaced structure is a variable of a fit + recipe or constrained in one. """ - raise NotImplementedError + if not isinstance(phase, BaseStructureParSet) or not isinstance( + phase.structure, Crystal + ): + raise ValueError( + f"set_structure_from_parset was given a " + f"{type(phase).__name__}, which the powder diffraction " + "generator can't calculate with. Please give a ParameterSet " + "made from a pyobjcryst Crystal." + ) + self._replace_phase(phase, "set_structure_from_parset") def set_peak_profile(self, peak_profile): """Set the peak profile component. + The peak profile replaces the one set before, with its Parameters + under ``peak_profile``. It is installed once the generator has a + structure, so it can be set before or after the structure. + Parameters ---------- peak_profile : PeakProfile @@ -104,12 +217,23 @@ def set_peak_profile(self, peak_profile): ------ ValueError If ``peak_profile`` is not a PeakProfile. + + Warns + ----- + UserWarning + If a Parameter of the replaced peak profile is a variable of a + fit recipe or constrained in one, since the recipe would refine + a Parameter that no longer changes the pattern. """ - raise NotImplementedError + self._set_component("peak_profile", peak_profile) def set_instrument(self, instrument): """Set the instrument component. + The instrument replaces the one set before, with its Parameters + under ``instrument``. It is installed once the generator has a + structure, so it can be set before or after the structure. + Parameters ---------- instrument : Instrument @@ -119,8 +243,15 @@ def set_instrument(self, instrument): ------ ValueError If ``instrument`` is not an Instrument. + + Warns + ----- + UserWarning + If a Parameter of the replaced instrument is a variable of a + fit recipe or constrained in one, since the recipe would refine + a Parameter that no longer changes the pattern. """ - raise NotImplementedError + self._set_component("instrument", instrument) def add_intensity_correction(self, correction): """Add an intensity correction component. @@ -155,6 +286,11 @@ def set_intensity_source(self, intensity_source): def __call__(self, tth): """Calculate the Bragg intensity. + Each component whose settings changed since it was last installed + is installed again. Then each component pushes its Parameter + values into pyobjcryst, and the pattern is calculated and + multiplied by ``scale``. + Parameters ---------- tth : ndarray @@ -165,8 +301,126 @@ def __call__(self, tth): ------- ndarray The calculated Bragg intensity at each 2theta value. + + Raises + ------ + SrFitError + If no structure is set, or the instrument has no wavelength. + ValueError + If a component's Parameter value is outside the limits of its + ObjCryst ``RefinablePar``. """ - raise NotImplementedError + self._check_structure() + components = [self.get(stage_name) for stage_name in COMPONENT_STAGES] + for component in components: + component._validate() + if not np.array_equal(tth, self._last_tth): + self._set_tth(tth) + for component in components: + installed_component, installed_version = ( + self._installed_settings.get(component.name, (None, None)) + ) + if ( + installed_component is not component + or installed_version != component.settings_version + ): + component.install(self._pattern, self._diffraction) + self._installed_settings[component.name] = ( + component, + component.settings_version, + ) + for component in components: + component.update(self._pattern, self._diffraction) + self._pattern.Prepare() + calculated_pattern = np.array(self._pattern.GetPowderPatternCalc()) + bragg_intensity = self.scale.value * calculated_pattern + return bragg_intensity + + def _validate(self): + self._check_structure() + ProfileGenerator._validate(self) + + def _check_structure(self): + if self.phase is None: + raise SrFitError( + "The generator has no structure. Please set one with " + "set_structure or set_structure_from_parset." + ) + + def _replace_phase(self, phase, setter_name): + if self.phase is not None: + _warn_if_parameters_used(self.phase, setter_name, "structure") + self.remove_parameter_set(self.phase) + self.phase = phase + self.add_parameter_set(phase) + self._pattern = PowderPattern() + self._set_tth(PLACEHOLDER_TTH) + self._diffraction = self._pattern.AddPowderPatternDiffraction( + phase.structure + ) + self._installed_settings = {} + self._flush(other=(self,)) + + def _set_component(self, stage_name, component): + base_class, kind, examples = COMPONENT_STAGES[stage_name] + setter_name = f"set_{stage_name}" + if not isinstance(component, base_class): + given_name = type(component).__name__ + raise ValueError( + f"{setter_name} was given {_get_article(given_name)} " + f"{given_name}, which is not {_get_article(kind)} {kind}. " + f"Please give {_get_article(base_class.__name__)} " + f"{base_class.__name__}, such as {examples}." + ) + old_component = self.get(stage_name) + _warn_if_parameters_used(old_component, setter_name, kind) + self.remove_parameter_set(old_component) + self.add_parameter_set(component) + self._flush(other=(self,)) + + def _set_tth(self, tth): + self._last_tth = np.array(tth, dtype=float) + self._pattern.SetPowderPatternX(np.radians(self._last_tth)) + self._pattern.SetPowderPatternObs(np.zeros_like(self._last_tth)) + + +def _read_crystal_from_cif(cif_path): + # create_crystal_from_cif takes no Path, and raises ObjCrystException + # without the file name for a file with no structure + try: + crystal = create_crystal_from_cif(str(cif_path)) + except ObjCrystException: + raise ValueError( + f"No crystal structure could be read from {cif_path}. " + "Please give a CIF file that holds a crystal structure." + ) from None + return crystal + + +def _warn_if_parameters_used(parameter_set, setter_name, kind): + # A Parameter is observed by its ParameterSet alone, unless a recipe + # variable proxies it, which adds the recipe's observer + used_names = [] + for parameter in parameter_set.iterate_over_parameters(): + if len(parameter._observers) > 1 or parameter.constrained: + if parameter.name not in used_names: + used_names.append(parameter.name) + if not used_names: + return + warnings.warn( + f"{setter_name} replaced {_get_article(kind)} {kind} whose " + f"Parameters are used in a fit recipe: {', '.join(used_names)}. " + "The recipe would still refine them, but they no longer change the " + f"pattern. Please call {setter_name} before adding the {kind}'s " + "Parameters to the recipe.", + UserWarning, + stacklevel=4, + ) + + +def _get_article(word): + article = "an" if word[0].lower() in "aeiou" else "a" + return article # End of file diff --git a/tests/conftest.py b/tests/conftest.py index 505004a..fe21a49 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,11 +5,20 @@ import pytest from pyobjcryst.crystal import create_crystal_from_cif from pyobjcryst.powderpattern import PowderPattern -from pyobjcryst.reflectionprofile import ReflectionProfilePseudoVoigt +from pyobjcryst.reflectionprofile import ( + ReflectionProfilePseudoVoigt, + ReflectionProfilePseudoVoigtAnisotropic, +) from diffpy.cmirietveld.component import Component +from diffpy.cmirietveld.peak_profiles import PeakProfile +from diffpy.cmirietveld.powderdiffractiongenerator import ( + PowderDiffractionGenerator, +) +from diffpy.srfit.fitbase import FitContribution, Profile TESTDATA_DIR = Path(__file__).parent / "testdata" +PBSO4_CIF = TESTDATA_DIR / "PbSO4-Wyckoff.cif" PSEUDO_VOIGT_PARAMETER_NAMES = { "U": "U", "V": "V", @@ -24,18 +33,18 @@ class PbSO4Pattern: """A plain pyobjcryst pattern for PbSO4, on a 16-110 degree grid at - 1.5406 angstroms, with its diffraction component.""" + 1.5406 angstroms, with its crystal and 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.crystal = create_crystal_from_cif(str(PBSO4_CIF)) + self.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) + self.pattern.SetPowderPatternX(np.radians(self.tth)) + self.pattern.SetPowderPatternObs(np.zeros_like(self.tth)) + self.diffraction = self.pattern.AddPowderPatternDiffraction( + self.crystal + ) def calculate(self): self.pattern.Prepare() @@ -43,6 +52,29 @@ def calculate(self): return calculated_pattern +class ExternalPeakProfile(PeakProfile): + """A peak profile that stands for one written in another package. + + The package doesn't know this class, so a generator that calculates + with it shows that a new peak profile needs no change to the + generator. It wraps pyobjcryst's anisotropic pseudo-Voigt, which the + package doesn't have yet, and maps only two of its Parameters. + """ + + def __init__(self): + PeakProfile.__init__(self) + self.create_parameters_from( + ReflectionProfilePseudoVoigtAnisotropic(), + {"W": "W", "G_HH": "G_HH"}, + ) + + def install(self, pattern, diffraction): + diffraction.SetProfile(ReflectionProfilePseudoVoigtAnisotropic()) + + def update(self, pattern, diffraction): + self.push_parameters(diffraction.GetProfile()) + + class NotificationCounter: """An observer that counts the notifications it receives.""" @@ -71,6 +103,37 @@ def other_pbso4_pattern(): return other_pbso4_pattern +@pytest.fixture +def pbso4_generator(): + pbso4_generator = PowderDiffractionGenerator() + pbso4_generator.set_structure(create_crystal_from_cif(str(PBSO4_CIF))) + pbso4_generator.instrument.set_wavelength(1.5406) + return pbso4_generator + + +@pytest.fixture +def other_pbso4_generator(): + other_pbso4_generator = PowderDiffractionGenerator() + other_pbso4_generator.set_structure( + create_crystal_from_cif(str(PBSO4_CIF)) + ) + other_pbso4_generator.instrument.set_wavelength(1.5406) + return other_pbso4_generator + + +@pytest.fixture +def pbso4_contribution(pbso4_generator, pbso4_pattern): + profile = Profile() + profile.set_observed_profile( + pbso4_pattern.tth, np.zeros_like(pbso4_pattern.tth) + ) + pbso4_contribution = FitContribution("xray") + pbso4_contribution.set_profile(profile) + pbso4_contribution.add_profile_generator(pbso4_generator) + pbso4_contribution.set_equation("bragg") + return pbso4_contribution + + @pytest.fixture def pseudo_voigt_component(): component = Component("peak_profile") diff --git a/tests/test_powderdiffractiongenerator.py b/tests/test_powderdiffractiongenerator.py new file mode 100644 index 0000000..41923a9 --- /dev/null +++ b/tests/test_powderdiffractiongenerator.py @@ -0,0 +1,944 @@ +import re +import warnings + +import numpy as np +import pytest +from conftest import PBSO4_CIF, TESTDATA_DIR, ExternalPeakProfile +from pyobjcryst.crystal import Crystal, create_crystal_from_cif +from pyobjcryst.reflectionprofile import ( + ReflectionProfilePseudoVoigtAnisotropic, + ReflectionProfilePseudoVoigtTCH, +) + +from diffpy.cmirietveld.instruments import ConstantWavelengthInstrument +from diffpy.cmirietveld.peak_profiles import PseudoVoigtProfile, TCHProfile +from diffpy.cmirietveld.powderdiffractiongenerator import ( + PowderDiffractionGenerator, +) +from diffpy.cmistructure import structure_to_parameter_set +from diffpy.cmistructure.objcrystparset import ObjCrystCrystalParSet +from diffpy.srfit.exceptions import SrFitError +from diffpy.srfit.fitbase import FitContribution, FitRecipe, Profile +from diffpy.structure import load_structure + + +def test_init(): + # Test a new generator before anything is set + # Expected: It is named bragg, has no structure, uses a pseudo-Voigt + # peak profile and a constant-wavelength instrument, and its scale is 1. + generator = PowderDiffractionGenerator() + actual_name = generator.name + actual_phase = generator.phase + actual_peak_profile_type = type(generator.peak_profile) + actual_instrument_type = type(generator.instrument) + actual_scale = generator.scale.value + expected_name = "bragg" + expected_phase = None + expected_peak_profile_type = PseudoVoigtProfile + expected_instrument_type = ConstantWavelengthInstrument + expected_scale = 1.0 + assert actual_name == expected_name + assert actual_phase == expected_phase + assert actual_peak_profile_type == expected_peak_profile_type + assert actual_instrument_type == expected_instrument_type + assert actual_scale == expected_scale + + +@pytest.mark.parametrize( + "set_structure_kwargs, expected_phase_name", + [ + # Test setting the structure from a pyobjcryst Crystal + # C1: A user sets the PbSO4 crystal without naming it. + # Expected: The structure's Parameters are under generator.phase. + ({}, "phase"), + # C2: A user sets the PbSO4 crystal and names it pbso4. + # Expected: The structure's Parameters are under generator.pbso4. + ({"name": "pbso4"}, "pbso4"), + ], +) +def test_set_structure_with_crystal(set_structure_kwargs, expected_phase_name): + crystal = create_crystal_from_cif(str(PBSO4_CIF)) + generator = PowderDiffractionGenerator() + generator.set_structure(crystal, **set_structure_kwargs) + actual_phase_type = type(generator.phase) + actual_phase_name = generator.phase.name + actual_managed_phase = generator.get(expected_phase_name) + actual_structure = generator.phase.structure + expected_phase_type = ObjCrystCrystalParSet + expected_managed_phase = generator.phase + expected_structure = crystal + assert actual_phase_type == expected_phase_type + assert actual_phase_name == expected_phase_name + assert actual_managed_phase == expected_managed_phase + assert actual_structure == expected_structure + + +@pytest.mark.parametrize( + "cif_path", + [ + # Test setting the structure from the path to a CIF file + # C1: A user gives the path to the PbSO4 CIF as a pathlib.Path. + # Expected: The generator reads the file into a pyobjcryst Crystal, + # and the pattern equals plain pyobjcryst's for that CIF. + PBSO4_CIF, + # C2: A user gives the same path as a string. + # Expected: The same Crystal type and pattern as for the Path. + str(PBSO4_CIF), + ], +) +def test_set_structure_with_cif_path(pbso4_pattern, cif_path): + generator = PowderDiffractionGenerator() + generator.set_structure(cif_path) + generator.instrument.set_wavelength(1.5406) + actual_structure_type = type(generator.phase.structure) + actual_pattern = generator(pbso4_pattern.tth) + expected_structure_type = Crystal + expected_pattern = pbso4_pattern.calculate() + assert actual_structure_type == expected_structure_type + assert np.allclose(actual_pattern, expected_pattern) + + +def test_call_with_cif_path_given_to_two_generators(pbso4_pattern): + # Test giving the same CIF path to two generators + # Expected: Each generator reads its own structure, so after the lattice + # parameter a of one is set to 8.50 angstroms, the other's pattern is + # still plain pyobjcryst's for the CIF. Sharing one structure needs one + # ParameterSet given to both generators. + # Give two generators the same path + generator = PowderDiffractionGenerator() + other_generator = PowderDiffractionGenerator() + generator.set_structure(PBSO4_CIF) + other_generator.set_structure(PBSO4_CIF) + generator.instrument.set_wavelength(1.5406) + other_generator.instrument.set_wavelength(1.5406) + # Change a on the first generator only + generator.phase.a.set_value(8.50) + actual_other_pattern = other_generator(pbso4_pattern.tth) + expected_other_pattern = pbso4_pattern.calculate() + assert np.allclose(actual_other_pattern, expected_other_pattern) + + +@pytest.mark.parametrize( + "file_path", + [ + # Test setting the structure from a file that isn't a CIF file + # holding a crystal structure + # C1: A user gives the path to their data file by mistake. + # Expected: A ValueError names the file and says to give a CIF file + # with a crystal structure. + TESTDATA_DIR / "PBSO4.XRA", + # C2: A user gives the .stru file from their PDF fits, which + # diffpy.cmipdf's PDF generators read. + # Expected: The same ValueError, since the generator reads CIF + # files only. + TESTDATA_DIR / "pbso4.stru", + ], +) +def test_set_structure_with_non_cif_file(file_path): + generator = PowderDiffractionGenerator() + expected_msg = ( + f"No crystal structure could be read from {file_path}. " + "Please give a CIF file that holds a crystal structure." + ) + with pytest.raises(ValueError, match=re.escape(expected_msg)): + generator.set_structure(file_path) + + +@pytest.mark.parametrize( + "phase_name, set_structure_kwargs", + [ + # Test setting the structure from an existing ParameterSet, as a + # user does to share one structure between datasets + # C1: A user makes a ParameterSet named phase from the PbSO4 crystal + # and gives it to set_structure. + # Expected: The generator manages that same ParameterSet, under + # generator.phase. + ("phase", {}), + # C2: A user makes the ParameterSet with the name pbso4. + # Expected: The ParameterSet keeps its name, so it is under + # generator.pbso4, and generator.phase is it too. + ("pbso4", {}), + # C3: A user makes the ParameterSet with the name pbso4 and gives + # set_structure the same name. + # Expected: The same as without the name, since the names match. + ("pbso4", {"name": "pbso4"}), + ], +) +def test_set_structure_with_parameter_set(phase_name, set_structure_kwargs): + phase = structure_to_parameter_set( + phase_name, create_crystal_from_cif(str(PBSO4_CIF)) + ) + generator = PowderDiffractionGenerator() + generator.set_structure(phase, **set_structure_kwargs) + actual_phase = generator.phase + actual_managed_phase = generator.get(phase_name) + expected_phase = phase + assert actual_phase == expected_phase + assert actual_managed_phase == expected_phase + + +def test_set_structure_with_parameter_set_and_other_name(): + # Test giving set_structure a name with a ParameterSet that has another + # name of its own + # Expected: A ValueError says the name can't be used and how to name + # the ParameterSet instead. + phase = structure_to_parameter_set( + "phase", create_crystal_from_cif(str(PBSO4_CIF)) + ) + generator = PowderDiffractionGenerator() + expected_msg = ( + "set_structure was given the name 'pbso4' with a ParameterSet, " + "which keeps its own name, 'phase'. Please leave out the name, or " + "give it when you create the ParameterSet." + ) + with pytest.raises(ValueError, match=re.escape(expected_msg)): + generator.set_structure(phase, name="pbso4") + + +def test_set_structure_with_diffpy_structure(): + # Test giving set_structure the diffpy.structure Structure a user has + # from their PDF fits, which ObjCryst can't calculate with + # Expected: A ValueError names the type and lists what to give instead. + diffpy_structure = load_structure(str(PBSO4_CIF)) + generator = PowderDiffractionGenerator() + expected_msg = ( + "set_structure was given a Structure, which the powder diffraction " + "generator can't calculate with. Please give a pyobjcryst Crystal, " + "the path to a CIF file, or a ParameterSet made from a Crystal." + ) + with pytest.raises(ValueError, match=re.escape(expected_msg)): + generator.set_structure(diffpy_structure) + + +def test_set_structure_from_parset(): + # Test setting the structure with set_structure_from_parset, which + # srfit's and diffpy.cmipdf's PDF generators also have + # Expected: The generator manages that same ParameterSet. + phase = structure_to_parameter_set( + "phase", create_crystal_from_cif(str(PBSO4_CIF)) + ) + generator = PowderDiffractionGenerator() + generator.set_structure_from_parset(phase) + actual_phase = generator.phase + expected_phase = phase + assert actual_phase == expected_phase + + +def test_call_after_set_structure_twice(pbso4_pattern): + # Test setting a second structure, as when a user reruns the setup cell + # of their script with another CIF + # Expected: The generator holds only the second structure, and the + # pattern equals plain pyobjcryst's for it alone, not the sum of both. + # Set a first structure, with a changed lattice, and calculate once + generator = PowderDiffractionGenerator() + first_crystal = create_crystal_from_cif(str(PBSO4_CIF)) + first_crystal.GetPar("a").SetHumanValue(8.50) + generator.set_structure(first_crystal) + generator.instrument.set_wavelength(1.5406) + generator(pbso4_pattern.tth) + # Replace it with the CIF's structure and calculate again + second_crystal = create_crystal_from_cif(str(PBSO4_CIF)) + generator.set_structure(second_crystal) + actual_structure = generator.phase.structure + actual_pattern = generator(pbso4_pattern.tth) + expected_structure = second_crystal + expected_pattern = pbso4_pattern.calculate() + assert actual_structure == expected_structure + assert np.allclose(actual_pattern, expected_pattern) + + +def test_get_after_set_structure_with_new_name(pbso4_generator): + # Test setting a second structure under a new name, as when a user + # renames their phase and reruns the setup cell of their script + # Expected: The first structure, named phase, is no longer under the + # generator, and the second one is under generator.pbso4. + pbso4_generator.set_structure( + create_crystal_from_cif(str(PBSO4_CIF)), name="pbso4" + ) + actual_old_phase = pbso4_generator.get("phase") + actual_new_phase = pbso4_generator.get("pbso4") + expected_old_phase = None + expected_new_phase = pbso4_generator.phase + assert actual_old_phase == expected_old_phase + assert actual_new_phase == expected_new_phase + + +def test_call_with_default_components(pbso4_generator, pbso4_pattern): + # Test calculating a pattern with the default peak profile and + # instrument + # Expected: The pattern equals the one plain pyobjcryst calculates for + # the same structure, grid and wavelength. + actual_pattern = pbso4_generator(pbso4_pattern.tth) + expected_pattern = pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + + +def test_call_after_set_wavelength(pbso4_generator, pbso4_pattern): + # Test that a wavelength set after a calculation is used in the next one + # Expected: After a user sets a single wavelength of 1.909 angstroms, + # the pattern equals plain pyobjcryst's at 1.909 angstroms. + # Calculate once, so the new wavelength has to be installed again + pbso4_generator(pbso4_pattern.tth) + # Set the new wavelength and calculate again + pbso4_generator.instrument.set_wavelength(1.909) + actual_pattern = pbso4_generator(pbso4_pattern.tth) + # Set the same wavelength directly in pyobjcryst + pbso4_pattern.pattern.SetWavelength(1.909) + expected_pattern = pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + + +def test_call_after_set_xray_source(pbso4_generator, pbso4_pattern): + # Test that an x-ray tube set after a calculation is used in the next one + # Expected: After a user sets a Cu tube, the pattern equals plain + # pyobjcryst's for a Cu tube with a Kalpha2/Kalpha1 ratio of 0.5. + # Calculate once, so the tube has to be installed again + pbso4_generator(pbso4_pattern.tth) + # Set the Cu tube and calculate again + pbso4_generator.instrument.set_xray_source("Cu") + actual_pattern = pbso4_generator(pbso4_pattern.tth) + # Set the same tube directly in pyobjcryst + pbso4_pattern.pattern.SetWavelength("Cu", 0.5) + expected_pattern = pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + + +@pytest.mark.parametrize( + "peak_profile_class, pyobjcryst_class, parameter_name, new_value", + [ + # Test that the generator calculates with the peak profile it is + # given + # C1: A user switches to a TCH profile and sets its Lorentzian + # width X. + # Expected: The pattern equals plain pyobjcryst's with a TCH profile + # and the same X. + (TCHProfile, ReflectionProfilePseudoVoigtTCH, "X", 0.02), + # C2: A user writes their own peak profile in another package, here + # one for pyobjcryst's anisotropic pseudo-Voigt, and sets its G_HH. + # Expected: The pattern equals plain pyobjcryst's with that profile + # and the same G_HH, so a new profile needs no change to the + # generator. + ( + ExternalPeakProfile, + ReflectionProfilePseudoVoigtAnisotropic, + "G_HH", + 0.0005, + ), + ], +) +def test_call_after_set_peak_profile( + pbso4_generator, + pbso4_pattern, + peak_profile_class, + pyobjcryst_class, + parameter_name, + new_value, +): + # Give the generator the new profile and change one of its Parameters + pbso4_generator.set_peak_profile(peak_profile_class()) + pbso4_generator.peak_profile.get(parameter_name).set_value(new_value) + actual_pattern = pbso4_generator(pbso4_pattern.tth) + # Install the same profile and value directly in pyobjcryst + pbso4_pattern.diffraction.SetProfile(pyobjcryst_class()) + installed_profile = pbso4_pattern.diffraction.GetProfile() + installed_profile.GetPar(parameter_name).SetHumanValue(new_value) + expected_pattern = pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + + +def test_call_after_set_peak_profile_before_set_structure(pbso4_pattern): + # Test setting up the peak profile before the structure, as a user may + # do at the top of a script + # Expected: The pattern equals plain pyobjcryst's with a TCH profile, + # because the profile is installed once the structure is set. + # Set the profile first, then the structure and the wavelength + generator = PowderDiffractionGenerator() + tch_profile = TCHProfile() + tch_profile.X.set_value(0.02) + generator.set_peak_profile(tch_profile) + generator.set_structure(create_crystal_from_cif(str(PBSO4_CIF))) + generator.instrument.set_wavelength(1.5406) + actual_pattern = generator(pbso4_pattern.tth) + # Install the same profile directly in pyobjcryst + pbso4_pattern.diffraction.SetProfile(ReflectionProfilePseudoVoigtTCH()) + installed_profile = pbso4_pattern.diffraction.GetProfile() + installed_profile.GetPar("X").SetHumanValue(0.02) + expected_pattern = pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + + +@pytest.mark.parametrize( + "setter_name, component_name, component_class", + [ + # Test that a setter replaces the component and its Parameters + # C1: A user switches to a TCH profile. + # Expected: generator.peak_profile is the TCH profile, and the + # generator manages it, so a change to its Parameters reaches a + # FitContribution. + ("set_peak_profile", "peak_profile", TCHProfile), + # C2: A user gives the generator an instrument they set up. + # Expected: generator.instrument is that instrument, and the + # generator manages it. + ("set_instrument", "instrument", ConstantWavelengthInstrument), + ], +) +def test_component_after_setter( + pbso4_generator, setter_name, component_name, component_class +): + new_component = component_class() + getattr(pbso4_generator, setter_name)(new_component) + actual_component = getattr(pbso4_generator, component_name) + actual_managed_component = pbso4_generator.get(component_name) + expected_component = new_component + assert actual_component == expected_component + assert actual_managed_component == expected_component + + +def test_call_after_peak_profile_parameter_change( + pbso4_generator, pbso4_pattern +): + # Test that a changed peak profile Parameter is used in the next + # calculation + # Expected: After W is set to 0.05, the pattern equals plain + # pyobjcryst's with W = 0.05. + # Calculate once, then change W and calculate again + pbso4_generator(pbso4_pattern.tth) + pbso4_generator.peak_profile.W.set_value(0.05) + actual_pattern = pbso4_generator(pbso4_pattern.tth) + # Set the same W directly in pyobjcryst + installed_profile = pbso4_pattern.diffraction.GetProfile() + installed_profile.GetPar("W").SetHumanValue(0.05) + expected_pattern = pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + + +def test_call_after_instrument_parameter_change( + pbso4_generator, pbso4_pattern +): + # Test that a changed instrument Parameter is used in the next + # calculation + # Expected: After zero is set to 0.1 degrees, the pattern equals plain + # pyobjcryst's with a zero shift of 0.1 degrees. + # Calculate once, then change zero and calculate again + pbso4_generator(pbso4_pattern.tth) + pbso4_generator.instrument.zero.set_value(0.1) + actual_pattern = pbso4_generator(pbso4_pattern.tth) + # Set the same zero shift directly in pyobjcryst + pbso4_pattern.pattern.GetPar("Zero").SetHumanValue(0.1) + expected_pattern = pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + + +def test_call_after_wavelength_change(pbso4_generator, pbso4_pattern): + # Test that a refined wavelength is used in the next calculation + # Expected: After the wavelength Parameter is set to 1.55 angstroms, as + # a refinement would, the pattern equals plain pyobjcryst's at 1.55 + # angstroms. + # Calculate once, then change the wavelength and calculate again + pbso4_generator(pbso4_pattern.tth) + pbso4_generator.instrument.wavelength.set_value(1.55) + actual_pattern = pbso4_generator(pbso4_pattern.tth) + # Set the same wavelength directly in pyobjcryst + pbso4_pattern.pattern.SetWavelength(1.55) + expected_pattern = pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + + +def test_call_after_structure_parameter_change(pbso4_generator, pbso4_pattern): + # Test that a changed structure Parameter is used in the next + # calculation + # Expected: After the lattice parameter a is set to 8.50 angstroms, the + # pattern equals plain pyobjcryst's with a = 8.50 angstroms. + # Calculate once, then change a and calculate again + pbso4_generator(pbso4_pattern.tth) + pbso4_generator.phase.a.set_value(8.50) + actual_pattern = pbso4_generator(pbso4_pattern.tth) + # Set the same lattice parameter directly in pyobjcryst + pbso4_pattern.crystal.GetPar("a").SetHumanValue(8.50) + expected_pattern = pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + + +def test_call_after_scale_change(pbso4_generator, pbso4_pattern): + # Test that the scale is applied to the calculated pattern + # Expected: Doubling the scale doubles the pattern. + initial_pattern = pbso4_generator(pbso4_pattern.tth) + pbso4_generator.scale.set_value(2.0) + actual_pattern = pbso4_generator(pbso4_pattern.tth) + expected_pattern = 2 * initial_pattern + assert np.allclose(actual_pattern, expected_pattern) + + +def test_call_after_grid_change(pbso4_generator, pbso4_pattern): + # Test calculating on a new 2theta grid, as when a user changes the + # calculation range + # Expected: The pattern equals plain pyobjcryst's on the new 20-60 + # degree grid, with one value per new point. + # Calculate once on the full grid, then on the new one + pbso4_generator(pbso4_pattern.tth) + new_tth = np.arange(20.0, 60.0, 0.02) + actual_pattern = pbso4_generator(new_tth) + # Calculate on the same grid directly in pyobjcryst + pbso4_pattern.pattern.SetPowderPatternX(np.radians(new_tth)) + pbso4_pattern.pattern.SetPowderPatternObs(np.zeros_like(new_tth)) + expected_pattern = pbso4_pattern.calculate() + actual_number_of_points = len(actual_pattern) + expected_number_of_points = len(new_tth) + assert actual_number_of_points == expected_number_of_points + assert np.allclose(actual_pattern, expected_pattern) + + +def test_call_with_shared_peak_profile( + pbso4_generator, other_pbso4_generator, pbso4_pattern +): + # Test one peak profile given to two generators, as a user does for one + # phase measured in two datasets + # Expected: After W is set to 0.05, both patterns equal plain + # pyobjcryst's with W = 0.05. + # Give both generators the same profile and calculate once + shared_profile = PseudoVoigtProfile() + pbso4_generator.set_peak_profile(shared_profile) + other_pbso4_generator.set_peak_profile(shared_profile) + pbso4_generator(pbso4_pattern.tth) + other_pbso4_generator(pbso4_pattern.tth) + # Change W once and calculate both again + shared_profile.W.set_value(0.05) + actual_pattern = pbso4_generator(pbso4_pattern.tth) + actual_other_pattern = other_pbso4_generator(pbso4_pattern.tth) + # Set the same W directly in pyobjcryst + installed_profile = pbso4_pattern.diffraction.GetProfile() + installed_profile.GetPar("W").SetHumanValue(0.05) + expected_pattern = pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + assert np.allclose(actual_other_pattern, expected_pattern) + + +def test_call_with_shared_instrument_after_parameter_change( + pbso4_generator, other_pbso4_generator, pbso4_pattern +): + # Test one instrument given to two generators, as a user does for two + # phases in one dataset + # Expected: After zero is set to 0.1 degrees, both patterns equal plain + # pyobjcryst's with a zero shift of 0.1 degrees. + # Give both generators the same instrument and calculate once + shared_instrument = ConstantWavelengthInstrument() + shared_instrument.set_wavelength(1.5406) + pbso4_generator.set_instrument(shared_instrument) + other_pbso4_generator.set_instrument(shared_instrument) + pbso4_generator(pbso4_pattern.tth) + other_pbso4_generator(pbso4_pattern.tth) + # Change zero once and calculate both again + shared_instrument.zero.set_value(0.1) + actual_pattern = pbso4_generator(pbso4_pattern.tth) + actual_other_pattern = other_pbso4_generator(pbso4_pattern.tth) + # Set the same zero shift directly in pyobjcryst + pbso4_pattern.pattern.GetPar("Zero").SetHumanValue(0.1) + expected_pattern = pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + assert np.allclose(actual_other_pattern, expected_pattern) + + +def test_call_with_shared_instrument_after_setting_change( + pbso4_generator, other_pbso4_generator, pbso4_pattern +): + # Test one instrument given to two generators when a setting changes + # Expected: After a user sets a Cu tube, both patterns equal plain + # pyobjcryst's for a Cu tube, because both generators install the + # instrument again. + # Give both generators the same instrument and calculate once + shared_instrument = ConstantWavelengthInstrument() + shared_instrument.set_wavelength(1.5406) + pbso4_generator.set_instrument(shared_instrument) + other_pbso4_generator.set_instrument(shared_instrument) + pbso4_generator(pbso4_pattern.tth) + other_pbso4_generator(pbso4_pattern.tth) + # Set a Cu tube once and calculate both again + shared_instrument.set_xray_source("Cu") + actual_pattern = pbso4_generator(pbso4_pattern.tth) + actual_other_pattern = other_pbso4_generator(pbso4_pattern.tth) + # Set the same tube directly in pyobjcryst + pbso4_pattern.pattern.SetWavelength("Cu", 0.5) + expected_pattern = pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + assert np.allclose(actual_other_pattern, expected_pattern) + + +def test_call_with_shared_structure(pbso4_pattern): + # Test one structure ParameterSet given to two generators, as a user + # does to refine one structure against two datasets + # Expected: After the lattice parameter a is set to 8.50 angstroms, + # both patterns equal plain pyobjcryst's with a = 8.50 angstroms. + # Give two generators the same structure and calculate once + shared_phase = structure_to_parameter_set( + "phase", create_crystal_from_cif(str(PBSO4_CIF)) + ) + generator = PowderDiffractionGenerator() + other_generator = PowderDiffractionGenerator() + generator.set_structure(shared_phase) + other_generator.set_structure(shared_phase) + generator.instrument.set_wavelength(1.5406) + other_generator.instrument.set_wavelength(1.5406) + generator(pbso4_pattern.tth) + other_generator(pbso4_pattern.tth) + # Change a once and calculate both again + shared_phase.a.set_value(8.50) + actual_pattern = generator(pbso4_pattern.tth) + actual_other_pattern = other_generator(pbso4_pattern.tth) + # Set the same lattice parameter directly in pyobjcryst + pbso4_pattern.crystal.GetPar("a").SetHumanValue(8.50) + expected_pattern = pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + assert np.allclose(actual_other_pattern, expected_pattern) + + +@pytest.mark.parametrize( + "component_name, parameter_name, new_value", + [ + # Test that a FitContribution recalculates after a Parameter + # changes, as it does at every step of a fit + # C1: A fit steps the peak profile's W to 0.05. + # Expected: The contribution's value equals the generator's pattern + # with W = 0.05, not the cached pattern. + ("peak_profile", "W", 0.05), + # C2: A fit steps the instrument's zero to 0.1 degrees. + # Expected: The contribution's value equals the generator's pattern + # with a zero shift of 0.1 degrees. + ("instrument", "zero", 0.1), + # C3: A fit steps the wavelength to 1.55 angstroms. + # Expected: The contribution's value equals the generator's pattern + # at 1.55 angstroms. + ("instrument", "wavelength", 1.55), + # C4: A fit steps the lattice parameter a to 8.50 angstroms. + # Expected: The contribution's value equals the generator's pattern + # with a = 8.50 angstroms. + ("phase", "a", 8.50), + ], +) +def test_evaluate_after_parameter_change( + pbso4_generator, + pbso4_contribution, + pbso4_pattern, + component_name, + parameter_name, + new_value, +): + # Evaluate the contribution once, then change the Parameter and + # evaluate again + pbso4_contribution.evaluate() + component = getattr(pbso4_generator, component_name) + component.get(parameter_name).set_value(new_value) + actual_pattern = pbso4_contribution.evaluate() + # Calculate with the generator directly, which has no cache + expected_pattern = pbso4_generator(pbso4_pattern.tth) + assert np.allclose(actual_pattern, expected_pattern) + + +def test_evaluate_after_instrument_setting_change( + pbso4_generator, pbso4_contribution, pbso4_pattern +): + # Test that a FitContribution recalculates after an instrument setting + # changes, as it does after a Parameter changes + # Expected: After a user sets a Cu tube, the contribution's value equals + # plain pyobjcryst's pattern for a Cu tube, not the cached pattern. + # Evaluate the contribution once + pbso4_contribution.evaluate() + # Set a Cu tube and evaluate again + pbso4_generator.instrument.set_xray_source("Cu") + actual_pattern = pbso4_contribution.evaluate() + # Set the same tube directly in pyobjcryst + pbso4_pattern.pattern.SetWavelength("Cu", 0.5) + expected_pattern = pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + + +def test_evaluate_after_set_peak_profile( + pbso4_generator, pbso4_contribution, pbso4_pattern +): + # Test that a FitContribution recalculates after the peak profile is + # replaced, and that the replaced profile no longer counts + # Expected: After a user switches to a TCH profile, the contribution's + # value equals plain pyobjcryst's with a TCH profile, and it stays so + # after the old profile's W is changed. + # Evaluate the contribution once + pbso4_contribution.evaluate() + # Switch to a TCH profile and evaluate again + old_profile = pbso4_generator.peak_profile + pbso4_generator.set_peak_profile(TCHProfile()) + actual_pattern = pbso4_contribution.evaluate() + # Change the old profile's W and evaluate once more + old_profile.W.set_value(0.05) + actual_pattern_after_old_change = pbso4_contribution.evaluate() + # Install a TCH profile directly in pyobjcryst + pbso4_pattern.diffraction.SetProfile(ReflectionProfilePseudoVoigtTCH()) + expected_pattern = pbso4_pattern.calculate() + assert np.allclose(actual_pattern, expected_pattern) + assert np.allclose(actual_pattern_after_old_change, expected_pattern) + + +def test_peak_profile_after_add_profile_generator(pbso4_generator): + # Test that a FitContribution doesn't replace the peak profile, since + # srfit gives each generator a data Profile named profile + # Expected: generator.peak_profile is still the peak profile, and + # generator.profile is the contribution's data Profile. + expected_peak_profile = pbso4_generator.peak_profile + expected_profile = Profile() + contribution = FitContribution("xray") + contribution.set_profile(expected_profile) + contribution.add_profile_generator(pbso4_generator) + actual_peak_profile = pbso4_generator.peak_profile + actual_profile = pbso4_generator.profile + assert actual_peak_profile == expected_peak_profile + assert actual_profile == expected_profile + + +@pytest.mark.parametrize( + "component_name, parameter_name, new_value, limits, used_value", + [ + # Test that a value outside ObjCryst's limits stops the calculation, + # since ObjCryst would silently use another value + # C1: A fit steps the peak profile's eta0 to 1.5, past its limits of + # 0 to 1. + # Expected: A ValueError says ObjCryst would use 1.0, and how to + # keep the value within the limits. + ("peak_profile", "eta0", 1.5, "[0.0, 1.0]", "1.0"), + # C2: A fit steps the instrument's zero to 3 degrees, past its + # limits of +-0.05 rad. + # Expected: A ValueError says ObjCryst would use 0.05 rad in + # degrees, and how to keep the value within the limits. + ( + "instrument", + "zero", + 3.0, + "[-2.8647889756541165, 2.8647889756541165]", + "2.8647889756541165", + ), + ], +) +def test_call_with_value_outside_limits( + pbso4_generator, + pbso4_pattern, + component_name, + parameter_name, + new_value, + limits, + used_value, +): + component = getattr(pbso4_generator, component_name) + component.get(parameter_name).set_value(new_value) + expected_msg = ( + f"The value of '{parameter_name}', {new_value}, is outside its " + f"limits {limits}. ObjCryst 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())." + ) + with pytest.raises(ValueError, match=re.escape(expected_msg)): + pbso4_generator(pbso4_pattern.tth) + + +@pytest.mark.parametrize( + "setter_name, wrong_component_class, expected_msg", + [ + # Test that a setter rejects the wrong kind of component + # C1: A user passes an instrument to set_peak_profile. + # Expected: A ValueError says it is not a peak profile and names the + # peak profiles to use. + ( + "set_peak_profile", + ConstantWavelengthInstrument, + "set_peak_profile was given a ConstantWavelengthInstrument, " + "which is not a peak profile. Please give a PeakProfile, such as " + "PseudoVoigtProfile or TCHProfile.", + ), + # C2: A user passes a peak profile to set_instrument. + # Expected: A ValueError says it is not an instrument and names the + # instrument to use. + ( + "set_instrument", + PseudoVoigtProfile, + "set_instrument was given a PseudoVoigtProfile, which is not an " + "instrument. Please give an Instrument, such as " + "ConstantWavelengthInstrument.", + ), + ], +) +def test_setter_with_wrong_component( + pbso4_generator, setter_name, wrong_component_class, expected_msg +): + with pytest.raises(ValueError, match=re.escape(expected_msg)): + getattr(pbso4_generator, setter_name)(wrong_component_class()) + + +def test_call_before_set_structure(pbso4_pattern): + # Test calculating a pattern before a structure is set + # Expected: An SrFitError says that the structure is missing and how to + # set it. + generator = PowderDiffractionGenerator() + generator.instrument.set_wavelength(1.5406) + expected_msg = ( + "The generator has no structure. Please set one with " + "set_structure or set_structure_from_parset." + ) + with pytest.raises(SrFitError, match=re.escape(expected_msg)): + generator(pbso4_pattern.tth) + + +def test_call_before_set_wavelength(pbso4_pattern): + # Test calculating a pattern before the instrument has a wavelength + # Expected: An SrFitError says that the wavelength is missing and how to + # set it. + generator = PowderDiffractionGenerator() + generator.set_structure(create_crystal_from_cif(str(PBSO4_CIF))) + 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)): + generator(pbso4_pattern.tth) + + +@pytest.mark.parametrize( + "setter_name, component_name, parameter_name, new_component_class, " + "expected_msg", + [ + # Test that a setter warns when the component it replaces has + # Parameters in a fit recipe, since the recipe would refine + # Parameters that no longer change the pattern + # C1: A user adds the peak profile's W to their recipe, then + # switches to a TCH profile. + # Expected: A UserWarning names W and says to set the profile before + # adding its Parameters to the recipe. + ( + "set_peak_profile", + "peak_profile", + "W", + TCHProfile, + "set_peak_profile replaced a peak profile whose Parameters are " + "used in a fit recipe: W. The recipe would still refine them, " + "but they no longer change the pattern. Please call " + "set_peak_profile before adding the peak profile's Parameters " + "to the recipe.", + ), + # C2: A user adds the instrument's zero to their recipe, then gives + # the generator an instrument they set up. + # Expected: A UserWarning names zero and says to set the instrument + # before adding its Parameters to the recipe. + ( + "set_instrument", + "instrument", + "zero", + ConstantWavelengthInstrument, + "set_instrument replaced an instrument whose Parameters are used " + "in a fit recipe: zero. The recipe would still refine them, but " + "they no longer change the pattern. Please call set_instrument " + "before adding the instrument's Parameters to the recipe.", + ), + ], +) +def test_setter_warns_recipe_variable_replaced( + pbso4_generator, + setter_name, + component_name, + parameter_name, + new_component_class, + expected_msg, +): + # Add one of the current component's Parameters to a recipe + recipe = FitRecipe() + component = getattr(pbso4_generator, component_name) + recipe.add_variable(component.get(parameter_name)) + # Replace the component + with pytest.warns(UserWarning, match=re.escape(expected_msg)): + getattr(pbso4_generator, setter_name)(new_component_class()) + + +def test_set_peak_profile_warns_constrained_parameter_replaced( + pbso4_generator, +): + # Test replacing a peak profile whose Parameter is constrained in a fit + # recipe, as when a user ties the peak width of two datasets to one + # recipe variable + # Expected: The same UserWarning as for a recipe variable, naming W, + # since the recipe would still set W, but W no longer changes the + # pattern. + # Constrain the peak profile's W to a recipe variable + recipe = FitRecipe() + recipe.create_new_variable("width", 0.01) + recipe.constrain(pbso4_generator.peak_profile.W, "width") + # Switch to a TCH profile + expected_msg = ( + "set_peak_profile replaced a peak profile whose Parameters are " + "used in a fit recipe: W. The recipe would still refine them, " + "but they no longer change the pattern. Please call " + "set_peak_profile before adding the peak profile's Parameters " + "to the recipe." + ) + with pytest.warns(UserWarning, match=re.escape(expected_msg)): + pbso4_generator.set_peak_profile(TCHProfile()) + + +def test_set_structure_warns_recipe_variable_replaced(pbso4_generator): + # Test setting a second structure after the first one's Parameters are + # in a fit recipe, as when a user reruns the setup cell of their script + # after building their recipe + # Expected: A UserWarning names a and says to set the structure before + # adding its Parameters to the recipe. + # Add the first structure's lattice parameter a to a recipe + recipe = FitRecipe() + recipe.add_variable(pbso4_generator.phase.a) + # Set a second structure + expected_msg = ( + "set_structure replaced a structure whose Parameters are used in a " + "fit recipe: a. The recipe would still refine them, but they no " + "longer change the pattern. Please call set_structure before " + "adding the structure's Parameters to the recipe." + ) + with pytest.warns(UserWarning, match=re.escape(expected_msg)): + pbso4_generator.set_structure(create_crystal_from_cif(str(PBSO4_CIF))) + + +@pytest.mark.parametrize( + "setter_name, new_component_class", + [ + # Test that the setters stay silent when none of the replaced + # component's Parameters is in a recipe, even with the generator in + # a FitContribution of a recipe that refines other Parameters + # C1: A user builds a recipe that refines the scale, then switches + # to a TCH profile. + # Expected: No warning. + ("set_peak_profile", TCHProfile), + # C2: A user builds a recipe that refines the scale, then gives the + # generator an instrument they set up. + # Expected: No warning. + ("set_instrument", ConstantWavelengthInstrument), + ], +) +def test_setter_without_recipe_variables( + pbso4_generator, pbso4_contribution, setter_name, new_component_class +): + # Put the contribution in a recipe that refines only the scale + recipe = FitRecipe() + recipe.add_contribution(pbso4_contribution) + recipe.add_variable(pbso4_generator.scale) + # Replace the component, with warnings turned into errors + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + getattr(pbso4_generator, setter_name)(new_component_class()) + + +def test_set_structure_without_recipe_variables( + pbso4_generator, pbso4_contribution +): + # Test that set_structure stays silent when none of the replaced + # structure's Parameters is in a recipe + # Expected: After a user builds a recipe that refines the scale and + # then sets a second structure, no warning. + # Put the contribution in a recipe that refines only the scale + recipe = FitRecipe() + recipe.add_contribution(pbso4_contribution) + recipe.add_variable(pbso4_generator.scale) + # Set a second structure, with warnings turned into errors + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + pbso4_generator.set_structure(create_crystal_from_cif(str(PBSO4_CIF))) diff --git a/tests/testdata/README.rst b/tests/testdata/README.rst index a103367..5b9659d 100644 --- a/tests/testdata/README.rst +++ b/tests/testdata/README.rst @@ -10,6 +10,10 @@ Laboratory. All rights reserved. This product includes software produced by UChicago Argonne, LLC under Contract No. DE-AC02-06CH11357 with the Department of Energy. +``pbso4.stru`` is ``PbSO4-Wyckoff.cif`` written in the PDFfit format by +diffpy.structure (``load_structure(...).write(..., "pdffit")``), with trailing +whitespace removed. + ``pbso4-esd.xra`` is ``PBSO4.XRA`` written in the GSAS ESD format, with the square root of each count as its uncertainty, to three decimals. Each line holds five (count, uncertainty) pairs of eight characters, and the last line diff --git a/tests/testdata/pbso4.stru b/tests/testdata/pbso4.stru new file mode 100644 index 0000000..be85039 --- /dev/null +++ b/tests/testdata/pbso4.stru @@ -0,0 +1,153 @@ +title +format pdffit +scale 1.000000 +sharp 0.000000, 0.000000, 1.000000, 0.000000 +spcgr P1 +cell 8.480000, 5.398000, 6.958000, 90.000000, 90.000000, 90.000000 +dcell 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000 +ncell 1, 1, 1, 24 +atoms +PB 0.18820000 0.25000000 0.16700000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +PB 0.31180000 0.75000000 0.66700000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +PB 0.81180000 0.75000000 0.83300000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +PB 0.68820000 0.25000000 0.33300000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +S 0.06300000 0.25000000 0.68600000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +S 0.43700000 0.75000000 0.18600000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +S 0.93700000 0.75000000 0.31400000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +S 0.56300000 0.25000000 0.81400000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +O 0.90500000 0.25000000 0.60000000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +O 0.59500000 0.75000000 0.10000000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +O 0.09500000 0.75000000 0.40000000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +O 0.40500000 0.25000000 0.90000000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +O 0.18100000 0.25000000 0.54300000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +O 0.31900000 0.75000000 0.04300000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +O 0.81900000 0.75000000 0.45700000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +O 0.68100000 0.25000000 0.95700000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +O 0.08500000 0.02600000 0.80600000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +O 0.41500000 0.52600000 0.30600000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +O 0.08500000 0.47400000 0.80600000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +O 0.41500000 0.97400000 0.30600000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +O 0.91500000 0.97400000 0.19400000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +O 0.58500000 0.47400000 0.69400000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +O 0.91500000 0.52600000 0.19400000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 +O 0.58500000 0.02600000 0.69400000 1.0000 + 0.00000000 0.00000000 0.00000000 0.0000 + 0.01000000 0.01000000 0.01000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 + 0.00000000 0.00000000 0.00000000 From aa182feb1190dda6272a2426b7764d7f7269ee22 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Tue, 3 Nov 2026 11:05:50 -0500 Subject: [PATCH 3/6] ci: install pyobjcryst from main instead of commit d6a1279 in tests-on-pr.yml The pin predates the March-Dollase (#119) and TOF profile (#120) bindings, which later steps use. Co-Authored-By: Claude Opus 5.5 --- .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 f6a6019..2fae32c 100644 --- a/.github/workflows/tests-on-pr.yml +++ b/.github/workflows/tests-on-pr.yml @@ -12,7 +12,7 @@ jobs: c_extension: false headless: false run: | - python -m pip install git+https://github.com/diffpy/pyobjcryst.git@d6a1279a26b9b1e6e303d103935b4469c5a62e41 --no-deps + python -m pip install git+https://github.com/diffpy/pyobjcryst.git@main --no-deps python -m pip install diffpy.cmistructure --no-deps secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} From 3bf54e7713b94864627aa1b632bcadb01bf6f5f5 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Tue, 3 Nov 2026 11:50:50 -0500 Subject: [PATCH 4/6] feat: add print_info and get_settings methods to components and the generator --- news/powder-diffraction-generator.rst | 4 +- src/diffpy/cmirietveld/component.py | 17 ++ src/diffpy/cmirietveld/instruments.py | 27 ++ .../cmirietveld/powderdiffractiongenerator.py | 146 +++++++--- tests/conftest.py | 13 +- tests/test_component.py | 9 + tests/test_instruments.py | 62 ++++ tests/test_powderdiffractiongenerator.py | 275 ++++++++++++++++-- 8 files changed, 486 insertions(+), 67 deletions(-) diff --git a/news/powder-diffraction-generator.rst b/news/powder-diffraction-generator.rst index 82bd571..b311f93 100644 --- a/news/powder-diffraction-generator.rst +++ b/news/powder-diffraction-generator.rst @@ -1,6 +1,8 @@ **Added:** -* Add ``PowderDiffractionGenerator``, which calculates one phase's Bragg intensity with pyobjcryst from a structure set with ``set_structure`` (a pyobjcryst ``Crystal``, the path to a CIF file or a ParameterSet), a peak profile set with ``set_peak_profile`` and an instrument set with ``set_instrument``, and a refinable ``scale``. +* Add ``PowderDiffractionGenerator``, which calculates one phase's Bragg intensity with pyobjcryst from a structure set with ``set_structure`` (a pyobjcryst ``Crystal``, the path to a CIF file or a ParameterSet), a peak profile set with ``set_peak_profile`` and an instrument set with ``set_instrument``, and a refinable ``scale``. It has no default peak profile or instrument, so all three must be set before a pattern is calculated. +* Add ``PowderDiffractionGenerator.print_info``, which prints the generator's structure, instrument and peak profile with their settings and Parameter values. +* Add ``Component.get_settings``, which returns a component's settings for showing to a user, and its ``ConstantWavelengthInstrument`` version, which lists the radiation type, the x-ray source or Kalpha1/Kalpha2 wavelengths, and the polarization rate. * Warn when ``set_structure``, ``set_peak_profile`` or ``set_instrument`` replaces a structure or component whose Parameters are used in a fit recipe. **Changed:** diff --git a/src/diffpy/cmirietveld/component.py b/src/diffpy/cmirietveld/component.py index a6892fc..aff1d5a 100644 --- a/src/diffpy/cmirietveld/component.py +++ b/src/diffpy/cmirietveld/component.py @@ -114,6 +114,23 @@ def mark_settings_changed(self): self._settings_version += 1 self._flush(other=(self,)) + def get_settings(self): + """Return the settings of the component, for showing to a user. + + A generator's ``print_info`` shows them next to the Parameters. + A component with settings overrides this to list the settings in + use, each under the name of the setter argument that sets it, so + a user sees what to pass to change it. + + Returns + ------- + dict of str to object + The value of each setting in use, by name. It is empty for a + component without settings. + """ + settings = {} + return settings + def install(self, pattern, diffraction): """Set the model up in one generator. diff --git a/src/diffpy/cmirietveld/instruments.py b/src/diffpy/cmirietveld/instruments.py index 2ee8695..632e7ca 100644 --- a/src/diffpy/cmirietveld/instruments.py +++ b/src/diffpy/cmirietveld/instruments.py @@ -365,6 +365,33 @@ def set_polarization(self, polarization_rate): self._polarization_rate = polarization_rate self.mark_settings_changed() + def get_settings(self): + """Return the settings in use, for showing to a user. + + The radiation type and the polarization rate are always listed. + A tube adds ``xray_source`` and ``kalpha2_ratio``, and explicit + wavelengths add ``kalpha1``, ``kalpha2`` and ``kalpha2_ratio``. + A single wavelength adds nothing, since it is the ``wavelength`` + Parameter. + + Returns + ------- + dict of str to object + The value of each setting in use, by the name of the setter + argument that sets it. + """ + settings = {"radiation_type": self._radiation_type} + if self._xray_source is not None: + settings["xray_source"] = self._xray_source + settings["kalpha2_ratio"] = self._kalpha2_ratio + elif self._kalpha_wavelengths is not None: + kalpha1, kalpha2 = self._kalpha_wavelengths + settings["kalpha1"] = kalpha1 + settings["kalpha2"] = kalpha2 + settings["kalpha2_ratio"] = self._kalpha2_ratio + settings["polarization_rate"] = self._polarization_rate + return settings + def install(self, pattern, diffraction): """Set the radiation, wavelengths and polarization of one generator. diff --git a/src/diffpy/cmirietveld/powderdiffractiongenerator.py b/src/diffpy/cmirietveld/powderdiffractiongenerator.py index a49a99b..006741f 100644 --- a/src/diffpy/cmirietveld/powderdiffractiongenerator.py +++ b/src/diffpy/cmirietveld/powderdiffractiongenerator.py @@ -30,11 +30,8 @@ from pyobjcryst.crystal import Crystal, create_crystal_from_cif from pyobjcryst.powderpattern import PowderPattern -from diffpy.cmirietveld.instruments import ( - ConstantWavelengthInstrument, - Instrument, -) -from diffpy.cmirietveld.peak_profiles import PeakProfile, PseudoVoigtProfile +from diffpy.cmirietveld.instruments import Instrument +from diffpy.cmirietveld.peak_profiles import PeakProfile from diffpy.cmistructure import structure_to_parameter_set from diffpy.cmistructure.basestructureparset import BaseStructureParSet from diffpy.srfit.exceptions import SrFitError @@ -42,15 +39,6 @@ PLACEHOLDER_TTH = np.linspace(5.0, 175.0, 1000) -COMPONENT_STAGES = { - "instrument": (Instrument, "instrument", "ConstantWavelengthInstrument"), - "peak_profile": ( - PeakProfile, - "peak profile", - "PseudoVoigtProfile or TCHProfile", - ), -} - class PowderDiffractionGenerator(ProfileGenerator): """Calculate the Bragg intensity of one phase of a powder pattern @@ -60,9 +48,10 @@ class PowderDiffractionGenerator(ProfileGenerator): calculation: an intensity source, an instrument, zero or more intensity corrections and a peak profile. The Parameters of each component sit under its name, for example - ``generator.peak_profile.U`` and ``generator.instrument.zero``. With - nothing set, the generator uses a ``PseudoVoigtProfile`` and a - ``ConstantWavelengthInstrument``. + ``generator.peak_profile.U`` and ``generator.instrument.zero``. The + generator has no defaults: the structure, the instrument and the peak + profile are each set before a pattern is calculated, so a script + always shows which models it refines. The generator calculates the Bragg intensity only; a background is added in the FitContribution equation. @@ -78,10 +67,10 @@ class PowderDiffractionGenerator(ProfileGenerator): ---------- phase : ParameterSet or None The structure ParameterSet, or None until a structure is set. - peak_profile : PeakProfile - The peak profile component. - instrument : Instrument - The instrument component. + peak_profile : PeakProfile or None + The peak profile component, or None until one is set. + instrument : Instrument or None + The instrument component, or None until one is set. Managed Parameters ------------------ @@ -97,8 +86,8 @@ def __init__(self, name="bragg"): self._last_tth = np.empty(0) self._installed_settings = {} self._new_parameter("scale", 1.0) - self.add_parameter_set(ConstantWavelengthInstrument()) - self.add_parameter_set(PseudoVoigtProfile()) + self.instrument = None + self.peak_profile = None def set_structure(self, structure, name=None): """Set the structure used to calculate the pattern. @@ -225,7 +214,12 @@ def set_peak_profile(self, peak_profile): fit recipe or constrained in one, since the recipe would refine a Parameter that no longer changes the pattern. """ - self._set_component("peak_profile", peak_profile) + self._set_component( + "peak_profile", + peak_profile, + PeakProfile, + "PseudoVoigtProfile or TCHProfile", + ) def set_instrument(self, instrument): """Set the instrument component. @@ -251,7 +245,12 @@ def set_instrument(self, instrument): fit recipe or constrained in one, since the recipe would refine a Parameter that no longer changes the pattern. """ - self._set_component("instrument", instrument) + self._set_component( + "instrument", + instrument, + Instrument, + "ConstantWavelengthInstrument", + ) def add_intensity_correction(self, correction): """Add an intensity correction component. @@ -283,6 +282,57 @@ def set_intensity_source(self, intensity_source): """ raise NotImplementedError + def print_info(self): + """Print what the generator holds. + + The scale is printed first, then the structure, the instrument + and the peak profile, each under the name its Parameters sit + under, so a user can check a setup before refining it. The + structure shows its space group and lattice parameters, with the + angles in degrees, rather than all its Parameters. Each + component shows its type, then its settings from + ``get_settings`` on one line if it has any, then the current + value of each of its Parameters. Anything that isn't set yet + shows "not set". + """ + lines = [ + f"PowderDiffractionGenerator '{self.name}'", + f" scale = {_format_value(self.scale.value)}", + ] + if self.phase is None: + lines.append(" phase: not set") + else: + crystal = self.phase.structure + lines.append( + f" {self.phase.name}: Crystal '{crystal.GetName()}', " + f"space group {crystal.GetSpaceGroup().GetName()}" + ) + for length_name in ["a", "b", "c"]: + length = self.phase.get(length_name).value + lines.append(f" {length_name} = {_format_value(length)}") + for angle_name in ["alpha", "beta", "gamma"]: + angle = np.degrees(self.phase.get(angle_name).value) + lines.append(f" {angle_name} = {_format_value(angle)}") + components = { + "instrument": self.instrument, + "peak_profile": self.peak_profile, + } + for component_name, component in components.items(): + if component is None: + lines.append(f" {component_name}: not set") + continue + lines.append(f" {component_name}: {type(component).__name__}") + settings = [] + for setting_name, value in component.get_settings().items(): + settings.append(f"{setting_name} = {_format_value(value)}") + if settings: + lines.append(f" settings: {', '.join(settings)}") + for parameter in component.iterate_over_parameters(): + lines.append( + f" {parameter.name} = {_format_value(parameter.value)}" + ) + print("\n".join(lines)) + def __call__(self, tth): """Calculate the Bragg intensity. @@ -305,13 +355,14 @@ def __call__(self, tth): Raises ------ SrFitError - If no structure is set, or the instrument has no wavelength. + If no structure, instrument or peak profile is set, or the + instrument has no wavelength. ValueError If a component's Parameter value is outside the limits of its ObjCryst ``RefinablePar``. """ - self._check_structure() - components = [self.get(stage_name) for stage_name in COMPONENT_STAGES] + self._check_setup() + components = [self.instrument, self.peak_profile] for component in components: component._validate() if not np.array_equal(tth, self._last_tth): @@ -337,15 +388,26 @@ def __call__(self, tth): return bragg_intensity def _validate(self): - self._check_structure() + self._check_setup() ProfileGenerator._validate(self) - def _check_structure(self): + def _check_setup(self): if self.phase is None: raise SrFitError( "The generator has no structure. Please set one with " "set_structure or set_structure_from_parset." ) + if self.instrument is None: + raise SrFitError( + "The generator has no instrument. Please set one with " + "set_instrument, such as a ConstantWavelengthInstrument." + ) + if self.peak_profile is None: + raise SrFitError( + "The generator has no peak profile. Please set one with " + "set_peak_profile, such as a PseudoVoigtProfile or a " + "TCHProfile." + ) def _replace_phase(self, phase, setter_name): if self.phase is not None: @@ -361,9 +423,9 @@ def _replace_phase(self, phase, setter_name): self._installed_settings = {} self._flush(other=(self,)) - def _set_component(self, stage_name, component): - base_class, kind, examples = COMPONENT_STAGES[stage_name] - setter_name = f"set_{stage_name}" + def _set_component(self, component_name, component, base_class, examples): + setter_name = f"set_{component_name}" + kind = component_name.replace("_", " ") if not isinstance(component, base_class): given_name = type(component).__name__ raise ValueError( @@ -372,9 +434,11 @@ def _set_component(self, stage_name, component): f"Please give {_get_article(base_class.__name__)} " f"{base_class.__name__}, such as {examples}." ) - old_component = self.get(stage_name) - _warn_if_parameters_used(old_component, setter_name, kind) - self.remove_parameter_set(old_component) + old_component = getattr(self, component_name) + if old_component is not None: + _warn_if_parameters_used(old_component, setter_name, kind) + self.remove_parameter_set(old_component) + setattr(self, component_name, component) self.add_parameter_set(component) self._flush(other=(self,)) @@ -418,6 +482,16 @@ def _warn_if_parameters_used(parameter_set, setter_name, kind): ) +def _format_value(value): + if value is None: + formatted_value = "not set" + elif isinstance(value, str): + formatted_value = value + else: + formatted_value = f"{value:g}" + return formatted_value + + def _get_article(word): article = "an" if word[0].lower() in "aeiou" else "a" return article diff --git a/tests/conftest.py b/tests/conftest.py index fe21a49..e88c83a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,8 @@ ) from diffpy.cmirietveld.component import Component -from diffpy.cmirietveld.peak_profiles import PeakProfile +from diffpy.cmirietveld.instruments import ConstantWavelengthInstrument +from diffpy.cmirietveld.peak_profiles import PeakProfile, PseudoVoigtProfile from diffpy.cmirietveld.powderdiffractiongenerator import ( PowderDiffractionGenerator, ) @@ -107,7 +108,10 @@ def other_pbso4_pattern(): def pbso4_generator(): pbso4_generator = PowderDiffractionGenerator() pbso4_generator.set_structure(create_crystal_from_cif(str(PBSO4_CIF))) - pbso4_generator.instrument.set_wavelength(1.5406) + instrument = ConstantWavelengthInstrument() + instrument.set_wavelength(1.5406) + pbso4_generator.set_instrument(instrument) + pbso4_generator.set_peak_profile(PseudoVoigtProfile()) return pbso4_generator @@ -117,7 +121,10 @@ def other_pbso4_generator(): other_pbso4_generator.set_structure( create_crystal_from_cif(str(PBSO4_CIF)) ) - other_pbso4_generator.instrument.set_wavelength(1.5406) + instrument = ConstantWavelengthInstrument() + instrument.set_wavelength(1.5406) + other_pbso4_generator.set_instrument(instrument) + other_pbso4_generator.set_peak_profile(PseudoVoigtProfile()) return other_pbso4_generator diff --git a/tests/test_component.py b/tests/test_component.py index 7e2b1a9..5a168f6 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -328,3 +328,12 @@ def test_settings_version_after_parameter_change(): actual_settings_version = component.settings_version expected_settings_version = 0 assert actual_settings_version == expected_settings_version + + +def test_get_settings(): + # Test the settings of a component that has none, such as a peak profile + # Expected: An empty dict, so print_info shows no settings for it. + component = Component("peak_profile") + actual_settings = component.get_settings() + expected_settings = {} + assert actual_settings == expected_settings diff --git a/tests/test_instruments.py b/tests/test_instruments.py index 49cf81a..bf868ee 100644 --- a/tests/test_instruments.py +++ b/tests/test_instruments.py @@ -152,6 +152,68 @@ def test_wavelength_after_setter( instrument._validate() +@pytest.mark.parametrize( + "setter_name, setter_args, expected_settings", + [ + # Test the settings an instrument reports to print_info. Only the + # settings in use are listed, each under the name of the setter + # argument that sets it + # C1: A user sets a single wavelength. + # Expected: The radiation type and the polarization rate. + ( + "set_wavelength", + [1.5406], + {"radiation_type": "X", "polarization_rate": 0.0}, + ), + # C2: A user sets a Cu tube. + # Expected: The tube and its Kalpha2/Kalpha1 ratio are listed too. + ( + "set_xray_source", + ["Cu"], + { + "radiation_type": "X", + "xray_source": "Cu", + "kalpha2_ratio": 0.5, + "polarization_rate": 0.0, + }, + ), + # C3: A user sets the Kalpha1 and Kalpha2 wavelengths from their + # instrument file, with a ratio of 0.4. + # Expected: The two wavelengths and the ratio are listed. + ( + "set_xray_wavelengths", + [CU_KALPHA1, CU_KALPHA2, 0.4], + { + "radiation_type": "X", + "kalpha1": CU_KALPHA1, + "kalpha2": CU_KALPHA2, + "kalpha2_ratio": 0.4, + "polarization_rate": 0.0, + }, + ), + # C4: A user switches to neutrons. + # Expected: The radiation type is N. + ( + "set_radiation_type", + ["N"], + {"radiation_type": "N", "polarization_rate": 0.0}, + ), + # C5: A user sets a polarization rate of 0.95. + # Expected: The polarization rate is 0.95. + ( + "set_polarization", + [0.95], + {"radiation_type": "X", "polarization_rate": 0.95}, + ), + ], +) +def test_get_settings(setter_name, setter_args, expected_settings): + instrument = ConstantWavelengthInstrument() + getattr(instrument, setter_name)(*setter_args) + actual_settings = instrument.get_settings() + assert actual_settings == expected_settings + + @pytest.mark.parametrize( "setter_name, setter_args", [ diff --git a/tests/test_powderdiffractiongenerator.py b/tests/test_powderdiffractiongenerator.py index 41923a9..064aa3d 100644 --- a/tests/test_powderdiffractiongenerator.py +++ b/tests/test_powderdiffractiongenerator.py @@ -24,23 +24,23 @@ def test_init(): # Test a new generator before anything is set - # Expected: It is named bragg, has no structure, uses a pseudo-Voigt - # peak profile and a constant-wavelength instrument, and its scale is 1. + # Expected: It is named bragg, has no structure, no instrument and no + # peak profile, since a user chooses each of them, and its scale is 1. generator = PowderDiffractionGenerator() actual_name = generator.name actual_phase = generator.phase - actual_peak_profile_type = type(generator.peak_profile) - actual_instrument_type = type(generator.instrument) + actual_instrument = generator.instrument + actual_peak_profile = generator.peak_profile actual_scale = generator.scale.value expected_name = "bragg" expected_phase = None - expected_peak_profile_type = PseudoVoigtProfile - expected_instrument_type = ConstantWavelengthInstrument + expected_instrument = None + expected_peak_profile = None expected_scale = 1.0 assert actual_name == expected_name assert actual_phase == expected_phase - assert actual_peak_profile_type == expected_peak_profile_type - assert actual_instrument_type == expected_instrument_type + assert actual_instrument == expected_instrument + assert actual_peak_profile == expected_peak_profile assert actual_scale == expected_scale @@ -89,7 +89,10 @@ def test_set_structure_with_crystal(set_structure_kwargs, expected_phase_name): def test_set_structure_with_cif_path(pbso4_pattern, cif_path): generator = PowderDiffractionGenerator() generator.set_structure(cif_path) - generator.instrument.set_wavelength(1.5406) + instrument = ConstantWavelengthInstrument() + instrument.set_wavelength(1.5406) + generator.set_instrument(instrument) + generator.set_peak_profile(PseudoVoigtProfile()) actual_structure_type = type(generator.phase.structure) actual_pattern = generator(pbso4_pattern.tth) expected_structure_type = Crystal @@ -104,13 +107,18 @@ def test_call_with_cif_path_given_to_two_generators(pbso4_pattern): # parameter a of one is set to 8.50 angstroms, the other's pattern is # still plain pyobjcryst's for the CIF. Sharing one structure needs one # ParameterSet given to both generators. - # Give two generators the same path + # Give two generators the same path, instrument and peak profile generator = PowderDiffractionGenerator() other_generator = PowderDiffractionGenerator() generator.set_structure(PBSO4_CIF) other_generator.set_structure(PBSO4_CIF) - generator.instrument.set_wavelength(1.5406) - other_generator.instrument.set_wavelength(1.5406) + instrument = ConstantWavelengthInstrument() + instrument.set_wavelength(1.5406) + peak_profile = PseudoVoigtProfile() + generator.set_instrument(instrument) + other_generator.set_instrument(instrument) + generator.set_peak_profile(peak_profile) + other_generator.set_peak_profile(peak_profile) # Change a on the first generator only generator.phase.a.set_value(8.50) actual_other_pattern = other_generator(pbso4_pattern.tth) @@ -234,7 +242,10 @@ def test_call_after_set_structure_twice(pbso4_pattern): first_crystal = create_crystal_from_cif(str(PBSO4_CIF)) first_crystal.GetPar("a").SetHumanValue(8.50) generator.set_structure(first_crystal) - generator.instrument.set_wavelength(1.5406) + instrument = ConstantWavelengthInstrument() + instrument.set_wavelength(1.5406) + generator.set_instrument(instrument) + generator.set_peak_profile(PseudoVoigtProfile()) generator(pbso4_pattern.tth) # Replace it with the CIF's structure and calculate again second_crystal = create_crystal_from_cif(str(PBSO4_CIF)) @@ -263,11 +274,12 @@ def test_get_after_set_structure_with_new_name(pbso4_generator): assert actual_new_phase == expected_new_phase -def test_call_with_default_components(pbso4_generator, pbso4_pattern): - # Test calculating a pattern with the default peak profile and - # instrument +def test_call_with_pseudo_voigt_profile(pbso4_generator, pbso4_pattern): + # Test calculating a pattern with a pseudo-Voigt peak profile and a + # constant-wavelength instrument # Expected: The pattern equals the one plain pyobjcryst calculates for - # the same structure, grid and wavelength. + # the same structure, grid and wavelength, whose own default profile is + # pseudo-Voigt. actual_pattern = pbso4_generator(pbso4_pattern.tth) expected_pattern = pbso4_pattern.calculate() assert np.allclose(actual_pattern, expected_pattern) @@ -346,18 +358,21 @@ def test_call_after_set_peak_profile( assert np.allclose(actual_pattern, expected_pattern) -def test_call_after_set_peak_profile_before_set_structure(pbso4_pattern): - # Test setting up the peak profile before the structure, as a user may - # do at the top of a script +def test_call_after_set_components_before_set_structure(pbso4_pattern): + # Test setting up the instrument and the peak profile before the + # structure, as a user may do at the top of a script # Expected: The pattern equals plain pyobjcryst's with a TCH profile, - # because the profile is installed once the structure is set. - # Set the profile first, then the structure and the wavelength + # because the instrument and the profile are installed once the + # structure is set. + # Set the instrument and the profile first, then the structure generator = PowderDiffractionGenerator() + instrument = ConstantWavelengthInstrument() + instrument.set_wavelength(1.5406) + generator.set_instrument(instrument) tch_profile = TCHProfile() tch_profile.X.set_value(0.02) generator.set_peak_profile(tch_profile) generator.set_structure(create_crystal_from_cif(str(PBSO4_CIF))) - generator.instrument.set_wavelength(1.5406) actual_pattern = generator(pbso4_pattern.tth) # Install the same profile directly in pyobjcryst pbso4_pattern.diffraction.SetProfile(ReflectionProfilePseudoVoigtTCH()) @@ -568,7 +583,8 @@ def test_call_with_shared_structure(pbso4_pattern): # does to refine one structure against two datasets # Expected: After the lattice parameter a is set to 8.50 angstroms, # both patterns equal plain pyobjcryst's with a = 8.50 angstroms. - # Give two generators the same structure and calculate once + # Give two generators the same structure, instrument and peak profile, + # and calculate once shared_phase = structure_to_parameter_set( "phase", create_crystal_from_cif(str(PBSO4_CIF)) ) @@ -576,8 +592,13 @@ def test_call_with_shared_structure(pbso4_pattern): other_generator = PowderDiffractionGenerator() generator.set_structure(shared_phase) other_generator.set_structure(shared_phase) - generator.instrument.set_wavelength(1.5406) - other_generator.instrument.set_wavelength(1.5406) + instrument = ConstantWavelengthInstrument() + instrument.set_wavelength(1.5406) + peak_profile = PseudoVoigtProfile() + generator.set_instrument(instrument) + other_generator.set_instrument(instrument) + generator.set_peak_profile(peak_profile) + other_generator.set_peak_profile(peak_profile) generator(pbso4_pattern.tth) other_generator(pbso4_pattern.tth) # Change a once and calculate both again @@ -774,8 +795,12 @@ def test_call_before_set_structure(pbso4_pattern): # Test calculating a pattern before a structure is set # Expected: An SrFitError says that the structure is missing and how to # set it. + # Set everything but the structure generator = PowderDiffractionGenerator() - generator.instrument.set_wavelength(1.5406) + instrument = ConstantWavelengthInstrument() + instrument.set_wavelength(1.5406) + generator.set_instrument(instrument) + generator.set_peak_profile(PseudoVoigtProfile()) expected_msg = ( "The generator has no structure. Please set one with " "set_structure or set_structure_from_parset." @@ -784,12 +809,141 @@ def test_call_before_set_structure(pbso4_pattern): generator(pbso4_pattern.tth) +def test_call_before_set_instrument(pbso4_pattern): + # Test calculating a pattern before an instrument is set, as when a user + # forgets it in their script + # Expected: An SrFitError says that the instrument is missing and how to + # set it. + # Set everything but the instrument + generator = PowderDiffractionGenerator() + generator.set_structure(create_crystal_from_cif(str(PBSO4_CIF))) + generator.set_peak_profile(PseudoVoigtProfile()) + expected_msg = ( + "The generator has no instrument. Please set one with " + "set_instrument, such as a ConstantWavelengthInstrument." + ) + with pytest.raises(SrFitError, match=re.escape(expected_msg)): + generator(pbso4_pattern.tth) + + +def test_call_before_set_peak_profile(pbso4_pattern): + # Test calculating a pattern before a peak profile is set, as when a + # user forgets it in their script + # Expected: An SrFitError says that the peak profile is missing and how + # to set it. + # Set everything but the peak profile + generator = PowderDiffractionGenerator() + generator.set_structure(create_crystal_from_cif(str(PBSO4_CIF))) + instrument = ConstantWavelengthInstrument() + instrument.set_wavelength(1.5406) + generator.set_instrument(instrument) + expected_msg = ( + "The generator has no peak profile. Please set one with " + "set_peak_profile, such as a PseudoVoigtProfile or a TCHProfile." + ) + with pytest.raises(SrFitError, match=re.escape(expected_msg)): + generator(pbso4_pattern.tth) + + +def test_residual_before_set_structure(pbso4_pattern): + # Test starting a fit with a generator that has no structure + # Expected: The recipe's first residual raises the same SrFitError as + # calculating the pattern, before anything is calculated. + # Set everything but the structure, and build a recipe on the generator + generator = PowderDiffractionGenerator() + instrument = ConstantWavelengthInstrument() + instrument.set_wavelength(1.5406) + generator.set_instrument(instrument) + generator.set_peak_profile(PseudoVoigtProfile()) + profile = Profile() + profile.set_observed_profile( + pbso4_pattern.tth, np.zeros_like(pbso4_pattern.tth) + ) + contribution = FitContribution("xray") + contribution.set_profile(profile) + contribution.add_profile_generator(generator) + contribution.set_equation("bragg") + recipe = FitRecipe() + recipe.add_contribution(contribution) + recipe.add_variable(generator.scale) + # Start the fit + expected_msg = ( + "The generator has no structure. Please set one with " + "set_structure or set_structure_from_parset." + ) + with pytest.raises(SrFitError, match=re.escape(expected_msg)): + recipe.residual() + + +def test_residual_before_set_instrument(pbso4_pattern): + # Test starting a fit with a generator that has no instrument + # Expected: The recipe's first residual raises the same SrFitError as + # calculating the pattern, before anything is calculated. + # Set everything but the instrument, and build a recipe on the + # generator + generator = PowderDiffractionGenerator() + generator.set_structure(create_crystal_from_cif(str(PBSO4_CIF))) + generator.set_peak_profile(PseudoVoigtProfile()) + profile = Profile() + profile.set_observed_profile( + pbso4_pattern.tth, np.zeros_like(pbso4_pattern.tth) + ) + contribution = FitContribution("xray") + contribution.set_profile(profile) + contribution.add_profile_generator(generator) + contribution.set_equation("bragg") + recipe = FitRecipe() + recipe.add_contribution(contribution) + recipe.add_variable(generator.scale) + # Start the fit + expected_msg = ( + "The generator has no instrument. Please set one with " + "set_instrument, such as a ConstantWavelengthInstrument." + ) + with pytest.raises(SrFitError, match=re.escape(expected_msg)): + recipe.residual() + + +def test_residual_before_set_peak_profile(pbso4_pattern): + # Test starting a fit with a generator that has no peak profile + # Expected: The recipe's first residual raises the same SrFitError as + # calculating the pattern, before anything is calculated. + # Set everything but the peak profile, and build a recipe on the + # generator + generator = PowderDiffractionGenerator() + generator.set_structure(create_crystal_from_cif(str(PBSO4_CIF))) + instrument = ConstantWavelengthInstrument() + instrument.set_wavelength(1.5406) + generator.set_instrument(instrument) + profile = Profile() + profile.set_observed_profile( + pbso4_pattern.tth, np.zeros_like(pbso4_pattern.tth) + ) + contribution = FitContribution("xray") + contribution.set_profile(profile) + contribution.add_profile_generator(generator) + contribution.set_equation("bragg") + recipe = FitRecipe() + recipe.add_contribution(contribution) + recipe.add_variable(generator.scale) + # Start the fit + expected_msg = ( + "The generator has no peak profile. Please set one with " + "set_peak_profile, such as a PseudoVoigtProfile or a TCHProfile." + ) + with pytest.raises(SrFitError, match=re.escape(expected_msg)): + recipe.residual() + + def test_call_before_set_wavelength(pbso4_pattern): # Test calculating a pattern before the instrument has a wavelength # Expected: An SrFitError says that the wavelength is missing and how to # set it. + # Set everything, with an instrument that has no wavelength generator = PowderDiffractionGenerator() generator.set_structure(create_crystal_from_cif(str(PBSO4_CIF))) + generator.set_instrument(ConstantWavelengthInstrument()) + generator.set_peak_profile(PseudoVoigtProfile()) expected_msg = ( "The instrument has no wavelength. Please set one with " "set_wavelength, set_xray_source or set_xray_wavelengths." @@ -942,3 +1096,70 @@ def test_set_structure_without_recipe_variables( with warnings.catch_warnings(): warnings.simplefilter("error", UserWarning) pbso4_generator.set_structure(create_crystal_from_cif(str(PBSO4_CIF))) + + +def test_print_info(pbso4_generator, capsys): + # Test printing what a fully set up generator holds, as a user does to + # check their setup before refining + # Expected: The scale, then the structure, the instrument and the peak + # profile under the names their Parameters sit under. The structure + # shows its space group and lattice parameters, with the angles in + # degrees. Each component shows its type, then its settings, if it + # has any, on one line, then its Parameter values. + pbso4_generator.print_info() + actual_info = capsys.readouterr().out + expected_info = ( + "PowderDiffractionGenerator 'bragg'\n" + " scale = 1\n" + " phase: Crystal 'O4 Pb S', space group P n m a\n" + " a = 8.48\n" + " b = 5.398\n" + " c = 6.958\n" + " alpha = 90\n" + " beta = 90\n" + " gamma = 90\n" + " instrument: ConstantWavelengthInstrument\n" + " settings: radiation_type = X, polarization_rate = 0\n" + " zero = 0\n" + " displacement = 0\n" + " transparency = 0\n" + " mu_r = 0\n" + " wavelength = 1.5406\n" + " peak_profile: PseudoVoigtProfile\n" + " U = 0\n" + " V = 0\n" + " W = 0.01\n" + " eta0 = 0.5\n" + " eta1 = 0\n" + " asym0 = 1\n" + " asym1 = 0\n" + " asym2 = 0\n" + ) + assert actual_info == expected_info + + +def test_print_info_before_setup_is_complete(capsys): + # Test printing what a generator holds while a user is still setting it + # up + # Expected: The structure and the peak profile, which aren't set, show + # "not set", and so does the wavelength of the instrument, which has no + # wavelength yet. + # Set only an instrument, without a wavelength + generator = PowderDiffractionGenerator() + generator.set_instrument(ConstantWavelengthInstrument()) + generator.print_info() + actual_info = capsys.readouterr().out + expected_info = ( + "PowderDiffractionGenerator 'bragg'\n" + " scale = 1\n" + " phase: not set\n" + " instrument: ConstantWavelengthInstrument\n" + " settings: radiation_type = X, polarization_rate = 0\n" + " zero = 0\n" + " displacement = 0\n" + " transparency = 0\n" + " mu_r = 0\n" + " wavelength = not set\n" + " peak_profile: not set\n" + ) + assert actual_info == expected_info From c8b62d43cc5edff0857ca2e1e6ea59d8a49a21c0 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Tue, 3 Nov 2026 11:56:21 -0500 Subject: [PATCH 5/6] fix pre-commit failures --- .github/workflows/check-news-item.yml | 2 +- src/diffpy/cmirietveld/__init__.py | 6 ++++-- src/diffpy/cmirietveld/version.py | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check-news-item.yml b/.github/workflows/check-news-item.yml index 2e36b22..4bccdde 100644 --- a/.github/workflows/check-news-item.yml +++ b/.github/workflows/check-news-item.yml @@ -3,7 +3,7 @@ name: Check for News on: pull_request_target: branches: - - main # GitHub does not evaluate expressions in trigger filters; edit this value if your base branch is not main + - main # GitHub does not evaluate expressions in trigger filters; edit this value if your base branch is not main jobs: check-news-item: diff --git a/src/diffpy/cmirietveld/__init__.py b/src/diffpy/cmirietveld/__init__.py index e916dfc..a408547 100644 --- a/src/diffpy/cmirietveld/__init__.py +++ b/src/diffpy/cmirietveld/__init__.py @@ -4,7 +4,8 @@ # (c) 2026 diffpy.cmirietveld contributors. # All rights reserved. # -# File coded by: Caden Myers, members of the Billinge group, and contributors to the diffpy.cmirietveld project. +# File coded by: Caden Myers, members of the Billinge group, and +# contributors to the diffpy.cmirietveld project. # # See GitHub contributions for a more detailed list of contributors. # https://github.com/diffpy/diffpy.cmirietveld/graphs/contributors @@ -12,7 +13,8 @@ # See LICENSE.rst for license information. # ############################################################################## -"""A pack for diffpy.cmi that allows it to do Rietveld refinement of powder diffraction data""" +"""A pack for diffpy.cmi that allows it to do Rietveld refinement of +powder diffraction data.""" # package version from diffpy.cmirietveld.version import __version__ # noqa diff --git a/src/diffpy/cmirietveld/version.py b/src/diffpy/cmirietveld/version.py index 2b4865c..97d61cc 100644 --- a/src/diffpy/cmirietveld/version.py +++ b/src/diffpy/cmirietveld/version.py @@ -4,7 +4,8 @@ # (c) 2026 diffpy.cmirietveld contributors. # All rights reserved. # -# File coded by: Caden Myers, members of the Billinge group, and contributors to the diffpy.cmirietveld project. +# File coded by: Caden Myers, members of the Billinge group, and +# contributors to the diffpy.cmirietveld project. # # See GitHub contributions for a more detailed list of contributors. # https://github.com/diffpy/diffpy.cmirietveld/graphs/contributors # noqa: E501 From 06f3507ed7a18e6d197770aa175fb913b263f565 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Tue, 3 Nov 2026 12:05:34 -0500 Subject: [PATCH 6/6] build: drop Python 3.14 until diffpy.cmistructure supports it diffpy.cmistructure 0.1.1 requires Python <3.14, so CI's tests on 3.14 could not install it. Cap requires-python at <3.14 in pyproject.toml and cookiecutter.json, and use 3.13 in the getting-started docs. Co-Authored-By: Claude Opus 5.5 --- cookiecutter.json | 2 +- docs/source/getting-started.rst | 4 ++-- pyproject.toml | 3 +-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/cookiecutter.json b/cookiecutter.json index 80f07a4..dcd91b6 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -14,7 +14,7 @@ "project_short_description": "A pack for diffpy.cmi that allows it to do Rietveld refinement of powder diffraction data", "project_keywords": "diffraction, PDF, X-ray, neutron, Rietveld", "minimum_supported_python_version": "3.12", - "maximum_supported_python_version": "3.14", + "maximum_supported_python_version": "3.13", "project_needs_c_code_compiled": "No", "project_has_gui_tests": "No" } diff --git a/docs/source/getting-started.rst b/docs/source/getting-started.rst index 6b62bf8..3f47908 100644 --- a/docs/source/getting-started.rst +++ b/docs/source/getting-started.rst @@ -38,12 +38,12 @@ Here is how you can write a block of code in the documentation. You can use the .. code-block:: bash # Create a new environment, without build dependencies (pure Python package) - conda create -n -env python=3.14 \ + conda create -n -env python=3.13 \ --file requirements/tests.txt \ --file requirements/conda.txt # Create a new environment, with build dependencies (non-pure Python package) - conda create -n -env python=3.14 \ + conda create -n -env python=3.13 \ --file requirements/tests.txt \ --file requirements/conda.txt \ --file requirements/build.txt diff --git a/pyproject.toml b/pyproject.toml index 52491de..fac3b93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ maintainers = [ description = "A pack for diffpy.cmi that allows it to do Rietveld refinement of powder diffraction data" keywords = ['diffraction', 'PDF', 'X-ray', 'neutron', 'Rietveld'] readme = "README.rst" -requires-python = ">=3.12, <3.15" +requires-python = ">=3.12, <3.14" classifiers = [ 'Development Status :: 5 - Production/Stable', 'Environment :: Console', @@ -29,7 +29,6 @@ classifiers = [ 'Operating System :: Unix', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', - 'Programming Language :: Python :: 3.14', 'Topic :: Scientific/Engineering :: Physics', 'Topic :: Scientific/Engineering :: Chemistry', ]