Skip to content

Visualization

xrops.viz provides plotting helpers and a family of validation panels — Operator subclasses that return a matplotlib.figure.Figure, so a panel slots into a Sequential or Graph pipeline as the final step.

Axes & colormaps

make_axes builds a (optionally cartopy-projected) axes grid; cmap_for looks up the default colormap for a variable; shared_norm derives a common colour normalization across panels; PRESETS is the registry of named cartopy extents.

make_axes

make_axes(projection: Any = None, *, fig: Figure | None = None, figsize: tuple[float, float] = (8, 5)) -> tuple[Figure, Any]

Create a Figure + Axes pair, applying a preset extent if requested.

Parameters:

Name Type Description Default
projection Any

Preset name (one of :data:PRESETS), a cartopy class name ("PlateCarree"), an instantiated cartopy :class:~cartopy.crs.Projection, or None for plain matplotlib axes. Anything but None needs the xrtoolz-ops[maps] extra (cartopy).

None
fig Figure | None

Optional pre-existing Figure to draw into. When None a new Figure is created with figsize.

None
figsize tuple[float, float]

Figure size when fig is None.

(8, 5)

Returns:

Type Description
Figure

(Figure, Axes). With a preset, the Axes is a

Any

class:cartopy.mpl.geoaxes.GeoAxes with set_extent already

tuple[Figure, Any]

applied. With projection=None, plain matplotlib axes.

Raises:

Type Description
ImportError

projection is set but cartopy is not installed.

Source code in packages/xrtoolz-ops/src/xrops/viz/_src/projections.py
def make_axes(
    projection: Any = None,
    *,
    fig: mpl_figure.Figure | None = None,
    figsize: tuple[float, float] = (8, 5),
) -> tuple[mpl_figure.Figure, Any]:
    """Create a Figure + Axes pair, applying a preset extent if requested.

    Args:
        projection: Preset name (one of :data:`PRESETS`), a cartopy
            class name (``"PlateCarree"``), an instantiated cartopy
            :class:`~cartopy.crs.Projection`, or ``None`` for plain
            matplotlib axes. Anything but ``None`` needs the
            ``xrtoolz-ops[maps]`` extra (cartopy).
        fig: Optional pre-existing Figure to draw into. When ``None`` a
            new Figure is created with ``figsize``.
        figsize: Figure size when ``fig`` is ``None``.

    Returns:
        ``(Figure, Axes)``. With a preset, the Axes is a
        :class:`cartopy.mpl.geoaxes.GeoAxes` with ``set_extent`` already
        applied. With ``projection=None``, plain matplotlib axes.

    Raises:
        ImportError: ``projection`` is set but cartopy is not installed.
    """
    if fig is None:
        fig = plt.figure(figsize=figsize)
    crs = _resolve_projection(projection)
    if crs is None:
        ax = fig.add_subplot(1, 1, 1)
        return fig, ax

    ax = fig.add_subplot(1, 1, 1, projection=crs)
    if isinstance(projection, str) and projection in PRESETS:
        extent = PRESETS[projection]["extent"]
        if extent is not None:
            # ax is a cartopy GeoAxes when a projection is set.
            ccrs = _require_cartopy()
            ax.set_extent(extent, crs=ccrs.PlateCarree())
    return fig, ax

cmap_for

cmap_for(variable: str | Variable | None, default: str = 'viridis') -> str

Return the registry-recommended colormap for variable.

Parameters:

Name Type Description Default
variable str | Variable | None

Variable instance, canonical short name (case-insensitive), or None.

required
default str

Fallback colormap when variable is None, unknown, or its registry entry has no cmap.

'viridis'

Returns:

Type Description
str

Matplotlib colormap name (or any string the caller accepted on

str

registration — cmocean colormaps via "cmo.<name>" work too

str

when cmocean is installed and registered).

Source code in packages/xrtoolz-ops/src/xrops/viz/_src/cmaps.py
def cmap_for(variable: str | Variable | None, default: str = "viridis") -> str:
    """Return the registry-recommended colormap for ``variable``.

    Args:
        variable: Variable instance, canonical short name (case-insensitive),
            or ``None``.
        default: Fallback colormap when ``variable`` is ``None``, unknown, or
            its registry entry has no ``cmap``.

    Returns:
        Matplotlib colormap name (or any string the caller accepted on
        registration — cmocean colormaps via ``"cmo.<name>"`` work too
        when ``cmocean`` is installed and registered).
    """
    if variable is None:
        return default
    if isinstance(variable, Variable):
        return variable.cmap or default
    entry = REGISTRY.get(variable) or REGISTRY.get(variable.lower())
    if entry is None or entry.cmap is None:
        return default
    return entry.cmap

shared_norm

shared_norm(*arrays: DataArray | Dataset, q: tuple[float, float] | None = (0.02, 0.98), symmetric: bool = False) -> tuple[float, float]

Compute matched (vmin, vmax) across multiple inputs.

Useful for multi-panel comparison grids where the eye should not pick up colour-scale stretch artefacts as if they were structural differences in the data.

Reductions go through xarray (.quantile / .min / .max) rather than np.quantile on a materialised np.concatenate, so dask-backed inputs stay lazy: only the final scalar result is realised.

Parameters:

Name Type Description Default
*arrays DataArray | Dataset

One or more :class:xr.DataArray or :class:xr.Dataset. A single-variable Dataset is auto unwrapped; a multi-variable Dataset raises.

()
q tuple[float, float] | None

(low, high) quantile pair in [0, 1]. Default (0.02, 0.98) strips outliers. Pass None for the full (min, max) range.

(0.02, 0.98)
symmetric bool

When True, return symmetric limits (-M, +M) with M = max(|low|, |high|). Useful for divergent (signed-error) fields.

False

Returns:

Type Description
float

(vmin, vmax) as Python floats. NaNs are ignored. Returns

float

(nan, nan) if every input is fully NaN.

Source code in packages/xrtoolz-ops/src/xrops/viz/_src/norm.py
def shared_norm(
    *arrays: xr.DataArray | xr.Dataset,
    q: tuple[float, float] | None = (0.02, 0.98),
    symmetric: bool = False,
) -> tuple[float, float]:
    """Compute matched ``(vmin, vmax)`` across multiple inputs.

    Useful for multi-panel comparison grids where the eye should not
    pick up colour-scale stretch artefacts as if they were structural
    differences in the data.

    Reductions go through xarray (``.quantile`` / ``.min`` /
    ``.max``) rather than ``np.quantile`` on a materialised
    ``np.concatenate``, so dask-backed inputs stay lazy: only the
    final scalar result is realised.

    Args:
        *arrays: One or more :class:`xr.DataArray` or
            :class:`xr.Dataset`. A single-variable Dataset is auto
            unwrapped; a multi-variable Dataset raises.
        q: ``(low, high)`` quantile pair in ``[0, 1]``. Default
            ``(0.02, 0.98)`` strips outliers. Pass ``None`` for the
            full ``(min, max)`` range.
        symmetric: When ``True``, return symmetric limits ``(-M, +M)``
            with ``M = max(|low|, |high|)``. Useful for divergent
            (signed-error) fields.

    Returns:
        ``(vmin, vmax)`` as Python floats. NaNs are ignored. Returns
        ``(nan, nan)`` if every input is fully NaN.
    """
    if not arrays:
        raise ValueError("shared_norm requires at least one input array.")
    if q is not None and not (0.0 <= q[0] <= q[1] <= 1.0):
        raise ValueError(f"q must satisfy 0 <= q[0] <= q[1] <= 1, got {q!r}.")

    pieces = [_flatten(_coerce_to_dataarray(a)) for a in arrays]
    combined = xr.concat(pieces, dim=_FLAT_DIM)
    # Bail out early when every value is NaN — the quantile / min / max
    # below would otherwise return NaN in a less obvious way.
    if int(combined.count().values) == 0:
        return (float("nan"), float("nan"))

    if q is None:
        lo = float(combined.min(skipna=True).values)
        hi = float(combined.max(skipna=True).values)
    else:
        qs = combined.quantile(list(q), skipna=True)
        lo = float(qs.isel(quantile=0).values)
        hi = float(qs.isel(quantile=1).values)

    if symmetric:
        m = max(abs(lo), abs(hi))
        return (-m, m)
    return (lo, hi)

PRESETS module-attribute

PRESETS: dict[str, dict[str, Any]] = _build_presets()

Cartopy presets

The projection kwarg of SpatialMapPanel (and make_axes) accepts a preset name from PRESETS ("global", "north_atlantic", "gulf_stream", "kuroshio", "mediterranean"), a cartopy class name, or an instantiated cartopy CRS. With a preset, the right set_extent is applied automatically.

Variable → colormap registry

Default colormaps are looked up from the curated Variable registry (SSH → RdBu_r, SST → RdYlBu_r, salinity → viridis, ice → Blues). cmap_for performs the lookup; SpatialMapPanel(var=…) calls it automatically when cmap is unset.

