diff --git a/.codecov.yml b/.codecov.yml index 4af5eb24..1cdcf2e9 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -5,6 +5,9 @@ coverage: target: auto # use the coverage from the base commit, fail if coverage is lower threshold: 0% # allow the coverage to drop by + ignore: + - "tests/test_speed.py" # ignore performance testing in test coverage. + comment: layout: " diff, flags, files" behavior: default diff --git a/.github/ISSUE_TEMPLATE/release_checklist.md b/.github/ISSUE_TEMPLATE/release_checklist.md index 6107962c..56c5fca3 100644 --- a/.github/ISSUE_TEMPLATE/release_checklist.md +++ b/.github/ISSUE_TEMPLATE/release_checklist.md @@ -11,12 +11,13 @@ assignees: "" - [ ] All PRs/issues attached to the release are merged. - [ ] All the badges on the README are passing. - [ ] License information is verified as correct. If you are unsure, please comment below. -- [ ] Locally rendered documentation contains all appropriate pages, including API references (check no modules are - missing), tutorials, and other human-written text is up-to-date with any changes in the code. +- [ ] Locally rendered documentation contains all appropriate pages, tutorials, and other human-written text is up-to-date with any changes in the code. +- [ ] All API references are included. To check this, run `conda install scikit-package` and then `package build api-doc`. Review any edits made by rerendering the docs locally. - [ ] Installation instructions in the README, documentation, and the website are updated. - [ ] Successfully run any tutorial examples or do functional testing with the latest Python version. - [ ] Grammar and writing quality are checked (no typos). - [ ] Install `pip install build twine`, run `python -m build` and `twine check dist/*` to ensure that the package can be built and is correctly formatted for PyPI release. +- [ ] Dispatch matrix testing to test the release on all Python versions and systems. If you do not have permission to run this workflow, tag the maintainer and say `@maintainer, please dispatch matrix testing workflow`. Please tag the maintainer (e.g., @username) in the comment here when you are ready for the PyPI/GitHub release. Include any additional comments necessary, such as version information and details about the pre-release here: @@ -34,7 +35,7 @@ Please let the maintainer know that all checks are done and the package is ready - [ ] Ensure that the full release has appeared on PyPI successfully. -- [ ] New package dependencies listed in `conda.txt` and `test.txt` are added to `meta.yaml` in the feedstock. +- [ ] New package dependencies listed in `conda.txt` and `tests.txt` are added to `meta.yaml` in the feedstock. - [ ] Close any open issues on the feedstock. Reach out to the maintainer if you have questions. - [ ] Tag the maintainer for conda-forge release. diff --git a/.github/workflows/build-and-publish-docs-on-dispatch.yml b/.github/workflows/build-and-publish-docs-on-dispatch.yml new file mode 100644 index 00000000..3d7394d0 --- /dev/null +++ b/.github/workflows/build-and-publish-docs-on-dispatch.yml @@ -0,0 +1,18 @@ +name: Build and Publish Docs on Dispatch + +on: + workflow_dispatch: + +jobs: + get-python-version: + uses: scikit-package/release-scripts/.github/workflows/_get-python-version-latest.yml@v0 + with: + python_version: 0 + + docs: + uses: scikit-package/release-scripts/.github/workflows/_release-docs.yml@v0 + with: + project: diffpy.srfit + c_extension: false + headless: false + python_version: ${{ fromJSON(needs.get-python-version.outputs.latest_python_version) }} diff --git a/.github/workflows/build-wheel-release-upload.yml b/.github/workflows/build-wheel-release-upload.yml index 4d66f1b5..d51e7a42 100644 --- a/.github/workflows/build-wheel-release-upload.yml +++ b/.github/workflows/build-wheel-release-upload.yml @@ -1,18 +1,75 @@ -name: Release (GitHub/PyPI) and Deploy Docs +name: Build Wheel and Release +# Trigger on tag push or manual dispatch. +# Tag and release privilege are verified inside the reusable workflow. on: workflow_dispatch: push: tags: - - "*" # Trigger on all tags initially, but tag and release privilege are verified in _build-wheel-release-upload.yml + - "*" +# ── Release modality ────────────────────────────────────────────────────────── +# Three options are provided below. Only ONE job should be active at a time. +# To switch: comment out the active job and uncomment your preferred option, +# then commit the change to main before tagging a release. +# ───────────────────────────────────────────────────────────────────────────── jobs: - release: + # Option 1 (default): Release to GitHub, publish to PyPI, and deploy docs. + # + # The wheel is uploaded to PyPI so users can install with `pip install`. + # A GitHub release is created with the changelog as the release body, and + # the Sphinx documentation is rebuilt and deployed to GitHub Pages. + # + # Choose this for open-source packages distributed via PyPI and/or + # conda-forge where broad public availability is the goal. + build-release: uses: scikit-package/release-scripts/.github/workflows/_build-wheel-release-upload.yml@v0 with: project: diffpy.srfit c_extension: false - maintainer_GITHUB_username: sbillinge + maintainer_github_username: cadenmyers13, sbillinge secrets: PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} PAT_TOKEN: ${{ secrets.PAT_TOKEN }} + + # Option 2: Release to GitHub and deploy docs, without publishing to PyPI. + # + # A GitHub release is created and the Sphinx docs are deployed, but the + # wheel is not uploaded to PyPI. The source code remains publicly visible + # on GitHub and can be installed directly from there. + # + # Choose this when the package is public but you prefer to keep it off the + # default pip index — for example, if you distribute via conda-forge only, + # or if the package is not yet ready for a permanent PyPI presence. + # + # To use: comment out Option 1 above and uncomment the lines below. + # build-release-no-pypi: + # uses: scikit-package/release-scripts/.github/workflows/_build-release-github-no-pypi.yml@v0 + # with: + # project: diffpy.srfit + # c_extension: false + # maintainer_github_username: cadenmyers13, sbillinge + # secrets: + # PAT_TOKEN: ${{ secrets.PAT_TOKEN }} + + # Option 3: Release to GitHub with wheel, license, and instructions bundled + # as a downloadable zip attached to the GitHub release asset. + # + # The wheel is built and packaged together with INSTRUCTIONS.txt and the + # LICENSE file into a zip that is attached directly to the GitHub release. + # Users with access to the (private) repo download the zip, follow the + # instructions inside, and install locally with pip. No PyPI or conda-forge + # upload occurs, and no docs are deployed. + # + # Choose this for private or restricted packages where distribution must be + # controlled: only users with repo access can download the release asset, + # making the GitHub release itself the distribution channel. + # + # To use: comment out Option 1 above and uncomment the lines below. + # build-release-private: + # uses: scikit-package/release-scripts/.github/workflows/_build-release-github-private-pure.yml@v0 + # with: + # project: diffpy.srfit + # maintainer_github_username: cadenmyers13, sbillinge + # secrets: + # PAT_TOKEN: ${{ secrets.PAT_TOKEN }} diff --git a/.github/workflows/check-news-item.yml b/.github/workflows/check-news-item.yml index bd352c24..4d641bc2 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 + - 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/.github/workflows/matrix-and-codecov-on-merge-to-main.yml b/.github/workflows/matrix-and-codecov.yml similarity index 84% rename from .github/workflows/matrix-and-codecov-on-merge-to-main.yml rename to .github/workflows/matrix-and-codecov.yml index 2bd09ede..b9a73c69 100644 --- a/.github/workflows/matrix-and-codecov-on-merge-to-main.yml +++ b/.github/workflows/matrix-and-codecov.yml @@ -1,6 +1,9 @@ -name: CI +name: Matrix and Codecov on: + # push: + # branches: + # - main release: types: - prereleased diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 97e70f7e..c424bcf7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -52,7 +52,7 @@ repos: - tomli # prettier - multi formatter for .json, .yml, and .md files - repo: https://github.com/pre-commit/mirrors-prettier - rev: f12edd9c7be1c20cfa42420fd0e6df71e42b51ea # frozen: v4.0.0-alpha.8 + rev: v4.0.0-alpha.8 hooks: - id: prettier additional_dependencies: diff --git a/AUTHORS.rst b/AUTHORS.rst index 66f8baaf..fe6397ad 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -1,7 +1,7 @@ Authors ------- -Christopher Farrow, Pavol Juhas, Simon J. L. Billinge, and members of the Billinge Group +Christopher Farrow, Pavol Juhas, Caden Myers, Simon J. L. Billinge, and members of the Billinge Group Contributors ------------ diff --git a/CODE-OF-CONDUCT.rst b/CODE-OF-CONDUCT.rst index e8199ca5..9352c6ec 100644 --- a/CODE-OF-CONDUCT.rst +++ b/CODE-OF-CONDUCT.rst @@ -67,7 +67,7 @@ Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at -sb2896@columbia.edu. All complaints will be reviewed and investigated promptly and fairly. +cjm2304@columbia.edu and sbillinge@ucsb.edu. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. diff --git a/LICENSE.rst b/LICENSE.rst index acbe6078..6d222385 100644 --- a/LICENSE.rst +++ b/LICENSE.rst @@ -9,7 +9,7 @@ OPEN SOURCE LICENSE AGREEMENT - Copyright (c) 2008-2012, The Trustees of Columbia University in the City of New York - Copyright (c) 2014-2019, Brookhaven Science Associates, Brookhaven National Laboratory - Copyright (c) 2020-2025, The Trustees of Columbia University in the City of New York - +- Copyright (c) 2026-present, The DiffPy Team. The "DiffPy-CMI" is distributed subject to the following license conditions: diff --git a/MANIFEST.in b/MANIFEST.in index b31fb162..c1ccccac 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -11,4 +11,3 @@ global-exclude __pycache__ # Exclude Python cache directories. global-exclude .git* # Exclude git files and directories. global-exclude .idea # Exclude PyCharm project settings. exclude .codecov.yml -exclude .coveragerc diff --git a/README.rst b/README.rst index 2d4040ec..b0474756 100644 --- a/README.rst +++ b/README.rst @@ -25,6 +25,7 @@ :target: https://anaconda.org/conda-forge/diffpy.srfit .. |PR| image:: https://img.shields.io/badge/PR-Welcome-29ab47ff + :target: https://github.com/diffpy/diffpy.srfit/pulls .. |PyPI| image:: https://img.shields.io/pypi/v/diffpy.srfit :target: https://pypi.org/project/diffpy.srfit/ @@ -38,7 +39,7 @@ diffpy.srfit ============ -Configurable code for solving atomic structures. +Generalized code base for modeling problems. The diffpy.srfit package provides the framework for building a global optimizer on the fly from components such as function calculators (that calculate @@ -62,6 +63,23 @@ obtain the total cost function. Additionally, diffpy.srfit is designed to be extensible, allowing the user to integrate external calculators to perform co-refinements with other techniques. + +SrFit has tools for coherently combining known information about a +material to derive other properties, in particular material structure. +SrFit allows the customization and creation of structure +representations, profile calculators, constraints, restraints and file +input parsers. The customized pieces can be glued together within SrFit +to optimize a structure, or other physically relevant information from +one or more experimental profiles. Other known information about the +system of interest can be included with arbitrarily complex constraints +and restraints. In this way, the end user creates a customized fitting +application that suits the problem to the available information. + +The subpackages herein define various pieces of the SrFit framework. +Developers are encouraged to work through the examples described in the +documentation to learn how to use and customize the various parts of +SrFit. + For more information about the diffpy.srfit library, please consult our `online documentation `_. Citation @@ -94,10 +112,6 @@ The following creates and activates a new environment named ``diffpy.srfit_env`` conda create -n diffpy.srfit_env diffpy.srfit conda activate diffpy.srfit_env -To confirm that the installation was successful, type :: - - python -c "import diffpy.srfit; print(diffpy.srfit.__version__)" - The output should print the latest version displayed on the badges above. This will install the minimal `diffpy.srfit` installation. It will often be used @@ -136,6 +150,19 @@ and run the following :: pip install . +This package also provides command-line utilities. To check the software has been installed correctly, type :: + + diffpy.srfit --version + +You can also type the following command to verify the installation. :: + + python -c "import diffpy.srfit; print(diffpy.srfit.__version__)" + + +To view the basic usage and available commands, type :: + + diffpy.srfit -h + Getting Started --------------- @@ -167,12 +194,12 @@ trying to commit again. Improvements and fixes are always appreciated. -Before contributing, please read our `Code of Conduct `_. +Before contributing, please read our `Code of Conduct `_. Contact ------- -For more information on diffpy.srfit please visit the project `web-page `_ or email Simon Billinge at sb2896@columbia.edu. +For more information on diffpy.srfit please visit the project `web-page `_ or email the maintainers ``Caden Myers(cjm2304@columbia.edu) and Simon J. L. Billinge(sbillinge@ucsb.edu)``. Acknowledgements ---------------- diff --git a/cookiecutter.json b/cookiecutter.json new file mode 100644 index 00000000..63ecd00d --- /dev/null +++ b/cookiecutter.json @@ -0,0 +1,20 @@ +{ + "author_names": "Christopher Farrow, Pavol Juhas, Caden Myers, Simon J. L. Billinge", + "author_emails": "farrowch@gmail.com, pavol.juhas@gmail.com, cjm2304@columbia.edu, sbillinge@ucsb.edu", + "maintainer_names": "Caden Myers, Simon J. L. Billinge", + "maintainer_emails": "cjm2304@columbia.edu, sbillinge@ucsb.edu", + "maintainer_github_usernames": "cadenmyers13, sbillinge", + "contributors": "Christopher Farrow, Pavol Juhas, Caden Myers, Simon J. L. Billinge, and members of the DiffPy community.", + "project_name": "diffpy.srfit", + "license_holders": "The DiffPy Team", + "github_username_or_orgname": "diffpy", + "github_repo_name": "diffpy.srfit", + "conda_pypi_package_dist_name": "diffpy.srfit", + "package_dir_name": "diffpy.srfit", + "project_short_description": "Generalized code base for modeling problems.", + "project_keywords": "regression, modeling, fitting, diffraction, PDF", + "minimum_supported_python_version": "3.12", + "maximum_supported_python_version": "3.14", + "project_needs_c_code_compiled": "No", + "project_has_gui_tests": "No" +} diff --git a/docs/examples/coreshellnp.py b/docs/examples/coreshellnp.py index ca18715f..2f4353e9 100644 --- a/docs/examples/coreshellnp.py +++ b/docs/examples/coreshellnp.py @@ -20,7 +20,8 @@ different phases, each with an appropriate characteristic function. """ -import numpy +from pathlib import Path + from pyobjcryst import loadCrystal from scipy.optimize import leastsq @@ -42,9 +43,9 @@ def makeRecipe(stru1, stru2, datname): # Load data and add it to the profile parser = PDFParser() - parser.parseFile(datname) - profile.loadParsedData(parser) - profile.setCalculationRange(xmin=1.5, xmax=45, dx=0.1) + parser.parse_file(datname) + profile.load_parsed_data(parser) + profile.set_calculation_range(xmin=1.5, xmax=45, dx=0.1) # The ProfileGenerator # In order to fit the core and shell phases simultaneously, we must use two @@ -65,100 +66,90 @@ def makeRecipe(stru1, stru2, datname): # The FitContribution # Add both generators and the profile to the FitContribution. contribution = FitContribution("cdszns") - contribution.addProfileGenerator(generator_cds) - contribution.addProfileGenerator(generator_zns) + contribution.add_profile_generator(generator_cds) + contribution.add_profile_generator(generator_zns) contribution.set_profile(profile, xname="r") # Set up the characteristic functions. We use a spherical CF for the core # and a spherical shell CF for the shell. Since this is set up as two # phases, we implicitly assume that the core-shell correlations contribute # very little to the PDF. - from diffpy.srfit.pdf.characteristicfunctions import shellCF, sphericalCF + from diffpy.srfit.pdf.characteristicfunctions import ( + shell_particle, + spherical_particle, + ) - contribution.registerFunction(sphericalCF, name="f_CdS") - contribution.registerFunction(shellCF, name="f_ZnS") + contribution.register_function(spherical_particle, name="f_CdS") + contribution.register_function(shell_particle, name="f_ZnS") # Write the fitting equation. We want to sum the PDFs from each phase and # multiply it by a scaling factor. - contribution.setEquation("scale * (f_CdS * G_CdS + f_ZnS * G_ZnS)") + contribution.set_equation("scale * (f_CdS * G_CdS + f_ZnS * G_ZnS)") # Make the FitRecipe and add the FitContribution. recipe = FitRecipe() - recipe.addContribution(contribution) + recipe.add_contribution(contribution) # Vary the inner radius and thickness of the shell. Constrain the core # diameter to twice the shell radius. - recipe.addVar(contribution.radius, 15) - recipe.addVar(contribution.thickness, 11) - recipe.constrain(contribution.psize, "2 * radius") + recipe.add_variable(contribution.radius, 15) + recipe.add_variable(contribution.thickness, 11) + recipe.add_constraint(contribution.psize, "2 * radius") # Configure the fit variables # Start by configuring the scale factor and resolution factors. # We want the sum of the phase scale factors to be 1. - recipe.newVar("scale_CdS", 0.7) - recipe.constrain(generator_cds.scale, "scale_CdS") - recipe.constrain(generator_zns.scale, "1 - scale_CdS") + recipe.create_new_variable("scale_CdS", 0.7) + recipe.add_constraint(generator_cds.scale, "scale_CdS") + recipe.add_constraint(generator_zns.scale, "1 - scale_CdS") # We also want the resolution factor to be the same on each. # Vary the global scale as well. - recipe.addVar(contribution.scale, 0.3) + recipe.add_variable(contribution.scale, 0.3) # Now we can configure the structural parameters. We tag the different # structural variables so we can easily turn them on and off in the # subsequent refinement. phase_cds = generator_cds.phase for par in phase_cds.sgpars.latpars: - recipe.addVar(par, name=par.name + "_cds", tag="lat") + recipe.add_variable(par, name=par.name + "_cds", tag="lat") for par in phase_cds.sgpars.adppars: - recipe.addVar(par, 1, name=par.name + "_cds", tag="adp") - recipe.addVar(phase_cds.sgpars.xyzpars.z_1, name="z_1_cds", tag="xyz") + recipe.add_variable(par, 1, name=par.name + "_cds", tag="adp") + recipe.add_variable( + phase_cds.sgpars.xyzpars.z_1, name="z_1_cds", tag="xyz" + ) # Since we know these have stacking disorder, constrain the B33 adps for # each atom type. - recipe.constrain("B33_1_cds", "B33_0_cds") - recipe.addVar(generator_cds.delta2, name="delta2_cds", value=5) + recipe.add_constraint("B33_1_cds", "B33_0_cds") + recipe.add_variable(generator_cds.delta2, name="delta2_cds", value=5) phase_zns = generator_zns.phase for par in phase_zns.sgpars.latpars: - recipe.addVar(par, name=par.name + "_zns", tag="lat") + recipe.add_variable(par, name=par.name + "_zns", tag="lat") for par in phase_zns.sgpars.adppars: - recipe.addVar(par, 1, name=par.name + "_zns", tag="adp") - recipe.addVar(phase_zns.sgpars.xyzpars.z_1, name="z_1_zns", tag="xyz") - recipe.constrain("B33_1_zns", "B33_0_zns") - recipe.addVar(generator_zns.delta2, name="delta2_zns", value=2.5) + recipe.add_variable(par, 1, name=par.name + "_zns", tag="adp") + recipe.add_variable( + phase_zns.sgpars.xyzpars.z_1, name="z_1_zns", tag="xyz" + ) + recipe.add_constraint("B33_1_zns", "B33_0_zns") + recipe.add_variable(generator_zns.delta2, name="delta2_zns", value=2.5) # Give the recipe away so it can be used! return recipe -def plotResults(recipe): - """Plot the results contained within a refined FitRecipe.""" - # All this should be pretty familiar by now. - r = recipe.cdszns.profile.x - g = recipe.cdszns.profile.y - gcalc = recipe.cdszns.profile.ycalc - diffzero = -0.8 * max(g) * numpy.ones_like(g) - diff = g - gcalc + diffzero - - import pylab - - pylab.plot(r, g, "bo", label="G(r) Data") - pylab.plot(r, gcalc, "r-", label="G(r) Fit") - pylab.plot(r, diff, "g-", label="G(r) diff") - pylab.plot(r, diffzero, "k-") - pylab.xlabel(r"$r (\AA)$") - pylab.ylabel(r"$G (\AA^{-2})$") - pylab.legend(loc=1) - - pylab.show() - return +plot_styles = { + "xlabel": r"$r (\AA)$", + "ylabel": r"$G (\AA^{-2})$", +} def main(): """Set up and refine the recipe.""" # Make the data and the recipe - cdsciffile = "data/CdS.cif" - znsciffile = "data/ZnS.cif" - data = "data/CdS_ZnS_nano.gr" + cdsciffile = Path(__file__).parent / "data/CdS.cif" + znsciffile = Path(__file__).parent / "data/ZnS.cif" + data = Path(__file__).parent / "data/CdS_ZnS_nano.gr" # Make the recipe stru1 = loadCrystal(cdsciffile) @@ -166,7 +157,7 @@ def main(): recipe = makeRecipe(stru1, stru2, data) from diffpy.srfit.fitbase.fithook import PlotFitHook - recipe.pushFitHook(PlotFitHook()) + recipe.push_fit_hook(PlotFitHook()) recipe.fithooks[0].verbose = 3 # Optimize - we do this in steps to help convergence @@ -199,10 +190,10 @@ def main(): # Generate and print the FitResults res = FitResults(recipe) - res.printResults() + res.print_results() # Plot! - plotResults(recipe) + recipe.plot_recipe(**plot_styles) return diff --git a/docs/examples/crystalpdf.py b/docs/examples/crystalpdf.py index f215668e..c0d26805 100644 --- a/docs/examples/crystalpdf.py +++ b/docs/examples/crystalpdf.py @@ -24,7 +24,8 @@ demonstrates only the basic configuration. """ -import numpy +from pathlib import Path + from gaussianrecipe import scipyOptimize from diffpy.srfit.fitbase import ( @@ -47,38 +48,39 @@ def makeRecipe(ciffile, datname): profile = Profile() # Load data and add it to the Profile. Unlike in other examples, we use a - # class (PDFParser) to help us load the data. This class will read the data - # and relevant metadata from a two- to four-column data file generated + # class (PDFParser) to help us load the data. This class will read the + # data and relevant metadata from a two- to four-column data file generated # with PDFGetX2 or PDFGetN. The metadata will be passed to the PDFGenerator # when they are associated in the FitContribution, which saves some # configuration steps. parser = PDFParser() - parser.parseFile(datname) - profile.loadParsedData(parser) - profile.setCalculationRange(xmax=20) + parser.parse_file(datname) + profile.load_parsed_data(parser) + profile.set_calculation_range(xmax=20) # The ProfileGenerator # The PDFGenerator is for configuring and calculating a PDF profile. Here, # we want to refine a Structure object from diffpy.structure. We tell the # PDFGenerator that with the 'setStructure' method. All other configuration - # options will be inferred from the metadata that is read by the PDFParser. + # options will be inferred from the metadata that is read by the + # PDFParser. # In particular, this will set the scattering type (x-ray or neutron), the # Qmax value, as well as initial values for the non-structural Parameters. generator = PDFGenerator("G") stru = Structure() - stru.read(ciffile) + stru.read(str(ciffile)) generator.setStructure(stru) # The FitContribution # Here we associate the Profile and ProfileGenerator, as has been done # before. contribution = FitContribution("nickel") - contribution.addProfileGenerator(generator) + contribution.add_profile_generator(generator) contribution.set_profile(profile, xname="r") # Make the FitRecipe and add the FitContribution. recipe = FitRecipe() - recipe.addContribution(contribution) + recipe.add_contribution(contribution) # Configure the fit variables @@ -92,16 +94,17 @@ def makeRecipe(ciffile, datname): # We start by constraining the phase to the known space group. We could do # this by hand, but there is a method in diffpy.srfit.structure named - # 'constrainAsSpaceGroup' for this purpose. The constraints will by default - # be applied to the sites, the lattice and to the ADPs. See the method - # documentation for more details. The 'constrainAsSpaceGroup' method may - # create new Parameters, which it returns in a SpaceGroupParameters object. - from diffpy.srfit.structure import constrainAsSpaceGroup + # 'constrain_as_space_group' for this purpose. The constraints will by + # default be applied to the sites, the lattice and to the ADPs. See the + # method documentation for more details. The 'constrain_as_space_group' + # method may create new Parameters, which it returns in a + # SpaceGroupParameters object. + from diffpy.srfit.structure import constrain_as_space_group - sgpars = constrainAsSpaceGroup(phase, "Fm-3m") + sgpars = constrain_as_space_group(phase, "Fm-3m") - # The SpaceGroupParameters object returned by 'constrainAsSpaceGroup' holds - # the free Parameters allowed by the space group constraints. Once a + # The SpaceGroupParameters object returned by 'constrain_as_space_group' + # holds the free Parameters allowed by the space group constraints. Once a # structure is constrained, we need (should) only use the Parameters # provided in the SpaceGroupParameters, as the relevant structure # Parameters are constrained to these. @@ -112,50 +115,33 @@ def makeRecipe(ciffile, datname): # the xyzpars, latpars, and adppars members of the SpaceGroupParameters # object. for par in sgpars.latpars: - recipe.addVar(par) + recipe.add_variable(par) for par in sgpars.adppars: - recipe.addVar(par, 0.005) + recipe.add_variable(par, 0.005) # We now select non-structural parameters to refine. # This controls the scaling of the PDF. - recipe.addVar(generator.scale, 1) + recipe.add_variable(generator.scale, 1) # This is a peak-damping resolution term. - recipe.addVar(generator.qdamp, 0.01) + recipe.add_variable(generator.qdamp, 0.01) # This is a vibrational correlation term that sharpens peaks at low-r. - recipe.addVar(generator.delta2, 5) + recipe.add_variable(generator.delta2, 5) # Give the recipe away so it can be used! return recipe -def plotResults(recipe): - """Plot the results contained within a refined FitRecipe.""" - # All this should be pretty familiar by now. - r = recipe.nickel.profile.x - g = recipe.nickel.profile.y - gcalc = recipe.nickel.profile.ycalc - diffzero = -0.8 * max(g) * numpy.ones_like(g) - diff = g - gcalc + diffzero - - import pylab - - pylab.plot(r, g, "bo", label="G(r) Data") - pylab.plot(r, gcalc, "r-", label="G(r) Fit") - pylab.plot(r, diff, "g-", label="G(r) diff") - pylab.plot(r, diffzero, "k-") - pylab.xlabel(r"$r (\AA)$") - pylab.ylabel(r"$G (\AA^{-2})$") - pylab.legend(loc=1) - - pylab.show() - return +plot_styles = { + "xlabel": r"$r (\AA)$", + "ylabel": r"$G (\AA^{-2})$", +} if __name__ == "__main__": # Make the data and the recipe - ciffile = "data/ni.cif" - data = "data/ni-q27r100-neutron.gr" + ciffile = Path(__file__).parent / "data/ni.cif" + data = Path(__file__).parent / "data/ni-q27r100-neutron.gr" # Make the recipe recipe = makeRecipe(ciffile, data) @@ -166,9 +152,9 @@ def plotResults(recipe): # Generate and print the FitResults res = FitResults(recipe) - res.printResults() + res.print_results() # Plot! - plotResults(recipe) + recipe.plot_recipe(**plot_styles) # End of file diff --git a/docs/examples/crystalpdfall.py b/docs/examples/crystalpdfall.py index 6b368a74..e851b050 100644 --- a/docs/examples/crystalpdfall.py +++ b/docs/examples/crystalpdfall.py @@ -18,7 +18,8 @@ structure to all the available data. """ -import numpy +from pathlib import Path + from gaussianrecipe import scipyOptimize from pyobjcryst import loadCrystal @@ -38,16 +39,16 @@ def makeProfile(datafile): """Make an place data within a Profile.""" profile = Profile() parser = PDFParser() - parser.parseFile(datafile) - profile.loadParsedData(parser) - profile.setCalculationRange(xmax=20) + parser.parse_file(datafile) + profile.load_parsed_data(parser) + profile.set_calculation_range(xmax=20) return profile def makeContribution(name, generator, profile): """Make a FitContribution and add a generator and profile.""" contribution = FitContribution(name) - contribution.addProfileGenerator(generator) + contribution.add_profile_generator(generator) contribution.set_profile(profile, xname="r") return contribution @@ -92,44 +93,44 @@ def makeRecipe( xcontribution_sini = makeContribution( "xsini", xgenerator_sini_ni, xprofile_sini ) - xcontribution_sini.addProfileGenerator(xgenerator_sini_si) - xcontribution_sini.setEquation("scale * (xG_sini_ni + xG_sini_si)") + xcontribution_sini.add_profile_generator(xgenerator_sini_si) + xcontribution_sini.set_equation("scale * (xG_sini_ni + xG_sini_si)") # As explained in another example, we want to minimize using Rw^2. - xcontribution_ni.setResidualEquation("resv") - xcontribution_si.setResidualEquation("resv") - ncontribution_ni.setResidualEquation("resv") - xcontribution_sini.setResidualEquation("resv") + xcontribution_ni.set_residual_equation("resv") + xcontribution_si.set_residual_equation("resv") + ncontribution_ni.set_residual_equation("resv") + xcontribution_sini.set_residual_equation("resv") # Make the FitRecipe and add the FitContributions. recipe = FitRecipe() - recipe.addContribution(xcontribution_ni) - recipe.addContribution(xcontribution_si) - recipe.addContribution(ncontribution_ni) - recipe.addContribution(xcontribution_sini) + recipe.add_contribution(xcontribution_ni) + recipe.add_contribution(xcontribution_si) + recipe.add_contribution(ncontribution_ni) + recipe.add_contribution(xcontribution_sini) # Now we vary and constrain Parameters as before. for par in phase_ni.sgpars: - recipe.addVar(par, name=par.name + "_ni") - delta2_ni = recipe.newVar("delta2_ni", 2.5) - recipe.constrain(xgenerator_ni.delta2, delta2_ni) - recipe.constrain(ngenerator_ni.delta2, delta2_ni) - recipe.constrain(xgenerator_sini_ni.delta2, delta2_ni) + recipe.add_variable(par, name=par.name + "_ni") + delta2_ni = recipe.create_new_variable("delta2_ni", 2.5) + recipe.add_constraint(xgenerator_ni.delta2, delta2_ni) + recipe.add_constraint(ngenerator_ni.delta2, delta2_ni) + recipe.add_constraint(xgenerator_sini_ni.delta2, delta2_ni) for par in phase_si.sgpars: - recipe.addVar(par, name=par.name + "_si") - delta2_si = recipe.newVar("delta2_si", 2.5) - recipe.constrain(xgenerator_si.delta2, delta2_si) - recipe.constrain(xgenerator_sini_si.delta2, delta2_si) + recipe.add_variable(par, name=par.name + "_si") + delta2_si = recipe.create_new_variable("delta2_si", 2.5) + recipe.add_constraint(xgenerator_si.delta2, delta2_si) + recipe.add_constraint(xgenerator_sini_si.delta2, delta2_si) # Now the experimental parameters - recipe.addVar(xgenerator_ni.scale, name="xscale_ni") - recipe.addVar(xgenerator_si.scale, name="xscale_si") - recipe.addVar(ngenerator_ni.scale, name="nscale_ni") - recipe.addVar(xcontribution_sini.scale, 1.0, "xscale_sini") - recipe.newVar("pscale_sini_ni", 0.8) - recipe.constrain(xgenerator_sini_ni.scale, "pscale_sini_ni") - recipe.constrain(xgenerator_sini_si.scale, "1 - pscale_sini_ni") + recipe.add_variable(xgenerator_ni.scale, name="xscale_ni") + recipe.add_variable(xgenerator_si.scale, name="xscale_si") + recipe.add_variable(ngenerator_ni.scale, name="nscale_ni") + recipe.add_variable(xcontribution_sini.scale, 1.0, "xscale_sini") + recipe.create_new_variable("pscale_sini_ni", 0.8) + recipe.add_constraint(xgenerator_sini_ni.scale, "pscale_sini_ni") + recipe.add_constraint(xgenerator_sini_si.scale, "1 - pscale_sini_ni") # The qdamp parameters are too correlated to vary so we fix them based on # previous measurements. @@ -143,82 +144,20 @@ def makeRecipe( return recipe -def plotResults(recipe): - """Plot the results contained within a refined FitRecipe.""" - # All this should be pretty familiar by now. - xnickel = recipe.xnickel - xr_ni = xnickel.profile.x - xg_ni = xnickel.profile.y - xgcalc_ni = xnickel.profile.ycalc - xdiffzero_ni = -0.8 * max(xg_ni) * numpy.ones_like(xg_ni) - xdiff_ni = xg_ni - xgcalc_ni + xdiffzero_ni - - xsilicon = recipe.xsilicon - xr_si = xsilicon.profile.x - xg_si = xsilicon.profile.y - xgcalc_si = xsilicon.profile.ycalc - xdiffzero_si = -0.8 * max(xg_si) * numpy.ones_like(xg_si) - xdiff_si = xg_si - xgcalc_si + xdiffzero_si - - nnickel = recipe.nnickel - nr_ni = nnickel.profile.x - ng_ni = nnickel.profile.y - ngcalc_ni = nnickel.profile.ycalc - ndiffzero_ni = -0.8 * max(ng_ni) * numpy.ones_like(ng_ni) - ndiff_ni = ng_ni - ngcalc_ni + ndiffzero_ni - - xsini = recipe.xsini - xr_sini = xsini.profile.x - xg_sini = xsini.profile.y - xgcalc_sini = xsini.profile.ycalc - xdiffzero_sini = -0.8 * max(xg_sini) * numpy.ones_like(xg_sini) - xdiff_sini = xg_sini - xgcalc_sini + xdiffzero_sini - - import pylab - - pylab.subplot(2, 2, 1) - pylab.plot(xr_ni, xg_ni, "bo", label="G(r) x-ray nickel Data") - pylab.plot(xr_ni, xgcalc_ni, "r-", label="G(r) x-ray nickel Fit") - pylab.plot(xr_ni, xdiff_ni, "g-", label="G(r) x-ray nickel diff") - pylab.plot(xr_ni, xdiffzero_ni, "k-") - pylab.xlabel(r"$r (\AA)$") - pylab.ylabel(r"$G (\AA^{-2})$") - pylab.legend(loc=1) - - pylab.subplot(2, 2, 2) - pylab.plot(xr_si, xg_si, "bo", label="G(r) x-ray silicon Data") - pylab.plot(xr_si, xgcalc_si, "r-", label="G(r) x-ray silicon Fit") - pylab.plot(xr_si, xdiff_si, "g-", label="G(r) x-ray silicon diff") - pylab.plot(xr_si, xdiffzero_si, "k-") - pylab.legend(loc=1) - - pylab.subplot(2, 2, 3) - pylab.plot(nr_ni, ng_ni, "bo", label="G(r) neutron nickel Data") - pylab.plot(nr_ni, ngcalc_ni, "r-", label="G(r) neutron nickel Fit") - pylab.plot(nr_ni, ndiff_ni, "g-", label="G(r) neutron nickel diff") - pylab.plot(nr_ni, ndiffzero_ni, "k-") - pylab.legend(loc=1) - - pylab.subplot(2, 2, 4) - pylab.plot(xr_sini, xg_sini, "bo", label="G(r) x-ray sini Data") - pylab.plot(xr_sini, xgcalc_sini, "r-", label="G(r) x-ray sini Fit") - pylab.plot(xr_sini, xdiff_sini, "g-", label="G(r) x-ray sini diff") - pylab.plot(xr_sini, xdiffzero_sini, "k-") - pylab.legend(loc=1) - - pylab.show() - return - +plot_styles = { + "xlabel": r"$r (\AA)$", + "ylabel": r"$G (\AA^{-2})$", +} if __name__ == "__main__": # Make the data and the recipe - ciffile_ni = "data/ni.cif" - ciffile_si = "data/si.cif" - xdata_ni = "data/ni-q27r60-xray.gr" - ndata_ni = "data/ni-q27r100-neutron.gr" - xdata_si = "data/si-q27r60-xray.gr" - xdata_sini = "data/si90ni10-q27r60-xray.gr" + ciffile_ni = Path(__file__).parent / "data/ni.cif" + ciffile_si = Path(__file__).parent / "data/si.cif" + xdata_ni = Path(__file__).parent / "data/ni-q27r60-xray.gr" + ndata_ni = Path(__file__).parent / "data/ni-q27r100-neutron.gr" + xdata_si = Path(__file__).parent / "data/si-q27r60-xray.gr" + xdata_sini = Path(__file__).parent / "data/si90ni10-q27r60-xray.gr" # Make the recipe recipe = makeRecipe( @@ -230,9 +169,11 @@ def plotResults(recipe): # Generate and print the FitResults res = FitResults(recipe) - res.printResults() + res.print_results() - # Plot! - plotResults(recipe) + # Plot! The recipe has four contributions ("xnickel", "xsilicon", + # "nnickel", "xsini"), so plot_recipe produces one figure per + # contribution. + recipe.plot_recipe(**plot_styles) # End of file diff --git a/docs/examples/crystalpdfobjcryst.py b/docs/examples/crystalpdfobjcryst.py index aa22705a..8db94287 100644 --- a/docs/examples/crystalpdfobjcryst.py +++ b/docs/examples/crystalpdfobjcryst.py @@ -19,7 +19,8 @@ provided by the ObjCrystCrystalParSet structure adapter. """ -from crystalpdf import plotResults +from pathlib import Path + from gaussianrecipe import scipyOptimize from pyobjcryst import loadCrystal @@ -41,14 +42,14 @@ def makeRecipe(ciffile, datname): # This will be used to store the observed and calculated PDF profile. profile = Profile() - # Load data and add it to the Profile. As before we use a PDFParser. The - # metadata is still passed to the PDFGenerator later on. The interaction - # between the PDFGenerator and the metadata does not depend on type of - # structure being refined. + # Load data and add it to the Profile. As before we use a PDFParser. + # The metadata is still passed to the PDFGenerator later on. + # The interaction between the PDFGenerator and the metadata does not + # depend on type of structure being refined. parser = PDFParser() - parser.parseFile(datname) - profile.loadParsedData(parser) - profile.setCalculationRange(xmax=20) + parser.parse_file(datname) + profile.load_parsed_data(parser) + profile.set_calculation_range(xmax=20) # The ProfileGenerator # This time we use the CreateCrystalFromCIF method of pyobjcryst.crystal to @@ -61,12 +62,12 @@ def makeRecipe(ciffile, datname): # The FitContribution contribution = FitContribution("nickel") - contribution.addProfileGenerator(generator) + contribution.add_profile_generator(generator) contribution.set_profile(profile, xname="r") # Make the FitRecipe and add the FitContribution. recipe = FitRecipe() - recipe.addContribution(contribution) + recipe.add_contribution(contribution) # Configure the fit variables @@ -84,33 +85,36 @@ def makeRecipe(ciffile, datname): # constraints get enforced within the ObjCrystCrystalParSet. Free # Parameters are stored within the 'sgpars' member of the # ObjCrystCrystalParSet, which is the same as the object returned from - # 'constrainAsSpaceGroup'. + # 'constrain_as_space_group'. # # As before, we have one free lattice parameter ('a'). We can simplify # things by iterating through all the sgpars. for par in phase.sgpars: - recipe.addVar(par) - # set the initial thermal factor to a non-zero value - assert hasattr(recipe, "B11_0") - recipe.B11_0 = 0.1 + recipe.add_variable(par) # We now select non-structural parameters to refine. # This controls the scaling of the PDF. - recipe.addVar(generator.scale, 1) + recipe.add_variable(generator.scale, 1) # This is a peak-damping resolution term. - recipe.addVar(generator.qdamp, 0.01) + recipe.add_variable(generator.qdamp, 0.01) # This is a vibrational correlation term that sharpens peaks at low-r. - recipe.addVar(generator.delta2, 5) + recipe.add_variable(generator.delta2, 5) # Give the recipe away so it can be used! return recipe +plot_styles = { + "xlabel": r"$r (\AA)$", + "ylabel": r"$G (\AA^{-2})$", +} + + if __name__ == "__main__": # Make the data and the recipe - ciffile = "data/si.cif" - data = "data/si-q27r60-xray.gr" + ciffile = Path(__file__).parent / "data/si.cif" + data = Path(__file__).parent / "data/si-q27r60-xray.gr" # Make the recipe recipe = makeRecipe(ciffile, data) @@ -120,9 +124,9 @@ def makeRecipe(ciffile, datname): # Generate and print the FitResults res = FitResults(recipe) - res.printResults() + res.print_results() # Plot! - plotResults(recipe) + recipe.plot_recipe(**plot_styles) # End of file diff --git a/docs/examples/crystalpdftwodata.py b/docs/examples/crystalpdftwodata.py index 09035d08..cb6b3091 100644 --- a/docs/examples/crystalpdftwodata.py +++ b/docs/examples/crystalpdftwodata.py @@ -20,7 +20,8 @@ underlying ObjCrystCrystalParSet. """ -import numpy +from pathlib import Path + from gaussianrecipe import scipyOptimize from pyobjcryst import loadCrystal @@ -46,14 +47,14 @@ def makeRecipe(ciffile, xdatname, ndatname): # Load data and add it to the proper Profile. parser = PDFParser() - parser.parseFile(xdatname) - xprofile.loadParsedData(parser) - xprofile.setCalculationRange(xmax=20) + parser.parse_file(xdatname) + xprofile.load_parsed_data(parser) + xprofile.set_calculation_range(xmax=20) parser = PDFParser() - parser.parseFile(ndatname) - nprofile.loadParsedData(parser) - nprofile.setCalculationRange(xmax=20) + parser.parse_file(ndatname) + nprofile.load_parsed_data(parser) + nprofile.set_calculation_range(xmax=20) # The ProfileGenerators # We need one of these for the x-ray data. @@ -83,11 +84,11 @@ def makeRecipe(ciffile, xdatname, ndatname): # The FitContributions # We associate the x-ray PDFGenerator and Profile in one FitContribution... xcontribution = FitContribution("xnickel") - xcontribution.addProfileGenerator(xgenerator) + xcontribution.add_profile_generator(xgenerator) xcontribution.set_profile(xprofile, xname="r") # and the neutron objects in another. ncontribution = FitContribution("nnickel") - ncontribution.addProfileGenerator(ngenerator) + ncontribution.add_profile_generator(ngenerator) ncontribution.set_profile(nprofile, xname="r") # This example is different than the previous ones in that we are composing @@ -104,79 +105,48 @@ def makeRecipe(ciffile, xdatname, ndatname): # The contribution's residual can be either chi^2, Rw^2, or custom crafted. # In this case, we should minimize Rw^2 of each contribution so that each # one can contribute roughly equally to the fit. - xcontribution.setResidualEquation("resv") - ncontribution.setResidualEquation("resv") + xcontribution.set_residual_equation("resv") + ncontribution.set_residual_equation("resv") # Make the FitRecipe and add the FitContributions. recipe = FitRecipe() - recipe.addContribution(xcontribution) - recipe.addContribution(ncontribution) + recipe.add_contribution(xcontribution) + recipe.add_contribution(ncontribution) # Now we vary and constrain Parameters as before. - recipe.addVar(xgenerator.scale, 1, "xscale") - recipe.addVar(ngenerator.scale, 1, "nscale") - recipe.addVar(xgenerator.qdamp, 0.01, "xqdamp") - recipe.addVar(ngenerator.qdamp, 0.01, "nqdamp") + recipe.add_variable(xgenerator.scale, 1, "xscale") + recipe.add_variable(ngenerator.scale, 1, "nscale") + recipe.add_variable(xgenerator.qdamp, 0.01, "xqdamp") + recipe.add_variable(ngenerator.qdamp, 0.01, "nqdamp") # delta2 is a non-structual material property. Thus, we constrain together # delta2 Parameter from each PDFGenerator. - delta2 = recipe.newVar("delta2", 2) - recipe.constrain(xgenerator.delta2, delta2) - recipe.constrain(ngenerator.delta2, delta2) + delta2 = recipe.create_new_variable("delta2", 2) + recipe.add_constraint(xgenerator.delta2, delta2) + recipe.add_constraint(ngenerator.delta2, delta2) # We only need to constrain phase properties once since there is a single # ObjCrystCrystalParSet for the Crystal. phase = xgenerator.phase for par in phase.sgpars: - recipe.addVar(par) + recipe.add_variable(par) recipe.B11_0 = 0.1 # Give the recipe away so it can be used! return recipe -def plotResults(recipe): - """Plot the results contained within a refined FitRecipe.""" - # All this should be pretty familiar by now. - xr = recipe.xnickel.profile.x - xg = recipe.xnickel.profile.y - xgcalc = recipe.xnickel.profile.ycalc - xdiffzero = -0.8 * max(xg) * numpy.ones_like(xg) - xdiff = xg - xgcalc + xdiffzero - - nr = recipe.nnickel.profile.x - ng = recipe.nnickel.profile.y - ngcalc = recipe.nnickel.profile.ycalc - ndiffzero = -0.8 * max(ng) * numpy.ones_like(ng) - ndiff = ng - ngcalc + ndiffzero - - import pylab - - pylab.subplot(2, 1, 1) - pylab.plot(xr, xg, "bo", label="G(r) x-ray Data") - pylab.plot(xr, xgcalc, "r-", label="G(r) x-ray Fit") - pylab.plot(xr, xdiff, "g-", label="G(r) x-ray diff") - pylab.plot(xr, xdiffzero, "k-") - pylab.legend(loc=1) - - pylab.subplot(2, 1, 2) - pylab.plot(nr, ng, "bo", label="G(r) neutron Data") - pylab.plot(nr, ngcalc, "r-", label="G(r) neutron Fit") - pylab.plot(nr, ndiff, "g-", label="G(r) neutron diff") - pylab.plot(nr, ndiffzero, "k-") - pylab.xlabel(r"$r (\AA)$") - pylab.ylabel(r"$G (\AA^{-2})$") - pylab.legend(loc=1) - - pylab.show() - return +plot_styles = { + "xlabel": r"$r (\AA)$", + "ylabel": r"$G (\AA^{-2})$", +} if __name__ == "__main__": # Make the data and the recipe - ciffile = "data/ni.cif" - xdata = "data/ni-q27r60nodg-xray.gr" - ndata = "data/ni-q27r100-neutron.gr" + ciffile = Path(__file__).parent / "data/ni.cif" + xdata = Path(__file__).parent / "data/ni-q27r60nodg-xray.gr" + ndata = Path(__file__).parent / "data/ni-q27r100-neutron.gr" # Make the recipe recipe = makeRecipe(ciffile, xdata, ndata) @@ -186,9 +156,11 @@ def plotResults(recipe): # Generate and print the FitResults res = FitResults(recipe) - res.printResults() + res.print_results() - # Plot! - plotResults(recipe) + # Plot! The recipe has two contributions ("xnickel" for x-ray, + # "nnickel" for neutron), so plot_recipe produces one figure per + # contribution. + recipe.plot_recipe(**plot_styles) # End of file diff --git a/docs/examples/crystalpdftwophase.py b/docs/examples/crystalpdftwophase.py index 0a5e3f8a..af6d1df7 100644 --- a/docs/examples/crystalpdftwophase.py +++ b/docs/examples/crystalpdftwophase.py @@ -20,7 +20,8 @@ nickel and silicon to find the structures and phase fractions. """ -import numpy +from pathlib import Path + from gaussianrecipe import scipyOptimize from pyobjcryst import loadCrystal @@ -43,9 +44,9 @@ def makeRecipe(niciffile, siciffile, datname): # Load data and add it to the profile parser = PDFParser() - parser.parseFile(datname) - profile.loadParsedData(parser) - profile.setCalculationRange(xmax=20) + parser.parse_file(datname) + profile.load_parsed_data(parser) + profile.set_calculation_range(xmax=20) # The ProfileGenerator # In order to fit two phases simultaneously, we must use two PDFGenerators. @@ -71,33 +72,33 @@ def makeRecipe(niciffile, siciffile, datname): # Add both generators to the FitContribution. Add the Profile. This will # send the metadata to the generators. contribution = FitContribution("nisi") - contribution.addProfileGenerator(generator_ni) - contribution.addProfileGenerator(generator_si) + contribution.add_profile_generator(generator_ni) + contribution.add_profile_generator(generator_si) contribution.set_profile(profile, xname="r") # Write the fitting equation. We want to sum the PDFs from each phase and # multiply it by a scaling factor. We also want a certain phase scaling # relationship between the PDFs which we will enforce with constraints in # the FitRecipe. - contribution.setEquation("scale * (G_ni + G_si)") + contribution.set_equation("scale * (G_ni + G_si)") # Make the FitRecipe and add the FitContribution. recipe = FitRecipe() - recipe.addContribution(contribution) + recipe.add_contribution(contribution) # Configure the fit variables # Start by configuring the scale factor and resolution factors. # We want the sum of the phase scale factors to be 1. - recipe.newVar("scale_ni", 0.1) - recipe.constrain(generator_ni.scale, "scale_ni") - recipe.constrain(generator_si.scale, "1 - scale_ni") + recipe.create_new_variable("scale_ni", 0.1) + recipe.add_constraint(generator_ni.scale, "scale_ni") + recipe.add_constraint(generator_si.scale, "1 - scale_ni") # We also want the resolution factor to be the same on each. - recipe.newVar("qdamp", 0.03) - recipe.constrain(generator_ni.qdamp, "qdamp") - recipe.constrain(generator_si.qdamp, "qdamp") + recipe.create_new_variable("qdamp", 0.03) + recipe.add_constraint(generator_ni.qdamp, "qdamp") + recipe.add_constraint(generator_si.qdamp, "qdamp") # Vary the global scale as well. - recipe.addVar(contribution.scale, 1) + recipe.add_variable(contribution.scale, 1) # Now we can configure the structural parameters. Since we're using # ObjCrystCrystalParSets, the space group constraints are automatically @@ -106,13 +107,13 @@ def makeRecipe(niciffile, siciffile, datname): # First the nickel parameters phase_ni = generator_ni.phase for par in phase_ni.sgpars: - recipe.addVar(par, name=par.name + "_ni") - recipe.addVar(generator_ni.delta2, name="delta2_ni") + recipe.add_variable(par, name=par.name + "_ni") + recipe.add_variable(generator_ni.delta2, name="delta2_ni") # Next the silicon parameters phase_si = generator_si.phase for par in phase_si.sgpars: - recipe.addVar(par, name=par.name + "_si") - recipe.addVar(generator_si.delta2, name="delta2_si") + recipe.add_variable(par, name=par.name + "_si") + recipe.add_variable(generator_si.delta2, name="delta2_si") # We have prior information from the earlier examples so we'll use it here # in the form of restraints. @@ -122,52 +123,47 @@ def makeRecipe(niciffile, siciffile, datname): # derived has no uncertainty. Thus, we will tell the recipe to scale the # residual, which means that it will be weighted as much as the average # data point during the fit. - recipe.restrain("a_ni", lb=3.527, ub=3.527, scaled=True) + recipe.add_soft_bounds( + "a_ni", lower_bound=3.527, upper_bound=3.527, scaled=True + ) # Now we do the same with the delta2 and Biso parameters (remember that # Biso = 8*pi**2*Uiso) - recipe.restrain("delta2_ni", lb=2.22, ub=2.22, scaled=True) - recipe.restrain("Biso_0_ni", lb=0.454, ub=0.454, scaled=True) + recipe.add_soft_bounds( + "delta2_ni", lower_bound=2.22, upper_bound=2.22, scaled=True + ) + recipe.add_soft_bounds( + "Biso_0_ni", lower_bound=0.454, upper_bound=0.454, scaled=True + ) # # We can do the same with the silicon values. We haven't done a thorough # job of measuring the uncertainties in the results, so we'll scale these # as well. - recipe.restrain("a_si", lb=5.430, ub=5.430, scaled=True) - recipe.restrain("delta2_si", lb=3.54, ub=3.54, scaled=True) - recipe.restrain("Biso_0_si", lb=0.645, ub=0.645, scaled=True) + recipe.add_soft_bounds( + "a_si", lower_bound=5.430, upper_bound=5.430, scaled=True + ) + recipe.add_soft_bounds( + "delta2_si", lower_bound=3.54, upper_bound=3.54, scaled=True + ) + recipe.add_soft_bounds( + "Biso_0_si", lower_bound=0.645, upper_bound=0.645, scaled=True + ) # Give the recipe away so it can be used! return recipe -def plotResults(recipe): - """Plot the results contained within a refined FitRecipe.""" - # All this should be pretty familiar by now. - r = recipe.nisi.profile.x - g = recipe.nisi.profile.y - gcalc = recipe.nisi.profile.ycalc - diffzero = -0.8 * max(g) * numpy.ones_like(g) - diff = g - gcalc + diffzero - - import pylab - - pylab.plot(r, g, "bo", label="G(r) Data") - pylab.plot(r, gcalc, "r-", label="G(r) Fit") - pylab.plot(r, diff, "g-", label="G(r) diff") - pylab.plot(r, diffzero, "k-") - pylab.xlabel(r"$r (\AA)$") - pylab.ylabel(r"$G (\AA^{-2})$") - pylab.legend(loc=1) - - pylab.show() - return +plot_styles = { + "xlabel": r"$r (\AA)$", + "ylabel": r"$G (\AA^{-2})$", +} if __name__ == "__main__": # Make the data and the recipe - niciffile = "data/ni.cif" - siciffile = "data/si.cif" - data = "data/si90ni10-q27r60-xray.gr" + niciffile = Path(__file__).parent / "data/ni.cif" + siciffile = Path(__file__).parent / "data/si.cif" + data = Path(__file__).parent / "data/si90ni10-q27r60-xray.gr" # Make the recipe recipe = makeRecipe(niciffile, siciffile, data) @@ -177,9 +173,9 @@ def plotResults(recipe): # Generate and print the FitResults res = FitResults(recipe) - res.printResults() + res.print_results() # Plot! - plotResults(recipe) + recipe.plot_recipe(**plot_styles) # End of file diff --git a/docs/examples/debyemodel.py b/docs/examples/debyemodel.py index c7196f5f..1529d848 100644 --- a/docs/examples/debyemodel.py +++ b/docs/examples/debyemodel.py @@ -12,7 +12,8 @@ # See LICENSE_DANSE.txt for license information. # ######################################################################## -"""Example of fitting the Debye model to experimental Debye-Waller factors. +"""Example of fitting the Debye model to experimental Debye-Waller +factors. In this example, we build a fit recipe that uses an external function that can simulate a atomic displacement parameters using the Debye model. This serves as @@ -92,7 +93,7 @@ def makeRecipe(): # data into the profile. xydy = numpy.array(data.split(), dtype=float).reshape(-1, 3) x, y, dy = xydy.T - profile.setObservedProfile(x, y, dy) + profile.set_observed_profile(x, y, dy) # The FitContribution # The FitContribution associates the profile with the Debye function. @@ -104,14 +105,14 @@ def makeRecipe(): contribution.set_profile(profile, xname="T") # We now need to create the fitting equation. We tell the FitContribution - # to use the 'debye' function defined below. The 'registerFunction' method + # to use the 'debye' function defined below. The 'register_function' method # will let us do this. Since we haven't told it otherwise, - # 'registerFunction' will extract the name of the function ('debye') and + # 'register_function' will extract the name of the function ('debye') and # the names of the arguments ('T', 'm', 'thetaD'). These arguments will # become Parameters of the FitContribution. Since we named the x-variable # 'T' above, the 'T' in the 'debye' equation will refer to this x-variable # whenever it is used. - contribution.registerFunction(debye) + contribution.register_function(debye) # Now we can create the fitting equation. We want to extend the 'debye' # equation by adding a vertical offset. We could wrap 'debye' in a new @@ -125,7 +126,7 @@ def makeRecipe(): # the debye equation to be positive, so we specify the input as abs(thetaD) # in the equation below. Furthermore, we know 'm', the mass of lead, so we # can specify that as well. - contribution.setEquation("debye(T, 207.2, abs(thetaD)) + offset") + contribution.set_equation("debye(T, 207.2, abs(thetaD)) + offset") # The FitRecipe # The FitRecipe lets us define what we want to fit. It is where we can @@ -133,14 +134,14 @@ def makeRecipe(): # to fit simultaneously, the contribution from each could be added to the # recipe. recipe = FitRecipe() - recipe.addContribution(contribution) + recipe.add_contribution(contribution) # Specify which Parameters we want to refine. # Vary the offset - recipe.addVar(contribution.offset, 0) + recipe.add_variable(contribution.offset, 0) # We also vary the Debye temperature. - recipe.addVar(contribution.thetaD, 100) + recipe.add_variable(contribution.thetaD, 100) # We would like to 'suggest' that the offset should remain positive. This # is somethine that we know about the system that might help the refinement @@ -150,31 +151,25 @@ def makeRecipe(): # breaking the restraint by the point-average chi^2 value so that the # restraint is roughly as significant as any other data point throughout # the fit. - recipe.restrain(recipe.offset, lb=0, scaled=True) + recipe.add_soft_bounds(recipe.offset, lower_bound=0, scaled=True) # We're done setting up the recipe. We can now do other things with it. return recipe -def plotResults(recipe): +def plot_results(recipe): """Plot the results contained within a refined FitRecipe.""" - # Plot this. # Note that since the contribution was given the name "pb", it is # accessible from the recipe with this name. This is a useful way to # organize multiple contributions to a fit. - T = recipe.pb.profile.x - U = recipe.pb.profile.y - Ucalc = recipe.pb.profile.ycalc - - import pylab - - pylab.plot(T, U, "o", label="Pb $U_{iso}$ Data") - pylab.plot(T, Ucalc) - pylab.xlabel("T (K)") - pylab.ylabel(r"$U_{iso} (\AA^2)$") - pylab.legend(loc=(0.0, 0.8)) - - pylab.show() + recipe.plot_recipe( + show_diff=False, + data_label=r"Pb $U_{iso}$ Data", + fit_label="Calculated", + xlabel="T (K)", + ylabel=r"$U_{iso} (\AA^2)$", + legend_loc=(0.0, 0.8), + ) return @@ -190,10 +185,10 @@ def main(): res = FitResults(recipe) # Print the results - res.printResults() + res.print_results() # Plot the results - plotResults(recipe) + plot_results(recipe) return @@ -204,7 +199,8 @@ def main(): def debye(T, m, thetaD): - """A wrapped version of 'adps' that can handle an array of T-values.""" + """A wrapped version of 'adps' that can handle an array of + T-values.""" y = numpy.array([adps(m, thetaD, x) for x in T]) return y diff --git a/docs/examples/debyemodelII.py b/docs/examples/debyemodelII.py index 4f769a86..c75d9cf7 100644 --- a/docs/examples/debyemodelII.py +++ b/docs/examples/debyemodelII.py @@ -12,7 +12,8 @@ # See LICENSE_DANSE.txt for license information. # ######################################################################## -"""Example of fitting the Debye recipe to experimental Debye-Waller factors. +"""Example of fitting the Debye recipe to experimental Debye-Waller +factors. This is an extension of example in debyemodel.py. The recipe we create will simultaneously fit the low and high temperature parts of the experimental data @@ -70,65 +71,58 @@ def makeRecipeII(): # Now create a fresh FitRecipe to work with and add to it the two # FitContributions. recipe = FitRecipe() - recipe.addContribution(lowT) - recipe.addContribution(highT) + recipe.add_contribution(lowT) + recipe.add_contribution(highT) # Change the fit ranges of the Profiles embedded within the # FitContributions. We want to fit one of the contributions at low # temperature, and one at high. - lowT.profile.setCalculationRange(0, 150) - highT.profile.setCalculationRange(400, 500) + lowT.profile.set_calculation_range(0, 150) + highT.profile.set_calculation_range(400, 500) # Vary the offset from each FitContribution separately, while keeping the # Debye temperatures the same. We give each offset variable a different # name in the recipe so it retains its identity. - recipe.addVar(recipe.lowT.offset, name="lowToffset") - recipe.addVar(recipe.highT.offset, name="highToffset") + recipe.add_variable(recipe.lowT.offset, name="lowToffset") + recipe.add_variable(recipe.highT.offset, name="highToffset") # We create a new Variable and use the recipe's "constrain" method to # associate the Debye temperature parameters with that variable. - recipe.newVar("thetaD", 100) - recipe.constrain(recipe.lowT.thetaD, "thetaD") - recipe.constrain(recipe.highT.thetaD, "thetaD") + recipe.create_new_variable("thetaD", 100) + recipe.add_constraint(recipe.lowT.thetaD, "thetaD") + recipe.add_constraint(recipe.highT.thetaD, "thetaD") return recipe -def plotResults(recipe): +def plot_results(recipe): """Display the results contained within a refined FitRecipe.""" # The variable values are returned in the order in which the variables were # added to the FitRecipe. - lowToffset, highToffset, thetaD = recipe.getValues() + lowToffset, highToffset, thetaD = recipe.get_values() + print( + r"lowT: $T_d$=%3.1f K, offset=%1.5f $\AA^2$" + % (abs(thetaD), lowToffset) + ) + print( + r"highT: $T_d$=%3.1f K, offset=%1.5f $\AA^2$" + % (abs(thetaD), highToffset) + ) # We want to extend the fitting range to its full extent so we can get a - # nice full plot. - recipe.lowT.profile.setCalculationRange(xmin="obs", xmax="obs") - recipe.highT.profile.setCalculationRange(xmin="obs", xmax="obs") - T = recipe.lowT.profile.x - U = recipe.lowT.profile.y - # We can use a FitContribution's 'evaluateEquation' method to evaluate - # expressions involving the Parameters and other aspects of the - # FitContribution. Here we evaluate the fitting equation, which is always - # accessed using the name "eq". We access it this way (rather than through - # the Profile's ycalc attribute) because we changed the calculation range - # above, and we therefore need to recalculate the profile. - lowU = recipe.lowT.evaluateEquation("eq") - highU = recipe.highT.evaluateEquation("eq") - - # Now we can plot this. - import pylab - - pylab.plot(T, U, "o", label="Pb $U_{iso}$ Data") - lbl1 = r"$T_d$=%3.1f K, lowToff=%1.5f $\AA^2$" % (abs(thetaD), lowToffset) - lbl2 = r"$T_d$=%3.1f K, highToff=%1.5f $\AA^2$" % ( - abs(thetaD), - highToffset, + # nice full plot. Since the calculated profile is only valid for the + # calculation range that was used during the fit, we need to trigger a + # recalculation over the widened range before plotting. + recipe.lowT.profile.set_calculation_range(xmin="obs", xmax="obs") + recipe.highT.profile.set_calculation_range(xmin="obs", xmax="obs") + recipe.residual() + + recipe.plot_recipe( + show_diff=False, + data_label=r"Pb $U_{iso}$ Data", + fit_label="Calculated", + xlabel="T (K)", + ylabel=r"$U_{iso} (\AA^2)$", + legend_loc=(0.0, 0.8), ) - pylab.plot(T, lowU, label=lbl1) - pylab.plot(T, highU, label=lbl2) - pylab.xlabel("T (K)") - pylab.ylabel(r"$U_{iso} (\AA^2)$") - pylab.legend(loc=(0.0, 0.8)) - - pylab.show() return @@ -144,10 +138,10 @@ def main(): res = FitResults(recipe) # Print the results - res.printResults() + res.print_results() # Plot the results - plotResults(recipe) + plot_results(recipe) return diff --git a/docs/examples/ellipsoidsas.py b/docs/examples/ellipsoidsas.py index 70fb572a..ad9a2c86 100644 --- a/docs/examples/ellipsoidsas.py +++ b/docs/examples/ellipsoidsas.py @@ -14,6 +14,9 @@ ######################################################################## """Example of a refinement of SAS I(Q) data to an ellipsoidal model.""" +from pathlib import Path + +import matplotlib.pyplot as plt from gaussianrecipe import scipyOptimize from diffpy.srfit.fitbase import ( @@ -37,8 +40,8 @@ def makeRecipe(datname): # Load data and add it to the Profile. We use a SASParser to load the data # properly and pass the metadata along. parser = SASParser() - parser.parseFile(datname) - profile.loadParsedData(parser) + parser.parse_file(datname) + profile.load_parsed_data(parser) # The ProfileGenerator # The SASGenerator is for configuring and calculating a SAS profile. We use @@ -56,18 +59,18 @@ def makeRecipe(datname): # Here we associate the Profile and ProfileGenerator, as has been done # before. contribution = FitContribution("ellipsoid") - contribution.addProfileGenerator(generator) + contribution.add_profile_generator(generator) contribution.set_profile(profile, xname="q") # We want to fit the log of the signal to the log of the data so that the # higher-Q information remains significant. There are no I(Q) uncertainty # values with the data, so we do not need to worry about the effect this # will have on the estimated parameter uncertainties. - contribution.setResidualEquation("log(eq) - log(y)") + contribution.set_residual_equation("log(eq) - log(y)") # Make the FitRecipe and add the FitContribution. recipe = FitRecipe() - recipe.addContribution(contribution) + recipe.add_contribution(contribution) # Configure the fit variables # The SASGenerator uses the parameters from the params and dispersion @@ -77,40 +80,44 @@ def makeRecipe(datname): # SASGenerator these are named like "radius_width". # # We want to fit the scale factor, radii and background factors. - recipe.addVar(generator.scale, 1) - recipe.addVar(generator.radius_a, 50) - recipe.addVar(generator.radius_b, 500) - recipe.addVar(generator.background, 0) + recipe.add_variable(generator.scale, 1) + recipe.add_variable(generator.radius_a, 50) + recipe.add_variable(generator.radius_b, 500) + recipe.add_variable(generator.background, 0) # Give the recipe away so it can be used! return recipe -def plotResults(recipe): +def plot_results(recipe): """Plot the results contained within a refined FitRecipe.""" - # All this should be pretty familiar by now. - r = recipe.ellipsoid.profile.x - y = recipe.ellipsoid.profile.y - ycalc = recipe.ellipsoid.profile.ycalc - diff = y - ycalc + min(y) - - import pylab - - pylab.loglog(r, y, "bo", label="I(Q) Data") - pylab.loglog(r, ycalc, "r-", label="I(Q) Fit") - pylab.loglog(r, diff, "g-", label="I(Q) diff") - pylab.xlabel(r"$Q (\AA^{-1})$") - pylab.ylabel("$I (arb. units)$") - pylab.legend(loc=1) - - pylab.show() + # I(Q) SAS data is best viewed on a log-log scale, so we prepare the + # axes ourselves and hand them to plot_recipe. The residual difference + # curve is not shown since it can go negative and is not meaningful on + # a log scale. + fig = plt.figure() + ax = fig.add_subplot(111) + ax.set_xscale("log") + ax.set_yscale("log") + recipe.plot_recipe( + ax=ax, + show=False, + show_diff=False, + data_color="b", + fit_color="r", + data_label="I(Q) Data", + fit_label="I(Q) Fit", + xlabel=r"$Q (\AA^{-1})$", + ylabel="$I (arb. units)$", + ) + plt.show() return if __name__ == "__main__": # Make the data and the recipe - data = "data/sas_ellipsoid_testdata.txt" + data = Path(__file__).parent / "data/sas_ellipsoid_testdata.txt" # Make the recipe recipe = makeRecipe(data) @@ -120,9 +127,9 @@ def plotResults(recipe): # Generate and print the FitResults res = FitResults(recipe) - res.printResults() + res.print_results() # Plot! - plotResults(recipe) + plot_results(recipe) # End of file diff --git a/docs/examples/gaussiangenerator.py b/docs/examples/gaussiangenerator.py index c83f4734..b7d3e007 100644 --- a/docs/examples/gaussiangenerator.py +++ b/docs/examples/gaussiangenerator.py @@ -33,11 +33,14 @@ Extensions -- Remove the amplitude from GaussianGenerator and instead use the 'setEquation' - method of the FitContribution to account for it. Note that the +- Remove the amplitude from GaussianGenerator and instead use the + 'set_equation' method of the + FitContribution to account for it. Note that the GaussianGenerator will be accessible by its name, "g". """ +from pathlib import Path + from numpy import exp from diffpy.srfit.fitbase import ( @@ -130,7 +133,7 @@ def makeRecipe(): # Load data and add it to the profile. This uses the loadtxt function from # numpy. - profile.loadtxt("data/gaussian.dat") + profile.loadtxt(Path(__file__).parent / "data/gaussian.dat") # The ProfileGenerator # Create a GaussianGenerator named "g". This will be the name we use to @@ -143,13 +146,13 @@ def makeRecipe(): # attribute of the FitContribution by its name ("g"). Note that this will # set the fitting equation to "g", which calls the GaussianGenerator. contribution = FitContribution("g1") - contribution.addProfileGenerator(generator) + contribution.add_profile_generator(generator) contribution.set_profile(profile) # The FitRecipe # Now we create the FitRecipe and add the FitContribution. recipe = FitRecipe() - recipe.addContribution(contribution) + recipe.add_contribution(contribution) # Specify which Parameters we want to vary in the fit. This will add # Variables to the FitRecipe that directly modify the Parameters of the @@ -159,9 +162,9 @@ def makeRecipe(): # that the Parameters belong to the GaussianGenerator, not the # FitContribution as in gaussianrecipe.py. We initialize parameters as in # gaussianrecipe.py so we can expect the same output. - recipe.addVar(generator.A, 1) - recipe.addVar(generator.x0, 5) - recipe.addVar(generator.sigma, name="sig") + recipe.add_variable(generator.A, 1) + recipe.add_variable(generator.x0, 5) + recipe.add_variable(generator.sigma, name="sig") recipe.sig.value = 1 # Give the recipe away so it can be used! diff --git a/docs/examples/gaussianrecipe.py b/docs/examples/gaussianrecipe.py index 46abe9bf..61f00384 100644 --- a/docs/examples/gaussianrecipe.py +++ b/docs/examples/gaussianrecipe.py @@ -26,8 +26,8 @@ to get an understanding of how a fit recipe can be used once created. After that, read the 'makeRecipe' code to see what goes into a fit recipe. After that, read the 'scipyOptimize' code to see how the refinement is executed. -Finally, read the 'plotResults' code to see how to extracts the refined profile -and plot it. +Finally, look at the 'plot_styles' dict and the 'recipe.plot_recipe' call +to see how the refined profile is plotted. Extensions @@ -45,6 +45,8 @@ from __future__ import print_function +from pathlib import Path + from diffpy.srfit.fitbase import ( FitContribution, FitRecipe, @@ -72,10 +74,10 @@ def main(): res = FitResults(recipe) # Print the results. - res.printResults() + res.print_results() # Plot the results. - plotResults(recipe) + recipe.plot_recipe(**plot_styles) return @@ -100,7 +102,7 @@ def makeRecipe(): # Load data and add it to the profile. This uses the loadtxt function from # numpy. - profile.loadtxt("data/gaussian.dat") + profile.loadtxt(Path(__file__).parent / "data/gaussian.dat") # The FitContribution # The FitContribution associates the Profile with a fitting equation. The @@ -122,7 +124,7 @@ def makeRecipe(): # contribution by name. Since we told the contribution that our # independent variable is named "x", this value will be substituted into # the fitting equation whenever it is called. - contribution.setEquation("A * exp(-0.5*(x-x0)**2/sigma**2)") + contribution.set_equation("A * exp(-0.5*(x-x0)**2/sigma**2)") # To demonstrate how these parameters are used, we will give "A" an initial # value. Note that Parameters are not numbers, but are containers for @@ -138,7 +140,7 @@ def makeRecipe(): # Here we tell the FitRecipe to use our FitContribution. When the FitRecipe # calculates its residual function, it will call on the FitContribution to # do part of the work. - recipe.addContribution(contribution) + recipe.add_contribution(contribution) # Specify which Parameters we want to vary in the fit. This will add # Variables to the FitRecipe that directly modify the Parameters of the @@ -147,13 +149,13 @@ def makeRecipe(): # Here we create a Variable for the 'A' Parameter from our fit equation. # The resulting Variable will be named 'A' as well, but it will be accessed # via the FitRecipe. - recipe.addVar(contribution.A) + recipe.add_variable(contribution.A) # Here we create the Variable for 'x0' and give it an initial value of 5. - recipe.addVar(contribution.x0, 5) + recipe.add_variable(contribution.x0, 5) # Here we create a Variable named 'sig', which is tied to the 'sigma' # Parameter of our FitContribution. We give it an initial value through the # FitRecipe instance. - recipe.addVar(contribution.sigma, name="sig") + recipe.add_variable(contribution.sigma, name="sig") recipe.sig.value = 1 return recipe @@ -169,38 +171,19 @@ def scipyOptimize(recipe): # We're going to use the least-squares (Levenberg-Marquardt) optimizer from # scipy. We simply have to give it the function to minimize # (recipe.residual) and the starting values of the Variables - # (recipe.getValues()). + # (recipe.get_values()). from scipy.optimize.minpack import leastsq print("Fit using scipy's LM optimizer") - leastsq(recipe.residual, recipe.getValues()) + leastsq(recipe.residual, recipe.get_values()) return -def plotResults(recipe): - """Plot the results contained within a refined FitRecipe.""" - # We can access the data and fit profile through the Profile we created - # above. We get to it through our FitContribution, which we named "g1". - # - # The independent variable. This is always under the "x" attribute. - x = recipe.g1.profile.x - # The observed profile that we loaded earlier, the "y" attribute. - y = recipe.g1.profile.y - # The calculated profile, the "ycalc" attribute. - ycalc = recipe.g1.profile.ycalc - - # This stuff is specific to pylab from the matplotlib distribution. - import pylab - - pylab.plot(x, y, "b.", label="observed Gaussian") - pylab.plot(x, ycalc, "g-", label="calculated Gaussian") - pylab.legend(loc=(0.0, 0.8)) - pylab.xlabel("x") - pylab.ylabel("y") - - pylab.show() - return +plot_styles = { + "xlabel": "x", + "ylabel": "y", +} if __name__ == "__main__": diff --git a/docs/examples/interface.py b/docs/examples/interface.py index 4611ee6b..96466808 100644 --- a/docs/examples/interface.py +++ b/docs/examples/interface.py @@ -18,6 +18,8 @@ defined in the diffpy.srfit.interface.interface.py module. """ +from pathlib import Path + from diffpy.srfit.fitbase import ( FitContribution, FitRecipe, @@ -32,13 +34,13 @@ def main(): p = Profile() - p.loadtxt("data/gaussian.dat") + p.loadtxt(Path(__file__).parent / "data/gaussian.dat") # FitContribution operations # "<<" - Inject a parameter value c = FitContribution("g1") c.set_profile(p) - c.setEquation("A * exp(-0.5*(x-x0)**2/sigma**2)") + c.set_equation("A * exp(-0.5*(x-x0)**2/sigma**2)") c.A << 0.5 c.x0 << 5 c.sigma << 1 @@ -46,7 +48,7 @@ def main(): # FitRecipe operations # "|=" - Union of necessary components. # "+=" - Add Parameter or create a new one. Each tuple is a set of - # arguments for either setVar or addVar. + # arguments for either setVar or add_variable. # "*=" - Constrain a parameter. Think of "*" as a push-pin holding one # parameter's value to that of another. # "%=" - Restrain a parameter or equation. Think of "%" as a rope @@ -63,11 +65,13 @@ def main(): res = FitResults(r) # Print the results. - res.printResults() + res.print_results() # Plot the results. - from gaussianrecipe import plotResults - - plotResults(r) + plot_styles = { + "xlabel": "x", + "ylabel": "y", + } + r.plot_recipe(**plot_styles) return diff --git a/docs/examples/npintensity.py b/docs/examples/npintensity.py index 006e266a..09a0cb26 100644 --- a/docs/examples/npintensity.py +++ b/docs/examples/npintensity.py @@ -32,9 +32,9 @@ Extensions -- The IntensityGenerator class uses the 'addParameterSet' method to associate +- The IntensityGenerator class uses the 'add_parameter_set' method to associate the structure adapter (DiffpyStructureParSet) with the generator. Most SrFit - classes have an 'addParameterSet' class and can store ParameterSet objects. + classes have an 'add_parameter_set' class and can store ParameterSet objects. Grab the phase object from the IntensityGenerator and try to add it to other objects used in the fit recipe. Create variables from the moved Parameters rather than from the 'phase' that lives in the IntensityGenerator and see if @@ -43,6 +43,9 @@ from __future__ import print_function +from pathlib import Path + +import matplotlib.pyplot as plt import numpy from gaussianrecipe import scipyOptimize @@ -141,7 +144,7 @@ def setStructure(self, strufile): from diffpy.structure import Structure stru = Structure() - stru.read(strufile) + stru.read(str(strufile)) # Create a ParameterSet designed to interface with # diffpy.structure.Structure objects that organizes the Parameter @@ -153,7 +156,7 @@ def setStructure(self, strufile): parset = DiffpyStructureParSet("phase", stru) # Put this ParameterSet in the ProfileGenerator. - self.addParameterSet(parset) + self.add_parameter_set(parset) return @@ -202,7 +205,7 @@ def makeRecipe(strufile, datname): # the FitContribution to name the x-variable of the profile "q", so we can # use it in equations with this name. contribution = FitContribution("bucky") - contribution.addProfileGenerator(generator) + contribution.add_profile_generator(generator) contribution.set_profile(profile, xname="q") # Now we're ready to define the fitting equation for the FitContribution. @@ -230,7 +233,7 @@ def makeRecipe(strufile, datname): # This creates a callable equation named "bkgd" within the FitContribution, # and turns the polynomial coefficients into Parameters. - contribution.registerStringFunction(bkgdstr, "bkgd") + contribution.register_string_function(bkgdstr, "bkgd") # We will create the broadening function that we need by creating a python # function and registering it with the FitContribution. @@ -246,7 +249,7 @@ def gaussian(q, q0, width): # This registers the python function and extracts the name and creates # Parameters from the arguments. - contribution.registerFunction(gaussian) + contribution.register_function(gaussian) # Center the Gaussian so it is not truncated. contribution.q0.value = x[len(x) // 2] @@ -255,27 +258,27 @@ def gaussian(q, q0, width): # convolve the signal with the Gaussian to broaden it. Recall that we don't # need to supply arguments to the registered functions unless we want to # make changes to their input values. - contribution.setEquation("scale * convolve(I, gaussian) + bkgd") + contribution.set_equation("scale * convolve(I, gaussian) + bkgd") # Make the FitRecipe and add the FitContribution. recipe = FitRecipe() - recipe.addContribution(contribution) + recipe.add_contribution(contribution) # Specify which parameters we want to refine. - recipe.addVar(contribution.b0, 0) - recipe.addVar(contribution.b1, 0) - recipe.addVar(contribution.b2, 0) - recipe.addVar(contribution.b3, 0) - recipe.addVar(contribution.b4, 0) - recipe.addVar(contribution.b5, 0) - recipe.addVar(contribution.b6, 0) - recipe.addVar(contribution.b7, 0) - recipe.addVar(contribution.b8, 0) - recipe.addVar(contribution.b9, 0) + recipe.add_variable(contribution.b0, 0) + recipe.add_variable(contribution.b1, 0) + recipe.add_variable(contribution.b2, 0) + recipe.add_variable(contribution.b3, 0) + recipe.add_variable(contribution.b4, 0) + recipe.add_variable(contribution.b5, 0) + recipe.add_variable(contribution.b6, 0) + recipe.add_variable(contribution.b7, 0) + recipe.add_variable(contribution.b8, 0) + recipe.add_variable(contribution.b9, 0) # We also want to adjust the scale and the convolution width - recipe.addVar(contribution.scale, 1) - recipe.addVar(contribution.width, 0.1) + recipe.add_variable(contribution.scale, 1) + recipe.add_variable(contribution.width, 0.1) # We can also refine structural parameters. Here we extract the # DiffpyStructureParSet from the intensity generator and use the parameters @@ -288,16 +291,16 @@ def gaussian(q, q0, width): # constrained to a Variable by name. This has the same effect. lattice = phase.getLattice() a = lattice.a - recipe.addVar(a) - recipe.constrain(lattice.b, a) - recipe.constrain(lattice.c, a) + recipe.add_variable(a) + recipe.add_constraint(lattice.b, a) + recipe.add_constraint(lattice.c, a) # We want to refine the thermal parameters as well. We will add a new # Variable that we call "Uiso" and constrain the atomic Uiso values to # this. Note that we don't give Uiso an initial value. The initial value # will be inferred from the following constraints. - Uiso = recipe.newVar("Uiso") + Uiso = recipe.create_new_variable("Uiso") for atom in phase.getScatterers(): - recipe.constrain(atom.Uiso, Uiso) + recipe.add_constraint(atom.Uiso, Uiso) # Give the recipe away so it can be used! return recipe @@ -306,7 +309,7 @@ def gaussian(q, q0, width): def main(): # Make the data and the recipe - strufile = "data/C60.stru" + strufile = Path(__file__).parent / "data/C60.stru" q = numpy.arange(1, 20, 0.05) makeData(strufile, q, "C60.iq", 1.0, 100.68, 0.005, 0.13, 2) @@ -325,35 +328,37 @@ def main(): rescount = recipe.fithooks[0].count calcount = recipe.bucky.I.count footer = "iofq called %i%% of the time" % int(100.0 * calcount / rescount) - res.printResults(footer=footer) + res.print_results(footer=footer) # Plot! - plotResults(recipe) + plot_results(recipe) return -def plotResults(recipe): +def plot_results(recipe): """Plot the results contained within a refined FitRecipe.""" - # All this should be pretty familiar by now. + # The background is not part of the standard observed/fit/diff plot + # that plot_recipe produces, so we overlay it afterwards. q = recipe.bucky.profile.x - - Imeas = recipe.bucky.profile.y - Icalc = recipe.bucky.profile.ycalc - bkgd = recipe.bucky.evaluateEquation("bkgd") - diff = Imeas - Icalc - - import pylab - - pylab.plot(q, Imeas, "ob", label="I(Q) Data") - pylab.plot(q, Icalc, "r-", label="I(Q) Fit") - pylab.plot(q, diff, "g-", label="I(Q) diff") - pylab.plot(q, bkgd, "c-", label="Bkgd. Fit") - pylab.xlabel(r"$Q (\AA^{-1})$") - pylab.ylabel("Intensity (arb. units)") - pylab.legend(loc=1) - - pylab.show() + bkgd = recipe.bucky.evaluate_equation("bkgd") + + fig, ax = recipe.plot_recipe( + show=False, + return_fig=True, + data_color="b", + fit_color="r", + diff_color="g", + data_label="I(Q) Data", + fit_label="I(Q) Fit", + diff_label="I(Q) diff", + xlabel=r"$Q (\AA^{-1})$", + ylabel="Intensity (arb. units)", + ) + ax.plot(q, bkgd, "c-", label="Bkgd. Fit") + ax.legend(loc=1) + + plt.show() return @@ -485,7 +490,7 @@ def makeData(strufile, q, datname, scale, a, Uiso, sig, bkgc, nl=1): from diffpy.structure import Structure S = Structure() - S.read(strufile) + S.read(str(strufile)) # Set the lattice parameters S.lattice.setLatPar(a, a, a) diff --git a/docs/examples/npintensityII.py b/docs/examples/npintensityII.py index 4a599c8c..62b3e8c7 100644 --- a/docs/examples/npintensityII.py +++ b/docs/examples/npintensityII.py @@ -12,7 +12,8 @@ # See LICENSE_DANSE.txt for license information. # ######################################################################## -"""Example of extracting information from multiple data sets simultaneously. +"""Example of extracting information from multiple data sets +simultaneously. This example builds on npintensitygenerator.py, and uses IntensityGenerator from that example to build a recipe that simultaneously refines two data sets @@ -33,6 +34,9 @@ first step towards writing a user interface. """ +from pathlib import Path + +import matplotlib.pyplot as plt import numpy from gaussianrecipe import scipyOptimize from npintensity import IntensityGenerator, makeData @@ -82,15 +86,15 @@ def makeRecipe(strufile, datname1, datname2): generator1 = IntensityGenerator("I") generator1.setStructure(strufile) generator2 = IntensityGenerator("I") - generator2.addParameterSet(generator1.phase) + generator2.add_parameter_set(generator1.phase) # The FitContributions # Create the FitContributions. contribution1 = FitContribution("bucky1") - contribution1.addProfileGenerator(generator1) + contribution1.add_profile_generator(generator1) contribution1.set_profile(profile1, xname="q") contribution2 = FitContribution("bucky2") - contribution2.addProfileGenerator(generator2) + contribution2.add_profile_generator(generator2) contribution2.set_profile(profile2, xname="q") # Now we're ready to define the fitting equation for each FitContribution. @@ -98,12 +102,13 @@ def makeRecipe(strufile, datname1, datname2): # the same form and use the same Parameter names. By default, Parameters # in different contributions are different Parameters even if they have the # same names. FitContributions are isolated namespaces than only share - # information if you tell them to by using addParameter or addParameterSet. + # information if you tell them to by using addParameter or + # add_parameter_set. bkgdstr = "b0 + b1*q + b2*q**2 + b3*q**3 + b4*q**4 + b5*q**5 + b6*q**6 +\ b7*q**7 +b8*q**8 + b9*q**9" - contribution1.registerStringFunction(bkgdstr, "bkgd") - contribution2.registerStringFunction(bkgdstr, "bkgd") + contribution1.register_string_function(bkgdstr, "bkgd") + contribution2.register_string_function(bkgdstr, "bkgd") # We will create the broadening function by registering a python function. pi = numpy.pi @@ -116,113 +121,102 @@ def gaussian(q, q0, width): * exp(-0.5 * ((q - q0) / width) ** 2) ) - contribution1.registerFunction(gaussian) - contribution2.registerFunction(gaussian) + contribution1.register_function(gaussian) + contribution2.register_function(gaussian) # Center the gaussian contribution1.q0.value = x[len(x) // 2] contribution2.q0.value = x[len(x) // 2] # Now we can incorporate the scale and bkgd into our calculation. We also # convolve the signal with the gaussian to broaden it. - contribution1.setEquation("scale * convolve(I, gaussian) + bkgd") - contribution2.setEquation("scale * convolve(I, gaussian) + bkgd") + contribution1.set_equation("scale * convolve(I, gaussian) + bkgd") + contribution2.set_equation("scale * convolve(I, gaussian) + bkgd") # Make a FitRecipe and associate the FitContributions. recipe = FitRecipe() - recipe.addContribution(contribution1) - recipe.addContribution(contribution2) + recipe.add_contribution(contribution1) + recipe.add_contribution(contribution2) # Specify which Parameters we want to refine. We want to refine the # background that we just defined in the FitContributions. We have to do # this separately for each FitContribution. We tag the variables so it is # easy to retrieve the background variables. - recipe.addVar(contribution1.b0, 0, name="b1_0", tag="bcoeffs1") - recipe.addVar(contribution1.b1, 0, name="b1_1", tag="bcoeffs1") - recipe.addVar(contribution1.b2, 0, name="b1_2", tag="bcoeffs1") - recipe.addVar(contribution1.b3, 0, name="b1_3", tag="bcoeffs1") - recipe.addVar(contribution1.b4, 0, name="b1_4", tag="bcoeffs1") - recipe.addVar(contribution1.b5, 0, name="b1_5", tag="bcoeffs1") - recipe.addVar(contribution1.b6, 0, name="b1_6", tag="bcoeffs1") - recipe.addVar(contribution1.b7, 0, name="b1_7", tag="bcoeffs1") - recipe.addVar(contribution1.b8, 0, name="b1_8", tag="bcoeffs1") - recipe.addVar(contribution1.b9, 0, name="b1_9", tag="bcoeffs1") - recipe.addVar(contribution2.b0, 0, name="b2_0", tag="bcoeffs2") - recipe.addVar(contribution2.b1, 0, name="b2_1", tag="bcoeffs2") - recipe.addVar(contribution2.b2, 0, name="b2_2", tag="bcoeffs2") - recipe.addVar(contribution2.b3, 0, name="b2_3", tag="bcoeffs2") - recipe.addVar(contribution2.b4, 0, name="b2_4", tag="bcoeffs2") - recipe.addVar(contribution2.b5, 0, name="b2_5", tag="bcoeffs2") - recipe.addVar(contribution2.b6, 0, name="b2_6", tag="bcoeffs2") - recipe.addVar(contribution2.b7, 0, name="b2_7", tag="bcoeffs2") - recipe.addVar(contribution2.b8, 0, name="b2_8", tag="bcoeffs2") - recipe.addVar(contribution2.b9, 0, name="b2_9", tag="bcoeffs2") + recipe.add_variable(contribution1.b0, 0, name="b1_0", tag="bcoeffs1") + recipe.add_variable(contribution1.b1, 0, name="b1_1", tag="bcoeffs1") + recipe.add_variable(contribution1.b2, 0, name="b1_2", tag="bcoeffs1") + recipe.add_variable(contribution1.b3, 0, name="b1_3", tag="bcoeffs1") + recipe.add_variable(contribution1.b4, 0, name="b1_4", tag="bcoeffs1") + recipe.add_variable(contribution1.b5, 0, name="b1_5", tag="bcoeffs1") + recipe.add_variable(contribution1.b6, 0, name="b1_6", tag="bcoeffs1") + recipe.add_variable(contribution1.b7, 0, name="b1_7", tag="bcoeffs1") + recipe.add_variable(contribution1.b8, 0, name="b1_8", tag="bcoeffs1") + recipe.add_variable(contribution1.b9, 0, name="b1_9", tag="bcoeffs1") + recipe.add_variable(contribution2.b0, 0, name="b2_0", tag="bcoeffs2") + recipe.add_variable(contribution2.b1, 0, name="b2_1", tag="bcoeffs2") + recipe.add_variable(contribution2.b2, 0, name="b2_2", tag="bcoeffs2") + recipe.add_variable(contribution2.b3, 0, name="b2_3", tag="bcoeffs2") + recipe.add_variable(contribution2.b4, 0, name="b2_4", tag="bcoeffs2") + recipe.add_variable(contribution2.b5, 0, name="b2_5", tag="bcoeffs2") + recipe.add_variable(contribution2.b6, 0, name="b2_6", tag="bcoeffs2") + recipe.add_variable(contribution2.b7, 0, name="b2_7", tag="bcoeffs2") + recipe.add_variable(contribution2.b8, 0, name="b2_8", tag="bcoeffs2") + recipe.add_variable(contribution2.b9, 0, name="b2_9", tag="bcoeffs2") # We also want to adjust the scale and the convolution width - recipe.addVar(contribution1.scale, 1, name="scale1") - recipe.addVar(contribution1.width, 0.1, name="width1") - recipe.addVar(contribution2.scale, 1, name="scale2") - recipe.addVar(contribution2.width, 0.1, name="width2") + recipe.add_variable(contribution1.scale, 1, name="scale1") + recipe.add_variable(contribution1.width, 0.1, name="width1") + recipe.add_variable(contribution2.scale, 1, name="scale2") + recipe.add_variable(contribution2.width, 0.1, name="width2") # We can also refine structural parameters. We only have to do this once, # since each generator holds the same DiffpyStructureParSet. phase = generator1.phase lattice = phase.getLattice() - a = recipe.addVar(lattice.a) + a = recipe.add_variable(lattice.a) # We want to allow for isotropic expansion, so we'll make constraints for # that. - recipe.constrain(lattice.b, a) - recipe.constrain(lattice.c, a) + recipe.add_constraint(lattice.b, a) + recipe.add_constraint(lattice.c, a) # We want to refine the thermal parameters as well. We will add a new # variable that we call "Uiso" and constrain the atomic Uiso values to # this. Note that we don't give Uiso an initial value. The initial value # will be inferred from the subsequent constraints. - Uiso = recipe.newVar("Uiso") + Uiso = recipe.create_new_variable("Uiso") for atom in phase.getScatterers(): - recipe.constrain(atom.Uiso, Uiso) + recipe.add_constraint(atom.Uiso, Uiso) # Give the recipe away so it can be used! return recipe -def plotResults(recipe): - """Plot the results contained within a refined FitRecipe.""" - # plotting song and dance - q = recipe.bucky1.profile.x +def plot_results(recipe): + """Plot the results contained within a refined FitRecipe. - # Plot this for fun. - I1 = recipe.bucky1.profile.y - Icalc1 = recipe.bucky1.profile.ycalc - bkgd1 = recipe.bucky1.evaluateEquation("bkgd") - diff1 = I1 - Icalc1 - I2 = recipe.bucky2.profile.y - Icalc2 = recipe.bucky2.profile.ycalc - bkgd2 = recipe.bucky2.evaluateEquation("bkgd") - diff2 = I2 - Icalc2 - offset = 1.2 * max(I2) * numpy.ones_like(I2) - I1 += offset - Icalc1 += offset - bkgd1 += offset - diff1 += offset - - import pylab - - pylab.subplot(2, 1, 1) - pylab.plot(q, I1, "bo", label="I1(Q) Data") - pylab.plot(q, Icalc1, "r-", label="I1(Q) Fit") - pylab.plot(q, diff1, "g-", label="I1(Q) diff") - pylab.plot(q, bkgd1, "c-", label="Bkgd1 Fit") - pylab.legend(loc=1) - - pylab.subplot(2, 1, 2) - pylab.plot(q, I2, "bo", label="I2(Q) Data") - pylab.plot(q, Icalc2, "r-", label="I2(Q) Fit") - pylab.plot(q, diff2, "g-", label="I2(Q) diff") - pylab.plot(q, bkgd2, "c-", label="Bkgd2 Fit") - pylab.xlabel(r"$Q (\AA^{-1})$") - pylab.ylabel("Intensity (arb. units)") - pylab.legend(loc=1) - - pylab.show() + The recipe has two contributions ("bucky1" and "bucky2"), so + plot_recipe produces one figure per contribution. The backgrounds + are not part of the standard observed/fit/diff plot, so they are + overlaid on each figure afterwards. + """ + q = recipe.bucky1.profile.x + bkgd1 = recipe.bucky1.evaluate_equation("bkgd") + bkgd2 = recipe.bucky2.evaluate_equation("bkgd") + + figs, axes = recipe.plot_recipe( + show=False, + return_fig=True, + data_label="I(Q) Data", + fit_label="I(Q) Fit", + diff_label="I(Q) diff", + xlabel=r"$Q (\AA^{-1})$", + ylabel="Intensity (arb. units)", + ) + # "bucky1" was added to the recipe first, so its axes come first. + axes[0].plot(q, bkgd1, "c-", label="Bkgd1 Fit") + axes[0].legend(loc=1) + axes[1].plot(q, bkgd2, "c-", label="Bkgd2 Fit") + axes[1].legend(loc=1) + + plt.show() return @@ -230,7 +224,7 @@ def main(): # Make two different data sets, each from the same structure, but with # different scale, noise, broadening and background. - strufile = "data/C60.stru" + strufile = Path(__file__).parent / "data/C60.stru" q = numpy.arange(1, 20, 0.05) makeData(strufile, q, "C60_1.iq", 8.1, 101.68, 0.008, 0.12, 2, 0.01) makeData(strufile, q, "C60_2.iq", 3.2, 101.68, 0.02, 0.003, 0, 1) @@ -246,25 +240,25 @@ def main(): # fit. recipe.fix("all") recipe.free("bcoeffs1") - recipe.setWeight(recipe.bucky2, 0) + recipe.set_weight(recipe.bucky2, 0) scipyOptimize(recipe) # Now do the same for the second background recipe.fix("all") recipe.free("bcoeffs1") - recipe.setWeight(recipe.bucky2, 1) - recipe.setWeight(recipe.bucky1, 0) + recipe.set_weight(recipe.bucky2, 1) + recipe.set_weight(recipe.bucky1, 0) scipyOptimize(recipe) # Now refine everything with the structure parameters included recipe.free("all") - recipe.setWeight(recipe.bucky1, 1) + recipe.set_weight(recipe.bucky1, 1) scipyOptimize(recipe) # Generate and print the FitResults res = FitResults(recipe) - res.printResults() + res.print_results() # Plot! - plotResults(recipe) + plot_results(recipe) return diff --git a/docs/examples/nppdfcrystal.py b/docs/examples/nppdfcrystal.py index 5afbc448..ec3adc37 100644 --- a/docs/examples/nppdfcrystal.py +++ b/docs/examples/nppdfcrystal.py @@ -23,7 +23,9 @@ diffpy.srfit.pdf.characteristicfunctions module. """ -import numpy +from pathlib import Path + +import matplotlib.pyplot as plt from gaussianrecipe import scipyOptimize from pyobjcryst import loadCrystal @@ -42,9 +44,9 @@ def makeRecipe(ciffile, grdata): pdfprofile = Profile() pdfparser = PDFParser() - pdfparser.parseFile(grdata) - pdfprofile.loadParsedData(pdfparser) - pdfprofile.setCalculationRange(xmin=0.1, xmax=20) + pdfparser.parse_file(grdata) + pdfprofile.load_parsed_data(pdfparser) + pdfprofile.set_calculation_range(xmin=0.1, xmax=20) pdfcontribution = FitContribution("pdf") pdfcontribution.set_profile(pdfprofile, xname="r") @@ -53,75 +55,71 @@ def makeRecipe(ciffile, grdata): pdfgenerator.setQmax(30.0) stru = loadCrystal(ciffile) pdfgenerator.setStructure(stru) - pdfcontribution.addProfileGenerator(pdfgenerator) + pdfcontribution.add_profile_generator(pdfgenerator) # Register the nanoparticle shape factor. - from diffpy.srfit.pdf.characteristicfunctions import sphericalCF + from diffpy.srfit.pdf.characteristicfunctions import spherical_particle - pdfcontribution.registerFunction(sphericalCF, name="f") + pdfcontribution.register_function(spherical_particle, name="f") # Now we set up the fitting equation. - pdfcontribution.setEquation("f * G") + pdfcontribution.set_equation("f * G") # Now make the recipe. Make sure we fit the characteristic function shape # parameters, in this case 'psize', which is the diameter of the particle. recipe = FitRecipe() - recipe.addContribution(pdfcontribution) + recipe.add_contribution(pdfcontribution) phase = pdfgenerator.phase for par in phase.sgpars: - recipe.addVar(par) + recipe.add_variable(par) - recipe.addVar(pdfcontribution.psize, 20) - recipe.addVar(pdfgenerator.scale, 1) - recipe.addVar(pdfgenerator.delta2, 0) + recipe.add_variable(pdfcontribution.psize, 20) + recipe.add_variable(pdfgenerator.scale, 1) + recipe.add_variable(pdfgenerator.delta2, 0) recipe.B11_0 = 0.1 return recipe -def plotResults(recipe): +def plot_results(recipe): """Plot the results contained within a refined FitRecipe.""" - # All this should be pretty familiar by now. r = recipe.pdf.profile.x g = recipe.pdf.profile.y - gcalc = recipe.pdf.profile.ycalc - diffzero = -0.8 * max(g) * numpy.ones_like(g) - diff = g - gcalc + diffzero - gcryst = recipe.pdf.evaluateEquation("G") + # These two curves are not part of the standard observed/fit/diff plot + # that plot_recipe produces, so we overlay them afterwards. + gcryst = recipe.pdf.evaluate_equation("G") gcryst /= recipe.scale.value - fr = recipe.pdf.evaluateEquation("f") + fr = recipe.pdf.evaluate_equation("f") fr *= max(g) / fr[0] - import pylab - - pylab.plot(r, g, "bo", label="G(r) Data") - pylab.plot(r, gcryst, "y--", label="G(r) Crystal") - pylab.plot(r, fr, "k--", label="f(r) calculated (scaled)") - pylab.plot(r, gcalc, "r-", label="G(r) Fit") - pylab.plot(r, diff, "g-", label="G(r) diff") - pylab.plot(r, diffzero, "k-") - pylab.xlabel(r"$r (\AA)$") - pylab.ylabel(r"$G (\AA^{-2})$") - pylab.legend(loc=1) - - pylab.show() + fig, ax = recipe.plot_recipe( + show=False, + return_fig=True, + xlabel=r"$r (\AA)$", + ylabel=r"$G (\AA^{-2})$", + ) + ax.plot(r, gcryst, "y--", label="G(r) Crystal") + ax.plot(r, fr, "k--", label="f(r) calculated (scaled)") + ax.legend(loc=1) + + plt.show() return if __name__ == "__main__": - ciffile = "data/pb.cif" - grdata = "data/pb_100_qmin1.gr" + ciffile = Path(__file__).parent / "data/pb.cif" + grdata = Path(__file__).parent / "data/pb_100_qmin1.gr" recipe = makeRecipe(ciffile, grdata) scipyOptimize(recipe) res = FitResults(recipe) - res.printResults() + res.print_results() - plotResults(recipe) + plot_results(recipe) # End of file diff --git a/docs/examples/nppdfobjcryst.py b/docs/examples/nppdfobjcryst.py index d99e75fd..8367d1ca 100644 --- a/docs/examples/nppdfobjcryst.py +++ b/docs/examples/nppdfobjcryst.py @@ -18,7 +18,7 @@ the DebyePDFGenerator from SrReal to refine a pyobjcryst Molecule. """ -import numpy +from pathlib import Path from diffpy.srfit.fitbase import ( FitContribution, @@ -39,7 +39,7 @@ def makeRecipe(molecule, datname): # Load data and add it to the profile profile.loadtxt(datname) - profile.setCalculationRange(xmin=1.2, xmax=8) + profile.set_calculation_range(xmin=1.2, xmax=8) # The ProfileGenerator # Create a DebyePDFGenerator named "G". @@ -51,12 +51,12 @@ def makeRecipe(molecule, datname): # The FitContribution contribution = FitContribution("bucky") - contribution.addProfileGenerator(generator) + contribution.add_profile_generator(generator) contribution.set_profile(profile, xname="r") # Make a FitRecipe. recipe = FitRecipe() - recipe.addContribution(contribution) + recipe.add_contribution(contribution) # Specify which parameters we want to refine. We'll be using the # MoleculeParSet within the generator, so let's get a handle to it. See the @@ -65,14 +65,14 @@ def makeRecipe(molecule, datname): c60 = generator.phase # First, the isotropic thermal displacement factor. - Biso = recipe.newVar("Biso") + Biso = recipe.create_new_variable("Biso") for atom in c60.getScatterers(): # We have defined a 'center' atom that is a dummy, which means that it # has no scattering power. It is only used as a reference point for # our bond length. We don't want to constrain it. if not atom.isDummy(): - recipe.constrain(atom.Biso, Biso) + recipe.add_constraint(atom.Biso, Biso) # We need to let the molecule expand. If we were modeling it as a crystal, # we could let the unit cell expand. For instruction purposes, we use a @@ -87,7 +87,7 @@ def makeRecipe(molecule, datname): # that we don't give it an initial value. Since the variable is being # directly constrained to further below, its initial value will be inferred # from the constraint. - radius = recipe.newVar("radius") + radius = recipe.create_new_variable("radius") for i, atom in enumerate(c60.getScatterers()): if atom.isDummy(): @@ -96,59 +96,42 @@ def makeRecipe(molecule, datname): # This creates a Parameter that moves the second atom according to the # bond length. Note that each Parameter needs a unique name. par = c60.addBondLengthParameter("rad%i" % i, center, atom) - recipe.constrain(par, radius) + recipe.add_constraint(par, radius) # Add the correlation term, scale. The scale is too short to effectively # determine qdamp. - recipe.addVar(generator.delta2, 2) - recipe.addVar(generator.scale, 1.3e4) + recipe.add_variable(generator.delta2, 2) + recipe.add_variable(generator.scale, 1.3e4) # Give the recipe away so it can be used! return recipe -def plotResults(recipe): - """Plot the results contained within a refined FitRecipe.""" - # Plot this. - r = recipe.bucky.profile.x - g = recipe.bucky.profile.y - gcalc = recipe.bucky.profile.ycalc - diffzero = -0.8 * max(g) * numpy.ones_like(g) - diff = g - gcalc + diffzero - - import pylab - - pylab.plot(r, g, "ob", label="G(r) Data") - pylab.plot(r, gcalc, "-r", label="G(r) Fit") - pylab.plot(r, diff, "-g", label="G(r) diff") - pylab.plot(r, diffzero, "-k") - pylab.xlabel(r"$r (\AA)$") - pylab.ylabel(r"$G (\AA^{-2})$") - pylab.legend(loc=1) - - pylab.show() - return +plot_styles = { + "xlabel": r"$r (\AA)$", + "ylabel": r"$G (\AA^{-2})$", +} def main(): molecule = makeC60() # Make the data and the recipe - recipe = makeRecipe(molecule, "data/C60.gr") + recipe = makeRecipe(molecule, Path(__file__).parent / "data/C60.gr") # Tell the fithook that we want very verbose output. recipe.fithooks[0].verbose = 3 # Optimize from scipy.optimize import leastsq - leastsq(recipe.residual, recipe.getValues()) + leastsq(recipe.residual, recipe.get_values()) # Print results res = FitResults(recipe) - res.printResults() + res.print_results() # Plot results - plotResults(recipe) + recipe.plot_recipe(**plot_styles) return diff --git a/docs/examples/nppdfsas.py b/docs/examples/nppdfsas.py index 1bb8d8ed..91983bdf 100644 --- a/docs/examples/nppdfsas.py +++ b/docs/examples/nppdfsas.py @@ -22,7 +22,9 @@ of the nanoparticle that agrees best with both the PDF and SAS data. """ -import numpy +from pathlib import Path + +import matplotlib.pyplot as plt from gaussianrecipe import scipyOptimize from pyobjcryst import loadCrystal @@ -38,7 +40,8 @@ def makeRecipe(ciffile, grdata, iqdata): - """Make complex-modeling recipe where I(q) and G(r) are fit simultaneously. + """Make complex-modeling recipe where I(q) and G(r) are fit + simultaneously. The fit I(q) is fed into the calculation of G(r), which provides feedback for the fit parameters of both. @@ -46,9 +49,9 @@ def makeRecipe(ciffile, grdata, iqdata): # Create a PDF contribution as before pdfprofile = Profile() pdfparser = PDFParser() - pdfparser.parseFile(grdata) - pdfprofile.loadParsedData(pdfparser) - pdfprofile.setCalculationRange(xmin=0.1, xmax=20) + pdfparser.parse_file(grdata) + pdfprofile.load_parsed_data(pdfparser) + pdfprofile.set_calculation_range(xmin=0.1, xmax=20) pdfcontribution = FitContribution("pdf") pdfcontribution.set_profile(pdfprofile, xname="r") @@ -57,15 +60,15 @@ def makeRecipe(ciffile, grdata, iqdata): pdfgenerator.setQmax(30.0) stru = loadCrystal(ciffile) pdfgenerator.setStructure(stru) - pdfcontribution.addProfileGenerator(pdfgenerator) - pdfcontribution.setResidualEquation("resv") + pdfcontribution.add_profile_generator(pdfgenerator) + pdfcontribution.set_residual_equation("resv") # Create a SAS contribution as well. We assume the nanoparticle is roughly # elliptical. sasprofile = Profile() sasparser = SASParser() - sasparser.parseFile(iqdata) - sasprofile.loadParsedData(sasparser) + sasparser.parse_file(iqdata) + sasprofile.load_parsed_data(sasparser) if all(sasprofile.dy == 0): sasprofile.dy[:] = 1 @@ -76,8 +79,8 @@ def makeRecipe(ciffile, grdata, iqdata): model = EllipsoidModel() sasgenerator = SASGenerator("generator", model) - sascontribution.addProfileGenerator(sasgenerator) - sascontribution.setResidualEquation("resv") + sascontribution.add_profile_generator(sasgenerator) + sascontribution.set_residual_equation("resv") # Now we set up a characteristic function calculator that depends on the # sas model. @@ -85,34 +88,34 @@ def makeRecipe(ciffile, grdata, iqdata): # Register the calculator with the pdf contribution and define the fitting # equation. - pdfcontribution.registerCalculator(cfcalculator) + pdfcontribution.register_calculator(cfcalculator) # The PDF for a nanoscale crystalline is approximated by # Gnano = f * Gcryst - pdfcontribution.setEquation("f * G") + pdfcontribution.set_equation("f * G") # Moving on recipe = FitRecipe() - recipe.addContribution(pdfcontribution) - recipe.addContribution(sascontribution) + recipe.add_contribution(pdfcontribution) + recipe.add_contribution(sascontribution) # PDF phase = pdfgenerator.phase for par in phase.sgpars: - recipe.addVar(par) + recipe.add_variable(par) - recipe.addVar(pdfgenerator.scale, 1) - recipe.addVar(pdfgenerator.delta2, 0) + recipe.add_variable(pdfgenerator.scale, 1) + recipe.add_variable(pdfgenerator.delta2, 0) # SAS - recipe.addVar(sasgenerator.scale, 1, name="iqscale") - recipe.addVar(sasgenerator.radius_a, 10) - recipe.addVar(sasgenerator.radius_b, 10) + recipe.add_variable(sasgenerator.scale, 1, name="iqscale") + recipe.add_variable(sasgenerator.radius_a, 10) + recipe.add_variable(sasgenerator.radius_b, 10) # Even though the cfcalculator and sasgenerator depend on the same sas # model, we must still constrain the cfcalculator Parameters so that it is # informed of changes in the refined parameters. - recipe.constrain(cfcalculator.radius_a, "radius_a") - recipe.constrain(cfcalculator.radius_b, "radius_b") + recipe.add_constraint(cfcalculator.radius_a, "radius_a") + recipe.add_constraint(cfcalculator.radius_b, "radius_b") return recipe @@ -120,73 +123,75 @@ def makeRecipe(ciffile, grdata, iqdata): def fitRecipe(recipe): """We refine in stages to help the refinement converge.""" # Tune SAS. - recipe.setWeight(recipe.pdf, 0) + recipe.set_weight(recipe.pdf, 0) recipe.fix("all") recipe.free("radius_a", "radius_b", iqscale=1e8) - recipe.constrain("radius_b", "radius_a") + recipe.add_constraint("radius_b", "radius_a") scipyOptimize(recipe) - recipe.unconstrain("radius_b") + recipe.remove_constraint("radius_b") # Tune PDF - recipe.setWeight(recipe.pdf, 1) - recipe.setWeight(recipe.sas, 0) + recipe.set_weight(recipe.pdf, 1) + recipe.set_weight(recipe.sas, 0) recipe.fix("all") recipe.free("a", "Biso_0", "scale", "delta2") scipyOptimize(recipe) # Tune all - recipe.setWeight(recipe.pdf, 1) - recipe.setWeight(recipe.sas, 1) + recipe.set_weight(recipe.pdf, 1) + recipe.set_weight(recipe.sas, 1) recipe.free("all") scipyOptimize(recipe) return -def plotResults(recipe): - """Plot the results contained within a refined FitRecipe.""" - # All this should be pretty familiar by now. +def plot_results(recipe): + """Plot the results contained within a refined FitRecipe. + + The recipe has two contributions ("pdf" and "sas"), so plot_recipe + produces one figure per contribution. The G(r) crystal and shape + curves are not part of the standard observed/fit/diff plot, so they + are overlaid on the "pdf" figure afterwards. + """ r = recipe.pdf.profile.x g = recipe.pdf.profile.y - gcalc = recipe.pdf.profile.ycalc - diffzero = -0.8 * max(g) * numpy.ones_like(g) - diff = g - gcalc + diffzero - gcryst = recipe.pdf.evaluateEquation("G") + gcryst = recipe.pdf.evaluate_equation("G") gcryst /= recipe.scale.value - fr = recipe.pdf.evaluateEquation("f") + fr = recipe.pdf.evaluate_equation("f") fr *= max(g) / fr[0] - import pylab - - pylab.plot(r, g, "bo", label="G(r) Data") - pylab.plot(r, gcryst, "y--", label="G(r) Crystal") - pylab.plot(r, fr, "k--", label="f(r) calculated (scaled)") - pylab.plot(r, gcalc, "r-", label="G(r) Fit") - pylab.plot(r, diff, "g-", label="G(r) diff") - pylab.plot(r, diffzero, "k-") - pylab.xlabel(r"$r (\AA)$") - pylab.ylabel(r"$G (\AA^{-2})$") - pylab.legend(loc=1) - - pylab.show() + figs, axes = recipe.plot_recipe( + show=False, + return_fig=True, + xlabel=r"$r (\AA)$", + ylabel=r"$G (\AA^{-2})$", + ) + # "pdf" was added to the recipe first, so its axes come first. + ax = axes[0] + ax.plot(r, gcryst, "y--", label="G(r) Crystal") + ax.plot(r, fr, "k--", label="f(r) calculated (scaled)") + ax.legend(loc=1) + + plt.show() return if __name__ == "__main__": - ciffile = "data/pb.cif" - grdata = "data/pb_100_qmin1.gr" - iqdata = "data/pb_100_qmax1.iq" + ciffile = Path(__file__).parent / "data/pb.cif" + grdata = Path(__file__).parent / "data/pb_100_qmin1.gr" + iqdata = Path(__file__).parent / "data/pb_100_qmax1.iq" recipe = makeRecipe(ciffile, grdata, iqdata) recipe.fithooks[0].verbose = 3 fitRecipe(recipe) res = FitResults(recipe) - res.printResults() + res.print_results() - plotResults(recipe) + plot_results(recipe) # End of file diff --git a/docs/examples/simplepdf.py b/docs/examples/simplepdf.py index 2bba40db..9815b2a7 100644 --- a/docs/examples/simplepdf.py +++ b/docs/examples/simplepdf.py @@ -18,7 +18,8 @@ data. It uses the PDFContribution class to simplify fit setup. """ -from crystalpdf import plotResults +from pathlib import Path + from gaussianrecipe import scipyOptimize from diffpy.srfit.fitbase import FitRecipe, FitResults @@ -34,42 +35,48 @@ def makeRecipe(ciffile, datname): # Work directly with a custom PDFContribution to load the data contribution = PDFContribution("nickel") contribution.loadData(datname) - contribution.setCalculationRange(xmin=1, xmax=20, dx=0.1) + contribution.set_calculation_range(xmin=1, xmax=20, dx=0.1) # and the phase stru = Structure() - stru.read(ciffile) + stru.read(str(ciffile)) contribution.addStructure("nickel", stru) # Make the FitRecipe and add the FitContribution. recipe = FitRecipe() - recipe.addContribution(contribution) + recipe.add_contribution(contribution) # Configure the fit variables phase = contribution.nickel.phase - from diffpy.srfit.structure import constrainAsSpaceGroup + from diffpy.srfit.structure import constrain_as_space_group - sgpars = constrainAsSpaceGroup(phase, "Fm-3m") + sgpars = constrain_as_space_group(phase, "Fm-3m") for par in sgpars.latpars: - recipe.addVar(par) + recipe.add_variable(par) for par in sgpars.adppars: - recipe.addVar(par, 0.005) + recipe.add_variable(par, 0.005) - recipe.addVar(contribution.scale, 1) - recipe.addVar(contribution.qdamp, 0.03, fixed=True) - recipe.addVar(contribution.nickel.delta2, 5) + recipe.add_variable(contribution.scale, 1) + recipe.add_variable(contribution.qdamp, 0.03, fixed=True) + recipe.add_variable(contribution.nickel.delta2, 5) # Give the recipe away so it can be used! return recipe +plot_styles = { + "xlabel": r"$r (\AA)$", + "ylabel": r"$G (\AA^{-2})$", +} + + if __name__ == "__main__": # Make the data and the recipe - ciffile = "data/ni.cif" - data = "data/ni-q27r100-neutron.gr" + ciffile = Path(__file__).parent / "data/ni.cif" + data = Path(__file__).parent / "data/ni-q27r100-neutron.gr" # Make the recipe recipe = makeRecipe(ciffile, data) @@ -81,10 +88,10 @@ def makeRecipe(ciffile, datname): # Generate, print and save the FitResults res = FitResults(recipe) - res.printResults() - res.saveResults("nickel_example.res") + res.print_results() + res.save_results("nickel_example.res") # Plot! - plotResults(recipe) + recipe.plot_recipe(**plot_styles) # End of file diff --git a/docs/examples/simplepdftwophase.py b/docs/examples/simplepdftwophase.py index 349fd67c..520b734c 100644 --- a/docs/examples/simplepdftwophase.py +++ b/docs/examples/simplepdftwophase.py @@ -14,7 +14,8 @@ ######################################################################## """Example of a simplified PDF refinement of two-phase structure.""" -from crystalpdftwophase import plotResults +from pathlib import Path + from gaussianrecipe import scipyOptimize from pyobjcryst import loadCrystal @@ -30,7 +31,7 @@ def makeRecipe(niciffile, siciffile, datname): # Load data and add it to the profile contribution = PDFContribution("nisi") contribution.loadData(datname) - contribution.setCalculationRange(xmax=20) + contribution.set_calculation_range(xmax=20) stru = loadCrystal(niciffile) contribution.addStructure("ni", stru) @@ -40,20 +41,20 @@ def makeRecipe(niciffile, siciffile, datname): # Make the FitRecipe and add the FitContribution. recipe = FitRecipe() - recipe.addContribution(contribution) + recipe.add_contribution(contribution) # Configure the fit variables # Start by configuring the scale factor and resolution factors. # We want the sum of the phase scale factors to be 1. - recipe.newVar("scale_ni", 0.1) - recipe.constrain(contribution.ni.scale, "scale_ni") - recipe.constrain(contribution.si.scale, "1 - scale_ni") + recipe.create_new_variable("scale_ni", 0.1) + recipe.add_constraint(contribution.ni.scale, "scale_ni") + recipe.add_constraint(contribution.si.scale, "1 - scale_ni") # We also want the resolution factor to be the same on each. This is done # for free by the PDFContribution. We simply need to add it to the recipe. - recipe.addVar(contribution.qdamp, 0.03) + recipe.add_variable(contribution.qdamp, 0.03) # Vary the global scale as well. - recipe.addVar(contribution.scale, 1) + recipe.add_variable(contribution.scale, 1) # Now we can configure the structural parameters. Since we're using # ObjCrystCrystalParSets, the space group constraints are automatically @@ -65,13 +66,13 @@ def makeRecipe(niciffile, siciffile, datname): # above. phase_ni = contribution.ni.phase for par in phase_ni.sgpars: - recipe.addVar(par, name=par.name + "_ni") - recipe.addVar(contribution.ni.delta2, name="delta2_ni") + recipe.add_variable(par, name=par.name + "_ni") + recipe.add_variable(contribution.ni.delta2, name="delta2_ni") # Next the silicon parameters phase_si = contribution.si.phase for par in phase_si.sgpars: - recipe.addVar(par, name=par.name + "_si") - recipe.addVar(contribution.si.delta2, name="delta2_si") + recipe.add_variable(par, name=par.name + "_si") + recipe.add_variable(contribution.si.delta2, name="delta2_si") # We have prior information from the earlier examples so we'll use it here # in the form of restraints. @@ -81,29 +82,47 @@ def makeRecipe(niciffile, siciffile, datname): # derived has no uncertainty. Thus, we will tell the recipe to scale the # residual, which means that it will be weighted as much as the average # data point during the fit. - recipe.restrain("a_ni", lb=3.527, ub=3.527, scaled=True) + recipe.add_soft_bounds( + "a_ni", lower_bound=3.527, upper_bound=3.527, scaled=True + ) # Now we do the same with the delta2 and Biso parameters (remember that # Biso = 8*pi**2*Uiso) - recipe.restrain("delta2_ni", lb=2.22, ub=2.22, scaled=True) - recipe.restrain("Biso_0_ni", lb=0.454, ub=0.454, scaled=True) + recipe.add_soft_bounds( + "delta2_ni", lower_bound=2.22, upper_bound=2.22, scaled=True + ) + recipe.add_soft_bounds( + "Biso_0_ni", lower_bound=0.454, upper_bound=0.454, scaled=True + ) # # We can do the same with the silicon values. We haven't done a thorough # job of measuring the uncertainties in the results, so we'll scale these # as well. - recipe.restrain("a_si", lb=5.430, ub=5.430, scaled=True) - recipe.restrain("delta2_si", lb=3.54, ub=3.54, scaled=True) - recipe.restrain("Biso_0_si", lb=0.645, ub=0.645, scaled=True) + recipe.add_soft_bounds( + "a_si", lower_bound=5.430, upper_bound=5.430, scaled=True + ) + recipe.add_soft_bounds( + "delta2_si", lower_bound=3.54, upper_bound=3.54, scaled=True + ) + recipe.add_soft_bounds( + "Biso_0_si", lower_bound=0.645, upper_bound=0.645, scaled=True + ) # Give the recipe away so it can be used! return recipe +plot_styles = { + "xlabel": r"$r (\AA)$", + "ylabel": r"$G (\AA^{-2})$", +} + + if __name__ == "__main__": # Make the data and the recipe - niciffile = "data/ni.cif" - siciffile = "data/si.cif" - data = "data/si90ni10-q27r60-xray.gr" + niciffile = Path(__file__).parent / "data/ni.cif" + siciffile = Path(__file__).parent / "data/si.cif" + data = Path(__file__).parent / "data/si90ni10-q27r60-xray.gr" # Make the recipe recipe = makeRecipe(niciffile, siciffile, data) @@ -113,9 +132,9 @@ def makeRecipe(niciffile, siciffile, datname): # Generate and print the FitResults res = FitResults(recipe) - res.printResults() + res.print_results() # Plot! - plotResults(recipe) + recipe.plot_recipe(**plot_styles) # End of file diff --git a/docs/examples/simplerecipe.py b/docs/examples/simplerecipe.py index b065cbfb..0ff981f4 100644 --- a/docs/examples/simplerecipe.py +++ b/docs/examples/simplerecipe.py @@ -19,6 +19,8 @@ creation. """ +from pathlib import Path + from diffpy.srfit.fitbase import SimpleRecipe ###### @@ -32,12 +34,12 @@ def main(): recipe = SimpleRecipe() # Load text from file. - recipe.loadtxt("data/gaussian.dat") + recipe.loadtxt(Path(__file__).parent / "data/gaussian.dat") # Set the equation. The variable "x" is taken from the data that was just # loaded. The other variables, "A", "x0" and "sigma" are turned into # attributes with an initial value of 0. - recipe.setEquation("A * exp(-0.5*(x-x0)**2/sigma**2)") + recipe.set_equation("A * exp(-0.5*(x-x0)**2/sigma**2)") # We can give them other values here. recipe.A = 1 @@ -50,7 +52,7 @@ def main(): leastsq(recipe.residual, recipe.values) # Print the results - recipe.printResults() + recipe.print_results() return diff --git a/docs/examples/threedoublepeaks.py b/docs/examples/threedoublepeaks.py index c0aa2cc9..935aa715 100644 --- a/docs/examples/threedoublepeaks.py +++ b/docs/examples/threedoublepeaks.py @@ -16,6 +16,8 @@ from __future__ import print_function +from pathlib import Path + import numpy from diffpy.srfit.fitbase import ( @@ -30,7 +32,8 @@ def makeRecipe(): - """Make a FitRecipe for fitting three double-gaussian curves to data. + """Make a FitRecipe for fitting three double-gaussian curves to + data. The separation and amplitude ratio of the double peaks follows a specific relationship. The peaks are broadend according to their @@ -46,7 +49,9 @@ def makeRecipe(): # The Profile # Create a Profile to hold the experimental and calculated signal. profile = Profile() - x, y, dy = profile.loadtxt("data/threedoublepeaks.dat") + x, y, dy = profile.loadtxt( + Path(__file__).parent / "data/threedoublepeaks.dat" + ) # Create the contribution contribution = FitContribution("peaks") @@ -58,7 +63,7 @@ def makeRecipe(): def gaussian(t, mu, sig): return 1 / (2 * pi * sig**2) ** 0.5 * exp(-0.5 * ((t - mu) / sig) ** 2) - contribution.registerFunction(gaussian, name="peakshape") + contribution.register_function(gaussian, name="peakshape") def delta(t, mu): """Calculate a delta-function. @@ -69,15 +74,15 @@ def delta(t, mu): sig = t[1] - t[0] return gaussian(t, mu, sig) - contribution.registerFunction(delta) + contribution.register_function(delta) # Here is another one bkgdstr = "b0 + b1*t + b2*t**2 + b3*t**3 + b4*t**4 + b5*t**5 + b6*t**6" - contribution.registerStringFunction(bkgdstr, "bkgd") + contribution.register_string_function(bkgdstr, "bkgd") # Now define our fitting equation. We will hardcode the peak ratios. - contribution.setEquation( + contribution.set_equation( "A1 * ( convolve( delta(t, mu11), peakshape(t, c, sig11) ) \ + 0.23*convolve( delta(t, mu12), peakshape(t, c, sig12) ) ) + \ A2 * ( convolve( delta(t, mu21), peakshape(t, c, sig21) ) \ @@ -98,17 +103,17 @@ def delta(t, mu): # Here we tell the FitRecipe to use our FitContribution. When the FitRecipe # calculates its residual function, it will call on the FitContribution to # do part of the work. - recipe.addContribution(contribution) + recipe.add_contribution(contribution) # Vary the amplitudes for each double peak - recipe.addVar(contribution.A1, 100) - recipe.addVar(contribution.A2, 100) - recipe.addVar(contribution.A3, 100) + recipe.add_variable(contribution.A1, 100) + recipe.add_variable(contribution.A2, 100) + recipe.add_variable(contribution.A3, 100) # Vary the position of the first of the double peaks - recipe.addVar(contribution.mu11, 13.0) - recipe.addVar(contribution.mu21, 24.0) - recipe.addVar(contribution.mu31, 33.0) + recipe.add_variable(contribution.mu11, 13.0) + recipe.add_variable(contribution.mu21, 24.0) + recipe.add_variable(contribution.mu31, 33.0) # Constrain the position of the second double peak from numpy import arcsin, sin @@ -119,52 +124,52 @@ def peakloc(mu): l2 = 1.0 return 180 / pi * arcsin(pi / 180 * l2 * sin(mu) / l1) - recipe.registerFunction(peakloc) - recipe.constrain(contribution.mu12, "peakloc(mu11)") - recipe.constrain(contribution.mu22, "peakloc(mu21)") - recipe.constrain(contribution.mu32, "peakloc(mu31)") + recipe.register_function(peakloc) + recipe.add_constraint(contribution.mu12, "peakloc(mu11)") + recipe.add_constraint(contribution.mu22, "peakloc(mu21)") + recipe.add_constraint(contribution.mu32, "peakloc(mu31)") # Vary the width of the peaks. We know the functional form of the peak # broadening. - recipe.newVar("sig0", 0.001) - recipe.newVar("dsig", 4) + recipe.create_new_variable("sig0", 0.001) + recipe.create_new_variable("dsig", 4) def sig(sig0, dsig, mu): """Calculate the peak broadening with respect to position.""" return sig0 * (1 - dsig * mu**2) - recipe.registerFunction(sig) + recipe.register_function(sig) recipe.fix("mu") # Now constrain the peak widths to this recipe.sig0.value = 0.001 recipe.dsig.value = 4.0 - recipe.constrain(contribution.sig11, "sig(sig0, dsig, mu11)") - recipe.constrain( + recipe.add_constraint(contribution.sig11, "sig(sig0, dsig, mu11)") + recipe.add_constraint( contribution.sig12, "sig(sig0, dsig, mu12)", ns={"mu12": contribution.mu12}, ) - recipe.constrain(contribution.sig21, "sig(sig0, dsig, mu21)") - recipe.constrain( + recipe.add_constraint(contribution.sig21, "sig(sig0, dsig, mu21)") + recipe.add_constraint( contribution.sig22, "sig(sig0, dsig, mu22)", ns={"mu22": contribution.mu22}, ) - recipe.constrain(contribution.sig31, "sig(sig0, dsig, mu31)") - recipe.constrain( + recipe.add_constraint(contribution.sig31, "sig(sig0, dsig, mu31)") + recipe.add_constraint( contribution.sig32, "sig(sig0, dsig, mu32)", ns={"mu32": contribution.mu32}, ) # Also the background - recipe.addVar(contribution.b0, 0, tag="bkgd") - recipe.addVar(contribution.b1, 0, tag="bkgd") - recipe.addVar(contribution.b2, 0, tag="bkgd") - recipe.addVar(contribution.b3, 0, tag="bkgd") - recipe.addVar(contribution.b4, 0, tag="bkgd") - recipe.addVar(contribution.b5, 0, tag="bkgd") - recipe.addVar(contribution.b6, 0, tag="bkgd") + recipe.add_variable(contribution.b0, 0, tag="bkgd") + recipe.add_variable(contribution.b1, 0, tag="bkgd") + recipe.add_variable(contribution.b2, 0, tag="bkgd") + recipe.add_variable(contribution.b3, 0, tag="bkgd") + recipe.add_variable(contribution.b4, 0, tag="bkgd") + recipe.add_variable(contribution.b5, 0, tag="bkgd") + recipe.add_variable(contribution.b6, 0, tag="bkgd") return recipe @@ -178,39 +183,19 @@ def scipyOptimize(recipe): # We're going to use the least-squares (Levenberg-Marquardt) optimizer from # scipy. We simply have to give it the function to minimize # (recipe.residual) and the starting values of the Variables - # (recipe.getValues()). + # (recipe.get_values()). from scipy.optimize.minpack import leastsq print("Fit using scipy's LM optimizer") - leastsq(recipe.residual, recipe.getValues()) + leastsq(recipe.residual, recipe.get_values()) return -def plotResults(recipe): - """Plot the results contained within a refined FitRecipe.""" - # We can access the data and fit profile through the Profile we created - # above. We get to it through our FitContribution, which we named "g1". - # - # The independent variable. This is always under the "x" attribute. - x = recipe.peaks.profile.x - # The observed profile that we loaded earlier, the "y" attribute. - y = recipe.peaks.profile.y - # The calculated profile, the "ycalc" attribute. - ycalc = recipe.peaks.profile.ycalc - - # This stuff is specific to pylab from the matplotlib distribution. - import pylab - - pylab.plot(x, y, "b.", label="observed profile") - pylab.plot(x, ycalc, "r-", label="calculated profile") - pylab.plot(x, y - ycalc - 0.1 * max(y), "g-", label="difference") - pylab.legend(loc=(0.0, 0.8)) - pylab.xlabel("x") - pylab.ylabel("y") - - pylab.show() - return +plot_styles = { + "xlabel": "x", + "ylabel": "y", +} def steerFit(recipe): @@ -249,10 +234,10 @@ def steerFit(recipe): res = FitResults(recipe) # Print the results - res.printResults() + res.print_results() # Plot the results - plotResults(recipe) + recipe.plot_recipe(**plot_styles) # End of file diff --git a/docs/source/api/diffpy.srfit.rst b/docs/source/api/diffpy.srfit.rst index 8f840d0c..1e2925bf 100644 --- a/docs/source/api/diffpy.srfit.rst +++ b/docs/source/api/diffpy.srfit.rst @@ -1,7 +1,9 @@ :tocdepth: -1 -diffpy.srfit package -==================== +|title| +======= + +.. |title| replace:: diffpy.srfit package .. automodule:: diffpy.srfit :members: @@ -25,8 +27,10 @@ Subpackages Submodules ---------- -diffpy.srfit.exceptions module -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +|module| +-------- + +.. |module| replace:: diffpy.srfit.exceptions module .. automodule:: diffpy.srfit.exceptions :members: diff --git a/docs/source/conf.py b/docs/source/conf.py index da14b4ad..39187884 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -32,9 +32,7 @@ sys.path.insert(0, str(Path("../../src").resolve())) # abbreviations -ab_authors = ( - "Christopher Farrow, Pavol Juhas, and members of the Billinge Group" -) +ab_authors = "Christopher Farrow, Pavol Juhas, Caden Myers, Simon J. L. Billinge, and members of the Billinge Group." # -- General configuration ------------------------------------------------ @@ -86,7 +84,7 @@ # General information about the project. project = "diffpy.srfit" -copyright = "%Y, The Trustees of Columbia University in the City of New York" +copyright = "%Y, The DiffPy Team" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -317,7 +315,7 @@ "diffpy.srfit Documentation", ab_authors, "diffpy.srfit", - "Configurable code for solving atomic structures.", + "Generalized code base for modeling problems.", "Miscellaneous", ), ] diff --git a/docs/source/extending.rst b/docs/source/extending.rst index 6e3563de..8dc4313f 100644 --- a/docs/source/extending.rst +++ b/docs/source/extending.rst @@ -108,7 +108,7 @@ can be adapted as:: setter = SimpleAtom.set, attr = "x") Thus, when ``xpar.getValue()`` is called, it in turn calls -``SimpleAtom.get(atom, "x")``. ``xpar.setValue(value)`` calls +``SimpleAtom.get(atom, "x")``. ``xpar.set_value(value)`` calls ``SimpleAtom.set(atom, "x", value)``. If the attributes of an object cannot be accessed in one of these three ways, @@ -122,29 +122,65 @@ functions as in the second example. Extending Profile Parsers -------------------------- -The ``ProfileParser`` class is located in the ``diffpy.srfit.fitbase.parser`` -module. The purpose of this class is to read data and metadata from a file or -string and pass those data and metadata to a ``Profile`` instance. The -``Profile`` in turn will pass this information to a ``ProfileGenerator``. +The ``ProfileParser`` class is located in the +``diffpy.srfit.fitbase.profileparser`` module. The purpose of this class is to +read data and metadata from a file and pass those data and metadata to a +``Profile`` instance. The ``Profile`` in turn will pass this information to a +``ProfileGenerator``. -The simplest way to extend the ``ProfileParser`` is to derive a new class from -``ProfileParser`` and overload the ``parseString`` method. By default, the -``parseFile`` method can read an ASCII file and passes the loaded string to the -``parseString`` method. For non-ASCII data one should overload both of these -methods. An example of a customized ``ProfileParser`` is the ``PDFParser`` -class in the ``diffpy.srfit.pdf.pdfparser`` module. +``parse_file`` is a template method that a subclass is not expected to +override. It resets the parser, calls the two hooks described below, records +the file name and selects the first bank. A format is customized by overriding +one or both hooks: -Here is a simple example demonstrating how to extract (x,y) data from a -two-column string. :: +``_parse_metadata(filename)`` + Return the metadata read from the header, as a dictionary. The default + implementation uses ``load_data`` from ``diffpy.utils.parsers`` to collect + plain ``name = value`` pairs. - def parseString(self, datastring): +``_parse_data(filename, column_format=None, **kwargs)`` + Append one entry to ``self._banks`` for each data set in the file. The + default implementation reads a single bank with ``load_data`` and maps its + columns onto ``x``, ``y``, ``dx`` and ``dy``. + +The ``PDFParser`` class in the ``diffpy.srfit.pdf.pdfparser`` module is a +``ProfileParser`` subclass for PDFgetX and PDFgetN data. PDFgetX and PDFgetN +write their header as plain ``name = value`` pairs, including ``stype``, +``qmin``, ``qmax`` and other PDF specific values, so ``PDFParser`` inherits +both ``_parse_metadata`` and ``_parse_data`` unchanged and only sets +``_format`` to identify the data as PDF data. + +Here is a simple example demonstrating how to read metadata that is stored as +``name: value`` pairs rather than the default ``name = value``. :: + + def _parse_metadata(self, filename): + + meta = {} + + for line in Path(filename).read_text().splitlines(): + if not line.startswith("#"): + break + name, sep, value = line.lstrip("# ").partition(":") + if sep: + meta[name.strip()] = value.strip() + + return meta + +The parser can put any information into the returned dictionary; it is merged +into the ``_meta`` attribute. It is up to a ``ProfileGenerator`` that may use +the parsed data to define and retrieve usable metadata. + +A format whose data block is not a plain matrix of columns overrides +``_parse_data`` instead. :: + + def _parse_data(self, filename, column_format=None, **kwargs): xvals = [] yvals = [] dxvals = None dyvals = None - for line in datastring.splitlines(): + for line in Path(filename).read_text().splitlines(): sline = line.split() x, y = map(float, sline) @@ -158,16 +194,15 @@ The ``self._banks.append`` line puts the data arrays into the ``_banks`` list. This list is for collecting multiple data sets that may be present within a single file. The ``dxvals`` and ``dyvals`` are the uncertainty values on the ``xvals`` and ``yvals``. In this simple example they are not present, and so -are set to None. - -In general, the data string may contain metadata. The ``ProfileParser`` has a -dictionary attribute named ``_meta``. The parser can put any information into -this dictionary. It is up to a ``ProfileGenerator`` that may use the parsed -data to define and retrieve usable metadata. +are set to None. A ``Profile`` reads that as an unweighted data set. If the data are not in a form that can be stored in a ``Profile`` then it is the responsibility of the parser to convert this data to a usable form. +Parsers read from a file, not from a string. The older ``parseString`` method +has been removed, and ``parseFile`` is deprecated: it now delegates to +``parse_file`` and will be removed in version 4.0.0. + Extending Profiles -------------------------- @@ -185,7 +220,7 @@ customized Profile is the ``SASProfile`` class in the The ``__init__`` method sets the ``xobs``, ``yobs`` and ``dyobs`` attributes of the ``SASProfile`` to the associated arrays within the ``_datainfo`` attribute. -The ``setObservedProfile`` method is overloaded to modify the ``_datainfo`` +The ``set_observed_profile`` method is overloaded to modify the ``_datainfo`` arrays when their corresponding attributes are modified. This keeps the arrays in sync. @@ -263,5 +298,5 @@ overload. * .. automethod:: FitHook.postcall To use a custom ``FitHook``, assign an instance to a ``FitRecipe`` using the -``pushFitHook`` method. All ``FitHook`` instances held by a ``FitRecipe`` will +``push_fit_hook`` method. All ``FitHook`` instances held by a ``FitRecipe`` will be used in sequence during a call to ``FitRecipe.residual``. diff --git a/docs/source/index.rst b/docs/source/index.rst index ea21479d..18a64a76 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,14 +1,20 @@ -.. _developers-guide-index: +####### +|title| +####### -#################################################### -diffpy.srfit documentation -#################################################### +.. |title| replace:: diffpy.srfit documentation -diffpy.srfit - configurable code for solving atomic structures. +``diffpy.srfit`` - Generalized code base for modeling problems. -| Software version |release|. +| Software version |release| | Last updated |today|. +=============== +Getting started +=============== + +Welcome to the ``diffpy.srfit`` documentation! + The diffpy.srfit package provides the framework for building a global optimizer on the fly from components such as function calculators (that calculate different data spectra), regression algorithms and structure models. The @@ -31,27 +37,33 @@ obtain the total cost function. Additionally, diffpy.srfit is designed to be extensible, allowing the user to integrate external calculators to perform co-refinements with other techniques. -======================================== +======= Authors -======================================== +======= diffpy.srfit is developed by members of the Billinge Group at -Columbia University and at Brookhaven National Laboratory including +the University of California, Santa Barbara, Columbia University and at Brookhaven National Laboratory including Christopher L. Farrow, Pavol Juhás, Simon J.L. Billinge. The source code in *observable.py* was derived from the 1.0 version of the Caltech "Pyre" project. - -For a detailed list of contributors see +``diffpy.srfit`` is developed by Christopher Farrow, Pavol Juhas, Caden Myers, Simon J. L. Billinge, and members of the DiffPy community. +This project is maintained by Caden Myers and Simon J. L. Billinge. For a detailed list of contributors see https://github.com/diffpy/diffpy.srfit/graphs/contributors. -====================================== +============ Installation -====================================== +============ -See the `README `_ +See the `README `_ file included with the distribution. +================ +Acknowledgements +================ + +``diffpy.srfit`` is built and maintained with `scikit-package `_. + ====================================== Where next? ====================================== @@ -62,22 +74,22 @@ Where next? examples.rst extending.rst -====================================== +================= Table of contents -====================================== - +================= .. toctree:: :titlesonly: + getting-started + Package API license release - Package API .. faq.rst -====================================== +======= Indices -====================================== +======= * :ref:`genindex` * :ref:`modindex` diff --git a/docs/source/license.rst b/docs/source/license.rst index 7d78e94f..748b63bf 100644 --- a/docs/source/license.rst +++ b/docs/source/license.rst @@ -1,3 +1,5 @@ +:tocdepth: -1 + .. index:: license License @@ -12,10 +14,11 @@ OPEN SOURCE LICENSE AGREEMENT Lawrence Berkeley National Laboratory | Copyright (c) 2014, Australian Synchrotron Research Program Inc., ("ASRP") | Copyright (c) 2006-2007, Board of Trustees of Michigan State University -| Copyright (c) 2008-2012, The Trustees of Columbia University in - the City of New York | Copyright (c) 2014-2019, Brookhaven Science Associates, Brookhaven National Laboratory +| Copyright (c) 2008-2025, The Trustees of Columbia University in + the City of New York +| Copyright (c) 2026-present, The DiffPy Team. The "DiffPy-CMI" is distributed subject to the following license conditions: diff --git a/news/addcontrib-dep.rst b/news/addcontrib-dep.rst new file mode 100644 index 00000000..8a8cf420 --- /dev/null +++ b/news/addcontrib-dep.rst @@ -0,0 +1,23 @@ +**Added:** + +* Added ``add_contribution`` in replace of ``addContribution``. + +**Changed:** + +* + +**Deprecated:** + +* Deprecated ``addContribution`` for removal in 4.0.0. + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/news/addparameterset-dep.rst b/news/addparameterset-dep.rst new file mode 100644 index 00000000..f69da3c2 --- /dev/null +++ b/news/addparameterset-dep.rst @@ -0,0 +1,25 @@ +**Added:** + +* Added ``add_parameter_set`` method to replace deprecated ``FitRecipe.addParameterSet``. +* Added ``add_parameter_set`` method to replace deprecated ``ParameterSet.addParameterSet``. + +**Changed:** + +* + +**Deprecated:** + +* Deprecated ``addParameterSet`` method in ``FitRecipe``. +* Deprecate ``addParameterSet`` method in ``ParameterSet``. + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/news/addprofilegenerator-dep.rst b/news/addprofilegenerator-dep.rst new file mode 100644 index 00000000..3a281ff7 --- /dev/null +++ b/news/addprofilegenerator-dep.rst @@ -0,0 +1,23 @@ +**Added:** + +* Added ``add_profile_generator`` to replace deprecated function. + +**Changed:** + +* + +**Deprecated:** + +* Deprecated ``addProfileGenerator`` for removal in 4.0.0. + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/news/cf-non-physical-input.rst b/news/cf-non-physical-input.rst new file mode 100644 index 00000000..a176945f --- /dev/null +++ b/news/cf-non-physical-input.rst @@ -0,0 +1,27 @@ +**Added:** + +* + +**Changed:** + +* Change the current characteristic functions in ``diffpy.srfit.pdf.characteristicfunctions`` (``spherical_particle``, ``spheroidal_particle``, ``lognormal_spherical_particle``, ``sheet_particle`` and ``shell_particle``) to emit a ``RuntimeWarning`` when a non-physical shape parameter makes them return zero, since a zero return flattens the fit residual and stalls a refinement without any other sign to the user. The warning is issued once per process for each distinct problem so a refinement loop does not repeat it. The deprecated camel-case functions forward to these functions directly, so they emit the same warning and return the same result for non-physical input. +* Change ``lognormal_spherical_particle`` in ``diffpy.srfit.pdf.characteristicfunctions`` to return zero for a negative ``particle_diameter_sigma`` instead of silently returning ``spherical_particle``. A ``particle_diameter_sigma`` of zero is still the sphere limit. +* Change ``sheet_particle`` in ``diffpy.srfit.pdf.characteristicfunctions`` to return an array of zeros for a non-positive ``sheet_thickness`` when ``r`` is an array, instead of a scalar zero. + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* Fix ``shell_particle`` in ``diffpy.srfit.pdf.characteristicfunctions`` returning a smoothly varying branch of unphysical values, some below negative one, for a negative ``thickness`` or ``radius``, which a refinement could mistake for a real solution. It now returns zero. +* Fix ``shell_particle`` in ``diffpy.srfit.pdf.characteristicfunctions`` returning one at every ``r`` for a zero ``thickness``, where the normalizing denominator vanishes. It now returns zero. +* Fix ``spheroidal_particle`` in ``diffpy.srfit.pdf.characteristicfunctions`` raising ``ZeroDivisionError`` for a zero ``equatorial_radius``. It now returns zero. + +**Security:** + +* diff --git a/news/characteristicfunctions-dep.rst b/news/characteristicfunctions-dep.rst new file mode 100644 index 00000000..fe5e7f2c --- /dev/null +++ b/news/characteristicfunctions-dep.rst @@ -0,0 +1,25 @@ +**Added:** + +* Add ``spherical_particle``, ``spheroidal_particle``, ``lognormal_spherical_particle``, ``sheet_particle``, and ``shell_particle`` to ``diffpy.srfit.pdf.characteristicfunctions``, replacing ``sphericalCF``, ``spheroidalCF``, ``lognormalSphericalCF``, ``sheetCF``, and ``shellCF``. +* Add ``constrain_as_space_group`` to ``diffpy.srfit.structure.sgconstraints``, replacing ``constrainAsSpaceGroup``. + +**Changed:** + +* Change ``spheroidalCF2`` and ``shellCF2`` in ``diffpy.srfit.pdf.characteristicfunctions`` to be deprecated aliases that convert their arguments and call ``spheroidal_particle`` and ``shell_particle``, instead of separate implementations. + +**Deprecated:** + +* Deprecate ``sphericalCF``, ``spheroidalCF``, ``spheroidalCF2``, ``lognormalSphericalCF``, ``sheetCF``, ``shellCF``, and ``shellCF2`` in ``diffpy.srfit.pdf.characteristicfunctions``. +* Deprecate ``constrainAsSpaceGroup`` in ``diffpy.srfit.structure.sgconstraints``. + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/news/fitcontrib-dep-final.rst b/news/fitcontrib-dep-final.rst new file mode 100644 index 00000000..44a3b787 --- /dev/null +++ b/news/fitcontrib-dep-final.rst @@ -0,0 +1,25 @@ +**Added:** + +* Added ``get_residual_equation`` method to ``FitContribution``. +* Added ``set_residual_equation`` method to ``FitContribution``. + +**Changed:** + +* + +**Deprecated:** + +* Deprecated ``getResidualEquation`` method of ``FitContribution`` for removal in 4.0.0. +* Deprecated ``setResidualEquation`` method of ``FitContribution`` for removal in 4.0.0. + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/news/fitcontrib-dep.rst b/news/fitcontrib-dep.rst new file mode 100644 index 00000000..cedaa94e --- /dev/null +++ b/news/fitcontrib-dep.rst @@ -0,0 +1,25 @@ +**Added:** + +* Added ``get_equation`` method to ``BaseBuilder``. +* Added ``get_equation`` method to ``FitContribution``. + +**Changed:** + +* + +**Deprecated:** + +* Deprecated ``BaseBuilder.getEquation`` method for removal in 4.0.0. +* Deprecated ``FitContributution.getEquation`` method for removal in 4.0.0. + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/news/fithook-and-weights-dep.rst b/news/fithook-and-weights-dep.rst new file mode 100644 index 00000000..43157e5a --- /dev/null +++ b/news/fithook-and-weights-dep.rst @@ -0,0 +1,31 @@ +**Added:** + +* Added ``set_weight`` method to ``FitRecipe`` to replace ``setWeight``. +* Added ``get_fit_hooks`` method to ``FitRecipe`` to replace ``getFitHooks``. +* Added ``clear_fit_hooks`` method to ``FitRecipe`` to replace ``clearFitHooks``. +* Added ``pop_fit_hook`` method to ``FitRecipe`` to replace ``popFitHook``. +* Added ``push_fit_hook`` method to ``FitRecipe`` to replace ``pushFitHook``. + +**Changed:** + +* + +**Deprecated:** + +* Deprecated ``setWeight`` in ``FitRecipe`` for removal in 4.0.0. +* Deprecated ``getFitHooks`` in ``FitRecipe`` for removal in 4.0.0. +* Deprecated ``clearFitHooks`` in ``FitRecipe`` for removal in 4.0.0. +* Deprecated ``popFitHook`` in ``FitRecipe`` for removal in 4.0.0. +* Deprecated ``pushFitHook`` in ``FitRecipe`` for removal in 4.0.0. + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/news/fitrecipe-dep.rst b/news/fitrecipe-dep.rst new file mode 100644 index 00000000..fbf04526 --- /dev/null +++ b/news/fitrecipe-dep.rst @@ -0,0 +1,32 @@ +**Added:** + +* Added ``create_new_variable`` method to ``FitRecipe``. +* Added ``delete_variable`` method to ``FitRecipe``. +* Added ``add_variable`` method to ``FitRecipe``. +* Added ``scalar_residual`` method to ``FitRecipe``. +* Added ``remove_parameter_set`` method to ``FitRecipe``. +* Added test for ``remove_parameter_set`` method in ``FitRecipe``. + +**Changed:** + +* + +**Deprecated:** + +* Deprecated ``newVar`` method for removal in 4.0.0. +* Deprecated ``delVar`` method for removal in 4.0.0. +* Deprecated ``addVar`` method for removal in 4.0.0. +* Deprecated ``scalarResidual`` method for removal in 4.0.0. +* Deprecated ``removeParameterSet`` method for removal in 4.0.0. + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/news/fitrecipe2-dep.rst b/news/fitrecipe2-dep.rst new file mode 100644 index 00000000..0b7d9e3a --- /dev/null +++ b/news/fitrecipe2-dep.rst @@ -0,0 +1,33 @@ +**Added:** + +* Added ``convert_bounds_to_restraints`` method to ``FitRecipe``. +* Added ``get_bounds_pairs`` method to ``FitRecipe``. +* Added ``get_bounds_array`` method to ``FitRecipe``. +* Added ``get_names`` method to ``FitRecipe`` and ``RecipeContainer``. +* Added ``get_values`` method to ``FitRecipe`` and ``RecipeContainer``. +* Added ``is_free`` method to ``FitRecipe``. + +**Changed:** + +* + +**Deprecated:** + +* Deprecated ``boundsToRestraints`` method for removal in 4.0.0. +* Deprecated ``getBounds`` method for removal in 4.0.0. +* Deprecated ``getBounds2`` method for removal in 4.0.0. +* Deprecated ``getNames`` method for removal in 4.0.0. +* Deprecated ``getValues`` method for removal in 4.0.0. +* Deprecated ``isFree`` method for removal in 4.0.0. + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/news/fitresults-dep.rst b/news/fitresults-dep.rst new file mode 100644 index 00000000..2ee8a8a3 --- /dev/null +++ b/news/fitresults-dep.rst @@ -0,0 +1,27 @@ +**Added:** + +* Added ``save_results`` method to ``FitResults``. +* Added ``print_results`` method to ``FitResults``. +* Added ``get_results_string`` method to ``FitResults``. + +**Changed:** + +* + +**Deprecated:** + +* Deprecated ``saveResults`` method for removal in 4.0.0. +* Deprecated ``printResults`` method for removal in 4.0.0. +* Deprecated ``formatResults`` method for removal in 4.0.0. + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/news/init-w-recipe.rst b/news/init-w-recipe.rst new file mode 100644 index 00000000..073926e7 --- /dev/null +++ b/news/init-w-recipe.rst @@ -0,0 +1,23 @@ +**Added:** + +* Added initialize_recipe_from_recipe to ``FitRecipe``. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/news/init-w-results.rst b/news/init-w-results.rst new file mode 100644 index 00000000..ae63a94a --- /dev/null +++ b/news/init-w-results.rst @@ -0,0 +1,23 @@ +**Added:** + +* Added ``initialize_recipe_with_results`` to ``FitRecipe``. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/news/iterpar-behavior.rst b/news/iterpar-behavior.rst new file mode 100644 index 00000000..556a91c6 --- /dev/null +++ b/news/iterpar-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/news/np-docstrings.rst b/news/np-docstrings.rst new file mode 100644 index 00000000..a123ce9e --- /dev/null +++ b/news/np-docstrings.rst @@ -0,0 +1,23 @@ +**Added:** + +* No news needed: converted docstrings in `fitbase/` and `pdf/` to NumPy style + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/news/param-dep.rst b/news/param-dep.rst new file mode 100644 index 00000000..e0b8eabe --- /dev/null +++ b/news/param-dep.rst @@ -0,0 +1,27 @@ +**Added:** + +* Added ``is_constant`` method to ``Parameter``. +* Added ``bound_range`` method to ``Parameter``. +* Added ``bound_window`` method to ``Parameter``. + +**Changed:** + +* + +**Deprecated:** + +* Deprecated ``isConst`` method of ``Parameter``. Use ``is_constant`` instead. +* Deprecated ``boundRange`` method of ``Parameter``. Use ``bound_range`` instead. +* Deprecated ``boundWindow`` method of ``Parameter``. Use ``bound_window`` instead. + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/news/profile-dep.rst b/news/profile-dep.rst new file mode 100644 index 00000000..48424bcd --- /dev/null +++ b/news/profile-dep.rst @@ -0,0 +1,29 @@ +**Added:** + +* Added ``load_parsed_data`` in replace of ``loadParsedData`` in ``Profile`` and ``SimpleRecipe``. +* Added ``set_observed_profile`` in replace of ``setObservedProfile`` in ``Profile`` and ``SimpleRecipe``. +* Added ``set_calculation_range`` in replace of ``setCalculationRange`` in ``Profile`` and ``SimpleRecipe``. +* Added ``set_calculation_points`` in replace of ``setCalculationPoints`` in ``Profile`` and ``SimpleRecipe``. + +**Changed:** + +* + +**Deprecated:** + +* Deprecated ``loadParsedData`` in ``Profile`` and ``SimpleRecipe`` for removal in 4.0.0. +* Deprecated ``setObservedProfile`` in ``Profile`` and ``SimpleRecipe`` for removal in 4.0.0. +* Deprecated ``setCalculationRange`` in ``Profile`` and ``SimpleRecipe`` for removal in 4.0.0. +* Deprecated ``setCalculationPoints`` in ``Profile`` and ``SimpleRecipe`` for removal in 4.0.0. + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/news/profileparser-uncertainty-none.rst b/news/profileparser-uncertainty-none.rst new file mode 100644 index 00000000..f408ceb6 --- /dev/null +++ b/news/profileparser-uncertainty-none.rst @@ -0,0 +1,26 @@ +**Added:** + +* + +**Changed:** + +* Change ``ProfileParser.parse_file`` to set unavailable ``dx``/``dy`` uncertainties to ``None`` instead of ``0``, matching the format-specific parsers. +* Change ``Profile.set_observed_profile`` to leave ``dyobs`` as ``None`` when no uncertainties are observed, instead of silently replacing them with an array of ones. The calculated ``dy`` still defaults to 1 at every calculation point, so unweighted fits refine exactly as before. +* Change ``Profile._validate`` to accept a ``None`` ``dyobs``, since observed uncertainties are optional. ``x``, ``y``, ``dy``, ``xobs`` and ``yobs`` are still required. + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* Fix ``ProfileParser.parse_file`` producing zero-valued uncertainties for absent or invalid ``dx``/``dy`` columns, which gave non-finite residuals for any file without usable uncertainties instead of falling back to an unweighted fit. +* Fix ``Profile.dyobs`` reporting an array of ones for data that carries no uncertainties, which made an unweighted profile indistinguishable from one whose uncertainties were genuinely all 1. + +**Security:** + +* diff --git a/news/profileparser_dep.rst b/news/profileparser_dep.rst new file mode 100644 index 00000000..4030e12f --- /dev/null +++ b/news/profileparser_dep.rst @@ -0,0 +1,34 @@ +**Added:** + +* Add ``parse_file`` method to ``ProfileParser`` to parse a file directly with ``load_data`` from ``diffpy.utils``. +* Add ``_parse_metadata`` and ``_parse_data`` hooks to ``ProfileParser``, which subclasses override to support a new data format. ``parse_file`` is a template method that calls them. +* Add ``get_num_banks`` method to ``ProfileParser`` to replace ``getNumBanks``. +* Add ``select_bank`` method to ``ProfileParser`` to replace ``selectBank``. +* Add ``get_format`` method to ``ProfileParser`` to replace ``getFormat``. +* Add ``get_data`` method to ``ProfileParser`` to replace ``getData``. +* Add ``get_metadata`` method to ``ProfileParser`` to replace ``getMetaData``. + +**Changed:** + +* Change ``PDFParser`` to inherit ``_parse_metadata`` and ``_parse_data`` unchanged from ``ProfileParser``, since PDFgetX and PDFgetN headers are now plain ``name = value`` pairs. ``PDFParser.parse_file`` returns the same data and metadata that ``PDFParser.parseFile`` did. +* Change ``ProfileParser.parse_file`` to accept the keyword arguments of ``load_data``, such as ``usecols``, ``delimiter`` and ``comments``. Use ``usecols`` to select columns from a file with more than four of them. +* Change ``PDFContribution.loadData`` to take a file name only. +* Change the headers of the ``si-q27r60-xray.gr`` and ``ni-q27r100-neutron.gr`` test files to the modern ``diffpy.pdfgetx`` and xPDFsuite configuration formats, replacing the 2008-era PDFgetX2/PDFgetN headers. The data values are unchanged. + +**Deprecated:** + +* Deprecate ``getNumBanks``, ``selectBank``, ``getFormat``, ``getData``, and ``getMetaData`` in ``ProfileParser``. +* Deprecate ``ProfileParser.parseFile``, which now delegates to ``parse_file``. Use ``parse_file`` instead. + +**Removed:** + +* Remove ``parseString`` from ``ProfileParser``, and ``PDFParser``. Parsers read from a file, so a format is now supported by overriding the ``_parse_metadata`` and ``_parse_data`` hooks. +* Remove support for passing a data string or an open file object to ``PDFContribution.loadData``. Pass a file name instead. + +**Fixed:** + +* Fix ``PDFParser.parse_file`` and ``PDFContribution.loadData`` discarding PDF metadata. They parsed PDFgetX and PDFgetN headers as generic ``name = value`` pairs, which dropped ``stype``, ``qmin``, ``qmax``, ``qdamp`` and ``qbroad``, so ``PDFGenerator`` silently fell back to its default scattering type and Q range. + +**Security:** + +* diff --git a/news/recipeorg-dep1.rst b/news/recipeorg-dep1.rst new file mode 100644 index 00000000..aff30369 --- /dev/null +++ b/news/recipeorg-dep1.rst @@ -0,0 +1,37 @@ +**Added:** + +* Added ``iterate_over_parameters`` method. +* Added ``register_calculator`` method. +* Added ``register_function`` method. +* Added ``register_string_function`` method. +* Added ``evaluate_equation`` method. +* Added ``is_constrained`` method. +* Added ``get_constrained_parameters`` method. +* Added ``clear_all_constraints`` method. + +**Changed:** + +* + +**Deprecated:** + +* Deprecated ``iterPars`` method. Use ``iterate_over_parameters`` instead. +* Deprecated ``registerCalculator`` method. Use ``register_calculator`` instead. +* Deprecated ``registerFunction`` method. Use ``register_function`` instead. +* Deprecated ``registerStringFunction`` method. Use ``register_string_function`` instead. +* Deprecated ``evaluateEquation`` method. Use ``evaluate_equation`` instead. +* Deprecated ``isConstrained`` method. Use ``is_constrained`` instead. +* Deprecated ``getConstrainedPars`` method. Use ``get_constrained_parameters`` instead. +* Deprecated ``clearConstraints`` method. Use ``clear_all_constraints`` instead. + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/news/recipeorg-dep2.rst b/news/recipeorg-dep2.rst new file mode 100644 index 00000000..bcd8a3b0 --- /dev/null +++ b/news/recipeorg-dep2.rst @@ -0,0 +1,35 @@ +**Added:** + +* Added ``add_constraint`` method to ``RecipeOrganizer``. +* Added ``remove_constraint`` method to ``RecipeOrganizer``. +* Added ``add_soft_bounds`` method to ``RecipeOrganizer``. +* Added ``remove_soft_bounds`` method to ``RecipeOrganizer``. +* Added ``register_soft_bounds`` method to ``RecipeOrganizer``. +* Added ``clear_all_soft_bounds`` method to ``RecipeOrganizer``. +* Added ``get_equation_from_string`` method to ``RecipeOrganizer``. + +**Changed:** + +* + +**Deprecated:** + +* Deprecated ``constrain`` method of ``RecipeOrganizer``. Use ``add_constraint`` instead. +* Deprecated ``unconstrain`` method of ``RecipeOrganizer``. Use ``remove_constraint`` instead. +* Deprecated ``restrain`` method of ``RecipeOrganizer``. Use ``add_soft_bounds`` instead. +* Deprecated ``unrestrain`` methods of ``RecipeOrganizer``. Use ``remove_soft_bounds`` instead. +* Deprecated ``addRestraint`` method of ``RecipeOrganizer``. Use ``register_soft_bounds`` instead. +* Deprecate ``clearRestraints`` method of ``RecipeOrganizer``. Use ``clear_all_soft_bounds`` instead. +* Deprecated ``equationFromString`` method of ``RecipeOrganizer``. Use ``get_equation_from_string`` instead. + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/news/res-dict.rst b/news/res-dict.rst new file mode 100644 index 00000000..6ebe11fc --- /dev/null +++ b/news/res-dict.rst @@ -0,0 +1,23 @@ +**Added:** + +* Added ``FitResults.get_results_dictionary`` in replace of ``resultsDictionary``. + +**Changed:** + +* + +**Deprecated:** + +* Deprecated ``resultsDictionary`` for removal in 4.0.0. + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/news/scikitv3.3.0.rst b/news/scikitv3.3.0.rst new file mode 100644 index 00000000..6ba0f7c5 --- /dev/null +++ b/news/scikitv3.3.0.rst @@ -0,0 +1,23 @@ +**Added:** + +* + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* Updated project to the latest scikit-package template + +**Security:** + +* diff --git a/news/setequation-dep.rst b/news/setequation-dep.rst new file mode 100644 index 00000000..cb6fe13b --- /dev/null +++ b/news/setequation-dep.rst @@ -0,0 +1,23 @@ +**Added:** + +* Added ``set_equation`` method to replace ``setEquation``. + +**Changed:** + +* + +**Deprecated:** + +* Deprecated ``setEquation`` for removal in 4.0.0. + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/news/setvalue-dep.rst b/news/setvalue-dep.rst new file mode 100644 index 00000000..83121f06 --- /dev/null +++ b/news/setvalue-dep.rst @@ -0,0 +1,23 @@ +**Added:** + +* Added ``set_value`` method to ``diffpy.srfit.fitbase.Parameter``. + +**Changed:** + +* + +**Deprecated:** + +* Deprecated ``setValue`` method in ``diffpy.srfit.fitbase.Parameter`` for removal in 4.0.0. + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/news/sync-w-main-v1.rst b/news/sync-w-main-v1.rst new file mode 100644 index 00000000..4aa5933c --- /dev/null +++ b/news/sync-w-main-v1.rst @@ -0,0 +1,23 @@ +**Added:** + +* No news needed: refactoring of pytest fixtures and merging with main branch. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/news/sync-w-main-v2.rst b/news/sync-w-main-v2.rst new file mode 100644 index 00000000..b4e54734 --- /dev/null +++ b/news/sync-w-main-v2.rst @@ -0,0 +1,23 @@ +**Added:** + +* No news needed: merge main into v3.3.0. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/news/update030.rst b/news/update030.rst new file mode 100644 index 00000000..3ce80500 --- /dev/null +++ b/news/update030.rst @@ -0,0 +1,23 @@ +**Added:** + +* + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* Update codebase to scikit-package 0.3.0 standards. + +**Security:** + +* diff --git a/pyproject.toml b/pyproject.toml index fdf15084..622b574d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,13 +6,17 @@ build-backend = "setuptools.build_meta" name = "diffpy.srfit" dynamic=['version', 'dependencies'] authors = [ - { name="Simon Billinge", email="sb2896@columbia.edu" }, + {name='Christopher Farrow', email='farrowch@gmail.com'}, + {name='Pavol Juhas', email='pavol.juhas@gmail.com'}, + {name='Caden Myers', email='cjm2304@columbia.edu'}, + {name='Simon J. L. Billinge', email='sbillinge@ucsb.edu'}, ] maintainers = [ - { name="Simon Billinge", email="sb2896@columbia.edu" }, + {name='Caden Myers', email='cjm2304@columbia.edu'}, + {name='Simon J. L. Billinge', email='sbillinge@ucsb.edu'}, ] -description = "Configurable code for solving atomic structures." -keywords = ['regression', 'modelling', 'fitting', 'diffraction', 'PDF'] +description = "Generalized code base for modeling problems." +keywords = ['regression', 'modeling', 'fitting', 'diffraction', 'PDF'] readme = "README.rst" requires-python = ">=3.12, <3.15" classifiers = [ @@ -48,6 +52,9 @@ include = ["*"] # package names should match these glob patterns (["*"] by defa exclude = [] # exclude packages matching these glob patterns (empty by default) namespaces = false # to disable scanning PEP 420 namespaces (true by default) +[project.scripts] +diffpy-srfit = "diffpy.srfit.app:main" + [tool.setuptools.dynamic] dependencies = {file = ["requirements/pip.txt"]} @@ -56,6 +63,11 @@ exclude-file = ".codespell/ignore_lines.txt" ignore-words = ".codespell/ignore_words.txt" skip = "*.cif,*.dat" +[tool.docformatter] +recursive = true +wrap-summaries = 72 +wrap-descriptions = 72 + [tool.black] line-length = 79 include = '\.pyi?$' diff --git a/src/diffpy/__init__.py b/src/diffpy/__init__.py index 9b1379de..dc504884 100644 --- a/src/diffpy/__init__.py +++ b/src/diffpy/__init__.py @@ -4,8 +4,10 @@ # (c) 2008-2025 The Trustees of Columbia University in the City of New York. # All rights reserved. # -# File coded by: Chris Farrow and Billinge Group members and community -# contributors. +# (c) 2025-present The DiffPy Team. +# +# File coded by: Christopher Farrow, Pavol Juhas, Caden Myers, +# Simon J. L. Billinge, Billinge Group members and community contributors. # # See GitHub contributions for a more detailed list of contributors. # https://github.com/diffpy/diffpy.srfit/graphs/contributors diff --git a/src/diffpy/srfit/__init__.py b/src/diffpy/srfit/__init__.py index 0374fdcf..7a70bbf2 100644 --- a/src/diffpy/srfit/__init__.py +++ b/src/diffpy/srfit/__init__.py @@ -3,9 +3,10 @@ # # (c) 2008-2025 The Trustees of Columbia University in the City of New York. # All rights reserved. +# (c) 2026-present The DiffPy Team. All rights reserved. # -# File coded by: Christopher Farrow, Pavol Juhas, and members of the -# Billinge Group. +# File coded by: Christopher Farrow, Pavol Juhas, Caden Myers, +# Simon J. L. Billinge, Billinge Group members and community contributors. # # See GitHub contributions for a more detailed list of contributors. # https://github.com/diffpy/diffpy.srfit/graphs/contributors @@ -13,27 +14,10 @@ # See LICENSE.rst for license information. # ############################################################################## -"""Complex modeling framework for structure refinement and solution. - -SrFit is a tool for coherently combining known information about a -material to derive other properties, in particular material structure. -SrFit allows the customization and creation of structure -representations, profile calculators, constraints, restraints and file -input parsers. The customized pieces can be glued together within SrFit -to optimize a structure, or other physically relevant information from -one or more experimental profiles. Other known information about the -system of interest can be included with arbitrarily complex constraints -and restraints. In this way, the end user creates a customized fitting -application that suits the problem to the available information. - -The subpackages herein define various pieces of the SrFit framework. -Developers are encouraged to work through the examples described in the -documentation to learn how to use and customize the various parts of -SrFit. -""" +"""Generalized code base for modeling problems.""" # package version -from diffpy.srfit.version import __version__ +from diffpy.srfit.version import __version__ # noqa # silence the pyflakes syntax checker assert __version__ or True diff --git a/src/diffpy/srfit/equation/builder.py b/src/diffpy/srfit/equation/builder.py index 23dca701..1bc187ec 100644 --- a/src/diffpy/srfit/equation/builder.py +++ b/src/diffpy/srfit/equation/builder.py @@ -53,12 +53,12 @@ > # sin is defined in this module as an OperatorBuilder > sin = getBuilder("sin") > beq = A*sin(a*x) -> eq = beq.getEquation() +> eq = beq.get_equation() The equation builder can also handle scalar constants. Staring with the above setup: > beq2 = A*sin(a*x) + 3 -> eq2 = beq2.getEquation() +> eq2 = beq2.get_equation() Here, we didn't have to wrap '3' in an ArgumentBuilder. Non scalars, constant or otherwise, must be wrapped as ArgumentBuilders in order to be used in this way. @@ -87,6 +87,7 @@ import diffpy.srfit.equation.literals as literals from diffpy.srfit.equation.equationmod import Equation from diffpy.srfit.equation.literals.literal import Literal +from diffpy.utils._deprecator import build_deprecation_message, deprecated __all__ = [ "EquationFactory", @@ -106,6 +107,16 @@ _builders = {} +EquationFactory_base = "diffpy.srfit.equation.builder.EquationFactory" +removal_version = "4.0.0" + +registerFunction_dep_msg = build_deprecation_message( + EquationFactory_base, + "registerFunction", + "register_function", + removal_version, +) + class EquationFactory(object): """A Factory for equations. @@ -173,7 +184,7 @@ def makeEquation( lit = literals.Argument(value=beq, const=True) eq = Equation(name="", root=lit) else: - eq = beq.getEquation() + eq = beq.get_equation() self.equations.add(eq) return eq @@ -208,23 +219,26 @@ def registerOperator(self, name, op): opbuilder = wrapOperator(name, op) return self.registerBuilder(name, opbuilder) - def registerFunction(self, name, func, argnames): + def register_function(self, name, func, argnames): """Register a named function with the factory. This will register a builder for the function. - Attributes + Parameters ---------- - name + name : str The name of the function - func - A callable python object - argnames + func : callable + The callable python object + argnames : list of str The argument names for func. If these names do not correspond to builders, then new constants with value 0 will be created for each name. - Returns the registered builder. + Returns + ------- + registered_builder : OperatorBuilder + The registered builder. """ for n in argnames: if n not in self.builders: @@ -234,11 +248,23 @@ def registerFunction(self, name, func, argnames): builder = self.builders[argname] argliteral = builder.literal opbuilder.literal.addLiteral(argliteral) + registered_builder = self.registerBuilder(name, opbuilder) + return registered_builder - return self.registerBuilder(name, opbuilder) + @deprecated(registerFunction_dep_msg) + def registerFunction(self, name, func, argnames): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use + diffpy.srfit.equation.builder.EquationFactory.register_function + instead. + """ + return self.register_function(name, func, argnames) def registerBuilder(self, name, builder): - """Register builder in this module so it can be used in makeEquation. + """Register builder in this module so it can be used in + makeEquation. If an extant builder with the given name is already registered, this will replace all instances of the old builder's literal in @@ -282,7 +308,8 @@ def deRegisterBuilder(self, name): return def wipeout(self, eq): - """Invalidate the specified equation and remove it from the factory. + """Invalidate the specified equation and remove it from the + factory. This will remove the equation from the purview of the factory and also change its formula to return NaN. This ensures that eq @@ -404,6 +431,16 @@ def _get_undefined_args(self, eqstr): # End class EquationFactory +base_basebuilder = "diffpy.srfit.equation.builder.BaseBuilder" +removal_version = "4.0.0" + +getequation_dep_msg = build_deprecation_message( + base_basebuilder, + "getEquation", + "get_equation", + removal_version, +) + class BaseBuilder(object): """Class for building equations. @@ -430,7 +467,7 @@ def __call__(self, *args): ) raise TypeError(m) - def getEquation(self): + def get_equation(self): """Get the equation built by this object. The equation will given the name "_eq_" where "" is @@ -441,12 +478,22 @@ def getEquation(self): eq = Equation(name, self.literal) return eq + @deprecated(getequation_dep_msg) + def getEquation(self): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.equation.builder.BaseBuilder.get_equation + instead. + """ + return self.get_equation() + def __eval_binary(self, other, OperatorClass, onleft=True): """Evaluate a binary function. Other can be an BaseBuilder or a constant. - Attributes + Parameters ---------- onleft Indicates that the operator was passed on the left side @@ -555,7 +602,8 @@ class ArgumentBuilder(BaseBuilder): """ def __init__(self, value=None, name=None, const=False, arg=None): - """Create an ArgumentBuilder instance, containing a new Argument. + """Create an ArgumentBuilder instance, containing a new + Argument. Parameters ---------- @@ -618,7 +666,7 @@ def __call__(self, *args): This creates a new builder that encapsulates the operation. - Attributes + Parameters ---------- args Arguments of the operation. @@ -682,7 +730,7 @@ def wrapOperator(name, op): def wrapFunction(name, func, nin=2, nout=1): """Wrap a function in an OperatorBuilder instance. - Attributes + Parameters ---------- name The name of the function @@ -711,8 +759,8 @@ def getBuilder(name): def __wrap_numpy_operators(): - """Export all numpy operators as OperatorBuilder instances in the module - namespace.""" + """Export all numpy operators as OperatorBuilder instances in the + module namespace.""" for name in dir(numpy): op = getattr(numpy, name) if isinstance(op, numpy.ufunc): diff --git a/src/diffpy/srfit/equation/equationmod.py b/src/diffpy/srfit/equation/equationmod.py index acbaff7c..3c05dcbc 100644 --- a/src/diffpy/srfit/equation/equationmod.py +++ b/src/diffpy/srfit/equation/equationmod.py @@ -25,8 +25,8 @@ these! > b = Argument(name="b") > add.addLiteral(a) > add.addLiteral(b) > # make an Equation instance and pass the root > eq = Equation(root = add) > eq(a=3, b=4) # returns 7 > eq(a=2) # remembers b=4, returns 6 > -eq.a.setValue(-3) > eq.b.setValue(3) > eq() # uses last assignment of a -and b, returns 0 +eq.a.set_value(-3) > eq.b.set_value(3) > eq() # uses last assignment of +a and b, returns 0 See the class documentation for more information. """ @@ -91,7 +91,7 @@ class Equation(Operator): def __init__(self, name=None, root=None): """Initialize. - Attributes + Parameters ---------- name A name for this Equation. @@ -192,14 +192,14 @@ def __call__(self, *args, **kw): if idx >= len(self.argdict): raise ValueError("Too many arguments") arg = self.args[idx] - arg.setValue(val) + arg.set_value(val) # Process kw for name, val in kw.items(): arg = self.argdict.get(name) if arg is None: raise ValueError("No argument named '%s' here" % name) - arg.setValue(val) + arg.set_value(val) self._value = self.root.getValue() return self._value diff --git a/src/diffpy/srfit/equation/literals/abcs.py b/src/diffpy/srfit/equation/literals/abcs.py index 605bc53e..82c633cc 100644 --- a/src/diffpy/srfit/equation/literals/abcs.py +++ b/src/diffpy/srfit/equation/literals/abcs.py @@ -16,14 +16,10 @@ __all__ = ["LiteralABC", "ArgumentABC", "OperatorABC"] +from abc import ABC, abstractmethod -from abc import ABCMeta, abstractmethod, abstractproperty -import six - - -@six.add_metaclass(ABCMeta) -class LiteralABC(object): +class LiteralABC(ABC): """Abstract Base Class for Literal. See Literal for usage. @@ -31,13 +27,19 @@ class LiteralABC(object): @abstractmethod def identify(self, visitor): + """Identify this literal using a visitor.""" pass @abstractmethod def getValue(self): + """Return the value of the literal.""" pass - name = abstractproperty(None, None) + @property + @abstractmethod + def name(self): + """Name of the literal.""" + pass # End class LiteralABC @@ -50,11 +52,21 @@ class ArgumentABC(LiteralABC): """ @abstractmethod - def setValue(self, value): + def set_value(self, value): + """Set the value of the argument.""" pass - const = abstractproperty(None, None) - value = abstractproperty(None, None) + @property + @abstractmethod + def const(self): + """Whether the argument is constant.""" + pass + + @property + @abstractmethod + def value(self): + """Value of the argument.""" + pass # End class ArgumentABC @@ -68,14 +80,44 @@ class OperatorABC(LiteralABC): @abstractmethod def addLiteral(self, literal): + """Add a literal argument to the operator.""" + pass + + @property + @abstractmethod + def args(self): + """Arguments of the operator.""" + pass + + @property + @abstractmethod + def nin(self): + """Number of input arguments.""" pass - args = abstractproperty(None, None) - nin = abstractproperty(None, None) - nout = abstractproperty(None, None) - operation = abstractproperty(None, None) - symbol = abstractproperty(None, None) - value = abstractproperty(None, None) + @property + @abstractmethod + def nout(self): + """Number of outputs.""" + pass + + @property + @abstractmethod + def operation(self): + """Callable implementing the operator.""" + pass + + @property + @abstractmethod + def symbol(self): + """Symbol representing the operator.""" + pass + + @property + @abstractmethod + def value(self): + """Value produced by the operator.""" + pass # End class OperatorABC diff --git a/src/diffpy/srfit/equation/literals/argument.py b/src/diffpy/srfit/equation/literals/argument.py index 60d639f2..c468d516 100644 --- a/src/diffpy/srfit/equation/literals/argument.py +++ b/src/diffpy/srfit/equation/literals/argument.py @@ -37,9 +37,9 @@ class Argument(Literal, ArgumentABC): A flag indicating whether this is considered a constant. Constants may be given special treatment by the Visitors. _value - The value of the Argument. Modified with 'setValue'. + The value of the Argument. Modified with 'set_value'. value - Property for 'getValue' and 'setValue'. + Property for 'getValue' and 'set_value'. """ const = None @@ -59,10 +59,10 @@ def getValue(self): """Get the value of this Literal.""" return self._value - def setValue(self, val): + def set_value(self, val): """Set the value of the Literal. - Attributes + Parameters ---------- val The value to assign @@ -77,7 +77,7 @@ def setValue(self, val): return value = property( - lambda self: self.getValue(), lambda self, val: self.setValue(val) + lambda self: self.getValue(), lambda self, val: self.set_value(val) ) diff --git a/src/diffpy/srfit/equation/literals/literal.py b/src/diffpy/srfit/equation/literals/literal.py index ebdc17a9..48c48eaa 100644 --- a/src/diffpy/srfit/equation/literals/literal.py +++ b/src/diffpy/srfit/equation/literals/literal.py @@ -26,7 +26,8 @@ class Literal(Observable, LiteralABC): - """Abstract class for equation pieces, such as operators and arguments. + """Abstract class for equation pieces, such as operators and + arguments. Literal derives from Observable. See diffpy.srfit.util.observable. diff --git a/src/diffpy/srfit/equation/literals/operators.py b/src/diffpy/srfit/equation/literals/operators.py index 05a15f83..6715f309 100644 --- a/src/diffpy/srfit/equation/literals/operators.py +++ b/src/diffpy/srfit/equation/literals/operators.py @@ -141,7 +141,8 @@ def _loop_check(self, literal): class UnaryOperator(Operator): - """Abstract class for an unary operator with one input and one result. + """Abstract class for an unary operator with one input and one + result. This base class defines the `nin` and `nout` attributes. The derived concrete operator must provide the remaining abstract @@ -154,7 +155,8 @@ class UnaryOperator(Operator): class BinaryOperator(Operator): - """Abstract class for a binary operator with two inputs and one result. + """Abstract class for a binary operator with two inputs and one + result. This base class defines the `nin` and `nout` attributes. The derived concrete operator must define the remaining abstract @@ -345,7 +347,7 @@ def __init__(self, op): Arguments - Attributes + Parameters ---------- op A numpy ufunc @@ -359,7 +361,8 @@ def __init__(self, op): class ArrayOperator(Operator): - """Operator that will take parameters and turn them into an array.""" + """Operator that will take parameters and turn them into an + array.""" name = "array" symbol = "array" diff --git a/src/diffpy/srfit/equation/visitors/__init__.py b/src/diffpy/srfit/equation/visitors/__init__.py index ef50ec43..82239eb4 100644 --- a/src/diffpy/srfit/equation/visitors/__init__.py +++ b/src/diffpy/srfit/equation/visitors/__init__.py @@ -35,7 +35,7 @@ def getArgs(literal, getconsts=True): """Get the Arguments of a Literal tree. - Attributes + Parameters ---------- getconsts If True (default), then Arguments designated as constant @@ -50,7 +50,7 @@ def getArgs(literal, getconsts=True): def getExpression(literal, eqskip=None): """Get math expression string from the Literal tree object. - Attributes + Parameters ---------- eqskip regular expression pattern for Equation objects that should diff --git a/src/diffpy/srfit/equation/visitors/printer.py b/src/diffpy/srfit/equation/visitors/printer.py index 98cfab6a..4fcb717b 100644 --- a/src/diffpy/srfit/equation/visitors/printer.py +++ b/src/diffpy/srfit/equation/visitors/printer.py @@ -12,7 +12,8 @@ # See LICENSE_DANSE.txt for license information. # ############################################################################## -"""Printer visitor for printing the equation represented by a Literal tree. +"""Printer visitor for printing the equation represented by a Literal +tree. The Printer visitor creates a one-line representation of the Literal tree, which is valid as a string equivalent of the equation. @@ -28,8 +29,6 @@ class Printer(Visitor): """Printer for printing a Literal tree. - Attributes: - Attributes ---------- eqskip diff --git a/src/diffpy/srfit/equation/visitors/swapper.py b/src/diffpy/srfit/equation/visitors/swapper.py index 108c1df6..5c76323b 100644 --- a/src/diffpy/srfit/equation/visitors/swapper.py +++ b/src/diffpy/srfit/equation/visitors/swapper.py @@ -12,7 +12,8 @@ # See LICENSE_DANSE.txt for license information. # ############################################################################## -"""Swapper for replacing a Literal in an equation with another Literals.""" +"""Swapper for replacing a Literal in an equation with another +Literals.""" __all__ = ["Swapper"] @@ -20,7 +21,8 @@ class Swapper(Visitor): - """Swapper for swapping out one literal for another in a literal tree. + """Swapper for swapping out one literal for another in a literal + tree. Note that this cannot swap out a root node of a literal tree. This case must be tested for explicitly. @@ -36,7 +38,7 @@ class Swapper(Visitor): def __init__(self, oldlit, newlit): """Initialize. - Attributes + Parameters ---------- oldlit The literal to be replaced. diff --git a/src/diffpy/srfit/exceptions.py b/src/diffpy/srfit/exceptions.py index 01dba06e..24333e66 100644 --- a/src/diffpy/srfit/exceptions.py +++ b/src/diffpy/srfit/exceptions.py @@ -14,7 +14,7 @@ ############################################################################## """Exceptions used for SrFit - specific errors.""" -# FIXME: remove this line when `docformatter` fixes the blank line bug +# FIXME: remove this line when black and docformatter agree. class SrFitError(Exception): diff --git a/src/diffpy/srfit/fitbase/__init__.py b/src/diffpy/srfit/fitbase/__init__.py index bdbcb2d1..ce68fa7d 100644 --- a/src/diffpy/srfit/fitbase/__init__.py +++ b/src/diffpy/srfit/fitbase/__init__.py @@ -39,6 +39,7 @@ "Profile", "ProfileGenerator", "SimpleRecipe", + "ProfileParser", ] from diffpy.srfit.fitbase.calculator import Calculator @@ -48,6 +49,7 @@ from diffpy.srfit.fitbase.fitresults import FitResults, initializeRecipe from diffpy.srfit.fitbase.profile import Profile from diffpy.srfit.fitbase.profilegenerator import ProfileGenerator +from diffpy.srfit.fitbase.profileparser import ProfileParser from diffpy.srfit.fitbase.simplerecipe import SimpleRecipe # End of file diff --git a/src/diffpy/srfit/fitbase/calculator.py b/src/diffpy/srfit/fitbase/calculator.py index 75083b4e..c0afc776 100644 --- a/src/diffpy/srfit/fitbase/calculator.py +++ b/src/diffpy/srfit/fitbase/calculator.py @@ -20,7 +20,7 @@ overloaded to accept external arguments. Calculators are used to wrap registered functions so that the function's Parameters are contained in an object specific to the function. A custom Calculator can be added to -another RecipeOrganizer with the 'registerCalculator' method. +another RecipeOrganizer with the 'register_calculator' method. """ __all__ = ["Calculator"] @@ -77,9 +77,9 @@ class Calculator(Operator, ParameterSet): Properties ---------- names - Variable names (read only). See getNames. + Variable names (read only). See get_names. values - Variable values (read only). See getValues. + Variable values (read only). See get_values. """ # define abstract attributes from the Operator base. @@ -101,15 +101,37 @@ def symbol(self): # Overload me! def __call__(self, *args): - """Calculate something. + """Calculate the signal produced by this Calculator. This method must be overloaded. When overloading, you should specify the arguments explicitly, otherwise the parameters must be specified when adding the Calculator to a RecipeOrganizer. + + Parameters + ---------- + *args + The arguments needed to calculate the signal. + + Returns + ------- + object + The calculated signal. """ return 0 def operation(self, *args): + """Calculate and cache the signal produced by this Calculator. + + Parameters + ---------- + *args + The arguments needed to calculate the signal. + + Returns + ------- + object + The calculated signal. + """ self._value = self.__call__(*args) return self._value @@ -120,7 +142,10 @@ def _validate(self): the operation, since this could be costly. The operation should be validated with a containing equation. - Raises AttributeError if validation fails. + Raises + ------ + AttributeError + If validation fails. """ ParameterSet._validate(self) diff --git a/src/diffpy/srfit/fitbase/configurable.py b/src/diffpy/srfit/fitbase/configurable.py index 8fb275bc..7c0ba19d 100644 --- a/src/diffpy/srfit/fitbase/configurable.py +++ b/src/diffpy/srfit/fitbase/configurable.py @@ -21,15 +21,13 @@ class Configurable(object): - """Configurable class. - - A Configurable has state of which a FitRecipe must be aware. + """Base class for objects with state a FitRecipe must be aware of. Attributes ---------- _configobjs - Set of Configureables in a hierarchy or instances. - Messages get passed up the hierarchy to a FitReciple + The set of Configurables in a hierarchy of instances. + Messages get passed up the hierarchy to a FitRecipe via these objects. """ @@ -46,8 +44,8 @@ def _update_configuration(self): def _store_configurable(self, obj): """Store a Configurable. - The passed obj is only stored if it is a a Configurable, - otherwise this method quietly exits. + The passed obj is only stored if it is a Configurable, otherwise + this method quietly exits. """ if isinstance(obj, Configurable): self._configobjs.add(obj) diff --git a/src/diffpy/srfit/fitbase/constraint.py b/src/diffpy/srfit/fitbase/constraint.py index 1e58cfd7..f78d1de0 100644 --- a/src/diffpy/srfit/fitbase/constraint.py +++ b/src/diffpy/srfit/fitbase/constraint.py @@ -24,10 +24,28 @@ from diffpy.srfit.exceptions import SrFitError from diffpy.srfit.fitbase.validatable import Validatable +from diffpy.utils._deprecator import build_deprecation_message, deprecated + +base = "diffpy.srfit.fitbase.constraint.Constraint" +removal_version = "4.0.0" + +constrain_deprecation_msg = build_deprecation_message( + base, + "constrain", + "add_constraint", + removal_version, +) + +unconstrain_deprecation_msg = build_deprecation_message( + base, + "unconstrain", + "remove_constraint", + removal_version, +) class Constraint(Validatable): - """Constraint class. + """Associate a Parameter with an equation that determines its value. Constraints are designed to be stored in only one place. (The holder of the constraint owns it). @@ -35,25 +53,35 @@ class Constraint(Validatable): Attributes ---------- par - A Parameter that is the subject of the constraint. + The Parameter that is the subject of the constraint. eq - An equation whose evaluation is used to set the value of the + The equation whose evaluation is used to set the value of the constraint. """ def __init__(self): - """Initialization.""" + """Initialize an empty constraint.""" self.par = None self.eq = None return - def constrain(self, par, eq): + def add_constraint(self, par, eq): """Constrain a Parameter according to an Equation. The parameter will be set constant once it is constrained. This will keep it from being constrained multiple times. - Raises a ValueError if par is const. + Parameters + ---------- + par : Parameter + The Parameter to constrain. + eq : Equation + The Equation to use to constrain the Parameter. + + Raises + ------ + ValueError + If par is constant or already constrained. """ if par.const: raise ValueError("The parameter '%s' is constant" % par) @@ -68,28 +96,55 @@ def constrain(self, par, eq): self.update() return - def unconstrain(self): - """Clear the constraint.""" + @deprecated(constrain_deprecation_msg) + def constrain(self, par, eq): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use + diffpy.srfit.fitbase.constraint.Constraint.add_constraint + instead. + """ + self.add_constraint(par, eq) + return + + def remove_constraint(self): + """Clear the constraint from a Parameter.""" self.par.constrained = False self.par = None self.eq = None return + @deprecated(unconstrain_deprecation_msg) + def unconstrain(self): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use + diffpy.srfit.fitbase.constraint.Constraint.remove_constraint + instead. + """ + self.remove_constraint() + return + def update(self): """Update the parameter according to the equation.""" # This will be evaluated quickly thanks to the Equation class. val = self.eq() # This will only change the Parameter if val is different from the # currently stored value. - self.par.setValue(val) + self.par.set_value(val) return def _validate(self): """Validate my state. - This validates that par is not None. This validates eq. + This validates that ``par`` is not None. This validates ``eq``. - Raises SrFitError if validation fails. + Raises + ------ + SrFitError + If validation fails. """ if self.par is None: raise SrFitError("par is None") @@ -106,7 +161,7 @@ def _validate(self): # Try to get the value of eq. try: val = self.eq() - self.par.setValue(val) + self.par.set_value(val) except TypeError: raise SrFitError("eq cannot be evaluated") finally: diff --git a/src/diffpy/srfit/fitbase/fitcontribution.py b/src/diffpy/srfit/fitbase/fitcontribution.py index 71048092..d896e86e 100644 --- a/src/diffpy/srfit/fitbase/fitcontribution.py +++ b/src/diffpy/srfit/fitbase/fitcontribution.py @@ -28,12 +28,20 @@ from diffpy.srfit.fitbase.parameter import ParameterProxy from diffpy.srfit.fitbase.parameterset import ParameterSet from diffpy.srfit.fitbase.profile import Profile -from diffpy.srfit.fitbase.recipeorganizer import equationFromString +from diffpy.srfit.fitbase.recipeorganizer import get_equation_from_string from diffpy.utils._deprecator import build_deprecation_message, deprecated base = "diffpy.srfit.fitbase.FitContribution" removal_version = "4.0.0" +setequation_dep_msg = build_deprecation_message( + base, + "setEquation", + "set_equation", + removal_version, +) + + setprofile_dep_msg = build_deprecation_message( base, "setProfile", @@ -41,9 +49,37 @@ removal_version, ) +addprofilegenerator_dep_msg = build_deprecation_message( + base, + "addProfileGenerator", + "add_profile_generator", + removal_version, +) + +getequation_dep_msg = build_deprecation_message( + base, + "getEquation", + "get_equation", + removal_version, +) + +setresidualequation_dep_msg = build_deprecation_message( + base, + "setResidualEquation", + "set_residual_equation", + removal_version, +) + +getresidualequation_dep_msg = build_deprecation_message( + base, + "getResidualEquation", + "get_residual_equation", + removal_version, +) + class FitContribution(ParameterSet): - """FitContribution class. + """Organize an Equation, a Profile, and their supporting objects. FitContributions organize an Equation that calculates the signal, and a Profile that holds the signal. ProfileGenerators and Calculators can be @@ -61,14 +97,14 @@ class FitContribution(ParameterSet): A managed dictionary of Calculators, indexed by name. _constraints A set of constrained Parameters. Constraints can be - added using the 'constrain' methods. + added using the ``constrain`` methods. _generators A managed dictionary of ProfileGenerators. _parameters A managed OrderedDict of parameters. _restraints A set of Restraints. Restraints can be added using the - 'restrain' method. + ``restrain`` method. _parsets A managed dictionary of ParameterSets. _eqfactory @@ -89,13 +125,19 @@ class FitContribution(ParameterSet): Properties ---------- names - Variable names (read only). See getNames. + Variable names (read only). See get_names. values - Variable values (read only). See getValues. + Variable values (read only). See get_values. """ def __init__(self, name): - """Initialization.""" + """Initialize the FitContribution. + + Parameters + ---------- + name : str + The name of this FitContribution. + """ ParameterSet.__init__(self, name) self._eq = None self._reseq = None @@ -111,22 +153,22 @@ def __init__(self, name): def set_profile(self, profile, xname=None, yname=None, dyname=None): """Assign the Profile for this FitContribution. - Attributes + Parameters ---------- - profile - A Profile that specifies the calculation points and that + profile : Profile + The Profile that specifies the calculation points and that will store the calculated signal. - xname + xname : str, optional The name of the independent variable from the Profile. If this is None (default), then the name specified by the Profile for this parameter will be used. This variable is usable within string equations with the specified name. - yname + yname : str, optional The name of the observed Profile. If this is None (default), then the name specified by the Profile for this parameter will be used. This variable is usable within string equations with the specified name. - dyname + dyname : str, optional The name of the uncertainty in the observed Profile. If this is None (default), then the name specified by the Profile for this parameter will be used. This variable is @@ -164,7 +206,7 @@ def set_profile(self, profile, xname=None, yname=None, dyname=None): # If we have _eq, but not _reseq, set the residual if self._eq is not None and self._reseq is None: - self.setResidualEquation("chiv") + self.set_residual_equation("chiv") return @@ -179,30 +221,32 @@ def setProfile(self, profile, xname=None, yname=None, dyname=None): profile, xname=xname, yname=yname, dyname=dyname ) - def addProfileGenerator(self, gen, name=None): + def add_profile_generator(self, gen, name=None): """Add a ProfileGenerator to be used by this FitContribution. The ProfileGenerator is given a name so that it can be used as part of - the profile equation (see setEquation). This can be different from the - name of the ProfileGenerator used for attribute access. + the profile equation (see ``set_equation``). This can be different + from the name of the ProfileGenerator used for attribute access. FitContributions should not share ProfileGenerator instances. Different ProfileGenerators can share Parameters and ParameterSets, however. - Calling addProfileGenerator sets the profile equation to call the - calculator and if there is not a profile equation already. + Calling ``add_profile_generator`` sets the profile equation to call + the calculator if there is not a profile equation already. - Attributes + Parameters ---------- - gen - A ProfileGenerator instance - name + gen : ProfileGenerator + The ProfileGenerator instance to add. + name : str, optional A name for the calculator. If name is None (default), then the ProfileGenerator's name attribute will be used. - - Raises ValueError if the ProfileGenerator has no name. - Raises ValueError if the ProfileGenerator has the same name as some - other managed object. + Raises + ------ + ValueError + If the ProfileGenerator has no name, or if the + ProfileGenerator has the same name as some other managed + object. """ if name is None: name = gen.name @@ -219,36 +263,51 @@ def addProfileGenerator(self, gen, name=None): # Make this our equation if we don't have one. This will set the # residual equation if necessary. if self._eq is None: - self.setEquation(name) + self.set_equation(name) return - def setEquation(self, eqstr, ns={}): + @deprecated(addprofilegenerator_dep_msg) + def addProfileGenerator(self, gen, name=None): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.fitbase.FitContribution.add_profile_generator + instead. + """ + self.add_profile_generator(gen, name=name) + return + + def set_equation(self, eqstr, ns={}): """Set the profile equation for the FitContribution. This sets the equation that will be used when generating the residual for this FitContribution. The equation will be usable within - setResidualEquation as "eq", and it takes no arguments. + ``set_residual_equation`` as ``"eq"``, and it takes no arguments. - Attributes + Parameters ---------- - eqstr + eqstr : str A string representation of the equation. Any Parameter - registered by addParameter or setProfile, or function - registered by setCalculator, registerFunction or - registerStringFunction can be can be used in the equation + registered by ``addParameter`` or ``set_profile``, or function + registered by ``register_calculator``, ``register_function`` or + ``register_string_function`` can be used in the equation by name. Other names will be turned into Parameters of this FitContribution. - ns + ns : dict, optional A dictionary of Parameters, indexed by name, that are used in the eqstr, but not registered (default {}). - - Raises ValueError if ns uses a name that is already used for a - variable. + Raises + ------ + ValueError + If ns uses a name that is already used for a variable. """ # Build the equation instance. - eq = equationFromString(eqstr, self._eqfactory, buildargs=True, ns=ns) + eq = get_equation_from_string( + eqstr, self._eqfactory, buildargs=True, ns=ns + ) eq.name = "eq" # Register any new Parameters. @@ -262,15 +321,30 @@ def setEquation(self, eqstr, ns={}): # Set the residual if we need to if self.profile is not None and self._reseq is None: - self.setResidualEquation("chiv") + self.set_residual_equation("chiv") return - def getEquation(self): - """Get math expression string for the active profile equation. + @deprecated(setequation_dep_msg) + def setEquation(self, eqstr, ns={}): + """This function has been deprecated and will be removed in version + 4.0.0. - Return normalized math expression or an empty string if profile - equation has not been set yet. + Please use diffpy.srfit.fitbase.FitContribution.set_equation + instead. + """ + self.set_equation(eqstr, ns=ns) + return + + def get_equation(self): + """Get the math expression string for the active profile + equation. + + Returns + ------- + str + The normalized math expression, or an empty string if the + profile equation has not been set yet. """ from diffpy.srfit.equation.visitors import getExpression @@ -279,29 +353,42 @@ def getEquation(self): rv = getExpression(self._eq) return rv - def setResidualEquation(self, eqstr): - """Set the residual equation for the FitContribution. + @deprecated(getequation_dep_msg) + def getEquation(self): + """This function has been deprecated and will be removed in version + 4.0.0. - Attributes - ---------- - eqstr - A string representation of the residual. If eqstr is None - (default), then the previous residual equation will be - used, or the chi2 residual will be used if that does not - exist. + Please use diffpy.srfit.fitbase.FitContribution.get_equation + instead. + """ + return self.get_equation() + def set_residual_equation(self, eqstr): + """Set the residual equation for the FitContribution. - Two residuals are preset for convenience, "chiv" and "resv". - chiv is defined such that dot(chiv, chiv) = chi^2. - resv is defined such that dot(resv, resv) = Rw^2. + Two residuals are preset for convenience, ``"chiv"`` and ``"resv"``. + ``chiv`` is defined such that ``dot(chiv, chiv) = chi^2``. + ``resv`` is defined such that ``dot(resv, resv) = Rw^2``. You can call on these in your residual equation. Note that the quantity that will be optimized is the summed square of the residual equation. Keep that in mind when defining a new residual or using the built-in ones. - Raises SrFitError if the Profile is not yet defined. - Raises ValueError if eqstr depends on a Parameter that is not part of - the FitContribution. + Parameters + ---------- + eqstr : str + A string representation of the residual. If eqstr is None + (default), then the previous residual equation will be + used, or the chi2 residual will be used if that does not + exist. + + Raises + ------ + SrFitError + If the Profile is not yet defined. + ValueError + If eqstr depends on a Parameter that is not part of the + FitContribution. """ if self.profile is None: raise SrFitError("Assign the Profile first") @@ -317,17 +404,33 @@ def setResidualEquation(self, eqstr): elif eqstr == "resv": eqstr = resvstr - reseq = equationFromString(eqstr, self._eqfactory) + reseq = get_equation_from_string(eqstr, self._eqfactory) self._eqfactory.wipeout(self._reseq) self._reseq = reseq return - def getResidualEquation(self): - """Get math expression string for the active residual equation. + @deprecated(setresidualequation_dep_msg) + def setResidualEquation(self, eqstr): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.fitbase.FitContribution.set_residual_equation + instead. + """ + self.set_residual_equation(eqstr) + return - Return normalized math formula or an empty string if residual - equation has not been configured yet. + def get_residual_equation(self): + """Get the math expression string for the active residual + equation. + + Returns + ------- + str + The normalized math formula, or an empty string if the + residual equation has not been configured yet. """ from diffpy.srfit.equation.visitors import getExpression @@ -336,19 +439,35 @@ def getResidualEquation(self): rv = getExpression(self._reseq, eqskip="eq$") return rv + @deprecated(getresidualequation_dep_msg) + def getResidualEquation(self): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.fitbase.FitContribution.get_residual_equation + instead. + """ + return self.get_residual_equation() + def residual(self): - """Calculate the residual for this fitcontribution. + """Calculate the residual for this FitContribution. When this method is called, it is assumed that all parameters have been assigned their most current values by the FitRecipe. This will be the case when being called as part of a FitRecipe refinement. - The residual is by default an array chiv: - chiv = (eq() - self.profile.y) / self.profile.dy - The value that is optimized is dot(chiv, chiv). + The residual is by default an array ``chiv``: + ``chiv = (eq() - self.profile.y) / self.profile.dy``. + The value that is optimized is ``dot(chiv, chiv)``. + + The residual equation can be changed with the + ``set_residual_equation`` method. - The residual equation can be changed with the setResidualEquation - method. + Returns + ------- + numpy.ndarray + The array of residual values. """ # Assign the calculated profile self.profile.ycalc = self._eq() @@ -357,7 +476,13 @@ def residual(self): return self._reseq() def evaluate(self): - """Evaluate the contribution equation and update profile.ycalc.""" + """Evaluate the contribution equation and update profile.ycalc. + + Returns + ------- + numpy.ndarray + The calculated signal. + """ yc = self._eq() if self.profile is not None: self.profile.ycalc = yc @@ -370,7 +495,10 @@ def _validate(self): ProfileGenerator validations. This validates _eq. This validates _reseq and residual. - Raises SrFitError if validation fails. + Raises + ------ + SrFitError + If validation fails. """ self.profile._validate() ParameterSet._validate(self) diff --git a/src/diffpy/srfit/fitbase/fithook.py b/src/diffpy/srfit/fitbase/fithook.py index 14c5811c..6edae312 100644 --- a/src/diffpy/srfit/fitbase/fithook.py +++ b/src/diffpy/srfit/fitbase/fithook.py @@ -12,7 +12,8 @@ # See LICENSE_DANSE.txt for license information. # ############################################################################## -"""The FitHook class for inspecting the progress of a FitRecipe refinement. +"""The FitHook class for inspecting the progress of a FitRecipe +refinement. FitHooks are called by a FitRecipe during various times of the residual is evaluation. The default FitHook simply counts the number of times the @@ -21,7 +22,7 @@ and the current variable values. Custom FitHooks can be added to a FitRecipe with the -FitRecipe.setFitHook method. +FitRecipe.push_fit_hook method. """ from __future__ import print_function @@ -37,10 +38,10 @@ class FitHook(object): """Base class for inspecting the progress of a FitRecipe refinement. Can serve as a fithook for the FitRecipe class (see - FitRecipe.pushFitHook method.) The methods in this class are called - during the preparation of the FitRecipe for refinement, and during - the residual call. See the class methods for a description of their - purpose. + FitRecipe.push_fit_hook method.) The methods in this class are + called during the preparation of the FitRecipe for refinement, and + during the residual call. See the class methods for a description of + their purpose. """ def reset(self, recipe): @@ -54,24 +55,26 @@ def reset(self, recipe): return def precall(self, recipe): - """This is called within FitRecipe.residual, before the calculation. + """This is called within FitRecipe.residual, before the + calculation. - Attributes + Parameters ---------- - recipe - The FitRecipe instance + recipe : FitRecipe + The FitRecipe instance. """ return def postcall(self, recipe, chiv): - """This is called within FitRecipe.residual, after the calculation. + """This is called within FitRecipe.residual, after the + calculation. - Attributes + Parameters ---------- - recipe - The FitRecipe instance - chiv - The residual vector + recipe : FitRecipe + The FitRecipe instance. + chiv : ndarray + The residual vector. """ return @@ -80,27 +83,26 @@ def postcall(self, recipe, chiv): class PrintFitHook(FitHook): - """Base class for inspecting the progress of a FitRecipe refinement. + """Print the progress of a FitRecipe refinement. This FitHook prints out a running count of the number of times the residual has been called, or other information, based on the verbosity. - Attributes - Attributes ---------- count The number of times the residual has been called (default 0). verbose An integer telling how verbose to be (default 1). - 0 - print nothing - 1 - print the count during the precall - 2 - print the residual during the postcall - >=3 - print the variables during the postcall + + 0 + Print nothing. + 1 + Print the count during the precall. + 2 + Print the residual during the postcall. + >=3 + Print the variables during the postcall. """ def __init__(self): @@ -121,12 +123,13 @@ def reset(self, recipe): return def precall(self, recipe): - """This is called within FitRecipe.residual, before the calculation. + """This is called within FitRecipe.residual, before the + calculation. - Attributes + Parameters ---------- - recipe - The FitRecipe instance + recipe : FitRecipe + The FitRecipe instance. """ self.count += 1 if self.verbose > 0: @@ -134,14 +137,15 @@ def precall(self, recipe): return def postcall(self, recipe, chiv): - """This is called within FitRecipe.residual, after the calculation. + """This is called within FitRecipe.residual, after the + calculation. - Attributes + Parameters ---------- - recipe - The FitRecipe instance - chiv - The residual vector + recipe : FitRecipe + The FitRecipe instance. + chiv : ndarray + The residual vector. """ if self.verbose < 2: return @@ -164,8 +168,8 @@ def postcall(self, recipe, chiv): if self.verbose >= 3: print("Variables") - vnames = recipe.getNames() - vals = recipe.getValues() + vnames = recipe.get_names() + vals = recipe.get_values() # byname = _byname() items = sorted(zip(vnames, vals), key=_byname) for name, val in items: @@ -182,7 +186,7 @@ def _byname(nv): # TODO - Display the chi^2 on the plot during refinement. class PlotFitHook(FitHook): - """This FitHook has live plotting of whatever is being refined.""" + """Live-plot the progress of a FitRecipe refinement.""" def reset(self, recipe): """Set up the plot.""" @@ -227,16 +231,17 @@ def reset(self, recipe): return def postcall(self, recipe, chiv): - """This is called within FitRecipe.residual, after the calculation. + """This is called within FitRecipe.residual, after the + calculation. Find data and plot it. - Attributes + Parameters ---------- - recipe - The FitRecipe instance - chiv - The residual vector + recipe : FitRecipe + The FitRecipe instance. + chiv : ndarray + The residual vector. """ FitHook.postcall(self, recipe, chiv) import pylab diff --git a/src/diffpy/srfit/fitbase/fitrecipe.py b/src/diffpy/srfit/fitbase/fitrecipe.py index 62761fbb..efa1aa64 100644 --- a/src/diffpy/srfit/fitbase/fitrecipe.py +++ b/src/diffpy/srfit/fitbase/fitrecipe.py @@ -26,7 +26,7 @@ Variables added to a FitRecipe can be tagged with string identifiers. Variables can be later retrieved or manipulated by tag. The tag name -"__fixed" is reserved. +``__fixed`` is reserved. See the examples in the documentation for how to create an optimization problem using FitRecipe. @@ -35,90 +35,176 @@ __all__ = ["FitRecipe"] from collections import OrderedDict +from pathlib import Path import matplotlib.pyplot as plt from bg_mpl_stylesheets.styles import all_styles from numpy import array, concatenate, dot, sqrt +import diffpy.srfit.util.inpututils as utils from diffpy.srfit.fitbase.fithook import PrintFitHook from diffpy.srfit.fitbase.parameter import ParameterProxy from diffpy.srfit.fitbase.recipeorganizer import RecipeOrganizer from diffpy.srfit.interface import _fitrecipe_interface from diffpy.srfit.util.tagmanager import TagManager +from diffpy.utils._deprecator import build_deprecation_message, deprecated plt.style.use(all_styles["bg-style"]) +base = "diffpy.srfit.fitbase.FitRecipe" +removal_version = "4.0.0" +addcontrib_dep_msg = build_deprecation_message( + base, "addContribution", "add_contribution", removal_version +) + +pushfithook_dep_msg = build_deprecation_message( + base, "pushFitHook", "push_fit_hook", removal_version +) + +popfithook_dep_msg = build_deprecation_message( + base, "popFitHook", "pop_fit_hook", removal_version +) + +getfithooks_dep_msg = build_deprecation_message( + base, "getFitHooks", "get_fit_hooks", removal_version +) + +clearfithooks_dep_msg = build_deprecation_message( + base, "clearFitHooks", "clear_fit_hooks", removal_version +) + +setweight_dep_msg = build_deprecation_message( + base, "setWeight", "set_weight", removal_version +) + +addparset_dep_msg = build_deprecation_message( + base, "addParameterSet", "add_parameter_set", removal_version +) + +removeParameterSet_dep_msg = build_deprecation_message( + base, "removeParameterSet", "remove_parameter_set", removal_version +) + +scalarResidual_dep_msg = build_deprecation_message( + base, "scalarResidual", "scalar_residual", removal_version +) + +addVar_dep_msg = build_deprecation_message( + base, "addVar", "add_variable", removal_version +) + +delVar_dep_msg = build_deprecation_message( + base, "delVar", "delete_variable", removal_version +) + +newVar_dep_msg = build_deprecation_message( + base, "newVar", "create_new_variable", removal_version +) + +isFree_dep_msg = build_deprecation_message( + base, "isFree", "is_free", removal_version +) + +getValues_dep_msg = build_deprecation_message( + base, "getValues", "get_values", removal_version +) + +getNames_dep_msg = build_deprecation_message( + base, "getNames", "get_names", removal_version +) + +getBounds_dep_msg = build_deprecation_message( + base, "getBounds", "get_bounds_pairs", removal_version +) + +getBounds2_dep_msg = build_deprecation_message( + base, "getBounds2", "get_bounds_array", removal_version +) + +boundsToRestraints_dep_msg = build_deprecation_message( + base, "boundsToRestraints", "convert_bounds_to_restraints", removal_version +) + +constrain_dep_msg = build_deprecation_message( + base, "constrain", "add_constraint", removal_version +) + +unconstrain_dep_msg = build_deprecation_message( + base, "unconstrain", "remove_constraint", removal_version +) + class FitRecipe(_fitrecipe_interface, RecipeOrganizer): - """FitRecipe class. + """Organize FitContributions, variables, restraints, and constraints + into a refinable recipe. Attributes ---------- - name + name : str A name for this FitRecipe. - fithooks - List of FitHook instances that can pass information out - of the system during a refinement. By default, the is + fithooks : list + The list of FitHook instances that can pass information out + of the system during a refinement. By default, this is populated by a PrintFitHook instance. - _constraints - A dictionary of Constraints, indexed by the constrained + _constraints : dict + The dictionary of Constraints, indexed by the constrained Parameter. Constraints can be added using the - 'constrain' method. - _oconstraints - An ordered list of the constraints from this and all + `add_constraint` method. + _oconstraints : list + The ordered list of the constraints from this and all sub-components. - _calculators - A managed dictionary of Calculators. - _contributions - A managed OrderedDict of FitContributions. - _parameters - A managed OrderedDict of parameters (in this case the + _calculators : dict + The managed dictionary of Calculators. + _contributions : OrderedDict + The managed OrderedDict of FitContributions. + _parameters : OrderedDict + The managed OrderedDict of parameters (in this case the parameters are varied). - _parsets - A managed dictionary of ParameterSets. - _eqfactory - A diffpy.srfit.equation.builder.EquationFactory + _parsets : dict + The managed dictionary of ParameterSets. + _eqfactory : diffpy.srfit.equation.builder.EquationFactory + The diffpy.srfit.equation.builder.EquationFactory instance that is used to create constraints and - restraints from string - _restraintlist - A list of restraints from this and all sub-components. - _restraints - A set of Restraints. Restraints can be added using the + restraints from strings. + _restraintlist : list + The list of restraints from this and all sub-components. + _restraints : set + The set of Restraints. Restraints can be added using the 'restrain' or 'confine' methods. - _ready - A flag indicating if all attributes are ready for the + _ready : bool + The flag indicating if all attributes are ready for the calculation. - _tagmanager - A TagManager instance for managing tags on Parameters. - _weights - List of weighing factors for each FitContribution. The + _tagmanager : TagManager + The TagManager instance for managing tags on Parameters. + _weights : list + The list of weighing factors for each FitContribution. The weights are multiplied by the residual of the FitContribution when determining the overall residual. - _fixedtag - "__fixed", used for tagging variables as fixed. Don't + _fixedtag : str + ``__fixed``, used for tagging variables as fixed. Don't use this tag unless you want issues. Properties ---------- - names - Variable names (read only). See getNames. - values - Variable values (read only). See getValues. - fixednames - Names of the fixed refinable variables (read only). - fixedvalues - Values of the fixed refinable variables (read only). - bounds - Bounds on parameters (read only). See getBounds. - bounds2 - Bounds on parameters (read only). See getBounds2. + names : list + The variable names (read only). See get_names. + values : numpy.ndarray + The variable values (read only). See get_values. + fixednames : list + The names of the fixed refinable variables (read only). + fixedvalues : numpy.ndarray + The values of the fixed refinable variables (read only). + bounds : list of tuple + The bounds on parameters (read only). See get_bounds_pairs. + bounds2 : tuple of numpy.ndarray + The bounds on parameters (read only). See get_bounds_array. """ fixednames = property( lambda self: [ v.name for v in self._parameters.values() - if not (self.isFree(v) or self.isConstrained(v)) + if not (self.is_free(v) or self.is_constrained(v)) ], doc="names of the fixed refinable variables", ) @@ -127,16 +213,22 @@ class FitRecipe(_fitrecipe_interface, RecipeOrganizer): [ v.value for v in self._parameters.values() - if not (self.isFree(v) or self.isConstrained(v)) + if not (self.is_free(v) or self.is_constrained(v)) ] ), doc="values of the fixed refinable variables", ) - bounds = property(lambda self: self.getBounds()) - bounds2 = property(lambda self: self.getBounds2()) + bounds = property(lambda self: self.get_bounds_pairs()) + bounds2 = property(lambda self: self.get_bounds_array()) def __init__(self, name="fit"): - """Initialization.""" + """Initialize the FitRecipe. + + Parameters + ---------- + name : str, optional + The name for this FitRecipe. Default is "fit". + """ RecipeOrganizer.__init__(self, name) self.fithooks = [] self.pushFitHook(PrintFitHook()) @@ -184,7 +276,7 @@ def __init__(self, name="fit"): } return - def pushFitHook(self, fithook, index=None): + def push_fit_hook(self, fithook, index=None): """Add a FitHook to be called within the residual method. The hook is an object for reporting updates, or more fundamentally, @@ -192,12 +284,12 @@ def pushFitHook(self, fithook, index=None): diffpy.srfit.fitbase.fithook.FitHook class for the required interface. Added FitHooks will be called sequentially during refinement. - Attributes + Parameters ---------- - fithook - FitHook instance to add to the sequence - index - Index for inserting fithook into the list of fit hooks. If + fithook : diffpy.srfit.fitbase.fithook.FitHook + The FitHook instance to add to the sequence. + index : int or None, optional + The index for inserting fithook into the list of fit hooks. If this is None (default), the fithook is added to the end. """ if index is None: @@ -207,21 +299,33 @@ def pushFitHook(self, fithook, index=None): self._update_configuration() return - def popFitHook(self, fithook=None, index=-1): + @deprecated(pushfithook_dep_msg) + def pushFitHook(self, fithook, index=None): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.FitRecipe.push_fit_hook instead. + """ + self.push_fit_hook(fithook, index) + return + + def pop_fit_hook(self, fithook=None, index=-1): """Remove a FitHook by index or reference. - Attributes + Parameters ---------- - fithook - FitHook instance to remove from the sequence. If this is + fithook : diffpy.srfit.fitbase.fithook.FitHook or None, optional + The FitHook instance to remove from the sequence. If this is None (default), default to index. - index - Index of FitHook instance to remove (default -1). - - - Raises ValueError if fithook is not None, but is not present in the - sequence. - Raises IndexError if the sequence is empty or index is out of range. + index : int, optional + The index of FitHook instance to remove (default -1). + + Raises + ------ + ValueError + If fithook is not None, but is not present in the sequence. + IndexError + If the sequence is empty or index is out of range. """ if fithook is not None: self.fithooks.remove(fithook) @@ -229,80 +333,185 @@ def popFitHook(self, fithook=None, index=-1): self.fithook.remove(index) return - def getFitHooks(self): - """Get the sequence of FitHook instances.""" + @deprecated(popfithook_dep_msg) + def popFitHook(self, fithook=None, index=-1): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.FitRecipe.pop_fit_hook instead. + """ + self.pop_fit_hook(fithook, index) + return + + def get_fit_hooks(self): + """Get the sequence of FitHook instances. + + Returns + ------- + list + The list of FitHook instances registered with this FitRecipe. + """ return self.fithooks[:] - def clearFitHooks(self): + @deprecated(getfithooks_dep_msg) + def getFitHooks(self): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.FitRecipe.get_fit_hooks instead.""" + return self.get_fit_hooks() + + def clear_fit_hooks(self): """Clear the FitHook sequence.""" del self.fithooks[:] return - def addContribution(self, con, weight=1.0): + @deprecated(clearfithooks_dep_msg) + def clearFitHooks(self): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.FitRecipe.clear_fit_hooks instead.""" + self.clear_fit_hooks() + return + + def add_contribution(self, con, weight=1.0): """Add a FitContribution to the FitRecipe. - Attributes + Parameters ---------- - con + con : FitContribution The FitContribution to be stored. - - - Raises ValueError if the FitContribution has no name - Raises ValueError if the FitContribution has the same name as some - other managed object. + weight : float, optional + The weight of the FitContribution. Default is 1.0. + + Raises + ------ + ValueError + If the FitContribution has no name or if the FitContribution has + the same name as some other managed object. """ self._add_object(con, self._contributions, True) self._weights.append(weight) return - def setWeight(self, con, weight): - """Set the weight of a FitContribution.""" + @deprecated(addcontrib_dep_msg) + def addContribution(self, con, weight=1.0): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.FitRecipe.add_contribution + instead. + """ + self.add_contribution(con, weight) + return + + def set_weight(self, con, weight): + """Set the weight of a FitContribution. + + Parameters + ---------- + con : FitContribution + The FitContribution object whose weight is to be set. + weight : float + The weight value to assign to the specified FitContribution. + + Returns + ------- + None + """ idx = list(self._contributions.values()).index(con) self._weights[idx] = weight return - def addParameterSet(self, parset): + @deprecated(setweight_dep_msg) + def setWeight(self, con, weight): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.FitRecipe.set_weight instead.""" + self.set_weight(con, weight) + return + + def add_parameter_set(self, parset): """Add a ParameterSet to the hierarchy. - Attributes + Parameters ---------- - parset + parset : ParameterSet The ParameterSet to be stored. - - Raises ValueError if the ParameterSet has no name. - Raises ValueError if the ParameterSet has the same name as some other - managed object. + Raises + ------ + ValueError + If the ParameterSet has no name or if the ParameterSet has the same + name as some other managed object. """ self._add_object(parset, self._parsets, True) return - def removeParameterSet(self, parset): + @deprecated(addparset_dep_msg) + def addParameterSet(self, parset): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.FitRecipe.add_parameter_set instead. + """ + self.add_parameter_set(parset) + return + + def remove_parameter_set(self, parset): """Remove a ParameterSet from the hierarchy. - Raises ValueError if parset is not managed by this object. + This method removes the specified ParameterSet object from the internal + hierarchy of managed ParameterSets. If the provided ParameterSet is not + currently managed by this object, a ValueError will be raised. + + Parameters + ---------- + parset : ParameterSet + The ParameterSet instance to be removed from the hierarchy. + + Raises + ------ + ValueError + If the provided ParameterSet is not managed by this object. """ self._remove_object(parset, self._parsets) return + @deprecated(removeParameterSet_dep_msg) + def removeParameterSet(self, parset): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.FitRecipe.remove_parameter_set instead. + """ + self.remove_parameter_set(parset) + return + def residual(self, p=[]): """Calculate the vector residual to be optimized. + 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``. + Parameters ---------- - p + p : list or numpy.ndarray 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. - Notes - ----- - The residual is by default the weighted concatenation of each - :class:`FitContribution` residual, plus the value of each restraint. - The returned array ``chiv`` satisfies - ``dot(chiv, chiv) = chi^2 + restraints``. + Returns + ------- + chiv : numpy.ndarray + The array of residuals to be optimized. The array is such that + ``dot(chiv, chiv) = chi^2 + restraints``. """ # Prepare, if necessary @@ -338,18 +547,24 @@ def residual(self, p=[]): return chiv - def scalarResidual(self, p=[]): + def scalar_residual(self, p=[]): """Calculate the scalar residual to be optimized. Parameters ---------- - p + p : list or numpy.ndarray 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. + Returns + ------- + float + The scalar residual, ``dot(chiv, chiv)``, where ``chiv`` is + the vector residual returned by `residual`. + Notes ----- The residual is by default the weighted concatenation of each @@ -360,9 +575,31 @@ def scalarResidual(self, p=[]): chiv = self.residual(p) return dot(chiv, chiv) + @deprecated(scalarResidual_dep_msg) + def scalarResidual(self, p=[]): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.FitRecipe.scalar_residual + instead. + """ + return self.scalar_residual(p) + def __call__(self, p=[]): - """Same as scalarResidual method.""" - return self.scalarResidual(p) + """Compute the scalar residual, same as `scalar_residual`. + + Parameters + ---------- + p : list or numpy.ndarray, optional + The list of current variable values, provided in the same order + as the ``_parameters`` list. Default is an empty list. + + Returns + ------- + float + The scalar residual, ``dot(chiv, chiv)``. + """ + return self.scalar_residual(p) def _prepare(self): """Prepare for the residual calculation, if necessary. @@ -373,7 +610,10 @@ def _prepare(self): This updates the local restraints with those of the contributions. - Raises AttributeError if there are variables without a value. + Raises + ------ + AttributeError + If there are variables without a value. """ # Only prepare if the configuration has changed within the recipe # hierarchy. @@ -427,7 +667,7 @@ def __verify_parameters(self): """Verify that all Parameters have values.""" # Get all parameters with a value of None badpars = [] - for par in self.iterPars(): + for par in self.iterate_over_parameters(): try: par.getValue() except ValueError: @@ -517,60 +757,57 @@ def cmp(x, y): # Variable manipulation - def addVar( + def add_variable( self, par, value=None, name=None, fixed=False, tag=None, tags=[] ): """Add a variable to be refined. - Attributes + Parameters ---------- - par - A Parameter that will be varied during a fit. - value - An initial value for the variable. If this is None + par : diffpy.srfit.fitbase.Parameter + The Parameter that will be varied during a fit. + value : float or None, optional + The initial value for the variable. If this is None (default), then the current value of par will be used. - name - A name for this variable. If name is None (default), then + name : str or None, optional + The name for this variable. If name is None (default), then the name of the parameter will be used. - fixed + fixed : bool, optional Fix the variable so that it does not vary (default False). - tag - A tag for the variable. This can be used to retrieve, fix + tag : str or None, optional + The tag for the variable. This can be used to retrieve, fix or free variables by tag (default None). Note that a variable is automatically tagged with its name and "all". - tags - A list of tags (default []). Both tag and tags can be + tags : list of str, optional + The list of tags (default []). Both tag and tags can be applied. - Returns ------- - vars - ParameterProxy (variable) for the passed Parameter. - - - Raises ValueError if the name of the variable is already taken by - another managed object. - Raises ValueError if par is constant. - Raises ValueError if par is constrained. + ParameterProxy + The ParameterProxy (variable) for the passed Parameter. + + Raises + ------ + ValueError + If the name of the variable is already taken by + another managed object. + ValueError + If par is constant. + ValueError + If par is constrained. """ name = name or par.name - if par.const: raise ValueError("The parameter '%s' is constant" % par) - if par.constrained: raise ValueError("The parameter '%s' is constrained" % par) - var = ParameterProxy(name, par) if value is not None: - var.setValue(value) - + var.set_value(value) self._add_parameter(var) - if fixed: self.fix(var) - # Tag with passed tags and by name self._tagmanager.tag(var, var.name) self._tagmanager.tag(var, "all") @@ -579,32 +816,59 @@ def addVar( self._tagmanager.tag(var, tag) return var - def delVar(self, var): + @deprecated(addVar_dep_msg) + def addVar( + self, par, value=None, name=None, fixed=False, tag=None, tags=[] + ): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.FitRecipe.add_variable instead. + """ + return self.add_variable(par, value, name, fixed, tag, tags) + + def delete_variable(self, var): """Remove a variable. Note that constraints and restraints involving the variable are not modified. - Attributes + Parameters ---------- - var + var : ParameterProxy A variable of the FitRecipe. - - Raises ValueError if var is not part of the FitRecipe. + Raises + ------ + ValueError + If var is not part of the FitRecipe. """ self._remove_parameter(var) self._tagmanager.untag(var) return + @deprecated(delVar_dep_msg) + def delVar(self, var): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.FitRecipe.delete_variable instead. + """ + self.delete_variable(var) + return + def __delattr__(self, name): + """Delete a variable if name refers to one, otherwise defer to + the base class.""" if name in self._parameters: - self.delVar(self._parameters[name]) + self.delete_variable(self._parameters[name]) return super(FitRecipe, self).__delattr__(name) return - def newVar(self, name, value=None, fixed=False, tag=None, tags=[]): + def create_new_variable( + self, name, value=None, fixed=False, tag=None, tags=[] + ): """Create a new variable of the fit. This method lets new variables be created that are not tied to a @@ -612,37 +876,37 @@ def newVar(self, name, value=None, fixed=False, tag=None, tags=[]): optimization routine, and therefore should only be created to be used in constraint or restraint equations. - Attributes + Parameters ---------- - name + name : str The name of the variable. The variable will be able to be used by this name in restraint and constraint equations. - value - An initial value for the variable. If this is None + value : float or None, optional + The initial value for the variable. If this is None (default), then the variable will be given the value of the first non-None-valued Parameter constrained to it. If this fails, an error will be thrown when 'residual' is called. - fixed + fixed : bool, optional Fix the variable so that it does not vary (default False). The variable will still be managed by the FitRecipe. - tag - A tag for the variable. This can be used to fix and free + tag : str or None, optional + The tag for the variable. This can be used to fix and free variables by tag (default None). Note that a variable is automatically tagged with its name and "all". - tags - A list of tags (default []). Both tag and tags can be + tags : list of str, optional + The list of tags (default []). Both tag and tags can be applied. - - Returns the new variable (Parameter instance). + Returns + ------- + Parameter + The new variable (Parameter instance). """ # This will fix the Parameter var = self._new_parameter(name, value) - # We may explicitly free it if not fixed: self.free(var) - # Tag with passed tags self._tagmanager.tag(var, *tags) if tag is not None: @@ -650,6 +914,15 @@ def newVar(self, name, value=None, fixed=False, tag=None, tags=[]): return var + @deprecated(newVar_dep_msg) + def newVar(self, name, value=None, fixed=False, tag=None, tags=[]): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.FitRecipe.create_new_variable instead. + """ + return self.create_new_variable(name, value, fixed, tag, tags) + def _new_parameter(self, name, value, check=True): """Overloaded to tag variables. @@ -665,13 +938,20 @@ def _new_parameter(self, name, value, check=True): def __get_var_and_check(self, var): """Get the actual variable from var. - Attributes + Parameters ---------- var A variable of the FitRecipe, or the name of a variable. - Returns the variable or None if the variable cannot be found in the - _parameters list. + Returns + ------- + object + The variable. + + Raises + ------ + ValueError + If the variable is not part of the FitRecipe. """ if isinstance(var, str): var = self._parameters.get(var) @@ -723,16 +1003,51 @@ def __get_vars_from_args(self, *args, **kw): return varargs def fix(self, *args, **kw): - """Fix a parameter by reference, name or tag. + """Fix one or more parameters by reference, name, or tag. - A fixed variable is not refined. Variables are free by default. + This method marks specified parameters as fixed, meaning they will not + be refined during the fitting process. By default, all parameters are + free (not fixed). Parameters can be specified using their references, + names, or tags. Additionally, keyword arguments can be used to assign + specific values to the fixed parameters. - This method accepts string or variable arguments. An argument of - "all" selects all variables. Keyword arguments must be parameter - names, followed by a value to assign to the fixed variable. + Parameters + ---------- + *args : str or Parameter + The positional arguments specifying the parameters to fix. + These can be parameter objects, their names as strings, or + tags. The special string "all" can be used to select all + parameters. + **kw : dict + The keyword arguments where the keys are parameter names and + the values are the values to assign to the corresponding + fixed parameters. + + Raises + ------ + ValueError + If an unknown parameter, name, or tag is passed, or if a + tag is passed as a keyword argument. + + Examples + -------- - Raises ValueError if an unknown Parameter, name or tag is - passed, or if a tag is passed in a keyword. + :: + + # Fix a parameter by reference + recipe.fix(param1) + + # Fix a parameter by name + recipe.fix("param2") + + # Fix all parameters + recipe.fix("all") + + # Fix parameters by tag + recipe.fix(tag="group1") + + # Fix a parameter and assign it a value + recipe.fix(param3=10.0) """ # Check the inputs and get the variables from them varargs = self.__get_vars_from_args(*args, **kw) @@ -748,17 +1063,44 @@ def fix(self, *args, **kw): return def free(self, *args, **kw): - """Free a parameter by reference, name or tag. + """Free one or more parameters by reference, name, or tag. - A free variable is refined. Variables are free by default. - Constrained variables are not free. + This method marks specified parameters as free, allowing them to be + refined during the fitting process. By default, variables + are free unless they are constrained. Constrained variables + cannot be freed. - This method accepts string or variable arguments. An argument of - "all" selects all variables. Keyword arguments must be parameter - names, followed by a value to assign to the fixed variable. + Parameters + ---------- + *args : str or Parameter + The positional arguments specifying the parameters to free. + These can be: + - Parameter objects + - Names of parameters (as strings) + - Tags associated with parameters (as strings) + - The string "all" to select all parameters. + **kw : dict + The keyword arguments specifying parameter names as keys and + their values to assign after freeing. This is useful + for setting the value of a parameter while marking it as free. + + Returns + ------- + None + + Raises + ------ + ValueError + If an unknown parameter, name, or tag is passed, or if a + tag is passed as a keyword argument. - Raises ValueError if an unknown Parameter, name or tag is - passed, or if a tag is passed in a keyword. + Notes + ----- + - Parameters that are already free will remain free. + - Tags associated with fixed parameters will be removed when they + are freed. + - If keyword arguments are provided, the corresponding parameter values + will be updated after freeing. """ # Check the inputs and get the variables from them varargs = self.__get_vars_from_args(*args, **kw) @@ -774,23 +1116,51 @@ def free(self, *args, **kw): return - def isFree(self, var): - """Check if a variable is fixed.""" + def is_free(self, var): + """Determine if a variable is free (not fixed) in the fit + recipe. + + This method checks whether the specified variable does not have the + fixed tag associated with it, indicating that it is free to vary + during the fitting process. + + Parameters + ---------- + var : object + The variable to check. This is typically an instance of a parameter + or variable object used in the fit recipe. + + Returns + ------- + bool + True if the variable is free (not fixed), False otherwise. + """ return not self._tagmanager.hasTags(var, self._fixedtag) - def unconstrain(self, *pars): + @deprecated(isFree_dep_msg) + def isFree(self, var): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.FitRecipe.is_free instead. + """ + return self.is_free(var) + + def remove_constraint(self, *pars): """Unconstrain a Parameter. This removes any constraints on a Parameter. If the Parameter is also a variable of the recipe, it will be freed as well. - Attributes + Parameters ---------- - *pars - The names of Parameters or Parameters to unconstrain. - + *pars : str or Parameter + The names of Parameters or Parameter objects to unconstrain. - Raises ValueError if the Parameter is not constrained. + Raises + ------ + ValueError + If the Parameter is not constrained. """ update = False for par in pars: @@ -802,7 +1172,7 @@ def unconstrain(self, *pars): raise ValueError("The parameter cannot be found") if par in self._constraints: - self._constraints[par].unconstrain() + self._constraints[par].remove_constraint() del self._constraints[par] update = True @@ -815,7 +1185,18 @@ def unconstrain(self, *pars): return - def constrain(self, par, con, ns={}): + @deprecated(unconstrain_dep_msg) + def unconstrain(self, *pars): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use diffpy.srfit.fitbase.FitRecipe.remove_constraint + instead. + """ + self.remove_constraint(*pars) + return + + def add_constraint(self, par, con, ns={}): """Constrain a parameter to an equation. Note that only one constraint can exist on a Parameter at a time. @@ -824,26 +1205,29 @@ def constrain(self, par, con, ns={}): and its current value is None. A constrained variable will be set as fixed. - Attributes + Parameters ---------- - par + par : Parameter The Parameter to constrain. - con - A string representation of the constraint equation or a - Parameter to constrain to. A constraint equation must + con : str or Parameter + The string representation of the constraint equation or a + Parameter to constrain to. A constraint equation must consist of numpy operators and "known" Parameters. Parameters are known if they are in the ns argument, or if they are managed by this object. - ns - A dictionary of Parameters, indexed by name, that are used + ns : dict, optional + The dictionary of Parameters, indexed by name, that are used in the eqstr, but not part of this object (default {}). - - Raises ValueError if ns uses a name that is already used for a - variable. - Raises ValueError if eqstr depends on a Parameter that is not part of - the FitRecipe and that is not defined in ns. - Raises ValueError if par is marked as constant. + Raises + ------ + ValueError + If ns uses a name that is already used for a variable. + ValueError + If eqstr depends on a Parameter that is not part of the FitRecipe + and that is not defined in ns. + ValueError + If par is marked as constant. """ if isinstance(par, str): name = par @@ -865,41 +1249,244 @@ def constrain(self, par, con, ns={}): val = con.getValue() if val is None: val = par.getValue() - con.setValue(val) + con.set_value(val) if par in self._parameters.values(): self.fix(par) - RecipeOrganizer.constrain(self, par, con, ns) + RecipeOrganizer.add_constraint(self, par, con, ns) return - def getValues(self): - """Get the current values of the variables in a list.""" - return array( - [v.value for v in self._parameters.values() if self.isFree(v)] + @deprecated(constrain_dep_msg) + def constrain(self, par, con, ns={}): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use diffpy.srfit.fitbase.FitRecipe.add_constraint + instead. + """ + self.add_constraint(par, con, ns) + return + + def get_values(self): + """Retrieve the current values of all free variables in the fit + recipe. + + This method collects the values of all parameters that are marked as + free (i.e., adjustable during the fitting process) and returns them + as a NumPy array. + + Returns + ------- + values_array : numpy.ndarray + The array containing the current values of all free + variables in the fit recipe. + """ + values_array = array( + [v.value for v in self._parameters.values() if self.is_free(v)] ) + return values_array + + @deprecated(getValues_dep_msg) + def getValues(self): + """This function has been deprecated and will be removed in version + 4.0.0. + Please use diffpy.srfit.fitbase.FitRecipe.get_values instead.""" + return self.get_values() + + def get_names(self): + """Retrieve the names of all free variables in the fit recipe. + + This method iterates through the parameters in the fit recipe and + returns a list of names for those variables that are marked as free. + + Returns + ------- + parameter_names : list of str + The list containing the names of free variables. + """ + parameter_names = [ + v.name for v in self._parameters.values() if self.is_free(v) + ] + return parameter_names + + @deprecated(getNames_dep_msg) def getNames(self): - """Get the names of the variables in a list.""" - return [v.name for v in self._parameters.values() if self.isFree(v)] + """This function has been deprecated and will be removed in version + 4.0.0. - def getBounds(self): + Please use diffpy.srfit.fitbase.FitRecipe.get_names instead.""" + return self.get_names() + + def get_bounds_pairs(self): """Get the bounds on variables in a list. - Returns a list of (lb, ub) pairs, where lb is the lower bound - and ub is the upper bound. + Returns + ------- + bounds_pair_list : list of tuple of float + The list of ``(lower, upper)`` bounds on the variables, in the same + order as ``get_names`` and ``get_values``. """ - return [v.bounds for v in self._parameters.values() if self.isFree(v)] + return [v.bounds for v in self._parameters.values() if self.is_free(v)] + @deprecated(getBounds_dep_msg) + def getBounds(self): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.FitRecipe.get_bounds_pairs + instead. + """ + return self.get_bounds_pairs() + + def get_bounds_array(self): + """Get the bounds on variables in two numpy arrays. + + Returns + ------- + lower_bounds : numpy.ndarray + The numpy array of lower bounds on the variables, in the same order + as ``get_names`` and ``get_values``. + upper_bounds : numpy.ndarray + The numpy array of upper bounds on the variables, in the same order + as ``get_names`` and ``get_values``. + """ + bounds = self.get_bounds_pairs() + lower_bounds = array([b[0] for b in bounds]) + upper_bounds = array([b[1] for b in bounds]) + return lower_bounds, upper_bounds + + @deprecated(getBounds2_dep_msg) def getBounds2(self): - """Get the bounds on variables in two lists. + """This function has been deprecated and will be removed in version + 4.0.0. - Returns lower- and upper-bound lists of variable bounds. + Please use diffpy.srfit.fitbase.FitRecipe.get_bounds_array instead. """ - bounds = self.getBounds() - lb = array([b[0] for b in bounds]) - ub = array([b[1] for b in bounds]) - return lb, ub + return self.get_bounds_array() + + def initialize_recipe_with_recipe(self, recipe_object): + """Initialize a FitRecipe with another FitRecipe. + + This is used to initialize a FitRecipe with the contribution(s), + parameters, constraints and restraints of another FitRecipe. + If a duplicate contribution, parameter, constraint, or restraint + is added to the FitRecipe you are initializing, the value from the + added object will be used. + + Parameters + ---------- + recipe_object : FitRecipe + The FitRecipe to initialize with. + + Raises + ------ + ValueError + If the object passed is not a FitRecipe. + """ + if not isinstance(recipe_object, FitRecipe): + raise ValueError( + "The input recipe_object must be a FitRecipe, " + f"but got {type(recipe_object)}." + ) + + for contrib_object in recipe_object._contributions.values(): + if contrib_object not in self._contributions.values(): + self.add_contribution(contrib_object) + + for param_name, param_object in recipe_object._parameters.items(): + if param_name not in self._parameters: + self._parameters.update({param_name: param_object}) + + for ( + parameter_object, + constraint_object, + ) in recipe_object._constraints.items(): + if parameter_object not in self._constraints: + self._constraints.update({parameter_object: constraint_object}) + + for restraint in recipe_object._restraints: + if restraint not in self._restraints: + self._restraints.add(restraint) + + def _pretty_print_results_dict(self, params_dict): + """Pretty print a dictionary of parameter names and values.""" + sorted_params = sorted(params_dict.items()) + width = max(len(name) for name, _ in sorted_params) + for name, value in sorted_params: + if isinstance(value, float): + value_str = f"{value:.6g}" + else: + value_str = str(value) + print(f" {name:<{width}} = {value_str}") + + def _set_parameters_from_dict(self, params_dict): + """Set the parameters of the FitRecipe from a dictionary of + parameter names and values.""" + for param_name, param_value in params_dict.items(): + if param_name in self._parameters: + self._parameters[param_name].set_value(param_value) + else: + print( + f"Warning: Parameter '{param_name}' from results " + "not found in FitRecipe and will be ignored." + ) + + def initialize_recipe_with_results(self, results, verbose=True): + """Initialize a FitRecipe with a FitResults object or a results + file. + + Note that at least one FitContribution must already exist in + the FitRecipe. + + Parameters + ---------- + results : FitResults, pathlib.Path, or str + The FitResults object or path to results file to initialize with. + verbose : bool, optional + If True, print warnings for any parameters in the results that are + not in the FitRecipe. Default is True. + + Raises + ------ + ValueError + If the input results is not a FitResults object or a path to a + results file. + """ + if hasattr(results, "get_results_dictionary"): + params_dict = results.get_results_dictionary() + metrics_in_dict = [ + "Residual", + "Contributions", + "Restraints", + "Chi2", + "Reduced Chi2", + "Rw", + ] + for metric in metrics_in_dict: + params_dict.pop(metric, None) + elif isinstance(results, (str, Path)): + params_dict = utils.get_dict_from_results_file(results) + else: + raise ValueError( + "The input results must be a FitResults object or a path to a " + f"results file, but got {type(results)}." + ) + self._set_parameters_from_dict(params_dict) + if verbose: + print() + print("Parameters found in Results:") + print("=" * 30) + self._pretty_print_results_dict(params_dict) + print() + print("Parameters set in FitRecipe:") + print("=" * 30) + set_parameters_dict = { + param.name: param.getValue() + for param in self._parameters.values() + } + self._pretty_print_results_dict(set_parameters_dict) def set_plot_defaults(self, **kwargs): """Set default plotting options for all future plots. @@ -954,7 +1541,9 @@ def set_plot_defaults(self, **kwargs): ylabel : str, optional The label for the y-axis. title : str or None, optional - The plot title. Default is no title. + The plot title. If None (default), each figure created by + `plot_recipe` is titled with the name of the contribution it + shows. A title is not added to a user-supplied axes. legend : bool, optional The legend is shown if True. Default is True. legend_loc : str, optional @@ -970,11 +1559,16 @@ def set_plot_defaults(self, **kwargs): Default is 1.0. show : bool, optional The plot is displayed using `plt.show()` if True. Default is True. - ax : matplotlib.axes.Axes or None, optional - The axes object to plot on. If None, creates a new figure. - Default is None. - return_fig : bool, optional - The figure and axes objects are returned if True. Default is False. + + Notes + ----- + The `data_label`, `fit_label`, `diff_label` and `title` options accept + a ``{contribution}`` placeholder that is replaced by the name of the + FitContribution being plotted, e.g. + ``fit_label="{contribution} calculated"``. When several contributions + are drawn on a shared axes, labels without the placeholder are + prefixed with the contribution name so the legend entries stay + distinguishable. Examples -------- @@ -993,9 +1587,17 @@ def set_plot_defaults(self, **kwargs): ) self.plot_options.update(kwargs) + def _format_plot_label(self, label, contribution_name, add_prefix): + """Insert the contribution name into a legend label.""" + if "{contribution}" in label: + return label.format(contribution=contribution_name) + if add_prefix: + return f"{contribution_name}: {label}" + return label + def _set_axes_labels_from_metadata(self, meta, plot_params): - """Set axes labels based on filename suffix in profile metadata if not - already set.""" + """Set axes labels based on filename suffix in profile metadata + if not already set.""" if isinstance(meta, dict): filename = meta.get("filename") if filename: @@ -1008,8 +1610,8 @@ def _set_axes_labels_from_metadata(self, meta, plot_params): return def plot_recipe(self, ax=None, return_fig=False, **kwargs): - """Plot the observed, fit, and difference curves for each contribution - of the fit recipe. + """Plot the observed, fit, and difference curves for each + contribution of the fit recipe. If the recipe has multiple contributions, a separate plot is created for each contribution. @@ -1029,9 +1631,12 @@ def plot_recipe(self, ax=None, return_fig=False, **kwargs): Returns ------- - fig, axes : tuple of (mpl.figure.Figure, list of mpl.axes.Axes) - The figure object and a list of axes objects (one per contribution) - are returned if return_fig=True. + fig, axes : tuple + The figure and axes objects, returned only if + ``return_fig=True``. If the recipe has a single contribution, + a single ``mpl.figure.Figure`` and ``mpl.axes.Axes`` are + returned. If it has multiple contributions, a list of figures + and a list of axes (one per contribution) are returned instead. Examples -------- @@ -1092,20 +1697,23 @@ def plot_recipe(self, ax=None, return_fig=False, **kwargs): ) figures = [] axes_list = [] + shared_axes = ax is not None and len(self._contributions) > 1 for name, contrib in self._contributions.items(): profile = contrib.profile x = profile.x yobs = profile.y ycalc = profile.ycalc + show_fit = plot_params["show_fit"] + show_diff = plot_params["show_diff"] if ycalc is None: - if plot_params["show_fit"] or plot_params["show_diff"]: + if show_fit or show_diff: print( f"Contribution '{name}' has no calculated values " "(ycalc is None). " "Only observed data will be plotted." ) - plot_params["show_fit"] = False - plot_params["show_diff"] = False + show_fit = False + show_diff = False else: diff = yobs - ycalc y_min = min(yobs.min(), ycalc.min()) @@ -1124,27 +1732,33 @@ def plot_recipe(self, ax=None, return_fig=False, **kwargs): x, yobs, plot_params["data_style"], - label=plot_params["data_label"], + label=self._format_plot_label( + plot_params["data_label"], name, shared_axes + ), color=plot_params["data_color"], markersize=plot_params["markersize"], alpha=plot_params["alpha"], ) - if plot_params["show_fit"]: + if show_fit: current_ax.plot( x, ycalc, plot_params["fit_style"], - label=plot_params["fit_label"], + label=self._format_plot_label( + plot_params["fit_label"], name, shared_axes + ), color=plot_params["fit_color"], linewidth=plot_params["linewidth"], alpha=plot_params["alpha"], ) - if plot_params["show_diff"]: + if show_diff: current_ax.plot( x, diff + offset, plot_params["diff_style"], - label=plot_params["diff_label"], + label=self._format_plot_label( + plot_params["diff_label"], name, shared_axes + ), color=plot_params["diff_color"], linewidth=plot_params["linewidth"], alpha=plot_params["alpha"], @@ -1161,7 +1775,11 @@ def plot_recipe(self, ax=None, return_fig=False, **kwargs): if plot_params["ylabel"] is not None: current_ax.set_ylabel(plot_params["ylabel"]) if plot_params["title"] is not None: - current_ax.set_title(plot_params["title"]) + current_ax.set_title( + self._format_plot_label(plot_params["title"], name, False) + ) + elif ax is None: + current_ax.set_title(name) if plot_params["legend"]: current_ax.legend(loc=plot_params["legend_loc"], frameon=True) if plot_params["grid"]: @@ -1184,39 +1802,55 @@ def plot_recipe(self, ax=None, return_fig=False, **kwargs): else: return figures, axes_list - def boundsToRestraints(self, sig=1, scaled=False): + def convert_bounds_to_restraints(self, sig=1, scaled=False): """Turn all bounded parameters into restraints. The bounds become limits on the restraint. - Attributes + Parameters ---------- - sig - The uncertainty on the bounds (scalar or iterable, - default 1). - scaled - Scale the restraints, see restrain. + sig : float or iterable of float, optional + The number of standard deviations associated with each bound. + Smaller values produce stronger restraints. If a scalar is given, + the same value is applied to all parameters. If an iterable is + provided, it must match the number of parameters. Default is 1. + scaled : bool, optional + If True, scale each restraint by the magnitude of the corresponding + parameter, consistent with the behavior of :meth:`restrain`. + Default is False. """ pars = self._parameters.values() if not hasattr(sig, "__iter__"): sig = [sig] * len(pars) for par, x in zip(pars, sig): - self.restrain( + self.add_soft_bounds( par, par.bounds[0], par.bounds[1], sig=x, scaled=scaled ) return + @deprecated(boundsToRestraints_dep_msg) + def boundsToRestraints(self, sig=1, scaled=False): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.FitRecipe.convert_bounds_to_restraints + instead. + """ + self.convert_bounds_to_restraints(sig, scaled) + return + def _apply_values(self, p): """Apply variable values to the variables.""" if len(p) == 0: return - vargen = (v for v in self._parameters.values() if self.isFree(v)) + vargen = (v for v in self._parameters.values() if self.is_free(v)) for var, pval in zip(vargen, p): - var.setValue(pval) + var.set_value(pval) return def _update_configuration(self): - """Notify RecipeContainers in hierarchy of configuration change.""" + """Notify RecipeContainers in hierarchy of configuration + change.""" self._ready = False return diff --git a/src/diffpy/srfit/fitbase/fitresults.py b/src/diffpy/srfit/fitbase/fitresults.py index d2da8b1c..4f7fa5d8 100644 --- a/src/diffpy/srfit/fitbase/fitresults.py +++ b/src/diffpy/srfit/fitbase/fitresults.py @@ -12,7 +12,8 @@ # See LICENSE_DANSE.txt for license information. # ############################################################################## -"""The FitResults and ContributionResults classes for storing results of a fit. +"""The FitResults and ContributionResults classes for storing results of +a fit. The FitResults class is used to display the current state of a FitRecipe. It stores the state, and uses it to calculate useful @@ -31,6 +32,47 @@ from diffpy.srfit.util import _DASHEDLINE from diffpy.srfit.util import sortKeyForNumericString as numstr from diffpy.srfit.util.inpututils import inputToString +from diffpy.utils._deprecator import build_deprecation_message, deprecated + +fitresults_base = "diffpy.srfit.fitbase.FitResults" +removal_version = "4.0.0" + +formatResults_dep_msg = build_deprecation_message( + fitresults_base, + "formatResults", + "get_results_string", + removal_version, +) + +printResults_dep_msg = build_deprecation_message( + fitresults_base, + "printResults", + "print_results", + removal_version, +) + +saveResults_dep_msg = build_deprecation_message( + fitresults_base, + "saveResults", + "save_results", + removal_version, +) + +resultsDictionary_dep_msg = build_deprecation_message( + "diffpy.srfit.fitbase", + "resultsDictionary", + "get_results_dictionary", + removal_version, + new_base="diffpy.srfit.fitbase.FitResults", +) + +initializeRecipe_dep_msg = build_deprecation_message( + "diffpy.srfit.fitbase", + "initializeRecipe", + "initialize_recipe_with_results", + removal_version, + new_base="diffpy.srfit.fitbase.FitRecipe", +) class FitResults(object): @@ -38,58 +80,80 @@ class FitResults(object): Attributes ---------- - recipe - The recipe containing the results. - cov - The covariance matrix from the recipe. - conresults - An ordered dictionary of ContributionResults for each - FitContribution, indexed by the FitContribution name. - derivstep - The fractional step size for calculating numeric - derivatives. Default 1e-8. - varnames - Names of the variables in the recipe. - varvals - Values of the variables in the recipe. - varunc - Uncertainties in the variable values. - showfixed - Show fixed variables (default True). - fixednames - Names of the fixed variables of the recipe. - fixedvals - Values of the fixed variables of the recipe. - showcon - Show constraint values in the output (default False). - connames - Names of the constrained parameters. - convals - Values of the constrained parameters. - conunc - Uncertainties in the constraint values. - residual - The scalar residual of the recipe. - penalty - The penalty to residual from the restraints. - chi2 - The chi2 of the recipe. - cumchi2 - The cumulative chi2 of the recipe. - rchi2 - The reduced chi2 of the recipe. - rw - The Rw of the recipe. - cumrw - The cumulative Rw of the recipe. - messages - A list of messages about the results. - precision - The precision of numeric output (default 8). - _dcon - The derivatives of the constraint equations with respect to - the variables. This is used internally. + recipe : FitRecipe + The recipe from which the results were generated. + cov : numpy.ndarray or None + The covariance matrix of the refined variables. None if unavailable. + + conresults : collections.OrderedDict[str, ContributionResults] + The ordered mapping of FitContribution name → ContributionResults. + + derivstep : float + The fractional step size used for numerical derivatives (default 1e-8). + + varnames : list[str] + The names of refined variables in the recipe. + + varvals : numpy.ndarray + The optimized values of the refined variables. + + varunc : numpy.ndarray or None + The estimated standard uncertainties of the variables. None if invalid. + + showfixed : bool + The flag indicating whether to show the fixed variables in the + formatted output (default True). + + fixednames : list[str] + The names of variables held fixed during refinement. + + fixedvals : numpy.ndarray + The values of the fixed variables. + + showcon : bool + The flag indicating whether to show the constrained parameters + in the formatted output (default False). + + connames : list[str] + The names of constrained parameters. + + convals : numpy.ndarray + The values of constrained parameters. + + conunc : numpy.ndarray or None + The uncertainties of constrained parameters. None if unavailable. + + residual : float + The scalar residual value of the recipe. + + penalty : float + The penalty contribution to the residual from restraints. + + chi2 : float + The chi-squared value of the fit. + + cumchi2 : numpy.ndarray + The cumulative chi-squared as a function of data index. + + rchi2 : float + The reduced chi-squared of the fit. + + rw : float + The weighted R-factor of the fit. + + cumrw : numpy.ndarray + The cumulative weighted R-factor as a function of data index. + + messages : list[str] + The informational or warning messages associated with the results. + + precision : int + The number of digits used when formatting numeric output (default 8). + + _dcon : numpy.ndarray + The jacobian of constraint equations with respect to variables. + Used internally for uncertainty propagation. Each of these attributes, except the recipe, are created or updated when the update method is called. @@ -98,17 +162,19 @@ class FitResults(object): def __init__(self, recipe, update=True, showfixed=True, showcon=False): """Initialize the attributes. - Attributes + Parameters ---------- - recipe - The recipe containing the results - update - Flag indicating whether to do an immediate update (default - True). - showcon - Show fixed variables in the output (default True). - showcon - Show constraint values in the output (default False). + recipe : FitRecipe + The recipe containing the results. + update : bool + The flag indicating whether to do an immediate update + (default True). + showfixed : bool + The flag indicating whether to show fixed variables in the + output (default True). + showcon : bool + The flag indicating whether to show constraint values in + the output (default False). """ self.recipe = recipe self.conresults = OrderedDict() @@ -139,7 +205,8 @@ def __init__(self, recipe, update=True, showfixed=True, showcon=False): return def update(self): - """Update the results according to the current state of the recipe.""" + """Update the results according to the current state of the + recipe.""" # Note that the order of these operations are chosen to reduce # computation time. @@ -152,8 +219,8 @@ def update(self): recipe._prepare() # Store the variable names and values - self.varnames = recipe.getNames() - self.varvals = recipe.getValues() + self.varnames = recipe.get_names() + self.varvals = recipe.get_values() fixedpars = recipe._tagmanager.union(recipe._fixedtag) fixedpars = [p for p in fixedpars if not p.constrained] self.fixednames = [p.name for p in fixedpars] @@ -196,9 +263,9 @@ def update(self): def _calculate_covariance(self): """Calculate the covariance matrix. - This is called by update. This code borrowed from PARK. - It finds the pseudo-inverse of the Jacobian using the - singular value decomposition. + This is called by update. This code borrowed from PARK. It finds + the pseudo-inverse of the Jacobian using the singular value + decomposition. """ try: J = self._calculate_jacobian() @@ -278,7 +345,8 @@ def _calculate_jacobian(self): return jac def _calculate_metrics(self): - """Calculate chi2, cumchi2, rchi2, rw and cumrw for the recipe.""" + """Calculate chi2, cumchi2, rchi2, rw and cumrw for the + recipe.""" cumchi2 = numpy.array([], dtype=float) # total weighed denominator for the ratio in the Rw formula yw2tot = 0.0 @@ -325,25 +393,27 @@ def _calculate_constraint_uncertainties(self): self.conunc.append(sig2c**0.5) return - def formatResults(self, header="", footer="", update=False): + def get_results_string(self, header="", footer="", update=False): """Format the results and return them in a string. - This function is called by printResults and saveResults. Overloading - the formatting here will change all three functions. + This function is called by ``print_results`` and + ``save_results``. Overloading the formatting here will change + all three functions. - Attributes + Parameters ---------- - header - A header to add to the output (default "") - footer - A footer to add to the output (default "") - update - Flag indicating whether to call update() (default False). + header : str + The header to add to the output (default "") + footer : str + The footer to add to the output (default "") + update : bool + The flag indicating whether to call ``update()`` (default + False). Returns ------- - out - a string containing the formatted results. + str + The string containing the formatted results. """ if update: self.update() @@ -517,37 +587,61 @@ def formatResults(self, header="", footer="", update=False): out = "\n".join(lines) + "\n" return out - def printResults(self, header="", footer="", update=False): + @deprecated(formatResults_dep_msg) + def formatResults(self, header="", footer="", update=False): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.FitResults.get_results_string + instead. + """ + return self.get_results_string(header, footer, update) + + def print_results(self, header="", footer="", update=False): """Format and print the results. Parameters ---------- - header - A header to add to the output (default "") - footer - A footer to add to the output (default "") - update - Flag indicating whether to call update() (default False). + header : str + The header to add to the output (default "") + footer : str + The footer to add to the output (default "") + update : bool + The flag indicating whether to call ``update()`` (default + False). + """ + print(self.get_results_string(header, footer, update).rstrip()) + return + + @deprecated(printResults_dep_msg) + def printResults(self, header="", footer="", update=False): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.FitResults.print_results + instead. """ - print(self.formatResults(header, footer, update).rstrip()) + self.print_results(header, footer, update) return def __str__(self): - return self.formatResults() + """Return the formatted results string.""" + return self.get_results_string() - def saveResults(self, filename, header="", footer="", update=False): + def save_results(self, filename, header="", footer="", update=False): """Format and save the results. Parameters - ---------------------------------- - filename - Name of the save file. - header - A header to add to the output (default "") - footer - A footer to add to the output (default "") - update - Flag indicating whether to call update() (default False). + ---------- + filename : str + The name of the save file. + header : str + The header to add to the output (default "") + footer : str + The footer to add to the output (default "") + update : bool + The flag indicating whether to call ``update()`` (default + False). """ # Save the time and user from getpass import getuser @@ -557,12 +651,48 @@ def saveResults(self, filename, header="", footer="", update=False): myheader += "produced by " + getuser() + "\n" header = myheader + header - res = self.formatResults(header, footer, update) + res = self.get_results_string(header, footer, update) f = open(filename, "w") f.write(res) f.close() return + @deprecated(saveResults_dep_msg) + def saveResults(self, filename, header="", footer="", update=False): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.FitResults.save_results + instead. + """ + self.save_results(filename, header, footer, update) + return + + def get_results_dictionary(self): + """Get a dictionary of results, with variable names and values, + and overall metrics. + + Returns + ------- + results_dict : dict + The dictionary containing the variable names and values, + and overall metrics, from the FitResults. + """ + parameter_names = self.varnames + parameter_values = self.varvals + results_dict = dict(zip(parameter_names, parameter_values)) + results_dict.update( + { + "Residual": self.residual, + "Contributions": self.residual - self.penalty, + "Restraints": self.penalty, + "Chi2": self.chi2, + "Reduced Chi2": self.rchi2, + "Rw": self.rw, + } + ) + return results_dict + # End class FitResults @@ -574,51 +704,52 @@ class ContributionResults(object): Attributes ---------- - y + y : numpy.ndarray or None The FitContribution's profile over the calculation range (default None). - dy + dy : numpy.ndarray or None The uncertainty in the FitContribution's profile over the calculation range (default None). - x - A numpy array of the calculated independent variable for the + x : numpy.ndarray or None + The numpy array of the calculated independent variable for the FitContribution (default None). - ycalc - A numpy array of the calculated signal for the FitContribution + ycalc : numpy.ndarray or None + The numpy array of the calculated signal for the FitContribution (default None). - residual + residual : float The scalar residual of the FitContribution. - chi2 + chi2 : float The chi2 of the FitContribution. - cumchi2 + cumchi2 : numpy.ndarray The cumulative chi2 of the FitContribution. - rw + rw : float The Rw of the FitContribution. - cumrw + cumrw : numpy.ndarray The cumulative Rw of the FitContribution. - weight + weight : float The weight of the FitContribution in the recipe. - conlocs + conlocs : list The location of the constrained parameters in the FitContribution (see the RecipeContainer._locate_managed_object method). - convals - Values of the constrained parameters. - conunc - Uncertainties in the constraint values. + convals : list + The values of the constrained parameters. + conunc : list + The uncertainties in the constraint values. """ def __init__(self, con, weight, fitres): """Initialize the attributes. - Attributes + Parameters ---------- - con - The FitContribution - weight - The weight of the FitContribution in the recipe - fitres - The FitResults instance to contain this ContributionResults + con : FitContribution + The FitContribution to summarize. + weight : float + The weight of the FitContribution in the recipe. + fitres : FitResults + The FitResults instance containing this + ContributionResults. """ self.x = None self.y = None @@ -694,17 +825,29 @@ def _calculate_metrics(self): # End class ContributionResults +@deprecated(resultsDictionary_dep_msg) def resultsDictionary(results): - """Get dictionary of results from file. + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.fitbase.FitResults.get_results_dictionary instead. + + Get dictionary of results from file. This reads the results from file and stores them in a dictionary to be returned to the caller. The dictionary may contain non-result entries. - Attributes + Parameters ---------- - results - An open file-like object, name of a file that contains - results from FitResults or a string containing fit results. + results : str or file-like + The open file-like object, name of a file that contains + results from FitResults, or a string containing fit results. + + Returns + ------- + dict + The mapping of result names to their string values. """ resstr = inputToString(results) @@ -721,20 +864,33 @@ def resultsDictionary(results): return mpairs +@deprecated(initializeRecipe_dep_msg) def initializeRecipe(recipe, results): - """Initialize the variables of a recipe from a results file. + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.fitbase.FitRecipe.initialize_recipe_with_results + instead. + + Initialize the variables of a recipe from a results file. This reads the results from file and initializes any variables (fixed or free) in the recipe to the results values. Note that the recipe has to be configured, with variables. This does not reconstruct a FitRecipe. - Attributes + Parameters ---------- - recipe - A configured recipe with variables - results - An open file-like object, name of a file that contains - results from FitResults or a string containing fit results. + recipe : FitRecipe + The configured recipe with variables. + results : str or file-like + The open file-like object, name of a file that contains + results from FitResults, or a string containing fit results. + + Raises + ------ + AttributeError + If no results can be found in ``results``. """ mpairs = resultsDictionary(results) if not mpairs: diff --git a/src/diffpy/srfit/fitbase/parameter.py b/src/diffpy/srfit/fitbase/parameter.py index 90691e55..74cab96c 100644 --- a/src/diffpy/srfit/fitbase/parameter.py +++ b/src/diffpy/srfit/fitbase/parameter.py @@ -34,10 +34,29 @@ from diffpy.srfit.interface import _parameter_interface from diffpy.srfit.util.argbinders import bind2nd from diffpy.srfit.util.nameutils import validateName +from diffpy.utils._deprecator import build_deprecation_message, deprecated + +parameter_base = "diffpy.srfit.fitbase.Parameter" +removal_version = "4.0.0" +setValue_dep_msg = build_deprecation_message( + parameter_base, "setValue", "set_value", removal_version +) + +setConst_dep_msg = build_deprecation_message( + parameter_base, "setConst", "set_constant", removal_version +) + +boundRange_dep_msg = build_deprecation_message( + parameter_base, "boundRange", "bound_range", removal_version +) + +boundWindow_dep_msg = build_deprecation_message( + parameter_base, "boundWindow", "bound_window", removal_version +) class Parameter(_parameter_interface, Argument, Validatable): - """Parameter class. + """Encapsulate an adjustable parameter within SrFit. Attributes ---------- @@ -46,34 +65,36 @@ class Parameter(_parameter_interface, Argument, Validatable): const A flag indicating whether this is considered a constant. _value - The value of the Parameter. Modified with 'setValue'. + The value of the Parameter. Modified with ``set_value``. value - Property for 'getValue' and 'setValue'. + Property for ``getValue`` and ``set_value``. constrained A flag indicating if the Parameter is constrained (default False). bounds A 2-list defining the bounds on the Parameter. This can be used by some optimizers when the Parameter is varied. See - FitRecipe.getBounds and FitRecipe.boundsToRestraints. + FitRecipe.get_bounds_pairs and FitRecipe.convert_bounds_to_restraints. """ def __init__(self, name, value=None, const=False): - """Initialization. + """Initialize the Parameter. - Attributes + Parameters ---------- - name + name : str The name of this Parameter (must be a valid attribute - identifier) - value + identifier). + value : float, optional The initial value of this Parameter (default 0). - const - A flag inticating whether the Parameter is a constant (like + const : bool, optional + A flag indicating whether the Parameter is a constant (like pi). - - Raises ValueError if the name is not a valid attribute identifier + Raises + ------ + ValueError + If the name is not a valid attribute identifier. """ self.constrained = False self.bounds = [-numpy.inf, +numpy.inf] @@ -81,96 +102,129 @@ def __init__(self, name, value=None, const=False): Argument.__init__(self, name, value, const) return - def setValue(self, val): - """Set the value of the Parameter and the bounds. + def set_value(self, val): + """Set the value of the Parameter. - Attributes + Parameters ---------- - val + val : float The value to assign. - lb - The lower bounds for the bounds list. If this is None - (default), then the lower bound will not be alterered. - ub - The upper bounds for the bounds list. If this is None - (default), then the upper bound will not be alterered. Returns ------- - self - Returns self so that mutators can be chained. + Parameter + Return self so that mutators can be chained. """ - Argument.setValue(self, val) + Argument.set_value(self, val) return self - def setConst(self, const=True, value=None): + @deprecated(setValue_dep_msg) + def setValue(self, val): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use diffpy.srfit.fitbase.Parameter.set_value instead. + """ + return self.set_value(val) + + def set_constant(self, is_constant=True, value=None): """Toggle the Parameter as constant. - Attributes + Parameters ---------- - const - Flag indicating if the parameter is constant (default + is_constant : bool, optional + The flag indicating if the parameter is constant (default True). - value - An optional value for the parameter (default None). If this - is not None, then the parameter will get a new value, - constant or otherwise. + value : float, optional + The value to set the parameter to (default None). If this is + not None, then the parameter will get a new value, constant + or otherwise. Returns ------- - self - Returns self so that mutators can be chained. + Parameter + Return self so that mutators can be chained. """ - self.const = bool(const) + self.const = bool(is_constant) if value is not None: - self.setValue(value) + self.set_value(value) return self - def boundRange(self, lb=None, ub=None): + @deprecated(setConst_dep_msg) + def setConst(self, const=True, value=None): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use diffpy.srfit.fitbase.Parameter.set_constant instead. + """ + self.set_constant(const, value) + return self + + def bound_range(self, lower_bound=None, upper_bound=None): """Set lower and upper bound of the Parameter. - Attributes + Parameters ---------- - lb + lower_bound : float The lower bound for the bounds list. - ub + upper_bound : float The upper bound for the bounds list. Returns ------- - self - Returns self so that mutators can be chained. + Parameter + Return self so that mutators can be chained. """ - if lb is not None: - self.bounds[0] = lb - if ub is not None: - self.bounds[1] = ub + if lower_bound is not None: + self.bounds[0] = lower_bound + if upper_bound is not None: + self.bounds[1] = upper_bound return self - def boundWindow(self, lr=0, ur=None): + @deprecated(boundRange_dep_msg) + def boundRange(self, lb=None, ub=None): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use diffpy.srfit.fitbase.Parameter.bound_range instead. + """ + self.bound_range(lb, ub) + return self + + def bound_window(self, lower_radius=0, upper_radius=None): """Create bounds centered on the current value of the Parameter. - Attributes + Parameters ---------- - lr + lower_radius : float, optional The radius of the lower bound (default 0). The lower bound is - computed as value - lr. - ur + computed as value - lower_radius. + upper_radius : float, optional The radius of the upper bound. The upper bound is computed as - value + ur. If this is None (default), then the value of the - lower radius is used. + value + upper_radius. If this is None (default), then the value + of the lower radius is used. Returns ------- - self - Returns self so that mutators can be chained. + Parameter + Return self so that mutators can be chained. """ val = self.getValue() - lb = val - lr - if ur is None: - ur = lr - ub = val + ur - self.bounds = [lb, ub] + lower_bound = val - lower_radius + if upper_radius is None: + upper_radius = lower_radius + upper_bound = val + upper_radius + self.bounds = [lower_bound, upper_bound] + return self + + @deprecated(boundWindow_dep_msg) + def boundWindow(self, lr=0, ur=None): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use diffpy.srfit.fitbase.Parameter.bound_window instead. + """ + self.bound_window(lr, ur) return self def _validate(self): @@ -178,7 +232,10 @@ def _validate(self): This validates that value is not None. - Raises SrFitError if validation fails. + Raises + ------ + SrFitError + If validation fails. """ if self.value is None: raise SrFitError("value of '%s' is None" % self.name) @@ -203,17 +260,19 @@ class ParameterProxy(Parameter): """ def __init__(self, name, par): - """Initialization. + """Initialize the ParameterProxy. - Attributes + Parameters ---------- - name + name : str The name of this ParameterProxy. - par + par : Parameter The Parameter this is a proxy for. - - Raises ValueError if the name is not a valid attribute identifier + Raises + ------ + ValueError + If the name is not a valid attribute identifier. """ validateName(name) @@ -238,8 +297,8 @@ def bounds(self): """List of lower and upper bounds of the proxied Parameter. This can be used by some optimizers when the Parameter is - varied. See FitRecipe.getBounds and - FitRecipe.boundsToRestraints. + varied. See FitRecipe.get_bounds_pairs and + FitRecipe.convert_bounds_to_restraints. """ return self.par.bounds @@ -254,32 +313,35 @@ def _observers(self): # wrap Parameter methods to use the target object ------------------------ - @wraps(Parameter.setValue) - def setValue(self, val): - return self.par.setValue(val) + @wraps(Parameter.set_value) + def set_value(self, val): + return self.par.set_value(val) @wraps(Parameter.getValue) def getValue(self): return self.par.getValue() - @wraps(Parameter.setConst) - def setConst(self, const=True, value=None): - return self.par.setConst(const, value) + @wraps(Parameter.set_constant) + def set_constant(self, const=True, value=None): + return self.par.set_constant(const, value) - @wraps(Parameter.boundRange) - def boundRange(self, lb=None, ub=None): - return self.par.boundRange(lb, ub) + @wraps(Parameter.bound_range) + def bound_range(self, lower_bound=None, upper_bound=None): + return self.par.bound_range(lower_bound, upper_bound) - @wraps(Parameter.boundWindow) - def boundWindow(self, lr=0, ur=None): - return self.par.boundWindow(lr, ur) + @wraps(Parameter.bound_window) + def bound_window(self, lr=0, ur=None): + return self.par.bound_window(lr, ur) def _validate(self): """Validate my state. This validates that value and par are not None. - Raises SrFitError if validation fails. + Raises + ------ + SrFitError + If validation fails. """ if self.par is None: raise SrFitError("par is None") @@ -293,42 +355,44 @@ def _validate(self): class ParameterAdapter(Parameter): """An adapter for parameter-like objects. - This class wraps an object as a Parameter. The getValue and setValue - methods defer to the data of the wrapped object. + This class wraps an object as a Parameter. The getValue and + set_value methods defer to the data of the wrapped object. """ def __init__(self, name, obj, getter=None, setter=None, attr=None): """Wrap an object as a Parameter. - Attributes + Parameters ---------- - name + name : str The name of this Parameter. - obj + obj : object The object to be wrapped. - getter + getter : callable, optional The unbound function that can be used to access the - attribute containing the parameter value. getter(obj) - should return the Parameter value. If getter is None + attribute containing the parameter value. ``getter(obj)`` + should return the Parameter value. If getter is None (default), it is assumed that an attribute is accessed via attr. If attr is also specified, then the Parameter - value will be accessed via getter(obj, attr). - setter + value will be accessed via ``getter(obj, attr)``. + setter : callable, optional The unbound function that can be used to modify the attribute containing the parameter value. - setter(obj, value) should set the attribute to the + ``setter(obj, value)`` should set the attribute to the passed value. If setter is None (default), it is assumed that an attribute is accessed via attr. If attr is also specified, then the Parameter value will be set via - setter(obj, attr, value). - attr + ``setter(obj, attr, value)``. + attr : str, optional The name of the attribute that contains the value of the parameter. If attr is None (default), then both getter and setter must be specified. - - Raises ValueError if exactly one of getter or setter is not None, or if - getter, setter and attr are all None. + Raises + ------ + ValueError + If exactly one of getter or setter is not None, or if + getter, setter and attr are all None. """ if getter is None and setter is None and attr is None: raise ValueError("Specify attribute access") @@ -356,11 +420,28 @@ def __init__(self, name, obj, getter=None, setter=None, attr=None): return def getValue(self): - """Get the value of the Parameter.""" + """Get the value of the Parameter. + + Returns + ------- + object + The current value of the wrapped attribute. + """ return self.getter(self.obj) - def setValue(self, value): - """Set the value of the Parameter.""" + def set_value(self, value): + """Set the value of the Parameter. + + Parameters + ---------- + value : object + The value to assign. + + Returns + ------- + ParameterAdapter + Return self so that mutators can be chained. + """ if value != self.getValue(): self.setter(self.obj, value) self.notify() diff --git a/src/diffpy/srfit/fitbase/parameterset.py b/src/diffpy/srfit/fitbase/parameterset.py index ce89326f..21bb2dac 100644 --- a/src/diffpy/srfit/fitbase/parameterset.py +++ b/src/diffpy/srfit/fitbase/parameterset.py @@ -25,10 +25,26 @@ from collections import OrderedDict from diffpy.srfit.fitbase.recipeorganizer import RecipeOrganizer +from diffpy.utils._deprecator import build_deprecation_message, deprecated + +base = "diffpy.srfit.fitbase.parameterset.ParameterSet" +removal_version = "4.0.0" + +addparset_dep_msg = build_deprecation_message( + base, "addParameterSet", "add_parameter_set", removal_version +) + +removeParameterSet_dep_msg = build_deprecation_message( + base, "removeParameterSet", "remove_parameter_set", removal_version +) + +setConst_dep_msg = build_deprecation_message( + base, "setConst", "set_constant", removal_version +) class ParameterSet(RecipeOrganizer): - """Class for organizing Parameters and other ParameterSets. + """Organize Parameters and other ParameterSets in a hierarchy. ParameterSets are hierarchical organizations of Parameters, Constraints, Restraints and other ParameterSets. @@ -36,8 +52,8 @@ class ParameterSet(RecipeOrganizer): Contained Parameters and other ParameterSets can be accessed by name as attributes in order to facilitate multi-level constraints and restraints. These constraints and restraints can be placed at any level and a flattened - list of them can be retrieved with the getConstraints and getRestraints - methods. + list of them can be retrieved with the '_get_constraints' and + '_get_restraints' methods. Attributes ---------- @@ -63,17 +79,17 @@ class ParameterSet(RecipeOrganizer): Properties ---------- names - Variable names (read only). See getNames. + Variable names (read only). See get_names. values - Variable values (read only). See getValues. + Variable values (read only). See get_values. """ def __init__(self, name): """Initialize. - Attributes + Parameters ---------- - name + name : str The name of this ParameterSet. """ RecipeOrganizer.__init__(self, name) @@ -87,42 +103,86 @@ def __init__(self, name): newParameter = RecipeOrganizer._new_parameter removeParameter = RecipeOrganizer._remove_parameter - def addParameterSet(self, parset): + def add_parameter_set(self, parset): """Add a ParameterSet to the hierarchy. - Attributes + Parameters ---------- - parset + parset : ParameterSet The ParameterSet to be stored. - - Raises ValueError if the ParameterSet has no name. - Raises ValueError if the ParameterSet has the same name as some other - managed object. + Raises + ------ + ValueError + If the ParameterSet has no name, or if it has the same name + as some other managed object. """ self._add_object(parset, self._parsets, True) return - def removeParameterSet(self, parset): + @deprecated(addparset_dep_msg) + def addParameterSet(self, parset): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.fitbase.parameterset.ParameterSet.add_parameter_set + instead. + """ + self.add_parameter_set(parset) + return + + def remove_parameter_set(self, parset): """Remove a ParameterSet from the hierarchy. - Raises ValueError if parset is not managed by this object. + Parameters + ---------- + parset : ParameterSet + The ParameterSet to remove. + + Raises + ------ + ValueError + If parset is not managed by this object. """ self._remove_object(parset, self._parsets) return - def setConst(self, const=True): + @deprecated(removeParameterSet_dep_msg) + def removeParameterSet(self, parset): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.fitbase.parameterset.ParameterSet.remove_parameter_set + instead. + """ + self.remove_parameter_set(parset) + return + + def set_constant(self, is_constant=True): """Set every parameter within the set to a constant. - Attributes + Parameters ---------- - const - Flag indicating if the parameter is constant (default + is_constant : bool, optional + The flag indicating if the parameter is constant (default True). """ - for par in self.iterPars(): - par.setConst(const) + for par in self.iterate_over_parameters(): + par.set_constant(is_constant) + return + @deprecated(setConst_dep_msg) + def setConst(self, const=True): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use + diffpy.srfit.fitbase.parameterset.ParameterSet.set_constant + instead. + """ + self.set_constant(const) return diff --git a/src/diffpy/srfit/fitbase/profile.py b/src/diffpy/srfit/fitbase/profile.py index 6ab7190b..45a295b7 100644 --- a/src/diffpy/srfit/fitbase/profile.py +++ b/src/diffpy/srfit/fitbase/profile.py @@ -29,9 +29,40 @@ from diffpy.srfit.fitbase.parameter import Parameter from diffpy.srfit.fitbase.validatable import Validatable from diffpy.srfit.util.observable import Observable +from diffpy.utils._deprecator import build_deprecation_message, deprecated # This is the roundoff tolerance for selecting bounds on arrays. epsilon = 1e-8 +base = "diffpy.srfit.fitbase.profile.Profile" +removal_version = "4.0.0" + +loadParsedData_dep_msg = build_deprecation_message( + base, + "loadParsedData", + "load_parsed_data", + removal_version, +) + +setObservedProfile_dep_msg = build_deprecation_message( + base, + "setObservedProfile", + "set_observed_profile", + removal_version, +) + +setCalculationRange_dep_msg = build_deprecation_message( + base, + "setCalculationRange", + "set_calculation_range", + removal_version, +) + +setCalculationPoints_dep_msg = build_deprecation_message( + base, + "setCalculationPoints", + "set_calculation_points", + removal_version, +) class Profile(Observable, Validatable): @@ -40,8 +71,6 @@ class Profile(Observable, Validatable): Profile is an Observable. The xpar, ypar and dypar attributes are observed by the Profile, which can in turn be observed by some other object. - Attributes - Attributes ---------- _xobs @@ -60,13 +89,13 @@ class Profile(Observable, Validatable): Read-only property of _dyobs. x A numpy array of the calculated independent variable (default - None, property for xpar accessors). + None, property for ``xpar`` accessors). y The profile over the calculation range (default None, property - for ypar accessors). + for ``ypar`` accessors). dy The uncertainty in the profile over the calculation range - (default None, property for dypar accessors). + (default None, property for ``dypar`` accessors). ycalc A numpy array of the calculated signal (default None). xpar @@ -105,19 +134,19 @@ def __init__(self): # We want x, y, ycalc and dy to stay in-sync with xpar, ypar and dypar x = property( lambda self: self.xpar.getValue(), - lambda self, val: self.xpar.setValue(val), + lambda self, val: self.xpar.set_value(val), ) y = property( lambda self: self.ypar.getValue(), - lambda self, val: self.ypar.setValue(val), + lambda self, val: self.ypar.set_value(val), ) dy = property( lambda self: self.dypar.getValue(), - lambda self, val: self.dypar.setValue(val), + lambda self, val: self.dypar.set_value(val), ) ycalc = property( lambda self: self.ycpar.getValue(), - lambda self, val: self.ycpar.setValue(val), + lambda self, val: self.ycpar.set_value(val), ) # We want xobs, yobs and dyobs to be read-only @@ -125,37 +154,54 @@ def __init__(self): yobs = property(lambda self: self._yobs) dyobs = property(lambda self: self._dyobs) - def loadParsedData(self, parser): + def load_parsed_data(self, parser): """Load parsed data from a ProfileParser. - This sets the xobs, yobs, dyobs arrays as well as the metadata. + This sets the ``xobs``, ``yobs``, ``dyobs`` arrays as well as + the metadata. + + Parameters + ---------- + parser : ProfileParser + The parser holding the observed profile data and metadata. + """ + x, y, dx, dy = parser.get_data() + self.meta = dict(parser.get_metadata()) + self.set_observed_profile(x, y, dy) + return + + @deprecated(loadParsedData_dep_msg) + def loadParsedData(self, parser): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.profile.Profile.load_parsed_data + instead. """ - x, y, junk, dy = parser.getData() - self.meta = dict(parser.getMetaData()) - self.setObservedProfile(x, y, dy) + self.load_parsed_data(parser) return - def setObservedProfile(self, xobs, yobs, dyobs=None): + def set_observed_profile(self, xobs, yobs, dyobs=None): """Set the observed profile. Parameters ---------- - xobs - 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`. - + xobs : numpy.ndarray + The array of the independent variable. + yobs : numpy.ndarray + The array of the observed signal. + dyobs : numpy.ndarray, optional + The array of the uncertainty in the observed signal. If + ``dyobs`` is None (default), ``dyobs`` stays None to + indicate no uncertainty was observed, and the calculated + ``dy`` will be set to 1 at each calculation point instead. Raises - ----------- + ------ ValueError - if len(yobs) != len(xobs) + If ``len(yobs) != len(xobs)``. ValueError - if dyobs != None and len(dyobs) != len(xobs) + If ``dyobs`` is not None and ``len(dyobs) != len(xobs)``. """ if len(yobs) != len(xobs): raise ValueError("xobs and yobs are different lengths") @@ -166,19 +212,31 @@ def setObservedProfile(self, xobs, yobs, dyobs=None): self._yobs = numpy.asarray(yobs, dtype=float) if dyobs is None: - self._dyobs = numpy.ones_like(xobs) + self._dyobs = None else: self._dyobs = numpy.asarray(dyobs, dtype=float) # Set the default calculation points if self.x is None: - self.setCalculationPoints(self._xobs) + self.set_calculation_points(self._xobs) else: - self.setCalculationPoints(self.x) + self.set_calculation_points(self.x) return - def setCalculationRange(self, xmin=None, xmax=None, dx=None): + @deprecated(setObservedProfile_dep_msg) + def setObservedProfile(self, xobs, yobs, dyobs=None): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.fitbase.profile.Profile.set_observed_profile + instead. + """ + self.set_observed_profile(xobs, yobs, dyobs) + return + + def set_calculation_range(self, xmin=None, xmax=None, dx=None): """Set epsilon-inclusive calculation range. Adhere to the observed ``xobs`` points when ``dx`` is the same @@ -187,7 +245,6 @@ def setCalculationRange(self, xmin=None, xmax=None, dx=None): Parameters ---------- - xmin : float or "obs", optional The minimum value of the independent variable. Keep the current minimum when not specified. If specified as "obs" @@ -276,23 +333,39 @@ def _isobs(a): indices = (lo - epslo <= self.xobs) & (self.xobs <= hi + epshi) self.x = self.xobs[indices] self.y = self.yobs[indices] - self.dy = self.dyobs[indices] + self.dy = ( + self.dyobs[indices] + if self.dyobs is not None + else numpy.ones_like(self.x) + ) else: x1 = numpy.arange(lo, hi + epshi, step) - self.setCalculationPoints(x1) + self.set_calculation_points(x1) return - def setCalculationPoints(self, x): + @deprecated(setCalculationRange_dep_msg) + def setCalculationRange(self, xmin=None, xmax=None, dx=None): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.fitbase.profile.Profile.set_calculation_range + instead. + """ + self.set_calculation_range(xmin, xmax, dx) + return + + def set_calculation_points(self, x): """Set the calculation points. + This creates ``y`` and ``dy`` on the specified grid if + ``xobs``, ``yobs`` and ``dyobs`` exist. + Parameters ---------- - x - A non-empty numpy array containing the calculation points. If - xobs exists, the bounds of x will be limited to its bounds. - - This will create y and dy on the specified grid if xobs, yobs and - dyobs exist. + x : numpy.ndarray + The non-empty array of calculation points. If ``xobs`` + exists, the bounds of ``x`` will be limited to its bounds. """ x = numpy.asarray(x) if self.xobs is not None: @@ -300,7 +373,7 @@ def setCalculationPoints(self, x): x = x[x <= self.xobs[-1] + epsilon] self.x = x if self.yobs is not None: - self.y = rebinArray(self.yobs, self.xobs, self.x) + self.y = _rebin_array(self.yobs, self.xobs, self.x) if self.dyobs is not None: # work around for interpolation issue making some of these non-1 if (self.dyobs == 1).all(): @@ -308,30 +381,56 @@ def setCalculationPoints(self, x): else: # FIXME - This does not follow error propagation rules and it # introduces (more) correlation between the data points. - self.dy = rebinArray(self.dyobs, self.xobs, self.x) + self.dy = _rebin_array(self.dyobs, self.xobs, self.x) + elif self.yobs is not None: + self.dy = numpy.ones_like(self.x) return + @deprecated(setCalculationPoints_dep_msg) + def setCalculationPoints(self, x): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.fitbase.profile.Profile.set_calculation_points + instead. + """ + self.set_calculation_points(x) + return + def loadtxt(self, *args, **kw): - """Use numpy.loadtxt to load data. + """Load data using ``numpy.loadtxt``. - Arguments are passed to numpy.loadtxt. unpack = True is - enforced. The first two arrays returned by numpy.loadtxt are - assumed to be x and y. If there is a third array, it is assumed - to by dy. Any other arrays are ignored. These are passed to - setObservedProfile. + Arguments are passed to ``numpy.loadtxt``. ``unpack=True`` is + enforced. The first two arrays returned by ``numpy.loadtxt`` + are assumed to be x and y. If there is a third array, it is + assumed to be dy. Any other arrays are ignored. The loaded + arrays are passed to ``set_observed_profile``. - Raises ValueError if the call to numpy.loadtxt returns fewer - than 2 arrays. + Parameters + ---------- + *args + The positional arguments passed to ``numpy.loadtxt``. + **kw + The keyword arguments passed to ``numpy.loadtxt``. Returns ------- - x - x array loaded from the file. - y - y array loaded from the file. - dy - dy array loaded from the file. + x : numpy.ndarray + The array of the independent variable loaded from the + file. + y : numpy.ndarray + The array of the observed signal loaded from the file. + dy : numpy.ndarray or None + The array of the uncertainty loaded from the file, or None + if no third column is present. + + Raises + ------ + ValueError + If the call to ``numpy.loadtxt`` returns fewer than 2 + arrays. """ if len(args) == 8 and not args[-1]: args = list(args) @@ -350,25 +449,25 @@ def loadtxt(self, *args, **kw): if len(cols) > 2: dy = cols[2] - self.setObservedProfile(x, y, dy) + self.set_observed_profile(x, y, dy) return x, y, dy def savetxt(self, fname, **kwargs): - """Call `numpy.savetxt` with x, ycalc, y, dy. + """Call ``numpy.savetxt`` with x, ycalc, y, dy. Parameters ---------- fname : filename or file handle - This is passed to `numpy.savetxt`. + The filename or file handle passed to ``numpy.savetxt``. **kwargs - The keyword arguments that are passed to `numpy.savetxt`. + The keyword arguments that are passed to ``numpy.savetxt``. We preset file header "x ycalc y dy". Use ``header=''`` to save data without any header. Raises ------ SrFitError - When `self.ycalc` has not been set. + When ``self.ycalc`` has not been set. See also -------- @@ -397,10 +496,15 @@ def _flush(self, other): def _validate(self): """Validate my state. - This validates that x, y, dy, xobx, yobs and dyobs are not None. - This validates that x, y, and dy are the same length. + This validates that ``x``, ``y``, ``dy``, ``xobs`` and + ``yobs`` are not None. ``dyobs`` may be None, since observed + uncertainties are optional. This also validates that ``x``, + ``y``, and ``dy`` are the same length. - Raises SrFitError if validation fails. + Raises + ------ + SrFitError + If validation fails. """ datanotset = any( v is None @@ -410,7 +514,6 @@ def _validate(self): self.dy, self.xobs, self.yobs, - self.dyobs, ] ) if datanotset: @@ -423,25 +526,24 @@ def _validate(self): # End class Profile -def rebinArray(A, xold, xnew): - """Rebin the an array by interpolating over the new x range. +def _rebin_array(A, xold, xnew): + """Rebin an array by interpolating over a new sampling grid. + + This uses linear interpolation via ``numpy.interp``. Parameters ---------- - A - Array to interpolate - xold - Old sampling array - xnew - New sampling array - - - This uses cubic spline interpolation. + A : numpy.ndarray + The array to interpolate. + xold : numpy.ndarray + The old sampling array. + xnew : numpy.ndarray + The new sampling array. Returns ------- - array - A new array over the new sampling array. + numpy.ndarray + The array ``A`` resampled onto ``xnew``. """ if numpy.array_equal(xold, xnew): return A diff --git a/src/diffpy/srfit/fitbase/profilegenerator.py b/src/diffpy/srfit/fitbase/profilegenerator.py index 3007e8de..26a7141b 100644 --- a/src/diffpy/srfit/fitbase/profilegenerator.py +++ b/src/diffpy/srfit/fitbase/profilegenerator.py @@ -110,9 +110,9 @@ class ProfileGenerator(Operator, ParameterSet): Properties ---------- names - Variable names (read only). See getNames. + Variable names (read only). See get_names. values - Variable values (read only). See getValues. + Variable values (read only). See get_values. """ # define abstract attributes from the Operator base. @@ -136,10 +136,18 @@ def symbol(self): def __call__(self, x): """Evaluate the profile. - This method must be overloaded. + This method must be overloaded. It only takes the independent + variable to calculate over. - This method only takes the independent variables to calculate - over. + Parameters + ---------- + x : ndarray + The independent variable over which to calculate. + + Returns + ------- + ndarray + The calculated profile. """ return x @@ -148,7 +156,10 @@ def __call__(self, x): def operation(self): """Evaluate the profile. - Return the result of __call__(profile.x). + Returns + ------- + ndarray + The result of ``__call__(profile.x)``. """ y = self.__call__(self.profile.x) return y @@ -156,10 +167,10 @@ def operation(self): def set_profile(self, profile): """Assign the profile. - Attributes + Parameters ---------- - profile - A Profile that specifies the calculation points and which + profile : Profile + The Profile that specifies the calculation points and which will store the calculated signal. """ if self.profile is not None: @@ -170,14 +181,12 @@ def set_profile(self, profile): self._on_profile_update(other=(self,)) return - def _on_profile_update(self, other=()): - if self.profile is not None: - self.meta.update(self.profile.meta) - self.processMetaData() - self._flush(other=other) + # Merge the profiles metadata with our own + self.meta.update(self.profile.meta) + self._process_metadata() return - def processMetaData(self): + def _process_metadata(self): """Process the metadata. This can be used to configure a ProfileGenerator upon a change @@ -194,7 +203,10 @@ def _validate(self): could be costly. The operation should be validated with a containing equation. - Raises SrFitError if validation fails. + Raises + ------ + SrFitError + If validation fails. """ if self.profile is None: raise SrFitError("profile is None") diff --git a/src/diffpy/srfit/fitbase/profileparser.py b/src/diffpy/srfit/fitbase/profileparser.py index a026e61c..7dd8ca4e 100644 --- a/src/diffpy/srfit/fitbase/profileparser.py +++ b/src/diffpy/srfit/fitbase/profileparser.py @@ -22,56 +22,106 @@ See the class documentation for more information. """ +import warnings + +import numpy as np + from diffpy.srfit.exceptions import ParseError +from diffpy.utils._deprecator import build_deprecation_message, deprecated +from diffpy.utils.parsers import load_data + +removal_verison = "4.0.0" +pp_base = "diffpy.srfit.fitbase.profileparser.ProfileParser" + +parseFile_dep_msg = build_deprecation_message( + pp_base, + "parseFile", + "parse_file", + removal_verison, +) + +getFormat_dep_msg = build_deprecation_message( + pp_base, + "getFormat", + "get_format", + removal_verison, +) + +getNumBanks_dep_msg = build_deprecation_message( + pp_base, + "getNumBanks", + "get_num_banks", + removal_verison, +) + +selectBank_dep_msg = build_deprecation_message( + pp_base, + "selectBank", + "select_bank", + removal_verison, +) + +getData_dep_msg = build_deprecation_message( + pp_base, + "getData", + "get_data", + removal_verison, +) + +getMetaData_dep_msg = build_deprecation_message( + pp_base, + "getMetaData", + "get_metadata", + removal_verison, +) class ProfileParser(object): - """Class for parsing data from a or string. + """Base class for parsing profile data from a file. Attributes ---------- - _format - Name of the data format that this parses (string, default - ""). The format string is a unique identifier for the data + _format : str, optional + The name of the data format that this parses (string, default + ``""``). The format string is a unique identifier for the data format handled by the parser. - _banks + _banks : list of tuples The data from each bank. Each bank contains a (x, y, dx, dy) tuple: - x - A numpy array containing the independent - variable read from the file. - y - A numpy array containing the profile + x : np.ndarray + The independent variable read from the file. + y : np.ndarray + The dependent variable (profile) read from the file. - dx - A numpy array containing the uncertainty in x + dx : np.ndarray + The uncertainties associated with x + read from the file. This is None if the + uncertainty cannot be read. + dy : np.ndarray + The uncertainties associated with y read from the file. This is None if the uncertainty cannot be read. - dy - A numpy array containing the uncertainty read - from the file. This is None if the uncertainty - cannot be read. - _x + _x : np.ndarray Independent variable from the chosen bank - _y + _y : np.ndarray Profile from the chosen bank - _dx + _dx : np.ndarray Uncertainty in independent variable from the chosen bank - _dy + _dy : np.ndarray Uncertainty in profile from the chosen bank - _meta + _meta : dict A dictionary containing metadata read from the file. General Metadata ---------------- - filename + filename : str or Path The name of the file from which data was parsed. This key will not exist if data was not read from file. - nbanks + nbanks : int The number of banks parsed. - bank + bank : int The chosen bank number. """ @@ -87,65 +137,246 @@ def __init__(self): self._dy = None return - def getFormat(self): - """Get the format string.""" - return self._format + def get_format(self): + """Get the format string. - def parseString(self, patstring): - """Parse a string and set the _x, _y, _dx, _dy and _meta variables. + Returns + ------- + str + The unique identifier for the data format handled by this + parser. + """ + return self._format - When _dx or _dy cannot be obtained in the data format it is set to - None. + @deprecated(getFormat_dep_msg) + def getFormat(self): + """This function is deprecated and will be removed in version + 4.0.0. - This wipes out the currently loaded data and selected bank number. + Please use diffpy.srfit.fitbase.ProfileParser.get_format + instead. + """ + return self.get_format() - Parameters - ---------- - patstring - A string containing the pattern + @deprecated(parseFile_dep_msg) + def parseFile(self, filename): + """This function is deprecated and will be removed in version + 4.0.0. - Raises - ---------- - ParseError if the string cannot be parsed + Please use diffpy.srfit.fitbase.ProfileParser.parse_file + instead. """ - raise NotImplementedError() + return self.parse_file(filename) - def parseFile(self, filename): - """Parse a file and set the _x, _y, _dx, _dy and _meta variables. + _reserved_metadata_keys = {"filename", "bank", "nbanks"} + + def parse_file( + self, filename, column_format=None, metadata=None, **kwargs + ): + """Parse a data file to extract data and metadata, with + automatic handling of uncertainties. + + This is a template method. Subclasses customize a format by + overriding the ``_parse_metadata`` and ``_parse_data`` hooks rather + than this method. + + The default ``_parse_data`` reads a single bank: + + - For files with 2 columns: assumes (x, y) and sets dx, dy to None. + - For files with 3 columns: assumes (x, y, dy) and sets dx to None. + - For files with 4 columns: assumes (x, y, dx, dy). + - For other cases: ``column_format`` must be explicitly specified. + + Uncertainty columns (dx, dy) are only considered valid if all values + are positive and not NaN/Inf. Otherwise they are set to None. This wipes out the currently loaded data and selected bank number. Parameters ---------- - filename - The name of the file to parse + filename : str or Path + The name of the file to parse. + column_format : tuple of str, optional + The order in which columns appear in the file. + If None, the format is auto-detected based on the + number of columns. + + Valid labels: ``"x"``, ``"y"``, ``"dx"``, ``"dy"`` + + Examples: + + - ``("x", "y")`` + - ``("x", "y", "dy")`` + - ``("x", "y", "dx", "dy")`` + - ``("x", "dx", "y", "dy")`` + + metadata : dict, optional + Additional metadata to merge into the metadata parsed from + the file. Keys must be strings. A key that collides with + one already present in the parsed metadata overrides the + parsed value. A key that collides with ``"filename"``, + ``"bank"``, or ``"nbanks"``, which ``parse_file`` sets itself, + also overrides the automatically set value, but raises a + ``UserWarning`` since it may affect other code that relies + on the automatically set value. + + kwargs + The keyword arguments passed on to + ``diffpy.utils.parsers.load_data``, such as ``usecols``, + ``delimiter``, ``comments`` and ``minrows``. Use ``usecols`` to + select four columns out of a wider file, then label them + with ``column_format``. Raises - ---------- - IOError - if the file cannot be read + ------ ParseError - if the file cannot be parsed + If parsing fails or ambiguity detected. """ - infile = open(filename, "r") + metadata = self._validate_metadata(metadata) self._banks = [] self._meta = {} - filestring = infile.read() - self.parseString(filestring) - infile.close() - self._meta["filename"] = filename - + self._meta.update(self._parse_metadata(filename)) + self._parse_data(filename, column_format, **kwargs) + self._meta["filename"] = str(filename) if len(self._banks) < 1: raise ParseError("There are no data in the banks") + self.select_bank(0) + self._apply_extra_metadata(metadata) + + @staticmethod + def _validate_metadata(metadata): + """Validate and copy a user-supplied metadata dict.""" + if metadata is None: + return None + if not isinstance(metadata, dict): + raise ParseError( + "The metadata argument must be a dictionary. " + f"Received type '{type(metadata).__name__}' instead." + ) + for key in metadata: + if not isinstance(key, str): + raise ParseError( + f"Key '{key}' in the metadata dictionary is not a " + "string. All keys in the metadata dictionary must " + "be strings." + ) + return dict(metadata) + + def _apply_extra_metadata(self, metadata): + """Merge validated user-supplied metadata into + ``self._meta``.""" + if not metadata: + return + for key in metadata: + if key in self._reserved_metadata_keys: + warnings.warn( + f"'{key}' is a reserved metadata key normally set " + "by parse_file. The supplied value will override " + "it." + ) + self._meta.update(metadata) + + def _parse_metadata(self, filename): + """Return the metadata read from the header of a file. + + Override this hook to parse a format whose header is not a plain + list of ``name = value`` pairs. + """ + return load_data(filename, headers=True) - self.selectBank(0) - return + def _parse_data(self, filename, column_format=None, **kwargs): + """Append the banks read from the data block of a file. - def getNumBanks(self): - """Get the number of banks read by the parser.""" + Override this hook to parse a format whose data block is not a + plain matrix of columns, or one that holds several banks. + """ + data = load_data(filename, **kwargs) + if data.size == 0 or data.ndim == 1: + raise ParseError( + "Data block must have at least two columns (x, y)." + ) + column_format = self._detect_column_format(data, column_format) + columns = self._map_column_labels_to_data(data, column_format) + self._banks.append( + [ + columns["x"], + columns["y"], + self._validate_uncertainty(columns.get("dx")), + self._validate_uncertainty(columns.get("dy")), + ] + ) + + def _detect_column_format(self, data, column_format): + """Auto-detect or validate column format.""" + num_cols = data.shape[1] + + if column_format is None: + if num_cols == 2: + column_format = ("x", "y") + elif num_cols == 3: + column_format = ("x", "y", "dy") + elif num_cols == 4: + column_format = ("x", "y", "dx", "dy") + else: + raise ParseError( + f"Expected 2 to 4 columns but found {num_cols}." + ) + if len(column_format) != num_cols: + raise ParseError( + f"column_format has {len(column_format)} " + f"labels but file contains {num_cols} columns." + ) + if len(set(column_format)) != len(column_format): + raise ParseError("column_format cannot contain duplicate labels.") + for label in column_format: + if label not in {"x", "y", "dx", "dy"}: + raise ParseError( + f"column_format contains invalid label '{label}'. " + "Valid labels are 'x', 'y', 'dx', and 'dy'." + ) + return column_format + + def _map_column_labels_to_data(self, data, column_format): + """Map numeric data to columns by label.""" + columns = {} + for i, label in enumerate(column_format): + columns[label] = data[:, i] + + if "x" not in columns or "y" not in columns: + raise ParseError( + "Both 'x' and 'y' columns must be present in the data." + ) + + return columns + + @staticmethod + def _validate_uncertainty(data): + """Return the uncertainty data if valid, otherwise None.""" + if data is None or not np.all(np.isfinite(data)) or np.any(data <= 0): + return None + return data + + def get_num_banks(self): + """Get the number of banks read by the parser. + + Returns + ------- + int + The number of banks read by the parser. + """ return len(self._banks) - def selectBank(self, index): + @deprecated(getNumBanks_dep_msg) + def getNumBanks(self): + """This function is deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.ProfileParser.get_num_banks + instead. + """ + return self.get_num_banks() + + def select_bank(self, index): """Select which bank to use. This method should only be called after the data has been parsed. The @@ -155,18 +386,18 @@ def selectBank(self, index): Parameters ---------- - index - index of bank (integer, starting at 0). + index : int + The index of the bank (integer, starting at 0). Raises - ---------- + ------ IndexError - if requesting a bank that does not exist + If requesting a bank that does not exist. """ if index is None: index = self._meta.get("bank", 0) - numbanks = self.getNumBanks() + numbanks = self.get_num_banks() if index > numbanks: raise IndexError("Bank index out of range") @@ -181,7 +412,18 @@ def selectBank(self, index): self._x, self._y, self._dx, self._dy = self._banks[index] return - def getData(self, index=None): + @deprecated(selectBank_dep_msg) + def selectBank(self, index): + """This function is deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.ProfileParser.select_bank + instead. + """ + self.select_bank(index) + return + + def get_data(self, index=None): """Get the data. This method should only be called after the data has been parsed. The @@ -191,21 +433,47 @@ def getData(self, index=None): Parameters ---------- - index - index of bank (integer, starting at 0, default None). If - index is None then the currently selected bank is used. + index : int, optional + The index of the 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. + ------- + tuple + The ``(x, y, dx, dy)`` tuple for the bank. ``dx`` and ``dy`` + are None if they cannot be determined from the data format. """ - self.selectBank(index) + self.select_bank(index) return self._x, self._y, self._dx, self._dy + @deprecated(getData_dep_msg) + def getData(self, index=None): + """This function is deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.ProfileParser.get_data instead. + """ + return self.get_data(index) + + def get_metadata(self): + """Get the parsed metadata. + + Returns + ------- + dict + A dictionary containing metadata read from the file. + """ + return self._meta + + @deprecated(getMetaData_dep_msg) def getMetaData(self): - """Get the parsed metadata.""" + """This function is deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.fitbase.ProfileParser.get_metadata + instead. + """ return self._meta diff --git a/src/diffpy/srfit/fitbase/recipeorganizer.py b/src/diffpy/srfit/fitbase/recipeorganizer.py index 5f3d2ade..caa28aed 100644 --- a/src/diffpy/srfit/fitbase/recipeorganizer.py +++ b/src/diffpy/srfit/fitbase/recipeorganizer.py @@ -17,17 +17,16 @@ RecipeContainer is the base class for organizing Parameters, and other RecipeContainers. RecipeOrganizer is an extended RecipeContainer that incorporates equation building, constraints and Restraints. -equationFromString creates an Equation instance from a string. +get_equation_from_string creates an Equation instance from a string. """ -__all__ = ["RecipeContainer", "RecipeOrganizer", "equationFromString"] +__all__ = ["RecipeContainer", "RecipeOrganizer", "get_equation_from_string"] import re from collections import OrderedDict from functools import partial from itertools import chain, groupby -import six from numpy import inf from diffpy.srfit.equation import Equation @@ -42,6 +41,131 @@ from diffpy.srfit.util import sortKeyForNumericString as numstr from diffpy.srfit.util.nameutils import validateName from diffpy.srfit.util.observable import Observable +from diffpy.utils._deprecator import build_deprecation_message, deprecated + +recipecontainer_base = "diffpy.srfit.fitbase.recipeorganizer.RecipeContainer" +removal_version = "4.0.0" + +getValues_deprecation_msg = build_deprecation_message( + recipecontainer_base, + "getValues", + "get_values", + removal_version, +) + +getNames_deprecation_msg = build_deprecation_message( + recipecontainer_base, + "getNames", + "get_names", + removal_version, +) + +iterPars_deprecation_msg = build_deprecation_message( + recipecontainer_base, + "iterPars", + "iterate_over_parameters", + removal_version, +) + +recipeorganizer_base = "diffpy.srfit.fitbase.recipeorganizer.RecipeOrganizer" + +registerCalculator_deprecation_msg = build_deprecation_message( + recipeorganizer_base, + "registerCalculator", + "register_calculator", + removal_version, +) + +registerFunction_deprecation_msg = build_deprecation_message( + recipeorganizer_base, + "registerFunction", + "register_function", + removal_version, +) + +registerStringFunction_deprecation_msg = build_deprecation_message( + recipeorganizer_base, + "registerStringFunction", + "register_string_function", + removal_version, +) + +evaluateEquation_deprecation_msg = build_deprecation_message( + recipeorganizer_base, + "evaluateEquation", + "evaluate_equation", + removal_version, +) + +isConstrained_deprecation_msg = build_deprecation_message( + recipeorganizer_base, + "isConstrained", + "is_constrained", + removal_version, +) + +getConstrainedPars_deprecation_msg = build_deprecation_message( + recipeorganizer_base, + "getConstrainedPars", + "get_constrained_parmeters", + removal_version, +) + +clearConstraints_deprecation_msg = build_deprecation_message( + recipeorganizer_base, + "clearConstraints", + "clear_all_constraints", + removal_version, +) + +addRestraint_deprecation_msg = build_deprecation_message( + recipeorganizer_base, + "addRestraint", + "register_soft_bounds", + removal_version, +) + +constrain_deprecation_msg = build_deprecation_message( + recipeorganizer_base, + "constrain", + "add_constraint", + removal_version, +) + +unconstrain_deprecation_msg = build_deprecation_message( + recipeorganizer_base, + "unconstrain", + "remove_constraint", + removal_version, +) + +restrain_deprecation_msg = build_deprecation_message( + recipeorganizer_base, + "restrain", + "add_soft_bounds", + removal_version, +) + +unrestrain_deprecation_msg = build_deprecation_message( + recipeorganizer_base, + "unrestrain", + "remove_soft_bounds", + removal_version, +) + +clearRestraints_deprecation_msg = build_deprecation_message( + recipeorganizer_base, + "clearRestraints", + "clear_all_soft_bounds", + removal_version, +) + +equationFromString_deprecation_msg = build_deprecation_message( + recipeorganizer_base, + "equationFromString", + "get_equation_from_string", + removal_version, +) class RecipeContainer(Observable, Configurable, Validatable): @@ -50,10 +174,10 @@ class RecipeContainer(Observable, Configurable, Validatable): RecipeContainers are hierarchical organizations of Parameters and other RecipeContainers. This class provides attribute-access to these contained objects. Parameters and other RecipeContainers can be found within the - hierarchy with the _locate_managed_object method. + hierarchy with the `_locate_managed_object` method. A RecipeContainer can manage dictionaries for that store various objects. - These dictionaries can be added to the RecipeContainer using the _manage + These dictionaries can be added to the RecipeContainer using the `_manage` method. RecipeContainer methods that add, remove or retrieve objects will work with any managed dictionary. This makes it easy to add new types of objects to be contained by a RecipeContainer. By default, the @@ -83,13 +207,13 @@ class RecipeContainer(Observable, Configurable, Validatable): Properties ---------- names - Variable names (read only). See getNames. + Variable names (read only). See get_names. values - Variable values (read only). See getValues. + Variable values (read only). See get_values. """ - names = property(lambda self: self.getNames()) - values = property(lambda self: self.getValues()) + names = property(lambda self: self.get_names()) + values = property(lambda self: self.get_values()) def __init__(self, name): Observable.__init__(self) @@ -116,48 +240,170 @@ 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 iterate_over_parameters( + 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). - recurse : bool - Recurse into managed objects when True (default). + pattern : str, optional + The regular expression pattern to match parameter + names against. Only parameters with names matching + this pattern will be returned. Default is an empty + string, which matches all parameter names. + recurse : bool, optional + The flag indicating whether to recurse into managed + objects when iterating over parameters. If True + (default), the method will also iterate over + parameters in managed sub-objects. If False, only + top-level parameters will be iterated over. + fullnames : bool, optional + The flag indicating whether to match against hierarchical + dotted names relative to this object. + If False (default), match only leaf parameter names. + + Yields + ------ + Parameter + The next Parameter whose name matches `pattern`. + + Examples + -------- + + .. + for param in recipe.iterate_over_parameters(pattern="scale_"): + # print the name and value of parameters containing "scale_" + print(f"{param.name}={param.value}") """ regexp = re.compile(pattern) - for par in list(self._parameters.values()): - if regexp.search(par.name): - yield par - if not recurse: - return - # Iterate over objects within the managed dictionaries. + if not fullnames: + yield from self._iterpars_leafnames(regexp, pattern, recurse) + else: + yield from self._iterpars_fullnames( + regexp, recurse=recurse, prefix="" + ) + + def _iter_local_parameters(self, regexp, prefix=""): + """Iterate over local Parameters with matching names.""" + for parameter in list(self._parameters.values()): + name = f"{prefix}{parameter.name}" + if regexp.search(name): + yield parameter + + def _iter_managed_parameter_containers(self): + """Iterate over managed objects that can iterate over + Parameters.""" 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): - yield par - return + for managed_dict in managed: + for obj in managed_dict.values(): + if hasattr(obj, "iterate_over_parameters"): + yield obj + + def _iterpars_leafnames(self, regexp, pattern, recurse=True): + """Iterate over Parameters matched by leaf parameter names.""" + yield from self._iter_local_parameters(regexp) + if recurse: + for obj in self._iter_managed_parameter_containers(): + yield from obj.iterate_over_parameters( + pattern=pattern, + recurse=True, + ) + + def _iter_child_fullname_parameters(self, obj, regexp, prefix): + """Iterate over one child's Parameters with hierarchical + names.""" + if hasattr(obj, "_iterpars_fullnames"): + childprefix = f"{prefix}{obj.name}." + yield from obj._iterpars_fullnames( + regexp, + recurse=True, + prefix=childprefix, + ) + else: + yield from obj.iterate_over_parameters( + pattern=regexp.pattern, + recurse=True, + ) + + def _iterpars_fullnames(self, regexp, recurse=True, prefix=""): + """Iterate over Parameters matched by hierarchical dotted + names.""" + yield from self._iter_local_parameters(regexp, prefix=prefix) + if recurse: + for obj in self._iter_managed_parameter_containers(): + yield from self._iter_child_fullname_parameters( + obj, + regexp, + prefix, + ) + + @deprecated(iterPars_deprecation_msg) + def iterPars(self, pattern="", recurse=True): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use + diffpy.srfit.fitbase.recipeorganizer.RecipeContainer.iterate_over_parameters + instead. + """ + return self.iterate_over_parameters(pattern=pattern, recurse=recurse) def __iter__(self): - """Iterate over top-level parameters.""" + """Iterate over top-level parameters. + + Returns + ------- + iterator + The iterator over the top-level Parameters. + """ return iter(self._parameters.values()) def __len__(self): - """Get number of top-level parameters.""" + """Get number of top-level parameters. + + Returns + ------- + int + The number of top-level Parameters. + """ return len(self._parameters) def __getitem__(self, idx): - """Get top-level parameters by index.""" + """Get top-level parameters by index. + + Parameters + ---------- + idx : int or slice + The index, or slice, of the top-level Parameters to get. + + Returns + ------- + Parameter or list of Parameter + The Parameter, or list of Parameters, at `idx`. + """ # need to wrap this in a list for python 3 compatibility. return list(self._parameters.values())[idx] def __getattr__(self, name): - """Gives access to the contained objects as attributes.""" + """Give access to the contained objects as attributes. + + Parameters + ---------- + name : str + The name of the managed object to retrieve. + + Returns + ------- + object + The managed object registered under `name`. + + Raises + ------ + AttributeError + If no managed object is registered under `name`. + """ arg = self.get(name) if arg is None: raise AttributeError(name) @@ -172,7 +418,14 @@ def __getattr__(self, name): ) def __dir__(self): - """Return sorted list of attributes for this object.""" + """Return sorted list of attributes for this object. + + Returns + ------- + list of str + The sorted list of attribute names, including managed + objects. + """ rv = set(dir(type(self))) rv.update(self.__dict__) # self.get fetches looks up for items in all managed dictionaries. @@ -186,13 +439,34 @@ def __dir__(self): __managed = [] def __setattr__(self, name, value): - """Parameter access and object checking.""" + """Set an attribute, routing Parameter names to Parameter + values. + + If `name` matches a managed Parameter, the Parameter's value is + set rather than replacing the Parameter itself. Otherwise this + behaves like normal attribute assignment, except that a managed + non-Parameter object of that name may not be overwritten. + + Parameters + ---------- + name : str + The name of the attribute to set. + value + The value to assign. If `name` refers to a managed + Parameter, this may be a plain value or a Parameter, whose + value will be copied. + + Raises + ------ + AttributeError + If `name` refers to a managed, non-Parameter object. + """ if name in self._parameters: - par = self._parameters[name] + parameter = self._parameters[name] if isinstance(value, Parameter): - par.value = value.value + parameter.value = value.value else: - par.value = value + parameter.value = value return m = self.get(name) @@ -203,11 +477,21 @@ def __setattr__(self, name, value): return def __delattr__(self, name): - """Delete parameters with del. + """Delete parameters with ``del``. This does not allow deletion of non-parameters, as this may require configuration changes that are not yet handled in a general way. + + Parameters + ---------- + name : str + The name of the Parameter to delete. + + Raises + ------ + AttributeError + If `name` refers to a managed, non-Parameter object. """ if name in self._parameters: self._remove_parameter(self._parameters[name]) @@ -221,7 +505,22 @@ def __delattr__(self, name): return def get(self, name, default=None): - """Get a managed object.""" + """Get a managed object. + + Parameters + ---------- + name : str + The name of the managed object to retrieve. + default : optional + The value to return if no managed object is found under + `name` (default None). + + Returns + ------- + object + The managed object registered under `name`, or `default` + if no such object exists. + """ for d in self.__managed: arg = d.get(name) if arg is not None: @@ -229,31 +528,68 @@ def get(self, name, default=None): return default - def getNames(self): - """Get the names of managed parameters.""" + def get_names(self): + """Get the names of managed parameters. + + Returns + ------- + list of str + The names of the managed Parameters. + """ return [p.name for p in self._parameters.values()] - def getValues(self): - """Get the values of managed parameters.""" + @deprecated(getNames_deprecation_msg) + def getNames(self): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.fitbase.recipeorganizer.RecipeContainer.get_names + instead. + """ + return self.get_names() + + def get_values(self): + """Get the values of managed parameters. + + Returns + ------- + list + The values of the managed Parameters. + """ return [p.value for p in self._parameters.values()] + @deprecated(getValues_deprecation_msg) + def getValues(self): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.fitbase.recipeorganizer.RecipeContainer.get_values + instead. + """ + return self.get_values() + def _add_object(self, obj, d, check=True): """Add an object to a managed dictionary. - Attributes + Parameters ---------- obj The object to be stored. d The managed dictionary to store the object in. check - If True (default), a ValueError is raised an object of the - given name already exists. - - - Raises ValueError if the object has no name. - Raises ValueError if the object has the same name as some other managed - object. + If True (default), a ValueError is raised if an object of + the given name already exists. + + Raises + ------ + ValueError + If the object has no name. + ValueError + If the object has the same name as some other managed + object. """ # Check name if not obj.name: @@ -294,7 +630,10 @@ def _add_object(self, obj, d, check=True): def _remove_object(self, obj, d): """Remove an object from a managed dictionary. - Raises ValueError if obj is not part of the dictionary. + Raises + ------ + ValueError + If `obj` is not part of the dictionary. """ if obj not in d.values(): m = "'%s' is not part of the %s" % (obj, self.__class__.__name__) @@ -308,16 +647,18 @@ def _remove_object(self, obj, d): def _locate_managed_object(self, obj): """Find the location a managed object within the hierarchy. - Attributes + Parameters ---------- obj The object to find. - - Returns a list of objects. The first member of the list is this object, - and each subsequent member is a sub-object of the previous one. The - last entry in the list is obj. If obj cannot be found, the list is - empty. + Returns + ------- + list + The list of objects. The first member of the list is this + object, and each subsequent member is a sub-object of the + previous one. The last entry in the list is `obj`. If `obj` + cannot be found, the list is empty. """ loc = [self] @@ -356,7 +697,10 @@ def _validate(self): This validates that contained Parameters and managed objects are valid. - Raises AttributeError if validation fails. + Raises + ------ + AttributeError + If validation fails. """ iterable = chain(self.__iter__(), self._iter_managed()) self._validate_others(iterable) @@ -373,7 +717,7 @@ class RecipeOrganizer(_recipeorganizer_interface, RecipeContainer): Restraints, as well as Equations that can be used in Constraint and Restraint equations. These constraints and Restraints can be placed at any level and a flattened list of them can be retrieved with the - _get_constraints and _get_restraints methods. + `_get_constraints` and `_get_restraints` methods. Attributes ---------- @@ -399,12 +743,14 @@ class RecipeOrganizer(_recipeorganizer_interface, RecipeContainer): Properties ---------- names - Variable names (read only). See getNames. + Variable names (read only). See get_names. values - Variable values (read only). See getValues. + Variable values (read only). See get_values. - - Raises ValueError if the name is not a valid attribute identifier + Raises + ------ + ValueError + If the name is not a valid attribute identifier. """ def __init__(self, name): @@ -423,56 +769,66 @@ def _new_parameter(self, name, value, check=True): """Add a new Parameter to the container. This creates a new Parameter and adds it to the container using - the _add_parameter method. + the `_add_parameter` method. - Returns the Parameter. + Returns + ------- + Parameter + The newly created Parameter. """ p = Parameter(name, value) self._add_parameter(p, check) return p - def _add_parameter(self, par, check=True): + def _add_parameter(self, parameter, check=True): """Store a Parameter. Parameters added in this way are registered with the _eqfactory. - Attributes + Parameters ---------- - par + parameter The Parameter to be stored. check - If True (default), a ValueError is raised a Parameter of + If True (default), a ValueError is raised if a Parameter of the specified name has already been inserted. - - Raises ValueError if the Parameter has no name. - Raises ValueError if the Parameter has the same name as a contained - RecipeContainer. + Raises + ------ + ValueError + If the Parameter has no name. + ValueError + If the Parameter has the same name as a contained + RecipeContainer. """ # Store the Parameter - RecipeContainer._add_object(self, par, self._parameters, check) + RecipeContainer._add_object(self, parameter, self._parameters, check) # Register the Parameter - self._eqfactory.registerArgument(par.name, par) + self._eqfactory.registerArgument(parameter.name, parameter) return - def _remove_parameter(self, par): + def _remove_parameter(self, parameter): """Remove a parameter. - This de-registers the Parameter with the _eqfactory. The + This de-registers the Parameter with the `_eqfactory`. The Parameter will remain part of built equations. Note that constraints and restraints involving the Parameter are not modified. - Raises ValueError if par is not part of the RecipeOrganizer. + Raises + ------ + ValueError + If `parameter` is not part of the RecipeOrganizer. """ - self._remove_object(par, self._parameters) - self._eqfactory.deRegisterBuilder(par.name) + self._remove_object(parameter, self._parameters) + self._eqfactory.deRegisterBuilder(parameter.name) return - def registerCalculator(self, f, argnames=None): - """Register a Calculator so it can be used within equation strings. + def register_calculator(self, calculator, argnames=None): + """Register a Calculator so it can be used within equation + strings. A Calculator is an elaborate function that can organize Parameters. This creates a function with this class that can be used within string @@ -480,99 +836,133 @@ def registerCalculator(self, f, argnames=None): arguments like a function or without, in which case the values of the Parameters created from argnames will be be used to compute the value. - Attributes + Parameters ---------- - f + calculator : Calculator object The Calculator to register. - argnames - The names of the arguments to f (list or None). + argnames : list or None, optional + The names of the arguments to `calculator` (list or None). If this is None, then the argument names will be extracted from the function. + + Returns + ------- + Equation + The callable Equation object wrapping `calculator`. """ - self._eqfactory.registerOperator(f.name, f) - self._add_object(f, self._calculators) + self._eqfactory.registerOperator(calculator.name, calculator) + self._add_object(calculator, self._calculators) # Register arguments of the calculator if argnames is None: - fncode = f.__call__.__func__.__code__ + fncode = calculator.__call__.__func__.__code__ argnames = list(fncode.co_varnames) argnames = argnames[1 : fncode.co_argcount] for pname in argnames: if pname not in self._eqfactory.builders: - par = self._new_parameter(pname, 0) + parameter = self._new_parameter(pname, 0) else: - par = self.get(pname) - f.addLiteral(par) + parameter = self.get(pname) + calculator.addLiteral(parameter) # Now return an equation object - eq = self._eqfactory.makeEquation(f.name) + eq = self._eqfactory.makeEquation(calculator.name) return eq - def registerFunction(self, f, name=None, argnames=None): - """Register a function so it can be used within equation strings. + @deprecated(registerCalculator_deprecation_msg) + def registerCalculator(self, f, argnames=None): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use + diffpy.srfit.fitbase.recipeorganizer.RecipeOrganizer.register_calculator + instead. + """ + return self.register_calculator(f, argnames=argnames) + + def register_function(self, function, name=None, argnames=None): + """Register a function so it can be used within equation + strings. This creates a function with this class that can be used within string - equations. The resulting equation does not require the arguments to be + equations. The resulting equation does not require the arguments to be passed in the equation string, as this will be handled automatically. - Attributes + Parameters ---------- - f + function : callable The callable to register. If this is an Equation instance, then all that needs to be provided is a name. - name + name : str or None, optional The name of the function to be used in equations. If this is None (default), the method will try to determine the name of the function automatically. - argnames - The names of the arguments to f (list or None). + argnames : list or None, optional + The names of the arguments to `function` (list or None). If this is None (default), then the argument names will be extracted from the function. - - Note that name and argnames can be extracted from regular python - functions (of type 'function'), bound class methods and callable + Returns + ------- + equation_object : Equation + The callable Equation object. + + Raises + ------ + TypeError + If name or argnames cannot be automatically extracted. + TypeError + If an automatically extracted name is ''. + ValueError + If function is an Equation object and name is None. + + Notes + ----- + The `name` and `argnames` args can be extracted from regular Python + functions (of type ), bound class methods, and callable classes. - - - Raises TypeError if name or argnames cannot be automatically - extracted. - Raises TypeError if an automatically extracted name is ''. - Raises ValueError if f is an Equation object and name is None. - - Returns the callable Equation object. """ # If the function is an equation, we treat it specially. This is # required so that the objects observed by the root get observed if the # Equation is used within another equation. It is assumed that a plain # function is not observable. - if isinstance(f, Equation): + if isinstance(function, Equation): if name is None: - m = "Equation must be given a name" + m = ( + "The equation must be given a name. " + "Specify a name with the 'name' argument." + ) raise ValueError(m) - self._eqfactory.registerOperator(name, f) - return f + self._eqfactory.registerOperator(name, function) + return function # Introspection code if name is None or argnames is None: import inspect + # A decorator such as `deprecated` replaces the code object with + # that of its (*args, **kwargs) wrapper, so introspect the + # function it wraps while still registering the decorated one. + wrapped_function = inspect.unwrap(function) + fncode = None # This will let us offset the argument list to eliminate 'self' offset = 0 # check regular functions - if inspect.isfunction(f): - fncode = f.__code__ + if inspect.isfunction(wrapped_function): + fncode = wrapped_function.__code__ # check class method - elif inspect.ismethod(f): - fncode = f.__func__.__code__ + elif inspect.ismethod(function): + fncode = function.__func__.__code__ offset = 1 # check functor - elif hasattr(f, "__call__") and hasattr(f.__call__, "__func__"): - fncode = f.__call__.__func__.__code__ + elif hasattr(wrapped_function, "__call__") and hasattr( + wrapped_function.__call__, "__func__" + ): + fncode = wrapped_function.__call__.__func__.__code__ offset = 1 else: m = "Cannot extract name or argnames" @@ -600,178 +990,272 @@ def registerFunction(self, f, name=None, argnames=None): # Initialize and register from diffpy.srfit.fitbase.calculator import Calculator - if isinstance(f, Calculator): + if isinstance(function, Calculator): for pname in argnames: - par = self.get(pname) - f.addLiteral(par) - self._eqfactory.registerOperator(name, f) + parameter = self.get(pname) + function.addLiteral(parameter) + self._eqfactory.registerOperator(name, function) else: - self._eqfactory.registerFunction(name, f, argnames) + self._eqfactory.register_function(name, function, argnames) # Now we can create the Equation and return it to the user. - eq = self._eqfactory.makeEquation(name) + equation_object = self._eqfactory.makeEquation(name) - return eq + return equation_object - def registerStringFunction(self, fstr, name, ns={}): + @deprecated(registerFunction_deprecation_msg) + def registerFunction(self, f, name=None, argnames=None): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use + diffpy.srfit.fitbase.recipeorganizer.RecipeOrganizer.register_function + instead. + """ + return self.register_function(f, name=name, argnames=argnames) + + def register_string_function(self, function_str, name, func_params={}): """Register a string function. This creates a function with this class that can be used within string - equations. The resulting equation does not require the arguments to be + equations. The resulting equation does not require the arguments to be passed in the function string, as this will be handled automatically. - Attributes + Parameters ---------- - fstr + function_str : str A string equation to register. - name + name : str The name of the function to be used in equations. - ns + func_params : dict, optional A dictionary of Parameters, indexed by name, that are - used in fstr, but not part of the FitRecipe (default - {}). + used in `function_str`, but not part of the FitRecipe (default {}). - - Raises ValueError if ns uses a name that is already used for another - managed object. - Raises ValueError if the function name is the name of another managed - object. - - Returns the callable Equation object. + Returns + ------- + equation_object : Equation + The callable Equation object. + + Raises + ------ + ValueError + If `func_params` uses a name that is already used for another + managed object. + ValueError + If the function name is the name of another managed object. """ # Build the equation instance. - eq = equationFromString(fstr, self._eqfactory, ns=ns, buildargs=True) + eq = get_equation_from_string( + function_str, self._eqfactory, ns=func_params, buildargs=True + ) eq.name = name # Register any new Parameters. - for par in self._eqfactory.newargs: - self._add_parameter(par) + for parameter in self._eqfactory.newargs: + self._add_parameter(parameter) # Register the equation as a callable function. argnames = eq.argdict.keys() - return self.registerFunction(eq, name, argnames) + equation_object = self.register_function( + eq, name=name, argnames=argnames + ) + return equation_object - def evaluateEquation(self, eqstr, ns={}): + @deprecated(registerStringFunction_deprecation_msg) + def registerStringFunction(self, fstr, name, ns={}): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use + diffpy.srfit.fitbase.recipeorganizer.RecipeOrganizer.register_string_function + instead. + """ + return self.register_string_function(fstr, name, func_params=ns) + + def evaluate_equation(self, equation_str, func_params={}): """Evaluate a string equation. - Attributes + This method takes a string representation of a mathematical equation + and evaluates it using the current values of the registered Parameters + in the FitRecipe. Additional parameters not part of the FitRecipe can + also be provided via the `func_params` dictionary. + + Parameters ---------- - eqstr - A string equation to evaluate. The equation is evaluated at + equation_str + The string equation to evaluate. The equation is evaluated at the current value of the registered Parameters. - ns - A dictionary of Parameters, indexed by name, that are - used in fstr, but not part of the FitRecipe (default {}). + func_params : dict, optional + The dictionary of Parameters, indexed by name, that are + used in `equation_str`, but not part of the FitRecipe + (default `{}`). - - Raises ValueError if ns uses a name that is already used for a - variable. + Returns + ------- + returned_value : float + The value of the evaluated equation. + + Raises + ------ + ValueError + If `func_params` uses a name that is already used for a + variable. """ - eq = equationFromString(eqstr, self._eqfactory, ns) + eq = get_equation_from_string( + equation_str, self._eqfactory, func_params + ) try: - rv = eq() + returned_value = eq() finally: self._eqfactory.wipeout(eq) - return rv + return returned_value - def constrain(self, par, con, ns={}): + @deprecated(evaluateEquation_deprecation_msg) + def evaluateEquation(self, eqstr, ns={}): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use + diffpy.srfit.fitbase.recipeorganizer.RecipeOrganizer.evaluate_equation + instead. + """ + return self.evaluate_equation(eqstr, func_params=ns) + + def add_constraint(self, parameter, constraint_eq, params={}): """Constrain a parameter to an equation. Note that only one constraint can exist on a Parameter at a time. - Attributes + Parameters ---------- - par + parameter : str or Parameter The name of a Parameter or a Parameter to constrain. - con + constraint_eq : str or Equation A string representation of the constraint equation or a - Parameter to constrain to. A constraint equation must + Parameter to constrain to. A constraint equation must consist of numpy operators and "known" Parameters. - Parameters are known if they are in the ns argument, or if - they are managed by this object. - ns + Parameters are known if they are in the `params` + argument, or if they are managed by this object. + params : dict, optional A dictionary of Parameters, indexed by name, that are used - in the parameter, but not part of this object (default {}). - - - Raises ValueError if ns uses a name that is already used for a - variable. - Raises ValueError if par is a string but not part of this object or in - ns. - Raises ValueError if par is marked as constant. + in `parameter`, but not part of this object (default {}). + + Raises + ------ + ValueError + If `params` uses a name that is already used for a + variable. + ValueError + If `parameter` is a string but not part of this object or + in `params`. + ValueError + If `parameter` is marked as constant. """ - if isinstance(par, six.string_types): - name = par - par = self.get(name) - if par is None: - par = ns.get(name) + if isinstance(parameter, str): + name = parameter + parameter = self.get(name) + if parameter is None: + parameter = params.get(name) - if par is None: + if parameter is None: raise ValueError("The parameter cannot be found") - if par.const: - raise ValueError("The parameter '%s' is constant" % par) + if parameter.const: + raise ValueError("The parameter '%s' is constant" % parameter) - if isinstance(con, six.string_types): - eqstr = con - eq = equationFromString(con, self._eqfactory, ns) + if isinstance(constraint_eq, str): + eqstr = constraint_eq + eq = get_equation_from_string( + constraint_eq, self._eqfactory, params + ) else: - eq = Equation(root=con) - eqstr = con.name + eq = Equation(root=constraint_eq) + eqstr = constraint_eq.name - eq.name = "_constraint_%s" % par.name + eq.name = "_constraint_%s" % parameter.name # Make and store the constraint - con = Constraint() - con.constrain(par, eq) + constraint_eq = Constraint() + constraint_eq.add_constraint(parameter, eq) # Store the equation string so it can be shown later. - con.eqstr = eqstr - self._constraints[par] = con + constraint_eq.eqstr = eqstr + self._constraints[parameter] = constraint_eq # Our configuration changed self._update_configuration() return - def isConstrained(self, par): + @deprecated(constrain_deprecation_msg) + def constrain(self, par, con, ns={}): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use + diffpy.srfit.fitbase.recipeorganizer.RecipeOrganizer.add_constraint + instead. + """ + self.add_constraint(par, con, params=ns) + return + + def is_constrained(self, parameter): """Determine if a Parameter is constrained in this object. - Attributes + Parameters ---------- - par + parameter : str or Parameter The name of a Parameter or a Parameter to check. + + Returns + ------- + bool + True if the Parameter is constrained in this object, False + otherwise. """ - if isinstance(par, six.string_types): - name = par - par = self.get(name) + if isinstance(parameter, str): + name = parameter + parameter = self.get(name) - return par in self._constraints + return parameter in self._constraints - def unconstrain(self, *pars): + @deprecated(isConstrained_deprecation_msg) + def isConstrained(self, par): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use + diffpy.srfit.fitbase.recipeorganizer.RecipeOrganizer.is_constrained + instead. + """ + return self.is_constrained(par) + + def remove_constraint(self, *pars): """Unconstrain a Parameter. This removes any constraints on a Parameter. - Attributes + Parameters ---------- - *pars + *pars : str or Parameter The names of Parameters or Parameters to unconstrain. - - Raises ValueError if the Parameter is not constrained. + Raises + ------ + ValueError + If the Parameter is not constrained. """ update = False - for par in pars: - if isinstance(par, six.string_types): - name = par - par = self.get(name) + for parameter in pars: + if isinstance(parameter, str): + name = parameter + parameter = self.get(name) - if par is None: + if parameter is None: raise ValueError("The parameter cannot be found") - if par in self._constraints: - self._constraints[par].unconstrain() - del self._constraints[par] + if parameter in self._constraints: + self._constraints[parameter].remove_constraint() + del self._constraints[parameter] update = True if update: @@ -784,94 +1268,190 @@ def unconstrain(self, *pars): return - def getConstrainedPars(self, recurse=False): + @deprecated(unconstrain_deprecation_msg) + def unconstrain(self, *pars): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use + diffpy.srfit.fitbase.recipeorganizer.RecipeOrganizer.remove_constraint + instead. + """ + self.remove_constraint(*pars) + return + + def get_constrained_parmeters(self, recurse=False): """Get a list of constrained managed Parameters in this object. - Attributes + Parameters ---------- - recurse - Recurse into managed objects and retrieve their constrained - Parameters as well (default False). + recurse : bool, optional + If False (default), only constrained Parameters in + this object are returned. If True, constrained + Parameters in managed sub-objects are also included. + + Returns + ------- + constrained_params : list of Parameter + The list of constrained managed Parameters in this object. """ const = self._get_constraints(recurse) - return const.keys() + constrained_params = const.keys() + return constrained_params - def clearConstraints(self, recurse=False): - """Clear all constraints managed by this organizer. + @deprecated(getConstrainedPars_deprecation_msg) + def getConstrainedPars(self, recurse=False): + """This function has been deprecated and will be removed in + version 4.0.0. - Attributes - ---------- - recurse - Recurse into managed objects and clear all constraints - found there as well. + Please use + diffpy.srfit.fitbase.recipeorganizer.RecipeOrganizer.get_constrained_parmeters + instead. + """ + return self.get_constrained_parmeters(recurse=recurse) + def clear_all_constraints(self, recurse=False): + """Clear all constraints managed by this organizer. This removes constraints that are held in this organizer, no matter where the constrained parameters are from. + + Parameters + ---------- + recurse : bool, optional + If False (default), only constraints in this object + are cleared. If True, constraints in managed + sub-objects are also cleared. """ if self._constraints: - self.unconstrain(*self._constraints) + self.remove_constraint(*self._constraints) if recurse: for m in filter(_has_clear_constraints, self._iter_managed()): - m.clearConstraints(recurse) + m.clear_all_constraints(recurse) return - def restrain(self, res, lb=-inf, ub=inf, sig=1, scaled=False, ns={}): - """Restrain an expression to specified bounds. - - Attributes - ---------- - res - An equation string or Parameter to restrain. - lb - The lower bound on the restraint evaluation (default -inf). - ub - The lower bound on the restraint evaluation (default inf). - sig - The uncertainty on the bounds (default 1). - scaled - A flag indicating if the restraint is scaled (multiplied) - by the unrestrained point-average chi^2 (chi^2/numpoints) - (default False). - ns - A dictionary of Parameters, indexed by name, that are used - in the equation string, but not part of the RecipeOrganizer - (default {}). + @deprecated(clearConstraints_deprecation_msg) + def clearConstraints(self, recurse=False): + """This function has been deprecated and will be removed in + version 4.0.0. + Please use + diffpy.srfit.fitbase.recipeorganizer.RecipeOrganizer.clear_all_constraints + instead. + """ + return self.clear_all_constraints(recurse=recurse) + + def add_soft_bounds( + self, + param_or_eq, + lower_bound=-inf, + upper_bound=inf, + sig=1, + scaled=False, + params={}, + ): + """Restrain an expression to specified bounds. - The penalty is calculated as - (max(0, lb - val, val - ub)/sig)**2 - and val is the value of the calculated equation. This is multiplied by - the average chi^2 if scaled is True. + See Notes for how the penalty is calculated. + Parameters + ---------- + param_or_eq : str + The equation or parameter to restrain. + lower_bound : float, optional + The lower bound for the restraint evaluation (default is -inf). + upper_bound : float, optional + The upper bound for the restraint evaluation (default is inf). + sig : float, optional + The uncertainty associated with the bounds (default is 1). + Please see Notes for how this is used in the penalty calculation. + scaled : bool, optional + If True, the restraint penalty is scaled by the unrestrained + point-average chi^2 (chi^2/numpoints) (default is False). + params : dict, optional + The dictionary of Parameters, indexed by name, that are used in + `param_or_eq` (if an equation string is used) but are not part + of the RecipeOrganizer (default is {}). - Raises ValueError if ns uses a name that is already used for a - Parameter. - Raises ValueError if res depends on a Parameter that is not part of - the RecipeOrganizer and that is not defined in ns. + Returns + ------- + Restraint + The created Restraint object, which can be used with the + 'unrestrain' method. + + Notes + ----- + The penalty is calculated as: + + .. + (max(0, lower_bound - val, val - upper_bound) / sig) ** 2 + + where `val` is the value of the evaluated `param_or_eq`. + If `scaled` is True, this penalty is multiplied by + the average chi^2. + + Examples + -------- + Restraining the lattice parameters of an Ni lattice to be + approximately 7.4Å (2x the original lattice param) + can be done with the following code: + .. + recipe.add_soft_bounds( + "a_ni + b_ni", + lower_bound=7.0, + upper_bound=7.5, + sig=0.1, + scaled=True, + params={"b_ni": Parameter("b_ni", 3.473)} + ) - Returns the Restraint object for use with the 'unrestrain' method. + Raises + ------ + ValueError + If `params` contains a name that is already used + for a Parameter. + ValueError + If `param_or_eq` depends on a Parameter that is not part of the + RecipeOrganizer and is not defined in `params`. """ - if isinstance(res, six.string_types): - eqstr = res - eq = equationFromString(res, self._eqfactory, ns) + if isinstance(param_or_eq, str): + eqstr = param_or_eq + eq = get_equation_from_string(param_or_eq, self._eqfactory, params) else: - eq = Equation(root=res) - eqstr = res.name + eq = Equation(root=param_or_eq) + eqstr = param_or_eq.name # Make and store the restraint - res = Restraint(eq, lb, ub, sig, scaled) - res.eqstr = eqstr - self.addRestraint(res) - return res + param_or_eq = Restraint(eq, lower_bound, upper_bound, sig, scaled) + param_or_eq.eqstr = eqstr + self.register_soft_bounds(param_or_eq) + return param_or_eq - def addRestraint(self, res): + @deprecated(restrain_deprecation_msg) + def restrain(self, res, lb=-inf, ub=inf, sig=1, scaled=False, ns={}): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use + diffpy.srfit.fitbase.recipeorganizer.RecipeOrganizer.add_soft_bounds + instead. + """ + return self.add_soft_bounds( + res, + lower_bound=lb, + upper_bound=ub, + sig=sig, + scaled=scaled, + params=ns, + ) + + def register_soft_bounds(self, res): """Add a Restraint instance to the RecipeOrganizer. - Attributes + Parameters ---------- - res + res : Restraint A Restraint instance. """ self._restraints.add(res) @@ -879,14 +1459,26 @@ def addRestraint(self, res): self._update_configuration() return - def unrestrain(self, *ress): + @deprecated(addRestraint_deprecation_msg) + def addRestraint(self, res): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use + diffpy.srfit.fitbase.recipeorganizer.RecipeOrganizer.register_soft_bounds + instead. + """ + self.register_soft_bounds(res) + return + + def remove_soft_bounds(self, *ress): """Remove a Restraint from the RecipeOrganizer. - Attributes + Parameters ---------- - *ress - Restraints returned from the 'restrain' method or added - with the 'addRestraint' method. + *ress : Restraint + The Restraints returned from the 'add_soft_bounds' method or added + with the 'register_soft_bounds' method. """ update = False restuple = tuple(self._restraints) @@ -901,23 +1493,49 @@ def unrestrain(self, *ress): return - def clearRestraints(self, recurse=False): + @deprecated(unrestrain_deprecation_msg) + def unrestrain(self, *ress): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use + diffpy.srfit.fitbase.recipeorganizer.RecipeOrganizer.remove_soft_bounds + instead. + """ + self.remove_soft_bounds(*ress) + return + + def clear_all_soft_bounds(self, recurse=False): """Clear all restraints. - Attributes + Parameters ---------- - recurse - Recurse into managed objects and clear all restraints - found there as well. + recurse : bool, optional + If False (default), only restraints in this object are + cleared. If True, restraints in managed sub-objects are + also cleared. """ - self.unrestrain(*self._restraints) + self.remove_soft_bounds(*self._restraints) if recurse: for msg in filter(_has_clear_restraints, self._iter_managed()): - msg.clearRestraints(recurse) + msg.clear_all_soft_bounds(recurse) + return + + @deprecated(clearRestraints_deprecation_msg) + def clearRestraints(self, recurse=False): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use + diffpy.srfit.fitbase.recipeorganizer.RecipeOrganizer.clear_all_soft_bounds + instead. + """ + self.clear_all_soft_bounds(recurse=recurse) return def _get_constraints(self, recurse=True): - """Get the constrained Parameters for this and managed sub-objects.""" + """Get the constrained Parameters for this and managed sub- + objects.""" constraints = {} if recurse: for m in filter(_has_get_constraints, self._iter_managed()): @@ -945,7 +1563,10 @@ def _validate(self): This performs RecipeContainer validations. This validates contained Restraints and Constraints. - Raises AttributeError if validation fails. + Raises + ------ + AttributeError + If validation fails. """ RecipeContainer._validate(self) iterable = chain(self._restraints, self._constraints.values()) @@ -965,7 +1586,7 @@ def _format_managed(self, prefix=""): Returns ------- list - List of formatted lines, one per each Parameter. + The list of formatted lines, one per each Parameter. """ lines = [] formatstr = "{: xmax or if dx <= 0. Also if dx > xmax - xmin. """ - return self.profile.setCalculationRange(xmin, xmax, dx) + return self.profile.set_calculation_range(xmin, xmax, dx) - def setCalculationPoints(self, x): + @deprecated(setCalculationRange_dep_msg) + def setCalculationRange(self, xmin=None, xmax=None, dx=None): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.fitbase.SimpleRecipe.set_calculation_range + instead. + """ + return self.set_calculation_range(xmin, xmax, dx) + + def set_calculation_points(self, x): """Set the calculation points. + This will create y and dy on the specified grid if xobs, yobs + and dyobs exist. + Parameters ---------- - x - A non-empty numpy array containing the calculation points. If - xobs exists, the bounds of x will be limited to its bounds. + x : ndarray + The non-empty array of calculation points. If xobs exists, + the bounds of x will be limited to its bounds. + """ + return self.profile.set_calculation_points(x) - This will create y and dy on the specified grid if xobs, yobs and - dyobs exist. + @deprecated(setCalculationPoints_dep_msg) + def setCalculationPoints(self, x): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.fitbase.SimpleRecipe.set_calculation_points + instead. """ - return self.profile.setCalculationPoints(x) + return self.set_calculation_points(x) def loadtxt(self, *args, **kw): """Use numpy.loadtxt to load data. @@ -194,38 +304,44 @@ def loadtxt(self, *args, **kw): enforced. The first two arrays returned by numpy.loadtxt are assumed to be x and y. If there is a third array, it is assumed to by dy. Any other arrays are ignored. These are passed to - setObservedProfile. + set_observed_profile. - Raises ValueError if the call to numpy.loadtxt returns fewer - than 2 arrays. + Raises + ------ + ValueError + If the call to numpy.loadtxt returns fewer than 2 arrays. - Returns the x, y and dy arrays loaded from the file + Returns + ------- + tuple + The x, y and dy arrays loaded from the file. """ return self.profile.loadtxt(*args, **kw) # FitContribution - def setEquation(self, eqstr, ns={}): + def set_equation(self, eqstr, ns={}): """Set the profile equation for the FitContribution. This sets the equation that will be used when generating the residual. - The equation will be usable within setResidualEquation as "eq", and it - takes no arguments. + The equation will be usable within set_residual_equation as "eq", and + it takes no arguments. - Attributes + Parameters ---------- - eqstr - A string representation of the equation. Variables will be - extracted from this equation and be given an initial value - of 0. - ns - A dictionary of Parameters, indexed by name, that are used - in the eqstr, but not registered (default {}). - + eqstr : str + The string representation of the equation. Variables will + be extracted from this equation and be given an initial + value of 0. + ns : dict, optional + The dictionary of Parameters, indexed by name, that are + used in the eqstr, but not registered (default {}). - Raises ValueError if ns uses a name that is already used for a - variable. + Raises + ------ + ValueError + If ns uses a name that is already used for a variable. """ - self.contribution.setEquation(eqstr, ns={}) + self.contribution.set_equation(eqstr, ns={}) # Extract variables for par in self.contribution: # Skip Profile Parameters @@ -234,7 +350,19 @@ def setEquation(self, eqstr, ns={}): if par.value is None: par.value = 0 if par.name not in self._parameters: - self.addVar(par) + self.add_variable(par) + return + + @deprecated(setEquation_dep_msg) + def setEquation(self, eqstr, ns={}): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.fitbase.SimpleRecipe.set_equation + instead. + """ + self.set_equation(eqstr, ns) return def __call__(self): @@ -242,33 +370,55 @@ def __call__(self): return self.contribution.evaluate() # FitResults methods - - def printResults(self, header="", footer=""): + def print_results(self, header="", footer=""): """Format and print the results. - Attributes + Parameters ---------- - header - A header to add to the output (default "") - footer - A footer to add to the output (default "") + header : str, optional + The header to add to the output (default ""). + footer : str, optional + The footer to add to the output (default ""). """ - self.results.printResults(header, footer, True) + self.results.print_results(header, footer, True) return - def saveResults(self, filename, header="", footer=""): + @deprecated(printResults_dep_msg) + def printResults(self, header="", footer=""): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.fitbase.SimpleRecipe.print_results + instead. + """ + self.print_results(header, footer) + return + + def save_results(self, filename, header="", footer=""): """Format and save the results. Parameters ---------- - filename - Name of the save file. - header - A header to add to the output (default "") - footer - A footer to add to the output (default "") + filename : str + The name of the save file. + header : str, optional + The header to add to the output (default ""). + footer : str, optional + The footer to add to the output (default ""). + """ + self.results.save_results(filename, header, footer, True) + + @deprecated(saveResults_dep_msg) + def saveResults(self, filename, header="", footer=""): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.fitbase.SimpleRecipe.save_results + instead. """ - self.results.saveResults(filename, header, footer, True) + self.save_results(filename, header, footer) # End class SimpleRecipe diff --git a/src/diffpy/srfit/fitbase/validatable.py b/src/diffpy/srfit/fitbase/validatable.py index 5a28b132..6a6af045 100644 --- a/src/diffpy/srfit/fitbase/validatable.py +++ b/src/diffpy/srfit/fitbase/validatable.py @@ -22,17 +22,22 @@ class Validatable(object): - """Validatable class. + """Base class for objects with state that must be validated. A Validatable has state that must be validated by a FitRecipe. """ def _validate_others(self, iterable): - """Method to validate configuration of Validatables in iterable. + """Validate the configuration of Validatables in iterable. - This is provided as a convenience for derived classes. No need + This is provided as a convenience for derived classes. No need to overload this. Call this method from overloaded _validate with an iterable of other Validatables. + + Parameters + ---------- + iterable : iterable + The Validatables to validate. """ for obj in iterable: if obj is self: @@ -47,7 +52,10 @@ def _validate(self): Overload this in a derived class. - Raises AttributeError if validation fails. + Raises + ------ + AttributeError + If validation fails. """ # Validate self here. # Then validate others. diff --git a/src/diffpy/srfit/interface/interface.py b/src/diffpy/srfit/interface/interface.py index 9ec30790..fc0b78e9 100644 --- a/src/diffpy/srfit/interface/interface.py +++ b/src/diffpy/srfit/interface/interface.py @@ -36,14 +36,19 @@ class ParameterInterface(object): """Mix-in class for enhancing the Parameter interface.""" def __lshift__(self, v): - """SetValue with << + """Set the value with ``<<``. - Think of '<<' as injecting a value + Think of ``<<`` as injecting a value. - Attributes + Parameters ---------- - v - value or Argument derivative + v : float or ArgumentABC + The value, or Argument derivative, to assign. + + Returns + ------- + ParameterInterface + Return self after the value has been set. """ if isinstance(v, ArgumentABC): self.value = v.value @@ -61,31 +66,60 @@ class RecipeOrganizerInterface(object): """Mix-in class for enhancing the RecipeOrganizer interface.""" def __imul__(self, args): - """Constrain with *= + """Constrain with ``*=``. + + Think of ``*`` as a push-pin. This accepts arguments for a + single constraint. - Think of '*' as a push-pin. + Parameters + ---------- + args : tuple + The arguments to pass to `constrain`. - This accepts arguments for a single constraint. + Returns + ------- + RecipeOrganizerInterface + Return self after applying the constraint. """ _applyargs(args, self.constrain) return self def __imod__(self, args): - """Restrain with %= + """Restrain with ``%=``. - This of '%' as a loose rope. + Think of ``%`` as a loose rope. This accepts arguments for a + single restraint. + + Parameters + ---------- + args : tuple + The arguments to pass to `restrain`. - This accepts arguments for a single restraint. + Returns + ------- + RecipeOrganizerInterface + Return self after applying the restraint. """ _applyargs(args, self.restrain) return self def __iadd__(self, args): - """_new_parameter or _add_parameter with += + """Add a parameter with ``+=``. - Think of "+" as addition of a Parameter. + Think of ``+`` as addition of a Parameter. This accepts + arguments for a single call to `_new_parameter` or + `_add_parameter`. - This accepts arguments for a single function call. + Parameters + ---------- + args : tuple + The arguments to pass to `_new_parameter` or + `_add_parameter`. + + Returns + ------- + RecipeOrganizerInterface + Return self after adding the parameter. """ # Want to detect _add_parameter or _new_parameter @@ -109,30 +143,49 @@ class FitRecipeInterface(object): """Mix-in class for enhancing the FitRecipe interface.""" def __ior__(self, args): - """AddContribution with |= + """Add a contribution with ``|=``. + + Think of ``|`` as the union of components. This accepts a + single argument. - Think of "|" as the union of components. + Parameters + ---------- + args : FitContribution + The contribution to add. - This accepts a single argument. + Returns + ------- + FitRecipeInterface + Return self after adding the contribution. """ - self.addContribution(args) + self.add_contribution(args) return self def __iadd__(self, args): - """AddVar or newVar with += + """Add a variable with ``+=``. - Think of "+" as addition of a variable. + Think of ``+`` as addition of a variable. This accepts a + single argument or an iterable of single arguments or + argument tuples. - This accepts a single argument or an iterable of single - arguments or argument tuples. + Parameters + ---------- + args + The arguments to pass to `add_variable` or + `create_new_variable`. + + Returns + ------- + FitRecipeInterface + Return self after adding the variable. """ - # Want to detect addVar or newVar + # Want to detect add_variable or create_new_variable def f(*args): if isinstance(args[0], six.string_types): - self.newVar(*args) + self.create_new_variable(*args) else: - self.addVar(*args) + self.add_variable(*args) return _applymanyargs(args, f) diff --git a/src/diffpy/srfit/pdf/basepdfgenerator.py b/src/diffpy/srfit/pdf/basepdfgenerator.py index c01c7a37..0c4d7341 100644 --- a/src/diffpy/srfit/pdf/basepdfgenerator.py +++ b/src/diffpy/srfit/pdf/basepdfgenerator.py @@ -93,7 +93,13 @@ class BasePDFGenerator(ProfileGenerator): """ def __init__(self, name="pdf"): - """Initialize the generator.""" + """Initialize the generator. + + Parameters + ---------- + name : str, optional + The name for this generator (default "pdf"). + """ ProfileGenerator.__init__(self, name) self._phase = None @@ -117,21 +123,20 @@ def _set_calculator(self, calc): self._calc = calc for pname in self.__class__._parnames: self.addParameter(ParameterAdapter(pname, self._calc, attr=pname)) - self.processMetaData() + self._process_metadata() return def parallel(self, ncpu, mapfunc=None): """Run calculation in parallel. - Attributes + Parameters ---------- - ncpu - Number of parallel processes. Revert to serial mode when 1. - mapfunc - A mapping function to use. If this is None (default), - multiprocessing.Pool.imap_unordered will be used. - - No return value. + ncpu : int + The number of parallel processes. Revert to serial mode + when 1. + mapfunc : callable, optional + The mapping function to use. If this is None (default), + ``multiprocessing.Pool.imap_unordered`` will be used. """ from diffpy.srreal.parallel import createParallelCalculator @@ -154,9 +159,9 @@ def parallel(self, ncpu, mapfunc=None): self._calc = createParallelCalculator(calc_serial, ncpu, mapfunc) return - def processMetaData(self): + def _process_metadata(self): """Process the metadata once it gets set.""" - ProfileGenerator.processMetaData(self) + ProfileGenerator._process_metadata(self) stype = self.meta.get("stype") if stype is not None: @@ -174,21 +179,24 @@ def processMetaData(self): val = self.meta.get(name) if val is not None: par = self.get(name) - par.setValue(val) + par.set_value(val) return def setScatteringType(self, stype="X"): """Set the scattering type. - Attributes + Parameters ---------- - stype + stype : str, optional "X" for x-ray, "N" for neutron, "E" for electrons, or any registered type from diffpy.srreal from - ScatteringFactorTable.getRegisteredTypes(). + ScatteringFactorTable.getRegisteredTypes() (default "X"). - Raises ValueError for unknown scattering type. + Raises + ------ + ValueError + If stype is not a recognized scattering type. """ self._calc.setScatteringFactorTableByType(stype) # update the meta dictionary only if there was no exception @@ -198,28 +206,57 @@ def setScatteringType(self, stype="X"): def getScatteringType(self): """Get the scattering type. - See 'setScatteringType'. + See ``setScatteringType``. + + Returns + ------- + str + The scattering type used to calculate the PDF. """ return self._calc.getRadiationType() def setQmax(self, qmax): - """Set the qmax value.""" + """Set the qmax value. + + Parameters + ---------- + qmax : float + The maximum scattering vector used to generate the PDF. + """ self._calc.qmax = qmax self.meta["qmax"] = self.getQmax() return def getQmax(self): - """Get the qmax value.""" + """Get the qmax value. + + Returns + ------- + float + The maximum scattering vector used to generate the PDF. + """ return self._calc.qmax def setQmin(self, qmin): - """Set the qmin value.""" + """Set the qmin value. + + Parameters + ---------- + qmin : float + The minimum scattering vector used to generate the PDF. + """ self._calc.qmin = qmin self.meta["qmin"] = self.getQmin() return def getQmin(self): - """Get the qmin value.""" + """Get the qmin value. + + Returns + ------- + float + The minimum scattering vector used to generate the PDF. + """ return self._calc.qmin def setStructure(self, stru, name="phase", periodic=True): @@ -230,15 +267,15 @@ def setStructure(self, stru, name="phase", periodic=True): See those classes (located in diffpy.srfit.structure) for how they are used. The resulting ParameterSet will be managed by this generator. - Attributes + Parameters ---------- stru - diffpy.structure.Structure, pyobjcryst.crystal.Crystal or - pyobjcryst.molecule.Molecule instance. Default None. - name - A name to give to the managed ParameterSet that adapts stru + The diffpy.structure.Structure, pyobjcryst.crystal.Crystal or + pyobjcryst.molecule.Molecule instance to adapt (default None). + name : str, optional + The name to give to the managed ParameterSet that adapts stru (default "phase"). - periodic + periodic : bool, optional The structure should be treated as periodic (default True). Note that some structures do not support periodicity, in which case this will have no effect on the @@ -259,14 +296,14 @@ def setPhase(self, parset, periodic=True): object (from diffpy or pyobjcryst). The passed ParameterSet will be managed by this generator. - Attributes + Parameters ---------- parset - A SrRealParSet that holds the structural information. + The SrRealParSet that holds the structural information. This can be used to share the phase between multiple BasePDFGenerators, and have the changes in one reflect in another. - periodic + periodic : bool, optional The structure should be treated as periodic (default True). Note that some structures do not support periodicity, in which case this will be ignored. @@ -276,7 +313,7 @@ def setPhase(self, parset, periodic=True): self.stru = self._phase.stru # Put this ParameterSet in the ProfileGenerator. - self.addParameterSet(parset) + self.add_parameter_set(parset) # Set periodicity self._phase.useSymmetry(periodic) @@ -298,7 +335,10 @@ def _validate(self): This validates that the phase is not None. This performs ProfileGenerator validations. - Raises SrFitError if validation fails. + Raises + ------ + SrFitError + If validation fails. """ if self._calc is None: raise SrFitError("_calc is None") @@ -315,6 +355,16 @@ def __call__(self, r): evaluated, the crystal has been updated by the optimizer via the ObjCrystParSet created in setCrystal. Thus, we need only call pdf with the internal structure object. + + Parameters + ---------- + r : np.ndarray + The independent variable over which to calculate the PDF. + + Returns + ------- + np.ndarray + The calculated PDF, evaluated over r. """ if not numpy.array_equal(r, self._lastr): self._prepare(r) diff --git a/src/diffpy/srfit/pdf/characteristicfunctions.py b/src/diffpy/srfit/pdf/characteristicfunctions.py index e74ac399..4e760538 100644 --- a/src/diffpy/srfit/pdf/characteristicfunctions.py +++ b/src/diffpy/srfit/pdf/characteristicfunctions.py @@ -12,7 +12,8 @@ # See LICENSE_DANSE.txt for license information. # ############################################################################## -"""Form factors (characteristic functions) used in PDF nanoshape fitting. +"""Form factors (characteristic functions) used in PDF nanoshape +fitting. These are used to calculate the attenuation of the PDF due to a finite size. For a crystal-like nanoparticle, one can calculate the PDF via @@ -20,10 +21,16 @@ function and Gcryst(f) is the crystal PDF. These functions are meant to be imported and added to a FitContribution -using the 'registerFunction' method of that class. +using the 'register_function' method of that class. """ __all__ = [ + "spherical_particle", + "spheroidal_particle", + "lognormal_spherical_particle", + "sheet_particle", + "shell_particle", + "SASCF", "sphericalCF", "spheroidalCF", "spheroidalCF2", @@ -31,9 +38,10 @@ "sheetCF", "shellCF", "shellCF2", - "SASCF", ] +import warnings + import numpy from numpy import arctan as atan from numpy import arctanh as atanh @@ -42,88 +50,204 @@ from scipy.special import erf from diffpy.srfit.fitbase.calculator import Calculator +from diffpy.utils._deprecator import build_deprecation_message, deprecated +removal_version = "4.0.0" +cf_base = "diffpy.srfit.pdf.characteristicfunctions" -def sphericalCF(r, psize): - """Spherical nanoparticle characteristic function. - - Attributes - ---------- - r - distance of interaction - psize - The particle diameter +def _build_dep_msg(old_name, new_name, signature_note=None): + """Build the deprecation message for a camel case function. - From Kodama et al., Acta Cryst. A, 62, 444-453 - (converted from radius to diameter) + The note describing how the signature changed, when there is one, is + appended to the standard message from `build_deprecation_message`. """ - f = numpy.zeros(numpy.shape(r), dtype=float) - if psize > 0: - x = numpy.array(r, dtype=float) / psize - inside = x < 1.0 - xin = x[inside] - f[inside] = 1.0 - 1.5 * xin + 0.5 * xin * xin * xin - return f + message = build_deprecation_message( + cf_base, old_name, new_name, removal_version + ) + if signature_note is None: + return message + return f"{message} {signature_note}" -def spheroidalCF(r, erad, prad): - """Spheroidal characteristic function specified using radii. +sphericalCF_dep_msg = _build_dep_msg( + "sphericalCF", + "spherical_particle", + "Additionally, the signature has changed. Please pass the parameter " + "'psize' as 'particle_diameter'.", +) - Spheroid with radii (erad, erad, prad) +spheroidalCF_dep_msg = _build_dep_msg( + "spheroidalCF", + "spheroidal_particle", + "Additionally, the signature has changed. Please pass the parameters " + "'erad' and 'prad' as 'equatorial_radius' and 'polar_radius', " + "respectively.", +) + +spheroidalCF2_dep_msg = _build_dep_msg( + "spheroidalCF2", + "spheroidal_particle", + "Additionally, the parameterization has changed. 'spheroidalCF2' took " + "the equatorial diameter 'psize' and the axis ratio 'axrat', while " + "'spheroidal_particle' takes radii. Please pass " + "equatorial_radius = psize / 2 and polar_radius = axrat * psize / 2.", +) + +lognormalSphericalCF_dep_msg = _build_dep_msg( + "lognormalSphericalCF", + "lognormal_spherical_particle", + "Additionally, the signature has changed. Please pass the parameters " + "'psize' and 'psig' as 'particle_diameter' and " + "'particle_diameter_sigma', respectively.", +) + +sheetCF_dep_msg = _build_dep_msg( + "sheetCF", + "sheet_particle", + "Additionally, the signature has changed. Please pass the parameter " + "'sthick' as 'sheet_thickness'.", +) + +shellCF_dep_msg = _build_dep_msg("shellCF", "shell_particle") + +shellCF2_dep_msg = _build_dep_msg( + "shellCF2", + "shell_particle", + "Additionally, the parameterization has changed. 'shellCF2' took the " + "central radius 'a' and the shell thickness 'delta', while " + "'shell_particle' takes the inner radius. Please pass " + "radius = a - delta / 2 and thickness = delta.", +) - Attributes - ---------- - prad - polar radius - erad - equatorial radius +# The record of non-physical input warnings already issued in this process, +# keyed by (function name, requirement). See _warn_non_physical. +_warned_non_physical = set() - erad < prad equates to a prolate spheroid - erad > prad equates to a oblate spheroid - erad == prad is a sphere + +def _warn_non_physical(function_name, requirement): + """Warn that non-physical input produced a zero characteristic + function. + + A refinement evaluates a characteristic function on every iteration, + so the warning is issued only once per process for each distinct + requirement. The message must not include the offending value, since + the `warnings` registry keys on the message text and a value that + drifts from iteration to iteration would defeat the suppression. """ - psize = 2.0 * erad - pelpt = 1.0 * prad / erad - return spheroidalCF2(r, psize, pelpt) + key = (function_name, requirement) + if key in _warned_non_physical: + return + _warned_non_physical.add(key) + warnings.warn( + f"In '{function_name}', {requirement}. The characteristic function " + "was set to zero for every r, which flattens the fit residual so a " + "refinement cannot recover on its own. Please use a physically " + "meaningful starting value, or keep the parameter in range with " + "FitRecipe.add_soft_bounds.", + RuntimeWarning, + stacklevel=3, + ) -def spheroidalCF2(r, psize, axrat): - """Spheroidal nanoparticle characteristic function. +def spherical_particle(r, particle_diameter): + """Compute the spherical nanoparticle characteristic function. - Form factor for ellipsoid with radii (psize/2, psize/2, axrat*psize/2) + From Kodama et al., Acta Cryst. A, 62, 444-453 (converted from + radius to diameter). - Attributes + Parameters ---------- - r - distance of interaction - psize - The equatorial diameter - axrat - The ratio of axis lengths + r : array_like + The distance of interaction. + particle_diameter : float + The particle diameter. + + Returns + ------- + numpy.ndarray + The characteristic function values evaluated at `r`. + + Warns + ----- + RuntimeWarning + If `particle_diameter` is not positive, in which case the + characteristic function is zero everywhere. The warning is issued + only once per process. + """ + characteristic_function = numpy.zeros(numpy.shape(r), dtype=float) + if particle_diameter <= 0: + _warn_non_physical( + "spherical_particle", "'particle_diameter' must be positive" + ) + return characteristic_function + scaled_r = numpy.array(r, dtype=float) / particle_diameter + inside = scaled_r < 1.0 + scaled_r_inside = scaled_r[inside] + characteristic_function[inside] = ( + 1.0 - 1.5 * scaled_r_inside + 0.5 * scaled_r_inside**3 + ) + return characteristic_function + + +def spheroidal_particle(r, equatorial_radius, polar_radius): + """Compute the spheroidal nanoparticle characteristic function. + Spheroid with radii (equatorial_radius, equatorial_radius, + polar_radius). ``equatorial_radius < polar_radius`` equates to a + prolate spheroid, ``equatorial_radius > polar_radius`` equates to + an oblate spheroid, and ``equatorial_radius == polar_radius`` is a + sphere. From Lei et al., Phys. Rev. B, 80, 024118 (2009). - From Lei et al., Phys. Rev. B, 80, 024118 (2009) + Parameters + ---------- + r : array_like + The distance of interaction. + equatorial_radius : float + The equatorial radius. + polar_radius : float + The polar radius. + + Returns + ------- + numpy.ndarray + The characteristic function values evaluated at `r`. + + Warns + ----- + RuntimeWarning + If `equatorial_radius` or `polar_radius` is not positive, in which + case the characteristic function is zero everywhere. The warning is + issued only once per process. """ - pelpt = 1.0 * axrat + if equatorial_radius <= 0: + _warn_non_physical( + "spheroidal_particle", "'equatorial_radius' must be positive" + ) + return numpy.zeros(numpy.shape(r), dtype=float) + if polar_radius <= 0: + _warn_non_physical( + "spheroidal_particle", "'polar_radius' must be positive" + ) + return numpy.zeros(numpy.shape(r), dtype=float) - if psize <= 0 or pelpt <= 0: - return numpy.zeros_like(r) + particle_diameter = 2.0 * equatorial_radius + axis_ratio = 1.0 * polar_radius / equatorial_radius # to simplify the equations - v = pelpt - d = 1.0 * psize + v = axis_ratio + d = particle_diameter d2 = d * d v2 = v * v if v == 1: - return sphericalCF(r, psize) + return spherical_particle(r, particle_diameter) rx = r if v < 1: - r = rx[rx <= v * psize] + r = rx[rx <= v * d] r2 = r * r f1 = ( 1 @@ -137,7 +261,7 @@ def spheroidalCF2(r, psize, axrat): * atanh(sqrt(1 - v2)) ) - r = rx[numpy.logical_and(rx > v * psize, rx <= psize)] + r = rx[numpy.logical_and(rx > v * d, rx <= d)] r2 = r * r f2 = ( ( @@ -152,14 +276,14 @@ def spheroidalCF2(r, psize, axrat): / sqrt(1 - v2) ) - r = rx[rx > psize] + r = rx[rx > d] f3 = numpy.zeros_like(r) f = numpy.concatenate((f1, f2, f3)) elif v > 1: - r = rx[rx <= psize] + r = rx[rx <= d] r2 = r * r f1 = ( 1 @@ -173,7 +297,7 @@ def spheroidalCF2(r, psize, axrat): * atan(sqrt(v2 - 1)) ) - r = rx[numpy.logical_and(rx > psize, rx <= v * psize)] + r = rx[numpy.logical_and(rx > d, rx <= v * d)] r2 = r * r f2 = ( 1 @@ -193,7 +317,7 @@ def spheroidalCF2(r, psize, axrat): * (atan(sqrt(v2 - 1)) - atan(sqrt(r2 / d2 - 1))) ) - r = rx[rx > v * psize] + r = rx[rx > v * d] f3 = numpy.zeros_like(r) f = numpy.concatenate((f1, f2, f3)) @@ -201,48 +325,86 @@ def spheroidalCF2(r, psize, axrat): return f -def lognormalSphericalCF(r, psize, psig): - """Spherical nanoparticle characteristic function with lognormal size - distribution. - - Attributes - ---------- - r - distance of interaction - psize - The mean particle diameter - psig - The log-normal width of the particle diameter +def lognormal_spherical_particle( + r, particle_diameter, particle_diameter_sigma +): + """Compute the spherical nanoparticle characteristic function with + lognormal size distribution. - - Here, r is the independent variable, mu is the mean of the distribution - (not of the particle size), and s is the width of the distribution. This is - the characteristic function for the lognormal distribution of particle - diameter: + Here, r is the independent variable, mu is the mean of the + distribution (not of the particle size), and s is the width of + the distribution. This is the characteristic function for the + lognormal distribution of particle diameter: F(r, mu, s) = 0.5*Erfc((-mu-3*s^2+Log(r))/(sqrt(2)*s)) + 0.25*r^3*Erfc((-mu+Log(r))/(sqrt(2)*s))*exp(-3*mu-4.5*s^2) - 0.75*r*Erfc((-mu-2*s^2+Log(r))/(sqrt(2)*s))*exp(-mu-2.5*s^2) The expectation value of the distribution gives the average particle - diameter, psize. The variance of the distribution gives psig^2. mu and s - can be expressed in terms of these as: + diameter, particle_diameter. The variance of the distribution gives + particle_diameter_sigma^2. mu and s can be expressed in terms of these + as: + + s^2 = log((particle_diameter_sigma/particle_diameter)^2 + 1) + mu = log(particle_diameter) - s^2/2 - s^2 = log((psig/psize)^2 + 1) - mu = log(psize) - s^2/2 + Source unknown. - Source unknown + Parameters + ---------- + r : array_like + The distance of interaction. + particle_diameter : float + The mean particle diameter. + particle_diameter_sigma : float + The log-normal width of the particle diameter. + + Returns + ------- + numpy.ndarray + The characteristic function values evaluated at `r`. + + Warns + ----- + RuntimeWarning + If `particle_diameter` is not positive, if `particle_diameter_sigma` + is negative, or if `particle_diameter` is too small for + `particle_diameter_sigma` for the closed form to hold, in which case + the characteristic function is zero everywhere. The warning is issued + only once per process. A `particle_diameter_sigma` of zero is the + sphere limit rather than an error and does not warn. """ - if psize <= 0: - return numpy.zeros_like(r) - if psig <= 0: - return sphericalCF(r, psize) + if particle_diameter <= 0: + _warn_non_physical( + "lognormal_spherical_particle", + "'particle_diameter' must be positive", + ) + return numpy.zeros(numpy.shape(r), dtype=float) + if particle_diameter_sigma < 0: + _warn_non_physical( + "lognormal_spherical_particle", + "'particle_diameter_sigma' must not be negative", + ) + return numpy.zeros(numpy.shape(r), dtype=float) + if particle_diameter_sigma == 0: + return spherical_particle(r, particle_diameter) sqrt2 = sqrt(2.0) - s = sqrt(log(psig * psig / (1.0 * psize * psize) + 1)) - mu = log(psize) - s * s / 2 + s = sqrt( + log( + particle_diameter_sigma + * particle_diameter_sigma + / (1.0 * particle_diameter * particle_diameter) + + 1 + ) + ) + mu = log(particle_diameter) - s * s / 2 if mu < 0: - return numpy.zeros_like(r) + _warn_non_physical( + "lognormal_spherical_particle", + "'particle_diameter' is too small for 'particle_diameter_sigma'", + ) + return numpy.zeros(numpy.shape(r), dtype=float) return ( 0.5 * erfc((-mu - 3 * s * s + log(r)) / (sqrt2 * s)) @@ -259,73 +421,94 @@ def lognormalSphericalCF(r, psize, psig): ) -def sheetCF(r, sthick): - """Nanosheet characteristic function. - - Attributes - ---------- - r - distance of interaction - sthick - Thickness of nanosheet +def sheet_particle(r, sheet_thickness): + """Compute the nanosheet characteristic function. + From Kodama et al., Acta Cryst. A, 62, 444-453. - From Kodama et al., Acta Cryst. A, 62, 444-453 + Parameters + ---------- + r : array_like + The distance of interaction. + sheet_thickness : float + The thickness of the nanosheet. + + Returns + ------- + numpy.ndarray + The characteristic function values evaluated at `r`. + + Warns + ----- + RuntimeWarning + If `sheet_thickness` is not positive, in which case the + characteristic function is zero everywhere. The warning is issued + only once per process. """ - # handle zero or negative sthick. make it work for scalars and arrays. - if sthick <= 0: - return 0 * sthick + if sheet_thickness <= 0: + _warn_non_physical( + "sheet_particle", "'sheet_thickness' must be positive" + ) + if numpy.isscalar(r): + return 0.0 + return numpy.zeros(numpy.shape(r), dtype=float) # process scalar r if numpy.isscalar(r): - rv = 1 - 0.5 * r / sthick if r < sthick else 0.5 * sthick / r + rv = ( + 1 - 0.5 * r / sheet_thickness + if r < sheet_thickness + else 0.5 * sheet_thickness / r + ) return rv # handle array-type r - ra = numpy.asarray(r) - lo = ra < sthick - hi = ~lo - f = numpy.empty_like(ra, dtype=float) - f[lo] = 1 - 0.5 * ra[lo] / sthick - f[hi] = 0.5 * sthick / ra[hi] - return f - + r_array = numpy.asarray(r) + inside = r_array < sheet_thickness + outside = ~inside + characteristic_function = numpy.empty_like(r_array, dtype=float) + characteristic_function[inside] = ( + 1 - 0.5 * r_array[inside] / sheet_thickness + ) + characteristic_function[outside] = 0.5 * sheet_thickness / r_array[outside] + return characteristic_function -def shellCF(r, radius, thickness): - """Spherical shell characteristic function. - Attributes - ---------- - radius - Inner radius - thickness - Thickness of shell +def shell_particle(r, radius, thickness): + """Compute the spherical shell characteristic function. + The outer radius equals ``radius + thickness``. From Lei et al., + Phys. Rev. B, 80, 024118 (2009). - outer radius = radius + thickness - - From Lei et al., Phys. Rev. B, 80, 024118 (2009) + Parameters + ---------- + r : array_like + The distance of interaction. + radius : float + The inner radius. + thickness : float + The thickness of the shell. + + Returns + ------- + numpy.ndarray + The characteristic function values evaluated at `r`. + + Warns + ----- + RuntimeWarning + If `thickness` is not positive or `radius` is negative, in which case + the characteristic function is zero everywhere. The warning is issued + only once per process. A `radius` of zero is the solid sphere limit + rather than an error and does not warn. """ + if thickness <= 0: + _warn_non_physical("shell_particle", "'thickness' must be positive") + return numpy.zeros(numpy.shape(r), dtype=float) + if radius < 0: + _warn_non_physical("shell_particle", "'radius' must not be negative") + return numpy.zeros(numpy.shape(r), dtype=float) + d = 1.0 * thickness a = 1.0 * radius + d / 2.0 - return shellCF2(r, a, d) - - -def shellCF2(r, a, delta): - """Spherical shell characteristic function. - - Attributes - ---------- - a - Central radius - delta - Thickness of shell - - outer radius = a + thickness/2 - - - From Lei et al., Phys. Rev. B, 80, 024118 (2009) - """ - a = 1.0 * a - d = 1.0 * delta a2 = a**2 d2 = d**2 dmr = d - r @@ -354,6 +537,84 @@ def shellCF2(r, a, delta): return f +@deprecated(sphericalCF_dep_msg) +def sphericalCF(r, psize): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.pdf.characteristicfunctions.spherical_particle + instead. + """ + return spherical_particle(r, psize) + + +@deprecated(spheroidalCF_dep_msg) +def spheroidalCF(r, erad, prad): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.pdf.characteristicfunctions.spheroidal_particle + instead. + """ + return spheroidal_particle(r, erad, prad) + + +@deprecated(spheroidalCF2_dep_msg) +def spheroidalCF2(r, psize, axrat): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.pdf.characteristicfunctions.spheroidal_particle + instead. + """ + return spheroidal_particle(r, psize / 2, axrat * psize / 2) + + +@deprecated(lognormalSphericalCF_dep_msg) +def lognormalSphericalCF(r, psize, psig): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.pdf.characteristicfunctions.lognormal_spherical_particle + instead. + """ + return lognormal_spherical_particle(r, psize, psig) + + +@deprecated(sheetCF_dep_msg) +def sheetCF(r, sthick): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.pdf.characteristicfunctions.sheet_particle + instead. + """ + return sheet_particle(r, sthick) + + +@deprecated(shellCF_dep_msg) +def shellCF(r, radius, thickness): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.pdf.characteristicfunctions.shell_particle + instead. + """ + return shell_particle(r, radius, thickness) + + +@deprecated(shellCF2_dep_msg) +def shellCF2(r, a, delta): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.pdf.characteristicfunctions.shell_particle + instead. + """ + return shell_particle(r, a - delta / 2, delta) + + class SASCF(Calculator): """Calculator class for characteristic functions from sas-models. @@ -377,12 +638,12 @@ class SASCF(Calculator): def __init__(self, name, model): """Initialize the generator. - Attributes + Parameters ---------- - name - A name for the SASCF - model - SASModel object this adapts. + name : str + The name for the SASCF. + model : BaseModel + The sas.models.BaseModel object this adapts. """ Calculator.__init__(self, name) @@ -405,8 +666,8 @@ def __init__(self, name, model): return def __call__(self, r): - """Calculate the characteristic function from the transform of the - BaseModel.""" + """Calculate the characteristic function from the transform of + the BaseModel.""" # Determine q-values. # We want very fine r-spacing so we can properly normalize f(r). This # equates to having a large qmax so that the Fourier transform is @@ -416,7 +677,8 @@ def __call__(self, r): # # The initial dr is somewhat arbitrary, but using dr = 0.01 allows for # the f(r) calculated from a particle of diameter 50, over r = - # arange(1, 60, 0.1) to agree with the sphericalCF with Rw < 1e-4%. + # arange(1, 60, 0.1) to agree with the spherical_particle with Rw < + # 1e-4%. # # We also have to make a q-spacing small enough to compute out to at # least the size of the signal. @@ -472,6 +734,18 @@ def __call__(self, r): def erfc(x): + """Compute the complementary error function. + + Parameters + ---------- + x : array_like + The input value. + + Returns + ------- + numpy.ndarray + The complementary error function evaluated at `x`. + """ return 1.0 - erf(x) diff --git a/src/diffpy/srfit/pdf/debyepdfgenerator.py b/src/diffpy/srfit/pdf/debyepdfgenerator.py index 641c577f..7861bcd4 100644 --- a/src/diffpy/srfit/pdf/debyepdfgenerator.py +++ b/src/diffpy/srfit/pdf/debyepdfgenerator.py @@ -92,15 +92,15 @@ def setStructure(self, stru, name="phase", periodic=False): See those classes (located in diffpy.srfit.structure) for how they are used. The resulting ParameterSet will be managed by this generator. - Attributes + Parameters ---------- stru - diffpy.structure.Structure, pyobjcryst.crystal.Crystal or - pyobjcryst.molecule.Molecule instance. Default None. - name - A name to give to the managed ParameterSet that adapts stru + The diffpy.structure.Structure, pyobjcryst.crystal.Crystal or + pyobjcryst.molecule.Molecule instance to adapt (default None). + name : str, optional + The name to give to the managed ParameterSet that adapts stru (default "phase"). - periodic + periodic : bool, optional The structure should be treated as periodic (default False). Note that some structures do not support periodicity, in which case this will have no effect on the @@ -116,14 +116,14 @@ def setPhase(self, parset, periodic=False): object (from diffpy or pyobjcryst). The passed ParameterSet will be managed by this generator. - Attributes + Parameters ---------- parset - A SrRealParSet that holds the structural information. + The SrRealParSet that holds the structural information. This can be used to share the phase between multiple BasePDFGenerators, and have the changes in one reflect in another. - periodic + periodic : bool, optional The structure should be treated as periodic (default True). Note that some structures do not support periodicity, in which case this will be ignored. @@ -131,7 +131,13 @@ def setPhase(self, parset, periodic=False): return BasePDFGenerator.setPhase(self, parset, periodic) def __init__(self, name="pdf"): - """Initialize the generator.""" + """Initialize the generator. + + Parameters + ---------- + name : str, optional + The name for this generator (default "pdf"). + """ from diffpy.srreal.pdfcalculator import DebyePDFCalculator BasePDFGenerator.__init__(self, name) diff --git a/src/diffpy/srfit/pdf/pdfcontribution.py b/src/diffpy/srfit/pdf/pdfcontribution.py index 65ee5eda..b103a414 100644 --- a/src/diffpy/srfit/pdf/pdfcontribution.py +++ b/src/diffpy/srfit/pdf/pdfcontribution.py @@ -24,10 +24,9 @@ class PDFContribution(FitContribution): - """PDFContribution class. + """A FitContribution that is customized for PDF fits. - PDFContribution is a FitContribution that is customized for PDF fits. Data - and phases can be added directly to the PDFContribution. Setup of + Data and phases can be added directly to the PDFContribution. Setup of constraints and restraints requires direct interaction with the generator attributes (see setPhase). @@ -84,9 +83,9 @@ class PDFContribution(FitContribution): def __init__(self, name): """Create the PDFContribution. - Attributes + Parameters ---------- - name + name : str The name of the contribution. """ FitContribution.__init__(self, name) @@ -105,31 +104,24 @@ def __init__(self, name): # Data methods - def loadData(self, data): - """Load the data in various formats. + def loadData(self, datafile): + """Load the data from a data file. - This uses the PDFParser to load the data and then passes it to the - built-in profile with loadParsedData. + This uses the PDFParser to load the data and then passes it to + the built-in profile with load_parsed_data. - Attributes + Parameters ---------- - data - An open file-like object, name of a file that contains data - or a string containing the data. + datafile : str or Path + The path to the file that contains the data. """ - # Get the data into a string - from diffpy.srfit.util.inpututils import inputToString - - datstr = inputToString(data) - - # Load data with a PDFParser from diffpy.srfit.pdf.pdfparser import PDFParser parser = PDFParser() - parser.parseString(datstr) + parser.parse_file(datafile) # Pass it to the profile - self.profile.loadParsedData(parser) + self.profile.load_parsed_data(parser) return def setCalculationRange(self, xmin=None, xmax=None, dx=None): @@ -141,16 +133,15 @@ 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``. @@ -164,14 +155,19 @@ def setCalculationRange(self, xmin=None, xmax=None, dx=None): ValueError When xmin > xmax or if dx <= 0. Also if dx > xmax - xmin. """ - return self.profile.setCalculationRange(xmin, xmax, dx) + return self.profile.set_calculation_range(xmin, xmax, dx) def savetxt(self, fname, **kwargs): """Call numpy.savetxt with x, ycalc, y, dy. This calls on the built-in Profile. - Arguments are passed to numpy.savetxt. + Parameters + ---------- + fname : str or Path + The file or filename to which the data is saved. + **kwargs + Additional arguments passed to numpy.savetxt. """ return self.profile.savetxt(fname, **kwargs) @@ -180,9 +176,9 @@ def savetxt(self, fname, **kwargs): def addStructure(self, name, stru, periodic=True): """Add a phase that goes into the PDF calculation. - Attributes + Parameters ---------- - name + name : str A name to give the generator that will manage the PDF calculation from the passed structure. The adapted structure will be accessible via the name "phase" as an @@ -191,9 +187,9 @@ def addStructure(self, name, stru, periodic=True): contribution and 'name' is passed name. (default), then the name will be set as "phase". stru - diffpy.structure.Structure, pyobjcryst.crystal.Crystal or - pyobjcryst.molecule.Molecule instance. Default None. - periodic + The diffpy.structure.Structure, pyobjcryst.crystal.Crystal or + pyobjcryst.molecule.Molecule instance to adapt (default None). + periodic : bool, optional The structure should be treated as periodic. If this is True (default), then a PDFGenerator will be used to calculate the PDF from the phase. Otherwise, a @@ -201,9 +197,11 @@ def addStructure(self, name, stru, periodic=True): do not support periodicity, in which case this may be ignored. - - Returns the new phase (ParameterSet appropriate for what was passed in - stru.) + Returns + ------- + ParameterSet + The new phase, of the type appropriate for what was passed + in stru. """ # Based on periodic, create the proper generator. if periodic: @@ -224,9 +222,9 @@ def addStructure(self, name, stru, periodic=True): def addPhase(self, name, parset, periodic=True): """Add a phase that goes into the PDF calculation. - Attributes + Parameters ---------- - name + name : str A name to give the generator that will manage the PDF calculation from the passed parameter phase. The parset will be accessible via the name "phase" as an attribute @@ -234,11 +232,11 @@ def addPhase(self, name, parset, periodic=True): 'contribution' is this contribution and 'name' is passed name. parset - A SrRealParSet that holds the structural information. + The SrRealParSet that holds the structural information. This can be used to share the phase between multiple BasePDFGenerators, and have the changes in one reflect in another. - periodic + periodic : bool, optional The structure should be treated as periodic. If this is True (default), then a PDFGenerator will be used to calculate the PDF from the phase. Otherwise, a @@ -246,9 +244,11 @@ def addPhase(self, name, parset, periodic=True): do not support periodicity, in which case this may be ignored. - - Returns the new phase (ParameterSet appropriate for what was passed in - stru.) + Returns + ------- + ParameterSet + The new phase, of the type appropriate for what was passed + in parset. """ # Based on periodic, create the proper generator. if periodic: @@ -273,22 +273,22 @@ def _setup_generator(self, gen): with setStructure or setPhase. """ # Add the generator to this FitContribution - self.addProfileGenerator(gen) + self.add_profile_generator(gen) # Set the proper equation for the fit, depending on the number of # phases we have. gnames = self._generators.keys() eqstr = " + ".join(gnames) eqstr = "scale * (%s)" % eqstr - self.setEquation(eqstr) + self.set_equation(eqstr) # Update with our metadata gen.meta.update(self._meta) - gen.processMetaData() + gen._process_metadata() # Constrain the shared parameters - self.constrain(gen.qdamp, self.qdamp) - self.constrain(gen.qbroad, self.qbroad) + self.add_constraint(gen.qdamp, self.qdamp) + self.add_constraint(gen.qbroad, self.qbroad) return # Calculation setup methods @@ -307,12 +307,15 @@ def _get_meta_value(self, kwd): def setScatteringType(self, type="X"): """Set the scattering type. - Attributes + Parameters ---------- - type - "X" for x-ray or "N" for neutron + type : str, optional + "X" for x-ray or "N" for neutron (default "X"). - Raises ValueError if type is not "X" or "N" + Raises + ------ + ValueError + If type is not "X" or "N". """ self._meta["stype"] = type for gen in self._generators.values(): @@ -322,30 +325,59 @@ def setScatteringType(self, type="X"): def getScatteringType(self): """Get the scattering type. - See 'setScatteringType'. + See ``setScatteringType``. + + Returns + ------- + str + The scattering type used to calculate the PDF. """ return self._get_meta_value("stype") def setQmax(self, qmax): - """Set the qmax value.""" + """Set the qmax value. + + Parameters + ---------- + qmax : float + The maximum scattering vector used to generate the PDF. + """ self._meta["qmax"] = qmax for gen in self._generators.values(): gen.setQmax(qmax) return def getQmax(self): - """Get the qmax value.""" + """Get the qmax value. + + Returns + ------- + float + The maximum scattering vector used to generate the PDF. + """ return self._get_meta_value("qmax") def setQmin(self, qmin): - """Set the qmin value.""" + """Set the qmin value. + + Parameters + ---------- + qmin : float + The minimum scattering vector used to generate the PDF. + """ self._meta["qmin"] = qmin for gen in self._generators.values(): gen.setQmin(qmin) return def getQmin(self): - """Get the qmin value.""" + """Get the qmin value. + + Returns + ------- + float + The minimum scattering vector used to generate the PDF. + """ return self._get_meta_value("qmin") diff --git a/src/diffpy/srfit/pdf/pdfgenerator.py b/src/diffpy/srfit/pdf/pdfgenerator.py index 3a2f919b..7dea458f 100644 --- a/src/diffpy/srfit/pdf/pdfgenerator.py +++ b/src/diffpy/srfit/pdf/pdfgenerator.py @@ -84,7 +84,13 @@ class PDFGenerator(BasePDFGenerator): """ def __init__(self, name="pdf"): - """Initialize the generator.""" + """Initialize the generator. + + Parameters + ---------- + name : str, optional + The name for this generator (default "pdf"). + """ from diffpy.srreal.pdfcalculator import PDFCalculator BasePDFGenerator.__init__(self, name) diff --git a/src/diffpy/srfit/pdf/pdfparser.py b/src/diffpy/srfit/pdf/pdfparser.py index 1f836303..26f5c584 100644 --- a/src/diffpy/srfit/pdf/pdfparser.py +++ b/src/diffpy/srfit/pdf/pdfparser.py @@ -22,24 +22,22 @@ __all__ = ["PDFParser"] -import re - -import numpy - -from diffpy.srfit.exceptions import ParseError from diffpy.srfit.fitbase.profileparser import ProfileParser class PDFParser(ProfileParser): - """Class for holding a diffraction pattern. + """Parser for PDF diffraction pattern data. - Attributes + PDFgetX and PDFgetN write their header as plain ``name = value`` + pairs, including ``stype = X`` or ``stype = N`` for the scattering + type, so this class parses files identically to ``ProfileParser`` + and only sets ``_format`` to identify PDF data. Attributes ---------- _format - Name of the data format that this parses (string, default - ""). The format string is a unique identifier for the data + The name of the data format that this parses (string, default + ``""``). The format string is a unique identifier for the data format handled by the parser. _banks The data from each bank. Each bank contains a @@ -52,27 +50,25 @@ class PDFParser(ProfileParser): from the file. dx A numpy array containing the uncertainty in x - read from the file. This is 0 if the + read from the file. This is None if the uncertainty cannot be read. dy A numpy array containing the uncertainty read - from the file. This is 0 if the uncertainty + from the file. This is None if the uncertainty cannot be read. _x - Independent variable from the chosen bank + The independent variable from the chosen bank. _y - Profile from the chosen bank + The profile from the chosen bank. _dx - Uncertainty in independent variable from the chosen bank + The uncertainty in independent variable from the chosen bank. _dy - Uncertainty in profile from the chosen bank + The uncertainty in profile from the chosen bank. _meta A dictionary containing metadata read from the file. General Metadata - - Attributes - ---------- + ----------------- filename The name of the file from which data was parsed. This key will not exist if data was not read from file. @@ -82,180 +78,19 @@ class PDFParser(ProfileParser): The chosen bank number. Metadata - ---------- + -------- stype - The scattering type ("X", "N") + The scattering type ("X", "N"). qmin - Minimum scattering vector (float) + The minimum scattering vector (float). qmax - Maximum scattering vector (float) - qdamp - Resolution damping factor (float) - qbroad - Resolution broadening factor (float) - spdiameter - Nanoparticle diameter (float) - scale - Data scale (float) - temperature - Temperature (float) - doping - Doping (float) - + The maximum scattering vector (float). - These may appear in the metadata dictionary. + These, along with any other ``name = value`` pairs in the header, + may appear in the metadata dictionary. """ _format = "PDF" - def parseString(self, patstring): - """Parse a string and set the _x, _y, _dx, _dy and _meta variables. - - When _dx or _dy cannot be obtained in the data format it is set to 0. - - This wipes out the currently loaded data and selected bank number. - - Parameters - ---------- - patstring - A string containing the pattern - - Raises - ---------- - ParseError - if the string cannot be parsed - """ - # useful regex patterns: - rx = {"f": r"[-+]?(\d+(\.\d*)?|\d*\.\d+)([eE][-+]?\d+)?"} - # find where does the data start - res = re.search(r"^#+ start data\s*(?:#.*\s+)*", patstring, re.M) - # start_data is position where the first data line starts - if res: - start_data = res.end() - else: - # find line that starts with a floating point number - regexp = r"^\s*%(f)s" % rx - res = re.search(regexp, patstring, re.M) - if res: - start_data = res.start() - else: - start_data = 0 - header = patstring[:start_data] - databody = patstring[start_data:].strip() - - # find where the metadata starts - metadata = "" - res = re.search(r"^#+\ +metadata\b\n", header, re.M) - if res: - metadata = header[res.end() :] - header = header[: res.start()] - - # parse header - meta = self._meta - # stype - if re.search("(x-?ray|PDFgetX)", header, re.I): - meta["stype"] = "X" - elif re.search("(neutron|PDFgetN)", header, re.I): - meta["stype"] = "N" - # qmin - regexp = r"\bqmin *= *(%(f)s)\b" % rx - res = re.search(regexp, header, re.I) - if res: - meta["qmin"] = float(res.groups()[0]) - # qmax - regexp = r"\bqmax *= *(%(f)s)\b" % rx - res = re.search(regexp, header, re.I) - if res: - meta["qmax"] = float(res.groups()[0]) - # qdamp - regexp = r"\b(?:qdamp|qsig) *= *(%(f)s)\b" % rx - res = re.search(regexp, header, re.I) - if res: - meta["qdamp"] = float(res.groups()[0]) - # qbroad - regexp = r"\b(?:qbroad|qalp) *= *(%(f)s)\b" % rx - res = re.search(regexp, header, re.I) - if res: - meta["qbroad"] = float(res.groups()[0]) - # spdiameter - regexp = r"\bspdiameter *= *(%(f)s)\b" % rx - res = re.search(regexp, header, re.I) - if res: - meta["spdiameter"] = float(res.groups()[0]) - # dscale - regexp = r"\bdscale *= *(%(f)s)\b" % rx - res = re.search(regexp, header, re.I) - if res: - meta["scale"] = float(res.groups()[0]) - # temperature - regexp = r"\b(?:temp|temperature|T)\ *=\ *(%(f)s)\b" % rx - res = re.search(regexp, header) - if res: - meta["temperature"] = float(res.groups()[0]) - # doping - regexp = r"\b(?:x|doping)\ *=\ *(%(f)s)\b" % rx - res = re.search(regexp, header) - if res: - meta["doping"] = float(res.groups()[0]) - - # parsing general metadata - if metadata: - regexp = r"\b(\w+)\ *=\ *(%(f)s)\b" % rx - while True: - res = re.search(regexp, metadata, re.M) - if res: - meta[res.groups()[0]] = float(res.groups()[1]) - metadata = metadata[res.end() :] - else: - break - - # read actual data - robs, Gobs, drobs, dGobs - inf_or_nan = re.compile("(?i)^[+-]?(NaN|Inf)\\b") - has_drobs = True - has_dGobs = True - # raise ParseError if something goes wrong - robs = [] - Gobs = [] - drobs = [] - dGobs = [] - try: - for line in databody.split("\n"): - v = line.split() - # there should be at least 2 value in the line - robs.append(float(v[0])) - Gobs.append(float(v[1])) - # drobs is valid if all values are defined and positive - has_drobs = ( - has_drobs and len(v) > 2 and not inf_or_nan.match(v[2]) - ) - if has_drobs: - v2 = float(v[2]) - has_drobs = v2 > 0.0 - drobs.append(v2) - # dGobs is valid if all values are defined and positive - has_dGobs = ( - has_dGobs and len(v) > 3 and not inf_or_nan.match(v[3]) - ) - if has_dGobs: - v3 = float(v[3]) - has_dGobs = v3 > 0.0 - dGobs.append(v3) - except (ValueError, IndexError) as err: - raise ParseError(err) - if has_drobs: - drobs = numpy.asarray(drobs) - else: - drobs = None - if has_dGobs: - dGobs = numpy.asarray(dGobs) - else: - dGobs = None - - robs = numpy.asarray(robs) - Gobs = numpy.asarray(Gobs) - - self._banks.append([robs, Gobs, drobs, dGobs]) - return - # End of PDFParser diff --git a/src/diffpy/srfit/sas/prcalculator.py b/src/diffpy/srfit/sas/prcalculator.py index 17c63de6..00d3c8e8 100644 --- a/src/diffpy/srfit/sas/prcalculator.py +++ b/src/diffpy/srfit/sas/prcalculator.py @@ -66,7 +66,7 @@ class PrCalculator(Calculator): def __init__(self, name): """Initialize the generator. - Attributes + Parameters ---------- name A name for the PrCalculator @@ -116,7 +116,8 @@ def _inverted(self, x, c): class CFCalculator(PrCalculator): - """A class for calculating the characteristic function (CF) from data. + """A class for calculating the characteristic function (CF) from + data. This calculator produces f(r) = P(r) / 4 pi r**2 diff --git a/src/diffpy/srfit/sas/sasgenerator.py b/src/diffpy/srfit/sas/sasgenerator.py index 238ae43f..a986e283 100644 --- a/src/diffpy/srfit/sas/sasgenerator.py +++ b/src/diffpy/srfit/sas/sasgenerator.py @@ -43,7 +43,7 @@ class SASGenerator(ProfileGenerator): def __init__(self, name, model): """Initialize the generator. - Attributes + Parameters ---------- name A name for the SASGenerator diff --git a/src/diffpy/srfit/sas/sasimport.py b/src/diffpy/srfit/sas/sasimport.py index a50b5005..523c9601 100644 --- a/src/diffpy/srfit/sas/sasimport.py +++ b/src/diffpy/srfit/sas/sasimport.py @@ -14,13 +14,13 @@ ############################################################################## """Universal import functions for volatile SasView/SansViews API-s.""" -# FIXME: remove this line when `docformatter` fixes the blank line bug +# FIXME: remove this line when black and docformatter agree. def sasimport(modname): """Import specified module from the SasView sas package. - Attributes + Parameters ---------- modname absolute module name contained in the sas package. diff --git a/src/diffpy/srfit/sas/sasparameter.py b/src/diffpy/srfit/sas/sasparameter.py index ad3d9895..b74a58f1 100644 --- a/src/diffpy/srfit/sas/sasparameter.py +++ b/src/diffpy/srfit/sas/sasparameter.py @@ -33,9 +33,9 @@ class SASParameter(Parameter): const A flag indicating whether this is considered a constant. _value - The value of the Parameter. Modified with 'setValue'. + The value of the Parameter. Modified with 'set_value'. value - Property for 'getValue' and 'setValue'. + Property for 'getValue' and 'set_value'. constrained A flag indicating if the Parameter is constrained (default False). @@ -52,7 +52,7 @@ class SASParameter(Parameter): def __init__(self, name, model, parname=None): """Create the Parameter. - Attributes + Parameters ---------- name Name of the Parameter @@ -73,7 +73,7 @@ def getValue(self): value = self._model.getParam(self._parname) return value - def setValue(self, value): + def set_value(self, value): """Set the value of the Parameter.""" if value != self.getValue(): self._model.setParam(self._parname, value) diff --git a/src/diffpy/srfit/sas/sasparser.py b/src/diffpy/srfit/sas/sasparser.py index 50c7f505..ab6b4d4b 100644 --- a/src/diffpy/srfit/sas/sasparser.py +++ b/src/diffpy/srfit/sas/sasparser.py @@ -30,8 +30,6 @@ class SASParser(ProfileParser): This uses a sas DataLoader to load the data. The DataInfo object it returns is held in the metadata under the name "datainfo". - Attributes - Attributes ---------- _format @@ -90,8 +88,13 @@ class SASParser(ProfileParser): _format = "SAS" - def parseFile(self, filename): - """Parse a file and set the _x, _y, _dx, _dy and _meta variables. + def parse_file(self, filename, column_format=None, **kwargs): + """Parse a file and set the _x, _y, _dx, _dy and _meta + variables. + + The sas DataLoader reads the data and the metadata together, so + this overrides parse_file itself rather than the + `_parse_metadata` and `_parse_data` hooks. This wipes out the currently loaded data and selected bank number. @@ -99,6 +102,12 @@ def parseFile(self, filename): ---------- filename The name of the file to parse + column_format + Unused. Accepted so that the signature matches + `ProfileParser.parse_file`. + kwargs + Unused. Accepted so that the signature matches + `ProfileParser.parse_file`. Raises ---------- @@ -131,42 +140,7 @@ def parseFile(self, filename): # FIXME: Revisit when we refactor the SAS characteristic functions. # Why is a list imported but only the first element is taken? # Is this desired behavior? - self.selectBank(0) - return - - def parseString(self, patstring): - """Parse a string and set the _x, _y, _dx, _dy and _meta variables. - - When _dx or _dy cannot be obtained in the data format it is set to 0. - - This wipes out the currently loaded data and selected bank number. - - Parameters - ---------- - patstring - A string containing the pattern - - Raises - ---------- - ParseError - if the string cannot be parsed - """ - # This calls on parseFile, as that is how the sas data loader works. - import tempfile - - fh, fn = tempfile.mkstemp() - outfile = open(fn, "w") - fn.write(patstring) - outfile.close() - self.parseFile(fn) - - del self._metadata["filename"] - - # Close the temporary file and delete it - import os - - os.close(fh) - os.remove(fn) + self.select_bank(0) return diff --git a/src/diffpy/srfit/sas/sasprofile.py b/src/diffpy/srfit/sas/sasprofile.py index a05420ae..a0761fb8 100644 --- a/src/diffpy/srfit/sas/sasprofile.py +++ b/src/diffpy/srfit/sas/sasprofile.py @@ -12,7 +12,8 @@ # See LICENSE_DANSE.txt for license information. # ############################################################################## -"""Class for adapting a sas DataInfo objects to the Profile interface.""" +"""Class for adapting a sas DataInfo objects to the Profile +interface.""" __all__ = ["SASProfile"] @@ -29,8 +30,6 @@ class SASProfile(Profile): Otherwise, use the SASParser class and load the data into a base Profile object. - Attributes - Attributes ---------- _xobs @@ -77,7 +76,7 @@ class SASProfile(Profile): def __init__(self, datainfo): """Initialize the attributes. - Attributes + Parameters ---------- datainfo The DataInfo object this wraps. diff --git a/src/diffpy/srfit/srfit_app.py b/src/diffpy/srfit/srfit_app.py new file mode 100644 index 00000000..aed5989a --- /dev/null +++ b/src/diffpy/srfit/srfit_app.py @@ -0,0 +1,33 @@ +import argparse + +from diffpy.srfit.version import __version__ # noqa + + +def main(): + parser = argparse.ArgumentParser( + prog="diffpy.srfit", + description=( + "Generalized code base for modeling problems.\n\n" + "For more information, visit: " + "https://github.com/diffpy/diffpy.srfit/" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + parser.add_argument( + "--version", + action="store_true", + help="Show the program's version number and exit", + ) + + args = parser.parse_args() + + if args.version: + print(f"diffpy.srfit {__version__}") + else: + # Default behavior when no arguments are given + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/src/diffpy/srfit/structure/__init__.py b/src/diffpy/srfit/structure/__init__.py index 2a34680e..0990c76f 100644 --- a/src/diffpy/srfit/structure/__init__.py +++ b/src/diffpy/srfit/structure/__init__.py @@ -12,11 +12,14 @@ # See LICENSE_DANSE.txt for license information. # ############################################################################## -"""Modules and classes that adapt structure representations to the ParameterSet -interface and automatic structure constraint generation from space group -information.""" +"""Modules and classes that adapt structure representations to the +ParameterSet interface and automatic structure constraint generation +from space group information.""" -from diffpy.srfit.structure.sgconstraints import constrainAsSpaceGroup +from diffpy.srfit.structure.sgconstraints import ( + constrain_as_space_group, + constrainAsSpaceGroup, +) def struToParameterSet(name, stru): @@ -25,7 +28,7 @@ def struToParameterSet(name, stru): This returns a ParameterSet adapted for the structure depending on its type. - Attributes + Parameters ---------- stru a structure object known by this module @@ -58,6 +61,7 @@ def struToParameterSet(name, stru): # silence pyflakes checker +assert constrain_as_space_group assert constrainAsSpaceGroup diff --git a/src/diffpy/srfit/structure/bvsrestraint.py b/src/diffpy/srfit/structure/bvsrestraint.py index f8af1ebe..ec156449 100644 --- a/src/diffpy/srfit/structure/bvsrestraint.py +++ b/src/diffpy/srfit/structure/bvsrestraint.py @@ -47,7 +47,7 @@ class BVSRestraint(Restraint): def __init__(self, parset, sig=1, scaled=False): """Initialize the Restraint. - Attributes + Parameters ---------- parset SrRealParSet that creates this BVSRestraint. @@ -69,7 +69,7 @@ def __init__(self, parset, sig=1, scaled=False): def penalty(self, w=1.0): """Calculate the penalty of the restraint. - Attributes + Parameters ---------- w The point-average chi^2 which is optionally used to scale the diff --git a/src/diffpy/srfit/structure/cctbxparset.py b/src/diffpy/srfit/structure/cctbxparset.py index 2bb05c7d..99711c07 100644 --- a/src/diffpy/srfit/structure/cctbxparset.py +++ b/src/diffpy/srfit/structure/cctbxparset.py @@ -20,11 +20,11 @@ object after wrapping may not be reflected within the wrapper, which can have unpredictable results during a structure refinement. -Classes: +The following classes are adapted: -CCTBXCrystalParSet -- Wrapper for cctbx.crystal -CCTBXUnitCellParSet -- Wrapper for the unit cell of cctbx.crystal -CCTBXScattererParSet -- Wrapper for cctbx.xray.scatterer +- `CCTBXCrystalParSet`: wrapper for `cctbx.crystal`. +- `CCTBXUnitCellParSet`: wrapper for the unit cell of `cctbx.crystal`. +- `CCTBXScattererParSet`: wrapper for `cctbx.xray.scatterer`. """ from diffpy.srfit.fitbase.parameter import ParameterAdapter @@ -56,7 +56,7 @@ class CCTBXScattererParSet(ParameterSet): def __init__(self, name, strups, idx): """Initialize. - Attributes + Parameters ---------- name The name of this scatterer. @@ -153,7 +153,7 @@ class CCTBXUnitCellParSet(ParameterSet): def __init__(self, strups): """Initialize. - Attributes + Parameters ---------- strups The CCTBXCrystalParSet that contains the cctbx structure @@ -228,7 +228,7 @@ class CCTBXCrystalParSet(BaseStructureParSet): def __init__(self, name, stru): """Initialize. - Attributes + Parameters ---------- name A name for this @@ -237,7 +237,7 @@ def __init__(self, name, stru): """ ParameterSet.__init__(self, name) self.stru = stru - self.addParameterSet(CCTBXUnitCellParSet(self)) + self.add_parameter_set(CCTBXUnitCellParSet(self)) self.scatterers = [] self._update = False @@ -249,7 +249,7 @@ def __init__(self, name, stru): sname = "%s%i" % (el, i) cdict[el] = i + 1 scatterer = CCTBXScattererParSet(sname, self, i) - self.addParameterSet(scatterer) + self.add_parameter_set(scatterer) self.scatterers.append(scatterer) # Constrain the lattice diff --git a/src/diffpy/srfit/structure/diffpyparset.py b/src/diffpy/srfit/structure/diffpyparset.py index 6ed3beef..545cb976 100644 --- a/src/diffpy/srfit/structure/diffpyparset.py +++ b/src/diffpy/srfit/structure/diffpyparset.py @@ -22,9 +22,11 @@ the diffpy.structure.Structure object should be fully configured before passing it to DiffpyStructureParSet. -DiffpyStructureParSet -- Adapter for diffpy.structure.Structure -DiffpyLatticeParSet -- Adapter for diffpy.structure.Lattice -DiffpyAtomParSet -- Adapter for diffpy.structure.Atom +The following classes are adapted: + +- `DiffpyStructureParSet`: adapter for `diffpy.structure.Structure`. +- `DiffpyLatticeParSet`: adapter for `diffpy.structure.Lattice`. +- `DiffpyAtomParSet`: adapter for `diffpy.structure.Atom`. """ __all__ = ["DiffpyStructureParSet"] @@ -91,7 +93,7 @@ class DiffpyAtomParSet(ParameterSet): def __init__(self, name, atom): """Initialize. - Attributes + Parameters ---------- atom A diffpy.structure.Atom instance @@ -205,7 +207,7 @@ class DiffpyLatticeParSet(ParameterSet): def __init__(self, lattice): """Initialize. - Attributes + Parameters ---------- lattice A diffpy.structure.Lattice instance @@ -275,7 +277,7 @@ class DiffpyStructureParSet(SrRealParSet): def __init__(self, name, stru): """Initialize. - Attributes + Parameters ---------- name A name for the structure @@ -284,7 +286,7 @@ def __init__(self, name, stru): """ SrRealParSet.__init__(self, name) self.stru = stru - self.addParameterSet(DiffpyLatticeParSet(stru.lattice)) + self.add_parameter_set(DiffpyLatticeParSet(stru.lattice)) self.atoms = [] cdict = {} @@ -297,7 +299,7 @@ def __init__(self, name, stru): aname = "%s%i" % (el, i) cdict[el] = i + 1 atom = DiffpyAtomParSet(aname, a) - self.addParameterSet(atom) + self.add_parameter_set(atom) self.atoms.append(atom) return diff --git a/src/diffpy/srfit/structure/objcrystparset.py b/src/diffpy/srfit/structure/objcrystparset.py index b35d3b29..755f818e 100644 --- a/src/diffpy/srfit/structure/objcrystparset.py +++ b/src/diffpy/srfit/structure/objcrystparset.py @@ -12,28 +12,31 @@ # See LICENSE_DANSE.txt for license information. # ############################################################################## -"""Wrappers for adapting pyobjcryst.crystal.Crystal to a srfit ParameterSet. +"""Wrappers for adapting pyobjcryst.crystal.Crystal to a srfit +ParameterSet. This will adapt a Crystal or Molecule object from pyobjcryst into the -ParameterSet interface. The following classes are adapted. - -ObjCrystCrystalParSet -- Adapter for pyobjcryst.crystal.Crystal -ObjCrystAtomParSet -- Adapter for pyobjcryst.atom.Atom -ObjCrystMoleculeParSet -- Adapter for pyobjcryst.molecule.Molecule -ObjCrystMolAtomParSet -- Adapter for pyobjcryst.molecule.MolAtom - -Related to the adaptation of Molecule and MolAtom, there are adaptors for -specifying molecule restraints. -ObjCrystBondLengthRestraint -ObjCrystBondAngleRestraint -ObjCrystDihedralAngleRestraint - -There are also Parameters for encapsulating and modifying atoms via their -relative positions. These Parameters can also act like constraints, and can -modify the positions of multiple MolAtoms. -ObjCrystBondLengthParameter -ObjCrystBondAngleParameter -ObjCrystDihedralAngleParameter +ParameterSet interface. The following classes are adapted: + +- `ObjCrystCrystalParSet`: adapter for `pyobjcryst.crystal.Crystal`. +- `ObjCrystAtomParSet`: adapter for `pyobjcryst.atom.Atom`. +- `ObjCrystMoleculeParSet`: adapter for `pyobjcryst.molecule.Molecule`. +- `ObjCrystMolAtomParSet`: adapter for `pyobjcryst.molecule.MolAtom`. + +Related to the adaptation of Molecule and MolAtom, there are adaptors +for specifying molecule restraints: + +- `ObjCrystBondLengthRestraint` +- `ObjCrystBondAngleRestraint` +- `ObjCrystDihedralAngleRestraint` + +There are also Parameters for encapsulating and modifying atoms via +their relative positions. These Parameters can also act like +constraints, and can modify the positions of multiple MolAtoms: + +- `ObjCrystBondLengthParameter` +- `ObjCrystBondAngleParameter` +- `ObjCrystDihedralAngleParameter` """ __all__ = ["ObjCrystMoleculeParSet", "ObjCrystCrystalParSet"] @@ -55,6 +58,37 @@ ) from diffpy.srfit.fitbase.parameterset import ParameterSet from diffpy.srfit.structure.srrealparset import SrRealParSet +from diffpy.utils._deprecator import build_deprecation_message, deprecated + +removal_version = "4.0.0" +bl_base = "diffpy.srfit.structure.objcrystparset.ObjCrystBondLengthParameter" + +bl_setConst_dep_msg = build_deprecation_message( + bl_base, + "setConst", + "set_constant", + removal_version, +) + +ba_base = "diffpy.srfit.structure.objcrystparset.ObjCrystBondAngleParameter" + +ba_setConst_dep_msg = build_deprecation_message( + ba_base, + "setConst", + "set_constant", + removal_version, +) + +da_base = ( + "diffpy.srfit.structure.objcrystparset.ObjCrystDihedralAngleParameter" +) + +da_setConst_dep_msg = build_deprecation_message( + da_base, + "setConst", + "set_constant", + removal_version, +) class ObjCrystScattererParSet(ParameterSet): @@ -82,7 +116,7 @@ class ObjCrystScattererParSet(ParameterSet): def __init__(self, name, scat, parent): """Initialize. - Attributes + Parameters ---------- name The name of the scatterer @@ -144,7 +178,7 @@ class ObjCrystAtomParSet(ObjCrystScattererParSet): def __init__(self, name, atom, parent): """Initialize. - Attributes + Parameters ---------- name The name of the scatterer @@ -220,7 +254,7 @@ class ObjCrystMoleculeParSet(ObjCrystScattererParSet): def __init__(self, name, molecule, parent=None): """Initialize. - Attributes + Parameters ---------- name The name of the scatterer @@ -252,7 +286,7 @@ def __init__(self, name, molecule, parent=None): atom = ObjCrystMolAtomParSet(name, a, self) atom.molecule = self - self.addParameterSet(atom) + self.add_parameter_set(atom) self.atoms.append(atom) anames.append(name) @@ -335,7 +369,8 @@ def wrapRestraints(self): return def wrapStretchModeParameters(self): - """Wrap the stretch modes implicit to the Molecule as Parameters. + """Wrap the stretch modes implicit to the Molecule as + Parameters. This will wrap StretchModeBondLengths and StretchModeBondAngles of the Molecule as Parameters. Note that this requires that the MolBondAtoms @@ -400,7 +435,7 @@ def restrainBondLength( This creates an instance of ObjCrystBondLengthRestraint and adds it to the ObjCrystMoleculeParSet. - Attributes + Parameters ---------- atom1 First atom (ObjCrystMolAtomParSet) in the bond @@ -438,7 +473,7 @@ def restrainBondLengthParameter( This creates an instance of ObjCrystBondLengthRestraint and adds it to the ObjCrystMoleculeParSet. - Attributes + Parameters ---------- par A ObjCrystBondLengthParameter (see addBondLengthParameter) @@ -471,7 +506,7 @@ def restrainBondAngle( This creates an instance of ObjCrystBondAngleRestraint and adds it to the ObjCrystMoleculeParSet. - Attributes + Parameters ---------- atom1 First atom (ObjCrystMolAtomParSet) in the bond angle @@ -512,7 +547,7 @@ def restrainBondAngleParameter( This creates an instance of ObjCrystBondAngleRestraint and adds it to the ObjCrystMoleculeParSet. - Attributes + Parameters ---------- par A ObjCrystBondAngleParameter (see addBondAngleParameter) @@ -545,7 +580,7 @@ def restrainDihedralAngle( This creates an instance of ObjCrystDihedralAngleRestraint and adds it to the ObjCrystMoleculeParSet. - Attributes + Parameters ---------- atom1 First atom (ObjCrystMolAtomParSet) in the angle @@ -587,7 +622,7 @@ def restrainDihedralAngleParameter( This creates an instance of ObjCrystDihedralAngleRestraint and adds it to the ObjCrystMoleculeParSet. - Attributes + Parameters ---------- par A ObjCrystDihedralAngleParameter (see @@ -628,7 +663,7 @@ def addBondLengthParameter( This creates a ObjCrystBondLengthParameter to the ObjCrystMoleculeParSet that can be adjusted during the fit. - Attributes + Parameters ---------- name The name of the ObjCrystBondLengthParameter @@ -663,7 +698,7 @@ def addBondAngleParameter( This creates a ObjCrystBondAngleParameter to the ObjCrystMoleculeParSet that can be adjusted during the fit. - Attributes + Parameters ---------- name The name of the ObjCrystBondAngleParameter @@ -703,7 +738,7 @@ def addDihedralAngleParameter( This creates a ObjCrystDihedralAngleParameter to the ObjCrystMoleculeParSet that can be adjusted during the fit. - Attributes + Parameters ---------- name The name of the ObjCrystDihedralAngleParameter. @@ -776,7 +811,7 @@ class ObjCrystMolAtomParSet(ObjCrystScattererParSet): def __init__(self, name, scat, parent): """Initialize. - Attributes + Parameters ---------- name The name of the scatterer @@ -848,7 +883,7 @@ class ObjCrystMoleculeRestraint(object): def __init__(self, res, scaled=False): """Create a Restraint-like from a pyobjcryst Molecule restraint. - Attributes + Parameters ---------- res The pyobjcryst Molecule restraint. @@ -864,7 +899,7 @@ def __init__(self, res, scaled=False): def penalty(self, w=1.0): """Calculate the penalty of the restraint. - Attributes + Parameters ---------- w The point-average chi^2 which is optionally used to scale the @@ -905,7 +940,7 @@ class ObjCrystBondLengthRestraint(ObjCrystMoleculeRestraint): def __init__(self, atom1, atom2, length, sigma, delta, scaled=False): """Create a bond length restraint. - Attributes + Parameters ---------- atom1 First atom (ObjCrystMolAtomParSet) in the bond @@ -977,7 +1012,7 @@ class ObjCrystBondAngleRestraint(ObjCrystMoleculeRestraint): def __init__(self, atom1, atom2, atom3, angle, sigma, delta, scaled=False): """Create a bond angle restraint. - Attributes + Parameters ---------- atom1 First atom (ObjCrystMolAtomParSet) in the bond angle @@ -1059,7 +1094,7 @@ def __init__( ): """Create a dihedral angle restraint. - Attributes + Parameters ---------- atom1 First atom (ObjCrystMolAtomParSet) in the angle @@ -1135,7 +1170,7 @@ class StretchModeParameter(Parameter): def __init__(self, name, value=None, const=False): """Initialization. - Attributes + Parameters ---------- name The name of this Parameter (must be a valid attribute @@ -1151,7 +1186,7 @@ def __init__(self, name, value=None, const=False): Parameter.__init__(self, name, value, const) self.keepcenter = True - def setValue(self, val): + def set_value(self, val): """Change the value of the Parameter.""" curval = self.getValue() val = float(val) @@ -1164,7 +1199,7 @@ def setValue(self, val): self.mode.Stretch(delta, self.keepcenter) # Let Parameter take care of the general details - Parameter.setValue(self, val) + Parameter.set_value(self, val) return self @@ -1266,9 +1301,9 @@ class ObjCrystBondLengthParameter(StretchModeParameter): const A flag indicating whether this is considered a constant. _value - The value of the Parameter. Modified with 'setValue'. + The value of the Parameter. Modified with 'set_value'. value - Property for 'getValue' and 'setValue'. + Property for 'getValue' and 'set_value'. constraint A callable that calculates the value of this Parameter. If this is None (None), the the Parameter is responsible for its @@ -1281,7 +1316,7 @@ class ObjCrystBondLengthParameter(StretchModeParameter): def __init__(self, name, atom1, atom2, value=None, const=False, mode=None): """Create a ObjCrystBondLengthParameter. - Attributes + Parameters ---------- name The name of the ObjCrystBondLengthParameter @@ -1324,31 +1359,49 @@ def __init__(self, name, atom1, atom2, value=None, const=False, mode=None): if value is None: value = GetBondLength(atom1.scat, atom2.scat) StretchModeParameter.__init__(self, name, value, const) - self.setConst(const) + self.set_constant(const) return - def setConst(self, const=True, value=None): + def set_constant(self, is_constant=True, value=None): """Toggle the Parameter as constant. - This sets the underlying ObjCrystMolAtomParSet positions const as well. + This sets the underlying ObjCrystMolAtomParSet positions + constant as well. - Attributes + Parameters ---------- - const + is_constant Flag indicating if the Parameter is constant (default True). value An optional value for the Parameter (default None). If this is not None, then the Parameter will get a new value, constant or otherwise. + + Return + ------ + self + Returns self so that mutators can be chained. """ - StretchModeParameter.setConst(self, const, value) + StretchModeParameter.set_constant(self, is_constant, value) for a in [self.atom1, self.atom2]: - a.x.setConst(const) - a.y.setConst(const) - a.z.setConst(const) + a.x.set_constant(is_constant) + a.y.set_constant(is_constant) + a.z.set_constant(is_constant) + return self + + @deprecated(bl_setConst_dep_msg) + def setConst(self, const=True, value=None): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.structure.objcryst.ObjCrystBondLengthParameter.set_constant + instead. + """ + self.set_constant(const, value) return self def getValue(self): @@ -1360,7 +1413,7 @@ def getValue(self): """ if self._value is None: val = GetBondLength(self.atom1.scat, self.atom2.scat) - Parameter.setValue(self, val) + Parameter.set_value(self, val) return self._value @@ -1403,9 +1456,9 @@ class ObjCrystBondAngleParameter(StretchModeParameter): const A flag indicating whether this is considered a constant. _value - The value of the Parameter. Modified with 'setValue'. + The value of the Parameter. Modified with 'set_value'. value - Property for 'getValue' and 'setValue'. + Property for 'getValue' and 'set_value'. constraint A callable that calculates the value of this Parameter. If this is None (None), the the Parameter is responsible for its @@ -1420,7 +1473,7 @@ def __init__( ): """Create a ObjCrystBondAngleParameter. - Attributes + Parameters ---------- name The name of the ObjCrystBondAngleParameter. @@ -1468,30 +1521,48 @@ def __init__( if value is None: value = GetBondAngle(atom1.scat, atom2.scat, atom3.scat) StretchModeParameter.__init__(self, name, value, const) - self.setConst(const) + self.set_constant(const) return - def setConst(self, const=True, value=None): + def set_constant(self, is_constant=True, value=None): """Toggle the Parameter as constant. - This sets the underlying ObjCrystMolAtomParSet positions const as well. + This sets the underlying ObjCrystMolAtomParSet positions + constant as well. - Attributes + Parameters ---------- - const + is_constant Flag indicating if the Parameter is constant (default True). value An optional value for the Parameter (default None). If this is not None, then the Parameter will get a new value, constant or otherwise. + + Return + ------ + self + Returns self so that mutators can be chained. """ - StretchModeParameter.setConst(self, const, value) + StretchModeParameter.set_constant(self, is_constant, value) for a in [self.atom1, self.atom2, self.atom3]: - a.x.setConst(const) - a.y.setConst(const) - a.z.setConst(const) + a.x.set_constant(is_constant) + a.y.set_constant(is_constant) + a.z.set_constant(is_constant) + return self + + @deprecated(ba_setConst_dep_msg) + def setConst(self, const=True, value=None): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use + diffpy.srfit.structure.objcryst.ObjCrystBondAngleParameter.set_constant + instead. + """ + self.set_constant(const, value) return self def getValue(self): @@ -1505,7 +1576,7 @@ def getValue(self): val = GetBondAngle( self.atom1.scat, self.atom2.scat, self.atom3.scat ) - Parameter.setValue(self, val) + Parameter.set_value(self, val) return self._value @@ -1514,7 +1585,8 @@ def getValue(self): class ObjCrystDihedralAngleParameter(StretchModeParameter): - """Class for abstracting a dihedral angle in a Molecule to a Parameter. + """Class for abstracting a dihedral angle in a Molecule to a + Parameter. This wraps up a pyobjcryst.molecule.StretchModeTorsion object so that the angle defined by four MolAtoms ([a1-a2].[a3-a4]) in a Molecule can be used @@ -1552,9 +1624,9 @@ class ObjCrystDihedralAngleParameter(StretchModeParameter): const A flag indicating whether this is considered a constant. _value - The value of the Parameter. Modified with 'setValue'. + The value of the Parameter. Modified with 'set_value'. value - Property for 'getValue' and 'setValue'. + Property for 'getValue' and 'set_value'. constraint A callable that calculates the value of this Parameter. If this is None (None), the the Parameter is responsible for its @@ -1577,7 +1649,7 @@ def __init__( ): """Create a ObjCrystDihedralAngleParameter. - Attributes + Parameters ---------- name The name of the ObjCrystDihedralAngleParameter @@ -1630,30 +1702,47 @@ def __init__( atom1.scat, atom2.scat, atom3.scat, atom4.scat ) StretchModeParameter.__init__(self, name, value, const) - self.setConst(const) + self.set_constant(const) return - def setConst(self, const=True, value=None): + def set_constant(self, is_constant=True, value=None): """Toggle the Parameter as constant. This sets the underlying ObjCrystMolAtomParSet positions const as well. - Attributes + Parameters ---------- - const + is_constant Flag indicating if the Parameter is constant (default True). value An optional value for the Parameter (default None). If this is not None, then the Parameter will get a new value, constant or otherwise. + + Return + ------ + self + Returns self so that mutators can be chained. """ - StretchModeParameter.setConst(self, const, value) + StretchModeParameter.set_constant(self, is_constant, value) for a in [self.atom1, self.atom2, self.atom3, self.atom4]: - a.x.setConst(const) - a.y.setConst(const) - a.z.setConst(const) + a.x.set_constant(is_constant) + a.y.set_constant(is_constant) + a.z.set_constant(is_constant) + return self + + @deprecated(da_setConst_dep_msg) + def setConst(self, const=True, value=None): + """This function has been deprecated and will be removed in + version 4.0.0. + + Please use + diffpy.srfit.structure.objcryst.ObjCrystDihedralAngleParameter.set_constant + instead. + """ + self.set_constant(const, value) return self def getValue(self): @@ -1670,7 +1759,7 @@ def getValue(self): self.atom3.scat, self.atom4.scat, ) - Parameter.setValue(self, val) + Parameter.set_value(self, val) return self._value @@ -1716,7 +1805,7 @@ class ObjCrystCrystalParSet(SrRealParSet): def __init__(self, name, cryst): """Initialize. - Attributes + Parameters ---------- name A name for this ParameterSet @@ -1757,7 +1846,7 @@ def __init__(self, name, cryst): else: raise TypeError("Unrecognized scatterer '%s'" % cname) - self.addParameterSet(parset) + self.add_parameter_set(parset) self.scatterers.append(parset) snames.append(name) @@ -1791,7 +1880,7 @@ def _constrain_space_group(self): def _create_space_group(sgobjcryst): """Create a diffpy.structure SpaceGroup object from pyobjcryst. - Attributes + Parameters ---------- sgobjcryst A pyobjcryst.spacegroup.SpaceGroup instance. diff --git a/src/diffpy/srfit/structure/sgconstraints.py b/src/diffpy/srfit/structure/sgconstraints.py index 7777dca1..2fdd48cb 100644 --- a/src/diffpy/srfit/structure/sgconstraints.py +++ b/src/diffpy/srfit/structure/sgconstraints.py @@ -20,11 +20,22 @@ from diffpy.srfit.fitbase.parameter import ParameterProxy from diffpy.srfit.fitbase.recipeorganizer import RecipeContainer +from diffpy.utils._deprecator import build_deprecation_message, deprecated -__all__ = ["constrainAsSpaceGroup"] +__all__ = ["constrain_as_space_group", "constrainAsSpaceGroup"] +removal_version = "4.0.0" +sgconstraints_base = "diffpy.srfit.structure.sgconstraints" -def constrainAsSpaceGroup( +constrainAsSpaceGroup_dep_msg = build_deprecation_message( + sgconstraints_base, + "constrainAsSpaceGroup", + "constrain_as_space_group", + removal_version, +) + + +def constrain_as_space_group( phase, spacegroup, scatterers=None, @@ -120,6 +131,36 @@ def constrainAsSpaceGroup( return sgp +@deprecated(constrainAsSpaceGroup_dep_msg) +def constrainAsSpaceGroup( + phase, + spacegroup, + scatterers=None, + sgoffset=[0, 0, 0], + constrainlat=True, + constrainadps=True, + adpsymbols=None, + isosymbol="Uiso", +): + """This function is deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.structure.sgconstraints.constrain_as_space_group + instead. + """ + return constrain_as_space_group( + phase, + spacegroup, + scatterers, + sgoffset, + constrainlat, + constrainadps, + adpsymbols, + isosymbol, + ) + + def _constrain_as_space_group( phase, sg, @@ -130,10 +171,10 @@ def _constrain_as_space_group( adpsymbols=None, isosymbol="Uiso", ): - """Restricted interface to constrainAsSpaceGroup. + """Restricted interface to constrain_as_space_group. - Arguments: As constrainAsSpaceGroup, except - ------------------------------------------- + Arguments: As constrain_as_space_group, except + ----------------------------------------------- sg diffpy.structure.spacegroups.SpaceGroup instance """ @@ -158,7 +199,7 @@ def _constrain_as_space_group( return sgp -# End constrainAsSpaceGroup +# End constrain_as_space_group class BaseSpaceGroupParameters(RecipeContainer): @@ -187,7 +228,7 @@ def __init__(self, name="sgpars"): def addParameter(self, par, check=True): """Store a Parameter. - Attributes + Parameters ---------- par The Parameter to be stored. @@ -210,7 +251,7 @@ class SpaceGroupParameters(BaseSpaceGroupParameters): This class is used to store the variable Parameters of a structure, leaving out those that constrained or fixed due to space group. This does the work - of the constrainAsSpaceGroup method. This class has the same Parameter + of the constrain_as_space_group method. This class has the same Parameter attribute access of a ParameterSet. Attributes @@ -352,7 +393,7 @@ def _get_adp_pars(self): def _make_constraints(self): """Constrain the structure to the space group. - This works as described by the constrainAsSpaceGroup method. + This works as described by the constrain_as_space_group method. """ # Start by clearing the constraints self._clear_constraints() @@ -386,9 +427,9 @@ def _clear_constraints(self): for scatterer in scatterers: for par in [scatterer.x, scatterer.y, scatterer.z]: - if scatterer.isConstrained(par): - scatterer.unconstrain(par) - par.setConst(False) + if scatterer.is_constrained(par): + scatterer.remove_constraint(par) + par.set_constant(False) # Clear the lattice if self.constrainlat: @@ -403,9 +444,9 @@ def _clear_constraints(self): lattice.gamma, ] for par in latpars: - if lattice.isConstrained(par): - lattice.unconstrain(par) - par.setConst(False) + if lattice.is_constrained(par): + lattice.remove_constraint(par) + par.set_constant(False) # Clear ADPs if self.constrainadps: @@ -413,16 +454,16 @@ def _clear_constraints(self): if isosymbol: par = scatterer.get(isosymbol) if par is not None: - if scatterer.isConstrained(par): - scatterer.unconstrain(par) - par.setConst(False) + if scatterer.is_constrained(par): + scatterer.remove_constraint(par) + par.set_constant(False) for pname in adpsymbols: par = scatterer.get(pname) if par is not None: - if scatterer.isConstrained(par): - scatterer.unconstrain(par) - par.setConst(False) + if scatterer.is_constrained(par): + scatterer.remove_constraint(par) + par.set_constant(False) return @@ -465,7 +506,7 @@ def _constrain_lattice(self): def _constrain_xyzs(self, positions): """Constrain the positions. - Attributes + Parameters ---------- positions The coordinates of the scatterers. @@ -507,7 +548,7 @@ def _constrain_xyzs(self, positions): def _constrain_adps(self, positions): """Constrain the ADPs. - Attributes + Parameters ---------- positions The coordinates of the scatterers. @@ -589,7 +630,9 @@ def _constrain_adps(self, positions): continue isoidx.append(j) scatterer = scatterers[j] - scatterer.constrain(isosymbol, isoname, ns=self._parameters) + scatterer.add_constraint( + isosymbol, isoname, params=self._parameters + ) fadp = g.UFormulas(adpnames) @@ -609,7 +652,7 @@ def _constrain_adps(self, positions): def __add_par(self, parname, par): """Constrain a parameter via proxy with a specified name. - Attributes + Parameters ---------- par Parameter to constrain @@ -643,14 +686,14 @@ def _constrain_monoclinic(lattice): if lattice.angunits == "rad": afactor = deg2rad ang90 = 90.0 * afactor - lattice.alpha.setConst(True, ang90) + lattice.alpha.set_constant(True, ang90) beta = lattice.beta.getValue() gamma = lattice.gamma.getValue() if ang90 != beta and ang90 == gamma: - lattice.gamma.setConst(True, ang90) + lattice.gamma.set_constant(True, ang90) else: - lattice.beta.setConst(True, ang90) + lattice.beta.set_constant(True, ang90) return @@ -663,9 +706,9 @@ def _constrain_orthorhombic(lattice): if lattice.angunits == "rad": afactor = deg2rad ang90 = 90.0 * afactor - lattice.alpha.setConst(True, ang90) - lattice.beta.setConst(True, ang90) - lattice.gamma.setConst(True, ang90) + lattice.alpha.set_constant(True, ang90) + lattice.beta.set_constant(True, ang90) + lattice.gamma.set_constant(True, ang90) return @@ -679,10 +722,10 @@ def _constrain_tetragonal(lattice): if lattice.angunits == "rad": afactor = deg2rad ang90 = 90.0 * afactor - lattice.alpha.setConst(True, ang90) - lattice.beta.setConst(True, ang90) - lattice.gamma.setConst(True, ang90) - lattice.constrain(lattice.b, lattice.a) + lattice.alpha.set_constant(True, ang90) + lattice.beta.set_constant(True, ang90) + lattice.gamma.set_constant(True, ang90) + lattice.add_constraint(lattice.b, lattice.a) return @@ -699,15 +742,15 @@ def _constrain_trigonal(lattice): ang90 = 90.0 * afactor ang120 = 120.0 * afactor if lattice.gamma.getValue() == ang120: - lattice.constrain(lattice.b, lattice.a) - lattice.alpha.setConst(True, ang90) - lattice.beta.setConst(True, ang90) - lattice.gamma.setConst(True, ang120) + lattice.add_constraint(lattice.b, lattice.a) + lattice.alpha.set_constant(True, ang90) + lattice.beta.set_constant(True, ang90) + lattice.gamma.set_constant(True, ang120) else: - lattice.constrain(lattice.b, lattice.a) - lattice.constrain(lattice.c, lattice.a) - lattice.constrain(lattice.beta, lattice.alpha) - lattice.constrain(lattice.gamma, lattice.alpha) + lattice.add_constraint(lattice.b, lattice.a) + lattice.add_constraint(lattice.c, lattice.a) + lattice.add_constraint(lattice.beta, lattice.alpha) + lattice.add_constraint(lattice.gamma, lattice.alpha) return @@ -722,10 +765,10 @@ def _constrain_hexagonal(lattice): afactor = deg2rad ang90 = 90.0 * afactor ang120 = 120.0 * afactor - lattice.constrain(lattice.b, lattice.a) - lattice.alpha.setConst(True, ang90) - lattice.beta.setConst(True, ang90) - lattice.gamma.setConst(True, ang120) + lattice.add_constraint(lattice.b, lattice.a) + lattice.alpha.set_constant(True, ang90) + lattice.beta.set_constant(True, ang90) + lattice.gamma.set_constant(True, ang120) return @@ -739,11 +782,11 @@ def _constrain_cubic(lattice): if lattice.angunits == "rad": afactor = deg2rad ang90 = 90.0 * afactor - lattice.constrain(lattice.b, lattice.a) - lattice.constrain(lattice.c, lattice.a) - lattice.alpha.setConst(True, ang90) - lattice.beta.setConst(True, ang90) - lattice.gamma.setConst(True, ang90) + lattice.add_constraint(lattice.b, lattice.a) + lattice.add_constraint(lattice.c, lattice.a) + lattice.alpha.set_constant(True, ang90) + lattice.beta.set_constant(True, ang90) + lattice.gamma.set_constant(True, ang90) return @@ -763,7 +806,7 @@ def _constrain_cubic(lattice): def _makeconstraint(parname, formula, scatterer, idx, ns={}): """Constrain a parameter according to a formula. - Attributes + Parameters ---------- parname Name of parameter @@ -796,18 +839,19 @@ def _makeconstraint(parname, formula, scatterer, idx, ns={}): # Check to see if it is a constant fval = _get_float(formula) if fval is not None: - par.setConst() + par.set_constant() return # If we got here, then we have a constraint equation # Fix any division issues formula = formula.replace("/", "*1.0/") - scatterer.constrain(par, formula, ns=ns) + scatterer.add_constraint(par, formula, params=ns) return def _get_float(formula): - """Get a float from a formula string, or None if this is not possible.""" + """Get a float from a formula string, or None if this is not + possible.""" try: return eval(formula) except NameError: diff --git a/src/diffpy/srfit/structure/srrealparset.py b/src/diffpy/srfit/structure/srrealparset.py index ac384b87..e98f06c7 100644 --- a/src/diffpy/srfit/structure/srrealparset.py +++ b/src/diffpy/srfit/structure/srrealparset.py @@ -52,7 +52,7 @@ def restrainBVS(self, sig=1, scaled=False): this is also scaled by the current point-averaged chi^2 value so the restraint is roughly equally weighted in the fit. - Attributes + Parameters ---------- sig The uncertainty on the BVS (default 1). diff --git a/src/diffpy/srfit/util/__init__.py b/src/diffpy/srfit/util/__init__.py index 023b4256..c8c9b830 100644 --- a/src/diffpy/srfit/util/__init__.py +++ b/src/diffpy/srfit/util/__init__.py @@ -18,8 +18,8 @@ def sortKeyForNumericString(s): - """Compute key for sorting strings according to their integer numeric - value. + """Compute key for sorting strings according to their integer + numeric value. Each string gets split to string and integer segments to create keys for comparison. Signs, decimal points and exponents are ignored. diff --git a/src/diffpy/srfit/util/argbinders.py b/src/diffpy/srfit/util/argbinders.py index 9fa000dd..4c896308 100644 --- a/src/diffpy/srfit/util/argbinders.py +++ b/src/diffpy/srfit/util/argbinders.py @@ -14,11 +14,12 @@ ############################################################################## """Functions for binding arguments of callable objects.""" -# FIXME: remove this line when `docformatter` fixes the blank line bug +# FIXME: remove this line when black and docformatter agree. class bind2nd(object): - """Freeze second argument of a callable object to a given constant.""" + """Freeze second argument of a callable object to a given + constant.""" def __init__(self, func, arg1): """Freeze the second argument of function func to arg1.""" diff --git a/src/diffpy/srfit/util/inpututils.py b/src/diffpy/srfit/util/inpututils.py index b5dfa2ac..1e0d83fa 100644 --- a/src/diffpy/srfit/util/inpututils.py +++ b/src/diffpy/srfit/util/inpututils.py @@ -17,6 +17,7 @@ __all__ = ["inputToString"] import os.path +from pathlib import Path def inputToString(input): @@ -25,7 +26,7 @@ def inputToString(input): This is useful when you want a method to accept a string, open file object or file name. - Attributes + Parameters ---------- input An open file-like object, name of a file @@ -51,4 +52,44 @@ def inputToString(input): return inptstr +def get_dict_from_results_file( + results_filepath: Path | str, +) -> dict[str, float]: + """Get a dictionary of parameter names and values from a results + file. + + The file should have lines in the format: + "parameter_name value +/- uncertainty". Lines that do not match this + format will be ignored. + + Parameters + ---------- + results_filepath : pathlib.Path or str + The path to the results file. + + Returns + ------- + parsed_results_dict : dict + The dictionary where keys are parameter names and values are the + corresponding parameter values as floats. + """ + with open(results_filepath, "r") as f: + results_string = f.read() + parsed_results_dict = {} + for raw_line in results_string.splitlines(): + line = raw_line.strip() + # skip blank lines and lines that are just dashes + if not line or set(line) == {"-"}: + continue + line_items = line.split() + if len(line_items) < 2: + continue + if len(line_items) >= 4 and line_items[2] == "+/-": + try: + parsed_results_dict[line_items[0]] = float(line_items[1]) + except ValueError: + pass + return parsed_results_dict + + # End of file diff --git a/src/diffpy/srfit/util/tagmanager.py b/src/diffpy/srfit/util/tagmanager.py index 9e981d64..37c58a3d 100644 --- a/src/diffpy/srfit/util/tagmanager.py +++ b/src/diffpy/srfit/util/tagmanager.py @@ -53,7 +53,7 @@ def tag(self, obj, *tags): Tags are stored as strings. - Attributes + Parameters ---------- obj Any hashable object to be untagged. @@ -71,7 +71,7 @@ def tag(self, obj, *tags): def untag(self, obj, *tags): """Remove tags from an object. - Attributes + Parameters ---------- obj Any hashable object to be untagged. diff --git a/src/diffpy/srfit/util/weakrefcallable.py b/src/diffpy/srfit/util/weakrefcallable.py index 3f143d7f..1cbb87aa 100644 --- a/src/diffpy/srfit/util/weakrefcallable.py +++ b/src/diffpy/srfit/util/weakrefcallable.py @@ -68,7 +68,8 @@ def __init__(self, f, fallback=None): return def __call__(self, *args, **kwargs): - """Call the wrapped method if the weak-referenced object is alive. + """Call the wrapped method if the weak-referenced object is + alive. If that object does not exist and the fallback function is defined, call the fallback function instead. @@ -121,7 +122,8 @@ def __getstate__(self): return state def __setstate__(self, state): - """Restore the weak reference in this wrapper upon unpickling.""" + """Restore the weak reference in this wrapper upon + unpickling.""" self._class, nm, self.fallback, mobj = state self.function = getattr(self._class, nm) if mobj is None: diff --git a/src/diffpy/srfit/version.py b/src/diffpy/srfit/version.py index f0833049..e341883e 100644 --- a/src/diffpy/srfit/version.py +++ b/src/diffpy/srfit/version.py @@ -3,12 +3,12 @@ # # (c) 2008-2025 The Trustees of Columbia University in the City of New York. # All rights reserved. +# (c) 2026-present The DiffPy Team. All rights reserved. # -# File coded by: Christopher Farrow, Pavol Juhas, and members of the -# Billinge Group. -# +# File coded by: Christopher Farrow, Pavol Juhas, Caden Myers, +# Simon J. L. Billinge, Billinge Group members and community contributors. # See GitHub contributions for a more detailed list of contributors. -# https://github.com/diffpy/diffpy.srfit/graphs/contributors +# https://github.com/diffpy/diffpy.srfit/graphs/contributors # noqa: E501 # # See LICENSE.rst for license information. # @@ -19,8 +19,9 @@ # __all__ = ["__date__", "__git_commit__", "__timestamp__", "__version__"] # obtain version information -from importlib.metadata import version - -__version__ = version("diffpy.srfit") +from importlib.metadata import PackageNotFoundError, version -# End of file +try: + __version__ = version("diffpy.srfit") +except PackageNotFoundError: + __version__ = "unknown" diff --git a/tests/conftest.py b/tests/conftest.py index 20c47a22..b0859d0d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -85,7 +85,8 @@ def pyobjcryst_available(): @pytest.fixture(scope="session") def datafile(): - """Fixture to load a test data file from the testdata package directory.""" + """Fixture to load a test data file from the testdata package + directory.""" def _datafile(filename): return importlib.resources.files("tests.testdata").joinpath(filename) @@ -141,68 +142,314 @@ def _capturestdout(f, *args, **kwargs): return _capturestdout -@pytest.fixture(scope="session") -def build_recipe_one_contribution(): - "helper to build a simple recipe" - profile = Profile() - x = linspace(0, pi, 10) +@pytest.fixture(scope="function") +def build_recipes_one_contribution(): + """Helper to build a simple recipe. + + Two identical recipes are returned if needed. + """ + x = linspace(0, pi, 11) y = sin(x) - profile.setObservedProfile(x, y) - contribution = FitContribution("c1") - contribution.set_profile(profile) - contribution.setEquation("A*sin(k*x + c)") - recipe = FitRecipe() - recipe.addContribution(contribution) - recipe.addVar(contribution.A, 1) - recipe.addVar(contribution.k, 1) - recipe.addVar(contribution.c, 1) - return recipe + profile1 = Profile() + profile1.set_observed_profile(x, y) + contribution1 = FitContribution("c1") + contribution1.set_profile(profile1) + contribution1.set_equation("amplitude*sin(wave_number*x + phase_shift)") + recipe1 = FitRecipe() + recipe1.add_contribution(contribution1) + recipe1.add_variable(contribution1.amplitude, 4) + recipe1.add_variable(contribution1.wave_number, 3) + recipe1.add_variable(contribution1.phase_shift, 2) -@pytest.fixture(scope="session") + profile2 = Profile() + profile2.set_observed_profile(x, y) + contribution2 = FitContribution("c2") + contribution2.set_profile(profile2) + contribution2.set_equation("amplitude*sin(wave_number*x + phase_shift)") + recipe2 = FitRecipe() + recipe2.add_contribution(contribution2) + recipe2.add_variable(contribution2.amplitude, 4) + recipe2.add_variable(contribution2.wave_number, 3) + recipe2.add_variable(contribution2.phase_shift, 2) + return recipe1, recipe2 + + +@pytest.fixture(scope="function") def build_recipe_two_contributions(): - "helper to build a recipe with two contributions" + """Helper to build a recipe with two physically related + contributions.""" profile1 = Profile() - x = linspace(0, pi, 10) - y1 = sin(x) - profile1.setObservedProfile(x, y1) + x = linspace(0, pi, 51) + y1 = sin(x) # amplitude=1, freq=1 + profile1.set_observed_profile(x, y1) + contribution1 = FitContribution("c1") contribution1.set_profile(profile1) - contribution1.setEquation("A*sin(k*x + c)") + contribution1.set_equation("A*sin(k*x + c)") profile2 = Profile() - y2 = 0.5 * sin(2 * x) - profile2.setObservedProfile(x, y2) + y2 = 0.5 * sin(2 * x) # amplitude=0.5, freq=2 + profile2.set_observed_profile(x, y2) + contribution2 = FitContribution("c2") contribution2.set_profile(profile2) - contribution2.setEquation("B*sin(m*x + d)") + contribution2.set_equation("B*sin(m*x + d)") + recipe = FitRecipe() - recipe.addContribution(contribution1) - recipe.addContribution(contribution2) - recipe.addVar(contribution1.A, 1) - recipe.addVar(contribution1.k, 1) - recipe.addVar(contribution1.c, 1) - recipe.addVar(contribution2.B, 0.5) - recipe.addVar(contribution2.m, 2) - recipe.addVar(contribution2.d, 0) + recipe.add_contribution(contribution1) + recipe.add_contribution(contribution2) + + # Add variables with reasonable initial guesses + recipe.add_variable(contribution1.A, 0.8) + recipe.add_variable(contribution1.k, 1.0) + recipe.add_variable(contribution1.c, 0.1) + + recipe.add_variable(contribution2.B, 0.4) + recipe.add_variable(contribution2.m, 2.0) + recipe.add_variable(contribution2.d, 0.1) + + # ---- Meaningful constraints ---- + recipe.add_constraint(contribution2.m, "2*k") + recipe.add_constraint(contribution2.d, contribution1.c) + recipe.add_constraint(contribution2.B, "0.5*A") + recipe.add_soft_bounds(contribution1.A, 0.5, 1.5) + recipe.add_soft_bounds(contribution1.k, 0.8, 1.2) return recipe +@pytest.fixture(scope="session") +def as_list(): + def _as_list(values): + """Unavailable uncertainties are None rather than an array.""" + return None if values is None else values.tolist() + + return _as_list + + +@pytest.fixture(scope="function") +def build_recipe_with_uncertainty(): + """Helper to build a sine recipe whose profile carries the observed + uncertainties returned by ``make_dyobs``. + + The observed profile is a noiseless sine, so a refinement recovers + ``A=1``, ``k=1`` and ``c=0`` regardless of how it is weighted. + """ + + def _build_recipe(make_dyobs): + xobs = linspace(0, pi, 21) + yobs = sin(xobs) + profile = Profile() + profile.set_observed_profile(xobs, yobs, make_dyobs(xobs)) + contribution = FitContribution("c1") + contribution.set_profile(profile) + contribution.set_equation("A*sin(k*x + c)") + recipe = FitRecipe() + recipe.fithooks[0].verbose = 0 + recipe.add_contribution(contribution) + recipe.add_variable(contribution.A, 0.8) + recipe.add_variable(contribution.k, 1.2) + recipe.add_variable(contribution.c, 0.1) + return recipe, profile + + return _build_recipe + + @pytest.fixture def temp_data_files(tmp_path): """ Temporary directory containing: - - data_with_meta.gr - - data_without_meta.dat - Each file contains a single line of data. + - gr_file.gr + - dat_file.dat + - cgr_file.cgr + - fit_results.res """ - file_with_meta = tmp_path / "gr_file.gr" - file_with_meta.write_text("1.0 2.0\n" "1.1 2.1\n" "1.2 2.2\n") + data = "\n".join( + [ + "1.0 2.0", + "1.1 2.1", + "1.2 2.2", + "", + ] + ) + + (tmp_path / "gr_file.gr").write_text(data) + (tmp_path / "dat_file.dat").write_text(data) + (tmp_path / "cgr_file.cgr").write_text(data) + + results = "\n".join( + [ + "Results written: Wed Feb 25 15:14:58 2026", + "produced by cadenmyers", + "", + "Some quantities invalid due to missing profile uncertainty", + "Overall (Chi2 and Reduced Chi2 invalid)", + "------------------------------------------------------------------------------", # noqa + "Residual 0.00000000", + "Contributions 0.00000000", + "Restraints 0.00000000", + "Chi2 0.00000000", + "Reduced Chi2 0.00000000", + "Rw 0.00000000", + "", + "Variables (Uncertainties invalid)", + "------------------------------------------------------------------------------", # noqa + "amplitude 1.00000000e+00 +/- 4.82804000e-01", + "phase_shift -1.61291146e-18 +/- 1.00000000e+00", + "wave_number 1.00000000e+00 +/- 2.17496687e-01", + "", + "Variable Correlations greater than 25% (Correlations invalid)", + "------------------------------------------------------------------------------", # noqa + "No correlations greater than 25%", + "", + ] + ) + + (tmp_path / "fit_results.res").write_text(results) + + yield tmp_path - dat_file = tmp_path / "dat_file.dat" - dat_file.write_text("1.0 2.0\n" "1.1 2.1\n" "1.2 2.2\n") - cgr_file = tmp_path / "cgr_file.cgr" - cgr_file.write_text("1.0 2.0\n" "1.1 2.1\n" "1.2 2.2\n") +@pytest.fixture +def parser_datafiles(tmp_path): + """Create temporary data files with different column layouts.""" + metadata = "\n".join( + [ + "# xPDFsuite Configuration #", + "[PDF]", + "wavelength = 0.1", + "dataformat = QA", + "inputfile = input.iq", + "backgroundfile = backgroundfile.iq", + "mode = xray", + "bgscale = 1.0", + "composition = TiSe2", + "outputtype = gr", + "qmaxinst = 25.0", + "qmin = 0.1", + "qmax = 25.0", + "rmax = 140.0", + "rmin = 0.0", + "rstep = 0.01", + "rpoly = 0.7", + "", + "[Misc]", + "inputdir = /my/data/dir", + "savedir = /my/save/dir", + "backgroundfilefull = /my/data/dir/backgroundfile.iq", + "", + "#### start data", + "#S 1", + "", + ] + ) + + (tmp_path / "four_col.gr").write_text( + metadata + + "\n".join( + [ + r"#L r($\AA$) G($\AA^{-2}$) dr($\AA$) dG($\AA^{-2}$)", + "1.0 2.0 0.1 0.2", + "1.1 2.1 0.3 0.4", + "1.2 2.2 0.5 0.6", + "", + ] + ) + ) + + (tmp_path / "three_col.dat").write_text( + metadata + + "\n".join( + [ + r"#L r($\AA$) G($\AA^{-2}$) dG($\AA^{-2}$)", + "1.0 2.0 0.2", + "1.1 2.1 0.4", + "1.2 2.2 0.6", + "", + ] + ) + ) + + (tmp_path / "two_col.txt").write_text( + metadata + + "\n".join( + [ + r"#L r($\AA$) G($\AA^{-2}$)", + "1.0 2.0", + "1.1 2.1", + "1.2 2.2", + "", + ] + ) + ) + + (tmp_path / "four_col_reordered.txt").write_text( + metadata + + "\n".join( + [ + r"#L r($\AA$) dr($\AA$) G($\AA^{-2}$) dG($\AA^{-2}$)", + "1.0 0.1 2.0 0.2", + "1.1 0.3 2.1 0.4", + "1.2 0.5 2.2 0.6", + "", + ] + ) + ) + + (tmp_path / "four_col_nan_inf.gr").write_text( + metadata + + "\n".join( + [ + r"#L r($\AA$) G($\AA^{-2}$) dr($\AA$) dG($\AA^{-2}$)", + "1.0 2.0 nan inf", + "1.1 2.1 inf 1", + "1.2 2.2 nan nan", + "", + ] + ) + ) + + (tmp_path / "one_col.gr").write_text( + metadata + + "\n".join( + [ + r"#L r($\AA$)", + "1.0", + "1.1", + "1.2", + "", + ] + ) + ) + + (tmp_path / "five_col.gr").write_text( + metadata + + "\n".join( + [ + r"#L r($\AA$) G($\AA^{-2}$) dr($\AA$) dG($\AA^{-2}$) extra", + "1.0 2.0 0.1 0.2 9.9", + "1.1 2.1 0.3 0.4 9.8", + "1.2 2.2 0.5 0.6 9.7", + "", + ] + ) + ) + yield tmp_path + + +@pytest.fixture +def reset_characteristic_function_warnings(): + """Clear the record of already-issued non-physical input warnings. + + The characteristic functions warn only once per process for each + distinct non-physical input so that a refinement loop does not flood + the user with repeated messages. Tests that assert on the warning + must start and finish with a clean record. + """ + from diffpy.srfit.pdf.characteristicfunctions import _warned_non_physical + + _warned_non_physical.clear() + yield + _warned_non_physical.clear() diff --git a/tests/test_builder.py b/tests/test_builder.py index 449aa25c..d4b38fd7 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -96,7 +96,7 @@ def g2(v1): factory.registerArgument("v2", v2) factory.registerArgument("v3", v3) factory.registerArgument("v4", v4) - b = factory.registerFunction("g", g1, ["v1", "v2", "v3", "v4"]) + b = factory.register_function("g", g1, ["v1", "v2", "v3", "v4"]) # Now associate args with the wrapped function op = b.literal @@ -120,7 +120,7 @@ def g2(v1): assert round(abs(24 - eq1()), 7) == 0 # Now swap out the function - b = factory.registerFunction("g", g2, ["v1"]) + b = factory.register_function("g", g2, ["v1"]) op = b.literal assert op.operation == g2 assert v1 in op.args @@ -152,10 +152,10 @@ def testParseEquation(noObserversInGlobalBuilders): x = numpy.pi B = 4.0 C = 2.0 - eq.A.setValue(A) - eq.x.setValue(x) - eq.B.setValue(B) - eq.C.setValue(C) + eq.A.set_value(A) + eq.x.set_value(x) + eq.B.set_value(B) + eq.C.set_value(C) assert numpy.array_equal(eq(), f_equation(A, x, B, C)) # Make sure that the arguments of eq are listed in the order in which @@ -166,8 +166,8 @@ def testParseEquation(noObserversInGlobalBuilders): eq = factory.makeEquation("sqrt(e**(-0.5*(x/sigma)**2))") x = numpy.arange(0, 1, 0.05) sigma = 0.1 - eq.x.setValue(x) - eq.sigma.setValue(sigma) + eq.x.set_value(x) + eq.sigma.set_value(sigma) assert numpy.allclose(eq(), gaussian_test(x, sigma)) assert eq.args == [eq.x, eq.sigma] @@ -180,7 +180,7 @@ def testParseEquation(noObserversInGlobalBuilders): assert eq.args == [eq.sigma] # Equation with user-defined functions - factory.registerFunction("myfunc", eq, ["sigma"]) + factory.register_function("myfunc", eq, ["sigma"]) eq2 = factory.makeEquation("c*myfunc(sigma)") assert numpy.allclose(eq2(c=2, sigma=sigma), 2 * gaussian_test(x, sigma)) assert "sigma" in eq2.argdict @@ -213,7 +213,7 @@ def testBuildEquation(noObserversInGlobalBuilders): x = numpy.arange(0, numpy.pi, 0.1) beq = A * sin(a * x) - eq = beq.getEquation() + eq = beq.get_equation() assert "a" in eq.argdict assert "A" in eq.argdict @@ -234,7 +234,7 @@ def _f(a, b): b = builder.ArgumentBuilder(name="b", value=1) beq = sin(f(a, b)) - eq = beq.getEquation() + eq = beq.get_equation() assert eq() == numpy.sin(_f(2, 1)) # complex function @@ -244,23 +244,23 @@ def _f(a, b): x = builder.ArgumentBuilder(name="x", value=_x, const=True) sigma = builder.ArgumentBuilder(name="sigma", value=0.1) beq = sqrt(e ** (-0.5 * (x / sigma) ** 2)) - eq = beq.getEquation() + eq = beq.get_equation() assert numpy.allclose(eq(), numpy.sqrt(numpy.exp(-0.5 * (_x / 0.1) ** 2))) # Equation with Equation A = builder.ArgumentBuilder(name="A", value=2) B = builder.ArgumentBuilder(name="B", value=4) beq = A + B - eq = beq.getEquation() + eq = beq.get_equation() E = builder.wrapOperator("eq", eq) - eq2 = (2 * E).getEquation() + eq2 = (2 * E).get_equation() # Make sure these evaluate to the same thing assert eq.args == [A.literal, B.literal] assert 2 * eq() == eq2() # Pass new arguments to the equation C = builder.ArgumentBuilder(name="C", value=5) D = builder.ArgumentBuilder(name="D", value=6) - eq3 = (E(C, D) + 1).getEquation() + eq3 = (E(C, D) + 1).get_equation() assert 12 == eq3() # Pass old and new arguments to the equation # If things work right, A has been given the value of C in the last diff --git a/tests/test_characteristicfunctions.py b/tests/test_characteristicfunctions.py index cb292c95..98c8c4ab 100644 --- a/tests/test_characteristicfunctions.py +++ b/tests/test_characteristicfunctions.py @@ -12,14 +12,18 @@ # See LICENSE_DANSE.txt for license information. # ############################################################################## -"""Tests for sas package.""" +"""Tests for the nanoparticle characteristic functions and the SAS +calculator wrapper.""" # FIXME: remove this line when `docformatter` fixes the blank line bug +import re import unittest +import warnings import numpy +import numpy.testing as npt import pytest try: @@ -56,7 +60,7 @@ def testSphere(sas_available): fr1 = ff(r) # Calculate sphere cf analytically - fr2 = cf.sphericalCF(r, 2 * radius) + fr2 = cf.spherical_particle(r, 2 * radius) diff = fr1 - fr2 res = numpy.dot(diff, diff) res /= numpy.dot(fr2, fr2) @@ -86,7 +90,7 @@ def testSpheroid(sas_available): fr1 = ff(r) # Calculate cf analytically - fr2 = cf.spheroidalCF(r, erad, prad) + fr2 = cf.spheroidal_particle(r, erad, prad) diff = fr1 - fr2 res = numpy.dot(diff, diff) res /= numpy.dot(fr2, fr2) @@ -116,7 +120,7 @@ def testShell(sas_available): fr1 = ff(r) # Calculate sphere cf analytically - fr2 = cf.shellCF(r, radius, thickness) + fr2 = cf.shell_particle(r, radius, thickness) diff = fr1 - fr2 res = numpy.dot(diff, diff) res /= numpy.dot(fr2, fr2) @@ -133,7 +137,7 @@ def testCylinder(sas_available): + "latest sasview API" ) load_standard_models() - """Make sure cylinder works over different r-ranges.""" + # Make sure cylinder works over different r-ranges. radius = 100 length = 30 @@ -188,5 +192,678 @@ def testCylinder(sas_available): # End of class TestSASCF +# ---------------------------------------------------------------------------- +# The characteristic functions below compute the attenuation of the PDF due +# to a finite nanoparticle shape. Each has a known value of 1 at r = 0 (the +# particle always overlaps itself at zero distance) and drops to 0 once r +# reaches the largest distance spanned by the shape. + + +@pytest.mark.parametrize( + "input_r, input_particle_diameter, expected_characteristic_function", + [ + # C1: r ranges over values inside and outside the particle. + # Expected: the polynomial form 1 - 1.5x + 0.5x^3 (x = r / + # particle_diameter) applies inside the particle, and the function + # is zero at and beyond the particle diameter. + ( + numpy.array([0.0, 5.0, 10.0, 15.0]), + 10.0, + [1.0, 0.3125, 0.0, 0.0], + ), + ], +) +def test_spherical_particle( + input_r, input_particle_diameter, expected_characteristic_function +): + actual_characteristic_function = cf.spherical_particle( + input_r, input_particle_diameter + ) + npt.assert_allclose( + actual_characteristic_function, expected_characteristic_function + ) + + +@pytest.mark.parametrize( + "input_r, input_equatorial_radius, input_polar_radius, " + "expected_characteristic_function", + [ + # C1: equatorial_radius equals polar_radius, i.e. a sphere. + # Expected: matches spherical_particle for the equivalent diameter. + ( + numpy.linspace(0, 25, 6), + 10.0, + 10.0, + cf.spherical_particle(numpy.linspace(0, 25, 6), 20.0), + ), + # C2: polar_radius > equatorial_radius, i.e. a prolate spheroid. + # Expected: values from the Lei et al. characteristic function. + ( + numpy.array([0.0, 10.0, 20.0, 30.0, 40.0, 50.0]), + 10.0, + 20.0, + [ + 1.0, + 0.40106264900760513, + 0.054200238412168256, + 0.003650768214115807, + 0.0, + 0.0, + ], + ), + # C3: polar_radius < equatorial_radius, i.e. an oblate spheroid. + # Expected: values from the Lei et al. characteristic function. + ( + numpy.array([0.0, 5.0, 10.0, 15.0, 20.0, 25.0]), + 20.0, + 10.0, + [ + 1.0, + 0.744181556741916, + 0.5061470768546105, + 0.30368052370886184, + 0.15456586067544859, + 0.0675583474738014, + ], + ), + ], +) +def test_spheroidal_particle( + input_r, + input_equatorial_radius, + input_polar_radius, + expected_characteristic_function, +): + actual_characteristic_function = cf.spheroidal_particle( + input_r, input_equatorial_radius, input_polar_radius + ) + npt.assert_allclose( + actual_characteristic_function, expected_characteristic_function + ) + + +@pytest.mark.parametrize( + "input_r, input_particle_diameter, input_particle_diameter_sigma, " + "expected_characteristic_function", + [ + # C1: the lognormal distribution has no width. + # Expected: matches spherical_particle for the same diameter. + ( + numpy.array([0.0, 5.0, 10.0]), + 10.0, + 0.0, + cf.spherical_particle(numpy.array([0.0, 5.0, 10.0]), 10.0), + ), + # C2: a genuine lognormal size distribution is applied. + # Expected: values from the closed-form lognormal-averaged + # spherical characteristic function. + ( + numpy.array([1.0, 5.0, 10.0, 15.0]), + 10.0, + 2.0, + [ + 0.8617610662266728, + 0.362144891493816, + 0.039068792680198694, + 0.0009056141323687261, + ], + ), + ], +) +def test_lognormal_spherical_particle( + input_r, + input_particle_diameter, + input_particle_diameter_sigma, + expected_characteristic_function, +): + actual_characteristic_function = cf.lognormal_spherical_particle( + input_r, input_particle_diameter, input_particle_diameter_sigma + ) + npt.assert_allclose( + actual_characteristic_function, expected_characteristic_function + ) + + +@pytest.mark.parametrize( + "input_r, input_sheet_thickness, expected_characteristic_function", + [ + # C1: r is an array with values inside and outside the sheet + # thickness. + # Expected: 1 - 0.5*r/thickness inside the sheet, and + # 0.5*thickness/r beyond it. + ( + numpy.array([0.0, 2.0, 4.0, 8.0]), + 4.0, + [1.0, 0.75, 0.5, 0.25], + ), + # C2: r is a scalar inside the sheet thickness. + # Expected: 1 - 0.5*r/thickness. + (2.0, 4.0, 0.75), + ], +) +def test_sheet_particle( + input_r, input_sheet_thickness, expected_characteristic_function +): + actual_characteristic_function = cf.sheet_particle( + input_r, input_sheet_thickness + ) + npt.assert_allclose( + actual_characteristic_function, expected_characteristic_function + ) + + +@pytest.mark.parametrize( + "input_r, input_radius, input_thickness, " + "expected_characteristic_function", + [ + # C1: r ranges from the shell center out past the outer radius. + # Expected: values from the Lei et al. shell characteristic + # function; the function is 1 at r = 0 and 0 once r exceeds the + # outer radius (radius + thickness). + ( + numpy.array([0.0, 5.0, 12.5, 15.0, 20.0, 30.0]), + 10.0, + 5.0, + [ + 1.0, + 0.4934210526315789, + 0.19736842105263158, + 0.16447368421052633, + 0.12335526315789473, + 0.0, + ], + ), + ], +) +def test_shell_particle( + input_r, input_radius, input_thickness, expected_characteristic_function +): + actual_characteristic_function = cf.shell_particle( + input_r, input_radius, input_thickness + ) + npt.assert_allclose( + actual_characteristic_function, expected_characteristic_function + ) + + +# ---------------------------------------------------------------------------- +# A refinement can step a shape parameter into a region that has no physical +# meaning, such as a negative diameter or thickness. The characteristic +# functions must not raise there, because that would abort a refinement that +# would otherwise recover on its own, so they return zero for every r instead. +# A zero return is flat in the shape parameter and so stalls the optimizer, +# which the user cannot see from the fit output alone; each function therefore +# also warns. The warning is issued only once per process for each distinct +# problem so that a refinement loop does not bury the user in repeated +# messages. +# +# Every such warning states which requirement was violated and then closes +# with the same explanation of the consequence and of how to avoid it. Only +# the requirement varies between cases. + + +@pytest.mark.parametrize( + "function_name, input_args, expected_requirement", + [ + # C1: the sphere diameter is zero. + # Expected: zero everywhere, warning that the diameter must be + # positive. + ( + "spherical_particle", + (numpy.array([0.0, 5.0]), 0.0), + "'particle_diameter' must be positive", + ), + # C2: the sphere diameter is negative. + # Expected: zero everywhere, warning that the diameter must be + # positive. + ( + "spherical_particle", + (numpy.array([1.0, 5.0, 10.0]), -10.0), + "'particle_diameter' must be positive", + ), + # C3: the spheroid equatorial radius is zero, which the axis ratio + # would otherwise be divided by. + # Expected: zero everywhere, warning that the equatorial radius must + # be positive, and no ZeroDivisionError. + ( + "spheroidal_particle", + (numpy.array([1.0, 5.0]), 0.0, 5.0), + "'equatorial_radius' must be positive", + ), + # C4: the spheroid polar radius is negative. + # Expected: zero everywhere, warning that the polar radius must be + # positive. + ( + "spheroidal_particle", + (numpy.array([1.0, 5.0]), 10.0, -5.0), + "'polar_radius' must be positive", + ), + # C5: the mean particle diameter of the lognormal distribution is + # zero. + # Expected: zero everywhere, warning that the diameter must be + # positive. + ( + "lognormal_spherical_particle", + (numpy.array([1.0, 2.0]), 0.0, 1.0), + "'particle_diameter' must be positive", + ), + # C6: the width of the lognormal distribution is negative. A negative + # width is meaningless rather than a limiting case, so it must not be + # quietly treated as a distribution of zero width. + # Expected: zero everywhere, warning that the width must not be + # negative. + ( + "lognormal_spherical_particle", + (numpy.array([1.0, 5.0, 10.0]), 10.0, -2.0), + "'particle_diameter_sigma' must not be negative", + ), + # C7: the mean particle diameter is too small for the requested + # distribution width, which puts the underlying lognormal mean below + # zero and invalidates the closed form. + # Expected: zero everywhere, warning that the diameter is too small + # for the width. + ( + "lognormal_spherical_particle", + (numpy.array([1.0, 5.0]), 2.0, 4.0), + "'particle_diameter' is too small for 'particle_diameter_sigma'", + ), + # C8: the sheet thickness is zero. + # Expected: zero everywhere, warning that the thickness must be + # positive. + ( + "sheet_particle", + (numpy.array([1.0, 2.0]), 0.0), + "'sheet_thickness' must be positive", + ), + # C9: the sheet thickness is negative. + # Expected: zero everywhere, warning that the thickness must be + # positive. + ( + "sheet_particle", + (numpy.array([1.0, 2.0]), -1.0), + "'sheet_thickness' must be positive", + ), + # C10: the shell thickness is zero, which would otherwise make the + # normalizing denominator vanish and return an unattenuated one at + # every r. + # Expected: zero everywhere, warning that the thickness must be + # positive. + ( + "shell_particle", + (numpy.array([1.0, 5.0, 10.0]), 10.0, 0.0), + "'thickness' must be positive", + ), + # C11: the shell thickness is negative, which would otherwise return + # a smoothly varying branch with values below negative one that an + # optimizer can mistake for a real solution. + # Expected: zero everywhere, warning that the thickness must be + # positive. + ( + "shell_particle", + (numpy.array([1.0, 5.0, 10.0]), 10.0, -5.0), + "'thickness' must be positive", + ), + # C12: the shell inner radius is negative. + # Expected: zero everywhere, warning that the radius must not be + # negative. + ( + "shell_particle", + (numpy.array([1.0, 5.0, 10.0]), -20.0, 5.0), + "'radius' must not be negative", + ), + ], +) +def test_non_physical_input_returns_zero_and_warns( + function_name, + input_args, + expected_requirement, + reset_characteristic_function_warnings, +): + function = getattr(cf, function_name) + input_r = input_args[0] + expected_remedy = ( + "The characteristic function was set to zero for every r, which " + "flattens the fit residual so a refinement cannot recover on its own. " + "Please use a physically meaningful starting value, or keep the " + "parameter in range with FitRecipe.add_soft_bounds." + ) + expected_msg = ( + f"In '{function_name}', {expected_requirement}. {expected_remedy}" + ) + with pytest.warns(RuntimeWarning, match=re.escape(expected_msg)): + actual_characteristic_function = function(*input_args) + npt.assert_allclose( + actual_characteristic_function, numpy.zeros_like(input_r) + ) + + +@pytest.mark.parametrize( + "function_name, input_args, expected_characteristic_function", + [ + # C1: a lognormal distribution of zero width is the sphere limit + # rather than a mistake. + # Expected: matches spherical_particle for the same diameter, with no + # warning. + ( + "lognormal_spherical_particle", + (numpy.array([1.0, 5.0, 10.0]), 10.0, 0.0), + cf.spherical_particle(numpy.array([1.0, 5.0, 10.0]), 10.0), + ), + # C2: a shell of zero inner radius is a solid sphere rather than a + # mistake. + # Expected: matches spherical_particle for twice the thickness, with + # no warning. + ( + "shell_particle", + (numpy.array([1.0, 5.0, 10.0]), 0.0, 5.0), + cf.spherical_particle(numpy.array([1.0, 5.0, 10.0]), 10.0), + ), + ], +) +def test_limiting_input_does_not_warn( + function_name, + input_args, + expected_characteristic_function, + reset_characteristic_function_warnings, +): + function = getattr(cf, function_name) + with warnings.catch_warnings(): + warnings.simplefilter("error") + actual_characteristic_function = function(*input_args) + npt.assert_allclose( + actual_characteristic_function, expected_characteristic_function + ) + + +def test_non_physical_input_warns_only_once( + reset_characteristic_function_warnings, +): + # A refinement evaluates the characteristic function on every iteration, + # so a function that warns on each call would flood the user. The + # suppression must not rely on the warnings registry, which callers can + # disable, so this asserts under the "always" filter. + with warnings.catch_warnings(record=True) as raised_warnings: + warnings.simplefilter("always") + for _ in range(5): + cf.spherical_particle(numpy.array([1.0, 2.0]), -10.0) + actual_warning_count = len(raised_warnings) + expected_warning_count = 1 + assert actual_warning_count == expected_warning_count + + +# ---------------------------------------------------------------------------- +# sphericalCF, spheroidalCF, spheroidalCF2, lognormalSphericalCF, sheetCF, +# shellCF, and shellCF2 are deprecated in favor of the snake_case functions +# above, and forward to them directly (translating arguments where the old +# and new parameterizations differ). Consequently they also inherit the +# replacement's handling of non-physical input in full: values that the old, +# pre-3.3.0 implementation used to accept silently, or handled with its own +# quirks, now warn and return zero exactly like the replacement. Each old +# name must still work, forward the (translated) arguments, and emit a +# DeprecationWarning naming its replacement. + +# The warning must also tell the caller how to translate the old arguments, +# so each case pins the exact note appended to the standard message. A case +# whose parameters did not change appends nothing. + + +@pytest.mark.parametrize( + "old_name, new_name, expected_signature_note, old_args, new_args", + [ + # C1: sphericalCF forwards its arguments unchanged, only renamed. + # Expected: The note names the renamed parameter. + ( + "sphericalCF", + "spherical_particle", + "Additionally, the signature has changed. Please pass the " + "parameter 'psize' as 'particle_diameter'.", + (numpy.array([0.0, 5.0, 10.0]), 10.0), + (numpy.array([0.0, 5.0, 10.0]), 10.0), + ), + # C2: spheroidalCF forwards its arguments unchanged, only renamed. + # Expected: The note names both renamed parameters. + ( + "spheroidalCF", + "spheroidal_particle", + "Additionally, the signature has changed. Please pass the " + "parameters 'erad' and 'prad' as 'equatorial_radius' and " + "'polar_radius', respectively.", + (numpy.array([0.0, 5.0, 10.0]), 10.0, 15.0), + (numpy.array([0.0, 5.0, 10.0]), 10.0, 15.0), + ), + # C3: spheroidalCF2 used the raw (diameter, axis ratio) + # parameterization, which must be converted to (equatorial_radius, + # polar_radius) before forwarding to spheroidal_particle. + # Expected: The note gives the conversion, not a rename. + ( + "spheroidalCF2", + "spheroidal_particle", + "Additionally, the parameterization has changed. 'spheroidalCF2' " + "took the equatorial diameter 'psize' and the axis ratio " + "'axrat', while 'spheroidal_particle' takes radii. Please pass " + "equatorial_radius = psize / 2 and " + "polar_radius = axrat * psize / 2.", + (numpy.array([0.0, 5.0, 10.0]), 20.0, 1.5), + (numpy.array([0.0, 5.0, 10.0]), 10.0, 15.0), + ), + # C4: lognormalSphericalCF forwards its arguments unchanged, only + # renamed. + # Expected: The note names both renamed parameters. + ( + "lognormalSphericalCF", + "lognormal_spherical_particle", + "Additionally, the signature has changed. Please pass the " + "parameters 'psize' and 'psig' as 'particle_diameter' and " + "'particle_diameter_sigma', respectively.", + (numpy.array([1.0, 5.0, 10.0]), 10.0, 2.0), + (numpy.array([1.0, 5.0, 10.0]), 10.0, 2.0), + ), + # C5: sheetCF forwards its arguments unchanged, only renamed. + # Expected: The note names the renamed parameter. + ( + "sheetCF", + "sheet_particle", + "Additionally, the signature has changed. Please pass the " + "parameter 'sthick' as 'sheet_thickness'.", + (numpy.array([0.0, 2.0, 4.0]), 4.0), + (numpy.array([0.0, 2.0, 4.0]), 4.0), + ), + # C6: shellCF, whose parameters kept their names and meanings. + # Expected: The standard message with no note appended. + ( + "shellCF", + "shell_particle", + "", + (numpy.array([0.0, 5.0, 12.5]), 10.0, 5.0), + (numpy.array([0.0, 5.0, 12.5]), 10.0, 5.0), + ), + # C7: shellCF2 used the raw (central_radius, thickness) + # parameterization, which must be converted to (radius, thickness) + # before forwarding to shell_particle. + # Expected: The note gives the conversion, not a rename. + ( + "shellCF2", + "shell_particle", + "Additionally, the parameterization has changed. 'shellCF2' took " + "the central radius 'a' and the shell thickness 'delta', while " + "'shell_particle' takes the inner radius. Please pass " + "radius = a - delta / 2 and thickness = delta.", + (numpy.array([0.0, 5.0, 12.5]), 12.5, 5.0), + (numpy.array([0.0, 5.0, 12.5]), 10.0, 5.0), + ), + ], +) +def test_deprecated_functions_warn_and_forward( + old_name, + new_name, + expected_signature_note, + old_args, + new_args, +): + old_function = getattr(cf, old_name) + new_function = getattr(cf, new_name) + module_path = "diffpy.srfit.pdf.characteristicfunctions" + expected_characteristic_function = new_function(*new_args) + expected_msg = ( + f"'{module_path}.{old_name}' is deprecated and will be removed " + f"in version 4.0.0. Please use '{module_path}.{new_name}' " + "instead." + ) + if expected_signature_note: + expected_msg = f"{expected_msg} {expected_signature_note}" + # Capture every warning rather than using pytest.warns, which only + # checks that a matching warning occurred somewhere and would not + # notice a second, irrelevant DeprecationWarning from a call chained + # through another deprecated function. + with warnings.catch_warnings(record=True) as raised_warnings: + warnings.simplefilter("always") + actual_characteristic_function = old_function(*old_args) + deprecation_warnings = [ + raised + for raised in raised_warnings + if raised.category is DeprecationWarning + ] + actual_deprecation_count = len(deprecation_warnings) + expected_deprecation_count = 1 + assert actual_deprecation_count == expected_deprecation_count + actual_msg = str(deprecation_warnings[0].message) + assert actual_msg == expected_msg + npt.assert_allclose( + actual_characteristic_function, expected_characteristic_function + ) + + +# Because the deprecated functions forward to their replacements, they must +# also forward the replacement's non-physical-input handling: a +# RuntimeWarning alongside the DeprecationWarning, and a result of zero (or, +# for a translated case, zero reached via the translated argument). + + +@pytest.mark.parametrize( + "old_name, new_name, old_args, new_args, expected_requirement", + [ + # C1: sphericalCF forwards a non-positive diameter unchanged. + # Expected: the RuntimeWarning names the forwarded parameter. + ( + "sphericalCF", + "spherical_particle", + (numpy.array([1.0, 5.0]), -10.0), + (numpy.array([1.0, 5.0]), -10.0), + "'particle_diameter' must be positive", + ), + # C2: spheroidalCF2 translates a non-positive equatorial diameter + # into a non-positive equatorial radius. + # Expected: the RuntimeWarning names the translated parameter. + ( + "spheroidalCF2", + "spheroidal_particle", + (numpy.array([1.0, 5.0]), -20.0, 1.5), + (numpy.array([1.0, 5.0]), -10.0, -15.0), + "'equatorial_radius' must be positive", + ), + # C3: lognormalSphericalCF forwards a negative distribution width + # unchanged. This is the case where the old, pre-3.3.0 code and the + # new function most disagreed: the old code treated a negative width + # as the zero-width (sphere) limit, where the new function rejects + # it outright. + # Expected: the RuntimeWarning that the old code never raised. + ( + "lognormalSphericalCF", + "lognormal_spherical_particle", + (numpy.array([1.0, 5.0, 10.0]), 10.0, -2.0), + (numpy.array([1.0, 5.0, 10.0]), 10.0, -2.0), + "'particle_diameter_sigma' must not be negative", + ), + # C4: sheetCF forwards a non-positive thickness unchanged. The old + # code returned a bare scalar 0 regardless of the shape of r, where + # the new function returns an array shaped like r. + # Expected: an array of zeros shaped like r. + ( + "sheetCF", + "sheet_particle", + (numpy.array([1.0, 5.0]), 0.0), + (numpy.array([1.0, 5.0]), 0.0), + "'sheet_thickness' must be positive", + ), + # C5: shellCF forwards a zero thickness unchanged. The old code hit + # a vanishing normalization denominator and substituted one, where + # the new function rejects the input outright. + # Expected: zero everywhere rather than the old fallback of one. + ( + "shellCF", + "shell_particle", + (numpy.array([1.0, 5.0]), 10.0, 0.0), + (numpy.array([1.0, 5.0]), 10.0, 0.0), + "'thickness' must be positive", + ), + # C6: shellCF2 translates a central radius smaller than half the + # thickness into a negative inner radius. + # Expected: the RuntimeWarning names the translated parameter. + ( + "shellCF2", + "shell_particle", + (numpy.array([1.0, 5.0]), 1.0, 5.0), + (numpy.array([1.0, 5.0]), -1.5, 5.0), + "'radius' must not be negative", + ), + ], +) +def test_deprecated_functions_forward_non_physical_input( + old_name, + new_name, + old_args, + new_args, + expected_requirement, + reset_characteristic_function_warnings, +): + old_function = getattr(cf, old_name) + new_function = getattr(cf, new_name) + expected_characteristic_function = new_function(*new_args) + + # The oracle call above already consumed the once-per-process + # RuntimeWarning for this (function, requirement) pair; clear the + # record so the call under test below is free to raise it again. + cf._warned_non_physical.clear() + + with warnings.catch_warnings(record=True) as raised_warnings: + warnings.simplefilter("always") + actual_characteristic_function = old_function(*old_args) + + # Exactly two warnings are expected: the DeprecationWarning for the old + # name, and the RuntimeWarning forwarded from the new function. A stray + # third warning, such as a second DeprecationWarning from a call + # chained through another deprecated function, must fail this check. + actual_warning_count = len(raised_warnings) + expected_warning_count = 2 + assert actual_warning_count == expected_warning_count + + actual_categories = {raised.category for raised in raised_warnings} + expected_categories = {DeprecationWarning, RuntimeWarning} + assert actual_categories == expected_categories + + # The RuntimeWarning always names the new function, since that is where + # it is actually raised, regardless of which name the caller used. + expected_remedy = ( + "The characteristic function was set to zero for every r, which " + "flattens the fit residual so a refinement cannot recover on its " + "own. Please use a physically meaningful starting value, or keep " + "the parameter in range with FitRecipe.add_soft_bounds." + ) + expected_runtime_msg = ( + f"In '{new_name}', {expected_requirement}. {expected_remedy}" + ) + runtime_warning = next( + raised + for raised in raised_warnings + if raised.category is RuntimeWarning + ) + actual_runtime_msg = str(runtime_warning.message) + assert actual_runtime_msg == expected_runtime_msg + + npt.assert_allclose( + actual_characteristic_function, expected_characteristic_function + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_constraint.py b/tests/test_constraint.py index 6107f5cc..f82c4384 100644 --- a/tests/test_constraint.py +++ b/tests/test_constraint.py @@ -19,11 +19,49 @@ from diffpy.srfit.equation.builder import EquationFactory from diffpy.srfit.fitbase.constraint import Constraint from diffpy.srfit.fitbase.parameter import Parameter -from diffpy.srfit.fitbase.recipeorganizer import equationFromString +from diffpy.srfit.fitbase.recipeorganizer import get_equation_from_string class TestConstraint(unittest.TestCase): + def test_constrain_parameter(self): + """Test the Constraint class.""" + p1 = Parameter("p1", 1) + p2 = Parameter("p2", 2) + + factory = EquationFactory() + + factory.registerArgument("p1", p1) + factory.registerArgument("p2", p2) + + c = Constraint() + # Constrain p1 = 2*p2 + eq = get_equation_from_string("2*p2", factory) + c.add_constraint(p1, eq) + + self.assertTrue(p1.constrained) + self.assertFalse(p2.constrained) + + eq2 = get_equation_from_string("2*p2+1", factory) + c2 = Constraint() + self.assertRaises(ValueError, c2.constrain, p1, eq2) + p2.set_constant() + eq3 = get_equation_from_string("p1", factory) + self.assertRaises(ValueError, c2.constrain, p2, eq3) + + p2.set_value(2.5) + c.update() + self.assertEqual(5.0, p1.getValue()) + + p2.set_value(8.1) + self.assertEqual(5.0, p1.getValue()) + c.update() + self.assertEqual(16.2, p1.getValue()) + return + + +class TestConstraint_deprecated(unittest.TestCase): + def testConstraint(self): """Test the Constraint class.""" p1 = Parameter("p1", 1) @@ -36,24 +74,24 @@ def testConstraint(self): c = Constraint() # Constrain p1 = 2*p2 - eq = equationFromString("2*p2", factory) + eq = get_equation_from_string("2*p2", factory) c.constrain(p1, eq) self.assertTrue(p1.constrained) self.assertFalse(p2.constrained) - eq2 = equationFromString("2*p2+1", factory) + eq2 = get_equation_from_string("2*p2+1", factory) c2 = Constraint() self.assertRaises(ValueError, c2.constrain, p1, eq2) p2.setConst() - eq3 = equationFromString("p1", factory) + eq3 = get_equation_from_string("p1", factory) self.assertRaises(ValueError, c2.constrain, p2, eq3) - p2.setValue(2.5) + p2.set_value(2.5) c.update() self.assertEqual(5.0, p1.getValue()) - p2.setValue(8.1) + p2.set_value(8.1) self.assertEqual(5.0, p1.getValue()) c.update() self.assertEqual(16.2, p1.getValue()) diff --git a/tests/test_contribution.py b/tests/test_contribution.py index 62a5fcc7..56975905 100644 --- a/tests/test_contribution.py +++ b/tests/test_contribution.py @@ -51,7 +51,7 @@ def testset_profile(self): self.assertRaises(TypeError, fc1.setProfile, "invalid") # check if residual equation is set up when possible fc2 = FitContribution("test2") - fc2.setEquation("A * x") + fc2.set_equation("A * x") fc2.set_profile(profile) self.assertFalse(fc2._reseq is None) return @@ -68,15 +68,28 @@ def testAddProfileGenerator(self): self.assertTrue(fc._eq is not None) return + def test_add_profile_generator(self): + fc = self.fitcontribution + gen = self.gen + fc.add_profile_generator(gen, "gen") + + xobs = arange(0, 10, 0.5) + self.assertTrue(array_equal(xobs, gen(xobs))) + + self.assertTrue(gen.profile is None) + self.assertTrue(fc._eq is not None) + return + def testInteraction(self): - """Test the interaction between the profile and profile generator.""" + """Test the interaction between the profile and profile + generator.""" fc = self.fitcontribution profile = self.profile gen = self.gen # Add the calculator and profile fc.set_profile(profile) - fc.addProfileGenerator(gen, "I") + fc.add_profile_generator(gen, "I") # Check attributes are created self.assertTrue(fc.profile is profile) @@ -90,7 +103,7 @@ def testInteraction(self): # create some data xobs = arange(0, 10, 0.5) yobs = xobs - profile.setObservedProfile(xobs, yobs) + profile.set_observed_profile(xobs, yobs) # Make sure this is where it's supposed to be self.assertTrue(gen.profile.xobs is xobs) @@ -112,16 +125,16 @@ def testReplacements(self): xobs = arange(0, 10, 0.5) yobs = xobs profile = self.profile - profile.setObservedProfile(xobs, yobs) + profile.set_observed_profile(xobs, yobs) xobs2 = arange(0, 10, 0.8) yobs2 = 0.5 * xobs2 profile2 = Profile() - profile2.setObservedProfile(xobs2, yobs2) + profile2.set_observed_profile(xobs2, yobs2) gen = self.gen # Validate equations fc.set_profile(profile) - fc.addProfileGenerator(gen, "I") + fc.add_profile_generator(gen, "I") self.assertTrue(array_equal(gen.value, xobs)) self.assertTrue(array_equal(fc._eq(), xobs)) self.assertAlmostEqual(0, sum(fc._reseq())) @@ -144,40 +157,53 @@ def testReplacements(self): self.assertEqual(len(xobs2), len(fc.residual())) return + def test_get_residual_equation(self): + """Check getting the current formula for residual equation.""" + fc = self.fitcontribution + self.assertEqual("", fc.get_residual_equation()) + fc.set_profile(self.profile) + fc.set_equation("A * x + B") + self.assertEqual("((eq - y) / dy)", fc.get_residual_equation()) + fc.set_residual_equation("2 * (eq - y)") + self.assertEqual("(2 * (eq - y))", fc.get_residual_equation()) + return + def test_getResidualEquation(self): """Check getting the current formula for residual equation.""" fc = self.fitcontribution - self.assertEqual("", fc.getResidualEquation()) + self.assertEqual("", fc.get_residual_equation()) fc.set_profile(self.profile) - fc.setEquation("A * x + B") + fc.set_equation("A * x + B") self.assertEqual("((eq - y) / dy)", fc.getResidualEquation()) fc.setResidualEquation("2 * (eq - y)") - self.assertEqual("(2 * (eq - y))", fc.getResidualEquation()) + self.assertEqual("(2 * (eq - y))", fc.get_residual_equation()) return def test_releaseOldEquations(self): - """Ensure EquationFactory does not hold to obsolete Equations.""" + """Ensure EquationFactory does not hold to obsolete + Equations.""" fc = self.fitcontribution self.assertEqual(0, len(fc._eqfactory.equations)) for i in range(5): - fc.setEquation("A * x + B") + fc.set_equation("A * x + B") self.assertEqual(1, len(fc._eqfactory.equations)) fc.set_profile(self.profile) for i in range(5): - fc.setResidualEquation("chiv") + fc.set_residual_equation("chiv") self.assertEqual(2, len(fc._eqfactory.equations)) return - def test_registerFunction(self): - """Ensure registered function works after second setEquation call.""" + def test_register_function(self): + """Ensure registered function works after second set_equation + call.""" fc = self.fitcontribution - fc.registerFunction(_fsquare, name="fsquare") - fc.setEquation("fsquare") - fc.x.setValue(5) + fc.register_function(_fsquare, name="fsquare") + fc.set_equation("fsquare") + fc.x.set_value(5) self.assertEqual(25, fc.evaluate()) fc.x << 6 self.assertEqual(36, fc.evaluate()) - fc.setEquation("fsquare + 5") + fc.set_equation("fsquare + 5") self.assertEqual(41, fc.evaluate()) fc.x << -1 self.assertEqual(6, fc.evaluate()) @@ -197,7 +223,7 @@ def testResidual(noObserversInGlobalBuilders): # Add the calculator and profile fc.set_profile(profile) assert fc.profile is profile - fc.addProfileGenerator(gen, "I") + fc.add_profile_generator(gen, "I") assert fc._eq._value is None assert fc._reseq._value is None assert 1 == len(fc._generators) @@ -206,7 +232,7 @@ def testResidual(noObserversInGlobalBuilders): # Let's create some data) xobs = arange(0, 10, 0.5) yobs = xobs - profile.setObservedProfile(xobs, yobs) + profile.set_observed_profile(xobs, yobs) # Check our fitting equation. assert np.array_equal(fc._eq(), gen(xobs)) @@ -216,7 +242,7 @@ def testResidual(noObserversInGlobalBuilders): assert dot(chiv, chiv) == pytest.approx(0) # Now change the equation - fc.setEquation("2*I") + fc.set_equation("2*I") assert fc._eq._value is None assert fc._reseq._value is None chiv = fc.residual() @@ -225,20 +251,20 @@ def testResidual(noObserversInGlobalBuilders): # Try to add a parameter c = Parameter("c", 2) fc._add_parameter(c) - fc.setEquation("c*I") + fc.set_equation("c*I") assert fc._eq._value is None assert fc._reseq._value is None chiv = fc.residual() assert dot(chiv, chiv) == pytest.approx(dot(yobs, yobs)) # Try something more complex - c.setValue(3) - fc.setEquation("c**2*sin(I)") + c.set_value(3) + fc.set_equation("c**2*sin(I)") assert fc._eq._value is None assert fc._reseq._value is None xobs = arange(0, 10, 0.5) yobs = 9 * sin(xobs) - profile.setObservedProfile(xobs, yobs) + profile.set_observed_profile(xobs, yobs) assert fc._eq._value is None assert fc._reseq._value is None @@ -246,27 +272,27 @@ def testResidual(noObserversInGlobalBuilders): assert dot(chiv, chiv) == pytest.approx(0) # Choose a new residual. - fc.setEquation("2*I") - fc.setResidualEquation("resv") + fc.set_equation("2*I") + fc.set_residual_equation("resv") chiv = fc.residual() assert dot(chiv, chiv) == pytest.approx( sum((2 * xobs - yobs) ** 2) / sum(yobs**2) ) # Make a custom residual. - fc.setResidualEquation("abs(eq-y)**0.5") + fc.set_residual_equation("abs(eq-y)**0.5") chiv = fc.residual() assert dot(chiv, chiv) == pytest.approx(sum(abs(2 * xobs - yobs))) # Test configuration checks fc1 = FitContribution("test1") with pytest.raises(SrFitError): - fc1.setResidualEquation("chiv") + fc1.set_residual_equation("chiv") fc1.set_profile(profile) with pytest.raises(SrFitError): - fc1.setResidualEquation("chiv") - fc1.setEquation("A * x") - fc1.setResidualEquation("chiv") + fc1.set_residual_equation("chiv") + fc1.set_equation("A * x") + fc1.set_residual_equation("chiv") assert noObserversInGlobalBuilders return @@ -275,7 +301,21 @@ def test_setEquation(noObserversInGlobalBuilders): """Check replacement of removed parameters.""" fc = FitContribution("test") fc.setEquation("x + 5") - fc.x.setValue(2) + fc.x.set_value(2) + assert 7 == fc.evaluate() + fc.removeParameter(fc.x) + x = arange(0, 10, 0.5) + fc.newParameter("x", x) + assert np.array_equal(5 + x, fc.evaluate()) + assert noObserversInGlobalBuilders + return + + +def test_set_equation(noObserversInGlobalBuilders): + """Check replacement of removed parameters.""" + fc = FitContribution("test") + fc.set_equation("x + 5") + fc.x.set_value(2) assert 7 == fc.evaluate() fc.removeParameter(fc.x) x = arange(0, 10, 0.5) @@ -288,12 +328,22 @@ def test_setEquation(noObserversInGlobalBuilders): def test_getEquation(noObserversInGlobalBuilders): """Check getting the current profile simulation formula.""" fc = FitContribution("test") - assert "" == fc.getEquation() - fc.setEquation("A * sin(x + 5)") + assert "" == fc.get_equation() + fc.set_equation("A * sin(x + 5)") assert "(A * sin((x + 5)))" == fc.getEquation() assert noObserversInGlobalBuilders return +def test_get_equation(noObserversInGlobalBuilders): + """Check getting the current profile simulation formula.""" + fc = FitContribution("test") + assert "" == fc.get_equation() + fc.set_equation("A * sin(x + 5)") + assert "(A * sin((x + 5)))" == fc.get_equation() + assert noObserversInGlobalBuilders + return + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_diffpyparset.py b/tests/test_diffpyparset.py index f4ea39aa..a6e519b3 100644 --- a/tests/test_diffpyparset.py +++ b/tests/test_diffpyparset.py @@ -87,12 +87,12 @@ def _testLattice(): _testLattice() # Now change values from the srfit DiffpyStructureParSet - s.Cu0.x.setValue(0.456) - s.Cu0.U22.setValue(0.441) - s.Cu0.B13.setValue(0.550) + s.Cu0.x.set_value(0.456) + s.Cu0.U22.set_value(0.441) + s.Cu0.B13.set_value(0.550) d = dsstru.lattice.dist(a1.xyz, a2.xyz) - s.lattice.b.setValue(4.6) - s.lattice.alpha.setValue(91.3) + s.lattice.b.set_value(4.6) + s.lattice.alpha.set_value(91.3) _testAtoms() _testLattice() # Make sure the distance changed diff --git a/tests/test_equation.py b/tests/test_equation.py index 13bbc3f8..bd89881e 100644 --- a/tests/test_equation.py +++ b/tests/test_equation.py @@ -45,11 +45,11 @@ def testSimpleFunction(make_args, noObserversInGlobalBuilders): # Set the values of the variables. # The equation should evaluate to 2.5*(1+3)*(4-2) = 20 - v1.setValue(1) - v2.setValue(2) - v3.setValue(3) - v4.setValue(4) - c.setValue(2.5) + v1.set_value(1) + v2.set_value(2) + v3.set_value(3) + v4.set_value(4) + c.set_value(2.5) # Make an equation and test eq = Equation("eq", mult2) @@ -133,11 +133,11 @@ def testEmbeddedEquation(make_args, noObserversInGlobalBuilders): # Set the values of the variables. # The equation should evaluate to 2.5*(1+3)*(4-2) = 20 - v1.setValue(1) - v2.setValue(2) - v3.setValue(3) - v4.setValue(4) - c.setValue(2.5) + v1.set_value(1) + v2.set_value(2) + v3.set_value(3) + v4.set_value(4) + c.set_value(2.5) # Make an equation and test root = Equation("root", mult2) diff --git a/tests/test_fitrecipe.py b/tests/test_fitrecipe.py index 6902f0f0..3b51b857 100644 --- a/tests/test_fitrecipe.py +++ b/tests/test_fitrecipe.py @@ -18,10 +18,12 @@ import matplotlib import matplotlib.pyplot as plt +import numpy as np import pytest -from numpy import array_equal, dot, linspace, pi, sin +from numpy import array_equal, dot, linspace, ones_like, pi, sin from scipy.optimize import leastsq +from diffpy.srfit.fitbase import FitResults from diffpy.srfit.fitbase.fitcontribution import FitContribution from diffpy.srfit.fitbase.fitrecipe import FitRecipe from diffpy.srfit.fitbase.parameter import Parameter @@ -41,58 +43,58 @@ def setUp(self): self.profile = Profile() x = linspace(0, pi, 10) y = sin(x) - self.profile.setObservedProfile(x, y) + self.profile.set_observed_profile(x, y) # Set up the FitContribution self.fitcontribution = FitContribution("cont") self.fitcontribution.set_profile(self.profile) - self.fitcontribution.setEquation("A*sin(k*x + c)") - self.fitcontribution.A.setValue(1) - self.fitcontribution.k.setValue(1) - self.fitcontribution.c.setValue(0) + self.fitcontribution.set_equation("A*sin(k*x + c)") + self.fitcontribution.A.set_value(1) + self.fitcontribution.k.set_value(1) + self.fitcontribution.c.set_value(0) - self.recipe.addContribution(self.fitcontribution) + self.recipe.add_contribution(self.fitcontribution) return - def testFixFree(self): + def test_fix_free(self): recipe = self.recipe con = self.fitcontribution - recipe.addVar(con.A, 2, tag="tagA") - recipe.addVar(con.k, 1, tag="tagk") - recipe.addVar(con.c, 0) - recipe.newVar("B", 0) + recipe.add_variable(con.A, 2, tag="tagA") + recipe.add_variable(con.k, 1, tag="tagk") + recipe.add_variable(con.c, 0) + recipe.create_new_variable("B", 0) - self.assertTrue(recipe.isFree(recipe.A)) + self.assertTrue(recipe.is_free(recipe.A)) recipe.fix("tagA") - self.assertFalse(recipe.isFree(recipe.A)) + self.assertFalse(recipe.is_free(recipe.A)) recipe.free("tagA") - self.assertTrue(recipe.isFree(recipe.A)) + self.assertTrue(recipe.is_free(recipe.A)) recipe.fix("A") - self.assertFalse(recipe.isFree(recipe.A)) + self.assertFalse(recipe.is_free(recipe.A)) recipe.free("A") - self.assertTrue(recipe.isFree(recipe.A)) + self.assertTrue(recipe.is_free(recipe.A)) recipe.fix(recipe.A) - self.assertFalse(recipe.isFree(recipe.A)) + self.assertFalse(recipe.is_free(recipe.A)) recipe.free(recipe.A) - self.assertTrue(recipe.isFree(recipe.A)) + self.assertTrue(recipe.is_free(recipe.A)) recipe.fix(recipe.A) - self.assertFalse(recipe.isFree(recipe.A)) + self.assertFalse(recipe.is_free(recipe.A)) recipe.free("all") - self.assertTrue(recipe.isFree(recipe.A)) - self.assertTrue(recipe.isFree(recipe.k)) - self.assertTrue(recipe.isFree(recipe.c)) - self.assertTrue(recipe.isFree(recipe.B)) + self.assertTrue(recipe.is_free(recipe.A)) + self.assertTrue(recipe.is_free(recipe.k)) + self.assertTrue(recipe.is_free(recipe.c)) + self.assertTrue(recipe.is_free(recipe.B)) recipe.fix(recipe.A, "tagk", c=3) - self.assertFalse(recipe.isFree(recipe.A)) - self.assertFalse(recipe.isFree(recipe.k)) - self.assertFalse(recipe.isFree(recipe.c)) - self.assertTrue(recipe.isFree(recipe.B)) + self.assertFalse(recipe.is_free(recipe.A)) + self.assertFalse(recipe.is_free(recipe.k)) + self.assertFalse(recipe.is_free(recipe.c)) + self.assertTrue(recipe.is_free(recipe.B)) self.assertEqual(3, recipe.c.value) recipe.fix("all") - self.assertFalse(recipe.isFree(recipe.A)) - self.assertFalse(recipe.isFree(recipe.k)) - self.assertFalse(recipe.isFree(recipe.c)) + self.assertFalse(recipe.is_free(recipe.A)) + self.assertFalse(recipe.is_free(recipe.k)) + self.assertFalse(recipe.is_free(recipe.c)) self.assertFalse(recipe.isFree(recipe.B)) self.assertRaises(ValueError, recipe.free, "junk") @@ -100,39 +102,87 @@ def testFixFree(self): self.assertRaises(ValueError, recipe.fix, "junk") return + def test_variables(self): + """Test to see if variables are added and removed properly.""" + recipe = self.recipe + con = self.fitcontribution + + recipe.add_variable(con.A, 2) + recipe.add_variable(con.k, 1) + recipe.add_variable(con.c, 0) + recipe.create_new_variable("B", 0) + + names = recipe.get_names() + self.assertEqual(names, ["A", "k", "c", "B"]) + values = recipe.get_values() + self.assertTrue((values == [2, 1, 0, 0]).all()) + + # Constrain a parameter to the B-variable to give it a value + p = Parameter("Bpar", -1) + recipe.add_constraint(recipe.B, p) + values = recipe.get_values() + self.assertTrue((values == [2, 1, 0]).all()) + recipe.delete_variable(recipe.B) + + recipe.fix(recipe.k) + + names = recipe.get_names() + self.assertEqual(names, ["A", "c"]) + values = recipe.get_values() + self.assertTrue((values == [2, 0]).all()) + + recipe.fix("all") + names = recipe.get_names() + self.assertEqual(names, []) + values = recipe.get_values() + self.assertTrue((values == []).all()) + + recipe.free("all") + names = recipe.get_names() + self.assertEqual(3, len(names)) + self.assertTrue("A" in names) + self.assertTrue("k" in names) + self.assertTrue("c" in names) + values = recipe.get_values() + self.assertEqual(3, len(values)) + self.assertTrue(0 in values) + self.assertTrue(1 in values) + self.assertTrue(2 in values) + return + def testVars(self): """Test to see if variables are added and removed properly.""" recipe = self.recipe con = self.fitcontribution - recipe.addVar(con.A, 2) - recipe.addVar(con.k, 1) + recipe.add_variable(con.A, 2) + recipe.add_variable(con.k, 1) recipe.addVar(con.c, 0) recipe.newVar("B", 0) - names = recipe.getNames() + names = recipe.get_names() self.assertEqual(names, ["A", "k", "c", "B"]) - values = recipe.getValues() + values = recipe.get_values() self.assertTrue((values == [2, 1, 0, 0]).all()) # Constrain a parameter to the B-variable to give it a value p = Parameter("Bpar", -1) recipe.constrain(recipe.B, p) - values = recipe.getValues() + values = recipe.get_values() self.assertTrue((values == [2, 1, 0]).all()) recipe.delVar(recipe.B) recipe.fix(recipe.k) - names = recipe.getNames() + names = recipe.get_names() self.assertEqual(names, ["A", "c"]) - values = recipe.getValues() + values = recipe.get_values() self.assertTrue((values == [2, 0]).all()) recipe.fix("all") - names = recipe.getNames() + names = recipe.get_names() self.assertEqual(names, []) - values = recipe.getValues() + values = recipe.get_values() self.assertTrue((values == []).all()) recipe.free("all") @@ -157,18 +207,18 @@ def testResidual(self): # Change the c value to 1 so that the equation evaluates as sin(x+1) x = self.profile.x y = sin(x + 1) - self.recipe.cont.c.setValue(1) + self.recipe.cont.c.set_value(1) res = self.recipe.residual() self.assertTrue(array_equal(y - self.profile.y, res)) # Try some constraints # Make c = 2*A, A = Avar - var = self.recipe.newVar("Avar") - self.recipe.constrain( + var = self.recipe.create_new_variable("Avar") + self.recipe.add_constraint( self.fitcontribution.c, "2*A", {"A": self.fitcontribution.A} ) self.assertEqual(2, self.fitcontribution.c.value) - self.recipe.constrain(self.fitcontribution.A, var) + self.recipe.add_constraint(self.fitcontribution.A, var) self.assertEqual(1, var.getValue()) self.assertEqual(self.recipe.cont.A.getValue(), var.getValue()) # c is constrained to a constrained parameter. @@ -181,7 +231,7 @@ def testResidual(self): # Now try some restraints. We want c to be exactly zero. It should give # a penalty of (c-0)**2, which is 4 in this case - r1 = self.recipe.restrain(self.fitcontribution.c, 0, 0, 1) + r1 = self.recipe.add_soft_bounds(self.fitcontribution.c, 0, 0, 1) self.recipe._ready = False res = self.recipe.residual() chi2 = 4 + dot(y - self.profile.y, y - self.profile.y) @@ -189,22 +239,22 @@ def testResidual(self): # Clear the constraint and restore the value of c to 0. This should # give us chi2 = 0 again. - self.recipe.unconstrain(self.fitcontribution.c) - self.fitcontribution.c.setValue(0) + self.recipe.remove_constraint(self.fitcontribution.c) + self.fitcontribution.c.set_value(0) res = self.recipe.residual([self.recipe.cont.A.getValue()]) chi2 = 0 self.assertAlmostEqual(chi2, dot(res, res)) # Remove the restraint and variable - self.recipe.unrestrain(r1) - self.recipe.delVar(self.recipe.Avar) + self.recipe.remove_soft_bounds(r1) + self.recipe.delete_variable(self.recipe.Avar) self.recipe._ready = False res = self.recipe.residual() chi2 = 0 self.assertAlmostEqual(chi2, dot(res, res)) # Add constraints at the fitcontribution level. - self.fitcontribution.constrain(self.fitcontribution.c, "2*A") + self.fitcontribution.add_constraint(self.fitcontribution.c, "2*A") # This should evaluate to sin(x+2) x = self.profile.x y = sin(x + 2) @@ -212,7 +262,9 @@ def testResidual(self): self.assertTrue(array_equal(y - self.profile.y, res)) # Add a restraint at the fitcontribution level. - r1 = self.fitcontribution.restrain(self.fitcontribution.c, 0, 0, 1) + r1 = self.fitcontribution.add_soft_bounds( + self.fitcontribution.c, 0, 0, 1 + ) self.recipe._ready = False # The chi2 is the same as above, plus 4 res = self.recipe.residual() @@ -222,22 +274,22 @@ def testResidual(self): self.assertAlmostEqual(chi2, dot(res, res)) # Remove those - self.fitcontribution.unrestrain(r1) + self.fitcontribution.remove_soft_bounds(r1) self.recipe._ready = False self.fitcontribution.unconstrain(self.fitcontribution.c) - self.fitcontribution.c.setValue(0) + self.fitcontribution.c.set_value(0) res = self.recipe.residual() chi2 = 0 self.assertAlmostEqual(chi2, dot(res, res)) # Now try to use the observed profile inside of the equation # Set the equation equal to the data - self.fitcontribution.setEquation("y") + self.fitcontribution.set_equation("y") res = self.recipe.residual() self.assertAlmostEqual(0, dot(res, res)) # Now add the uncertainty. This should give dy/dy = 1 for the residual - self.fitcontribution.setEquation("y+dy") + self.fitcontribution.set_equation("y+dy") res = self.recipe.residual() self.assertAlmostEqual(len(res), dot(res, res)) @@ -248,6 +300,46 @@ def testResidual(self): # ---------------------------------------------------------------------------- +def test_boundsToRestraints(): + recipe = FitRecipe("recipe") + + # create a bounded variable + recipe.create_new_variable("var1", 1) + expected_lower_bound = -1 + expected_upper_bound = 1 + expected_sigma = 2 + recipe.var1.bounds = (expected_lower_bound, expected_upper_bound) + + # apply restraints from bounds + recipe.boundsToRestraints(sig=expected_sigma, scaled=True) + restraints = list(recipe._restraints) + assert len(restraints) == 1 + r = restraints[0] + actual_lower_bound = r.lower_bound + actual_upper_bound = r.upper_bound + actual_sigma = r.sig + assert actual_lower_bound == expected_lower_bound + assert actual_upper_bound == expected_upper_bound + assert actual_sigma == expected_sigma + assert r.scaled is True + + +def test_convert_bounds_to_restraints(): + recipe = FitRecipe("recipe") + # create a bounded variable + recipe.create_new_variable("var1", 1) + recipe.var1.bounds = (-1, 1) + # apply restraints from bounds + recipe.convert_bounds_to_restraints(sig=2, scaled=True) + restraints = list(recipe._restraints) + assert len(restraints) == 1 + r = restraints[0] + assert r.lower_bound == -1 + assert r.upper_bound == 1 + assert r.sig == 2 + assert r.scaled is True + + def testPrintFitHook(capturestdout): "check output from default PrintFitHook." recipe = FitRecipe("recipe") @@ -257,29 +349,29 @@ def testPrintFitHook(capturestdout): profile = Profile() x = linspace(0, pi, 10) y = sin(x) - profile.setObservedProfile(x, y) + profile.set_observed_profile(x, y) # Set up the FitContribution fitcontribution = FitContribution("cont") fitcontribution.set_profile(profile) - fitcontribution.setEquation("A*sin(k*x + c)") - fitcontribution.A.setValue(1) - fitcontribution.k.setValue(1) - fitcontribution.c.setValue(0) + fitcontribution.set_equation("A*sin(k*x + c)") + fitcontribution.A.set_value(1) + fitcontribution.k.set_value(1) + fitcontribution.c.set_value(0) recipe.addContribution(fitcontribution) - recipe.addVar(fitcontribution.c) - recipe.restrain("c", lb=5) + recipe.add_variable(fitcontribution.c) + recipe.add_soft_bounds("c", lower_bound=5) (pfh,) = recipe.getFitHooks() - out = capturestdout(recipe.scalarResidual) + out = capturestdout(recipe.scalar_residual) assert "" == out pfh.verbose = 1 - out = capturestdout(recipe.scalarResidual) + out = capturestdout(recipe.scalar_residual) assert out.strip().isdigit() assert "\nRestraints:" not in out pfh.verbose = 2 - out = capturestdout(recipe.scalarResidual) + out = capturestdout(recipe.scalar_residual) assert "\nResidual:" in out assert "\nRestraints:" in out assert "\nVariables" not in out @@ -290,6 +382,82 @@ def testPrintFitHook(capturestdout): return +def test_add_and_remove_ParameterSet(): + # add a parset + recipe = FitRecipe("recipe") + parameter_to_add = Parameter("added_param", 1) + recipe.addParameterSet(parameter_to_add) + # check that the parameter is added + assert recipe.added_param == parameter_to_add + assert recipe.added_param.value == 1 + # remove the added parameter + recipe.removeParameterSet(parameter_to_add) + # check that the parameter is removed + assert not hasattr(recipe, "added_param") + + +def test_add_and_remove_parameter_set(): + recipe = FitRecipe("recipe") + parameter_to_add = Parameter("added_param", 1) + # add a parset + recipe.add_parameter_set(parameter_to_add) + # check that the parameter is added + assert recipe.added_param == parameter_to_add + assert recipe.added_param.value == 1 + # remove the added parameter + recipe.remove_parameter_set(parameter_to_add) + # check that the parameter is removed + assert not hasattr(recipe, "added_param") + + +def test_add_contribution(capturestdout): + """Duplicated test of PrintFitHooks except addContribution method + has changed to the new add_contribution method. This is because + addContribution is deprecated. + + Remove this test after addContribution is removed and update + testPrintFitHook to use add_contribution instead of addContribution. + """ + recipe = FitRecipe("recipe") + recipe.fithooks[0].verbose = 0 + + # Set up the Profile + profile = Profile() + x = linspace(0, pi, 10) + y = sin(x) + profile.set_observed_profile(x, y) + + # Set up the FitContribution + fitcontribution = FitContribution("cont") + fitcontribution.set_profile(profile) + fitcontribution.set_equation("A*sin(k*x + c)") + fitcontribution.A.set_value(1) + fitcontribution.k.set_value(1) + fitcontribution.c.set_value(0) + + recipe.add_contribution(fitcontribution) + + recipe.add_variable(fitcontribution.c) + recipe.add_soft_bounds("c", lower_bound=5) + (pfh,) = recipe.get_fit_hooks() + out = capturestdout(recipe.scalar_residual) + assert "" == out + pfh.verbose = 1 + out = capturestdout(recipe.scalar_residual) + assert out.strip().isdigit() + assert "\nRestraints:" not in out + pfh.verbose = 2 + out = capturestdout(recipe.scalar_residual) + assert "\nResidual:" in out + assert "\nRestraints:" in out + assert "\nVariables" not in out + pfh.verbose = 3 + out = capturestdout(recipe.scalar_residual) + assert "\nVariables" in out + assert "c = " in out + return + + def optimize_recipe(recipe): recipe.fithooks[0].verbose = 0 residuals = recipe.residual @@ -297,6 +465,174 @@ def optimize_recipe(recipe): leastsq(residuals, values) +def test_initialize_recipe_from_recipe(build_recipes_one_contribution): + # Case: User initializes a FitRecipe from a previously optimized fit + # expected: recipe is initialized with everything: + # contributions, profiles (contained in contributions), + # variables, restraints, and constraints + recipe1, _ = build_recipes_one_contribution + optimize_recipe(recipe1) + expected_parameters_dict = recipe1._parameters + expected_constraints_dict = recipe1._constraints + expected_restraints_set = recipe1._restraints + expected_contributions_dict = recipe1._contributions + expected_profiles_list = [] + for con_name, contribution in expected_contributions_dict.items(): + expected_profile = contribution.profile + expected_profiles_list.append(expected_profile) + + recipe2 = FitRecipe() + assert recipe1 != recipe2 + recipe2.initialize_recipe_with_recipe(recipe1) + actual_parameters_dict = recipe2._parameters + actual_constraints_dict = recipe2._constraints + actual_restraints_set = recipe2._restraints + actual_contributions_dict = recipe2._contributions + actual_profiles_list = [] + for con_name, contribution in actual_contributions_dict.items(): + actual_profile = contribution.profile + actual_profiles_list.append(actual_profile) + + assert expected_parameters_dict == actual_parameters_dict + assert expected_constraints_dict == actual_constraints_dict + assert expected_restraints_set == actual_restraints_set + assert expected_contributions_dict == actual_contributions_dict + assert expected_profiles_list == actual_profiles_list + + # Check to see if the refined values and variable names are + # the same in the results objects for each recipe + results1 = FitResults(recipe1) + # round to account for small numerical differences + expected_values = np.round(results1.varvals, 7) + expected_names = results1.varnames + + optimize_recipe(recipe2) + results2 = FitResults(recipe2) + # round to account for small numerical differences + actual_values = np.round(results2.varvals, 7) + actual_names = results2.varnames + + assert sorted(expected_names) == sorted(actual_names) + assert sorted(list(expected_values)) == sorted(list(actual_values)) + + +def test_initialize_recipe_from_recipe_bad(): + # Case: User tries to initialize a FitRecipe from a non recipe object + # expected: raised ValueError with message + recipe_bad = 12345 # not a FitRecipe object + recipe2 = FitRecipe() + msg = ( + "The input recipe_object must be a FitRecipe, " + "but got ." + ) + with pytest.raises(ValueError, match=msg): + recipe2.initialize_recipe_with_recipe(recipe_bad) + + +def test_initialize_recipe_from_results_object(build_recipes_one_contribution): + # Case: User initializes a FitRecipe from a FitResults object + # expected: recipe is initialized with variables from previous fit + + # create unique recipe1 + recipe1, recipe2 = build_recipes_one_contribution + optimize_recipe(recipe1) + results1 = FitResults(recipe1) + expected_values = np.round(results1.varvals, 5) + expected_names = results1.varnames + + assert recipe1 != recipe2 + # create a new var that should be include in the initialized recipe + recipe2.create_new_variable("extra_var", 5) + actual_values_before_init = [val for val in recipe2.get_values()] + actual_names_before_init = sorted(recipe2.get_names()) + # the three variables + the extra_var + expected_names_before_init = sorted( + [ + "amplitude", + "extra_var", + "phase_shift", + "wave_number", + ] + ) + expected_values_before_init = [4, 3, 2, 5] + assert actual_values_before_init == expected_values_before_init + assert actual_names_before_init == expected_names_before_init + recipe2.initialize_recipe_with_results(results1) + optimize_recipe(recipe2) + results2 = FitResults(recipe2) + actual_values = np.round(results2.varvals, 5) + actual_names = results2.varnames + + # add the new variable name to expected names + expected_names = expected_names + ["extra_var"] + # add the value of the new variable to expected values + expected_values = list(expected_values) + [5] + assert sorted(expected_names) == sorted(actual_names) + assert sorted(expected_values) == sorted(list(actual_values)) + + +def test_initialize_recipe_from_results_file( + build_recipes_one_contribution, temp_data_files +): + # Case: User initializes a FitRecipe from a FitResults file + # expected: recipe is initialized with variables from previous fit + results_file = temp_data_files / "fit_results.res" + expected_names = ["amplitude", "phase_shift", "wave_number"] + expected_values = [1, 1, 0] + + recipe, _ = build_recipes_one_contribution + recipe.initialize_recipe_with_results(results_file) + results = FitResults(recipe) + actual_values = np.round(results.varvals, 5) + actual_names = results.varnames + + assert sorted(expected_names) == sorted(actual_names) + assert list(expected_values) == list(actual_values) + + +def test_initialize_recipe_from_results_file_bad( + build_recipes_one_contribution, +): + # Case: User tries to initialize a recipe with something that + # isn't a path, str, or FitResults object + # Expected: raised ValueError with message + recipe, _ = build_recipes_one_contribution + bad_input = 12345 # not a valid input type + msg = ( + "The input results must be a FitResults object or a path to a " + "results file, but got ." + ) + with pytest.raises(ValueError, match=msg): + recipe.initialize_recipe_with_results(bad_input) + + +def test_initialize_recipe_from_results_file_wrong( + build_recipe_two_contributions, temp_data_files, capsys +): + # Case: User tries to initialize a FitRecipe from a results file + # that does not match params in the recipe + # expected: Warning message is printed and things proceed as + # usual with the variables in the recipe + + results_file_from_single_contrib = temp_data_files / "fit_results.res" + recipe = build_recipe_two_contributions + recipe.initialize_recipe_with_results(results_file_from_single_contrib) + captured = capsys.readouterr() + actual_print_msg = captured.out # .strip() + + results_file_param_names = ["amplitude", "phase_shift", "wave_number"] + expected_print_messages = [] + for param_name in results_file_param_names: + msg = ( + f"Warning: Parameter '{param_name}' from results not found " + "in FitRecipe and will be ignored." + ) + expected_print_messages.append(msg) + + for expected_print_msg in expected_print_messages: + assert expected_print_msg in actual_print_msg + + def get_labels_and_linecount(ax): """Helper to get line labels and count from a matplotlib Axes.""" labels = [ @@ -319,21 +655,42 @@ def build_recipe_from_datafile(datafile): PDFGenerator.""" profile = Profile() parser = PDFParser() + parser.parse_file(str(datafile)) + profile.load_parsed_data(parser) + + contribution = FitContribution("c") + contribution.set_profile(profile) + contribution.set_equation("m*x + b") + recipe = FitRecipe() + recipe.add_contribution(contribution) + recipe.add_variable(contribution.m, 1) + recipe.add_variable(contribution.b, 0) + return recipe + + +def build_recipe_from_datafile_deprecated(datafile): + """Duplicate of build_recipe_from_datafile to use deprecated + loadParsedData method. + + Remove in version 4.0.0. + """ + profile = Profile() + parser = PDFParser() parser.parseFile(str(datafile)) profile.loadParsedData(parser) contribution = FitContribution("c") contribution.set_profile(profile) - contribution.setEquation("m*x + b") + contribution.set_equation("m*x + b") recipe = FitRecipe() - recipe.addContribution(contribution) - recipe.addVar(contribution.m, 1) - recipe.addVar(contribution.b, 0) + recipe.add_contribution(contribution) + recipe.add_variable(contribution.m, 1) + recipe.add_variable(contribution.b, 0) return recipe -def test_plot_recipe_bad_display(build_recipe_one_contribution): - recipe = build_recipe_one_contribution +def test_plot_recipe_bad_display(build_recipes_one_contribution): + recipe, _ = build_recipes_one_contribution # Case: All plots are disabled # expected: raised ValueError with message plt.close("all") @@ -359,11 +716,11 @@ def test_plot_recipe_no_contribution(): recipe.plot_recipe() -def test_plot_recipe_before_refinement(capsys, build_recipe_one_contribution): +def test_plot_recipe_before_refinement(capsys, build_recipes_one_contribution): # Case: User tries to plot recipe before refinement # expected: Data plotted without fit line or difference curve # and warning message printed - recipe = build_recipe_one_contribution + recipe, _ = build_recipes_one_contribution plt.close("all") before = set(plt.get_fignums()) # include fit_label="nothing" to make sure fit line is not plotted @@ -388,10 +745,10 @@ def test_plot_recipe_before_refinement(capsys, build_recipe_one_contribution): assert actual == expected -def test_plot_recipe_after_refinement(build_recipe_one_contribution): +def test_plot_recipe_after_refinement(build_recipes_one_contribution): # Case: User refines recipe and then plots # expected: Plot generates with no problem - recipe = build_recipe_one_contribution + recipe, _ = build_recipes_one_contribution optimize_recipe(recipe) plt.close("all") before = set(plt.get_fignums()) @@ -425,10 +782,10 @@ def test_plot_recipe_two_contributions(build_recipe_two_contributions): assert len(new_figs) == 2 -def test_plot_recipe_on_existing_plot(build_recipe_one_contribution): +def test_plot_recipe_on_existing_plot(build_recipes_one_contribution): # Case: User passes axes to plot_recipe to plot on existing figure # expected: User modifications are present in the final figure - recipe = build_recipe_one_contribution + recipe, _ = build_recipes_one_contribution optimize_recipe(recipe) plt.close("all") fig, ax = plt.subplots() @@ -445,10 +802,10 @@ def test_plot_recipe_on_existing_plot(build_recipe_one_contribution): assert actual_title == expected_title -def test_plot_recipe_add_new_data(build_recipe_one_contribution): +def test_plot_recipe_add_new_data(build_recipes_one_contribution): # Case: User wants to add data to figure generated by plot_recipe # Expected: New data is added to existing figure (check with labels) - recipe = build_recipe_one_contribution + recipe, _ = build_recipes_one_contribution optimize_recipe(recipe) plt.close("all") before = set(plt.get_fignums()) @@ -489,10 +846,10 @@ def test_plot_recipe_add_new_data_two_figs(build_recipe_two_contributions): assert len(new_figs) == 2 -def test_plot_recipe_set_title(build_recipe_one_contribution): +def test_plot_recipe_set_title(build_recipes_one_contribution): # Case: User sets title via plot_recipe # Expected: Title is set correctly - recipe = build_recipe_one_contribution + recipe, _ = build_recipes_one_contribution optimize_recipe(recipe) plt.close("all") expected_title = "Custom Recipe Title" @@ -503,10 +860,98 @@ def test_plot_recipe_set_title(build_recipe_one_contribution): assert actual_title == expected_title -def test_plot_recipe_set_defaults(build_recipe_one_contribution): +def test_plot_recipe_default_title(build_recipes_one_contribution): + # Case: A single contribution is plotted with no title given. + # Expected: The figure is titled with the name of the contribution. + recipe, _ = build_recipes_one_contribution + optimize_recipe(recipe) + plt.close("all") + _, ax = recipe.plot_recipe(show=False, return_fig=True) + actual_title = ax.get_title() + expected_title = "c1" + assert actual_title == expected_title + + +def test_plot_recipe_titles_two_contributions(build_recipe_two_contributions): + # Case: Two contributions are plotted on separate figures with no + # title given. + # Expected: Each figure is titled with the name of its contribution. + recipe = build_recipe_two_contributions + optimize_recipe(recipe) + plt.close("all") + _, axes = recipe.plot_recipe(show=False, return_fig=True) + actual_titles = [ax.get_title() for ax in axes] + expected_titles = ["c1", "c2"] + assert actual_titles == expected_titles + + +def test_plot_recipe_labels_shared_axes(build_recipe_two_contributions): + # Case: Two contributions are plotted on a single user-supplied axes. + # Expected: Legend labels are prefixed with the contribution name so + # that the curves of each contribution can be told apart. + recipe = build_recipe_two_contributions + optimize_recipe(recipe) + plt.close("all") + _, ax = plt.subplots() + recipe.plot_recipe(ax=ax, show=False) + actual_labels, _ = get_labels_and_linecount(ax) + expected_labels = [ + "c1: Observed", + "c1: Calculated", + "c1: Difference", + "c2: Observed", + "c2: Calculated", + "c2: Difference", + ] + assert actual_labels == expected_labels + + +def test_plot_recipe_label_contribution_placeholder( + build_recipe_two_contributions, +): + # Case: User passes a label containing the {contribution} placeholder. + # Expected: The placeholder is replaced by the contribution name and no + # prefix is added. + recipe = build_recipe_two_contributions + optimize_recipe(recipe) + plt.close("all") + _, axes = recipe.plot_recipe( + fit_label="{contribution} calculated", + show_observed=False, + show_diff=False, + show=False, + return_fig=True, + ) + actual_labels = [get_labels_and_linecount(ax)[0] for ax in axes] + expected_labels = [["c1 calculated"], ["c2 calculated"]] + assert actual_labels == expected_labels + + +def test_plot_recipe_missing_ycalc_one_contribution( + build_recipe_two_contributions, +): + # Case: Only the second contribution has been evaluated, so the ycalc of + # the first one is None. + # Expected: The unevaluated contribution shows observed data only while + # the other one is still plotted in full. + recipe = build_recipe_two_contributions + optimize_recipe(recipe) + plt.close("all") + # manually set ycalc to None + recipe._contributions["c1"].profile.ycalc = None + _, axes = recipe.plot_recipe(show=False, return_fig=True) + actual_labels = [get_labels_and_linecount(ax)[0] for ax in axes] + expected_labels = [ + ["Observed"], + ["Observed", "Calculated", "Difference"], + ] + assert actual_labels == expected_labels + + +def test_plot_recipe_set_defaults(build_recipes_one_contribution): # Case: user sets default plot options with set_plot_defaults # Expected: plot_recipe uses the default options for all calls - recipe = build_recipe_one_contribution + recipe, _ = build_recipes_one_contribution optimize_recipe(recipe) plt.close("all") # set new defaults @@ -531,10 +976,10 @@ def test_plot_recipe_set_defaults(build_recipe_one_contribution): assert actual_labels == expected_labels -def test_plot_recipe_set_defaults_bad(capsys, build_recipe_one_contribution): +def test_plot_recipe_set_defaults_bad(capsys, build_recipes_one_contribution): # Case: user tries to set kwargs that are not valid plot_recipe options # Expected: Plot is shown and warning is printed - recipe = build_recipe_one_contribution + recipe, _ = build_recipes_one_contribution optimize_recipe(recipe) plt.close("all") recipe.set_plot_defaults( @@ -598,7 +1043,24 @@ def test_plot_recipe_labels_from_gr_file_overwrite(temp_data_files): assert actual_ylabel == expected_ylabel -def test_plot_recipe_reset_all_defaults(build_recipe_one_contribution): +def test_plot_recipe_labels_from_gr_file_overwrite_deprecated(temp_data_files): + "Remove this test with version 4.0.0." + gr_file = temp_data_files / "gr_file.gr" + recipe = build_recipe_from_datafile_deprecated(gr_file) + optimize_recipe(recipe) + plt.close("all") + fig, ax = recipe.plot_recipe( + return_fig=True, show=False, xlabel="My X", ylabel="My Y" + ) + actual_xlabel = ax.get_xlabel() + actual_ylabel = ax.get_ylabel() + expected_xlabel = "My X" + expected_ylabel = "My Y" + assert actual_xlabel == expected_xlabel + assert actual_ylabel == expected_ylabel + + +def test_plot_recipe_reset_all_defaults(build_recipes_one_contribution): expected_defaults = { "show_observed": True, "show_fit": True, @@ -627,7 +1089,7 @@ def test_plot_recipe_reset_all_defaults(build_recipe_one_contribution): "show": True, } - recipe = build_recipe_one_contribution + recipe, _ = build_recipes_one_contribution optimize_recipe(recipe) plt.close("all") @@ -686,5 +1148,86 @@ def test_plot_recipe_reset_all_defaults(build_recipe_one_contribution): assert actual_legend == expected_legend +# Observed uncertainties are optional. A profile loaded without an +# uncertainty column keeps dyobs as None and is refined unweighted, with +# dy falling back to one at every calculation point. The cases below +# refine the same noiseless sine profile so that the known solution +# (A=1, k=1, c=0) is recovered no matter how the fit is weighted. + + +# make_input_dyobs returns a function that evaluates dyobs on the array +# of xobs for insertion into the build_recipe_with_uncertainty fixture. +@pytest.mark.parametrize( + "make_input_dyobs, expected_dyobs_is_set", + [ + # C1: No uncertainties are observed, as for a file with no + # uncertainty column. + # Expected: The refinement converges and dyobs stays None. + (lambda xobs: None, False), + # C2: Uniform uncertainties of one are observed explicitly. + # Expected: The refinement converges to the same values as C1, + # since an unweighted fit is weighted by one everywhere. + (lambda xobs: ones_like(xobs), True), + # C3: Uncertainties vary across the profile, so the refinement + # is weighted point by point. + # Expected: The refinement still converges to the known values. + (lambda xobs: linspace(0.1, 1.0, len(xobs)), True), + ], +) +def test_refine_with_and_without_uncertainty( + build_recipe_with_uncertainty, make_input_dyobs, expected_dyobs_is_set +): + recipe, profile = build_recipe_with_uncertainty(make_input_dyobs) + optimize_recipe(recipe) + actual_dyobs_is_set = profile.dyobs is not None + actual_names = recipe.get_names() + actual_values = recipe.get_values() + expected_names = ["A", "k", "c"] + expected_values = [1.0, 1.0, 0.0] + assert actual_dyobs_is_set == expected_dyobs_is_set + assert actual_names == expected_names + assert actual_values == pytest.approx(expected_values, abs=1e-5) + + +# The residual that is optimized is (ycalc - y)/dy, so dy is what +# actually weights a refinement. These cases check that a profile with +# no observed uncertainties is weighted identically to one whose +# uncertainties are all one, and that observed uncertainties are carried +# through to the residual unchanged. + + +# make_input_dyobs returns a function that evaluates dyobs on the array +# of xobs for insertion into the build_recipe_with_uncertainty fixture. +@pytest.mark.parametrize( + "make_input_dyobs, make_expected_dy", + [ + # C1: No uncertainties are observed. + # Expected: dy falls back to one everywhere, so the residual is + # unweighted. + (lambda xobs: None, lambda xobs: ones_like(xobs)), + # C2: Uniform uncertainties of one are observed explicitly. + # Expected: dy is one everywhere, matching C1. + (lambda xobs: ones_like(xobs), lambda xobs: ones_like(xobs)), + # C3: Uncertainties vary across the profile. + # Expected: dy keeps the observed values, so the residual is + # weighted point by point. + ( + lambda xobs: linspace(0.1, 1.0, len(xobs)), + lambda xobs: linspace(0.1, 1.0, len(xobs)), + ), + ], +) +def test_residual_is_weighted_by_uncertainty( + build_recipe_with_uncertainty, make_input_dyobs, make_expected_dy +): + recipe, profile = build_recipe_with_uncertainty(make_input_dyobs) + actual_residual = recipe.residual(recipe.values) + actual_dy = profile.dy + expected_dy = make_expected_dy(profile.xobs) + expected_residual = (profile.ycalc - profile.y) / expected_dy + assert actual_dy == pytest.approx(expected_dy) + assert actual_residual == pytest.approx(expected_residual) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_fitresults.py b/tests/test_fitresults.py index 5702b03f..d9be7317 100644 --- a/tests/test_fitresults.py +++ b/tests/test_fitresults.py @@ -16,17 +16,201 @@ import unittest +import numpy as np import pytest +from scipy.optimize import leastsq from diffpy.srfit.fitbase.fitrecipe import FitRecipe -from diffpy.srfit.fitbase.fitresults import initializeRecipe +from diffpy.srfit.fitbase.fitresults import ( + FitResults, + initializeRecipe, + resultsDictionary, +) + +# The fit results from the recipe fixture in conftest.py +expected_fitresults = """\ +My Custom header +Some quantities invalid due to missing profile uncertainty +Overall (Chi2 and Reduced Chi2 invalid) +------------------------------------------------------------------------------ +Residual 0.00000000 +Contributions 0.00000000 +Restraints 0.00000000 +Chi2 0.00000000 +Reduced Chi2 0.00000000 +Rw 0.00000010 + +Variables (Uncertainties invalid) +------------------------------------------------------------------------------ +""" +expected_refined_variables = ["amplitude", "wave_number", "phase_shift"] + + +def optimize_recipe(recipe): + recipe.fithooks[0].verbose = 0 + residuals = recipe.residual + values = recipe.values + leastsq(residuals, values) + + +def test_formatResults(build_recipes_one_contribution): + recipe, _ = build_recipes_one_contribution + optimize_recipe(recipe) + results = FitResults(recipe) + actual_results_string = results.formatResults(header="My Custom header") + # Because slight variations in refinement, just check + # that the header of the results are the same. + assert expected_fitresults.strip() in actual_results_string.strip() + # check if the refined variables are in the results + for expected_var in expected_refined_variables: + assert expected_var in actual_results_string.strip() + + +def test_get_results_string(build_recipes_one_contribution): + recipe, _ = build_recipes_one_contribution + optimize_recipe(recipe) + results = FitResults(recipe) + actual_results_string = results.get_results_string( + header="My Custom header" + ) + # Because slight variations in refinement, just check + # that the header of the results are the same. + assert expected_fitresults.strip() in actual_results_string.strip() + # check if the refined variables are in the results + for expected_var in expected_refined_variables: + assert expected_var in actual_results_string.strip() + + +def test_printResults(build_recipes_one_contribution, capsys): + recipe, _ = build_recipes_one_contribution + optimize_recipe(recipe) + results = FitResults(recipe) + results.printResults(header="My Custom header") + actual_results = capsys.readouterr().out + # Because slight variations in refinement, just check + # that the header of the results are the same. + assert expected_fitresults.strip() in actual_results.strip() + # check if the refined variables are in the results + for expected_var in expected_refined_variables: + assert expected_var in actual_results.strip() + + +def test_print_results(build_recipes_one_contribution, capsys): + recipe, _ = build_recipes_one_contribution + optimize_recipe(recipe) + results = FitResults(recipe) + results.print_results(header="My Custom header") + actual_results = capsys.readouterr().out + # Because slight variations in refinement, just check + # that the header of the results are the same. + assert expected_fitresults.strip() in actual_results.strip() + # check if the refined variables are in the results + for expected_var in expected_refined_variables: + assert expected_var in actual_results.strip() + + +def test_saveResults(build_recipes_one_contribution, tmp_path): + recipe, _ = build_recipes_one_contribution + optimize_recipe(recipe) + results = FitResults(recipe) + actual_results_file = tmp_path / "fit_results.txt" + results.saveResults(actual_results_file, header="My Custom header") + assert actual_results_file.exists() + with open(actual_results_file, "r") as res_file: + actual_results = res_file.read() + # Because slight variations in refinement, just check + # that the header of the results are the same. + assert expected_fitresults.strip() in actual_results.strip() + # check if the refined variables are in the results + for expected_var in expected_refined_variables: + assert expected_var in actual_results.strip() + + +def test_save_results(build_recipes_one_contribution, tmp_path): + recipe, _ = build_recipes_one_contribution + optimize_recipe(recipe) + results = FitResults(recipe) + actual_results_file = tmp_path / "fit_results.txt" + results.save_results(actual_results_file, header="My Custom header") + assert actual_results_file.exists() + with open(actual_results_file, "r") as res_file: + actual_results = res_file.read() + # Because slight variations in refinement, just check + # that the header of the results are the same. + assert expected_fitresults.strip() in actual_results.strip() + # check if the refined variables are in the results + for expected_var in expected_refined_variables: + assert expected_var in actual_results.strip() + + +def test_get_results_dictionary(build_recipes_one_contribution): + # Case: user gets results dictionary after optimization + # expected: results dictionary contains expected keys and values + recipe, _ = build_recipes_one_contribution + optimize_recipe(recipe) + results = FitResults(recipe) + actual_results_dict = results.get_results_dictionary() + expected_results_dict = { + "amplitude": 1.000000000060171, + "wave_number": 1.00000000012548, + "phase_shift": -1.6129114631049646e-18, + "Residual": 3.3284672708760557e-19, + "Contributions": 3.3284672708760557e-19, + "Restraints": 0, + "Chi2": 3.3284672708760557e-19, + "Reduced Chi2": 4.7549532441086507e-20, + "Rw": 2.7196679825449506e-10, + } + actual_values = np.round(np.array(list(actual_results_dict.values())), 5) + actual_keys = set(actual_results_dict.keys()) + expected_values = np.round( + np.array(list(expected_results_dict.values())), 5 + ) + expected_keys = set(expected_results_dict.keys()) + assert expected_keys == actual_keys + assert list(expected_values == list(actual_values)) + + +def test_resultsDictionary(temp_data_files): + # Case: user gets results dictionary from a results file + # expected: results dictionary contains expected keys and values + actual_results_dict = resultsDictionary( + temp_data_files / "fit_results.res" + ) + # bad behavior: values are stored as strings + expected_results_dict = { + "than": "25", # bad behavior: shouldn't be here + "wave_number": "1.00000000e+00", + "phase_shift": "-1.61291146e-18", + "amplitude": "1.00000000e+00", + "Rw": "0.00000000", + "Chi2": "0.00000000", + "Restraints": "0.00000000", + "Contributions": "0.00000000", + "Residual": "0.00000000", + "Feb": "25", # bad behavior: shouldn't be here + } + # convert values to float for comparison (with rounding) + for key in expected_results_dict: + expected_results_dict[key] = float(expected_results_dict[key]) + for key in actual_results_dict: + actual_results_dict[key] = float(actual_results_dict[key]) + + actual_keys = set(actual_results_dict.keys()) + actual_values = np.round(np.array(list(actual_results_dict.values())), 5) + expected_keys = set(expected_results_dict.keys()) + expected_values = np.round( + np.array(list(expected_results_dict.values())), 5 + ) + assert expected_keys == actual_keys + assert list(expected_values == list(actual_values)) def testInitializeFromFileName(datafile): recipe = FitRecipe("recipe") - recipe.newVar("A", 0) - recipe.newVar("sig", 0) - recipe.newVar("x0", 0) + recipe.create_new_variable("A", 0) + recipe.create_new_variable("sig", 0) + recipe.create_new_variable("x0", 0) filename = datafile("results.res") Aval = 5.77619823e-01 sigval = -9.22758690e-01 @@ -44,9 +228,9 @@ def testInitializeFromFileName(datafile): def testInitializeFromFileObj(datafile): recipe = FitRecipe("recipe") - recipe.newVar("A", 0) - recipe.newVar("sig", 0) - recipe.newVar("x0", 0) + recipe.create_new_variable("A", 0) + recipe.create_new_variable("sig", 0) + recipe.create_new_variable("x0", 0) filename = datafile("results.res") Aval = 5.77619823e-01 sigval = -9.22758690e-01 @@ -67,9 +251,9 @@ def testInitializeFromFileObj(datafile): def testInitializeFromString(datafile): recipe = FitRecipe("recipe") - recipe.newVar("A", 0) - recipe.newVar("sig", 0) - recipe.newVar("x0", 0) + recipe.create_new_variable("A", 0) + recipe.create_new_variable("sig", 0) + recipe.create_new_variable("x0", 0) filename = datafile("results.res") Aval = 5.77619823e-01 sigval = -9.22758690e-01 diff --git a/tests/test_literals.py b/tests/test_literals.py index db9fc31d..891bbde9 100644 --- a/tests/test_literals.py +++ b/tests/test_literals.py @@ -48,10 +48,10 @@ def testValue(self): self.assertEqual(None, a.getValue()) # Test setting value - a.setValue(3.14) + a.set_value(3.14) self.assertAlmostEqual(3.14, a._value) - a.setValue(3.14) + a.set_value(3.14) self.assertAlmostEqual(3.14, a.value) self.assertAlmostEqual(3.14, a.getValue()) return @@ -99,7 +99,7 @@ def testValue(self): self.assertAlmostEqual(0, op.value) # Test update from the nodes - a.setValue(4) + a.set_value(4) self.assertTrue(op._value is None) self.assertAlmostEqual(4, op.value) self.assertAlmostEqual(4, op.getValue()) @@ -128,11 +128,11 @@ def testAddLiteral(self): op.addLiteral(b) self.assertAlmostEqual(0, op.value) - a.setValue(1) - b.setValue(2) + a.set_value(1) + b.set_value(2) self.assertAlmostEqual(3, op.value) - a.setValue(None) + a.set_value(None) # Test for self-references # Try to add self diff --git a/tests/test_objcrystparset.py b/tests/test_objcrystparset.py index 11290740..533092c2 100644 --- a/tests/test_objcrystparset.py +++ b/tests/test_objcrystparset.py @@ -222,11 +222,11 @@ def _testMolecule(): _testMolecule() # Now change values from the srfit StructureParSet - cryst.c60.C44.x.setValue(1.1) - cryst.c60.C44.occ.setValue(1.1) - cryst.c60.C44.Biso.setValue(1.1) - cryst.c60.q3.setValue(1.1) - cryst.a.setValue(1.1) + cryst.c60.C44.x.set_value(1.1) + cryst.c60.C44.occ.set_value(1.1) + cryst.c60.C44.Biso.set_value(1.1) + cryst.c60.q3.set_value(1.1) + cryst.a.set_value(1.1) _testCrystal() _testMolecule() @@ -410,7 +410,7 @@ def testExplicitBondLengthParameter(self): # Change the value scale = 1.05 - p1.setValue(scale * d0) + p1.set_value(scale * d0) # Verify that it has changed. assert scale * d0 == pytest.approx(p1.getValue(), abs=1e-6) @@ -482,7 +482,7 @@ def testExplicitBondAngleParameter(self): # Change the value scale = 1.05 - p1.setValue(scale * angle0) + p1.set_value(scale * angle0) # Verify that it has changed. assert scale * angle0 == pytest.approx(p1.getValue(), abs=1e-6) @@ -563,7 +563,7 @@ def testExplicitDihedralAngleParameter(self): # Change the value scale = 1.05 - p1.setValue(scale * angle0) + p1.set_value(scale * angle0) # Verify that it has changed. assert scale * angle0 == pytest.approx(p1.getValue(), abs=1e-6) @@ -649,7 +649,8 @@ def sgsEquivalent(self, sg1, sg2): # FIXME: only about 50% of the spacegroups pass the assertion # test disabled even if cctbx is installed def xtestCreateSpaceGroup(self): - """Check all sgtbx space groups for proper conversion to SpaceGroup.""" + """Check all sgtbx space groups for proper conversion to + SpaceGroup.""" try: from cctbx import sgtbx except ImportError: diff --git a/tests/test_parameter.py b/tests/test_parameter.py index 04ecd4c7..c954256b 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -16,6 +16,9 @@ import unittest +import numpy as np +import pytest + from diffpy.srfit.fitbase.parameter import ( Parameter, ParameterAdapter, @@ -29,7 +32,7 @@ def testSetValue(self): """Test initialization.""" par_l = Parameter("l") - par_l.setValue(3.14) + par_l.set_value(3.14) self.assertAlmostEqual(3.14, par_l.getValue()) # Try array @@ -47,7 +50,7 @@ def testSetValue(self): self.assertTrue(par_l.value is y) # Back to scalar - par_l.setValue(1.01) + par_l.set_value(1.01) self.assertAlmostEqual(1.01, par_l.getValue()) self.assertAlmostEqual(1.01, par_l.value) return @@ -89,18 +92,18 @@ def testWrapper(self): # Try Accessor adaptation la = ParameterAdapter( - "l", par_l, getter=Parameter.getValue, setter=Parameter.setValue + "l", par_l, getter=Parameter.getValue, setter=Parameter.set_value ) self.assertEqual(par_l.name, la.name) self.assertEqual(par_l.getValue(), la.getValue()) # Change the parameter - par_l.setValue(2.3) + par_l.set_value(2.3) self.assertEqual(par_l.getValue(), la.getValue()) # Change the adapter - la.setValue(3.2) + la.set_value(3.2) self.assertEqual(par_l.getValue(), la.getValue()) # Try Attribute adaptation @@ -111,15 +114,105 @@ def testWrapper(self): self.assertEqual(par_l.getValue(), la.getValue()) # Change the parameter - par_l.setValue(2.3) + par_l.set_value(2.3) self.assertEqual(par_l.getValue(), la.getValue()) # Change the adapter - la.setValue(3.2) + la.set_value(3.2) self.assertEqual(par_l.getValue(), la.getValue()) return +@pytest.mark.parametrize( + "lower, upper, expected", + [ + # User sets both lower and upper bounds explicitly. + (1, 10, [1, 10]), + # User sets only a lower bound. + (2, None, [2, np.inf]), + # User sets only an upper bound. + (None, 8, [-np.inf, 8]), + # User overwrites existing bounds. + (2, 6, [2, 6]), + ], +) +def test_bound_range(lower, upper, expected): + p = Parameter("a", value=5) + # If testing overwrite, pre-set bounds to see overwrite effect + if expected == [2, 6]: + p.bound_range(0, 10) + p.bound_range(lower_bound=lower, upper_bound=upper) + actual = p.bounds + assert actual == expected + + +@pytest.mark.parametrize( + "lower, upper, expected", + [ + # User sets both lower and upper bounds explicitly. + (1, 10, [1, 10]), + # User sets only a lower bound. + (2, None, [2, np.inf]), + # User sets only an upper bound. + (None, 8, [-np.inf, 8]), + # User overwrites existing bounds. + (2, 6, [2, 6]), + ], +) +def test_boundRange(lower, upper, expected): + p = Parameter("a", value=5) + # If testing overwrite, pre-set bounds to see overwrite effect + if expected == [2, 6]: + p.boundRange(0, 10) + p.boundRange(lb=lower, ub=upper) + actual = p.bounds + assert actual == expected + + +@pytest.mark.parametrize( + "value, lower_radius, upper_radius, expected", + [ + # Symmetric radius (upper_radius None, uses lower_radius) + (10, 2, None, [8, 12]), + # Asymmetric radius + (10, 3, 5, [7, 15]), + # Zero radius + (4, 0, None, [4, 4]), + # Current value updated before bounding + (20, 2, None, [18, 22]), + ], +) +def test_bound_window(value, lower_radius, upper_radius, expected): + p = Parameter("a", value=5) + if value != 5: + p.set_value(value) + p.bound_window(lower_radius=lower_radius, upper_radius=upper_radius) + actual = p.bounds + assert actual == expected + + +@pytest.mark.parametrize( + "value, lower_radius, upper_radius, expected", + [ + # Symmetric radius (upper_radius None, uses lower_radius) + (10, 2, None, [8, 12]), + # Asymmetric radius + (10, 3, 5, [7, 15]), + # Zero radius + (4, 0, None, [4, 4]), + # Current value updated before bounding + (20, 2, None, [18, 22]), + ], +) +def test_boundWindow(value, lower_radius, upper_radius, expected): + p = Parameter("a", value=5) + if value != 5: + p.set_value(value) + p.boundWindow(lr=lower_radius, ur=upper_radius) + actual = p.bounds + assert actual == expected + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_parameterset.py b/tests/test_parameterset.py index 51ffa14c..1a954c8d 100644 --- a/tests/test_parameterset.py +++ b/tests/test_parameterset.py @@ -27,14 +27,35 @@ def setUp(self): return def testAddParameterSet(self): - """Test the addParameterSet method.""" + """Test the deprecated addParameterSet method. + + Remove this test after the addParameterSet is removed in version + 4.0.0. + """ parset2 = ParameterSet("parset2") p1 = Parameter("parset2", 1) self.parset.addParameterSet(parset2) self.assertTrue(self.parset.parset2 is parset2) - self.assertRaises(ValueError, self.parset.addParameterSet, p1) + self.assertRaises(ValueError, self.parset.add_parameter_set, p1) + + p1.name = "p1" + parset2.addParameter(p1) + + self.assertTrue(self.parset.parset2.p1 is p1) + + return + + def test_add_parameter_set(self): + """Test the add_parameter_set method.""" + parset2 = ParameterSet("parset2") + p1 = Parameter("parset2", 1) + + self.parset.add_parameter_set(parset2) + self.assertTrue(self.parset.parset2 is parset2) + + self.assertRaises(ValueError, self.parset.add_parameter_set, p1) p1.name = "p1" parset2.addParameter(p1) diff --git a/tests/test_pdf.py b/tests/test_pdf.py index dbb9799f..4a6b1136 100644 --- a/tests/test_pdf.py +++ b/tests/test_pdf.py @@ -23,121 +23,221 @@ 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 # ---------------------------------------------------------------------------- -def testParser1(datafile): - data = datafile("ni-q27r100-neutron.gr") +def approx_or_none(expected_values): + """Wrap expected_values in pytest.approx, unless it is None.""" + if expected_values is None: + return None + return pytest.approx(expected_values) + + +@pytest.mark.parametrize( + "input_filename, expected_x, expected_y, expected_dy", + [ + # C1: A neutron PDF written by PDFgetN, which has no dx or dy + # columns. + # Expected: x and y are read correctly, and dx and dy are None. + ( + "ni-q27r100-neutron.gr", + numpy.linspace(0.01, 100, 10000), + [ + 1.144, + 2.258, + 3.312, + 4.279, + 5.135, + 5.862, + 6.445, + 6.875, + 7.150, + 7.272, + ], + None, + ), + # C2: An x-ray PDF written by PDFgetX2, which has a dy column + # and a negative dx column. + # Expected: x, y, and dy are read correctly, and the invalid + # negative dx column is dropped. + ( + "si-q27r60-xray.gr", + numpy.linspace(0.01, 60, 5999, endpoint=False), + [ + 0.1105784, + 0.2199684, + 0.3270088, + 0.4305913, + 0.5296853, + 0.6233606, + 0.7108060, + 0.7913456, + 0.8644501, + 0.9297440, + ], + [ + 0.001802192, + 0.003521449, + 0.005079115, + 0.006404892, + 0.007440527, + 0.008142955, + 0.008486813, + 0.008466340, + 0.008096858, + 0.007416456, + ], + ), + ], +) +def test_pdfparser_data( + datafile, as_list, input_filename, expected_x, expected_y, expected_dy +): + """PDFParser reads the x, y, and dy arrays correctly, and always + drops the invalid dx column.""" parser = PDFParser() - parser.parseFile(data) - - meta = parser._meta - - assert data == meta["filename"] - assert 1 == meta["nbanks"] - assert "N" == meta["stype"] - assert 27 == meta["qmax"] - assert 300 == meta.get("temperature") - assert meta.get("qdamp") is None - assert meta.get("qbroad") is None - assert meta.get("spdiameter") is None - assert meta.get("scale") is None - assert meta.get("doping") is None - - x, y, dx, dy = parser.getData() - assert dx is None - assert dy is None - - testx = numpy.linspace(0.01, 100, 10000) - diff = testx - x - res = numpy.dot(diff, diff) - assert 0 == pytest.approx(res) - - testy = numpy.array( - [ - 1.144, - 2.258, - 3.312, - 4.279, - 5.135, - 5.862, - 6.445, - 6.875, - 7.150, - 7.272, - ] - ) - diff = testy - y[:10] - res = numpy.dot(diff, diff) - assert 0 == pytest.approx(res) - - return - - -def testParser2(datafile): - data = datafile("si-q27r60-xray.gr") + parser.parse_file(datafile(input_filename)) + + actual_x, actual_y, actual_dx, actual_dy = parser.get_data() + actual_dy = as_list(actual_dy) + if actual_dy is not None: + # Compare only the first 10 values + actual_dy = actual_dy[:10] + assert actual_dx is None + assert actual_x.tolist() == pytest.approx(expected_x.tolist()) + assert actual_y[:10].tolist() == pytest.approx(expected_y) + assert actual_dy == approx_or_none(expected_dy) + + +# PDFParser inherits ProfileParser's hooks unchanged: PDFgetX and +# PDFgetN headers are already plain name = value pairs, including +# stype = X or stype = N for the scattering type. The metadata below +# reaches PDFGenerator, which uses stype, qmin and qmax to set the +# scattering type and the Q range, so losing a key silently changes a +# refinement. +@pytest.mark.parametrize( + "input_filename, expected_metadata", + [ + # C1: An x-ray PDF written by PDFgetX2. + # Expected: The header yields the x-ray scattering type, + # the Q range and the rest of the diffpy.pdfgetx config. + ( + "si-q27r60-xray.gr", + { + "version": "diffpy.pdfgetx-2.4.0", + "dataformat": "QA", + "outputtype": "gr", + "stype": "X", + "composition": "Si", + "bgscale": 1.0, + "rpoly": 0.9, + "qmaxinst": 29.0, + "qmin": 0.01, + "qmax": 27.0, + "rmin": 0.0, + "rmax": 60.0, + "rstep": 0.01, + "temperature": 300.0, + "bank": 0, + "nbanks": 1, + }, + ), + # C2: A neutron PDF written by PDFgetN. + # Expected: The header yields the neutron scattering type, + # the Q range and the rest of the xPDFsuite config. + ( + "ni-q27r100-neutron.gr", + { + "wavelength": 1.333, + "dataformat": "QA", + "inputfile": "npdf_03315.chi", + "backgroundfile": "npdf_03001.chi", + "stype": "N", + "bgscale": 1.0, + "composition": "Ni", + "outputtype": "gr", + "qmaxinst": 27.0, + "qmin": 0.87, + "qmax": 27.0, + "temperature": 300.0, + "rmax": 100.0, + "rmin": 0.0, + "rstep": 0.01, + "rpoly": 0.9, + "inputdir": "/data/npdf/chi", + "savedir": "/data/npdf/gr", + "bank": 0, + "nbanks": 1, + }, + ), + ], +) +def test_pdfparser_metadata(datafile, input_filename, expected_metadata): + """PDF specific metadata survives the load_data based parse_file.""" parser = PDFParser() - parser.parseFile(data) - - meta = parser._meta - - assert data == meta["filename"] - assert 1 == meta["nbanks"] - assert "X" == meta["stype"] - assert 27 == meta["qmax"] - assert 300 == meta.get("temperature") - assert meta.get("qdamp") is None - assert meta.get("qbroad") is None - assert meta.get("spdiameter") is None - assert meta.get("scale") is None - assert meta.get("doping") is None - - x, y, dx, dy = parser.getData() - testx = numpy.linspace(0.01, 60, 5999, endpoint=False) - diff = testx - x - res = numpy.dot(diff, diff) - assert 0 == pytest.approx(res) - - testy = numpy.array( - [ - 0.1105784, - 0.2199684, - 0.3270088, - 0.4305913, - 0.5296853, - 0.6233606, - 0.7108060, - 0.7913456, - 0.8644501, - 0.9297440, - ] + parser.parse_file(datafile(input_filename)) + actual_metadata = parser.get_metadata() + # add the filename key to the expected metadata for comparison + expected_metadata["filename"] = str(datafile(input_filename)) + assert actual_metadata == expected_metadata + + +def test_pdfparser_deprecated_parseFile(datafile): + """The deprecated parseFile warns and delegates to parse_file.""" + input_filename = datafile("si-q27r60-xray.gr") + expected_parser = PDFParser() + expected_parser.parse_file(input_filename) + actual_parser = PDFParser() + with pytest.warns(DeprecationWarning): + actual_parser.parseFile(input_filename) + + actual_metadata = actual_parser.get_metadata() + expected_metadata = expected_parser.get_metadata() + assert actual_metadata == expected_metadata + + actual_x, actual_y, actual_dx, actual_dy = actual_parser.get_data() + expected_x, expected_y, expected_dx, expected_dy = ( + expected_parser.get_data() ) - diff = testy - y[:10] - res = numpy.dot(diff, diff) - assert 0 == pytest.approx(res) - - testdy = numpy.array( - [ - 0.001802192, - 0.003521449, - 0.005079115, - 0.006404892, - 0.007440527, - 0.008142955, - 0.008486813, - 0.008466340, - 0.008096858, - 0.007416456, - ] - ) - diff = testdy - dy[:10] - res = numpy.dot(diff, diff) - assert 0 == pytest.approx(res) - - assert dx is None - return + assert actual_x.tolist() == expected_x.tolist() + assert actual_y.tolist() == expected_y.tolist() + assert actual_dx == expected_dx + assert actual_dy.tolist() == expected_dy.tolist() + + +def test_pdfcontribution_loadData(datafile): + """LoadData passes the PDF metadata on to the built-in profile.""" + contribution = PDFContribution("pdf") + contribution.loadData(datafile("si-q27r60-xray.gr")) + + expected_metadata = { + "version": "diffpy.pdfgetx-2.4.0", + "dataformat": "QA", + "outputtype": "gr", + "stype": "X", + "composition": "Si", + "bgscale": 1.0, + "rpoly": 0.9, + "qmaxinst": 29.0, + "qmin": 0.01, + "qmax": 27.0, + "rmin": 0.0, + "rmax": 60.0, + "rstep": 0.01, + "temperature": 300.0, + "filename": str(datafile("si-q27r60-xray.gr")), + "bank": 0, + "nbanks": 1, + } + actual_metadata = contribution.profile.meta + assert actual_metadata == expected_metadata + actual_point_count = len(contribution.profile.xobs) + expected_point_count = 5999 + assert actual_point_count == expected_point_count def testGenerator(diffpy_srreal_available, datafile): @@ -169,9 +269,9 @@ def testGenerator(diffpy_srreal_available, datafile): defval = calc._getDoubleAttr(pname) assert defval == par.getValue() # Test setting values - par.setValue(1.0) + par.set_value(1.0) assert 1.0 == par.getValue() - par.setValue(defval) + par.set_value(defval) assert defval == par.getValue() r = numpy.arange(0, 10, 0.1) @@ -292,7 +392,9 @@ def test_pickling(diffpy_srreal_available, datafile): pc2 = pickle.loads(pickle.dumps(pc)) res0 = pc.residual() assert numpy.array_equal(res0, pc2.residual()) - for p in chain(pc.iterPars("Uiso"), pc2.iterPars("Uiso")): + for p in chain( + pc.iterate_over_parameters("Uiso"), pc2.iterate_over_parameters("Uiso") + ): p.value = 0.004 res1 = pc.residual() assert not numpy.allclose(res0, res1) @@ -302,3 +404,114 @@ 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"), + [ + # C1: Match leaf parameter names without fullnames. + # Expected: all Biso parameters in the hierarchy are returned. + (r"^Biso$", {}, [10, 20, 40, 50]), + # C2: Match hierarchical names without fullnames. + # Expected: no leaf names match the hierarchical pattern. + (r"^Ni\d+\.Biso$", {}, []), + # C3: Match hierarchical names with fullnames enabled. + # Expected: matching Ni Biso parameters are returned. + (r"^Ni\d+\.Biso$", {"fullnames": True}, [20, 40]), + # C4: Match one hierarchical Uiso name. + # Expected: only Ni0.Uiso is returned. + (r"^Ni0\.Uiso$", {"fullnames": True}, [30]), + # C5: Match one hierarchical Biso name outside Ni containers. + # Expected: only O0.Biso is returned. + (r"^O0\.Biso$", {"fullnames": True}, [50]), + # C6: Disable recursion while matching child fullnames. + # Expected: no child parameters are returned. + (r"^Ni\d+\.Biso$", {"fullnames": True, "recurse": False}, []), + # C7: Disable recursion while matching root fullname. + # Expected: only the root-level Biso parameter is returned. + (r"^Biso$", {"fullnames": True, "recurse": False}, [10]), + ], +) +def test_iterpars_fullname_matching(pattern, kwargs, expected_values): + """Verify leaf-name and fullname matching in + iterate_over_parameters.""" + objs = _make_iterpars_tree() + root = objs["root"] + + actual_values = [ + parameter.value + for parameter in root.iterate_over_parameters(pattern, **kwargs) + ] + + assert actual_values == expected_values + + +@pytest.mark.parametrize( + ("pattern", "expected_name"), + [ + # C1: Match Biso relative to the called Ni0 container. + # Expected: Ni0.Biso is returned without the Ni0 prefix. + (r"^Biso$", ["Biso"]), + # C2: Match Uiso relative to the called Ni0 container. + # Expected: Ni0.Uiso is returned without the Ni0 prefix. + (r"^Uiso$", ["Uiso"]), + # C3: Match with the parent container prefix from inside Ni0. + # Expected: no parameter is returned because fullnames are relative + # to the container on which iterate_over_parameters is called. + (r"^Ni0\.Biso$", []), + ], +) +def test_iterpars_fullnames_are_relative_to_called_container( + pattern, + expected_name, +): + """Verify fullname matching is relative to the called container.""" + objs = _make_iterpars_tree() + ni0 = objs["ni0"] + + actual_name = [ + parameter.name + for parameter in ni0.iterate_over_parameters(pattern, fullnames=True) + ] + + assert actual_name == expected_name diff --git a/tests/test_profile.py b/tests/test_profile.py index fa65d09d..54b47d20 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -22,6 +22,7 @@ from numpy import allclose, arange, array, array_equal, ones_like from diffpy.srfit.exceptions import SrFitError +from diffpy.srfit.fitbase import FitContribution, ProfileParser from diffpy.srfit.fitbase.profile import Profile @@ -43,8 +44,44 @@ def testInit(self): self.assertEqual(profile.meta, {}) return + def test_set_observed_profile(self): + """Test the set_observed_profile method.""" + # Make a profile with defined dy + + x = arange(0, 10, 0.1) + y = x + dy = x + + prof = self.profile + prof.set_observed_profile(x, y, dy) + + self.assertTrue(array_equal(x, prof.xobs)) + self.assertTrue(array_equal(y, prof.yobs)) + self.assertTrue(array_equal(dy, prof.dyobs)) + + # Make a profile with undefined dy + x = arange(0, 10, 0.1) + y = x + dy = None + + self.profile.set_observed_profile(x, y, dy) + + self.assertTrue(array_equal(x, prof.xobs)) + self.assertTrue(array_equal(y, prof.yobs)) + self.assertTrue(prof.dyobs is None) + + # Get the ranged profile to make sure its the same + self.assertTrue(array_equal(x, prof.x)) + self.assertTrue(array_equal(y, prof.y)) + self.assertTrue(array_equal(ones_like(prof.xobs), prof.dy)) + + return + def testSetObservedProfile(self): - """Test the setObservedProfile method.""" + """Test the deprecated setObservedProfile method. + + Remove this test when setObservedProfile is removed in 4.0.0. + """ # Make a profile with defined dy x = arange(0, 10, 0.1) @@ -52,7 +89,7 @@ def testSetObservedProfile(self): dy = x prof = self.profile - prof.setObservedProfile(x, y, dy) + prof.set_observed_profile(x, y, dy) self.assertTrue(array_equal(x, prof.xobs)) self.assertTrue(array_equal(y, prof.yobs)) @@ -67,7 +104,7 @@ def testSetObservedProfile(self): self.assertTrue(array_equal(x, prof.xobs)) self.assertTrue(array_equal(y, prof.yobs)) - self.assertTrue(array_equal(ones_like(prof.xobs), prof.dyobs)) + self.assertTrue(prof.dyobs is None) # Get the ranged profile to make sure its the same self.assertTrue(array_equal(x, prof.x)) @@ -76,90 +113,134 @@ def testSetObservedProfile(self): return - def testSetCalculationRange(self): - """Test the setCalculationRange method.""" + def test_set_calculation_range(self): + """Test the set_calculation_range method.""" x = arange(2, 9.6, 0.5) y = array(x) dy = array(x) prof = self.profile # Check call before data arrays are present - self.assertRaises(AttributeError, prof.setCalculationRange) - self.assertRaises(AttributeError, prof.setCalculationRange, 0) - self.assertRaises(AttributeError, prof.setCalculationRange, 0, 5) - self.assertRaises(AttributeError, prof.setCalculationRange, 0, 5, 0.2) + self.assertRaises(AttributeError, prof.set_calculation_range) + self.assertRaises(AttributeError, prof.set_calculation_range, 0) + self.assertRaises(AttributeError, prof.set_calculation_range, 0, 5) + self.assertRaises( + AttributeError, prof.set_calculation_range, 0, 5, 0.2 + ) # assign data - prof.setObservedProfile(x, y, dy) + prof.set_observed_profile(x, y, dy) # Test normal execution w/o arguments self.assertTrue(array_equal(x, prof.x)) self.assertTrue(array_equal(y, prof.y)) self.assertTrue(array_equal(dy, prof.dy)) - prof.setCalculationRange() + prof.set_calculation_range() self.assertTrue(array_equal(x, prof.x)) self.assertTrue(array_equal(y, prof.y)) self.assertTrue(array_equal(dy, prof.dy)) # Test a lower bound < xmin - prof.setCalculationRange(xmin=0) + prof.set_calculation_range(xmin=0) self.assertTrue(array_equal(x, prof.x)) self.assertTrue(array_equal(y, prof.y)) self.assertTrue(array_equal(dy, prof.dy)) # Test an upper bound > xmax - prof.setCalculationRange(xmax=100) + prof.set_calculation_range(xmax=100) self.assertTrue(array_equal(x, prof.x)) self.assertTrue(array_equal(y, prof.y)) self.assertTrue(array_equal(dy, prof.dy)) # Test xmin > xmax self.assertRaises( - ValueError, prof.setCalculationRange, xmin=10, xmax=3 + ValueError, prof.set_calculation_range, xmin=10, xmax=3 ) # Test xmax - xmin < dx self.assertRaises( - ValueError, prof.setCalculationRange, xmin=3, xmax=3.9, dx=1.0 + ValueError, prof.set_calculation_range, xmin=3, xmax=3.9, dx=1.0 ) # Test dx <= 0 - self.assertRaises(ValueError, prof.setCalculationRange, dx=0) - self.assertRaises(ValueError, prof.setCalculationRange, dx=-0.000001) + self.assertRaises(ValueError, prof.set_calculation_range, dx=0) + self.assertRaises(ValueError, prof.set_calculation_range, dx=-0.000001) # using string other than 'obs' - self.assertRaises(ValueError, prof.setCalculationRange, xmin="oobs") - self.assertRaises(ValueError, prof.setCalculationRange, xmax="oobs") - self.assertRaises(ValueError, prof.setCalculationRange, dx="oobs") + self.assertRaises(ValueError, prof.set_calculation_range, xmin="oobs") + self.assertRaises(ValueError, prof.set_calculation_range, xmax="oobs") + self.assertRaises(ValueError, prof.set_calculation_range, dx="oobs") # This should be alright - prof.setCalculationRange(3, 5) - prof.setCalculationRange(xmin="obs", xmax=7, dx=0.001) + prof.set_calculation_range(3, 5) + prof.set_calculation_range(xmin="obs", xmax=7, dx=0.001) self.assertEqual(5001, len(prof.x)) self.assertEqual(len(prof.x), len(prof.y)) self.assertEqual(len(prof.x), len(prof.dy)) # Test an internal bound - prof.setCalculationRange(4, 7, dx="obs") + prof.set_calculation_range(4, 7, dx="obs") self.assertTrue(array_equal(prof.x, arange(4, 7.1, 0.5))) self.assertTrue(array_equal(prof.y, arange(4, 7.1, 0.5))) self.assertTrue(array_equal(prof.y, arange(4, 7.1, 0.5))) # test setting only one of the bounds - prof.setCalculationRange(xmin=3) + prof.set_calculation_range(xmin=3) self.assertTrue(array_equal(prof.x, arange(3, 7.1, 0.5))) self.assertTrue(array_equal(prof.x, prof.y)) self.assertTrue(array_equal(prof.x, prof.dy)) - prof.setCalculationRange(xmax=5.1) + prof.set_calculation_range(xmax=5.1) self.assertTrue(array_equal(prof.x, arange(3, 5.1, 0.5))) self.assertTrue(array_equal(prof.x, prof.y)) self.assertTrue(array_equal(prof.x, prof.dy)) - prof.setCalculationRange(dx=1) + prof.set_calculation_range(dx=1) self.assertTrue(array_equal(prof.x, arange(3, 5.1))) self.assertTrue(array_equal(prof.x, prof.y)) self.assertTrue(array_equal(prof.x, prof.dy)) # Test a new grid - prof.setCalculationRange(4.2, 7, 0.3) + prof.set_calculation_range(4.2, 7, 0.3) self.assertTrue(array_equal(prof.x, arange(4.2, 6.901, 0.3))) self.assertTrue(allclose(prof.x, prof.y)) self.assertTrue(allclose(prof.x, prof.dy)) - prof.setCalculationRange(xmin=4.2, xmax=6.001) + prof.set_calculation_range(xmin=4.2, xmax=6.001) self.assertTrue(array_equal(prof.x, arange(4.2, 6.001, 0.3))) # resample on a clipped grid - prof.setCalculationRange(dx=0.5) + prof.set_calculation_range(dx=0.5) self.assertTrue(array_equal(prof.x, arange(4.5, 6.1, 0.5))) return + def testSetCalculationRange(self): + """Test the deprecated setCalculationRange method. + + Remove this test when setCalculationRange is removed in 4.0.0. + """ + x = arange(2, 9.6, 0.5) + y = array(x) + dy = array(x) + prof = self.profile + prof.set_observed_profile(x, y, dy) + # Test normal execution w/o arguments + self.assertTrue(array_equal(x, prof.x)) + self.assertTrue(array_equal(y, prof.y)) + self.assertTrue(array_equal(dy, prof.dy)) + prof.setCalculationRange() + self.assertTrue(array_equal(x, prof.x)) + self.assertTrue(array_equal(y, prof.y)) + self.assertTrue(array_equal(dy, prof.dy)) + return + + def test_set_calculation_points(self): + """Test the set_calculation_points method.""" + prof = self.profile + + x = arange(2, 10.5, 0.5) + y = array(x) + dy = array(x) + + # Test without data + xcalc = arange(3, 12.2, 0.2) + prof.set_calculation_points(xcalc) + self.assertTrue(array_equal(xcalc, prof.x)) + + # Add the data. This should change the bounds of the calculation array. + prof.set_observed_profile(x, y, dy) + self.assertTrue(array_equal(arange(3, 10.1, 0.2), prof.x)) + + return + def testSetCalculationPoints(self): - """Test the setCalculationPoints method.""" + """Test the deprecated setCalculationPoints method. + + Remove this test when setCalculationPoints is removed in 4.0.0. + """ prof = self.profile x = arange(2, 10.5, 0.5) @@ -172,7 +253,7 @@ def testSetCalculationPoints(self): self.assertTrue(array_equal(xcalc, prof.x)) # Add the data. This should change the bounds of the calculation array. - prof.setObservedProfile(x, y, dy) + prof.set_observed_profile(x, y, dy) self.assertTrue(array_equal(arange(3, 10.1, 0.2), prof.x)) return @@ -183,7 +264,7 @@ def test_savetxt(self): self.assertRaises(SrFitError, prof.savetxt, "foo") xobs = arange(-2, 3.01, 0.25) yobs = xobs**2 - prof.setObservedProfile(xobs, yobs) + prof.set_observed_profile(xobs, yobs) prof.ycalc = yobs.copy() fp = io.BytesIO() prof.savetxt(fp) @@ -226,5 +307,77 @@ def _test(p): return +# The parsed x, y and dy arrays are copied onto the observed profile. +# Uncertainties on x are dropped, since srfit treats the independent +# variable as having no uncertainty. +@pytest.mark.parametrize( + "input_filename, expected_xobs, expected_yobs, expected_dyobs", + [ + # C1: File has four columns, so uncertainties are present. + # Expected: xobs, yobs and dyobs are loaded and dx is dropped. + ( + "four_col.gr", + [1.0, 1.1, 1.2], + [2.0, 2.1, 2.2], + [0.2, 0.4, 0.6], + ), + # C3: File has three columns, so uncertainties are present. + # Expected: xobs, yobs and dyobs are loaded. + ( + "three_col.dat", + [1.0, 1.1, 1.2], + [2.0, 2.1, 2.2], + [0.2, 0.4, 0.6], + ), + # C2: File has two columns, so no uncertainties are present. + # Expected: xobs and yobs are loaded and dyobs stays None, + # marking the profile as unweighted. + ( + "two_col.txt", + [1.0, 1.1, 1.2], + [2.0, 2.1, 2.2], + None, + ), + ], +) +def test_load_parsed_data( + as_list, + parser_datafiles, + input_filename, + expected_xobs, + expected_yobs, + expected_dyobs, +): + """Load a parsed profile onto the observed arrays.""" + parser = ProfileParser() + parser.parse_file(parser_datafiles / input_filename) + prof = Profile() + prof.load_parsed_data(parser) + actual_xobs = prof.xobs.tolist() + actual_yobs = prof.yobs.tolist() + # Unavailable uncertainties are None rather than an array. + actual_dyobs = as_list(prof.dyobs) + assert actual_xobs == expected_xobs + assert actual_yobs == expected_yobs + assert actual_dyobs == expected_dyobs + + +# Case: A profile is set without uncertainties, so dyobs is None, and a +# residual is computed from the equation y = A*x with A = 2. +# Expected: The residual is (A*x - y) with unit weights, since missing +# uncertainties default to ones rather than being used as divisors. +def test_residual_without_uncertainties(): + """Compute the residual for a profile with no uncertainties.""" + prof = Profile() + prof.set_observed_profile(array([1.0, 2.0, 3.0]), array([1.0, 2.0, 3.0])) + contribution = FitContribution("test") + contribution.set_profile(prof) + contribution.set_equation("A*x") + contribution.A.set_value(2) + actual_residual = contribution.residual().tolist() + expected_residual = [1.0, 2.0, 3.0] + assert actual_residual == expected_residual + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_profilegenerator.py b/tests/test_profilegenerator.py index 14612a93..e4ea0fa3 100644 --- a/tests/test_profilegenerator.py +++ b/tests/test_profilegenerator.py @@ -29,7 +29,7 @@ def setUp(self): self.gen = ProfileGenerator("test") self.profile = Profile() x = arange(0, 10, 0.1) - self.profile.setCalculationPoints(x) + self.profile.set_calculation_points(x) self.gen.set_profile(self.profile) return @@ -48,14 +48,15 @@ def testOperation(self): return def testUpdate(self): - """Update and change the profile to make sure generator is flushed.""" + """Update and change the profile to make sure generator is + flushed.""" gen = self.gen prof = self.profile # Make sure attributes get updated with a change in the calculation # points. x = arange(0, 9, 0.1) - prof.setCalculationPoints(x) + prof.set_calculation_points(x) self.assertTrue(gen._value is None) val = gen.value self.assertTrue(array_equal(x, val)) @@ -68,7 +69,7 @@ def testUpdate(self): # Make sure attributes get updated with a new profile. x = arange(0, 8, 0.1) prof = Profile() - prof.setCalculationPoints(x) + prof.set_calculation_points(x) gen.set_profile(prof) self.assertTrue(gen._value is None) self.assertTrue(array_equal(x, gen.value)) diff --git a/tests/test_profileparser.py b/tests/test_profileparser.py new file mode 100644 index 00000000..008482bb --- /dev/null +++ b/tests/test_profileparser.py @@ -0,0 +1,444 @@ +import re +from pathlib import Path + +import pytest + +from diffpy.srfit.exceptions import ParseError +from diffpy.srfit.fitbase.profileparser import ProfileParser + +EXPECTED_META = { + "wavelength": 0.1, + "dataformat": "QA", + "inputfile": "input.iq", + "backgroundfile": "backgroundfile.iq", + "mode": "xray", + "bgscale": 1.0, + "composition": "TiSe2", + "outputtype": "gr", + "qmaxinst": 25.0, + "qmin": 0.1, + "qmax": 25.0, + "rmax": 140.0, + "rmin": 0.0, + "rstep": 0.01, + "rpoly": 0.7, + "inputdir": "/my/data/dir", + "savedir": "/my/save/dir", + "backgroundfilefull": "/my/data/dir/backgroundfile.iq", + "nbanks": 1, + "bank": 0, +} + + +@pytest.mark.parametrize( + "input_file, column_order, expected_x, " + "expected_y, expected_dx, expected_dy", + [ + # UC1: 4-column file (x, y, dx, dy) — all columns present + # expected: x, y, dx, dy, and metadata are all read correctly + ( + Path("four_col.gr"), + None, + [1.0, 1.1, 1.2], + [2.0, 2.1, 2.2], + [0.1, 0.3, 0.5], + [0.2, 0.4, 0.6], + ), + # UC2: 3-column file (x, y, dy) — dx is missing + # expected: x, y, dy, and metadata are all read correctly + ( + Path("three_col.dat"), + None, + [1.0, 1.1, 1.2], + [2.0, 2.1, 2.2], + None, + [0.2, 0.4, 0.6], + ), + # UC3: 2-column file (x, y) — dx and dy are missing + # expected: x, y, and metadata are all read correctly + ( + Path("two_col.txt"), + None, + [1.0, 1.1, 1.2], + [2.0, 2.1, 2.2], + None, + None, + ), + # UC4: 4-column file in (x, dx, y, dy) order with explicit + # column_format + # expected: x, y, dx, dy, and metadata are all read correctly + ( + Path("four_col_reordered.txt"), + ("x", "dx", "y", "dy"), + [1.0, 1.1, 1.2], + [2.0, 2.1, 2.2], + [0.1, 0.3, 0.5], + [0.2, 0.4, 0.6], + ), + # UC5: 4-column file where dx/dy contain NaN and inf values + # expected: x, y, and metadata are read correctly; dx and dy + # are set to None + ( + Path("four_col_nan_inf.gr"), + None, + [1.0, 1.1, 1.2], + [2.0, 2.1, 2.2], + None, + None, + ), + ], +) +def test_parse_file( + as_list, + parser_datafiles, + input_file, + column_order, + expected_x, + expected_y, + expected_dx, + expected_dy, +): + parser = ProfileParser() + parser.parse_file(parser_datafiles / input_file, column_order) + actual_x = parser._x.tolist() + actual_y = parser._y.tolist() + actual_dx = as_list(parser._dx) + actual_dy = as_list(parser._dy) + actual_metadata = parser._meta + actual_metadata["filename"] = actual_metadata["filename"].split("/")[-1] + + EXPECTED_META["filename"] = str(input_file).split("/")[-1] + assert actual_x == expected_x + assert actual_y == expected_y + assert actual_dx == expected_dx + assert actual_dy == expected_dy + assert actual_metadata == EXPECTED_META + + +@pytest.mark.parametrize( + "input_file, column_order, msg", + [ + # UC6: Only one column — cannot form x/y pair + # expected: ParseError is raised + ( + "one_col.gr", + None, + "Data block must have at least two columns (x, y).", + ), + # UC7: Five columns — ambiguous, no mapping defined + # expected: ParseError is raised + ("five_col.gr", None, "Expected 2 to 4 columns but found 5."), + # UC8: 3-column file but column_format expects 4 columns + # expected: ParseError is raised + ( + "three_col.dat", + ("x", "y", "dx", "dy"), + "column_format has 4 labels but file contains 3 columns.", + ), + # UC9: 4-column file but column_format expects 5 columns + # expected: ParseError is raised + ( + "four_col.gr", + ("x", "y", "dx", "dy", "extra"), + "column_format has 5 labels but file contains 4 columns.", + ), + # UC10: 4-column file but column_format expects only 3 columns + # expected: ParseError is raised + ( + "four_col.gr", + ("x", "y", "dy"), + "column_format has 3 labels but file contains 4 columns.", + ), + # UC11: column_format contains duplicate column names + # expected: ParseError is raised + ( + "four_col.gr", + ("x", "x", "dx", "dy"), + "column_format cannot contain duplicate labels.", + ), + # UC12: column_format contains invalid column names + ( + "four_col.gr", + ("x", "y", "dx", "invalid"), + "column_format contains invalid label 'invalid'. " + "Valid labels are 'x', 'y', 'dx', and 'dy'.", + ), + ], +) +def test_parse_file_bad(parser_datafiles, input_file, column_order, msg): + parser = ProfileParser() + with pytest.raises(ParseError, match=re.escape(msg)): + parser.parse_file(parser_datafiles / input_file, column_order) + + +# ProfileParser is an extension point. A subclass customizes a format by +# overriding the _parse_metadata and _parse_data hooks; parse_file itself +# is a template method that subclasses are not expected to touch. The +# tests below pin that contract so a future refactor cannot quietly turn +# parse_file back into a concrete parser. + + +class MetadataOnlyParser(ProfileParser): + """A parser that customizes only the metadata, as PDFParser does.""" + + _format = "metadata-only" + + def _parse_metadata(self, filename): + return {"instrument": "custom"} + + +def test_parse_file_uses_metadata_hook(parser_datafiles): + """Overriding _parse_metadata keeps the inherited column + handling.""" + # Case: a subclass overrides _parse_metadata to customize the metadata, + # but does not override _parse_data. + # Expected: The subclass's metadata parser is used, + # but the data columns are still read correctly. + parser = MetadataOnlyParser() + parser.parse_file(parser_datafiles / "four_col.gr") + + actual_format = parser.get_format() + expected_format = "metadata-only" + assert actual_format == expected_format + + # The custom hook replaces the generic header scan entirely, so the + # name = value pairs the default would have collected are absent. + actual_metadata = parser.get_metadata() + assert actual_metadata["instrument"] == "custom" + assert "wavelength" not in actual_metadata + + actual_x, actual_y, actual_dx, actual_dy = parser.get_data() + expected_x = [1.0, 1.1, 1.2] + expected_y = [2.0, 2.1, 2.2] + expected_dx = [0.1, 0.3, 0.5] + expected_dy = [0.2, 0.4, 0.6] + assert actual_x.tolist() == expected_x + assert actual_y.tolist() == expected_y + assert actual_dx.tolist() == expected_dx + assert actual_dy.tolist() == expected_dy + + +class TwoBankParser(ProfileParser): + """A parser that customizes only the data, adding a second bank.""" + + def _parse_data(self, filename, column_format=None, **kwargs): + super()._parse_data(filename, column_format, **kwargs) + input_x, input_y, input_dx, input_dy = self._banks[0] + self._banks.append([input_x, 2 * input_y, input_dx, input_dy]) + + +def test_parse_file_uses_data_hook(parser_datafiles): + """Overriding _parse_data may contribute several banks.""" + # Case: a subclass overrides _parse_data to customize the data, + # but does not override _parse_metadata. + # Expected: The subclass's data parser is used, and the + # metadata is still read correctly. + parser = TwoBankParser() + parser.parse_file(parser_datafiles / "four_col.gr") + + actual_bank_count = parser.get_num_banks() + expected_bank_count = 2 + assert actual_bank_count == expected_bank_count + + actual_reported_bank_count = parser.get_metadata()["nbanks"] + assert actual_reported_bank_count == expected_bank_count + + actual_second_bank_y = parser.get_data(1)[1].tolist() + expected_second_bank_y = [4.0, 4.2, 4.4] + assert actual_second_bank_y == expected_second_bank_y + + +def test_parse_file_deprecated_warns_about_profileparser(parser_datafiles): + """The shared parseFile reports its own name, not a subclass one. + + parseFile is inherited by every parser, so a message naming a + specific subclass would misdirect users of the others. + """ + # Case: parseFile is called on a subclass of ProfileParser. + # Expected: A DeprecationWarning is raised, and the message mentions + parser = ProfileParser() + expected_msg = ( + "'diffpy.srfit.fitbase.profileparser.ProfileParser.parseFile' is " + "deprecated and will be removed in version 4.0.0. Please use " + "'diffpy.srfit.fitbase.profileparser.ProfileParser.parse_file' " + "instead." + ) + with pytest.warns( + DeprecationWarning, + match=re.escape(expected_msg), + ): + parser.parseFile(parser_datafiles / "two_col.txt") + + actual_x = parser.get_data()[0].tolist() + expected_x = [1.0, 1.1, 1.2] + assert actual_x == expected_x + + +# parse_file accepts an optional `metadata` dict, merged into the parsed +# metadata after the file header is read. This lets a caller attach +# information that is not present in the file itself (e.g. sample name), +# without having to edit `parser.get_metadata()` by hand after parsing. + + +@pytest.mark.parametrize( + "input_metadata, expected_added_metadata", + [ + # UC13: Supplied keys are not present in the file header + # Expected: the supplied keys/values are added to the parsed + # metadata, alongside the keys read from the file + ( + {"operator": "jdoe", "sample": "LaB6"}, + {"operator": "jdoe", "sample": "LaB6"}, + ), + # UC14: A supplied key overlaps with one already parsed from the + # file header + # Expected: the supplied value overrides the value parsed from + # the file + ( + {"wavelength": 99.9}, + {"wavelength": 99.9}, + ), + ], +) +def test_parse_file_appends_metadata( + parser_datafiles, input_metadata, expected_added_metadata +): + parser = ProfileParser() + parser.parse_file( + parser_datafiles / "four_col.gr", metadata=input_metadata + ) + + actual_metadata = parser.get_metadata() + for key, expected_value in expected_added_metadata.items(): + assert actual_metadata[key] == expected_value + + +@pytest.mark.parametrize( + "bad_metadata_input, expected_msg", + [ + # UC15: The user supplies a metadata dict with a key that is not + # a string. + # Expected: A ParseError is raised with a message indicating that + # all keys must be strings. + ( + {"operator": "jdoe", 42: "LaB6"}, + "Key '42' in the metadata dictionary " + "is not a string. All keys in the metadata " + "dictionary must be strings.", + ), + # UC16: User supplies metadata not in the form of a dictionary. + # Expected: A ParseError is raised with a message indicating that + # the metadata must be a dictionary. + ( + ["operator", "jdoe"], + "The metadata argument must be a dictionary. " + "Received type 'list' instead.", + ), + ], +) +def test_parse_file_appends_metadata_bad( + parser_datafiles, bad_metadata_input, expected_msg +): + parser = ProfileParser() + with pytest.raises(ParseError, match=re.escape(expected_msg)): + parser.parse_file( + parser_datafiles / "four_col.gr", metadata=bad_metadata_input + ) + + +@pytest.mark.parametrize( + "input_metadata, reserved_key, expected_value, expected_msg", + [ + # UC17: Supplied metadata overlaps with the "filename" key, which + # parse_file normally sets itself from the file argument. + # Expected: A UserWarning is raised naming the reserved key, and + # the supplied value overrides the automatically parsed one. + ( + {"filename": "spoofed.dat"}, + "filename", + "spoofed.dat", + "'filename' is a reserved metadata key normally set by " + "parse_file. The supplied value will override it.", + ), + # UC18: Supplied metadata overlaps with the "bank" key, which + # parse_file normally sets from select_bank. + # Expected: A UserWarning is raised naming the reserved key, and + # the supplied value overrides the automatically parsed one. + ( + {"bank": 5}, + "bank", + 5, + "'bank' is a reserved metadata key normally set by " + "parse_file. The supplied value will override it.", + ), + # UC19: Supplied metadata overlaps with the "nbanks" key, which + # parse_file normally sets from the number of banks read. + # Expected: A UserWarning is raised naming the reserved key, and + # the supplied value overrides the automatically parsed one. + ( + {"nbanks": 99}, + "nbanks", + 99, + "'nbanks' is a reserved metadata key normally set by " + "parse_file. The supplied value will override it.", + ), + ], +) +def test_parse_file_appends_metadata_warns_on_reserved_key( + parser_datafiles, + input_metadata, + reserved_key, + expected_value, + expected_msg, +): + parser = ProfileParser() + with pytest.warns(UserWarning, match=re.escape(expected_msg)): + parser.parse_file( + parser_datafiles / "four_col.gr", metadata=input_metadata + ) + + actual_metadata = parser.get_metadata() + actual_value = actual_metadata[reserved_key] + assert actual_value == expected_value + + +def test_parse_file_appends_metadata_does_not_mutate_input(parser_datafiles): + # Case: the caller's metadata dict is mutated after parse_file returns. + # Expected: the parser's own metadata is unaffected, so parse_file must + # have copied the dict rather than stored a reference to it. + parser = ProfileParser() + input_metadata = {"operator": "jdoe"} + parser.parse_file( + parser_datafiles / "four_col.gr", metadata=input_metadata + ) + + input_metadata["operator"] = "mutated" + input_metadata["sample"] = "added after parsing" + + actual_metadata = parser.get_metadata() + assert actual_metadata["operator"] == "jdoe" + assert "sample" not in actual_metadata + + +def test_parse_file_usecols_selects_columns(parser_datafiles): + """A file wider than four columns is read by selecting columns. + + column_format has to label every column that is loaded, so a wider + file is narrowed first with the load_data usecols argument. + """ + # Case: A file with five columns is loaded, but only the first two are + # used. + # Expected: The first two columns are read as x and y, and dx and dy are + # None. + parser = ProfileParser() + parser.parse_file( + parser_datafiles / "five_col.gr", ("x", "y"), usecols=(0, 1) + ) + + actual_x, actual_y, actual_dx, actual_dy = parser.get_data() + expected_x = [1.0, 1.1, 1.2] + expected_y = [2.0, 2.1, 2.2] + assert actual_x.tolist() == expected_x + assert actual_y.tolist() == expected_y + assert actual_dx is None + assert actual_dy is None diff --git a/tests/test_recipeorganizer.py b/tests/test_recipeorganizer.py index 7138f45e..ac00dad5 100644 --- a/tests/test_recipeorganizer.py +++ b/tests/test_recipeorganizer.py @@ -15,8 +15,10 @@ """Tests for refinableobj module.""" import unittest +import warnings import numpy +import pytest from diffpy.srfit.equation.builder import EquationFactory from diffpy.srfit.fitbase.calculator import Calculator @@ -25,15 +27,17 @@ RecipeContainer, RecipeOrganizer, equationFromString, + get_equation_from_string, ) +from diffpy.utils._deprecator import deprecated # ---------------------------------------------------------------------------- class TestEquationFromString(unittest.TestCase): - def testEquationFromString(self): - """Test the equationFromString method.""" + def test_get_equation_from_string(self): + """Test the get_equation_from_string method.""" p1 = Parameter("p1", 1) p2 = Parameter("p2", 2) p3 = Parameter("p3", 3) @@ -45,7 +49,7 @@ def testEquationFromString(self): factory.registerArgument("p2", p2) # Check usage where all parameters are registered with the factory - eq = equationFromString("p1+p2", factory) + eq = get_equation_from_string("p1+p2", factory) self.assertEqual(2, len(eq.args)) self.assertTrue(p1 in eq.args) @@ -53,7 +57,9 @@ def testEquationFromString(self): self.assertEqual(3, eq()) # Try to use a parameter that is not registered - self.assertRaises(ValueError, equationFromString, "p1+p2+p3", factory) + self.assertRaises( + ValueError, get_equation_from_string, "p1+p2+p3", factory + ) # Pass that argument in the ns dictionary eq = equationFromString("p1+p2+p3", factory, {"p3": p3}) @@ -68,12 +74,16 @@ def testEquationFromString(self): # Pass and use an unregistered parameter self.assertRaises( - ValueError, equationFromString, "p1+p2+p3+p4", factory, {"p3": p3} + ValueError, + get_equation_from_string, + "p1+p2+p3+p4", + factory, + {"p3": p3}, ) # Try to overload a registered parameter self.assertRaises( - ValueError, equationFromString, "p1+p2", factory, {"p2": p4} + ValueError, get_equation_from_string, "p1+p2", factory, {"p2": p4} ) return @@ -255,7 +265,7 @@ def testRemoveParameter(self): self.assertRaises(ValueError, m._remove_parameter, c) return - def testConstrain(self): + def test_constrain_parameter(self): """Test the constrain method.""" p1 = self.m._new_parameter("p1", 1) p2 = self.m._new_parameter("p2", 2) @@ -263,14 +273,26 @@ def testConstrain(self): self.assertFalse(p1.constrained) self.assertEqual(0, len(self.m._constraints)) - self.m.constrain(p1, "2*p2") + self.m.add_constraint(p1, "2*p2") + + actual_constrained_params = self.m.getConstrainedPars() + actual_constrained_params = [p.name for p in actual_constrained_params] + expected_constrained_params = [p1.name] + + assert actual_constrained_params == expected_constrained_params + + actual_constrained_params = self.m.get_constrained_parmeters() + actual_constrained_params = [p.name for p in actual_constrained_params] + expected_constrained_params = [p1.name] + + assert actual_constrained_params == expected_constrained_params self.assertTrue(p1.constrained) self.assertTrue(p1 in self.m._constraints) self.assertEqual(1, len(self.m._constraints)) - self.assertTrue(self.m.isConstrained(p1)) + self.assertTrue(self.m.is_constrained(p1)) - p2.setValue(10) + p2.set_value(10) self.m._constraints[p1].update() self.assertEqual(20, p1.getValue()) @@ -279,19 +301,39 @@ def testConstrain(self): self.assertRaises(ValueError, self.m.constrain, p1, "2*p2", {"p2": p3}) # Remove the constraint - self.m.unconstrain(p1) + self.m.remove_constraint(p1) self.assertFalse(p1.constrained) self.assertEqual(0, len(self.m._constraints)) self.assertFalse(self.m.isConstrained(p1)) # Try an straight constraint - self.m.constrain(p1, p2) - p2.setValue(7) + self.m.add_constraint(p1, p2) + p2.set_value(7) self.m._constraints[p1].update() self.assertEqual(7, p1.getValue()) + + self.m.clear_all_constraints() + actual_constrained_params = self.m.get_constrained_parmeters() + actual_constrained_params = [p.name for p in actual_constrained_params] + expected_constrained_params = [] + assert actual_constrained_params == expected_constrained_params + + # add constraint back and test the old function name `clearConstraints` + self.m.add_constraint(p1, p2) + actual_constrained_params = self.m.get_constrained_parmeters() + actual_constrained_params = [p.name for p in actual_constrained_params] + expected_constrained_params = [p1.name] + assert actual_constrained_params == expected_constrained_params + + self.m.clearConstraints() + actual_constrained_params = self.m.get_constrained_parmeters() + actual_constrained_params = [p.name for p in actual_constrained_params] + expected_constrained_params = [] + assert actual_constrained_params == expected_constrained_params + return - def testRestrain(self): + def test_add_restraint(self): """Test the restrain method.""" p1 = Parameter("p1", 1) p2 = Parameter("p2", 2) @@ -300,16 +342,16 @@ def testRestrain(self): self.m._eqfactory.registerArgument("p2", p2) self.assertEqual(0, len(self.m._restraints)) - r = self.m.restrain("p1+p2", ub=10) + r = self.m.add_soft_bounds("p1+p2", upper_bound=10) self.assertEqual(1, len(self.m._restraints)) - p2.setValue(10) + p2.set_value(10) self.assertEqual(1, r.penalty()) - self.m.unrestrain(r) + self.m.remove_soft_bounds(r) self.assertEqual(0, len(self.m._restraints)) r = self.m.restrain(p1, ub=10) self.assertEqual(1, len(self.m._restraints)) - p1.setValue(11) + p1.set_value(11) self.assertEqual(1, r.penalty()) # Check errors on unregistered parameters @@ -335,8 +377,8 @@ def testGetConstraints(self): m2._add_parameter(p3) m2._add_parameter(p4) - self.m.constrain(p1, "p2") - m2.constrain(p3, "p4") + self.m.add_constraint(p1, "p2") + m2.add_constraint(p3, "p4") cons = self.m._get_constraints() self.assertTrue(p1 in cons) @@ -362,8 +404,8 @@ def testGetRestraints(self): m2._add_parameter(p3) m2._add_parameter(p4) - r1 = self.m.restrain("p1 + p2") - r2 = m2.restrain("2*p3 + p4") + r1 = self.m.add_soft_bounds("p1 + p2") + r2 = m2.add_soft_bounds("2*p3 + p4") res = self.m._get_restraints() self.assertTrue(r1 in res) @@ -371,6 +413,51 @@ def testGetRestraints(self): self.assertEqual(2, len(res)) return + def test_register_calculator(self): + + class GCalc(Calculator): + + def __init__(self, name): + Calculator.__init__(self, name) + self.newParameter("A", 1.0) + self.newParameter("center", 0.0) + self.newParameter("width", 0.1) + return + + def __call__(self, x): + A = self.A.getValue() + c = self.center.getValue() + w = self.width.getValue() + return A * numpy.exp(-0.5 * ((x - c) / w) ** 2) + + # End class GCalc + + g = GCalc("g") + + self.m.register_calculator(g) + + x = numpy.arange(0.5, 10, 0.5) + self.m.x.set_value(x) + + self.m.g.center.set_value(3.0) + + self.assertTrue( + numpy.array_equal(numpy.exp(-0.5 * ((x - 3.0) / 0.1) ** 2), g(x)) + ) + + self.m.g.center.set_value(5.0) + + self.assertTrue( + numpy.array_equal(numpy.exp(-0.5 * ((x - 5.0) / 0.1) ** 2), g(x)) + ) + + # Use this in another equation + + eq = self.m.register_string_function("g/x - 1", "pdf") + self.assertTrue(numpy.array_equal(g(x) / x - 1, eq())) + + return + def testRegisterCalculator(self): class GCalc(Calculator): @@ -395,15 +482,15 @@ def __call__(self, x): self.m.registerCalculator(g) x = numpy.arange(0.5, 10, 0.5) - self.m.x.setValue(x) + self.m.x.set_value(x) - self.m.g.center.setValue(3.0) + self.m.g.center.set_value(3.0) self.assertTrue( numpy.array_equal(numpy.exp(-0.5 * ((x - 3.0) / 0.1) ** 2), g(x)) ) - self.m.g.center.setValue(5.0) + self.m.g.center.set_value(5.0) self.assertTrue( numpy.array_equal(numpy.exp(-0.5 * ((x - 5.0) / 0.1) ** 2), g(x)) @@ -411,7 +498,7 @@ def __call__(self, x): # Use this in another equation - eq = self.m.registerStringFunction("g/x - 1", "pdf") + eq = self.m.register_string_function("g/x - 1", "pdf") self.assertTrue(numpy.array_equal(g(x) / x - 1, eq())) return @@ -425,27 +512,27 @@ def g1(A, c, w, x): def g2(A): return A + 1 - eq = self.m.registerFunction(g1, "g") + eq = self.m.register_function(g1, "g") for p in eq.args: self.assertTrue(p in self.m._parameters.values()) x = numpy.arange(0.5, 10, 0.5) - self.m.x.setValue(x) - self.m.A.setValue(1.0) - self.m.c.setValue(3.0) - self.m.w.setValue(0.1) + self.m.x.set_value(x) + self.m.A.set_value(1.0) + self.m.c.set_value(3.0) + self.m.w.set_value(0.1) self.assertTrue( numpy.array_equal(numpy.exp(-0.5 * ((x - 3.0) / 0.1) ** 2), eq()) ) # Use this in another equation - eq2 = self.m.registerStringFunction("g/x - 1", "pdf") + eq2 = self.m.register_string_function("g/x - 1", "pdf") self.assertTrue(numpy.array_equal(eq() / x - 1, eq2())) # Make sure we can swap out "g". - self.m.registerFunction(g2, "g") + self.m.register_function(g2, "g") self.assertAlmostEqual(2.0, eq()) # Try a bound method @@ -457,7 +544,7 @@ def __call__(self): return 4.56 t = temp() - eq = self.m.registerFunction(t.eval, "eval") + eq = self.m.register_function(t.eval, "eval") self.assertAlmostEqual(1.23, eq()) # Now the callable @@ -466,11 +553,11 @@ def __call__(self): return - def testRegisterStringFunction(self): + def test_register_string_function(self): """Test registering string functions in various ways.""" # Make an equation. - eq1 = self.m.registerStringFunction("x**2 + 3", "eq1") - eq1.x.setValue(0) + eq1 = self.m.register_string_function("x**2 + 3", "eq1") + eq1.x.set_value(0) for p in eq1.args: self.assertTrue(p in self.m._parameters.values()) @@ -485,9 +572,9 @@ def testRegisterStringFunction(self): # Use eq1 in some equations # x**2 (x**2 + 3 - 3) - eq2 = self.m.registerStringFunction("eq1 - 3", "eq2") + eq2 = self.m.register_string_function("eq1 - 3", "eq2") # y**2 - eq3 = self.m.registerStringFunction("eq1(y) - 3", "eq3") + eq3 = self.m.register_string_function("eq1(y) - 3", "eq3") # Test these equations. self.assertEqual(0, eq2()) @@ -503,6 +590,13 @@ def testRegisterStringFunction(self): return + def test_release_old_equations(self): + """Verify EquationFactory does not hold temporary equations.""" + self.m._new_parameter("x", 12) + self.assertEqual(36, self.m.evaluate_equation("3 * x")) + self.assertEqual(0, len(self.m._eqfactory.equations)) + return + def test_releaseOldEquations(self): """Verify EquationFactory does not hold temporary equations.""" self.m._new_parameter("x", 12) @@ -532,14 +626,14 @@ def capture_show(*args, **kwargs): assert "Constraints" not in lines1 assert "Restraints" not in lines1 organizer._new_parameter("z", 7) - organizer.constrain("y", "3 * z") + organizer.add_constraint("y", "3 * z") out2 = capture_show() lines2 = out2.strip().split("\n") assert 9 == len(lines2) assert "Parameters" in lines2 assert "Constraints" in lines2 assert "Restraints" not in lines2 - organizer.restrain("z", lb=2, ub=3, sig=0.001) + organizer.add_soft_bounds("z", lower_bound=2, upper_bound=3, sig=0.001) out3 = capture_show() lines3 = out3.strip().split("\n") assert 13 == len(lines3) @@ -565,5 +659,41 @@ def capture_show(*args, **kwargs): # ---------------------------------------------------------------------------- +# `register_function` extracts the name and the argument names from the code +# object of the function it is given. A decorator such as `deprecated` +# replaces that code object with the one of its (*args, **kwargs) wrapper, so +# a decorated function must be introspected through the wrapper to give the +# same result as the undecorated function. + + +def _gaussian(A, c, w, x): + return A * numpy.exp(-0.5 * ((x - c) / w) ** 2) + + +@pytest.mark.parametrize( + "input_function", + [ + # C1: Plain undecorated function. + # Expected: The name and the argument names come from the function. + _gaussian, + # C2: The same function behind a deprecation decorator. + # Expected: The name and the argument names of the wrapped function, + # not those of the wrapper. + deprecated("'_gaussian' is deprecated.")(_gaussian), + ], +) +def test_register_function_introspects_through_a_decorator(input_function): + organizer = RecipeOrganizer("organizer") + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + equation = organizer.register_function(input_function) + actual_name = equation.name + expected_name = "_eq__gaussian" + actual_argnames = list(equation.argdict.keys()) + expected_argnames = ["A", "c", "w", "x"] + assert actual_name == expected_name + assert actual_argnames == expected_argnames + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_restraint.py b/tests/test_restraint.py index f225675a..93a4daff 100644 --- a/tests/test_restraint.py +++ b/tests/test_restraint.py @@ -18,7 +18,7 @@ from diffpy.srfit.equation.builder import EquationFactory from diffpy.srfit.fitbase.parameter import Parameter -from diffpy.srfit.fitbase.recipeorganizer import equationFromString +from diffpy.srfit.fitbase.recipeorganizer import get_equation_from_string from diffpy.srfit.fitbase.restraint import Restraint @@ -35,24 +35,24 @@ def testRestraint(self): factory.registerArgument("p2", p2) # Restrain 1 < p1 + p2 < 5 - eq = equationFromString("p1 + p2", factory) + eq = get_equation_from_string("p1 + p2", factory) r = Restraint(eq, 1, 5) # This should have no penalty - p1.setValue(1) - p2.setValue(1) + p1.set_value(1) + p2.set_value(1) self.assertEqual(0, r.penalty()) # Make p1 + p2 = 0 # This should have a penalty of 1*(1 - 0)**2 = 1 - p1.setValue(-1) - p2.setValue(1) + p1.set_value(-1) + p2.set_value(1) self.assertEqual(1, r.penalty()) # Make p1 + p2 = 8 # This should have a penalty of 1*(8 - 5)**2 = 9 - p1.setValue(4) - p2.setValue(4) + p1.set_value(4) + p2.set_value(4) self.assertEqual(9, r.penalty()) # Set the chi^2 to get a dynamic penalty @@ -62,8 +62,8 @@ def testRestraint(self): # Make a really large number to check the upper bound import numpy - r.ub = numpy.inf - p1.setValue(1e100) + r.upper_bound = numpy.inf + p1.set_value(1e100) self.assertEqual(0, r.penalty()) return diff --git a/tests/test_sas.py b/tests/test_sas.py index 59cb14c0..516d387a 100644 --- a/tests/test_sas.py +++ b/tests/test_sas.py @@ -30,8 +30,8 @@ def testParser(sas_available, datafile): data = datafile("sas_ascii_test_1.txt") parser = SASParser() - parser.parseFile(data) - x, y, dx, dy = parser.getData() + parser.parse_file(data) + x, y, dx, dy = parser.get_data() testx = numpy.array( [ 0.002618, @@ -123,10 +123,10 @@ def test_generator(sas_available): par = gen.get(pname) assert defval == par.getValue() # Test setting values - par.setValue(1.0) + par.set_value(1.0) assert 1.0 == par.getValue() assert 1.0 == model.getParam(pname) - par.setValue(defval) + par.set_value(defval) assert defval == par.getValue() assert defval == model.getParam(pname) diff --git a/tests/test_sgconstraints.py b/tests/test_sgconstraints.py index 32d6afe4..40319d76 100644 --- a/tests/test_sgconstraints.py +++ b/tests/test_sgconstraints.py @@ -14,6 +14,7 @@ ############################################################################## """Tests space group constraints.""" +import re import unittest import numpy @@ -87,36 +88,36 @@ def test_ObjCryst_constrain_space_group(pyobjcryst_available): # Make sure we can't constrain these with pytest.raises(ValueError): - mn.constrain(mn.x, "y") + mn.add_constraint(mn.x, "y") with pytest.raises(ValueError): - mn.constrain(mn.y, "z") + mn.add_constraint(mn.y, "z") with pytest.raises(ValueError): - mn.constrain(mn.z, "x") + mn.add_constraint(mn.z, "x") # Nor can we make them into variables from diffpy.srfit.fitbase.fitrecipe import FitRecipe f = FitRecipe() with pytest.raises(ValueError): - f.addVar(mn.x) + f.add_variable(mn.x) return def test_DiffPy_constrain_as_space_group(datafile, pyobjcryst_available): - """Test the constrainAsSpaceGroup function.""" + """Test the constrain_as_space_group function.""" if not pyobjcryst_available: pytest.skip("pyobjcrysta package not available") from diffpy.srfit.structure.diffpyparset import DiffpyStructureParSet - from diffpy.srfit.structure.sgconstraints import constrainAsSpaceGroup + from diffpy.srfit.structure.sgconstraints import constrain_as_space_group stru = makeLaMnO3_P1(datafile) parset = DiffpyStructureParSet("LaMnO3", stru) - sgpars = constrainAsSpaceGroup( + sgpars = constrain_as_space_group( parset, "P b n m", scatterers=parset.getScatterers()[::2], @@ -179,26 +180,63 @@ def _alltests(par): def test_constrain_as_space_group_args(pyobjcryst_available, datafile): - """Test the arguments processing of constrainAsSpaceGroup function.""" + """Test the arguments processing of constrain_as_space_group + function.""" if not pyobjcryst_available: pytest.skip("pyobjcrysta package not available") from diffpy.srfit.structure.diffpyparset import DiffpyStructureParSet - from diffpy.srfit.structure.sgconstraints import constrainAsSpaceGroup + from diffpy.srfit.structure.sgconstraints import constrain_as_space_group from diffpy.structure.spacegroups import GetSpaceGroup stru = makeLaMnO3_P1(datafile) parset = DiffpyStructureParSet("LaMnO3", stru) - sgpars = constrainAsSpaceGroup(parset, "P b n m") + sgpars = constrain_as_space_group(parset, "P b n m") sg = GetSpaceGroup("P b n m") parset2 = DiffpyStructureParSet("LMO", makeLaMnO3_P1(datafile)) - sgpars2 = constrainAsSpaceGroup(parset2, sg) + sgpars2 = constrain_as_space_group(parset2, sg) list(sgpars) list(sgpars2) assert sgpars.names == sgpars2.names return +# ---------------------------------------------------------------------------- +# constrainAsSpaceGroup is deprecated in favor of constrain_as_space_group. +# The old name must still work, emit a DeprecationWarning naming its +# replacement, and forward to the new implementation. + + +def test_constrainAsSpaceGroup_warns_and_forwards( + pyobjcryst_available, datafile +): + if not pyobjcryst_available: + pytest.skip("pyobjcrysta package not available") + + from diffpy.srfit.structure.diffpyparset import DiffpyStructureParSet + from diffpy.srfit.structure.sgconstraints import ( + constrain_as_space_group, + constrainAsSpaceGroup, + ) + + module_path = "diffpy.srfit.structure.sgconstraints" + expected_msg = ( + f"'{module_path}.constrainAsSpaceGroup' is deprecated and will be " + f"removed in version 4.0.0. Please use " + f"'{module_path}.constrain_as_space_group' instead." + ) + + parset = DiffpyStructureParSet("LaMnO3", makeLaMnO3_P1(datafile)) + with pytest.warns(DeprecationWarning, match=re.escape(expected_msg)): + actual_sgpars = constrainAsSpaceGroup(parset, "P b n m") + + parset2 = DiffpyStructureParSet("LMO", makeLaMnO3_P1(datafile)) + expected_sgpars = constrain_as_space_group(parset2, "P b n m") + + assert actual_sgpars.names == expected_sgpars.names + return + + def makeLaMnO3_P1(datafile): from diffpy.structure import Structure diff --git a/tests/test_speed.py b/tests/test_speed.py index 23ffa13b..a7d43b4a 100644 --- a/tests/test_speed.py +++ b/tests/test_speed.py @@ -17,14 +17,23 @@ import random import numpy +import pytest import diffpy.srfit.equation.literals as literals import diffpy.srfit.equation.visitors as visitors +pytestmark = pytest.mark.skip( + reason=( + "This is a performance benchmark test, " + "not a unit test. Comment out this line " + "to run performance testing." + ) +) + x = numpy.arange(0, 20, 0.05) -def makeLazyEquation(make_args): +def make_lazy_equation(make_args): """Make a lazy equation and see how fast it is.""" # Make some variables v1, v2, v3, v4, v5, v6, v7 = make_args(7) @@ -51,19 +60,19 @@ def makeLazyEquation(make_args): mult2.addLiteral(pow) mult2.addLiteral(exp) - v2.setValue(x) - v3.setValue(50 * x) - v5.setValue(2.11) - v6.setValue(numpy.e) + v2.set_value(x) + v3.set_value(50 * x) + v5.set_value(2.11) + v6.set_value(numpy.e) evaluator = visitors.Evaluator() def _f(a, b, c, d, e): - v1.setValue(a) - v4.setValue(b) - v5.setValue(c) - v6.setValue(d) - v7.setValue(e) + v1.set_value(a) + v4.set_value(b) + v5.set_value(c) + v6.set_value(d) + v7.set_value(e) mult2.identify(evaluator) evaluator.clicker.click() return evaluator.value @@ -71,7 +80,7 @@ def _f(a, b, c, d, e): return _f -def makeEquation1(): +def make_equation1(): """Make the same equation as the lazy one.""" y = 50 * x @@ -81,7 +90,7 @@ def _f(a, b, c, d, e): return _f -def timeFunction(f, *args, **kw): +def time_function(f, *args, **kw): """Time a function in ms.""" import time @@ -91,9 +100,9 @@ def timeFunction(f, *args, **kw): return (t2 - t1) * 1000 -def speedTest1(): - f1 = makeLazyEquation() - f2 = makeEquation1() +def test_speed1(): + f1 = make_lazy_equation() + f2 = make_equation1() args = [3.1, 8.19973123410, 2.1, numpy.e, numpy.pi] @@ -102,8 +111,8 @@ def speedTest1(): for i in range(len(args)): args[i] = 10 * random.random() print("Changing argument %i" % (i + 1)) - t1 = timeFunction(f1, *args) - t2 = timeFunction(f2, *args) + t1 = time_function(f1, *args) + t2 = time_function(f2, *args) total1 += t1 total2 += t2 print("lazy", t1) @@ -115,7 +124,7 @@ def speedTest1(): print("Ratio (lazy/regular)", total1 / total2) -def speedTest2(mutate=2): +def test_speed2(mutate=2): from diffpy.srfit.equation.builder import EquationFactory @@ -131,18 +140,18 @@ def speedTest2(mutate=2): """ factory.registerConstant("x", x) eq = factory.makeEquation(eqstr) - eq.qsig.setValue(qsig) - eq.sigma1.setValue(sigma) - eq.sigma2.setValue(sigma) - eq.A0.setValue(1.0) - eq.b1.setValue(0) - eq.b2.setValue(1) - eq.b3.setValue(2.0) - eq.b4.setValue(2.0) - eq.b5.setValue(2.0) - eq.b6.setValue(2.0) - eq.b7.setValue(2.0) - eq.b8.setValue(2.0) + eq.qsig.set_value(qsig) + eq.sigma1.set_value(sigma) + eq.sigma2.set_value(sigma) + eq.A0.set_value(1.0) + eq.b1.set_value(0) + eq.b2.set_value(1) + eq.b3.set_value(2.0) + eq.b4.set_value(2.0) + eq.b5.set_value(2.0) + eq.b6.set_value(2.0) + eq.b7.set_value(2.0) + eq.b8.set_value(2.0) from numpy import exp, polyval @@ -174,8 +183,8 @@ def f(A0, qsig, sigma1, sigma2, b1, b2, b3, b4, b5, b6, b7, b8): args[idx] = random.random() # Time the different functions with these arguments - tnpy += timeFunction(f, *args) - teq += timeFunction(eq, *args) + tnpy += time_function(f, *args) + teq += time_function(eq, *args) print( "Average call time (%i calls, %i mutations/call):" % (numcalls, mutate) @@ -187,7 +196,7 @@ def f(A0, qsig, sigma1, sigma2, b1, b2, b3, b4, b5, b6, b7, b8): return -def speedTest3(mutate=2): +def test_speed3(mutate=2): """Test wrt sympy. Results - sympy is 10 to 24 times faster without using arrays (ouch!). @@ -208,18 +217,18 @@ def speedTest3(mutate=2): """ factory.registerConstant("x", x) eq = factory.makeEquation(eqstr) - eq.qsig.setValue(qsig) - eq.sigma1.setValue(sigma) - eq.sigma2.setValue(sigma) - eq.A0.setValue(1.0) - eq.b1.setValue(0) - eq.b2.setValue(1) - eq.b3.setValue(2.0) - eq.b4.setValue(2.0) - eq.b5.setValue(2.0) - eq.b6.setValue(2.0) - eq.b7.setValue(2.0) - eq.b8.setValue(2.0) + eq.qsig.set_value(qsig) + eq.sigma1.set_value(sigma) + eq.sigma2.set_value(sigma) + eq.A0.set_value(1.0) + eq.b1.set_value(0) + eq.b2.set_value(1) + eq.b3.set_value(2.0) + eq.b4.set_value(2.0) + eq.b5.set_value(2.0) + eq.b6.set_value(2.0) + eq.b7.set_value(2.0) + eq.b8.set_value(2.0) from numpy import polyval from sympy import exp, lambdify, var @@ -262,8 +271,8 @@ def speedTest3(mutate=2): args[idx] = random.random() # Time the different functions with these arguments - teq += timeFunction(eq, *(args[:-1])) - tnpy += timeFunction(f, *args) + teq += time_function(eq, *(args[:-1])) + tnpy += time_function(f, *args) print( "Average call time (%i calls, %i mutations/call):" % (numcalls, mutate) @@ -275,7 +284,7 @@ def speedTest3(mutate=2): return -def speedTest4(mutate=2): +def test_speed4(mutate=2): """Test wrt sympy. Results - sympy is 10 to 24 times faster without using arrays (ouch!). @@ -306,7 +315,7 @@ def speedTest4(mutate=2): teq = 0 # Randomly change variables numargs = len(eq.args) - choices = range(numargs) + choices = list(range(numargs)) args = [1.0] * (len(eq.args)) args.append(x) @@ -325,8 +334,8 @@ def speedTest4(mutate=2): args[idx] = random.random() # Time the different functions with these arguments - teq += timeFunction(eq, *(args[:-1])) - tnpy += timeFunction(f, *args) + teq += time_function(eq, *(args[:-1])) + tnpy += time_function(f, *args) print( "Average call time (%i calls, %i mutations/call):" % (numcalls, mutate) @@ -338,7 +347,7 @@ def speedTest4(mutate=2): return -def weightedTest(mutate=2): +def test_weighted(mutate=2): """Show the benefits of a properly balanced equation tree.""" from diffpy.srfit.equation.builder import EquationFactory @@ -352,14 +361,14 @@ def weightedTest(mutate=2): factory.registerConstant("x", x) eq = factory.makeEquation(eqstr) - eq.b1.setValue(0) - eq.b2.setValue(1) - eq.b3.setValue(2.0) - eq.b4.setValue(2.0) - eq.b5.setValue(2.0) - eq.b6.setValue(2.0) - eq.b7.setValue(2.0) - eq.b8.setValue(2.0) + eq.b1.set_value(0) + eq.b2.set_value(1) + eq.b3.set_value(2.0) + eq.b4.set_value(2.0) + eq.b5.set_value(2.0) + eq.b6.set_value(2.0) + eq.b7.set_value(2.0) + eq.b8.set_value(2.0) # scale = visitors.NodeWeigher() # eq.root.identify(scale) @@ -374,7 +383,7 @@ def f(b1, b2, b3, b4, b5, b6, b7, b8): teq = 0 # Randomly change variables numargs = len(eq.args) - choices = range(numargs) + choices = list(range(numargs)) args = [0.1] * numargs # The call-loop @@ -394,8 +403,8 @@ def f(b1, b2, b3, b4, b5, b6, b7, b8): # print(args) # Time the different functions with these arguments - teq += timeFunction(eq, *args) - tnpy += timeFunction(f, *args) + teq += time_function(eq, *args) + tnpy += time_function(f, *args) print( "Average call time (%i calls, %i mutations/call):" % (numcalls, mutate) @@ -407,9 +416,9 @@ def f(b1, b2, b3, b4, b5, b6, b7, b8): return -def profileTest(): +def test_profile(): - from diffpy.srfit.builder import EquationFactory + from diffpy.srfit.equation.builder import EquationFactory factory = EquationFactory() @@ -421,18 +430,18 @@ def profileTest(): factory.registerConstant("x", x) eq = factory.makeEquation(eqstr) - eq.b1.setValue(0) - eq.b2.setValue(1) - eq.b3.setValue(2.0) - eq.b4.setValue(2.0) - eq.b5.setValue(2.0) - eq.b6.setValue(2.0) - eq.b7.setValue(2.0) - eq.b8.setValue(2.0) + eq.b1.set_value(0) + eq.b2.set_value(1) + eq.b3.set_value(2.0) + eq.b4.set_value(2.0) + eq.b5.set_value(2.0) + eq.b6.set_value(2.0) + eq.b7.set_value(2.0) + eq.b8.set_value(2.0) mutate = 8 numargs = len(eq.args) - choices = range(numargs) + choices = list(range(numargs)) args = [0.1] * numargs # The call-loop diff --git a/tests/test_visitors.py b/tests/test_visitors.py index 993f183d..c7130be2 100644 --- a/tests/test_visitors.py +++ b/tests/test_visitors.py @@ -115,10 +115,10 @@ def testSimpleFunction(self): # Set the values of the variables. # The equation should evaluate to (1+3)*(4-2) = 8 - v1.setValue(1) - v2.setValue(2) - v3.setValue(3) - v4.setValue(4) + v1.set_value(1) + v2.set_value(2) + v3.set_value(3) + v4.set_value(4) # now get the args args = visitors.getArgs(mult) @@ -167,11 +167,11 @@ def testSimpleFunction(self): # Set the values of the variables. # The equation should evaluate to (1+3)*(4-2) = 8 - v1.setValue(1) - v2.setValue(2) - v3.setValue(3) - v4.setValue(4) - v5.setValue(5) + v1.set_value(1) + v2.set_value(2) + v3.set_value(3) + v4.set_value(4) + v5.set_value(5) # Evaluate assert 8 == mult.value diff --git a/tests/test_weakrefcallable.py b/tests/test_weakrefcallable.py index c148033c..688bc563 100644 --- a/tests/test_weakrefcallable.py +++ b/tests/test_weakrefcallable.py @@ -28,7 +28,7 @@ class TestWeakBoundMethod(unittest.TestCase): def setUp(self): self.f = FitContribution("f") - self.f.setEquation("7") + self.f.set_equation("7") self.w = weak_ref(self.f._eq._flush, fallback=_fallback_example) return @@ -111,14 +111,14 @@ def test_observable_deregistration(self): """Check if Observable drops dead Observer.""" f = self.f x = f.newParameter("x", 5) - f.setEquation("3 * x") + f.set_equation("3 * x") self.assertEqual(15, f.evaluate()) self.assertEqual(15, f._eq._value) # get one of the observer callables that are associated with f xof = next(iter(x._observers)) self.assertTrue(isinstance(xof, WeakBoundMethod)) # changing value of x should reset f._eq - x.setValue(x.value + 1) + x.set_value(x.value + 1) self.assertTrue(None is f._eq._value) self.assertEqual(18, f.evaluate()) # deallocate f now @@ -126,7 +126,7 @@ def test_observable_deregistration(self): self.assertTrue(xof in x._observers) # since f does not exist anymore, the next notification call # should drop the associated observer. - x.setValue(x.value + 1) + x.set_value(x.value + 1) self.assertEqual(0, len(x._observers)) return diff --git a/tests/testdata/ni-q27r100-neutron.gr b/tests/testdata/ni-q27r100-neutron.gr index 1b18d9a1..e8c73487 100644 --- a/tests/testdata/ni-q27r100-neutron.gr +++ b/tests/testdata/ni-q27r100-neutron.gr @@ -1,50 +1,26 @@ -# History written: Tue May 6 11:04:33 2008 -# produced by bozin -# ##### Run Information runCorrection=T -# prep=gsas machine=npdf -# run=npdf_03315 background=npdf_03001 -# smooth=2 smoothParam=32 32 0 backKillThresh=-1.0 -# in beam: radius=0.45325 height=4.5 -# temp=300 runTitle=Run 3315: Ni commercial, RT_stick -# -# ##### Vanadium runCorrection=T -# run=npdf_03000 background=npdf_03001 -# smooth=2 smoothParam=32 32 0 vanKillThresh=-1.0 vBackKillThresh=-1.0 -# in beam: radius=0.47625 height=4.5 -# -# ##### Container runCorrection=T -# run=npdf_03002 background=npdf_03001 -# smooth=2 smoothParam=32 32 0 cBackKillThresh=-1.0 -# wallThick=0.023 atomDensity=0.072110 -# atomic information: scattCS=5.100 absorpCS=5.080 -# -# ##### Sample Material numElements=1 NormLaue=0.00000 -# Element relAtomNum atomMass atomCoherCS atomIncoherCS atomAbsorpCS -# Ni 1.0000 58.693 13.3000 5.2000 4.49000 -# density=7.0 effDensity=2.8777 -# -# ##### Banks=4 deltaQ=0.01 matchRef=0 matchScal=T matchOffset=T -# bank angle blendQmin blendQmax (0.0 means no info) -# 1 46.6 0.87 21.63 -# 2 90.0 1.51 37.66 -# 3 119.0 1.84 45.96 -# 4 148.0 2.05 51.25 -# -# ##### Program Specific Information -# ## Ft calcError=1 (1 for true, 0 for false) -# numRpoints=10000 maxR=100.0 numDensity=0.0 intMaxR=1.5 -# ## Damp Qmin=0.87 Qmax=27.0 startDampQ=27.0 QAveMin=0.6 -# dampFuncType=0 modEqn=1.0000*S(Q) +0.0000 +0.0000*Q dampExtraToZero=0 -# ## Blend numBanks=4 banks=1,2,3,4 -# soqCorrFile= -# ## Soqd minProcOut=0 -# samPlazcek=1 vanPlazcek=1 smoothData=0 modifyData=1 -# ## Corps minProcOut=0 numBanksMiss=0 -# -# ##### prepgsas prepOutput=1 numBanksMiss=0 fileExt=gsa -# instParamFile=npdf_TL-displex_2018.iparm -# numBanksAdd=0 -# numBanksMult=0 +# xPDFsuite Configuration # +[PDF] +wavelength = 1.333 +dataformat = QA +inputfile = npdf_03315.chi +backgroundfile = npdf_03001.chi +stype = N +bgscale = 1.0 +composition = Ni +outputtype = gr +qmaxinst = 27.0 +qmin = 0.87 +qmax = 27.0 +temperature = 300 +rmax = 100.0 +rmin = 0.0 +rstep = 0.01 +rpoly = 0.9 + +[Misc] +inputdir = /data/npdf/chi +savedir = /data/npdf/gr + ##### start data #O0 rg_int sig_rg_int low_int sig_low_int rmax rhofit #S 1 - PDF from PDFgetN diff --git a/tests/testdata/si-q27r60-xray.gr b/tests/testdata/si-q27r60-xray.gr index e25d5795..6753d343 100644 --- a/tests/testdata/si-q27r60-xray.gr +++ b/tests/testdata/si-q27r60-xray.gr @@ -1,130 +1,26 @@ -History written: Mon Apr 21 20:48:28 2008 -Produced by -####### Get_XPDF ####### +[DEFAULT] -##### General_Setting -title=X-ray PDF -workingdirectory=e:\Ahmad\MUCAT0804\standards\pdfgetx2 -sourcedir=C:\Program Files\PDFgetX2\ -logfile=.pdfgetx2.log -quiet=0 debug=0 autosave_isa=1 savefilenamebase=si325_mesh_300k_nor_4-8 -iqfilesurfix=.iq sqfilesurfix=.sq fqfilesurfix=.fq grfilesurfix=.gr +version = diffpy.pdfgetx-2.4.0 -##### DataFileFormat -datatype=1 (0:SPEC, 1:CHI, 2:nxm column, 3:unknown) -num_skiplines=3 comment_id=# delimiter= -### SPEC Format scan_id=#S scan_delimiter= -columnname_id=#L columnname_delimiter= -data_id= data_delimiter= -### CHI Format -### nxm column Format -### End of file format +# input and output specifications +dataformat = QA +outputtype = gr -##### Data&Background -samfile=si325_mesh_300k_nor_4-8.chi num_sams=1 -sambkgfile=kapton_bgrd_300k_nor_2-3.chi num_sambkgs=1 -confile= num_cons=1 -conbkgfile= num_conbkgs=1 -det# used xcol detcol deterrcol xmin xmax add_det mul_det add_bkg mul_bkg add_con mul_con add_conbkg mul_conbkg - 0 1 0 1 3 0.600000 32.0000 0.000000 1.00000 0.000000 1.00000 0.000000 1.00000 0.000000 1.00000 +# PDF calculation setup +stype = X +composition = Si +bgscale = 1.0 +rpoly = 0.9 +qmaxinst = 29 +qmin = 0.01 +qmax = 27 +rmin = 0 +rmax = 60 +rstep = 0.01 +temperature = 300 -##### Experiment_Setup -title=PDF analysis -user=me -facility=In house -temperature=300.000 containermut=0.000500000 filtermut=0.0200000 -## X-Ray radiationtype=3 - (0: Ag K_alpha, 1:Cu K_alpha, 2:Mo K_alpha, 3:Customize) -lambda=0.142773 energy=86.8406 polartype=0 polardegree=1.00000 -## MonoChromator crystaltype=0 (0:Perfect, 1:Mosaic, 2:None) -position=0 (0:Primary beam, 1:Diffracted beam) -dspacetype=0 (0:Si{111}, 1:Ge{111}, 2:Customize) dspacing=3.13200 +# End of config -------------------------------------------------------------- -##### Sample_Setup information num_atoms=1 -#L symbol valence fractions z user_f1 user_f2 user_macoef - Si 0.00 1.000000 14 0.000000 0.000000 0.001000 -geometry=2 mut=0.50000000 numberdensity=0.00600000 -thickness=2.00000 packingFraction=0.500000 theory_mut=0.00579218 - -##### GetIQ_Setup -xformat=1 -smoothcorr_isa=0 selfnormalize_isa=0 -#L par_name sample sample_bkg container container_bkg -smooth_degree 2 2 2 2 -smooth_width 6 6 6 6 -selfnormalize 0 0 0 0 -filtercorr_isa=0 samfiltercorr_isa=0 sambkgfiltercorr_isa=0 -confiltercorr_isa=0 conbkgfiltercorr_isa=0 -scatveffcorr_isa=1 samconveffcorr_isa=1 sambkgveffcorr_isa=0 -conbkgveffcorr_isa=0 -nonegative_isa=1 negativevalue=-1.00000 - -##### Calibration_Data -## Detection efficiency energy dependence detedepxaxis=0 -detedepfunctype=0 detedep_elastic=1.00000 detedep_fluores=1.80000 -detedep_quadra=0.000000 detedep_spline=0.000000 detedep_file= -## Detector transmission energy dependence dettcoefxaxis=0 -dettcoeffunctype=0 dettcoef_elastic=0.950000 dettcoef_fluores=0.600000 -dettcoef_quadra=0.000000 dettcoef_spline=0.000000 dettcoef_file= - -##### IQ_Simulation -### Elastic used_isa=1 mymethod=1 -do_samabsorp=1 do_multscat=1 do_conabsorp=0 do_airabsorp=0 -do_polarization=1 do_oblincident=0 do_energydep=0 -do_breitdirac=0 breitdiracexpo=2.00000 -do_rulandwin=0 rulandwinwidth=0.00100000 -do_useredit=0 add_user=0.000000 mul_user=1.00000 -### Compton used_isa=1 mymethod=1 -do_samabsorp=1 do_multscat=1 do_conabsorp=0 do_airabsorp=0 -do_polarization=1 do_oblincident=0 do_energydep=0 -do_breitdirac=0 breitdiracexpo=2.00000 -do_rulandwin=0 rulandwinwidth=0.00100000 -do_useredit=0 add_user=0.000000 mul_user=1.00000 -### Fluores used_isa=1 mymethod=1 -do_samabsorp=1 do_multscat=1 do_conabsorp=0 do_airabsorp=0 -do_polarization=1 do_oblincident=0 do_energydep=0 -do_breitdirac=0 breitdiracexpo=2.00000 -do_rulandwin=0 rulandwinwidth=0.00100000 -do_useredit=0 add_user=0.000000 mul_user=1.00000 - -##### Correction_Setup corrmethod=0 -oblincident_isa=1 dettranscoef=0.980000 samfluore_isa=1 -samfluoretype=0 samfluorescale=15.000000 -multiscat_isa=1 xraypolar_isa=1 samabsorp_isa=1 -highqscale_isa=1 highqratio=0.600000 scaleconst=0.039181914 -scaleconst_theory=0.039181914 -comptonscat_isa=1 rulandwin_isa=0 rulandintewidth=0.0100000 -comptonmethod=0 breitdirac_isa=1 breitdiracexponent=3 -detefficiency_isa=1 detefficiencytype=2 (0-1: linear, 2-3: quadratic) -detefficiency_a=-0.054826792 detefficiency_b=0.028062565 -lauediffuse_isa=1 -weight_isa=1 weighttype=0 (0: ^2, 1: , 2: Data Smoothed) -weightsmoothrmin=3.00000 weightsmoothwidth=100 weightsmoothcycles=600 -editsq_isa=0 editsqtype=0 add_sq=0.000000 mul_sq=1.00000 -editsqsmoothrmin=3.00000 editsqsmoothwidth=100 editsqsmoothcycles=600 -smoothdata_isa=0 smoothfunctype=0 smoothqmin=12.0000 smoothboxwidth=9 -interpolateqmin_isa=0 qmininterpolationtype=0 -dampfq_isa=0 dampfqtype=0 dampfqwidth=23.0000 - -##### SqGr_Optimization Setup -ftmethod=0 -## S(q) qmin=0.010000 qmax=27.000000 qgrid=0.000000 -## G(r) rmin=0.010000 rmax=60.000000 rgrid=0.010000 -## SqOptimization sqoptfunction=1 -optqmin=15.0000 optqmax=40.0000 optqgrid=0.000000 -optrmin=0.000000 optrmax=2.20000 optrgrid=0.0200000 -maxiter=20 relstep=0.000000 weighttype=0 weightfunc=0 -fitbkgmult_isa=0 fitsampmut_isa=1 fitpolariz_isa=1 -fitoblique_isa=0 fitfluores_isa=0 -fitrulandw_isa=0 fitenergya_isa=1 fitenergyb_isa=1 -fitsimurulandw_isa=1 fitDetEdepfluores_isa=0 fitDetEdepquadra_isa=0 -fitDetEdepspline_isa=0 fitDetTCoefElastic_isa=0 fitDetTCoefFluores_isa=0 -fitDetTcoefquadra_isa=0 fitDetTcoefspline_isa=0 - -##### Save&Plot Settings -datatype=GrData iqcorrtype=Int iqsimutype=SimuIq -sqcorrtype=Oblin sqtofqtype=DampFq -gropttype=OptFq miscdatatype=AtomASF ##### start data #F si325_mesh_300k_nor_4-8.gr #D Mon Apr 21 21:17:23 2008