From c11216c28ef5783c7fcc740086ab65cebb025b8b Mon Sep 17 00:00:00 2001 From: stevenhua0320 Date: Mon, 6 Apr 2026 18:17:55 -0400 Subject: [PATCH 1/6] fix: fix Qmax update after load new parsed data --- news/fix-qmax-update.rst | 23 ++++++++++++++++++++ src/diffpy/srfit/fitbase/profilegenerator.py | 15 ++++++++----- 2 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 news/fix-qmax-update.rst diff --git a/news/fix-qmax-update.rst b/news/fix-qmax-update.rst new file mode 100644 index 00000000..790d30b1 --- /dev/null +++ b/news/fix-qmax-update.rst @@ -0,0 +1,23 @@ +**Added:** + +* + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/srfit/fitbase/profilegenerator.py b/src/diffpy/srfit/fitbase/profilegenerator.py index c6adabbc..ee4495d6 100644 --- a/src/diffpy/srfit/fitbase/profilegenerator.py +++ b/src/diffpy/srfit/fitbase/profilegenerator.py @@ -163,15 +163,18 @@ def set_profile(self, profile): will store the calculated signal. """ if self.profile is not None: - self.profile.removeObserver(self._flush) + self.profile.removeObserver(self._on_profile_changed) self.profile = profile - self.profile.addObserver(self._flush) - self._flush(other=(self,)) + self.profile.addObserver(self._on_profile_changed) + self._on_profile_changed(other=(self,)) + return - # Merge the profiles metadata with our own - self.meta.update(self.profile.meta) - self.processMetaData() + def _on_profile_changed(self, other=()): + if self.profile is not None: + self.meta.update(self.profile.meta) + self.processMetaData() + self._flush(other=other) return def processMetaData(self): From 3c65924653abccf5938e69d655bedc3f2baf2eee Mon Sep 17 00:00:00 2001 From: stevenhua0320 Date: Mon, 6 Apr 2026 18:24:44 -0400 Subject: [PATCH 2/6] chore: add news item --- news/fix-qmax-update.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/news/fix-qmax-update.rst b/news/fix-qmax-update.rst index 790d30b1..958a4dbe 100644 --- a/news/fix-qmax-update.rst +++ b/news/fix-qmax-update.rst @@ -16,7 +16,7 @@ **Fixed:** -* +* Fixed load new parsed data with updated `Qmax` attribute **Security:** From e2b5c46c188c0cdb718bca6f3d1f8feb02c113fc Mon Sep 17 00:00:00 2001 From: stevenhua0320 Date: Tue, 7 Apr 2026 11:08:49 -0400 Subject: [PATCH 3/6] chore: rename the private method --- src/diffpy/srfit/fitbase/profilegenerator.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/diffpy/srfit/fitbase/profilegenerator.py b/src/diffpy/srfit/fitbase/profilegenerator.py index ee4495d6..3007e8de 100644 --- a/src/diffpy/srfit/fitbase/profilegenerator.py +++ b/src/diffpy/srfit/fitbase/profilegenerator.py @@ -163,14 +163,14 @@ def set_profile(self, profile): will store the calculated signal. """ if self.profile is not None: - self.profile.removeObserver(self._on_profile_changed) + self.profile.removeObserver(self._on_profile_update) self.profile = profile - self.profile.addObserver(self._on_profile_changed) - self._on_profile_changed(other=(self,)) + self.profile.addObserver(self._on_profile_update) + self._on_profile_update(other=(self,)) return - def _on_profile_changed(self, other=()): + def _on_profile_update(self, other=()): if self.profile is not None: self.meta.update(self.profile.meta) self.processMetaData() From 43d83a1ab0986f8f31d1053d119f4c890c1d9033 Mon Sep 17 00:00:00 2001 From: stevenhua0320 Date: Tue, 7 Apr 2026 23:20:01 -0400 Subject: [PATCH 4/6] build: add diffpy.structure back to requirement --- news/fix-test-package.rst | 23 +++++++++++++++++++++++ requirements/conda.txt | 1 + requirements/pip.txt | 1 + src/diffpy/__init__.py | 3 +++ tests/conftest.py | 5 ----- tests/test_diffpyparset.py | 13 +++---------- tests/test_pdf.py | 28 ++++++---------------------- 7 files changed, 37 insertions(+), 37 deletions(-) create mode 100644 news/fix-test-package.rst diff --git a/news/fix-test-package.rst b/news/fix-test-package.rst new file mode 100644 index 00000000..a569932c --- /dev/null +++ b/news/fix-test-package.rst @@ -0,0 +1,23 @@ +**Added:** + +* Added `diffpy.structure` back to the requirements and run the test + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/requirements/conda.txt b/requirements/conda.txt index 8bc0acb8..2e2402dd 100644 --- a/requirements/conda.txt +++ b/requirements/conda.txt @@ -3,3 +3,4 @@ numpy scipy bg-mpl-stylesheets diffpy.utils +diffpy.structure diff --git a/requirements/pip.txt b/requirements/pip.txt index 4deb9dbf..6a05210c 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -3,3 +3,4 @@ numpy scipy bg-mpl-stylesheets diffpy.utils +diffpy.structure diff --git a/src/diffpy/__init__.py b/src/diffpy/__init__.py index 3254c0a6..9b1379de 100644 --- a/src/diffpy/__init__.py +++ b/src/diffpy/__init__.py @@ -13,3 +13,6 @@ # See LICENSE.rst for license information. # ############################################################################## +from pkgutil import extend_path + +__path__ = extend_path(__path__, __name__) diff --git a/tests/conftest.py b/tests/conftest.py index f251746f..20c47a22 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -73,11 +73,6 @@ def sas_available(): return has_sas() -@pytest.fixture(scope="session") -def diffpy_structure_available(): - return has_diffpy_structure() - - @pytest.fixture(scope="session") def diffpy_srreal_available(): return has_diffpy_srreal() diff --git a/tests/test_diffpyparset.py b/tests/test_diffpyparset.py index 8463d80d..f4ea39aa 100644 --- a/tests/test_diffpyparset.py +++ b/tests/test_diffpyparset.py @@ -18,15 +18,12 @@ import unittest import numpy as np -import pytest from diffpy.srfit.structure.diffpyparset import DiffpyStructureParSet -def testDiffpyStructureParSet(diffpy_structure_available): +def testDiffpyStructureParSet(): """Test the structure conversion.""" - if not diffpy_structure_available: - pytest.skip("diffpy.structure package not available") from diffpy.structure import Atom, Lattice, Structure a1 = Atom("Cu", xyz=np.array([0.0, 0.1, 0.2]), Uisoequiv=0.003) @@ -103,10 +100,8 @@ def _testLattice(): return -def test___repr__(diffpy_structure_available): +def test___repr__(): """Test representation of DiffpyStructureParSet objects.""" - if not diffpy_structure_available: - pytest.skip("diffpy.structure package not available") from diffpy.structure import Atom, Lattice, Structure lat = Lattice(3, 3, 2, 90, 90, 90) @@ -119,10 +114,8 @@ def test___repr__(diffpy_structure_available): return -def test_pickling(diffpy_structure_available): +def test_pickling(): """Test pickling of DiffpyStructureParSet.""" - if not diffpy_structure_available: - pytest.skip("diffpy.structure package not available") from diffpy.structure import Atom, Structure stru = Structure([Atom("C", [0, 0.2, 0.5])]) diff --git a/tests/test_pdf.py b/tests/test_pdf.py index b46fa2a6..dbb9799f 100644 --- a/tests/test_pdf.py +++ b/tests/test_pdf.py @@ -140,11 +140,7 @@ def testParser2(datafile): return -def testGenerator( - diffpy_srreal_available, diffpy_structure_available, datafile -): - if not diffpy_structure_available: - pytest.skip("diffpy.structure package not available") +def testGenerator(diffpy_srreal_available, datafile): if not diffpy_srreal_available: pytest.skip("diffpy.srreal package not available") @@ -200,7 +196,7 @@ def testGenerator( return -def test_setQmin(diffpy_structure_available, diffpy_srreal_available): +def test_setQmin(diffpy_srreal_available): """Verify qmin is propagated to the calculator object.""" if not diffpy_srreal_available: pytest.skip("diffpy.srreal package not available") @@ -214,10 +210,8 @@ def test_setQmin(diffpy_structure_available, diffpy_srreal_available): return -def test_setQmax(diffpy_structure_available, diffpy_srreal_available): +def test_setQmax(diffpy_srreal_available): """Check PDFContribution.setQmax()""" - if not diffpy_structure_available: - pytest.skip("diffpy.structure package not available") from diffpy.structure import Structure if not diffpy_srreal_available: @@ -233,10 +227,8 @@ def test_setQmax(diffpy_structure_available, diffpy_srreal_available): return -def test_getQmax(diffpy_structure_available, diffpy_srreal_available): +def test_getQmax(diffpy_srreal_available): """Check PDFContribution.getQmax()""" - if not diffpy_structure_available: - pytest.skip("diffpy.structure package not available") from diffpy.structure import Structure if not diffpy_srreal_available: @@ -260,12 +252,8 @@ def test_getQmax(diffpy_structure_available, diffpy_srreal_available): return -def test_savetxt( - diffpy_structure_available, diffpy_srreal_available, datafile -): +def test_savetxt(diffpy_srreal_available, datafile): "check PDFContribution.savetxt()" - if not diffpy_structure_available: - pytest.skip("diffpy.structure package not available") from diffpy.structure import Structure if not diffpy_srreal_available: @@ -286,12 +274,8 @@ def test_savetxt( return -def test_pickling( - diffpy_structure_available, diffpy_srreal_available, datafile -): +def test_pickling(diffpy_srreal_available, datafile): "validate PDFContribution.residual() after pickling." - if not diffpy_structure_available: - pytest.skip("diffpy.structure package not available") from diffpy.structure import loadStructure if not diffpy_srreal_available: From a9eae82abd7756eb17e788fbaf4ac49535ac058e Mon Sep 17 00:00:00 2001 From: stevenhua0320 Date: Sat, 11 Apr 2026 15:52:47 -0400 Subject: [PATCH 5/6] chore: rebuild the documentation with proper metadata handling --- docs/source/conf.py | 7 +++-- news/documentation-metadata.rst | 23 ++++++++++++++++ src/diffpy/srfit/fitbase/fitrecipe.py | 32 +++++++++++++---------- src/diffpy/srfit/fitbase/profile.py | 17 +++++++----- src/diffpy/srfit/fitbase/profileparser.py | 19 +++++++++++--- src/diffpy/srfit/fitbase/simplerecipe.py | 19 ++++++++------ src/diffpy/srfit/pdf/pdfcontribution.py | 10 +++---- src/diffpy/srfit/pdf/pdfparser.py | 5 +++- src/diffpy/srfit/sas/sasparser.py | 13 ++++++--- src/diffpy/srfit/sas/sasprofile.py | 16 +++++++----- 10 files changed, 112 insertions(+), 49 deletions(-) create mode 100644 news/documentation-metadata.rst diff --git a/docs/source/conf.py b/docs/source/conf.py index 6f33a1b7..da14b4ad 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -77,7 +77,7 @@ napoleon_custom_sections = [ ("Managed Parameters", "Attributes"), ("Usable Metadata", "Attributes"), - ("General Metadata", "Attributes"), + ("General Metadata", "params_style"), ("Metadata", "Attributes"), ("Properties", "Attributes"), ("Operator Attributes", "Attributes"), @@ -336,4 +336,7 @@ # Example configuration for intersphinx: refer to the Python standard library. -# intersphinx_mapping = {'http://docs.python.org/': None} +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "numpy": ("https://numpy.org/doc/stable/", None), +} diff --git a/news/documentation-metadata.rst b/news/documentation-metadata.rst new file mode 100644 index 00000000..3a124fc7 --- /dev/null +++ b/news/documentation-metadata.rst @@ -0,0 +1,23 @@ +**Added:** + +* No News Added: rebuild the documentation with proper metadata handling + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/srfit/fitbase/fitrecipe.py b/src/diffpy/srfit/fitbase/fitrecipe.py index 4d7be2c9..e04f3af6 100644 --- a/src/diffpy/srfit/fitbase/fitrecipe.py +++ b/src/diffpy/srfit/fitbase/fitrecipe.py @@ -292,15 +292,17 @@ def residual(self, p=[]): ---------- p The list of current variable values, provided in the same order - as the '_parameters' list. If p is an empty iterable (default), - then it is assumed that the parameters have already been - updated in some other way, and the explicit update within this - function is skipped. + as the ``_parameters`` list. If ``p`` is an empty iterable + (default), then it is assumed that the parameters have already + been updated in some other way, and the explicit update within + this function is skipped. + Notes + ----- The residual is by default the weighted concatenation of each - FitContribution's residual, plus the value of each restraint. The array - returned, denoted chiv, is such that - dot(chiv, chiv) = chi^2 + restraints. + :class:`FitContribution` residual, plus the value of each restraint. + The returned array ``chiv`` satisfies + ``dot(chiv, chiv) = chi^2 + restraints``. """ # Prepare, if necessary @@ -343,15 +345,17 @@ def scalarResidual(self, p=[]): ---------- p The list of current variable values, provided in the same order - as the '_parameters' list. If p is an empty iterable (default), - then it is assumed that the parameters have already been - updated in some other way, and the explicit update within this - function is skipped. + as the ``_parameters`` list. If ``p`` is an empty iterable + (default), then it is assumed that the parameters have already + been updated in some other way, and the explicit update within + this function is skipped. + Notes + ----- The residual is by default the weighted concatenation of each - FitContribution's residual, plus the value of each restraint. The array - returned, denoted chiv, is such that - dot(chiv, chiv) = chi^2 + restraints. + :class:`FitContribution` residual, plus the value of each restraint. + The returned array, denoted ``chiv``, is such that + ``dot(chiv, chiv) = chi^2 + restraints``. """ chiv = self.residual(p) return dot(chiv, chiv) diff --git a/src/diffpy/srfit/fitbase/profile.py b/src/diffpy/srfit/fitbase/profile.py index 2600fb8f..6ab7190b 100644 --- a/src/diffpy/srfit/fitbase/profile.py +++ b/src/diffpy/srfit/fitbase/profile.py @@ -146,11 +146,16 @@ def setObservedProfile(self, xobs, yobs, dyobs=None): Numpy array of the observed signal. dyobs Numpy array of the uncertainty in the observed signal. If - dyobs is None (default), it will be set to 1 at each - observed xobs. + `dyobs` is None (default), it will be set to 1 at each + observed `xobs`. - Raises ValueError if len(yobs) != len(xobs) - Raises ValueError if dyobs != None and len(dyobs) != len(xobs) + + Raises + ----------- + ValueError + if len(yobs) != len(xobs) + ValueError + if dyobs != None and len(dyobs) != len(xobs) """ if len(yobs) != len(xobs): raise ValueError("xobs and yobs are different lengths") @@ -195,8 +200,8 @@ def setCalculationRange(self, xmin=None, xmax=None, dx=None): The sample spacing in the independent variable. When different from the data, resample the ``x`` as anchored at ``xmin``. - Note that xmin is always inclusive (unless clipped). xmax is inclusive - if it is within the bounds of the observed data. + Note that ``xmin`` is always inclusive (unless clipped). + ``xmax`` is inclusive if it is within the bounds of the observed data. Raises ------ diff --git a/src/diffpy/srfit/fitbase/profileparser.py b/src/diffpy/srfit/fitbase/profileparser.py index 32cb9669..902c70f6 100644 --- a/src/diffpy/srfit/fitbase/profileparser.py +++ b/src/diffpy/srfit/fitbase/profileparser.py @@ -105,7 +105,9 @@ def parseString(self, patstring): patstring A string containing the pattern - Raises ParseError if the string cannot be parsed + Raises + ---------- + ParseError if the string cannot be parsed """ raise NotImplementedError() @@ -119,8 +121,12 @@ def parseFile(self, filename): filename The name of the file to parse - Raises IOError if the file cannot be read - Raises ParseError if the file cannot be parsed + Raises + ---------- + IOError + if the file cannot be read + ParseError + if the file cannot be parsed """ infile = open(filename, "r") self._banks = [] @@ -153,7 +159,10 @@ def selectBank(self, index): index index of bank (integer, starting at 0). - Raises IndexError if requesting a bank that does not exist + Raises + ---------- + IndexError + if requesting a bank that does not exist """ if index is None: index = self._meta.get("bank", 0) @@ -187,6 +196,8 @@ def getData(self, index=None): index of bank (integer, starting at 0, default None). If index is None then the currently selected bank is used. + Returns + ---------- This returns (x, y, dx, dy) tuple for the bank. dx is 0 if it cannot be determined from the data format. """ diff --git a/src/diffpy/srfit/fitbase/simplerecipe.py b/src/diffpy/srfit/fitbase/simplerecipe.py index 81a0094a..9e7b5b94 100644 --- a/src/diffpy/srfit/fitbase/simplerecipe.py +++ b/src/diffpy/srfit/fitbase/simplerecipe.py @@ -130,9 +130,12 @@ def setObservedProfile(self, xobs, yobs, dyobs=None): dyobs is None (default), it will be set to 1 at each observed xobs. - - Raises ValueError if len(yobs) != len(xobs) - Raises ValueError if dyobs != None and len(dyobs) != len(xobs) + Raises + ---------- + ValueError + if len(yobs) != len(xobs) + ValueError + if dyobs != None and len(dyobs) != len(xobs) """ return self.profile.setObservedProfile(xobs, yobs, dyobs) @@ -146,20 +149,20 @@ def setCalculationRange(self, xmin=None, xmax=None, dx=None): Parameters ---------- - xmin : float or "obs", optional + xmin : float or `obs`, optional The minimum value of the independent variable. Keep the current minimum when not specified. If specified as "obs" reset to the minimum observed value. - xmax : float or "obs", optional + xmax : float or `obs`, optional The maximum value of the independent variable. Keep the current maximum when not specified. If specified as "obs" reset to the maximum observed value. - dx : float or "obs", optional + dx : float or `obs`, optional The sample spacing in the independent variable. When different from the data, resample the ``x`` as anchored at ``xmin``. - Note that xmin is always inclusive (unless clipped). xmax is inclusive - if it is within the bounds of the observed data. + Note that ``xmin`` is always inclusive (unless clipped). + ``xmax`` is inclusive if it is within the bounds of the observed data. Raises ------ diff --git a/src/diffpy/srfit/pdf/pdfcontribution.py b/src/diffpy/srfit/pdf/pdfcontribution.py index bcd694ff..65ee5eda 100644 --- a/src/diffpy/srfit/pdf/pdfcontribution.py +++ b/src/diffpy/srfit/pdf/pdfcontribution.py @@ -142,20 +142,20 @@ def setCalculationRange(self, xmin=None, xmax=None, dx=None): Parameters ---------- - xmin : float or "obs", optional + xmin : float or `obs`, optional The minimum value of the independent variable. Keep the current minimum when not specified. If specified as "obs" reset to the minimum observed value. - xmax : float or "obs", optional + xmax : float or `obs`, optional The maximum value of the independent variable. Keep the current maximum when not specified. If specified as "obs" reset to the maximum observed value. - dx : float or "obs", optional + dx : float or `obs`, optional The sample spacing in the independent variable. When different from the data, resample the ``x`` as anchored at ``xmin``. - Note that xmin is always inclusive (unless clipped). xmax is inclusive - if it is within the bounds of the observed data. + Note that ``xmin`` is always inclusive (unless clipped). + ``xmax`` is inclusive if it is within the bounds of the observed data. Raises ------ diff --git a/src/diffpy/srfit/pdf/pdfparser.py b/src/diffpy/srfit/pdf/pdfparser.py index 445835f7..1f836303 100644 --- a/src/diffpy/srfit/pdf/pdfparser.py +++ b/src/diffpy/srfit/pdf/pdfparser.py @@ -120,7 +120,10 @@ def parseString(self, patstring): patstring A string containing the pattern - Raises ParseError if the string cannot be parsed + Raises + ---------- + ParseError + if the string cannot be parsed """ # useful regex patterns: rx = {"f": r"[-+]?(\d+(\.\d*)?|\d*\.\d+)([eE][-+]?\d+)?"} diff --git a/src/diffpy/srfit/sas/sasparser.py b/src/diffpy/srfit/sas/sasparser.py index 1ddd48b3..50c7f505 100644 --- a/src/diffpy/srfit/sas/sasparser.py +++ b/src/diffpy/srfit/sas/sasparser.py @@ -100,8 +100,12 @@ def parseFile(self, filename): filename The name of the file to parse - Raises IOError if the file cannot be read - Raises ParseError if the file cannot be parsed + Raises + ---------- + IOError + if the file cannot be read + ParseError + if the file cannot be parsed """ import sasdata.dataloader.loader as sas_dataloader @@ -142,7 +146,10 @@ def parseString(self, patstring): patstring A string containing the pattern - Raises ParseError if the string cannot be parsed + Raises + ---------- + ParseError + if the string cannot be parsed """ # This calls on parseFile, as that is how the sas data loader works. import tempfile diff --git a/src/diffpy/srfit/sas/sasprofile.py b/src/diffpy/srfit/sas/sasprofile.py index a5ad4225..a05420ae 100644 --- a/src/diffpy/srfit/sas/sasprofile.py +++ b/src/diffpy/srfit/sas/sasprofile.py @@ -101,16 +101,20 @@ def setObservedProfile(self, xobs, yobs, dyobs=None): Parameters ---------- xobs - Numpy array of the independent variable + Numpy array of the independent variable. yobs Numpy array of the observed signal. dyobs Numpy array of the uncertainty in the observed signal. If - dyobs is None (default), it will be set to 1 at each - observed xobs. - - Raises ValueError if len(yobs) != len(xobs) - Raises ValueError if dyobs != None and len(dyobs) != len(xobs) + ``dyobs`` is ``None`` (default), it will be set to 1 at each + observed ``xobs``. + + Raises + ------ + ValueError + If ``len(yobs) != len(xobs)``. + ValueError + If ``dyobs is not None`` and ``len(dyobs) != len(xobs)``. """ Profile.setObservedProfile(self, xobs, yobs, dyobs) # Copy the arrays to the _datainfo attribute. From a133fb8505e39c17c9340e9666f2583edd6fc1f7 Mon Sep 17 00:00:00 2001 From: stevenhua0320 Date: Sun, 19 Apr 2026 16:41:16 -0400 Subject: [PATCH 6/6] fix: change logic of iterPars method with same type atoms to have same ADPs --- news/iteratepars-behavior.rst | 23 +++++++ src/diffpy/srfit/fitbase/recipeorganizer.py | 54 ++++++++++++--- tests/test_pdf.py | 74 +++++++++++++++++++++ 3 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 news/iteratepars-behavior.rst diff --git a/news/iteratepars-behavior.rst b/news/iteratepars-behavior.rst new file mode 100644 index 00000000..556a91c6 --- /dev/null +++ b/news/iteratepars-behavior.rst @@ -0,0 +1,23 @@ +**Added:** + +* + +**Changed:** + +* Changed `iterPars` method to match all equal-type atoms to have same ADPs + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/srfit/fitbase/recipeorganizer.py b/src/diffpy/srfit/fitbase/recipeorganizer.py index 3b95a216..07df90e7 100644 --- a/src/diffpy/srfit/fitbase/recipeorganizer.py +++ b/src/diffpy/srfit/fitbase/recipeorganizer.py @@ -116,32 +116,70 @@ def _iter_managed(self): """Get iterator over managed objects.""" return chain(*(d.values() for d in self.__managed)) - def iterPars(self, pattern="", recurse=True): + def iterPars(self, pattern="", recurse=True, fullnames=False): """Iterate over the Parameters contained in this object. Parameters ---------- pattern : str - Iterate over parameters with names matching this regular - expression (all parameters by default). + Iterate over parameters with names matching this regular expression + (all parameters by default). + + When `fullnames` is True, the regular expression is matched against + dotted parameter names relative to this object, e.g. ``Ni0.Biso``. recurse : bool Recurse into managed objects when True (default). + fullnames : bool + Match against hierarchical dotted names relative to this object + when True. Match only leaf parameter names when False (default). """ regexp = re.compile(pattern) + if not fullnames: + for par in list(self._parameters.values()): + if regexp.search(par.name): + yield par + if not recurse: + return + managed = self.__managed[:] + managed.remove(self._parameters) + for m in managed: + for obj in m.values(): + if hasattr(obj, "iterPars"): + for par in obj.iterPars(pattern=pattern, recurse=True): + yield par + return + for par in self._iterpars_fullnames( + regexp, recurse=recurse, prefix="" + ): + yield par + + def _iterpars_fullnames(self, regexp, recurse=True, prefix=""): + """Internal helper for iterPars(fullnames=True).""" for par in list(self._parameters.values()): - if regexp.search(par.name): + name = f"{prefix}{par.name}" + if regexp.search(name): yield par + if not recurse: return - # Iterate over objects within the managed dictionaries. + managed = self.__managed[:] managed.remove(self._parameters) for m in managed: for obj in m.values(): - if hasattr(obj, "iterPars"): - for par in obj.iterPars(pattern=pattern): + if hasattr(obj, "_iterpars_fullnames"): + childprefix = f"{prefix}{obj.name}." + for par in obj._iterpars_fullnames( + regexp, + recurse=True, + prefix=childprefix, + ): + yield par + elif hasattr(obj, "iterPars"): + for par in obj.iterPars( + pattern=regexp.pattern, recurse=True + ): yield par - return def __iter__(self): """Iterate over top-level parameters.""" diff --git a/tests/test_pdf.py b/tests/test_pdf.py index dbb9799f..60d094f8 100644 --- a/tests/test_pdf.py +++ b/tests/test_pdf.py @@ -23,6 +23,8 @@ import pytest from diffpy.srfit.exceptions import SrFitError +from diffpy.srfit.fitbase.parameter import Parameter +from diffpy.srfit.fitbase.recipeorganizer import RecipeContainer from diffpy.srfit.pdf import PDFContribution, PDFGenerator, PDFParser # ---------------------------------------------------------------------------- @@ -302,3 +304,75 @@ def test_pickling(diffpy_srreal_available, datafile): if __name__ == "__main__": unittest.main() + + +def _make_iterpars_tree(): + """Build a small hierarchy for iterPars tests.""" + root = RecipeContainer("root") + root._containers = {} + root._manage(root._containers) + + root_biso = Parameter("Biso", 10) + root._add_object(root_biso, root._parameters) + + ni0 = RecipeContainer("Ni0") + ni0_biso = Parameter("Biso", 20) + ni0_uiso = Parameter("Uiso", 30) + ni0._add_object(ni0_biso, ni0._parameters) + ni0._add_object(ni0_uiso, ni0._parameters) + + ni1 = RecipeContainer("Ni1") + ni1_biso = Parameter("Biso", 40) + ni1._add_object(ni1_biso, ni1._parameters) + + o0 = RecipeContainer("O0") + o0_biso = Parameter("Biso", 50) + o0._add_object(o0_biso, o0._parameters) + + root._add_object(ni0, root._containers) + root._add_object(ni1, root._containers) + root._add_object(o0, root._containers) + + return { + "root": root, + "root_biso": root_biso, + "ni0": ni0, + "ni0_biso": ni0_biso, + "ni0_uiso": ni0_uiso, + "ni1": ni1, + "ni1_biso": ni1_biso, + "o0": o0, + "o0_biso": o0_biso, + } + + +@pytest.mark.parametrize( + ("pattern", "kwargs", "expected_values"), + [ + (r"^Biso$", {}, [10, 20, 40, 50]), + (r"^Ni\d+\.Biso$", {}, []), + (r"^Ni\d+\.Biso$", {"fullnames": True}, [20, 40]), + (r"^Ni0\.Uiso$", {"fullnames": True}, [30]), + (r"^O0\.Biso$", {"fullnames": True}, [50]), + (r"^Ni\d+\.Biso$", {"fullnames": True, "recurse": False}, []), + (r"^Biso$", {"fullnames": True, "recurse": False}, [10]), + ], +) +def test_iterpars_fullname_matching(pattern, kwargs, expected_values): + """Verify leaf-name and fullname matching in iterPars.""" + objs = _make_iterpars_tree() + root = objs["root"] + + values = [par.value for par in root.iterPars(pattern, **kwargs)] + + assert values == expected_values + + +def test_iterpars_fullnames_are_relative_to_called_container(): + """Verify fullname matching is relative to the called container.""" + objs = _make_iterpars_tree() + ni0 = objs["ni0"] + + assert list(ni0.iterPars(r"^Biso$", fullnames=True)) == [objs["ni0_biso"]] + assert list(ni0.iterPars(r"^Uiso$", fullnames=True)) == [objs["ni0_uiso"]] + assert list(ni0.iterPars(r"^Ni0\.Biso$", fullnames=True)) == []