V1 — Scale & spectral skill

LeadTimeSkillPanel

LeadTimeSkillPanel(*, lead_dim: str = 'lead_time', ylabel: str = 'Skill', **kw: Any)

Bases: _ValidationPanel

Plot skill vs forecast lead time.

Consumes the output of :class:xrops.metrics.SkillByLeadTime — a :class:xr.DataArray (or :class:xr.Dataset) indexed by lead_dim (default "lead_time").

Parameters:

Name Type Description Default
lead_dim str

Name of the lead-time dimension. Default "lead_time".

'lead_time'
ylabel str

Y-axis label. Default "Skill".

'Skill'
Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/scales.py
def __init__(
    self,
    *,
    lead_dim: str = "lead_time",
    ylabel: str = "Skill",
    **kw: Any,
) -> None:
    super().__init__(**kw)
    self.lead_dim = lead_dim
    self.ylabel = ylabel

ScaleSkillPanel

ScaleSkillPanel(*, region_dim: str = 'region', metric: str | None = None, ylabel: str = 'Score', **kw: Any)

Bases: _ValidationPanel

Bar chart of skill broken down by region.

Consumes :class:xrops.metrics.EvaluateByRegion output — a :class:xr.Dataset whose data_vars are per-metric scores indexed by a region dimension.

Parameters:

Name Type Description Default
region_dim str

Name of the region dimension. Default "region".

'region'
metric str | None

Name of the variable in the input Dataset to plot. None plots the first data_var.

None
ylabel str

Y-axis label. Default "Score".

'Score'
Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/scales.py
def __init__(
    self,
    *,
    region_dim: str = "region",
    metric: str | None = None,
    ylabel: str = "Score",
    **kw: Any,
) -> None:
    super().__init__(**kw)
    self.region_dim = region_dim
    self.metric = metric
    self.ylabel = ylabel

SpectralSkillPanel

SpectralSkillPanel(*, freq_dim: str = 'freq', log_x: bool = True, log_y: bool = False, ylabel: str = 'PSD score', **kw: Any)

Bases: _ValidationPanel

Plot skill vs spatial frequency / wavenumber.

Consumes a 1-D PSD-score-style :class:xr.DataArray indexed by a frequency or wavenumber coord (default "freq"). Pairs with :class:xrops.metrics.PSDScore output collapsed to 1-D.

Parameters:

Name Type Description Default
freq_dim str

Frequency / wavenumber dim. Default "freq".

'freq'
log_x bool

Whether to use a log x-axis. Default True.

True
log_y bool

Whether to use a log y-axis. Default False.

False
ylabel str

Y-axis label. Default "PSD score".

'PSD score'
Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/scales.py
def __init__(
    self,
    *,
    freq_dim: str = "freq",
    log_x: bool = True,
    log_y: bool = False,
    ylabel: str = "PSD score",
    **kw: Any,
) -> None:
    super().__init__(**kw)
    self.freq_dim = freq_dim
    self.log_x = log_x
    self.log_y = log_y
    self.ylabel = ylabel

TimeSeriesErrorPanel

TimeSeriesErrorPanel(*, time_dim: str = 'time', method_dim: str | None = 'method', metrics: Sequence[str] | None = None, ylabel: str = 'Score', legend_loc: str = 'best', palette: str | dict[str, str] | None = None, **kw: Any)

Bases: _ValidationPanel

Scalar skill metric(s) over time, one line per method.

Two input layouts are accepted:

  • method_dim set (default "method"): a Dataset whose data variables are metrics on (method_dim, time_dim). Each metric gets its own subplot (stacked, shared x) with one line per method.
  • method_dim=None: a Dataset whose data variables are the methods, each 1-D on time_dim. One axes, one line per variable.

A DataArray is treated as a single-metric Dataset. Colours come from :func:method_palette, so a method keeps its colour across figures.

Parameters:

Name Type Description Default
time_dim str

Time / step dimension. Default "time".

'time'
method_dim str | None

Method dimension, or None for the variables-are-methods layout. Default "method".

'method'
metrics Sequence[str] | None

Subset of data variables to plot (metrics, or methods when method_dim=None). None plots all of them.

None
ylabel str

Y-axis label for a single-metric figure. Multi-metric figures label each subplot with its metric name instead. Default "Score".

'Score'
legend_loc str

Matplotlib legend location. Default "best".

'best'
palette str | dict[str, str] | None

None (default tab: cycle), a matplotlib colormap name to draw the cycle from, or an explicit {method: colour} mapping.

None

Returns:

Type Description

A Figure with one axes per metric.

Raises:

Type Description
ValueError

A requested metric is missing, or a series is not 1-D along time_dim once method_dim is accounted for.

Example

import matplotlib matplotlib.use("Agg") import numpy as np, xarray as xr from xrops.viz.validation import TimeSeriesErrorPanel ds = xr.Dataset( ... {"rmse": (("method", "time"), np.random.rand(3, 20))}, ... coords={ ... "method": ["duacs", "miost", "4dvarnet"], ... "time": np.arange(20), ... }, ... ) fig = TimeSeriesErrorPanel(ylabel="RMSE [m]")(ds) sorted(line.get_label() for line in fig.axes[0].get_lines()) ['4dvarnet', 'duacs', 'miost']

Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/timeseries.py
def __init__(
    self,
    *,
    time_dim: str = "time",
    method_dim: str | None = "method",
    metrics: Sequence[str] | None = None,
    ylabel: str = "Score",
    legend_loc: str = "best",
    palette: str | dict[str, str] | None = None,
    **kw: Any,
) -> None:
    super().__init__(**kw)
    self.time_dim = time_dim
    self.method_dim = method_dim
    self.metrics = None if metrics is None else list(metrics)
    self.ylabel = ylabel
    self.legend_loc = legend_loc
    self.palette = dict(palette) if isinstance(palette, dict) else palette

V1.5 — PSD plots

Power-spectrum visualisations consuming transforms.power_spectrum and metrics.psd_score outputs.

PSDIsotropicPanel

PSDIsotropicPanel(*, freq_dim: str = 'freq_r', space_scale: float = 1.0, wavelength_label: str = 'Wavelength [units]', wavenumber_label: str = 'Wavenumber [cycles / unit]', ylabel: str = 'PSD', show_wavelength: bool = True, **kw: Any)

Bases: _PSDPanelBase

Log-log isotropic PSD vs radial wavenumber.

Parameters:

Name Type Description Default
freq_dim str

Radial-frequency dim name (xrft default "freq_r").

'freq_r'
space_scale float

Multiplier converting freq_dim units to inverse length used by the wavelength axis (e.g. 1e-3 for cycles/m → cycles/km). Default 1.0.

1.0
wavelength_label str

Top-axis label. Default "Wavelength [units]".

'Wavelength [units]'
wavenumber_label str

Bottom-axis label. Default "Wavenumber [cycles / unit]".

'Wavenumber [cycles / unit]'
ylabel str

Y-axis label. Default "PSD".

'PSD'
show_wavelength bool

Toggle the wavelength twin axis. Default True.

True
Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/psd.py
def __init__(
    self,
    *,
    freq_dim: str = "freq_r",
    space_scale: float = 1.0,
    wavelength_label: str = "Wavelength [units]",
    wavenumber_label: str = "Wavenumber [cycles / unit]",
    ylabel: str = "PSD",
    show_wavelength: bool = True,
    **kw: Any,
) -> None:
    super().__init__(**kw)
    self.freq_dim = freq_dim
    self.space_scale = float(space_scale)
    self.wavelength_label = wavelength_label
    self.wavenumber_label = wavenumber_label
    self.ylabel = ylabel
    self.show_wavelength = show_wavelength

PSDIsotropicScorePanel

PSDIsotropicScorePanel(*, freq_dim: str = 'freq_r', threshold: float = 0.5, space_scale: float = 1.0, wavelength_label: str = 'Wavelength [units]', wavenumber_label: str = 'Wavenumber [cycles / unit]', ylabel: str = 'PSD score', score_var: str = 'score', show_wavelength: bool = True, resolved_units: str | None = None, clip: bool = True, ylim: tuple[float, float] | None = None, **kw: Any)

Bases: _PSDPanelBase

PSD score vs radial wavenumber with resolved-scale marker.

Plots the 1-D PSD score (typically from :func:xrops.metrics.psd_score with isotropic=True) on a log-x axis, marks the score threshold with a horizontal dashed line, and annotates the resolved wavelength where the score first crosses threshold (computed via :func:xrops.metrics._src.spectral.find_intercept_1D).

Parameters:

Name Type Description Default
freq_dim str

Frequency dim name. Default "freq_r".

'freq_r'
threshold float

Score threshold for the resolved-scale marker. Default 0.5.

0.5
space_scale float

As :class:PSDIsotropicPanel.

1.0
wavelength_label str

Top-axis label.

'Wavelength [units]'
wavenumber_label str

Bottom-axis label.

'Wavenumber [cycles / unit]'
ylabel str

