"""Visualization helpers for analysing Neural-LAM predictions and errors."""
# Standard library
import warnings
from typing import Any
# Third-party
import cartopy.crs as ccrs
import cartopy.feature as cfeature
import matplotlib
import matplotlib.axes
import matplotlib.collections
import matplotlib.colors
import matplotlib.figure
import matplotlib.pyplot as plt
import numpy as np
import torch
import xarray as xr
from cartopy.mpl.geoaxes import GeoAxes
# Local
from . import utils
from .datastore.base import BaseDatastore
# Font sizes shared across projection-aware plot functions.
_TITLE_SIZE = 13 # suptitle and per-axes titles
_LABEL_SIZE = 11 # axis / colorbar labels
_TICK_SIZE = 11 # tick labels
# Annotations become unreadable when cells are smaller than this (in points)
# or when the total number of cells exceeds a readable count.
_MIN_CELL_SIZE_FOR_ANNOTATIONS = 18
_MAX_CELLS_FOR_ANNOTATIONS = 800
_HEATMAP_CMAP = matplotlib.colors.LinearSegmentedColormap.from_list(
"error_heatmap_white_red",
["#ffffff", "#fee5d9", "#fcae91", "#fb6a4a", "#cb181d"],
)
_LEAD_TIME_UNIT_ABBREVIATIONS = {
"weeks": "w",
"days": "d",
"hours": "h",
"minutes": "min",
"seconds": "s",
"milliseconds": "ms",
"microseconds": "µs",
"unknown": "steps",
}
def _tex_safe(s: str) -> str:
"""
Escape TeX special characters in s if TeX rendering is active.
Needed because % is a TeX comment character; without escaping it would
silently truncate any text that follows it (e.g. the title for r2m (%)).
Parameters
----------
s : str
The string to escape.
Returns
-------
str
The escaped string.
"""
if plt.rcParams.get("text.usetex", False):
s = s.replace("%", r"\%")
return s
def _compute_heatmap_layout(n_rows: int, n_cols: int) -> dict[str, float]:
"""
Choose figure and font sizes from the heatmap dimensions.
Parameters
----------
n_rows : int
Number of rows in the heatmap.
n_cols : int
Number of columns in the heatmap.
Returns
-------
dict[str, float]
Dictionary containing figure width, figure height, font sizes, etc.
"""
max_dim = max(n_rows, n_cols)
# Size the figure so each cell gets ~0.8 x 0.5 inches; floor at 8 x 4.5.
fig_width = float(max(4.5 + 0.8 * n_cols, 8.0))
fig_height = float(max(2.5 + 0.5 * n_rows, 4.5))
# Approximate cell size in points (72 pt/inch) to decide whether
# in-cell annotations will be legible.
cell_w_pt = (fig_width / max(n_cols, 1)) * 72
cell_h_pt = (fig_height / max(n_rows, 1)) * 72
show_annotations = (
n_rows * n_cols <= _MAX_CELLS_FOR_ANNOTATIONS
and min(cell_w_pt, cell_h_pt) >= _MIN_CELL_SIZE_FOR_ANNOTATIONS
)
return {
"fig_width": fig_width,
"fig_height": fig_height,
"tick_label_size": float(np.clip(15.0 - 0.18 * max_dim, 9.0, 14.0)),
"annotation_size": float(np.clip(13.0 - 0.22 * max_dim, 5.0, 12.0)),
"title_size": float(np.clip(16.0 - 0.15 * max_dim, 9.0, 15.0)),
"x_tick_rotation": 45.0 if n_cols > 12 else 0.0,
"show_annotations": show_annotations,
}
def _get_heatmap_var_labels(datastore: BaseDatastore) -> list[str]:
"""
Build state-variable labels from datastore metadata.
Parameters
----------
datastore : BaseDatastore
The datastore containing metadata about the grid.
Returns
-------
list[str]
List of formatted variable labels.
"""
var_names = datastore.get_vars_names(category="state")
var_units = datastore.get_vars_units(category="state")
return [
_tex_safe(f"{name} ({unit})" if unit else name)
for name, unit in zip(var_names, var_units)
]
def _to_heatmap_matrix(values: Any) -> np.ndarray:
"""
Convert heatmap inputs to a ``(num_state_vars, pred_steps)`` matrix.
A single-step tensor may arrive as one-dimensional ``(num_state_vars,)``,
especially in single-GPU or focused metric logging paths. In that case we
first treat it as one row of ``(pred_steps=1, num_state_vars)`` before
transposing.
Parameters
----------
values : array-like
The input values to convert.
Shape ``(num_state_vars,)`` or ``(pred_steps, num_state_vars)``.
Returns
-------
np.ndarray
The converted heatmap matrix with shape
``(num_state_vars, pred_steps)``.
"""
if hasattr(values, "detach"):
values = values.detach().cpu().numpy()
values = np.asarray(values, dtype=float)
if values.ndim == 1:
values = values[np.newaxis, :]
return values.T
def _get_feature_scale(
ds_stats: xr.Dataset, var_name: str, n_vars: int
) -> np.ndarray | None:
"""
Extract a 1D per-feature scale, averaging over any extra dims.
Parameters
----------
ds_stats : xr.Dataset
The standardization statistics dataset.
var_name : str
The name of the variable to extract scale for.
n_vars : int
The number of variables expected.
Returns
-------
np.ndarray or None
The extracted scale as a 1D array, or None if unavailable.
"""
if var_name not in ds_stats:
return None
da_scale = ds_stats[var_name]
feature_dim = "state_feature"
if feature_dim not in da_scale.dims:
return None
reduce_dims = [dim for dim in da_scale.dims if dim != feature_dim]
if reduce_dims:
da_scale = da_scale.mean(dim=reduce_dims)
scale = np.asarray(da_scale.values, dtype=float).reshape(-1)
if scale.size < n_vars:
return None
return scale[:n_vars]
def _get_heatmap_color_values(
errors_np: np.ndarray,
datastore: BaseDatastore,
normalization: str,
) -> tuple[np.ndarray, str, matplotlib.colors.Colormap]:
"""
Normalize heatmap colors according to `normalization`.
Returns a 3-tuple: (color_values, colorbar_label, cmap).
The returned array drives the colormap only; the numeric annotations in the
heatmap remain the original (physical-unit) values passed in `errors_np`.
Both modes fall back to per-variable max normalization when their required
stat is unavailable, appending "[fallback]" to the colorbar label.
Parameters
----------
errors_np : np.ndarray
The error values to normalize.
datastore : BaseDatastore
The datastore containing standardization stats.
normalization : str
The normalization mode to use ('state_std' or 'diff_std').
Returns
-------
tuple[np.ndarray, str, matplotlib.colors.Colormap]
A 3-tuple containing:
- color_values: The normalized values for the colormap.
- colorbar_label: The label for the colorbar.
- cmap: The colormap to use.
Raises
------
ValueError
If ``normalization`` is not one of ``'state_std'`` or ``'diff_std'``.
"""
def _per_var_fallback() -> tuple[
np.ndarray, str, matplotlib.colors.Colormap
]:
"""
Normalize errors by per-variable maximum value.
Returns
-------
tuple[np.ndarray, str, matplotlib.colors.Colormap]
Normalized errors, fallback label, and colormap.
"""
max_err = errors_np.max(axis=1, keepdims=True)
safe = np.where(max_err > np.finfo(float).eps, max_err, 1.0)
return (
errors_np / safe,
"Per-variable scale (relative to max error) [fallback]",
_HEATMAP_CMAP,
)
try:
ds_stats = datastore.get_standardization_dataarray(category="state")
except (AttributeError, KeyError, TypeError, ValueError) as exc:
warnings.warn(
f"Could not load standardization stats ({exc}); "
"falling back to per-variable scale.",
UserWarning,
stacklevel=3,
)
return _per_var_fallback()
n_vars = errors_np.shape[0]
if normalization == "state_std":
state_std = _get_feature_scale(ds_stats, "state_std", n_vars)
if state_std is None:
warnings.warn(
"state_std unavailable; falling back to per-variable scale.",
UserWarning,
stacklevel=3,
)
return _per_var_fallback()
safe_std = np.where(
np.isfinite(state_std) & (state_std > np.finfo(float).eps),
state_std,
1.0,
)
return errors_np / safe_std[:, None], "Error / state_std", _HEATMAP_CMAP
if normalization == "diff_std":
diff_std_std = _get_feature_scale(
ds_stats, "state_diff_std_standardized", n_vars
)
if diff_std_std is None:
warnings.warn(
"state_diff_std_standardized unavailable; "
"falling back to per-variable scale.",
UserWarning,
stacklevel=3,
)
return _per_var_fallback()
state_std = _get_feature_scale(ds_stats, "state_std", n_vars)
if state_std is None:
warnings.warn(
"state_std unavailable (needed to recover physical diff_std); "
"falling back to per-variable scale.",
UserWarning,
stacklevel=3,
)
return _per_var_fallback()
scale = state_std * diff_std_std # physical diff_std
safe = np.where(
np.isfinite(scale) & (np.abs(scale) > np.finfo(float).eps),
scale,
1.0,
)
return (
errors_np / safe[:, None],
"Error / physical diff_std",
_HEATMAP_CMAP,
)
raise ValueError(
f"Unknown normalization {normalization!r}; "
"expected 'state_std' or 'diff_std'."
)
def _get_annotation_text_color(
value: float, image: matplotlib.image.AxesImage
) -> str:
"""
Choose a readable annotation color from the rendered background.
Parameters
----------
value : float
The numeric value at the cell.
image : matplotlib.image.AxesImage
The rendered image object to determine background color.
Returns
-------
str
'white' or 'black' depending on the background luminance.
"""
if not np.isfinite(value):
return "black"
rgba = image.cmap(image.norm(value))
luminance = 0.2126 * rgba[0] + 0.7152 * rgba[1] + 0.0722 * rgba[2]
return "white" if luminance < 0.5 else "black"
[docs]
def plot_on_axis(
ax: GeoAxes,
da: xr.DataArray,
datastore: BaseDatastore,
vmin: float | None = None,
vmax: float | None = None,
ax_title: str | None = None,
cmap: str | matplotlib.colors.Colormap = "plasma",
boundary_alpha: float | None = None,
crop_to_interior: bool = False,
) -> matplotlib.collections.QuadMesh:
"""
Plot weather state on a projection-aware axis using datastore metadata.
Parameters
----------
ax : matplotlib.axes.Axes
The axis to plot on. Should have a cartopy projection.
da : xarray.DataArray
The data to plot. Should have shape (num_grid_nodes,).
datastore : BaseDatastore
The datastore containing metadata about the grid.
vmin : float, optional
Minimum value for color scale.
vmax : float, optional
Maximum value for color scale.
ax_title : str, optional
Title for the axis.
cmap : str or matplotlib.colors.Colormap, optional
Colormap to use for plotting.
boundary_alpha : float, optional
If provided, overlay boundary mask with given alpha transparency.
crop_to_interior : bool, optional
If True, crop the plot to the interior region.
Returns
-------
matplotlib.collections.QuadMesh
The mesh object created by pcolormesh.
"""
ax.coastlines(resolution="50m")
ax.add_feature(cfeature.BORDERS, linestyle="-", alpha=0.5)
gl = ax.gridlines(
draw_labels=True,
dms=True,
x_inline=False,
y_inline=False,
)
gl.top_labels = False
gl.right_labels = False
gl.xlabel_style = {"size": _TICK_SIZE}
gl.ylabel_style = {"size": _TICK_SIZE}
lats_lons = datastore.get_lat_lon("state")
grid_shape = (
datastore.grid_shape_state.x,
datastore.grid_shape_state.y,
)
lons = lats_lons[:, 0].reshape(grid_shape)
lats = lats_lons[:, 1].reshape(grid_shape)
if isinstance(da, xr.DataArray) and "x" in da.dims and "y" in da.dims:
da = da.transpose("x", "y")
values = da.values.reshape(grid_shape)
mesh = ax.pcolormesh(
lons,
lats,
values,
transform=ccrs.PlateCarree(),
vmin=vmin,
vmax=vmax,
cmap=cmap,
shading="auto",
)
if boundary_alpha is not None:
# Overlay boundary mask
mask_da = datastore.boundary_mask
mask_values = mask_da.values
if mask_values.ndim == 2 and mask_values.shape[1] == 1:
mask_values = mask_values[:, 0]
mask_2d = mask_values.reshape(grid_shape)
# Create overlay: 1 where boundary, NaN where interior
overlay = np.where(mask_2d == 1, 1.0, np.nan)
ax.pcolormesh(
lons,
lats,
overlay,
transform=ccrs.PlateCarree(),
cmap=matplotlib.colors.ListedColormap([(1, 1, 1, boundary_alpha)]),
shading="auto",
)
if crop_to_interior:
# Calculate extent of interior
mask_da = datastore.boundary_mask
mask_values = mask_da.values
if mask_values.ndim == 2 and mask_values.shape[1] == 1:
mask_values = mask_values[:, 0]
mask_2d = mask_values.reshape(grid_shape)
interior_points = mask_2d == 0
if np.any(interior_points):
interior_lons = lons[interior_points]
interior_lats = lats[interior_points]
min_lon, max_lon = interior_lons.min(), interior_lons.max()
min_lat, max_lat = interior_lats.min(), interior_lats.max()
ax.set_extent(
[min_lon, max_lon, min_lat, max_lat], crs=ccrs.PlateCarree()
)
if ax_title:
ax.set_title(ax_title, size=_TITLE_SIZE)
return mesh
# rc_context applies NeurIPS font/text settings; figure size is overridden
# by the explicit figsize= below but font family and usetex stay in effect.
[docs]
@matplotlib.rc_context(utils.fractional_plot_bundle(1))
def plot_error_heatmap(
errors: torch.Tensor,
datastore: BaseDatastore,
title: str | None = None,
normalization: str = "state_std",
) -> matplotlib.figure.Figure:
"""
Plot a heatmap of errors across variables and prediction horizons.
Parameters
----------
errors : torch.Tensor
Shape ``(pred_steps, num_state_vars)``. Per-step, per-variable
errors. These values are used for the numeric annotations in each
cell.
datastore : BaseDatastore
Datastore providing variable names, units, and step length.
title : str, optional
Title for the axes.
normalization : {"state_std", "diff_std"}, default "state_std"
Color scaling mode. "state_std" divides by climatological std;
"diff_std" divides by the typical one-step change magnitude. Both fall
back to per-variable max error when the required stats are missing.
Returns
-------
matplotlib.figure.Figure
The completed heatmap figure.
Notes
-----
Color scaling is controlled by `normalization`; see
`_get_heatmap_color_values` for the full fallback logic. When stats are
unavailable the colorbar label includes "[fallback]".
"""
errors_np = _to_heatmap_matrix(errors)
num_variables, pred_steps = errors_np.shape
step_length = datastore.step_length
time_step_int, time_step_unit = utils.get_integer_time(step_length)
layout = _compute_heatmap_layout(n_rows=num_variables, n_cols=pred_steps)
color_values_np, colorbar_label, heatmap_cmap = _get_heatmap_color_values(
errors_np, datastore, normalization
)
finite_color_values = color_values_np[np.isfinite(color_values_np)]
if finite_color_values.size == 0:
vmin, vmax = 0.0, 1.0
else:
vmin = float(finite_color_values.min())
vmax = float(finite_color_values.max())
vmin = min(0.0, vmin)
if np.isclose(vmin, vmax):
vmax = vmin + 1.0
fig, ax = plt.subplots(
figsize=(layout["fig_width"], layout["fig_height"]),
constrained_layout=True,
)
im = ax.imshow(
color_values_np,
cmap=heatmap_cmap,
vmin=vmin,
vmax=vmax,
interpolation="none",
aspect="auto",
)
cbar = fig.colorbar(im, ax=ax, pad=0.02)
cbar.set_label(_tex_safe(colorbar_label), size=layout["tick_label_size"])
cbar.ax.tick_params(labelsize=layout["tick_label_size"])
cbar.ax.yaxis.get_offset_text().set_fontsize(layout["tick_label_size"])
if layout["show_annotations"]:
for (j, i), error in np.ndenumerate(errors_np):
if np.isfinite(error):
formatted_error = (
f"{error:.3g}" if abs(error) < 1.0e4 else f"{error:.2E}"
)
else:
formatted_error = str(error)
text_color = _get_annotation_text_color(
float(color_values_np[j, i]), im
)
ax.text(
i,
j,
formatted_error,
ha="center",
va="center",
usetex=False,
fontsize=layout["annotation_size"],
color=text_color,
)
ax.set_xticks(np.arange(pred_steps))
pred_hor_i = np.arange(pred_steps) + 1
pred_hor_h = time_step_int * pred_hor_i
ax.set_xticklabels(
pred_hor_h,
size=layout["tick_label_size"],
rotation=layout["x_tick_rotation"],
ha="right" if layout["x_tick_rotation"] > 0 else "center",
)
unit_abbr = _LEAD_TIME_UNIT_ABBREVIATIONS.get(
time_step_unit, time_step_unit
)
ax.set_xlabel(f"Lead time ({unit_abbr})", size=layout["tick_label_size"])
ax.set_yticks(np.arange(num_variables))
ax.set_yticklabels(
_get_heatmap_var_labels(datastore=datastore),
size=layout["tick_label_size"],
)
if title:
ax.set_title(title, size=layout["title_size"])
return fig
[docs]
def plot_error_map(
errors: torch.Tensor,
datastore: BaseDatastore,
title: str | None = None,
) -> matplotlib.figure.Figure:
"""
Deprecated: use :func:`plot_error_heatmap` instead.
Parameters
----------
errors : torch.Tensor
The error values to plot.
datastore : BaseDatastore
The datastore containing grid metadata.
title : str, optional
The title for the plot.
Returns
-------
matplotlib.figure.Figure
The completed heatmap figure.
"""
warnings.warn(
"plot_error_map is deprecated, use plot_error_heatmap instead",
DeprecationWarning,
stacklevel=2,
)
return plot_error_heatmap(errors, datastore=datastore, title=title)
[docs]
@matplotlib.rc_context(utils.fractional_plot_bundle(1))
def plot_prediction(
datastore: BaseDatastore,
da_prediction: xr.DataArray,
da_target: xr.DataArray,
title: str | None = None,
vrange: tuple[float, float] | None = None,
boundary_alpha: float = 0.7,
crop_to_interior: bool = True,
colorbar_label: str = "",
) -> matplotlib.figure.Figure:
"""
Plot an example prediction alongside the ground truth.
Parameters
----------
datastore : BaseDatastore
Datastore providing grid metadata and projection.
da_prediction : xarray.DataArray
Shape ``(num_grid_nodes,)``. Predicted field values.
da_target : xarray.DataArray
Shape ``(num_grid_nodes,)``. Ground-truth field values.
title : str, optional
Overall figure title.
vrange : tuple of (float, float), optional
``(vmin, vmax)`` for the shared colour scale. Inferred from data
if not given.
boundary_alpha : float, optional
Alpha transparency for the boundary overlay (default 0.7).
crop_to_interior : bool, optional
If True, crop the axes to the interior region (default True).
colorbar_label : str, optional
Label for the shared colorbar.
Returns
-------
matplotlib.figure.Figure
The completed two-panel prediction figure.
"""
if vrange is None:
vmin = float(min(da_prediction.min(), da_target.min()))
vmax = float(max(da_prediction.max(), da_target.max()))
else:
vmin, vmax = vrange
fig, axes = plt.subplots(
1,
2,
figsize=(13, 6),
subplot_kw={"projection": datastore.coords_projection},
)
for ax, da, subtitle in zip(
axes, (da_target, da_prediction), ("Ground Truth", "Prediction")
):
plot_on_axis(
ax=ax,
da=da,
datastore=datastore,
vmin=vmin,
vmax=vmax,
ax_title=subtitle,
cmap="viridis",
boundary_alpha=boundary_alpha,
crop_to_interior=crop_to_interior,
)
if title:
fig.suptitle(title, size=_TITLE_SIZE)
cbar = fig.colorbar(
axes[0].collections[0],
ax=axes,
orientation="horizontal",
location="bottom",
shrink=0.6,
pad=0.02,
)
cbar.ax.tick_params(labelsize=_TICK_SIZE)
if colorbar_label:
cbar.set_label(_tex_safe(colorbar_label), size=_LABEL_SIZE)
return fig
[docs]
@matplotlib.rc_context(utils.fractional_plot_bundle(1))
def plot_spatial_error(
error: torch.Tensor,
datastore: BaseDatastore,
title: str | None = None,
vrange: tuple[float, float] | None = None,
boundary_alpha: float = 0.7,
crop_to_interior: bool = True,
colorbar_label: str = "",
) -> matplotlib.figure.Figure:
"""
Plot a spatially resolved error map on a projection-aware axis.
Parameters
----------
error : torch.Tensor
Error magnitudes on the flattened grid.
* **Shape**: ``(num_grid_nodes,)``
datastore : BaseDatastore
Datastore providing coordinate metadata and boundary masks.
title : str or None, optional
Optional figure title.
vrange : tuple[float, float] or None, optional
Explicit value range ``(vmin, vmax)`` for the color scale.
boundary_alpha : float, optional
Alpha transparency for the boundary overlay (default 0.7).
crop_to_interior : bool, optional
If True, crop the axes to the interior region (default True).
colorbar_label : str, optional
Label for the colorbar.
Returns
-------
matplotlib.figure.Figure
Figure handle containing the plotted map.
"""
error_np = error.detach().cpu().numpy()
if vrange is None:
vmin = float(np.nanmin(error_np))
vmax = float(np.nanmax(error_np))
else:
vmin, vmax = vrange
fig, ax = plt.subplots(
figsize=(6.5, 6),
subplot_kw={"projection": datastore.coords_projection},
)
mesh = plot_on_axis(
ax=ax, # ty: ignore[invalid-argument-type]
da=xr.DataArray(error_np),
datastore=datastore,
vmin=vmin,
vmax=vmax,
cmap="OrRd",
boundary_alpha=boundary_alpha,
crop_to_interior=crop_to_interior,
)
cbar = fig.colorbar(
mesh,
ax=ax,
orientation="horizontal",
location="bottom",
shrink=0.8,
pad=0.02,
)
cbar.ax.tick_params(labelsize=_TICK_SIZE)
cbar.formatter.set_powerlimits((-3, 3)) # ty: ignore[unresolved-attribute]
if colorbar_label:
cbar.set_label(_tex_safe(colorbar_label), size=_LABEL_SIZE)
if title:
fig.suptitle(title, size=_TITLE_SIZE)
return fig