Source code for sdf_xarray.plotting

from __future__ import annotations

import warnings
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal

import numpy as np
import xarray as xr

if TYPE_CHECKING:
    import matplotlib.pyplot as plt
    from matplotlib.animation import FuncAnimation
    from matplotlib.colors import ListedColormap


[docs] @dataclass class AnimationUnit: update: Callable[[int], object] n_frames: int
[docs] def get_frame_title( data: xr.DataArray, frame: int, display_sdf_name: bool = False, title_custom: str | None = None, t: str = "time", ) -> str: """Generate the title for a frame Parameters ---------- data DataArray containing the target data frame Frame number display_sdf_name Display the sdf file name in the animation title title_custom Custom title to add to the plot t Time coordinate """ # Adds custom text to the start of the title, if specified title_custom = "" if title_custom is None else f"{title_custom}, " # Adds the time axis and associated units to the title t_axis_value = data[t][frame].values t_axis_units = data[t].attrs.get("units", False) t_axis_units_formatted = f" [{t_axis_units}]" if t_axis_units else "" title_t_axis = f"{data[t].long_name} = {t_axis_value:.2e}{t_axis_units_formatted}" # Adds sdf name to the title, if specified title_sdf = f", {frame:04d}.sdf" if display_sdf_name else "" return f"{title_custom}{title_t_axis}{title_sdf}"
[docs] def get_axis_label(dim: xr.DataArray) -> str: """Formats the axes label for a given dim in the form of ``dim.long_name [dim.units]``.""" return f"{dim.long_name} [{dim.units}]"
def _recover_vertex_coord(w_mid: xr.DataArray) -> np.ndarray: """Takes a midpoint coordinate, returns a vertex coordinate.""" w_size = w_mid.size dw = w_mid[1] - w_mid[0] w = np.zeros(w_mid.size + 1) w[:w_size] = w_mid - dw / 2 w[w_size] = w[w_size - 1] + dw return w
[docs] def shift_cmap( cmap: str, vmin: float, vmax: float, vcenter: float, N: int = 1024 ) -> ListedColormap: """ Create a new colormap where the visual center of the original colormap is shifted to a specific data value. Parameters ---------- cmap The name of the original colormap (e.g., 'viridis') or the colormap object itself. vmin The minimum value of your data range. vmax The maximum value of your data range. vcenter The data value that should map to the visual midpoint (0.5) of the colormap. N The number of interpolation steps (color bins) in the new colormap. Returns ------- The newly constructed, shifted colormap. """ import matplotlib.colors as mc # noqa: PLC0415 import matplotlib.pyplot as plt # noqa: PLC0415 # get the original colourmap if type(cmap) is str: cmap = plt.get_cmap(cmap) midpoint = (vcenter - vmin) / (vmax - vmin) lower_size = int(N * midpoint) upper_size = N - lower_size bottom_colors = cmap(np.linspace(0.0, 0.5, lower_size)) top_colors = cmap(np.linspace(0.5, 1.0, upper_size)) new_colors = np.vstack((bottom_colors, top_colors)) return mc.ListedColormap(new_colors, name=f"shifted_{cmap.name}")
[docs] def calculate_window_boundaries( data: xr.DataArray, xlim: tuple[float, float] | None = None, x_axis_name: str = "X_Grid_mid", t: str = "time", ) -> np.ndarray: """Calculate the boundaries a moving window frame. If the user specifies xlim, this will be used as the initial boundaries and the window will move along accordingly. Parameters ---------- data DataArray containing the target data xlim x limits x_axis_name Name of coordinate to assign to the x-axis t Time coordinate """ x_grid = data[x_axis_name].values x_half_cell = (x_grid[1] - x_grid[0]) / 2 n_frames = data[t].size # Find the window boundaries by finding the first and last non-NaN values in the 0th lineout # along the x-axis. window_boundaries = np.zeros((n_frames, 2)) for i in range(n_frames): # Check if data is 1D if data.ndim == 2: target_lineout = data[i].values # Check if data is 2D if data.ndim == 3: target_lineout = data[i, :, 0].values x_grid_non_nan = x_grid[~np.isnan(target_lineout)] window_boundaries[i, 0] = x_grid_non_nan[0] - x_half_cell window_boundaries[i, 1] = x_grid_non_nan[-1] + x_half_cell # User's choice for initial window edge supersedes the one calculated if xlim is not None: window_boundaries = window_boundaries + xlim - window_boundaries[0] return window_boundaries
[docs] def compute_global_limits( data: xr.DataArray, min_percentile: float = 0, max_percentile: float = 100, ) -> tuple[float, float]: """Remove all NaN values from the target data to calculate the global minimum and maximum of the data. User defined percentiles can remove extreme outliers. Parameters ---------- data DataArray containing the target data min_percentile Minimum percentile of the data max_percentile Maximum percentile of the data """ # Removes NaN values, needed for moving windows values_no_nan = data.values[~np.isnan(data.values)] # Finds the global minimum and maximum of the plot, based on the percentile of the data global_min = np.percentile(values_no_nan, min_percentile) global_max = np.percentile(values_no_nan, max_percentile) return global_min, global_max
def _set_axes_labels(ax: plt.Axes, axis_kwargs: dict) -> None: """Set the labels for the x and y axes""" if "xlabel" in axis_kwargs: ax.set_xlabel(axis_kwargs["xlabel"]) if "ylabel" in axis_kwargs: ax.set_ylabel(axis_kwargs["ylabel"])
[docs] def voxel_plot( da: xr.DataArray, ax: plt.Axes | None = None, vmin: float | None = None, vmax: float | None = None, vcenter: float | None = None, mask: np.ndarray[bool] | None = None, xlim: tuple[float | None, float | None] = (None, None), ylim: tuple[float | None, float | None] = (None, None), zlim: tuple[float | None, float | None] = (None, None), aspect: Literal["equal", "auto"] | tuple[float, float, float] = "equal", elev: float = 30, azim: float = -60, cmap: str = "viridis", cbar_scale: float = 0.9, **kwargs, ) -> tuple[plt.Figure, plt.Axes]: """ Plot 3-dimensional data as voxels. Parameters ---------- da DataArray to be plotted. ax Matplotlib axes on which to plot (This must use a 3d projection). vmin Minimum value. If `mask` is not stated, will be used to define the mask. vmax Maximum value. If `mask` is not stated, will be used to define the mask. vcenter Center value of the colourmap. Useful for diverging colourmaps with non-symmetrical data. mask Array of bools specifying which cells to show. Must be same size as ``da`` xlim, ylim, zlim Sets the limits of the plot. aspect Aspect ratio of the plot. "equal", "auto" or list of floats. (default = "equal") elev Elevation angle in degrees. (default = 30) azim Azimuthal angle in degrees. (default = -60) cmap Colourmap (default = "viridis") cbar_scale Vertical scale of the colorbar (default = 0.9) """ import matplotlib.pyplot as plt # noqa: PLC0415 warnings.warn( "Voxel plots can be extremely computationally expensive and may take longer than expected to plot.", stacklevel=2, ) if ax is None: fig, ax = plt.subplots(figsize=(8, 6), subplot_kw={"projection": "3d"}) else: fig = ax.get_figure() # Limit arrays based on axis limits da = da.epoch.limit((xlim, ylim, zlim)) dims = da.dims # Create W_Grid from W_Grid_mid coords x = _recover_vertex_coord(da[dims[0]]) y = _recover_vertex_coord(da[dims[1]]) z = _recover_vertex_coord(da[dims[2]]) # Create mesh x_mesh, y_mesh, z_mesh = np.meshgrid(x, y, z, indexing="ij") if vmin is None: vmin = np.min(da.values) if vmax is None: vmax = np.max(da.values) # Mask out data if mask is None: mask = (da >= vmin) * (da <= vmax) # Plot the data array ax.view_init(elev, azim) # Set axis labels ax.set_xlabel(get_axis_label(da[dims[0]])) ax.set_ylabel(get_axis_label(da[dims[1]])) ax.set_zlabel(get_axis_label(da[dims[2]])) # Find and set axis limits xlim, ylim, zlim = [ (data.min() if low is None else low, data.max() if high is None else high) for (low, high), data in zip([xlim, ylim, zlim], [x, y, z]) ] ax.set_xlim(xlim) ax.set_ylim(ylim) ax.set_zlim(zlim) # Compute and set the box aspect ratio if aspect == "equal": box_aspect = (1, 1, 1) elif aspect == "auto": box_aspect = (xlim[1] - xlim[0], ylim[1] - ylim[0], zlim[1] - zlim[0]) else: box_aspect = aspect ax.set_box_aspect(box_aspect) # Colour bar and colour map if vcenter is not None: cmap = shift_cmap(cmap, vmin, vmax, vcenter) norm = plt.Normalize(vmin=vmin, vmax=vmax) cmap = plt.get_cmap(cmap) colours = cmap(norm(da)) sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm) sm.set_array([]) # Required for the colorbar to function cbar_label = get_axis_label(da) fig.colorbar(sm, ax=ax, label=cbar_label, shrink=cbar_scale, aspect=20 * cbar_scale) ax.voxels(x_mesh, y_mesh, z_mesh, mask, facecolors=colours, **kwargs) return fig, ax
def _setup_line_plot( data: xr.DataArray, ax: plt.Axes, coord_names: list[str], kwargs: dict, axis_kwargs: dict, min_percentile: float, max_percentile: float, t: str, ) -> tuple[float, float]: """Line animation initialization.""" kwargs.setdefault("x", coord_names[0]) data.isel({t: 0}).plot(ax=ax, **kwargs) global_min, global_max = compute_global_limits(data, min_percentile, max_percentile) _set_axes_labels(ax, axis_kwargs) if "ylim" not in kwargs: ax.set_ylim(global_min, global_max) return global_min, global_max def _setup_pcolormesh_plot( data: xr.DataArray, ax: plt.Axes, coord_names: list[str], kwargs: dict, kwargs_original: dict, axis_kwargs: dict, min_percentile: float, max_percentile: float, t: str, ) -> None: """pcolormesh animation initialization.""" import matplotlib.pyplot as plt # noqa: PLC0415 if "norm" not in kwargs: global_min, global_max = compute_global_limits( data, min_percentile, max_percentile ) kwargs["norm"] = plt.Normalize(vmin=global_min, vmax=global_max) kwargs["add_colorbar"] = False kwargs.setdefault("x", coord_names[0]) kwargs.setdefault("y", coord_names[1]) argmin_time = np.unravel_index(np.argmin(data.values), data.shape)[0] plot = data.isel({t: argmin_time}).plot(ax=ax, **kwargs) kwargs["cmap"] = plot.cmap _set_axes_labels(ax, axis_kwargs) if kwargs_original.get("add_colorbar", True): long_name = data.attrs.get("long_name") units = data.attrs.get("units") fig = plot.get_figure() fig.colorbar(plot, ax=ax, label=f"{long_name} [{units}]") def _setup_voxel_plot( data: xr.DataArray, ax: plt.Axes, coord_names: list[str], kwargs: dict, kwargs_original: dict, axis_kwargs: dict, min_percentile: float, max_percentile: float, t: str, ) -> None: """ Voxel animation initialization. NOTE: this function exists for completeness, voxel plots don't work in animations yet """ import matplotlib.pyplot as plt # noqa: PLC0415 if "norm" not in kwargs: global_min, global_max = compute_global_limits( data, min_percentile, max_percentile ) kwargs["norm"] = plt.Normalize(vmin=global_min, vmax=global_max) kwargs["add_colorbar"] = False kwargs.setdefault("x", coord_names[0]) kwargs.setdefault("y", coord_names[1]) argmin_time = np.unravel_index(np.argmin(data.values), data.shape)[0] plot = data.isel({t: argmin_time}).plot(ax=ax, **kwargs) kwargs["cmap"] = plot.cmap _set_axes_labels(ax, axis_kwargs) if kwargs_original.get("add_colorbar", True): long_name = data.attrs.get("long_name") units = data.attrs.get("units") fig = plot.get_figure() fig.colorbar(plot, ax=ax, label=f"{long_name} [{units}]") def _generate_animation( data: xr.DataArray, clear_axes: bool = False, min_percentile: float = 0, max_percentile: float = 100, title: str | None = None, display_sdf_name: bool = False, move_window: bool = False, t: str | None = None, ax: plt.Axes | None = None, kwargs: dict | None = None, ) -> AnimationUnit: """ Internal function for generating the plotting logic required for animations. Parameters --------- data DataArray containing the target data clear_axes Decide whether to run ``ax.clear()`` in every update min_percentile Minimum percentile of the data max_percentile Maximum percentile of the data title Custom title to add to the plot display_sdf_name Display the sdf file name in the animation title move_window Update the ``xlim`` to be only values that are not NaNs at each time interval t Coordinate for t axis (the coordinate which will be animated over). If ``None``, use ``data.dims[0]`` ax Matplotlib axes on which to plot kwargs Keyword arguments to be passed to matplotlib """ if kwargs is None: kwargs = {} kwargs_original = kwargs.copy() axis_kwargs = {} for key in ("xlabel", "ylabel"): if key in kwargs: axis_kwargs[key] = kwargs.pop(key) # Sets the animation coordinate (t) for iteration. If time is in the coords # then it will set time to be t. If it is not it will fallback to the last # coordinate passed in. By default coordinates are passed in from xarray in # the form x, y, z so in order to preserve the x and y being on their # respective axes we animate over the final coordinate that is passed in # which in this example is z coord_names = list(data.dims) if t is None: t = "time" if "time" in coord_names else coord_names[-1] coord_names.remove(t) N_frames = data[t].size global_min = global_max = None if data.ndim == 2: global_min, global_max = _setup_line_plot( data=data, ax=ax, coord_names=coord_names, kwargs=kwargs, axis_kwargs=axis_kwargs, min_percentile=min_percentile, max_percentile=max_percentile, t=t, ) elif data.ndim == 3: _setup_pcolormesh_plot( data=data, ax=ax, coord_names=coord_names, kwargs=kwargs, kwargs_original=kwargs_original, axis_kwargs=axis_kwargs, min_percentile=min_percentile, max_percentile=max_percentile, t=t, ) elif data.ndim == 4: raise NotImplementedError("Voxel animations are not currently supported.") # _setup_voxel_plot( # data=data, # ax=ax, # coord_names=coord_names, # kwargs=kwargs, # kwargs_original=kwargs_original, # axis_kwargs=axis_kwargs, # min_percentile=min_percentile, # max_percentile=max_percentile, # t=t, # ) ax.set_title(get_frame_title(data, 0, display_sdf_name, title, t)) window_boundaries = None if move_window: window_boundaries = calculate_window_boundaries( data, kwargs.get("xlim"), kwargs["x"] ) def update(frame): if clear_axes: ax.clear() # Set the xlim for each frame in the case of a moving window if move_window: kwargs["xlim"] = window_boundaries[frame] plot = data.isel({t: frame}).plot(ax=ax, **kwargs) ax.set_title(get_frame_title(data, frame, display_sdf_name, title, t)) _set_axes_labels(ax, axis_kwargs) if data.ndim == 2 and "ylim" not in kwargs and global_min is not None: ax.set_ylim(global_min, global_max) return plot return AnimationUnit( update=update, n_frames=N_frames, )
[docs] def animate( data: xr.DataArray, fps: float = 10, min_percentile: float = 0, max_percentile: float = 100, title: str | None = None, display_sdf_name: bool = False, move_window: bool = False, t: str | None = None, ax: plt.Axes | None = None, **kwargs, ) -> FuncAnimation: """ Generate an animation using an `xarray.DataArray`. The intended use of this function is via `sdf_xarray.dataarray_accessor.EpochAccessor.animate`. Parameters --------- data DataArray containing the target data fps Frames per second for the animation min_percentile Minimum percentile of the data max_percentile Maximum percentile of the data title Custom title to add to the plot display_sdf_name Display the sdf file name in the animation title move_window Update the ``xlim`` to be only values that are not NaNs at each time interval t Coordinate for t axis (the coordinate which will be animated over). If ``None``, use ``data.dims[0]`` ax Matplotlib axes on which to plot kwargs Keyword arguments to be passed to matplotlib Examples -------- >>> anim = ds["Derived_Number_Density_Electron"].epoch.animate() >>> anim.save("animation.gif") """ import matplotlib.pyplot as plt # noqa: PLC0415 from matplotlib.animation import FuncAnimation # noqa: PLC0415 # Create plot if no ax is provided if ax is None: _, ax = plt.subplots() animation = _generate_animation( data, clear_axes=True, min_percentile=min_percentile, max_percentile=max_percentile, title=title, display_sdf_name=display_sdf_name, move_window=move_window, t=t, ax=ax, kwargs=kwargs, ) return FuncAnimation( ax.get_figure(), animation.update, frames=range(animation.n_frames), interval=1000 / fps, repeat=True, )
[docs] def animate_multiple( *datasets: xr.DataArray, datasets_kwargs: list[dict[str, Any]] | None = None, fps: float = 10, min_percentile: float = 0, max_percentile: float = 100, title: str | None = None, display_sdf_name: bool = False, move_window: bool = False, t: str | None = None, ax: plt.Axes | None = None, **common_kwargs, ) -> FuncAnimation: """ Generate an animation using multiple `xarray.DataArray`. The intended use of this function is via `sdf_xarray.dataset_accessor.EpochAccessor.animate_multiple`. Parameters --------- datasets `xarray.DataArray` objects containing the data to be animated datasets_kwargs A list of dictionaries, following the same order as ``datasets``, containing per-dataset matplotlib keyword arguments. The list does not need to be the same length as ``datasets``; missing entries are initialised as empty dictionaries fps Frames per second for the animation min_percentile Minimum percentile of the data max_percentile Maximum percentile of the data title Custom title to add to the plot display_sdf_name Display the sdf file name in the animation title move_window Update the ``xlim`` to be only values that are not NaNs at each time interval t Coordinate for t axis (the coordinate which will be animated over). If ``None``, use ``data.dims[0]`` ax Matplotlib axes on which to plot common_kwargs Matplotlib keyword arguments applied to all datasets. These are overridden by per-dataset entries in ``datasets_kwargs`` Examples -------- >>> anim = animate_multiple( ds["Derived_Number_Density_Electron"], ds["Derived_Number_Density_Ion"], datasets_kwargs=[{"label": "Electron"}, {"label": "Ion"}], ylim=(0e27,4e27), display_sdf_name=True, ylabel="Derived Number Density [1/m$^3$]" ) >>> anim.save("animation.gif") """ import matplotlib.pyplot as plt # noqa: PLC0415 from matplotlib.animation import FuncAnimation # noqa: PLC0415 if not datasets: raise ValueError("At least one dataset must be provided") # Create plot if no ax is provided if ax is None: _, ax = plt.subplots() n_datasets = len(datasets) if datasets_kwargs is None: # Initialise an empty series of dicts the same size as the number of datasets datasets_kwargs = [{} for _ in range(n_datasets)] else: # The user might only want to use kwargs on some of the datasets so we make sure # to initialise additional empty dicts and append them to the list datasets_kwargs.extend({} for _ in range(n_datasets - len(datasets_kwargs))) animations: list[AnimationUnit] = [] for da, kw in zip(datasets, datasets_kwargs): animations.append( _generate_animation( da, ax=ax, min_percentile=min_percentile, max_percentile=max_percentile, title=title, display_sdf_name=display_sdf_name, move_window=move_window, t=t, # Per-dataset kwargs override common matplotlib kwargs kwargs={**common_kwargs, **kw}, ) ) lengths = [anim.n_frames for anim in animations] n_frames = min(lengths) if len(set(lengths)) > 1: warnings.warn( "Datasets have different frame counts; truncating to the shortest", stacklevel=2, ) # Render the legend if a label exists for any 2D dataset show_legend = any( "label" in kw and da.ndim == 2 for da, kw in zip(datasets, datasets_kwargs) ) def update(frame): ax.clear() for anim in animations: anim.update(frame) if show_legend: ax.legend(loc="upper right") return FuncAnimation( ax.get_figure(), update, frames=range(n_frames), interval=1000 / fps, repeat=True, )
[docs] def show(anim): """Shows the FuncAnimation in a Jupyter notebook. Parameters ---------- anim `matplotlib.animation.FuncAnimation` """ from IPython.display import HTML # noqa: PLC0415 return HTML(anim.to_jshtml())