Y-axis label.

'PSD score'
score_var str

Variable name when input is a Dataset. Default "score".

'score'
show_wavelength bool

Toggle the twin axis.

True
resolved_units str | None

Unit string used in the resolved-scale legend text. None (default) parses it from the trailing [unit] of wavelength_label (e.g. "Wavelength [km]" → "km"); empty string suppresses the unit.

None
clip bool

When True (default), display the score clipped to [0, 1] and pin the y-axis to that range — matches the oceanbench / Ballarotta 2019 convention where negative values just mean "worse than zero predictor". Set to False for diagnostic mode that exposes the signed score, with the y-limits chosen from the data unless ylim is given.

True
ylim tuple[float, float] | None

Optional (ymin, ymax) override for the y-axis. When unset and clip=True the panel uses (0, 1); when unset and clip=False matplotlib auto-scales.

None
Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/psd.py
def __init__(
    self,
    *,
    freq_dim: str = "freq_r",
    threshold: float = 0.5,
    space_scale: float = 1.0,
    wavelength_label: str = "Wavelength [units]",
    wavenumber_label: str = "Wavenumber [cycles / unit]",
    ylabel: str = "PSD score",
    score_var: str = "score",
    show_wavelength: bool = True,
    resolved_units: str | None = None,
    clip: bool = True,
    ylim: tuple[float, float] | None = None,
    **kw: Any,
) -> None:
    super().__init__(**kw)
    self.freq_dim = freq_dim
    self.threshold = float(threshold)
    self.space_scale = float(space_scale)
    self.wavelength_label = wavelength_label
    self.wavenumber_label = wavenumber_label
    self.ylabel = ylabel
    self.score_var = score_var
    self.show_wavelength = show_wavelength
    # Units string for the resolved-scale legend. If unset, parsed
    # from the trailing ``[unit]`` of ``wavelength_label`` (so
    # ``"Wavelength [km]"`` → ``"km"``); falls back to empty.
    self.resolved_units = resolved_units
    self.clip = bool(clip)
    if ylim is not None:
        ylim_t = tuple(ylim)
        if len(ylim_t) != 2:
            raise ValueError(
                f"ylim must be a (ymin, ymax) 2-tuple; got length {len(ylim_t)}."
            )
        if ylim_t[0] > ylim_t[1]:
            raise ValueError(f"ylim must satisfy ymin <= ymax; got {ylim_t!r}.")
        self.ylim = ylim_t
    else:
        self.ylim = None

PSDSpaceTimePanel

PSDSpaceTimePanel(*, freq_space_dim: str = 'freq_lon', freq_time_dim: str = 'freq_time', space_scale: float = 1.0, time_scale: float = 1.0, wavelength_label: str = 'Wavelength [units]', period_label: str = 'Period [units]', wavenumber_label: str = 'Wavenumber [cycles / unit]', frequency_label: str = 'Frequency [cycles / unit]', cmap: str = 'RdYlBu_r', vmin: float | None = None, vmax: float | None = None, show_dual_axes: bool = True, **kw: Any)

Bases: _PSDPanelBase

2-D space-time PSD with log-norm color scale.

Renders |PSD| as a pcolormesh over (freq_space, freq_time) with a :class:matplotlib.colors.LogNorm colour scale, with optional twin axes converting to wavelength and period. Non-positive cells are masked before normalisation so LogNorm can't trip on zeros.

Parameters:

Name Type Description Default
freq_space_dim str

Spatial-frequency dim. Default "freq_lon".

'freq_lon'
freq_time_dim str

Temporal-frequency dim. Default "freq_time".

'freq_time'
space_scale float

Multiplier converting freq units to inverse length used by the wavelength axis. Default 1.0.

1.0
time_scale float

Multiplier converting freq units to inverse time used by the period axis. Default 1.0.

1.0
wavelength_label str

Top-axis label.

'Wavelength [units]'
period_label str

Right-axis label.

'Period [units]'
wavenumber_label str

Bottom-axis label.

'Wavenumber [cycles / unit]'
frequency_label str

Left-axis label.

'Frequency [cycles / unit]'
cmap str

Colormap. Default "RdYlBu_r".

'RdYlBu_r'
vmin float | None

Optional log-norm lower limit.

None
vmax float | None

Optional log-norm upper limit.

None
show_dual_axes bool

Toggle wavelength + period twin axes.

True
Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/psd.py
def __init__(
    self,
    *,
    freq_space_dim: str = "freq_lon",
    freq_time_dim: str = "freq_time",
    space_scale: float = 1.0,
    time_scale: float = 1.0,
    wavelength_label: str = "Wavelength [units]",
    period_label: str = "Period [units]",
    wavenumber_label: str = "Wavenumber [cycles / unit]",
    frequency_label: str = "Frequency [cycles / unit]",
    cmap: str = "RdYlBu_r",
    vmin: float | None = None,
    vmax: float | None = None,
    show_dual_axes: bool = True,
    **kw: Any,
) -> None:
    super().__init__(**kw)
    self.freq_space_dim = freq_space_dim
    self.freq_time_dim = freq_time_dim
    self.space_scale = float(space_scale)
    self.time_scale = float(time_scale)
    self.wavelength_label = wavelength_label
    self.period_label = period_label
    self.wavenumber_label = wavenumber_label
    self.frequency_label = frequency_label
    self.cmap = cmap
    self.vmin = vmin
    self.vmax = vmax
    self.show_dual_axes = show_dual_axes

PSDSpaceTimeScorePanel

PSDSpaceTimeScorePanel(*, threshold: float = 0.5, score_var: str = 'score', levels: ndarray | None = None, cmap: str = 'RdYlGn', clip: bool = True, **kw: Any)

Bases: PSDSpaceTimePanel

2-D space-time PSD score with threshold contour.

Same axes layout as :class:PSDSpaceTimePanel, but renders score (linear [0, 1]) and overlays a contour line at threshold marking the resolved boundary.

Parameters:

Name Type Description Default
threshold float

Score threshold contour. Default 0.5.

0.5
score_var str

Variable name when input is a Dataset. Default "score".

'score'
levels ndarray | None

Filled-contour levels. Default np.linspace(0, 1, 11) when clip=True; ignored (matplotlib auto-picks) when clip=False and levels is unset.

None
cmap str

Colormap. Default "RdYlGn".

'RdYlGn'
clip bool

When True (default), display the score clipped to [0, 1] — matches the oceanbench / Ballarotta 2019 convention. False exposes signed values for diagnostic mode; the colour scale and contour levels then auto-stretch to the data range unless levels is given.

True
Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/psd.py
def __init__(
    self,
    *,
    threshold: float = 0.5,
    score_var: str = "score",
    levels: np.ndarray | None = None,
    cmap: str = "RdYlGn",
    clip: bool = True,
    **kw: Any,
) -> None:
    # Replace cmap default and forward.
    super().__init__(cmap=cmap, **kw)
    self.threshold = float(threshold)
    self.score_var = score_var
    self.clip = bool(clip)
    # Only pin levels when clipping. With clip=False we let
    # contourf pick a sensible range from the data.
    self._levels_user = None if levels is None else np.asarray(levels)
    self.levels = (
        self._levels_user
        if self._levels_user is not None
        else (np.linspace(0, 1, 11) if self.clip else None)
    )

Region & rotary diagnostics

RegionScoreBarPanel

RegionScoreBarPanel(*, metrics: Sequence[str] | None = None, region_dim: str = 'region', method_dim: str | None = 'method', horizontal: bool = False, cmap: str = 'tab10', figsize: tuple[float, float] = (8, 5), **kw: Any)

Bases: _ValidationPanel

Grouped bar chart for region-stratified metric scores.

Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/regime_bars.py
def __init__(
    self,
    *,
    metrics: Sequence[str] | None = None,
    region_dim: str = "region",
    method_dim: str | None = "method",
    horizontal: bool = False,
    cmap: str = "tab10",
    figsize: tuple[float, float] = (8, 5),
    **kw: Any,
) -> None:
    super().__init__(figsize=figsize, **kw)
    self.metrics = tuple(metrics) if metrics is not None else None
    self.region_dim = region_dim
    self.method_dim = method_dim
    self.horizontal = bool(horizontal)
    self.cmap = cmap

RotaryPolarizationPanel

RotaryPolarizationPanel(*, variable: str = 'polarization', wavenumber_dim: str = 'wavenumber', y_dim: str = 'lat', cmap: str = 'RdBu_r', vmin: float = -1.0, vmax: float = 1.0, wavelength_axis: bool = True, space_scale: float = 1.0, wavelength_label: str = 'Wavelength [km]', figsize: tuple[float, float] = (6, 8), **kw: Any)

Bases: _PSDPanelBase

Heatmap of rotary polarization over wavenumber and latitude.

