From 5938a4f5ef94ca021e7d0212632f9170d8535191 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Sat, 30 May 2026 15:46:33 +0200 Subject: [PATCH 01/10] implement plot_residuals setting --- docs/docs/tutorials/analysis1d.ipynb | 8 +-- docs/docs/tutorials/tutorial0_basics.ipynb | 4 +- src/easydynamics/analysis/analysis.py | 51 ++++++++++++++++++ src/easydynamics/analysis/analysis1d.py | 61 +++++++++++++++++++++- 4 files changed, 117 insertions(+), 7 deletions(-) diff --git a/docs/docs/tutorials/analysis1d.ipynb b/docs/docs/tutorials/analysis1d.ipynb index 326e4522..8584f314 100644 --- a/docs/docs/tutorials/analysis1d.ipynb +++ b/docs/docs/tutorials/analysis1d.ipynb @@ -81,15 +81,15 @@ ")\n", "\n", "fit_result = my_analysis.fit()\n", - "my_analysis.plot_data_and_model()" + "my_analysis.plot_data_and_model(plot_residuals=True, residuals_yoffset=-0.1)" ] } ], "metadata": { "kernelspec": { - "display_name": "Python (Pixi)", + "display_name": "Python 3", "language": "python", - "name": "pixi-kernel-python3" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -101,7 +101,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.12" + "version": "3.14.5" } }, "nbformat": 4, diff --git a/docs/docs/tutorials/tutorial0_basics.ipynb b/docs/docs/tutorials/tutorial0_basics.ipynb index ff273e7d..8e3142da 100644 --- a/docs/docs/tutorials/tutorial0_basics.ipynb +++ b/docs/docs/tutorials/tutorial0_basics.ipynb @@ -293,7 +293,7 @@ "\n", "\n", "\n", - "Since the fit looked good, we can now fit all $Q$. We also plot the result, again using the slicer." + "Since the fit looked good, we can now fit all $Q$. We also plot the result, again using the slicer. We can plot the residuals (data - model) and offset them on the y axis to avoid overlap between them and the data." ] }, { @@ -304,7 +304,7 @@ "outputs": [], "source": [ "fit_result_all_Q = analysis.fit()\n", - "analysis.plot_data_and_model()" + "analysis.plot_data_and_model(plot_residuals=True, residuals_yoffset=-2.0)" ] }, { diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index 56b322c7..5a18622c 100644 --- a/src/easydynamics/analysis/analysis.py +++ b/src/easydynamics/analysis/analysis.py @@ -213,6 +213,8 @@ def plot_data_and_model( Q_index: int | None = None, plot_components: bool = True, add_background: bool = True, + plot_residuals: bool = False, + residuals_yoffset: float = 0.0, energy: sc.Variable | None = None, **kwargs: dict[str, Any], ) -> InteractiveFigure: @@ -232,6 +234,11 @@ def plot_data_and_model( add_background : bool, default=True Whether to add background components to the sample model components when plotting. Default is True. + plot_residuals : bool, default=False + Whether to plot the residuals (data - model). Default is False. + residuals_yoffset : float, default=0.0 + Vertical offset to apply to the residuals when plotting, to avoid overlap with the data + and model. Only used if plot_residuals is True. Default is 0.0. energy : sc.Variable | None, default=None The energy values to use for calculating the model. If None, uses the energy from the experiment. @@ -280,6 +287,12 @@ def plot_data_and_model( if not isinstance(add_background, bool): raise TypeError('add_background must be True or False.') + if not isinstance(plot_residuals, bool): + raise TypeError('plot_residuals must be True or False.') + + if not isinstance(residuals_yoffset, (int, float)): + raise TypeError('residuals_yoffset must be a number.') + if energy is None: energy = self.energy @@ -289,6 +302,8 @@ def plot_data_and_model( energy=energy, add_background=add_background, include_components=plot_components, + include_residuals=plot_residuals, + residuals_yoffset=residuals_yoffset, ) plot_kwargs_defaults = { @@ -313,6 +328,12 @@ def plot_data_and_model( plot_kwargs_defaults['color'][key] = 'red' plot_kwargs_defaults['markerfacecolor'][key] = 'none' + elif key == 'Residuals': + plot_kwargs_defaults['linestyle'][key] = 'none' + plot_kwargs_defaults['marker'][key] = 'o' + plot_kwargs_defaults['color'][key] = 'blue' + plot_kwargs_defaults['markerfacecolor'][key] = 'none' + else: plot_kwargs_defaults['linestyle'][key] = '--' plot_kwargs_defaults['marker'][key] = None @@ -334,6 +355,8 @@ def data_and_model_to_datagroup( energy: sc.Variable | None = None, add_background: bool = True, include_components: bool = True, + include_residuals: bool = False, + residuals_yoffset: float = 0.0, ) -> sc.DataGroup: """ Create a scipp DataGroup containing the experimental data, model calculation and optionally @@ -350,6 +373,11 @@ def data_and_model_to_datagroup( include_components : bool, default=True Whether to include the individual components of the model in the DataGroup. If False, only the total model will be included. + include_residuals : bool, default=False + Whether to include the residuals (data - model) in the DataGroup. + residuals_yoffset : float, default=0.0 + Vertical offset to apply to the residuals when plotting, to avoid overlap with the data + and model. Only used if include_residuals is True. Default is 0.0. Raises ------ @@ -380,6 +408,12 @@ def data_and_model_to_datagroup( if not isinstance(include_components, bool): raise TypeError('include_components must be True or False.') + if not isinstance(include_residuals, bool): + raise TypeError('include_residuals must be True or False.') + + if not isinstance(residuals_yoffset, (int, float)): + raise TypeError('residuals_yoffset must be a number.') + energy = self._verify_energy(energy) if energy is None: @@ -397,6 +431,10 @@ def data_and_model_to_datagroup( for key in components: data_and_model[key] = components[key] + if include_residuals: + data_and_model['Residuals'] = self._create_residuals_array() + data_and_model['Residuals'] += residuals_yoffset + return sc.DataGroup(data_and_model) def parameters_to_dataset(self) -> sc.Dataset: @@ -736,6 +774,19 @@ def _create_model_array(self, energy: sc.Variable | None = None) -> sc.DataArray coords={'Q': self.Q, 'energy': energy}, ) + def _create_residuals_array(self) -> sc.DataArray: + """ + Create a scipp array for the residuals (data - model). + + Returns + ------- + sc.DataArray + A DataArray containing the residuals, with dimensions "Q" and "energy". + """ + data = self.experiment.binned_data + model = self._create_model_array() + return data.copy(deep=True) - model + def _create_components_dataset( self, add_background: bool = True, diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index b3f0ad85..738ae01e 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -47,7 +47,7 @@ def __init__( Parameters ---------- - display_name : str | None, default='MyAnalysis' + display_name : str | None, default="MyAnalysis" Display name of the analysis. unique_name : str | None, default=None Unique name of the analysis. If None, a unique name is automatically generated. @@ -269,6 +269,8 @@ def plot_data_and_model( self, plot_components: bool = True, add_background: bool = True, + plot_residuals: bool = False, + residuals_yoffset: float = 0.0, energy: sc.Variable | None = None, **kwargs: dict[str, Any], ) -> InteractiveFigure: @@ -285,6 +287,11 @@ def plot_data_and_model( add_background : bool, default=True Whether to add the background to the model prediction when plotting individual components. + plot_residuals : bool, default=False + Whether to plot the residuals (data - model). + residuals_yoffset : float, default=0.0 + A y-offset to apply to the residuals when plotting, to avoid overlap with the data and + model. Only used if plot_residuals is True. energy : sc.Variable | None, default=None Optional energy grid to use for plotting. If None, the energy grid from the experiment is used. @@ -302,6 +309,8 @@ def plot_data_and_model( energy=energy, add_background=add_background, include_components=plot_components, + include_residuals=plot_residuals, + residuals_yoffset=residuals_yoffset, ) plot_kwargs_defaults = { @@ -325,6 +334,12 @@ def plot_data_and_model( plot_kwargs_defaults['color'][key] = 'red' plot_kwargs_defaults['markerfacecolor'][key] = 'none' + elif key == 'Residuals': + plot_kwargs_defaults['linestyle'][key] = 'none' + plot_kwargs_defaults['marker'][key] = 'o' + plot_kwargs_defaults['color'][key] = 'blue' + plot_kwargs_defaults['markerfacecolor'][key] = 'none' + else: plot_kwargs_defaults['linestyle'][key] = '--' plot_kwargs_defaults['marker'][key] = None @@ -342,6 +357,8 @@ def data_and_model_to_datagroup( energy: sc.Variable | None = None, add_background: bool = True, include_components: bool = True, + include_residuals: bool = False, + residuals_yoffset: float = 0.0, ) -> sc.DataGroup: """ Create a scipp DataGroup containing the experimental data, model calculation, and @@ -359,6 +376,12 @@ def data_and_model_to_datagroup( Whether to include the individual components of the model in the DataGroup. If True, the DataGroup will include a DataArray for each component with the component's display name as the key + include_residuals : bool, default=False + Whether to include the residuals (data - model) in the DataGroup. If True, the + DataGroup will include a DataArray with key 'Residuals' containing the residuals. + residuals_yoffset : float, default=0.0 + A y-offset to apply to the residuals when plotting, to avoid overlap with the data and + model. Only used if include_residuals is True. Raises ------ @@ -390,6 +413,9 @@ def data_and_model_to_datagroup( if not isinstance(include_components, bool): raise TypeError('include_components must be True or False.') + if not isinstance(include_residuals, bool): + raise TypeError('include_residuals must be True or False.') + if self.Q_index is None: raise ValueError('Q_index must be set to create DataGroup.') @@ -412,6 +438,11 @@ def data_and_model_to_datagroup( for key in components: data_and_model[key] = components[key] + if include_residuals: + residuals = self._create_residuals_array() + residuals += residuals_yoffset + data_and_model['Residuals'] = residuals + return sc.DataGroup(data_and_model) def fix_energy_offset(self) -> None: @@ -844,6 +875,34 @@ def _create_model_array(self, energy: sc.Variable | None = None) -> sc.DataArray values = self.calculate(energy=energy) return self._to_scipp_array(values=values, energy=energy) + def _create_residuals_array(self) -> sc.DataArray: + """ + Create a scipp DataArray for the residuals (data - model). + + Returns + ------- + sc.DataArray + The residuals (data - model). + + Raises + ------ + ValueError + If no data is available in the experiment to calculate residuals. If Q_index is not set + to calculate residuals. + """ + if self.experiment.binned_data is None: + raise ValueError('No data to calculate residuals. Please load data first.') + + if self.Q_index is None: + raise ValueError('Q_index must be set to calculate residuals.') + + data = self.experiment.binned_data['Q', self.Q_index] + model = self.calculate() + residuals = data.copy(deep=True) + residuals.values -= model + return residuals + # return self._to_scipp_array(values=residuals) + def _create_components_dataset_single_Q( self, add_background: bool = True, From 5bf24869da713dc7fe70d956e6718e2fc3030e79 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Mon, 1 Jun 2026 10:48:05 +0200 Subject: [PATCH 02/10] add a notebook for testing residual plots --- docs/docs/tutorials/test_residual_plot.ipynb | 355 ++++++++++++++++++ .../sample_model/components/mixins.py | 34 +- 2 files changed, 372 insertions(+), 17 deletions(-) create mode 100644 docs/docs/tutorials/test_residual_plot.ipynb diff --git a/docs/docs/tutorials/test_residual_plot.ipynb b/docs/docs/tutorials/test_residual_plot.ipynb new file mode 100644 index 00000000..f8d5fb68 --- /dev/null +++ b/docs/docs/tutorials/test_residual_plot.ipynb @@ -0,0 +1,355 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "8643b10c", + "metadata": {}, + "source": [ + "# Brownian Diffusion\n", + "We here show how to set up an Analysis object and use it to first fit an artificial vanadium measurement to obtain the resolution. 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 and fit them to a diffusion model. Finally, we show how to fit all the data simultaneously to the diffusion model." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "bca91d3c", + "metadata": {}, + "outputs": [], + "source": [ + "# Imports\n", + "import pooch\n", + "\n", + "import easydynamics as edyn\n", + "import easydynamics.sample_model as sm\n", + "\n", + "# Make the plots interactive\n", + "%matplotlib widget" + ] + }, + { + "cell_type": "markdown", + "id": "4c8e97b7", + "metadata": {}, + "source": [ + "We first create an `Experiment` object to contain the data. The data must either be a hdf5 file or a scipp.DataArray; in both cases it must have coordinates `Q` and `energy`. We here use Pooch to download an example vanadium data set.\n", + "\n", + "The data can be rebinned if needed, but we will show how to do that in a different tutorial." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "8deca9b6", + "metadata": {}, + "outputs": [], + "source": [ + "# Load the vanadium data\n", + "vanadium_experiment = edyn.Experiment('Vanadium')\n", + "\n", + "file_path = pooch.retrieve(\n", + " url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/master/docs/docs/tutorials/data/vanadium_data_example.h5',\n", + " known_hash='16cc1b327c303feeb88fb9dda5390dc4880b62396b1793f98c6fef0b27c7b873',\n", + ")\n", + "\n", + "\n", + "vanadium_experiment.load_hdf5(filename=file_path)" + ] + }, + { + "cell_type": "markdown", + "id": "7daa3f64", + "metadata": {}, + "source": [ + "We can visualize the data in multiple ways, relying on plopp: https://scipp.github.io/plopp/\n", + "\n", + "We here show two ways to look at the data: as a 2d colormap with intensity as function of `Q` and `energy`, and as a slicer with intensity as function of `energy` for various `Q`.\n", + "\n", + "If you want $Q$ on the x axis, then set `transpose_axes=True`" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "fbd31297", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "dbdbba074be949a99d9b601c0d94947b", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "InteractiveFigure(children=(HBar(), HBar(children=(VBar(children=(Toolbar(children=(ButtonTool(icon='home', la…" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "vanadium_experiment.plot_data(slicer=False, transpose_axes=False)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "4153ba52", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "16799ce2bcf64d1f8439735d4111ee28", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "InteractiveFigure(children=(HBar(), HBar(children=(VBar(children=(Toolbar(children=(ButtonTool(icon='home', la…" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "vanadium_experiment.plot_data(slicer=True)" + ] + }, + { + "cell_type": "markdown", + "id": "6c87b01c", + "metadata": {}, + "source": [ + "We now want to fit the vanadium data to determine our resolution. The scattering from vanadium is almost exclusively incoherent elastic, so we model it as a delta function. We do this by creating a `SampleModel` and adding a `DeltaFunction` component to it. The component acts as a template and gets copied to every `Q` when we attach the `SampleModel` to our `Analysis` object. Let's create the `SampleModel`.\n", + "\n", + "We do not give the `DeltaFunction` a `center` value. In this case, the center will be fixed at 0 energy transfer. We set the start value of the area to 1." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "6762faba", + "metadata": {}, + "outputs": [], + "source": [ + "delta_function = sm.DeltaFunction(name='DeltaFunction', area=1)\n", + "sample_model = sm.SampleModel(components=delta_function)" + ] + }, + { + "cell_type": "markdown", + "id": "dc82774e", + "metadata": {}, + "source": [ + "We now want to define our resolution function. We will here model it as a Gaussian. We create a `ComponentCollection` and append the `Gaussian` to it. We can add as many components to our resolution as we like; sometimes you need several Gaussians and other functions to accurately describe the resolution.\n", + "\n", + "We fix the area of the resolution to have value 1. If we did not do this, we would fit both the area of the delta function and of the resolution Gaussian, and the fit would never converge.\n", + "\n", + "We finally insert the components in a `ResolutionModel`" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "8fdb7f19", + "metadata": {}, + "outputs": [], + "source": [ + "resolution_components = sm.ComponentCollection()\n", + "res_gauss = sm.Gaussian(width=0.1, area=1, name='Res. Gauss')\n", + "res_gauss.area.fixed = True\n", + "resolution_components.append_component(res_gauss)\n", + "resolution_model = sm.ResolutionModel(components=resolution_components)" + ] + }, + { + "cell_type": "markdown", + "id": "088ac17d", + "metadata": {}, + "source": [ + "The background intensity was not 0, so we also create a background model. We use a `Polynomial` with a single coefficient, i.e. a flat background. We here show how to create the `BackgroundModel` and add the background in a single line. We could of course also add it like we did for the `SampleModel` or first create a `ComponentCollection` like we did for the `ResolutionModel`" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "1ec6836f", + "metadata": {}, + "outputs": [], + "source": [ + "background_model = sm.BackgroundModel(components=sm.Polynomial(coefficients=[0.001]))" + ] + }, + { + "cell_type": "markdown", + "id": "eae3d14b", + "metadata": {}, + "source": [ + "We combine the resolution abd background model into an `InstrumentModel`. This model also contains a fittable energy offset to account for instrument misalignment. All components are centered at this energy offset." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "0ad79a75", + "metadata": {}, + "outputs": [], + "source": [ + "instrument_model = sm.InstrumentModel(\n", + " resolution_model=resolution_model,\n", + " background_model=background_model,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "a99be7c1", + "metadata": {}, + "source": [ + "We are now ready to collect everything in an analysis object. We give it a display name, the experiment, the sample model and the instrument model. It will then automatically generate a model for each `Q` using the templates given in the `SampleModel`, `ResolutionModel` and `BackgroundModel`." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "b98d63dc", + "metadata": {}, + "outputs": [], + "source": [ + "vanadium_analysis = edyn.Analysis(\n", + " display_name='Vanadium Full Analysis',\n", + " experiment=vanadium_experiment,\n", + " sample_model=sample_model,\n", + " instrument_model=instrument_model,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "a81248a4", + "metadata": {}, + "source": [ + "Let us first fit a single Q index and plot the data and model to see how it looks. For this, we use the `independent` fit method and choose an arbitrary Q index" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "aec75b7f", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "507964703fb947379fd18a7b4b7c61ca", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "InteractiveFigure(children=(HBar(), HBar(children=(VBar(children=(Toolbar(children=(ButtonTool(icon='home', la…" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "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": "markdown", + "id": "c3fe553b", + "metadata": {}, + "source": [ + "The fit looks good, so let us fit all Q indices independently and plot the results." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "e98e3d65", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "125f1cb9dee94f1baae2bfd75b893f73", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "InteractiveFigure(children=(HBar(), HBar(children=(VBar(children=(Toolbar(children=(ButtonTool(icon='home', la…" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "fit_result_independent_all_Q = vanadium_analysis.fit(fit_method='independent')\n", + "vanadium_analysis.plot_data_and_model()" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "99697abf", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
  • Data
    scipp
    DataArray
    (Q: 16, energy: 201)
    float64
    𝟙
    0.117, 0.112, ..., 0.110, 0.083
  • Model
    scipp
    DataArray
    (Q: 16, energy: 201)
    float64
    𝟙
    0.099, 0.099, ..., 0.101, 0.101
  • DeltaFunction
    scipp
    DataArray
    (Q: 16, energy: 201)
    float64
    𝟙
    0.099, 0.099, ..., 0.101, 0.101
  • Polynomial
    scipp
    DataArray
    (Q: 16, energy: 201)
    float64
    𝟙
    0.099, 0.099, ..., 0.101, 0.101
" + ], + "text/plain": [ + "DataGroup(sizes={'Q': 16, 'energy': 201}, keys=[\n", + " Data: DataArray({'Q': 16, 'energy': 201}),\n", + " Model: DataArray({'Q': 16, 'energy': 201}),\n", + " DeltaFunction: DataArray({'Q': 16, 'energy': 201}),\n", + " Polynomial: DataArray({'Q': 16, 'energy': 201}),\n", + "])" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "datagroup = vanadium_analysis.data_and_model_to_datagroup()\n", + "datagroup" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "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.14.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/easydynamics/sample_model/components/mixins.py b/src/easydynamics/sample_model/components/mixins.py index 0d760865..c3fad7b4 100644 --- a/src/easydynamics/sample_model/components/mixins.py +++ b/src/easydynamics/sample_model/components/mixins.py @@ -26,7 +26,7 @@ def _create_area_parameter( self, area: Numeric | Parameter, name: str, - unit: str | sc.Unit = 'meV', + unit: str | sc.Unit = "meV", minimum_area: float = MINIMUM_AREA, ) -> Parameter: """ @@ -59,17 +59,17 @@ def _create_area_parameter( The validated area Parameter. """ if not isinstance(area, (Parameter, Numeric)): - raise TypeError('area must be a number or a Parameter.') + raise TypeError("area must be a number or a Parameter.") if isinstance(area, Numeric): if not np.isfinite(area): - raise ValueError('area must be a finite number or a Parameter') + raise ValueError("area must be a finite number or a Parameter") - area = Parameter(name=name + ' area', value=float(area), unit=unit) + area = Parameter(name=name + " area", value=float(area), unit=unit) if area.value < 0: warnings.warn( - f'The area of {name} is negative, which may not be physically meaningful.', + f"The area of {name} is negative, which may not be physically meaningful.", UserWarning, stacklevel=3, ) @@ -84,7 +84,7 @@ def _create_center_parameter( center: Numeric | Parameter | None, name: str, fix_if_none: bool, - unit: str | sc.Unit = 'meV', + unit: str | sc.Unit = "meV", enforce_minimum_center: bool = False, ) -> Parameter: """ @@ -117,20 +117,20 @@ def _create_center_parameter( The validated center Parameter. """ if center is not None and not isinstance(center, (Numeric, Parameter)): - raise TypeError('center must be None, a number, or a Parameter.') + raise TypeError("center must be None, a number, or a Parameter.") if center is None: center = Parameter( - name=name + ' center', + name=name + " center", value=0.0, unit=unit, fixed=fix_if_none, ) elif isinstance(center, Numeric): if not np.isfinite(center): - raise ValueError('center must be None, a finite number or a Parameter') + raise ValueError("center must be None, a finite number or a Parameter") - center = Parameter(name=name + ' center', value=float(center), unit=unit) + center = Parameter(name=name + " center", value=float(center), unit=unit) if enforce_minimum_center and center.min < DHO_MINIMUM_CENTER: center.min = DHO_MINIMUM_CENTER return center @@ -139,8 +139,8 @@ def _create_width_parameter( self, width: Numeric | Parameter, name: str, - param_name: str = 'width', - unit: str | sc.Unit = 'meV', + param_name: str = "width", + unit: str | sc.Unit = "meV", minimum_width: float = MINIMUM_WIDTH, ) -> Parameter: """ @@ -172,18 +172,18 @@ def _create_width_parameter( The validated width Parameter. """ if not isinstance(width, (Numeric, Parameter)): - raise TypeError(f'{param_name} must be a number or a Parameter.') + raise TypeError(f"{param_name} must be a number or a Parameter.") if isinstance(width, Numeric): if not np.isfinite(width): - raise ValueError(f'{param_name} must be a finite number or a Parameter') + raise ValueError(f"{param_name} must be a finite number or a Parameter") if float(width) < minimum_width: raise ValueError( - f'The {param_name} of a {self.__class__.__name__} must be greater than zero.' + f"The {param_name} of a {self.__class__.__name__} must be greater than zero." ) width = Parameter( - name=name + ' ' + param_name, + name=name + " " + param_name, value=float(width), unit=unit, min=minimum_width, @@ -191,7 +191,7 @@ def _create_width_parameter( else: if width.value <= 0: raise ValueError( - f'The {param_name} of a {self.__class__.__name__} must be greater than zero.' + f"The {param_name} of a {self.__class__.__name__} must be greater than zero." ) if width.min < minimum_width: width.min = minimum_width From 3d2ded981515f89e2186bc07a27fa83ac039d339 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Mon, 1 Jun 2026 11:55:03 +0200 Subject: [PATCH 03/10] test residual plot --- docs/docs/tutorials/test_residual_plot.ipynb | 153 +++++++------------ 1 file changed, 52 insertions(+), 101 deletions(-) diff --git a/docs/docs/tutorials/test_residual_plot.ipynb b/docs/docs/tutorials/test_residual_plot.ipynb index f8d5fb68..fe59f9a7 100644 --- a/docs/docs/tutorials/test_residual_plot.ipynb +++ b/docs/docs/tutorials/test_residual_plot.ipynb @@ -13,7 +13,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "bca91d3c", "metadata": {}, "outputs": [], @@ -40,7 +40,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "8deca9b6", "metadata": {}, "outputs": [], @@ -71,52 +71,20 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "fbd31297", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "dbdbba074be949a99d9b601c0d94947b", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "InteractiveFigure(children=(HBar(), HBar(children=(VBar(children=(Toolbar(children=(ButtonTool(icon='home', la…" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "vanadium_experiment.plot_data(slicer=False, transpose_axes=False)" ] }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "4153ba52", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "16799ce2bcf64d1f8439735d4111ee28", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "InteractiveFigure(children=(HBar(), HBar(children=(VBar(children=(Toolbar(children=(ButtonTool(icon='home', la…" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "vanadium_experiment.plot_data(slicer=True)" ] @@ -133,7 +101,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "6762faba", "metadata": {}, "outputs": [], @@ -156,7 +124,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "id": "8fdb7f19", "metadata": {}, "outputs": [], @@ -178,7 +146,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "id": "1ec6836f", "metadata": {}, "outputs": [], @@ -196,7 +164,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "id": "0ad79a75", "metadata": {}, "outputs": [], @@ -217,7 +185,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "id": "b98d63dc", "metadata": {}, "outputs": [], @@ -240,26 +208,10 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "id": "aec75b7f", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "507964703fb947379fd18a7b4b7c61ca", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "InteractiveFigure(children=(HBar(), HBar(children=(VBar(children=(Toolbar(children=(ButtonTool(icon='home', la…" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "fit_result_independent_single_Q = vanadium_analysis.fit(fit_method='independent', Q_index=5)\n", "vanadium_analysis.plot_data_and_model(Q_index=5)" @@ -275,26 +227,10 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "id": "e98e3d65", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "125f1cb9dee94f1baae2bfd75b893f73", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "InteractiveFigure(children=(HBar(), HBar(children=(VBar(children=(Toolbar(children=(ButtonTool(icon='home', la…" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "fit_result_independent_all_Q = vanadium_analysis.fit(fit_method='independent')\n", "vanadium_analysis.plot_data_and_model()" @@ -302,33 +238,48 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "id": "99697abf", "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
  • Data
    scipp
    DataArray
    (Q: 16, energy: 201)
    float64
    𝟙
    0.117, 0.112, ..., 0.110, 0.083
  • Model
    scipp
    DataArray
    (Q: 16, energy: 201)
    float64
    𝟙
    0.099, 0.099, ..., 0.101, 0.101
  • DeltaFunction
    scipp
    DataArray
    (Q: 16, energy: 201)
    float64
    𝟙
    0.099, 0.099, ..., 0.101, 0.101
  • Polynomial
    scipp
    DataArray
    (Q: 16, energy: 201)
    float64
    𝟙
    0.099, 0.099, ..., 0.101, 0.101
" - ], - "text/plain": [ - "DataGroup(sizes={'Q': 16, 'energy': 201}, keys=[\n", - " Data: DataArray({'Q': 16, 'energy': 201}),\n", - " Model: DataArray({'Q': 16, 'energy': 201}),\n", - " DeltaFunction: DataArray({'Q': 16, 'energy': 201}),\n", - " Polynomial: DataArray({'Q': 16, 'energy': 201}),\n", - "])" - ] - }, - "execution_count": 14, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "datagroup = vanadium_analysis.data_and_model_to_datagroup()\n", + "import numpy as np\n", + "import scipp as sc\n", + "datagroup = vanadium_analysis.data_and_model_to_datagroup(energy=sc.linspace(start=-4,stop=4,dim='energy',num=1000, unit='meV'),include_residuals=True)\n", "datagroup" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f8e5dc19", + "metadata": {}, + "outputs": [], + "source": [ + "import plopp as pp\n", + "from plopp.plotting._slicer import SlicerPlot\n", + "import scipp as sc\n", + "\n", + "\n", + "sp = SlicerPlot({key: da for key, da in datagroup.items() if key != 'Residuals'})\n", + "fig1 = sp.figure\n", + "fig1" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "35252683", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "diff = sp.slicer.reduce_nodes[0] - sp.slicer.reduce_nodes[1]\n", + "fig2 = pp.linefigure(diff, figsize=(6, 2))\n", + "\n", + "fig1.bottom_bar.children = [pp.widgets.VBar([fig2, fig1.bottom_bar.children[0]])]\n", + "fig1" + ] } ], "metadata": { From b53cfecf6a48dda966aeb9c9dc63619a83616069 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Mon, 1 Jun 2026 16:03:26 +0200 Subject: [PATCH 04/10] add slicerplot_with_residuals function --- docs/docs/tutorials/test_residual_plot.ipynb | 306 ------------------ docs/docs/tutorials/tutorial0_basics.ipynb | 18 +- .../tutorials/tutorial0_more_advanced.ipynb | 6 +- src/easydynamics/analysis/analysis.py | 22 +- src/easydynamics/analysis/analysis1d.py | 2 +- .../sample_model/components/mixins.py | 34 +- src/easydynamics/utils/__init__.py | 3 +- src/easydynamics/utils/plotting.py | 77 +++++ 8 files changed, 124 insertions(+), 344 deletions(-) delete mode 100644 docs/docs/tutorials/test_residual_plot.ipynb create mode 100644 src/easydynamics/utils/plotting.py diff --git a/docs/docs/tutorials/test_residual_plot.ipynb b/docs/docs/tutorials/test_residual_plot.ipynb deleted file mode 100644 index fe59f9a7..00000000 --- a/docs/docs/tutorials/test_residual_plot.ipynb +++ /dev/null @@ -1,306 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "8643b10c", - "metadata": {}, - "source": [ - "# Brownian Diffusion\n", - "We here show how to set up an Analysis object and use it to first fit an artificial vanadium measurement to obtain the resolution. 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 and fit them to a diffusion model. Finally, we show how to fit all the data simultaneously to the diffusion model." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bca91d3c", - "metadata": {}, - "outputs": [], - "source": [ - "# Imports\n", - "import pooch\n", - "\n", - "import easydynamics as edyn\n", - "import easydynamics.sample_model as sm\n", - "\n", - "# Make the plots interactive\n", - "%matplotlib widget" - ] - }, - { - "cell_type": "markdown", - "id": "4c8e97b7", - "metadata": {}, - "source": [ - "We first create an `Experiment` object to contain the data. The data must either be a hdf5 file or a scipp.DataArray; in both cases it must have coordinates `Q` and `energy`. We here use Pooch to download an example vanadium data set.\n", - "\n", - "The data can be rebinned if needed, but we will show how to do that in a different tutorial." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8deca9b6", - "metadata": {}, - "outputs": [], - "source": [ - "# Load the vanadium data\n", - "vanadium_experiment = edyn.Experiment('Vanadium')\n", - "\n", - "file_path = pooch.retrieve(\n", - " url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/master/docs/docs/tutorials/data/vanadium_data_example.h5',\n", - " known_hash='16cc1b327c303feeb88fb9dda5390dc4880b62396b1793f98c6fef0b27c7b873',\n", - ")\n", - "\n", - "\n", - "vanadium_experiment.load_hdf5(filename=file_path)" - ] - }, - { - "cell_type": "markdown", - "id": "7daa3f64", - "metadata": {}, - "source": [ - "We can visualize the data in multiple ways, relying on plopp: https://scipp.github.io/plopp/\n", - "\n", - "We here show two ways to look at the data: as a 2d colormap with intensity as function of `Q` and `energy`, and as a slicer with intensity as function of `energy` for various `Q`.\n", - "\n", - "If you want $Q$ on the x axis, then set `transpose_axes=True`" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fbd31297", - "metadata": {}, - "outputs": [], - "source": [ - "vanadium_experiment.plot_data(slicer=False, transpose_axes=False)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4153ba52", - "metadata": {}, - "outputs": [], - "source": [ - "vanadium_experiment.plot_data(slicer=True)" - ] - }, - { - "cell_type": "markdown", - "id": "6c87b01c", - "metadata": {}, - "source": [ - "We now want to fit the vanadium data to determine our resolution. The scattering from vanadium is almost exclusively incoherent elastic, so we model it as a delta function. We do this by creating a `SampleModel` and adding a `DeltaFunction` component to it. The component acts as a template and gets copied to every `Q` when we attach the `SampleModel` to our `Analysis` object. Let's create the `SampleModel`.\n", - "\n", - "We do not give the `DeltaFunction` a `center` value. In this case, the center will be fixed at 0 energy transfer. We set the start value of the area to 1." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6762faba", - "metadata": {}, - "outputs": [], - "source": [ - "delta_function = sm.DeltaFunction(name='DeltaFunction', area=1)\n", - "sample_model = sm.SampleModel(components=delta_function)" - ] - }, - { - "cell_type": "markdown", - "id": "dc82774e", - "metadata": {}, - "source": [ - "We now want to define our resolution function. We will here model it as a Gaussian. We create a `ComponentCollection` and append the `Gaussian` to it. We can add as many components to our resolution as we like; sometimes you need several Gaussians and other functions to accurately describe the resolution.\n", - "\n", - "We fix the area of the resolution to have value 1. If we did not do this, we would fit both the area of the delta function and of the resolution Gaussian, and the fit would never converge.\n", - "\n", - "We finally insert the components in a `ResolutionModel`" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8fdb7f19", - "metadata": {}, - "outputs": [], - "source": [ - "resolution_components = sm.ComponentCollection()\n", - "res_gauss = sm.Gaussian(width=0.1, area=1, name='Res. Gauss')\n", - "res_gauss.area.fixed = True\n", - "resolution_components.append_component(res_gauss)\n", - "resolution_model = sm.ResolutionModel(components=resolution_components)" - ] - }, - { - "cell_type": "markdown", - "id": "088ac17d", - "metadata": {}, - "source": [ - "The background intensity was not 0, so we also create a background model. We use a `Polynomial` with a single coefficient, i.e. a flat background. We here show how to create the `BackgroundModel` and add the background in a single line. We could of course also add it like we did for the `SampleModel` or first create a `ComponentCollection` like we did for the `ResolutionModel`" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1ec6836f", - "metadata": {}, - "outputs": [], - "source": [ - "background_model = sm.BackgroundModel(components=sm.Polynomial(coefficients=[0.001]))" - ] - }, - { - "cell_type": "markdown", - "id": "eae3d14b", - "metadata": {}, - "source": [ - "We combine the resolution abd background model into an `InstrumentModel`. This model also contains a fittable energy offset to account for instrument misalignment. All components are centered at this energy offset." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0ad79a75", - "metadata": {}, - "outputs": [], - "source": [ - "instrument_model = sm.InstrumentModel(\n", - " resolution_model=resolution_model,\n", - " background_model=background_model,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "a99be7c1", - "metadata": {}, - "source": [ - "We are now ready to collect everything in an analysis object. We give it a display name, the experiment, the sample model and the instrument model. It will then automatically generate a model for each `Q` using the templates given in the `SampleModel`, `ResolutionModel` and `BackgroundModel`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b98d63dc", - "metadata": {}, - "outputs": [], - "source": [ - "vanadium_analysis = edyn.Analysis(\n", - " display_name='Vanadium Full Analysis',\n", - " experiment=vanadium_experiment,\n", - " sample_model=sample_model,\n", - " instrument_model=instrument_model,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "a81248a4", - "metadata": {}, - "source": [ - "Let us first fit a single Q index and plot the data and model to see how it looks. For this, we use the `independent` fit method and choose an arbitrary Q index" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "aec75b7f", - "metadata": {}, - "outputs": [], - "source": [ - "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": "markdown", - "id": "c3fe553b", - "metadata": {}, - "source": [ - "The fit looks good, so let us fit all Q indices independently and plot the results." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e98e3d65", - "metadata": {}, - "outputs": [], - "source": [ - "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": "99697abf", - "metadata": {}, - "outputs": [], - "source": [ - "import numpy as np\n", - "import scipp as sc\n", - "datagroup = vanadium_analysis.data_and_model_to_datagroup(energy=sc.linspace(start=-4,stop=4,dim='energy',num=1000, unit='meV'),include_residuals=True)\n", - "datagroup" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f8e5dc19", - "metadata": {}, - "outputs": [], - "source": [ - "import plopp as pp\n", - "from plopp.plotting._slicer import SlicerPlot\n", - "import scipp as sc\n", - "\n", - "\n", - "sp = SlicerPlot({key: da for key, da in datagroup.items() if key != 'Residuals'})\n", - "fig1 = sp.figure\n", - "fig1" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "35252683", - "metadata": {}, - "outputs": [], - "source": [ - "\n", - "diff = sp.slicer.reduce_nodes[0] - sp.slicer.reduce_nodes[1]\n", - "fig2 = pp.linefigure(diff, figsize=(6, 2))\n", - "\n", - "fig1.bottom_bar.children = [pp.widgets.VBar([fig2, fig1.bottom_bar.children[0]])]\n", - "fig1" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "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.14.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/docs/tutorials/tutorial0_basics.ipynb b/docs/docs/tutorials/tutorial0_basics.ipynb index 8e3142da..548c6c71 100644 --- a/docs/docs/tutorials/tutorial0_basics.ipynb +++ b/docs/docs/tutorials/tutorial0_basics.ipynb @@ -57,8 +57,8 @@ "experiment = edyn.Experiment(display_name='Tutorial')\n", "\n", "file_path = pooch.retrieve(\n", - " url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/tutorial0/docs/docs/tutorials/data/fake_simple_data.hdf5',\n", - " known_hash='b49944c4447e69be4d30d1bed935173c4a1727c25a347285cbb156edc76ee261',\n", + " url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/master/docs/docs/tutorials/data/fake_simple_data.hdf5',\n", + " known_hash='7aea2b5c315b0bd6f0a82ed925d07eaed93f5d61b13686935893c0b4e71999b6',\n", ")\n", "\n", "experiment.load_hdf5(filename=file_path)" @@ -293,7 +293,7 @@ "\n", "\n", "\n", - "Since the fit looked good, we can now fit all $Q$. We also plot the result, again using the slicer. We can plot the residuals (data - model) and offset them on the y axis to avoid overlap between them and the data." + "Since the fit looked good, we can now fit all $Q$. We also plot the result, again using the slicer. We can plot the residuals by setting `plot_residuals=True`." ] }, { @@ -304,7 +304,7 @@ "outputs": [], "source": [ "fit_result_all_Q = analysis.fit()\n", - "analysis.plot_data_and_model(plot_residuals=True, residuals_yoffset=-2.0)" + "analysis.plot_data_and_model(plot_residuals=True)" ] }, { @@ -312,7 +312,7 @@ "id": "64afbd3c", "metadata": {}, "source": [ - "Information about the fit is stored in the output. It is stored as a list of **EasyScience** `FitResult`s. Here we show a few of the relevant properties:" + "Information about the fit is stored in the output. It is stored as a list of **EasyScience** `FitResult`s. Here we show just the one for `Q_index=5`:" ] }, { @@ -322,9 +322,7 @@ "metadata": {}, "outputs": [], "source": [ - "print(f'The reduced chi-squared value for Q_index=5 is: {fit_result_all_Q[5].reduced_chi2}')\n", - "\n", - "print(f'The minimizer engine is: {fit_result_all_Q[5].minimizer_engine}')" + "print(fit_result_all_Q[5])" ] }, { @@ -478,7 +476,7 @@ ], "metadata": { "kernelspec": { - "display_name": "in16b", + "display_name": "Python 3", "language": "python", "name": "python3" }, @@ -492,7 +490,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.4" + "version": "3.14.5" } }, "nbformat": 4, diff --git a/docs/docs/tutorials/tutorial0_more_advanced.ipynb b/docs/docs/tutorials/tutorial0_more_advanced.ipynb index 25088780..d4fc8563 100644 --- a/docs/docs/tutorials/tutorial0_more_advanced.ipynb +++ b/docs/docs/tutorials/tutorial0_more_advanced.ipynb @@ -47,7 +47,7 @@ "experiment = edyn.Experiment(display_name='Tutorial')\n", "\n", "file_path = pooch.retrieve(\n", - " url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/tutorial0/docs/docs/tutorials/data/fake_advanced_data.hdf5',\n", + " url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/master/docs/docs/tutorials/data/fake_advanced_data.hdf5',\n", " known_hash='ee7310249df71a312ebc219f3e16b8da4e9aa37d29df919bbcaa541a38e1c39f',\n", ")\n", "\n", @@ -274,7 +274,7 @@ "outputs": [], "source": [ "fit_result_all_Q = analysis.fit()\n", - "analysis.plot_data_and_model()" + "analysis.plot_data_and_model(plot_residuals=True, autoscale=False)" ] }, { @@ -366,7 +366,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.4" + "version": "3.14.5" } }, "nbformat": 4, diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index 5a18622c..a23e37fe 100644 --- a/src/easydynamics/analysis/analysis.py +++ b/src/easydynamics/analysis/analysis.py @@ -18,6 +18,7 @@ from easydynamics.sample_model.instrument_model import InstrumentModel from easydynamics.settings.convolution_settings import ConvolutionSettings from easydynamics.settings.detailed_balance_settings import DetailedBalanceSettings +from easydynamics.utils.plotting import slicerplot_with_residuals from easydynamics.utils.utils import _in_notebook @@ -341,12 +342,21 @@ def plot_data_and_model( # Overwrite defaults with any user-provided kwargs plot_kwargs_defaults.update(kwargs) - fig = pp.slicer( - data_and_model, - **plot_kwargs_defaults, - ) - for widget in fig.bottom_bar[0].controls.values(): - widget.slider_toggler.value = '-o-' + if plot_residuals: + fig = slicerplot_with_residuals( + data_and_model, + residuals_key='Residuals', + operation='sum', + **plot_kwargs_defaults, + ) + + else: + fig = pp.slicer( + data_and_model, + **plot_kwargs_defaults, + ) + for widget in fig.bottom_bar[0].controls.values(): + widget.slider_toggler.value = '-o-' fig.autoscale() return fig diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index 738ae01e..3b6dc694 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -47,7 +47,7 @@ def __init__( Parameters ---------- - display_name : str | None, default="MyAnalysis" + display_name : str | None, default='MyAnalysis' Display name of the analysis. unique_name : str | None, default=None Unique name of the analysis. If None, a unique name is automatically generated. diff --git a/src/easydynamics/sample_model/components/mixins.py b/src/easydynamics/sample_model/components/mixins.py index c3fad7b4..0d760865 100644 --- a/src/easydynamics/sample_model/components/mixins.py +++ b/src/easydynamics/sample_model/components/mixins.py @@ -26,7 +26,7 @@ def _create_area_parameter( self, area: Numeric | Parameter, name: str, - unit: str | sc.Unit = "meV", + unit: str | sc.Unit = 'meV', minimum_area: float = MINIMUM_AREA, ) -> Parameter: """ @@ -59,17 +59,17 @@ def _create_area_parameter( The validated area Parameter. """ if not isinstance(area, (Parameter, Numeric)): - raise TypeError("area must be a number or a Parameter.") + raise TypeError('area must be a number or a Parameter.') if isinstance(area, Numeric): if not np.isfinite(area): - raise ValueError("area must be a finite number or a Parameter") + raise ValueError('area must be a finite number or a Parameter') - area = Parameter(name=name + " area", value=float(area), unit=unit) + area = Parameter(name=name + ' area', value=float(area), unit=unit) if area.value < 0: warnings.warn( - f"The area of {name} is negative, which may not be physically meaningful.", + f'The area of {name} is negative, which may not be physically meaningful.', UserWarning, stacklevel=3, ) @@ -84,7 +84,7 @@ def _create_center_parameter( center: Numeric | Parameter | None, name: str, fix_if_none: bool, - unit: str | sc.Unit = "meV", + unit: str | sc.Unit = 'meV', enforce_minimum_center: bool = False, ) -> Parameter: """ @@ -117,20 +117,20 @@ def _create_center_parameter( The validated center Parameter. """ if center is not None and not isinstance(center, (Numeric, Parameter)): - raise TypeError("center must be None, a number, or a Parameter.") + raise TypeError('center must be None, a number, or a Parameter.') if center is None: center = Parameter( - name=name + " center", + name=name + ' center', value=0.0, unit=unit, fixed=fix_if_none, ) elif isinstance(center, Numeric): if not np.isfinite(center): - raise ValueError("center must be None, a finite number or a Parameter") + raise ValueError('center must be None, a finite number or a Parameter') - center = Parameter(name=name + " center", value=float(center), unit=unit) + center = Parameter(name=name + ' center', value=float(center), unit=unit) if enforce_minimum_center and center.min < DHO_MINIMUM_CENTER: center.min = DHO_MINIMUM_CENTER return center @@ -139,8 +139,8 @@ def _create_width_parameter( self, width: Numeric | Parameter, name: str, - param_name: str = "width", - unit: str | sc.Unit = "meV", + param_name: str = 'width', + unit: str | sc.Unit = 'meV', minimum_width: float = MINIMUM_WIDTH, ) -> Parameter: """ @@ -172,18 +172,18 @@ def _create_width_parameter( The validated width Parameter. """ if not isinstance(width, (Numeric, Parameter)): - raise TypeError(f"{param_name} must be a number or a Parameter.") + raise TypeError(f'{param_name} must be a number or a Parameter.') if isinstance(width, Numeric): if not np.isfinite(width): - raise ValueError(f"{param_name} must be a finite number or a Parameter") + raise ValueError(f'{param_name} must be a finite number or a Parameter') if float(width) < minimum_width: raise ValueError( - f"The {param_name} of a {self.__class__.__name__} must be greater than zero." + f'The {param_name} of a {self.__class__.__name__} must be greater than zero.' ) width = Parameter( - name=name + " " + param_name, + name=name + ' ' + param_name, value=float(width), unit=unit, min=minimum_width, @@ -191,7 +191,7 @@ def _create_width_parameter( else: if width.value <= 0: raise ValueError( - f"The {param_name} of a {self.__class__.__name__} must be greater than zero." + f'The {param_name} of a {self.__class__.__name__} must be greater than zero.' ) if width.min < minimum_width: width.min = minimum_width diff --git a/src/easydynamics/utils/__init__.py b/src/easydynamics/utils/__init__.py index 18e7bee5..5e644a06 100644 --- a/src/easydynamics/utils/__init__.py +++ b/src/easydynamics/utils/__init__.py @@ -2,5 +2,6 @@ # SPDX-License-Identifier: BSD-3-Clause from easydynamics.utils.detailed_balance import detailed_balance_factor +from easydynamics.utils.plotting import slicerplot_with_residuals -__all__ = ['detailed_balance_factor'] +__all__ = ['detailed_balance_factor', 'slicerplot_with_residuals'] diff --git a/src/easydynamics/utils/plotting.py b/src/easydynamics/utils/plotting.py new file mode 100644 index 00000000..e7b7c129 --- /dev/null +++ b/src/easydynamics/utils/plotting.py @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: 2025 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + + +import plopp as pp +import scipp as sc +from plopp.backends.matplotlib.figure import InteractiveFigure +from plopp.plotting._slicer import SlicerPlot +from plopp.plotting._slicer import _maybe_reduce_dim +from plopp.widgets import slice_dims + + +def slicerplot_with_residuals( + dg: sc.DataGroup, + *, + residuals_key: str = 'Residuals', + keep: list[str] | str | None = None, + operation: str = 'sum', + **kwargs: object, +) -> InteractiveFigure: + """ + Create a SlicerPlot with an additional subplot for residuals. + + Parameters + ---------- + dg : sc.DataGroup + DataGroup containing the data to plot. Must include a key for residuals. + residuals_key : str, default="Residuals" + Key in the DataGroup that contains the residuals data. + keep : list[str] | str | None, default=None + Dimensions to keep in the SlicerPlot. Passed to SlicerPlot. + operation : str, default="sum" + Operation to apply when reducing the residuals data. Passed to SlicerPlot. + **kwargs : object + Additional keyword arguments passed to SlicerPlot. + + Returns + ------- + InteractiveFigure + A figure containing the SlicerPlot and the residuals subplot. + """ + + sp = SlicerPlot( + {k: da for k, da in dg.items() if k != residuals_key}, + keep=keep, + operation=operation, + **kwargs, + ) + + other_dims = [dim for dim in dg.dims if dim not in sp.slicer.keep] + + slice_res = slice_dims(pp.Node(dg[residuals_key]), sp.slicer.slider_node) + reduce_res = pp.Node(_maybe_reduce_dim, da=slice_res, dims=other_dims, op=operation) + + res_fig_kwargs = {} + if 'autoscale' in kwargs: + res_fig_kwargs['autoscale'] = kwargs['autoscale'] + + res_fig = pp.linefigure(reduce_res, figsize=(6, 2), **res_fig_kwargs) + + res_fig.layout.margin = '0px' + res_fig.layout.padding = '0px' + + fig1 = sp.figure + for widget in fig1.bottom_bar[0].controls.values(): + widget.slider_toggler.value = '-o-' + fig1.bottom_bar.children = [pp.widgets.VBar([res_fig, fig1.bottom_bar.children[0]])] + + res_fig.autoscale() + + # Small tweaks + fig1.ax.set_xlabel('') + fig1.ax.xaxis.label.set_visible(False) + fig1.ax.tick_params(labelbottom=False) + + fig1.autoscale() + return fig1 From 2bb62982055b8f407c3cf8d2b8117e7941e236a8 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Mon, 1 Jun 2026 21:58:11 +0200 Subject: [PATCH 05/10] add residual plot for analysis1d --- .../tutorials/tutorial0_more_advanced.ipynb | 4 +-- src/easydynamics/analysis/analysis.py | 1 + src/easydynamics/analysis/analysis1d.py | 30 +++++++++---------- src/easydynamics/utils/plotting.py | 4 +-- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/docs/docs/tutorials/tutorial0_more_advanced.ipynb b/docs/docs/tutorials/tutorial0_more_advanced.ipynb index d4fc8563..f203dbdc 100644 --- a/docs/docs/tutorials/tutorial0_more_advanced.ipynb +++ b/docs/docs/tutorials/tutorial0_more_advanced.ipynb @@ -226,7 +226,7 @@ "id": "a81248a4", "metadata": {}, "source": [ - "As before, let us first fit a single $Q$ index and plot the data and model to see how it looks. We choose an arbitrary $Q$ and plot only that one" + "As before, let us first fit a single $Q$ index and plot the data and model to see how it looks. We choose an arbitrary $Q$ and plot only that one. We also plot the residuals underneath by setting plot_residuals to True:" ] }, { @@ -237,7 +237,7 @@ "outputs": [], "source": [ "fit_result_independent_single_Q = analysis.fit(Q_index=5)\n", - "analysis.plot_data_and_model(Q_index=5)" + "analysis.plot_data_and_model(Q_index=5, plot_residuals=True)" ] }, { diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index a23e37fe..f285a49c 100644 --- a/src/easydynamics/analysis/analysis.py +++ b/src/easydynamics/analysis/analysis.py @@ -267,6 +267,7 @@ def plot_data_and_model( return self.analysis_list[Q_index].plot_data_and_model( plot_components=plot_components, add_background=add_background, + plot_residuals=plot_residuals, energy=energy, **kwargs, ) diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index 3b6dc694..2d89157c 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -21,6 +21,7 @@ from easydynamics.settings.convolution_settings import ConvolutionSettings from easydynamics.settings.detailed_balance_settings import DetailedBalanceSettings from easydynamics.utils.detailed_balance import detailed_balance_factor +from easydynamics.utils.plotting import slicerplot_with_residuals class Analysis1d(AnalysisBase): @@ -270,7 +271,6 @@ def plot_data_and_model( plot_components: bool = True, add_background: bool = True, plot_residuals: bool = False, - residuals_yoffset: float = 0.0, energy: sc.Variable | None = None, **kwargs: dict[str, Any], ) -> InteractiveFigure: @@ -289,9 +289,6 @@ def plot_data_and_model( components. plot_residuals : bool, default=False Whether to plot the residuals (data - model). - residuals_yoffset : float, default=0.0 - A y-offset to apply to the residuals when plotting, to avoid overlap with the data and - model. Only used if plot_residuals is True. energy : sc.Variable | None, default=None Optional energy grid to use for plotting. If None, the energy grid from the experiment is used. @@ -310,7 +307,6 @@ def plot_data_and_model( add_background=add_background, include_components=plot_components, include_residuals=plot_residuals, - residuals_yoffset=residuals_yoffset, ) plot_kwargs_defaults = { @@ -347,10 +343,20 @@ def plot_data_and_model( # Overwrite defaults with any user-provided kwargs plot_kwargs_defaults.update(kwargs) - return pp.plot( - data_and_model, - **plot_kwargs_defaults, - ) + if plot_residuals: + fig = slicerplot_with_residuals( + data_and_model, + residuals_key='Residuals', + operation='sum', + **plot_kwargs_defaults, + ) + else: + fig = pp.plot( + data_and_model, + **plot_kwargs_defaults, + ) + fig.autoscale() + return fig def data_and_model_to_datagroup( self, @@ -358,7 +364,6 @@ def data_and_model_to_datagroup( add_background: bool = True, include_components: bool = True, include_residuals: bool = False, - residuals_yoffset: float = 0.0, ) -> sc.DataGroup: """ Create a scipp DataGroup containing the experimental data, model calculation, and @@ -379,9 +384,6 @@ def data_and_model_to_datagroup( include_residuals : bool, default=False Whether to include the residuals (data - model) in the DataGroup. If True, the DataGroup will include a DataArray with key 'Residuals' containing the residuals. - residuals_yoffset : float, default=0.0 - A y-offset to apply to the residuals when plotting, to avoid overlap with the data and - model. Only used if include_residuals is True. Raises ------ @@ -440,7 +442,6 @@ def data_and_model_to_datagroup( if include_residuals: residuals = self._create_residuals_array() - residuals += residuals_yoffset data_and_model['Residuals'] = residuals return sc.DataGroup(data_and_model) @@ -901,7 +902,6 @@ def _create_residuals_array(self) -> sc.DataArray: residuals = data.copy(deep=True) residuals.values -= model return residuals - # return self._to_scipp_array(values=residuals) def _create_components_dataset_single_Q( self, diff --git a/src/easydynamics/utils/plotting.py b/src/easydynamics/utils/plotting.py index e7b7c129..69d04544 100644 --- a/src/easydynamics/utils/plotting.py +++ b/src/easydynamics/utils/plotting.py @@ -25,11 +25,11 @@ def slicerplot_with_residuals( ---------- dg : sc.DataGroup DataGroup containing the data to plot. Must include a key for residuals. - residuals_key : str, default="Residuals" + residuals_key : str, default='Residuals' Key in the DataGroup that contains the residuals data. keep : list[str] | str | None, default=None Dimensions to keep in the SlicerPlot. Passed to SlicerPlot. - operation : str, default="sum" + operation : str, default='sum' Operation to apply when reducing the residuals data. Passed to SlicerPlot. **kwargs : object Additional keyword arguments passed to SlicerPlot. From b4b275774c821e7a98746fc96df3e449a0e0beb6 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Mon, 1 Jun 2026 22:09:18 +0200 Subject: [PATCH 06/10] fix test --- docs/docs/tutorials/tutorial1_brownian.ipynb | 2 +- docs/docs/tutorials/tutorial2_nanoparticles.ipynb | 2 +- tests/unit/easydynamics/analysis/test_analysis.py | 6 +++++- tests/unit/easydynamics/analysis/test_analysis1d.py | 4 ++-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/docs/tutorials/tutorial1_brownian.ipynb b/docs/docs/tutorials/tutorial1_brownian.ipynb index 40a6c21f..c1f1aa41 100644 --- a/docs/docs/tutorials/tutorial1_brownian.ipynb +++ b/docs/docs/tutorials/tutorial1_brownian.ipynb @@ -726,7 +726,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.4" + "version": "3.14.5" } }, "nbformat": 4, diff --git a/docs/docs/tutorials/tutorial2_nanoparticles.ipynb b/docs/docs/tutorials/tutorial2_nanoparticles.ipynb index 2fb4fc4a..31e15c74 100644 --- a/docs/docs/tutorials/tutorial2_nanoparticles.ipynb +++ b/docs/docs/tutorials/tutorial2_nanoparticles.ipynb @@ -616,7 +616,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.4" + "version": "3.14.5" } }, "nbformat": 4, diff --git a/tests/unit/easydynamics/analysis/test_analysis.py b/tests/unit/easydynamics/analysis/test_analysis.py index 209a6d92..583566e3 100644 --- a/tests/unit/easydynamics/analysis/test_analysis.py +++ b/tests/unit/easydynamics/analysis/test_analysis.py @@ -238,7 +238,11 @@ def test_plot_data_and_model_Q_index(self, analysis): # EXPECT analysis.analysis_list[1].plot_data_and_model.assert_called_once_with( - plot_components=True, add_background=True, energy=None, **kwargs + plot_components=True, + add_background=True, + plot_residuals=False, + energy=None, + **kwargs, ) assert result == 'plot_Q1' diff --git a/tests/unit/easydynamics/analysis/test_analysis1d.py b/tests/unit/easydynamics/analysis/test_analysis1d.py index 2219864d..38bc81ad 100644 --- a/tests/unit/easydynamics/analysis/test_analysis1d.py +++ b/tests/unit/easydynamics/analysis/test_analysis1d.py @@ -241,8 +241,8 @@ def test_plot_raises_if_no_data(self, analysis1d): def test_plot_calls_plopp_with_correct_arguments(self, analysis1d): # WHEN - fake_fig = object() - + fake_fig = MagicMock() + fake_fig.autoscale = MagicMock() with patch('plopp.plot', return_value=fake_fig) as mock_plot: # THEN result = analysis1d.plot_data_and_model() From 1c39a3d39160f64979029a87c70005f4186c0913 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Tue, 2 Jun 2026 23:35:44 +0200 Subject: [PATCH 07/10] some tests --- src/easydynamics/analysis/analysis.py | 16 -- src/easydynamics/utils/plotting.py | 20 ++- .../unit/easydynamics/utils/test_plotting.py | 141 ++++++++++++++++++ 3 files changed, 159 insertions(+), 18 deletions(-) create mode 100644 tests/unit/easydynamics/utils/test_plotting.py diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index f285a49c..c7d01ef8 100644 --- a/src/easydynamics/analysis/analysis.py +++ b/src/easydynamics/analysis/analysis.py @@ -215,7 +215,6 @@ def plot_data_and_model( plot_components: bool = True, add_background: bool = True, plot_residuals: bool = False, - residuals_yoffset: float = 0.0, energy: sc.Variable | None = None, **kwargs: dict[str, Any], ) -> InteractiveFigure: @@ -237,9 +236,6 @@ def plot_data_and_model( Default is True. plot_residuals : bool, default=False Whether to plot the residuals (data - model). Default is False. - residuals_yoffset : float, default=0.0 - Vertical offset to apply to the residuals when plotting, to avoid overlap with the data - and model. Only used if plot_residuals is True. Default is 0.0. energy : sc.Variable | None, default=None The energy values to use for calculating the model. If None, uses the energy from the experiment. @@ -292,9 +288,6 @@ def plot_data_and_model( if not isinstance(plot_residuals, bool): raise TypeError('plot_residuals must be True or False.') - if not isinstance(residuals_yoffset, (int, float)): - raise TypeError('residuals_yoffset must be a number.') - if energy is None: energy = self.energy @@ -305,7 +298,6 @@ def plot_data_and_model( add_background=add_background, include_components=plot_components, include_residuals=plot_residuals, - residuals_yoffset=residuals_yoffset, ) plot_kwargs_defaults = { @@ -367,7 +359,6 @@ def data_and_model_to_datagroup( add_background: bool = True, include_components: bool = True, include_residuals: bool = False, - residuals_yoffset: float = 0.0, ) -> sc.DataGroup: """ Create a scipp DataGroup containing the experimental data, model calculation and optionally @@ -386,9 +377,6 @@ def data_and_model_to_datagroup( only the total model will be included. include_residuals : bool, default=False Whether to include the residuals (data - model) in the DataGroup. - residuals_yoffset : float, default=0.0 - Vertical offset to apply to the residuals when plotting, to avoid overlap with the data - and model. Only used if include_residuals is True. Default is 0.0. Raises ------ @@ -422,9 +410,6 @@ def data_and_model_to_datagroup( if not isinstance(include_residuals, bool): raise TypeError('include_residuals must be True or False.') - if not isinstance(residuals_yoffset, (int, float)): - raise TypeError('residuals_yoffset must be a number.') - energy = self._verify_energy(energy) if energy is None: @@ -444,7 +429,6 @@ def data_and_model_to_datagroup( if include_residuals: data_and_model['Residuals'] = self._create_residuals_array() - data_and_model['Residuals'] += residuals_yoffset return sc.DataGroup(data_and_model) diff --git a/src/easydynamics/utils/plotting.py b/src/easydynamics/utils/plotting.py index 69d04544..4e501246 100644 --- a/src/easydynamics/utils/plotting.py +++ b/src/easydynamics/utils/plotting.py @@ -25,11 +25,11 @@ def slicerplot_with_residuals( ---------- dg : sc.DataGroup DataGroup containing the data to plot. Must include a key for residuals. - residuals_key : str, default='Residuals' + residuals_key : str, default="Residuals" Key in the DataGroup that contains the residuals data. keep : list[str] | str | None, default=None Dimensions to keep in the SlicerPlot. Passed to SlicerPlot. - operation : str, default='sum' + operation : str, default="sum" Operation to apply when reducing the residuals data. Passed to SlicerPlot. **kwargs : object Additional keyword arguments passed to SlicerPlot. @@ -38,8 +38,24 @@ def slicerplot_with_residuals( ------- InteractiveFigure A figure containing the SlicerPlot and the residuals subplot. + + Raises + ------ + TypeError + If dg is not a sc.DataGroup or if residuals_key is not a string. + ValueError + If residuals_key is not found in the DataGroup. """ + if not isinstance(dg, sc.DataGroup): + raise TypeError(f'Expected a sc.DataGroup, got {type(dg)}') + + if not isinstance(residuals_key, str): + raise TypeError(f'Expected residuals_key to be a string, got {type(residuals_key)}') + + if residuals_key not in dg: + raise ValueError(f"Residuals key '{residuals_key}' not found in DataGroup") + sp = SlicerPlot( {k: da for k, da in dg.items() if k != residuals_key}, keep=keep, diff --git a/tests/unit/easydynamics/utils/test_plotting.py b/tests/unit/easydynamics/utils/test_plotting.py new file mode 100644 index 00000000..5214d0da --- /dev/null +++ b/tests/unit/easydynamics/utils/test_plotting.py @@ -0,0 +1,141 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest +import scipp as sc + +from easydynamics.utils.plotting import slicerplot_with_residuals + + +class TestSlicerplot_with_residuals: + @pytest.fixture + def datagroup(self): + return sc.DataGroup( + Data=sc.DataArray(sc.array(dims=['x'], values=[1.0])), + Residuals=sc.DataArray(sc.array(dims=['x'], values=[0.0])), + ) + + @pytest.fixture + def datagroup_with_model(self): + return sc.DataGroup( + Data=sc.DataArray(sc.array(dims=['x'], values=[1])), + Model=sc.DataArray(sc.array(dims=['x'], values=[2])), + Residuals=sc.DataArray(sc.array(dims=['x'], values=[0])), + ) + + @pytest.mark.parametrize( + 'dg', + [ + None, + [], + {}, + sc.scalar(1), + ], + ids=[ + 'None', + 'List', + 'Dict', + 'Scalar', + ], + ) + def test_slicerplot_with_residuals_invalid_datagroup_raises(self, dg): + # WHEN THEN EXPECT + with pytest.raises( + TypeError, + match=r'Expected a sc.DataGroup', + ): + slicerplot_with_residuals(dg) + + @pytest.mark.parametrize( + 'residuals_key', + [ + None, + 123, + [], + ], + ids=[ + 'None', + 'Integer', + 'List', + ], + ) + def test_slicerplot_with_residuals_invalid_residuals_key_raises( + self, + residuals_key, + datagroup, + ): + # WHEN THEN EXPECT + with pytest.raises( + TypeError, + match='Expected residuals_key to be a string', + ): + slicerplot_with_residuals( + datagroup, + residuals_key=residuals_key, + ) + + def test_slicerplot_with_residuals_missing_residuals_key_raises(self, datagroup): + # WHEN THEN EXPECT + with pytest.raises( + ValueError, + match="Residuals key 'NonExistentKey' not found in DataGroup", + ): + slicerplot_with_residuals(datagroup, residuals_key='NonExistentKey') + + def test_slicerplot_with_residuals_excludes_residuals_from_main_plot( + self, datagroup_with_model + ): + # WHEN + mock_sp = MagicMock() + mock_sp.slicer.keep = ['x'] + mock_sp.slicer.slider_node = MagicMock() + mock_sp.figure = MagicMock() + + with ( + patch( + 'easydynamics.utils.plotting.SlicerPlot', + return_value=mock_sp, + ) as slicer_plot, + patch('easydynamics.utils.plotting.pp.linefigure', return_value=MagicMock()), + patch('easydynamics.utils.plotting.pp.widgets.VBar'), + patch('easydynamics.utils.plotting.slice_dims'), + patch('easydynamics.utils.plotting.pp.Node'), + ): + # THEN + slicerplot_with_residuals(datagroup_with_model) + + # EXPECT + plotted_group = slicer_plot.call_args.args[0] + + assert 'Residuals' not in plotted_group + assert set(plotted_group.keys()) == {'Data', 'Model'} + + def test_slicerplot_with_residuals_hides_top_x_axis(self, datagroup): + # WHEN + mock_fig = MagicMock() + mock_sp = MagicMock() + mock_sp.slicer.keep = ['x'] + mock_sp.slicer.slider_node = MagicMock() + mock_sp.figure = mock_fig + + with ( + patch( + 'easydynamics.utils.plotting.SlicerPlot', + return_value=mock_sp, + ), + patch('easydynamics.utils.plotting.pp.linefigure', return_value=MagicMock()), + patch('easydynamics.utils.plotting.pp.widgets.VBar'), + patch('easydynamics.utils.plotting.slice_dims'), + patch('easydynamics.utils.plotting.pp.Node'), + ): + # THEN + slicerplot_with_residuals(datagroup) + + # EXPECT + mock_fig.ax.set_xlabel.assert_called_once_with('') + mock_fig.ax.xaxis.label.set_visible.assert_called_once_with(False) + mock_fig.ax.tick_params.assert_called_once_with(labelbottom=False) + mock_fig.autoscale.assert_called_once() From 151b6c30ca1d8d65ffa72cb0b6082cf75d21e7f7 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Wed, 3 Jun 2026 00:08:24 +0200 Subject: [PATCH 08/10] some more tests --- docs/docs/tutorials/analysis1d.ipynb | 2 +- src/easydynamics/analysis/analysis1d.py | 9 +- src/easydynamics/utils/plotting.py | 4 +- .../easydynamics/analysis/test_analysis.py | 97 ++++++++++++++++++- .../easydynamics/analysis/test_analysis1d.py | 38 +++++++- 5 files changed, 136 insertions(+), 14 deletions(-) diff --git a/docs/docs/tutorials/analysis1d.ipynb b/docs/docs/tutorials/analysis1d.ipynb index 8584f314..fbd2dbfb 100644 --- a/docs/docs/tutorials/analysis1d.ipynb +++ b/docs/docs/tutorials/analysis1d.ipynb @@ -81,7 +81,7 @@ ")\n", "\n", "fit_result = my_analysis.fit()\n", - "my_analysis.plot_data_and_model(plot_residuals=True, residuals_yoffset=-0.1)" + "my_analysis.plot_data_and_model(plot_residuals=True)" ] } ], diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index 2d89157c..480b5fd0 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -891,17 +891,12 @@ def _create_residuals_array(self) -> sc.DataArray: If no data is available in the experiment to calculate residuals. If Q_index is not set to calculate residuals. """ - if self.experiment.binned_data is None: - raise ValueError('No data to calculate residuals. Please load data first.') - if self.Q_index is None: raise ValueError('Q_index must be set to calculate residuals.') data = self.experiment.binned_data['Q', self.Q_index] - model = self.calculate() - residuals = data.copy(deep=True) - residuals.values -= model - return residuals + model = self._create_model_array() + return data.copy(deep=True) - model def _create_components_dataset_single_Q( self, diff --git a/src/easydynamics/utils/plotting.py b/src/easydynamics/utils/plotting.py index 4e501246..e1687c66 100644 --- a/src/easydynamics/utils/plotting.py +++ b/src/easydynamics/utils/plotting.py @@ -25,11 +25,11 @@ def slicerplot_with_residuals( ---------- dg : sc.DataGroup DataGroup containing the data to plot. Must include a key for residuals. - residuals_key : str, default="Residuals" + residuals_key : str, default='Residuals' Key in the DataGroup that contains the residuals data. keep : list[str] | str | None, default=None Dimensions to keep in the SlicerPlot. Passed to SlicerPlot. - operation : str, default="sum" + operation : str, default='sum' Operation to apply when reducing the residuals data. Passed to SlicerPlot. **kwargs : object Additional keyword arguments passed to SlicerPlot. diff --git a/tests/unit/easydynamics/analysis/test_analysis.py b/tests/unit/easydynamics/analysis/test_analysis.py index 583566e3..7e76bef2 100644 --- a/tests/unit/easydynamics/analysis/test_analysis.py +++ b/tests/unit/easydynamics/analysis/test_analysis.py @@ -280,6 +280,17 @@ def test_plot_data_and_model_invalid_add_background_raises(self, analysis): ): analysis.plot_data_and_model(add_background='not_a_boolean') + def test_plot_data_and_model_invalid_plot_residuals_raises(self, analysis): + # WHEN / THEN / EXPECT + with ( + patch('easydynamics.analysis.analysis._in_notebook', return_value=True), + pytest.raises( + TypeError, + match='plot_residuals must be True or False', + ), + ): + analysis.plot_data_and_model(plot_residuals='not_a_boolean') + @pytest.mark.parametrize( 'plot_components, expect_components', [ @@ -357,10 +368,60 @@ def test_plot_data_and_model( # Widget toggler updated assert fake_widget.slider_toggler.value == '-o-' - def test_data_and_model_to_datagroup(self, analysis): + def test_plot_data_and_model_with_residuals( + self, + analysis, + ): + # WHEN + fake_widget = MagicMock() + fake_widget.slider_toggler = MagicMock() + + fake_fig = MagicMock() + fake_fig.bottom_bar = [MagicMock()] + fake_fig.bottom_bar[0].controls = {'test': fake_widget} + + with ( + patch( + 'easydynamics.analysis.analysis.slicerplot_with_residuals', + return_value=fake_fig, + ) as mock_residuals, + patch('easydynamics.analysis.analysis._in_notebook', return_value=True), + ): + # THEN + fig = analysis.plot_data_and_model( + plot_components=True, + plot_residuals=True, + ) + + # EXPECT + assert fig is fake_fig + mock_residuals.assert_called_once() + + args, kwargs = mock_residuals.call_args + datagroup = args[0] + + assert isinstance(datagroup, sc.DataGroup) + assert 'Data' in datagroup + assert 'Model' in datagroup + assert 'Residuals' in datagroup + + # residual styling + assert kwargs['linestyle']['Residuals'] == 'none' + assert kwargs['marker']['Residuals'] == 'o' + assert kwargs['color']['Residuals'] == 'blue' + assert kwargs['markerfacecolor']['Residuals'] == 'none' + + # still respects global metadata + assert kwargs['title'] == analysis.display_name + assert kwargs['keep'] == 'energy' + + @pytest.mark.parametrize('include_residuals', [True, False]) + def test_data_and_model_to_datagroup(self, analysis, include_residuals): # WHEN energy = sc.array(dims=['energy'], values=[20.0, 30.0, 40.0], unit='meV') - datagroup = analysis.data_and_model_to_datagroup(energy=energy) + datagroup = analysis.data_and_model_to_datagroup( + energy=energy, include_residuals=include_residuals + ) # EXPECT assert isinstance(datagroup, sc.DataGroup) @@ -368,6 +429,12 @@ def test_data_and_model_to_datagroup(self, analysis): assert 'Model' in datagroup assert sc.identical(datagroup['Data'], analysis.experiment.binned_data) assert sc.identical(datagroup['Model'], analysis._create_model_array(energy=energy)) + if include_residuals: + assert 'Residuals' in datagroup + assert sc.identical( + datagroup['Residuals'], + analysis.experiment.binned_data - analysis._create_model_array(), + ) def test_data_and_model_to_datagroup_no_data_raises(self, analysis): # WHEN @@ -406,6 +473,14 @@ def test_data_and_model_to_datagroup_include_components_not_bool_raises(self, an ): analysis.data_and_model_to_datagroup(include_components='not_a_boolean') + def test_data_and_model_to_datagroup_include_residuals_not_bool_raises(self, analysis): + # WHEN / THEN / EXPECT + with pytest.raises( + TypeError, + match=r'include_residuals must be True or False', + ): + analysis.data_and_model_to_datagroup(include_residuals='not_a_boolean') + def test_parameters_to_dataset(self, analysis): # WHEN analysis.sample_model.append_component( @@ -775,6 +850,24 @@ def test_create_model_array(self, analysis): np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]), ) + def test_create_residuals_array(self, analysis): + # WHEN + # Mock the _create_model_array method to return a specific model array + model = analysis.experiment.binned_data.copy(deep=True) + model.values *= 0.5 + + with patch.object( + analysis, + '_create_model_array', + return_value=model, + ): + # THEN + residuals = analysis._create_residuals_array() + + # EXPECT + expected = analysis.experiment.binned_data - model + assert sc.identical(residuals, expected) + def test_create_components_dataset_raises(self, analysis): # WHEN / THEN / EXPECT with pytest.raises( diff --git a/tests/unit/easydynamics/analysis/test_analysis1d.py b/tests/unit/easydynamics/analysis/test_analysis1d.py index 38bc81ad..75bfd7ec 100644 --- a/tests/unit/easydynamics/analysis/test_analysis1d.py +++ b/tests/unit/easydynamics/analysis/test_analysis1d.py @@ -281,12 +281,19 @@ def test_plot_calls_plopp_with_correct_arguments(self, analysis1d): # Return value propagated assert result is fake_fig - def test_data_and_model_to_datagroup(self, analysis1d): + @pytest.mark.parametrize( + 'include_residuals', + [True, False], + ids=['Include residuals', 'Do not include residuals'], + ) + def test_data_and_model_to_datagroup(self, analysis1d, include_residuals): # WHEN energy = sc.array(dims=['energy'], values=[20.0, 30.0, 40.0], unit='meV') # THEN - datagroup = analysis1d.data_and_model_to_datagroup(energy=energy) + datagroup = analysis1d.data_and_model_to_datagroup( + energy=energy, include_residuals=include_residuals + ) # EXPECT assert isinstance(datagroup, sc.DataGroup) @@ -297,6 +304,14 @@ def test_data_and_model_to_datagroup(self, analysis1d): analysis1d.experiment.binned_data['Q', analysis1d.Q_index], ) assert sc.identical(datagroup['Model'], analysis1d._create_model_array(energy=energy)) + if include_residuals: + assert 'Residuals' in datagroup + assert sc.identical( + datagroup['Residuals'], + datagroup['Data'] - analysis1d._create_model_array(), + ) + else: + assert 'Residuals' not in datagroup def test_data_and_model_to_datagroup_no_data_raises(self, analysis1d): # WHEN @@ -743,6 +758,25 @@ def test_create_convolver_returns_none_if_no_sample_components(self, analysis1d) # Private methods: create scipp arrays for plotting ############# + def test_create_residuals_array(self, analysis1d): + # WHEN + # Mock the _create_model_array method to return a specific model array + model = analysis1d.experiment.binned_data.copy(deep=True) + model = model['Q', analysis1d.Q_index] + model.values *= 0.5 + + with patch.object( + analysis1d, + '_create_model_array', + return_value=model, + ): + # THEN + residuals = analysis1d._create_residuals_array() + + # EXPECT + expected = analysis1d.experiment.binned_data['Q', analysis1d.Q_index] - model + assert sc.identical(residuals, expected) + @pytest.mark.parametrize( 'background', [ From c7908d9f5178728abc89e894f49431032f02b586 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Wed, 3 Jun 2026 00:20:15 +0200 Subject: [PATCH 09/10] a few more tests --- .../easydynamics/analysis/test_analysis1d.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/unit/easydynamics/analysis/test_analysis1d.py b/tests/unit/easydynamics/analysis/test_analysis1d.py index 75bfd7ec..f324afd9 100644 --- a/tests/unit/easydynamics/analysis/test_analysis1d.py +++ b/tests/unit/easydynamics/analysis/test_analysis1d.py @@ -281,6 +281,56 @@ def test_plot_calls_plopp_with_correct_arguments(self, analysis1d): # Return value propagated assert result is fake_fig + def test_plot_calls_plopp_with_correct_arguments_plot_residuals(self, analysis1d): + # WHEN + fake_fig = MagicMock() + fake_fig.autoscale = MagicMock() + with patch( + 'easydynamics.analysis.analysis1d.slicerplot_with_residuals', + return_value=fake_fig, + ) as mock_plot: + # THEN + result = analysis1d.plot_data_and_model(plot_residuals=True) + + # EXPECT + + # plot called once + mock_plot.assert_called_once() + + # Extract arguments + args, kwargs = mock_plot.call_args + + datagroup = args[0] + + # Basic structure + assert isinstance(datagroup, sc.DataGroup) + + assert 'Data' in datagroup + assert 'Model' in datagroup + assert 'Residuals' in datagroup + + # Gaussian sample component should also be included + # because plot_components=True by default + component_names = set(datagroup.keys()) - {'Data', 'Model'} + assert len(component_names) > 0 + + # Check plotting defaults + assert kwargs['title'] == analysis1d.display_name + + assert kwargs['linestyle']['Data'] == 'none' + assert kwargs['marker']['Data'] == 'o' + assert kwargs['color']['Data'] == 'black' + + assert kwargs['linestyle']['Model'] == '-' + assert kwargs['color']['Model'] == 'red' + + assert kwargs['linestyle']['Residuals'] == 'none' + assert kwargs['marker']['Residuals'] == 'o' + assert kwargs['color']['Residuals'] == 'blue' + + # Return value propagated + assert result is fake_fig + @pytest.mark.parametrize( 'include_residuals', [True, False], @@ -350,6 +400,14 @@ def test_data_and_model_to_datagroup_include_components_not_bool_raises(self, an ): analysis1d.data_and_model_to_datagroup(include_components='not_a_boolean') + def test_data_and_model_to_datagroup_include_residuals_not_bool_raises(self, analysis1d): + # WHEN / THEN / EXPECT + with pytest.raises( + TypeError, + match=r'include_residuals must be True or False', + ): + analysis1d.data_and_model_to_datagroup(include_residuals='not_a_boolean') + def test_plot_data_and_model_no_Q_index_raises(self, analysis1d): # WHEN analysis1d._Q_index = None From 52f8d8f511cd2bb90521c9f6504e4ab17da43abe Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Wed, 3 Jun 2026 00:28:22 +0200 Subject: [PATCH 10/10] one more test --- tests/unit/easydynamics/analysis/test_analysis1d.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/unit/easydynamics/analysis/test_analysis1d.py b/tests/unit/easydynamics/analysis/test_analysis1d.py index f324afd9..9df8815e 100644 --- a/tests/unit/easydynamics/analysis/test_analysis1d.py +++ b/tests/unit/easydynamics/analysis/test_analysis1d.py @@ -835,6 +835,14 @@ def test_create_residuals_array(self, analysis1d): expected = analysis1d.experiment.binned_data['Q', analysis1d.Q_index] - model assert sc.identical(residuals, expected) + def test_create_residuals_array_no_Q_index_raises(self, analysis1d): + # WHEN + analysis1d._Q_index = None + + # THEN EXPECT + with pytest.raises(ValueError, match='Q_index must be set'): + analysis1d._create_residuals_array() + @pytest.mark.parametrize( 'background', [