diff --git a/docs/docs/tutorials/tutorial1_brownian.ipynb b/docs/docs/tutorials/tutorial1_brownian.ipynb index 5562adde..b09546b7 100644 --- a/docs/docs/tutorials/tutorial1_brownian.ipynb +++ b/docs/docs/tutorials/tutorial1_brownian.ipynb @@ -76,7 +76,9 @@ "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`." + "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`" ] }, { @@ -86,7 +88,7 @@ "metadata": {}, "outputs": [], "source": [ - "vanadium_experiment.plot_data(slicer=False)" + "vanadium_experiment.plot_data(slicer=False, transpose_axes=False)" ] }, { diff --git a/pixi.lock b/pixi.lock index 1665d1a6..44c70390 100644 --- a/pixi.lock +++ b/pixi.lock @@ -4071,8 +4071,8 @@ packages: requires_python: '>=3.5' - pypi: ./ name: easydynamics - version: 0.4.0+devdirty10 - sha256: bd1d44f7263fe45e52e8b62d2740c303be86c7bcc89e3cbec95ec663568953b1 + version: 0.4.0+devdirty2 + sha256: aa4f4851802d4abba9dca0112aa3c99739e1c43425b48d91030f822baa577257 requires_dist: - darkdetect - easyscience diff --git a/pyproject.toml b/pyproject.toml index 49f35979..954af4af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -256,8 +256,7 @@ select = [ # Ignore specific rules globally ignore = [ 'COM812', # https://docs.astral.sh/ruff/rules/missing-trailing-comma/ - # The following is replaced by 'D'/[tool.ruff.lint.pydocstyle] and [tool.pydoclint] - 'DOC', # https://docs.astral.sh/ruff/rules/#pydoclint-doc + # The following is replaced by 'D'/[tool.ruff.lint.pydocstyle] and [tool.pydoclint] 'DOC', # https://docs.astral.sh/ruff/rules/#pydoclint-doc # Disable, as [tool.format_docstring] split one-line docstrings into the canonical multi-line layout 'D200', # https://docs.astral.sh/ruff/rules/unnecessary-multiline-docstring/ ] diff --git a/src/easydynamics/experiment/experiment.py b/src/easydynamics/experiment/experiment.py index 383f6c06..0d305400 100644 --- a/src/easydynamics/experiment/experiment.py +++ b/src/easydynamics/experiment/experiment.py @@ -8,6 +8,7 @@ import plopp as pp import scipp as sc from easyscience.base_classes.new_base import NewBase +from plopp.backends.matplotlib.figure import InteractiveFigure from scipp.io import load_hdf5 as sc_load_hdf5 from scipp.io import save_hdf5 as sc_save_hdf5 @@ -146,9 +147,9 @@ def Q(self) -> sc.Variable | None: sc.Variable | None The Q values from the dataset, or None if no data is loaded. """ - if self._binned_data is None: + if self.binned_data is None: return None - return self._binned_data.coords['Q'] + return self.binned_data.coords['Q'] @Q.setter def Q(self, _value: sc.Variable) -> None: @@ -179,9 +180,9 @@ def energy(self) -> sc.Variable | None: sc.Variable | None The energy values from the dataset, or None if no data is loaded. """ - if self._binned_data is None: + if self.binned_data is None: return None - return self._binned_data.coords['energy'] + return self.binned_data.coords['energy'] @energy.setter def energy(self, _value: sc.Variable) -> None: @@ -222,7 +223,7 @@ def get_masked_energy(self, Q_index: int) -> sc.Variable | None: sc.Variable | None The masked energy values from the dataset, or None if no data is loaded. """ - if self._binned_data is None: + if self.binned_data is None: return None if ( @@ -232,7 +233,7 @@ def get_masked_energy(self, Q_index: int) -> sc.Variable | None: ): raise IndexError('Q_index must be a valid index for the Q values.') - energy = self._binned_data.coords['energy'] + energy = self.binned_data.coords['energy'] _, _, _, mask = self._extract_x_y_weights_only_finite(Q_index=Q_index) mask_var = sc.array(dims=['energy'], values=mask) @@ -372,7 +373,12 @@ def rebin(self, dimensions: dict[str, int | sc.Variable]) -> None: # other methods ########### - def plot_data(self, slicer: bool = False, **kwargs: dict) -> None: + def plot_data( + self, + slicer: bool = False, + transpose_axes: bool = False, + **kwargs: dict, + ) -> InteractiveFigure: """ Plot the dataset using plopp: https://scipp.github.io/plopp/. @@ -380,23 +386,41 @@ def plot_data(self, slicer: bool = False, **kwargs: dict) -> None: ---------- slicer : bool, default=False If True, use plopp's slicer instead of plot. + transpose_axes : bool, default=False + If True, transpose the data to have dimensions in the order (energy, Q) before + plotting, so that energy is on the x-axis. This only applies when slicer=False. **kwargs : dict Additional keyword arguments to pass to plopp. + Returns + ------- + InteractiveFigure + A plot of the data and model. + Raises ------ ValueError If there is no data to plot. RuntimeError If not in a Jupyter notebook environment. + TypeError + If slicer or transpose_axes are not True or False. """ - if self._binned_data is None: + if self.binned_data is None: raise ValueError('No data to plot. Please load data first.') if not _in_notebook(): raise RuntimeError('plot_data() can only be used in a Jupyter notebook environment.') + if not isinstance(slicer, bool): + raise TypeError(f'slicer must be True or False, not {type(slicer).__name__}') + + if not isinstance(transpose_axes, bool): + raise TypeError( + f'transpose_axes must be True or False, not {type(transpose_axes).__name__}' + ) + plot_kwargs_defaults = { 'title': self.display_name, } @@ -408,15 +432,19 @@ def plot_data(self, slicer: bool = False, **kwargs: dict) -> None: plot_kwargs_defaults.update(kwargs) if slicer: fig = pp.slicer( - self._binned_data, + self.binned_data, **plot_kwargs_defaults, ) for widget in fig.bottom_bar[0].controls.values(): widget.slider_toggler.value = '-o-' else: + if transpose_axes: + data_to_plot = self.binned_data.transpose(dims=['energy', 'Q']) + else: + data_to_plot = self.binned_data.transpose(dims=['Q', 'energy']) fig = pp.plot( - self._binned_data.transpose(dims=['energy', 'Q']), + data_to_plot, **plot_kwargs_defaults, ) return fig diff --git a/tests/unit/easydynamics/analysis/test_analysis.py b/tests/unit/easydynamics/analysis/test_analysis.py index 291e1811..25274a55 100644 --- a/tests/unit/easydynamics/analysis/test_analysis.py +++ b/tests/unit/easydynamics/analysis/test_analysis.py @@ -260,6 +260,9 @@ def test_plot_data_and_model_defaults(self, analysis): fake_fig.bottom_bar = [MagicMock()] fake_fig.bottom_bar[0].controls = {'test': fake_widget} + fake_data = MagicMock() + fake_data.coords = {'Q': 'Q_VALUES', 'energy': 'ENERGY_VALUES'} + analysis._create_model_array = MagicMock(return_value='MODEL') with ( patch('plopp.slicer', return_value=fake_fig) as mock_slicer, @@ -270,7 +273,7 @@ def test_plot_data_and_model_defaults(self, analysis): ) as mock_binned, patch('easydynamics.analysis.analysis._in_notebook', return_value=True), ): - mock_binned.return_value = 'DATA' + mock_binned.return_value = fake_data # THEN fig = analysis.plot_data_and_model(plot_components=False) @@ -284,7 +287,7 @@ def test_plot_data_and_model_defaults(self, analysis): assert 'Data' in data_passed assert 'Model' in data_passed - assert data_passed['Data'] == 'DATA' + assert data_passed['Data'] == fake_data assert data_passed['Model'] == 'MODEL' # Check the default kwargs @@ -310,6 +313,9 @@ def test_plot_data_and_model_plot_components_true(self, analysis): fake_fig.bottom_bar = [MagicMock()] fake_fig.bottom_bar[0].controls = {'test': fake_widget} + fake_data = MagicMock() + fake_data.coords = {'Q': 'Q_VALUES', 'energy': 'ENERGY_VALUES'} + analysis._create_model_array = MagicMock(return_value='MODEL') analysis._create_components_dataset = MagicMock(return_value={'Gaussian': 'GAUSS'}) with ( @@ -321,7 +327,7 @@ def test_plot_data_and_model_plot_components_true(self, analysis): ) as mock_binned, patch('easydynamics.analysis.analysis._in_notebook', return_value=True), ): - mock_binned.return_value = 'DATA' + mock_binned.return_value = fake_data # THEN fig = analysis.plot_data_and_model(plot_components=True) @@ -335,7 +341,7 @@ def test_plot_data_and_model_plot_components_true(self, analysis): assert 'Data' in data_passed assert 'Model' in data_passed - assert data_passed['Data'] == 'DATA' + assert data_passed['Data'] == fake_data assert data_passed['Model'] == 'MODEL' # Check the default kwargs assert kwargs['title'] == 'TestAnalysis' diff --git a/tests/unit/easydynamics/experiment/test_experiment.py b/tests/unit/easydynamics/experiment/test_experiment.py index 4568580e..8430d13f 100644 --- a/tests/unit/easydynamics/experiment/test_experiment.py +++ b/tests/unit/easydynamics/experiment/test_experiment.py @@ -332,7 +332,15 @@ def test_get_masked_energy_invalid_Q_index_raises(self, experiment_with_data, Q_ # test plotting ############## - def test_plot_data_success(self, experiment): + @pytest.mark.parametrize( + 'transpose_axes, expected_dims', + [ + (False, ['Q', 'energy']), + (True, ['energy', 'Q']), + ], + ids=['no_transpose', 'transpose'], + ) + def test_plot_data_success(self, experiment, transpose_axes, expected_dims): "Test plotting data successfully when in notebook environment" # WHEN with ( @@ -343,12 +351,12 @@ def test_plot_data_success(self, experiment): mock_plot.return_value = mock_fig # THEN - result = experiment.plot_data() + result = experiment.plot_data(transpose_axes=transpose_axes) # EXPECT mock_plot.assert_called_once() args, kwargs = mock_plot.call_args - assert sc.identical(args[0], experiment.data.transpose()) + assert sc.identical(args[0], experiment.data.transpose(dims=expected_dims)) assert kwargs['title'] == f'{experiment.display_name}' assert result == mock_fig @@ -395,6 +403,25 @@ def test_plot_data_not_in_notebook_raises(self, experiment): ): experiment.plot_data() + def test_plot_data_invalid_slicer_type_raises(self, experiment): + "Test plotting data raises TypeError when slicer argument is invalid" + # WHEN THEN EXPECT + + with ( + patch(f'{Experiment.__module__}._in_notebook', return_value=True), + pytest.raises(TypeError, match='slicer must be True or False'), + ): + experiment.plot_data(slicer='not_a_boolean') + + def test_plot_data_invalid_transpose_type_raises(self, experiment): + "Test plotting data raises TypeError when transpose argument is invalid" + # WHEN THEN EXPECT + with ( + patch(f'{Experiment.__module__}._in_notebook', return_value=True), + pytest.raises(TypeError, match='transpose_axes must be True or False'), + ): + experiment.plot_data(transpose_axes='not_a_boolean') + ############## # test private methods ##############