Inherits the constrained-layout / per-axes title pattern from :class:_PSDPanelBase so the optional top wavelength axis doesn't collide with the figure title.

Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/rotary.py
def __init__(
    self,
    *,
    variable: str = "polarization",
    wavenumber_dim: str = "wavenumber",
    y_dim: str = "lat",
    cmap: str = "RdBu_r",
    vmin: float = -1.0,
    vmax: float = 1.0,
    wavelength_axis: bool = True,
    space_scale: float = 1.0,
    wavelength_label: str = "Wavelength [km]",
    figsize: tuple[float, float] = (6, 8),
    **kw: Any,
) -> None:
    super().__init__(figsize=figsize, **kw)
    if vmin >= vmax:
        raise ValueError(f"vmin must be less than vmax; got {vmin=} and {vmax=}.")
    self.variable = variable
    self.wavenumber_dim = wavenumber_dim
    self.y_dim = y_dim
    self.cmap = cmap
    self.vmin = float(vmin)
    self.vmax = float(vmax)
    self.wavelength_axis = bool(wavelength_axis)
    # ``space_scale`` multiplies 1/k to convert into the units used in
    # ``wavelength_label``. xrft outputs cycles/<coord-unit>, so for
    # coords in metres + a "[km]" label this is 1e-3; for coords
    # already in km, leave it at the default 1.0.
    self.space_scale = float(space_scale)
    self.wavelength_label = str(wavelength_label)

Spatial snapshots

SpatialMapPanel

SpatialMapPanel(*, variable: str | None = None, time_index: int | None = 0, cmap: str | None = None, vmin: float | None = None, vmax: float | None = None, projection: Any = None, coastlines: bool = True, gridlines: bool = True, cbar_label: str = '', lon: str = 'lon', lat: str = 'lat', **kw: Any)

Bases: _ValidationPanel

Single 2-D (lat, lon) snapshot with optional cartopy projection.

Parameters:

Name Type Description Default
variable str | None

Data-variable name when input is a Dataset. None (default) auto-picks the first data_var. Also used as the lookup key for :func:xrops.viz.cmap_for when cmap is unset.

None
time_index int | None

When the input has a time dim, isel(time=…) this index before plotting. Default 0 (first snapshot). Set to None to skip selection (useful for inputs that were already pre-reduced).

0
cmap str | None

Matplotlib colormap. None (default) auto-resolves from the variable registry; falls back to "viridis" for unknown names.

None
vmin float | None

Optional lower colour-scale limit.

None
vmax float | None

Optional upper colour-scale limit.

None
projection Any

Preset name ("global", "north_atlantic", "gulf_stream", "kuroshio", "mediterranean"), a cartopy class name, a cartopy CRS instance, or None for plain matplotlib axes (no cartopy). A projection needs the xrtoolz-ops[maps] extra; without it the panel raises :class:ImportError when called.

None
coastlines bool

Add :meth:cartopy.mpl.geoaxes.GeoAxes.coastlines. Ignored when projection is None. Default True.

True
gridlines bool

Add :meth:cartopy.mpl.geoaxes.GeoAxes.gridlines with labelled lat/lon ticks. Ignored without a projection. Default True.

True
cbar_label str

Colorbar label. Empty string suppresses it. Default "".

''
lon str

Longitude coord name. Default "lon".

'lon'
lat str

Latitude coord name. Default "lat".

'lat'
Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/spatial.py
def __init__(
    self,
    *,
    variable: str | None = None,
    time_index: int | None = 0,
    cmap: str | None = None,
    vmin: float | None = None,
    vmax: float | None = None,
    projection: Any = None,
    coastlines: bool = True,
    gridlines: bool = True,
    cbar_label: str = "",
    lon: str = "lon",
    lat: str = "lat",
    **kw: Any,
) -> None:
    super().__init__(**kw)
    self.variable = variable
    self.time_index = time_index
    self.cmap = cmap
    self.vmin = vmin
    self.vmax = vmax
    self.projection = projection
    self.coastlines = bool(coastlines)
    self.gridlines = bool(gridlines)
    self.cbar_label = cbar_label
    self.lon = lon
    self.lat = lat

HovmollerPanel

HovmollerPanel(*, variable: str | None = None, time_dim: str = 'time', keep_dim: str = 'lat', time_axis: Literal['x', 'y'] = 'x', cmap: str | None = None, norm: Literal['linear', 'log'] = 'linear', vmin: float | None = None, vmax: float | None = None, **kw: Any)

Bases: _ValidationPanel

Time × spatial-axis cross-section of a field.

Every dim other than time_dim and keep_dim is averaged out (NaN-skipping, unweighted — area-weight beforehand if averaging over latitude matters).

Parameters:

Name Type Description Default
variable str | None

Data-variable name when input is a Dataset. None (default) auto-picks the first data_var.

None
time_dim str

Time dimension. Default "time".

'time'
keep_dim str

Spatial dimension to keep, e.g. "lat" (average over lon), "lon", or "x" / "y" for Cartesian model output. Default "lat".

'lat'
time_axis Literal['x', 'y']

Put time on the "x" (default) or "y" axis.

'x'
cmap str | None

Matplotlib colormap. None (default) resolves from the variable registry, falling back to "RdBu_r".

None
norm Literal['linear', 'log']

"linear" (default) or "log". The log scale masks non-positive values.

'linear'
vmin float | None

Optional lower colour limit.

None
vmax float | None

Optional upper colour limit.

None

Returns:

Name Type Description
A

class:matplotlib.figure.Figure with one section plot.

Raises:

Type Description
ValueError

An unknown norm / time_axis, or the input lacks time_dim or keep_dim.

Example

import matplotlib matplotlib.use("Agg") import numpy as np, xarray as xr from xrops.viz.validation import HovmollerPanel t, lon = np.arange(30.0), np.linspace(0.0, 350.0, 36) wave = np.sin(np.deg2rad(lon[None, :] - 5.0 * t[:, None])) da = xr.DataArray( ... wave[:, None, :] * np.ones((1, 4, 1)), ... dims=("time", "lat", "lon"), ... coords={"time": t, "lat": np.arange(4.0), "lon": lon}, ... ) fig = HovmollerPanel(keep_dim="lon", time_axis="y")(da) fig.axes[0].get_xlabel(), fig.axes[0].get_ylabel() ('lon', 'time')

Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/hovmoller.py
def __init__(
    self,
    *,
    variable: str | None = None,
    time_dim: str = "time",
    keep_dim: str = "lat",
    time_axis: Literal["x", "y"] = "x",
    cmap: str | None = None,
    norm: Literal["linear", "log"] = "linear",
    vmin: float | None = None,
    vmax: float | None = None,
    **kw: Any,
) -> None:
    if norm not in ("linear", "log"):
        raise ValueError(f"norm={norm!r} must be 'linear' or 'log'.")
    if time_axis not in ("x", "y"):
        raise ValueError(f"time_axis={time_axis!r} must be 'x' or 'y'.")
    super().__init__(**kw)
    self.variable = variable
    self.time_dim = time_dim
    self.keep_dim = keep_dim
    self.time_axis = time_axis
    self.cmap = cmap
    self.norm = norm
    self.vmin = vmin
    self.vmax = vmax

Cartesian model output

Idealised f/β-plane runs have x/y in metres and no lon/lat. CartesianMapPanel draws them on equal-aspect km axes with a zero-centred 1/99 % colour scale and no cartopy; EnergyTimeSeriesPanel tracks model invariants over time. Both compose with the wrappers below:

from xrops.viz.validation import AnimatePanel, CartesianMapPanel, EnergyTimeSeriesPanel

CartesianMapPanel(variable="zeta", contour="psi")(snapshots)
AnimatePanel(CartesianMapPanel(variable="zeta"))(snapshots)  # FuncAnimation
EnergyTimeSeriesPanel(["kinetic_energy", "enstrophy"], relative=True)(invariants)

CartesianMapPanel

CartesianMapPanel(*, variable: str | None = None, dims: tuple[str, str] = ('x', 'y'), units: Literal['km', 'm'] = 'km', time_index: int | None = 0, cmap: str | None = None, norm: NormSpec = 'symmetric_q99', mask: str | None = 'mask', contour: str | None = None, contour_levels: int = 10, cbar_label: str | None = None, **kw: Any)

Bases: _ValidationPanel

Single 2-D (y, x) snapshot on Cartesian axes, without cartopy.

Parameters:

Name Type Description Default
variable str | None

Data-variable name when input is a Dataset. None (default) auto-picks the first data_var. Also the lookup key for :func:xrops.viz.cmap_for when cmap is unset.

None
dims tuple[str, str]

(x_dim, y_dim) coordinate names, in metres. Default ("x", "y").

('x', 'y')
units Literal['km', 'm']

Axis units to display, "km" (default) or "m".

'km'
time_index int | None

When the input has a time dim, isel(time=…) this index before plotting. Default 0; None skips the selection (e.g. for pre-reduced or animated inputs).

0
cmap str | None

Matplotlib colormap. None (default) auto-resolves from the variable registry, falling back to "viridis".

None
norm NormSpec

