From 568a59a63c7e66b401d5686c21593dd2ad358044 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Mon, 2 Feb 2026 10:20:57 +0100 Subject: [PATCH 1/9] Experiment mwp (#90) * initial experiment.py, with pixi fix * Add example data, fix some tests * Update .toml * add some tests * Update docs * Update docs/docs/tutorials/index.md Co-authored-by: Andrew Sazonov * respond to PR comments * update notebooks * reset notebooks --------- Co-authored-by: Andrew Sazonov --- .../docs/tutorials/component_collection.ipynb | 4 +- docs/docs/tutorials/components.ipynb | 4 +- docs/docs/tutorials/convolution.ipynb | 7 +- docs/docs/tutorials/detailed_balance.ipynb | 42 +- docs/docs/tutorials/diffusion_data_example.h5 | Bin 0 -> 67392 bytes docs/docs/tutorials/diffusion_model.ipynb | 23 +- docs/docs/tutorials/experiment.ipynb | 83 +++ docs/docs/tutorials/index.md | 19 +- docs/docs/tutorials/sample_model.ipynb | 5 +- docs/docs/tutorials/vanadium_data_example.h5 | Bin 0 -> 67392 bytes docs/mkdocs.yml | 1 + pixi.lock | 105 +++- pyproject.toml | 1 + src/easydynamics/experiment/__init__.py | 8 + src/easydynamics/experiment/experiment.py | 307 ++++++++++++ tests/conftest.py | 22 +- .../experiment/test_experiment.py | 474 ++++++++++++++++++ .../sample_model/test_model_base.py | 2 +- .../sample_model/test_resolution_model.py | 6 +- 19 files changed, 1041 insertions(+), 72 deletions(-) create mode 100644 docs/docs/tutorials/diffusion_data_example.h5 create mode 100644 docs/docs/tutorials/experiment.ipynb create mode 100644 docs/docs/tutorials/vanadium_data_example.h5 create mode 100644 src/easydynamics/experiment/__init__.py create mode 100644 src/easydynamics/experiment/experiment.py create mode 100644 tests/unit/easydynamics/experiment/test_experiment.py diff --git a/docs/docs/tutorials/component_collection.ipynb b/docs/docs/tutorials/component_collection.ipynb index 8cd6c790..5a957afc 100644 --- a/docs/docs/tutorials/component_collection.ipynb +++ b/docs/docs/tutorials/component_collection.ipynb @@ -7,7 +7,7 @@ "source": [ "# Component Collection\n", "\n", - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + "Most data will be modelled by a sum of components, which is what a ComponentCollection handles. Here we show how to create a ComponentCollection and add components to it." ] }, { @@ -67,7 +67,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "easydynamics_newbase", "language": "python", "name": "python3" }, diff --git a/docs/docs/tutorials/components.ipynb b/docs/docs/tutorials/components.ipynb index 5ab8836f..7815bcd4 100644 --- a/docs/docs/tutorials/components.ipynb +++ b/docs/docs/tutorials/components.ipynb @@ -7,7 +7,9 @@ "source": [ "# Components\n", "\n", - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + "Components are the basic ingredients for all models. Currently, the available components are Gaussian, Lorentzian, Voigt (the convolution of a Gaussian with a Lorentzian), delta function, damped harmonic oscillator and polynomial. This notebooks shows how to use the components. \n", + "\n", + "Note in particular that a Gaussian, Lorentzian, Voigt or delta function where the center has not been given will be centered at 0." ] }, { diff --git a/docs/docs/tutorials/convolution.ipynb b/docs/docs/tutorials/convolution.ipynb index a586ea5d..922970f9 100644 --- a/docs/docs/tutorials/convolution.ipynb +++ b/docs/docs/tutorials/convolution.ipynb @@ -7,7 +7,11 @@ "source": [ "# Convolution\n", "\n", - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + "The experimental resolution function must be taken into account when analysing neutron scattering data, especially QENS. In general, the scattering is given by the convolution of the model of the scattering from the sample with the model of the resolution function. Here, both the scattering from the sample and the resolution function is modelled by a ComponentCollection.\n", + "\n", + "Analytical expressions exist for the convolution between Gaussians, Lorentzians and Voigt functions, as well as between delta functions and any other function. We use these expressions whenever possible. When analytical convolution is not possible, e.g. for a Damped Harmonic Oscillator or if detailed balancing is included, we use numerical convolution based on the Fast fourier transform algorithm. The accuracy of numerical convolution depends on several factors such as the width of the peaks related to the bin size and full span of the data. Warnings are given if it seems the accuracy is low, and several settings are available to improve the accuracy.\n", + "\n", + "For most purposes, the convolution will happen behind the scenes, and you will not need to call it yourself. However, we here show how to use it and play around with the settings." ] }, { @@ -93,6 +97,7 @@ "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", diff --git a/docs/docs/tutorials/detailed_balance.ipynb b/docs/docs/tutorials/detailed_balance.ipynb index d3bca0ee..d09a2546 100644 --- a/docs/docs/tutorials/detailed_balance.ipynb +++ b/docs/docs/tutorials/detailed_balance.ipynb @@ -6,8 +6,13 @@ "metadata": {}, "source": [ "# Detailed Balance\n", + "Detailed balance describes the relationship between the intensity of inelastic and quasielastic scattering at positive and negative energy transfers. The equation is $S(-{\\bf Q}, E) = \\exp(-\\beta E) S({\\bf Q}, E)$, where $E$ is the energy transfer, ${\\bf {Q}}$ is the momentum transfer and $\\beta=1/k_BT$ is the inverse of the temperature ($T$) multiplied by Boltzman's constant ($k_B$). To enforce this relationship, we can multiply our scattering function using the Detailed Balance Factor (DBF), defined by $DBF = E (1+n)$, where $n$ is the Bose occupation factor.\n", "\n", - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + "In some communities it is customary to normalise the DBF by temperature, i.e. $DBF_N = E/(k_B T) (1+n)$.\n", + "\n", + "This notebook shows how to calculate and use the DBF. Note that it will be automatically applied if temperature is set, so you do not have to think about it.\n", + "\n", + "Details on detailed balancing can be found in most textbooks on neutron scattering." ] }, { @@ -46,7 +51,8 @@ "plt.xlabel('Energy transfer (meV)')\n", "plt.ylabel('Detailed balance factor')\n", "plt.title(\n", - " 'Detailed balance factor for different temperatures, normalized to 1 at zero energy transfer'\n", + " 'Detailed balance factor for different temperatures, \\n '\n", + " 'normalized to 1 at zero energy transfer'\n", ")\n", "plt.show()" ] @@ -76,39 +82,11 @@ "plt.title('Detailed balance factor for different temperatures, not normalized')\n", "plt.show()" ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [ - "import scipp as sc\n", - "\n", - "temperatures = [1, 10, 100]\n", - "temperature_unit = 'K'\n", - "energy = np.linspace(-1, 1, 100)\n", - "# energy=1.0\n", - "energy_unit = 'meV'\n", - "\n", - "plt.figure()\n", - "for temperature in temperatures:\n", - " DBF = detailed_balance_factor(\n", - " energy, temperature, sc.Unit('meV'), sc.Unit('K'), divide_by_temperature=False\n", - " )\n", - " plt.plot(energy, DBF, label=f'T={temperature} K')\n", - "plt.legend()\n", - "plt.xlabel('Energy transfer (meV)')\n", - "plt.ylabel('Detailed balance factor')\n", - "plt.title('Detailed balance factor for different temperatures, not normalized')\n", - "plt.show()" - ] } ], "metadata": { "kernelspec": { - "display_name": "newdynamics", + "display_name": "easydynamics_newbase", "language": "python", "name": "python3" }, @@ -122,7 +100,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.13" + "version": "3.12.12" } }, "nbformat": 4, diff --git a/docs/docs/tutorials/diffusion_data_example.h5 b/docs/docs/tutorials/diffusion_data_example.h5 new file mode 100644 index 0000000000000000000000000000000000000000..78baba303499b64ae89ee2aa771c4ccece9f5dc9 GIT binary patch literal 67392 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

V1=E*A+nX97#5k%}3+X_Jt$f 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 Vl^rjj=?4}vu>1^e?7##mpaK48d07Ae literal 0 HcmV?d00001 diff --git a/docs/docs/tutorials/diffusion_model.ipynb b/docs/docs/tutorials/diffusion_model.ipynb index c093d760..9277486e 100644 --- a/docs/docs/tutorials/diffusion_model.ipynb +++ b/docs/docs/tutorials/diffusion_model.ipynb @@ -6,8 +6,7 @@ "metadata": {}, "source": [ "# Diffusion Model\n", - "\n", - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + "We support several standard models of diffusion. Here we show an example of Browniand Translational Diffusion, where the scattering is a Lorentzian with width ($\\Gamma$) given by $\\Gamma = D Q^2$, where $D$ is the diffusion coefficient (in m$^2$/s) and $Q$ is the momentum transfer." ] }, { @@ -66,6 +65,26 @@ "plt.ylabel('Intensity (arb. units)')\n", "plt.title('Brownian Translational Diffusion Model')" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a50c67ec", + "metadata": {}, + "outputs": [], + "source": [ + "# Calculate and plot the half width at half maximum (HWHM) as function\n", + "# of Q\n", + "Q = np.linspace(0.1, 2, 101)\n", + "HWHM = diffusion_model.calculate_width(Q)\n", + "plt.figure()\n", + "plt.plot(Q, HWHM)\n", + "plt.xlabel('Q (Å$^{-1}$)')\n", + "plt.ylabel('HWHM (meV)')\n", + "plt.xlim(0, 2.5)\n", + "plt.ylim(0, max(HWHM) * 1.1)\n", + "plt.title('HWHM vs Q for Brownian Translational Diffusion')" + ] } ], "metadata": { diff --git a/docs/docs/tutorials/experiment.ipynb b/docs/docs/tutorials/experiment.ipynb new file mode 100644 index 00000000..6319c61f --- /dev/null +++ b/docs/docs/tutorials/experiment.ipynb @@ -0,0 +1,83 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "906b959a", + "metadata": {}, + "source": [ + "# Experiment\n", + "The experimental data is stored in an Experiment class. Underneath we use Scipp and Plopp to handle and plot the data. We here show how to load an example data set, rebin it and plot it in various ways." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c7d23add", + "metadata": {}, + "outputs": [], + "source": [ + "from easydynamics.experiment import Experiment\n", + "\n", + "%matplotlib widget" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2b7c5ca8", + "metadata": {}, + "outputs": [], + "source": [ + "# Load and plot example vanadium data\n", + "vanadium_experiment = Experiment('Vanadium')\n", + "vanadium_experiment.load_hdf5(filename='vanadium_data_example.h5')\n", + "\n", + "vanadium_experiment.plot_data()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "238ba6ee", + "metadata": {}, + "outputs": [], + "source": [ + "# Rebin the data and plot again\n", + "vanadium_experiment.rebin({'Q': 5, 'energy': 50})\n", + "vanadium_experiment.plot_data()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bc32ab1f", + "metadata": {}, + "outputs": [], + "source": [ + "# Plot using the plopp slicer with extra arguments\n", + "vanadium_experiment.plot_data(slicer=True, keep='energy', vmin=0, vmax=2.0)" + ] + } + ], + "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/index.md b/docs/docs/tutorials/index.md index c59f6283..ec6ed05e 100644 --- a/docs/docs/tutorials/index.md +++ b/docs/docs/tutorials/index.md @@ -17,9 +17,16 @@ The tutorials are organized into the following categories: ## Getting Started -- [Component collection](component_collection.ipynb) – Learn how to ... -- [Components](components.ipynb) – Learn how to ... -- [Convolution](convolution.ipynb) – Learn how to ... -- [Detailed balance](detailed_balance.ipynb) – Learn how to ... -- [Diffusion model](diffusion_model.ipynb) – Learn how to ... -- [Sample model](sample_model.ipynb) – Learn how to ... +- [Component collection](component_collection.ipynb) – Learn how to + create a collectin of components for fitting +- [Components](components.ipynb) – Learn how to use the EasyDynamics + components +- [Convolution](convolution.ipynb) – Learn how to calculate the + convolution of your resolution function with your model +- [Detailed balance](detailed_balance.ipynb) – Learn how to apply + detailed balancing to your model +- [Diffusion model](diffusion_model.ipynb) – Learn how to create and use + a model of diffusion +- [Sample model](sample_model.ipynb) – Learn how to create a model of + the scattering from your sample +- [Experiment](experiment.ipynb) - Learn how to load and bin your data diff --git a/docs/docs/tutorials/sample_model.ipynb b/docs/docs/tutorials/sample_model.ipynb index dbd8e0bb..802aff0b 100644 --- a/docs/docs/tutorials/sample_model.ipynb +++ b/docs/docs/tutorials/sample_model.ipynb @@ -6,8 +6,11 @@ "metadata": {}, "source": [ "# Sample Model\n", + "We here introduce the SampleModel, ResolutionModel and BackgroundModel, which all function in a similar way. They are, as the name implies, used to describe scattering from the sample, the resolution function and the background, respectively.\n", "\n", - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + "The models describe the scattering from the sample as function of ${\\bf Q}$ and $E$. This is done by generating ComponentCollection's for each ${\\bf Q}$, where each ComponentCollection contains a collection of ModelComponents such as Gaussians and Lorentzians. The model can be given a single template component or a collection of components that will be copied to each ${\\bf Q}$\n", + "\n", + "We further support various models of diffusion, which typically generate one or more Lorentzian components, where the width (and usually the area) has a particular dependence on $Q$." ] }, { diff --git a/docs/docs/tutorials/vanadium_data_example.h5 b/docs/docs/tutorials/vanadium_data_example.h5 new file mode 100644 index 0000000000000000000000000000000000000000..0c7534a36b04206035789971fcfcd4aea602fb7a GIT binary patch literal 67392 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 TpCj|5}j6&=3.5' - pypi: ./ name: easydynamics - version: 999.0.0+devdirty44 - sha256: a67202e3a0848ef4cdb1a02a62ce4938bb4ef4a8e2c148fb2a36ed9ebfb02bbf + version: 0.1.0+devdirty7 + sha256: de299c914d4a865b9e2fdefa5e3947f37b1f26f73ff9087f7918ee417f3dd288 requires_dist: - darkdetect - easyscience @ git+https://github.com/easyscience/corelib.git@develop @@ -4071,6 +4101,7 @@ packages: - jupyterlab - pandas - pixi-kernel + - plopp - plotly - pooch - py3dmol @@ -4103,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: @@ -5436,6 +5466,18 @@ packages: - atomicwrites ; extra == 'atomic-cache' - interegular>=0.3.1,<0.4.0 ; extra == 'interegular' requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/83/60/d497a310bde3f01cb805196ac61b7ad6dc5dcf8dce66634dc34364b20b4f/lazy_loader-0.4-py3-none-any.whl + name: lazy-loader + version: '0.4' + sha256: 342aa8e14d543a154047afb4ba8ef17f5563baad3fc610d7b15b213b0f119efc + requires_dist: + - packaging + - importlib-metadata ; python_full_version < '3.8' + - changelist==0.5 ; extra == 'dev' + - pre-commit==3.7.0 ; extra == 'lint' + - pytest>=7.4 ; extra == 'test' + - pytest-cov>=4.1 ; extra == 'test' + requires_python: '>=3.7' - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45-default_hbd61a6d_105.conda sha256: 1027bd8aa0d5144e954e426ab6218fd5c14e54a98f571985675468b339c808ca md5: 3ec0aa5037d39b06554109a01e6fb0c6 @@ -8534,6 +8576,37 @@ packages: - pytest>=8.4.2 ; extra == 'test' - mypy>=1.18.2 ; extra == 'type' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/84/4a/d070dc6a36c2eb8b8a19b31908d0817e2a85fe0b70f9db20834a495a74e1/plopp-25.11.0-py3-none-any.whl + name: plopp + version: 25.11.0 + sha256: b449415fed4fe9254140393df75c3b57640de7ba0571c12463331b12cbf54180 + requires_dist: + - lazy-loader>=0.4 + - matplotlib>=3.8 + - scipp>=25.5.0 ; extra == 'scipp' + - scipp>=25.5.0 ; extra == 'all' + - ipympl>0.8.4 ; extra == 'all' + - pythreejs>=2.4.1 ; extra == 'all' + - mpltoolbox>=24.6.0 ; extra == 'all' + - ipywidgets>=8.1.0 ; extra == 'all' + - graphviz>=0.20.3 ; extra == 'all' + - graphviz>=0.20.3 ; extra == 'test' + - h5py>=3.12 ; extra == 'test' + - ipympl>=0.8.4 ; extra == 'test' + - ipywidgets>=8.1.0 ; extra == 'test' + - ipykernel<7 ; extra == 'test' + - mpltoolbox>=24.6.0 ; extra == 'test' + - pandas>=2.2.2 ; extra == 'test' + - plotly>=5.15.0 ; extra == 'test' + - pooch>=1.5 ; extra == 'test' + - pyarrow>=13.0.0 ; extra == 'test' + - pytest>=7.0 ; extra == 'test' + - pythreejs>=2.4.1 ; extra == 'test' + - scipp>=25.5.0 ; extra == 'test' + - scipy>=1.10.0 ; extra == 'test' + - xarray>=2024.5.0 ; extra == 'test' + - anywidget>=0.9.0 ; extra == 'test' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/8a/67/f95b5460f127840310d2187f916cf0023b5875c0717fdf893f71e1325e87/plotly-6.5.2-py3-none-any.whl name: plotly version: 6.5.2 diff --git a/pyproject.toml b/pyproject.toml index 88d8b131..46ab2717 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ 'ipywidgets', # Jupyter widgets 'jupyterlab', # Jupyter notebooks 'pixi-kernel', # Pixi Jupyter kernel + 'plopp', # Plotting library ] [project.optional-dependencies] diff --git a/src/easydynamics/experiment/__init__.py b/src/easydynamics/experiment/__init__.py new file mode 100644 index 00000000..6b3a8a44 --- /dev/null +++ b/src/easydynamics/experiment/__init__.py @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-License-Identifier: BSD-3-Clause + +from .experiment import Experiment + +__all__ = [ + 'Experiment', +] diff --git a/src/easydynamics/experiment/experiment.py b/src/easydynamics/experiment/experiment.py new file mode 100644 index 00000000..b3df2a11 --- /dev/null +++ b/src/easydynamics/experiment/experiment.py @@ -0,0 +1,307 @@ +import os +import warnings +from typing import Optional + +import plopp as pp +import scipp as sc +from easyscience.base_classes.new_base import NewBase +from scipp.io import load_hdf5 as sc_load_hdf5 +from scipp.io import save_hdf5 as sc_save_hdf5 + + +class Experiment(NewBase): + """Holds data from an experiment as a sc.DataArray along with + metadata. + + This is a minimal implementation that will be extended in the + future. + """ + + def __init__( + self, + display_name: str = 'MyExperiment', + unique_name: str | None = None, + data: sc.DataArray | str | None = None, + ): + super().__init__( + display_name=display_name, + unique_name=unique_name, + ) + + if data is None: + self._data: Optional[sc.DataArray] = None + elif isinstance(data, str): + self.load_hdf5(filename=data) + elif isinstance(data, sc.DataArray): + self._validate_coordinates(data) + self._data = data + else: + raise TypeError( + f'Data must be a sc.DataArray or a filename string, not {type(data).__name__}' + ) + + self._binned_data = ( + self._convert_to_bin_centers(self._data) if self._data is not None else None + ) + + ########### + # Properties + ########### + + @property + def data(self) -> sc.DataArray | None: + """Get the dataset associated with this experiment.""" + return self._data + + @data.setter + 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__}') + self._validate_coordinates(value) + self._data = value + self._binned_data = ( + self._convert_to_bin_centers(self._data) if self._data is not None else None + ) + + @property + def binned_data(self) -> sc.DataArray | None: + """Get the binned dataset associated with this experiment.""" + return self._binned_data + + @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') + + @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) + return None + 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.') + + @property + def energy(self) -> sc.Variable: + """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): + """Set the energy values for the dataset.""" + raise AttributeError('energy is a read-only property derived from the data.') + + ########### + # Handle data + ########### + + def load_hdf5(self, filename: str, display_name: str | None = None): + """Load data from an HDF5 file. + + Args: + filename (str ): Path to the HDF5 file. + display_name (str | None): Optional display name for the + experiment. + """ + if not isinstance(filename, str): + 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__}' + ) + 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__}' + ) + self._validate_coordinates(loaded_data) + self.data = loaded_data + + def save_hdf5(self, filename: str | None = None): + """Save the dataset to HDF5. + + Args: + filename (str | None): Path to the output HDF5 file. + """ + + if filename is None: + filename = f'{self.unique_name}.h5' + + if not isinstance(filename, str): + raise TypeError(f'Filename must be a string, not {type(filename).__name__}') + + if self._data is None: + raise ValueError('No data to save.') + + dir_name = os.path.dirname(filename) + if dir_name: + os.makedirs(dir_name, exist_ok=True) + + sc_save_hdf5(self._data, filename) + + def remove_data(self): + """Remove the dataset from the experiment.""" + self._data = None + self._binned_data = None + + def rebin(self, dimensions: dict[str, int | sc.Variable]) -> None: + """Rebin the dataset along specified dimensions. + + Args: + dimensions (dict[str, int | sc.Variable]): A dictionary + mapping dimension names to number of bins (int) or bin edges + (sc.Variable). + Raises: + TypeError: If dimensions is not a dictionary or if + keys/values are of incorrect types. KeyError: If a specified + dimension is not in the dataset. + """ + + 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.' + ) + if self._data is None: + 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.' + ) + 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}.' + ) + 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"Got {type(value)} for dimension '{dim}' instead." + ) + binned_data = binned_data.bin({dim: value}) + + binned_data = binned_data.bins.mean() + binned_data = self._convert_to_bin_centers(binned_data) + self._binned_data = binned_data + + ########### + # other methods + ########### + + 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.') + + if not self._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, + } + # Overwrite defaults with any user-provided kwargs + plot_kwargs_defaults.update(kwargs) + if slicer: + fig = pp.slicer( + self._binned_data, + **plot_kwargs_defaults, + ) + else: + fig = pp.plot( + self._binned_data.transpose(dims=['energy', 'Q']), + **plot_kwargs_defaults, + ) + display(fig) + + ########### + # 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. + + Raises: + ValueError: If required coordinates are missing. + """ + if not isinstance(data, sc.DataArray): + raise TypeError('Data must be a sc.DataArray.') + + required_coords = ['Q', 'energy'] + for coord in required_coords: + if coord not in data.coords: + raise ValueError(f"Data is missing required coordinate: '{coord}'") + + def _convert_to_bin_centers(self, data: sc.DataArray) -> sc.DataArray: + """Convert the coordinates of the data to bin centers. + + Args: + data (sc.DataArray): The data to check. + + Returns: + sc.DataArray: The data with coordinates at bin centers. + """ + for dim in data.dims: + coord = data.coords[dim] + if coord.ndim == 1 and coord.size == data.sizes[dim] + 1: + # Coordinate is at bin edges, convert to bin centers + data = data.assign_coords({dim: sc.midpoints(coord)}) + return data + + ######## + # dunder methods + ########### + + def __repr__(self) -> str: + return f'Experiment `{self.unique_name}` with data: {self._data}' + + def __copy__(self) -> 'Experiment': + """Return a copy of the object.""" + 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/tests/conftest.py b/tests/conftest.py index 5a32716c..aefc6c0b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,13 +7,19 @@ import easyscience.global_object import pytest -from easyscience.global_object.map import Map +# from easyscience.global_object.map import Map -@pytest.fixture(autouse=True) -def reset_global_object(monkeypatch): - # Before each test - monkeypatch.setattr(easyscience.global_object, 'map', Map()) - yield - # After each test (cleanup) - monkeypatch.setattr(easyscience.global_object, 'map', Map()) + +# @pytest.fixture(autouse=True) +# def reset_global_object(monkeypatch): +# # Before each test +# monkeypatch.setattr(easyscience.global_object, 'map', Map()) +# yield +# # After each test (cleanup) +# monkeypatch.setattr(easyscience.global_object, 'map', Map()) + + +@pytest.fixture(autouse=False) +def reset_global_object(): + easyscience.global_object.map._clear() diff --git a/tests/unit/easydynamics/experiment/test_experiment.py b/tests/unit/easydynamics/experiment/test_experiment.py new file mode 100644 index 00000000..067a2017 --- /dev/null +++ b/tests/unit/easydynamics/experiment/test_experiment.py @@ -0,0 +1,474 @@ +from copy import copy +from unittest.mock import MagicMock +from unittest.mock import patch + +import numpy as np +import pytest +import scipp as sc + +from easydynamics.experiment import Experiment + + +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}) + + experiment = Experiment(display_name='test_experiment', data=data) + return experiment + + ############## + # test init + ############## + + def test_init_array(self, experiment): + "Test initialization with a Scipp DataArray" + # WHEN THEN EXPECT + 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 sc.identical( + experiment._data.data, + 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' + # 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}) + + filename = tmp_path / 'test_experiment.h5' + sc.io.save_hdf5(data, filename) + + # THEN + experiment = Experiment(display_name='loaded_experiment', data=str(filename)) + + # EXPECT + 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 sc.identical( + experiment._data.data, + 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') + + # THEN EXPECT + assert experiment.display_name == 'empty_experiment' + assert experiment._data is None + + def test_init_invalid_data(self): + "Test initialization with invalid data type" + # WHEN / THEN EXPECT + with pytest.raises(TypeError): + Experiment(data=123) + + ############## + # test data manipulation + ############## + + 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.' + # WHEN + # First create a file to load from + 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') + loaded_data = new_experiment.data + + # EXPECT + assert sc.identical(data_to_save, 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' + # WHEN / THEN EXPECT + with pytest.raises(TypeError): + 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'): + experiment.load_hdf5(123) + + def test_load_hdf5_invalid_file_raises(self, experiment): + "Test loading data from a non-existent HDF5 file" + # WHEN / THEN EXPECT + + with pytest.raises(OSError): + 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.' + # WHEN THEN + filename = tmp_path / 'saved_data.h5' + experiment.save_hdf5(str(filename)) + + # EXPECT + loaded_data = sc.io.load_hdf5(str(filename)) + original_data = experiment.data + assert sc.identical(original_data, loaded_data) + + def test_save_hdf5_default_filename(self, tmp_path, experiment, monkeypatch): + "Test saving data to an HDF5 file with default filename" + # WHEN + monkeypatch.chdir(tmp_path) + + # THEN + experiment.save_hdf5() + + # EXPECT + 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' + # WHEN + experiment = Experiment() + + # THEN EXPECT + with pytest.raises(ValueError): + 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'): + experiment.save_hdf5(123) + + def test_remove_data(self, experiment): + "Test removing data from the experiment" + # WHEN + experiment.remove_data() + + # THEN EXPECT + assert experiment._data is None + + @pytest.mark.parametrize( + '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'), + ), + ( + 6, + 7, + ), + ( + 6.0, + 7.0, + ), + ( + sc.linspace('Q', 0.5, 1.5, num=7, unit='1/Angstrom'), + 7, + ), + ], + 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}) + + # EXPECT + rebinned_data = experiment.binned_data + 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" + # WHEN + experiment = Experiment() + + # THEN EXPECT + with pytest.raises(ValueError): + 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') + + 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}) + + 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}) + + 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', + ): + experiment.rebin({'Q': [0.5, 1.0, 1.5], 'energy': 7}) + + ############## + # test setters and getters + ############## + + def test_data_setter_raises_type_error(self, experiment): + "Test setting data to an invalid type raises TypeError" + # WHEN THEN EXPECT + with pytest.raises(TypeError): + experiment.data = 123 + + def test_binned_data_setter_raises(self, experiment): + "Test that setting binned data raises AttributeError" + # WHEN THEN EXPECT + with pytest.raises(AttributeError): + experiment.binned_data = experiment.binned_data + + def test_energy_setter_raises(self, experiment): + "Test that setting energy data raises AttributeError" + # WHEN THEN EXPECT + with pytest.raises(AttributeError): + experiment.energy = experiment.energy + + def test_Q_setter_raises(self, experiment): + "Test that setting Q data raises AttributeError" + # WHEN THEN EXPECT + 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 + ############## + + 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, + ): + mock_fig = MagicMock() + mock_plot.return_value = mock_fig + + # THEN + experiment.plot_data() + + # EXPECT + 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}' + mock_display.assert_called_once_with(mock_fig) + + def test_plot_data_no_data_raises(self): + "Test plotting data raises ValueError when no data is present" + # WHEN + experiment = Experiment() + + # THEN EXPECT + 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 + with patch.object(Experiment, '_in_notebook', return_value=False): + # THEN EXPECT + with pytest.raises( + RuntimeError, + match='plot_data\\(\\) can only be used in a Jupyter notebook environment', + ): + experiment.plot_data() + + ############## + # 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 + experiment._validate_coordinates(experiment._data) + + def test_validate_coordinates_raises_missing_Q(self, experiment): + "Test that _validate_coordinates raises ValueError when Q coord" + 'is missing' + # WHEN + invalid_data = experiment._data.copy() + invalid_data.coords.pop('Q') + + # THEN EXPECT + 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' + # WHEN + invalid_data = experiment._data.copy() + invalid_data.coords.pop('energy') + + # THEN EXPECT + 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' + # WHEN THEN EXPECT + 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}) + + # THEN + experiment._data = binned_data # Set data to avoid warnings + converted_data = experiment._convert_to_bin_centers(binned_data) + + # EXPECT + 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.data, binned_data.data) + + ############## + # test dunder methods + ############## + + def test_repr(self, experiment): + # WHEN + repr_str = repr(experiment) + + # THEN EXPECT + 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.' + # WHEN + copied_experiment = copy(experiment) + + # THEN EXPECT + assert copied_experiment.display_name == experiment.display_name + assert sc.identical(copied_experiment.data, experiment.data) + assert copied_experiment is not experiment + assert copied_experiment.data is not experiment.data diff --git a/tests/unit/easydynamics/sample_model/test_model_base.py b/tests/unit/easydynamics/sample_model/test_model_base.py index 44541e12..31feb66a 100644 --- a/tests/unit/easydynamics/sample_model/test_model_base.py +++ b/tests/unit/easydynamics/sample_model/test_model_base.py @@ -14,7 +14,7 @@ class TestModelBase: @pytest.fixture - def model_base(self): + def model_base(self, reset_global_object): component1 = Gaussian( display_name='TestGaussian1', area=1.0, diff --git a/tests/unit/easydynamics/sample_model/test_resolution_model.py b/tests/unit/easydynamics/sample_model/test_resolution_model.py index d45eee19..120cbf9b 100644 --- a/tests/unit/easydynamics/sample_model/test_resolution_model.py +++ b/tests/unit/easydynamics/sample_model/test_resolution_model.py @@ -89,7 +89,7 @@ def test_init_raises_with_invalid_components(self, invalid_component, expected_e collection.append_component(invalid_component) ResolutionModel(components=collection) - def test_append_and_remove_and_clear_component(self, resolution_model): + def test_append_and_remove_and_clear_component(self, resolution_model, reset_global_object): # WHEN new_component = Gaussian(unique_name='NewGaussian') @@ -136,7 +136,9 @@ def test_append_component_collection(self, resolution_model): ], ids=['DeltaFunction', 'Polynomial'], ) - def test_append_invalid_component_type_raises(self, resolution_model, invalid_component): + def test_append_invalid_component_type_raises( + self, resolution_model, invalid_component, reset_global_object + ): # WHEN / THEN / EXPECT # appending a single component with pytest.raises( From 7d4bf05a499cd36733b3ceee6d118ca6ff444813 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Thu, 5 Feb 2026 20:30:14 +0100 Subject: [PATCH 2/9] 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 --- docs/docs/tutorials/instrument_model.ipynb | 101 +++++ pixi.lock | 2 +- .../sample_model/component_collection.py | 2 +- .../sample_model/instrument_model.py | 304 ++++++++++++- src/easydynamics/sample_model/model_base.py | 76 +++- src/easydynamics/sample_model/sample_model.py | 11 +- .../sample_model/test_component_collection.py | 7 +- .../sample_model/test_instrument_model.py | 398 ++++++++++++++++++ .../sample_model/test_model_base.py | 50 +++ 9 files changed, 923 insertions(+), 28 deletions(-) create mode 100644 docs/docs/tutorials/instrument_model.ipynb create mode 100644 tests/unit/easydynamics/sample_model/test_instrument_model.py diff --git a/docs/docs/tutorials/instrument_model.ipynb b/docs/docs/tutorials/instrument_model.ipynb new file mode 100644 index 00000000..a56b300f --- /dev/null +++ b/docs/docs/tutorials/instrument_model.ipynb @@ -0,0 +1,101 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Instrument Model\n", + "We here introduce the InstrumentModel, which contains all information related to the instrument: the BackgroundModel, ResolutionModel and also a fittable offset in the energy transfer due to slight instrument misalignment.\n", + "\n", + "The InstrumentModel does not itself do any calculations; it is merely a container for all information about the instrument.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\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", + "\n", + "%matplotlib widget" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# Create a BackgroundModel and a ResolutionModel and add them to an\n", + "# InstrumentModel\n", + "\n", + "Q = np.linspace(0.1, 2.0, 5)\n", + "\n", + "background_model = BackgroundModel()\n", + "background_model.components = Polynomial(coefficients=[1, 0.1, 0.01])\n", + "\n", + "resolution_model = ResolutionModel()\n", + "resolution_model.append_component(Gaussian(width=0.05))\n", + "\n", + "instrument_model = InstrumentModel(\n", + " Q=Q,\n", + " resolution_model=resolution_model,\n", + " background_model=background_model,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "instrument_model.get_all_variables(Q_index=1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3eca4688", + "metadata": {}, + "outputs": [], + "source": [ + "instrument_model.fix_resolution_parameters()\n", + "instrument_model.get_all_variables(Q_index=1)" + ] + } + ], + "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 d9461637..da8aee45 100644 --- a/pixi.lock +++ b/pixi.lock @@ -4091,7 +4091,7 @@ packages: requires_python: '>=3.5' - pypi: ./ name: easydynamics - version: 0.1.0+devdirty7 + version: 0.1.1+devdirty2 sha256: de299c914d4a865b9e2fdefa5e3947f37b1f26f73ff9087f7918ee417f3dd288 requires_dist: - darkdetect diff --git a/src/easydynamics/sample_model/component_collection.py b/src/easydynamics/sample_model/component_collection.py index 5978539d..586a6649 100644 --- a/src/easydynamics/sample_model/component_collection.py +++ b/src/easydynamics/sample_model/component_collection.py @@ -223,7 +223,7 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) """ if not self.components: - raise ValueError('No components in the model to evaluate.') + return np.zeros_like(x) return sum(component.evaluate(x) for component in self.components) def evaluate_component( diff --git a/src/easydynamics/sample_model/instrument_model.py b/src/easydynamics/sample_model/instrument_model.py index 9f3eb1d2..bef6bd92 100644 --- a/src/easydynamics/sample_model/instrument_model.py +++ b/src/easydynamics/sample_model/instrument_model.py @@ -1,5 +1,305 @@ # SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors # SPDX-License-Identifier: BSD-3-Clause -# instrument_model will contain resolution_model and background_model -# as well as offset +from copy import copy + +import numpy as np +import scipp as sc +from easyscience.base_classes.new_base import NewBase +from easyscience.variable import Parameter + +from easydynamics.sample_model.background_model import BackgroundModel +from easydynamics.sample_model.resolution_model import ResolutionModel +from easydynamics.utils.utils import Numeric +from easydynamics.utils.utils import Q_type +from easydynamics.utils.utils import _validate_and_convert_Q +from easydynamics.utils.utils import _validate_unit + + +class InstrumentModel(NewBase): + """InstrumentModel represents a model of the instrument in an + experiment at various Q. It can contain a model of the resolution + function for convolutions, of the background and an offset in the + energy axis. + + Parameters + ---------- + display_name : str, optional + The display name of the InstrumentModel. Default is + "MyInstrumentModel". + unique_name : str or None, optional + The unique name of the InstrumentModel. Default is None. + Q : np.ndarray, list, scipp Variable or None, optional + The Q values where the instrument is modelled. + resolution_model : ResolutionModel or None, optional + The resolution model of the instrument. If None, an empty + resolution model is created and no resolution convolution is + carried out. Default is None. + background_model : BackgroundModel or None, optional + The background model of the instrument. If None, an empty + background model is created, and the background evaluates to 0. + Default is None. + energy_offset : float, int or None, optional + Template energy offset of the instrument. Will be copied to each + Q value. If None, the energy offset will be 0. Default is None. + unit : str or sc.Unit, optional + The unit of the energy axis. Default is 'meV'. + """ + + def __init__( + self, + 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', + ): + super().__init__( + display_name=display_name, + unique_name=unique_name, + ) + + self._unit = _validate_unit(unit) + + if resolution_model is None: + self._resolution_model = ResolutionModel() + else: + if not isinstance(resolution_model, ResolutionModel): + raise TypeError( + f'resolution_model must be a ResolutionModel or None, ' + f'got {type(resolution_model).__name__}' + ) + self._resolution_model = resolution_model + + if background_model is None: + self._background_model = BackgroundModel() + else: + if not isinstance(background_model, BackgroundModel): + raise TypeError( + f'background_model must be a BackgroundModel or None, ' + f'got {type(background_model).__name__}' + ) + self._background_model = background_model + + if energy_offset is None: + energy_offset = 0.0 + + if not isinstance(energy_offset, Numeric): + raise TypeError('energy_offset must be a number or None') + + self._energy_offset = Parameter( + name='energy_offset', + value=float(energy_offset), + unit=self.unit, + fixed=False, + ) + self._Q = _validate_and_convert_Q(Q) + self._on_Q_change() + + # ------------------------------------------------------------- + # Properties + # ------------------------------------------------------------- + + @property + def resolution_model(self) -> ResolutionModel: + """Get the resolution model of the instrument.""" + return self._resolution_model + + @resolution_model.setter + 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__}' + ) + self._resolution_model = value + self._on_resolution_model_change() + + @property + def background_model(self) -> BackgroundModel: + """The background model of the instrument.""" + return self._background_model + + @background_model.setter + 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__}' + ) + self._background_model = value + self._on_background_model_change() + + @property + def Q(self) -> np.ndarray | None: + """Get the Q values of the InstrumentModel.""" + return self._Q + + @Q.setter + def Q(self, value: Q_type | None) -> None: + """Set the Q values of the InstrumentModel.""" + self._Q = _validate_and_convert_Q(value) + self._on_Q_change() + + @property + def unit(self) -> sc.Unit: + """Get the unit of the InstrumentModel. + + Returns + ------- + str or sc.Unit or None + """ + return self._unit + + @unit.setter + 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.' + ) + ) # noqa: E501 + + @property + def energy_offset(self) -> Parameter: + """The energy offset template parameter of the instrument + model. + """ + return self._energy_offset + + @energy_offset.setter + def energy_offset(self, value: Numeric): + """Set the offset parameter of the instrument model.". + + Parameters + ---------- + value : float or int + The new value for the energy offset parameter. Will be + copied to all Q values. + Raises + ------ + TypeError + If value is not a number. + """ + if not isinstance(value, Numeric): + raise TypeError(f'energy_offset must be a number, got {type(value).__name__}') + self._energy_offset.value = value + + self._on_energy_offset_change() + + # -------------------------------------------------------------- + # Other methods + # -------------------------------------------------------------- + + def convert_unit(self, unit_str: str | sc.Unit) -> None: + """Convert the unit of the InstrumentModel. + + Parameters + ---------- + unit_str : str or sc.Unit + The unit to convert to. + + Raises + ------ + TypeError + If unit_str is not a string or scipp Unit. + """ + unit = _validate_unit(unit_str) + if unit is None: + 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) + self._energy_offset.convert_unit(unit) + for offset in self._energy_offsets: + offset.convert_unit(unit) + + self._unit = unit + + def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: + """Get all variables in the InstrumentModel. + + Parameters + ---------- + Q_index : int | None + The index of the Q value to get variables for. If None, get + variables for all Q values. + Returns + ------- + list of Parameter + All variables in the InstrumentModel. + """ + if self._Q is None: + return [] + + if Q_index is None: + 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__}') + 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)}' + ) + variables = [self._energy_offsets[Q_index]] + + variables.extend(self._background_model.get_all_variables(Q_index=Q_index)) + variables.extend(self._resolution_model.get_all_variables(Q_index=Q_index)) + + return variables + + def fix_resolution_parameters(self) -> None: + """Fix all parameters in the resolution model.""" + self.resolution_model.fix_all_parameters() + + def free_resolution_parameters(self) -> None: + """Free all parameters in the resolution model.""" + self.resolution_model.free_all_parameters() + + # -------------------------------------------------------------- + # Private methods + # -------------------------------------------------------------- + + def _generate_energy_offsets(self) -> None: + """Generate energy offset Parameters for each Q value.""" + if self._Q is None: + self._energy_offsets = [] + return + + self._energy_offsets = [copy(self._energy_offset) for _ in self._Q] + + def _on_Q_change(self) -> None: + """Handle changes to the Q values.""" + self._generate_energy_offsets() + self._resolution_model.Q = self._Q + self._background_model.Q = self._Q + + def _on_energy_offset_change(self) -> None: + """Handle changes to the energy offset.""" + for offset in self._energy_offsets: + offset.value = self._energy_offset.value + + def _on_resolution_model_change(self) -> None: + """Handle changes to the resolution model.""" + self._resolution_model.Q = self._Q + + def _on_background_model_change(self) -> None: + """Handle changes to the background model.""" + self._background_model.Q = self._Q + + # ------------------------------------------------------------- + # Dunder methods + # ------------------------------------------------------------- + + 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')' + ) diff --git a/src/easydynamics/sample_model/model_base.py b/src/easydynamics/sample_model/model_base.py index c99e3f62..b6b8bcdd 100644 --- a/src/easydynamics/sample_model/model_base.py +++ b/src/easydynamics/sample_model/model_base.py @@ -7,6 +7,7 @@ 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.sample_model.component_collection import ComponentCollection from easydynamics.sample_model.components.model_component import ModelComponent @@ -106,7 +107,7 @@ def append_component(self, component: ModelComponent | ComponentCollection) -> N The ModelComponent or ComponentCollection to append. """ self._components.append_component(component) - self._generate_component_collections() + self._on_components_change() def remove_component(self, unique_name: str) -> None: """Remove a ModelComponent from the SampleModel by its unique @@ -117,12 +118,12 @@ def remove_component(self, unique_name: str) -> None: to remove. """ self._components.remove_component(unique_name) - self._generate_component_collections() + self._on_components_change() def clear_components(self) -> None: """Clear all ModelComponents from the SampleModel.""" self._components.clear_components() - self._generate_component_collections() + self._on_components_change() # ------------------------------------------------------------------ # Properties @@ -166,7 +167,7 @@ def convert_unit(self, unit: str | sc.Unit) -> None: except Exception: # noqa: S110 pass # Best effort rollback raise e - self._generate_component_collections() + self._on_components_change() @property def components(self) -> list[ModelComponent]: @@ -192,7 +193,53 @@ def Q(self) -> np.ndarray | None: def Q(self, value: Q_type | None) -> None: """Set the Q values of the SampleModel.""" self._Q = _validate_and_convert_Q(value) - self._generate_component_collections() + self._on_Q_change() + + # ------------------------------------------------------------------ + # Other methods + # ------------------------------------------------------------------ + def fix_all_parameters(self) -> None: + """Fix all Parameters in all ComponentCollections.""" + for par in self.get_all_variables(): + par.fixed = True + + def free_all_parameters(self) -> None: + """Free all Parameters in all ComponentCollections.""" + for par in self.get_all_variables(): + par.fixed = False + + def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: + """Get all Parameters and Descriptors from all + ComponentCollections in the ModelBase. Parameters Ignores the + Parameters and Descriptors in self._components as these are just + templates. + + Parameters + ---------- + Q_index : int | None + If int, get variables for the ComponentCollection at + this index. If None, get variables for all + ComponentCollections. + Returns + ------- + list[Parameter] + """ + if Q_index is None: + all_vars = [ + var + for collection in self._component_collections + for var in collection.get_all_variables() + ] + else: + if not isinstance(Q_index, int): + 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)}' + ) + all_vars = self._component_collections[Q_index].get_all_variables() + return all_vars # ------------------------------------------------------------------ # Private methods @@ -215,20 +262,13 @@ def _generate_component_collections(self) -> None: for component in self._components.components: collection.append_component(copy(component)) - def get_all_variables(self): - """Get all Parameters and Descriptors from all - ComponentCollections in the ModelBase. - - Ignores the Parameters and Descriptors in self._components as - these are just templates. - """ + def _on_Q_change(self) -> None: + """Handle changes to the Q values.""" + self._generate_component_collections() - all_vars = [ - var - for collection in self._component_collections - for var in collection.get_all_variables() - ] - return all_vars + def _on_components_change(self) -> None: + """Handle changes to the components.""" + self._generate_component_collections() # ------------------------------------------------------------------ # dunder methods diff --git a/src/easydynamics/sample_model/sample_model.py b/src/easydynamics/sample_model/sample_model.py index af5550a9..cd1f2c2e 100644 --- a/src/easydynamics/sample_model/sample_model.py +++ b/src/easydynamics/sample_model/sample_model.py @@ -175,7 +175,7 @@ def diffusion_models( 'or None' ) self._diffusion_models = value - self._generate_component_collections() + self._on_diffusion_models_change() @property def temperature(self) -> Parameter | None: @@ -286,7 +286,7 @@ def evaluate( return y - def get_all_variables(self): + def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: """Get all Parameters and Descriptors from all ComponentCollections in the SampleModel. @@ -294,7 +294,8 @@ def get_all_variables(self): diffusion models. Ignores the Parameters and Descriptors in self._components as these are just templates. """ - all_vars = super().get_all_variables() + + all_vars = super().get_all_variables(Q_index=Q_index) if self._temperature is not None: all_vars.append(self._temperature) @@ -325,6 +326,10 @@ def _generate_component_collections(self) -> None: for component in source.components: target.append_component(component) + def _on_diffusion_models_change(self) -> None: + """Handle changes to the diffusion models.""" + self._generate_component_collections() + # ------------------------------------------------------------------ # dunder methods # ------------------------------------------------------------------ diff --git a/tests/unit/easydynamics/sample_model/test_component_collection.py b/tests/unit/easydynamics/sample_model/test_component_collection.py index 926adfa6..42a66f6a 100644 --- a/tests/unit/easydynamics/sample_model/test_component_collection.py +++ b/tests/unit/easydynamics/sample_model/test_component_collection.py @@ -216,13 +216,14 @@ def test_evaluate(self, component_collection): ) + component_collection.components[1].evaluate(x) np.testing.assert_allclose(result, expected_result, rtol=1e-5) - def test_evaluate_no_components_raises(self): + def test_evaluate_no_components_returns_zero(self): # WHEN THEN component_collection = ComponentCollection(display_name='EmptyModel') x = np.linspace(-5, 5, 100) # EXPECT - with pytest.raises(ValueError, match='No components in the model to evaluate.'): - component_collection.evaluate(x) + result = component_collection.evaluate(x) + assert np.all(result == 0.0) + assert result.shape == x.shape def test_evaluate_component(self, component_collection): # WHEN THEN diff --git a/tests/unit/easydynamics/sample_model/test_instrument_model.py b/tests/unit/easydynamics/sample_model/test_instrument_model.py new file mode 100644 index 00000000..00f036cd --- /dev/null +++ b/tests/unit/easydynamics/sample_model/test_instrument_model.py @@ -0,0 +1,398 @@ +# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-License-Identifier: BSD-3-Clause + + +from unittest.mock import MagicMock +from unittest.mock import patch + +import numpy as np +import pytest + +from easydynamics.sample_model import Gaussian +from easydynamics.sample_model import Polynomial +from easydynamics.sample_model.background_model import BackgroundModel +from easydynamics.sample_model.instrument_model import InstrumentModel +from easydynamics.sample_model.resolution_model import ResolutionModel + + +class TestInstrumentModel: + @pytest.fixture + def instrument_model(self): + Q = np.array([1.0, 2.0, 3.0]) + component1 = Polynomial(coefficients=[1.0, 2.0]) + background_model = BackgroundModel(components=component1, Q=Q) + + component2 = Gaussian() + resolution_model = ResolutionModel(components=component2, Q=Q) + + instrument_model = InstrumentModel( + display_name='TestInstrumentModel', + background_model=background_model, + resolution_model=resolution_model, + Q=Q, + ) + + return instrument_model + + @pytest.fixture + def resolution_model(self): + component = Gaussian() + resolution_model = ResolutionModel(components=component) + return resolution_model + + @pytest.fixture + def background_model(self): + component = Polynomial(coefficients=[1.0, 2.0]) + background_model = BackgroundModel(components=component) + return background_model + + def test_init(self, instrument_model): + # WHEN THEN + model = instrument_model + + # EXPECT + assert model.display_name == 'TestInstrumentModel' + np.testing.assert_array_equal(model.Q, np.array([1.0, 2.0, 3.0])) + assert isinstance(model.background_model, BackgroundModel) + assert isinstance(model.resolution_model, ResolutionModel) + np.testing.assert_array_equal(model.background_model.Q, np.array([1.0, 2.0, 3.0])) + np.testing.assert_array_equal(model.resolution_model.Q, np.array([1.0, 2.0, 3.0])) + np.testing.assert_array_equal(model.Q, np.array([1.0, 2.0, 3.0])) + + def test_init_defaults(self): + # WHEN THEN + model = InstrumentModel() + + # EXPECT + assert model.display_name == 'MyInstrumentModel' + assert isinstance(model.background_model, BackgroundModel) + assert isinstance(model.resolution_model, ResolutionModel) + assert model.Q is None + + @pytest.mark.parametrize( + 'kwargs, expected_exception, expected_message', + [ + ( + {'resolution_model': 123}, + TypeError, + 'resolution_model must be a ResolutionModel', + ), + ( + {'background_model': 'not a model'}, + TypeError, + 'background_model must be a BackgroundModel', + ), + ( + {'energy_offset': 'abc'}, + TypeError, + 'energy_offset must be a number', + ), + ( + {'unit': 123}, + TypeError, + 'unit must be', + ), + ], + ids=[ + 'invalid resolution_model', + 'invalid background_model', + 'invalid energy_offset', + 'invalid unit', + ], + ) + def test_instrument_model_init_invalid_inputs( + self, kwargs, expected_exception, expected_message + ): + with pytest.raises(expected_exception, match=expected_message): + InstrumentModel(**kwargs) + + def test_resolution_model_setter_calls_update(self, instrument_model, resolution_model): + # WHEN + instrument_model._on_resolution_model_change = MagicMock() + + # THEN + instrument_model.resolution_model = resolution_model + + # EXPECT + assert instrument_model._resolution_model is resolution_model + instrument_model._on_resolution_model_change.assert_called_once() + + def test_resolution_model_setter_raises(self, instrument_model): + # WHEN / THEN / EXPECT + with pytest.raises( + TypeError, + match='resolution_model must be a ResolutionModel', + ): + instrument_model.resolution_model = 'invalid_model' + + def test_background_model_setter_calls_update(self, instrument_model, background_model): + # WHEN + instrument_model._on_background_model_change = MagicMock() + + # THEN + instrument_model.background_model = background_model + + # EXPECT + assert instrument_model._background_model is background_model + instrument_model._on_background_model_change.assert_called_once() + + def test_background_model_setter_raises(self, instrument_model): + # WHEN / THEN / EXPECT + with pytest.raises( + TypeError, + match='background_model must be a BackgroundModel', + ): + instrument_model.background_model = 123 + + def test_Q_setter(self, instrument_model): + "Test that Q setter calls the appropriate methods." + # WHEN + new_Q = np.array([4.0, 5.0, 6.0]) + + instrument_model._on_Q_change = MagicMock() + + # THEN EXPECT + with patch( + 'easydynamics.sample_model.instrument_model._validate_and_convert_Q', + return_value=new_Q, + ) as mock_validate: + instrument_model.Q = new_Q + + np.testing.assert_array_equal(instrument_model.Q, new_Q) + mock_validate.assert_called_once_with(new_Q) + instrument_model._on_Q_change.assert_called_once() + + def test_unit_setter_raises(self, instrument_model): + # WHEN / THEN / EXPECT + with pytest.raises( + AttributeError, + match='Unit is read-only. Use convert_unit to change the unit between allowed types ', + ): + instrument_model.unit = 'meV' + + def test_energy_offset_setter(self, instrument_model): + # WHEN + instrument_model._on_energy_offset_change = MagicMock() + + # THEN + instrument_model.energy_offset = 1.0 + + # EXPECT + assert instrument_model.energy_offset.value == 1.0 + instrument_model._on_energy_offset_change.assert_called_once() + + def test_energy_offset_setter_raises(self, instrument_model): + # WHEN / THEN / EXPECT + with pytest.raises( + TypeError, + match='energy_offset must be a number', + ): + instrument_model.energy_offset = 'invalid_offset' + + def test_convert_unit_calls_all_children(self, instrument_model): + # WHEN + new_unit = 'eV' + + # THEN + # Mock downstream convert_unit calls + instrument_model._background_model.convert_unit = MagicMock() + instrument_model._resolution_model.convert_unit = MagicMock() + instrument_model._energy_offset.convert_unit = MagicMock() + for offset in instrument_model._energy_offsets: + offset.convert_unit = MagicMock() + + with patch( + 'easydynamics.sample_model.instrument_model._validate_unit', + return_value=new_unit, + ) as mock_validate: + instrument_model.convert_unit(new_unit) + + # EXPECT + mock_validate.assert_called_once_with(new_unit) + + instrument_model._background_model.convert_unit.assert_called_once_with(new_unit) + instrument_model._resolution_model.convert_unit.assert_called_once_with(new_unit) + instrument_model._energy_offset.convert_unit.assert_called_once_with(new_unit) + + for offset in instrument_model._energy_offsets: + offset.convert_unit.assert_called_once_with(new_unit) + + # final state + assert instrument_model.unit == new_unit + + def test_convert_unit_None_raises(self, instrument_model): + # WHEN / THEN / EXPECT + with pytest.raises( + ValueError, + match=' must be a valid unit', + ): + instrument_model.convert_unit(None) + + def test_fix_resolution_parameters(self, instrument_model): + # WHEN + instrument_model.resolution_model.fix_all_parameters = MagicMock() + + # THEN + instrument_model.fix_resolution_parameters() + + # EXPECT + instrument_model.resolution_model.fix_all_parameters.assert_called_once() + + def test_free_all_resolution_parameters(self, instrument_model): + # WHEN + instrument_model.resolution_model.free_all_parameters = MagicMock() + + # THEN + instrument_model.free_resolution_parameters() + + # EXPECT + instrument_model.resolution_model.free_all_parameters.assert_called_once() + + def test_get_all_variables(self, instrument_model): + # WHEN + all_vars = instrument_model.get_all_variables() + + # THEN + expected_var_names = { + 'energy_offset', + 'Polynomial_c0', + 'Polynomial_c1', + 'Gaussian area', + 'Gaussian center', + 'Gaussian width', + } + + retrieved_var_names = {var.name for var in all_vars} + + assert expected_var_names == retrieved_var_names + assert len(all_vars) == 18 + + def test_get_all_variables_no_Q(self, instrument_model): + # WHEN + instrument_model.Q = None + + # THEN + all_vars = instrument_model.get_all_variables() + + # EXPECT + assert all_vars == [] + + def test_get_all_variables_with_Q_index(self, instrument_model): + # WHEN + all_vars = instrument_model.get_all_variables(Q_index=1) + + # THEN + expected_var_names = { + 'energy_offset', + 'Polynomial_c0', + 'Polynomial_c1', + 'Gaussian area', + 'Gaussian center', + 'Gaussian width', + } + + retrieved_var_names = {var.name for var in all_vars} + + assert expected_var_names == retrieved_var_names + assert len(all_vars) == 6 + + def test_get_all_variables_with_invalid_Q_index_raises(self, instrument_model): + # WHEN / THEN / EXPECT + with pytest.raises( + IndexError, + match='Q_index 5 is out of bounds', + ): + instrument_model.get_all_variables(Q_index=5) + + def test_get_all_variables_with_nonint_Q_index_raises(self, instrument_model): + # WHEN / THEN / EXPECT + with pytest.raises( + TypeError, + match='Q_index must be an int or None, got str', + ): + instrument_model.get_all_variables(Q_index='invalid_index') + + def test_generate_energy_offsets_Q_none(self, instrument_model): + # WHEN + instrument_model._Q = None + + # THEN + instrument_model._generate_energy_offsets() + + # EXPECT + assert instrument_model._energy_offsets == [] + + def test_generate_energy_offsets(self, instrument_model): + # WHEN + instrument_model._Q = np.array([1.0, 2.0, 3.0, 4.0]) + + # THEN + instrument_model._generate_energy_offsets() + + # EXPECT + assert len(instrument_model._energy_offsets) == 4 + for offset in instrument_model._energy_offsets: + assert offset.name == 'energy_offset' + assert offset.unit == instrument_model.unit + assert offset.value == instrument_model.energy_offset.value + + def test_on_Q_change(self, instrument_model): + # WHEN + instrument_model._generate_energy_offsets = MagicMock() + new_Q = np.array([1.0, 2.0, 3.0, 4.0]) + + # THEN + instrument_model._Q = new_Q + instrument_model._on_Q_change() + + # EXPECT + instrument_model._generate_energy_offsets.assert_called_once() + instrument_model._background_model.Q = new_Q + instrument_model._resolution_model.Q = new_Q + + def test_on_energy_offset_change(self, instrument_model): + # WHEN + new_offset = 2.0 + + # THEN + instrument_model._energy_offset.value = new_offset + instrument_model._on_energy_offset_change() + + # EXPECT + for offset in instrument_model._energy_offsets: + assert offset.value == new_offset + + def test_on_resolution_model_change(self, instrument_model, resolution_model): + # WHEN + new_resolution_model = resolution_model + + # THEN + instrument_model._resolution_model = new_resolution_model + instrument_model._on_resolution_model_change() + + # EXPECT + assert instrument_model._resolution_model is new_resolution_model + + def test_on_background_model_change(self, instrument_model, background_model): + # WHEN + new_background_model = background_model + + # THEN + instrument_model._background_model = new_background_model + instrument_model._on_background_model_change() + + # EXPECT + assert instrument_model._background_model is new_background_model + + def test_repr_contains_expected_fields(self, instrument_model): + # WHEN THEN + repr_str = repr(instrument_model) + + # EXPECT + assert repr_str.startswith('InstrumentModel(') + assert f'unique_name={instrument_model.unique_name!r}' in repr_str + assert f'unit={instrument_model.unit}' in repr_str + assert 'Q_len=3' in repr_str + assert f'resolution_model={instrument_model._resolution_model!r}' in repr_str + assert f'background_model={instrument_model._background_model!r}' in repr_str + assert repr_str.endswith(')') diff --git a/tests/unit/easydynamics/sample_model/test_model_base.py b/tests/unit/easydynamics/sample_model/test_model_base.py index 31feb66a..27e38a03 100644 --- a/tests/unit/easydynamics/sample_model/test_model_base.py +++ b/tests/unit/easydynamics/sample_model/test_model_base.py @@ -113,6 +113,21 @@ def test_generate_component_collections_without_Q_warns(self, model_base): with pytest.warns(UserWarning, match='Q is not set'): model_base._generate_component_collections() + 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() @@ -132,6 +147,41 @@ def test_get_all_variables(self, model_base): assert expected_var_display_names == retrieved_var_display_names assert len(all_vars) == 18 + def test_get_all_variables_with_Q_index(self, model_base): + # WHEN + all_vars = model_base.get_all_variables(Q_index=1) + + # THEN + expected_var_display_names = { + 'TestGaussian1 area', + 'TestGaussian1 center', + 'TestGaussian1 width', + 'TestLorentzian1 area', + 'TestLorentzian1 center', + 'TestLorentzian1 width', + } + + retrieved_var_display_names = {var.display_name for var in all_vars} + + assert expected_var_display_names == retrieved_var_display_names + assert len(all_vars) == 6 + + 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', + ): + model_base.get_all_variables(Q_index=5) + + 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', + ): + 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') From 88af3d9d7488da79428662511d0ce2575d60037f Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Thu, 5 Feb 2026 20:32:47 +0100 Subject: [PATCH 3/9] Jump diffusion model (#95) * jump diffusion model * fix notebook * fix weakref? * fix weakref? * fix weakref??? * ... * weakref... * claude fixed it? * respond to PR comments * Move scale test to base model and add some formatting * fix typo * small fix --- docs/docs/tutorials/diffusion_model.ipynb | 2 - docs/docs/tutorials/sample_model.ipynb | 2 - .../sample_model/diffusion_model/__init__.py | 4 +- .../brownian_translational_diffusion.py | 73 ++-- .../diffusion_model/diffusion_model_base.py | 37 +- .../jump_translational_diffusion.py | 340 ++++++++++++++++++ src/easydynamics/sample_model/sample_model.py | 2 +- tests/conftest.py | 76 +++- .../test_brownian_translational_diffusion.py | 50 --- .../diffusion_model/test_diffusion_model.py | 12 + .../test_jump_translational_diffusion.py | 254 +++++++++++++ .../sample_model/test_model_base.py | 2 +- .../sample_model/test_resolution_model.py | 6 +- .../sample_model/test_sample_model.py | 7 +- 14 files changed, 740 insertions(+), 127 deletions(-) create mode 100644 src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py create mode 100644 tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py diff --git a/docs/docs/tutorials/diffusion_model.ipynb b/docs/docs/tutorials/diffusion_model.ipynb index 9277486e..be9ec8e1 100644 --- a/docs/docs/tutorials/diffusion_model.ipynb +++ b/docs/docs/tutorials/diffusion_model.ipynb @@ -39,13 +39,11 @@ "energy = np.linspace(-2, 2, 501)\n", "scale = 1.0\n", "diffusion_coefficient = 2.4e-9 # m^2/s\n", - "diffusion_unit = 'm**2/s'\n", "\n", "diffusion_model = BrownianTranslationalDiffusion(\n", " display_name='DiffusionModel',\n", " scale=scale,\n", " diffusion_coefficient=diffusion_coefficient,\n", - " diffusion_unit=diffusion_unit,\n", ")\n", "\n", "component_collections = diffusion_model.create_component_collections(Q)\n", diff --git a/docs/docs/tutorials/sample_model.ipynb b/docs/docs/tutorials/sample_model.ipynb index 802aff0b..5371f7df 100644 --- a/docs/docs/tutorials/sample_model.ipynb +++ b/docs/docs/tutorials/sample_model.ipynb @@ -48,12 +48,10 @@ "\n", "scale = 1.0\n", "diffusion_coefficient = 2.4e-9 # m^2/s\n", - "diffusion_unit = 'm**2/s'\n", "diffusion_model = BrownianTranslationalDiffusion(\n", " display_name='DiffusionModel',\n", " scale=scale,\n", " diffusion_coefficient=diffusion_coefficient,\n", - " diffusion_unit=diffusion_unit,\n", ")\n", "\n", "\n", diff --git a/src/easydynamics/sample_model/diffusion_model/__init__.py b/src/easydynamics/sample_model/diffusion_model/__init__.py index 6fd920dc..dc0a469c 100644 --- a/src/easydynamics/sample_model/diffusion_model/__init__.py +++ b/src/easydynamics/sample_model/diffusion_model/__init__.py @@ -2,9 +2,9 @@ # SPDX-License-Identifier: BSD-3-Clause from .brownian_translational_diffusion import BrownianTranslationalDiffusion -from .diffusion_model_base import DiffusionModelBase +from .jump_translational_diffusion import JumpTranslationalDiffusion __all__ = [ - 'DiffusionModelBase', 'BrownianTranslationalDiffusion', + 'JumpTranslationalDiffusion', ] diff --git a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py index 749f8de4..d277d227 100644 --- a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py @@ -3,24 +3,20 @@ from typing import Dict from typing import List -from typing import Union import numpy as np import scipp as sc from easyscience.variable import DescriptorNumber from easyscience.variable import Parameter -from numpy.typing import ArrayLike from scipp.constants import hbar as scipp_hbar 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.utils.utils import Numeric +from easydynamics.utils.utils import Q_type from easydynamics.utils.utils import _validate_and_convert_Q -Numeric = Union[float, int] - -Q_type = np.ndarray | Numeric | list | ArrayLike - class BrownianTranslationalDiffusion(DiffusionModelBase): """Model of Brownian translational diffusion, consisting of a @@ -46,7 +42,6 @@ def __init__( unit: str | sc.Unit = 'meV', scale: Numeric = 1.0, diffusion_coefficient: Numeric = 1.0, - diffusion_unit: str = 'm**2/s', ): """Initialize a new BrownianTranslationalDiffusion model. @@ -62,65 +57,35 @@ def __init__( Defaults to "meV". scale : float or Parameter, optional Scale factor for the diffusion model. - diffusion_coefficient : float or Parameter, optional - Diffusion coefficient D. If a number is provided, - it is assumed to be in the unit given by diffusion_unit. + diffusion_coefficient : Number, optional + Diffusion coefficient D in m^2/s. Defaults to 1.0. - diffusion_unit : str, optional - Unit for the diffusion coefficient D. Default is m**2/s. - Options are 'meV*Å**2' or 'm**2/s' """ - if not isinstance(scale, (Parameter, Numeric)): + if not isinstance(scale, Numeric): raise TypeError('scale must be a number.') - if not isinstance(diffusion_coefficient, (Parameter, Numeric)): + if not isinstance(diffusion_coefficient, Numeric): raise TypeError('diffusion_coefficient must be a number.') - if not isinstance(diffusion_unit, str): - raise TypeError("diffusion_unit must be 'meV*Å**2' or 'm**2/s'.") - - if diffusion_unit == 'meV*Å**2' or diffusion_unit == 'meV*angstrom**2': - # In this case, hbar is absorbed in the unit of D - self._hbar = DescriptorNumber('hbar', 1.0) - elif diffusion_unit == 'm**2/s' or diffusion_unit == 'm^2/s': - self._hbar = DescriptorNumber.from_scipp('hbar', scipp_hbar) - else: - raise ValueError("diffusion_unit must be 'meV*Å**2' or 'm**2/s'.") - - scale = Parameter(name='scale', value=float(scale), fixed=False, min=0.0) - diffusion_coefficient = Parameter( name='diffusion_coefficient', value=float(diffusion_coefficient), fixed=False, - unit=diffusion_unit, + unit='m**2/s', ) super().__init__( display_name=display_name, unique_name=unique_name, unit=unit, + scale=scale, ) + self._hbar = DescriptorNumber.from_scipp('hbar', scipp_hbar) self._angstrom = DescriptorNumber('angstrom', 1e-10, unit='m') - self._scale = scale self._diffusion_coefficient = diffusion_coefficient - @property - def scale(self) -> Parameter: - """Get the scale parameter of the diffusion model. - - Returns - ------- - Parameter - Scale parameter. - """ - return self._scale - - @scale.setter - def scale(self, scale: Numeric) -> None: - """Set the scale parameter of the diffusion model.""" - if not isinstance(scale, (Numeric)): - raise TypeError('scale must be a number.') - self._scale.value = scale + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ @property def diffusion_coefficient(self) -> Parameter: @@ -136,10 +101,14 @@ def diffusion_coefficient(self) -> Parameter: @diffusion_coefficient.setter def diffusion_coefficient(self, diffusion_coefficient: Numeric) -> None: """Set the diffusion coefficient parameter D.""" - if not isinstance(diffusion_coefficient, (Numeric)): + if not isinstance(diffusion_coefficient, Numeric): raise TypeError('diffusion_coefficient must be a number.') self._diffusion_coefficient.value = diffusion_coefficient + # ------------------------------------------------------------------ + # Other methods + # ------------------------------------------------------------------ + def calculate_width(self, Q: Q_type) -> np.ndarray: """Calculate the half-width at half-maximum (HWHM) for the diffusion model. @@ -265,6 +234,10 @@ def create_component_collections( return component_collection_list + # ------------------------------------------------------------------ + # Private methods + # ------------------------------------------------------------------ + def _write_width_dependency_expression(self, Q: float) -> str: """Write the dependency expression for the width as a function of Q to make dependent Parameters. @@ -316,6 +289,10 @@ def _write_area_dependency_map_expression(self) -> Dict[str, DescriptorNumber]: 'scale': self.scale, } + # ------------------------------------------------------------------ + # dunder methods + # ------------------------------------------------------------------ + def __repr__(self): """String representation of the BrownianTranslationalDiffusion model. diff --git a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py index 18b5bce8..a6711334 100644 --- a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py +++ b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py @@ -1,17 +1,14 @@ # 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 from easyscience.variable import DescriptorNumber -from numpy.typing import ArrayLike +from easyscience.variable import Parameter from scipp import UnitError from easydynamics.utils.utils import Numeric -Q_type = np.ndarray | Numeric | list | ArrayLike - class DiffusionModelBase(ModelBase): """Base class for constructing diffusion models.""" @@ -20,6 +17,7 @@ def __init__( self, display_name='MyDiffusionModel', unique_name: str | None = None, + scale: Numeric = 1.0, unit: str | sc.Unit = 'meV', ): """Initialize a new DiffusionModel. @@ -31,6 +29,10 @@ def __init__( unit : str or sc.Unit, optional Unit of the diffusion model. Defaults to "meV". """ + if not isinstance(scale, Numeric): + raise TypeError('scale must be a number.') + + scale = Parameter(name='scale', value=float(scale), fixed=False, min=0.0) try: test = DescriptorNumber(name='test', value=1, unit=unit) @@ -42,6 +44,11 @@ def __init__( super().__init__(display_name=display_name, unique_name=unique_name) self._unit = unit + self._scale = scale + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ @property def unit(self) -> str: @@ -62,6 +69,28 @@ def unit(self, unit_str: str) -> None: ) ) # noqa: E501 + @property + def scale(self) -> Parameter: + """Get the scale parameter of the diffusion model. + + Returns + ------- + Parameter + Scale parameter. + """ + return self._scale + + @scale.setter + def scale(self, scale: Numeric) -> None: + """Set the scale parameter of the diffusion model.""" + if not isinstance(scale, Numeric): + raise TypeError('scale must be a number.') + self._scale.value = scale + + # ------------------------------------------------------------------ + # dunder methods + # ------------------------------------------------------------------ + def __repr__(self): """String representation of the Diffusion model.""" return f'{self.__class__.__name__}(display_name={self.display_name}, unit={self.unit})' diff --git a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py new file mode 100644 index 00000000..8bb65480 --- /dev/null +++ b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py @@ -0,0 +1,340 @@ +from typing import Dict +from typing import List + +import numpy as np +import scipp as sc +from easyscience.variable import DescriptorNumber +from easyscience.variable import Parameter +from scipp.constants import hbar as scipp_hbar + +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.utils.utils import Numeric +from easydynamics.utils.utils import Q_type +from easydynamics.utils.utils import _validate_and_convert_Q + + +class JumpTranslationalDiffusion(DiffusionModelBase): + """Model of Jump translational diffusion, consisting of a Lorentzian + function for each Q-value, where the width is given by :math:`D + Q^2/(1+D t Q^2)`. Q is assumed to have units of 1/angstrom. Creates + ComponentCollections with Lorentzian components for given Q-values. + + Example usage: Q=np.linspace(0.5,2,7) energy=np.linspace(-2, 2, 501) + scale=1.0 diffusion_coefficient = 2.4e-9 # m^2/s + diffusion_model=JumpTranslationalDiffusion(display_name="DiffusionModel", + scale=scale, diffusion_coefficient= diffusion_coefficient) + component_collections=diffusion_model.create_component_collections(Q) + See also the examples. + """ + + def __init__( + self, + display_name: str | None = 'JumpTranslationalDiffusion', + unique_name: str | None = None, + unit: str | sc.Unit = 'meV', + scale: Numeric = 1.0, + diffusion_coefficient: Numeric = 1.0, + relaxation_time: Numeric = 1.0, + ): + """Initialize a new JumpTranslationalDiffusion model. + + Parameters + ---------- + display_name : str + Display name of the diffusion model. + unique_name : str or None + Unique name of the diffusion model. If None, a unique name + is automatically generated. + unit : str or sc.Unit, optional + Energy unit for the underlying Lorentzian components. + Defaults to "meV". + scale : float , optional + Scale factor for the diffusion model. + diffusion_coefficient : float , optional + Diffusion coefficient D in m^2/s. Defaults to 1.0. + relaxation_time : float , optional + Relaxation time t in ps. Defaults to 1.0. + """ + super().__init__( + display_name=display_name, + unique_name=unique_name, + unit=unit, + scale=scale, + ) + + if not isinstance(diffusion_coefficient, Numeric): + raise TypeError('diffusion_coefficient must be a number.') + + if not isinstance(relaxation_time, Numeric): + raise TypeError('relaxation_time must be a number.') + + diffusion_coefficient = Parameter( + name='diffusion_coefficient', + value=float(diffusion_coefficient), + fixed=False, + unit='m**2/s', + ) + + relaxation_time = Parameter( + name='relaxation_time', + value=float(relaxation_time), + fixed=False, + unit='ps', + ) + + 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 + + ################################ + # Properties + ################################ + + @property + def diffusion_coefficient(self) -> Parameter: + """Get the diffusion coefficient parameter D. + + Returns + ------- + Parameter + Diffusion coefficient D. + """ + return self._diffusion_coefficient + + @diffusion_coefficient.setter + 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.') + self._diffusion_coefficient.value = diffusion_coefficient + + @property + def relaxation_time(self) -> Parameter: + """Get the relaxation time parameter t. + + Returns + ------- + Parameter + Relaxation time t. + """ + return self._relaxation_time + + @relaxation_time.setter + 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.') + self._relaxation_time.value = relaxation_time + + ################################ + # Other methods + ################################ + + def calculate_width(self, Q: Q_type) -> np.ndarray: + """Calculate the half-width at half-maximum (HWHM) for the + diffusion model. Equation: :math:`\\Gamma(Q) = \\hbar D Q^2/(1+D + t Q^2)` + + Parameters + ---------- + Q : np.ndarray | Numeric | list | ArrayLike + Scattering vector in 1/angstrom + + Returns + ------- + np.ndarray + HWHM values in the unit of the model (e.g., meV). + """ + + Q = _validate_and_convert_Q(Q) + + unit_conversion_factor_numerator = ( + self._hbar * self.diffusion_coefficient / (self._angstrom**2) + ) + unit_conversion_factor_numerator.convert_unit(self.unit) + + numerator = unit_conversion_factor_numerator.value * Q**2 + + unit_conversion_factor_denominator = ( + self.diffusion_coefficient / self._angstrom**2 * self.relaxation_time + ) + unit_conversion_factor_denominator.convert_unit('dimensionless') + + denominator = 1 + unit_conversion_factor_denominator.value * Q**2 + + width = numerator / denominator + return width + + def calculate_EISF(self, Q: Q_type) -> np.ndarray: + """Calculate the Elastic Incoherent Structure Factor (EISF). + + Parameters + ---------- + Q : np.ndarray | Numeric | list | ArrayLike + Scattering vector in 1/angstrom + + Returns + ------- + np.ndarray + EISF values (dimensionless). + """ + Q = _validate_and_convert_Q(Q) + EISF = np.zeros_like(Q) + return EISF + + def calculate_QISF(self, Q: Q_type) -> np.ndarray: + """Calculate the Quasi-Elastic Incoherent Structure Factor + (QISF). + + Parameters + ---------- + Q : np.ndarray | Numeric | list | ArrayLike + Scattering vector in 1/angstrom + + Returns + ------- + np.ndarray + QISF values (dimensionless). + """ + + Q = _validate_and_convert_Q(Q) + QISF = np.ones_like(Q) + return QISF + + def create_component_collections( + self, + Q: Q_type, + component_display_name: str = 'Jump translational diffusion', + ) -> List[ComponentCollection]: + """Create ComponentCollection components for the diffusion model + at given Q values. + + Args: + ---------- + Q : Number, list, or np.ndarray + Scattering vector values. + component_display_name : str + Name of the Jump Diffusion Lorentzian component. + Returns + ------- + List[ComponentCollection] + List of ComponentCollections with Jump Diffusion + Lorentzian components. + """ + Q = _validate_and_convert_Q(Q) + + if not isinstance(component_display_name, str): + 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 + # Lorentzians and the delta function. + QISF = self.calculate_QISF(Q) + + # Create a Lorentzian component for each Q-value, with width + # D*Q^2 and area equal to scale. No delta function, as the EISF + # 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 + ) + + lorentzian_component = Lorentzian( + display_name=component_display_name, + unit=self.unit, + ) + + # Make the width dependent on Q + dependency_expression = self._write_width_dependency_expression(Q[i]) + dependency_map = self._write_width_dependency_map_expression() + + lorentzian_component.width.make_dependent_on( + dependency_expression=dependency_expression, + dependency_map=dependency_map, + ) + + # Make the area dependent on Q + area_dependency_map = self._write_area_dependency_map_expression() + lorentzian_component.area.make_dependent_on( + dependency_expression=self._write_area_dependency_expression(QISF[i]), + dependency_map=area_dependency_map, + ) + + # Resolving the dependency can do weird things to the units, + # so we make sure it's correct. + lorentzian_component.width.convert_unit(self.unit) + component_collection_list[i].append_component(lorentzian_component) + + return component_collection_list + + ################################ + # Private methods + ################################ + + def _write_width_dependency_expression(self, Q: float) -> str: + """Write the dependency expression for the width as a function + of Q to make dependent Parameters. + + Parameters + ---------- + Q : float + Scattering vector in 1/angstrom + Returns + ------- + str + Dependency expression for the width. + """ + if not isinstance(Q, (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)))' + + 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, + } + + def _write_area_dependency_expression(self, QISF: float) -> str: + """Write the dependency expression for the area to make + dependent Parameters. + + Returns + ------- + str + Dependency expression for the area. + """ + if not isinstance(QISF, (float)): + raise TypeError('QISF must be a float.') + + 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, + } + + ################################ + # dunder methods + ################################ + + def __repr__(self): + """String representation of the JumpTranslationalDiffusion + model. + """ + return ( + f'JumpTranslationalDiffusion(display_name={self.display_name}, ' + f'diffusion_coefficient={self._diffusion_coefficient}, scale={self._scale})' + ) diff --git a/src/easydynamics/sample_model/sample_model.py b/src/easydynamics/sample_model/sample_model.py index cd1f2c2e..346bd7a4 100644 --- a/src/easydynamics/sample_model/sample_model.py +++ b/src/easydynamics/sample_model/sample_model.py @@ -5,7 +5,7 @@ import scipp as sc from easyscience.variable import Parameter -from easydynamics.sample_model.diffusion_model import DiffusionModelBase +from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase from easydynamics.sample_model.model_base import ModelBase from easydynamics.utils import _detailed_balance_factor from easydynamics.utils.utils import Numeric diff --git a/tests/conftest.py b/tests/conftest.py index aefc6c0b..d11735d3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,21 +5,73 @@ # TODO: remove once weakref bug is fixed -import easyscience.global_object +# import easyscience.global_object +# import pytest + + +# @pytest.fixture(autouse=True) +# def reset_global_object(): +# easyscience.global_object.map._clear() + +from unittest.mock import patch + import pytest -# from easyscience.global_object.map import Map +@pytest.fixture(autouse=True) +def patch_easyscience_map(): + """Patch the problematic Map methods.""" + from easyscience.global_object.map import Map -# @pytest.fixture(autouse=True) -# def reset_global_object(monkeypatch): -# # Before each test -# monkeypatch.setattr(easyscience.global_object, 'map', Map()) -# yield -# # After each test (cleanup) -# monkeypatch.setattr(easyscience.global_object, 'map', Map()) + # Store the original methods + original_add_vertex = Map.add_vertex + # original_vertices = Map.vertices + + def safe_add_vertex(self, obj: object, obj_type: str = None): + try: + return original_add_vertex(self, obj, obj_type) + except KeyError: + # Object was garbage collected during setup + name = obj.unique_name + # Clean up any partial state + if hasattr(self, '_Map__type_dict') and name in self._Map__type_dict: + del self._Map__type_dict[name] + if name in self._store: + del self._store[name] + + def safe_vertices(self): + """Safe version of vertices() that handles dictionary changes + during iteration.""" + max_retries = 3 + for attempt in range(max_retries): + try: + return list(self._store.keys()) + except RuntimeError as e: + if 'dictionary changed size during iteration' in str(e): + if attempt < max_retries - 1: + # Force cleanup and try again + import gc + gc.collect() + continue + else: + # Last attempt - return what we can get + try: + # Try to get keys in a different way + keys = [] + for k in list(self._store.data.keys()): + if k in self._store: + keys.append(k) + return keys + except: # noqa: E722 + return [] + else: + raise + return [] -@pytest.fixture(autouse=False) -def reset_global_object(): - easyscience.global_object.map._clear() + # Apply the patches + with ( + patch.object(Map, 'add_vertex', safe_add_vertex), + patch.object(Map, 'vertices', safe_vertices), + ): + yield diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py index 7476755b..0d0963c0 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py @@ -37,7 +37,6 @@ def test_init_default(self, brownian_diffusion_model): 'unit': 123, 'scale': 1.0, 'diffusion_coefficient': 1.0, - 'diffusion_unit': 'm**2/s', }, UnitError, 'Invalid unit', @@ -47,7 +46,6 @@ def test_init_default(self, brownian_diffusion_model): 'unit': 123, 'scale': 'invalid', 'diffusion_coefficient': 1.0, - 'diffusion_unit': 'm**2/s', }, TypeError, 'scale must be a number', @@ -57,50 +55,16 @@ def test_init_default(self, brownian_diffusion_model): 'unit': 123, 'scale': 1.0, 'diffusion_coefficient': 'invalid', - 'diffusion_unit': 'm**2/s', }, TypeError, 'diffusion_coefficient must be a number', ), - ( - { - 'unit': 123, - 'scale': 1.0, - 'diffusion_coefficient': 1.0, - 'diffusion_unit': 123, - }, - TypeError, - 'diffusion_unit must be ', - ), ], ) def test_input_type_validation_raises(self, kwargs, expected_exception, expected_message): with pytest.raises(expected_exception, match=expected_message): BrownianTranslationalDiffusion(display_name='BrownianTranslationalDiffusion', **kwargs) - def test_diffusion_unit_value_error(self): - # WHEN THEN EXPECT - with pytest.raises(ValueError, match='diffusion_unit must be .'): - BrownianTranslationalDiffusion( - display_name='BrownianTranslationalDiffusion', - unit='meV', - scale=1.0, - diffusion_coefficient=1.0, - diffusion_unit='invalid_unit', - ) - - def test_scale_setter(self, brownian_diffusion_model): - # WHEN - brownian_diffusion_model.scale = 2.0 - - # THEN EXPECT - assert brownian_diffusion_model.scale.value == 2.0 - - def test_scale_setter_raises(self, brownian_diffusion_model): - # WHEN THEN EXPECT - with pytest.raises(TypeError, match='scale must be a number.'): - brownian_diffusion_model.scale = 'invalid' # Invalid type - def test_diffusion_coefficient_setter(self, brownian_diffusion_model): # WHEN brownian_diffusion_model.diffusion_coefficient = 3.0 @@ -136,20 +100,6 @@ def test_calculate_width(self, brownian_diffusion_model): expected_widths = 1.0 * unit_conversion_factor.value * (Q_values**2) np.testing.assert_allclose(widths, expected_widths, rtol=1e-5) - def test_calculate_width_diffusion_unit_mev_angstrom2(self): - # WHEN - diffusion_model = BrownianTranslationalDiffusion( - diffusion_coefficient=2.0, diffusion_unit='meV*Å**2' - ) - Q_values = np.array([0.1, 0.2, 0.3]) # Example Q values in Å^-1 - - # WHEN - widths = diffusion_model.calculate_width(Q_values) - - # THEN EXPECT - expected_widths = 2.0 * (Q_values**2) - np.testing.assert_allclose(widths, expected_widths, rtol=1e-5) - def test_calculate_EISF(self, brownian_diffusion_model): # WHEN Q_values = np.array([0.1, 0.2, 0.3]) # Example Q values in Å^-1 diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py index e7bca65a..b8eb0956 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py @@ -23,3 +23,15 @@ def test_unit_setter_raises(self, diffusion_model): match='Unit is read-only. Use convert_unit to change the unit between allowed types', ): diffusion_model.unit = 'eV' + + def test_scale_setter(self, diffusion_model): + # WHEN + diffusion_model.scale = 2.0 + + # THEN EXPECT + assert diffusion_model.scale.value == 2.0 + + def test_scale_setter_raises(self, diffusion_model): + # WHEN THEN EXPECT + with pytest.raises(TypeError, match='scale must be a number.'): + diffusion_model.scale = 'invalid' # Invalid type diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py new file mode 100644 index 00000000..90a842d6 --- /dev/null +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py @@ -0,0 +1,254 @@ +import numpy as np +import pytest +import scipp as sc +from easyscience.variable import DescriptorNumber +from scipp import UnitError +from scipp.constants import hbar as scipp_hbar + +from easydynamics.sample_model.diffusion_model.jump_translational_diffusion import ( + JumpTranslationalDiffusion, +) + +hbar_1 = DescriptorNumber('hbar', 1.0) +hbar = DescriptorNumber.from_scipp('hbar', scipp_hbar) +angstrom = DescriptorNumber('angstrom', 1e-10, unit='m') + + +class TestJumpTranslationalDiffusion: + @pytest.fixture + def jump_diffusion_model(self): + return JumpTranslationalDiffusion() + + def test_init_default(self, jump_diffusion_model): + # WHEN THEN EXPECT + assert jump_diffusion_model.display_name == 'JumpTranslationalDiffusion' + assert jump_diffusion_model.unit == 'meV' + assert jump_diffusion_model.scale.value == 1.0 + assert jump_diffusion_model.diffusion_coefficient.value == 1.0 + assert jump_diffusion_model.relaxation_time.value == 1.0 + + @pytest.mark.parametrize( + 'kwargs,expected_exception, expected_message', + [ + ( + { + 'unit': 123, + 'scale': 1.0, + 'diffusion_coefficient': 1.0, + 'relaxation_time': 1.0, + }, + UnitError, + 'Invalid unit', + ), + ( + { + 'unit': 'meV', + 'scale': 'invalid', + 'diffusion_coefficient': 1.0, + 'relaxation_time': 1.0, + }, + TypeError, + 'scale must be a number', + ), + ( + { + 'unit': 'meV', + 'scale': 1.0, + 'diffusion_coefficient': 'invalid', + 'relaxation_time': 1.0, + }, + TypeError, + 'diffusion_coefficient must be a number', + ), + ( + { + 'unit': 'meV', + 'scale': 1.0, + 'diffusion_coefficient': 1.0, + 'relaxation_time': 'invalid', + }, + TypeError, + 'relaxation_time must be a number', + ), + ], + ) + def test_input_type_validation_raises(self, kwargs, expected_exception, expected_message): + with pytest.raises(expected_exception, match=expected_message): + JumpTranslationalDiffusion(display_name='JumpTranslationalDiffusion', **kwargs) + + def test_diffusion_coefficient_setter(self, jump_diffusion_model): + # WHEN + jump_diffusion_model.diffusion_coefficient = 3.0 + + # THEN EXPECT + assert jump_diffusion_model.diffusion_coefficient.value == 3.0 + + def test_diffusion_coefficient_setter_raises(self, jump_diffusion_model): + # WHEN THEN EXPECT + with pytest.raises(TypeError, match='diffusion_coefficient must be a number.'): + jump_diffusion_model.diffusion_coefficient = 'invalid' # Invalid type + + def test_relaxation_time_setter(self, jump_diffusion_model): + # WHEN + jump_diffusion_model.relaxation_time = 2.5 + + # THEN EXPECT + assert jump_diffusion_model.relaxation_time.value == 2.5 + + def test_relaxation_time_setter_raises(self, jump_diffusion_model): + # WHEN THEN EXPECT + with pytest.raises(TypeError, match='relaxation_time must be a number.'): + jump_diffusion_model.relaxation_time = 'invalid' # Invalid type + + def test_calculate_width_type_error(self, jump_diffusion_model): + # WHEN THEN EXPECT + with pytest.raises(TypeError, match='Q must be '): + jump_diffusion_model.calculate_width(Q='invalid') # Invalid type + + def test_calculate_width(self, jump_diffusion_model): + "Test the calculation relying solely on a scipp implementation" + 'instead of our Parameters' + # WHEN + Q_values = sc.linspace('Q', 0.5, 1.5, num=6, unit='1/angstrom') + relaxation_time_sc = jump_diffusion_model.relaxation_time.value * sc.Unit( + jump_diffusion_model.relaxation_time.unit + ) + diffusion_coefficient_sc = jump_diffusion_model.diffusion_coefficient.value * sc.Unit( + jump_diffusion_model.diffusion_coefficient.unit + ) + + # THEN + widths = jump_diffusion_model.calculate_width(Q_values) + + denominator = diffusion_coefficient_sc * relaxation_time_sc * Q_values**2 + denominator = denominator.to(unit='1') + + # EXPECT + expected_widths = scipp_hbar * diffusion_coefficient_sc * (Q_values**2) / (1 + denominator) + + expected_widths = expected_widths.to(unit=jump_diffusion_model.unit) + + np.testing.assert_allclose(widths, expected_widths.values, rtol=1e-5) + + def test_calculate_EISF(self, jump_diffusion_model): + # WHEN + Q_values = np.array([0.1, 0.2, 0.3]) # Example Q values in Å^-1 + + # THEN + EISF = jump_diffusion_model.calculate_EISF(Q_values) + + # EXPECT + expected_EISF = np.zeros_like(Q_values) + np.testing.assert_array_equal(EISF, expected_EISF) + + def test_calculate_EISF_type_error(self, jump_diffusion_model): + # WHEN THEN EXPECT + with pytest.raises(TypeError, match='Q must be '): + jump_diffusion_model.calculate_EISF(Q='invalid') # Invalid type + + def test_calculate_QISF(self, jump_diffusion_model): + # WHEN + Q_values = np.array([0.1, 0.2, 0.3]) # Example Q values in Å^-1 + + # THEN + QISF = jump_diffusion_model.calculate_QISF(Q_values) + + # EXPECT + expected_QISF = np.ones_like(Q_values) + np.testing.assert_array_equal(QISF, expected_QISF) + + def test_calculate_QISF_type_error(self, jump_diffusion_model): + # WHEN THEN EXPECT + with pytest.raises(TypeError, match='Q must be '): + jump_diffusion_model.calculate_QISF(Q='invalid') # Invalid type + + @pytest.mark.parametrize( + 'Q', + [ + (0.5), + ([1.0, 2.0, 3.0]), + (np.array([1.0, 2.0, 3.0])), + ], + ids=[ + 'python_scalar', + 'python_list', + 'numpy_array', + ], + ) + def test_create_component_collections(self, jump_diffusion_model, Q): + # WHEN + + # THEN + component_collections = jump_diffusion_model.create_component_collections(Q=Q) + + # EXPECT + expected_widths = jump_diffusion_model.calculate_width(Q) + for model_index in range(len(component_collections)): + model = component_collections[model_index] + assert len(model.components) == 1 + component = model.components[0] + assert component.width.unit == jump_diffusion_model.unit + assert np.isclose(component.width.value, expected_widths[model_index]) + assert component.width.independent is False + + def test_create_component_collections_component_name_must_be_string( + self, jump_diffusion_model + ): + # WHEN THEN EXPECT + with pytest.raises(TypeError, match='component_name must be a string.'): + jump_diffusion_model.create_component_collections( + Q=np.array([0.1, 0.2, 0.3]), component_display_name=123 + ) + + def test_create_component_collections_Q_type_error(self, jump_diffusion_model): + # WHEN THEN EXPECT + with pytest.raises(TypeError, match='Q must be a '): + jump_diffusion_model.create_component_collections(Q='invalid') # Invalid type + + def test_create_component_collections_Q_1dimensional_error(self, jump_diffusion_model): + # WHEN THEN EXPECT + with pytest.raises(ValueError, match='Q must be a 1-dimensional array.'): + jump_diffusion_model.create_component_collections( + Q=np.array([[0.1, 0.2], [0.3, 0.4]]) + ) # Invalid shape + + def test_write_width_dependency_expression(self, jump_diffusion_model): + # WHEN THEN + expression = jump_diffusion_model._write_width_dependency_expression(0.5) + + # EXPECT + expected_expression = ( + 'hbar * D* 0.5 **2/(angstrom**2)/(1 + (D * t* 0.5 **2/(angstrom**2)))' + ) + assert expression == expected_expression + + def test_write_width_dependency_map_expression(self, jump_diffusion_model): + # WHEN THEN + expression_map = jump_diffusion_model._write_width_dependency_map_expression() + + # EXPECT + expected_map = { + 'D': jump_diffusion_model.diffusion_coefficient, + 't': jump_diffusion_model.relaxation_time, + 'hbar': jump_diffusion_model._hbar, + 'angstrom': jump_diffusion_model._angstrom, + } + + assert expression_map == expected_map + + def test_write_width_dependency_expression_raises(self, jump_diffusion_model): + with pytest.raises(TypeError, match='Q must be a float'): + jump_diffusion_model._write_width_dependency_expression('invalid') + + def test_write_area_dependency_expression_raises(self, jump_diffusion_model): + with pytest.raises(TypeError, match='QISF must be a float'): + jump_diffusion_model._write_area_dependency_expression('invalid') + + def test_repr(self, jump_diffusion_model): + # WHEN THEN + repr_str = repr(jump_diffusion_model) + + # EXPECT + assert 'JumpTranslationalDiffusion' in repr_str + assert 'diffusion_coefficient' in repr_str + assert 'scale=' in repr_str diff --git a/tests/unit/easydynamics/sample_model/test_model_base.py b/tests/unit/easydynamics/sample_model/test_model_base.py index 27e38a03..05591735 100644 --- a/tests/unit/easydynamics/sample_model/test_model_base.py +++ b/tests/unit/easydynamics/sample_model/test_model_base.py @@ -14,7 +14,7 @@ class TestModelBase: @pytest.fixture - def model_base(self, reset_global_object): + def model_base(self): component1 = Gaussian( display_name='TestGaussian1', area=1.0, diff --git a/tests/unit/easydynamics/sample_model/test_resolution_model.py b/tests/unit/easydynamics/sample_model/test_resolution_model.py index 120cbf9b..d45eee19 100644 --- a/tests/unit/easydynamics/sample_model/test_resolution_model.py +++ b/tests/unit/easydynamics/sample_model/test_resolution_model.py @@ -89,7 +89,7 @@ def test_init_raises_with_invalid_components(self, invalid_component, expected_e collection.append_component(invalid_component) ResolutionModel(components=collection) - def test_append_and_remove_and_clear_component(self, resolution_model, reset_global_object): + def test_append_and_remove_and_clear_component(self, resolution_model): # WHEN new_component = Gaussian(unique_name='NewGaussian') @@ -136,9 +136,7 @@ def test_append_component_collection(self, resolution_model): ], ids=['DeltaFunction', 'Polynomial'], ) - def test_append_invalid_component_type_raises( - self, resolution_model, invalid_component, reset_global_object - ): + def test_append_invalid_component_type_raises(self, resolution_model, invalid_component): # WHEN / THEN / EXPECT # appending a single component with pytest.raises( diff --git a/tests/unit/easydynamics/sample_model/test_sample_model.py b/tests/unit/easydynamics/sample_model/test_sample_model.py index 8a383ee3..e5f7a9a7 100644 --- a/tests/unit/easydynamics/sample_model/test_sample_model.py +++ b/tests/unit/easydynamics/sample_model/test_sample_model.py @@ -22,6 +22,7 @@ class TestSampleModel: def sample_model(self): component1 = Gaussian( display_name='TestGaussian1', + unique_name='TestGaussian1', area=1.0, center=0.0, width=1.0, @@ -29,6 +30,7 @@ def sample_model(self): ) component2 = Lorentzian( display_name='TestLorentzian1', + unique_name='TestLorentzian1', area=2.0, center=1.0, width=0.5, @@ -38,7 +40,9 @@ def sample_model(self): component_collection.append_component(component1) component_collection.append_component(component2) - diffusion_model = BrownianTranslationalDiffusion(display_name='DiffusionModel') + diffusion_model = BrownianTranslationalDiffusion( + display_name='DiffusionModel', unique_name='DiffusionModel' + ) sample_model = SampleModel( display_name='InitModel', @@ -52,6 +56,7 @@ def sample_model(self): return sample_model def test_init(self, sample_model): + # WHEN THEN model = sample_model From 33b093ba2550949b087a2327807ef67aad9ac0a4 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Fri, 6 Feb 2026 09:49:32 +0100 Subject: [PATCH 4/9] Reapply updated Copier templates and streamline developer workflow (#97) * Update pixi.lock * Reapply templates from v0.4.0 * pixi run fix-all * Switch to manual pre-commit and simplify tasks * Enable PyPI Trusted Publishing via OIDC * Reorder imports and widget magic in notebooks * Normalize notebook cell IDs --- .copier-answers.yml | 2 +- .github/actions/publish-to-pypi/action.yml | 11 +- .github/workflows/docs.yml | 7 +- .github/workflows/pypi-publish.yml | 16 +- .github/workflows/quality.yml | 8 + .github/workflows/tutorial-tests.yml | 2 +- .gitignore | 5 + .pre-commit-config.yaml | 47 ++-- README.md | 12 +- docs/docs/assets/stylesheets/extra.css | 23 +- docs/docs/installation-and-setup/index.md | 34 +-- docs/docs/introduction/index.md | 4 +- docs/docs/tutorials/components.ipynb | 3 +- docs/docs/tutorials/detailed_balance.ipynb | 6 +- docs/docs/tutorials/diffusion_model.ipynb | 2 +- docs/docs/tutorials/experiment.ipynb | 10 +- pixi.lock | 35 ++- pixi.toml | 110 +++++---- tools/update_github_labels.py | 254 +++++++++++++++++++++ 19 files changed, 437 insertions(+), 154 deletions(-) create mode 100644 tools/update_github_labels.py diff --git a/.copier-answers.yml b/.copier-answers.yml index b2bdcfa2..eeff466a 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,6 +1,6 @@ # WARNING: Do not edit this file manually. # Any changes will be overwritten by Copier. -_commit: v0.0.5 +_commit: v0.4.2 _src_path: gh:easyscience/templates app_docs_url: https://easyscience.github.io/dynamics-app app_doi: 10.5281/zenodo.18163581 diff --git a/.github/actions/publish-to-pypi/action.yml b/.github/actions/publish-to-pypi/action.yml index 522e3a02..719928d9 100644 --- a/.github/actions/publish-to-pypi/action.yml +++ b/.github/actions/publish-to-pypi/action.yml @@ -1,13 +1,14 @@ name: 'Publish to PyPI' -description: 'Publish a built distribution to PyPI using pypa/gh-action-pypi-publish' +description: 'Publish dist/ to PyPI via Trusted Publishing (OIDC)' inputs: - password: - description: 'PyPI API token (or password) for authentication' - required: true + packages_dir: + description: 'Directory containing the built packages to upload' + required: false + default: 'dist' runs: using: 'composite' steps: - uses: pypa/gh-action-pypi-publish@release/v1 with: - password: ${{ inputs.password }} + packages-dir: ${{ inputs.packages_dir }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 05fef7db..4056c1c0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -107,11 +107,8 @@ jobs: - name: Pre-build site step run: pixi run python -c "import easydynamics" - # Convert Python scripts in the docs/docs/tutorials/ directory to Jupyter - # notebooks. - # This step also strips any existing output from the notebooks and - # prepares them for documentation. - - name: Convert tutorial scripts to notebooks + # Prepare the Jupyter notebooks for documentation (strip output, etc.). + - name: Prepare notebooks run: pixi run notebook-prepare # Execute all Jupyter notebooks to generate output cells (plots, tables, etc.). diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 15a2c6ed..6e48e610 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -14,6 +14,10 @@ jobs: pypi-publish: runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # IMPORTANT: this permission is mandatory for trusted publishing + steps: - name: Check-out repository uses: actions/checkout@v5 @@ -23,10 +27,18 @@ jobs: - name: Set up pixi uses: ./.github/actions/setup-pixi + # Build the Python package (to dist/ folder) - name: Create Python package run: pixi run default-build + # Publish the package to PyPI (from dist/ folder) + # Instead of publishing with personal access token, we use + # GitHub Actions OIDC to get a short-lived token from PyPI. + # New publisher must be previously configured in PyPI at + # https://pypi.org/manage/project/easydynamics/settings/publishing/ + # Use the following data: + # Owner: easyscience + # Repository name: dynamics-lib + # Workflow name: pypi-publish.yml - name: Publish to PyPI uses: ./.github/actions/publish-to-pypi - with: - password: ${{ secrets.PYPI_PASSWORD }} diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 1397f485..201dace4 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -79,6 +79,14 @@ jobs: continue-on-error: true shell: bash run: pixi run nonpy-format-check + # Check formatting of Jupyter Notebooks in the tutorials folder + - name: Prepare notebooks and check formatting + id: check_notebooks_formatting + continue-on-error: true + shell: bash + run: | + pixi run notebook-prepare + pixi run notebook-format-check # Add summary - name: Add quality checks summary diff --git a/.github/workflows/tutorial-tests.yml b/.github/workflows/tutorial-tests.yml index a3454fe7..55998847 100644 --- a/.github/workflows/tutorial-tests.yml +++ b/.github/workflows/tutorial-tests.yml @@ -46,7 +46,7 @@ jobs: shell: bash run: pixi run script-tests - - name: Convert tutorial scripts to notebooks + - name: Prepare notebooks shell: bash run: pixi run notebook-prepare diff --git a/.gitignore b/.gitignore index 7e0f2da3..f7ce4ac2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,11 @@ __pycache__/ .venv/ .coverage +# PyInstaller +dist/ +build/ +*.spec + # MkDocs docs/site/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c3d471cd..007d2389 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,57 +1,54 @@ repos: - repo: local hooks: - # ----------------- - # Pre-commit checks - # ----------------- + # ------------- + # Manual checks + # ------------- - id: pixi-pyproject-check name: pixi run pyproject-check entry: pixi run pyproject-check language: system pass_filenames: false - stages: [pre-commit] + stages: [manual] - - id: pixi-py-lint-check-staged - name: pixi run py-lint-check-staged - entry: pixi run py-lint-check-pre + - id: pixi-py-lint-check + name: pixi run py-lint-check + entry: pixi run py-lint-check language: system pass_filenames: false - stages: [pre-commit] + stages: [manual] - - id: pixi-py-format-check-staged - name: pixi run py-format-check-staged - entry: pixi run py-format-check-pre + - id: pixi-py-format-check + name: pixi run py-format-check + entry: pixi run py-format-check language: system pass_filenames: false - stages: [pre-commit] + stages: [manual] - - id: pixi-nonpy-format-check-modified - name: pixi run nonpy-format-check-modified - entry: pixi run nonpy-format-check-modified + - id: pixi-nonpy-format-check + name: pixi run nonpy-format-check + entry: pixi run nonpy-format-check language: system pass_filenames: false - stages: [pre-commit] + stages: [manual] - id: pixi-docs-format-check name: pixi run docs-format-check entry: pixi run docs-format-check language: system pass_filenames: false - stages: [pre-commit] + stages: [manual] - # ---------------- - # Pre-push checks - # ---------------- - - id: pixi-nonpy-format-check - name: pixi run nonpy-format-check - entry: pixi run nonpy-format-check + - id: pixi-notebook-format-check + name: pixi run notebook-format-check + entry: pixi run notebook-format-check language: system pass_filenames: false - stages: [pre-push] + stages: [manual] - id: pixi-unit-tests name: pixi run unit-tests entry: pixi run unit-tests language: system pass_filenames: false - stages: [pre-push] + stages: [manual] diff --git a/README.md b/README.md index 2f55c56f..373d3828 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,18 @@

- + - + - EasyDynamics + EasyDynamics

-**EasyDynamics** is a scientific software for plotting and fitting qens -and ins powder data. +**EasyDynamics** is a scientific software for plotting and fitting QENS +and INS powder data. + + **EasyDynamics** is available both as a Python library and as a cross-platform desktop application. diff --git a/docs/docs/assets/stylesheets/extra.css b/docs/docs/assets/stylesheets/extra.css index 1c199950..a625be80 100644 --- a/docs/docs/assets/stylesheets/extra.css +++ b/docs/docs/assets/stylesheets/extra.css @@ -222,9 +222,27 @@ Adjust the margins and paddings to fit the defaults in MkDocs Material and do no width: 100% !important; display: flex !important; } + .jp-Notebook { padding: 0 !important; margin-top: -3em !important; + + /* Ensure notebook content stretches across the page */ + width: 100% !important; + max-width: 100% !important; + + /* mkdocs-material + some notebook HTML end up as flex */ + align-items: stretch !important; +} + +.jp-Notebook .jp-Cell { + /* Key: flex children often need min-width: 0 to prevent weird shrink */ + width: 100% !important; + max-width: 100% !important; + min-width: 0 !important; + + /* Removes jupyter cell paddings */ + padding-left: 0 !important; } /* Removes jupyter cell prefixes, like In[123]: */ @@ -234,11 +252,6 @@ Adjust the margins and paddings to fit the defaults in MkDocs Material and do no display: none !important; } -/* Removes jupyter cell paddings */ -.jp-Cell { - padding-left: 0 !important; -} - /* Removes jupyter output cell padding to align with input cell text */ .jp-RenderedText { padding-left: 0.85em !important; diff --git a/docs/docs/installation-and-setup/index.md b/docs/docs/installation-and-setup/index.md index 3513f6e9..420ef07e 100644 --- a/docs/docs/installation-and-setup/index.md +++ b/docs/docs/installation-and-setup/index.md @@ -8,8 +8,8 @@ icon: material/cog-box **Python 3.11** through **3.12**. To install and set up EasyDynamics, we recommend using -[**Pixi**](https://prefix.dev), a modern package manager for Windows, -macOS, and Linux. +[**Pixi**](https://pixi.prefix.dev), a modern package manager for +Windows, macOS, and Linux. !!! note "Main benefits of using Pixi" @@ -46,16 +46,9 @@ This section describes the simplest way to set up EasyDynamics using ```txt pixi add python=3.12 ``` -- Add the GNU Scientific Library (GSL) dependency: +- Add EasyDynamics to the Pixi environment from PyPI: ```txt - pixi add gsl - ``` -- Add EasyDynamics with the `visualization` extras, which include - optional dependencies used for simplified visualization of charts and - tables. This can be especially useful for running the Jupyter Notebook - examples: - ```txt - pixi add --pypi "easydynamics[visualization]" + pixi add --pypi easydynamics ``` - Add a Pixi task to run EasyDynamics commands easily: ```txt @@ -160,20 +153,7 @@ simply delete and recreate the environment. ### Installing from PyPI { #from-pypi } EasyDynamics is available on **PyPI (Python Package Index)** and can be -installed using `pip`. - -We recommend installing the latest release of EasyDynamics with the -`visualization` extras, which include optional dependencies used for -simplified visualization of charts and tables. This can be especially -useful for running the Jupyter Notebook examples. To do so, use the -following command: - -```txt -pip install 'easydynamics[visualization]' -``` - -If only the core functionality is needed, the library can be installed -simply with: +installed using `pip`. To do so, use the following command: ```txt pip install easydynamics @@ -216,10 +196,10 @@ example: pip install git+https://github.com/easyscience/dynamics-lib@develop ``` -To include extra dependencies (e.g., visualization): +To include extra dependencies (e.g., dev): ```txt -pip install 'easydynamics[visualization] @ git+https://github.com/easyscience/dynamics-lib@develop' +pip install 'easydynamics[dev] @ git+https://github.com/easyscience/dynamics-lib@develop' ``` ## How to Run Tutorials diff --git a/docs/docs/introduction/index.md b/docs/docs/introduction/index.md index 58240514..740d4b0d 100644 --- a/docs/docs/introduction/index.md +++ b/docs/docs/introduction/index.md @@ -6,8 +6,8 @@ icon: material/information-slab-circle ## Description -**EasyDynamics** is a scientific software for plotting and fitting qens -and ins powder data. +**EasyDynamics** is a scientific software for plotting and fitting QENS +and INS powder data. **EasyDynamics** is available both as a Python library and as a cross-platform desktop application. diff --git a/docs/docs/tutorials/components.ipynb b/docs/docs/tutorials/components.ipynb index 7815bcd4..83278fc4 100644 --- a/docs/docs/tutorials/components.ipynb +++ b/docs/docs/tutorials/components.ipynb @@ -21,6 +21,7 @@ "source": [ "import matplotlib.pyplot as plt\n", "import numpy as np\n", + "import scipp as sc\n", "\n", "from easydynamics.sample_model import DampedHarmonicOscillator\n", "from easydynamics.sample_model import DeltaFunction\n", @@ -105,8 +106,6 @@ "metadata": {}, "outputs": [], "source": [ - "import scipp as sc\n", - "\n", "x1 = sc.linspace(dim='x', start=-2.0, stop=2.0, num=100, unit='meV')\n", "x2 = sc.linspace(dim='x', start=-2.0 * 1e3, stop=2.0 * 1e3, num=101, unit='microeV')\n", "\n", diff --git a/docs/docs/tutorials/detailed_balance.ipynb b/docs/docs/tutorials/detailed_balance.ipynb index d09a2546..135894d3 100644 --- a/docs/docs/tutorials/detailed_balance.ipynb +++ b/docs/docs/tutorials/detailed_balance.ipynb @@ -23,11 +23,11 @@ "outputs": [], "source": [ "import matplotlib.pyplot as plt\n", - "\n", - "%matplotlib widget\n", "import numpy as np\n", "\n", - "from easydynamics.utils import _detailed_balance_factor as detailed_balance_factor" + "from easydynamics.utils import _detailed_balance_factor as detailed_balance_factor\n", + "\n", + "%matplotlib widget" ] }, { diff --git a/docs/docs/tutorials/diffusion_model.ipynb b/docs/docs/tutorials/diffusion_model.ipynb index be9ec8e1..f3d1571b 100644 --- a/docs/docs/tutorials/diffusion_model.ipynb +++ b/docs/docs/tutorials/diffusion_model.ipynb @@ -67,7 +67,7 @@ { "cell_type": "code", "execution_count": null, - "id": "a50c67ec", + "id": "3", "metadata": {}, "outputs": [], "source": [ diff --git a/docs/docs/tutorials/experiment.ipynb b/docs/docs/tutorials/experiment.ipynb index 6319c61f..f6e185df 100644 --- a/docs/docs/tutorials/experiment.ipynb +++ b/docs/docs/tutorials/experiment.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "906b959a", + "id": "0", "metadata": {}, "source": [ "# Experiment\n", @@ -12,7 +12,7 @@ { "cell_type": "code", "execution_count": null, - "id": "c7d23add", + "id": "1", "metadata": {}, "outputs": [], "source": [ @@ -24,7 +24,7 @@ { "cell_type": "code", "execution_count": null, - "id": "2b7c5ca8", + "id": "2", "metadata": {}, "outputs": [], "source": [ @@ -38,7 +38,7 @@ { "cell_type": "code", "execution_count": null, - "id": "238ba6ee", + "id": "3", "metadata": {}, "outputs": [], "source": [ @@ -50,7 +50,7 @@ { "cell_type": "code", "execution_count": null, - "id": "bc32ab1f", + "id": "4", "metadata": {}, "outputs": [], "source": [ diff --git a/pixi.lock b/pixi.lock index da8aee45..f51bc65b 100644 --- a/pixi.lock +++ b/pixi.lock @@ -5,8 +5,6 @@ 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 @@ -80,7 +78,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?rev=develop#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 @@ -335,7 +333,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?rev=develop#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 @@ -590,7 +588,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?rev=develop#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 @@ -838,7 +836,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?rev=develop#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 @@ -1031,8 +1029,6 @@ 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 @@ -1106,7 +1102,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?rev=develop#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 @@ -1362,7 +1358,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?rev=develop#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 @@ -1618,7 +1614,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?rev=develop#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 @@ -1867,7 +1863,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?rev=develop#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 @@ -2061,8 +2057,6 @@ 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 @@ -2136,7 +2130,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?rev=develop#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 @@ -2391,7 +2385,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?rev=develop#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 @@ -2646,7 +2640,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?rev=develop#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 @@ -2894,7 +2888,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?rev=develop#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 @@ -4091,7 +4085,7 @@ packages: requires_python: '>=3.5' - pypi: ./ name: easydynamics - version: 0.1.1+devdirty2 + version: 0.1.0+devdirty6 sha256: de299c914d4a865b9e2fdefa5e3947f37b1f26f73ff9087f7918ee417f3dd288 requires_dist: - darkdetect @@ -4134,7 +4128,8 @@ packages: - validate-pyproject[all] ; extra == 'dev' - versioningit ; extra == 'dev' requires_python: '>=3.11' -- pypi: git+https://github.com/easyscience/corelib.git#bd106537fcf522336aa0176aa6ccf215be8a5b86 + editable: true +- pypi: git+https://github.com/easyscience/corelib.git?rev=develop#bd106537fcf522336aa0176aa6ccf215be8a5b86 name: easyscience version: 2.1.0 requires_dist: diff --git a/pixi.toml b/pixi.toml index baa0ea35..d280c259 100644 --- a/pixi.toml +++ b/pixi.toml @@ -76,41 +76,41 @@ default = { features = ['default', 'py-max'] } [tasks] +################## # 🧪 Testing Tasks -unit-tests = 'python -m pytest tests/unit/ --color=yes --cov= --cov-report=' +################## + +unit-tests = 'python -m pytest tests/unit/ --color=yes -v' integration-tests = 'python -m pytest tests/integration/ --color=yes -n auto -v' notebook-tests = 'python -m pytest --nbmake docs/docs/tutorials/ --nbmake-timeout=600 --color=yes -n auto -v' script-tests = 'python -m pytest tools/test_scripts.py --color=yes -n auto -v' test = { depends-on = ['unit-tests'] } +########### # ✔️ Checks +########### + pyproject-check = 'python -m validate_pyproject pyproject.toml' -py-lint-check-pre = "python -m ruff check" -py-lint-check = 'pixi run py-lint-check-pre .' -py-format-check-pre = "python -m ruff format --check" -py-format-check = "pixi run py-format-check-pre ." -nonpy-format-check-pre = "npx prettier --list-different --config=prettierrc.toml" -nonpy-format-check-modified = "pixi run nonpy-format-check-pre $(git diff --diff-filter=d --name-only HEAD | grep -E '\\.(json|ya?ml|toml|md|css|html)$' || echo .)" -nonpy-format-check = "pixi run nonpy-format-check-pre ." +docs-format-check = 'docformatter --check src/ docs/docs/tutorials/' notebook-format-check = 'nbqa ruff docs/docs/tutorials/' -docs-format-check = 'docformatter src/ docs/docs/tutorials/ --check' +py-lint-check = 'ruff check src/ tests/ docs/docs/tutorials/' +py-format-check = "ruff format --check src/ tests/ docs/docs/tutorials/" +nonpy-format-check = "npx prettier --list-different --config=prettierrc.toml --ignore-unknown ." +nonpy-format-check-modified = "python tools/nonpy_prettier_modified.py" -check = { depends-on = [ - 'docs-format-check', - 'py-format-check', - 'py-lint-check', - 'nonpy-format-check-modified', -] } +check = 'pre-commit run --hook-stage manual --all-files' +########## # 🛠️ Fixes -py-lint-fix = 'pixi run py-lint-check --fix' -#py-format-fix = "python -m ruff format $(git diff --cached --name-only -- '*.py')" -py-format-fix = "python -m ruff format" -nonpy-format-fix = 'pixi run nonpy-format-check --write' -nonpy-format-fix-modified = "pixi run nonpy-format-check-modified --write" -notebook-format-fix = 'pixi run notebook-format-check --fix' -docs-format-fix = 'docformatter src/ docs/docs/tutorials/ --in-place' +########## + +docs-format-fix = 'docformatter --in-place src/ docs/docs/tutorials/' +notebook-format-fix = 'nbqa ruff --fix docs/docs/tutorials/' +py-lint-fix = 'ruff check --fix src/ tests/ docs/docs/tutorials/' +py-format-fix = "ruff format src/ tests/ docs/docs/tutorials/" +nonpy-format-fix = 'npx prettier --write --list-different --config=prettierrc.toml --ignore-unknown .' +nonpy-format-fix-modified = "python tools/nonpy_prettier_modified.py --write" success-message-fix = 'echo "✅ All code auto-formatting steps have been applied."' fix = { depends-on = [ @@ -118,10 +118,14 @@ fix = { depends-on = [ 'docs-format-fix', 'py-lint-fix', 'nonpy-format-fix', + 'notebook-format-fix', 'success-message-fix', ] } +#################### # 🧮 Code Complexity +#################### + complexity-check = 'radon cc -s src/' complexity-check-json = 'radon cc -s -j src/' maintainability-check = 'radon mi src/' @@ -129,8 +133,11 @@ maintainability-check-json = 'radon mi -j src/' raw-metrics = 'radon raw -s src/' raw-metrics-json = 'radon raw -s -j src/' +############# # 📊 Coverage -unit-tests-coverage = 'python -m pytest tests/unit/ --color=yes --cov=src/easydynamics --cov-report=term-missing' +############# + +unit-tests-coverage = 'pixi run unit-tests --cov=src/easydynamics --cov-report=term-missing' integration-tests-coverage = 'pixi run integration-tests --cov=src/easydynamics --cov-report=term-missing' docstring-coverage = 'interrogate -c pyproject.toml src/easydynamics' @@ -140,19 +147,25 @@ cov = { depends-on = [ 'integration-tests-coverage', ] } +######################## # 📓 Notebook Management +######################## + notebook-convert = 'jupytext docs/docs/tutorials/*.py --from py:percent --to ipynb' notebook-strip = 'nbstripout docs/docs/tutorials/*.ipynb' notebook-tweak = 'python tools/tweak_notebooks.py tutorials/' notebook-exec = 'python -m pytest --nbmake docs/docs/tutorials/ --nbmake-timeout=600 --overwrite --color=yes -n auto -v' notebook-prepare = { depends-on = [ - ###'notebook-convert', + #'notebook-convert', 'notebook-strip', - ###'notebook-tweak', + #'notebook-tweak', ] } +######################## # 📚 Documentation Tasks +######################## + docs-vars = "JUPYTER_PLATFORM_DIRS=1 PYTHONWARNINGS='ignore::RuntimeWarning'" docs-pre = "pixi run docs-vars python -m mkdocs" docs-serve = "pixi run docs-pre serve -f docs/mkdocs.yml" @@ -163,29 +176,19 @@ docs-build-local = "pixi run docs-build --no-directory-urls" docs-deploy-pre = 'mike deploy -F docs/mkdocs.yml --push --branch gh-pages --update-aliases --alias-type redirect' docs-set-default-pre = 'mike set-default -F docs/mkdocs.yml --push --branch gh-pages' -docs-update-assets = 'pixi run python tools/update_docs_assets.py' +docs-update-assets = 'python tools/update_docs_assets.py' +############################## # 📦 Template Management Tasks -copier-copy = "pixi run copier copy gh:easyscience/templates . --data-file ../dynamics/.copier-answers.yml --data template_type=lib" -copier-recopy = "pixi run copier recopy --data-file ../dynamics/.copier-answers.yml --data template_type=lib" -copier-update = "pixi run copier update --data-file ../dynamics/.copier-answers.yml --data template_type=lib" +############################## -# 🚀 Development & Build Tasks -default-build = 'python -m build' -dist-build = 'python -m build --wheel --outdir dist' +copier-copy = "copier copy gh:easyscience/templates . --data-file ../dynamics/.copier-answers.yml --data template_type=lib" +copier-recopy = "copier recopy --data-file ../dynamics/.copier-answers.yml --data template_type=lib" +copier-update = "copier update --data-file ../dynamics/.copier-answers.yml --data template_type=lib" -npm-config = 'npm config set registry https://registry.npmjs.org/' -prettier-install = 'npm install --no-save --no-audit --no-fund prettier prettier-plugin-toml' - -clean-pycache = "find . -type d -name '__pycache__' -prune -exec rm -rf '{}' +" -spdx-update = 'python tools/update_spdx.py' - -# Run like a real commit: staged files only (almost) -pre-commit-check = 'pre-commit run --hook-stage pre-commit' -# CI check: lint/format everything -pre-commit-check-all = 'pre-commit run --all-files --hook-stage pre-commit' -# Pre-push check: lint/format everything -pre-push-check = 'pre-commit run --all-files --hook-stage pre-push' +##################### +# 🪝 Pre-commit Hooks +##################### pre-commit-clean = 'pre-commit clean' pre-commit-install = 'pre-commit install --hook-type pre-commit --hook-type pre-push --overwrite' @@ -196,11 +199,28 @@ pre-commit-setup = { depends-on = [ 'pre-commit-install', ] } +#################################### +# 🚀 Other Development & Build Tasks +#################################### + +github-labels = 'python tools/update_github_labels.py' + +default-build = 'python -m build' +dist-build = 'python -m build --wheel --outdir dist' + +npm-config = 'npm config set registry https://registry.npmjs.org/' +prettier-install = 'npm install --no-save --no-audit --no-fund prettier prettier-plugin-toml' + +clean-pycache = "find . -type d -name '__pycache__' -prune -exec rm -rf '{}' +" +spdx-update = 'python tools/update_spdx.py' + post-install = { depends-on = [ 'npm-config', 'prettier-install', - 'pre-commit-setup', + #'pre-commit-setup', ] } +########################## # 🔗 Main Package Shortcut +########################## easydynamics = 'python -m easydynamics' diff --git a/tools/update_github_labels.py b/tools/update_github_labels.py new file mode 100644 index 00000000..a18043d0 --- /dev/null +++ b/tools/update_github_labels.py @@ -0,0 +1,254 @@ +""" +Set/update GitHub labels for current or specified easyscience +repository. + +Requires: + - gh CLI installed + - gh auth login completed + +Usage: + python update_github_labels.py + python update_github_labels.py --dry-run + python update_github_labels.py --repo easyscience/my-repo + python update_github_labels.py --repo easyscience/my-repo --dry-run +""" + +from __future__ import annotations + +import argparse +import json +import shlex +import subprocess +import sys +from dataclasses import dataclass +from typing import Iterable + + +EASYSCIENCE_ORG = 'easyscience' + + +# --- Label definitions ----------------------------------------------------------- + +BASIC_GITHUB_LABELS = [ + 'bug', + 'documentation', + 'duplicate', + 'enhancement', + 'good first issue', + 'help wanted', + 'invalid', + 'question', + 'wontfix', +] + +NEW_BASIC_LABEL_NAMES = [ + '[scope] bug', + '[scope] documentation', + '[maintainer] duplicate', + '[scope] enhancement', + '[maintainer] good first issue', + '[maintainer] help wanted', + '[maintainer] invalid', + '[maintainer] question', + '[maintainer] wontfix', +] + +SCOPE_LABELS = [ + ('bug', 'Bug report or fix (major.minor.PATCH)'), + ('documentation', 'Documentation only changes (major.minor.patch.POST)'), + ('enhancement', 'Adds/improves features (major.MINOR.patch)'), + ('maintenance', 'Code/tooling cleanup, no feature or bugfix (major.minor.PATCH)'), + ('significant', 'Breaking or major changes (MAJOR.minor.patch)'), + ('⚠️ label needed', 'Automatically added to issues and PRs without a [scope] label'), +] + +MAINTAINER_LABELS = [ + ('duplicate', 'Already reported or submitted'), + ('good first issue', 'Good entry-level issue for newcomers'), + ('help wanted', 'Needs additional help to resolve or implement'), + ('invalid', 'Invalid, incorrect or outdated'), + ('question', 'Needs clarification, discussion, or more information'), + ('wontfix', 'Will not be fixed or continued'), +] + +PRIORITY_LABELS = [ + ('lowest', 'Very low urgency'), + ('low', 'Low importance'), + ('medium', 'Normal/default priority'), + ('high', 'Should be prioritized soon'), + ('highest', 'Urgent. Needs attention ASAP'), + ('⚠️ label needed', 'Automatically added to issues without a [priority] label'), +] + +BOT_LABEL = ( + '[bot] pull request', + 'Automated release PR. Excluded from changelog/versioning', +) + +COLORS = { + 'scope': 'd73a4a', + 'maintainer': '0e8a16', + 'priority': 'fbca04', + 'bot': '5319e7', +} + + +# --- Helpers -------------------------------------------------------------------- + + +@dataclass(frozen=True) +class CmdResult: + returncode: int + stdout: str + stderr: str + + +def run_cmd(args: list[str], *, dry_run: bool, check: bool = True) -> CmdResult: + """Run a command (or print it in dry-run mode).""" + cmd_str = ' '.join(shlex.quote(a) for a in args) + + if dry_run: + print(f'{cmd_str}') + return CmdResult(0, '', '') + + proc = subprocess.run( + args, + text=True, + capture_output=True, + ) + res = CmdResult(proc.returncode, proc.stdout.strip(), proc.stderr.strip()) + + if check and proc.returncode != 0: + raise RuntimeError(f'Command failed ({proc.returncode}): {cmd_str}\n{res.stderr}') + + return res + + +def get_current_repo_name_with_owner() -> str: + res = subprocess.run( + ['gh', 'repo', 'view', '--json', 'nameWithOwner'], + text=True, + capture_output=True, + check=True, + ) + data = json.loads(res.stdout) + nwo = data.get('nameWithOwner') + if not nwo or '/' not in nwo: + raise RuntimeError('Could not determine current repository name') + return nwo + + +def try_rename_label(repo: str, old: str, new: str, *, dry_run: bool) -> None: + try: + run_cmd( + ['gh', 'label', 'edit', old, '--name', new, '--repo', repo], + dry_run=dry_run, + ) + print(f'Rename: {old!r} → {new!r}') + except Exception: + print(f'Skip rename (label not found): {old!r}') + + +def upsert_label( + repo: str, + name: str, + color: str, + description: str, + *, + dry_run: bool, +) -> None: + run_cmd( + [ + 'gh', + 'label', + 'create', + name, + '--color', + color, + '--description', + description, + '--force', + '--repo', + repo, + ], + dry_run=dry_run, + ) + print(f'Upsert label: {name!r}') + + +def upsert_group( + repo: str, + prefix: str, + color: str, + items: Iterable[tuple[str, str]], + *, + dry_run: bool, +) -> None: + for short, desc in items: + upsert_label( + repo, + f'[{prefix}] {short}', + color, + desc, + dry_run=dry_run, + ) + + +# --- Main ----------------------------------------------------------------------- + + +def main() -> int: + parser = argparse.ArgumentParser(description='Sync GitHub labels for easyscience repos') + parser.add_argument( + '--repo', + help='Target repository in the form easyscience/', + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Print actions without applying changes', + ) + args = parser.parse_args() + + if args.repo: + repo = args.repo + else: + repo = get_current_repo_name_with_owner() + + org, _ = repo.split('/', 1) + + if org.lower() != EASYSCIENCE_ORG: + print( + f"Refusing to run: repository {repo!r} is not under '{EASYSCIENCE_ORG}'.", + file=sys.stderr, + ) + return 2 + + print(f'Target repository: {repo}') + if args.dry_run: + print('Running in DRY-RUN mode (no changes will be made)\n') + + # 1) Rename basic labels + for old, new in zip(BASIC_GITHUB_LABELS, NEW_BASIC_LABEL_NAMES, strict=True): + try_rename_label(repo, old, new, dry_run=args.dry_run) + + # 2) Scope / Maintainer / Priority groups + upsert_group(repo, 'scope', COLORS['scope'], SCOPE_LABELS, dry_run=args.dry_run) + upsert_group(repo, 'maintainer', COLORS['maintainer'], MAINTAINER_LABELS, dry_run=args.dry_run) + upsert_group(repo, 'priority', COLORS['priority'], PRIORITY_LABELS, dry_run=args.dry_run) + + # 3) Bot label + upsert_label( + repo, + BOT_LABEL[0], + COLORS['bot'], + BOT_LABEL[1], + dry_run=args.dry_run, + ) + + print('\nDone.') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) From 9eeee30f8503fe9b9bdee37bf763434f4390b038 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Sat, 28 Feb 2026 19:44:29 +0100 Subject: [PATCH 5/9] Analysis (#100) * initial analysis class * make analysis_base * test things in notebook * reintroduce energy_offset in convolution. It's needed. * Progress on Analysis * multiple parameters with same unique_name????? * fitting and plotting for multiple Q * analysis MWP * Add plotting of parameters and examples * Update failing tests * 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 * initial analysis class * fix merge conflict * Remove notebook * Update notebook, remove unused file * pixi run fix * add missing tests * More missing tests * test analysis_base * 100% coverage of base * Test analysis1d * Another test * More analysis1d tests * linting * update component_collection among other things * Add a few more tests * fix failing test * Update analyis example * analysis tests * Minor fixes and tests * one more test * more tests * Update docstrings for AnalysisBase * pixi run fix * pixi run fix * more docstring for analysis1d * finish analysis1d docstring * react to PR comments * Update analysis.py docstrings * Handle changes to experiment etc in analysis * fix updaters * More tests and response to PR comments * minor PR comments * fix test --- docs/docs/tutorials/analysis.ipynb | 352 ++++++++ docs/docs/tutorials/analysis1d.ipynb | 101 +++ docs/docs/tutorials/convolution.ipynb | 72 +- pixi.lock | 35 +- src/easydynamics/analysis/__init__.py | 8 + src/easydynamics/analysis/analysis.py | 564 +++++++++++++ src/easydynamics/analysis/analysis1d.py | 583 +++++++++++++ src/easydynamics/analysis/analysis_base.py | 355 ++++++++ .../convolution/analytical_convolution.py | 28 +- src/easydynamics/convolution/convolution.py | 17 +- .../convolution/convolution_base.py | 88 +- .../convolution/numerical_convolution.py | 17 +- .../convolution/numerical_convolution_base.py | 6 +- src/easydynamics/experiment/experiment.py | 40 +- src/easydynamics/sample_model/__init__.py | 8 + .../sample_model/component_collection.py | 40 +- .../brownian_translational_diffusion.py | 4 +- .../jump_translational_diffusion.py | 10 +- .../sample_model/instrument_model.py | 26 + src/easydynamics/sample_model/model_base.py | 47 +- src/easydynamics/utils/utils.py | 20 + .../easydynamics/analysis/test_analysis.py | 630 ++++++++++++++ .../easydynamics/analysis/test_analysis1d.py | 780 ++++++++++++++++++ .../analysis/test_analysis_base.py | 368 +++++++++ .../convolution/test_convolution.py | 52 ++ .../convolution/test_convolution_base.py | 94 +++ .../convolution/test_numerical_convolution.py | 3 +- .../experiment/test_experiment.py | 80 +- .../components/test_polynomial.py | 5 + .../diffusion_model/test_diffusion_model.py | 9 + .../sample_model/test_component_collection.py | 24 + .../sample_model/test_instrument_model.py | 28 + .../sample_model/test_model_base.py | 32 +- .../sample_model/test_sample_model.py | 8 + tests/unit/easydynamics/utils/test_utils.py | 62 ++ 35 files changed, 4396 insertions(+), 200 deletions(-) create mode 100644 docs/docs/tutorials/analysis.ipynb create mode 100644 docs/docs/tutorials/analysis1d.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 create mode 100644 src/easydynamics/analysis/analysis_base.py create mode 100644 tests/unit/easydynamics/analysis/test_analysis.py create mode 100644 tests/unit/easydynamics/analysis/test_analysis1d.py create mode 100644 tests/unit/easydynamics/analysis/test_analysis_base.py diff --git a/docs/docs/tutorials/analysis.ipynb b/docs/docs/tutorials/analysis.ipynb new file mode 100644 index 00000000..8403f24a --- /dev/null +++ b/docs/docs/tutorials/analysis.ipynb @@ -0,0 +1,352 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "8643b10c", + "metadata": {}, + "source": [ + "# 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 measurement. Next, we use the fitted resolution to fit an artificial measurement of a model with diffusion and some elastic scattering. \n", + "\n", + "We extract and plot the relevant parameters. Finally, we show how to fit directly to the diffusion model.\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." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bca91d3c", + "metadata": {}, + "outputs": [], + "source": [ + "# Imports\n", + "from easydynamics.analysis.analysis import Analysis\n", + "from easydynamics.experiment import Experiment\n", + "from easydynamics.sample_model import BrownianTranslationalDiffusion\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 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", + "\n", + "%matplotlib widget" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8deca9b6", + "metadata": {}, + "outputs": [], + "source": [ + "# Load the vanadium data\n", + "vanadium_experiment = Experiment('Vanadium')\n", + "vanadium_experiment.load_hdf5(filename='vanadium_data_example.h5')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6762faba", + "metadata": {}, + "outputs": [], + "source": [ + "# Example of Analysis with a simple sample model and instrument model\n", + "# The scattering from vanadium is purely elastic, so we model it with a\n", + "# delta function\n", + "delta_function = DeltaFunction(display_name='DeltaFunction', area=1)\n", + "sample_model = SampleModel(\n", + " components=delta_function,\n", + ")\n", + "\n", + "# The resolution is in this case modeled as a Gaussian. However, we can\n", + "# add as many components as we like to the resolution model\n", + "res_gauss = Gaussian(width=0.1)\n", + "res_gauss.area.fixed = True\n", + "resolution_components = ComponentCollection()\n", + "resolution_components.append_component(res_gauss)\n", + "resolution_model = ResolutionModel(components=resolution_components)\n", + "\n", + "# The background model is created in the same way. In this case, we use\n", + "# a flat background\n", + "background_model = BackgroundModel(components=Polynomial(coefficients=[0.001]))\n", + "\n", + "# We combine the resolution abd background model into an instrument\n", + "# model. This model also contains a small energy offset to account for\n", + "# instrument misalignment.\n", + "\n", + "instrument_model = InstrumentModel(\n", + " resolution_model=resolution_model,\n", + " background_model=background_model,\n", + ")\n", + "\n", + "# Collect everything into an analysis object.\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", + ")\n", + "\n", + "# Let us first fit a single Q index and plot the data and model to see\n", + "# how it looks\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)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e98e3d65", + "metadata": {}, + "outputs": [], + "source": [ + "# It looks good, so let us fit all Q indices independently and plot the\n", + "# results\n", + "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": "133e682e", + "metadata": {}, + "outputs": [], + "source": [ + "# Inspect the Parameters as a scipp Dataset\n", + "vanadium_analysis.parameters_to_dataset()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dfacdf24", + "metadata": {}, + "outputs": [], + "source": [ + "# Plot some of fitted parameters as a function of Q\n", + "vanadium_analysis.plot_parameters(names=['DeltaFunction area'])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b6f9f316", + "metadata": {}, + "outputs": [], + "source": [ + "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, + "id": "3609e6c1", + "metadata": {}, + "outputs": [], + "source": [ + "# Now it's time to look at the data we want to fit. We first load the\n", + "# data\n", + "diffusion_experiment = Experiment('Diffusion')\n", + "diffusion_experiment.load_hdf5(filename='diffusion_data_example.h5')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e685909a", + "metadata": {}, + "outputs": [], + "source": [ + "# Now we set up the model, similarly to how we set up the model for the\n", + "# vanadium data.\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", + "\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 = (\n", + " vanadium_analysis.instrument_model.resolution_model\n", + ")\n", + "\n", + "# We fix all parameters of the resolution model.\n", + "diffusion_analysis.instrument_model.resolution_model.fix_all_parameters()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c66828eb", + "metadata": {}, + "outputs": [], + "source": [ + "# Let us see how good the starting parameters are\n", + "diffusion_analysis.plot_data_and_model()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2a4b7572", + "metadata": {}, + "outputs": [], + "source": [ + "# 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": "df14b5c4", + "metadata": {}, + "outputs": [], + "source": [ + "# 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": "eb226c8f", + "metadata": {}, + "outputs": [], + "source": [ + "# It will be possible to fit this to a DiffusionModel, but that will\n", + "# come later." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4c6d7808", + "metadata": {}, + "outputs": [], + "source": [ + "# Let us now fit directly to a diffusion model. We replace the\n", + "# Lorentzian with a Brownian translational diffusion model and keep the\n", + "# other parameters the same.\n", + "delta_function = DeltaFunction(display_name='DeltaFunction', area=0.2)\n", + "component_collection = ComponentCollection(\n", + " components=[delta_function],\n", + ")\n", + "diffusion_model = BrownianTranslationalDiffusion(\n", + " display_name='Brownian Translational Diffusion', diffusion_coefficient=2.4e-9, scale=0.5\n", + ")\n", + "\n", + "sample_model = SampleModel(\n", + " components=component_collection,\n", + " diffusion_models=diffusion_model,\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_model_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 again need to hack in the resolution model from the vanadium\n", + "# analysis, since the setters and getters overwrite the model. This will\n", + "# be fixed asap.\n", + "diffusion_model_analysis.instrument_model._resolution_model = (\n", + " vanadium_analysis.instrument_model.resolution_model\n", + ")\n", + "diffusion_model_analysis.instrument_model.resolution_model.fix_all_parameters()\n", + "\n", + "# Let us see how good the starting parameters are\n", + "diffusion_model_analysis.plot_data_and_model()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fd04d359", + "metadata": {}, + "outputs": [], + "source": [ + "# We now fit all the data simultaneously to the diffusion model, then\n", + "# plot the result. Looks good.\n", + "diffusion_model_analysis.fit(fit_method='simultaneous')\n", + "diffusion_model_analysis.plot_data_and_model()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "842c1f01", + "metadata": {}, + "outputs": [], + "source": [ + "# Let us look at the fitted diffusion coefficient\n", + "diffusion_model.get_all_parameters()" + ] + } + ], + "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/analysis1d.ipynb b/docs/docs/tutorials/analysis1d.ipynb new file mode 100644 index 00000000..48eb8082 --- /dev/null +++ b/docs/docs/tutorials/analysis1d.ipynb @@ -0,0 +1,101 @@ +{ + "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": 1, + "id": "bca91d3c", + "metadata": {}, + "outputs": [], + "source": [ + "from easydynamics.analysis.analysis1d import Analysis1d\n", + "from easydynamics.experiment import Experiment\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", + "\n", + "%matplotlib widget" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "8deca9b6", + "metadata": {}, + "outputs": [], + "source": [ + "vanadium_experiment = Experiment('Vanadium')\n", + "vanadium_experiment.load_hdf5(filename='vanadium_data_example.h5')" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "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", + "fig = 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/docs/docs/tutorials/convolution.ipynb b/docs/docs/tutorials/convolution.ipynb index 922970f9..b13d7973 100644 --- a/docs/docs/tutorials/convolution.ipynb +++ b/docs/docs/tutorials/convolution.ipynb @@ -109,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", @@ -119,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", @@ -132,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", @@ -145,6 +145,70 @@ "plt.ylim(0, 2.5)\n", "plt.show()" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c318f9b8", + "metadata": {}, + "outputs": [], + "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", + "energy_offset = 0.2\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,\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", + "\n", + "plt.plot(energy, y, label='Convoluted Model')\n", + "\n", + "plt.plot(\n", + " energy,\n", + " sample_components.evaluate(energy - 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": { diff --git a/pixi.lock b/pixi.lock index f51bc65b..2e77e94b 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 - 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 - 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 - 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 - 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 - 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 - 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 - 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 - 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 - 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 - 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 - 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 - 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+devdirty20 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 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..ba120037 --- /dev/null +++ b/src/easydynamics/analysis/analysis.py @@ -0,0 +1,564 @@ +# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-License-Identifier: BSD-3-Clause + + +import numpy as np +import scipp as sc +from easyscience.fitting.minimizers.utils import FitResults +from easyscience.fitting.multi_fitter import MultiFitter +from easyscience.variable import Parameter +from plopp.backends.matplotlib.figure import InteractiveFigure +from scipp import UnitError + +from easydynamics.analysis.analysis1d import Analysis1d +from easydynamics.analysis.analysis_base import AnalysisBase +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): + """For analysing two-dimensional data, i.e. intensity as function of + energy and Q. Supports independent fits of each Q value and + simultaneous fits of all Q. + + Args: + display_name (str): Display name of the analysis. + unique_name (str or None): Unique name of the analysis. If None, + a unique name is automatically generated. + experiment (Experiment | None): The Experiment associated with + this Analysis. If None, a default Experiment is created. + sample_model (SampleModel | None): The SampleModel associated + with this Analysis. If None, a default SampleModel is + created. + instrument_model (InstrumentModel | None): The InstrumentModel + associated with this Analysis. If None, a default + InstrumentModel is created. + extra_parameters (Parameter | list[Parameter] | None): + Extra parameters to be included in the analysis for advanced + users. If None, no extra parameters are added. + + Attributes: + experiment (Experiment): The Experiment associated with this + Analysis. + sample_model (SampleModel): The SampleModel associated with this + Analysis. + instrument_model (InstrumentModel): The InstrumentModel + associated with this Analysis. + Q (sc.Variable | None): The Q values from the associated + Experiment, if available. + energy (sc.Variable | None): The energy values from the + associated Experiment, if available. + temperature (Parameter | None): The temperature from the + associated SampleModel, if available. + extra_parameters (list[Parameter]): The extra parameters + included in this Analysis. + """ + + 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, + extra_parameters: Parameter | list[Parameter] | None = None, + ): + + # Avoid triggering updates before the object is fully + # initialized + self._call_updaters = False + super().__init__( + display_name=display_name, + unique_name=unique_name, + experiment=experiment, + sample_model=sample_model, + instrument_model=instrument_model, + extra_parameters=extra_parameters, + ) + + 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}'), + experiment=self.experiment, + sample_model=self.sample_model, + instrument_model=self.instrument_model, + extra_parameters=self._extra_parameters, + Q_index=Q_index, + ) + self._analysis_list.append(analysis) + # Now we can allow updates to trigger recalculations + self._call_updaters = True + + ############# + # Properties + ############# + + @property + def analysis_list(self) -> list[Analysis1d]: + """Get the Analysis1d objects associated with this Analysis. + + Returns: + list[Analysis1d]: A 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. + + Raises: + AttributeError: Always raised, since analysis_list is + read-only. + """ + + 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] | 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. + + Args: + Q_index (int or None): Index of the Q value to calculate + for. If None, calculate for all Q values. + + Returns: + list[np.ndarray] | np.ndarray: 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. + + Raises: + IndexError: If Q_index is not None and is out of bounds. + """ + + if Q_index is None: + return [analysis.calculate() for analysis in self.analysis_list] + + 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, + ) -> FitResults | list[FitResults]: + """Fit the model to the experimental data. + + Args: + fit_method (str): Method to use for fitting. Options are + "independent" (fit each Q index independently, one after + the other) or "simultaneous" (fit all Q indices + simultaneously). Default is "independent". + Q_index (int or None): If fit_method is "independent", + specify which Q index to fit. If None, fit all Q indices + independently. Ignored if fit_method is "simultaneous". + Default is None. + + Returns: Fit results, which may be a list of FitResults if + fitting independently, or a single FitResults object if + fitting simultaneously. + + Raises: + ValueError: If fit_method is not "independent" or + "simultaneous" + IndexError: If fit_method is "independent" and Q_index is + out of bounds. + """ + + 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) + 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'.") + + def plot_data_and_model( + self, + Q_index: int | None = None, + plot_components: bool = True, + add_background: bool = True, + **kwargs, + ) -> InteractiveFigure: + """Plot the experimental data and the model prediction. + Optionally also plot the individual components of the model. + + Uses Plopp for plotting: https://scipp.github.io/plopp/ + + Args: + Q_index (int or None): Index of the Q value to plot. If + None, plot all Q values. Default is None. + plot_components (bool): Whether to plot the individual + components. Default is True. + add_background (bool): Whether to add background components + to the sample model components when plotting. Default is + True. + **kwargs: Additional keyword arguments passed to plopp + for customizing the plot. + + Raises: + ValueError: If Q_index is out of bounds, or if there is no + data to plot, or if there are no Q values available for + plotting. + RuntimeError: If not in a Jupyter notebook environment. + TypeError: If plot_components or add_background is not True + or False. + + Returns: + InteractiveFigure: A Plopp InteractiveFigure containing the + plot of the data and model. + """ + + 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.') + + if not _in_notebook(): + 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.') + + import plopp as pp + + 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'}, + } + data_and_model = { + '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 + + # Overwrite defaults with any user-provided kwargs + plot_kwargs_defaults.update(kwargs) + + fig = pp.slicer( + data_and_model, + **plot_kwargs_defaults, + ) + return 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. + + Returns: + sc.Dataset: A dataset where each entry is a parameter, with + dimensions "Q" and values corresponding to the parameter + values. + + Raises: + UnitError: If there are inconsistent units for the same + parameter across different Q values. + """ + + 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.convert_unit(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, + ) -> InteractiveFigure: + """Plot fitted parameters as a function of Q. + + Args: + names (str | list[str] | None): 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: + InteractiveFigure: A Plopp InteractiveFigure containing the + plot of the parameters. + """ + + 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) + + import plopp as pp + + fig = pp.plot( + data_to_plot, + **plot_kwargs_defaults, + ) + return fig + + ############# + # Private methods - updating models when things change + ############# + + def _on_experiment_changed(self) -> None: + """Update the Q values in the sample and instrument models when + the experiment changes. + + Also update all the Analysi1d objects with the new experiment. + """ + if self._call_updaters: + super()._on_experiment_changed() + for analysis in self.analysis_list: + analysis.experiment = self.experiment + + def _on_sample_model_changed(self) -> None: + """Update the Q values in the sample model when the sample model + changes. + + Also update all the Analysi1d objects with the new sample model. + """ + if self._call_updaters: + super()._on_sample_model_changed() + for analysis in self.analysis_list: + analysis.sample_model = self.sample_model + + def _on_instrument_model_changed(self) -> None: + """Update the Q values in the instrument model when the + instrument model changes. + + Also update all the Analysi1d objects with the new instrument + model. + """ + if self._call_updaters: + super()._on_instrument_model_changed() + for analysis in self.analysis_list: + analysis.instrument_model = self.instrument_model + + ############# + # Private methods + ############# + + def _fit_single_Q(self, Q_index: int) -> FitResults: + """Fit data for a single Q index. + + Args: + Q_index (int): Index of the Q value to fit. + + Returns: + FitResults: The results of the fit for the specified + Q index. + """ + + Q_index = self._verify_Q_index(Q_index) + + return self.analysis_list[Q_index].fit() + + def _fit_all_Q_independently(self) -> list[FitResults]: + """Fit data for all Q indices independently. + + Returns: + list[FitResults]: A list of FitResults, one for each Q + index. + """ + return [analysis.fit() for analysis in self.analysis_list] + + def _fit_all_Q_simultaneously(self) -> FitResults: + """Fit data for all Q indices simultaneously. + + Returns: + FitResults: The results of the simultaneous fit across all + Q indices. + """ + + xs = [] + ys = [] + ws = [] + + for analysis in self.analysis_list: + x, y, weight = self._extract_x_y_weights_from_experiment(analysis.Q_index) + xs.append(x) + ys.append(y) + ws.append(weight) + + # Make sure the convolver is up to date for this Q index + analysis._convolver = analysis._create_convolver() + + mf = MultiFitter( + fit_objects=self.analysis_list, + fit_functions=self.get_fit_functions(), + ) + + results = mf.fit( + x=xs, + y=ys, + weights=ws, + ) + return results + + def get_fit_functions(self) -> list[callable]: + """Get fit functions for all Q indices, which can be used for + simultaneous fitting. + + Returns: + list[callable]: A list of fit functions, one for each + Q index. + """ + 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. + + Returns: + sc.DataArray: A DataArray containing the model values, with + dimensions "Q" and "energy". + """ + + 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_dataset(self, add_background: bool = True) -> sc.Dataset: + """Create a scipp dataset containing the individual components + of the model for plotting. + + Args: + add_background (bool): Whether to add background components + to the sample model components when creating the + dataset. Default is True. + + Raises: + TypeError: If add_background is not True or False. + + Returns: + sc.Dataset: A scipp Dataset where each entry is a component + of the model, with dimensions "Q". + """ + if not isinstance(add_background, bool): + 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') + + ############# + # Dunder methods + ############# diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py new file mode 100644 index 00000000..07fdf6ec --- /dev/null +++ b/src/easydynamics/analysis/analysis1d.py @@ -0,0 +1,583 @@ +# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-License-Identifier: BSD-3-Clause + +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 easyscience.variable import Parameter +from plopp.backends.matplotlib.figure import InteractiveFigure + +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): + """For analysing one-dimensional data, i.e. intensity as function of + energy for a single Q index. Is used primarily in the Analysis + class, but can also be used on its own for simpler analyses. + + Args: + display_name (str): Display name of the analysis. + unique_name (str or None): Unique name of the analysis. If None, + a unique name is automatically generated. + experiment (Experiment | None): The Experiment associated with + this Analysis. If None, a default Experiment is created. + sample_model (SampleModel | None): The SampleModel associated + with this Analysis. If None, a default SampleModel is + created. + instrument_model (InstrumentModel | None): The InstrumentModel + associated with this Analysis. If None, a default + InstrumentModel is created. + Q_index (int | None): The Q index to analyze. If None, the + analysis will not be able to calculate or fit until a + Q index is set. + extra_parameters (Parameter | list[Parameter] | None): + Extra parameters to be included in the analysis for advanced + users. If None, no extra parameters are added. + + Attributes: + experiment (Experiment): The Experiment associated with this + Analysis. + sample_model (SampleModel): The SampleModel associated with this + Analysis. + instrument_model (InstrumentModel): The InstrumentModel + associated with this Analysis. + Q (sc.Variable | None): The Q values from the associated + Experiment, if available. + energy (sc.Variable | None): The energy values from the + associated Experiment, if available. + temperature (Parameter | None): The temperature from the + associated SampleModel, if available. + Q_index (int | None): The Q index being analyzed. + extra_parameters (list[Parameter]): The extra parameters + included in this Analysis. + """ + + 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, + extra_parameters: Parameter | list[Parameter] | None = None, + ): + super().__init__( + display_name=display_name, + unique_name=unique_name, + experiment=experiment, + sample_model=sample_model, + instrument_model=instrument_model, + extra_parameters=extra_parameters, + ) + + self._Q_index = self._verify_Q_index(Q_index) + + self._fit_result = None + if self._Q_index is not None: + self._convolver = self._create_convolver() + else: + self._convolver = None + + ############# + # Properties + ############# + + @property + def Q_index(self) -> int | None: + """Get the Q index associated with this Analysis. + + Returns: + Experiment: The Experiment associated with this Analysis. + """ + + return self._Q_index + + @Q_index.setter + def Q_index(self, value: int | None) -> None: + """Set the Q index for single Q analysis. + + Args: + index (int | None): The Q index. + """ + + self._Q_index = self._verify_Q_index(value) + self._on_Q_index_changed() + + ############# + # Other methods + ############# + + def calculate(self) -> np.ndarray: + """Calculate the model prediction for the chosen Q index. Makes + sure the convolver is up to date before calculating. + + Returns: + np.ndarray: The calculated model prediction. + """ + + self._convolver = self._create_convolver() + + return self._calculate() + + def _calculate(self) -> np.ndarray: + """Calculate the model prediction for the chosen Q index. Does + not check if the convolver is up to date. + + Returns: + np.ndarray: The calculated model prediction. + """ + + sample_intensity = self._evaluate_sample() + + background_intensity = self._evaluate_background() + + sample_plus_background = sample_intensity + background_intensity + + return sample_plus_background + + def fit(self) -> FitResults: + """Fit the model to the experimental data for the chosen Q + index. + + The energy grid is fixed for the duration of the fit. + Convolution objects are created once and reused during + parameter optimization for performance reasons. + + Returns: + FitResult: The result of the fit. + + Raises: + ValueError: If no experiment is associated with this + Analysis. + + Returns: + FitResults: The result of the fit. + """ + if self._experiment is None: + raise ValueError('No experiment is associated with this Analysis.') + + # Create convolver once to reuse during fitting + self._convolver = self._create_convolver() + + fitter = EasyScienceFitter( + fit_object=self, + fit_function=self.as_fit_function(), + ) + + x, y, weights = self._extract_x_y_weights_from_experiment(Q_index=self._require_Q_index()) + 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) -> callable: + """Return self._calculate as a fit function. + + 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. + + Args: + x: Ignored. The energy grid is taken from the experiment. + kwargs: Ignored. Included for compatibility with the + EasyScience fitter. + """ + + def fit_function(x, **kwargs): + return self._calculate() + + return fit_function + + def get_all_variables(self) -> list[DescriptorNumber]: + """Get all variables used in the analysis. + + Returns: + List[Descriptor]: A list of all variables. + """ + 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 + + def plot_data_and_model( + self, + plot_components: bool = True, + add_background=True, + **kwargs, + ) -> InteractiveFigure: + """Plot the experimental data and the model prediction for the + chosen Q index. Optionally also plot the individual components + of the model. + + Uses Plopp for plotting: https://scipp.github.io/plopp/ + + Args: + plot_components (bool): Whether to plot the individual + components of the model. Default is True. + 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: + InteractiveFigure: 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'}, + '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 + + # 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: small utilities + ############# + + def _require_Q_index(self) -> int: + """Get the Q index, ensuring it is set. Raises a ValueError if + the Q index is not set. + + Returns: + int: The Q index. + + Raises: + ValueError: If the Q index is not set. + """ + if self._Q_index is None: + raise ValueError('Q_index must be set.') + return self._Q_index + + 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() + + ############# + # Private methods: evaluation + ############# + + def _evaluate_components( + self, + components: ComponentCollection | ModelComponent, + convolver: Convolution | None = None, + convolve: bool = True, + ) -> 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 (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. + 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. + """ + + Q_index = self._require_Q_index() + energy = self.energy.values + energy_offset = self.instrument_model.get_energy_offset_at_Q(Q_index) + + # 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.value) + + # 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() + + # If no convolver is provided, create a new one. This is for + # evaluating individual components for plotting, where + # performance is not important. + + # We don't create a convolver if the resolution is empty. + resolution = self.instrument_model.resolution_model.get_component_collection(Q_index) + if resolution.is_empty: + return components.evaluate(energy - energy_offset.value) + + conv = Convolution( + sample_components=components, + resolution_components=resolution, + energy=energy, + temperature=self.temperature, + energy_offset=energy_offset, + ) + return conv.convolution() + + def _evaluate_sample(self) -> np.ndarray: + """Evaluate the sample contribution for a given Q index. + + Assumes that self._convolver is up to date. + + Returns: + np.ndarray: The evaluated sample contribution. + """ + Q_index = self._require_Q_index() + components = self.sample_model.get_component_collection(Q_index=Q_index) + return self._evaluate_components( + components=components, + convolver=self._convolver, + convolve=True, + ) + + def _evaluate_sample_component( + self, + component: ModelComponent, + ) -> np.ndarray: + """Evaluate a single sample component for the chosen Q index. + + Args: + component (ModelComponent): The sample component to + evaluate. + + Returns: + np.ndarray: The evaluated sample component contribution. + """ + return self._evaluate_components( + components=component, + convolver=None, + convolve=True, + ) + + def _evaluate_background(self) -> np.ndarray: + """Evaluate the background contribution for the chosen 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 + ) + return self._evaluate_components( + components=background_components, + convolver=None, + convolve=False, + ) + + def _evaluate_background_component( + self, + component: ModelComponent, + ) -> np.ndarray: + """Evaluate a single background component for the chosen Q + index. + + Args: + component (ModelComponent): The background component to + evaluate. + + Returns: + np.ndarray: The evaluated background component contribution. + """ + + return self._evaluate_components( + components=component, + convolver=None, + convolve=False, + ) + + def _create_convolver(self) -> Convolution | None: + """Initialize and return a Convolution object for the chosen 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 + + resolution_components = self.instrument_model.resolution_model.get_component_collection( + Q_index + ) + if resolution_components.is_empty: + return 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 + + ############# + # Private methods: create scipp arrays for plotting + ############# + + def _create_component_scipp_array( + self, + component: ModelComponent, + background: np.ndarray | None = None, + ) -> sc.DataArray: + """Create a scipp DataArray for a single component. Adds the + background if it is not None. + + Args: + component (ModelComponent): The component to evaluate + background (np.ndarray | None): Optional background to add + to the component. + + Returns: + sc.DataArray: 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=values) + + def _create_background_component_scipp_array( + self, + component: ModelComponent, + ) -> sc.DataArray: + """Create a scipp DataArray for a single background component. + + Args: + component (ModelComponent): The component to evaluate. + + Returns: + sc.DataArray: 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. + + Returns: + sc.DataArray: The model calculation of the full sample + model. + """ + values = self._calculate() + return self._to_scipp_array(values=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. + + Args: + add_background (bool): Whether to add background components. + + Returns: + dict[str, sc.DataArray]: A dictionary of component names to + their corresponding sc.DataArrays. + """ + + 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=component, background=background + ) + for component in background_components: + scipp_arrays[component.display_name] = self._create_background_component_scipp_array( + component=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 new file mode 100644 index 00000000..07136062 --- /dev/null +++ b/src/easydynamics/analysis/analysis_base.py @@ -0,0 +1,355 @@ +# 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.variable import Parameter + +from easydynamics.experiment import Experiment +from easydynamics.sample_model import InstrumentModel +from easydynamics.sample_model import SampleModel + + +class AnalysisBase(EasyScienceModelBase): + """Base class for analysis in EasyDynamics. This class is not meant + to be used directly. + + An Analysis consists of an Experiment, a SampleModel, and an + InstrumentModel. The Experiment contains the data to be fitted, the + SampleModel contains the model for the sample, and the + InstrumentModel contains the model for the instrument, including + background and resolution + + Args: + display_name (str): Display name of the analysis. + unique_name (str or None): Unique name of the analysis. If None, + a unique name is automatically generated. + experiment (Experiment | None): The Experiment associated with + this Analysis. If None, a default Experiment is created. + sample_model (SampleModel | None): The SampleModel associated + with this Analysis. If None, a default SampleModel is + created. + instrument_model (InstrumentModel | None): The InstrumentModel + associated with this Analysis. If None, a default + InstrumentModel is created. + extra_parameters (Parameter | list[Parameter] | None): + Extra parameters to be included in the analysis for advanced + users. If None, no extra parameters are added. + + Attributes: + experiment (Experiment): The Experiment associated with this + Analysis. + sample_model (SampleModel): The SampleModel associated with this + Analysis. + instrument_model (InstrumentModel): The InstrumentModel + associated with this Analysis. + Q (sc.Variable | None): The Q values from the associated + Experiment, if available. + energy (sc.Variable | None): The energy values from the + associated Experiment, if available. + temperature (Parameter | None): The temperature from the + associated SampleModel, if available. + extra_parameters (list[Parameter]): The extra parameters + included in this Analysis. + """ + + 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, + extra_parameters: Parameter | list[Parameter] | 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.') + + 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._on_experiment_changed() + + ############# + # Properties + ############# + + @property + def experiment(self) -> Experiment: + """Get the Experiment associated with this Analysis. + + Returns: + Experiment: The Experiment associated with this Analysis. + """ + + return self._experiment + + @experiment.setter + def experiment(self, value: Experiment) -> None: + """Set the Experiment for this Analysis. + + Raises: + TypeError: if value is not an Experiment. + """ + + 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: + """Get the SampleModel associated with this Analysis. + + Returns: + SampleModel: The SampleModel associated with this Analysis. + """ + + return self._sample_model + + @sample_model.setter + def sample_model(self, value: SampleModel) -> None: + """Set the SampleModel for this Analysis. + + Raises: + TypeError: if value is not a SampleModel. + """ + 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: + """Get the InstrumentModel associated with this Analysis. + + Returns: + InstrumentModel: The InstrumentModel associated with this + Analysis. + """ + return self._instrument_model + + @instrument_model.setter + def instrument_model(self, value: InstrumentModel) -> None: + """Set the InstrumentModel for this Analysis. + + Raises: + TypeError: if value is not an InstrumentModel. + """ + 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: + """Get the Q values from the associated Experiment, if + available. + + Returns: + sc.Variable: The Q values from the associated Experiment, + if available. + None: If the Experiment does not have any data. + """ + return self.experiment.Q + + @Q.setter + def Q(self, value) -> None: + """Q cannot be set, as it is a read-only property derived from + the Experiment. + + Raises: + AttributeError: If trying to set Q. + """ + raise AttributeError('Q is a read-only property derived from the Experiment.') + + @property + def energy(self) -> sc.Variable | None: + """Get the energy values from the associated Experiment, if + available. + + Returns: + sc.Variable: The energy values from the associated + Experiment, if available. + None: If the Experiment does not have any data. + """ + + return self.experiment.energy + + @energy.setter + def energy(self, value) -> None: + """Energy cannot be set, as it is a read-only property derived + from the Experiment. + + Raises: + AttributeError: If trying to set energy. + """ + + raise AttributeError('energy is a read-only property derived from the Experiment.') + + @property + def temperature(self) -> Parameter | None: + """Get the temperature from the associated SampleModel, if + available. + + Returns: + Parameter: The temperature from the associated SampleModel, + if available. + None: If the SampleModel does not have a temperature. + """ + + return self.sample_model.temperature + + @temperature.setter + def temperature(self, value) -> None: + """Temperature cannot be set, as it is a read-only property + derived from the SampleModel. + + Raises: + AttributeError: If trying to set temperature. + """ + + raise AttributeError('temperature is a read-only property derived from the SampleModel.') + + @property + def extra_parameters(self) -> list[Parameter]: + """Get the extra parameters included in this Analysis. + + Returns: + list[Parameter]: The extra parameters included in this + Analysis. + """ + return self._extra_parameters + + @extra_parameters.setter + def extra_parameters(self, value: Parameter | list[Parameter]) -> None: + """Set the extra parameters for this Analysis. + + Args: + value (Parameter | list[Parameter]): The extra parameters to + include in this Analysis. + + Raises: + TypeError: If value is not a Parameter or a list of + Parameters. + """ + if isinstance(value, Parameter): + self._extra_parameters = [value] + elif isinstance(value, list) and all(isinstance(p, Parameter) for p in value): + self._extra_parameters = value + else: + raise TypeError('extra_parameters must be a Parameter or a list of Parameters.') + + ############# + # Other methods + ############# + + ############# + # Private methods + ############# + + 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. + + Args: + Q_index (int | None): The Q index to verify. + + Returns: + int | None: The verified Q index. + + Raises: + IndexError: 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 IndexError('Q_index must be a valid index for the Q values.') + return Q_index + + def _extract_x_y_weights_from_experiment( + self, Q_index: int + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Extract the x, y, and weights arrays from the experiment for + the given Q index. + + Args: + Q_index (int): The Q index to extract the data for. + + Returns: + tuple[np.ndarray, np.ndarray, np.ndarray]: The x, y, and + weights arrays extracted from the experiment for the + given Q index. + """ + data = self.experiment.data['Q', Q_index] + x = data.coords['energy'].values + y = data.values + e = data.variances**0.5 + if np.any(e == 0): + raise ValueError('Cannot compute weights: some variances are zero.') + weights = 1.0 / e + return x, y, weights + + ############# + # Dunder methods + ############# + + def __repr__(self) -> str: + """Return a string representation of the Analysis. + + Returns: + str: A string representation of the Analysis. + """ + return f' {self.__class__.__name__} (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 cfa56c9f..031d5975 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): @@ -49,12 +49,14 @@ def __init__( 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( @@ -77,16 +79,8 @@ def convolution( If component pair cannot be handled analytically. """ - # prepare list of components - if isinstance(self.sample_components, ComponentCollection): - sample_components = self.sample_components.components - else: - sample_components = [self.sample_components] - - if isinstance(self.resolution_components, ComponentCollection): - resolution_components = self.resolution_components.components - else: - resolution_components = [self.resolution_components] + sample_components = self.sample_components.components + resolution_components = self.resolution_components.components total = np.zeros_like(self.energy.values, dtype=float) @@ -199,7 +193,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( @@ -420,7 +414,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 +437,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 +465,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..b4fa19e3 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): @@ -77,9 +76,10 @@ 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, + 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, @@ -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, @@ -140,7 +141,9 @@ def _convolve_delta_functions(self) -> np.ndarray: '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 ) @@ -245,6 +248,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 +258,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, diff --git a/src/easydynamics/convolution/convolution_base.py b/src/easydynamics/convolution/convolution_base.py index 34eab3f4..d0235eaf 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: @@ -31,8 +31,9 @@ def __init__( sample_components: ComponentCollection | ModelComponent = None, resolution_components: ComponentCollection | ModelComponent = None, 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)): @@ -44,8 +45,17 @@ def __init__( if isinstance(energy, np.ndarray): 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) @@ -54,6 +64,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,8 +75,50 @@ 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 + 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.""" @@ -84,7 +138,7 @@ 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)): @@ -112,18 +166,34 @@ def energy_unit(self, unit_str: str) -> None: ) # noqa: E501 def convert_energy_unit(self, energy_unit: str | sc.Unit) -> None: - """Convert the energy to the specified unit + """Convert the energy and energy_offset to the specified unit. + Args: energy_unit : str or sc.Unit The unit of the energy. Raises: TypeError: If energy_unit is not a string or scipp unit. + UnitError: If energy cannot be converted to the specified + unit. """ if not isinstance(energy_unit, (str, sc.Unit)): raise TypeError('Energy unit must be a string or scipp unit.') - self.energy = sc.to_unit(self.energy, energy_unit) + old_energy = self.energy.copy() + try: + self.energy = sc.to_unit(self.energy, energy_unit) + except Exception as e: + self.energy = old_energy + raise e + + old_energy_offset = self.energy_offset + try: + self.energy_offset.convert_unit(energy_unit) + except Exception as e: + self.energy_offset = old_energy_offset + raise e + self._energy_unit = energy_unit @property @@ -146,6 +216,9 @@ def sample_components(self, sample_components: ComponentCollection | ModelCompon 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 @property @@ -171,4 +244,7 @@ def resolution_components( 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 diff --git a/src/easydynamics/convolution/numerical_convolution.py b/src/easydynamics/convolution/numerical_convolution.py index 125c4451..1b8ca6d1 100644 --- a/src/easydynamics/convolution/numerical_convolution.py +++ b/src/easydynamics/convolution/numerical_convolution.py @@ -10,8 +10,7 @@ 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.utils import Numeric class NumericalConvolution(NumericalConvolutionBase): @@ -53,9 +52,10 @@ 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, + 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, @@ -64,6 +64,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, @@ -97,13 +98,15 @@ def convolution( # 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, diff --git a/src/easydynamics/convolution/numerical_convolution_base.py b/src/easydynamics/convolution/numerical_convolution_base.py index ffcf0058..5a60f5d8 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(temperature, (Numerical, Parameter)): @@ -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 diff --git a/src/easydynamics/experiment/experiment.py b/src/easydynamics/experiment/experiment.py index b3df2a11..ff48706f 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 @@ -8,6 +6,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 @@ -29,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): @@ -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.') @@ -215,7 +213,7 @@ def plot_data(self, slicer=False, **kwargs) -> None: if self._binned_data is None: raise ValueError('No data to plot. Please load data first.') - if not self._in_notebook(): + if not _in_notebook(): raise RuntimeError('plot_data() can only be used in a Jupyter notebook environment.') from IPython.display import display @@ -241,26 +239,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. diff --git a/src/easydynamics/sample_model/__init__.py b/src/easydynamics/sample_model/__init__.py index 5929fc50..1f1602aa 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,9 @@ from .components import Polynomial from .components import Voigt 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', @@ -19,4 +23,8 @@ '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 586a6649..f04d7ae8 100644 --- a/src/easydynamics/sample_model/component_collection.py +++ b/src/easydynamics/sample_model/component_collection.py @@ -67,13 +67,28 @@ def __init__( 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.') + """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.' + ) + if isinstance(component, ModelComponent): + components = (component,) + if isinstance(component, ComponentCollection): + components = component.components for comp in components: if comp in self._components: @@ -116,6 +131,17 @@ def components(self, components: List[ModelComponent]) -> None: 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. diff --git a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py index d277d227..2853132a 100644 --- a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py @@ -218,6 +218,7 @@ def create_component_collections( lorentzian_component.width.make_dependent_on( dependency_expression=dependency_expression, dependency_map=dependency_map, + desired_unit=self.unit, ) # Make the area dependent on Q @@ -227,9 +228,6 @@ def create_component_collections( dependency_map=area_dependency_map, ) - # Resolving the dependency can do weird things to the units, - # so we make sure it's correct. - lorentzian_component.width.convert_unit(self.unit) component_collection_list[i].append_component(lorentzian_component) return component_collection_list 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..d6ab64b6 100644 --- a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py @@ -50,11 +50,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__( @@ -254,6 +254,7 @@ def create_component_collections( lorentzian_component.width.make_dependent_on( dependency_expression=dependency_expression, dependency_map=dependency_map, + desired_unit=self.unit, ) # Make the area dependent on Q @@ -263,9 +264,6 @@ def create_component_collections( dependency_map=area_dependency_map, ) - # Resolving the dependency can do weird things to the units, - # so we make sure it's correct. - lorentzian_component.width.convert_unit(self.unit) component_collection_list[i].append_component(lorentzian_component) return component_collection_list diff --git a/src/easydynamics/sample_model/instrument_model.py b/src/easydynamics/sample_model/instrument_model.py index bef6bd92..33b6aacb 100644 --- a/src/easydynamics/sample_model/instrument_model.py +++ b/src/easydynamics/sample_model/instrument_model.py @@ -258,6 +258,32 @@ 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 # -------------------------------------------------------------- diff --git a/src/easydynamics/sample_model/model_base.py b/src/easydynamics/sample_model/model_base.py index b6b8bcdd..570234a2 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 @@ -192,7 +191,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() # ------------------------------------------------------------------ @@ -241,26 +250,42 @@ def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: 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 # ------------------------------------------------------------------ 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 - 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)) + 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..e3cc842d 100644 --- a/src/easydynamics/utils/utils.py +++ b/src/easydynamics/utils/utils.py @@ -68,3 +68,23 @@ def _validate_unit(unit: str | sc.Unit | None) -> sc.Unit | None: 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) diff --git a/tests/unit/easydynamics/analysis/test_analysis.py b/tests/unit/easydynamics/analysis/test_analysis.py new file mode 100644 index 00000000..812bcac9 --- /dev/null +++ b/tests/unit/easydynamics/analysis/test_analysis.py @@ -0,0 +1,630 @@ +from unittest.mock import MagicMock +from unittest.mock import PropertyMock +from unittest.mock import patch + +import numpy as np +import pytest +import scipp as sc + +from easydynamics.analysis.analysis import Analysis +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 TestAnalysis: + @pytest.fixture + def analysis(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') + 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) + sample_model = SampleModel(components=Gaussian(), display_name='Gaussian') + instrument_model = InstrumentModel() + + analysis = Analysis( + display_name='TestAnalysis', + experiment=experiment, + sample_model=sample_model, + instrument_model=instrument_model, + extra_parameters=None, + ) + + return analysis + + def test_init(self, analysis): + # WHEN THEN + + # EXPECT + assert analysis.display_name == 'TestAnalysis' + assert isinstance(analysis._experiment, Experiment) + assert isinstance(analysis._sample_model, SampleModel) + assert isinstance(analysis._instrument_model, InstrumentModel) + assert analysis._extra_parameters == [] + assert np.array_equal(analysis.Q.values, [1, 2, 3]) + assert len(analysis.analysis_list) == 3 + + def test_init_raises_with_invalid_experiment(self): + # WHEN / THEN / EXPECT + with pytest.raises( + TypeError, + match='experiment must be an instance of Experiment', + ): + Analysis(experiment='invalid_experiment') + + def test_analysis_list_contains_all_Q_indices(self, analysis): + # WHEN THEN + + # EXPECT + assert len(analysis.analysis_list) == 3 + for i in range(3): + assert analysis.analysis_list[i].Q_index == i + + def test_analysis_list_setter_raises(self, analysis): + # WHEN / THEN / EXPECT + with pytest.raises( + AttributeError, + match='analysis_list is read-only', + ): + analysis.analysis_list = 'invalid_analysis_list' + + def test_calculate_with_Q_index(self, analysis): + # WHEN + analysis.analysis_list[1].calculate = MagicMock(return_value=np.array([4.0, 5.0, 6.0])) + + # THEN + result = analysis.calculate(Q_index=1) + + # EXPECT + analysis.analysis_list[1].calculate.assert_called_once() + np.testing.assert_array_equal(result, np.array([4.0, 5.0, 6.0])) + + def test_calculate_without_Q_index(self, analysis): + # WHEN + for i in range(3): + analysis.analysis_list[i].calculate = MagicMock( + return_value=np.array([1.0, 2.0, 3.0]) + i + ) + + # THEN + result = analysis.calculate() + + # EXPECT + for i in range(3): + analysis.analysis_list[i].calculate.assert_called_once() + np.testing.assert_array_equal(result[i], np.array([1.0, 2.0, 3.0]) + i) + + def test_calculate_with_invalid_Q_index(self, analysis): + # WHEN / THEN / EXPECT + with pytest.raises( + IndexError, + match='must be a valid index', + ): + analysis.calculate(Q_index=3) + + def test_fit_no_Q_values_raises(self, analysis): + # WHEN + analysis.experiment = Experiment() + + # THEN EXPECT + with pytest.raises( + ValueError, + match='No Q values available for fitting', + ): + analysis.fit() + + def test_fit_fit_method_independent_with_Q_index(self, analysis): + # WHEN + analysis.analysis_list[1].fit = MagicMock(return_value='fit_result_Q1') + + # THEN + result = analysis.fit(fit_method='independent', Q_index=1) + + # EXPECT + analysis.analysis_list[1].fit.assert_called_once() + assert result == 'fit_result_Q1' + + def test_fit_fit_method_independent_without_Q_index(self, analysis): + # WHEN + for i in range(3): + analysis.analysis_list[i].fit = MagicMock(return_value=f'fit_result_Q{i}') + + # THEN + result = analysis.fit(fit_method='independent') + + # EXPECT + for i in range(3): + analysis.analysis_list[i].fit.assert_called_once() + assert result[i] == f'fit_result_Q{i}' + + def test_fit_fit_method_simultaneous(self, analysis): + # WHEN + analysis._fit_all_Q_simultaneously = MagicMock(return_value='simultaneous_fit_result') + + # THEN + result = analysis.fit(fit_method='simultaneous') + + # EXPECT + analysis._fit_all_Q_simultaneously.assert_called_once() + assert result == 'simultaneous_fit_result' + + def test_fit_with_invalid_fit_method(self, analysis): + # WHEN / THEN / EXPECT + with pytest.raises( + ValueError, + match="Invalid fit method. Choose 'independent' or 'simultaneous'.", + ): + analysis.fit(fit_method='invalid_fit_method') + + def test_plot_data_and_model_not_in_notebook_raises(self, analysis): + # WHEN / THEN / EXPECT + with patch('easydynamics.analysis.analysis._in_notebook', return_value=False): + with pytest.raises( + RuntimeError, + match=' can only be used in a Jupyter notebook environment', + ): + analysis.plot_data_and_model() + + def test_plot_data_and_model_Q_index(self, analysis): + + # WHEN + analysis.analysis_list[1].plot_data_and_model = MagicMock(return_value='plot_Q1') + + kwargs = { + 'marker': {'amplitude': 'x', 'width': 's'}, + 'title': 'My Plot', + } + + # THEN + result = analysis.plot_data_and_model( + Q_index=1, plot_components=True, add_background=True, **kwargs + ) + + # EXPECT + analysis.analysis_list[1].plot_data_and_model.assert_called_once_with( + plot_components=True, add_background=True, **kwargs + ) + assert result == 'plot_Q1' + + def test_plot_data_and_model_no_data_raises(self, analysis): + # WHEN + analysis.experiment = Experiment() + + # THEN EXPECT + with pytest.raises( + ValueError, + match='No data to plot', + ): + analysis.plot_data_and_model() + + def test_plot_data_and_model_invalid_plot_components_raises(self, analysis): + # WHEN / THEN / EXPECT + + with ( + patch('easydynamics.analysis.analysis._in_notebook', return_value=True), + ): + with pytest.raises( + TypeError, + match='plot_components must be True or False', + ): + analysis.plot_data_and_model(plot_components='not_a_boolean') + + def test_plot_data_and_model_invalid_add_background_raises(self, analysis): + # WHEN / THEN / EXPECT + with ( + patch('easydynamics.analysis.analysis._in_notebook', return_value=True), + ): + with pytest.raises( + TypeError, + match='add_background must be True or False', + ): + analysis.plot_data_and_model(add_background='not_a_boolean') + + def test_plot_data_and_model_defaults(self, analysis): + + # WHEN + fake_fig = object() + + analysis._create_model_array = MagicMock(return_value='MODEL') + with ( + patch('plopp.slicer', return_value=fake_fig) as mock_slicer, + patch.object( + type(analysis.experiment), + 'binned_data', + new_callable=PropertyMock, + ) as mock_binned, + patch('easydynamics.analysis.analysis._in_notebook', return_value=True), + ): + mock_binned.return_value = 'DATA' + # THEN + fig = analysis.plot_data_and_model(plot_components=False) + + # EXPECT + mock_slicer.assert_called_once() + assert fig == fake_fig + # Inspect arguments passed to slicer + args, kwargs = mock_slicer.call_args + + data_passed = args[0] + assert 'Data' in data_passed + assert 'Model' in data_passed + + assert data_passed['Data'] == 'DATA' + assert data_passed['Model'] == 'MODEL' + + # Check the default kwargs + assert kwargs['title'] == 'TestAnalysis' + assert kwargs['linestyle'] == {'Data': 'none', 'Model': '-'} + assert kwargs['marker'] == {'Data': 'o', 'Model': None} + assert kwargs['color'] == {'Data': 'black', 'Model': 'red'} + assert kwargs['markerfacecolor'] == { + 'Data': 'none', + 'Model': 'none', + } + + def test_plot_data_and_model_plot_components_true(self, analysis): + + # WHEN + fake_fig = object() + + analysis._create_model_array = MagicMock(return_value='MODEL') + with ( + patch('plopp.slicer', return_value=fake_fig) as mock_slicer, + patch.object( + type(analysis.experiment), + 'binned_data', + new_callable=PropertyMock, + ) as mock_binned, + patch('easydynamics.analysis.analysis._in_notebook', return_value=True), + ): + mock_binned.return_value = 'DATA' + # THEN + fig = analysis.plot_data_and_model(plot_components=True) + + # EXPECT + mock_slicer.assert_called_once() + assert fig == fake_fig + # Inspect arguments passed to slicer + args, kwargs = mock_slicer.call_args + + data_passed = args[0] + assert 'Data' in data_passed + assert 'Model' in data_passed + + assert data_passed['Data'] == 'DATA' + assert data_passed['Model'] == 'MODEL' + # Check the default kwargs + assert kwargs['title'] == 'TestAnalysis' + assert kwargs['linestyle'] == {'Data': 'none', 'Model': '-', 'Gaussian': '--'} + assert kwargs['marker'] == {'Data': 'o', 'Model': None, 'Gaussian': None} + assert kwargs['color'] == {'Data': 'black', 'Model': 'red'} + assert kwargs['markerfacecolor'] == { + 'Data': 'none', + 'Model': 'none', + } + + def test_parameters_to_dataset(self, analysis): + # WHEN + analysis.sample_model.append_component(Gaussian(display_name='Gaussian2', area=0.5)) + # THEN + parameters_dataset = analysis.parameters_to_dataset() + + # EXPECT + assert isinstance(parameters_dataset, sc.Dataset) + parameter_names = [ + 'Gaussian area', + 'Gaussian center', + 'Gaussian width', + 'Gaussian2 area', + 'Gaussian2 center', + 'Gaussian2 width', + 'energy_offset', + ] + for parameter_name in parameter_names: + assert parameter_name in parameters_dataset + assert 'Q' in parameters_dataset[parameter_name].dims + + def test_parameters_to_dataset_different_units(self, analysis): + + # WHEN + analysis.sample_model.append_component(Gaussian(display_name='Gaussian2', area=0.5)) + + # Convert the unit of a component to eV. + analysis.sample_model.get_component_collection(Q_index=1).components[0].convert_unit('eV') + + # THEN + parameters_dataset = analysis.parameters_to_dataset() + + # EXPECT + assert isinstance(parameters_dataset, sc.Dataset) + parameter_names = [ + 'Gaussian area', + 'Gaussian center', + 'Gaussian width', + 'Gaussian2 area', + 'Gaussian2 center', + 'Gaussian2 width', + 'energy_offset', + ] + for parameter_name in parameter_names: + assert parameter_name in parameters_dataset + assert 'Q' in parameters_dataset[parameter_name].dims + + @pytest.mark.parametrize( + 'parameter_names', + [ + 123, # not str or list + ['parameter_name', 123], # list contains non-string + {'a': 1}, # completely wrong type + ], + ids=[ + 'not_string_or_list', + 'list_contains_non_string', + 'wrong_container_type', + ], + ) + def test_plot_parameters_raises_with_invalid_parameter_names(self, analysis, parameter_names): + + with pytest.raises( + TypeError, + match='names must be a string or a list of strings', + ): + analysis.plot_parameters(names=parameter_names) + + def test_plot_parameters_raises_with_nonexistent_parameter_names(self, analysis): + with pytest.raises( + ValueError, + match='not found in dataset', + ): + analysis.plot_parameters(names='nonexistent_parameter') + + def test_plot_parameters(self, analysis): + + # WHEN + + # Mock all the methods that are called. + fake_fig = object() + user_kwargs = { + 'title': 'My Plot', + 'marker': {'amplitude': 'x', 'width': 's'}, + } + + fake_dataset = { + 'amplitude': object(), + 'width': object(), + } + + analysis.parameters_to_dataset = MagicMock(return_value=fake_dataset) + + with patch('plopp.plot', return_value=fake_fig) as mock_plot: + # THEN + result = analysis.plot_parameters(**user_kwargs) + + # EXPECT + mock_plot.assert_called_once() + + # Inspect arguments + args, kwargs = mock_plot.call_args + + dataset_passed = args[0] + + assert dataset_passed == fake_dataset + + # Check default kwargs + assert 'linestyle' in kwargs + assert kwargs['linestyle'] == { + 'amplitude': 'none', + 'width': 'none', + } + + assert 'markerfacecolor' in kwargs + assert kwargs['markerfacecolor'] == { + 'amplitude': 'none', + 'width': 'none', + } + + # Check that user kwargs override defaults + assert kwargs['marker'] == user_kwargs['marker'] + assert kwargs['title'] == 'My Plot' + + # and that we return the figure + assert result is fake_fig + + def test_on_experiment_changed(self, analysis): + # WHEN + # Create a new experiment. + Q = sc.array(dims=['Q'], values=[2, 3, 4], unit='1/Angstrom') + energy = sc.array(dims=['energy'], values=[20.0, 30.0, 40.0], unit='meV') + data = sc.array( + dims=['Q', 'energy'], + values=[[2.0, 3.0, 4.0], [5.0, 6.0, 7.0], [8.0, 9.0, 10.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}) + + new_experiment = Experiment(data=data_array) + + # THEN (this call _on_experiment_changed internally) + analysis.experiment = new_experiment + + # EXPECT + assert np.array_equal(analysis.Q.values, [2, 3, 4]) + assert len(analysis.analysis_list) == 3 + for analysis in analysis.analysis_list: + assert analysis.experiment is new_experiment + + def test_on_sample_model_changed(self, analysis): + # WHEN + # Create a new sample model. + new_sample_model = SampleModel() + + # THEN (this call _on_sample_model_changed internally) + analysis.sample_model = new_sample_model + + # EXPECT + assert analysis.sample_model is new_sample_model + for analysis in analysis.analysis_list: + assert analysis.sample_model is new_sample_model + + def test_on_instrument_model_changed(self, analysis): + # WHEN + # Create a new instrument model. + new_instrument_model = InstrumentModel() + + # THEN (this call _on_instrument_model_changed internally) + analysis.instrument_model = new_instrument_model + + # EXPECT + assert analysis.instrument_model is new_instrument_model + for analysis in analysis.analysis_list: + assert analysis.instrument_model is new_instrument_model + + def test_fit_single_Q_valid(self, analysis): + # WHEN + analysis.analysis_list[1].fit = MagicMock(return_value='fit_result_Q1') + + # THEN + result = analysis._fit_single_Q(Q_index=1) + + # EXPECT + analysis.analysis_list[1].fit.assert_called_once() + assert result == 'fit_result_Q1' + + def test_fit_single_Q_invalid_Q_index(self, analysis): + # WHEN / THEN / EXPECT + with pytest.raises( + IndexError, + match='must be a valid index', + ): + analysis._fit_single_Q(Q_index=3) + + def test_fit_all_Q_independently(self, analysis): + # WHEN + for i in range(3): + analysis.analysis_list[i].fit = MagicMock(return_value=f'fit_result_Q{i}') + + # THEN + result = analysis._fit_all_Q_independently() + + # EXPECT + for i in range(3): + analysis.analysis_list[i].fit.assert_called_once() + assert result[i] == f'fit_result_Q{i}' + + def test_fit_all_Q_simultaneously(self, analysis): + # WHEN + # Mock the MultiFitter and its fit method + + fake_fit_result = object() + + fake_fitter_instance = MagicMock() + fake_fitter_instance.fit.return_value = fake_fit_result + + # Also mock the get_fit_functions method to return a list of fit + # functions for each Q index + analysis.get_fit_functions = MagicMock( + return_value=['fit_function_Q0', 'fit_function_Q1', 'fit_function_Q2'] + ) + with patch( + 'easydynamics.analysis.analysis.MultiFitter', + return_value=fake_fitter_instance, + ) as mock_fitter: + result = analysis._fit_all_Q_simultaneously() + + # EXPECT + # Check that the correct objects are passed to the MultiFitter + expected_fit_objects = analysis.analysis_list + expected_fit_functions = analysis.get_fit_functions() + mock_fitter.assert_called_once() + args, kwargs = mock_fitter.call_args + assert kwargs['fit_objects'] == expected_fit_objects + assert kwargs['fit_functions'] == expected_fit_functions + + # And check that the correct x, y, and weights arrays are passed + # to the fit method of the MultiFitter + expected_xs = [] + expected_ys = [] + expected_ws = [] + for analysis in analysis.analysis_list: + data = analysis.experiment.data['Q', analysis.Q_index] + + expected_xs.append(data.coords['energy'].values) + expected_ys.append(data.values) + expected_ws.append(1.0 / np.sqrt(data.variances)) + fake_fitter_instance.fit.assert_called_once() + + args, kwargs = fake_fitter_instance.fit.call_args + np.testing.assert_array_equal(kwargs['x'], expected_xs) + np.testing.assert_array_equal(kwargs['y'], expected_ys) + np.testing.assert_array_equal(kwargs['weights'], expected_ws) + + # And that the result from the fit method is returned + assert result == fake_fit_result + + def test_get_fit_functions(self, analysis): + # WHEN + + # THEN + fit_functions = analysis.get_fit_functions() + + # EXPECT + assert isinstance(fit_functions, list) + assert len(fit_functions) == len(analysis.analysis_list) + for fit_function in fit_functions: + assert callable(fit_function) + + def test_create_model_array(self, analysis): + # WHEN + analysis.calculate = MagicMock( + return_value=np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]) + ) + + # THEN + model_array = analysis._create_model_array() + + # EXPECT + analysis.calculate.assert_called_once() + assert isinstance(model_array, sc.DataArray) + assert 'Q' in model_array.dims + assert 'energy' in model_array.dims + assert sc.identical(model_array.coords['Q'], analysis.Q) + assert sc.identical( + model_array.coords['energy'], analysis.experiment.data.coords['energy'] + ) + np.testing.assert_array_equal( + model_array.values, + np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]), + ) + + def test_create_components_dataset_raises(self, analysis): + # WHEN / THEN / EXPECT + with pytest.raises( + TypeError, + match='add_background must be True or False', + ): + analysis._create_components_dataset(add_background='123') + + def test_create_components_dataset(self, analysis): + # WHEN + # Add another component so that there are two components + analysis.sample_model.append_component(Gaussian(display_name='Gaussian2', area=0.5)) + + # THEN + components_dataset = analysis._create_components_dataset(add_background=True) + + # THEN EXPECT + assert isinstance(components_dataset, sc.Dataset) + component_names = [comp.display_name for comp in analysis.sample_model.components] + for component_name in component_names: + assert component_name in components_dataset + assert 'Q' in components_dataset[component_name].dims + assert 'energy' in components_dataset[component_name].dims diff --git a/tests/unit/easydynamics/analysis/test_analysis1d.py b/tests/unit/easydynamics/analysis/test_analysis1d.py new file mode 100644 index 00000000..3ddf74b3 --- /dev/null +++ b/tests/unit/easydynamics/analysis/test_analysis1d.py @@ -0,0 +1,780 @@ +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.analysis1d import Analysis1d +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.gaussian import Gaussian +from easydynamics.sample_model.components.polynomial import Polynomial + + +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') + 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) + sample_model = SampleModel(components=Gaussian()) + instrument_model = InstrumentModel() + + analysis1d = Analysis1d( + display_name='TestAnalysis', + experiment=experiment, + sample_model=sample_model, + instrument_model=instrument_model, + Q_index=0, + extra_parameters=None, + ) + + return analysis1d + + def test_init(self, analysis1d): + # WHEN THEN + + # EXPECT + 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_init_no_experiment(self): + # WHEN + analysis1d = Analysis1d(display_name='TestAnalysisNoExperiment') + + # THEN EXPECT + assert isinstance(analysis1d._experiment, Experiment) + assert analysis1d._convolver is None + + def test_Q_index_setter(self, analysis1d): + # WHEN + analysis1d.Q_index = 1 + + # THEN / EXPECT + assert analysis1d.Q_index == 1 + + @pytest.mark.parametrize( + '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 '), + ], + ids=[ + 'Negative index', + 'Index out of range', + 'Non-integer string', + 'NaN value', + 'List instead of integer', + ], + ) + def test_Q_index_setter_incorrect_Q( + self, analysis1d, invalid_Q_index, expected_exception, expected_message + ): + # 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 + + # mock the _create_convolver and _calculate methods to verify + # they are called + fake_convolver = object() + expected_result = np.array([42.0]) + + 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) + + 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): + + # WHEN + + # 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 + result = analysis1d.fit() + + # EXPECT + + # Check that all the mocked methods were called with the correct + # arguments + analysis1d._create_convolver.assert_called_once() + + mock_fitter.assert_called_once_with( + fit_object=analysis1d, + fit_function='fit_func', + ) + + analysis1d._extract_x_y_weights_from_experiment.assert_called_once() + + fake_fitter_instance.fit.assert_called_once_with( + x=fake_x, + y=fake_y, + weights=fake_weights, + ) + + # 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 + 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 + # call the fit function with some x values + result = fit_func(x=[1, 2, 3]) # should be ignored + + # EXPECT + analysis1d._calculate.assert_called_once() + + assert result is expected + + 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) + analysis1d._extra_parameters = [extra_par1, extra_par2] + + # 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 + + ############# + # 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() + + ############# + # Private methods: evaluation + ############# + + def test_evaluate_components_no_components(self, analysis1d): + # WHEN + components = ComponentCollection() + + # THEN + result = analysis1d._evaluate_components(components=components) + + # EXPECT + assert isinstance(result, np.ndarray) + assert result.shape == (len(analysis1d.experiment.energy),) + assert np.all(result == 0.0) + + def test_evaluate_components_no_convolution(self, analysis1d): + # WHEN + components = Polynomial(coefficients=[1.0]) + # THEN + result = analysis1d._evaluate_components( + components=components, convolver=None, convolve=False + ) + # EXPECT + assert np.array_equal(result, np.array([1.0, 1.0, 1.0])) + + def test_evaluate_components_convolution(self, analysis1d): + # WHEN + components = Gaussian() + convolver = MagicMock() + convolver.convolution = MagicMock(return_value=np.array([1, 2, 3])) + + # THEN + result = analysis1d._evaluate_components( + components=components, convolver=convolver, convolve=True + ) + + # EXPECT + convolver.convolution.assert_called_once() + assert result is convolver.convolution.return_value + + def test_evaluate_components_empty_resolution(self, analysis1d): + # WHEN + components = MagicMock() + components.evaluate = MagicMock(return_value=np.array([1.0, 2.0, 3.0])) + + # The default analysis1d has no resolution model components, so + # no convolution should be applied even if convolve=True + + # THEN + result = analysis1d._evaluate_components( + components=components, convolver=None, convolve=True + ) + + # EXPECT + components.evaluate.assert_called_once() + assert np.array_equal(result, np.array([1.0, 2.0, 3.0])) + + def test_evaluate_with_resolution(self, analysis1d): + # WHEN (set up the resolution model and create a component to + # evaluate) + analysis1d.instrument_model.resolution_model.components = Gaussian() + components = Gaussian() + + with patch('easydynamics.analysis.analysis1d.Convolution') as MockConvolution: + # THEN + analysis1d._evaluate_components( + components=components, + convolver=None, + convolve=True, + ) + + # EXPECT + # Ensure constructor called once + MockConvolution.assert_called_once() + + # The convolver should be created with the correct arguments + resolution_components = ( + analysis1d.instrument_model.resolution_model.get_component_collection( + analysis1d.Q_index + ) + ) + + energy_offset = analysis1d.instrument_model.get_energy_offset_at_Q(analysis1d.Q_index) + + # Extract call arguments + _, kwargs = MockConvolution.call_args + + assert kwargs['sample_components'] == components + assert kwargs['resolution_components'] == resolution_components + assert kwargs['temperature'] == analysis1d.temperature + assert kwargs['energy_offset'] == energy_offset + + # check that the energy array passed to the convolver is the + # same as the analysis1d energy array + np.testing.assert_array_equal( + kwargs['energy'], + analysis1d.energy.values, + ) + + # and check that convolution() was called + MockConvolution.return_value.convolution.assert_called_once_with() + + 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', + [ + 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 + 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, + ) 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..f4e937ad --- /dev/null +++ b/tests/unit/easydynamics/analysis/test_analysis_base.py @@ -0,0 +1,368 @@ +# from unittest.mock import Mock + +from unittest.mock import PropertyMock +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.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_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') 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 + + @pytest.mark.parametrize( + 'extra_parameters', + [ + Parameter(name='param1', value=1.0), + [ + Parameter(name='param1', value=1.0), + Parameter(name='param2', value=2.0), + ], + ], + ids=[ + 'single parameter', + 'list of parameters', + ], + ) + def test_extra_parameters_property(self, analysis_base, extra_parameters): + # WHEN + analysis_base.extra_parameters = extra_parameters + + # THEN + analysis_base.extra_parameters = extra_parameters + + # EXPECT + expected = ( + [extra_parameters] if isinstance(extra_parameters, Parameter) else extra_parameters + ) + + assert analysis_base.extra_parameters == expected + + @pytest.mark.parametrize( + 'invalid_extra_parameters', + [ + 'not a parameter', + [Parameter(name='param1', value=1.0), 'not a parameter'], + ], + ids=[ + 'single invalid parameter', + 'list with invalid parameter', + ], + ) + def test_extra_parameters_setter_invalid_type(self, analysis_base, invalid_extra_parameters): + with pytest.raises( + TypeError, + match='extra_parameters must be a Parameter or a list of Parameters.', + ): + analysis_base.extra_parameters = invalid_extra_parameters + + 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(IndexError, match='Q_index must be a valid index'): + analysis_base._verify_Q_index(invalid_Q_index) + + def test_extract_x_y_weights_from_experiment(self, analysis_base): + # WHEN + 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'], + 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) + analysis_base.experiment = experiment + + Q_index = 0 + + # THEN + x, y, weights = analysis_base._extract_x_y_weights_from_experiment(Q_index=Q_index) + + # EXPECT + assert np.array_equal(x, analysis_base.experiment.energy.values) + assert np.array_equal(y, analysis_base.experiment.data.values[Q_index]) + assert np.array_equal( + weights, + 1 / analysis_base.experiment.data.variances[Q_index] ** 0.5, + ) + + def test_repr(self, analysis_base): + # WHEN + repr_str = repr(analysis_base) + + # THEN EXPECT + assert 'AnalysisBase' in repr_str + assert 'display_name=TestAnalysis' in repr_str + assert 'unique_name=' in repr_str diff --git a/tests/unit/easydynamics/convolution/test_convolution.py b/tests/unit/easydynamics/convolution/test_convolution.py index b82b89cd..a2cfb193 100644 --- a/tests/unit/easydynamics/convolution/test_convolution.py +++ b/tests/unit/easydynamics/convolution/test_convolution.py @@ -51,6 +51,22 @@ def default_convolution(self): ) return conv + @pytest.fixture + def convolution_with_components(self): + energy = np.linspace(-10, 10, 5001) + sample_components = Gaussian(display_name='Gaussian1', area=2.0, center=0.1, width=0.4) + + resolution_components = Gaussian( + display_name='GaussianRes', area=3.0, center=0.2, width=0.5 + ) + + conv = Convolution( + energy=energy, + sample_components=sample_components, + resolution_components=resolution_components, + ) + return conv + def test_init(self, default_convolution): "Test initialization of Convolution with default parameters." # WHEN THEN EXPECT @@ -85,6 +101,42 @@ def test_init(self, default_convolution): assert default_convolution._convolution_plan_is_valid is True assert default_convolution._reactions_enabled is True + def test_init_components(self, convolution_with_components): + "Test initialization of Convolution with default parameters." + # WHEN THEN EXPECT + assert isinstance(convolution_with_components, Convolution) + assert isinstance(convolution_with_components.energy, sc.Variable) + assert np.allclose(convolution_with_components.energy.values, np.linspace(-10, 10, 5001)) + assert isinstance(convolution_with_components._sample_components, ComponentCollection) + assert isinstance(convolution_with_components._resolution_components, ComponentCollection) + assert convolution_with_components.upsample_factor == 5 + assert convolution_with_components.extension_factor == 0.2 + assert convolution_with_components.temperature is None + assert convolution_with_components.energy_unit == 'meV' + assert convolution_with_components.normalize_detailed_balance is True + assert isinstance(convolution_with_components._energy_grid, EnergyGrid) + + assert isinstance( + convolution_with_components._analytical_sample_components, + ComponentCollection, + ) + assert ( + convolution_with_components._analytical_sample_components.components[0] + is convolution_with_components.sample_components.components[0] + ) + assert isinstance( + convolution_with_components._numerical_sample_components, + ComponentCollection, + ) + assert convolution_with_components._numerical_sample_components.is_empty + + assert isinstance( + convolution_with_components._delta_sample_components, ComponentCollection + ) + assert convolution_with_components._delta_sample_components.is_empty + assert convolution_with_components._convolution_plan_is_valid is True + assert convolution_with_components._reactions_enabled is True + def test_convolution_plan_is_built_when_invalid(self, default_convolution): """ Test that convolution plan is built when invalid. diff --git a/tests/unit/easydynamics/convolution/test_convolution_base.py b/tests/unit/easydynamics/convolution/test_convolution_base.py index be6249c7..94272f95 100644 --- a/tests/unit/easydynamics/convolution/test_convolution_base.py +++ b/tests/unit/easydynamics/convolution/test_convolution_base.py @@ -4,8 +4,11 @@ import numpy as np import pytest import scipp as sc +from easyscience.variable import Parameter +from scipp import UnitError from easydynamics.convolution.convolution_base import ConvolutionBase +from easydynamics.sample_model import Gaussian from easydynamics.sample_model.component_collection import ComponentCollection @@ -30,6 +33,27 @@ 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 @@ -55,6 +79,7 @@ def test_init_energy_numerical_none_offset(self): 'sample_components': ComponentCollection(), 'resolution_components': ComponentCollection(), 'energy_unit': 'meV', + 'energy_offset': 0, }, 'Energy must be', ), @@ -64,6 +89,7 @@ def test_init_energy_numerical_none_offset(self): 'sample_components': 'invalid', 'resolution_components': ComponentCollection(), 'energy_unit': 'meV', + 'energy_offset': 0, }, ( '`sample_components` is an instance of str, ' @@ -76,6 +102,7 @@ def test_init_energy_numerical_none_offset(self): 'sample_components': ComponentCollection(), 'resolution_components': 'invalid', 'energy_unit': 'meV', + 'energy_offset': 0, }, ( '`resolution_components` is an instance of str, ' @@ -88,9 +115,20 @@ def test_init_energy_numerical_none_offset(self): '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_offset must be ', + ), ], ) def test_input_type_validation_raises(self, kwargs, expected_message): @@ -164,6 +202,62 @@ def test_convert_energy_unit_invalid_type_raises(self, convolution_base): ): convolution_base.convert_energy_unit(123) + def test_convert_energy_unit_invalid_unit_rollback(self, convolution_base): + # WHEN THEN + with pytest.raises( + UnitError, + match='Conversion from `meV` to `s` is not valid.', + ): + convolution_base.convert_energy_unit('s') + + # EXPECT + assert convolution_base.energy_unit == 'meV' + assert np.allclose(convolution_base.energy.values, np.linspace(-10, 10, 100)) + + def test_convert_energy_unit_invalid_offset_unit_rollback(self, convolution_base): + # WHEN + convolution_base.energy_offset = Parameter(name='energy_offset', value=5, unit='s') + + # THEN + with pytest.raises( + UnitError, + match='Conversion from `s` to `meV` is not valid.', + ): + convolution_base.convert_energy_unit('meV') + + # EXPECT + assert convolution_base.energy_unit == 'meV' + assert convolution_base.energy_offset.unit == 's' + + 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_offset_setter_invalid_type_raises(self, convolution_base): + # WHEN THEN EXPECT + with pytest.raises( + TypeError, + match='Energy_offset must be a number or a Parameter.', + ): + convolution_base.energy_offset = 'invalid' + + 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) diff --git a/tests/unit/easydynamics/convolution/test_numerical_convolution.py b/tests/unit/easydynamics/convolution/test_numerical_convolution.py index e388f17f..9201d07c 100644 --- a/tests/unit/easydynamics/convolution/test_numerical_convolution.py +++ b/tests/unit/easydynamics/convolution/test_numerical_convolution.py @@ -60,12 +60,13 @@ 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_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( diff --git a/tests/unit/easydynamics/experiment/test_experiment.py b/tests/unit/easydynamics/experiment/test_experiment.py index 067a2017..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" @@ -271,24 +273,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,7 +281,7 @@ 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(f'{Experiment.__module__}._in_notebook', return_value=True), patch('plopp.plot') as mock_plot, patch('IPython.display.display') as mock_display, ): @@ -327,7 +311,7 @@ def test_plot_data_not_in_notebook_raises(self, experiment): "Test plotting data raises RuntimeError" '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, @@ -339,62 +323,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 diff --git a/tests/unit/easydynamics/sample_model/components/test_polynomial.py b/tests/unit/easydynamics/sample_model/components/test_polynomial.py index f2f73a74..db9910c1 100644 --- a/tests/unit/easydynamics/sample_model/components/test_polynomial.py +++ b/tests/unit/easydynamics/sample_model/components/test_polynomial.py @@ -58,6 +58,11 @@ def test_input_type_validation_raises(self, kwargs, expected_message): with pytest.raises(TypeError, match=expected_message): Polynomial(display_name='TestPolynomial', **kwargs) + def test_init_no_coefficients_raises(self): + # WHEN THEN EXPECT + with pytest.raises(ValueError, match='At least one coefficient must be provided.'): + Polynomial(display_name='TestPolynomial', coefficients=[]) + def test_negative_value_warns_in_evaluate(self): # WHEN THEN test_polynomial = Polynomial(display_name='TestPolynomial', coefficients=[-1.0]) diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py index b8eb0956..f053e4cb 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py @@ -35,3 +35,12 @@ def test_scale_setter_raises(self, diffusion_model): # WHEN THEN EXPECT with pytest.raises(TypeError, match='scale must be a number.'): diffusion_model.scale = 'invalid' # Invalid type + + def test_repr(self, diffusion_model): + # WHEN THEN + repr_str = repr(diffusion_model) + + # EXPECT + assert 'DiffusionModelBase' in repr_str + assert 'display_name=TestDiffusionModel' in repr_str + assert 'unit=meV' in repr_str diff --git a/tests/unit/easydynamics/sample_model/test_component_collection.py b/tests/unit/easydynamics/sample_model/test_component_collection.py index 42a66f6a..115c2f2e 100644 --- a/tests/unit/easydynamics/sample_model/test_component_collection.py +++ b/tests/unit/easydynamics/sample_model/test_component_collection.py @@ -69,6 +69,11 @@ def test_init_with_invalid_components_raises(self): with pytest.raises(TypeError, match='Component must be.'): ComponentCollection(components=['NotAComponent']) + def test_init_with_invalid_list_of_components_raises(self): + # WHEN THEN EXPECT + with pytest.raises(TypeError, match='components must be a list of'): + ComponentCollection(components='NotAList') + def test_init_with_invalid_unit_raises(self): # WHEN THEN EXPECT with pytest.raises(TypeError, match='unit must be'): @@ -153,6 +158,25 @@ def test_component_setter_invalid_raises(self, component_collection): with pytest.raises(TypeError, match='components must be a list of'): component_collection.components = 'NotAList' + def test_is_empty(self): + # WHEN THEN + component_collection = ComponentCollection(display_name='EmptyModel') + # EXPECT + assert component_collection.is_empty is True + + # WHEN THEN + component = Gaussian( + display_name='TestComponent', area=1.0, center=0.0, width=1.0, unit='meV' + ) + component_collection.append_component(component) + # EXPECT + assert component_collection.is_empty is False + + def test_is_empty_setter(self, component_collection): + # WHEN THEN EXPECT + with pytest.raises(AttributeError, match='is_empty is a read-only property.'): + component_collection.is_empty = True + def test_list_component_names(self, component_collection): # WHEN THEN components = component_collection.list_component_names() diff --git a/tests/unit/easydynamics/sample_model/test_instrument_model.py b/tests/unit/easydynamics/sample_model/test_instrument_model.py index 00f036cd..54396cc2 100644 --- a/tests/unit/easydynamics/sample_model/test_instrument_model.py +++ b/tests/unit/easydynamics/sample_model/test_instrument_model.py @@ -189,6 +189,34 @@ 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_get_energy_offset_at_Q_no_Q_raises(self, instrument_model): + # WHEN + instrument_model.Q = None + + # THEN / EXPECT + with pytest.raises( + ValueError, + match='No Q values are set', + ): + instrument_model.get_energy_offset_at_Q(0) + 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 05591735..fbe44d73 100644 --- a/tests/unit/easydynamics/sample_model/test_model_base.py +++ b/tests/unit/easydynamics/sample_model/test_model_base.py @@ -105,14 +105,6 @@ def test_generate_component_collections_with_Q(self, model_base): 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() - def test_fix_free_all_parameters(self, model_base): # WHEN model_base.fix_all_parameters() @@ -182,6 +174,28 @@ 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_type_raises(self, model_base): + # WHEN THEN EXPECT + with pytest.raises( + TypeError, + match='Q_index must be an int, got str', + ): + model_base.get_component_collection(Q_index='invalid_index') + + 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') @@ -223,7 +237,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): 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( diff --git a/tests/unit/easydynamics/utils/test_utils.py b/tests/unit/easydynamics/utils/test_utils.py index 97a6c36c..cb3eed27 100644 --- a/tests/unit/easydynamics/utils/test_utils.py +++ b/tests/unit/easydynamics/utils/test_utils.py @@ -5,6 +5,7 @@ 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 @@ -112,3 +113,64 @@ 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'): _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 cadc6f84bdfad267c45165ee6261756ba1aa306e Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Wed, 4 Mar 2026 09:17:58 +0100 Subject: [PATCH 6/9] Docstrings (#111) * Update docstrings of ModelComponent * Update DHO and delta function docstring * More docstrings * gaussian docstring * Update ModelComponent docstrings and add a few tests * Update detailed balance * add DHO test * Change test to use the weakref fix fixture * Try to fix equations in docs * Update doc strings to have proper equations * fix DHO * respond the PR comments --- docs/mkdocs.yml | 7 +- .../components/damped_harmonic_oscillator.py | 115 +++++++++++--- .../sample_model/components/delta_function.py | 85 ++++++++-- .../sample_model/components/gaussian.py | 146 ++++++++++++++---- .../sample_model/components/lorentzian.py | 123 ++++++++++++--- .../sample_model/components/mixins.py | 37 +++-- .../components/model_component.py | 66 +++++++- .../sample_model/components/polynomial.py | 82 +++++++++- .../sample_model/components/voigt.py | 122 ++++++++++++--- src/easydynamics/sample_model/sample_model.py | 2 +- src/easydynamics/utils/detailed_balance.py | 65 +++++--- tests/conftest.py | 8 - .../test_damped_harmonic_oscillator.py | 5 + .../sample_model/components/test_gaussian.py | 5 + .../components/test_lorentzian.py | 5 + .../sample_model/components/test_voigt.py | 13 ++ 16 files changed, 715 insertions(+), 171 deletions(-) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 88b1bda5..b0b2fd61 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -86,6 +86,8 @@ extra_css: - assets/stylesheets/extra.css extra_javascript: - assets/javascripts/extra.js + - javascripts/mathjax.js + - https://unpkg.com/mathjax@3/es5/tex-mml-chtml.js # A list of extensions beyond the ones that MkDocs uses by default (meta, toc, tables, and fenced_code) markdown_extensions: @@ -94,6 +96,8 @@ markdown_extensions: - attr_list - def_list - footnotes + - pymdownx.arithmatex: + generic: true - pymdownx.blocks.caption - pymdownx.details - pymdownx.emoji: @@ -132,7 +136,7 @@ plugins: allow_errors: false include_source: true include_requirejs: true # Required for Plotly - custom_mathjax_url: 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/latest.js?config=TeX-AMS_CHTML-full,Safe' + # custom_mathjax_url: 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/latest.js?config=TeX-AMS_CHTML-full,Safe' ignore_h1_titles: true # Use titles defined in the nav section below remove_tag_config: remove_input_tags: @@ -143,6 +147,7 @@ plugins: paths: ['src'] # Change 'src' to your actual sources directory options: docstring_style: google + render_markdown: true group_by_category: false heading_level: 1 show_root_heading: true diff --git a/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py b/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py index 2bedeb76..f85eb93b 100644 --- a/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py +++ b/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py @@ -14,19 +14,38 @@ class DampedHarmonicOscillator(CreateParametersMixin, ModelComponent): - """ - Damped Harmonic Oscillator (DHO). - 2*area*center^2*width/pi / ( (x^2 - center^2)^2 + (2*width*x)^2 ) + r"""Model of a Damped Harmonic Oscillator (DHO). + + The intensity is given by + $$ + I(x) = \frac{2 A x_0^2 \gamma}{\pi \left( (x^2 - x_0^2)^2 + + (2 \gamma x)^2 \right)}, + $$ + where $A$ is the area, $x_0$ is the center, and $\gamma$ is the + width. + Args: - display_name (str): Display name of the component. - center (Int or float): Resonance frequency, approximately the - peak position. - width (Int or float): Damping constant, approximately the - half width at half max (HWHM) of the peaks. - area (Int or float): Area under the curve. - unit (str or sc.Unit): Unit of the parameters. - Defaults to "meV". + area (Int | float): Area under the curve. + center (Int | float): Resonance frequency, approximately the + peak position. + width (Int | float): Damping constant, approximately the + half width at half max (HWHM) of the peaks. + unit (str | sc.Unit): Unit of the parameters. + Defaults to "meV". + display_name (str | None): Display name of the component. + unique_name (str | None): Unique name of the component. + If None, a unique_name is automatically generated. + + Attributes: + area (Parameter): Area under the curve. + center (Parameter): Resonance frequency, approximately the + peak position. + width (Parameter): Damping constant, approximately the + half width at half max (HWHM) of the peaks. + unit (str | sc.Unit): Unit of the parameters. + display_name (str | None): Display name of the component. + unique_name (str | None): Unique name of the component. """ def __init__( @@ -62,50 +81,95 @@ def __init__( @property def area(self) -> Parameter: - """Get the area parameter.""" + """Get the area parameter. + + Returns: + Parameter: The area parameter. + """ return self._area @area.setter def area(self, value: Numeric) -> None: - """Set the area parameter value.""" + """Set the value of the area parameter.""" if not isinstance(value, Numeric): raise TypeError('area must be a number') self._area.value = value @property def center(self) -> Parameter: - """Get the center parameter.""" + """Get the center parameter. + + Returns: + Parameter: The center parameter. + """ return self._center @center.setter def center(self, value: Numeric) -> None: - """Set the center parameter value.""" + """Set the value of the center parameter. + + Args: + value (Numeric): The new value for the center parameter. + + Raises: + TypeError: If the value is not a number. + ValueError: If the value is not positive. + """ if not isinstance(value, Numeric): raise TypeError('center must be a number') - if value <= 0: + if float(value) <= 0: raise ValueError('center must be positive') self._center.value = value @property def width(self) -> Parameter: - """Get the width parameter.""" + """Get the width parameter. + + Returns: + Parameter: The width parameter. + """ return self._width @width.setter def width(self, value: Numeric) -> None: - """Set the width parameter value.""" + """Set the value of the width parameter. + + Args: + value (Numeric): The new value for the width parameter. + + Raises: + TypeError: If the value is not a number. + ValueError: If the value is not positive. + """ if not isinstance(value, Numeric): raise TypeError('width must be a number') + + if float(value) <= 0: + raise ValueError('width must be positive') + self._width.value = value def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: - """Evaluate the Damped Harmonic Oscillator at the given x + r"""Evaluate the Damped Harmonic Oscillator at the given x values. If x is a scipp Variable, the unit of the DHO will be converted - to match x. The DHO evaluates to - 2*area*center^2*width/pi / ((x^2 - center^2)^2 + (2*width*x)^2) + to match x. The intensity is given by + $$ + I(x) = \frac{2 A x_0^2 \gamma}{\pi \left( (x^2 - x_0^2)^2 + + (2 \gamma x)^2 \right)}, + $$ + where $A$ is the area, $x_0$ is the center, and $\gamma$ is the + width. + + Args: + x (Numeric | list | np.ndarray | sc.Variable | + sc.DataArray): The x values at which to evaluate the + DHO. + + Returns: + np.ndarray: The intensity of the DHO at the given x values. """ x = self._prepare_x_for_evaluate(x) @@ -116,7 +180,14 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) return self.area.value * normalization / (denominator) - def __repr__(self): + def __repr__(self) -> str: + """Return a string representation of the Damped Harmonic + Oscillator. + + Returns: + str: A string representation of the Damped Harmonic + Oscillator. + """ return ( f'DampedHarmonicOscillator(display_name = {self.display_name}, unit = {self._unit},\n \ area = {self.area},\n center = {self.center},\n width = {self.width})' diff --git a/src/easydynamics/sample_model/components/delta_function.py b/src/easydynamics/sample_model/components/delta_function.py index 7e302886..6cc1faca 100644 --- a/src/easydynamics/sample_model/components/delta_function.py +++ b/src/easydynamics/sample_model/components/delta_function.py @@ -16,20 +16,29 @@ class DeltaFunction(CreateParametersMixin, ModelComponent): - """Delta function. Evaluates to zero everywhere, except in - convolutions, where it acts as an identity. This is handled in the - ResolutionHandler. If the center is not provided, it will be - centered at 0 and fixed, which is typically what you want in QENS. + """Delta function. + + Evaluates to zero everywhere, except in convolutions, where it acts + as an identity. This is handled by the Convolution method. If the + center is not provided, it will be centered at 0 and fixed, which is + typically what you want in QENS. Args: - center (Int or float or None): Center of the delta function. - If None, defaults to 0 and is fixed. - area (Int or float): Total area under the curve. - unit (str or sc.Unit): Unit of the parameters. - Defaults to "meV". - display_name (str): Name of the component. - unique_name (str or None): Unique name of the component. - If None, a unique_name is automatically generated. + center (Int | float | None): Center of the delta function. If + None, defaults to 0 and is fixed. + area (Int | float): Total area under the curve. + unit (str | sc.Unit): Unit of the parameters. + Defaults to "meV". + display_name (str | None): Name of the component. + unique_name (str | None): Unique name of the component. + If None, a unique_name is automatically generated. + + Attributes: + center (Parameter): Center of the delta function. + area (Parameter): Total area under the curve. + unit (str | sc.Unit): Unit of the parameters. + display_name (str | None): Name of the component. + unique_name (str | None): Unique name of the component. """ def __init__( @@ -58,24 +67,51 @@ def __init__( @property def area(self) -> Parameter: - """Get the area parameter.""" + """Get the area parameter. + + Returns: + Parameter: The area parameter. + """ + return self._area @area.setter def area(self, value: Numeric) -> None: - """Set the area parameter value.""" + """Set the value of the area parameter. + + Args: + value (Numeric): The new value for the area parameter. + + Raises: + TypeError: If the value is not a number. + """ + if not isinstance(value, Numeric): raise TypeError('area must be a number') self._area.value = value @property def center(self) -> Parameter: - """Get the center parameter.""" + """Get the center parameter. + + Returns: + Parameter: The center parameter. + """ + return self._center @center.setter def center(self, value: Numeric | None) -> None: - """Set the center parameter value.""" + """Set the center parameter value. + + Args: + value (Numeric | None): The new value for the center + parameter. If None, defaults to 0 and is fixed. + + Raises: + TypeError: If the value is not a number or None. + """ + if value is None: value = 0.0 self._center.fixed = True @@ -89,6 +125,15 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) The Delta function evaluates to zero everywhere, except at the center. Its numerical integral is equal to the area. It acts as an identity in convolutions. + + Args: + x (Numeric | list | np.ndarray | sc.Variable | + sc.DataArray): The x values at which to evaluate the + Delta function. + + Returns: + np.ndarray: The evaluated Delta function at the given x + values. """ # x assumed sorted, 1D numpy array @@ -120,6 +165,12 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) return model - def __repr__(self): + def __repr__(self) -> str: + """Return a string representation of the Delta function. + + Returns: + str: A string representation of the Delta function. + """ + return f'DeltaFunction(unique_name = {self.unique_name}, unit = {self._unit},\n \ area = {self.area},\n center = {self.center}' diff --git a/src/easydynamics/sample_model/components/gaussian.py b/src/easydynamics/sample_model/components/gaussian.py index 196b4f23..d10d6978 100644 --- a/src/easydynamics/sample_model/components/gaussian.py +++ b/src/easydynamics/sample_model/components/gaussian.py @@ -14,22 +14,42 @@ class Gaussian(CreateParametersMixin, ModelComponent): - """ - Gaussian function: - area/(width*sqrt(2pi)) * exp(-0.5*((x - center)/width)^2) - If the center is not provided, it will be centered at 0 and fixed, - which is typically what you want in QENS. - - Args: - area (Int, float or Parameter): Area of the Gaussian. - center (Int, float, None or Parameter): Center of the Gaussian. - If None, defaults to 0 and is fixed - width (Int, float or Parameter): Standard deviation. - unit (str or sc.Unit): Unit of the parameters. - Defaults to "meV". - display_name (str): Name of the component. - unique_name (str or None): Unique name of the component. - If None, a unique_name is automatically generated. + r"""Model of a Gaussian function. + + The intensity is given by + + $$ + I(x) = \frac{A}{\sigma \sqrt{2\pi}} + \exp\left( + -\frac{1}{2} + \left(\frac{x - x_0}{\sigma}\right)^2 + \right) + $$ + + where $A$ is the area, $x_0$ is the center, and $\sigma$ is the + width. + + If the center is not provided, it will be centered at 0 and + fixed, which is typically what you want in QENS. + + Args: + area (Int | float | Parameter): Area of the Gaussian. + center (Int | float | None | Parameter): Center of the + Gaussian. If None, defaults to 0 and is fixed. + width (Int | float | Parameter): Standard deviation. + unit (str | sc.Unit): Unit of the parameters. Defaults to + "meV". + display_name (str | None): Name of the component. + unique_name (str | None): Unique name of the component. if + None, a unique_name is automatically generated. + + Attributes: + area (Parameter): Area of the Gaussian. + center (Parameter): Center of the Gaussian. + width (Parameter): Standard deviation of the Gaussian. + unit (str | sc.Unit): Unit of the parameters. + display_name (str | None): Name of the component. + unique_name (str | None): Unique name of the component. """ def __init__( @@ -61,24 +81,51 @@ def __init__( @property def area(self) -> Parameter: - """Get the area parameter.""" + """Get the area parameter. + + Returns: + Parameter: The area parameter. + """ + return self._area @area.setter def area(self, value: Numeric) -> None: - """Set the area parameter value.""" + """Set the value of the area parameter. + + Args: + value (Numeric): The new value for the area parameter. + + Raises: + TypeError: If the value is not a number. + """ + if not isinstance(value, Numeric): raise TypeError('area must be a number') self._area.value = value @property def center(self) -> Parameter: - """Get the center parameter.""" + """Get the center parameter. + + Returns: + Parameter: The center parameter. + """ + return self._center @center.setter def center(self, value: Numeric) -> None: - """Set the center parameter value.""" + """Set the center parameter value. + + Args: + value (Numeric | None): The new value for the center + parameter. If None, defaults to 0 and is fixed. + + Raises: + TypeError: If the value is not a number or None. + """ + if value is None: value = 0.0 self._center.fixed = True @@ -88,23 +135,62 @@ def center(self, value: Numeric) -> None: @property def width(self) -> Parameter: - """Get the width parameter.""" + """Get the width parameter (standard deviation). + + Returns: + Parameter: The width parameter. + """ return self._width @width.setter def width(self, value: Numeric) -> None: - """Set the width parameter value.""" + """Set the width parameter value. + + Args: + value (Numeric | None): The new value for the width + parameter. + + Raises: + TypeError: If the value is not a number or None. + ValueError: If the value is not positive. + """ if not isinstance(value, Numeric): raise TypeError('width must be a number') + + if float(value) <= 0: + raise ValueError('width must be positive') + self._width.value = value - def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: - """Evaluate the Gaussian at the given x values. + def evaluate( + self, + x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray, + ) -> np.ndarray: + r"""Evaluate the Gaussian at the given x values. If x is a scipp Variable, the unit of the Gaussian will be converted to match x. - The Gaussian evaluates to - area/(width*sqrt(2pi)) * exp(-0.5*((x - center)/width)^2) + The intensity is given by + $$ + I(x) = \frac{A}{\sigma \sqrt{2\pi}} + \exp\left( + -\frac{1}{2} + \left(\frac{x - x_0}{\sigma}\right)^2 + \right) + $$ + + where $A$ is the area, $x_0$ is the center, and $\sigma$ is the + width. + + + Args: + x (Numeric or list or np.ndarray or sc.Variable or + sc.DataArray): + The x values at which to evaluate the Gaussian. + + Returns: + np.ndarray: The intensity of the Gaussian at the given x + values. """ x = self._prepare_x_for_evaluate(x) @@ -114,6 +200,12 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) return self.area.value * normalization * np.exp(exponent) - def __repr__(self): + def __repr__(self) -> str: + """Return a string representation of the Gaussian. + + Returns: + str: A string representation of the Gaussian. + """ + return f'Gaussian(unique_name = {self.unique_name}, unit = {self._unit},\n \ area = {self.area},\n center = {self.center},\n width = {self.width})' diff --git a/src/easydynamics/sample_model/components/lorentzian.py b/src/easydynamics/sample_model/components/lorentzian.py index 8b5c6ab7..28685f98 100644 --- a/src/easydynamics/sample_model/components/lorentzian.py +++ b/src/easydynamics/sample_model/components/lorentzian.py @@ -14,22 +14,37 @@ class Lorentzian(CreateParametersMixin, ModelComponent): - """ - Lorentzian function: - area*width / (pi * ( (x - center)^2 + width^2 ) ) - If the center is not provided, it will be centered at 0 and fixed, - which is typically what you want in QENS. + r"""Model of a Lorentzian function. + + The intensity is given by + $$ + I(x) = \frac{A}{\pi} \frac{\Gamma}{(x - x_0)^2 + \Gamma^2}, + $$ + where $A$ is the area, $x_0$ is the center, and $\Gamma$ is the + half width at half maximum (HWHM). + + If the center is not provided, it will be centered at 0 + and fixed, which is typically what you want in QENS. Args: - area (Int, float or Parameter): Area of the Lorentzian. - center (Int, float, None or Parameter): Peak center. - If None, defaults to 0 and is fixed. - width (Int, float or Parameter): - Half Width at Half Maximum (HWHM) - unit (str or sc.Unit): Unit of the parameters. Defaults to "meV" - display_name (str): Display name of the component. - unique_name (str or None): Unique name of the component. - If None, a unique_name is automatically generated. + area (Int | float | Parameter): Area of the Lorentzian. + center (Int | float | None | Parameter): Center of the + Lorentzian. If None, defaults to 0 and is fixed + width (Int | float | Parameter): Half width at half maximum + (HWHM). + unit (str | sc.Unit): Unit of the parameters. Defaults to "meV". + display_name (str | None): Name of the component. + unique_name (str | None): Unique name of the component. If None, + a unique_name is automatically generated. + + Attributes: + area (Parameter): Area of the Lorentzian. + center (Parameter): Center of the Lorentzian. + width (Parameter): Half width at half maximum (HWHM) of the + Lorentzian. + unit (str | sc.Unit): Unit of the parameters. + display_name (str | None): Name of the component. + unique_name (str | None): Unique name of the component. """ def __init__( @@ -60,24 +75,48 @@ def __init__( @property def area(self) -> Parameter: - """Get the area parameter.""" + """Get the area parameter. + + Returns: + Parameter: The area parameter. + """ return self._area @area.setter def area(self, value: Numeric) -> None: - """Set the area parameter value.""" + """Set the value of the area parameter. + + Args: + value (Numeric): The new value for the area parameter. + + Raises: + TypeError: If the value is not a number. + """ if not isinstance(value, Numeric): raise TypeError('area must be a number') self._area.value = value @property def center(self) -> Parameter: - """Get the center parameter.""" + """Get the center parameter. + + Returns: + Parameter: The center parameter. + """ return self._center @center.setter def center(self, value: Numeric | None) -> None: - """Set the center parameter value.""" + """Set the value of the center parameter. + + Args: + value (Numeric | None): The new value for the center + parameter. If None, defaults to 0 and is fixed. + + Raises: + TypeError: If the value is not a number or None. + """ + if value is None: value = 0.0 self._center.fixed = True @@ -87,23 +126,54 @@ def center(self, value: Numeric | None) -> None: @property def width(self) -> Parameter: - """Get the width parameter.""" + """Get the width parameter (HWHM). + + Returns: + Parameter: The width parameter. + """ return self._width @width.setter def width(self, value: Numeric) -> None: - """Set the width parameter value.""" + """Set the width parameter value (HWHM). + + Args: + value (Numeric | None): The new value for the width + parameter. + + Raises: + TypeError: If the value is not a number or None. + ValueError: If the value is not positive. + """ if not isinstance(value, Numeric): raise TypeError('width must be a number') + + if float(value) <= 0: + raise ValueError('width must be positive') self._width.value = value def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: - """Evaluate the Lorentzian at the given x values. + r"""Evaluate the Lorentzian at the given x values. If x is a scipp Variable, the unit of the Lorentzian will be - converted to match x. - The Lorentzian evaluates to - area*width / (pi * ( (x - center)^2 + width^2 ) ) + converted to match x. The intensity is given by + + $$ + I(x) = \frac{A}{\pi} \frac{\Gamma}{(x - + x_0)^2 + \Gamma^2}, + $$ + + where $A$ is the area, $x_0$ is the center, and $\Gamma$ is + the half width at half maximum (HWHM). + + Args: + x (Numeric or list or np.ndarray or sc.Variable or + sc.DataArray): + The x values at which to evaluate the Lorentzian. + + Returns: + np.ndarray: The intensity of the Lorentzian at the given x + values. """ x = self._prepare_x_for_evaluate(x) @@ -114,5 +184,10 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) return self.area.value * normalization / denominator def __repr__(self): + """Return a string representation of the Lorentzian. + + Returns: + str: A string representation of the Lorentzian. + """ return f'Lorentzian(unique_name = {self.unique_name}, unit = {self._unit},\n \ area = {self.area},\n center = {self.center},\n width = {self.width})' diff --git a/src/easydynamics/sample_model/components/mixins.py b/src/easydynamics/sample_model/components/mixins.py index 9c98e7e8..b8bb8b47 100644 --- a/src/easydynamics/sample_model/components/mixins.py +++ b/src/easydynamics/sample_model/components/mixins.py @@ -36,14 +36,17 @@ def _create_area_parameter( If the area is negative, a warning is raised. If the area is non-negative, its minimum is set to 0 to avoid it accidentally becoming negative during fitting. - args: - area (Numeric or Parameter): The area value or Parameter. + + Args: + area (Numeric | Parameter): The area value or Parameter. name (str): The name of the model component. - unit (str or sc.Unit): The unit of the area Parameter. + unit (str | sc.Unit): The unit of the area Parameter. minimum_area (float): The minimum allowed area. - returns: + + Returns: Parameter: The validated area Parameter. - raises: + + Raises: TypeError: If area is not a number or a Parameter. Warning: If area is negative. """ @@ -76,16 +79,18 @@ def _create_center_parameter( """Validate and convert a number to a Parameter describing the center of a function. - args: - center (Numeric, Parameter, or None): The center value or - Parameter. + Args: + center (Numeric | Parameter | None): The center value or + Parameter. name (str): The name of the model component. fix_if_none (bool): Whether to fix the center Parameter - if center is None. - unit (str or sc.Unit): The unit of the center Parameter. - returns: + if center is None. + unit (str | sc.Unit): The unit of the center Parameter. + + Returns: Parameter: The validated center Parameter. - raises: + + Raises: TypeError: If center is not None, a number, or a Parameter. """ if center is not None and not isinstance(center, (Numeric, Parameter)): @@ -118,15 +123,17 @@ def _create_width_parameter( """Validate and convert a number to a Parameter describing the width of a function. - args: + Args: width (Numeric or Parameter): The width value or Parameter. name (str): The name of the model component. param_name (str): The name of the width parameter. unit (str or sc.Unit): The unit of the width Parameter. minimum_width (float): The minimum allowed width. - returns: + + Returns: Parameter: The validated width Parameter. - raises: + + Raises: TypeError: If width is not a number or a Parameter. ValueError: If width is non-positive. """ diff --git a/src/easydynamics/sample_model/components/model_component.py b/src/easydynamics/sample_model/components/model_component.py index 1ffdd0ce..470e2cb2 100644 --- a/src/easydynamics/sample_model/components/model_component.py +++ b/src/easydynamics/sample_model/components/model_component.py @@ -16,7 +16,23 @@ class ModelComponent(ModelBase): - """Abstract base class for all model components.""" + """Abstract base class for all model components. + + Args: + unit (str | sc.Unit): The unit of the model component. + Default is 'meV'. + display_name (str | None): A human-readable name for the + component. Default is None. + unique_name (str | None): A unique identifier for the + component. Default is None. + + Attributes: + unit (str): The unit of the model component. + display_name (str | None): A human-readable name for the + component. + unique_name (str | None): A unique identifier for the + component. + """ def __init__( self, @@ -32,12 +48,23 @@ def __init__( def unit(self) -> str: """Get the unit. - :return: Unit as a string. + Returns: + str: The unit of the model component. """ return str(self._unit) @unit.setter def unit(self, unit_str: str) -> None: + """Unit is read-only. Use convert_unit to change the unit + between allowed types or create a new ModelComponent with the + desired unit. + + Args: + unit_str (str): The new unit to set. + + Raises: + AttributeError: Always raised since unit is read-only. + """ raise AttributeError( ( f'Unit is read-only. Use convert_unit to change the unit between allowed types ' @@ -62,6 +89,19 @@ def _prepare_x_for_evaluate( ) -> np.ndarray: """Prepare the input x for evaluation by handling units and converting to a numpy array. + + Args: + x (Numeric | List[Numeric] | np.ndarray | sc.Variable | + sc.DataArray): The input data to prepare. + + Returns: + np.ndarray: The prepared input data as a numpy array. + + Raises: + ValueError: If x contains NaN or infinite values, or if a + sc.DataArray has more than one coordinate. + UnitError: If x has incompatible units that cannot be + converted to the component's unit. """ # Handle units @@ -119,8 +159,10 @@ def _prepare_x_for_evaluate( @staticmethod def validate_unit(unit) -> None: - """Raise TypeError if unit is not allowed (string or - sc.Unit). + """Validate that the unit is either a string or a scipp Unit. + + Raises: + TypeError: If unit is not a string or scipp Unit. """ if unit is not None and not isinstance(unit, (str, sc.Unit)): raise TypeError( @@ -151,11 +193,15 @@ def convert_unit(self, unit: str | sc.Unit): raise e @abstractmethod - def evaluate(self, x: Numeric | sc.Variable) -> np.ndarray: - """Evaluate the model component at input x. + def evaluate( + self, x: Numeric | List[Numeric] | np.ndarray | sc.Variable | sc.DataArray + ) -> np.ndarray: + """Abstract method to evaluate the model component at input x. + Must be implemented by subclasses. Args: - x (Numeric | sc.Variable): Input values. + x (Numeric | list[Numeric] | np.ndarray | sc.Variable | + sc.DataArray): Input values. Returns: np.ndarray: Evaluated function values. @@ -163,4 +209,10 @@ def evaluate(self, x: Numeric | sc.Variable) -> np.ndarray: pass def __repr__(self): + """Return a string representation of the ModelComponent. + + Returns: + str: A string representation of the ModelComponent. + """ + return f'{self.__class__.__name__}(unique_name={self.unique_name}, unit={self._unit})' diff --git a/src/easydynamics/sample_model/components/polynomial.py b/src/easydynamics/sample_model/components/polynomial.py index 43777807..fa475b2c 100644 --- a/src/easydynamics/sample_model/components/polynomial.py +++ b/src/easydynamics/sample_model/components/polynomial.py @@ -18,15 +18,28 @@ class Polynomial(ModelComponent): - """Polynomial function component. c0 + c1*x + c2*x^2 + ... + cN*x^N. + r"""Polynomial function component. + + The intensity is given by + $$ + I(x) = c_0 + c_1 x + c_2 x^2 + ... + c_N x^N, + $$ + where $C_i$ are the coefficients. Args: coefficients (list or tuple): Coefficients c0, c1, ..., cN - representing f(x) = c0 + c1*x + c2*x^2 + ... + cN*x^N unit (str or sc.Unit): Unit of the Polynomial component. display_name (str): Display name of the Polynomial component. unique_name (str or None): Unique name of the component. If None, a unique_name is automatically generated. + + Attributes: + coefficients (list of Parameter): Coefficients of the polynomial + as Parameters. + unit (str): Unit of the Polynomial component. + display_name (str): Display name of the Polynomial component. + unique_name (str or None): Unique name of the component. + If None, a unique_name is automatically generated. """ def __init__( @@ -68,14 +81,29 @@ def __init__( def coefficients(self) -> list[Parameter]: """Get the coefficients of the polynomial as a list of Parameters. + + Returns: + list[Parameter]: The coefficients of the polynomial. """ return list(self._coefficients) @coefficients.setter def coefficients(self, coeffs: Sequence[Numeric | Parameter]) -> None: - """Replace the coefficients. + """Set the coefficients of the polynomial. Length must match current number of coefficients. + + Args: + coeffs (Sequence[Numeric | Parameter]): New coefficients as + a sequence of numbers or Parameters. + + Raises: + TypeError: If coeffs is not a sequence of numbers or + Parameters. + ValueError: If the length of coeffs does not match the + existing number of coefficients. + TypeError: If any item in coeffs is not a number or + Parameter. """ if not isinstance(coeffs, (list, tuple, np.ndarray)): raise TypeError( @@ -95,14 +123,31 @@ def coefficients(self, coeffs: Sequence[Numeric | Parameter]) -> None: raise TypeError('Each coefficient must be either a numeric value or a Parameter.') def coefficient_values(self) -> list[float]: - """Get the coefficients of the polynomial as a list.""" + """Get the coefficients of the polynomial as a list. + + Returns: + list[float]: The coefficient values of the polynomial. + """ coefficient_list = [param.value for param in self._coefficients] return coefficient_list def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: - """Evaluate the Polynomial at the given x values. + r"""Evaluate the Polynomial at the given x values. + + The intensity is given by + $$ + I(x) = c_0 + c_1 x + c_2 x^2 + ... + c_N x^N, + $$ + where $C_i$ are the coefficients. + + Args: + x (Numeric | list | np.ndarray | sc.Variable | + sc.DataArray): + The x values at which to evaluate the Polynomial. - The Polynomial evaluates to c0 + c1*x + c2*x^2 + ... + cN*x^N + Returns: + np.ndarray: The evaluated Polynomial at the given x + values. """ x = self._prepare_x_for_evaluate(x) @@ -121,11 +166,25 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) @property def degree(self) -> int: - """Return the degree of the polynomial.""" + """Get the degree of the polynomial. + + Returns: + int: The degree of the polynomial. + """ return len(self._coefficients) - 1 @degree.setter def degree(self, value: int) -> None: + """The degree is determined by the number of coefficients and + cannot be set directly. + + Args: + value (int): The new degree of the polynomial. + + Raises: + AttributeError: Always raised since degree cannot be set + directly. + """ raise AttributeError( 'The degree of the polynomial is determined by the number of coefficients \ and cannot be set directly.' @@ -144,6 +203,9 @@ def convert_unit(self, unit: str | sc.Unit): Args: unit (str or sc.Unit): The target unit to convert to. + + Raises: + UnitError: If the provided unit is not a string or sc.Unit. """ if not isinstance(unit, (str, sc.Unit)): @@ -162,6 +224,12 @@ def convert_unit(self, unit: str | sc.Unit): self._unit = unit def __repr__(self) -> str: + """Return a string representation of the Polynomial. + + Returns: + str: A string representation of the Polynomial. + """ + coeffs_str = ', '.join(f'{param.name}={param.value}' for param in self._coefficients) return f'Polynomial(unique_name = {self.unique_name}, \ unit = {self._unit},\n coefficients = [{coeffs_str}])' diff --git a/src/easydynamics/sample_model/components/voigt.py b/src/easydynamics/sample_model/components/voigt.py index fcd05fb2..dc0c3315 100644 --- a/src/easydynamics/sample_model/components/voigt.py +++ b/src/easydynamics/sample_model/components/voigt.py @@ -15,21 +15,34 @@ class Voigt(CreateParametersMixin, ModelComponent): - """Voigt profile, a convolution of Gaussian and Lorentzian. If the + r"""Voigt profile, a convolution of Gaussian and Lorentzian. If the center is not provided, it will be centered at 0 and fixed, which is typically what you want in QENS. + Use scipy.special.voigt_profile to evaluate the Voigt profile. + Args: - area (Int or float): Total area under the curve. - center (Int or float or None): Center of the Voigt profile. - gaussian_width (Int or float): Standard deviation of the - Gaussian part. - lorentzian_width (Int or float): Half width at half max (HWHM) - of the Lorentzian part. - unit (str or sc.Unit): Unit of the parameters. Defaults to "meV" - display_name (str): Display name of the component. - unique_name (str or None): Unique name of the component. - If None, a unique_name is automatically generated. + area (Int | float): Total area under the curve. + center (Int | float | None): Center of the Voigt profile. + gaussian_width (Int | float): Standard deviation of the + Gaussian part. + lorentzian_width (Int | float): Half width at half max (HWHM) + of the Lorentzian part. + unit (str | sc.Unit): Unit of the parameters. Defaults to "meV" + display_name (str | None): Display name of the component. + unique_name (str | None): Unique name of the component. + If None, a unique_name is automatically generated. + + Attributes: + area (Parameter): Total area under the curve. + center (Parameter): Center of the Voigt profile. + gaussian_width (Parameter): Standard deviation of the Gaussian + part. + lorentzian_width (Parameter): Half width at half max (HWHM) of + the Lorentzian part. + unit (str | sc.Unit): Unit of the parameters. + display_name (str | None): Display name of the component. + unique_name (str | None): Unique name of the component. """ def __init__( @@ -73,24 +86,47 @@ def __init__( @property def area(self) -> Parameter: - """Get the area parameter.""" + """Get the area parameter. + + Returns: + Parameter: The area parameter. + """ return self._area @area.setter def area(self, value: Numeric) -> None: - """Set the area parameter value.""" + """Set the value of the area parameter. + + Args: + value (Numeric): The new value for the area parameter. + + Raises: + TypeError: If the value is not a number. + """ if not isinstance(value, Numeric): raise TypeError('area must be a number') self._area.value = value @property def center(self) -> Parameter: - """Get the center parameter.""" + """Get the center parameter. + + Returns: + Parameter: The center parameter. + """ return self._center @center.setter def center(self, value: Numeric | None) -> None: - """Set the center parameter value.""" + """Set the value of the center parameter. + + Args: + value (Numeric | None): The new value for the center + parameter. If None, defaults to 0 and is fixed. + + Raises: + TypeError: If the value is not a number. + """ if value is None: value = 0.0 self._center.fixed = True @@ -100,36 +136,76 @@ def center(self, value: Numeric | None) -> None: @property def gaussian_width(self) -> Parameter: - """Get the width parameter.""" + """Get the Gaussian width parameter. + + Returns: + Parameter: The Gaussian width parameter. + """ return self._gaussian_width @gaussian_width.setter def gaussian_width(self, value: Numeric) -> None: - """Set the width parameter value.""" + """Set the width parameter value. + + Args: + value (Numeric | None): The new value for the width + parameter. + + Raises: + TypeError: If the value is not a number or None. + ValueError: If the value is not positive. + """ if not isinstance(value, Numeric): raise TypeError('gaussian_width must be a number') + if float(value) <= 0: + raise ValueError('gaussian_width must be positive') self._gaussian_width.value = value @property def lorentzian_width(self) -> Parameter: - """Get the width parameter.""" + """Get the Lorentzian width parameter (HWHM). + + Returns: + Parameter: The Lorentzian width parameter. + """ return self._lorentzian_width @lorentzian_width.setter def lorentzian_width(self, value: Numeric) -> None: - """Set the width parameter value.""" + """Set the value of the Lorentzian width parameter. + + Args: + value (Numeric): The new value for the Lorentzian width + parameter. + + Raises: + TypeError: If the value is not a number. + ValueError: If the value is not positive. + """ if not isinstance(value, Numeric): raise TypeError('lorentzian_width must be a number') + if float(value) <= 0: + raise ValueError('lorentzian_width must be positive') self._lorentzian_width.value = value def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: - """Evaluate the Voigt at the given x values. + r"""Evaluate the Voigt at the given x values. If x is a scipp Variable, the unit of the Voigt will be converted to match x. The Voigt evaluates to the convolution of a Gaussian with sigma gaussian_width and a Lorentzian with half width at half max lorentzian_width, centered at center, with area equal to area. + + + Args: + x (Numeric or list or np.ndarray or sc.Variable or + sc.DataArray): + The x values at which to evaluate the Voigt. + + Returns: + np.ndarray: The intensity of the Voigt at the given x + values. """ x = self._prepare_x_for_evaluate(x) @@ -141,6 +217,12 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) ) def __repr__(self): + """Return a string representation of the Voigt. + + Returns: + str: A string representation of the Voigt. + """ + return f'Voigt(unique_name = {self.unique_name}, unit = {self._unit},\n \ area = {self.area},\n \ center = {self.center},\n \ diff --git a/src/easydynamics/sample_model/sample_model.py b/src/easydynamics/sample_model/sample_model.py index 346bd7a4..ba21e869 100644 --- a/src/easydynamics/sample_model/sample_model.py +++ b/src/easydynamics/sample_model/sample_model.py @@ -115,7 +115,7 @@ def append_diffusion_model(self, diffusion_model: DiffusionModelBase) -> None: Args: diffusion_model (DiffusionModelBase): The DiffusionModel - to append. + to append. """ if not isinstance(diffusion_model, DiffusionModelBase): diff --git a/src/easydynamics/utils/detailed_balance.py b/src/easydynamics/utils/detailed_balance.py index f82d3c89..8afcd47c 100644 --- a/src/easydynamics/utils/detailed_balance.py +++ b/src/easydynamics/utils/detailed_balance.py @@ -30,32 +30,31 @@ def _detailed_balance_factor( divide_by_temperature: bool = True, ) -> np.ndarray: """ - Compute the detailed balance factor (DBF): - DBF(E, T) = E*(n(E)+1)=E / (1 - exp(-E / (kB*T))), - where n(E) is the Bose-Einstein distribution. - If divide_by_temperature is True, - the result is normalized by kB*T to have value 1 at E=0. + Compute the detailed balance factor (DBF): $DBF(E, T) = E*(n(E)+1)=E + / (1 - exp(-E / (kB*T)))$, where $n(E)$ is the Bose-Einstein + distribution, $E$ is the energy transfer, and $T$ is the + temperature. $k_B$ is the Boltzmann constant. If + divide_by_temperature is True, the result is normalized by kB*T to + have value 1 at E=0. Args: - energy : number, list, np.ndarray, or scipp Variable. - If number, assumed to be in meV unless energy_unit is set. - Energy transfer - T : number, scipp Variable, or Parameter. - If number, assumed to be in K unless temperature_unit is set. - Temperature - energy_unit : str, optional - Unit for energy if energy is given as a number or list. - Default is 'meV' - temperature_unit : str, optional - Unit for temperature if temperature is given as a number. - Default is 'K' - divide_by_temperature : True or False, optional - If True, divide the result by kB*T to make it dimensionless - and have value 1 at E=0. Default is True. + energy (number | list | np.ndarray | scipp.Variable): The energy + transfer. If number, assumed to be in meV unless energy_unit + is set. + temperature (number | scipp.Variable | Parameter): The + temperature. If number, assumed to be in K unless + temperature_unit is set. + energy_unit (str | sc.Unit |None): Unit for energy if energy is + given as a number or list. Default is 'meV' + temperature_unit (str | sc.Unit |None): Unit for temperature if + temperature is given as a number. Default is 'K' + divide_by_temperature (bool | None): If True, divide the result + by $k_B*T$ to make it dimensionless and have value 1 at E=0. + Default is True. Returns: - DBF : np.ndarray TODO: change to sc.Variable? - Detailed balance factor + DBF (np.ndarray): Detailed balance factor evaluated at the + given energy and temperature. Examples -------- @@ -178,6 +177,28 @@ def _convert_to_scipp_variable( ) -> sc.Variable: """Convert various input types to a scipp Variable with proper units. + + Args: + value (int | float | list | np.ndarray | Parameter | + sc.Variable): The value to convert. Can be a number, list, + numpy array, Parameter, or scipp Variable. If a number or + list, the unit must be specified in the unit argument. + name (str): The name of the variable, used for error messages. + unit (str | None): The unit to use if value is a number or list. + Must be specified if value is a number or list. Ignored if + value is a Parameter or sc.Variable, which have their own + units. + + Raises: + TypeError: If value is not one of the accepted types, or if unit + is not a string when needed. + ValueError: If value is a number or list and unit is not + provided. + UnitError: If the provided unit is invalid. + + Returns: + sc.Variable: The input value converted to a scipp Variable with + appropriate units. """ if isinstance(value, sc.Variable): return value diff --git a/tests/conftest.py b/tests/conftest.py index d11735d3..0bca2e2a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,14 +5,6 @@ # TODO: remove once weakref bug is fixed -# import easyscience.global_object -# import pytest - - -# @pytest.fixture(autouse=True) -# def reset_global_object(): -# easyscience.global_object.map._clear() - from unittest.mock import patch import pytest diff --git a/tests/unit/easydynamics/sample_model/components/test_damped_harmonic_oscillator.py b/tests/unit/easydynamics/sample_model/components/test_damped_harmonic_oscillator.py index 0b10a43e..e77f521e 100644 --- a/tests/unit/easydynamics/sample_model/components/test_damped_harmonic_oscillator.py +++ b/tests/unit/easydynamics/sample_model/components/test_damped_harmonic_oscillator.py @@ -138,6 +138,11 @@ def test_center_setter_negative_raises(self, dho: DampedHarmonicOscillator): with pytest.raises(ValueError, match='center must be positive'): dho.center = -1.0 + def test_width_must_be_positive(self, dho: DampedHarmonicOscillator): + # WHEN THEN EXPECT + with pytest.raises(ValueError, match='width must be positive'): + dho.width = -0.5 + def test_evaluate(self, dho: DampedHarmonicOscillator): # WHEN x = np.array([0.0, 1.5, 3.0]) diff --git a/tests/unit/easydynamics/sample_model/components/test_gaussian.py b/tests/unit/easydynamics/sample_model/components/test_gaussian.py index c96eacc3..6699c6d0 100644 --- a/tests/unit/easydynamics/sample_model/components/test_gaussian.py +++ b/tests/unit/easydynamics/sample_model/components/test_gaussian.py @@ -124,6 +124,11 @@ def test_property_setters( with pytest.raises(TypeError, match=invalid_message): setattr(gaussian, prop, invalid_value) + def test_width_must_be_positive(self, gaussian: Gaussian): + # WHEN THEN EXPECT + with pytest.raises(ValueError, match='width must be positive'): + gaussian.width = -0.5 + def test_evaluate(self, gaussian: Gaussian): # WHEN x = np.array([0.0, 0.5, 1.0]) diff --git a/tests/unit/easydynamics/sample_model/components/test_lorentzian.py b/tests/unit/easydynamics/sample_model/components/test_lorentzian.py index 7757a8ab..34b26cca 100644 --- a/tests/unit/easydynamics/sample_model/components/test_lorentzian.py +++ b/tests/unit/easydynamics/sample_model/components/test_lorentzian.py @@ -128,6 +128,11 @@ def test_property_setters( with pytest.raises(TypeError, match=invalid_message): setattr(lorentzian, prop, invalid_value) + def test_width_must_be_positive(self, lorentzian: Lorentzian): + # WHEN THEN EXPECT + with pytest.raises(ValueError, match='width must be positive'): + lorentzian.width = -0.5 + def test_evaluate(self, lorentzian: Lorentzian): # WHEN x = np.array([0.0, 0.5, 1.0]) diff --git a/tests/unit/easydynamics/sample_model/components/test_voigt.py b/tests/unit/easydynamics/sample_model/components/test_voigt.py index a2515a47..9094aedc 100644 --- a/tests/unit/easydynamics/sample_model/components/test_voigt.py +++ b/tests/unit/easydynamics/sample_model/components/test_voigt.py @@ -196,6 +196,19 @@ def test_property_setters( with pytest.raises(TypeError, match=invalid_message): setattr(voigt, prop, invalid_value) + def test_gaussian_width_must_be_positive(self, voigt: Voigt): + # WHEN THEN + with pytest.raises(ValueError, match='gaussian_width must be positive'): + voigt.gaussian_width = -0.6 + + def test_lorentzian_width_must_be_positive(self, voigt: Voigt): + # WHEN THEN + with pytest.raises( + ValueError, + match='lorentzian_width must be positive', + ): + voigt.lorentzian_width = -0.7 + def test_center_is_fixed_if_set_to_None(self, voigt: Voigt): # WHEN assert voigt.center.fixed is False From 37a0522858b2b7d8f6575893b88a07c1a5ea2fe5 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Wed, 4 Mar 2026 09:20:41 +0100 Subject: [PATCH 7/9] More docstring (#112) * initial analysis class * make analysis_base * test things in notebook * reintroduce energy_offset in convolution. It's needed. * Progress on Analysis * multiple parameters with same unique_name????? * fitting and plotting for multiple Q * analysis MWP * Add plotting of parameters and examples * Update failing tests * 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 * initial analysis class * fix merge conflict * Remove notebook * Update notebook, remove unused file * pixi run fix * add missing tests * More missing tests * test analysis_base * 100% coverage of base * Test analysis1d * Another test * More analysis1d tests * linting * update component_collection among other things * Add a few more tests * fix failing test * Update analyis example * analysis tests * Minor fixes and tests * one more test * more tests * Update docstrings for AnalysisBase * pixi run fix * pixi run fix * more docstring for analysis1d * finish analysis1d docstring * react to PR comments * Update analysis.py docstrings * Handle changes to experiment etc in analysis * fix updaters * More tests and response to PR comments * More docstrings * fix merge conflicts properly * make math render properly * minor update * minor update * update conftest * Delete conftest content since weakref bug has been fixed --- src/easydynamics/analysis/analysis.py | 2 +- src/easydynamics/convolution/convolution.py | 12 +- .../convolution/convolution_base.py | 25 +- src/easydynamics/experiment/experiment.py | 134 +++++++- .../sample_model/background_model.py | 35 +- .../sample_model/component_collection.py | 317 ++++++++++++------ .../brownian_translational_diffusion.py | 199 ++++++----- .../diffusion_model/diffusion_model_base.py | 78 ++++- .../jump_translational_diffusion.py | 230 ++++++++----- .../sample_model/instrument_model.py | 210 ++++++++---- src/easydynamics/sample_model/model_base.py | 154 ++++++--- .../sample_model/resolution_model.py | 36 +- src/easydynamics/sample_model/sample_model.py | 151 ++++++--- tests/conftest.py | 67 ---- .../test_brownian_translational_diffusion.py | 5 + .../diffusion_model/test_diffusion_model.py | 5 + .../test_jump_translational_diffusion.py | 10 + 17 files changed, 1092 insertions(+), 578 deletions(-) diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index ba120037..cee393b1 100644 --- a/src/easydynamics/analysis/analysis.py +++ b/src/easydynamics/analysis/analysis.py @@ -374,7 +374,7 @@ def plot_parameters( Returns: InteractiveFigure: A Plopp InteractiveFigure containing the - plot of the parameters. + plot of the parameters. """ ds = self.parameters_to_dataset() diff --git a/src/easydynamics/convolution/convolution.py b/src/easydynamics/convolution/convolution.py index b4fa19e3..46c4f0c6 100644 --- a/src/easydynamics/convolution/convolution.py +++ b/src/easydynamics/convolution/convolution.py @@ -136,9 +136,15 @@ def convolution( return total 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.' + """Convolve delta function components of the sample model with + the resolution components. No detailed balance correction is + applied to delta functions. + + Returns: + np.ndarray + The convolved values of the delta function components + evaluated at energy. + """ return sum( delta.area.value * self._resolution_components.evaluate( diff --git a/src/easydynamics/convolution/convolution_base.py b/src/easydynamics/convolution/convolution_base.py index d0235eaf..9f1799f5 100644 --- a/src/easydynamics/convolution/convolution_base.py +++ b/src/easydynamics/convolution/convolution_base.py @@ -15,14 +15,14 @@ class ConvolutionBase: base class has no convolution functionality. Args: - energy : np.ndarray or scipp.Variable - 1D array of energy values where the convolution is evaluated. - sample_components : ComponentCollection or ModelComponent - The sample model to be convolved. - resolution_components : ComponentCollection or ModelComponent - The resolution model to convolve with. - energy_unit : str or sc.Unit, optional - The unit of the energy. Default is 'meV'. + energy : np.ndarray or scipp.Variable + 1D array of energy values where the convolution is evaluated + sample_components : ComponentCollection or ModelComponent + The sample model to be convolved. + resolution_components : ComponentCollection or ModelComponent + The resolution model to convolve with. + energy_unit : str or sc.Unit, optional + The unit of the energy. Default is 'meV'. """ def __init__( @@ -126,16 +126,15 @@ def energy(self) -> sc.Variable: return self._energy @energy.setter - def energy(self, energy: np.ndarray) -> None: + def energy(self, energy: np.ndarray | sc.Variable) -> None: """Set the energy. Args: - energy : np.ndarray or scipp.Variable - 1D array of energy values where the convolution is - evaluated. + energy (np.ndarray | scipp.Variable): 1D array of energy + values where the convolution is evaluated. Raises: TypeError: If energy is not a numpy ndarray or a - scipp Variable. + scipp Variable. """ if isinstance(energy, Numeric): diff --git a/src/easydynamics/experiment/experiment.py b/src/easydynamics/experiment/experiment.py index ff48706f..4245eba3 100644 --- a/src/easydynamics/experiment/experiment.py +++ b/src/easydynamics/experiment/experiment.py @@ -15,6 +15,21 @@ class Experiment(NewBase): This is a minimal implementation that will be extended in the future. + + Args: + display_name (str): Display name of the experiment. + unique_name (str | None): Unique name of the experiment. If + None, a unique name will be generated. + data (sc.DataArray | str | None): Dataset associated with the + experiment. Can be a sc.DataArray or a filename string to + load from. If None, no data is loaded. + + Attributes: + data (sc.DataArray | None): Dataset associated with the + experiment. + binned_data (sc.DataArray | None): Binned dataset associated + with the experiment. This is derived from `data` and is updated + whenever `data` is set. """ def __init__( @@ -50,12 +65,26 @@ def __init__( @property def data(self) -> sc.DataArray | None: - """Get the dataset associated with this experiment.""" + """Get the dataset associated with this experiment. + + Returns: + sc.DataArray | None: The dataset associated with this + experiment, or None if no data is loaded. + """ return self._data @data.setter def data(self, value: sc.DataArray) -> None: - """Set the dataset associated with this experiment.""" + """Set the dataset associated with this experiment. + + Args: + value (sc.DataArray): The new dataset to associate with this + experiment. + + Raises: + TypeError: If the value is not a sc.DataArray. + ValueError: If the dataset is missing required coordinates. + """ if not isinstance(value, sc.DataArray): raise TypeError(f'Data must be a sc.DataArray, not {type(value).__name__}') self._validate_coordinates(value) @@ -66,36 +95,76 @@ def data(self, value: sc.DataArray) -> None: @property def binned_data(self) -> sc.DataArray | None: - """Get the binned dataset associated with this experiment.""" + """Get the binned dataset associated with this experiment. + + Returns: + sc.DataArray | None: The binned dataset associated with this + experiment, or None if no data is loaded. + """ return self._binned_data @binned_data.setter def binned_data(self, value: sc.DataArray) -> None: - """Set the binned dataset associated with this experiment.""" + """Set the binned dataset associated with this experiment. Read- + only property. Use rebin() to rebin the data instead. + + Args: + value (sc.DataArray): The new binned dataset to associate + with this experiment (ignored) + + Raises: + AttributeError: Always, since binned_data is read-only. + """ 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.""" + """Get the Q values from the dataset. + + Returns: + sc.Variable | None: The Q values from the dataset, or None + if no data is loaded. + """ if self._data is None: return None return self._binned_data.coords['Q'] @Q.setter def Q(self, value: sc.Variable) -> None: - """Set the Q values for the dataset.""" + """Set the Q values for the dataset. Q is a read-only property + derived from the data, so this setter raises an error. + + Args: + value (sc.Variable): The new Q values to set (ignored) + + Raises: + AttributeError: Always, since Q is read-only. + """ raise AttributeError('Q is a read-only property derived from the data.') @property def energy(self) -> sc.Variable | None: - """Get the energy values from the dataset.""" + """Get the energy values from the dataset. + + Returns: + sc.Variable | None: The energy values from the dataset, or + None if no data is loaded. + """ if self._data is None: return None return self._binned_data.coords['energy'] @energy.setter def energy(self, value: sc.Variable) -> None: - """Set the energy values for the dataset.""" + """Set the energy values for the dataset. Energy is a read-only + property derived from the data, so this setter raises an error. + + Args: + value (sc.Variable): The new energy values to set (ignored) + + Raises: + AttributeError: Always, since energy is read-only. + """ raise AttributeError('energy is a read-only property derived from the data.') ########### @@ -109,6 +178,12 @@ def load_hdf5(self, filename: str, display_name: str | None = None): filename (str ): Path to the HDF5 file. display_name (str | None): Optional display name for the experiment. + + Raises: + TypeError: If filename is not a string or if display_name is + not a string or None. + ValueError: If the loaded data is missing required + coordinates. """ if not isinstance(filename, str): raise TypeError(f'Filename must be a string, not {type(filename).__name__}') @@ -133,6 +208,12 @@ def save_hdf5(self, filename: str | None = None): Args: filename (str | None): Path to the output HDF5 file. + If None, the file will be named after the unique_name of + the experiment with a .h5 extension. + + Raises: + TypeError: If filename is not a string or None. + ValueError: If there is no data to save. """ if filename is None: @@ -160,12 +241,13 @@ def rebin(self, dimensions: dict[str, int | sc.Variable]) -> None: Args: dimensions (dict[str, int | sc.Variable]): A dictionary - mapping dimension names to number of bins (int) or bin edges - (sc.Variable). + mapping dimension names to number of bins (int) or bin + edges (sc.Variable). + Raises: TypeError: If dimensions is not a dictionary or if - keys/values are of incorrect types. KeyError: If a specified - dimension is not in the dataset. + keys/values are of incorrect types. + KeyError: If a specified dimension is not in the dataset. """ if not isinstance(dimensions, dict): @@ -208,7 +290,16 @@ def rebin(self, dimensions: dict[str, int | sc.Variable]) -> None: ########### def plot_data(self, slicer=False, **kwargs) -> None: - """Plot the dataset using plopp.""" + """Plot the dataset using plopp: https://scipp.github.io/plopp/ + + Args: + slicer (bool): If True, use plopp's slicer instead of plot. + **kwargs: Additional keyword arguments to pass to plopp. + + Raises: + ValueError: If there is no data to plot. + RuntimeError: If not in a Jupyter notebook environment. + """ if self._binned_data is None: raise ValueError('No data to plot. Please load data first.') @@ -243,6 +334,9 @@ def plot_data(self, slicer=False, **kwargs) -> None: def _validate_coordinates(data: sc.DataArray) -> None: """Validate that required coordinates are present in the data. + Args: + data (sc.DataArray): The data to validate. + Raises: ValueError: If required coordinates are missing. """ @@ -258,7 +352,7 @@ def _convert_to_bin_centers(self, data: sc.DataArray) -> sc.DataArray: """Convert the coordinates of the data to bin centers. Args: - data (sc.DataArray): The data to check. + data (sc.DataArray): The data to convert. Returns: sc.DataArray: The data with coordinates at bin centers. @@ -275,10 +369,20 @@ def _convert_to_bin_centers(self, data: sc.DataArray) -> sc.DataArray: ########### def __repr__(self) -> str: + """Return a string representation of the Experiment object. + + Returns: + str: A string representation of the Experiment object. + """ + return f'Experiment `{self.unique_name}` with data: {self._data}' def __copy__(self) -> 'Experiment': - """Return a copy of the object.""" + """Return a copy of the object. + + Returns: + Experiment: A copy of the Experiment object. + """ 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 diff --git a/src/easydynamics/sample_model/background_model.py b/src/easydynamics/sample_model/background_model.py index 50f76cc2..71a16881 100644 --- a/src/easydynamics/sample_model/background_model.py +++ b/src/easydynamics/sample_model/background_model.py @@ -14,22 +14,25 @@ class BackgroundModel(ModelBase): """BackgroundModel represents a model of the background in an experiment at various Q. - Parameters - ---------- - display_name : str - Display name of the model. - unique_name : str | None - Unique name of the model. If None, a unique name will be - generated. - unit : str | sc.Unit | None - Unit of the model. If None, unitless. - components : ModelComponent | ComponentCollection | None - Template components of the model. If None, no components are - added. - These components are copied into ComponentCollections for each - Q value. - Q : Q_type | None - Q values for the model. If None, Q is not set. + Args: + display_name (str): Display name of the model. + unique_name (str | None): Unique name of the model. If None, a + unique name will be generated. + unit (str | sc.Unit | None): Unit of the model. Defaults to + "meV". + components (ModelComponent | ComponentCollection | None): + Template components of the model. If None, no components + are added. These components are copied into + ComponentCollections for each Q value. + Q (Q_type | None): Q values for the model. If None, Q is not + set. + + Attributes: + unit (str | sc.Unit): Unit of the model. + components (list[ModelComponent]): List of ModelComponents in + the model. + Q (np.ndarray | Numeric | list | ArrayLike | sc.Variable + | None): Q values of the model. """ def __init__( diff --git a/src/easydynamics/sample_model/component_collection.py b/src/easydynamics/sample_model/component_collection.py index f04d7ae8..45bac989 100644 --- a/src/easydynamics/sample_model/component_collection.py +++ b/src/easydynamics/sample_model/component_collection.py @@ -18,15 +18,24 @@ class ComponentCollection(ModelBase): - """A model of the scattering from a sample, combining multiple model - components. - - Attributes - ---------- - display_name : str - Display name of the ComponentCollection. - unit : str or sc.Unit - Unit of the ComponentCollection. + """Collection of model components representing a sample, background + or resolution model. + + Args: + unit (str | sc.Unit): Unit of the sample model. Defaults to + "meV". + display_name (str): Display name of the sample model. + unique_name (str | None): Unique name of the sample model. + If None, a unique_name is automatically generated. + components (List[ModelComponent] | None): Initial model + components to add to the ComponentCollection. + + Attributes: + components (List[ModelComponent]): List of model components in + the collection. + unit (str | sc.Unit): Unit of the sample model. + display_name (str): Display name of the sample model. + unique_name (str): Unique name of the sample model. """ def __init__( @@ -38,16 +47,19 @@ def __init__( ): """Initialize a new ComponentCollection. - Parameters - ---------- - unit : str or sc.Unit, optional - Unit of the sample model. Defaults to "meV". - display_name : str - Display name of the sample model. - unique_name : str or None, optional - Unique name of the sample model. Defaults to None. - components : List[ModelComponent], optional - Initial model components to add to the ComponentCollection. + Args: + unit (str | sc.Unit | None): Unit of the sample model. + Defaults to "meV". + display_name (str | None): Display name of the sample model. + unique_name (str | None): Unique name of the sample model. + Defaults to None. + components (List[ModelComponent] | None): Initial model + components to add to the ComponentCollection. + + Raises: + TypeError: If unit is not a string or sc.Unit, + or if components is not a list of ModelComponent. + ValueError: If components contains duplicate unique names. """ super().__init__(display_name=display_name, unique_name=unique_name) @@ -66,19 +78,143 @@ def __init__( for comp in components: self.append_component(comp) + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def components(self) -> list[ModelComponent]: + """Get the list of components in the collection. + + Returns: + List[ModelComponent]: The components in the collection. + """ + + return list(self._components) + + @components.setter + def components(self, components: List[ModelComponent]) -> None: + """Set the list of components in the collection. + + Args: + components (List[ModelComponent]): The new list of + components. + + Raises: + TypeError: If components is not a list of ModelComponent. + """ + + if not isinstance(components, list): + 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.' + ) + + self._components = components + + @property + def is_empty(self) -> bool: + """Check if the ComponentCollection has no components. + + Returns: + bool: True if the collection has no components, + False otherwise. + """ + return not self._components + + @is_empty.setter + def is_empty(self, value: bool) -> None: + """is_empty is a read-only property that indicates whether the + collection has components. + + Args: + value (bool): The value to set (ignored). + + Raises: + AttributeError: Always raised since is_empty is read-only. + """ + raise AttributeError( + 'is_empty is a read-only property that indicates ' + 'whether the collection has components.' + ) + + @property + def unit(self) -> str | sc.Unit | None: + """Get the unit of the ComponentCollection. + + Returns: + str | sc.Unit | None: The unit of the ComponentCollection, + which is the same as the unit of its components. + """ + return self._unit + + @unit.setter + def unit(self, unit_str: str) -> None: + """Unit is read-only and cannot be set directly. + + Args: + unit_str (str): The unit to set (ignored). + + Raises: + AttributeError: Always raised since unit is read-only. + """ + + 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.' + ) + ) # noqa: E501 + + def convert_unit(self, unit: str | sc.Unit) -> None: + """Convert the unit of the ComponentCollection and all its + components. + + Args: + unit (str | sc.Unit): The target unit to convert to. + + Raises: + TypeError: If unit is not a string or sc.Unit. + UnitError: If any component cannot be converted to the + specified unit. + """ + + old_unit = self._unit + + try: + for component in self.components: + component.convert_unit(unit) + self._unit = unit + except Exception as e: + # Attempt to rollback on failure + try: + for component in self.components: + component.convert_unit(old_unit) + except Exception: # noqa: S110 + pass # Best effort rollback + raise e + + # ------------------------------------------------------------------ + # Component management + # ------------------------------------------------------------------ + 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. + Args: + component (ModelComponent | ComponentCollection): The component + to append. If a ComponentCollection is provided, all of its + components will be appended. + + Raises: + TypeError: If component is not a ModelComponent or + ComponentCollection. + ValueError: If a component with the same unique name already + exists in the collection. """ if not isinstance(component, (ModelComponent, ComponentCollection)): raise TypeError( @@ -100,6 +236,16 @@ def append_component(self, component: ModelComponent | 'ComponentCollection') -> self._components.append(comp) def remove_component(self, unique_name: str) -> None: + """Remove a component from the collection by its unique name. + + Args: + unique_name (str): Unique name of the component to remove. + Raises: + TypeError: If unique_name is not a string. + KeyError: If no component with the given unique name exists + in the collection. + """ + if not isinstance(unique_name, str): raise TypeError('Component name must be a string.') @@ -145,10 +291,9 @@ def is_empty(self, value: bool) -> None: def list_component_names(self) -> List[str]: """List the names of all components in the model. - Returns - ------- - List[str] - Component names. + Returns: + List[str]: List of unique names of the components in the + collection. """ return [component.unique_name for component in self._components] @@ -158,8 +303,14 @@ def clear_components(self) -> None: self._components.clear() def normalize_area(self) -> None: - # Useful for convolutions. - """Normalize the areas of all components so they sum to 1.""" + """Normalize the areas of all components so they sum to 1. This + is useful for convolutions. + + Raises: + ValueError: If there are no components in the model. + ValueError: If the total area is zero or not finite, which + would prevent normalization. + """ if not self.components: raise ValueError('No components in the model to normalize.') @@ -186,66 +337,28 @@ def normalize_area(self) -> None: for param in area_params: param.value /= total_area.value + # ------------------------------------------------------------------ + # Other methods + # ------------------------------------------------------------------ + def get_all_variables(self) -> list[DescriptorBase]: """Get all parameters from the model component. Returns: - List[Parameter]: List of parameters in the component. + List[Parameter]: List of parameters in the component. """ return [var for component in self.components for var in component.get_all_variables()] - @property - def unit(self) -> str | sc.Unit: - """Get the unit of the ComponentCollection. - - Returns - ------- - str or sc.Unit or None - """ - return self._unit - - @unit.setter - 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.' - ) - ) # noqa: E501 - - def convert_unit(self, unit: str | sc.Unit) -> None: - """Convert the unit of the ComponentCollection and all its - components. - """ - - old_unit = self._unit - - try: - for component in self.components: - component.convert_unit(unit) - self._unit = unit - except Exception as e: - # Attempt to rollback on failure - try: - for component in self.components: - component.convert_unit(old_unit) - except Exception: # noqa: S110 - pass # Best effort rollback - raise e - def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) -> np.ndarray: """Evaluate the sum of all components. - Parameters - ---------- - x : Number, list, np.ndarray, sc.Variable, or sc.DataArray - Energy axis. + Args: + x (Number, list, np.ndarray, sc.Variable, or sc.DataArray): + Energy axis. Returns - ------- - np.ndarray - Evaluated model values. + np.ndarray: Evaluated model values. """ if not self.components: @@ -259,17 +372,18 @@ def evaluate_component( ) -> np.ndarray: """Evaluate a single component by name. - Parameters - ---------- - x : Number, list, np.ndarray, sc.Variable, or sc.DataArray - Energy axis. - unique_name : str - Component unique name. + Args: + x (Number, list, np.ndarray, sc.Variable, or sc.DataArray): + Energy axis. + unique_name (str): Component unique name. - Returns - ------- - np.ndarray - Evaluated values for the specified component. + Returns: + np.ndarray: Evaluated values for the specified component. + Raises: + ValueError: If there are no components in the model. + TypeError: If unique_name is not a string. + KeyError: If no component with the given unique name exists + in the collection. """ if not self.components: raise ValueError('No components in the model to evaluate.') @@ -299,18 +413,20 @@ def free_all_parameters(self) -> None: for param in self.get_fittable_parameters(): param.fixed = False + # ------------------------------------------------------------------ + # Dunder methods + # ------------------------------------------------------------------ + def __contains__(self, item: str | ModelComponent) -> bool: """Check if a component with the given name or instance exists in the ComponentCollection. Args: - ---------- - item : str or ModelComponent - The component name or instance to check for. - Returns - ------- - bool - True if the component exists, False otherwise. + item (str or ModelComponent): The component name or instance + to check for. + + Returns: + bool: True if the component exists, False otherwise. """ if isinstance(item, str): @@ -325,9 +441,8 @@ def __contains__(self, item: str | ModelComponent) -> bool: def __repr__(self) -> str: """Return a string representation of the ComponentCollection. - Returns - ------- - str + Returns: + str: String representation of the ComponentCollection. """ comp_names = ', '.join(c.unique_name for c in self.components) or 'No components' diff --git a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py index 2853132a..1ecb8c2a 100644 --- a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py @@ -19,19 +19,40 @@ class BrownianTranslationalDiffusion(DiffusionModelBase): - """Model of Brownian translational diffusion, consisting of a - Lorentzian function for each Q-value, where the width is given by - :math:`DQ^2`. Q is assumed to have units of 1/angstrom. Creates - ComponentCollections with Lorentzian components for given Q-values. + r"""Model of Brownian translational diffusion, consisting of a + Lorentzian function for each Q-value, where the width is given by $D + Q^2$, where $D$ is the diffusion coefficient. The area of the + Lorentzians is given by the scale parameter multiplied by the QISF, + which is 1 for this model. The EISF is 0 for this model, so there is + no delta function component. Q is assumed to have units of + 1/angstrom. Creates ComponentCollections with Lorentzian components + for given Q-values. + + Args: + display_name (str): Display name of the diffusion model. + unique_name (str | None): Unique name of the diffusion model. If + None, a unique name will be generated. + unit (str | sc.Unit): Unit of the diffusion model. Must be + convertible to meV. Defaults to "meV". + scale (Numeric): Scale factor for the diffusion model. Must be + a non-negative number. Defaults to 1.0. + diffusion_coefficient (Numeric): Diffusion coefficient D in + m^2/s. Defaults to 1.0. + + Attributes: + unit (str | sc.Unit): Unit of the diffusion model. + scale (Parameter): Scale parameter of the diffusion model. + diffusion_coefficient (Parameter): Diffusion coefficient D in + m^2/s. Example usage: - Q=np.linspace(0.5,2,7) - energy=np.linspace(-2, 2, 501) - scale=1.0 - diffusion_coefficient = 2.4e-9 # m^2/s - diffusion_model=BrownianTranslationalDiffusion(display_name="DiffusionModel", - scale=scale, diffusion_coefficient= diffusion_coefficient) - component_collections=diffusion_model.create_component_collections(Q) + >>>Q=np.linspace(0.5,2,7) + >>>energy=np.linspace(-2, 2, 501) + >>>scale=1.0 + >>>diffusion_coefficient = 2.4e-9 # m^2/s + >>>diffusion_model=BrownianTranslationalDiffusion(display_name="DiffusionModel", + >>>scale=scale, diffusion_coefficient= diffusion_coefficient) + >>>component_collections=diffusion_model.create_component_collections(Q) See also the examples. """ @@ -45,21 +66,22 @@ def __init__( ): """Initialize a new BrownianTranslationalDiffusion model. - Parameters - ---------- - display_name : str - Display name of the diffusion model. - unique_name : str or None - Unique name of the diffusion model. If None, a unique name - is automatically generated. - unit : str or sc.Unit, optional - Energy unit for the underlying Lorentzian components. - Defaults to "meV". - scale : float or Parameter, optional - Scale factor for the diffusion model. - diffusion_coefficient : Number, optional - Diffusion coefficient D in m^2/s. - Defaults to 1.0. + Args: + display_name (str): Display name of the diffusion model. + unique_name (str | None): Unique name of the diffusion + model. If None, a unique name will be generated. + unit (str | sc.Unit): Unit of the diffusion model. Must be + convertible to meV. Defaults to "meV". + scale (Numeric): Scale factor for the diffusion model. Must + be a non-negative number. Defaults to 1.0. + diffusion_coefficient (Numeric): Diffusion coefficient D in + m^2/s. Defaults to 1.0. + + Raises: + TypeError: If scale or diffusion_coefficient is not a + number. + ValueError: If scale is negative. + UnitError: If unit is not a string or scipp Unit. """ if not isinstance(scale, Numeric): raise TypeError('scale must be a number.') @@ -72,6 +94,7 @@ def __init__( value=float(diffusion_coefficient), fixed=False, unit='m**2/s', + min=0.0, ) super().__init__( display_name=display_name, @@ -91,19 +114,29 @@ def __init__( def diffusion_coefficient(self) -> Parameter: """Get the diffusion coefficient parameter D. - Returns - ------- - Parameter - Diffusion coefficient D. + Returns: + Parameter: Diffusion coefficient D in m^2/s. """ return self._diffusion_coefficient @diffusion_coefficient.setter def diffusion_coefficient(self, diffusion_coefficient: Numeric) -> None: - """Set the diffusion coefficient parameter D.""" + """Set the diffusion coefficient parameter D. + + Args: + diffusion_coefficient (Numeric): The new value for the + diffusion coefficient D in m^2/s. + + Raises: + TypeError: If diffusion_coefficient is not a number. + ValueError: If diffusion_coefficient is negative. + """ if not isinstance(diffusion_coefficient, Numeric): raise TypeError('diffusion_coefficient must be a number.') - self._diffusion_coefficient.value = diffusion_coefficient + + if float(diffusion_coefficient) < 0: + raise ValueError('diffusion_coefficient must be non-negative.') + self._diffusion_coefficient.value = float(diffusion_coefficient) # ------------------------------------------------------------------ # Other methods @@ -113,15 +146,13 @@ def calculate_width(self, Q: Q_type) -> np.ndarray: """Calculate the half-width at half-maximum (HWHM) for the diffusion model. - Parameters - ---------- - Q : np.ndarray | Numeric | list | ArrayLike - Scattering vector in 1/angstrom + Args: + Q (np.ndarray | Numeric | list | ArrayLike): Scattering + vector in 1/angstrom - Returns - ------- - np.ndarray - HWHM values in the unit of the model (e.g., meV). + Returns: + np.ndarray: HWHM values in the unit of the model + (e.g., meV). """ Q = _validate_and_convert_Q(Q) @@ -136,15 +167,12 @@ def calculate_EISF(self, Q: Q_type) -> np.ndarray: """Calculate the Elastic Incoherent Structure Factor (EISF) for the Brownian translational diffusion model. - Parameters - ---------- - Q : np.ndarray | Numeric | list | ArrayLike - Scattering vector in 1/angstrom + Args: + Q (np.ndarray | Numeric | list | ArrayLike): Scattering + vector in 1/angstrom - Returns - ------- - np.ndarray - EISF values (dimensionless). + Returns: + np.ndarray: EISF values (dimensionless). """ Q = _validate_and_convert_Q(Q) EISF = np.zeros_like(Q) @@ -154,15 +182,12 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: """Calculate the Quasi-Elastic Incoherent Structure Factor (QISF). - Parameters - ---------- - Q : np.ndarray | Numeric | list | ArrayLike - Scattering vector in 1/angstrom + Args: + Q (np.ndarray | Numeric | list | ArrayLike): Scattering + vector in 1/angstrom - Returns - ------- - np.ndarray - QISF values (dimensionless). + Returns: + np.ndarray: QISF values (dimensionless). """ Q = _validate_and_convert_Q(Q) @@ -174,19 +199,23 @@ def create_component_collections( Q: Q_type, component_display_name: str = 'Brownian translational diffusion', ) -> List[ComponentCollection]: - """Create ComponentCollection components for the Brownian + r"""Create ComponentCollection components for the Brownian translational diffusion model at given Q values. Args: - ---------- - Q : Number, list, or np.ndarray - Scattering vector values. - component_display_name : str - Name of the Lorentzian component. - Returns - ------- - List[ComponentCollection] - List of ComponentCollections with Lorentzian components. + Q (Number, list, or np.ndarray): Scattering vector values. + component_display_name (str): Name of the Lorentzian + component. + + Returns: + List[ComponentCollection]: List of ComponentCollections with + Lorentzian components for each Q value. Each Lorentzian + has a width given by $D*Q^2$ and an area given by the + scale parameter multiplied by the QISF (which is 1 for + this model). + + Raises: + TypeError: If component_display_name is not a string. """ Q = _validate_and_convert_Q(Q) @@ -240,14 +269,14 @@ def _write_width_dependency_expression(self, Q: float) -> str: """Write the dependency expression for the width as a function of Q to make dependent Parameters. - Parameters - ---------- - Q : float - Scattering vector in 1/angstrom - Returns - ------- - str - Dependency expression for the width. + Args: + Q (float): Scattering vector in 1/angstrom + + Returns: + str: Dependency expression for the width. + + Raises: + TypeError: If Q is not a float. """ if not isinstance(Q, (float)): raise TypeError('Q must be a float.') @@ -258,6 +287,9 @@ def _write_width_dependency_expression(self, Q: float) -> str: def _write_width_dependency_map_expression(self) -> Dict[str, DescriptorNumber]: """Write the dependency map expression to make dependent Parameters. + + Returns: + Dict[str, DescriptorNumber]: Dependency map for the width. """ return { 'D': self.diffusion_coefficient, @@ -269,10 +301,14 @@ def _write_area_dependency_expression(self, QISF: float) -> str: """Write the dependency expression for the area to make dependent Parameters. - Returns - ------- - str - Dependency expression for the area. + Args: + QISF (float): Quasielastic Incoherent Scattering Function. + + Returns: + str: Dependency expression for the area. + + Raises: + TypeError: If QISF is not a float. """ if not isinstance(QISF, (float)): raise TypeError('QISF must be a float.') @@ -282,6 +318,9 @@ def _write_area_dependency_expression(self, QISF: float) -> str: def _write_area_dependency_map_expression(self) -> Dict[str, DescriptorNumber]: """Write the dependency map expression to make dependent Parameters. + + Returns: + Dict[str, DescriptorNumber]: Dependency map for the area. """ return { 'scale': self.scale, @@ -294,6 +333,10 @@ def _write_area_dependency_map_expression(self) -> Dict[str, DescriptorNumber]: def __repr__(self): """String representation of the BrownianTranslationalDiffusion model. + + Returns: + str: String representation of the + BrownianTranslationalDiffusion model. """ return ( f'BrownianTranslationalDiffusion(display_name={self.display_name},' diff --git a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py index a6711334..096272ee 100644 --- a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py +++ b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py @@ -11,7 +11,21 @@ class DiffusionModelBase(ModelBase): - """Base class for constructing diffusion models.""" + """Base class for constructing diffusion models. + + Args: + display_name (str): Display name of the diffusion model. + unique_name (str | None): Unique name of the diffusion model. + If None, a unique name will be generated. + scale (Numeric): Scale factor for the diffusion model. Must be a + non-negative number. Defaults to 1.0. + unit (str | sc.Unit): Unit of the diffusion model. Must be + convertible to meV. Defaults to "meV". + + Attributes: + unit (str | sc.Unit): Unit of the diffusion model. + scale (Parameter): Scale parameter of the diffusion model. + """ def __init__( self, @@ -22,12 +36,19 @@ def __init__( ): """Initialize a new DiffusionModel. - Parameters - ---------- - display_name : str - Display name of the diffusion model. - unit : str or sc.Unit, optional - Unit of the diffusion model. Defaults to "meV". + Args: + display_name (str): Display name of the diffusion model. + unique_name (str | None): Unique name of the diffusion + model. If None, a unique name will be generated. + scale (Numeric): Scale factor for the diffusion model. Must + be a non-negative number. Defaults to 1.0. + unit (str | sc.Unit): Unit of the diffusion model. Must be + convertible to meV. Defaults to "meV". + + Raises: + TypeError: If scale is not a number. + UnitError: If unit is not a string or scipp Unit, or if it + cannot be converted to meV. """ if not isinstance(scale, Numeric): raise TypeError('scale must be a number.') @@ -52,16 +73,25 @@ def __init__( @property def unit(self) -> str: - """Get the unit of the DiffusionModel. + """Get the unit of the energy axis of the DiffusionModel. - Returns - ------- - str or sc.Unit or None + Returns: + (str | sc.Unit | None): Unit of the DiffusionModel. """ return str(self._unit) @unit.setter def unit(self, unit_str: str) -> None: + """The unit of the energy axis is read-only. To change the unit, + use convert_unit or create a new DiffusionModel with the desired + unit. + + Args: + unit_str (str): The new unit to set (ignored) + + Raises: + AttributeError: Always, since the unit is read-only. + """ raise AttributeError( ( f'Unit is read-only. Use convert_unit to change the unit between allowed types ' @@ -73,18 +103,28 @@ def unit(self, unit_str: str) -> None: def scale(self) -> Parameter: """Get the scale parameter of the diffusion model. - Returns - ------- - Parameter - Scale parameter. + Returns: + Parameter: scale parameter of the diffusion model """ return self._scale @scale.setter def scale(self, scale: Numeric) -> None: - """Set the scale parameter of the diffusion model.""" + """Set the scale parameter of the diffusion model. + + Args: + scale (Numeric): The new value for the scale parameter. Must + be a non-negative number. + + Raises: + TypeError: If scale is not a number. + ValueError: If scale is negative. + """ if not isinstance(scale, Numeric): raise TypeError('scale must be a number.') + + if float(scale) < 0: + raise ValueError('scale must be non-negative.') self._scale.value = scale # ------------------------------------------------------------------ @@ -92,5 +132,9 @@ def scale(self, scale: Numeric) -> None: # ------------------------------------------------------------------ def __repr__(self): - """String representation of the Diffusion model.""" + """String representation of the Diffusion model. + + Returns: + str: String representation of the DiffusionModel. + """ return f'{self.__class__.__name__}(display_name={self.display_name}, unit={self.unit})' 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 d6ab64b6..b7e28573 100644 --- a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py @@ -16,16 +16,48 @@ class JumpTranslationalDiffusion(DiffusionModelBase): - """Model of Jump translational diffusion, consisting of a Lorentzian - function for each Q-value, where the width is given by :math:`D - Q^2/(1+D t Q^2)`. Q is assumed to have units of 1/angstrom. Creates + r"""Model of Jump translational diffusion. The model consists of a + Lorentzian function for each Q-value, where the width is given by + + $$ + \Gamma(Q) = \frac{Q^2}{1+D t Q^2}. + $$ + + where $D$ is the diffusion coefficient and $t$ is the relaxation + time. Q is assumed to have units of 1/angstrom. Creates ComponentCollections with Lorentzian components for given Q-values. - Example usage: Q=np.linspace(0.5,2,7) energy=np.linspace(-2, 2, 501) - scale=1.0 diffusion_coefficient = 2.4e-9 # m^2/s - diffusion_model=JumpTranslationalDiffusion(display_name="DiffusionModel", - scale=scale, diffusion_coefficient= diffusion_coefficient) - component_collections=diffusion_model.create_component_collections(Q) + Args: + display_name (str): Display name of the diffusion model. + unique_name (str | None): Unique name of the diffusion model. If + None, a unique name will be generated. + unit (str | sc.Unit): Unit of the diffusion model. Must be + convertible to meV. Defaults to "meV". + scale (Numeric): Scale factor for the diffusion model. Must be + a non-negative number. Defaults to 1.0. + diffusion_coefficient (Numeric): Diffusion coefficient D in + m^2/s. Defaults to 1.0. + relaxation_time (Numeric): Relaxation time t in ps. Defaults to + 1.0. + + Attributes: + unit (str | sc.Unit): Unit of the diffusion model. + scale (Parameter): Scale parameter of the diffusion model. + diffusion_coefficient (Parameter): Diffusion coefficient D in + m^2/s. + relaxation_time (Parameter): Relaxation time t in ps. + + Example usage: + >>> Q = np.linspace(0.5, 2, 7) + >>> energy = np.linspace(-2, 2, 501) + >>> scale = 1.0 + >>> diffusion_coefficient = 2.4e-9 # m^2/s + >>> relaxation_time = 1.0 # ps + >>> diffusion_model=JumpTranslationalDiffusion( + >>> scale = scale, diffusion_coefficient = (diffusion_coefficient,) + >>> relaxation_time=relaxation_time) + >>> component_collections= + >>> diffusion_model.create_component_collections(Q) See also the examples. """ @@ -40,22 +72,24 @@ def __init__( ): """Initialize a new JumpTranslationalDiffusion model. - Parameters - ---------- - display_name : str - Display name of the diffusion model. - unique_name : str or None - Unique name of the diffusion model. If None, a unique name - is automatically generated. - unit : str or sc.Unit, optional - Energy unit for the underlying Lorentzian components. - Defaults to "meV". - scale : float, optional - Scale factor for the diffusion model. - diffusion_coefficient : float, optional - Diffusion coefficient D in m^2/s. Defaults to 1.0. - relaxation_time : float, optional - Relaxation time t in ps. Defaults to 1.0. + Args: + display_name (str): Display name of the diffusion model. + unique_name (str | None): Unique name of the diffusion model. If + None, a unique name will be generated. + unit (str | sc.Unit): Unit of the diffusion model. Must be + convertible to meV. Defaults to "meV". + scale (Numeric): Scale factor for the diffusion model. Must be + a non-negative number. Defaults to 1.0. + diffusion_coefficient (Numeric): Diffusion coefficient D in + m^2/s. Defaults to 1.0. + relaxation_time (Numeric): Relaxation time t in ps. Defaults to + 1.0. + + Raises: + TypeError: If scale, diffusion_coefficient, or relaxation_time + are not numbers. + ValueError: If scale is negative. + UnitError: If unit is not a string or scipp Unit """ super().__init__( display_name=display_name, @@ -97,56 +131,72 @@ def __init__( def diffusion_coefficient(self) -> Parameter: """Get the diffusion coefficient parameter D. - Returns - ------- - Parameter - Diffusion coefficient D. + Returns: + Parameter: Diffusion coefficient D. """ return self._diffusion_coefficient @diffusion_coefficient.setter def diffusion_coefficient(self, diffusion_coefficient: Numeric) -> None: - """Set the diffusion coefficient parameter D.""" + """Set the diffusion coefficient parameter D. + + Args: + diffusion_coefficient (Numeric): Diffusion coefficient D in + m^2/s. + + Raises: + TypeError: If diffusion_coefficient is not a number. + ValueError: If diffusion_coefficient is negative. + """ if not isinstance(diffusion_coefficient, Numeric): raise TypeError('diffusion_coefficient must be a number.') - self._diffusion_coefficient.value = diffusion_coefficient + if float(diffusion_coefficient) < 0: + raise ValueError('diffusion_coefficient must be non-negative.') + self._diffusion_coefficient.value = float(diffusion_coefficient) @property def relaxation_time(self) -> Parameter: """Get the relaxation time parameter t. - Returns - ------- - Parameter - Relaxation time t. + Returns: + Parameter: Relaxation time t in ps. """ return self._relaxation_time @relaxation_time.setter def relaxation_time(self, relaxation_time: Numeric) -> None: - """Set the relaxation time parameter t.""" + """Set the relaxation time parameter t. + + Args: + relaxation_time (Numeric): Relaxation time t in ps. + + Raises: + TypeError: If relaxation_time is not a number. + ValueError: If relaxation_time is negative. + """ if not isinstance(relaxation_time, Numeric): raise TypeError('relaxation_time must be a number.') - self._relaxation_time.value = relaxation_time + + if float(relaxation_time) < 0: + raise ValueError('relaxation_time must be non-negative.') + self._relaxation_time.value = float(relaxation_time) ################################ # Other methods ################################ def calculate_width(self, Q: Q_type) -> np.ndarray: - """Calculate the half-width at half-maximum (HWHM) for the - diffusion model. Equation: :math:`\\Gamma(Q) = \\hbar D Q^2/(1+D - t Q^2)` - - Parameters - ---------- - Q : np.ndarray | Numeric | list | ArrayLike - Scattering vector in 1/angstrom - - Returns - ------- - np.ndarray - HWHM values in the unit of the model (e.g., meV). + r"""Calculate the half-width at half-maximum (HWHM) for the + diffusion model. $\Gamma(Q) = Q^2/(1+D t Q^2)$, where $D$ is the + diffusion coefficient and $t$ is the relaxation time. + + Args: + Q (Q_type): Scattering vector in 1/angstrom. Can be a single + value or an array of values. + + Returns: + np.ndarray: HWHM values in the unit of the model (e.g., + meV). """ Q = _validate_and_convert_Q(Q) @@ -171,15 +221,12 @@ def calculate_width(self, Q: Q_type) -> np.ndarray: def calculate_EISF(self, Q: Q_type) -> np.ndarray: """Calculate the Elastic Incoherent Structure Factor (EISF). - Parameters - ---------- - Q : np.ndarray | Numeric | list | ArrayLike - Scattering vector in 1/angstrom + Args: + Q (Q_type): Scattering vector in 1/angstrom. Can be a single + value or an array of values. - Returns - ------- - np.ndarray - EISF values (dimensionless). + Returns: + np.ndarray: EISF values (dimensionless). """ Q = _validate_and_convert_Q(Q) EISF = np.zeros_like(Q) @@ -189,17 +236,12 @@ def calculate_QISF(self, Q: Q_type) -> np.ndarray: """Calculate the Quasi-Elastic Incoherent Structure Factor (QISF). - Parameters - ---------- - Q : np.ndarray | Numeric | list | ArrayLike - Scattering vector in 1/angstrom - - Returns - ------- - np.ndarray - QISF values (dimensionless). + Args: + Q (Q_type): Scattering vector in 1/angstrom. Can be a single + value or an array of values. + Returns: + np.ndarray: QISF values (dimensionless). """ - Q = _validate_and_convert_Q(Q) QISF = np.ones_like(Q) return QISF @@ -213,16 +255,17 @@ def create_component_collections( at given Q values. Args: - ---------- - Q : Number, list, or np.ndarray - Scattering vector values. - component_display_name : str - Name of the Jump Diffusion Lorentzian component. - Returns - ------- - List[ComponentCollection] - List of ComponentCollections with Jump Diffusion - Lorentzian components. + Q (Q_type): Scattering vector in 1/angstrom. Can be a single + value or an array of values. + component_display_name (str): Name of the Jump Diffusion + Lorentzian component. + + Returns: + List[ComponentCollection]: List of ComponentCollections with + Jump Diffusion Lorentzian components. + + Raises: + TypeError: If component_display_name is not a string. """ Q = _validate_and_convert_Q(Q) @@ -276,14 +319,11 @@ def _write_width_dependency_expression(self, Q: float) -> str: """Write the dependency expression for the width as a function of Q to make dependent Parameters. - Parameters - ---------- - Q : float - Scattering vector in 1/angstrom - Returns - ------- - str - Dependency expression for the width. + Args: + Q (float): Scattering vector in 1/angstrom + + Returns: + str: Dependency expression for the width. """ if not isinstance(Q, (float)): raise TypeError('Q must be a float.') @@ -294,6 +334,9 @@ def _write_width_dependency_expression(self, Q: float) -> str: def _write_width_dependency_map_expression(self) -> Dict[str, DescriptorNumber]: """Write the dependency map expression to make dependent Parameters. + + Returns: + Dict[str, DescriptorNumber]: Dependency map for the width. """ return { 'D': self._diffusion_coefficient, @@ -306,11 +349,13 @@ def _write_area_dependency_expression(self, QISF: float) -> str: """Write the dependency expression for the area to make dependent Parameters. - Returns - ------- - str - Dependency expression for the area. + Args: + QISF (float): Q-dependent intermediate scattering function. + + Returns: + str: Dependency expression for the area. """ + if not isinstance(QISF, (float)): raise TypeError('QISF must be a float.') @@ -319,6 +364,9 @@ def _write_area_dependency_expression(self, QISF: float) -> str: def _write_area_dependency_map_expression(self) -> Dict[str, DescriptorNumber]: """Write the dependency map expression to make dependent Parameters. + + Returns: + Dict[str, DescriptorNumber]: Dependency map for the area. """ return { 'scale': self._scale, @@ -331,6 +379,10 @@ def _write_area_dependency_map_expression(self) -> Dict[str, DescriptorNumber]: def __repr__(self): """String representation of the JumpTranslationalDiffusion model. + + Returns: + str: String representation of the JumpTranslationalDiffusion + model. """ return ( f'JumpTranslationalDiffusion(display_name={self.display_name}, ' diff --git a/src/easydynamics/sample_model/instrument_model.py b/src/easydynamics/sample_model/instrument_model.py index 33b6aacb..732955fc 100644 --- a/src/easydynamics/sample_model/instrument_model.py +++ b/src/easydynamics/sample_model/instrument_model.py @@ -22,28 +22,36 @@ class InstrumentModel(NewBase): function for convolutions, of the background and an offset in the energy axis. - Parameters - ---------- - display_name : str, optional - The display name of the InstrumentModel. Default is - "MyInstrumentModel". - unique_name : str or None, optional - The unique name of the InstrumentModel. Default is None. - Q : np.ndarray, list, scipp Variable or None, optional - The Q values where the instrument is modelled. - resolution_model : ResolutionModel or None, optional - The resolution model of the instrument. If None, an empty - resolution model is created and no resolution convolution is - carried out. Default is None. - background_model : BackgroundModel or None, optional - The background model of the instrument. If None, an empty - background model is created, and the background evaluates to 0. - Default is None. - energy_offset : float, int or None, optional - Template energy offset of the instrument. Will be copied to each - Q value. If None, the energy offset will be 0. Default is None. - unit : str or sc.Unit, optional - The unit of the energy axis. Default is 'meV'. + Args: + display_name (str | None): The display name of the + InstrumentModel. Default is "MyInstrumentModel". + unique_name (str | None): The unique name of the + InstrumentModel. Default is None. + Q (np.ndarray | list | sc.Variable | None): The Q values where + the instrument is modelled. + resolution_model (ResolutionModel | None): The resolution model + of the instrument. If None, an empty resolution model is + created and no resolution convolution is carried out. + Default is None. + background_model (BackgroundModel | None): The background model + of the instrument. If None, an empty background model is + created, and the background evaluates to 0. Default is None. + energy_offset (float | int | None): Template energy offset of + the instrument. Will be copied to each Q value. If None, the + energy offset will be 0. Default is None. + unit (str | sc.Unit): The unit of the energy axis. Default is + 'meV'. + + Attributes: + resolution_model (ResolutionModel): The resolution model of the + instrument. + background_model (BackgroundModel): The background model of the + instrument. + Q (np.ndarray | None): The Q values where the instrument is + modelled. + energy_offset (Parameter): The template energy offset Parameter + of the instrument. Will be copied to each Q value. + unit (str | sc.Unit): The unit of the energy axis. """ def __init__( @@ -104,12 +112,24 @@ def __init__( @property def resolution_model(self) -> ResolutionModel: - """Get the resolution model of the instrument.""" + """Get the resolution model of the instrument. + + Returns: + ResolutionModel: The resolution model of the instrument. + """ return self._resolution_model @resolution_model.setter def resolution_model(self, value: ResolutionModel): - """Set the resolution model of the instrument.""" + """Set the resolution model of the instrument. + + Args: + value (ResolutionModel): The new resolution model of the + instrument. + + Raises: + TypeError: If value is not a ResolutionModel. + """ if not isinstance(value, ResolutionModel): raise TypeError( f'resolution_model must be a ResolutionModel, got {type(value).__name__}' @@ -119,12 +139,26 @@ def resolution_model(self, value: ResolutionModel): @property def background_model(self) -> BackgroundModel: - """The background model of the instrument.""" + """Get the background model of the instrument. + + Returns: + BackgroundModel: The background model of the instrument. + """ + return self._background_model @background_model.setter def background_model(self, value: BackgroundModel): - """Set the background model of the instrument.""" + """Set the background model of the instrument. + + Args: + value (BackgroundModel): The new background model of the + instrument. + + Raises: + TypeError: If value is not a BackgroundModel. + """ + if not isinstance(value, BackgroundModel): raise TypeError( f'background_model must be a BackgroundModel, got {type(value).__name__}' @@ -134,12 +168,25 @@ def background_model(self, value: BackgroundModel): @property def Q(self) -> np.ndarray | None: - """Get the Q values of the InstrumentModel.""" + """Get the Q values of the InstrumentModel. + + Returns: + np.ndarray or None: The Q values of the InstrumentModel, or + None if not set + """ return self._Q @Q.setter def Q(self, value: Q_type | None) -> None: - """Set the Q values of the InstrumentModel.""" + """Set the Q values of the InstrumentModel. + + Args: + value (Q_type | None): The new Q values for the + InstrumentModel. + + Raises: + TypeError: If value is not a valid Q_type or None. + """ self._Q = _validate_and_convert_Q(value) self._on_Q_change() @@ -147,14 +194,25 @@ def Q(self, value: Q_type | None) -> None: def unit(self) -> sc.Unit: """Get the unit of the InstrumentModel. - Returns - ------- - str or sc.Unit or None + Returns: + (str | sc.Unit): The unit of the InstrumentModel. """ return self._unit @unit.setter def unit(self, unit_str: str) -> None: + """Set the unit of the InstrumentModel. The unit is read-only + and cannot be set directly. Use convert_unit to change the unit + between allowed types or create a new InstrumentModel with the + desired unit. + + Args: + unit_str (str): The new unit for the InstrumentModel + (ignored) + + Raises: + AttributeError: Always, as the unit is read-only. + """ raise AttributeError( ( f'Unit is read-only. Use convert_unit to change the unit between allowed types ' @@ -164,24 +222,25 @@ def unit(self, unit_str: str) -> None: @property def energy_offset(self) -> Parameter: - """The energy offset template parameter of the instrument + """Get the energy offset template parameter of the instrument model. + + Returns: + Parameter: The energy offset template parameter of the + instrument model. """ return self._energy_offset @energy_offset.setter def energy_offset(self, value: Numeric): - """Set the offset parameter of the instrument model.". - - Parameters - ---------- - value : float or int - The new value for the energy offset parameter. Will be - copied to all Q values. - Raises - ------ - TypeError - If value is not a number. + """Set the offset parameter of the instrument model. + + Args: + value (float | int): The new value for the energy offset + parameter. Will be copied to all Q values. + + Raises: + TypeError: If value is not a number. """ if not isinstance(value, Numeric): raise TypeError(f'energy_offset must be a number, got {type(value).__name__}') @@ -196,15 +255,13 @@ def energy_offset(self, value: Numeric): def convert_unit(self, unit_str: str | sc.Unit) -> None: """Convert the unit of the InstrumentModel. - Parameters - ---------- - unit_str : str or sc.Unit - The unit to convert to. + Args: + unit_str (str | sc.Unit): The unit to convert to. - Raises - ------ - TypeError - If unit_str is not a string or scipp Unit. + Raises: + TypeError: If unit_str is not a string or scipp Unit. + ValueError: If unit_str is not a valid unit string or + scipp Unit. """ unit = _validate_unit(unit_str) if unit is None: @@ -221,15 +278,21 @@ def convert_unit(self, unit_str: str | sc.Unit) -> None: def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: """Get all variables in the InstrumentModel. - Parameters - ---------- - Q_index : int | None - The index of the Q value to get variables for. If None, get - variables for all Q values. - Returns - ------- - list of Parameter - All variables in the InstrumentModel. + Args: + Q_index (int | None): The index of the Q value to get + variables for. If None, get variables for all Q values. + + Returns: + list[Parameter]: A list of all variables in the + InstrumentModel. If Q_index is specified, only variables + from the ComponentCollection at the given Q index are + included. Otherwise, all variables in the + InstrumentModel are included. + + Raises: + TypeError: If Q_index is not an int or None. + IndexError: If Q_index is out of bounds for the Q values in + the InstrumentModel. """ if self._Q is None: return [] @@ -261,20 +324,17 @@ def free_resolution_parameters(self) -> None: 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. + Args: + 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. + Returns: + Parameter: The energy offset Parameter at the specified Q + index. - Raises - ------ - IndexError - If Q_index is out of bounds. + Raises: + ValueError: If no Q values are set in the InstrumentModel. + IndexError: If Q_index is out of bounds. """ if self._Q is None: raise ValueError('No Q values are set in the InstrumentModel.') @@ -320,6 +380,12 @@ def _on_background_model_change(self) -> None: # ------------------------------------------------------------- def __repr__(self): + """Return a string representation of the InstrumentModel. + + Returns: + str: A string representation of the InstrumentModel. + """ + return ( f'{self.__class__.__name__}(' f'unique_name={self.unique_name!r}, ' diff --git a/src/easydynamics/sample_model/model_base.py b/src/easydynamics/sample_model/model_base.py index 570234a2..18098cd0 100644 --- a/src/easydynamics/sample_model/model_base.py +++ b/src/easydynamics/sample_model/model_base.py @@ -22,22 +22,25 @@ class ModelBase(EasyScienceModelBase): Contains common functionality for models with components and Q dependence. - Parameters - ---------- - display_name : str - Display name of the model. - unique_name : str | None - Unique name of the model. If None, a unique name will be - generated. - unit : str | sc.Unit | None - Unit of the model. If None, unitless. - components : ModelComponent | ComponentCollection | None - Template components of the model. If None, no components - are added. - These components are copied into ComponentCollections for each - Q value. - Q : Q_type | None - Q values for the model. If None, Q is not set. + Args: + display_name (str): Display name of the model. + unique_name (str | None): Unique name of the model. If None, a + unique name will be generated. + unit (str | sc.Unit | None): Unit of the model. Defaults to + "meV". + components (ModelComponent | ComponentCollection | None): + Template components of the model. If None, no components + are added. These components are copied into + ComponentCollections for each Q value. + Q (Q_type | None): Q values for the model. If None, Q is not + set. + + Attributes: + unit (str | sc.Unit): Unit of the model. + components (list[ModelComponent]): List of ModelComponents in + the model. + Q (np.ndarray | Numeric | list | ArrayLike | sc.Variable + | None): Q values of the model. """ def __init__( @@ -74,15 +77,21 @@ def evaluate( ) -> list[np.ndarray]: """Evaluate the sample model at all Q for the given x values. - Parameters - ---------- - x : Number, list, np.ndarray, sc.Variable, or sc.DataArray - Energy axis. - - Returns - ------- - list[np.ndarray] - Evaluated model values. + Args: + x (Numeric | list | np.ndarray | sc.Variable | sc.DataArray): + Energy axis values to evaluate the model at. If a scipp + Variable or DataArray is provided, the unit of the model + will be converted to match the unit of x for evaluation, and + the result will be returned in the same unit as x. + + Returns: + list[np.ndarray]: A list of numpy arrays containing the + evaluated model values for each Q. The length of the + list will match the number of Q values in the model. + + Raises: + ValueError: If there are no components in the model to + evaluate. """ if not self._component_collections: @@ -132,14 +141,23 @@ def clear_components(self) -> None: def unit(self) -> str | sc.Unit: """Get the unit of the ComponentCollection. - Returns - ------- - str or sc.Unit or None + Returns: + str | sc.Unit |None: The unit of the ComponentCollection. """ + return self._unit @unit.setter def unit(self, unit_str: str) -> None: + """Unit is read-only and cannot be set directly. + + Args: + unit_str (str): The new unit to set (ignored). + + Raises: + AttributeError: Always raised to indicate that the unit is + read-only. + """ raise AttributeError( ( f'Unit is read-only. Use convert_unit to change the unit between allowed types ' @@ -150,6 +168,14 @@ def unit(self, unit_str: str) -> None: def convert_unit(self, unit: str | sc.Unit) -> None: """Convert the unit of the ComponentCollection and all its components. + + Args: + unit (str | sc.Unit): The new unit to convert to. + + Raises: + TypeError: If the provided unit is not a string or sc.Unit. + UnitError: If the provided unit is not compatible with the + current unit. """ old_unit = self._unit @@ -170,12 +196,25 @@ def convert_unit(self, unit: str | sc.Unit) -> None: @property def components(self) -> list[ModelComponent]: - """Get the components of the SampleModel.""" + """Get the components of the SampleModel. + + Returns: + list[ModelComponent]: The components of the SampleModel. + """ return self._components.components @components.setter def components(self, value: ModelComponent | ComponentCollection | None) -> None: - """Set the components of the SampleModel.""" + """Set the components of the SampleModel. + + Args: + value (ModelComponent | ComponentCollection | None): The new + components to set. If None, all components will be cleared. + + Raises: + TypeError: If value is not a ModelComponent, + ComponentCollection, or None. + """ if not isinstance(value, (ModelComponent, ComponentCollection, type(None))): raise TypeError('Components must be a ModelComponent or a ComponentCollection') @@ -185,12 +224,22 @@ def components(self, value: ModelComponent | ComponentCollection | None) -> None @property def Q(self) -> np.ndarray | None: - """Get the Q values of the SampleModel.""" + """Get the Q values of the SampleModel. + + Returns: + np.ndarray | None: The Q values of the SampleModel, or None + if not set. + """ return self._Q @Q.setter def Q(self, value: Q_type | None) -> None: - """Set the Q values of the SampleModel.""" + """Set the Q values of the SampleModel. + + Args: + value (Q_type | None): The new Q values to set. If None, Q + will be unset. + """ old_Q = self._Q new_Q = _validate_and_convert_Q(value) @@ -223,16 +272,16 @@ def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: Parameters and Descriptors in self._components as these are just templates. - Parameters - ---------- - Q_index : int | None - If int, get variables for the ComponentCollection at - this index. If None, get variables for all - ComponentCollections. - Returns - ------- - list[Parameter] + Args: + Q_index (int | None): If None, get variables for all + ComponentCollections. If int, get variables for the + ComponentCollection at this index. Defaults to None. + + Returns: + list[Parameter]: A list of all Parameters and Descriptors + from the ComponentCollections in the ModelBase. """ + if Q_index is None: all_vars = [ var @@ -253,15 +302,17 @@ def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: 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. + Args: + Q_index (int): The index of the desired ComponentCollection. - Returns - ------- - ComponentCollection - The ComponentCollection at the specified Q index. + Returns: + ComponentCollection: The ComponentCollection at the + specified Q index. + + Raises: + TypeError: If Q_index is not an int. + IndexError: If Q_index is out of bounds for the number of + ComponentCollections. """ if not isinstance(Q_index, int): raise TypeError(f'Q_index must be an int, got {type(Q_index).__name__}') @@ -300,6 +351,11 @@ def _on_components_change(self) -> None: # ------------------------------------------------------------------ def __repr__(self): + """Return a string representation of the ModelBase. + + Returns: + str: A string representation of the ModelBase. + """ return ( f'{self.__class__.__name__}(unique_name={self.unique_name}, ' f'unit={self.unit}), Q = {self.Q}, components = {self.components}' diff --git a/src/easydynamics/sample_model/resolution_model.py b/src/easydynamics/sample_model/resolution_model.py index 16a1bccb..ae042b0c 100644 --- a/src/easydynamics/sample_model/resolution_model.py +++ b/src/easydynamics/sample_model/resolution_model.py @@ -16,21 +16,25 @@ class ResolutionModel(ModelBase): """ResolutionModel represents a model of the instrment resolution in an experiment at various Q. - Parameters - ---------- - display_name : str - Display name of the model. - unique_name : str | None - Unique name of the model. If None, a unique name will be - generated. - unit : str | sc.Unit | None - Unit of the model. If None, unitless. - components : ModelComponent | ComponentCollection | None - Template components of the model. If None, no components - are added. These components are copied into ComponentCollections - for each Q value. - Q : Number, list, np.ndarray or sc.Variable | None - Q values for the model. If None, Q is not set. + Args: + display_name (str): Display name of the model. + unique_name (str | None): Unique name of the model. If None, a + unique name will be generated. + unit (str | sc.Unit | None): Unit of the model. Defaults to + "meV". + components (ModelComponent | ComponentCollection | None): + Template components of the model. If None, no components + are added. These components are copied into + ComponentCollections for each Q value. + Q (Q_type | None): Q values for the model. If None, Q is not + set. + + Attributes: + unit (str | sc.Unit): Unit of the model. + components (list[ModelComponent]): List of ModelComponents in + the model. + Q (np.ndarray | Numeric | list | ArrayLike | sc.Variable + | None): Q values of the model. """ def __init__( @@ -54,9 +58,11 @@ def append_component(self, component: ModelComponent | ComponentCollection): Does not allow DeltaFunction or Polynomial components, as these are not physical resolution components. + Args: component (ModelComponent | ComponentCollection): Component(s) to append. + Raises: TypeError: If the component is a DeltaFunction or Polynomial """ diff --git a/src/easydynamics/sample_model/sample_model.py b/src/easydynamics/sample_model/sample_model.py index ba21e869..ce94bbe5 100644 --- a/src/easydynamics/sample_model/sample_model.py +++ b/src/easydynamics/sample_model/sample_model.py @@ -22,33 +22,28 @@ class SampleModel(ModelBase): components from the base model and diffusion models. Applies detailed balancing based on temperature if provided. - Parameters - ---------- - display_name : str - Display name of the model. - unique_name : str | None - Unique name of the model. If None, a unique name will be - generated. - unit : str | sc.Unit | None - Unit of the model. If None, unitless. - components : ModelComponent | ComponentCollection | None - Template components of the model. If None, no components are - added. These components are copied into ComponentCollections - for each Q value. - Q : Number, list, np.ndarray or sc.array or None. - Q values for the model. If None, Q is not set. - diffusion_models : DiffusionModelBase | list[DiffusionModelBase] - | None - Diffusion models to include in the SampleModel. If None, - no diffusion models are added - temperature : float | None - Temperature for detailed balancing. - If None, no detailed balancing is applied. - temperature_unit : str | sc.Unit - Unit of the temperature. Defaults to "K". - divide_by_temperature : bool - Whether to divide the detailed balance factor by temperature. - Defaults to True. + + Args: + display_name (str): Display name of the model. + unique_name (str | None): Unique name of the model. If None, a + unique name will be generated. + unit (str | sc.Unit | None): Unit of the model. If None, + defaults to "meV". + components (ModelComponent | ComponentCollection | None): + Template components of the model. If None, no components are + added. These components are copied into ComponentCollections + for each Q value. + Q (Number, list, np.ndarray, sc.array | None): + Q values for the model. If None, Q is not set. + diffusion_models (DiffusionModelBase | list[DiffusionModelBase] + | None): Diffusion models to include in the SampleModel. + If None, no diffusion models are added. + temperature (float | None): Temperature for detailed balancing. + If None, no detailed balancing is applied. + temperature_unit (str | sc.Unit): Unit of the temperature. + Defaults to "K". + divide_by_temperature (bool): Whether to divide the detailed + balance factor by temperature. Defaults to True. """ def __init__( @@ -115,7 +110,11 @@ def append_diffusion_model(self, diffusion_model: DiffusionModelBase) -> None: Args: diffusion_model (DiffusionModelBase): The DiffusionModel - to append. + to append. + + Raises: + TypeError: If the diffusion_model is not a + DiffusionModelBase """ if not isinstance(diffusion_model, DiffusionModelBase): @@ -131,6 +130,10 @@ def remove_diffusion_model(self, name: 'str') -> None: Args: name (str): The unique name of the DiffusionModel to remove. + + Raises: + ValueError: If no DiffusionModel with the given unique name + is found. """ for i, dm in enumerate(self._diffusion_models): if dm.unique_name == name: @@ -153,14 +156,28 @@ def clear_diffusion_models(self) -> None: @property def diffusion_models(self) -> list[DiffusionModelBase]: - """Get the diffusion models of the SampleModel.""" + """Get the diffusion models of the SampleModel. + + Returns: + list[DiffusionModelBase]: The diffusion models of the + SampleModel. + """ return self._diffusion_models @diffusion_models.setter def diffusion_models( self, value: DiffusionModelBase | list[DiffusionModelBase] | None ) -> None: - """Set the diffusion models of the SampleModel.""" + """Set the diffusion models of the SampleModel. + + Args: + value (DiffusionModelBase | list[DiffusionModelBase] | + None): + The diffusion model(s) to set. Can be a single + DiffusionModelBase, a list of DiffusionModelBase, or + None to clear all diffusion models. + """ + if value is None: self._diffusion_models = [] return @@ -179,12 +196,22 @@ def diffusion_models( @property def temperature(self) -> Parameter | None: - """Get the temperature of the SampleModel.""" + """Get the temperature of the SampleModel. + + Returns: + Parameter | None: The temperature Parameter of the + SampleModel, or None if not set. + """ return self._temperature @temperature.setter def temperature(self, value: Numeric | None) -> None: - """Set the temperature of the SampleModel.""" + """Set the temperature of the SampleModel. + + Args: + value (Numeric | None): The temperature value to set. Can be + a number or None to unset the temperature. + """ if value is None: self._temperature = None return @@ -208,18 +235,40 @@ def temperature(self, value: Numeric | None) -> None: @property def temperature_unit(self) -> str | sc.Unit: - """Get the temperature unit of the SampleModel.""" + """Get the temperature unit of the SampleModel. + + Returns: + str | sc.Unit: The unit of the temperature Parameter. + """ return self._temperature_unit @temperature_unit.setter def temperature_unit(self, value: str | sc.Unit) -> None: + """The temperature unit of the SampleModel is read-only. + + Args: + value (str | sc.Unit): The unit to set for the temperature + Parameter. + + Raises: + AttributeError: Always, as temperature_unit is read-only. + """ + raise AttributeError( f'Temperature_unit is read-only. Use convert_temperature_unit to change the unit between allowed types ' # noqa: E501 f'or create a new {self.__class__.__name__} with the desired unit.' ) # noqa: E501 def convert_temperature_unit(self, unit: str | sc.Unit) -> None: - """Convert the unit of the temperature Parameter.""" + """Convert the unit of the temperature Parameter. + + Args: + unit (str | sc.Unit): The unit to convert the temperature + Parameter to. + + Raises: + ValueError: If temperature is not set or conversion fails. + """ if self._temperature is None: raise ValueError('Temperature is not set, cannot convert unit.') @@ -241,6 +290,10 @@ def convert_temperature_unit(self, unit: str | sc.Unit) -> None: def divide_by_temperature(self) -> bool: """Get whether to divide the detailed balance factor by temperature. + + Returns: + bool: True if the detailed balance factor is divided by + temperature, False otherwise. """ return self._divide_by_temperature @@ -248,6 +301,10 @@ def divide_by_temperature(self) -> bool: def divide_by_temperature(self, value: bool) -> None: """Set whether to divide the detailed balance factor by temperature. + + Args: + value (bool): True to divide the detailed balance factor by + temperature, False otherwise. """ if not isinstance(value, bool): raise TypeError('divide_by_temperature must be True or False') @@ -262,15 +319,14 @@ def evaluate( ) -> list[np.ndarray]: """Evaluate the sample model at all Q for the given x values. - Parameters - ---------- - x : Number, list, np.ndarray, sc.Variable, or sc.DataArray - Energy axis. + Args: + x (Numeric | list | np.ndarray | sc.Variable | + sc.DataArray): + The x values to evaluate the model at. Can be a number, + list, numpy array, scipp Variable, or scipp DataArray. - Returns - ------- - list[np.ndarray] - List of evaluated model values for each Q. + Returns: + list[np.ndarray]: List of evaluated model values for each Q. """ y = super().evaluate(x) @@ -293,6 +349,11 @@ def get_all_variables(self, Q_index: int | None = None) -> list[Parameter]: Also includes temperature if set and all variables from diffusion models. Ignores the Parameters and Descriptors in self._components as these are just templates. + + Args: + Q_index (int | None): If specified, only get variables from + the ComponentCollection at the given Q index. If None, + get variables from all ComponentCollections. """ all_vars = super().get_all_variables(Q_index=Q_index) @@ -335,6 +396,12 @@ def _on_diffusion_models_change(self) -> None: # ------------------------------------------------------------------ def __repr__(self): + """Return a string representation of the SampleModel. + + Returns: + str: A string representation of the SampleModel. + """ + return ( f'{self.__class__.__name__}(unique_name={self.unique_name}, unit={self._unit}), ' f'Q = {self._Q}, ' diff --git a/tests/conftest.py b/tests/conftest.py index 0bca2e2a..98e27afe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,69 +1,2 @@ # SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors # SPDX-License-Identifier: BSD-3-Clause - -# Local fixture to reset global object map for problematic test -# TODO: remove once weakref bug is fixed - - -from unittest.mock import patch - -import pytest - - -@pytest.fixture(autouse=True) -def patch_easyscience_map(): - """Patch the problematic Map methods.""" - from easyscience.global_object.map import Map - - # Store the original methods - original_add_vertex = Map.add_vertex - # original_vertices = Map.vertices - - def safe_add_vertex(self, obj: object, obj_type: str = None): - try: - return original_add_vertex(self, obj, obj_type) - except KeyError: - # Object was garbage collected during setup - name = obj.unique_name - # Clean up any partial state - if hasattr(self, '_Map__type_dict') and name in self._Map__type_dict: - del self._Map__type_dict[name] - if name in self._store: - del self._store[name] - - def safe_vertices(self): - """Safe version of vertices() that handles dictionary changes - during iteration.""" - max_retries = 3 - for attempt in range(max_retries): - try: - return list(self._store.keys()) - except RuntimeError as e: - if 'dictionary changed size during iteration' in str(e): - if attempt < max_retries - 1: - # Force cleanup and try again - import gc - - gc.collect() - continue - else: - # Last attempt - return what we can get - try: - # Try to get keys in a different way - keys = [] - for k in list(self._store.data.keys()): - if k in self._store: - keys.append(k) - return keys - except: # noqa: E722 - return [] - else: - raise - return [] - - # Apply the patches - with ( - patch.object(Map, 'add_vertex', safe_add_vertex), - patch.object(Map, 'vertices', safe_vertices), - ): - yield diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py index 0d0963c0..cbe57926 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py @@ -77,6 +77,11 @@ def test_diffusion_coefficient_setter_raises(self, brownian_diffusion_model): with pytest.raises(TypeError, match='diffusion_coefficient must be a number.'): brownian_diffusion_model.diffusion_coefficient = 'invalid' # Invalid type + def test_diffusion_coefficient_setter_negative_raises(self, brownian_diffusion_model): + # WHEN THEN EXPECT + with pytest.raises(ValueError, match='diffusion_coefficient must be non-negative.'): + brownian_diffusion_model.diffusion_coefficient = -1.0 # Invalid negative value + def test_calculate_width_type_error(self, brownian_diffusion_model): # WHEN THEN EXPECT with pytest.raises(TypeError, match='Q must be '): diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py index f053e4cb..e7e726d4 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py @@ -31,6 +31,11 @@ def test_scale_setter(self, diffusion_model): # THEN EXPECT assert diffusion_model.scale.value == 2.0 + def test_scale_setter_negative_raises(self, diffusion_model): + # WHEN THEN EXPECT + with pytest.raises(ValueError, match='scale must be non-negative.'): + diffusion_model.scale = -1.0 # Invalid negative value + def test_scale_setter_raises(self, diffusion_model): # WHEN THEN EXPECT with pytest.raises(TypeError, match='scale must be a number.'): diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py index 90a842d6..744e176b 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py @@ -88,6 +88,11 @@ def test_diffusion_coefficient_setter_raises(self, jump_diffusion_model): with pytest.raises(TypeError, match='diffusion_coefficient must be a number.'): jump_diffusion_model.diffusion_coefficient = 'invalid' # Invalid type + def test_diffusion_coefficient_setter_negative_raises(self, jump_diffusion_model): + # WHEN THEN EXPECT + with pytest.raises(ValueError, match='diffusion_coefficient must be non-negative.'): + jump_diffusion_model.diffusion_coefficient = -1.0 # Invalid negative value + def test_relaxation_time_setter(self, jump_diffusion_model): # WHEN jump_diffusion_model.relaxation_time = 2.5 @@ -100,6 +105,11 @@ def test_relaxation_time_setter_raises(self, jump_diffusion_model): with pytest.raises(TypeError, match='relaxation_time must be a number.'): jump_diffusion_model.relaxation_time = 'invalid' # Invalid type + def test_relaxation_time_setter_negative_raises(self, jump_diffusion_model): + # WHEN THEN EXPECT + with pytest.raises(ValueError, match='relaxation_time must be non-negative.'): + jump_diffusion_model.relaxation_time = -1.0 # Invalid negative value + def test_calculate_width_type_error(self, jump_diffusion_model): # WHEN THEN EXPECT with pytest.raises(TypeError, match='Q must be '): From ada007b413e1b24ee186309db4b36db4746064a8 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Wed, 4 Mar 2026 15:34:21 +0100 Subject: [PATCH 8/9] Even more docstrings (#113) * Update convolution * Fix most docstrings * pixi formatting * change gaussian a bit * Update analysis docstrings * docstrings for convolution * docstrings for experiment * Update sample models * Update diffusion models * Updatae components * Update utils * linting * more linting --- docs/docs/api-reference/analysis.md | 1 + docs/docs/api-reference/index.md | 14 +- pixi.lock | 28 +- src/easydynamics/analysis/analysis.py | 51 ++-- src/easydynamics/analysis/analysis1d.py | 50 +++- src/easydynamics/analysis/analysis_base.py | 44 ++- .../convolution/analytical_convolution.py | 267 ++++++++++-------- src/easydynamics/convolution/convolution.py | 137 ++++++--- .../convolution/convolution_base.py | 116 ++++++-- src/easydynamics/convolution/energy_grid.py | 21 +- .../convolution/numerical_convolution.py | 101 +++++-- .../convolution/numerical_convolution_base.py | 198 ++++++++----- src/easydynamics/experiment/experiment.py | 17 +- .../sample_model/background_model.py | 15 + .../components/damped_harmonic_oscillator.py | 31 +- .../sample_model/components/delta_function.py | 17 ++ .../sample_model/components/gaussian.py | 27 +- .../sample_model/components/lorentzian.py | 21 +- .../components/model_component.py | 17 +- .../sample_model/components/polynomial.py | 19 ++ .../sample_model/components/voigt.py | 30 +- .../brownian_translational_diffusion.py | 6 +- .../diffusion_model/diffusion_model_base.py | 2 +- .../jump_translational_diffusion.py | 33 +-- .../sample_model/instrument_model.py | 33 ++- src/easydynamics/sample_model/model_base.py | 30 +- .../sample_model/resolution_model.py | 25 +- src/easydynamics/sample_model/sample_model.py | 51 +++- src/easydynamics/utils/detailed_balance.py | 17 +- src/easydynamics/utils/utils.py | 37 +-- .../analysis/test_analysis_base.py | 2 +- 31 files changed, 1046 insertions(+), 412 deletions(-) create mode 100644 docs/docs/api-reference/analysis.md diff --git a/docs/docs/api-reference/analysis.md b/docs/docs/api-reference/analysis.md new file mode 100644 index 00000000..ab949729 --- /dev/null +++ b/docs/docs/api-reference/analysis.md @@ -0,0 +1 @@ +::: easydynamics.analysis diff --git a/docs/docs/api-reference/index.md b/docs/docs/api-reference/index.md index 6e4cfb8d..7c211941 100644 --- a/docs/docs/api-reference/index.md +++ b/docs/docs/api-reference/index.md @@ -7,6 +7,14 @@ icon: material/code-braces-box This section contains the reference detailing the functions and modules available in EasyDynamics. -- [convolution](convolution.md) – Contains ... -- [sample_model](sample_model.md) – Handles ... -- [utils](utils.md) – Miscellaneous utility functions for ... +- [convolution](convolution.md) – Handles convolution of the sample + model with the instrument resolution. +- [experiment] (experiment.md) - Load, manage and visualize experimental + data. +- [sample_model](sample_model.md) – All modelling in EasyDynamics: The + scattering from the sample, including individual model components and + diffusion models, background, resolution and instrument. +- [analysis] (analysis.md) - Analysing experimental data by fitting a + sample model to experimental data, optionally convoluted with a + resolution model and adding a background. +- [utils](utils.md) – Miscellaneous utility functions for EasyDynamics. diff --git a/pixi.lock b/pixi.lock index 2e77e94b..1a38c4ea 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 + - pypi: git+https://github.com/easyscience/corelib.git#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 + - pypi: git+https://github.com/easyscience/corelib.git#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 + - pypi: git+https://github.com/easyscience/corelib.git#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 + - pypi: git+https://github.com/easyscience/corelib.git#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 + - pypi: git+https://github.com/easyscience/corelib.git#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 + - pypi: git+https://github.com/easyscience/corelib.git#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 + - pypi: git+https://github.com/easyscience/corelib.git#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 + - pypi: git+https://github.com/easyscience/corelib.git#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 + - pypi: git+https://github.com/easyscience/corelib.git#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 + - pypi: git+https://github.com/easyscience/corelib.git#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 + - pypi: git+https://github.com/easyscience/corelib.git#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 + - pypi: git+https://github.com/easyscience/corelib.git#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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,9 +4134,9 @@ packages: - validate-pyproject[all] ; extra == 'dev' - versioningit ; extra == 'dev' requires_python: '>=3.11' -- pypi: git+https://github.com/easyscience/corelib.git#ac01d891e271c7e2e5044da69b9ecd7b7114f0c3 +- pypi: git+https://github.com/easyscience/corelib.git#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 name: easyscience - version: 2.1.0 + version: 2.2.0 requires_dist: - asteval - bumps diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index cee393b1..5332215b 100644 --- a/src/easydynamics/analysis/analysis.py +++ b/src/easydynamics/analysis/analysis.py @@ -35,8 +35,8 @@ class Analysis(AnalysisBase): instrument_model (InstrumentModel | None): The InstrumentModel associated with this Analysis. If None, a default InstrumentModel is created. - extra_parameters (Parameter | list[Parameter] | None): - Extra parameters to be included in the analysis for advanced + extra_parameters (Parameter | list[Parameter] | None): Extra + parameters to be included in the analysis for advanced users. If None, no extra parameters are added. Attributes: @@ -53,7 +53,7 @@ class Analysis(AnalysisBase): temperature (Parameter | None): The temperature from the associated SampleModel, if available. extra_parameters (list[Parameter]): The extra parameters - included in this Analysis. + included in this Analysis. """ def __init__( @@ -65,6 +65,25 @@ def __init__( instrument_model: InstrumentModel | None = None, extra_parameters: Parameter | list[Parameter] | None = None, ): + """Initialize an Analysis object. + + Args: + display_name (str): Display name of the analysis. + unique_name (str or None): Unique name of the analysis. If + None, a unique name is automatically generated. + experiment (Experiment | None): The Experiment associated + with this Analysis. If None, a default Experiment is + created. + sample_model (SampleModel | None): The SampleModel + associated with this Analysis. If None, a default + SampleModel is created. + instrument_model (InstrumentModel | None): The + InstrumentModel associated with this Analysis. If None, + a default InstrumentModel is created. + extra_parameters (Parameter | list[Parameter] | None): Extra + parameters to be included in the analysis for advanced + users. If None, no extra parameters are added. + """ # Avoid triggering updates before the object is fully # initialized @@ -138,7 +157,7 @@ def calculate( Args: Q_index (int or None): Index of the Q value to calculate - for. If None, calculate for all Q values. + for. If None, calculate for all Q values. Returns: list[np.ndarray] | np.ndarray: If Q_index is None, returns @@ -173,9 +192,9 @@ def fit( independently. Ignored if fit_method is "simultaneous". Default is None. - Returns: Fit results, which may be a list of FitResults if - fitting independently, or a single FitResults object if - fitting simultaneously. + Returns: + FitResults: a list of FitResults if fitting independently, + or a single FitResults object if fitting simultaneously. Raises: ValueError: If fit_method is not "independent" or @@ -215,13 +234,13 @@ def plot_data_and_model( Args: Q_index (int or None): Index of the Q value to plot. If - None, plot all Q values. Default is None. + None, plot all Q values. Default is None. plot_components (bool): Whether to plot the individual components. Default is True. add_background (bool): Whether to add background components to the sample model components when plotting. Default is True. - **kwargs: Additional keyword arguments passed to plopp + **kwargs (Any): Additional keyword arguments passed to plopp for customizing the plot. Raises: @@ -366,11 +385,11 @@ def plot_parameters( """Plot fitted parameters as a function of Q. Args: - names (str | list[str] | None): 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). + names (str | list[str] | None): Name(s) of the parameter(s) + to plot. If None, plots all parameters. + kwargs (Any): Additional keyword arguments passed to + plopp.slicer for customizing the plot (e.g., title, + linestyle, marker, color). Returns: InteractiveFigure: A Plopp InteractiveFigure containing the @@ -523,7 +542,7 @@ def _create_model_array(self) -> sc.DataArray: Returns: sc.DataArray: A DataArray containing the model values, with - dimensions "Q" and "energy". + dimensions "Q" and "energy". """ model = sc.array(dims=['Q', 'energy'], values=self.calculate()) @@ -547,7 +566,7 @@ def _create_components_dataset(self, add_background: bool = True) -> sc.Dataset: Returns: sc.Dataset: A scipp Dataset where each entry is a component - of the model, with dimensions "Q". + of the model, with dimensions "Q". """ if not isinstance(add_background, bool): raise TypeError('add_background must be True or False.') diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index 07fdf6ec..54bdab91 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -38,8 +38,8 @@ class Analysis1d(AnalysisBase): Q_index (int | None): The Q index to analyze. If None, the analysis will not be able to calculate or fit until a Q index is set. - extra_parameters (Parameter | list[Parameter] | None): - Extra parameters to be included in the analysis for advanced + extra_parameters (Parameter | list[Parameter] | None): Extra + parameters to be included in the analysis for advanced users. If None, no extra parameters are added. Attributes: @@ -57,7 +57,7 @@ class Analysis1d(AnalysisBase): associated SampleModel, if available. Q_index (int | None): The Q index being analyzed. extra_parameters (list[Parameter]): The extra parameters - included in this Analysis. + included in this Analysis. """ def __init__( @@ -70,6 +70,28 @@ def __init__( Q_index: int | None = None, extra_parameters: Parameter | list[Parameter] | None = None, ): + """Initialize a Analysis1d. + + Args: + display_name (str): Display name of the analysis. + unique_name (str or None): Unique name of the analysis. If + None, a unique name is automatically generated. + experiment (Experiment | None): The Experiment associated + with this Analysis. If None, a default Experiment is + created. + sample_model (SampleModel | None): The SampleModel + associated with this Analysis. If None, a default + SampleModel is created. + instrument_model (InstrumentModel | None): The + InstrumentModel associated with this Analysis. If None, + a default InstrumentModel is created. + Q_index (int | None): The Q index to analyze. If None, the + analysis will not be able to calculate or fit until a + Q index is set. + extra_parameters (Parameter | list[Parameter] | None): Extra + parameters to be included in the analysis for advanced + users. If None, no extra parameters are added. + """ super().__init__( display_name=display_name, unique_name=unique_name, @@ -96,7 +118,7 @@ def Q_index(self) -> int | None: """Get the Q index associated with this Analysis. Returns: - Experiment: The Experiment associated with this Analysis. + int | None: The Q index associated with this Analysis. """ return self._Q_index @@ -189,8 +211,9 @@ def as_fit_function(self, x=None, **kwargs) -> callable: calculated model. Args: - x: Ignored. The energy grid is taken from the experiment. - kwargs: Ignored. Included for compatibility with the + x (Any): Ignored. The energy grid is taken from the + experiment. + kwargs (dict): Ignored. Included for compatibility with the EasyScience fitter. """ @@ -203,7 +226,7 @@ def get_all_variables(self) -> list[DescriptorNumber]: """Get all variables used in the analysis. Returns: - List[Descriptor]: A list of all variables. + list[DescriptorNumber]: A list of all variables. """ variables = self.sample_model.get_all_variables(Q_index=self.Q_index) @@ -231,7 +254,7 @@ def plot_data_and_model( components of the model. Default is True. add_background (bool): Whether to add the background to the model prediction when plotting individual components. - kwargs: Keyword arguments to pass to the plotting + kwargs (dict): Keyword arguments to pass to the plotting function. Returns: @@ -327,14 +350,13 @@ def _evaluate_components( convolution (for background). Args: - components (ComponentCollection | ModelComponent): - The components to evaluate. + components (ComponentCollection | ModelComponent): The + components to evaluate. 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. + convolve (bool): Whether to perform convolution with the + resolution. Default is True. """ Q_index = self._require_Q_index() @@ -541,7 +563,7 @@ def _create_components_dataset_single_Q( Returns: dict[str, sc.DataArray]: A dictionary of component names to - their corresponding sc.DataArrays. + their corresponding sc.DataArrays. """ scipp_arrays = {} diff --git a/src/easydynamics/analysis/analysis_base.py b/src/easydynamics/analysis/analysis_base.py index 07136062..24be2e28 100644 --- a/src/easydynamics/analysis/analysis_base.py +++ b/src/easydynamics/analysis/analysis_base.py @@ -33,8 +33,8 @@ class AnalysisBase(EasyScienceModelBase): instrument_model (InstrumentModel | None): The InstrumentModel associated with this Analysis. If None, a default InstrumentModel is created. - extra_parameters (Parameter | list[Parameter] | None): - Extra parameters to be included in the analysis for advanced + extra_parameters (Parameter | list[Parameter] | None): Extra + parameters to be included in the analysis for advanced users. If None, no extra parameters are added. Attributes: @@ -51,7 +51,7 @@ class AnalysisBase(EasyScienceModelBase): temperature (Parameter | None): The temperature from the associated SampleModel, if available. extra_parameters (list[Parameter]): The extra parameters - included in this Analysis. + included in this Analysis. """ def __init__( @@ -63,6 +63,34 @@ def __init__( instrument_model: InstrumentModel | None = None, extra_parameters: Parameter | list[Parameter] | None = None, ): + """Initialize the AnalysisBase. + + Args: + display_name (str): Display name of the analysis. + unique_name (str or None): Unique name of the analysis. If + None, a unique name is automatically generated. + experiment (Experiment | None): The Experiment associated + with this Analysis. If None, a default Experiment is + created. + sample_model (SampleModel | None): The SampleModel + associated with this Analysis. If None, a default + SampleModel is created. + instrument_model (InstrumentModel | None): The + InstrumentModel associated with this Analysis. If None, + a default InstrumentModel is created. + extra_parameters (Parameter | list[Parameter] | None): Extra + parameters to be included in the analysis for advanced + users. If None, no extra parameters are added. + + Raises: + TypeError: If experiment is not an Experiment or None. + TypeError: If sample_model is not a SampleModel or None. + TypeError: If instrument_model is not an InstrumentModel or + None. + TypeError: If extra_parameters is not a Parameter, a list of + Parameters, or None. + """ + super().__init__(display_name=display_name, unique_name=unique_name) if experiment is None: @@ -257,18 +285,20 @@ def extra_parameters(self, value: Parameter | list[Parameter]) -> None: Args: value (Parameter | list[Parameter]): The extra parameters to - include in this Analysis. + include in this Analysis. Raises: - TypeError: If value is not a Parameter or a list of - Parameters. + TypeError: If value is not a Parameter, a list of + Parameters, or None. """ if isinstance(value, Parameter): self._extra_parameters = [value] elif isinstance(value, list) and all(isinstance(p, Parameter) for p in value): self._extra_parameters = value + elif value is None: + self._extra_parameters = [] else: - raise TypeError('extra_parameters must be a Parameter or a list of Parameters.') + raise TypeError('extra_parameters must be a Parameter, a list of Parameters, or None.') ############# # Other methods diff --git a/src/easydynamics/convolution/analytical_convolution.py b/src/easydynamics/convolution/analytical_convolution.py index 031d5975..a637e8fd 100644 --- a/src/easydynamics/convolution/analytical_convolution.py +++ b/src/easydynamics/convolution/analytical_convolution.py @@ -22,14 +22,18 @@ class AnalyticalConvolution(ConvolutionBase): Possible analytical convolutions are any combination of delta functions, Gaussians, Lorentzians and Voigt profiles. + Args: - energy : np.ndarray or scipp.Variable - 1D array of energy values where the convolution is - evaluated. - sample_components : ComponentCollection or ModelComponent - The sample model to be convolved. - resolution_components : ComponentCollection or ModelComponent + energy (np.ndarray | sc.Variable): 1D array of energy values + where the convolution is evaluated. + sample_components (ComponentCollection | ModelComponent): The + sample model to be convolved. + resolution_components (ComponentCollection | ModelComponent): The resolution model to convolve with. + energy_offset (Numeric | Parameter, optional): An offset to + shift the energy values by. Default is 0.0. + energy_unit (str | sc.Unit, optional): The unit of the energy. + Default is 'meV'. """ # Mapping of supported component type pairs to convolution methods. @@ -51,6 +55,20 @@ def __init__( resolution_components: ComponentCollection | ModelComponent | None = None, energy_offset: Numeric | Parameter = 0.0, ): + """Initialize an AnalyticalConvolution. + + Args: + energy (np.ndarray | sc.Variable): 1D array of energy values + where the convolution is evaluated. + sample_components (ComponentCollection | ModelComponent): + The sample model to be convolved. + resolution_components (ComponentCollection | + ModelComponent): The resolution model to convolve with. + energy_offset (Numeric | Parameter, optional): An offset to + shift the energy values by. Default is 0.0. + energy_unit (str | sc.Unit, optional): The unit of the + energy. Default is 'meV'. + """ super().__init__( energy=energy, energy_unit=energy_unit, @@ -68,15 +86,14 @@ def convolution( functions, Gaussians, Lorentzians and Voigt profiles. Returns: - np.ndarray - The convolution of the sample_components and resolution_ - components values evaluated at energy. + np.ndarray: The convolution of the sample_components and + resolution_components values evaluated at self.energy. Raises: - ValueError - If resolution_components contains delta functions. - ValueError - If component pair cannot be handled analytically. + ValueError: If resolution_components contains delta + functions. + ValueError: If component pair cannot be handled + analytically. """ sample_components = self.sample_components.components @@ -101,37 +118,48 @@ def _convolute_analytic_pair( sample_component: ModelComponent, resolution_component: ModelComponent, ) -> np.ndarray: - """Analytic convolution for component pair (sample_component, - resolution_component). The convolution of two gaussian - components results in another gaussian component with width - sqrt(w1^2 + w2^2). The convolution of two lorentzian components - results in another lorentzian component with width w1 + w2. The - convolution of a gaussian and a lorentzian results in a voigt - profile. The convolution of a gaussian and a voigt profile - results in another voigt profile, with the lorentzian width - unchanged and the gaussian widths summed in quadrature. The - convolution of a lorentzian and a voigt profile results in - another voigt profile, with the gaussian width unchanged and the - lorentzian widths summed. The convolution of two voigt profiles - results in another voigt profile, with the gaussian widths - summed in quadrature and the lorentzian widths summed. The - convolution of a delta function with any component or + r"""Analytic convolution for component pair (sample_component, + resolution_component). + + The convolution of two Gaussian components results in another + Gaussian component with width $\sqrt{w_1^2 + w_2^2}$. + + The convolution of two Lorentzian components results in another + Lorentzian component with width $w_1 + w_2$. + + The convolution of a Gaussian and a Lorentzian results in a + Voigt profile. + + The convolution of a Gaussian and a Voigt profile results in + another Voigt profile, with the Lorentzian width unchanged and + the Gaussian widths summed in quadrature. + + The convolution of a Lorentzian and a Voigt profile results in + another Voigt profile, with the Gaussian width unchanged and the + Lorentzian widths summed. + + The convolution of two Voigt profiles results in another Voigt + profile, with the Gaussian widths summed in quadrature and the + Lorentzian widths summed. + + The convolution of a delta function with any component or ComponentCollection results in the same component or - ComponentCollection shifted by the delta center. All areas are - multiplied. + ComponentCollection shifted by the delta center. + + All areas are multiplied in the convolution. Args: - sample_component : ModelComponent - The sample component to be convolved. - resolution_component : ModelComponent - The resolution component to convolve with. + sample_component (ModelComponent): The sample component to + be convolved. + resolution_component (ModelComponent): The resolution + component to convolve with. Returns: np.ndarray: The convolution result Raises: - ValueError: - If the component pair cannot be handled analytically. + ValueError: If the component pair cannot be handled + analytically. """ if isinstance(resolution_component, DeltaFunction): @@ -184,13 +212,13 @@ def _convolute_delta_any( multiplied. Args: - sample_component : DeltaFunction - The sample component to be convolved. - resolution_components : ComponentCollection | ModelComponent - The resolution model to convolve with. + sample_component (DeltaFunction): The sample component to + be convolved. + resolution_components (ComponentCollection | ModelComponent) + : The resolution model to convolve with. + Returns: - np.ndarray - The evaluated convolution values at self.energy. + np.ndarray: The evaluated convolution values at self.energy. """ return sample_component.area.value * resolution_components.evaluate( self.energy_with_offset.values - sample_component.center.value @@ -201,20 +229,18 @@ def _convolute_gaussian_gaussian( sample_component: Gaussian, resolution_component: Gaussian, ) -> np.ndarray: - """Convolution of two Gaussian components results in another - Gaussian component with width sqrt(w1^2 + w2^2). The areas are - multiplied. + r"""Convolution of two Gaussian components results in another + Gaussian component with width $\sqrt{w_1^2 + w_2^2}$. The areas + are multiplied. Args: - sample_component : Gaussian - The sample Gaussian component to be convolved. - resolution_component : Gaussian - The resolution Gaussian component to convolve with. + sample_component (Gaussian): The sample Gaussian component + to be convolved. + resolution_component (Gaussian): The resolution Gaussian + component to convolve with. Returns: - np.ndarray - - The evaluated convolution values at self.energy. + np.ndarray: The evaluated convolution values at self.energy. """ width = np.sqrt(sample_component.width.value**2 + resolution_component.width.value**2) @@ -234,14 +260,13 @@ def _convolute_gaussian_lorentzian( profile. The areas are multiplied. Args: - sample_component : Gaussian - The sample Gaussian component to be convolved. - resolution_component : Lorentzian - The resolution Lorentzian component to convolve with. + sample_component (Gaussian): The sample Gaussian component + to be convolved. + resolution_component (Lorentzian): The resolution Lorentzian + component to convolve with. Returns: - np.ndarray - The evaluated convolution values at self.energy. + np.ndarray: The evaluated convolution values at self.energy. """ center = sample_component.center.value + resolution_component.center.value area = sample_component.area.value * resolution_component.area.value @@ -264,14 +289,13 @@ def _convolute_gaussian_voigt( are multiplied. Args: - sample_component : Gaussian - The sample Gaussian component to be convolved. - resolution_component : Voigt - The resolution Voigt component to convolve with. + sample_component (Gaussian): The sample Gaussian component + to be convolved. + resolution_component (Voigt): The resolution Voigt component + to convolve with. Returns: - np.ndarray - The evaluated convolution values at self.energy. + np.ndarray: The evaluated convolution values at self.energy. """ area = sample_component.area.value * resolution_component.area.value @@ -295,18 +319,18 @@ def _convolute_lorentzian_lorentzian( sample_component: Lorentzian, resolution_component: Lorentzian, ) -> np.ndarray: - """Convolution of two Lorentzian components results in another - Lorentzian component with width w1 + w2. The areas are + r"""Convolution of two Lorentzian components results in another + Lorentzian component with width $w_1 + w_2$. The areas are multiplied. Args: - sample_component : Lorentzian - The sample Lorentzian component to be convolved. - resolution_component : Lorentzian - The resolution Lorentzian component to convolve with. + sample_component (Lorentzian): The sample Lorentzian + component to be convolved. + resolution_component (Lorentzian): The resolution Lorentzian + component to convolve with. + Returns: - np.ndarray - The evaluated convolution values at self.energy. + np.ndarray: The evaluated convolution values at self.energy. """ area = sample_component.area.value * resolution_component.area.value @@ -326,15 +350,16 @@ def _convolute_lorentzian_voigt( The Gaussian width remains unchanged, while the Lorentzian widths are summed. + The areas are multiplied. + Args: - sample_component : Lorentzian - The sample Lorentzian component to be convolved. - resolution_component : Voigt - The resolution Voigt component to convolve with. + sample_component (Lorentzian): The sample Lorentzian + component to be convolved. + resolution_component (Voigt): The resolution Voigt component + to convolve with. Returns: - np.ndarray - The evaluated convolution values at self.energy. + np.ndarray: The evaluated convolution values at self.energy. """ area = sample_component.area.value * resolution_component.area.value @@ -364,14 +389,15 @@ def _convolute_voigt_voigt( The Gaussian widths are summed in quadrature, while the Lorentzian widths are summed. The areas are multiplied. + Args: - sample_component : Voigt - The sample Voigt component to be convolved. - resolution_component : Voigt - The resolution Voigt component to convolve with. + sample_component (Voigt): The sample Voigt component to be + convolved. + resolution_component (Voigt): The resolution Voigt component + to convolve with. + Returns: - np.ndarray - The evaluated convolution values at self.energy. + np.ndarray: The evaluated convolution values at self.energy. """ area = sample_component.area.value * resolution_component.area.value @@ -397,20 +423,28 @@ def _gaussian_eval( center: float, width: float, ) -> np.ndarray: - """Evaluate a Gaussian function. y = (area/(sqrt(2pi) * - width))*exp(-0.5*((x-center) / width)^2) All checks are handled - in the calling function. + r"""Evaluate a Gaussian function. + + $$ + I(x) = \frac{A}{\sigma \sqrt{2\pi}} + \exp\left( + -\frac{1}{2} + \left(\frac{x - x_0}{\sigma}\right)^2 + \right) + $$ + + where $A$ is the area, $x_0$ is the center, and $\sigma$ is the + width. + + All checks are handled in the calling function. Args: - area : float - The area under the Gaussian curve. - center : float - The center of the Gaussian. - width : float - The width (sigma) of the Gaussian. + area (float): The area under the Gaussian curve. + center (float): The center of the Gaussian. + width (float): The width (sigma) of the Gaussian. + Returns: - np.ndarray - The evaluated Gaussian values at self.energy. + np.ndarray: The evaluated Gaussian values at self.energy. """ normalization = 1 / (np.sqrt(2 * np.pi) * width) @@ -419,21 +453,25 @@ def _gaussian_eval( return area * normalization * np.exp(exponent) def _lorentzian_eval(self, area: float, center: float, width: float) -> np.ndarray: - """ + r""" Evaluate a Lorentzian function. - y = (area * width / pi) / ((x - center)^2 + width^2). + + $$ + I(x) = \frac{A}{\\pi} \frac{\Gamma}{(x - x_0)^2 + \Gamma^2}, + $$ + + where $A$ is the area, $x_0$ is the center, and $\\Gamma$ is + the half width at half maximum (HWHM). + All checks are handled in the calling function. Args: - area : float - The area under the Lorentzian. - center : float - The center of the Lorentzian. - width : float - The width (HWHM) of the Lorentzian. + area (float): The area under the Lorentzian. + center (float): The center of the Lorentzian. + width (float): The width (HWHM) of the Lorentzian. + Returns: - np.ndarray - The evaluated Lorentzian values at self.energy. + np.ndarray: The evaluated Lorentzian values at self.energy. """ normalization = width / np.pi @@ -452,17 +490,16 @@ def _voigt_eval( voigt_profile. Args: - area : float - The area under the Voigt profile. - center : float - The center of the Voigt profile. - gaussian_width : float - The Gaussian width (sigma) of the Voigt profile. - lorentzian_width : float - The Lorentzian width (HWHM) of the Voigt profile. + area (float): The area under the Voigt profile. + center (float): The center of the Voigt profile. + gaussian_width (float): The Gaussian width (sigma) of the + Voigt profile. + lorentzian_width (float): The Lorentzian width (HWHM) of the + Voigt profile. + Returns: - np.ndarray - The evaluated Voigt profile values at self.energy. + np.ndarray: The evaluated Voigt profile values at + self.energy. """ return area * voigt_profile( diff --git a/src/easydynamics/convolution/convolution.py b/src/easydynamics/convolution/convolution.py index 46c4f0c6..b799d60f 100644 --- a/src/easydynamics/convolution/convolution.py +++ b/src/easydynamics/convolution/convolution.py @@ -20,40 +20,61 @@ class Convolution(NumericalConvolutionBase): """Convolution class that combines analytical and numerical convolution methods to efficiently perform convolutions of - ComponentCollections with ResolutionComponents. Supports analytical - convolution for pairs of analytical model components (DeltaFunction, - Gaussian, Lorentzian, Voigt), while using numerical convolution for - other components. If temperature is provided, detailed balance - correction is applied to the sample model. In this case, all - convolutions are handled numerically. Includes a setting to - normalize the detailed balance correction. Includes optional - upsampling and extended range to improve accuracy of the numerical - convolutions. Also warns about numerical instabilities if peaks are - very wide or very narrow. + ComponentCollections with ResolutionComponents. + + Supports analytical convolution for pairs of analytical model + components (DeltaFunction, Gaussian, Lorentzian, Voigt), while + using numerical convolution for other components. + If temperature is provided, detailed balance correction is applied + to the sample model. In this case, all convolutions are handled + numerically. + Includes a setting to normalize the detailed balance correction. + Includes optional upsampling and extended range to improve accuracy + of the numerical convolutions. Also warns about numerical + instabilities if peaks are very wide or very narrow. Args: - energy : np.ndarray or scipp.Variable - 1D array of energy values where the convolution is evaluated. - sample_components : ComponentCollection or ModelComponent - The sample components to be convolved. - resolution_components : ComponentCollection or ModelComponent - The resolution components to convolve with. - upsample_factor : int, optional - The factor by which to upsample the input data before - convolution. Default is 5. - extension_factor : float, optional - The factor by which to extend the input data range before - convolution. Default is 0.2. - temperature : Parameter, float, or None, optional - The temperature to use for detailed balance correction. - Default is None. - temperature_unit : str or sc.Unit, optional - The unit of the temperature parameter. Default is 'K'. - energy_unit : str or sc.Unit, optional - The unit of the energy. Default is 'meV'. - normalize_detailed_balance : bool, optional - Whether to normalize the detailed balance correction. - Default is True. + energy (np.ndarray | scipp.Variable): 1D array of energy values + where the convolution is evaluated. + sample_components (ComponentCollection | ModelComponent): The + sample components to be convolved. + resolution_components (ComponentCollection | ModelComponent): + The resolution components to convolve with. + upsample_factor (int | None): The factor by which to upsample + the input data before convolution. Default is 5. + extension_factor (float | None): The factor by which to + extend the input data range before convolution. Default is + 0.2. + temperature (Parameter | float | None): The + temperature to use for detailed balance correction. Default + is None. + temperature_unit (str | sc.Unit | None): The unit of the + temperature parameter. Default is 'K'. + energy_unit (str | sc.Unit | None): The unit of the energy. + Default is 'meV'. + normalize_detailed_balance (bool | None): Whether to + normalize the detailed balance correction. Default is True. + + Attributes: + energy (scipp.Variable): 1D array of energy values where the + convolution is evaluated. + sample_components (ComponentCollection): The sample components + to be convolved. + resolution_components (ComponentCollection): The resolution + components to convolve with. + energy_offset (Parameter): Energy offset to apply to the energy + values before convolution. + upsample_factor (int): The factor by which to upsample the input + data before convolution. + extension_factor (float): The factor by which to extend the + input data range before convolution. + temperature (Parameter | None): The temperature to use for + detailed balance correction. + temperature_unit (str | sc.Unit): The unit of the temperature + parameter. + energy_unit (str | sc.Unit): The unit of the energy. + normalize_detailed_balance (bool): Whether to normalize the + detailed balance correction. """ # When these attributes are changed, the convolution plan @@ -84,6 +105,34 @@ def __init__( energy_unit: str | sc.Unit = 'meV', normalize_detailed_balance: bool = True, ): + """Initialize the Convolution class. + + Args: + energy (np.ndarray | scipp.Variable): 1D array of energy + values where the convolution is evaluated. + sample_components (ComponentCollection | ModelComponent): + The sample components to be convolved. + resolution_components (ComponentCollection | + ModelComponent): The resolution components to convolve + with. + upsample_factor (int | None): The factor by which to + upsample the input data before convolution. Default is + 5. + extension_factor (float | None): The factor by which to + extend the input data range before convolution. Default + is 0.2. + temperature (Parameter | float | None): The + temperature to use for detailed balance correction. + Default is None. + temperature_unit (str | sc.Unit | None): The unit of the + temperature parameter. Default is 'K'. + energy_unit (str | sc.Unit | None): The unit of the energy. + Default is 'meV'. + normalize_detailed_balance (bool | None): Whether to + normalize the detailed balance correction. Default is + True. + """ + self._convolution_plan_is_valid = False self._reactions_enabled = False super().__init__( @@ -114,8 +163,7 @@ def convolution( components. Returns: - np.ndarray - The convolved values evaluated at energy. + np.ndarray: The convolved values evaluated at energy. """ if not self._convolution_plan_is_valid: self._build_convolution_plan() @@ -141,9 +189,8 @@ def _convolve_delta_functions(self) -> np.ndarray: applied to delta functions. Returns: - np.ndarray - The convolved values of the delta function components - evaluated at energy. + np.ndarray: The convolved values of the delta function c + components evaluated at energy. """ return sum( delta.area.value @@ -162,14 +209,14 @@ def _check_if_pair_is_analytic( handled analytically. Args: - sample_component : ModelComponent - The sample component to be convolved. - resolution_component : ModelComponent - The resolution component to convolve with. + sample_component (ModelComponent): The sample component to + be convolved. + resolution_component (ModelComponent): The resolution + component to convolve with. + Returns: - bool - True if the component pair can be handled analytically, - False otherwise. + bool: True if the component pair can be handled + analytically, False otherwise. """ if not isinstance(sample_component, ModelComponent): @@ -277,7 +324,7 @@ def _set_convolvers(self) -> None: self._numerical_convolver = None # Update some setters so the internal sample models are updated - def __setattr__(self, name, value): + def __setattr__(self, name, value) -> None: """Custom setattr to invalidate convolution plan on relevant attribute changes, and build a new plan. diff --git a/src/easydynamics/convolution/convolution_base.py b/src/easydynamics/convolution/convolution_base.py index 9f1799f5..5e69a4f4 100644 --- a/src/easydynamics/convolution/convolution_base.py +++ b/src/easydynamics/convolution/convolution_base.py @@ -15,14 +15,27 @@ class ConvolutionBase: base class has no convolution functionality. Args: - energy : np.ndarray or scipp.Variable - 1D array of energy values where the convolution is evaluated - sample_components : ComponentCollection or ModelComponent - The sample model to be convolved. - resolution_components : ComponentCollection or ModelComponent + energy (np.ndarray | scipp.Variable): 1D array of energy values + where the convolution is evaluated. + sample_components (ComponentCollection | ModelComponent): The + sample model to be convolved. + resolution_components (ComponentCollection | ModelComponent): The resolution model to convolve with. - energy_unit : str or sc.Unit, optional - The unit of the energy. Default is 'meV'. + energy_unit (str | sc.Unit, optional): The unit of the energy. + Default is 'meV'. + energy_offset (Numeric | Parameter, optional): The energy offset + applied to the convolution. Default is 0.0. + + Attributes: + energy (scipp.Variable): 1D array of energy values where the + convolution is evaluated + sample_components (ComponentCollection | ModelComponent): The + sample model to be convolved. + resolution_components (ComponentCollection | ModelComponent): + The resolution model to convolve with. + energy_unit (str | sc.Unit): The unit of the energy. + energy_offset (Parameter): The energy offset applied to the + convolution. """ def __init__( @@ -33,6 +46,30 @@ def __init__( energy_unit: str | sc.Unit = 'meV', energy_offset: Numeric | Parameter = 0.0, ): + """Initialize the ConvolutionBase. + + Args: + energy (np.ndarray | scipp.Variable): 1D array of energy + values where the convolution is evaluated. + sample_components (ComponentCollection | ModelComponent): + The sample model to be convolved. + resolution_components (ComponentCollection | + ModelComponent): The resolution model to convolve with. + energy_unit (str | sc.Unit, optional): The unit of the + energy. Default is 'meV'. + energy_offset (Numeric | Parameter, optional): The energy + offset applied to the convolution. Default is 0.0. + + Raises: + TypeError: If energy is not a numpy ndarray or a scipp + Variable. + TypeError: If energy_unit is not a string or scipp unit. + TypeError: If energy_offset is not a number or a Parameter. + TypeError: If sample_components is not a ComponentCollection + or ModelComponent. + TypeError: If resolution_components is not a + ComponentCollection or ModelComponent. + """ if isinstance(energy, Numeric): energy = np.array([float(energy)]) @@ -81,12 +118,17 @@ def __init__( @property def energy_offset(self) -> Parameter: - """Get the energy offset.""" + """Get the energy offset. + + Returns: + Parameter: The energy offset applied to the convolution. + """ 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. @@ -105,7 +147,11 @@ def energy_offset(self, energy_offset: Numeric | Parameter) -> None: @property def energy_with_offset(self) -> sc.Variable: - """Get the energy with the offset applied.""" + """Get the energy with the offset applied. + + Returns: + sc.Variable: The energy values 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 @@ -114,6 +160,13 @@ def energy_with_offset(self) -> sc.Variable: def energy_with_offset(self, value) -> None: """Energy with offset is a read-only property derived from energy and energy_offset. + + Args: + value: The value to set (ignored). + + Raises: + AttributeError: Always raised since energy_with_offset is + read-only. """ raise AttributeError( 'Energy with offset is a read-only property derived from energy and energy_offset.' @@ -121,16 +174,22 @@ def energy_with_offset(self, value) -> None: @property def energy(self) -> sc.Variable: - """Get the energy.""" + """Get the energy. + + Returns: + sc.Variable: The energy values where the convolution is + evaluated. + """ return self._energy @energy.setter def energy(self, energy: np.ndarray | sc.Variable) -> None: """Set the energy. + Args: energy (np.ndarray | scipp.Variable): 1D array of energy - values where the convolution is evaluated. + values where the convolution is evaluated. Raises: TypeError: If energy is not a numpy ndarray or a @@ -152,7 +211,11 @@ def energy(self, energy: np.ndarray | sc.Variable) -> None: @property def energy_unit(self) -> str: - """Get the energy unit.""" + """Get the energy unit. + + Returns: + str: The unit of the energy. + """ return self._energy_unit @energy_unit.setter @@ -168,8 +231,7 @@ def convert_energy_unit(self, energy_unit: str | sc.Unit) -> None: """Convert the energy and energy_offset to the specified unit. Args: - energy_unit : str or sc.Unit - The unit of the energy. + energy_unit (str | sc.Unit): The unit of the energy. Raises: TypeError: If energy_unit is not a string or scipp unit. @@ -197,19 +259,25 @@ def convert_energy_unit(self, energy_unit: str | sc.Unit) -> None: @property def sample_components(self) -> ComponentCollection | ModelComponent: - """Get the sample model.""" + """Get the sample model. + + Returns: + ComponentCollection or ModelComponent: The sample model to + be convolved. + """ return self._sample_components @sample_components.setter def sample_components(self, sample_components: ComponentCollection | ModelComponent) -> None: """Set the sample model. + Args: sample_components : ComponentCollection or ModelComponent The sample model to be convolved. Raises: TypeError: If sample_components is not a ComponentCollection - or ModelComponent. + or ModelComponent. """ if not isinstance(sample_components, (ComponentCollection, ModelComponent)): raise TypeError( @@ -222,7 +290,12 @@ def sample_components(self, sample_components: ComponentCollection | ModelCompon @property def resolution_components(self) -> ComponentCollection | ModelComponent: - """Get the resolution model.""" + """Get the resolution model. + + Returns: + ComponentCollection or ModelComponent: The resolution model + to be convolved. + """ return self._resolution_components @resolution_components.setter @@ -230,14 +303,15 @@ def resolution_components( self, resolution_components: ComponentCollection | ModelComponent ) -> None: """Set the resolution model. + Args: - resolution_components : ComponentCollection or - ModelComponent - The resolution model to convolve with. + resolution_components (ComponentCollection | ModelComponent) + : The resolution model to be convolved. Can be a + ComponentCollection or a single ModelComponent Raises: TypeError: If resolution_components is not a - ComponentCollection or ModelComponent. + ComponentCollection or ModelComponent. """ if not isinstance(resolution_components, (ComponentCollection, ModelComponent)): raise TypeError( diff --git a/src/easydynamics/convolution/energy_grid.py b/src/easydynamics/convolution/energy_grid.py index ab488753..c8b0c340 100644 --- a/src/easydynamics/convolution/energy_grid.py +++ b/src/easydynamics/convolution/energy_grid.py @@ -11,20 +11,17 @@ class EnergyGrid: """Container for the dense energy grid and related metadata. Attributes: - energy_dense : np.ndarray - The upsampled and extended energy array. - energy_dense_centered : np.ndarray - The centered version of energy_dense - (used for resolution evaluation). - energy_dense_step : float - The spacing of energy_dense + energy_dense (np.ndarray): The upsampled and extended energy + array. + energy_dense_centered (np.ndarray): The centered version of + energy_dense (used for resolution evaluation). + energy_dense_step (float): The spacing of energy_dense (used for width checks and normalization). - energy_span_dense : float - The total span of energy_dense. + energy_span_dense (float): The total span of energy_dense. (used for width checks). - energy_even_length_offset : float - The offset to apply if energy_dense has even length - (used for convolution alignment). + energy_even_length_offset (float): The offset to apply if + energy_dense has even length (used for convolution + alignment). """ energy_dense: np.ndarray diff --git a/src/easydynamics/convolution/numerical_convolution.py b/src/easydynamics/convolution/numerical_convolution.py index 1b8ca6d1..9738c58c 100644 --- a/src/easydynamics/convolution/numerical_convolution.py +++ b/src/easydynamics/convolution/numerical_convolution.py @@ -21,30 +21,47 @@ class NumericalConvolution(NumericalConvolutionBase): balance correction is applied to the sample model. Args: - energy : np.ndarray or scipp.Variable - 1D array of energy values where the convolution is evaluated. - sample_components : ComponentCollection or ModelComponent - The sample model to be convolved. - resolution_components : ComponentCollection or ModelComponent - The resolution model to convolve with. - upsample_factor : int, optional - The factor by which to upsample the input data - before convolution. - Default is 5. - extension_factor : float, optional - The factor by which to extend the input data range - before convolution. - Default is 0.2. - temperature : Parameter, float, or None, optional - The temperature to use for detailed balance correction. - Default is None. - temperature_unit : str or sc.Unit, optional - The unit of the temperature parameter. Default is 'K'. - energy_unit : str or sc.Unit, optional - The unit of the energy. Default is 'meV'. - normalize_detailed_balance : bool, optional - Whether to normalize the detailed balance correction. - Default is True. + energy (np.ndarray | sc.Variable): 1D array of energy values + where the convolution is evaluated. + sample_components (ComponentCollection | ModelComponent): The + sample model to be convolved. + resolution_components (ComponentCollection | ModelComponent): + The resolution model to convolve with. + upsample_factor (int, optional): The factor by which to upsample + the input data before convolution. Default is 5. + extension_factor (float, optional): The factor by which to + extend the input data range before convolution. Default is + 0.2. + temperature (Parameter | float | None, optional): The + temperature to use for detailed balance correction. Default + is None. + temperature_unit (str | sc.Unit, optional): The unit of the + temperature parameter. Default is 'K'. + energy_unit (str | sc.Unit, optional): The unit of the energy. + Default is 'meV'. + normalize_detailed_balance (bool, optional): Whether to + normalize the detailed balance correction. Default is True. + + Attributes: + energy (np.ndarray | sc.Variable): The energy values where the + convolution is evaluated. + sample_components (ComponentCollection | ModelComponent): The + sample model to be convolved. + resolution_components (ComponentCollection | ModelComponent): + The resolution model to convolve with. + energy_offset (Parameter): The energy offset to apply to the + sample model before convolution. + upsample_factor (int): The factor by which to upsample the input + data before convolution. + extension_factor (float): The factor by which to extend the + input data range before convolution. + temperature (Parameter | float | None): The temperature to use + for detailed balance correction. + temperature_unit (str | sc.Unit): The unit of the temperature + parameter. + energy_unit (str | sc.Unit): The unit of the energy. + normalize_detailed_balance (bool): Whether to normalize the + detailed balance correction. """ def __init__( @@ -60,6 +77,42 @@ def __init__( energy_unit: str | sc.Unit = 'meV', normalize_detailed_balance: bool = True, ): + """Initialize the NumericalConvolution object. + + Args: + energy (np.ndarray | sc.Variable): 1D array of energy values + where the convolution is evaluated. + sample_components (ComponentCollection | ModelComponent): + The sample model to be convolved. + resolution_components (ComponentCollection | + ModelComponent): The resolution model to convolve with. + upsample_factor (int, optional): The factor by which to + upsample the input data before convolution. Default is + 5. + extension_factor (float, optional): The factor by which to + extend the input data range before convolution. Default + is 0.2. + temperature (Parameter | float | None, optional): The + temperature to use for detailed balance correction. + Default is None. + temperature_unit (str | sc.Unit, optional): The unit of the + temperature parameter. Default is 'K'. + energy_unit (str | sc.Unit, optional): The unit of the + energy. Default is 'meV'. + normalize_detailed_balance (bool, optional): Whether to + normalize the detailed balance correction. Default is + True. + + Raises: + TypeError: If temperature is not None, a number, or a + Parameter. + TypeError: If temperature_unit is not a string or sc.Unit. + TypeError: If upsample_factor is not a number or None. + ValueError: If upsample_factor is not greater than 1. + TypeError: If extension_factor is not a number. + ValueError: If extension_factor is negative. + TypeError: If normalize_detailed_balance is not a bool. + """ super().__init__( energy=energy, sample_components=sample_components, diff --git a/src/easydynamics/convolution/numerical_convolution_base.py b/src/easydynamics/convolution/numerical_convolution_base.py index 5a60f5d8..b1672a16 100644 --- a/src/easydynamics/convolution/numerical_convolution_base.py +++ b/src/easydynamics/convolution/numerical_convolution_base.py @@ -34,28 +34,44 @@ class NumericalConvolutionBase(ConvolutionBase): functionality. Args: - energy : np.ndarray or scipp.Variable - 1D array of energy values where the convolution is evaluated. - sample_components : ComponentCollection or ModelComponent - The components to be convolved. - resolution_components : ComponentCollection or ModelComponent - The resolution components to convolve with. - upsample_factor : int, optional - The factor by which to upsample the input data - before convolution. Default is 5. - extension_factor : float, optional - The factor by which to extend the input data range - before convolution. Default is 0.2. - temperature : Parameter, float, or None, optional - The temperature to use for detailed balance correction. - Default is None. - temperature_unit : str or sc.Unit, optional - The unit of the temperature parameter. Default is 'K'. - energy_unit : str or sc.Unit, optional - The unit of the energy. Default is 'meV'. - normalize_detailed_balance : bool, optional - Whether to normalize the detailed balance correction. - Default is True. + energy (np.ndarray | sc.Variable): 1D array of energy values + where the convolution is evaluated. + sample_components (ComponentCollection | ModelComponent): The + components to be convolved. + resolution_components (ComponentCollection | ModelComponent): + The resolution components to convolve with. + upsample_factor (int | None): The factor by which to upsample + the input data before convolution. Default is 5. + extension_factor (float | None): The factor by which to extend + the input data range before convolution. Default is 0.2. + temperature (Parameter | float | None): The temperature to use + for detailed balance correction. Default is None. + temperature_unit (str | sc.Unit): The unit of the temperature + parameter. Default is 'K'. + energy_unit (str | sc.Unit): The unit of the energy. Default is + 'meV'. + normalize_detailed_balance (bool): Whether to normalize the + detailed balance correction. Default is True. + + Attributes: + energy (np.ndarray | sc.Variable): 1D array of energy values + where the convolution is evaluated. + sample_components (ComponentCollection | ModelComponent): The + components to be convolved. + resolution_components (ComponentCollection | ModelComponent): + The resolution components to convolve with. + upsample_factor (int | None): The factor by which to upsample + the input data before convolution. + extension_factor (float | None): The factor by which to extend + the input data range before convolution. + temperature (Parameter | None): The temperature parameter for + detailed balance correction, or None if detailed balance is + disabled. + temperature_unit (str | sc.Unit): The unit of the temperature + parameter. + energy_unit (str | sc.Unit): The unit of the energy. + normalize_detailed_balance (bool): Whether to normalize the + detailed balance correction. """ def __init__( @@ -71,6 +87,41 @@ def __init__( energy_unit: str | sc.Unit = 'meV', normalize_detailed_balance: bool = True, ): + """Initialize the NumericalConvolutionBase. + + Args: + energy (np.ndarray | sc.Variable): 1D array of energy values + where the convolution is evaluated. + sample_components (ComponentCollection | ModelComponent): + The components to be convolved. + resolution_components (ComponentCollection | + ModelComponent): The resolution components to convolve + with. + upsample_factor (int | None): The factor by which to + upsample the input data before convolution. Default is + 5. + extension_factor (float | None): The factor by which to + extend the input data range before convolution. Default + is 0.2. + temperature (Parameter | float | None): The temperature to + use for detailed balance correction. Default is None. + temperature_unit (str | sc.Unit): The unit of the + temperature parameter. Default is 'K'. + energy_unit (str | sc.Unit): The unit of the energy. Default + is 'meV'. + normalize_detailed_balance (bool): Whether to normalize the + detailed balance correction. Default is True. + + Raises: + TypeError: If temperature is not None, a number, or a + Parameter. + TypeError: If temperature_unit is not a string or sc.Unit. + TypeError: If upsample_factor is not a number or None. + ValueError: If upsample_factor is not greater than 1. + TypeError: If extension_factor is not a number. + ValueError: If extension_factor is negative. + TypeError: If normalize_detailed_balance is not a bool. + """ super().__init__( energy=energy, sample_components=sample_components, @@ -100,19 +151,36 @@ def __init__( @ConvolutionBase.energy.setter def energy(self, energy: np.ndarray) -> None: + """Set the energy array and recreate the dense grid. + + Args: + energy (np.ndarray): The new energy array. + """ ConvolutionBase.energy.fset(self, energy) # Recreate dense grid when energy is updated self._energy_grid = self._create_energy_grid() @property def upsample_factor(self) -> Numerical: - """Get the upsample factor.""" + """Get the upsample factor. + + Returns: + Numerical: The upsample factor. + """ return self._upsample_factor @upsample_factor.setter def upsample_factor(self, factor: Numerical) -> None: - """Set the upsample factor and recreate the dense grid.""" + """Set the upsample factor and recreate the dense grid. + + Args: + factor (Numerical): The new upsample factor. + + Raises: + TypeError: If factor is not a number or None. + ValueError: If factor is not greater than 1. + """ if factor is None: self._upsample_factor = factor self._energy_grid = self._create_energy_grid() @@ -137,6 +205,9 @@ def extension_factor(self) -> float: extended on both sides before convolution. 0.2 means extending by 20% of the original energy span on each side + + Returns: + float: The extension factor. """ return self._extension_factor @@ -151,12 +222,12 @@ def extension_factor(self, factor: Numerical) -> None: on each side. Args: - factor : float - The new extension factor. + factor (Numerical): The new extension factor. Raises: TypeError: If factor is not a number. """ + if not isinstance(factor, Numerical): raise TypeError('Extension factor must be a number.') if factor < 0.0: @@ -168,7 +239,12 @@ def extension_factor(self, factor: Numerical) -> None: @property def temperature(self) -> Optional[Parameter]: - """Get the temperature.""" + """Get the temperature. + + Returns: + Optional[Parameter]: The temperature parameter, or None if + detailed balance correction is disabled. + """ return self._temperature @@ -178,11 +254,12 @@ def temperature(self, temp: Parameter | float | None) -> None: If None, disables detailed balance correction and removes the temperature parameter. + Args: - temp : Parameter, float, or None - The temperature to set. The unit will be the same as - the existing temperature parameter if it exists, - otherwise 'K'. + temp (Parameter | float | None): The temperature to set. + The unit will be the same as the existing temperature + parameter if it exists, otherwise 'K'. + Raises: TypeError: If temp is not a float, Parameter, or None. """ @@ -206,7 +283,13 @@ def temperature(self, temp: Parameter | float | None) -> None: @property def normalize_detailed_balance(self) -> bool: - """Get whether to normalize the detailed balance factor.""" + """Get whether to normalize the detailed balance factor. + + If True, the detailed balance factor is divided by temperature. + + Returns: + bool: Whether to normalize the detailed balance factor. + """ return self._normalize_detailed_balance @@ -215,9 +298,11 @@ def normalize_detailed_balance(self, normalize: bool) -> None: """Set whether to normalize the detailed balance factor. If True, the detailed balance factor is divided by temperature. + Args: - normalize : bool - Whether to normalize the detailed balance factor. + normalize (bool): Whether to normalize the detailed balance + factor. + Raises: TypeError: If normalize is not a bool. """ @@ -236,24 +321,10 @@ def _create_energy_grid( If upsample_factor is None, no upsampling or extension is performed. This dense grid is used for convolution to improve accuracy. + Returns: - EnergyGrid - The dense grid created by upsampling and extending - energy. - The EnergyGrid has the following attributes: - energy_dense : np.ndarray - The upsampled and extended energy array. - energy_dense_centered : np.ndarray - The centered version of energy_dense - (used for resolution evaluation). - energy_dense_step : float - The spacing of energy_dense - (used for width checks and normalization). - energy_span_dense : float - The total span of energy_dense. (used for width checks). - energy_even_length_offset : float - The offset to apply if energy_dense has even length - (used for convolution alignment). + EnergyGrid: The dense grid created by upsampling and + extending energy. """ if self.upsample_factor is None: # Check if the array is uniformly spaced. @@ -326,19 +397,13 @@ def _check_width_thresholds( spacing. In both cases, the convolution accuracy may be compromised. + Args: - model : ComponentCollection or ModelComponent - The model to check. - model_name : str - A string indicating whether the model is a - 'sample model' or 'resolution model' for - warning messages. - returns: - None - warns: - UserWarning - If the component widths are not appropriate for the data - span or bin spacing. + model (ComponentCollection | ModelComponent): The model to + check + model_name (str): A string indicating whether the model is a + 'sample model' or 'resolution model' for warning + messages. """ # Handle ComponentCollection or ModelComponent @@ -369,6 +434,13 @@ def _check_width_thresholds( ) def __repr__(self) -> str: + """Return a string representation of the + NumericalConvolutionBase. + + Returns: + str: A string representation of the + NumericalConvolutionBase. + """ return ( f'{self.__class__.__name__}(' f'energy=array of shape {self.energy.values.shape},\n ' diff --git a/src/easydynamics/experiment/experiment.py b/src/easydynamics/experiment/experiment.py index 4245eba3..e506432b 100644 --- a/src/easydynamics/experiment/experiment.py +++ b/src/easydynamics/experiment/experiment.py @@ -38,6 +38,21 @@ def __init__( unique_name: str | None = None, data: sc.DataArray | str | None = None, ): + """Initialize the Experiment object. + + Args: + display_name (str): Display name of the experiment. + unique_name (str | None): Unique name of the experiment. If + None, a unique name will be generated. + data (sc.DataArray | str | None): Dataset associated with + the experiment. Can be a sc.DataArray or a filename + string to load from. If None, no data is loaded. + + Raises: + TypeError: If data is not a sc.DataArray, a string, or None. + ValueError: If the loaded data is missing required + coordinates. + """ super().__init__( display_name=display_name, unique_name=unique_name, @@ -123,7 +138,7 @@ def Q(self) -> sc.Variable | None: Returns: sc.Variable | None: The Q values from the dataset, or None - if no data is loaded. + if no data is loaded. """ if self._data is None: return None diff --git a/src/easydynamics/sample_model/background_model.py b/src/easydynamics/sample_model/background_model.py index 71a16881..c8b4f756 100644 --- a/src/easydynamics/sample_model/background_model.py +++ b/src/easydynamics/sample_model/background_model.py @@ -43,6 +43,21 @@ def __init__( components: ComponentCollection | ModelComponent | None = None, Q: Q_type | None = None, ): + """Initialize the BackgroundModel. + + Args: + display_name (str): Display name of the model. + unique_name (str | None): Unique name of the model. If None, + a unique name will be generated. + unit (str | sc.Unit | None): Unit of the model. Defaults to + "meV". + components (ModelComponent | ComponentCollection | None): + Template components of the model. If None, no components + are added. These components are copied into + ComponentCollections for each Q value. + Q (Q_type | None): Q values for the model. If None, Q is not + set. + """ super().__init__( display_name=display_name, unique_name=unique_name, diff --git a/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py b/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py index f85eb93b..7b12a1b0 100644 --- a/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py +++ b/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py @@ -57,6 +57,26 @@ def __init__( display_name: str | None = 'DampedHarmonicOscillator', unique_name: str | None = None, ): + """Initialize the Damped Harmonic Oscillator. + + Args: + area (Int | float): Area under the curve. + center (Int | float): Resonance frequency, approximately the + peak position. + width (Int | float): Damping constant, approximately the + half width at half max (HWHM) of the peaks. + unit (str | sc.Unit): Unit of the parameters. + Defaults to "meV". + display_name (str | None): Display name of the component. + unique_name (str | None): Unique name of the component. + If None, a unique_name is automatically generated. + + Raises: + TypeError: If any of the parameters are not numbers or + Parameters. + ValueError: If center or width are not positive. + """ + super().__init__( display_name=display_name, unique_name=unique_name, @@ -90,7 +110,14 @@ def area(self) -> Parameter: @area.setter def area(self, value: Numeric) -> None: - """Set the value of the area parameter.""" + """Set the value of the area parameter. + + Args: + value (Numeric): The new value for the area parameter. + + Raises: + TypeError: If the value is not a number. + """ if not isinstance(value, Numeric): raise TypeError('area must be a number') self._area.value = value @@ -186,7 +213,7 @@ def __repr__(self) -> str: Returns: str: A string representation of the Damped Harmonic - Oscillator. + Oscillator. """ return ( f'DampedHarmonicOscillator(display_name = {self.display_name}, unit = {self._unit},\n \ diff --git a/src/easydynamics/sample_model/components/delta_function.py b/src/easydynamics/sample_model/components/delta_function.py index 6cc1faca..61741f71 100644 --- a/src/easydynamics/sample_model/components/delta_function.py +++ b/src/easydynamics/sample_model/components/delta_function.py @@ -49,6 +49,23 @@ def __init__( display_name: str | None = 'DeltaFunction', unique_name: str | None = None, ): + """Initialize the Delta function. + + Args: + center (Int | float | None): Center of the delta function. + If None, defaults to 0 and is fixed. + area (Int | float): Total area under the curve. + unit (str | sc.Unit): Unit of the parameters. + Defaults to "meV". + display_name (str | None): Name of the component. + unique_name (str | None): Unique name of the component. + If None, a unique_name is automatically generated. + + Raises: + TypeError: If center is not a number or None. + TypeError: If area is not a number. + TypeError: If unit is not a string or sc.Unit. + """ # Validate inputs and create Parameters if not given super().__init__( display_name=display_name, diff --git a/src/easydynamics/sample_model/components/gaussian.py b/src/easydynamics/sample_model/components/gaussian.py index d10d6978..7423714f 100644 --- a/src/easydynamics/sample_model/components/gaussian.py +++ b/src/easydynamics/sample_model/components/gaussian.py @@ -30,13 +30,13 @@ class Gaussian(CreateParametersMixin, ModelComponent): width. If the center is not provided, it will be centered at 0 and - fixed, which is typically what you want in QENS. + fixed, which is typically what you want in QENS. Args: - area (Int | float | Parameter): Area of the Gaussian. - center (Int | float | None | Parameter): Center of the + area (Int | float | Parameter | None): Area of the Gaussian. + center (Int | float | Parameter | None): Center of the Gaussian. If None, defaults to 0 and is fixed. - width (Int | float | Parameter): Standard deviation. + width (Int | float | Parameter | None): Standard deviation. unit (str | sc.Unit): Unit of the parameters. Defaults to "meV". display_name (str | None): Name of the component. @@ -61,6 +61,25 @@ def __init__( display_name: str | None = 'Gaussian', unique_name: str | None = None, ): + """Initialize the Gaussian component. + + Args: + area (Int | float | Parameter | None): Area of the Gaussian. + center (Int | float | Parameter | None): Center of the + Gaussian. If None, defaults to 0 and is fixed. + width (Int | float | Parameter | None): Standard deviation. + unit (str | sc.Unit): Unit of the parameters. Defaults to + "meV". + display_name (str | None): Name of the component. + unique_name (str | None): Unique name of the component. if + None, a unique_name is automatically generated. + + Raises: + TypeError: If area, center, or width are not numbers or + Parameters. + ValueError: If width is not positive. + TypeError: If unit is not a string or sc.Unit. + """ # Validate inputs and create Parameters if not given super().__init__( display_name=display_name, diff --git a/src/easydynamics/sample_model/components/lorentzian.py b/src/easydynamics/sample_model/components/lorentzian.py index 28685f98..b8da443e 100644 --- a/src/easydynamics/sample_model/components/lorentzian.py +++ b/src/easydynamics/sample_model/components/lorentzian.py @@ -56,6 +56,25 @@ def __init__( display_name: str | None = 'Lorentzian', unique_name: str | None = None, ): + """Initialize the Lorentzian component. + + Args: + area (Int | float | Parameter): Area of the Lorentzian. + center (Int | float | None | Parameter): Center of the + Lorentzian. If None, defaults to 0 and is fixed + width (Int | float | Parameter): Half width at half maximum + (HWHM). + unit (str | sc.Unit): Unit of the parameters. Defaults to + "meV". + display_name (str | None): Name of the component. + unique_name (str | None): Unique name of the component. If + None, a unique_name is automatically generated. + + Raises: + TypeError: If any of the parameters are of the wrong type. + ValueError: If width is not positive. + """ + super().__init__( display_name=display_name, unit=unit, @@ -183,7 +202,7 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) return self.area.value * normalization / denominator - def __repr__(self): + def __repr__(self) -> str: """Return a string representation of the Lorentzian. Returns: diff --git a/src/easydynamics/sample_model/components/model_component.py b/src/easydynamics/sample_model/components/model_component.py index 470e2cb2..09ed6514 100644 --- a/src/easydynamics/sample_model/components/model_component.py +++ b/src/easydynamics/sample_model/components/model_component.py @@ -40,6 +40,19 @@ def __init__( display_name: str | None = None, unique_name: str | None = None, ): + """Initialize the ModelComponent. + + Args: + unit (str | sc.Unit): The unit of the model component. + Default is 'meV'. + display_name (str | None): A human-readable name for the + component. Default is None. + unique_name (str | None): A unique identifier for the + component. Default is None. + + Raises: + TypeError: If unit is not a string or scipp Unit. + """ self.validate_unit(unit) super().__init__(display_name=display_name, unique_name=unique_name) self._unit = unit @@ -201,14 +214,14 @@ def evaluate( Args: x (Numeric | list[Numeric] | np.ndarray | sc.Variable | - sc.DataArray): Input values. + sc.DataArray): Input values. Returns: np.ndarray: Evaluated function values. """ pass - def __repr__(self): + def __repr__(self) -> str: """Return a string representation of the ModelComponent. Returns: diff --git a/src/easydynamics/sample_model/components/polynomial.py b/src/easydynamics/sample_model/components/polynomial.py index fa475b2c..9210c484 100644 --- a/src/easydynamics/sample_model/components/polynomial.py +++ b/src/easydynamics/sample_model/components/polynomial.py @@ -49,6 +49,25 @@ def __init__( display_name: str | None = 'Polynomial', unique_name: str | None = None, ): + """Initialize the Polynomial component. + + Args: + coefficients (list or tuple): Coefficients c0, c1, ..., cN + unit (str or sc.Unit): Unit of the Polynomial component. + display_name (str): Display name of the Polynomial + component. + unique_name (str or None): Unique name of the component. + If None, a unique_name is automatically generated. + + Raises: + TypeError: If coefficients is not a sequence of numbers or + Parameters. + ValueError: If coefficients is an empty sequence. + TypeError: If any item in coefficients is not a number or + Parameter. + UnitError: If unit is not a string or sc.Unit. + """ + super().__init__(display_name=display_name, unit=unit, unique_name=unique_name) if not isinstance(coefficients, (list, tuple, np.ndarray)): diff --git a/src/easydynamics/sample_model/components/voigt.py b/src/easydynamics/sample_model/components/voigt.py index dc0c3315..117b5652 100644 --- a/src/easydynamics/sample_model/components/voigt.py +++ b/src/easydynamics/sample_model/components/voigt.py @@ -55,6 +55,28 @@ def __init__( display_name: str | None = 'Voigt', unique_name: str | None = None, ): + """Initialize a Voigt component. + + Args: + area (Int | float): Total area under the curve. + center (Int | float | None): Center of the Voigt profile. + gaussian_width (Int | float): Standard deviation of the + Gaussian part. + lorentzian_width (Int | float): Half width at half max + (HWHM) of the Lorentzian part. + unit (str | sc.Unit): Unit of the parameters. Defaults to + "meV" + display_name (str | None): Display name of the component. + unique_name (str | None): Unique name of the component. + If None, a unique_name is automatically generated. + + Raises: + TypeError: If any of the parameters are not of the correct + type. + ValueError: If any of the parameters are not valid (e.g. + negative widths). + """ + super().__init__( display_name=display_name, unit=unit, @@ -199,9 +221,9 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) Args: - x (Numeric or list or np.ndarray or sc.Variable or - sc.DataArray): - The x values at which to evaluate the Voigt. + x (Numeric | list[Numeric] | np.ndarray | sc.Variable | + sc.DataArray): The x values at which to evaluate the + Voigt. Returns: np.ndarray: The intensity of the Voigt at the given x @@ -216,7 +238,7 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) self.lorentzian_width.value, ) - def __repr__(self): + def __repr__(self) -> str: """Return a string representation of the Voigt. Returns: diff --git a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py index 1ecb8c2a..2198e032 100644 --- a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py @@ -205,7 +205,7 @@ def create_component_collections( Args: Q (Number, list, or np.ndarray): Scattering vector values. component_display_name (str): Name of the Lorentzian - component. + component. Returns: List[ComponentCollection]: List of ComponentCollections with @@ -330,13 +330,13 @@ def _write_area_dependency_map_expression(self) -> Dict[str, DescriptorNumber]: # dunder methods # ------------------------------------------------------------------ - def __repr__(self): + def __repr__(self) -> str: """String representation of the BrownianTranslationalDiffusion model. Returns: str: String representation of the - BrownianTranslationalDiffusion model. + BrownianTranslationalDiffusion model. """ return ( f'BrownianTranslationalDiffusion(display_name={self.display_name},' diff --git a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py index 096272ee..603dced8 100644 --- a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py +++ b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py @@ -131,7 +131,7 @@ def scale(self, scale: Numeric) -> None: # dunder methods # ------------------------------------------------------------------ - def __repr__(self): + def __repr__(self) -> str: """String representation of the Diffusion model. Returns: 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 b7e28573..3fa8959d 100644 --- a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py @@ -73,23 +73,24 @@ def __init__( """Initialize a new JumpTranslationalDiffusion model. Args: - display_name (str): Display name of the diffusion model. - unique_name (str | None): Unique name of the diffusion model. If - None, a unique name will be generated. - unit (str | sc.Unit): Unit of the diffusion model. Must be - convertible to meV. Defaults to "meV". - scale (Numeric): Scale factor for the diffusion model. Must be - a non-negative number. Defaults to 1.0. - diffusion_coefficient (Numeric): Diffusion coefficient D in - m^2/s. Defaults to 1.0. - relaxation_time (Numeric): Relaxation time t in ps. Defaults to - 1.0. + display_name (str): Display name of the diffusion model. + unique_name (str | None): Unique name of the diffusion + model. If + None, a unique name will be generated. + unit (str | sc.Unit): Unit of the diffusion model. Must be + convertible to meV. Defaults to "meV". + scale (Numeric): Scale factor for the diffusion model. Must + be a non-negative number. Defaults to 1.0. + diffusion_coefficient (Numeric): Diffusion coefficient D in + m^2/s. Defaults to 1.0. + relaxation_time (Numeric): Relaxation time t in ps. Defaults + to 1.0. Raises: - TypeError: If scale, diffusion_coefficient, or relaxation_time - are not numbers. - ValueError: If scale is negative. - UnitError: If unit is not a string or scipp Unit + TypeError: If scale, diffusion_coefficient, or + relaxation_time are not numbers. + ValueError: If scale is negative. + UnitError: If unit is not a valid unit string or scipp Unit. """ super().__init__( display_name=display_name, @@ -376,7 +377,7 @@ def _write_area_dependency_map_expression(self) -> Dict[str, DescriptorNumber]: # dunder methods ################################ - def __repr__(self): + def __repr__(self) -> str: """String representation of the JumpTranslationalDiffusion model. diff --git a/src/easydynamics/sample_model/instrument_model.py b/src/easydynamics/sample_model/instrument_model.py index 732955fc..aadd543f 100644 --- a/src/easydynamics/sample_model/instrument_model.py +++ b/src/easydynamics/sample_model/instrument_model.py @@ -64,6 +64,37 @@ def __init__( energy_offset: Numeric | None = None, unit: str | sc.Unit = 'meV', ): + """Initialize an InstrumentModel. + + Args: + display_name (str | None): The display name of the + InstrumentModel. Default is "MyInstrumentModel". + unique_name (str | None): The unique name of the + InstrumentModel. Default is None. + Q (np.ndarray | list | sc.Variable | None): The Q values + where the instrument is modelled. + resolution_model (ResolutionModel | None): The resolution + model of the instrument. If None, an empty resolution + model is created and no resolution convolution is + carried out. Default is None. + background_model (BackgroundModel | None): The background + model of the instrument. If None, an empty background + model is created, and the background evaluates to 0. + Default is None. + energy_offset (float | int | None): Template energy offset + of the instrument. Will be copied to each Q value. If + None, the energy offset will be 0. Default is None. + unit (str | sc.Unit): The unit of the energy axis. Default + is 'meV'. + + Raises: + TypeError: If resolution_model is not a ResolutionModel or + None + TypeError: If background_model is not a BackgroundModel or + None + TypeError: If energy_offset is not a number or None + UnitError: If unit is not a valid unit string or scipp Unit. + """ super().__init__( display_name=display_name, unique_name=unique_name, @@ -379,7 +410,7 @@ def _on_background_model_change(self) -> None: # Dunder methods # ------------------------------------------------------------- - def __repr__(self): + def __repr__(self) -> str: """Return a string representation of the InstrumentModel. Returns: diff --git a/src/easydynamics/sample_model/model_base.py b/src/easydynamics/sample_model/model_base.py index 18098cd0..cfd2cb89 100644 --- a/src/easydynamics/sample_model/model_base.py +++ b/src/easydynamics/sample_model/model_base.py @@ -32,15 +32,14 @@ class ModelBase(EasyScienceModelBase): Template components of the model. If None, no components are added. These components are copied into ComponentCollections for each Q value. - Q (Q_type | None): Q values for the model. If None, Q is not - set. + Q (ArrayLike | sc.Variable | None): Q values for the model. If + None, Q is not set. Attributes: unit (str | sc.Unit): Unit of the model. components (list[ModelComponent]): List of ModelComponents in the model. - Q (np.ndarray | Numeric | list | ArrayLike | sc.Variable - | None): Q values of the model. + Q (ArrayLike | sc.Variable | None): Q values of the model. """ def __init__( @@ -51,6 +50,21 @@ def __init__( components: ModelComponent | ComponentCollection | None = None, Q: Q_type | None = None, ): + """Initialize the ModelBase. + + Args: + display_name (str): Display name of the model. + unique_name (str | None): Unique name of the model. If None, + a unique name will be generated. + unit (str | sc.Unit | None): Unit of the model. Defaults to + "meV". + components (ModelComponent | ComponentCollection | None): + Template components of the model. If None, no components + are added. These components are copied into + ComponentCollections for each Q value. + Q (ArrayLike | sc.Variable | None): Q values for the model. + If None, Q is not set. + """ super().__init__( display_name=display_name, unique_name=unique_name, @@ -111,8 +125,8 @@ def append_component(self, component: ModelComponent | ComponentCollection) -> N SampleModel. Args: - component (ModelComponent | ComponentCollection): - The ModelComponent or ComponentCollection to append. + component (ModelComponent | ComponentCollection): The + ModelComponent or ComponentCollection to append. """ self._components.append_component(component) self._on_components_change() @@ -123,7 +137,7 @@ def remove_component(self, unique_name: str) -> None: Args: unique_name (str): The unique name of the ModelComponent - to remove. + to remove. """ self._components.remove_component(unique_name) self._on_components_change() @@ -350,7 +364,7 @@ def _on_components_change(self) -> None: # dunder methods # ------------------------------------------------------------------ - def __repr__(self): + def __repr__(self) -> str: """Return a string representation of the ModelBase. Returns: diff --git a/src/easydynamics/sample_model/resolution_model.py b/src/easydynamics/sample_model/resolution_model.py index ae042b0c..d4d23e39 100644 --- a/src/easydynamics/sample_model/resolution_model.py +++ b/src/easydynamics/sample_model/resolution_model.py @@ -45,6 +45,27 @@ def __init__( components: ComponentCollection | ModelComponent | None = None, Q: Q_type | None = None, ): + """Initialize a ResolutionModel. + + Args: + display_name (str): Display name of the model. + unique_name (str | None): Unique name of the model. If None, + a unique name will be generated. + unit (str | sc.Unit | None): Unit of the model. Defaults to + "meV". + components (ModelComponent | ComponentCollection | None): + Template components of the model. If None, no components + are added. These components are copied into + ComponentCollections for each Q value. + Q (Q_type | None): Q values for the model. If None, Q is not + set. + + Raises: + TypeError: If components is not a ModelComponent or + ComponentCollection. + ValueError: If Q is not a valid Q_type. + """ + super().__init__( display_name=display_name, unique_name=unique_name, @@ -53,7 +74,7 @@ def __init__( Q=Q, ) - def append_component(self, component: ModelComponent | ComponentCollection): + def append_component(self, component: ModelComponent | ComponentCollection) -> None: """Append a component to the ResolutionModel. Does not allow DeltaFunction or Polynomial components, as these @@ -61,7 +82,7 @@ def append_component(self, component: ModelComponent | ComponentCollection): Args: component (ModelComponent | ComponentCollection): - Component(s) to append. + Component(s) to append. Raises: TypeError: If the component is a DeltaFunction or Polynomial diff --git a/src/easydynamics/sample_model/sample_model.py b/src/easydynamics/sample_model/sample_model.py index ce94bbe5..ef5cb9ab 100644 --- a/src/easydynamics/sample_model/sample_model.py +++ b/src/easydynamics/sample_model/sample_model.py @@ -44,6 +44,19 @@ class SampleModel(ModelBase): Defaults to "K". divide_by_temperature (bool): Whether to divide the detailed balance factor by temperature. Defaults to True. + + Attributes: + unit (str | sc.Unit): Unit of the model. + components (list[ModelComponent]): List of ModelComponents in + the model. + Q (np.ndarray | Numeric | list | ArrayLike | sc.Variable + | None): Q values of the model. + diffusion_models (list[DiffusionModelBase]): List of diffusion + models in the SampleModel. + temperature (Parameter | None): Temperature Parameter for + detailed balancing, or None if not set. + divide_by_temperature (bool): Whether to divide the detailed + balance factor by temperature. """ def __init__( @@ -58,6 +71,38 @@ def __init__( temperature_unit: str | sc.Unit = 'K', divide_by_temperature: bool = True, ): + """Initialize the SampleModel. + + Args: + display_name (str): Display name of the model. + unique_name (str | None): Unique name of the model. If None, + a unique name will be generated. + unit (str | sc.Unit | None): Unit of the model. If None, + defaults to "meV". + components (ModelComponent | ComponentCollection | None): + Template components of the model. If None, no components + are added. These components are copied into + ComponentCollections for each Q value. + Q (Number, list, np.ndarray, sc.array | None): + Q values for the model. If None, Q is not set. + diffusion_models (DiffusionModelBase | + list[DiffusionModelBase] | None): Diffusion models to + include in the SampleModel. If None, no diffusion models + are added. + temperature (float | None): Temperature for detailed + balancing. If None, no detailed balancing is applied. + temperature_unit (str | sc.Unit): Unit of the temperature. + Defaults to "K". + divide_by_temperature (bool): Whether to divide the detailed + balance factor by temperature. Defaults to True. + + Raises: + TypeError: If diffusion_models is not a DiffusionModelBase, + a list of DiffusionModelBase, or None. + TypeError: If temperature is not a number or None. + ValueError: If temperature is negative. + TypeError: If divide_by_temperature is not a bool. + """ if diffusion_models is None: self._diffusion_models = [] elif isinstance(diffusion_models, DiffusionModelBase): @@ -110,7 +155,7 @@ def append_diffusion_model(self, diffusion_model: DiffusionModelBase) -> None: Args: diffusion_model (DiffusionModelBase): The DiffusionModel - to append. + to append. Raises: TypeError: If the diffusion_model is not a @@ -133,7 +178,7 @@ def remove_diffusion_model(self, name: 'str') -> None: Raises: ValueError: If no DiffusionModel with the given unique name - is found. + is found. """ for i, dm in enumerate(self._diffusion_models): if dm.unique_name == name: @@ -395,7 +440,7 @@ def _on_diffusion_models_change(self) -> None: # dunder methods # ------------------------------------------------------------------ - def __repr__(self): + def __repr__(self) -> str: """Return a string representation of the SampleModel. Returns: diff --git a/src/easydynamics/utils/detailed_balance.py b/src/easydynamics/utils/detailed_balance.py index 8afcd47c..0790412b 100644 --- a/src/easydynamics/utils/detailed_balance.py +++ b/src/easydynamics/utils/detailed_balance.py @@ -29,13 +29,16 @@ def _detailed_balance_factor( temperature_unit: str | sc.Unit = 'K', divide_by_temperature: bool = True, ) -> np.ndarray: - """ - Compute the detailed balance factor (DBF): $DBF(E, T) = E*(n(E)+1)=E - / (1 - exp(-E / (kB*T)))$, where $n(E)$ is the Bose-Einstein - distribution, $E$ is the energy transfer, and $T$ is the - temperature. $k_B$ is the Boltzmann constant. If - divide_by_temperature is True, the result is normalized by kB*T to - have value 1 at E=0. + r""" + Compute the detailed balance factor (DBF): + $$ + DBF(E, T) = E(n(E)+1)=\frac{E}{(1 - e^{-E / (k_B*T)})}}, + $$ + where $n(E)$ is the Bose-Einstein distribution, $E$ is the energy + transfer, and $T$ is the temperature. $k_B$ is the Boltzmann + constant. + If divide_by_temperature is True, the result is normalized by + $k_B*T$ to have value 1 at $E=0$. Args: energy (number | list | np.ndarray | scipp.Variable): The energy diff --git a/src/easydynamics/utils/utils.py b/src/easydynamics/utils/utils.py index e3cc842d..0ac12ab6 100644 --- a/src/easydynamics/utils/utils.py +++ b/src/easydynamics/utils/utils.py @@ -8,20 +8,18 @@ Numeric = float | int Q_type = np.ndarray | Numeric | list | ArrayLike | sc.Variable +energy_type = np.ndarray | Numeric | list | ArrayLike | sc.Variable def _validate_and_convert_Q(Q: Q_type | None) -> np.ndarray | None: """Validate and convert Q to a numpy array. - Parameters - ---------- - Q : Number, list, np.ndarray or sc.Variable - Scattering vector values in 1/angstrom. - Returns - ------- - np.ndarray - Q as a np.ndarray. - TODO: Update to sc.array, also propagate that to diffusionmodel + Args: + Q (Number, list, np.ndarray or sc.Variable): Scattering vector + values in 1/angstrom. + + Returns: + np.ndarray: Q as a np.ndarray. """ if Q is None: return None @@ -48,19 +46,14 @@ def _validate_and_convert_Q(Q: Q_type | None) -> np.ndarray | None: def _validate_unit(unit: str | sc.Unit | None) -> sc.Unit | None: """Validate that the unit is a string or scipp Unit. - Parameters - ---------- - unit : str or sc.Unit or None - Unit to validate. - Returns - ------- - sc.Unit | None - Validated unit or None. - - Raises - ------ - TypeError - If unit is not None, a string, or a scipp Unit. + Args: + unit (str | sc.Unit | None): Unit to validate. + + Returns: + sc.Unit | None: Validated unit or None. + + Raises: + TypeError: If unit is not None, a string, or a scipp Unit. """ if unit is not None and not isinstance(unit, (str, sc.Unit)): diff --git a/tests/unit/easydynamics/analysis/test_analysis_base.py b/tests/unit/easydynamics/analysis/test_analysis_base.py index f4e937ad..b2dabf00 100644 --- a/tests/unit/easydynamics/analysis/test_analysis_base.py +++ b/tests/unit/easydynamics/analysis/test_analysis_base.py @@ -261,7 +261,7 @@ def test_extra_parameters_property(self, analysis_base, extra_parameters): def test_extra_parameters_setter_invalid_type(self, analysis_base, invalid_extra_parameters): with pytest.raises( TypeError, - match='extra_parameters must be a Parameter or a list of Parameters.', + match='extra_parameters must be', ): analysis_base.extra_parameters = invalid_extra_parameters From 12c957c4442dd34e8cff8db19e6b5f3a01dc5fb7 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Thu, 5 Mar 2026 14:29:35 +0100 Subject: [PATCH 9/9] Apply updated templates with fixes and improvements (#114) * Update pixi lock * Apply new templates * Switch easyscience dep to PyPI * Apply linting and formatting * Add spdx-headers package * Update SPDX headers * Apply new templates * Switch easyscience to develop * Apply updated templates * Fix docstring formatting * Fix API docs links; add analysis to nav * Add instrument model and analysis tutorials * Add experiment API reference page * Switch easyscience to PyPI dependency * Apply templates 0.10.0. Fix spdx headers * Apply formatting --- .copier-answers.yml | 9 +- .github/actions/publish-to-pypi/action.yml | 14 - .github/actions/setup-pixi/action.yml | 2 +- .github/configs/pages-deployment.json | 6 + .github/configs/rulesets-develop.json | 37 + .github/configs/rulesets-gh-pages.json | 19 + .github/configs/rulesets-master.json | 30 + .github/scripts/backmerge-conflict-issue.js | 69 ++ .github/workflows/backmerge.yml | 101 ++- .github/workflows/cleanup.yml | 7 +- .github/workflows/dashboard.yml | 3 + .github/workflows/docs.yml | 10 +- .github/workflows/issues-labels.yml | 42 + .../{quality.yml => lint-format.yml} | 41 +- .../workflows/{labels.yml => pr-labels.yml} | 18 +- .github/workflows/pypi-publish.yml | 4 +- .github/workflows/pypi-test.yml | 3 + .github/workflows/release-pr.yml | 24 +- .github/workflows/security.yml | 103 ++- .github/workflows/test-trigger.yml | 3 + .github/workflows/test.yml | 3 +- .github/workflows/tutorial-tests-trigger.yml | 3 + .github/workflows/tutorial-tests.yml | 5 +- .gitignore | 14 +- .pre-commit-config.yaml | 7 + CONTRIBUTING.md | 408 +++++++++ README.md | 24 +- docs/docs/api-reference/experiment.md | 1 + docs/docs/api-reference/index.md | 4 +- docs/docs/assets/javascripts/mathjax.js | 27 + docs/docs/index.md | 2 +- docs/docs/installation-and-setup/index.md | 37 +- docs/docs/introduction/index.md | 30 +- docs/docs/tutorials/index.md | 19 +- docs/mkdocs.yml | 17 +- pixi.lock | 816 ++++-------------- pixi.toml | 49 +- pyproject.toml | 80 +- src/easydynamics/__init__.py | 2 +- src/easydynamics/analysis/__init__.py | 2 +- src/easydynamics/analysis/analysis.py | 3 +- src/easydynamics/analysis/analysis1d.py | 2 +- src/easydynamics/analysis/analysis_base.py | 2 +- src/easydynamics/convolution/__init__.py | 2 +- .../convolution/analytical_convolution.py | 6 +- src/easydynamics/convolution/convolution.py | 7 +- .../convolution/convolution_base.py | 6 +- src/easydynamics/convolution/energy_grid.py | 2 +- .../convolution/numerical_convolution.py | 6 +- .../convolution/numerical_convolution_base.py | 7 +- src/easydynamics/experiment/__init__.py | 2 +- src/easydynamics/experiment/experiment.py | 9 +- src/easydynamics/sample_model/__init__.py | 2 +- .../sample_model/background_model.py | 6 +- .../sample_model/component_collection.py | 2 +- .../sample_model/components/__init__.py | 2 +- .../components/damped_harmonic_oscillator.py | 7 +- .../sample_model/components/delta_function.py | 7 +- .../sample_model/components/gaussian.py | 5 +- .../sample_model/components/lorentzian.py | 5 +- .../sample_model/components/mixins.py | 2 +- .../components/model_component.py | 6 +- .../sample_model/components/polynomial.py | 5 +- .../sample_model/components/voigt.py | 7 +- .../sample_model/diffusion_model/__init__.py | 2 +- .../brownian_translational_diffusion.py | 2 +- .../diffusion_model/diffusion_model_base.py | 2 +- .../jump_translational_diffusion.py | 6 +- .../sample_model/instrument_model.py | 2 +- src/easydynamics/sample_model/model_base.py | 2 +- .../sample_model/resolution_model.py | 6 +- src/easydynamics/sample_model/sample_model.py | 20 +- src/easydynamics/utils/__init__.py | 2 +- src/easydynamics/utils/detailed_balance.py | 2 +- src/easydynamics/utils/utils.py | 2 +- tests/conftest.py | 2 +- tests/integration/fitting/test_import.py | 2 +- .../integration/scipp-analysis/test_import.py | 2 +- .../easydynamics/analysis/test_analysis.py | 3 + .../easydynamics/analysis/test_analysis1d.py | 3 + .../analysis/test_analysis_base.py | 3 +- .../test_analytical_convolution.py | 2 +- .../convolution/test_convolution.py | 2 +- .../convolution/test_convolution_base.py | 2 +- .../convolution/test_energy_grid.py | 2 +- .../convolution/test_numerical_convolution.py | 2 +- .../test_numerical_convolution_base.py | 2 +- .../experiment/test_experiment.py | 3 + .../test_damped_harmonic_oscillator.py | 2 +- .../components/test_delta_function.py | 2 +- .../sample_model/components/test_gaussian.py | 2 +- .../components/test_lorentzian.py | 2 +- .../sample_model/components/test_mixins.py | 2 +- .../components/test_model_component.py | 2 +- .../components/test_polynomial.py | 2 +- .../sample_model/components/test_voigt.py | 2 +- .../test_brownian_translational_diffusion.py | 2 +- .../diffusion_model/test_diffusion_model.py | 2 +- .../test_jump_translational_diffusion.py | 3 + .../sample_model/test_background_model.py | 2 +- .../sample_model/test_component_collection.py | 2 +- .../sample_model/test_instrument_model.py | 3 +- .../sample_model/test_model_base.py | 2 +- .../sample_model/test_resolution_model.py | 2 +- .../sample_model/test_sample_model.py | 2 +- tests/unit/easydynamics/test_import.py | 2 +- .../utils/test_detailed_balance.py | 2 +- tests/unit/easydynamics/utils/test_utils.py | 2 +- tools/add_spdx.py | 149 ++++ tools/check_spdx.py | 43 + tools/remove_spdx.py | 39 + tools/update_github_labels.py | 367 +++++--- tools/update_spdx.py | 104 --- 113 files changed, 1804 insertions(+), 1223 deletions(-) delete mode 100644 .github/actions/publish-to-pypi/action.yml create mode 100644 .github/configs/pages-deployment.json create mode 100644 .github/configs/rulesets-develop.json create mode 100644 .github/configs/rulesets-gh-pages.json create mode 100644 .github/configs/rulesets-master.json create mode 100644 .github/scripts/backmerge-conflict-issue.js create mode 100644 .github/workflows/issues-labels.yml rename .github/workflows/{quality.yml => lint-format.yml} (77%) rename .github/workflows/{labels.yml => pr-labels.yml} (71%) create mode 100644 CONTRIBUTING.md create mode 100644 docs/docs/api-reference/experiment.md create mode 100644 docs/docs/assets/javascripts/mathjax.js create mode 100644 tools/add_spdx.py create mode 100644 tools/check_spdx.py create mode 100644 tools/remove_spdx.py delete mode 100644 tools/update_spdx.py diff --git a/.copier-answers.yml b/.copier-answers.yml index eeff466a..5751d20f 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,12 +1,14 @@ # WARNING: Do not edit this file manually. # Any changes will be overwritten by Copier. -_commit: v0.4.2 +_commit: v0.10.0 _src_path: gh:easyscience/templates app_docs_url: https://easyscience.github.io/dynamics-app app_doi: 10.5281/zenodo.18163581 app_package_name: easydynamics_app app_python: '3.12' app_repo_name: dynamics-app +home_page_url: https://easyscience.github.io/dynamics +home_repo_name: dynamics lib_docs_url: https://easyscience.github.io/dynamics-lib lib_doi: 10.5281/zenodo.18163581 lib_package_name: easydynamics @@ -15,10 +17,9 @@ lib_python_min: '3.11' lib_repo_name: dynamics-lib project_contact_email: henrik.jacobsen@ess.eu project_copyright_years: 2025-2026 -project_extended_description: For plotting and fitting QENS and INS powder data -project_homepage_url: https://easyscience.github.io/dynamics +project_extended_description: A software for plotting and fitting QENS and INS powder + data project_name: EasyDynamics -project_repo_name: dynamics project_short_description: QENS data analysis project_shortcut: EQ project_type: both diff --git a/.github/actions/publish-to-pypi/action.yml b/.github/actions/publish-to-pypi/action.yml deleted file mode 100644 index 719928d9..00000000 --- a/.github/actions/publish-to-pypi/action.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: 'Publish to PyPI' -description: 'Publish dist/ to PyPI via Trusted Publishing (OIDC)' -inputs: - packages_dir: - description: 'Directory containing the built packages to upload' - required: false - default: 'dist' - -runs: - using: 'composite' - steps: - - uses: pypa/gh-action-pypi-publish@release/v1 - with: - packages-dir: ${{ inputs.packages_dir }} diff --git a/.github/actions/setup-pixi/action.yml b/.github/actions/setup-pixi/action.yml index eb891e1f..167ee623 100644 --- a/.github/actions/setup-pixi/action.yml +++ b/.github/actions/setup-pixi/action.yml @@ -33,7 +33,7 @@ inputs: runs: using: 'composite' steps: - - uses: prefix-dev/setup-pixi@v0.9.3 + - uses: prefix-dev/setup-pixi@v0.9.4 with: environments: ${{ inputs.environments }} activate-environment: ${{ inputs.activate-environment }} diff --git a/.github/configs/pages-deployment.json b/.github/configs/pages-deployment.json new file mode 100644 index 00000000..c0d3fbee --- /dev/null +++ b/.github/configs/pages-deployment.json @@ -0,0 +1,6 @@ +{ + "source": { + "branch": "gh-pages", + "path": "/" + } +} diff --git a/.github/configs/rulesets-develop.json b/.github/configs/rulesets-develop.json new file mode 100644 index 00000000..04489e52 --- /dev/null +++ b/.github/configs/rulesets-develop.json @@ -0,0 +1,37 @@ +{ + "name": "develop branch", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["refs/heads/develop"], + "exclude": [] + } + }, + "bypass_actors": [ + { + "actor_id": 2476259, + "actor_type": "Integration", + "bypass_mode": "always" + } + ], + "rules": [ + { + "type": "non_fast_forward" + }, + { + "type": "deletion" + }, + { + "type": "pull_request", + "parameters": { + "allowed_merge_methods": ["squash"], + "dismiss_stale_reviews_on_push": false, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_approving_review_count": 0, + "required_review_thread_resolution": false + } + } + ] +} diff --git a/.github/configs/rulesets-gh-pages.json b/.github/configs/rulesets-gh-pages.json new file mode 100644 index 00000000..ebf38928 --- /dev/null +++ b/.github/configs/rulesets-gh-pages.json @@ -0,0 +1,19 @@ +{ + "name": "gh-pages branch", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["refs/heads/gh-pages"], + "exclude": [] + } + }, + "rules": [ + { + "type": "non_fast_forward" + }, + { + "type": "deletion" + } + ] +} diff --git a/.github/configs/rulesets-master.json b/.github/configs/rulesets-master.json new file mode 100644 index 00000000..f658a5c6 --- /dev/null +++ b/.github/configs/rulesets-master.json @@ -0,0 +1,30 @@ +{ + "name": "master branch", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["~DEFAULT_BRANCH"], + "exclude": [] + } + }, + "rules": [ + { + "type": "non_fast_forward" + }, + { + "type": "deletion" + }, + { + "type": "pull_request", + "parameters": { + "allowed_merge_methods": ["merge"], + "dismiss_stale_reviews_on_push": false, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_approving_review_count": 0, + "required_review_thread_resolution": false + } + } + ] +} diff --git a/.github/scripts/backmerge-conflict-issue.js b/.github/scripts/backmerge-conflict-issue.js new file mode 100644 index 00000000..f6bd98b5 --- /dev/null +++ b/.github/scripts/backmerge-conflict-issue.js @@ -0,0 +1,69 @@ +module.exports = async ({ github, context, core }) => { + // Repo context + const owner = context.repo.owner + const repo = context.repo.repo + + // Link to the exact workflow run that detected the conflict + const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}` + + // We use a *stable title* so we can find/reuse the same "conflict tracker" issue + // instead of creating a new issue on every failed run. + const title = 'Backmerge conflict: master → develop' + + // Comment/issue body includes the run URL so maintainers can jump straight to logs. + const body = [ + 'Automatic backmerge failed due to merge conflicts.', + '', + `Workflow run: ${runUrl}`, + '', + 'Manual resolution required.', + ].join('\n') + + // Label applied to the tracker issue (assumed to already exist in the repo). + const label = '[bot] backmerge' + + // Search issues by title across *open and closed* issues. + // Why: if the conflict was resolved previously and the issue was closed, + // we prefer to reopen it and append a new comment instead of creating duplicates. + const q = `repo:${owner}/${repo} is:issue in:title "${title}"` + const search = await github.rest.search.issuesAndPullRequests({ + q, + per_page: 10, + }) + + // Pick the first exact-title match (search can return partial matches). + const existing = search.data.items.find((i) => i.title === title) + + if (existing) { + // If a tracker issue exists, reuse it: + // - reopen it if needed + // - add a comment with the new run URL + if (existing.state === 'closed') { + await github.rest.issues.update({ + owner, + repo, + issue_number: existing.number, + state: 'open', + }) + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: existing.number, + body, + }) + + core.notice(`Conflict issue updated: #${existing.number}`) + return + } + + // No tracker issue exists yet -> create the first one. + await github.rest.issues.create({ + owner, + repo, + title, + body, + labels: [label], + }) +} diff --git a/.github/workflows/backmerge.yml b/.github/workflows/backmerge.yml index abe0ef02..47b3384a 100644 --- a/.github/workflows/backmerge.yml +++ b/.github/workflows/backmerge.yml @@ -1,34 +1,29 @@ # This workflow automatically merges `master` into `develop` whenever a -# new version tag is pushed (v*). -# -# Key points: -# - Directly merges master into develop without creating a PR. -# - Skips CI on the merge commit using [skip ci] in the commit message. -# - The code being merged has already been tested as part of the release process. -# - This ensures develop stays up-to-date with release changes (version bumps, etc.). -# -# Required organization config: -# https://github.com/organizations/easyscience/settings/secrets/actions -# https://github.com/organizations/easyscience/settings/variables/actions -# - Actions secret: EASYSCIENCE_APP_KEY (GitHub App private key PEM) -# - Actions variable: EASYSCIENCE_APP_ID (GitHub App ID) -# -# IMPORTANT: -# The GitHub App must be added to the develop branch ruleset bypass list. - -name: Backmerge (master -> develop) +# new version release with a tag is published. It can also be triggered +# manually via workflow_dispatch for cases where an automatic backmerge +# is needed outside of the standard release process. +# If a merge conflict occurs, the workflow creates an issue to notify +# maintainers for manual resolution. + +name: Backmerge (master → develop) on: - push: - tags: ['v*'] + release: + types: [published, prereleased] workflow_dispatch: permissions: contents: write + issues: write + +concurrency: + group: backmerge-master-into-develop + cancel-in-progress: false jobs: backmerge: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout repository (for local actions) @@ -53,34 +48,62 @@ jobs: git config user.name "easyscience[bot]" git config user.email "${{ vars.EASYSCIENCE_APP_ID }}+easyscience[bot]@users.noreply.github.com" - - name: Merge master into develop + - name: Set merge message run: | - set -euo pipefail + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + MESSAGE="Backmerge: master into develop (manual) [skip ci]" + else + TAG="${{ github.event.release.tag_name }}" + MESSAGE="Backmerge: master (${TAG}) into develop [skip ci]" + fi - echo "Fetching develop branch" - git fetch origin develop:develop + echo "MESSAGE=$MESSAGE" >> "$GITHUB_ENV" + echo "message=$MESSAGE" >> "$GITHUB_OUTPUT" + echo "📝 Merge message: $MESSAGE" | tee -a "$GITHUB_STEP_SUMMARY" - echo "Switching to develop branch" - git checkout develop + - name: Prepare branches + run: | + git fetch origin master develop + git checkout -B develop origin/develop - echo "Checking if already up-to-date" + - name: Check if develop is already up-to-date + id: up_to_date + run: | if git merge-base --is-ancestor origin/master develop; then - echo "ℹ️ Develop is already up-to-date with master" - exit 0 + echo "value=true" >> "$GITHUB_OUTPUT" + echo "ℹ️ Develop is already up-to-date with master" | tee -a "$GITHUB_STEP_SUMMARY" + else + echo "value=false" >> "$GITHUB_OUTPUT" fi - echo "Preparing merge message" - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - MESSAGE="Backmerge: master into develop (manual) [skip ci]" - else - TAG="${{ github.ref_name }}" - MESSAGE="Backmerge: ${TAG} from master into develop [skip ci]" + - name: Try merge master into develop + id: merge + if: steps.up_to_date.outputs.value == 'false' + continue-on-error: true + run: | + if ! git merge origin/master --no-ff -m "${MESSAGE}"; then + echo "conflict=true" >> "$GITHUB_OUTPUT" + echo "❌ Backmerge conflict detected." | tee -a "$GITHUB_STEP_SUMMARY" + git status --porcelain || true + exit 0 fi - echo "Merging master into develop" - git merge origin/master --no-ff -m "${MESSAGE}" + echo "conflict=false" >> "$GITHUB_OUTPUT" + echo "✅ Merge commit created." | tee -a "$GITHUB_STEP_SUMMARY" - echo "Pushing to develop" + - name: Push to develop (if merge succeeded) + if: + steps.up_to_date.outputs.value == 'false' && steps.merge.outputs.conflict == + 'false' + run: | git push origin develop + echo "🚀 Backmerge successful: master → develop" | tee -a "$GITHUB_STEP_SUMMARY" - echo "✅ Successfully merged master into develop" + - name: Create issue (if merge failed with conflicts) + if: steps.merge.outputs.conflict == 'true' + uses: ./.github/actions/github-script + with: + github-token: ${{ steps.bot.outputs.token }} + script: | + const run = require('./.github/scripts/backmerge-conflict-issue.js') + await run({ github, context, core }) diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml index 7d9d11e3..7305679b 100644 --- a/.github/workflows/cleanup.yml +++ b/.github/workflows/cleanup.yml @@ -53,8 +53,13 @@ on: - skipped - success dry_run: - description: 'Only log actions, do not perform any delete operations.' + description: 'Only log actions, do not perform any delete operations (dry run).' required: false + default: 'false' + type: choice + options: + - 'false' + - 'true' jobs: del-runs: diff --git a/.github/workflows/dashboard.yml b/.github/workflows/dashboard.yml index f0178085..df89dfb7 100644 --- a/.github/workflows/dashboard.yml +++ b/.github/workflows/dashboard.yml @@ -4,6 +4,9 @@ on: workflow_dispatch: workflow_call: +permissions: + contents: read + # Set the environment variables to be used in all jobs defined in this workflow env: CI_BRANCH: ${{ github.head_ref || github.ref_name }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4056c1c0..52ccd8e2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -14,16 +14,16 @@ name: Docs build and deployment on: - # Trigger the workflow on pull request - pull_request: - # Selected branches - branches: [master, main, develop] # Trigger the workflow on push push: # Selected branches - branches: [master, main, develop] + branches: [develop] # master and main are already verified in PR # Runs on creating a new tag starting with 'v', e.g. 'v1.0.3' tags: ['v*'] + # Trigger the workflow on pull request + pull_request: + # Selected branches + branches: [master, main, develop] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/.github/workflows/issues-labels.yml b/.github/workflows/issues-labels.yml new file mode 100644 index 00000000..3a60cdd7 --- /dev/null +++ b/.github/workflows/issues-labels.yml @@ -0,0 +1,42 @@ +# Verifies if an issue has at least one of the `[scope]` and one of the +# `[priority]` labels. If not, the bot adds labels with a warning emoji +# to indicate that those labels need to be added. + +name: Issue labels check + +on: + issues: + types: [opened, labeled, unlabeled] + +permissions: + issues: write + +jobs: + check-labels: + runs-on: ubuntu-latest + + steps: + - name: Setup easyscience[bot] + id: bot + uses: ./.github/actions/setup-easyscience-bot + with: + app-id: ${{ vars.EASYSCIENCE_APP_ID }} + private-key: ${{ secrets.EASYSCIENCE_APP_KEY }} + + - name: Check for required [scope] label + uses: trstringer/require-label-prefix@v1 + with: + secret: ${{ steps.bot.outputs.token }} + prefix: '[scope]' + labelSeparator: ' ' + addLabel: true + defaultLabel: '[scope] ⚠️ label needed' + + - name: Check for required [priority] label + uses: trstringer/require-label-prefix@v1 + with: + secret: ${{ steps.bot.outputs.token }} + prefix: '[priority]' + labelSeparator: ' ' + addLabel: true + defaultLabel: '[priority] ⚠️ label needed' diff --git a/.github/workflows/quality.yml b/.github/workflows/lint-format.yml similarity index 77% rename from .github/workflows/quality.yml rename to .github/workflows/lint-format.yml index 201dace4..fc451d14 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/lint-format.yml @@ -1,17 +1,16 @@ -# The workflow is divided into several steps to ensure code quality: -# - Check the validity of pyproject.toml -# - Check code linting -# - Check code formatting -# - Check formatting of docstrings in the code -# - Check formatting of Markdown, YAML, TOML, etc. files +# The workflow is divided into several steps: +# - Check the validity of pyproject.toml +# - Check code linting +# - Check code formatting +# - Check formatting of docstrings in the code +# - Check formatting of Markdown, YAML, TOML, etc. files -name: Code quality checks +name: Lint and format checks on: # Trigger the workflow on push push: - # Every branch - branches: ['**'] + branches-ignore: [master, main] # Already verified in PR # Do not run this workflow on creating a new tag starting with # 'v', e.g. 'v1.0.3' (see publish-pypi.yml) tags-ignore: ['v*'] @@ -26,12 +25,15 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +permissions: + contents: read + # Set the environment variables to be used in all jobs defined in this workflow env: CI_BRANCH: ${{ github.head_ref || github.ref_name }} jobs: - code-quality: + lint-format: runs-on: ubuntu-latest steps: @@ -51,6 +53,14 @@ jobs: shell: bash run: pixi run pyproject-check + # Check the presence and correctness of SPDX license headers in + # the code files + - name: Check SPDX license headers + id: check_spdx_headers + continue-on-error: true + shell: bash + run: pixi run spdx-check + # Check code linting with Ruff in the project root - name: Check code linting id: check_code_linting @@ -79,14 +89,13 @@ jobs: continue-on-error: true shell: bash run: pixi run nonpy-format-check + # Check formatting of Jupyter Notebooks in the tutorials folder - name: Prepare notebooks and check formatting id: check_notebooks_formatting continue-on-error: true shell: bash - run: | - pixi run notebook-prepare - pixi run notebook-format-check + run: pixi run notebook-format-check # Add summary - name: Add quality checks summary @@ -94,24 +103,28 @@ jobs: shell: bash run: | { - echo "## 🧪 Code Quality Checks Summary" + echo "## 🧪 Checks Summary" echo "" echo "| Check | Status |" echo "|-------|--------|" echo "| pyproject.toml | ${{ steps.check_pyproject.outcome == 'success' && '✅' || '❌' }} |" + echo "| SPDX headers | ${{ steps.check_spdx_headers.outcome == 'success' && '✅' || '❌' }} |" echo "| py lint | ${{ steps.check_code_linting.outcome == 'success' && '✅' || '❌' }} |" echo "| py format | ${{ steps.check_code_formatting.outcome == 'success' && '✅' || '❌' }} |" echo "| docstring format | ${{ steps.check_docs_formatting.outcome == 'success' && '✅' || '❌' }} |" echo "| nonpy format | ${{ steps.check_others_formatting.outcome == 'success' && '✅' || '❌' }} |" + echo "| notebooks format | ${{ steps.check_notebooks_formatting.outcome == 'success' && '✅' || '❌' }} |" } >> "$GITHUB_STEP_SUMMARY" # Fail job if any check failed - name: Fail job if any check failed if: | steps.check_pyproject.outcome == 'failure' + || steps.check_spdx_headers.outcome == 'failure' || steps.check_code_linting.outcome == 'failure' || steps.check_code_formatting.outcome == 'failure' || steps.check_docs_formatting.outcome == 'failure' || steps.check_others_formatting.outcome == 'failure' + || steps.check_notebooks_formatting.outcome == 'failure' shell: bash run: exit 1 diff --git a/.github/workflows/labels.yml b/.github/workflows/pr-labels.yml similarity index 71% rename from .github/workflows/labels.yml rename to .github/workflows/pr-labels.yml index 4ea4e2fa..642cd318 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/pr-labels.yml @@ -1,7 +1,16 @@ # Verifies if a pull request has at least one label from a set of valid # labels before it can be merged. +# +# NOTE: +# This workflow may be triggered twice in quick succession when a PR is +# created: +# 1) `opened` — when the pull request is initially created +# 2) `labeled` — if labels are added immediately after creation +# (e.g. by manual labeling, another workflow, or GitHub App). +# +# These are separate GitHub events, so two workflow runs can be started. -name: PR label checks +name: PR labels check on: pull_request_target: @@ -11,16 +20,17 @@ permissions: pull-requests: read jobs: - require-label: + check-labels: runs-on: ubuntu-latest + steps: - - name: Validate required labels + - name: Check for valid labels run: | PR_LABELS=$(echo '${{ toJson(github.event.pull_request.labels.*.name) }}' | jq -r '.[]') echo "Current PR labels: $PR_LABELS" VALID_LABELS=( - "[bot] pull request" + "[bot] release" "[scope] bug" "[scope] documentation" "[scope] enhancement" diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 6e48e610..4839c4d1 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -41,4 +41,6 @@ jobs: # Repository name: dynamics-lib # Workflow name: pypi-publish.yml - name: Publish to PyPI - uses: ./.github/actions/publish-to-pypi + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: 'dist' diff --git a/.github/workflows/pypi-test.yml b/.github/workflows/pypi-test.yml index 52e7f55a..117d24d6 100644 --- a/.github/workflows/pypi-test.yml +++ b/.github/workflows/pypi-test.yml @@ -13,6 +13,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +permissions: + contents: read + # Set the environment variables to be used in all jobs defined in this workflow env: CI_BRANCH: ${{ github.head_ref || github.ref_name }} diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index e4b6a7e4..7e6fda49 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -1,21 +1,14 @@ -# This workflow creates an automated release PR from `develop` into `master`. +# This workflow creates an automated release PR from a source branch into the default branch. # # Usage: # - Triggered manually via workflow_dispatch. -# - Creates a PR titled "Release: merge develop into master". -# - Adds the label "[maintainer] auto-pull-request" so it is excluded from changelogs. +# - Creates a PR titled "Release: merge into ". +# - Adds the label "[bot] release" so it is excluded from changelogs. # - The PR body makes clear that this is automation only (no review needed). -# -# Required repo config: -# https://github.com/organizations/easyscience/settings/secrets/actions -# https://github.com/organizations/easyscience/settings/variables/actions -# - Actions secret: EASYSCIENCE_APP_KEY (GitHub App private key PEM) -# - Actions variable: EASYSCIENCE_APP_ID (GitHub App ID) -name: Release PR (develop/feature -> master) +name: 'Release PR (develop → master)' on: - # Allows you to run this workflow manually from the Actions tab workflow_dispatch: inputs: source_branch: @@ -28,7 +21,6 @@ permissions: contents: read pull-requests: write -# Set the environment variables to be used in all jobs defined in this workflow env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} SOURCE_BRANCH: ${{ inputs.source_branch || 'develop' }} @@ -50,14 +42,14 @@ jobs: private-key: ${{ secrets.EASYSCIENCE_APP_KEY }} - name: Create PR from ${{ env.SOURCE_BRANCH }} to ${{ env.DEFAULT_BRANCH }} + env: + GH_TOKEN: ${{ steps.bot.outputs.token }} run: | gh pr create \ --base ${{ env.DEFAULT_BRANCH }} \ --head ${{ env.SOURCE_BRANCH }} \ --title "Release: merge ${{ env.SOURCE_BRANCH }} into ${{ env.DEFAULT_BRANCH }}" \ - --label "[bot] pull request" \ + --label "[bot] release" \ --body "This PR is created automatically to trigger the release pipeline. It merges the accumulated changes from \`${{ env.SOURCE_BRANCH }}\` into \`${{ env.DEFAULT_BRANCH }}\`. - ⚠️ It is labeled \`[bot] pull request\` and is excluded from release notes and version bump logic." - env: - GH_TOKEN: ${{ steps.bot.outputs.token }} + ⚠️ It is labeled \`[bot] release\` and is excluded from release notes and version bump logic." diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 4bbb91fc..9b34cccf 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -1,40 +1,93 @@ -# Integrates a collection of open source static analysis tools with -# GitHub code scanning. -# https://github.com/github/ossar-action +# Code scanning (CodeQL) for vulnerabilities and insecure coding patterns. +# +# What this workflow does +# - Runs GitHub CodeQL analysis and uploads results to your repository's Security tab. +# - Triggers on PRs (so findings appear as PR checks) and on pushes to `develop`. +# - Runs on a weekly schedule. +# +# Where to find results on GitHub +# - Repository → Security → Code scanning alerts +# (You can filter by tool = CodeQL and by branch.) +# +# Where to configure on GitHub +# - Repository → Settings → Advanced Security +# Enable "GitHub Advanced Security" (if available) and configure CodeQL there. +# - Repository → Security → Code scanning alerts +# This page shows findings produced by this workflow. +# +# Notes about the scheduled run +# - Scheduled workflows are triggered from the repository's *default branch*. +# If your default branch is `master` but you want the scheduled scan to analyze +# `develop`, this workflow checks out `develop` explicitly for scheduled runs. +# +# References +# - CodeQL Action: https://github.com/github/codeql-action +# - Advanced setup docs: https://docs.github.com/en/code-security/code-scanning -name: Security scans +name: Security scans with CodeQL on: - # Trigger the workflow on pull request + # Run on pull requests so results show up as PR checks and code + # scanning alerts. pull_request: + branches: [master, main, develop] - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: + # Run on pushes (e.g., after merging PRs). + push: + branches: [master, main, develop] + + # Run weekly. (Cron is in UTC.) + schedule: + - cron: '0 3 * * 1' + +permissions: + contents: read + security-events: write jobs: - scan-security-ossar: - # OSSAR runs on windows-latest. - # ubuntu-latest and macos-latest support coming soon - runs-on: windows-latest + codeql: + name: Code scanning + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + # Keep this list tight to avoid noise and speed up runs. + language: [python, actions] steps: + # Scheduled workflows run from the default branch. + # We explicitly analyze `develop` on the schedule to keep the scan + # focused on the active dev branch. + - name: Checkout repository (scheduled → develop) + if: ${{ github.event_name == 'schedule' }} + uses: actions/checkout@v5 + with: + ref: develop + - name: Checkout repository + if: ${{ github.event_name != 'schedule' }} uses: actions/checkout@v5 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 with: - # We must fetch at least the immediate parents so that if this is - # a pull request then we can checkout the head. - fetch-depth: 2 + languages: ${{ matrix.language }} - # If this run was triggered by a pull request event, then checkout - # the head of the pull request instead of the merge commit. - - run: git checkout HEAD^2 - if: ${{ github.event_name == 'pull_request' }} + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 - - name: Run open source static analysis tools - uses: github/ossar-action@main - id: ossar + print-link: + name: Print results link + runs-on: ubuntu-latest - - name: Upload results to Security tab - uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: ${{ steps.ossar.outputs.sarifFile }} + needs: codeql + permissions: {} # no special perms needed just to print links + + steps: + - name: Add Code Scanning link to job summary + run: | + echo "## 🔎 CodeQL Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "View Code Scanning alerts here:" >> $GITHUB_STEP_SUMMARY + echo "${{ github.server_url }}/${{ github.repository }}/security/code-scanning" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/test-trigger.yml b/.github/workflows/test-trigger.yml index a68e1faa..ecf6b40c 100644 --- a/.github/workflows/test-trigger.yml +++ b/.github/workflows/test-trigger.yml @@ -7,6 +7,9 @@ on: # Allows you to run this workflow manually from the Actions tab workflow_dispatch: +permissions: + contents: read + jobs: code-tests-trigger: runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6af99cb6..3d23363d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,8 +20,7 @@ name: Code and package tests on: # Trigger the workflow on push push: - # Every branch - branches: ['**'] + branches-ignore: [master, main] # Already verified in PR # But do not run this workflow on creating a new tag starting with # 'v', e.g. 'v1.0.3' (see publish-pypi.yml) tags-ignore: ['v*'] diff --git a/.github/workflows/tutorial-tests-trigger.yml b/.github/workflows/tutorial-tests-trigger.yml index 4cbb8bb3..1bc27f4f 100644 --- a/.github/workflows/tutorial-tests-trigger.yml +++ b/.github/workflows/tutorial-tests-trigger.yml @@ -7,6 +7,9 @@ on: # Allows you to run this workflow manually from the Actions tab workflow_dispatch: +permissions: + contents: read + jobs: tutorial-tests-trigger: runs-on: ubuntu-latest diff --git a/.github/workflows/tutorial-tests.yml b/.github/workflows/tutorial-tests.yml index 55998847..4c9244d0 100644 --- a/.github/workflows/tutorial-tests.yml +++ b/.github/workflows/tutorial-tests.yml @@ -4,7 +4,7 @@ on: # Trigger the workflow on push push: # Selected branches - branches: [master, main, develop] + branches: [develop] # master and main are already verified in PR # Trigger the workflow on pull request pull_request: branches: ['**'] @@ -15,6 +15,9 @@ on: # Allows you to run this workflow manually from the Actions tab workflow_dispatch: +permissions: + contents: read + # Allow only one concurrent workflow, skipping runs queued between the run # in-progress and latest queued. And cancel in-progress runs. concurrency: diff --git a/.gitignore b/.gitignore index f7ce4ac2..0500ede3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,10 @@ __pycache__/ .venv/ .coverage +.pyc + +# Pixi +.pixi/ # PyInstaller dist/ @@ -19,16 +23,10 @@ node_modules/ # QtCreator *.autosave - -# QtCreator Qml *.qmlproject.user *.qmlproject.user.* - -# QtCreator Python *.pyproject.user *.pyproject.user.* - -# QtCreator CMake CMakeLists.txt.user* # PyCharm @@ -41,3 +39,7 @@ CMakeLists.txt.user* .DS_Store *.app *.dmg + +# Misc +*.log +*.zip diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 007d2389..feb102db 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,6 +11,13 @@ repos: pass_filenames: false stages: [manual] + - id: spdx-headers-check + name: pixi run spdx-check + entry: pixi run spdx-check + language: system + pass_filenames: false + stages: [manual] + - id: pixi-py-lint-check name: pixi run py-lint-check entry: pixi run py-lint-check diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..d3dd2479 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,408 @@ +# Contributing to EasyDynamics + +Thank you for your interest in contributing to **EasyDynamics**! + +This guide explains how to: + +- Report issues +- Contribute code +- Improve documentation +- Suggest enhancements +- Interact with the EasyScience community + +Whether you are an experienced developer or contributing for the first +time, this document walks you through the entire process step by step. + +Please make sure you follow the EasyScience organization-wide +[Code of Conduct](https://github.com/easyscience/.github/blob/master/CODE_OF_CONDUCT.md). + +--- + +## Table of Contents + +- [How to Interact With This Project](#how-to-interact-with-this-project) +- [1. Understanding the Development Model](#1-understanding-the-development-model) +- [2. Getting the Code](#2-getting-the-code) +- [3. Setting Up the Development Environment](#3-setting-up-the-development-environment) +- [4. Creating a Branch](#4-creating-a-branch) +- [5. Implementing Your Changes](#5-implementing-your-changes) +- [6. Code Quality Checks](#6-code-quality-checks) +- [7. Opening a Pull Request](#7-opening-a-pull-request) +- [8. Continuous Integration (CI)](#8-continuous-integration-ci) +- [9. Code Review](#9-code-review) +- [10. Documentation Contributions](#10-documentation-contributions) +- [11. Reporting Issues](#11-reporting-issues) +- [12. Security Issues](#12-security-issues) +- [13. Releases](#13-releases) + +--- + +## How to Interact With This Project + +If you are not planning to modify the code, you may want to: + +- 🐞 Report a bug — see [Reporting Issues](#11-reporting-issues) +- 🛡 Report a security issue — see + [Security Issues](#12-security-issues) +- 💬 Ask a question or start a discussion at + [Project Discussions](https://github.com/easyscience/dynamics-lib/discussions) + +If you plan to contribute code or documentation, continue below. + +--- + +## 1. Understanding the Development Model + +Before you start coding, it is important to understand how development +works in this project. + +### Branching Strategy + +We use the following branches: + +- `master` — stable releases only +- `develop` — active development branch +- Short-lived branches — one branch per contribution + +All normal contributions must target the `develop` branch. + +This means: + +- Do **not** open Pull Requests against `master` +- Always create your branch from `develop` +- Always target `develop` when opening a Pull Request + +See ADR easyscience/.github#12 for full details on the branching +strategy. + +--- + +## 2. Getting the Code + +### 2.1. If You Are an External Contributor + +If you are not a core maintainer of this repository, follow these steps. + +1. Open the repository page: + `https://github.com/easyscience/dynamics-lib` + +2. Click the **Fork** button (top-right corner). This creates your own + copy of the repository. + +3. Clone your fork locally: + + ```bash + git clone https://github.com//dynamics-lib.git + cd dynamics-lib + ``` + +4. Add the original repository as `upstream`: + + ```bash + git remote add upstream https://github.com/easyscience/dynamics-lib.git + ``` + +5. Switch to the `develop` branch and update it: + + ```bash + git fetch upstream + git checkout develop + git pull upstream develop + ``` + +If you have contributed before, make sure your local `develop` branch is +up to date before starting new work. You can update it with: + +```bash +git fetch upstream +git pull upstream develop +``` + +This ensures you are working on the latest version of the project. + +### 2.2. If You Are a Core Team Member + +Core team members do not need to fork the repository. You can create a +new branch directly from `develop`, but the rest of the workflow remains +the same. + +--- + +## 3. Setting Up the Development Environment + +You need: + +- Git +- Pixi + +EasyScience projects use **Pixi** to manage the development environment. + +To install Pixi, follow the official instructions: +https://pixi.prefix.dev/latest/installation/ + +You do **not** need to manually install Python. Pixi automatically: + +- Creates the correct Python environment +- Installs all required dependencies +- Installs development tools (linters, formatters, test tools) + +Set up the environment: + +```bash +pixi install +pixi run post-install +``` + +After this step, your development environment is ready. + +See ADR easyscience/.github#63 for more details about this decision. + +--- + +## 4. Creating a Branch + +Never work directly on `develop`. + +Create a new branch: + +```bash +git checkout -b my-change +``` + +Use a clear and descriptive name, for example: + +- `improve-solver-speed` +- `fix-boundary-condition` +- `add-tutorial-example` + +Clear branch names make reviews and history easier to understand. + +--- + +## 5. Implementing Your Changes + +While developing: + +- Make small, logical commits +- Write clear and descriptive commit messages +- Follow the Google docstring convention +- Add or update unit tests if behavior changes + +Example: + +```bash +git add . +git commit -m "Improve performance of time integrator for large systems" +``` + +Run tests locally: + +```bash +pixi run unit-tests +``` + +Running tests frequently is strongly recommended. + +--- + +## 6. Code Quality Checks + +Before opening a Pull Request, always run: + +```bash +pixi run check +``` + +This command runs: + +- Formatting checks +- Linting +- Docstring validation +- Notebook checks +- Unit tests +- Other project validations + +A successful run should look like this: + +```bash +pixi run pyproject-check...................................Passed +pixi run py-lint-check.....................................Passed +pixi run py-format-check...................................Passed +pixi run nonpy-format-check................................Passed +pixi run docs-format-check.................................Passed +pixi run notebook-format-check.............................Passed +pixi run unit-tests........................................Passed +``` + +If something fails, read the error message carefully and fix the issue. + +You can run individual checks, for example: + +```bash +pixi run py-lint-check +``` + +Some formatting issues can be fixed automatically: + +```bash +pixi run fix +``` + +If everything is correctly formatted, you will see: + +```text +✅ All code auto-formatting steps have been applied. +``` + +This indicates that the auto-formatting pipeline completed successfully. +If you do not see this message and no error messages appear, try running +the command again. + +If errors are reported, resolve them and re-run: + +```bash +pixi run check +``` + +All checks must pass before your Pull Request can be merged. + +If you are unsure how to fix an issue, ask for help in your Pull Request +discussion. + +--- + +## 7. Opening a Pull Request + +Push your branch: + +```bash +git push origin my-change +``` + +On GitHub: + +- Click **Compare & Pull Request** +- Ensure the base branch is `develop` +- Write a clear and concise title +- Add a description explaining what changed and why +- Add the required `[scope]` label + +### Pull Request Title + +The PR title appears in release notes and changelogs. It should be +concise and informative. + +Good examples: + +- Improve performance of time integrator for large systems +- Fix incorrect boundary condition handling in solver +- Add adaptive step-size control to ODE solver +- Add tutorial for custom model configuration +- Refactor solver API for improved readability + +### Required `[scope]` Label + +Each Pull Request must include one `[scope]` label: + +| Label | Description | +| ----------------------- | ----------------------------------------------------------------------- | +| `[scope] bug` | Bug report or fix (major.minor.**PATCH**) | +| `[scope] documentation` | Documentation-only changes (major.minor.patch.**POST**) | +| `[scope] enhancement` | Adds or improves features (major.**MINOR**.patch) | +| `[scope] maintenance` | Code/tooling cleanup without feature or bug fix (major.minor.**PATCH**) | +| `[scope] significant` | Breaking or major changes (**MAJOR**.minor.patch) | + +See ADR easyscience/.github#33 for full versioning rules. + +--- + +## 8. Continuous Integration (CI) + +After opening a Pull Request: + +- Automated checks run automatically +- You will see green checkmarks or red crosses + +If checks fail: + +1. Open the failing check +2. Read the logs +3. Fix the issue locally +4. Run `pixi run check` +5. Push your changes + +The Pull Request updates automatically. + +--- + +## 9. Code Review + +All Pull Requests are reviewed by at least one core team member. + +Code review is collaborative and aims to improve quality. + +Do not take comments personally — they are meant to help. + +To update your PR: + +```bash +git add . +git commit -m "Address review comments" +git push +``` + +--- + +## 10. Documentation Contributions + +If your change affects users, update the documentation. + +This may include: + +- API documentation +- Examples +- Tutorials +- Jupyter notebooks + +Preview documentation locally: + +```bash +pixi run docs-serve +``` + +Open the URL shown in the terminal to review your changes. + +--- + +## 11. Reporting Issues + +If you find a bug but do not want to fix it: + +- Search existing issues first +- Provide clear reproduction steps +- Include logs and environment details + +Clear issue reports help maintainers significantly. + +--- + +## 12. Security Issues + +Do **not** report security vulnerabilities publicly. + +If you discover a potential vulnerability, contact the maintainers +privately. + +--- + +## 13. Releases + +Releases are created by merging `develop` into `master`. + +Once your contribution is merged into `develop`, it will be included in +the next stable release. + +--- + +Thank you for contributing to EasyDynamics and the EasyScience +ecosystem! diff --git a/README.md b/README.md index 373d3828..065db358 100644 --- a/README.md +++ b/README.md @@ -9,20 +9,25 @@

-**EasyDynamics** is a scientific software for plotting and fitting QENS -and INS powder data. +**EasyDynamics** is a software for plotting and fitting QENS and INS +powder data. -**EasyDynamics** is available both as a Python library and as a +**EasyDynamics** is developed both as a Python library and as a cross-platform desktop application. Here, we focus on the Python library. For the graphical user interface (GUI), please see the corresponding [GUI resources](https://github.com/easyscience/dynamics-app). +License: +[BSD 3-Clause](https://github.com/easyscience/dynamics-lib/blob/master/LICENSE) + ## Useful Links +### For Users + - 📖 [Documentation](https://easyscience.github.io/dynamics-lib/latest) - 🚀 [Getting Started](https://easyscience.github.io/dynamics-lib/latest/introduction) @@ -32,11 +37,14 @@ Here, we focus on the Python library. For the graphical user interface [Get in Touch](https://easyscience.github.io/dynamics-lib/latest/introduction/#get-in-touch) - 🧾 [Citation](https://easyscience.github.io/dynamics-lib/latest/introduction/#citation) -- 🤝 - [Contributing](https://easyscience.github.io/dynamics-lib/latest/introduction/#contributing) + +### For Contributors + +- 🧑‍💻 [Source Code](https://github.com/easyscience/dynamics-lib) - 🐞 [Issue Tracker](https://github.com/easyscience/dynamics-lib/issues) - 💡 [Discussions](https://github.com/easyscience/dynamics-lib/discussions) -- 🧑‍💻 [Source Code](https://github.com/easyscience/dynamics-lib) -- ⚖️ - [License](https://raw.githubusercontent.com/easyscience/dynamics-lib/refs/heads/master/LICENSE) +- 🤝 + [Contributing Guide](https://github.com/easyscience/dynamics-lib/blob/master/CONTRIBUTING.md) +- 🛡 + [Code of Conduct](https://github.com/easyscience/.github/blob/master/CODE_OF_CONDUCT.md) diff --git a/docs/docs/api-reference/experiment.md b/docs/docs/api-reference/experiment.md new file mode 100644 index 00000000..9595c32a --- /dev/null +++ b/docs/docs/api-reference/experiment.md @@ -0,0 +1 @@ +::: easydynamics.experiment diff --git a/docs/docs/api-reference/index.md b/docs/docs/api-reference/index.md index 7c211941..d8c9860f 100644 --- a/docs/docs/api-reference/index.md +++ b/docs/docs/api-reference/index.md @@ -9,12 +9,12 @@ available in EasyDynamics. - [convolution](convolution.md) – Handles convolution of the sample model with the instrument resolution. -- [experiment] (experiment.md) - Load, manage and visualize experimental +- [experiment](experiment.md) - Load, manage and visualize experimental data. - [sample_model](sample_model.md) – All modelling in EasyDynamics: The scattering from the sample, including individual model components and diffusion models, background, resolution and instrument. -- [analysis] (analysis.md) - Analysing experimental data by fitting a +- [analysis](analysis.md) - Analysing experimental data by fitting a sample model to experimental data, optionally convoluted with a resolution model and adding a background. - [utils](utils.md) – Miscellaneous utility functions for EasyDynamics. diff --git a/docs/docs/assets/javascripts/mathjax.js b/docs/docs/assets/javascripts/mathjax.js new file mode 100644 index 00000000..a92ce69a --- /dev/null +++ b/docs/docs/assets/javascripts/mathjax.js @@ -0,0 +1,27 @@ +window.MathJax = { + tex: { + //inlineMath: [['\\(', '\\)']], + //displayMath: [['\\[', '\\]']], + // Add support for $...$ and \(...\) delimiters + inlineMath: [['$', '$'], ['\\(', '\\)']], + // Add support for $$...$$ and \[...]\ delimiters + displayMath: [['$$', '$$'], ['\\[', '\\]']], + processEscapes: true, + processEnvironments: true + }, + options: { + //ignoreHtmlClass: ".*|", + //processHtmlClass: "arithmatex" + // Skip code blocks only + skipHtmlTags: ['script','noscript','style','textarea','pre','code'], + // Only ignore explicit opt-out + ignoreHtmlClass: 'no-mathjax|tex2jax_ignore', + } +}; + +document$.subscribe(() => { + MathJax.startup.output.clearCache() + MathJax.typesetClear() + MathJax.texReset() + MathJax.typesetPromise() +}) diff --git a/docs/docs/index.md b/docs/docs/index.md index 735b0e57..d683b5b9 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -1,6 +1,6 @@ ![](assets/images/logo_dark.svg#gh-dark-mode-only)![](assets/images/logo_light.svg#gh-light-mode-only) -# QENS data analysis library +# QENS data analysis Here is a brief overview of the main documentation sections: diff --git a/docs/docs/installation-and-setup/index.md b/docs/docs/installation-and-setup/index.md index 420ef07e..150ba5ab 100644 --- a/docs/docs/installation-and-setup/index.md +++ b/docs/docs/installation-and-setup/index.md @@ -11,7 +11,7 @@ To install and set up EasyDynamics, we recommend using [**Pixi**](https://pixi.prefix.dev), a modern package manager for Windows, macOS, and Linux. -!!! note "Main benefits of using Pixi" +??? note "Main benefits of using Pixi" - **Ease of use**: Pixi simplifies the installation process, making it accessible even for users with limited experience in package management. @@ -37,6 +37,26 @@ This section describes the simplest way to set up EasyDynamics using #### Setting up EasyDynamics with Pixi + + +- Choose a project location (local drive recommended). + + ??? warning ":fontawesome-brands-windows: Windows + OneDrive" + + We **do not recommend creating a Pixi project inside OneDrive or other + synced folders**. + + By default, Pixi creates the virtual environment inside the project + directory (in `.pixi/`). On Windows, synced folders such as OneDrive + may cause file‑system issues (e.g., path-length limitations or + restricted link operations), which can lead to unexpected install + errors or environments being recreated. + + Instead, create your project in a **local directory on your drive** + where you have full write permissions. + + + - Initialize a new Pixi project and navigate into it: ```txt pixi init easydynamics @@ -75,19 +95,8 @@ This section describes the simplest way to set up EasyDynamics using ## Classical Installation This section describes how to install EasyDynamics using the traditional -method with **pip**. This approach is more flexible and suitable for -users familiar with Python package management and virtual environments. - -!!! warning - - Currently, classical installation doesn't allow installing the - GNU Scientific Library (GSL) dependency automatically. As a result, - the calculation engine **pdffit2** will not be available. To make it - work, ensure that GSL is installed on your system. - - Alternatively, consider using the **Pixi installation method** described - in the [Installing with Pixi](#installing-with-pixi) section, which - handles GSL installation automatically. +method with **pip**. It is assumed that you are familiar with Python +package management and virtual environments. ### Environment Setup optional { #environment-setup data-toc-label="Environment Setup" } diff --git a/docs/docs/introduction/index.md b/docs/docs/introduction/index.md index 740d4b0d..7b8e76a1 100644 --- a/docs/docs/introduction/index.md +++ b/docs/docs/introduction/index.md @@ -6,18 +6,20 @@ icon: material/information-slab-circle ## Description -**EasyDynamics** is a scientific software for plotting and fitting QENS -and INS powder data. +**EasyDynamics** is a software for plotting and fitting QENS and INS +powder data. -**EasyDynamics** is available both as a Python library and as a +**EasyDynamics** is developed both as a Python library and as a cross-platform desktop application. Here, we focus on the Python library. For the graphical user interface (GUI), please see the corresponding [GUI resources](https://easyscience.github.io/dynamics-app). + ## License @@ -47,22 +49,24 @@ BibTeX, JSON) are available on the ## Contributing -We welcome contributions from the community! **EasyDynamics** is -intended to be a community-driven, open-source project supported by a -diverse group of contributors. +We welcome contributions of any kind! + +**EasyDynamics** is intended to be a community-driven, open-source +project supported by a diverse group of contributors. The project is maintained by the [European Spallation Source (ESS)](https://ess.eu). -To contribute, see our +If you would like to report a bug or request a new feature, please use +the +[GitHub Issue Tracker](https://github.com/easyscience/dynamics-lib/issues) +(A free GitHub account is required.) + +To contribute code, documentation, or tests, please see our [:material-account-plus: Contributing Guidelines](https://github.com/easyscience/dynamics-lib/blob/master/CONTRIBUTING.md) -on GitHub. +for detailed development instructions. ## Get in Touch -For general questions or feedback, contact us at +For general questions or feedback, please contact us at [henrik.jacobsen@ess.eu](mailto:henrik.jacobsen@ess.eu). - -To report bugs or request features, please use the -[GitHub Issue Tracker](https://github.com/easyscience/dynamics-lib/issues) -(free registration required). diff --git a/docs/docs/tutorials/index.md b/docs/docs/tutorials/index.md index ec6ed05e..63d819a7 100644 --- a/docs/docs/tutorials/index.md +++ b/docs/docs/tutorials/index.md @@ -18,15 +18,20 @@ The tutorials are organized into the following categories: ## Getting Started - [Component collection](component_collection.ipynb) – Learn how to - create a collectin of components for fitting + create a collectin of components for fitting. - [Components](components.ipynb) – Learn how to use the EasyDynamics - components + components. - [Convolution](convolution.ipynb) – Learn how to calculate the - convolution of your resolution function with your model + convolution of your resolution function with your model. - [Detailed balance](detailed_balance.ipynb) – Learn how to apply - detailed balancing to your model + detailed balancing to your model. - [Diffusion model](diffusion_model.ipynb) – Learn how to create and use - a model of diffusion + a model of diffusion. - [Sample model](sample_model.ipynb) – Learn how to create a model of - the scattering from your sample -- [Experiment](experiment.ipynb) - Learn how to load and bin your data + the scattering from your sample. +- [Instrument model](instrument_model.ipynb) – Learn how to create a. + model of your instrument. +- [Experiment](experiment.ipynb) - Learn how to load and bin your data. +- [Analysis](analysis.ipynb) - Learn how to fit a model to your data. +- [Analysis 1D](analysis1d.ipynb) - Learn how to fit a model to your + data at a particular Q. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index b0b2fd61..1939b307 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -21,7 +21,7 @@ theme: - content.tooltips - navigation.footer - navigation.indexes - - navigation.instant # Instant loading (SPA-like) + #- navigation.instant # Instant loading, but it causes issues with rendering equations #- navigation.sections - navigation.top # Back-to-top button - navigation.tracking # Anchor tracking @@ -84,10 +84,12 @@ extra: # Customization to be included by the theme extra_css: - assets/stylesheets/extra.css + extra_javascript: - assets/javascripts/extra.js - - javascripts/mathjax.js - - https://unpkg.com/mathjax@3/es5/tex-mml-chtml.js + # MathJax for rendering mathematical expressions + - assets/javascripts/mathjax.js # Custom MathJax config to ensure compatibility with mkdocs-jupyter + - https://unpkg.com/mathjax@3/es5/tex-mml-chtml.js # Official MathJax CDN # A list of extensions beyond the ones that MkDocs uses by default (meta, toc, tables, and fenced_code) markdown_extensions: @@ -96,7 +98,7 @@ markdown_extensions: - attr_list - def_list - footnotes - - pymdownx.arithmatex: + - pymdownx.arithmatex: # rendering of equations and integrates with MathJax or KaTeX generic: true - pymdownx.blocks.caption - pymdownx.details @@ -136,7 +138,7 @@ plugins: allow_errors: false include_source: true include_requirejs: true # Required for Plotly - # custom_mathjax_url: 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/latest.js?config=TeX-AMS_CHTML-full,Safe' + #custom_mathjax_url: 'https://unpkg.com/mathjax@3/es5/tex-mml-chtml.js' # See 'extra_javascript' above ignore_h1_titles: true # Use titles defined in the nav section below remove_tag_config: remove_input_tags: @@ -183,9 +185,14 @@ nav: - Detailed balance: tutorials/detailed_balance.ipynb - Diffusion model: tutorials/diffusion_model.ipynb - Sample model: tutorials/sample_model.ipynb + - Instrument model: tutorials/instrument_model.ipynb - Experiment: tutorials/experiment.ipynb + - Analysis: tutorials/analysis.ipynb + - Analysis 1D: tutorials/analysis1d.ipynb - API Reference: - API Reference: api-reference/index.md - convolution: api-reference/convolution.md - sample_model: api-reference/sample_model.md + - experiment: api-reference/experiment.md + - analysis: api-reference/analysis.md - utils: api-reference/utils.md diff --git a/pixi.lock b/pixi.lock index 1a38c4ea..0c574543 100644 --- a/pixi.lock +++ b/pixi.lock @@ -14,27 +14,21 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/gsl-2.8-hbf7d49c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.2-h33c6efd_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45-default_hbd61a6d_105.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20250512.1-cxx17_hba17884_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-5_h4a7cf45_openblas.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-5_h0358290_openblas.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.3-hecca717_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h9ec8514_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_16.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_16.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_16.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_16.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_16.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.67.0-had1ee68_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.30-pthreads_h94d23a6_4.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.2-hf4e2dac_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_16.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda @@ -73,6 +67,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e8/ed/2fe5ea435ae480bd3a76be1415920ce52b3ff6e188d8eab6a635d6a2a1d1/chardet-7.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl @@ -80,7 +75,6 @@ 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#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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 @@ -92,6 +86,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/b4/a7ec1eaee86761a9dbfd339732b4706db3c6b65e970c12f0f56cfcce3dcf/docformatter-1.7.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/36/41/04e2a649058b0713b00d6c9bd22da35618bb157289e05d068e51fddf8d7e/dunamai-1.25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/64/f2/9d779717fd4ff4136d009a8023704f7eb37f2231fbfbe49eb9b430315bcc/easyscience-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl @@ -101,6 +96,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/30/d1c94066343a98bb2cea40120873193a4fed68c4ad7f8935c11caf74c681/h5py-3.15.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl @@ -185,7 +182,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/36/ce5f75aa7c736a663a901766edc3580098c7ea3959a0e878363a54a3714e/pixi_kernel-0.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/4a/d070dc6a36c2eb8b8a19b31908d0817e2a85fe0b70f9db20834a495a74e1/plopp-25.11.0-py3-none-any.whl @@ -237,7 +233,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl @@ -269,32 +267,23 @@ environments: - pypi: https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl - pypi: ./ osx-64: - - conda: https://conda.anaconda.org/conda-forge/osx-64/_openmp_mutex-4.5-7_kmp_llvm.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h500dc9f_8.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/c-ares-1.34.6-hb5e19a0_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/gsl-2.8-hc707ee6_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/icu-78.2-h14c5de8_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libabseil-20250512.1-cxx17_hfc00f1c_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libblas-3.11.0-5_he492b99_openblas.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libbrotlicommon-1.2.0-h8616949_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libbrotlidec-1.2.0-h8616949_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libbrotlienc-1.2.0-h8616949_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libcblas-3.11.0-5_h9b27e0a_openblas.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-21.1.8-h3d58e20_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libev-4.33-h10d778d_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.7.3-heffb93a_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.5.2-h750e83c_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libgcc-15.2.0-h08519bb_15.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libgfortran-15.2.0-h7e5c614_15.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libgfortran5-15.2.0-hd16e46c_15.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.2-h11316ed_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libnghttp2-1.67.0-h3338091_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libopenblas-0.3.30-openmp_h6006d49_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.51.2-hb99441e_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libuv-1.51.0-h58003a5_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.1-hd23fc13_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/llvm-openmp-21.1.8-h472b3d1_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.5-h0622a9a_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/nodejs-25.2.1-h4e79119_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.0-h230baf5_0.conda @@ -328,6 +317,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f6/88/4c6fe7dcd5d36a2cfd7030084fbd79264083f329faaf96038c23888a8e05/chardet-7.0.1-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl @@ -335,7 +325,6 @@ 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#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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 @@ -347,6 +336,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/b4/a7ec1eaee86761a9dbfd339732b4706db3c6b65e970c12f0f56cfcce3dcf/docformatter-1.7.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/36/41/04e2a649058b0713b00d6c9bd22da35618bb157289e05d068e51fddf8d7e/dunamai-1.25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/64/f2/9d779717fd4ff4136d009a8023704f7eb37f2231fbfbe49eb9b430315bcc/easyscience-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl @@ -356,6 +346,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/62/b8/c0d9aa013ecfa8b7057946c080c0c07f6fa41e231d2e9bd306a2f8110bdc/h5py-3.15.1-cp312-cp312-macosx_10_13_x86_64.whl @@ -440,7 +432,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/36/ce5f75aa7c736a663a901766edc3580098c7ea3959a0e878363a54a3714e/pixi_kernel-0.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/4a/d070dc6a36c2eb8b8a19b31908d0817e2a85fe0b70f9db20834a495a74e1/plopp-25.11.0-py3-none-any.whl @@ -492,7 +483,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl @@ -524,32 +517,23 @@ environments: - pypi: https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl - pypi: ./ osx-arm64: - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/_openmp_mutex-4.5-7_kmp_llvm.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.6-hc919400_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gsl-2.8-h8d0574d_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.2-h38cb7af_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20250512.1-cxx17_hd41c47c_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libblas-3.11.0-5_h51639a9_openblas.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-hc919400_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-hc919400_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hc919400_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcblas-3.11.0-5_hb0561ab_openblas.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-21.1.8-hf598326_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h93a5062_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.7.3-haf25636_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-he5f378a_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgcc-15.2.0-hcbb3090_16.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran-15.2.0-h07b0088_16.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran5-15.2.0-hdae7583_16.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.2-h8088a28_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.67.0-hc438710_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenblas-0.3.30-openmp_ha158390_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.51.2-h1ae2325_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.51.0-h6caf38d_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-21.1.8-h4a912ad_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-25.2.1-he11bded_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.0-h5503f6c_0.conda @@ -583,6 +567,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f9/fb/3b92a2433eadef83ae131fa720a17857cfbf7687c5f188bfb2f9eee2d3dd/chardet-7.0.1-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl @@ -590,7 +575,6 @@ 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#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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 @@ -602,6 +586,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/b4/a7ec1eaee86761a9dbfd339732b4706db3c6b65e970c12f0f56cfcce3dcf/docformatter-1.7.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/36/41/04e2a649058b0713b00d6c9bd22da35618bb157289e05d068e51fddf8d7e/dunamai-1.25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/64/f2/9d779717fd4ff4136d009a8023704f7eb37f2231fbfbe49eb9b430315bcc/easyscience-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl @@ -611,6 +596,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a4/5e/3c6f6e0430813c7aefe784d00c6711166f46225f5d229546eb53032c3707/h5py-3.15.1-cp312-cp312-macosx_11_0_arm64.whl @@ -695,7 +682,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/36/ce5f75aa7c736a663a901766edc3580098c7ea3959a0e878363a54a3714e/pixi_kernel-0.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/4a/d070dc6a36c2eb8b8a19b31908d0817e2a85fe0b70f9db20834a495a74e1/plopp-25.11.0-py3-none-any.whl @@ -747,7 +733,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl @@ -781,26 +769,14 @@ environments: win-64: - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_8.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-h4c7d964_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/gsl-2.8-h5b8d9c4_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/icu-78.2-h637d24d_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-5_hf2e6a31_mkl.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-5_h2a3cdd5_mkl.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.7.3-hac47afa_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h52bdfb6_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libhwloc-2.12.2-default_h4379cf1_1000.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.2-hfd05255_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.51.2-hf5d6505_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.1-h3cfd58e_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.1-h779ef1b_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.1-h2466b09_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-21.1.8-h4fa8253_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/mkl-2025.3.0-hac47afa_455.conda - conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-25.2.1-he453025_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.0-h725018a_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.12.12-h0159041_1_cpython.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tbb-2022.3.0-h3155e25_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h2c6b04d_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda @@ -831,6 +807,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/07/ba/7ca89301e492ac4184ba7f4736565d954ba3125acf6bf02c66a38a802bda/chardet-7.0.1-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl @@ -838,7 +815,6 @@ 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#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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 @@ -850,6 +826,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/b4/a7ec1eaee86761a9dbfd339732b4706db3c6b65e970c12f0f56cfcce3dcf/docformatter-1.7.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/36/41/04e2a649058b0713b00d6c9bd22da35618bb157289e05d068e51fddf8d7e/dunamai-1.25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/64/f2/9d779717fd4ff4136d009a8023704f7eb37f2231fbfbe49eb9b430315bcc/easyscience-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl @@ -859,6 +836,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cf/51/329e7436bf87ca6b0fe06dd0a3795c34bebe4ed8d6c44450a20565d57832/h5py-3.15.1-cp312-cp312-win_amd64.whl @@ -942,7 +921,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/36/ce5f75aa7c736a663a901766edc3580098c7ea3959a0e878363a54a3714e/pixi_kernel-0.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/4a/d070dc6a36c2eb8b8a19b31908d0817e2a85fe0b70f9db20834a495a74e1/plopp-25.11.0-py3-none-any.whl @@ -995,7 +973,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl @@ -1040,27 +1020,21 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/gsl-2.8-hbf7d49c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.2-h33c6efd_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45-default_hbd61a6d_105.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20250512.1-cxx17_hba17884_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-5_h4a7cf45_openblas.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-5_h0358290_openblas.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.3-hecca717_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h9ec8514_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_16.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_16.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_16.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_16.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_16.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.67.0-had1ee68_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.30-pthreads_h94d23a6_4.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.2-hf4e2dac_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_16.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda @@ -1099,6 +1073,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/9f/3d4ba1650e3eb3e7431a054e3bf1b5eaea25b84c72afabf5ef6fc33305d1/chardet-7.0.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl @@ -1106,7 +1081,6 @@ 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#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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 @@ -1118,6 +1092,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/b4/a7ec1eaee86761a9dbfd339732b4706db3c6b65e970c12f0f56cfcce3dcf/docformatter-1.7.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/36/41/04e2a649058b0713b00d6c9bd22da35618bb157289e05d068e51fddf8d7e/dunamai-1.25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/64/f2/9d779717fd4ff4136d009a8023704f7eb37f2231fbfbe49eb9b430315bcc/easyscience-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl @@ -1127,6 +1102,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8b/23/4ab1108e87851ccc69694b03b817d92e142966a6c4abd99e17db77f2c066/h5py-3.15.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl @@ -1212,7 +1189,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/36/ce5f75aa7c736a663a901766edc3580098c7ea3959a0e878363a54a3714e/pixi_kernel-0.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/4a/d070dc6a36c2eb8b8a19b31908d0817e2a85fe0b70f9db20834a495a74e1/plopp-25.11.0-py3-none-any.whl @@ -1264,7 +1240,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl @@ -1296,32 +1274,23 @@ environments: - pypi: https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl - pypi: ./ osx-64: - - conda: https://conda.anaconda.org/conda-forge/osx-64/_openmp_mutex-4.5-7_kmp_llvm.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h500dc9f_8.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/c-ares-1.34.6-hb5e19a0_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/gsl-2.8-hc707ee6_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/icu-78.2-h14c5de8_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libabseil-20250512.1-cxx17_hfc00f1c_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libblas-3.11.0-5_he492b99_openblas.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libbrotlicommon-1.2.0-h8616949_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libbrotlidec-1.2.0-h8616949_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libbrotlienc-1.2.0-h8616949_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libcblas-3.11.0-5_h9b27e0a_openblas.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-21.1.8-h3d58e20_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libev-4.33-h10d778d_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.7.3-heffb93a_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.5.2-h750e83c_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libgcc-15.2.0-h08519bb_15.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libgfortran-15.2.0-h7e5c614_15.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libgfortran5-15.2.0-hd16e46c_15.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.2-h11316ed_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libnghttp2-1.67.0-h3338091_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libopenblas-0.3.30-openmp_h6006d49_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.51.2-hb99441e_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libuv-1.51.0-h58003a5_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.1-hd23fc13_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/llvm-openmp-21.1.8-h472b3d1_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.5-h0622a9a_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/nodejs-25.2.1-h4e79119_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.0-h230baf5_0.conda @@ -1355,6 +1324,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/00/fb/a90b4510aa9080966c65321db2084bcfa184518ee1ed15570d351649ecb2/chardet-7.0.1-cp311-cp311-macosx_10_9_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl @@ -1362,7 +1332,6 @@ 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#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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 @@ -1374,6 +1343,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/b4/a7ec1eaee86761a9dbfd339732b4706db3c6b65e970c12f0f56cfcce3dcf/docformatter-1.7.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/36/41/04e2a649058b0713b00d6c9bd22da35618bb157289e05d068e51fddf8d7e/dunamai-1.25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/64/f2/9d779717fd4ff4136d009a8023704f7eb37f2231fbfbe49eb9b430315bcc/easyscience-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl @@ -1383,6 +1353,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/fd/8349b48b15b47768042cff06ad6e1c229f0a4bd89225bf6b6894fea27e6d/h5py-3.15.1-cp311-cp311-macosx_10_9_x86_64.whl @@ -1468,7 +1440,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/36/ce5f75aa7c736a663a901766edc3580098c7ea3959a0e878363a54a3714e/pixi_kernel-0.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/4a/d070dc6a36c2eb8b8a19b31908d0817e2a85fe0b70f9db20834a495a74e1/plopp-25.11.0-py3-none-any.whl @@ -1520,7 +1491,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl @@ -1552,32 +1525,23 @@ environments: - pypi: https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl - pypi: ./ osx-arm64: - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/_openmp_mutex-4.5-7_kmp_llvm.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.6-hc919400_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gsl-2.8-h8d0574d_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.2-h38cb7af_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20250512.1-cxx17_hd41c47c_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libblas-3.11.0-5_h51639a9_openblas.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-hc919400_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-hc919400_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hc919400_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcblas-3.11.0-5_hb0561ab_openblas.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-21.1.8-hf598326_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h93a5062_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.7.3-haf25636_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-he5f378a_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgcc-15.2.0-hcbb3090_16.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran-15.2.0-h07b0088_16.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran5-15.2.0-hdae7583_16.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.2-h8088a28_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.67.0-hc438710_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenblas-0.3.30-openmp_ha158390_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.51.2-h1ae2325_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.51.0-h6caf38d_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-21.1.8-h4a912ad_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-25.2.1-he11bded_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.0-h5503f6c_0.conda @@ -1611,6 +1575,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/24/fa/3ad0b454a55376b7971fe64c2f225dfe56a491d8d8728fbfba63f8ff416d/chardet-7.0.1-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl @@ -1618,7 +1583,6 @@ 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#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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 @@ -1630,6 +1594,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/b4/a7ec1eaee86761a9dbfd339732b4706db3c6b65e970c12f0f56cfcce3dcf/docformatter-1.7.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/36/41/04e2a649058b0713b00d6c9bd22da35618bb157289e05d068e51fddf8d7e/dunamai-1.25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/64/f2/9d779717fd4ff4136d009a8023704f7eb37f2231fbfbe49eb9b430315bcc/easyscience-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl @@ -1639,6 +1604,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/b0/1c628e26a0b95858f54aba17e1599e7f6cd241727596cc2580b72cb0a9bf/h5py-3.15.1-cp311-cp311-macosx_11_0_arm64.whl @@ -1724,7 +1691,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/36/ce5f75aa7c736a663a901766edc3580098c7ea3959a0e878363a54a3714e/pixi_kernel-0.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/4a/d070dc6a36c2eb8b8a19b31908d0817e2a85fe0b70f9db20834a495a74e1/plopp-25.11.0-py3-none-any.whl @@ -1776,7 +1742,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl @@ -1810,26 +1778,14 @@ environments: win-64: - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_8.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-h4c7d964_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/gsl-2.8-h5b8d9c4_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/icu-78.2-h637d24d_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-5_hf2e6a31_mkl.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-5_h2a3cdd5_mkl.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.7.3-hac47afa_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h52bdfb6_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libhwloc-2.12.2-default_h4379cf1_1000.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.2-hfd05255_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.51.2-hf5d6505_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.1-h3cfd58e_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.1-h779ef1b_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.1-h2466b09_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-21.1.8-h4fa8253_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/mkl-2025.3.0-hac47afa_455.conda - conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-25.2.1-he453025_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.0-h725018a_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.11.14-h0159041_2_cpython.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tbb-2022.3.0-h3155e25_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h2c6b04d_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda @@ -1860,6 +1816,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/73/64/9c5c450ba18359a8e8ab2943e6c3a0b100bd394799bc73a844e3c5cd9c7c/chardet-7.0.1-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl @@ -1867,7 +1824,6 @@ 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#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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 @@ -1879,6 +1835,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/b4/a7ec1eaee86761a9dbfd339732b4706db3c6b65e970c12f0f56cfcce3dcf/docformatter-1.7.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/36/41/04e2a649058b0713b00d6c9bd22da35618bb157289e05d068e51fddf8d7e/dunamai-1.25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/64/f2/9d779717fd4ff4136d009a8023704f7eb37f2231fbfbe49eb9b430315bcc/easyscience-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl @@ -1888,6 +1845,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/23/95/499b4e56452ef8b6c95a271af0dde08dac4ddb70515a75f346d4f400579b/h5py-3.15.1-cp311-cp311-win_amd64.whl @@ -1972,7 +1931,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/36/ce5f75aa7c736a663a901766edc3580098c7ea3959a0e878363a54a3714e/pixi_kernel-0.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/4a/d070dc6a36c2eb8b8a19b31908d0817e2a85fe0b70f9db20834a495a74e1/plopp-25.11.0-py3-none-any.whl @@ -2025,7 +1983,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl @@ -2070,27 +2030,21 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/gsl-2.8-hbf7d49c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.2-h33c6efd_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45-default_hbd61a6d_105.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20250512.1-cxx17_hba17884_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-5_h4a7cf45_openblas.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-5_h0358290_openblas.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.3-hecca717_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h9ec8514_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_16.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_16.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_16.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_16.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_16.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.67.0-had1ee68_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.30-pthreads_h94d23a6_4.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.2-hf4e2dac_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_16.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda @@ -2129,6 +2083,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e8/ed/2fe5ea435ae480bd3a76be1415920ce52b3ff6e188d8eab6a635d6a2a1d1/chardet-7.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl @@ -2136,7 +2091,6 @@ 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#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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 @@ -2148,6 +2102,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/b4/a7ec1eaee86761a9dbfd339732b4706db3c6b65e970c12f0f56cfcce3dcf/docformatter-1.7.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/36/41/04e2a649058b0713b00d6c9bd22da35618bb157289e05d068e51fddf8d7e/dunamai-1.25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/64/f2/9d779717fd4ff4136d009a8023704f7eb37f2231fbfbe49eb9b430315bcc/easyscience-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl @@ -2157,6 +2112,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/30/d1c94066343a98bb2cea40120873193a4fed68c4ad7f8935c11caf74c681/h5py-3.15.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl @@ -2241,7 +2198,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/36/ce5f75aa7c736a663a901766edc3580098c7ea3959a0e878363a54a3714e/pixi_kernel-0.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/4a/d070dc6a36c2eb8b8a19b31908d0817e2a85fe0b70f9db20834a495a74e1/plopp-25.11.0-py3-none-any.whl @@ -2293,7 +2249,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl @@ -2325,32 +2283,23 @@ environments: - pypi: https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl - pypi: ./ osx-64: - - conda: https://conda.anaconda.org/conda-forge/osx-64/_openmp_mutex-4.5-7_kmp_llvm.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h500dc9f_8.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/c-ares-1.34.6-hb5e19a0_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/gsl-2.8-hc707ee6_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/icu-78.2-h14c5de8_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libabseil-20250512.1-cxx17_hfc00f1c_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libblas-3.11.0-5_he492b99_openblas.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libbrotlicommon-1.2.0-h8616949_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libbrotlidec-1.2.0-h8616949_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libbrotlienc-1.2.0-h8616949_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libcblas-3.11.0-5_h9b27e0a_openblas.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-21.1.8-h3d58e20_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libev-4.33-h10d778d_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.7.3-heffb93a_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.5.2-h750e83c_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libgcc-15.2.0-h08519bb_15.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libgfortran-15.2.0-h7e5c614_15.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libgfortran5-15.2.0-hd16e46c_15.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.2-h11316ed_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libnghttp2-1.67.0-h3338091_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libopenblas-0.3.30-openmp_h6006d49_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.51.2-hb99441e_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libuv-1.51.0-h58003a5_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.1-hd23fc13_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/llvm-openmp-21.1.8-h472b3d1_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.5-h0622a9a_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/nodejs-25.2.1-h4e79119_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.0-h230baf5_0.conda @@ -2384,6 +2333,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f6/88/4c6fe7dcd5d36a2cfd7030084fbd79264083f329faaf96038c23888a8e05/chardet-7.0.1-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl @@ -2391,7 +2341,6 @@ 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#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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 @@ -2403,6 +2352,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/b4/a7ec1eaee86761a9dbfd339732b4706db3c6b65e970c12f0f56cfcce3dcf/docformatter-1.7.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/36/41/04e2a649058b0713b00d6c9bd22da35618bb157289e05d068e51fddf8d7e/dunamai-1.25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/64/f2/9d779717fd4ff4136d009a8023704f7eb37f2231fbfbe49eb9b430315bcc/easyscience-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl @@ -2412,6 +2362,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/62/b8/c0d9aa013ecfa8b7057946c080c0c07f6fa41e231d2e9bd306a2f8110bdc/h5py-3.15.1-cp312-cp312-macosx_10_13_x86_64.whl @@ -2496,7 +2448,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/36/ce5f75aa7c736a663a901766edc3580098c7ea3959a0e878363a54a3714e/pixi_kernel-0.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/4a/d070dc6a36c2eb8b8a19b31908d0817e2a85fe0b70f9db20834a495a74e1/plopp-25.11.0-py3-none-any.whl @@ -2548,7 +2499,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl @@ -2580,32 +2533,23 @@ environments: - pypi: https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl - pypi: ./ osx-arm64: - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/_openmp_mutex-4.5-7_kmp_llvm.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.6-hc919400_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gsl-2.8-h8d0574d_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.2-h38cb7af_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20250512.1-cxx17_hd41c47c_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libblas-3.11.0-5_h51639a9_openblas.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-hc919400_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-hc919400_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hc919400_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcblas-3.11.0-5_hb0561ab_openblas.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-21.1.8-hf598326_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h93a5062_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.7.3-haf25636_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-he5f378a_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgcc-15.2.0-hcbb3090_16.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran-15.2.0-h07b0088_16.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran5-15.2.0-hdae7583_16.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.2-h8088a28_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.67.0-hc438710_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenblas-0.3.30-openmp_ha158390_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.51.2-h1ae2325_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.51.0-h6caf38d_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-21.1.8-h4a912ad_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-25.2.1-he11bded_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.0-h5503f6c_0.conda @@ -2639,6 +2583,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f9/fb/3b92a2433eadef83ae131fa720a17857cfbf7687c5f188bfb2f9eee2d3dd/chardet-7.0.1-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl @@ -2646,7 +2591,6 @@ 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#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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 @@ -2658,6 +2602,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/b4/a7ec1eaee86761a9dbfd339732b4706db3c6b65e970c12f0f56cfcce3dcf/docformatter-1.7.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/36/41/04e2a649058b0713b00d6c9bd22da35618bb157289e05d068e51fddf8d7e/dunamai-1.25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/64/f2/9d779717fd4ff4136d009a8023704f7eb37f2231fbfbe49eb9b430315bcc/easyscience-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl @@ -2667,6 +2612,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a4/5e/3c6f6e0430813c7aefe784d00c6711166f46225f5d229546eb53032c3707/h5py-3.15.1-cp312-cp312-macosx_11_0_arm64.whl @@ -2751,7 +2698,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/36/ce5f75aa7c736a663a901766edc3580098c7ea3959a0e878363a54a3714e/pixi_kernel-0.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/4a/d070dc6a36c2eb8b8a19b31908d0817e2a85fe0b70f9db20834a495a74e1/plopp-25.11.0-py3-none-any.whl @@ -2803,7 +2749,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl @@ -2837,26 +2785,14 @@ environments: win-64: - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_8.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-h4c7d964_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/gsl-2.8-h5b8d9c4_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/icu-78.2-h637d24d_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-5_hf2e6a31_mkl.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-5_h2a3cdd5_mkl.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.7.3-hac47afa_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h52bdfb6_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libhwloc-2.12.2-default_h4379cf1_1000.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.2-hfd05255_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.51.2-hf5d6505_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.1-h3cfd58e_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.1-h779ef1b_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.1-h2466b09_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-21.1.8-h4fa8253_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/mkl-2025.3.0-hac47afa_455.conda - conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-25.2.1-he453025_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.0-h725018a_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.12.12-h0159041_1_cpython.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tbb-2022.3.0-h3155e25_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h2c6b04d_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda @@ -2887,6 +2823,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/07/ba/7ca89301e492ac4184ba7f4736565d954ba3125acf6bf02c66a38a802bda/chardet-7.0.1-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl @@ -2894,7 +2831,6 @@ 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#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 - 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 @@ -2906,6 +2842,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/b4/a7ec1eaee86761a9dbfd339732b4706db3c6b65e970c12f0f56cfcce3dcf/docformatter-1.7.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/36/41/04e2a649058b0713b00d6c9bd22da35618bb157289e05d068e51fddf8d7e/dunamai-1.25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/64/f2/9d779717fd4ff4136d009a8023704f7eb37f2231fbfbe49eb9b430315bcc/easyscience-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl @@ -2915,6 +2852,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cf/51/329e7436bf87ca6b0fe06dd0a3795c34bebe4ed8d6c44450a20565d57832/h5py-3.15.1-cp312-cp312-win_amd64.whl @@ -2998,7 +2937,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/36/ce5f75aa7c736a663a901766edc3580098c7ea3959a0e878363a54a3714e/pixi_kernel-0.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/4a/d070dc6a36c2eb8b8a19b31908d0817e2a85fe0b70f9db20834a495a74e1/plopp-25.11.0-py3-none-any.whl @@ -3051,7 +2989,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl @@ -3104,28 +3044,6 @@ packages: purls: [] size: 23621 timestamp: 1650670423406 -- conda: https://conda.anaconda.org/conda-forge/osx-64/_openmp_mutex-4.5-7_kmp_llvm.conda - build_number: 7 - sha256: 30006902a9274de8abdad5a9f02ef7c8bb3d69a503486af0c1faee30b023e5b7 - md5: eaac87c21aff3ed21ad9656697bb8326 - depends: - - llvm-openmp >=9.0.1 - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 8328 - timestamp: 1764092562779 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/_openmp_mutex-4.5-7_kmp_llvm.conda - build_number: 7 - sha256: 7acaa2e0782cad032bdaf756b536874346ac1375745fb250e9bdd6a48a7ab3cd - md5: a44032f282e7d2acdeb1c240308052dd - depends: - - llvm-openmp >=9.0.1 - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 8325 - timestamp: 1764092507920 - pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl name: aiohappyeyeballs version: 2.6.1 @@ -3671,6 +3589,46 @@ packages: version: 3.5.0 sha256: a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/00/fb/a90b4510aa9080966c65321db2084bcfa184518ee1ed15570d351649ecb2/chardet-7.0.1-cp311-cp311-macosx_10_9_x86_64.whl + name: chardet + version: 7.0.1 + sha256: c3f59dc3e148b54813ec5c7b4b2e025d37f5dc221ee28a06d1a62f169cfaedf5 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/07/ba/7ca89301e492ac4184ba7f4736565d954ba3125acf6bf02c66a38a802bda/chardet-7.0.1-cp312-cp312-win_amd64.whl + name: chardet + version: 7.0.1 + sha256: 302798e1e62008ca34a216dd04ecc5e240993b2090628e2a35d4c0754313ea9a + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/24/fa/3ad0b454a55376b7971fe64c2f225dfe56a491d8d8728fbfba63f8ff416d/chardet-7.0.1-cp311-cp311-macosx_11_0_arm64.whl + name: chardet + version: 7.0.1 + sha256: 3355a3c8453d673e7c1664fdd24a0c6ef39964c3d41befc4849250f7eb1de3b5 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/73/64/9c5c450ba18359a8e8ab2943e6c3a0b100bd394799bc73a844e3c5cd9c7c/chardet-7.0.1-cp311-cp311-win_amd64.whl + name: chardet + version: 7.0.1 + sha256: 26186f0ea03c4c1f9be20c088b127c71b0e9d487676930fab77625ddec2a4ef2 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/e4/9f/3d4ba1650e3eb3e7431a054e3bf1b5eaea25b84c72afabf5ef6fc33305d1/chardet-7.0.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: chardet + version: 7.0.1 + sha256: 265cb3b5dafc0411c0949800a0692f07e986fb663b6ae1ecfba32ad193a55a03 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/e8/ed/2fe5ea435ae480bd3a76be1415920ce52b3ff6e188d8eab6a635d6a2a1d1/chardet-7.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: chardet + version: 7.0.1 + sha256: 6f907962b18df78d5ca87a7484e4034354408d2c97cec6f53634b0ea0424c594 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/f6/88/4c6fe7dcd5d36a2cfd7030084fbd79264083f329faaf96038c23888a8e05/chardet-7.0.1-cp312-cp312-macosx_10_13_x86_64.whl + name: chardet + version: 7.0.1 + sha256: f661edbfa77b8683a503043ddc9b9fe9036cf28af13064200e11fa1844ded79c + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/f9/fb/3b92a2433eadef83ae131fa720a17857cfbf7687c5f188bfb2f9eee2d3dd/chardet-7.0.1-cp312-cp312-macosx_11_0_arm64.whl + name: chardet + version: 7.0.1 + sha256: 169951fa88d449e72e0c6194cec1c5e405fd36a6cfbe74c7dab5494cc35f1700 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl name: charset-normalizer version: 3.4.4 @@ -4091,11 +4049,11 @@ packages: requires_python: '>=3.5' - pypi: ./ name: easydynamics - version: 0.1.1+devdirty20 - sha256: de299c914d4a865b9e2fdefa5e3947f37b1f26f73ff9087f7918ee417f3dd288 + version: 0.1.1+devdirty22 + sha256: 43549224752712155825ef8e1cbc517ae54f091f2aada4006f87cb016509089d requires_dist: - darkdetect - - easyscience @ git+https://github.com/easyscience/corelib.git@develop + - easyscience - ipympl - ipywidgets - jupyterlab @@ -4108,6 +4066,7 @@ packages: - build ; extra == 'dev' - copier ; extra == 'dev' - docformatter ; extra == 'dev' + - gitpython ; extra == 'dev' - interrogate ; extra == 'dev' - jinja2 ; extra == 'dev' - jupyterquiz ; extra == 'dev' @@ -4131,12 +4090,14 @@ packages: - pyyaml ; extra == 'dev' - radon ; extra == 'dev' - ruff ; extra == 'dev' + - spdx-headers ; extra == 'dev' - validate-pyproject[all] ; extra == 'dev' - versioningit ; extra == 'dev' requires_python: '>=3.11' -- pypi: git+https://github.com/easyscience/corelib.git#59d787b5158c6d72e0b4aa3cedd5e24f7fa61c56 +- pypi: https://files.pythonhosted.org/packages/64/f2/9d779717fd4ff4136d009a8023704f7eb37f2231fbfbe49eb9b430315bcc/easyscience-2.2.0-py3-none-any.whl name: easyscience version: 2.2.0 + sha256: 5a09221feff4fbf9cfad32fe0009a293e4fe3e245d89303495183d8e3b31ed30 requires_dist: - asteval - bumps @@ -4538,6 +4499,35 @@ packages: - markdown ; extra == 'dev' - flake8 ; extra == 'dev' - wheel ; extra == 'dev' +- pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl + name: gitdb + version: 4.0.12 + sha256: 67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf + requires_dist: + - smmap>=3.0.1,<6 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl + name: gitpython + version: 3.1.46 + sha256: 79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058 + requires_dist: + - gitdb>=4.0.1,<5 + - typing-extensions>=3.10.0.2 ; python_full_version < '3.10' + - coverage[toml] ; extra == 'test' + - ddt>=1.1.1,!=1.4.3 ; extra == 'test' + - mock ; python_full_version < '3.8' and extra == 'test' + - mypy==1.18.2 ; python_full_version >= '3.9' and extra == 'test' + - pre-commit ; extra == 'test' + - pytest>=7.3.1 ; extra == 'test' + - pytest-cov ; extra == 'test' + - pytest-instafail ; extra == 'test' + - pytest-mock ; extra == 'test' + - pytest-sugar ; extra == 'test' + - typing-extensions ; python_full_version < '3.11' and extra == 'test' + - sphinx>=7.1.2,<7.2 ; extra == 'doc' + - sphinx-rtd-theme ; extra == 'doc' + - sphinx-autodoc-typehints ; extra == 'doc' + requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl name: griffe version: 1.15.0 @@ -4548,57 +4538,6 @@ packages: - platformdirs>=4.2 ; extra == 'pypi' - wheel>=0.42 ; extra == 'pypi' requires_python: '>=3.10' -- conda: https://conda.anaconda.org/conda-forge/linux-64/gsl-2.8-hbf7d49c_1.conda - sha256: f923af07c3a3db746d3be8efebdaa9c819a6007ee3cc12445cee059641611e05 - md5: 04e128d2adafe3c844cde58f103c481b - depends: - - __glibc >=2.17,<3.0.a0 - - libblas >=3.9.0,<4.0a0 - - libcblas >=3.9.0,<4.0a0 - - libgcc >=13 - license: GPL-3.0-or-later - license_family: GPL - purls: [] - size: 2486744 - timestamp: 1737621160295 -- conda: https://conda.anaconda.org/conda-forge/osx-64/gsl-2.8-hc707ee6_1.conda - sha256: 1d729f940f28dd5476b847123883abce119dff7af1abc236452d54ad4682b702 - md5: 382c8abc7d56f9236090a76fc6e51a97 - depends: - - __osx >=10.13 - - libblas >=3.9.0,<4.0a0 - - libcblas >=3.9.0,<4.0a0 - license: GPL-3.0-or-later - license_family: GPL - purls: [] - size: 2300171 - timestamp: 1737621445693 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gsl-2.8-h8d0574d_1.conda - sha256: f11d8f2007f6591022afa958d8fe15afbe4211198d1603c0eb886bc21a9eb19e - md5: cc261442bead590d89ca9f96884a344f - depends: - - __osx >=11.0 - - libblas >=3.9.0,<4.0a0 - - libcblas >=3.9.0,<4.0a0 - license: GPL-3.0-or-later - license_family: GPL - purls: [] - size: 1862134 - timestamp: 1737621413640 -- conda: https://conda.anaconda.org/conda-forge/win-64/gsl-2.8-h5b8d9c4_1.conda - sha256: 87a3468e09cc1ee0268e8639debad6a5b440090ef8cb1d2ee5eed66c86085528 - md5: a47cf810b7c03955139a150b228b93ca - depends: - - libblas >=3.9.0,<4.0a0 - - libcblas >=3.9.0,<4.0a0 - - ucrt >=10.0.20348.0 - - vc >=14.2,<15 - - vc14_runtime >=14.29.30139 - license: GPL-3.0-or-later - license_family: GPL - purls: [] - size: 1528970 - timestamp: 1737622367981 - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl name: h11 version: 0.16.0 @@ -4722,18 +4661,6 @@ packages: purls: [] size: 12358010 timestamp: 1767970350308 -- conda: https://conda.anaconda.org/conda-forge/win-64/icu-78.2-h637d24d_0.conda - sha256: 5a41fb28971342e293769fc968b3414253a2f8d9e30ed7c31517a15b4887246a - md5: 0ee3bb487600d5e71ab7d28951b2016a - depends: - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: MIT - license_family: MIT - purls: [] - size: 13222158 - timestamp: 1767970128854 - pypi: https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl name: identify version: 2.6.16 @@ -5534,76 +5461,6 @@ packages: purls: [] size: 1174081 timestamp: 1750194620012 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-5_h4a7cf45_openblas.conda - build_number: 5 - sha256: 18c72545080b86739352482ba14ba2c4815e19e26a7417ca21a95b76ec8da24c - md5: c160954f7418d7b6e87eaf05a8913fa9 - depends: - - libopenblas >=0.3.30,<0.3.31.0a0 - - libopenblas >=0.3.30,<1.0a0 - constrains: - - mkl <2026 - - liblapack 3.11.0 5*_openblas - - libcblas 3.11.0 5*_openblas - - blas 2.305 openblas - - liblapacke 3.11.0 5*_openblas - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 18213 - timestamp: 1765818813880 -- conda: https://conda.anaconda.org/conda-forge/osx-64/libblas-3.11.0-5_he492b99_openblas.conda - build_number: 5 - sha256: 4754de83feafa6c0b41385f8dab1b13f13476232e16f524564a340871a9fc3bc - md5: 36d2e68a156692cbae776b75d6ca6eae - depends: - - libopenblas >=0.3.30,<0.3.31.0a0 - - libopenblas >=0.3.30,<1.0a0 - constrains: - - liblapack 3.11.0 5*_openblas - - blas 2.305 openblas - - libcblas 3.11.0 5*_openblas - - mkl <2026 - - liblapacke 3.11.0 5*_openblas - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 18476 - timestamp: 1765819054657 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libblas-3.11.0-5_h51639a9_openblas.conda - build_number: 5 - sha256: 620a6278f194dcabc7962277da6835b1e968e46ad0c8e757736255f5ddbfca8d - md5: bcc025e2bbaf8a92982d20863fe1fb69 - depends: - - libopenblas >=0.3.30,<0.3.31.0a0 - - libopenblas >=0.3.30,<1.0a0 - constrains: - - libcblas 3.11.0 5*_openblas - - liblapack 3.11.0 5*_openblas - - liblapacke 3.11.0 5*_openblas - - blas 2.305 openblas - - mkl <2026 - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 18546 - timestamp: 1765819094137 -- conda: https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-5_hf2e6a31_mkl.conda - build_number: 5 - sha256: f0cb7b2697461a306341f7ff32d5b361bb84f3e94478464c1e27ee01fc8f276b - md5: f9decf88743af85c9c9e05556a4c47c0 - depends: - - mkl >=2025.3.0,<2026.0a0 - constrains: - - liblapack 3.11.0 5*_mkl - - libcblas 3.11.0 5*_mkl - - blas 2.305 mkl - - liblapacke 3.11.0 5*_mkl - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 67438 - timestamp: 1765819100043 - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda sha256: 318f36bd49ca8ad85e6478bd8506c88d82454cc008c1ac1c6bf00a3c42fa610e md5: 72c8fd1af66bd67bf580645b426513ed @@ -5703,66 +5560,6 @@ packages: purls: [] size: 290754 timestamp: 1764018009077 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-5_h0358290_openblas.conda - build_number: 5 - sha256: 0cbdcc67901e02dc17f1d19e1f9170610bd828100dc207de4d5b6b8ad1ae7ad8 - md5: 6636a2b6f1a87572df2970d3ebc87cc0 - depends: - - libblas 3.11.0 5_h4a7cf45_openblas - constrains: - - liblapacke 3.11.0 5*_openblas - - blas 2.305 openblas - - liblapack 3.11.0 5*_openblas - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 18194 - timestamp: 1765818837135 -- conda: https://conda.anaconda.org/conda-forge/osx-64/libcblas-3.11.0-5_h9b27e0a_openblas.conda - build_number: 5 - sha256: 8077c29ea720bd152be6e6859a3765228cde51301fe62a3b3f505b377c2cb48c - md5: b31d771cbccff686e01a687708a7ca41 - depends: - - libblas 3.11.0 5_he492b99_openblas - constrains: - - liblapack 3.11.0 5*_openblas - - blas 2.305 openblas - - liblapacke 3.11.0 5*_openblas - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 18484 - timestamp: 1765819073006 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcblas-3.11.0-5_hb0561ab_openblas.conda - build_number: 5 - sha256: 38809c361bbd165ecf83f7f05fae9b791e1baa11e4447367f38ae1327f402fc0 - md5: efd8bd15ca56e9d01748a3beab8404eb - depends: - - libblas 3.11.0 5_h51639a9_openblas - constrains: - - liblapacke 3.11.0 5*_openblas - - liblapack 3.11.0 5*_openblas - - blas 2.305 openblas - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 18548 - timestamp: 1765819108956 -- conda: https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-5_h2a3cdd5_mkl.conda - build_number: 5 - sha256: 49dc59d8e58360920314b8d276dd80da7866a1484a9abae4ee2760bc68f3e68d - md5: b3fa8e8b55310ba8ef0060103afb02b5 - depends: - - libblas 3.11.0 5_hf2e6a31_mkl - constrains: - - liblapack 3.11.0 5*_mkl - - liblapacke 3.11.0 5*_mkl - - blas 2.305 mkl - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 68079 - timestamp: 1765819124349 - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-21.1.8-h3d58e20_0.conda sha256: cbd8e821e97436d8fc126c24b50df838b05ba4c80494fbb93ccaf2e3b2d109fb md5: 9f8a60a77ecafb7966ca961c94f33bd1 @@ -5917,32 +5714,6 @@ packages: purls: [] size: 1042798 timestamp: 1765256792743 -- conda: https://conda.anaconda.org/conda-forge/osx-64/libgcc-15.2.0-h08519bb_15.conda - sha256: e04b115ae32f8cbf95905971856ff557b296511735f4e1587b88abf519ff6fb8 - md5: c816665789d1e47cdfd6da8a81e1af64 - depends: - - _openmp_mutex - constrains: - - libgomp 15.2.0 15 - - libgcc-ng ==15.2.0=*_15 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - purls: [] - size: 422960 - timestamp: 1764839601296 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgcc-15.2.0-hcbb3090_16.conda - sha256: 646c91dbc422fe92a5f8a3a5409c9aac66549f4ce8f8d1cab7c2aa5db789bb69 - md5: 8b216bac0de7a9d60f3ddeba2515545c - depends: - - _openmp_mutex - constrains: - - libgcc-ng ==15.2.0=*_16 - - libgomp 15.2.0 16 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - purls: [] - size: 402197 - timestamp: 1765258985740 - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_16.conda sha256: 5f07f9317f596a201cc6e095e5fc92621afca64829785e483738d935f8cab361 md5: 5a68259fac2da8f2ee6f7bfe49c9eb8b @@ -5953,79 +5724,6 @@ packages: purls: [] size: 27256 timestamp: 1765256804124 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_16.conda - sha256: 8a7b01e1ee1c462ad243524d76099e7174ebdd94ff045fe3e9b1e58db196463b - md5: 40d9b534410403c821ff64f00d0adc22 - depends: - - libgfortran5 15.2.0 h68bc16d_16 - constrains: - - libgfortran-ng ==15.2.0=*_16 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - purls: [] - size: 27215 - timestamp: 1765256845586 -- conda: https://conda.anaconda.org/conda-forge/osx-64/libgfortran-15.2.0-h7e5c614_15.conda - sha256: 7bb4d51348e8f7c1a565df95f4fc2a2021229d42300aab8366eda0ea1af90587 - md5: a089323fefeeaba2ae60e1ccebf86ddc - depends: - - libgfortran5 15.2.0 hd16e46c_15 - constrains: - - libgfortran-ng ==15.2.0=*_15 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - purls: [] - size: 139002 - timestamp: 1764839892631 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran-15.2.0-h07b0088_16.conda - sha256: 68a6c1384d209f8654112c4c57c68c540540dd8e09e17dd1facf6cf3467798b5 - md5: 11e09edf0dde4c288508501fe621bab4 - depends: - - libgfortran5 15.2.0 hdae7583_16 - constrains: - - libgfortran-ng ==15.2.0=*_16 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - purls: [] - size: 138630 - timestamp: 1765259217400 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_16.conda - sha256: d0e974ebc937c67ae37f07a28edace978e01dc0f44ee02f29ab8a16004b8148b - md5: 39183d4e0c05609fd65f130633194e37 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=15.2.0 - constrains: - - libgfortran 15.2.0 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - purls: [] - size: 2480559 - timestamp: 1765256819588 -- conda: https://conda.anaconda.org/conda-forge/osx-64/libgfortran5-15.2.0-hd16e46c_15.conda - sha256: 456385a7d3357d5fdfc8e11bf18dcdf71753c4016c440f92a2486057524dd59a - md5: c2a6149bf7f82774a0118b9efef966dd - depends: - - libgcc >=15.2.0 - constrains: - - libgfortran 15.2.0 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - purls: [] - size: 1061950 - timestamp: 1764839609607 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran5-15.2.0-hdae7583_16.conda - sha256: 9fb7f4ff219e3fb5decbd0ee90a950f4078c90a86f5d8d61ca608c913062f9b0 - md5: 265a9d03461da24884ecc8eb58396d57 - depends: - - libgcc >=15.2.0 - constrains: - - libgfortran 15.2.0 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - purls: [] - size: 598291 - timestamp: 1765258993165 - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_16.conda sha256: 5b3e5e4e9270ecfcd48f47e3a68f037f5ab0f529ccb223e8e5d5ac75a58fc687 md5: 26c46f90d0e727e95c6c9498a33a09f3 @@ -6036,32 +5734,6 @@ packages: purls: [] size: 603284 timestamp: 1765256703881 -- conda: https://conda.anaconda.org/conda-forge/win-64/libhwloc-2.12.2-default_h4379cf1_1000.conda - sha256: 8cdf11333a81085468d9aa536ebb155abd74adc293576f6013fc0c85a7a90da3 - md5: 3b576f6860f838f950c570f4433b086e - depends: - - libwinpthread >=12.0.0.r4.gg4f2fc60ca - - libxml2 - - libxml2-16 >=2.14.6 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 2411241 - timestamp: 1765104337762 -- conda: https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_2.conda - sha256: 0dcdb1a5f01863ac4e8ba006a8b0dc1a02d2221ec3319b5915a1863254d7efa7 - md5: 64571d1dd6cdcfa25d0664a5950fdaa2 - depends: - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: LGPL-2.1-only - purls: [] - size: 696926 - timestamp: 1754909290005 - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda sha256: 755c55ebab181d678c12e49cced893598f2bab22d582fbbf4d8b83c18be207eb md5: c7c83eecbb72d88b940c249af56c8b17 @@ -6169,51 +5841,6 @@ packages: purls: [] size: 33731 timestamp: 1750274110928 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.30-pthreads_h94d23a6_4.conda - sha256: 199d79c237afb0d4780ccd2fbf829cea80743df60df4705202558675e07dd2c5 - md5: be43915efc66345cccb3c310b6ed0374 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - - libgfortran - - libgfortran5 >=14.3.0 - constrains: - - openblas >=0.3.30,<0.3.31.0a0 - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 5927939 - timestamp: 1763114673331 -- conda: https://conda.anaconda.org/conda-forge/osx-64/libopenblas-0.3.30-openmp_h6006d49_4.conda - sha256: ba642353f7f41ab2d2eb6410fbe522238f0f4483bcd07df30b3222b4454ee7cd - md5: 9241a65e6e9605e4581a2a8005d7f789 - depends: - - __osx >=10.13 - - libgfortran - - libgfortran5 >=14.3.0 - - llvm-openmp >=19.1.7 - constrains: - - openblas >=0.3.30,<0.3.31.0a0 - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 6268795 - timestamp: 1763117623665 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenblas-0.3.30-openmp_ha158390_4.conda - sha256: ebbbc089b70bcde87c4121a083c724330f02a690fb9d7c6cd18c30f1b12504fa - md5: a6f6d3a31bb29e48d37ce65de54e2df0 - depends: - - __osx >=11.0 - - libgfortran - - libgfortran5 >=14.3.0 - - llvm-openmp >=19.1.7 - constrains: - - openblas >=0.3.30,<0.3.31.0a0 - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 4284132 - timestamp: 1768547079205 - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.2-hf4e2dac_0.conda sha256: 04596fcee262a870e4b7c9807224680ff48d4d0cc0dac076a602503d3dc6d217 md5: da5be73701eecd0e8454423fd6ffcf30 @@ -6313,18 +5940,6 @@ packages: purls: [] size: 421195 timestamp: 1753948426421 -- conda: https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda - sha256: 0fccf2d17026255b6e10ace1f191d0a2a18f2d65088fd02430be17c701f8ffe0 - md5: 8a86073cf3b343b87d03f41790d8b4e5 - depends: - - ucrt - constrains: - - pthreads-win32 <0.0a0 - - msys2-conda-epoch <0.0a0 - license: MIT AND BSD-3-Clause-Clear - purls: [] - size: 36621 - timestamp: 1759768399557 - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda sha256: 6ae68e0b86423ef188196fff6207ed0c8195dd84273cb5623b85aa08033a410c md5: 5aa797f8787fe7a17d1b0821485b5adc @@ -6334,41 +5949,6 @@ packages: purls: [] size: 100393 timestamp: 1702724383534 -- conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.1-h779ef1b_1.conda - sha256: 8b47d5fb00a6ccc0f495d16787ab5f37a434d51965584d6000966252efecf56d - md5: 68dc154b8d415176c07b6995bd3a65d9 - depends: - - icu >=78.1,<79.0a0 - - libiconv >=1.18,<2.0a0 - - liblzma >=5.8.1,<6.0a0 - - libxml2-16 2.15.1 h3cfd58e_1 - - libzlib >=1.3.1,<2.0a0 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: MIT - license_family: MIT - purls: [] - size: 43387 - timestamp: 1766327259710 -- conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.1-h3cfd58e_1.conda - sha256: a857e941156b7f462063e34e086d212c6ccbc1521ebdf75b9ed66bd90add57dc - md5: 07d73826fde28e7dbaec52a3297d7d26 - depends: - - icu >=78.1,<79.0a0 - - libiconv >=1.18,<2.0a0 - - liblzma >=5.8.1,<6.0a0 - - libzlib >=1.3.1,<2.0a0 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - constrains: - - libxml2 2.15.1 - license: MIT - license_family: MIT - purls: [] - size: 518964 - timestamp: 1766327232819 - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda sha256: d4bfe88d7cb447768e31650f06257995601f89076080e76df55e3112d4e47dc4 md5: edb0dca6bc32e4f4789199455a1dbeb8 @@ -6420,47 +6000,6 @@ packages: purls: [] size: 55476 timestamp: 1727963768015 -- conda: https://conda.anaconda.org/conda-forge/osx-64/llvm-openmp-21.1.8-h472b3d1_0.conda - sha256: 2a41885f44cbc1546ff26369924b981efa37a29d20dc5445b64539ba240739e6 - md5: e2d811e9f464dd67398b4ce1f9c7c872 - depends: - - __osx >=10.13 - constrains: - - openmp 21.1.8|21.1.8.* - - intel-openmp <0.0a0 - license: Apache-2.0 WITH LLVM-exception - license_family: APACHE - purls: [] - size: 311405 - timestamp: 1765965194247 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-21.1.8-h4a912ad_0.conda - sha256: 56bcd20a0a44ddd143b6ce605700fdf876bcf5c509adc50bf27e76673407a070 - md5: 206ad2df1b5550526e386087bef543c7 - depends: - - __osx >=11.0 - constrains: - - openmp 21.1.8|21.1.8.* - - intel-openmp <0.0a0 - license: Apache-2.0 WITH LLVM-exception - license_family: APACHE - purls: [] - size: 285974 - timestamp: 1765964756583 -- conda: https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-21.1.8-h4fa8253_0.conda - sha256: 145c4370abe870f10987efa9fc15a8383f1dab09abbc9ad4ff15a55d45658f7b - md5: 0d8b425ac862bcf17e4b28802c9351cb - depends: - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - constrains: - - intel-openmp <0.0a0 - - openmp 21.1.8|21.1.8.* - license: Apache-2.0 WITH LLVM-exception - license_family: APACHE - purls: [] - size: 347566 - timestamp: 1765964942856 - pypi: https://files.pythonhosted.org/packages/38/7e/7b91c89a4cf0f543a83be978657afb20c86af6d725253e319589dcc4ce52/lmfit-1.3.4-py3-none-any.whl name: lmfit version: 1.3.4 @@ -6945,20 +6484,6 @@ packages: - griffe>=1.13 - typing-extensions>=4.0 ; python_full_version < '3.11' requires_python: '>=3.10' -- conda: https://conda.anaconda.org/conda-forge/win-64/mkl-2025.3.0-hac47afa_455.conda - sha256: b2b4c84b95210760e4d12319416c60ab66e03674ccdcbd14aeb59f82ebb1318d - md5: fd05d1e894497b012d05a804232254ed - depends: - - llvm-openmp >=21.1.8 - - tbb >=2022.3.0 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: LicenseRef-IntelSimplifiedSoftwareOct2022 - license_family: Proprietary - purls: [] - size: 100224829 - timestamp: 1767634557029 - pypi: https://files.pythonhosted.org/packages/5b/69/93b34728cc386efdde0c342f8c680b9187dea7beb7adaf6b58a0713be101/mpld3-0.5.12-py3-none-any.whl name: mpld3 version: 0.5.12 @@ -8543,11 +8068,6 @@ packages: - trove-classifiers>=2024.10.12 ; extra == 'tests' - defusedxml ; extra == 'xmp' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl - name: pip - version: '25.3' - sha256: 9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd - requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/c4/36/ce5f75aa7c736a663a901766edc3580098c7ea3959a0e878363a54a3714e/pixi_kernel-0.7.1-py3-none-any.whl name: pixi-kernel version: 0.7.1 @@ -10239,11 +9759,38 @@ packages: version: 1.17.0 sha256: 4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*' +- pypi: https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl + name: smmap + version: 5.0.2 + sha256: b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e + requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl name: soupsieve version: 2.8.3 sha256: ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95 requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl + name: spdx-headers + version: 1.5.1 + sha256: 73bcb1ed087824b55ccaa497d03d8f0f0b0eaf30e5f0f7d5bbd29d2c4fe78fcf + requires_dist: + - chardet>=5.2.0 + - requests>=2.32.3 + - black>=23.0.0 ; extra == 'dev' + - build>=0.10.0 ; extra == 'dev' + - hatch>=1.9.0 ; extra == 'dev' + - isort>=5.12.0 ; extra == 'dev' + - mypy>=1.0.0 ; extra == 'dev' + - pre-commit>=4.3.0 ; extra == 'dev' + - pytest-cov>=4.0.0 ; extra == 'dev' + - pytest>=7.0.0 ; extra == 'dev' + - ruff>=0.5.0 ; extra == 'dev' + - twine>=4.0.0 ; extra == 'dev' + - types-requests>=2.31.0.6 ; extra == 'dev' + - pytest-cov>=4.0.0 ; extra == 'test' + - pytest-mock>=3.10.0 ; extra == 'test' + - pytest>=7.0.0 ; extra == 'test' + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl name: stack-data version: 0.6.3 @@ -10264,19 +9811,6 @@ packages: requires_dist: - wcwidth ; extra == 'widechars' requires_python: '>=3.7' -- conda: https://conda.anaconda.org/conda-forge/win-64/tbb-2022.3.0-h3155e25_2.conda - sha256: abd9a489f059fba85c8ffa1abdaa4d515d6de6a3325238b8e81203b913cf65a9 - md5: 0f9817ffbe25f9e69ceba5ea70c52606 - depends: - - libhwloc >=2.12.2,<2.12.3.0a0 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: Apache-2.0 - license_family: APACHE - purls: [] - size: 155869 - timestamp: 1767886839029 - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl name: terminado version: 0.18.1 diff --git a/pixi.toml b/pixi.toml index d280c259..22dabb86 100644 --- a/pixi.toml +++ b/pixi.toml @@ -42,10 +42,10 @@ channels = ['conda-forge'] [dependencies] nodejs = '*' # Required for Prettier (non-Python formatting) -gsl = '*' # GNU Scientific Library; required for pdffit2. +#gsl = '*' # GNU Scientific Library; required for pdffit2 [pypi-dependencies] # == [feature.default.pypi-dependencies] -pip = '*' # Native package installer +#pip = '*' # Native package installer easydynamics = { path = ".", editable = true, extras = ['dev'] } # Specific features: Set specific Python versions @@ -182,9 +182,9 @@ docs-update-assets = 'python tools/update_docs_assets.py' # 📦 Template Management Tasks ############################## -copier-copy = "copier copy gh:easyscience/templates . --data-file ../dynamics/.copier-answers.yml --data template_type=lib" -copier-recopy = "copier recopy --data-file ../dynamics/.copier-answers.yml --data template_type=lib" -copier-update = "copier update --data-file ../dynamics/.copier-answers.yml --data template_type=lib" +copier-copy = "copier copy gh:easyscience/templates . --data-file .copier-answers.yml --data template_type=lib" +copier-recopy = "copier recopy --data-file .copier-answers.yml --data template_type=lib" +copier-update = "copier update --data-file .copier-answers.yml --data template_type=lib" ##################### # 🪝 Pre-commit Hooks @@ -199,12 +199,46 @@ pre-commit-setup = { depends-on = [ 'pre-commit-install', ] } +################# +# 🐙️ GitHub Tasks +################# + +repo-wiki = 'gh api -X PATCH repos/easyscience/dynamics-lib -f has_wiki=false' +repo-discussions = 'gh api -X PATCH repos/easyscience/dynamics-lib -f has_discussions=true' +repo-description = "gh api -X PATCH repos/easyscience/dynamics-lib -f description='QENS data analysis'" +repo-homepage = "gh api -X PATCH repos/easyscience/dynamics-lib -f homepage='https://easyscience.github.io/dynamics-lib'" +repo-config = { depends-on = [ + 'repo-wiki', + 'repo-discussions', + 'repo-description', + 'repo-homepage', +] } + +master-protection = 'gh api -X POST repos/easyscience/dynamics-lib/rulesets --input .github/configs/rulesets-master.json' +develop-protection = 'gh api -X POST repos/easyscience/dynamics-lib/rulesets --input .github/configs/rulesets-develop.json' +gh-pages-protection = 'gh api -X POST repos/easyscience/dynamics-lib/rulesets --input .github/configs/rulesets-gh-pages.json' +branch-protection = { depends-on = [ + 'master-protection', + 'develop-protection', + 'gh-pages-protection', +] } + +pages-deployment = 'gh api -X POST repos/easyscience/dynamics-lib/pages --input .github/configs/pages-deployment.json' + +github-labels = 'python tools/update_github_labels.py' + +######################### +# ⚖️ SPDX License Headers +######################### + +spdx-remove = 'python tools/remove_spdx.py src/ tests/' +spdx-add = 'python tools/add_spdx.py src/ tests/' +spdx-check = 'python tools/check_spdx.py src/ tests/' + #################################### # 🚀 Other Development & Build Tasks #################################### -github-labels = 'python tools/update_github_labels.py' - default-build = 'python -m build' dist-build = 'python -m build --wheel --outdir dist' @@ -212,7 +246,6 @@ npm-config = 'npm config set registry https://registry.npmjs.org/' prettier-install = 'npm install --no-save --no-audit --no-fund prettier prettier-plugin-toml' clean-pycache = "find . -type d -name '__pycache__' -prune -exec rm -rf '{}' +" -spdx-update = 'python tools/update_spdx.py' post-install = { depends-on = [ 'npm-config', diff --git a/pyproject.toml b/pyproject.toml index 46ab2717..dfeb13ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,9 +6,10 @@ name = 'easydynamics' dynamic = ['version'] # Use versioningit to manage the version description = 'QENS data analysis' -authors = [{ name = 'EasyDynamics contributors' }] +authors = [{ name = 'EasyScience contributors' }] readme = 'README.md' -license = { file = 'LICENSE' } +license = 'BSD-3-Clause' +license-files = ['LICENSE'] classifiers = [ 'Intended Audience :: Science/Research', 'Topic :: Scientific/Engineering', @@ -21,21 +22,22 @@ classifiers = [ ] requires-python = '>=3.11' dependencies = [ - 'easyscience @ git+https://github.com/easyscience/corelib.git@develop', # The base library of the EasyScience framework - 'pooch', # Data downloader - 'darkdetect', # Detecting dark mode (system-level) - 'pandas', # Displaying tables in Jupyter notebooks - 'plotly', # Interactive plots - 'py3Dmol', # Visualisation of crystal structures - 'ipympl', # Matplotlib Jupyter backend - 'ipywidgets', # Jupyter widgets - 'jupyterlab', # Jupyter notebooks - 'pixi-kernel', # Pixi Jupyter kernel - 'plopp', # Plotting library + 'easyscience', # The base library of the EasyScience framework + 'pooch', # Data downloader + 'darkdetect', # Detecting dark mode (system-level) + 'pandas', # Displaying tables in Jupyter notebooks + 'plotly', # Interactive plots + 'py3Dmol', # Visualisation of crystal structures + 'ipympl', # Matplotlib Jupyter backend + 'ipywidgets', # Jupyter widgets + 'jupyterlab', # Jupyter notebooks + 'pixi-kernel', # Pixi Jupyter kernel + 'plopp', # Plotting library ] [project.optional-dependencies] dev = [ + 'GitPython', # Interact with Git repositories 'build', # Building the package 'pre-commit', # Pre-commit hooks 'jinja2', # Templating @@ -64,13 +66,15 @@ dev = [ 'mkdocs-markdownextradata-plugin', # MkDocs: Markdown extra data support, such as global variables 'mkdocstrings-python', # MkDocs: Python docstring support 'pyyaml', # YAML parser + 'spdx-headers', # SPDX license header validation ] [project.urls] -homepage = 'https://easyscience.github.io/dynamics' -documentation = 'https://easyscience.github.io/dynamics-lib' -source = 'https://github.com/easyscience/dynamics-lib' -tracker = 'https://github.com/easyscience/dynamics-lib/issues' +Homepage = 'https://easyscience.github.io/dynamics' +Documentation = 'https://easyscience.github.io/dynamics-lib' +'Release Notes' = 'https://github.com/easyscience/dynamics-lib/releases' +'Source Code' = 'https://github.com/easyscience/dynamics-lib' +'Issue Tracker' = 'https://github.com/easyscience/dynamics-lib/issues' ############################ # Build system configuration @@ -122,19 +126,6 @@ method = 'git' match = ['v*'] default-tag = 'v999.0.0' -################################ -# Configuration for docformatter -################################ - -# 'docformatter' -- Code formatter for docstrings -# https://docformatter.readthedocs.io/en/latest/ - -[tool.docformatter] -recursive = true -wrap-summaries = 72 -wrap-descriptions = 72 -close-quotes-on-newline = true - ################################ # Configuration for interrogate ################################ @@ -216,8 +207,33 @@ force-single-line = true max-complexity = 10 # default is 10 [tool.ruff.lint.pycodestyle] -max-line-length = 99 # https://peps.python.org/pep-0008/#maximum-line-length -max-doc-length = 72 # https://peps.python.org/pep-0008/#maximum-line-length +# PEP 8 line length guidance: +# https://peps.python.org/pep-0008/#maximum-line-length +# Use 99 characters as the project-wide maximum for regular code lines. +max-line-length = 99 +# allow longer lines so that parameter declarations such as +# `name (Type | Type | None):` remain on a single line. Splitting these +# lines can prevent tools such as MkDocs and IDEs from correctly +# parsing and rendering parameter documentation. +# The descriptive text itself is wrapped more strictly by +# `docformatter` (see the configuration in [tool.docformatter] below) +# whenever it is treated as normal paragraph text. +# The line length for code snippets in docstrings is also more strict, +# as defined in the [tool.ruff.format] section above. +max-doc-length = 99 [tool.ruff.lint.pydocstyle] convention = 'google' + +################################ +# Configuration for docformatter +################################ + +# 'docformatter' -- Code formatter for docstrings +# https://docformatter.readthedocs.io/en/latest/ + +[tool.docformatter] +recursive = true +wrap-summaries = 72 +wrap-descriptions = 72 +close-quotes-on-newline = true diff --git a/src/easydynamics/__init__.py b/src/easydynamics/__init__.py index 2585a1a0..8173d51c 100644 --- a/src/easydynamics/__init__.py +++ b/src/easydynamics/__init__.py @@ -1,3 +1,3 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2025 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause """EasyDynamics library.""" diff --git a/src/easydynamics/analysis/__init__.py b/src/easydynamics/analysis/__init__.py index 4cb511b4..431457ca 100644 --- a/src/easydynamics/analysis/__init__.py +++ b/src/easydynamics/analysis/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from .analysis import Analysis diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index 5332215b..da6decfd 100644 --- a/src/easydynamics/analysis/analysis.py +++ b/src/easydynamics/analysis/analysis.py @@ -1,7 +1,6 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause - import numpy as np import scipp as sc from easyscience.fitting.minimizers.utils import FitResults diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index 54bdab91..d267c546 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import numpy as np diff --git a/src/easydynamics/analysis/analysis_base.py b/src/easydynamics/analysis/analysis_base.py index 24be2e28..e4c19d8d 100644 --- a/src/easydynamics/analysis/analysis_base.py +++ b/src/easydynamics/analysis/analysis_base.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import numpy as np diff --git a/src/easydynamics/convolution/__init__.py b/src/easydynamics/convolution/__init__.py index 8aca8e92..9b023c95 100644 --- a/src/easydynamics/convolution/__init__.py +++ b/src/easydynamics/convolution/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from .convolution import Convolution diff --git a/src/easydynamics/convolution/analytical_convolution.py b/src/easydynamics/convolution/analytical_convolution.py index a637e8fd..2026c461 100644 --- a/src/easydynamics/convolution/analytical_convolution.py +++ b/src/easydynamics/convolution/analytical_convolution.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import numpy as np @@ -62,8 +62,8 @@ def __init__( where the convolution is evaluated. sample_components (ComponentCollection | ModelComponent): The sample model to be convolved. - resolution_components (ComponentCollection | - ModelComponent): The resolution model to convolve with. + resolution_components (ComponentCollection | ModelComponent): + The resolution model to convolve with. energy_offset (Numeric | Parameter, optional): An offset to shift the energy values by. Default is 0.0. energy_unit (str | sc.Unit, optional): The unit of the diff --git a/src/easydynamics/convolution/convolution.py b/src/easydynamics/convolution/convolution.py index b799d60f..14df08f4 100644 --- a/src/easydynamics/convolution/convolution.py +++ b/src/easydynamics/convolution/convolution.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import numpy as np @@ -112,9 +112,8 @@ def __init__( values where the convolution is evaluated. sample_components (ComponentCollection | ModelComponent): The sample components to be convolved. - resolution_components (ComponentCollection | - ModelComponent): The resolution components to convolve - with. + resolution_components (ComponentCollection | ModelComponent): + The resolution components to convolve with. upsample_factor (int | None): The factor by which to upsample the input data before convolution. Default is 5. diff --git a/src/easydynamics/convolution/convolution_base.py b/src/easydynamics/convolution/convolution_base.py index 5e69a4f4..ddab253f 100644 --- a/src/easydynamics/convolution/convolution_base.py +++ b/src/easydynamics/convolution/convolution_base.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import numpy as np @@ -53,8 +53,8 @@ def __init__( values where the convolution is evaluated. sample_components (ComponentCollection | ModelComponent): The sample model to be convolved. - resolution_components (ComponentCollection | - ModelComponent): The resolution model to convolve with. + resolution_components (ComponentCollection | ModelComponent): + The resolution model to convolve with. energy_unit (str | sc.Unit, optional): The unit of the energy. Default is 'meV'. energy_offset (Numeric | Parameter, optional): The energy diff --git a/src/easydynamics/convolution/energy_grid.py b/src/easydynamics/convolution/energy_grid.py index c8b0c340..645a605b 100644 --- a/src/easydynamics/convolution/energy_grid.py +++ b/src/easydynamics/convolution/energy_grid.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from dataclasses import dataclass diff --git a/src/easydynamics/convolution/numerical_convolution.py b/src/easydynamics/convolution/numerical_convolution.py index 9738c58c..79cc033d 100644 --- a/src/easydynamics/convolution/numerical_convolution.py +++ b/src/easydynamics/convolution/numerical_convolution.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import numpy as np @@ -84,8 +84,8 @@ def __init__( where the convolution is evaluated. sample_components (ComponentCollection | ModelComponent): The sample model to be convolved. - resolution_components (ComponentCollection | - ModelComponent): The resolution model to convolve with. + resolution_components (ComponentCollection | ModelComponent): + The resolution model to convolve with. upsample_factor (int, optional): The factor by which to upsample the input data before convolution. Default is 5. diff --git a/src/easydynamics/convolution/numerical_convolution_base.py b/src/easydynamics/convolution/numerical_convolution_base.py index b1672a16..e5e37a54 100644 --- a/src/easydynamics/convolution/numerical_convolution_base.py +++ b/src/easydynamics/convolution/numerical_convolution_base.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import warnings @@ -94,9 +94,8 @@ def __init__( where the convolution is evaluated. sample_components (ComponentCollection | ModelComponent): The components to be convolved. - resolution_components (ComponentCollection | - ModelComponent): The resolution components to convolve - with. + resolution_components (ComponentCollection | ModelComponent): + The resolution components to convolve with. upsample_factor (int | None): The factor by which to upsample the input data before convolution. Default is 5. diff --git a/src/easydynamics/experiment/__init__.py b/src/easydynamics/experiment/__init__.py index 6b3a8a44..da01800f 100644 --- a/src/easydynamics/experiment/__init__.py +++ b/src/easydynamics/experiment/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from .experiment import Experiment diff --git a/src/easydynamics/experiment/experiment.py b/src/easydynamics/experiment/experiment.py index e506432b..d0c6c912 100644 --- a/src/easydynamics/experiment/experiment.py +++ b/src/easydynamics/experiment/experiment.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + import os import plopp as pp @@ -28,8 +31,8 @@ class Experiment(NewBase): data (sc.DataArray | None): Dataset associated with the experiment. binned_data (sc.DataArray | None): Binned dataset associated - with the experiment. This is derived from `data` and is updated - whenever `data` is set. + with the experiment. This is derived from `data` and is + updated whenever `data` is set. """ def __init__( @@ -192,7 +195,7 @@ def load_hdf5(self, filename: str, display_name: str | None = None): Args: filename (str ): Path to the HDF5 file. display_name (str | None): Optional display name for the - experiment. + experiment. Raises: TypeError: If filename is not a string or if display_name is diff --git a/src/easydynamics/sample_model/__init__.py b/src/easydynamics/sample_model/__init__.py index 1f1602aa..2fb61420 100644 --- a/src/easydynamics/sample_model/__init__.py +++ b/src/easydynamics/sample_model/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2025 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from .background_model import BackgroundModel diff --git a/src/easydynamics/sample_model/background_model.py b/src/easydynamics/sample_model/background_model.py index c8b4f756..420fce40 100644 --- a/src/easydynamics/sample_model/background_model.py +++ b/src/easydynamics/sample_model/background_model.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import scipp as sc @@ -31,8 +31,8 @@ class BackgroundModel(ModelBase): unit (str | sc.Unit): Unit of the model. components (list[ModelComponent]): List of ModelComponents in the model. - Q (np.ndarray | Numeric | list | ArrayLike | sc.Variable - | None): Q values of the model. + Q (np.ndarray | Numeric | list | ArrayLike | sc.Variable | None): + Q values of the model. """ def __init__( diff --git a/src/easydynamics/sample_model/component_collection.py b/src/easydynamics/sample_model/component_collection.py index 45bac989..90ae9619 100644 --- a/src/easydynamics/sample_model/component_collection.py +++ b/src/easydynamics/sample_model/component_collection.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from __future__ import annotations diff --git a/src/easydynamics/sample_model/components/__init__.py b/src/easydynamics/sample_model/components/__init__.py index 95ccb0c1..add46783 100644 --- a/src/easydynamics/sample_model/components/__init__.py +++ b/src/easydynamics/sample_model/components/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2025 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from .damped_harmonic_oscillator import DampedHarmonicOscillator diff --git a/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py b/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py index 7b12a1b0..bf1007e6 100644 --- a/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py +++ b/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2025 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from __future__ import annotations @@ -191,9 +191,8 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) width. Args: - x (Numeric | list | np.ndarray | sc.Variable | - sc.DataArray): The x values at which to evaluate the - DHO. + x (Numeric | list | np.ndarray | sc.Variable | sc.DataArray): + The x values at which to evaluate the DHO. Returns: np.ndarray: The intensity of the DHO at the given x values. diff --git a/src/easydynamics/sample_model/components/delta_function.py b/src/easydynamics/sample_model/components/delta_function.py index 61741f71..5a06a26f 100644 --- a/src/easydynamics/sample_model/components/delta_function.py +++ b/src/easydynamics/sample_model/components/delta_function.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2025 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from __future__ import annotations @@ -144,9 +144,8 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) an identity in convolutions. Args: - x (Numeric | list | np.ndarray | sc.Variable | - sc.DataArray): The x values at which to evaluate the - Delta function. + x (Numeric | list | np.ndarray | sc.Variable | sc.DataArray): + The x values at which to evaluate the Delta function. Returns: np.ndarray: The evaluated Delta function at the given x diff --git a/src/easydynamics/sample_model/components/gaussian.py b/src/easydynamics/sample_model/components/gaussian.py index 7423714f..7e89cf18 100644 --- a/src/easydynamics/sample_model/components/gaussian.py +++ b/src/easydynamics/sample_model/components/gaussian.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2025 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from __future__ import annotations @@ -203,8 +203,7 @@ def evaluate( Args: - x (Numeric or list or np.ndarray or sc.Variable or - sc.DataArray): + x (Numeric or list or np.ndarray or sc.Variable or sc.DataArray): The x values at which to evaluate the Gaussian. Returns: diff --git a/src/easydynamics/sample_model/components/lorentzian.py b/src/easydynamics/sample_model/components/lorentzian.py index b8da443e..b61b7896 100644 --- a/src/easydynamics/sample_model/components/lorentzian.py +++ b/src/easydynamics/sample_model/components/lorentzian.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2025 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from __future__ import annotations @@ -186,8 +186,7 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) the half width at half maximum (HWHM). Args: - x (Numeric or list or np.ndarray or sc.Variable or - sc.DataArray): + x (Numeric or list or np.ndarray or sc.Variable or sc.DataArray): The x values at which to evaluate the Lorentzian. Returns: diff --git a/src/easydynamics/sample_model/components/mixins.py b/src/easydynamics/sample_model/components/mixins.py index b8bb8b47..c0b14d58 100644 --- a/src/easydynamics/sample_model/components/mixins.py +++ b/src/easydynamics/sample_model/components/mixins.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2025 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import warnings diff --git a/src/easydynamics/sample_model/components/model_component.py b/src/easydynamics/sample_model/components/model_component.py index 09ed6514..d4d5bc49 100644 --- a/src/easydynamics/sample_model/components/model_component.py +++ b/src/easydynamics/sample_model/components/model_component.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2025 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from __future__ import annotations @@ -213,8 +213,8 @@ def evaluate( Must be implemented by subclasses. Args: - x (Numeric | list[Numeric] | np.ndarray | sc.Variable | - sc.DataArray): Input values. + x (Numeric | list[Numeric] | np.ndarray | sc.Variable | sc.DataArray): + Input values. Returns: np.ndarray: Evaluated function values. diff --git a/src/easydynamics/sample_model/components/polynomial.py b/src/easydynamics/sample_model/components/polynomial.py index 9210c484..4cd60902 100644 --- a/src/easydynamics/sample_model/components/polynomial.py +++ b/src/easydynamics/sample_model/components/polynomial.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2025 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from __future__ import annotations @@ -160,8 +160,7 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) where $C_i$ are the coefficients. Args: - x (Numeric | list | np.ndarray | sc.Variable | - sc.DataArray): + x (Numeric | list | np.ndarray | sc.Variable | sc.DataArray): The x values at which to evaluate the Polynomial. Returns: diff --git a/src/easydynamics/sample_model/components/voigt.py b/src/easydynamics/sample_model/components/voigt.py index 117b5652..16d5d1e0 100644 --- a/src/easydynamics/sample_model/components/voigt.py +++ b/src/easydynamics/sample_model/components/voigt.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2025 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from __future__ import annotations @@ -221,9 +221,8 @@ def evaluate(self, x: Numeric | list | np.ndarray | sc.Variable | sc.DataArray) Args: - x (Numeric | list[Numeric] | np.ndarray | sc.Variable | - sc.DataArray): The x values at which to evaluate the - Voigt. + x (Numeric | list[Numeric] | np.ndarray | sc.Variable | sc.DataArray): + The x values at which to evaluate the Voigt. Returns: np.ndarray: The intensity of the Voigt at the given x diff --git a/src/easydynamics/sample_model/diffusion_model/__init__.py b/src/easydynamics/sample_model/diffusion_model/__init__.py index dc0a469c..7291f83e 100644 --- a/src/easydynamics/sample_model/diffusion_model/__init__.py +++ b/src/easydynamics/sample_model/diffusion_model/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from .brownian_translational_diffusion import BrownianTranslationalDiffusion diff --git a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py index 2198e032..87f37f59 100644 --- a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from typing import Dict diff --git a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py index 603dced8..837f5271 100644 --- a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py +++ b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import scipp as sc 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 3fa8959d..3ee24a99 100644 --- a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + from typing import Dict from typing import List @@ -75,8 +78,7 @@ def __init__( Args: display_name (str): Display name of the diffusion model. unique_name (str | None): Unique name of the diffusion - model. If - None, a unique name will be generated. + model. If None, a unique name will be generated. unit (str | sc.Unit): Unit of the diffusion model. Must be convertible to meV. Defaults to "meV". scale (Numeric): Scale factor for the diffusion model. Must diff --git a/src/easydynamics/sample_model/instrument_model.py b/src/easydynamics/sample_model/instrument_model.py index aadd543f..89ae3888 100644 --- a/src/easydynamics/sample_model/instrument_model.py +++ b/src/easydynamics/sample_model/instrument_model.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from copy import copy diff --git a/src/easydynamics/sample_model/model_base.py b/src/easydynamics/sample_model/model_base.py index cfd2cb89..c78cef35 100644 --- a/src/easydynamics/sample_model/model_base.py +++ b/src/easydynamics/sample_model/model_base.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from copy import copy diff --git a/src/easydynamics/sample_model/resolution_model.py b/src/easydynamics/sample_model/resolution_model.py index d4d23e39..c1913a3b 100644 --- a/src/easydynamics/sample_model/resolution_model.py +++ b/src/easydynamics/sample_model/resolution_model.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import scipp as sc @@ -33,8 +33,8 @@ class ResolutionModel(ModelBase): unit (str | sc.Unit): Unit of the model. components (list[ModelComponent]): List of ModelComponents in the model. - Q (np.ndarray | Numeric | list | ArrayLike | sc.Variable - | None): Q values of the model. + Q (np.ndarray | Numeric | list | ArrayLike | sc.Variable | None): + Q values of the model. """ def __init__( diff --git a/src/easydynamics/sample_model/sample_model.py b/src/easydynamics/sample_model/sample_model.py index ef5cb9ab..a624ac58 100644 --- a/src/easydynamics/sample_model/sample_model.py +++ b/src/easydynamics/sample_model/sample_model.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import numpy as np @@ -35,8 +35,8 @@ class SampleModel(ModelBase): for each Q value. Q (Number, list, np.ndarray, sc.array | None): Q values for the model. If None, Q is not set. - diffusion_models (DiffusionModelBase | list[DiffusionModelBase] - | None): Diffusion models to include in the SampleModel. + diffusion_models (DiffusionModelBase | list[DiffusionModelBase] | None): + Diffusion models to include in the SampleModel. If None, no diffusion models are added. temperature (float | None): Temperature for detailed balancing. If None, no detailed balancing is applied. @@ -49,8 +49,8 @@ class SampleModel(ModelBase): unit (str | sc.Unit): Unit of the model. components (list[ModelComponent]): List of ModelComponents in the model. - Q (np.ndarray | Numeric | list | ArrayLike | sc.Variable - | None): Q values of the model. + Q (np.ndarray | Numeric | list | ArrayLike | sc.Variable | None): + Q values of the model. diffusion_models (list[DiffusionModelBase]): List of diffusion models in the SampleModel. temperature (Parameter | None): Temperature Parameter for @@ -85,10 +85,9 @@ def __init__( ComponentCollections for each Q value. Q (Number, list, np.ndarray, sc.array | None): Q values for the model. If None, Q is not set. - diffusion_models (DiffusionModelBase | - list[DiffusionModelBase] | None): Diffusion models to - include in the SampleModel. If None, no diffusion models - are added. + diffusion_models (DiffusionModelBase | list[DiffusionModelBase] | None): + Diffusion models to include in the SampleModel. If None, + no diffusion models are added. temperature (float | None): Temperature for detailed balancing. If None, no detailed balancing is applied. temperature_unit (str | sc.Unit): Unit of the temperature. @@ -365,8 +364,7 @@ def evaluate( """Evaluate the sample model at all Q for the given x values. Args: - x (Numeric | list | np.ndarray | sc.Variable | - sc.DataArray): + x (Numeric | list | np.ndarray | sc.Variable | sc.DataArray): The x values to evaluate the model at. Can be a number, list, numpy array, scipp Variable, or scipp DataArray. diff --git a/src/easydynamics/utils/__init__.py b/src/easydynamics/utils/__init__.py index a6c7926f..68d324ce 100644 --- a/src/easydynamics/utils/__init__.py +++ b/src/easydynamics/utils/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2025 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from .detailed_balance import _detailed_balance_factor diff --git a/src/easydynamics/utils/detailed_balance.py b/src/easydynamics/utils/detailed_balance.py index 0790412b..ab323530 100644 --- a/src/easydynamics/utils/detailed_balance.py +++ b/src/easydynamics/utils/detailed_balance.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2025 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import warnings diff --git a/src/easydynamics/utils/utils.py b/src/easydynamics/utils/utils.py index 0ac12ab6..03965c6e 100644 --- a/src/easydynamics/utils/utils.py +++ b/src/easydynamics/utils/utils.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import numpy as np diff --git a/tests/conftest.py b/tests/conftest.py index 98e27afe..4e798e20 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,2 +1,2 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause diff --git a/tests/integration/fitting/test_import.py b/tests/integration/fitting/test_import.py index d6b1a9a9..6b4fc2f7 100644 --- a/tests/integration/fitting/test_import.py +++ b/tests/integration/fitting/test_import.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import pytest diff --git a/tests/integration/scipp-analysis/test_import.py b/tests/integration/scipp-analysis/test_import.py index c9c178a8..e062efcb 100644 --- a/tests/integration/scipp-analysis/test_import.py +++ b/tests/integration/scipp-analysis/test_import.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause diff --git a/tests/unit/easydynamics/analysis/test_analysis.py b/tests/unit/easydynamics/analysis/test_analysis.py index 812bcac9..801ed58c 100644 --- a/tests/unit/easydynamics/analysis/test_analysis.py +++ b/tests/unit/easydynamics/analysis/test_analysis.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + from unittest.mock import MagicMock from unittest.mock import PropertyMock from unittest.mock import patch diff --git a/tests/unit/easydynamics/analysis/test_analysis1d.py b/tests/unit/easydynamics/analysis/test_analysis1d.py index 3ddf74b3..5a48a857 100644 --- a/tests/unit/easydynamics/analysis/test_analysis1d.py +++ b/tests/unit/easydynamics/analysis/test_analysis1d.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + from collections import Counter from unittest.mock import MagicMock from unittest.mock import patch diff --git a/tests/unit/easydynamics/analysis/test_analysis_base.py b/tests/unit/easydynamics/analysis/test_analysis_base.py index b2dabf00..e91e7612 100644 --- a/tests/unit/easydynamics/analysis/test_analysis_base.py +++ b/tests/unit/easydynamics/analysis/test_analysis_base.py @@ -1,4 +1,5 @@ -# from unittest.mock import Mock +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause from unittest.mock import PropertyMock from unittest.mock import patch diff --git a/tests/unit/easydynamics/convolution/test_analytical_convolution.py b/tests/unit/easydynamics/convolution/test_analytical_convolution.py index 6fb14532..3bc8ec09 100644 --- a/tests/unit/easydynamics/convolution/test_analytical_convolution.py +++ b/tests/unit/easydynamics/convolution/test_analytical_convolution.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from unittest.mock import patch diff --git a/tests/unit/easydynamics/convolution/test_convolution.py b/tests/unit/easydynamics/convolution/test_convolution.py index a2cfb193..9151409e 100644 --- a/tests/unit/easydynamics/convolution/test_convolution.py +++ b/tests/unit/easydynamics/convolution/test_convolution.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from contextlib import nullcontext diff --git a/tests/unit/easydynamics/convolution/test_convolution_base.py b/tests/unit/easydynamics/convolution/test_convolution_base.py index 94272f95..44ae133c 100644 --- a/tests/unit/easydynamics/convolution/test_convolution_base.py +++ b/tests/unit/easydynamics/convolution/test_convolution_base.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import numpy as np diff --git a/tests/unit/easydynamics/convolution/test_energy_grid.py b/tests/unit/easydynamics/convolution/test_energy_grid.py index fb7c0c00..01cf19d4 100644 --- a/tests/unit/easydynamics/convolution/test_energy_grid.py +++ b/tests/unit/easydynamics/convolution/test_energy_grid.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import numpy as np diff --git a/tests/unit/easydynamics/convolution/test_numerical_convolution.py b/tests/unit/easydynamics/convolution/test_numerical_convolution.py index 9201d07c..082ac096 100644 --- a/tests/unit/easydynamics/convolution/test_numerical_convolution.py +++ b/tests/unit/easydynamics/convolution/test_numerical_convolution.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import numpy as np diff --git a/tests/unit/easydynamics/convolution/test_numerical_convolution_base.py b/tests/unit/easydynamics/convolution/test_numerical_convolution_base.py index 63e16ba5..3934eefb 100644 --- a/tests/unit/easydynamics/convolution/test_numerical_convolution_base.py +++ b/tests/unit/easydynamics/convolution/test_numerical_convolution_base.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import numpy as np diff --git a/tests/unit/easydynamics/experiment/test_experiment.py b/tests/unit/easydynamics/experiment/test_experiment.py index b62e3305..0e895b97 100644 --- a/tests/unit/easydynamics/experiment/test_experiment.py +++ b/tests/unit/easydynamics/experiment/test_experiment.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + from copy import copy from unittest.mock import MagicMock from unittest.mock import patch diff --git a/tests/unit/easydynamics/sample_model/components/test_damped_harmonic_oscillator.py b/tests/unit/easydynamics/sample_model/components/test_damped_harmonic_oscillator.py index e77f521e..702c0bfc 100644 --- a/tests/unit/easydynamics/sample_model/components/test_damped_harmonic_oscillator.py +++ b/tests/unit/easydynamics/sample_model/components/test_damped_harmonic_oscillator.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from copy import copy diff --git a/tests/unit/easydynamics/sample_model/components/test_delta_function.py b/tests/unit/easydynamics/sample_model/components/test_delta_function.py index 4fabf46b..b5c4bc11 100644 --- a/tests/unit/easydynamics/sample_model/components/test_delta_function.py +++ b/tests/unit/easydynamics/sample_model/components/test_delta_function.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from copy import copy diff --git a/tests/unit/easydynamics/sample_model/components/test_gaussian.py b/tests/unit/easydynamics/sample_model/components/test_gaussian.py index 6699c6d0..b1ad6f92 100644 --- a/tests/unit/easydynamics/sample_model/components/test_gaussian.py +++ b/tests/unit/easydynamics/sample_model/components/test_gaussian.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from copy import copy diff --git a/tests/unit/easydynamics/sample_model/components/test_lorentzian.py b/tests/unit/easydynamics/sample_model/components/test_lorentzian.py index 34b26cca..5f87032d 100644 --- a/tests/unit/easydynamics/sample_model/components/test_lorentzian.py +++ b/tests/unit/easydynamics/sample_model/components/test_lorentzian.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from copy import copy diff --git a/tests/unit/easydynamics/sample_model/components/test_mixins.py b/tests/unit/easydynamics/sample_model/components/test_mixins.py index 83a2cb57..7c5160ae 100644 --- a/tests/unit/easydynamics/sample_model/components/test_mixins.py +++ b/tests/unit/easydynamics/sample_model/components/test_mixins.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import numpy as np diff --git a/tests/unit/easydynamics/sample_model/components/test_model_component.py b/tests/unit/easydynamics/sample_model/components/test_model_component.py index 0637a252..3bd91742 100644 --- a/tests/unit/easydynamics/sample_model/components/test_model_component.py +++ b/tests/unit/easydynamics/sample_model/components/test_model_component.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import numpy as np diff --git a/tests/unit/easydynamics/sample_model/components/test_polynomial.py b/tests/unit/easydynamics/sample_model/components/test_polynomial.py index db9910c1..7d5de9db 100644 --- a/tests/unit/easydynamics/sample_model/components/test_polynomial.py +++ b/tests/unit/easydynamics/sample_model/components/test_polynomial.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from copy import copy diff --git a/tests/unit/easydynamics/sample_model/components/test_voigt.py b/tests/unit/easydynamics/sample_model/components/test_voigt.py index 9094aedc..f4ea949d 100644 --- a/tests/unit/easydynamics/sample_model/components/test_voigt.py +++ b/tests/unit/easydynamics/sample_model/components/test_voigt.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from copy import copy diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py index cbe57926..80ec766f 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import numpy as np diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py index e7e726d4..67f654b2 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import pytest diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py index 744e176b..756d422f 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + import numpy as np import pytest import scipp as sc diff --git a/tests/unit/easydynamics/sample_model/test_background_model.py b/tests/unit/easydynamics/sample_model/test_background_model.py index 544af559..6e1a1315 100644 --- a/tests/unit/easydynamics/sample_model/test_background_model.py +++ b/tests/unit/easydynamics/sample_model/test_background_model.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import numpy as np diff --git a/tests/unit/easydynamics/sample_model/test_component_collection.py b/tests/unit/easydynamics/sample_model/test_component_collection.py index 115c2f2e..595cdb8e 100644 --- a/tests/unit/easydynamics/sample_model/test_component_collection.py +++ b/tests/unit/easydynamics/sample_model/test_component_collection.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from copy import copy diff --git a/tests/unit/easydynamics/sample_model/test_instrument_model.py b/tests/unit/easydynamics/sample_model/test_instrument_model.py index 54396cc2..69c46879 100644 --- a/tests/unit/easydynamics/sample_model/test_instrument_model.py +++ b/tests/unit/easydynamics/sample_model/test_instrument_model.py @@ -1,7 +1,6 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause - from unittest.mock import MagicMock from unittest.mock import patch diff --git a/tests/unit/easydynamics/sample_model/test_model_base.py b/tests/unit/easydynamics/sample_model/test_model_base.py index fbe44d73..8b39c6f3 100644 --- a/tests/unit/easydynamics/sample_model/test_model_base.py +++ b/tests/unit/easydynamics/sample_model/test_model_base.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from unittest.mock import Mock diff --git a/tests/unit/easydynamics/sample_model/test_resolution_model.py b/tests/unit/easydynamics/sample_model/test_resolution_model.py index d45eee19..2df07a53 100644 --- a/tests/unit/easydynamics/sample_model/test_resolution_model.py +++ b/tests/unit/easydynamics/sample_model/test_resolution_model.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import numpy as np diff --git a/tests/unit/easydynamics/sample_model/test_sample_model.py b/tests/unit/easydynamics/sample_model/test_sample_model.py index 16919c91..22a5f2fb 100644 --- a/tests/unit/easydynamics/sample_model/test_sample_model.py +++ b/tests/unit/easydynamics/sample_model/test_sample_model.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause from unittest.mock import Mock diff --git a/tests/unit/easydynamics/test_import.py b/tests/unit/easydynamics/test_import.py index c9c178a8..e062efcb 100644 --- a/tests/unit/easydynamics/test_import.py +++ b/tests/unit/easydynamics/test_import.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause diff --git a/tests/unit/easydynamics/utils/test_detailed_balance.py b/tests/unit/easydynamics/utils/test_detailed_balance.py index 1d2fe2c0..4dfe196d 100644 --- a/tests/unit/easydynamics/utils/test_detailed_balance.py +++ b/tests/unit/easydynamics/utils/test_detailed_balance.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import numpy as np diff --git a/tests/unit/easydynamics/utils/test_utils.py b/tests/unit/easydynamics/utils/test_utils.py index cb3eed27..e5a2e9e9 100644 --- a/tests/unit/easydynamics/utils/test_utils.py +++ b/tests/unit/easydynamics/utils/test_utils.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors +# SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause import numpy as np diff --git a/tools/add_spdx.py b/tools/add_spdx.py new file mode 100644 index 00000000..295a7e0d --- /dev/null +++ b/tools/add_spdx.py @@ -0,0 +1,149 @@ +"""Add SPDX headers to Python files. + +- SPDX-FileCopyrightText with the license holder name and organization + URL from ``pyproject.toml`` as well as the file's creation year. +- SPDX-License-Identifier is taken from the project license value in + ``pyproject.toml``. +""" + +from __future__ import annotations + +import argparse +import tomllib +from datetime import datetime +from pathlib import Path +from typing import Optional +from typing import Union + +from git import Repo +from spdx_headers.core import find_repository_root +from spdx_headers.core import get_copyright_info +from spdx_headers.data import load_license_data +from spdx_headers.operations import add_header_to_single_file + +LICENSE_DATABASE = load_license_data() + + +def load_pyproject(repo_path: Union[str, Path]) -> dict: + """Load and return parsed ``pyproject.toml`` data for the + repository. + """ + repo_root = find_repository_root(repo_path) + pyproject_path = repo_root / 'pyproject.toml' + + with open(pyproject_path, 'rb') as file_handle: + return tomllib.load(file_handle) + + +def get_file_creation_year(file_path: Union[str, Path]) -> str: + """Return the year the file was first added to Git history. + + If the year cannot be determined, fall back to the current year. + """ + file_path = Path(file_path) + + repo = Repo(file_path, search_parent_directories=True) + root = Path(repo.working_tree_dir).resolve() + rel_path = file_path.resolve().relative_to(root) + + rel_path_git = rel_path.as_posix() # IMPORTANT for git pathspec + + # Get the year when the file was first added to Git history. + # NOTE: Do not combine `--reverse` with `--max-count=1` here, as it can + # yield an empty result with some Git versions. Instead, get the full + # filtered output and take the first line. + log_output = repo.git.log( + '--follow', + '--diff-filter=A', + '--reverse', + '--format=%ad', + '--date=format:%Y', + '--', + rel_path_git, + ).strip() + + year = log_output.splitlines()[0].strip() if log_output else '' + + return year or str(datetime.now().year) + + +def get_org_url(repo_path: Union[str, Path]) -> str: + """Return the organization URL derived from the repository source + URL. + """ + pyproject_data = load_pyproject(repo_path) + repo_url = pyproject_data['project']['urls']['Source Code'] + return repo_url.rsplit('/', 1)[0] + + +def get_project_license(repo_path: Union[str, Path]) -> str: + """Return the project license value from ``pyproject.toml``.""" + pyproject_data = load_pyproject(repo_path) + return pyproject_data['project']['license'] + + +def get_copyright_holder(repo_path: Union[str, Path]) -> str: + """Return the repository copyright holder name.""" + _, name, _ = get_copyright_info(repo_path) + return name + + +def add_spdx_header( + target_file: Union[str, Path], + *, + license_key: str, + copyright_holder: str, + org_url: str, +) -> None: + """Add SPDX headers.""" + year = get_file_creation_year(target_file) + + add_header_to_single_file( + filepath=target_file, + license_key=license_key, + license_data=LICENSE_DATABASE, + year=year, + name=copyright_holder, + email=org_url, + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description='Add SPDX headers to Python files under the given paths.', + ) + parser.add_argument( + 'paths', + nargs='+', + help='Relative paths to scan (e.g. src tests)', + ) + return parser + + +def main(argv: Optional[list[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + repo_path = Path('.').resolve() + license_key = get_project_license(repo_path) + copyright_holder = get_copyright_holder(repo_path) + org_url = get_org_url(repo_path) + + for base_dir in args.paths: + base_path = Path(base_dir) + if not base_path.exists(): + parser.error(f'Path does not exist: {base_dir}') + + for py_file in base_path.rglob('*.py'): + add_spdx_header( + py_file, + license_key=license_key, + copyright_holder=copyright_holder, + org_url=org_url, + ) + + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/check_spdx.py b/tools/check_spdx.py new file mode 100644 index 00000000..80555ab1 --- /dev/null +++ b/tools/check_spdx.py @@ -0,0 +1,43 @@ +"""Check SPDX headers in Python files.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Optional + +from spdx_headers.operations import check_headers + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description='Check SPDX headers in Python files under the given paths.', + ) + parser.add_argument( + 'paths', + nargs='+', + help='Relative paths to scan (e.g. src tests)', + ) + return parser + + +def main(argv: Optional[list[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + exit_codes = [] + + for base_dir in args.paths: + base_path = Path(base_dir) + if not base_path.exists(): + parser.error(f'Path does not exist: {base_dir}') + + print('=' * 50) + print(f'Checking SPDX headers in: {base_dir}') + exit_codes.append(check_headers(base_dir)) + + return 0 if all(code == 0 for code in exit_codes) else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/remove_spdx.py b/tools/remove_spdx.py new file mode 100644 index 00000000..50e547a7 --- /dev/null +++ b/tools/remove_spdx.py @@ -0,0 +1,39 @@ +"""Remove SPDX headers from Python files.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Optional + +from spdx_headers.operations import remove_header_from_py_files + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description='Remove SPDX headers from Python files under the given paths.', + ) + parser.add_argument( + 'paths', + nargs='+', + help='Relative paths to scan (e.g. src tests)', + ) + return parser + + +def main(argv: Optional[list[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + for base_dir in args.paths: + base_path = Path(base_dir) + if not base_path.exists(): + parser.error(f'Path does not exist: {base_dir}') + + remove_header_from_py_files(base_dir) + + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/update_github_labels.py b/tools/update_github_labels.py index a18043d0..8ec8c2d7 100644 --- a/tools/update_github_labels.py +++ b/tools/update_github_labels.py @@ -1,5 +1,4 @@ -""" -Set/update GitHub labels for current or specified easyscience +"""Set/update GitHub labels for current or specified easyscience repository. Requires: @@ -21,187 +20,290 @@ import subprocess import sys from dataclasses import dataclass -from typing import Iterable - EASYSCIENCE_ORG = 'easyscience' -# --- Label definitions ----------------------------------------------------------- +# Data structures -BASIC_GITHUB_LABELS = [ - 'bug', - 'documentation', - 'duplicate', - 'enhancement', - 'good first issue', - 'help wanted', - 'invalid', - 'question', - 'wontfix', -] -NEW_BASIC_LABEL_NAMES = [ - '[scope] bug', - '[scope] documentation', - '[maintainer] duplicate', - '[scope] enhancement', - '[maintainer] good first issue', - '[maintainer] help wanted', - '[maintainer] invalid', - '[maintainer] question', - '[maintainer] wontfix', -] +@dataclass(frozen=True) +class Label: + """A GitHub label with name, color, and description.""" -SCOPE_LABELS = [ - ('bug', 'Bug report or fix (major.minor.PATCH)'), - ('documentation', 'Documentation only changes (major.minor.patch.POST)'), - ('enhancement', 'Adds/improves features (major.MINOR.patch)'), - ('maintenance', 'Code/tooling cleanup, no feature or bugfix (major.minor.PATCH)'), - ('significant', 'Breaking or major changes (MAJOR.minor.patch)'), - ('⚠️ label needed', 'Automatically added to issues and PRs without a [scope] label'), -] + name: str + color: str + description: str = '' -MAINTAINER_LABELS = [ - ('duplicate', 'Already reported or submitted'), - ('good first issue', 'Good entry-level issue for newcomers'), - ('help wanted', 'Needs additional help to resolve or implement'), - ('invalid', 'Invalid, incorrect or outdated'), - ('question', 'Needs clarification, discussion, or more information'), - ('wontfix', 'Will not be fixed or continued'), -] -PRIORITY_LABELS = [ - ('lowest', 'Very low urgency'), - ('low', 'Low importance'), - ('medium', 'Normal/default priority'), - ('high', 'Should be prioritized soon'), - ('highest', 'Urgent. Needs attention ASAP'), - ('⚠️ label needed', 'Automatically added to issues without a [priority] label'), +@dataclass(frozen=True) +class LabelRename: + """Mapping from old label name to new label name.""" + + old: str + new: str + + +class Colors: + """Hex color codes for label groups.""" + + SCOPE = 'd73a4a' + MAINTAINER = '0e8a16' + PRIORITY = 'fbca04' + BOT = '5319e7' + + +LABEL_RENAMES = [ + # Default GitHub labels to rename (if they exist) + LabelRename('bug', '[scope] bug'), + LabelRename('documentation', '[scope] documentation'), + LabelRename('duplicate', '[maintainer] duplicate'), + LabelRename('enhancement', '[scope] enhancement'), + LabelRename('good first issue', '[maintainer] good first issue'), + LabelRename('help wanted', '[maintainer] help wanted'), + LabelRename('invalid', '[maintainer] invalid'), + LabelRename('question', '[maintainer] question'), + LabelRename('wontfix', '[maintainer] wontfix'), + # Custom label renames (if they exist) + LabelRename('[bot] pull request', '[bot] release'), ] -BOT_LABEL = ( - '[bot] pull request', - 'Automated release PR. Excluded from changelog/versioning', -) - -COLORS = { - 'scope': 'd73a4a', - 'maintainer': '0e8a16', - 'priority': 'fbca04', - 'bot': '5319e7', -} +LABELS = [ + # Scope labels + Label( + '[scope] bug', + Colors.SCOPE, + 'Bug report or fix (major.minor.PATCH)', + ), + Label( + '[scope] documentation', + Colors.SCOPE, + 'Documentation only changes (major.minor.patch.POST)', + ), + Label( + '[scope] enhancement', + Colors.SCOPE, + 'Adds/improves features (major.MINOR.patch)', + ), + Label( + '[scope] maintenance', + Colors.SCOPE, + 'Code/tooling cleanup, no feature or bugfix (major.minor.PATCH)', + ), + Label( + '[scope] significant', + Colors.SCOPE, + 'Breaking or major changes (MAJOR.minor.patch)', + ), + Label( + '[scope] ⚠️ label needed', + Colors.SCOPE, + 'Automatically added to issues and PRs without a [scope] label', + ), + # Maintainer labels + Label( + '[maintainer] duplicate', + Colors.MAINTAINER, + 'Already reported or submitted', + ), + Label( + '[maintainer] good first issue', + Colors.MAINTAINER, + 'Good entry-level issue for newcomers', + ), + Label( + '[maintainer] help wanted', + Colors.MAINTAINER, + 'Needs additional help to resolve or implement', + ), + Label( + '[maintainer] invalid', + Colors.MAINTAINER, + 'Invalid, incorrect or outdated', + ), + Label( + '[maintainer] question', + Colors.MAINTAINER, + 'Needs clarification, discussion, or more information', + ), + Label( + '[maintainer] wontfix', + Colors.MAINTAINER, + 'Will not be fixed or continued', + ), + # Priority labels + Label( + '[priority] lowest', + Colors.PRIORITY, + 'Very low urgency', + ), + Label( + '[priority] low', + Colors.PRIORITY, + 'Low importance', + ), + Label( + '[priority] medium', + Colors.PRIORITY, + 'Normal/default priority', + ), + Label( + '[priority] high', + Colors.PRIORITY, + 'Should be prioritized soon', + ), + Label( + '[priority] highest', + Colors.PRIORITY, + 'Urgent. Needs attention ASAP', + ), + Label( + '[priority] ⚠️ label needed', + Colors.PRIORITY, + 'Automatically added to issues without a [priority] label', + ), + # Bot label + Label( + '[bot] release', + Colors.BOT, + 'Automated release PR. Excluded from changelog/versioning', + ), + Label( + '[bot] backmerge', + Colors.BOT, + 'Automated backmerge master → develop failed due to conflicts', + ), +] -# --- Helpers -------------------------------------------------------------------- +# Helpers @dataclass(frozen=True) class CmdResult: + """Result of a shell command execution.""" + returncode: int stdout: str stderr: str -def run_cmd(args: list[str], *, dry_run: bool, check: bool = True) -> CmdResult: +def run_cmd( + args: list[str], + *, + dry_run: bool, + check: bool = True, +) -> CmdResult: """Run a command (or print it in dry-run mode).""" cmd_str = ' '.join(shlex.quote(a) for a in args) if dry_run: - print(f'{cmd_str}') + print(f' [dry-run] {cmd_str}') return CmdResult(0, '', '') proc = subprocess.run( - args, + args=args, text=True, capture_output=True, ) - res = CmdResult(proc.returncode, proc.stdout.strip(), proc.stderr.strip()) + result = CmdResult( + proc.returncode, + proc.stdout.strip(), + proc.stderr.strip(), + ) if check and proc.returncode != 0: - raise RuntimeError(f'Command failed ({proc.returncode}): {cmd_str}\n{res.stderr}') + raise RuntimeError(f'Command failed ({proc.returncode}): {cmd_str}\n{result.stderr}') - return res + return result -def get_current_repo_name_with_owner() -> str: - res = subprocess.run( - ['gh', 'repo', 'view', '--json', 'nameWithOwner'], +def get_current_repo() -> str: + """Get the current repository name in 'owner/repo' format.""" + result = subprocess.run( + args=[ + 'gh', + 'repo', + 'view', + '--json', + 'nameWithOwner', + ], text=True, capture_output=True, check=True, ) - data = json.loads(res.stdout) - nwo = data.get('nameWithOwner') - if not nwo or '/' not in nwo: + data = json.loads(result.stdout) + name_with_owner = data.get('nameWithOwner', '') + + if '/' not in name_with_owner: raise RuntimeError('Could not determine current repository name') - return nwo + return name_with_owner -def try_rename_label(repo: str, old: str, new: str, *, dry_run: bool) -> None: - try: - run_cmd( - ['gh', 'label', 'edit', old, '--name', new, '--repo', repo], - dry_run=dry_run, - ) - print(f'Rename: {old!r} → {new!r}') - except Exception: - print(f'Skip rename (label not found): {old!r}') + +def rename_label( + repo: str, + rename: LabelRename, + *, + dry_run: bool, +) -> None: + """Rename a label, silently skipping if it doesn't exist.""" + result = run_cmd( + args=[ + 'gh', + 'label', + 'edit', + rename.old, + '--name', + rename.new, + '--repo', + repo, + ], + dry_run=dry_run, + check=False, + ) + + if dry_run or result.returncode == 0: + print(f' Rename: {rename.old!r} → {rename.new!r}') + else: + print(f' Skip (not found): {rename.old!r}') def upsert_label( repo: str, - name: str, - color: str, - description: str, + label: Label, *, dry_run: bool, ) -> None: + """Create or update a label.""" run_cmd( [ 'gh', 'label', 'create', - name, + label.name, '--color', - color, + label.color, '--description', - description, + label.description, '--force', '--repo', repo, ], dry_run=dry_run, ) - print(f'Upsert label: {name!r}') + print(f' Upsert: {label.name!r}') -def upsert_group( - repo: str, - prefix: str, - color: str, - items: Iterable[tuple[str, str]], - *, - dry_run: bool, -) -> None: - for short, desc in items: - upsert_label( - repo, - f'[{prefix}] {short}', - color, - desc, - dry_run=dry_run, - ) - - -# --- Main ----------------------------------------------------------------------- +# Main def main() -> int: + """Entry point: parse arguments and sync labels.""" parser = argparse.ArgumentParser(description='Sync GitHub labels for easyscience repos') parser.add_argument( '--repo', - help='Target repository in the form easyscience/', + help='Target repository (owner/name)', ) parser.add_argument( '--dry-run', @@ -210,41 +312,24 @@ def main() -> int: ) args = parser.parse_args() - if args.repo: - repo = args.repo - else: - repo = get_current_repo_name_with_owner() - - org, _ = repo.split('/', 1) + repo = args.repo or get_current_repo() + org = repo.split('/')[0] if org.lower() != EASYSCIENCE_ORG: - print( - f"Refusing to run: repository {repo!r} is not under '{EASYSCIENCE_ORG}'.", - file=sys.stderr, - ) + print(f"Error: repository '{repo}' is not under '{EASYSCIENCE_ORG}'", file=sys.stderr) return 2 - print(f'Target repository: {repo}') + print(f'Repository: {repo}') if args.dry_run: - print('Running in DRY-RUN mode (no changes will be made)\n') - - # 1) Rename basic labels - for old, new in zip(BASIC_GITHUB_LABELS, NEW_BASIC_LABEL_NAMES, strict=True): - try_rename_label(repo, old, new, dry_run=args.dry_run) - - # 2) Scope / Maintainer / Priority groups - upsert_group(repo, 'scope', COLORS['scope'], SCOPE_LABELS, dry_run=args.dry_run) - upsert_group(repo, 'maintainer', COLORS['maintainer'], MAINTAINER_LABELS, dry_run=args.dry_run) - upsert_group(repo, 'priority', COLORS['priority'], PRIORITY_LABELS, dry_run=args.dry_run) - - # 3) Bot label - upsert_label( - repo, - BOT_LABEL[0], - COLORS['bot'], - BOT_LABEL[1], - dry_run=args.dry_run, - ) + print('Mode: DRY-RUN (no changes will be made)\n') + + print('\nRenaming default labels...') + for rename in LABEL_RENAMES: + rename_label(repo, rename, dry_run=args.dry_run) + + print('\nUpserting labels...') + for label in LABELS: + upsert_label(repo, label, dry_run=args.dry_run) print('\nDone.') return 0 diff --git a/tools/update_spdx.py b/tools/update_spdx.py deleted file mode 100644 index ec3bc446..00000000 --- a/tools/update_spdx.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Update or insert SPDX headers in Python files. - -- Ensures SPDX-FileCopyrightText has the current year. -- Ensures SPDX-License-Identifier is set to BSD-3-Clause. -""" - -import fnmatch -import re -from pathlib import Path - -COPYRIGHT_TEXT = '# SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors ' -LICENSE_TEXT = '# SPDX-License-Identifier: BSD-3-Clause' - -# Patterns to exclude from SPDX header updates (vendored code) -EXCLUDE_PATTERNS = [ - '*/_vendored/jupyter_dark_detect/*', -] - - -def should_exclude(file_path: Path) -> bool: - """Check if a file should be excluded from SPDX header updates.""" - path_str = str(file_path) - return any(fnmatch.fnmatch(path_str, pattern) for pattern in EXCLUDE_PATTERNS) - - -def update_spdx_header(file_path: Path): - # Use Path.open to satisfy lint rule PTH123. - with file_path.open('r', encoding='utf-8') as f: - original_lines = f.readlines() - - # Regexes for SPDX lines - copy_re = re.compile(r'^#\s*SPDX-FileCopyrightText:.*$') - lic_re = re.compile(r'^#\s*SPDX-License-Identifier:.*$') - - # 1) Preserve any leading shebang / coding cookie lines - prefix = [] - body_start = 0 - if original_lines: - # Shebang line like "#!/usr/bin/env python3" - if original_lines[0].startswith('#!'): - prefix.append(original_lines[0]) - body_start = 1 - # PEP 263 coding cookie on first or second line - # e.g. "# -*- coding: utf-8 -*-" or "# coding: utf-8" - for _ in range(2): # at most one more line to inspect - if body_start < len(original_lines): - line = original_lines[body_start] - if re.match(r'^#.*coding[:=]\s*[-\w.]+', line): - prefix.append(line) - body_start += 1 - else: - break - - # 2) Work on the remaining body - body = original_lines[body_start:] - - # Remove any existing SPDX lines anywhere in the body - body = [ln for ln in body if not (copy_re.match(ln) or lic_re.match(ln))] - - # Strip leading blank lines in the body so header is tight - while body and not body[0].strip(): - body.pop(0) - - # 3) Build canonical SPDX block: two lines + exactly one blank - spdx_block = [ - COPYRIGHT_TEXT + '\n', - LICENSE_TEXT + '\n', - '\n', - ] - - # 4) New content: prefix + SPDX + body - new_lines = prefix + spdx_block + body - - # 5) Normalize: collapse any extra blank lines immediately after - # LICENSE to exactly one. This keeps the script idempotent. - # Find the index of LICENSE we just inserted (prefix may be 0, 1, - # or 2 lines) - lic_idx = len(prefix) + 1 # spdx_block[1] is the license line - # Ensure exactly one blank line after LICENSE - # Remove all blank lines after lic_idx, then insert a single blank. - j = lic_idx + 1 - # Remove any number of blank lines following - while j < len(new_lines) and not new_lines[j].strip(): - new_lines.pop(j) - # Insert exactly one blank line at this position - new_lines.insert(j, '\n') - - with file_path.open('w', encoding='utf-8') as f: - f.writelines(new_lines) - - -def main(): - """Recursively update or insert SPDX headers in all Python files - under the 'src' and 'tests' directories. - """ - for base_dir in ('src', 'tests'): - for py_file in Path(base_dir).rglob('*.py'): - if should_exclude(py_file): - continue - update_spdx_header(py_file) - - -if __name__ == '__main__': - main()