From 1d5643a2676f32e6a78006a6c5355c8540567352 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Tue, 3 Feb 2026 10:46:07 +0100 Subject: [PATCH 01/27] initial analysis class --- docs/docs/tutorials/analysis.ipynb | 173 ++++++ pixi.lock | 35 +- src/easydynamics/analysis/__init__.py | 8 + src/easydynamics/analysis/analysis.py | 460 ++++++++++++++++ src/easydynamics/analysis/analysis1d.py | 498 ++++++++++++++++++ .../convolution/convolution_base.py | 4 + src/easydynamics/sample_model/__init__.py | 6 + 7 files changed, 1169 insertions(+), 15 deletions(-) create mode 100644 docs/docs/tutorials/analysis.ipynb create mode 100644 src/easydynamics/analysis/__init__.py create mode 100644 src/easydynamics/analysis/analysis.py create mode 100644 src/easydynamics/analysis/analysis1d.py diff --git a/docs/docs/tutorials/analysis.ipynb b/docs/docs/tutorials/analysis.ipynb new file mode 100644 index 00000000..7b843acc --- /dev/null +++ b/docs/docs/tutorials/analysis.ipynb @@ -0,0 +1,173 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "8643b10c", + "metadata": {}, + "source": [ + "asd" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bca91d3c", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "from easydynamics.analysis.analysis1d import Analysis1d\n", + "from easydynamics.experiment import Experiment\n", + "from easydynamics.sample_model import ComponentCollection\n", + "from easydynamics.sample_model import DeltaFunction\n", + "from easydynamics.sample_model import Gaussian\n", + "from easydynamics.sample_model import Polynomial\n", + "from easydynamics.sample_model.background_model import BackgroundModel\n", + "from easydynamics.sample_model.resolution_model import ResolutionModel\n", + "from easydynamics.sample_model.sample_model import SampleModel\n", + "\n", + "%matplotlib widget" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8deca9b6", + "metadata": {}, + "outputs": [], + "source": [ + "vanadium_experiment = Experiment('Vanadium')\n", + "vanadium_experiment.load_hdf5(filename='vanadium_data_example.h5')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "41f842f0", + "metadata": {}, + "outputs": [], + "source": [ + "# Create a diffusion_model and components for the SampleModel\n", + "\n", + "# Creating components\n", + "component_collection = ComponentCollection()\n", + "delta_function = DeltaFunction(display_name='DeltaFunction', area=1)\n", + "gaussian = Gaussian(display_name='Gaussian', width=0.1, center=0.5, area=0.5)\n", + "\n", + "# Adding components to the component collection\n", + "component_collection.append_component(delta_function)\n", + "\n", + "\n", + "sample_model = SampleModel(\n", + " components=component_collection,\n", + " unit='meV',\n", + " display_name='MySampleModel',\n", + ")\n", + "\n", + "res_gauss = Gaussian(width=0.1)\n", + "res_gauss.area.fixed = True\n", + "resolution_model = ResolutionModel(components=res_gauss)\n", + "\n", + "\n", + "background_model = BackgroundModel(components=Polynomial(coefficients=[0.001]))\n", + "\n", + "my_analysis = Analysis1d(\n", + " experiment=vanadium_experiment,\n", + " sample_model=sample_model,\n", + " resolution_model=resolution_model,\n", + " background_model=background_model,\n", + " Q_index=5,\n", + ")\n", + "\n", + "my_analysis._update_models()\n", + "\n", + "\n", + "values = my_analysis.calculate()\n", + "sample_values, background_values = my_analysis.calculate_individual_components()\n", + "\n", + "plt.figure()\n", + "plt.plot(my_analysis.energy.values, values, label='Total Model')\n", + "for component_index in range(len(sample_values)):\n", + " plt.plot(\n", + " my_analysis.energy.values,\n", + " sample_values[component_index],\n", + " label=f'Sample Component {component_index}',\n", + " linestyle='--',\n", + " )\n", + "\n", + "for component_index in range(len(background_values)):\n", + " plt.plot(\n", + " my_analysis.energy.values,\n", + " background_values[component_index],\n", + " label=f'Background Component {component_index}',\n", + " linestyle=':',\n", + " )\n", + "plt.xlabel('Energy (meV)')\n", + "plt.ylabel('Intensity')\n", + "plt.title(f'Q index: {5}')\n", + "plt.legend()\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6762faba", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "02702f95", + "metadata": {}, + "outputs": [], + "source": [ + "my_analysis.plot_data_and_model()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "70091539", + "metadata": {}, + "outputs": [], + "source": [ + "my_analysis.fit()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2ad6384e", + "metadata": {}, + "outputs": [], + "source": [ + "my_analysis.plot_data_and_model()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "easydynamics_newbase", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pixi.lock b/pixi.lock index f51bc65b..da8aee45 100644 --- a/pixi.lock +++ b/pixi.lock @@ -5,6 +5,8 @@ environments: - url: https://conda.anaconda.org/conda-forge/ indexes: - https://pypi.org/simple + options: + pypi-prerelease-mode: if-necessary-or-explicit packages: linux-64: - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 @@ -78,7 +80,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git?rev=develop#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 - pypi: https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -333,7 +335,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git?rev=develop#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 - pypi: https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -588,7 +590,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git?rev=develop#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 - pypi: https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -836,7 +838,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git?rev=develop#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 - pypi: https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -1029,6 +1031,8 @@ environments: - url: https://conda.anaconda.org/conda-forge/ indexes: - https://pypi.org/simple + options: + pypi-prerelease-mode: if-necessary-or-explicit packages: linux-64: - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 @@ -1102,7 +1106,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git?rev=develop#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 - pypi: https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -1358,7 +1362,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git?rev=develop#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 - pypi: https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -1614,7 +1618,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git?rev=develop#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 - pypi: https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -1863,7 +1867,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git?rev=develop#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 - pypi: https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -2057,6 +2061,8 @@ environments: - url: https://conda.anaconda.org/conda-forge/ indexes: - https://pypi.org/simple + options: + pypi-prerelease-mode: if-necessary-or-explicit packages: linux-64: - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 @@ -2130,7 +2136,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git?rev=develop#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 - pypi: https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -2385,7 +2391,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git?rev=develop#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 - pypi: https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -2640,7 +2646,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git?rev=develop#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 - pypi: https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -2888,7 +2894,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git?rev=develop#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 - pypi: https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -4085,7 +4091,7 @@ packages: requires_python: '>=3.5' - pypi: ./ name: easydynamics - version: 0.1.0+devdirty6 + version: 0.1.1+devdirty2 sha256: de299c914d4a865b9e2fdefa5e3947f37b1f26f73ff9087f7918ee417f3dd288 requires_dist: - darkdetect @@ -4128,8 +4134,7 @@ packages: - validate-pyproject[all] ; extra == 'dev' - versioningit ; extra == 'dev' requires_python: '>=3.11' - editable: true -- pypi: git+https://github.com/easyscience/corelib.git?rev=develop#bd106537fcf522336aa0176aa6ccf215be8a5b86 +- pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 name: easyscience version: 2.1.0 requires_dist: diff --git a/src/easydynamics/analysis/__init__.py b/src/easydynamics/analysis/__init__.py new file mode 100644 index 00000000..4cb511b4 --- /dev/null +++ b/src/easydynamics/analysis/__init__.py @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-License-Identifier: BSD-3-Clause + +from .analysis import Analysis + +__all__ = [ + 'Analysis', +] diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py new file mode 100644 index 00000000..33d23545 --- /dev/null +++ b/src/easydynamics/analysis/analysis.py @@ -0,0 +1,460 @@ +# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-License-Identifier: BSD-3-Clause + + +import numpy as np +import plopp as pp +import scipp as sc +from easyscience.base_classes.model_base import ModelBase as EasyScienceModelBase +from easyscience.fitting.fitter import Fitter as EasyScienceFitter +from easyscience.variable import Parameter + +from easydynamics.convolution import Convolution +from easydynamics.experiment import Experiment +from easydynamics.sample_model import BackgroundModel +from easydynamics.sample_model import ResolutionModel +from easydynamics.sample_model import SampleModel + + +class Analysis(EasyScienceModelBase): + """For analysing data.""" + + def __init__( + self, + display_name: str = 'MyAnalysis', + unique_name: str | None = None, + experiment: Experiment | None = None, + sample_model: SampleModel | None = None, + resolution_model: ResolutionModel | None = None, + background_model: BackgroundModel | None = None, + energy_offset: None = None, + ): + + super().__init__(display_name=display_name, unique_name=unique_name) + + if experiment is not None and not isinstance(experiment, Experiment): + raise TypeError('experiment must be an instance of Experiment or None.') + + self._experiment = experiment + + if sample_model is not None and not isinstance(sample_model, SampleModel): + raise TypeError('sample_model must be an instance of SampleModel or None.') + sample_model.Q = self.Q + self._sample_model = sample_model + + if resolution_model is not None and not isinstance(resolution_model, ResolutionModel): + raise TypeError('resolution_model must be an instance of ResolutionModel or None.') + resolution_model.Q = self.Q + self._resolution_model = resolution_model + + if background_model is not None and not isinstance(background_model, BackgroundModel): + raise TypeError('background_model must be an instance of BackgroundModel or None.') + background_model.Q = self.Q + self._background_model = background_model + + self._convolvers = [None] * (len(self.Q) if self.Q is not None else 0) + self._update_models() + + ############# + # Properties + ############# + + @property + def experiment(self) -> Experiment | None: + """The Experiment associated with this Analysis.""" + return self._experiment + + @experiment.setter + def experiment(self, value: Experiment | None) -> None: + if value is not None and not isinstance(value, Experiment): + raise TypeError('experiment must be an instance of Experiment or None.') + self._experiment = value + self._update_models() + + @property + def sample_model(self) -> SampleModel | None: + """The SampleModel associated with this Analysis.""" + return self._sample_model + + @sample_model.setter + def sample_model(self, value: SampleModel | None) -> None: + if value is not None and not isinstance(value, SampleModel): + raise TypeError('sample_model must be an instance of SampleModel or None.') + self._sample_model = value + self._update_models() + + @property + def resolution_model(self) -> ResolutionModel | None: + """The ResolutionModel associated with this Analysis.""" + return self._resolution_model + + @resolution_model.setter + def resolution_model(self, value: ResolutionModel | None) -> None: + if value is not None and not isinstance(value, ResolutionModel): + raise TypeError('resolution_model must be an instance of ResolutionModel or None.') + self._resolution_model = value + self._update_models() + + @property + def background_model(self) -> BackgroundModel | None: + """The BackgroundModel associated with this Analysis.""" + return self._background_model + + @background_model.setter + def background_model(self, value: BackgroundModel | None) -> None: + if value is not None and not isinstance(value, BackgroundModel): + raise TypeError('background_model must be an instance of BackgroundModel or None.') + self._background_model = value + self._update_models() + + @property + def Q(self) -> sc.Variable | None: + """The Q values from the associated Experiment, if available.""" + if self.experiment is not None: + return self.experiment.Q + return None + + @Q.setter + def Q(self, value) -> None: + """Q is a read-only property derived from the Experiment.""" + raise AttributeError('Q is a read-only property derived from the Experiment.') + + @property + def energy(self) -> sc.Variable | None: + """The energy values from the associated Experiment, if + available. + """ + if self.experiment is not None: + return self.experiment.energy + return None + + @energy.setter + def energy(self, value) -> None: + """Energy is a read-only property derived from the + Experiment. + """ + raise AttributeError('energy is a read-only property derived from the Experiment.') + + # TODO: make it use experiment temperature + @property + def temperature(self) -> Parameter | None: + """The temperature from the associated Experiment, if + available. + """ + return None + + @temperature.setter + def temperature(self, value) -> None: + """Temperature is a read-only property derived from the + Experiment. + """ + raise AttributeError('temperature is a read-only property derived from the Experiment.') + + # # TODO: make it use experiment temperature + # @property def temperature(self) -> Parameter | None: """The + # temperature from the associated Experiment, if available.""" if + # self.experiment is not None: return + # self.experiment.temperature return None + + # @temperature.setter def temperature(self, value) -> None: + # """temperature is a read-only property derived from the + # Experiment.""" raise AttributeError( "temperature is a + # read-only property derived from the Experiment." ) + + ############# + # Other methods + ############# + + def calculate(self, energy: float | None, Q_index: int) -> np.ndarray: + """Calculate the model prediction for a given Q index. + + Args: + energy (float): The energy value to calculate the model for. + Q_index (int): The index of the Q value to calculate the + model for. + Returns: + sc.DataArray: The calculated model prediction. + """ + if energy is None: + energy = self.energy + + if self.sample_model is None: + sample_intensity = np.zeros_like(energy) + else: + if self.resolution_model is None: + sample_intensity = self.sample_model._component_collections[Q_index].evaluate( + energy + ) + else: + convolver = self._create_convolver(Q_index) + sample_intensity = convolver.convolution() + + if self.background_model is None: + background_intensity = np.zeros_like(energy) + else: + background_intensity = self.background_model._component_collections[Q_index].evaluate( + energy + ) + + sample_plus_background = sample_intensity + background_intensity + + return sample_plus_background + + def calculate_individual_components( + self, Q_index: int + ) -> tuple[list[np.ndarray], list[np.ndarray]]: + """Calculate the model prediction for a given Q index for each + individual component. + + Args: + Q_index (int): The index of the Q value to calculate the + model for. + Returns: + list[np.ndarray]: The calculated model predictions for each + individual component. + """ + sample_results = [] + background_results = [] + + if self.sample_model is not None: + # Calculate sample components + for component in self.sample_model._component_collections[Q_index]._components: + if self.resolution_model is None: + component_intensity = component.evaluate(self.energy) + else: + convolver = Convolution( + sample_components=component, + resolution_components=self.resolution_model._component_collections[ + Q_index + ], + energy=self.energy, + temperature=self.temperature, + ) + component_intensity = convolver.convolution() + sample_results.append(component_intensity) + + if self.background_model is not None: + # Calculate background components + for component in self.background_model._component_collections[Q_index]._components: + component_intensity = component.evaluate(self.energy) + background_results.append(component_intensity) + + return sample_results, background_results + + def calculate_all_Q(self) -> list[np.ndarray]: + """Calculate the model prediction for all Q indices. + + Returns: + list[np.ndarray]: The calculated model predictions for all Q + indices. + """ + results = [] + for Q_index in range(len(self.Q)): + result = self.calculate(Q_index) + results.append(result) + return results + + # def calculate_individual_components_all_Q( + # self, + # add_background: bool = True, + # ) -> list[tuple[list[np.ndarray], list[np.ndarray]]]: + # """Calculate the model prediction for all Q indices for each + # individual component. + + # Returns: list[tuple[list[np.ndarray], list[np.ndarray]]]: The + # calculated model predictions for each individual component + # at all Q indices. """ all_results = [] for Q_index in + # range(len(self.Q)): sample_results, background_results = + # self.calculate_individual_components( Q_index ) if + # add_background: sample_results = sample_results + + # background_results all_results.append((sample_results, + # background_results)) return all_results + + def calculate_single_component_all_Q( + self, + component_index: int, + ) -> list[np.ndarray]: + """Calculate the model prediction for all Q indices for a single + component. + + Args: + component_index (int): The index of the component + Returns: + list[np.ndarray]: The calculated model predictions for the + specified component at all Q indices. + """ + + results = [] + for Q_index in range(len(self.Q)): + if self.sample_model is not None: + component = self.sample_model._component_collections[Q_index]._components[ + component_index + ] + if self.resolution_model is None: + component_intensity = component.evaluate(self.energy) + else: + convolver = Convolution( + sample_components=component, + resolution_components=self.resolution_model._component_collections[ + Q_index + ], + energy=self.energy, + temperature=self.temperature, + ) + component_intensity = convolver.convolution() + results.append(component_intensity) + else: + results.append(np.zeros_like(self.energy)) + + model_data_array = sc.DataArray( + data=sc.array(dims=['Q', 'energy'], values=results), + coords={ + 'Q': self.Q, + 'energy': self.energy, + }, + ) + return model_data_array + + def fit(self, Q_index: int): + """Fit the model to the experimental data for a given Q index. + + Args: + Q_index (int): The index of the Q value to fit the model + to. + Returns: + FitResult: The result of the fit. + """ + if self._experiment is None: + raise ValueError('No experiment is associated with this Analysis.') + + if not isinstance(Q_index, int) or Q_index < 0 or Q_index >= len(self.Q): + raise ValueError('Q_index must be a valid index for the Q values.') + + data = self.experiment.data['Q', Q_index] + x = data.coords['energy'].values + y = data.values + e = data.variances**0.5 + + def fit_func(x_vals): + return self.calculate_theory(energy=x_vals, Q_index=Q_index) + + fitter = EasyScienceFitter( + fit_object=self, + fit_function=fit_func, + ) + + # Perform the fit + fit_result = fitter.fit(x=x, y=y, weights=1.0 / e) + + # Store result + self.fit_result = fit_result + + return fit_result + + def plot_data_and_model( + self, + plot_individual_components: bool = True, + ) -> None: + """Plot the experimental data and the model prediction. + + Args: + plot_individual_components (bool): Whether to plot + individual components. Default is True. + """ + if not isinstance(plot_individual_components, bool): + raise TypeError('plot_individual_components must be True or False.') + + model_data_array = self._create_model_data_group( + individual_components=plot_individual_components + ) + if self.experiment is None or self.experiment.data is None: + raise ValueError('Experiment data is not available for plotting.') + + from IPython.display import display + + fig = pp.slicer( + {'Data': self.experiment.data, 'Model': model_data_array}, + color={'Data': 'black', 'Model': 'red'}, + linestyle={'Data': 'none', 'Model': 'solid'}, + marker={'Data': 'o', 'Model': 'None'}, + ) + display(fig) + + ############# + # Private methods + ############# + + def _update_models(self): + """Update models based on the current experiment.""" + if self.experiment is None: + return + + for Q_index in range(len(self.Q)): + self._convolvers[Q_index] = self._create_convolver(Q_index) + + def _create_convolver(self, Q_index: int): + """Initialize and return a Convolution object for the given Q + index. + """ + # Add checks of empty sample models etc + + sample_components = self.sample_model._component_collections[Q_index] + resolution_components = self.resolution_model._component_collections[Q_index] + energy = self.energy + convolver = Convolution( + sample_components=sample_components, + resolution_components=resolution_components, + energy=energy, + temperature=self.temperature, + ) + return convolver + + def _create_model_data_group(self, individual_components=True) -> sc.DataArray: + """Create a Scipp DataArray representing the model over all Q + and energy values. + """ + if self.Q is None or self.energy is None: + raise ValueError('Q and energy must be defined in the experiment.') + + model_data = [] + for Q_index in range(len(self.Q)): + model_at_Q = self.calculate(Q_index) + model_data.append(model_at_Q) + + model_data_array = sc.DataArray( + data=sc.array(dims=['Q', 'energy'], values=model_data), + coords={ + 'Q': self.Q, + 'energy': self.energy, + }, + ) + model_group = sc.DataGroup({'Model': model_data_array}) + + # if plot_individual_components: comps = + # ana.calculate_individual_components(E) for name, + # vals in comps.items(): if name not in + # component_arrays: component_arrays[name] = + # sc.zeros_like(data) csel = + # component_arrays[name] for d, i in + # zip(loop_dims, combo): csel = csel[d, i] + # csel.values = vals fsel.values = + # ana.calculate_theory(E) + + # # Build plot group + # data_and_model = {"Data": self._experiment._data.data, + # "Model": fit_total} if plot_individual_components and + # component_arrays: data_and_model.update(component_arrays) + # data_and_model = sc.DataGroup(data_and_model) + + if individual_components: + components = self.calculate_individual_components_all_Q() + for Q_index, (sample_comps, background_comps) in enumerate(components): + for samp_index, samp_comp in enumerate(sample_comps): + model_data_array[samp_comp.display_name] = sc.zeros_like(model_data_array.data) + model_data_array[samp_comp.display_name].data[Q_index, :] = samp_comp + for back_index, back_comp in enumerate(background_comps): + model_data_array[back_comp.display_name] = sc.zeros_like(model_data_array.data) + model_data_array[back_comp.display_name].data[Q_index, :] = back_comp + + model_data_array = model_data_array + model_group # WRONG BUT LINT + return model_data_array diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py new file mode 100644 index 00000000..ebad61d2 --- /dev/null +++ b/src/easydynamics/analysis/analysis1d.py @@ -0,0 +1,498 @@ +# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-License-Identifier: BSD-3-Clause + + +import numpy as np +import scipp as sc +from easyscience.base_classes.model_base import ModelBase as EasyScienceModelBase +from easyscience.fitting.fitter import Fitter as EasyScienceFitter +from easyscience.variable import DescriptorNumber +from easyscience.variable import Parameter + +from easydynamics.convolution import Convolution +from easydynamics.experiment import Experiment +from easydynamics.sample_model import BackgroundModel +from easydynamics.sample_model import ResolutionModel +from easydynamics.sample_model import SampleModel + + +class Analysis1d(EasyScienceModelBase): + """For analysing data.""" + + def __init__( + self, + display_name: str = 'MyAnalysis', + unique_name: str | None = None, + experiment: Experiment | None = None, + sample_model: SampleModel | None = None, + resolution_model: ResolutionModel | None = None, + background_model: BackgroundModel | None = None, + energy_offset: list[Parameter] | None = None, + Q_index: int | None = None, + ): + super().__init__(display_name=display_name, unique_name=unique_name) + + if experiment is not None and not isinstance(experiment, Experiment): + raise TypeError('experiment must be an instance of Experiment or None.') + + self._experiment = experiment + + if sample_model is not None and not isinstance(sample_model, SampleModel): + raise TypeError('sample_model must be an instance of SampleModel or None.') + sample_model.Q = self.Q + self._sample_model = sample_model + + if resolution_model is not None and not isinstance(resolution_model, ResolutionModel): + raise TypeError('resolution_model must be an instance of ResolutionModel or None.') + resolution_model.Q = self.Q + self._resolution_model = resolution_model + + if background_model is not None and not isinstance(background_model, BackgroundModel): + raise TypeError('background_model must be an instance of BackgroundModel or None.') + background_model.Q = self.Q + self._background_model = background_model + + self._convolvers = [None] * (len(self.Q) if self.Q is not None else 0) + self._update_models() + + if not isinstance(energy_offset, list) and energy_offset is not None: + raise TypeError('energy_offset must be a list of Parameters or None.') + + if energy_offset is not None: + if len(energy_offset) != len(self.Q): + raise ValueError('energy_offset list length must match number of Q values.') + for offset in energy_offset: + if not isinstance(offset, Parameter): + raise TypeError('Each energy_offset must be an instance of Parameter.') + else: + energy_offset = [ + Parameter(name='energy_offset', value=0.0, unit=self.sample_model.unit) + for _ in range(len(self.Q)) + ] + self._energy_offset = energy_offset + + if Q_index is not None: + if ( + not isinstance(Q_index, int) + or Q_index < 0 + or (self.Q is not None and Q_index >= len(self.Q)) + ): + raise ValueError('Q_index must be a valid index for the Q values.') + self._Q_index = Q_index + + ############# + # Properties + ############# + + @property + def experiment(self) -> Experiment | None: + """The Experiment associated with this Analysis.""" + return self._experiment + + @experiment.setter + def experiment(self, value: Experiment | None) -> None: + if value is not None and not isinstance(value, Experiment): + raise TypeError('experiment must be an instance of Experiment or None.') + self._experiment = value + self._update_models() + + @property + def sample_model(self) -> SampleModel | None: + """The SampleModel associated with this Analysis.""" + return self._sample_model + + @sample_model.setter + def sample_model(self, value: SampleModel | None) -> None: + if value is not None and not isinstance(value, SampleModel): + raise TypeError('sample_model must be an instance of SampleModel or None.') + self._sample_model = value + self._update_models() + + @property + def resolution_model(self) -> ResolutionModel | None: + """The ResolutionModel associated with this Analysis.""" + return self._resolution_model + + @resolution_model.setter + def resolution_model(self, value: ResolutionModel | None) -> None: + if value is not None and not isinstance(value, ResolutionModel): + raise TypeError('resolution_model must be an instance of ResolutionModel or None.') + self._resolution_model = value + self._update_models() + + @property + def background_model(self) -> BackgroundModel | None: + """The BackgroundModel associated with this Analysis.""" + return self._background_model + + @background_model.setter + def background_model(self, value: BackgroundModel | None) -> None: + if value is not None and not isinstance(value, BackgroundModel): + raise TypeError('background_model must be an instance of BackgroundModel or None.') + self._background_model = value + self._update_models() + + @property + def Q(self) -> sc.Variable | None: + """The Q values from the associated Experiment, if available.""" + if self.experiment is not None: + return self.experiment.Q + return None + + @Q.setter + def Q(self, value) -> None: + """Q is a read-only property derived from the Experiment.""" + raise AttributeError('Q is a read-only property derived from the Experiment.') + + @property + def energy(self) -> sc.Variable | None: + """The energy values from the associated Experiment, if + available. + """ + if self.experiment is not None: + return self.experiment.energy + return None + + @energy.setter + def energy(self, value) -> None: + """Energy is a read-only property derived from the + Experiment. + """ + raise AttributeError('energy is a read-only property derived from the Experiment.') + + @property + def temperature(self) -> Parameter | None: + """The temperature from the associated Experiment, if + available. + """ + return self.sample_model.temperature if self.sample_model is not None else None + + @temperature.setter + def temperature(self, value) -> None: + """Temperature is a read-only property derived from the + Experiment. + """ + raise AttributeError('temperature is a read-only property derived from the sample model.') + + @property + def energy_offset(self) -> list[Parameter] | None: + """Get the energy offsets for each Q value.""" + return self._energy_offset + + @energy_offset.setter + def energy_offset(self, offsets: list[Parameter] | None) -> None: + """Set the energy offsets for each Q value. + + Args: + offsets (list[Parameter] | None): The list of energy + offsets. + Raises: + TypeError: If offsets is not a list of Parameters or + None. + """ + if offsets is not None: + if len(offsets) != len(self.Q): + raise ValueError('energy_offset list length must match number of Q values.') + for offset in offsets: + if not isinstance(offset, Parameter): + raise TypeError('Each energy_offset must be an instance of Parameter.') + self._energy_offset = offsets + + @property + def Q_index(self) -> int | None: + """Get the Q index for single Q analysis.""" + return self._Q_index + + @Q_index.setter + def Q_index(self, index: int | None) -> None: + """Set the Q index for single Q analysis. + + Args: + index (int | None): The Q index. + """ + if index is not None: + if ( + not isinstance(index, int) + or index < 0 + or (self.Q is not None and index >= len(self.Q)) + ): + raise ValueError('Q_index must be a valid index for the Q values.') + self._Q_index = index + + ############# + # Other methods + ############# + + def calculate(self, energy: float | None = None) -> np.ndarray: + """Calculate the model prediction for a given Q index. + + Args: + energy (float): The energy value to calculate the model for. + Returns: + sc.DataArray: The calculated model prediction. + """ + Q_index = self.Q_index + if Q_index is None: + raise ValueError('Q_index must be set to calculate the model.') + + if energy is None: + energy = self.energy.values + + # TODO: handle units properly + energy = energy - self.energy_offset[Q_index].value + if self.sample_model is None: + sample_intensity = np.zeros_like(energy) + else: + if self.resolution_model is None: + sample_intensity = self.sample_model._component_collections[Q_index].evaluate( + energy + ) + else: + convolver = self._convolvers[Q_index] + sample_intensity = convolver.convolution() + + if self.background_model is None: + background_intensity = np.zeros_like(energy) + else: + background_intensity = self.background_model._component_collections[Q_index].evaluate( + energy + ) + + sample_plus_background = sample_intensity + background_intensity + + return sample_plus_background + + def calculate_individual_components( + self, + ) -> tuple[list[np.ndarray], list[np.ndarray]]: + """Calculate the model prediction for a given Q index for each + individual component. + + Args: + Q_index (int): The index of the Q value to calculate the + model for. + Returns: + list[np.ndarray]: The calculated model predictions for each + individual component. + """ + sample_results = [] + background_results = [] + Q_index = self.Q_index + if Q_index is None: + raise ValueError('Q_index must be set to calculate the model.') + + if self.sample_model is not None: + # Calculate sample components + for component in self.sample_model._component_collections[Q_index]._components: + if self.resolution_model is None: + component_intensity = component.evaluate(self.energy) + else: + convolver = Convolution( + sample_components=component, + resolution_components=self.resolution_model._component_collections[ + Q_index + ], + energy=self.energy, + temperature=self.temperature, + ) + component_intensity = convolver.convolution() + sample_results.append(component_intensity) + + if self.background_model is not None: + # Calculate background components + for component in self.background_model._component_collections[Q_index]._components: + component_intensity = component.evaluate(self.energy) + background_results.append(component_intensity) + + return sample_results, background_results + + def fit(self): + """Fit the model to the experimental data for a given Q index. + + Args: + Returns: + FitResult: The result of the fit. + """ + if self._experiment is None: + raise ValueError('No experiment is associated with this Analysis.') + + Q_index = self.Q_index + if Q_index is None: + raise ValueError('Q_index must be set to perform the fit.') + + data = self.experiment.data['Q', Q_index] + x = data.coords['energy'].values + y = data.values + e = data.variances**0.5 + + def fit_func(x_vals): + return self.calculate(energy=x_vals) + + fitter = EasyScienceFitter( + fit_object=self, + fit_function=fit_func, + ) + + # Perform the fit + fit_result = fitter.fit(x=x, y=y, weights=1.0 / e) + + # Store result + self.fit_result = fit_result + + return fit_result + + def plot_data_and_model( + self, + plot_individual_components: bool = True, + ) -> None: + """Plot the experimental data and the model prediction. + + Args: + plot_individual_components (bool): Whether to plot + individual components. Default is True. + """ + if not isinstance(plot_individual_components, bool): + raise TypeError('plot_individual_components must be True or False.') + + import matplotlib.pyplot as plt + + Q_index = self.Q_index + if Q_index is None: + raise ValueError('Q_index must be set to plot the data and model.') + if self.experiment is None or self.experiment.data is None: + raise ValueError('Experiment data is not available for plotting.') + data = self.experiment.data['Q', Q_index] + energy = data.coords['energy'].values + model = self.calculate(energy=energy) + plt.figure() + plt.errorbar( + energy, + data.values, + yerr=data.variances**0.5, + fmt='o', + label='Data', + color='black', + ) + plt.plot(energy, model, label='Model', color='red') + if plot_individual_components: + sample_comps, background_comps = self.calculate_individual_components() + for i, comp in enumerate(sample_comps): + plt.plot( + energy, + comp, + label=f'Sample Component {i + 1}', + linestyle='--', + ) + for i, comp in enumerate(background_comps): + plt.plot( + energy, + comp, + label=f'Background Component {i + 1}', + linestyle=':', + ) + plt.xlabel(f'Energy ({self.energy.unit})') + plt.ylabel(f'Intensity ({self.sample_model.unit})') + plt.title(f'Data and Model at Q index {Q_index}') + plt.legend() + plt.show() + # model_data_array = self._create_model_data_group( + # individual_components=plot_individual_components ) if + # self.experiment is None or self.experiment.data is None: raise + # ValueError("Experiment data is not available for plotting.") + + # from IPython.display import display + + # fig = pp.slicer( + # {"Data": self.experiment.data, "Model": model_data_array}, + # color={"Data": "black", "Model": "red"}, + # linestyle={"Data": "none", "Model": "solid"}, + # marker={"Data": "o", "Model": "None"}, + # ) + # display(fig) + + def get_all_variables(self) -> list[DescriptorNumber]: + """Get all variables used in the analysis. + + Returns: + List[Descriptor]: A list of all variables. + """ + variables = [] + if self.sample_model is not None: + variables.extend( + self.sample_model._component_collections[self.Q_index].get_all_variables() + ) + if self.resolution_model is not None: + variables.extend( + self.resolution_model._component_collections[self.Q_index].get_all_variables() + ) + if self.background_model is not None: + variables.extend( + self.background_model._component_collections[self.Q_index].get_all_variables() + ) + variables.append(self.energy_offset[self.Q_index]) + # TODO temperature and diffusion + return variables + + ############# + # Private methods + ############# + + def _update_models(self): + """Update models based on the current experiment.""" + if self.experiment is None: + return + + for Q_index in range(len(self.Q)): + self._convolvers[Q_index] = self._create_convolver(Q_index) + + def _create_convolver(self, Q_index: int): + """Initialize and return a Convolution object for the given Q + index. + """ + if self.sample_model is None or self.resolution_model is None: + raise ValueError('Both sample_model and resolution_model must be defined.') + + sample_components = self.sample_model._component_collections[Q_index] + resolution_components = self.resolution_model._component_collections[Q_index] + energy = self.energy + convolver = Convolution( + sample_components=sample_components, + resolution_components=resolution_components, + energy=energy, + temperature=self.temperature, + ) + return convolver + + def _create_model_data_group(self, individual_components=True) -> sc.DataArray: + """Create a Scipp DataArray representing the model over all Q + and energy values. + """ + if self.Q is None or self.energy is None: + raise ValueError('Q and energy must be defined in the experiment.') + + model_data = [] + for Q_index in range(len(self.Q)): + model_at_Q = self.calculate(Q_index) + model_data.append(model_at_Q) + + model_data_array = sc.DataArray( + data=sc.array(dims=['Q', 'energy'], values=model_data), + coords={ + 'Q': self.Q, + 'energy': self.energy, + }, + ) + model_group = sc.DataGroup({'Model': model_data_array}) + + if individual_components: + components = self.calculate_individual_components_all_Q() + for Q_index, (sample_comps, background_comps) in enumerate(components): + for samp_index, samp_comp in enumerate(sample_comps): + model_data_array[samp_comp.display_name] = sc.zeros_like(model_data_array.data) + model_data_array[samp_comp.display_name].data[Q_index, :] = samp_comp + for back_index, back_comp in enumerate(background_comps): + model_data_array[back_comp.display_name] = sc.zeros_like(model_data_array.data) + model_data_array[back_comp.display_name].data[Q_index, :] = back_comp + + model_data_array = model_data_array + model_group # WRONG BUT LINT + return model_data_array diff --git a/src/easydynamics/convolution/convolution_base.py b/src/easydynamics/convolution/convolution_base.py index 34eab3f4..cfe364b0 100644 --- a/src/easydynamics/convolution/convolution_base.py +++ b/src/easydynamics/convolution/convolution_base.py @@ -54,6 +54,8 @@ def __init__( raise TypeError( f'`sample_components` is an instance of {type(sample_components).__name__}, but must be a ComponentCollection or ModelComponent.' # noqa: E501 ) + if isinstance(sample_components, ModelComponent): + sample_components = ComponentCollection(components=[sample_components]) self._sample_components = sample_components if resolution_components is not None and not ( @@ -63,6 +65,8 @@ def __init__( raise TypeError( f'`resolution_components` is an instance of {type(resolution_components).__name__}, but must be a ComponentCollection or ModelComponent.' # noqa: E501 ) + if isinstance(resolution_components, ModelComponent): + resolution_components = ComponentCollection(components=[resolution_components]) self._resolution_components = resolution_components @property diff --git a/src/easydynamics/sample_model/__init__.py b/src/easydynamics/sample_model/__init__.py index 5929fc50..193ba7f5 100644 --- a/src/easydynamics/sample_model/__init__.py +++ b/src/easydynamics/sample_model/__init__.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors # SPDX-License-Identifier: BSD-3-Clause +from .background_model import BackgroundModel from .component_collection import ComponentCollection from .components import DampedHarmonicOscillator from .components import DeltaFunction @@ -9,6 +10,8 @@ from .components import Polynomial from .components import Voigt from .diffusion_model.brownian_translational_diffusion import BrownianTranslationalDiffusion +from .resolution_model import ResolutionModel +from .sample_model import SampleModel __all__ = [ 'ComponentCollection', @@ -19,4 +22,7 @@ 'DampedHarmonicOscillator', 'Polynomial', 'BrownianTranslationalDiffusion', + 'SampleModel', + 'ResolutionModel', + 'BackgroundModel', ] From 5e460ce02c8b5fb8ca082d50df937b9ddb01aa67 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Fri, 6 Feb 2026 11:51:56 +0100 Subject: [PATCH 02/27] make analysis_base --- src/easydynamics/analysis/analysis1d.py | 193 +++++++++--------- src/easydynamics/analysis/analysis_base.py | 189 +++++++++++++++++ .../convolution/numerical_convolution_base.py | 72 ++++--- src/easydynamics/sample_model/__init__.py | 28 +-- .../jump_translational_diffusion.py | 64 +++--- 5 files changed, 374 insertions(+), 172 deletions(-) create mode 100644 src/easydynamics/analysis/analysis_base.py diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index ebad61d2..b27fed1e 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -11,7 +11,7 @@ from easydynamics.convolution import Convolution from easydynamics.experiment import Experiment -from easydynamics.sample_model import BackgroundModel +from easydynamics.sample_model import InstrumentModel from easydynamics.sample_model import ResolutionModel from easydynamics.sample_model import SampleModel @@ -21,63 +21,46 @@ class Analysis1d(EasyScienceModelBase): def __init__( self, - display_name: str = 'MyAnalysis', + display_name: str = "MyAnalysis", unique_name: str | None = None, experiment: Experiment | None = None, sample_model: SampleModel | None = None, - resolution_model: ResolutionModel | None = None, - background_model: BackgroundModel | None = None, - energy_offset: list[Parameter] | None = None, + instrument_model: InstrumentModel | None = None, Q_index: int | None = None, ): super().__init__(display_name=display_name, unique_name=unique_name) if experiment is not None and not isinstance(experiment, Experiment): - raise TypeError('experiment must be an instance of Experiment or None.') + raise TypeError("experiment must be an instance of Experiment or None.") self._experiment = experiment if sample_model is not None and not isinstance(sample_model, SampleModel): - raise TypeError('sample_model must be an instance of SampleModel or None.') + raise TypeError("sample_model must be an instance of SampleModel or None.") sample_model.Q = self.Q self._sample_model = sample_model - if resolution_model is not None and not isinstance(resolution_model, ResolutionModel): - raise TypeError('resolution_model must be an instance of ResolutionModel or None.') - resolution_model.Q = self.Q - self._resolution_model = resolution_model - - if background_model is not None and not isinstance(background_model, BackgroundModel): - raise TypeError('background_model must be an instance of BackgroundModel or None.') - background_model.Q = self.Q - self._background_model = background_model + if instrument_model is not None and not isinstance( + instrument_model, InstrumentModel + ): + raise TypeError( + "instrument_model must be an instance of InstrumentModel or None." + ) + if instrument_model is None: + self._instrument_model = InstrumentModel() + else: + self._instrument_model = instrument_model self._convolvers = [None] * (len(self.Q) if self.Q is not None else 0) self._update_models() - if not isinstance(energy_offset, list) and energy_offset is not None: - raise TypeError('energy_offset must be a list of Parameters or None.') - - if energy_offset is not None: - if len(energy_offset) != len(self.Q): - raise ValueError('energy_offset list length must match number of Q values.') - for offset in energy_offset: - if not isinstance(offset, Parameter): - raise TypeError('Each energy_offset must be an instance of Parameter.') - else: - energy_offset = [ - Parameter(name='energy_offset', value=0.0, unit=self.sample_model.unit) - for _ in range(len(self.Q)) - ] - self._energy_offset = energy_offset - if Q_index is not None: if ( not isinstance(Q_index, int) or Q_index < 0 or (self.Q is not None and Q_index >= len(self.Q)) ): - raise ValueError('Q_index must be a valid index for the Q values.') + raise ValueError("Q_index must be a valid index for the Q values.") self._Q_index = Q_index ############# @@ -92,7 +75,7 @@ def experiment(self) -> Experiment | None: @experiment.setter def experiment(self, value: Experiment | None) -> None: if value is not None and not isinstance(value, Experiment): - raise TypeError('experiment must be an instance of Experiment or None.') + raise TypeError("experiment must be an instance of Experiment or None.") self._experiment = value self._update_models() @@ -104,7 +87,7 @@ def sample_model(self) -> SampleModel | None: @sample_model.setter def sample_model(self, value: SampleModel | None) -> None: if value is not None and not isinstance(value, SampleModel): - raise TypeError('sample_model must be an instance of SampleModel or None.') + raise TypeError("sample_model must be an instance of SampleModel or None.") self._sample_model = value self._update_models() @@ -116,22 +99,12 @@ def resolution_model(self) -> ResolutionModel | None: @resolution_model.setter def resolution_model(self, value: ResolutionModel | None) -> None: if value is not None and not isinstance(value, ResolutionModel): - raise TypeError('resolution_model must be an instance of ResolutionModel or None.') + raise TypeError( + "resolution_model must be an instance of ResolutionModel or None." + ) self._resolution_model = value self._update_models() - @property - def background_model(self) -> BackgroundModel | None: - """The BackgroundModel associated with this Analysis.""" - return self._background_model - - @background_model.setter - def background_model(self, value: BackgroundModel | None) -> None: - if value is not None and not isinstance(value, BackgroundModel): - raise TypeError('background_model must be an instance of BackgroundModel or None.') - self._background_model = value - self._update_models() - @property def Q(self) -> sc.Variable | None: """The Q values from the associated Experiment, if available.""" @@ -142,7 +115,7 @@ def Q(self) -> sc.Variable | None: @Q.setter def Q(self, value) -> None: """Q is a read-only property derived from the Experiment.""" - raise AttributeError('Q is a read-only property derived from the Experiment.') + raise AttributeError("Q is a read-only property derived from the Experiment.") @property def energy(self) -> sc.Variable | None: @@ -158,7 +131,9 @@ def energy(self, value) -> None: """Energy is a read-only property derived from the Experiment. """ - raise AttributeError('energy is a read-only property derived from the Experiment.') + raise AttributeError( + "energy is a read-only property derived from the Experiment." + ) @property def temperature(self) -> Parameter | None: @@ -172,7 +147,9 @@ def temperature(self, value) -> None: """Temperature is a read-only property derived from the Experiment. """ - raise AttributeError('temperature is a read-only property derived from the sample model.') + raise AttributeError( + "temperature is a read-only property derived from the sample model." + ) @property def energy_offset(self) -> list[Parameter] | None: @@ -192,10 +169,14 @@ def energy_offset(self, offsets: list[Parameter] | None) -> None: """ if offsets is not None: if len(offsets) != len(self.Q): - raise ValueError('energy_offset list length must match number of Q values.') + raise ValueError( + "energy_offset list length must match number of Q values." + ) for offset in offsets: if not isinstance(offset, Parameter): - raise TypeError('Each energy_offset must be an instance of Parameter.') + raise TypeError( + "Each energy_offset must be an instance of Parameter." + ) self._energy_offset = offsets @property @@ -216,7 +197,7 @@ def Q_index(self, index: int | None) -> None: or index < 0 or (self.Q is not None and index >= len(self.Q)) ): - raise ValueError('Q_index must be a valid index for the Q values.') + raise ValueError("Q_index must be a valid index for the Q values.") self._Q_index = index ############# @@ -233,7 +214,7 @@ def calculate(self, energy: float | None = None) -> np.ndarray: """ Q_index = self.Q_index if Q_index is None: - raise ValueError('Q_index must be set to calculate the model.') + raise ValueError("Q_index must be set to calculate the model.") if energy is None: energy = self.energy.values @@ -244,9 +225,9 @@ def calculate(self, energy: float | None = None) -> np.ndarray: sample_intensity = np.zeros_like(energy) else: if self.resolution_model is None: - sample_intensity = self.sample_model._component_collections[Q_index].evaluate( - energy - ) + sample_intensity = self.sample_model._component_collections[ + Q_index + ].evaluate(energy) else: convolver = self._convolvers[Q_index] sample_intensity = convolver.convolution() @@ -254,9 +235,9 @@ def calculate(self, energy: float | None = None) -> np.ndarray: if self.background_model is None: background_intensity = np.zeros_like(energy) else: - background_intensity = self.background_model._component_collections[Q_index].evaluate( - energy - ) + background_intensity = self.background_model._component_collections[ + Q_index + ].evaluate(energy) sample_plus_background = sample_intensity + background_intensity @@ -279,11 +260,13 @@ def calculate_individual_components( background_results = [] Q_index = self.Q_index if Q_index is None: - raise ValueError('Q_index must be set to calculate the model.') + raise ValueError("Q_index must be set to calculate the model.") if self.sample_model is not None: # Calculate sample components - for component in self.sample_model._component_collections[Q_index]._components: + for component in self.sample_model._component_collections[ + Q_index + ]._components: if self.resolution_model is None: component_intensity = component.evaluate(self.energy) else: @@ -300,7 +283,9 @@ def calculate_individual_components( if self.background_model is not None: # Calculate background components - for component in self.background_model._component_collections[Q_index]._components: + for component in self.background_model._component_collections[ + Q_index + ]._components: component_intensity = component.evaluate(self.energy) background_results.append(component_intensity) @@ -314,14 +299,14 @@ def fit(self): FitResult: The result of the fit. """ if self._experiment is None: - raise ValueError('No experiment is associated with this Analysis.') + raise ValueError("No experiment is associated with this Analysis.") Q_index = self.Q_index if Q_index is None: - raise ValueError('Q_index must be set to perform the fit.') + raise ValueError("Q_index must be set to perform the fit.") - data = self.experiment.data['Q', Q_index] - x = data.coords['energy'].values + data = self.experiment.data["Q", Q_index] + x = data.coords["energy"].values y = data.values e = data.variances**0.5 @@ -352,47 +337,47 @@ def plot_data_and_model( individual components. Default is True. """ if not isinstance(plot_individual_components, bool): - raise TypeError('plot_individual_components must be True or False.') + raise TypeError("plot_individual_components must be True or False.") import matplotlib.pyplot as plt Q_index = self.Q_index if Q_index is None: - raise ValueError('Q_index must be set to plot the data and model.') + raise ValueError("Q_index must be set to plot the data and model.") if self.experiment is None or self.experiment.data is None: - raise ValueError('Experiment data is not available for plotting.') - data = self.experiment.data['Q', Q_index] - energy = data.coords['energy'].values + raise ValueError("Experiment data is not available for plotting.") + data = self.experiment.data["Q", Q_index] + energy = data.coords["energy"].values model = self.calculate(energy=energy) plt.figure() plt.errorbar( energy, data.values, yerr=data.variances**0.5, - fmt='o', - label='Data', - color='black', + fmt="o", + label="Data", + color="black", ) - plt.plot(energy, model, label='Model', color='red') + plt.plot(energy, model, label="Model", color="red") if plot_individual_components: sample_comps, background_comps = self.calculate_individual_components() for i, comp in enumerate(sample_comps): plt.plot( energy, comp, - label=f'Sample Component {i + 1}', - linestyle='--', + label=f"Sample Component {i + 1}", + linestyle="--", ) for i, comp in enumerate(background_comps): plt.plot( energy, comp, - label=f'Background Component {i + 1}', - linestyle=':', + label=f"Background Component {i + 1}", + linestyle=":", ) - plt.xlabel(f'Energy ({self.energy.unit})') - plt.ylabel(f'Intensity ({self.sample_model.unit})') - plt.title(f'Data and Model at Q index {Q_index}') + plt.xlabel(f"Energy ({self.energy.unit})") + plt.ylabel(f"Intensity ({self.sample_model.unit})") + plt.title(f"Data and Model at Q index {Q_index}") plt.legend() plt.show() # model_data_array = self._create_model_data_group( @@ -419,15 +404,21 @@ def get_all_variables(self) -> list[DescriptorNumber]: variables = [] if self.sample_model is not None: variables.extend( - self.sample_model._component_collections[self.Q_index].get_all_variables() + self.sample_model._component_collections[ + self.Q_index + ].get_all_variables() ) if self.resolution_model is not None: variables.extend( - self.resolution_model._component_collections[self.Q_index].get_all_variables() + self.resolution_model._component_collections[ + self.Q_index + ].get_all_variables() ) if self.background_model is not None: variables.extend( - self.background_model._component_collections[self.Q_index].get_all_variables() + self.background_model._component_collections[ + self.Q_index + ].get_all_variables() ) variables.append(self.energy_offset[self.Q_index]) # TODO temperature and diffusion @@ -450,7 +441,7 @@ def _create_convolver(self, Q_index: int): index. """ if self.sample_model is None or self.resolution_model is None: - raise ValueError('Both sample_model and resolution_model must be defined.') + raise ValueError("Both sample_model and resolution_model must be defined.") sample_components = self.sample_model._component_collections[Q_index] resolution_components = self.resolution_model._component_collections[Q_index] @@ -468,7 +459,7 @@ def _create_model_data_group(self, individual_components=True) -> sc.DataArray: and energy values. """ if self.Q is None or self.energy is None: - raise ValueError('Q and energy must be defined in the experiment.') + raise ValueError("Q and energy must be defined in the experiment.") model_data = [] for Q_index in range(len(self.Q)): @@ -476,23 +467,31 @@ def _create_model_data_group(self, individual_components=True) -> sc.DataArray: model_data.append(model_at_Q) model_data_array = sc.DataArray( - data=sc.array(dims=['Q', 'energy'], values=model_data), + data=sc.array(dims=["Q", "energy"], values=model_data), coords={ - 'Q': self.Q, - 'energy': self.energy, + "Q": self.Q, + "energy": self.energy, }, ) - model_group = sc.DataGroup({'Model': model_data_array}) + model_group = sc.DataGroup({"Model": model_data_array}) if individual_components: components = self.calculate_individual_components_all_Q() for Q_index, (sample_comps, background_comps) in enumerate(components): for samp_index, samp_comp in enumerate(sample_comps): - model_data_array[samp_comp.display_name] = sc.zeros_like(model_data_array.data) - model_data_array[samp_comp.display_name].data[Q_index, :] = samp_comp + model_data_array[samp_comp.display_name] = sc.zeros_like( + model_data_array.data + ) + model_data_array[samp_comp.display_name].data[ + Q_index, : + ] = samp_comp for back_index, back_comp in enumerate(background_comps): - model_data_array[back_comp.display_name] = sc.zeros_like(model_data_array.data) - model_data_array[back_comp.display_name].data[Q_index, :] = back_comp + model_data_array[back_comp.display_name] = sc.zeros_like( + model_data_array.data + ) + model_data_array[back_comp.display_name].data[ + Q_index, : + ] = back_comp model_data_array = model_data_array + model_group # WRONG BUT LINT return model_data_array diff --git a/src/easydynamics/analysis/analysis_base.py b/src/easydynamics/analysis/analysis_base.py new file mode 100644 index 00000000..5d965cae --- /dev/null +++ b/src/easydynamics/analysis/analysis_base.py @@ -0,0 +1,189 @@ +# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-License-Identifier: BSD-3-Clause + + +import scipp as sc +from easyscience.base_classes.model_base import ModelBase as EasyScienceModelBase +from easyscience.variable import Parameter + +from easydynamics.convolution import Convolution +from easydynamics.experiment import Experiment +from easydynamics.sample_model import InstrumentModel +from easydynamics.sample_model import SampleModel + + +class Analysis1Base(EasyScienceModelBase): + """For analysing data.""" + + def __init__( + self, + display_name: str = "MyAnalysis", + unique_name: str | None = None, + experiment: Experiment | None = None, + sample_model: SampleModel | None = None, + instrument_model: InstrumentModel | None = None, + ): + super().__init__(display_name=display_name, unique_name=unique_name) + + if experiment is None: + self._experiment = Experiment() + elif isinstance(experiment, Experiment): + self._experiment = experiment + else: + raise TypeError("experiment must be an instance of Experiment or None.") + + if sample_model is None: + self._sample_model = SampleModel() + elif isinstance(sample_model, SampleModel): + self._sample_model = sample_model + else: + raise TypeError("sample_model must be an instance of SampleModel or None.") + + if instrument_model is None: + self._instrument_model = InstrumentModel() + elif isinstance(instrument_model, InstrumentModel): + self._instrument_model = instrument_model + else: + raise TypeError( + "instrument_model must be an instance of InstrumentModel or None." + ) + + self._convolvers = [None] * (len(self.Q) if self.Q is not None else 0) + self._update_models() + + ############# + # Properties + ############# + + @property + def experiment(self) -> Experiment | None: + """The Experiment associated with this Analysis.""" + return self._experiment + + @experiment.setter + def experiment(self, value: Experiment) -> None: + if not isinstance(value, Experiment): + raise TypeError("experiment must be an instance of Experiment") + self._experiment = value + self._on_experiment_changed() + + @property + def sample_model(self) -> SampleModel: + """The SampleModel associated with this Analysis.""" + return self._sample_model + + @sample_model.setter + def sample_model(self, value: SampleModel) -> None: + if not isinstance(value, SampleModel): + raise TypeError("sample_model must be an instance of SampleModel") + self._sample_model = value + self._on_sample_model_changed() + + @property + def instrument_model(self) -> InstrumentModel: + """The InstrumentModel associated with this Analysis.""" + return self._instrument_model + + @instrument_model.setter + def instrument_model(self, value: InstrumentModel) -> None: + if not isinstance(value, InstrumentModel): + raise TypeError("instrument_model must be an instance of InstrumentModel") + self._instrument_model = value + self._on_instrument_model_changed() + + @property + def Q(self) -> sc.Variable | None: + """The Q values from the associated Experiment, if available.""" + if self.experiment is not None: + return self.experiment.Q + return None + + @Q.setter + def Q(self, value) -> None: + """Q is a read-only property derived from the Experiment.""" + raise AttributeError("Q is a read-only property derived from the Experiment.") + + @property + def energy(self) -> sc.Variable | None: + """The energy values from the associated Experiment, if + available. + """ + if self.experiment is not None: + return self.experiment.energy + return None + + @energy.setter + def energy(self, value) -> None: + """Energy is a read-only property derived from the + Experiment. + """ + raise AttributeError( + "energy is a read-only property derived from the Experiment." + ) + + @property + def temperature(self) -> Parameter | None: + """ + The temperature from the associated SampleModel, if available. + """ + return self.sample_model.temperature if self.sample_model is not None else None + + @temperature.setter + def temperature(self, value) -> None: + """ + Temperature is a read-only property derived from the + SampleModel. + """ + raise AttributeError( + "temperature is a read-only property derived from the sample model." + ) + + ############# + # Other methods + ############# + + ############# + # Private methods + ############# + + def _on_experiment_changed(self): + pass + + def _on_sample_model_changed(self): + pass + + def _on_instrument_model_changed(self): + pass + + # def _update_models(self): + # """Update models based on the current experiment.""" + # if self.experiment is None: + # return + + # for Q_index in range(len(self.Q)): + # self._convolvers[Q_index] = self._create_convolver(Q_index) + + def _create_convolver(self, Q_index: int): + """Initialize and return a Convolution object for the given Q + index. + """ + sample_components = self.sample_model._component_collections[Q_index] + if sample_components == []: + raise ValueError(f"Sample model has no components at Q index {Q_index}.") + + resolution_components = ( + self.instrument_model.resolution_model._component_collections[Q_index] + ) + if resolution_components == []: + raise ValueError( + f"Resolution model has no components at Q index {Q_index}." + ) + + energy = self.energy + convolver = Convolution( + sample_components=sample_components, + resolution_components=resolution_components, + energy=energy, + temperature=self.temperature, + ) + return convolver diff --git a/src/easydynamics/convolution/numerical_convolution_base.py b/src/easydynamics/convolution/numerical_convolution_base.py index ffcf0058..dd3e68e3 100644 --- a/src/easydynamics/convolution/numerical_convolution_base.py +++ b/src/easydynamics/convolution/numerical_convolution_base.py @@ -66,8 +66,8 @@ def __init__( upsample_factor: Numerical = 5, extension_factor: float = 0.2, temperature: Parameter | float | None = None, - temperature_unit: str | sc.Unit = 'K', - energy_unit: str | sc.Unit = 'meV', + temperature_unit: str | sc.Unit = "K", + energy_unit: str | sc.Unit = "meV", normalize_detailed_balance: bool = True, ): super().__init__( @@ -77,11 +77,13 @@ def __init__( energy_unit=energy_unit, ) - if temperature is not None and not isinstance(temperature, (Numerical, Parameter)): - raise TypeError('Temperature must be None, a number or a Parameter.') + if temperature is not None and not isinstance( + temperature, (Numerical, Parameter) + ): + raise TypeError("Temperature must be None, a number or a Parameter.") if not isinstance(temperature_unit, (str, sc.Unit)): - raise TypeError('Temperature_unit must be a string or sc.Unit.') + raise TypeError("Temperature_unit must be a string or sc.Unit.") self._temperature_unit = temperature_unit self._temperature = None self.temperature = temperature @@ -117,10 +119,10 @@ def upsample_factor(self, factor: Numerical) -> None: return if not isinstance(factor, Numerical): - raise TypeError('Upsample factor must be a numerical value or None.') + raise TypeError("Upsample factor must be a numerical value or None.") factor = float(factor) if factor <= 1.0: - raise ValueError('Upsample factor must be greater than 1.') + raise ValueError("Upsample factor must be greater than 1.") self._upsample_factor = factor @@ -156,9 +158,9 @@ def extension_factor(self, factor: Numerical) -> None: TypeError: If factor is not a number. """ if not isinstance(factor, Numerical): - raise TypeError('Extension factor must be a number.') + raise TypeError("Extension factor must be a number.") if factor < 0.0: - raise ValueError('Extension factor must be non-negative.') + raise ValueError("Extension factor must be non-negative.") self._extension_factor = factor # Recreate dense grid when extension factor is updated @@ -192,7 +194,7 @@ def temperature(self, temp: Parameter | float | None) -> None: self._temperature.value = float(temp) else: self._temperature = Parameter( - name='temperature', + name="temperature", value=float(temp), unit=self._temperature_unit, fixed=True, @@ -200,7 +202,7 @@ def temperature(self, temp: Parameter | float | None) -> None: elif isinstance(temp, Parameter): self._temperature = temp else: - raise TypeError('Temperature must be None, a float or a Parameter.') + raise TypeError("Temperature must be None, a float or a Parameter.") @property def normalize_detailed_balance(self) -> bool: @@ -221,7 +223,7 @@ def normalize_detailed_balance(self, normalize: bool) -> None: """ if not isinstance(normalize, bool): - raise TypeError('normalize_detailed_balance must be True or False.') + raise TypeError("normalize_detailed_balance must be True or False.") self._normalize_detailed_balance = normalize @@ -239,9 +241,9 @@ def _create_energy_grid( The dense grid created by upsampling and extending energy. The EnergyGrid has the following attributes: - energy_dense : np.ndarray + energy_dense : np.ndarray The upsampled and extended energy array. - energy_dense_centered : np.ndarray + energy_dense_centered : np.ndarray The centered version of energy_dense (used for resolution evaluation). energy_dense_step : float @@ -259,7 +261,7 @@ def _create_energy_grid( is_uniform = np.allclose(energy_diff, energy_diff[0]) if not is_uniform: raise ValueError( - 'Input array `energy` must be uniformly spaced if upsample_factor is not given.' # noqa: E501 + "Input array `energy` must be uniformly spaced if upsample_factor is not given." # noqa: E501 ) energy_dense = self.energy.values @@ -276,7 +278,7 @@ def _create_energy_grid( energy_span_dense = extended_max - extended_min if len(energy_dense) < 2: - raise ValueError('Energy array must have at least two points.') + raise ValueError("Energy array must have at least two points.") energy_dense_step = energy_dense[1] - energy_dense[0] # Handle offset for even length of energy_dense in convolution. @@ -346,35 +348,41 @@ def _check_width_thresholds( components = [model] # Treat single ModelComponent as a list for comp in components: - if hasattr(comp, 'width'): - if comp.width.value > LARGE_WIDTH_THRESHOLD * self._energy_grid.energy_span_dense: + if hasattr(comp, "width"): + if ( + comp.width.value + > LARGE_WIDTH_THRESHOLD * self._energy_grid.energy_span_dense + ): warnings.warn( f"The width of the {model_name} component '{comp.unique_name}' \ ({comp.width.value}) is large compared to the span of the input " - f'array ({self._energy_grid.energy_span_dense}). \ + f"array ({self._energy_grid.energy_span_dense}). \ This may lead to inaccuracies in the convolution. \ - Increase extension_factor to improve accuracy.', + Increase extension_factor to improve accuracy.", UserWarning, ) - if comp.width.value < SMALL_WIDTH_THRESHOLD * self._energy_grid.energy_dense_step: + if ( + comp.width.value + < SMALL_WIDTH_THRESHOLD * self._energy_grid.energy_dense_step + ): warnings.warn( f"The width of the {model_name} component '{comp.unique_name}' \ ({comp.width.value}) is small compared to the spacing of the input " - f'array ({self._energy_grid.energy_dense_step}). \ + f"array ({self._energy_grid.energy_dense_step}). \ This may lead to inaccuracies in the convolution. \ - Increase upsample_factor to improve accuracy.', + Increase upsample_factor to improve accuracy.", UserWarning, ) def __repr__(self) -> str: return ( - f'{self.__class__.__name__}(' - f'energy=array of shape {self.energy.values.shape},\n ' - f'sample_components={repr(self.sample_components)}, \n' - f'resolution_components={repr(self.resolution_components)},\n ' - f'energy_unit={self._energy_unit}, ' - f'upsample_factor={self.upsample_factor}, ' - f'extension_factor={self.extension_factor}, ' - f'temperature={self.temperature}, ' - f'normalize_detailed_balance={self.normalize_detailed_balance})' + f"{self.__class__.__name__}(" + f"energy=array of shape {self.energy.values.shape},\n " + f"sample_components={repr(self.sample_components)}, \n" + f"resolution_components={repr(self.resolution_components)},\n " + f"energy_unit={self._energy_unit}, " + f"upsample_factor={self.upsample_factor}, " + f"extension_factor={self.extension_factor}, " + f"temperature={self.temperature}, " + f"normalize_detailed_balance={self.normalize_detailed_balance})" ) diff --git a/src/easydynamics/sample_model/__init__.py b/src/easydynamics/sample_model/__init__.py index 193ba7f5..443c1982 100644 --- a/src/easydynamics/sample_model/__init__.py +++ b/src/easydynamics/sample_model/__init__.py @@ -9,20 +9,24 @@ from .components import Lorentzian from .components import Polynomial from .components import Voigt -from .diffusion_model.brownian_translational_diffusion import BrownianTranslationalDiffusion +from .diffusion_model.brownian_translational_diffusion import ( + BrownianTranslationalDiffusion, +) +from .instrument_model import InstrumentModel from .resolution_model import ResolutionModel from .sample_model import SampleModel __all__ = [ - 'ComponentCollection', - 'Gaussian', - 'Lorentzian', - 'Voigt', - 'DeltaFunction', - 'DampedHarmonicOscillator', - 'Polynomial', - 'BrownianTranslationalDiffusion', - 'SampleModel', - 'ResolutionModel', - 'BackgroundModel', + "ComponentCollection", + "Gaussian", + "Lorentzian", + "Voigt", + "DeltaFunction", + "DampedHarmonicOscillator", + "Polynomial", + "BrownianTranslationalDiffusion", + "SampleModel", + "ResolutionModel", + "BackgroundModel", + "InstrumentModel", ] diff --git a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py index 8bb65480..286ea486 100644 --- a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py @@ -9,7 +9,9 @@ from easydynamics.sample_model.component_collection import ComponentCollection from easydynamics.sample_model.components import Lorentzian -from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase +from easydynamics.sample_model.diffusion_model.diffusion_model_base import ( + DiffusionModelBase, +) from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import Q_type from easydynamics.utils.utils import _validate_and_convert_Q @@ -31,9 +33,9 @@ class JumpTranslationalDiffusion(DiffusionModelBase): def __init__( self, - display_name: str | None = 'JumpTranslationalDiffusion', + display_name: str | None = "JumpTranslationalDiffusion", unique_name: str | None = None, - unit: str | sc.Unit = 'meV', + unit: str | sc.Unit = "meV", scale: Numeric = 1.0, diffusion_coefficient: Numeric = 1.0, relaxation_time: Numeric = 1.0, @@ -50,11 +52,11 @@ def __init__( unit : str or sc.Unit, optional Energy unit for the underlying Lorentzian components. Defaults to "meV". - scale : float , optional + scale : float, optional Scale factor for the diffusion model. - diffusion_coefficient : float , optional + diffusion_coefficient : float, optional Diffusion coefficient D in m^2/s. Defaults to 1.0. - relaxation_time : float , optional + relaxation_time : float, optional Relaxation time t in ps. Defaults to 1.0. """ super().__init__( @@ -65,27 +67,27 @@ def __init__( ) if not isinstance(diffusion_coefficient, Numeric): - raise TypeError('diffusion_coefficient must be a number.') + raise TypeError("diffusion_coefficient must be a number.") if not isinstance(relaxation_time, Numeric): - raise TypeError('relaxation_time must be a number.') + raise TypeError("relaxation_time must be a number.") diffusion_coefficient = Parameter( - name='diffusion_coefficient', + name="diffusion_coefficient", value=float(diffusion_coefficient), fixed=False, - unit='m**2/s', + unit="m**2/s", ) relaxation_time = Parameter( - name='relaxation_time', + name="relaxation_time", value=float(relaxation_time), fixed=False, - unit='ps', + unit="ps", ) - self._hbar = DescriptorNumber.from_scipp('hbar', scipp_hbar) - self._angstrom = DescriptorNumber('angstrom', 1e-10, unit='m') + self._hbar = DescriptorNumber.from_scipp("hbar", scipp_hbar) + self._angstrom = DescriptorNumber("angstrom", 1e-10, unit="m") self._diffusion_coefficient = diffusion_coefficient self._relaxation_time = relaxation_time @@ -108,7 +110,7 @@ def diffusion_coefficient(self) -> Parameter: def diffusion_coefficient(self, diffusion_coefficient: Numeric) -> None: """Set the diffusion coefficient parameter D.""" if not isinstance(diffusion_coefficient, Numeric): - raise TypeError('diffusion_coefficient must be a number.') + raise TypeError("diffusion_coefficient must be a number.") self._diffusion_coefficient.value = diffusion_coefficient @property @@ -126,7 +128,7 @@ def relaxation_time(self) -> Parameter: def relaxation_time(self, relaxation_time: Numeric) -> None: """Set the relaxation time parameter t.""" if not isinstance(relaxation_time, Numeric): - raise TypeError('relaxation_time must be a number.') + raise TypeError("relaxation_time must be a number.") self._relaxation_time.value = relaxation_time ################################ @@ -161,7 +163,7 @@ def calculate_width(self, Q: Q_type) -> np.ndarray: unit_conversion_factor_denominator = ( self.diffusion_coefficient / self._angstrom**2 * self.relaxation_time ) - unit_conversion_factor_denominator.convert_unit('dimensionless') + unit_conversion_factor_denominator.convert_unit("dimensionless") denominator = 1 + unit_conversion_factor_denominator.value * Q**2 @@ -207,7 +209,7 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: def create_component_collections( self, Q: Q_type, - component_display_name: str = 'Jump translational diffusion', + component_display_name: str = "Jump translational diffusion", ) -> List[ComponentCollection]: """Create ComponentCollection components for the diffusion model at given Q values. @@ -227,7 +229,7 @@ def create_component_collections( Q = _validate_and_convert_Q(Q) if not isinstance(component_display_name, str): - raise TypeError('component_name must be a string.') + raise TypeError("component_name must be a string.") component_collection_list = [None] * len(Q) # In more complex models, this is used to scale the area of the @@ -239,7 +241,7 @@ def create_component_collections( # is 0. for i, Q_value in enumerate(Q): component_collection_list[i] = ComponentCollection( - display_name=f'{self.display_name}_Q{Q_value:.2f}', unit=self.unit + display_name=f"{self.display_name}_Q{Q_value:.2f}", unit=self.unit ) lorentzian_component = Lorentzian( @@ -288,20 +290,20 @@ def _write_width_dependency_expression(self, Q: float) -> str: Dependency expression for the width. """ if not isinstance(Q, (float)): - raise TypeError('Q must be a float.') + raise TypeError("Q must be a float.") # Q is given as a float, so we need to add the units - return f'hbar * D* {Q} **2/(angstrom**2)/(1 + (D * t* {Q} **2/(angstrom**2)))' + return f"hbar * D* {Q} **2/(angstrom**2)/(1 + (D * t* {Q} **2/(angstrom**2)))" def _write_width_dependency_map_expression(self) -> Dict[str, DescriptorNumber]: """Write the dependency map expression to make dependent Parameters. """ return { - 'D': self._diffusion_coefficient, - 't': self._relaxation_time, - 'hbar': self._hbar, - 'angstrom': self._angstrom, + "D": self._diffusion_coefficient, + "t": self._relaxation_time, + "hbar": self._hbar, + "angstrom": self._angstrom, } def _write_area_dependency_expression(self, QISF: float) -> str: @@ -314,16 +316,16 @@ def _write_area_dependency_expression(self, QISF: float) -> str: Dependency expression for the area. """ if not isinstance(QISF, (float)): - raise TypeError('QISF must be a float.') + raise TypeError("QISF must be a float.") - return f'{QISF} * scale' + return f"{QISF} * scale" def _write_area_dependency_map_expression(self) -> Dict[str, DescriptorNumber]: """Write the dependency map expression to make dependent Parameters. """ return { - 'scale': self._scale, + "scale": self._scale, } ################################ @@ -335,6 +337,6 @@ def __repr__(self): model. """ return ( - f'JumpTranslationalDiffusion(display_name={self.display_name}, ' - f'diffusion_coefficient={self._diffusion_coefficient}, scale={self._scale})' + f"JumpTranslationalDiffusion(display_name={self.display_name}, " + f"diffusion_coefficient={self._diffusion_coefficient}, scale={self._scale})" ) From 74629d888c16051b2be8f828beaaf8b388eb2e60 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Fri, 6 Feb 2026 11:52:09 +0100 Subject: [PATCH 03/27] test things in notebook --- docs/docs/tutorials/convolution.ipynb | 150 +++++++++++++++++++++++++- 1 file changed, 145 insertions(+), 5 deletions(-) diff --git a/docs/docs/tutorials/convolution.ipynb b/docs/docs/tutorials/convolution.ipynb index 922970f9..6d864c64 100644 --- a/docs/docs/tutorials/convolution.ipynb +++ b/docs/docs/tutorials/convolution.ipynb @@ -16,7 +16,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "id": "1", "metadata": {}, "outputs": [], @@ -37,10 +37,36 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "2", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "a41109d24dec4f28bc04854ecb5c0a21", + "version_major": 2, + "version_minor": 0 + }, + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAoAAAAHgCAYAAAA10dzkAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQAArRFJREFUeJzs3Qd4U9X7B/BvVvdeQNlQ9t5btiCCoAiKCiiCC0XA7e+vuBAXbkQUBXGhgKACMmXI3nvvsqF00N0m+T/vaRPSnUIhSfP9+ETSm5vk5t7k5s17znmPxmw2m0FEREREbkPr6A0gIiIioluLASARERGRm2EASERERORmGAASERERuRkGgERERERuhgEgERERkZthAEhERETkZhgAEhEREbkZBoBEREREboYBIBEREZGbYQBIRERE5GYYABIRERG5GQaARERERG6GASARERGRm2EASERERORmGAASERERuRkGgERERERuhgEgERERkZthAEhERETkZhgAEhEREbkZBoBEREREboYBIBEREZGbYQBIRERE5GYYABIRERG5GQaARERERG6GASARERGRm2EASERERORmGAASERERuRkGgERERERuhgEgERERkZthAEhERETkZhgAEhEREbkZBoBEREREboYBIBEREZGbYQBITmnlypXQaDTq35L08MMPo0qVKnBmiYmJGD58OMqWLav2wejRo1EavfHGG+r1lQbyOuT1FNeJEyfUfadPn35Ttqs473dZ18/PD6VVp06d1KUk3ezjV9rOzbKf5L6y38jxGACWMkePHsXjjz+OatWqwcvLCwEBAWjXrh0+++wzpKSkwB2cPXtWfRnv2LEDrujdd99VJ8onn3wSP/74IwYPHlzguunp6erYNmnSRB3roKAg1KtXD4899hgOHDgAd2L5cpHLmjVr8txuNptRsWJFdXvv3r3hjpKTk9Vno6R/WAkJriz7Xy7e3t5o2LAhPv30U5hMJriyX375Rb0OZyIBu+xn+dznd24/fPiw9Vh89NFHDtlGcm56R28AlZwFCxZgwIAB8PT0xJAhQ1C/fn0VIMiX4QsvvIC9e/fim2++cYsA8M0331SZj8aNG+e47dtvv3X6L6N///0XrVu3xrhx44pct3///vjnn38waNAgjBgxAhkZGSrwmz9/Ptq2bYvatWvD3cgPH/nCbt++fY7lq1at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", + "text/html": [ + "\n", + "
\n", + "
\n", + " Figure\n", + "
\n", + " \n", + "
\n", + " " + ], + "text/plain": [ + "Canvas(toolbar=Toolbar(toolitems=[('Home', 'Reset original view', 'home', 'home'), ('Back', 'Back to previous …" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "# Standard example of convolution of a sample model with a\n", "# resolution model\n", @@ -83,10 +109,36 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "id": "3", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "aeb582a159c74ff6b51ca384adb1a903", + "version_major": 2, + "version_minor": 0 + }, + "image/png": "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", + "text/html": [ + "\n", + "
\n", + "
\n", + " Figure\n", + "
\n", + " \n", + "
\n", + " " + ], + "text/plain": [ + "Canvas(toolbar=Toolbar(toolitems=[('Home', 'Reset original view', 'home', 'home'), ('Back', 'Back to previous …" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "# Use some of the extra settings for the numerical convolution\n", "sample_components = ComponentCollection()\n", @@ -145,6 +197,94 @@ "plt.ylim(0, 2.5)\n", "plt.show()" ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "c318f9b8", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "e7d510c769bb4fca9c0f54ca3f0431bd", + "version_major": 2, + "version_minor": 0 + }, + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAoAAAAHgCAYAAAA10dzkAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQAAsJdJREFUeJzs3QV4U1cbB/B/XWmp0hZ3d3d33waMjeFsgwkb2/jGjBkTNmTCYIzh23DYkOHu7u6lUFraUvfme94T0lWhpRL7/3guSW9uck9ukps3R95jodFoNCAiIiIis2Gp7wIQERERUeFiAEhERERkZhgAEhEREZkZBoBEREREZoYBIBEREZGZYQBIREREZGYYABIRERGZGQaARERERGaGASARERGRmWEASERERGRmGAASERERmRkGgERERERmhgEgERERkZlhAEhERERkZhgAEhEREZkZBoBEREREZoYBIBEREZGZYQBIREREZGYYABIRERGZGQaARERERGaGASARERGRmWEASERERGRmGAASERERmRkGgERERERmhgEgERERkZlhAEhERERkZhgAEhEREZkZBoBEREREZoYBIBEREZGZYQBIREREZGYYABIRERGZGQaARERERGaGASARERGRmWEASERERGRmGACSQdq5cycsLCzUZX4aOnQoypQpA0MWFRWFkSNHwsfHRx2Dt956C6bo008/Vc/PFMjzkOeTWzdv3lT3nT9/foGUKzfvd9nW2dkZpqpNmzZqyU8F/fqZ2rlZjpPcV44b6R8DQBNz7do1vPLKKyhXrhzs7e3h4uKC5s2b44cffkBsbCzMwd27d9WX8cmTJ2GMvvrqK3WiHD16NBYtWoSXXnop220TEhLUa1u3bl31WhctWhTVq1fHyy+/jIsXL8Kc6L5cZNm7d2+m2zUaDUqWLKlu79GjB8xRTEyM+mzk9w8rIcGV7vjL4uDggFq1amH69OlISUmBMfvzzz/V8zAkErDLcZbPfVbn9itXrqS+Ft9//71eykiGzVrfBaD8s379evTr1w92dnYYPHgwatSooQIE+TJ87733cO7cOcyePdssAsDPPvtM1XzUqVMn3W2//fabwX8Zbd++HU2aNMHEiROfuO2zzz6Lf//9FwMHDsSoUaOQmJioAr9169ahWbNmqFKlCsyN/PCRL+wWLVqkW79r1y7cuXNHfT7MRcb3uwSA8tkQ+V0bJkqUKIGvv/5aXX/w4IF6Hd5++20EBwdj0qRJMFbyPM6ePZupNr506dIq+LKxsdFLuaytrdVrunbtWvTv3z/dbX/88Yf6LMTFxemlbGT4GACaiBs3buD5559XJyQJIHx9fVNve+2113D16lUVIJo7fZ2ocyMoKAjVqlV74nZHjhxRgZ58sX7wwQfpbvv555/x8OFDmKNu3bph+fLl+PHHH9UXZNov8fr166vAxFwU9vvd1dUVgwYNSv371VdfVT9CfvrpJ3z++eewsrKCKZHaNQmy9EV+zEgLz19//ZUpAJT3e/fu3bFy5Uq9lY8MG5uATcTkyZNV37Hff/89XfCnU6FCBYwdOzb176SkJHzxxRcoX768OolIbZkEEfHx8enuJ+uluUxqERs1aqROdtK8vHDhwtRtjh49qk6ECxYsyLTfTZs2qdskUNE5ceIEunbtqpoupM9R+/btcfDgwSc+RymLNHs8rm+PNG01bNhQXR82bFhqE4iuj05WfaKio6PxzjvvqOZBORaVK1dWTSbSZJiWPM7rr7+ONWvWqNpV2VaaWzdu3IicBnYjRoxAsWLF1HGsXbt2umOm61sjwbwE67qyZ9dfRpr7hXwBZCRftB4eHql/37p1C2PGjFHPTZrm5DapLc742LpmVHm933zzTXh5ealmZelWILXJElRK7bKbm5taxo8fn+446fpEyfGbNm2a+kEi+2vdurWqQcmJxYsXq0BN7ufu7q5+2Pj7+yOnpDY0JCQEW7ZsSV0nZV+xYgVeeOGFLO+T0/eAfD6kRkuOS5EiRdCrVy9Vq5iVgIAADB8+XL3euvfK3LlzkVtyzOX1lIBWR4JYS0tL9TqmLaN0G5C+ozpp3+/y2ki5hdQC6t5fGfsuSrn79OmjPpuy/bvvvovk5GQ8DXmfy+cxMjJSvf9z+zpLM6bUcstzkseSGkbZLjw8PNfnspz2R8vYx03OLfJ5lM+Q7pilPaZZ9QGUH+EtW7aEk5OT+vz07t0bFy5cyLIPrPw4l9dJtpMAWs5bUquXU/KellaAtD/45MehHLvs3u/Xr19Xn3857o6OjqrFIasKAnlvy3tBnoe3t7d672d3XA8dOoQuXbqo5yCPKZ/5ffv25fh5UOFjAGgipAlAAjNp9ssJGWTwySefoF69euqLWj6s0nQjJ9eM5AT13HPPoWPHjpgyZYr64pcTljQpiwYNGqh9L1u2LNN9ly5dqrbv3Lmz+lvuIyfGU6dOqeDh448/VgGPnGTlBJJXVatWVTUNQvrBSR86WVq1apXl9vLlKV/icgzk5DV16lT15S9N5uPGjcu0vQRGEkjJcZKgW5pX5AtKAo7HkWYieY5SlhdffBHfffedOlHKcZQ+fLqyy+2enp6q6VpXdt2XdkYSXOmaeuRL8HHkC2H//v2q3BJISM3Mtm3bVJmy+rJ544031BeIBApyfKTrgLxWPXv2VMGA9FOUJlZ5HlLGjOQHguxHap8nTJiggr927drh/v37jy2n1GZKgFmxYkX1WkiTm5RTXr+c1mjKl3PTpk1VrYiOfEFK0JDV+zs37wH53EhfsE6dOuGbb75RNWxSy5KRPE/5Ut26dav60SCvsfwIkx8Aue1LJoGB/ODYvXt3uvehBA+hoaE4f/586vo9e/aoz1dW5H00c+ZMdb1v376p769nnnkmdRt5beWzKoGlBMByXpDPfF66juiCJHkeuXmdJWiXssiPQ3k/zpgxQ32mJXhJ+17IzbnsaXz44Yfq8yifS90xe9xrKK+5lFsCXgny5D0knz35oZbVjzmpuZMAWcos1yWY1DXT54S8fnJ8V61ala72T2pe5Zhk9d6U7wn5cS7nMnkt5Dwmn4HVq1enO2fJj3PZTt7Dchzk/SXn7Ywk4JXXLiIiQnVdkfODvEbymT98+HCOnwsVMg0ZvfDwcKkC0PTu3TtH2588eVJtP3LkyHTr3333XbV++/btqetKly6t1u3evTt1XVBQkMbOzk7zzjvvpK6bMGGCxsbGRhMaGpq6Lj4+XlO0aFHN8OHDU9f16dNHY2trq7l27Vrqurt372qKFCmiadWqVeq6HTt2qP3KZdqyDBkyJNPzad26tVp0jhw5ou47b968TNvK/eVxdNasWaO2/fLLL9Nt99xzz2ksLCw0V69eTV0n20nZ0647deqUWv/TTz9pHmf69Olqu8WLF6euS0hI0DRt2lTj7OysiYiISPc8u3fvrnmSlJQU9bzlcYsVK6YZOHCgZsaMGZpbt25l2jYmJibTugMHDqj7Lly4MHWdHDNZ17lzZ/X4OlJOOR6vvvpq6rqkpCRNiRIl0h37GzduqPs7ODho7ty5k7r+0KFDav3bb7+dum7ixIlqnc7Nmzc1VlZWmkmTJqUr55kzZzTW1taZ1mekK7u8/j///LN6T+med79+/TRt27bN8vjm9D2g+9yMGTMm3XYvvPCCWi/PR2fEiBEaX19fzYMHD9Jt+/zzz2tcXV1Ty6U7Xlm9V9N67bXX1GusM27cOPV58fb21sycOVOtCwkJUeX94Ycfsn2/BwcHZypr2m3lts8//zzd+rp162rq16+veRJ5H1SpUkXtQ5aLFy9q3nvvPfWYaY93Tl/nEydOqPsuX748X85lGc8TuveLvAZpZXXukfKnPY46Wb1+derUUa+LvB5pzxOWlpaawYMHZ3r/pz0/ir59+2o8PDw0TyKvl5OTU+p7tX379up6cnKyxsfHR/PZZ5+llu+7775Lvd9bb72l1u3Zsyd1XWRkpKZs2bKaMmXKqPunPWctW7Ysdbvo6GhNhQoV0h0fOU9UrFgx0zlD3uPymB07dnziMSf9YA2gCZBfXUKapHJiw4YN6jJj7YY0gYmMTQHSHy1trYLUJEgNifwS1xkwYIAagJD2V+jmzZvVr0C5TVe7IOukSUFqDHWkyVqaKqRWQ/dcCoscC2lek+bOjMdCYj6pOUqrQ4cOqqlJR0Y5SlN22mOR3X6kGUuaJ3Wk9kj2K033MkAht+RXv/w6//LLL1Utq9R4SY2b1AzKMU9bSyLNbDryOkmNpdRISa3M8ePHMz221FSlTdHSuHFjdTxkvY4cN6n9zeq5y2tcvHjx1L+l+4A8hu69lxV578iABakFkSZO3SLHTWqKduzYkeNjI48hNRjS9UBqV+Qyu+awnL4HdGXPuF3GgQFyH+l3JbWlcj3tc5GaIamJzOqYP458/qTm5tKlS+pvqYmRGhdZL9eFfH5kf9nVAOaU1A5n3PeT3t86MgBJzg+ySA2U1BBLzVLaJtKcvs5SQy7kPZ5dk2huz2UF7d69eyr7gNTsS/Nq2vOEtKBk9f7P6njL5zM350J5b0uTdWBgoKqNk8vHvd/l85h2kJQ090vtqtRQ6mqUZTs5N0vrj4407cp2acnz1TU3S7l1r6d0q5AaRKm5NvSBd+aKAaAJkABEyBddTkhfFuk/JAFAWnICloBAbk+rVKlSmR5DAo6wsLDUv6U/m5zwpclXR65Ls4k0AwgZCSgncgkeM5LmTzlJ5KavV36Q5+rn55cpeJby6G7P7bHIbj/y5SbHPSf7ySnp8yRNM9K/SEY/SxAoTY/SHC/NNjoSDEkzma6Pm7wu8iUtQWLa/lTZPU/dl7HcP+P6rJ67PNeMKlWq9Nj8X/IlIgGM3FcXROgWeX4Z+5A9jtxHgnVpCpOAQ358pP0ie5r3gO5zk/YHgMj4fpb3uRxXaTbN+Dykf5fIzXMRuqBOgj35YpV+tLJOgkBdACiXci6Qz+LTkn52Gbsc5OT9nbb5XfpeStD2yy+/qB8BcjzSDpTI6etctmxZFdjNmTNHvV8leJZm4LTv19yeywqabn/ZneN0gdHjPmtyvEVOj7lu4JO8f+WcK11CpN9lxmOStozZlS/tc5BLeYyMuToz3ldeTzFkyJBMr6e8dtJnMKtzDOkfRwGbADnpyxdYTjvZ6+Q0CW92I/cydpCXWifpTyInOTkZ/fPPP6rGK+1IzLzIrrzy5V5Yowtzeiz0QX6tS78n6ZMoAw4kCJSaFzn+0odq3rx5qrZK+sdJ4CbHU7bP6td5ds8zq/X59dylHFImqXHLaj+5TVIsNRKSGkdqQ2TQUdo+aAVJdzxlNKx8KWZFaoRyQz7fEhBJbYoEWXLM5XWUL1kZ3CVf1hIASt+ujD8yciOvnyMZLCCBt470e5N+aDIoQzeIJTevs/Q/lNq0v//+W7UeSO2r9JWTfoEyIETnaRKKP+58Upjy45wiP+qkL6AMKpPa2qdJSp7X97vU9mZMu6VjygnGjRkDQBMhI3WlxuHAgQPqi+FxpIlQPrTyy033q09IE5PUXOgGF+SWBIDSeVmav2TkozRhpO2ILV9W0oSga8bK2HQkX1wZa5gy/jLOaiCAfPmlbVLOzZeBPFfptC21p2lrgHRJlJ/2WGS1n9OnT6vjnvYLOr/3o2talgBDXl9d05qMgJVgRL5QdaTjd0GlitHVCqR1+fLlx85KITVr8qUngY7UFuaVDHSQ0csSLKStmX7a94DucyOjr9PWgmR8P+tGCEsgkTYYyiup8ZMAUI6PfNHKPqS2T4J5GYkuzcpPGjxQ2DOvyPtQAuFff/1VjSaW2q7cvs41a9ZUy0cffZQ6mGLWrFmq60NezmW6mraMn4Gsag1zetx0+8vuHCc1mRIkFwT5wSOjzOX88rgBMFLG7Mqnu113KZUK8lqlff4Z76urEZeKiPx8v1PBYxOwiZCRWXJikRFxWY20lC8t3WhTaS4QGUeyyWg8kdWoxpyQE7CcqOXLVhapkUo7+lZ+6croSfk1n7YpUMqrS9yra87Oipxo5MtcRgfqSN+ujM3GuhNsToIbORbyRS1589KS0YRy0pOao/wg+5GaqLSBiIzclfxo8utYRi7mlnzp3b59O9N6ed7yQ0C+4HTNeXLsM9YoyL4LqrZDUuVIOhEdGQkoo7wfdzylBkPKKUFMxrLK308aaZ2RHFcZ9Sq1IdIfL6/vAd1l2nQsWX2O5DlILaz8EMqqVl6aRJ82AJTPjbyHdE3C8mUvtX7y2ZW+nU/q/yc/wERh5oiUc5OUTXd+yenrLD8gM45ul/OLPGddKpK8nMt0gUva0dXyPshqxLOcU3LSjCnnPAnOpSYu7TGW94HUYOrKWxDatm2r0uHI+zhtKqCMpAzyeZRzhI40S8vzlh9ouhyksp10K5EfjzrShSfj8ZFUPnIsZdS49GfOr/c7FTzWAJoI+QBKECW1cBKIpZ0JRH41S2JcXQ49qTWQ2iD5IMtJSoIPOSHISUs678uJ5GnJ/qWvmfT5kQEDGZuj5Fe79BGSYE9SEEjzpNQOyAld0qo8jgS3cjKSVB3SgVyCWskllrFPlvwtzX1SSyC1JHLylgEIUuOQkQQG8nylH518ucqxkRO1BKnSXJrxsZ+WdJyW5ymvwbFjx9SJVp6L5MmSL6+cDuBJS1LpyK9+CUzki186nUvQJa+jnLjlcXXNS1JDLOkrpLZITvBy8pdar7S5AvOT9B2S11jy0slrK2WRfWWVQkJHjrW8PyRtjLwW8l6U4yJpgiQ9hRxDqUXKjeyaYJ/mPSBf7NKlQfq2STAggZekLpE0SRlJihgZzCDvO2mGlmMuKVuklk6Ou1zPLV1wJzUwkmZDR35kSXOqNAPqcmBmRwYDSVkkiJTaN3nPyHlCloIi+5NgQvqDSSqhnL7OMphB+rFKvjopqwSD8h7WBdh5PZdJNwnpLyvlkNdDjsWSJUuyTKkkQY4cM+mTKMdYflxk96NCmkLlMyktMXIOlP638mNLPnsF2TQr51qpJX2S999/X/UVljJKk7o8bzlecvzlR4vunC3vWwkm5btEzlkS3Mrx1/2ISLtfeW3l8eSYSj9X6fsp5yL5DMiPeklTRgZIT6OPqYBcvnxZM2rUKDWcX1KWSCqM5s2bqzQlcXFxqdslJiaqNAEyTF/St5QsWVKlckm7zeNSkmRMqaBz5coVNcxflr1792ZZxuPHj6uUAZL+xNHRUaXn2L9//xNTMYgpU6ZoihcvrtLQyPM6evRolmX5+++/NdWqVVNpJdKmaciYFkOXAkHSk/j5+aljISkNJG1C2pQGQh5H0nFklF16mozu37+vGTZsmMbT01O9NjVr1swy/UdO08DI433zzTfquUvKEXmubm5umnbt2mlWrFiRbtuwsLDUfctxl+MvaToylj1tKpW0dCkrJL1HdqkoRNq0E/JayftKXquWLVuqVBhZPWZGK1eu1LRo0UI9riySWkSO+6VLlx57PLIre06Ob07fA7GxsZo333xTpemQsvXs2VPj7++fZWoVeX2k3HIM5DElNYek6pg9e3am4/WkNDA6kl5EtpfH1pHPmayTY5xRVu93+axJWhd5D6Ytd8bX8kmvU0byPqxevXqWt+3cuTPTMXrS63z9+nWVIqV8+fIae3t7jbu7uzpXbN26Nd1j5/RcltV5QtJRdejQQb1HJc3OBx98oNmyZUumc09UVJRK9yNpreQ23THN7vWTMsr5SdIhubi4qPfJ+fPnc/SZymmqlOxer7SySgOje96SOkaejxzbRo0aadatW5fp/pJSqlevXuo8LeeOsWPHajZu3JjluVnS9jzzzDPqsyHHU45R//79Ndu2bcv1c6PCYSH/6TsIJSLTIDU6UtMqtSC5ra0jIqLCwz6ARERERGaGASARERGRmWEASERERGRmTCIAlMSgMjJLRpJ5e3ur0V9Z5TlKSxLkSoqHtEvabPVElHu6JMXs/0dEZNhMIgCUeVRlDlTJEScpRiTnlOSbyzjlTkYyPF3mbtQthT1tEBEREZE+mEQeQMmCn7F2T2oCJXdR2kTEGUmt3+MSZhIRERGZIpOoAcxIl7FdElw+jmQtl+luZPqx3r1749y5c4VUQiIiIiL9Mbk8gDIvZK9evVRW+L1792a7ncyEIFNpyVyVEjDKNDYyJZAEgWknGU9LZjTQTUGk25dkkJcZDgp7jk0iIiJ6OhqNRs3/7efnl2nGKrOhMTGvvvqqykAu2flzIyEhQWWc/+ijj7LdRpe5nQuPAd8DfA/wPcD3AN8Dxv8e8M9lrGBKTKoGUOaNlPk7pSYvq3lfn0TmnJS5aWWexJzUAErNYalSpeDv768GlBAREZHhi4iIUN2/pLVQ5mk2RyYxCERi2DfeeENNJL5z586nCv6Sk5Nx5swZNWl5dmSydVkykuCPASAREZFxsTDj7lsmEQBKCpg///xT1f5JLsDAwEC1XqJ6BwcHdX3w4MEoXry4yhkoPv/8czRp0gQVKlRQvwBk7lJJAzNy5Ei9PhciIiKigmYSAeDMmTPVZZs2bdKtnzdvHoYOHaqu3759O11Hz7CwMIwaNUoFi25ubqhfvz7279+PatWqFXLpiYiIiAqXSfUB1EcfAqlllL6AbAImIiIyDvz+NpEaQCIiKljST1pmWSIyBlZWVmpQpzn38XsSBoBERPTEpPl37txRA+6IjIWjoyN8fX1ha2ur76IYJAaARET02Jo/Cf7ky9TLy4s1KmTw5IdKQkICgoODcePGDVSsWNF8kz0/BgNAIiLKljT7yheqBH+6rApEhk7eqzY2Niq7hwSD9vb2+i6SwWFITERET8S+VGRsWOv3eAwAiYiIiMwMA0AiIiIDqGFds2aNXvY9f/58FC1aFPomeXv79OmT4+1l5i85bjKZA+UeA0AiIjJJkuhfpgktV66cmsZT5n7t2bMntm3bBmNX2EGbBFqyHDx4MN36+Ph4eHh4qNskICPjwQCQiIhMzs2bN9UMT9u3b1dTfcpc7xs3bkTbtm3V9KGUexJAywxbaa1evRrOzs48nEaIASAREZmcMWPGqFqpw4cP49lnn0WlSpVQvXp1jBs3Ll0tlkwT2rt3bxXEyIxO/fv3x/3791Nv//TTT1GnTh0sWrQIZcqUUbM/Pf/884iMjFS3z549G35+fkhJSUm3f3nM4cOHp5uytHz58ionXeXKldXj5aZp8+TJk2qdBLZy+7Bhw9QsVLqaOSmnrkbu3XffRfHixeHk5ITGjRtnqpmT2sNSpUqp1D59+/ZFSEhIjo7pkCFDsGTJEsTGxqaumzt3rlqfkQTc7dq1U6NxpYbw5ZdfVvkk06YXktdCajHl9vHjx2fKMynH9Ouvv0bZsmXV49SuXRsrVqzIUVnpyRgAEhFRjsmXdExCkl6WnCaiDg0NVbV9UtMnQVBGuqZTCTAkUJPtd+3ahS1btuD69esYMGBAuu2vXbum+uetW7dOLbLtN998o27r16+fCqB27NiRaf8vvvhiai3Z2LFj8c477+Ds2bN45ZVXVACX9j650axZM0yfPl0FrPfu3VOLBH3i9ddfx4EDB1Sgdvr0aVW+Ll264MqVK+r2Q4cOYcSIEWo7CSqlRvTLL7/M0X6lRlWC4JUrV6YGz7t378ZLL72Ubrvo6Gh07twZbm5uOHLkCJYvX46tW7eqfepMmTJFBaISQO7du1cdMzlOaUnwt3DhQsyaNQvnzp3D22+/jUGDBqnjT3nHPIBERJRjsYnJqPbJJr0csfOfd4aj7ZO/tq5evaqCxSpVqjx2O+kLKDVVkixYmjeFBBxSUyiBS8OGDVMDRQlWihQpov6WgEfuO2nSJBXkdO3aFX/++Sfat2+vbpdaKk9PTxVcie+//14NcJBaSaGrhZT1um1yQ2oRpSZSav58fHxS10tAJk20cim1kkICQwlGZf1XX32FH374QQWEUuMmpGZ0//79apuckFpNCdokEJNj0q1bN5UjMi05FnFxcepY6gLwn3/+WfW//Pbbb1GsWDEVwE6YMAHPPPOMul2CvE2b/ntfSU2mlFcCx6ZNm6p10pdTgsVff/0VrVu3zvVxo/RYA0hERCYlpzWFFy5cUIGfLvgT1apVUzWEcpuO1Hrpgj8h04sFBQWl/i01fVIrJkGL+OOPP1QzsS4PnTxW8+bN0+1b/k67j/wgwaw0rUpQJ03aukVqzKQWU1cWaRZOSxdg5YQEflLDKDWlEgCmbebWkX1Ic23a2ld5vhJIX7p0STVdS61l2nLIvL0NGjRIF8THxMSgY8eO6Z6LBJW650J5wxpAIiLKMQcbK1UTp69954RM/SW1YxcvXsyX/cqMEmnJY6ft8yc1WxJ0rl+/XtUa7tmzB9OmTXvq/ekCx7SBrMzI8iTSx87KygrHjh1Tl2nl10AN6a/Xo0cP1YwstXxS+6nrD5mfdP0F5ZhKf8a0ZEQ35R1rAImIKMck+JFmWH0sOZ2NxN3dXfVBmzFjhuqPlpFucEXVqlXh7++vFp3z58+r26UmMKdkmjFpypSav7/++ksN8qhXr17q7bKfffv2pbuP/J3dPnRNqlJLpiP99TI2A0ttX1p169ZV66R2skKFCukWXVOxlEX6AaaVMbXLk0itnwwsGTx4cKZAU7ePU6dOpTv28nwlsJVjI83XUouathxJSUkqcNWRYyOBnjRnZ3wuaWts6emxBpCIiEyOBH/S7NioUSN8/vnnqFWrlgoyZKCHjMiVZsoOHTqgZs2aqglX+qTJ7dJPT/qXpW2OzAl5DKkZk8EK0kya1nvvvadGF0uAJvtcu3YtVq1apfq3ZUUX5MjIXulnePnyZTVoIi1plpZaMumLKM2tMqJXmn6lHBKYyfayv+DgYLWNPP/u3bvjzTffVMdF+h/KABjpd5fT/n860odQHlcGoWR3LCZOnKhGB8tzkG0lH6P0nZT+f0IGxchAGqmtlb6aU6dOTTfqWZrcpf+iDPyQ2tYWLVqopmMJJGW/WY08plzS0FMLDw+X+nl1SURkimJjYzXnz59Xl8bm7t27mtdee01TunRpja2traZ48eKaXr16aXbs2JG6za1bt9Q6JycnTZEiRTT9+vXTBAYGpt4+ceJETe3atdM97rRp09RjppWcnKzx9fVV3wnXrl3LVJZffvlFU65cOY2NjY2mUqVKmoULF6a7Xe63evXq1L/37t2rqVmzpsbe3l7TsmVLzfLly9U2N27cSN3m1Vdf1Xh4eKj1Uk6RkJCg+eSTTzRlypRR+5Iy9e3bV3P69OnU+/3++++aEiVKaBwcHDQ9e/bUfP/99xpXV9fHHsuM5UsrLCxM3Z72uMr+2rZtq8rv7u6uGTVqlCYyMjL19sTERM3YsWM1Li4umqJFi2rGjRunGTx4sKZ3796p26SkpGimT5+uqVy5snouXl5ems6dO2t27dqlbpf9yX5l/7l974bz+1tj8eiFpacQERGhqrLlV0l2v4SIiIyZ9POSUbKSi02aOolM4b0bwe9v9gEkIiIiMjccBEJERERkZhgAEhEREZkZBoBEREREZoYBIBEREZGZYQBIREREZGYYABIRERGZGQaARERERGaGASARERGRmWEASEREVMgsLCywZs0agz/ubdq0wVtvvZXj7efPn4+iRYsWaJkofzAAJCIikxMcHIzRo0ejVKlSsLOzg4+PDzp37ox9+/bBFNy8eVMFkVZWVggICEh3271792Btba1ul+2IssIAkIiITM6zzz6LEydOYMGCBbh8+TL++ecfVZsVEhICU1K8eHEsXLgw3Tp5zrKe6HEYABIRkUl5+PAh9uzZg2+//RZt27ZF6dKl0ahRI0yYMAG9evVK3W7q1KmoWbMmnJycULJkSYwZMwZRUVGZmjPXrVuHypUrw9HREc899xxiYmJUkFWmTBm4ubnhzTffRHJycur9ZP0XX3yBgQMHqseWYGzGjBmPLbO/vz/69++v9ufu7o7evXvnqPZuyJAhmDdvXrp18resz2jXrl3qOEiNqK+vL95//30kJSWl3h4dHY3BgwfD2dlZ3T5lypRMjxEfH493331XPSd5bo0bN8bOnTufWE4yPAwAiYgo9xKis18S43KxbWzOts0FCWBkkT52ErBkx9LSEj/++CPOnTunArrt27dj/Pjx6baRYE+2WbJkCTZu3KiCnb59+2LDhg1qWbRoEX799VesWLEi3f2+++471K5dW9VCSqA1duxYbNmyJctyJCYmqubpIkWKqMBVmqml/F26dEFCQsJjn6sEtGFhYdi7d6/6Wy7l7549e6bbTpqJu3XrhoYNG+LUqVOYOXMmfv/9d3z55Zep27z33nsqSPz777+xefNm9VyPHz+e7nFef/11HDhwQB2P06dPo1+/fqqcV65ceWw5yQBp6KmFh4dr5BDKJRGRKYqNjdWcP39eXaYz0SX7ZfFz6bf90if7bed2S7/tt2Wz3i6XVqxYoXFzc9PY29trmjVrppkwYYLm1KlTj73P8uXLNR4eHql/z5s3T53jr169mrrulVde0Tg6OmoiIyNT13Xu3Fmt1yldurSmS5cu6R57wIABmq5du6b+LY+7evVqdX3RokWaypUra1JSUlJvj4+P1zg4OGg2bdqUZVlv3LihHuPEiROat956SzNs2DC1Xi7ffvtttV5ul+3EBx98kGkfM2bM0Dg7O2uSk5PV87G1tdUsW7Ys9faQkBBVhrFjx6q/b926pbGystIEBASkK0v79u3V8dUdM1dXV41Bv3f5/a2wBpCIiEyyD+Ddu3dV3z+poZLarHr16qlmXZ2tW7eiffv2qjlTat9eeukl1UdQav10pNm3fPnyqX8XK1ZMNfFKDV3adUFBQen237Rp00x/X7hwIcuySo3c1atXVRl0tZfSDBwXF4dr16498bkOHz4cy5cvR2BgoLqUvzOSfUsZZGCITvPmzVWT9507d9R+pLZRmnR1pAzS9K1z5swZ1dRdqVKl1HLKIrWGOSknGRZrfReAiIiM0Ad3s7/Nwir93+9dfcy2Geoh3jqD/GJvb4+OHTuq5eOPP8bIkSMxceJEDB06VPWv69GjhxopPGnSJBXsSPPpiBEjVCAkgZ+wsbFJX1wLiyzXpaSkPHU5JQirX78+/vjjj0y3eXl5PfH+0o+xSpUqqs9h1apVUaNGDZw8efKpy/O4csqo42PHjqnLtNIGxGQcGAASEVHu2Trpf9tcqlatWmruPQliJGiTgQ7SF1AsW7Ys3/Z18ODBTH9LcJYVqZlcunQpvL294eLi8lT7k1o/GcQiffuyIvteuXKldPtKrQWUvoZS61iiRAkVAEtge+jQIZU6R0hfQhlB3bp1a/V33bp1VQ2g1Ha2bNnyqcpJhoNNwEREZFKkGbddu3ZYvHixGqhw48YN1TQ6efJkNbpWVKhQQQ2++Omnn3D9+nU1mGPWrFn5VgYJrmR/EkDJCGDZvwwEycqLL74IT09PVTYZBCLllSZrGV0szbM5MWrUKJX7UGo5syLBoYw0fuONN3Dx4kU10ENqQ8eNG6cCYKnBk9pPGQgig2HOnj2rakp1wbGQpl8pq4wUXrVqlSrn4cOH8fXXX2P9+vVPeaRIX1gDSEREJkWCGenLNm3aNNU3TQI9SfMiQdIHH3ygtpERupIGRlLFSHqYVq1aqUBGgpv88M477+Do0aP47LPPVK2e7EtG+mZFmpt3796N//3vf3jmmWcQGRmp+iVK/8Sc1ghK4mcJIrMjjyejliXAk+cuNX4S8H300UfpRi5LM6+MIJaaQXkO4eHhmVLMyMhhuU1GFss+mzRpoprTybhYyEgQfRfCWEVERMDV1VV9QJ622p6IyJDJQASp6SlbtqzqU0dPJoNEZPq03EyhRoX73o3g9zebgImIiIjMDfsAEhEREZkZ9gEkIiLKRzmZwo1I31gDSERERGRmGAASERERmRkGgERERERmhgEgERERkZlhAEhERERkZhgAEhEREZkZBoBEREQFQObS7dOnT54f59NPP0WdOnVgCnL7XCSljoWFBU6ePFmg5TJHDACJiMgkgy8JHGSxsbFR04GNHz9eTQ9myKS8a9asSbfu3XffxbZt2wplCjvZ/5IlSzLdVr16dXXb/PnzC7wcVDgYABIRZWHe2Xn49vC3uBnOpL7GqkuXLrh37x6uX7+OadOm4ddff8XEiRNhbJydneHh4VEo+ypZsiTmzZuXbt3BgwcRGBgIJyenQikDFQ4GgEREWVh3fR0WX1iMe9H3eHyMlJ2dHXx8fFRQI02xHTp0wJYtW1JvT0lJwddff61qBx0cHFC7dm2sWLEi9fawsDC8+OKL8PLyUrdXrFgxXXB05swZtGvXTt0mAdrLL7+MqKiox9awTZ8+Pd06aQ6VZlHd7aJv376qtk33d8ZmUyn3559/jhIlSqjnKLdt3LgxU7PpqlWr0LZtWzg6OqrnduDAgSceM3m+u3btgr+/f+q6uXPnqvXW1uknD7t9+zZ69+6tAlQXFxf0798f9+/fT7fNN998g2LFiqFIkSIYMWJEljWwc+bMQdWqVWFvb48qVargl19+eWI5Ke8YABIRZeGZis9gZM2RKO5cnMcnCzGJMWrRaDSp6xKTE9W6hOSELLdN0aT8t22Kdtv45PgcbZtXZ8+exf79+2Fra5u6ToK/hQsXYtasWTh37hzefvttDBo0SAVA4uOPP8b58+fx77//4sKFC5g5cyY8PT3VbdHR0ejcuTPc3Nxw5MgRLF++HFu3bsXrr7/+1GWUxxESZErNpe7vjH744QdMmTIF33//PU6fPq3K0atXL1y5ciXddh9++KFqPpb+c5UqVcLAgQORlJT02DJIsCaPt2DBAvV3TEwMli5diuHDh6fbToJQCf5CQ0PV8ZLAWmpaBwwYkLrNsmXLVPD61Vdf4ejRo/D19c0U3P3xxx/45JNPMGnSJHWMZVs57rr9UwHS0FMLDw+XM5+6JCLjN+ngJM3kw5M1dyLvpFsfHBOsOX7/uMYcxcbGas6fP68u06oxv4ZaQmJDUtf9eupXtW7ivonptm24uKFan/a4Ljy3UK0bv2t8um1b/tVSrb8SeiV13fJLy3Nd7iFDhmisrKw0Tk5OGjs7O3WutrS01KxYsULdHhcXp3F0dNTs378/3f1GjBihGThwoLres2dPzbBhw7J8/NmzZ2vc3Nw0UVFRqevWr1+v9hEYGJhaht69e6feXrp0ac20adPSPU7t2rU1Eyf+d7yknKtXr063jdwu2+n4+flpJk2alG6bhg0basaMGaOu37hxQz3OnDlzUm8/d+6cWnfhwoVsj5mufGvWrNGUL19ek5KSolmwYIGmbt266nZXV1fNvHnz1PXNmzer43v79u1M+zh8+LD6u2nTpqll0mncuHG65yL7+fPPP9Nt88UXX6j7pn0uJ06c0OTXe1eE8/tbwxpAIiIASSlJWH1lNRaeX5iuVkr6ALZd1hYvb35ZbUPGQ5o/pfbr0KFDGDJkCIYNG4Znn31W3Xb16lVVu9WxY0fVhKlbpEbw2rVrapvRo0erARHSxCoDSKQGUUdqq6RZNW2/uObNm6uasUuXLhXYc4qIiMDdu3fVvtKSv6VMadWqVSv1utS+iaCgoCfuo3v37qope/fu3ar5N2Ptn5B9SdO6LDrVqlVD0aJFU8shl40bN053v6ZNm6Zel1pUOdbSNJz2Nfjyyy9TXwMqOOkb9ImIzJQ0OX7U5CNcDL2IMi7avleilEspFLEtAm8Hb4TEhqCYUzG9ltNQHHrhkLp0sHZIXTes+jAMqjoI1pbpv1p29t+pLu2t7VPXPV/leTxb8VlYWVql23bjsxszbdu7Qu+nKqMEZxUqVFDXJZCRgO33339XAYeur9769etRvHj6Zn7pVye6du2KW7duYcOGDaqJs3379njttddU0+vTsLS0TNdkLhIT8968nR0Z/awjfQKFBKhPIn39XnrpJTVgRoLn1atXF0j5dK/Bb7/9lilQtLJK/76g/McaQCIiALZWtirQ+F+j/8HS4r9To1zf0X8H1vRZw+AvDUcbR7XoAgthY2Wj1smxzGrbtMfVxlK7rZ2VXY62zfOXnaUlPvjgA3z00UeIjY1VtVUS6MlABgkS0y5pa7VkAIjUHi5evFgN4Jg9e7ZaL4MWTp06pWqxdPbt26f2U7ly5SzLII8lffvS1ubduHEjU9CWnJyc7fOQwRZ+fn5qX2nJ3/Kc8ovU+knfPunnJ/0cM5LnLwNF0g4Wkf6SDx8+TC2HbCMBZMYRxWn7G8pzkb6DGV8DGZhDBYs1gERET5AxSCHj1K9fP7z33nuYMWOGGhwhiwz8kFqxFi1aIDw8XAVSEmRJ0CeDE+rXr69y4MXHx2PdunUqqBEyKlZqyGQ7GegQHByMN954Q9WcSWCTFRkxLHn0evbsqZpK5fEz1nTJyF/J+SdNuhKgZhV8yXOQfZcvX141T8ugEWnqlgEV+UWe54MHD9QI4qzIiOqaNWuq4yCBsQwuGTNmDFq3bo0GDRqobcaOHavyMcrf8nykfDLYply5cqmP89lnn+HNN9+Eq6urStsjx1kGjMgI7HHjxuXb86HMWANIRARg/939uBd1L1MTHZkOadqUUbqTJ09WNXdffPGFGnEqo4El4JEARJqEdbVPMmJ4woQJqi9dq1atVLCmS5IsgdGmTZvUKNiGDRviueeeU03EP//8c7b7l8eSAKlHjx6qn52kppEgLi0Z3SvNzVILWbdu3SwfRwImCY7eeecdFYRJCph//vlHpanJT5LaRlLcZEVqfv/++28VoMqxkYBQAjsZMawjI4Ll+Er/SQmkpTld+lWmNXLkSJUGRoJYeS5yfCRIZg1gwbOQ0TCFsB+TJNX38qtFfjXKL0YiMk5xSXFo8mcTJGuSseW5LfBx8kl3e2xSLD7Z94nqH7i85/J0/dNMneRtk2ZK+UKWPG1EpvDejeD3N2sAiYgexD5AJbdKKvAr5pi5+c7eyh5HAo/gZsRNFQQSERk79gEkIrNXokgJLOu5TKV5STuoQUfWvd/4fbjauqpAkYjI2DEAJCLSnRAzpC9Jq0uZLjxORGQyTGIQiHTglU64Mtegt7e36libk0ScMnWPzDsofQOk86nkeiIiIiIydSYRAEquIknOKfmFZPSUJNbs1KlTuvxMGUlGd5kXURKCnjhxQgWNssh8kURkPsLjw9F+eXuM3T72iXPOngw6icXnFyMyIbLQykdEVBBMoglYhsCnJUPIpSbw2LFjanh6dpNpy5B/yackJB2ABI8yhF8mBici83Au5ByCYoJwxerKExMOT9gzAXei7qBc0XJo5tcM5oQJI8jY8D1rBgFgRpKWRbi7u2e7zYEDBzIlmezcuTPWrFlT4OUjIsNRz7seFnRZgKhE7bRUj9OyREuVK1BGBZsLXaLihISEbHPCERkimes545R4ZMIBoGR0f+utt1TW8Ro1amS7XWBgYKZs7fK3rM+OZCiXJW0eISIybpLTr16xejna9oPGH8AckydL0mOZ6UK+SGWqMyJDr/mT4C8oKEjNuMJ5hc0kAJS+gNKPb+/evQUy2ESmrSEiMheSAsfX11cl1JWZHIiMhQR/Pj7pk7qTiQaAMsWPzNW4e/dulChR4rHbypvi/v376dbJ3497s8g0PmmbjaUGMO2k4URkXEJiQ/D3tb9Ry7MWGvho5y/NiYTkBHVpa2ULcyBTosk0Y9IMTGQMpLaaNX9mEABKda9Mwr169Wrs3LkzR3MINm3aVE24Lc3FOjIIRNZnRybmloWITIOM6p12bJpK7ryy18oc3ed/u/+Hzbc24/vW36N9qfYwF9L0y6ngiEyHpak0+y5evBh//vmnygUo/fhkiY2NTd1m8ODBqgZPZ+zYsWr0sEy8ffHiRXz66ac4evSoqkUkIvPgaueKjqU7onWJ1jm+j52VnZox5HLo5QItGxFRQbLQmMA46aymbhLz5s3D0KFD1fU2bdqgTJkyKkVM2kTQH330EW7evKmaNyZPnoxu3brleL+cTJrI/PhH+sMCFijuXDzbcw8RGbaIiAi4urqqrCEuLi4wRyYRAOoL30BERETGJ4IBoGk0ARMR5VaKJgXJKck8cERklhgAEpFZCogMQP3F9dF7Te9c33f55eWYcnQKgmOCC6RsREQFzSRGARMR5da96HtI1iSrmsDcWnhuIW5G3ETL4i3h5ejFg09ERocBIBGZpfrF6mNbv22ISnjyFHAZdSvXDZEJkfBw8CiQshERFTQOAskDdiIlIiIyPhEcBMI+gERERETmhk3ARGSWll1ahvjkeLQr1U7l9Mst6TsozcCSTJqIyNhwFDARmaXFFxZj8pHJKrFzbh27fwz1F9XHkH+HFEjZiIgKGmsAicgsdSrdSY3kLVWkVK7v62HvgSRNEoJigtRc5JwRhIiMDQeB5AE7kRKZJ5kL+EHsA3g6eMLakr+jiYxNBAeBsAaQiCi3JOjzcfLhgSMio8U+gERkdhKSE1QtHhGRuWIASERmZ+WVlWiwuAE+3f/pUz/G7ju71XRw+wP252vZiIgKAwNAIjLbaeAcrB2e+jH2392P+efm41DgoXwtGxFRYWDvZSIyO2PrjsWgqoNgafH0v4Gb+DZR929QrEG+lo2IqDBwFHAecBQRERGR8YngKGA2ARMRERGZG/YBJCKzIqN/ZQaQRecXqdHAeSHTwUkyaLkkIjIm7ANIRGYlOCZYBX+Sy+/Fqi8+9eNI0NfkzyaITYrFlue2MC8gERkVBoBEZFYk8BtafSjikuLyNAhE7utu747A6EAVVDIxNBEZEw4CyQN2IiUyb6FxoXCxdeF0cERGJoKDQFgDSET0tKQGkIjIGHEQCBGZlciESE4DR0RmjwEgEZmVD/Z8oKaBW3ttbZ4f60rYFTUd3Nyzc/OlbEREhYUBIBGZlfsx99U0cEXtiub5sWQAiEwHt+H6hnwpGxFRYeEoYCIyK391/wshcSEoYlskz49Vvmh5vFTtJZR1LZsvZSMiKiwcBZwHHEVERERkfCI4CphNwERERETmhn0AichsXAi5oKaBW3d9Xb49pm46OBldTERkLBgAEpHZOBdyTk0D9++Nf/PtMd/c/ibaL2+PzTc359tjEhEVNA4CISKzUdGtopoGLj8HbRRzLAYrCytEJETk22MSERU0DgLJA3YiJaKYxBjYWtlyOjgiIxLBQSCsASQiygtHG0ceQCIyOuwDSERmIzgmmNPAERExACQicyGjdTuv7KymgZMZPPKzCVimgxu/e7zaBxGRMdD7IJDbt2/j1q1biImJgZeXF6pXrw47Ozt9F4uITMzD+IfQaDSQfx4OHvn2uDZWNlhwboF63PENx8PTwTPfHpuIyKQCwJs3b2LmzJlYsmQJ7ty5o07KOra2tmjZsiVefvllPPvss7C0ZCs1EeWdu707jg46irD4MNhY2uTbIZXHeqX2K3CxdcnXxyUiMqlRwG+++SYWLFiAzp07o2fPnmjUqBH8/Pzg4OCA0NBQnD17Fnv27FHBoZWVFebNm4eGDRvCEHEUERERkfGJ4Cjgwq8BdHJywvXr1+HhkbkJxtvbG+3atVPLxIkTsXHjRvj7+xtsAEhERERkjJgHMA/4C4LIeMjsH2cenEGrEq3QxLdJvj52YkoiQmNDYWFhAW9H73x9bCLKfxGsAdRvGpjY2Fg1+ENHBoNMnz4dmzZt0mexiMgE7Q3Yq6aBO/vgbL4/tgwC6bCiA348/mO+PzYRkcmNAu7duzeeeeYZvPrqq3j48CEaN24MGxsbPHjwAFOnTsXo0aP1WTwiMiHtSraDh70H6njVyffHlseV6eASUhLy/bGJiEyuCdjT0xO7du1SqV/mzJmDn376CSdOnMDKlSvxySef4MKFCzBkrEImIl0TsASAlhbMWkBkDCLYBKzfGkBp/i1SpIi6vnnzZlUbKGlfmjRpopqDiYiMAdO/EJGx0evP1QoVKmDNmjVqpK/0++vUqZNaHxQUBBcXF30WjYhMiMzQERQTpGrqiIhIzwGgNPO+++67KFOmjOr/17Rp09TawLp16/L1IaJ8ERoXivbL26Ph4oYFMhew9KT57sh3GL9rPMLjw/P98YmITKoJ+LnnnkOLFi1w79491K5dO3V9+/btVXMwEVF+eBj3UPXRc7VzhbVl/p/2JP3LuuvrVKA5ouYItR8iIkOm1xrA4cOHq8TQUtuXdso3GRTy7bff6rNoRGRCKrhVwPGXjuOfPv8U2D6G1xiO9xq8l6/zDBMRmeQoYJnqTWr/ZAaQtCQNjI+PD5KS8r+pJj9xFBEREZHxieAoYP00AcuBl7hTlsjISNjb26felpycjA0bNmQKComIiIjIiAPAokWLqj4zslSqVCnT7bL+s88+00fRiMgErb22FudDzqNtybZo5NuoQPahmw5OFHMqViD7ICIy6gBwx44dqvavXbt2Kumzu7t76m22trYoXbo0/Pz89FE0IjLRaeA23NgAHyefAgsA/zj/B6Ycm4Lu5brjm5bfFMg+iIiMOgBs3bq1urxx4wZKlSqlavyIyIgcmQNE3gfaTADSDOAyVB1Kd1C1crW9/ss2kN9k8IeMNE5MZq5BIjJ8hT4I5PTp06hRo4Ya9SvXH6dWrVowZOxESmYpJhSYXFZ7vdMkoNnr+i6RQZD8gjIVHKeDIzJ8ERwEUvgBoAR+gYGBapCHXJfav6yKIOtlQIgh4xuIzNaeqcC2zwArO+DlnUCxavouERFRjkUwACz8JmBp9vXy8kq9TkRGqMXbwO2DwJVNwKqXgVHbAWtbGKLklGQ8iH0Adwd3ztlLRGQIeQCNHX9BkNlJjANsHqVtkj6AvzQBZORri3FAh4kwRIHRgei4oqMK/o4OOlqgTbRTjk5R+3u/0ftMCE1kwCJYA6jfqeDElStX1KjgoKAgpKSkZJormIgMyMoRQNR9oPPXQMmGQM8fgGUvAfumA5W6AKUaw9DI3LzWFtZws3cr8P55G65vQFBsEIbWGMoAkIgMml4DwN9++w2jR4+Gp6enmvkj7Whguc4AkMiASI3f5Y1AShJg66RdV60XUHsgcOov4PpOgwwAK7tXxrGXjiEqMarA9zW85nCkaFLg5aDt5kJEZKj02gQs+f7GjBmD//3vfzBGrEIms7J3OrB1IlCiITBy63/r48KBO0eACh30WToiohyLYBMw9JrAKywsDP369dNnEYgoJ+R34olF2uv1Bqe/zd6VwR8RkZHRawAowd/mzZv1WQQiyonbB4CQq4CtM1D9mey3i4sADCwR8uorq/Ht4W9xJPBIge9LpoOTQSCyEBEZMr32AaxQoQI+/vhjHDx4EDVr1oSNjU2629988029lY2I0ji+UHtZvS9g55z1oZnfA7i5FxixGShZMNOtPY09AXuw5dYWlChSAg19GhbovpZdWoZvDn+DTqU7YUqbKQW6LyIiow0AZ8+eDWdnZ+zatUstackgkNwEgLt378Z3332HY8eO4d69e1i9ejX69OmT7fY7d+5E27ZtM62X+8qAFCJ6JPYhcG6N9nq9IdkfFqkdhAYIOGZQAWDnMp1RskhJ1PIs+JmFZDo4GXGcrDHsJPZERHoNAPMzEXR0dDRq166N4cOH45lnHtNElcGlS5fg4uKS+rfMUEJEadg4An1nakf5lmiQ/aEpXg+4/C8QcNygDp8EgLIUhg6lOqDTS504HRwRGTy95wHML127dlVLbknAV7Ro0QIpE5FJkBk+pOlXlseRAFBIDaCZsrY0mVMqEZk4vZ6tpLbucebOnVvgZahTpw7i4+NRo0YNfPrpp2jevHm228p2sqQdRk5Ej/g9CgBDrwGxYYCDm94PTVJKkpoGzsPeAzZW6fsYExGZM72ngUm7yGwg27dvx6pVq/Dw4cMC3bevry9mzZqFlStXqqVkyZJo06YNjh/Pvvnq66+/hqura+oi9yEyaUkJwKFftYM7MszUk4mjO+BWVnv97gkYgnvR99Q0cM3+aobCSnk69dhUvLvrXQTFBBXK/oiIjK4GUAZqZCTTwcnsIOXLly/QfVeuXFktOs2aNcO1a9cwbdo0LFr0KN9ZBhMmTMC4cePS1QAyCCSTFnIF+Hc8YOcCvH/7ydtLM3DYDW0/wPLtYCjTwMngjLQzDRWkTTc24W70XQyqOgjejuxTTESGyeA6rFhaWqogS2rjxo8fX6j7btSoEfbu3Zvt7XZ2dmohMhuBZ7WXxarL0Pwnb1+xM2BlB/jWhiGo4VlDTQMXkxhTaPuUeYCl6dnHidkEiMhwGVwAKKQmLikpqdD3e/LkSdU0TESP3NcFgDVydkhqD9AuBsTSwhLOKkVN4RhYZWCh7YuIyCgDwLTNqUL66EgevvXr12PIkMfkG8tCVFQUrl69mi7FjAR07u7uKFWqlGq+DQgIwMKF2oS206dPR9myZVG9enXExcVhzpw5qv8hZyYhSuP+uf9qAImIyGToNQA8ceJEpuZfLy8vTJky5YkjhDM6evRousTOuuBSAsn58+erwPL27f/6MCUkJOCdd95RQaGjoyNq1aqFrVu3Zpkcmshs5bYGUCQnAcEXtP0G3UpDn1ZcXoGrD6+iY+mOqF+sfqHsMzE5ESFxIUjRpMDP2a9Q9klElFsWmsIaGmeCZBCIjAYODw9Pl0yayCREBQPfV5DTBDDhTvZTwGW0dixwbD7QYhzQYSL06Y3tb2Cn/0583ORj9K/cv1D2KdPBfXHwC7Qt2RY/tvuxUPZJRLkTwe9vw+wDSEQGIOhR86972ZwHf8K3jvbyrv5nBOletjvKu5ZXg0EKi+QclITQ/G1NRIaMNYB5wF8QZNISYoDAM0B8JFCxQ87vd+8U8GsrwM4V+N9N6dsBc5KckqwGnhRW2hkiyr0I1gCyBpCIsmHrCJRqnPvD410NsLYH4sOB0OuApzQjmw8rSyt9F4GI6InM66c5ERU8mXLNp5be5wVOTElEYHSgGpRBRETpMQAkoqxH8v77PnB8oXY6uNwqXl/v/QDvRN5R08C1Xtq60Pc9/dh0vLPzHdyLulfo+yYiMtoAUFK67N69W9/FIDJfIVeBQzOBjRMAy6cYKyZTwum5BlBNA2epnQausG27vQ2bb21GQFRAoe+biMhoRwG/9NJLuHz5MpKTk/VdFCLzzv8n/fmeZhBH6WZA2w+Bko2gL3W86+D4oOOISSq8aeB0BlcfjITkBBR3Ll7o+yYiMtoAcNu2bUhMZL8dIr3PAOLzlOlTXEsArQt3Lu+syEhcJxunQt9vv0r9Cn2fRERGHwD6+TF7PpFhzADCKeBMjuT+jw0DHt4CHt4GPCoCxarpu1REZG4BoDTzrl69GhcuXFB/V61aFX369IG1td6LRmS+UucAzkMC5Yi7wJ2jgKM7UKYFCpvMyHHt4TV0LdtVNQcXJhl5/CD2AZI1yShRpAQMwtmVwL4fgdAb2hQ9OhaWwBvHAPdy+iwdERUyvUZZ586dQ69evRAYGIjKlSurdd9++62aD3jt2rWoUaPwsvcT0SMxoUDEo8EL3lWf/rCcWw1s+gCo1lsvAeAO/x3YG7AXVdyrFHoAuPb6WkzcPxEti7fELx1+gd7tnQ5szTAtn5O3doYXeW0Y/BGZHb0GgCNHjkT16tXVqF83Nze1LiwsDEOHDsXLL7+M/fv367N4ROYp+KL2smgpwN716R/Hs5L28sEV6EOv8r1U8FfNo/CbNz0dPLXTwcFAplovWlJ72eQ1oN5L2tfW9lHfyJQ0g+3CbgJbPwO6fQ84Ff7oaSIyk6ngHBwcVPAnQWBaZ8+eRcOGDREbGwtDxqlkyGRFBQGRgYDvo4TOT0OCiR9qA1a2wIeBgBnNkGGQ08HdO/3k1/PP54HL/wJlWgKD/zar14zMSwSngtNvHsBKlSrh/v37mdYHBQWhQgXzmj6KyKA4e+ct+BOuJbVTwiUnaAccmNl0cHoN/uIitMFceJo8hDl5Pdv8D5BR0zf3AHunFmgRicjMAkCJunXL119/jTfffBMrVqzAnTt31CLX33rrLdUXkIiMmNQeeTz6IRd8udCngZNZOOKT42GWtn2mrcn7+7Xc3c+vLtD9e+31HV8Dtw8VSPGIyAz7ABYtWjTdL2Npge7fv3/qOl2LdM+ePZkImqiwSX+wpS8BXpWAVu/9108sL/0AJaXMg8tA5S4oLLfCb6HvP33haueKvc/vhT7IdHD+kf54u/7bhTsS+PZB4Mgc7fUWb+X+/rUHAtd2AGeWAStHAK/uARy0fbSJyHQUegC4Y8eOwt4lEeWUNNVeWg9c2wa0+zjvxy11IEjh1gBGJERop4Gz199Ahu3+23Ej/AYGVB5QeAFgUjzwzxva63UHAeXa5P4x5Md4j6nAnSNA2A3gnzeB/gu164nIZBR6ANi6tXZi9qSkJHz11VcYPnw4SpQwkDxZROYu5Jr20r18/gwAqN5Hm2TYpyYKU71i9dQ0cLFJ+htINqTaEMQlx6FkkUcjcAvD7u+1wbakeOn05dM/jl0R4Lm5wO+dgOBL2sTRks+RiEyG3tLASKLn7777DoMHD9ZXEYgoI13KFo/y+XNsJI9gXnIJ5oF0K3G0cYS+PFvp2cJP3q0buNHtu7w32xavB7ywFCjVFLDV33EkIhMcBdyuXTvs2rVLn0UgorRCrmovdYM3yHhI7V9KElC5uzb5dn6o0J7BH5GJ0msi6K5du+L999/HmTNnUL9+fTg5pe9wLrOEEJGRB4DXdwH3TgKVuwGeFVFY08BdfXhVTQNX17su9CEhOQHBscFqYFuh9AHsPQNwLws0HJn//fWkb+HJP4Eq3bUpgojI6Ok1ABwzZoy6nDp1apbNNzJPMBHpoQ9gfgZq+38Erm4F7FwKLQDc6b8TewL2qJlA9BUA/nPtH3x24DO0KtEKM9rPKPgdSjNt+08K5rFXDAcurgNCrwOdviiYfRCR+TQBp6SkZLsw+CMqZFLLE/cw/2sAPSsX+pRwMg3cqJqjUN0j/SxDhT0dnI2lDSxQwKNnw+9I/qyC3Ue9R321j/wORIcU7L6IyPRrAInIgFjbARPuAJH38nfEp67WrxBTwXQp20Ut+iQ1f8cGHSvYGUGSE4G5XbWjdiVVi2cB9d2s2AnwrQ3cOwUcnFFwNY1EZD4BYHR0tBoIcvv2bSQkJKS7TWYJIaJCJMGKi1/+PqaecgHqm8wFXODOrQbCbwNOXoBr8YJ9X0hi8KWDgEOzgWZvMDk0kZHTawB44sQJdOvWDTExMSoQdHd3x4MHD+Do6Ahvb28GgESmQBcAPrwNJMYCNg4FPg1ccEwwPBw8YGdlB5Mlzb57p2uvN361wI+rGl3sXR0IOgcc+hVo837B7o+ITLcP4Ntvv62mfAsLC4ODgwMOHjyIW7duqRHB33//aD5KIioc2ydpa3hu7M7fx3XyBOyLSsTy3yCTAiTTr3Ve2Rltlj7FLBj5bOqxqRi3cxzuRt3N/we/skUbjNk6Aw1HoMBZWgKt3tVeP/gLEBdR8PskItMMAE+ePIl33nkHlpaWsLKyQnx8PEqWLInJkyfjgw8+0GfRiMzP9Z3AhbVATGj+Nx966QaCXEJBi0yIVIMvpAZQ37bf3o4tt7YUTAC471HtX4NhhdccK/kFZVBP8Qba2UGIyGjptQnYxsZGBX9CmnylH2DVqlXh6uoKf39/fRaNyPyE6GYBKYCBBJ2/AqxsCyUNTG2v2mrwhT6ngdMZUn2IygeY73kA/Q8Dt/YBljZAE206rUIh0wOO3ArYuxTePonI9ALAunXr4siRI6hYsaKaI/iTTz5RfQAXLVqEGjVq6LNoROZFav10NTru5fL/8Us0gDlNA6fTr1K/gnngs6u0l7UH5P+gnSdh8EdkEvTaBPzVV1/B19dXXZ80aRLc3NwwevRoBAcHY/bs2fosGpF5zgDiUoJTfxmDLl8DL64AWozTXxki7gIX1+tv/0RkvDWADRr8VysgTcAbN27UZ3GIzFfqFHDlC+bxE+OAo3O1++n2vXZAQQFOA3cl7Ao6l+mMBj6FW/OY3XRworhz8fztV1mxI/RGknrPaKRt1n/nEuAgg3yIyJjotQaQiEx4DuC0LK2BrROBo78D4QXbv1emgFtyaQluRNyAvq25ugZdVnbBN4e/yZ8HTE7Sztiib/I+kfQ+SXHAuUfN0URkVAo9AOzSpYtK9/IkkZGR+PbbbzFjRiHMoUlk7qSGztq+4AZpWFkD7uULJSF0z3I91TRwNTz034/Yw94Dtpa2+Tcd3KUNwJQqwO7voFdSA1l3kPb6icX6LQsRGUcTcL9+/fDss8+qkb6SA1Cagf38/GBvb6/yAZ4/fx579+7Fhg0b0L17d3z3nZ5PdETmoMtXQKcvgZTEgtuHVyUg+II2ACzA5stOZTqpxRC0LdUWRwcdzb/p4E4sAmJDgYRo6F2tAcDWT4GAY8D980CxavouEREZcgA4YsQIDBo0CMuXL8fSpUvVYI/w8HB1m5wkq1Wrhs6dO6vRwZIShogKifTLsyzAmTPMcEq4fJ0OTgZdXN2qvV7nUe2bPjl7A5W6ABfXASf/ADpP0neJiMjQB4HY2dmpIFAWIQFgbGwsPDw8VG5AIjJBugAwuOACwKSUJNyPua+aXu2lSduUnPoL0KQApZoBngXUVzO36ryoDQBPLQE6fApY8fxNZCwMYhCINAf7+Pgw+CPSh5t7gVktgE0fFux+dP0LdQmnC4DMuCGDLlotbQVD8f2R7/H2jrcRGB2Yt3l/dX3tdH3vDIE05Tt5a5uk75/Vd2mIyNgCQCLSo6ALQOAZILSAR816PAoAo4MLbBqx8Phw7TRw9vqfBk5n6+2taslTAHj7ABB6XTvvr0zHZiikxm/gX8C7lwC/uvouDREZSx5AIjKDHIA6ds7Ay7u0M40U0GwSNb1qGsw0cDojao5AYnIifJ20Se+fyvFF2svqfbXH0ZAU8iwvRJQ/GAASmbuCzgGYll8ds5kGLl+ng2s+FnB0B6o/A4OWGAvYOOi7FESUAwwAicxdYQaA9HS8qxj2KFv/I8D6cYCDGzDkH32XhogMvQ/gkCFDsHv3bn0Wgci8yawSD28XXgAo/Q03vAds/7JAHn7F5RX48uCXOBJ4BIYiPjkedyLvICAqACbLyRMIPK0dUBQdou/SEJGhB4CS/qVDhw6oWLEivvrqKwQEmPAJksgQhd3UphaxLaLN61bQoh8Ah2cDZ1YUyMPvDdiLpZeW4trDazAUq66sQtdVXdVo4FwLuwWsegW48ij/n6FyLwv41AQ0ycCl9fouDREZegC4Zs0aFfSNHj1aJYUuU6YMunbtihUrViAxsQBnJCAirfhIwLOydhaH/JqtIie5AB/e0k4/l896lOuBl2u9rAaDGAoZkWxnZfd0s4HIPLunlwD7f4DB041OPs8mYCJjYKHRSIIpw3D8+HHMmzcPc+bMgbOzs0oUPWbMGFVDaIgiIiJUDkOpyXRxKZhRjUSFQk4DhREAyn6+LQ3EhQOj9wPFqsPUpWhS1FzATxUASn5GSdHTYzrQYBgMmiT4ntEQsLQB3rsKOBTVd4mIshXB72/DyQN47949bNmyRS1WVlbo1q0bzpw5o6aGmzZtmr6LR2TaCiP40+3HzKaEk+ngnir4e3BFG/xZWgNVe8HgyVzPXlW080lf3qjv0hCRIQeA0sy7cuVK9OjRA6VLl1bzA7/11lu4e/cuFixYgK1bt2LZsmX4/PPP9VlMIspP0uRcAFPCyTRwMtjCkHIA5snZVdrLcm0AJ8NJbP1YbAYmMhp6TQPj6+uLlJQUDBw4EIcPH0adOplzhLVt2xZFi7IpgahA/FBbm7qj/yKgaMnCOci6KeHyuQZQZtqQwRa2lrY4Oujo09W6FZBvD3+ryjeh8QR4O3rnvP+fqPEsjEa1Ptq0QjWe03dJiMiQA0Bp2u3Xrx/s7bOftF2Cvxs3CniKKiJzFBOqHQUsiyQZLixej2oAo+7n68NGJESowRYy6MKQgj+hmwpuWI1hOQsA758Hgi8CVrZAle4wGjKY6Lm5+i4FERl6ALhjxw706dMnUwAYHR2NN954A3Pn8kRCVGCkj5lwKQHYOhXegZYmzfE38j3orOZRDUdePIK45PwfXZxXo2qOQrImGT5OPjm7Q8wDbX86NW2ea0EXj4jMkF5HActgDxn84e2d/hfxgwcP4OPjg6SkJBgyjiIioybzy/7zOlCuLTB4jb5LQ1lJiC7c4Dw/yFeK1F5eWAs0fR2wNZxp+Yh0IjgKWD81gHLgJe6UJTIyMl0NYHJyMjZs2JApKCSifBZyJX2fPDI8xhb86fzRHwi/DXhXBar21HdpiMhQAkDp1yd9dGSpVOlRSog0ZP1nn32mj6IRmV8TsC4tS2E6+RdwdiVQ4xmgzgv5NuPG+ZDz6Fi6Ixr7NoYhiUuKQ3BMMCwtLVHcufjjN75/DnAra7w1Z9L/slov4MDP2qTQDACJDJK1vvr+Se1fu3btVBoYd/f/+gLZ2tqqlDB+fn76KBqR+QWAhTEHcEah14CrWwDXEvkWAO4L2IfNtzajrGtZgwsAZY7ib498i85lOuP71t8/vvn0zwHaATpD1gIl6sMoVemhDQCvbAaSkwArvXY3J6Is6OVT2bp1a3Upo3tLlSplcCP2iEyeBBoyGlfmAdZHDWABJIPuVq6bCv7qeGVOJ6VvHg4esLeyVzOCPNadI0C4P2DrrB1Ra6xKNtKmF4oNA+4cBko303eJiEjfAeDp06dRo0YN1RQiU6jJbB/ZqVWrVqGWjchsyI+u5//Q3/4LIABsX6q9WgyR1Px1KdPlyT92z63WXlbuBtg4FErZCoSlFVChI3BmGXB5EwNAIgNU6AGgJHsODAxUgzzkupwQsxqILOtlQAgRmSBds3N0sLa5szDzEOppOrgnkvOgjJxNO6OGMavU+b8AsCP7dBPB3ANAafb18vJKvU5EepAUr00yrK/uF3bO2vyDEXe0fRFL5a3PXnJKMu5G31VJoB1tjHTwxN0T2uZfGyeggmHWZOaKPAcLKyD8DhAdYjzT2RGZiUIPAGWAR1bXiagQrXsbuLge6PQFUG+wfg69V6VHAeClPAeAwbHB6LaqG6wtrXF80HGD7Ff8zeFv1GwgHzX5CJ4Onpk3uPCP9rJiR+Nu/tWRPoAjtwLFagDWtvouDRFlkIN2iYKzYMECrF+/PvXv8ePHqxQxzZo1w61bt/RZNCLTJrVucQ+1gw302Q/QzhVIiMnzQ4XHh6tp4Nzt3Q0y+BNbbm7BttvbcD/mftbNv5IyRZhS2pTi9Rj8ERkovc4EUrlyZcycOVOlgzlw4ADat2+P6dOnY926dbC2tsaqVY8mQzdQzCRORkk+8t+W0QaAr+4DfGqYRDO0nMpkGjgHa8OsPVt6cSk00KBD6Q5Z1wCGXAPO/w00GgXYFYFJvu8MNDgn8xPBmUD0Oxewv78/KlTQdgZfs2YNnnvuObz88sto3rw52rRpo8+iEZmumBBt8CcpSTzK668c1nb5+nBS82eowZ8YUGXA4zeQ16LlOJicvdOB4wuBTl8CVbrpuzREZAhNwM7OzggJCVHXN2/ejI4dO6rrMjVcbGxsrh5r9+7d6Nmzp0ogLV8EElA+yc6dO1GvXj3Y2dmpQHT+/PlP+UyIjIgu9UrRkqbR14wMW0SANvH35Y36LgkRGUoAKAHfyJEj1XL58mV066b9dXju3DmUKVMmV48VHR2N2rVrY8aMGTnaXkYgd+/eHW3btsXJkyfx1ltvqXJs2rTpqZ4LkfHNAGIAcwCvegX4qT5w/3zeHubKKnxx4AscvHcQhio+OR7+Ef64E3knc9Pvkhe1U+OZIkkHI2RWEP31OCIiQ2oClmDto48+Uk3BMiWch4c2TcCxY8cwcODAXD1W165d1ZJTs2bNQtmyZTFlyhT1d9WqVbF3715MmzYNnTs/OmERmXINoD5mAMlIaoZCrgJB5/M080XaaeCa+DaBIVp2aRkmH5mceTo46fd3cR2QGAPUeLZAyxARl4i4xGQkJWu0S0oKirnYw8muAL8KSrfQpraJvAcEngZ8axfcvojIOAJAGfH7888/Z1r/2WcFnzRUBp106NAh3ToJ/KQmMDvx8fFqSduJlMjoeFYEyrcHSjTQd0kA72ra6c/unwNqPvfUD9O9XHcV/NX1rgtD5eXolfV0cBcKdvRvYnIKNp4NxNx9N3DitvT9TM/R1gq96/jhxcalUaO4a/4XwMYeKN9WG+RKUmgGgEQGQe8zdD98+BCHDx9GUFAQUlJSUtdLP76XXnqpwPYrs5EUK1Ys3Tr5W4I66X/o4JC5b9TXX39dKMEpUYGqP1S7GAKfmtrL+2fz9DDtSrVTiyHrVLoTOpfunD5NzcPb2gTQEhRW6ZGv+3sYk4C/Dvtj4YGbuBcel7pedm9jaQlrK20oGp2QrLaTpU7JohjUpLQKCG2s8rGHUMVO/wWArcfn3+MSkXEGgGvXrsWLL76IqKgouLi4pDsxFnQA+DQmTJiAceP+G6UnwWLJkiX1WiYioyZJgkVg3gJAo50O7sI67WXpZoCzd77ta9uF+3hryUlExiepvz2dbVVgJ7V8XkXs0qXOOXwjFIsP3cbGs/dw0v+hWlYc88fMF+vDzck2/wJAEXAMiArK1+dKREYYAL7zzjsYPnw4vvrqKzg6Fu70TT4+Prh/P31CVvlbAtGsav+EjBaWhchoSdLl5HjtLA2GoFh17WXk3aeeEzgxJRH3ou6pJlZDTgOTpdTm31758nAS0P26+zq+3XhRjbeo4lMEI1uWQ8/avrCztsq0vfzQblzOQy0Poqph6RF/zNx5DQevh6L3jH34fUgDVCyWDzkJXXyByt0BZy8g6b/aSCIy01HAAQEBePPNNws9+BNNmzbFtm3b0q3bsmWLWk9ksq5u1SaBXtQXBsHeBXB7NOI/8MxTPYR/pD+6r+6O9ssMf/7cKUen4M3tbyIgKgCIvA/cfjRquWrem39lcMc7y07hm3+1wd+LjUth7Rst8Fz9ElkGfxl5OtvhtbYVsGpMM5R0d8Dt0Bj0/WU/dlwMQr4Y+CfQ8wegaKn8eTwiMt4AUAZdHD16NF8eS5qRJZ2LLLo0L3L99u3bqc23gwf/N+fpq6++iuvXr6vp5y5evIhffvkFy5Ytw9tvv50v5SEy6BHATgbUBFeiIeBXF0jRNlfmVkR8hKr583TMYnYNA7Przi7s8N+BgMgAIDpIOxBHnr9riTw9blBkHAb+dhCrTgTAytICX/Sujkl9az5VP75KxYrg79daoHFZd0TFJ2H4giOYs+d6nspHRIZHr03Akofvvffew/nz51GzZk3Y2Niku71Xr5w3i0ggKTn9dHR99YYMGaISPN+7dy81GBSSAkbmIZaA74cffkCJEiUwZ84cpoAh88gBKCOBDcWzc/J09zredXDohUMqz56hG1Z9mGqyLuVSCnDyAUZu1U6JlwfhMYkYOPsgrgVHw9XBBr+8WA/NK+QtGHZ3ssWiEY0x8Z+zanDIl+svoKijrapNzJOUZG0/wCI+rAkkMue5gC0ts/91Kn1TkpOTYcg4lyAZnd/aab+A+y8EqvV+qoc4GxCOz9edx40H0Sjv5aRqjKSfmPQ3q1fKTdVAUeFISErB0HmHsf9aCHxc7PHXy01Q1tMp3x5fvh6+33wJM3Zcg62VJZa80kS9xnlK/H16CdDmA6DN//KtnES5FcG5gPVbA5g27QsRFTD5rZdaA5j7JNDR8UmYtuWyyieX8uhnY3BkvBowoNO0nAdmD66PIvbpa/NzRGrCLG3klyFMXuh17UCcPAzGkeDsozVnVPDnZGuFuUMb5mvwp/sh/k7HyrhyPwqbz9/HK4uOYe3rLeDjav90DyijnSUAlL6oDACJ9MpgzrRxcRwZRlSgJP1GfAQg6Ujcy+Xqrtsv3kenabsxZ682+OtRyxfLX22KKf1q45XW5dCuirdKKHzgeojqi/YgKpfNmnO7Al/5AcEXcnc/APPPzsfnBz7HqeBTMHQJyQm4HXEbN/59B/iuAnDyz6d+rJm7rmHZ0TuQCtefX6iHan4uKAiWlhaYNqCOquGVgP/lRUfVgJOnUuHRQJ2Ao9pR30RkngGgNPF+8cUXKF68OJydndWgDPHxxx/j999/12fRiEyPLriSUbfWOU9nJAMAhs8/ioCHsShe1AHzhjVUAUfDMu54tn4JTOhaVdU+LXulKTycbHE2IAL9Zh2Af2hM7song0CeYiTwzjs7sfzycpUKxtBtu71NjVj+POq89vk+5awY60/fw+SNl9T1T3tVR9sqBTuoR6aK+21wA7g52uD0nXD8b+VpVQOZazLYxasqoEkBru8siKISkTEEgJMmTVIDNCZPngxb2/8SjtaoUUMNyCCifOTkBTQclav5Zk/5P1RpRcTQZmWwZVwrtK2cdbAh04hJraAEidI/8LlZ+3EpMDJnO/LRJYTOfQD4fOXn8WrtV1HZvTIMnaeDJxwsbWCtSQbcy2unwsulc3fDMW6ZNtvBsOZlMLjpozQ6BaykuyN+ebE+rC0t8PfJu/h974281QJeTZ+Gi4jMKABcuHAhZs+erWYDsbL6L09V7dq1VWoWIsrnpMvdvwfafZSjzSUFyJtLTiApRYPuNX0xsWc1ONo+vttwOS9nrBzdDJWKOeN+RDz6/5rDmkDdjCBPMSVcl7Jd8Fqd19RcwIauQbEGOGRXE78FBmvn/k07LVwOSNOrzPARn5Simt0/6p77ADIvmpb3wCc9tfv8btMl3HwQnfsHqfBoDnbpB6i/MYhEZk/viaArVKiQ5eCQxMREvZSJiLQ+WXMWt0JiVI3eV8/UTD+H7WPIAAFpDq5VwhXhsYmYsOrMk5sLU2sAz5p0UGCRFA+LK1u0f1TL/ewf32+6hCtBUSpp8/f9autlxPVLTUqjeQUPFYR+uCYHr21GpZoCNo5AVGCe54AmIiMNAKtVq4Y9e/ZkWr9ixQrUrVtXL2UiMknJScCdo0BCzmpsVh2/o5IKS3zxw/N1VH653JCccT88Xxd21pbYe/UBlh+98/g7SFOoDE6JeQBEpZ+i8XFik2JxK+IWYhJz2d9QX67vABKjAZcSgF+9XN31wLUQ/L5P2+w6+bmaKlefPsgPga/61oS9jSX2XQ3BimNPeG0zsrEHekwDRmzR9gckIvMLAD/55BO8/vrr+Pbbb1Wt36pVqzBq1CjVN1BuI6J8EnoNmNMe+L6yVLE/dlNp1vt4jbZm5q0OldCgTO7n5xWSkmRcR226mS/Wn8f9iMeM9LdxADwq/FcLmENngs+gx+oeGLBuAIzC+X8ws6gL3vD1xbnQ8zm+W0RcIt5dfkpVjg5sVBLtqhSDPpX2cFLvDSFJomV0cK7Ufh4o2Qiw0msmMiKzptcAsHfv3li7di22bt0KJycnFfRduHBBrevYsaM+i0ZkWnRNbd5VHptnLzlFg7FLTiA6IRmNyrqruWHzYkSLsqopODIuSQWVj20urNRFO0DF3jXHjx+dGK2mgfNy9IJR6DARh4tXx86E+7gVfivHd/vsn/NqFHYpd8dC7/eXnZEtyqK6n4tq5pfE4ERkXPQ6E4ixYyZxMhrbPgf2TAHqDwV6/pDtZv+cuos3/zqBIvbW2PRWK/gVdcjzri/ci0DPn/aqwSQzXqiH7rV8URD59Wyt9NMkmlubb27Gw/iHaOLbRDsl3BNsPHsPry4+rprjpW/l09bIFoQzd8LRe8ZelRty7tAGuauZlDQw59YA1fsA5doUZDGJMongTCD6rQEsV64cQkJCMq1/+PChuo2I8sn9c+lH22ZT+/fjNu1MIa+0KpcvwZ+o6uuCMW3Kq+syt2xYdALym7EEf6JTmU7oX7l/joI/mef3o0fN8a+0Lm9QwZ+oWcIVI1tqz9UfrT6rZovJsQvrgGPzVLM4EZlZAHjz5s0s5/uNj49XI4SJKL8DwOrZbrL+zD1cDYpSAz6GNMvf3HKvtauAit7OeBCVgK82PGa2D+mfGHINSMr/IFHvg3CWvAgcmQMk5nzWI5mHV46ZzLn8VoeKMERvd6iEEm4OuBseh7m5yQ2Ymg5mi0mP/CYyVHrpgfvPP//94tu0aRNcXf/r8yMB4bZt21CmTOEkNyUyebEPgXB/7fVsEg+nrf2Tvl1PNZfvY9hZW+GbZ2vh2Zn7sfL4HVWbVcHbOfOGP9YGHt4GRu0Aij95lOyPx39UzakDqwxERTfDDJCUm3uAi+uAW/uRUPsF3Iu4hbikuMcmr5bm1cWHtP0Ev+hTQx1DQ+Rga4XxXaqorgO/7r6OFxqXgodzDmaaKdNCO/ezvN4S9Hvmrb8pERlBANinT5/UdAJDhgxJd5uNjY0K/qZMmaKPohGZnqBHHfRdSwIORZ9c+9e8YH581S/tho7VimHL+fsq2PxxYBapnmSaOgkIZNBKDgLALbe24GbETXQt2xUG7dxq7WW1XjgZchYjNo9Qiav/6ZN182dKigYf/S2DZoDedfzQrLwnDFmPmr6Yvfuamgbw5x1XMbFn9jXNqeycgdJNgRu7gWvbGAASmUMTsKR8kaVUqVIICgpK/VsWaf69dOkSevTooY+iEZmeoqWAzl8DTcbkqPbPJZ9r/9LSNWOuPX0Xl+9nMU2cT61cpYIZWXMkRtcejTIuZQy7+ffCWu316n3h6eipRi7bW9lne5clR/zVNHxF7KzxYTfDz5VnaWmB97toy7n44K2czwNdntPCEZllH8AbN27A09Owf9kSGT3XEkDTMdolCxsKofZPp7qfK7rW8FE1W9O3Xs7zlHC9K/TGmDpjDDsNzM3dQGwo4OgBlG6Bsi5lcfjFw1jWc1mWm4dExePbjdqpMN/uWAneLtkHioakRUVPtKzoicRkDaZsvpS7eYGliTwpl7kEiShP9J6FU/r7yaKrCUxr7ty5eisXkTlIW/s3ooBr/3QkgfDGc4HYcCYQ5+9GoJqfy383+j6qAbx3SltzZgqJgnXNv1V7qefzpMnbJPiT3Hoyenpw09IwJv/rUgV7ruzFmpN31ejgGsWfkNNRAn7nYoCtMxB+B/DQjhYnIhOvAfzss8/QqVMnFQA+ePAAYWFh6RYiyiP5UXV6uXYUcBYzgEjtn8wt62JvjaEFXPunU9mnCHrU8lPXM9UCelUB7FyAhCgg6NHI5WxEJkTiZvhNw54GLjkxXfPvkxy7FYZlj6bN+7JPDVhb6fUUnWsS8PWqrX1tJ2/KQS2gzC895iDw5nEGf0SFTK8/r2fNmoX58+fjpZde0mcxiEzXw5vAqpGAlR3wwd1Mv/nm7LmuLke0KFcotX86Y9tXxPrTd7H5/H012lXyySmWVtopwq5uBW4fBHxrZ/sYewP2Yvzu8ahfrD7md5kPgxQTop3zN+gCULp56uoF5xbgaOBRDKw6EM38mqUO/NDNqNGvfgk1aMYYvdupMv49ew+7Lwdj39UHaF7hCd18HA0rtyGRudDrz8uEhAQ0a6Y9+RFRAeb/kyngMjSnSuB16k44bKwsMKjJk5MS5ydJAdO7TnF1fVrGWsBaA4BW7wGlmjz2MSSNiqO1I7wdvGGwivgAL60Cxp5Md/zPPjiLnXd24trDa+lmYZGBH062VnivS/bpYQxdKQ9HvNhY23T93aZLj5/+Ly3J/ZiLHIlEZMQB4MiRI/Hnn3/qswhEZjsDyB+Pcsx1reGbs7xt+ezN9hVhZWmB7ReDcNL/4X831OoPtPvosbV/om/Fvjj04iFMajkJBs/aLtPglY+bfIymvk3V37EJyakDP8a0rQDvIsYx8CM7Moe0nbWlel33X8s821MmWyYCk8sCZ1cWRvGISN9NwHFxcZg9eza2bt2KWrVqqRyAaU2dOlVvZSMyCbrRtBlmAImIS8TfJ6VJGBjURD8DDcp6OqFPneIqMfSvu65h5qD6T/U4NpJM2BCF3dQmOnbV1nSm1aJ4i0xN8ffC41C8qIMajGPsvIrYYWCjUpi//yZ+3n71yc3AVjbafp+SD7Dui4VVTCKzptcawNOnT6NOnTqwtLTE2bNnceLEidTl5MmT+iwakUlPAbf6eABiE5NRqZgzGpbRX1+zV1pr55GVUcHXg6P+uyEmFLj0L3DXiM8Du74DplUD9v342M3uR8Rh5i5tU/D/ulaBvY1hzviRWy+3Kqe6Fxy4HoJjt0JzNi3ctR1ASubpQYnIxGoAd+zYoc/dE5m2+Cgg9EamJmDpkyXJeoX01ZIZefSlUrEiaF/FG9suBuG3PTfw9TM1tTfsnQrs/wmoPwzwm57lfb88+CWSNckYUWMEShQpAYMi/dkuPhr9m8WMJokpiQiIDEBUYhQW7EhGTEIy6pYqip61fGEq/Io64Jm6JbD0qL+qBZw3rFH2GxdvANi5avMlStBf4ulqg4ko54wrxwAR5Vyw9CnTaPOsOf3XBHf4RqhK/eJgY4W+9TI3TxY2mRdYSFNwUOSjQQCltH3j1EjgbGy4vgErLq9AQnICDM6VzUBcOODs899zSeN2xG30XNMTIzeNworj2rQvH/eoptdgvCCMblMelhbAjkvBOBsQnv2GMkCmXCvtdWkGJiLTrAF85plncrTdqlWrCrwsRCbLsyIwcCkQn37KtT8O3VaXMsdsYaZ+yY40QUvt14nbD7Fg/02817kKULKx9sbgC9rm4AypQqQW8+0Gb+NBzAP4OPnA4Jz6S3tZq582tU0GMnOJjGBOTHCCRpOMXrVLol4p40z78jhlPJ3Qs7af6m86Y8fVx/fzlGnhJGeipABqPb4wi0lklvRSA+jq6pqjhYjywN4VqNxFG4Q88iAqXuVo0+fgj4yk1uvVR7WAiw7cQlR8krbG0rOSdgP/w1nep1+lfhhdZzQcbRxhUCRgvbxJe732wCw3cbF1wZd1/0HIpbdha22D8Uac9uVJxrSpkNrP80pW8z9nnBbuzhEglhMBEJlkDeC8efP0sVsis7f86B01V2vtkkWfPE1XIepYtRjKeTrh+oNoLDl8W00jpvIAPrgM3D6gDWSNxblVQEoi4FMz0+AbncTkFHz97wV1XUb9lnAzsCA2n2d+6Vy9GDadu49fdl7DtAF1st6waCltDkgJ/HOaO5CInhr7ABKZIpmCbNvnwMUN2jl1H8008edh3eCPwk38/CSWlhZq1Kj4fe8NJCSlPLYfYGhcKG6E3zDMaeDOrXls7Z+QIPd6cDQ8nGwxpo3pz3/7etuKqcmub4c85jV7ZjbQ6l3ODkJUCBgAEplq/r89U4A1rwIW2o/5vmsP4B8aq+b97floLl5D0qducZU/TvLhrT1197+ZQO4ezzRDhAwA6bWmFz7Z/wkMzsAlQJ9ZQM3/mt4z5mCctvUKbIoeQsmqf2HfPdMf9CBT/bWq5IXkFA1+36udfpCI9IsBIJEp8j+ivSzRUKrX1NWVx7SjTWUKNgdbw8s1J/nvhjUvo67/uvsaUlzLaAOp0fszzaQhaVScbJzg5eAFg2PnDNQZCDhnPUXdrJ3XEBqdALeiobgWfQQXQrVNwabuZWnWB7Ds6B08jEl4fB/KMyu0ibSJqMAwACQyRdKRXhcAAoiMS1Sd8MWz9Q0sZ14akpfQ2c4al+9HYcflYG0gJaOZM6RHGVZjGA6+cBDvNngXBiMH/dYCHsaqJm4xom4ffNL0E3QpY0T9G/OgeQUPVPEpohKQ60aiZ2nNaGDlCOAss0AQFSQGgESm6M7hdAHghjP3EJeYgvJeTqhdwnAGf2Tk6mCDFx71T5z1aHaMx7HKIsWK3tzaD8xsDhz5PdtNpmy6hPikFDQu646XG7VXI5mrelSFOZCR26Me1QJKuh/Vz/Nxs4JIOhgiKjAMAIlMTVTwo+YzC6BEA7Vq5bEAdflc/ZIGn2xYRsXaWlniyM0wHL92Fzj0K7D6VRnFAoMmuf+k76X0WcyCJEJedUL7OnzYvarBvw4FQXICFnOxQ1BkvBoQ8tgAUAb/SDJtIioQDACJTLX516uyygV4KyQah2+GqhkZ+tbV/8wfT1LMxT61nLP2+ANbP9MGV2pmE633dr2HT/d/qkYDG4TEWOD839mO/pXE1Z+vO6+u96njh1oliiI5JVmNZD52/xjMha21JYY00/bznLPnujoumbiXBTwqAJpk4PrOwi8kkZlgAEhkau6dTNf8u/K4ttapRUUv+Ljawxi83Lqc6va3+eIDRHvX1a68tU9dxCfHY+PNjVh5ZSWsLAykCfjSBiA+AnAtBZRqlunmjWcD1RR89jaWGN+lilon8wDLSOahG4eq52QuXmxUGo62VrgYGIm9Vx9kvVGFjtrLK1sKtWxE5oQBIJGpaTMBeP0o0OJtlftv1aO5Zp81gHl/c6q8lzM6VSumru9IrKFdeXF96u2fN/sco2uPVjNqGIQTi7WXtfqnjrrWiUtMxqQN2pG+L7cqD7+iDuq6lL2oXVGUdS2LyITHzJBhYlwdbdC/QUl1ffbubFLCVHwUAF7dxqTQRKY0EwgRFSCpOpORswAOXQvBnbBYFLGzRufqBjhn7mPI9HAye8S0O5XQwxbAzT0qRYidozv6VuwLg/HgCnBtu7bPZb2XMt08d98N9Rr4uNjj1dbaQRBC+gDuHrDbLPsCSj/PhQduYs+VB7gYGIEqPhkC+dLNAZniL/Ku9vh6PZoWkIjyDWsAiUzYyke1fz1q+6o8e8akbik3NCnnjmspPghyKAekJP03x64hOTJHe1mpC+Cm7d+mExQRhxnbr6rr/+taGY626X9zm2PwJ0q6O6JLDe0Pkjl7tGlx0rGxBwYsAsZdYPBHVEAYABKZEkmgu3wYcGEdouOTVPoX8Ww9w83996RaQLEi5lE/wIvr4B/hrwZPGEy/uaq9gKo9gcYvZ7rp+82XEJ2QrOZe7l3beJrgC4Oa71mmhzt5F8GR8VmPBnYxvBlriEwFA0AiUyK5086tAu6dUgMPYhKSUcbDEfVLu8EYta7khaq+LliXUB8psAJSkjHr1Ew1eGLR+UUwCGWaAwMWA+XbZUr7svzR7CsTe1ZT8x1ntD9gP17b9hp+OvETzE29Um6oU7IoEpJT8OfjEkMTUYFgAEhkSvwfJYAu2Si1+Vdq/4y1qVHK/Ua7CjivKY0Wmt8Q1nshLCws4WjtCD8nw60dUmlf1p5Xk4P0ruOngp2shMWHYfed3Th+P+vcgaZON/XfooO3EJ+UnHmD08uBRc8AFzcUfuGITBwDQCJTIXOohmpnz7hXpDoOXA9R1/sa0ejfrHSp7oNqvq64G2+PX3dfx5ctvlTTwHUpq+cp1CRR8aYPgdDMfdhWnwhQuRcl7cv/HqV9yUod7zpqOrjX674Oc9Stpq9KDP0gKh7rT2u7K6QjSbWvbQMu/TcCnIjyBwNAIlNLAO1REasvxqjaJ5lyrISbI4yZNJ2+00k7CnT+/ht4cO8WLJITYWmh59PXwV+AAz8D+35ItzosOgFfrtemfXmjXcXUtC9ZKe5cXE0HV79YfZgjGytLDG6qrQWct+9m5sTQullBrmxlOhiifMYAkMjEAkBNiQZY9Sj5s7EO/sioXRVv1V9sGqbA49fawI3d+i1QeIAaaKM0Sj/445t/LyI0OgGVijmnzn1L2RvYqBTsrC1xJiAcx26FZZEOxgmICvwvwTkR5QsGgEQm1v8vwLkmrgZFqS/VrjWNK/ff4/oCvtupMs7a2mJ0MU9MPzJdvwU6Nk87VVnpFkCxaqmrZbaPpUf91fWv+tZUU589yd2ouzh47yCCY4JhjtydbNGnTvHUWsBM6WDKt9Vev7RRD6UjMl0MAIlMhSYFsLTG2hDtl2mn6j4oYm8DU9G8ggcuelbAPkcHnIq8pkYE60VSPHBsvvZ6o1GpqxOSUvDh6jPq+vMNS6JBGfccPdwn+z7BqM2jVBBoroa10DYDbzwXiICHselvrNxVe3n5Xz2UjMh0MQAkMhVD1yFx/C3Muaztc/aMkQ/+yKoW8JmWw/B+cCQGhYfh/tld+inIiUVAdDBQxA+o0j119W97ruNKUBQ8nGzxftfsB35kJFPByaL3Po16JDOBNCvvgeQUjZohJJ2KnbWzrNw7BUTc1VcRiUyO+Z5xiEzQruvRCIlJgqezHVpW8ISp6V6jLkqn1Ef7mFhc2Pln4RcgMRbY/b32eou3ASttDeutkGj8uO2Kuv5h96oo6ihz1+XMh00+xD99/kH3cv8Fk+ZoWPOy6nLJYX/EJCT9d4Ozl7YvYLm2QOxD/RWQyMQwACQyBQkxqelHhOSes7YyzY938ab91GXFkB04cfNB4e5cpqOrNQDwrAzUH6JWSa3V+BWnEZ+Uomqx+tY1rZrXwhzoU8rdEeGxianv41RD1gKD16Trb0lEeWOa3xBE5kT6pH1fCUmz2+PohSsm2fyrczTwKDRVayHC0hnFLR7g7+VzkZicUngFsCsCdPwMGHMAsLZTq2btuoZDN0LhaGulBn4Ya9JtfbOytMCQZtq+gPMzpoSx5FcVUX7jp4rI2N0+ACREIiHkFu4nOaFysSKo5usCU5OiScGoLaPQe/0A3G/5Fj6yeB2LQypjzp7MiZgLnKWVujh+OwxTt1xW1z/rVR1lPJ1y/VDh8eF4fdvr6L+2v3qO5qxfgxJwsrVSfSn3Xs2idjfiHhB2Sx9FIzI5DACJjN3VberisEVt1Vleav9MsRYqMiESZVzKoIhtEZRt/TZqd38VSbDGD9su43aItgm8wEjfsz+fB24d+K88cYkYu+SEagLuWdsPz9V/upyLjjaO2BOwBxdCL+BBbCE3aRsYF3sb9GtQUl2fuzdDYC8Jt6dWAXZP1k/hiEwMA0AiEwkAV0VUgcR9vR/lVDM1rnauWN17NfY9vw/WltYq4GpazgMJiUn4cM2ZzLNI5CeZ8UPSkKx7G0jR1tJ9vOYs/ENjUbyoA77sU+Opg24bSxt82fxLzOowSwW35m5oszLqfbzjUjCuB0f9d4NPLe3l5c2prwERPT0GgETGTJrEgs5BAwvsSamBFhU84eNqD1OmC7Tk8seKx7DH7i2EXz2Ef04VUIqQ6AfAwZna620/UP3RVp+4gzUn76p+az8OrANXh7zlW+xZvieaF28OB+vsp40zF9KM3q6yt7q+YH+alDAyEtjOBYgO0s4RTER5wgCQyJhd264uLliURxhcnroZ0lh5hZ1Wg0FGW/+Dz9eex8OYhPzfiaR9SYgCfGsDVXviYmAEPl5zTt00tn1F1C+ds4TPlPuUMCuO3UFEXKJ2pbUtUKG99vqlDTycRHnEAJDImF3TNv9uTawBF3trdK5uGlO/ZWXmqZl4deur2Om/87+VLd5SF52tjqJozA2MW3YKSfk5KvjWfuDQLO319p/APywWg38/jKj4JDQp547X2lbIl92ExYWpmUBOBnG+W92sLzKXcnRCMpYd0U6tp1R6NCsIp4UjyjMGgETGrHI3nHBuje3J9dCrjh/sbbSjU03RqaBT2BewTwVLqbyrApW7wxIajLFZj+0Xg/Dp2nP50x8wLhxY9YrMsQfUGYTgYi3x0u+HEBQZjyo+RfDroAaqCTg/7PDfoaaDm3X6UbBp5qR5f2gzbS3gggM31UAbpWJHwMJKdXvgaGCivGEASGTEwsv3xoCHo3FSUwH9H42eNFWj64zGZ80+Q4NiDdLfIDNyAOhrtRd+FiFYfPA2ft19Pe87PPEHEH4bcCuDiLZfYOi8w7gZEoMSbg5YMLwRXB3zb57lkkVKqhHOvk6++faYxk4Sahd1tFEDbbZduK9d6egOlGqivX6JcwMT5YWFpkCHzpm2iIgIuLq6Ijw8HC4uppd3jQzfooO31GhUyf238a2WJpn+JUfm9wBu7sFNr3Zo4z9CpcP5aWBdlZ7lqcmp8dh8xHtUxuDNFirZs6ezLVa82uyp8v1R7n3z70WVaFtGe//1cprALz4SqNQZsHflYaWnEsHvb9YAEhmts6uw/6DkpdOoBLpmG/yJDp8BljYoE7IHE+pqB4K8s+wUDt8IffrHtLDA/UoDMXSLpQr+ithZY/6wRgz+CtHgpqVVM/uB6yE4fzdCu7JyV6BWfwZ/RHnEJmAiYxQXDs3KkZj58BWUtAw1+flnJUHy/oD98I9MMyAgrRL1gT6/AEPXY2S/vuhcvRgSklMwfP4RLD1yO+d9AiW/3J6pKvGzNDt2/WGPCj4cbKwwe3AD1CjOGqfC5FfUAV1raAc2/Z4xMTQR5QkDQCJjdH0XLDTJuJbii2pVq8HDWTsvrak6ev8oXtn6Cj7Y80H2G0mtUKnGqsZo+oC6apSujNb938ozeOn3w/APfcJsIQnRwLKXgG2fIeiHNnh5wSGERieoafXWvtECTct7oCBNPTYVz/zzDHbc3lGg+zE2I1uWU5f/nApAUETcf7kZ904HNozXb+GIjJhJBYAzZsxAmTJlYG9vj8aNG+Pw4cPZbjt//nzVZJZ2kfsRGYPkK1vV5e6UWiY/+ENYWVihQtEKKF+0fI62dwi9gL+cpuGzziVhZ22p5pXtMn03Fh24iYSkLNLERNxD/JwuwMV1SIA1Jkb0QjKsMLx5Wax+rRkqeDujoAVGB+JK2BXciuBct2nVKVkUDUq7ITFZo/q8KtIHcOtE4Mhv2mCQiHLNGiZi6dKlGDduHGbNmqWCv+nTp6Nz5864dOkSvL21WeUzkoEbcruOWfehIuORkoLEixshCV9O2tXHS5W8YOo6lu6olhxJSQaWD4FFyFUM0SSj7Yiv8O6mUBy+GYqP/z6HT9eeRxkPR1T0LqJyzRWNuIQe596Gt+YBQjXOeDlhHG441sK8frXRtkrW546CMKjqIPQq3wuV3CoV2j6NxYgWZXH0VhgWH7ylci/au5fVJua+d0oF7ag/VN9FJDI6JlMDOHXqVIwaNQrDhg1DtWrVVCDo6OiIuXPnZnsfCfh8fHxSl2LFihVqmYmeyu39sI+9jwiNI4rX6wxrK5P5GOcPSyug72zAyha4uhWlFjXDUu/5+KG9PdwcbVROuWvB0dh4LhD3d/2GAWdGquDvqsYPnxX7CV269cWmt1sVavAnannVQoviLeDtWLj7NQadqvugpLsDwmISsep4gHZltd7ay/N/67VsRMbKJL45EhIScOzYMXTo0CF1naWlpfr7wAEZJZm1qKgolC5dGiVLlkTv3r1x7px2eqfsxMfHq6HjaReiwhZ9dIm6/De5EZ5pmD8zUZgcGRQydD1QtjWQkgSL00vQe98zOF52Jg683xYLhzfCxz2q4UWPK3CyiEewZ2N4jd2NH8Y8o/qceZp4n0pjI/06hz1KDP373utIkcTQ1fpob7y+C4jJw2hvIjNlEgHggwcPkJycnKkGT/4ODAzM8j6VK1dWtYN///03Fi9ejJSUFDRr1gx37tzJdj9ff/21yvunWyRwJCpUKSlIetT/70qxroXSN03fZATvwHUDMXrraITEhuT8jiUbAUP+AUZtB6r0UKssrm2Hb/I9tKrkpZoVa3UZDnT8HF6j18PVXb9N6YkpiTh07xD+vvp3/sxkYmL6NyypUvFI7e2uy8GAR3mgWE1Ak8y5gYnMuQ9gbjVt2lQtOhL8Va1aFb/++iu++OKLLO8zYcIE1c9QR2oAGQRSYYpP0aBr4veomXAEfVprgxpTF5EQgbMhZ9V1J5unSMBcvD7w/B9A8CXg3Jr0t+maEQ1AckoyRm4eqa63KdkGrnZMOZOWs501nm9UEr/tuaFSwqgmenn97p/RNgPXHaSnV47IOJlEAOjp6QkrKyvcv/9ouqBH5G/p25cTNjY2qFu3Lq5evZrtNnZ2dmoh0peNZwNxN8YCyS4t8XP1PMxyYUTsre0xu+NslQtQrj81r8pAm//BUMlzq+tdF47WjohNimUAmIUhzcpg7r6balT3hXsRqCoB4N6pgH1R7cwtHMhHZF5NwLa2tqhfvz62bduWuk6adOXvtLV8jyNNyGfOnIGvL+fiJAOVkoxF+2+qqy80Kg0bMxn8YWdlh6Z+TdGzfE+YuoVdF2JWx1nwccrZD1dzU8LNEV0eJYb+TeZ79qoEjL8OPPsbgz+iXDKZbxBpmv3tt9+wYMECXLhwAaNHj0Z0dLQaFSwGDx6smnB1Pv/8c2zevBnXr1/H8ePHMWjQINy6dQsjR2qbYIgMTcDuhfg2cARetN6OgY3Y/5TM0yutdImh7yLgYSxg46DvIhEZJZNoAhYDBgxAcHAwPvnkEzXwo06dOti4cWPqwJDbt2+rkcE6YWFhKm2MbOvm5qZqEPfv369SyBAZoqhjf6Gy5T0080qCt4v5JC0/GXQSMYkxqOReCZ4OnvouDulZrRJF0byCB/ZdDcGcPdcxsWd17Q33zwOuJQB7F30XkcgoWGg43OypySAQGQ0cHh6ukkoTFZTIkLtw+LE6rC1ScLLPNtSp08BsDvZr217D7ju78VHjjzCgygCYsj139mD68elqxpPJrSbruzgGa8+VYDW9n8zRvP/9dnD7dzRwdgXQ80eg/hB9F4+MQAS/v02nCZjIlJ3bvEAFfxetKqJ27fowJ35OfijjUgYV3Ew/56EMBLkcdhmng0/ruygGrUUFT1T3c0FsYjIWHLgJ+NbS3nBKmyOTiJ6MASCRgZNK+iJXVqvrkRX6mN2UhR82+RBr+65F/WKmH/hWda+Kn9r9hN86/abvohg0+QyMbqOdF3rB/puIrfIMYGGpZslB6A19F4/IKDAAJDJwx06eQPWUS0jWWKBqRzZvmTJnW2eVA7BkEQ7yeZKuNXxR2sNRTQ+35GISUK6N9obTSwv+hSIyAQwAiQxcwI7Z6vJmkfpw9mRgQKSbHm5US+2I4Dl7biCp5qP+oaf+0uYEJKLHYgBIZMDOBoTjt+DqWJvcFK6tx8DcTD82Hb3W9MKqK6tgLvwj/dXz3em/U99FMXjP1S+h5m2WdDDrE+oBts5A2E3A/5C+i0Zk8BgAEhmwmTuv4aymHLZV/xqeDZ+FubkYdhE3wm8gKSUJ5uLA3QOYuH8ill5iU+aT2NtYYVjzMur6jH33oKnaS3vDWfP5wUAEc88DSGRqbjyIxoaz99T1Vx91eDc3XzT7AlfCrqBcUW1Tnzmo5lENTXyboI5XHX0XxSgMalJa/VC6fD8K+xv2R/PnewIVOui7WEQGj3kA84B5hKggzZs/GzZXN+J86UH4atQzPNhE2Ziy+RJ+2n4VVXyKYMObLWFpaV4j5Sn3IpgHkE3ARIbofkQcql6fh0HW2/CG20F9F4fIoI1oURZF7KxxMTASm88HaldyIAjRY7EPIJEBWrfpXzSxPI8kWMG341iYI5kCbunFpbgUegnmmv8xLilO38UwCkUdbVP7Av645SI0274AfqoHRAXru2hEBosBIJGBCY9JhPfZOer6g9LdANfiMEebb23Gl4e+xJqra2BuFpxbgCZ/NsFPJ37Sd1GMxvBHtYDn78cg/OxmIPQ6cGa5votFZLAYABIZmJU7D6ELDqjrxTq9A3NVsWhFtCrRCrW9a8PcONk4ISYpBtfCr+m7KEZZC7gorrl25bH5bAomygYHgeQBO5FSfouMS8TKb0dgqOZvPPBoCM83tvIgm6GwuDC1lHQpCRtLG30Xx6hqz1t8ux2a+AicdH4T1kkxwOB/gHKt9V00MjARHATCGkAiQ7Jw63E8k7JZXXdr/5a+i0N64mbvplLfMPjLHVdHGwxrURZRcMQGy7balYe1M+kQUXpsAiYyEIHhcZhz6C5+T+qGMI+6sKrSDeYqITkBySnJ+i4GGaERzbV9AX+MfFTrd2kD8NBf38UiMjgMAIkMxNQtlxCWaIt9JUai6GvbAEvz/Xj+c+0fNPqjEb48+CXM1eF7h/HD8R+wP2C/votilLWAVzUlcNyqFqBJAY7N03exiAyO+X7DEBmQi4ERWHFMW0vxQfeqsLC0gjm7Hn4dCSkJsLOyg7naeWcn5pyZgz0Be/RdFKMzqmVZuDvZ4ufYTrhSoi9Qva++i0RkcDgVHJEB+GfVn1hjMwtbS72JeqXcYO7eqf8OBlYeCBsr8x0AIdPBxSfFo5FPI30XxegUsbfB2PYVMfGfBJwObIKdblXhrO9CERkYBoBEerb/8n30CPwF1SxvobT7GX0XxyBYWVqpEbDmTFLgyEJP54XGpTB//001p/bsXdcwrlNlHkqiNNgETKRHKSkaHP5bG/zFWhWBa5eP+XoQ5QMbK0uM76wN+vbu2Y7Y5a8CAcd5bIkeYQBIpEfrjl3GwKj56npKi3cAR3ezfz0uh13Gd0e+w5ZbW8z+WMh0cA9iHyAiIcLsj8XT6FLDB/VLu+FFrIPDub+Aw7/xOBI9wgCQSE9CouLxcMOXKGbxEOH2xeHUcgxfi0dzAC88vxCrr6w2++Px7q530XZZW2y8sdHsj8XTsLCwwAfdqmBRUif1d8rZlZwfmOgRBoBEevLH0j8wKGWtuu7Y6zvA2nxHvKZV0a0iXqz6ItqXag9z5+fsB0sLS4TEhui7KEarfml3+FRtjhMpFWCZHA8c4PzKRIJTweUBp5Khp7X1/H0E//kKBlrvQEil5+Hxwq88mJT5HJMQAVtLW9hb2/Po5MH14Ch8PX06frP5DsnWjrB6+yzg5MFjasYiOBUcawCJCv3EE5eID9ecwYSkkVhX9iN4PDuFLwJlycXWhcFfPijn5YxSTfriTEoZWCXFIGkfawGJ2ARMVMi+3nAB9yPiUcbDCR1eGAfYMUOZTnxyPO5G3VWDH4jy09udKmOh7fPqesrBX4GYUB5gMmsMAIkK0dFTZ1D1+OdwRgy+fbYW7G3Me8aPjI4EHkHnlZ3Rb20/fRfFoKbFe2P7G2pqOHp6znbWaN97CHYn18QPib1wLSyRh5PMGgNAokISERMH/D0Gg623YEmxxWhcjn2QMpLaP+nzVtWjKt+Xj0jgt9N/J/bd3cdjkkeda/hiXrmpmJHYCx+tv8GaZjJrHASSB+xESjmVkpyCnT8MRbuIvxEHWyS9vBvOfgxysmsGjkqIgocDA2Rx8N5BnA85j1bFW6GCWwV+6PLIPzQGHabuQnxSCqYPqIM+dYvzmJqhCA4CYQ0gUWHY/+cXKvhL0VggsN0PDP4ew87KjsFfhjmBh9cYzuAvn5R0d8Sb7Sqgo+VRFP+nP8LDmGKHzBObgIkK2Lltf6DZ1Wnq+qmq41Cm1Qs85kR6NKplWXxovxwNNWdxeMkkvhZklhgAEhWg++f3odyet2BpocFB996oO4Bz/Wbnl5O/4NWtr2JfAPu6ZdUsLgNk9gfs5+c1H9jaWCOx+bvqetPAP7D32CkeVzI7DACJCkhcYjK+2nQZERpHHLVpgDqv/CZzU/F4Z0MNdAjYh4fxD3mMMth6ayuGbxqOH078wGOTTyq2HYw7TjXgbBGHyHUfIDgynseWzAoDQKICkJyiwbvLT+Hv+94YavUN/EYtgb0dp3p7nC+af4HxDcejsW9jviczaFCsAbwcvFDOtRxSNCk8PvnB0hLez/+EFFigq2Yv5v6xmKOCyaxwFHAecBQRZSUl8BzmbtiHLy8Xh42VBRYMa4RmFTx5sChPJDm2BWuQ813Ystfgdn4xLqSUxImuf+OFpuXzfydkcCI4Cpg1gET5SXPnGOJ+64JBtz5AQ6sr+GlgPQZ/lC8Y/BUMtx5fIM7GFVUt/XFgw2JcC44qoD0RGRY2ARPlE83NvUiY2wOOyRG4oCmNIb06oksNHx7fHFh0fpEa4JCQnMDj9QSSI5HykaM7bHtOxVSPz7A2sT7eXnoSiclsZifTxwCQKD+c/AvJC5+BXUoMDiRXw5XOi9GjcXUe2xx4GPcQk49MxitbX0FEQgSPWTbikuLUFHktlrRAeHw4j1M+sqz1HF4Y/CpcHWxx+k44Pl97nseXTB4DQKK8SEpAyrp3gDWvwjolHluS6+FSh7no35yzfORUbFIsepXvhWZ+zeDpwL6S2bG3tlc1pMmaZJx9cJaf23zm42qPqf1rw8ciFMcP7cQfh27xGJNJs9Z3AYiMWfzxP2B3dI66Pj3pGTh0+ACvtK6o72IZFV9nX0xqwWS8OfFVy69QzLEYA+UC0t4lAM2cPkRoojV6/e2NCl7OnLObTBZrAIme0v2IODx3sBxWJzfHy8njUaH/JLzShsEfFZzqHtUZ/BUkz4qwd/FAcYsQfGk1G6MXH8OdsJgC3SWRvjAAJMqN2IfA5o9x4WYA+s7YhzN3o/CF7dt4ZeQY9Kjlx2OZSzGJMQiJ5VysZCDsisDiubnQWNqgq9URdInfiJELjiImIUnfJSPKdwwAiXLq4gZofmkC7P8Rx35/C3fD41DeywlrxjRH/dJuPI5PYU/AHrRZ1gbjdo7j8cuh3Xd24+N9H3PKvILiVxcWHSaqq5/YLELy/QsYu+QkkjgymEwMA0CiJwkPAFYMB5YMhEXkPVxP8cHfSU3Qvoo3Vo1ujlIejjyGT+lG+A116e3ozWOYQ3sD9mLN1TXY4b+Dx6ygNHkNKN8e9kjAz7Y/Yfd5f4xbdkrN8ENkKjgIhCg7ceHA3unAwV+ApDgkwwK/JfXATIt+GN+nDl5oVIrJefPo1dqvYkDlAUhMSeT7MIdal2itLp+v/DyPWUGxtAT6zgJmNkflaH8Mtd6CX091h621JSY/WwuWlpzTm4wfA0Ci7Oz8Rhv8ATicUhlfJL6kmodWPV8H5b2cedzyiZs9m89zo3nx5mqhAubsrQ0CTyxGvYr/g9Wyc1hx7I4KAif1qcEff2T0GAAS6SREA7FhgGsJRMYlYl5cd7RJ2Ygfk/piJ+rjlTblMbZ9JfUFQPkzAMTRhs3nZMAqtAfKt0NnCwtMhTXeWnoSfx66DTtrS3zSoxqDQDJqDACJYkKBw7OBQ78i2a8ellaahqlbLuNBVDym4gu0qeyNTT2qsdYvH10Nu4oXNryAHuV64OMmH/OL9Cn4R/pj+eXl6FO+D8oVLcfPcUGx0Db39q7liyqnv8XUi26Ytw+IiU/Gl31rwMaKPwjJODEAJPOk0QABx4Bj84Czq4BEba6ve9fO4qtzhxAFR5T1dMLHPaqiXZVi+i6tydl6e6uaASQsLozB31OacnQKtt3epqaI+6DxB/n7AlFmp/5E5esL8Iu9DV6Kc8LSo8CdhzH45cX6cHWw4REjo8MAkMzPhbXAjq+BoHOpqy6iLH5O6IF/UxrB08URb7cqj5ealGZzbwF5pdYraOTTCE42TgW1C5P3fJXnVRDdqkQrfRfFPNQeCFz6F1YX12Gh43QMSRiPfVeB52bux9yhDVHSnd0ZyLhYaDRSFUJPIyIiAq6urggPD4eLiwsPoqGKj9TW+NlrXyPN8UWw+Od1JFrYYl1yE/yR2AZHNZVRws0Rr7Yuj+fql4C9jZW+S01EhiYxDlj8LHBrL1Ks7PAxXsMf0Q3g6WyL2YMboF4pDmgyFhH8/mYAyDeQCffru7IFuLQeuLwJaPcRgmqMwvoz97Dm8BXUfrAOa5KbIwLOqOrrguHNy6BP3eLsz1PA5Pem/LO0YL8pMuLBYitHac8tAObbD8anDzurc8dbHSqpH5FWTBNj8CIYADIA5BvIREgN3/2z2qBPAr47hwFNSurNh+xb4PnwMWozYW9jqaZue7FxKdQpWZT90ArJsfvH8OHeD/FStZfwYtUXC2u3Jk2agTdc34AqHlXUXMFUCFKSgS2fAAd+VtPGfV7yN8y7ZKtualjGDVP712GTsIGLYADIPoBkpCSSk5Qtju7av1OSgLldgISo1E1uWpfFhvhaWJ/UCOfiyqh19UoVRa/afuhbtwRcHdlxu7CtvrIaAVEBuBJ2pdD3baqmHp2KJZeWoGuZrpjcerK+i2MeLK2AzpMAtzKwsHPBJ7X6oNqxO/hs7XkcuRmGrj/swae9quPZesX545IMFgeBkPH0vbl3EvA/BPgf1l7auQBvHsfdh7E4dCME5e0bIjIxHP8m1Mb25Lq4C09115rFXTGhli+61/JV/fxIfz5s8iHqF6uPmp41+TLkk74V+6rp4Wp51eIxLWyNRqkLSRTTr0FJtLa9hHVbtuGL4JZ4d/kprD5xBx92q4ZqfuwjToaHg0DygFXIhWDPFODcaiDograWL40kCxv0tZ+DM2Hpa/Kkebd5eU+0qeKNNpW82BRDJi9Fk8J+lfqWEAPMbAqE3cRdl9oYFjoEl5J8VBrB/vVL4p1OleDtYq/vUtIjEWwCZg0g6VFSAhB6HXhwSRvg3T8HBF8EXt4F2DoiKTkF0feuwzXwjNr8oWVRHEmuiMNJFXEspRLOacogPtYG0t9aavmalPNA0/Ie6pKjeA3LiaATqOVZC1bSdEb5Lu2gmqSUJFhbsnGn0FnbA83eVH0D/SJO4V+7D7DNqzfev9dG5Qxce/ouRrUsh8FNS8PD2a7wy0eUAWsA84C/IHIgOQmIuAO4FAesHtXUHZJZN2apX8rQJGe6yw/lZmN7RHFcDIxE5eQr8LUIwdmUsghQTboWcLK1Qs0SrqhT0g2Ny7mjQWk3FLFnfz5DdTLoJIZuHKqafn9q9xOnfytAp4JP4YM9H2BSi0mo412nIHdF2XnoD6x9E7i2Xf2ZbO2AtTZd8UVYR4TAVU0j90y9EhjRogwqeBfhcdSTCNYAsgaQ8smDq8CdI0C4P/DwFvDwtnYJv6OabhNH7cFd+/K4ExYLhxv3US/0mrpbNBxwTeOHS8nFcVFTEpc0pXD8vDViEK5uv2pbCTa+LmjrW0TV8knQV8HbmWkWjMiD2AewtbKFh4MHHKwd9F0ck7bk4hLcjryNn0/8jDmd5+i7OOapaElg0CrgymZg5zewunscfZJWwadjL0w674ozAeH46/BttbSp7IXnG5ZSl2y1oMLGGsA8MPlfENEhgARqUfe1S6Qs94DIQO3SdybgU1M11cbumo4iuz/L8mESYI1RCe9gV0pt9XcJi2CUtAjCtRQ/BKGoqtWztbJEOS8nFdxV9C6CisW0+flKuzvCkjm1jN7N8JvwdPCEs62zvoti0qISojDj5AyMqTMGRWxZu2QQ2QqubtUGg10nQ7JQySjhe/98inPBiViT1BxBcEMRO2t0ruGjMhQ0K+8Ba84vXOAiTP37OwcYAJrDG0hOQo8mNEfYLeDeKSA2FIgJ0SZMVssDIPoB0PMHwLcW4hKTEb/nR7ju/jTbh/3M+WOsi6+DkKh4tLI4iZFW63FX4wl/jRfupFnuww0psFSDM2QUbkk3B5TxdEIZD6dHl44oXtSBJz0TS/h8P+Y+fJx89F0UmPvgEAv5p/v8k2Ekkv6uIpAYrc6L5ywqYHtidexNrokTmgpwdnRQg9iaV/BEy4qeHMRm7t/fBYgBoCG/gVJS1EkC8VHa/HYypZluKd3svxx413dqR8rK+rhwtWhiH0ITFw6LuHAE9FqKILc6iIhNhOe5eahx+qtsd/me9QQV1MUmJqOb5UFMsP4LwXBFsKYoHmhcEahxQyDcEaRxw+mUsgiD9nlL5nuZDsnHxR6+rg7wcbWHX1Ht9RJusjiq2/lFZPoiEyIxcf9EHA08ilW9V6maP9KPuWfn4uyDs/ikyScoai+17WQQo4VPLwVO/aVNZ5VGNOwxK7EHfkp+5tEaDUq5OaJBGXfV77lWCVdU83WFgy0HU+VVBANA9gE0RIHhcUjaNQUljmef1PW3CjNxwbYaouOT0OLBRrwUPj/d7fJ7X/eb/5Ole7E9JUZdb28ZjzHWFRGqKYKHGmeEQi6LIARFEKpxwcm4UoiFdmDGZjTFUbvWcHeyVYuMXJMgroyzHRo622FIEVt4F7GHt4sdPJzs2C+PFDsrO9yOuK0CQZn5o3OZzjwyevAw7iFmnZqlZgppU7INepXvxdfBENg6Ag2GaRfpI31tB3Bdlp1wignB841KwtqpEvZdfYDA25fxd8wruHbOD7fOFsPOlGJYZOGDFNdScPYsCQ+fkihbzA3lvJxRyt0Rbo42/JFN5lkDOGPGDHz33XcIDAxE7dq18dNPP6FRo0bZbr98+XJ8/PHHuHnzJipWrIhvv/0W3bp10/sviO83XULM7p/wic0i9XeSxlL9MoyCA6I0Dur6Z4mDcUpTQd1ey+Ia2lqeRCQcEQkHhGucEK5xRgQcEQVHxNp5wd7BQY2UdbG3VpeuDmkXa7g52arrbo62KOpog6KOtmpb1thRTtyJvKP6nLnauaq/rz28hrjkOE5NpmfnQs5h/fX1eK/Be6mf5fD48NTXiQyItPgEngYciqoZRkTs6TVwWDUk27tMTuyPX5L7qOtlLe7hPZvlSLQtihQHN1g6usHWwQW2Tq6wd3KFVbGqcPAuBxcHG7jYpKCIRRxs7Z0AazvtzCZmJoI1gKYTAC5duhSDBw/GrFmz0LhxY0yfPl0FeJcuXYK3t3em7ffv349WrVrh66+/Ro8ePfDnn3+qAPD48eOoUaOGXt9AC/bfxF97z8PVOhkWdk6wtnWEg501HG2t4GhrrdKgOD7628nOGs52VnCS9XbapYgEebLe3hoONlYM4qhAfbr/U6y8shLvN3qf8/sauITkBLRZ1galipRSKXm8HL30XSR6nKR4bW7UkGvanKlhN5AQdA0pYTdhExuM1SXGY0VyK1wPjkbl6CNYZPtNtg/1ZeKLmJPcXV2va3EFq+0m/rcbWCEBtiq5fpKlDTa6DsQBr+dgb22J4skBeM7/SzXnscbSWl1CLVaAlTXu+nZEYKnusLa0hGNCCCpd/AUWcpullbq0sLSGhZX8bY1YnwaIKdlGtRZZJ0XD7cKfsLCy1m5jaamuW8p9Layg8awITfEGahCgpM6xs87fIDWCAaDpNAFPnToVo0aNwrBhw9TfEgiuX78ec+fOxfvvv59p+x9++AFdunTBe++9p/7+4osvsGXLFvz888/qvvo0pFkZtRDpS2JyIsITtKl4dH345Lfid0e/w/Xw6/iy+Zep68sXLa8SEUstIBm28yHnEZ0YjaCYIJWWR2fe2Xlq0I40E1fzqJYaLEYkRKiaXWnWJz2Q2jnf2trlEVvdlZQUPKdJwXNW2q/x+AflEXraCTEPgxEXGYKU6FBoEqJgkRAF68QoWDqVQPFkB4THJsI+MSH9bpAMa8QCmlhID6Bb90Ow9u7d1Bamt+zOZVvENXecMe1ACXW9vEUAttn9me22vyV1w6QkbXn98AD77T/PdtvFSe3xUdIIdX1s+4p4u2OlJx8vMr8AMCEhAceOHcOECRNS11laWqJDhw44cOBAlveR9ePGjUu3rnPnzlizZk22+4mPj1eLjtT86X5J5Le119Zi8YXFaFW8FV6r+1rq+hGbRiAqMQrT2kyDn7OfWrfpxibMPTcXjX0aY1yD/57T6C2jERofiq9bfI1yRcupdTv9d2LmqZkqSeyERv8dr7e2v4V7MffwabNPUdW9qlp38O5BTDs+Tf0t63X+t/t/uBlxEx80/gC1vbQnJunrNfnIZJRzLYevW36duu0n+z7BpbBLGFd/HBr7Nk79EvrswGco4VwCU9pMSd120sFJOP3gNF6r/RpalWyl1l0Lu4YP9n0AL3sv/Nzh59Rtvz/6PY4EHsHImiPRsXRHtc4/wh/v7n5XfWHN6fRfDrSfj/+MPXf3YHDVweheXvsLOCg6CG/seEN9sS3sujB1219P/Yrt/tvRv1J/PFvp2dQms5e3vKyuL+m+JLVGdcG5BdhwYwP6lO+DgVUHqnVxSXEYslHbZDOv87zUpMdLLy7Fqqur0LVMVwytMTR1fwPWDdDut8OvqZ30V11ZhaWXlqJtibZ4tc6rqdsO+XeI6s8lNTfFnIqpdRuub8D8c/PR3K85xtYfm7rtqM2jVLm/b/09SrmUUuu23Nyi+oQ19G2oaut0hm8cjoCoAPW4ldwrpb7/Jh2apB437Wu05eIWBEQH4Gzps6hXrJ5a19a7LVp0bqECioL4LFD+KWdfDms6r4F/pD+iIqNS1687vw4XQi+gmmM1lLDRfpnLIJ7Xt7+Osi5l8VePv1K3Hb9rvGpals9/8+LN1boLIRfw0b6PUNypOH5s/2Pqtl8d+kolAn+tzmtoXbJ16md6wt4J6gfELx1+SfeZPnzvMEbVHIWOZbSfaflR8c6ud1DEpki6vIaS53BPwB68VPUl9CjfQ60LjglW5bWxtMHibotTt/3t9G/Yentrus+0BLbyGRF/df8rdSYV3We6d/neeKHqC6mB8Ev/vpTtZ7pLmS4YVkNb8SCeX/c8NNBgZoeZcLfXDtRbc3UN/rr4V5afaekq8WPbH1M/0/9e/xfzz89HU9+meKv+W6nbvrLlFTyMf4jJLSejtGtptW7brW2YfWa2SrI+vuF4WDcYqoblfbjtDQTZhOOLZl+kfqbrBuzDnhPfoZlHDUxo9CFuxw5BVFQUJp34FIExd/GC7yAUtyiGxIR4JCc+QPHQ6XCzKYVWToOwMOwzpKQk4J/kHQiyCEX7hDooleQOTXISLto5ws32e9ikeMMvth9+j+0Hi5RkbCxyFXdtotE+wgflY51hqUnCIVtPOBWbDCS7wjroOSyPbwxLjQZr3cNwwz4encIcUTPGFpaaZOy3coad77dIDG2OxFi/fD+3RDx6PBNpBH06GhMQEBAgr6Bm//796da/9957mkaNGmV5HxsbG82ff/6Zbt2MGTM03t7e2e5n4sSJaj9ceAz4HuB7gO8Bvgf4HjD+94C/v7/GXJlEDWBhkRrGtLWGKSkpCA0NhYeHR773s5NfJyVLloS/v79J5iji8zN+fA2Nm6m/fubwHPn8np5Go0FkZCT8/LQtaebIJAJAT09PWFlZ4f79++nWy98+PlknopX1udle2NnZqSWtokULNreWnLRM8cSlw+dn/PgaGjdTf/3M4Tny+T0dV1fzHg2v7fhg5GxtbVG/fn1s27YtXe2c/N20adMs7yPr024vZBBIdtsTERERmQqTqAEU0jQ7ZMgQNGjQQOX+kzQw0dHRqaOCJUVM8eLFVdoXMXbsWLRu3RpTpkxB9+7dsWTJEhw9ehSzZ8/W8zMhIiIiKlgmEwAOGDAAwcHB+OSTT1Qi6Dp16mDjxo0oVkw7sur27dtqZLBOs2bNVO6/jz76CB988IFKBC0jgHOaA7CgSVPzxIkTMzU5mwo+P+PH19C4mfrrZw7Pkc+P8sJkEkETERERkRn1ASQiIiKinGMASERERGRmGAASERERmRkGgERERERmhgGgAbh58yZGjBiBsmXLwsHBAeXLl1cj12SO48eJi4vDa6+9pmYicXZ2xrPPPpspubUhmTRpkhp97ejomOME2kOHDlWzrKRdunTpAlN5fjIGS0au+/r6qtde5q++cuUKDJHMevPiiy+qpLPy/OQ9K3OJPk6bNm0yvX6vvvrfXKj6NmPGDJQpUwb29vZo3LgxDh8+/Njtly9fjipVqqjta9asiQ0bNsCQ5eb5zZ8/P9NrJfczVLt370bPnj3VTA5S1sfN466zc+dO1KtXT42erVChgnrOhiy3z1GeX8bXUBbJjGGIJC1bw4YNUaRIEXh7e6NPnz64dOnSE+9nbJ9DQ8UA0ABcvHhRJa7+9ddfce7cOUybNg2zZs1S6Wke5+2338batWvVh2HXrl24e/cunnnmGRgqCWj79euH0aNH5+p+EvDdu3cvdfnrr/8mpjf25zd58mT8+OOP6vU+dOgQnJyc0LlzZxXcGxoJ/uT9KQnT161bp76cXn755Sfeb9SoUeleP3nOhmDp0qUqf6j82Dp+/Dhq166tjn1QUFCW2+/fvx8DBw5Uge+JEyfUl5UsZ8+ehSHK7fMTEtynfa1u3boFQyV5XuU5SZCbEzdu3FA5X9u2bYuTJ0/irbfewsiRI7Fp0yaYynPUkSAq7esowZUhku8tqcQ4ePCgOq8kJiaiU6dO6nlnx9g+hwZN35MRU9YmT56sKVu2bLaH5+HDhxobGxvN8uXLU9dduHBBTW594MABgz6s8+bN07i6uuZo2yFDhmh69+6tMSY5fX4pKSkaHx8fzXfffZfudbWzs9P89ddfGkNy/vx59d46cuRI6rp///1XY2FhoQkICMj2fq1bt9aMHTtWY4gaNWqkee2111L/Tk5O1vj5+Wm+/vrrLLfv37+/pnv37unWNW7cWPPKK69oTOH55eZzaWjkvbl69erHbjN+/HhN9erV060bMGCApnPnzhpTeY47duxQ24WFhWmMUVBQkCr/rl27st3G2D6Hhow1gAYqPDwc7u7u2d5+7Ngx9WtJmgx1pEq8VKlSOHDgAEyJNGvIL9jK/2/vTmCjqMI4gH/QchUkgFRQjkI5GrkLkUgwgFYK1EiBGNNyhJsiYoIRbBEIIiFAxCOUOwgIKIVAARPkCEcJlHAbyhFqW8FyGxAQbIsRnvl/yW52lu62Bbed3f3/koHO7OzsvJ2d3W/e+96bqCitXbtz544EAtRIoGnG9Rji3pRoqrPbMcT+oNkXd9pxwH5jcHXUXHrzww8/6P26Mcj61KlTpaCgQOxQW4tzyPW9R1kw7+m9x3LX9QE1anY7Vs9aPkCTfkREhDRp0kTi4+O1xjdQ+NPxe164EQLSSnr37i2ZmZniT7974O23L5iOo68FzJ1AAklubq6kpqbKggULPK6DwAH3QHbPNcOdT+ya7/Es0PyLZm3kR+bl5WmzeL9+/fRkDwkJEX/mOE6Ou9XY+Rhif9ybkUJDQ/WL2tu+Dh48WAMK5DBlZWVJcnKyNk+lp6dLRbp9+7Y8fvy42PceKRnFQTn94Vg9a/lwgbVq1Srp0KGD/hDj+wc5rQgCGzduLP7O0/H766+/pLCwUHNw/R2CPqST4ELt0aNHsnLlSs3DxUUach/tDGlQaJbv3r271zty+dN5aHesAfShlJSUYhNyXSf3L+Nr165p0INcMuROBWIZyyIhIUH69++vib7I80Du2YkTJ7RWMBDKV9F8XT7kCOLqHMcPOYRr166VrVu3ajBP9tKtWze9Zzpqj3CfdATp4eHhmptM/gFBfFJSknTp0kWDdwT0+B955XaHXEDk8aWlpVX0rgQN1gD60CeffKK9WL2JjIx0/o1OHEhQxgm7YsUKr89r2LChNvPcu3fPUguIXsB4zK5lfF7YFpoTUUsaExMj/lw+x3HCMcOVuwPm8SNcHkpbPuyre+eBf//9V3sGl+XzhuZtwPFDb/eKgs8QapDde817O3+wvCzrV6RnKZ+7KlWqSHR0tB6rQODp+KHjSyDU/nnStWtXOXz4sNjZxIkTnR3LSqpt9qfz0O4YAPoQrp4xlQZq/hD84cpt9erVmq/jDdbDF/S+fft0+BdA01p+fr5eyduxjP+Hq1evag6ga8Dkr+VDsza+tHAMHQEfmqPQXFPWntK+Lh8+U7jYQF4ZPnuwf/9+bbZxBHWlgd6XUF7HzxOkT6AceO9RswwoC+bxY+TpPcDjaKZyQM/F8jzffFk+d2hCPnv2rMTFxUkgwHFyHy7Ersfv/4RzrqLPN0/Qt+Wjjz7SVgG06uA7sST+dB7aXkX3QiFjrl69alq2bGliYmL07xs3bjgnByyPiooyx44dcy4bP368adq0qdm/f785efKk6datm0529fvvv5tffvnFzJo1y9SqVUv/xvTgwQPnOihjenq6/o3lkydP1l7Nly5dMnv37jWdO3c2rVq1MkVFRcbfywfz5s0zderUMdu3bzdZWVna4xm9vwsLC43d9O3b10RHR+tn8PDhw3ocEhMTPX5Gc3NzzRdffKGfTRw/lDEyMtL06NHD2EFaWpr2uF6zZo32ch43bpwei5s3b+rjw4YNMykpKc71MzMzTWhoqFmwYIH2uJ85c6b2xD979qyxo7KWD5/b3bt3m7y8PHPq1CmTkJBgqlevbs6fP2/sCOeV4xzDT9nXX3+tf+M8BJQNZXT47bffTFhYmJkyZYoev8WLF5uQkBCza9cuY1dlLeM333xjtm3bZnJycvRziR74lStX1u9OO/rggw+053lGRobld6+goMC5jr+fh3bGANAGMPwCTu7iJgf8gGIe3fwdECRMmDDB1K1bV7/YBg4caAka7QZDuhRXRtcyYR7vB+BLIDY21oSHh+sJHhERYcaOHev8AfP38jmGgpkxY4Zp0KCB/ljjIiA7O9vY0Z07dzTgQ3Bbu3ZtM3LkSEtw6/4Zzc/P12CvXr16WjZc5ODH9/79+8YuUlNT9SKqatWqOmzK0aNHLUPY4Ji62rRpk2ndurWujyFFduzYYeysLOWbNGmSc118HuPi4szp06eNXTmGPHGfHGXC/yij+3M6deqkZcTFiOu5aEdlLeP8+fNNixYtNHDHederVy+tILArT797rsclEM5Du6qEfyq6FpKIiIiIyg97ARMREREFGQaAREREREGGASARERFRkGEASERERBRkGAASERERBRkGgERERERBhgEgERERUZBhAEhE9AxwS8KXXnpJLl++bIv3LyEhQb766quK3g0i8hMMAInIp0aMGCGVKlV6aurbt69fv/Nz5syR+Ph4adasmc9eA/dexnt19OjRYh+PiYmRQYMG6d/Tp0/Xfbp//77P9oeIAgcDQCLyOQR7N27csEwbNmzw6Wv+888/Ptt2QUGBfPfddzJ69GjxpS5dukjHjh1l1apVTz2GmscDBw4496Fdu3bSokULWb9+vU/3iYgCAwNAIvK5atWqScOGDS1T3bp1nY+jlmvlypUycOBACQsLk1atWslPP/1k2ca5c+ekX79+UqtWLWnQoIEMGzZMbt++7Xy8V69eMnHiRJk0aZLUr19f+vTpo8uxHWyvevXq8uabb8r333+vr3fv3j35+++/pXbt2rJ582bLa23btk1q1qwpDx48KLY8P//8s5bp9ddfdy7LyMjQ7e7evVuio6OlRo0a8tZbb8kff/whO3fulFdffVVfa/DgwRpAOjx58kTmzp0rzZs31+cg4HPdHwR4GzdutDwH1qxZIy+//LKlJvXdd9+VtLS0Mh0bIgpODACJyBZmzZol77//vmRlZUlcXJwMGTJE/vzzT30MwRqCKQRWJ0+elF27dsmtW7d0fVcI7qpWrSqZmZmybNkyuXTpkrz33nsyYMAAOXPmjCQlJcm0adOc6yPIQ+7c6tWrLdvBPJ73wgsvFLuvhw4d0tq54nz++eeyaNEiOXLkiFy5ckX38dtvv5Uff/xRduzYIXv27JHU1FTn+gj+1q5dq/t7/vx5+fjjj2Xo0KFy8OBBfRzvw6NHjyxBIW7hjrKieT0kJMS5vGvXrnL8+HFdn4jIK0NE5EPDhw83ISEhpmbNmpZpzpw5znXwVTR9+nTn/MOHD3XZzp07dX727NkmNjbWst0rV67oOtnZ2Trfs2dPEx0dbVknOTnZtGvXzrJs2rRp+ry7d+/q/LFjx3T/rl+/rvO3bt0yoaGhJiMjw2OZ4uPjzahRoyzLDhw4oNvdu3evc9ncuXN1WV5ennNZUlKS6dOnj/5dVFRkwsLCzJEjRyzbGj16tElMTHTOJyQkaPkc9u3bp9vNycmxPO/MmTO6/PLlyx73nYgIQr2Hh0REzw9Nr0uXLrUsq1evnmW+Q4cOlpo5NJei+RRQe4d8NzT/usvLy5PWrVvr3+61ctnZ2fLaa69ZlqGWzH2+bdu2WqOWkpKiOXQRERHSo0cPj+UpLCzUJuXiuJYDTdVo0o6MjLQsQy0d5ObmatNu7969n8pfRG2nw6hRo7RJG2VFnh9yAnv27CktW7a0PA9NyODeXExE5I4BIBH5HAI692DFXZUqVSzzyKdDfhw8fPhQ89vmz5//1POQB+f6Os9izJgxsnjxYg0A0fw7cuRIfX1PkGN49+7dEsuBbZRULkDTcKNGjSzrIcfQtbdv06ZNNe9vypQpkp6eLsuXL3/qtR1N5uHh4aUsOREFKwaARGR7nTt3li1btuiQK6Ghpf/aioqK0g4brk6cOPHUesi5+/TTT2XhwoVy4cIFGT58uNftonbu/+ht26ZNGw308vPztUbPk8qVK2tQip7HCBSR54gcRXfoKNO4cWMNUImIvGEnECLyOXRKuHnzpmVy7cFbkg8//FBrtxITEzWAQ1MoetsiKHr8+LHH56HTx8WLFyU5OVl+/fVX2bRpk9aigWsNH3okYzw91K7FxsZqEOUNmmPRYcNTLWBpoZPJ5MmTteMHmqBRrtOnT2snEcy7QlmvXbsmn332mb4PjuZe984p2H8iopIwACQin0OvXTTVuk5vvPFGqZ//yiuvaM9eBHsIcNq3b6/DvdSpU0drxzzB0CroPYsmU+TmIQ/R0QvYtYnVMdwKcu+Qb1cSvD5qJRFQPq/Zs2fLjBkztDcwhorBsC5oEsa+u0IT8Ntvv61BZ3H7WFRUpMPXjB079rn3iYgCXyX0BKnonSAiKi+4WwaGXMEQLa7WrVunNXHXr1/XJtaSIEhDjSGaXb0FoeUFwe3WrVt1mBkiopIwB5CIAtqSJUu0J/CLL76otYhffvmlDhjtgB6zuDPJvHnztMm4NMEfvPPOO5KTk6PNsk2aNJGKhs4mruMLEhF5wxpAIgpoqNXDnTSQQ4hmVNxBZOrUqc7OJBi4GbWCGPZl+/btxQ41Q0QUaBgAEhEREQWZik9cISIiIqJyxQCQiIiIKMgwACQiIiIKMgwAiYiIiIIMA0AiIiKiIMMAkIiIiCjIMAAkIiIiCjIMAImIiIiCDANAIiIiIgku/wElHoO9N2L94gAAAABJRU5ErkJggg==", + "text/html": [ + "\n", + "
\n", + "
\n", + " Figure\n", + "
\n", + " \n", + "
\n", + " " + ], + "text/plain": [ + "Canvas(toolbar=Toolbar(toolitems=[('Home', 'Reset original view', 'home', 'home'), ('Back', 'Back to previous …" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Use some of the extra settings for the numerical convolution\n", + "sample_components = ComponentCollection()\n", + "gaussian = Gaussian(display_name='Gaussian', width=0.3, area=1)\n", + "dho = DampedHarmonicOscillator(display_name='DHO', center=1.0, width=0.3, area=2.0)\n", + "lorentzian = Lorentzian(display_name='Lorentzian', center=-1.0, width=0.2, area=1.0)\n", + "delta = DeltaFunction(display_name='Delta', center=0.4, area=0.5)\n", + "sample_components.append_component(gaussian)\n", + "# sample_components.append_component(dho)\n", + "sample_components.append_component(lorentzian)\n", + "# sample_components.append_component(delta)\n", + "\n", + "resolution_components = ComponentCollection()\n", + "resolution_gaussian = Gaussian(display_name='Resolution Gaussian', width=0.15, area=0.8)\n", + "resolution_lorentzian = Lorentzian(display_name='Resolution Lorentzian', width=0.25, area=0.2)\n", + "resolution_components.append_component(resolution_gaussian)\n", + "# resolution_components.append_component(resolution_lorentzian)\n", + "\n", + "energy = np.linspace(-2, 2, 100)\n", + "\n", + "\n", + "temperature = 10.0 # Temperature in Kelvin\n", + "offset = 0.5\n", + "upsample_factor = 5\n", + "extension_factor = 0.5\n", + "plt.figure()\n", + "plt.xlabel('Energy (meV)')\n", + "plt.ylabel('Intensity (arb. units)')\n", + "\n", + "convolver = Convolution(\n", + " sample_components=sample_components,\n", + " resolution_components=resolution_components,\n", + " energy=energy - offset,\n", + " upsample_factor=upsample_factor,\n", + " extension_factor=extension_factor,\n", + ")\n", + "y = convolver.convolution()\n", + "\n", + "\n", + "plt.plot(energy, y, label='Convoluted Model')\n", + "\n", + "plt.plot(\n", + " energy,\n", + " sample_components.evaluate(energy - offset),\n", + " label='Sample Model',\n", + " linestyle='--',\n", + ")\n", + "\n", + "plt.plot(energy, resolution_components.evaluate(energy), label='Resolution Model', linestyle=':')\n", + "plt.title('Convolution of Sample Model with Resolution Model')\n", + "\n", + "plt.legend()\n", + "plt.ylim(0, 2.5)\n", + "plt.show()" + ] } ], "metadata": { From 9af6322c81e110b2c2e1bde7ba191c830ebf8660 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Fri, 6 Feb 2026 12:24:18 +0100 Subject: [PATCH 04/27] reintroduce energy_offset in convolution. It's needed. --- docs/docs/tutorials/convolution.ipynb | 108 +++--------------- .../convolution/analytical_convolution.py | 51 +++++---- src/easydynamics/convolution/convolution.py | 64 ++++++----- .../convolution/convolution_base.py | 95 ++++++++++++--- .../convolution/numerical_convolution.py | 31 ++--- .../convolution/numerical_convolution_base.py | 2 + 6 files changed, 179 insertions(+), 172 deletions(-) diff --git a/docs/docs/tutorials/convolution.ipynb b/docs/docs/tutorials/convolution.ipynb index 6d864c64..b13d7973 100644 --- a/docs/docs/tutorials/convolution.ipynb +++ b/docs/docs/tutorials/convolution.ipynb @@ -16,7 +16,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "1", "metadata": {}, "outputs": [], @@ -37,36 +37,10 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "2", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "a41109d24dec4f28bc04854ecb5c0a21", - "version_major": 2, - "version_minor": 0 - }, - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAoAAAAHgCAYAAAA10dzkAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQAArRFJREFUeJzs3Qd4U9X7B/BvVvdeQNlQ9t5btiCCoAiKCiiCC0XA7e+vuBAXbkQUBXGhgKACMmXI3nvvsqF00N0m+T/vaRPSnUIhSfP9+ETSm5vk5t7k5s17znmPxmw2m0FEREREbkPr6A0gIiIioluLASARERGRm2EASERERORmGAASERERuRkGgERERERuhgEgERERkZthAEhERETkZhgAEhEREbkZBoBEREREboYBIBEREZGbYQBIRERE5GYYABIRERG5GQaARERERG6GASARERGRm2EASERERORmGAASERERuRkGgERERERuhgEgERERkZthAEhERETkZhgAEhEREbkZBoBEREREboYBIBEREZGbYQBIRERE5GYYABIRERG5GQaARERERG6GASARERGRm2EASERERORmGAASERERuRkGgERERERuhgEgERERkZthAEhERETkZhgAEhEREbkZBoBEREREboYBIBEREZGbYQBITmnlypXQaDTq35L08MMPo0qVKnBmiYmJGD58OMqWLav2wejRo1EavfHGG+r1lQbyOuT1FNeJEyfUfadPn35Ttqs473dZ18/PD6VVp06d1KUk3ezjV9rOzbKf5L6y38jxGACWMkePHsXjjz+OatWqwcvLCwEBAWjXrh0+++wzpKSkwB2cPXtWfRnv2LEDrujdd99VJ8onn3wSP/74IwYPHlzguunp6erYNmnSRB3roKAg1KtXD4899hgOHDgAd2L5cpHLmjVr8txuNptRsWJFdXvv3r3hjpKTk9Vno6R/WAkJriz7Xy7e3t5o2LAhPv30U5hMJriyX375Rb0OZyIBu+xn+dznd24/fPiw9Vh89NFHDtlGcm56R28AlZwFCxZgwIAB8PT0xJAhQ1C/fn0VIMiX4QsvvIC9e/fim2++cYsA8M0331SZj8aNG+e47dtvv3X6L6N///0XrVu3xrhx44pct3///vjnn38waNAgjBgxAhkZGSrwmz9/Ptq2bYvatWvD3cgPH/nCbt++fY7lq1at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", - "text/html": [ - "\n", - "
\n", - "
\n", - " Figure\n", - "
\n", - " \n", - "
\n", - " " - ], - "text/plain": [ - "Canvas(toolbar=Toolbar(toolitems=[('Home', 'Reset original view', 'home', 'home'), ('Back', 'Back to previous …" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Standard example of convolution of a sample model with a\n", "# resolution model\n", @@ -109,36 +83,10 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "3", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "aeb582a159c74ff6b51ca384adb1a903", - "version_major": 2, - "version_minor": 0 - }, - "image/png": "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", - "text/html": [ - "\n", - "
\n", - "
\n", - " Figure\n", - "
\n", - " \n", - "
\n", - " " - ], - "text/plain": [ - "Canvas(toolbar=Toolbar(toolitems=[('Home', 'Reset original view', 'home', 'home'), ('Back', 'Back to previous …" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Use some of the extra settings for the numerical convolution\n", "sample_components = ComponentCollection()\n", @@ -161,7 +109,7 @@ "\n", "\n", "temperature = 10.0 # Temperature in Kelvin\n", - "offset = 0.5\n", + "energy_offset = 0.5\n", "upsample_factor = 5\n", "extension_factor = 0.5\n", "plt.figure()\n", @@ -171,7 +119,7 @@ "convolver = Convolution(\n", " sample_components=sample_components,\n", " resolution_components=resolution_components,\n", - " energy=energy - offset,\n", + " energy=energy - energy_offset,\n", " upsample_factor=upsample_factor,\n", " extension_factor=extension_factor,\n", " temperature=temperature,\n", @@ -184,8 +132,8 @@ "\n", "plt.plot(\n", " energy,\n", - " sample_components.evaluate(energy - offset)\n", - " * detailed_balance_factor(energy - offset, temperature),\n", + " sample_components.evaluate(energy - energy_offset)\n", + " * detailed_balance_factor(energy - energy_offset, temperature),\n", " label='Sample Model with DB',\n", " linestyle='--',\n", ")\n", @@ -200,36 +148,10 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "id": "c318f9b8", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "e7d510c769bb4fca9c0f54ca3f0431bd", - "version_major": 2, - "version_minor": 0 - }, - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAoAAAAHgCAYAAAA10dzkAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQAAsJdJREFUeJzs3QV4U1cbB/B/XWmp0hZ3d3d33waMjeFsgwkb2/jGjBkTNmTCYIzh23DYkOHu7u6lUFraUvfme94T0lWhpRL7/3guSW9uck9ukps3R95jodFoNCAiIiIis2Gp7wIQERERUeFiAEhERERkZhgAEhEREZkZBoBEREREZoYBIBEREZGZYQBIREREZGYYABIRERGZGQaARERERGaGASARERGRmWEASERERGRmGAASERERmRkGgERERERmhgEgERERkZlhAEhERERkZhgAEhEREZkZBoBEREREZoYBIBEREZGZYQBIREREZGYYABIRERGZGQaARERERGaGASARERGRmWEASERERGRmGAASERERmRkGgERERERmhgEgERERkZlhAEhERERkZhgAEhEREZkZBoBEREREZoYBIBEREZGZYQBIREREZGYYABIRERGZGQaARERERGaGASARERGRmWEASERERGRmGACSQdq5cycsLCzUZX4aOnQoypQpA0MWFRWFkSNHwsfHRx2Dt956C6bo008/Vc/PFMjzkOeTWzdv3lT3nT9/foGUKzfvd9nW2dkZpqpNmzZqyU8F/fqZ2rlZjpPcV44b6R8DQBNz7do1vPLKKyhXrhzs7e3h4uKC5s2b44cffkBsbCzMwd27d9WX8cmTJ2GMvvrqK3WiHD16NBYtWoSXXnop220TEhLUa1u3bl31WhctWhTVq1fHyy+/jIsXL8Kc6L5cZNm7d2+m2zUaDUqWLKlu79GjB8xRTEyM+mzk9w8rIcGV7vjL4uDggFq1amH69OlISUmBMfvzzz/V8zAkErDLcZbPfVbn9itXrqS+Ft9//71eykiGzVrfBaD8s379evTr1w92dnYYPHgwatSooQIE+TJ87733cO7cOcyePdssAsDPPvtM1XzUqVMn3W2//fabwX8Zbd++HU2aNMHEiROfuO2zzz6Lf//9FwMHDsSoUaOQmJioAr9169ahWbNmqFKlCsyN/PCRL+wWLVqkW79r1y7cuXNHfT7M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m7y8PHPq1CmTkJBgqlevbs6fP2/sCOeV4xzDT9nXX3+tf+M8BJQNZXT47bffTFhYmJkyZYoev8WLF5uQkBCza9cuY1dlLeM333xjtm3bZnJycvRziR74lStX1u9OO/rggw+053lGRobld6+goMC5jr+fh3bGANAGMPwCTu7iJgf8gGIe3fwdECRMmDDB1K1bV7/YBg4caAka7QZDuhRXRtcyYR7vB+BLIDY21oSHh+sJHhERYcaOHev8AfP38jmGgpkxY4Zp0KCB/ljjIiA7O9vY0Z07dzTgQ3Bbu3ZtM3LkSEtw6/4Zzc/P12CvXr16WjZc5ODH9/79+8YuUlNT9SKqatWqOmzK0aNHLUPY4Ji62rRpk2ndurWujyFFduzYYeysLOWbNGmSc118HuPi4szp06eNXTmGPHGfHGXC/yij+3M6deqkZcTFiOu5aEdlLeP8+fNNixYtNHDHederVy+tILArT797rsclEM5Du6qEfyq6FpKIiIiIyg97ARMREREFGQaAREREREGGASARERFRkGEASERERBRkGAASERERBRkGgERERERBhgEgERERUZBhAEhE9AxwS8KXXnpJLl++bIv3LyEhQb766quK3g0i8hMMAInIp0aMGCGVKlV6aurbt69fv/Nz5syR+Ph4adasmc9eA/dexnt19OjRYh+PiYmRQYMG6d/Tp0/Xfbp//77P9oeIAgcDQCLyOQR7N27csEwbNmzw6Wv+888/Ptt2QUGBfPfddzJ69GjxpS5dukjHjh1l1apVTz2GmscDBw4496Fdu3bSokULWb9+vU/3iYgCAwNAIvK5atWqScOGDS1T3bp1nY+jlmvlypUycOBACQsLk1atWslPP/1k2ca5c+ekX79+UqtWLWnQoIEMGzZMbt++7Xy8V69eMnHiRJk0aZLUr19f+vTpo8uxHWyvevXq8uabb8r333+vr3fv3j35+++/pXbt2rJ582bLa23btk1q1qwpDx48KLY8P//8s5bp9ddfdy7LyMjQ7e7evVuio6OlRo0a8tZbb8kff/whO3fulFdffVVfa/DgwRpAOjx58kTmzp0rzZs31+cg4HPdHwR4GzdutDwH1qxZIy+//LKlJvXdd9+VtLS0Mh0bIgpODACJyBZmzZol77//vmRlZUlcXJwMGTJE/vzzT30MwRqCKQRWJ0+elF27dsmtW7d0fVcI7qpWrSqZmZmybNkyuXTpkrz33nsyYMAAOXPmjCQlJcm0adOc6yPIQ+7c6tWrLdvBPJ73wgsvFLuvhw4d0tq54nz++eeyaNEiOXLkiFy5ckX38dtvv5Uff/xRduzYIXv27JHU1FTn+gj+1q5dq/t7/vx5+fjjj2Xo0KFy8OBBfRzvw6NHjyxBIW7hjrKieT0kJMS5vGvXrnL8+HFdn4jIK0NE5EPDhw83ISEhpmbNmpZpzpw5znXwVTR9+nTn/MOHD3XZzp07dX727NkmNjbWst0rV67oOtnZ2Trfs2dPEx0dbVknOTnZtGvXzrJs2rRp+ry7d+/q/LFjx3T/rl+/rvO3bt0yoaGhJiMjw2OZ4uPjzahRoyzLDhw4oNvdu3evc9ncuXN1WV5ennNZUlKS6dOnj/5dVFRkwsLCzJEjRyzbGj16tElMTHTOJyQkaPkc9u3bp9vNycmxPO/MmTO6/PLlyx73nYgIQr2Hh0REzw9Nr0uXLrUsq1evnmW+Q4cOlpo5NJei+RRQe4d8NzT/usvLy5PWrVvr3+61ctnZ2fLaa69ZlqGWzH2+bdu2WqOWkpKiOXQRERHSo0cPj+UpLCzUJuXiuJYDTdVo0o6MjLQsQy0d5ObmatNu7969n8pfRG2nw6hRo7RJG2VFnh9yAnv27CktW7a0PA9NyODeXExE5I4BIBH5HAI692DFXZUqVSzzyKdDfhw8fPhQ89vmz5//1POQB+f6Os9izJgxsnjxYg0A0fw7cuRIfX1PkGN49+7dEsuBbZRULkDTcKNGjSzrIcfQtbdv06ZNNe9vypQpkp6eLsuXL3/qtR1N5uHh4aUsOREFKwaARGR7nTt3li1btuiQK6Ghpf/aioqK0g4brk6cOPHUesi5+/TTT2XhwoVy4cIFGT58uNftonbu/+ht26ZNGw308vPztUbPk8qVK2tQip7HCBSR54gcRXfoKNO4cWMNUImIvGEnECLyOXRKuHnzpmVy7cFbkg8//FBrtxITEzWAQ1MoetsiKHr8+LHH56HTx8WLFyU5OVl+/fVX2bRpk9aigWsNH3okYzw91K7FxsZqEOUNmmPRYcNTLWBpoZPJ5MmTteMHmqBRrtOnT2snEcy7QlmvXbsmn332mb4PjuZe984p2H8iopIwACQin0OvXTTVuk5vvPFGqZ//yiuvaM9eBHsIcNq3b6/DvdSpU0drxzzB0CroPYsmU+TmIQ/R0QvYtYnVMdwKcu+Qb1cSvD5qJRFQPq/Zs2fLjBkztDcwhorBsC5oEsa+u0IT8Ntvv61BZ3H7WFRUpMPXjB079rn3iYgCXyX0BKnonSAiKi+4WwaGXMEQLa7WrVunNXHXr1/XJtaSIEhDjSGaXb0FoeUFwe3WrVt1mBkiopIwB5CIAtqSJUu0J/CLL76otYhffvmlDhjtgB6zuDPJvHnztMm4NMEfvPPOO5KTk6PNsk2aNJGKhs4mruMLEhF5wxpAIgpoqNXDnTSQQ4hmVNxBZOrUqc7OJBi4GbWCGPZl+/btxQ41Q0QUaBgAEhEREQWZik9cISIiIqJyxQCQiIiIKMgwACQiIiIKMgwAiYiIiIIMA0AiIiKiIMMAkIiIiCjIMAAkIiIiCjIMAImIiIiCDANAIiIiIgku/wElHoO9N2L94gAAAABJRU5ErkJggg==", - "text/html": [ - "\n", - "
\n", - "
\n", - " Figure\n", - "
\n", - " \n", - "
\n", - " " - ], - "text/plain": [ - "Canvas(toolbar=Toolbar(toolitems=[('Home', 'Reset original view', 'home', 'home'), ('Back', 'Back to previous …" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Use some of the extra settings for the numerical convolution\n", "sample_components = ComponentCollection()\n", @@ -252,7 +174,7 @@ "\n", "\n", "temperature = 10.0 # Temperature in Kelvin\n", - "offset = 0.5\n", + "energy_offset = 0.2\n", "upsample_factor = 5\n", "extension_factor = 0.5\n", "plt.figure()\n", @@ -262,9 +184,11 @@ "convolver = Convolution(\n", " sample_components=sample_components,\n", " resolution_components=resolution_components,\n", - " energy=energy - offset,\n", + " energy=energy,\n", " upsample_factor=upsample_factor,\n", " extension_factor=extension_factor,\n", + " energy_offset=energy_offset,\n", + " temperature=temperature,\n", ")\n", "y = convolver.convolution()\n", "\n", @@ -273,7 +197,7 @@ "\n", "plt.plot(\n", " energy,\n", - " sample_components.evaluate(energy - offset),\n", + " sample_components.evaluate(energy - energy_offset),\n", " label='Sample Model',\n", " linestyle='--',\n", ")\n", diff --git a/src/easydynamics/convolution/analytical_convolution.py b/src/easydynamics/convolution/analytical_convolution.py index cfa56c9f..c24a50f2 100644 --- a/src/easydynamics/convolution/analytical_convolution.py +++ b/src/easydynamics/convolution/analytical_convolution.py @@ -3,6 +3,7 @@ import numpy as np import scipp as sc +from easyscience.variable import Parameter from scipy.special import voigt_profile from easydynamics.convolution.convolution_base import ConvolutionBase @@ -12,8 +13,7 @@ from easydynamics.sample_model import Voigt from easydynamics.sample_model.component_collection import ComponentCollection from easydynamics.sample_model.components.model_component import ModelComponent - -Numerical = float | int +from easydynamics.utils.utils import Numeric class AnalyticalConvolution(ConvolutionBase): @@ -35,26 +35,28 @@ class AnalyticalConvolution(ConvolutionBase): # Mapping of supported component type pairs to convolution methods. # Delta functions are handled separately. _CONVOLUTIONS = { - ('Gaussian', 'Gaussian'): '_convolute_gaussian_gaussian', - ('Gaussian', 'Lorentzian'): '_convolute_gaussian_lorentzian', - ('Gaussian', 'Voigt'): '_convolute_gaussian_voigt', - ('Lorentzian', 'Lorentzian'): '_convolute_lorentzian_lorentzian', - ('Lorentzian', 'Voigt'): '_convolute_lorentzian_voigt', - ('Voigt', 'Voigt'): '_convolute_voigt_voigt', + ("Gaussian", "Gaussian"): "_convolute_gaussian_gaussian", + ("Gaussian", "Lorentzian"): "_convolute_gaussian_lorentzian", + ("Gaussian", "Voigt"): "_convolute_gaussian_voigt", + ("Lorentzian", "Lorentzian"): "_convolute_lorentzian_lorentzian", + ("Lorentzian", "Voigt"): "_convolute_lorentzian_voigt", + ("Voigt", "Voigt"): "_convolute_voigt_voigt", } def __init__( self, energy: np.ndarray | sc.Variable, - energy_unit: str | sc.Unit = 'meV', + energy_unit: str | sc.Unit = "meV", sample_components: ComponentCollection | ModelComponent | None = None, resolution_components: ComponentCollection | ModelComponent | None = None, + energy_offset: Numeric | Parameter = 0.0, ): super().__init__( energy=energy, energy_unit=energy_unit, sample_components=sample_components, resolution_components=resolution_components, + energy_offset=energy_offset, ) def convolution( @@ -142,8 +144,8 @@ def _convolute_analytic_pair( if isinstance(resolution_component, DeltaFunction): raise ValueError( - 'Analytical convolution with a delta function \ - in the resolution model is not supported.' + "Analytical convolution with a delta function \ + in the resolution model is not supported." ) # Delta function + anything --> @@ -169,8 +171,8 @@ def _convolute_analytic_pair( if func_name is None: raise ValueError( - f'Analytical convolution not supported for component pair: ' - f'{type(sample_component).__name__}, {type(resolution_component).__name__}' + f"Analytical convolution not supported for component pair: " + f"{type(sample_component).__name__}, {type(resolution_component).__name__}" ) # Call the corresponding method @@ -199,7 +201,7 @@ def _convolute_delta_any( The evaluated convolution values at self.energy. """ return sample_component.area.value * resolution_components.evaluate( - self.energy.values - sample_component.center.value + self.energy_with_offset.values - sample_component.center.value ) def _convolute_gaussian_gaussian( @@ -223,7 +225,9 @@ def _convolute_gaussian_gaussian( The evaluated convolution values at self.energy. """ - width = np.sqrt(sample_component.width.value**2 + resolution_component.width.value**2) + width = np.sqrt( + sample_component.width.value**2 + resolution_component.width.value**2 + ) area = sample_component.area.value * resolution_component.area.value @@ -284,7 +288,8 @@ def _convolute_gaussian_voigt( center = sample_component.center.value + resolution_component.center.value gaussian_width = np.sqrt( - sample_component.width.value**2 + resolution_component.gaussian_width.value**2 + sample_component.width.value**2 + + resolution_component.gaussian_width.value**2 ) lorentzian_width = resolution_component.lorentzian_width.value @@ -384,11 +389,13 @@ def _convolute_voigt_voigt( center = sample_component.center.value + resolution_component.center.value gaussian_width = np.sqrt( - sample_component.gaussian_width.value**2 + resolution_component.gaussian_width.value**2 + sample_component.gaussian_width.value**2 + + resolution_component.gaussian_width.value**2 ) lorentzian_width = ( - sample_component.lorentzian_width.value + resolution_component.lorentzian_width.value + sample_component.lorentzian_width.value + + resolution_component.lorentzian_width.value ) return self._voigt_eval( area=area, @@ -420,7 +427,7 @@ def _gaussian_eval( """ normalization = 1 / (np.sqrt(2 * np.pi) * width) - exponent = -0.5 * ((self.energy.values - center) / width) ** 2 + exponent = -0.5 * ((self.energy_with_offset.values - center) / width) ** 2 return area * normalization * np.exp(exponent) @@ -443,7 +450,7 @@ def _lorentzian_eval(self, area: float, center: float, width: float) -> np.ndarr """ normalization = width / np.pi - denominator = (self.energy.values - center) ** 2 + width**2 + denominator = (self.energy_with_offset.values - center) ** 2 + width**2 return area * normalization / denominator @@ -471,4 +478,6 @@ def _voigt_eval( The evaluated Voigt profile values at self.energy. """ - return area * voigt_profile(self.energy.values - center, gaussian_width, lorentzian_width) + return area * voigt_profile( + self.energy_with_offset.values - center, gaussian_width, lorentzian_width + ) diff --git a/src/easydynamics/convolution/convolution.py b/src/easydynamics/convolution/convolution.py index 542515e9..827087c1 100644 --- a/src/easydynamics/convolution/convolution.py +++ b/src/easydynamics/convolution/convolution.py @@ -14,8 +14,7 @@ from easydynamics.sample_model import Lorentzian from easydynamics.sample_model import Voigt from easydynamics.sample_model.components.model_component import ModelComponent - -Numerical = float | int +from easydynamics.utils.utils import Numeric class Convolution(NumericalConvolutionBase): @@ -60,16 +59,16 @@ class Convolution(NumericalConvolutionBase): # When these attributes are changed, the convolution plan # needs to be rebuilt _invalidate_plan_on_change = { - 'energy', - '_energy', - '_energy_grid', - '_sample_components', - '_resolution_components', - '_temperature', - '_upsample_factor', - '_extension_factor', - '_energy_unit', - '_normalize_detailed_balance', + "energy", + "_energy", + "_energy_grid", + "_sample_components", + "_resolution_components", + "_temperature", + "_upsample_factor", + "_extension_factor", + "_energy_unit", + "_normalize_detailed_balance", } def __init__( @@ -77,11 +76,12 @@ def __init__( energy: np.ndarray | sc.Variable, sample_components: ComponentCollection | ModelComponent, resolution_components: ComponentCollection | ModelComponent, - upsample_factor: Numerical = 5, - extension_factor: Numerical = 0.2, - temperature: Parameter | Numerical | None = None, - temperature_unit: str | sc.Unit = 'K', - energy_unit: str | sc.Unit = 'meV', + energy_offset: Numeric | Parameter = 0.0, + upsample_factor: Numeric = 5, + extension_factor: Numeric = 0.2, + temperature: Parameter | Numeric | None = None, + temperature_unit: str | sc.Unit = "K", + energy_unit: str | sc.Unit = "meV", normalize_detailed_balance: bool = True, ): self._convolution_plan_is_valid = False @@ -90,6 +90,7 @@ def __init__( energy=energy, sample_components=sample_components, resolution_components=resolution_components, + energy_offset=energy_offset, upsample_factor=upsample_factor, extension_factor=extension_factor, temperature=temperature, @@ -136,11 +137,13 @@ def convolution( def _convolve_delta_functions(self) -> np.ndarray: "Convolve delta function components of the sample model with" - 'the resolution components.' - 'No detailed balance correction is applied to delta functions.' + "the resolution components." + "No detailed balance correction is applied to delta functions." return sum( delta.area.value - * self._resolution_components.evaluate(self.energy.values - delta.center.value) + * self._resolution_components.evaluate( + self.energy_with_offset.values - delta.center.value + ) for delta in self._delta_sample_components.components ) @@ -165,19 +168,19 @@ def _check_if_pair_is_analytic( if not isinstance(sample_component, ModelComponent): raise TypeError( - f'`sample_component` is an instance of {type(sample_component).__name__}, \ - but must be a ModelComponent.' + f"`sample_component` is an instance of {type(sample_component).__name__}, \ + but must be a ModelComponent." ) if not isinstance(resolution_component, ModelComponent): raise TypeError( - f'`resolution_component` is an instance of {type(resolution_component).__name__}, \ - but must be a ModelComponent.' + f"`resolution_component` is an instance of {type(resolution_component).__name__}, \ + but must be a ModelComponent." ) if isinstance(resolution_component, DeltaFunction): raise TypeError( - 'resolution components contains delta functions. This is not supported.' + "resolution components contains delta functions. This is not supported." ) analytical_types = (Gaussian, Lorentzian, Voigt) @@ -216,7 +219,9 @@ def _build_convolution_plan(self) -> None: pair_is_analytic = [] for resolution_component in self._resolution_components.components: pair_is_analytic.append( - self._check_if_pair_is_analytic(sample_component, resolution_component) + self._check_if_pair_is_analytic( + sample_component, resolution_component + ) ) # If all resolution components can be convolved analytically # with this sample component, add it to analytical @@ -245,6 +250,7 @@ def _set_convolvers(self) -> None: if self._analytical_sample_components.components: self._analytical_convolver = AnalyticalConvolution( energy=self.energy, + energy_offset=self.energy_offset, sample_components=self._analytical_sample_components, resolution_components=self._resolution_components, ) @@ -254,6 +260,7 @@ def _set_convolvers(self) -> None: if self._numerical_sample_components.components: self._numerical_convolver = NumericalConvolution( energy=self.energy, + energy_offset=self.energy_offset, sample_components=self._numerical_sample_components, resolution_components=self._resolution_components, upsample_factor=self.upsample_factor, @@ -278,5 +285,8 @@ def __setattr__(self, name, value): if name in self._invalidate_plan_on_change: self._convolution_plan_is_valid = False - if getattr(self, '_reactions_enabled', False) and name in self._invalidate_plan_on_change: + if ( + getattr(self, "_reactions_enabled", False) + and name in self._invalidate_plan_on_change + ): self._build_convolution_plan() diff --git a/src/easydynamics/convolution/convolution_base.py b/src/easydynamics/convolution/convolution_base.py index cfe364b0..9c212d64 100644 --- a/src/easydynamics/convolution/convolution_base.py +++ b/src/easydynamics/convolution/convolution_base.py @@ -3,11 +3,11 @@ import numpy as np import scipp as sc +from easyscience.variable import Parameter from easydynamics.sample_model.component_collection import ComponentCollection from easydynamics.sample_model.components.model_component import ModelComponent - -Numerical = float | int +from easydynamics.utils.utils import Numeric class ConvolutionBase: @@ -30,29 +30,39 @@ def __init__( energy: np.ndarray | sc.Variable, sample_components: ComponentCollection | ModelComponent = None, resolution_components: ComponentCollection | ModelComponent = None, - energy_unit: str | sc.Unit = 'meV', + energy_unit: str | sc.Unit = "meV", + energy_offset: Numeric | Parameter = 0.0, ): - if isinstance(energy, Numerical): + if isinstance(energy, Numeric): energy = np.array([float(energy)]) if not isinstance(energy, (np.ndarray, sc.Variable)): - raise TypeError('Energy must be a numpy ndarray or a scipp Variable.') + raise TypeError("Energy must be a numpy ndarray or a scipp Variable.") if not isinstance(energy_unit, (str, sc.Unit)): - raise TypeError('Energy_unit must be a string or sc.Unit.') + raise TypeError("Energy_unit must be a string or sc.Unit.") if isinstance(energy, np.ndarray): - energy = sc.array(dims=['energy'], values=energy, unit=energy_unit) + energy = sc.array(dims=["energy"], values=energy, unit=energy_unit) + + if isinstance(energy_offset, Numeric): + energy_offset = Parameter( + name="energy_offset", value=float(energy_offset), unit=energy_unit + ) + + if not isinstance(energy_offset, Parameter): + raise TypeError("Energy_offset must be a number or a Parameter.") self._energy = energy self._energy_unit = energy_unit + self._energy_offset = energy_offset if sample_components is not None and not ( isinstance(sample_components, ComponentCollection) or isinstance(sample_components, ModelComponent) ): raise TypeError( - f'`sample_components` is an instance of {type(sample_components).__name__}, but must be a ComponentCollection or ModelComponent.' # noqa: E501 + f"`sample_components` is an instance of {type(sample_components).__name__}, but must be a ComponentCollection or ModelComponent." # noqa: E501 ) if isinstance(sample_components, ModelComponent): sample_components = ComponentCollection(components=[sample_components]) @@ -63,12 +73,53 @@ def __init__( or isinstance(resolution_components, ModelComponent) ): raise TypeError( - f'`resolution_components` is an instance of {type(resolution_components).__name__}, but must be a ComponentCollection or ModelComponent.' # noqa: E501 + f"`resolution_components` is an instance of {type(resolution_components).__name__}, but must be a ComponentCollection or ModelComponent." # noqa: E501 ) if isinstance(resolution_components, ModelComponent): - resolution_components = ComponentCollection(components=[resolution_components]) + resolution_components = ComponentCollection( + components=[resolution_components] + ) self._resolution_components = resolution_components + @property + def energy_offset(self) -> Parameter: + """Get the energy offset.""" + return self._energy_offset + + @energy_offset.setter + def energy_offset(self, energy_offset: Numeric | Parameter) -> None: + """Set the energy offset. + Args: + energy_offset : Number or Parameter + The energy offset to apply to the convolution. + + Raises: + TypeError: If energy_offset is not a number or a Parameter. + """ + if not isinstance(energy_offset, Parameter | Numeric): + raise TypeError("Energy_offset must be a number or a Parameter.") + + if isinstance(energy_offset, Numeric): + self._energy_offset.value = float(energy_offset) + + if isinstance(energy_offset, Parameter): + self._energy_offset = energy_offset + + @property + def energy_with_offset(self) -> sc.Variable: + """Get the energy with the offset applied.""" + energy_with_offset = self.energy.copy() + energy_with_offset.values = self.energy.values - self.energy_offset.value + return energy_with_offset + + @energy_with_offset.setter + def energy_with_offset(self, value) -> None: + """Energy with offset is a read-only property derived from + energy and energy_offset.""" + raise AttributeError( + "Energy with offset is a read-only property derived from energy and energy_offset." + ) + @property def energy(self) -> sc.Variable: """Get the energy.""" @@ -88,14 +139,18 @@ def energy(self, energy: np.ndarray) -> None: scipp Variable. """ - if isinstance(energy, Numerical): + if isinstance(energy, Numeric): energy = np.array([float(energy)]) if not isinstance(energy, (np.ndarray, sc.Variable)): - raise TypeError('Energy must be a Number, a numpy ndarray or a scipp Variable.') + raise TypeError( + "Energy must be a Number, a numpy ndarray or a scipp Variable." + ) if isinstance(energy, np.ndarray): - self._energy = sc.array(dims=['energy'], values=energy, unit=self._energy.unit) + self._energy = sc.array( + dims=["energy"], values=energy, unit=self._energy.unit + ) if isinstance(energy, sc.Variable): self._energy = energy @@ -110,8 +165,8 @@ def energy_unit(self) -> str: def energy_unit(self, unit_str: str) -> None: raise AttributeError( ( - f'Unit is read-only. Use convert_unit to change the unit between allowed types ' - f'or create a new {self.__class__.__name__} with the desired unit.' + f"Unit is read-only. Use convert_unit to change the unit between allowed types " + f"or create a new {self.__class__.__name__} with the desired unit." ) ) # noqa: E501 @@ -125,7 +180,7 @@ def convert_energy_unit(self, energy_unit: str | sc.Unit) -> None: TypeError: If energy_unit is not a string or scipp unit. """ if not isinstance(energy_unit, (str, sc.Unit)): - raise TypeError('Energy unit must be a string or scipp unit.') + raise TypeError("Energy unit must be a string or scipp unit.") self.energy = sc.to_unit(self.energy, energy_unit) self._energy_unit = energy_unit @@ -136,7 +191,9 @@ def sample_components(self) -> ComponentCollection | ModelComponent: return self._sample_components @sample_components.setter - def sample_components(self, sample_components: ComponentCollection | ModelComponent) -> None: + def sample_components( + self, sample_components: ComponentCollection | ModelComponent + ) -> None: """Set the sample model. Args: sample_components : ComponentCollection or ModelComponent @@ -148,7 +205,7 @@ def sample_components(self, sample_components: ComponentCollection | ModelCompon """ if not isinstance(sample_components, (ComponentCollection, ModelComponent)): raise TypeError( - f'`sample_components` is an instance of {type(sample_components).__name__}, but must be a ComponentCollection or ModelComponent.' # noqa: E501 + f"`sample_components` is an instance of {type(sample_components).__name__}, but must be a ComponentCollection or ModelComponent." # noqa: E501 ) self._sample_components = sample_components @@ -173,6 +230,6 @@ def resolution_components( """ if not isinstance(resolution_components, (ComponentCollection, ModelComponent)): raise TypeError( - f'`resolution_components` is an instance of {type(resolution_components).__name__}, but must be a ComponentCollection or ModelComponent.' # noqa: E501 + f"`resolution_components` is an instance of {type(resolution_components).__name__}, but must be a ComponentCollection or ModelComponent." # noqa: E501 ) self._resolution_components = resolution_components diff --git a/src/easydynamics/convolution/numerical_convolution.py b/src/easydynamics/convolution/numerical_convolution.py index 125c4451..95d75917 100644 --- a/src/easydynamics/convolution/numerical_convolution.py +++ b/src/easydynamics/convolution/numerical_convolution.py @@ -9,9 +9,10 @@ from easydynamics.convolution.numerical_convolution_base import NumericalConvolutionBase from easydynamics.sample_model.component_collection import ComponentCollection from easydynamics.sample_model.components.model_component import ModelComponent -from easydynamics.utils.detailed_balance import _detailed_balance_factor as detailed_balance_factor - -Numerical = float | int +from easydynamics.utils.detailed_balance import ( + _detailed_balance_factor as detailed_balance_factor, +) +from easydynamics.utils.utils import Numeric class NumericalConvolution(NumericalConvolutionBase): @@ -53,17 +54,19 @@ def __init__( energy: np.ndarray | sc.Variable, sample_components: ComponentCollection | ModelComponent, resolution_components: ComponentCollection | ModelComponent, - upsample_factor: Numerical = 5, - extension_factor: float = 0.2, - temperature: Parameter | float | None = None, - temperature_unit: str | sc.Unit = 'K', - energy_unit: str | sc.Unit = 'meV', + energy_offset: Numeric | Parameter = 0.0, + upsample_factor: Numeric = 5, + extension_factor: Numeric = 0.2, + temperature: Parameter | Numeric | None = None, + temperature_unit: str | sc.Unit = "K", + energy_unit: str | sc.Unit = "meV", normalize_detailed_balance: bool = True, ): super().__init__( energy=energy, sample_components=sample_components, resolution_components=resolution_components, + energy_offset=energy_offset, upsample_factor=upsample_factor, extension_factor=extension_factor, temperature=temperature, @@ -87,23 +90,25 @@ def convolution( # Give warnings if peaks are very wide or very narrow self._check_width_thresholds( model=self.sample_components, - model_name='sample model', + model_name="sample model", ) self._check_width_thresholds( model=self.resolution_components, - model_name='resolution model', + model_name="resolution model", ) # Evaluate sample model. If called via the Convolution class, # delta functions are already filtered out. sample_vals = self.sample_components.evaluate( - self._energy_grid.energy_dense - self._energy_grid.energy_even_length_offset + self._energy_grid.energy_dense + - self._energy_grid.energy_even_length_offset + - self.energy_offset.value ) # Detailed balance correction if self.temperature is not None: detailed_balance_factor_correction = detailed_balance_factor( - energy=self._energy_grid.energy_dense, + energy=self._energy_grid.energy_dense - self.energy_offset.value, temperature=self.temperature, energy_unit=self.energy.unit, divide_by_temperature=self.normalize_detailed_balance, @@ -116,7 +121,7 @@ def convolution( ) # Convolution - convolved = fftconvolve(sample_vals, resolution_vals, mode='same') + convolved = fftconvolve(sample_vals, resolution_vals, mode="same") convolved *= self._energy_grid.energy_dense_step # normalize if self.upsample_factor is not None: diff --git a/src/easydynamics/convolution/numerical_convolution_base.py b/src/easydynamics/convolution/numerical_convolution_base.py index dd3e68e3..ba40f456 100644 --- a/src/easydynamics/convolution/numerical_convolution_base.py +++ b/src/easydynamics/convolution/numerical_convolution_base.py @@ -63,6 +63,7 @@ def __init__( energy: np.ndarray | sc.Variable, sample_components: ComponentCollection | ModelComponent, resolution_components: ComponentCollection | ModelComponent, + energy_offset: Numerical | Parameter = 0.0, upsample_factor: Numerical = 5, extension_factor: float = 0.2, temperature: Parameter | float | None = None, @@ -75,6 +76,7 @@ def __init__( sample_components=sample_components, resolution_components=resolution_components, energy_unit=energy_unit, + energy_offset=energy_offset, ) if temperature is not None and not isinstance( From ba85d95e018813ed5d0bcc83b54414a3e122adc1 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Fri, 6 Feb 2026 15:10:40 +0100 Subject: [PATCH 05/27] Progress on Analysis --- docs/docs/tutorials/analysis.ipynb | 31 +- src/easydynamics/analysis/analysis1d old.py | 497 ++++++++++++++++++ src/easydynamics/analysis/analysis1d.py | 458 ++++++---------- src/easydynamics/analysis/analysis_base.py | 63 ++- .../sample_model/component_collection.py | 79 ++- .../sample_model/instrument_model.py | 78 ++- src/easydynamics/sample_model/model_base.py | 58 +- 7 files changed, 870 insertions(+), 394 deletions(-) create mode 100644 src/easydynamics/analysis/analysis1d old.py diff --git a/docs/docs/tutorials/analysis.ipynb b/docs/docs/tutorials/analysis.ipynb index 7b843acc..83257cda 100644 --- a/docs/docs/tutorials/analysis.ipynb +++ b/docs/docs/tutorials/analysis.ipynb @@ -26,13 +26,13 @@ "from easydynamics.sample_model.background_model import BackgroundModel\n", "from easydynamics.sample_model.resolution_model import ResolutionModel\n", "from easydynamics.sample_model.sample_model import SampleModel\n", - "\n", + "from easydynamics.sample_model.instrument_model import InstrumentModel\n", "%matplotlib widget" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "8deca9b6", "metadata": {}, "outputs": [], @@ -46,7 +46,27 @@ "execution_count": null, "id": "41f842f0", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "C:\\Users\\henrikjacobsen3\\Documents\\easyScience\\dynamics-lib\\src\\easydynamics\\sample_model\\model_base.py:253: UserWarning: Q is not set. No component collections generated\n", + " warnings.warn('Q is not set. No component collections generated', UserWarning)\n" + ] + }, + { + "ename": "TypeError", + "evalue": "Analysis1d.__init__() got an unexpected keyword argument 'resolution_model'", + "output_type": "error", + "traceback": [ + "\u001b[31m---------------------------------------------------------------------------\u001b[39m", + "\u001b[31mTypeError\u001b[39m Traceback (most recent call last)", + "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[3]\u001b[39m\u001b[32m, line 25\u001b[39m\n\u001b[32m 20\u001b[39m resolution_model = ResolutionModel(components=res_gauss)\n\u001b[32m 23\u001b[39m background_model = BackgroundModel(components=Polynomial(coefficients=[\u001b[32m0.001\u001b[39m]))\n\u001b[32m---> \u001b[39m\u001b[32m25\u001b[39m my_analysis = \u001b[43mAnalysis1d\u001b[49m\u001b[43m(\u001b[49m\n\u001b[32m 26\u001b[39m \u001b[43m \u001b[49m\u001b[43mexperiment\u001b[49m\u001b[43m=\u001b[49m\u001b[43mvanadium_experiment\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m 27\u001b[39m \u001b[43m \u001b[49m\u001b[43msample_model\u001b[49m\u001b[43m=\u001b[49m\u001b[43msample_model\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m 28\u001b[39m \u001b[43m \u001b[49m\u001b[43mresolution_model\u001b[49m\u001b[43m=\u001b[49m\u001b[43mresolution_model\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m 29\u001b[39m \u001b[43m \u001b[49m\u001b[43mbackground_model\u001b[49m\u001b[43m=\u001b[49m\u001b[43mbackground_model\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m 30\u001b[39m \u001b[43m \u001b[49m\u001b[43mQ_index\u001b[49m\u001b[43m=\u001b[49m\u001b[32;43m5\u001b[39;49m\u001b[43m,\u001b[49m\n\u001b[32m 31\u001b[39m \u001b[43m)\u001b[49m\n\u001b[32m 33\u001b[39m my_analysis._update_models()\n\u001b[32m 36\u001b[39m values = my_analysis.calculate()\n", + "\u001b[31mTypeError\u001b[39m: Analysis1d.__init__() got an unexpected keyword argument 'resolution_model'" + ] + } + ], "source": [ "# Create a diffusion_model and components for the SampleModel\n", "\n", @@ -72,6 +92,11 @@ "\n", "background_model = BackgroundModel(components=Polynomial(coefficients=[0.001]))\n", "\n", + "instrument_model = InstrumentModel(\n", + " resolution_model=resolution_model,\n", + " background_model=background_model,\n", + ")\n", + "\n", "my_analysis = Analysis1d(\n", " experiment=vanadium_experiment,\n", " sample_model=sample_model,\n", diff --git a/src/easydynamics/analysis/analysis1d old.py b/src/easydynamics/analysis/analysis1d old.py new file mode 100644 index 00000000..b27fed1e --- /dev/null +++ b/src/easydynamics/analysis/analysis1d old.py @@ -0,0 +1,497 @@ +# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-License-Identifier: BSD-3-Clause + + +import numpy as np +import scipp as sc +from easyscience.base_classes.model_base import ModelBase as EasyScienceModelBase +from easyscience.fitting.fitter import Fitter as EasyScienceFitter +from easyscience.variable import DescriptorNumber +from easyscience.variable import Parameter + +from easydynamics.convolution import Convolution +from easydynamics.experiment import Experiment +from easydynamics.sample_model import InstrumentModel +from easydynamics.sample_model import ResolutionModel +from easydynamics.sample_model import SampleModel + + +class Analysis1d(EasyScienceModelBase): + """For analysing data.""" + + def __init__( + self, + display_name: str = "MyAnalysis", + unique_name: str | None = None, + experiment: Experiment | None = None, + sample_model: SampleModel | None = None, + instrument_model: InstrumentModel | None = None, + Q_index: int | None = None, + ): + super().__init__(display_name=display_name, unique_name=unique_name) + + if experiment is not None and not isinstance(experiment, Experiment): + raise TypeError("experiment must be an instance of Experiment or None.") + + self._experiment = experiment + + if sample_model is not None and not isinstance(sample_model, SampleModel): + raise TypeError("sample_model must be an instance of SampleModel or None.") + sample_model.Q = self.Q + self._sample_model = sample_model + + if instrument_model is not None and not isinstance( + instrument_model, InstrumentModel + ): + raise TypeError( + "instrument_model must be an instance of InstrumentModel or None." + ) + if instrument_model is None: + self._instrument_model = InstrumentModel() + else: + self._instrument_model = instrument_model + + self._convolvers = [None] * (len(self.Q) if self.Q is not None else 0) + self._update_models() + + if Q_index is not None: + if ( + not isinstance(Q_index, int) + or Q_index < 0 + or (self.Q is not None and Q_index >= len(self.Q)) + ): + raise ValueError("Q_index must be a valid index for the Q values.") + self._Q_index = Q_index + + ############# + # Properties + ############# + + @property + def experiment(self) -> Experiment | None: + """The Experiment associated with this Analysis.""" + return self._experiment + + @experiment.setter + def experiment(self, value: Experiment | None) -> None: + if value is not None and not isinstance(value, Experiment): + raise TypeError("experiment must be an instance of Experiment or None.") + self._experiment = value + self._update_models() + + @property + def sample_model(self) -> SampleModel | None: + """The SampleModel associated with this Analysis.""" + return self._sample_model + + @sample_model.setter + def sample_model(self, value: SampleModel | None) -> None: + if value is not None and not isinstance(value, SampleModel): + raise TypeError("sample_model must be an instance of SampleModel or None.") + self._sample_model = value + self._update_models() + + @property + def resolution_model(self) -> ResolutionModel | None: + """The ResolutionModel associated with this Analysis.""" + return self._resolution_model + + @resolution_model.setter + def resolution_model(self, value: ResolutionModel | None) -> None: + if value is not None and not isinstance(value, ResolutionModel): + raise TypeError( + "resolution_model must be an instance of ResolutionModel or None." + ) + self._resolution_model = value + self._update_models() + + @property + def Q(self) -> sc.Variable | None: + """The Q values from the associated Experiment, if available.""" + if self.experiment is not None: + return self.experiment.Q + return None + + @Q.setter + def Q(self, value) -> None: + """Q is a read-only property derived from the Experiment.""" + raise AttributeError("Q is a read-only property derived from the Experiment.") + + @property + def energy(self) -> sc.Variable | None: + """The energy values from the associated Experiment, if + available. + """ + if self.experiment is not None: + return self.experiment.energy + return None + + @energy.setter + def energy(self, value) -> None: + """Energy is a read-only property derived from the + Experiment. + """ + raise AttributeError( + "energy is a read-only property derived from the Experiment." + ) + + @property + def temperature(self) -> Parameter | None: + """The temperature from the associated Experiment, if + available. + """ + return self.sample_model.temperature if self.sample_model is not None else None + + @temperature.setter + def temperature(self, value) -> None: + """Temperature is a read-only property derived from the + Experiment. + """ + raise AttributeError( + "temperature is a read-only property derived from the sample model." + ) + + @property + def energy_offset(self) -> list[Parameter] | None: + """Get the energy offsets for each Q value.""" + return self._energy_offset + + @energy_offset.setter + def energy_offset(self, offsets: list[Parameter] | None) -> None: + """Set the energy offsets for each Q value. + + Args: + offsets (list[Parameter] | None): The list of energy + offsets. + Raises: + TypeError: If offsets is not a list of Parameters or + None. + """ + if offsets is not None: + if len(offsets) != len(self.Q): + raise ValueError( + "energy_offset list length must match number of Q values." + ) + for offset in offsets: + if not isinstance(offset, Parameter): + raise TypeError( + "Each energy_offset must be an instance of Parameter." + ) + self._energy_offset = offsets + + @property + def Q_index(self) -> int | None: + """Get the Q index for single Q analysis.""" + return self._Q_index + + @Q_index.setter + def Q_index(self, index: int | None) -> None: + """Set the Q index for single Q analysis. + + Args: + index (int | None): The Q index. + """ + if index is not None: + if ( + not isinstance(index, int) + or index < 0 + or (self.Q is not None and index >= len(self.Q)) + ): + raise ValueError("Q_index must be a valid index for the Q values.") + self._Q_index = index + + ############# + # Other methods + ############# + + def calculate(self, energy: float | None = None) -> np.ndarray: + """Calculate the model prediction for a given Q index. + + Args: + energy (float): The energy value to calculate the model for. + Returns: + sc.DataArray: The calculated model prediction. + """ + Q_index = self.Q_index + if Q_index is None: + raise ValueError("Q_index must be set to calculate the model.") + + if energy is None: + energy = self.energy.values + + # TODO: handle units properly + energy = energy - self.energy_offset[Q_index].value + if self.sample_model is None: + sample_intensity = np.zeros_like(energy) + else: + if self.resolution_model is None: + sample_intensity = self.sample_model._component_collections[ + Q_index + ].evaluate(energy) + else: + convolver = self._convolvers[Q_index] + sample_intensity = convolver.convolution() + + if self.background_model is None: + background_intensity = np.zeros_like(energy) + else: + background_intensity = self.background_model._component_collections[ + Q_index + ].evaluate(energy) + + sample_plus_background = sample_intensity + background_intensity + + return sample_plus_background + + def calculate_individual_components( + self, + ) -> tuple[list[np.ndarray], list[np.ndarray]]: + """Calculate the model prediction for a given Q index for each + individual component. + + Args: + Q_index (int): The index of the Q value to calculate the + model for. + Returns: + list[np.ndarray]: The calculated model predictions for each + individual component. + """ + sample_results = [] + background_results = [] + Q_index = self.Q_index + if Q_index is None: + raise ValueError("Q_index must be set to calculate the model.") + + if self.sample_model is not None: + # Calculate sample components + for component in self.sample_model._component_collections[ + Q_index + ]._components: + if self.resolution_model is None: + component_intensity = component.evaluate(self.energy) + else: + convolver = Convolution( + sample_components=component, + resolution_components=self.resolution_model._component_collections[ + Q_index + ], + energy=self.energy, + temperature=self.temperature, + ) + component_intensity = convolver.convolution() + sample_results.append(component_intensity) + + if self.background_model is not None: + # Calculate background components + for component in self.background_model._component_collections[ + Q_index + ]._components: + component_intensity = component.evaluate(self.energy) + background_results.append(component_intensity) + + return sample_results, background_results + + def fit(self): + """Fit the model to the experimental data for a given Q index. + + Args: + Returns: + FitResult: The result of the fit. + """ + if self._experiment is None: + raise ValueError("No experiment is associated with this Analysis.") + + Q_index = self.Q_index + if Q_index is None: + raise ValueError("Q_index must be set to perform the fit.") + + data = self.experiment.data["Q", Q_index] + x = data.coords["energy"].values + y = data.values + e = data.variances**0.5 + + def fit_func(x_vals): + return self.calculate(energy=x_vals) + + fitter = EasyScienceFitter( + fit_object=self, + fit_function=fit_func, + ) + + # Perform the fit + fit_result = fitter.fit(x=x, y=y, weights=1.0 / e) + + # Store result + self.fit_result = fit_result + + return fit_result + + def plot_data_and_model( + self, + plot_individual_components: bool = True, + ) -> None: + """Plot the experimental data and the model prediction. + + Args: + plot_individual_components (bool): Whether to plot + individual components. Default is True. + """ + if not isinstance(plot_individual_components, bool): + raise TypeError("plot_individual_components must be True or False.") + + import matplotlib.pyplot as plt + + Q_index = self.Q_index + if Q_index is None: + raise ValueError("Q_index must be set to plot the data and model.") + if self.experiment is None or self.experiment.data is None: + raise ValueError("Experiment data is not available for plotting.") + data = self.experiment.data["Q", Q_index] + energy = data.coords["energy"].values + model = self.calculate(energy=energy) + plt.figure() + plt.errorbar( + energy, + data.values, + yerr=data.variances**0.5, + fmt="o", + label="Data", + color="black", + ) + plt.plot(energy, model, label="Model", color="red") + if plot_individual_components: + sample_comps, background_comps = self.calculate_individual_components() + for i, comp in enumerate(sample_comps): + plt.plot( + energy, + comp, + label=f"Sample Component {i + 1}", + linestyle="--", + ) + for i, comp in enumerate(background_comps): + plt.plot( + energy, + comp, + label=f"Background Component {i + 1}", + linestyle=":", + ) + plt.xlabel(f"Energy ({self.energy.unit})") + plt.ylabel(f"Intensity ({self.sample_model.unit})") + plt.title(f"Data and Model at Q index {Q_index}") + plt.legend() + plt.show() + # model_data_array = self._create_model_data_group( + # individual_components=plot_individual_components ) if + # self.experiment is None or self.experiment.data is None: raise + # ValueError("Experiment data is not available for plotting.") + + # from IPython.display import display + + # fig = pp.slicer( + # {"Data": self.experiment.data, "Model": model_data_array}, + # color={"Data": "black", "Model": "red"}, + # linestyle={"Data": "none", "Model": "solid"}, + # marker={"Data": "o", "Model": "None"}, + # ) + # display(fig) + + def get_all_variables(self) -> list[DescriptorNumber]: + """Get all variables used in the analysis. + + Returns: + List[Descriptor]: A list of all variables. + """ + variables = [] + if self.sample_model is not None: + variables.extend( + self.sample_model._component_collections[ + self.Q_index + ].get_all_variables() + ) + if self.resolution_model is not None: + variables.extend( + self.resolution_model._component_collections[ + self.Q_index + ].get_all_variables() + ) + if self.background_model is not None: + variables.extend( + self.background_model._component_collections[ + self.Q_index + ].get_all_variables() + ) + variables.append(self.energy_offset[self.Q_index]) + # TODO temperature and diffusion + return variables + + ############# + # Private methods + ############# + + def _update_models(self): + """Update models based on the current experiment.""" + if self.experiment is None: + return + + for Q_index in range(len(self.Q)): + self._convolvers[Q_index] = self._create_convolver(Q_index) + + def _create_convolver(self, Q_index: int): + """Initialize and return a Convolution object for the given Q + index. + """ + if self.sample_model is None or self.resolution_model is None: + raise ValueError("Both sample_model and resolution_model must be defined.") + + sample_components = self.sample_model._component_collections[Q_index] + resolution_components = self.resolution_model._component_collections[Q_index] + energy = self.energy + convolver = Convolution( + sample_components=sample_components, + resolution_components=resolution_components, + energy=energy, + temperature=self.temperature, + ) + return convolver + + def _create_model_data_group(self, individual_components=True) -> sc.DataArray: + """Create a Scipp DataArray representing the model over all Q + and energy values. + """ + if self.Q is None or self.energy is None: + raise ValueError("Q and energy must be defined in the experiment.") + + model_data = [] + for Q_index in range(len(self.Q)): + model_at_Q = self.calculate(Q_index) + model_data.append(model_at_Q) + + model_data_array = sc.DataArray( + data=sc.array(dims=["Q", "energy"], values=model_data), + coords={ + "Q": self.Q, + "energy": self.energy, + }, + ) + model_group = sc.DataGroup({"Model": model_data_array}) + + if individual_components: + components = self.calculate_individual_components_all_Q() + for Q_index, (sample_comps, background_comps) in enumerate(components): + for samp_index, samp_comp in enumerate(sample_comps): + model_data_array[samp_comp.display_name] = sc.zeros_like( + model_data_array.data + ) + model_data_array[samp_comp.display_name].data[ + Q_index, : + ] = samp_comp + for back_index, back_comp in enumerate(background_comps): + model_data_array[back_comp.display_name] = sc.zeros_like( + model_data_array.data + ) + model_data_array[back_comp.display_name].data[ + Q_index, : + ] = back_comp + + model_data_array = model_data_array + model_group # WRONG BUT LINT + return model_data_array diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index b27fed1e..2a0b554c 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -3,20 +3,17 @@ import numpy as np -import scipp as sc -from easyscience.base_classes.model_base import ModelBase as EasyScienceModelBase from easyscience.fitting.fitter import Fitter as EasyScienceFitter from easyscience.variable import DescriptorNumber -from easyscience.variable import Parameter +from easydynamics.analysis.analysis_base import AnalysisBase from easydynamics.convolution import Convolution from easydynamics.experiment import Experiment from easydynamics.sample_model import InstrumentModel -from easydynamics.sample_model import ResolutionModel from easydynamics.sample_model import SampleModel -class Analysis1d(EasyScienceModelBase): +class Analysis1d(AnalysisBase): """For analysing data.""" def __init__( @@ -28,31 +25,13 @@ def __init__( instrument_model: InstrumentModel | None = None, Q_index: int | None = None, ): - super().__init__(display_name=display_name, unique_name=unique_name) - - if experiment is not None and not isinstance(experiment, Experiment): - raise TypeError("experiment must be an instance of Experiment or None.") - - self._experiment = experiment - - if sample_model is not None and not isinstance(sample_model, SampleModel): - raise TypeError("sample_model must be an instance of SampleModel or None.") - sample_model.Q = self.Q - self._sample_model = sample_model - - if instrument_model is not None and not isinstance( - instrument_model, InstrumentModel - ): - raise TypeError( - "instrument_model must be an instance of InstrumentModel or None." - ) - if instrument_model is None: - self._instrument_model = InstrumentModel() - else: - self._instrument_model = instrument_model - - self._convolvers = [None] * (len(self.Q) if self.Q is not None else 0) - self._update_models() + super().__init__( + display_name=display_name, + unique_name=unique_name, + experiment=experiment, + sample_model=sample_model, + instrument_model=instrument_model, + ) if Q_index is not None: if ( @@ -63,122 +42,12 @@ def __init__( raise ValueError("Q_index must be a valid index for the Q values.") self._Q_index = Q_index + self._fit_result = None + ############# # Properties ############# - @property - def experiment(self) -> Experiment | None: - """The Experiment associated with this Analysis.""" - return self._experiment - - @experiment.setter - def experiment(self, value: Experiment | None) -> None: - if value is not None and not isinstance(value, Experiment): - raise TypeError("experiment must be an instance of Experiment or None.") - self._experiment = value - self._update_models() - - @property - def sample_model(self) -> SampleModel | None: - """The SampleModel associated with this Analysis.""" - return self._sample_model - - @sample_model.setter - def sample_model(self, value: SampleModel | None) -> None: - if value is not None and not isinstance(value, SampleModel): - raise TypeError("sample_model must be an instance of SampleModel or None.") - self._sample_model = value - self._update_models() - - @property - def resolution_model(self) -> ResolutionModel | None: - """The ResolutionModel associated with this Analysis.""" - return self._resolution_model - - @resolution_model.setter - def resolution_model(self, value: ResolutionModel | None) -> None: - if value is not None and not isinstance(value, ResolutionModel): - raise TypeError( - "resolution_model must be an instance of ResolutionModel or None." - ) - self._resolution_model = value - self._update_models() - - @property - def Q(self) -> sc.Variable | None: - """The Q values from the associated Experiment, if available.""" - if self.experiment is not None: - return self.experiment.Q - return None - - @Q.setter - def Q(self, value) -> None: - """Q is a read-only property derived from the Experiment.""" - raise AttributeError("Q is a read-only property derived from the Experiment.") - - @property - def energy(self) -> sc.Variable | None: - """The energy values from the associated Experiment, if - available. - """ - if self.experiment is not None: - return self.experiment.energy - return None - - @energy.setter - def energy(self, value) -> None: - """Energy is a read-only property derived from the - Experiment. - """ - raise AttributeError( - "energy is a read-only property derived from the Experiment." - ) - - @property - def temperature(self) -> Parameter | None: - """The temperature from the associated Experiment, if - available. - """ - return self.sample_model.temperature if self.sample_model is not None else None - - @temperature.setter - def temperature(self, value) -> None: - """Temperature is a read-only property derived from the - Experiment. - """ - raise AttributeError( - "temperature is a read-only property derived from the sample model." - ) - - @property - def energy_offset(self) -> list[Parameter] | None: - """Get the energy offsets for each Q value.""" - return self._energy_offset - - @energy_offset.setter - def energy_offset(self, offsets: list[Parameter] | None) -> None: - """Set the energy offsets for each Q value. - - Args: - offsets (list[Parameter] | None): The list of energy - offsets. - Raises: - TypeError: If offsets is not a list of Parameters or - None. - """ - if offsets is not None: - if len(offsets) != len(self.Q): - raise ValueError( - "energy_offset list length must match number of Q values." - ) - for offset in offsets: - if not isinstance(offset, Parameter): - raise TypeError( - "Each energy_offset must be an instance of Parameter." - ) - self._energy_offset = offsets - @property def Q_index(self) -> int | None: """Get the Q index for single Q analysis.""" @@ -212,32 +81,37 @@ def calculate(self, energy: float | None = None) -> np.ndarray: Returns: sc.DataArray: The calculated model prediction. """ - Q_index = self.Q_index - if Q_index is None: - raise ValueError("Q_index must be set to calculate the model.") + Q_index = self._require_Q_index() if energy is None: energy = self.energy.values # TODO: handle units properly - energy = energy - self.energy_offset[Q_index].value - if self.sample_model is None: - sample_intensity = np.zeros_like(energy) - else: - if self.resolution_model is None: - sample_intensity = self.sample_model._component_collections[ - Q_index - ].evaluate(energy) - else: - convolver = self._convolvers[Q_index] - sample_intensity = convolver.convolution() - - if self.background_model is None: - background_intensity = np.zeros_like(energy) - else: - background_intensity = self.background_model._component_collections[ - Q_index - ].evaluate(energy) + + energy_offset = self.instrument_model.get_energy_offset_at_Q(Q_index).value + + # Sample + sample_components = self.sample_model.get_component_collection(Q_index) + resolution_components = ( + self.instrument_model.resolution_model.get_component_collection(Q_index) + ) + + sample_intensity = self._evaluate_sample( + sample_components=sample_components, + resolution_components=resolution_components, + energy=energy, + energy_offset=energy_offset, + ) + + # Background + background_component_collection = ( + self.instrument_model.background_model.get_component_collection(Q_index) + ) + background_intensity = self._evaluate_background( + background_components=background_component_collection, + energy=energy, + energy_offset=energy_offset, + ) sample_plus_background = sample_intensity + background_intensity @@ -245,51 +119,65 @@ def calculate(self, energy: float | None = None) -> np.ndarray: def calculate_individual_components( self, - ) -> tuple[list[np.ndarray], list[np.ndarray]]: - """Calculate the model prediction for a given Q index for each - individual component. + energy: float | None = None, + ) -> np.ndarray: + """Calculate the model prediction for a given Q index. Args: - Q_index (int): The index of the Q value to calculate the - model for. + energy (float): The energy value to calculate the model for. Returns: - list[np.ndarray]: The calculated model predictions for each - individual component. + sc.DataArray: The calculated model prediction. """ - sample_results = [] - background_results = [] - Q_index = self.Q_index - if Q_index is None: - raise ValueError("Q_index must be set to calculate the model.") - - if self.sample_model is not None: - # Calculate sample components - for component in self.sample_model._component_collections[ - Q_index - ]._components: - if self.resolution_model is None: - component_intensity = component.evaluate(self.energy) - else: - convolver = Convolution( - sample_components=component, - resolution_components=self.resolution_model._component_collections[ - Q_index - ], - energy=self.energy, - temperature=self.temperature, - ) - component_intensity = convolver.convolution() - sample_results.append(component_intensity) - - if self.background_model is not None: - # Calculate background components - for component in self.background_model._component_collections[ - Q_index - ]._components: - component_intensity = component.evaluate(self.energy) - background_results.append(component_intensity) - - return sample_results, background_results + Q_index = self._require_Q_index() + + if energy is None: + energy = self.energy.values + + # TODO: handle units properly + + energy_offset = self.instrument_model.get_energy_offset_at_Q(Q_index).value + + # Sample. Convolve with resolution if resolution components are + # present, otherwise just evaluate sample components one by one + # to get individual contributions. + sample_components = self.sample_model.get_component_collection(Q_index) + + resolution_components = ( + self.instrument_model.resolution_model.get_component_collection(Q_index) + ) + + if sample_components.is_empty: + sample_intensity = [np.zeros_like(energy)] + else: + sample_intensity = [] + for component in sample_components.components: + component_intensity = self._evaluate_sample_component( + component=component, + resolution_components=resolution_components, + energy=energy, + energy_offset=energy_offset, + ) + sample_intensity.append(component_intensity) + + # Background. Evaluate each background component separately to + # get individual contributions. + background_components = ( + self.instrument_model.background_model.get_component_collection(Q_index) + ) + + if background_components.is_empty: + background_intensity = [np.zeros_like(energy)] + else: + background_intensity = [] + for component in background_components.components: + component_intensity = self._evaluate_background_component( + component=component, + energy=energy, + energy_offset=energy_offset, + ) + background_intensity.append(component_intensity) + + return sample_intensity, background_intensity def fit(self): """Fit the model to the experimental data for a given Q index. @@ -301,9 +189,7 @@ def fit(self): if self._experiment is None: raise ValueError("No experiment is associated with this Analysis.") - Q_index = self.Q_index - if Q_index is None: - raise ValueError("Q_index must be set to perform the fit.") + Q_index = self._require_Q_index() data = self.experiment.data["Q", Q_index] x = data.coords["energy"].values @@ -322,13 +208,14 @@ def fit_func(x_vals): fit_result = fitter.fit(x=x, y=y, weights=1.0 / e) # Store result - self.fit_result = fit_result + self._fit_result = fit_result return fit_result def plot_data_and_model( self, plot_individual_components: bool = True, + add_background: bool = True, ) -> None: """Plot the experimental data and the model prediction. @@ -341,9 +228,7 @@ def plot_data_and_model( import matplotlib.pyplot as plt - Q_index = self.Q_index - if Q_index is None: - raise ValueError("Q_index must be set to plot the data and model.") + Q_index = self._require_Q_index() if self.experiment is None or self.experiment.data is None: raise ValueError("Experiment data is not available for plotting.") data = self.experiment.data["Q", Q_index] @@ -361,6 +246,9 @@ def plot_data_and_model( plt.plot(energy, model, label="Model", color="red") if plot_individual_components: sample_comps, background_comps = self.calculate_individual_components() + if add_background: + background = sum(background_comps) + sample_comps = [comp + background for comp in sample_comps] for i, comp in enumerate(sample_comps): plt.plot( energy, @@ -380,20 +268,6 @@ def plot_data_and_model( plt.title(f"Data and Model at Q index {Q_index}") plt.legend() plt.show() - # model_data_array = self._create_model_data_group( - # individual_components=plot_individual_components ) if - # self.experiment is None or self.experiment.data is None: raise - # ValueError("Experiment data is not available for plotting.") - - # from IPython.display import display - - # fig = pp.slicer( - # {"Data": self.experiment.data, "Model": model_data_array}, - # color={"Data": "black", "Model": "red"}, - # linestyle={"Data": "none", "Model": "solid"}, - # marker={"Data": "o", "Model": "None"}, - # ) - # display(fig) def get_all_variables(self) -> list[DescriptorNumber]: """Get all variables used in the analysis. @@ -401,97 +275,67 @@ def get_all_variables(self) -> list[DescriptorNumber]: Returns: List[Descriptor]: A list of all variables. """ - variables = [] - if self.sample_model is not None: - variables.extend( - self.sample_model._component_collections[ - self.Q_index - ].get_all_variables() - ) - if self.resolution_model is not None: - variables.extend( - self.resolution_model._component_collections[ - self.Q_index - ].get_all_variables() - ) - if self.background_model is not None: - variables.extend( - self.background_model._component_collections[ - self.Q_index - ].get_all_variables() - ) - variables.append(self.energy_offset[self.Q_index]) - # TODO temperature and diffusion + variables = self.sample_model.get_all_variables(Q_index=self.Q_index) + + variables.extend(self.instrument_model.get_all_variables(Q_index=self.Q_index)) + + if self._extra_parameters != []: + variables.extend(self._extra_parameters) + return variables ############# # Private methods ############# + def _evaluate_sample( + self, + sample_components, + resolution_components, + energy, + energy_offset, + ): + if resolution_components.is_empty: + return sample_components.evaluate(energy - energy_offset) + convolver = self._convolvers[self._require_Q_index()] + return convolver.convolution() - def _update_models(self): - """Update models based on the current experiment.""" - if self.experiment is None: - return - - for Q_index in range(len(self.Q)): - self._convolvers[Q_index] = self._create_convolver(Q_index) - - def _create_convolver(self, Q_index: int): - """Initialize and return a Convolution object for the given Q - index. - """ - if self.sample_model is None or self.resolution_model is None: - raise ValueError("Both sample_model and resolution_model must be defined.") - - sample_components = self.sample_model._component_collections[Q_index] - resolution_components = self.resolution_model._component_collections[Q_index] - energy = self.energy + def _evaluate_sample_component( + self, + component, + resolution_components, + energy, + energy_offset, + ): + if resolution_components.is_empty: + return component.evaluate(energy - energy_offset) convolver = Convolution( - sample_components=sample_components, + sample_components=component, resolution_components=resolution_components, energy=energy, temperature=self.temperature, + energy_offset=energy_offset, ) - return convolver + return convolver.convolution() - def _create_model_data_group(self, individual_components=True) -> sc.DataArray: - """Create a Scipp DataArray representing the model over all Q - and energy values. - """ - if self.Q is None or self.energy is None: - raise ValueError("Q and energy must be defined in the experiment.") - - model_data = [] - for Q_index in range(len(self.Q)): - model_at_Q = self.calculate(Q_index) - model_data.append(model_at_Q) - - model_data_array = sc.DataArray( - data=sc.array(dims=["Q", "energy"], values=model_data), - coords={ - "Q": self.Q, - "energy": self.energy, - }, - ) - model_group = sc.DataGroup({"Model": model_data_array}) - - if individual_components: - components = self.calculate_individual_components_all_Q() - for Q_index, (sample_comps, background_comps) in enumerate(components): - for samp_index, samp_comp in enumerate(sample_comps): - model_data_array[samp_comp.display_name] = sc.zeros_like( - model_data_array.data - ) - model_data_array[samp_comp.display_name].data[ - Q_index, : - ] = samp_comp - for back_index, back_comp in enumerate(background_comps): - model_data_array[back_comp.display_name] = sc.zeros_like( - model_data_array.data - ) - model_data_array[back_comp.display_name].data[ - Q_index, : - ] = back_comp - - model_data_array = model_data_array + model_group # WRONG BUT LINT - return model_data_array + def _evaluate_background( + self, + background_components, + energy, + energy_offset, + ): + if background_components.is_empty: + return np.zeros_like(energy) + return background_components.evaluate(energy - energy_offset) + + def _evaluate_background_component( + self, + component, + energy, + energy_offset, + ): + return component.evaluate(energy - energy_offset) + + def _require_Q_index(self) -> int: + if self._Q_index is None: + raise ValueError("Q_index must be set.") + return self._Q_index diff --git a/src/easydynamics/analysis/analysis_base.py b/src/easydynamics/analysis/analysis_base.py index 5d965cae..81d0c250 100644 --- a/src/easydynamics/analysis/analysis_base.py +++ b/src/easydynamics/analysis/analysis_base.py @@ -12,7 +12,7 @@ from easydynamics.sample_model import SampleModel -class Analysis1Base(EasyScienceModelBase): +class AnalysisBase(EasyScienceModelBase): """For analysing data.""" def __init__( @@ -22,6 +22,7 @@ def __init__( experiment: Experiment | None = None, sample_model: SampleModel | None = None, instrument_model: InstrumentModel | None = None, + extra_parameters: Parameter | list[Parameter] | None = None, ): super().__init__(display_name=display_name, unique_name=unique_name) @@ -48,8 +49,22 @@ def __init__( "instrument_model must be an instance of InstrumentModel or None." ) + if extra_parameters is not None: + if isinstance(extra_parameters, Parameter): + self._extra_parameters = [extra_parameters] + elif isinstance(extra_parameters, list) and all( + isinstance(p, Parameter) for p in extra_parameters + ): + self._extra_parameters = extra_parameters + else: + raise TypeError( + "extra_parameters must be a Parameter or a list of Parameters." + ) + else: + self._extra_parameters = [] + self._convolvers = [None] * (len(self.Q) if self.Q is not None else 0) - self._update_models() + self._on_experiment_changed() ############# # Properties @@ -146,44 +161,52 @@ def temperature(self, value) -> None: # Private methods ############# - def _on_experiment_changed(self): - pass + def _on_experiment_changed(self) -> None: + self._sample_model.Q = self.Q + self._instrument_model.Q = self.Q + self._create_convolvers() - def _on_sample_model_changed(self): - pass + def _on_sample_model_changed(self) -> None: + self._sample_model.Q = self.Q + self._create_convolvers() - def _on_instrument_model_changed(self): - pass + def _on_instrument_model_changed(self) -> None: + self._instrument_model.Q = self.Q + self._create_convolvers() - # def _update_models(self): - # """Update models based on the current experiment.""" - # if self.experiment is None: - # return + def _create_convolvers(self) -> None: + """Create Convolution objects for each Q value.""" + num_Q = len(self.Q) if self.Q is not None else 0 + self._convolvers = [self._create_convolver(i) for i in range(num_Q)] - # for Q_index in range(len(self.Q)): - # self._convolvers[Q_index] = self._create_convolver(Q_index) - - def _create_convolver(self, Q_index: int): + def _create_convolver(self, Q_index: int) -> Convolution: """Initialize and return a Convolution object for the given Q index. """ sample_components = self.sample_model._component_collections[Q_index] if sample_components == []: - raise ValueError(f"Sample model has no components at Q index {Q_index}.") + return Convolution() resolution_components = ( self.instrument_model.resolution_model._component_collections[Q_index] ) if resolution_components == []: - raise ValueError( - f"Resolution model has no components at Q index {Q_index}." - ) + return Convolution() energy = self.energy + # TODO: allow convolution options to be set. convolver = Convolution( sample_components=sample_components, resolution_components=resolution_components, energy=energy, temperature=self.temperature, + energy_offset=self.instrument_model._energy_offsets[Q_index], ) return convolver + + ############# + # Dunder methods + ############# + + def __repr__(self) -> str: + return f"AnalysisBase(display_name={self.display_name}, unique_name={self.unique_name})" diff --git a/src/easydynamics/sample_model/component_collection.py b/src/easydynamics/sample_model/component_collection.py index 586a6649..a0b1e668 100644 --- a/src/easydynamics/sample_model/component_collection.py +++ b/src/easydynamics/sample_model/component_collection.py @@ -31,8 +31,8 @@ class ComponentCollection(ModelBase): def __init__( self, - unit: str | sc.Unit = 'meV', - display_name: str = 'MyComponentCollection', + unit: str | sc.Unit = "meV", + display_name: str = "MyComponentCollection", unique_name: str | None = None, components: List[ModelComponent] | None = None, ): @@ -54,7 +54,7 @@ def __init__( if unit is not None and not isinstance(unit, (str, sc.Unit)): raise TypeError( - f'unit must be None, a string, or a scipp Unit, got {type(unit).__name__}' + f"unit must be None, a string, or a scipp Unit, got {type(unit).__name__}" ) self._unit = unit self._components = [] @@ -62,31 +62,37 @@ def __init__( # Add initial components if provided. Used for serialization. if components is not None: if not isinstance(components, list): - raise TypeError('components must be a list of ModelComponent instances.') + raise TypeError( + "components must be a list of ModelComponent instances." + ) for comp in components: self.append_component(comp) - def append_component(self, component: ModelComponent | 'ComponentCollection') -> None: + def append_component( + self, component: ModelComponent | "ComponentCollection" + ) -> None: match component: case ModelComponent(): components = (component,) case ComponentCollection(components=components): pass case _: - raise TypeError('Component must be a ModelComponent or ComponentCollection.') + raise TypeError( + "Component must be a ModelComponent or ComponentCollection." + ) for comp in components: if comp in self._components: raise ValueError( f"Component '{comp.unique_name}' is already in the collection. " - f'Existing components: {self.list_component_names()}' + f"Existing components: {self.list_component_names()}" ) self._components.append(comp) def remove_component(self, unique_name: str) -> None: if not isinstance(unique_name, str): - raise TypeError('Component name must be a string.') + raise TypeError("Component name must be a string.") for comp in self._components: if comp.unique_name == unique_name: @@ -95,8 +101,8 @@ def remove_component(self, unique_name: str) -> None: raise KeyError( f"No component named '{unique_name}' exists. " - f'Did you accidentally use the display_name? ' - f'Here is a list of the components in the collection: {self.list_component_names()}' + f"Did you accidentally use the display_name? " + f"Here is a list of the components in the collection: {self.list_component_names()}" ) @property @@ -106,16 +112,27 @@ def components(self) -> list[ModelComponent]: @components.setter def components(self, components: List[ModelComponent]) -> None: if not isinstance(components, list): - raise TypeError('components must be a list of ModelComponent instances.') + raise TypeError("components must be a list of ModelComponent instances.") for comp in components: if not isinstance(comp, ModelComponent): raise TypeError( - 'All items in components must be instances of ModelComponent. ' - f'Got {type(comp).__name__} instead.' + "All items in components must be instances of ModelComponent. " + f"Got {type(comp).__name__} instead." ) self._components = components + @property + def is_empty(self) -> bool: + return not self._components + + @is_empty.setter + def is_empty(self, value: bool) -> None: + raise AttributeError( + "is_empty is a read-only property that indicates " + "whether the collection has components." + ) + def list_component_names(self) -> List[str]: """List the names of all components in the model. @@ -135,27 +152,27 @@ def normalize_area(self) -> None: # Useful for convolutions. """Normalize the areas of all components so they sum to 1.""" if not self.components: - raise ValueError('No components in the model to normalize.') + raise ValueError("No components in the model to normalize.") area_params = [] - total_area = Parameter(name='total_area', value=0.0, unit=self._unit) + total_area = Parameter(name="total_area", value=0.0, unit=self._unit) for component in self.components: - if hasattr(component, 'area'): + if hasattr(component, "area"): area_params.append(component.area) total_area += component.area else: warnings.warn( f"Component '{component.unique_name}' does not have an 'area' attribute " - f'and will be skipped in normalization.', + f"and will be skipped in normalization.", UserWarning, ) if total_area.value == 0: - raise ValueError('Total area is zero; cannot normalize.') + raise ValueError("Total area is zero; cannot normalize.") if not np.isfinite(total_area.value): - raise ValueError('Total area is not finite; cannot normalize.') + raise ValueError("Total area is not finite; cannot normalize.") for param in area_params: param.value /= total_area.value @@ -167,7 +184,11 @@ def get_all_variables(self) -> list[DescriptorBase]: List[Parameter]: List of parameters in the component. """ - return [var for component in self.components for var in component.get_all_variables()] + return [ + var + for component in self.components + for var in component.get_all_variables() + ] @property def unit(self) -> str | sc.Unit: @@ -183,8 +204,8 @@ def unit(self) -> str | sc.Unit: def unit(self, unit_str: str) -> None: raise AttributeError( ( - f'Unit is read-only. Use convert_unit to change the unit between allowed types ' - f'or create a new {self.__class__.__name__} with the desired unit.' + f"Unit is read-only. Use convert_unit to change the unit between allowed types " + f"or create a new {self.__class__.__name__} with the desired unit." ) ) # noqa: E501 @@ -208,7 +229,9 @@ def convert_unit(self, unit: str | sc.Unit) -> None: pass # Best effort rollback raise e - def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: + def evaluate( + self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray + ) -> np.ndarray: """Evaluate the sum of all components. Parameters @@ -246,11 +269,13 @@ def evaluate_component( Evaluated values for the specified component. """ if not self.components: - raise ValueError('No components in the model to evaluate.') + raise ValueError("No components in the model to evaluate.") if not isinstance(unique_name, str): raise TypeError( - (f'Component unique name must be a string, got {type(unique_name)} instead.') + ( + f"Component unique name must be a string, got {type(unique_name)} instead." + ) ) matches = [comp for comp in self.components if comp.unique_name == unique_name] @@ -303,6 +328,8 @@ def __repr__(self) -> str: ------- str """ - comp_names = ', '.join(c.unique_name for c in self.components) or 'No components' + comp_names = ( + ", ".join(c.unique_name for c in self.components) or "No components" + ) return f"" diff --git a/src/easydynamics/sample_model/instrument_model.py b/src/easydynamics/sample_model/instrument_model.py index bef6bd92..4c767331 100644 --- a/src/easydynamics/sample_model/instrument_model.py +++ b/src/easydynamics/sample_model/instrument_model.py @@ -48,13 +48,13 @@ class InstrumentModel(NewBase): def __init__( self, - display_name: str = 'MyInstrumentModel', + display_name: str = "MyInstrumentModel", unique_name: str | None = None, Q: Q_type | None = None, resolution_model: ResolutionModel | None = None, background_model: BackgroundModel | None = None, energy_offset: Numeric | None = None, - unit: str | sc.Unit = 'meV', + unit: str | sc.Unit = "meV", ): super().__init__( display_name=display_name, @@ -68,8 +68,8 @@ def __init__( else: if not isinstance(resolution_model, ResolutionModel): raise TypeError( - f'resolution_model must be a ResolutionModel or None, ' - f'got {type(resolution_model).__name__}' + f"resolution_model must be a ResolutionModel or None, " + f"got {type(resolution_model).__name__}" ) self._resolution_model = resolution_model @@ -78,8 +78,8 @@ def __init__( else: if not isinstance(background_model, BackgroundModel): raise TypeError( - f'background_model must be a BackgroundModel or None, ' - f'got {type(background_model).__name__}' + f"background_model must be a BackgroundModel or None, " + f"got {type(background_model).__name__}" ) self._background_model = background_model @@ -87,10 +87,10 @@ def __init__( energy_offset = 0.0 if not isinstance(energy_offset, Numeric): - raise TypeError('energy_offset must be a number or None') + raise TypeError("energy_offset must be a number or None") self._energy_offset = Parameter( - name='energy_offset', + name="energy_offset", value=float(energy_offset), unit=self.unit, fixed=False, @@ -112,7 +112,7 @@ def resolution_model(self, value: ResolutionModel): """Set the resolution model of the instrument.""" if not isinstance(value, ResolutionModel): raise TypeError( - f'resolution_model must be a ResolutionModel, got {type(value).__name__}' + f"resolution_model must be a ResolutionModel, got {type(value).__name__}" ) self._resolution_model = value self._on_resolution_model_change() @@ -127,7 +127,7 @@ def background_model(self, value: BackgroundModel): """Set the background model of the instrument.""" if not isinstance(value, BackgroundModel): raise TypeError( - f'background_model must be a BackgroundModel, got {type(value).__name__}' + f"background_model must be a BackgroundModel, got {type(value).__name__}" ) self._background_model = value self._on_background_model_change() @@ -157,8 +157,8 @@ def unit(self) -> sc.Unit: def unit(self, unit_str: str) -> None: raise AttributeError( ( - f'Unit is read-only. Use convert_unit to change the unit between allowed types ' - f'or create a new {self.__class__.__name__} with the desired unit.' + f"Unit is read-only. Use convert_unit to change the unit between allowed types " + f"or create a new {self.__class__.__name__} with the desired unit." ) ) # noqa: E501 @@ -184,7 +184,9 @@ def energy_offset(self, value: Numeric): If value is not a number. """ if not isinstance(value, Numeric): - raise TypeError(f'energy_offset must be a number, got {type(value).__name__}') + raise TypeError( + f"energy_offset must be a number, got {type(value).__name__}" + ) self._energy_offset.value = value self._on_energy_offset_change() @@ -208,7 +210,7 @@ def convert_unit(self, unit_str: str | sc.Unit) -> None: """ unit = _validate_unit(unit_str) if unit is None: - raise ValueError('unit_str must be a valid unit string or scipp Unit') + raise ValueError("unit_str must be a valid unit string or scipp Unit") self._background_model.convert_unit(unit) self._resolution_model.convert_unit(unit) @@ -238,10 +240,12 @@ def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: variables = [self._energy_offsets[i] for i in range(len(self._Q))] else: if not isinstance(Q_index, int): - raise TypeError(f'Q_index must be an int or None, got {type(Q_index).__name__}') + raise TypeError( + f"Q_index must be an int or None, got {type(Q_index).__name__}" + ) if Q_index < 0 or Q_index >= len(self._Q): raise IndexError( - f'Q_index {Q_index} is out of bounds for Q of length {len(self._Q)}' + f"Q_index {Q_index} is out of bounds for Q of length {len(self._Q)}" ) variables = [self._energy_offsets[Q_index]] @@ -258,6 +262,34 @@ def free_resolution_parameters(self) -> None: """Free all parameters in the resolution model.""" self.resolution_model.free_all_parameters() + def get_energy_offset_at_Q(self, Q_index: int) -> Parameter: + """Get the energy offset Parameter at a specific Q index. + + Parameters + ---------- + Q_index : int + The index of the Q value to get the energy offset for. + + Returns + ------- + Parameter + The energy offset Parameter at the specified Q index. + + Raises + ------ + IndexError + If Q_index is out of bounds. + """ + if self._Q is None: + raise ValueError("No Q values are set in the InstrumentModel.") + + if Q_index < 0 or Q_index >= len(self._Q): + raise IndexError( + f"Q_index {Q_index} is out of bounds for Q of length {len(self._Q)}" + ) + + return self._energy_offsets[Q_index] + # -------------------------------------------------------------- # Private methods # -------------------------------------------------------------- @@ -295,11 +327,11 @@ def _on_background_model_change(self) -> None: def __repr__(self): return ( - f'{self.__class__.__name__}(' - f'unique_name={self.unique_name!r}, ' - f'unit={self.unit}, ' - f'Q_len={None if self._Q is None else len(self._Q)}, ' - f'resolution_model={self._resolution_model!r}, ' - f'background_model={self._background_model!r}' - f')' + f"{self.__class__.__name__}(" + f"unique_name={self.unique_name!r}, " + f"unit={self.unit}, " + f"Q_len={None if self._Q is None else len(self._Q)}, " + f"resolution_model={self._resolution_model!r}, " + f"background_model={self._background_model!r}" + f")" ) diff --git a/src/easydynamics/sample_model/model_base.py b/src/easydynamics/sample_model/model_base.py index b6b8bcdd..85b74d9f 100644 --- a/src/easydynamics/sample_model/model_base.py +++ b/src/easydynamics/sample_model/model_base.py @@ -43,9 +43,9 @@ class ModelBase(EasyScienceModelBase): def __init__( self, - display_name: str = 'MyModelBase', + display_name: str = "MyModelBase", unique_name: str | None = None, - unit: str | sc.Unit | None = 'meV', + unit: str | sc.Unit | None = "meV", components: ModelComponent | ComponentCollection | None = None, Q: Q_type | None = None, ): @@ -60,8 +60,8 @@ def __init__( components, (ModelComponent, ComponentCollection) ): raise TypeError( - f'Components must be a ModelComponent, a ComponentCollection or None, ' - f'got {type(components).__name__}' + f"Components must be a ModelComponent, a ComponentCollection or None, " + f"got {type(components).__name__}" ) self._components = ComponentCollection() @@ -88,8 +88,8 @@ def evaluate( if not self._component_collections: raise ValueError( - 'No components in the model to evaluate. ' - 'Run generate_component_collections() first' + "No components in the model to evaluate. " + "Run generate_component_collections() first" ) y = [collection.evaluate(x) for collection in self._component_collections] @@ -143,8 +143,8 @@ def unit(self) -> str | sc.Unit: def unit(self, unit_str: str) -> None: raise AttributeError( ( - f'Unit is read-only. Use convert_unit to change the unit between allowed types ' - f'or create a new {self.__class__.__name__} with the desired unit.' + f"Unit is read-only. Use convert_unit to change the unit between allowed types " + f"or create a new {self.__class__.__name__} with the desired unit." ) ) # noqa: E501 @@ -178,7 +178,9 @@ def components(self) -> list[ModelComponent]: def components(self, value: ModelComponent | ComponentCollection | None) -> None: """Set the components of the SampleModel.""" if not isinstance(value, (ModelComponent, ComponentCollection, type(None))): - raise TypeError('Components must be a ModelComponent or a ComponentCollection') + raise TypeError( + "Components must be a ModelComponent or a ComponentCollection" + ) self.clear_components() if value is not None: @@ -232,15 +234,39 @@ def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: ] else: if not isinstance(Q_index, int): - raise TypeError(f'Q_index must be an int or None, got {type(Q_index).__name__}') + raise TypeError( + f"Q_index must be an int or None, got {type(Q_index).__name__}" + ) if Q_index < 0 or Q_index >= len(self._component_collections): raise IndexError( - f'Q_index {Q_index} is out of bounds for component collections ' - f'of length {len(self._component_collections)}' + f"Q_index {Q_index} is out of bounds for component collections " + f"of length {len(self._component_collections)}" ) all_vars = self._component_collections[Q_index].get_all_variables() return all_vars + def get_component_collection(self, Q_index: int) -> ComponentCollection: + """Get the ComponentCollection at the given Q index. + + Parameters + ---------- + Q_index : int + The index of the desired ComponentCollection. + + Returns + ------- + ComponentCollection + The ComponentCollection at the specified Q index. + """ + if not isinstance(Q_index, int): + raise TypeError(f"Q_index must be an int, got {type(Q_index).__name__}") + if Q_index < 0 or Q_index >= len(self._component_collections): + raise IndexError( + f"Q_index {Q_index} is out of bounds for component collections " + f"of length {len(self._component_collections)}" + ) + return self._component_collections[Q_index] + # ------------------------------------------------------------------ # Private methods # ------------------------------------------------------------------ @@ -250,7 +276,9 @@ def _generate_component_collections(self) -> None: # TODO regenerate automatically if Q or components have changed if self._Q is None: - warnings.warn('Q is not set. No component collections generated', UserWarning) + warnings.warn( + "Q is not set. No component collections generated", UserWarning + ) self._component_collections = [] return @@ -276,6 +304,6 @@ def _on_components_change(self) -> None: def __repr__(self): return ( - f'{self.__class__.__name__}(unique_name={self.unique_name}, ' - f'unit={self.unit}), Q = {self.Q}, components = {self.components}' + f"{self.__class__.__name__}(unique_name={self.unique_name}, " + f"unit={self.unit}), Q = {self.Q}, components = {self.components}" ) From 1c7778497fbbdf5498e5aaf907fa7f8f5902c3ff Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Sun, 8 Feb 2026 06:35:38 +0100 Subject: [PATCH 06/27] multiple parameters with same unique_name????? --- docs/docs/tutorials/analysis.ipynb | 384 ++++++++++++--- src/easydynamics/analysis/analysis old.py | 497 ++++++++++++++++++++ src/easydynamics/analysis/analysis.py | 514 ++++----------------- src/easydynamics/analysis/analysis1d.py | 169 +++---- src/easydynamics/analysis/analysis_base.py | 210 ++++++++- 5 files changed, 1185 insertions(+), 589 deletions(-) create mode 100644 src/easydynamics/analysis/analysis old.py diff --git a/docs/docs/tutorials/analysis.ipynb b/docs/docs/tutorials/analysis.ipynb index 83257cda..e1ea2973 100644 --- a/docs/docs/tutorials/analysis.ipynb +++ b/docs/docs/tutorials/analysis.ipynb @@ -27,12 +27,13 @@ "from easydynamics.sample_model.resolution_model import ResolutionModel\n", "from easydynamics.sample_model.sample_model import SampleModel\n", "from easydynamics.sample_model.instrument_model import InstrumentModel\n", + "from easydynamics.analysis.analysis import Analysis\n", "%matplotlib widget" ] }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "8deca9b6", "metadata": {}, "outputs": [], @@ -46,27 +47,150 @@ "execution_count": null, "id": "41f842f0", "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "C:\\Users\\henrikjacobsen3\\Documents\\easyScience\\dynamics-lib\\src\\easydynamics\\sample_model\\model_base.py:253: UserWarning: Q is not set. No component collections generated\n", - " warnings.warn('Q is not set. No component collections generated', UserWarning)\n" - ] - }, - { - "ename": "TypeError", - "evalue": "Analysis1d.__init__() got an unexpected keyword argument 'resolution_model'", - "output_type": "error", - "traceback": [ - "\u001b[31m---------------------------------------------------------------------------\u001b[39m", - "\u001b[31mTypeError\u001b[39m Traceback (most recent call last)", - "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[3]\u001b[39m\u001b[32m, line 25\u001b[39m\n\u001b[32m 20\u001b[39m resolution_model = ResolutionModel(components=res_gauss)\n\u001b[32m 23\u001b[39m background_model = BackgroundModel(components=Polynomial(coefficients=[\u001b[32m0.001\u001b[39m]))\n\u001b[32m---> \u001b[39m\u001b[32m25\u001b[39m my_analysis = \u001b[43mAnalysis1d\u001b[49m\u001b[43m(\u001b[49m\n\u001b[32m 26\u001b[39m \u001b[43m \u001b[49m\u001b[43mexperiment\u001b[49m\u001b[43m=\u001b[49m\u001b[43mvanadium_experiment\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m 27\u001b[39m \u001b[43m \u001b[49m\u001b[43msample_model\u001b[49m\u001b[43m=\u001b[49m\u001b[43msample_model\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m 28\u001b[39m \u001b[43m \u001b[49m\u001b[43mresolution_model\u001b[49m\u001b[43m=\u001b[49m\u001b[43mresolution_model\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m 29\u001b[39m \u001b[43m \u001b[49m\u001b[43mbackground_model\u001b[49m\u001b[43m=\u001b[49m\u001b[43mbackground_model\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m 30\u001b[39m \u001b[43m \u001b[49m\u001b[43mQ_index\u001b[49m\u001b[43m=\u001b[49m\u001b[32;43m5\u001b[39;49m\u001b[43m,\u001b[49m\n\u001b[32m 31\u001b[39m \u001b[43m)\u001b[49m\n\u001b[32m 33\u001b[39m my_analysis._update_models()\n\u001b[32m 36\u001b[39m values = my_analysis.calculate()\n", - "\u001b[31mTypeError\u001b[39m: Analysis1d.__init__() got an unexpected keyword argument 'resolution_model'" - ] - } - ], + "outputs": [], + "source": [ + "# # Create a diffusion_model and components for the SampleModel\n", + "\n", + "# # Creating components\n", + "# component_collection = ComponentCollection()\n", + "# delta_function = DeltaFunction(display_name='DeltaFunction', area=1)\n", + "# gaussian = Gaussian(display_name='Gaussian', width=0.1, center=0.5, area=0.5)\n", + "\n", + "# # Adding components to the component collection\n", + "# component_collection.append_component(delta_function)\n", + "\n", + "\n", + "# sample_model = SampleModel(\n", + "# components=component_collection,\n", + "# unit='meV',\n", + "# display_name='MySampleModel',\n", + "# )\n", + "\n", + "# res_gauss = Gaussian(width=0.1)\n", + "# res_gauss.area.fixed = True\n", + "# resolution_model = ResolutionModel(components=res_gauss)\n", + "\n", + "\n", + "# background_model = BackgroundModel(components=Polynomial(coefficients=[0.001]))\n", + "\n", + "# instrument_model = InstrumentModel(\n", + "# resolution_model=resolution_model,\n", + "# background_model=background_model,\n", + "# )\n", + "\n", + "# my_analysis = Analysis1d(\n", + "# experiment=vanadium_experiment,\n", + "# sample_model=sample_model,\n", + "# instrument_model=instrument_model,\n", + "# Q_index=5,\n", + "# )\n", + "\n", + "\n", + "# values = my_analysis.calculate()\n", + "# sample_values, background_values = my_analysis.calculate_individual_components()\n", + "\n", + "# plt.figure()\n", + "# plt.plot(my_analysis.energy.values, values, label='Total Model')\n", + "# for component_index in range(len(sample_values)):\n", + "# plt.plot(\n", + "# my_analysis.energy.values,\n", + "# sample_values[component_index],\n", + "# label=f'Sample Component {component_index}',\n", + "# linestyle='--',\n", + "# )\n", + "\n", + "# for component_index in range(len(background_values)):\n", + "# plt.plot(\n", + "# my_analysis.energy.values,\n", + "# background_values[component_index],\n", + "# label=f'Background Component {component_index}',\n", + "# linestyle=':',\n", + "# )\n", + "# plt.xlabel('Energy (meV)')\n", + "# plt.ylabel('Intensity')\n", + "# plt.title(f'Q index: {5}')\n", + "# plt.legend()\n", + "# plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6762faba", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "02702f95", + "metadata": {}, + "outputs": [], + "source": [ + "# my_analysis.plot_data_and_model()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "70091539", + "metadata": {}, + "outputs": [], + "source": [ + "# my_analysis.fit()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2ad6384e", + "metadata": {}, + "outputs": [], + "source": [ + "# my_analysis.plot_data_and_model()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2dfb1f90", + "metadata": {}, + "outputs": [], + "source": [ + "# my_analysis.get_all_variables()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5afefbab", + "metadata": {}, + "outputs": [], + "source": [ + "# my_analysis.get_fit_parameters()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "465c0e1e", + "metadata": {}, + "outputs": [], + "source": [ + "# for Q_index in range(len(my_analysis.Q)):\n", + "# my_analysis.Q_index = Q_index\n", + "# my_analysis.fit()\n", + "# my_analysis.plot_data_and_model()\n", + "# print(my_analysis.get_fit_parameters())\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9bdeed2b", + "metadata": {}, + "outputs": [], "source": [ "# Create a diffusion_model and components for the SampleModel\n", "\n", @@ -97,81 +221,217 @@ " background_model=background_model,\n", ")\n", "\n", - "my_analysis = Analysis1d(\n", + "my_full_analysis = Analysis(\n", " experiment=vanadium_experiment,\n", " sample_model=sample_model,\n", - " resolution_model=resolution_model,\n", - " background_model=background_model,\n", - " Q_index=5,\n", + " instrument_model=instrument_model,\n", ")\n", "\n", - "my_analysis._update_models()\n", - "\n", - "\n", - "values = my_analysis.calculate()\n", - "sample_values, background_values = my_analysis.calculate_individual_components()\n", - "\n", - "plt.figure()\n", - "plt.plot(my_analysis.energy.values, values, label='Total Model')\n", - "for component_index in range(len(sample_values)):\n", - " plt.plot(\n", - " my_analysis.energy.values,\n", - " sample_values[component_index],\n", - " label=f'Sample Component {component_index}',\n", - " linestyle='--',\n", - " )\n", - "\n", - "for component_index in range(len(background_values)):\n", - " plt.plot(\n", - " my_analysis.energy.values,\n", - " background_values[component_index],\n", - " label=f'Background Component {component_index}',\n", - " linestyle=':',\n", - " )\n", - "plt.xlabel('Energy (meV)')\n", - "plt.ylabel('Intensity')\n", - "plt.title(f'Q index: {5}')\n", - "plt.legend()\n", - "plt.show()" + "# my_full_analysis._fit_all_Q_independently()\n", + "my_full_analysis._fit_all_Q_simultaneously()\n", + "for analysis_object in my_full_analysis._analysis_list:\n", + " analysis_object.plot_data_and_model()\n", + " print(analysis_object.get_fit_parameters())\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "6762faba", + "id": "0a727fc3", "metadata": {}, "outputs": [], - "source": [] + "source": [ + "for analysis_object in my_full_analysis._analysis_list:\n", + " print(analysis_object.get_fit_parameters())\n", + "\n", + "for analysis_object in my_full_analysis._analysis_list:\n", + " print(analysis_object.get_fit_parameters()[0].unique_name)\n", + "\n" + ] }, { "cell_type": "code", "execution_count": null, - "id": "02702f95", + "id": "d0ceec1d", "metadata": {}, "outputs": [], "source": [ - "my_analysis.plot_data_and_model()" + "p1=my_full_analysis._analysis_list[1].get_fit_parameters()[0]\n", + "print(p1)\n", + "print(p1.unique_name)\n", + "p2 = my_full_analysis._analysis_list[9].get_fit_parameters()[0]\n", + "print(p2)\n", + "print(p2.unique_name)\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "70091539", + "id": "d792eee3", "metadata": {}, "outputs": [], "source": [ - "my_analysis.fit()" + "\n", + "my_full_analysis.Q" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "4217d56d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Parameter_2\n", + "\n", + "Parameter_4\n", + "\n", + "Parameter_6\n", + "\n", + "Parameter_8\n", + "\n", + "Parameter_10\n", + "\n", + "Parameter_12\n", + "\n", + "Parameter_14\n", + "\n", + "Parameter_16\n", + "\n", + "Parameter_18\n", + "\n", + "Parameter_20\n", + "\n", + "Parameter_4\n", + "\n", + "Parameter_6\n", + "\n", + "Parameter_8\n", + "\n", + "Parameter_10\n", + "\n", + "Parameter_12\n", + "\n", + "Parameter_14\n", + "\n", + "Parameter_16\n", + "\n", + "Parameter_18\n", + "\n", + "Parameter_20\n", + "\n", + "Parameter_22\n", + "\n", + "Parameter_4\n", + "\n", + "Parameter_6\n", + "\n", + "Parameter_8\n", + "\n", + "Parameter_10\n", + "\n", + "Parameter_12\n", + "\n", + "Parameter_14\n", + "\n", + "Parameter_16\n", + "\n", + "Parameter_18\n", + "\n", + "Parameter_20\n", + "\n", + "Parameter_4\n", + "\n", + "Parameter_6\n", + "\n" + ] + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "from easydynamics.sample_model import ComponentCollection\n", + "from easydynamics.sample_model import DeltaFunction\n", + "from easydynamics.sample_model.model_base import ModelBase\n", + "%matplotlib widget\n", + "import numpy as np\n", + "Q=np.linspace(0.1,15,31)\n", + "component_collection = ComponentCollection()\n", + "delta_function = DeltaFunction(display_name='DeltaFunction', area=1)\n", + "\n", + "component_collection.append_component(delta_function)\n", + "\n", + "\n", + "# sample_model = SampleModel(\n", + "sample_model = ModelBase(\n", + " components=component_collection,\n", + " unit='meV',\n", + " display_name='MySampleModel',\n", + " Q=Q,\n", + ")\n", + "\n", + "\n", + "for Q_index in range(len(sample_model.Q)):\n", + " pars = sample_model.get_all_variables(Q_index=Q_index) \n", + " pars[0].value=pars[0].value+Q_index\n", + " print(pars[0].unique_name)\n", + " print(pars[0])\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "35c89ce3", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Parameter_5\n", + "\n", + "Parameter_4\n", + "\n", + "Parameter_5\n", + "\n", + "Parameter_4\n" + ] + } + ], + "source": [ + "vars2=sample_model._component_collections[1].get_all_variables()\n", + "for var in vars2:\n", + " print(var)\n", + " print(var.unique_name)\n", + "\n", + "var3=sample_model._component_collections[10].get_all_variables()\n", + "for var in var3:\n", + " print(var)\n", + " print(var.unique_name)" ] }, { "cell_type": "code", "execution_count": null, - "id": "2ad6384e", + "id": "02320e75", "metadata": {}, "outputs": [], "source": [ - "my_analysis.plot_data_and_model()" + "var." ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5451bbf3", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/src/easydynamics/analysis/analysis old.py b/src/easydynamics/analysis/analysis old.py new file mode 100644 index 00000000..9d9039ea --- /dev/null +++ b/src/easydynamics/analysis/analysis old.py @@ -0,0 +1,497 @@ +# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-License-Identifier: BSD-3-Clause + + +import numpy as np +import plopp as pp +import scipp as sc +from easyscience.base_classes.model_base import ModelBase as EasyScienceModelBase +from easyscience.fitting.fitter import Fitter as EasyScienceFitter +from easyscience.variable import Parameter + +from easydynamics.convolution import Convolution +from easydynamics.experiment import Experiment +from easydynamics.sample_model import BackgroundModel +from easydynamics.sample_model import ResolutionModel +from easydynamics.sample_model import SampleModel + + +class Analysis(EasyScienceModelBase): + """For analysing data.""" + + def __init__( + self, + display_name: str = "MyAnalysis", + unique_name: str | None = None, + experiment: Experiment | None = None, + sample_model: SampleModel | None = None, + resolution_model: ResolutionModel | None = None, + background_model: BackgroundModel | None = None, + energy_offset: None = None, + ): + + super().__init__(display_name=display_name, unique_name=unique_name) + + if experiment is not None and not isinstance(experiment, Experiment): + raise TypeError("experiment must be an instance of Experiment or None.") + + self._experiment = experiment + + if sample_model is not None and not isinstance(sample_model, SampleModel): + raise TypeError("sample_model must be an instance of SampleModel or None.") + sample_model.Q = self.Q + self._sample_model = sample_model + + if resolution_model is not None and not isinstance( + resolution_model, ResolutionModel + ): + raise TypeError( + "resolution_model must be an instance of ResolutionModel or None." + ) + resolution_model.Q = self.Q + self._resolution_model = resolution_model + + if background_model is not None and not isinstance( + background_model, BackgroundModel + ): + raise TypeError( + "background_model must be an instance of BackgroundModel or None." + ) + background_model.Q = self.Q + self._background_model = background_model + + self._convolvers = [None] * (len(self.Q) if self.Q is not None else 0) + self._update_models() + + ############# + # Properties + ############# + + @property + def experiment(self) -> Experiment | None: + """The Experiment associated with this Analysis.""" + return self._experiment + + @experiment.setter + def experiment(self, value: Experiment | None) -> None: + if value is not None and not isinstance(value, Experiment): + raise TypeError("experiment must be an instance of Experiment or None.") + self._experiment = value + self._update_models() + + @property + def sample_model(self) -> SampleModel | None: + """The SampleModel associated with this Analysis.""" + return self._sample_model + + @sample_model.setter + def sample_model(self, value: SampleModel | None) -> None: + if value is not None and not isinstance(value, SampleModel): + raise TypeError("sample_model must be an instance of SampleModel or None.") + self._sample_model = value + self._update_models() + + @property + def resolution_model(self) -> ResolutionModel | None: + """The ResolutionModel associated with this Analysis.""" + return self._resolution_model + + @resolution_model.setter + def resolution_model(self, value: ResolutionModel | None) -> None: + if value is not None and not isinstance(value, ResolutionModel): + raise TypeError( + "resolution_model must be an instance of ResolutionModel or None." + ) + self._resolution_model = value + self._update_models() + + @property + def background_model(self) -> BackgroundModel | None: + """The BackgroundModel associated with this Analysis.""" + return self._background_model + + @background_model.setter + def background_model(self, value: BackgroundModel | None) -> None: + if value is not None and not isinstance(value, BackgroundModel): + raise TypeError( + "background_model must be an instance of BackgroundModel or None." + ) + self._background_model = value + self._update_models() + + @property + def Q(self) -> sc.Variable | None: + """The Q values from the associated Experiment, if available.""" + if self.experiment is not None: + return self.experiment.Q + return None + + @Q.setter + def Q(self, value) -> None: + """Q is a read-only property derived from the Experiment.""" + raise AttributeError("Q is a read-only property derived from the Experiment.") + + @property + def energy(self) -> sc.Variable | None: + """The energy values from the associated Experiment, if + available. + """ + if self.experiment is not None: + return self.experiment.energy + return None + + @energy.setter + def energy(self, value) -> None: + """Energy is a read-only property derived from the + Experiment. + """ + raise AttributeError( + "energy is a read-only property derived from the Experiment." + ) + + # TODO: make it use experiment temperature + @property + def temperature(self) -> Parameter | None: + """The temperature from the associated Experiment, if + available. + """ + return None + + @temperature.setter + def temperature(self, value) -> None: + """Temperature is a read-only property derived from the + Experiment. + """ + raise AttributeError( + "temperature is a read-only property derived from the Experiment." + ) + + # # TODO: make it use experiment temperature + # @property def temperature(self) -> Parameter | None: """The + # temperature from the associated Experiment, if available.""" if + # self.experiment is not None: return + # self.experiment.temperature return None + + # @temperature.setter def temperature(self, value) -> None: + # """temperature is a read-only property derived from the + # Experiment.""" raise AttributeError( "temperature is a + # read-only property derived from the Experiment." ) + + ############# + # Other methods + ############# + + def calculate(self, energy: float | None, Q_index: int) -> np.ndarray: + """Calculate the model prediction for a given Q index. + + Args: + energy (float): The energy value to calculate the model for. + Q_index (int): The index of the Q value to calculate the + model for. + Returns: + sc.DataArray: The calculated model prediction. + """ + if energy is None: + energy = self.energy + + if self.sample_model is None: + sample_intensity = np.zeros_like(energy) + else: + if self.resolution_model is None: + sample_intensity = self.sample_model._component_collections[ + Q_index + ].evaluate(energy) + else: + convolver = self._create_convolver(Q_index) + sample_intensity = convolver.convolution() + + if self.background_model is None: + background_intensity = np.zeros_like(energy) + else: + background_intensity = self.background_model._component_collections[ + Q_index + ].evaluate(energy) + + sample_plus_background = sample_intensity + background_intensity + + return sample_plus_background + + def calculate_individual_components( + self, Q_index: int + ) -> tuple[list[np.ndarray], list[np.ndarray]]: + """Calculate the model prediction for a given Q index for each + individual component. + + Args: + Q_index (int): The index of the Q value to calculate the + model for. + Returns: + list[np.ndarray]: The calculated model predictions for each + individual component. + """ + sample_results = [] + background_results = [] + + if self.sample_model is not None: + # Calculate sample components + for component in self.sample_model._component_collections[ + Q_index + ]._components: + if self.resolution_model is None: + component_intensity = component.evaluate(self.energy) + else: + convolver = Convolution( + sample_components=component, + resolution_components=self.resolution_model._component_collections[ + Q_index + ], + energy=self.energy, + temperature=self.temperature, + ) + component_intensity = convolver.convolution() + sample_results.append(component_intensity) + + if self.background_model is not None: + # Calculate background components + for component in self.background_model._component_collections[ + Q_index + ]._components: + component_intensity = component.evaluate(self.energy) + background_results.append(component_intensity) + + return sample_results, background_results + + def calculate_all_Q(self) -> list[np.ndarray]: + """Calculate the model prediction for all Q indices. + + Returns: + list[np.ndarray]: The calculated model predictions for all Q + indices. + """ + results = [] + for Q_index in range(len(self.Q)): + result = self.calculate(Q_index) + results.append(result) + return results + + # def calculate_individual_components_all_Q( + # self, + # add_background: bool = True, + # ) -> list[tuple[list[np.ndarray], list[np.ndarray]]]: + # """Calculate the model prediction for all Q indices for each + # individual component. + + # Returns: list[tuple[list[np.ndarray], list[np.ndarray]]]: The + # calculated model predictions for each individual component + # at all Q indices. """ all_results = [] for Q_index in + # range(len(self.Q)): sample_results, background_results = + # self.calculate_individual_components( Q_index ) if + # add_background: sample_results = sample_results + + # background_results all_results.append((sample_results, + # background_results)) return all_results + + def calculate_single_component_all_Q( + self, + component_index: int, + ) -> list[np.ndarray]: + """Calculate the model prediction for all Q indices for a single + component. + + Args: + component_index (int): The index of the component + Returns: + list[np.ndarray]: The calculated model predictions for the + specified component at all Q indices. + """ + + results = [] + for Q_index in range(len(self.Q)): + if self.sample_model is not None: + component = self.sample_model._component_collections[ + Q_index + ]._components[component_index] + if self.resolution_model is None: + component_intensity = component.evaluate(self.energy) + else: + convolver = Convolution( + sample_components=component, + resolution_components=self.resolution_model._component_collections[ + Q_index + ], + energy=self.energy, + temperature=self.temperature, + ) + component_intensity = convolver.convolution() + results.append(component_intensity) + else: + results.append(np.zeros_like(self.energy)) + + model_data_array = sc.DataArray( + data=sc.array(dims=["Q", "energy"], values=results), + coords={ + "Q": self.Q, + "energy": self.energy, + }, + ) + return model_data_array + + def fit(self, Q_index: int): + """Fit the model to the experimental data for a given Q index. + + Args: + Q_index (int): The index of the Q value to fit the model + to. + Returns: + FitResult: The result of the fit. + """ + if self._experiment is None: + raise ValueError("No experiment is associated with this Analysis.") + + if not isinstance(Q_index, int) or Q_index < 0 or Q_index >= len(self.Q): + raise ValueError("Q_index must be a valid index for the Q values.") + + data = self.experiment.data["Q", Q_index] + x = data.coords["energy"].values + y = data.values + e = data.variances**0.5 + + def fit_func(x_vals): + return self.calculate_theory(energy=x_vals, Q_index=Q_index) + + fitter = EasyScienceFitter( + fit_object=self, + fit_function=fit_func, + ) + + # Perform the fit + fit_result = fitter.fit(x=x, y=y, weights=1.0 / e) + + # Store result + self.fit_result = fit_result + + return fit_result + + def plot_data_and_model( + self, + plot_individual_components: bool = True, + ) -> None: + """Plot the experimental data and the model prediction. + + Args: + plot_individual_components (bool): Whether to plot + individual components. Default is True. + """ + if not isinstance(plot_individual_components, bool): + raise TypeError("plot_individual_components must be True or False.") + + model_data_array = self._create_model_data_group( + individual_components=plot_individual_components + ) + if self.experiment is None or self.experiment.data is None: + raise ValueError("Experiment data is not available for plotting.") + + from IPython.display import display + + fig = pp.slicer( + {"Data": self.experiment.data, "Model": model_data_array}, + color={"Data": "black", "Model": "red"}, + linestyle={"Data": "none", "Model": "solid"}, + marker={"Data": "o", "Model": "None"}, + ) + display(fig) + + ############# + # Private methods + ############# + + def _update_models(self): + """Update models based on the current experiment.""" + if self.experiment is None: + return + + for Q_index in range(len(self.Q)): + self._convolvers[Q_index] = self._create_convolver(Q_index) + + def _create_convolver(self, Q_index: int): + """Initialize and return a Convolution object for the given Q + index. + """ + # Add checks of empty sample models etc + + sample_components = self.sample_model._component_collections[Q_index] + resolution_components = self.resolution_model._component_collections[Q_index] + energy = self.energy + convolver = Convolution( + sample_components=sample_components, + resolution_components=resolution_components, + energy=energy, + temperature=self.temperature, + ) + return convolver + + def _create_model_data_group(self, individual_components=True) -> sc.DataArray: + """Create a Scipp DataArray representing the model over all Q + and energy values. + """ + if self.Q is None or self.energy is None: + raise ValueError("Q and energy must be defined in the experiment.") + + model_data = [] + for Q_index in range(len(self.Q)): + model_at_Q = self.calculate(Q_index) + model_data.append(model_at_Q) + + model_data_array = sc.DataArray( + data=sc.array(dims=["Q", "energy"], values=model_data), + coords={ + "Q": self.Q, + "energy": self.energy, + }, + ) + model_group = sc.DataGroup({"Model": model_data_array}) + + # if plot_individual_components: comps = + # ana.calculate_individual_components(E) for name, + # vals in comps.items(): if name not in + # component_arrays: component_arrays[name] = + # sc.zeros_like(data) csel = + # component_arrays[name] for d, i in + # zip(loop_dims, combo): csel = csel[d, i] + # csel.values = vals fsel.values = + # ana.calculate_theory(E) + + # # Build plot group + # data_and_model = {"Data": self._experiment._data.data, + # "Model": fit_total} if plot_individual_components and + # component_arrays: data_and_model.update(component_arrays) + # data_and_model = sc.DataGroup(data_and_model) + + if individual_components: + components = self.calculate_individual_components_all_Q() + for Q_index, (sample_comps, background_comps) in enumerate(components): + for samp_index, samp_comp in enumerate(sample_comps): + model_data_array[samp_comp.display_name] = sc.zeros_like( + model_data_array.data + ) + model_data_array[samp_comp.display_name].data[ + Q_index, : + ] = samp_comp + for back_index, back_comp in enumerate(background_comps): + model_data_array[back_comp.display_name] = sc.zeros_like( + model_data_array.data + ) + model_data_array[back_comp.display_name].data[ + Q_index, : + ] = back_comp + + model_data_array = model_data_array + model_group # WRONG BUT LINT + return model_data_array + + # def _create_convolvers( + # self, energy: np.ndarray | sc.Variable | None = None + # ) -> None: + # """Create Convolution objects for each Q value.""" + # num_Q = len(self.Q) if self.Q is not None else 0 + # self._convolvers = [ + # self._create_convolver(i, energy=energy) for i in range(num_Q) + # ] diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index 33d23545..5a3f6073 100644 --- a/src/easydynamics/analysis/analysis.py +++ b/src/easydynamics/analysis/analysis.py @@ -3,458 +3,144 @@ import numpy as np -import plopp as pp -import scipp as sc -from easyscience.base_classes.model_base import ModelBase as EasyScienceModelBase -from easyscience.fitting.fitter import Fitter as EasyScienceFitter +from easyscience.fitting.multi_fitter import MultiFitter from easyscience.variable import Parameter -from easydynamics.convolution import Convolution +from easydynamics.analysis.analysis1d import Analysis1d +from easydynamics.analysis.analysis_base import AnalysisBase from easydynamics.experiment import Experiment -from easydynamics.sample_model import BackgroundModel -from easydynamics.sample_model import ResolutionModel from easydynamics.sample_model import SampleModel +from easydynamics.sample_model.instrument_model import InstrumentModel -class Analysis(EasyScienceModelBase): +class Analysis(AnalysisBase): """For analysing data.""" def __init__( self, - display_name: str = 'MyAnalysis', + display_name: str = "MyAnalysis", unique_name: str | None = None, experiment: Experiment | None = None, sample_model: SampleModel | None = None, - resolution_model: ResolutionModel | None = None, - background_model: BackgroundModel | None = None, - energy_offset: None = None, + instrument_model: InstrumentModel | None = None, + extra_parameters: ( + Parameter | list[Parameter] | list[list[Parameter]] | None + ) = None, ): - super().__init__(display_name=display_name, unique_name=unique_name) + super().__init__( + display_name=display_name, + unique_name=unique_name, + experiment=experiment, + sample_model=sample_model, + instrument_model=instrument_model, + ) if experiment is not None and not isinstance(experiment, Experiment): - raise TypeError('experiment must be an instance of Experiment or None.') + raise TypeError("experiment must be an instance of Experiment or None.") + + self._analysis_list = [] + if self.Q is not None: + for Q_index in range(len(self.Q)): + analysis = Analysis1d( + display_name=f"{self.display_name}_Q{Q_index}", + unique_name=( + f"{self.unique_name}_Q{Q_index}" if self.unique_name else None + ), + experiment=self.experiment, + sample_model=self.sample_model, + instrument_model=self.instrument_model, + extra_parameters=extra_parameters, + Q_index=Q_index, + ) + self._analysis_list.append(analysis) - self._experiment = experiment + ############# + # Properties + ############# - if sample_model is not None and not isinstance(sample_model, SampleModel): - raise TypeError('sample_model must be an instance of SampleModel or None.') - sample_model.Q = self.Q - self._sample_model = sample_model + ############# + # Other methods + ############# + def calculate(self, Q_index: int | None = None) -> list[np.ndarray]: + """Calculate model data for a specific Q index.""" - if resolution_model is not None and not isinstance(resolution_model, ResolutionModel): - raise TypeError('resolution_model must be an instance of ResolutionModel or None.') - resolution_model.Q = self.Q - self._resolution_model = resolution_model + if Q_index is None: + result = [] + for analysis in self._analysis_list: + result.append(analysis.calculate()) + return result - if background_model is not None and not isinstance(background_model, BackgroundModel): - raise TypeError('background_model must be an instance of BackgroundModel or None.') - background_model.Q = self.Q - self._background_model = background_model + if Q_index < 0 or Q_index >= len(self._analysis_list): + raise IndexError("Q_index out of range.") - self._convolvers = [None] * (len(self.Q) if self.Q is not None else 0) - self._update_models() + return self._analysis_list[Q_index].calculate() ############# - # Properties + # Private methods ############# - @property - def experiment(self) -> Experiment | None: - """The Experiment associated with this Analysis.""" - return self._experiment - - @experiment.setter - def experiment(self, value: Experiment | None) -> None: - if value is not None and not isinstance(value, Experiment): - raise TypeError('experiment must be an instance of Experiment or None.') - self._experiment = value - self._update_models() - - @property - def sample_model(self) -> SampleModel | None: - """The SampleModel associated with this Analysis.""" - return self._sample_model - - @sample_model.setter - def sample_model(self, value: SampleModel | None) -> None: - if value is not None and not isinstance(value, SampleModel): - raise TypeError('sample_model must be an instance of SampleModel or None.') - self._sample_model = value - self._update_models() - - @property - def resolution_model(self) -> ResolutionModel | None: - """The ResolutionModel associated with this Analysis.""" - return self._resolution_model - - @resolution_model.setter - def resolution_model(self, value: ResolutionModel | None) -> None: - if value is not None and not isinstance(value, ResolutionModel): - raise TypeError('resolution_model must be an instance of ResolutionModel or None.') - self._resolution_model = value - self._update_models() - - @property - def background_model(self) -> BackgroundModel | None: - """The BackgroundModel associated with this Analysis.""" - return self._background_model - - @background_model.setter - def background_model(self, value: BackgroundModel | None) -> None: - if value is not None and not isinstance(value, BackgroundModel): - raise TypeError('background_model must be an instance of BackgroundModel or None.') - self._background_model = value - self._update_models() - - @property - def Q(self) -> sc.Variable | None: - """The Q values from the associated Experiment, if available.""" - if self.experiment is not None: - return self.experiment.Q - return None - - @Q.setter - def Q(self, value) -> None: - """Q is a read-only property derived from the Experiment.""" - raise AttributeError('Q is a read-only property derived from the Experiment.') - - @property - def energy(self) -> sc.Variable | None: - """The energy values from the associated Experiment, if - available. - """ - if self.experiment is not None: - return self.experiment.energy - return None - - @energy.setter - def energy(self, value) -> None: - """Energy is a read-only property derived from the - Experiment. - """ - raise AttributeError('energy is a read-only property derived from the Experiment.') - - # TODO: make it use experiment temperature - @property - def temperature(self) -> Parameter | None: - """The temperature from the associated Experiment, if - available. - """ - return None - - @temperature.setter - def temperature(self, value) -> None: - """Temperature is a read-only property derived from the - Experiment. - """ - raise AttributeError('temperature is a read-only property derived from the Experiment.') - - # # TODO: make it use experiment temperature - # @property def temperature(self) -> Parameter | None: """The - # temperature from the associated Experiment, if available.""" if - # self.experiment is not None: return - # self.experiment.temperature return None - - # @temperature.setter def temperature(self, value) -> None: - # """temperature is a read-only property derived from the - # Experiment.""" raise AttributeError( "temperature is a - # read-only property derived from the Experiment." ) + def _fit_single_Q(self, Q_index: int) -> None: + """Fit data for a single Q index.""" - ############# - # Other methods - ############# + if Q_index < 0 or Q_index >= len(self._analysis_list): + raise IndexError("Q_index out of range.") - def calculate(self, energy: float | None, Q_index: int) -> np.ndarray: - """Calculate the model prediction for a given Q index. - - Args: - energy (float): The energy value to calculate the model for. - Q_index (int): The index of the Q value to calculate the - model for. - Returns: - sc.DataArray: The calculated model prediction. - """ - if energy is None: - energy = self.energy - - if self.sample_model is None: - sample_intensity = np.zeros_like(energy) - else: - if self.resolution_model is None: - sample_intensity = self.sample_model._component_collections[Q_index].evaluate( - energy - ) - else: - convolver = self._create_convolver(Q_index) - sample_intensity = convolver.convolution() - - if self.background_model is None: - background_intensity = np.zeros_like(energy) - else: - background_intensity = self.background_model._component_collections[Q_index].evaluate( - energy + self._analysis_list[Q_index].fit() + + def _fit_all_Q_independently(self) -> None: + """Fit data for all Q indices independently.""" + + for analysis in self._analysis_list: + analysis.fit() + + def _fit_all_Q_simultaneously(self) -> None: + """Fit data for all Q indices simultaneously.""" + + xs = [] + ys = [] + ws = [] + + for analysis in self._analysis_list: + data = analysis.experiment.data["Q", analysis.Q_index] + + x = data.coords["energy"].values + y = data.values + e = np.sqrt(data.variances) + + analysis._convolver = analysis._create_convolver( + Q_index=analysis.Q_index, + energy=x, ) - sample_plus_background = sample_intensity + background_intensity - - return sample_plus_background - - def calculate_individual_components( - self, Q_index: int - ) -> tuple[list[np.ndarray], list[np.ndarray]]: - """Calculate the model prediction for a given Q index for each - individual component. - - Args: - Q_index (int): The index of the Q value to calculate the - model for. - Returns: - list[np.ndarray]: The calculated model predictions for each - individual component. - """ - sample_results = [] - background_results = [] - - if self.sample_model is not None: - # Calculate sample components - for component in self.sample_model._component_collections[Q_index]._components: - if self.resolution_model is None: - component_intensity = component.evaluate(self.energy) - else: - convolver = Convolution( - sample_components=component, - resolution_components=self.resolution_model._component_collections[ - Q_index - ], - energy=self.energy, - temperature=self.temperature, - ) - component_intensity = convolver.convolution() - sample_results.append(component_intensity) - - if self.background_model is not None: - # Calculate background components - for component in self.background_model._component_collections[Q_index]._components: - component_intensity = component.evaluate(self.energy) - background_results.append(component_intensity) - - return sample_results, background_results - - def calculate_all_Q(self) -> list[np.ndarray]: - """Calculate the model prediction for all Q indices. - - Returns: - list[np.ndarray]: The calculated model predictions for all Q - indices. - """ - results = [] - for Q_index in range(len(self.Q)): - result = self.calculate(Q_index) - results.append(result) - return results + xs.append(x) + ys.append(y) + ws.append(1.0 / e) - # def calculate_individual_components_all_Q( - # self, - # add_background: bool = True, - # ) -> list[tuple[list[np.ndarray], list[np.ndarray]]]: - # """Calculate the model prediction for all Q indices for each - # individual component. - - # Returns: list[tuple[list[np.ndarray], list[np.ndarray]]]: The - # calculated model predictions for each individual component - # at all Q indices. """ all_results = [] for Q_index in - # range(len(self.Q)): sample_results, background_results = - # self.calculate_individual_components( Q_index ) if - # add_background: sample_results = sample_results + - # background_results all_results.append((sample_results, - # background_results)) return all_results - - def calculate_single_component_all_Q( - self, - component_index: int, - ) -> list[np.ndarray]: - """Calculate the model prediction for all Q indices for a single - component. - - Args: - component_index (int): The index of the component - Returns: - list[np.ndarray]: The calculated model predictions for the - specified component at all Q indices. - """ - - results = [] - for Q_index in range(len(self.Q)): - if self.sample_model is not None: - component = self.sample_model._component_collections[Q_index]._components[ - component_index - ] - if self.resolution_model is None: - component_intensity = component.evaluate(self.energy) - else: - convolver = Convolution( - sample_components=component, - resolution_components=self.resolution_model._component_collections[ - Q_index - ], - energy=self.energy, - temperature=self.temperature, - ) - component_intensity = convolver.convolution() - results.append(component_intensity) - else: - results.append(np.zeros_like(self.energy)) - - model_data_array = sc.DataArray( - data=sc.array(dims=['Q', 'energy'], values=results), - coords={ - 'Q': self.Q, - 'energy': self.energy, - }, - ) - return model_data_array - - def fit(self, Q_index: int): - """Fit the model to the experimental data for a given Q index. - - Args: - Q_index (int): The index of the Q value to fit the model - to. - Returns: - FitResult: The result of the fit. - """ - if self._experiment is None: - raise ValueError('No experiment is associated with this Analysis.') - - if not isinstance(Q_index, int) or Q_index < 0 or Q_index >= len(self.Q): - raise ValueError('Q_index must be a valid index for the Q values.') - - data = self.experiment.data['Q', Q_index] - x = data.coords['energy'].values - y = data.values - e = data.variances**0.5 - - def fit_func(x_vals): - return self.calculate_theory(energy=x_vals, Q_index=Q_index) - - fitter = EasyScienceFitter( - fit_object=self, - fit_function=fit_func, - ) + fit_functions = [] - # Perform the fit - fit_result = fitter.fit(x=x, y=y, weights=1.0 / e) + for analysis in self._analysis_list: - # Store result - self.fit_result = fit_result + def make_fit_func(a): + def fit_func(_): + return a._calculate() - return fit_result + return fit_func - def plot_data_and_model( - self, - plot_individual_components: bool = True, - ) -> None: - """Plot the experimental data and the model prediction. - - Args: - plot_individual_components (bool): Whether to plot - individual components. Default is True. - """ - if not isinstance(plot_individual_components, bool): - raise TypeError('plot_individual_components must be True or False.') - - model_data_array = self._create_model_data_group( - individual_components=plot_individual_components - ) - if self.experiment is None or self.experiment.data is None: - raise ValueError('Experiment data is not available for plotting.') + fit_functions.append(make_fit_func(analysis)) - from IPython.display import display + mf = MultiFitter( + fit_objects=self._analysis_list, + fit_functions=fit_functions, + ) - fig = pp.slicer( - {'Data': self.experiment.data, 'Model': model_data_array}, - color={'Data': 'black', 'Model': 'red'}, - linestyle={'Data': 'none', 'Model': 'solid'}, - marker={'Data': 'o', 'Model': 'None'}, + results = mf.fit( + x=xs, + y=ys, + weights=ws, ) - display(fig) + return results ############# - # Private methods + # Dunder methods ############# - - def _update_models(self): - """Update models based on the current experiment.""" - if self.experiment is None: - return - - for Q_index in range(len(self.Q)): - self._convolvers[Q_index] = self._create_convolver(Q_index) - - def _create_convolver(self, Q_index: int): - """Initialize and return a Convolution object for the given Q - index. - """ - # Add checks of empty sample models etc - - sample_components = self.sample_model._component_collections[Q_index] - resolution_components = self.resolution_model._component_collections[Q_index] - energy = self.energy - convolver = Convolution( - sample_components=sample_components, - resolution_components=resolution_components, - energy=energy, - temperature=self.temperature, - ) - return convolver - - def _create_model_data_group(self, individual_components=True) -> sc.DataArray: - """Create a Scipp DataArray representing the model over all Q - and energy values. - """ - if self.Q is None or self.energy is None: - raise ValueError('Q and energy must be defined in the experiment.') - - model_data = [] - for Q_index in range(len(self.Q)): - model_at_Q = self.calculate(Q_index) - model_data.append(model_at_Q) - - model_data_array = sc.DataArray( - data=sc.array(dims=['Q', 'energy'], values=model_data), - coords={ - 'Q': self.Q, - 'energy': self.energy, - }, - ) - model_group = sc.DataGroup({'Model': model_data_array}) - - # if plot_individual_components: comps = - # ana.calculate_individual_components(E) for name, - # vals in comps.items(): if name not in - # component_arrays: component_arrays[name] = - # sc.zeros_like(data) csel = - # component_arrays[name] for d, i in - # zip(loop_dims, combo): csel = csel[d, i] - # csel.values = vals fsel.values = - # ana.calculate_theory(E) - - # # Build plot group - # data_and_model = {"Data": self._experiment._data.data, - # "Model": fit_total} if plot_individual_components and - # component_arrays: data_and_model.update(component_arrays) - # data_and_model = sc.DataGroup(data_and_model) - - if individual_components: - components = self.calculate_individual_components_all_Q() - for Q_index, (sample_comps, background_comps) in enumerate(components): - for samp_index, samp_comp in enumerate(sample_comps): - model_data_array[samp_comp.display_name] = sc.zeros_like(model_data_array.data) - model_data_array[samp_comp.display_name].data[Q_index, :] = samp_comp - for back_index, back_comp in enumerate(background_comps): - model_data_array[back_comp.display_name] = sc.zeros_like(model_data_array.data) - model_data_array[back_comp.display_name].data[Q_index, :] = back_comp - - model_data_array = model_data_array + model_group # WRONG BUT LINT - return model_data_array diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index 2a0b554c..57046b4b 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -2,12 +2,14 @@ # SPDX-License-Identifier: BSD-3-Clause +from inspect import Parameter + import numpy as np +import scipp as sc from easyscience.fitting.fitter import Fitter as EasyScienceFitter from easyscience.variable import DescriptorNumber from easydynamics.analysis.analysis_base import AnalysisBase -from easydynamics.convolution import Convolution from easydynamics.experiment import Experiment from easydynamics.sample_model import InstrumentModel from easydynamics.sample_model import SampleModel @@ -24,6 +26,7 @@ def __init__( sample_model: SampleModel | None = None, instrument_model: InstrumentModel | None = None, Q_index: int | None = None, + extra_parameters: Parameter | list[Parameter] | None = None, ): super().__init__( display_name=display_name, @@ -44,6 +47,8 @@ def __init__( self._fit_result = None + self._convolver = self._create_convolver(Q_index=self.Q_index) + ############# # Properties ############# @@ -68,12 +73,13 @@ def Q_index(self, index: int | None) -> None: ): raise ValueError("Q_index must be a valid index for the Q values.") self._Q_index = index + self._on_Q_index_changed() ############# # Other methods ############# - def calculate(self, energy: float | None = None) -> np.ndarray: + def calculate(self, energy: np.ndarray | sc.Variable | None = None) -> np.ndarray: """Calculate the model prediction for a given Q index. Args: @@ -81,37 +87,23 @@ def calculate(self, energy: float | None = None) -> np.ndarray: Returns: sc.DataArray: The calculated model prediction. """ - Q_index = self._require_Q_index() - if energy is None: - energy = self.energy.values + self._convolver = self._create_convolver(Q_index=self.Q_index, energy=energy) - # TODO: handle units properly + return self._calculate() - energy_offset = self.instrument_model.get_energy_offset_at_Q(Q_index).value + def _calculate(self) -> np.ndarray: + """Calculate the model prediction for a given Q index. - # Sample - sample_components = self.sample_model.get_component_collection(Q_index) - resolution_components = ( - self.instrument_model.resolution_model.get_component_collection(Q_index) - ) + Args: + energy (float): The energy value to calculate the model for. + Returns: + sc.DataArray: The calculated model prediction. + """ - sample_intensity = self._evaluate_sample( - sample_components=sample_components, - resolution_components=resolution_components, - energy=energy, - energy_offset=energy_offset, - ) + sample_intensity = self._evaluate_sample() - # Background - background_component_collection = ( - self.instrument_model.background_model.get_component_collection(Q_index) - ) - background_intensity = self._evaluate_background( - background_components=background_component_collection, - energy=energy, - energy_offset=energy_offset, - ) + background_intensity = self._evaluate_background() sample_plus_background = sample_intensity + background_intensity @@ -119,7 +111,7 @@ def calculate(self, energy: float | None = None) -> np.ndarray: def calculate_individual_components( self, - energy: float | None = None, + energy: np.ndarray | sc.Variable | None = None, ) -> np.ndarray: """Calculate the model prediction for a given Q index. @@ -130,22 +122,10 @@ def calculate_individual_components( """ Q_index = self._require_Q_index() - if energy is None: - energy = self.energy.values - - # TODO: handle units properly - - energy_offset = self.instrument_model.get_energy_offset_at_Q(Q_index).value + energy = self._handle_energy(energy) - # Sample. Convolve with resolution if resolution components are - # present, otherwise just evaluate sample components one by one - # to get individual contributions. sample_components = self.sample_model.get_component_collection(Q_index) - resolution_components = ( - self.instrument_model.resolution_model.get_component_collection(Q_index) - ) - if sample_components.is_empty: sample_intensity = [np.zeros_like(energy)] else: @@ -153,9 +133,7 @@ def calculate_individual_components( for component in sample_components.components: component_intensity = self._evaluate_sample_component( component=component, - resolution_components=resolution_components, energy=energy, - energy_offset=energy_offset, ) sample_intensity.append(component_intensity) @@ -173,7 +151,6 @@ def calculate_individual_components( component_intensity = self._evaluate_background_component( component=component, energy=energy, - energy_offset=energy_offset, ) background_intensity.append(component_intensity) @@ -185,6 +162,12 @@ def fit(self): Args: Returns: FitResult: The result of the fit. + + Notes + ----- + The energy grid is fixed for the duration of the fit. + Convolution objects are created once and reused during + parameter optimization for performance reasons. """ if self._experiment is None: raise ValueError("No experiment is associated with this Analysis.") @@ -196,8 +179,10 @@ def fit(self): y = data.values e = data.variances**0.5 - def fit_func(x_vals): - return self.calculate(energy=x_vals) + self._convolver = self._create_convolver(Q_index=self.Q_index, energy=x) + + def fit_func(_): + return self._calculate() fitter = EasyScienceFitter( fit_object=self, @@ -279,7 +264,7 @@ def get_all_variables(self) -> list[DescriptorNumber]: variables.extend(self.instrument_model.get_all_variables(Q_index=self.Q_index)) - if self._extra_parameters != []: + if self._extra_parameters: variables.extend(self._extra_parameters) return variables @@ -287,55 +272,51 @@ def get_all_variables(self) -> list[DescriptorNumber]: ############# # Private methods ############# - def _evaluate_sample( - self, - sample_components, - resolution_components, - energy, - energy_offset, - ): - if resolution_components.is_empty: - return sample_components.evaluate(energy - energy_offset) - convolver = self._convolvers[self._require_Q_index()] - return convolver.convolution() - - def _evaluate_sample_component( - self, - component, - resolution_components, - energy, - energy_offset, - ): - if resolution_components.is_empty: - return component.evaluate(energy - energy_offset) - convolver = Convolution( - sample_components=component, - resolution_components=resolution_components, - energy=energy, - temperature=self.temperature, - energy_offset=energy_offset, - ) - return convolver.convolution() - - def _evaluate_background( - self, - background_components, - energy, - energy_offset, - ): - if background_components.is_empty: - return np.zeros_like(energy) - return background_components.evaluate(energy - energy_offset) - - def _evaluate_background_component( - self, - component, - energy, - energy_offset, - ): - return component.evaluate(energy - energy_offset) def _require_Q_index(self) -> int: + """ + Get the Q index for single Q analysis, ensuring it is set. + Raises a ValueError if the Q index is not set. + Returns: + int: The Q index. + """ if self._Q_index is None: raise ValueError("Q_index must be set.") return self._Q_index + + def _handle_energy( + self, energy: np.ndarray | sc.Variable | None + ) -> np.ndarray | sc.Variable: + """ " + Handle the energy input for evaluation methods. + + If energy is None, use the energy values from the experiment. + If energy is a sc.Variable, extract the values as a numpy array. + If energy is already a numpy array, return it as is. + + Args: + energy (np.ndarray | sc.Variable | None): The input energy values. + Returns: + np.ndarray: The energy values to use for evaluation. + """ + # TODO: handle units properly + + if energy is None: + energy = self.energy.values + + if isinstance(energy, np.ndarray): + return energy + + if isinstance(energy, sc.Variable): + return energy.values + + raise TypeError("Energy must be a numpy array, sc.Variable, or None.") + + def _on_Q_index_changed(self) -> None: + """ + Handle changes to the Q index. + + This method is called whenever the Q index is changed. It updates + the Convolution object for the new Q index. + """ + self._convolver = self._create_convolver(Q_index=self.Q_index) diff --git a/src/easydynamics/analysis/analysis_base.py b/src/easydynamics/analysis/analysis_base.py index 81d0c250..7caf89d6 100644 --- a/src/easydynamics/analysis/analysis_base.py +++ b/src/easydynamics/analysis/analysis_base.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: BSD-3-Clause +import numpy as np import scipp as sc from easyscience.base_classes.model_base import ModelBase as EasyScienceModelBase from easyscience.variable import Parameter @@ -10,6 +11,8 @@ from easydynamics.experiment import Experiment from easydynamics.sample_model import InstrumentModel from easydynamics.sample_model import SampleModel +from easydynamics.sample_model.component_collection import ComponentCollection +from easydynamics.sample_model.components.model_component import ModelComponent class AnalysisBase(EasyScienceModelBase): @@ -63,7 +66,6 @@ def __init__( else: self._extra_parameters = [] - self._convolvers = [None] * (len(self.Q) if self.Q is not None else 0) self._on_experiment_changed() ############# @@ -164,46 +166,216 @@ def temperature(self, value) -> None: def _on_experiment_changed(self) -> None: self._sample_model.Q = self.Q self._instrument_model.Q = self.Q - self._create_convolvers() def _on_sample_model_changed(self) -> None: self._sample_model.Q = self.Q - self._create_convolvers() def _on_instrument_model_changed(self) -> None: self._instrument_model.Q = self.Q - self._create_convolvers() - def _create_convolvers(self) -> None: - """Create Convolution objects for each Q value.""" - num_Q = len(self.Q) if self.Q is not None else 0 - self._convolvers = [self._create_convolver(i) for i in range(num_Q)] - - def _create_convolver(self, Q_index: int) -> Convolution: + def _create_convolver( + self, Q_index: int, energy: np.ndarray | sc.Variable | None = None + ) -> Convolution | None: """Initialize and return a Convolution object for the given Q index. """ - sample_components = self.sample_model._component_collections[Q_index] - if sample_components == []: - return Convolution() + sample_components = self.sample_model.get_component_collection(Q_index) + if sample_components.is_empty: + return None resolution_components = ( - self.instrument_model.resolution_model._component_collections[Q_index] + self.instrument_model.resolution_model.get_component_collection(Q_index) ) - if resolution_components == []: - return Convolution() - - energy = self.energy + if resolution_components.is_empty: + return None + if energy is None: + energy = self.energy # TODO: allow convolution options to be set. convolver = Convolution( sample_components=sample_components, resolution_components=resolution_components, energy=energy, temperature=self.temperature, - energy_offset=self.instrument_model._energy_offsets[Q_index], + energy_offset=self.instrument_model.get_energy_offset_at_Q(Q_index), ) return convolver + def _evaluate_components( + self, + components: ComponentCollection | ModelComponent, + energy: np.ndarray | sc.Variable | None = None, + convolver: Convolution | None = None, + convolve: bool = True, + Q_index: int | None = None, + ): + """ + Calculate the contribution of a set of components, optionally + convolving with the resolution. + If convolve is True and a Convolution object is provided, + use it to perform the convolution of the components with the + resolution. If convolve is True but no Convolution object is + provided, create a new Convolution object for the given + components and energy. If convolve is False, evaluate the + components directly without convolution. + Args: + components (ComponentCollection | ModelComponent): + The components to evaluate. + energy (np.ndarray | sc.Variable | None): + The energy values to evaluate the components for. If + None, the energy values from the experiment will be + used. + convolver (Convolution | None): + An optional Convolution object to use for convolution. + If None, a new Convolution object will be created if + convolve is True. + convolve (bool): + Whether to perform convolution with the resolution. + Default is True. + """ + if Q_index is None: + Q_index = self._require_Q_index() + energy = self._handle_energy(energy) + energy_offset = self.instrument_model.get_energy_offset_at_Q(Q_index).value + + # If there are no components, return zero + if isinstance(components, ComponentCollection) and components.is_empty: + return np.zeros_like(energy) + + # No convolution + if not convolve: + return components.evaluate(energy - energy_offset) + + resolution = self.instrument_model.resolution_model.get_component_collection( + Q_index + ) + if resolution.is_empty: + return components.evaluate(energy - energy_offset) + + # Convolution For fitting we don't want to create a new + # Convolution object at each iteration + if convolver is not None: + return convolver.convolution() + + # For evaluating individual components + conv = Convolution( + sample_components=components, + resolution_components=resolution, + energy=energy, + temperature=self.temperature, + energy_offset=energy_offset, + ) + return conv.convolution() + + def _evaluate_sample( + self, + energy: np.ndarray | sc.Variable | None = None, + Q_index: int | None = None, + ): + """ + Evaluate the sample contribution for a given Q index. + + If a Convolution object exists for the Q index, use it to + perform the convolution of the sample components with the + resolution components. If no Convolution object exists, evaluate + the sample components directly without convolution. + + Args: + energy (np.ndarray | sc.Variable | None): The energy values + to evaluate the sample contribution for. If None, the energy + values from the experiment will be used. + Returns: + np.ndarray: The evaluated sample contribution. + """ + if Q_index is None: + Q_index = self._require_Q_index() + components = self.sample_model.get_component_collection(Q_index=Q_index) + return self._evaluate_components( + components=components, + energy=energy, + convolver=self._convolver, + convolve=True, + ) + + def _evaluate_sample_component( + self, + component, + energy: np.ndarray | sc.Variable | None = None, + ): + """ + Evaluate a single sample component for a given Q index. + If a Convolution object exists for the Q index, use it to + perform the convolution of the sample component with the + resolution components. If no Convolution object exists, evaluate + the sample component directly without convolution. + Args: + component: The sample component to evaluate. + energy (np.ndarray | sc.Variable | None): The energy values + to evaluate the sample component for. If None, the energy + values from the experiment will be used. + Returns: + np.ndarray: The evaluated sample component contribution. + """ + return self._evaluate_components( + components=component, + energy=energy, + convolver=None, + convolve=True, + ) + + def _evaluate_background( + self, + energy: np.ndarray | sc.Variable | None = None, + Q_index: int | None = None, + ): + """ + Evaluate the background contribution for a given Q index. + Evaluate each background component separately to get individual + contributions. Args: + energy (np.ndarray | sc.Variable | None): The energy values + to evaluate the background contribution for. If None, the + energy values from the experiment will be used. + Returns: + np.ndarray: The evaluated background contribution. + """ + + if Q_index is None: + Q_index = self._require_Q_index() + background_components = ( + self.instrument_model.background_model.get_component_collection( + Q_index=Q_index + ) + ) + return self._evaluate_components( + components=background_components, + energy=energy, + convolver=None, + convolve=False, + ) + + def _evaluate_background_component( + self, + component, + energy: np.ndarray | sc.Variable | None = None, + ): + """ + Evaluate a single background component for a given Q index. + Evaluate the background component directly without convolution. + Args: + component: The background component to evaluate. + energy (np.ndarray | sc.Variable | None): The energy values + to evaluate the background component for. If None, the energy + values from the experiment will be used. + Returns: + np.ndarray: The evaluated background component contribution. + """ + + return self._evaluate_components( + components=component, + energy=energy, + convolver=None, + convolve=False, + ) + ############# # Dunder methods ############# From 8c37ee4281b3b4a717effae4d6fe1415ce6fc68f Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Tue, 10 Feb 2026 16:20:10 +0100 Subject: [PATCH 07/27] fitting and plotting for multiple Q --- .../analysis old parameter bug.ipynb | 384 ++++++++++++++++++ docs/docs/tutorials/analysis.ipynb | 336 ++++----------- src/easydynamics/analysis/analysis.py | 230 +++++++++-- src/easydynamics/analysis/analysis1d.py | 227 ++++++++++- src/easydynamics/analysis/analysis_base.py | 207 ---------- src/easydynamics/experiment/experiment.py | 94 ++--- src/easydynamics/sample_model/model_base.py | 11 +- src/easydynamics/utils/utils.py | 34 +- 8 files changed, 965 insertions(+), 558 deletions(-) create mode 100644 docs/docs/tutorials/analysis old parameter bug.ipynb diff --git a/docs/docs/tutorials/analysis old parameter bug.ipynb b/docs/docs/tutorials/analysis old parameter bug.ipynb new file mode 100644 index 00000000..85bddaaa --- /dev/null +++ b/docs/docs/tutorials/analysis old parameter bug.ipynb @@ -0,0 +1,384 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "8643b10c", + "metadata": {}, + "source": [ + "asd" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bca91d3c", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "from easydynamics.analysis.analysis1d import Analysis1d\n", + "from easydynamics.experiment import Experiment\n", + "from easydynamics.sample_model import ComponentCollection\n", + "from easydynamics.sample_model import DeltaFunction\n", + "from easydynamics.sample_model import Gaussian\n", + "from easydynamics.sample_model import Polynomial\n", + "from easydynamics.sample_model.background_model import BackgroundModel\n", + "from easydynamics.sample_model.resolution_model import ResolutionModel\n", + "from easydynamics.sample_model.sample_model import SampleModel\n", + "from easydynamics.sample_model.instrument_model import InstrumentModel\n", + "from easydynamics.analysis.analysis import Analysis\n", + "%matplotlib widget" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8deca9b6", + "metadata": {}, + "outputs": [], + "source": [ + "vanadium_experiment = Experiment('Vanadium')\n", + "vanadium_experiment.load_hdf5(filename='vanadium_data_example.h5')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "41f842f0", + "metadata": {}, + "outputs": [], + "source": [ + "# # Create a diffusion_model and components for the SampleModel\n", + "\n", + "# # Creating components\n", + "# component_collection = ComponentCollection()\n", + "# delta_function = DeltaFunction(display_name='DeltaFunction', area=1)\n", + "# gaussian = Gaussian(display_name='Gaussian', width=0.1, center=0.5, area=0.5)\n", + "\n", + "# # Adding components to the component collection\n", + "# component_collection.append_component(delta_function)\n", + "\n", + "\n", + "# sample_model = SampleModel(\n", + "# components=component_collection,\n", + "# unit='meV',\n", + "# display_name='MySampleModel',\n", + "# )\n", + "\n", + "# res_gauss = Gaussian(width=0.1)\n", + "# res_gauss.area.fixed = True\n", + "# resolution_model = ResolutionModel(components=res_gauss)\n", + "\n", + "\n", + "# background_model = BackgroundModel(components=Polynomial(coefficients=[0.001]))\n", + "\n", + "# instrument_model = InstrumentModel(\n", + "# resolution_model=resolution_model,\n", + "# background_model=background_model,\n", + "# )\n", + "\n", + "# my_analysis = Analysis1d(\n", + "# experiment=vanadium_experiment,\n", + "# sample_model=sample_model,\n", + "# instrument_model=instrument_model,\n", + "# Q_index=5,\n", + "# )\n", + "\n", + "\n", + "# values = my_analysis.calculate()\n", + "# sample_values, background_values = my_analysis.calculate_individual_components()\n", + "\n", + "# plt.figure()\n", + "# plt.plot(my_analysis.energy.values, values, label='Total Model')\n", + "# for component_index in range(len(sample_values)):\n", + "# plt.plot(\n", + "# my_analysis.energy.values,\n", + "# sample_values[component_index],\n", + "# label=f'Sample Component {component_index}',\n", + "# linestyle='--',\n", + "# )\n", + "\n", + "# for component_index in range(len(background_values)):\n", + "# plt.plot(\n", + "# my_analysis.energy.values,\n", + "# background_values[component_index],\n", + "# label=f'Background Component {component_index}',\n", + "# linestyle=':',\n", + "# )\n", + "# plt.xlabel('Energy (meV)')\n", + "# plt.ylabel('Intensity')\n", + "# plt.title(f'Q index: {5}')\n", + "# plt.legend()\n", + "# plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6762faba", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "02702f95", + "metadata": {}, + "outputs": [], + "source": [ + "# my_analysis.plot_data_and_model()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "70091539", + "metadata": {}, + "outputs": [], + "source": [ + "# my_analysis.fit()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2ad6384e", + "metadata": {}, + "outputs": [], + "source": [ + "# my_analysis.plot_data_and_model()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2dfb1f90", + "metadata": {}, + "outputs": [], + "source": [ + "# my_analysis.get_all_variables()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5afefbab", + "metadata": {}, + "outputs": [], + "source": [ + "# my_analysis.get_fit_parameters()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "465c0e1e", + "metadata": {}, + "outputs": [], + "source": [ + "# for Q_index in range(len(my_analysis.Q)):\n", + "# my_analysis.Q_index = Q_index\n", + "# my_analysis.fit()\n", + "# my_analysis.plot_data_and_model()\n", + "# print(my_analysis.get_fit_parameters())\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9bdeed2b", + "metadata": {}, + "outputs": [], + "source": [ + "# Create a diffusion_model and components for the SampleModel\n", + "\n", + "# Creating components\n", + "component_collection = ComponentCollection()\n", + "delta_function = DeltaFunction(display_name='DeltaFunction', area=1)\n", + "gaussian = Gaussian(display_name='Gaussian', width=0.1, center=0.5, area=0.5)\n", + "\n", + "# Adding components to the component collection\n", + "component_collection.append_component(delta_function)\n", + "\n", + "\n", + "sample_model = SampleModel(\n", + " components=component_collection,\n", + " unit='meV',\n", + " display_name='MySampleModel',\n", + ")\n", + "\n", + "res_gauss = Gaussian(width=0.1)\n", + "res_gauss.area.fixed = True\n", + "resolution_model = ResolutionModel(components=res_gauss)\n", + "\n", + "\n", + "background_model = BackgroundModel(components=Polynomial(coefficients=[0.001]))\n", + "\n", + "instrument_model = InstrumentModel(\n", + " resolution_model=resolution_model,\n", + " background_model=background_model,\n", + ")\n", + "\n", + "my_full_analysis = Analysis(\n", + " experiment=vanadium_experiment,\n", + " sample_model=sample_model,\n", + " instrument_model=instrument_model,\n", + ")\n", + "\n", + "# my_full_analysis._fit_all_Q_independently()\n", + "my_full_analysis._fit_all_Q_simultaneously()\n", + "for analysis_object in my_full_analysis._analysis_list:\n", + " analysis_object.plot_data_and_model()\n", + " print(analysis_object.get_fit_parameters())\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0a727fc3", + "metadata": {}, + "outputs": [], + "source": [ + "for analysis_object in my_full_analysis._analysis_list:\n", + " print(analysis_object.get_fit_parameters())\n", + "\n", + "for analysis_object in my_full_analysis._analysis_list:\n", + " print(analysis_object.get_fit_parameters()[0].unique_name)\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d0ceec1d", + "metadata": {}, + "outputs": [], + "source": [ + "p1=my_full_analysis._analysis_list[1].get_fit_parameters()[0]\n", + "print(p1)\n", + "print(p1.unique_name)\n", + "p2 = my_full_analysis._analysis_list[9].get_fit_parameters()[0]\n", + "print(p2)\n", + "print(p2.unique_name)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d792eee3", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "my_full_analysis.Q" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4217d56d", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "from easydynamics.sample_model import ComponentCollection\n", + "from easydynamics.sample_model import DeltaFunction\n", + "from easydynamics.sample_model.model_base import ModelBase\n", + "%matplotlib widget\n", + "import numpy as np\n", + "Q=np.linspace(0.1,15,31)\n", + "component_collection = ComponentCollection()\n", + "delta_function = DeltaFunction(display_name='DeltaFunction', area=1)\n", + "\n", + "component_collection.append_component(delta_function)\n", + "\n", + "\n", + "# sample_model = SampleModel(\n", + "sample_model = ModelBase(\n", + " components=component_collection,\n", + " unit='meV',\n", + " display_name='MySampleModel',\n", + " Q=Q,\n", + ")\n", + "\n", + "\n", + "for Q_index in range(len(sample_model.Q)):\n", + " pars = sample_model.get_all_variables(Q_index=Q_index) \n", + " pars[0].value=pars[0].value+Q_index\n", + "\n", + "for Q_index in range(len(sample_model.Q)):\n", + " pars = sample_model.get_all_variables(Q_index=Q_index)\n", + " print(pars[0].unique_name)\n", + " print(pars[0])\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "35c89ce3", + "metadata": {}, + "outputs": [], + "source": [ + "vars2=sample_model._component_collections[1].get_all_variables()\n", + "for var in vars2:\n", + " print(var)\n", + " print(var.unique_name)\n", + "\n", + "var3=sample_model._component_collections[10].get_all_variables()\n", + "for var in var3:\n", + " print(var)\n", + " print(var.unique_name)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "02320e75", + "metadata": {}, + "outputs": [], + "source": [ + "a=vanadium_experiment.binned_data.coords['energy']\n", + "a" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5451bbf3", + "metadata": {}, + "outputs": [], + "source": [ + "import scipp as sc\n", + "x_pixel_range = [-10, -5, 0, 5, 10]\n", + "a,b=sc.array(values=x_pixel_range, dims='x')\n", + "print(a)\n", + "print(b)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "easydynamics_newbase", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/docs/tutorials/analysis.ipynb b/docs/docs/tutorials/analysis.ipynb index e1ea2973..39b70c8e 100644 --- a/docs/docs/tutorials/analysis.ipynb +++ b/docs/docs/tutorials/analysis.ipynb @@ -49,88 +49,102 @@ "metadata": {}, "outputs": [], "source": [ - "# # Create a diffusion_model and components for the SampleModel\n", - "\n", - "# # Creating components\n", - "# component_collection = ComponentCollection()\n", - "# delta_function = DeltaFunction(display_name='DeltaFunction', area=1)\n", - "# gaussian = Gaussian(display_name='Gaussian', width=0.1, center=0.5, area=0.5)\n", + "# Example of Analysis1d with a simple sample model and instrument model\n", + "delta_function = DeltaFunction(display_name='DeltaFunction', area=1)\n", + "gaussian = Gaussian(display_name='Gaussian', width=0.1, area=1)\n", + "components = ComponentCollection(components=[delta_function, gaussian])\n", + "sample_model = SampleModel(\n", + " components=components,\n", + ")\n", "\n", - "# # Adding components to the component collection\n", - "# component_collection.append_component(delta_function)\n", + "res_gauss = Gaussian(width=0.1)\n", + "res_gauss.area.fixed = True\n", + "resolution_model = ResolutionModel(components=res_gauss)\n", "\n", "\n", - "# sample_model = SampleModel(\n", - "# components=component_collection,\n", - "# unit='meV',\n", - "# display_name='MySampleModel',\n", - "# )\n", + "background_model = BackgroundModel(components=Polynomial(coefficients=[0.001]))\n", "\n", - "# res_gauss = Gaussian(width=0.1)\n", - "# res_gauss.area.fixed = True\n", - "# resolution_model = ResolutionModel(components=res_gauss)\n", + "instrument_model = InstrumentModel(\n", + " resolution_model=resolution_model,\n", + " background_model=background_model,\n", + ")\n", "\n", + "my_analysis = Analysis1d(\n", + " experiment=vanadium_experiment,\n", + " sample_model=sample_model,\n", + " instrument_model=instrument_model,\n", + " Q_index=5,\n", + ")\n", "\n", - "# background_model = BackgroundModel(components=Polynomial(coefficients=[0.001]))\n", + "fit_result = my_analysis.fit()\n", + "my_analysis.plot_data_and_model()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6762faba", + "metadata": {}, + "outputs": [], + "source": [ + "# Example of Analysis with a simple sample model and instrument model\n", + "delta_function = DeltaFunction(display_name='DeltaFunction', area=1)\n", + "gaussian = Gaussian(display_name='Gaussian', width=0.1, area=1)\n", + "components = ComponentCollection(components=[delta_function, gaussian])\n", + "sample_model = SampleModel(\n", + " components=components,\n", + ")\n", "\n", - "# instrument_model = InstrumentModel(\n", - "# resolution_model=resolution_model,\n", - "# background_model=background_model,\n", - "# )\n", + "res_gauss = Gaussian(width=0.1)\n", + "res_gauss.area.fixed = True\n", + "resolution_model = ResolutionModel(components=res_gauss)\n", "\n", - "# my_analysis = Analysis1d(\n", - "# experiment=vanadium_experiment,\n", - "# sample_model=sample_model,\n", - "# instrument_model=instrument_model,\n", - "# Q_index=5,\n", - "# )\n", "\n", + "background_model = BackgroundModel(components=Polynomial(coefficients=[0.001]))\n", "\n", - "# values = my_analysis.calculate()\n", - "# sample_values, background_values = my_analysis.calculate_individual_components()\n", + "instrument_model = InstrumentModel(\n", + " resolution_model=resolution_model,\n", + " background_model=background_model,\n", + ")\n", "\n", - "# plt.figure()\n", - "# plt.plot(my_analysis.energy.values, values, label='Total Model')\n", - "# for component_index in range(len(sample_values)):\n", - "# plt.plot(\n", - "# my_analysis.energy.values,\n", - "# sample_values[component_index],\n", - "# label=f'Sample Component {component_index}',\n", - "# linestyle='--',\n", - "# )\n", + "my_analysis = Analysis(\n", + " experiment=vanadium_experiment,\n", + " sample_model=sample_model,\n", + " instrument_model=instrument_model,\n", + ")\n", "\n", - "# for component_index in range(len(background_values)):\n", - "# plt.plot(\n", - "# my_analysis.energy.values,\n", - "# background_values[component_index],\n", - "# label=f'Background Component {component_index}',\n", - "# linestyle=':',\n", - "# )\n", - "# plt.xlabel('Energy (meV)')\n", - "# plt.ylabel('Intensity')\n", - "# plt.title(f'Q index: {5}')\n", - "# plt.legend()\n", - "# plt.show()" + "fit_result1 = my_analysis.fit(fit_method=\"independent\", Q_index=5)" ] }, { "cell_type": "code", "execution_count": null, - "id": "6762faba", + "id": "e98e3d65", "metadata": {}, "outputs": [], - "source": [] + "source": [ + "fit_result2 = my_analysis.fit(fit_method=\"independent\")" + ] }, { "cell_type": "code", "execution_count": null, - "id": "02702f95", + "id": "af13afce", "metadata": {}, "outputs": [], "source": [ - "# my_analysis.plot_data_and_model()" + "fit_result3 = my_analysis.fit(fit_method=\"simultaneous\")\n", + "fit_result3" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "02702f95", + "metadata": {}, + "outputs": [], + "source": [] + }, { "cell_type": "code", "execution_count": null, @@ -138,7 +152,7 @@ "metadata": {}, "outputs": [], "source": [ - "# my_analysis.fit()" + "my_analysis.plot_data_and_model()" ] }, { @@ -148,7 +162,18 @@ "metadata": {}, "outputs": [], "source": [ - "# my_analysis.plot_data_and_model()" + "sample_comps, background_comps = my_analysis.analysis_list[0].calculate_individual_components()\n", + "sample_comps" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "35b0fac5", + "metadata": {}, + "outputs": [], + "source": [ + "my_analysis.sample_model" ] }, { @@ -233,205 +258,6 @@ " analysis_object.plot_data_and_model()\n", " print(analysis_object.get_fit_parameters())\n" ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0a727fc3", - "metadata": {}, - "outputs": [], - "source": [ - "for analysis_object in my_full_analysis._analysis_list:\n", - " print(analysis_object.get_fit_parameters())\n", - "\n", - "for analysis_object in my_full_analysis._analysis_list:\n", - " print(analysis_object.get_fit_parameters()[0].unique_name)\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d0ceec1d", - "metadata": {}, - "outputs": [], - "source": [ - "p1=my_full_analysis._analysis_list[1].get_fit_parameters()[0]\n", - "print(p1)\n", - "print(p1.unique_name)\n", - "p2 = my_full_analysis._analysis_list[9].get_fit_parameters()[0]\n", - "print(p2)\n", - "print(p2.unique_name)\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d792eee3", - "metadata": {}, - "outputs": [], - "source": [ - "\n", - "my_full_analysis.Q" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "4217d56d", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Parameter_2\n", - "\n", - "Parameter_4\n", - "\n", - "Parameter_6\n", - "\n", - "Parameter_8\n", - "\n", - "Parameter_10\n", - "\n", - "Parameter_12\n", - "\n", - "Parameter_14\n", - "\n", - "Parameter_16\n", - "\n", - "Parameter_18\n", - "\n", - "Parameter_20\n", - "\n", - "Parameter_4\n", - "\n", - "Parameter_6\n", - "\n", - "Parameter_8\n", - "\n", - "Parameter_10\n", - "\n", - "Parameter_12\n", - "\n", - "Parameter_14\n", - "\n", - "Parameter_16\n", - "\n", - "Parameter_18\n", - "\n", - "Parameter_20\n", - "\n", - "Parameter_22\n", - "\n", - "Parameter_4\n", - "\n", - "Parameter_6\n", - "\n", - "Parameter_8\n", - "\n", - "Parameter_10\n", - "\n", - "Parameter_12\n", - "\n", - "Parameter_14\n", - "\n", - "Parameter_16\n", - "\n", - "Parameter_18\n", - "\n", - "Parameter_20\n", - "\n", - "Parameter_4\n", - "\n", - "Parameter_6\n", - "\n" - ] - } - ], - "source": [ - "import matplotlib.pyplot as plt\n", - "\n", - "from easydynamics.sample_model import ComponentCollection\n", - "from easydynamics.sample_model import DeltaFunction\n", - "from easydynamics.sample_model.model_base import ModelBase\n", - "%matplotlib widget\n", - "import numpy as np\n", - "Q=np.linspace(0.1,15,31)\n", - "component_collection = ComponentCollection()\n", - "delta_function = DeltaFunction(display_name='DeltaFunction', area=1)\n", - "\n", - "component_collection.append_component(delta_function)\n", - "\n", - "\n", - "# sample_model = SampleModel(\n", - "sample_model = ModelBase(\n", - " components=component_collection,\n", - " unit='meV',\n", - " display_name='MySampleModel',\n", - " Q=Q,\n", - ")\n", - "\n", - "\n", - "for Q_index in range(len(sample_model.Q)):\n", - " pars = sample_model.get_all_variables(Q_index=Q_index) \n", - " pars[0].value=pars[0].value+Q_index\n", - " print(pars[0].unique_name)\n", - " print(pars[0])\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "35c89ce3", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "Parameter_5\n", - "\n", - "Parameter_4\n", - "\n", - "Parameter_5\n", - "\n", - "Parameter_4\n" - ] - } - ], - "source": [ - "vars2=sample_model._component_collections[1].get_all_variables()\n", - "for var in vars2:\n", - " print(var)\n", - " print(var.unique_name)\n", - "\n", - "var3=sample_model._component_collections[10].get_all_variables()\n", - "for var in var3:\n", - " print(var)\n", - " print(var.unique_name)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "02320e75", - "metadata": {}, - "outputs": [], - "source": [ - "var." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5451bbf3", - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index 5a3f6073..1bf2aa5a 100644 --- a/src/easydynamics/analysis/analysis.py +++ b/src/easydynamics/analysis/analysis.py @@ -3,6 +3,8 @@ import numpy as np +import plopp as pp +import scipp as sc from easyscience.fitting.multi_fitter import MultiFitter from easyscience.variable import Parameter @@ -11,6 +13,7 @@ from easydynamics.experiment import Experiment from easydynamics.sample_model import SampleModel from easydynamics.sample_model.instrument_model import InstrumentModel +from easydynamics.utils.utils import _in_notebook class Analysis(AnalysisBase): @@ -44,9 +47,7 @@ def __init__( for Q_index in range(len(self.Q)): analysis = Analysis1d( display_name=f"{self.display_name}_Q{Q_index}", - unique_name=( - f"{self.unique_name}_Q{Q_index}" if self.unique_name else None - ), + unique_name=(f"{self.unique_name}_Q{Q_index}"), experiment=self.experiment, sample_model=self.sample_model, instrument_model=self.instrument_model, @@ -59,55 +60,156 @@ def __init__( # Properties ############# + @property + def analysis_list(self) -> list[Analysis1d]: + """List of Analysis1d objects, one for each Q index.""" + return self._analysis_list + + @analysis_list.setter + def analysis_list(self, value: list[Analysis1d]) -> None: + """analysis_list is read-only. To change the analysis list, + modify the experiment, sample model, or instrument model.""" + + raise AttributeError( + "analysis_list is read-only. " + "To change the analysis list, modify the experiment, sample model, " + "or instrument model." + ) + ############# # Other methods ############# - def calculate(self, Q_index: int | None = None) -> list[np.ndarray]: - """Calculate model data for a specific Q index.""" + def calculate(self, Q_index: int | None = None) -> list[np.ndarray] | np.ndarray: + """Calculate model data for a specific Q index. + If Q_index is None, calculate for all Q indices and return a + list of arrays. + + Parameters: Q_index: Index of the Q value to calculate for. If + None, calculate for all Q values. + + Returns: If Q_index is None, returns a list of numpy arrays, one + for each Q index. If Q_index is an integer, returns a single + numpy array for that Q index. + """ if Q_index is None: - result = [] - for analysis in self._analysis_list: - result.append(analysis.calculate()) - return result + return [analysis.calculate() for analysis in self.analysis_list] + + self._verify_Q_index(Q_index) + return self.analysis_list[Q_index].calculate() + + def fit(self, fit_method: str = "independent", Q_index: int | None = None): + """Fit the model to the experimental data. + + Parameters: fit_method: Method to use for fitting. Options are + "independent" (fit each Q index independently, one after the + other) or "simultaneous" (fit all Q indices simultaneously). + Q_index: If fit_method is "sequential", specify which Q index to + fit. If None, fit all Q indices independently. + + Returns: Fit results, which may be a list of FitResults if + fitting independently, or a single FitResults object if fitting + simultaneously. + """ + + if fit_method == "independent": + if Q_index is not None: + return self._fit_single_Q(Q_index) + else: + return self._fit_all_Q_independently() + elif fit_method == "simultaneous": + return self._fit_all_Q_simultaneously() + else: + raise ValueError( + "Invalid fit method. Choose 'independent' or 'simultaneous'." + ) - if Q_index < 0 or Q_index >= len(self._analysis_list): - raise IndexError("Q_index out of range.") + def plot_data_and_model( + self, + plot_components: bool = True, + Q_index: int | None = None, + **kwargs, + ) -> None: + """Plot the dataset using plopp.""" + + if self.experiment.binned_data is None: + raise ValueError("No data to plot. Please load data first.") + + if not _in_notebook(): + raise RuntimeError( + "plot_data() can only be used in a Jupyter notebook environment." + ) + from IPython.display import display + + plot_kwargs_defaults = { + "title": self.display_name, + "linestyle": {"Data": "none", "Model": "-"}, + "marker": {"Data": "o", "Model": None}, + "color": {"Data": "black", "Model": "red"}, + } + # Overwrite defaults with any user-provided kwargs + plot_kwargs_defaults.update(kwargs) + data_and_model = { + "Data": self.experiment.binned_data, + "Model": self._create_model_scipp_array(), + } + + if plot_components: + components_da, background_da = ( + self._create_components_and_background_scipp_arrays() + ) + + data_and_model["Background"] = background_da + plot_kwargs_defaults["linestyle"]["Background"] = "--" + plot_kwargs_defaults["marker"]["Background"] = None - return self._analysis_list[Q_index].calculate() + for icomp in range(components_da.sizes["component"]): + Q_index = 0 + comp_name = ( + self.sample_model.get_component_collection(Q_index) + .components[icomp] + .display_name + ) + data_and_model[comp_name] = components_da["component", icomp] + plot_kwargs_defaults["linestyle"][comp_name] = "--" + plot_kwargs_defaults["marker"][comp_name] = None + + fig = pp.slicer( + data_and_model, + **plot_kwargs_defaults, + ) + display(fig) ############# # Private methods ############# - def _fit_single_Q(self, Q_index: int) -> None: + def _fit_single_Q(self, Q_index: int): """Fit data for a single Q index.""" - if Q_index < 0 or Q_index >= len(self._analysis_list): - raise IndexError("Q_index out of range.") + self._verify_Q_index(Q_index) - self._analysis_list[Q_index].fit() + return self.analysis_list[Q_index].fit() - def _fit_all_Q_independently(self) -> None: + def _fit_all_Q_independently(self): """Fit data for all Q indices independently.""" + return [analysis.fit() for analysis in self.analysis_list] - for analysis in self._analysis_list: - analysis.fit() - - def _fit_all_Q_simultaneously(self) -> None: + def _fit_all_Q_simultaneously(self): """Fit data for all Q indices simultaneously.""" xs = [] ys = [] ws = [] - for analysis in self._analysis_list: + for analysis in self.analysis_list: data = analysis.experiment.data["Q", analysis.Q_index] x = data.coords["energy"].values y = data.values e = np.sqrt(data.variances) + # Make sure the convolver is up to date for this Q index analysis._convolver = analysis._create_convolver( Q_index=analysis.Q_index, energy=x, @@ -118,9 +220,8 @@ def _fit_all_Q_simultaneously(self) -> None: ws.append(1.0 / e) fit_functions = [] - - for analysis in self._analysis_list: - + for analysis in self.analysis_list: + # Use the private method to avoid excessive checks def make_fit_func(a): def fit_func(_): return a._calculate() @@ -130,7 +231,7 @@ def fit_func(_): fit_functions.append(make_fit_func(analysis)) mf = MultiFitter( - fit_objects=self._analysis_list, + fit_objects=self.analysis_list, fit_functions=fit_functions, ) @@ -141,6 +242,83 @@ def fit_func(_): ) return results + def _verify_Q_index(self, Q_index: int) -> None: + """Verify that the provided Q_index is valid.""" + if not isinstance(Q_index, int): + raise TypeError("Q_index must be an integer.") + if Q_index < 0 or Q_index >= len(self.analysis_list): + raise IndexError("Q_index out of range.") + + def _create_model_scipp_array(self) -> sc.DataArray: + """Create a scipp array for the model""" + + model = sc.array(dims=["Q", "energy"], values=self.calculate()) + model_data_array = sc.DataArray( + data=model, + coords={"Q": self.Q, "energy": self.experiment.energy}, + ) + return model_data_array + + def _create_components_and_background_scipp_arrays( + self, + ) -> tuple[sc.DataArray, sc.DataArray]: + """ + Create: + 1) A DataArray with sample components + background + dims = (component, Q, energy) + 2) A DataArray with summed background + dims = (Q, energy) + """ + + component_values = None # List[List[np.ndarray]] + background_values = [] # List[np.ndarray] + + for analysis in self.analysis_list: + sample_comps_q, background_comps_q = ( + analysis.calculate_individual_components() + ) + + # (energy,) + background_sum_q = sum(background_comps_q) + background_values.append(background_sum_q) + + if component_values is None: + component_values = [[] for _ in range(len(sample_comps_q))] + + for icomp, sample_comp_q in enumerate(sample_comps_q): + component_values[icomp].append(sample_comp_q + background_sum_q) + + # Sample components DataArray + components_array = sc.array( + dims=["component", "Q", "energy"], + values=component_values, + ) + + components_da = sc.DataArray( + data=components_array, + coords={ + "component": sc.arange("component", len(component_values)), + "Q": self.Q, + "energy": self.experiment.energy, + }, + ) + + # Background-only DataArray + background_array = sc.array( + dims=["Q", "energy"], + values=background_values, + ) + + background_da = sc.DataArray( + data=background_array, + coords={ + "Q": self.Q, + "energy": self.experiment.energy, + }, + ) + + return components_da, background_da + ############# # Dunder methods ############# diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index 57046b4b..2c00bfda 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -7,12 +7,16 @@ import numpy as np import scipp as sc from easyscience.fitting.fitter import Fitter as EasyScienceFitter +from easyscience.fitting.minimizers.utils import FitResults from easyscience.variable import DescriptorNumber from easydynamics.analysis.analysis_base import AnalysisBase +from easydynamics.convolution.convolution import Convolution from easydynamics.experiment import Experiment from easydynamics.sample_model import InstrumentModel from easydynamics.sample_model import SampleModel +from easydynamics.sample_model.component_collection import ComponentCollection +from easydynamics.sample_model.components.model_component import ModelComponent class Analysis1d(AnalysisBase): @@ -156,7 +160,7 @@ def calculate_individual_components( return sample_intensity, background_intensity - def fit(self): + def fit(self) -> FitResults: """Fit the model to the experimental data for a given Q index. Args: @@ -235,21 +239,33 @@ def plot_data_and_model( background = sum(background_comps) sample_comps = [comp + background for comp in sample_comps] for i, comp in enumerate(sample_comps): + comp_name = ( + self.sample_model.get_component_collection(Q_index) + .components[i] + .display_name + ) plt.plot( energy, comp, - label=f"Sample Component {i + 1}", + label=comp_name, linestyle="--", ) for i, comp in enumerate(background_comps): + comp_name = ( + self.instrument_model.background_model.get_component_collection( + Q_index + ) + .components[i] + .display_name + ) plt.plot( energy, comp, - label=f"Background Component {i + 1}", + label=comp_name, linestyle=":", ) plt.xlabel(f"Energy ({self.energy.unit})") - plt.ylabel(f"Intensity ({self.sample_model.unit})") + plt.ylabel("Intensity (arb. units)") plt.title(f"Data and Model at Q index {Q_index}") plt.legend() plt.show() @@ -320,3 +336,206 @@ def _on_Q_index_changed(self) -> None: the Convolution object for the new Q index. """ self._convolver = self._create_convolver(Q_index=self.Q_index) + + def _evaluate_components( + self, + components: ComponentCollection | ModelComponent, + energy: np.ndarray | sc.Variable | None = None, + convolver: Convolution | None = None, + convolve: bool = True, + Q_index: int | None = None, + ): + """ + Calculate the contribution of a set of components, optionally + convolving with the resolution. + If convolve is True and a Convolution object is provided, + use it to perform the convolution of the components with the + resolution. If convolve is True but no Convolution object is + provided, create a new Convolution object for the given + components and energy. If convolve is False, evaluate the + components directly without convolution. + Args: + components (ComponentCollection | ModelComponent): + The components to evaluate. + energy (np.ndarray | sc.Variable | None): + The energy values to evaluate the components for. If + None, the energy values from the experiment will be + used. + convolver (Convolution | None): + An optional Convolution object to use for convolution. + If None, a new Convolution object will be created if + convolve is True. + convolve (bool): + Whether to perform convolution with the resolution. + Default is True. + """ + if Q_index is None: + Q_index = self._require_Q_index() + energy = self._handle_energy(energy) + energy_offset = self.instrument_model.get_energy_offset_at_Q(Q_index).value + + # If there are no components, return zero + if isinstance(components, ComponentCollection) and components.is_empty: + return np.zeros_like(energy) + + # No convolution + if not convolve: + return components.evaluate(energy - energy_offset) + + resolution = self.instrument_model.resolution_model.get_component_collection( + Q_index + ) + if resolution.is_empty: + return components.evaluate(energy - energy_offset) + + # Convolution For fitting we don't want to create a new + # Convolution object at each iteration + if convolver is not None: + return convolver.convolution() + + # For evaluating individual components + conv = Convolution( + sample_components=components, + resolution_components=resolution, + energy=energy, + temperature=self.temperature, + energy_offset=energy_offset, + ) + return conv.convolution() + + def _evaluate_sample( + self, + energy: np.ndarray | sc.Variable | None = None, + Q_index: int | None = None, + ): + """ + Evaluate the sample contribution for a given Q index. + + If a Convolution object exists for the Q index, use it to + perform the convolution of the sample components with the + resolution components. If no Convolution object exists, evaluate + the sample components directly without convolution. + + Args: + energy (np.ndarray | sc.Variable | None): The energy values + to evaluate the sample contribution for. If None, the energy + values from the experiment will be used. + Returns: + np.ndarray: The evaluated sample contribution. + """ + if Q_index is None: + Q_index = self._require_Q_index() + components = self.sample_model.get_component_collection(Q_index=Q_index) + return self._evaluate_components( + components=components, + energy=energy, + convolver=self._convolver, + convolve=True, + ) + + def _evaluate_sample_component( + self, + component, + energy: np.ndarray | sc.Variable | None = None, + ): + """ + Evaluate a single sample component for a given Q index. + If a Convolution object exists for the Q index, use it to + perform the convolution of the sample component with the + resolution components. If no Convolution object exists, evaluate + the sample component directly without convolution. + Args: + component: The sample component to evaluate. + energy (np.ndarray | sc.Variable | None): The energy values + to evaluate the sample component for. If None, the energy + values from the experiment will be used. + Returns: + np.ndarray: The evaluated sample component contribution. + """ + return self._evaluate_components( + components=component, + energy=energy, + convolver=None, + convolve=True, + ) + + def _evaluate_background( + self, + energy: np.ndarray | sc.Variable | None = None, + Q_index: int | None = None, + ): + """ + Evaluate the background contribution for a given Q index. + Evaluate each background component separately to get individual + contributions. Args: + energy (np.ndarray | sc.Variable | None): The energy values + to evaluate the background contribution for. If None, the + energy values from the experiment will be used. + Returns: + np.ndarray: The evaluated background contribution. + """ + + if Q_index is None: + Q_index = self._require_Q_index() + background_components = ( + self.instrument_model.background_model.get_component_collection( + Q_index=Q_index + ) + ) + return self._evaluate_components( + components=background_components, + energy=energy, + convolver=None, + convolve=False, + ) + + def _evaluate_background_component( + self, + component, + energy: np.ndarray | sc.Variable | None = None, + ): + """ + Evaluate a single background component for a given Q index. + Evaluate the background component directly without convolution. + Args: + component: The background component to evaluate. + energy (np.ndarray | sc.Variable | None): The energy values + to evaluate the background component for. If None, the energy + values from the experiment will be used. + Returns: + np.ndarray: The evaluated background component contribution. + """ + + return self._evaluate_components( + components=component, + energy=energy, + convolver=None, + convolve=False, + ) + + def _create_convolver( + self, Q_index: int, energy: np.ndarray | sc.Variable | None = None + ) -> Convolution | None: + """Initialize and return a Convolution object for the given Q + index. + """ + sample_components = self.sample_model.get_component_collection(Q_index) + if sample_components.is_empty: + return None + + resolution_components = ( + self.instrument_model.resolution_model.get_component_collection(Q_index) + ) + if resolution_components.is_empty: + return None + if energy is None: + energy = self.energy + # TODO: allow convolution options to be set. + convolver = Convolution( + sample_components=sample_components, + resolution_components=resolution_components, + energy=energy, + temperature=self.temperature, + energy_offset=self.instrument_model.get_energy_offset_at_Q(Q_index), + ) + return convolver diff --git a/src/easydynamics/analysis/analysis_base.py b/src/easydynamics/analysis/analysis_base.py index 7caf89d6..3a7d0e8b 100644 --- a/src/easydynamics/analysis/analysis_base.py +++ b/src/easydynamics/analysis/analysis_base.py @@ -2,17 +2,13 @@ # SPDX-License-Identifier: BSD-3-Clause -import numpy as np import scipp as sc from easyscience.base_classes.model_base import ModelBase as EasyScienceModelBase from easyscience.variable import Parameter -from easydynamics.convolution import Convolution from easydynamics.experiment import Experiment from easydynamics.sample_model import InstrumentModel from easydynamics.sample_model import SampleModel -from easydynamics.sample_model.component_collection import ComponentCollection -from easydynamics.sample_model.components.model_component import ModelComponent class AnalysisBase(EasyScienceModelBase): @@ -173,209 +169,6 @@ def _on_sample_model_changed(self) -> None: def _on_instrument_model_changed(self) -> None: self._instrument_model.Q = self.Q - def _create_convolver( - self, Q_index: int, energy: np.ndarray | sc.Variable | None = None - ) -> Convolution | None: - """Initialize and return a Convolution object for the given Q - index. - """ - sample_components = self.sample_model.get_component_collection(Q_index) - if sample_components.is_empty: - return None - - resolution_components = ( - self.instrument_model.resolution_model.get_component_collection(Q_index) - ) - if resolution_components.is_empty: - return None - if energy is None: - energy = self.energy - # TODO: allow convolution options to be set. - convolver = Convolution( - sample_components=sample_components, - resolution_components=resolution_components, - energy=energy, - temperature=self.temperature, - energy_offset=self.instrument_model.get_energy_offset_at_Q(Q_index), - ) - return convolver - - def _evaluate_components( - self, - components: ComponentCollection | ModelComponent, - energy: np.ndarray | sc.Variable | None = None, - convolver: Convolution | None = None, - convolve: bool = True, - Q_index: int | None = None, - ): - """ - Calculate the contribution of a set of components, optionally - convolving with the resolution. - If convolve is True and a Convolution object is provided, - use it to perform the convolution of the components with the - resolution. If convolve is True but no Convolution object is - provided, create a new Convolution object for the given - components and energy. If convolve is False, evaluate the - components directly without convolution. - Args: - components (ComponentCollection | ModelComponent): - The components to evaluate. - energy (np.ndarray | sc.Variable | None): - The energy values to evaluate the components for. If - None, the energy values from the experiment will be - used. - convolver (Convolution | None): - An optional Convolution object to use for convolution. - If None, a new Convolution object will be created if - convolve is True. - convolve (bool): - Whether to perform convolution with the resolution. - Default is True. - """ - if Q_index is None: - Q_index = self._require_Q_index() - energy = self._handle_energy(energy) - energy_offset = self.instrument_model.get_energy_offset_at_Q(Q_index).value - - # If there are no components, return zero - if isinstance(components, ComponentCollection) and components.is_empty: - return np.zeros_like(energy) - - # No convolution - if not convolve: - return components.evaluate(energy - energy_offset) - - resolution = self.instrument_model.resolution_model.get_component_collection( - Q_index - ) - if resolution.is_empty: - return components.evaluate(energy - energy_offset) - - # Convolution For fitting we don't want to create a new - # Convolution object at each iteration - if convolver is not None: - return convolver.convolution() - - # For evaluating individual components - conv = Convolution( - sample_components=components, - resolution_components=resolution, - energy=energy, - temperature=self.temperature, - energy_offset=energy_offset, - ) - return conv.convolution() - - def _evaluate_sample( - self, - energy: np.ndarray | sc.Variable | None = None, - Q_index: int | None = None, - ): - """ - Evaluate the sample contribution for a given Q index. - - If a Convolution object exists for the Q index, use it to - perform the convolution of the sample components with the - resolution components. If no Convolution object exists, evaluate - the sample components directly without convolution. - - Args: - energy (np.ndarray | sc.Variable | None): The energy values - to evaluate the sample contribution for. If None, the energy - values from the experiment will be used. - Returns: - np.ndarray: The evaluated sample contribution. - """ - if Q_index is None: - Q_index = self._require_Q_index() - components = self.sample_model.get_component_collection(Q_index=Q_index) - return self._evaluate_components( - components=components, - energy=energy, - convolver=self._convolver, - convolve=True, - ) - - def _evaluate_sample_component( - self, - component, - energy: np.ndarray | sc.Variable | None = None, - ): - """ - Evaluate a single sample component for a given Q index. - If a Convolution object exists for the Q index, use it to - perform the convolution of the sample component with the - resolution components. If no Convolution object exists, evaluate - the sample component directly without convolution. - Args: - component: The sample component to evaluate. - energy (np.ndarray | sc.Variable | None): The energy values - to evaluate the sample component for. If None, the energy - values from the experiment will be used. - Returns: - np.ndarray: The evaluated sample component contribution. - """ - return self._evaluate_components( - components=component, - energy=energy, - convolver=None, - convolve=True, - ) - - def _evaluate_background( - self, - energy: np.ndarray | sc.Variable | None = None, - Q_index: int | None = None, - ): - """ - Evaluate the background contribution for a given Q index. - Evaluate each background component separately to get individual - contributions. Args: - energy (np.ndarray | sc.Variable | None): The energy values - to evaluate the background contribution for. If None, the - energy values from the experiment will be used. - Returns: - np.ndarray: The evaluated background contribution. - """ - - if Q_index is None: - Q_index = self._require_Q_index() - background_components = ( - self.instrument_model.background_model.get_component_collection( - Q_index=Q_index - ) - ) - return self._evaluate_components( - components=background_components, - energy=energy, - convolver=None, - convolve=False, - ) - - def _evaluate_background_component( - self, - component, - energy: np.ndarray | sc.Variable | None = None, - ): - """ - Evaluate a single background component for a given Q index. - Evaluate the background component directly without convolution. - Args: - component: The background component to evaluate. - energy (np.ndarray | sc.Variable | None): The energy values - to evaluate the background component for. If None, the energy - values from the experiment will be used. - Returns: - np.ndarray: The evaluated background component contribution. - """ - - return self._evaluate_components( - components=component, - energy=energy, - convolver=None, - convolve=False, - ) - ############# # Dunder methods ############# diff --git a/src/easydynamics/experiment/experiment.py b/src/easydynamics/experiment/experiment.py index b3df2a11..c0fd4b5e 100644 --- a/src/easydynamics/experiment/experiment.py +++ b/src/easydynamics/experiment/experiment.py @@ -8,6 +8,8 @@ from scipp.io import load_hdf5 as sc_load_hdf5 from scipp.io import save_hdf5 as sc_save_hdf5 +from easydynamics.utils.utils import _in_notebook + class Experiment(NewBase): """Holds data from an experiment as a sc.DataArray along with @@ -19,7 +21,7 @@ class Experiment(NewBase): def __init__( self, - display_name: str = 'MyExperiment', + display_name: str = "MyExperiment", unique_name: str | None = None, data: sc.DataArray | str | None = None, ): @@ -37,7 +39,7 @@ def __init__( self._data = data else: raise TypeError( - f'Data must be a sc.DataArray or a filename string, not {type(data).__name__}' + f"Data must be a sc.DataArray or a filename string, not {type(data).__name__}" ) self._binned_data = ( @@ -57,7 +59,7 @@ def data(self) -> sc.DataArray | None: def data(self, value: sc.DataArray): """Set the dataset associated with this experiment.""" if not isinstance(value, sc.DataArray): - raise TypeError(f'Data must be a sc.DataArray, not {type(value).__name__}') + raise TypeError(f"Data must be a sc.DataArray, not {type(value).__name__}") self._validate_coordinates(value) self._data = value self._binned_data = ( @@ -72,33 +74,35 @@ def binned_data(self) -> sc.DataArray | None: @binned_data.setter def binned_data(self, value: sc.DataArray): """Set the binned dataset associated with this experiment.""" - raise AttributeError('binned_data is a read-only property. Use rebin() to rebin the data') + raise AttributeError( + "binned_data is a read-only property. Use rebin() to rebin the data" + ) @property def Q(self) -> sc.Variable | None: """Get the Q values from the dataset.""" if self._data is None: - warnings.warn('No data loaded.', UserWarning) + warnings.warn("No data loaded.", UserWarning) return None - return self._binned_data.coords['Q'] + return self._binned_data.coords["Q"] @Q.setter def Q(self, value: sc.Variable): """Set the Q values for the dataset.""" - raise AttributeError('Q is a read-only property derived from the data.') + raise AttributeError("Q is a read-only property derived from the data.") @property def energy(self) -> sc.Variable: """Get the energy values from the dataset.""" if self._data is None: - warnings.warn('No data loaded.', UserWarning) + warnings.warn("No data loaded.", UserWarning) return None - return self._binned_data.coords['energy'] + return self._binned_data.coords["energy"] @energy.setter def energy(self, value: sc.Variable): """Set the energy values for the dataset.""" - raise AttributeError('energy is a read-only property derived from the data.') + raise AttributeError("energy is a read-only property derived from the data.") ########### # Handle data @@ -113,19 +117,19 @@ def load_hdf5(self, filename: str, display_name: str | None = None): experiment. """ if not isinstance(filename, str): - raise TypeError(f'Filename must be a string, not {type(filename).__name__}') + raise TypeError(f"Filename must be a string, not {type(filename).__name__}") if display_name is not None: if not isinstance(display_name, str): raise TypeError( - f'Display name must be a string, not {type(display_name).__name__}' + f"Display name must be a string, not {type(display_name).__name__}" ) self.display_name = display_name loaded_data = sc_load_hdf5(filename) if not isinstance(loaded_data, sc.DataArray): raise TypeError( - f'Loaded data must be a sc.DataArray, not {type(loaded_data).__name__}' + f"Loaded data must be a sc.DataArray, not {type(loaded_data).__name__}" ) self._validate_coordinates(loaded_data) self.data = loaded_data @@ -138,13 +142,13 @@ def save_hdf5(self, filename: str | None = None): """ if filename is None: - filename = f'{self.unique_name}.h5' + filename = f"{self.unique_name}.h5" if not isinstance(filename, str): - raise TypeError(f'Filename must be a string, not {type(filename).__name__}') + raise TypeError(f"Filename must be a string, not {type(filename).__name__}") if self._data is None: - raise ValueError('No data to save.') + raise ValueError("No data to save.") dir_name = os.path.dirname(filename) if dir_name: @@ -172,31 +176,33 @@ def rebin(self, dimensions: dict[str, int | sc.Variable]) -> None: if not isinstance(dimensions, dict): raise TypeError( - 'dimensions must be a dictionary mapping dimension names ' - 'to number of bins or bin values as sc.Variable.' + "dimensions must be a dictionary mapping dimension names " + "to number of bins or bin values as sc.Variable." ) if self._data is None: - raise ValueError('No data to rebin. Please load data first.') + raise ValueError("No data to rebin. Please load data first.") binned_data = self._data.copy() dim_copy = dimensions.copy() for dim, value in dim_copy.items(): if not isinstance(dim, str): raise TypeError( - f'Dimension keys must be strings. Got {type(dim)} for {dim} instead.' + f"Dimension keys must be strings. Got {type(dim)} for {dim} instead." ) if dim not in self._data.dims: raise KeyError( f"Dimension '{dim}' not a valid dimension for rebinning. " - f'Should be one of {self._data.dims}.' + f"Should be one of {self._data.dims}." ) - if isinstance(value, float) and value.is_integer(): # I allow eg. 2.0 as well as 2 + if ( + isinstance(value, float) and value.is_integer() + ): # I allow eg. 2.0 as well as 2 value = int(value) # This line can be removed when scipp resize support # resizing with coordinates dimensions[dim] = value if not (isinstance(value, int) or isinstance(value, sc.Variable)): raise TypeError( - f'Dimension values must be integers or sc.Variable. ' + f"Dimension values must be integers or sc.Variable. " f"Got {type(value)} for dimension '{dim}' instead." ) binned_data = binned_data.bin({dim: value}) @@ -213,15 +219,17 @@ def plot_data(self, slicer=False, **kwargs) -> None: """Plot the dataset using plopp.""" if self._binned_data is None: - raise ValueError('No data to plot. Please load data first.') + raise ValueError("No data to plot. Please load data first.") - if not self._in_notebook(): - raise RuntimeError('plot_data() can only be used in a Jupyter notebook environment.') + if not _in_notebook(): + raise RuntimeError( + "plot_data() can only be used in a Jupyter notebook environment." + ) from IPython.display import display plot_kwargs_defaults = { - 'title': self.display_name, + "title": self.display_name, } # Overwrite defaults with any user-provided kwargs plot_kwargs_defaults.update(kwargs) @@ -232,7 +240,7 @@ def plot_data(self, slicer=False, **kwargs) -> None: ) else: fig = pp.plot( - self._binned_data.transpose(dims=['energy', 'Q']), + self._binned_data.transpose(dims=["energy", "Q"]), **plot_kwargs_defaults, ) display(fig) @@ -241,26 +249,6 @@ def plot_data(self, slicer=False, **kwargs) -> None: # private methods ########### - @staticmethod - def _in_notebook() -> bool: - """Check if the code is running in a Jupyter notebook. - - Returns: - bool: True if in a Jupyter notebook, False otherwise. - """ - try: - from IPython import get_ipython - - shell = get_ipython().__class__.__name__ - if shell == 'ZMQInteractiveShell': - return True # Jupyter notebook or JupyterLab - elif shell == 'TerminalInteractiveShell': - return False # Terminal IPython - else: - return False - except (NameError, ImportError): - return False # Standard Python (no IPython) - @staticmethod def _validate_coordinates(data: sc.DataArray) -> None: """Validate that required coordinates are present in the data. @@ -269,9 +257,9 @@ def _validate_coordinates(data: sc.DataArray) -> None: ValueError: If required coordinates are missing. """ if not isinstance(data, sc.DataArray): - raise TypeError('Data must be a sc.DataArray.') + raise TypeError("Data must be a sc.DataArray.") - required_coords = ['Q', 'energy'] + required_coords = ["Q", "energy"] for coord in required_coords: if coord not in data.coords: raise ValueError(f"Data is missing required coordinate: '{coord}'") @@ -297,11 +285,11 @@ def _convert_to_bin_centers(self, data: sc.DataArray) -> sc.DataArray: ########### def __repr__(self) -> str: - return f'Experiment `{self.unique_name}` with data: {self._data}' + return f"Experiment `{self.unique_name}` with data: {self._data}" - def __copy__(self) -> 'Experiment': + def __copy__(self) -> "Experiment": """Return a copy of the object.""" - temp = self.to_dict(skip=['unique_name']) + temp = self.to_dict(skip=["unique_name"]) new_obj = self.__class__.from_dict(temp) new_obj.data = self.data.copy() if self.data is not None else None return new_obj diff --git a/src/easydynamics/sample_model/model_base.py b/src/easydynamics/sample_model/model_base.py index 85b74d9f..bb5859cc 100644 --- a/src/easydynamics/sample_model/model_base.py +++ b/src/easydynamics/sample_model/model_base.py @@ -282,13 +282,10 @@ def _generate_component_collections(self) -> None: self._component_collections = [] return - self._component_collections = [ComponentCollection() for _ in self._Q] - - # Add copies of components from self._components to each - # component collection - for collection in self._component_collections: - for component in self._components.components: - collection.append_component(copy(component)) + # Will fix it for my code I think + self._component_collections = [] + for _ in self._Q: + self._component_collections.append(copy(self._components)) def _on_Q_change(self) -> None: """Handle changes to the Q values.""" diff --git a/src/easydynamics/utils/utils.py b/src/easydynamics/utils/utils.py index 576b451d..bcd44c14 100644 --- a/src/easydynamics/utils/utils.py +++ b/src/easydynamics/utils/utils.py @@ -26,7 +26,7 @@ def _validate_and_convert_Q(Q: Q_type | None) -> np.ndarray | None: if Q is None: return None if not isinstance(Q, (Numeric, list, np.ndarray, sc.Variable)): - raise TypeError('Q must be a number, list, numpy array, or scipp array.') + raise TypeError("Q must be a number, list, numpy array, or scipp array.") if isinstance(Q, Numeric): Q = np.array([Q]) @@ -34,14 +34,14 @@ def _validate_and_convert_Q(Q: Q_type | None) -> np.ndarray | None: Q = np.array(Q) if isinstance(Q, np.ndarray): if Q.ndim > 1: - raise ValueError('Q must be a 1-dimensional array.') + raise ValueError("Q must be a 1-dimensional array.") - Q = sc.array(dims=['Q'], values=Q, unit='1/angstrom') + Q = sc.array(dims=["Q"], values=Q, unit="1/angstrom") if isinstance(Q, sc.Variable): - if Q.dims != ('Q',): + if Q.dims != ("Q",): raise ValueError("Q must have a single dimension named 'Q'.") - Q = Q.to(unit='1/angstrom') + Q = Q.to(unit="1/angstrom") return Q.values @@ -64,7 +64,29 @@ def _validate_unit(unit: str | sc.Unit | None) -> sc.Unit | None: """ if unit is not None and not isinstance(unit, (str, sc.Unit)): - raise TypeError(f'unit must be None, a string, or a scipp Unit, got {type(unit).__name__}') + raise TypeError( + f"unit must be None, a string, or a scipp Unit, got {type(unit).__name__}" + ) if isinstance(unit, str): unit = sc.Unit(unit) return unit + + +def _in_notebook() -> bool: + """Check if the code is running in a Jupyter notebook. + + Returns: + bool: True if in a Jupyter notebook, False otherwise. + """ + try: + from IPython import get_ipython + + shell = get_ipython().__class__.__name__ + if shell == "ZMQInteractiveShell": + return True # Jupyter notebook or JupyterLab + elif shell == "TerminalInteractiveShell": + return False # Terminal IPython + else: + return False + except (NameError, ImportError): + return False # Standard Python (no IPython) From b477a38d3d853ee40d4d2938e16d3b90d1d9cefd Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Wed, 11 Feb 2026 20:08:17 +0100 Subject: [PATCH 08/27] analysis MWP --- src/easydynamics/analysis/analysis.py | 211 ++++----- src/easydynamics/analysis/analysis1d.py | 463 +++++++++----------- src/easydynamics/analysis/analysis_base.py | 40 +- src/easydynamics/experiment/experiment.py | 8 +- src/easydynamics/sample_model/model_base.py | 19 +- 5 files changed, 353 insertions(+), 388 deletions(-) diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index 1bf2aa5a..ae7b16a7 100644 --- a/src/easydynamics/analysis/analysis.py +++ b/src/easydynamics/analysis/analysis.py @@ -5,6 +5,7 @@ import numpy as np import plopp as pp import scipp as sc +from easyscience.fitting.minimizers.utils import FitResults from easyscience.fitting.multi_fitter import MultiFitter from easyscience.variable import Parameter @@ -26,9 +27,7 @@ def __init__( experiment: Experiment | None = None, sample_model: SampleModel | None = None, instrument_model: InstrumentModel | None = None, - extra_parameters: ( - Parameter | list[Parameter] | list[list[Parameter]] | None - ) = None, + extra_parameters: Parameter | list[Parameter] | None = None, ): super().__init__( @@ -37,6 +36,7 @@ def __init__( experiment=experiment, sample_model=sample_model, instrument_model=instrument_model, + extra_parameters=extra_parameters, ) if experiment is not None and not isinstance(experiment, Experiment): @@ -51,7 +51,7 @@ def __init__( experiment=self.experiment, sample_model=self.sample_model, instrument_model=self.instrument_model, - extra_parameters=extra_parameters, + extra_parameters=self._extra_parameters, Q_index=Q_index, ) self._analysis_list.append(analysis) @@ -79,7 +79,10 @@ def analysis_list(self, value: list[Analysis1d]) -> None: ############# # Other methods ############# - def calculate(self, Q_index: int | None = None) -> list[np.ndarray] | np.ndarray: + def calculate( + self, + Q_index: int | None = None, + ) -> list[np.ndarray] | np.ndarray: """Calculate model data for a specific Q index. If Q_index is None, calculate for all Q indices and return a list of arrays. @@ -95,23 +98,38 @@ def calculate(self, Q_index: int | None = None) -> list[np.ndarray] | np.ndarray if Q_index is None: return [analysis.calculate() for analysis in self.analysis_list] - self._verify_Q_index(Q_index) + Q_index = self._verify_Q_index(Q_index) return self.analysis_list[Q_index].calculate() - def fit(self, fit_method: str = "independent", Q_index: int | None = None): + def fit( + self, + fit_method: str = "independent", + Q_index: int | None = None, + ) -> FitResults | list[FitResults]: """Fit the model to the experimental data. - Parameters: fit_method: Method to use for fitting. Options are - "independent" (fit each Q index independently, one after the - other) or "simultaneous" (fit all Q indices simultaneously). - Q_index: If fit_method is "sequential", specify which Q index to - fit. If None, fit all Q indices independently. + Parameters: + --------------- + fit_method: string, optional + Method to use for fitting. Options are "independent" (fit + each Q index independently, one after the other) or + "simultaneous" (fit all Q indices simultaneously). + Q_index: int or None, optional + If fit_method is "independent", specify which Q index to + fit. If None, fit all Q indices independently. Returns: Fit results, which may be a list of FitResults if - fitting independently, or a single FitResults object if fitting - simultaneously. + fitting independently, or a single FitResults object if + fitting simultaneously. """ + if self.Q is None: + raise ValueError( + "No Q values available for fitting. Please check the experiment data." + ) + + Q_index = self._verify_Q_index(Q_index) + if fit_method == "independent": if Q_index is not None: return self._fit_single_Q(Q_index) @@ -126,11 +144,20 @@ def fit(self, fit_method: str = "independent", Q_index: int | None = None): def plot_data_and_model( self, - plot_components: bool = True, Q_index: int | None = None, + plot_components: bool = True, + add_background: bool = True, **kwargs, ) -> None: - """Plot the dataset using plopp.""" + """Plot the data and model using plopp.""" + + if Q_index is not None: + Q_index = self._verify_Q_index(Q_index) + return self.analysis_list[Q_index].plot_data_and_model( + plot_components=plot_components, + add_background=add_background, + **kwargs, + ) if self.experiment.binned_data is None: raise ValueError("No data to plot. Please load data first.") @@ -139,6 +166,18 @@ def plot_data_and_model( raise RuntimeError( "plot_data() can only be used in a Jupyter notebook environment." ) + + if self.Q is None: + raise ValueError( + "No Q values available for plotting. Please check the experiment data." + ) + + if not isinstance(plot_components, bool): + raise TypeError("plot_components must be True or False.") + + if not isinstance(add_background, bool): + raise TypeError("add_background must be True or False.") + from IPython.display import display plot_kwargs_defaults = { @@ -147,32 +186,20 @@ def plot_data_and_model( "marker": {"Data": "o", "Model": None}, "color": {"Data": "black", "Model": "red"}, } - # Overwrite defaults with any user-provided kwargs - plot_kwargs_defaults.update(kwargs) data_and_model = { "Data": self.experiment.binned_data, - "Model": self._create_model_scipp_array(), + "Model": self._create_model_array(), } if plot_components: - components_da, background_da = ( - self._create_components_and_background_scipp_arrays() - ) - - data_and_model["Background"] = background_da - plot_kwargs_defaults["linestyle"]["Background"] = "--" - plot_kwargs_defaults["marker"]["Background"] = None + components = self._create_components_dataset(add_background=add_background) + for key in components.keys(): + data_and_model[key] = components[key] + plot_kwargs_defaults["linestyle"][key] = "--" + plot_kwargs_defaults["marker"][key] = None - for icomp in range(components_da.sizes["component"]): - Q_index = 0 - comp_name = ( - self.sample_model.get_component_collection(Q_index) - .components[icomp] - .display_name - ) - data_and_model[comp_name] = components_da["component", icomp] - plot_kwargs_defaults["linestyle"][comp_name] = "--" - plot_kwargs_defaults["marker"][comp_name] = None + # Overwrite defaults with any user-provided kwargs + plot_kwargs_defaults.update(kwargs) fig = pp.slicer( data_and_model, @@ -184,18 +211,18 @@ def plot_data_and_model( # Private methods ############# - def _fit_single_Q(self, Q_index: int): + def _fit_single_Q(self, Q_index: int) -> FitResults: """Fit data for a single Q index.""" - self._verify_Q_index(Q_index) + Q_index = self._verify_Q_index(Q_index) return self.analysis_list[Q_index].fit() - def _fit_all_Q_independently(self): + def _fit_all_Q_independently(self) -> list[FitResults]: """Fit data for all Q indices independently.""" return [analysis.fit() for analysis in self.analysis_list] - def _fit_all_Q_simultaneously(self): + def _fit_all_Q_simultaneously(self) -> FitResults: """Fit data for all Q indices simultaneously.""" xs = [] @@ -210,29 +237,15 @@ def _fit_all_Q_simultaneously(self): e = np.sqrt(data.variances) # Make sure the convolver is up to date for this Q index - analysis._convolver = analysis._create_convolver( - Q_index=analysis.Q_index, - energy=x, - ) + analysis._convolver = analysis._create_convolver() xs.append(x) ys.append(y) ws.append(1.0 / e) - fit_functions = [] - for analysis in self.analysis_list: - # Use the private method to avoid excessive checks - def make_fit_func(a): - def fit_func(_): - return a._calculate() - - return fit_func - - fit_functions.append(make_fit_func(analysis)) - mf = MultiFitter( fit_objects=self.analysis_list, - fit_functions=fit_functions, + fit_functions=self.get_fit_functions(), ) results = mf.fit( @@ -242,14 +255,14 @@ def fit_func(_): ) return results - def _verify_Q_index(self, Q_index: int) -> None: - """Verify that the provided Q_index is valid.""" - if not isinstance(Q_index, int): - raise TypeError("Q_index must be an integer.") - if Q_index < 0 or Q_index >= len(self.analysis_list): - raise IndexError("Q_index out of range.") + def get_fit_functions(self) -> list[callable]: + """ + Get fit functions for all Q indices, which can be used for + simultaneous fitting. + """ + return [analysis.as_fit_function() for analysis in self.analysis_list] - def _create_model_scipp_array(self) -> sc.DataArray: + def _create_model_array(self) -> sc.DataArray: """Create a scipp array for the model""" model = sc.array(dims=["Q", "energy"], values=self.calculate()) @@ -259,65 +272,29 @@ def _create_model_scipp_array(self) -> sc.DataArray: ) return model_data_array - def _create_components_and_background_scipp_arrays( - self, - ) -> tuple[sc.DataArray, sc.DataArray]: - """ - Create: - 1) A DataArray with sample components + background - dims = (component, Q, energy) - 2) A DataArray with summed background - dims = (Q, energy) + def _create_components_dataset(self, add_background: bool = True) -> sc.Dataset: """ + Create a scipp dataset containing the individual components of + the model for plotting. - component_values = None # List[List[np.ndarray]] - background_values = [] # List[np.ndarray] + Parameters: + --------------- + add_background: bool, optional + Whether to add background components to the sample model + components. Default is True. - for analysis in self.analysis_list: - sample_comps_q, background_comps_q = ( - analysis.calculate_individual_components() - ) - - # (energy,) - background_sum_q = sum(background_comps_q) - background_values.append(background_sum_q) - - if component_values is None: - component_values = [[] for _ in range(len(sample_comps_q))] - - for icomp, sample_comp_q in enumerate(sample_comps_q): - component_values[icomp].append(sample_comp_q + background_sum_q) - - # Sample components DataArray - components_array = sc.array( - dims=["component", "Q", "energy"], - values=component_values, - ) - - components_da = sc.DataArray( - data=components_array, - coords={ - "component": sc.arange("component", len(component_values)), - "Q": self.Q, - "energy": self.experiment.energy, - }, - ) - - # Background-only DataArray - background_array = sc.array( - dims=["Q", "energy"], - values=background_values, - ) + Returns: A scipp Dataset where each variable is a component of + the model, with dimensions "Q" and "energy". + """ + if not isinstance(add_background, bool): + raise TypeError("add_background must be True or False.") - background_da = sc.DataArray( - data=background_array, - coords={ - "Q": self.Q, - "energy": self.experiment.energy, - }, - ) + datasets = [ + analysis._create_components_dataset_single_Q(add_background=add_background) + for analysis in self.analysis_list + ] - return components_da, background_da + return sc.concat(datasets, dim="Q") ############# # Dunder methods diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index 2c00bfda..4e652aac 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -40,18 +40,11 @@ def __init__( instrument_model=instrument_model, ) - if Q_index is not None: - if ( - not isinstance(Q_index, int) - or Q_index < 0 - or (self.Q is not None and Q_index >= len(self.Q)) - ): - raise ValueError("Q_index must be a valid index for the Q values.") - self._Q_index = Q_index + self._Q_index = self._verify_Q_index(Q_index) self._fit_result = None - self._convolver = self._create_convolver(Q_index=self.Q_index) + self._convolver = self._create_convolver() ############# # Properties @@ -63,36 +56,28 @@ def Q_index(self) -> int | None: return self._Q_index @Q_index.setter - def Q_index(self, index: int | None) -> None: + def Q_index(self, value: int | None) -> None: """Set the Q index for single Q analysis. Args: index (int | None): The Q index. """ - if index is not None: - if ( - not isinstance(index, int) - or index < 0 - or (self.Q is not None and index >= len(self.Q)) - ): - raise ValueError("Q_index must be a valid index for the Q values.") - self._Q_index = index + self._Q_index = self._verify_Q_index(value) self._on_Q_index_changed() ############# # Other methods ############# - def calculate(self, energy: np.ndarray | sc.Variable | None = None) -> np.ndarray: + def calculate(self) -> np.ndarray: """Calculate the model prediction for a given Q index. + Makes sure the convolver is up to date before calculating. - Args: - energy (float): The energy value to calculate the model for. Returns: - sc.DataArray: The calculated model prediction. + np.ndarray: The calculated model prediction. """ - self._convolver = self._create_convolver(Q_index=self.Q_index, energy=energy) + self._convolver = self._create_convolver() return self._calculate() @@ -102,7 +87,7 @@ def _calculate(self) -> np.ndarray: Args: energy (float): The energy value to calculate the model for. Returns: - sc.DataArray: The calculated model prediction. + np.ndarray: The calculated model prediction. """ sample_intensity = self._evaluate_sample() @@ -113,57 +98,9 @@ def _calculate(self) -> np.ndarray: return sample_plus_background - def calculate_individual_components( - self, - energy: np.ndarray | sc.Variable | None = None, - ) -> np.ndarray: - """Calculate the model prediction for a given Q index. - - Args: - energy (float): The energy value to calculate the model for. - Returns: - sc.DataArray: The calculated model prediction. - """ - Q_index = self._require_Q_index() - - energy = self._handle_energy(energy) - - sample_components = self.sample_model.get_component_collection(Q_index) - - if sample_components.is_empty: - sample_intensity = [np.zeros_like(energy)] - else: - sample_intensity = [] - for component in sample_components.components: - component_intensity = self._evaluate_sample_component( - component=component, - energy=energy, - ) - sample_intensity.append(component_intensity) - - # Background. Evaluate each background component separately to - # get individual contributions. - background_components = ( - self.instrument_model.background_model.get_component_collection(Q_index) - ) - - if background_components.is_empty: - background_intensity = [np.zeros_like(energy)] - else: - background_intensity = [] - for component in background_components.components: - component_intensity = self._evaluate_background_component( - component=component, - energy=energy, - ) - background_intensity.append(component_intensity) - - return sample_intensity, background_intensity - def fit(self) -> FitResults: """Fit the model to the experimental data for a given Q index. - Args: Returns: FitResult: The result of the fit. @@ -183,92 +120,34 @@ def fit(self) -> FitResults: y = data.values e = data.variances**0.5 - self._convolver = self._create_convolver(Q_index=self.Q_index, energy=x) - - def fit_func(_): - return self._calculate() + # Create convolver once to reuse during fitting + self._convolver = self._create_convolver() fitter = EasyScienceFitter( fit_object=self, - fit_function=fit_func, + fit_function=self.as_fit_function(), ) - # Perform the fit fit_result = fitter.fit(x=x, y=y, weights=1.0 / e) - # Store result self._fit_result = fit_result return fit_result - def plot_data_and_model( - self, - plot_individual_components: bool = True, - add_background: bool = True, - ) -> None: - """Plot the experimental data and the model prediction. + def as_fit_function(self, x=None, **kwargs): + """ + Return self._calculate as a fit function. - Args: - plot_individual_components (bool): Whether to plot - individual components. Default is True. + The EasyScience fitter requires x as input, but + self._calculate() already uses the correct energy from the + experiment. So we ignore the x input and just return the + calculated model. """ - if not isinstance(plot_individual_components, bool): - raise TypeError("plot_individual_components must be True or False.") - import matplotlib.pyplot as plt + def fit_function(x, **kwargs): + return self._calculate() - Q_index = self._require_Q_index() - if self.experiment is None or self.experiment.data is None: - raise ValueError("Experiment data is not available for plotting.") - data = self.experiment.data["Q", Q_index] - energy = data.coords["energy"].values - model = self.calculate(energy=energy) - plt.figure() - plt.errorbar( - energy, - data.values, - yerr=data.variances**0.5, - fmt="o", - label="Data", - color="black", - ) - plt.plot(energy, model, label="Model", color="red") - if plot_individual_components: - sample_comps, background_comps = self.calculate_individual_components() - if add_background: - background = sum(background_comps) - sample_comps = [comp + background for comp in sample_comps] - for i, comp in enumerate(sample_comps): - comp_name = ( - self.sample_model.get_component_collection(Q_index) - .components[i] - .display_name - ) - plt.plot( - energy, - comp, - label=comp_name, - linestyle="--", - ) - for i, comp in enumerate(background_comps): - comp_name = ( - self.instrument_model.background_model.get_component_collection( - Q_index - ) - .components[i] - .display_name - ) - plt.plot( - energy, - comp, - label=comp_name, - linestyle=":", - ) - plt.xlabel(f"Energy ({self.energy.unit})") - plt.ylabel("Intensity (arb. units)") - plt.title(f"Data and Model at Q index {Q_index}") - plt.legend() - plt.show() + return fit_function def get_all_variables(self) -> list[DescriptorNumber]: """Get all variables used in the analysis. @@ -285,13 +164,76 @@ def get_all_variables(self) -> list[DescriptorNumber]: return variables + def plot_data_and_model( + self, + plot_components: bool = True, + add_background=True, + **kwargs, + ): + """Plot the experimental data and the model prediction for a + given Q index. + + Uses Plopp for plotting. + + Args: + add_background (bool): Whether to add the background to the + model prediction when plotting individual components. + + kwargs: Keyword arguments to pass to the plotting + function. + Returns: + A plot of the data and model. + """ + import plopp as pp + + if self.experiment.data is None: + raise ValueError("No data to plot. Please load data first.") + + data = self.experiment.data["Q", self.Q_index] + model_array = self._create_sample_scipp_array() + + component_dataset = self._create_components_dataset_single_Q( + add_background=add_background + ) + + # Create a dataset containing the data, model, and individual + # components for plotting. + data_and_model = sc.Dataset( + { + "Data": data, + "Model": model_array, + } + ) + + data_and_model = sc.merge(data_and_model, component_dataset) + plot_kwargs_defaults = { + "title": self.display_name, + "linestyle": {"Data": "none", "Model": "-"}, + "marker": {"Data": "o", "Model": None}, + "color": {"Data": "black", "Model": "red"}, + } + + if plot_components: + for comp_name in component_dataset.keys(): + plot_kwargs_defaults["linestyle"][comp_name] = "--" + plot_kwargs_defaults["marker"][comp_name] = None + + # Overwrite defaults with any user-provided kwargs + plot_kwargs_defaults.update(kwargs) + + fig = pp.plot( + data_and_model, + **plot_kwargs_defaults, + ) + return fig + ############# - # Private methods + # Private methods: small utilities ############# def _require_Q_index(self) -> int: """ - Get the Q index for single Q analysis, ensuring it is set. + Get the Q index, ensuring it is set. Raises a ValueError if the Q index is not set. Returns: int: The Q index. @@ -300,78 +242,49 @@ def _require_Q_index(self) -> int: raise ValueError("Q_index must be set.") return self._Q_index - def _handle_energy( - self, energy: np.ndarray | sc.Variable | None - ) -> np.ndarray | sc.Variable: - """ " - Handle the energy input for evaluation methods. - - If energy is None, use the energy values from the experiment. - If energy is a sc.Variable, extract the values as a numpy array. - If energy is already a numpy array, return it as is. - - Args: - energy (np.ndarray | sc.Variable | None): The input energy values. - Returns: - np.ndarray: The energy values to use for evaluation. - """ - # TODO: handle units properly - - if energy is None: - energy = self.energy.values - - if isinstance(energy, np.ndarray): - return energy - - if isinstance(energy, sc.Variable): - return energy.values - - raise TypeError("Energy must be a numpy array, sc.Variable, or None.") - def _on_Q_index_changed(self) -> None: """ Handle changes to the Q index. - This method is called whenever the Q index is changed. It updates - the Convolution object for the new Q index. + This method is called whenever the Q index is changed. It + updates the Convolution object for the new Q index. """ - self._convolver = self._create_convolver(Q_index=self.Q_index) + self._convolver = self._create_convolver() + + ############# + # Private methods: evaluation + ############# def _evaluate_components( self, components: ComponentCollection | ModelComponent, - energy: np.ndarray | sc.Variable | None = None, convolver: Convolution | None = None, convolve: bool = True, - Q_index: int | None = None, - ): + ) -> np.ndarray: """ Calculate the contribution of a set of components, optionally convolving with the resolution. - If convolve is True and a Convolution object is provided, - use it to perform the convolution of the components with the - resolution. If convolve is True but no Convolution object is - provided, create a new Convolution object for the given - components and energy. If convolve is False, evaluate the - components directly without convolution. + If convolve is True and a + Convolution object is provided (for full model evaluation), we + use it to perform the convolution of the components with the + resolution. + If convolve is True but no Convolution object is + provided, create a new Convolution object for the given + components (for individual components). + If convolve is False, evaluate the components directly without + convolution (for background). Args: components (ComponentCollection | ModelComponent): The components to evaluate. - energy (np.ndarray | sc.Variable | None): - The energy values to evaluate the components for. If - None, the energy values from the experiment will be - used. - convolver (Convolution | None): - An optional Convolution object to use for convolution. - If None, a new Convolution object will be created if - convolve is True. + convolver (Convolution | None): An optional Convolution + object to use for convolution. If None, a new + Convolution object will be created if convolve is True. convolve (bool): Whether to perform convolution with the resolution. Default is True. """ - if Q_index is None: - Q_index = self._require_Q_index() - energy = self._handle_energy(energy) + Q_index = self._require_Q_index() + energy = self.energy.values energy_offset = self.instrument_model.get_energy_offset_at_Q(Q_index).value # If there are no components, return zero @@ -388,12 +301,15 @@ def _evaluate_components( if resolution.is_empty: return components.evaluate(energy - energy_offset) - # Convolution For fitting we don't want to create a new - # Convolution object at each iteration + # If a convolver is provided, use it. This allows reusing the + # same convolver for multiple evaluations during fitting for + # performance reasons. if convolver is not None: return convolver.convolution() - # For evaluating individual components + # If no convolver is provided, create a new one. This is for + # evaluating individual components for plotting, where + # performance is not important. conv = Convolution( sample_components=components, resolution_components=resolution, @@ -403,80 +319,49 @@ def _evaluate_components( ) return conv.convolution() - def _evaluate_sample( - self, - energy: np.ndarray | sc.Variable | None = None, - Q_index: int | None = None, - ): + def _evaluate_sample(self) -> np.ndarray: """ Evaluate the sample contribution for a given Q index. - If a Convolution object exists for the Q index, use it to - perform the convolution of the sample components with the - resolution components. If no Convolution object exists, evaluate - the sample components directly without convolution. + Assumes that self._convolver is up to date. - Args: - energy (np.ndarray | sc.Variable | None): The energy values - to evaluate the sample contribution for. If None, the energy - values from the experiment will be used. Returns: np.ndarray: The evaluated sample contribution. """ - if Q_index is None: - Q_index = self._require_Q_index() + Q_index = self._require_Q_index() components = self.sample_model.get_component_collection(Q_index=Q_index) return self._evaluate_components( components=components, - energy=energy, convolver=self._convolver, convolve=True, ) def _evaluate_sample_component( self, - component, - energy: np.ndarray | sc.Variable | None = None, - ): + component: ModelComponent, + ) -> np.ndarray: """ Evaluate a single sample component for a given Q index. - If a Convolution object exists for the Q index, use it to - perform the convolution of the sample component with the - resolution components. If no Convolution object exists, evaluate - the sample component directly without convolution. + Args: component: The sample component to evaluate. - energy (np.ndarray | sc.Variable | None): The energy values - to evaluate the sample component for. If None, the energy - values from the experiment will be used. Returns: np.ndarray: The evaluated sample component contribution. """ return self._evaluate_components( components=component, - energy=energy, convolver=None, convolve=True, ) - def _evaluate_background( - self, - energy: np.ndarray | sc.Variable | None = None, - Q_index: int | None = None, - ): + def _evaluate_background(self) -> np.ndarray: """ Evaluate the background contribution for a given Q index. - Evaluate each background component separately to get individual - contributions. Args: - energy (np.ndarray | sc.Variable | None): The energy values - to evaluate the background contribution for. If None, the - energy values from the experiment will be used. + Returns: np.ndarray: The evaluated background contribution. """ - - if Q_index is None: - Q_index = self._require_Q_index() + Q_index = self._require_Q_index() background_components = ( self.instrument_model.background_model.get_component_collection( Q_index=Q_index @@ -484,41 +369,41 @@ def _evaluate_background( ) return self._evaluate_components( components=background_components, - energy=energy, convolver=None, convolve=False, ) def _evaluate_background_component( self, - component, - energy: np.ndarray | sc.Variable | None = None, - ): + component: ModelComponent, + ) -> np.ndarray: """ Evaluate a single background component for a given Q index. - Evaluate the background component directly without convolution. + Args: component: The background component to evaluate. - energy (np.ndarray | sc.Variable | None): The energy values - to evaluate the background component for. If None, the energy - values from the experiment will be used. Returns: np.ndarray: The evaluated background component contribution. """ return self._evaluate_components( components=component, - energy=energy, convolver=None, convolve=False, ) - def _create_convolver( - self, Q_index: int, energy: np.ndarray | sc.Variable | None = None - ) -> Convolution | None: - """Initialize and return a Convolution object for the given Q - index. + def _create_convolver(self) -> Convolution | None: """ + Initialize and return a Convolution object for the given Q + index. If the necessary components for convolution are not + available, return None. + + Returns: + Convolution | None: The initialized Convolution object or + None if not available. + """ + Q_index = self._require_Q_index() + sample_components = self.sample_model.get_component_collection(Q_index) if sample_components.is_empty: return None @@ -528,8 +413,7 @@ def _create_convolver( ) if resolution_components.is_empty: return None - if energy is None: - energy = self.energy + energy = self.energy # TODO: allow convolution options to be set. convolver = Convolution( sample_components=sample_components, @@ -539,3 +423,72 @@ def _create_convolver( energy_offset=self.instrument_model.get_energy_offset_at_Q(Q_index), ) return convolver + + ############# + # Private methods: create scipp arrays for plotting + ############# + + def _create_component_scipp_array( + self, + component: ModelComponent, + background: np.ndarray | None = None, + ) -> sc.DataArray: + values = self._evaluate_sample_component(component) + if background is not None: + values += background + return self._to_scipp_array(values) + + def _create_background_component_scipp_array( + self, + component: ModelComponent, + ) -> sc.DataArray: + values = self._evaluate_background_component(component) + return self._to_scipp_array(values) + + def _create_sample_scipp_array(self) -> sc.DataArray: + values = self._calculate() + return self._to_scipp_array(values) + + def _create_components_dataset_single_Q( + self, add_background: bool = True + ) -> dict[str, sc.DataArray]: + """Create sc.DataArrays for all sample and background + components.""" + scipp_arrays = {} + sample_components = self.sample_model.get_component_collection( + Q_index=self.Q_index + ).components + + background_components = ( + self.instrument_model.background_model.get_component_collection( + Q_index=self.Q_index + ).components + ) + background = self._evaluate_background() if add_background else None + for component in sample_components: + scipp_arrays[component.display_name] = self._create_component_scipp_array( + component, background=background + ) + for component in background_components: + scipp_arrays[component.display_name] = ( + self._create_background_component_scipp_array(component) + ) + return sc.Dataset(scipp_arrays) + + def _to_scipp_array(self, values: np.ndarray) -> sc.DataArray: + """ + Convert a numpy array of values to a sc.DataArray with the + correct coordinates for energy and Q. + + Args: + values (np.ndarray): The values to convert. + Returns: + sc.DataArray: The converted sc.DataArray. + """ + return sc.DataArray( + data=sc.array(dims=["energy"], values=values), + coords={ + "energy": self.energy, + "Q": self.Q[self.Q_index], + }, + ) diff --git a/src/easydynamics/analysis/analysis_base.py b/src/easydynamics/analysis/analysis_base.py index 3a7d0e8b..e6f63939 100644 --- a/src/easydynamics/analysis/analysis_base.py +++ b/src/easydynamics/analysis/analysis_base.py @@ -107,9 +107,7 @@ def instrument_model(self, value: InstrumentModel) -> None: @property def Q(self) -> sc.Variable | None: """The Q values from the associated Experiment, if available.""" - if self.experiment is not None: - return self.experiment.Q - return None + return self.experiment.Q @Q.setter def Q(self, value) -> None: @@ -121,9 +119,7 @@ def energy(self) -> sc.Variable | None: """The energy values from the associated Experiment, if available. """ - if self.experiment is not None: - return self.experiment.energy - return None + return self.experiment.energy @energy.setter def energy(self, value) -> None: @@ -160,15 +156,47 @@ def temperature(self, value) -> None: ############# def _on_experiment_changed(self) -> None: + """ + Update the Q values in the sample and instrument models when the + experiment changes. + """ self._sample_model.Q = self.Q self._instrument_model.Q = self.Q def _on_sample_model_changed(self) -> None: + """ + Update the Q values in the sample model when the sample model + changes. + """ self._sample_model.Q = self.Q def _on_instrument_model_changed(self) -> None: + """ + Update the Q values in the instrument model when the instrument + model changes. + """ self._instrument_model.Q = self.Q + def _verify_Q_index(self, Q_index: int | None) -> int | None: + """ + Verify that the Q index is valid. + + Params: + Q_index (int | None): The Q index to verify. + Returns: + int | None: The verified Q index. + Raises: + ValueError: If the Q index is not valid. + """ + if Q_index is not None: + if ( + not isinstance(Q_index, int) + or Q_index < 0 + or (self.Q is not None and Q_index >= len(self.Q)) + ): + raise ValueError("Q_index must be a valid index for the Q values.") + return Q_index + ############# # Dunder methods ############# diff --git a/src/easydynamics/experiment/experiment.py b/src/easydynamics/experiment/experiment.py index c0fd4b5e..771656b0 100644 --- a/src/easydynamics/experiment/experiment.py +++ b/src/easydynamics/experiment/experiment.py @@ -1,6 +1,4 @@ import os -import warnings -from typing import Optional import plopp as pp import scipp as sc @@ -31,7 +29,7 @@ def __init__( ) if data is None: - self._data: Optional[sc.DataArray] = None + self._data = None elif isinstance(data, str): self.load_hdf5(filename=data) elif isinstance(data, sc.DataArray): @@ -82,7 +80,7 @@ def binned_data(self, value: sc.DataArray): def Q(self) -> sc.Variable | None: """Get the Q values from the dataset.""" if self._data is None: - warnings.warn("No data loaded.", UserWarning) + # warnings.warn("No data loaded.", UserWarning) return None return self._binned_data.coords["Q"] @@ -95,7 +93,7 @@ def Q(self, value: sc.Variable): def energy(self) -> sc.Variable: """Get the energy values from the dataset.""" if self._data is None: - warnings.warn("No data loaded.", UserWarning) + # warnings.warn("No data loaded.", UserWarning) return None return self._binned_data.coords["energy"] diff --git a/src/easydynamics/sample_model/model_base.py b/src/easydynamics/sample_model/model_base.py index bb5859cc..039da8bd 100644 --- a/src/easydynamics/sample_model/model_base.py +++ b/src/easydynamics/sample_model/model_base.py @@ -1,7 +1,6 @@ # SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors # SPDX-License-Identifier: BSD-3-Clause -import warnings from copy import copy import numpy as np @@ -194,7 +193,17 @@ def Q(self) -> np.ndarray | None: @Q.setter def Q(self, value: Q_type | None) -> None: """Set the Q values of the SampleModel.""" - self._Q = _validate_and_convert_Q(value) + old_Q = self._Q + new_Q = _validate_and_convert_Q(value) + + if ( + old_Q is not None + and new_Q is not None + and len(old_Q) == len(new_Q) + and all(np.isclose(old_Q, new_Q)) + ): + return # No change in Q, so do nothing + self._Q = new_Q self._on_Q_change() # ------------------------------------------------------------------ @@ -276,9 +285,9 @@ def _generate_component_collections(self) -> None: # TODO regenerate automatically if Q or components have changed if self._Q is None: - warnings.warn( - "Q is not set. No component collections generated", UserWarning - ) + # warnings.warn( + # "Q is not set. No component collections generated", UserWarning + # ) self._component_collections = [] return From 08cdef2a834806eefbd3916f89eb3884450a4719 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Thu, 12 Feb 2026 12:08:50 +0100 Subject: [PATCH 09/27] Add plotting of parameters and examples --- docs/docs/tutorials/analysis.ipynb | 183 ++++--- docs/docs/tutorials/analysis1d.ipynb | 104 ++++ src/easydynamics/analysis/analysis.py | 109 +++++ src/easydynamics/analysis/analysis1d old.py | 497 -------------------- src/easydynamics/analysis/analysis1d.py | 3 +- 5 files changed, 294 insertions(+), 602 deletions(-) create mode 100644 docs/docs/tutorials/analysis1d.ipynb delete mode 100644 src/easydynamics/analysis/analysis1d old.py diff --git a/docs/docs/tutorials/analysis.ipynb b/docs/docs/tutorials/analysis.ipynb index 39b70c8e..3da1411c 100644 --- a/docs/docs/tutorials/analysis.ipynb +++ b/docs/docs/tutorials/analysis.ipynb @@ -5,7 +5,10 @@ "id": "8643b10c", "metadata": {}, "source": [ - "asd" + "# Analysis\n", + "It is time to analyse some data. We here show how to set up an Analysis object and use it to first fit an artificial vanadium measurements, and next an artificial measurement of a model with diffusion and some elastic scattering.\n", + "\n", + "In the near future, it will be possible to fit the width and area of the Lorentzian to the diffusion model, as well as fitting the diffusion model directly to the data." ] }, { @@ -21,6 +24,7 @@ "from easydynamics.experiment import Experiment\n", "from easydynamics.sample_model import ComponentCollection\n", "from easydynamics.sample_model import DeltaFunction\n", + "from easydynamics.sample_model import Lorentzian\n", "from easydynamics.sample_model import Gaussian\n", "from easydynamics.sample_model import Polynomial\n", "from easydynamics.sample_model.background_model import BackgroundModel\n", @@ -28,6 +32,7 @@ "from easydynamics.sample_model.sample_model import SampleModel\n", "from easydynamics.sample_model.instrument_model import InstrumentModel\n", "from easydynamics.analysis.analysis import Analysis\n", + "from copy import copy\n", "%matplotlib widget" ] }, @@ -45,20 +50,18 @@ { "cell_type": "code", "execution_count": null, - "id": "41f842f0", + "id": "6762faba", "metadata": {}, "outputs": [], "source": [ - "# Example of Analysis1d with a simple sample model and instrument model\n", + "# Example of Analysis with a simple sample model and instrument model\n", "delta_function = DeltaFunction(display_name='DeltaFunction', area=1)\n", - "gaussian = Gaussian(display_name='Gaussian', width=0.1, area=1)\n", - "components = ComponentCollection(components=[delta_function, gaussian])\n", "sample_model = SampleModel(\n", - " components=components,\n", + " components=delta_function,\n", ")\n", "\n", "res_gauss = Gaussian(width=0.1)\n", - "res_gauss.area.fixed = True\n", + "res_gauss.area.fixed=True\n", "resolution_model = ResolutionModel(components=res_gauss)\n", "\n", "\n", @@ -69,194 +72,166 @@ " background_model=background_model,\n", ")\n", "\n", - "my_analysis = Analysis1d(\n", + "vanadium_analysis = Analysis(\n", + " display_name='Vanadium Full Analysis',\n", " experiment=vanadium_experiment,\n", " sample_model=sample_model,\n", " instrument_model=instrument_model,\n", - " Q_index=5,\n", ")\n", "\n", - "fit_result = my_analysis.fit()\n", - "my_analysis.plot_data_and_model()" + "fit_result_independent_single_Q = vanadium_analysis.fit(fit_method=\"independent\", Q_index=5)\n", + "vanadium_analysis.plot_data_and_model(Q_index=5)" ] }, { "cell_type": "code", "execution_count": null, - "id": "6762faba", + "id": "e98e3d65", "metadata": {}, "outputs": [], "source": [ - "# Example of Analysis with a simple sample model and instrument model\n", - "delta_function = DeltaFunction(display_name='DeltaFunction', area=1)\n", - "gaussian = Gaussian(display_name='Gaussian', width=0.1, area=1)\n", - "components = ComponentCollection(components=[delta_function, gaussian])\n", - "sample_model = SampleModel(\n", - " components=components,\n", - ")\n", - "\n", - "res_gauss = Gaussian(width=0.1)\n", - "res_gauss.area.fixed = True\n", - "resolution_model = ResolutionModel(components=res_gauss)\n", - "\n", - "\n", - "background_model = BackgroundModel(components=Polynomial(coefficients=[0.001]))\n", - "\n", - "instrument_model = InstrumentModel(\n", - " resolution_model=resolution_model,\n", - " background_model=background_model,\n", - ")\n", - "\n", - "my_analysis = Analysis(\n", - " experiment=vanadium_experiment,\n", - " sample_model=sample_model,\n", - " instrument_model=instrument_model,\n", - ")\n", - "\n", - "fit_result1 = my_analysis.fit(fit_method=\"independent\", Q_index=5)" + "fit_result_independent_all_Q = vanadium_analysis.fit(fit_method=\"independent\")\n", + "vanadium_analysis.plot_data_and_model()" ] }, { "cell_type": "code", "execution_count": null, - "id": "e98e3d65", + "id": "af13afce", "metadata": {}, "outputs": [], "source": [ - "fit_result2 = my_analysis.fit(fit_method=\"independent\")" + "fit_result_simultaneous = vanadium_analysis.fit(fit_method=\"simultaneous\")\n", + "fit_result_simultaneous\n", + "vanadium_analysis.plot_data_and_model()" ] }, { "cell_type": "code", "execution_count": null, - "id": "af13afce", + "id": "133e682e", "metadata": {}, "outputs": [], "source": [ - "fit_result3 = my_analysis.fit(fit_method=\"simultaneous\")\n", - "fit_result3" + "# Inspect the Parameters as a scipp Dataset\n", + "vanadium_analysis.parameters_to_dataset()\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "02702f95", + "id": "dfacdf24", "metadata": {}, "outputs": [], - "source": [] + "source": [ + "# Plot some of fitted parameters as a function of Q\n", + "vanadium_analysis.plot_parameters(names=[\"DeltaFunction area\"])\n" + ] }, { "cell_type": "code", "execution_count": null, - "id": "70091539", + "id": "b6f9f316", "metadata": {}, "outputs": [], "source": [ - "my_analysis.plot_data_and_model()" + "vanadium_analysis.plot_parameters(names=[\"Gaussian width\"])" ] }, { "cell_type": "code", "execution_count": null, - "id": "2ad6384e", + "id": "3609e6c1", "metadata": {}, "outputs": [], "source": [ - "sample_comps, background_comps = my_analysis.analysis_list[0].calculate_individual_components()\n", - "sample_comps" + "# Set up the diffusion analysis with the same resolution model as the\n", + "# vanadium analysis\n", + "diffusion_experiment = Experiment('Diffusion')\n", + "diffusion_experiment.load_hdf5(filename='diffusion_data_example.h5')" ] }, { "cell_type": "code", "execution_count": null, - "id": "35b0fac5", + "id": "e685909a", "metadata": {}, "outputs": [], "source": [ - "my_analysis.sample_model" + "# We set up the model first.\n", + "delta_function = DeltaFunction(display_name='DeltaFunction', area=0.2)\n", + "lorentzian = Lorentzian(display_name='Lorentzian', area=0.5, width=0.3)\n", + "component_collection=ComponentCollection(\n", + " components=[delta_function, lorentzian],\n", + ")\n", + "sample_model = SampleModel(\n", + " components=component_collection,\n", + ")\n", + "\n", + "background_model = BackgroundModel(components=Polynomial(coefficients=[0.001]))\n", + "\n", + "instrument_model = InstrumentModel(\n", + " background_model=background_model,\n", + ")\n", + "\n", + "diffusion_analysis = Analysis(\n", + " display_name='Diffusion Full Analysis',\n", + " experiment=diffusion_experiment,\n", + " sample_model=sample_model,\n", + " instrument_model=instrument_model,\n", + ")\n", + "\n", + "# We need to hack in the resolution model from the vanadium analysis,\n", + "# since the setters and getters overwrite the model. This will be fixed\n", + "# asap.\n", + "diffusion_analysis.instrument_model._resolution_model = vanadium_analysis.instrument_model.resolution_model\n", + "diffusion_analysis.instrument_model.resolution_model.fix_all_parameters()\n", + "diffusion_analysis.plot_parameters(names=[\"Gaussian width\"])\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "2dfb1f90", + "id": "c66828eb", "metadata": {}, "outputs": [], "source": [ - "# my_analysis.get_all_variables()" + "# Let us see how good the starting parameters are\n", + "diffusion_analysis.plot_data_and_model()" ] }, { "cell_type": "code", "execution_count": null, - "id": "5afefbab", + "id": "197b44c5", "metadata": {}, "outputs": [], "source": [ - "# my_analysis.get_fit_parameters()" + "# Now we fit the data and plot the result. Looks good!\n", + "diffusion_analysis.fit(fit_method=\"independent\")\n", + "diffusion_analysis.plot_data_and_model()" ] }, { "cell_type": "code", "execution_count": null, - "id": "465c0e1e", + "id": "df14b5c4", "metadata": {}, "outputs": [], "source": [ - "# for Q_index in range(len(my_analysis.Q)):\n", - "# my_analysis.Q_index = Q_index\n", - "# my_analysis.fit()\n", - "# my_analysis.plot_data_and_model()\n", - "# print(my_analysis.get_fit_parameters())\n" + "# Let us look at the most interesting fit parameters\n", + "diffusion_analysis.plot_parameters(names=[\"Lorentzian width\", \"Lorentzian area\"])" ] }, { "cell_type": "code", "execution_count": null, - "id": "9bdeed2b", + "id": "eb226c8f", "metadata": {}, "outputs": [], "source": [ - "# Create a diffusion_model and components for the SampleModel\n", - "\n", - "# Creating components\n", - "component_collection = ComponentCollection()\n", - "delta_function = DeltaFunction(display_name='DeltaFunction', area=1)\n", - "gaussian = Gaussian(display_name='Gaussian', width=0.1, center=0.5, area=0.5)\n", - "\n", - "# Adding components to the component collection\n", - "component_collection.append_component(delta_function)\n", - "\n", - "\n", - "sample_model = SampleModel(\n", - " components=component_collection,\n", - " unit='meV',\n", - " display_name='MySampleModel',\n", - ")\n", - "\n", - "res_gauss = Gaussian(width=0.1)\n", - "res_gauss.area.fixed = True\n", - "resolution_model = ResolutionModel(components=res_gauss)\n", - "\n", - "\n", - "background_model = BackgroundModel(components=Polynomial(coefficients=[0.001]))\n", - "\n", - "instrument_model = InstrumentModel(\n", - " resolution_model=resolution_model,\n", - " background_model=background_model,\n", - ")\n", - "\n", - "my_full_analysis = Analysis(\n", - " experiment=vanadium_experiment,\n", - " sample_model=sample_model,\n", - " instrument_model=instrument_model,\n", - ")\n", - "\n", - "# my_full_analysis._fit_all_Q_independently()\n", - "my_full_analysis._fit_all_Q_simultaneously()\n", - "for analysis_object in my_full_analysis._analysis_list:\n", - " analysis_object.plot_data_and_model()\n", - " print(analysis_object.get_fit_parameters())\n" + "# It will be possible to fit this to a DiffusionModel, but that will\n", + "# come later." ] } ], diff --git a/docs/docs/tutorials/analysis1d.ipynb b/docs/docs/tutorials/analysis1d.ipynb new file mode 100644 index 00000000..8a695913 --- /dev/null +++ b/docs/docs/tutorials/analysis1d.ipynb @@ -0,0 +1,104 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "8643b10c", + "metadata": {}, + "source": [ + "# Analysis1d\n", + "Sometimes, you will only be interested in a particular Q, not the full dataset. For this, use the Analysis1d object. We here show how to set it up to fit an artificial vanadium measurement." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bca91d3c", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "from easydynamics.analysis.analysis1d import Analysis1d\n", + "from easydynamics.experiment import Experiment\n", + "from easydynamics.sample_model import ComponentCollection\n", + "from easydynamics.sample_model import DeltaFunction\n", + "from easydynamics.sample_model import Gaussian\n", + "from easydynamics.sample_model import Polynomial\n", + "from easydynamics.sample_model.background_model import BackgroundModel\n", + "from easydynamics.sample_model.resolution_model import ResolutionModel\n", + "from easydynamics.sample_model.sample_model import SampleModel\n", + "from easydynamics.sample_model.instrument_model import InstrumentModel\n", + "from easydynamics.analysis.analysis import Analysis\n", + "%matplotlib widget" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8deca9b6", + "metadata": {}, + "outputs": [], + "source": [ + "vanadium_experiment = Experiment('Vanadium')\n", + "vanadium_experiment.load_hdf5(filename='vanadium_data_example.h5')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "41f842f0", + "metadata": {}, + "outputs": [], + "source": [ + "# Example of Analysis1d with a simple sample model and instrument model\n", + "delta_function = DeltaFunction(display_name='DeltaFunction', area=1)\n", + "sample_model = SampleModel(\n", + " components=delta_function,\n", + ")\n", + "\n", + "res_gauss = Gaussian(width=0.1)\n", + "resolution_model = ResolutionModel(components=res_gauss)\n", + "\n", + "\n", + "background_model = BackgroundModel(components=Polynomial(coefficients=[0.001]))\n", + "\n", + "instrument_model = InstrumentModel(\n", + " resolution_model=resolution_model,\n", + " background_model=background_model,\n", + ")\n", + "\n", + "my_analysis = Analysis1d(\n", + " display_name='Vanadium Analysis',\n", + " experiment=vanadium_experiment,\n", + " sample_model=sample_model,\n", + " instrument_model=instrument_model,\n", + " Q_index=5,\n", + ")\n", + "\n", + "fit_result = my_analysis.fit()\n", + "my_analysis.plot_data_and_model()\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "easydynamics_newbase", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index ae7b16a7..81921b20 100644 --- a/src/easydynamics/analysis/analysis.py +++ b/src/easydynamics/analysis/analysis.py @@ -8,6 +8,7 @@ from easyscience.fitting.minimizers.utils import FitResults from easyscience.fitting.multi_fitter import MultiFitter from easyscience.variable import Parameter +from scipp import UnitError from easydynamics.analysis.analysis1d import Analysis1d from easydynamics.analysis.analysis_base import AnalysisBase @@ -185,6 +186,7 @@ def plot_data_and_model( "linestyle": {"Data": "none", "Model": "-"}, "marker": {"Data": "o", "Model": None}, "color": {"Data": "black", "Model": "red"}, + "markerfacecolor": {"Data": "none", "Model": "none"}, } data_and_model = { "Data": self.experiment.binned_data, @@ -207,6 +209,113 @@ def plot_data_and_model( ) display(fig) + def parameters_to_dataset(self) -> sc.Dataset: + """ + Creates a scipp dataset with copies of the Parameters in the + model. Ensures unit consistency across Q. + """ + + ds = sc.Dataset(coords={"Q": self.Q}) + + # Collect all parameter names + all_names = { + param.name + for analysis in self.analysis_list + for param in analysis.get_all_parameters() + } + + # Storage + values = {name: [] for name in all_names} + variances = {name: [] for name in all_names} + units = {} + + for analysis in self.analysis_list: + pars = {p.name: p for p in analysis.get_all_parameters()} + + for name in all_names: + if name in pars: + p = pars[name] + + # Unit consistency check + if name not in units: + units[name] = p.unit + elif units[name] != p.unit: + try: + p.unit.convert(units[name]) + except Exception as e: + raise UnitError( + f"Inconsistent units for parameter '{name}': " + f"{units[name]} vs {p.unit}" + ) from e + + values[name].append(p.value) + variances[name].append(p.variance) + else: + values[name].append(np.nan) + variances[name].append(np.nan) + + # Build dataset variables + for name in all_names: + ds[name] = sc.Variable( + dims=["Q"], + values=np.asarray(values[name], dtype=float), + variances=np.asarray(variances[name], dtype=float), + unit=units.get(name, None), + ) + + return ds + + def plot_parameters( + self, + names: str | list[str] | None = None, + **kwargs, + ) -> None: + """ + Plot fitted parameters as a function of Q. + + Parameters: + --------------- + names: str or list of str + Name(s) of the parameter(s) to plot. If None, plots all + parameters. + kwargs: Additional keyword arguments passed to plopp.slicer for + customizing the plot (e.g., title, linestyle, marker, + color). + + Returns: A plopp figure. + """ + + ds = self.parameters_to_dataset() + + if not names: + names = list(ds.keys()) + + if isinstance(names, str): + names = [names] + + if not isinstance(names, list) or not all( + isinstance(name, str) for name in names + ): + raise TypeError("names must be a string or a list of strings.") + + for name in names: + if name not in ds: + raise ValueError(f"Parameter '{name}' not found in dataset.") + + data_to_plot = {name: ds[name] for name in names} + plot_kwargs_defaults = { + "linestyle": {name: "none" for name in names}, + "marker": {name: "o" for name in names}, + "markerfacecolor": {name: "none" for name in names}, + } + + plot_kwargs_defaults.update(kwargs) + fig = pp.plot( + data_to_plot, + **plot_kwargs_defaults, + ) + return fig + ############# # Private methods ############# diff --git a/src/easydynamics/analysis/analysis1d old.py b/src/easydynamics/analysis/analysis1d old.py deleted file mode 100644 index b27fed1e..00000000 --- a/src/easydynamics/analysis/analysis1d old.py +++ /dev/null @@ -1,497 +0,0 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors -# SPDX-License-Identifier: BSD-3-Clause - - -import numpy as np -import scipp as sc -from easyscience.base_classes.model_base import ModelBase as EasyScienceModelBase -from easyscience.fitting.fitter import Fitter as EasyScienceFitter -from easyscience.variable import DescriptorNumber -from easyscience.variable import Parameter - -from easydynamics.convolution import Convolution -from easydynamics.experiment import Experiment -from easydynamics.sample_model import InstrumentModel -from easydynamics.sample_model import ResolutionModel -from easydynamics.sample_model import SampleModel - - -class Analysis1d(EasyScienceModelBase): - """For analysing data.""" - - def __init__( - self, - display_name: str = "MyAnalysis", - unique_name: str | None = None, - experiment: Experiment | None = None, - sample_model: SampleModel | None = None, - instrument_model: InstrumentModel | None = None, - Q_index: int | None = None, - ): - super().__init__(display_name=display_name, unique_name=unique_name) - - if experiment is not None and not isinstance(experiment, Experiment): - raise TypeError("experiment must be an instance of Experiment or None.") - - self._experiment = experiment - - if sample_model is not None and not isinstance(sample_model, SampleModel): - raise TypeError("sample_model must be an instance of SampleModel or None.") - sample_model.Q = self.Q - self._sample_model = sample_model - - if instrument_model is not None and not isinstance( - instrument_model, InstrumentModel - ): - raise TypeError( - "instrument_model must be an instance of InstrumentModel or None." - ) - if instrument_model is None: - self._instrument_model = InstrumentModel() - else: - self._instrument_model = instrument_model - - self._convolvers = [None] * (len(self.Q) if self.Q is not None else 0) - self._update_models() - - if Q_index is not None: - if ( - not isinstance(Q_index, int) - or Q_index < 0 - or (self.Q is not None and Q_index >= len(self.Q)) - ): - raise ValueError("Q_index must be a valid index for the Q values.") - self._Q_index = Q_index - - ############# - # Properties - ############# - - @property - def experiment(self) -> Experiment | None: - """The Experiment associated with this Analysis.""" - return self._experiment - - @experiment.setter - def experiment(self, value: Experiment | None) -> None: - if value is not None and not isinstance(value, Experiment): - raise TypeError("experiment must be an instance of Experiment or None.") - self._experiment = value - self._update_models() - - @property - def sample_model(self) -> SampleModel | None: - """The SampleModel associated with this Analysis.""" - return self._sample_model - - @sample_model.setter - def sample_model(self, value: SampleModel | None) -> None: - if value is not None and not isinstance(value, SampleModel): - raise TypeError("sample_model must be an instance of SampleModel or None.") - self._sample_model = value - self._update_models() - - @property - def resolution_model(self) -> ResolutionModel | None: - """The ResolutionModel associated with this Analysis.""" - return self._resolution_model - - @resolution_model.setter - def resolution_model(self, value: ResolutionModel | None) -> None: - if value is not None and not isinstance(value, ResolutionModel): - raise TypeError( - "resolution_model must be an instance of ResolutionModel or None." - ) - self._resolution_model = value - self._update_models() - - @property - def Q(self) -> sc.Variable | None: - """The Q values from the associated Experiment, if available.""" - if self.experiment is not None: - return self.experiment.Q - return None - - @Q.setter - def Q(self, value) -> None: - """Q is a read-only property derived from the Experiment.""" - raise AttributeError("Q is a read-only property derived from the Experiment.") - - @property - def energy(self) -> sc.Variable | None: - """The energy values from the associated Experiment, if - available. - """ - if self.experiment is not None: - return self.experiment.energy - return None - - @energy.setter - def energy(self, value) -> None: - """Energy is a read-only property derived from the - Experiment. - """ - raise AttributeError( - "energy is a read-only property derived from the Experiment." - ) - - @property - def temperature(self) -> Parameter | None: - """The temperature from the associated Experiment, if - available. - """ - return self.sample_model.temperature if self.sample_model is not None else None - - @temperature.setter - def temperature(self, value) -> None: - """Temperature is a read-only property derived from the - Experiment. - """ - raise AttributeError( - "temperature is a read-only property derived from the sample model." - ) - - @property - def energy_offset(self) -> list[Parameter] | None: - """Get the energy offsets for each Q value.""" - return self._energy_offset - - @energy_offset.setter - def energy_offset(self, offsets: list[Parameter] | None) -> None: - """Set the energy offsets for each Q value. - - Args: - offsets (list[Parameter] | None): The list of energy - offsets. - Raises: - TypeError: If offsets is not a list of Parameters or - None. - """ - if offsets is not None: - if len(offsets) != len(self.Q): - raise ValueError( - "energy_offset list length must match number of Q values." - ) - for offset in offsets: - if not isinstance(offset, Parameter): - raise TypeError( - "Each energy_offset must be an instance of Parameter." - ) - self._energy_offset = offsets - - @property - def Q_index(self) -> int | None: - """Get the Q index for single Q analysis.""" - return self._Q_index - - @Q_index.setter - def Q_index(self, index: int | None) -> None: - """Set the Q index for single Q analysis. - - Args: - index (int | None): The Q index. - """ - if index is not None: - if ( - not isinstance(index, int) - or index < 0 - or (self.Q is not None and index >= len(self.Q)) - ): - raise ValueError("Q_index must be a valid index for the Q values.") - self._Q_index = index - - ############# - # Other methods - ############# - - def calculate(self, energy: float | None = None) -> np.ndarray: - """Calculate the model prediction for a given Q index. - - Args: - energy (float): The energy value to calculate the model for. - Returns: - sc.DataArray: The calculated model prediction. - """ - Q_index = self.Q_index - if Q_index is None: - raise ValueError("Q_index must be set to calculate the model.") - - if energy is None: - energy = self.energy.values - - # TODO: handle units properly - energy = energy - self.energy_offset[Q_index].value - if self.sample_model is None: - sample_intensity = np.zeros_like(energy) - else: - if self.resolution_model is None: - sample_intensity = self.sample_model._component_collections[ - Q_index - ].evaluate(energy) - else: - convolver = self._convolvers[Q_index] - sample_intensity = convolver.convolution() - - if self.background_model is None: - background_intensity = np.zeros_like(energy) - else: - background_intensity = self.background_model._component_collections[ - Q_index - ].evaluate(energy) - - sample_plus_background = sample_intensity + background_intensity - - return sample_plus_background - - def calculate_individual_components( - self, - ) -> tuple[list[np.ndarray], list[np.ndarray]]: - """Calculate the model prediction for a given Q index for each - individual component. - - Args: - Q_index (int): The index of the Q value to calculate the - model for. - Returns: - list[np.ndarray]: The calculated model predictions for each - individual component. - """ - sample_results = [] - background_results = [] - Q_index = self.Q_index - if Q_index is None: - raise ValueError("Q_index must be set to calculate the model.") - - if self.sample_model is not None: - # Calculate sample components - for component in self.sample_model._component_collections[ - Q_index - ]._components: - if self.resolution_model is None: - component_intensity = component.evaluate(self.energy) - else: - convolver = Convolution( - sample_components=component, - resolution_components=self.resolution_model._component_collections[ - Q_index - ], - energy=self.energy, - temperature=self.temperature, - ) - component_intensity = convolver.convolution() - sample_results.append(component_intensity) - - if self.background_model is not None: - # Calculate background components - for component in self.background_model._component_collections[ - Q_index - ]._components: - component_intensity = component.evaluate(self.energy) - background_results.append(component_intensity) - - return sample_results, background_results - - def fit(self): - """Fit the model to the experimental data for a given Q index. - - Args: - Returns: - FitResult: The result of the fit. - """ - if self._experiment is None: - raise ValueError("No experiment is associated with this Analysis.") - - Q_index = self.Q_index - if Q_index is None: - raise ValueError("Q_index must be set to perform the fit.") - - data = self.experiment.data["Q", Q_index] - x = data.coords["energy"].values - y = data.values - e = data.variances**0.5 - - def fit_func(x_vals): - return self.calculate(energy=x_vals) - - fitter = EasyScienceFitter( - fit_object=self, - fit_function=fit_func, - ) - - # Perform the fit - fit_result = fitter.fit(x=x, y=y, weights=1.0 / e) - - # Store result - self.fit_result = fit_result - - return fit_result - - def plot_data_and_model( - self, - plot_individual_components: bool = True, - ) -> None: - """Plot the experimental data and the model prediction. - - Args: - plot_individual_components (bool): Whether to plot - individual components. Default is True. - """ - if not isinstance(plot_individual_components, bool): - raise TypeError("plot_individual_components must be True or False.") - - import matplotlib.pyplot as plt - - Q_index = self.Q_index - if Q_index is None: - raise ValueError("Q_index must be set to plot the data and model.") - if self.experiment is None or self.experiment.data is None: - raise ValueError("Experiment data is not available for plotting.") - data = self.experiment.data["Q", Q_index] - energy = data.coords["energy"].values - model = self.calculate(energy=energy) - plt.figure() - plt.errorbar( - energy, - data.values, - yerr=data.variances**0.5, - fmt="o", - label="Data", - color="black", - ) - plt.plot(energy, model, label="Model", color="red") - if plot_individual_components: - sample_comps, background_comps = self.calculate_individual_components() - for i, comp in enumerate(sample_comps): - plt.plot( - energy, - comp, - label=f"Sample Component {i + 1}", - linestyle="--", - ) - for i, comp in enumerate(background_comps): - plt.plot( - energy, - comp, - label=f"Background Component {i + 1}", - linestyle=":", - ) - plt.xlabel(f"Energy ({self.energy.unit})") - plt.ylabel(f"Intensity ({self.sample_model.unit})") - plt.title(f"Data and Model at Q index {Q_index}") - plt.legend() - plt.show() - # model_data_array = self._create_model_data_group( - # individual_components=plot_individual_components ) if - # self.experiment is None or self.experiment.data is None: raise - # ValueError("Experiment data is not available for plotting.") - - # from IPython.display import display - - # fig = pp.slicer( - # {"Data": self.experiment.data, "Model": model_data_array}, - # color={"Data": "black", "Model": "red"}, - # linestyle={"Data": "none", "Model": "solid"}, - # marker={"Data": "o", "Model": "None"}, - # ) - # display(fig) - - def get_all_variables(self) -> list[DescriptorNumber]: - """Get all variables used in the analysis. - - Returns: - List[Descriptor]: A list of all variables. - """ - variables = [] - if self.sample_model is not None: - variables.extend( - self.sample_model._component_collections[ - self.Q_index - ].get_all_variables() - ) - if self.resolution_model is not None: - variables.extend( - self.resolution_model._component_collections[ - self.Q_index - ].get_all_variables() - ) - if self.background_model is not None: - variables.extend( - self.background_model._component_collections[ - self.Q_index - ].get_all_variables() - ) - variables.append(self.energy_offset[self.Q_index]) - # TODO temperature and diffusion - return variables - - ############# - # Private methods - ############# - - def _update_models(self): - """Update models based on the current experiment.""" - if self.experiment is None: - return - - for Q_index in range(len(self.Q)): - self._convolvers[Q_index] = self._create_convolver(Q_index) - - def _create_convolver(self, Q_index: int): - """Initialize and return a Convolution object for the given Q - index. - """ - if self.sample_model is None or self.resolution_model is None: - raise ValueError("Both sample_model and resolution_model must be defined.") - - sample_components = self.sample_model._component_collections[Q_index] - resolution_components = self.resolution_model._component_collections[Q_index] - energy = self.energy - convolver = Convolution( - sample_components=sample_components, - resolution_components=resolution_components, - energy=energy, - temperature=self.temperature, - ) - return convolver - - def _create_model_data_group(self, individual_components=True) -> sc.DataArray: - """Create a Scipp DataArray representing the model over all Q - and energy values. - """ - if self.Q is None or self.energy is None: - raise ValueError("Q and energy must be defined in the experiment.") - - model_data = [] - for Q_index in range(len(self.Q)): - model_at_Q = self.calculate(Q_index) - model_data.append(model_at_Q) - - model_data_array = sc.DataArray( - data=sc.array(dims=["Q", "energy"], values=model_data), - coords={ - "Q": self.Q, - "energy": self.energy, - }, - ) - model_group = sc.DataGroup({"Model": model_data_array}) - - if individual_components: - components = self.calculate_individual_components_all_Q() - for Q_index, (sample_comps, background_comps) in enumerate(components): - for samp_index, samp_comp in enumerate(sample_comps): - model_data_array[samp_comp.display_name] = sc.zeros_like( - model_data_array.data - ) - model_data_array[samp_comp.display_name].data[ - Q_index, : - ] = samp_comp - for back_index, back_comp in enumerate(background_comps): - model_data_array[back_comp.display_name] = sc.zeros_like( - model_data_array.data - ) - model_data_array[back_comp.display_name].data[ - Q_index, : - ] = back_comp - - model_data_array = model_data_array + model_group # WRONG BUT LINT - return model_data_array diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index 4e652aac..c4127960 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -209,8 +209,9 @@ def plot_data_and_model( plot_kwargs_defaults = { "title": self.display_name, "linestyle": {"Data": "none", "Model": "-"}, - "marker": {"Data": "o", "Model": None}, + "marker": {"Data": "o", "Model": "none"}, "color": {"Data": "black", "Model": "red"}, + "markerfacecolor": {"Data": "none", "Model": "none"}, } if plot_components: From 02158a1f2350174802518a82c5e6d6b1c3a082c6 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Thu, 12 Feb 2026 12:20:07 +0100 Subject: [PATCH 10/27] Update failing tests --- .../experiment/test_experiment.py | 245 +++++++----------- .../sample_model/test_model_base.py | 102 ++++---- tests/unit/easydynamics/utils/test_utils.py | 97 +++++-- 3 files changed, 217 insertions(+), 227 deletions(-) diff --git a/tests/unit/easydynamics/experiment/test_experiment.py b/tests/unit/easydynamics/experiment/test_experiment.py index 067a2017..05aa2470 100644 --- a/tests/unit/easydynamics/experiment/test_experiment.py +++ b/tests/unit/easydynamics/experiment/test_experiment.py @@ -12,12 +12,12 @@ class TestExperiment: @pytest.fixture def experiment(self): - Q = sc.linspace('Q', 0.5, 1.5, num=10, unit='1/Angstrom') - energy = sc.linspace('energy', -5, 5, num=11, unit='meV') - values = sc.array(dims=['Q', 'energy'], values=np.ones((10, 11))) - data = sc.DataArray(data=values, coords={'Q': Q, 'energy': energy}) + Q = sc.linspace("Q", 0.5, 1.5, num=10, unit="1/Angstrom") + energy = sc.linspace("energy", -5, 5, num=11, unit="meV") + values = sc.array(dims=["Q", "energy"], values=np.ones((10, 11))) + data = sc.DataArray(data=values, coords={"Q": Q, "energy": energy}) - experiment = Experiment(display_name='test_experiment', data=data) + experiment = Experiment(display_name="test_experiment", data=data) return experiment ############## @@ -27,51 +27,51 @@ def experiment(self): def test_init_array(self, experiment): "Test initialization with a Scipp DataArray" # WHEN THEN EXPECT - assert experiment.display_name == 'test_experiment' + assert experiment.display_name == "test_experiment" assert isinstance(experiment._data, sc.DataArray) - assert 'Q' in experiment._data.dims - assert 'energy' in experiment._data.dims - assert experiment._data.sizes['Q'] == 10 - assert experiment._data.sizes['energy'] == 11 + assert "Q" in experiment._data.dims + assert "energy" in experiment._data.dims + assert experiment._data.sizes["Q"] == 10 + assert experiment._data.sizes["energy"] == 11 assert sc.identical( experiment._data.data, - sc.array(dims=['Q', 'energy'], values=np.ones((10, 11))), + sc.array(dims=["Q", "energy"], values=np.ones((10, 11))), ) def test_init_string(self, tmp_path): "Test initialization with a filename string," - 'should load the file' + "should load the file" # WHEN - Q = sc.linspace('Q', 0.5, 1.5, num=10, unit='1/Angstrom') - energy = sc.linspace('energy', -5, 5, num=11, unit='meV') - values = sc.array(dims=['Q', 'energy'], values=np.ones((10, 11))) - data = sc.DataArray(data=values, coords={'Q': Q, 'energy': energy}) + Q = sc.linspace("Q", 0.5, 1.5, num=10, unit="1/Angstrom") + energy = sc.linspace("energy", -5, 5, num=11, unit="meV") + values = sc.array(dims=["Q", "energy"], values=np.ones((10, 11))) + data = sc.DataArray(data=values, coords={"Q": Q, "energy": energy}) - filename = tmp_path / 'test_experiment.h5' + filename = tmp_path / "test_experiment.h5" sc.io.save_hdf5(data, filename) # THEN - experiment = Experiment(display_name='loaded_experiment', data=str(filename)) + experiment = Experiment(display_name="loaded_experiment", data=str(filename)) # EXPECT - assert experiment.display_name == 'loaded_experiment' + assert experiment.display_name == "loaded_experiment" assert isinstance(experiment._data, sc.DataArray) - assert 'Q' in experiment._data.dims - assert 'energy' in experiment._data.dims - assert experiment._data.sizes['Q'] == 10 - assert experiment._data.sizes['energy'] == 11 + assert "Q" in experiment._data.dims + assert "energy" in experiment._data.dims + assert experiment._data.sizes["Q"] == 10 + assert experiment._data.sizes["energy"] == 11 assert sc.identical( experiment._data.data, - sc.array(dims=['Q', 'energy'], values=np.ones((10, 11))), + sc.array(dims=["Q", "energy"], values=np.ones((10, 11))), ) def test_init_no_data(self): "Test initialization with no data" # WHEN - experiment = Experiment(display_name='empty_experiment') + experiment = Experiment(display_name="empty_experiment") # THEN EXPECT - assert experiment.display_name == 'empty_experiment' + assert experiment.display_name == "empty_experiment" assert experiment._data is None def test_init_invalid_data(self): @@ -86,34 +86,34 @@ def test_init_invalid_data(self): def test_load_hdf5(self, tmp_path, experiment): "Test loading data from an HDF5 file." - 'First use scipp to save data to a file, ' - 'then load it using the method.' + "First use scipp to save data to a file, " + "then load it using the method." # WHEN # First create a file to load from - filename = tmp_path / 'test.h5' + filename = tmp_path / "test.h5" data_to_save = experiment.data sc.io.save_hdf5(data_to_save, filename) # THEN - new_experiment = Experiment(display_name='new_experiment') - new_experiment.load_hdf5(str(filename), display_name='loaded_data') + new_experiment = Experiment(display_name="new_experiment") + new_experiment.load_hdf5(str(filename), display_name="loaded_data") loaded_data = new_experiment.data # EXPECT assert sc.identical(data_to_save, loaded_data) - assert new_experiment.display_name == 'loaded_data' + assert new_experiment.display_name == "loaded_data" def test_load_hdf5_invalid_name_raises(self, experiment): "Test loading data from an HDF5 file," - 'giving the Experiment an invalid name' + "giving the Experiment an invalid name" # WHEN / THEN EXPECT with pytest.raises(TypeError): - experiment.load_hdf5('some_file.h5', display_name=123) + experiment.load_hdf5("some_file.h5", display_name=123) def test_load_hdf5_invalid_filename_raises(self, experiment): "Test loading data from an HDF5 file with an invalid filename" # WHEN / THEN EXPECT - with pytest.raises(TypeError, match='must be a string'): + with pytest.raises(TypeError, match="must be a string"): experiment.load_hdf5(123) def test_load_hdf5_invalid_file_raises(self, experiment): @@ -121,13 +121,13 @@ def test_load_hdf5_invalid_file_raises(self, experiment): # WHEN / THEN EXPECT with pytest.raises(OSError): - experiment.load_hdf5('non_existent_file.h5') + experiment.load_hdf5("non_existent_file.h5") def test_save_hdf5(self, tmp_path, experiment): "Test saving data to an HDF5 file. Load the saved file" - 'using scipp and compare to the original data.' + "using scipp and compare to the original data." # WHEN THEN - filename = tmp_path / 'saved_data.h5' + filename = tmp_path / "saved_data.h5" experiment.save_hdf5(str(filename)) # EXPECT @@ -144,25 +144,25 @@ def test_save_hdf5_default_filename(self, tmp_path, experiment, monkeypatch): experiment.save_hdf5() # EXPECT - expected_filename = tmp_path / f'{experiment.unique_name}.h5' + expected_filename = tmp_path / f"{experiment.unique_name}.h5" loaded_data = sc.io.load_hdf5(str(expected_filename)) original_data = experiment.data assert sc.identical(original_data, loaded_data) def test_save_hdf5_no_data_raises(self): "Test saving data to an HDF5 file when no data is present" - 'in the experiment' + "in the experiment" # WHEN experiment = Experiment() # THEN EXPECT with pytest.raises(ValueError): - experiment.save_hdf5('should_fail.h5') + experiment.save_hdf5("should_fail.h5") def test_save_hdf5_invalid_filename_raises(self, experiment): "Test saving data to an HDF5 file with an invalid filename" # WHEN / THEN EXPECT - with pytest.raises(TypeError, match='must be a string'): + with pytest.raises(TypeError, match="must be a string"): experiment.save_hdf5(123) def test_remove_data(self, experiment): @@ -174,11 +174,11 @@ def test_remove_data(self, experiment): assert experiment._data is None @pytest.mark.parametrize( - 'new_Q_bins, new_energy_bins', + "new_Q_bins, new_energy_bins", [ ( - sc.linspace('Q', 0.5, 1.5, num=7, unit='1/Angstrom'), - sc.linspace('energy', -5, 5, num=8, unit='meV'), + sc.linspace("Q", 0.5, 1.5, num=7, unit="1/Angstrom"), + sc.linspace("energy", -5, 5, num=8, unit="meV"), ), ( 6, @@ -189,23 +189,23 @@ def test_remove_data(self, experiment): 7.0, ), ( - sc.linspace('Q', 0.5, 1.5, num=7, unit='1/Angstrom'), + sc.linspace("Q", 0.5, 1.5, num=7, unit="1/Angstrom"), 7, ), ], - ids=['sc_bins', 'integers_bins', 'float_bins', 'mixed_bins'], + ids=["sc_bins", "integers_bins", "float_bins", "mixed_bins"], ) def test_rebin(self, experiment, new_Q_bins, new_energy_bins): "Test rebinning data in the experiment" # WHEN # THEN - experiment.rebin({'Q': new_Q_bins, 'energy': new_energy_bins}) + experiment.rebin({"Q": new_Q_bins, "energy": new_energy_bins}) # EXPECT rebinned_data = experiment.binned_data - assert rebinned_data.sizes['Q'] == 6 - assert rebinned_data.sizes['energy'] == 7 + assert rebinned_data.sizes["Q"] == 6 + assert rebinned_data.sizes["energy"] == 7 def test_rebin_no_data_raises(self): "Test rebinning data when no data is present" @@ -214,34 +214,34 @@ def test_rebin_no_data_raises(self): # THEN EXPECT with pytest.raises(ValueError): - experiment.rebin({'Q': 6, 'energy': 7}) + experiment.rebin({"Q": 6, "energy": 7}) def test_rebin_invalid_dimensions_raises(self, experiment): "Test rebinning data with invalid dimensions" # WHEN / THEN EXPECT with pytest.raises(TypeError): - experiment.rebin('invalid_dimensions') + experiment.rebin("invalid_dimensions") def test_rebin_invalid_dimension_name_raises(self, experiment): "Test rebinning data with invalid dimension name" # WHEN / THEN EXPECT - with pytest.raises(TypeError, match='Dimension keys must be strings'): - experiment.rebin({123: 6, 'energy': 7}) + with pytest.raises(TypeError, match="Dimension keys must be strings"): + experiment.rebin({123: 6, "energy": 7}) def test_rebin_dimension_not_in_data_raises(self, experiment): "Test rebinning data with a dimension not in the data" # WHEN / THEN EXPECT with pytest.raises(KeyError, match="Dimension 'time' not a valid"): - experiment.rebin({'time': 6, 'energy': 7}) + experiment.rebin({"time": 6, "energy": 7}) def test_rebin_invalid_bin_values_raises(self, experiment): "Test rebinning data with invalid bin values" # WHEN / THEN EXPECT with pytest.raises( TypeError, - match='Dimension values must be integers or', + match="Dimension values must be integers or", ): - experiment.rebin({'Q': [0.5, 1.0, 1.5], 'energy': 7}) + experiment.rebin({"Q": [0.5, 1.0, 1.5], "energy": 7}) ############## # test setters and getters @@ -271,24 +271,6 @@ def test_Q_setter_raises(self, experiment): with pytest.raises(AttributeError): experiment.Q = experiment.Q - def test_Q_getter_warns_no_data(self): - "Test that getting Q data with no data raises Warning" - # WHEN - experiment = Experiment() - - # THEN EXPECT - with pytest.warns(UserWarning, match='No data loaded'): - _ = experiment.Q - - def test_energy_getter_warns_no_data(self): - "Test that getting energy data with no data raises Warning" - # WHEN - experiment = Experiment() - - # THEN EXPECT - with pytest.warns(UserWarning, match='No data loaded'): - _ = experiment.energy - ############## # test plotting ############## @@ -297,9 +279,9 @@ def test_plot_data_success(self, experiment): "Test plotting data successfully when in notebook environment" # WHEN with ( - patch.object(Experiment, '_in_notebook', return_value=True), - patch('plopp.plot') as mock_plot, - patch('IPython.display.display') as mock_display, + patch(f"{Experiment.__module__}._in_notebook", return_value=True), + patch("plopp.plot") as mock_plot, + patch("IPython.display.display") as mock_display, ): mock_fig = MagicMock() mock_plot.return_value = mock_fig @@ -311,7 +293,7 @@ def test_plot_data_success(self, experiment): mock_plot.assert_called_once() args, kwargs = mock_plot.call_args assert sc.identical(args[0], experiment._data.transpose()) - assert kwargs['title'] == f'{experiment.display_name}' + assert kwargs["title"] == f"{experiment.display_name}" mock_display.assert_called_once_with(mock_fig) def test_plot_data_no_data_raises(self): @@ -320,18 +302,18 @@ def test_plot_data_no_data_raises(self): experiment = Experiment() # THEN EXPECT - with pytest.raises(ValueError, match='No data to plot'): + with pytest.raises(ValueError, match="No data to plot"): experiment.plot_data() def test_plot_data_not_in_notebook_raises(self, experiment): "Test plotting data raises RuntimeError" - 'when not in notebook environment' + "when not in notebook environment" # WHEN - with patch.object(Experiment, '_in_notebook', return_value=False): + with patch(f"{Experiment.__module__}._in_notebook", return_value=False): # THEN EXPECT with pytest.raises( RuntimeError, - match='plot_data\\(\\) can only be used in a Jupyter notebook environment', + match="plot_data\\(\\) can only be used in a Jupyter notebook environment", ): experiment.plot_data() @@ -339,62 +321,6 @@ def test_plot_data_not_in_notebook_raises(self, experiment): # test private methods ############## - def test_in_notebook_returns_true_for_jupyter(self, monkeypatch): - """Should return True when IPython shell is - ZMQInteractiveShell (Jupyter).""" - - # WHEN - class ZMQInteractiveShell: - __name__ = 'ZMQInteractiveShell' - - # THEN - monkeypatch.setattr('IPython.get_ipython', lambda: ZMQInteractiveShell()) - - # EXPECT - assert Experiment._in_notebook() is True - - def test_in_notebook_returns_false_for_terminal_ipython(self, monkeypatch): - """Should return False when IPython shell is - TerminalInteractiveShell.""" - - # WHEN - class TerminalInteractiveShell: - __name__ = 'TerminalInteractiveShell' - - # THEN - - monkeypatch.setattr('IPython.get_ipython', lambda: TerminalInteractiveShell()) - - # EXPECT - assert Experiment._in_notebook() is False - - def test_in_notebook_returns_false_for_unknown_shell(self, monkeypatch): - """Should return False when IPython shell type is - unrecognized.""" - - # WHEN - class UnknownShell: - __name__ = 'UnknownShell' - - # THEN - monkeypatch.setattr('IPython.get_ipython', lambda: UnknownShell()) - # EXPECT - assert Experiment._in_notebook() is False - - def test_in_notebook_returns_false_when_no_ipython(self, monkeypatch): - """Should return False when IPython is not installed or - available.""" - - # WHEN - def raise_import_error(*args, **kwargs): - raise ImportError - - # THEN - monkeypatch.setattr('builtins.__import__', raise_import_error) - - # EXPECT - assert Experiment._in_notebook() is False - def test_validate_coordinates(self, experiment): "Test that _validate_coordinates does not raise for valid data" # WHEN / THEN EXPECT @@ -402,40 +328,42 @@ def test_validate_coordinates(self, experiment): def test_validate_coordinates_raises_missing_Q(self, experiment): "Test that _validate_coordinates raises ValueError when Q coord" - 'is missing' + "is missing" # WHEN invalid_data = experiment._data.copy() - invalid_data.coords.pop('Q') + invalid_data.coords.pop("Q") # THEN EXPECT - with pytest.raises(ValueError, match='missing required coordinate'): + with pytest.raises(ValueError, match="missing required coordinate"): experiment._validate_coordinates(invalid_data) def test_validate_coordinates_raises_missing_energy(self, experiment): "Test that _validate_coordinates raises ValueError when energy" - 'coord is missing' + "coord is missing" # WHEN invalid_data = experiment._data.copy() - invalid_data.coords.pop('energy') + invalid_data.coords.pop("energy") # THEN EXPECT - with pytest.raises(ValueError, match='missing required coordinate'): + with pytest.raises(ValueError, match="missing required coordinate"): experiment._validate_coordinates(invalid_data) def test_validate_coordinates_raises_not_DataArray(self): "Test that _validate_coordinates raises TypeError when data is" - 'not a Scipp DataArray' + "not a Scipp DataArray" # WHEN THEN EXPECT - with pytest.raises(TypeError, match='must be a'): - Experiment()._validate_coordinates('not_a_data_array') + with pytest.raises(TypeError, match="must be a"): + Experiment()._validate_coordinates("not_a_data_array") def test_convert_to_bin_centers(self, experiment): "Test that _convert_to_bin_centers converts edges to centers" # WHEN - Q_edges = sc.linspace('Q', 0.0, 2.0, num=11, unit='1/Angstrom') - energy_edges = sc.linspace('energy', -6, 6, num=13, unit='meV') - values = sc.array(dims=['Q', 'energy'], values=np.ones((10, 12))) - binned_data = sc.DataArray(data=values, coords={'Q': Q_edges, 'energy': energy_edges}) + Q_edges = sc.linspace("Q", 0.0, 2.0, num=11, unit="1/Angstrom") + energy_edges = sc.linspace("energy", -6, 6, num=13, unit="meV") + values = sc.array(dims=["Q", "energy"], values=np.ones((10, 12))) + binned_data = sc.DataArray( + data=values, coords={"Q": Q_edges, "energy": energy_edges} + ) # THEN experiment._data = binned_data # Set data to avoid warnings @@ -445,8 +373,8 @@ def test_convert_to_bin_centers(self, experiment): expected_Q = 0.5 * (Q_edges[:-1] + Q_edges[1:]) expected_energy = 0.5 * (energy_edges[:-1] + energy_edges[1:]) - assert sc.identical(converted_data.coords['Q'], expected_Q) - assert sc.identical(converted_data.coords['energy'], expected_energy) + assert sc.identical(converted_data.coords["Q"], expected_Q) + assert sc.identical(converted_data.coords["energy"], expected_energy) assert sc.identical(converted_data.data, binned_data.data) ############## @@ -458,12 +386,15 @@ def test_repr(self, experiment): repr_str = repr(experiment) # THEN EXPECT - assert repr_str == f'Experiment `{experiment.unique_name}` with data: {experiment._data}' + assert ( + repr_str + == f"Experiment `{experiment.unique_name}` with data: {experiment._data}" + ) def test_copy_experiment(self, experiment): "Test copying an Experiment object." - 'The copied object should have the same attributes ' - 'but be a different object in memory.' + "The copied object should have the same attributes " + "but be a different object in memory." # WHEN copied_experiment = copy(experiment) diff --git a/tests/unit/easydynamics/sample_model/test_model_base.py b/tests/unit/easydynamics/sample_model/test_model_base.py index 05591735..692d7e42 100644 --- a/tests/unit/easydynamics/sample_model/test_model_base.py +++ b/tests/unit/easydynamics/sample_model/test_model_base.py @@ -16,26 +16,26 @@ class TestModelBase: @pytest.fixture def model_base(self): component1 = Gaussian( - display_name='TestGaussian1', + display_name="TestGaussian1", area=1.0, center=0.0, width=1.0, - unit='meV', + unit="meV", ) component2 = Lorentzian( - display_name='TestLorentzian1', + display_name="TestLorentzian1", area=2.0, center=1.0, width=0.5, - unit='meV', + unit="meV", ) component_collection = ComponentCollection() component_collection.append_component(component1) component_collection.append_component(component2) model_base = ModelBase( - display_name='InitModel', + display_name="InitModel", components=component_collection, - unit='meV', + unit="meV", Q=np.array([1.0, 2.0, 3.0]), ) @@ -46,8 +46,8 @@ def test_init(self, model_base): model = model_base # EXPECT - assert model.display_name == 'InitModel' - assert model.unit == 'meV' + assert model.display_name == "InitModel" + assert model.unit == "meV" assert len(model.components) == 2 np.testing.assert_array_equal(model.Q, np.array([1.0, 2.0, 3.0])) @@ -55,9 +55,9 @@ def test_init_raises_with_invalid_components(self): # WHEN / THEN / EXPECT with pytest.raises( TypeError, - match='Components must be ', + match="Components must be ", ): - ModelBase(components='invalid_component') + ModelBase(components="invalid_component") def test_evaluate_calls_all_component_collections(self, model_base): # WHEN @@ -88,7 +88,7 @@ def test_evaluate_no_component_collections_raises(self, model_base): model_base._component_collections = [] # THEN / EXPECT - with pytest.raises(ValueError, match='No components'): + with pytest.raises(ValueError, match="No components"): model_base.evaluate(x) def test_generate_component_collections_with_Q(self, model_base): @@ -101,17 +101,9 @@ def test_generate_component_collections_with_Q(self, model_base): assert isinstance(collection, ComponentCollection) assert len(collection.components) == 2 assert isinstance(collection.components[0], Gaussian) - assert collection.components[0].display_name == 'TestGaussian1' + assert collection.components[0].display_name == "TestGaussian1" assert isinstance(collection.components[1], Lorentzian) - assert collection.components[1].display_name == 'TestLorentzian1' - - def test_generate_component_collections_without_Q_warns(self, model_base): - # WHEN - model_base._Q = None - - # THEN / EXPECT - with pytest.warns(UserWarning, match='Q is not set'): - model_base._generate_component_collections() + assert collection.components[1].display_name == "TestLorentzian1" def test_fix_free_all_parameters(self, model_base): # WHEN @@ -134,12 +126,12 @@ def test_get_all_variables(self, model_base): # THEN expected_var_display_names = { - 'TestGaussian1 area', - 'TestGaussian1 center', - 'TestGaussian1 width', - 'TestLorentzian1 area', - 'TestLorentzian1 center', - 'TestLorentzian1 width', + "TestGaussian1 area", + "TestGaussian1 center", + "TestGaussian1 width", + "TestLorentzian1 area", + "TestLorentzian1 center", + "TestLorentzian1 width", } retrieved_var_display_names = {var.display_name for var in all_vars} @@ -153,12 +145,12 @@ def test_get_all_variables_with_Q_index(self, model_base): # THEN expected_var_display_names = { - 'TestGaussian1 area', - 'TestGaussian1 center', - 'TestGaussian1 width', - 'TestLorentzian1 area', - 'TestLorentzian1 center', - 'TestLorentzian1 width', + "TestGaussian1 area", + "TestGaussian1 center", + "TestGaussian1 width", + "TestLorentzian1 area", + "TestLorentzian1 center", + "TestLorentzian1 width", } retrieved_var_display_names = {var.display_name for var in all_vars} @@ -170,7 +162,7 @@ def test_get_all_variables_with_invalid_Q_index_raises(self, model_base): # WHEN / THEN / EXPECT with pytest.raises( IndexError, - match='Q_index 5 is out of bounds for component collections of length 3', + match="Q_index 5 is out of bounds for component collections of length 3", ): model_base.get_all_variables(Q_index=5) @@ -178,13 +170,13 @@ def test_get_all_variables_with_nonint_Q_index_raises(self, model_base): # WHEN / THEN / EXPECT with pytest.raises( TypeError, - match='Q_index must be an int or None, got str', + match="Q_index must be an int or None, got str", ): - model_base.get_all_variables(Q_index='invalid_index') + model_base.get_all_variables(Q_index="invalid_index") def test_append_and_remove_and_clear_component(self, model_base): # WHEN - new_component = Gaussian(unique_name='NewGaussian') + new_component = Gaussian(unique_name="NewGaussian") # THEN model_base.append_component(new_component) @@ -194,7 +186,7 @@ def test_append_and_remove_and_clear_component(self, model_base): assert model_base.components[-1] is new_component # THEN - model_base.remove_component('NewGaussian') + model_base.remove_component("NewGaussian") # EXPECT assert len(model_base.components) == 2 @@ -223,38 +215,40 @@ def test_append_component_collection(self, model_base): def test_append_component_invalid_type_raises(self, model_base): # WHEN / THEN / EXPECT - with pytest.raises(TypeError, match=' must be a ModelComponent or ComponentCollection'): - model_base.append_component('invalid_component') + with pytest.raises( + TypeError, match=" must be a ModelComponent or ComponentCollection" + ): + model_base.append_component("invalid_component") def test_unit_property(self, model_base): # WHEN unit = model_base.unit # THEN / EXPECT - assert unit == 'meV' + assert unit == "meV" def test_unit_setter_raises(self, model_base): # WHEN / THEN / EXPECT - with pytest.raises(AttributeError, match='Use convert_unit to change '): - model_base.unit = 'K' + with pytest.raises(AttributeError, match="Use convert_unit to change "): + model_base.unit = "K" def test_convert_unit(self, model_base): # WHEN - model_base.convert_unit('eV') + model_base.convert_unit("eV") # THEN / EXPECT - assert model_base.unit == 'eV' + assert model_base.unit == "eV" for component in model_base.components: - assert component.unit == 'eV' + assert component.unit == "eV" def test_convert_unit_invalid_raises(self, model_base): # WHEN / THEN / EXPECT with pytest.raises(Exception): - model_base.convert_unit('invalid_unit') + model_base.convert_unit("invalid_unit") def test_components_setter(self, model_base): # WHEN - new_component = Lorentzian(unique_name='NewLorentzian') + new_component = Lorentzian(unique_name="NewLorentzian") model_base.components = new_component # THEN / EXPECT @@ -280,9 +274,9 @@ def test_components_setter_invalid_raises(self, model_base): # WHEN / THEN / EXPECT with pytest.raises( TypeError, - match='Components must be ', + match="Components must be ", ): - model_base.components = 'invalid_component' + model_base.components = "invalid_component" def test_Q_setter(self, model_base): # WHEN @@ -297,7 +291,7 @@ def test_repr(self, model_base): repr_str = repr(model_base) # THEN / EXPECT - assert 'unique_name' in repr_str - assert 'unit' in repr_str - assert 'Q = ' in repr_str - assert 'components = ' in repr_str + assert "unique_name" in repr_str + assert "unit" in repr_str + assert "Q = " in repr_str + assert "components = " in repr_str diff --git a/tests/unit/easydynamics/utils/test_utils.py b/tests/unit/easydynamics/utils/test_utils.py index 97a6c36c..76c967c8 100644 --- a/tests/unit/easydynamics/utils/test_utils.py +++ b/tests/unit/easydynamics/utils/test_utils.py @@ -5,13 +5,14 @@ import pytest import scipp as sc +from easydynamics.utils.utils import _in_notebook from easydynamics.utils.utils import _validate_and_convert_Q from easydynamics.utils.utils import _validate_unit class TestValidateAndConvertQ: @pytest.mark.parametrize( - 'Q_input, expected', + "Q_input, expected", [ (1.0, np.array([1.0])), (2, np.array([2])), @@ -29,7 +30,7 @@ def test_validate_and_convert_Q_numeric_and_array(self, Q_input, expected): def test_validate_and_convert_Q_scipp_variable(self): # WHEN - Q = sc.array(dims=['Q'], values=[1.0, 2.0], unit='1/angstrom') + Q = sc.array(dims=["Q"], values=[1.0, 2.0], unit="1/angstrom") # THEN result = _validate_and_convert_Q(Q) @@ -43,29 +44,29 @@ def test_validate_and_convert_Q_none(self): assert _validate_and_convert_Q(None) is None @pytest.mark.parametrize( - 'Q_input', + "Q_input", [ - 'invalid', - {'a': 1}, + "invalid", + {"a": 1}, (1, 2), object(), ], ) def test_validate_and_convert_Q_invalid_type(self, Q_input): # WHEN THEN EXPECT - with pytest.raises(TypeError, match='Q must be a number'): + with pytest.raises(TypeError, match="Q must be a number"): _validate_and_convert_Q(Q_input) def test_validate_and_convert_Q_ndarray_wrong_dim(self): # WHEN THEN Q = np.array([[1.0, 2.0]]) # EXPECT - with pytest.raises(ValueError, match='Q must be a 1-dimensional array'): + with pytest.raises(ValueError, match="Q must be a 1-dimensional array"): _validate_and_convert_Q(Q) def test_validate_and_convert_Q_scipp_wrong_dims(self): # WHEN THEN - Q = sc.array(dims=['x'], values=[1.0, 2.0], unit='1/angstrom') + Q = sc.array(dims=["x"], values=[1.0, 2.0], unit="1/angstrom") # EXPECT with pytest.raises(ValueError, match="single dimension named 'Q'"): @@ -77,12 +78,12 @@ def test_validate_and_convert_Q_scipp_wrong_dims(self): class TestValidateUnit: @pytest.mark.parametrize( - 'unit_input', + "unit_input", [ None, - '1/angstrom', - 'meV', - sc.Unit('meV'), + "1/angstrom", + "meV", + sc.Unit("meV"), ], ) def test_validate_unit_valid(self, unit_input): @@ -94,13 +95,13 @@ def test_validate_unit_valid(self, unit_input): assert isinstance(unit, sc.Unit) def test_validate_unit_string_conversion(self): - unit = _validate_unit('meV') + unit = _validate_unit("meV") assert isinstance(unit, sc.Unit) - assert unit == sc.Unit('meV') + assert unit == sc.Unit("meV") @pytest.mark.parametrize( - 'unit_input', + "unit_input", [ 123, 45.6, @@ -110,5 +111,69 @@ def test_validate_unit_string_conversion(self): ], ) def test_validate_unit_invalid_type(self, unit_input): - with pytest.raises(TypeError, match='unit must be None, a string, or a scipp Unit'): + with pytest.raises( + TypeError, match="unit must be None, a string, or a scipp Unit" + ): _validate_unit(unit_input) + + +# ----------------------------- + + +class TestInNotebook: + + def test_in_notebook_returns_true_for_jupyter(self, monkeypatch): + """Should return True when IPython shell is + ZMQInteractiveShell (Jupyter).""" + + # WHEN + class ZMQInteractiveShell: + __name__ = "ZMQInteractiveShell" + + # THEN + monkeypatch.setattr("IPython.get_ipython", lambda: ZMQInteractiveShell()) + + # EXPECT + assert _in_notebook() is True + + def test_in_notebook_returns_false_for_terminal_ipython(self, monkeypatch): + """Should return False when IPython shell is + TerminalInteractiveShell.""" + + # WHEN + class TerminalInteractiveShell: + __name__ = "TerminalInteractiveShell" + + # THEN + + monkeypatch.setattr("IPython.get_ipython", lambda: TerminalInteractiveShell()) + + # EXPECT + assert _in_notebook() is False + + def test_in_notebook_returns_false_for_unknown_shell(self, monkeypatch): + """Should return False when IPython shell type is + unrecognized.""" + + # WHEN + class UnknownShell: + __name__ = "UnknownShell" + + # THEN + monkeypatch.setattr("IPython.get_ipython", lambda: UnknownShell()) + # EXPECT + assert _in_notebook() is False + + def test_in_notebook_returns_false_when_no_ipython(self, monkeypatch): + """Should return False when IPython is not installed or + available.""" + + # WHEN + def raise_import_error(*args, **kwargs): + raise ImportError + + # THEN + monkeypatch.setattr("builtins.__import__", raise_import_error) + + # EXPECT + assert _in_notebook() is False From b9d3fecf02f609ba29957cfefb6164727184fdae Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Thu, 5 Feb 2026 20:30:14 +0100 Subject: [PATCH 11/27] Instrument model (#94) * initial instrument model * first draft of analysis * add test of model base * small changes * tests * clear notebook * respond to PR comments * Update resolution_model docstring for clarity --- .../easydynamics/sample_model/test_model_base.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/unit/easydynamics/sample_model/test_model_base.py b/tests/unit/easydynamics/sample_model/test_model_base.py index 692d7e42..0a5ec3f5 100644 --- a/tests/unit/easydynamics/sample_model/test_model_base.py +++ b/tests/unit/easydynamics/sample_model/test_model_base.py @@ -120,6 +120,21 @@ def test_fix_free_all_parameters(self, model_base): for par in model_base.get_all_variables(): assert par.fixed is False + def test_fix_free_all_parameters(self, model_base): + # WHEN + model_base.fix_all_parameters() + + # THEN + for par in model_base.get_all_variables(): + assert par.fixed is True + + # WHEN + model_base.free_all_parameters() + + # THEN + for par in model_base.get_all_variables(): + assert par.fixed is False + def test_get_all_variables(self, model_base): # WHEN all_vars = model_base.get_all_variables() From 248773d7b8b1ffb0349a73b7ea4d1092b80b8772 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Tue, 3 Feb 2026 10:46:07 +0100 Subject: [PATCH 12/27] initial analysis class --- docs/docs/tutorials/analysis.ipynb | 108 ++++++++++++++++++ .../convolution/convolution_base.py | 2 + src/easydynamics/sample_model/__init__.py | 18 +++ 3 files changed, 128 insertions(+) diff --git a/docs/docs/tutorials/analysis.ipynb b/docs/docs/tutorials/analysis.ipynb index 3da1411c..27b0cdb6 100644 --- a/docs/docs/tutorials/analysis.ipynb +++ b/docs/docs/tutorials/analysis.ipynb @@ -5,10 +5,14 @@ "id": "8643b10c", "metadata": {}, "source": [ +<<<<<<< HEAD "# Analysis\n", "It is time to analyse some data. We here show how to set up an Analysis object and use it to first fit an artificial vanadium measurements, and next an artificial measurement of a model with diffusion and some elastic scattering.\n", "\n", "In the near future, it will be possible to fit the width and area of the Lorentzian to the diffusion model, as well as fitting the diffusion model directly to the data." +======= + "asd" +>>>>>>> 7b7cf5e (initial analysis class) ] }, { @@ -24,15 +28,22 @@ "from easydynamics.experiment import Experiment\n", "from easydynamics.sample_model import ComponentCollection\n", "from easydynamics.sample_model import DeltaFunction\n", +<<<<<<< HEAD "from easydynamics.sample_model import Lorentzian\n", +======= +>>>>>>> 7b7cf5e (initial analysis class) "from easydynamics.sample_model import Gaussian\n", "from easydynamics.sample_model import Polynomial\n", "from easydynamics.sample_model.background_model import BackgroundModel\n", "from easydynamics.sample_model.resolution_model import ResolutionModel\n", "from easydynamics.sample_model.sample_model import SampleModel\n", +<<<<<<< HEAD "from easydynamics.sample_model.instrument_model import InstrumentModel\n", "from easydynamics.analysis.analysis import Analysis\n", "from copy import copy\n", +======= + "\n", +>>>>>>> 7b7cf5e (initial analysis class) "%matplotlib widget" ] }, @@ -50,6 +61,7 @@ { "cell_type": "code", "execution_count": null, +<<<<<<< HEAD "id": "6762faba", "metadata": {}, "outputs": [], @@ -62,11 +74,37 @@ "\n", "res_gauss = Gaussian(width=0.1)\n", "res_gauss.area.fixed=True\n", +======= + "id": "41f842f0", + "metadata": {}, + "outputs": [], + "source": [ + "# Create a diffusion_model and components for the SampleModel\n", + "\n", + "# Creating components\n", + "component_collection = ComponentCollection()\n", + "delta_function = DeltaFunction(display_name='DeltaFunction', area=1)\n", + "gaussian = Gaussian(display_name='Gaussian', width=0.1, center=0.5, area=0.5)\n", + "\n", + "# Adding components to the component collection\n", + "component_collection.append_component(delta_function)\n", + "\n", + "\n", + "sample_model = SampleModel(\n", + " components=component_collection,\n", + " unit='meV',\n", + " display_name='MySampleModel',\n", + ")\n", + "\n", + "res_gauss = Gaussian(width=0.1)\n", + "res_gauss.area.fixed = True\n", +>>>>>>> 7b7cf5e (initial analysis class) "resolution_model = ResolutionModel(components=res_gauss)\n", "\n", "\n", "background_model = BackgroundModel(components=Polynomial(coefficients=[0.001]))\n", "\n", +<<<<<<< HEAD "instrument_model = InstrumentModel(\n", " resolution_model=resolution_model,\n", " background_model=background_model,\n", @@ -187,22 +225,77 @@ "diffusion_analysis.instrument_model._resolution_model = vanadium_analysis.instrument_model.resolution_model\n", "diffusion_analysis.instrument_model.resolution_model.fix_all_parameters()\n", "diffusion_analysis.plot_parameters(names=[\"Gaussian width\"])\n" +======= + "my_analysis = Analysis1d(\n", + " experiment=vanadium_experiment,\n", + " sample_model=sample_model,\n", + " resolution_model=resolution_model,\n", + " background_model=background_model,\n", + " Q_index=5,\n", + ")\n", + "\n", + "my_analysis._update_models()\n", + "\n", + "\n", + "values = my_analysis.calculate()\n", + "sample_values, background_values = my_analysis.calculate_individual_components()\n", + "\n", + "plt.figure()\n", + "plt.plot(my_analysis.energy.values, values, label='Total Model')\n", + "for component_index in range(len(sample_values)):\n", + " plt.plot(\n", + " my_analysis.energy.values,\n", + " sample_values[component_index],\n", + " label=f'Sample Component {component_index}',\n", + " linestyle='--',\n", + " )\n", + "\n", + "for component_index in range(len(background_values)):\n", + " plt.plot(\n", + " my_analysis.energy.values,\n", + " background_values[component_index],\n", + " label=f'Background Component {component_index}',\n", + " linestyle=':',\n", + " )\n", + "plt.xlabel('Energy (meV)')\n", + "plt.ylabel('Intensity')\n", + "plt.title(f'Q index: {5}')\n", + "plt.legend()\n", + "plt.show()" +>>>>>>> 7b7cf5e (initial analysis class) ] }, { "cell_type": "code", "execution_count": null, +<<<<<<< HEAD "id": "c66828eb", "metadata": {}, "outputs": [], "source": [ "# Let us see how good the starting parameters are\n", "diffusion_analysis.plot_data_and_model()" +======= + "id": "6762faba", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "02702f95", + "metadata": {}, + "outputs": [], + "source": [ + "my_analysis.plot_data_and_model()" +>>>>>>> 7b7cf5e (initial analysis class) ] }, { "cell_type": "code", "execution_count": null, +<<<<<<< HEAD "id": "197b44c5", "metadata": {}, "outputs": [], @@ -210,11 +303,19 @@ "# Now we fit the data and plot the result. Looks good!\n", "diffusion_analysis.fit(fit_method=\"independent\")\n", "diffusion_analysis.plot_data_and_model()" +======= + "id": "70091539", + "metadata": {}, + "outputs": [], + "source": [ + "my_analysis.fit()" +>>>>>>> 7b7cf5e (initial analysis class) ] }, { "cell_type": "code", "execution_count": null, +<<<<<<< HEAD "id": "df14b5c4", "metadata": {}, "outputs": [], @@ -232,6 +333,13 @@ "source": [ "# It will be possible to fit this to a DiffusionModel, but that will\n", "# come later." +======= + "id": "2ad6384e", + "metadata": {}, + "outputs": [], + "source": [ + "my_analysis.plot_data_and_model()" +>>>>>>> 7b7cf5e (initial analysis class) ] } ], diff --git a/src/easydynamics/convolution/convolution_base.py b/src/easydynamics/convolution/convolution_base.py index 9c212d64..be5cff06 100644 --- a/src/easydynamics/convolution/convolution_base.py +++ b/src/easydynamics/convolution/convolution_base.py @@ -79,6 +79,8 @@ def __init__( resolution_components = ComponentCollection( components=[resolution_components] ) + if isinstance(resolution_components, ModelComponent): + resolution_components = ComponentCollection(components=[resolution_components]) self._resolution_components = resolution_components @property diff --git a/src/easydynamics/sample_model/__init__.py b/src/easydynamics/sample_model/__init__.py index 443c1982..c8fc2a0e 100644 --- a/src/easydynamics/sample_model/__init__.py +++ b/src/easydynamics/sample_model/__init__.py @@ -9,14 +9,19 @@ from .components import Lorentzian from .components import Polynomial from .components import Voigt +<<<<<<< HEAD from .diffusion_model.brownian_translational_diffusion import ( BrownianTranslationalDiffusion, ) from .instrument_model import InstrumentModel +======= +from .diffusion_model.brownian_translational_diffusion import BrownianTranslationalDiffusion +>>>>>>> 7b7cf5e (initial analysis class) from .resolution_model import ResolutionModel from .sample_model import SampleModel __all__ = [ +<<<<<<< HEAD "ComponentCollection", "Gaussian", "Lorentzian", @@ -29,4 +34,17 @@ "ResolutionModel", "BackgroundModel", "InstrumentModel", +======= + 'ComponentCollection', + 'Gaussian', + 'Lorentzian', + 'Voigt', + 'DeltaFunction', + 'DampedHarmonicOscillator', + 'Polynomial', + 'BrownianTranslationalDiffusion', + 'SampleModel', + 'ResolutionModel', + 'BackgroundModel', +>>>>>>> 7b7cf5e (initial analysis class) ] From ce9acf82aadd90cc42ab0eb5d168826fe0cbc4a7 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Thu, 12 Feb 2026 13:18:32 +0100 Subject: [PATCH 13/27] fix merge conflict --- src/easydynamics/sample_model/__init__.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/easydynamics/sample_model/__init__.py b/src/easydynamics/sample_model/__init__.py index c8fc2a0e..443c1982 100644 --- a/src/easydynamics/sample_model/__init__.py +++ b/src/easydynamics/sample_model/__init__.py @@ -9,19 +9,14 @@ from .components import Lorentzian from .components import Polynomial from .components import Voigt -<<<<<<< HEAD from .diffusion_model.brownian_translational_diffusion import ( BrownianTranslationalDiffusion, ) from .instrument_model import InstrumentModel -======= -from .diffusion_model.brownian_translational_diffusion import BrownianTranslationalDiffusion ->>>>>>> 7b7cf5e (initial analysis class) from .resolution_model import ResolutionModel from .sample_model import SampleModel __all__ = [ -<<<<<<< HEAD "ComponentCollection", "Gaussian", "Lorentzian", @@ -34,17 +29,4 @@ "ResolutionModel", "BackgroundModel", "InstrumentModel", -======= - 'ComponentCollection', - 'Gaussian', - 'Lorentzian', - 'Voigt', - 'DeltaFunction', - 'DampedHarmonicOscillator', - 'Polynomial', - 'BrownianTranslationalDiffusion', - 'SampleModel', - 'ResolutionModel', - 'BackgroundModel', ->>>>>>> 7b7cf5e (initial analysis class) ] From f16a7e3beac95af1b2872f5ea5eea582f577bc56 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Thu, 12 Feb 2026 13:28:04 +0100 Subject: [PATCH 14/27] Remove notebook --- .../analysis old parameter bug.ipynb | 384 ------------------ 1 file changed, 384 deletions(-) delete mode 100644 docs/docs/tutorials/analysis old parameter bug.ipynb diff --git a/docs/docs/tutorials/analysis old parameter bug.ipynb b/docs/docs/tutorials/analysis old parameter bug.ipynb deleted file mode 100644 index 85bddaaa..00000000 --- a/docs/docs/tutorials/analysis old parameter bug.ipynb +++ /dev/null @@ -1,384 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "8643b10c", - "metadata": {}, - "source": [ - "asd" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bca91d3c", - "metadata": {}, - "outputs": [], - "source": [ - "import matplotlib.pyplot as plt\n", - "\n", - "from easydynamics.analysis.analysis1d import Analysis1d\n", - "from easydynamics.experiment import Experiment\n", - "from easydynamics.sample_model import ComponentCollection\n", - "from easydynamics.sample_model import DeltaFunction\n", - "from easydynamics.sample_model import Gaussian\n", - "from easydynamics.sample_model import Polynomial\n", - "from easydynamics.sample_model.background_model import BackgroundModel\n", - "from easydynamics.sample_model.resolution_model import ResolutionModel\n", - "from easydynamics.sample_model.sample_model import SampleModel\n", - "from easydynamics.sample_model.instrument_model import InstrumentModel\n", - "from easydynamics.analysis.analysis import Analysis\n", - "%matplotlib widget" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8deca9b6", - "metadata": {}, - "outputs": [], - "source": [ - "vanadium_experiment = Experiment('Vanadium')\n", - "vanadium_experiment.load_hdf5(filename='vanadium_data_example.h5')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "41f842f0", - "metadata": {}, - "outputs": [], - "source": [ - "# # Create a diffusion_model and components for the SampleModel\n", - "\n", - "# # Creating components\n", - "# component_collection = ComponentCollection()\n", - "# delta_function = DeltaFunction(display_name='DeltaFunction', area=1)\n", - "# gaussian = Gaussian(display_name='Gaussian', width=0.1, center=0.5, area=0.5)\n", - "\n", - "# # Adding components to the component collection\n", - "# component_collection.append_component(delta_function)\n", - "\n", - "\n", - "# sample_model = SampleModel(\n", - "# components=component_collection,\n", - "# unit='meV',\n", - "# display_name='MySampleModel',\n", - "# )\n", - "\n", - "# res_gauss = Gaussian(width=0.1)\n", - "# res_gauss.area.fixed = True\n", - "# resolution_model = ResolutionModel(components=res_gauss)\n", - "\n", - "\n", - "# background_model = BackgroundModel(components=Polynomial(coefficients=[0.001]))\n", - "\n", - "# instrument_model = InstrumentModel(\n", - "# resolution_model=resolution_model,\n", - "# background_model=background_model,\n", - "# )\n", - "\n", - "# my_analysis = Analysis1d(\n", - "# experiment=vanadium_experiment,\n", - "# sample_model=sample_model,\n", - "# instrument_model=instrument_model,\n", - "# Q_index=5,\n", - "# )\n", - "\n", - "\n", - "# values = my_analysis.calculate()\n", - "# sample_values, background_values = my_analysis.calculate_individual_components()\n", - "\n", - "# plt.figure()\n", - "# plt.plot(my_analysis.energy.values, values, label='Total Model')\n", - "# for component_index in range(len(sample_values)):\n", - "# plt.plot(\n", - "# my_analysis.energy.values,\n", - "# sample_values[component_index],\n", - "# label=f'Sample Component {component_index}',\n", - "# linestyle='--',\n", - "# )\n", - "\n", - "# for component_index in range(len(background_values)):\n", - "# plt.plot(\n", - "# my_analysis.energy.values,\n", - "# background_values[component_index],\n", - "# label=f'Background Component {component_index}',\n", - "# linestyle=':',\n", - "# )\n", - "# plt.xlabel('Energy (meV)')\n", - "# plt.ylabel('Intensity')\n", - "# plt.title(f'Q index: {5}')\n", - "# plt.legend()\n", - "# plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6762faba", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "02702f95", - "metadata": {}, - "outputs": [], - "source": [ - "# my_analysis.plot_data_and_model()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "70091539", - "metadata": {}, - "outputs": [], - "source": [ - "# my_analysis.fit()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2ad6384e", - "metadata": {}, - "outputs": [], - "source": [ - "# my_analysis.plot_data_and_model()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2dfb1f90", - "metadata": {}, - "outputs": [], - "source": [ - "# my_analysis.get_all_variables()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5afefbab", - "metadata": {}, - "outputs": [], - "source": [ - "# my_analysis.get_fit_parameters()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "465c0e1e", - "metadata": {}, - "outputs": [], - "source": [ - "# for Q_index in range(len(my_analysis.Q)):\n", - "# my_analysis.Q_index = Q_index\n", - "# my_analysis.fit()\n", - "# my_analysis.plot_data_and_model()\n", - "# print(my_analysis.get_fit_parameters())\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9bdeed2b", - "metadata": {}, - "outputs": [], - "source": [ - "# Create a diffusion_model and components for the SampleModel\n", - "\n", - "# Creating components\n", - "component_collection = ComponentCollection()\n", - "delta_function = DeltaFunction(display_name='DeltaFunction', area=1)\n", - "gaussian = Gaussian(display_name='Gaussian', width=0.1, center=0.5, area=0.5)\n", - "\n", - "# Adding components to the component collection\n", - "component_collection.append_component(delta_function)\n", - "\n", - "\n", - "sample_model = SampleModel(\n", - " components=component_collection,\n", - " unit='meV',\n", - " display_name='MySampleModel',\n", - ")\n", - "\n", - "res_gauss = Gaussian(width=0.1)\n", - "res_gauss.area.fixed = True\n", - "resolution_model = ResolutionModel(components=res_gauss)\n", - "\n", - "\n", - "background_model = BackgroundModel(components=Polynomial(coefficients=[0.001]))\n", - "\n", - "instrument_model = InstrumentModel(\n", - " resolution_model=resolution_model,\n", - " background_model=background_model,\n", - ")\n", - "\n", - "my_full_analysis = Analysis(\n", - " experiment=vanadium_experiment,\n", - " sample_model=sample_model,\n", - " instrument_model=instrument_model,\n", - ")\n", - "\n", - "# my_full_analysis._fit_all_Q_independently()\n", - "my_full_analysis._fit_all_Q_simultaneously()\n", - "for analysis_object in my_full_analysis._analysis_list:\n", - " analysis_object.plot_data_and_model()\n", - " print(analysis_object.get_fit_parameters())\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0a727fc3", - "metadata": {}, - "outputs": [], - "source": [ - "for analysis_object in my_full_analysis._analysis_list:\n", - " print(analysis_object.get_fit_parameters())\n", - "\n", - "for analysis_object in my_full_analysis._analysis_list:\n", - " print(analysis_object.get_fit_parameters()[0].unique_name)\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d0ceec1d", - "metadata": {}, - "outputs": [], - "source": [ - "p1=my_full_analysis._analysis_list[1].get_fit_parameters()[0]\n", - "print(p1)\n", - "print(p1.unique_name)\n", - "p2 = my_full_analysis._analysis_list[9].get_fit_parameters()[0]\n", - "print(p2)\n", - "print(p2.unique_name)\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d792eee3", - "metadata": {}, - "outputs": [], - "source": [ - "\n", - "my_full_analysis.Q" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4217d56d", - "metadata": {}, - "outputs": [], - "source": [ - "import matplotlib.pyplot as plt\n", - "\n", - "from easydynamics.sample_model import ComponentCollection\n", - "from easydynamics.sample_model import DeltaFunction\n", - "from easydynamics.sample_model.model_base import ModelBase\n", - "%matplotlib widget\n", - "import numpy as np\n", - "Q=np.linspace(0.1,15,31)\n", - "component_collection = ComponentCollection()\n", - "delta_function = DeltaFunction(display_name='DeltaFunction', area=1)\n", - "\n", - "component_collection.append_component(delta_function)\n", - "\n", - "\n", - "# sample_model = SampleModel(\n", - "sample_model = ModelBase(\n", - " components=component_collection,\n", - " unit='meV',\n", - " display_name='MySampleModel',\n", - " Q=Q,\n", - ")\n", - "\n", - "\n", - "for Q_index in range(len(sample_model.Q)):\n", - " pars = sample_model.get_all_variables(Q_index=Q_index) \n", - " pars[0].value=pars[0].value+Q_index\n", - "\n", - "for Q_index in range(len(sample_model.Q)):\n", - " pars = sample_model.get_all_variables(Q_index=Q_index)\n", - " print(pars[0].unique_name)\n", - " print(pars[0])\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "35c89ce3", - "metadata": {}, - "outputs": [], - "source": [ - "vars2=sample_model._component_collections[1].get_all_variables()\n", - "for var in vars2:\n", - " print(var)\n", - " print(var.unique_name)\n", - "\n", - "var3=sample_model._component_collections[10].get_all_variables()\n", - "for var in var3:\n", - " print(var)\n", - " print(var.unique_name)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "02320e75", - "metadata": {}, - "outputs": [], - "source": [ - "a=vanadium_experiment.binned_data.coords['energy']\n", - "a" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5451bbf3", - "metadata": {}, - "outputs": [], - "source": [ - "import scipp as sc\n", - "x_pixel_range = [-10, -5, 0, 5, 10]\n", - "a,b=sc.array(values=x_pixel_range, dims='x')\n", - "print(a)\n", - "print(b)" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "easydynamics_newbase", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.12" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} From 7a31120299d5ce29817072007e3f0a8e2dba54f4 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Thu, 12 Feb 2026 13:36:07 +0100 Subject: [PATCH 15/27] Update notebook, remove unused file --- docs/docs/tutorials/analysis.ipynb | 108 ----- src/easydynamics/analysis/analysis old.py | 497 ---------------------- 2 files changed, 605 deletions(-) delete mode 100644 src/easydynamics/analysis/analysis old.py diff --git a/docs/docs/tutorials/analysis.ipynb b/docs/docs/tutorials/analysis.ipynb index 27b0cdb6..3da1411c 100644 --- a/docs/docs/tutorials/analysis.ipynb +++ b/docs/docs/tutorials/analysis.ipynb @@ -5,14 +5,10 @@ "id": "8643b10c", "metadata": {}, "source": [ -<<<<<<< HEAD "# Analysis\n", "It is time to analyse some data. We here show how to set up an Analysis object and use it to first fit an artificial vanadium measurements, and next an artificial measurement of a model with diffusion and some elastic scattering.\n", "\n", "In the near future, it will be possible to fit the width and area of the Lorentzian to the diffusion model, as well as fitting the diffusion model directly to the data." -======= - "asd" ->>>>>>> 7b7cf5e (initial analysis class) ] }, { @@ -28,22 +24,15 @@ "from easydynamics.experiment import Experiment\n", "from easydynamics.sample_model import ComponentCollection\n", "from easydynamics.sample_model import DeltaFunction\n", -<<<<<<< HEAD "from easydynamics.sample_model import Lorentzian\n", -======= ->>>>>>> 7b7cf5e (initial analysis class) "from easydynamics.sample_model import Gaussian\n", "from easydynamics.sample_model import Polynomial\n", "from easydynamics.sample_model.background_model import BackgroundModel\n", "from easydynamics.sample_model.resolution_model import ResolutionModel\n", "from easydynamics.sample_model.sample_model import SampleModel\n", -<<<<<<< HEAD "from easydynamics.sample_model.instrument_model import InstrumentModel\n", "from easydynamics.analysis.analysis import Analysis\n", "from copy import copy\n", -======= - "\n", ->>>>>>> 7b7cf5e (initial analysis class) "%matplotlib widget" ] }, @@ -61,7 +50,6 @@ { "cell_type": "code", "execution_count": null, -<<<<<<< HEAD "id": "6762faba", "metadata": {}, "outputs": [], @@ -74,37 +62,11 @@ "\n", "res_gauss = Gaussian(width=0.1)\n", "res_gauss.area.fixed=True\n", -======= - "id": "41f842f0", - "metadata": {}, - "outputs": [], - "source": [ - "# Create a diffusion_model and components for the SampleModel\n", - "\n", - "# Creating components\n", - "component_collection = ComponentCollection()\n", - "delta_function = DeltaFunction(display_name='DeltaFunction', area=1)\n", - "gaussian = Gaussian(display_name='Gaussian', width=0.1, center=0.5, area=0.5)\n", - "\n", - "# Adding components to the component collection\n", - "component_collection.append_component(delta_function)\n", - "\n", - "\n", - "sample_model = SampleModel(\n", - " components=component_collection,\n", - " unit='meV',\n", - " display_name='MySampleModel',\n", - ")\n", - "\n", - "res_gauss = Gaussian(width=0.1)\n", - "res_gauss.area.fixed = True\n", ->>>>>>> 7b7cf5e (initial analysis class) "resolution_model = ResolutionModel(components=res_gauss)\n", "\n", "\n", "background_model = BackgroundModel(components=Polynomial(coefficients=[0.001]))\n", "\n", -<<<<<<< HEAD "instrument_model = InstrumentModel(\n", " resolution_model=resolution_model,\n", " background_model=background_model,\n", @@ -225,77 +187,22 @@ "diffusion_analysis.instrument_model._resolution_model = vanadium_analysis.instrument_model.resolution_model\n", "diffusion_analysis.instrument_model.resolution_model.fix_all_parameters()\n", "diffusion_analysis.plot_parameters(names=[\"Gaussian width\"])\n" -======= - "my_analysis = Analysis1d(\n", - " experiment=vanadium_experiment,\n", - " sample_model=sample_model,\n", - " resolution_model=resolution_model,\n", - " background_model=background_model,\n", - " Q_index=5,\n", - ")\n", - "\n", - "my_analysis._update_models()\n", - "\n", - "\n", - "values = my_analysis.calculate()\n", - "sample_values, background_values = my_analysis.calculate_individual_components()\n", - "\n", - "plt.figure()\n", - "plt.plot(my_analysis.energy.values, values, label='Total Model')\n", - "for component_index in range(len(sample_values)):\n", - " plt.plot(\n", - " my_analysis.energy.values,\n", - " sample_values[component_index],\n", - " label=f'Sample Component {component_index}',\n", - " linestyle='--',\n", - " )\n", - "\n", - "for component_index in range(len(background_values)):\n", - " plt.plot(\n", - " my_analysis.energy.values,\n", - " background_values[component_index],\n", - " label=f'Background Component {component_index}',\n", - " linestyle=':',\n", - " )\n", - "plt.xlabel('Energy (meV)')\n", - "plt.ylabel('Intensity')\n", - "plt.title(f'Q index: {5}')\n", - "plt.legend()\n", - "plt.show()" ->>>>>>> 7b7cf5e (initial analysis class) ] }, { "cell_type": "code", "execution_count": null, -<<<<<<< HEAD "id": "c66828eb", "metadata": {}, "outputs": [], "source": [ "# Let us see how good the starting parameters are\n", "diffusion_analysis.plot_data_and_model()" -======= - "id": "6762faba", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "02702f95", - "metadata": {}, - "outputs": [], - "source": [ - "my_analysis.plot_data_and_model()" ->>>>>>> 7b7cf5e (initial analysis class) ] }, { "cell_type": "code", "execution_count": null, -<<<<<<< HEAD "id": "197b44c5", "metadata": {}, "outputs": [], @@ -303,19 +210,11 @@ "# Now we fit the data and plot the result. Looks good!\n", "diffusion_analysis.fit(fit_method=\"independent\")\n", "diffusion_analysis.plot_data_and_model()" -======= - "id": "70091539", - "metadata": {}, - "outputs": [], - "source": [ - "my_analysis.fit()" ->>>>>>> 7b7cf5e (initial analysis class) ] }, { "cell_type": "code", "execution_count": null, -<<<<<<< HEAD "id": "df14b5c4", "metadata": {}, "outputs": [], @@ -333,13 +232,6 @@ "source": [ "# It will be possible to fit this to a DiffusionModel, but that will\n", "# come later." -======= - "id": "2ad6384e", - "metadata": {}, - "outputs": [], - "source": [ - "my_analysis.plot_data_and_model()" ->>>>>>> 7b7cf5e (initial analysis class) ] } ], diff --git a/src/easydynamics/analysis/analysis old.py b/src/easydynamics/analysis/analysis old.py deleted file mode 100644 index 9d9039ea..00000000 --- a/src/easydynamics/analysis/analysis old.py +++ /dev/null @@ -1,497 +0,0 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors -# SPDX-License-Identifier: BSD-3-Clause - - -import numpy as np -import plopp as pp -import scipp as sc -from easyscience.base_classes.model_base import ModelBase as EasyScienceModelBase -from easyscience.fitting.fitter import Fitter as EasyScienceFitter -from easyscience.variable import Parameter - -from easydynamics.convolution import Convolution -from easydynamics.experiment import Experiment -from easydynamics.sample_model import BackgroundModel -from easydynamics.sample_model import ResolutionModel -from easydynamics.sample_model import SampleModel - - -class Analysis(EasyScienceModelBase): - """For analysing data.""" - - def __init__( - self, - display_name: str = "MyAnalysis", - unique_name: str | None = None, - experiment: Experiment | None = None, - sample_model: SampleModel | None = None, - resolution_model: ResolutionModel | None = None, - background_model: BackgroundModel | None = None, - energy_offset: None = None, - ): - - super().__init__(display_name=display_name, unique_name=unique_name) - - if experiment is not None and not isinstance(experiment, Experiment): - raise TypeError("experiment must be an instance of Experiment or None.") - - self._experiment = experiment - - if sample_model is not None and not isinstance(sample_model, SampleModel): - raise TypeError("sample_model must be an instance of SampleModel or None.") - sample_model.Q = self.Q - self._sample_model = sample_model - - if resolution_model is not None and not isinstance( - resolution_model, ResolutionModel - ): - raise TypeError( - "resolution_model must be an instance of ResolutionModel or None." - ) - resolution_model.Q = self.Q - self._resolution_model = resolution_model - - if background_model is not None and not isinstance( - background_model, BackgroundModel - ): - raise TypeError( - "background_model must be an instance of BackgroundModel or None." - ) - background_model.Q = self.Q - self._background_model = background_model - - self._convolvers = [None] * (len(self.Q) if self.Q is not None else 0) - self._update_models() - - ############# - # Properties - ############# - - @property - def experiment(self) -> Experiment | None: - """The Experiment associated with this Analysis.""" - return self._experiment - - @experiment.setter - def experiment(self, value: Experiment | None) -> None: - if value is not None and not isinstance(value, Experiment): - raise TypeError("experiment must be an instance of Experiment or None.") - self._experiment = value - self._update_models() - - @property - def sample_model(self) -> SampleModel | None: - """The SampleModel associated with this Analysis.""" - return self._sample_model - - @sample_model.setter - def sample_model(self, value: SampleModel | None) -> None: - if value is not None and not isinstance(value, SampleModel): - raise TypeError("sample_model must be an instance of SampleModel or None.") - self._sample_model = value - self._update_models() - - @property - def resolution_model(self) -> ResolutionModel | None: - """The ResolutionModel associated with this Analysis.""" - return self._resolution_model - - @resolution_model.setter - def resolution_model(self, value: ResolutionModel | None) -> None: - if value is not None and not isinstance(value, ResolutionModel): - raise TypeError( - "resolution_model must be an instance of ResolutionModel or None." - ) - self._resolution_model = value - self._update_models() - - @property - def background_model(self) -> BackgroundModel | None: - """The BackgroundModel associated with this Analysis.""" - return self._background_model - - @background_model.setter - def background_model(self, value: BackgroundModel | None) -> None: - if value is not None and not isinstance(value, BackgroundModel): - raise TypeError( - "background_model must be an instance of BackgroundModel or None." - ) - self._background_model = value - self._update_models() - - @property - def Q(self) -> sc.Variable | None: - """The Q values from the associated Experiment, if available.""" - if self.experiment is not None: - return self.experiment.Q - return None - - @Q.setter - def Q(self, value) -> None: - """Q is a read-only property derived from the Experiment.""" - raise AttributeError("Q is a read-only property derived from the Experiment.") - - @property - def energy(self) -> sc.Variable | None: - """The energy values from the associated Experiment, if - available. - """ - if self.experiment is not None: - return self.experiment.energy - return None - - @energy.setter - def energy(self, value) -> None: - """Energy is a read-only property derived from the - Experiment. - """ - raise AttributeError( - "energy is a read-only property derived from the Experiment." - ) - - # TODO: make it use experiment temperature - @property - def temperature(self) -> Parameter | None: - """The temperature from the associated Experiment, if - available. - """ - return None - - @temperature.setter - def temperature(self, value) -> None: - """Temperature is a read-only property derived from the - Experiment. - """ - raise AttributeError( - "temperature is a read-only property derived from the Experiment." - ) - - # # TODO: make it use experiment temperature - # @property def temperature(self) -> Parameter | None: """The - # temperature from the associated Experiment, if available.""" if - # self.experiment is not None: return - # self.experiment.temperature return None - - # @temperature.setter def temperature(self, value) -> None: - # """temperature is a read-only property derived from the - # Experiment.""" raise AttributeError( "temperature is a - # read-only property derived from the Experiment." ) - - ############# - # Other methods - ############# - - def calculate(self, energy: float | None, Q_index: int) -> np.ndarray: - """Calculate the model prediction for a given Q index. - - Args: - energy (float): The energy value to calculate the model for. - Q_index (int): The index of the Q value to calculate the - model for. - Returns: - sc.DataArray: The calculated model prediction. - """ - if energy is None: - energy = self.energy - - if self.sample_model is None: - sample_intensity = np.zeros_like(energy) - else: - if self.resolution_model is None: - sample_intensity = self.sample_model._component_collections[ - Q_index - ].evaluate(energy) - else: - convolver = self._create_convolver(Q_index) - sample_intensity = convolver.convolution() - - if self.background_model is None: - background_intensity = np.zeros_like(energy) - else: - background_intensity = self.background_model._component_collections[ - Q_index - ].evaluate(energy) - - sample_plus_background = sample_intensity + background_intensity - - return sample_plus_background - - def calculate_individual_components( - self, Q_index: int - ) -> tuple[list[np.ndarray], list[np.ndarray]]: - """Calculate the model prediction for a given Q index for each - individual component. - - Args: - Q_index (int): The index of the Q value to calculate the - model for. - Returns: - list[np.ndarray]: The calculated model predictions for each - individual component. - """ - sample_results = [] - background_results = [] - - if self.sample_model is not None: - # Calculate sample components - for component in self.sample_model._component_collections[ - Q_index - ]._components: - if self.resolution_model is None: - component_intensity = component.evaluate(self.energy) - else: - convolver = Convolution( - sample_components=component, - resolution_components=self.resolution_model._component_collections[ - Q_index - ], - energy=self.energy, - temperature=self.temperature, - ) - component_intensity = convolver.convolution() - sample_results.append(component_intensity) - - if self.background_model is not None: - # Calculate background components - for component in self.background_model._component_collections[ - Q_index - ]._components: - component_intensity = component.evaluate(self.energy) - background_results.append(component_intensity) - - return sample_results, background_results - - def calculate_all_Q(self) -> list[np.ndarray]: - """Calculate the model prediction for all Q indices. - - Returns: - list[np.ndarray]: The calculated model predictions for all Q - indices. - """ - results = [] - for Q_index in range(len(self.Q)): - result = self.calculate(Q_index) - results.append(result) - return results - - # def calculate_individual_components_all_Q( - # self, - # add_background: bool = True, - # ) -> list[tuple[list[np.ndarray], list[np.ndarray]]]: - # """Calculate the model prediction for all Q indices for each - # individual component. - - # Returns: list[tuple[list[np.ndarray], list[np.ndarray]]]: The - # calculated model predictions for each individual component - # at all Q indices. """ all_results = [] for Q_index in - # range(len(self.Q)): sample_results, background_results = - # self.calculate_individual_components( Q_index ) if - # add_background: sample_results = sample_results + - # background_results all_results.append((sample_results, - # background_results)) return all_results - - def calculate_single_component_all_Q( - self, - component_index: int, - ) -> list[np.ndarray]: - """Calculate the model prediction for all Q indices for a single - component. - - Args: - component_index (int): The index of the component - Returns: - list[np.ndarray]: The calculated model predictions for the - specified component at all Q indices. - """ - - results = [] - for Q_index in range(len(self.Q)): - if self.sample_model is not None: - component = self.sample_model._component_collections[ - Q_index - ]._components[component_index] - if self.resolution_model is None: - component_intensity = component.evaluate(self.energy) - else: - convolver = Convolution( - sample_components=component, - resolution_components=self.resolution_model._component_collections[ - Q_index - ], - energy=self.energy, - temperature=self.temperature, - ) - component_intensity = convolver.convolution() - results.append(component_intensity) - else: - results.append(np.zeros_like(self.energy)) - - model_data_array = sc.DataArray( - data=sc.array(dims=["Q", "energy"], values=results), - coords={ - "Q": self.Q, - "energy": self.energy, - }, - ) - return model_data_array - - def fit(self, Q_index: int): - """Fit the model to the experimental data for a given Q index. - - Args: - Q_index (int): The index of the Q value to fit the model - to. - Returns: - FitResult: The result of the fit. - """ - if self._experiment is None: - raise ValueError("No experiment is associated with this Analysis.") - - if not isinstance(Q_index, int) or Q_index < 0 or Q_index >= len(self.Q): - raise ValueError("Q_index must be a valid index for the Q values.") - - data = self.experiment.data["Q", Q_index] - x = data.coords["energy"].values - y = data.values - e = data.variances**0.5 - - def fit_func(x_vals): - return self.calculate_theory(energy=x_vals, Q_index=Q_index) - - fitter = EasyScienceFitter( - fit_object=self, - fit_function=fit_func, - ) - - # Perform the fit - fit_result = fitter.fit(x=x, y=y, weights=1.0 / e) - - # Store result - self.fit_result = fit_result - - return fit_result - - def plot_data_and_model( - self, - plot_individual_components: bool = True, - ) -> None: - """Plot the experimental data and the model prediction. - - Args: - plot_individual_components (bool): Whether to plot - individual components. Default is True. - """ - if not isinstance(plot_individual_components, bool): - raise TypeError("plot_individual_components must be True or False.") - - model_data_array = self._create_model_data_group( - individual_components=plot_individual_components - ) - if self.experiment is None or self.experiment.data is None: - raise ValueError("Experiment data is not available for plotting.") - - from IPython.display import display - - fig = pp.slicer( - {"Data": self.experiment.data, "Model": model_data_array}, - color={"Data": "black", "Model": "red"}, - linestyle={"Data": "none", "Model": "solid"}, - marker={"Data": "o", "Model": "None"}, - ) - display(fig) - - ############# - # Private methods - ############# - - def _update_models(self): - """Update models based on the current experiment.""" - if self.experiment is None: - return - - for Q_index in range(len(self.Q)): - self._convolvers[Q_index] = self._create_convolver(Q_index) - - def _create_convolver(self, Q_index: int): - """Initialize and return a Convolution object for the given Q - index. - """ - # Add checks of empty sample models etc - - sample_components = self.sample_model._component_collections[Q_index] - resolution_components = self.resolution_model._component_collections[Q_index] - energy = self.energy - convolver = Convolution( - sample_components=sample_components, - resolution_components=resolution_components, - energy=energy, - temperature=self.temperature, - ) - return convolver - - def _create_model_data_group(self, individual_components=True) -> sc.DataArray: - """Create a Scipp DataArray representing the model over all Q - and energy values. - """ - if self.Q is None or self.energy is None: - raise ValueError("Q and energy must be defined in the experiment.") - - model_data = [] - for Q_index in range(len(self.Q)): - model_at_Q = self.calculate(Q_index) - model_data.append(model_at_Q) - - model_data_array = sc.DataArray( - data=sc.array(dims=["Q", "energy"], values=model_data), - coords={ - "Q": self.Q, - "energy": self.energy, - }, - ) - model_group = sc.DataGroup({"Model": model_data_array}) - - # if plot_individual_components: comps = - # ana.calculate_individual_components(E) for name, - # vals in comps.items(): if name not in - # component_arrays: component_arrays[name] = - # sc.zeros_like(data) csel = - # component_arrays[name] for d, i in - # zip(loop_dims, combo): csel = csel[d, i] - # csel.values = vals fsel.values = - # ana.calculate_theory(E) - - # # Build plot group - # data_and_model = {"Data": self._experiment._data.data, - # "Model": fit_total} if plot_individual_components and - # component_arrays: data_and_model.update(component_arrays) - # data_and_model = sc.DataGroup(data_and_model) - - if individual_components: - components = self.calculate_individual_components_all_Q() - for Q_index, (sample_comps, background_comps) in enumerate(components): - for samp_index, samp_comp in enumerate(sample_comps): - model_data_array[samp_comp.display_name] = sc.zeros_like( - model_data_array.data - ) - model_data_array[samp_comp.display_name].data[ - Q_index, : - ] = samp_comp - for back_index, back_comp in enumerate(background_comps): - model_data_array[back_comp.display_name] = sc.zeros_like( - model_data_array.data - ) - model_data_array[back_comp.display_name].data[ - Q_index, : - ] = back_comp - - model_data_array = model_data_array + model_group # WRONG BUT LINT - return model_data_array - - # def _create_convolvers( - # self, energy: np.ndarray | sc.Variable | None = None - # ) -> None: - # """Create Convolution objects for each Q value.""" - # num_Q = len(self.Q) if self.Q is not None else 0 - # self._convolvers = [ - # self._create_convolver(i, energy=energy) for i in range(num_Q) - # ] From 10d085edeeb92a6e4353348db0a6ffad2876c7b2 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Thu, 12 Feb 2026 13:39:45 +0100 Subject: [PATCH 16/27] pixi run fix --- docs/docs/tutorials/analysis.ipynb | 37 ++-- docs/docs/tutorials/analysis1d.ipynb | 9 +- pixi.lock | 2 +- src/easydynamics/analysis/analysis.py | 118 ++++++------ src/easydynamics/analysis/analysis1d.py | 115 +++++------- src/easydynamics/analysis/analysis_base.py | 57 +++--- .../convolution/analytical_convolution.py | 35 ++-- src/easydynamics/convolution/convolution.py | 47 +++-- .../convolution/convolution_base.py | 49 +++-- .../convolution/numerical_convolution.py | 14 +- .../convolution/numerical_convolution_base.py | 68 +++---- src/easydynamics/experiment/experiment.py | 66 +++---- src/easydynamics/sample_model/__init__.py | 28 ++- .../sample_model/component_collection.py | 72 +++----- .../jump_translational_diffusion.py | 58 +++--- .../sample_model/instrument_model.py | 56 +++--- src/easydynamics/sample_model/model_base.py | 43 ++--- src/easydynamics/utils/utils.py | 18 +- .../experiment/test_experiment.py | 171 +++++++++--------- .../sample_model/test_model_base.py | 109 +++++------ tests/unit/easydynamics/utils/test_utils.py | 49 +++-- 21 files changed, 543 insertions(+), 678 deletions(-) diff --git a/docs/docs/tutorials/analysis.ipynb b/docs/docs/tutorials/analysis.ipynb index 3da1411c..72c182b7 100644 --- a/docs/docs/tutorials/analysis.ipynb +++ b/docs/docs/tutorials/analysis.ipynb @@ -18,21 +18,18 @@ "metadata": {}, "outputs": [], "source": [ - "import matplotlib.pyplot as plt\n", - "\n", - "from easydynamics.analysis.analysis1d import Analysis1d\n", + "from easydynamics.analysis.analysis import Analysis\n", "from easydynamics.experiment import Experiment\n", "from easydynamics.sample_model import ComponentCollection\n", "from easydynamics.sample_model import DeltaFunction\n", - "from easydynamics.sample_model import Lorentzian\n", "from easydynamics.sample_model import Gaussian\n", + "from easydynamics.sample_model import Lorentzian\n", "from easydynamics.sample_model import Polynomial\n", "from easydynamics.sample_model.background_model import BackgroundModel\n", + "from easydynamics.sample_model.instrument_model import InstrumentModel\n", "from easydynamics.sample_model.resolution_model import ResolutionModel\n", "from easydynamics.sample_model.sample_model import SampleModel\n", - "from easydynamics.sample_model.instrument_model import InstrumentModel\n", - "from easydynamics.analysis.analysis import Analysis\n", - "from copy import copy\n", + "\n", "%matplotlib widget" ] }, @@ -61,7 +58,7 @@ ")\n", "\n", "res_gauss = Gaussian(width=0.1)\n", - "res_gauss.area.fixed=True\n", + "res_gauss.area.fixed = True\n", "resolution_model = ResolutionModel(components=res_gauss)\n", "\n", "\n", @@ -79,7 +76,7 @@ " instrument_model=instrument_model,\n", ")\n", "\n", - "fit_result_independent_single_Q = vanadium_analysis.fit(fit_method=\"independent\", Q_index=5)\n", + "fit_result_independent_single_Q = vanadium_analysis.fit(fit_method='independent', Q_index=5)\n", "vanadium_analysis.plot_data_and_model(Q_index=5)" ] }, @@ -90,7 +87,7 @@ "metadata": {}, "outputs": [], "source": [ - "fit_result_independent_all_Q = vanadium_analysis.fit(fit_method=\"independent\")\n", + "fit_result_independent_all_Q = vanadium_analysis.fit(fit_method='independent')\n", "vanadium_analysis.plot_data_and_model()" ] }, @@ -101,7 +98,7 @@ "metadata": {}, "outputs": [], "source": [ - "fit_result_simultaneous = vanadium_analysis.fit(fit_method=\"simultaneous\")\n", + "fit_result_simultaneous = vanadium_analysis.fit(fit_method='simultaneous')\n", "fit_result_simultaneous\n", "vanadium_analysis.plot_data_and_model()" ] @@ -114,7 +111,7 @@ "outputs": [], "source": [ "# Inspect the Parameters as a scipp Dataset\n", - "vanadium_analysis.parameters_to_dataset()\n" + "vanadium_analysis.parameters_to_dataset()" ] }, { @@ -125,7 +122,7 @@ "outputs": [], "source": [ "# Plot some of fitted parameters as a function of Q\n", - "vanadium_analysis.plot_parameters(names=[\"DeltaFunction area\"])\n" + "vanadium_analysis.plot_parameters(names=['DeltaFunction area'])" ] }, { @@ -135,7 +132,7 @@ "metadata": {}, "outputs": [], "source": [ - "vanadium_analysis.plot_parameters(names=[\"Gaussian width\"])" + "vanadium_analysis.plot_parameters(names=['Gaussian width'])" ] }, { @@ -161,7 +158,7 @@ "# We set up the model first.\n", "delta_function = DeltaFunction(display_name='DeltaFunction', area=0.2)\n", "lorentzian = Lorentzian(display_name='Lorentzian', area=0.5, width=0.3)\n", - "component_collection=ComponentCollection(\n", + "component_collection = ComponentCollection(\n", " components=[delta_function, lorentzian],\n", ")\n", "sample_model = SampleModel(\n", @@ -184,9 +181,11 @@ "# We need to hack in the resolution model from the vanadium analysis,\n", "# since the setters and getters overwrite the model. This will be fixed\n", "# asap.\n", - "diffusion_analysis.instrument_model._resolution_model = vanadium_analysis.instrument_model.resolution_model\n", + "diffusion_analysis.instrument_model._resolution_model = (\n", + " vanadium_analysis.instrument_model.resolution_model\n", + ")\n", "diffusion_analysis.instrument_model.resolution_model.fix_all_parameters()\n", - "diffusion_analysis.plot_parameters(names=[\"Gaussian width\"])\n" + "diffusion_analysis.plot_parameters(names=['Gaussian width'])" ] }, { @@ -208,7 +207,7 @@ "outputs": [], "source": [ "# Now we fit the data and plot the result. Looks good!\n", - "diffusion_analysis.fit(fit_method=\"independent\")\n", + "diffusion_analysis.fit(fit_method='independent')\n", "diffusion_analysis.plot_data_and_model()" ] }, @@ -220,7 +219,7 @@ "outputs": [], "source": [ "# Let us look at the most interesting fit parameters\n", - "diffusion_analysis.plot_parameters(names=[\"Lorentzian width\", \"Lorentzian area\"])" + "diffusion_analysis.plot_parameters(names=['Lorentzian width', 'Lorentzian area'])" ] }, { diff --git a/docs/docs/tutorials/analysis1d.ipynb b/docs/docs/tutorials/analysis1d.ipynb index 8a695913..2aff88b3 100644 --- a/docs/docs/tutorials/analysis1d.ipynb +++ b/docs/docs/tutorials/analysis1d.ipynb @@ -16,19 +16,16 @@ "metadata": {}, "outputs": [], "source": [ - "import matplotlib.pyplot as plt\n", - "\n", "from easydynamics.analysis.analysis1d import Analysis1d\n", "from easydynamics.experiment import Experiment\n", - "from easydynamics.sample_model import ComponentCollection\n", "from easydynamics.sample_model import DeltaFunction\n", "from easydynamics.sample_model import Gaussian\n", "from easydynamics.sample_model import Polynomial\n", "from easydynamics.sample_model.background_model import BackgroundModel\n", + "from easydynamics.sample_model.instrument_model import InstrumentModel\n", "from easydynamics.sample_model.resolution_model import ResolutionModel\n", "from easydynamics.sample_model.sample_model import SampleModel\n", - "from easydynamics.sample_model.instrument_model import InstrumentModel\n", - "from easydynamics.analysis.analysis import Analysis\n", + "\n", "%matplotlib widget" ] }, @@ -76,7 +73,7 @@ ")\n", "\n", "fit_result = my_analysis.fit()\n", - "my_analysis.plot_data_and_model()\n" + "my_analysis.plot_data_and_model()" ] } ], diff --git a/pixi.lock b/pixi.lock index da8aee45..789c15eb 100644 --- a/pixi.lock +++ b/pixi.lock @@ -4091,7 +4091,7 @@ packages: requires_python: '>=3.5' - pypi: ./ name: easydynamics - version: 0.1.1+devdirty2 + version: 0.1.1+devdirty20 sha256: de299c914d4a865b9e2fdefa5e3947f37b1f26f73ff9087f7918ee417f3dd288 requires_dist: - darkdetect diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index 81921b20..6c662928 100644 --- a/src/easydynamics/analysis/analysis.py +++ b/src/easydynamics/analysis/analysis.py @@ -23,7 +23,7 @@ class Analysis(AnalysisBase): def __init__( self, - display_name: str = "MyAnalysis", + display_name: str = 'MyAnalysis', unique_name: str | None = None, experiment: Experiment | None = None, sample_model: SampleModel | None = None, @@ -41,14 +41,14 @@ def __init__( ) if experiment is not None and not isinstance(experiment, Experiment): - raise TypeError("experiment must be an instance of Experiment or None.") + raise TypeError('experiment must be an instance of Experiment or None.') self._analysis_list = [] if self.Q is not None: for Q_index in range(len(self.Q)): analysis = Analysis1d( - display_name=f"{self.display_name}_Q{Q_index}", - unique_name=(f"{self.unique_name}_Q{Q_index}"), + display_name=f'{self.display_name}_Q{Q_index}', + unique_name=(f'{self.unique_name}_Q{Q_index}'), experiment=self.experiment, sample_model=self.sample_model, instrument_model=self.instrument_model, @@ -68,13 +68,16 @@ def analysis_list(self) -> list[Analysis1d]: @analysis_list.setter def analysis_list(self, value: list[Analysis1d]) -> None: - """analysis_list is read-only. To change the analysis list, - modify the experiment, sample model, or instrument model.""" + """analysis_list is read-only. + + To change the analysis list, modify the experiment, sample + model, or instrument model. + """ raise AttributeError( - "analysis_list is read-only. " - "To change the analysis list, modify the experiment, sample model, " - "or instrument model." + 'analysis_list is read-only. ' + 'To change the analysis list, modify the experiment, sample model, ' + 'or instrument model.' ) ############# @@ -84,9 +87,8 @@ def calculate( self, Q_index: int | None = None, ) -> list[np.ndarray] | np.ndarray: - """Calculate model data for a specific Q index. - If Q_index is None, calculate for all Q indices and return a - list of arrays. + """Calculate model data for a specific Q index. If Q_index is + None, calculate for all Q indices and return a list of arrays. Parameters: Q_index: Index of the Q value to calculate for. If None, calculate for all Q values. @@ -104,7 +106,7 @@ def calculate( def fit( self, - fit_method: str = "independent", + fit_method: str = 'independent', Q_index: int | None = None, ) -> FitResults | list[FitResults]: """Fit the model to the experimental data. @@ -126,22 +128,20 @@ def fit( if self.Q is None: raise ValueError( - "No Q values available for fitting. Please check the experiment data." + 'No Q values available for fitting. Please check the experiment data.' ) Q_index = self._verify_Q_index(Q_index) - if fit_method == "independent": + if fit_method == 'independent': if Q_index is not None: return self._fit_single_Q(Q_index) else: return self._fit_all_Q_independently() - elif fit_method == "simultaneous": + elif fit_method == 'simultaneous': return self._fit_all_Q_simultaneously() else: - raise ValueError( - "Invalid fit method. Choose 'independent' or 'simultaneous'." - ) + raise ValueError("Invalid fit method. Choose 'independent' or 'simultaneous'.") def plot_data_and_model( self, @@ -161,44 +161,42 @@ def plot_data_and_model( ) if self.experiment.binned_data is None: - raise ValueError("No data to plot. Please load data first.") + raise ValueError('No data to plot. Please load data first.') if not _in_notebook(): - raise RuntimeError( - "plot_data() can only be used in a Jupyter notebook environment." - ) + raise RuntimeError('plot_data() can only be used in a Jupyter notebook environment.') if self.Q is None: raise ValueError( - "No Q values available for plotting. Please check the experiment data." + 'No Q values available for plotting. Please check the experiment data.' ) if not isinstance(plot_components, bool): - raise TypeError("plot_components must be True or False.") + raise TypeError('plot_components must be True or False.') if not isinstance(add_background, bool): - raise TypeError("add_background must be True or False.") + raise TypeError('add_background must be True or False.') from IPython.display import display plot_kwargs_defaults = { - "title": self.display_name, - "linestyle": {"Data": "none", "Model": "-"}, - "marker": {"Data": "o", "Model": None}, - "color": {"Data": "black", "Model": "red"}, - "markerfacecolor": {"Data": "none", "Model": "none"}, + 'title': self.display_name, + 'linestyle': {'Data': 'none', 'Model': '-'}, + 'marker': {'Data': 'o', 'Model': None}, + 'color': {'Data': 'black', 'Model': 'red'}, + 'markerfacecolor': {'Data': 'none', 'Model': 'none'}, } data_and_model = { - "Data": self.experiment.binned_data, - "Model": self._create_model_array(), + 'Data': self.experiment.binned_data, + 'Model': self._create_model_array(), } if plot_components: components = self._create_components_dataset(add_background=add_background) for key in components.keys(): data_and_model[key] = components[key] - plot_kwargs_defaults["linestyle"][key] = "--" - plot_kwargs_defaults["marker"][key] = None + plot_kwargs_defaults['linestyle'][key] = '--' + plot_kwargs_defaults['marker'][key] = None # Overwrite defaults with any user-provided kwargs plot_kwargs_defaults.update(kwargs) @@ -210,12 +208,13 @@ def plot_data_and_model( display(fig) def parameters_to_dataset(self) -> sc.Dataset: - """ - Creates a scipp dataset with copies of the Parameters in the - model. Ensures unit consistency across Q. + """Creates a scipp dataset with copies of the Parameters in the + model. + + Ensures unit consistency across Q. """ - ds = sc.Dataset(coords={"Q": self.Q}) + ds = sc.Dataset(coords={'Q': self.Q}) # Collect all parameter names all_names = { @@ -245,7 +244,7 @@ def parameters_to_dataset(self) -> sc.Dataset: except Exception as e: raise UnitError( f"Inconsistent units for parameter '{name}': " - f"{units[name]} vs {p.unit}" + f'{units[name]} vs {p.unit}' ) from e values[name].append(p.value) @@ -257,7 +256,7 @@ def parameters_to_dataset(self) -> sc.Dataset: # Build dataset variables for name in all_names: ds[name] = sc.Variable( - dims=["Q"], + dims=['Q'], values=np.asarray(values[name], dtype=float), variances=np.asarray(variances[name], dtype=float), unit=units.get(name, None), @@ -270,8 +269,7 @@ def plot_parameters( names: str | list[str] | None = None, **kwargs, ) -> None: - """ - Plot fitted parameters as a function of Q. + """Plot fitted parameters as a function of Q. Parameters: --------------- @@ -293,10 +291,8 @@ def plot_parameters( if isinstance(names, str): names = [names] - if not isinstance(names, list) or not all( - isinstance(name, str) for name in names - ): - raise TypeError("names must be a string or a list of strings.") + if not isinstance(names, list) or not all(isinstance(name, str) for name in names): + raise TypeError('names must be a string or a list of strings.') for name in names: if name not in ds: @@ -304,9 +300,9 @@ def plot_parameters( data_to_plot = {name: ds[name] for name in names} plot_kwargs_defaults = { - "linestyle": {name: "none" for name in names}, - "marker": {name: "o" for name in names}, - "markerfacecolor": {name: "none" for name in names}, + 'linestyle': {name: 'none' for name in names}, + 'marker': {name: 'o' for name in names}, + 'markerfacecolor': {name: 'none' for name in names}, } plot_kwargs_defaults.update(kwargs) @@ -339,9 +335,9 @@ def _fit_all_Q_simultaneously(self) -> FitResults: ws = [] for analysis in self.analysis_list: - data = analysis.experiment.data["Q", analysis.Q_index] + data = analysis.experiment.data['Q', analysis.Q_index] - x = data.coords["energy"].values + x = data.coords['energy'].values y = data.values e = np.sqrt(data.variances) @@ -365,26 +361,24 @@ def _fit_all_Q_simultaneously(self) -> FitResults: return results def get_fit_functions(self) -> list[callable]: - """ - Get fit functions for all Q indices, which can be used for + """Get fit functions for all Q indices, which can be used for simultaneous fitting. """ return [analysis.as_fit_function() for analysis in self.analysis_list] def _create_model_array(self) -> sc.DataArray: - """Create a scipp array for the model""" + """Create a scipp array for the model.""" - model = sc.array(dims=["Q", "energy"], values=self.calculate()) + model = sc.array(dims=['Q', 'energy'], values=self.calculate()) model_data_array = sc.DataArray( data=model, - coords={"Q": self.Q, "energy": self.experiment.energy}, + coords={'Q': self.Q, 'energy': self.experiment.energy}, ) return model_data_array def _create_components_dataset(self, add_background: bool = True) -> sc.Dataset: - """ - Create a scipp dataset containing the individual components of - the model for plotting. + """Create a scipp dataset containing the individual components + of the model for plotting. Parameters: --------------- @@ -396,14 +390,14 @@ def _create_components_dataset(self, add_background: bool = True) -> sc.Dataset: the model, with dimensions "Q" and "energy". """ if not isinstance(add_background, bool): - raise TypeError("add_background must be True or False.") + raise TypeError('add_background must be True or False.') datasets = [ analysis._create_components_dataset_single_Q(add_background=add_background) for analysis in self.analysis_list ] - return sc.concat(datasets, dim="Q") + return sc.concat(datasets, dim='Q') ############# # Dunder methods diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index c4127960..aa5114ee 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -24,7 +24,7 @@ class Analysis1d(AnalysisBase): def __init__( self, - display_name: str = "MyAnalysis", + display_name: str = 'MyAnalysis', unique_name: str | None = None, experiment: Experiment | None = None, sample_model: SampleModel | None = None, @@ -70,8 +70,8 @@ def Q_index(self, value: int | None) -> None: ############# def calculate(self) -> np.ndarray: - """Calculate the model prediction for a given Q index. - Makes sure the convolver is up to date before calculating. + """Calculate the model prediction for a given Q index. Makes + sure the convolver is up to date before calculating. Returns: np.ndarray: The calculated model prediction. @@ -111,12 +111,12 @@ def fit(self) -> FitResults: parameter optimization for performance reasons. """ if self._experiment is None: - raise ValueError("No experiment is associated with this Analysis.") + raise ValueError('No experiment is associated with this Analysis.') Q_index = self._require_Q_index() - data = self.experiment.data["Q", Q_index] - x = data.coords["energy"].values + data = self.experiment.data['Q', Q_index] + x = data.coords['energy'].values y = data.values e = data.variances**0.5 @@ -135,8 +135,7 @@ def fit(self) -> FitResults: return fit_result def as_fit_function(self, x=None, **kwargs): - """ - Return self._calculate as a fit function. + """Return self._calculate as a fit function. The EasyScience fitter requires x as input, but self._calculate() already uses the correct energy from the @@ -187,37 +186,33 @@ def plot_data_and_model( import plopp as pp if self.experiment.data is None: - raise ValueError("No data to plot. Please load data first.") + raise ValueError('No data to plot. Please load data first.') - data = self.experiment.data["Q", self.Q_index] + data = self.experiment.data['Q', self.Q_index] model_array = self._create_sample_scipp_array() - component_dataset = self._create_components_dataset_single_Q( - add_background=add_background - ) + component_dataset = self._create_components_dataset_single_Q(add_background=add_background) # Create a dataset containing the data, model, and individual # components for plotting. - data_and_model = sc.Dataset( - { - "Data": data, - "Model": model_array, - } - ) + data_and_model = sc.Dataset({ + 'Data': data, + 'Model': model_array, + }) data_and_model = sc.merge(data_and_model, component_dataset) plot_kwargs_defaults = { - "title": self.display_name, - "linestyle": {"Data": "none", "Model": "-"}, - "marker": {"Data": "o", "Model": "none"}, - "color": {"Data": "black", "Model": "red"}, - "markerfacecolor": {"Data": "none", "Model": "none"}, + 'title': self.display_name, + 'linestyle': {'Data': 'none', 'Model': '-'}, + 'marker': {'Data': 'o', 'Model': 'none'}, + 'color': {'Data': 'black', 'Model': 'red'}, + 'markerfacecolor': {'Data': 'none', 'Model': 'none'}, } if plot_components: for comp_name in component_dataset.keys(): - plot_kwargs_defaults["linestyle"][comp_name] = "--" - plot_kwargs_defaults["marker"][comp_name] = None + plot_kwargs_defaults['linestyle'][comp_name] = '--' + plot_kwargs_defaults['marker'][comp_name] = None # Overwrite defaults with any user-provided kwargs plot_kwargs_defaults.update(kwargs) @@ -233,19 +228,18 @@ def plot_data_and_model( ############# def _require_Q_index(self) -> int: - """ - Get the Q index, ensuring it is set. - Raises a ValueError if the Q index is not set. + """Get the Q index, ensuring it is set. + + Raises a ValueError if the Q index is not set. Returns: int: The Q index. """ if self._Q_index is None: - raise ValueError("Q_index must be set.") + raise ValueError('Q_index must be set.') return self._Q_index def _on_Q_index_changed(self) -> None: - """ - Handle changes to the Q index. + """Handle changes to the Q index. This method is called whenever the Q index is changed. It updates the Convolution object for the new Q index. @@ -262,9 +256,9 @@ def _evaluate_components( convolver: Convolution | None = None, convolve: bool = True, ) -> np.ndarray: - """ - Calculate the contribution of a set of components, optionally + """Calculate the contribution of a set of components, optionally convolving with the resolution. + If convolve is True and a Convolution object is provided (for full model evaluation), we use it to perform the convolution of the components with the @@ -296,9 +290,7 @@ def _evaluate_components( if not convolve: return components.evaluate(energy - energy_offset) - resolution = self.instrument_model.resolution_model.get_component_collection( - Q_index - ) + resolution = self.instrument_model.resolution_model.get_component_collection(Q_index) if resolution.is_empty: return components.evaluate(energy - energy_offset) @@ -321,8 +313,7 @@ def _evaluate_components( return conv.convolution() def _evaluate_sample(self) -> np.ndarray: - """ - Evaluate the sample contribution for a given Q index. + """Evaluate the sample contribution for a given Q index. Assumes that self._convolver is up to date. @@ -341,8 +332,7 @@ def _evaluate_sample_component( self, component: ModelComponent, ) -> np.ndarray: - """ - Evaluate a single sample component for a given Q index. + """Evaluate a single sample component for a given Q index. Args: component: The sample component to evaluate. @@ -356,17 +346,14 @@ def _evaluate_sample_component( ) def _evaluate_background(self) -> np.ndarray: - """ - Evaluate the background contribution for a given Q index. + """Evaluate the background contribution for a given Q index. Returns: np.ndarray: The evaluated background contribution. """ Q_index = self._require_Q_index() - background_components = ( - self.instrument_model.background_model.get_component_collection( - Q_index=Q_index - ) + background_components = self.instrument_model.background_model.get_component_collection( + Q_index=Q_index ) return self._evaluate_components( components=background_components, @@ -378,8 +365,7 @@ def _evaluate_background_component( self, component: ModelComponent, ) -> np.ndarray: - """ - Evaluate a single background component for a given Q index. + """Evaluate a single background component for a given Q index. Args: component: The background component to evaluate. @@ -394,8 +380,7 @@ def _evaluate_background_component( ) def _create_convolver(self) -> Convolution | None: - """ - Initialize and return a Convolution object for the given Q + """Initialize and return a Convolution object for the given Q index. If the necessary components for convolution are not available, return None. @@ -409,8 +394,8 @@ def _create_convolver(self) -> Convolution | None: if sample_components.is_empty: return None - resolution_components = ( - self.instrument_model.resolution_model.get_component_collection(Q_index) + resolution_components = self.instrument_model.resolution_model.get_component_collection( + Q_index ) if resolution_components.is_empty: return None @@ -454,31 +439,29 @@ def _create_components_dataset_single_Q( self, add_background: bool = True ) -> dict[str, sc.DataArray]: """Create sc.DataArrays for all sample and background - components.""" + components. + """ scipp_arrays = {} sample_components = self.sample_model.get_component_collection( Q_index=self.Q_index ).components - background_components = ( - self.instrument_model.background_model.get_component_collection( - Q_index=self.Q_index - ).components - ) + background_components = self.instrument_model.background_model.get_component_collection( + Q_index=self.Q_index + ).components background = self._evaluate_background() if add_background else None for component in sample_components: scipp_arrays[component.display_name] = self._create_component_scipp_array( component, background=background ) for component in background_components: - scipp_arrays[component.display_name] = ( - self._create_background_component_scipp_array(component) + scipp_arrays[component.display_name] = self._create_background_component_scipp_array( + component ) return sc.Dataset(scipp_arrays) def _to_scipp_array(self, values: np.ndarray) -> sc.DataArray: - """ - Convert a numpy array of values to a sc.DataArray with the + """Convert a numpy array of values to a sc.DataArray with the correct coordinates for energy and Q. Args: @@ -487,9 +470,9 @@ def _to_scipp_array(self, values: np.ndarray) -> sc.DataArray: sc.DataArray: The converted sc.DataArray. """ return sc.DataArray( - data=sc.array(dims=["energy"], values=values), + data=sc.array(dims=['energy'], values=values), coords={ - "energy": self.energy, - "Q": self.Q[self.Q_index], + 'energy': self.energy, + 'Q': self.Q[self.Q_index], }, ) diff --git a/src/easydynamics/analysis/analysis_base.py b/src/easydynamics/analysis/analysis_base.py index e6f63939..f855e5da 100644 --- a/src/easydynamics/analysis/analysis_base.py +++ b/src/easydynamics/analysis/analysis_base.py @@ -16,7 +16,7 @@ class AnalysisBase(EasyScienceModelBase): def __init__( self, - display_name: str = "MyAnalysis", + display_name: str = 'MyAnalysis', unique_name: str | None = None, experiment: Experiment | None = None, sample_model: SampleModel | None = None, @@ -30,23 +30,21 @@ def __init__( elif isinstance(experiment, Experiment): self._experiment = experiment else: - raise TypeError("experiment must be an instance of Experiment or None.") + raise TypeError('experiment must be an instance of Experiment or None.') if sample_model is None: self._sample_model = SampleModel() elif isinstance(sample_model, SampleModel): self._sample_model = sample_model else: - raise TypeError("sample_model must be an instance of SampleModel or None.") + raise TypeError('sample_model must be an instance of SampleModel or None.') if instrument_model is None: self._instrument_model = InstrumentModel() elif isinstance(instrument_model, InstrumentModel): self._instrument_model = instrument_model else: - raise TypeError( - "instrument_model must be an instance of InstrumentModel or None." - ) + raise TypeError('instrument_model must be an instance of InstrumentModel or None.') if extra_parameters is not None: if isinstance(extra_parameters, Parameter): @@ -56,9 +54,7 @@ def __init__( ): self._extra_parameters = extra_parameters else: - raise TypeError( - "extra_parameters must be a Parameter or a list of Parameters." - ) + raise TypeError('extra_parameters must be a Parameter or a list of Parameters.') else: self._extra_parameters = [] @@ -76,7 +72,7 @@ def experiment(self) -> Experiment | None: @experiment.setter def experiment(self, value: Experiment) -> None: if not isinstance(value, Experiment): - raise TypeError("experiment must be an instance of Experiment") + raise TypeError('experiment must be an instance of Experiment') self._experiment = value self._on_experiment_changed() @@ -88,7 +84,7 @@ def sample_model(self) -> SampleModel: @sample_model.setter def sample_model(self, value: SampleModel) -> None: if not isinstance(value, SampleModel): - raise TypeError("sample_model must be an instance of SampleModel") + raise TypeError('sample_model must be an instance of SampleModel') self._sample_model = value self._on_sample_model_changed() @@ -100,7 +96,7 @@ def instrument_model(self) -> InstrumentModel: @instrument_model.setter def instrument_model(self, value: InstrumentModel) -> None: if not isinstance(value, InstrumentModel): - raise TypeError("instrument_model must be an instance of InstrumentModel") + raise TypeError('instrument_model must be an instance of InstrumentModel') self._instrument_model = value self._on_instrument_model_changed() @@ -112,7 +108,7 @@ def Q(self) -> sc.Variable | None: @Q.setter def Q(self, value) -> None: """Q is a read-only property derived from the Experiment.""" - raise AttributeError("Q is a read-only property derived from the Experiment.") + raise AttributeError('Q is a read-only property derived from the Experiment.') @property def energy(self) -> sc.Variable | None: @@ -126,26 +122,21 @@ def energy(self, value) -> None: """Energy is a read-only property derived from the Experiment. """ - raise AttributeError( - "energy is a read-only property derived from the Experiment." - ) + raise AttributeError('energy is a read-only property derived from the Experiment.') @property def temperature(self) -> Parameter | None: - """ - The temperature from the associated SampleModel, if available. + """The temperature from the associated SampleModel, if + available. """ return self.sample_model.temperature if self.sample_model is not None else None @temperature.setter def temperature(self, value) -> None: - """ - Temperature is a read-only property derived from the + """Temperature is a read-only property derived from the SampleModel. """ - raise AttributeError( - "temperature is a read-only property derived from the sample model." - ) + raise AttributeError('temperature is a read-only property derived from the sample model.') ############# # Other methods @@ -156,30 +147,26 @@ def temperature(self, value) -> None: ############# def _on_experiment_changed(self) -> None: - """ - Update the Q values in the sample and instrument models when the - experiment changes. + """Update the Q values in the sample and instrument models when + the experiment changes. """ self._sample_model.Q = self.Q self._instrument_model.Q = self.Q def _on_sample_model_changed(self) -> None: - """ - Update the Q values in the sample model when the sample model + """Update the Q values in the sample model when the sample model changes. """ self._sample_model.Q = self.Q def _on_instrument_model_changed(self) -> None: - """ - Update the Q values in the instrument model when the instrument - model changes. + """Update the Q values in the instrument model when the + instrument model changes. """ self._instrument_model.Q = self.Q def _verify_Q_index(self, Q_index: int | None) -> int | None: - """ - Verify that the Q index is valid. + """Verify that the Q index is valid. Params: Q_index (int | None): The Q index to verify. @@ -194,7 +181,7 @@ def _verify_Q_index(self, Q_index: int | None) -> int | None: or Q_index < 0 or (self.Q is not None and Q_index >= len(self.Q)) ): - raise ValueError("Q_index must be a valid index for the Q values.") + raise ValueError('Q_index must be a valid index for the Q values.') return Q_index ############# @@ -202,4 +189,4 @@ def _verify_Q_index(self, Q_index: int | None) -> int | None: ############# def __repr__(self) -> str: - return f"AnalysisBase(display_name={self.display_name}, unique_name={self.unique_name})" + return f'AnalysisBase(display_name={self.display_name}, unique_name={self.unique_name})' diff --git a/src/easydynamics/convolution/analytical_convolution.py b/src/easydynamics/convolution/analytical_convolution.py index c24a50f2..6583f1e5 100644 --- a/src/easydynamics/convolution/analytical_convolution.py +++ b/src/easydynamics/convolution/analytical_convolution.py @@ -35,18 +35,18 @@ class AnalyticalConvolution(ConvolutionBase): # Mapping of supported component type pairs to convolution methods. # Delta functions are handled separately. _CONVOLUTIONS = { - ("Gaussian", "Gaussian"): "_convolute_gaussian_gaussian", - ("Gaussian", "Lorentzian"): "_convolute_gaussian_lorentzian", - ("Gaussian", "Voigt"): "_convolute_gaussian_voigt", - ("Lorentzian", "Lorentzian"): "_convolute_lorentzian_lorentzian", - ("Lorentzian", "Voigt"): "_convolute_lorentzian_voigt", - ("Voigt", "Voigt"): "_convolute_voigt_voigt", + ('Gaussian', 'Gaussian'): '_convolute_gaussian_gaussian', + ('Gaussian', 'Lorentzian'): '_convolute_gaussian_lorentzian', + ('Gaussian', 'Voigt'): '_convolute_gaussian_voigt', + ('Lorentzian', 'Lorentzian'): '_convolute_lorentzian_lorentzian', + ('Lorentzian', 'Voigt'): '_convolute_lorentzian_voigt', + ('Voigt', 'Voigt'): '_convolute_voigt_voigt', } def __init__( self, energy: np.ndarray | sc.Variable, - energy_unit: str | sc.Unit = "meV", + energy_unit: str | sc.Unit = 'meV', sample_components: ComponentCollection | ModelComponent | None = None, resolution_components: ComponentCollection | ModelComponent | None = None, energy_offset: Numeric | Parameter = 0.0, @@ -144,8 +144,8 @@ def _convolute_analytic_pair( if isinstance(resolution_component, DeltaFunction): raise ValueError( - "Analytical convolution with a delta function \ - in the resolution model is not supported." + 'Analytical convolution with a delta function \ + in the resolution model is not supported.' ) # Delta function + anything --> @@ -171,8 +171,8 @@ def _convolute_analytic_pair( if func_name is None: raise ValueError( - f"Analytical convolution not supported for component pair: " - f"{type(sample_component).__name__}, {type(resolution_component).__name__}" + f'Analytical convolution not supported for component pair: ' + f'{type(sample_component).__name__}, {type(resolution_component).__name__}' ) # Call the corresponding method @@ -225,9 +225,7 @@ def _convolute_gaussian_gaussian( The evaluated convolution values at self.energy. """ - width = np.sqrt( - sample_component.width.value**2 + resolution_component.width.value**2 - ) + width = np.sqrt(sample_component.width.value**2 + resolution_component.width.value**2) area = sample_component.area.value * resolution_component.area.value @@ -288,8 +286,7 @@ def _convolute_gaussian_voigt( center = sample_component.center.value + resolution_component.center.value gaussian_width = np.sqrt( - sample_component.width.value**2 - + resolution_component.gaussian_width.value**2 + sample_component.width.value**2 + resolution_component.gaussian_width.value**2 ) lorentzian_width = resolution_component.lorentzian_width.value @@ -389,13 +386,11 @@ def _convolute_voigt_voigt( center = sample_component.center.value + resolution_component.center.value gaussian_width = np.sqrt( - sample_component.gaussian_width.value**2 - + resolution_component.gaussian_width.value**2 + sample_component.gaussian_width.value**2 + resolution_component.gaussian_width.value**2 ) lorentzian_width = ( - sample_component.lorentzian_width.value - + resolution_component.lorentzian_width.value + sample_component.lorentzian_width.value + resolution_component.lorentzian_width.value ) return self._voigt_eval( area=area, diff --git a/src/easydynamics/convolution/convolution.py b/src/easydynamics/convolution/convolution.py index 827087c1..b4fa19e3 100644 --- a/src/easydynamics/convolution/convolution.py +++ b/src/easydynamics/convolution/convolution.py @@ -59,16 +59,16 @@ class Convolution(NumericalConvolutionBase): # When these attributes are changed, the convolution plan # needs to be rebuilt _invalidate_plan_on_change = { - "energy", - "_energy", - "_energy_grid", - "_sample_components", - "_resolution_components", - "_temperature", - "_upsample_factor", - "_extension_factor", - "_energy_unit", - "_normalize_detailed_balance", + 'energy', + '_energy', + '_energy_grid', + '_sample_components', + '_resolution_components', + '_temperature', + '_upsample_factor', + '_extension_factor', + '_energy_unit', + '_normalize_detailed_balance', } def __init__( @@ -80,8 +80,8 @@ def __init__( upsample_factor: Numeric = 5, extension_factor: Numeric = 0.2, temperature: Parameter | Numeric | None = None, - temperature_unit: str | sc.Unit = "K", - energy_unit: str | sc.Unit = "meV", + temperature_unit: str | sc.Unit = 'K', + energy_unit: str | sc.Unit = 'meV', normalize_detailed_balance: bool = True, ): self._convolution_plan_is_valid = False @@ -137,8 +137,8 @@ def convolution( def _convolve_delta_functions(self) -> np.ndarray: "Convolve delta function components of the sample model with" - "the resolution components." - "No detailed balance correction is applied to delta functions." + 'the resolution components.' + 'No detailed balance correction is applied to delta functions.' return sum( delta.area.value * self._resolution_components.evaluate( @@ -168,19 +168,19 @@ def _check_if_pair_is_analytic( if not isinstance(sample_component, ModelComponent): raise TypeError( - f"`sample_component` is an instance of {type(sample_component).__name__}, \ - but must be a ModelComponent." + f'`sample_component` is an instance of {type(sample_component).__name__}, \ + but must be a ModelComponent.' ) if not isinstance(resolution_component, ModelComponent): raise TypeError( - f"`resolution_component` is an instance of {type(resolution_component).__name__}, \ - but must be a ModelComponent." + f'`resolution_component` is an instance of {type(resolution_component).__name__}, \ + but must be a ModelComponent.' ) if isinstance(resolution_component, DeltaFunction): raise TypeError( - "resolution components contains delta functions. This is not supported." + 'resolution components contains delta functions. This is not supported.' ) analytical_types = (Gaussian, Lorentzian, Voigt) @@ -219,9 +219,7 @@ def _build_convolution_plan(self) -> None: pair_is_analytic = [] for resolution_component in self._resolution_components.components: pair_is_analytic.append( - self._check_if_pair_is_analytic( - sample_component, resolution_component - ) + self._check_if_pair_is_analytic(sample_component, resolution_component) ) # If all resolution components can be convolved analytically # with this sample component, add it to analytical @@ -285,8 +283,5 @@ def __setattr__(self, name, value): if name in self._invalidate_plan_on_change: self._convolution_plan_is_valid = False - if ( - getattr(self, "_reactions_enabled", False) - and name in self._invalidate_plan_on_change - ): + if getattr(self, '_reactions_enabled', False) and name in self._invalidate_plan_on_change: self._build_convolution_plan() diff --git a/src/easydynamics/convolution/convolution_base.py b/src/easydynamics/convolution/convolution_base.py index be5cff06..3a7e753e 100644 --- a/src/easydynamics/convolution/convolution_base.py +++ b/src/easydynamics/convolution/convolution_base.py @@ -30,28 +30,28 @@ def __init__( energy: np.ndarray | sc.Variable, sample_components: ComponentCollection | ModelComponent = None, resolution_components: ComponentCollection | ModelComponent = None, - energy_unit: str | sc.Unit = "meV", + energy_unit: str | sc.Unit = 'meV', energy_offset: Numeric | Parameter = 0.0, ): if isinstance(energy, Numeric): energy = np.array([float(energy)]) if not isinstance(energy, (np.ndarray, sc.Variable)): - raise TypeError("Energy must be a numpy ndarray or a scipp Variable.") + raise TypeError('Energy must be a numpy ndarray or a scipp Variable.') if not isinstance(energy_unit, (str, sc.Unit)): - raise TypeError("Energy_unit must be a string or sc.Unit.") + raise TypeError('Energy_unit must be a string or sc.Unit.') if isinstance(energy, np.ndarray): - energy = sc.array(dims=["energy"], values=energy, unit=energy_unit) + energy = sc.array(dims=['energy'], values=energy, unit=energy_unit) if isinstance(energy_offset, Numeric): energy_offset = Parameter( - name="energy_offset", value=float(energy_offset), unit=energy_unit + name='energy_offset', value=float(energy_offset), unit=energy_unit ) if not isinstance(energy_offset, Parameter): - raise TypeError("Energy_offset must be a number or a Parameter.") + raise TypeError('Energy_offset must be a number or a Parameter.') self._energy = energy self._energy_unit = energy_unit @@ -62,7 +62,7 @@ def __init__( or isinstance(sample_components, ModelComponent) ): raise TypeError( - f"`sample_components` is an instance of {type(sample_components).__name__}, but must be a ComponentCollection or ModelComponent." # noqa: E501 + f'`sample_components` is an instance of {type(sample_components).__name__}, but must be a ComponentCollection or ModelComponent.' # noqa: E501 ) if isinstance(sample_components, ModelComponent): sample_components = ComponentCollection(components=[sample_components]) @@ -73,12 +73,10 @@ def __init__( or isinstance(resolution_components, ModelComponent) ): raise TypeError( - f"`resolution_components` is an instance of {type(resolution_components).__name__}, but must be a ComponentCollection or ModelComponent." # noqa: E501 + f'`resolution_components` is an instance of {type(resolution_components).__name__}, but must be a ComponentCollection or ModelComponent.' # noqa: E501 ) if isinstance(resolution_components, ModelComponent): - resolution_components = ComponentCollection( - components=[resolution_components] - ) + resolution_components = ComponentCollection(components=[resolution_components]) if isinstance(resolution_components, ModelComponent): resolution_components = ComponentCollection(components=[resolution_components]) self._resolution_components = resolution_components @@ -99,7 +97,7 @@ def energy_offset(self, energy_offset: Numeric | Parameter) -> None: TypeError: If energy_offset is not a number or a Parameter. """ if not isinstance(energy_offset, Parameter | Numeric): - raise TypeError("Energy_offset must be a number or a Parameter.") + raise TypeError('Energy_offset must be a number or a Parameter.') if isinstance(energy_offset, Numeric): self._energy_offset.value = float(energy_offset) @@ -117,9 +115,10 @@ def energy_with_offset(self) -> sc.Variable: @energy_with_offset.setter def energy_with_offset(self, value) -> None: """Energy with offset is a read-only property derived from - energy and energy_offset.""" + energy and energy_offset. + """ raise AttributeError( - "Energy with offset is a read-only property derived from energy and energy_offset." + 'Energy with offset is a read-only property derived from energy and energy_offset.' ) @property @@ -145,14 +144,10 @@ def energy(self, energy: np.ndarray) -> None: energy = np.array([float(energy)]) if not isinstance(energy, (np.ndarray, sc.Variable)): - raise TypeError( - "Energy must be a Number, a numpy ndarray or a scipp Variable." - ) + raise TypeError('Energy must be a Number, a numpy ndarray or a scipp Variable.') if isinstance(energy, np.ndarray): - self._energy = sc.array( - dims=["energy"], values=energy, unit=self._energy.unit - ) + self._energy = sc.array(dims=['energy'], values=energy, unit=self._energy.unit) if isinstance(energy, sc.Variable): self._energy = energy @@ -167,8 +162,8 @@ def energy_unit(self) -> str: def energy_unit(self, unit_str: str) -> None: raise AttributeError( ( - f"Unit is read-only. Use convert_unit to change the unit between allowed types " - f"or create a new {self.__class__.__name__} with the desired unit." + f'Unit is read-only. Use convert_unit to change the unit between allowed types ' + f'or create a new {self.__class__.__name__} with the desired unit.' ) ) # noqa: E501 @@ -182,7 +177,7 @@ def convert_energy_unit(self, energy_unit: str | sc.Unit) -> None: TypeError: If energy_unit is not a string or scipp unit. """ if not isinstance(energy_unit, (str, sc.Unit)): - raise TypeError("Energy unit must be a string or scipp unit.") + raise TypeError('Energy unit must be a string or scipp unit.') self.energy = sc.to_unit(self.energy, energy_unit) self._energy_unit = energy_unit @@ -193,9 +188,7 @@ def sample_components(self) -> ComponentCollection | ModelComponent: return self._sample_components @sample_components.setter - def sample_components( - self, sample_components: ComponentCollection | ModelComponent - ) -> None: + def sample_components(self, sample_components: ComponentCollection | ModelComponent) -> None: """Set the sample model. Args: sample_components : ComponentCollection or ModelComponent @@ -207,7 +200,7 @@ def sample_components( """ if not isinstance(sample_components, (ComponentCollection, ModelComponent)): raise TypeError( - f"`sample_components` is an instance of {type(sample_components).__name__}, but must be a ComponentCollection or ModelComponent." # noqa: E501 + f'`sample_components` is an instance of {type(sample_components).__name__}, but must be a ComponentCollection or ModelComponent.' # noqa: E501 ) self._sample_components = sample_components @@ -232,6 +225,6 @@ def resolution_components( """ if not isinstance(resolution_components, (ComponentCollection, ModelComponent)): raise TypeError( - f"`resolution_components` is an instance of {type(resolution_components).__name__}, but must be a ComponentCollection or ModelComponent." # noqa: E501 + f'`resolution_components` is an instance of {type(resolution_components).__name__}, but must be a ComponentCollection or ModelComponent.' # noqa: E501 ) self._resolution_components = resolution_components diff --git a/src/easydynamics/convolution/numerical_convolution.py b/src/easydynamics/convolution/numerical_convolution.py index 95d75917..1b8ca6d1 100644 --- a/src/easydynamics/convolution/numerical_convolution.py +++ b/src/easydynamics/convolution/numerical_convolution.py @@ -9,9 +9,7 @@ from easydynamics.convolution.numerical_convolution_base import NumericalConvolutionBase from easydynamics.sample_model.component_collection import ComponentCollection from easydynamics.sample_model.components.model_component import ModelComponent -from easydynamics.utils.detailed_balance import ( - _detailed_balance_factor as detailed_balance_factor, -) +from easydynamics.utils.detailed_balance import _detailed_balance_factor as detailed_balance_factor from easydynamics.utils.utils import Numeric @@ -58,8 +56,8 @@ def __init__( upsample_factor: Numeric = 5, extension_factor: Numeric = 0.2, temperature: Parameter | Numeric | None = None, - temperature_unit: str | sc.Unit = "K", - energy_unit: str | sc.Unit = "meV", + temperature_unit: str | sc.Unit = 'K', + energy_unit: str | sc.Unit = 'meV', normalize_detailed_balance: bool = True, ): super().__init__( @@ -90,11 +88,11 @@ def convolution( # Give warnings if peaks are very wide or very narrow self._check_width_thresholds( model=self.sample_components, - model_name="sample model", + model_name='sample model', ) self._check_width_thresholds( model=self.resolution_components, - model_name="resolution model", + model_name='resolution model', ) # Evaluate sample model. If called via the Convolution class, @@ -121,7 +119,7 @@ def convolution( ) # Convolution - convolved = fftconvolve(sample_vals, resolution_vals, mode="same") + convolved = fftconvolve(sample_vals, resolution_vals, mode='same') convolved *= self._energy_grid.energy_dense_step # normalize if self.upsample_factor is not None: diff --git a/src/easydynamics/convolution/numerical_convolution_base.py b/src/easydynamics/convolution/numerical_convolution_base.py index ba40f456..5a60f5d8 100644 --- a/src/easydynamics/convolution/numerical_convolution_base.py +++ b/src/easydynamics/convolution/numerical_convolution_base.py @@ -67,8 +67,8 @@ def __init__( upsample_factor: Numerical = 5, extension_factor: float = 0.2, temperature: Parameter | float | None = None, - temperature_unit: str | sc.Unit = "K", - energy_unit: str | sc.Unit = "meV", + temperature_unit: str | sc.Unit = 'K', + energy_unit: str | sc.Unit = 'meV', normalize_detailed_balance: bool = True, ): super().__init__( @@ -79,13 +79,11 @@ def __init__( energy_offset=energy_offset, ) - if temperature is not None and not isinstance( - temperature, (Numerical, Parameter) - ): - raise TypeError("Temperature must be None, a number or a Parameter.") + if temperature is not None and not isinstance(temperature, (Numerical, Parameter)): + raise TypeError('Temperature must be None, a number or a Parameter.') if not isinstance(temperature_unit, (str, sc.Unit)): - raise TypeError("Temperature_unit must be a string or sc.Unit.") + raise TypeError('Temperature_unit must be a string or sc.Unit.') self._temperature_unit = temperature_unit self._temperature = None self.temperature = temperature @@ -121,10 +119,10 @@ def upsample_factor(self, factor: Numerical) -> None: return if not isinstance(factor, Numerical): - raise TypeError("Upsample factor must be a numerical value or None.") + raise TypeError('Upsample factor must be a numerical value or None.') factor = float(factor) if factor <= 1.0: - raise ValueError("Upsample factor must be greater than 1.") + raise ValueError('Upsample factor must be greater than 1.') self._upsample_factor = factor @@ -160,9 +158,9 @@ def extension_factor(self, factor: Numerical) -> None: TypeError: If factor is not a number. """ if not isinstance(factor, Numerical): - raise TypeError("Extension factor must be a number.") + raise TypeError('Extension factor must be a number.') if factor < 0.0: - raise ValueError("Extension factor must be non-negative.") + raise ValueError('Extension factor must be non-negative.') self._extension_factor = factor # Recreate dense grid when extension factor is updated @@ -196,7 +194,7 @@ def temperature(self, temp: Parameter | float | None) -> None: self._temperature.value = float(temp) else: self._temperature = Parameter( - name="temperature", + name='temperature', value=float(temp), unit=self._temperature_unit, fixed=True, @@ -204,7 +202,7 @@ def temperature(self, temp: Parameter | float | None) -> None: elif isinstance(temp, Parameter): self._temperature = temp else: - raise TypeError("Temperature must be None, a float or a Parameter.") + raise TypeError('Temperature must be None, a float or a Parameter.') @property def normalize_detailed_balance(self) -> bool: @@ -225,7 +223,7 @@ def normalize_detailed_balance(self, normalize: bool) -> None: """ if not isinstance(normalize, bool): - raise TypeError("normalize_detailed_balance must be True or False.") + raise TypeError('normalize_detailed_balance must be True or False.') self._normalize_detailed_balance = normalize @@ -263,7 +261,7 @@ def _create_energy_grid( is_uniform = np.allclose(energy_diff, energy_diff[0]) if not is_uniform: raise ValueError( - "Input array `energy` must be uniformly spaced if upsample_factor is not given." # noqa: E501 + 'Input array `energy` must be uniformly spaced if upsample_factor is not given.' # noqa: E501 ) energy_dense = self.energy.values @@ -280,7 +278,7 @@ def _create_energy_grid( energy_span_dense = extended_max - extended_min if len(energy_dense) < 2: - raise ValueError("Energy array must have at least two points.") + raise ValueError('Energy array must have at least two points.') energy_dense_step = energy_dense[1] - energy_dense[0] # Handle offset for even length of energy_dense in convolution. @@ -350,41 +348,35 @@ def _check_width_thresholds( components = [model] # Treat single ModelComponent as a list for comp in components: - if hasattr(comp, "width"): - if ( - comp.width.value - > LARGE_WIDTH_THRESHOLD * self._energy_grid.energy_span_dense - ): + if hasattr(comp, 'width'): + if comp.width.value > LARGE_WIDTH_THRESHOLD * self._energy_grid.energy_span_dense: warnings.warn( f"The width of the {model_name} component '{comp.unique_name}' \ ({comp.width.value}) is large compared to the span of the input " - f"array ({self._energy_grid.energy_span_dense}). \ + f'array ({self._energy_grid.energy_span_dense}). \ This may lead to inaccuracies in the convolution. \ - Increase extension_factor to improve accuracy.", + Increase extension_factor to improve accuracy.', UserWarning, ) - if ( - comp.width.value - < SMALL_WIDTH_THRESHOLD * self._energy_grid.energy_dense_step - ): + if comp.width.value < SMALL_WIDTH_THRESHOLD * self._energy_grid.energy_dense_step: warnings.warn( f"The width of the {model_name} component '{comp.unique_name}' \ ({comp.width.value}) is small compared to the spacing of the input " - f"array ({self._energy_grid.energy_dense_step}). \ + f'array ({self._energy_grid.energy_dense_step}). \ This may lead to inaccuracies in the convolution. \ - Increase upsample_factor to improve accuracy.", + Increase upsample_factor to improve accuracy.', UserWarning, ) def __repr__(self) -> str: return ( - f"{self.__class__.__name__}(" - f"energy=array of shape {self.energy.values.shape},\n " - f"sample_components={repr(self.sample_components)}, \n" - f"resolution_components={repr(self.resolution_components)},\n " - f"energy_unit={self._energy_unit}, " - f"upsample_factor={self.upsample_factor}, " - f"extension_factor={self.extension_factor}, " - f"temperature={self.temperature}, " - f"normalize_detailed_balance={self.normalize_detailed_balance})" + f'{self.__class__.__name__}(' + f'energy=array of shape {self.energy.values.shape},\n ' + f'sample_components={repr(self.sample_components)}, \n' + f'resolution_components={repr(self.resolution_components)},\n ' + f'energy_unit={self._energy_unit}, ' + f'upsample_factor={self.upsample_factor}, ' + f'extension_factor={self.extension_factor}, ' + f'temperature={self.temperature}, ' + f'normalize_detailed_balance={self.normalize_detailed_balance})' ) diff --git a/src/easydynamics/experiment/experiment.py b/src/easydynamics/experiment/experiment.py index 771656b0..ddc5ac5d 100644 --- a/src/easydynamics/experiment/experiment.py +++ b/src/easydynamics/experiment/experiment.py @@ -19,7 +19,7 @@ class Experiment(NewBase): def __init__( self, - display_name: str = "MyExperiment", + display_name: str = 'MyExperiment', unique_name: str | None = None, data: sc.DataArray | str | None = None, ): @@ -37,7 +37,7 @@ def __init__( self._data = data else: raise TypeError( - f"Data must be a sc.DataArray or a filename string, not {type(data).__name__}" + f'Data must be a sc.DataArray or a filename string, not {type(data).__name__}' ) self._binned_data = ( @@ -57,7 +57,7 @@ def data(self) -> sc.DataArray | None: def data(self, value: sc.DataArray): """Set the dataset associated with this experiment.""" if not isinstance(value, sc.DataArray): - raise TypeError(f"Data must be a sc.DataArray, not {type(value).__name__}") + raise TypeError(f'Data must be a sc.DataArray, not {type(value).__name__}') self._validate_coordinates(value) self._data = value self._binned_data = ( @@ -72,9 +72,7 @@ def binned_data(self) -> sc.DataArray | None: @binned_data.setter def binned_data(self, value: sc.DataArray): """Set the binned dataset associated with this experiment.""" - raise AttributeError( - "binned_data is a read-only property. Use rebin() to rebin the data" - ) + raise AttributeError('binned_data is a read-only property. Use rebin() to rebin the data') @property def Q(self) -> sc.Variable | None: @@ -82,12 +80,12 @@ def Q(self) -> sc.Variable | None: if self._data is None: # warnings.warn("No data loaded.", UserWarning) return None - return self._binned_data.coords["Q"] + return self._binned_data.coords['Q'] @Q.setter def Q(self, value: sc.Variable): """Set the Q values for the dataset.""" - raise AttributeError("Q is a read-only property derived from the data.") + raise AttributeError('Q is a read-only property derived from the data.') @property def energy(self) -> sc.Variable: @@ -95,12 +93,12 @@ def energy(self) -> sc.Variable: if self._data is None: # warnings.warn("No data loaded.", UserWarning) return None - return self._binned_data.coords["energy"] + return self._binned_data.coords['energy'] @energy.setter def energy(self, value: sc.Variable): """Set the energy values for the dataset.""" - raise AttributeError("energy is a read-only property derived from the data.") + raise AttributeError('energy is a read-only property derived from the data.') ########### # Handle data @@ -115,19 +113,19 @@ def load_hdf5(self, filename: str, display_name: str | None = None): experiment. """ if not isinstance(filename, str): - raise TypeError(f"Filename must be a string, not {type(filename).__name__}") + raise TypeError(f'Filename must be a string, not {type(filename).__name__}') if display_name is not None: if not isinstance(display_name, str): raise TypeError( - f"Display name must be a string, not {type(display_name).__name__}" + f'Display name must be a string, not {type(display_name).__name__}' ) self.display_name = display_name loaded_data = sc_load_hdf5(filename) if not isinstance(loaded_data, sc.DataArray): raise TypeError( - f"Loaded data must be a sc.DataArray, not {type(loaded_data).__name__}" + f'Loaded data must be a sc.DataArray, not {type(loaded_data).__name__}' ) self._validate_coordinates(loaded_data) self.data = loaded_data @@ -140,13 +138,13 @@ def save_hdf5(self, filename: str | None = None): """ if filename is None: - filename = f"{self.unique_name}.h5" + filename = f'{self.unique_name}.h5' if not isinstance(filename, str): - raise TypeError(f"Filename must be a string, not {type(filename).__name__}") + raise TypeError(f'Filename must be a string, not {type(filename).__name__}') if self._data is None: - raise ValueError("No data to save.") + raise ValueError('No data to save.') dir_name = os.path.dirname(filename) if dir_name: @@ -174,33 +172,31 @@ def rebin(self, dimensions: dict[str, int | sc.Variable]) -> None: if not isinstance(dimensions, dict): raise TypeError( - "dimensions must be a dictionary mapping dimension names " - "to number of bins or bin values as sc.Variable." + 'dimensions must be a dictionary mapping dimension names ' + 'to number of bins or bin values as sc.Variable.' ) if self._data is None: - raise ValueError("No data to rebin. Please load data first.") + raise ValueError('No data to rebin. Please load data first.') binned_data = self._data.copy() dim_copy = dimensions.copy() for dim, value in dim_copy.items(): if not isinstance(dim, str): raise TypeError( - f"Dimension keys must be strings. Got {type(dim)} for {dim} instead." + f'Dimension keys must be strings. Got {type(dim)} for {dim} instead.' ) if dim not in self._data.dims: raise KeyError( f"Dimension '{dim}' not a valid dimension for rebinning. " - f"Should be one of {self._data.dims}." + f'Should be one of {self._data.dims}.' ) - if ( - isinstance(value, float) and value.is_integer() - ): # I allow eg. 2.0 as well as 2 + if isinstance(value, float) and value.is_integer(): # I allow eg. 2.0 as well as 2 value = int(value) # This line can be removed when scipp resize support # resizing with coordinates dimensions[dim] = value if not (isinstance(value, int) or isinstance(value, sc.Variable)): raise TypeError( - f"Dimension values must be integers or sc.Variable. " + f'Dimension values must be integers or sc.Variable. ' f"Got {type(value)} for dimension '{dim}' instead." ) binned_data = binned_data.bin({dim: value}) @@ -217,17 +213,15 @@ def plot_data(self, slicer=False, **kwargs) -> None: """Plot the dataset using plopp.""" if self._binned_data is None: - raise ValueError("No data to plot. Please load data first.") + raise ValueError('No data to plot. Please load data first.') if not _in_notebook(): - raise RuntimeError( - "plot_data() can only be used in a Jupyter notebook environment." - ) + raise RuntimeError('plot_data() can only be used in a Jupyter notebook environment.') from IPython.display import display plot_kwargs_defaults = { - "title": self.display_name, + 'title': self.display_name, } # Overwrite defaults with any user-provided kwargs plot_kwargs_defaults.update(kwargs) @@ -238,7 +232,7 @@ def plot_data(self, slicer=False, **kwargs) -> None: ) else: fig = pp.plot( - self._binned_data.transpose(dims=["energy", "Q"]), + self._binned_data.transpose(dims=['energy', 'Q']), **plot_kwargs_defaults, ) display(fig) @@ -255,9 +249,9 @@ def _validate_coordinates(data: sc.DataArray) -> None: ValueError: If required coordinates are missing. """ if not isinstance(data, sc.DataArray): - raise TypeError("Data must be a sc.DataArray.") + raise TypeError('Data must be a sc.DataArray.') - required_coords = ["Q", "energy"] + required_coords = ['Q', 'energy'] for coord in required_coords: if coord not in data.coords: raise ValueError(f"Data is missing required coordinate: '{coord}'") @@ -283,11 +277,11 @@ def _convert_to_bin_centers(self, data: sc.DataArray) -> sc.DataArray: ########### def __repr__(self) -> str: - return f"Experiment `{self.unique_name}` with data: {self._data}" + return f'Experiment `{self.unique_name}` with data: {self._data}' - def __copy__(self) -> "Experiment": + def __copy__(self) -> 'Experiment': """Return a copy of the object.""" - temp = self.to_dict(skip=["unique_name"]) + temp = self.to_dict(skip=['unique_name']) new_obj = self.__class__.from_dict(temp) new_obj.data = self.data.copy() if self.data is not None else None return new_obj diff --git a/src/easydynamics/sample_model/__init__.py b/src/easydynamics/sample_model/__init__.py index 443c1982..1f1602aa 100644 --- a/src/easydynamics/sample_model/__init__.py +++ b/src/easydynamics/sample_model/__init__.py @@ -9,24 +9,22 @@ from .components import Lorentzian from .components import Polynomial from .components import Voigt -from .diffusion_model.brownian_translational_diffusion import ( - BrownianTranslationalDiffusion, -) +from .diffusion_model.brownian_translational_diffusion import BrownianTranslationalDiffusion from .instrument_model import InstrumentModel from .resolution_model import ResolutionModel from .sample_model import SampleModel __all__ = [ - "ComponentCollection", - "Gaussian", - "Lorentzian", - "Voigt", - "DeltaFunction", - "DampedHarmonicOscillator", - "Polynomial", - "BrownianTranslationalDiffusion", - "SampleModel", - "ResolutionModel", - "BackgroundModel", - "InstrumentModel", + 'ComponentCollection', + 'Gaussian', + 'Lorentzian', + 'Voigt', + 'DeltaFunction', + 'DampedHarmonicOscillator', + 'Polynomial', + 'BrownianTranslationalDiffusion', + 'SampleModel', + 'ResolutionModel', + 'BackgroundModel', + 'InstrumentModel', ] diff --git a/src/easydynamics/sample_model/component_collection.py b/src/easydynamics/sample_model/component_collection.py index a0b1e668..4f4ebe2a 100644 --- a/src/easydynamics/sample_model/component_collection.py +++ b/src/easydynamics/sample_model/component_collection.py @@ -31,8 +31,8 @@ class ComponentCollection(ModelBase): def __init__( self, - unit: str | sc.Unit = "meV", - display_name: str = "MyComponentCollection", + unit: str | sc.Unit = 'meV', + display_name: str = 'MyComponentCollection', unique_name: str | None = None, components: List[ModelComponent] | None = None, ): @@ -54,7 +54,7 @@ def __init__( if unit is not None and not isinstance(unit, (str, sc.Unit)): raise TypeError( - f"unit must be None, a string, or a scipp Unit, got {type(unit).__name__}" + f'unit must be None, a string, or a scipp Unit, got {type(unit).__name__}' ) self._unit = unit self._components = [] @@ -62,37 +62,31 @@ def __init__( # Add initial components if provided. Used for serialization. if components is not None: if not isinstance(components, list): - raise TypeError( - "components must be a list of ModelComponent instances." - ) + raise TypeError('components must be a list of ModelComponent instances.') for comp in components: self.append_component(comp) - def append_component( - self, component: ModelComponent | "ComponentCollection" - ) -> None: + def append_component(self, component: ModelComponent | 'ComponentCollection') -> None: match component: case ModelComponent(): components = (component,) case ComponentCollection(components=components): pass case _: - raise TypeError( - "Component must be a ModelComponent or ComponentCollection." - ) + raise TypeError('Component must be a ModelComponent or ComponentCollection.') for comp in components: if comp in self._components: raise ValueError( f"Component '{comp.unique_name}' is already in the collection. " - f"Existing components: {self.list_component_names()}" + f'Existing components: {self.list_component_names()}' ) self._components.append(comp) def remove_component(self, unique_name: str) -> None: if not isinstance(unique_name, str): - raise TypeError("Component name must be a string.") + raise TypeError('Component name must be a string.') for comp in self._components: if comp.unique_name == unique_name: @@ -101,8 +95,8 @@ def remove_component(self, unique_name: str) -> None: raise KeyError( f"No component named '{unique_name}' exists. " - f"Did you accidentally use the display_name? " - f"Here is a list of the components in the collection: {self.list_component_names()}" + f'Did you accidentally use the display_name? ' + f'Here is a list of the components in the collection: {self.list_component_names()}' ) @property @@ -112,12 +106,12 @@ def components(self) -> list[ModelComponent]: @components.setter def components(self, components: List[ModelComponent]) -> None: if not isinstance(components, list): - raise TypeError("components must be a list of ModelComponent instances.") + raise TypeError('components must be a list of ModelComponent instances.') for comp in components: if not isinstance(comp, ModelComponent): raise TypeError( - "All items in components must be instances of ModelComponent. " - f"Got {type(comp).__name__} instead." + 'All items in components must be instances of ModelComponent. ' + f'Got {type(comp).__name__} instead.' ) self._components = components @@ -129,8 +123,8 @@ def is_empty(self) -> bool: @is_empty.setter def is_empty(self, value: bool) -> None: raise AttributeError( - "is_empty is a read-only property that indicates " - "whether the collection has components." + 'is_empty is a read-only property that indicates ' + 'whether the collection has components.' ) def list_component_names(self) -> List[str]: @@ -152,27 +146,27 @@ def normalize_area(self) -> None: # Useful for convolutions. """Normalize the areas of all components so they sum to 1.""" if not self.components: - raise ValueError("No components in the model to normalize.") + raise ValueError('No components in the model to normalize.') area_params = [] - total_area = Parameter(name="total_area", value=0.0, unit=self._unit) + total_area = Parameter(name='total_area', value=0.0, unit=self._unit) for component in self.components: - if hasattr(component, "area"): + if hasattr(component, 'area'): area_params.append(component.area) total_area += component.area else: warnings.warn( f"Component '{component.unique_name}' does not have an 'area' attribute " - f"and will be skipped in normalization.", + f'and will be skipped in normalization.', UserWarning, ) if total_area.value == 0: - raise ValueError("Total area is zero; cannot normalize.") + raise ValueError('Total area is zero; cannot normalize.') if not np.isfinite(total_area.value): - raise ValueError("Total area is not finite; cannot normalize.") + raise ValueError('Total area is not finite; cannot normalize.') for param in area_params: param.value /= total_area.value @@ -184,11 +178,7 @@ def get_all_variables(self) -> list[DescriptorBase]: List[Parameter]: List of parameters in the component. """ - return [ - var - for component in self.components - for var in component.get_all_variables() - ] + return [var for component in self.components for var in component.get_all_variables()] @property def unit(self) -> str | sc.Unit: @@ -204,8 +194,8 @@ def unit(self) -> str | sc.Unit: def unit(self, unit_str: str) -> None: raise AttributeError( ( - f"Unit is read-only. Use convert_unit to change the unit between allowed types " - f"or create a new {self.__class__.__name__} with the desired unit." + f'Unit is read-only. Use convert_unit to change the unit between allowed types ' + f'or create a new {self.__class__.__name__} with the desired unit.' ) ) # noqa: E501 @@ -229,9 +219,7 @@ def convert_unit(self, unit: str | sc.Unit) -> None: pass # Best effort rollback raise e - def evaluate( - self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray - ) -> np.ndarray: + def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: """Evaluate the sum of all components. Parameters @@ -269,13 +257,11 @@ def evaluate_component( Evaluated values for the specified component. """ if not self.components: - raise ValueError("No components in the model to evaluate.") + raise ValueError('No components in the model to evaluate.') if not isinstance(unique_name, str): raise TypeError( - ( - f"Component unique name must be a string, got {type(unique_name)} instead." - ) + (f'Component unique name must be a string, got {type(unique_name)} instead.') ) matches = [comp for comp in self.components if comp.unique_name == unique_name] @@ -328,8 +314,6 @@ def __repr__(self) -> str: ------- str """ - comp_names = ( - ", ".join(c.unique_name for c in self.components) or "No components" - ) + comp_names = ', '.join(c.unique_name for c in self.components) or 'No components' return f"" diff --git a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py index 286ea486..ba2ad2df 100644 --- a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py @@ -9,9 +9,7 @@ from easydynamics.sample_model.component_collection import ComponentCollection from easydynamics.sample_model.components import Lorentzian -from easydynamics.sample_model.diffusion_model.diffusion_model_base import ( - DiffusionModelBase, -) +from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import Q_type from easydynamics.utils.utils import _validate_and_convert_Q @@ -33,9 +31,9 @@ class JumpTranslationalDiffusion(DiffusionModelBase): def __init__( self, - display_name: str | None = "JumpTranslationalDiffusion", + display_name: str | None = 'JumpTranslationalDiffusion', unique_name: str | None = None, - unit: str | sc.Unit = "meV", + unit: str | sc.Unit = 'meV', scale: Numeric = 1.0, diffusion_coefficient: Numeric = 1.0, relaxation_time: Numeric = 1.0, @@ -67,27 +65,27 @@ def __init__( ) if not isinstance(diffusion_coefficient, Numeric): - raise TypeError("diffusion_coefficient must be a number.") + raise TypeError('diffusion_coefficient must be a number.') if not isinstance(relaxation_time, Numeric): - raise TypeError("relaxation_time must be a number.") + raise TypeError('relaxation_time must be a number.') diffusion_coefficient = Parameter( - name="diffusion_coefficient", + name='diffusion_coefficient', value=float(diffusion_coefficient), fixed=False, - unit="m**2/s", + unit='m**2/s', ) relaxation_time = Parameter( - name="relaxation_time", + name='relaxation_time', value=float(relaxation_time), fixed=False, - unit="ps", + unit='ps', ) - self._hbar = DescriptorNumber.from_scipp("hbar", scipp_hbar) - self._angstrom = DescriptorNumber("angstrom", 1e-10, unit="m") + self._hbar = DescriptorNumber.from_scipp('hbar', scipp_hbar) + self._angstrom = DescriptorNumber('angstrom', 1e-10, unit='m') self._diffusion_coefficient = diffusion_coefficient self._relaxation_time = relaxation_time @@ -110,7 +108,7 @@ def diffusion_coefficient(self) -> Parameter: def diffusion_coefficient(self, diffusion_coefficient: Numeric) -> None: """Set the diffusion coefficient parameter D.""" if not isinstance(diffusion_coefficient, Numeric): - raise TypeError("diffusion_coefficient must be a number.") + raise TypeError('diffusion_coefficient must be a number.') self._diffusion_coefficient.value = diffusion_coefficient @property @@ -128,7 +126,7 @@ def relaxation_time(self) -> Parameter: def relaxation_time(self, relaxation_time: Numeric) -> None: """Set the relaxation time parameter t.""" if not isinstance(relaxation_time, Numeric): - raise TypeError("relaxation_time must be a number.") + raise TypeError('relaxation_time must be a number.') self._relaxation_time.value = relaxation_time ################################ @@ -163,7 +161,7 @@ def calculate_width(self, Q: Q_type) -> np.ndarray: unit_conversion_factor_denominator = ( self.diffusion_coefficient / self._angstrom**2 * self.relaxation_time ) - unit_conversion_factor_denominator.convert_unit("dimensionless") + unit_conversion_factor_denominator.convert_unit('dimensionless') denominator = 1 + unit_conversion_factor_denominator.value * Q**2 @@ -209,7 +207,7 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: def create_component_collections( self, Q: Q_type, - component_display_name: str = "Jump translational diffusion", + component_display_name: str = 'Jump translational diffusion', ) -> List[ComponentCollection]: """Create ComponentCollection components for the diffusion model at given Q values. @@ -229,7 +227,7 @@ def create_component_collections( Q = _validate_and_convert_Q(Q) if not isinstance(component_display_name, str): - raise TypeError("component_name must be a string.") + raise TypeError('component_name must be a string.') component_collection_list = [None] * len(Q) # In more complex models, this is used to scale the area of the @@ -241,7 +239,7 @@ def create_component_collections( # is 0. for i, Q_value in enumerate(Q): component_collection_list[i] = ComponentCollection( - display_name=f"{self.display_name}_Q{Q_value:.2f}", unit=self.unit + display_name=f'{self.display_name}_Q{Q_value:.2f}', unit=self.unit ) lorentzian_component = Lorentzian( @@ -290,20 +288,20 @@ def _write_width_dependency_expression(self, Q: float) -> str: Dependency expression for the width. """ if not isinstance(Q, (float)): - raise TypeError("Q must be a float.") + raise TypeError('Q must be a float.') # Q is given as a float, so we need to add the units - return f"hbar * D* {Q} **2/(angstrom**2)/(1 + (D * t* {Q} **2/(angstrom**2)))" + return f'hbar * D* {Q} **2/(angstrom**2)/(1 + (D * t* {Q} **2/(angstrom**2)))' def _write_width_dependency_map_expression(self) -> Dict[str, DescriptorNumber]: """Write the dependency map expression to make dependent Parameters. """ return { - "D": self._diffusion_coefficient, - "t": self._relaxation_time, - "hbar": self._hbar, - "angstrom": self._angstrom, + 'D': self._diffusion_coefficient, + 't': self._relaxation_time, + 'hbar': self._hbar, + 'angstrom': self._angstrom, } def _write_area_dependency_expression(self, QISF: float) -> str: @@ -316,16 +314,16 @@ def _write_area_dependency_expression(self, QISF: float) -> str: Dependency expression for the area. """ if not isinstance(QISF, (float)): - raise TypeError("QISF must be a float.") + raise TypeError('QISF must be a float.') - return f"{QISF} * scale" + return f'{QISF} * scale' def _write_area_dependency_map_expression(self) -> Dict[str, DescriptorNumber]: """Write the dependency map expression to make dependent Parameters. """ return { - "scale": self._scale, + 'scale': self._scale, } ################################ @@ -337,6 +335,6 @@ def __repr__(self): model. """ return ( - f"JumpTranslationalDiffusion(display_name={self.display_name}, " - f"diffusion_coefficient={self._diffusion_coefficient}, scale={self._scale})" + f'JumpTranslationalDiffusion(display_name={self.display_name}, ' + f'diffusion_coefficient={self._diffusion_coefficient}, scale={self._scale})' ) diff --git a/src/easydynamics/sample_model/instrument_model.py b/src/easydynamics/sample_model/instrument_model.py index 4c767331..33b6aacb 100644 --- a/src/easydynamics/sample_model/instrument_model.py +++ b/src/easydynamics/sample_model/instrument_model.py @@ -48,13 +48,13 @@ class InstrumentModel(NewBase): def __init__( self, - display_name: str = "MyInstrumentModel", + display_name: str = 'MyInstrumentModel', unique_name: str | None = None, Q: Q_type | None = None, resolution_model: ResolutionModel | None = None, background_model: BackgroundModel | None = None, energy_offset: Numeric | None = None, - unit: str | sc.Unit = "meV", + unit: str | sc.Unit = 'meV', ): super().__init__( display_name=display_name, @@ -68,8 +68,8 @@ def __init__( else: if not isinstance(resolution_model, ResolutionModel): raise TypeError( - f"resolution_model must be a ResolutionModel or None, " - f"got {type(resolution_model).__name__}" + f'resolution_model must be a ResolutionModel or None, ' + f'got {type(resolution_model).__name__}' ) self._resolution_model = resolution_model @@ -78,8 +78,8 @@ def __init__( else: if not isinstance(background_model, BackgroundModel): raise TypeError( - f"background_model must be a BackgroundModel or None, " - f"got {type(background_model).__name__}" + f'background_model must be a BackgroundModel or None, ' + f'got {type(background_model).__name__}' ) self._background_model = background_model @@ -87,10 +87,10 @@ def __init__( energy_offset = 0.0 if not isinstance(energy_offset, Numeric): - raise TypeError("energy_offset must be a number or None") + raise TypeError('energy_offset must be a number or None') self._energy_offset = Parameter( - name="energy_offset", + name='energy_offset', value=float(energy_offset), unit=self.unit, fixed=False, @@ -112,7 +112,7 @@ def resolution_model(self, value: ResolutionModel): """Set the resolution model of the instrument.""" if not isinstance(value, ResolutionModel): raise TypeError( - f"resolution_model must be a ResolutionModel, got {type(value).__name__}" + f'resolution_model must be a ResolutionModel, got {type(value).__name__}' ) self._resolution_model = value self._on_resolution_model_change() @@ -127,7 +127,7 @@ def background_model(self, value: BackgroundModel): """Set the background model of the instrument.""" if not isinstance(value, BackgroundModel): raise TypeError( - f"background_model must be a BackgroundModel, got {type(value).__name__}" + f'background_model must be a BackgroundModel, got {type(value).__name__}' ) self._background_model = value self._on_background_model_change() @@ -157,8 +157,8 @@ def unit(self) -> sc.Unit: def unit(self, unit_str: str) -> None: raise AttributeError( ( - f"Unit is read-only. Use convert_unit to change the unit between allowed types " - f"or create a new {self.__class__.__name__} with the desired unit." + f'Unit is read-only. Use convert_unit to change the unit between allowed types ' + f'or create a new {self.__class__.__name__} with the desired unit.' ) ) # noqa: E501 @@ -184,9 +184,7 @@ def energy_offset(self, value: Numeric): If value is not a number. """ if not isinstance(value, Numeric): - raise TypeError( - f"energy_offset must be a number, got {type(value).__name__}" - ) + raise TypeError(f'energy_offset must be a number, got {type(value).__name__}') self._energy_offset.value = value self._on_energy_offset_change() @@ -210,7 +208,7 @@ def convert_unit(self, unit_str: str | sc.Unit) -> None: """ unit = _validate_unit(unit_str) if unit is None: - raise ValueError("unit_str must be a valid unit string or scipp Unit") + raise ValueError('unit_str must be a valid unit string or scipp Unit') self._background_model.convert_unit(unit) self._resolution_model.convert_unit(unit) @@ -240,12 +238,10 @@ def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: variables = [self._energy_offsets[i] for i in range(len(self._Q))] else: if not isinstance(Q_index, int): - raise TypeError( - f"Q_index must be an int or None, got {type(Q_index).__name__}" - ) + raise TypeError(f'Q_index must be an int or None, got {type(Q_index).__name__}') if Q_index < 0 or Q_index >= len(self._Q): raise IndexError( - f"Q_index {Q_index} is out of bounds for Q of length {len(self._Q)}" + f'Q_index {Q_index} is out of bounds for Q of length {len(self._Q)}' ) variables = [self._energy_offsets[Q_index]] @@ -281,12 +277,10 @@ def get_energy_offset_at_Q(self, Q_index: int) -> Parameter: If Q_index is out of bounds. """ if self._Q is None: - raise ValueError("No Q values are set in the InstrumentModel.") + raise ValueError('No Q values are set in the InstrumentModel.') if Q_index < 0 or Q_index >= len(self._Q): - raise IndexError( - f"Q_index {Q_index} is out of bounds for Q of length {len(self._Q)}" - ) + raise IndexError(f'Q_index {Q_index} is out of bounds for Q of length {len(self._Q)}') return self._energy_offsets[Q_index] @@ -327,11 +321,11 @@ def _on_background_model_change(self) -> None: def __repr__(self): return ( - f"{self.__class__.__name__}(" - f"unique_name={self.unique_name!r}, " - f"unit={self.unit}, " - f"Q_len={None if self._Q is None else len(self._Q)}, " - f"resolution_model={self._resolution_model!r}, " - f"background_model={self._background_model!r}" - f")" + f'{self.__class__.__name__}(' + f'unique_name={self.unique_name!r}, ' + f'unit={self.unit}, ' + f'Q_len={None if self._Q is None else len(self._Q)}, ' + f'resolution_model={self._resolution_model!r}, ' + f'background_model={self._background_model!r}' + f')' ) diff --git a/src/easydynamics/sample_model/model_base.py b/src/easydynamics/sample_model/model_base.py index 039da8bd..570234a2 100644 --- a/src/easydynamics/sample_model/model_base.py +++ b/src/easydynamics/sample_model/model_base.py @@ -42,9 +42,9 @@ class ModelBase(EasyScienceModelBase): def __init__( self, - display_name: str = "MyModelBase", + display_name: str = 'MyModelBase', unique_name: str | None = None, - unit: str | sc.Unit | None = "meV", + unit: str | sc.Unit | None = 'meV', components: ModelComponent | ComponentCollection | None = None, Q: Q_type | None = None, ): @@ -59,8 +59,8 @@ def __init__( components, (ModelComponent, ComponentCollection) ): raise TypeError( - f"Components must be a ModelComponent, a ComponentCollection or None, " - f"got {type(components).__name__}" + f'Components must be a ModelComponent, a ComponentCollection or None, ' + f'got {type(components).__name__}' ) self._components = ComponentCollection() @@ -87,8 +87,8 @@ def evaluate( if not self._component_collections: raise ValueError( - "No components in the model to evaluate. " - "Run generate_component_collections() first" + 'No components in the model to evaluate. ' + 'Run generate_component_collections() first' ) y = [collection.evaluate(x) for collection in self._component_collections] @@ -142,8 +142,8 @@ def unit(self) -> str | sc.Unit: def unit(self, unit_str: str) -> None: raise AttributeError( ( - f"Unit is read-only. Use convert_unit to change the unit between allowed types " - f"or create a new {self.__class__.__name__} with the desired unit." + f'Unit is read-only. Use convert_unit to change the unit between allowed types ' + f'or create a new {self.__class__.__name__} with the desired unit.' ) ) # noqa: E501 @@ -177,9 +177,7 @@ def components(self) -> list[ModelComponent]: def components(self, value: ModelComponent | ComponentCollection | None) -> None: """Set the components of the SampleModel.""" if not isinstance(value, (ModelComponent, ComponentCollection, type(None))): - raise TypeError( - "Components must be a ModelComponent or a ComponentCollection" - ) + raise TypeError('Components must be a ModelComponent or a ComponentCollection') self.clear_components() if value is not None: @@ -243,13 +241,11 @@ def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: ] else: if not isinstance(Q_index, int): - raise TypeError( - f"Q_index must be an int or None, got {type(Q_index).__name__}" - ) + raise TypeError(f'Q_index must be an int or None, got {type(Q_index).__name__}') if Q_index < 0 or Q_index >= len(self._component_collections): raise IndexError( - f"Q_index {Q_index} is out of bounds for component collections " - f"of length {len(self._component_collections)}" + f'Q_index {Q_index} is out of bounds for component collections ' + f'of length {len(self._component_collections)}' ) all_vars = self._component_collections[Q_index].get_all_variables() return all_vars @@ -268,11 +264,11 @@ def get_component_collection(self, Q_index: int) -> ComponentCollection: The ComponentCollection at the specified Q index. """ if not isinstance(Q_index, int): - raise TypeError(f"Q_index must be an int, got {type(Q_index).__name__}") + raise TypeError(f'Q_index must be an int, got {type(Q_index).__name__}') if Q_index < 0 or Q_index >= len(self._component_collections): raise IndexError( - f"Q_index {Q_index} is out of bounds for component collections " - f"of length {len(self._component_collections)}" + f'Q_index {Q_index} is out of bounds for component collections ' + f'of length {len(self._component_collections)}' ) return self._component_collections[Q_index] @@ -282,16 +278,11 @@ def get_component_collection(self, Q_index: int) -> ComponentCollection: def _generate_component_collections(self) -> None: """Generate ComponentCollections for each Q value.""" - # TODO regenerate automatically if Q or components have changed if self._Q is None: - # warnings.warn( - # "Q is not set. No component collections generated", UserWarning - # ) self._component_collections = [] return - # Will fix it for my code I think self._component_collections = [] for _ in self._Q: self._component_collections.append(copy(self._components)) @@ -310,6 +301,6 @@ def _on_components_change(self) -> None: def __repr__(self): return ( - f"{self.__class__.__name__}(unique_name={self.unique_name}, " - f"unit={self.unit}), Q = {self.Q}, components = {self.components}" + f'{self.__class__.__name__}(unique_name={self.unique_name}, ' + f'unit={self.unit}), Q = {self.Q}, components = {self.components}' ) diff --git a/src/easydynamics/utils/utils.py b/src/easydynamics/utils/utils.py index bcd44c14..e3cc842d 100644 --- a/src/easydynamics/utils/utils.py +++ b/src/easydynamics/utils/utils.py @@ -26,7 +26,7 @@ def _validate_and_convert_Q(Q: Q_type | None) -> np.ndarray | None: if Q is None: return None if not isinstance(Q, (Numeric, list, np.ndarray, sc.Variable)): - raise TypeError("Q must be a number, list, numpy array, or scipp array.") + raise TypeError('Q must be a number, list, numpy array, or scipp array.') if isinstance(Q, Numeric): Q = np.array([Q]) @@ -34,14 +34,14 @@ def _validate_and_convert_Q(Q: Q_type | None) -> np.ndarray | None: Q = np.array(Q) if isinstance(Q, np.ndarray): if Q.ndim > 1: - raise ValueError("Q must be a 1-dimensional array.") + raise ValueError('Q must be a 1-dimensional array.') - Q = sc.array(dims=["Q"], values=Q, unit="1/angstrom") + Q = sc.array(dims=['Q'], values=Q, unit='1/angstrom') if isinstance(Q, sc.Variable): - if Q.dims != ("Q",): + if Q.dims != ('Q',): raise ValueError("Q must have a single dimension named 'Q'.") - Q = Q.to(unit="1/angstrom") + Q = Q.to(unit='1/angstrom') return Q.values @@ -64,9 +64,7 @@ def _validate_unit(unit: str | sc.Unit | None) -> sc.Unit | None: """ if unit is not None and not isinstance(unit, (str, sc.Unit)): - raise TypeError( - f"unit must be None, a string, or a scipp Unit, got {type(unit).__name__}" - ) + raise TypeError(f'unit must be None, a string, or a scipp Unit, got {type(unit).__name__}') if isinstance(unit, str): unit = sc.Unit(unit) return unit @@ -82,9 +80,9 @@ def _in_notebook() -> bool: from IPython import get_ipython shell = get_ipython().__class__.__name__ - if shell == "ZMQInteractiveShell": + if shell == 'ZMQInteractiveShell': return True # Jupyter notebook or JupyterLab - elif shell == "TerminalInteractiveShell": + elif shell == 'TerminalInteractiveShell': return False # Terminal IPython else: return False diff --git a/tests/unit/easydynamics/experiment/test_experiment.py b/tests/unit/easydynamics/experiment/test_experiment.py index 05aa2470..8da97ce0 100644 --- a/tests/unit/easydynamics/experiment/test_experiment.py +++ b/tests/unit/easydynamics/experiment/test_experiment.py @@ -12,12 +12,12 @@ class TestExperiment: @pytest.fixture def experiment(self): - Q = sc.linspace("Q", 0.5, 1.5, num=10, unit="1/Angstrom") - energy = sc.linspace("energy", -5, 5, num=11, unit="meV") - values = sc.array(dims=["Q", "energy"], values=np.ones((10, 11))) - data = sc.DataArray(data=values, coords={"Q": Q, "energy": energy}) + Q = sc.linspace('Q', 0.5, 1.5, num=10, unit='1/Angstrom') + energy = sc.linspace('energy', -5, 5, num=11, unit='meV') + values = sc.array(dims=['Q', 'energy'], values=np.ones((10, 11))) + data = sc.DataArray(data=values, coords={'Q': Q, 'energy': energy}) - experiment = Experiment(display_name="test_experiment", data=data) + experiment = Experiment(display_name='test_experiment', data=data) return experiment ############## @@ -27,51 +27,51 @@ def experiment(self): def test_init_array(self, experiment): "Test initialization with a Scipp DataArray" # WHEN THEN EXPECT - assert experiment.display_name == "test_experiment" + assert experiment.display_name == 'test_experiment' assert isinstance(experiment._data, sc.DataArray) - assert "Q" in experiment._data.dims - assert "energy" in experiment._data.dims - assert experiment._data.sizes["Q"] == 10 - assert experiment._data.sizes["energy"] == 11 + assert 'Q' in experiment._data.dims + assert 'energy' in experiment._data.dims + assert experiment._data.sizes['Q'] == 10 + assert experiment._data.sizes['energy'] == 11 assert sc.identical( experiment._data.data, - sc.array(dims=["Q", "energy"], values=np.ones((10, 11))), + sc.array(dims=['Q', 'energy'], values=np.ones((10, 11))), ) def test_init_string(self, tmp_path): "Test initialization with a filename string," - "should load the file" + 'should load the file' # WHEN - Q = sc.linspace("Q", 0.5, 1.5, num=10, unit="1/Angstrom") - energy = sc.linspace("energy", -5, 5, num=11, unit="meV") - values = sc.array(dims=["Q", "energy"], values=np.ones((10, 11))) - data = sc.DataArray(data=values, coords={"Q": Q, "energy": energy}) + Q = sc.linspace('Q', 0.5, 1.5, num=10, unit='1/Angstrom') + energy = sc.linspace('energy', -5, 5, num=11, unit='meV') + values = sc.array(dims=['Q', 'energy'], values=np.ones((10, 11))) + data = sc.DataArray(data=values, coords={'Q': Q, 'energy': energy}) - filename = tmp_path / "test_experiment.h5" + filename = tmp_path / 'test_experiment.h5' sc.io.save_hdf5(data, filename) # THEN - experiment = Experiment(display_name="loaded_experiment", data=str(filename)) + experiment = Experiment(display_name='loaded_experiment', data=str(filename)) # EXPECT - assert experiment.display_name == "loaded_experiment" + assert experiment.display_name == 'loaded_experiment' assert isinstance(experiment._data, sc.DataArray) - assert "Q" in experiment._data.dims - assert "energy" in experiment._data.dims - assert experiment._data.sizes["Q"] == 10 - assert experiment._data.sizes["energy"] == 11 + assert 'Q' in experiment._data.dims + assert 'energy' in experiment._data.dims + assert experiment._data.sizes['Q'] == 10 + assert experiment._data.sizes['energy'] == 11 assert sc.identical( experiment._data.data, - sc.array(dims=["Q", "energy"], values=np.ones((10, 11))), + sc.array(dims=['Q', 'energy'], values=np.ones((10, 11))), ) def test_init_no_data(self): "Test initialization with no data" # WHEN - experiment = Experiment(display_name="empty_experiment") + experiment = Experiment(display_name='empty_experiment') # THEN EXPECT - assert experiment.display_name == "empty_experiment" + assert experiment.display_name == 'empty_experiment' assert experiment._data is None def test_init_invalid_data(self): @@ -86,34 +86,34 @@ def test_init_invalid_data(self): def test_load_hdf5(self, tmp_path, experiment): "Test loading data from an HDF5 file." - "First use scipp to save data to a file, " - "then load it using the method." + 'First use scipp to save data to a file, ' + 'then load it using the method.' # WHEN # First create a file to load from - filename = tmp_path / "test.h5" + filename = tmp_path / 'test.h5' data_to_save = experiment.data sc.io.save_hdf5(data_to_save, filename) # THEN - new_experiment = Experiment(display_name="new_experiment") - new_experiment.load_hdf5(str(filename), display_name="loaded_data") + new_experiment = Experiment(display_name='new_experiment') + new_experiment.load_hdf5(str(filename), display_name='loaded_data') loaded_data = new_experiment.data # EXPECT assert sc.identical(data_to_save, loaded_data) - assert new_experiment.display_name == "loaded_data" + assert new_experiment.display_name == 'loaded_data' def test_load_hdf5_invalid_name_raises(self, experiment): "Test loading data from an HDF5 file," - "giving the Experiment an invalid name" + 'giving the Experiment an invalid name' # WHEN / THEN EXPECT with pytest.raises(TypeError): - experiment.load_hdf5("some_file.h5", display_name=123) + experiment.load_hdf5('some_file.h5', display_name=123) def test_load_hdf5_invalid_filename_raises(self, experiment): "Test loading data from an HDF5 file with an invalid filename" # WHEN / THEN EXPECT - with pytest.raises(TypeError, match="must be a string"): + with pytest.raises(TypeError, match='must be a string'): experiment.load_hdf5(123) def test_load_hdf5_invalid_file_raises(self, experiment): @@ -121,13 +121,13 @@ def test_load_hdf5_invalid_file_raises(self, experiment): # WHEN / THEN EXPECT with pytest.raises(OSError): - experiment.load_hdf5("non_existent_file.h5") + experiment.load_hdf5('non_existent_file.h5') def test_save_hdf5(self, tmp_path, experiment): "Test saving data to an HDF5 file. Load the saved file" - "using scipp and compare to the original data." + 'using scipp and compare to the original data.' # WHEN THEN - filename = tmp_path / "saved_data.h5" + filename = tmp_path / 'saved_data.h5' experiment.save_hdf5(str(filename)) # EXPECT @@ -144,25 +144,25 @@ def test_save_hdf5_default_filename(self, tmp_path, experiment, monkeypatch): experiment.save_hdf5() # EXPECT - expected_filename = tmp_path / f"{experiment.unique_name}.h5" + expected_filename = tmp_path / f'{experiment.unique_name}.h5' loaded_data = sc.io.load_hdf5(str(expected_filename)) original_data = experiment.data assert sc.identical(original_data, loaded_data) def test_save_hdf5_no_data_raises(self): "Test saving data to an HDF5 file when no data is present" - "in the experiment" + 'in the experiment' # WHEN experiment = Experiment() # THEN EXPECT with pytest.raises(ValueError): - experiment.save_hdf5("should_fail.h5") + experiment.save_hdf5('should_fail.h5') def test_save_hdf5_invalid_filename_raises(self, experiment): "Test saving data to an HDF5 file with an invalid filename" # WHEN / THEN EXPECT - with pytest.raises(TypeError, match="must be a string"): + with pytest.raises(TypeError, match='must be a string'): experiment.save_hdf5(123) def test_remove_data(self, experiment): @@ -174,11 +174,11 @@ def test_remove_data(self, experiment): assert experiment._data is None @pytest.mark.parametrize( - "new_Q_bins, new_energy_bins", + 'new_Q_bins, new_energy_bins', [ ( - sc.linspace("Q", 0.5, 1.5, num=7, unit="1/Angstrom"), - sc.linspace("energy", -5, 5, num=8, unit="meV"), + sc.linspace('Q', 0.5, 1.5, num=7, unit='1/Angstrom'), + sc.linspace('energy', -5, 5, num=8, unit='meV'), ), ( 6, @@ -189,23 +189,23 @@ def test_remove_data(self, experiment): 7.0, ), ( - sc.linspace("Q", 0.5, 1.5, num=7, unit="1/Angstrom"), + sc.linspace('Q', 0.5, 1.5, num=7, unit='1/Angstrom'), 7, ), ], - ids=["sc_bins", "integers_bins", "float_bins", "mixed_bins"], + ids=['sc_bins', 'integers_bins', 'float_bins', 'mixed_bins'], ) def test_rebin(self, experiment, new_Q_bins, new_energy_bins): "Test rebinning data in the experiment" # WHEN # THEN - experiment.rebin({"Q": new_Q_bins, "energy": new_energy_bins}) + experiment.rebin({'Q': new_Q_bins, 'energy': new_energy_bins}) # EXPECT rebinned_data = experiment.binned_data - assert rebinned_data.sizes["Q"] == 6 - assert rebinned_data.sizes["energy"] == 7 + assert rebinned_data.sizes['Q'] == 6 + assert rebinned_data.sizes['energy'] == 7 def test_rebin_no_data_raises(self): "Test rebinning data when no data is present" @@ -214,34 +214,34 @@ def test_rebin_no_data_raises(self): # THEN EXPECT with pytest.raises(ValueError): - experiment.rebin({"Q": 6, "energy": 7}) + experiment.rebin({'Q': 6, 'energy': 7}) def test_rebin_invalid_dimensions_raises(self, experiment): "Test rebinning data with invalid dimensions" # WHEN / THEN EXPECT with pytest.raises(TypeError): - experiment.rebin("invalid_dimensions") + experiment.rebin('invalid_dimensions') def test_rebin_invalid_dimension_name_raises(self, experiment): "Test rebinning data with invalid dimension name" # WHEN / THEN EXPECT - with pytest.raises(TypeError, match="Dimension keys must be strings"): - experiment.rebin({123: 6, "energy": 7}) + with pytest.raises(TypeError, match='Dimension keys must be strings'): + experiment.rebin({123: 6, 'energy': 7}) def test_rebin_dimension_not_in_data_raises(self, experiment): "Test rebinning data with a dimension not in the data" # WHEN / THEN EXPECT with pytest.raises(KeyError, match="Dimension 'time' not a valid"): - experiment.rebin({"time": 6, "energy": 7}) + experiment.rebin({'time': 6, 'energy': 7}) def test_rebin_invalid_bin_values_raises(self, experiment): "Test rebinning data with invalid bin values" # WHEN / THEN EXPECT with pytest.raises( TypeError, - match="Dimension values must be integers or", + match='Dimension values must be integers or', ): - experiment.rebin({"Q": [0.5, 1.0, 1.5], "energy": 7}) + experiment.rebin({'Q': [0.5, 1.0, 1.5], 'energy': 7}) ############## # test setters and getters @@ -279,9 +279,9 @@ def test_plot_data_success(self, experiment): "Test plotting data successfully when in notebook environment" # WHEN with ( - patch(f"{Experiment.__module__}._in_notebook", return_value=True), - patch("plopp.plot") as mock_plot, - patch("IPython.display.display") as mock_display, + patch(f'{Experiment.__module__}._in_notebook', return_value=True), + patch('plopp.plot') as mock_plot, + patch('IPython.display.display') as mock_display, ): mock_fig = MagicMock() mock_plot.return_value = mock_fig @@ -293,7 +293,7 @@ def test_plot_data_success(self, experiment): mock_plot.assert_called_once() args, kwargs = mock_plot.call_args assert sc.identical(args[0], experiment._data.transpose()) - assert kwargs["title"] == f"{experiment.display_name}" + assert kwargs['title'] == f'{experiment.display_name}' mock_display.assert_called_once_with(mock_fig) def test_plot_data_no_data_raises(self): @@ -302,18 +302,18 @@ def test_plot_data_no_data_raises(self): experiment = Experiment() # THEN EXPECT - with pytest.raises(ValueError, match="No data to plot"): + with pytest.raises(ValueError, match='No data to plot'): experiment.plot_data() def test_plot_data_not_in_notebook_raises(self, experiment): "Test plotting data raises RuntimeError" - "when not in notebook environment" + 'when not in notebook environment' # WHEN - with patch(f"{Experiment.__module__}._in_notebook", return_value=False): + with patch(f'{Experiment.__module__}._in_notebook', return_value=False): # THEN EXPECT with pytest.raises( RuntimeError, - match="plot_data\\(\\) can only be used in a Jupyter notebook environment", + match='plot_data\\(\\) can only be used in a Jupyter notebook environment', ): experiment.plot_data() @@ -328,42 +328,40 @@ def test_validate_coordinates(self, experiment): def test_validate_coordinates_raises_missing_Q(self, experiment): "Test that _validate_coordinates raises ValueError when Q coord" - "is missing" + 'is missing' # WHEN invalid_data = experiment._data.copy() - invalid_data.coords.pop("Q") + invalid_data.coords.pop('Q') # THEN EXPECT - with pytest.raises(ValueError, match="missing required coordinate"): + with pytest.raises(ValueError, match='missing required coordinate'): experiment._validate_coordinates(invalid_data) def test_validate_coordinates_raises_missing_energy(self, experiment): "Test that _validate_coordinates raises ValueError when energy" - "coord is missing" + 'coord is missing' # WHEN invalid_data = experiment._data.copy() - invalid_data.coords.pop("energy") + invalid_data.coords.pop('energy') # THEN EXPECT - with pytest.raises(ValueError, match="missing required coordinate"): + with pytest.raises(ValueError, match='missing required coordinate'): experiment._validate_coordinates(invalid_data) def test_validate_coordinates_raises_not_DataArray(self): "Test that _validate_coordinates raises TypeError when data is" - "not a Scipp DataArray" + 'not a Scipp DataArray' # WHEN THEN EXPECT - with pytest.raises(TypeError, match="must be a"): - Experiment()._validate_coordinates("not_a_data_array") + with pytest.raises(TypeError, match='must be a'): + Experiment()._validate_coordinates('not_a_data_array') def test_convert_to_bin_centers(self, experiment): "Test that _convert_to_bin_centers converts edges to centers" # WHEN - Q_edges = sc.linspace("Q", 0.0, 2.0, num=11, unit="1/Angstrom") - energy_edges = sc.linspace("energy", -6, 6, num=13, unit="meV") - values = sc.array(dims=["Q", "energy"], values=np.ones((10, 12))) - binned_data = sc.DataArray( - data=values, coords={"Q": Q_edges, "energy": energy_edges} - ) + Q_edges = sc.linspace('Q', 0.0, 2.0, num=11, unit='1/Angstrom') + energy_edges = sc.linspace('energy', -6, 6, num=13, unit='meV') + values = sc.array(dims=['Q', 'energy'], values=np.ones((10, 12))) + binned_data = sc.DataArray(data=values, coords={'Q': Q_edges, 'energy': energy_edges}) # THEN experiment._data = binned_data # Set data to avoid warnings @@ -373,8 +371,8 @@ def test_convert_to_bin_centers(self, experiment): expected_Q = 0.5 * (Q_edges[:-1] + Q_edges[1:]) expected_energy = 0.5 * (energy_edges[:-1] + energy_edges[1:]) - assert sc.identical(converted_data.coords["Q"], expected_Q) - assert sc.identical(converted_data.coords["energy"], expected_energy) + assert sc.identical(converted_data.coords['Q'], expected_Q) + assert sc.identical(converted_data.coords['energy'], expected_energy) assert sc.identical(converted_data.data, binned_data.data) ############## @@ -386,15 +384,12 @@ def test_repr(self, experiment): repr_str = repr(experiment) # THEN EXPECT - assert ( - repr_str - == f"Experiment `{experiment.unique_name}` with data: {experiment._data}" - ) + assert repr_str == f'Experiment `{experiment.unique_name}` with data: {experiment._data}' def test_copy_experiment(self, experiment): "Test copying an Experiment object." - "The copied object should have the same attributes " - "but be a different object in memory." + 'The copied object should have the same attributes ' + 'but be a different object in memory.' # WHEN copied_experiment = copy(experiment) diff --git a/tests/unit/easydynamics/sample_model/test_model_base.py b/tests/unit/easydynamics/sample_model/test_model_base.py index 0a5ec3f5..2eec4321 100644 --- a/tests/unit/easydynamics/sample_model/test_model_base.py +++ b/tests/unit/easydynamics/sample_model/test_model_base.py @@ -16,26 +16,26 @@ class TestModelBase: @pytest.fixture def model_base(self): component1 = Gaussian( - display_name="TestGaussian1", + display_name='TestGaussian1', area=1.0, center=0.0, width=1.0, - unit="meV", + unit='meV', ) component2 = Lorentzian( - display_name="TestLorentzian1", + display_name='TestLorentzian1', area=2.0, center=1.0, width=0.5, - unit="meV", + unit='meV', ) component_collection = ComponentCollection() component_collection.append_component(component1) component_collection.append_component(component2) model_base = ModelBase( - display_name="InitModel", + display_name='InitModel', components=component_collection, - unit="meV", + unit='meV', Q=np.array([1.0, 2.0, 3.0]), ) @@ -46,8 +46,8 @@ def test_init(self, model_base): model = model_base # EXPECT - assert model.display_name == "InitModel" - assert model.unit == "meV" + assert model.display_name == 'InitModel' + assert model.unit == 'meV' assert len(model.components) == 2 np.testing.assert_array_equal(model.Q, np.array([1.0, 2.0, 3.0])) @@ -55,9 +55,9 @@ def test_init_raises_with_invalid_components(self): # WHEN / THEN / EXPECT with pytest.raises( TypeError, - match="Components must be ", + match='Components must be ', ): - ModelBase(components="invalid_component") + ModelBase(components='invalid_component') def test_evaluate_calls_all_component_collections(self, model_base): # WHEN @@ -88,7 +88,7 @@ def test_evaluate_no_component_collections_raises(self, model_base): model_base._component_collections = [] # THEN / EXPECT - with pytest.raises(ValueError, match="No components"): + with pytest.raises(ValueError, match='No components'): model_base.evaluate(x) def test_generate_component_collections_with_Q(self, model_base): @@ -101,24 +101,9 @@ def test_generate_component_collections_with_Q(self, model_base): assert isinstance(collection, ComponentCollection) assert len(collection.components) == 2 assert isinstance(collection.components[0], Gaussian) - assert collection.components[0].display_name == "TestGaussian1" + assert collection.components[0].display_name == 'TestGaussian1' assert isinstance(collection.components[1], Lorentzian) - assert collection.components[1].display_name == "TestLorentzian1" - - def test_fix_free_all_parameters(self, model_base): - # WHEN - model_base.fix_all_parameters() - - # THEN - for par in model_base.get_all_variables(): - assert par.fixed is True - - # WHEN - model_base.free_all_parameters() - - # THEN - for par in model_base.get_all_variables(): - assert par.fixed is False + assert collection.components[1].display_name == 'TestLorentzian1' def test_fix_free_all_parameters(self, model_base): # WHEN @@ -141,12 +126,12 @@ def test_get_all_variables(self, model_base): # THEN expected_var_display_names = { - "TestGaussian1 area", - "TestGaussian1 center", - "TestGaussian1 width", - "TestLorentzian1 area", - "TestLorentzian1 center", - "TestLorentzian1 width", + 'TestGaussian1 area', + 'TestGaussian1 center', + 'TestGaussian1 width', + 'TestLorentzian1 area', + 'TestLorentzian1 center', + 'TestLorentzian1 width', } retrieved_var_display_names = {var.display_name for var in all_vars} @@ -160,12 +145,12 @@ def test_get_all_variables_with_Q_index(self, model_base): # THEN expected_var_display_names = { - "TestGaussian1 area", - "TestGaussian1 center", - "TestGaussian1 width", - "TestLorentzian1 area", - "TestLorentzian1 center", - "TestLorentzian1 width", + 'TestGaussian1 area', + 'TestGaussian1 center', + 'TestGaussian1 width', + 'TestLorentzian1 area', + 'TestLorentzian1 center', + 'TestLorentzian1 width', } retrieved_var_display_names = {var.display_name for var in all_vars} @@ -177,7 +162,7 @@ def test_get_all_variables_with_invalid_Q_index_raises(self, model_base): # WHEN / THEN / EXPECT with pytest.raises( IndexError, - match="Q_index 5 is out of bounds for component collections of length 3", + match='Q_index 5 is out of bounds for component collections of length 3', ): model_base.get_all_variables(Q_index=5) @@ -185,13 +170,13 @@ def test_get_all_variables_with_nonint_Q_index_raises(self, model_base): # WHEN / THEN / EXPECT with pytest.raises( TypeError, - match="Q_index must be an int or None, got str", + match='Q_index must be an int or None, got str', ): - model_base.get_all_variables(Q_index="invalid_index") + model_base.get_all_variables(Q_index='invalid_index') def test_append_and_remove_and_clear_component(self, model_base): # WHEN - new_component = Gaussian(unique_name="NewGaussian") + new_component = Gaussian(unique_name='NewGaussian') # THEN model_base.append_component(new_component) @@ -201,7 +186,7 @@ def test_append_and_remove_and_clear_component(self, model_base): assert model_base.components[-1] is new_component # THEN - model_base.remove_component("NewGaussian") + model_base.remove_component('NewGaussian') # EXPECT assert len(model_base.components) == 2 @@ -230,40 +215,38 @@ def test_append_component_collection(self, model_base): def test_append_component_invalid_type_raises(self, model_base): # WHEN / THEN / EXPECT - with pytest.raises( - TypeError, match=" must be a ModelComponent or ComponentCollection" - ): - model_base.append_component("invalid_component") + with pytest.raises(TypeError, match=' must be a ModelComponent or ComponentCollection'): + model_base.append_component('invalid_component') def test_unit_property(self, model_base): # WHEN unit = model_base.unit # THEN / EXPECT - assert unit == "meV" + assert unit == 'meV' def test_unit_setter_raises(self, model_base): # WHEN / THEN / EXPECT - with pytest.raises(AttributeError, match="Use convert_unit to change "): - model_base.unit = "K" + with pytest.raises(AttributeError, match='Use convert_unit to change '): + model_base.unit = 'K' def test_convert_unit(self, model_base): # WHEN - model_base.convert_unit("eV") + model_base.convert_unit('eV') # THEN / EXPECT - assert model_base.unit == "eV" + assert model_base.unit == 'eV' for component in model_base.components: - assert component.unit == "eV" + assert component.unit == 'eV' def test_convert_unit_invalid_raises(self, model_base): # WHEN / THEN / EXPECT with pytest.raises(Exception): - model_base.convert_unit("invalid_unit") + model_base.convert_unit('invalid_unit') def test_components_setter(self, model_base): # WHEN - new_component = Lorentzian(unique_name="NewLorentzian") + new_component = Lorentzian(unique_name='NewLorentzian') model_base.components = new_component # THEN / EXPECT @@ -289,9 +272,9 @@ def test_components_setter_invalid_raises(self, model_base): # WHEN / THEN / EXPECT with pytest.raises( TypeError, - match="Components must be ", + match='Components must be ', ): - model_base.components = "invalid_component" + model_base.components = 'invalid_component' def test_Q_setter(self, model_base): # WHEN @@ -306,7 +289,7 @@ def test_repr(self, model_base): repr_str = repr(model_base) # THEN / EXPECT - assert "unique_name" in repr_str - assert "unit" in repr_str - assert "Q = " in repr_str - assert "components = " in repr_str + assert 'unique_name' in repr_str + assert 'unit' in repr_str + assert 'Q = ' in repr_str + assert 'components = ' in repr_str diff --git a/tests/unit/easydynamics/utils/test_utils.py b/tests/unit/easydynamics/utils/test_utils.py index 76c967c8..cb3eed27 100644 --- a/tests/unit/easydynamics/utils/test_utils.py +++ b/tests/unit/easydynamics/utils/test_utils.py @@ -12,7 +12,7 @@ class TestValidateAndConvertQ: @pytest.mark.parametrize( - "Q_input, expected", + 'Q_input, expected', [ (1.0, np.array([1.0])), (2, np.array([2])), @@ -30,7 +30,7 @@ def test_validate_and_convert_Q_numeric_and_array(self, Q_input, expected): def test_validate_and_convert_Q_scipp_variable(self): # WHEN - Q = sc.array(dims=["Q"], values=[1.0, 2.0], unit="1/angstrom") + Q = sc.array(dims=['Q'], values=[1.0, 2.0], unit='1/angstrom') # THEN result = _validate_and_convert_Q(Q) @@ -44,29 +44,29 @@ def test_validate_and_convert_Q_none(self): assert _validate_and_convert_Q(None) is None @pytest.mark.parametrize( - "Q_input", + 'Q_input', [ - "invalid", - {"a": 1}, + 'invalid', + {'a': 1}, (1, 2), object(), ], ) def test_validate_and_convert_Q_invalid_type(self, Q_input): # WHEN THEN EXPECT - with pytest.raises(TypeError, match="Q must be a number"): + with pytest.raises(TypeError, match='Q must be a number'): _validate_and_convert_Q(Q_input) def test_validate_and_convert_Q_ndarray_wrong_dim(self): # WHEN THEN Q = np.array([[1.0, 2.0]]) # EXPECT - with pytest.raises(ValueError, match="Q must be a 1-dimensional array"): + with pytest.raises(ValueError, match='Q must be a 1-dimensional array'): _validate_and_convert_Q(Q) def test_validate_and_convert_Q_scipp_wrong_dims(self): # WHEN THEN - Q = sc.array(dims=["x"], values=[1.0, 2.0], unit="1/angstrom") + Q = sc.array(dims=['x'], values=[1.0, 2.0], unit='1/angstrom') # EXPECT with pytest.raises(ValueError, match="single dimension named 'Q'"): @@ -78,12 +78,12 @@ def test_validate_and_convert_Q_scipp_wrong_dims(self): class TestValidateUnit: @pytest.mark.parametrize( - "unit_input", + 'unit_input', [ None, - "1/angstrom", - "meV", - sc.Unit("meV"), + '1/angstrom', + 'meV', + sc.Unit('meV'), ], ) def test_validate_unit_valid(self, unit_input): @@ -95,13 +95,13 @@ def test_validate_unit_valid(self, unit_input): assert isinstance(unit, sc.Unit) def test_validate_unit_string_conversion(self): - unit = _validate_unit("meV") + unit = _validate_unit('meV') assert isinstance(unit, sc.Unit) - assert unit == sc.Unit("meV") + assert unit == sc.Unit('meV') @pytest.mark.parametrize( - "unit_input", + 'unit_input', [ 123, 45.6, @@ -111,9 +111,7 @@ def test_validate_unit_string_conversion(self): ], ) def test_validate_unit_invalid_type(self, unit_input): - with pytest.raises( - TypeError, match="unit must be None, a string, or a scipp Unit" - ): + with pytest.raises(TypeError, match='unit must be None, a string, or a scipp Unit'): _validate_unit(unit_input) @@ -121,17 +119,16 @@ def test_validate_unit_invalid_type(self, unit_input): class TestInNotebook: - def test_in_notebook_returns_true_for_jupyter(self, monkeypatch): """Should return True when IPython shell is ZMQInteractiveShell (Jupyter).""" # WHEN class ZMQInteractiveShell: - __name__ = "ZMQInteractiveShell" + __name__ = 'ZMQInteractiveShell' # THEN - monkeypatch.setattr("IPython.get_ipython", lambda: ZMQInteractiveShell()) + monkeypatch.setattr('IPython.get_ipython', lambda: ZMQInteractiveShell()) # EXPECT assert _in_notebook() is True @@ -142,11 +139,11 @@ def test_in_notebook_returns_false_for_terminal_ipython(self, monkeypatch): # WHEN class TerminalInteractiveShell: - __name__ = "TerminalInteractiveShell" + __name__ = 'TerminalInteractiveShell' # THEN - monkeypatch.setattr("IPython.get_ipython", lambda: TerminalInteractiveShell()) + monkeypatch.setattr('IPython.get_ipython', lambda: TerminalInteractiveShell()) # EXPECT assert _in_notebook() is False @@ -157,10 +154,10 @@ def test_in_notebook_returns_false_for_unknown_shell(self, monkeypatch): # WHEN class UnknownShell: - __name__ = "UnknownShell" + __name__ = 'UnknownShell' # THEN - monkeypatch.setattr("IPython.get_ipython", lambda: UnknownShell()) + monkeypatch.setattr('IPython.get_ipython', lambda: UnknownShell()) # EXPECT assert _in_notebook() is False @@ -173,7 +170,7 @@ def raise_import_error(*args, **kwargs): raise ImportError # THEN - monkeypatch.setattr("builtins.__import__", raise_import_error) + monkeypatch.setattr('builtins.__import__', raise_import_error) # EXPECT assert _in_notebook() is False From 4e3d1c4f9af740a3f544f7f1c678747e245116d9 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Thu, 12 Feb 2026 14:28:03 +0100 Subject: [PATCH 17/27] add missing tests --- .../convolution/convolution_base.py | 48 ++--- .../convolution/test_convolution_base.py | 170 ++++++++++++------ 2 files changed, 146 insertions(+), 72 deletions(-) diff --git a/src/easydynamics/convolution/convolution_base.py b/src/easydynamics/convolution/convolution_base.py index 3a7e753e..db8c069b 100644 --- a/src/easydynamics/convolution/convolution_base.py +++ b/src/easydynamics/convolution/convolution_base.py @@ -30,28 +30,28 @@ def __init__( energy: np.ndarray | sc.Variable, sample_components: ComponentCollection | ModelComponent = None, resolution_components: ComponentCollection | ModelComponent = None, - energy_unit: str | sc.Unit = 'meV', + energy_unit: str | sc.Unit = "meV", energy_offset: Numeric | Parameter = 0.0, ): if isinstance(energy, Numeric): energy = np.array([float(energy)]) if not isinstance(energy, (np.ndarray, sc.Variable)): - raise TypeError('Energy must be a numpy ndarray or a scipp Variable.') + raise TypeError("Energy must be a numpy ndarray or a scipp Variable.") if not isinstance(energy_unit, (str, sc.Unit)): - raise TypeError('Energy_unit must be a string or sc.Unit.') + raise TypeError("Energy_unit must be a string or sc.Unit.") if isinstance(energy, np.ndarray): - energy = sc.array(dims=['energy'], values=energy, unit=energy_unit) + energy = sc.array(dims=["energy"], values=energy, unit=energy_unit) if isinstance(energy_offset, Numeric): energy_offset = Parameter( - name='energy_offset', value=float(energy_offset), unit=energy_unit + name="energy_offset", value=float(energy_offset), unit=energy_unit ) if not isinstance(energy_offset, Parameter): - raise TypeError('Energy_offset must be a number or a Parameter.') + raise TypeError("Energy_offset must be a number or a Parameter.") self._energy = energy self._energy_unit = energy_unit @@ -62,7 +62,7 @@ def __init__( or isinstance(sample_components, ModelComponent) ): raise TypeError( - f'`sample_components` is an instance of {type(sample_components).__name__}, but must be a ComponentCollection or ModelComponent.' # noqa: E501 + f"`sample_components` is an instance of {type(sample_components).__name__}, but must be a ComponentCollection or ModelComponent." # noqa: E501 ) if isinstance(sample_components, ModelComponent): sample_components = ComponentCollection(components=[sample_components]) @@ -73,12 +73,12 @@ def __init__( or isinstance(resolution_components, ModelComponent) ): raise TypeError( - f'`resolution_components` is an instance of {type(resolution_components).__name__}, but must be a ComponentCollection or ModelComponent.' # noqa: E501 + f"`resolution_components` is an instance of {type(resolution_components).__name__}, but must be a ComponentCollection or ModelComponent." # noqa: E501 ) if isinstance(resolution_components, ModelComponent): - resolution_components = ComponentCollection(components=[resolution_components]) - if isinstance(resolution_components, ModelComponent): - resolution_components = ComponentCollection(components=[resolution_components]) + resolution_components = ComponentCollection( + components=[resolution_components] + ) self._resolution_components = resolution_components @property @@ -97,7 +97,7 @@ def energy_offset(self, energy_offset: Numeric | Parameter) -> None: TypeError: If energy_offset is not a number or a Parameter. """ if not isinstance(energy_offset, Parameter | Numeric): - raise TypeError('Energy_offset must be a number or a Parameter.') + raise TypeError("Energy_offset must be a number or a Parameter.") if isinstance(energy_offset, Numeric): self._energy_offset.value = float(energy_offset) @@ -118,7 +118,7 @@ def energy_with_offset(self, value) -> None: energy and energy_offset. """ raise AttributeError( - 'Energy with offset is a read-only property derived from energy and energy_offset.' + "Energy with offset is a read-only property derived from energy and energy_offset." ) @property @@ -144,10 +144,14 @@ def energy(self, energy: np.ndarray) -> None: energy = np.array([float(energy)]) if not isinstance(energy, (np.ndarray, sc.Variable)): - raise TypeError('Energy must be a Number, a numpy ndarray or a scipp Variable.') + raise TypeError( + "Energy must be a Number, a numpy ndarray or a scipp Variable." + ) if isinstance(energy, np.ndarray): - self._energy = sc.array(dims=['energy'], values=energy, unit=self._energy.unit) + self._energy = sc.array( + dims=["energy"], values=energy, unit=self._energy.unit + ) if isinstance(energy, sc.Variable): self._energy = energy @@ -162,8 +166,8 @@ def energy_unit(self) -> str: def energy_unit(self, unit_str: str) -> None: raise AttributeError( ( - f'Unit is read-only. Use convert_unit to change the unit between allowed types ' - f'or create a new {self.__class__.__name__} with the desired unit.' + f"Unit is read-only. Use convert_unit to change the unit between allowed types " + f"or create a new {self.__class__.__name__} with the desired unit." ) ) # noqa: E501 @@ -177,7 +181,7 @@ def convert_energy_unit(self, energy_unit: str | sc.Unit) -> None: TypeError: If energy_unit is not a string or scipp unit. """ if not isinstance(energy_unit, (str, sc.Unit)): - raise TypeError('Energy unit must be a string or scipp unit.') + raise TypeError("Energy unit must be a string or scipp unit.") self.energy = sc.to_unit(self.energy, energy_unit) self._energy_unit = energy_unit @@ -188,7 +192,9 @@ def sample_components(self) -> ComponentCollection | ModelComponent: return self._sample_components @sample_components.setter - def sample_components(self, sample_components: ComponentCollection | ModelComponent) -> None: + def sample_components( + self, sample_components: ComponentCollection | ModelComponent + ) -> None: """Set the sample model. Args: sample_components : ComponentCollection or ModelComponent @@ -200,7 +206,7 @@ def sample_components(self, sample_components: ComponentCollection | ModelCompon """ if not isinstance(sample_components, (ComponentCollection, ModelComponent)): raise TypeError( - f'`sample_components` is an instance of {type(sample_components).__name__}, but must be a ComponentCollection or ModelComponent.' # noqa: E501 + f"`sample_components` is an instance of {type(sample_components).__name__}, but must be a ComponentCollection or ModelComponent." # noqa: E501 ) self._sample_components = sample_components @@ -225,6 +231,6 @@ def resolution_components( """ if not isinstance(resolution_components, (ComponentCollection, ModelComponent)): raise TypeError( - f'`resolution_components` is an instance of {type(resolution_components).__name__}, but must be a ComponentCollection or ModelComponent.' # noqa: E501 + f"`resolution_components` is an instance of {type(resolution_components).__name__}, but must be a ComponentCollection or ModelComponent." # noqa: E501 ) self._resolution_components = resolution_components diff --git a/tests/unit/easydynamics/convolution/test_convolution_base.py b/tests/unit/easydynamics/convolution/test_convolution_base.py index be6249c7..1cc604e0 100644 --- a/tests/unit/easydynamics/convolution/test_convolution_base.py +++ b/tests/unit/easydynamics/convolution/test_convolution_base.py @@ -4,8 +4,10 @@ import numpy as np import pytest import scipp as sc +from easyscience.variable import Parameter from easydynamics.convolution.convolution_base import ConvolutionBase +from easydynamics.sample_model import Gaussian from easydynamics.sample_model.component_collection import ComponentCollection @@ -13,8 +15,8 @@ class TestConvolutionBase: @pytest.fixture def convolution_base(self): energy = np.linspace(-10, 10, 100) - sample_components = ComponentCollection(display_name='ComponentCollection') - resolution_components = ComponentCollection(display_name='ResolutionModel') + sample_components = ComponentCollection(display_name="ComponentCollection") + resolution_components = ComponentCollection(display_name="ResolutionModel") return ConvolutionBase( energy=energy, @@ -30,6 +32,29 @@ def test_init(self, convolution_base): assert isinstance(convolution_base._sample_components, ComponentCollection) assert isinstance(convolution_base._resolution_components, ComponentCollection) + def test_init_with_model_component(self): + # WHEN + energy = np.linspace(-10, 10, 100) + sample_component = Gaussian() + resolution_component = Gaussian() + + convolution_base = ConvolutionBase( + energy=energy, + sample_components=sample_component, + resolution_components=resolution_component, + ) + + # THEN EXPECT + assert isinstance(convolution_base, ConvolutionBase) + assert isinstance(convolution_base.energy, sc.Variable) + assert np.allclose(convolution_base.energy.values, np.linspace(-10, 10, 100)) + assert isinstance(convolution_base.sample_components, ComponentCollection) + assert isinstance(convolution_base.resolution_components, ComponentCollection) + assert convolution_base.sample_components.components[0] == sample_component + assert ( + convolution_base.resolution_components.components[0] == resolution_component + ) + def test_init_energy_numerical_none_offset(self): # WHEN energy = 1 @@ -42,54 +67,68 @@ def test_init_energy_numerical_none_offset(self): assert isinstance(convolution_base, ConvolutionBase) assert isinstance(convolution_base.energy, sc.Variable) assert convolution_base.energy.values == np.array([1.0]) - assert convolution_base.energy.unit == 'meV' + assert convolution_base.energy.unit == "meV" assert convolution_base._sample_components is None assert convolution_base._resolution_components is None @pytest.mark.parametrize( - 'kwargs, expected_message', + "kwargs, expected_message", [ ( { - 'energy': 'invalid', - 'sample_components': ComponentCollection(), - 'resolution_components': ComponentCollection(), - 'energy_unit': 'meV', + "energy": "invalid", + "sample_components": ComponentCollection(), + "resolution_components": ComponentCollection(), + "energy_unit": "meV", + "energy_offset": 0, }, - 'Energy must be', + "Energy must be", ), ( { - 'energy': np.linspace(-10, 10, 100), - 'sample_components': 'invalid', - 'resolution_components': ComponentCollection(), - 'energy_unit': 'meV', + "energy": np.linspace(-10, 10, 100), + "sample_components": "invalid", + "resolution_components": ComponentCollection(), + "energy_unit": "meV", + "energy_offset": 0, }, ( - '`sample_components` is an instance of str, ' - 'but must be a ComponentCollection or ModelComponent.' + "`sample_components` is an instance of str, " + "but must be a ComponentCollection or ModelComponent." ), ), ( { - 'energy': np.linspace(-10, 10, 100), - 'sample_components': ComponentCollection(), - 'resolution_components': 'invalid', - 'energy_unit': 'meV', + "energy": np.linspace(-10, 10, 100), + "sample_components": ComponentCollection(), + "resolution_components": "invalid", + "energy_unit": "meV", + "energy_offset": 0, }, ( - '`resolution_components` is an instance of str, ' - 'but must be a ComponentCollection or ModelComponent.' + "`resolution_components` is an instance of str, " + "but must be a ComponentCollection or ModelComponent." ), ), ( { - 'energy': np.linspace(-10, 10, 100), - 'sample_components': ComponentCollection(), - 'resolution_components': ComponentCollection(), - 'energy_unit': 123, + "energy": np.linspace(-10, 10, 100), + "sample_components": ComponentCollection(), + "resolution_components": ComponentCollection(), + "energy_unit": 123, + "energy_offset": 0, + }, + "Energy_unit must be ", + ), + ( + { + "energy": np.linspace(-10, 10, 100), + "sample_components": ComponentCollection(), + "resolution_components": ComponentCollection(), + "energy_unit": "meV", + "energy_offset": "invalid", }, - 'Energy_unit must be ', + "Energy_offset must be ", ), ], ) @@ -99,26 +138,26 @@ def test_input_type_validation_raises(self, kwargs, expected_message): ConvolutionBase(**kwargs) @pytest.mark.parametrize( - 'energy, expected_energy', + "energy, expected_energy", [ ( 1, - sc.array(dims=['energy'], values=[1.0], unit='meV'), + sc.array(dims=["energy"], values=[1.0], unit="meV"), ), ( 1.0, - sc.array(dims=['energy'], values=[1.0], unit='meV'), + sc.array(dims=["energy"], values=[1.0], unit="meV"), ), ( np.linspace(-5, 5, 50), - sc.array(dims=['energy'], values=np.linspace(-5, 5, 50), unit='meV'), + sc.array(dims=["energy"], values=np.linspace(-5, 5, 50), unit="meV"), ), ( - sc.array(dims=['energy'], values=np.linspace(-5, 5, 50), unit='meV'), - sc.array(dims=['energy'], values=np.linspace(-5, 5, 50), unit='meV'), + sc.array(dims=["energy"], values=np.linspace(-5, 5, 50), unit="meV"), + sc.array(dims=["energy"], values=np.linspace(-5, 5, 50), unit="meV"), ), ], - ids=['int', 'float', 'np.ndarray', 'scipp.Variable'], + ids=["int", "float", "np.ndarray", "scipp.Variable"], ) def test_energy_setter(self, convolution_base, energy, expected_energy): # WHEN @@ -131,46 +170,73 @@ def test_energy_setter_invalid_type_raises(self, convolution_base): # WHEN THEN EXPECT with pytest.raises( TypeError, - match='Energy must be a Number, a numpy ndarray or a scipp Variable.', + match="Energy must be a Number, a numpy ndarray or a scipp Variable.", ): - convolution_base.energy = 'invalid' + convolution_base.energy = "invalid" def test_energy_unit_property(self, convolution_base): # WHEN THEN EXPECT - assert convolution_base.energy.unit == 'meV' + assert convolution_base.energy.unit == "meV" def test_energy_unit_setter_raises(self, convolution_base): # WHEN THEN EXPECT with pytest.raises( AttributeError, - match='Use convert_unit to change the unit between allowed types ', + match="Use convert_unit to change the unit between allowed types ", ): - convolution_base.energy_unit = 'K' + convolution_base.energy_unit = "K" def test_convert_energy_unit(self, convolution_base): # WHEN THEN - convolution_base.convert_energy_unit('eV') + convolution_base.convert_energy_unit("eV") # EXPECT - assert convolution_base.energy.unit == 'eV' - assert convolution_base.energy_unit == 'eV' - assert np.allclose(convolution_base.energy.values, np.linspace(-0.01, 0.01, 100)) + assert convolution_base.energy.unit == "eV" + assert convolution_base.energy_unit == "eV" + assert np.allclose( + convolution_base.energy.values, np.linspace(-0.01, 0.01, 100) + ) def test_convert_energy_unit_invalid_type_raises(self, convolution_base): # WHEN THEN EXPECT with pytest.raises( TypeError, - match='Energy unit must be a string or scipp unit.', + match="Energy unit must be a string or scipp unit.", ): convolution_base.convert_energy_unit(123) + def test_energy_offset_property(self, convolution_base): + # WHEN THEN EXPECT + assert convolution_base.energy_offset.value == 0 + + # THEN + convolution_base.energy_offset = 5 + assert convolution_base.energy_offset.value == 5 + + # THEN + convolution_base.energy_offset = Parameter( + name="energy_offset", value=10, unit="meV" + ) + assert convolution_base.energy_offset.value == 10 + assert convolution_base.energy_offset.unit == "meV" + + def test_energy_with_offset_setter_raises(self, convolution_base): + # WHEN THEN EXPECT + with pytest.raises( + AttributeError, + match="is a read-only property", + ): + convolution_base.energy_with_offset = 5 + def test_sample_components_property(self, convolution_base): # WHEN THEN EXPECT assert isinstance(convolution_base.sample_components, ComponentCollection) def test_sample_components_setter(self, convolution_base): # WHEN - new_sample_components = ComponentCollection(display_name='NewComponentCollection') + new_sample_components = ComponentCollection( + display_name="NewComponentCollection" + ) # THEN convolution_base.sample_components = new_sample_components @@ -183,11 +249,11 @@ def test_sample_components_setter_invalid_type_raises(self, convolution_base): with pytest.raises( TypeError, match=( - '`sample_components` is an instance of str, ' - 'but must be a ComponentCollection or ModelComponent.' + "`sample_components` is an instance of str, " + "but must be a ComponentCollection or ModelComponent." ), ): - convolution_base.sample_components = 'invalid' + convolution_base.sample_components = "invalid" def test_resolution_components_property(self, convolution_base): # WHEN THEN EXPECT @@ -195,7 +261,9 @@ def test_resolution_components_property(self, convolution_base): def test_resolution_components_setter(self, convolution_base): # WHEN - new_resolution_components = ComponentCollection(display_name='NewResolutionModel') + new_resolution_components = ComponentCollection( + display_name="NewResolutionModel" + ) # THEN convolution_base.resolution_components = new_resolution_components @@ -207,8 +275,8 @@ def test_resolution_components_setter_invalid_type_raises(self, convolution_base with pytest.raises( TypeError, match=( - '`resolution_components` is an instance of str, ' - 'but must be a ComponentCollection or ModelComponent.' + "`resolution_components` is an instance of str, " + "but must be a ComponentCollection or ModelComponent." ), ): - convolution_base.resolution_components = 'invalid' + convolution_base.resolution_components = "invalid" From 703d8240fca14466e8ce4098db00bad2c2cb8a41 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Thu, 12 Feb 2026 15:11:52 +0100 Subject: [PATCH 18/27] More missing tests --- src/easydynamics/analysis/analysis1d.py | 1 + .../convolution/convolution_base.py | 46 +++--- src/easydynamics/experiment/experiment.py | 12 +- .../convolution/test_convolution_base.py | 140 ++++++++---------- .../experiment/test_experiment.py | 2 + .../sample_model/test_instrument_model.py | 17 +++ .../sample_model/test_model_base.py | 14 ++ .../sample_model/test_sample_model.py | 8 + 8 files changed, 131 insertions(+), 109 deletions(-) diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index aa5114ee..1bac582c 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -38,6 +38,7 @@ def __init__( experiment=experiment, sample_model=sample_model, instrument_model=instrument_model, + extra_parameters=extra_parameters, ) self._Q_index = self._verify_Q_index(Q_index) diff --git a/src/easydynamics/convolution/convolution_base.py b/src/easydynamics/convolution/convolution_base.py index db8c069b..fd8d92bd 100644 --- a/src/easydynamics/convolution/convolution_base.py +++ b/src/easydynamics/convolution/convolution_base.py @@ -30,28 +30,28 @@ def __init__( energy: np.ndarray | sc.Variable, sample_components: ComponentCollection | ModelComponent = None, resolution_components: ComponentCollection | ModelComponent = None, - energy_unit: str | sc.Unit = "meV", + energy_unit: str | sc.Unit = 'meV', energy_offset: Numeric | Parameter = 0.0, ): if isinstance(energy, Numeric): energy = np.array([float(energy)]) if not isinstance(energy, (np.ndarray, sc.Variable)): - raise TypeError("Energy must be a numpy ndarray or a scipp Variable.") + raise TypeError('Energy must be a numpy ndarray or a scipp Variable.') if not isinstance(energy_unit, (str, sc.Unit)): - raise TypeError("Energy_unit must be a string or sc.Unit.") + raise TypeError('Energy_unit must be a string or sc.Unit.') if isinstance(energy, np.ndarray): - energy = sc.array(dims=["energy"], values=energy, unit=energy_unit) + energy = sc.array(dims=['energy'], values=energy, unit=energy_unit) if isinstance(energy_offset, Numeric): energy_offset = Parameter( - name="energy_offset", value=float(energy_offset), unit=energy_unit + name='energy_offset', value=float(energy_offset), unit=energy_unit ) if not isinstance(energy_offset, Parameter): - raise TypeError("Energy_offset must be a number or a Parameter.") + raise TypeError('Energy_offset must be a number or a Parameter.') self._energy = energy self._energy_unit = energy_unit @@ -62,7 +62,7 @@ def __init__( or isinstance(sample_components, ModelComponent) ): raise TypeError( - f"`sample_components` is an instance of {type(sample_components).__name__}, but must be a ComponentCollection or ModelComponent." # noqa: E501 + f'`sample_components` is an instance of {type(sample_components).__name__}, but must be a ComponentCollection or ModelComponent.' # noqa: E501 ) if isinstance(sample_components, ModelComponent): sample_components = ComponentCollection(components=[sample_components]) @@ -73,12 +73,10 @@ def __init__( or isinstance(resolution_components, ModelComponent) ): raise TypeError( - f"`resolution_components` is an instance of {type(resolution_components).__name__}, but must be a ComponentCollection or ModelComponent." # noqa: E501 + f'`resolution_components` is an instance of {type(resolution_components).__name__}, but must be a ComponentCollection or ModelComponent.' # noqa: E501 ) if isinstance(resolution_components, ModelComponent): - resolution_components = ComponentCollection( - components=[resolution_components] - ) + resolution_components = ComponentCollection(components=[resolution_components]) self._resolution_components = resolution_components @property @@ -97,7 +95,7 @@ def energy_offset(self, energy_offset: Numeric | Parameter) -> None: TypeError: If energy_offset is not a number or a Parameter. """ if not isinstance(energy_offset, Parameter | Numeric): - raise TypeError("Energy_offset must be a number or a Parameter.") + raise TypeError('Energy_offset must be a number or a Parameter.') if isinstance(energy_offset, Numeric): self._energy_offset.value = float(energy_offset) @@ -118,7 +116,7 @@ def energy_with_offset(self, value) -> None: energy and energy_offset. """ raise AttributeError( - "Energy with offset is a read-only property derived from energy and energy_offset." + 'Energy with offset is a read-only property derived from energy and energy_offset.' ) @property @@ -144,14 +142,10 @@ def energy(self, energy: np.ndarray) -> None: energy = np.array([float(energy)]) if not isinstance(energy, (np.ndarray, sc.Variable)): - raise TypeError( - "Energy must be a Number, a numpy ndarray or a scipp Variable." - ) + raise TypeError('Energy must be a Number, a numpy ndarray or a scipp Variable.') if isinstance(energy, np.ndarray): - self._energy = sc.array( - dims=["energy"], values=energy, unit=self._energy.unit - ) + self._energy = sc.array(dims=['energy'], values=energy, unit=self._energy.unit) if isinstance(energy, sc.Variable): self._energy = energy @@ -166,8 +160,8 @@ def energy_unit(self) -> str: def energy_unit(self, unit_str: str) -> None: raise AttributeError( ( - f"Unit is read-only. Use convert_unit to change the unit between allowed types " - f"or create a new {self.__class__.__name__} with the desired unit." + f'Unit is read-only. Use convert_unit to change the unit between allowed types ' + f'or create a new {self.__class__.__name__} with the desired unit.' ) ) # noqa: E501 @@ -181,7 +175,7 @@ def convert_energy_unit(self, energy_unit: str | sc.Unit) -> None: TypeError: If energy_unit is not a string or scipp unit. """ if not isinstance(energy_unit, (str, sc.Unit)): - raise TypeError("Energy unit must be a string or scipp unit.") + raise TypeError('Energy unit must be a string or scipp unit.') self.energy = sc.to_unit(self.energy, energy_unit) self._energy_unit = energy_unit @@ -192,9 +186,7 @@ def sample_components(self) -> ComponentCollection | ModelComponent: return self._sample_components @sample_components.setter - def sample_components( - self, sample_components: ComponentCollection | ModelComponent - ) -> None: + def sample_components(self, sample_components: ComponentCollection | ModelComponent) -> None: """Set the sample model. Args: sample_components : ComponentCollection or ModelComponent @@ -206,7 +198,7 @@ def sample_components( """ if not isinstance(sample_components, (ComponentCollection, ModelComponent)): raise TypeError( - f"`sample_components` is an instance of {type(sample_components).__name__}, but must be a ComponentCollection or ModelComponent." # noqa: E501 + f'`sample_components` is an instance of {type(sample_components).__name__}, but must be a ComponentCollection or ModelComponent.' # noqa: E501 ) self._sample_components = sample_components @@ -231,6 +223,6 @@ def resolution_components( """ if not isinstance(resolution_components, (ComponentCollection, ModelComponent)): raise TypeError( - f"`resolution_components` is an instance of {type(resolution_components).__name__}, but must be a ComponentCollection or ModelComponent." # noqa: E501 + f'`resolution_components` is an instance of {type(resolution_components).__name__}, but must be a ComponentCollection or ModelComponent.' # noqa: E501 ) self._resolution_components = resolution_components diff --git a/src/easydynamics/experiment/experiment.py b/src/easydynamics/experiment/experiment.py index ddc5ac5d..ff48706f 100644 --- a/src/easydynamics/experiment/experiment.py +++ b/src/easydynamics/experiment/experiment.py @@ -54,7 +54,7 @@ def data(self) -> sc.DataArray | None: return self._data @data.setter - def data(self, value: sc.DataArray): + def data(self, value: sc.DataArray) -> None: """Set the dataset associated with this experiment.""" if not isinstance(value, sc.DataArray): raise TypeError(f'Data must be a sc.DataArray, not {type(value).__name__}') @@ -70,7 +70,7 @@ def binned_data(self) -> sc.DataArray | None: return self._binned_data @binned_data.setter - def binned_data(self, value: sc.DataArray): + def binned_data(self, value: sc.DataArray) -> None: """Set the binned dataset associated with this experiment.""" raise AttributeError('binned_data is a read-only property. Use rebin() to rebin the data') @@ -78,25 +78,23 @@ def binned_data(self, value: sc.DataArray): def Q(self) -> sc.Variable | None: """Get the Q values from the dataset.""" if self._data is None: - # warnings.warn("No data loaded.", UserWarning) return None return self._binned_data.coords['Q'] @Q.setter - def Q(self, value: sc.Variable): + def Q(self, value: sc.Variable) -> None: """Set the Q values for the dataset.""" raise AttributeError('Q is a read-only property derived from the data.') @property - def energy(self) -> sc.Variable: + def energy(self) -> sc.Variable | None: """Get the energy values from the dataset.""" if self._data is None: - # warnings.warn("No data loaded.", UserWarning) return None return self._binned_data.coords['energy'] @energy.setter - def energy(self, value: sc.Variable): + def energy(self, value: sc.Variable) -> None: """Set the energy values for the dataset.""" raise AttributeError('energy is a read-only property derived from the data.') diff --git a/tests/unit/easydynamics/convolution/test_convolution_base.py b/tests/unit/easydynamics/convolution/test_convolution_base.py index 1cc604e0..5dd893fb 100644 --- a/tests/unit/easydynamics/convolution/test_convolution_base.py +++ b/tests/unit/easydynamics/convolution/test_convolution_base.py @@ -15,8 +15,8 @@ class TestConvolutionBase: @pytest.fixture def convolution_base(self): energy = np.linspace(-10, 10, 100) - sample_components = ComponentCollection(display_name="ComponentCollection") - resolution_components = ComponentCollection(display_name="ResolutionModel") + sample_components = ComponentCollection(display_name='ComponentCollection') + resolution_components = ComponentCollection(display_name='ResolutionModel') return ConvolutionBase( energy=energy, @@ -51,9 +51,7 @@ def test_init_with_model_component(self): assert isinstance(convolution_base.sample_components, ComponentCollection) assert isinstance(convolution_base.resolution_components, ComponentCollection) assert convolution_base.sample_components.components[0] == sample_component - assert ( - convolution_base.resolution_components.components[0] == resolution_component - ) + assert convolution_base.resolution_components.components[0] == resolution_component def test_init_energy_numerical_none_offset(self): # WHEN @@ -67,68 +65,68 @@ def test_init_energy_numerical_none_offset(self): assert isinstance(convolution_base, ConvolutionBase) assert isinstance(convolution_base.energy, sc.Variable) assert convolution_base.energy.values == np.array([1.0]) - assert convolution_base.energy.unit == "meV" + assert convolution_base.energy.unit == 'meV' assert convolution_base._sample_components is None assert convolution_base._resolution_components is None @pytest.mark.parametrize( - "kwargs, expected_message", + 'kwargs, expected_message', [ ( { - "energy": "invalid", - "sample_components": ComponentCollection(), - "resolution_components": ComponentCollection(), - "energy_unit": "meV", - "energy_offset": 0, + 'energy': 'invalid', + 'sample_components': ComponentCollection(), + 'resolution_components': ComponentCollection(), + 'energy_unit': 'meV', + 'energy_offset': 0, }, - "Energy must be", + 'Energy must be', ), ( { - "energy": np.linspace(-10, 10, 100), - "sample_components": "invalid", - "resolution_components": ComponentCollection(), - "energy_unit": "meV", - "energy_offset": 0, + 'energy': np.linspace(-10, 10, 100), + 'sample_components': 'invalid', + 'resolution_components': ComponentCollection(), + 'energy_unit': 'meV', + 'energy_offset': 0, }, ( - "`sample_components` is an instance of str, " - "but must be a ComponentCollection or ModelComponent." + '`sample_components` is an instance of str, ' + 'but must be a ComponentCollection or ModelComponent.' ), ), ( { - "energy": np.linspace(-10, 10, 100), - "sample_components": ComponentCollection(), - "resolution_components": "invalid", - "energy_unit": "meV", - "energy_offset": 0, + 'energy': np.linspace(-10, 10, 100), + 'sample_components': ComponentCollection(), + 'resolution_components': 'invalid', + 'energy_unit': 'meV', + 'energy_offset': 0, }, ( - "`resolution_components` is an instance of str, " - "but must be a ComponentCollection or ModelComponent." + '`resolution_components` is an instance of str, ' + 'but must be a ComponentCollection or ModelComponent.' ), ), ( { - "energy": np.linspace(-10, 10, 100), - "sample_components": ComponentCollection(), - "resolution_components": ComponentCollection(), - "energy_unit": 123, - "energy_offset": 0, + 'energy': np.linspace(-10, 10, 100), + 'sample_components': ComponentCollection(), + 'resolution_components': ComponentCollection(), + 'energy_unit': 123, + 'energy_offset': 0, }, - "Energy_unit must be ", + 'Energy_unit must be ', ), ( { - "energy": np.linspace(-10, 10, 100), - "sample_components": ComponentCollection(), - "resolution_components": ComponentCollection(), - "energy_unit": "meV", - "energy_offset": "invalid", + 'energy': np.linspace(-10, 10, 100), + 'sample_components': ComponentCollection(), + 'resolution_components': ComponentCollection(), + 'energy_unit': 'meV', + 'energy_offset': 'invalid', }, - "Energy_offset must be ", + 'Energy_offset must be ', ), ], ) @@ -138,26 +136,26 @@ def test_input_type_validation_raises(self, kwargs, expected_message): ConvolutionBase(**kwargs) @pytest.mark.parametrize( - "energy, expected_energy", + 'energy, expected_energy', [ ( 1, - sc.array(dims=["energy"], values=[1.0], unit="meV"), + sc.array(dims=['energy'], values=[1.0], unit='meV'), ), ( 1.0, - sc.array(dims=["energy"], values=[1.0], unit="meV"), + sc.array(dims=['energy'], values=[1.0], unit='meV'), ), ( np.linspace(-5, 5, 50), - sc.array(dims=["energy"], values=np.linspace(-5, 5, 50), unit="meV"), + sc.array(dims=['energy'], values=np.linspace(-5, 5, 50), unit='meV'), ), ( - sc.array(dims=["energy"], values=np.linspace(-5, 5, 50), unit="meV"), - sc.array(dims=["energy"], values=np.linspace(-5, 5, 50), unit="meV"), + sc.array(dims=['energy'], values=np.linspace(-5, 5, 50), unit='meV'), + sc.array(dims=['energy'], values=np.linspace(-5, 5, 50), unit='meV'), ), ], - ids=["int", "float", "np.ndarray", "scipp.Variable"], + ids=['int', 'float', 'np.ndarray', 'scipp.Variable'], ) def test_energy_setter(self, convolution_base, energy, expected_energy): # WHEN @@ -170,38 +168,36 @@ def test_energy_setter_invalid_type_raises(self, convolution_base): # WHEN THEN EXPECT with pytest.raises( TypeError, - match="Energy must be a Number, a numpy ndarray or a scipp Variable.", + match='Energy must be a Number, a numpy ndarray or a scipp Variable.', ): - convolution_base.energy = "invalid" + convolution_base.energy = 'invalid' def test_energy_unit_property(self, convolution_base): # WHEN THEN EXPECT - assert convolution_base.energy.unit == "meV" + assert convolution_base.energy.unit == 'meV' def test_energy_unit_setter_raises(self, convolution_base): # WHEN THEN EXPECT with pytest.raises( AttributeError, - match="Use convert_unit to change the unit between allowed types ", + match='Use convert_unit to change the unit between allowed types ', ): - convolution_base.energy_unit = "K" + convolution_base.energy_unit = 'K' def test_convert_energy_unit(self, convolution_base): # WHEN THEN - convolution_base.convert_energy_unit("eV") + convolution_base.convert_energy_unit('eV') # EXPECT - assert convolution_base.energy.unit == "eV" - assert convolution_base.energy_unit == "eV" - assert np.allclose( - convolution_base.energy.values, np.linspace(-0.01, 0.01, 100) - ) + assert convolution_base.energy.unit == 'eV' + assert convolution_base.energy_unit == 'eV' + assert np.allclose(convolution_base.energy.values, np.linspace(-0.01, 0.01, 100)) def test_convert_energy_unit_invalid_type_raises(self, convolution_base): # WHEN THEN EXPECT with pytest.raises( TypeError, - match="Energy unit must be a string or scipp unit.", + match='Energy unit must be a string or scipp unit.', ): convolution_base.convert_energy_unit(123) @@ -214,17 +210,15 @@ def test_energy_offset_property(self, convolution_base): assert convolution_base.energy_offset.value == 5 # THEN - convolution_base.energy_offset = Parameter( - name="energy_offset", value=10, unit="meV" - ) + convolution_base.energy_offset = Parameter(name='energy_offset', value=10, unit='meV') assert convolution_base.energy_offset.value == 10 - assert convolution_base.energy_offset.unit == "meV" + assert convolution_base.energy_offset.unit == 'meV' def test_energy_with_offset_setter_raises(self, convolution_base): # WHEN THEN EXPECT with pytest.raises( AttributeError, - match="is a read-only property", + match='is a read-only property', ): convolution_base.energy_with_offset = 5 @@ -234,9 +228,7 @@ def test_sample_components_property(self, convolution_base): def test_sample_components_setter(self, convolution_base): # WHEN - new_sample_components = ComponentCollection( - display_name="NewComponentCollection" - ) + new_sample_components = ComponentCollection(display_name='NewComponentCollection') # THEN convolution_base.sample_components = new_sample_components @@ -249,11 +241,11 @@ def test_sample_components_setter_invalid_type_raises(self, convolution_base): with pytest.raises( TypeError, match=( - "`sample_components` is an instance of str, " - "but must be a ComponentCollection or ModelComponent." + '`sample_components` is an instance of str, ' + 'but must be a ComponentCollection or ModelComponent.' ), ): - convolution_base.sample_components = "invalid" + convolution_base.sample_components = 'invalid' def test_resolution_components_property(self, convolution_base): # WHEN THEN EXPECT @@ -261,9 +253,7 @@ def test_resolution_components_property(self, convolution_base): def test_resolution_components_setter(self, convolution_base): # WHEN - new_resolution_components = ComponentCollection( - display_name="NewResolutionModel" - ) + new_resolution_components = ComponentCollection(display_name='NewResolutionModel') # THEN convolution_base.resolution_components = new_resolution_components @@ -275,8 +265,8 @@ def test_resolution_components_setter_invalid_type_raises(self, convolution_base with pytest.raises( TypeError, match=( - "`resolution_components` is an instance of str, " - "but must be a ComponentCollection or ModelComponent." + '`resolution_components` is an instance of str, ' + 'but must be a ComponentCollection or ModelComponent.' ), ): - convolution_base.resolution_components = "invalid" + convolution_base.resolution_components = 'invalid' diff --git a/tests/unit/easydynamics/experiment/test_experiment.py b/tests/unit/easydynamics/experiment/test_experiment.py index 8da97ce0..b62e3305 100644 --- a/tests/unit/easydynamics/experiment/test_experiment.py +++ b/tests/unit/easydynamics/experiment/test_experiment.py @@ -73,6 +73,8 @@ def test_init_no_data(self): # THEN EXPECT assert experiment.display_name == 'empty_experiment' assert experiment._data is None + assert experiment.energy is None + assert experiment.Q is None def test_init_invalid_data(self): "Test initialization with invalid data type" diff --git a/tests/unit/easydynamics/sample_model/test_instrument_model.py b/tests/unit/easydynamics/sample_model/test_instrument_model.py index 00f036cd..f7df8a4d 100644 --- a/tests/unit/easydynamics/sample_model/test_instrument_model.py +++ b/tests/unit/easydynamics/sample_model/test_instrument_model.py @@ -189,6 +189,23 @@ def test_energy_offset_setter_raises(self, instrument_model): ): instrument_model.energy_offset = 'invalid_offset' + def test_get_energy_offset_at_Q(self, instrument_model): + # WHEN + + # THEN + offset_at_Q0 = instrument_model.get_energy_offset_at_Q(0) + + # EXPECT + assert offset_at_Q0.value == instrument_model.energy_offset.value + + def test_get_energy_offset_at_Q_invalid_index_raises(self, instrument_model): + # WHEN / THEN / EXPECT + with pytest.raises( + IndexError, + match='Q_index 5 is out of bounds', + ): + instrument_model.get_energy_offset_at_Q(5) + def test_convert_unit_calls_all_children(self, instrument_model): # WHEN new_unit = 'eV' diff --git a/tests/unit/easydynamics/sample_model/test_model_base.py b/tests/unit/easydynamics/sample_model/test_model_base.py index 2eec4321..ac61af0a 100644 --- a/tests/unit/easydynamics/sample_model/test_model_base.py +++ b/tests/unit/easydynamics/sample_model/test_model_base.py @@ -174,6 +174,20 @@ def test_get_all_variables_with_nonint_Q_index_raises(self, model_base): ): model_base.get_all_variables(Q_index='invalid_index') + def test_get_component_collection(self, model_base): + # WHEN THEN + collection = model_base.get_component_collection(Q_index=0) + # EXPECT + assert collection is model_base._component_collections[0] + + def test_get_component_collection_invalid_index_raises(self, model_base): + # WHEN THEN EXPECT + with pytest.raises( + IndexError, + match='Q_index 5 is out of bounds for ', + ): + model_base.get_component_collection(Q_index=5) + def test_append_and_remove_and_clear_component(self, model_base): # WHEN new_component = Gaussian(unique_name='NewGaussian') diff --git a/tests/unit/easydynamics/sample_model/test_sample_model.py b/tests/unit/easydynamics/sample_model/test_sample_model.py index e5f7a9a7..16919c91 100644 --- a/tests/unit/easydynamics/sample_model/test_sample_model.py +++ b/tests/unit/easydynamics/sample_model/test_sample_model.py @@ -98,6 +98,14 @@ def test_init_raises_with_invalid_temperature(self): ): SampleModel(temperature='invalid_temperature') + def test_init_raises_with_negative_temperature(self): + # WHEN / THEN / EXPECT + with pytest.raises( + ValueError, + match='temperature must be non-negative', + ): + SampleModel(temperature=-5.0) + def test_init_raises_with_invalid_divide_by_temperature(self): # WHEN / THEN / EXPECT with pytest.raises( From e343767451850ff6369fad41f9915e687f5c1f2d Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Thu, 12 Feb 2026 15:53:38 +0100 Subject: [PATCH 19/27] test analysis_base --- src/easydynamics/analysis/analysis_base.py | 42 +-- .../analysis/test_analysis_base.py | 280 ++++++++++++++++++ .../convolution/test_numerical_convolution.py | 41 ++- 3 files changed, 332 insertions(+), 31 deletions(-) create mode 100644 tests/unit/easydynamics/analysis/test_analysis_base.py diff --git a/src/easydynamics/analysis/analysis_base.py b/src/easydynamics/analysis/analysis_base.py index f855e5da..fb74dcf2 100644 --- a/src/easydynamics/analysis/analysis_base.py +++ b/src/easydynamics/analysis/analysis_base.py @@ -16,7 +16,7 @@ class AnalysisBase(EasyScienceModelBase): def __init__( self, - display_name: str = 'MyAnalysis', + display_name: str = "MyAnalysis", unique_name: str | None = None, experiment: Experiment | None = None, sample_model: SampleModel | None = None, @@ -30,21 +30,23 @@ def __init__( elif isinstance(experiment, Experiment): self._experiment = experiment else: - raise TypeError('experiment must be an instance of Experiment or None.') + raise TypeError("experiment must be an instance of Experiment or None.") if sample_model is None: self._sample_model = SampleModel() elif isinstance(sample_model, SampleModel): self._sample_model = sample_model else: - raise TypeError('sample_model must be an instance of SampleModel or None.') + raise TypeError("sample_model must be an instance of SampleModel or None.") if instrument_model is None: self._instrument_model = InstrumentModel() elif isinstance(instrument_model, InstrumentModel): self._instrument_model = instrument_model else: - raise TypeError('instrument_model must be an instance of InstrumentModel or None.') + raise TypeError( + "instrument_model must be an instance of InstrumentModel or None." + ) if extra_parameters is not None: if isinstance(extra_parameters, Parameter): @@ -54,7 +56,9 @@ def __init__( ): self._extra_parameters = extra_parameters else: - raise TypeError('extra_parameters must be a Parameter or a list of Parameters.') + raise TypeError( + "extra_parameters must be a Parameter or a list of Parameters." + ) else: self._extra_parameters = [] @@ -72,7 +76,7 @@ def experiment(self) -> Experiment | None: @experiment.setter def experiment(self, value: Experiment) -> None: if not isinstance(value, Experiment): - raise TypeError('experiment must be an instance of Experiment') + raise TypeError("experiment must be an instance of Experiment") self._experiment = value self._on_experiment_changed() @@ -84,7 +88,7 @@ def sample_model(self) -> SampleModel: @sample_model.setter def sample_model(self, value: SampleModel) -> None: if not isinstance(value, SampleModel): - raise TypeError('sample_model must be an instance of SampleModel') + raise TypeError("sample_model must be an instance of SampleModel") self._sample_model = value self._on_sample_model_changed() @@ -96,7 +100,7 @@ def instrument_model(self) -> InstrumentModel: @instrument_model.setter def instrument_model(self, value: InstrumentModel) -> None: if not isinstance(value, InstrumentModel): - raise TypeError('instrument_model must be an instance of InstrumentModel') + raise TypeError("instrument_model must be an instance of InstrumentModel") self._instrument_model = value self._on_instrument_model_changed() @@ -108,7 +112,7 @@ def Q(self) -> sc.Variable | None: @Q.setter def Q(self, value) -> None: """Q is a read-only property derived from the Experiment.""" - raise AttributeError('Q is a read-only property derived from the Experiment.') + raise AttributeError("Q is a read-only property derived from the Experiment.") @property def energy(self) -> sc.Variable | None: @@ -122,7 +126,9 @@ def energy(self, value) -> None: """Energy is a read-only property derived from the Experiment. """ - raise AttributeError('energy is a read-only property derived from the Experiment.') + raise AttributeError( + "energy is a read-only property derived from the Experiment." + ) @property def temperature(self) -> Parameter | None: @@ -136,7 +142,9 @@ def temperature(self, value) -> None: """Temperature is a read-only property derived from the SampleModel. """ - raise AttributeError('temperature is a read-only property derived from the sample model.') + raise AttributeError( + "temperature is a read-only property derived from the SampleModel." + ) ############# # Other methods @@ -150,20 +158,20 @@ def _on_experiment_changed(self) -> None: """Update the Q values in the sample and instrument models when the experiment changes. """ - self._sample_model.Q = self.Q - self._instrument_model.Q = self.Q + self.sample_model.Q = self.Q + self.instrument_model.Q = self.Q def _on_sample_model_changed(self) -> None: """Update the Q values in the sample model when the sample model changes. """ - self._sample_model.Q = self.Q + self.sample_model.Q = self.Q def _on_instrument_model_changed(self) -> None: """Update the Q values in the instrument model when the instrument model changes. """ - self._instrument_model.Q = self.Q + self.instrument_model.Q = self.Q def _verify_Q_index(self, Q_index: int | None) -> int | None: """Verify that the Q index is valid. @@ -181,7 +189,7 @@ def _verify_Q_index(self, Q_index: int | None) -> int | None: or Q_index < 0 or (self.Q is not None and Q_index >= len(self.Q)) ): - raise ValueError('Q_index must be a valid index for the Q values.') + raise ValueError("Q_index must be a valid index for the Q values.") return Q_index ############# @@ -189,4 +197,4 @@ def _verify_Q_index(self, Q_index: int | None) -> int | None: ############# def __repr__(self) -> str: - return f'AnalysisBase(display_name={self.display_name}, unique_name={self.unique_name})' + return f"AnalysisBase(display_name={self.display_name}, unique_name={self.unique_name})" diff --git a/tests/unit/easydynamics/analysis/test_analysis_base.py b/tests/unit/easydynamics/analysis/test_analysis_base.py new file mode 100644 index 00000000..c81bcbda --- /dev/null +++ b/tests/unit/easydynamics/analysis/test_analysis_base.py @@ -0,0 +1,280 @@ +# from unittest.mock import Mock + +from unittest.mock import PropertyMock +from unittest.mock import patch + +import numpy as np +import pytest + +from easydynamics.analysis.analysis_base import AnalysisBase +from easydynamics.experiment import Experiment +from easydynamics.sample_model import InstrumentModel +from easydynamics.sample_model import SampleModel + + +class TestAnalysisBase: + @pytest.fixture + def analysis_base(self): + experiment = Experiment() + sample_model = SampleModel() + instrument_model = InstrumentModel() + analysis_base = AnalysisBase( + display_name="TestAnalysis", + experiment=experiment, + sample_model=sample_model, + instrument_model=instrument_model, + ) + return analysis_base + + def test_init(self, analysis_base): + # WHEN THEN + + # EXPECT + assert analysis_base.display_name == "TestAnalysis" + assert isinstance(analysis_base._experiment, Experiment) + assert isinstance(analysis_base._sample_model, SampleModel) + assert isinstance(analysis_base._instrument_model, InstrumentModel) + assert analysis_base._extra_parameters == [] + + def test_init_calls_on_experiment_changed(self): + with patch.object( + AnalysisBase, "_on_experiment_changed" + ) as mock_on_experiment_changed: + AnalysisBase() + mock_on_experiment_changed.assert_called_once() + + @pytest.mark.parametrize( + "kwargs, expected_exception, expected_message", + [ + ( + {"experiment": 123}, + TypeError, + "experiment must be an instance of Experiment", + ), + ( + {"sample_model": "not a model"}, + TypeError, + "sample_model must be an instance of SampleModel", + ), + ( + {"instrument_model": "not a model"}, + TypeError, + "instrument_model must be an instance of InstrumentModel", + ), + ( + {"extra_parameters": 123}, + TypeError, + "extra_parameters must be a Parameter or a list of Parameters.", + ), + ( + {"extra_parameters": [123]}, + TypeError, + "extra_parameters must be a Parameter or a list of Parameters.", + ), + ], + ids=[ + "invalid experiment", + "invalid sample_model", + "invalid instrument_model", + "invalid extra_parameters", + "invalid extra_parameters list", + ], + ) + def test_init_invalid_inputs(self, kwargs, expected_exception, expected_message): + with pytest.raises(expected_exception, match=expected_message): + AnalysisBase(**kwargs) + + def test_experiment_setter_calls_on_experiment_changed(self, analysis_base): + with patch.object( + analysis_base, "_on_experiment_changed" + ) as mock_on_experiment_changed: + new_experiment = Experiment() + analysis_base.experiment = new_experiment + mock_on_experiment_changed.assert_called_once() + + def test_experiment_setter_invalid_type(self, analysis_base): + with pytest.raises( + TypeError, match="experiment must be an instance of Experiment" + ): + analysis_base.experiment = "not an experiment" + + def test_experiment_setter_valid(self, analysis_base): + new_experiment = Experiment() + analysis_base.experiment = new_experiment + assert analysis_base.experiment == new_experiment + + def test_sample_model_setter_invalid_type(self, analysis_base): + with pytest.raises( + TypeError, match="sample_model must be an instance of SampleModel" + ): + analysis_base.sample_model = "not a sample model" + + def test_sample_model_setter_valid(self, analysis_base): + new_sample_model = SampleModel() + analysis_base.sample_model = new_sample_model + assert analysis_base.sample_model == new_sample_model + + def test_sample_model_setter_calls_on_sample_model_changed(self, analysis_base): + with patch.object( + analysis_base, "_on_sample_model_changed" + ) as mock_on_sample_model_changed: + new_sample_model = SampleModel() + analysis_base.sample_model = new_sample_model + mock_on_sample_model_changed.assert_called_once() + + def test_instrument_model_setter_invalid_type(self, analysis_base): + with pytest.raises( + TypeError, match="instrument_model must be an instance of InstrumentModel" + ): + analysis_base.instrument_model = "not an instrument model" + + def test_instrument_model_setter_valid(self, analysis_base): + new_instrument_model = InstrumentModel() + analysis_base.instrument_model = new_instrument_model + assert analysis_base.instrument_model == new_instrument_model + + def test_instrument_model_setter_calls_on_instrument_model_changed( + self, analysis_base + ): + with patch.object( + analysis_base, "_on_instrument_model_changed" + ) as mock_on_instrument_model_changed: + new_instrument_model = InstrumentModel() + analysis_base.instrument_model = new_instrument_model + mock_on_instrument_model_changed.assert_called_once() + + def test_Q_property(self, analysis_base): + # Create a mock Q value + fake_Q = [1, 2, 3] + + # Patch the 'experiment' attribute's Q property + with patch.object( + type(analysis_base.experiment), "Q", new_callable=PropertyMock + ) as mock_Q: + mock_Q.return_value = fake_Q + result = analysis_base.Q # Access the property + assert result == fake_Q + mock_Q.assert_called_once() + + def test_Q_setter_raises(self, analysis_base): + with pytest.raises( + AttributeError, + match="Q is a read-only property derived from the Experiment.", + ): + analysis_base.Q = [1, 2, 3] + + def test_energy_property(self, analysis_base): + # Create a mock energy value + fake_energy = [10, 20, 30] + + # Patch the 'experiment' attribute's energy property + with patch.object( + type(analysis_base.experiment), "energy", new_callable=PropertyMock + ) as mock_energy: + mock_energy.return_value = fake_energy + result = analysis_base.energy # Access the property + assert result == fake_energy + mock_energy.assert_called_once() + + def test_energy_setter_raises(self, analysis_base): + with pytest.raises( + AttributeError, + match="energy is a read-only property derived from the Experiment.", + ): + analysis_base.energy = [10, 20, 30] + + def test_temperature_property_no_temperature(self, analysis_base): + # Patch the 'experiment' attribute's temperature property to + # return None + with patch.object( + type(analysis_base.sample_model), "temperature", new_callable=PropertyMock + ) as mock_temperature: + mock_temperature.return_value = None + result = analysis_base.temperature # Access the property + assert result is None + mock_temperature.assert_called_once() + + def test_temperature_property(self, analysis_base): + # Create a mock temperature value + fake_temperature = 300 + + # Patch the 'sample_model' attribute's temperature property + with patch.object( + type(analysis_base.sample_model), "temperature", new_callable=PropertyMock + ) as mock_temperature: + mock_temperature.return_value = fake_temperature + result = analysis_base.temperature # Access the property + assert result == fake_temperature + mock_temperature.assert_called_once() + + def test_temperature_setter_raises(self, analysis_base): + with pytest.raises( + AttributeError, + match="temperature is a read-only property", + ): + analysis_base.temperature = 300 + + def test_on_experiment_changed_updates_Q(self, analysis_base): + # WHEN + fake_Q = [1, 2, 3] + + # Patch the Q property of analysis_base + with patch.object( + type(analysis_base.experiment), "Q", new_callable=PropertyMock + ) as mock_Q: + mock_Q.return_value = fake_Q + + # THEN + analysis_base._on_experiment_changed() + + # EXPECT + # assert that the Q attribute was set + np.testing.assert_array_equal(analysis_base.Q, fake_Q) + np.testing.assert_array_equal(analysis_base.sample_model.Q, fake_Q) + np.testing.assert_array_equal(analysis_base.instrument_model.Q, fake_Q) + + def test_on_sample_model_changed_updates_Q(self, analysis_base): + # WHEN + fake_Q = [1, 2, 3] + + # Patch the Q property of analysis_base + with patch.object( + type(analysis_base.experiment), "Q", new_callable=PropertyMock + ) as mock_Q: + mock_Q.return_value = fake_Q + + # THEN + analysis_base._on_sample_model_changed() + + # EXPECT + np.testing.assert_array_equal(analysis_base.sample_model.Q, fake_Q) + + def test_on_instrument_model_changed_updates_Q(self, analysis_base): + fake_Q = [1, 2, 3] + + # Patch the Q property of analysis_base + with patch.object( + type(analysis_base.experiment), "Q", new_callable=PropertyMock + ) as mock_Q: + mock_Q.return_value = fake_Q + + analysis_base._on_instrument_model_changed() + np.testing.assert_array_equal(analysis_base.instrument_model.Q, fake_Q) + + def test_verify_Q_index_valid(self, analysis_base): + # WHEN + valid_Q_index = 0 + + # THEN + result = analysis_base._verify_Q_index(valid_Q_index) + + # EXPECT + assert result == valid_Q_index + + def test_verify_Q_index_invalid(self, analysis_base): + # WHEN + invalid_Q_index = -1 + + # THEN / EXPECT + with pytest.raises(ValueError, match="Q_index must be a valid index"): + analysis_base._verify_Q_index(invalid_Q_index) diff --git a/tests/unit/easydynamics/convolution/test_numerical_convolution.py b/tests/unit/easydynamics/convolution/test_numerical_convolution.py index e388f17f..503487f6 100644 --- a/tests/unit/easydynamics/convolution/test_numerical_convolution.py +++ b/tests/unit/easydynamics/convolution/test_numerical_convolution.py @@ -10,20 +10,22 @@ from easydynamics.convolution.numerical_convolution import NumericalConvolution from easydynamics.sample_model import Gaussian from easydynamics.sample_model.component_collection import ComponentCollection -from easydynamics.utils.detailed_balance import _detailed_balance_factor as detailed_balance_factor +from easydynamics.utils.detailed_balance import ( + _detailed_balance_factor as detailed_balance_factor, +) class TestNumericalConvolution: @pytest.fixture def default_numerical_convolution(self): energy = np.linspace(-10, 10, 5001) - sample_components = ComponentCollection(display_name='ComponentCollection') + sample_components = ComponentCollection(display_name="ComponentCollection") sample_components.append_component( - Gaussian(display_name='Gaussian1', area=2.0, center=0.1, width=0.4) + Gaussian(display_name="Gaussian1", area=2.0, center=0.1, width=0.4) ) - resolution_components = ComponentCollection(display_name='ResolutionModel') + resolution_components = ComponentCollection(display_name="ResolutionModel") resolution_components.append_component( - Gaussian(display_name='GaussianRes', area=3.0, center=0.2, width=0.5) + Gaussian(display_name="GaussianRes", area=3.0, center=0.2, width=0.5) ) return NumericalConvolution( @@ -40,19 +42,23 @@ def test_init(self, default_numerical_convolution): # WHEN THEN EXPECT assert isinstance(default_numerical_convolution, NumericalConvolution) assert isinstance(default_numerical_convolution.energy, sc.Variable) - assert np.allclose(default_numerical_convolution.energy.values, np.linspace(-10, 10, 5001)) - assert isinstance(default_numerical_convolution._sample_components, ComponentCollection) + assert np.allclose( + default_numerical_convolution.energy.values, np.linspace(-10, 10, 5001) + ) + assert isinstance( + default_numerical_convolution._sample_components, ComponentCollection + ) assert isinstance( default_numerical_convolution._resolution_components, ComponentCollection ) assert default_numerical_convolution.upsample_factor == 5 assert default_numerical_convolution.extension_factor == 0.2 assert default_numerical_convolution.temperature is None - assert default_numerical_convolution.energy_unit == 'meV' + assert default_numerical_convolution.energy_unit == "meV" assert default_numerical_convolution.normalize_detailed_balance is True assert isinstance(default_numerical_convolution._energy_grid, EnergyGrid) - @pytest.mark.parametrize('upsample_factor', [None, 5]) + @pytest.mark.parametrize("upsample_factor", [None, 5]) def test_convolution(self, default_numerical_convolution, upsample_factor): """ Test that convolution of two Gaussians produces the @@ -60,16 +66,19 @@ def test_convolution(self, default_numerical_convolution, upsample_factor): """ # WHEN THEN default_numerical_convolution.upsample_factor = upsample_factor + default_numerical_convolution.energy_offset = 0.4 result = default_numerical_convolution.convolution() # EXPECT - expected_area = 2.0 * 3.0 # area of sample_components * area of resolution_components + expected_area = ( + 2.0 * 3.0 + ) # area of sample_components * area of resolution_components expected_center = ( - 0.1 + 0.2 + 0.1 + 0.2 + 0.4 ) # center of sample_components + center of resolution_components expected_width = np.sqrt(0.4**2 + 0.5**2) # sqrt(width_sample^2 + width_res^2) expected_result = Gaussian( - display_name='ExpectedConvolution', + display_name="ExpectedConvolution", area=expected_area, center=expected_center, width=expected_width, @@ -98,8 +107,12 @@ def test_convolution_with_temperature( resolution_vals = default_numerical_convolution._resolution_components.evaluate( default_numerical_convolution.energy.values ) - DBF = detailed_balance_factor(energy=default_numerical_convolution.energy, temperature=5.0) - expected_result = fftconvolve(sample_valds * DBF, resolution_vals, mode='same') * ( + DBF = detailed_balance_factor( + energy=default_numerical_convolution.energy, temperature=5.0 + ) + expected_result = fftconvolve( + sample_valds * DBF, resolution_vals, mode="same" + ) * ( default_numerical_convolution.energy.values[1] - default_numerical_convolution.energy.values[0] ) From fb7682ccf9f7edddf6101da862d4b8560aeea131 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Thu, 12 Feb 2026 16:01:56 +0100 Subject: [PATCH 20/27] 100% coverage of base --- .../easydynamics/analysis/test_analysis1d.py | 280 ++++++++++++++++++ .../analysis/test_analysis_base.py | 14 + 2 files changed, 294 insertions(+) create mode 100644 tests/unit/easydynamics/analysis/test_analysis1d.py diff --git a/tests/unit/easydynamics/analysis/test_analysis1d.py b/tests/unit/easydynamics/analysis/test_analysis1d.py new file mode 100644 index 00000000..c81bcbda --- /dev/null +++ b/tests/unit/easydynamics/analysis/test_analysis1d.py @@ -0,0 +1,280 @@ +# from unittest.mock import Mock + +from unittest.mock import PropertyMock +from unittest.mock import patch + +import numpy as np +import pytest + +from easydynamics.analysis.analysis_base import AnalysisBase +from easydynamics.experiment import Experiment +from easydynamics.sample_model import InstrumentModel +from easydynamics.sample_model import SampleModel + + +class TestAnalysisBase: + @pytest.fixture + def analysis_base(self): + experiment = Experiment() + sample_model = SampleModel() + instrument_model = InstrumentModel() + analysis_base = AnalysisBase( + display_name="TestAnalysis", + experiment=experiment, + sample_model=sample_model, + instrument_model=instrument_model, + ) + return analysis_base + + def test_init(self, analysis_base): + # WHEN THEN + + # EXPECT + assert analysis_base.display_name == "TestAnalysis" + assert isinstance(analysis_base._experiment, Experiment) + assert isinstance(analysis_base._sample_model, SampleModel) + assert isinstance(analysis_base._instrument_model, InstrumentModel) + assert analysis_base._extra_parameters == [] + + def test_init_calls_on_experiment_changed(self): + with patch.object( + AnalysisBase, "_on_experiment_changed" + ) as mock_on_experiment_changed: + AnalysisBase() + mock_on_experiment_changed.assert_called_once() + + @pytest.mark.parametrize( + "kwargs, expected_exception, expected_message", + [ + ( + {"experiment": 123}, + TypeError, + "experiment must be an instance of Experiment", + ), + ( + {"sample_model": "not a model"}, + TypeError, + "sample_model must be an instance of SampleModel", + ), + ( + {"instrument_model": "not a model"}, + TypeError, + "instrument_model must be an instance of InstrumentModel", + ), + ( + {"extra_parameters": 123}, + TypeError, + "extra_parameters must be a Parameter or a list of Parameters.", + ), + ( + {"extra_parameters": [123]}, + TypeError, + "extra_parameters must be a Parameter or a list of Parameters.", + ), + ], + ids=[ + "invalid experiment", + "invalid sample_model", + "invalid instrument_model", + "invalid extra_parameters", + "invalid extra_parameters list", + ], + ) + def test_init_invalid_inputs(self, kwargs, expected_exception, expected_message): + with pytest.raises(expected_exception, match=expected_message): + AnalysisBase(**kwargs) + + def test_experiment_setter_calls_on_experiment_changed(self, analysis_base): + with patch.object( + analysis_base, "_on_experiment_changed" + ) as mock_on_experiment_changed: + new_experiment = Experiment() + analysis_base.experiment = new_experiment + mock_on_experiment_changed.assert_called_once() + + def test_experiment_setter_invalid_type(self, analysis_base): + with pytest.raises( + TypeError, match="experiment must be an instance of Experiment" + ): + analysis_base.experiment = "not an experiment" + + def test_experiment_setter_valid(self, analysis_base): + new_experiment = Experiment() + analysis_base.experiment = new_experiment + assert analysis_base.experiment == new_experiment + + def test_sample_model_setter_invalid_type(self, analysis_base): + with pytest.raises( + TypeError, match="sample_model must be an instance of SampleModel" + ): + analysis_base.sample_model = "not a sample model" + + def test_sample_model_setter_valid(self, analysis_base): + new_sample_model = SampleModel() + analysis_base.sample_model = new_sample_model + assert analysis_base.sample_model == new_sample_model + + def test_sample_model_setter_calls_on_sample_model_changed(self, analysis_base): + with patch.object( + analysis_base, "_on_sample_model_changed" + ) as mock_on_sample_model_changed: + new_sample_model = SampleModel() + analysis_base.sample_model = new_sample_model + mock_on_sample_model_changed.assert_called_once() + + def test_instrument_model_setter_invalid_type(self, analysis_base): + with pytest.raises( + TypeError, match="instrument_model must be an instance of InstrumentModel" + ): + analysis_base.instrument_model = "not an instrument model" + + def test_instrument_model_setter_valid(self, analysis_base): + new_instrument_model = InstrumentModel() + analysis_base.instrument_model = new_instrument_model + assert analysis_base.instrument_model == new_instrument_model + + def test_instrument_model_setter_calls_on_instrument_model_changed( + self, analysis_base + ): + with patch.object( + analysis_base, "_on_instrument_model_changed" + ) as mock_on_instrument_model_changed: + new_instrument_model = InstrumentModel() + analysis_base.instrument_model = new_instrument_model + mock_on_instrument_model_changed.assert_called_once() + + def test_Q_property(self, analysis_base): + # Create a mock Q value + fake_Q = [1, 2, 3] + + # Patch the 'experiment' attribute's Q property + with patch.object( + type(analysis_base.experiment), "Q", new_callable=PropertyMock + ) as mock_Q: + mock_Q.return_value = fake_Q + result = analysis_base.Q # Access the property + assert result == fake_Q + mock_Q.assert_called_once() + + def test_Q_setter_raises(self, analysis_base): + with pytest.raises( + AttributeError, + match="Q is a read-only property derived from the Experiment.", + ): + analysis_base.Q = [1, 2, 3] + + def test_energy_property(self, analysis_base): + # Create a mock energy value + fake_energy = [10, 20, 30] + + # Patch the 'experiment' attribute's energy property + with patch.object( + type(analysis_base.experiment), "energy", new_callable=PropertyMock + ) as mock_energy: + mock_energy.return_value = fake_energy + result = analysis_base.energy # Access the property + assert result == fake_energy + mock_energy.assert_called_once() + + def test_energy_setter_raises(self, analysis_base): + with pytest.raises( + AttributeError, + match="energy is a read-only property derived from the Experiment.", + ): + analysis_base.energy = [10, 20, 30] + + def test_temperature_property_no_temperature(self, analysis_base): + # Patch the 'experiment' attribute's temperature property to + # return None + with patch.object( + type(analysis_base.sample_model), "temperature", new_callable=PropertyMock + ) as mock_temperature: + mock_temperature.return_value = None + result = analysis_base.temperature # Access the property + assert result is None + mock_temperature.assert_called_once() + + def test_temperature_property(self, analysis_base): + # Create a mock temperature value + fake_temperature = 300 + + # Patch the 'sample_model' attribute's temperature property + with patch.object( + type(analysis_base.sample_model), "temperature", new_callable=PropertyMock + ) as mock_temperature: + mock_temperature.return_value = fake_temperature + result = analysis_base.temperature # Access the property + assert result == fake_temperature + mock_temperature.assert_called_once() + + def test_temperature_setter_raises(self, analysis_base): + with pytest.raises( + AttributeError, + match="temperature is a read-only property", + ): + analysis_base.temperature = 300 + + def test_on_experiment_changed_updates_Q(self, analysis_base): + # WHEN + fake_Q = [1, 2, 3] + + # Patch the Q property of analysis_base + with patch.object( + type(analysis_base.experiment), "Q", new_callable=PropertyMock + ) as mock_Q: + mock_Q.return_value = fake_Q + + # THEN + analysis_base._on_experiment_changed() + + # EXPECT + # assert that the Q attribute was set + np.testing.assert_array_equal(analysis_base.Q, fake_Q) + np.testing.assert_array_equal(analysis_base.sample_model.Q, fake_Q) + np.testing.assert_array_equal(analysis_base.instrument_model.Q, fake_Q) + + def test_on_sample_model_changed_updates_Q(self, analysis_base): + # WHEN + fake_Q = [1, 2, 3] + + # Patch the Q property of analysis_base + with patch.object( + type(analysis_base.experiment), "Q", new_callable=PropertyMock + ) as mock_Q: + mock_Q.return_value = fake_Q + + # THEN + analysis_base._on_sample_model_changed() + + # EXPECT + np.testing.assert_array_equal(analysis_base.sample_model.Q, fake_Q) + + def test_on_instrument_model_changed_updates_Q(self, analysis_base): + fake_Q = [1, 2, 3] + + # Patch the Q property of analysis_base + with patch.object( + type(analysis_base.experiment), "Q", new_callable=PropertyMock + ) as mock_Q: + mock_Q.return_value = fake_Q + + analysis_base._on_instrument_model_changed() + np.testing.assert_array_equal(analysis_base.instrument_model.Q, fake_Q) + + def test_verify_Q_index_valid(self, analysis_base): + # WHEN + valid_Q_index = 0 + + # THEN + result = analysis_base._verify_Q_index(valid_Q_index) + + # EXPECT + assert result == valid_Q_index + + def test_verify_Q_index_invalid(self, analysis_base): + # WHEN + invalid_Q_index = -1 + + # THEN / EXPECT + with pytest.raises(ValueError, match="Q_index must be a valid index"): + analysis_base._verify_Q_index(invalid_Q_index) diff --git a/tests/unit/easydynamics/analysis/test_analysis_base.py b/tests/unit/easydynamics/analysis/test_analysis_base.py index c81bcbda..8a2c8bb7 100644 --- a/tests/unit/easydynamics/analysis/test_analysis_base.py +++ b/tests/unit/easydynamics/analysis/test_analysis_base.py @@ -5,6 +5,7 @@ import numpy as np import pytest +from easyscience.variable import Parameter from easydynamics.analysis.analysis_base import AnalysisBase from easydynamics.experiment import Experiment @@ -36,6 +37,19 @@ def test_init(self, analysis_base): assert isinstance(analysis_base._instrument_model, InstrumentModel) assert analysis_base._extra_parameters == [] + def test_init_extra_parameter(self): + extra_parameter = Parameter(name="param1", value=1.0) + analysis = AnalysisBase(extra_parameters=extra_parameter) + assert analysis._extra_parameters == [extra_parameter] + + def test_init_extra_parameters(self): + extra_parameters = [ + Parameter(name="param1", value=1.0), + Parameter(name="param2", value=2.0), + ] + analysis = AnalysisBase(extra_parameters=extra_parameters) + assert analysis._extra_parameters == extra_parameters + def test_init_calls_on_experiment_changed(self): with patch.object( AnalysisBase, "_on_experiment_changed" From 51194a51a24989f6182d92fc38d16c0348b18cfa Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Mon, 16 Feb 2026 19:52:53 +0100 Subject: [PATCH 21/27] Test analysis1d --- docs/docs/tutorials/analysis.ipynb | 19 +- src/easydynamics/analysis/analysis1d.py | 101 ++-- src/easydynamics/analysis/analysis_base.py | 2 +- .../easydynamics/analysis/test_analysis1d.py | 442 +++++++++--------- .../analysis/test_analysis_base.py | 2 +- 5 files changed, 294 insertions(+), 272 deletions(-) diff --git a/docs/docs/tutorials/analysis.ipynb b/docs/docs/tutorials/analysis.ipynb index 72c182b7..4042cf4d 100644 --- a/docs/docs/tutorials/analysis.ipynb +++ b/docs/docs/tutorials/analysis.ipynb @@ -201,10 +201,25 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 15, "id": "197b44c5", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "89e62e42c895492f88da712db64c0018", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "InteractiveFigure(children=(HBar(), HBar(children=(VBar(children=(Toolbar(children=(ButtonTool(icon='home', la…" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "# Now we fit the data and plot the result. Looks good!\n", "diffusion_analysis.fit(fit_method='independent')\n", diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index 1bac582c..04b73988 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -24,7 +24,7 @@ class Analysis1d(AnalysisBase): def __init__( self, - display_name: str = 'MyAnalysis', + display_name: str = "MyAnalysis", unique_name: str | None = None, experiment: Experiment | None = None, sample_model: SampleModel | None = None, @@ -44,8 +44,10 @@ def __init__( self._Q_index = self._verify_Q_index(Q_index) self._fit_result = None - - self._convolver = self._create_convolver() + if self._Q_index is not None: + self._convolver = self._create_convolver() + else: + self._convolver = None ############# # Properties @@ -112,14 +114,7 @@ def fit(self) -> FitResults: parameter optimization for performance reasons. """ if self._experiment is None: - raise ValueError('No experiment is associated with this Analysis.') - - Q_index = self._require_Q_index() - - data = self.experiment.data['Q', Q_index] - x = data.coords['energy'].values - y = data.values - e = data.variances**0.5 + raise ValueError("No experiment is associated with this Analysis.") # Create convolver once to reuse during fitting self._convolver = self._create_convolver() @@ -129,13 +124,14 @@ def fit(self) -> FitResults: fit_function=self.as_fit_function(), ) - fit_result = fitter.fit(x=x, y=y, weights=1.0 / e) + x, y, weights = self._extract_x_y_weights_from_experiment() + fit_result = fitter.fit(x=x, y=y, weights=weights) self._fit_result = fit_result return fit_result - def as_fit_function(self, x=None, **kwargs): + def as_fit_function(self, x=None, **kwargs) -> callable: """Return self._calculate as a fit function. The EasyScience fitter requires x as input, but @@ -187,33 +183,37 @@ def plot_data_and_model( import plopp as pp if self.experiment.data is None: - raise ValueError('No data to plot. Please load data first.') + raise ValueError("No data to plot. Please load data first.") - data = self.experiment.data['Q', self.Q_index] + data = self.experiment.data["Q", self.Q_index] model_array = self._create_sample_scipp_array() - component_dataset = self._create_components_dataset_single_Q(add_background=add_background) + component_dataset = self._create_components_dataset_single_Q( + add_background=add_background + ) # Create a dataset containing the data, model, and individual # components for plotting. - data_and_model = sc.Dataset({ - 'Data': data, - 'Model': model_array, - }) + data_and_model = sc.Dataset( + { + "Data": data, + "Model": model_array, + } + ) data_and_model = sc.merge(data_and_model, component_dataset) plot_kwargs_defaults = { - 'title': self.display_name, - 'linestyle': {'Data': 'none', 'Model': '-'}, - 'marker': {'Data': 'o', 'Model': 'none'}, - 'color': {'Data': 'black', 'Model': 'red'}, - 'markerfacecolor': {'Data': 'none', 'Model': 'none'}, + "title": self.display_name, + "linestyle": {"Data": "none", "Model": "-"}, + "marker": {"Data": "o", "Model": "none"}, + "color": {"Data": "black", "Model": "red"}, + "markerfacecolor": {"Data": "none", "Model": "none"}, } if plot_components: for comp_name in component_dataset.keys(): - plot_kwargs_defaults['linestyle'][comp_name] = '--' - plot_kwargs_defaults['marker'][comp_name] = None + plot_kwargs_defaults["linestyle"][comp_name] = "--" + plot_kwargs_defaults["marker"][comp_name] = None # Overwrite defaults with any user-provided kwargs plot_kwargs_defaults.update(kwargs) @@ -236,7 +236,7 @@ def _require_Q_index(self) -> int: int: The Q index. """ if self._Q_index is None: - raise ValueError('Q_index must be set.') + raise ValueError("Q_index must be set.") return self._Q_index def _on_Q_index_changed(self) -> None: @@ -247,6 +247,19 @@ def _on_Q_index_changed(self) -> None: """ self._convolver = self._create_convolver() + def _extract_x_y_weights_from_experiment(self): + """ + Extract the x, y, and weights arrays from the experiment for + the current Q index. + """ + Q_index = self._require_Q_index() + data = self.experiment.data["Q", Q_index] + x = data.coords["energy"].values + y = data.values + e = data.variances**0.5 + weights = 1.0 / e + return x, y, weights + ############# # Private methods: evaluation ############# @@ -291,7 +304,9 @@ def _evaluate_components( if not convolve: return components.evaluate(energy - energy_offset) - resolution = self.instrument_model.resolution_model.get_component_collection(Q_index) + resolution = self.instrument_model.resolution_model.get_component_collection( + Q_index + ) if resolution.is_empty: return components.evaluate(energy - energy_offset) @@ -353,8 +368,10 @@ def _evaluate_background(self) -> np.ndarray: np.ndarray: The evaluated background contribution. """ Q_index = self._require_Q_index() - background_components = self.instrument_model.background_model.get_component_collection( - Q_index=Q_index + background_components = ( + self.instrument_model.background_model.get_component_collection( + Q_index=Q_index + ) ) return self._evaluate_components( components=background_components, @@ -395,8 +412,8 @@ def _create_convolver(self) -> Convolution | None: if sample_components.is_empty: return None - resolution_components = self.instrument_model.resolution_model.get_component_collection( - Q_index + resolution_components = ( + self.instrument_model.resolution_model.get_component_collection(Q_index) ) if resolution_components.is_empty: return None @@ -447,17 +464,19 @@ def _create_components_dataset_single_Q( Q_index=self.Q_index ).components - background_components = self.instrument_model.background_model.get_component_collection( - Q_index=self.Q_index - ).components + background_components = ( + self.instrument_model.background_model.get_component_collection( + Q_index=self.Q_index + ).components + ) background = self._evaluate_background() if add_background else None for component in sample_components: scipp_arrays[component.display_name] = self._create_component_scipp_array( component, background=background ) for component in background_components: - scipp_arrays[component.display_name] = self._create_background_component_scipp_array( - component + scipp_arrays[component.display_name] = ( + self._create_background_component_scipp_array(component) ) return sc.Dataset(scipp_arrays) @@ -471,9 +490,9 @@ def _to_scipp_array(self, values: np.ndarray) -> sc.DataArray: sc.DataArray: The converted sc.DataArray. """ return sc.DataArray( - data=sc.array(dims=['energy'], values=values), + data=sc.array(dims=["energy"], values=values), coords={ - 'energy': self.energy, - 'Q': self.Q[self.Q_index], + "energy": self.energy, + "Q": self.Q[self.Q_index], }, ) diff --git a/src/easydynamics/analysis/analysis_base.py b/src/easydynamics/analysis/analysis_base.py index fb74dcf2..a7f8f477 100644 --- a/src/easydynamics/analysis/analysis_base.py +++ b/src/easydynamics/analysis/analysis_base.py @@ -189,7 +189,7 @@ def _verify_Q_index(self, Q_index: int | None) -> int | None: or Q_index < 0 or (self.Q is not None and Q_index >= len(self.Q)) ): - raise ValueError("Q_index must be a valid index for the Q values.") + raise IndexError("Q_index must be a valid index for the Q values.") return Q_index ############# diff --git a/tests/unit/easydynamics/analysis/test_analysis1d.py b/tests/unit/easydynamics/analysis/test_analysis1d.py index c81bcbda..470b1c4b 100644 --- a/tests/unit/easydynamics/analysis/test_analysis1d.py +++ b/tests/unit/easydynamics/analysis/test_analysis1d.py @@ -1,280 +1,268 @@ # from unittest.mock import Mock -from unittest.mock import PropertyMock + +from collections import Counter +from unittest.mock import MagicMock from unittest.mock import patch import numpy as np import pytest +import scipp as sc +from easyscience.variable import Parameter -from easydynamics.analysis.analysis_base import AnalysisBase +from easydynamics.analysis.analysis1d import Analysis1d from easydynamics.experiment import Experiment from easydynamics.sample_model import InstrumentModel from easydynamics.sample_model import SampleModel +from easydynamics.sample_model.components.gaussian import Gaussian -class TestAnalysisBase: +class TestAnalysis1d: @pytest.fixture - def analysis_base(self): - experiment = Experiment() - sample_model = SampleModel() + def analysis1d(self): + Q = sc.array(dims=["Q"], values=[1, 2, 3], unit="1/Angstrom") + energy = sc.array(dims=["energy"], values=[10, 20, 30], unit="meV") + data = sc.array(dims=["Q", "energy"], values=[[1, 2, 3], [4, 5, 6], [7, 8, 9]]) + data_array = sc.DataArray(data=data, coords={"Q": Q, "energy": energy}) + + experiment = Experiment(data=data_array) + sample_model = SampleModel(components=Gaussian()) instrument_model = InstrumentModel() - analysis_base = AnalysisBase( + analysis1d = Analysis1d( display_name="TestAnalysis", experiment=experiment, sample_model=sample_model, instrument_model=instrument_model, + Q_index=0, + extra_parameters=None, ) - return analysis_base - def test_init(self, analysis_base): + return analysis1d + + def test_init(self, analysis1d): # WHEN THEN # EXPECT - assert analysis_base.display_name == "TestAnalysis" - assert isinstance(analysis_base._experiment, Experiment) - assert isinstance(analysis_base._sample_model, SampleModel) - assert isinstance(analysis_base._instrument_model, InstrumentModel) - assert analysis_base._extra_parameters == [] - - def test_init_calls_on_experiment_changed(self): - with patch.object( - AnalysisBase, "_on_experiment_changed" - ) as mock_on_experiment_changed: - AnalysisBase() - mock_on_experiment_changed.assert_called_once() + assert analysis1d.display_name == "TestAnalysis" + assert isinstance(analysis1d._experiment, Experiment) + assert isinstance(analysis1d._sample_model, SampleModel) + assert isinstance(analysis1d._instrument_model, InstrumentModel) + assert analysis1d._extra_parameters == [] + assert np.array_equal(analysis1d.Q.values, [1, 2, 3]) + assert analysis1d.Q_index == 0 + + def test_Q_index_setter(self, analysis1d): + # WHEN + analysis1d.Q_index = 1 + + # THEN / EXPECT + assert analysis1d.Q_index == 1 @pytest.mark.parametrize( - "kwargs, expected_exception, expected_message", + "invalid_Q_index, expected_exception, expected_message", [ - ( - {"experiment": 123}, - TypeError, - "experiment must be an instance of Experiment", - ), - ( - {"sample_model": "not a model"}, - TypeError, - "sample_model must be an instance of SampleModel", - ), - ( - {"instrument_model": "not a model"}, - TypeError, - "instrument_model must be an instance of InstrumentModel", - ), - ( - {"extra_parameters": 123}, - TypeError, - "extra_parameters must be a Parameter or a list of Parameters.", - ), - ( - {"extra_parameters": [123]}, - TypeError, - "extra_parameters must be a Parameter or a list of Parameters.", - ), + (-1, IndexError, "Q_index must be"), + (10, IndexError, "Q_index must be"), + ("invalid", IndexError, "Q_index must be "), + (np.nan, IndexError, "Q_index must be "), + ([1, 2], IndexError, "Q_index must be "), ], ids=[ - "invalid experiment", - "invalid sample_model", - "invalid instrument_model", - "invalid extra_parameters", - "invalid extra_parameters list", + "Negative index", + "Index out of range", + "Non-integer string", + "NaN value", + "List instead of integer", ], ) - def test_init_invalid_inputs(self, kwargs, expected_exception, expected_message): - with pytest.raises(expected_exception, match=expected_message): - AnalysisBase(**kwargs) - - def test_experiment_setter_calls_on_experiment_changed(self, analysis_base): - with patch.object( - analysis_base, "_on_experiment_changed" - ) as mock_on_experiment_changed: - new_experiment = Experiment() - analysis_base.experiment = new_experiment - mock_on_experiment_changed.assert_called_once() - - def test_experiment_setter_invalid_type(self, analysis_base): - with pytest.raises( - TypeError, match="experiment must be an instance of Experiment" - ): - analysis_base.experiment = "not an experiment" - - def test_experiment_setter_valid(self, analysis_base): - new_experiment = Experiment() - analysis_base.experiment = new_experiment - assert analysis_base.experiment == new_experiment - - def test_sample_model_setter_invalid_type(self, analysis_base): - with pytest.raises( - TypeError, match="sample_model must be an instance of SampleModel" - ): - analysis_base.sample_model = "not a sample model" - - def test_sample_model_setter_valid(self, analysis_base): - new_sample_model = SampleModel() - analysis_base.sample_model = new_sample_model - assert analysis_base.sample_model == new_sample_model - - def test_sample_model_setter_calls_on_sample_model_changed(self, analysis_base): - with patch.object( - analysis_base, "_on_sample_model_changed" - ) as mock_on_sample_model_changed: - new_sample_model = SampleModel() - analysis_base.sample_model = new_sample_model - mock_on_sample_model_changed.assert_called_once() - - def test_instrument_model_setter_invalid_type(self, analysis_base): - with pytest.raises( - TypeError, match="instrument_model must be an instance of InstrumentModel" - ): - analysis_base.instrument_model = "not an instrument model" - - def test_instrument_model_setter_valid(self, analysis_base): - new_instrument_model = InstrumentModel() - analysis_base.instrument_model = new_instrument_model - assert analysis_base.instrument_model == new_instrument_model - - def test_instrument_model_setter_calls_on_instrument_model_changed( - self, analysis_base + def test_Q_index_setter_incorrect_Q( + self, analysis1d, invalid_Q_index, expected_exception, expected_message ): - with patch.object( - analysis_base, "_on_instrument_model_changed" - ) as mock_on_instrument_model_changed: - new_instrument_model = InstrumentModel() - analysis_base.instrument_model = new_instrument_model - mock_on_instrument_model_changed.assert_called_once() - - def test_Q_property(self, analysis_base): - # Create a mock Q value - fake_Q = [1, 2, 3] - - # Patch the 'experiment' attribute's Q property - with patch.object( - type(analysis_base.experiment), "Q", new_callable=PropertyMock - ) as mock_Q: - mock_Q.return_value = fake_Q - result = analysis_base.Q # Access the property - assert result == fake_Q - mock_Q.assert_called_once() - - def test_Q_setter_raises(self, analysis_base): - with pytest.raises( - AttributeError, - match="Q is a read-only property derived from the Experiment.", - ): - analysis_base.Q = [1, 2, 3] - - def test_energy_property(self, analysis_base): - # Create a mock energy value - fake_energy = [10, 20, 30] - - # Patch the 'experiment' attribute's energy property - with patch.object( - type(analysis_base.experiment), "energy", new_callable=PropertyMock - ) as mock_energy: - mock_energy.return_value = fake_energy - result = analysis_base.energy # Access the property - assert result == fake_energy - mock_energy.assert_called_once() - - def test_energy_setter_raises(self, analysis_base): - with pytest.raises( - AttributeError, - match="energy is a read-only property derived from the Experiment.", - ): - analysis_base.energy = [10, 20, 30] - - def test_temperature_property_no_temperature(self, analysis_base): - # Patch the 'experiment' attribute's temperature property to - # return None - with patch.object( - type(analysis_base.sample_model), "temperature", new_callable=PropertyMock - ) as mock_temperature: - mock_temperature.return_value = None - result = analysis_base.temperature # Access the property - assert result is None - mock_temperature.assert_called_once() - - def test_temperature_property(self, analysis_base): - # Create a mock temperature value - fake_temperature = 300 - - # Patch the 'sample_model' attribute's temperature property - with patch.object( - type(analysis_base.sample_model), "temperature", new_callable=PropertyMock - ) as mock_temperature: - mock_temperature.return_value = fake_temperature - result = analysis_base.temperature # Access the property - assert result == fake_temperature - mock_temperature.assert_called_once() - - def test_temperature_setter_raises(self, analysis_base): - with pytest.raises( - AttributeError, - match="temperature is a read-only property", - ): - analysis_base.temperature = 300 - - def test_on_experiment_changed_updates_Q(self, analysis_base): + # WHEN / THEN / EXPECT + with pytest.raises(expected_exception, match=expected_message): + analysis1d.Q_index = invalid_Q_index + + def test_calculate_updates_convolver_and_calls_calculate(self, analysis1d): # WHEN - fake_Q = [1, 2, 3] - # Patch the Q property of analysis_base - with patch.object( - type(analysis_base.experiment), "Q", new_callable=PropertyMock - ) as mock_Q: - mock_Q.return_value = fake_Q + # mock the _create_convolver and _calculate methods to verify + # they are called + fake_convolver = object() + expected_result = np.array([42.0]) - # THEN - analysis_base._on_experiment_changed() + analysis1d._create_convolver = MagicMock(return_value=fake_convolver) + analysis1d._calculate = MagicMock(return_value=expected_result) + + # THEN + result = analysis1d.calculate() + + # EXPECT + + analysis1d._create_convolver.assert_called_once() + assert analysis1d._convolver is fake_convolver + analysis1d._calculate.assert_called_once() + np.testing.assert_array_equal(result, expected_result) + + def test__calculate_adds_sample_and_background(self, analysis1d): + sample = np.array([1.0, 2.0, 3.0]) + background = np.array([0.5, 0.5, 0.5]) + + analysis1d._evaluate_sample = MagicMock(return_value=sample) + analysis1d._evaluate_background = MagicMock(return_value=background) - # EXPECT - # assert that the Q attribute was set - np.testing.assert_array_equal(analysis_base.Q, fake_Q) - np.testing.assert_array_equal(analysis_base.sample_model.Q, fake_Q) - np.testing.assert_array_equal(analysis_base.instrument_model.Q, fake_Q) + result = analysis1d._calculate() + + np.testing.assert_array_equal(result, sample + background) + + analysis1d._evaluate_sample.assert_called_once() + analysis1d._evaluate_background.assert_called_once() + + def test_fit_raises_if_no_experiment(self, analysis1d): + # WHEN THEN + analysis1d._experiment = None + + # EXPECT + with pytest.raises(ValueError, match="No experiment"): + analysis1d.fit() + + def test_fit_calls_fitter_with_correct_arguments(self, analysis1d): - def test_on_sample_model_changed_updates_Q(self, analysis_base): # WHEN - fake_Q = [1, 2, 3] - # Patch the Q property of analysis_base - with patch.object( - type(analysis_base.experiment), "Q", new_callable=PropertyMock - ) as mock_Q: - mock_Q.return_value = fake_Q + # Mock all the methods that are called during fit to verify they + # are called with the correct arguments + fake_x = np.array([1, 2, 3]) + fake_y = np.array([10, 20, 30]) + fake_weights = np.array([0.1, 0.2, 0.3]) + + analysis1d._extract_x_y_weights_from_experiment = MagicMock( + return_value=(fake_x, fake_y, fake_weights) + ) + + analysis1d._create_convolver = MagicMock(return_value="fake_convolver") + + fake_fit_result = object() + fake_fitter_instance = MagicMock() + fake_fitter_instance.fit.return_value = fake_fit_result + + with patch( + "easydynamics.analysis.analysis1d.EasyScienceFitter", + return_value=fake_fitter_instance, + ) as mock_fitter: + analysis1d.as_fit_function = MagicMock(return_value="fit_func") # THEN - analysis_base._on_sample_model_changed() + result = analysis1d.fit() - # EXPECT - np.testing.assert_array_equal(analysis_base.sample_model.Q, fake_Q) + # EXPECT + + # Check that all the mocked methods were called with the correct + # arguments + analysis1d._create_convolver.assert_called_once() - def test_on_instrument_model_changed_updates_Q(self, analysis_base): - fake_Q = [1, 2, 3] + mock_fitter.assert_called_once_with( + fit_object=analysis1d, + fit_function="fit_func", + ) - # Patch the Q property of analysis_base - with patch.object( - type(analysis_base.experiment), "Q", new_callable=PropertyMock - ) as mock_Q: - mock_Q.return_value = fake_Q + analysis1d._extract_x_y_weights_from_experiment.assert_called_once() - analysis_base._on_instrument_model_changed() - np.testing.assert_array_equal(analysis_base.instrument_model.Q, fake_Q) + fake_fitter_instance.fit.assert_called_once_with( + x=fake_x, + y=fake_y, + weights=fake_weights, + ) - def test_verify_Q_index_valid(self, analysis_base): + # And that the result is returned + assert analysis1d._fit_result is fake_fit_result + assert result is fake_fit_result + + def test_as_fit_function_calls_calculate(self, analysis1d): # WHEN - valid_Q_index = 0 + expected = np.array([1.0, 2.0, 3.0]) + analysis1d._calculate = MagicMock(return_value=expected) + + # THEN + fit_func = analysis1d.as_fit_function() + + # EXPECT + assert callable(fit_func) # THEN - result = analysis_base._verify_Q_index(valid_Q_index) + # call the fit function with some x values + result = fit_func(x=[1, 2, 3]) # should be ignored # EXPECT - assert result == valid_Q_index + analysis1d._calculate.assert_called_once() + + assert result is expected - def test_verify_Q_index_invalid(self, analysis_base): + def test_get_all_variables(self, analysis1d): # WHEN - invalid_Q_index = -1 + extra_par1 = Parameter(name="extra_par1", value=1.0) + extra_par2 = Parameter(name="extra_par2", value=2.0) + analysis1d._extra_parameters = [extra_par1, extra_par2] - # THEN / EXPECT - with pytest.raises(ValueError, match="Q_index must be a valid index"): - analysis_base._verify_Q_index(invalid_Q_index) + # THEN + variables = analysis1d.get_all_variables() + + # EXPECT + assert isinstance(variables, list) + sample_vars = analysis1d.sample_model.get_all_variables( + Q_index=analysis1d.Q_index + ) + instrument_vars = analysis1d.instrument_model.get_all_variables( + Q_index=analysis1d.Q_index + ) + extra_vars = [extra_par1, extra_par2] + expected_vars = sample_vars + instrument_vars + extra_vars + assert Counter(variables) == Counter(expected_vars) + + def test_plot_raises_if_no_data(self, analysis1d): + analysis1d.experiment._data = None + + with pytest.raises(ValueError, match="No data"): + analysis1d.plot_data_and_model() + + def test_plot_calls_plopp_with_correct_arguments(self, analysis1d): + # WHEN + + # Mock the data and model components to be plotted + fake_model = sc.DataArray(data=sc.array(dims=["energy"], values=[1, 2, 3])) + analysis1d._create_sample_scipp_array = MagicMock(return_value=fake_model) + + fake_components = sc.Dataset( + { + "Component1": sc.DataArray( + data=sc.array(dims=["energy"], values=[0.1, 0.2, 0.3]) + ) + } + ) + analysis1d._create_components_dataset_single_Q = MagicMock( + return_value=fake_components + ) + + fake_fig = object() + + with patch("plopp.plot", return_value=fake_fig) as mock_plot: + # THEN + result = analysis1d.plot_data_and_model() + + # EXPECT + + # Ensure component dataset created + analysis1d._create_components_dataset_single_Q.assert_called_once() + + # Ensure plot called + mock_plot.assert_called_once() + + # Inspect arguments + args, kwargs = mock_plot.call_args + + dataset_passed = args[0] + + assert "Data" in dataset_passed + assert "Model" in dataset_passed + assert "Component1" in dataset_passed + + assert result is fake_fig diff --git a/tests/unit/easydynamics/analysis/test_analysis_base.py b/tests/unit/easydynamics/analysis/test_analysis_base.py index 8a2c8bb7..66380520 100644 --- a/tests/unit/easydynamics/analysis/test_analysis_base.py +++ b/tests/unit/easydynamics/analysis/test_analysis_base.py @@ -290,5 +290,5 @@ def test_verify_Q_index_invalid(self, analysis_base): invalid_Q_index = -1 # THEN / EXPECT - with pytest.raises(ValueError, match="Q_index must be a valid index"): + with pytest.raises(IndexError, match="Q_index must be a valid index"): analysis_base._verify_Q_index(invalid_Q_index) From 097280774d4a0d4590ebbd9be39988175f8ce057 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Mon, 16 Feb 2026 20:05:42 +0100 Subject: [PATCH 22/27] Another test --- docs/docs/tutorials/analysis.ipynb | 27 +++++++------------ .../easydynamics/analysis/test_analysis1d.py | 22 +++++++++++++++ 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/docs/docs/tutorials/analysis.ipynb b/docs/docs/tutorials/analysis.ipynb index 4042cf4d..edd8d626 100644 --- a/docs/docs/tutorials/analysis.ipynb +++ b/docs/docs/tutorials/analysis.ipynb @@ -201,25 +201,10 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "id": "197b44c5", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "89e62e42c895492f88da712db64c0018", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "InteractiveFigure(children=(HBar(), HBar(children=(VBar(children=(Toolbar(children=(ButtonTool(icon='home', la…" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Now we fit the data and plot the result. Looks good!\n", "diffusion_analysis.fit(fit_method='independent')\n", @@ -247,6 +232,14 @@ "# It will be possible to fit this to a DiffusionModel, but that will\n", "# come later." ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "508c9247", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/tests/unit/easydynamics/analysis/test_analysis1d.py b/tests/unit/easydynamics/analysis/test_analysis1d.py index 470b1c4b..44b63e23 100644 --- a/tests/unit/easydynamics/analysis/test_analysis1d.py +++ b/tests/unit/easydynamics/analysis/test_analysis1d.py @@ -266,3 +266,25 @@ def test_plot_calls_plopp_with_correct_arguments(self, analysis1d): assert "Component1" in dataset_passed assert result is fake_fig + + ######################## + + def test_to_scipp_array(self, analysis1d): + # WHEN + numpy_array = np.array([1.0, 2.0, 3.0]) + + # THEN + scipp_array = analysis1d._to_scipp_array(numpy_array) + + # EXPECT + assert isinstance(scipp_array, sc.DataArray) + np.testing.assert_array_equal(scipp_array.values, numpy_array) + + np.testing.assert_array_equal( + scipp_array.coords["energy"].values, analysis1d.experiment.energy.values + ) + + np.testing.assert_array_equal( + scipp_array.coords["Q"].values, + analysis1d.experiment.Q[analysis1d.Q_index].values, + ) From d0543454cc6309ca63501b57d1bf9570c72342f0 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Tue, 17 Feb 2026 09:30:27 +0100 Subject: [PATCH 23/27] More analysis1d tests --- src/easydynamics/analysis/analysis1d.py | 46 ++- .../easydynamics/analysis/test_analysis1d.py | 282 +++++++++++++++++- 2 files changed, 317 insertions(+), 11 deletions(-) diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index 04b73988..c3ffea75 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -437,24 +437,54 @@ def _create_component_scipp_array( component: ModelComponent, background: np.ndarray | None = None, ) -> sc.DataArray: - values = self._evaluate_sample_component(component) + """ + Create a scipp DataArray for a single component. Adds the + background if it is not None. + + Parameters: + ----------- + component: ModelComponent. + The component to evaulate + backgrond: np.ndarray | None + Optional background to add to the component. + + Returns: + sc.DataArray with the model calculation of the component. + """ + values = self._evaluate_sample_component(component=component) if background is not None: values += background - return self._to_scipp_array(values) + return self._to_scipp_array(values=values) def _create_background_component_scipp_array( self, component: ModelComponent, ) -> sc.DataArray: - values = self._evaluate_background_component(component) - return self._to_scipp_array(values) + """ + Create a scipp DataArray for a single background component. + + Parameters: + ----------- + component: ModelComponent. + The component to evaulate + + Returns: + sc.DataArray with the model calculation of the component. + """ + values = self._evaluate_background_component(component=component) + return self._to_scipp_array(values=values) def _create_sample_scipp_array(self) -> sc.DataArray: + """ + Create a scipp DataArray for the full sample model including + background. + """ values = self._calculate() - return self._to_scipp_array(values) + return self._to_scipp_array(values=values) def _create_components_dataset_single_Q( - self, add_background: bool = True + self, + add_background: bool = True, ) -> dict[str, sc.DataArray]: """Create sc.DataArrays for all sample and background components. @@ -472,11 +502,11 @@ def _create_components_dataset_single_Q( background = self._evaluate_background() if add_background else None for component in sample_components: scipp_arrays[component.display_name] = self._create_component_scipp_array( - component, background=background + component=component, background=background ) for component in background_components: scipp_arrays[component.display_name] = ( - self._create_background_component_scipp_array(component) + self._create_background_component_scipp_array(component=component) ) return sc.Dataset(scipp_arrays) diff --git a/tests/unit/easydynamics/analysis/test_analysis1d.py b/tests/unit/easydynamics/analysis/test_analysis1d.py index 44b63e23..6d9e1a31 100644 --- a/tests/unit/easydynamics/analysis/test_analysis1d.py +++ b/tests/unit/easydynamics/analysis/test_analysis1d.py @@ -21,8 +21,13 @@ class TestAnalysis1d: @pytest.fixture def analysis1d(self): Q = sc.array(dims=["Q"], values=[1, 2, 3], unit="1/Angstrom") - energy = sc.array(dims=["energy"], values=[10, 20, 30], unit="meV") - data = sc.array(dims=["Q", "energy"], values=[[1, 2, 3], [4, 5, 6], [7, 8, 9]]) + energy = sc.array(dims=["energy"], values=[10.0, 20.0, 30.0], unit="meV") + data = sc.array( + dims=["Q", "energy"], + values=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], + variances=[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]], + ) + data_array = sc.DataArray(data=data, coords={"Q": Q, "energy": energy}) experiment = Experiment(data=data_array) @@ -267,7 +272,278 @@ def test_plot_calls_plopp_with_correct_arguments(self, analysis1d): assert result is fake_fig - ######################## + ############# + # Private methods: small utilities + ############# + + def test_require_Q_index(self, analysis1d): + # WHEN THEN + Q_index = analysis1d._require_Q_index() + + # EXPECT + assert Q_index == analysis1d.Q_index + + def test_require_Q_index_raises_if_no_Q_index(self, analysis1d): + # WHEN THEN + analysis1d._Q_index = None + + # EXPECT + with pytest.raises(ValueError, match="Q_index must be set"): + analysis1d._require_Q_index() + + def test_on_Q_index_changed(self, analysis1d): + # WHEN + analysis1d._create_convolver = MagicMock() + + # THEN + analysis1d._on_Q_index_changed() + + # EXPECT + analysis1d._create_convolver.assert_called_once() + + def test_extract_x_y_weights_from_experiment(self, analysis1d): + # WHEN THEN + x, y, weights = analysis1d._extract_x_y_weights_from_experiment() + + # EXPECT + assert np.array_equal(x, analysis1d.experiment.energy.values) + assert np.array_equal(y, analysis1d.experiment.data.values[analysis1d.Q_index]) + assert np.array_equal( + weights, 1 / analysis1d.experiment.data.variances[analysis1d.Q_index] ** 0.5 + ) + + ############# + # Private methods: evaluation + ############# + + ############# + # Private methods: create scipp arrays for plotting + ############# + + @pytest.mark.parametrize( + "background", + [ + None, + np.array([0.5, 0.5, 0.5]), + ], + ids=[ + "No background", + "With background", + ], + ) + def test_create_component_scipp_array(self, analysis1d, background): + """Test that _create_component_scipp_array correctly evaluates + the component, adds the background and calls _to_scipp_array + with the correct values.""" + "" + # WHEN + + # Mock the functions that will be called. + analysis1d._evaluate_sample_component = MagicMock( + return_value=np.array([1.0, 2.0, 3.0]) + ) + + analysis1d._to_scipp_array = MagicMock() + + component = object() + + # THEN + analysis1d._create_component_scipp_array( + component=component, background=background + ) + + # EXPECT + analysis1d._evaluate_sample_component.assert_called_once_with( + component=component + ) + + expected_values = np.array([1.0, 2.0, 3.0]) + if background is not None: + expected_values += background + + analysis1d._to_scipp_array.assert_called_once() + + # Extract the actual call + _, kwargs = analysis1d._to_scipp_array.call_args + + np.testing.assert_array_equal( + kwargs["values"], + expected_values, + ) + + def test_create_background_component_scipp_array(self, analysis1d): + """Test that _create_background_component_scipp_array correctly + evaluates the component, adds the background and calls + _to_scipp_array with the correct values.""" + + # WHEN + + # Mock the functions that will be called. + analysis1d._evaluate_background_component = MagicMock( + return_value=np.array([1.0, 2.0, 3.0]) + ) + analysis1d._to_scipp_array = MagicMock() + + component = object() + + # THEN + analysis1d._create_background_component_scipp_array(component=component) + + # EXPECT + analysis1d._evaluate_background_component.assert_called_once_with( + component=component + ) + + analysis1d._to_scipp_array.assert_called_once() + + # Extract the actual call + _, kwargs = analysis1d._to_scipp_array.call_args + + np.testing.assert_array_equal( + kwargs["values"], + np.array([1.0, 2.0, 3.0]), + ) + + def test_create_sample_scipp_array(self, analysis1d): + """Test that _create_sample_scipp_array correctly + evaluates the full model and calls _to_scipp_array with the + correct values.""" + + # WHEN + + # Mock the functions that will be called. + analysis1d._calculate = MagicMock(return_value=np.array([1.0, 2.0, 3.0])) + analysis1d._to_scipp_array = MagicMock() + + # THEN + analysis1d._create_sample_scipp_array() + + # EXPECT + analysis1d._calculate.assert_called_once() + + analysis1d._to_scipp_array.assert_called_once() + + # Extract the actual call + _, kwargs = analysis1d._to_scipp_array.call_args + + np.testing.assert_array_equal( + kwargs["values"], + np.array([1.0, 2.0, 3.0]), + ) + + @pytest.mark.parametrize( + "add_background", + [True, False], + ids=["With background", "Without background"], + ) + def test_create_components_dataset_single_Q( + self, + analysis1d, + add_background, + ): + """Test orchestration of _create_components_dataset_single_Q.""" + + # WHEN + + # Choose a particular Q_index, but without using the setter to + # avoid validation logic + analysis1d._Q_index = 5 + + # Mock all the things + + # ---- Sample component ---- + sample_component = MagicMock() + sample_component.display_name = "sample_comp" + + sample_collection = MagicMock() + sample_collection.components = [sample_component] + + analysis1d.sample_model.get_component_collection = MagicMock( + return_value=sample_collection + ) + + # ---- Background component ---- + background_component = MagicMock() + background_component.display_name = "background_comp" + + background_collection = MagicMock() + background_collection.components = [background_component] + + analysis1d.instrument_model.background_model.get_component_collection = ( + MagicMock(return_value=background_collection) + ) + + # ---- Background evaluation ---- + background_value = np.array([10.0, 20.0, 30.0]) + analysis1d._evaluate_background = MagicMock(return_value=background_value) + + # ---- Return scipp DataArrays ---- + fake_sample_da = sc.DataArray( + data=sc.array(dims=["energy"], values=[1.0, 2.0, 3.0]) + ) + + analysis1d._create_component_scipp_array = MagicMock( + return_value=fake_sample_da + ) + + fake_background_da = sc.DataArray( + data=sc.array(dims=["energy"], values=[4.0, 5.0, 6.0]) + ) + + analysis1d._create_background_component_scipp_array = MagicMock( + return_value=fake_background_da + ) + + # THEN + dataset = analysis1d._create_components_dataset_single_Q( + add_background=add_background + ) + + # EXPECT + + # The correct component collections are requested with the + # correct Q_index + analysis1d.sample_model.get_component_collection.assert_called_once_with( + Q_index=analysis1d.Q_index + ) + + analysis1d.instrument_model.background_model.get_component_collection.assert_called_once_with( + Q_index=analysis1d.Q_index + ) + + # Background is evaluated if add_background=True, and not + # evaluated if False + if add_background: + analysis1d._evaluate_background.assert_called_once() + expected_background = background_value + else: + analysis1d._evaluate_background.assert_not_called() + expected_background = None + + # The sample component scipp array is created with the correct + # component and background + analysis1d._create_component_scipp_array.assert_called_once() + _, kwargs = analysis1d._create_component_scipp_array.call_args + + assert kwargs["component"] is sample_component + + if expected_background is None: + assert kwargs["background"] is None + else: + np.testing.assert_array_equal( + kwargs["background"], + expected_background, + ) + + # Background component creation + analysis1d._create_background_component_scipp_array.assert_called_once_with( + component=background_component + ) + + # Dataset content + assert isinstance(dataset, sc.Dataset) + assert "sample_comp" in dataset + assert "background_comp" in dataset def test_to_scipp_array(self, analysis1d): # WHEN From 9c01ffc02faa2934d18c5c345cd424dfc0f25e17 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Tue, 17 Feb 2026 09:37:33 +0100 Subject: [PATCH 24/27] linting --- src/easydynamics/analysis/analysis1d.py | 86 ++++------ src/easydynamics/analysis/analysis_base.py | 34 ++-- .../easydynamics/analysis/test_analysis1d.py | 156 ++++++++---------- .../analysis/test_analysis_base.py | 96 +++++------ .../convolution/test_numerical_convolution.py | 38 ++--- 5 files changed, 170 insertions(+), 240 deletions(-) diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index c3ffea75..7c324952 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -24,7 +24,7 @@ class Analysis1d(AnalysisBase): def __init__( self, - display_name: str = "MyAnalysis", + display_name: str = 'MyAnalysis', unique_name: str | None = None, experiment: Experiment | None = None, sample_model: SampleModel | None = None, @@ -114,7 +114,7 @@ def fit(self) -> FitResults: parameter optimization for performance reasons. """ if self._experiment is None: - raise ValueError("No experiment is associated with this Analysis.") + raise ValueError('No experiment is associated with this Analysis.') # Create convolver once to reuse during fitting self._convolver = self._create_convolver() @@ -183,37 +183,33 @@ def plot_data_and_model( import plopp as pp if self.experiment.data is None: - raise ValueError("No data to plot. Please load data first.") + raise ValueError('No data to plot. Please load data first.') - data = self.experiment.data["Q", self.Q_index] + data = self.experiment.data['Q', self.Q_index] model_array = self._create_sample_scipp_array() - component_dataset = self._create_components_dataset_single_Q( - add_background=add_background - ) + component_dataset = self._create_components_dataset_single_Q(add_background=add_background) # Create a dataset containing the data, model, and individual # components for plotting. - data_and_model = sc.Dataset( - { - "Data": data, - "Model": model_array, - } - ) + data_and_model = sc.Dataset({ + 'Data': data, + 'Model': model_array, + }) data_and_model = sc.merge(data_and_model, component_dataset) plot_kwargs_defaults = { - "title": self.display_name, - "linestyle": {"Data": "none", "Model": "-"}, - "marker": {"Data": "o", "Model": "none"}, - "color": {"Data": "black", "Model": "red"}, - "markerfacecolor": {"Data": "none", "Model": "none"}, + 'title': self.display_name, + 'linestyle': {'Data': 'none', 'Model': '-'}, + 'marker': {'Data': 'o', 'Model': 'none'}, + 'color': {'Data': 'black', 'Model': 'red'}, + 'markerfacecolor': {'Data': 'none', 'Model': 'none'}, } if plot_components: for comp_name in component_dataset.keys(): - plot_kwargs_defaults["linestyle"][comp_name] = "--" - plot_kwargs_defaults["marker"][comp_name] = None + plot_kwargs_defaults['linestyle'][comp_name] = '--' + plot_kwargs_defaults['marker'][comp_name] = None # Overwrite defaults with any user-provided kwargs plot_kwargs_defaults.update(kwargs) @@ -236,7 +232,7 @@ def _require_Q_index(self) -> int: int: The Q index. """ if self._Q_index is None: - raise ValueError("Q_index must be set.") + raise ValueError('Q_index must be set.') return self._Q_index def _on_Q_index_changed(self) -> None: @@ -248,13 +244,12 @@ def _on_Q_index_changed(self) -> None: self._convolver = self._create_convolver() def _extract_x_y_weights_from_experiment(self): - """ - Extract the x, y, and weights arrays from the experiment for + """Extract the x, y, and weights arrays from the experiment for the current Q index. """ Q_index = self._require_Q_index() - data = self.experiment.data["Q", Q_index] - x = data.coords["energy"].values + data = self.experiment.data['Q', Q_index] + x = data.coords['energy'].values y = data.values e = data.variances**0.5 weights = 1.0 / e @@ -304,9 +299,7 @@ def _evaluate_components( if not convolve: return components.evaluate(energy - energy_offset) - resolution = self.instrument_model.resolution_model.get_component_collection( - Q_index - ) + resolution = self.instrument_model.resolution_model.get_component_collection(Q_index) if resolution.is_empty: return components.evaluate(energy - energy_offset) @@ -368,10 +361,8 @@ def _evaluate_background(self) -> np.ndarray: np.ndarray: The evaluated background contribution. """ Q_index = self._require_Q_index() - background_components = ( - self.instrument_model.background_model.get_component_collection( - Q_index=Q_index - ) + background_components = self.instrument_model.background_model.get_component_collection( + Q_index=Q_index ) return self._evaluate_components( components=background_components, @@ -412,8 +403,8 @@ def _create_convolver(self) -> Convolution | None: if sample_components.is_empty: return None - resolution_components = ( - self.instrument_model.resolution_model.get_component_collection(Q_index) + resolution_components = self.instrument_model.resolution_model.get_component_collection( + Q_index ) if resolution_components.is_empty: return None @@ -437,8 +428,7 @@ def _create_component_scipp_array( component: ModelComponent, background: np.ndarray | None = None, ) -> sc.DataArray: - """ - Create a scipp DataArray for a single component. Adds the + """Create a scipp DataArray for a single component. Adds the background if it is not None. Parameters: @@ -460,8 +450,7 @@ def _create_background_component_scipp_array( self, component: ModelComponent, ) -> sc.DataArray: - """ - Create a scipp DataArray for a single background component. + """Create a scipp DataArray for a single background component. Parameters: ----------- @@ -475,8 +464,7 @@ def _create_background_component_scipp_array( return self._to_scipp_array(values=values) def _create_sample_scipp_array(self) -> sc.DataArray: - """ - Create a scipp DataArray for the full sample model including + """Create a scipp DataArray for the full sample model including background. """ values = self._calculate() @@ -494,19 +482,17 @@ def _create_components_dataset_single_Q( Q_index=self.Q_index ).components - background_components = ( - self.instrument_model.background_model.get_component_collection( - Q_index=self.Q_index - ).components - ) + background_components = self.instrument_model.background_model.get_component_collection( + Q_index=self.Q_index + ).components background = self._evaluate_background() if add_background else None for component in sample_components: scipp_arrays[component.display_name] = self._create_component_scipp_array( component=component, background=background ) for component in background_components: - scipp_arrays[component.display_name] = ( - self._create_background_component_scipp_array(component=component) + scipp_arrays[component.display_name] = self._create_background_component_scipp_array( + component=component ) return sc.Dataset(scipp_arrays) @@ -520,9 +506,9 @@ def _to_scipp_array(self, values: np.ndarray) -> sc.DataArray: sc.DataArray: The converted sc.DataArray. """ return sc.DataArray( - data=sc.array(dims=["energy"], values=values), + data=sc.array(dims=['energy'], values=values), coords={ - "energy": self.energy, - "Q": self.Q[self.Q_index], + 'energy': self.energy, + 'Q': self.Q[self.Q_index], }, ) diff --git a/src/easydynamics/analysis/analysis_base.py b/src/easydynamics/analysis/analysis_base.py index a7f8f477..d8f32c65 100644 --- a/src/easydynamics/analysis/analysis_base.py +++ b/src/easydynamics/analysis/analysis_base.py @@ -16,7 +16,7 @@ class AnalysisBase(EasyScienceModelBase): def __init__( self, - display_name: str = "MyAnalysis", + display_name: str = 'MyAnalysis', unique_name: str | None = None, experiment: Experiment | None = None, sample_model: SampleModel | None = None, @@ -30,23 +30,21 @@ def __init__( elif isinstance(experiment, Experiment): self._experiment = experiment else: - raise TypeError("experiment must be an instance of Experiment or None.") + raise TypeError('experiment must be an instance of Experiment or None.') if sample_model is None: self._sample_model = SampleModel() elif isinstance(sample_model, SampleModel): self._sample_model = sample_model else: - raise TypeError("sample_model must be an instance of SampleModel or None.") + raise TypeError('sample_model must be an instance of SampleModel or None.') if instrument_model is None: self._instrument_model = InstrumentModel() elif isinstance(instrument_model, InstrumentModel): self._instrument_model = instrument_model else: - raise TypeError( - "instrument_model must be an instance of InstrumentModel or None." - ) + raise TypeError('instrument_model must be an instance of InstrumentModel or None.') if extra_parameters is not None: if isinstance(extra_parameters, Parameter): @@ -56,9 +54,7 @@ def __init__( ): self._extra_parameters = extra_parameters else: - raise TypeError( - "extra_parameters must be a Parameter or a list of Parameters." - ) + raise TypeError('extra_parameters must be a Parameter or a list of Parameters.') else: self._extra_parameters = [] @@ -76,7 +72,7 @@ def experiment(self) -> Experiment | None: @experiment.setter def experiment(self, value: Experiment) -> None: if not isinstance(value, Experiment): - raise TypeError("experiment must be an instance of Experiment") + raise TypeError('experiment must be an instance of Experiment') self._experiment = value self._on_experiment_changed() @@ -88,7 +84,7 @@ def sample_model(self) -> SampleModel: @sample_model.setter def sample_model(self, value: SampleModel) -> None: if not isinstance(value, SampleModel): - raise TypeError("sample_model must be an instance of SampleModel") + raise TypeError('sample_model must be an instance of SampleModel') self._sample_model = value self._on_sample_model_changed() @@ -100,7 +96,7 @@ def instrument_model(self) -> InstrumentModel: @instrument_model.setter def instrument_model(self, value: InstrumentModel) -> None: if not isinstance(value, InstrumentModel): - raise TypeError("instrument_model must be an instance of InstrumentModel") + raise TypeError('instrument_model must be an instance of InstrumentModel') self._instrument_model = value self._on_instrument_model_changed() @@ -112,7 +108,7 @@ def Q(self) -> sc.Variable | None: @Q.setter def Q(self, value) -> None: """Q is a read-only property derived from the Experiment.""" - raise AttributeError("Q is a read-only property derived from the Experiment.") + raise AttributeError('Q is a read-only property derived from the Experiment.') @property def energy(self) -> sc.Variable | None: @@ -126,9 +122,7 @@ def energy(self, value) -> None: """Energy is a read-only property derived from the Experiment. """ - raise AttributeError( - "energy is a read-only property derived from the Experiment." - ) + raise AttributeError('energy is a read-only property derived from the Experiment.') @property def temperature(self) -> Parameter | None: @@ -142,9 +136,7 @@ def temperature(self, value) -> None: """Temperature is a read-only property derived from the SampleModel. """ - raise AttributeError( - "temperature is a read-only property derived from the SampleModel." - ) + raise AttributeError('temperature is a read-only property derived from the SampleModel.') ############# # Other methods @@ -189,7 +181,7 @@ def _verify_Q_index(self, Q_index: int | None) -> int | None: or Q_index < 0 or (self.Q is not None and Q_index >= len(self.Q)) ): - raise IndexError("Q_index must be a valid index for the Q values.") + raise IndexError('Q_index must be a valid index for the Q values.') return Q_index ############# @@ -197,4 +189,4 @@ def _verify_Q_index(self, Q_index: int | None) -> int | None: ############# def __repr__(self) -> str: - return f"AnalysisBase(display_name={self.display_name}, unique_name={self.unique_name})" + return f'AnalysisBase(display_name={self.display_name}, unique_name={self.unique_name})' diff --git a/tests/unit/easydynamics/analysis/test_analysis1d.py b/tests/unit/easydynamics/analysis/test_analysis1d.py index 6d9e1a31..324fd710 100644 --- a/tests/unit/easydynamics/analysis/test_analysis1d.py +++ b/tests/unit/easydynamics/analysis/test_analysis1d.py @@ -20,21 +20,21 @@ class TestAnalysis1d: @pytest.fixture def analysis1d(self): - Q = sc.array(dims=["Q"], values=[1, 2, 3], unit="1/Angstrom") - energy = sc.array(dims=["energy"], values=[10.0, 20.0, 30.0], unit="meV") + Q = sc.array(dims=['Q'], values=[1, 2, 3], unit='1/Angstrom') + energy = sc.array(dims=['energy'], values=[10.0, 20.0, 30.0], unit='meV') data = sc.array( - dims=["Q", "energy"], + dims=['Q', 'energy'], values=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], variances=[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]], ) - data_array = sc.DataArray(data=data, coords={"Q": Q, "energy": energy}) + data_array = sc.DataArray(data=data, coords={'Q': Q, 'energy': energy}) experiment = Experiment(data=data_array) sample_model = SampleModel(components=Gaussian()) instrument_model = InstrumentModel() analysis1d = Analysis1d( - display_name="TestAnalysis", + display_name='TestAnalysis', experiment=experiment, sample_model=sample_model, instrument_model=instrument_model, @@ -48,7 +48,7 @@ def test_init(self, analysis1d): # WHEN THEN # EXPECT - assert analysis1d.display_name == "TestAnalysis" + assert analysis1d.display_name == 'TestAnalysis' assert isinstance(analysis1d._experiment, Experiment) assert isinstance(analysis1d._sample_model, SampleModel) assert isinstance(analysis1d._instrument_model, InstrumentModel) @@ -64,20 +64,20 @@ def test_Q_index_setter(self, analysis1d): assert analysis1d.Q_index == 1 @pytest.mark.parametrize( - "invalid_Q_index, expected_exception, expected_message", + 'invalid_Q_index, expected_exception, expected_message', [ - (-1, IndexError, "Q_index must be"), - (10, IndexError, "Q_index must be"), - ("invalid", IndexError, "Q_index must be "), - (np.nan, IndexError, "Q_index must be "), - ([1, 2], IndexError, "Q_index must be "), + (-1, IndexError, 'Q_index must be'), + (10, IndexError, 'Q_index must be'), + ('invalid', IndexError, 'Q_index must be '), + (np.nan, IndexError, 'Q_index must be '), + ([1, 2], IndexError, 'Q_index must be '), ], ids=[ - "Negative index", - "Index out of range", - "Non-integer string", - "NaN value", - "List instead of integer", + 'Negative index', + 'Index out of range', + 'Non-integer string', + 'NaN value', + 'List instead of integer', ], ) def test_Q_index_setter_incorrect_Q( @@ -127,7 +127,7 @@ def test_fit_raises_if_no_experiment(self, analysis1d): analysis1d._experiment = None # EXPECT - with pytest.raises(ValueError, match="No experiment"): + with pytest.raises(ValueError, match='No experiment'): analysis1d.fit() def test_fit_calls_fitter_with_correct_arguments(self, analysis1d): @@ -144,17 +144,17 @@ def test_fit_calls_fitter_with_correct_arguments(self, analysis1d): return_value=(fake_x, fake_y, fake_weights) ) - analysis1d._create_convolver = MagicMock(return_value="fake_convolver") + analysis1d._create_convolver = MagicMock(return_value='fake_convolver') fake_fit_result = object() fake_fitter_instance = MagicMock() fake_fitter_instance.fit.return_value = fake_fit_result with patch( - "easydynamics.analysis.analysis1d.EasyScienceFitter", + 'easydynamics.analysis.analysis1d.EasyScienceFitter', return_value=fake_fitter_instance, ) as mock_fitter: - analysis1d.as_fit_function = MagicMock(return_value="fit_func") + analysis1d.as_fit_function = MagicMock(return_value='fit_func') # THEN result = analysis1d.fit() @@ -167,7 +167,7 @@ def test_fit_calls_fitter_with_correct_arguments(self, analysis1d): mock_fitter.assert_called_once_with( fit_object=analysis1d, - fit_function="fit_func", + fit_function='fit_func', ) analysis1d._extract_x_y_weights_from_experiment.assert_called_once() @@ -204,8 +204,8 @@ def test_as_fit_function_calls_calculate(self, analysis1d): def test_get_all_variables(self, analysis1d): # WHEN - extra_par1 = Parameter(name="extra_par1", value=1.0) - extra_par2 = Parameter(name="extra_par2", value=2.0) + extra_par1 = Parameter(name='extra_par1', value=1.0) + extra_par2 = Parameter(name='extra_par2', value=2.0) analysis1d._extra_parameters = [extra_par1, extra_par2] # THEN @@ -213,12 +213,8 @@ def test_get_all_variables(self, analysis1d): # EXPECT assert isinstance(variables, list) - sample_vars = analysis1d.sample_model.get_all_variables( - Q_index=analysis1d.Q_index - ) - instrument_vars = analysis1d.instrument_model.get_all_variables( - Q_index=analysis1d.Q_index - ) + sample_vars = analysis1d.sample_model.get_all_variables(Q_index=analysis1d.Q_index) + instrument_vars = analysis1d.instrument_model.get_all_variables(Q_index=analysis1d.Q_index) extra_vars = [extra_par1, extra_par2] expected_vars = sample_vars + instrument_vars + extra_vars assert Counter(variables) == Counter(expected_vars) @@ -226,30 +222,24 @@ def test_get_all_variables(self, analysis1d): def test_plot_raises_if_no_data(self, analysis1d): analysis1d.experiment._data = None - with pytest.raises(ValueError, match="No data"): + with pytest.raises(ValueError, match='No data'): analysis1d.plot_data_and_model() def test_plot_calls_plopp_with_correct_arguments(self, analysis1d): # WHEN # Mock the data and model components to be plotted - fake_model = sc.DataArray(data=sc.array(dims=["energy"], values=[1, 2, 3])) + fake_model = sc.DataArray(data=sc.array(dims=['energy'], values=[1, 2, 3])) analysis1d._create_sample_scipp_array = MagicMock(return_value=fake_model) - fake_components = sc.Dataset( - { - "Component1": sc.DataArray( - data=sc.array(dims=["energy"], values=[0.1, 0.2, 0.3]) - ) - } - ) - analysis1d._create_components_dataset_single_Q = MagicMock( - return_value=fake_components - ) + fake_components = sc.Dataset({ + 'Component1': sc.DataArray(data=sc.array(dims=['energy'], values=[0.1, 0.2, 0.3])) + }) + analysis1d._create_components_dataset_single_Q = MagicMock(return_value=fake_components) fake_fig = object() - with patch("plopp.plot", return_value=fake_fig) as mock_plot: + with patch('plopp.plot', return_value=fake_fig) as mock_plot: # THEN result = analysis1d.plot_data_and_model() @@ -266,9 +256,9 @@ def test_plot_calls_plopp_with_correct_arguments(self, analysis1d): dataset_passed = args[0] - assert "Data" in dataset_passed - assert "Model" in dataset_passed - assert "Component1" in dataset_passed + assert 'Data' in dataset_passed + assert 'Model' in dataset_passed + assert 'Component1' in dataset_passed assert result is fake_fig @@ -288,7 +278,7 @@ def test_require_Q_index_raises_if_no_Q_index(self, analysis1d): analysis1d._Q_index = None # EXPECT - with pytest.raises(ValueError, match="Q_index must be set"): + with pytest.raises(ValueError, match='Q_index must be set'): analysis1d._require_Q_index() def test_on_Q_index_changed(self, analysis1d): @@ -321,41 +311,35 @@ def test_extract_x_y_weights_from_experiment(self, analysis1d): ############# @pytest.mark.parametrize( - "background", + 'background', [ None, np.array([0.5, 0.5, 0.5]), ], ids=[ - "No background", - "With background", + 'No background', + 'With background', ], ) def test_create_component_scipp_array(self, analysis1d, background): """Test that _create_component_scipp_array correctly evaluates the component, adds the background and calls _to_scipp_array with the correct values.""" - "" + '' # WHEN # Mock the functions that will be called. - analysis1d._evaluate_sample_component = MagicMock( - return_value=np.array([1.0, 2.0, 3.0]) - ) + analysis1d._evaluate_sample_component = MagicMock(return_value=np.array([1.0, 2.0, 3.0])) analysis1d._to_scipp_array = MagicMock() component = object() # THEN - analysis1d._create_component_scipp_array( - component=component, background=background - ) + analysis1d._create_component_scipp_array(component=component, background=background) # EXPECT - analysis1d._evaluate_sample_component.assert_called_once_with( - component=component - ) + analysis1d._evaluate_sample_component.assert_called_once_with(component=component) expected_values = np.array([1.0, 2.0, 3.0]) if background is not None: @@ -367,7 +351,7 @@ def test_create_component_scipp_array(self, analysis1d, background): _, kwargs = analysis1d._to_scipp_array.call_args np.testing.assert_array_equal( - kwargs["values"], + kwargs['values'], expected_values, ) @@ -390,9 +374,7 @@ def test_create_background_component_scipp_array(self, analysis1d): analysis1d._create_background_component_scipp_array(component=component) # EXPECT - analysis1d._evaluate_background_component.assert_called_once_with( - component=component - ) + analysis1d._evaluate_background_component.assert_called_once_with(component=component) analysis1d._to_scipp_array.assert_called_once() @@ -400,7 +382,7 @@ def test_create_background_component_scipp_array(self, analysis1d): _, kwargs = analysis1d._to_scipp_array.call_args np.testing.assert_array_equal( - kwargs["values"], + kwargs['values'], np.array([1.0, 2.0, 3.0]), ) @@ -427,14 +409,14 @@ def test_create_sample_scipp_array(self, analysis1d): _, kwargs = analysis1d._to_scipp_array.call_args np.testing.assert_array_equal( - kwargs["values"], + kwargs['values'], np.array([1.0, 2.0, 3.0]), ) @pytest.mark.parametrize( - "add_background", + 'add_background', [True, False], - ids=["With background", "Without background"], + ids=['With background', 'Without background'], ) def test_create_components_dataset_single_Q( self, @@ -453,7 +435,7 @@ def test_create_components_dataset_single_Q( # ---- Sample component ---- sample_component = MagicMock() - sample_component.display_name = "sample_comp" + sample_component.display_name = 'sample_comp' sample_collection = MagicMock() sample_collection.components = [sample_component] @@ -464,13 +446,13 @@ def test_create_components_dataset_single_Q( # ---- Background component ---- background_component = MagicMock() - background_component.display_name = "background_comp" + background_component.display_name = 'background_comp' background_collection = MagicMock() background_collection.components = [background_component] - analysis1d.instrument_model.background_model.get_component_collection = ( - MagicMock(return_value=background_collection) + analysis1d.instrument_model.background_model.get_component_collection = MagicMock( + return_value=background_collection ) # ---- Background evaluation ---- @@ -478,26 +460,18 @@ def test_create_components_dataset_single_Q( analysis1d._evaluate_background = MagicMock(return_value=background_value) # ---- Return scipp DataArrays ---- - fake_sample_da = sc.DataArray( - data=sc.array(dims=["energy"], values=[1.0, 2.0, 3.0]) - ) + fake_sample_da = sc.DataArray(data=sc.array(dims=['energy'], values=[1.0, 2.0, 3.0])) - analysis1d._create_component_scipp_array = MagicMock( - return_value=fake_sample_da - ) + analysis1d._create_component_scipp_array = MagicMock(return_value=fake_sample_da) - fake_background_da = sc.DataArray( - data=sc.array(dims=["energy"], values=[4.0, 5.0, 6.0]) - ) + fake_background_da = sc.DataArray(data=sc.array(dims=['energy'], values=[4.0, 5.0, 6.0])) analysis1d._create_background_component_scipp_array = MagicMock( return_value=fake_background_da ) # THEN - dataset = analysis1d._create_components_dataset_single_Q( - add_background=add_background - ) + dataset = analysis1d._create_components_dataset_single_Q(add_background=add_background) # EXPECT @@ -525,13 +499,13 @@ def test_create_components_dataset_single_Q( analysis1d._create_component_scipp_array.assert_called_once() _, kwargs = analysis1d._create_component_scipp_array.call_args - assert kwargs["component"] is sample_component + assert kwargs['component'] is sample_component if expected_background is None: - assert kwargs["background"] is None + assert kwargs['background'] is None else: np.testing.assert_array_equal( - kwargs["background"], + kwargs['background'], expected_background, ) @@ -542,8 +516,8 @@ def test_create_components_dataset_single_Q( # Dataset content assert isinstance(dataset, sc.Dataset) - assert "sample_comp" in dataset - assert "background_comp" in dataset + assert 'sample_comp' in dataset + assert 'background_comp' in dataset def test_to_scipp_array(self, analysis1d): # WHEN @@ -557,10 +531,10 @@ def test_to_scipp_array(self, analysis1d): np.testing.assert_array_equal(scipp_array.values, numpy_array) np.testing.assert_array_equal( - scipp_array.coords["energy"].values, analysis1d.experiment.energy.values + scipp_array.coords['energy'].values, analysis1d.experiment.energy.values ) np.testing.assert_array_equal( - scipp_array.coords["Q"].values, + scipp_array.coords['Q'].values, analysis1d.experiment.Q[analysis1d.Q_index].values, ) diff --git a/tests/unit/easydynamics/analysis/test_analysis_base.py b/tests/unit/easydynamics/analysis/test_analysis_base.py index 66380520..62248e56 100644 --- a/tests/unit/easydynamics/analysis/test_analysis_base.py +++ b/tests/unit/easydynamics/analysis/test_analysis_base.py @@ -20,7 +20,7 @@ def analysis_base(self): sample_model = SampleModel() instrument_model = InstrumentModel() analysis_base = AnalysisBase( - display_name="TestAnalysis", + display_name='TestAnalysis', experiment=experiment, sample_model=sample_model, instrument_model=instrument_model, @@ -31,67 +31,65 @@ def test_init(self, analysis_base): # WHEN THEN # EXPECT - assert analysis_base.display_name == "TestAnalysis" + assert analysis_base.display_name == 'TestAnalysis' assert isinstance(analysis_base._experiment, Experiment) assert isinstance(analysis_base._sample_model, SampleModel) assert isinstance(analysis_base._instrument_model, InstrumentModel) assert analysis_base._extra_parameters == [] def test_init_extra_parameter(self): - extra_parameter = Parameter(name="param1", value=1.0) + extra_parameter = Parameter(name='param1', value=1.0) analysis = AnalysisBase(extra_parameters=extra_parameter) assert analysis._extra_parameters == [extra_parameter] def test_init_extra_parameters(self): extra_parameters = [ - Parameter(name="param1", value=1.0), - Parameter(name="param2", value=2.0), + Parameter(name='param1', value=1.0), + Parameter(name='param2', value=2.0), ] analysis = AnalysisBase(extra_parameters=extra_parameters) assert analysis._extra_parameters == extra_parameters def test_init_calls_on_experiment_changed(self): - with patch.object( - AnalysisBase, "_on_experiment_changed" - ) as mock_on_experiment_changed: + with patch.object(AnalysisBase, '_on_experiment_changed') as mock_on_experiment_changed: AnalysisBase() mock_on_experiment_changed.assert_called_once() @pytest.mark.parametrize( - "kwargs, expected_exception, expected_message", + 'kwargs, expected_exception, expected_message', [ ( - {"experiment": 123}, + {'experiment': 123}, TypeError, - "experiment must be an instance of Experiment", + 'experiment must be an instance of Experiment', ), ( - {"sample_model": "not a model"}, + {'sample_model': 'not a model'}, TypeError, - "sample_model must be an instance of SampleModel", + 'sample_model must be an instance of SampleModel', ), ( - {"instrument_model": "not a model"}, + {'instrument_model': 'not a model'}, TypeError, - "instrument_model must be an instance of InstrumentModel", + 'instrument_model must be an instance of InstrumentModel', ), ( - {"extra_parameters": 123}, + {'extra_parameters': 123}, TypeError, - "extra_parameters must be a Parameter or a list of Parameters.", + 'extra_parameters must be a Parameter or a list of Parameters.', ), ( - {"extra_parameters": [123]}, + {'extra_parameters': [123]}, TypeError, - "extra_parameters must be a Parameter or a list of Parameters.", + 'extra_parameters must be a Parameter or a list of Parameters.', ), ], ids=[ - "invalid experiment", - "invalid sample_model", - "invalid instrument_model", - "invalid extra_parameters", - "invalid extra_parameters list", + 'invalid experiment', + 'invalid sample_model', + 'invalid instrument_model', + 'invalid extra_parameters', + 'invalid extra_parameters list', ], ) def test_init_invalid_inputs(self, kwargs, expected_exception, expected_message): @@ -99,18 +97,14 @@ def test_init_invalid_inputs(self, kwargs, expected_exception, expected_message) AnalysisBase(**kwargs) def test_experiment_setter_calls_on_experiment_changed(self, analysis_base): - with patch.object( - analysis_base, "_on_experiment_changed" - ) as mock_on_experiment_changed: + with patch.object(analysis_base, '_on_experiment_changed') as mock_on_experiment_changed: new_experiment = Experiment() analysis_base.experiment = new_experiment mock_on_experiment_changed.assert_called_once() def test_experiment_setter_invalid_type(self, analysis_base): - with pytest.raises( - TypeError, match="experiment must be an instance of Experiment" - ): - analysis_base.experiment = "not an experiment" + with pytest.raises(TypeError, match='experiment must be an instance of Experiment'): + analysis_base.experiment = 'not an experiment' def test_experiment_setter_valid(self, analysis_base): new_experiment = Experiment() @@ -118,10 +112,8 @@ def test_experiment_setter_valid(self, analysis_base): assert analysis_base.experiment == new_experiment def test_sample_model_setter_invalid_type(self, analysis_base): - with pytest.raises( - TypeError, match="sample_model must be an instance of SampleModel" - ): - analysis_base.sample_model = "not a sample model" + with pytest.raises(TypeError, match='sample_model must be an instance of SampleModel'): + analysis_base.sample_model = 'not a sample model' def test_sample_model_setter_valid(self, analysis_base): new_sample_model = SampleModel() @@ -130,7 +122,7 @@ def test_sample_model_setter_valid(self, analysis_base): def test_sample_model_setter_calls_on_sample_model_changed(self, analysis_base): with patch.object( - analysis_base, "_on_sample_model_changed" + analysis_base, '_on_sample_model_changed' ) as mock_on_sample_model_changed: new_sample_model = SampleModel() analysis_base.sample_model = new_sample_model @@ -138,20 +130,18 @@ def test_sample_model_setter_calls_on_sample_model_changed(self, analysis_base): def test_instrument_model_setter_invalid_type(self, analysis_base): with pytest.raises( - TypeError, match="instrument_model must be an instance of InstrumentModel" + TypeError, match='instrument_model must be an instance of InstrumentModel' ): - analysis_base.instrument_model = "not an instrument model" + analysis_base.instrument_model = 'not an instrument model' def test_instrument_model_setter_valid(self, analysis_base): new_instrument_model = InstrumentModel() analysis_base.instrument_model = new_instrument_model assert analysis_base.instrument_model == new_instrument_model - def test_instrument_model_setter_calls_on_instrument_model_changed( - self, analysis_base - ): + def test_instrument_model_setter_calls_on_instrument_model_changed(self, analysis_base): with patch.object( - analysis_base, "_on_instrument_model_changed" + analysis_base, '_on_instrument_model_changed' ) as mock_on_instrument_model_changed: new_instrument_model = InstrumentModel() analysis_base.instrument_model = new_instrument_model @@ -163,7 +153,7 @@ def test_Q_property(self, analysis_base): # Patch the 'experiment' attribute's Q property with patch.object( - type(analysis_base.experiment), "Q", new_callable=PropertyMock + type(analysis_base.experiment), 'Q', new_callable=PropertyMock ) as mock_Q: mock_Q.return_value = fake_Q result = analysis_base.Q # Access the property @@ -173,7 +163,7 @@ def test_Q_property(self, analysis_base): def test_Q_setter_raises(self, analysis_base): with pytest.raises( AttributeError, - match="Q is a read-only property derived from the Experiment.", + match='Q is a read-only property derived from the Experiment.', ): analysis_base.Q = [1, 2, 3] @@ -183,7 +173,7 @@ def test_energy_property(self, analysis_base): # Patch the 'experiment' attribute's energy property with patch.object( - type(analysis_base.experiment), "energy", new_callable=PropertyMock + type(analysis_base.experiment), 'energy', new_callable=PropertyMock ) as mock_energy: mock_energy.return_value = fake_energy result = analysis_base.energy # Access the property @@ -193,7 +183,7 @@ def test_energy_property(self, analysis_base): def test_energy_setter_raises(self, analysis_base): with pytest.raises( AttributeError, - match="energy is a read-only property derived from the Experiment.", + match='energy is a read-only property derived from the Experiment.', ): analysis_base.energy = [10, 20, 30] @@ -201,7 +191,7 @@ def test_temperature_property_no_temperature(self, analysis_base): # Patch the 'experiment' attribute's temperature property to # return None with patch.object( - type(analysis_base.sample_model), "temperature", new_callable=PropertyMock + type(analysis_base.sample_model), 'temperature', new_callable=PropertyMock ) as mock_temperature: mock_temperature.return_value = None result = analysis_base.temperature # Access the property @@ -214,7 +204,7 @@ def test_temperature_property(self, analysis_base): # Patch the 'sample_model' attribute's temperature property with patch.object( - type(analysis_base.sample_model), "temperature", new_callable=PropertyMock + type(analysis_base.sample_model), 'temperature', new_callable=PropertyMock ) as mock_temperature: mock_temperature.return_value = fake_temperature result = analysis_base.temperature # Access the property @@ -224,7 +214,7 @@ def test_temperature_property(self, analysis_base): def test_temperature_setter_raises(self, analysis_base): with pytest.raises( AttributeError, - match="temperature is a read-only property", + match='temperature is a read-only property', ): analysis_base.temperature = 300 @@ -234,7 +224,7 @@ def test_on_experiment_changed_updates_Q(self, analysis_base): # Patch the Q property of analysis_base with patch.object( - type(analysis_base.experiment), "Q", new_callable=PropertyMock + type(analysis_base.experiment), 'Q', new_callable=PropertyMock ) as mock_Q: mock_Q.return_value = fake_Q @@ -253,7 +243,7 @@ def test_on_sample_model_changed_updates_Q(self, analysis_base): # Patch the Q property of analysis_base with patch.object( - type(analysis_base.experiment), "Q", new_callable=PropertyMock + type(analysis_base.experiment), 'Q', new_callable=PropertyMock ) as mock_Q: mock_Q.return_value = fake_Q @@ -268,7 +258,7 @@ def test_on_instrument_model_changed_updates_Q(self, analysis_base): # Patch the Q property of analysis_base with patch.object( - type(analysis_base.experiment), "Q", new_callable=PropertyMock + type(analysis_base.experiment), 'Q', new_callable=PropertyMock ) as mock_Q: mock_Q.return_value = fake_Q @@ -290,5 +280,5 @@ def test_verify_Q_index_invalid(self, analysis_base): invalid_Q_index = -1 # THEN / EXPECT - with pytest.raises(IndexError, match="Q_index must be a valid index"): + with pytest.raises(IndexError, match='Q_index must be a valid index'): analysis_base._verify_Q_index(invalid_Q_index) diff --git a/tests/unit/easydynamics/convolution/test_numerical_convolution.py b/tests/unit/easydynamics/convolution/test_numerical_convolution.py index 503487f6..9201d07c 100644 --- a/tests/unit/easydynamics/convolution/test_numerical_convolution.py +++ b/tests/unit/easydynamics/convolution/test_numerical_convolution.py @@ -10,22 +10,20 @@ from easydynamics.convolution.numerical_convolution import NumericalConvolution from easydynamics.sample_model import Gaussian from easydynamics.sample_model.component_collection import ComponentCollection -from easydynamics.utils.detailed_balance import ( - _detailed_balance_factor as detailed_balance_factor, -) +from easydynamics.utils.detailed_balance import _detailed_balance_factor as detailed_balance_factor class TestNumericalConvolution: @pytest.fixture def default_numerical_convolution(self): energy = np.linspace(-10, 10, 5001) - sample_components = ComponentCollection(display_name="ComponentCollection") + sample_components = ComponentCollection(display_name='ComponentCollection') sample_components.append_component( - Gaussian(display_name="Gaussian1", area=2.0, center=0.1, width=0.4) + Gaussian(display_name='Gaussian1', area=2.0, center=0.1, width=0.4) ) - resolution_components = ComponentCollection(display_name="ResolutionModel") + resolution_components = ComponentCollection(display_name='ResolutionModel') resolution_components.append_component( - Gaussian(display_name="GaussianRes", area=3.0, center=0.2, width=0.5) + Gaussian(display_name='GaussianRes', area=3.0, center=0.2, width=0.5) ) return NumericalConvolution( @@ -42,23 +40,19 @@ def test_init(self, default_numerical_convolution): # WHEN THEN EXPECT assert isinstance(default_numerical_convolution, NumericalConvolution) assert isinstance(default_numerical_convolution.energy, sc.Variable) - assert np.allclose( - default_numerical_convolution.energy.values, np.linspace(-10, 10, 5001) - ) - assert isinstance( - default_numerical_convolution._sample_components, ComponentCollection - ) + assert np.allclose(default_numerical_convolution.energy.values, np.linspace(-10, 10, 5001)) + assert isinstance(default_numerical_convolution._sample_components, ComponentCollection) assert isinstance( default_numerical_convolution._resolution_components, ComponentCollection ) assert default_numerical_convolution.upsample_factor == 5 assert default_numerical_convolution.extension_factor == 0.2 assert default_numerical_convolution.temperature is None - assert default_numerical_convolution.energy_unit == "meV" + assert default_numerical_convolution.energy_unit == 'meV' assert default_numerical_convolution.normalize_detailed_balance is True assert isinstance(default_numerical_convolution._energy_grid, EnergyGrid) - @pytest.mark.parametrize("upsample_factor", [None, 5]) + @pytest.mark.parametrize('upsample_factor', [None, 5]) def test_convolution(self, default_numerical_convolution, upsample_factor): """ Test that convolution of two Gaussians produces the @@ -70,15 +64,13 @@ def test_convolution(self, default_numerical_convolution, upsample_factor): result = default_numerical_convolution.convolution() # EXPECT - expected_area = ( - 2.0 * 3.0 - ) # area of sample_components * area of resolution_components + expected_area = 2.0 * 3.0 # area of sample_components * area of resolution_components expected_center = ( 0.1 + 0.2 + 0.4 ) # center of sample_components + center of resolution_components expected_width = np.sqrt(0.4**2 + 0.5**2) # sqrt(width_sample^2 + width_res^2) expected_result = Gaussian( - display_name="ExpectedConvolution", + display_name='ExpectedConvolution', area=expected_area, center=expected_center, width=expected_width, @@ -107,12 +99,8 @@ def test_convolution_with_temperature( resolution_vals = default_numerical_convolution._resolution_components.evaluate( default_numerical_convolution.energy.values ) - DBF = detailed_balance_factor( - energy=default_numerical_convolution.energy, temperature=5.0 - ) - expected_result = fftconvolve( - sample_valds * DBF, resolution_vals, mode="same" - ) * ( + DBF = detailed_balance_factor(energy=default_numerical_convolution.energy, temperature=5.0) + expected_result = fftconvolve(sample_valds * DBF, resolution_vals, mode='same') * ( default_numerical_convolution.energy.values[1] - default_numerical_convolution.energy.values[0] ) From 61f49bdfa5851df63934e788f489da6a95d49fcf Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Wed, 18 Feb 2026 16:24:54 +0100 Subject: [PATCH 25/27] update component_collection among other things --- docs/docs/tutorials/analysis.ipynb | 18 +- docs/docs/tutorials/analysis1d.ipynb | 8 + src/easydynamics/analysis/analysis1d.py | 74 ++++---- src/easydynamics/convolution/convolution.py | 47 ++--- .../sample_model/component_collection.py | 102 +++++++---- src/easydynamics/sample_model/model_base.py | 41 +++-- .../easydynamics/analysis/test_analysis1d.py | 161 ++++++++++-------- 7 files changed, 271 insertions(+), 180 deletions(-) diff --git a/docs/docs/tutorials/analysis.ipynb b/docs/docs/tutorials/analysis.ipynb index edd8d626..d478d9f3 100644 --- a/docs/docs/tutorials/analysis.ipynb +++ b/docs/docs/tutorials/analysis.ipynb @@ -135,6 +135,16 @@ "vanadium_analysis.plot_parameters(names=['Gaussian width'])" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "572664a0", + "metadata": {}, + "outputs": [], + "source": [ + "vanadium_analysis.plot_parameters(names=['energy_offset'])" + ] + }, { "cell_type": "code", "execution_count": null, @@ -232,14 +242,6 @@ "# It will be possible to fit this to a DiffusionModel, but that will\n", "# come later." ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "508c9247", - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { diff --git a/docs/docs/tutorials/analysis1d.ipynb b/docs/docs/tutorials/analysis1d.ipynb index 2aff88b3..5a14a05f 100644 --- a/docs/docs/tutorials/analysis1d.ipynb +++ b/docs/docs/tutorials/analysis1d.ipynb @@ -75,6 +75,14 @@ "fit_result = my_analysis.fit()\n", "my_analysis.plot_data_and_model()" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c055bd93", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index 7c324952..a61b88a5 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -24,7 +24,7 @@ class Analysis1d(AnalysisBase): def __init__( self, - display_name: str = 'MyAnalysis', + display_name: str = "MyAnalysis", unique_name: str | None = None, experiment: Experiment | None = None, sample_model: SampleModel | None = None, @@ -114,7 +114,7 @@ def fit(self) -> FitResults: parameter optimization for performance reasons. """ if self._experiment is None: - raise ValueError('No experiment is associated with this Analysis.') + raise ValueError("No experiment is associated with this Analysis.") # Create convolver once to reuse during fitting self._convolver = self._create_convolver() @@ -183,33 +183,37 @@ def plot_data_and_model( import plopp as pp if self.experiment.data is None: - raise ValueError('No data to plot. Please load data first.') + raise ValueError("No data to plot. Please load data first.") - data = self.experiment.data['Q', self.Q_index] + data = self.experiment.data["Q", self.Q_index] model_array = self._create_sample_scipp_array() - component_dataset = self._create_components_dataset_single_Q(add_background=add_background) + component_dataset = self._create_components_dataset_single_Q( + add_background=add_background + ) # Create a dataset containing the data, model, and individual # components for plotting. - data_and_model = sc.Dataset({ - 'Data': data, - 'Model': model_array, - }) + data_and_model = sc.Dataset( + { + "Data": data, + "Model": model_array, + } + ) data_and_model = sc.merge(data_and_model, component_dataset) plot_kwargs_defaults = { - 'title': self.display_name, - 'linestyle': {'Data': 'none', 'Model': '-'}, - 'marker': {'Data': 'o', 'Model': 'none'}, - 'color': {'Data': 'black', 'Model': 'red'}, - 'markerfacecolor': {'Data': 'none', 'Model': 'none'}, + "title": self.display_name, + "linestyle": {"Data": "none", "Model": "-"}, + "marker": {"Data": "o", "Model": "none"}, + "color": {"Data": "black", "Model": "red"}, + "markerfacecolor": {"Data": "none", "Model": "none"}, } if plot_components: for comp_name in component_dataset.keys(): - plot_kwargs_defaults['linestyle'][comp_name] = '--' - plot_kwargs_defaults['marker'][comp_name] = None + plot_kwargs_defaults["linestyle"][comp_name] = "--" + plot_kwargs_defaults["marker"][comp_name] = None # Overwrite defaults with any user-provided kwargs plot_kwargs_defaults.update(kwargs) @@ -232,7 +236,7 @@ def _require_Q_index(self) -> int: int: The Q index. """ if self._Q_index is None: - raise ValueError('Q_index must be set.') + raise ValueError("Q_index must be set.") return self._Q_index def _on_Q_index_changed(self) -> None: @@ -248,8 +252,8 @@ def _extract_x_y_weights_from_experiment(self): the current Q index. """ Q_index = self._require_Q_index() - data = self.experiment.data['Q', Q_index] - x = data.coords['energy'].values + data = self.experiment.data["Q", Q_index] + x = data.coords["energy"].values y = data.values e = data.variances**0.5 weights = 1.0 / e @@ -299,7 +303,9 @@ def _evaluate_components( if not convolve: return components.evaluate(energy - energy_offset) - resolution = self.instrument_model.resolution_model.get_component_collection(Q_index) + resolution = self.instrument_model.resolution_model.get_component_collection( + Q_index + ) if resolution.is_empty: return components.evaluate(energy - energy_offset) @@ -361,8 +367,10 @@ def _evaluate_background(self) -> np.ndarray: np.ndarray: The evaluated background contribution. """ Q_index = self._require_Q_index() - background_components = self.instrument_model.background_model.get_component_collection( - Q_index=Q_index + background_components = ( + self.instrument_model.background_model.get_component_collection( + Q_index=Q_index + ) ) return self._evaluate_components( components=background_components, @@ -403,8 +411,8 @@ def _create_convolver(self) -> Convolution | None: if sample_components.is_empty: return None - resolution_components = self.instrument_model.resolution_model.get_component_collection( - Q_index + resolution_components = ( + self.instrument_model.resolution_model.get_component_collection(Q_index) ) if resolution_components.is_empty: return None @@ -482,17 +490,19 @@ def _create_components_dataset_single_Q( Q_index=self.Q_index ).components - background_components = self.instrument_model.background_model.get_component_collection( - Q_index=self.Q_index - ).components + background_components = ( + self.instrument_model.background_model.get_component_collection( + Q_index=self.Q_index + ).components + ) background = self._evaluate_background() if add_background else None for component in sample_components: scipp_arrays[component.display_name] = self._create_component_scipp_array( component=component, background=background ) for component in background_components: - scipp_arrays[component.display_name] = self._create_background_component_scipp_array( - component=component + scipp_arrays[component.display_name] = ( + self._create_background_component_scipp_array(component=component) ) return sc.Dataset(scipp_arrays) @@ -506,9 +516,9 @@ def _to_scipp_array(self, values: np.ndarray) -> sc.DataArray: sc.DataArray: The converted sc.DataArray. """ return sc.DataArray( - data=sc.array(dims=['energy'], values=values), + data=sc.array(dims=["energy"], values=values), coords={ - 'energy': self.energy, - 'Q': self.Q[self.Q_index], + "energy": self.energy, + "Q": self.Q[self.Q_index], }, ) diff --git a/src/easydynamics/convolution/convolution.py b/src/easydynamics/convolution/convolution.py index b4fa19e3..827087c1 100644 --- a/src/easydynamics/convolution/convolution.py +++ b/src/easydynamics/convolution/convolution.py @@ -59,16 +59,16 @@ class Convolution(NumericalConvolutionBase): # When these attributes are changed, the convolution plan # needs to be rebuilt _invalidate_plan_on_change = { - 'energy', - '_energy', - '_energy_grid', - '_sample_components', - '_resolution_components', - '_temperature', - '_upsample_factor', - '_extension_factor', - '_energy_unit', - '_normalize_detailed_balance', + "energy", + "_energy", + "_energy_grid", + "_sample_components", + "_resolution_components", + "_temperature", + "_upsample_factor", + "_extension_factor", + "_energy_unit", + "_normalize_detailed_balance", } def __init__( @@ -80,8 +80,8 @@ def __init__( upsample_factor: Numeric = 5, extension_factor: Numeric = 0.2, temperature: Parameter | Numeric | None = None, - temperature_unit: str | sc.Unit = 'K', - energy_unit: str | sc.Unit = 'meV', + temperature_unit: str | sc.Unit = "K", + energy_unit: str | sc.Unit = "meV", normalize_detailed_balance: bool = True, ): self._convolution_plan_is_valid = False @@ -137,8 +137,8 @@ def convolution( def _convolve_delta_functions(self) -> np.ndarray: "Convolve delta function components of the sample model with" - 'the resolution components.' - 'No detailed balance correction is applied to delta functions.' + "the resolution components." + "No detailed balance correction is applied to delta functions." return sum( delta.area.value * self._resolution_components.evaluate( @@ -168,19 +168,19 @@ def _check_if_pair_is_analytic( if not isinstance(sample_component, ModelComponent): raise TypeError( - f'`sample_component` is an instance of {type(sample_component).__name__}, \ - but must be a ModelComponent.' + f"`sample_component` is an instance of {type(sample_component).__name__}, \ + but must be a ModelComponent." ) if not isinstance(resolution_component, ModelComponent): raise TypeError( - f'`resolution_component` is an instance of {type(resolution_component).__name__}, \ - but must be a ModelComponent.' + f"`resolution_component` is an instance of {type(resolution_component).__name__}, \ + but must be a ModelComponent." ) if isinstance(resolution_component, DeltaFunction): raise TypeError( - 'resolution components contains delta functions. This is not supported.' + "resolution components contains delta functions. This is not supported." ) analytical_types = (Gaussian, Lorentzian, Voigt) @@ -219,7 +219,9 @@ def _build_convolution_plan(self) -> None: pair_is_analytic = [] for resolution_component in self._resolution_components.components: pair_is_analytic.append( - self._check_if_pair_is_analytic(sample_component, resolution_component) + self._check_if_pair_is_analytic( + sample_component, resolution_component + ) ) # If all resolution components can be convolved analytically # with this sample component, add it to analytical @@ -283,5 +285,8 @@ def __setattr__(self, name, value): if name in self._invalidate_plan_on_change: self._convolution_plan_is_valid = False - if getattr(self, '_reactions_enabled', False) and name in self._invalidate_plan_on_change: + if ( + getattr(self, "_reactions_enabled", False) + and name in self._invalidate_plan_on_change + ): self._build_convolution_plan() diff --git a/src/easydynamics/sample_model/component_collection.py b/src/easydynamics/sample_model/component_collection.py index 4f4ebe2a..fec3f3dd 100644 --- a/src/easydynamics/sample_model/component_collection.py +++ b/src/easydynamics/sample_model/component_collection.py @@ -31,8 +31,8 @@ class ComponentCollection(ModelBase): def __init__( self, - unit: str | sc.Unit = 'meV', - display_name: str = 'MyComponentCollection', + unit: str | sc.Unit = "meV", + display_name: str = "MyComponentCollection", unique_name: str | None = None, components: List[ModelComponent] | None = None, ): @@ -54,7 +54,7 @@ def __init__( if unit is not None and not isinstance(unit, (str, sc.Unit)): raise TypeError( - f'unit must be None, a string, or a scipp Unit, got {type(unit).__name__}' + f"unit must be None, a string, or a scipp Unit, got {type(unit).__name__}" ) self._unit = unit self._components = [] @@ -62,31 +62,55 @@ def __init__( # Add initial components if provided. Used for serialization. if components is not None: if not isinstance(components, list): - raise TypeError('components must be a list of ModelComponent instances.') + raise TypeError( + "components must be a list of ModelComponent instances." + ) for comp in components: self.append_component(comp) - def append_component(self, component: ModelComponent | 'ComponentCollection') -> None: - match component: - case ModelComponent(): - components = (component,) - case ComponentCollection(components=components): - pass - case _: - raise TypeError('Component must be a ModelComponent or ComponentCollection.') + def append_component( + self, component: ModelComponent | "ComponentCollection" + ) -> None: + """ + Append a model component or the components from another + ComponentCollection to this ComponentCollection. + + Parameters + ---------- + component : ModelComponent or ComponentCollection + The component to append. + Raises + ------ + TypeError + If the component is not a ModelComponent or + ComponentCollection. + """ + if not isinstance(component, (ModelComponent, ComponentCollection)): + raise TypeError( + "Component must be an instance of ModelComponent or ComponentCollection. " + f"Got {type(component).__name__} instead." + ) + elif isinstance(component, ModelComponent): + components = (component,) + elif isinstance(component, ComponentCollection): + components = component.components + else: + raise TypeError( + "Component must be an instance of ModelComponent or ComponentCollection." + ) for comp in components: if comp in self._components: raise ValueError( f"Component '{comp.unique_name}' is already in the collection. " - f'Existing components: {self.list_component_names()}' + f"Existing components: {self.list_component_names()}" ) self._components.append(comp) def remove_component(self, unique_name: str) -> None: if not isinstance(unique_name, str): - raise TypeError('Component name must be a string.') + raise TypeError("Component name must be a string.") for comp in self._components: if comp.unique_name == unique_name: @@ -95,8 +119,8 @@ def remove_component(self, unique_name: str) -> None: raise KeyError( f"No component named '{unique_name}' exists. " - f'Did you accidentally use the display_name? ' - f'Here is a list of the components in the collection: {self.list_component_names()}' + f"Did you accidentally use the display_name? " + f"Here is a list of the components in the collection: {self.list_component_names()}" ) @property @@ -106,12 +130,12 @@ def components(self) -> list[ModelComponent]: @components.setter def components(self, components: List[ModelComponent]) -> None: if not isinstance(components, list): - raise TypeError('components must be a list of ModelComponent instances.') + raise TypeError("components must be a list of ModelComponent instances.") for comp in components: if not isinstance(comp, ModelComponent): raise TypeError( - 'All items in components must be instances of ModelComponent. ' - f'Got {type(comp).__name__} instead.' + "All items in components must be instances of ModelComponent. " + f"Got {type(comp).__name__} instead." ) self._components = components @@ -123,8 +147,8 @@ def is_empty(self) -> bool: @is_empty.setter def is_empty(self, value: bool) -> None: raise AttributeError( - 'is_empty is a read-only property that indicates ' - 'whether the collection has components.' + "is_empty is a read-only property that indicates " + "whether the collection has components." ) def list_component_names(self) -> List[str]: @@ -146,27 +170,27 @@ def normalize_area(self) -> None: # Useful for convolutions. """Normalize the areas of all components so they sum to 1.""" if not self.components: - raise ValueError('No components in the model to normalize.') + raise ValueError("No components in the model to normalize.") area_params = [] - total_area = Parameter(name='total_area', value=0.0, unit=self._unit) + total_area = Parameter(name="total_area", value=0.0, unit=self._unit) for component in self.components: - if hasattr(component, 'area'): + if hasattr(component, "area"): area_params.append(component.area) total_area += component.area else: warnings.warn( f"Component '{component.unique_name}' does not have an 'area' attribute " - f'and will be skipped in normalization.', + f"and will be skipped in normalization.", UserWarning, ) if total_area.value == 0: - raise ValueError('Total area is zero; cannot normalize.') + raise ValueError("Total area is zero; cannot normalize.") if not np.isfinite(total_area.value): - raise ValueError('Total area is not finite; cannot normalize.') + raise ValueError("Total area is not finite; cannot normalize.") for param in area_params: param.value /= total_area.value @@ -178,7 +202,11 @@ def get_all_variables(self) -> list[DescriptorBase]: List[Parameter]: List of parameters in the component. """ - return [var for component in self.components for var in component.get_all_variables()] + return [ + var + for component in self.components + for var in component.get_all_variables() + ] @property def unit(self) -> str | sc.Unit: @@ -194,8 +222,8 @@ def unit(self) -> str | sc.Unit: def unit(self, unit_str: str) -> None: raise AttributeError( ( - f'Unit is read-only. Use convert_unit to change the unit between allowed types ' - f'or create a new {self.__class__.__name__} with the desired unit.' + f"Unit is read-only. Use convert_unit to change the unit between allowed types " + f"or create a new {self.__class__.__name__} with the desired unit." ) ) # noqa: E501 @@ -219,7 +247,9 @@ def convert_unit(self, unit: str | sc.Unit) -> None: pass # Best effort rollback raise e - def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: + def evaluate( + self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray + ) -> np.ndarray: """Evaluate the sum of all components. Parameters @@ -257,11 +287,13 @@ def evaluate_component( Evaluated values for the specified component. """ if not self.components: - raise ValueError('No components in the model to evaluate.') + raise ValueError("No components in the model to evaluate.") if not isinstance(unique_name, str): raise TypeError( - (f'Component unique name must be a string, got {type(unique_name)} instead.') + ( + f"Component unique name must be a string, got {type(unique_name)} instead." + ) ) matches = [comp for comp in self.components if comp.unique_name == unique_name] @@ -314,6 +346,8 @@ def __repr__(self) -> str: ------- str """ - comp_names = ', '.join(c.unique_name for c in self.components) or 'No components' + comp_names = ( + ", ".join(c.unique_name for c in self.components) or "No components" + ) return f"" diff --git a/src/easydynamics/sample_model/model_base.py b/src/easydynamics/sample_model/model_base.py index 570234a2..5361a48d 100644 --- a/src/easydynamics/sample_model/model_base.py +++ b/src/easydynamics/sample_model/model_base.py @@ -42,9 +42,9 @@ class ModelBase(EasyScienceModelBase): def __init__( self, - display_name: str = 'MyModelBase', + display_name: str = "MyModelBase", unique_name: str | None = None, - unit: str | sc.Unit | None = 'meV', + unit: str | sc.Unit | None = "meV", components: ModelComponent | ComponentCollection | None = None, Q: Q_type | None = None, ): @@ -59,8 +59,8 @@ def __init__( components, (ModelComponent, ComponentCollection) ): raise TypeError( - f'Components must be a ModelComponent, a ComponentCollection or None, ' - f'got {type(components).__name__}' + f"Components must be a ModelComponent, a ComponentCollection or None, " + f"got {type(components).__name__}" ) self._components = ComponentCollection() @@ -87,8 +87,8 @@ def evaluate( if not self._component_collections: raise ValueError( - 'No components in the model to evaluate. ' - 'Run generate_component_collections() first' + "No components in the model to evaluate. " + "Run generate_component_collections() first" ) y = [collection.evaluate(x) for collection in self._component_collections] @@ -142,8 +142,8 @@ def unit(self) -> str | sc.Unit: def unit(self, unit_str: str) -> None: raise AttributeError( ( - f'Unit is read-only. Use convert_unit to change the unit between allowed types ' - f'or create a new {self.__class__.__name__} with the desired unit.' + f"Unit is read-only. Use convert_unit to change the unit between allowed types " + f"or create a new {self.__class__.__name__} with the desired unit." ) ) # noqa: E501 @@ -177,7 +177,9 @@ def components(self) -> list[ModelComponent]: def components(self, value: ModelComponent | ComponentCollection | None) -> None: """Set the components of the SampleModel.""" if not isinstance(value, (ModelComponent, ComponentCollection, type(None))): - raise TypeError('Components must be a ModelComponent or a ComponentCollection') + raise TypeError( + "Components must be a ModelComponent or a ComponentCollection" + ) self.clear_components() if value is not None: @@ -241,17 +243,20 @@ def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: ] else: if not isinstance(Q_index, int): - raise TypeError(f'Q_index must be an int or None, got {type(Q_index).__name__}') + raise TypeError( + f"Q_index must be an int or None, got {type(Q_index).__name__}" + ) if Q_index < 0 or Q_index >= len(self._component_collections): raise IndexError( - f'Q_index {Q_index} is out of bounds for component collections ' - f'of length {len(self._component_collections)}' + f"Q_index {Q_index} is out of bounds for component collections " + f"of length {len(self._component_collections)}" ) all_vars = self._component_collections[Q_index].get_all_variables() return all_vars def get_component_collection(self, Q_index: int) -> ComponentCollection: - """Get the ComponentCollection at the given Q index. + """ + Get the ComponentCollection at the given Q index. Parameters ---------- @@ -264,11 +269,11 @@ def get_component_collection(self, Q_index: int) -> ComponentCollection: The ComponentCollection at the specified Q index. """ if not isinstance(Q_index, int): - raise TypeError(f'Q_index must be an int, got {type(Q_index).__name__}') + raise TypeError(f"Q_index must be an int, got {type(Q_index).__name__}") if Q_index < 0 or Q_index >= len(self._component_collections): raise IndexError( - f'Q_index {Q_index} is out of bounds for component collections ' - f'of length {len(self._component_collections)}' + f"Q_index {Q_index} is out of bounds for component collections " + f"of length {len(self._component_collections)}" ) return self._component_collections[Q_index] @@ -301,6 +306,6 @@ def _on_components_change(self) -> None: def __repr__(self): return ( - f'{self.__class__.__name__}(unique_name={self.unique_name}, ' - f'unit={self.unit}), Q = {self.Q}, components = {self.components}' + f"{self.__class__.__name__}(unique_name={self.unique_name}, " + f"unit={self.unit}), Q = {self.Q}, components = {self.components}" ) diff --git a/tests/unit/easydynamics/analysis/test_analysis1d.py b/tests/unit/easydynamics/analysis/test_analysis1d.py index 324fd710..dbc57d40 100644 --- a/tests/unit/easydynamics/analysis/test_analysis1d.py +++ b/tests/unit/easydynamics/analysis/test_analysis1d.py @@ -20,21 +20,21 @@ class TestAnalysis1d: @pytest.fixture def analysis1d(self): - Q = sc.array(dims=['Q'], values=[1, 2, 3], unit='1/Angstrom') - energy = sc.array(dims=['energy'], values=[10.0, 20.0, 30.0], unit='meV') + Q = sc.array(dims=["Q"], values=[1, 2, 3], unit="1/Angstrom") + energy = sc.array(dims=["energy"], values=[10.0, 20.0, 30.0], unit="meV") data = sc.array( - dims=['Q', 'energy'], + dims=["Q", "energy"], values=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], variances=[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]], ) - data_array = sc.DataArray(data=data, coords={'Q': Q, 'energy': energy}) + data_array = sc.DataArray(data=data, coords={"Q": Q, "energy": energy}) experiment = Experiment(data=data_array) sample_model = SampleModel(components=Gaussian()) instrument_model = InstrumentModel() analysis1d = Analysis1d( - display_name='TestAnalysis', + display_name="TestAnalysis", experiment=experiment, sample_model=sample_model, instrument_model=instrument_model, @@ -48,7 +48,7 @@ def test_init(self, analysis1d): # WHEN THEN # EXPECT - assert analysis1d.display_name == 'TestAnalysis' + assert analysis1d.display_name == "TestAnalysis" assert isinstance(analysis1d._experiment, Experiment) assert isinstance(analysis1d._sample_model, SampleModel) assert isinstance(analysis1d._instrument_model, InstrumentModel) @@ -64,20 +64,20 @@ def test_Q_index_setter(self, analysis1d): assert analysis1d.Q_index == 1 @pytest.mark.parametrize( - 'invalid_Q_index, expected_exception, expected_message', + "invalid_Q_index, expected_exception, expected_message", [ - (-1, IndexError, 'Q_index must be'), - (10, IndexError, 'Q_index must be'), - ('invalid', IndexError, 'Q_index must be '), - (np.nan, IndexError, 'Q_index must be '), - ([1, 2], IndexError, 'Q_index must be '), + (-1, IndexError, "Q_index must be"), + (10, IndexError, "Q_index must be"), + ("invalid", IndexError, "Q_index must be "), + (np.nan, IndexError, "Q_index must be "), + ([1, 2], IndexError, "Q_index must be "), ], ids=[ - 'Negative index', - 'Index out of range', - 'Non-integer string', - 'NaN value', - 'List instead of integer', + "Negative index", + "Index out of range", + "Non-integer string", + "NaN value", + "List instead of integer", ], ) def test_Q_index_setter_incorrect_Q( @@ -127,7 +127,7 @@ def test_fit_raises_if_no_experiment(self, analysis1d): analysis1d._experiment = None # EXPECT - with pytest.raises(ValueError, match='No experiment'): + with pytest.raises(ValueError, match="No experiment"): analysis1d.fit() def test_fit_calls_fitter_with_correct_arguments(self, analysis1d): @@ -144,17 +144,17 @@ def test_fit_calls_fitter_with_correct_arguments(self, analysis1d): return_value=(fake_x, fake_y, fake_weights) ) - analysis1d._create_convolver = MagicMock(return_value='fake_convolver') + analysis1d._create_convolver = MagicMock(return_value="fake_convolver") fake_fit_result = object() fake_fitter_instance = MagicMock() fake_fitter_instance.fit.return_value = fake_fit_result with patch( - 'easydynamics.analysis.analysis1d.EasyScienceFitter', + "easydynamics.analysis.analysis1d.EasyScienceFitter", return_value=fake_fitter_instance, ) as mock_fitter: - analysis1d.as_fit_function = MagicMock(return_value='fit_func') + analysis1d.as_fit_function = MagicMock(return_value="fit_func") # THEN result = analysis1d.fit() @@ -167,7 +167,7 @@ def test_fit_calls_fitter_with_correct_arguments(self, analysis1d): mock_fitter.assert_called_once_with( fit_object=analysis1d, - fit_function='fit_func', + fit_function="fit_func", ) analysis1d._extract_x_y_weights_from_experiment.assert_called_once() @@ -204,8 +204,8 @@ def test_as_fit_function_calls_calculate(self, analysis1d): def test_get_all_variables(self, analysis1d): # WHEN - extra_par1 = Parameter(name='extra_par1', value=1.0) - extra_par2 = Parameter(name='extra_par2', value=2.0) + extra_par1 = Parameter(name="extra_par1", value=1.0) + extra_par2 = Parameter(name="extra_par2", value=2.0) analysis1d._extra_parameters = [extra_par1, extra_par2] # THEN @@ -213,8 +213,12 @@ def test_get_all_variables(self, analysis1d): # EXPECT assert isinstance(variables, list) - sample_vars = analysis1d.sample_model.get_all_variables(Q_index=analysis1d.Q_index) - instrument_vars = analysis1d.instrument_model.get_all_variables(Q_index=analysis1d.Q_index) + sample_vars = analysis1d.sample_model.get_all_variables( + Q_index=analysis1d.Q_index + ) + instrument_vars = analysis1d.instrument_model.get_all_variables( + Q_index=analysis1d.Q_index + ) extra_vars = [extra_par1, extra_par2] expected_vars = sample_vars + instrument_vars + extra_vars assert Counter(variables) == Counter(expected_vars) @@ -222,24 +226,30 @@ def test_get_all_variables(self, analysis1d): def test_plot_raises_if_no_data(self, analysis1d): analysis1d.experiment._data = None - with pytest.raises(ValueError, match='No data'): + with pytest.raises(ValueError, match="No data"): analysis1d.plot_data_and_model() def test_plot_calls_plopp_with_correct_arguments(self, analysis1d): # WHEN # Mock the data and model components to be plotted - fake_model = sc.DataArray(data=sc.array(dims=['energy'], values=[1, 2, 3])) + fake_model = sc.DataArray(data=sc.array(dims=["energy"], values=[1, 2, 3])) analysis1d._create_sample_scipp_array = MagicMock(return_value=fake_model) - fake_components = sc.Dataset({ - 'Component1': sc.DataArray(data=sc.array(dims=['energy'], values=[0.1, 0.2, 0.3])) - }) - analysis1d._create_components_dataset_single_Q = MagicMock(return_value=fake_components) + fake_components = sc.Dataset( + { + "Component1": sc.DataArray( + data=sc.array(dims=["energy"], values=[0.1, 0.2, 0.3]) + ) + } + ) + analysis1d._create_components_dataset_single_Q = MagicMock( + return_value=fake_components + ) fake_fig = object() - with patch('plopp.plot', return_value=fake_fig) as mock_plot: + with patch("plopp.plot", return_value=fake_fig) as mock_plot: # THEN result = analysis1d.plot_data_and_model() @@ -256,9 +266,9 @@ def test_plot_calls_plopp_with_correct_arguments(self, analysis1d): dataset_passed = args[0] - assert 'Data' in dataset_passed - assert 'Model' in dataset_passed - assert 'Component1' in dataset_passed + assert "Data" in dataset_passed + assert "Model" in dataset_passed + assert "Component1" in dataset_passed assert result is fake_fig @@ -278,7 +288,7 @@ def test_require_Q_index_raises_if_no_Q_index(self, analysis1d): analysis1d._Q_index = None # EXPECT - with pytest.raises(ValueError, match='Q_index must be set'): + with pytest.raises(ValueError, match="Q_index must be set"): analysis1d._require_Q_index() def test_on_Q_index_changed(self, analysis1d): @@ -311,35 +321,42 @@ def test_extract_x_y_weights_from_experiment(self, analysis1d): ############# @pytest.mark.parametrize( - 'background', + "background", [ None, np.array([0.5, 0.5, 0.5]), ], ids=[ - 'No background', - 'With background', + "No background", + "With background", ], ) def test_create_component_scipp_array(self, analysis1d, background): - """Test that _create_component_scipp_array correctly evaluates + """ + Test that _create_component_scipp_array correctly evaluates the component, adds the background and calls _to_scipp_array - with the correct values.""" - '' + with the correct values. + """ # WHEN # Mock the functions that will be called. - analysis1d._evaluate_sample_component = MagicMock(return_value=np.array([1.0, 2.0, 3.0])) + analysis1d._evaluate_sample_component = MagicMock( + return_value=np.array([1.0, 2.0, 3.0]) + ) analysis1d._to_scipp_array = MagicMock() component = object() # THEN - analysis1d._create_component_scipp_array(component=component, background=background) + analysis1d._create_component_scipp_array( + component=component, background=background + ) # EXPECT - analysis1d._evaluate_sample_component.assert_called_once_with(component=component) + analysis1d._evaluate_sample_component.assert_called_once_with( + component=component + ) expected_values = np.array([1.0, 2.0, 3.0]) if background is not None: @@ -351,7 +368,7 @@ def test_create_component_scipp_array(self, analysis1d, background): _, kwargs = analysis1d._to_scipp_array.call_args np.testing.assert_array_equal( - kwargs['values'], + kwargs["values"], expected_values, ) @@ -374,7 +391,9 @@ def test_create_background_component_scipp_array(self, analysis1d): analysis1d._create_background_component_scipp_array(component=component) # EXPECT - analysis1d._evaluate_background_component.assert_called_once_with(component=component) + analysis1d._evaluate_background_component.assert_called_once_with( + component=component + ) analysis1d._to_scipp_array.assert_called_once() @@ -382,7 +401,7 @@ def test_create_background_component_scipp_array(self, analysis1d): _, kwargs = analysis1d._to_scipp_array.call_args np.testing.assert_array_equal( - kwargs['values'], + kwargs["values"], np.array([1.0, 2.0, 3.0]), ) @@ -409,14 +428,14 @@ def test_create_sample_scipp_array(self, analysis1d): _, kwargs = analysis1d._to_scipp_array.call_args np.testing.assert_array_equal( - kwargs['values'], + kwargs["values"], np.array([1.0, 2.0, 3.0]), ) @pytest.mark.parametrize( - 'add_background', + "add_background", [True, False], - ids=['With background', 'Without background'], + ids=["With background", "Without background"], ) def test_create_components_dataset_single_Q( self, @@ -435,7 +454,7 @@ def test_create_components_dataset_single_Q( # ---- Sample component ---- sample_component = MagicMock() - sample_component.display_name = 'sample_comp' + sample_component.display_name = "sample_comp" sample_collection = MagicMock() sample_collection.components = [sample_component] @@ -446,13 +465,13 @@ def test_create_components_dataset_single_Q( # ---- Background component ---- background_component = MagicMock() - background_component.display_name = 'background_comp' + background_component.display_name = "background_comp" background_collection = MagicMock() background_collection.components = [background_component] - analysis1d.instrument_model.background_model.get_component_collection = MagicMock( - return_value=background_collection + analysis1d.instrument_model.background_model.get_component_collection = ( + MagicMock(return_value=background_collection) ) # ---- Background evaluation ---- @@ -460,18 +479,26 @@ def test_create_components_dataset_single_Q( analysis1d._evaluate_background = MagicMock(return_value=background_value) # ---- Return scipp DataArrays ---- - fake_sample_da = sc.DataArray(data=sc.array(dims=['energy'], values=[1.0, 2.0, 3.0])) + fake_sample_da = sc.DataArray( + data=sc.array(dims=["energy"], values=[1.0, 2.0, 3.0]) + ) - analysis1d._create_component_scipp_array = MagicMock(return_value=fake_sample_da) + analysis1d._create_component_scipp_array = MagicMock( + return_value=fake_sample_da + ) - fake_background_da = sc.DataArray(data=sc.array(dims=['energy'], values=[4.0, 5.0, 6.0])) + fake_background_da = sc.DataArray( + data=sc.array(dims=["energy"], values=[4.0, 5.0, 6.0]) + ) analysis1d._create_background_component_scipp_array = MagicMock( return_value=fake_background_da ) # THEN - dataset = analysis1d._create_components_dataset_single_Q(add_background=add_background) + dataset = analysis1d._create_components_dataset_single_Q( + add_background=add_background + ) # EXPECT @@ -499,13 +526,13 @@ def test_create_components_dataset_single_Q( analysis1d._create_component_scipp_array.assert_called_once() _, kwargs = analysis1d._create_component_scipp_array.call_args - assert kwargs['component'] is sample_component + assert kwargs["component"] is sample_component if expected_background is None: - assert kwargs['background'] is None + assert kwargs["background"] is None else: np.testing.assert_array_equal( - kwargs['background'], + kwargs["background"], expected_background, ) @@ -516,8 +543,8 @@ def test_create_components_dataset_single_Q( # Dataset content assert isinstance(dataset, sc.Dataset) - assert 'sample_comp' in dataset - assert 'background_comp' in dataset + assert "sample_comp" in dataset + assert "background_comp" in dataset def test_to_scipp_array(self, analysis1d): # WHEN @@ -531,10 +558,10 @@ def test_to_scipp_array(self, analysis1d): np.testing.assert_array_equal(scipp_array.values, numpy_array) np.testing.assert_array_equal( - scipp_array.coords['energy'].values, analysis1d.experiment.energy.values + scipp_array.coords["energy"].values, analysis1d.experiment.energy.values ) np.testing.assert_array_equal( - scipp_array.coords['Q'].values, + scipp_array.coords["Q"].values, analysis1d.experiment.Q[analysis1d.Q_index].values, ) From 51785251ff20bf0a617281fcf1c2fce6d5bc50b4 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Wed, 18 Feb 2026 17:00:18 +0100 Subject: [PATCH 26/27] Add a few more tests --- docs/docs/tutorials/analysis.ipynb | 2 + pixi.lock | 26 +- src/easydynamics/analysis/analysis1d.py | 74 ++--- src/easydynamics/convolution/convolution.py | 47 ++- .../sample_model/component_collection.py | 77 ++--- src/easydynamics/sample_model/model_base.py | 41 ++- .../easydynamics/analysis/test_analysis1d.py | 301 ++++++++++++------ 7 files changed, 328 insertions(+), 240 deletions(-) diff --git a/docs/docs/tutorials/analysis.ipynb b/docs/docs/tutorials/analysis.ipynb index d478d9f3..0190d30d 100644 --- a/docs/docs/tutorials/analysis.ipynb +++ b/docs/docs/tutorials/analysis.ipynb @@ -30,6 +30,8 @@ "from easydynamics.sample_model.resolution_model import ResolutionModel\n", "from easydynamics.sample_model.sample_model import SampleModel\n", "\n", + "from easyscience import global_object\n", + "\n", "%matplotlib widget" ] }, diff --git a/pixi.lock b/pixi.lock index 789c15eb..6eb5ea10 100644 --- a/pixi.lock +++ b/pixi.lock @@ -80,7 +80,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#ed35c9982dd050813efd974e7eb4ea88279327dc - pypi: https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -335,7 +335,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#ed35c9982dd050813efd974e7eb4ea88279327dc - pypi: https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -590,7 +590,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#ed35c9982dd050813efd974e7eb4ea88279327dc - pypi: https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -838,7 +838,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#ed35c9982dd050813efd974e7eb4ea88279327dc - pypi: https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -1106,7 +1106,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#ed35c9982dd050813efd974e7eb4ea88279327dc - pypi: https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -1362,7 +1362,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#ed35c9982dd050813efd974e7eb4ea88279327dc - pypi: https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -1618,7 +1618,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#ed35c9982dd050813efd974e7eb4ea88279327dc - pypi: https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -1867,7 +1867,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#ed35c9982dd050813efd974e7eb4ea88279327dc - pypi: https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -2136,7 +2136,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#ed35c9982dd050813efd974e7eb4ea88279327dc - pypi: https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -2391,7 +2391,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#ed35c9982dd050813efd974e7eb4ea88279327dc - pypi: https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -2646,7 +2646,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#ed35c9982dd050813efd974e7eb4ea88279327dc - pypi: https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -2894,7 +2894,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/b8/01/74922a1c552137c05a41fee0c61153753dddc9117d19c7c5902c146c25ab/copier-9.11.3-py3-none-any.whl - - pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 + - pypi: git+https://github.com/easyscience/corelib.git#ed35c9982dd050813efd974e7eb4ea88279327dc - pypi: https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl @@ -4134,7 +4134,7 @@ packages: - validate-pyproject[all] ; extra == 'dev' - versioningit ; extra == 'dev' requires_python: '>=3.11' -- pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 +- pypi: git+https://github.com/easyscience/corelib.git#ed35c9982dd050813efd974e7eb4ea88279327dc name: easyscience version: 2.1.0 requires_dist: diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index a61b88a5..7c324952 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -24,7 +24,7 @@ class Analysis1d(AnalysisBase): def __init__( self, - display_name: str = "MyAnalysis", + display_name: str = 'MyAnalysis', unique_name: str | None = None, experiment: Experiment | None = None, sample_model: SampleModel | None = None, @@ -114,7 +114,7 @@ def fit(self) -> FitResults: parameter optimization for performance reasons. """ if self._experiment is None: - raise ValueError("No experiment is associated with this Analysis.") + raise ValueError('No experiment is associated with this Analysis.') # Create convolver once to reuse during fitting self._convolver = self._create_convolver() @@ -183,37 +183,33 @@ def plot_data_and_model( import plopp as pp if self.experiment.data is None: - raise ValueError("No data to plot. Please load data first.") + raise ValueError('No data to plot. Please load data first.') - data = self.experiment.data["Q", self.Q_index] + data = self.experiment.data['Q', self.Q_index] model_array = self._create_sample_scipp_array() - component_dataset = self._create_components_dataset_single_Q( - add_background=add_background - ) + component_dataset = self._create_components_dataset_single_Q(add_background=add_background) # Create a dataset containing the data, model, and individual # components for plotting. - data_and_model = sc.Dataset( - { - "Data": data, - "Model": model_array, - } - ) + data_and_model = sc.Dataset({ + 'Data': data, + 'Model': model_array, + }) data_and_model = sc.merge(data_and_model, component_dataset) plot_kwargs_defaults = { - "title": self.display_name, - "linestyle": {"Data": "none", "Model": "-"}, - "marker": {"Data": "o", "Model": "none"}, - "color": {"Data": "black", "Model": "red"}, - "markerfacecolor": {"Data": "none", "Model": "none"}, + 'title': self.display_name, + 'linestyle': {'Data': 'none', 'Model': '-'}, + 'marker': {'Data': 'o', 'Model': 'none'}, + 'color': {'Data': 'black', 'Model': 'red'}, + 'markerfacecolor': {'Data': 'none', 'Model': 'none'}, } if plot_components: for comp_name in component_dataset.keys(): - plot_kwargs_defaults["linestyle"][comp_name] = "--" - plot_kwargs_defaults["marker"][comp_name] = None + plot_kwargs_defaults['linestyle'][comp_name] = '--' + plot_kwargs_defaults['marker'][comp_name] = None # Overwrite defaults with any user-provided kwargs plot_kwargs_defaults.update(kwargs) @@ -236,7 +232,7 @@ def _require_Q_index(self) -> int: int: The Q index. """ if self._Q_index is None: - raise ValueError("Q_index must be set.") + raise ValueError('Q_index must be set.') return self._Q_index def _on_Q_index_changed(self) -> None: @@ -252,8 +248,8 @@ def _extract_x_y_weights_from_experiment(self): the current Q index. """ Q_index = self._require_Q_index() - data = self.experiment.data["Q", Q_index] - x = data.coords["energy"].values + data = self.experiment.data['Q', Q_index] + x = data.coords['energy'].values y = data.values e = data.variances**0.5 weights = 1.0 / e @@ -303,9 +299,7 @@ def _evaluate_components( if not convolve: return components.evaluate(energy - energy_offset) - resolution = self.instrument_model.resolution_model.get_component_collection( - Q_index - ) + resolution = self.instrument_model.resolution_model.get_component_collection(Q_index) if resolution.is_empty: return components.evaluate(energy - energy_offset) @@ -367,10 +361,8 @@ def _evaluate_background(self) -> np.ndarray: np.ndarray: The evaluated background contribution. """ Q_index = self._require_Q_index() - background_components = ( - self.instrument_model.background_model.get_component_collection( - Q_index=Q_index - ) + background_components = self.instrument_model.background_model.get_component_collection( + Q_index=Q_index ) return self._evaluate_components( components=background_components, @@ -411,8 +403,8 @@ def _create_convolver(self) -> Convolution | None: if sample_components.is_empty: return None - resolution_components = ( - self.instrument_model.resolution_model.get_component_collection(Q_index) + resolution_components = self.instrument_model.resolution_model.get_component_collection( + Q_index ) if resolution_components.is_empty: return None @@ -490,19 +482,17 @@ def _create_components_dataset_single_Q( Q_index=self.Q_index ).components - background_components = ( - self.instrument_model.background_model.get_component_collection( - Q_index=self.Q_index - ).components - ) + background_components = self.instrument_model.background_model.get_component_collection( + Q_index=self.Q_index + ).components background = self._evaluate_background() if add_background else None for component in sample_components: scipp_arrays[component.display_name] = self._create_component_scipp_array( component=component, background=background ) for component in background_components: - scipp_arrays[component.display_name] = ( - self._create_background_component_scipp_array(component=component) + scipp_arrays[component.display_name] = self._create_background_component_scipp_array( + component=component ) return sc.Dataset(scipp_arrays) @@ -516,9 +506,9 @@ def _to_scipp_array(self, values: np.ndarray) -> sc.DataArray: sc.DataArray: The converted sc.DataArray. """ return sc.DataArray( - data=sc.array(dims=["energy"], values=values), + data=sc.array(dims=['energy'], values=values), coords={ - "energy": self.energy, - "Q": self.Q[self.Q_index], + 'energy': self.energy, + 'Q': self.Q[self.Q_index], }, ) diff --git a/src/easydynamics/convolution/convolution.py b/src/easydynamics/convolution/convolution.py index 827087c1..b4fa19e3 100644 --- a/src/easydynamics/convolution/convolution.py +++ b/src/easydynamics/convolution/convolution.py @@ -59,16 +59,16 @@ class Convolution(NumericalConvolutionBase): # When these attributes are changed, the convolution plan # needs to be rebuilt _invalidate_plan_on_change = { - "energy", - "_energy", - "_energy_grid", - "_sample_components", - "_resolution_components", - "_temperature", - "_upsample_factor", - "_extension_factor", - "_energy_unit", - "_normalize_detailed_balance", + 'energy', + '_energy', + '_energy_grid', + '_sample_components', + '_resolution_components', + '_temperature', + '_upsample_factor', + '_extension_factor', + '_energy_unit', + '_normalize_detailed_balance', } def __init__( @@ -80,8 +80,8 @@ def __init__( upsample_factor: Numeric = 5, extension_factor: Numeric = 0.2, temperature: Parameter | Numeric | None = None, - temperature_unit: str | sc.Unit = "K", - energy_unit: str | sc.Unit = "meV", + temperature_unit: str | sc.Unit = 'K', + energy_unit: str | sc.Unit = 'meV', normalize_detailed_balance: bool = True, ): self._convolution_plan_is_valid = False @@ -137,8 +137,8 @@ def convolution( def _convolve_delta_functions(self) -> np.ndarray: "Convolve delta function components of the sample model with" - "the resolution components." - "No detailed balance correction is applied to delta functions." + 'the resolution components.' + 'No detailed balance correction is applied to delta functions.' return sum( delta.area.value * self._resolution_components.evaluate( @@ -168,19 +168,19 @@ def _check_if_pair_is_analytic( if not isinstance(sample_component, ModelComponent): raise TypeError( - f"`sample_component` is an instance of {type(sample_component).__name__}, \ - but must be a ModelComponent." + f'`sample_component` is an instance of {type(sample_component).__name__}, \ + but must be a ModelComponent.' ) if not isinstance(resolution_component, ModelComponent): raise TypeError( - f"`resolution_component` is an instance of {type(resolution_component).__name__}, \ - but must be a ModelComponent." + f'`resolution_component` is an instance of {type(resolution_component).__name__}, \ + but must be a ModelComponent.' ) if isinstance(resolution_component, DeltaFunction): raise TypeError( - "resolution components contains delta functions. This is not supported." + 'resolution components contains delta functions. This is not supported.' ) analytical_types = (Gaussian, Lorentzian, Voigt) @@ -219,9 +219,7 @@ def _build_convolution_plan(self) -> None: pair_is_analytic = [] for resolution_component in self._resolution_components.components: pair_is_analytic.append( - self._check_if_pair_is_analytic( - sample_component, resolution_component - ) + self._check_if_pair_is_analytic(sample_component, resolution_component) ) # If all resolution components can be convolved analytically # with this sample component, add it to analytical @@ -285,8 +283,5 @@ def __setattr__(self, name, value): if name in self._invalidate_plan_on_change: self._convolution_plan_is_valid = False - if ( - getattr(self, "_reactions_enabled", False) - and name in self._invalidate_plan_on_change - ): + if getattr(self, '_reactions_enabled', False) and name in self._invalidate_plan_on_change: self._build_convolution_plan() diff --git a/src/easydynamics/sample_model/component_collection.py b/src/easydynamics/sample_model/component_collection.py index fec3f3dd..3d2cafb4 100644 --- a/src/easydynamics/sample_model/component_collection.py +++ b/src/easydynamics/sample_model/component_collection.py @@ -31,8 +31,8 @@ class ComponentCollection(ModelBase): def __init__( self, - unit: str | sc.Unit = "meV", - display_name: str = "MyComponentCollection", + unit: str | sc.Unit = 'meV', + display_name: str = 'MyComponentCollection', unique_name: str | None = None, components: List[ModelComponent] | None = None, ): @@ -54,7 +54,7 @@ def __init__( if unit is not None and not isinstance(unit, (str, sc.Unit)): raise TypeError( - f"unit must be None, a string, or a scipp Unit, got {type(unit).__name__}" + f'unit must be None, a string, or a scipp Unit, got {type(unit).__name__}' ) self._unit = unit self._components = [] @@ -62,17 +62,12 @@ def __init__( # Add initial components if provided. Used for serialization. if components is not None: if not isinstance(components, list): - raise TypeError( - "components must be a list of ModelComponent instances." - ) + raise TypeError('components must be a list of ModelComponent instances.') for comp in components: self.append_component(comp) - def append_component( - self, component: ModelComponent | "ComponentCollection" - ) -> None: - """ - Append a model component or the components from another + def append_component(self, component: ModelComponent | 'ComponentCollection') -> None: + """Append a model component or the components from another ComponentCollection to this ComponentCollection. Parameters @@ -87,8 +82,8 @@ def append_component( """ if not isinstance(component, (ModelComponent, ComponentCollection)): raise TypeError( - "Component must be an instance of ModelComponent or ComponentCollection. " - f"Got {type(component).__name__} instead." + 'Component must be an instance of ModelComponent or ComponentCollection. ' + f'Got {type(component).__name__} instead.' ) elif isinstance(component, ModelComponent): components = (component,) @@ -96,21 +91,21 @@ def append_component( components = component.components else: raise TypeError( - "Component must be an instance of ModelComponent or ComponentCollection." + 'Component must be an instance of ModelComponent or ComponentCollection.' ) for comp in components: if comp in self._components: raise ValueError( f"Component '{comp.unique_name}' is already in the collection. " - f"Existing components: {self.list_component_names()}" + f'Existing components: {self.list_component_names()}' ) self._components.append(comp) def remove_component(self, unique_name: str) -> None: if not isinstance(unique_name, str): - raise TypeError("Component name must be a string.") + raise TypeError('Component name must be a string.') for comp in self._components: if comp.unique_name == unique_name: @@ -119,8 +114,8 @@ def remove_component(self, unique_name: str) -> None: raise KeyError( f"No component named '{unique_name}' exists. " - f"Did you accidentally use the display_name? " - f"Here is a list of the components in the collection: {self.list_component_names()}" + f'Did you accidentally use the display_name? ' + f'Here is a list of the components in the collection: {self.list_component_names()}' ) @property @@ -130,12 +125,12 @@ def components(self) -> list[ModelComponent]: @components.setter def components(self, components: List[ModelComponent]) -> None: if not isinstance(components, list): - raise TypeError("components must be a list of ModelComponent instances.") + raise TypeError('components must be a list of ModelComponent instances.') for comp in components: if not isinstance(comp, ModelComponent): raise TypeError( - "All items in components must be instances of ModelComponent. " - f"Got {type(comp).__name__} instead." + 'All items in components must be instances of ModelComponent. ' + f'Got {type(comp).__name__} instead.' ) self._components = components @@ -147,8 +142,8 @@ def is_empty(self) -> bool: @is_empty.setter def is_empty(self, value: bool) -> None: raise AttributeError( - "is_empty is a read-only property that indicates " - "whether the collection has components." + 'is_empty is a read-only property that indicates ' + 'whether the collection has components.' ) def list_component_names(self) -> List[str]: @@ -170,27 +165,27 @@ def normalize_area(self) -> None: # Useful for convolutions. """Normalize the areas of all components so they sum to 1.""" if not self.components: - raise ValueError("No components in the model to normalize.") + raise ValueError('No components in the model to normalize.') area_params = [] - total_area = Parameter(name="total_area", value=0.0, unit=self._unit) + total_area = Parameter(name='total_area', value=0.0, unit=self._unit) for component in self.components: - if hasattr(component, "area"): + if hasattr(component, 'area'): area_params.append(component.area) total_area += component.area else: warnings.warn( f"Component '{component.unique_name}' does not have an 'area' attribute " - f"and will be skipped in normalization.", + f'and will be skipped in normalization.', UserWarning, ) if total_area.value == 0: - raise ValueError("Total area is zero; cannot normalize.") + raise ValueError('Total area is zero; cannot normalize.') if not np.isfinite(total_area.value): - raise ValueError("Total area is not finite; cannot normalize.") + raise ValueError('Total area is not finite; cannot normalize.') for param in area_params: param.value /= total_area.value @@ -202,11 +197,7 @@ def get_all_variables(self) -> list[DescriptorBase]: List[Parameter]: List of parameters in the component. """ - return [ - var - for component in self.components - for var in component.get_all_variables() - ] + return [var for component in self.components for var in component.get_all_variables()] @property def unit(self) -> str | sc.Unit: @@ -222,8 +213,8 @@ def unit(self) -> str | sc.Unit: def unit(self, unit_str: str) -> None: raise AttributeError( ( - f"Unit is read-only. Use convert_unit to change the unit between allowed types " - f"or create a new {self.__class__.__name__} with the desired unit." + f'Unit is read-only. Use convert_unit to change the unit between allowed types ' + f'or create a new {self.__class__.__name__} with the desired unit.' ) ) # noqa: E501 @@ -247,9 +238,7 @@ def convert_unit(self, unit: str | sc.Unit) -> None: pass # Best effort rollback raise e - def evaluate( - self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray - ) -> np.ndarray: + def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: """Evaluate the sum of all components. Parameters @@ -287,13 +276,11 @@ def evaluate_component( Evaluated values for the specified component. """ if not self.components: - raise ValueError("No components in the model to evaluate.") + raise ValueError('No components in the model to evaluate.') if not isinstance(unique_name, str): raise TypeError( - ( - f"Component unique name must be a string, got {type(unique_name)} instead." - ) + (f'Component unique name must be a string, got {type(unique_name)} instead.') ) matches = [comp for comp in self.components if comp.unique_name == unique_name] @@ -346,8 +333,6 @@ def __repr__(self) -> str: ------- str """ - comp_names = ( - ", ".join(c.unique_name for c in self.components) or "No components" - ) + comp_names = ', '.join(c.unique_name for c in self.components) or 'No components' return f"" diff --git a/src/easydynamics/sample_model/model_base.py b/src/easydynamics/sample_model/model_base.py index 5361a48d..570234a2 100644 --- a/src/easydynamics/sample_model/model_base.py +++ b/src/easydynamics/sample_model/model_base.py @@ -42,9 +42,9 @@ class ModelBase(EasyScienceModelBase): def __init__( self, - display_name: str = "MyModelBase", + display_name: str = 'MyModelBase', unique_name: str | None = None, - unit: str | sc.Unit | None = "meV", + unit: str | sc.Unit | None = 'meV', components: ModelComponent | ComponentCollection | None = None, Q: Q_type | None = None, ): @@ -59,8 +59,8 @@ def __init__( components, (ModelComponent, ComponentCollection) ): raise TypeError( - f"Components must be a ModelComponent, a ComponentCollection or None, " - f"got {type(components).__name__}" + f'Components must be a ModelComponent, a ComponentCollection or None, ' + f'got {type(components).__name__}' ) self._components = ComponentCollection() @@ -87,8 +87,8 @@ def evaluate( if not self._component_collections: raise ValueError( - "No components in the model to evaluate. " - "Run generate_component_collections() first" + 'No components in the model to evaluate. ' + 'Run generate_component_collections() first' ) y = [collection.evaluate(x) for collection in self._component_collections] @@ -142,8 +142,8 @@ def unit(self) -> str | sc.Unit: def unit(self, unit_str: str) -> None: raise AttributeError( ( - f"Unit is read-only. Use convert_unit to change the unit between allowed types " - f"or create a new {self.__class__.__name__} with the desired unit." + f'Unit is read-only. Use convert_unit to change the unit between allowed types ' + f'or create a new {self.__class__.__name__} with the desired unit.' ) ) # noqa: E501 @@ -177,9 +177,7 @@ def components(self) -> list[ModelComponent]: def components(self, value: ModelComponent | ComponentCollection | None) -> None: """Set the components of the SampleModel.""" if not isinstance(value, (ModelComponent, ComponentCollection, type(None))): - raise TypeError( - "Components must be a ModelComponent or a ComponentCollection" - ) + raise TypeError('Components must be a ModelComponent or a ComponentCollection') self.clear_components() if value is not None: @@ -243,20 +241,17 @@ def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: ] else: if not isinstance(Q_index, int): - raise TypeError( - f"Q_index must be an int or None, got {type(Q_index).__name__}" - ) + raise TypeError(f'Q_index must be an int or None, got {type(Q_index).__name__}') if Q_index < 0 or Q_index >= len(self._component_collections): raise IndexError( - f"Q_index {Q_index} is out of bounds for component collections " - f"of length {len(self._component_collections)}" + f'Q_index {Q_index} is out of bounds for component collections ' + f'of length {len(self._component_collections)}' ) all_vars = self._component_collections[Q_index].get_all_variables() return all_vars def get_component_collection(self, Q_index: int) -> ComponentCollection: - """ - Get the ComponentCollection at the given Q index. + """Get the ComponentCollection at the given Q index. Parameters ---------- @@ -269,11 +264,11 @@ def get_component_collection(self, Q_index: int) -> ComponentCollection: The ComponentCollection at the specified Q index. """ if not isinstance(Q_index, int): - raise TypeError(f"Q_index must be an int, got {type(Q_index).__name__}") + raise TypeError(f'Q_index must be an int, got {type(Q_index).__name__}') if Q_index < 0 or Q_index >= len(self._component_collections): raise IndexError( - f"Q_index {Q_index} is out of bounds for component collections " - f"of length {len(self._component_collections)}" + f'Q_index {Q_index} is out of bounds for component collections ' + f'of length {len(self._component_collections)}' ) return self._component_collections[Q_index] @@ -306,6 +301,6 @@ def _on_components_change(self) -> None: def __repr__(self): return ( - f"{self.__class__.__name__}(unique_name={self.unique_name}, " - f"unit={self.unit}), Q = {self.Q}, components = {self.components}" + f'{self.__class__.__name__}(unique_name={self.unique_name}, ' + f'unit={self.unit}), Q = {self.Q}, components = {self.components}' ) diff --git a/tests/unit/easydynamics/analysis/test_analysis1d.py b/tests/unit/easydynamics/analysis/test_analysis1d.py index dbc57d40..eb71aa7e 100644 --- a/tests/unit/easydynamics/analysis/test_analysis1d.py +++ b/tests/unit/easydynamics/analysis/test_analysis1d.py @@ -20,21 +20,22 @@ class TestAnalysis1d: @pytest.fixture def analysis1d(self): - Q = sc.array(dims=["Q"], values=[1, 2, 3], unit="1/Angstrom") - energy = sc.array(dims=["energy"], values=[10.0, 20.0, 30.0], unit="meV") + Q = sc.array(dims=['Q'], values=[1, 2, 3], unit='1/Angstrom') + energy = sc.array(dims=['energy'], values=[10.0, 20.0, 30.0], unit='meV') data = sc.array( - dims=["Q", "energy"], + dims=['Q', 'energy'], values=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], variances=[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]], ) - data_array = sc.DataArray(data=data, coords={"Q": Q, "energy": energy}) + data_array = sc.DataArray(data=data, coords={'Q': Q, 'energy': energy}) experiment = Experiment(data=data_array) sample_model = SampleModel(components=Gaussian()) instrument_model = InstrumentModel() + analysis1d = Analysis1d( - display_name="TestAnalysis", + display_name='TestAnalysis', experiment=experiment, sample_model=sample_model, instrument_model=instrument_model, @@ -48,7 +49,7 @@ def test_init(self, analysis1d): # WHEN THEN # EXPECT - assert analysis1d.display_name == "TestAnalysis" + assert analysis1d.display_name == 'TestAnalysis' assert isinstance(analysis1d._experiment, Experiment) assert isinstance(analysis1d._sample_model, SampleModel) assert isinstance(analysis1d._instrument_model, InstrumentModel) @@ -64,20 +65,20 @@ def test_Q_index_setter(self, analysis1d): assert analysis1d.Q_index == 1 @pytest.mark.parametrize( - "invalid_Q_index, expected_exception, expected_message", + 'invalid_Q_index, expected_exception, expected_message', [ - (-1, IndexError, "Q_index must be"), - (10, IndexError, "Q_index must be"), - ("invalid", IndexError, "Q_index must be "), - (np.nan, IndexError, "Q_index must be "), - ([1, 2], IndexError, "Q_index must be "), + (-1, IndexError, 'Q_index must be'), + (10, IndexError, 'Q_index must be'), + ('invalid', IndexError, 'Q_index must be '), + (np.nan, IndexError, 'Q_index must be '), + ([1, 2], IndexError, 'Q_index must be '), ], ids=[ - "Negative index", - "Index out of range", - "Non-integer string", - "NaN value", - "List instead of integer", + 'Negative index', + 'Index out of range', + 'Non-integer string', + 'NaN value', + 'List instead of integer', ], ) def test_Q_index_setter_incorrect_Q( @@ -127,7 +128,7 @@ def test_fit_raises_if_no_experiment(self, analysis1d): analysis1d._experiment = None # EXPECT - with pytest.raises(ValueError, match="No experiment"): + with pytest.raises(ValueError, match='No experiment'): analysis1d.fit() def test_fit_calls_fitter_with_correct_arguments(self, analysis1d): @@ -144,17 +145,17 @@ def test_fit_calls_fitter_with_correct_arguments(self, analysis1d): return_value=(fake_x, fake_y, fake_weights) ) - analysis1d._create_convolver = MagicMock(return_value="fake_convolver") + analysis1d._create_convolver = MagicMock(return_value='fake_convolver') fake_fit_result = object() fake_fitter_instance = MagicMock() fake_fitter_instance.fit.return_value = fake_fit_result with patch( - "easydynamics.analysis.analysis1d.EasyScienceFitter", + 'easydynamics.analysis.analysis1d.EasyScienceFitter', return_value=fake_fitter_instance, ) as mock_fitter: - analysis1d.as_fit_function = MagicMock(return_value="fit_func") + analysis1d.as_fit_function = MagicMock(return_value='fit_func') # THEN result = analysis1d.fit() @@ -167,7 +168,7 @@ def test_fit_calls_fitter_with_correct_arguments(self, analysis1d): mock_fitter.assert_called_once_with( fit_object=analysis1d, - fit_function="fit_func", + fit_function='fit_func', ) analysis1d._extract_x_y_weights_from_experiment.assert_called_once() @@ -204,8 +205,8 @@ def test_as_fit_function_calls_calculate(self, analysis1d): def test_get_all_variables(self, analysis1d): # WHEN - extra_par1 = Parameter(name="extra_par1", value=1.0) - extra_par2 = Parameter(name="extra_par2", value=2.0) + extra_par1 = Parameter(name='extra_par1', value=1.0) + extra_par2 = Parameter(name='extra_par2', value=2.0) analysis1d._extra_parameters = [extra_par1, extra_par2] # THEN @@ -213,12 +214,8 @@ def test_get_all_variables(self, analysis1d): # EXPECT assert isinstance(variables, list) - sample_vars = analysis1d.sample_model.get_all_variables( - Q_index=analysis1d.Q_index - ) - instrument_vars = analysis1d.instrument_model.get_all_variables( - Q_index=analysis1d.Q_index - ) + sample_vars = analysis1d.sample_model.get_all_variables(Q_index=analysis1d.Q_index) + instrument_vars = analysis1d.instrument_model.get_all_variables(Q_index=analysis1d.Q_index) extra_vars = [extra_par1, extra_par2] expected_vars = sample_vars + instrument_vars + extra_vars assert Counter(variables) == Counter(expected_vars) @@ -226,30 +223,24 @@ def test_get_all_variables(self, analysis1d): def test_plot_raises_if_no_data(self, analysis1d): analysis1d.experiment._data = None - with pytest.raises(ValueError, match="No data"): + with pytest.raises(ValueError, match='No data'): analysis1d.plot_data_and_model() def test_plot_calls_plopp_with_correct_arguments(self, analysis1d): # WHEN # Mock the data and model components to be plotted - fake_model = sc.DataArray(data=sc.array(dims=["energy"], values=[1, 2, 3])) + fake_model = sc.DataArray(data=sc.array(dims=['energy'], values=[1, 2, 3])) analysis1d._create_sample_scipp_array = MagicMock(return_value=fake_model) - fake_components = sc.Dataset( - { - "Component1": sc.DataArray( - data=sc.array(dims=["energy"], values=[0.1, 0.2, 0.3]) - ) - } - ) - analysis1d._create_components_dataset_single_Q = MagicMock( - return_value=fake_components - ) + fake_components = sc.Dataset({ + 'Component1': sc.DataArray(data=sc.array(dims=['energy'], values=[0.1, 0.2, 0.3])) + }) + analysis1d._create_components_dataset_single_Q = MagicMock(return_value=fake_components) fake_fig = object() - with patch("plopp.plot", return_value=fake_fig) as mock_plot: + with patch('plopp.plot', return_value=fake_fig) as mock_plot: # THEN result = analysis1d.plot_data_and_model() @@ -266,9 +257,9 @@ def test_plot_calls_plopp_with_correct_arguments(self, analysis1d): dataset_passed = args[0] - assert "Data" in dataset_passed - assert "Model" in dataset_passed - assert "Component1" in dataset_passed + assert 'Data' in dataset_passed + assert 'Model' in dataset_passed + assert 'Component1' in dataset_passed assert result is fake_fig @@ -288,7 +279,7 @@ def test_require_Q_index_raises_if_no_Q_index(self, analysis1d): analysis1d._Q_index = None # EXPECT - with pytest.raises(ValueError, match="Q_index must be set"): + with pytest.raises(ValueError, match='Q_index must be set'): analysis1d._require_Q_index() def test_on_Q_index_changed(self, analysis1d): @@ -316,19 +307,165 @@ def test_extract_x_y_weights_from_experiment(self, analysis1d): # Private methods: evaluation ############# + def test_evaluate_components(self, analysis1d): + pass + + def test_evaluate_sample(self, analysis1d): + # WHEN + analysis1d.sample_model.get_component_collection = MagicMock() + analysis1d._evaluate_components = MagicMock() + + # THEN + analysis1d._evaluate_sample() + + # EXPECT + + # The correct component collection is requested with the correct + # Q_index + analysis1d.sample_model.get_component_collection.assert_called_once_with( + Q_index=analysis1d.Q_index + ) + + # The components are evaluated with the correct convolver and + # convolve=True + analysis1d._evaluate_components.assert_called_once_with( + components=analysis1d.sample_model.get_component_collection(), + convolver=analysis1d._convolver, + convolve=True, + ) + + def test_evaluate_sample_component(self, analysis1d): + # WHEN + analysis1d._evaluate_components = MagicMock() + component = object() + + # THEN + analysis1d._evaluate_sample_component(component=component) + + # EXPECT + + # The components are evaluated with the correct convolver and + # convolve=True + analysis1d._evaluate_components.assert_called_once_with( + components=component, + convolver=None, + convolve=True, + ) + + def test_evaluate_background(self, analysis1d): + # WHEN + analysis1d.instrument_model.background_model.get_component_collection = MagicMock() + analysis1d._evaluate_components = MagicMock() + + # THEN + analysis1d._evaluate_background() + + # EXPECT + + # The correct component collection is requested with the correct + # Q_index + analysis1d.instrument_model.background_model.get_component_collection.assert_called_once_with( + Q_index=analysis1d.Q_index + ) + + # The components are evaluated with the correct convolver and + # convolve=True + analysis1d._evaluate_components.assert_called_once_with( + components=analysis1d.instrument_model.background_model.get_component_collection(), + convolver=None, + convolve=False, + ) + + def test_evaluate_background_component(self, analysis1d): + # WHEN + analysis1d._evaluate_components = MagicMock() + component = object() + + # THEN + analysis1d._evaluate_background_component(component=component) + + # EXPECT + + # The components are evaluated with the correct convolver and + # convolve=True + analysis1d._evaluate_components.assert_called_once_with( + components=component, + convolver=None, + convolve=False, + ) + + def test_create_convolver(self, analysis1d): + # WHEN + # Mock sample components + sample_components = MagicMock() + sample_components.is_empty = False + + # Mock resolution components + resolution_components = MagicMock() + resolution_components.is_empty = False + + # And all the other inputs to the convolver + analysis1d.sample_model.get_component_collection = MagicMock( + return_value=sample_components + ) + + analysis1d.instrument_model.resolution_model.get_component_collection = MagicMock( + return_value=resolution_components + ) + + analysis1d.instrument_model.get_energy_offset_at_Q = MagicMock(return_value=123.0) + + with patch('easydynamics.analysis.analysis1d.Convolution') as MockConvolution: + # THEN + result = analysis1d._create_convolver() + + # EXPECT + # Check the convolver was created with the correct arguments + MockConvolution.assert_called_once() + + _, kwargs = MockConvolution.call_args + + assert kwargs['sample_components'] is sample_components + assert kwargs['resolution_components'] is resolution_components + assert sc.identical(kwargs['energy'], analysis1d.energy) + assert kwargs['temperature'] is analysis1d.temperature + assert kwargs['energy_offset'] == 123.0 + + assert result == MockConvolution.return_value + + def test_create_convolver_returns_none_if_no_resolution_components(self, analysis1d): + # WHEN + analysis1d.instrument_model.resolution_model.clear_components() + + # THEN + convolver = analysis1d._create_convolver() + + # EXPECT + assert convolver is None + + def test_create_convolver_returns_none_if_no_sample_components(self, analysis1d): + # WHEN + analysis1d.sample_model.clear_components() + + # THEN + convolver = analysis1d._create_convolver() + + # EXPECT + assert convolver is None + ############# # Private methods: create scipp arrays for plotting ############# @pytest.mark.parametrize( - "background", + 'background', [ None, np.array([0.5, 0.5, 0.5]), ], ids=[ - "No background", - "With background", + 'No background', + 'With background', ], ) def test_create_component_scipp_array(self, analysis1d, background): @@ -340,23 +477,17 @@ def test_create_component_scipp_array(self, analysis1d, background): # WHEN # Mock the functions that will be called. - analysis1d._evaluate_sample_component = MagicMock( - return_value=np.array([1.0, 2.0, 3.0]) - ) + analysis1d._evaluate_sample_component = MagicMock(return_value=np.array([1.0, 2.0, 3.0])) analysis1d._to_scipp_array = MagicMock() component = object() # THEN - analysis1d._create_component_scipp_array( - component=component, background=background - ) + analysis1d._create_component_scipp_array(component=component, background=background) # EXPECT - analysis1d._evaluate_sample_component.assert_called_once_with( - component=component - ) + analysis1d._evaluate_sample_component.assert_called_once_with(component=component) expected_values = np.array([1.0, 2.0, 3.0]) if background is not None: @@ -368,7 +499,7 @@ def test_create_component_scipp_array(self, analysis1d, background): _, kwargs = analysis1d._to_scipp_array.call_args np.testing.assert_array_equal( - kwargs["values"], + kwargs['values'], expected_values, ) @@ -391,9 +522,7 @@ def test_create_background_component_scipp_array(self, analysis1d): analysis1d._create_background_component_scipp_array(component=component) # EXPECT - analysis1d._evaluate_background_component.assert_called_once_with( - component=component - ) + analysis1d._evaluate_background_component.assert_called_once_with(component=component) analysis1d._to_scipp_array.assert_called_once() @@ -401,7 +530,7 @@ def test_create_background_component_scipp_array(self, analysis1d): _, kwargs = analysis1d._to_scipp_array.call_args np.testing.assert_array_equal( - kwargs["values"], + kwargs['values'], np.array([1.0, 2.0, 3.0]), ) @@ -428,14 +557,14 @@ def test_create_sample_scipp_array(self, analysis1d): _, kwargs = analysis1d._to_scipp_array.call_args np.testing.assert_array_equal( - kwargs["values"], + kwargs['values'], np.array([1.0, 2.0, 3.0]), ) @pytest.mark.parametrize( - "add_background", + 'add_background', [True, False], - ids=["With background", "Without background"], + ids=['With background', 'Without background'], ) def test_create_components_dataset_single_Q( self, @@ -454,7 +583,7 @@ def test_create_components_dataset_single_Q( # ---- Sample component ---- sample_component = MagicMock() - sample_component.display_name = "sample_comp" + sample_component.display_name = 'sample_comp' sample_collection = MagicMock() sample_collection.components = [sample_component] @@ -465,13 +594,13 @@ def test_create_components_dataset_single_Q( # ---- Background component ---- background_component = MagicMock() - background_component.display_name = "background_comp" + background_component.display_name = 'background_comp' background_collection = MagicMock() background_collection.components = [background_component] - analysis1d.instrument_model.background_model.get_component_collection = ( - MagicMock(return_value=background_collection) + analysis1d.instrument_model.background_model.get_component_collection = MagicMock( + return_value=background_collection ) # ---- Background evaluation ---- @@ -479,26 +608,18 @@ def test_create_components_dataset_single_Q( analysis1d._evaluate_background = MagicMock(return_value=background_value) # ---- Return scipp DataArrays ---- - fake_sample_da = sc.DataArray( - data=sc.array(dims=["energy"], values=[1.0, 2.0, 3.0]) - ) + fake_sample_da = sc.DataArray(data=sc.array(dims=['energy'], values=[1.0, 2.0, 3.0])) - analysis1d._create_component_scipp_array = MagicMock( - return_value=fake_sample_da - ) + analysis1d._create_component_scipp_array = MagicMock(return_value=fake_sample_da) - fake_background_da = sc.DataArray( - data=sc.array(dims=["energy"], values=[4.0, 5.0, 6.0]) - ) + fake_background_da = sc.DataArray(data=sc.array(dims=['energy'], values=[4.0, 5.0, 6.0])) analysis1d._create_background_component_scipp_array = MagicMock( return_value=fake_background_da ) # THEN - dataset = analysis1d._create_components_dataset_single_Q( - add_background=add_background - ) + dataset = analysis1d._create_components_dataset_single_Q(add_background=add_background) # EXPECT @@ -526,13 +647,13 @@ def test_create_components_dataset_single_Q( analysis1d._create_component_scipp_array.assert_called_once() _, kwargs = analysis1d._create_component_scipp_array.call_args - assert kwargs["component"] is sample_component + assert kwargs['component'] is sample_component if expected_background is None: - assert kwargs["background"] is None + assert kwargs['background'] is None else: np.testing.assert_array_equal( - kwargs["background"], + kwargs['background'], expected_background, ) @@ -543,8 +664,8 @@ def test_create_components_dataset_single_Q( # Dataset content assert isinstance(dataset, sc.Dataset) - assert "sample_comp" in dataset - assert "background_comp" in dataset + assert 'sample_comp' in dataset + assert 'background_comp' in dataset def test_to_scipp_array(self, analysis1d): # WHEN @@ -558,10 +679,10 @@ def test_to_scipp_array(self, analysis1d): np.testing.assert_array_equal(scipp_array.values, numpy_array) np.testing.assert_array_equal( - scipp_array.coords["energy"].values, analysis1d.experiment.energy.values + scipp_array.coords['energy'].values, analysis1d.experiment.energy.values ) np.testing.assert_array_equal( - scipp_array.coords["Q"].values, + scipp_array.coords['Q'].values, analysis1d.experiment.Q[analysis1d.Q_index].values, ) From ad0689bf1b26305bd50c966b3db83ed2df7efade Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Wed, 18 Feb 2026 17:04:26 +0100 Subject: [PATCH 27/27] fix failing test --- docs/docs/tutorials/analysis.ipynb | 3 +-- tests/unit/easydynamics/sample_model/test_model_base.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/docs/tutorials/analysis.ipynb b/docs/docs/tutorials/analysis.ipynb index 0190d30d..8e0573eb 100644 --- a/docs/docs/tutorials/analysis.ipynb +++ b/docs/docs/tutorials/analysis.ipynb @@ -18,6 +18,7 @@ "metadata": {}, "outputs": [], "source": [ + "\n", "from easydynamics.analysis.analysis import Analysis\n", "from easydynamics.experiment import Experiment\n", "from easydynamics.sample_model import ComponentCollection\n", @@ -30,8 +31,6 @@ "from easydynamics.sample_model.resolution_model import ResolutionModel\n", "from easydynamics.sample_model.sample_model import SampleModel\n", "\n", - "from easyscience import global_object\n", - "\n", "%matplotlib widget" ] }, diff --git a/tests/unit/easydynamics/sample_model/test_model_base.py b/tests/unit/easydynamics/sample_model/test_model_base.py index ac61af0a..a1f11318 100644 --- a/tests/unit/easydynamics/sample_model/test_model_base.py +++ b/tests/unit/easydynamics/sample_model/test_model_base.py @@ -229,7 +229,7 @@ def test_append_component_collection(self, model_base): def test_append_component_invalid_type_raises(self, model_base): # WHEN / THEN / EXPECT - with pytest.raises(TypeError, match=' must be a ModelComponent or ComponentCollection'): + with pytest.raises(TypeError, match=' must be '): model_base.append_component('invalid_component') def test_unit_property(self, model_base):