Colour limits. "symmetric_q99" (default) centres the scale on zero at the 1/99 % quantiles — the convention for signed fields such as vorticity or SSH anomaly. "q99" clips at the 1/99 % quantiles without centring. A (vmin, vmax) tuple is used as-is; None lets matplotlib span the full range.

'symmetric_q99'
mask str | None

Name of a mask variable or coordinate (non-zero / True = valid). Cells where it is zero are blanked. Ignored when the input carries no such name. Default "mask".

'mask'
contour str | None

Name of a Dataset variable to overlay as black contour lines (e.g. "psi" over vorticity). Default None.

None
contour_levels int

Number of contour levels. Default 10.

10
cbar_label str | None

Colorbar label. None (default) builds one from the variable's long_name / units attrs; "" suppresses it.

None

Returns:

Name Type Description
A

class:matplotlib.figure.Figure with one equal-aspect map.

Example

import matplotlib matplotlib.use("Agg") import numpy as np, xarray as xr from xrops.viz.validation import CartesianMapPanel x = np.linspace(0.0, 1e6, 32) zeta = np.sin(x[None, :] / 2e5) * np.cos(x[:, None] / 2e5) ds = xr.Dataset({"zeta": (("y", "x"), zeta)}, coords={"x": x, "y": x}) fig = CartesianMapPanel(variable="zeta")(ds) fig.axes[0].get_xlabel() 'x [km]'

Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/cartesian.py
def __init__(
    self,
    *,
    variable: str | None = None,
    dims: tuple[str, str] = ("x", "y"),
    units: Literal["km", "m"] = "km",
    time_index: int | None = 0,
    cmap: str | None = None,
    norm: NormSpec = "symmetric_q99",
    mask: str | None = "mask",
    contour: str | None = None,
    contour_levels: int = 10,
    cbar_label: str | None = None,
    **kw: Any,
) -> None:
    if units not in _UNIT_SCALE:
        raise ValueError(f"units={units!r} must be one of {sorted(_UNIT_SCALE)}.")
    if isinstance(norm, list | tuple):
        # Lists arrive from a get_config() round-trip.
        norm = (float(norm[0]), float(norm[1]))
    elif isinstance(norm, str) and norm not in ("symmetric_q99", "q99"):
        raise ValueError(
            f"norm={norm!r} must be 'symmetric_q99', 'q99', a (vmin, vmax) "
            "tuple or None."
        )
    super().__init__(**kw)
    self.variable = variable
    self.dims = tuple(dims)
    self.units = units
    self.time_index = time_index
    self.cmap = cmap
    self.norm = norm
    self.mask = mask
    self.contour = contour
    self.contour_levels = int(contour_levels)
    self.cbar_label = cbar_label

EnergyTimeSeriesPanel

EnergyTimeSeriesPanel(variables: Sequence[str], *, relative: bool = False, log: bool = False, time: str = 'time', **kw: Any)

Bases: _ValidationPanel

One line per scalar invariant over time.

Parameters:

Name Type Description Default
variables Sequence[str]

Names of 1-D (time) data variables to plot, e.g. ["kinetic_energy", "enstrophy"].

required
relative bool

Plot X(t) / X(t₀) so invariants with different units share one axis and drift reads directly off the y-axis. Default False.

False
log bool

Log-scale the y-axis. Default False.

False
time str

Name of the time dimension. Default "time".

'time'

Returns:

Name Type Description
A

class:matplotlib.figure.Figure with one line per variable.

Raises:

Type Description
ValueError

A variable is missing, is not 1-D along time, or (with relative=True) starts at zero.

Example

import matplotlib matplotlib.use("Agg") import numpy as np, xarray as xr from xrops.viz.validation import EnergyTimeSeriesPanel t = np.arange(10.0) ds = xr.Dataset( ... {"ke": ("time", 2.0 - 0.01 * t), "ens": ("time", 5.0 + 0.1 * t)}, ... coords={"time": t}, ... ) fig = EnergyTimeSeriesPanel(["ke", "ens"], relative=True)(ds) fig.axes[0].lines[0].get_ydata()[0] np.float64(1.0)

Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/timeseries.py
def __init__(
    self,
    variables: Sequence[str],
    *,
    relative: bool = False,
    log: bool = False,
    time: str = "time",
    **kw: Any,
) -> None:
    if isinstance(variables, str):
        variables = [variables]
    if not variables:
        raise ValueError("EnergyTimeSeriesPanel needs at least one variable.")
    super().__init__(**kw)
    self.variables = list(variables)
    self.relative = bool(relative)
    self.log = bool(log)
    self.time = time

V3 — Lagrangian / Eulerian

EulerianLagrangianPanel

EulerianLagrangianPanel(*, eulerian_var: str = 'ssh', traj_dim: str = 'trajectory', time_dim: str = 'time', lon: str = 'lon', lat: str = 'lat', **kw: Any)

Bases: _ValidationPanel

Side-by-side Eulerian field + Lagrangian trajectory bundle.

Consumes (eulerian_ds, trajectory_ds). The trajectory Dataset follows the V3.1 schema: dims (traj_dim, time_dim) with at least lon, lat variables sampled along each track.

Parameters:

Name Type Description Default
eulerian_var str

Variable to plot from the Eulerian Dataset.

'ssh'
traj_dim str

Trajectory dimension name in the trajectory Dataset.

'trajectory'
time_dim str

Time dimension name in the trajectory Dataset.

'time'
lon str

Longitude coordinate name in both Datasets.

'lon'
lat str

Latitude coordinate name in both Datasets.

'lat'
Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/lagrangian.py
def __init__(
    self,
    *,
    eulerian_var: str = "ssh",
    traj_dim: str = "trajectory",
    time_dim: str = "time",
    lon: str = "lon",
    lat: str = "lat",
    **kw: Any,
) -> None:
    kw.setdefault("figsize", (11, 4.5))
    super().__init__(**kw)
    self.eulerian_var = eulerian_var
    self.traj_dim = traj_dim
    self.time_dim = time_dim
    self.lon = lon
    self.lat = lat

V4 — Process budgets

ProcessBudgetPanel

ProcessBudgetPanel(*, time_dim: str = 'time', reduce_dims: tuple[str, ...] = ('lat', 'lon'), **kw: Any)

Bases: _ValidationPanel

Stacked-area chart of budget components + residual line.

Parameters:

Name Type Description Default
time_dim str

Time coordinate name to plot against. Default "time".

'time'
reduce_dims tuple[str, ...]

Spatial dims to mean-reduce before plotting. Default ("lat", "lon").

('lat', 'lon')
Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/budgets.py
def __init__(
    self,
    *,
    time_dim: str = "time",
    reduce_dims: tuple[str, ...] = ("lat", "lon"),
    **kw: Any,
) -> None:
    super().__init__(**kw)
    self.time_dim = time_dim
    self.reduce_dims = tuple(reduce_dims)

V5 — Event verification

EventVerificationPanel

EventVerificationPanel(*, label_var: str = 'label', lon: str = 'lon', lat: str = 'lat', use_cartopy: bool = True, **kw: Any)

Bases: _ValidationPanel

Overlay matched / unmatched events + side panel of scores.

Consumes (objects_pred, objects_ref, matches, scores) where:

  • objects_pred / objects_ref carry an integer label DataArray on (lat, lon) (0 = background, n = event id).
  • matches is a mapping with three iterables of (pred_id, ref_id) / pred_id / ref_id keyed by "hits", "false_alarms", "misses".
  • scores is a mapping {"POD": ..., "FAR": ..., "CSI": ..., "IoU": ...}; rendered as a side text block.

Cartopy is used when available for a coastline backdrop; otherwise a plain matplotlib axes is used.

Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/events.py
def __init__(
    self,
    *,
    label_var: str = "label",
    lon: str = "lon",
    lat: str = "lat",
    use_cartopy: bool = True,
    **kw: Any,
) -> None:
    kw.setdefault("figsize", (12, 5))
    super().__init__(**kw)
    self.label_var = label_var
    self.lon = lon
    self.lat = lat
    self.use_cartopy = use_cartopy

Composable wrappers

These consume any of the panels above — or a plain (ds, ax) -> Any callable — and multiply it. They nest, so a per-experiment N-up movie is AnimatePanel(FacetPanel(SpatialMapPanel(...), facet_dim="experiment")) and the six-panel scale × (ref, study, diff) mosaic is FacetPanel(PairwiseComparePanel(SpatialMapPanel(...)), facet_dim="scale").

Generality boundary

Only single-input, single-axes panels can be wrapped — the wrappers hand an inner panel one sliced object and one Axes. That covers SpatialMapPanel, the PSD panels, RegionScoreBarPanel, RotaryPolarizationPanel and the scale-skill panels, plus any (ds, ax) -> Any callable.

EulerianLagrangianPanel (eulerian + trajectories) and EventVerificationPanel (four inputs across an axes pair) take more of both and are rejected at construction with a TypeError. Plots that own the whole figure (seaborn JointGrid, pairplot) and non-matplotlib backends (plotly, hvplot, bokeh) are also out of scope.

