diff --git a/src/easydynamics/utils/plotting.py b/src/easydynamics/utils/plotting.py index 52360b51..bfe66627 100644 --- a/src/easydynamics/utils/plotting.py +++ b/src/easydynamics/utils/plotting.py @@ -5,6 +5,7 @@ import plopp as pp import scipp as sc from plopp.backends.matplotlib.figure import WidgetFigure +from plopp.backends.matplotlib.utils import make_figure from plopp.plotting._slicer import SlicerPlot from plopp.plotting._slicer import _maybe_reduce_dim from plopp.widgets import slice_dims @@ -76,10 +77,23 @@ def slicerplot_with_residuals( if residuals_key not in dg: raise ValueError(f"Residuals key '{residuals_key}' not found in DataGroup") + # Draw the data and the residuals as two axes of one Matplotlib figure with a shared x axis. + # Two separate figures are laid out independently, so their axes only line up when their y + # tick labels and y labels happen to be equally wide. + figsize = kwargs.pop('figsize', None) + mpl_fig = make_figure(figsize=(6.0, 6.0) if figsize is None else figsize) + main_ax, res_ax = mpl_fig.subplots( + nrows=2, + ncols=1, + sharex=True, + gridspec_kw={'height_ratios': [2, 1]}, + ) + sp = SlicerPlot( {k: da for k, da in dg.items() if k != residuals_key}, keep=keep, operation=operation, + ax=main_ax, **kwargs, ) @@ -92,15 +106,13 @@ def slicerplot_with_residuals( 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' + # The log buttons of the main plot also apply to the shared x axis, and a log y axis makes + # no sense for residuals, which can be negative. + res_fig = pp.linefigure(reduce_res, ax=res_ax, hide_log_buttons=True, **res_fig_kwargs) 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() diff --git a/tests/unit/easydynamics/utils/test_plotting.py b/tests/unit/easydynamics/utils/test_plotting.py index 5214d0da..325d36ae 100644 --- a/tests/unit/easydynamics/utils/test_plotting.py +++ b/tests/unit/easydynamics/utils/test_plotting.py @@ -139,3 +139,79 @@ def test_slicerplot_with_residuals_hides_top_x_axis(self, datagroup): 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() + + @pytest.mark.parametrize( + ('figsize', 'expected_figsize'), + [ + (None, (6.0, 6.0)), + ((8.0, 5.0), (8.0, 5.0)), + ], + ids=['Default', 'Custom'], + ) + def test_slicerplot_with_residuals_draws_both_panels_in_one_figure( + self, datagroup, figsize, expected_figsize + ): + # WHEN + mock_sp = MagicMock() + mock_sp.slicer.keep = ['x'] + kwargs = {} if figsize is None else {'figsize': figsize} + + with ( + patch( + 'easydynamics.utils.plotting.SlicerPlot', + return_value=mock_sp, + ) as slicer_plot, + patch( + 'easydynamics.utils.plotting.pp.linefigure', return_value=MagicMock() + ) as linefigure, + patch('easydynamics.utils.plotting.slice_dims'), + patch('easydynamics.utils.plotting.pp.Node'), + ): + # THEN + slicerplot_with_residuals(datagroup, **kwargs) + + # EXPECT the data and residuals axes belong to one figure and share their x axis + main_ax = slicer_plot.call_args.kwargs['ax'] + res_ax = linefigure.call_args.kwargs['ax'] + + assert main_ax.get_figure() is res_ax.get_figure() + assert main_ax.get_shared_x_axes().joined(main_ax, res_ax) + assert tuple(main_ax.get_figure().get_size_inches()) == expected_figsize + assert 'figsize' not in slicer_plot.call_args.kwargs + assert linefigure.call_args.kwargs['hide_log_buttons'] is True + + def test_slicerplot_with_residuals_aligns_data_and_residuals_axes(self): + # WHEN the plopp slicer needs an interactive matplotlib backend, switched in for the + # test, and the two panels have y tick labels of different widths + import matplotlib.pyplot as plt + + energy = sc.linspace('energy', -1.0, 1.0, 11, unit='meV') + Q = sc.array(dims=['Q'], values=[0.5, 1.0], unit='1/angstrom') + + def data_array(values): + return sc.DataArray( + sc.array(dims=['Q', 'energy'], values=[values, values]), + coords={'energy': energy, 'Q': Q}, + ) + + datagroup = sc.DataGroup( + Data=data_array([1000.0 * (i + 1) for i in range(11)]), + Residuals=data_array([0.1 * (-1) ** i for i in range(11)]), + ) + + plt.switch_backend('module://ipympl.backend_nbagg') + try: + # THEN + fig = slicerplot_with_residuals(datagroup, keep='energy') + fig.canvas.to_widget() + + # EXPECT the axes span the same x range on screen and in data + main_ax, res_ax = fig.canvas.fig.axes + main_box = main_ax.get_window_extent() + res_box = res_ax.get_window_extent() + + assert main_box.x0 == pytest.approx(res_box.x0) + assert main_box.x1 == pytest.approx(res_box.x1) + assert main_ax.get_xlim() == res_ax.get_xlim() + finally: + plt.switch_backend('Agg')