From dc7f2167633dc5f3e59b336d95402cde1554827a Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 26 Sep 2026 20:57:38 +0200 Subject: [PATCH 1/5] Added xarray support --- pyproject.toml | 4 + src/maxplotlib/canvas/canvas.py | 19 ++- src/maxplotlib/subfigure/line_plot.py | 53 +++++++- src/maxplotlib/tests/test_xarray.py | 178 +++++++++++++++++++++++++ src/maxplotlib/utils/xarray_support.py | 92 +++++++++++++ 5 files changed, 337 insertions(+), 9 deletions(-) create mode 100644 src/maxplotlib/tests/test_xarray.py create mode 100644 src/maxplotlib/utils/xarray_support.py diff --git a/pyproject.toml b/pyproject.toml index e7c49cf..1832aa9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,10 @@ dependencies = [ test = [ "pytest", "coverage", + "xarray", +] +xarray = [ + "xarray", ] docs = [ "myst-parser", diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py index 43a882c..f927d8f 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -25,6 +25,7 @@ _tikz_step_coordinates, _tikz_style_kwargs, ) +from maxplotlib.utils import xarray_support from maxplotlib.utils.options import Backends @@ -856,14 +857,18 @@ def contourf( def pcolormesh( self, x, - y, - z, + y=None, + z=None, layer=0, row: int | None = None, col: int | None = None, **kwargs, ): - """Add a pseudocolor mesh to a subplot.""" + """Add a pseudocolor mesh to a subplot. + + ``pcolormesh(da)`` accepts a 2-D ``xarray.DataArray``; see + :meth:`LinePlot.pcolormesh`. + """ self._get_or_create_subplot(row, col).pcolormesh(x, y, z, layer=layer, **kwargs) def hexbin( @@ -2082,10 +2087,18 @@ def plot(self, *args, backend=None, **kwargs): """Add a line, or render when called with backend options. ``canvas.plot(x, y, **style)`` is the convenient direct plotting form. + ``canvas.plot(da)`` plots a 1-D ``xarray.DataArray`` against its + coordinate, labelling the axes from its attributes. Rendering is named explicitly by ``canvas.render(...)``; the legacy ``canvas.plot(backend=...)`` form remains supported. """ explicit_render = backend is not None or (args and isinstance(args[0], str)) + if len(args) == 1 and xarray_support.is_dataarray(args[0]): + layer = kwargs.pop("layer", 0) + row = kwargs.pop("row", None) + col = kwargs.pop("col", None) + self._get_or_create_subplot(row, col).plot(args[0], layer=layer, **kwargs) + return self if args and not isinstance(args[0], str): if len(args) < 2: raise TypeError("plot(x, y) requires both x and y data") diff --git a/src/maxplotlib/subfigure/line_plot.py b/src/maxplotlib/subfigure/line_plot.py index 346db44..375ad4a 100644 --- a/src/maxplotlib/subfigure/line_plot.py +++ b/src/maxplotlib/subfigure/line_plot.py @@ -6,6 +6,8 @@ from mpl_toolkits.axes_grid1 import make_axes_locatable from tikzfigure import TikzFigure +from maxplotlib.utils import xarray_support + # Keyword arguments that every drawing method accepts and that are handled by # maxplotlib itself rather than being forwarded to a backend drawing call. _NEUTRAL_KWARGS = ("hover", "meta") @@ -388,10 +390,26 @@ def add_line( } self._add(ld, layer) - def plot(self, x, y, layer=0, **kwargs): - """Matplotlib-style alias for :meth:`add_line`.""" + def plot(self, x, y=None, layer=0, **kwargs): + """Matplotlib-style alias for :meth:`add_line`. + + ``plot(da)`` with a 1-D ``xarray.DataArray`` plots it against its + coordinate and labels axes that have no label yet. + """ + if y is None: + if not xarray_support.is_dataarray(x): + raise TypeError("plot(x, y) requires both x and y data") + x, y, xlabel, ylabel = xarray_support.line_data(x) + self._set_default_labels(xlabel, ylabel) self.add_line(x, y, layer=layer, **kwargs) + def _set_default_labels(self, xlabel, ylabel): + """Set axis labels that the user has not set.""" + if not self._xlabel and xlabel: + self._xlabel = xlabel + if not self._ylabel and ylabel: + self._ylabel = ylabel + def scatter(self, x, y, layer=0, **kwargs): """ Add a scatter plot to the subplot. @@ -608,8 +626,28 @@ def contourf(self, x, y, z, layer=0, **kwargs): layer, ) - def pcolormesh(self, x, y, z, layer=0, **kwargs): - """Add a pseudocolor mesh.""" + def pcolormesh(self, x, y=None, z=None, layer=0, **kwargs): + """Add a pseudocolor mesh. + + ``pcolormesh(da)`` with a 2-D ``xarray.DataArray`` uses its coordinates + and labels axes that have no label yet. ``xdim=``/``ydim=`` pick which + dimension goes on each axis, and a labelled colorbar is added unless + ``add_colorbar=False``. + """ + if xarray_support.is_dataarray(x): + if y is not None or z is not None: + raise TypeError("pcolormesh(da) takes no y or z data") + add_colorbar = kwargs.pop("add_colorbar", True) + x, y, z, xlabel, ylabel, zlabel = xarray_support.mesh_data( + x, x=kwargs.pop("xdim", None), y=kwargs.pop("ydim", None) + ) + self._set_default_labels(xlabel, ylabel) + self.pcolormesh(x, y, z, layer=layer, **kwargs) + if add_colorbar: + self.add_colorbar(label=zlabel, layer=layer) + return + if y is None or z is None: + raise TypeError("pcolormesh(x, y, z) requires x, y and z data") self._add( { "x": x, @@ -1520,7 +1558,9 @@ def plot_matplotlib( ax.contourf(line["x"], line["y"], line["z"], **line["kwargs"]) ) elif line["plot_type"] == "pcolormesh": - ax.pcolormesh(line["x"], line["y"], line["z"], **line["kwargs"]) + im = ax.pcolormesh( + line["x"], line["y"], line["z"], **line["kwargs"] + ) elif line["plot_type"] == "hexbin": ax.hexbin(line["x"], line["y"], **line["kwargs"]) elif line["plot_type"] == "matshow": @@ -1725,7 +1765,7 @@ def plot_matplotlib( elif line["plot_type"] == "colorbar": divider = make_axes_locatable(ax) cax = divider.append_axes("right", size="5%", pad=0.05) - plt.colorbar(im, cax=cax, label="Potential (V)") + plt.colorbar(im, cax=cax, label=line["label"]) if "source_artist_id" in line: created = [ @@ -2739,6 +2779,7 @@ def bar_marker(kwargs): showscale=kwargs.get("colorbar", True), ) ) + last_heatmap_idx = len(traces) - 1 elif plot_type in ("pcolor", "pcolorfast"): # Plotly's heatmap is the closest equivalent to Matplotlib's # pseudocolor artists. The cell-centered rendering differs diff --git a/src/maxplotlib/tests/test_xarray.py b/src/maxplotlib/tests/test_xarray.py new file mode 100644 index 0000000..0851838 --- /dev/null +++ b/src/maxplotlib/tests/test_xarray.py @@ -0,0 +1,178 @@ +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt +import numpy as np +import pytest + +xr = pytest.importorskip("xarray") + +from maxplotlib import Canvas # noqa: E402 +from maxplotlib.utils import xarray_support # noqa: E402 + + +def _line(): + t = np.linspace(0, 1, 5) + return xr.DataArray( + t**2, + dims="t", + coords={"t": ("t", t, {"long_name": "Time", "units": "s"})}, + name="energy", + attrs={"units": "J"}, + ) + + +def _mesh(): + x = np.linspace(0, 1, 4) + y = np.linspace(0, 2, 3) + return xr.DataArray( + np.arange(12.0).reshape(3, 4), + dims=("y", "x"), + coords={"x": ("x", x, {"units": "m"}), "y": y}, + name="phi", + attrs={"long_name": "Potential", "units": "V"}, + ) + + +def test_is_dataarray(): + assert xarray_support.is_dataarray(_line()) + assert not xarray_support.is_dataarray(np.zeros(3)) + assert not xarray_support.is_dataarray(_line().to_dataset()) + + +def test_labels_from_attrs(): + da = _mesh() + assert xarray_support.value_label(da) == "Potential [V]" + assert xarray_support.coord_label(da, "x") == "x [m]" + assert xarray_support.coord_label(da, "y") == "y" + assert xarray_support.value_label(xr.DataArray([1.0])) == "" + assert ( + xarray_support.value_label(xr.DataArray([1.0], attrs={"units": "K"})) == "[K]" + ) + + +def test_dimension_without_coordinate_uses_index(): + da = xr.DataArray([3.0, 4.0, 5.0], dims="i") + x, y, xlabel, ylabel = xarray_support.line_data(da) + np.testing.assert_array_equal(x, [0, 1, 2]) + assert xlabel == "i" + + +def test_canvas_plot_dataarray_matplotlib(): + da = _line() + canvas = Canvas() + assert canvas.plot(da, color="red") is canvas + + fig, ax = canvas.get_matplotlib_figaxs() + ax = np.ravel(ax)[0] + line = ax.get_lines()[0] + np.testing.assert_allclose(line.get_xdata(), da.t.values) + np.testing.assert_allclose(line.get_ydata(), da.values) + assert ax.get_xlabel() == "Time [s]" + assert ax.get_ylabel() == "energy [J]" + plt.close(fig) + + +def test_subplot_plot_dataarray(): + canvas, ax = Canvas.subplots() + ax.plot(_line()) + fig = canvas.render(backend="plotly") + assert fig.layout.xaxis.title.text == "Time [s]" + np.testing.assert_allclose(fig.data[0].y, _line().values) + + +def test_explicit_labels_win_regardless_of_order(): + canvas, ax = Canvas.subplots() + ax.set_xlabel("before") + ax.plot(_line()) + ax.set_ylabel("after") + fig, axes = canvas.get_matplotlib_figaxs() + axes = np.ravel(axes)[0] + assert axes.get_xlabel() == "before" + assert axes.get_ylabel() == "after" + plt.close(fig) + + +def test_plot_rejects_wrong_ndim(): + canvas = Canvas() + with pytest.raises(ValueError, match="1-D DataArray.*sel"): + canvas.plot(_mesh()) + with pytest.raises(TypeError, match="both x and y"): + canvas.plot(np.arange(3)) + + +def test_plot_x_y_arrays_still_work_with_dataarrays(): + da = _line() + canvas = Canvas() + canvas.plot(da.t, da) + fig, ax = canvas.get_matplotlib_figaxs() + ax = np.ravel(ax)[0] + np.testing.assert_allclose(ax.get_lines()[0].get_ydata(), da.values) + assert ax.get_xlabel() == "" + plt.close(fig) + + +def test_pcolormesh_dataarray_matplotlib(): + da = _mesh() + canvas = Canvas() + canvas.pcolormesh(da, cmap="magma") + fig, axes = canvas.get_matplotlib_figaxs() + ax = np.ravel(axes)[0] + assert ax.get_xlabel() == "x [m]" + assert ax.get_ylabel() == "y" + mesh = ax.collections[0] + assert mesh.get_cmap().name == "magma" + np.testing.assert_allclose(mesh.get_array().reshape(3, 4), da.values) + colorbars = [a for a in fig.axes if a is not ax] + assert len(colorbars) == 1 + assert colorbars[0].get_ylabel() == "Potential [V]" + plt.close(fig) + + +def test_pcolormesh_dataarray_transpose_and_no_colorbar(): + da = _mesh() + canvas = Canvas() + canvas.pcolormesh(da, xdim="y", add_colorbar=False) + fig, axes = canvas.get_matplotlib_figaxs() + ax = np.ravel(axes)[0] + assert ax.get_xlabel() == "y" + assert ax.get_ylabel() == "x [m]" + np.testing.assert_allclose(ax.collections[0].get_array().reshape(4, 3), da.values.T) + assert len(fig.axes) == 1 + plt.close(fig) + + +def test_pcolormesh_dataarray_plotly(): + da = _mesh() + canvas = Canvas() + canvas.pcolormesh(da) + fig = canvas.render(backend="plotly") + heatmap = fig.data[0] + np.testing.assert_allclose(heatmap.z, da.values) + np.testing.assert_allclose(heatmap.x, da.x.values) + assert heatmap.colorbar.title.text == "Potential [V]" + assert fig.layout.xaxis.title.text == "x [m]" + + +def test_pcolormesh_dataarray_errors(): + canvas = Canvas() + with pytest.raises(ValueError, match="2-D DataArray"): + canvas.pcolormesh(_line()) + with pytest.raises(ValueError, match="not a dimension"): + canvas.pcolormesh(_mesh(), xdim="t") + with pytest.raises(ValueError, match="different"): + canvas.pcolormesh(_mesh(), xdim="x", ydim="x") + with pytest.raises(TypeError, match="no y or z"): + canvas.pcolormesh(_mesh(), np.arange(3)) + with pytest.raises(TypeError, match="requires x, y and z"): + canvas.pcolormesh(np.arange(3), np.arange(3)) + + +def test_imshow_colorbar_uses_given_label(): + canvas = Canvas() + canvas.imshow(np.arange(4.0).reshape(2, 2)) + canvas.colorbar(label="density") + fig, axes = canvas.get_matplotlib_figaxs() + assert fig.axes[-1].get_ylabel() == "density" + plt.close(fig) diff --git a/src/maxplotlib/utils/xarray_support.py b/src/maxplotlib/utils/xarray_support.py new file mode 100644 index 0000000..eeb2672 --- /dev/null +++ b/src/maxplotlib/utils/xarray_support.py @@ -0,0 +1,92 @@ +"""Optional support for plotting labelled ``xarray.DataArray`` objects. + +xarray is never imported here: an object can only be a ``DataArray`` if the +caller has already imported xarray, so detection looks it up in +``sys.modules``. Install it with ``pip install maxplotlibx[xarray]``. +""" + +import sys + +import numpy as np + + +def is_dataarray(obj) -> bool: + """Return True if ``obj`` is an ``xarray.DataArray``.""" + xr = sys.modules.get("xarray") + return xr is not None and isinstance(obj, xr.DataArray) + + +def _label(name, attrs) -> str: + """Build ``"long_name [units]"`` from CF-style attributes.""" + label = attrs.get("long_name") or attrs.get("standard_name") or name or "" + units = attrs.get("units", "") + if units: + return f"{label} [{units}]" if label else f"[{units}]" + return str(label) + + +def value_label(da) -> str: + """Label for the values of ``da``.""" + return _label(da.name, da.attrs) + + +def coord_label(da, dim) -> str: + """Label for the coordinate ``dim`` of ``da`` (the dimension name if none).""" + if dim in da.coords: + return _label(dim, da.coords[dim].attrs) + return str(dim) + + +def coord_values(da, dim): + """Values of the coordinate ``dim``, or ``0..n-1`` if it has none.""" + if dim in da.coords: + return np.asarray(da.coords[dim].values) + return np.arange(da.sizes[dim]) + + +def _require_ndim(da, ndim, method): + if da.ndim != ndim: + raise ValueError( + f"{method}() needs a {ndim}-D DataArray, got {da.ndim}-D with dims " + f"{da.dims}. Select a slice first, e.g. da.sel(...) or da.isel(...)." + ) + + +def line_data(da): + """Return ``(x, y, xlabel, ylabel)`` for a 1-D DataArray.""" + _require_ndim(da, 1, "plot") + (dim,) = da.dims + return ( + coord_values(da, dim), + np.asarray(da.values), + coord_label(da, dim), + value_label(da), + ) + + +def mesh_data(da, x=None, y=None): + """Return ``(x, y, z, xlabel, ylabel, zlabel)`` for a 2-D DataArray. + + Like ``xarray.DataArray.plot.pcolormesh``, the first dimension goes on the + y-axis and the second on the x-axis unless ``x`` or ``y`` names a dimension. + """ + _require_ndim(da, 2, "pcolormesh") + for name in (x, y): + if name is not None and name not in da.dims: + raise ValueError(f"{name!r} is not a dimension of {da.dims}") + if x is None and y is None: + y, x = da.dims + elif x is None: + (x,) = [d for d in da.dims if d != y] + elif y is None: + (y,) = [d for d in da.dims if d != x] + if x == y: + raise ValueError("x and y must be different dimensions") + return ( + coord_values(da, x), + coord_values(da, y), + np.asarray(da.transpose(y, x).values), + coord_label(da, x), + coord_label(da, y), + value_label(da), + ) From c8e25639469e4291189fedba67f5db87e23f7bf7 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 26 Sep 2026 21:24:10 +0200 Subject: [PATCH 2/5] small edits like adding titles and so on --- src/maxplotlib/canvas/canvas.py | 182 +++++++++++++- src/maxplotlib/subfigure/line_plot.py | 175 +++++++++++--- src/maxplotlib/tests/test_xarray.py | 315 +++++++++++++++++++++++++ src/maxplotlib/utils/xarray_support.py | 172 +++++++++++--- 4 files changed, 770 insertions(+), 74 deletions(-) diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py index f927d8f..f146069 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -371,6 +371,7 @@ def __init__( self._supylabel_kwargs: dict = {} self._subplots_adjust_kwargs: dict = {} self._tight_layout_kwargs: dict | None = None + self._hide_empty_subplots = False self._set_tight_layout = None self._align_labels = False self._align_titles = False @@ -487,6 +488,148 @@ def subplots( return canvas, [row[0] for row in axes] return canvas, axes + _FACET_KINDS = { + "plot": "plot", + "scatter": "scatter", + "pcolormesh": "pcolormesh", + "imshow": "add_imshow", + "contour": "contour", + "contourf": "contourf", + } + + @classmethod + def facet( + cls, + da, + col=None, + row=None, + col_wrap: int | None = None, + kind: str = "pcolormesh", + sharey: bool = True, + canvas_kwargs: dict | None = None, + **kwargs, + ): + """ + Create a Canvas with one subplot per value of a DataArray dimension. + + Parameters: + da (xarray.DataArray): The data. After removing ``col``/``row``, it + must have the dimensions ``kind`` needs: 1-D for ``"plot"`` and + ``"scatter"`` (2-D with ``hue=``), 2-D for the others. + col, row (str): Dimensions to lay out across columns and rows. + col_wrap (int): Wrap a ``col`` facet after this many columns. + kind (str): ``"plot"``, ``"scatter"``, ``"pcolormesh"``, ``"imshow"``, + ``"contour"`` or ``"contourf"``. + sharey (bool): For ``"plot"``/``"scatter"``, give every subplot the + y-range of the whole array. With ``False`` each subplot scales + its own y-axis and keeps its y tick labels. + canvas_kwargs (dict): Forwarded to the Canvas constructor. + **kwargs: Forwarded to each subplot's ``kind`` method. + + Color-mapped kinds share one color scale (``vmin``/``vmax`` default to + the data range, and contour levels are shared) and one colorbar for + the whole figure, which ``add_colorbar=False`` turns off. Axis and + tick labels are kept on the outer subplots only, and each subplot is + titled with its coordinate values. + + Returns: + (canvas, axes): The Canvas and a 2-D list of LinePlots, with ``None`` + where a wrapped grid has no subplot. + + Examples: + >>> canvas, axes = Canvas.facet(da, col="t", col_wrap=3) + >>> canvas, axes = Canvas.facet(da, row="species", kind="plot") + """ + if not xarray_support.is_dataarray(da): + raise TypeError("facet() needs an xarray.DataArray") + if kind not in cls._FACET_KINDS: + raise ValueError( + f"kind must be one of {sorted(cls._FACET_KINDS)}, got {kind!r}" + ) + if col is None and row is None: + raise ValueError("facet() needs col= and/or row=") + if col == row: + raise ValueError("col and row must be different dimensions") + for dim in (col, row): + if dim is not None and dim not in da.dims: + raise ValueError(f"{dim!r} is not a dimension of {da.dims}") + if col_wrap is not None and (col is None or row is not None): + raise ValueError("col_wrap= needs col= and no row=") + + ncol_values = da.sizes[col] if col is not None else 1 + nrow_values = da.sizes[row] if row is not None else 1 + if col_wrap is not None: + ncols = max(1, min(col_wrap, ncol_values)) + nrows = -(-ncol_values // ncols) + panels = [(j // ncols, j % ncols, {col: j}) for j in range(ncol_values)] + else: + ncols, nrows = ncol_values, nrow_values + panels = [ + (i, j, {d: k for d, k in ((row, i), (col, j)) if d is not None}) + for i in range(nrows) + for j in range(ncols) + ] + + mapped = kind not in ("plot", "scatter") + sharey = sharey or mapped + add_colorbar = kwargs.pop("add_colorbar", kind != "contour") + values = xarray_support.magnitude(da) + if mapped: + kwargs.setdefault("vmin", float(np.nanmin(values))) + kwargs.setdefault("vmax", float(np.nanmax(values))) + if kind in ("contour", "contourf") and "levels" not in kwargs: + kwargs["levels"] = np.linspace(kwargs["vmin"], kwargs["vmax"], 11) + # One colorbar for the figure, added below; "colorbar" hides the + # per-trace scale Plotly would otherwise show. + kwargs["add_colorbar"] = False + kwargs["colorbar"] = False + + canvas_kwargs = dict(canvas_kwargs or {}) + if not {"subplot_spacing", "gridspec_kw"} & canvas_kwargs.keys(): + canvas_kwargs["subplot_spacing"] = SubplotSpacing( + wspace=0.1 if sharey else 0.3, hspace=0.3 + ) + canvas = cls(nrows=nrows, ncols=ncols, **canvas_kwargs) + canvas._hide_empty_subplots = True + axes = [[None] * ncols for _ in range(nrows)] + method = cls._FACET_KINDS[kind] + for r, c, selection in panels: + subplot = canvas.add_subplot(row=r, col=c) + getattr(subplot, method)(da.isel(selection), **kwargs) + axes[r][c] = subplot + + if not mapped and sharey: + low, high = float(np.nanmin(values)), float(np.nanmax(values)) + margin = 0.05 * (high - low) + for r, c, _ in panels: + subplot = axes[r][c] + tick_params = {} + if r + 1 < nrows and axes[r + 1][c] is not None: + subplot._xlabel = None + tick_params["labelbottom"] = False + if c > 0 and sharey: + subplot._ylabel = None + tick_params["labelleft"] = False + if tick_params: + subplot.tick_params(**tick_params) + if not mapped and sharey: + subplot.set_ylim(low - margin, high + margin) + if (r, c) != panels[0][:2]: + subplot._legend = False + if mapped and add_colorbar: + first = axes[panels[0][0]][panels[0][1]] + first._add( + { + "label": xarray_support.value_label(da), + "layer": 0, + "plot_type": "colorbar", + "kwargs": {}, + "span_figure": True, + }, + 0, + ) + return canvas, axes + @property def _subplot_dict(self): return self._subplots @@ -610,7 +753,7 @@ def _get_or_create_subplot(self, row, col): def scatter( self, x, - y, + y=None, layer=0, row: int | None = None, col: int | None = None, @@ -625,6 +768,8 @@ def scatter( layer (int): Layer index (default 0). row, col (int): Subplot position (default top-left). **kwargs: Forwarded to the backend (e.g., color, marker, s, label). + + ``scatter(da)`` accepts an ``xarray.DataArray`` like ``plot(da)``. """ sp = self._get_or_create_subplot(row, col) sp.scatter(x, y, layer=layer, **kwargs) @@ -831,27 +976,27 @@ def eventplot( def contour( self, x, - y, - z, + y=None, + z=None, layer=0, row: int | None = None, col: int | None = None, **kwargs, ): - """Add contour lines to a subplot.""" + """Add contour lines to a subplot; ``contour(da)`` takes a DataArray.""" self._get_or_create_subplot(row, col).contour(x, y, z, layer=layer, **kwargs) def contourf( self, x, - y, - z, + y=None, + z=None, layer=0, row: int | None = None, col: int | None = None, **kwargs, ): - """Add filled contours to a subplot.""" + """Add filled contours to a subplot; ``contourf(da)`` takes a DataArray.""" self._get_or_create_subplot(row, col).contourf(x, y, z, layer=layer, **kwargs) def pcolormesh( @@ -1598,7 +1743,7 @@ def imshow( col: int | None = None, **kwargs, ): - """Add an image/matrix plot to a subplot.""" + """Add an image/matrix plot to a subplot; ``imshow(da)`` takes a DataArray.""" self._get_or_create_subplot(row, col).add_imshow(data, layer=layer, **kwargs) def add_image( @@ -2088,7 +2233,8 @@ def plot(self, *args, backend=None, **kwargs): ``canvas.plot(x, y, **style)`` is the convenient direct plotting form. ``canvas.plot(da)`` plots a 1-D ``xarray.DataArray`` against its - coordinate, labelling the axes from its attributes. + coordinate, labelling the axes from its attributes; ``hue=`` + draws a 2-D one as one line per value of ``dim``. Rendering is named explicitly by ``canvas.render(...)``; the legacy ``canvas.plot(backend=...)`` form remains supported. """ @@ -2394,6 +2540,18 @@ def plot_matplotlib( else: subplot.plot_matplotlib(ax, layers=layers) + if self._hide_empty_subplots: + for row in range(self.nrows): + for col in range(self.ncols): + if (row, col) not in self._subplot_dict: + axes[row][col].set_visible(False) + figure_axes = [ax for ax in fig.axes if ax.get_visible()] + for subplot in self._subplot_dict.values(): + figure_colorbar = getattr(subplot, "_figure_colorbar", None) + if figure_colorbar is not None and figure_colorbar[0] is not None: + mappable, label = figure_colorbar + fig.colorbar(mappable, ax=figure_axes, label=label) + if verbose: print("Finished plotting subplots.") @@ -2939,6 +3097,12 @@ def plot_plotly( subplot_titles=subplot_titles, specs=specs, ) + if self._hide_empty_subplots: + for row in range(self.nrows): + for col in range(self.ncols): + if (row, col) not in self._subplot_dict: + fig.update_xaxes(visible=False, row=row + 1, col=col + 1) + fig.update_yaxes(visible=False, row=row + 1, col=col + 1) # Plot each subplot and propagate axis labels/scale for (row, col), line_plot in self._subplot_dict.items(): diff --git a/src/maxplotlib/subfigure/line_plot.py b/src/maxplotlib/subfigure/line_plot.py index 375ad4a..cfa90a5 100644 --- a/src/maxplotlib/subfigure/line_plot.py +++ b/src/maxplotlib/subfigure/line_plot.py @@ -12,6 +12,16 @@ # maxplotlib itself rather than being forwarded to a backend drawing call. _NEUTRAL_KWARGS = ("hover", "meta") + +def _mpl_mappable_kwargs(line): + """Kwargs for a Matplotlib color-mapped artist. + + ``colorbar`` is a Plotly-only flag; Matplotlib adds colorbars through a + separate ``colorbar()`` entry, and would reject it as an artist property. + """ + return {k: v for k, v in line["kwargs"].items() if k != "colorbar"} + + _TIKZ_SUPPORTED_PLOT_TYPES = { "plot", "scatter", @@ -394,26 +404,68 @@ def plot(self, x, y=None, layer=0, **kwargs): """Matplotlib-style alias for :meth:`add_line`. ``plot(da)`` with a 1-D ``xarray.DataArray`` plots it against its - coordinate and labels axes that have no label yet. + coordinate; see :meth:`_plot_dataarray`. """ if y is None: if not xarray_support.is_dataarray(x): raise TypeError("plot(x, y) requires both x and y data") - x, y, xlabel, ylabel = xarray_support.line_data(x) - self._set_default_labels(xlabel, ylabel) + self._plot_dataarray(self.add_line, x, layer, kwargs) + return self.add_line(x, y, layer=layer, **kwargs) - def _set_default_labels(self, xlabel, ylabel): - """Set axis labels that the user has not set.""" + def _set_default_labels(self, xlabel, ylabel, da=None): + """Set axis labels, and a title from ``da``, that the user has not set.""" if not self._xlabel and xlabel: self._xlabel = xlabel if not self._ylabel and ylabel: self._ylabel = ylabel + if da is not None and not self._title: + self._title = xarray_support.title(da) or self._title - def scatter(self, x, y, layer=0, **kwargs): + def _plot_dataarray(self, method, da, layer, kwargs): + """Draw a DataArray as lines or points with ``method(x, y, ...)``. + + Axes are labelled from the array's attributes and the title from its + single-value coordinates, unless already set. ``hue=`` draws a + 2-D array as one labelled series per value of ``dim`` and shows the + legend unless ``add_legend=False``. + """ + hue = kwargs.pop("hue", None) + add_legend = kwargs.pop("add_legend", True) + if hue is not None and "label" in kwargs: + raise TypeError("label= cannot be combined with hue=") + x, lines, xlabel, ylabel = xarray_support.line_data(da, hue=hue) + self._set_default_labels(xlabel, ylabel, da) + for y, label in lines: + line_kwargs = dict(kwargs) if label is None else {**kwargs, "label": label} + method(x, y, layer=layer, **line_kwargs) + if hue is not None and add_legend: + self._legend = True + + def _mesh_dataarray(self, method, da, layer, kwargs, add_colorbar=True, name=None): + """Draw a 2-D DataArray with ``method(x, y, z, ...)``. + + ``xdim=``/``ydim=`` pick which dimension goes on each axis. A colorbar + labelled from the array is added unless ``add_colorbar=False``. + """ + add_colorbar = kwargs.pop("add_colorbar", add_colorbar) + x, y, z, xlabel, ylabel, zlabel = xarray_support.mesh_data( + da, + x=kwargs.pop("xdim", None), + y=kwargs.pop("ydim", None), + method=name or method.__name__, + ) + self._set_default_labels(xlabel, ylabel, da) + method(x, y, z, layer=layer, **kwargs) + if add_colorbar: + self.add_colorbar(label=zlabel, layer=layer) + + def scatter(self, x, y=None, layer=0, **kwargs): """ Add a scatter plot to the subplot. + ``scatter(da)`` accepts an ``xarray.DataArray`` like :meth:`plot`. + Parameters: x (array-like): X-axis data. y (array-like): Y-axis data. @@ -421,6 +473,11 @@ def scatter(self, x, y, layer=0, **kwargs): **kwargs: Additional keyword arguments forwarded to the backend (e.g., color, marker, s, label). """ + if y is None: + if not xarray_support.is_dataarray(x): + raise TypeError("scatter(x, y) requires both x and y data") + self._plot_dataarray(self.scatter, x, layer, kwargs) + return ld = { "x": np.array(x), "y": np.array(y), @@ -598,8 +655,19 @@ def eventplot(self, positions, layer=0, **kwargs): layer, ) - def contour(self, x, y, z, layer=0, **kwargs): - """Add contour lines for a 2D scalar field.""" + def contour(self, x, y=None, z=None, layer=0, **kwargs): + """Add contour lines for a 2D scalar field. + + ``contour(da)`` accepts a 2-D ``xarray.DataArray`` like + :meth:`pcolormesh`, but adds no colorbar by default. + """ + if xarray_support.is_dataarray(x): + if y is not None or z is not None: + raise TypeError("contour(da) takes no y or z data") + self._mesh_dataarray(self.contour, x, layer, kwargs, add_colorbar=False) + return + if y is None or z is None: + raise TypeError("contour(x, y, z) requires x, y and z data") self._add( { "x": x, @@ -612,8 +680,19 @@ def contour(self, x, y, z, layer=0, **kwargs): layer, ) - def contourf(self, x, y, z, layer=0, **kwargs): - """Add filled contours for a 2D scalar field.""" + def contourf(self, x, y=None, z=None, layer=0, **kwargs): + """Add filled contours for a 2D scalar field. + + ``contourf(da)`` accepts a 2-D ``xarray.DataArray`` like + :meth:`pcolormesh`. + """ + if xarray_support.is_dataarray(x): + if y is not None or z is not None: + raise TypeError("contourf(da) takes no y or z data") + self._mesh_dataarray(self.contourf, x, layer, kwargs, add_colorbar=True) + return + if y is None or z is None: + raise TypeError("contourf(x, y, z) requires x, y and z data") self._add( { "x": x, @@ -629,22 +708,16 @@ def contourf(self, x, y, z, layer=0, **kwargs): def pcolormesh(self, x, y=None, z=None, layer=0, **kwargs): """Add a pseudocolor mesh. - ``pcolormesh(da)`` with a 2-D ``xarray.DataArray`` uses its coordinates - and labels axes that have no label yet. ``xdim=``/``ydim=`` pick which - dimension goes on each axis, and a labelled colorbar is added unless - ``add_colorbar=False``. + ``pcolormesh(da)`` with a 2-D ``xarray.DataArray`` uses its coordinates. + Axes are labelled from its attributes and the title from its + single-value coordinates, unless already set. ``xdim=``/``ydim=`` pick + which dimension goes on each axis, and a labelled colorbar is added + unless ``add_colorbar=False``. """ if xarray_support.is_dataarray(x): if y is not None or z is not None: raise TypeError("pcolormesh(da) takes no y or z data") - add_colorbar = kwargs.pop("add_colorbar", True) - x, y, z, xlabel, ylabel, zlabel = xarray_support.mesh_data( - x, x=kwargs.pop("xdim", None), y=kwargs.pop("ydim", None) - ) - self._set_default_labels(xlabel, ylabel) - self.pcolormesh(x, y, z, layer=layer, **kwargs) - if add_colorbar: - self.add_colorbar(label=zlabel, layer=layer) + self._mesh_dataarray(self.pcolormesh, x, layer, kwargs) return if y is None or z is None: raise TypeError("pcolormesh(x, y, z) requires x, y and z data") @@ -1419,6 +1492,28 @@ def text(self, x, y, s, layer=0, **kwargs): self._add(ld, layer) def add_imshow(self, data, layer=0, **kwargs): + """Add an image. + + ``add_imshow(da)`` with a 2-D ``xarray.DataArray`` places the image by + its evenly spaced coordinates (``origin="lower"``) and otherwise + behaves like :meth:`pcolormesh` with a DataArray. + """ + if xarray_support.is_dataarray(data): + xdim, ydim = xarray_support.mesh_dims( + data, kwargs.get("xdim"), kwargs.get("ydim"), "imshow" + ) + kwargs.setdefault("origin", "lower") + kwargs.setdefault( + "extent", + xarray_support.image_extent( + xarray_support.coord_values(data, xdim), + xarray_support.coord_values(data, ydim), + xdim, + ydim, + ), + ) + self._mesh_dataarray(self._imshow_xyz, data, layer, kwargs, name="imshow") + return ld = { "data": np.asanyarray(data).copy(), "layer": layer, @@ -1427,6 +1522,9 @@ def add_imshow(self, data, layer=0, **kwargs): } self._add(ld, layer) + def _imshow_xyz(self, x, y, z, layer=0, **kwargs): + self.add_imshow(z, layer=layer, **kwargs) + def add_image(self, data, layer=0, **kwargs): """Matplotlib-style alias for ``imshow``.""" self.add_imshow(data, layer=layer, **kwargs) @@ -1469,6 +1567,7 @@ def plot_matplotlib( ax (matplotlib.axes.Axes): Axis on which to plot the lines. """ im = None + self._figure_colorbar = None self._import_rendered_artists = {} if hasattr(self, "_import_projection_artist_ids"): for name, identifiers in self._import_projection_artist_ids.items(): @@ -1549,17 +1648,14 @@ def plot_matplotlib( ax.violinplot(line["dataset"], **line["kwargs"]) elif line["plot_type"] == "eventplot": ax.eventplot(line["positions"], **line["kwargs"]) - elif line["plot_type"] == "contour": - contour_sets.append( - ax.contour(line["x"], line["y"], line["z"], **line["kwargs"]) - ) - elif line["plot_type"] == "contourf": - contour_sets.append( - ax.contourf(line["x"], line["y"], line["z"], **line["kwargs"]) + elif line["plot_type"] in ("contour", "contourf"): + im = getattr(ax, line["plot_type"])( + line["x"], line["y"], line["z"], **_mpl_mappable_kwargs(line) ) + contour_sets.append(im) elif line["plot_type"] == "pcolormesh": im = ax.pcolormesh( - line["x"], line["y"], line["z"], **line["kwargs"] + line["x"], line["y"], line["z"], **_mpl_mappable_kwargs(line) ) elif line["plot_type"] == "hexbin": ax.hexbin(line["x"], line["y"], **line["kwargs"]) @@ -1755,13 +1851,16 @@ def plot_matplotlib( elif line["plot_type"] == "imshow": im = ax.imshow( line["data"], - **line["kwargs"], + **_mpl_mappable_kwargs(line), ) elif line["plot_type"] == "patch": ax.add_patch( line["patch"], **line["kwargs"], ) + elif line["plot_type"] == "colorbar" and line.get("span_figure"): + # Drawn by the Canvas once every subplot exists. + self._figure_colorbar = (im, line["label"]) elif line["plot_type"] == "colorbar": divider = make_axes_locatable(ax) cax = divider.append_axes("right", size="5%", pad=0.05) @@ -2744,8 +2843,11 @@ def bar_marker(kwargs): contours=contours, colorscale=kwargs.get("cmap", "Viridis"), showscale=kwargs.get("colorbar", True), + zmin=kwargs.get("vmin"), + zmax=kwargs.get("vmax"), ) ) + last_heatmap_idx = len(traces) - 1 elif plot_type == "contourf": kwargs = line["kwargs"] contours = {} @@ -2766,8 +2868,11 @@ def bar_marker(kwargs): colorscale=kwargs.get("cmap", "Viridis"), showscale=kwargs.get("colorbar", True), contours=contours, + zmin=kwargs.get("vmin"), + zmax=kwargs.get("vmax"), ) ) + last_heatmap_idx = len(traces) - 1 elif plot_type == "pcolormesh": kwargs = line["kwargs"] traces.append( @@ -2777,6 +2882,8 @@ def bar_marker(kwargs): z=line["z"], colorscale=kwargs.get("cmap", "Viridis"), showscale=kwargs.get("colorbar", True), + zmin=kwargs.get("vmin"), + zmax=kwargs.get("vmax"), ) ) last_heatmap_idx = len(traces) - 1 @@ -3567,11 +3674,11 @@ def error_spec(values, scale): ) elif plot_type == "colorbar": if last_heatmap_idx is not None: + trace = traces[last_heatmap_idx] + trace.update(showscale=True) label = line.get("label", "") or line["kwargs"].get("label", "") if label: - traces[last_heatmap_idx].update( - colorbar=dict(title=dict(text=label)) - ) + trace.update(colorbar=dict(title=dict(text=label))) elif plot_type == "patch": kwargs = line["kwargs"] patch = line["patch"] diff --git a/src/maxplotlib/tests/test_xarray.py b/src/maxplotlib/tests/test_xarray.py index 0851838..e1ee5b9 100644 --- a/src/maxplotlib/tests/test_xarray.py +++ b/src/maxplotlib/tests/test_xarray.py @@ -176,3 +176,318 @@ def test_imshow_colorbar_uses_given_label(): fig, axes = canvas.get_matplotlib_figaxs() assert fig.axes[-1].get_ylabel() == "density" plt.close(fig) + + +# --------------------------------------------------------------------------- +# Titles from single-value coordinates +# --------------------------------------------------------------------------- + + +def _cube(): + t = np.array([0.0, 0.5, 1.0]) + return xr.DataArray( + np.arange(36.0).reshape(3, 3, 4), + dims=("t", "y", "x"), + coords={ + "t": ("t", t, {"units": "s"}), + "x": np.linspace(0, 1, 4), + "y": np.linspace(0, 2, 3), + }, + name="phi", + attrs={"long_name": "Potential", "units": "V"}, + ) + + +def test_title_from_selected_coordinates(): + da = _cube().sel(t=0.5).isel(y=1) + assert xarray_support.title(da) == "t = 0.5 s, y = 1" + canvas = Canvas() + canvas.plot(da) + fig, axes = canvas.get_matplotlib_figaxs() + assert np.ravel(axes)[0].get_title() == "t = 0.5 s, y = 1" + plt.close(fig) + + +def test_title_formats_strings_and_datetimes(): + da = xr.DataArray( + np.zeros((2, 2)), + dims=("species", "time"), + coords={ + "species": ["ions", "electrons"], + "time": np.array(["2026-01-01", "2026-01-02"], dtype="datetime64[D]"), + }, + ) + assert xarray_support.title(da.isel(species=0, time=1)) == ( + "species = ions, time = 2026-01-02" + ) + + +def test_explicit_title_wins(): + canvas, ax = Canvas.subplots() + ax.set_title("mine") + ax.pcolormesh(_cube().isel(t=0)) + fig = canvas.render(backend="plotly") + assert "mine" in [a.text for a in fig.layout.annotations] + assert not any("t = " in (a.text or "") for a in fig.layout.annotations) + + +def test_no_title_without_scalar_coords(): + canvas, ax = Canvas.subplots() + ax.plot(_line()) + assert ax._title is None + + +# --------------------------------------------------------------------------- +# imshow, contour, contourf, scatter +# --------------------------------------------------------------------------- + + +def test_imshow_dataarray_extent_and_labels(): + da = _mesh() + canvas = Canvas() + canvas.imshow(da) + fig, axes = canvas.get_matplotlib_figaxs() + ax = np.ravel(axes)[0] + image = ax.get_images()[0] + # x: 0..1 in 4 steps of 1/3, y: 0..2 in 3 steps of 1. + np.testing.assert_allclose(image.get_extent(), [-1 / 6, 7 / 6, -0.5, 2.5]) + assert image.origin == "lower" + np.testing.assert_allclose(image.get_array(), da.values) + assert ax.get_xlabel() == "x [m]" + assert fig.axes[-1].get_ylabel() == "Potential [V]" + plt.close(fig) + + +def test_imshow_dataarray_plotly_shows_colorbar(): + canvas = Canvas() + canvas.imshow(_mesh()) + fig = canvas.render(backend="plotly") + heatmap = fig.data[0] + assert heatmap.showscale is True + assert heatmap.colorbar.title.text == "Potential [V]" + np.testing.assert_allclose(heatmap.x0, 0.0, atol=1e-12) + np.testing.assert_allclose(heatmap.dx, 1 / 3) + + +def test_imshow_dataarray_rejects_uneven_coordinates(): + da = _mesh().assign_coords(x=[0.0, 0.1, 0.5, 2.0]) + with pytest.raises(ValueError, match="'x' is not.*pcolormesh"): + Canvas().imshow(da) + + +def test_contour_and_contourf_dataarray(): + da = _mesh() + canvas, (left, right) = Canvas.subplots(ncols=2) + left.contour(da, levels=3) + right.contourf(da) + fig, axes = canvas.get_matplotlib_figaxs() + axes = np.ravel(axes) + assert axes[0].get_xlabel() == "x [m]" + # contour adds no colorbar by default, contourf does. + colorbars = [a for a in fig.axes if a not in axes] + assert [a.get_ylabel() for a in colorbars] == ["Potential [V]"] + plt.close(fig) + + +def test_contour_colorbar_flag_does_not_reach_matplotlib(): + canvas = Canvas() + canvas.contourf(_mesh(), colorbar=False) + fig, _ = canvas.get_matplotlib_figaxs() + plt.close(fig) + + +def test_scatter_dataarray(): + da = _line() + canvas = Canvas() + canvas.scatter(da, color="k") + fig, axes = canvas.get_matplotlib_figaxs() + ax = np.ravel(axes)[0] + np.testing.assert_allclose(ax.collections[0].get_offsets()[:, 1], da.values) + assert ax.get_ylabel() == "energy [J]" + plt.close(fig) + + +# --------------------------------------------------------------------------- +# hue +# --------------------------------------------------------------------------- + + +def _species(): + t = np.linspace(0, 1, 5) + return xr.DataArray( + np.stack([t, 2 * t]), + dims=("species", "t"), + coords={"species": ["ions", "electrons"], "t": ("t", t, {"units": "s"})}, + name="density", + ) + + +def test_plot_hue_draws_one_labelled_line_per_value(): + da = _species() + canvas = Canvas() + canvas.plot(da, hue="species") + fig, axes = canvas.get_matplotlib_figaxs() + ax = np.ravel(axes)[0] + lines = ax.get_lines() + assert [line.get_label() for line in lines] == [ + "species = ions", + "species = electrons", + ] + np.testing.assert_allclose(lines[1].get_ydata(), da.sel(species="electrons")) + assert ax.get_legend() is not None + assert ax.get_xlabel() == "t [s]" + plt.close(fig) + + +def test_hue_on_first_dimension_and_without_coordinate(): + da = _species().T.drop_vars("species") + x, lines, _, _ = xarray_support.line_data(da, hue="species") + assert [label for _, label in lines] == ["species = 0", "species = 1"] + np.testing.assert_allclose(lines[1][0], 2 * x) + + +def test_hue_errors_and_add_legend(): + canvas = Canvas() + with pytest.raises(ValueError, match="hue="): + canvas.plot(_species()) + with pytest.raises(ValueError, match="hue= needs a 2-D"): + canvas.plot(_line(), hue="t") + with pytest.raises(TypeError, match="label="): + canvas.plot(_species(), hue="species", label="x") + canvas, ax = Canvas.subplots() + ax.scatter(_species(), hue="species", add_legend=False) + assert not ax._legend + fig = canvas.render(backend="plotly") + assert [trace.name for trace in fig.data] == [ + "species = ions", + "species = electrons", + ] + + +# --------------------------------------------------------------------------- +# facets +# --------------------------------------------------------------------------- + + +def test_facet_col_wrap_shares_scale_and_colorbar(): + da = _cube() + canvas, axes = Canvas.facet(da, col="t", col_wrap=2) + assert len(axes) == 2 and len(axes[0]) == 2 + assert axes[1][1] is None + fig, mpl_axes = canvas.get_matplotlib_figaxs() + assert not mpl_axes[1][1].get_visible() + meshes = [mpl_axes[r][c].collections[0] for r, c in [(0, 0), (0, 1), (1, 0)]] + assert {mesh.norm.vmin for mesh in meshes} == {0.0} + assert {mesh.norm.vmax for mesh in meshes} == {35.0} + assert [ax.get_title() for ax in fig.axes[:4] if ax.get_visible()] == [ + "t = 0 s", + "t = 0.5 s", + "t = 1 s", + ] + # One figure-wide colorbar: 4 grid axes + 1 colorbar axis. + assert len(fig.axes) == 5 + assert fig.axes[-1].get_ylabel() == "Potential [V]" + # Outer labels only: (0, 1) has nothing below it, so keeps its x label. + assert mpl_axes[0][0].get_xlabel() == "" + assert mpl_axes[0][1].get_xlabel() == "x" + assert mpl_axes[1][0].get_ylabel() == "y" + assert mpl_axes[0][1].get_ylabel() == "" + plt.close(fig) + + +def test_facet_plotly_single_colorbar(): + canvas, _ = Canvas.facet(_cube(), col="t") + fig = canvas.render(backend="plotly") + assert [trace.showscale for trace in fig.data] == [True, False, False] + assert {trace.zmin for trace in fig.data} == {0.0} + assert {trace.zmax for trace in fig.data} == {35.0} + assert fig.data[0].colorbar.title.text == "Potential [V]" + + +def test_facet_row_and_col_lines(): + da = _cube() + canvas, axes = Canvas.facet(da.isel(y=0), row="t", kind="plot") + assert len(axes) == 3 and len(axes[0]) == 1 + assert axes[2][0]._title == "t = 1 s, y = 0" + canvas, axes = Canvas.facet(da, row="t", col="y", kind="plot", color="k") + assert len(axes) == 3 and len(axes[0]) == 3 + fig, _ = canvas.get_matplotlib_figaxs() + assert len(fig.axes) == 9 # no colorbar for lines + plt.close(fig) + + +def test_facet_lines_share_y_range_and_hide_inner_ticks(): + da = _cube().isel(y=0) # values 0..27 over t + canvas, axes = Canvas.facet(da, col="t", kind="plot") + fig, mpl_axes = canvas.get_matplotlib_figaxs() + limits = {tuple(ax.get_ylim()) for ax in np.ravel(mpl_axes)} + assert len(limits) == 1 + np.testing.assert_allclose(limits.pop(), (-1.35, 28.35)) # data range + 5 % + assert mpl_axes[0][0].yaxis.get_tick_params()["labelleft"] + assert not mpl_axes[0][1].yaxis.get_tick_params()["labelleft"] + plt.close(fig) + + canvas, axes = Canvas.facet(da, col="t", kind="plot", sharey=False) + fig, mpl_axes = canvas.get_matplotlib_figaxs() + assert len({tuple(ax.get_ylim()) for ax in np.ravel(mpl_axes)}) == 3 + assert mpl_axes[0][1].get_ylabel() == "Potential [V]" + plt.close(fig) + + +def test_facet_contourf_shares_levels_and_can_skip_colorbar(): + canvas, axes = Canvas.facet(_cube(), col="t", kind="contourf", add_colorbar=False) + fig, mpl_axes = canvas.get_matplotlib_figaxs() + levels = [ax.collections[0].levels for ax in np.ravel(mpl_axes)] + np.testing.assert_allclose(levels[0], levels[2]) + assert len(fig.axes) == 3 + plt.close(fig) + + +def test_facet_errors(): + da = _cube() + with pytest.raises(ValueError, match="col= and/or row="): + Canvas.facet(da) + with pytest.raises(ValueError, match="not a dimension"): + Canvas.facet(da, col="z") + with pytest.raises(ValueError, match="kind must be"): + Canvas.facet(da, col="t", kind="bar") + with pytest.raises(ValueError, match="col_wrap"): + Canvas.facet(da, row="t", col_wrap=2) + with pytest.raises(ValueError, match="2-D DataArray"): + Canvas.facet(da.isel(x=0), col="t") # leaves 1-D (y) panels + + +# --------------------------------------------------------------------------- +# pint units +# --------------------------------------------------------------------------- + + +def test_pint_units_label_and_values(): + pint = pytest.importorskip("pint") + ureg = pint.UnitRegistry() + t = np.linspace(0, 1, 4) + da = xr.DataArray( + ureg.Quantity(t * 3.0, "m/s"), + dims="t", + coords={"t": ("t", t, {"units": "s"})}, + name="speed", + ) + assert xarray_support.value_label(da) == "speed [m/s]" + canvas = Canvas() + canvas.plot(da) + fig, axes = canvas.get_matplotlib_figaxs() + ax = np.ravel(axes)[0] + np.testing.assert_allclose(ax.get_lines()[0].get_ydata(), t * 3.0) + assert ax.get_ylabel() == "speed [m/s]" + plt.close(fig) + + +def test_pint_units_override_attrs_and_mesh(): + pint = pytest.importorskip("pint") + ureg = pint.UnitRegistry() + da = _mesh().copy(data=ureg.Quantity(_mesh().values, "kV")) + # attrs still say "V"; the quantity is authoritative. + assert xarray_support.value_label(da) == "Potential [kV]" + x, y, z, *_ = xarray_support.mesh_data(da) + assert type(z) is np.ndarray + np.testing.assert_allclose(z, _mesh().values) diff --git a/src/maxplotlib/utils/xarray_support.py b/src/maxplotlib/utils/xarray_support.py index eeb2672..c93fa10 100644 --- a/src/maxplotlib/utils/xarray_support.py +++ b/src/maxplotlib/utils/xarray_support.py @@ -3,6 +3,9 @@ xarray is never imported here: an object can only be a ``DataArray`` if the caller has already imported xarray, so detection looks it up in ``sys.modules``. Install it with ``pip install maxplotlibx[xarray]``. + +Units come from the ``units`` attribute, or from the data itself when it is a +pint ``Quantity`` (as with pint-xarray). """ import sys @@ -16,64 +19,140 @@ def is_dataarray(obj) -> bool: return xr is not None and isinstance(obj, xr.DataArray) -def _label(name, attrs) -> str: +def _is_quantity(data) -> bool: + return hasattr(data, "magnitude") and hasattr(data, "units") + + +def _pint_units(data) -> str: + try: + return format(data.units, "~P") + except (TypeError, ValueError): + return str(data.units) + + +def magnitude(variable): + """Plain numpy values of a DataArray or coordinate, stripping pint units.""" + data = variable.data + return np.asarray(data.magnitude if _is_quantity(data) else variable.values) + + +def units(variable) -> str: + """Units of a DataArray or coordinate: pint units first, then ``attrs``.""" + if _is_quantity(variable.data): + return _pint_units(variable.data) + return str(variable.attrs.get("units", "")) + + +def _label(variable) -> str: """Build ``"long_name [units]"`` from CF-style attributes.""" - label = attrs.get("long_name") or attrs.get("standard_name") or name or "" - units = attrs.get("units", "") - if units: - return f"{label} [{units}]" if label else f"[{units}]" - return str(label) + attrs = variable.attrs + label = attrs.get("long_name") or attrs.get("standard_name") or variable.name + label = "" if label is None else str(label) + unit = units(variable) + if unit: + return f"{label} [{unit}]" if label else f"[{unit}]" + return label def value_label(da) -> str: """Label for the values of ``da``.""" - return _label(da.name, da.attrs) + return _label(da) def coord_label(da, dim) -> str: """Label for the coordinate ``dim`` of ``da`` (the dimension name if none).""" if dim in da.coords: - return _label(dim, da.coords[dim].attrs) + return _label(da.coords[dim]) return str(dim) def coord_values(da, dim): """Values of the coordinate ``dim``, or ``0..n-1`` if it has none.""" if dim in da.coords: - return np.asarray(da.coords[dim].values) + return magnitude(da.coords[dim]) return np.arange(da.sizes[dim]) -def _require_ndim(da, ndim, method): - if da.ndim != ndim: - raise ValueError( - f"{method}() needs a {ndim}-D DataArray, got {da.ndim}-D with dims " - f"{da.dims}. Select a slice first, e.g. da.sel(...) or da.isel(...)." - ) +def format_value(value) -> str: + """Short text for a single coordinate value.""" + value = np.asarray(value) + if np.issubdtype(value.dtype, np.floating): + return f"{float(value):.4g}" + if np.issubdtype(value.dtype, np.datetime64): + return np.datetime_as_string(value, unit="auto") + return str(value.item() if value.ndim == 0 else value) -def line_data(da): - """Return ``(x, y, xlabel, ylabel)`` for a 1-D DataArray.""" - _require_ndim(da, 1, "plot") - (dim,) = da.dims - return ( - coord_values(da, dim), - np.asarray(da.values), - coord_label(da, dim), - value_label(da), +def _coord_text(name, coord, value) -> str: + unit = units(coord) + text = f"{name} = {format_value(value)}" + return f"{text} {unit}" if unit else text + + +def title(da) -> str: + """Title from the single-value coordinates left by ``sel``/``isel``. + + For example ``da.sel(t=1.0)`` gives ``"t = 1 s"`` if ``t`` has units ``s``. + """ + return ", ".join( + _coord_text(name, coord, magnitude(coord)) + for name, coord in da.coords.items() + if coord.ndim == 0 ) -def mesh_data(da, x=None, y=None): - """Return ``(x, y, z, xlabel, ylabel, zlabel)`` for a 2-D DataArray. +def _check_dims(da, *dims): + for dim in dims: + if dim is not None and dim not in da.dims: + raise ValueError(f"{dim!r} is not a dimension of {da.dims}") + + +def line_data(da, hue=None): + """Return ``(x, [(y, label), ...], xlabel, ylabel)`` for plotting lines. + + A 1-D array gives one unlabelled line. A 2-D array needs ``hue``, the + dimension to draw one line per value of. + """ + _check_dims(da, hue) + if hue is None and da.ndim != 1: + raise ValueError( + f"a 1-D DataArray is needed, got {da.ndim}-D with dims {da.dims}. " + "Select a slice first, e.g. da.sel(...) or da.isel(...), or pass " + "hue= to draw one line per value of a dimension." + ) + if hue is not None and da.ndim != 2: + raise ValueError( + f"hue= needs a 2-D DataArray, got {da.ndim}-D with dims {da.dims}" + ) + if hue is None: + (dim,) = da.dims + lines = [(magnitude(da), None)] + else: + (dim,) = [d for d in da.dims if d != hue] + hue_coord = da.coords[hue] if hue in da.coords else None + values = magnitude(da.transpose(hue, dim)) + lines = [] + for i in range(da.sizes[hue]): + if hue_coord is None: + label = f"{hue} = {i}" + else: + label = _coord_text(hue, hue_coord, magnitude(hue_coord)[i]) + lines.append((values[i], label)) + return coord_values(da, dim), lines, coord_label(da, dim), value_label(da) + + +def mesh_dims(da, x=None, y=None, method="pcolormesh"): + """Return the ``(x, y)`` dimension names for plotting a 2-D DataArray. Like ``xarray.DataArray.plot.pcolormesh``, the first dimension goes on the y-axis and the second on the x-axis unless ``x`` or ``y`` names a dimension. """ - _require_ndim(da, 2, "pcolormesh") - for name in (x, y): - if name is not None and name not in da.dims: - raise ValueError(f"{name!r} is not a dimension of {da.dims}") + if da.ndim != 2: + raise ValueError( + f"{method}() needs a 2-D DataArray, got {da.ndim}-D with dims " + f"{da.dims}. Select a slice first, e.g. da.sel(...) or da.isel(...)." + ) + _check_dims(da, x, y) if x is None and y is None: y, x = da.dims elif x is None: @@ -82,11 +161,42 @@ def mesh_data(da, x=None, y=None): (y,) = [d for d in da.dims if d != x] if x == y: raise ValueError("x and y must be different dimensions") + return x, y + + +def mesh_data(da, x=None, y=None, method="pcolormesh"): + """Return ``(x, y, z, xlabel, ylabel, zlabel)`` for a 2-D DataArray. + + See :func:`mesh_dims` for which dimension goes on which axis. + """ + x, y = mesh_dims(da, x, y, method) return ( coord_values(da, x), coord_values(da, y), - np.asarray(da.transpose(y, x).values), + magnitude(da.transpose(y, x)), coord_label(da, x), coord_label(da, y), value_label(da), ) + + +def _edges(values, name): + """Outer edges of evenly spaced cell centres.""" + if values.size == 1: + return values[0] - 0.5, values[0] + 0.5 + if not np.issubdtype(values.dtype, np.number): + raise ValueError( + f"imshow() needs a numeric coordinate {name!r}; use pcolormesh() instead" + ) + step = np.diff(values.astype(float)) + if not np.allclose(step, step[0], rtol=1e-3, atol=0): + raise ValueError( + f"imshow() needs an evenly spaced coordinate, but {name!r} is not; " + "use pcolormesh() instead" + ) + return values[0] - step[0] / 2, values[-1] + step[0] / 2 + + +def image_extent(x, y, xname="x", yname="y"): + """``(left, right, bottom, top)`` for ``imshow(..., origin="lower")``.""" + return (*_edges(x, xname), *_edges(y, yname)) From 3fd242bddcd59339bc7ad8f9c5fe965df02b2068 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 26 Sep 2026 21:29:43 +0200 Subject: [PATCH 3/5] Added da.xarray accessor --- src/maxplotlib/canvas/canvas.py | 6 +- src/maxplotlib/tests/test_xarray.py | 63 ++++++++++++++++++++ src/maxplotlib/xarray.py | 90 +++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 src/maxplotlib/xarray.py diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py index f146069..796ae7b 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -578,7 +578,11 @@ def facet( kwargs.setdefault("vmin", float(np.nanmin(values))) kwargs.setdefault("vmax", float(np.nanmax(values))) if kind in ("contour", "contourf") and "levels" not in kwargs: - kwargs["levels"] = np.linspace(kwargs["vmin"], kwargs["vmax"], 11) + from matplotlib.ticker import MaxNLocator + + kwargs["levels"] = MaxNLocator(10).tick_values( + kwargs["vmin"], kwargs["vmax"] + ) # One colorbar for the figure, added below; "colorbar" hides the # per-trace scale Plotly would otherwise show. kwargs["add_colorbar"] = False diff --git a/src/maxplotlib/tests/test_xarray.py b/src/maxplotlib/tests/test_xarray.py index e1ee5b9..4dd67f2 100644 --- a/src/maxplotlib/tests/test_xarray.py +++ b/src/maxplotlib/tests/test_xarray.py @@ -491,3 +491,66 @@ def test_pint_units_override_attrs_and_mesh(): x, y, z, *_ = xarray_support.mesh_data(da) assert type(z) is np.ndarray np.testing.assert_allclose(z, _mesh().values) + + +# --------------------------------------------------------------------------- +# da.maxplot accessor +# --------------------------------------------------------------------------- + + +def test_accessor_methods_return_canvases(): + import maxplotlib.xarray # noqa: F401 + + canvas = _line().maxplot.line(color="red") + assert isinstance(canvas, Canvas) + fig, axes = canvas.get_matplotlib_figaxs() + ax = np.ravel(axes)[0] + assert ax.get_lines()[0].get_color() == "red" + assert ax.get_xlabel() == "Time [s]" + plt.close(fig) + + for method in ("pcolormesh", "imshow", "contour", "contourf"): + canvas = getattr(_mesh().maxplot, method)() + fig = canvas.render(backend="plotly") + np.testing.assert_allclose(fig.data[0].z, _mesh().values) + + canvas = _species().maxplot.scatter(hue="species") + assert len(canvas.render(backend="plotly").data) == 2 + + +def test_accessor_canvas_kwargs_and_facets(): + import maxplotlib.xarray # noqa: F401 + + canvas = _mesh().maxplot.pcolormesh(canvas_kwargs={"fontsize": 14}) + assert canvas.fontsize == 14 + + canvas = _cube().maxplot.pcolormesh(col="t", col_wrap=2, cmap="magma") + assert (canvas.nrows, canvas.ncols) == (2, 2) + fig = canvas.render(backend="plotly") + assert [trace.showscale for trace in fig.data] == [True, False, False] + + canvas = _cube().isel(y=0).maxplot.line(col="t", sharey=False) + assert canvas.ncols == 3 + + +def test_accessor_call_picks_kind(): + import maxplotlib.xarray # noqa: F401 + + fig = _line().maxplot().render(backend="plotly") + assert fig.data[0].type == "scatter" + fig = _mesh().maxplot().render(backend="plotly") + assert fig.data[0].type == "heatmap" + fig = _species().maxplot(hue="species").render(backend="plotly") + assert [trace.type for trace in fig.data] == ["scatter", "scatter"] + canvas = _cube().maxplot(col="t") + assert canvas.ncols == 3 + with pytest.raises(ValueError, match="1-D or 2-D"): + _cube().maxplot() + + +def test_maxplotlib_does_not_import_xarray(): + import subprocess + import sys + + code = "import sys, maxplotlib; assert 'xarray' not in sys.modules" + subprocess.run([sys.executable, "-c", code], check=True) diff --git a/src/maxplotlib/xarray.py b/src/maxplotlib/xarray.py new file mode 100644 index 0000000..1966ff5 --- /dev/null +++ b/src/maxplotlib/xarray.py @@ -0,0 +1,90 @@ +"""The ``DataArray.maxplot`` accessor. + +Importing this module registers it:: + + import maxplotlib.xarray # noqa: F401 + + canvas = da.maxplot.line() + canvas.show(backend="plotly") + +Every method returns a new :class:`~maxplotlib.Canvas`; choose the backend +when rendering it. ``col=``, ``row=``, ``col_wrap=`` and ``sharey=`` lay the +data out over several subplots with :meth:`Canvas.facet`, and +``canvas_kwargs=`` is forwarded to the Canvas constructor. Everything else is +forwarded to the Canvas method of the same name (``line`` uses ``plot``). +""" + +try: + import xarray as xr +except ImportError as error: # pragma: no cover - depends on the environment + raise ImportError( + "maxplotlib.xarray needs xarray; install it with " + "`pip install maxplotlibx[xarray]`" + ) from error + +from maxplotlib.canvas.canvas import Canvas + +__all__ = ["MaxplotAccessor"] + +_FACET_KWARGS = ("col", "row", "col_wrap", "sharey") + + +@xr.register_dataarray_accessor("maxplot") +class MaxplotAccessor: + """Plot a DataArray with maxplotlib: ``da.maxplot.line()`` etc.""" + + def __init__(self, da): + self._da = da + + def __call__(self, **kwargs): + """Plot with a kind chosen from the dimensions, like ``da.plot()``. + + After removing any ``col``/``row`` dimensions, 1-D data (or 2-D data + with ``hue=``) is drawn with :meth:`line` and 2-D data with + :meth:`pcolormesh`. + """ + facet_dims = {kwargs.get("col"), kwargs.get("row")} - {None} + ndim = self._da.ndim - len(facet_dims & set(self._da.dims)) + if ndim == 1 or (ndim == 2 and "hue" in kwargs): + return self.line(**kwargs) + if ndim == 2: + return self.pcolormesh(**kwargs) + raise ValueError( + f"da.maxplot() plots 1-D or 2-D data, got dims {self._da.dims}. " + "Select a slice first, e.g. da.sel(...), or facet with col=/row=." + ) + + def line(self, **kwargs): + """Lines against the coordinate; ``hue=`` for one per value.""" + return self._draw("plot", kwargs) + + def scatter(self, **kwargs): + """Points against the coordinate; ``hue=`` for one series per value.""" + return self._draw("scatter", kwargs) + + def pcolormesh(self, **kwargs): + """A pseudocolor mesh of 2-D data, with a labelled colorbar.""" + return self._draw("pcolormesh", kwargs) + + def imshow(self, **kwargs): + """An image of 2-D data on evenly spaced coordinates.""" + return self._draw("imshow", kwargs) + + def contour(self, **kwargs): + """Contour lines of 2-D data.""" + return self._draw("contour", kwargs) + + def contourf(self, **kwargs): + """Filled contours of 2-D data, with a labelled colorbar.""" + return self._draw("contourf", kwargs) + + def _draw(self, kind, kwargs): + canvas_kwargs = kwargs.pop("canvas_kwargs", None) + if any(name in kwargs for name in _FACET_KWARGS): + canvas, _ = Canvas.facet( + self._da, kind=kind, canvas_kwargs=canvas_kwargs, **kwargs + ) + return canvas + canvas = Canvas(**(canvas_kwargs or {})) + getattr(canvas, kind)(self._da, **kwargs) + return canvas From c1b41f66fac425d041d4411e72e56d14a5885875 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 26 Sep 2026 22:07:38 +0200 Subject: [PATCH 4/5] Added xarray to docs + bugfixes --- README.md | 46 ++ README.qmd | 34 ++ .../figure-commonmark/cell-20-output-1.png | Bin 0 -> 68939 bytes docs/source/index.rst | 1 + pyproject.toml | 1 + src/maxplotlib/canvas/canvas.py | 58 +- src/maxplotlib/subfigure/line_plot.py | 209 ++++--- src/maxplotlib/tests/test_xarray.py | 364 ++++++++++- src/maxplotlib/utils/xarray_support.py | 214 +++++-- src/maxplotlib/xarray.py | 88 ++- tutorials/tutorial_17_xarray.ipynb | 568 ++++++++++++++++++ 11 files changed, 1448 insertions(+), 135 deletions(-) create mode 100644 README_files/figure-commonmark/cell-20-output-1.png create mode 100644 tutorials/tutorial_17_xarray.ipynb diff --git a/README.md b/README.md index b10ade0..02dfd59 100644 --- a/README.md +++ b/README.md @@ -349,3 +349,49 @@ canvas.show() (
, array([[]], dtype=object)) + +### xarray data + +Plot labelled [xarray](https://docs.xarray.dev) data directly +(`pip install maxplotlibx[xarray]`). Axes come from the coordinates, +labels from the `long_name` and `units` attributes, and titles from the +coordinates you selected. `import maxplotlib.xarray` adds a `.maxplot` +accessor that mirrors xarray’s own `.plot` API and returns an ordinary +`Canvas`, so the backend is still chosen when rendering: + +``` python +import xarray as xr + +import maxplotlib.xarray # registers da.maxplot and ds.maxplot + +t = np.linspace(0, 1.5, 6) +xs = np.linspace(0, 2 * np.pi, 80) +ys = np.linspace(-1, 1, 50) +wave = xr.DataArray( + np.sin(xs - 2 * t[:, None, None]) * np.exp(-3 * ys[None, :, None] ** 2), + dims=("t", "y", "x"), + coords={"t": ("t", t, {"units": "s"}), "y": ys, "x": ("x", xs, {"units": "m"})}, + name="phi", + attrs={"long_name": "Potential", "units": "V"}, +) + +wave.maxplot.pcolormesh(col="t", col_wrap=3, canvas_kwargs={"width": "16cm", "ratio": 0.6}).show() +``` + +![](README_files/figure-commonmark/cell-20-output-1.png) + + (
, + array([[, + , + ], + [, + , + ]], + dtype=object)) + +The same works through Canvas methods, +e.g. `canvas.plot(da, hue="species")`, +`ax.pcolormesh(da, xcoord="R", ycoord="Z")` for curvilinear grids, or +`Canvas.facet(da, col="t")`. `ds.maxplot.scatter(x=..., y=..., hue=...)` +plots one Dataset variable against another. See the [xarray +tutorial](tutorials/tutorial_17_xarray.ipynb) for more. diff --git a/README.qmd b/README.qmd index 531a7ce..cefdf37 100644 --- a/README.qmd +++ b/README.qmd @@ -276,3 +276,37 @@ Show all layers: ```{python} canvas.show() ``` + +### xarray data + +Plot labelled [xarray](https://docs.xarray.dev) data directly +(`pip install maxplotlibx[xarray]`). Axes come from the coordinates, labels +from the `long_name` and `units` attributes, and titles from the coordinates +you selected. `import maxplotlib.xarray` adds a `.maxplot` accessor that mirrors +xarray's own `.plot` API and returns an ordinary `Canvas`, so the backend is +still chosen when rendering: + +```{python} +import xarray as xr + +import maxplotlib.xarray # registers da.maxplot and ds.maxplot + +t = np.linspace(0, 1.5, 6) +xs = np.linspace(0, 2 * np.pi, 80) +ys = np.linspace(-1, 1, 50) +wave = xr.DataArray( + np.sin(xs - 2 * t[:, None, None]) * np.exp(-3 * ys[None, :, None] ** 2), + dims=("t", "y", "x"), + coords={"t": ("t", t, {"units": "s"}), "y": ys, "x": ("x", xs, {"units": "m"})}, + name="phi", + attrs={"long_name": "Potential", "units": "V"}, +) + +wave.maxplot.pcolormesh(col="t", col_wrap=3, canvas_kwargs={"width": "16cm", "ratio": 0.6}).show() +``` + +The same works through Canvas methods, e.g. `canvas.plot(da, hue="species")`, +`ax.pcolormesh(da, xcoord="R", ycoord="Z")` for curvilinear grids, or +`Canvas.facet(da, col="t")`. `ds.maxplot.scatter(x=..., y=..., hue=...)` plots +one Dataset variable against another. See the +[xarray tutorial](tutorials/tutorial_17_xarray.ipynb) for more. diff --git a/README_files/figure-commonmark/cell-20-output-1.png b/README_files/figure-commonmark/cell-20-output-1.png new file mode 100644 index 0000000000000000000000000000000000000000..74215c780cbc7e32c48c6d6510e1c01aca936eb0 GIT binary patch literal 68939 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 literal 0 HcmV?d00001 diff --git a/docs/source/index.rst b/docs/source/index.rst index e1bd3ab..82a2734 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -31,3 +31,4 @@ documentation for details. tutorials/tutorial_14_axis_and_layout_controls tutorials/tutorial_15_tikzfigure_subplots tutorials/tutorial_16_plotext_advanced + tutorials/tutorial_17_xarray diff --git a/pyproject.toml b/pyproject.toml index 1832aa9..2ca1936 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ xarray = [ "xarray", ] docs = [ + "xarray", "myst-parser", "sphinx", "sphinx-rtd-theme", diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py index 796ae7b..ee66087 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -372,6 +372,9 @@ def __init__( self._subplots_adjust_kwargs: dict = {} self._tight_layout_kwargs: dict | None = None self._hide_empty_subplots = False + # Set by Canvas.facet: merge Plotly legend entries across subplots and + # leave room for the figure title above the subplot titles. + self._facet = False self._set_tight_layout = None self._align_labels = False self._align_titles = False @@ -521,16 +524,18 @@ def facet( kind (str): ``"plot"``, ``"scatter"``, ``"pcolormesh"``, ``"imshow"``, ``"contour"`` or ``"contourf"``. sharey (bool): For ``"plot"``/``"scatter"``, give every subplot the - y-range of the whole array. With ``False`` each subplot scales - its own y-axis and keeps its y tick labels. + value range of the whole array (on the x-axis with ``ycoord=``). + With ``False`` each subplot scales its own axis and keeps its y + tick labels. canvas_kwargs (dict): Forwarded to the Canvas constructor. **kwargs: Forwarded to each subplot's ``kind`` method. Color-mapped kinds share one color scale (``vmin``/``vmax`` default to the data range, and contour levels are shared) and one colorbar for the whole figure, which ``add_colorbar=False`` turns off. Axis and - tick labels are kept on the outer subplots only, and each subplot is - titled with its coordinate values. + tick labels are kept on the outer subplots only. Each subplot is + titled with the coordinate values that differ between subplots, and + the figure with the ones they share. Returns: (canvas, axes): The Canvas and a 2-D list of LinePlots, with ``None`` @@ -575,6 +580,8 @@ def facet( add_colorbar = kwargs.pop("add_colorbar", kind != "contour") values = xarray_support.magnitude(da) if mapped: + # Shared limits for every panel, with xarray's color defaults. + xarray_support.color_limits(values, kwargs) kwargs.setdefault("vmin", float(np.nanmin(values))) kwargs.setdefault("vmax", float(np.nanmax(values))) if kind in ("contour", "contourf") and "levels" not in kwargs: @@ -595,13 +602,26 @@ def facet( ) canvas = cls(nrows=nrows, ncols=ncols, **canvas_kwargs) canvas._hide_empty_subplots = True + canvas._facet = True + # Coordinates shared by every panel go in the figure title, so the + # panel titles only show what differs between them. + common = [name for name, coord in da.coords.items() if coord.ndim == 0] + common_title = xarray_support.title(da) + if common_title: + canvas.suptitle(common_title) axes = [[None] * ncols for _ in range(nrows)] method = cls._FACET_KINDS[kind] for r, c, selection in panels: subplot = canvas.add_subplot(row=r, col=c) - getattr(subplot, method)(da.isel(selection), **kwargs) + panel = da.isel(selection) + subplot.set_title( + xarray_support.title(panel, exclude=common) + or ", ".join(f"{dim} = {index}" for dim, index in selection.items()) + ) + getattr(subplot, method)(panel, **kwargs) axes[r][c] = subplot + vertical = "ycoord" in kwargs if not mapped and sharey: low, high = float(np.nanmin(values)), float(np.nanmax(values)) margin = 0.05 * (high - low) @@ -616,7 +636,9 @@ def facet( tick_params["labelleft"] = False if tick_params: subplot.tick_params(**tick_params) - if not mapped and sharey: + if not mapped and sharey and vertical: + subplot.set_xlim(low - margin, high + margin) + elif not mapped and sharey: subplot.set_ylim(low - margin, high + margin) if (r, c) != panels[0][:2]: subplot._legend = False @@ -2568,6 +2590,10 @@ def plot_matplotlib( fig.supxlabel(self._supxlabel, **self._supxlabel_kwargs) if self._supylabel: fig.supylabel(self._supylabel, **self._supylabel_kwargs) + if self._facet and self._suptitle and "top" not in self._subplots_adjust_kwargs: + # About four font heights: the figure title plus subplot titles. + room = 4 * self.fontsize / 72 + fig.subplots_adjust(top=max(0.5, 1 - room / fig.get_figheight())) if self._subplots_adjust_kwargs: fig.subplots_adjust(**self._subplots_adjust_kwargs) if self._tight_layout_kwargs is not None: @@ -2755,8 +2781,8 @@ def plot_tikzfigure( ) if plot_type == "plot": # Extract and transform x, y data - x = (line_data["x"] + line_plot._xshift) * line_plot._xscale - y = (line_data["y"] + line_plot._yshift) * line_plot._yscale + x = line_plot._shift_x(line_data["x"]) + y = line_plot._shift_y(line_data["y"]) kwargs = line_data.get("kwargs", {}) if verbose: print(f"Line {kwargs = }") @@ -2767,8 +2793,8 @@ def plot_tikzfigure( **_tikz_style_kwargs(kwargs), ) elif plot_type == "scatter": - x = (line_data["x"] + line_plot._xshift) * line_plot._xscale - y = (line_data["y"] + line_plot._yshift) * line_plot._yscale + x = line_plot._shift_x(line_data["x"]) + y = line_plot._shift_y(line_data["y"]) kwargs = _tikz_style_kwargs(line_data.get("kwargs", {})) kwargs.setdefault("mark", "*") kwargs["line_width"] = 0 @@ -3109,10 +3135,22 @@ def plot_plotly( fig.update_yaxes(visible=False, row=row + 1, col=col + 1) # Plot each subplot and propagate axis labels/scale + legend_names = set() for (row, col), line_plot in self._subplot_dict.items(): traces, shapes, annotations = line_plot.plot_plotly( layers=layers, allow_unsupported=allow_unsupported ) + if self._facet: + # One legend entry per label across all subplots; clicking it + # toggles the matching trace in every subplot. + for trace in traces: + name = getattr(trace, "name", None) + if not name or trace.type in ("pie", "table"): + continue + trace.legendgroup = name + if name in legend_names: + trace.showlegend = False + legend_names.add(name) for trace in traces: if trace.type in ("pie", "table"): fig.add_trace(trace) diff --git a/src/maxplotlib/subfigure/line_plot.py b/src/maxplotlib/subfigure/line_plot.py index cfa90a5..3107353 100644 --- a/src/maxplotlib/subfigure/line_plot.py +++ b/src/maxplotlib/subfigure/line_plot.py @@ -3,7 +3,6 @@ import matplotlib.pyplot as plt import numpy as np import plotly.graph_objects as go -from mpl_toolkits.axes_grid1 import make_axes_locatable from tikzfigure import TikzFigure from maxplotlib.utils import xarray_support @@ -426,39 +425,63 @@ def _plot_dataarray(self, method, da, layer, kwargs): """Draw a DataArray as lines or points with ``method(x, y, ...)``. Axes are labelled from the array's attributes and the title from its - single-value coordinates, unless already set. ``hue=`` draws a - 2-D array as one labelled series per value of ``dim`` and shows the - legend unless ``add_legend=False``. + single-value coordinates, unless already set. ``xcoord=`` names the + dimension or 1-D coordinate to plot against; ``ycoord=`` does the same + with the coordinate on the y-axis, e.g. for vertical profiles. + ``hue=`` draws a 2-D array as one labelled series per value of + ``dim``, colored ``C0``, ``C1``, ... unless ``color`` is given, and + shows the legend unless ``add_legend=False``. """ hue = kwargs.pop("hue", None) add_legend = kwargs.pop("add_legend", True) if hue is not None and "label" in kwargs: raise TypeError("label= cannot be combined with hue=") - x, lines, xlabel, ylabel = xarray_support.line_data(da, hue=hue) - self._set_default_labels(xlabel, ylabel, da) - for y, label in lines: - line_kwargs = dict(kwargs) if label is None else {**kwargs, "label": label} - method(x, y, layer=layer, **line_kwargs) + data = xarray_support.line_data( + da, hue=hue, x=kwargs.pop("xcoord", None), y=kwargs.pop("ycoord", None) + ) + if data.vertical: + self._set_default_labels(data.value_label, data.coord_label, da) + else: + self._set_default_labels(data.coord_label, data.value_label, da) + cycle_colors = hue is not None and not {"color", "c"} & kwargs.keys() + for i, (positions, values, label) in enumerate(data.lines): + line_kwargs = dict(kwargs) + if label is not None: + line_kwargs["label"] = label + if cycle_colors: + line_kwargs["color"] = f"C{i % 10}" + if data.vertical: + method(values, positions, layer=layer, **line_kwargs) + else: + method(positions, values, layer=layer, **line_kwargs) if hue is not None and add_legend: self._legend = True def _mesh_dataarray(self, method, da, layer, kwargs, add_colorbar=True, name=None): """Draw a 2-D DataArray with ``method(x, y, z, ...)``. - ``xdim=``/``ydim=`` pick which dimension goes on each axis. A colorbar - labelled from the array is added unless ``add_colorbar=False``. + ``xcoord=``/``ycoord=`` name the dimension or coordinate for each + axis; 2-D coordinates give a curvilinear mesh. Colors follow xarray's + defaults (see :func:`xarray_support.color_limits`, including + ``robust=`` and ``center=``). A colorbar labelled from the array is + added unless ``add_colorbar=False``. """ add_colorbar = kwargs.pop("add_colorbar", add_colorbar) - x, y, z, xlabel, ylabel, zlabel = xarray_support.mesh_data( + mesh = self._mesh_of(da, kwargs, name or method.__name__) + self._set_default_labels(mesh.xlabel, mesh.ylabel, da) + xarray_support.color_limits(mesh.z, kwargs) + method(mesh.x, mesh.y, mesh.z, layer=layer, **kwargs) + if add_colorbar: + self.add_colorbar(label=mesh.zlabel, layer=layer) + + @staticmethod + def _mesh_of(da, kwargs, method): + return xarray_support.mesh_data( da, - x=kwargs.pop("xdim", None), - y=kwargs.pop("ydim", None), - method=name or method.__name__, + x=kwargs.pop("xcoord", None), + y=kwargs.pop("ycoord", None), + method=method, ) - self._set_default_labels(xlabel, ylabel, da) - method(x, y, z, layer=layer, **kwargs) - if add_colorbar: - self.add_colorbar(label=zlabel, layer=layer) def scatter(self, x, y=None, layer=0, **kwargs): """ @@ -710,9 +733,10 @@ def pcolormesh(self, x, y=None, z=None, layer=0, **kwargs): ``pcolormesh(da)`` with a 2-D ``xarray.DataArray`` uses its coordinates. Axes are labelled from its attributes and the title from its - single-value coordinates, unless already set. ``xdim=``/``ydim=`` pick - which dimension goes on each axis, and a labelled colorbar is added - unless ``add_colorbar=False``. + single-value coordinates, unless already set. ``xcoord=``/``ycoord=`` + pick the dimension or coordinate for each axis (2-D coordinates give + a curvilinear mesh), and a labelled colorbar is added unless + ``add_colorbar=False``. See :meth:`_mesh_dataarray` for colors. """ if xarray_support.is_dataarray(x): if y is not None or z is not None: @@ -1499,18 +1523,15 @@ def add_imshow(self, data, layer=0, **kwargs): behaves like :meth:`pcolormesh` with a DataArray. """ if xarray_support.is_dataarray(data): - xdim, ydim = xarray_support.mesh_dims( - data, kwargs.get("xdim"), kwargs.get("ydim"), "imshow" - ) + mesh = self._mesh_of(data, dict(kwargs), "imshow") + if mesh.curvilinear: + raise ValueError( + "imshow() cannot draw on 2-D coordinates; use pcolormesh() instead" + ) kwargs.setdefault("origin", "lower") kwargs.setdefault( "extent", - xarray_support.image_extent( - xarray_support.coord_values(data, xdim), - xarray_support.coord_values(data, ydim), - xdim, - ydim, - ), + xarray_support.image_extent(mesh.x, mesh.y, mesh.xname, mesh.yname), ) self._mesh_dataarray(self._imshow_xyz, data, layer, kwargs, name="imshow") return @@ -1525,6 +1546,10 @@ def add_imshow(self, data, layer=0, **kwargs): def _imshow_xyz(self, x, y, z, layer=0, **kwargs): self.add_imshow(z, layer=layer, **kwargs) + def imshow(self, data, layer=0, **kwargs): + """Matplotlib-style alias for :meth:`add_imshow`.""" + self.add_imshow(data, layer=layer, **kwargs) + def add_image(self, data, layer=0, **kwargs): """Matplotlib-style alias for ``imshow``.""" self.add_imshow(data, layer=layer, **kwargs) @@ -1591,8 +1616,8 @@ def plot_matplotlib( line["snapshot"].draw(ax, **line["kwargs"]) elif line["plot_type"] == "plot": ax.plot( - (line["x"] + self._xshift) * self._xscale, - (line["y"] + self._yshift) * self._yscale, + self._shift_x(line["x"]), + self._shift_y(line["y"]), **line["kwargs"], ) elif line["plot_type"] == "scatter": @@ -1602,14 +1627,14 @@ def plot_matplotlib( scatter_kwargs = { k: v for k, v in line["kwargs"].items() if k != "colorbar" } - ax.scatter( - (line["x"] + self._xshift) * self._xscale, - (line["y"] + self._yshift) * self._yscale, + im = ax.scatter( + self._shift_x(line["x"]), + self._shift_y(line["y"]), **scatter_kwargs, ) elif line["plot_type"] == "bar": ax.bar( - (line["x"] + self._xshift) * self._xscale, + self._shift_x(line["x"]), line["height"] * self._yscale, **line["kwargs"], ) @@ -1623,8 +1648,8 @@ def plot_matplotlib( ax.hist(line["x"], bins=line["bins"], **line["kwargs"]) elif line["plot_type"] == "step": ax.step( - (line["x"] + self._xshift) * self._xscale, - (line["y"] + self._yshift) * self._yscale, + self._shift_x(line["x"]), + self._shift_y(line["y"]), **line["kwargs"], ) elif line["plot_type"] == "stairs": @@ -1707,7 +1732,7 @@ def plot_matplotlib( ax.table(cellText=line["cellText"], **line["kwargs"]) elif line["plot_type"] == "gantt": tasks = line["tasks"] - start_times = (line["start_times"] + self._xshift) * self._xscale + start_times = self._shift_x(line["start_times"]) durations = line["durations"] * self._xscale y_positions = np.arange(len(tasks)) ax.barh(y_positions, durations, left=start_times, **line["kwargs"]) @@ -1725,7 +1750,7 @@ def plot_matplotlib( if start_times is None: start_times = np.zeros(n) else: - start_times = (start_times + self._xshift) * self._xscale + start_times = self._shift_x(start_times) # Calculate depths based on parent relationships for i in range(n): @@ -1777,21 +1802,21 @@ def plot_matplotlib( ax.set_ylabel("Stack Depth") elif line["plot_type"] == "fill_between": ax.fill_between( - (line["x"] + self._xshift) * self._xscale, + self._shift_x(line["x"]), ( line["y1"] if np.isscalar(line["y1"]) - else (line["y1"] + self._yshift) * self._yscale + else self._shift_y(line["y1"]) ), ( line["y2"] if np.isscalar(line["y2"]) - else (line["y2"] + self._yshift) * self._yscale + else self._shift_y(line["y2"]) ), **line["kwargs"], ) elif line["plot_type"] == "fill_betweenx": - y = (line["y"] + self._yshift) * self._yscale + y = self._shift_y(line["y"]) x1 = line["x1"] x2 = line["x2"] if np.isscalar(x1): @@ -1800,16 +1825,16 @@ def plot_matplotlib( x2 = np.full_like(y, x2, dtype=float) ax.fill_betweenx( y, - (np.asarray(x1) + self._xshift) * self._xscale, - (np.asarray(x2) + self._xshift) * self._xscale, + self._shift_x(np.asarray(x1)), + self._shift_x(np.asarray(x2)), **line["kwargs"], ) elif line["plot_type"] == "fill": ax.fill(*line["args"], **line["kwargs"]) elif line["plot_type"] == "errorbar": ax.errorbar( - (line["x"] + self._xshift) * self._xscale, - (line["y"] + self._yshift) * self._yscale, + self._shift_x(line["x"]), + self._shift_y(line["y"]), yerr=line["yerr"], xerr=line["xerr"], **line["kwargs"], @@ -1862,9 +1887,9 @@ def plot_matplotlib( # Drawn by the Canvas once every subplot exists. self._figure_colorbar = (im, line["label"]) elif line["plot_type"] == "colorbar": - divider = make_axes_locatable(ax) - cax = divider.append_axes("right", size="5%", pad=0.05) - plt.colorbar(im, cax=cax, label=line["label"]) + # Shrinks ``ax`` to make room, so the label stays inside + # the figure. + ax.figure.colorbar(im, ax=ax, label=line["label"]) if "source_artist_id" in line: created = [ @@ -2101,8 +2126,8 @@ def plot_tikzfigure(self, layers=None, verbose: bool = False) -> TikzFigure: f"{plot_type} is not supported by the tikzfigure backend" ) if plot_type == "plot": - x = (line["x"] + self._xshift) * self._xscale - y = (line["y"] + self._yshift) * self._yscale + x = self._shift_x(line["x"]) + y = self._shift_y(line["y"]) nodes = [[xi, yi] for xi, yi in zip(x, y)] tikz_figure.draw( @@ -2110,8 +2135,8 @@ def plot_tikzfigure(self, layers=None, verbose: bool = False) -> TikzFigure: **_tikz_style_kwargs(line["kwargs"]), ) elif plot_type == "scatter": - x = (line["x"] + self._xshift) * self._xscale - y = (line["y"] + self._yshift) * self._yscale + x = self._shift_x(line["x"]) + y = self._shift_y(line["y"]) style = _tikz_style_kwargs(line["kwargs"]) style.setdefault("mark", "*") style["line_width"] = 0 @@ -2128,7 +2153,7 @@ def plot_tikzfigure(self, layers=None, verbose: bool = False) -> TikzFigure: if plot_type == "bar": width = kwargs.get("width", 0.8) for x, height in zip(line["x"], line["height"]): - x = (x + self._xshift) * self._xscale + x = self._shift_x(x) height = height * self._yscale tikz_figure.draw( nodes=[ @@ -2143,7 +2168,7 @@ def plot_tikzfigure(self, layers=None, verbose: bool = False) -> TikzFigure: else: height = kwargs.get("height", 0.8) for y, width in zip(line["y"], line["width"]): - y = (y + self._yshift) * self._yscale + y = self._shift_y(y) width = width * self._xscale tikz_figure.draw( nodes=[ @@ -2156,7 +2181,7 @@ def plot_tikzfigure(self, layers=None, verbose: bool = False) -> TikzFigure: **style, ) elif plot_type == "fill_between": - x = (line["x"] + self._xshift) * self._xscale + x = self._shift_x(line["x"]) y1 = np.asarray(line["y1"]) y2 = np.broadcast_to(line["y2"], y1.shape) nodes = [[xi, yi] for xi, yi in zip(x, y1)] @@ -2167,8 +2192,8 @@ def plot_tikzfigure(self, layers=None, verbose: bool = False) -> TikzFigure: style["fill_opacity"] = kwargs.get("alpha", 0.25) tikz_figure.draw(nodes=nodes, cycle=True, **style) elif plot_type == "errorbar": - x = (line["x"] + self._xshift) * self._xscale - y = (line["y"] + self._yshift) * self._yscale + x = self._shift_x(line["x"]) + y = self._shift_y(line["y"]) style = _tikz_style_kwargs(line["kwargs"]) tikz_figure.draw(nodes=[[xi, yi] for xi, yi in zip(x, y)], **style) y_bounds = _tikz_error_bounds(line["yerr"], y) @@ -2196,15 +2221,15 @@ def plot_tikzfigure(self, layers=None, verbose: bool = False) -> TikzFigure: y = np.r_[values, values[-1]] where = "post" x, y = _tikz_step_coordinates(x, y, where=where) - x = (x + self._xshift) * self._xscale - y = (y + self._yshift) * self._yscale + x = self._shift_x(x) + y = self._shift_y(y) tikz_figure.draw( nodes=[[xi, yi] for xi, yi in zip(x, y)], **_tikz_style_kwargs(kwargs), ) elif plot_type == "stem": - x = (line["x"] + self._xshift) * self._xscale - y = (line["y"] + self._yshift) * self._yscale + x = self._shift_x(line["x"]) + y = self._shift_y(line["y"]) kwargs = line["kwargs"] style = _tikz_style_kwargs(kwargs) marker_style = dict(style) @@ -2267,7 +2292,7 @@ def plot_tikzfigure(self, layers=None, verbose: bool = False) -> TikzFigure: ) elif line["plot_type"] == "gantt": tasks = line["tasks"] - start_times = (line["start_times"] + self._xshift) * self._xscale + start_times = self._shift_x(line["start_times"]) durations = line["durations"] * self._xscale y_positions = np.arange(len(tasks)) @@ -2306,7 +2331,7 @@ def plot_tikzfigure(self, layers=None, verbose: bool = False) -> TikzFigure: if start_times is None: start_times = np.zeros(n) else: - start_times = (start_times + self._xshift) * self._xscale + start_times = self._shift_x(start_times) for i in range(n): if parents[i] is None: @@ -2484,6 +2509,16 @@ def bar_marker(kwargs): for line in self._iter_layer_lines(layers=layers): plot_type = line["plot_type"] + if plot_type in ("contour", "contourf", "pcolormesh") and ( + np.ndim(line["x"]) == 2 or np.ndim(line["y"]) == 2 + ): + if allow_unsupported: + continue + raise NotImplementedError( + f"Plotly cannot draw {plot_type} on 2-D (curvilinear) " + "coordinates; use the matplotlib backend, or pass " + "allow_unsupported=True to skip it for Plotly" + ) if plot_type in unsupported_plot_types: if allow_unsupported: continue @@ -2597,6 +2632,8 @@ def bar_marker(kwargs): marker=marker_dict, ) traces.append(trace) + if colorscale is not None: + last_heatmap_idx = len(traces) - 1 elif plot_type == "bar": kwargs = line["kwargs"] base = kwargs.get("bottom") @@ -2841,7 +2878,9 @@ def bar_marker(kwargs): y=line["y"], z=line["z"], contours=contours, - colorscale=kwargs.get("cmap", "Viridis"), + colorscale=_colormap_to_plotly_colorscale( + kwargs.get("cmap", "viridis") + ), showscale=kwargs.get("colorbar", True), zmin=kwargs.get("vmin"), zmax=kwargs.get("vmax"), @@ -2865,7 +2904,9 @@ def bar_marker(kwargs): x=line["x"], y=line["y"], z=line["z"], - colorscale=kwargs.get("cmap", "Viridis"), + colorscale=_colormap_to_plotly_colorscale( + kwargs.get("cmap", "viridis") + ), showscale=kwargs.get("colorbar", True), contours=contours, zmin=kwargs.get("vmin"), @@ -2880,7 +2921,9 @@ def bar_marker(kwargs): x=line["x"], y=line["y"], z=line["z"], - colorscale=kwargs.get("cmap", "Viridis"), + colorscale=_colormap_to_plotly_colorscale( + kwargs.get("cmap", "viridis") + ), showscale=kwargs.get("colorbar", True), zmin=kwargs.get("vmin"), zmax=kwargs.get("vmax"), @@ -3675,10 +3718,12 @@ def error_spec(values, scale): elif plot_type == "colorbar": if last_heatmap_idx is not None: trace = traces[last_heatmap_idx] - trace.update(showscale=True) + # Value-colored scatter markers carry their own scale. + target = trace.marker if trace.type == "scatter" else trace + target.update(showscale=True) label = line.get("label", "") or line["kwargs"].get("label", "") if label: - trace.update(colorbar=dict(title=dict(text=label))) + target.update(colorbar=dict(title=dict(text=label))) elif plot_type == "patch": kwargs = line["kwargs"] patch = line["patch"] @@ -4066,12 +4111,28 @@ def _symlog_inverse(self, values): def _plotext_axis_scale(self, axis: str): return self._xaxis_scale if axis == "x" else self._yaxis_scale + def _shift_x(self, values): + """Apply ``xshift``/``xscale``; values pass through unchanged by default. + + Skipping the no-op arithmetic keeps non-numeric data, such as + datetimes or category labels, plottable. + """ + if self._xshift == 0 and self._xscale == 1: + return values + return (values + self._xshift) * self._xscale + + def _shift_y(self, values): + """Apply ``yshift``/``yscale``; see :meth:`_shift_x`.""" + if self._yshift == 0 and self._yscale == 1: + return values + return (values + self._yshift) * self._yscale + def _plotext_axis_transform(self, values, axis: str): array = np.asarray(values) if axis == "x": - transformed = (array + self._xshift) * self._xscale + transformed = self._shift_x(array) else: - transformed = (array + self._yshift) * self._yscale + transformed = self._shift_y(array) if self._plotext_axis_scale(axis) == "symlog": return self._symlog_transform(transformed) return transformed diff --git a/src/maxplotlib/tests/test_xarray.py b/src/maxplotlib/tests/test_xarray.py index 4dd67f2..0713d5e 100644 --- a/src/maxplotlib/tests/test_xarray.py +++ b/src/maxplotlib/tests/test_xarray.py @@ -54,9 +54,9 @@ def test_labels_from_attrs(): def test_dimension_without_coordinate_uses_index(): da = xr.DataArray([3.0, 4.0, 5.0], dims="i") - x, y, xlabel, ylabel = xarray_support.line_data(da) - np.testing.assert_array_equal(x, [0, 1, 2]) - assert xlabel == "i" + data = xarray_support.line_data(da) + np.testing.assert_array_equal(data.lines[0][0], [0, 1, 2]) + assert data.coord_label == "i" def test_canvas_plot_dataarray_matplotlib(): @@ -133,7 +133,7 @@ def test_pcolormesh_dataarray_matplotlib(): def test_pcolormesh_dataarray_transpose_and_no_colorbar(): da = _mesh() canvas = Canvas() - canvas.pcolormesh(da, xdim="y", add_colorbar=False) + canvas.pcolormesh(da, xcoord="y", add_colorbar=False) fig, axes = canvas.get_matplotlib_figaxs() ax = np.ravel(axes)[0] assert ax.get_xlabel() == "y" @@ -159,10 +159,10 @@ def test_pcolormesh_dataarray_errors(): canvas = Canvas() with pytest.raises(ValueError, match="2-D DataArray"): canvas.pcolormesh(_line()) - with pytest.raises(ValueError, match="not a dimension"): - canvas.pcolormesh(_mesh(), xdim="t") - with pytest.raises(ValueError, match="different"): - canvas.pcolormesh(_mesh(), xdim="x", ydim="x") + with pytest.raises(ValueError, match="not a dimension or coordinate"): + canvas.pcolormesh(_mesh(), xcoord="t") + with pytest.raises(ValueError, match="different dimensions"): + canvas.pcolormesh(_mesh(), xcoord="x", ycoord="x") with pytest.raises(TypeError, match="no y or z"): canvas.pcolormesh(_mesh(), np.arange(3)) with pytest.raises(TypeError, match="requires x, y and z"): @@ -341,9 +341,10 @@ def test_plot_hue_draws_one_labelled_line_per_value(): def test_hue_on_first_dimension_and_without_coordinate(): da = _species().T.drop_vars("species") - x, lines, _, _ = xarray_support.line_data(da, hue="species") - assert [label for _, label in lines] == ["species = 0", "species = 1"] - np.testing.assert_allclose(lines[1][0], 2 * x) + data = xarray_support.line_data(da, hue="species") + assert [label for _, _, label in data.lines] == ["species = 0", "species = 1"] + positions, values, _ = data.lines[1] + np.testing.assert_allclose(values, 2 * positions) def test_hue_errors_and_add_legend(): @@ -408,7 +409,9 @@ def test_facet_row_and_col_lines(): da = _cube() canvas, axes = Canvas.facet(da.isel(y=0), row="t", kind="plot") assert len(axes) == 3 and len(axes[0]) == 1 - assert axes[2][0]._title == "t = 1 s, y = 0" + # y = 0 is shared by every panel, so it goes in the figure title. + assert axes[2][0]._title == "t = 1 s" + assert canvas._suptitle == "y = 0" canvas, axes = Canvas.facet(da, row="t", col="y", kind="plot", color="k") assert len(axes) == 3 and len(axes[0]) == 3 fig, _ = canvas.get_matplotlib_figaxs() @@ -554,3 +557,340 @@ def test_maxplotlib_does_not_import_xarray(): code = "import sys, maxplotlib; assert 'xarray' not in sys.modules" subprocess.run([sys.executable, "-c", code], check=True) + + +# --------------------------------------------------------------------------- +# Non-numeric coordinates +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("kind", ["line", "scatter"]) +def test_datetime_and_string_coordinates(kind): + import maxplotlib.xarray # noqa: F401 + + dates = np.arange("2026-01-01", "2026-01-06", dtype="datetime64[D]") + for da in ( + xr.DataArray(np.arange(5.0), dims="time", coords={"time": dates}), + xr.DataArray([1.0, 2, 3], dims="species", coords={"species": ["a", "b", "c"]}), + ): + canvas = getattr(da.maxplot, kind)() + fig, _ = canvas.get_matplotlib_figaxs() + plt.close(fig) + np.testing.assert_array_equal( + canvas.render(backend="plotly").data[0].x, da[da.dims[0]].values + ) + + +def test_shift_and_scale_still_apply_to_numbers(): + canvas, ax = Canvas.subplots() + ax._xshift, ax._xscale = 1.0, 2.0 + ax.plot([0.0, 1.0], [0.0, 1.0]) + fig, axes = canvas.get_matplotlib_figaxs() + np.testing.assert_allclose(axes[0][0].get_lines()[0].get_xdata(), [2.0, 4.0]) + plt.close(fig) + + +# --------------------------------------------------------------------------- +# Choosing coordinates: xcoord=/ycoord= (x=/y= in the accessor) +# --------------------------------------------------------------------------- + + +def _profile(): + z = np.linspace(0, 10, 6) + return xr.DataArray( + np.linspace(300, 250, 6), + dims="i", + coords={ + "z": ("i", z, {"long_name": "Height", "units": "km"}), + "p": ("i", 1000 - 50 * z, {"units": "hPa"}), + }, + name="T", + attrs={"units": "K"}, + ) + + +def test_line_against_non_dimension_coordinate(): + da = _profile() + canvas = Canvas() + canvas.plot(da, xcoord="p") + fig, axes = canvas.get_matplotlib_figaxs() + ax = np.ravel(axes)[0] + np.testing.assert_allclose(ax.get_lines()[0].get_xdata(), da.p.values) + assert ax.get_xlabel() == "p [hPa]" + plt.close(fig) + + +def test_vertical_profile_with_ycoord(): + da = _profile() + canvas = Canvas() + canvas.plot(da, ycoord="z") + fig, axes = canvas.get_matplotlib_figaxs() + ax = np.ravel(axes)[0] + line = ax.get_lines()[0] + np.testing.assert_allclose(line.get_xdata(), da.values) + np.testing.assert_allclose(line.get_ydata(), da.z.values) + assert (ax.get_xlabel(), ax.get_ylabel()) == ("T [K]", "Height [km]") + plt.close(fig) + + +def test_line_coordinate_errors(): + canvas = Canvas() + with pytest.raises(ValueError, match="not both"): + canvas.plot(_profile(), xcoord="z", ycoord="p") + with pytest.raises(ValueError, match="not a dimension or coordinate"): + canvas.plot(_profile(), xcoord="q") + with pytest.raises(ValueError, match="other than hue"): + canvas.plot(_species(), hue="species", xcoord="species") + + +def _curvilinear(): + r = np.linspace(1, 2, 4)[:, None] + theta = np.linspace(0, np.pi / 2, 5)[None, :] + return xr.DataArray( + np.arange(20.0).reshape(4, 5), + dims=("e1", "e2"), + coords={ + "R": (("e1", "e2"), r * np.cos(theta), {"units": "m"}), + "Z": (("e1", "e2"), r * np.sin(theta), {"units": "m"}), + }, + name="n", + ) + + +def test_pcolormesh_on_2d_coordinates(): + da = _curvilinear() + canvas = Canvas() + canvas.pcolormesh(da, xcoord="R", ycoord="Z") + fig, axes = canvas.get_matplotlib_figaxs() + ax = np.ravel(axes)[0] + mesh = ax.collections[0] + # Matplotlib turns the cell centres into a (5, 6) grid of cell corners. + assert mesh.get_coordinates().shape == (5, 6, 2) + np.testing.assert_allclose(mesh.get_array().reshape(4, 5), da.values) + # The edges extend half a cell beyond the R values (0 to 2 m). + low, high = ax.dataLim.intervalx + assert low < da.R.min() and high > da.R.max() + assert (ax.get_xlabel(), ax.get_ylabel()) == ("R [m]", "Z [m]") + plt.close(fig) + # Transposed coordinates are broadcast to the data's dimension order. + t = da.assign_coords(R=da.R.T) + mesh_data = xarray_support.mesh_data(t, x="R", y="Z") + np.testing.assert_allclose(mesh_data.x, da.R.values) + + +def test_2d_coordinate_errors_and_plotly(): + da = _curvilinear() + with pytest.raises(ValueError, match="give both"): + Canvas().pcolormesh(da, xcoord="R") + with pytest.raises(ValueError, match="imshow.*pcolormesh"): + Canvas().imshow(da, xcoord="R", ycoord="Z") + canvas = Canvas() + canvas.contourf(da, xcoord="R", ycoord="Z") + with pytest.raises(NotImplementedError, match="curvilinear"): + canvas.render(backend="plotly") + canvas.render(backend="plotly", allow_unsupported=True) + + +def test_mesh_with_one_axis_coordinate(): + da = _mesh().assign_coords(xc=("x", np.arange(4) * 10.0)) + data = xarray_support.mesh_data(da, x="xc") + assert (data.xname, data.yname) == ("xc", "y") + np.testing.assert_allclose(data.x, [0, 10, 20, 30]) + + +def test_accessor_x_and_y(): + import maxplotlib.xarray # noqa: F401 + + fig, axes = _profile().maxplot.line(y="z").get_matplotlib_figaxs() + assert np.ravel(axes)[0].get_ylabel() == "Height [km]" + plt.close(fig) + fig, axes = _curvilinear().maxplot.pcolormesh(x="R", y="Z").get_matplotlib_figaxs() + assert np.ravel(axes)[0].get_xlabel() == "R [m]" + plt.close(fig) + + +# --------------------------------------------------------------------------- +# Color defaults: centering and robust limits +# --------------------------------------------------------------------------- + + +def test_color_limits(): + signed = np.array([-1.0, 0.5, 3.0]) + assert xarray_support.color_limits(signed, {}) == { + "cmap": "RdBu_r", + "vmin": -3.0, + "vmax": 3.0, + } + assert xarray_support.color_limits(signed, {"cmap": "magma"})["cmap"] == "magma" + assert "cmap" not in xarray_support.color_limits(signed, {"colors": "k"}) + assert xarray_support.color_limits(signed, {"center": False}) == {} + assert xarray_support.color_limits(np.arange(3.0), {}) == {} + assert xarray_support.color_limits(np.arange(3.0), {"center": 1.5}) == { + "cmap": "RdBu_r", + "vmin": 0.0, + "vmax": 3.0, + } + both = {"vmin": -1, "vmax": 5} + assert xarray_support.color_limits(signed, dict(both)) == both + robust = xarray_support.color_limits(np.r_[np.arange(100.0), 1e6], {"robust": True}) + assert robust["vmax"] < 1e3 + assert "norm" in xarray_support.color_limits(signed, {"norm": "x"}) + + +def test_signed_data_gets_diverging_colormap_in_both_backends(): + da = _mesh() - 5.5 + canvas = Canvas() + canvas.pcolormesh(da) + fig, axes = canvas.get_matplotlib_figaxs() + mesh = np.ravel(axes)[0].collections[0] + assert mesh.get_cmap().name == "RdBu_r" + assert (mesh.norm.vmin, mesh.norm.vmax) == (-5.5, 5.5) + plt.close(fig) + heatmap = canvas.render(backend="plotly").data[0] + assert (heatmap.zmin, heatmap.zmax) == (-5.5, 5.5) + # Matplotlib's RdBu_r runs blue -> red; Plotly's own "RdBu_r" is reversed, + # so the colorscale must be sampled from Matplotlib. + low, high = heatmap.colorscale[0][1], heatmap.colorscale[-1][1] + assert low.startswith("#05") and high.startswith("#67") + + +# --------------------------------------------------------------------------- +# Colorbar layout +# --------------------------------------------------------------------------- + + +def test_colorbar_label_stays_inside_figure(): + canvas = Canvas() + canvas.pcolormesh(_mesh()) + fig, _ = canvas.get_matplotlib_figaxs() + fig.canvas.draw() + colorbar_axes = fig.axes[-1] + label = colorbar_axes.yaxis.label.get_window_extent() + assert label.x1 <= fig.bbox.x1 + plt.close(fig) + + +# --------------------------------------------------------------------------- +# Facet titles, legends and colors +# --------------------------------------------------------------------------- + + +def test_hue_lines_get_consistent_colors(): + canvas, axes = Canvas.facet( + xr.concat([_species(), 2 * _species()], dim="run"), + col="run", + kind="plot", + hue="species", + ) + fig = canvas.render(backend="plotly") + colors = [trace.line.color for trace in fig.data] + assert colors[:2] == colors[2:] + # One legend entry per species, grouped across panels. + assert [trace.showlegend for trace in fig.data] == [True, True, False, False] + assert {trace.legendgroup for trace in fig.data} == { + "species = ions", + "species = electrons", + } + + +def test_facet_without_coordinate_titles_by_index(): + da = _cube().drop_vars("t") + canvas, axes = Canvas.facet(da, col="t") + assert [ax._title for ax in axes[0]] == ["t = 0", "t = 1", "t = 2"] + + +def test_facet_vertical_profiles_share_value_axis(): + da = xr.concat([_profile(), _profile() + 10], dim="run") + canvas, axes = Canvas.facet(da, col="run", kind="plot", ycoord="z") + assert axes[0][0]._xmin == axes[0][1]._xmin + assert axes[0][0]._ymin is None + + +def test_facet_robust_and_center(): + da = _cube() - 10 + canvas, axes = Canvas.facet(da, col="t", center=False) + heatmaps = canvas.render(backend="plotly").data + assert {trace.zmin for trace in heatmaps} == {-10.0} + canvas, axes = Canvas.facet(da, col="t") + assert {trace.zmin for trace in canvas.render(backend="plotly").data} == {-25.0} + + +# --------------------------------------------------------------------------- +# Dataset accessor +# --------------------------------------------------------------------------- + + +def _dataset(): + t = np.linspace(0, 1, 5) + return xr.Dataset( + { + "n": (("species", "t"), np.stack([t + 1, 2 * t + 1]), {"units": "m^-3"}), + "T": (("species", "t"), np.stack([10 * t, 20 * t]), {"units": "eV"}), + "q": (("species", "t"), np.stack([-t, t]), {"long_name": "Charge"}), + }, + coords={"species": ["ions", "electrons"], "t": t}, + ) + + +def test_dataset_line_and_scatter_with_dimension_hue(): + import maxplotlib.xarray # noqa: F401 + + ds = _dataset() + fig = ds.maxplot.line(x="n", y="T", hue="species").render(backend="plotly") + assert [trace.name for trace in fig.data] == [ + "species = ions", + "species = electrons", + ] + np.testing.assert_allclose(fig.data[1].x, ds.n.sel(species="electrons")) + assert fig.layout.xaxis.title.text == "n [m^-3]" + canvas = ds.isel(species=0).maxplot.scatter(x="n", y="T") + fig, axes = canvas.get_matplotlib_figaxs() + assert np.ravel(axes)[0].get_title() == "species = ions" + plt.close(fig) + + +def test_dataset_scatter_colored_by_variable(): + import maxplotlib.xarray # noqa: F401 + + ds = _dataset() + canvas = ds.maxplot.scatter(x="n", y="T", hue="q") + fig, axes = canvas.get_matplotlib_figaxs() + points = np.ravel(axes)[0].collections[0] + assert len(points.get_offsets()) == 10 + assert points.get_cmap().name == "RdBu_r" # q has both signs + assert fig.axes[-1].get_ylabel() == "Charge" + plt.close(fig) + marker = canvas.render(backend="plotly").data[0].marker + assert marker.showscale is True + assert marker.colorbar.title.text == "Charge" + + +def test_dataset_errors(): + import maxplotlib.xarray # noqa: F401 + + ds = _dataset() + with pytest.raises(ValueError, match="not a variable"): + ds.maxplot.scatter(x="nope", y="T") + with pytest.raises(ValueError, match="facets are not supported"): + ds.maxplot.scatter(x="n", y="T", hue="q", col="species") + with pytest.raises(ValueError, match="not all dims"): + ds.assign(u=("u", [1.0, 2.0])).maxplot.line(x="u", y="T", hue="species") + + +def test_subplot_imshow_alias(): + canvas, ax = Canvas.subplots() + ax.imshow(_mesh()) + fig, axes = canvas.get_matplotlib_figaxs() + assert len(np.ravel(axes)[0].get_images()) == 1 + plt.close(fig) + + +def test_facet_figure_title_does_not_overlap_panel_titles(): + canvas, _ = Canvas.facet(_cube().isel(y=0), col="t", kind="plot") + fig, axes = canvas.get_matplotlib_figaxs() + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + suptitle = fig._suptitle.get_window_extent(renderer) + for ax in np.ravel(axes): + assert ax.title.get_window_extent(renderer).y1 <= suptitle.y0 + plt.close(fig) diff --git a/src/maxplotlib/utils/xarray_support.py b/src/maxplotlib/utils/xarray_support.py index c93fa10..a36438d 100644 --- a/src/maxplotlib/utils/xarray_support.py +++ b/src/maxplotlib/utils/xarray_support.py @@ -9,6 +9,7 @@ """ import sys +from typing import NamedTuple import numpy as np @@ -89,15 +90,16 @@ def _coord_text(name, coord, value) -> str: return f"{text} {unit}" if unit else text -def title(da) -> str: +def title(da, exclude=()) -> str: """Title from the single-value coordinates left by ``sel``/``isel``. For example ``da.sel(t=1.0)`` gives ``"t = 1 s"`` if ``t`` has units ``s``. + Coordinates in ``exclude`` are left out. """ return ", ".join( _coord_text(name, coord, magnitude(coord)) for name, coord in da.coords.items() - if coord.ndim == 0 + if coord.ndim == 0 and name not in exclude ) @@ -107,12 +109,53 @@ def _check_dims(da, *dims): raise ValueError(f"{dim!r} is not a dimension of {da.dims}") -def line_data(da, hue=None): - """Return ``(x, [(y, label), ...], xlabel, ylabel)`` for plotting lines. +def _resolve(da, name): + """Return ``(dims, variable)`` for a dimension or coordinate name. + + ``variable`` is the coordinate, or ``None`` for a dimension without one. + """ + if name in da.coords: + coord = da.coords[name] + if not set(coord.dims) <= set(da.dims): + raise ValueError(f"coordinate {name!r} has dims outside {da.dims}") + return coord.dims, coord + if name in da.dims: + return (name,), None + raise ValueError( + f"{name!r} is not a dimension or coordinate of the DataArray; " + f"dims are {da.dims}, coordinates {tuple(da.coords)}" + ) + + +def _axis(da, name): + """``(values, label)`` for a 1-D dimension or coordinate.""" + dims, coord = _resolve(da, name) + if coord is None: + return np.arange(da.sizes[name]), str(name) + return magnitude(coord), _label(coord) + + +class LineData(NamedTuple): + """Data for :func:`line_data`: one ``(positions, values, label)`` per line.""" + + lines: list + coord_label: str + value_label: str + vertical: bool + + +def line_data(da, hue=None, x=None, y=None): + """Return the positions, value arrays and labels for plotting lines. A 1-D array gives one unlabelled line. A 2-D array needs ``hue``, the - dimension to draw one line per value of. + dimension to draw one line per value of. ``x`` names the dimension or + coordinate to plot against; ``y`` does the same but puts the coordinate + on the y-axis (``vertical`` is then True), e.g. for profiles. With + ``hue``, the coordinate may also vary along ``hue`` (2-D), giving each + line its own positions. """ + if x is not None and y is not None: + raise ValueError("give x= or y= for a line plot, not both") _check_dims(da, hue) if hue is None and da.ndim != 1: raise ValueError( @@ -124,11 +167,29 @@ def line_data(da, hue=None): raise ValueError( f"hue= needs a 2-D DataArray, got {da.ndim}-D with dims {da.dims}" ) + name = x if x is not None else y + if name is None: + (dim,) = [d for d in da.dims if d != hue] + name = dim + coord = None + else: + dims, coord = _resolve(da, name) + along = [d for d in dims if d != hue] + if len(along) != 1 or len(dims) > 1 + (hue is not None): + raise ValueError( + f"{name!r} must be a dimension, or a coordinate along one " + f"dimension other than hue (optionally also along hue); it " + f"has dims {dims}" + ) + (dim,) = along + if coord is not None and coord.ndim == 2: + positions = magnitude(coord.transpose(hue, dim)) + position_label = _label(coord) + else: + positions, position_label = _axis(da, name) if hue is None: - (dim,) = da.dims - lines = [(magnitude(da), None)] + lines = [(positions, magnitude(da), None)] else: - (dim,) = [d for d in da.dims if d != hue] hue_coord = da.coords[hue] if hue in da.coords else None values = magnitude(da.transpose(hue, dim)) lines = [] @@ -137,46 +198,85 @@ def line_data(da, hue=None): label = f"{hue} = {i}" else: label = _coord_text(hue, hue_coord, magnitude(hue_coord)[i]) - lines.append((values[i], label)) - return coord_values(da, dim), lines, coord_label(da, dim), value_label(da) + line_positions = positions[i] if np.ndim(positions) == 2 else positions + lines.append((line_positions, values[i], label)) + return LineData(lines, position_label, value_label(da), y is not None) + + +class MeshData(NamedTuple): + """Data for :func:`mesh_data`; ``x``/``y`` are 2-D on curvilinear grids.""" + x: np.ndarray + y: np.ndarray + z: np.ndarray + xlabel: str + ylabel: str + zlabel: str + xname: str + yname: str -def mesh_dims(da, x=None, y=None, method="pcolormesh"): - """Return the ``(x, y)`` dimension names for plotting a 2-D DataArray. + @property + def curvilinear(self) -> bool: + return np.ndim(self.x) == 2 - Like ``xarray.DataArray.plot.pcolormesh``, the first dimension goes on the - y-axis and the second on the x-axis unless ``x`` or ``y`` names a dimension. + +def mesh_data(da, x=None, y=None, method="pcolormesh"): + """Return the coordinates, values and labels for a 2-D DataArray. + + ``x``/``y`` name a dimension or a coordinate, which may be 2-D, e.g. + physical ``R(e1, e2)`` over logical dimensions. Like + ``xarray.DataArray.plot.pcolormesh``, the first dimension goes on the + y-axis and the second on the x-axis by default. """ if da.ndim != 2: raise ValueError( f"{method}() needs a 2-D DataArray, got {da.ndim}-D with dims " f"{da.dims}. Select a slice first, e.g. da.sel(...) or da.isel(...)." ) - _check_dims(da, x, y) if x is None and y is None: y, x = da.dims - elif x is None: - (x,) = [d for d in da.dims if d != y] + resolved = {name: _resolve(da, name) for name in (x, y) if name is not None} + if any(len(dims) == 2 for dims, _ in resolved.values()): + if x is None or y is None: + raise ValueError( + "give both x= and y= to plot on a 2-D coordinate " + f"({', '.join(resolved)})" + ) + return _curvilinear_mesh(da, x, y) + if x is None: + (ydim,) = resolved[y][0] + (x,) = [d for d in da.dims if d != ydim] elif y is None: - (y,) = [d for d in da.dims if d != x] - if x == y: - raise ValueError("x and y must be different dimensions") - return x, y - - -def mesh_data(da, x=None, y=None, method="pcolormesh"): - """Return ``(x, y, z, xlabel, ylabel, zlabel)`` for a 2-D DataArray. - - See :func:`mesh_dims` for which dimension goes on which axis. - """ - x, y = mesh_dims(da, x, y, method) - return ( - coord_values(da, x), - coord_values(da, y), - magnitude(da.transpose(y, x)), - coord_label(da, x), - coord_label(da, y), + (xdim,) = resolved[x][0] + (y,) = [d for d in da.dims if d != xdim] + (xdim,), _ = _resolve(da, x) + (ydim,), _ = _resolve(da, y) + if xdim == ydim: + raise ValueError(f"x and y must lie along different dimensions ({x!r}, {y!r})") + xvalues, xlabel = _axis(da, x) + yvalues, ylabel = _axis(da, y) + z = magnitude(da.transpose(ydim, xdim)) + return MeshData(xvalues, yvalues, z, xlabel, ylabel, value_label(da), x, y) + + +def _curvilinear_mesh(da, x, y): + def grid(name): + dims, coord = _resolve(da, name) + if coord is None: + coord = type(da)(np.arange(da.sizes[name]), dims=dims) + return magnitude(coord.broadcast_like(da).transpose(*da.dims)), coord + + xvalues, xcoord = grid(x) + yvalues, ycoord = grid(y) + return MeshData( + xvalues, + yvalues, + magnitude(da), + _label(xcoord) if xcoord.name is not None else str(x), + _label(ycoord) if ycoord.name is not None else str(y), value_label(da), + x, + y, ) @@ -200,3 +300,47 @@ def _edges(values, name): def image_extent(x, y, xname="x", yname="y"): """``(left, right, bottom, top)`` for ``imshow(..., origin="lower")``.""" return (*_edges(x, xname), *_edges(y, yname)) + + +def color_limits(values, kwargs): + """Apply xarray's color defaults to ``kwargs`` for color-mapped ``values``. + + Pops ``robust`` and ``center``. ``robust=True`` takes ``vmin``/``vmax`` + from the 2nd and 98th percentiles. Data that crosses ``center`` (default: + 0, when it has both signs and ``vmin``/``vmax`` are not both given) gets + limits symmetric about it and, unless ``cmap`` or ``colors`` is given, + the diverging ``"RdBu_r"`` colormap. ``center=False`` turns that off. + A ``norm`` is left alone. + """ + robust = kwargs.pop("robust", False) + center = kwargs.pop("center", None) + if kwargs.get("norm") is not None: + return kwargs + values = np.asarray(values) + if not np.issubdtype(values.dtype, np.number): + return kwargs + finite = values[np.isfinite(values)] + if finite.size == 0: + return kwargs + if robust: + low, high = np.percentile(finite, [2, 98]) + else: + low, high = finite.min(), finite.max() + vmin, vmax = kwargs.get("vmin"), kwargs.get("vmax") + low = low if vmin is None else vmin + high = high if vmax is None else vmax + if center is None: + both_given = vmin is not None and vmax is not None + divergent = not both_given and low < 0 < high + center = 0.0 + else: + divergent = center is not False + if divergent: + half_range = max(abs(low - center), abs(high - center)) + low = center - half_range if vmin is None else vmin + high = center + half_range if vmax is None else vmax + if kwargs.get("colors") is None: # contour(colors=...) excludes cmap + kwargs.setdefault("cmap", "RdBu_r") + if divergent or robust: + kwargs["vmin"], kwargs["vmax"] = float(low), float(high) + return kwargs diff --git a/src/maxplotlib/xarray.py b/src/maxplotlib/xarray.py index 1966ff5..e95f4a0 100644 --- a/src/maxplotlib/xarray.py +++ b/src/maxplotlib/xarray.py @@ -1,6 +1,6 @@ -"""The ``DataArray.maxplot`` accessor. +"""The ``DataArray.maxplot`` and ``Dataset.maxplot`` accessors. -Importing this module registers it:: +Importing this module registers them:: import maxplotlib.xarray # noqa: F401 @@ -8,7 +8,8 @@ canvas.show(backend="plotly") Every method returns a new :class:`~maxplotlib.Canvas`; choose the backend -when rendering it. ``col=``, ``row=``, ``col_wrap=`` and ``sharey=`` lay the +when rendering it. ``x=``/``y=`` name the dimension or coordinate for each +axis, as in xarray. ``col=``, ``row=``, ``col_wrap=`` and ``sharey=`` lay the data out over several subplots with :meth:`Canvas.facet`, and ``canvas_kwargs=`` is forwarded to the Canvas constructor. Everything else is forwarded to the Canvas method of the same name (``line`` uses ``plot``). @@ -23,8 +24,9 @@ ) from error from maxplotlib.canvas.canvas import Canvas +from maxplotlib.utils import xarray_support -__all__ = ["MaxplotAccessor"] +__all__ = ["MaxplotAccessor", "MaxplotDatasetAccessor"] _FACET_KWARGS = ("col", "row", "col_wrap", "sharey") @@ -80,6 +82,11 @@ def contourf(self, **kwargs): def _draw(self, kind, kwargs): canvas_kwargs = kwargs.pop("canvas_kwargs", None) + # xarray's x=/y= are xcoord=/ycoord= on Canvas methods, where x and y + # are the positional data arguments. + for axis in ("x", "y"): + if axis in kwargs: + kwargs[f"{axis}coord"] = kwargs.pop(axis) if any(name in kwargs for name in _FACET_KWARGS): canvas, _ = Canvas.facet( self._da, kind=kind, canvas_kwargs=canvas_kwargs, **kwargs @@ -88,3 +95,76 @@ def _draw(self, kind, kwargs): canvas = Canvas(**(canvas_kwargs or {})) getattr(canvas, kind)(self._da, **kwargs) return canvas + + +@xr.register_dataset_accessor("maxplot") +class MaxplotDatasetAccessor: + """Plot one Dataset variable against another: ``ds.maxplot.scatter(x=, y=)``.""" + + def __init__(self, ds): + self._ds = ds + + def line(self, x, y, hue=None, **kwargs): + """Lines of variable ``y`` against variable or coordinate ``x``. + + ``hue`` names a dimension to draw one line per value of. Other + arguments are as for ``DataArray.maxplot.line``, including facets. + """ + return self._as_dataarray(x, y).maxplot.line(x=x, hue=hue, **kwargs) + + def scatter(self, x, y, hue=None, **kwargs): + """Points of variable ``y`` against variable or coordinate ``x``. + + ``hue`` names a dimension, for one series per value, or a variable, + which colors the points by value with a labelled colorbar (turned off + by ``add_colorbar=False``; ``cmap``, ``robust`` and ``center`` apply). + """ + if hue is None or hue in self._ds.dims: + return self._as_dataarray(x, y).maxplot.scatter(x=x, hue=hue, **kwargs) + if any(name in kwargs for name in _FACET_KWARGS): + raise ValueError("facets are not supported with a variable as hue=") + return self._scatter_colored(x, y, hue, kwargs) + + def _variable(self, name): + if name not in self._ds.variables: + raise ValueError( + f"{name!r} is not a variable or coordinate of the Dataset; " + f"variables are {tuple(self._ds.data_vars)}" + ) + return self._ds[name] + + def _as_dataarray(self, x, y): + """``y`` with ``x`` attached as a coordinate, to plot against it.""" + xvar, yvar = self._variable(x), self._variable(y) + if x in yvar.coords: + return yvar + if not set(xvar.dims) <= set(yvar.dims): + raise ValueError( + f"{x!r} has dims {xvar.dims}, which are not all dims of " + f"{y!r} {yvar.dims}" + ) + return yvar.assign_coords({x: xvar}) + + def _scatter_colored(self, x, y, hue, kwargs): + canvas_kwargs = kwargs.pop("canvas_kwargs", None) + add_colorbar = kwargs.pop("add_colorbar", True) + xvar, yvar, cvar = xr.broadcast( + self._variable(x), self._variable(y), self._variable(hue) + ) + colors = xarray_support.magnitude(cvar).ravel() + xarray_support.color_limits(colors, kwargs) + canvas = Canvas(**(canvas_kwargs or {})) + canvas.scatter( + xarray_support.magnitude(xvar).ravel(), + xarray_support.magnitude(yvar).ravel(), + c=colors, + **kwargs, + ) + canvas.set_xlabel(xarray_support.value_label(xvar)) + canvas.set_ylabel(xarray_support.value_label(yvar)) + title = xarray_support.title(yvar) + if title: + canvas.set_title(title) + if add_colorbar: + canvas.colorbar(label=xarray_support.value_label(cvar)) + return canvas diff --git a/tutorials/tutorial_17_xarray.ipynb b/tutorials/tutorial_17_xarray.ipynb new file mode 100644 index 0000000..ec0822a --- /dev/null +++ b/tutorials/tutorial_17_xarray.ipynb @@ -0,0 +1,568 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Tutorial 17 – Plotting xarray data\n", + "\n", + "maxplotlib understands labelled [xarray](https://docs.xarray.dev) data. Hand it a\n", + "`DataArray` and it takes the coordinates for the axes, builds axis labels from the\n", + "`long_name` and `units` attributes, and titles the plot with the coordinates you\n", + "selected. Because the result is an ordinary `Canvas`, the same plot renders with\n", + "every backend: Matplotlib, Plotly, plotext or TikZ.\n", + "\n", + "There are two ways in:\n", + "\n", + "- **Canvas methods** such as `canvas.plot(da)`, `canvas.pcolormesh(da)` and\n", + " `Canvas.facet(da, ...)`, which fit in with the rest of maxplotlib.\n", + "- **The `.maxplot` accessor**, `da.maxplot.line()` and friends, which mirrors\n", + " xarray's own `da.plot.line()` API. Enable it with `import maxplotlib.xarray`.\n", + "\n", + "xarray is optional: install it with `pip install maxplotlibx[xarray]`.\n", + "maxplotlib never imports it for you." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import xarray as xr\n", + "\n", + "import maxplotlib.xarray # registers da.maxplot and ds.maxplot\n", + "from maxplotlib import Canvas" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## A labelled line\n", + "\n", + "A 1-D `DataArray` is plotted against its coordinate. The axis labels come from the\n", + "attributes: `long_name` (falling back to the variable name) plus `[units]`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "t = np.linspace(0, 10, 400)\n", + "energy = xr.DataArray(\n", + " np.exp(-0.2 * t) * np.cos(3 * t) ** 2,\n", + " dims=\"t\",\n", + " coords={\"t\": (\"t\", t, {\"long_name\": \"Time\", \"units\": \"s\"})},\n", + " name=\"energy\",\n", + " attrs={\"long_name\": \"Field energy\", \"units\": \"J\"},\n", + ")\n", + "\n", + "canvas = Canvas(width=\"12cm\", ratio=0.5)\n", + "canvas.plot(energy, color=\"steelblue\")\n", + "canvas.show()" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "Labels you set yourself always win, whether you set them before or after\n", + "plotting the array." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "canvas = Canvas(width=\"12cm\", ratio=0.5)\n", + "canvas.set_ylabel(\"$W$ [J]\")\n", + "canvas.plot(energy, color=\"steelblue\")\n", + "canvas.show()" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "## Choose the backend when rendering\n", + "\n", + "`da.maxplot.line()` returns a `Canvas`; it doesn't draw anything yet. Choose\n", + "the backend when you show or save it, so the same object gives you an interactive\n", + "Plotly figure, a terminal plot or TikZ code." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "canvas = energy.maxplot.line(canvas_kwargs={\"width\": \"12cm\", \"ratio\": 0.5})\n", + "fig = canvas.show(backend=\"plotly\")\n", + "fig" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "canvas.show(backend=\"plotext\")" + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "## Titles from selections\n", + "\n", + "After `sel` or `isel`, the selected values stay on the array as single-value\n", + "coordinates. maxplotlib turns them into the title, with units, unless you set one." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 80)\n", + "y = np.linspace(-1, 1, 50)\n", + "ts = np.linspace(0, 1.5, 6)\n", + "wave = xr.DataArray(\n", + " np.sin(x[None, None, :] - 2 * ts[:, None, None]) * np.exp(-3 * y[None, :, None] ** 2),\n", + " dims=(\"t\", \"y\", \"x\"),\n", + " coords={\n", + " \"t\": (\"t\", ts, {\"units\": \"s\"}),\n", + " \"y\": (\"y\", y, {\"long_name\": \"Height\", \"units\": \"m\"}),\n", + " \"x\": (\"x\", x, {\"units\": \"m\"}),\n", + " },\n", + " name=\"phi\",\n", + " attrs={\"long_name\": \"Potential\", \"units\": \"V\"},\n", + ")\n", + "\n", + "wave.sel(t=0.6).isel(y=25).maxplot.line().show()" + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, + "source": [ + "## Time series and categories\n", + "\n", + "Datetime and text coordinates work as they are." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "days = np.arange(\"2026-01-01\", \"2026-03-01\", dtype=\"datetime64[D]\")\n", + "temperature = xr.DataArray(\n", + " 5 + 3 * np.sin(np.arange(days.size) / 9) + np.random.default_rng(0).normal(0, 0.6, days.size),\n", + " dims=\"time\",\n", + " coords={\"time\": days},\n", + " name=\"temperature\",\n", + " attrs={\"units\": \"°C\"},\n", + ")\n", + "canvas = temperature.maxplot.line(canvas_kwargs={\"width\": \"12cm\", \"ratio\": 0.45})\n", + "canvas.autofmt_xdate()\n", + "canvas.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "yields = xr.DataArray(\n", + " [4.2, 3.1, 5.6, 2.4],\n", + " dims=\"crop\",\n", + " coords={\"crop\": [\"wheat\", \"barley\", \"maize\", \"oats\"]},\n", + " name=\"yield\",\n", + " attrs={\"units\": \"t/ha\"},\n", + ")\n", + "yields.maxplot.scatter(s=60).show()" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "## One line per value: `hue=`\n", + "\n", + "A 2-D array draws as one line per value of the `hue` dimension. The lines are\n", + "labelled `dim = value`, coloured `C0`, `C1`, … and the legend is switched on.\n", + "Pass `add_legend=False` to hide it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "density = xr.DataArray(\n", + " np.stack([np.exp(-t / 3), 0.8 * np.exp(-t / 5), 0.5 * np.exp(-t)]),\n", + " dims=(\"species\", \"t\"),\n", + " coords={\"species\": [\"ions\", \"electrons\", \"fast\"], \"t\": energy.t},\n", + " name=\"density\",\n", + " attrs={\"units\": \"m^-3\"},\n", + ")\n", + "density.maxplot.line(hue=\"species\").show()" + ] + }, + { + "cell_type": "markdown", + "id": "16", + "metadata": {}, + "source": [ + "## Choosing the coordinate for each axis\n", + "\n", + "With the accessor, `x=` and `y=` name a dimension or coordinate, exactly as in\n", + "xarray. On Canvas methods, where `x` and `y` are already the positional data\n", + "arguments, the same options are called `xcoord=` and `ycoord=`.\n", + "\n", + "`x=` can pick a non-dimension coordinate, and `y=` puts the coordinate on the\n", + "y-axis for a vertical profile:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], + "source": [ + "z = np.linspace(0, 12, 40)\n", + "temperature_profile = xr.DataArray(\n", + " 288 - 6.5 * np.minimum(z, 11) + 0.4 * np.sin(z),\n", + " dims=\"level\",\n", + " coords={\n", + " \"z\": (\"level\", z, {\"long_name\": \"Altitude\", \"units\": \"km\"}),\n", + " \"p\": (\"level\", 1013 * np.exp(-z / 8), {\"long_name\": \"Pressure\", \"units\": \"hPa\"}),\n", + " },\n", + " name=\"T\",\n", + " attrs={\"long_name\": \"Temperature\", \"units\": \"K\"},\n", + ")\n", + "\n", + "canvas, (left, right) = Canvas.subplots(ncols=2, width=\"14cm\", ratio=0.45, wspace=0.35)\n", + "left.plot(temperature_profile, xcoord=\"p\")\n", + "right.plot(temperature_profile, ycoord=\"z\")\n", + "canvas.show()" + ] + }, + { + "cell_type": "markdown", + "id": "18", + "metadata": {}, + "source": [ + "## 2-D fields\n", + "\n", + "`pcolormesh`, `imshow`, `contourf` and `contour` take a 2-D array. Like xarray,\n", + "the first dimension goes on the y-axis. A labelled colorbar is added, except for\n", + "`contour`; `add_colorbar=` changes that. Other keyword arguments go to the\n", + "backend as usual, such as `aspect=\"auto\"` to stretch the image to its axes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19", + "metadata": {}, + "outputs": [], + "source": [ + "field = wave.sel(t=0)\n", + "canvas, axes = Canvas.subplots(nrows=2, ncols=2, width=\"16cm\", ratio=0.75, wspace=0.7, hspace=0.5)\n", + "axes[0][0].pcolormesh(field)\n", + "axes[0][1].imshow(field, aspect=\"auto\")\n", + "axes[1][0].contourf(field)\n", + "axes[1][1].contour(field, add_colorbar=True)\n", + "canvas.show()" + ] + }, + { + "cell_type": "markdown", + "id": "20", + "metadata": {}, + "source": [ + "Swap the axes by naming the coordinates. Plots also combine: here are contour\n", + "lines drawn over a mesh." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [], + "source": [ + "canvas = Canvas(width=\"10cm\", ratio=0.8)\n", + "canvas.pcolormesh(field, xcoord=\"y\", ycoord=\"x\", cmap=\"magma\", center=False)\n", + "canvas.contour(field, xcoord=\"y\", ycoord=\"x\", colors=\"white\", levels=6)\n", + "canvas.show()" + ] + }, + { + "cell_type": "markdown", + "id": "22", + "metadata": {}, + "source": [ + "## Colour defaults\n", + "\n", + "Data with both signs gets a diverging colour map (`RdBu_r`) centred on zero, as in\n", + "xarray. `center=` moves the centre and `center=False` turns this off. `robust=True`\n", + "takes the colour limits from the 2nd and 98th percentiles, so a few outliers\n", + "don't wash out the plot." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "23", + "metadata": {}, + "outputs": [], + "source": [ + "noisy = field.copy()\n", + "noisy[10, 10] = 25 # a single outlier\n", + "\n", + "canvas, (left, right) = Canvas.subplots(ncols=2, width=\"16cm\", ratio=0.4, wspace=0.7)\n", + "left.pcolormesh(noisy)\n", + "left.set_title(\"default: the outlier sets the scale\")\n", + "right.pcolormesh(noisy, robust=True)\n", + "right.set_title(\"robust=True\")\n", + "canvas.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "24", + "metadata": {}, + "outputs": [], + "source": [ + "canvas = Canvas(width=\"8cm\", ratio=0.8)\n", + "canvas.pcolormesh(field, center=False)\n", + "canvas.set_title(\"center=False: a sequential colour map\")\n", + "canvas.show()" + ] + }, + { + "cell_type": "markdown", + "id": "25", + "metadata": {}, + "source": [ + "## Curvilinear grids\n", + "\n", + "Simulation output often lives on logical dimensions with 2-D physical coordinates,\n", + "for example `R(e1, e2)` and `Z(e1, e2)`. Name both coordinates to draw the field\n", + "where it really is. This needs the Matplotlib backend; Plotly's heatmaps and\n", + "contours only support rectilinear grids." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26", + "metadata": {}, + "outputs": [], + "source": [ + "e1 = np.linspace(0.2, 1, 30)[:, None]\n", + "e2 = np.linspace(0, 2 * np.pi, 90)[None, :]\n", + "R = 3 + e1 * np.cos(e2)\n", + "Z = 1.6 * e1 * np.sin(e2)\n", + "pressure = xr.DataArray(\n", + " (1 - e1**2) * (1 + 0.15 * np.cos(3 * e2)),\n", + " dims=(\"e1\", \"e2\"),\n", + " coords={\"R\": ((\"e1\", \"e2\"), R, {\"units\": \"m\"}), \"Z\": ((\"e1\", \"e2\"), Z, {\"units\": \"m\"})},\n", + " name=\"p\",\n", + " attrs={\"long_name\": \"Pressure\", \"units\": \"kPa\"},\n", + ")\n", + "\n", + "canvas = pressure.maxplot.pcolormesh(x=\"R\", y=\"Z\", canvas_kwargs={\"width\": \"8cm\", \"ratio\": 1.1})\n", + "canvas.set_aspect(\"equal\")\n", + "canvas.show()" + ] + }, + { + "cell_type": "markdown", + "id": "27", + "metadata": {}, + "source": [ + "## Small multiples: facets\n", + "\n", + "`col=`, `row=` and `col_wrap=` lay the data out over a grid, one subplot per value.\n", + "Colour-mapped plots share one colour scale and one colorbar. Axis and tick labels\n", + "appear on the outer subplots only, and each subplot is titled with its value." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "28", + "metadata": {}, + "outputs": [], + "source": [ + "wave.maxplot.pcolormesh(col=\"t\", col_wrap=3, canvas_kwargs={\"width\": \"16cm\", \"ratio\": 0.6}).show()" + ] + }, + { + "cell_type": "markdown", + "id": "29", + "metadata": {}, + "source": [ + "Line facets share their value range too, which makes panels easy to compare.\n", + "`sharey=False` gives each panel its own scale. Coordinates that all panels share\n", + "(here the selected height) move into the figure title." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "30", + "metadata": {}, + "outputs": [], + "source": [ + "wave.sel(y=0, method=\"nearest\").maxplot.line(col=\"t\", col_wrap=3, canvas_kwargs={\"width\": \"16cm\", \"ratio\": 0.5}).show()" + ] + }, + { + "cell_type": "markdown", + "id": "31", + "metadata": {}, + "source": [ + "The accessor methods are shortcuts for `Canvas.facet`, which you can also call\n", + "directly. Like `Canvas.subplots`, it returns the canvas and a grid of subplots,\n", + "which you can keep editing. In Plotly, a label shared across subplots gets a\n", + "single legend entry that toggles all of them." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "32", + "metadata": {}, + "outputs": [], + "source": [ + "runs = xr.concat([density, 1.5 * density], dim=xr.DataArray([\"baseline\", \"heated\"], dims=\"run\", name=\"run\"))\n", + "canvas, axes = Canvas.facet(\n", + " runs.isel(t=slice(0, None, 4)), col=\"run\", kind=\"plot\", hue=\"species\",\n", + " canvas_kwargs={\"width\": \"15cm\", \"ratio\": 0.4},\n", + ")\n", + "axes[0][0].set_yscale(\"log\")\n", + "axes[0][1].set_yscale(\"log\")\n", + "fig = canvas.show(backend=\"plotly\")\n", + "fig" + ] + }, + { + "cell_type": "markdown", + "id": "33", + "metadata": {}, + "source": [ + "## Datasets\n", + "\n", + "`ds.maxplot.line()` and `ds.maxplot.scatter()` plot one variable against another.\n", + "`hue=` may be a dimension, for one series per value, or, for scatter, another\n", + "variable that colours the points." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34", + "metadata": {}, + "outputs": [], + "source": [ + "rng = np.random.default_rng(1)\n", + "n = rng.uniform(1, 5, (3, 60))\n", + "plasma = xr.Dataset(\n", + " {\n", + " \"n\": ((\"species\", \"sample\"), n, {\"long_name\": \"Density\", \"units\": \"1e19 m^-3\"}),\n", + " \"T\": ((\"species\", \"sample\"), 2 * n + rng.normal(0, 0.5, n.shape), {\"long_name\": \"Temperature\", \"units\": \"keV\"}),\n", + " \"v\": ((\"species\", \"sample\"), rng.normal(0, 1, n.shape), {\"long_name\": \"Flow\", \"units\": \"km/s\"}),\n", + " },\n", + " coords={\"species\": [\"D\", \"T\", \"e\"]},\n", + ")\n", + "\n", + "plasma.maxplot.scatter(x=\"n\", y=\"T\", hue=\"species\").show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "35", + "metadata": {}, + "outputs": [], + "source": [ + "plasma.maxplot.scatter(x=\"n\", y=\"T\", hue=\"v\").show()" + ] + }, + { + "cell_type": "markdown", + "id": "36", + "metadata": {}, + "source": [ + "## Units with pint\n", + "\n", + "If the data is a pint `Quantity`, as with\n", + "[pint-xarray](https://pint-xarray.readthedocs.io), maxplotlib reads the units from\n", + "it and plots the plain magnitudes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "37", + "metadata": {}, + "outputs": [], + "source": [ + "import pint\n", + "\n", + "ureg = pint.UnitRegistry()\n", + "speed = xr.DataArray(\n", + " ureg.Quantity(3 * np.sqrt(t), \"m/s\"),\n", + " dims=\"t\",\n", + " coords={\"t\": energy.t},\n", + " name=\"speed\",\n", + ")\n", + "speed.maxplot.line().show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 6e290be422ff614715c73d6a309693c37a734398 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 26 Sep 2026 22:09:26 +0200 Subject: [PATCH 5/5] formatting --- tutorials/tutorial_17_xarray.ipynb | 56 +++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/tutorials/tutorial_17_xarray.ipynb b/tutorials/tutorial_17_xarray.ipynb index ec0822a..b2d37c0 100644 --- a/tutorials/tutorial_17_xarray.ipynb +++ b/tutorials/tutorial_17_xarray.ipynb @@ -148,7 +148,8 @@ "y = np.linspace(-1, 1, 50)\n", "ts = np.linspace(0, 1.5, 6)\n", "wave = xr.DataArray(\n", - " np.sin(x[None, None, :] - 2 * ts[:, None, None]) * np.exp(-3 * y[None, :, None] ** 2),\n", + " np.sin(x[None, None, :] - 2 * ts[:, None, None])\n", + " * np.exp(-3 * y[None, :, None] ** 2),\n", " dims=(\"t\", \"y\", \"x\"),\n", " coords={\n", " \"t\": (\"t\", ts, {\"units\": \"s\"}),\n", @@ -181,7 +182,9 @@ "source": [ "days = np.arange(\"2026-01-01\", \"2026-03-01\", dtype=\"datetime64[D]\")\n", "temperature = xr.DataArray(\n", - " 5 + 3 * np.sin(np.arange(days.size) / 9) + np.random.default_rng(0).normal(0, 0.6, days.size),\n", + " 5\n", + " + 3 * np.sin(np.arange(days.size) / 9)\n", + " + np.random.default_rng(0).normal(0, 0.6, days.size),\n", " dims=\"time\",\n", " coords={\"time\": days},\n", " name=\"temperature\",\n", @@ -266,7 +269,11 @@ " dims=\"level\",\n", " coords={\n", " \"z\": (\"level\", z, {\"long_name\": \"Altitude\", \"units\": \"km\"}),\n", - " \"p\": (\"level\", 1013 * np.exp(-z / 8), {\"long_name\": \"Pressure\", \"units\": \"hPa\"}),\n", + " \"p\": (\n", + " \"level\",\n", + " 1013 * np.exp(-z / 8),\n", + " {\"long_name\": \"Pressure\", \"units\": \"hPa\"},\n", + " ),\n", " },\n", " name=\"T\",\n", " attrs={\"long_name\": \"Temperature\", \"units\": \"K\"},\n", @@ -299,7 +306,9 @@ "outputs": [], "source": [ "field = wave.sel(t=0)\n", - "canvas, axes = Canvas.subplots(nrows=2, ncols=2, width=\"16cm\", ratio=0.75, wspace=0.7, hspace=0.5)\n", + "canvas, axes = Canvas.subplots(\n", + " nrows=2, ncols=2, width=\"16cm\", ratio=0.75, wspace=0.7, hspace=0.5\n", + ")\n", "axes[0][0].pcolormesh(field)\n", "axes[0][1].imshow(field, aspect=\"auto\")\n", "axes[1][0].contourf(field)\n", @@ -400,12 +409,17 @@ "pressure = xr.DataArray(\n", " (1 - e1**2) * (1 + 0.15 * np.cos(3 * e2)),\n", " dims=(\"e1\", \"e2\"),\n", - " coords={\"R\": ((\"e1\", \"e2\"), R, {\"units\": \"m\"}), \"Z\": ((\"e1\", \"e2\"), Z, {\"units\": \"m\"})},\n", + " coords={\n", + " \"R\": ((\"e1\", \"e2\"), R, {\"units\": \"m\"}),\n", + " \"Z\": ((\"e1\", \"e2\"), Z, {\"units\": \"m\"}),\n", + " },\n", " name=\"p\",\n", " attrs={\"long_name\": \"Pressure\", \"units\": \"kPa\"},\n", ")\n", "\n", - "canvas = pressure.maxplot.pcolormesh(x=\"R\", y=\"Z\", canvas_kwargs={\"width\": \"8cm\", \"ratio\": 1.1})\n", + "canvas = pressure.maxplot.pcolormesh(\n", + " x=\"R\", y=\"Z\", canvas_kwargs={\"width\": \"8cm\", \"ratio\": 1.1}\n", + ")\n", "canvas.set_aspect(\"equal\")\n", "canvas.show()" ] @@ -429,7 +443,9 @@ "metadata": {}, "outputs": [], "source": [ - "wave.maxplot.pcolormesh(col=\"t\", col_wrap=3, canvas_kwargs={\"width\": \"16cm\", \"ratio\": 0.6}).show()" + "wave.maxplot.pcolormesh(\n", + " col=\"t\", col_wrap=3, canvas_kwargs={\"width\": \"16cm\", \"ratio\": 0.6}\n", + ").show()" ] }, { @@ -449,7 +465,9 @@ "metadata": {}, "outputs": [], "source": [ - "wave.sel(y=0, method=\"nearest\").maxplot.line(col=\"t\", col_wrap=3, canvas_kwargs={\"width\": \"16cm\", \"ratio\": 0.5}).show()" + "wave.sel(y=0, method=\"nearest\").maxplot.line(\n", + " col=\"t\", col_wrap=3, canvas_kwargs={\"width\": \"16cm\", \"ratio\": 0.5}\n", + ").show()" ] }, { @@ -470,9 +488,15 @@ "metadata": {}, "outputs": [], "source": [ - "runs = xr.concat([density, 1.5 * density], dim=xr.DataArray([\"baseline\", \"heated\"], dims=\"run\", name=\"run\"))\n", + "runs = xr.concat(\n", + " [density, 1.5 * density],\n", + " dim=xr.DataArray([\"baseline\", \"heated\"], dims=\"run\", name=\"run\"),\n", + ")\n", "canvas, axes = Canvas.facet(\n", - " runs.isel(t=slice(0, None, 4)), col=\"run\", kind=\"plot\", hue=\"species\",\n", + " runs.isel(t=slice(0, None, 4)),\n", + " col=\"run\",\n", + " kind=\"plot\",\n", + " hue=\"species\",\n", " canvas_kwargs={\"width\": \"15cm\", \"ratio\": 0.4},\n", ")\n", "axes[0][0].set_yscale(\"log\")\n", @@ -505,8 +529,16 @@ "plasma = xr.Dataset(\n", " {\n", " \"n\": ((\"species\", \"sample\"), n, {\"long_name\": \"Density\", \"units\": \"1e19 m^-3\"}),\n", - " \"T\": ((\"species\", \"sample\"), 2 * n + rng.normal(0, 0.5, n.shape), {\"long_name\": \"Temperature\", \"units\": \"keV\"}),\n", - " \"v\": ((\"species\", \"sample\"), rng.normal(0, 1, n.shape), {\"long_name\": \"Flow\", \"units\": \"km/s\"}),\n", + " \"T\": (\n", + " (\"species\", \"sample\"),\n", + " 2 * n + rng.normal(0, 0.5, n.shape),\n", + " {\"long_name\": \"Temperature\", \"units\": \"keV\"},\n", + " ),\n", + " \"v\": (\n", + " (\"species\", \"sample\"),\n", + " rng.normal(0, 1, n.shape),\n", + " {\"long_name\": \"Flow\", \"units\": \"km/s\"},\n", + " ),\n", " },\n", " coords={\"species\": [\"D\", \"T\", \"e\"]},\n", ")\n",