FacetPanel

FacetPanel(panel: InnerPanel, *, facet_dim: str, ncols: int | None = None, nrows: int | None = None, sharex: bool = True, sharey: bool = True, sharebar: bool = False, figsize_per_panel: tuple[float, float] = (5, 4), title_format: str = '{value}', subplot_kw: dict[str, Any] | None = None, **kw: Any)

Bases: _ValidationPanel

Render any single-axes panel faceted across a categorical dim.

Parameters:

Name Type Description Default
panel InnerPanel

The panel to repeat. Either a _ValidationPanel subclass or a callable (ds, ax) -> Any. Panels that own the whole figure (seaborn JointGrid, pairplot) and non-matplotlib backends are out of scope.

required
facet_dim str

Dimension to facet over. One cell per value.

required
ncols int | None

Columns in the grid. None derives a near-square layout, ceil(sqrt(n)).

None
nrows int | None

Rows in the grid. None derives it from ncols.

None
sharex bool

Share the x axis across cells. Default True.

True
sharey bool

Share the y axis across cells. Default True.

True
sharebar bool

Replace the inner panel's per-cell colorbars with a single shared one spanning the grid. Requires that the inner panel produce a mappable; when none is found a warning is emitted and per-cell behaviour is left untouched. Default False.

False
figsize_per_panel tuple[float, float]

Per-cell (width, height) in inches, so the figure grows with the facet count (xarray FacetGrid convention). Default (5, 4).

(5, 4)
title_format str

Per-cell title template, formatted with value (the coord value) and index. Default "{value}".

'{value}'
subplot_kw dict[str, Any] | None

Forwarded to :func:matplotlib.pyplot.subplots, overriding the projection inferred from the inner panel. Use it to pick a different projection: stripping one from a panel that draws in projected coordinates ({} against a SpatialMapPanel(projection=...)) leaves the panel transforming onto axes that cannot accept it, and cartopy raises.

None

Returns:

Type Description

A Figure with one cell per facet_dim value; trailing unused

cells are hidden.

Example

import matplotlib matplotlib.use("Agg") import numpy as np, xarray as xr from xrops.viz.validation import FacetPanel ds = xr.DataArray( ... np.arange(12.0).reshape(4, 3), ... dims=("run", "x"), ... coords={"run": ["a", "b", "c", "d"]}, ... ).to_dataset(name="score") def line(sub, ax): ... ax.plot(sub["score"].values) fig = FacetPanel(line, facet_dim="run").call(ds) len(fig.axes) 4

Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/facet.py
def __init__(
    self,
    panel: InnerPanel,
    *,
    facet_dim: str,
    ncols: int | None = None,
    nrows: int | None = None,
    sharex: bool = True,
    sharey: bool = True,
    sharebar: bool = False,
    figsize_per_panel: tuple[float, float] = (5, 4),
    title_format: str = "{value}",
    subplot_kw: dict[str, Any] | None = None,
    **kw: Any,
) -> None:
    _require_single_input_panel(panel)
    super().__init__(**kw)
    self.panel = panel
    self.facet_dim = facet_dim
    self.ncols = ncols
    self.nrows = nrows
    self.sharex = bool(sharex)
    self.sharey = bool(sharey)
    self.sharebar = bool(sharebar)
    self.figsize_per_panel = tuple(figsize_per_panel)
    self.title_format = title_format
    self.subplot_kw = subplot_kw

seasonal_groupby

seasonal_groupby(ds: Dataset | DataArray, *, time: str = 'time', reduction: str = 'mean') -> Dataset | DataArray

Reduce a continuous-time object to a four-cell season dim.

A one-liner over ds.groupby(f"{time}.season") so the seasonal mosaic is a straight feed into FacetPanel(facet_dim="season").

Parameters:

Name Type Description Default
ds Dataset | DataArray

Input carrying a datetime-like time coordinate.

required
time str

Name of that coordinate. Default "time".

'time'
reduction str

Any groupby reduction name — "mean", "sum", "median", … Default "mean".

'mean'

Returns:

Type Description
Dataset | DataArray

The reduced object with a season dimension holding

Dataset | DataArray

DJF/MAM/JJA/SON in chronological order. Grouping

Dataset | DataArray

on the string time.season coordinate sorts lexicographically

Dataset | DataArray

(DJF, JJA, MAM, SON), which would put summer

Dataset | DataArray

before spring in a mosaic, so the result is reindexed.

Raises:

Type Description
ValueError

If reduction is not a groupby method.

Example

import numpy as np, pandas as pd, xarray as xr from xrops.viz.validation import seasonal_groupby ds = xr.DataArray( ... np.arange(365.0), ... dims="time", ... coords={"time": pd.date_range("2020-01-01", periods=365)}, ... ).to_dataset(name="sst") int(seasonal_groupby(ds).sizes["season"]) 4

Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/facet.py
def seasonal_groupby(
    ds: xr.Dataset | xr.DataArray,
    *,
    time: str = "time",
    reduction: str = "mean",
) -> xr.Dataset | xr.DataArray:
    """Reduce a continuous-time object to a four-cell ``season`` dim.

    A one-liner over ``ds.groupby(f"{time}.season")`` so the seasonal
    mosaic is a straight feed into ``FacetPanel(facet_dim="season")``.

    Args:
        ds: Input carrying a datetime-like ``time`` coordinate.
        time: Name of that coordinate. Default ``"time"``.
        reduction: Any groupby reduction name — ``"mean"``, ``"sum"``,
            ``"median"``, … Default ``"mean"``.

    Returns:
        The reduced object with a ``season`` dimension holding
        ``DJF``/``MAM``/``JJA``/``SON`` in chronological order. Grouping
        on the string ``time.season`` coordinate sorts lexicographically
        (``DJF``, ``JJA``, ``MAM``, ``SON``), which would put summer
        before spring in a mosaic, so the result is reindexed.

    Raises:
        ValueError: If ``reduction`` is not a groupby method.

    Example:
        >>> import numpy as np, pandas as pd, xarray as xr
        >>> from xrops.viz.validation import seasonal_groupby
        >>> ds = xr.DataArray(
        ...     np.arange(365.0),
        ...     dims="time",
        ...     coords={"time": pd.date_range("2020-01-01", periods=365)},
        ... ).to_dataset(name="sst")
        >>> int(seasonal_groupby(ds).sizes["season"])
        4

    """
    grouped = ds.groupby(f"{time}.season")
    if not hasattr(grouped, reduction):
        raise ValueError(
            f"unknown reduction {reduction!r}; expected a groupby method such "
            "as 'mean', 'sum' or 'median'."
        )
    reduced = getattr(grouped, reduction)()
    present = [s for s in _SEASON_ORDER if s in set(reduced["season"].values)]
    return reduced.reindex(season=present)

PairwiseComparePanel

PairwiseComparePanel(panel: InnerPanel, *, method_dim: str = 'method', diff: DiffMode = 'relative', diff_kwargs: dict[str, Any] | None = None, diff_label: str | None = None, layout: Literal['row', 'col'] = 'row', sharex: bool = True, sharey: bool = True, figsize_per_panel: tuple[float, float] = (5, 4), subplot_kw: dict[str, Any] | None = None, **kw: Any)

Bases: _ValidationPanel

Side-by-side study vs reference, with an optional diff cell.

The input carries method_dim of size 2: position 0 is the reference / baseline, position 1 the study / candidate.

Parameters:

Name Type Description Default
panel InnerPanel

Inner panel to render each cell with — a _ValidationPanel or a (ds, ax) -> Any callable, the same boundary :class:FacetPanel accepts.

required
method_dim str

Dimension holding the two methods. Default "method".

'method'
diff DiffMode

"relative" renders 100 * (study - ref) / ref, "absolute" renders study - ref, "none" omits the third cell entirely. Default "relative".

'relative'
diff_kwargs dict[str, Any] | None

Attribute overrides applied to the inner panel for the diff cell only — typically {"cmap": "coolwarm", "vmin": -20, "vmax": 20}, since a signed difference wants a divergent scale. Ignored with a warning for callable inners, which have no attributes to override.

None
diff_label str | None

Title for the diff cell. None uses "Δ" or "Δ%" per diff.

None
layout Literal['row', 'col']

"row" lays the cells out horizontally, "col" vertically. Default "row".

'row'
sharex bool

Share the x axis across cells. Default True.

True
sharey bool

Share the y axis across cells. Default True.

True
figsize_per_panel tuple[float, float]

Per-cell (width, height) in inches.

(5, 4)
subplot_kw dict[str, Any] | None

Forwarded to :func:matplotlib.pyplot.subplots, overriding the inner panel's projection.

None

Returns:

Type Description

A Figure of three cells (two when diff="none").

Example

