diff --git a/docs/docs/tutorials/analysis1d.ipynb b/docs/docs/tutorials/analysis1d.ipynb index 326e4522..fbd2dbfb 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)" ] } ], "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..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." + "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()" + "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..f203dbdc 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", @@ -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)" ] }, { @@ -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/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/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index 56b322c7..c7d01ef8 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 @@ -213,6 +214,7 @@ def plot_data_and_model( Q_index: int | None = None, plot_components: bool = True, add_background: bool = True, + plot_residuals: bool = False, energy: sc.Variable | None = None, **kwargs: dict[str, Any], ) -> InteractiveFigure: @@ -232,6 +234,8 @@ 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. energy : sc.Variable | None, default=None The energy values to use for calculating the model. If None, uses the energy from the experiment. @@ -259,6 +263,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, ) @@ -280,6 +285,9 @@ 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 energy is None: energy = self.energy @@ -289,6 +297,7 @@ def plot_data_and_model( energy=energy, add_background=add_background, include_components=plot_components, + include_residuals=plot_residuals, ) plot_kwargs_defaults = { @@ -313,6 +322,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 @@ -320,12 +335,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 @@ -334,6 +358,7 @@ def data_and_model_to_datagroup( energy: sc.Variable | None = None, add_background: bool = True, include_components: bool = True, + include_residuals: bool = False, ) -> sc.DataGroup: """ Create a scipp DataGroup containing the experimental data, model calculation and optionally @@ -350,6 +375,8 @@ 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. Raises ------ @@ -380,6 +407,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.') + energy = self._verify_energy(energy) if energy is None: @@ -397,6 +427,9 @@ 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() + return sc.DataGroup(data_and_model) def parameters_to_dataset(self) -> sc.Dataset: @@ -736,6 +769,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..480b5fd0 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): @@ -269,6 +270,7 @@ def plot_data_and_model( self, plot_components: bool = True, add_background: bool = True, + plot_residuals: bool = False, energy: sc.Variable | None = None, **kwargs: dict[str, Any], ) -> InteractiveFigure: @@ -285,6 +287,8 @@ 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). 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 +306,7 @@ def plot_data_and_model( energy=energy, add_background=add_background, include_components=plot_components, + include_residuals=plot_residuals, ) plot_kwargs_defaults = { @@ -325,6 +330,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 @@ -332,16 +343,27 @@ 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, energy: sc.Variable | None = None, add_background: bool = True, include_components: bool = True, + include_residuals: bool = False, ) -> sc.DataGroup: """ Create a scipp DataGroup containing the experimental data, model calculation, and @@ -359,6 +381,9 @@ 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. Raises ------ @@ -390,6 +415,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 +440,10 @@ def data_and_model_to_datagroup( for key in components: data_and_model[key] = components[key] + if include_residuals: + residuals = self._create_residuals_array() + data_and_model['Residuals'] = residuals + return sc.DataGroup(data_and_model) def fix_energy_offset(self) -> None: @@ -844,6 +876,28 @@ 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.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._create_model_array() + return data.copy(deep=True) - model + def _create_components_dataset_single_Q( self, add_background: bool = True, 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..e1687c66 --- /dev/null +++ b/src/easydynamics/utils/plotting.py @@ -0,0 +1,93 @@ +# 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. + + 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, + 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 diff --git a/tests/unit/easydynamics/analysis/test_analysis.py b/tests/unit/easydynamics/analysis/test_analysis.py index 209a6d92..7e76bef2 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' @@ -276,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', [ @@ -353,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) @@ -364,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 @@ -402,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( @@ -771,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 2219864d..9df8815e 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() @@ -281,12 +281,69 @@ 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): + 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], + 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 +354,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 @@ -335,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 @@ -743,6 +816,33 @@ 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) + + 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', [ 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()