diff --git a/docs/_static/plotting/marginals-plot.png b/docs/_static/plotting/marginals-plot.png new file mode 100644 index 000000000..ee338c794 Binary files /dev/null and b/docs/_static/plotting/marginals-plot.png differ diff --git a/docs/index.md b/docs/index.md index 75a57b64c..f4c31013f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,7 +29,6 @@

- ## Getting started ::::{grid} 2 @@ -120,6 +119,12 @@ ::: +:::{grid-item-card} Marginals plot +:link: plotting/marginals.ipynb +:img-bottom: _static/plotting/marginals-plot.png + +::: + :::: ## Custom figures diff --git a/docs/plotting/index.md b/docs/plotting/index.md index c35568dc0..657eebd9f 100644 --- a/docs/plotting/index.md +++ b/docs/plotting/index.md @@ -58,6 +58,12 @@ ::: +:::{grid-item-card} Marginals plot +:link: marginals.ipynb +:img-bottom: ../_static/plotting/marginals-plot.png + +::: + :::: ```{toctree} @@ -73,4 +79,5 @@ super-plot scatter-plot scatter3d-plot mesh3d-plot +marginals ``` diff --git a/docs/plotting/marginals.ipynb b/docs/plotting/marginals.ipynb new file mode 100644 index 000000000..ad5ab1f0c --- /dev/null +++ b/docs/plotting/marginals.ipynb @@ -0,0 +1,106 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Marginals plot\n", + "\n", + "The marginals plot shows a 2-dimensional image together with the 1-dimensional lines obtained from summing over each dimension." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import plopp as pp" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Basic marginals plot\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "da = pp.data.data2d()\n", + "pp.marginals(da)" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## Control the side of the vertical plot\n", + "\n", + "Above, the 1D plot as a function of `y` is placed on the left and its data axis increases from right to left.\n", + "We can alternatively show it on the right. This also results in the axis increasing from left to right as is the more common case:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "pp.marginals(da, side=\"right\")" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "## Extra arguments\n", + "\n", + "`marginals` supports some additional arguments like `title`. It also forward any extra arguments to the underlying plot functions, in this example, `grid`. Note that the arguments are passed to *all* three subplots." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "pp.marginals(da, title=\"My plot\", grid=True)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 2 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython2", + "version": "2.7.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/plopp/__init__.py b/src/plopp/__init__.py index 57a53f09c..def5b8527 100644 --- a/src/plopp/__init__.py +++ b/src/plopp/__init__.py @@ -28,6 +28,7 @@ 'plotting': [ 'DimensionSlicer', 'inspector', + 'marginals', 'mesh3d', 'plot', 'scatter', diff --git a/src/plopp/plotting/__init__.pyi b/src/plopp/plotting/__init__.pyi index 7e82c1cbb..2d3c9c0d2 100644 --- a/src/plopp/plotting/__init__.pyi +++ b/src/plopp/plotting/__init__.pyi @@ -2,6 +2,7 @@ # Copyright (c) 2025 Scipp contributors (https://github.com/scipp) from ._inspector import inspector +from ._marginals import marginals from ._mesh3d import mesh3d from ._plot import plot from ._scatter import scatter @@ -13,6 +14,7 @@ from ._xyplot import xyplot __all__ = [ 'DimensionSlicer', 'inspector', + 'marginals', 'mesh3d', 'plot', 'scatter', diff --git a/src/plopp/plotting/_marginals.py b/src/plopp/plotting/_marginals.py new file mode 100644 index 000000000..85762db59 --- /dev/null +++ b/src/plopp/plotting/_marginals.py @@ -0,0 +1,128 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) + +from functools import partial +from typing import Any, Literal + +import matplotlib.pyplot as plt +import scipp as sc +from mpl_toolkits.axes_grid1 import make_axes_locatable + +from ..backends.matplotlib.utils import make_figure +from ..core import Node +from ..core.typing import FigureLike +from ..graphics import imagefigure, linefigure +from .common import input_to_nodes, preprocess + + +def marginals( + data: sc.DataArray | Node, + *, + side: Literal["left", "right"] = "left", + title: str = "", + cbar: bool = True, + ax: plt.Axes | None = None, + ignore_size: bool = False, + ignore_sorted: bool = False, + coords=None, + **plot_kwargs: Any, +) -> FigureLike: + """Plot 2D data and the sums over each dim as a 1D plot. + + This function plots ``data`` as a 2D image with a colorbar. + It also sums over each of its dimensions and plots the resulting 1D data + next to and above the main image. + + .. versionadded:: 26.11.0 + + Parameters + ---------- + data: + The data to plot. Must be 2-dimensional. + side: + Which side to show the 1D plot along the y-axis. + Note that when ``side == "left"``, the x-axis of the side plot is inverted, + i.e., it increases from right to left. + title: + Title of the plot. + cbar: + Whether to use a colorbar. + ax: + Optional axes to plot into. If None, ``marginals_plot`` creates new Axes. + ignore_size: + If ``True``, skip the check that prevents the rendering of very large data. + ignore_sorted: + Do not check if the coordinates are sorted before plotting. + coords: + If supplied, use these coords instead of the input's dimension coordinates. + **plot_kwargs: + Forwarded to *all* subplots :func:`plopp.graphics.linefigure`, + and :func:`plopp.graphics.imagefigure`. + + Returns + ------- + : + A figure that contains all axes. + """ + if ax is None: + ax_2d = make_figure().subplots() + else: + ax_2d = ax + + divider = make_axes_locatable(ax_2d) + ax_top = divider.append_axes("top", 1.2, pad=0.1, sharex=ax_2d) + ax_side = divider.append_axes(side, 1.2, pad=0.1, sharey=ax_2d) + cax = divider.append_axes("right", "4%", pad="5%") if cbar else None + + main_data_node = input_to_nodes( + data, + processor=partial( + preprocess, + ignore_size=ignore_size, + ignore_sorted=ignore_sorted, + coords=coords, + ), + ) + + if len(main_data_node) > 1: + raise ValueError("The marginals plot accepts only a single data input.") + main_data_node = main_data_node[0] + + fig = imagefigure(main_data_node, ax=ax_2d, cax=cax, cbar=cbar, **plot_kwargs) + + # Main plot (2D) + y_label = ax_2d.get_ylabel() + if side == "left": + ax_2d.yaxis.set_tick_params(labelleft=False) + ax_2d.set_ylabel("") + + # Top Plot + top_node = Node(lambda da: da.sum(da.dims[0]), main_data_node) + linefigure(top_node, ax=ax_top, title=title, **plot_kwargs) + ax_top.xaxis.set_tick_params(labelbottom=False) + ax_top.set_xlabel("") + + # Side plot + def make_side_data(da: sc.DataArray) -> sc.DataArray: + if da.coords.is_edges(da.dims[0]): + raise sc.DimensionError( + "marginals() does not support bin edges along the y-axis." + ) + summed = da.sum(da.dims[1]) + return sc.DataArray( + data=summed.coords[summed.dim], + # rename to work around https://github.com/scipp/plopp/issues/616. + coords={summed.dim: summed.data}, + ).rename({summed.dim: " "}) + + side_node = Node(make_side_data, main_data_node) + linefigure(side_node, ax=ax_side, **plot_kwargs) + ax_side.set_xlabel(ax_top.get_ylabel()) + if side == "left": + ax_side.set_ylabel(y_label) + ax_side.invert_xaxis() + else: + ax_side.yaxis.set_tick_params(labelleft=False) + ax_side.set_ylabel("") + + return fig diff --git a/src/plopp/plotting/_plot.py b/src/plopp/plotting/_plot.py index 59f4a9ecd..2a47ce6fa 100644 --- a/src/plopp/plotting/_plot.py +++ b/src/plopp/plotting/_plot.py @@ -31,6 +31,7 @@ def plot( figsize: tuple[float, float] | None = None, grid: bool = False, ignore_size: bool = False, + ignore_sorted: bool = False, legend: bool | tuple[float, float] = True, hide_log_buttons: bool = False, logc: bool | None = None, @@ -85,6 +86,8 @@ def plot( If ``True``, the interactive log buttons will be hidden. ignore_size: If ``True``, skip the check that prevents the rendering of very large data. + ignore_sorted: + Do not check if the coordinates are sorted before plotting. legend: Show legend if ``True``. If ``legend`` is a tuple, it should contain the ``(x, y)`` coordinates of the legend's anchor point in axes coordinates. @@ -170,7 +173,13 @@ def plot( ) nodes = input_to_nodes( - obj, processor=partial(preprocess, ignore_size=ignore_size, coords=coords) + obj, + processor=partial( + preprocess, + ignore_size=ignore_size, + ignore_sorted=ignore_sorted, + coords=coords, + ), ) ndims = set() diff --git a/src/plopp/plotting/common.py b/src/plopp/plotting/common.py index 60d18fea1..d226f0cf1 100644 --- a/src/plopp/plotting/common.py +++ b/src/plopp/plotting/common.py @@ -231,28 +231,29 @@ def _handle_coords_with_left_over_dimensions(da: sc.DataArray) -> sc.DataArray: ) -def _check_coord_sanity(da: sc.DataArray) -> None: +def _check_coord_sanity(da: sc.DataArray, ignore_sorted: bool = False) -> None: """ Warn if any coordinate is not sorted. This can lead to unpredictable results when plotting. Also, raise an error if any coordinate is scalar. """ for name, coord in da.coords.items(): - try: - if not ( - _all_dims_sorted(coord, order='ascending') - or _all_dims_sorted(coord, order='descending') - ): - warnings.warn( - 'The input contains a coordinate with unsorted values ' - f'({name}). The results may be unpredictable. ' - 'Coordinates can be sorted using ' - '`scipp.sort(data, key="to_be_sorted", order="ascending")`.', - RuntimeWarning, - stacklevel=2, - ) - except sc.DTypeError: - pass + if not ignore_sorted: + try: + if not ( + _all_dims_sorted(coord, order='ascending') + or _all_dims_sorted(coord, order='descending') + ): + warnings.warn( + 'The input contains a coordinate with unsorted values ' + f'({name}). The results may be unpredictable. ' + 'Coordinates can be sorted using ' + '`scipp.sort(data, key="to_be_sorted", order="ascending")`.', + RuntimeWarning, + stacklevel=2, + ) + except sc.DTypeError: + pass if not coord.dims: raise ValueError( @@ -266,6 +267,7 @@ def preprocess( obj: Plottable | list, name: str | None = None, ignore_size: bool = False, + ignore_sorted: bool = False, coords: Iterable[str] | str | None = None, ) -> sc.DataArray: """ @@ -284,6 +286,8 @@ def preprocess( Override the input's name (when a dict-like structure is to be plotted). ignore_size: Do not perform a size check on the object before plotting it. + ignore_sorted: + Do not check if the coordinates are sorted before plotting. coords: If supplied, use these coords instead of the input's dimension coordinates. """ @@ -304,7 +308,7 @@ def preprocess( out = _add_missing_dimension_coords(out) out = _drop_non_dimension_coords(out) out = _handle_coords_with_left_over_dimensions(out) - _check_coord_sanity(out) + _check_coord_sanity(out, ignore_sorted=ignore_sorted) return out diff --git a/tests/plotting/marginals_test.py b/tests/plotting/marginals_test.py new file mode 100644 index 000000000..a913875aa --- /dev/null +++ b/tests/plotting/marginals_test.py @@ -0,0 +1,158 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) + +import matplotlib.pyplot as plt +import numpy as np +import pytest +import scipp as sc + +import plopp as pp +from plopp.data.testing import data_array + +pytestmark = pytest.mark.usefixtures("_parametrize_mpl_backends") + + +def test_marginals_creates_all_axes(): + da = data_array(ndim=2) + fig = pp.marginals(da) + main, _, _, cax = fig.canvas.fig.axes + assert fig.canvas.ax is main + assert fig.canvas.cax is cax + + +def test_marginals_from_node(): + da = data_array(ndim=2) + pp.marginals(pp.Node(da)) + + +def test_marginals_marginal_values(): + da = data_array(ndim=2) + fig = pp.marginals(da) + _, top, side, _ = fig.canvas.fig.axes + top_line = top.lines[0] + side_line = side.lines[0] + xdim, ydim = da.dims[1], da.dims[0] + np.testing.assert_allclose(top_line.get_xdata(), da.coords[xdim].values) + np.testing.assert_allclose(top_line.get_ydata(), da.sum(ydim).values) + np.testing.assert_allclose(side_line.get_xdata(), da.sum(xdim).values) + np.testing.assert_allclose(side_line.get_ydata(), da.coords[ydim].values) + + +def test_marginals_bin_edges_x(): + da = data_array(ndim=2) + da = da.hist({da.dims[1]: 10}) + fig = pp.marginals(da) + _, top, side, _ = fig.canvas.fig.axes + top_line = top.lines[0] + side_line = side.lines[0] + xdim, ydim = da.dims[1], da.dims[0] + np.testing.assert_allclose(top_line.get_xdata(), da.coords[xdim].values) + np.testing.assert_allclose(top_line.get_ydata()[1:], da.sum(ydim).values) + np.testing.assert_allclose(side_line.get_xdata(), da.sum(xdim).values) + np.testing.assert_allclose(side_line.get_ydata(), da.coords[ydim].values) + + +def test_marginals_bin_edges_y_not_supported(): + da = data_array(ndim=2) + da = da.hist({da.dims[0]: 10}).transpose(da.dims) # bin in the first dim + with pytest.raises(sc.DimensionError): + pp.marginals(da) + + +def test_marginals_updates_when_node_changes(): + da = data_array(ndim=2) + node = pp.Node(da) + fig = pp.marginals(node) + _, top, side, _ = fig.canvas.fig.axes + node.func = lambda: da * 2.0 + node.notify_children('updated') + np.testing.assert_allclose( + top.lines[0].get_ydata(), (da * 2.0).sum(da.dims[0]).values + ) + np.testing.assert_allclose( + side.lines[0].get_xdata(), (da * 2.0).sum(da.dims[1]).values + ) + + +def test_marginals_side_left(): + da = data_array(ndim=2) + fig = pp.marginals(da, side='left') + main, top, side, _ = fig.canvas.fig.axes + assert side.xaxis_inverted() + assert not main.xaxis_inverted() + assert main.get_ylabel() == '' + assert side.get_ylabel() == f'{da.dims[0]} [m]' + assert side.get_xlabel() == top.get_ylabel() + assert top.get_xlabel() == '' + assert main.get_xlabel() == f'{da.dims[1]} [m]' + + +def test_marginals_side_right(): + da = data_array(ndim=2) + fig = pp.marginals(da, side='right') + main, top, side, _ = fig.canvas.fig.axes + assert not side.xaxis_inverted() + assert main.get_ylabel() == f'{da.dims[0]} [m]' + assert side.get_ylabel() == '' + assert side.get_xlabel() == top.get_ylabel() + assert top.get_xlabel() == '' + + +def test_marginals_side_axes_position(): + da = data_array(ndim=2) + fig_left = pp.marginals(da, side='left') + fig_right = pp.marginals(da, side='right') + fig_left.canvas.fig.draw_without_rendering() + fig_right.canvas.fig.draw_without_rendering() + main_l, _, side_l, _ = fig_left.canvas.fig.axes + main_r, _, side_r, _ = fig_right.canvas.fig.axes + assert side_l.get_position().x1 <= main_l.get_position().x0 + assert side_r.get_position().x0 >= main_r.get_position().x1 + + +def test_marginals_title(): + da = data_array(ndim=2) + fig = pp.marginals(da, title='My title') + _, top, _, _ = fig.canvas.fig.axes + assert top.get_title() == 'My title' + + +def test_marginals_with_existing_ax(): + da = data_array(ndim=2) + f, ax = plt.subplots() + fig = pp.marginals(da, ax=ax) + assert fig.canvas.ax is ax + assert len(f.axes) == 4 + + +def test_marginals_forwards_kwargs(): + da = data_array(ndim=2) + fig = pp.marginals(da, grid=True) + main, top, side, _ = fig.canvas.fig.axes + for ax in (main, top, side): + assert ax.xaxis.get_gridlines()[0].get_visible() + + +def test_marginals_small_known_values(): + da = sc.DataArray( + data=sc.array(dims=['a', 'b'], values=np.arange(12.0).reshape(3, 4), unit='K'), + coords={ + 'a': sc.arange('a', 3.0, unit='s'), + 'b': sc.arange('b', 4.0, unit='m'), + }, + ) + fig = pp.marginals(da) + _, top, side, _ = fig.canvas.fig.axes + np.testing.assert_allclose(top.lines[0].get_ydata(), [12.0, 15.0, 18.0, 21.0]) + np.testing.assert_allclose(side.lines[0].get_xdata(), [6.0, 22.0, 38.0]) + + +def test_marginals_missing_coords(): + da = data_array(ndim=2) + da.coords.clear() + pp.marginals(da) + + +def test_marginals_numpy_array(): + da = data_array(ndim=2) + pp.marginals(da.values)