import matplotlib matplotlib.use("Agg") import numpy as np, xarray as xr from xrops.viz.validation import PairwiseComparePanel ds = xr.DataArray( ... np.arange(8.0).reshape(2, 4) + 1.0, ... dims=("method", "x"), ... coords={"method": ["duacs", "miost"]}, ... ).to_dataset(name="rmse") def line(sub, ax): ... ax.plot(sub["rmse"].values) fig = PairwiseComparePanel(line).call(ds) len(fig.axes) 3

Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/compare.py
def __init__(
    self,
    panel: InnerPanel,
    *,
    method_dim: str = "method",
    diff: DiffMode = "relative",
    diff_kwargs: dict[str, Any] | None = None,
    diff_label: str | None = None,
    layout: Literal["row", "col"] = "row",
    sharex: bool = True,
    sharey: bool = True,
    figsize_per_panel: tuple[float, float] = (5, 4),
    subplot_kw: dict[str, Any] | None = None,
    **kw: Any,
) -> None:
    if diff not in ("relative", "absolute", "none"):
        raise ValueError(
            f"unknown diff mode {diff!r}; expected 'relative', 'absolute' "
            "or 'none'."
        )
    if layout not in ("row", "col"):
        raise ValueError(f"unknown layout {layout!r}; expected 'row' or 'col'.")
    _require_single_input_panel(panel)
    super().__init__(**kw)
    self.panel = panel
    self.method_dim = method_dim
    self.diff = diff
    self.diff_kwargs = dict(diff_kwargs) if diff_kwargs else None
    self.diff_label = diff_label
    self.layout = layout
    self.sharex = bool(sharex)
    self.sharey = bool(sharey)
    self.figsize_per_panel = tuple(figsize_per_panel)
    self.subplot_kw = subplot_kw

SpatialDiffPanel

SpatialDiffPanel(panel: _ValidationPanel | None = None, *, variable: str | None = None, time_index: int | None = 0, field_cmap: str | None = None, diff_cmap: str = 'RdBu_r', vmin: float | None = None, vmax: float | None = None, diff_symmetric: bool = True, names: tuple[str, str] = ('prediction', 'reference'), figsize_per_panel: tuple[float, float] = (5, 4), **kw: Any)

Bases: _ValidationPanel

Three-axis (pred, ref, pred − ref) snapshot panel.

Called with two inputs, panel(pred, ref), each a Dataset or DataArray on the same grid.

Parameters:

Name Type Description Default
panel _ValidationPanel | None

Map panel used to draw each cell. None (default) uses SpatialMapPanel(variable=variable); pass a configured :class:SpatialMapPanel (e.g. with a projection) or a :class:CartesianMapPanel for model output in metres.

None
variable str | None

Data-variable name when the inputs are Datasets. None picks the first data_var.

None
time_index int | None

When the inputs have a time dim, isel this index before plotting. None skips the selection.

0
field_cmap str | None

Colormap for the prediction / reference cells. None keeps the inner panel's own (registry) colormap.

None
diff_cmap str

Colormap for the difference cell. Default "RdBu_r".

'RdBu_r'
vmin float | None

Lower colour limit shared by prediction and reference. None uses the 2/98 % quantiles of both fields together.

None
vmax float | None

Upper colour limit, as vmin.

None
diff_symmetric bool

Centre the difference scale on zero at its 2/98 % quantiles. False lets the inner panel scale it. Default True.

True
names tuple[str, str]

Cell titles for the two inputs. Default ("prediction", "reference").

('prediction', 'reference')
figsize_per_panel tuple[float, float]

Per-cell (width, height) in inches.

(5, 4)

Returns:

Type Description

A Figure with three map cells.

Raises:

Type Description
ValueError

The two inputs are not on the same grid.

Example

import matplotlib matplotlib.use("Agg") import numpy as np, xarray as xr from xrops.viz.validation import SpatialDiffPanel coords = {"lat": np.linspace(30, 40, 6), "lon": np.linspace(-70, -60, 8)} ref = xr.DataArray(np.ones((6, 8)), dims=("lat", "lon"), coords=coords) fig = SpatialDiffPanel()(ref + 0.1, ref) [ax.get_title() for ax in fig.axes[:3]]['prediction', 'reference', 'prediction - reference']

Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/diff.py
def __init__(
    self,
    panel: _ValidationPanel | None = None,
    *,
    variable: str | None = None,
    time_index: int | None = 0,
    field_cmap: str | None = None,
    diff_cmap: str = "RdBu_r",
    vmin: float | None = None,
    vmax: float | None = None,
    diff_symmetric: bool = True,
    names: tuple[str, str] = ("prediction", "reference"),
    figsize_per_panel: tuple[float, float] = (5, 4),
    **kw: Any,
) -> None:
    inner = panel if panel is not None else SpatialMapPanel(variable=variable)
    _require_single_input_panel(inner)
    super().__init__(**kw)
    self.panel = inner
    self.variable = variable
    self.time_index = time_index
    self.field_cmap = field_cmap
    self.diff_cmap = diff_cmap
    self.vmin = vmin
    self.vmax = vmax
    self.diff_symmetric = bool(diff_symmetric)
    self.names = (str(names[0]), str(names[1]))
    self.figsize_per_panel = tuple(figsize_per_panel)

AnimatePanel

AnimatePanel(panel: InnerPanel, *, frame_dim: str = 'time', fps: int = 24, interval_ms: int | None = None, title_format: str = '{value}', figsize: tuple[float, float] | None = None, pixelwidth: int | None = None, pixelheight: int | None = None, dpi: int = 100, subplot_kw: dict[str, Any] | None = None, blit: bool = False)

Animate any single-axes panel along a frame dimension.

Deliberately not a :class:_ValidationPanel subclass: that base contract is "callable returning a Figure", and an animation is a FuncAnimation. Rather than bend the contract, this is a sibling that consumes a panel — so it composes with :class:~xrops.viz.validation.FacetPanel and :class:~xrops.viz.validation.PairwiseComparePanel instead of competing with them.

Parameters:

Name Type Description Default
panel InnerPanel

Panel to render per frame — a _ValidationPanel or a (ds, ax) -> Any callable.

required
frame_dim str

Dimension to animate over. Default "time".

'time'
fps int

Frames per second, used when interval_ms is unset and as the default save rate.

24
interval_ms int | None

Explicit inter-frame delay in milliseconds. None derives 1000 / fps.

None
title_format str

Per-frame title template, formatted with value (the frame coord value) and index.

'{value}'
figsize tuple[float, float] | None

Figure size in inches. None (default) lets the inner panel size itself, which matters for composite inners — FacetPanel and PairwiseComparePanel compute a total from figsize_per_panel. An explicit value is applied to the finished figure. Ignored when pixel dimensions are given.

None
pixelwidth int | None

Output width in pixels. Must be paired with pixelheight; together they override figsize as (pixelwidth / dpi, pixelheight / dpi).

None
pixelheight int | None

Output height in pixels. Must be paired with pixelwidth.

None
dpi int

Dots per inch, and the pixel/inch conversion factor.

100
subplot_kw dict[str, Any] | None

Forwarded to :func:matplotlib.pyplot.subplots, overriding the inner panel's projection.

None
blit bool

Kept for signature compatibility; only False is supported. Panels redraw whole axes and do not report their artists, which is what FuncAnimation blitting requires, so True raises rather than silently failing to draw.

False
Example

import matplotlib matplotlib.use("Agg") import numpy as np, xarray as xr from xrops.viz.validation import AnimatePanel ds = xr.DataArray( ... np.arange(12.0).reshape(4, 3), dims=("time", "x") ... ).to_dataset(name="ssh") def line(sub, ax): ... ax.plot(sub["ssh"].values) ani = AnimatePanel(line).call(ds) ani._save_count if hasattr(ani, "_save_count") else 4 4

Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/animate.py
def __init__(
    self,
    panel: InnerPanel,
    *,
    frame_dim: str = "time",
    fps: int = 24,
    interval_ms: int | None = None,
    title_format: str = "{value}",
    figsize: tuple[float, float] | None = None,
    pixelwidth: int | None = None,
    pixelheight: int | None = None,
    dpi: int = 100,
    subplot_kw: dict[str, Any] | None = None,
    blit: bool = False,
) -> None:
    if fps <= 0:
        raise ValueError(f"fps must be positive, got {fps!r}.")
    if (pixelwidth is None) != (pixelheight is None):
        raise ValueError(
            "pixelwidth and pixelheight must be given together; got "
            f"pixelwidth={pixelwidth!r}, pixelheight={pixelheight!r}. "
            "Pass both to size the output in pixels, or neither and use "
            "figsize."
        )
    if blit:
        # FuncAnimation's blitting contract requires the callback to
        # return the artists it touched. Panels redraw whole axes
        # through `_build` and report nothing, and `ax.clear()` per
        # frame invalidates the cached background anyway.
        raise ValueError(
            "blit=True is not supported: panels redraw entire axes each "
            "frame and do not report their artists. Leave blit=False."
        )
    _require_single_input_panel(panel)
    self.panel = panel
    self.frame_dim = frame_dim
    self.fps = int(fps)
    self.interval_ms = interval_ms
    self.title_format = title_format
    self.figsize = tuple(figsize) if figsize is not None else None
    self.pixelwidth = pixelwidth
    self.pixelheight = pixelheight
    self.dpi = int(dpi)
    self.subplot_kw = subplot_kw
    self.blit = bool(blit)

preview

preview(ds: Dataset | DataArray, *, frame_index: int = 0) -> tuple[Figure, Any]

Render a single frame for inspection.

Lets you tune cmap / clim / projection / figsize without paying for a full render.

Parameters:

Name Type Description Default
ds Dataset | DataArray

Input carrying frame_dim.

required
frame_index int

Which frame to draw. Default 0.

0

Returns:

Type Description
tuple[Figure, Any]

The (figure, axes) pair holding that frame.

Raises:

Type Description
ValueError

If frame_dim is absent, empty, or frame_index is out of range.

Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/animate.py
def preview(
    self, ds: xr.Dataset | xr.DataArray, *, frame_index: int = 0
) -> tuple[mpl_figure.Figure, Any]:
    """Render a single frame for inspection.

    Lets you tune cmap / clim / projection / figsize without paying
    for a full render.

    Args:
        ds: Input carrying ``frame_dim``.
        frame_index: Which frame to draw. Default ``0``.

    Returns:
        The ``(figure, axes)`` pair holding that frame.

    Raises:
        ValueError: If ``frame_dim`` is absent, empty, or
            ``frame_index`` is out of range.
    """
    n = self._validate(ds)
    if not -n <= frame_index < n:
        raise ValueError(
            f"frame_index {frame_index} is out of range for "
            f"{self.frame_dim!r} of length {n}."
        )
    fig, axes = self._setup(ds)
    values = self._frame_values(ds, n)
    self._draw(fig, axes, ds, frame_index % n, values, frozenset(fig.axes))
    return fig, axes

get_config

get_config() -> dict[str, Any]

Return a JSON-serialisable description of this wrapper.

Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/animate.py
def get_config(self) -> dict[str, Any]:
    """Return a JSON-serialisable description of this wrapper."""
    return {
        **_inner_config(self.panel),
        "frame_dim": self.frame_dim,
        "fps": self.fps,
        "interval_ms": self.interval_ms,
        "title_format": self.title_format,
        "figsize": list(self.figsize) if self.figsize else None,
        "pixelwidth": self.pixelwidth,
        "pixelheight": self.pixelheight,
        "dpi": self.dpi,
        "blit": self.blit,
    }

save_animation

save_animation(ani: Any, path: str | Path, *, fps: int | None = None, progress: bool = False, overwrite_existing: bool = False, writer_kwargs: dict[str, Any] | None = None) -> Path

Write a FuncAnimation to disk, dispatching on file extension.

Parameters:

Name Type Description Default
ani Any

The animation to write.

required
path str | Path

Destination. .mp4 uses matplotlib's FFMpegWriter (needs a system ffmpeg), .gif uses PillowWriter (no external binary), and .html writes ani.to_jshtml() — a self-contained player that needs nothing installed.

required
fps int | None

Frame rate to encode at. None (default) uses the rate the :class:AnimatePanel was configured with, falling back to the animation's frame interval, so a 5-fps panel does not silently encode at 24.

None
progress bool

Attach a tqdm progress bar ticking once per rendered frame. A no-op with a warning when tqdm is unavailable.

False
overwrite_existing bool

Permit clobbering an existing file. Default False — an existing path raises instead.

False
writer_kwargs dict[str, Any] | None

Forwarded to the matplotlib writer, e.g. {"codec": "libx264"} or {"extra_args": ["-pix_fmt", "yuv420p"]}.

None

Returns:

Type Description
Path

The path written.

Raises:

Type Description
ValueError

If the extension is not one of .mp4 / .gif / .html.

FileExistsError

If the path exists and overwrite_existing is False.

RuntimeError

If .mp4 is requested without a usable ffmpeg.

Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/animate.py
def save_animation(
    ani: Any,
    path: str | Path,
    *,
    fps: int | None = None,
    progress: bool = False,
    overwrite_existing: bool = False,
    writer_kwargs: dict[str, Any] | None = None,
) -> Path:
    """Write a ``FuncAnimation`` to disk, dispatching on file extension.

    Args:
        ani: The animation to write.
        path: Destination. ``.mp4`` uses matplotlib's ``FFMpegWriter``
            (needs a system ffmpeg), ``.gif`` uses ``PillowWriter`` (no
            external binary), and ``.html`` writes ``ani.to_jshtml()``
            — a self-contained player that needs nothing installed.
        fps: Frame rate to encode at. ``None`` (default) uses the rate the
            :class:`AnimatePanel` was configured with, falling back to the
            animation's frame interval, so a 5-fps panel does not silently
            encode at 24.
        progress: Attach a tqdm progress bar ticking once per rendered
            frame. A no-op with a warning when tqdm is unavailable.
        overwrite_existing: Permit clobbering an existing file. Default
            ``False`` — an existing path raises instead.
        writer_kwargs: Forwarded to the matplotlib writer, e.g.
            ``{"codec": "libx264"}`` or
            ``{"extra_args": ["-pix_fmt", "yuv420p"]}``.

    Returns:
        The path written.

    Raises:
        ValueError: If the extension is not one of ``.mp4`` / ``.gif`` /
            ``.html``.
        FileExistsError: If the path exists and ``overwrite_existing``
            is ``False``.
        RuntimeError: If ``.mp4`` is requested without a usable ffmpeg.
    """
    destination = Path(path).expanduser()
    suffix = destination.suffix.lower()
    if suffix not in _WRITERS:
        raise ValueError(
            f"unsupported animation format {suffix or destination.name!r}; "
            f"expected one of {sorted(_WRITERS)}."
        )
    if destination.exists() and not overwrite_existing:
        raise FileExistsError(
            f"{destination} already exists; pass overwrite_existing=True to clobber it."
        )
    destination.parent.mkdir(parents=True, exist_ok=True)
    rate = _animation_fps(ani) if fps is None else int(fps)

    if suffix == ".html":
        destination.write_text(ani.to_jshtml(fps=rate), encoding="utf-8")
        return destination

    from matplotlib.animation import FFMpegWriter, PillowWriter

    extra = dict(writer_kwargs) if writer_kwargs else {}
    if suffix == ".mp4":
        if not FFMpegWriter.isAvailable():
            raise RuntimeError(
                "writing .mp4 needs ffmpeg on PATH, which was not found. "
                "Install it (e.g. `conda install -c conda-forge ffmpeg`) or "
                "save to .gif / .html instead."
            )
        writer: Any = FFMpegWriter(fps=rate, **extra)
    else:
        writer = PillowWriter(fps=rate, **extra)

    callback = None
    if progress:
        try:
            # Undeclared by design: progress bars are a convenience, so the
            # ImportError path below is the supported no-tqdm behaviour.
            from tqdm.auto import tqdm  # ty: ignore[unresolved-import]
        except ImportError:
            warnings.warn(
                "progress=True but tqdm is not installed; rendering without a "
                "progress bar.",
                UserWarning,
                stacklevel=2,
            )
        else:
            bar = tqdm(total=getattr(ani, "_save_count", None), unit="frame")
            callback = lambda index, total: bar.update()

    try:
        ani.save(str(destination), writer=writer, progress_callback=callback)
    finally:
        if callback is not None:
            bar.close()
    return destination

Palette helper

method_palette

method_palette(names: Iterable[str], cycle: Iterable[str] | None = None) -> dict[str, str]

Return a deterministic {name: colour} mapping.

The order is determined by sorting names alphabetically (so the same set of methods always yields the same mapping regardless of input order), then cycling through cycle.

Parameters:

Name Type Description Default
names Iterable[str]

Iterable of method names. Duplicates are de-duplicated.

required
cycle Iterable[str] | None

Iterable of matplotlib colour specifiers. Defaults to the tab:* family.

None

Returns:

Type Description
dict[str, str]

dict mapping each unique name to a colour.

Source code in packages/xrtoolz-ops/src/xrops/viz/validation/_src/palette.py
def method_palette(
    names: Iterable[str],
    cycle: Iterable[str] | None = None,
) -> dict[str, str]:
    """Return a deterministic ``{name: colour}`` mapping.

    The order is determined by sorting ``names`` alphabetically (so the
    same set of methods always yields the same mapping regardless of
    input order), then cycling through ``cycle``.

    Args:
        names: Iterable of method names. Duplicates are de-duplicated.
        cycle: Iterable of matplotlib colour specifiers. Defaults to the
            ``tab:*`` family.

    Returns:
        ``dict`` mapping each unique name to a colour.
    """
    palette = tuple(cycle) if cycle is not None else _DEFAULT_CYCLE
    if not palette:
        raise ValueError("`cycle` must contain at least one colour.")
    unique = sorted(set(names))
    return {name: palette[i % len(palette)] for i, name in enumerate(unique)}