Skip to content

API Reference

Full reference for every public module in nicefigs, generated from the in-source docstrings. See the Guide for a narrative tour and the Gallery for runnable examples.

nicefigs.style

style

Themes: frozen bundles of font sizes (pt), line widths (pt) and colours.

Because font sizes and figure sizes are both physical units, a figure rendered at a requested physical size (see :mod:nicefigs.figure) keeps its fonts at exactly the theme's sizes.

Example::

import nicefigs as nf

nf.set_theme("paper")            # globally

with nf.style("talk"):           # or temporarily
    fig, ax = plt.subplots()

PALETTE_BRIGHT module-attribute

PALETTE_BRIGHT = ['#4477AA', '#EE6677', '#228833', '#CCBB44', '#66CCEE', '#AA3377', '#BBBBBB']

PALETTE_MUTED module-attribute

PALETTE_MUTED = ['#332288', '#88CCEE', '#44AA99', '#117733', '#999933', '#DDCC77', '#CC6677', '#882255', '#AA4499', '#DDDDDD']

PALETTE_HIGH_CONTRAST module-attribute

PALETTE_HIGH_CONTRAST = ['#004488', '#DDAA33', '#BB5566', '#000000']

COLORS module-attribute

COLORS = {'blue': '#4477AA', 'red': '#EE6677', 'green': '#228833', 'yellow': '#CCBB44', 'cyan': '#66CCEE', 'purple': '#AA3377', 'grey': '#BBBBBB', 'gray': '#BBBBBB', 'primary': '#2166ac', 'accent': '#d95f02', 'good': '#1b9e77', 'bad': '#d7191c', 'neutral': '#666666', 'muted': '#bdbdbd', 'highlight': '#d95f02', 'land': '#f0f0f0', 'ocean': '#eaf1f5', 'coast': '#888888'}

THEMES module-attribute

THEMES: dict[str, Theme] = {'paper': Theme(name='paper'), 'paper-large': Theme.from_base(8.5, name='paper-large'), 'talk': Theme.from_base(13.0, name='talk', lw_axes=0.9, lw_line=2.0, tick_len=3.5), 'poster': Theme.from_base(20.0, name='poster', lw_axes=1.2, lw_line=2.8, tick_len=4.5)}

Theme dataclass

Theme(name: str = 'paper', base: float = 7.0, fs_body: float = 6.5, fs_panel_label: float = 7.8, fs_title: float = 7.0, fs_panel_title: float = 7.2, fs_axis_label: float = 6.5, fs_tick: float = 5.5, fs_legend: float = 6.0, fs_annot: float = 5.5, lw_axes: float = 0.5, lw_grid: float = 0.5, lw_line: float = 1.2, lw_tick: float = 0.4, tick_len: float = 2.0, palette: list[str] = (lambda: list(NICE))(), grid_alpha: float = 0.15, text_color: str = '#222222', spine_color: str = '#222222', font_family: str = 'sans-serif', sans_serif: tuple[str, ...] = ('Helvetica', 'Arial', 'DejaVu Sans', 'sans-serif'), spines_topright: bool = False, grid: bool = False, dpi: int = 150, savedpi: int = 300)

A frozen bundle of styling parameters.

Font sizes (fs_*), line widths (lw_*) and tick_len are in points. Use :meth:from_base to derive every font size from one base size, or :meth:scaled to scale an existing theme.

Attributes:

Name Type Description
name str

Preset name.

base float

Base (body) font size the fs_* sizes derive from.

palette list[str]

Categorical colour cycle (hex strings).

grid_alpha float

Opacity of grid lines when grid is on.

text_color str

Colour of all text.

spine_color str

Colour of spines and ticks.

font_family str

Matplotlib font.family.

sans_serif tuple[str, ...]

Font fallback list for font.sans-serif.

spines_topright bool

Draw the top and right spines.

grid bool

Draw an axes grid by default.

dpi int

Screen resolution (dots per inch).

savedpi int

Resolution for raster output of :func:nicefigs.save.

name class-attribute instance-attribute

name: str = 'paper'

base class-attribute instance-attribute

base: float = 7.0

fs_body class-attribute instance-attribute

fs_body: float = 6.5

fs_panel_label class-attribute instance-attribute

fs_panel_label: float = 7.8

fs_title class-attribute instance-attribute

fs_title: float = 7.0

fs_panel_title class-attribute instance-attribute

fs_panel_title: float = 7.2

fs_axis_label class-attribute instance-attribute

fs_axis_label: float = 6.5

fs_tick class-attribute instance-attribute

fs_tick: float = 5.5

fs_legend class-attribute instance-attribute

fs_legend: float = 6.0

fs_annot class-attribute instance-attribute

fs_annot: float = 5.5

lw_axes class-attribute instance-attribute

lw_axes: float = 0.5

lw_grid class-attribute instance-attribute

lw_grid: float = 0.5

lw_line class-attribute instance-attribute

lw_line: float = 1.2

lw_tick class-attribute instance-attribute

lw_tick: float = 0.4

tick_len class-attribute instance-attribute

tick_len: float = 2.0

palette class-attribute instance-attribute

palette: list[str] = field(default_factory=lambda: list(NICE))

grid_alpha class-attribute instance-attribute

grid_alpha: float = 0.15

text_color class-attribute instance-attribute

text_color: str = '#222222'

spine_color class-attribute instance-attribute

spine_color: str = '#222222'

font_family class-attribute instance-attribute

font_family: str = 'sans-serif'

sans_serif class-attribute instance-attribute

sans_serif: tuple[str, ...] = ('Helvetica', 'Arial', 'DejaVu Sans', 'sans-serif')

spines_topright class-attribute instance-attribute

spines_topright: bool = False

grid class-attribute instance-attribute

grid: bool = False

dpi class-attribute instance-attribute

dpi: int = 150

savedpi class-attribute instance-attribute

savedpi: int = 300

from_base classmethod

from_base(base: float, **overrides: Any) -> Theme

Build a theme whose font sizes scale from one base size.

Parameters:

Name Type Description Default
base float

Body font size (pt).

required
**overrides Any

Field values that replace the derived ones.

{}

Returns:

Type Description
Theme

The new theme.

Source code in src/nicefigs/style.py
@classmethod
def from_base(cls, base: float, **overrides: Any) -> Theme:
    """Build a theme whose font sizes scale from one ``base`` size.

    Args:
        base: Body font size (pt).
        **overrides: Field values that replace the derived ones.

    Returns:
        The new theme.
    """
    sizes = dict(
        base=base,
        fs_body=base,
        fs_panel_label=round(base * 1.2, 2),
        fs_title=round(base * 1.08, 2),
        fs_panel_title=round(base * 1.1, 2),
        fs_axis_label=round(base * 1.00, 2),
        fs_tick=round(base * 0.85, 2),
        fs_legend=round(base * 0.92, 2),
        fs_annot=round(base * 0.85, 2),
    )
    sizes.update(overrides)
    return cls(**sizes)

scaled

scaled(factor: float) -> Theme

Return a copy with every font size multiplied by factor.

Source code in src/nicefigs/style.py
def scaled(self, factor: float) -> Theme:
    """Return a copy with every font size multiplied by ``factor``."""
    return replace(
        self,
        base=self.base * factor,
        fs_body=self.fs_body * factor,
        fs_panel_label=self.fs_panel_label * factor,
        fs_title=self.fs_title * factor,
        fs_panel_title=self.fs_panel_title * factor,
        fs_axis_label=self.fs_axis_label * factor,
        fs_tick=self.fs_tick * factor,
        fs_legend=self.fs_legend * factor,
        fs_annot=self.fs_annot * factor,
    )

with_

with_(**overrides: Any) -> Theme

Return a copy with the given fields overridden.

Parameters:

Name Type Description Default
**overrides Any

Field names and their new values.

{}
Source code in src/nicefigs/style.py
def with_(self, **overrides: Any) -> Theme:
    """Return a copy with the given fields overridden.

    Args:
        **overrides: Field names and their new values.
    """
    return replace(self, **overrides)

rc

rc() -> dict[str, Any]

Render this theme as a matplotlib rcParams dict.

Source code in src/nicefigs/style.py
def rc(self) -> dict[str, Any]:
    """Render this theme as a matplotlib ``rcParams`` dict."""
    return {
        "figure.dpi": self.dpi,
        "savefig.dpi": self.savedpi,
        "savefig.bbox": "tight",
        "savefig.pad_inches": 0.015,
        "figure.facecolor": "white",
        "savefig.facecolor": "white",
        "font.family": self.font_family,
        "font.sans-serif": list(self.sans_serif),
        "font.size": self.fs_body,
        "text.color": self.text_color,
        "axes.edgecolor": self.spine_color,
        "axes.labelcolor": self.text_color,
        "xtick.color": self.spine_color,
        "ytick.color": self.spine_color,
        "xtick.labelcolor": self.text_color,
        "ytick.labelcolor": self.text_color,
        "axes.titlesize": self.fs_title,
        "axes.labelsize": self.fs_axis_label,
        "xtick.labelsize": self.fs_tick,
        "ytick.labelsize": self.fs_tick,
        "legend.fontsize": self.fs_legend,
        "figure.titlesize": self.fs_title,
        "axes.linewidth": self.lw_axes,
        "lines.linewidth": self.lw_line,
        "grid.linewidth": self.lw_grid,
        "xtick.major.width": self.lw_tick,
        "ytick.major.width": self.lw_tick,
        "xtick.minor.width": self.lw_tick * 0.7,
        "ytick.minor.width": self.lw_tick * 0.7,
        "xtick.major.size": self.tick_len,
        "ytick.major.size": self.tick_len,
        "xtick.minor.size": self.tick_len * 0.6,
        "ytick.minor.size": self.tick_len * 0.6,
        "xtick.major.pad": 1.8,
        "ytick.major.pad": 1.8,
        "axes.labelpad": 2.0,
        "axes.titlepad": 3.0,
        "axes.spines.top": self.spines_topright,
        "axes.spines.right": self.spines_topright,
        "axes.grid": self.grid,
        "grid.alpha": self.grid_alpha,
        "grid.linestyle": "-",
        "legend.frameon": False,
        "legend.handlelength": 1.4,
        "legend.handletextpad": 0.5,
        "legend.columnspacing": 1.2,
        "legend.borderaxespad": 0.4,
        "mathtext.fontset": "dejavusans",
        "text.usetex": False,
        "pdf.fonttype": 42,
        "ps.fonttype": 42,
        "axes.prop_cycle": mpl.cycler(color=self.palette),
        "image.cmap": "inferno",
    }

get_theme

get_theme(theme: str | Theme | None = None) -> Theme

Resolve a theme name, a :class:Theme, or None (the active theme).

Raises:

Type Description
KeyError

theme is not a key of :data:THEMES.

Source code in src/nicefigs/style.py
def get_theme(theme: str | Theme | None = None) -> Theme:
    """Resolve a theme name, a :class:`Theme`, or ``None`` (the active theme).

    Raises:
        KeyError: ``theme`` is not a key of :data:`THEMES`.
    """
    if theme is None:
        return _active_theme
    if isinstance(theme, Theme):
        return theme
    try:
        return THEMES[theme]
    except KeyError as exc:
        raise KeyError(f"Unknown theme {theme!r}. Available: {sorted(THEMES)}") from exc

set_theme

set_theme(theme: str | Theme = 'paper') -> Theme

Set the active theme globally and apply its rcParams.

Parameters:

Name Type Description Default
theme str | Theme

Preset name (a key of :data:THEMES) or a :class:Theme.

'paper'

Returns:

Type Description
Theme

The resolved theme.

Source code in src/nicefigs/style.py
def set_theme(theme: str | Theme = "paper") -> Theme:
    """Set the active theme globally and apply its rcParams.

    Args:
        theme: Preset name (a key of :data:`THEMES`) or a :class:`Theme`.

    Returns:
        The resolved theme.
    """
    global _active_theme
    _active_theme = get_theme(theme)
    plt.rcParams.update(_active_theme.rc())
    return _active_theme

style

style(theme: str | Theme | None = None, **rc_overrides: Any) -> Iterator[Theme]

Apply a theme within a with block, restoring the previous one on exit.

The theme is also the active one (:func:get_theme) inside the block.

Parameters:

Name Type Description Default
theme str | Theme | None

Preset name, a :class:Theme, or None for the active theme.

None
**rc_overrides Any

Extra rcParams, e.g. nf.style("paper", **{"axes.grid": True}).

{}

Yields:

Type Description
Theme

The resolved theme.

Source code in src/nicefigs/style.py
@contextlib.contextmanager
def style(theme: str | Theme | None = None, **rc_overrides: Any) -> Iterator[Theme]:
    """Apply a theme within a ``with`` block, restoring the previous one on exit.

    The theme is also the active one (:func:`get_theme`) inside the block.

    Args:
        theme: Preset name, a :class:`Theme`, or ``None`` for the active theme.
        **rc_overrides: Extra ``rcParams``, e.g.
            ``nf.style("paper", **{"axes.grid": True})``.

    Yields:
        The resolved theme.
    """
    global _active_theme
    resolved = get_theme(theme)
    prev = _active_theme
    rc = resolved.rc()
    rc.update(rc_overrides)
    _active_theme = resolved
    try:
        with plt.rc_context(rc):
            yield resolved
    finally:
        _active_theme = prev

nicefigs.figure

figure

Figure creation at physical sizes, saving, and text-layout checks.

Give one dimension (usually a named width such as "single") and an aspect ratio; the other dimension is solved. Fonts are in points, so they render at exactly the theme's sizes::

fig, ax = nf.subplots(width="single", aspect=0.75)
fig, axs = nf.subplots(2, 3, width="full", panel_aspect=1.0)

MM module-attribute

MM = 1.0 / 25.4

WIDTHS module-attribute

WIDTHS: dict[str, float] = {'single': 89 * MM, 'onehalf': 120 * MM, 'double': 183 * MM, 'full': 5.5, 'half': 2.75, 'third': 1.83, 'slide': 13.33, 'slide-half': 6.5, 'slide-third': 4.2}

CANVASES module-attribute

CANVASES: dict[str, tuple[float, float]] = {'slide-16-9': (13.33, 7.5), 'slide-4-3': (10.0, 7.5), 'a4-portrait': (8.27, 11.69), 'a4-landscape': (11.69, 8.27), 'poster-a0-portrait': (33.1, 46.8), 'poster-a0-landscape': (46.8, 33.1)}

DEFAULT_ASPECT module-attribute

DEFAULT_ASPECT = 0.66

TextIssue dataclass

TextIssue(kind: str, texts: tuple[str, ...])

A layout problem found by :func:check_text_overlaps.

Attributes:

Name Type Description
kind str

"overlap" (two text boxes intersect) or "clipped" (a box leaves the figure).

texts tuple[str, ...]

The strings involved: two for an overlap, one for a clipped text.

kind instance-attribute

kind: str

texts instance-attribute

texts: tuple[str, ...]

figure_size

figure_size(width: float | str | None = None, height: float | None = None, aspect: float | None = None, *, nrows: int = 1, ncols: int = 1, panel_aspect: float | None = None) -> tuple[float, float]

Solve a (width, height) in inches from a flexible spec.

Parameters:

Name Type Description Default
width float | str | None

Inches, or a name from :data:WIDTHS / :data:CANVASES.

None
height float | None

Inches. Give either width or height (or both).

None
aspect float | None

Whole-figure height / width. Ignored if both dims are given.

None
nrows int

Panel rows; only used with panel_aspect.

1
ncols int

Panel columns; only used with panel_aspect.

1
panel_aspect float | None

Per-panel height / width. If set, the figure aspect is derived so each panel has this shape (nrows/ncols * panel_aspect). Takes precedence over aspect.

None

Returns:

Type Description
float

(width, height) in inches. With nothing given, a "half"

float

wide panel at :data:DEFAULT_ASPECT.

Raises:

Type Description
KeyError

width is an unknown preset name.

Source code in src/nicefigs/figure.py
def figure_size(
    width: float | str | None = None,
    height: float | None = None,
    aspect: float | None = None,
    *,
    nrows: int = 1,
    ncols: int = 1,
    panel_aspect: float | None = None,
) -> tuple[float, float]:
    """Solve a ``(width, height)`` in inches from a flexible spec.

    Args:
        width: Inches, or a name from :data:`WIDTHS` / :data:`CANVASES`.
        height: Inches. Give *either* ``width`` or ``height`` (or both).
        aspect: Whole-figure ``height / width``. Ignored if both dims are given.
        nrows: Panel rows; only used with ``panel_aspect``.
        ncols: Panel columns; only used with ``panel_aspect``.
        panel_aspect: Per-panel ``height / width``. If set, the figure aspect is
            derived so each panel has this shape (``nrows/ncols * panel_aspect``).
            Takes precedence over ``aspect``.

    Returns:
        ``(width, height)`` in inches. With nothing given, a ``"half"``
        wide panel at :data:`DEFAULT_ASPECT`.

    Raises:
        KeyError: ``width`` is an unknown preset name.
    """
    if isinstance(width, str):
        if width in CANVASES and height is None and aspect is None and panel_aspect is None:
            return CANVASES[width]
        if width in CANVASES:
            width = CANVASES[width][0]
        elif width in WIDTHS:
            width = WIDTHS[width]
        else:
            raise KeyError(f"Unknown width preset {width!r}. Known: {sorted(WIDTHS) + sorted(CANVASES)}")

    if panel_aspect is not None:
        aspect = (nrows / ncols) * panel_aspect

    if width is not None and height is not None:
        return float(width), float(height)
    if width is not None:
        return float(width), float(width) * (aspect if aspect is not None else DEFAULT_ASPECT)
    if height is not None:
        asp = aspect if aspect is not None else DEFAULT_ASPECT
        return float(height) / asp, float(height)
    return WIDTHS["half"], WIDTHS["half"] * DEFAULT_ASPECT

figure

figure(width: float | str | None = None, height: float | None = None, aspect: float | None = None, *, theme: str | Theme | None = None, **kwargs: Any) -> Figure

Create an empty figure at a physical size.

Parameters:

Name Type Description Default
width float | str | None

Inches, or a preset name; see :func:figure_size.

None
height float | None

Inches.

None
aspect float | None

Figure height / width.

None
theme str | Theme | None

Theme applied while the figure is created; None uses the active theme. Use :func:nicefigs.set_theme to keep it afterwards.

None
**kwargs Any

Forwarded to :func:matplotlib.pyplot.figure.

{}

Returns:

Type Description
Figure

The new figure.

Source code in src/nicefigs/figure.py
def figure(
    width: float | str | None = None,
    height: float | None = None,
    aspect: float | None = None,
    *,
    theme: str | Theme | None = None,
    **kwargs: Any,
) -> Figure:
    """Create an empty figure at a physical size.

    Args:
        width: Inches, or a preset name; see :func:`figure_size`.
        height: Inches.
        aspect: Figure ``height / width``.
        theme: Theme applied while the figure is created; ``None`` uses the
            active theme. Use :func:`nicefigs.set_theme` to keep it afterwards.
        **kwargs: Forwarded to :func:`matplotlib.pyplot.figure`.

    Returns:
        The new figure.
    """
    size = figure_size(width, height, aspect)
    th = get_theme(theme)
    with style(th):
        fig = plt.figure(figsize=size, dpi=th.dpi, **kwargs)
    return fig

subplots

subplots(nrows: int = 1, ncols: int = 1, *, width: float | str | None = None, height: float | None = None, aspect: float | None = None, panel_aspect: float | None = None, theme: str | Theme | None = None, **kwargs: Any) -> tuple[Figure, Any]

:func:matplotlib.pyplot.subplots at a physical size, with a theme applied.

Parameters:

Name Type Description Default
nrows int

Panel rows.

1
ncols int

Panel columns.

1
width float | str | None

Inches, or a preset name; see :func:figure_size.

None
height float | None

Inches.

None
aspect float | None

Whole-figure height / width.

None
panel_aspect float | None

Per-panel height / width; overrides aspect.

None
theme str | Theme | None

Theme applied while the figure is created; None uses the active theme.

None
**kwargs Any

Forwarded to :func:matplotlib.pyplot.subplots; wspace, hspace, width_ratios and height_ratios are routed into gridspec_kw.

{}

Returns:

Type Description
Figure

(fig, axes) as returned by :func:matplotlib.pyplot.subplots: one

Any

Axes, or an array of them.

Source code in src/nicefigs/figure.py
def subplots(
    nrows: int = 1,
    ncols: int = 1,
    *,
    width: float | str | None = None,
    height: float | None = None,
    aspect: float | None = None,
    panel_aspect: float | None = None,
    theme: str | Theme | None = None,
    **kwargs: Any,
) -> tuple[Figure, Any]:
    """:func:`matplotlib.pyplot.subplots` at a physical size, with a theme applied.

    Args:
        nrows: Panel rows.
        ncols: Panel columns.
        width: Inches, or a preset name; see :func:`figure_size`.
        height: Inches.
        aspect: Whole-figure ``height / width``.
        panel_aspect: Per-panel ``height / width``; overrides ``aspect``.
        theme: Theme applied while the figure is created; ``None`` uses the
            active theme.
        **kwargs: Forwarded to :func:`matplotlib.pyplot.subplots`;
            ``wspace``, ``hspace``, ``width_ratios`` and ``height_ratios`` are
            routed into ``gridspec_kw``.

    Returns:
        ``(fig, axes)`` as returned by :func:`matplotlib.pyplot.subplots`: one
        Axes, or an array of them.
    """
    size = figure_size(width, height, aspect, nrows=nrows, ncols=ncols, panel_aspect=panel_aspect)
    gridspec_kw = dict(kwargs.pop("gridspec_kw", None) or {})
    for k in ("wspace", "hspace", "width_ratios", "height_ratios"):
        if k in kwargs:
            gridspec_kw[k] = kwargs.pop(k)
    if gridspec_kw:
        kwargs["gridspec_kw"] = gridspec_kw
    th = get_theme(theme)
    with style(th):
        fig, axes = plt.subplots(nrows, ncols, figsize=size, dpi=th.dpi, **kwargs)
    return fig, axes

save

save(fig: Figure, path: str | PathLike[str], *, formats: tuple[str, ...] | None = None, dpi: int | None = None, transparent: bool = False, close: bool = False) -> list[Path]

Save fig with a tight bounding box, creating parent directories.

Parameters:

Name Type Description Default
fig Figure

The figure.

required
path str | PathLike[str]

Output file. With formats, its suffix is replaced by each format.

required
formats tuple[str, ...] | None

Extensions to write, e.g. ("pdf", "png").

None
dpi int | None

Raster resolution; defaults to the active theme's savedpi.

None
transparent bool

Transparent background.

False
close bool

Close the figure after saving.

False

Returns:

Type Description
list[Path]

The written paths, one per format, in the order of formats.

Example::

nf.save(fig, "figures/fig01", formats=("pdf", "png"))
Source code in src/nicefigs/figure.py
def save(
    fig: Figure,
    path: str | os.PathLike[str],
    *,
    formats: tuple[str, ...] | None = None,
    dpi: int | None = None,
    transparent: bool = False,
    close: bool = False,
) -> list[Path]:
    """Save ``fig`` with a tight bounding box, creating parent directories.

    Args:
        fig: The figure.
        path: Output file. With ``formats``, its suffix is replaced by each format.
        formats: Extensions to write, e.g. ``("pdf", "png")``.
        dpi: Raster resolution; defaults to the active theme's ``savedpi``.
        transparent: Transparent background.
        close: Close the figure after saving.

    Returns:
        The written paths, one per format, in the order of ``formats``.

    Example::

        nf.save(fig, "figures/fig01", formats=("pdf", "png"))
    """
    p = Path(path)
    th = get_theme()
    dpi = dpi or th.savedpi
    paths = []
    if formats is None:
        p.parent.mkdir(parents=True, exist_ok=True)
        fig.savefig(p, dpi=dpi, bbox_inches="tight", pad_inches=0.02, transparent=transparent)
        paths.append(p)
    else:
        stem = p.with_suffix("")
        stem.parent.mkdir(parents=True, exist_ok=True)
        for ext in formats:
            out = stem.with_suffix(f".{ext.lstrip('.')}")
            fig.savefig(out, dpi=dpi, bbox_inches="tight", pad_inches=0.02, transparent=transparent)
            paths.append(out)
    if close:
        plt.close(fig)
    return paths

check_text_overlaps

check_text_overlaps(fig: Figure, *, ignore_ticks: bool = False, clipped: bool = True, tolerance: float = 1.0) -> list[TextIssue]

Find text that overlaps other text or runs off the figure, as the renderer draws it.

Every visible, non-empty text artist is measured by its rendered bounding box (titles, axis and tick labels, colorbar labels, legend entries, annotations). Boxes are axis-aligned, so rotated labels count by their enclosing rectangle.

Parameters:

Name Type Description Default
fig Figure

The figure; it is drawn once to lay out its text.

required
ignore_ticks bool

Leave tick labels out entirely.

False
clipped bool

Also report text whose box leaves the figure canvas. Saving with bbox_inches="tight" (as :func:save does) grows the canvas instead of cutting.

True
tolerance float

Pixels a box may cross the figure edge or another box before it counts.

1.0

Returns:

Name Type Description
One list[TextIssue]

class:TextIssue per overlapping pair and per clipped text; empty when the layout is

list[TextIssue]

clean.

Source code in src/nicefigs/figure.py
def check_text_overlaps(
    fig: Figure,
    *,
    ignore_ticks: bool = False,
    clipped: bool = True,
    tolerance: float = 1.0,
) -> list[TextIssue]:
    """Find text that overlaps other text or runs off the figure, as the renderer draws it.

    Every visible, non-empty text artist is measured by its rendered bounding box (titles, axis and
    tick labels, colorbar labels, legend entries, annotations). Boxes are axis-aligned, so rotated
    labels count by their enclosing rectangle.

    Args:
        fig: The figure; it is drawn once to lay out its text.
        ignore_ticks: Leave tick labels out entirely.
        clipped: Also report text whose box leaves the figure canvas. Saving with
            ``bbox_inches="tight"`` (as :func:`save` does) grows the canvas instead of cutting.
        tolerance: Pixels a box may cross the figure edge or another box before it counts.

    Returns:
        One :class:`TextIssue` per overlapping pair and per clipped text; empty when the layout is
        clean.
    """
    fig.canvas.draw()
    renderer = fig.canvas.get_renderer()
    ticks, hidden = _tick_labels(fig)
    skip = ticks if ignore_ticks else hidden
    boxes = []
    for t in fig.findobj(Text):
        if id(t) in skip or not t.get_visible() or not t.get_text().strip():
            continue
        boxes.append((t.get_text(), t.get_window_extent(renderer).padded(-tolerance / 2)))
    issues = []
    for i, (s1, b1) in enumerate(boxes):
        for s2, b2 in boxes[i + 1 :]:
            if b1.overlaps(b2):
                issues.append(TextIssue("overlap", (s1, s2)))
    if clipped:
        fb = fig.bbox
        for s, b in boxes:
            if (
                b.x0 < fb.x0 - tolerance
                or b.y0 < fb.y0 - tolerance
                or b.x1 > fb.x1 + tolerance
                or b.y1 > fb.y1 + tolerance
            ):
                issues.append(TextIssue("clipped", (s,)))
    return issues

nicefigs.panels

panels

Multi-panel layout: grids, small multiples, panel letters, row/column headers and titles.

panel_label

panel_label(ax: Axes, label: str, title: str = '', *, x: float = 0.0, y: float = 1.0, pad: float | None = None, weight: str = 'bold', parens: bool = False, use_ax_title: bool = False, dedupe: bool = False) -> None

Draw a panel label (a) and an optional short title above ax.

The label is bold lowercase without parentheses, set at the theme's fs_panel_label (1.2 times the axis-label size). Its left edge is flush with the axes' left edge and its baseline sits pad points above the top edge. The title follows on the same baseline in normal weight at fs_panel_title, half a label-height after the label. On cartopy map axes both are anchored to the axes' grid cell, so labels line up down the figure.

Note

The title is drawn as passed: label "a" with title "a priori flux" renders a a priori flux. Pass dedupe=True to strip a title prefix equal to the label.

Parameters:

Name Type Description Default
ax Axes

Target axes.

required
label str

Label text, e.g. "a".

required
title str

Short title drawn after the label.

''
x float

Anchor x-position in axes fraction (of the grid cell for map axes).

0.0
y float

Anchor y-position, same coordinates as x.

1.0
pad float | None

Height of the baseline above the anchor (pt); defaults to half the label size.

None
weight str

Font weight of the label; the title is always normal weight.

'bold'
parens bool

Wrap the label in parentheses, e.g. "(a)".

False
use_ax_title bool

When title is empty, use the axes' own left, centre or right title instead and clear it, so it is not drawn twice.

False
dedupe bool

Strip a leading copy of the label (and whitespace) from title.

False
Source code in src/nicefigs/panels.py
def panel_label(
    ax: Axes,
    label: str,
    title: str = "",
    *,
    x: float = 0.0,
    y: float = 1.0,
    pad: float | None = None,
    weight: str = "bold",
    parens: bool = False,
    use_ax_title: bool = False,
    dedupe: bool = False,
) -> None:
    """Draw a panel label (``a``) and an optional short title above ``ax``.

    The label is bold lowercase without parentheses, set at the theme's
    ``fs_panel_label`` (1.2 times the axis-label size). Its left edge is flush
    with the axes' left edge and its baseline sits ``pad`` points above the top
    edge. The title follows on the same baseline in normal weight at
    ``fs_panel_title``, half a label-height after the label. On cartopy map
    axes both are anchored to the axes' grid cell, so labels line up down the
    figure.

    Note:
        The title is drawn as passed: label ``"a"`` with title
        ``"a priori flux"`` renders ``a  a priori flux``. Pass ``dedupe=True``
        to strip a title prefix equal to the label.

    Args:
        ax: Target axes.
        label: Label text, e.g. ``"a"``.
        title: Short title drawn after the label.
        x: Anchor x-position in axes fraction (of the grid cell for map axes).
        y: Anchor y-position, same coordinates as ``x``.
        pad: Height of the baseline above the anchor (pt); defaults to half the
            label size.
        weight: Font weight of the label; the title is always normal weight.
        parens: Wrap the label in parentheses, e.g. ``"(a)"``.
        use_ax_title: When ``title`` is empty, use the axes' own left, centre
            or right title instead and clear it, so it is not drawn twice.
        dedupe: Strip a leading copy of the label (and whitespace) from ``title``.
    """
    from matplotlib.transforms import offset_copy

    th = get_theme()
    text = f"({label})" if parens else label
    if not title and use_ax_title:
        for loc in ("left", "center", "right"):
            title = ax.get_title(loc=loc)
            if title:
                ax.set_title("", loc=loc)
                break
    if title and dedupe:
        title = _dedupe_title(text, label, title)
    pad = 0.5 * th.fs_panel_label if pad is None else pad
    fig = ax.figure
    if _is_geoaxes(ax):
        # The original (pre-aspect) position is the grid cell, not the pillar-boxed map.
        fig.canvas.draw()
        pos = ax.get_position(original=True)
        base, target = fig.transFigure, fig
        tx, ty = pos.x0 + x * pos.width, pos.y1 + (y - 1.0) * pos.height
    else:
        base, target = ax.transAxes, ax
        tx, ty = x, y
    at_label = offset_copy(base, fig=fig, x=0.0, y=pad, units="points")
    mark = target.text(
        tx,
        ty,
        text,
        transform=at_label,
        fontsize=th.fs_panel_label,
        fontweight=weight,
        va="baseline",
        ha="left",
    )
    if title:
        width_pt = mark.get_window_extent(fig.canvas.get_renderer()).width * 72.0 / fig.dpi
        gap_pt = width_pt + 0.5 * th.fs_panel_label
        at_title = offset_copy(base, fig=fig, x=gap_pt, y=pad, units="points")
        target.text(tx, ty, title, transform=at_title, fontsize=th.fs_panel_title, va="baseline", ha="left")

label_panels

label_panels(axes: Iterable[Axes | None], titles: Sequence[str] | None = None, *, start: int = 0, upper: bool = False, parens: bool = False, x: float = 0.0, y: float = 1.0, pad: float | None = None, skip_none: bool = True, use_ax_title: bool = False, dedupe: bool = False) -> None

Label axes a, b, c, … in reading order with :func:panel_label.

Parameters:

Name Type Description Default
axes Iterable[Axes | None]

Axes, or an array of them (flattened row-major).

required
titles Sequence[str] | None

Per-panel short titles, aligned with the flattened axes.

None
start int

Offset into the alphabet (0 is a).

0
upper bool

Use A, B, C.

False
parens bool

Wrap the letter in parentheses.

False
x float

Anchor x-position in axes fraction.

0.0
y float

Anchor y-position in axes fraction.

1.0
pad float | None

Height of the label baseline above the anchor (pt); see :func:panel_label.

None
skip_none bool

Skip None entries (empty grid cells) without using a letter.

True
use_ax_title bool

For panels with no entry in titles, fall back to each axes' own ax.get_title() and clear it — see :func:panel_label.

False
dedupe bool

Drop a leading duplicate of the letter from the title — see :func:panel_label.

False
Source code in src/nicefigs/panels.py
def label_panels(
    axes: Iterable[Axes | None],
    titles: Sequence[str] | None = None,
    *,
    start: int = 0,
    upper: bool = False,
    parens: bool = False,
    x: float = 0.0,
    y: float = 1.0,
    pad: float | None = None,
    skip_none: bool = True,
    use_ax_title: bool = False,
    dedupe: bool = False,
) -> None:
    """Label axes ``a, b, c, …`` in reading order with :func:`panel_label`.

    Args:
        axes: Axes, or an array of them (flattened row-major).
        titles: Per-panel short titles, aligned with the flattened ``axes``.
        start: Offset into the alphabet (``0`` is ``a``).
        upper: Use ``A, B, C``.
        parens: Wrap the letter in parentheses.
        x: Anchor x-position in axes fraction.
        y: Anchor y-position in axes fraction.
        pad: Height of the label baseline above the anchor (pt); see :func:`panel_label`.
        skip_none: Skip ``None`` entries (empty grid cells) without using a letter.
        use_ax_title: For panels with no entry in ``titles``, fall back to
            each axes' own ``ax.get_title()`` and clear it — see
            :func:`panel_label`.
        dedupe: Drop a leading duplicate of the letter from the title — see
            :func:`panel_label`.
    """
    flat = (
        list(np.asarray(list(axes), dtype=object).ravel())
        if not isinstance(axes, np.ndarray)
        else list(axes.ravel())
    )
    i = 0
    for k, ax in enumerate(flat):
        if ax is None and skip_none:
            continue
        letter = chr(ord("A" if upper else "a") + start + i)
        title = titles[k] if titles is not None and k < len(titles) else ""
        panel_label(
            ax,
            letter,
            title or "",
            x=x,
            y=y,
            pad=pad,
            parens=parens,
            use_ax_title=use_ax_title,
            dedupe=dedupe,
        )
        i += 1

row_labels

row_labels(axes: Any, labels: Sequence[str], *, x: float = -0.22, rotation: float = 90.0, fontsize: float | None = None, weight: str = 'normal', pad: float = 0.0) -> None

Label the rows of a panel grid with rotated headers down the left margin.

Parameters:

Name Type Description Default
axes Any

A (nrows, ncols) grid (its first column is used), or one axes per row.

required
labels Sequence[str]

One header per row; empty strings are skipped.

required
x float

Header x-position in axes fraction.

-0.22
rotation float

Text rotation (degrees).

90.0
fontsize float | None

Font size (pt); defaults to the theme's fs_panel_title.

None
weight str

Font weight.

'normal'
pad float

Vertical shift of the header from the row centre, in axes fraction.

0.0
Source code in src/nicefigs/panels.py
def row_labels(
    axes: Any,
    labels: Sequence[str],
    *,
    x: float = -0.22,
    rotation: float = 90.0,
    fontsize: float | None = None,
    weight: str = "normal",
    pad: float = 0.0,
) -> None:
    """Label the rows of a panel grid with rotated headers down the left margin.

    Args:
        axes: A ``(nrows, ncols)`` grid (its first column is used), or one axes per row.
        labels: One header per row; empty strings are skipped.
        x: Header x-position in axes fraction.
        rotation: Text rotation (degrees).
        fontsize: Font size (pt); defaults to the theme's ``fs_panel_title``.
        weight: Font weight.
        pad: Vertical shift of the header from the row centre, in axes fraction.
    """
    th = get_theme()
    fs = fontsize if fontsize is not None else th.fs_panel_title
    targets = _cell_axes(axes, "row")
    for ax, lab in zip(targets, labels):
        if ax is None or not lab:
            continue
        if _is_geoaxes(ax):
            fig = ax.figure
            fig.canvas.draw()
            pos = ax.get_position(original=True)
            fig.text(
                pos.x0 + x * pos.width,
                pos.y0 + 0.5 * pos.height,
                lab,
                rotation=rotation,
                ha="center",
                va="center",
                fontsize=fs,
                fontweight=weight,
            )
        else:
            ax.text(
                x,
                0.5 + pad,
                lab,
                transform=ax.transAxes,
                rotation=rotation,
                ha="center",
                va="center",
                fontsize=fs,
                fontweight=weight,
            )

col_labels

col_labels(axes: Any, labels: Sequence[str], *, y: float = 1.05, fontsize: float | None = None, weight: str = 'normal', pad: float = 0.0) -> None

Label the columns of a panel grid with headers above the top row.

Parameters:

Name Type Description Default
axes Any

A (nrows, ncols) grid (its first row is used), or one axes per column.

required
labels Sequence[str]

One header per column; empty strings are skipped.

required
y float

Header y-position in axes fraction.

1.05
fontsize float | None

Font size (pt); defaults to the theme's fs_panel_title.

None
weight str

Font weight.

'normal'
pad float

Horizontal shift of the header from the column centre, in axes fraction.

0.0
Source code in src/nicefigs/panels.py
def col_labels(
    axes: Any,
    labels: Sequence[str],
    *,
    y: float = 1.05,
    fontsize: float | None = None,
    weight: str = "normal",
    pad: float = 0.0,
) -> None:
    """Label the columns of a panel grid with headers above the top row.

    Args:
        axes: A ``(nrows, ncols)`` grid (its first row is used), or one axes per column.
        labels: One header per column; empty strings are skipped.
        y: Header y-position in axes fraction.
        fontsize: Font size (pt); defaults to the theme's ``fs_panel_title``.
        weight: Font weight.
        pad: Horizontal shift of the header from the column centre, in axes fraction.
    """
    th = get_theme()
    fs = fontsize if fontsize is not None else th.fs_panel_title
    targets = _cell_axes(axes, "col")
    for ax, lab in zip(targets, labels):
        if ax is None or not lab:
            continue
        if _is_geoaxes(ax):
            fig = ax.figure
            fig.canvas.draw()
            pos = ax.get_position(original=True)
            fig.text(
                pos.x0 + 0.5 * pos.width,
                pos.y1 + (y - 1.0) * pos.height,
                lab,
                ha="center",
                va="bottom",
                fontsize=fs,
                fontweight=weight,
            )
        else:
            ax.text(
                0.5 + pad,
                y,
                lab,
                transform=ax.transAxes,
                ha="center",
                va="bottom",
                fontsize=fs,
                fontweight=weight,
            )

clean_title

clean_title(ax: Axes, title: str, *, loc: str = 'left', pad: float = 3.0) -> None

Set an axes title at the theme's title size, left-aligned by default.

Parameters:

Name Type Description Default
ax Axes

Target axes.

required
title str

Title text.

required
loc str

"left", "center" or "right".

'left'
pad float

Gap between title and axes (pt).

3.0
Source code in src/nicefigs/panels.py
def clean_title(ax: Axes, title: str, *, loc: str = "left", pad: float = 3.0) -> None:
    """Set an axes title at the theme's title size, left-aligned by default.

    Args:
        ax: Target axes.
        title: Title text.
        loc: ``"left"``, ``"center"`` or ``"right"``.
        pad: Gap between title and axes (pt).
    """
    th = get_theme()
    ax.set_title(title, fontsize=th.fs_title, loc=loc, pad=pad)

despine

despine(ax: Axes, *, top: bool = True, right: bool = True, left: bool = False, bottom: bool = False) -> None

Hide the selected spines of ax (top and right by default).

Source code in src/nicefigs/panels.py
def despine(
    ax: Axes, *, top: bool = True, right: bool = True, left: bool = False, bottom: bool = False
) -> None:
    """Hide the selected spines of ``ax`` (top and right by default)."""
    for side, off in (("top", top), ("right", right), ("left", left), ("bottom", bottom)):
        if off:
            ax.spines[side].set_visible(False)

grid

grid(nrows: int, ncols: int, *, width: float | str | None = None, height: float | None = None, aspect: float | None = None, panel_aspect: float | None = None, theme: str | Theme | None = None, hspace: float = 0.28, wspace: float = 0.22, width_ratios: Sequence[float] | None = None, height_ratios: Sequence[float] | None = None, projection: Any = None, with_legend_row: bool = False, legend_row_height: float = 0.12) -> tuple[Figure, np.ndarray] | tuple[Figure, np.ndarray, Axes]

Create a GridSpec panel grid at a physical size.

Parameters:

Name Type Description Default
nrows int

Panel rows.

required
ncols int

Panel columns.

required
width float | str | None

Inches or a preset name; see :func:nicefigs.figure_size.

None
height float | None

Inches.

None
aspect float | None

Whole-figure height / width.

None
panel_aspect float | None

Per-panel height / width; overrides aspect.

None
theme str | Theme | None

Theme applied while the figure is created; None uses the active theme.

None
hspace float

Vertical gap between panels (fraction of mean panel height).

0.28
wspace float

Horizontal gap between panels (fraction of mean panel width).

0.22
width_ratios Sequence[float] | None

Relative column widths.

None
height_ratios Sequence[float] | None

Relative row heights.

None
projection Any

Projection for every panel, e.g. a cartopy CRS.

None
with_legend_row bool

Add a hidden, full-width bottom row for a shared legend.

False
legend_row_height float

Height ratio of the legend row relative to a panel row.

0.12

Returns:

Type Description
tuple[Figure, ndarray] | tuple[Figure, ndarray, Axes]

(fig, axes) with axes a (nrows, ncols) object array, or

tuple[Figure, ndarray] | tuple[Figure, ndarray, Axes]

(fig, axes, ax_legend) when with_legend_row is set.

Source code in src/nicefigs/panels.py
def grid(
    nrows: int,
    ncols: int,
    *,
    width: float | str | None = None,
    height: float | None = None,
    aspect: float | None = None,
    panel_aspect: float | None = None,
    theme: str | Theme | None = None,
    hspace: float = 0.28,
    wspace: float = 0.22,
    width_ratios: Sequence[float] | None = None,
    height_ratios: Sequence[float] | None = None,
    projection: Any = None,
    with_legend_row: bool = False,
    legend_row_height: float = 0.12,
) -> tuple[Figure, np.ndarray] | tuple[Figure, np.ndarray, Axes]:
    """Create a GridSpec panel grid at a physical size.

    Args:
        nrows: Panel rows.
        ncols: Panel columns.
        width: Inches or a preset name; see :func:`nicefigs.figure_size`.
        height: Inches.
        aspect: Whole-figure ``height / width``.
        panel_aspect: Per-panel ``height / width``; overrides ``aspect``.
        theme: Theme applied while the figure is created; ``None`` uses the active theme.
        hspace: Vertical gap between panels (fraction of mean panel height).
        wspace: Horizontal gap between panels (fraction of mean panel width).
        width_ratios: Relative column widths.
        height_ratios: Relative row heights.
        projection: Projection for every panel, e.g. a cartopy CRS.
        with_legend_row: Add a hidden, full-width bottom row for a shared legend.
        legend_row_height: Height ratio of the legend row relative to a panel row.

    Returns:
        ``(fig, axes)`` with ``axes`` a ``(nrows, ncols)`` object array, or
        ``(fig, axes, ax_legend)`` when ``with_legend_row`` is set.
    """
    from matplotlib.gridspec import GridSpec

    size = figure_size(width, height, aspect, nrows=nrows, ncols=ncols, panel_aspect=panel_aspect)
    th = get_theme(theme)

    total_rows = nrows + (1 if with_legend_row else 0)
    hr = list(height_ratios) if height_ratios is not None else [1.0] * nrows
    if with_legend_row:
        hr = hr + [legend_row_height]
    with style(th):
        fig = plt.figure(figsize=size, dpi=th.dpi)
        gs = GridSpec(
            total_rows,
            ncols,
            figure=fig,
            hspace=hspace,
            wspace=wspace,
            width_ratios=width_ratios,
            height_ratios=hr,
        )
        axes = np.empty((nrows, ncols), dtype=object)
        for i in range(nrows):
            for j in range(ncols):
                axes[i, j] = fig.add_subplot(gs[i, j], projection=projection)
        if with_legend_row:
            ax_legend = fig.add_subplot(gs[nrows, :])
            ax_legend.axis("off")
            return fig, axes, ax_legend
    return fig, axes

reference_line

reference_line(ax: Axes, value: float, *, axis: str = 'y', label: str = '', color: str | None = None, linestyle: str = '--', linewidth: float = 0.7, alpha: float = 0.7, fmt: str = '{label}', zorder: float = 1) -> None

Draw a horizontal (axis="y") or vertical (axis="x") reference line with an optional label.

Parameters:

Name Type Description Default
ax Axes

Target axes.

required
value float

Position of the line in data coordinates.

required
axis str

"y" for a horizontal line at y=value, "x" for a vertical one.

'y'
label str

Text drawn at the line's far end; empty draws none.

''
color str | None

Line and label colour; defaults to grey.

None
linestyle str

Line style.

'--'
linewidth float

Line width (pt).

0.7
alpha float

Line opacity.

0.7
fmt str

Label format with {label} and {value} fields, e.g. "{label} ({value:.2g})".

'{label}'
zorder float

Line drawing order.

1
Source code in src/nicefigs/panels.py
def reference_line(
    ax: Axes,
    value: float,
    *,
    axis: str = "y",
    label: str = "",
    color: str | None = None,
    linestyle: str = "--",
    linewidth: float = 0.7,
    alpha: float = 0.7,
    fmt: str = "{label}",
    zorder: float = 1,
) -> None:
    """Draw a horizontal (``axis="y"``) or vertical (``axis="x"``) reference line with an optional label.

    Args:
        ax: Target axes.
        value: Position of the line in data coordinates.
        axis: ``"y"`` for a horizontal line at ``y=value``, ``"x"`` for a vertical one.
        label: Text drawn at the line's far end; empty draws none.
        color: Line and label colour; defaults to grey.
        linestyle: Line style.
        linewidth: Line width (pt).
        alpha: Line opacity.
        fmt: Label format with ``{label}`` and ``{value}`` fields, e.g. ``"{label} ({value:.2g})"``.
        zorder: Line drawing order.
    """
    th = get_theme()
    color = color or "#888888"
    (ax.axhline if axis == "y" else ax.axvline)(
        value, color=color, linestyle=linestyle, linewidth=linewidth, alpha=alpha, zorder=zorder
    )
    if label:
        text = fmt.format(label=label, value=value)
        if axis == "y":
            ax.text(
                0.995,
                value,
                f" {text}",
                transform=ax.get_yaxis_transform(),
                ha="right",
                va="bottom",
                fontsize=th.fs_annot,
                color=color,
            )
        else:
            ax.text(
                value,
                0.995,
                f" {text}",
                transform=ax.get_xaxis_transform(),
                ha="left",
                va="top",
                rotation=90,
                fontsize=th.fs_annot,
                color=color,
            )

facet

facet(items: Iterable[Any], draw: Callable[[Axes, Any, int], object], *, ncols: int = 3, width: float | str = 'full', panel_aspect: float = 0.8, theme: str | Theme | None = None, sharex: bool | str = False, sharey: bool | str = False, hide_unused: bool = True, labels: bool = False, titles: Sequence[str] | None = None, hspace: float = 0.35, wspace: float = 0.22) -> tuple[Figure, np.ndarray]

Draw small multiples: one panel per item, filled by draw(ax, item, i).

Parameters:

Name Type Description Default
items Iterable[Any]

One entry per panel, passed to draw.

required
draw Callable[[Axes, Any, int], object]

Callback drawing item (the i-th) into ax.

required
ncols int

Maximum panel columns; rows follow from len(items).

3
width float | str

Figure width, inches or a preset name.

'full'
panel_aspect float

Per-panel height / width.

0.8
theme str | Theme | None

Theme applied while the figure is created; None uses the active theme.

None
sharex bool | str

Forwarded to :func:matplotlib.pyplot.subplots.

False
sharey bool | str

Forwarded to :func:matplotlib.pyplot.subplots.

False
hide_unused bool

Hide the trailing cells without an item.

True
labels bool

Add a, b, c panel labels.

False
titles Sequence[str] | None

Per-panel titles set with :func:clean_title.

None
hspace float

Vertical gap between panels.

0.35
wspace float

Horizontal gap between panels.

0.22

Returns:

Type Description
tuple[Figure, ndarray]

(fig, axes) with axes the flat array of all cells, unused ones included.

Source code in src/nicefigs/panels.py
def facet(
    items: Iterable[Any],
    draw: Callable[[Axes, Any, int], object],
    *,
    ncols: int = 3,
    width: float | str = "full",
    panel_aspect: float = 0.8,
    theme: str | Theme | None = None,
    sharex: bool | str = False,
    sharey: bool | str = False,
    hide_unused: bool = True,
    labels: bool = False,
    titles: Sequence[str] | None = None,
    hspace: float = 0.35,
    wspace: float = 0.22,
) -> tuple[Figure, np.ndarray]:
    """Draw small multiples: one panel per item, filled by ``draw(ax, item, i)``.

    Args:
        items: One entry per panel, passed to ``draw``.
        draw: Callback drawing ``item`` (the ``i``-th) into ``ax``.
        ncols: Maximum panel columns; rows follow from ``len(items)``.
        width: Figure width, inches or a preset name.
        panel_aspect: Per-panel ``height / width``.
        theme: Theme applied while the figure is created; ``None`` uses the active theme.
        sharex: Forwarded to :func:`matplotlib.pyplot.subplots`.
        sharey: Forwarded to :func:`matplotlib.pyplot.subplots`.
        hide_unused: Hide the trailing cells without an item.
        labels: Add ``a, b, c`` panel labels.
        titles: Per-panel titles set with :func:`clean_title`.
        hspace: Vertical gap between panels.
        wspace: Horizontal gap between panels.

    Returns:
        ``(fig, axes)`` with ``axes`` the flat array of all cells, unused ones included.
    """
    items = list(items)
    n = len(items)
    ncols = min(ncols, n) if n else ncols
    nrows = int(np.ceil(n / ncols)) if n else 1

    size = figure_size(width, panel_aspect=panel_aspect, nrows=nrows, ncols=ncols)
    th = get_theme(theme)
    with style(th):
        fig, axes = plt.subplots(
            nrows,
            ncols,
            figsize=size,
            dpi=th.dpi,
            squeeze=False,
            sharex=sharex,
            sharey=sharey,
            gridspec_kw=dict(hspace=hspace, wspace=wspace),
        )
    axes_flat = axes.ravel()
    for i, (ax, item) in enumerate(zip(axes_flat, items)):
        draw(ax, item, i)
        if titles is not None and i < len(titles):
            clean_title(ax, titles[i])
    if hide_unused:
        for ax in axes_flat[n:]:
            ax.axis("off")
    if labels:
        label_panels(axes_flat[:n])
    return fig, axes_flat

nicefigs.palettes

palettes

Colormaps and palettes: named colormaps, land-cover classes, comparison roles and colour limits.

Colormaps resolve by name through :func:cmap (matplotlib names, the data-kind names in :data:DEFAULTS, and :data:CUSTOM_CMAPS); land-cover products through :func:categorical; comparison roles through :func:role_style.

DEFAULTS module-attribute

DEFAULTS = {'sequential': 'inferno', 'sequential2': 'Spectral_r', 'intensity': 'inferno', 'diverging': 'RdBu_r', 'diverging2': 'BrBG', 'cyclic': 'twilight', 'rainbow': 'Spectral_r'}

CUSTOM_CMAPS module-attribute

CUSTOM_CMAPS: dict[str, Colormap] = {'inside_outside': mcolors.LinearSegmentedColormap.from_list('inside_outside', ['#003f5c', '#3d6d8e', '#8fb3c9', '#cfe0ea', '#ffffff', '#f3c1a0', '#dd8452', '#a34511', '#581c87'])}

SCORECARD_DIVERGING_BOUNDARIES module-attribute

SCORECARD_DIVERGING_BOUNDARIES: list[float] = [-50, -20, -10, -5, -2, -1, 1, 2, 5, 10, 20, 50]

ROLES module-attribute

ROLES: dict[str, Role] = {'primary': Role('#4477AA', 'o'), 'contrast': Role('#D95F02', 's'), 'baseline': Role('#3A3A3A', 'D'), 'null': Role('#AAAAAA', 'o', filled=False, zorder=1.0, band='#E6E6E6'), 'reference': Role('#000000', '*', linestyle='--', linewidth_scale=0.5, zorder=1.5)}

CATEGORICAL module-attribute

CATEGORICAL: dict[str, Categorical] = {'esa_worldcover': Categorical('esa_worldcover', _ESA_WC_NAMES, _ESA_WC_COLORS), 'esri_lulc': Categorical('esri_lulc', _ESRI_NAMES, _ESRI_COLORS), 'modis_igbp': Categorical('modis_igbp', _MODIS_NAMES, _MODIS_COLORS)}

Role dataclass

Role(color: str, marker: str, filled: bool = True, linestyle: str = '-', linewidth_scale: float = 1.0, zorder: float = 2.0, band: str | None = None)

How one role in a comparison figure (method under study, baseline, ...) is drawn.

Attributes:

Name Type Description
color str

Line, marker and marker-edge colour.

marker str

Matplotlib marker shape.

filled bool

Whether markers are filled (False draws them open).

linestyle str

Line style for lines.

linewidth_scale float

Line width as a multiple of the active theme's lw_line.

zorder float

Drawing order; lower draws behind the data.

band str | None

Fill colour for a range drawn behind the data, or None.

color instance-attribute

color: str

marker instance-attribute

marker: str

filled class-attribute instance-attribute

filled: bool = True

linestyle class-attribute instance-attribute

linestyle: str = '-'

linewidth_scale class-attribute instance-attribute

linewidth_scale: float = 1.0

zorder class-attribute instance-attribute

zorder: float = 2.0

band class-attribute instance-attribute

band: str | None = None

Categorical dataclass

Categorical(key: str, names: dict[int, str], colors: dict[int, str])

A categorical palette mapping integer class codes to names and colours.

Attributes:

Name Type Description
key str

Palette name.

names dict[int, str]

Display name per class code.

colors dict[int, str]

Hex colour per class code.

key instance-attribute

key: str

names instance-attribute

names: dict[int, str]

colors instance-attribute

colors: dict[int, str]

codes property

codes: list[int]

Class codes in ascending order.

cmap_norm

cmap_norm() -> tuple[mcolors.ListedColormap, mcolors.BoundaryNorm]

(cmap, norm) mapping each class code to its colour, for ax.imshow(codes, cmap=cmap, norm=norm).

Source code in src/nicefigs/palettes.py
def cmap_norm(self) -> tuple[mcolors.ListedColormap, mcolors.BoundaryNorm]:
    """``(cmap, norm)`` mapping each class code to its colour, for ``ax.imshow(codes, cmap=cmap, norm=norm)``."""
    codes = self.codes
    cols = [self.colors[c] for c in codes]
    cm = mcolors.ListedColormap(cols)
    bounds = [codes[0] - 0.5] + [0.5 * (a + b) for a, b in zip(codes[:-1], codes[1:])] + [codes[-1] + 0.5]
    norm = mcolors.BoundaryNorm(bounds, cm.N)
    return cm, norm

to_rgb

to_rgb(arr: ArrayLike, *, default: str = '#dddddd') -> np.ndarray

Convert class codes of shape (H, W) to a float32 RGB image (H, W, 3).

Parameters:

Name Type Description Default
arr ArrayLike

Class codes.

required
default str

Colour of codes not in the palette.

'#dddddd'
Source code in src/nicefigs/palettes.py
def to_rgb(self, arr: ArrayLike, *, default: str = "#dddddd") -> np.ndarray:
    """Convert class codes of shape ``(H, W)`` to a float32 RGB image ``(H, W, 3)``.

    Args:
        arr: Class codes.
        default: Colour of codes not in the palette.
    """
    arr = np.asarray(arr)
    out = np.tile(np.asarray(mcolors.to_rgb(default), dtype=np.float32), (*arr.shape, 1))
    for code, hexcol in self.colors.items():
        m = arr == code
        if m.any():
            out[m] = mcolors.to_rgb(hexcol)
    return out

legend_handles

legend_handles(codes: list[int] | None = None) -> list[Patch]

Legend patches, one per code in codes (default: all) that the palette defines.

Source code in src/nicefigs/palettes.py
def legend_handles(self, codes: list[int] | None = None) -> list[Patch]:
    """Legend patches, one per code in ``codes`` (default: all) that the palette defines."""
    codes = codes if codes is not None else self.codes
    return [
        Patch(facecolor=self.colors[c], edgecolor="none", label=self.names.get(c, str(c)))
        for c in codes
        if c in self.colors
    ]

cmap

cmap(name: str | Colormap, n: int | None = None) -> mcolors.Colormap

Resolve a colormap by name.

Parameters:

Name Type Description Default
name str | Colormap

A :data:CUSTOM_CMAPS or :data:DEFAULTS key, a matplotlib colormap name, or a Colormap (returned as is).

required
n int | None

Resample to n discrete levels.

None

Raises:

Type Description
KeyError

Unknown name.

Source code in src/nicefigs/palettes.py
def cmap(name: str | mcolors.Colormap, n: int | None = None) -> mcolors.Colormap:
    """Resolve a colormap by name.

    Args:
        name: A :data:`CUSTOM_CMAPS` or :data:`DEFAULTS` key, a matplotlib
            colormap name, or a ``Colormap`` (returned as is).
        n: Resample to ``n`` discrete levels.

    Raises:
        KeyError: Unknown name.
    """
    if isinstance(name, mcolors.Colormap):
        cm = name
    else:
        key = str(name)
        if key in CUSTOM_CMAPS:
            cm = CUSTOM_CMAPS[key]
        else:
            key = _ALIASES.get(key.lower(), key)
            if key in DEFAULTS:
                key = DEFAULTS[key]
                key = _ALIASES.get(key.lower(), key)
            cm = CUSTOM_CMAPS.get(key) or mpl.colormaps[key]
    if n is not None:
        cm = cm.resampled(n)
    return cm

role_color

role_color(role: str, *, band: bool = False) -> str

Hex colour of role in :data:ROLES, or of its band fill if band.

Raises:

Type Description
KeyError

role is not in :data:ROLES.

ValueError

band is requested for a role without a band colour.

Source code in src/nicefigs/palettes.py
def role_color(role: str, *, band: bool = False) -> str:
    """Hex colour of ``role`` in :data:`ROLES`, or of its band fill if ``band``.

    Raises:
        KeyError: ``role`` is not in :data:`ROLES`.
        ValueError: ``band`` is requested for a role without a band colour.
    """
    r = _role(role)
    if not band:
        return r.color
    if r.band is None:
        raise ValueError(f"Role {role!r} has no band colour.")
    return r.band

role_style

role_style(role: str, kind: str = 'marker', *, filled: bool | None = None, **overrides: Any) -> dict[str, Any]

Keyword arguments that draw role with ax.plot, ax.errorbar or ax.fill_between.

Line widths scale with the active theme's lw_line; marker size is left to the caller.

Parameters:

Name Type Description Default
role str

A key of :data:ROLES.

required
kind str

"marker" (markers, no line), "line" (line, no markers), "both", or "band" (fill keywords for fill_between or axhspan, drawn behind the role's markers).

'marker'
filled bool | None

Override the role's marker fill. False gives the open variant, e.g. for a sensitivity run of the same method.

None
**overrides Any

Matplotlib keywords that replace the role's defaults.

{}

Returns:

Type Description
dict[str, Any]

A new dict of matplotlib keyword arguments.

Raises:

Type Description
KeyError

role is not in :data:ROLES.

ValueError

Unknown kind, or kind="band" for a role without a band.

Example::

ax.plot(x, y, **nf.role_style("primary", "both"))
ax.fill_between(x, lo, hi, **nf.role_style("null", "band"))
Source code in src/nicefigs/palettes.py
def role_style(
    role: str, kind: str = "marker", *, filled: bool | None = None, **overrides: Any
) -> dict[str, Any]:
    """Keyword arguments that draw ``role`` with ``ax.plot``, ``ax.errorbar`` or ``ax.fill_between``.

    Line widths scale with the active theme's ``lw_line``; marker size is left
    to the caller.

    Args:
        role: A key of :data:`ROLES`.
        kind: ``"marker"`` (markers, no line), ``"line"`` (line, no markers),
            ``"both"``, or ``"band"`` (fill keywords for ``fill_between`` or
            ``axhspan``, drawn behind the role's markers).
        filled: Override the role's marker fill. ``False`` gives the open
            variant, e.g. for a sensitivity run of the same method.
        **overrides: Matplotlib keywords that replace the role's defaults.

    Returns:
        A new dict of matplotlib keyword arguments.

    Raises:
        KeyError: ``role`` is not in :data:`ROLES`.
        ValueError: Unknown ``kind``, or ``kind="band"`` for a role without a band.

    Example::

        ax.plot(x, y, **nf.role_style("primary", "both"))
        ax.fill_between(x, lo, hi, **nf.role_style("null", "band"))
    """
    if kind not in _ROLE_KINDS:
        raise ValueError(f"Unknown kind {kind!r}. Expected one of {_ROLE_KINDS}.")
    r = _role(role)
    if kind == "band":
        band = {"color": role_color(role, band=True), "linewidth": 0, "zorder": r.zorder - 0.5}
        return {**band, **overrides}
    kw: dict[str, Any] = {"color": r.color, "zorder": r.zorder}
    if kind == "marker":
        kw["linestyle"] = "none"
    else:
        kw["linestyle"] = r.linestyle
        kw["linewidth"] = r.linewidth_scale * get_theme().lw_line
    if kind == "line":
        kw["marker"] = "none"
    else:
        fill = r.filled if filled is None else filled
        kw.update(marker=r.marker, markeredgecolor=r.color, markerfacecolor=r.color if fill else "none")
    return {**kw, **overrides}

diverging_norm

diverging_norm(vmin: float, vmax: float, center: float = 0.0) -> mcolors.TwoSlopeNorm

A TwoSlopeNorm mapping center to the colormap's midpoint, for anomaly maps.

vmin and vmax are widened if needed so that vmin < center < vmax.

Source code in src/nicefigs/palettes.py
def diverging_norm(vmin: float, vmax: float, center: float = 0.0) -> mcolors.TwoSlopeNorm:
    """A ``TwoSlopeNorm`` mapping ``center`` to the colormap's midpoint, for anomaly maps.

    ``vmin`` and ``vmax`` are widened if needed so that ``vmin < center < vmax``.
    """
    vmin = min(vmin, center - 1e-9)
    vmax = max(vmax, center + 1e-9)
    return mcolors.TwoSlopeNorm(vmin=vmin, vcenter=center, vmax=vmax)

symmetric_limits

symmetric_limits(values: ArrayLike, *, percentile: float = 98.0) -> tuple[float, float]

Colour limits (-m, m) with m the percentile of |values|.

Non-finite values are ignored; no finite values give (-1, 1).

Source code in src/nicefigs/palettes.py
def symmetric_limits(values: ArrayLike, *, percentile: float = 98.0) -> tuple[float, float]:
    """Colour limits ``(-m, m)`` with ``m`` the ``percentile`` of ``|values|``.

    Non-finite values are ignored; no finite values give ``(-1, 1)``.
    """
    a = np.asarray(values, dtype=float)
    a = a[np.isfinite(a)]
    if a.size == 0:
        return -1.0, 1.0
    m = float(np.percentile(np.abs(a), percentile))
    m = max(m, 1e-12)
    return -m, m

robust_limits

robust_limits(values: ArrayLike, *, low: float = 2.0, high: float = 98.0) -> tuple[float, float]

Colour limits at the low and high percentiles of values.

Non-finite values are ignored; no finite values give (0, 1).

Source code in src/nicefigs/palettes.py
def robust_limits(values: ArrayLike, *, low: float = 2.0, high: float = 98.0) -> tuple[float, float]:
    """Colour limits at the ``low`` and ``high`` percentiles of ``values``.

    Non-finite values are ignored; no finite values give ``(0, 1)``.
    """
    a = np.asarray(values, dtype=float)
    a = a[np.isfinite(a)]
    if a.size == 0:
        return 0.0, 1.0
    lo, hi = np.percentile(a, [low, high])
    if hi <= lo:
        hi = lo + 1e-12
    return float(lo), float(hi)

elevation_cmap

elevation_cmap(*, sea_level: float = 0.0, vmin: float = -8000.0, vmax: float = 6000.0, name: str = 'nicefigs_elevation') -> tuple[mcolors.LinearSegmentedColormap, mcolors.Normalize]

Hypsometric elevation colormap and its norm.

Blues from deep to shallow below sea_level; green, tan, brown and white above.

Parameters:

Name Type Description Default
sea_level float

Elevation of the land/sea colour break (m).

0.0
vmin float

Lowest elevation (m).

-8000.0
vmax float

Highest elevation (m).

6000.0
name str

Name of the colormap.

'nicefigs_elevation'

Returns:

Type Description
tuple[LinearSegmentedColormap, Normalize]

(cmap, norm); use both together so the break lands at sea_level.

Source code in src/nicefigs/palettes.py
def elevation_cmap(
    *,
    sea_level: float = 0.0,
    vmin: float = -8000.0,
    vmax: float = 6000.0,
    name: str = "nicefigs_elevation",
) -> tuple[mcolors.LinearSegmentedColormap, mcolors.Normalize]:
    """Hypsometric elevation colormap and its norm.

    Blues from deep to shallow below ``sea_level``; green, tan, brown and white
    above.

    Args:
        sea_level: Elevation of the land/sea colour break (m).
        vmin: Lowest elevation (m).
        vmax: Highest elevation (m).
        name: Name of the colormap.

    Returns:
        ``(cmap, norm)``; use both together so the break lands at ``sea_level``.
    """
    ocean = [
        (0.00, "#08306b"),
        (0.60, "#4292c6"),
        (1.00, "#c6dbef"),
    ]
    land = [
        (0.00, "#1a7a3c"),
        (0.18, "#8cc665"),
        (0.40, "#e8d98a"),
        (0.62, "#c9a24b"),
        (0.80, "#8f6b3f"),
        (0.92, "#c9c0b8"),
        (1.00, "#ffffff"),
    ]
    span = vmax - vmin
    break_frac = (sea_level - vmin) / span
    stops = []
    for pos, col in ocean:
        stops.append((pos * break_frac, col))
    for pos, col in land:
        stops.append((break_frac + pos * (1 - break_frac), col))
    # Ocean and land stops meet at break_frac; keep one stop per position.
    seen = {}
    for pos, col in stops:
        seen[round(pos, 6)] = col
    stops = sorted(seen.items())
    cm = mcolors.LinearSegmentedColormap.from_list(name, stops)
    norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
    return cm, norm

scorecard_diverging_cmap

scorecard_diverging_cmap(boundaries: list[float] | None = None) -> tuple[mcolors.ListedColormap, mcolors.BoundaryNorm]

Discrete, quasi-logarithmic diverging (cmap, norm) for skill scorecards.

This is not "the WeatherBench colormap": WeatherBench2's released weatherbench2/visualization.py colours scorecards with continuous RdBu_r at vmin=-100, vmax=100 — no discrete bands, no BoundaryNorm, no extend triangles. The bands and colours here were instead sampled pixel-wise from WeatherBench2's published website figure, which underwent a separate design pass never released as code. The sampled colours are confirmed not to be RdBu_r under any sampling: the worst per-channel error is 61/255 at equal band centres and 93/255 at 11 equal steps. So this is a discrete quasi-logarithmic diverging scale, WeatherBench2-inspired — not a reproduction of WeatherBench2's own code.

The quasi-log spacing (1, 2, 5, 10, 20, 50 per side) resolves the +/-1-5% range where real forecast-skill differences cluster, while still reaching +/-50%; a continuous linear +/-100% scale (as WB2's code uses) flattens that whole cluster to near-uniform pale colour. The centre band is light grey (#F2F2F2), not white, and the under/over extend colours (#26549A / #9C2520) are deliberately distinct from the outermost band colours — the extend triangles read as their own colour, not a repeat of the band next to them.

Parameters:

Name Type Description Default
boundaries list[float] | None

12 band edges (11 bands). Defaults to :data:SCORECARD_DIVERGING_BOUNDARIES.

None

Returns:

Type Description
ListedColormap

(cmap, norm): a ListedColormap with set_under/set_over

BoundaryNorm

applied, and a matching BoundaryNorm — ready for

tuple[ListedColormap, BoundaryNorm]

imshow/pcolormesh/colorbar with extend="both".

Source code in src/nicefigs/palettes.py
def scorecard_diverging_cmap(
    boundaries: list[float] | None = None,
) -> tuple[mcolors.ListedColormap, mcolors.BoundaryNorm]:
    """Discrete, quasi-logarithmic diverging ``(cmap, norm)`` for skill scorecards.

    This is **not** "the WeatherBench colormap": WeatherBench2's released
    ``weatherbench2/visualization.py`` colours scorecards with continuous
    ``RdBu_r`` at ``vmin=-100, vmax=100`` — no discrete bands, no
    ``BoundaryNorm``, no extend triangles. The bands and colours here were
    instead sampled pixel-wise from WeatherBench2's *published website figure*,
    which underwent a separate design pass never released as code. The sampled
    colours are confirmed not to be ``RdBu_r`` under any sampling: the worst
    per-channel error is 61/255 at equal band centres and 93/255 at 11 equal
    steps. So this is a discrete quasi-logarithmic diverging scale,
    WeatherBench2-*inspired* — not a reproduction of WeatherBench2's own code.

    The quasi-log spacing (``1, 2, 5, 10, 20, 50`` per side) resolves the
    +/-1-5% range where real forecast-skill differences cluster, while still
    reaching +/-50%; a continuous linear +/-100% scale (as WB2's code uses)
    flattens that whole cluster to near-uniform pale colour. The centre band is
    light grey (``#F2F2F2``), not white, and the under/over extend colours
    (``#26549A`` / ``#9C2520``) are deliberately distinct from the outermost
    band colours — the extend triangles read as their own colour, not a repeat
    of the band next to them.

    Args:
        boundaries: 12 band edges (11 bands). Defaults to
            :data:`SCORECARD_DIVERGING_BOUNDARIES`.

    Returns:
        ``(cmap, norm)``: a ``ListedColormap`` with ``set_under``/``set_over``
        applied, and a matching ``BoundaryNorm`` — ready for
        ``imshow``/``pcolormesh``/``colorbar`` with ``extend="both"``.
    """
    boundaries = list(boundaries) if boundaries is not None else list(SCORECARD_DIVERGING_BOUNDARIES)
    if len(boundaries) != len(_SCORECARD_DIVERGING_COLORS) + 1:
        raise ValueError(
            f"boundaries must have {len(_SCORECARD_DIVERGING_COLORS) + 1} edges "
            f"for {len(_SCORECARD_DIVERGING_COLORS)} bands, got {len(boundaries)}"
        )
    base = mcolors.ListedColormap(_SCORECARD_DIVERGING_COLORS, name="scorecard_diverging")
    cm = base.with_extremes(under=_SCORECARD_DIVERGING_UNDER, over=_SCORECARD_DIVERGING_OVER)
    cm.name = "scorecard_diverging"
    norm = mcolors.BoundaryNorm(boundaries, cm.N)
    return cm, norm

categorical

categorical(name: str) -> Categorical

Look up a categorical palette in :data:CATEGORICAL by name, e.g. "esa_worldcover".

Raises:

Type Description
KeyError

Unknown name.

Source code in src/nicefigs/palettes.py
def categorical(name: str) -> Categorical:
    """Look up a categorical palette in :data:`CATEGORICAL` by name, e.g. ``"esa_worldcover"``.

    Raises:
        KeyError: Unknown name.
    """
    try:
        return CATEGORICAL[name]
    except KeyError as exc:
        raise KeyError(f"Unknown categorical palette {name!r}. Known: {sorted(CATEGORICAL)}") from exc

qualitative

qualitative(n: int, palette: str = 'nice') -> list[str]

n qualitative colours from a named palette.

Parameters:

Name Type Description Default
n int

Number of colours; the palette repeats if n exceeds its length.

required
palette str

"bright", "muted" or "high_contrast" (colour-blind safe), or a key of :data:nicefigs.ARTIST_PALETTES such as "nice" or "hockney".

'nice'

Returns:

Type Description
list[str]

Hex colour strings.

Raises:

Type Description
KeyError

palette is not a known palette name.

Source code in src/nicefigs/palettes.py
def qualitative(n: int, palette: str = "nice") -> list[str]:
    """``n`` qualitative colours from a named palette.

    Args:
        n: Number of colours; the palette repeats if ``n`` exceeds its length.
        palette: ``"bright"``, ``"muted"`` or ``"high_contrast"`` (colour-blind
            safe), or a key of :data:`nicefigs.ARTIST_PALETTES` such as
            ``"nice"`` or ``"hockney"``.

    Returns:
        Hex colour strings.

    Raises:
        KeyError: ``palette`` is not a known palette name.
    """
    pals = {"bright": PALETTE_BRIGHT, "muted": PALETTE_MUTED, "high_contrast": PALETTE_HIGH_CONTRAST}
    pals.update(ARTIST_PALETTES)
    try:
        base = pals[palette]
    except KeyError as exc:
        raise KeyError(f"Unknown palette {palette!r}. Available: {sorted(pals)}") from exc
    if n <= len(base):
        return list(base[:n])
    return [base[i % len(base)] for i in range(n)]

nicefigs.colorbars

colorbars

Slim colorbars flush to one or several axes, and histogram colorbars that show the data distribution.

colorbar

colorbar(mappable: ScalarMappable, ax: Axes | Sequence[Axes], *, label: str = '', location: str = 'right', pad: float = 0.012, thickness: float = 0.012, shrink: float = 1.0, extend: str = 'neither', ticks: ArrayLike | None = None) -> Colorbar

Draw a slim colorbar flush to one axes, or spanning several.

Parameters:

Name Type Description Default
mappable ScalarMappable

E.g. the return of imshow, scatter or pcolormesh, or one from :func:make_mappable.

required
ax Axes | Sequence[Axes]

One axes, or a sequence of axes the bar should span.

required
label str

Colorbar label.

''
location str

"right", "left", "bottom" or "top".

'right'
pad float

Gap between plot and bar, in figure fraction.

0.012
thickness float

Bar thickness, in figure fraction.

0.012
shrink float

Fraction of the spanned length the bar occupies (centred).

1.0
extend str

"neither", "both", "min" or "max".

'neither'
ticks ArrayLike | None

Explicit tick locations.

None

Returns:

Type Description
Colorbar

The colorbar.

Source code in src/nicefigs/colorbars.py
def colorbar(
    mappable: ScalarMappable,
    ax: Axes | Sequence[Axes],
    *,
    label: str = "",
    location: str = "right",
    pad: float = 0.012,
    thickness: float = 0.012,
    shrink: float = 1.0,
    extend: str = "neither",
    ticks: ArrayLike | None = None,
) -> Colorbar:
    """Draw a slim colorbar flush to one axes, or spanning several.

    Args:
        mappable: E.g. the return of ``imshow``, ``scatter`` or ``pcolormesh``,
            or one from :func:`make_mappable`.
        ax: One axes, or a sequence of axes the bar should span.
        label: Colorbar label.
        location: ``"right"``, ``"left"``, ``"bottom"`` or ``"top"``.
        pad: Gap between plot and bar, in figure fraction.
        thickness: Bar thickness, in figure fraction.
        shrink: Fraction of the spanned length the bar occupies (centred).
        extend: ``"neither"``, ``"both"``, ``"min"`` or ``"max"``.
        ticks: Explicit tick locations.

    Returns:
        The colorbar.
    """
    axes = list(ax) if isinstance(ax, (list, tuple, np.ndarray)) else [ax]
    fig = axes[0].figure
    th = get_theme()
    x0, y0, x1, y1 = _bbox_union(axes)

    if location in ("right", "left"):
        length = (y1 - y0) * shrink
        cy = 0.5 * (y0 + y1) - length / 2
        if location == "right":
            cax = fig.add_axes([x1 + pad, cy, thickness, length])
        else:
            cax = fig.add_axes([x0 - pad - thickness, cy, thickness, length])
        orientation = "vertical"
    else:
        length = (x1 - x0) * shrink
        cx = 0.5 * (x0 + x1) - length / 2
        if location == "bottom":
            cax = fig.add_axes([cx, y0 - pad - thickness, length, thickness])
        else:
            cax = fig.add_axes([cx, y1 + pad, length, thickness])
        orientation = "horizontal"

    cb = fig.colorbar(mappable, cax=cax, orientation=orientation, extend=extend, ticks=ticks)
    cb.outline.set_linewidth(0.4)
    cb.ax.tick_params(labelsize=th.fs_tick, length=1.6, width=0.35, pad=1.2)
    if location == "left":
        cax.yaxis.set_ticks_position("left")
        cax.yaxis.set_label_position("left")
    if location == "top":
        cax.xaxis.set_ticks_position("top")
        cax.xaxis.set_label_position("top")
    if label:
        cb.set_label(label, fontsize=th.fs_annot, labelpad=2.0)
    return cb

make_mappable

make_mappable(values: ArrayLike | None = None, *, cmap: str | Colormap = 'inferno', vmin: float | None = None, vmax: float | None = None, norm: Normalize | None = None) -> ScalarMappable

Build a ScalarMappable for a colorbar without a plotted artist.

Parameters:

Name Type Description Default
values ArrayLike | None

Data whose finite min and max fill in missing vmin/vmax.

None
cmap str | Colormap

Colormap or name, resolved by :func:nicefigs.cmap.

'inferno'
vmin float | None

Lower colour limit.

None
vmax float | None

Upper colour limit.

None
norm Normalize | None

Normalisation; overrides vmin, vmax and values.

None
Source code in src/nicefigs/colorbars.py
def make_mappable(
    values: ArrayLike | None = None,
    *,
    cmap: str | Colormap = "inferno",
    vmin: float | None = None,
    vmax: float | None = None,
    norm: Normalize | None = None,
) -> ScalarMappable:
    """Build a ``ScalarMappable`` for a colorbar without a plotted artist.

    Args:
        values: Data whose finite min and max fill in missing ``vmin``/``vmax``.
        cmap: Colormap or name, resolved by :func:`nicefigs.cmap`.
        vmin: Lower colour limit.
        vmax: Upper colour limit.
        norm: Normalisation; overrides ``vmin``, ``vmax`` and ``values``.
    """
    if norm is None:
        if (vmin is None or vmax is None) and values is not None:
            a = np.asarray(values, dtype=float)
            a = a[np.isfinite(a)]
            vmin = float(np.min(a)) if vmin is None else vmin
            vmax = float(np.max(a)) if vmax is None else vmax
        norm = Normalize(vmin=vmin, vmax=vmax)
    sm = ScalarMappable(norm=norm, cmap=_resolve_cmap(cmap))
    sm.set_array([])
    return sm

inset_histogram_colorbar

inset_histogram_colorbar(ax: Axes, values: ArrayLike, *, cmap: str | Colormap = 'inferno', vmin: float | None = None, vmax: float | None = None, norm: Normalize | None = None, bounds: tuple[float, float, float, float] = (0.045, 0.08, 0.24, 0.17), n_bins: int = 24, label: str | None = None, frame_alpha: float = 0.0, log: bool = False) -> Axes | None

Draw an inset histogram of values with bars coloured by cmap.

The inset acts as a colorbar that also shows the data distribution.

Parameters:

Name Type Description Default
ax Axes

Host axes.

required
values ArrayLike

Mapped data; non-finite values are ignored.

required
cmap str | Colormap

Colormap or name, resolved by :func:nicefigs.cmap.

'inferno'
vmin float | None

Histogram and colour lower limit; defaults to the 2nd percentile.

None
vmax float | None

Histogram and colour upper limit; defaults to the 98th percentile.

None
norm Normalize | None

Colour normalisation used instead of the linear vmin/vmax one.

None
bounds tuple[float, float, float, float]

Inset (x, y, width, height) in axes fraction; the default sits in the lower left, the empty South Pacific of a world map.

(0.045, 0.08, 0.24, 0.17)
n_bins int

Number of bins.

24
label str | None

X-axis label of the inset.

None
frame_alpha float

Opacity of the inset's white background.

0.0
log bool

Show log1p counts.

False

Returns:

Type Description
Axes | None

The inset axes, or None when values has no finite entries.

Source code in src/nicefigs/colorbars.py
def inset_histogram_colorbar(
    ax: Axes,
    values: ArrayLike,
    *,
    cmap: str | Colormap = "inferno",
    vmin: float | None = None,
    vmax: float | None = None,
    norm: Normalize | None = None,
    bounds: tuple[float, float, float, float] = (0.045, 0.08, 0.24, 0.17),
    n_bins: int = 24,
    label: str | None = None,
    frame_alpha: float = 0.0,
    log: bool = False,
) -> Axes | None:
    """Draw an inset histogram of ``values`` with bars coloured by ``cmap``.

    The inset acts as a colorbar that also shows the data distribution.

    Args:
        ax: Host axes.
        values: Mapped data; non-finite values are ignored.
        cmap: Colormap or name, resolved by :func:`nicefigs.cmap`.
        vmin: Histogram and colour lower limit; defaults to the 2nd percentile.
        vmax: Histogram and colour upper limit; defaults to the 98th percentile.
        norm: Colour normalisation used instead of the linear ``vmin``/``vmax`` one.
        bounds: Inset ``(x, y, width, height)`` in axes fraction; the default
            sits in the lower left, the empty South Pacific of a world map.
        n_bins: Number of bins.
        label: X-axis label of the inset.
        frame_alpha: Opacity of the inset's white background.
        log: Show ``log1p`` counts.

    Returns:
        The inset axes, or ``None`` when ``values`` has no finite entries.
    """
    th = get_theme()
    vals = np.asarray(values, dtype=float)
    vals = vals[np.isfinite(vals)]
    if vals.size == 0:
        return None
    if vmin is None or vmax is None:
        lo, hi = np.percentile(vals, [2.0, 98.0])
        vmin = float(lo) if vmin is None else vmin
        vmax = float(hi) if vmax is None else vmax
    if vmax - vmin < 1e-12:
        vmax = vmin + 1e-12

    inset = ax.inset_axes(bounds)
    counts, edges = np.histogram(vals, bins=n_bins, range=(vmin, vmax))
    centers = 0.5 * (edges[:-1] + edges[1:])
    cm = _resolve_cmap(cmap)
    if norm is not None:
        colors = [cm(float(np.clip(norm(c), 0, 1))) for c in centers]
    else:
        colors = [cm(float((c - vmin) / (vmax - vmin))) for c in centers]
    if log:
        counts = np.log1p(counts)
    inset.bar(centers, counts, width=(edges[1] - edges[0]) * 0.95, color=colors, edgecolor="none")
    inset.set_xlim(vmin, vmax)
    inset.set_yticks([])
    inset.xaxis.set_major_locator(mticker.MaxNLocator(3))
    inset.tick_params(axis="x", labelsize=th.fs_annot - 0.5, length=1.6, pad=0.8)
    for side in ("top", "right", "left"):
        inset.spines[side].set_visible(False)
    inset.spines["bottom"].set_linewidth(0.4)
    inset.set_facecolor("#ffffff")
    inset.patch.set_alpha(frame_alpha)
    if label is not None:
        # Left-aligned so a long label stays inside a curved map frame.
        inset.set_xlabel(label, fontsize=th.fs_annot - 0.5, labelpad=0.8, ha="left", x=0.0)
    return inset

nicefigs.units

units

Axis labels of the form Quantity [unit], unit prettifying, and number formatting.

METRIC_NAMES module-attribute

METRIC_NAMES: dict[str, str] = {'rmse': 'RMSE', 'mse': 'MSE', 'mae': 'MAE', 'bias': 'Bias', 'r2': 'R²', 'r': 'r', 'corr': 'Correlation', 'cosine': 'Cosine similarity', 'fve': 'FVE', 'nse': 'NSE', 'kge': 'KGE', 'auc': 'AUC', 'acc': 'Accuracy', 'f1': 'F1', 'l0': 'L0 norm', 'sparsity': 'Sparsity', 'morans_i': "Moran's I", 'smoothness': 'Smoothness', 'skill': 'Skill'}

METRIC_DIRECTION module-attribute

METRIC_DIRECTION: dict[str, str] = {'rmse': 'lower_better', 'mse': 'lower_better', 'mae': 'lower_better', 'l0': 'lower_better', 'r2': 'higher_better', 'cosine': 'higher_better', 'fve': 'higher_better', 'nse': 'higher_better', 'kge': 'higher_better', 'auc': 'higher_better', 'acc': 'higher_better', 'f1': 'higher_better', 'skill': 'higher_better'}

DIRECTIONS module-attribute

DIRECTIONS: tuple[str, ...] = ('higher_better', 'lower_better', 'target')

prettify_unit

prettify_unit(unit: str | None, *, superscript: bool = True) -> str

Prettify a plain-text unit string.

"m2" becomes "m²", "kg m-2 s-1" becomes "kg m⁻² s⁻¹" and "degC" becomes "°C".

Parameters:

Name Type Description Default
unit str | None

Plain-text unit; None gives "".

required
superscript bool

Render exponents as Unicode superscripts; otherwise as ^n.

True

Returns:

Type Description
str

The prettified unit.

Source code in src/nicefigs/units.py
def prettify_unit(unit: str | None, *, superscript: bool = True) -> str:
    """Prettify a plain-text unit string.

    ``"m2"`` becomes ``"m²"``, ``"kg m-2 s-1"`` becomes ``"kg m⁻² s⁻¹"`` and
    ``"degC"`` becomes ``"°C"``.

    Args:
        unit: Plain-text unit; ``None`` gives ``""``.
        superscript: Render exponents as Unicode superscripts; otherwise as ``^n``.

    Returns:
        The prettified unit.
    """
    if unit is None:
        return ""
    u = unit.strip()
    if u in _UNIT_FIX:
        return _UNIT_FIX[u]
    for k, v in _UNIT_FIX.items():
        u = re.sub(rf"\b{k}\b", v, u)

    def _exp(m: re.Match) -> str:
        base, exp = m.group(1), m.group(2)
        if superscript:
            return base + exp.translate(_SUP)
        return f"{base}^{exp}"

    u = re.sub(r"([A-Za-zµ°%])(-?\d+)", _exp, u)
    u = re.sub(r"([A-Za-zµ°%])\^(-?\d+)", _exp, u)
    return u

check_direction

check_direction(direction: str) -> str

Return direction if it is one of :data:DIRECTIONS.

Raises:

Type Description
ValueError

direction is not in :data:DIRECTIONS.

Source code in src/nicefigs/units.py
def check_direction(direction: str) -> str:
    """Return ``direction`` if it is one of :data:`DIRECTIONS`.

    Raises:
        ValueError: ``direction`` is not in :data:`DIRECTIONS`.
    """
    if direction not in DIRECTIONS:
        raise ValueError(f"unknown direction {direction!r}; known: {list(DIRECTIONS)}")
    return direction

axis_label

axis_label(quantity: str, units: str | None = None, *, superscript: bool = True, brackets: str = '[]', direction: str | None = None) -> str

Compose an axis label "Quantity [unit]".

Parameters:

Name Type Description Default
quantity str

Quantity name.

required
units str | None

Plain-text unit, prettified with :func:prettify_unit; empty or None gives just the quantity.

None
superscript bool

Render unit exponents as superscripts.

True
brackets str

Opening and closing bracket characters.

'[]'
direction str | None

One of :data:DIRECTIONS; "higher_better" and "lower_better" append an arrow showing which way is better.

None

Returns:

Type Description
str

The label.

Raises:

Type Description
ValueError

direction is not in :data:DIRECTIONS.

Source code in src/nicefigs/units.py
def axis_label(
    quantity: str,
    units: str | None = None,
    *,
    superscript: bool = True,
    brackets: str = "[]",
    direction: str | None = None,
) -> str:
    """Compose an axis label ``"Quantity [unit]"``.

    Args:
        quantity: Quantity name.
        units: Plain-text unit, prettified with :func:`prettify_unit`; empty or
            ``None`` gives just the quantity.
        superscript: Render unit exponents as superscripts.
        brackets: Opening and closing bracket characters.
        direction: One of :data:`DIRECTIONS`; ``"higher_better"`` and
            ``"lower_better"`` append an arrow showing which way is better.

    Returns:
        The label.

    Raises:
        ValueError: ``direction`` is not in :data:`DIRECTIONS`.
    """
    label = quantity
    if units:
        lo, hi = brackets[0], brackets[1] if len(brackets) > 1 else brackets[0]
        label = f"{quantity} {lo}{prettify_unit(units, superscript=superscript)}{hi}"
    arrow = _arrow(direction)
    return f"{label} {arrow}".rstrip() if arrow else label

metric_label

metric_label(key: str, units: str | None = None, *, direction: str | bool | None = None, registry: dict[str, str] | None = None) -> str

Axis label for a metric key, e.g. "rmse" gives "RMSE".

Parameters:

Name Type Description Default
key str

Metric key, looked up case-insensitively; unknown keys are title-cased.

required
units str | None

Plain-text unit.

None
direction str | bool | None

True looks the arrow up in :data:METRIC_DIRECTION; a string sets it as in :func:axis_label; None or False omits it.

None
registry dict[str, str] | None

Key-to-name mapping used instead of :data:METRIC_NAMES.

None

Returns:

Type Description
str

The label.

Source code in src/nicefigs/units.py
def metric_label(
    key: str,
    units: str | None = None,
    *,
    direction: str | bool | None = None,
    registry: dict[str, str] | None = None,
) -> str:
    """Axis label for a metric key, e.g. ``"rmse"`` gives ``"RMSE"``.

    Args:
        key: Metric key, looked up case-insensitively; unknown keys are title-cased.
        units: Plain-text unit.
        direction: ``True`` looks the arrow up in :data:`METRIC_DIRECTION`; a
            string sets it as in :func:`axis_label`; ``None`` or ``False`` omits it.
        registry: Key-to-name mapping used instead of :data:`METRIC_NAMES`.

    Returns:
        The label.
    """
    reg = registry or METRIC_NAMES
    name = reg.get(key.lower(), key.replace("_", " ").title())
    if direction is True:
        direction = METRIC_DIRECTION.get(key.lower())
    elif direction is False:
        direction = None
    return axis_label(name, units, direction=direction)

set_labels

set_labels(ax: Axes, x: str | None = None, y: str | None = None, *, xunits: str | None = None, yunits: str | None = None, title: str | None = None) -> None

Set x and y labels (as :func:axis_label) and the title; None leaves one unchanged.

Source code in src/nicefigs/units.py
def set_labels(
    ax: Axes,
    x: str | None = None,
    y: str | None = None,
    *,
    xunits: str | None = None,
    yunits: str | None = None,
    title: str | None = None,
) -> None:
    """Set x and y labels (as :func:`axis_label`) and the title; ``None`` leaves one unchanged."""
    if x is not None:
        ax.set_xlabel(axis_label(x, xunits))
    if y is not None:
        ax.set_ylabel(axis_label(y, yunits))
    if title is not None:
        ax.set_title(title)

fmt_val

fmt_val(v: float, *, sig: int = 3) -> str

Format a number with sig significant digits.

Magnitudes outside [1e-3, 1e4) use m×10ⁿ notation; zero gives "0" and non-finite values give "–".

Source code in src/nicefigs/units.py
def fmt_val(v: float, *, sig: int = 3) -> str:
    """Format a number with ``sig`` significant digits.

    Magnitudes outside ``[1e-3, 1e4)`` use ``m×10ⁿ`` notation; zero gives ``"0"``
    and non-finite values give ``"–"``.
    """
    if v == 0 or not np.isfinite(v):
        return "0" if v == 0 else "–"
    a = abs(v)
    if 1e-3 <= a < 1e4:
        s = f"{v:.{sig}g}"
        return s
    exp = int(np.floor(np.log10(a)))
    mant = v / 10**exp
    return f"{mant:.{max(sig - 1, 0)}f}×10{str(exp).translate(_SUP)}"

fmt_pval

fmt_pval(p: float) -> str

Format a p-value as "p < 0.001", "p = 0.004" or "p = 0.12".

Source code in src/nicefigs/units.py
def fmt_pval(p: float) -> str:
    """Format a p-value as ``"p < 0.001"``, ``"p = 0.004"`` or ``"p = 0.12"``."""
    if p < 1e-3:
        return "p < 0.001"
    if p < 1e-2:
        return f"p = {p:.3f}"
    return f"p = {p:.2f}"

nicefigs.maps

maps

Global and regional maps of points, gridded fields, unstructured meshes and class labels.

The default map is a Robinson projection with Antarctica cropped, a light land backdrop, coastlines and an inset histogram that doubles as the colorbar. :func:map picks a drawing method from the data layout; the per-layout helpers (:func:scatter_map, :func:binned_map, :func:raster_map, :func:tri_map, :func:categorical_map) draw onto an existing axes. Without cartopy, maps fall back to plain equirectangular axes.

ANTARCTICA_CUTOFF_EXTENT module-attribute

ANTARCTICA_CUTOFF_EXTENT = (-180.0, 180.0, -60.0, 84.0)

AUTO_BIN_MIN_POINTS module-attribute

AUTO_BIN_MIN_POINTS = 5000

make_map_axes

make_map_axes(fig: Figure, subplot_spec: int | tuple[int, int, int] | SubplotSpec = 111, *, projection: str = 'robinson', central_longitude: float = 0.0, **proj_kwargs: Any) -> tuple[Axes, ModuleType | None]

Add a map axes to fig, projected when cartopy is installed.

Parameters:

Name Type Description Default
fig Figure

Figure to add the axes to.

required
subplot_spec int | tuple[int, int, int] | SubplotSpec

An int such as 111, a (nrows, ncols, index) tuple, or a :class:~matplotlib.gridspec.SubplotSpec.

111
projection str

"robinson", "platecarree", "mollweide", "equalearth", "orthographic" or "nearside".

'robinson'
central_longitude float

Central meridian in degrees east, where the projection supports it.

0.0
**proj_kwargs Any

Forwarded to the cartopy projection class.

{}

Returns:

Type Description
Axes

(ax, ccrs): the axes and the cartopy.crs module, or a plain axes

ModuleType | None

and None without cartopy.

Raises:

Type Description
ValueError

projection is not one of the names above.

Source code in src/nicefigs/maps.py
def make_map_axes(
    fig: Figure,
    subplot_spec: int | tuple[int, int, int] | SubplotSpec = 111,
    *,
    projection: str = "robinson",
    central_longitude: float = 0.0,
    **proj_kwargs: Any,
) -> tuple[Axes, ModuleType | None]:
    """Add a map axes to ``fig``, projected when cartopy is installed.

    Args:
        fig: Figure to add the axes to.
        subplot_spec: An int such as ``111``, a ``(nrows, ncols, index)`` tuple,
            or a :class:`~matplotlib.gridspec.SubplotSpec`.
        projection: ``"robinson"``, ``"platecarree"``, ``"mollweide"``,
            ``"equalearth"``, ``"orthographic"`` or ``"nearside"``.
        central_longitude: Central meridian in degrees east, where the
            projection supports it.
        **proj_kwargs: Forwarded to the cartopy projection class.

    Returns:
        ``(ax, ccrs)``: the axes and the ``cartopy.crs`` module, or a plain axes
        and ``None`` without cartopy.

    Raises:
        ValueError: ``projection`` is not one of the names above.
    """
    key = projection.lower()
    if key not in _PROJECTIONS:
        raise ValueError(f"Unknown projection {projection!r}. Known: {sorted(_PROJECTIONS)}")
    ccrs = _try_cartopy()
    proj = None
    if ccrs is not None:
        cls = getattr(ccrs, _PROJECTIONS[key])
        try:
            proj = cls(central_longitude=central_longitude, **proj_kwargs)
        except TypeError:
            proj = cls(**proj_kwargs)
    if isinstance(subplot_spec, tuple):
        ax = fig.add_subplot(*subplot_spec, projection=proj)
    else:
        ax = fig.add_subplot(subplot_spec, projection=proj)
    return ax, ccrs

draw_background

draw_background(ax: Axes, ccrs: ModuleType | None, *, land: bool = True, ocean: bool = False, coastline: bool = True, borders: bool = False, land_color: str = COLORS['land'], ocean_color: str = COLORS['ocean'], coast_color: str = COLORS['coast'], coast_lw: float = 0.3, resolution: str = '110m') -> None

Draw a land, ocean, coastline and border backdrop on a projected map axes.

The features are rasterized so vector output (SVG, PDF) stays small while text and frame remain vectors. Does nothing when ccrs is None.

Parameters:

Name Type Description Default
ax Axes

Cartopy GeoAxes, e.g. from :func:make_map_axes.

required
ccrs ModuleType | None

The cartopy.crs module, or None for a plain axes.

required
land bool

Fill land with land_color.

True
ocean bool

Fill ocean with ocean_color.

False
coastline bool

Draw coastlines.

True
borders bool

Draw country borders.

False
land_color str

Land fill colour.

COLORS['land']
ocean_color str

Ocean fill colour.

COLORS['ocean']
coast_color str

Coastline and border colour.

COLORS['coast']
coast_lw float

Coastline width in points; borders are 0.7 times this.

0.3
resolution str

Natural Earth coastline resolution: "110m", "50m" or "10m".

'110m'
Source code in src/nicefigs/maps.py
def draw_background(
    ax: Axes,
    ccrs: ModuleType | None,
    *,
    land: bool = True,
    ocean: bool = False,
    coastline: bool = True,
    borders: bool = False,
    land_color: str = COLORS["land"],
    ocean_color: str = COLORS["ocean"],
    coast_color: str = COLORS["coast"],
    coast_lw: float = 0.3,
    resolution: str = "110m",
) -> None:
    """Draw a land, ocean, coastline and border backdrop on a projected map axes.

    The features are rasterized so vector output (SVG, PDF) stays small while text
    and frame remain vectors. Does nothing when ``ccrs`` is ``None``.

    Args:
        ax: Cartopy ``GeoAxes``, e.g. from :func:`make_map_axes`.
        ccrs: The ``cartopy.crs`` module, or ``None`` for a plain axes.
        land: Fill land with ``land_color``.
        ocean: Fill ocean with ``ocean_color``.
        coastline: Draw coastlines.
        borders: Draw country borders.
        land_color: Land fill colour.
        ocean_color: Ocean fill colour.
        coast_color: Coastline and border colour.
        coast_lw: Coastline width in points; borders are 0.7 times this.
        resolution: Natural Earth coastline resolution: ``"110m"``, ``"50m"`` or ``"10m"``.
    """
    if ccrs is None:
        return
    import cartopy.feature as cfeature

    def _rasterize(artist: Any) -> None:
        if artist is not None and hasattr(artist, "set_rasterized"):
            artist.set_rasterized(True)

    if ocean:
        _rasterize(ax.add_feature(cfeature.OCEAN, facecolor=ocean_color, edgecolor="none", zorder=0))
    if land:
        _rasterize(ax.add_feature(cfeature.LAND, facecolor=land_color, edgecolor="none", zorder=0))
    if coastline:
        try:
            _rasterize(
                ax.coastlines(resolution=resolution, linewidth=coast_lw, color=coast_color, zorder=2.5)
            )
        except OSError:  # Natural Earth data unavailable (e.g. offline): skip coastlines.
            pass
    if borders:
        _rasterize(
            ax.add_feature(cfeature.BORDERS, linewidth=coast_lw * 0.7, edgecolor=coast_color, zorder=2.5)
        )
    try:
        ax.spines["geo"].set_edgecolor("#333333")
        ax.spines["geo"].set_linewidth(0.7)
    except (KeyError, AttributeError):
        pass

hatch_region

hatch_region(ax: Axes, lon: ArrayLike, lat: ArrayLike, mask: ArrayLike, *, ccrs: ModuleType | None = None, hatch: str = '////', edgecolor: str = '#333333', linewidth: float = 0.5, bin_deg: float = 2.0, threshold: float = 0.5) -> QuadContourSet

Hatch the region where a per-point mask is set, leaving the colours beneath visible.

The mask is bin-averaged onto a bin_deg grid and cells at or above threshold are hatched without fill.

Parameters:

Name Type Description Default
ax Axes

Map axes.

required
lon ArrayLike

Point longitudes in degrees east.

required
lat ArrayLike

Point latitudes in degrees north.

required
mask ArrayLike

Per-point boolean or fraction, same shape as lon.

required
ccrs ModuleType | None

The cartopy.crs module when ax is projected, else None.

None
hatch str

Matplotlib hatch pattern.

'////'
edgecolor str

Hatch colour.

'#333333'
linewidth float

Hatch line width in points.

0.5
bin_deg float

Grid cell size in degrees.

2.0
threshold float

Minimum cell-mean mask value that is hatched.

0.5

Returns:

Type Description
QuadContourSet

The contour set drawing the hatch.

Source code in src/nicefigs/maps.py
def hatch_region(
    ax: Axes,
    lon: ArrayLike,
    lat: ArrayLike,
    mask: ArrayLike,
    *,
    ccrs: ModuleType | None = None,
    hatch: str = "////",
    edgecolor: str = "#333333",
    linewidth: float = 0.5,
    bin_deg: float = 2.0,
    threshold: float = 0.5,
) -> QuadContourSet:
    """Hatch the region where a per-point mask is set, leaving the colours beneath visible.

    The mask is bin-averaged onto a ``bin_deg`` grid and cells at or above
    ``threshold`` are hatched without fill.

    Args:
        ax: Map axes.
        lon: Point longitudes in degrees east.
        lat: Point latitudes in degrees north.
        mask: Per-point boolean or fraction, same shape as ``lon``.
        ccrs: The ``cartopy.crs`` module when ``ax`` is projected, else ``None``.
        hatch: Matplotlib hatch pattern.
        edgecolor: Hatch colour.
        linewidth: Hatch line width in points.
        bin_deg: Grid cell size in degrees.
        threshold: Minimum cell-mean mask value that is hatched.

    Returns:
        The contour set drawing the hatch.
    """
    lon = np.asarray(lon, dtype=float)
    lat = np.asarray(lat, dtype=float)
    m = np.asarray(mask, dtype=float)
    lon_e, lat_e, grid = _bin_values(lon, lat, m, bin_deg, 0, False, "110m")
    lon_c = 0.5 * (lon_e[:-1] + lon_e[1:])
    lat_c = 0.5 * (lat_e[:-1] + lat_e[1:])
    filled = np.ma.filled(grid, 0.0)
    kw = {}
    if ccrs is not None:
        kw["transform"] = ccrs.PlateCarree()
    cs = ax.contourf(lon_c, lat_c, filled, levels=[threshold, 1e9], colors="none", hatches=[hatch], **kw)
    # matplotlib < 3.8 exposes per-level ``.collections``; later the ContourSet is the collection.
    targets = getattr(cs, "collections", None) or [cs]
    for coll in targets:
        coll.set_edgecolor(edgecolor)
        coll.set_linewidth(linewidth)
    return cs

scatter_map

scatter_map(lon: ArrayLike, lat: ArrayLike, values: ArrayLike | None = None, *, ax: Axes | None = None, ccrs: ModuleType | None = None, cmap: str | Colormap = 'inferno', vmin: float | None = None, vmax: float | None = None, norm: Normalize | None = None, size: float = 6.0, alpha: float = 0.9, sort_by_value: bool = True, **scatter_kw: Any) -> tuple[Axes, PathCollection]

Scatter points on a map, coloured by values if given.

Points with non-finite values are dropped.

Parameters:

Name Type Description Default
ax Axes | None

Map axes; a Robinson map figure is created if None.

None
lon ArrayLike

Longitudes in degrees east, shape (N,).

required
lat ArrayLike

Latitudes in degrees north, shape (N,).

required
values ArrayLike | None

Per-point values; None draws plain locations.

None
ccrs ModuleType | None

The cartopy.crs module; detected from ax if None.

None
cmap str | Colormap

Colormap name or object.

'inferno'
vmin float | None

Lower colour limit.

None
vmax float | None

Upper colour limit.

None
norm Normalize | None

Colour normalisation; overrides vmin/vmax.

None
size float

Marker area in points².

6.0
alpha float

Marker opacity.

0.9
sort_by_value bool

Draw low values first so high values sit on top.

True
**scatter_kw Any

Forwarded to :meth:~matplotlib.axes.Axes.scatter.

{}

Returns:

Type Description
tuple[Axes, PathCollection]

(ax, mappable): the axes and the scatter collection (a mappable when values is given).

Source code in src/nicefigs/maps.py
def scatter_map(
    lon: ArrayLike,
    lat: ArrayLike,
    values: ArrayLike | None = None,
    *,
    ax: Axes | None = None,
    ccrs: ModuleType | None = None,
    cmap: str | Colormap = "inferno",
    vmin: float | None = None,
    vmax: float | None = None,
    norm: Normalize | None = None,
    size: float = 6.0,
    alpha: float = 0.9,
    sort_by_value: bool = True,
    **scatter_kw: Any,
) -> tuple[Axes, PathCollection]:
    """Scatter points on a map, coloured by ``values`` if given.

    Points with non-finite values are dropped.

    Args:
        ax: Map axes; a Robinson map figure is created if ``None``.
        lon: Longitudes in degrees east, shape ``(N,)``.
        lat: Latitudes in degrees north, shape ``(N,)``.
        values: Per-point values; ``None`` draws plain locations.
        ccrs: The ``cartopy.crs`` module; detected from ``ax`` if ``None``.
        cmap: Colormap name or object.
        vmin: Lower colour limit.
        vmax: Upper colour limit.
        norm: Colour normalisation; overrides ``vmin``/``vmax``.
        size: Marker area in points².
        alpha: Marker opacity.
        sort_by_value: Draw low values first so high values sit on top.
        **scatter_kw: Forwarded to :meth:`~matplotlib.axes.Axes.scatter`.

    Returns:
        ``(ax, mappable)``: the axes and the scatter collection (a mappable when ``values`` is given).
    """
    ax, ccrs = _layer_axes(ax, ccrs)
    lon = np.asarray(lon, dtype=float)
    lat = np.asarray(lat, dtype=float)
    kw = dict(s=size, linewidths=0, alpha=alpha, rasterized=True)
    if ccrs is not None:
        kw["transform"] = ccrs.PlateCarree()
    if values is None:
        return ax, ax.scatter(lon, lat, **kw, **scatter_kw)
    values = np.asarray(values, dtype=float)
    valid = np.isfinite(values)
    lon, lat, values = lon[valid], lat[valid], values[valid]
    if sort_by_value:
        order = np.argsort(values)
        lon, lat, values = lon[order], lat[order], values[order]
    kw.update(c=values, cmap=resolve_cmap(cmap), vmin=vmin, vmax=vmax)
    if norm is not None:
        kw["norm"] = norm
        kw.pop("vmin")
        kw.pop("vmax")
    return ax, ax.scatter(lon, lat, **kw, **scatter_kw)

binned_map

binned_map(lon: ArrayLike, lat: ArrayLike, values: ArrayLike, *, ax: Axes | None = None, ccrs: ModuleType | None = None, cmap: str | Colormap = 'inferno', vmin: float | None = None, vmax: float | None = None, norm: Normalize | None = None, bin_deg: float = 2.0, fill_bins: int = 0, clip_ocean: bool = False, land_resolution: str = '110m', **mesh_kw: Any) -> tuple[Axes, QuadMesh]

Average point values onto a regular lon/lat grid and draw it as a mesh.

Parameters:

Name Type Description Default
ax Axes | None

Map axes; a Robinson map figure is created if None.

None
lon ArrayLike

Point longitudes in degrees east, shape (N,).

required
lat ArrayLike

Point latitudes in degrees north, shape (N,).

required
values ArrayLike

Point values, shape (N,); non-finite values are ignored.

required
ccrs ModuleType | None

The cartopy.crs module; detected from ax if None.

None
cmap str | Colormap

Colormap name or object.

'inferno'
vmin float | None

Lower colour limit.

None
vmax float | None

Upper colour limit.

None
norm Normalize | None

Colour normalisation; overrides vmin/vmax.

None
bin_deg float

Grid cell size in degrees.

2.0
fill_bins int

Fill empty cells within this many cells of data by linear interpolation (requires scipy); 0 disables filling.

0
clip_ocean bool

Mask cells whose centre lies at sea (requires cartopy and shapely); the grid then spans the globe.

False
land_resolution str

Natural Earth resolution of the land mask.

'110m'
**mesh_kw Any

Forwarded to :meth:~matplotlib.axes.Axes.pcolormesh.

{}

Returns:

Type Description
tuple[Axes, QuadMesh]

(ax, mappable): the axes and the QuadMesh mappable.

Source code in src/nicefigs/maps.py
def binned_map(
    lon: ArrayLike,
    lat: ArrayLike,
    values: ArrayLike,
    *,
    ax: Axes | None = None,
    ccrs: ModuleType | None = None,
    cmap: str | Colormap = "inferno",
    vmin: float | None = None,
    vmax: float | None = None,
    norm: Normalize | None = None,
    bin_deg: float = 2.0,
    fill_bins: int = 0,
    clip_ocean: bool = False,
    land_resolution: str = "110m",
    **mesh_kw: Any,
) -> tuple[Axes, QuadMesh]:
    """Average point values onto a regular lon/lat grid and draw it as a mesh.

    Args:
        ax: Map axes; a Robinson map figure is created if ``None``.
        lon: Point longitudes in degrees east, shape ``(N,)``.
        lat: Point latitudes in degrees north, shape ``(N,)``.
        values: Point values, shape ``(N,)``; non-finite values are ignored.
        ccrs: The ``cartopy.crs`` module; detected from ``ax`` if ``None``.
        cmap: Colormap name or object.
        vmin: Lower colour limit.
        vmax: Upper colour limit.
        norm: Colour normalisation; overrides ``vmin``/``vmax``.
        bin_deg: Grid cell size in degrees.
        fill_bins: Fill empty cells within this many cells of data by linear
            interpolation (requires scipy); 0 disables filling.
        clip_ocean: Mask cells whose centre lies at sea (requires cartopy and
            shapely); the grid then spans the globe.
        land_resolution: Natural Earth resolution of the land mask.
        **mesh_kw: Forwarded to :meth:`~matplotlib.axes.Axes.pcolormesh`.

    Returns:
        ``(ax, mappable)``: the axes and the ``QuadMesh`` mappable.
    """
    ax, ccrs = _layer_axes(ax, ccrs)
    lon = np.asarray(lon, dtype=float)
    lat = np.asarray(lat, dtype=float)
    values = np.asarray(values, dtype=float)
    lon_e, lat_e, grid = _bin_values(lon, lat, values, bin_deg, fill_bins, clip_ocean, land_resolution)
    kw = dict(cmap=resolve_cmap(cmap), shading="flat", rasterized=True)
    if norm is not None:
        kw["norm"] = norm
    else:
        kw["vmin"], kw["vmax"] = vmin, vmax
    if ccrs is not None:
        kw["transform"] = ccrs.PlateCarree()
    return ax, ax.pcolormesh(lon_e, lat_e, grid, **kw, **mesh_kw)

categorical_map

categorical_map(lon: ArrayLike, lat: ArrayLike, codes: ArrayLike, *, ax: Axes | None = None, categories: Mapping[Any, str] | Sequence[str], ccrs: ModuleType | None = None, colors: Mapping[Any, str] | Sequence[str] | None = None, kind: Literal['auto', 'binned', 'scatter'] = 'auto', bin_deg: float = 2.0, legend: bool = True, legend_kw: dict[str, Any] | None = None, **kw: Any) -> tuple[Axes, QuadMesh | PathCollection]

Map per-point class labels as discrete colours with a matching legend.

Class codes cannot be averaged, so dense clouds are reduced to a bin_deg grid by majority vote (ties go to the earlier category) rather than by the mean :func:binned_map takes; sparse clouds are drawn as a scatter. Points whose code is not in categories, and points with a non-finite coordinate, are dropped.

Parameters:

Name Type Description Default
ax Axes | None

Map axes; a Robinson map figure is created if None.

None
lon ArrayLike

Point longitudes in degrees east.

required
lat ArrayLike

Point latitudes in degrees north.

required
codes ArrayLike

Per-point class label (integer or string), same shape as lon.

required
categories Mapping[Any, str] | Sequence[str]

Ordered class names — a {code: name} mapping for arbitrary codes, or a sequence of names read as codes 0..n-1 (or, for string labels, as the codes themselves).

required
ccrs ModuleType | None

The cartopy.crs module; detected from ax if None.

None
colors Mapping[Any, str] | Sequence[str] | None

One colour per category, as a sequence in category order or a mapping keyed by category name or code. Defaults to :func:nicefigs.qualitative.

None
kind Literal['auto', 'binned', 'scatter']

"auto" (binned above 5000 valid points, else scatter), "binned", or "scatter".

'auto'
bin_deg float

Grid cell size in degrees for the binned path.

2.0
legend bool

Draw a discrete patch legend, one entry per category present.

True
legend_kw dict[str, Any] | None

Overrides forwarded to :meth:~matplotlib.axes.Axes.legend.

None
**kw Any

Forwarded to :meth:~matplotlib.axes.Axes.pcolormesh or :func:scatter_map.

{}

Returns:

Type Description
Axes

(ax, mappable): the axes and the mappable (a QuadMesh or PathCollection). The legend, when

QuadMesh | PathCollection

drawn, is reachable as ax.get_legend().

Example

categorical_map(lon, lat, codes, categories=["in", "out"], ax=ax) # doctest: +SKIP

Source code in src/nicefigs/maps.py
def categorical_map(
    lon: ArrayLike,
    lat: ArrayLike,
    codes: ArrayLike,
    *,
    ax: Axes | None = None,
    categories: Mapping[Any, str] | Sequence[str],
    ccrs: ModuleType | None = None,
    colors: Mapping[Any, str] | Sequence[str] | None = None,
    kind: Literal["auto", "binned", "scatter"] = "auto",
    bin_deg: float = 2.0,
    legend: bool = True,
    legend_kw: dict[str, Any] | None = None,
    **kw: Any,
) -> tuple[Axes, QuadMesh | PathCollection]:
    """Map per-point class labels as discrete colours with a matching legend.

    Class codes cannot be averaged, so dense clouds are reduced to a ``bin_deg``
    grid by **majority vote** (ties go to the earlier category) rather than by
    the mean :func:`binned_map` takes; sparse clouds are drawn as a scatter.
    Points whose code is not in ``categories``, and points with a non-finite
    coordinate, are dropped.

    Args:
        ax: Map axes; a Robinson map figure is created if ``None``.
        lon: Point longitudes in degrees east.
        lat: Point latitudes in degrees north.
        codes: Per-point class label (integer or string), same shape as ``lon``.
        categories: Ordered class names — a ``{code: name}`` mapping for
            arbitrary codes, or a sequence of names read as codes ``0..n-1``
            (or, for string labels, as the codes themselves).
        ccrs: The ``cartopy.crs`` module; detected from ``ax`` if ``None``.
        colors: One colour per category, as a sequence in category order or a
            mapping keyed by category name or code. Defaults to
            :func:`nicefigs.qualitative`.
        kind: ``"auto"`` (binned above 5000 valid points, else scatter),
            ``"binned"``, or ``"scatter"``.
        bin_deg: Grid cell size in degrees for the binned path.
        legend: Draw a discrete patch legend, one entry per category present.
        legend_kw: Overrides forwarded to :meth:`~matplotlib.axes.Axes.legend`.
        **kw: Forwarded to :meth:`~matplotlib.axes.Axes.pcolormesh` or :func:`scatter_map`.

    Returns:
        ``(ax, mappable)``: the axes and the mappable (a ``QuadMesh`` or ``PathCollection``). The legend, when
        drawn, is reachable as ``ax.get_legend()``.

    Example:
        >>> categorical_map(lon, lat, codes, categories=["in", "out"], ax=ax)  # doctest: +SKIP
    """
    ax, ccrs = _layer_axes(ax, ccrs)
    import matplotlib.colors as mcolors

    cat_codes, cat_names = _resolve_categories(categories, codes)
    cat_colors = _resolve_category_colors(colors, cat_codes, cat_names)
    n = len(cat_codes)
    cmap = mcolors.ListedColormap(cat_colors)
    norm = mcolors.BoundaryNorm(np.arange(-0.5, n + 0.5, 1.0), n)

    lon = np.asarray(lon, dtype=float).ravel()
    lat = np.asarray(lat, dtype=float).ravel()
    index = _category_indices(codes, cat_codes).ravel()
    valid = (index >= 0) & np.isfinite(lon) & np.isfinite(lat)
    lon, lat, index = lon[valid], lat[valid], index[valid]

    binned = kind == "binned" or (kind == "auto" and index.size > AUTO_BIN_MIN_POINTS)
    if binned and index.size:
        lon_e, lat_e, grid = _bin_majority(lon, lat, index, n, bin_deg)
        mesh_kw = dict(cmap=cmap, norm=norm, shading="flat", rasterized=True)
        if ccrs is not None:
            mesh_kw["transform"] = ccrs.PlateCarree()
        mappable = ax.pcolormesh(lon_e, lat_e, grid, **mesh_kw, **kw)
        present = np.unique(np.ma.compressed(grid))
    else:
        _, mappable = scatter_map(
            lon,
            lat,
            index.astype(float),
            ccrs=ccrs,
            cmap=cmap,
            norm=norm,
            sort_by_value=False,
            **kw,
            ax=ax,
        )
        present = np.unique(index)

    if legend and present.size:
        from matplotlib.patches import Patch

        th = get_theme()
        handles = [
            Patch(facecolor=cat_colors[i], edgecolor="none", label=cat_names[i]) for i in present.astype(int)
        ]
        # A class map fills its frame, so the legend sits on a translucent
        # backing rather than over bare colour.
        lkw = dict(
            loc="lower left",
            fontsize=th.fs_legend,
            frameon=True,
            framealpha=0.85,
            facecolor="white",
            edgecolor="none",
            handlelength=1.0,
            handletextpad=0.5,
            borderpad=0.4,
            labelspacing=0.35,
        )
        lkw.update(legend_kw or {})
        ax.legend(handles=handles, **lkw)
    return ax, mappable

raster_map

raster_map(lon: ArrayLike, lat: ArrayLike, field: ArrayLike, *, ax: Axes | None = None, ccrs: ModuleType | None = None, cmap: str | Colormap = 'inferno', vmin: float | None = None, vmax: float | None = None, norm: Normalize | None = None, method: Literal['pcolormesh', 'imshow'] = 'pcolormesh', shading: str = 'auto', **kw: Any) -> tuple[Axes, ScalarMappable]

Draw an already-gridded 2-D field on a map.

Parameters:

Name Type Description Default
ax Axes | None

Map axes; a Robinson map figure is created if None.

None
lon ArrayLike

Cell-centre longitudes in degrees east: 1-D (nx,) for a regular grid, or 2-D (ny, nx) for a curvilinear one.

required
lat ArrayLike

Cell-centre latitudes in degrees north: 1-D (ny,) or 2-D (ny, nx).

required
field ArrayLike

Values of shape (ny, nx); NaNs render transparent.

required
ccrs ModuleType | None

The cartopy.crs module; detected from ax if None.

None
cmap str | Colormap

Colormap name or object.

'inferno'
vmin float | None

Lower colour limit.

None
vmax float | None

Upper colour limit.

None
norm Normalize | None

Colour normalisation; overrides vmin/vmax.

None
method Literal['pcolormesh', 'imshow']

"pcolormesh" handles irregular and curvilinear grids; "imshow" is faster but needs a regular grid with 1-D coordinates.

'pcolormesh'
shading str

pcolormesh shading mode.

'auto'
**kw Any

Forwarded to :meth:~matplotlib.axes.Axes.pcolormesh or :meth:~matplotlib.axes.Axes.imshow.

{}

Returns:

Type Description
tuple[Axes, ScalarMappable]

(ax, mappable): the axes and the QuadMesh or AxesImage mappable.

Source code in src/nicefigs/maps.py
def raster_map(
    lon: ArrayLike,
    lat: ArrayLike,
    field: ArrayLike,
    *,
    ax: Axes | None = None,
    ccrs: ModuleType | None = None,
    cmap: str | Colormap = "inferno",
    vmin: float | None = None,
    vmax: float | None = None,
    norm: Normalize | None = None,
    method: Literal["pcolormesh", "imshow"] = "pcolormesh",
    shading: str = "auto",
    **kw: Any,
) -> tuple[Axes, ScalarMappable]:
    """Draw an already-gridded 2-D field on a map.

    Args:
        ax: Map axes; a Robinson map figure is created if ``None``.
        lon: Cell-centre longitudes in degrees east: 1-D ``(nx,)`` for a regular
            grid, or 2-D ``(ny, nx)`` for a curvilinear one.
        lat: Cell-centre latitudes in degrees north: 1-D ``(ny,)`` or 2-D ``(ny, nx)``.
        field: Values of shape ``(ny, nx)``; NaNs render transparent.
        ccrs: The ``cartopy.crs`` module; detected from ``ax`` if ``None``.
        cmap: Colormap name or object.
        vmin: Lower colour limit.
        vmax: Upper colour limit.
        norm: Colour normalisation; overrides ``vmin``/``vmax``.
        method: ``"pcolormesh"`` handles irregular and curvilinear grids;
            ``"imshow"`` is faster but needs a regular grid with 1-D coordinates.
        shading: ``pcolormesh`` shading mode.
        **kw: Forwarded to :meth:`~matplotlib.axes.Axes.pcolormesh` or
            :meth:`~matplotlib.axes.Axes.imshow`.

    Returns:
        ``(ax, mappable)``: the axes and the ``QuadMesh`` or ``AxesImage`` mappable.
    """
    ax, ccrs = _layer_axes(ax, ccrs)
    field = np.ma.masked_invalid(np.asarray(field, dtype=float))
    kw = dict(kw, cmap=resolve_cmap(cmap), rasterized=True)
    if norm is not None:
        kw["norm"] = norm
    else:
        kw["vmin"], kw["vmax"] = vmin, vmax
    if ccrs is not None:
        kw["transform"] = ccrs.PlateCarree()
    if method == "imshow":
        lon = np.asarray(lon)
        lat = np.asarray(lat)
        extent = (float(lon.min()), float(lon.max()), float(lat.min()), float(lat.max()))
        origin = "upper" if lat[0] > lat[-1] else "lower"
        return ax, ax.imshow(field, extent=extent, origin=origin, aspect="auto", **kw)
    return ax, ax.pcolormesh(np.asarray(lon), np.asarray(lat), field, shading=shading, **kw)

tri_map

tri_map(lon: ArrayLike, lat: ArrayLike, values: ArrayLike, *, ax: Axes | None = None, ccrs: ModuleType | None = None, cmap: str | Colormap = 'inferno', vmin: float | None = None, vmax: float | None = None, norm: Normalize | None = None, method: Literal['tricontourf', 'tripcolor'] = 'tricontourf', levels: int | Sequence[float] = 14, max_edge_frac: float = 0.12, **kw: Any) -> tuple[Axes, ScalarMappable]

Draw unstructured data (e.g. an icosahedral mesh) by triangulating its points.

The triangulation is built in projected coordinates, and triangles whose longest edge exceeds max_edge_frac of the map width are masked, so no triangle smears across the antimeridian or the poles.

Parameters:

Name Type Description Default
ax Axes | None

Map axes; a Robinson map figure is created if None.

None
lon ArrayLike

Node longitudes in degrees east, shape (N,).

required
lat ArrayLike

Node latitudes in degrees north, shape (N,).

required
values ArrayLike

Node values, shape (N,); non-finite nodes are dropped.

required
ccrs ModuleType | None

The cartopy.crs module; detected from ax if None.

None
cmap str | Colormap

Colormap name or object.

'inferno'
vmin float | None

Lower colour limit.

None
vmax float | None

Upper colour limit.

None
norm Normalize | None

Colour normalisation; overrides vmin/vmax.

None
method Literal['tricontourf', 'tripcolor']

"tricontourf" for smooth filled contours, "tripcolor" for Gouraud-shaded triangles.

'tricontourf'
levels int | Sequence[float]

Contour levels (count or boundaries) for tricontourf.

14
max_edge_frac float

Mask triangles with an edge longer than this fraction of the projected map width.

0.12
**kw Any

Forwarded to :meth:~matplotlib.axes.Axes.tricontourf or :meth:~matplotlib.axes.Axes.tripcolor.

{}

Returns:

Type Description
tuple[Axes, ScalarMappable]

(ax, mappable): the axes and the contour set or PolyCollection mappable.

Source code in src/nicefigs/maps.py
def tri_map(
    lon: ArrayLike,
    lat: ArrayLike,
    values: ArrayLike,
    *,
    ax: Axes | None = None,
    ccrs: ModuleType | None = None,
    cmap: str | Colormap = "inferno",
    vmin: float | None = None,
    vmax: float | None = None,
    norm: Normalize | None = None,
    method: Literal["tricontourf", "tripcolor"] = "tricontourf",
    levels: int | Sequence[float] = 14,
    max_edge_frac: float = 0.12,
    **kw: Any,
) -> tuple[Axes, ScalarMappable]:
    """Draw unstructured data (e.g. an icosahedral mesh) by triangulating its points.

    The triangulation is built in projected coordinates, and triangles whose
    longest edge exceeds ``max_edge_frac`` of the map width are masked, so no
    triangle smears across the antimeridian or the poles.

    Args:
        ax: Map axes; a Robinson map figure is created if ``None``.
        lon: Node longitudes in degrees east, shape ``(N,)``.
        lat: Node latitudes in degrees north, shape ``(N,)``.
        values: Node values, shape ``(N,)``; non-finite nodes are dropped.
        ccrs: The ``cartopy.crs`` module; detected from ``ax`` if ``None``.
        cmap: Colormap name or object.
        vmin: Lower colour limit.
        vmax: Upper colour limit.
        norm: Colour normalisation; overrides ``vmin``/``vmax``.
        method: ``"tricontourf"`` for smooth filled contours, ``"tripcolor"`` for
            Gouraud-shaded triangles.
        levels: Contour levels (count or boundaries) for ``tricontourf``.
        max_edge_frac: Mask triangles with an edge longer than this fraction of
            the projected map width.
        **kw: Forwarded to :meth:`~matplotlib.axes.Axes.tricontourf` or
            :meth:`~matplotlib.axes.Axes.tripcolor`.

    Returns:
        ``(ax, mappable)``: the axes and the contour set or ``PolyCollection`` mappable.
    """
    ax, ccrs = _layer_axes(ax, ccrs)
    from matplotlib.tri import Triangulation

    lon = np.asarray(lon, dtype=float)
    lat = np.asarray(lat, dtype=float)
    values = np.asarray(values, dtype=float)
    finite = np.isfinite(values) & np.isfinite(lon) & np.isfinite(lat)
    lon, lat, values = lon[finite], lat[finite], values[finite]

    # Points are projected here, so the plot call needs no ``transform``.
    if ccrs is not None:
        pts = ax.projection.transform_points(ccrs.PlateCarree(), lon, lat)
        x, y = pts[:, 0], pts[:, 1]
        ok = np.isfinite(x) & np.isfinite(y)
        x, y, values = x[ok], y[ok], values[ok]
    else:
        x, y = lon, lat

    tri = Triangulation(x, y)
    xt, yt = x[tri.triangles], y[tri.triangles]
    edges = np.sqrt((xt - np.roll(xt, 1, 1)) ** 2 + (yt - np.roll(yt, 1, 1)) ** 2).max(1)
    width = np.nanmax(x) - np.nanmin(x)
    tri.set_mask(edges > max_edge_frac * width)

    kw = dict(kw, cmap=resolve_cmap(cmap))
    if norm is not None:
        kw["norm"] = norm
    else:
        kw["vmin"], kw["vmax"] = vmin, vmax
    if method == "tripcolor":
        return ax, ax.tripcolor(tri, values, shading="gouraud", rasterized=True, **kw)
    return ax, ax.tricontourf(tri, values, levels=levels, **kw)

from_xarray

from_xarray(da: Any) -> tuple[np.ndarray, np.ndarray, np.ndarray]

Extract (lon, lat, field) numpy arrays from an xarray DataArray.

Longitude and latitude coordinates are found by common names (lon, longitude, x, nav_lon, ...; case-insensitive) after squeezing singleton dimensions. Works for regular grids and for unstructured data with one cell dimension.

Parameters:

Name Type Description Default
da Any

An xarray.DataArray.

required

Returns:

Type Description
tuple[ndarray, ndarray, ndarray]

(lon, lat, field) as numpy arrays.

Raises:

Type Description
KeyError

No longitude or latitude coordinate is found.

Source code in src/nicefigs/maps.py
def from_xarray(da: Any) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Extract ``(lon, lat, field)`` numpy arrays from an xarray ``DataArray``.

    Longitude and latitude coordinates are found by common names (``lon``,
    ``longitude``, ``x``, ``nav_lon``, ...; case-insensitive) after squeezing
    singleton dimensions. Works for regular grids and for unstructured data with
    one cell dimension.

    Args:
        da: An ``xarray.DataArray``.

    Returns:
        ``(lon, lat, field)`` as numpy arrays.

    Raises:
        KeyError: No longitude or latitude coordinate is found.
    """
    da = da.squeeze()
    coords = {str(k).lower(): k for k in da.coords}
    lon_key = next((coords[n] for n in _LON_NAMES if n in coords), None)
    lat_key = next((coords[n] for n in _LAT_NAMES if n in coords), None)
    if lon_key is None or lat_key is None:
        raise KeyError(
            f"Could not find lon/lat coords in {list(da.coords)}. "
            "Pass lon/lat explicitly, or rename coords to lon/lat."
        )
    return np.asarray(da[lon_key].values), np.asarray(da[lat_key].values), np.asarray(da.values)

map

map(lon: ArrayLike, lat: ArrayLike | None = None, values: ArrayLike | None = None, *, ax: Axes | None = None, fig: Figure | None = None, subplot_spec: int | tuple[int, int, int] | SubplotSpec = 111, kind: str = 'auto', projection: str = 'robinson', central_longitude: float = 0.0, extent: Sequence[float] | None = None, antarctica_cutoff: bool = True, cmap: str | Colormap | None = None, vmin: float | None = None, vmax: float | None = None, norm: Normalize | None = None, robust: bool = True, diverging: bool = False, center: float = 0.0, bin_deg: float = 2.0, fill_bins: int = 0, clip_ocean: bool | None = None, land: bool = True, ocean: bool = False, coastline: bool = True, borders: bool = False, inset_hist: bool = True, colorbar_label: str = '', cbar: bool = False, cbar_kwargs: dict[str, Any] | None = None, hist_bounds: tuple[float, float, float, float] = (0.07, 0.22, 0.18, 0.11), width: str | float = 'full', aspect: float = 0.52, title: str = '', **plot_kw: Any) -> tuple[Figure, Axes, ScalarMappable | None]

Draw a global or regional map, choosing the drawing method from the data layout.

Four layouts are supported:

  • points: 1-D lon, lat, values, scattered, or bin-averaged for dense clouds;
  • gridded: 1-D or 2-D lon/lat with a 2-D values field, or an xarray DataArray as the only argument (map(da));
  • unstructured: 1-D lon/lat/values with kind="tri";
  • classes: class labels with kind="categorical" and categories=..., decoded by a patch legend instead of a colorbar.

Parameters:

Name Type Description Default
lon ArrayLike

Longitudes in degrees east, or an xarray DataArray passed alone.

required
lat ArrayLike | None

Latitudes in degrees north.

None
values ArrayLike | None

Per-point values (1-D) or a gridded field (2-D); None draws just the locations.

None
ax Axes | None

Existing map axes to draw into; created if None.

None
fig Figure | None

Figure to add the axes to when ax is None.

None
subplot_spec int | tuple[int, int, int] | SubplotSpec

Position of the new axes, as in :func:make_map_axes.

111
kind str

"auto" (raster for 2-D fields, binned above :data:AUTO_BIN_MIN_POINTS points, else scatter), "scatter", "binned", "raster", "imshow", "tri", "tripcolor" or "categorical" (see :func:categorical_map).

'auto'
projection str

Projection name, as in :func:make_map_axes.

'robinson'
central_longitude float

Central meridian in degrees east.

0.0
extent Sequence[float] | None

(west, east, south, north) in degrees for a regional map; overrides antarctica_cutoff.

None
antarctica_cutoff bool

Crop the global view to latitudes -60 to 84.

True
cmap str | Colormap | None

Colormap name or object; defaults to "RdBu_r" when diverging, else "inferno".

None
vmin float | None

Lower colour limit.

None
vmax float | None

Upper colour limit.

None
norm Normalize | None

Colour normalisation; overrides the limits.

None
robust bool

Use 2nd/98th-percentile limits when vmin/vmax are unset.

True
diverging bool

Use limits symmetric about center and a diverging colormap.

False
center float

Centre value of the diverging normalisation.

0.0
bin_deg float

Grid cell size in degrees for binned and categorical maps.

2.0
fill_bins int

Gap filling for binned maps, as in :func:binned_map.

0
clip_ocean bool | None

For binned maps, mask cells whose centre is at sea. None clips global maps that draw land; pass False for data measured over the ocean.

None
land bool

Draw the land background.

True
ocean bool

Fill the ocean.

False
coastline bool

Draw coastlines.

True
borders bool

Draw country borders.

False
inset_hist bool

Draw the inset histogram that doubles as the colorbar.

True
colorbar_label str

Label of the histogram and/or colorbar.

''
cbar bool

Also draw a conventional colorbar.

False
cbar_kwargs dict[str, Any] | None

Keywords for :func:nicefigs.colorbar.

None
hist_bounds tuple[float, float, float, float]

Inset histogram (x0, y0, width, height) in axes fractions.

(0.07, 0.22, 0.18, 0.11)
width str | float

Width of a new figure, a named width or inches.

'full'
aspect float

Height / width of a new figure.

0.52
title str

Left-aligned panel title.

''
**plot_kw Any

Forwarded to the layout helper (:func:scatter_map, :func:binned_map, :func:raster_map, :func:tri_map or :func:categorical_map).

{}

Returns:

Type Description
tuple[Figure, Axes, ScalarMappable | None]

(fig, ax, mappable); mappable is None when values is None.

Source code in src/nicefigs/maps.py
def map(
    lon: ArrayLike,
    lat: ArrayLike | None = None,
    values: ArrayLike | None = None,
    *,
    ax: Axes | None = None,
    fig: Figure | None = None,
    subplot_spec: int | tuple[int, int, int] | SubplotSpec = 111,
    kind: str = "auto",
    projection: str = "robinson",
    central_longitude: float = 0.0,
    extent: Sequence[float] | None = None,
    antarctica_cutoff: bool = True,
    cmap: str | Colormap | None = None,
    vmin: float | None = None,
    vmax: float | None = None,
    norm: Normalize | None = None,
    robust: bool = True,
    diverging: bool = False,
    center: float = 0.0,
    bin_deg: float = 2.0,
    fill_bins: int = 0,
    clip_ocean: bool | None = None,
    land: bool = True,
    ocean: bool = False,
    coastline: bool = True,
    borders: bool = False,
    inset_hist: bool = True,
    colorbar_label: str = "",
    cbar: bool = False,
    cbar_kwargs: dict[str, Any] | None = None,
    hist_bounds: tuple[float, float, float, float] = (0.07, 0.22, 0.18, 0.11),
    width: str | float = "full",
    aspect: float = 0.52,
    title: str = "",
    **plot_kw: Any,
) -> tuple[Figure, Axes, ScalarMappable | None]:
    """Draw a global or regional map, choosing the drawing method from the data layout.

    Four layouts are supported:

    * **points**: 1-D ``lon``, ``lat``, ``values``, scattered, or bin-averaged for
      dense clouds;
    * **gridded**: 1-D or 2-D ``lon``/``lat`` with a 2-D ``values`` field, or an
      xarray ``DataArray`` as the only argument (``map(da)``);
    * **unstructured**: 1-D ``lon``/``lat``/``values`` with ``kind="tri"``;
    * **classes**: class labels with ``kind="categorical"`` and ``categories=...``,
      decoded by a patch legend instead of a colorbar.

    Args:
        lon: Longitudes in degrees east, or an xarray ``DataArray`` passed alone.
        lat: Latitudes in degrees north.
        values: Per-point values (1-D) or a gridded field (2-D); ``None`` draws
            just the locations.
        ax: Existing map axes to draw into; created if ``None``.
        fig: Figure to add the axes to when ``ax`` is ``None``.
        subplot_spec: Position of the new axes, as in :func:`make_map_axes`.
        kind: ``"auto"`` (raster for 2-D fields, binned above
            :data:`AUTO_BIN_MIN_POINTS` points, else scatter), ``"scatter"``,
            ``"binned"``, ``"raster"``, ``"imshow"``, ``"tri"``, ``"tripcolor"``
            or ``"categorical"`` (see :func:`categorical_map`).
        projection: Projection name, as in :func:`make_map_axes`.
        central_longitude: Central meridian in degrees east.
        extent: ``(west, east, south, north)`` in degrees for a regional map;
            overrides ``antarctica_cutoff``.
        antarctica_cutoff: Crop the global view to latitudes -60 to 84.
        cmap: Colormap name or object; defaults to ``"RdBu_r"`` when
            ``diverging``, else ``"inferno"``.
        vmin: Lower colour limit.
        vmax: Upper colour limit.
        norm: Colour normalisation; overrides the limits.
        robust: Use 2nd/98th-percentile limits when ``vmin``/``vmax`` are unset.
        diverging: Use limits symmetric about ``center`` and a diverging colormap.
        center: Centre value of the diverging normalisation.
        bin_deg: Grid cell size in degrees for binned and categorical maps.
        fill_bins: Gap filling for binned maps, as in :func:`binned_map`.
        clip_ocean: For binned maps, mask cells whose centre is at sea. ``None``
            clips global maps that draw land; pass ``False`` for data measured
            over the ocean.
        land: Draw the land background.
        ocean: Fill the ocean.
        coastline: Draw coastlines.
        borders: Draw country borders.
        inset_hist: Draw the inset histogram that doubles as the colorbar.
        colorbar_label: Label of the histogram and/or colorbar.
        cbar: Also draw a conventional colorbar.
        cbar_kwargs: Keywords for :func:`nicefigs.colorbar`.
        hist_bounds: Inset histogram ``(x0, y0, width, height)`` in axes fractions.
        width: Width of a new figure, a named width or inches.
        aspect: Height / width of a new figure.
        title: Left-aligned panel title.
        **plot_kw: Forwarded to the layout helper (:func:`scatter_map`,
            :func:`binned_map`, :func:`raster_map`, :func:`tri_map` or
            :func:`categorical_map`).

    Returns:
        ``(fig, ax, mappable)``; ``mappable`` is ``None`` when ``values`` is ``None``.
    """
    th = get_theme()
    # An xarray DataArray may be passed as the sole positional argument.
    if _is_dataarray(lon) and lat is None:
        lon, lat, values = from_xarray(lon)
        if kind == "auto":
            kind = "raster"

    if ax is None:
        if fig is None:
            from .figure import figure_size

            fig = plt.figure(figsize=figure_size(width, aspect=aspect), dpi=th.dpi)
        ax, ccrs = make_map_axes(
            fig, subplot_spec, projection=projection, central_longitude=central_longitude
        )
    else:
        fig = ax.figure
        ccrs = _try_cartopy() if hasattr(ax, "projection") else None

    if cmap is None:
        cmap = "RdBu_r" if diverging else "inferno"

    # limits (meaningless for class labels, which carry their own discrete norm)
    if values is not None and norm is None and kind != "categorical":
        if diverging:
            from .palettes import diverging_norm, symmetric_limits

            lo, hi = symmetric_limits(values) if (vmin is None or vmax is None) else (vmin, vmax)
            vmin = lo if vmin is None else vmin
            vmax = hi if vmax is None else vmax
            norm = diverging_norm(vmin, vmax, center)
        elif robust and (vmin is None or vmax is None):
            lo, hi = robust_limits(values)
            vmin = lo if vmin is None else vmin
            vmax = hi if vmax is None else vmax

    draw_background(ax, ccrs, land=land, ocean=ocean, coastline=coastline, borders=borders)
    _apply_extent(ax, ccrs, extent, antarctica_cutoff and extent is None)

    mappable = None
    if values is not None:
        eff = kind
        if kind == "auto":
            eff = (
                "raster"
                if np.ndim(values) == 2
                else ("binned" if np.size(values) > AUTO_BIN_MIN_POINTS else "scatter")
            )

        if eff in ("raster", "imshow", "pcolormesh"):
            _, mappable = raster_map(
                lon,
                lat,
                values,
                ccrs=ccrs,
                cmap=cmap,
                vmin=vmin,
                vmax=vmax,
                norm=norm,
                method="imshow" if eff == "imshow" else "pcolormesh",
                **plot_kw,
                ax=ax,
            )
        elif eff in ("tri", "tricontourf", "tripcolor"):
            _, mappable = tri_map(
                lon,
                lat,
                values,
                ccrs=ccrs,
                cmap=cmap,
                vmin=vmin,
                vmax=vmax,
                norm=norm,
                method="tripcolor" if eff == "tripcolor" else "tricontourf",
                **plot_kw,
                ax=ax,
            )
        elif eff == "categorical":
            _, mappable = categorical_map(lon, lat, values, ccrs=ccrs, bin_deg=bin_deg, **plot_kw, ax=ax)
        elif eff == "binned":
            # Ocean-clip only for global maps: over a fine regional grid the
            # point-in-polygon land mask is prohibitively slow and rarely wanted.
            clip = (land and extent is None) if clip_ocean is None else bool(clip_ocean)
            _, mappable = binned_map(
                lon,
                lat,
                values,
                ccrs=ccrs,
                cmap=cmap,
                vmin=vmin,
                vmax=vmax,
                norm=norm,
                bin_deg=bin_deg,
                fill_bins=fill_bins,
                clip_ocean=clip,
                **plot_kw,
                ax=ax,
            )
        else:
            _, mappable = scatter_map(
                lon,
                lat,
                values,
                ccrs=ccrs,
                cmap=cmap,
                vmin=vmin,
                vmax=vmax,
                norm=norm,
                **plot_kw,
                ax=ax,
            )
    else:
        scatter_map(lon, lat, ccrs=ccrs, color=COLORS["primary"], **plot_kw, ax=ax)

    if title:
        from .panels import clean_title

        clean_title(ax, title)

    # A categorical map decodes itself through its legend, never a colorbar.
    if mappable is not None and kind != "categorical":
        if inset_hist:
            inset_histogram_colorbar(
                ax,
                values,
                cmap=cmap,
                vmin=vmin,
                vmax=vmax,
                norm=norm,
                bounds=hist_bounds,
                label=colorbar_label or None,
            )
        if cbar:
            cbar_kwargs = dict(cbar_kwargs or {})
            cbar_kwargs.setdefault("label", colorbar_label)
            colorbar(mappable, ax, **cbar_kwargs)

    return fig, ax, mappable

nicefigs.images

images

Images, heatmaps and RGB composites, singly or in grids with shared colorbars.

PCABasis module-attribute

PCABasis = tuple[np.ndarray, np.ndarray, np.ndarray]

image

image(data: ArrayLike, *, ax: Axes | None = None, cmap: str = 'inferno', vmin: float | None = None, vmax: float | None = None, norm: Normalize | None = None, robust: bool = False, rgb: bool = False, stretch: bool = False, title: str = '', interpolation: str = 'nearest', origin: Literal['upper', 'lower'] = 'upper', overlay_contour: ArrayLike | None = None, contour_color: str = 'white', contour_linewidth: float = 0.8, frame: bool = False, **imshow_kw: Any) -> tuple[Axes, AxesImage | None]

Show an intensity or RGB array on ax without ticks.

Parameters:

Name Type Description Default
ax Axes | None

Axes to draw on; a new figure is created if None.

None
data ArrayLike

Intensity array (H, W), or colour image (H, W, 3|4) in [0, 1] or [0, 255]. A 3-D array with 3 or 4 channels is always shown as RGB.

required
cmap str

Colormap name for intensity data.

'inferno'
vmin float | None

Lower colour limit for intensity data.

None
vmax float | None

Upper colour limit for intensity data.

None
norm Normalize | None

Colour normalisation; overrides vmin/vmax.

None
robust bool

Use the 2nd/98th percentiles when no limits or norm are given.

False
rgb bool

Treat data as a colour image.

False
stretch bool

Contrast-stretch each RGB channel to its 2nd/98th percentiles.

False
title str

Axes title.

''
interpolation str

Passed to :meth:~matplotlib.axes.Axes.imshow.

'nearest'
origin Literal['upper', 'lower']

Passed to :meth:~matplotlib.axes.Axes.imshow.

'upper'
overlay_contour ArrayLike | None

Mask of shape (H, W); its 0.5 contour is drawn on top.

None
contour_color str

Colour of the overlay contour.

'white'
contour_linewidth float

Line width of the overlay contour in points.

0.8
frame bool

Draw a thin box on all four sides; otherwise no spines.

False
**imshow_kw Any

Forwarded to :meth:~matplotlib.axes.Axes.imshow.

{}

Returns:

Type Description
Axes

(ax, image): the axes and the :class:~matplotlib.image.AxesImage of

AxesImage | None

intensity data (for a colorbar), or None for RGB data.

Source code in src/nicefigs/images.py
def image(
    data: ArrayLike,
    *,
    ax: Axes | None = None,
    cmap: str = "inferno",
    vmin: float | None = None,
    vmax: float | None = None,
    norm: Normalize | None = None,
    robust: bool = False,
    rgb: bool = False,
    stretch: bool = False,
    title: str = "",
    interpolation: str = "nearest",
    origin: Literal["upper", "lower"] = "upper",
    overlay_contour: ArrayLike | None = None,
    contour_color: str = "white",
    contour_linewidth: float = 0.8,
    frame: bool = False,
    **imshow_kw: Any,
) -> tuple[Axes, AxesImage | None]:
    """Show an intensity or RGB array on ``ax`` without ticks.

    Args:
        ax: Axes to draw on; a new figure is created if ``None``.
        data: Intensity array ``(H, W)``, or colour image ``(H, W, 3|4)`` in
            ``[0, 1]`` or ``[0, 255]``. A 3-D array with 3 or 4 channels is always
            shown as RGB.
        cmap: Colormap name for intensity data.
        vmin: Lower colour limit for intensity data.
        vmax: Upper colour limit for intensity data.
        norm: Colour normalisation; overrides ``vmin``/``vmax``.
        robust: Use the 2nd/98th percentiles when no limits or ``norm`` are given.
        rgb: Treat ``data`` as a colour image.
        stretch: Contrast-stretch each RGB channel to its 2nd/98th percentiles.
        title: Axes title.
        interpolation: Passed to :meth:`~matplotlib.axes.Axes.imshow`.
        origin: Passed to :meth:`~matplotlib.axes.Axes.imshow`.
        overlay_contour: Mask of shape ``(H, W)``; its 0.5 contour is drawn on top.
        contour_color: Colour of the overlay contour.
        contour_linewidth: Line width of the overlay contour in points.
        frame: Draw a thin box on all four sides; otherwise no spines.
        **imshow_kw: Forwarded to :meth:`~matplotlib.axes.Axes.imshow`.

    Returns:
        ``(ax, image)``: the axes and the :class:`~matplotlib.image.AxesImage` of
        intensity data (for a colorbar), or ``None`` for RGB data.
    """
    if ax is None:
        _, ax = subplots()
    th = get_theme()
    if rgb or (np.ndim(data) == 3 and np.asarray(data).shape[-1] in (3, 4)):
        img = (
            _stretch_rgb(data)
            if stretch
            else np.clip(np.asarray(data, float) / (255.0 if np.nanmax(data) > 1.5 else 1.0), 0, 1)
        )
        ax.imshow(img, interpolation=interpolation, origin=origin, aspect="equal", **imshow_kw)
        mappable = None
    else:
        if robust and vmin is None and vmax is None and norm is None:
            vmin, vmax = robust_limits(data)
        kw = dict(cmap=resolve_cmap(cmap), interpolation=interpolation, origin=origin, aspect="equal")
        if norm is not None:
            kw["norm"] = norm
        else:
            kw["vmin"], kw["vmax"] = vmin, vmax
        mappable = ax.imshow(data, **kw, **imshow_kw)
    if overlay_contour is not None:
        ax.contour(
            np.asarray(overlay_contour, dtype=float),
            levels=[0.5],
            colors=contour_color,
            linewidths=contour_linewidth,
        )
    ax.set_xticks([])
    ax.set_yticks([])
    # Symmetric spines: a thin box on all four sides, or none at all — never the
    # theme's asymmetric top/right-off look, which reads oddly around an image.
    for sp in ax.spines.values():
        sp.set_visible(frame)
        if frame:
            sp.set_linewidth(0.5)
            sp.set_color("#888888")
    if title:
        ax.set_title(title, fontsize=th.fs_title, pad=2.5)
    return ax, mappable

overlay_grid

overlay_grid(ax: Axes, values: ArrayLike, *, window: tuple[int, int, int], cmap: str = 'inferno', vmin: float | None = None, vmax: float | None = None, alpha: float = 0.6, mask_below: float | None = None, outline: bool = True, outline_color: str = 'white', outline_linewidth: float = 0.6) -> AxesImage

Overlay a coarse (h, w) grid of values on a square window of an image.

The grid is stretched over image pixels [row, row + size) x [col, col + size) of the image already on ax (pixel-centre coordinates, as :func:image draws it), so grid cell (i, j) covers size / h x size / w image pixels. The axes limits are left as they were.

Parameters:

Name Type Description Default
ax Axes

Axes holding the image.

required
values ArrayLike

Grid of shape (h, w); NaN cells are transparent.

required
window tuple[int, int, int]

(row, col, size) of the window, in image pixels.

required
cmap str

Colormap name of the grid.

'inferno'
vmin float | None

Lower colour limit of the grid.

None
vmax float | None

Upper colour limit of the grid.

None
alpha float

Opacity of the grid.

0.6
mask_below float | None

Cells with values at or below this are transparent.

None
outline bool

Draw the window's frame.

True
outline_color str

Frame colour.

'white'
outline_linewidth float

Frame line width in points.

0.6

Returns:

Type Description
AxesImage

The grid's AxesImage.

Source code in src/nicefigs/images.py
def overlay_grid(
    ax: Axes,
    values: ArrayLike,
    *,
    window: tuple[int, int, int],
    cmap: str = "inferno",
    vmin: float | None = None,
    vmax: float | None = None,
    alpha: float = 0.6,
    mask_below: float | None = None,
    outline: bool = True,
    outline_color: str = "white",
    outline_linewidth: float = 0.6,
) -> AxesImage:
    """Overlay a coarse ``(h, w)`` grid of values on a square window of an image.

    The grid is stretched over image pixels ``[row, row + size)`` x ``[col, col + size)``
    of the image already on ``ax`` (pixel-centre coordinates, as :func:`image` draws it),
    so grid cell ``(i, j)`` covers ``size / h`` x ``size / w`` image pixels. The axes
    limits are left as they were.

    Args:
        ax: Axes holding the image.
        values: Grid of shape ``(h, w)``; NaN cells are transparent.
        window: ``(row, col, size)`` of the window, in image pixels.
        cmap: Colormap name of the grid.
        vmin: Lower colour limit of the grid.
        vmax: Upper colour limit of the grid.
        alpha: Opacity of the grid.
        mask_below: Cells with values at or below this are transparent.
        outline: Draw the window's frame.
        outline_color: Frame colour.
        outline_linewidth: Frame line width in points.

    Returns:
        The grid's ``AxesImage``.
    """
    import matplotlib.patches as mpatches

    row, col, size = window
    grid = np.ma.masked_invalid(np.asarray(values, dtype=float))
    if mask_below is not None:
        grid = np.ma.masked_where(grid <= mask_below, grid)
    xlim, ylim = ax.get_xlim(), ax.get_ylim()
    extent = (col - 0.5, col + size - 0.5, row + size - 0.5, row - 0.5)
    im = ax.imshow(
        grid,
        cmap=resolve_cmap(cmap),
        vmin=vmin,
        vmax=vmax,
        alpha=alpha,
        extent=extent,
        interpolation="nearest",
        zorder=3,
    )
    if outline:
        ax.add_patch(
            mpatches.Rectangle(
                (col - 0.5, row - 0.5),
                size,
                size,
                fill=False,
                edgecolor=outline_color,
                linewidth=outline_linewidth,
                zorder=4,
            )
        )
    ax.set_xlim(xlim)
    ax.set_ylim(ylim)
    return im

pca_rgb

pca_rgb(field: ArrayLike, *, basis: PCABasis | tuple[ndarray, ndarray] | None = None, percentile: tuple[float, float] = (2, 98), return_basis: bool = False) -> np.ndarray | tuple[np.ndarray, PCABasis]

Map a multi-channel field to RGB through its first three principal components.

Each component is scaled to [0, 1] between its percentile limits and mapped to R, G, B.

Parameters:

Name Type Description Default
field ArrayLike

Array of shape (..., D) with D >= 3, e.g. (H, W, D).

required
basis PCABasis | tuple[ndarray, ndarray] | None

Reuse a projection. A (mean, components, limits) basis from return_basis=True colours several fields identically; a (mean, components) pair reuses the projection but rescales each field to its own percentiles. None fits the PCA on field.

None
percentile tuple[float, float]

Lower/upper percentiles mapped to 0 and 1.

(2, 98)
return_basis bool

Also return the (mean, components, limits) basis.

False

Returns:

Type Description
ndarray | tuple[ndarray, PCABasis]

RGB array of shape field.shape[:-1] + (3,), or (rgb, basis) when

ndarray | tuple[ndarray, PCABasis]

return_basis.

Source code in src/nicefigs/images.py
def pca_rgb(
    field: ArrayLike,
    *,
    basis: PCABasis | tuple[np.ndarray, np.ndarray] | None = None,
    percentile: tuple[float, float] = (2, 98),
    return_basis: bool = False,
) -> np.ndarray | tuple[np.ndarray, PCABasis]:
    """Map a multi-channel field to RGB through its first three principal components.

    Each component is scaled to ``[0, 1]`` between its ``percentile`` limits and
    mapped to R, G, B.

    Args:
        field: Array of shape ``(..., D)`` with ``D >= 3``, e.g. ``(H, W, D)``.
        basis: Reuse a projection. A ``(mean, components, limits)`` basis from
            ``return_basis=True`` colours several fields identically; a
            ``(mean, components)`` pair reuses the projection but rescales each
            field to its own percentiles. ``None`` fits the PCA on ``field``.
        percentile: Lower/upper percentiles mapped to 0 and 1.
        return_basis: Also return the ``(mean, components, limits)`` basis.

    Returns:
        RGB array of shape ``field.shape[:-1] + (3,)``, or ``(rgb, basis)`` when
        ``return_basis``.
    """
    arr = np.asarray(field, dtype=float)
    lead = arr.shape[:-1]
    flat = arr.reshape(-1, arr.shape[-1])
    limits = None
    if basis is None:
        mean = flat.mean(0)
        comps = np.linalg.svd(flat - mean, full_matrices=False)[2][:3]
    elif len(basis) == 2:
        mean, comps = basis
    else:
        mean, comps, limits = basis[0], basis[1], np.asarray(basis[2], dtype=float)
    proj = (flat - mean) @ comps.T  # (N, 3)
    if limits is None:
        limits = np.stack([np.nanpercentile(proj[:, c], percentile) for c in range(3)])
    out = np.clip((proj - limits[:, 0]) / np.maximum(limits[:, 1] - limits[:, 0], 1e-9), 0, 1)
    rgb = out.reshape(*lead, 3)
    return (rgb, (mean, comps, limits)) if return_basis else rgb

heatmap

heatmap(matrix: ArrayLike, *, ax: Axes | None = None, row_labels: Sequence[str] | None = None, col_labels: Sequence[str] | None = None, cmap: str = 'Spectral_r', vmin: float | None = None, vmax: float | None = None, norm: Normalize | None = None, annotate: bool = False, annot_fmt: str = '{:.2f}', annot_size: float | None = None, grid: bool = True, title: str = '') -> tuple[Axes, AxesImage]

Draw a labelled heatmap of a 2-D matrix, optionally annotating each cell.

Parameters:

Name Type Description Default
ax Axes | None

Axes to draw on; a new figure is created if None.

None
matrix ArrayLike

Values of shape (rows, cols); NaN cells are left blank.

required
row_labels Sequence[str] | None

Tick labels for the rows.

None
col_labels Sequence[str] | None

Tick labels for the columns, rotated 45°.

None
cmap str

Colormap name.

'Spectral_r'
vmin float | None

Lower colour limit.

None
vmax float | None

Upper colour limit.

None
norm Normalize | None

Colour normalisation; overrides vmin/vmax.

None
annotate bool

Write each value in its cell, in black or white by cell luminance.

False
annot_fmt str

:meth:str.format pattern for annotations.

'{:.2f}'
annot_size float | None

Annotation size in points; defaults to the theme's annotation size.

None
grid bool

Separate cells with thin white lines.

True
title str

Axes title.

''

Returns:

Type Description
tuple[Axes, AxesImage]

(ax, image): the axes and the drawn :class:~matplotlib.image.AxesImage.

Source code in src/nicefigs/images.py
def heatmap(
    matrix: ArrayLike,
    *,
    ax: Axes | None = None,
    row_labels: Sequence[str] | None = None,
    col_labels: Sequence[str] | None = None,
    cmap: str = "Spectral_r",
    vmin: float | None = None,
    vmax: float | None = None,
    norm: Normalize | None = None,
    annotate: bool = False,
    annot_fmt: str = "{:.2f}",
    annot_size: float | None = None,
    grid: bool = True,
    title: str = "",
) -> tuple[Axes, AxesImage]:
    """Draw a labelled heatmap of a 2-D matrix, optionally annotating each cell.

    Args:
        ax: Axes to draw on; a new figure is created if ``None``.
        matrix: Values of shape ``(rows, cols)``; NaN cells are left blank.
        row_labels: Tick labels for the rows.
        col_labels: Tick labels for the columns, rotated 45°.
        cmap: Colormap name.
        vmin: Lower colour limit.
        vmax: Upper colour limit.
        norm: Colour normalisation; overrides ``vmin``/``vmax``.
        annotate: Write each value in its cell, in black or white by cell luminance.
        annot_fmt: :meth:`str.format` pattern for annotations.
        annot_size: Annotation size in points; defaults to the theme's annotation size.
        grid: Separate cells with thin white lines.
        title: Axes title.

    Returns:
        ``(ax, image)``: the axes and the drawn :class:`~matplotlib.image.AxesImage`.
    """
    if ax is None:
        _, ax = subplots()
    th = get_theme()
    matrix = np.asarray(matrix, dtype=float)
    kw = dict(cmap=resolve_cmap(cmap), aspect="auto")
    if norm is not None:
        kw["norm"] = norm
    else:
        kw["vmin"], kw["vmax"] = vmin, vmax
    im = ax.imshow(matrix, **kw)
    nr, nc = matrix.shape
    ax.set_xticks(np.arange(nc))
    ax.set_yticks(np.arange(nr))
    if col_labels is not None:
        ax.set_xticklabels(col_labels, rotation=45, ha="right", fontsize=th.fs_tick)
    if row_labels is not None:
        ax.set_yticklabels(row_labels, fontsize=th.fs_tick)
    ax.tick_params(length=0)
    for sp in ax.spines.values():
        sp.set_visible(False)
    if grid:
        ax.set_xticks(np.arange(-0.5, nc, 1), minor=True)
        ax.set_yticks(np.arange(-0.5, nr, 1), minor=True)
        ax.grid(which="minor", color="white", linewidth=0.6)
        ax.tick_params(which="minor", length=0)
    if annotate:
        fs = annot_size or th.fs_annot
        for i in range(nr):
            for j in range(nc):
                v = matrix[i, j]
                if not np.isfinite(v):
                    continue
                r, g, b, _ = im.cmap(im.norm(v))
                lum = 0.299 * r + 0.587 * g + 0.114 * b
                ax.text(
                    j,
                    i,
                    annot_fmt.format(v),
                    ha="center",
                    va="center",
                    fontsize=fs,
                    color="white" if lum < 0.55 else "#222222",
                )
    if title:
        ax.set_title(title, fontsize=th.fs_title, pad=3)
    return ax, im

image_grid

image_grid(images: Sequence[ArrayLike], *, nrows: int | None = None, ncols: int | None = None, cmap: str = 'inferno', vmin: float | None = None, vmax: float | None = None, norm: Normalize | None = None, robust: bool = True, rgb: bool = False, stretch: bool = False, shared_scale: bool = True, colorbar_mode: Literal['single', 'row', 'each', 'none'] = 'single', colorbar_label: str = '', titles: Sequence[str] | None = None, row_labels: Sequence[str] | None = None, col_labels: Sequence[str] | None = None, width: str | float = 'full', panel_aspect: float = 1.0, wspace: float = 0.06, hspace: float = 0.1, fig: Figure | None = None) -> tuple[Figure, np.ndarray]

Lay out images in a grid, with one, per-row, per-image or no colorbars.

Parameters:

Name Type Description Default
images Sequence[ArrayLike]

2-D intensity or 3-D RGB arrays, filled row by row.

required
nrows int | None

Number of rows; inferred (near-square grid) if omitted.

None
ncols int | None

Number of columns; inferred if omitted.

None
cmap str

Colormap name for intensity images.

'inferno'
vmin float | None

Lower colour limit; overrides the shared scale.

None
vmax float | None

Upper colour limit; overrides the shared scale.

None
norm Normalize | None

Colour normalisation; overrides vmin/vmax.

None
robust bool

Base the shared scale on the 2nd/98th percentiles, not min/max.

True
rgb bool

Treat every image as a colour image.

False
stretch bool

Contrast-stretch RGB images, as in :func:image.

False
shared_scale bool

Use one colour range across all intensity images.

True
colorbar_mode Literal['single', 'row', 'each', 'none']

"single" (one bar for the grid), "row" (one per row), "each" (one per image) or "none".

'single'
colorbar_label str

Label of every colorbar.

''
titles Sequence[str] | None

Per-image titles.

None
row_labels Sequence[str] | None

Labels left of the first column.

None
col_labels Sequence[str] | None

Labels above the first row.

None
width str | float

Figure width, a named width or inches (see :func:nicefigs.figure_size).

'full'
panel_aspect float

Height / width of each panel.

1.0
wspace float

Horizontal gap between panels, as a fraction of panel width.

0.06
hspace float

Vertical gap between panels, as a fraction of panel height.

0.1
fig Figure | None

Existing figure to draw into; a new one is created if None.

None

Returns:

Type Description
Figure

(fig, axes) with axes a (nrows, ncols) object array; cells

ndarray

beyond len(images) are switched off.

Source code in src/nicefigs/images.py
def image_grid(
    images: Sequence[ArrayLike],
    *,
    nrows: int | None = None,
    ncols: int | None = None,
    cmap: str = "inferno",
    vmin: float | None = None,
    vmax: float | None = None,
    norm: Normalize | None = None,
    robust: bool = True,
    rgb: bool = False,
    stretch: bool = False,
    shared_scale: bool = True,
    colorbar_mode: Literal["single", "row", "each", "none"] = "single",
    colorbar_label: str = "",
    titles: Sequence[str] | None = None,
    row_labels: Sequence[str] | None = None,
    col_labels: Sequence[str] | None = None,
    width: str | float = "full",
    panel_aspect: float = 1.0,
    wspace: float = 0.06,
    hspace: float = 0.1,
    fig: Figure | None = None,
) -> tuple[Figure, np.ndarray]:
    """Lay out images in a grid, with one, per-row, per-image or no colorbars.

    Args:
        images: 2-D intensity or 3-D RGB arrays, filled row by row.
        nrows: Number of rows; inferred (near-square grid) if omitted.
        ncols: Number of columns; inferred if omitted.
        cmap: Colormap name for intensity images.
        vmin: Lower colour limit; overrides the shared scale.
        vmax: Upper colour limit; overrides the shared scale.
        norm: Colour normalisation; overrides ``vmin``/``vmax``.
        robust: Base the shared scale on the 2nd/98th percentiles, not min/max.
        rgb: Treat every image as a colour image.
        stretch: Contrast-stretch RGB images, as in :func:`image`.
        shared_scale: Use one colour range across all intensity images.
        colorbar_mode: ``"single"`` (one bar for the grid), ``"row"`` (one per
            row), ``"each"`` (one per image) or ``"none"``.
        colorbar_label: Label of every colorbar.
        titles: Per-image titles.
        row_labels: Labels left of the first column.
        col_labels: Labels above the first row.
        width: Figure width, a named width or inches (see :func:`nicefigs.figure_size`).
        panel_aspect: Height / width of each panel.
        wspace: Horizontal gap between panels, as a fraction of panel width.
        hspace: Vertical gap between panels, as a fraction of panel height.
        fig: Existing figure to draw into; a new one is created if ``None``.

    Returns:
        ``(fig, axes)`` with ``axes`` a ``(nrows, ncols)`` object array; cells
        beyond ``len(images)`` are switched off.
    """
    from .figure import figure_size
    from .style import style

    th = get_theme()
    n = len(images)
    if nrows is None and ncols is None:
        ncols = int(np.ceil(np.sqrt(n)))
        nrows = int(np.ceil(n / ncols))
    elif ncols is None:
        ncols = int(np.ceil(n / nrows))
    elif nrows is None:
        nrows = int(np.ceil(n / ncols))

    if shared_scale and not rgb and norm is None and vmin is None and vmax is None:
        allv = np.concatenate([np.asarray(im, float).ravel() for im in images])
        vmin, vmax = robust_limits(allv) if robust else (float(np.nanmin(allv)), float(np.nanmax(allv)))

    size = figure_size(width, panel_aspect=panel_aspect, nrows=nrows, ncols=ncols)
    with style(th):
        if fig is None:
            fig = plt.figure(figsize=size, dpi=th.dpi)
        axes = np.empty((nrows, ncols), dtype=object)
        mappables = np.empty((nrows, ncols), dtype=object)
        gs = fig.add_gridspec(nrows, ncols, wspace=wspace, hspace=hspace)
        for k in range(nrows * ncols):
            i, j = divmod(k, ncols)
            ax = fig.add_subplot(gs[i, j])
            axes[i, j] = ax
            if k < n:
                _, m = image(
                    images[k],
                    cmap=cmap,
                    vmin=vmin,
                    vmax=vmax,
                    norm=norm,
                    rgb=rgb,
                    stretch=stretch,
                    title=(titles[k] if titles is not None and k < len(titles) else ""),
                    ax=ax,
                )
                mappables[i, j] = m
                if colorbar_mode == "each" and m is not None:
                    colorbar(m, ax, label=colorbar_label, thickness=0.02)
            else:
                ax.axis("off")
            if row_labels is not None and j == 0 and i < len(row_labels):
                ax.set_ylabel(row_labels[i], fontsize=th.fs_axis_label)
            if col_labels is not None and i == 0 and j < len(col_labels):
                ax.set_title(col_labels[j], fontsize=th.fs_axis_label, pad=3)

        valid = [m for m in mappables.ravel() if m is not None]
        if colorbar_mode == "single" and valid:
            colorbar(valid[0], list(axes.ravel()), label=colorbar_label, location="right")
        elif colorbar_mode == "row":
            for i in range(nrows):
                row_m = [m for m in mappables[i] if m is not None]
                if row_m:
                    colorbar(row_m[0], list(axes[i]), label=colorbar_label, location="right")
    return fig, axes

nicefigs.atlas

atlas

Atlas panels: one figure that summarises a mapped quantity with its examples.

:func:component_panel composes a world map of a per-location quantity, a histogram of its values, a text box with free-form notes and a label slot, and rows of example image chips with a coarse grid of the quantity overlaid.

component_panel

component_panel(lon: ArrayLike, lat: ArrayLike, values: ArrayLike, *, chips: ArrayLike | None = None, overlays: ArrayLike | None = None, window: tuple[int, int, int] | None = None, hist_edges: ArrayLike | None = None, hist_counts: ArrayLike | None = None, text: Sequence[str] = (), label: str = '', title: str = '', map_label: str = '', cmap: str | Colormap = 'inferno', overlay_cmap: str | Colormap = 'inferno', overlay_alpha: float = 0.6, overlay_mask_below: float | None = None, extra_rows: Sequence[Mapping[str, Any]] = (), width: float | str = 'full', fig: Figure | None = None) -> tuple[Figure, dict[str, Any]]

Draw the atlas panel of one component.

Top row: a Robinson map of values at lon/lat, the histogram given by hist_edges/hist_counts, and a text box (text lines, then the label slot, left blank as label: ____ when label is empty). Bottom row: one axes per chip, its RGB stretched per chip, with overlays[i] drawn over window by :func:nicefigs.overlay_grid on one shared colour scale.

Parameters:

Name Type Description Default
lon ArrayLike

(n,) longitudes in degrees east.

required
lat ArrayLike

(n,) latitudes in degrees north.

required
values ArrayLike

(n,) values of the component at those locations.

required
chips ArrayLike | None

(m, H, W, 3) RGB chips; None draws no bottom row.

None
overlays ArrayLike | None

(m, h, w) coarse grids, one per chip.

None
window tuple[int, int, int] | None

(row, col, size) of the overlaid window in chip pixels; required with overlays.

None
hist_edges ArrayLike | None

Histogram bin edges, as returned by :func:numpy.histogram.

None
hist_counts ArrayLike | None

Histogram counts, as returned by :func:numpy.histogram.

None
text Sequence[str]

Lines for the text box; wrapped to fit, shrinking the font if needed.

()
label str

The component's short label.

''
title str

Panel title, above the map.

''
map_label str

Colorbar label of the map.

''
cmap str | Colormap

Colormap of the map.

'inferno'
overlay_cmap str | Colormap

Colormap of the overlays.

'inferno'
overlay_alpha float

Opacity of the overlays.

0.6
overlay_mask_below float | None

Overlay cells at or below this value are transparent.

None
extra_rows Sequence[Mapping[str, Any]]

Further chip rows below the first, each a dict with chips, optional overlays (one per chip, needs window) and optional title (shown above the row's first chip).

()
width float | str

Figure width (inches or a :data:nicefigs.figure.WIDTHS name).

'full'
fig Figure | None

Draw into this figure instead of a new one.

None

Returns:

Type Description
Figure

(fig, axes), axes a dict with map, hist, text, the list chips

dict[str, Any]

and extra_rows (one list of axes per extra row).

Raises:

Type Description
ValueError

If overlays come without a window or not one per chip.

Source code in src/nicefigs/atlas.py
def component_panel(
    lon: ArrayLike,
    lat: ArrayLike,
    values: ArrayLike,
    *,
    chips: ArrayLike | None = None,
    overlays: ArrayLike | None = None,
    window: tuple[int, int, int] | None = None,
    hist_edges: ArrayLike | None = None,
    hist_counts: ArrayLike | None = None,
    text: Sequence[str] = (),
    label: str = "",
    title: str = "",
    map_label: str = "",
    cmap: str | Colormap = "inferno",
    overlay_cmap: str | Colormap = "inferno",
    overlay_alpha: float = 0.6,
    overlay_mask_below: float | None = None,
    extra_rows: Sequence[Mapping[str, Any]] = (),
    width: float | str = "full",
    fig: Figure | None = None,
) -> tuple[Figure, dict[str, Any]]:
    """Draw the atlas panel of one component.

    Top row: a Robinson map of ``values`` at ``lon``/``lat``, the histogram given by
    ``hist_edges``/``hist_counts``, and a text box (``text`` lines, then the label
    slot, left blank as ``label: ____`` when ``label`` is empty). Bottom row: one
    axes per chip, its RGB stretched per chip, with ``overlays[i]`` drawn over
    ``window`` by :func:`nicefigs.overlay_grid` on one shared colour scale.

    Args:
        lon: ``(n,)`` longitudes in degrees east.
        lat: ``(n,)`` latitudes in degrees north.
        values: ``(n,)`` values of the component at those locations.
        chips: ``(m, H, W, 3)`` RGB chips; ``None`` draws no bottom row.
        overlays: ``(m, h, w)`` coarse grids, one per chip.
        window: ``(row, col, size)`` of the overlaid window in chip pixels; required
            with ``overlays``.
        hist_edges: Histogram bin edges, as returned by :func:`numpy.histogram`.
        hist_counts: Histogram counts, as returned by :func:`numpy.histogram`.
        text: Lines for the text box; wrapped to fit, shrinking the font if needed.
        label: The component's short label.
        title: Panel title, above the map.
        map_label: Colorbar label of the map.
        cmap: Colormap of the map.
        overlay_cmap: Colormap of the overlays.
        overlay_alpha: Opacity of the overlays.
        overlay_mask_below: Overlay cells at or below this value are transparent.
        extra_rows: Further chip rows below the first, each a dict with ``chips``,
            optional ``overlays`` (one per chip, needs ``window``) and optional ``title``
            (shown above the row's first chip).
        width: Figure width (inches or a :data:`nicefigs.figure.WIDTHS` name).
        fig: Draw into this figure instead of a new one.

    Returns:
        ``(fig, axes)``, ``axes`` a dict with ``map``, ``hist``, ``text``, the list ``chips``
        and ``extra_rows`` (one list of axes per extra row).

    Raises:
        ValueError: If ``overlays`` come without a ``window`` or not one per chip.
    """
    n_chips = 0 if chips is None else len(chips)
    for c, o in [(chips, overlays)] + [(r["chips"], r.get("overlays")) for r in extra_rows]:
        if o is not None:
            if window is None:
                raise ValueError("overlays need the chip window they cover")
            if len(o) != (0 if c is None else len(c)):
                raise ValueError(f"{len(o)} overlays for {0 if c is None else len(c)} chips")
    th = get_theme()
    with style(th):
        if fig is None:
            aspect = (0.62 if n_chips else 0.42) + 0.2 * len(extra_rows)
            fig = plt.figure(figsize=figure_size(width, aspect=aspect), dpi=th.dpi)
        heights = [1.0] + ([0.62] if n_chips else []) + [0.62] * len(extra_rows)
        gs = fig.add_gridspec(len(heights), 12, height_ratios=heights, hspace=0.12, wspace=0.25)
        _, ax_map, _ = world_map(
            lon,
            lat,
            values,
            ax=None,
            fig=fig,
            subplot_spec=gs[0, 0:7],
            kind="scatter",
            cmap=cmap,
            inset_hist=False,
            cbar=True,
            colorbar_label=map_label,
            size=3.0,
        )
        if title:
            clean_title(ax_map, title)
        side = gs[0, 9:12].subgridspec(2, 1, height_ratios=[1.0, 1.0], hspace=0.35)
        ax_hist = fig.add_subplot(side[0])
        if hist_edges is not None and len(hist_edges):
            ax_hist.stairs(hist_counts, hist_edges, fill=True, color=COLORS["primary"], alpha=0.85)
        ax_hist.set_xlabel("value", fontsize=th.fs_axis_label)
        ax_hist.set_ylabel("count", fontsize=th.fs_axis_label)
        ax_hist.tick_params(labelsize=th.fs_tick)
        ax_hist.yaxis.set_major_locator(MaxNLocator(3))
        ax_hist.yaxis.set_major_formatter(
            EngFormatter(sep="")
        )  # 20k, not 20000: keeps clear of the map's colorbar
        ax_text = fig.add_subplot(side[1])
        ax_text.axis("off")
        lines = [*text, f"label: {label}" if label else "label: ____________"]
        _fit_text(fig, ax_text, ax_hist, lines, fontsize=th.fs_annot, color=COLORS["neutral"])
        rows = ([{"chips": chips, "overlays": overlays, "title": ""}] if n_chips else []) + list(extra_rows)
        all_overlays = [np.asarray(r["overlays"], float) for r in rows if r.get("overlays") is not None]
        vmax = (
            float(np.nanpercentile(np.concatenate([o.ravel() for o in all_overlays]), 98))
            if all_overlays
            else None
        )
        vmin = 0.0 if all_overlays and min(np.nanmin(o) for o in all_overlays) >= 0 else None
        drawn = []
        for j, row in enumerate(rows):
            span = 12 // max(len(row["chips"]), 1)
            axes_row = []
            for i, chip in enumerate(row["chips"]):
                ax = fig.add_subplot(gs[1 + j, i * span : (i + 1) * span])
                image(chip, rgb=True, stretch=True, ax=ax)
                if row.get("overlays") is not None:
                    overlay_grid(
                        ax,
                        row["overlays"][i],
                        window=window,
                        cmap=overlay_cmap,
                        vmin=vmin,
                        vmax=vmax,
                        alpha=overlay_alpha,
                        mask_below=overlay_mask_below,
                    )
                if i == 0 and row.get("title"):
                    ax.set_title(row["title"], fontsize=th.fs_annot, loc="left", pad=2)
                axes_row.append(ax)
            drawn.append(axes_row)
        chip_axes = drawn[0] if n_chips else []
        extra_axes = drawn[1:] if n_chips else drawn
    return fig, {
        "map": ax_map,
        "hist": ax_hist,
        "text": ax_text,
        "chips": chip_axes,
        "extra_rows": extra_axes,
    }

nicefigs.scatter

scatter

Scatter plots that encode extra variables as marker shape, size and colour.

MARKERS module-attribute

MARKERS: list[str] = ['o', 's', '^', 'D', 'v', 'P', 'X', '*', '<', '>']

scatter

scatter(x: ArrayLike, y: ArrayLike, *, ax: Axes | None = None, color: Any = None, hue: ArrayLike | None = None, hue_cmap: str = 'viridis', hue_label: str = '', vmin: float | None = None, vmax: float | None = None, size: ArrayLike | None = None, size_range: tuple[float, float] = (8, 120), size_label: str = '', group: ArrayLike | None = None, group_markers: Mapping[Any, str] | None = None, group_colors: Mapping[Any, Any] | None = None, marker: str = 'o', alpha: float = 0.85, edgecolor: Any = 'none', linewidth: float = 0.0, xerr: ArrayLike | None = None, yerr: ArrayLike | None = None, point_labels: Sequence[Any] | None = None, label_top: int = 0, legend: bool = True, colorbar: bool = False, **kwargs: Any) -> tuple[Axes, PathCollection | dict[Any, PathCollection]]

Scatter y against x with optional shape, size and colour encodings.

Parameters:

Name Type Description Default
ax Axes | None

Axes to draw on; a new figure is created if None.

None
x ArrayLike

Point x coordinates, shape (n,).

required
y ArrayLike

Point y coordinates, shape (n,).

required
color Any

Single colour for all points; ignored when hue or group is set.

None
hue ArrayLike | None

Continuous values, shape (n,), mapped to colour on hue_cmap.

None
hue_cmap str

Colormap name for hue.

'viridis'
hue_label str

Colorbar label for hue.

''
vmin float | None

Lower hue colour limit.

None
vmax float | None

Upper hue colour limit.

None
size ArrayLike | None

Continuous values, shape (n,), mapped to marker area.

None
size_range tuple[float, float]

Marker area range (pt²) that size is scaled into.

(8, 120)
size_label str

Title of the size legend.

''
group ArrayLike | None

Categorical labels, shape (n,), mapped to marker shape and, unless hue is set, colour.

None
group_markers Mapping[Any, str] | None

{group: marker} overrides; defaults rotate :data:MARKERS.

None
group_colors Mapping[Any, Any] | None

{group: colour} overrides; defaults to the theme palette.

None
marker str

Marker shape when group is not set.

'o'
alpha float

Marker opacity.

0.85
edgecolor Any

Marker edge colour.

'none'
linewidth float

Marker edge width (pt).

0.0
xerr ArrayLike | None

Per-point x error bars, drawn faint behind the markers.

None
yerr ArrayLike | None

Per-point y error bars, drawn faint behind the markers.

None
point_labels Sequence[Any] | None

Per-point text labels, shape (n,).

None
label_top int

Annotate only the label_top points furthest from the centroid with point_labels; 0 annotates none.

0
legend bool

Add group and size legends where they apply.

True
colorbar bool

Add a colorbar for hue.

False
**kwargs Any

Forwarded to :meth:matplotlib.axes.Axes.scatter.

{}

Returns:

Type Description
tuple[Axes, PathCollection | dict[Any, PathCollection]]

(ax, artists): the axes and the scatter collection, or {group: collection} when group is given.

Source code in src/nicefigs/scatter.py
def scatter(
    x: ArrayLike,
    y: ArrayLike,
    *,
    ax: Axes | None = None,
    color: Any = None,
    hue: ArrayLike | None = None,
    hue_cmap: str = "viridis",
    hue_label: str = "",
    vmin: float | None = None,
    vmax: float | None = None,
    size: ArrayLike | None = None,
    size_range: tuple[float, float] = (8, 120),
    size_label: str = "",
    group: ArrayLike | None = None,
    group_markers: Mapping[Any, str] | None = None,
    group_colors: Mapping[Any, Any] | None = None,
    marker: str = "o",
    alpha: float = 0.85,
    edgecolor: Any = "none",
    linewidth: float = 0.0,
    xerr: ArrayLike | None = None,
    yerr: ArrayLike | None = None,
    point_labels: Sequence[Any] | None = None,
    label_top: int = 0,
    legend: bool = True,
    colorbar: bool = False,
    **kwargs: Any,
) -> tuple[Axes, PathCollection | dict[Any, PathCollection]]:
    """Scatter ``y`` against ``x`` with optional shape, size and colour encodings.

    Args:
        ax: Axes to draw on; a new figure is created if ``None``.
        x: Point x coordinates, shape ``(n,)``.
        y: Point y coordinates, shape ``(n,)``.
        color: Single colour for all points; ignored when ``hue`` or ``group`` is set.
        hue: Continuous values, shape ``(n,)``, mapped to colour on ``hue_cmap``.
        hue_cmap: Colormap name for ``hue``.
        hue_label: Colorbar label for ``hue``.
        vmin: Lower ``hue`` colour limit.
        vmax: Upper ``hue`` colour limit.
        size: Continuous values, shape ``(n,)``, mapped to marker area.
        size_range: Marker area range (pt²) that ``size`` is scaled into.
        size_label: Title of the size legend.
        group: Categorical labels, shape ``(n,)``, mapped to marker shape and,
            unless ``hue`` is set, colour.
        group_markers: ``{group: marker}`` overrides; defaults rotate :data:`MARKERS`.
        group_colors: ``{group: colour}`` overrides; defaults to the theme palette.
        marker: Marker shape when ``group`` is not set.
        alpha: Marker opacity.
        edgecolor: Marker edge colour.
        linewidth: Marker edge width (pt).
        xerr: Per-point x error bars, drawn faint behind the markers.
        yerr: Per-point y error bars, drawn faint behind the markers.
        point_labels: Per-point text labels, shape ``(n,)``.
        label_top: Annotate only the ``label_top`` points furthest from the
            centroid with ``point_labels``; ``0`` annotates none.
        legend: Add group and size legends where they apply.
        colorbar: Add a colorbar for ``hue``.
        **kwargs: Forwarded to :meth:`matplotlib.axes.Axes.scatter`.

    Returns:
        ``(ax, artists)``: the axes and the scatter collection, or ``{group: collection}`` when ``group`` is given.
    """
    if ax is None:
        _, ax = subplots()
    th = get_theme()
    x = np.asarray(x, dtype=float)
    y = np.asarray(y, dtype=float)
    s = _scale_size(size, size_range) if size is not None else None
    s_default = kwargs.pop("s", 36)

    if xerr is not None or yerr is not None:
        ax.errorbar(
            x,
            y,
            xerr=xerr,
            yerr=yerr,
            fmt="none",
            ecolor="#999999",
            elinewidth=0.6,
            capsize=1.2,
            zorder=1,
            alpha=0.7,
        )

    handles = []
    mappable = None

    if group is not None:
        group = np.asarray(group)
        cats = list(dict.fromkeys(group.tolist()))
        gm = group_markers or {c: MARKERS[i % len(MARKERS)] for i, c in enumerate(cats)}
        gc = group_colors or (dict(zip(cats, qualitative(len(cats)))) if hue is None else None)
        colls = {}
        for c in cats:
            m = group == c
            kwc = dict(marker=gm[c], alpha=alpha, edgecolors=edgecolor, linewidths=linewidth)
            kwc["s"] = s[m] if s is not None else s_default
            if hue is not None:
                kwc.update(c=np.asarray(hue)[m], cmap=resolve_cmap(hue_cmap), vmin=vmin, vmax=vmax)
            else:
                kwc["color"] = gc[c]
            colls[c] = ax.scatter(x[m], y[m], **kwc, **kwargs)
            mappable = colls[c]
            handles.append(
                mlines.Line2D(
                    [],
                    [],
                    color=(gc[c] if gc else th.text_color),
                    marker=gm[c],
                    linestyle="None",
                    markersize=np.sqrt(40),
                    label=str(c),
                )
            )
        result = colls
    else:
        kwc = dict(marker=marker, alpha=alpha, linewidths=linewidth)
        if MarkerStyle(marker).is_filled():  # an unfilled marker ("x", "+") is drawn by its edge colour
            kwc["edgecolors"] = edgecolor
        kwc["s"] = s if s is not None else s_default
        if hue is not None:
            kwc.update(c=np.asarray(hue, dtype=float), cmap=resolve_cmap(hue_cmap), vmin=vmin, vmax=vmax)
        else:
            kwc["color"] = color or th.palette[0]
        mappable = ax.scatter(x, y, **kwc, **kwargs)
        result = mappable

    if legend and group is not None:
        leg = ax.legend(
            handles=handles, loc="best", fontsize=th.fs_legend, title=None, handletextpad=0.4, borderpad=0.4
        )
        ax.add_artist(leg)

    if legend and size is not None:
        _add_size_legend(ax, size, size_range, size_label, th)

    if colorbar and hue is not None and mappable is not None:
        from .colorbars import colorbar as _cb

        _cb(mappable, ax, label=hue_label)

    if point_labels is not None and label_top > 0:
        d = np.hypot(x - np.nanmean(x), y - np.nanmean(y))
        for i in np.argsort(d)[::-1][:label_top]:
            ax.annotate(
                str(point_labels[i]),
                (x[i], y[i]),
                fontsize=th.fs_annot,
                xytext=(3, 2),
                textcoords="offset points",
            )

    return ax, result

nicefigs.kde

kde

Predicted-vs-observed agreement plots with density shading, 1:1 line, fit and metrics.

agreement

agreement(x: ArrayLike, y: ArrayLike, *, ax: Axes | None = None, cmap: str = 'nice_teal', density: str = 'kde', gridsize: int = 60, outlier_z: float = 3.0, show_outliers: bool = True, one_to_one: bool = True, regression: bool = True, metrics: bool = True, xlabel: str = 'Observed', ylabel: str = 'Predicted', units: str | None = None, equal: bool = True, width: float | str = 'half', color_line: Any = None) -> tuple[Axes, dict[str, float]]

Density agreement plot of predicted y against observed x.

Layers a density of the bulk of the cloud, a dashed 1:1 line, outliers as faint points, a least-squares fit line and a metrics box (n, R², RMSE, bias). Non-finite pairs are dropped.

Parameters:

Name Type Description Default
x ArrayLike

Observed values, shape (n,).

required
y ArrayLike

Predicted values, shape (n,).

required
ax Axes | None

Axes to draw on; a new square figure is created if None.

None
cmap str

Colormap for the density shading.

'nice_teal'
density str

"kde" (gaussian-KDE contours, needs scipy; falls back to "hexbin" for degenerate clouds), "hexbin" or "hist".

'kde'
gridsize int

Grid resolution of the density.

60
outlier_z float

Points whose residual from the 1:1 line exceeds this many robust standard deviations are drawn as points instead of density.

3.0
show_outliers bool

Draw the outlier points.

True
one_to_one bool

Draw the 1:1 reference line.

True
regression bool

Draw the least-squares fit line.

True
metrics bool

Draw the metrics box.

True
xlabel str

x-axis label.

'Observed'
ylabel str

y-axis label.

'Predicted'
units str | None

Unit appended to both axis labels.

None
equal bool

Share limits on both axes and force a square aspect.

True
width float | str

Figure width (preset name or inches) when ax is None.

'half'
color_line Any

Colour of the fit line; defaults to the theme orange.

None

Returns:

Type Description
Axes

(ax, stats) with stats keys slope, intercept, r2,

dict[str, float]

rmse and bias (y - x, in data units), and n.

Source code in src/nicefigs/kde.py
def agreement(
    x: ArrayLike,
    y: ArrayLike,
    *,
    ax: Axes | None = None,
    cmap: str = "nice_teal",
    density: str = "kde",
    gridsize: int = 60,
    outlier_z: float = 3.0,
    show_outliers: bool = True,
    one_to_one: bool = True,
    regression: bool = True,
    metrics: bool = True,
    xlabel: str = "Observed",
    ylabel: str = "Predicted",
    units: str | None = None,
    equal: bool = True,
    width: float | str = "half",
    color_line: Any = None,
) -> tuple[Axes, dict[str, float]]:
    """Density agreement plot of predicted ``y`` against observed ``x``.

    Layers a density of the bulk of the cloud, a dashed 1:1 line, outliers as
    faint points, a least-squares fit line and a metrics box (n, R², RMSE, bias).
    Non-finite pairs are dropped.

    Args:
        x: Observed values, shape ``(n,)``.
        y: Predicted values, shape ``(n,)``.
        ax: Axes to draw on; a new square figure is created if ``None``.
        cmap: Colormap for the density shading.
        density: ``"kde"`` (gaussian-KDE contours, needs scipy; falls back to
            ``"hexbin"`` for degenerate clouds), ``"hexbin"`` or ``"hist"``.
        gridsize: Grid resolution of the density.
        outlier_z: Points whose residual from the 1:1 line exceeds this many
            robust standard deviations are drawn as points instead of density.
        show_outliers: Draw the outlier points.
        one_to_one: Draw the 1:1 reference line.
        regression: Draw the least-squares fit line.
        metrics: Draw the metrics box.
        xlabel: x-axis label.
        ylabel: y-axis label.
        units: Unit appended to both axis labels.
        equal: Share limits on both axes and force a square aspect.
        width: Figure width (preset name or inches) when ``ax`` is ``None``.
        color_line: Colour of the fit line; defaults to the theme orange.

    Returns:
        ``(ax, stats)`` with ``stats`` keys ``slope``, ``intercept``, ``r2``,
        ``rmse`` and ``bias`` (``y - x``, in data units), and ``n``.
    """
    th = get_theme()
    x = np.asarray(x, dtype=float)
    y = np.asarray(y, dtype=float)
    m = np.isfinite(x) & np.isfinite(y)
    x, y = x[m], y[m]

    if ax is None:
        from .figure import subplots

        _, ax = subplots(width=width, aspect=1.0)

    a, b, r2, rmse, bias = _linfit(x, y)

    resid = y - x
    rstd = np.median(np.abs(resid - np.median(resid))) * 1.4826 or resid.std() or 1.0
    is_out = np.abs(resid - np.median(resid)) > outlier_z * rstd
    inl = ~is_out

    cm = resolve_cmap(cmap)
    if density == "kde":
        density = _kde_density(ax, x[inl], y[inl], x, y, cm, gridsize)
    if density == "hexbin":
        ax.hexbin(x[inl], y[inl], gridsize=gridsize, cmap=cm, mincnt=1, linewidths=0, zorder=1)
    elif density == "hist":
        ax.hist2d(x[inl], y[inl], bins=gridsize, cmap=cm, cmin=1, zorder=1)

    if show_outliers and is_out.any():
        ax.scatter(
            x[is_out], y[is_out], s=5, c=COLORS["neutral"], alpha=0.5, linewidths=0, zorder=2, rasterized=True
        )

    lo = min(x.min(), y.min())
    hi = max(x.max(), y.max())
    pad = 0.03 * (hi - lo)
    lim = (lo - pad, hi + pad)
    if one_to_one:
        ax.plot(lim, lim, ls="--", lw=0.8, color=COLORS["neutral"], zorder=3, label="1:1")
    if regression:
        xs = np.array(lim)
        from .artists import NICE_COLORS

        ax.plot(
            xs, a * xs + b, ls="-", lw=1.2, color=color_line or NICE_COLORS["orange"], zorder=4, label="fit"
        )

    if equal:
        ax.set_xlim(*lim)
        ax.set_ylim(*lim)
        ax.set_aspect("equal")

    from .units import axis_label

    ax.set_xlabel(axis_label(xlabel, units))
    ax.set_ylabel(axis_label(ylabel, units))

    stats = dict(
        slope=float(a), intercept=float(b), r2=float(r2), rmse=float(rmse), bias=float(bias), n=int(x.size)
    )
    if metrics:
        txt = f"n = {stats['n']}\nR² = {fmt_val(r2)}\nRMSE = {fmt_val(rmse)}\nbias = {fmt_val(bias)}"
        ax.text(
            0.04,
            0.96,
            txt,
            transform=ax.transAxes,
            va="top",
            ha="left",
            fontsize=th.fs_annot,
            linespacing=1.35,
            bbox=dict(boxstyle="round,pad=0.35", fc="white", ec="#cccccc", lw=0.4, alpha=0.85),
        )
    return ax, stats

nicefigs.timeseries

timeseries

Timeseries of named series against dates, with compact date axes and bands.

timeseries

timeseries(dates: Sequence[Any] | ArrayLike, series: Mapping[str, ArrayLike] | ArrayLike, *, ax: Axes | None = None, bands: Mapping[str, Any] | None = None, colors: Mapping[str, Any] | None = None, styles: Mapping[str, str] | None = None, linewidth: float | None = None, obs: tuple[Any, ArrayLike] | Mapping[str, tuple[Any, ArrayLike]] | None = None, obs_label: str = 'obs', obs_color: Any = '#222222', obs_size: float = 9, obs_marker: str = 'x', obs_hue: ArrayLike | Mapping[str, ArrayLike] | None = None, obs_cmap: str = 'viridis', obs_vmin: float | None = None, obs_vmax: float | None = None, obs_hue_label: str = '', obs_colorbar: bool = False, annotation: str = '', ylabel: str = '', yunits: str | None = None, xlabel: str = '', title: str = '', legend: bool = True, highlight: str | None = None, marker: str | None = None, width: float | str = 'full', aspect: float = 0.4, band_alpha: float = 0.18) -> Axes

Plot named series against dates on a date axis sized to the span.

Parameters:

Name Type Description Default
dates Sequence[Any] | ArrayLike

Shared datetime/date/datetime64 x values.

required
series Mapping[str, ArrayLike] | ArrayLike

{name: values}, or a single array for one unnamed series.

required
ax Axes | None

Axes to draw on; a new figure is created if None.

None
bands Mapping[str, Any] | None

{name: (lower, upper)} for one uncertainty band, or {name: [(lo1, hi1), (lo2, hi2), ...]} for nested bands. Nested bands are drawn widest first with opacity rising toward the centre, in any input order.

None
colors Mapping[str, Any] | None

{name: colour}; defaults to the theme palette.

None
styles Mapping[str, str] | None

{name: linestyle}.

None
linewidth float | None

Line width (pt); defaults to the theme's.

None
obs tuple[Any, ArrayLike] | Mapping[str, tuple[Any, ArrayLike]] | None

Sparse observations drawn as markers, (dates, values) or {label: (dates, values)}.

None
obs_label str

Legend label when obs is a single tuple.

'obs'
obs_color Any

Marker colour of obs without obs_hue.

'#222222'
obs_size float

Marker area of obs (pt²).

9
obs_marker str

Marker shape of obs.

'x'
obs_hue ArrayLike | Mapping[str, ArrayLike] | None

Continuous values colouring the obs markers on obs_cmap; an array for a single obs tuple, or {label: values} matching an obs mapping.

None
obs_cmap str

Colormap for obs_hue.

'viridis'
obs_vmin float | None

Lower obs_hue colour limit.

None
obs_vmax float | None

Upper obs_hue colour limit.

None
obs_hue_label str

Colorbar label for obs_hue.

''
obs_colorbar bool

Add a colorbar for obs_hue.

False
annotation str

Text box in the upper-left corner.

''
ylabel str

y-axis label.

''
yunits str | None

Units appended to ylabel.

None
xlabel str

x-axis label.

''
title str

Left-aligned panel title.

''
legend bool

Add a legend when there is more than one labelled entry.

True
highlight str | None

Name of a series to draw thicker and on top.

None
marker str | None

Marker shape on the series lines.

None
width float | str

Figure width (preset name or inches) when ax is None.

'full'
aspect float

Figure height / width when ax is None.

0.4
band_alpha float

Opacity of a single band; the innermost of nested bands.

0.18

Returns:

Type Description
Axes

The axes.

Source code in src/nicefigs/timeseries.py
def timeseries(
    dates: Sequence[Any] | ArrayLike,
    series: Mapping[str, ArrayLike] | ArrayLike,
    *,
    ax: Axes | None = None,
    bands: Mapping[str, Any] | None = None,
    colors: Mapping[str, Any] | None = None,
    styles: Mapping[str, str] | None = None,
    linewidth: float | None = None,
    obs: tuple[Any, ArrayLike] | Mapping[str, tuple[Any, ArrayLike]] | None = None,
    obs_label: str = "obs",
    obs_color: Any = "#222222",
    obs_size: float = 9,
    obs_marker: str = "x",
    obs_hue: ArrayLike | Mapping[str, ArrayLike] | None = None,
    obs_cmap: str = "viridis",
    obs_vmin: float | None = None,
    obs_vmax: float | None = None,
    obs_hue_label: str = "",
    obs_colorbar: bool = False,
    annotation: str = "",
    ylabel: str = "",
    yunits: str | None = None,
    xlabel: str = "",
    title: str = "",
    legend: bool = True,
    highlight: str | None = None,
    marker: str | None = None,
    width: float | str = "full",
    aspect: float = 0.4,
    band_alpha: float = 0.18,
) -> Axes:
    """Plot named series against ``dates`` on a date axis sized to the span.

    Args:
        dates: Shared ``datetime``/``date``/``datetime64`` x values.
        series: ``{name: values}``, or a single array for one unnamed series.
        ax: Axes to draw on; a new figure is created if ``None``.
        bands: ``{name: (lower, upper)}`` for one uncertainty band, or
            ``{name: [(lo1, hi1), (lo2, hi2), ...]}`` for nested bands. Nested
            bands are drawn widest first with opacity rising toward the centre,
            in any input order.
        colors: ``{name: colour}``; defaults to the theme palette.
        styles: ``{name: linestyle}``.
        linewidth: Line width (pt); defaults to the theme's.
        obs: Sparse observations drawn as markers, ``(dates, values)`` or
            ``{label: (dates, values)}``.
        obs_label: Legend label when ``obs`` is a single tuple.
        obs_color: Marker colour of ``obs`` without ``obs_hue``.
        obs_size: Marker area of ``obs`` (pt²).
        obs_marker: Marker shape of ``obs``.
        obs_hue: Continuous values colouring the ``obs`` markers on ``obs_cmap``;
            an array for a single ``obs`` tuple, or ``{label: values}`` matching
            an ``obs`` mapping.
        obs_cmap: Colormap for ``obs_hue``.
        obs_vmin: Lower ``obs_hue`` colour limit.
        obs_vmax: Upper ``obs_hue`` colour limit.
        obs_hue_label: Colorbar label for ``obs_hue``.
        obs_colorbar: Add a colorbar for ``obs_hue``.
        annotation: Text box in the upper-left corner.
        ylabel: y-axis label.
        yunits: Units appended to ``ylabel``.
        xlabel: x-axis label.
        title: Left-aligned panel title.
        legend: Add a legend when there is more than one labelled entry.
        highlight: Name of a series to draw thicker and on top.
        marker: Marker shape on the series lines.
        width: Figure width (preset name or inches) when ``ax`` is ``None``.
        aspect: Figure height / width when ``ax`` is ``None``.
        band_alpha: Opacity of a single band; the innermost of nested bands.

    Returns:
        The axes.
    """
    th = get_theme()
    if not isinstance(series, Mapping):
        series = {"series": np.asarray(series)}
    names = list(series)
    if ax is None:
        from .figure import subplots

        _, ax = subplots(width=width, aspect=aspect)
    cols = colors or dict(zip(names, qualitative(len(names))))
    styles = styles or {}
    lw = linewidth if linewidth is not None else th.lw_line

    for name in names:
        vals = np.asarray(series[name], dtype=float)
        is_hi = name == highlight
        ax.plot(
            dates,
            vals,
            color=cols[name],
            lw=lw * (1.4 if is_hi else 1.0),
            linestyle=styles.get(name, "-"),
            zorder=(5 if is_hi else 3),
            label=name,
            marker=marker,
            markersize=3 if marker else 0,
        )
        if bands and name in bands:
            raw = bands[name]
            band_list = raw if isinstance(raw, list) else [raw]
            band_list = sorted(
                band_list,
                key=lambda b: float(np.nanmax(np.asarray(b[1], dtype=float) - np.asarray(b[0], dtype=float))),
                reverse=True,
            )
            n_bands = len(band_list)
            for bi, (lo, hi) in enumerate(band_list):
                a = band_alpha * (bi + 1) / n_bands if n_bands > 1 else band_alpha
                ax.fill_between(dates, lo, hi, color=cols[name], alpha=a, linewidth=0, zorder=1)

    proxy_handles = []
    if obs is not None:
        obs_map = obs if isinstance(obs, Mapping) else {obs_label: obs}
        hue_map = (
            {}
            if obs_hue is None
            else (obs_hue if isinstance(obs_hue, Mapping) else {next(iter(obs_map)): obs_hue})
        )
        # 'x' markers read cleanly against the model lines; thin stroke, no fill.
        lw = 0.7 if obs_marker in ("x", "+", ".", "1", "2") else 0
        hue_mappable = None
        for label, (odates, ovals) in obs_map.items():
            hue_vals = hue_map.get(label)
            if hue_vals is not None:
                # Per-point colour would give the legend a meaningless swatch, so draw
                # unlabelled and add one neutral proxy handle. scatter() casts x to
                # float, which datetimes reject, hence date2num.
                _, hue_mappable = _scatter(
                    mdates.date2num(odates),
                    np.asarray(ovals, dtype=float),
                    hue=hue_vals,
                    hue_cmap=obs_cmap,
                    hue_label=obs_hue_label,
                    vmin=obs_vmin,
                    vmax=obs_vmax,
                    s=obs_size,
                    marker=obs_marker,
                    alpha=0.8,
                    edgecolor="none",
                    linewidth=lw,
                    zorder=6,
                    legend=False,
                    ax=ax,
                )
                proxy_handles.append(
                    mlines.Line2D(
                        [],
                        [],
                        color=th.text_color,
                        marker=obs_marker,
                        linestyle="None",
                        markersize=np.sqrt(obs_size),
                        label=label,
                    )
                )
            else:
                ax.scatter(
                    odates,
                    np.asarray(ovals, dtype=float),
                    s=obs_size,
                    marker=obs_marker,
                    color=obs_color,
                    alpha=0.8,
                    linewidths=lw,
                    zorder=6,
                    label=label,
                )
        if obs_colorbar and hue_mappable is not None:
            from .colorbars import colorbar as _cb

            _cb(hue_mappable, ax, label=obs_hue_label)

    _format_date_axis(ax, dates)
    if xlabel:
        ax.set_xlabel(xlabel)
    ax.set_ylabel(axis_label(ylabel, yunits) if ylabel else "")
    if title:
        from .panels import clean_title

        clean_title(ax, title)
    if annotation:
        ax.text(
            0.02,
            0.97,
            annotation,
            transform=ax.transAxes,
            va="top",
            ha="left",
            fontsize=th.fs_annot,
            linespacing=1.3,
            bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="#cccccc", lw=0.4, alpha=0.85),
        )
    n_lab = len(names) + (0 if obs is None else (len(obs) if isinstance(obs, Mapping) else 1))
    if legend and n_lab > 1:
        if proxy_handles:
            handles, labels = ax.get_legend_handles_labels()
            handles = handles + proxy_handles
            labels = labels + [h.get_label() for h in proxy_handles]
            ax.legend(
                handles=handles,
                labels=labels,
                loc="best",
                fontsize=th.fs_legend,
                ncol=min(n_lab, 3),
                handlelength=1.2,
                columnspacing=1.0,
            )
        else:
            ax.legend(
                loc="best", fontsize=th.fs_legend, ncol=min(n_lab, 3), handlelength=1.2, columnspacing=1.0
            )
    ax.margins(x=0.01)
    return ax

nicefigs.lines

lines

Multi-series line plots against a numeric x (sweeps, thresholds, lead times).

lines

lines(x: ArrayLike | Mapping[str, ArrayLike], series: Mapping[str, ArrayLike], *, ax: Axes | None = None, colors: Mapping[str, Any] | None = None, styles: Mapping[str, str] | None = None, bands: Mapping[str, tuple[ArrayLike, ArrayLike]] | None = None, markers: str | Mapping[str, str] | None = None, logx: bool = False, logy: bool = False, logx_base: float = 10, baselines: Mapping[str, float] | None = None, baseline_colors: Mapping[str, Any] | None = None, xlabel: str = '', ylabel: str = '', xunits: str | None = None, yunits: str | None = None, xdirection: str | None = None, ydirection: str | None = None, title: str = '', legend: bool = True, band_alpha: float = 0.16, width: float | str = 'half', aspect: float = 0.72) -> Axes

Plot named series against a numeric x.

Use :func:nicefigs.timeseries when x holds dates.

Parameters:

Name Type Description Default
x ArrayLike | Mapping[str, ArrayLike]

Shared x values, or {name: x} for per-series x.

required
series Mapping[str, ArrayLike]

{name: y}, one line per entry.

required
ax Axes | None

Axes to draw on; a new figure is created if None.

None
colors Mapping[str, Any] | None

{name: colour}; defaults to the theme palette.

None
styles Mapping[str, str] | None

{name: linestyle}, a second encoding beside colour.

None
bands Mapping[str, tuple[ArrayLike, ArrayLike]] | None

{name: (lower, upper)} uncertainty shading.

None
markers str | Mapping[str, str] | None

One marker for all series, or {name: marker}.

None
logx bool

Log-scale the x-axis.

False
logy bool

Log-scale the y-axis.

False
logx_base float

Base of the log x-axis (e.g. 2).

10
baselines Mapping[str, float] | None

{label: y} labelled horizontal reference lines.

None
baseline_colors Mapping[str, Any] | None

{label: colour} for baselines; defaults to greys.

None
xlabel str

x-axis label.

''
ylabel str

y-axis label.

''
xunits str | None

Unit appended to xlabel.

None
yunits str | None

Unit appended to ylabel.

None
xdirection str | None

"lower_better" or "higher_better"; adds an arrow to xlabel.

None
ydirection str | None

"lower_better" or "higher_better"; adds an arrow to ylabel.

None
title str

Left-aligned panel title.

''
legend bool

Add a legend when there is more than one series.

True
band_alpha float

Opacity of bands.

0.16
width float | str

Figure width (preset name or inches) when ax is None.

'half'
aspect float

Figure height / width when ax is None.

0.72

Returns:

Type Description
Axes

The axes.

Source code in src/nicefigs/lines.py
def lines(
    x: ArrayLike | Mapping[str, ArrayLike],
    series: Mapping[str, ArrayLike],
    *,
    ax: Axes | None = None,
    colors: Mapping[str, Any] | None = None,
    styles: Mapping[str, str] | None = None,
    bands: Mapping[str, tuple[ArrayLike, ArrayLike]] | None = None,
    markers: str | Mapping[str, str] | None = None,
    logx: bool = False,
    logy: bool = False,
    logx_base: float = 10,
    baselines: Mapping[str, float] | None = None,
    baseline_colors: Mapping[str, Any] | None = None,
    xlabel: str = "",
    ylabel: str = "",
    xunits: str | None = None,
    yunits: str | None = None,
    xdirection: str | None = None,
    ydirection: str | None = None,
    title: str = "",
    legend: bool = True,
    band_alpha: float = 0.16,
    width: float | str = "half",
    aspect: float = 0.72,
) -> Axes:
    """Plot named series against a numeric ``x``.

    Use :func:`nicefigs.timeseries` when ``x`` holds dates.

    Args:
        x: Shared x values, or ``{name: x}`` for per-series x.
        series: ``{name: y}``, one line per entry.
        ax: Axes to draw on; a new figure is created if ``None``.
        colors: ``{name: colour}``; defaults to the theme palette.
        styles: ``{name: linestyle}``, a second encoding beside colour.
        bands: ``{name: (lower, upper)}`` uncertainty shading.
        markers: One marker for all series, or ``{name: marker}``.
        logx: Log-scale the x-axis.
        logy: Log-scale the y-axis.
        logx_base: Base of the log x-axis (e.g. ``2``).
        baselines: ``{label: y}`` labelled horizontal reference lines.
        baseline_colors: ``{label: colour}`` for ``baselines``; defaults to greys.
        xlabel: x-axis label.
        ylabel: y-axis label.
        xunits: Unit appended to ``xlabel``.
        yunits: Unit appended to ``ylabel``.
        xdirection: ``"lower_better"`` or ``"higher_better"``; adds an arrow to ``xlabel``.
        ydirection: ``"lower_better"`` or ``"higher_better"``; adds an arrow to ``ylabel``.
        title: Left-aligned panel title.
        legend: Add a legend when there is more than one series.
        band_alpha: Opacity of ``bands``.
        width: Figure width (preset name or inches) when ``ax`` is ``None``.
        aspect: Figure height / width when ``ax`` is ``None``.

    Returns:
        The axes.
    """
    th = get_theme()
    names = list(series)
    if ax is None:
        from .figure import subplots

        _, ax = subplots(width=width, aspect=aspect)
    cols = colors or dict(zip(names, qualitative(len(names))))
    styles = styles or {}
    xmap = x if isinstance(x, Mapping) else None

    for name in names:
        xs = np.asarray(xmap[name] if xmap else x, dtype=float)
        ys = np.asarray(series[name], dtype=float)
        mk = markers.get(name) if isinstance(markers, dict) else markers
        ax.plot(
            xs,
            ys,
            color=cols[name],
            linestyle=styles.get(name, "-"),
            lw=th.lw_line,
            marker=mk,
            markersize=3.5 if mk else 0,
            label=name,
            zorder=3,
        )
        if bands and name in bands:
            lo, hi = bands[name]
            ax.fill_between(xs, lo, hi, color=cols[name], alpha=band_alpha, linewidth=0, zorder=1)

    if logx:
        ax.set_xscale("log", base=logx_base)
    if logy:
        ax.set_yscale("log")

    if baselines:
        from .panels import reference_line

        bc = baseline_colors or {}
        for i, (label, val) in enumerate(baselines.items()):
            reference_line(
                ax,
                val,
                axis="y",
                label=label,
                fmt="{label} ({value:.3g})",
                color=bc.get(label, ["#888888", "#aaaaaa", "#666666"][i % 3]),
                linestyle=":",
            )

    if xlabel:
        ax.set_xlabel(axis_label(xlabel, xunits, direction=xdirection))
    if ylabel:
        ax.set_ylabel(axis_label(ylabel, yunits, direction=ydirection))
    if title:
        from .panels import clean_title

        clean_title(ax, title)
    ax.grid(alpha=0.15, linewidth=0.5)
    ax.set_axisbelow(True)
    if legend and len(names) > 1:
        ax.legend(loc="best", fontsize=th.fs_legend)
    return ax

nicefigs.spectra

spectra

Eigen-spectrum plots: eigenvalue decay against rank, or cumulative explained variance.

spectrum

spectrum(eigenvalues: ArrayLike | Sequence[ArrayLike] | Mapping[str, ArrayLike], *, ax: Axes | None = None, labels: Sequence[str] | None = None, colors: Mapping[str, Any] | Sequence[Any] | None = None, styles: Mapping[str, str] | None = None, normalize: bool = True, rtol: float = 1e-10, cumulative: bool = False, thresholds: Sequence[float] = (0.5, 0.9, 0.99), reference_alpha: float | None = None, xlabel: str = 'rank $k$', ylabel: str | None = None, title: str = '', legend: bool = True, width: float | str = 'half', aspect: float = 0.72) -> Axes

Plot eigenvalue spectra against rank k, counted from 1.

By default this draws the log-log decay of λ_k, or of λ_k / Σλ when normalize. With cumulative=True it draws the cumulative explained-variance fraction on a log rank axis instead, with one labelled horizontal guide per threshold and, for each series, a marker where it first reaches that threshold. Each spectrum is sorted descending, and its non-finite, non-positive and round-off-sized (λ ≤ rtol · λ_max) eigenvalues are dropped.

Parameters:

Name Type Description Default
eigenvalues ArrayLike | Sequence[ArrayLike] | Mapping[str, ArrayLike]

One 1-D array, a sequence of 1-D arrays, or {name: array}.

required
ax Axes | None

Axes to draw on; a new figure is created if None.

None
labels Sequence[str] | None

Series names for array or sequence input; overrides dict keys.

None
colors Mapping[str, Any] | Sequence[Any] | None

{name: colour} or a list in series order; defaults to :func:~nicefigs.qualitative.

None
styles Mapping[str, str] | None

{name: linestyle}.

None
normalize bool

Plot λ_k / Σλ instead of λ_k; ignored when cumulative.

True
rtol float

Relative floor below which eigenvalues count as numerical zeros; 0 keeps every positive eigenvalue.

1e-10
cumulative bool

Plot Σ_{j≤k} λ_j / Σλ instead of the spectrum.

False
thresholds Sequence[float]

Explained-variance fractions marked when cumulative.

(0.5, 0.9, 0.99)
reference_alpha float | None

Draw a k^-α power law through the plotted leading value of the first series; spectrum mode only.

None
xlabel str

x-axis label.

'rank $k$'
ylabel str | None

y-axis label; defaults to the plotted quantity.

None
title str

Panel title.

''
legend bool

Add a legend when more than one line is labelled.

True
width float | str

Figure width (preset name or inches) when ax is None.

'half'
aspect float

Figure height / width when ax is None.

0.72

Returns:

Type Description
Axes

The axes.

Raises:

Type Description
ValueError

reference_alpha is given with cumulative, a spectrum is not 1-D or has no positive eigenvalue, or labels has the wrong length.

Source code in src/nicefigs/spectra.py
def spectrum(
    eigenvalues: ArrayLike | Sequence[ArrayLike] | Mapping[str, ArrayLike],
    *,
    ax: Axes | None = None,
    labels: Sequence[str] | None = None,
    colors: Mapping[str, Any] | Sequence[Any] | None = None,
    styles: Mapping[str, str] | None = None,
    normalize: bool = True,
    rtol: float = 1e-10,
    cumulative: bool = False,
    thresholds: Sequence[float] = (0.5, 0.9, 0.99),
    reference_alpha: float | None = None,
    xlabel: str = "rank $k$",
    ylabel: str | None = None,
    title: str = "",
    legend: bool = True,
    width: float | str = "half",
    aspect: float = 0.72,
) -> Axes:
    """Plot eigenvalue spectra against rank ``k``, counted from 1.

    By default this draws the log-log decay of ``λ_k``, or of ``λ_k / Σλ`` when
    ``normalize``. With ``cumulative=True`` it draws the cumulative
    explained-variance fraction on a log rank axis instead, with one labelled
    horizontal guide per threshold and, for each series, a marker where it
    first reaches that threshold. Each spectrum is sorted descending, and its
    non-finite, non-positive and round-off-sized (``λ ≤ rtol · λ_max``)
    eigenvalues are dropped.

    Args:
        eigenvalues: One 1-D array, a sequence of 1-D arrays, or ``{name: array}``.
        ax: Axes to draw on; a new figure is created if ``None``.
        labels: Series names for array or sequence input; overrides dict keys.
        colors: ``{name: colour}`` or a list in series order; defaults to
            :func:`~nicefigs.qualitative`.
        styles: ``{name: linestyle}``.
        normalize: Plot ``λ_k / Σλ`` instead of ``λ_k``; ignored when ``cumulative``.
        rtol: Relative floor below which eigenvalues count as numerical zeros;
            ``0`` keeps every positive eigenvalue.
        cumulative: Plot ``Σ_{j≤k} λ_j / Σλ`` instead of the spectrum.
        thresholds: Explained-variance fractions marked when ``cumulative``.
        reference_alpha: Draw a ``k^-α`` power law through the plotted leading
            value of the first series; spectrum mode only.
        xlabel: x-axis label.
        ylabel: y-axis label; defaults to the plotted quantity.
        title: Panel title.
        legend: Add a legend when more than one line is labelled.
        width: Figure width (preset name or inches) when ``ax`` is ``None``.
        aspect: Figure ``height / width`` when ``ax`` is ``None``.

    Returns:
        The axes.

    Raises:
        ValueError: ``reference_alpha`` is given with ``cumulative``, a spectrum
            is not 1-D or has no positive eigenvalue, or ``labels`` has the
            wrong length.
    """
    if cumulative and reference_alpha is not None:
        raise ValueError("reference_alpha applies to the spectrum, not cumulative=True")
    spectra = _as_named_spectra(eigenvalues, labels, rtol)
    names = list(spectra)
    if colors is None:
        colors = dict(zip(names, qualitative(len(names))))
    elif not isinstance(colors, Mapping):
        colors = dict(zip(names, colors))

    if cumulative:
        series = {n: np.cumsum(ev) / ev.sum() for n, ev in spectra.items()}
        default_ylabel = "cumulative explained variance"
    elif normalize:
        series = {n: ev / ev.sum() for n, ev in spectra.items()}
        default_ylabel = r"$\lambda_k / \sum \lambda$"
    else:
        series = spectra
        default_ylabel = r"$\lambda_k$"
    ranks = {n: np.arange(1, len(y) + 1) for n, y in series.items()}

    ax = lines(
        ranks,
        series,
        ax=ax,
        colors=colors,
        styles=styles,
        logx=True,
        logy=not cumulative,
        xlabel=xlabel,
        ylabel=default_ylabel if ylabel is None else ylabel,
        title=title,
        legend=False,
        width=width,
        aspect=aspect,
    )

    if cumulative:
        _mark_thresholds(ax, ranks, series, colors, thresholds)
        ax.set_ylim(0, 1.02)
    elif reference_alpha is not None:
        k = np.geomspace(1, max(len(r) for r in ranks.values()), 50)
        lead = series[names[0]][0]  # the plotted value at k = 1, normalised or not
        ax.plot(
            k,
            lead * k ** -float(reference_alpha),
            label=f"$k^{{-{reference_alpha:g}}}$",
            **role_style("reference", kind="line"),
        )

    if legend and len([lbl for lbl in ax.get_legend_handles_labels()[1] if lbl]) > 1:
        ax.legend(loc="best")
    return ax

nicefigs.comparison

comparison

Model-comparison figures: one panel per metric, one violin, box or strip per model.

model_comparison

model_comparison(scores: Mapping[str, Any], *, models: Sequence[str] | None = None, metrics: Sequence[str] | None = None, groups: Sequence[str] | None = None, group_label: str = '', kind: str = 'violin', ncols: int | None = None, metric_units: Mapping[str, str] | None = None, metric_lims: Mapping[str, tuple[float, float]] | None = None, model_colors: Mapping[str, Any] | None = None, reference: float | Mapping[str, float] | None = None, reference_label: str = '', median_labels: bool = False, divider_after: int | None = None, width: float | str = 'full', panel_aspect: float = 0.85, group_gap: float = 0.82, legend: bool = True, sharey: bool = False) -> tuple[Figure, np.ndarray]

Grid of metric panels comparing the score distributions of several models.

Parameters:

Name Type Description Default
scores Mapping[str, Any]

{metric: {model: values}}, or {metric: {model: {group: values}}} to dodge a second factor (e.g. dataset) within each model. Also accepts the dict returned by :func:nicefigs.data.model_scores.

required
models Sequence[str] | None

Model order on the x-axis; inferred if None.

None
metrics Sequence[str] | None

Metric (panel) order; inferred if None.

None
groups Sequence[str] | None

Group order within each model; inferred if None.

None
group_label str

Title of the shared group legend.

''
kind str

"violin", "box" or "strip".

'violin'
ncols int | None

Panel columns; defaults to one row for up to three metrics.

None
metric_units Mapping[str, str] | None

{metric: unit} shown as the y-axis label.

None
metric_lims Mapping[str, tuple[float, float]] | None

{metric: (lo, hi)} y-limits.

None
model_colors Mapping[str, Any] | None

{model: colour}; colours by model instead of by group.

None
reference float | Mapping[str, float] | None

Reference line value, one for all panels or {metric: value}.

None
reference_label str

Label of the reference line; defaults to its value.

''
median_labels bool

Annotate each distribution with its median.

False
divider_after int | None

Draw a vertical divider after this many models.

None
width float | str

Figure width (preset name or inches).

'full'
panel_aspect float

Height / width of each panel.

0.85
group_gap float

Fraction of the slot per model shared by its groups.

0.82
legend bool

Add the shared group legend.

True
sharey bool

Share the y-axis across panels.

False

Returns:

Type Description
Figure

(fig, axes) with axes a flat array, one per metric; unused grid

ndarray

cells are hidden.

Raises:

Type Description
ValueError

kind is not "violin", "box" or "strip".

Source code in src/nicefigs/comparison.py
def model_comparison(
    scores: Mapping[str, Any],
    *,
    models: Sequence[str] | None = None,
    metrics: Sequence[str] | None = None,
    groups: Sequence[str] | None = None,
    group_label: str = "",
    kind: str = "violin",
    ncols: int | None = None,
    metric_units: Mapping[str, str] | None = None,
    metric_lims: Mapping[str, tuple[float, float]] | None = None,
    model_colors: Mapping[str, Any] | None = None,
    reference: float | Mapping[str, float] | None = None,
    reference_label: str = "",
    median_labels: bool = False,
    divider_after: int | None = None,
    width: float | str = "full",
    panel_aspect: float = 0.85,
    group_gap: float = 0.82,
    legend: bool = True,
    sharey: bool = False,
) -> tuple[Figure, np.ndarray]:
    """Grid of metric panels comparing the score distributions of several models.

    Args:
        scores: ``{metric: {model: values}}``, or ``{metric: {model: {group: values}}}``
            to dodge a second factor (e.g. dataset) within each model. Also accepts
            the dict returned by :func:`nicefigs.data.model_scores`.
        models: Model order on the x-axis; inferred if ``None``.
        metrics: Metric (panel) order; inferred if ``None``.
        groups: Group order within each model; inferred if ``None``.
        group_label: Title of the shared group legend.
        kind: ``"violin"``, ``"box"`` or ``"strip"``.
        ncols: Panel columns; defaults to one row for up to three metrics.
        metric_units: ``{metric: unit}`` shown as the y-axis label.
        metric_lims: ``{metric: (lo, hi)}`` y-limits.
        model_colors: ``{model: colour}``; colours by model instead of by group.
        reference: Reference line value, one for all panels or ``{metric: value}``.
        reference_label: Label of the reference line; defaults to its value.
        median_labels: Annotate each distribution with its median.
        divider_after: Draw a vertical divider after this many models.
        width: Figure width (preset name or inches).
        panel_aspect: Height / width of each panel.
        group_gap: Fraction of the slot per model shared by its groups.
        legend: Add the shared group legend.
        sharey: Share the y-axis across panels.

    Returns:
        ``(fig, axes)`` with ``axes`` a flat array, one per metric; unused grid
        cells are hidden.

    Raises:
        ValueError: ``kind`` is not ``"violin"``, ``"box"`` or ``"strip"``.
    """
    if kind not in ("violin", "box", "strip"):
        raise ValueError(f"kind must be 'violin', 'box' or 'strip', got {kind!r}")
    th = get_theme()
    if isinstance(scores, dict) and "scores" in scores and isinstance(scores["scores"], dict):
        meta = scores
        scores = meta["scores"]
        models = models or meta.get("models")
        metrics = metrics or meta.get("metrics")
        groups = groups or meta.get("timescales")

    metrics = metrics or list(scores)
    first_metric = scores[metrics[0]]
    models = models or list(first_metric)

    _, inferred_groups = _as_grouped(first_metric)
    groups = groups if groups is not None else inferred_groups

    n = len(metrics)
    ncols = ncols or (n if n <= 3 else int(np.ceil(np.sqrt(n))))
    nrows = int(np.ceil(n / ncols))

    from .figure import figure_size
    from .style import style

    size = figure_size(width, nrows=nrows, ncols=ncols, panel_aspect=panel_aspect)
    with style(th):
        fig, axes = plt.subplots(nrows, ncols, figsize=size, dpi=th.dpi, squeeze=False, sharey=sharey)
    axes_flat = axes.ravel()

    gcolors = dict(zip(groups, qualitative(len(groups)))) if groups else None
    ng = len(groups) if groups else 1

    for mi, metric in enumerate(metrics):
        ax = axes_flat[mi]
        norm, _ = _as_grouped(scores[metric])
        base = np.arange(len(models))
        for gi, g in enumerate(groups or [""]):
            offset = (gi - (ng - 1) / 2) * (group_gap / ng)
            wdt = (group_gap / ng) * 0.9
            for mj, model in enumerate(models):
                color = (
                    (model_colors or {}).get(model)
                    if model_colors
                    else (gcolors[g] if gcolors else th.palette[0])
                )
                d = norm.get(model, {}).get(g, [])
                _violin(ax, d, base[mj] + offset, color, wdt, kind)
                if median_labels and np.size(d):
                    med = float(np.nanmedian(np.asarray(d, dtype=float)))
                    ax.annotate(
                        f"{med:.2g}",
                        (base[mj] + offset, med),
                        fontsize=th.fs_annot - 0.5,
                        ha="center",
                        va="bottom",
                        xytext=(0, 2),
                        textcoords="offset points",
                    )
        ref = reference.get(metric) if isinstance(reference, dict) else reference
        if ref is not None:
            from .panels import reference_line

            reference_line(
                ax, ref, axis="y", label=reference_label, fmt="{label}" if reference_label else "{value:.2g}"
            )
        if divider_after is not None and 0 < divider_after < len(models):
            ax.axvline(divider_after - 0.5, color="#bbbbbb", linewidth=0.7, linestyle="-", zorder=0)
        ax.set_xticks(base)
        ax.set_xticklabels(models, rotation=30, ha="right", fontsize=th.fs_tick)
        from .units import prettify_unit

        unit = (metric_units or {}).get(metric)
        ax.set_ylabel(prettify_unit(unit) if unit else "", fontsize=th.fs_axis_label)
        ax.set_title(metric, fontsize=th.fs_title, loc="left", pad=3)
        if metric_lims and metric in metric_lims:
            ax.set_ylim(*metric_lims[metric])
        ax.grid(axis="y", alpha=0.15, linewidth=0.5)
        ax.set_axisbelow(True)

    for k in range(n, len(axes_flat)):
        axes_flat[k].axis("off")

    top = 1.0
    if legend and groups:
        import matplotlib.patches as mpatches

        handles = [
            mpatches.Patch(facecolor=gcolors[g], edgecolor="none", alpha=0.7, label=str(g)) for g in groups
        ]
        leg = fig.legend(
            handles=handles,
            title=group_label or None,
            loc="upper center",
            bbox_to_anchor=(0.5, 1.0),
            ncol=len(groups),
            fontsize=th.fs_legend,
            title_fontsize=th.fs_legend,
            frameon=False,
        )
        # Reserve exactly the legend's height so it never overlaps the panel titles.
        bbox = leg.get_window_extent(fig.canvas.get_renderer()).transformed(fig.transFigure.inverted())
        top = bbox.y0 - 0.01
    fig.tight_layout(rect=(0, 0, 1, top))
    return fig, axes_flat

nicefigs.ternary

ternary

Ternary encodings: three-part compositions drawn as barycentric blends of corner colours.

A composition (a, b, c) with a + b + c = 1 (sand/silt/clay, three land-cover fractions, ...) becomes one colour via :func:color. The same encoding is used by :func:ternary_scatter, :func:ternary_map and :func:ternary_image, and :func:ternary_legend draws the triangular key that decodes it.

Corners module-attribute

Corners = str | Sequence[str]

TERNARY_PALETTES module-attribute

TERNARY_PALETTES: dict[str, tuple[str, str, str]] = {'default': ('#d73027', '#1a9850', '#4575b4'), 'bright': ('#4477AA', '#EE6677', '#228833'), 'muted': ('#332288', '#CC6677', '#117733'), 'warm': ('#d73027', '#fc8d59', '#fee090'), 'prevalence': ('#FFEB33', '#EB4DC7', '#66C7FF')}

DEFAULT_CORNERS module-attribute

DEFAULT_CORNERS = TERNARY_PALETTES['default']

resolve_corners

resolve_corners(corners: Corners) -> tuple[str, str, str]

Resolve a palette name or three explicit colours to a 3-tuple of colours.

Raises:

Type Description
KeyError

corners is a name not in :data:TERNARY_PALETTES.

ValueError

corners is a sequence that is not of length 3.

Source code in src/nicefigs/ternary.py
def resolve_corners(corners: Corners) -> tuple[str, str, str]:
    """Resolve a palette name or three explicit colours to a 3-tuple of colours.

    Raises:
        KeyError: ``corners`` is a name not in :data:`TERNARY_PALETTES`.
        ValueError: ``corners`` is a sequence that is not of length 3.
    """
    if isinstance(corners, str):
        try:
            return TERNARY_PALETTES[corners]
        except KeyError as exc:
            raise KeyError(f"Unknown ternary palette {corners!r}. Known: {sorted(TERNARY_PALETTES)}") from exc
    corners = tuple(corners)
    if len(corners) != 3:
        raise ValueError(f"ternary corners must be a name or 3 colours, got {corners!r}")
    return corners

normalize_composition

normalize_composition(comp: ArrayLike) -> np.ndarray

Rescale compositions of shape (..., 3) so each sums to 1; all-zero rows become NaN.

Source code in src/nicefigs/ternary.py
def normalize_composition(comp: ArrayLike) -> np.ndarray:
    """Rescale compositions of shape ``(..., 3)`` so each sums to 1; all-zero rows become NaN."""
    c = np.asarray(comp, dtype=float)
    s = c.sum(axis=-1, keepdims=True)
    with np.errstate(invalid="ignore", divide="ignore"):
        out = np.where(s > 0, c / s, np.nan)
    return out

color

color(comp: ArrayLike, *, corners: Corners = DEFAULT_CORNERS, gamma: float = 0.8, brighten: float = 1.0) -> np.ndarray

Colour each composition by barycentric blending of three corner colours.

Parameters:

Name Type Description Default
comp ArrayLike

Compositions of shape (..., 3), e.g. (N, 3) or (H, W, 3); normalised to sum to 1.

required
corners Corners

Palette name or three colours for components 0, 1, 2.

DEFAULT_CORNERS
gamma float

Exponent applied to the blended RGB; values below 1 brighten mixtures.

0.8
brighten float

Multiplicative factor applied before gamma, then clipped to 1.

1.0

Returns:

Type Description
ndarray

RGB array of shape comp.shape (trailing axis of 3) in [0, 1].

Source code in src/nicefigs/ternary.py
def color(
    comp: ArrayLike, *, corners: Corners = DEFAULT_CORNERS, gamma: float = 0.8, brighten: float = 1.0
) -> np.ndarray:
    """Colour each composition by barycentric blending of three corner colours.

    Args:
        comp: Compositions of shape ``(..., 3)``, e.g. ``(N, 3)`` or ``(H, W, 3)``;
            normalised to sum to 1.
        corners: Palette name or three colours for components 0, 1, 2.
        gamma: Exponent applied to the blended RGB; values below 1 brighten mixtures.
        brighten: Multiplicative factor applied before ``gamma``, then clipped to 1.

    Returns:
        RGB array of shape ``comp.shape`` (trailing axis of 3) in ``[0, 1]``.
    """
    w = normalize_composition(comp)
    cols = np.array([to_rgb(c) for c in resolve_corners(corners)])  # (3,3)
    rgb = np.tensordot(w, cols, axes=([-1], [0]))  # (..., 3)
    rgb = np.clip(rgb * brighten, 0, 1) ** gamma
    return rgb

barycentric_to_xy

barycentric_to_xy(comp: ArrayLike) -> np.ndarray

Map compositions of shape (..., 3) to (x, y) positions in the unit-edge triangle.

Component 0 sits at (0, 0), 1 at (1, 0) and 2 at the apex (0.5, sqrt(3)/2).

Source code in src/nicefigs/ternary.py
def barycentric_to_xy(comp: ArrayLike) -> np.ndarray:
    """Map compositions of shape ``(..., 3)`` to ``(x, y)`` positions in the unit-edge triangle.

    Component 0 sits at ``(0, 0)``, 1 at ``(1, 0)`` and 2 at the apex ``(0.5, sqrt(3)/2)``.
    """
    w = normalize_composition(comp)
    return w @ _V

ternary_axes

ternary_axes(ax: Axes | None = None, *, labels: Sequence[str] = ('A', 'B', 'C'), gridlines: int = 5, corner_colors: Sequence[str] | None = None, fontsize: float | None = None) -> Axes

Draw a ternary triangle frame (outline, gridlines, corner labels) on ax.

Parameters:

Name Type Description Default
ax Axes | None

Axes to draw into; a new figure is created if None.

None
labels Sequence[str]

Corner labels for components 0, 1, 2.

('A', 'B', 'C')
gridlines int

Number of intervals per edge; gridlines - 1 interior lines are drawn parallel to each edge.

5
corner_colors Sequence[str] | None

Label colours per corner; dark grey if None.

None
fontsize float | None

Label size in points; defaults to the theme's axis-label size.

None

Returns:

Type Description
Axes

The axes, with limits, equal aspect and hidden ticks/spines set.

Source code in src/nicefigs/ternary.py
def ternary_axes(
    ax: Axes | None = None,
    *,
    labels: Sequence[str] = ("A", "B", "C"),
    gridlines: int = 5,
    corner_colors: Sequence[str] | None = None,
    fontsize: float | None = None,
) -> Axes:
    """Draw a ternary triangle frame (outline, gridlines, corner labels) on ``ax``.

    Args:
        ax: Axes to draw into; a new figure is created if ``None``.
        labels: Corner labels for components 0, 1, 2.
        gridlines: Number of intervals per edge; ``gridlines - 1`` interior lines
            are drawn parallel to each edge.
        corner_colors: Label colours per corner; dark grey if ``None``.
        fontsize: Label size in points; defaults to the theme's axis-label size.

    Returns:
        The axes, with limits, equal aspect and hidden ticks/spines set.
    """
    th = get_theme()
    if ax is None:
        from .figure import subplots

        _, ax = subplots(width="half", aspect=np.sqrt(3) / 2 + 0.12)
    fs = fontsize or th.fs_axis_label
    tri = np.vstack([_V, _V[0]])
    ax.plot(tri[:, 0], tri[:, 1], color="#333333", lw=0.8, zorder=2)
    for k in range(1, gridlines):
        f = k / gridlines
        # lines parallel to each edge
        for i, j, m in ((0, 1, 2), (1, 2, 0), (2, 0, 1)):
            p1 = _V[i] * (1 - f) + _V[m] * f
            p2 = _V[j] * (1 - f) + _V[m] * f
            ax.plot([p1[0], p2[0]], [p1[1], p2[1]], color="#cccccc", lw=0.35, zorder=1)
    ccols = corner_colors or ["#333333"] * 3
    offs = [(-0.02, -0.04, "right", "top"), (0.02, -0.04, "left", "top"), (0.0, 0.03, "center", "bottom")]
    for v, lab, cc, (dx, dy, ha, va) in zip(_V, labels, ccols, offs):
        ax.text(v[0] + dx, v[1] + dy, lab, ha=ha, va=va, fontsize=fs, color=cc, fontweight="bold")
    ax.set_xlim(-0.08, 1.08)
    ax.set_ylim(-0.1, np.sqrt(3) / 2 + 0.1)
    ax.set_aspect("equal")
    # Hide the frame but keep the background patch (so insets can be given a
    # white backing) — ``axis("off")`` would suppress the patch too.
    ax.set_xticks([])
    ax.set_yticks([])
    for sp in ax.spines.values():
        sp.set_visible(False)
    return ax

ternary_scatter

ternary_scatter(comp: ArrayLike, values: ArrayLike | None = None, *, ax: Axes | None = None, corners: Corners = DEFAULT_CORNERS, labels: Sequence[str] = ('A', 'B', 'C'), size: float = 10, cmap: str = 'inferno', color_by: Literal['composition', 'value'] = 'composition', **kw: Any) -> tuple[Axes, PathCollection | None]

Scatter compositions inside a ternary triangle.

Parameters:

Name Type Description Default
comp ArrayLike

Compositions of shape (N, 3).

required
values ArrayLike | None

Per-point values of shape (N,), used when color_by="value".

None
ax Axes | None

Axes to draw into; a new figure is created if None.

None
corners Corners

Palette name or three corner colours.

DEFAULT_CORNERS
labels Sequence[str]

Corner labels for components 0, 1, 2.

('A', 'B', 'C')
size float

Marker area in points².

10
cmap str

Colormap name for color_by="value".

'inferno'
color_by Literal['composition', 'value']

"composition" colours points by their ternary colour; "value" colours them by values through cmap.

'composition'
**kw Any

Forwarded to :meth:matplotlib.axes.Axes.scatter.

{}

Returns:

Type Description
Axes

(ax, mappable); mappable is the scatter collection when colouring by

PathCollection | None

value (for a colorbar) and None otherwise.

Source code in src/nicefigs/ternary.py
def ternary_scatter(
    comp: ArrayLike,
    values: ArrayLike | None = None,
    *,
    ax: Axes | None = None,
    corners: Corners = DEFAULT_CORNERS,
    labels: Sequence[str] = ("A", "B", "C"),
    size: float = 10,
    cmap: str = "inferno",
    color_by: Literal["composition", "value"] = "composition",
    **kw: Any,
) -> tuple[Axes, PathCollection | None]:
    """Scatter compositions inside a ternary triangle.

    Args:
        comp: Compositions of shape ``(N, 3)``.
        values: Per-point values of shape ``(N,)``, used when ``color_by="value"``.
        ax: Axes to draw into; a new figure is created if ``None``.
        corners: Palette name or three corner colours.
        labels: Corner labels for components 0, 1, 2.
        size: Marker area in points².
        cmap: Colormap name for ``color_by="value"``.
        color_by: ``"composition"`` colours points by their ternary colour;
            ``"value"`` colours them by ``values`` through ``cmap``.
        **kw: Forwarded to :meth:`matplotlib.axes.Axes.scatter`.

    Returns:
        ``(ax, mappable)``; ``mappable`` is the scatter collection when colouring by
        value (for a colorbar) and ``None`` otherwise.
    """
    ax = ternary_axes(ax, labels=labels, corner_colors=list(resolve_corners(corners)))
    xy = barycentric_to_xy(comp)
    if color_by == "value" and values is not None:
        from .palettes import cmap as resolve_cmap

        sc = ax.scatter(
            xy[:, 0], xy[:, 1], c=np.asarray(values), cmap=resolve_cmap(cmap), s=size, linewidths=0, **kw
        )
        return ax, sc
    cols = color(comp, corners=corners)
    ax.scatter(xy[:, 0], xy[:, 1], c=cols, s=size, linewidths=0, **kw)
    return ax, None

ternary_legend

ternary_legend(ax: Axes | None = None, *, corners: Corners = DEFAULT_CORNERS, labels: Sequence[str] = ('A', 'B', 'C'), n: int = 160, gamma: float = 0.8, points: ArrayLike | None = None, point_kw: dict[str, Any] | None = None, fontsize: float | None = None) -> Axes

Draw the filled colour triangle that decodes a ternary encoding.

Parameters:

Name Type Description Default
ax Axes | None

Axes to draw into, typically parent.inset_axes([...]); a new figure is created if None.

None
corners Corners

Palette name or three corner colours.

DEFAULT_CORNERS
labels Sequence[str]

Corner labels for components 0, 1, 2.

('A', 'B', 'C')
n int

Raster resolution of the triangle per side.

160
gamma float

As in :func:color.

0.8
points ArrayLike | None

Optional compositions (N, 3) overlaid as dots to show where the data lie.

None
point_kw dict[str, Any] | None

Overrides for the overlay's :meth:~matplotlib.axes.Axes.scatter keywords.

None
fontsize float | None

Corner-label size in points.

None

Returns:

Type Description
Axes

The legend axes.

Source code in src/nicefigs/ternary.py
def ternary_legend(
    ax: Axes | None = None,
    *,
    corners: Corners = DEFAULT_CORNERS,
    labels: Sequence[str] = ("A", "B", "C"),
    n: int = 160,
    gamma: float = 0.8,
    points: ArrayLike | None = None,
    point_kw: dict[str, Any] | None = None,
    fontsize: float | None = None,
) -> Axes:
    """Draw the filled colour triangle that decodes a ternary encoding.

    Args:
        ax: Axes to draw into, typically ``parent.inset_axes([...])``; a new
            figure is created if ``None``.
        corners: Palette name or three corner colours.
        labels: Corner labels for components 0, 1, 2.
        n: Raster resolution of the triangle per side.
        gamma: As in :func:`color`.
        points: Optional compositions ``(N, 3)`` overlaid as dots to show where the
            data lie.
        point_kw: Overrides for the overlay's :meth:`~matplotlib.axes.Axes.scatter` keywords.
        fontsize: Corner-label size in points.

    Returns:
        The legend axes.
    """
    ax = ternary_axes(ax, labels=labels, corner_colors=list(resolve_corners(corners)), fontsize=fontsize)
    xs = np.linspace(0, 1, n)
    ys = np.linspace(0, np.sqrt(3) / 2, n)
    gx, gy = np.meshgrid(xs, ys)
    # invert xy -> barycentric
    T = np.array([[_V[0, 0] - _V[2, 0], _V[1, 0] - _V[2, 0]], [_V[0, 1] - _V[2, 1], _V[1, 1] - _V[2, 1]]])
    Tinv = np.linalg.inv(T)
    d = np.stack([gx - _V[2, 0], gy - _V[2, 1]], -1)
    ab = d @ Tinv.T
    a, b = ab[..., 0], ab[..., 1]
    c = 1 - a - b
    inside = (a >= 0) & (b >= 0) & (c >= 0)
    comp = np.stack([a, b, c], -1)
    rgb = color(comp, corners=corners, gamma=gamma)
    img = np.dstack([rgb, inside.astype(float)])
    ax.imshow(img, extent=(0, 1, 0, np.sqrt(3) / 2), origin="lower", interpolation="bilinear", zorder=0)
    if points is not None:
        xy = barycentric_to_xy(points)
        pk = dict(s=3, color="#222222", alpha=0.5, linewidths=0)
        pk.update(point_kw or {})
        ax.scatter(xy[:, 0], xy[:, 1], zorder=3, **pk)
    return ax

ternary_image

ternary_image(comp_img: ArrayLike, *, ax: Axes | None = None, corners: Corners = DEFAULT_CORNERS, labels: Sequence[str] = ('A', 'B', 'C'), inset: bool = True, title: str = '', gamma: float = 0.8) -> Axes

Show a composition image of shape (H, W, 3) in ternary colours.

Parameters:

Name Type Description Default
comp_img ArrayLike

Per-pixel compositions of shape (H, W, 3).

required
ax Axes | None

Axes to draw into; a new figure is created if None.

None
corners Corners

Palette name or three corner colours.

DEFAULT_CORNERS
labels Sequence[str]

Corner labels for the legend.

('A', 'B', 'C')
inset bool

Add a ternary legend inset in the top-right corner.

True
title str

Axes title.

''
gamma float

As in :func:color.

0.8

Returns:

Type Description
Axes

The image axes.

Source code in src/nicefigs/ternary.py
def ternary_image(
    comp_img: ArrayLike,
    *,
    ax: Axes | None = None,
    corners: Corners = DEFAULT_CORNERS,
    labels: Sequence[str] = ("A", "B", "C"),
    inset: bool = True,
    title: str = "",
    gamma: float = 0.8,
) -> Axes:
    """Show a composition image of shape ``(H, W, 3)`` in ternary colours.

    Args:
        comp_img: Per-pixel compositions of shape ``(H, W, 3)``.
        ax: Axes to draw into; a new figure is created if ``None``.
        corners: Palette name or three corner colours.
        labels: Corner labels for the legend.
        inset: Add a ternary legend inset in the top-right corner.
        title: Axes title.
        gamma: As in :func:`color`.

    Returns:
        The image axes.
    """
    th = get_theme()
    if ax is None:
        from .figure import subplots

        _, ax = subplots(width="half", aspect=1.0)
    rgb = color(comp_img, corners=corners, gamma=gamma)
    ax.imshow(np.clip(rgb, 0, 1), interpolation="nearest")
    ax.set_xticks([])
    ax.set_yticks([])
    if title:
        ax.set_title(title, fontsize=th.fs_title, pad=2.5)
    if inset:
        iax = ax.inset_axes([0.71, 0.71, 0.29, 0.29])
        iax.set_facecolor("white")
        iax.patch.set_alpha(0.85)
        ternary_legend(iax, corners=corners, labels=labels, gamma=gamma, fontsize=th.fs_annot)
    return ax

ternary_map

ternary_map(lon: ArrayLike, lat: ArrayLike, comp: ArrayLike, *, ax: Axes | None = None, corners: Corners = DEFAULT_CORNERS, labels: Sequence[str] = ('A', 'B', 'C'), projection: str = 'robinson', antarctica_cutoff: bool = True, extent: Sequence[float] | None = None, size: float = 6, inset: bool = True, land: bool = True, coastline: bool = True, title: str = '', gamma: float = 0.8, **kw: Any) -> tuple[Figure, Axes]

Scatter points on a map, each coloured by its composition.

Parameters:

Name Type Description Default
lon ArrayLike

Longitudes in degrees east, shape (N,).

required
lat ArrayLike

Latitudes in degrees north, shape (N,).

required
comp ArrayLike

Compositions of shape (N, 3).

required
ax Axes | None

Existing (cartopy) axes; a new figure is created if None.

None
corners Corners

Palette name or three corner colours.

DEFAULT_CORNERS
labels Sequence[str]

Corner labels for the legend.

('A', 'B', 'C')
projection str

Projection name, as in :func:nicefigs.make_map_axes.

'robinson'
antarctica_cutoff bool

Crop the southern edge when extent is None.

True
extent Sequence[float] | None

(lon_min, lon_max, lat_min, lat_max) in degrees.

None
size float

Marker area in points².

6
inset bool

Add a ternary legend inset in the lower-left corner.

True
land bool

Draw the land background.

True
coastline bool

Draw coastlines.

True
title str

Axes title.

''
gamma float

As in :func:color.

0.8
**kw Any

Forwarded to :meth:matplotlib.axes.Axes.scatter.

{}

Returns:

Type Description
tuple[Figure, Axes]

(fig, ax).

Source code in src/nicefigs/ternary.py
def ternary_map(
    lon: ArrayLike,
    lat: ArrayLike,
    comp: ArrayLike,
    *,
    ax: Axes | None = None,
    corners: Corners = DEFAULT_CORNERS,
    labels: Sequence[str] = ("A", "B", "C"),
    projection: str = "robinson",
    antarctica_cutoff: bool = True,
    extent: Sequence[float] | None = None,
    size: float = 6,
    inset: bool = True,
    land: bool = True,
    coastline: bool = True,
    title: str = "",
    gamma: float = 0.8,
    **kw: Any,
) -> tuple[Figure, Axes]:
    """Scatter points on a map, each coloured by its composition.

    Args:
        lon: Longitudes in degrees east, shape ``(N,)``.
        lat: Latitudes in degrees north, shape ``(N,)``.
        comp: Compositions of shape ``(N, 3)``.
        ax: Existing (cartopy) axes; a new figure is created if ``None``.
        corners: Palette name or three corner colours.
        labels: Corner labels for the legend.
        projection: Projection name, as in :func:`nicefigs.make_map_axes`.
        antarctica_cutoff: Crop the southern edge when ``extent`` is ``None``.
        extent: ``(lon_min, lon_max, lat_min, lat_max)`` in degrees.
        size: Marker area in points².
        inset: Add a ternary legend inset in the lower-left corner.
        land: Draw the land background.
        coastline: Draw coastlines.
        title: Axes title.
        gamma: As in :func:`color`.
        **kw: Forwarded to :meth:`matplotlib.axes.Axes.scatter`.

    Returns:
        ``(fig, ax)``.
    """
    from .maps import _apply_extent, _try_cartopy, draw_background, make_map_axes

    th = get_theme()
    if ax is None:
        from .figure import figure_size

        fig = plt.figure(figsize=figure_size("full", aspect=0.52), dpi=th.dpi)
        ax, ccrs = make_map_axes(fig, 111, projection=projection)
    else:
        fig = ax.figure
        ccrs = _try_cartopy() if hasattr(ax, "projection") else None
    draw_background(ax, ccrs, land=land, coastline=coastline)
    _apply_extent(ax, ccrs, extent, antarctica_cutoff and extent is None)
    cols = color(comp, corners=corners, gamma=gamma)
    skw = dict(s=size, linewidths=0, rasterized=True)
    if ccrs is not None:
        skw["transform"] = ccrs.PlateCarree()
    ax.scatter(np.asarray(lon), np.asarray(lat), c=cols, **skw, **kw)
    if title:
        from .panels import clean_title

        clean_title(ax, title)
    if inset:
        # Lower-left corner of a global map is open ocean, so the inset needs no backing.
        iax = ax.inset_axes([0.03, 0.06, 0.19, 0.19])
        iax.patch.set_alpha(0.0)
        ternary_legend(iax, corners=corners, labels=labels, gamma=gamma, fontsize=th.fs_annot - 0.5)
    return fig, ax

nicefigs.pareto

pareto

Trade-off scatter plots with the Pareto frontier drawn on top.

frontier

frontier(x: ArrayLike, y: ArrayLike, *, xdirection: str = 'lower_better', ydirection: str = 'higher_better') -> np.ndarray

Indices of the Pareto-optimal points of (x, y), ordered by x.

A point is optimal iff no other point is at least as good on both axes and strictly better on at least one.

Parameters:

Name Type Description Default
x ArrayLike

First objective, shape (n,).

required
y ArrayLike

Second objective, shape (n,).

required
xdirection str

"lower_better" or "higher_better" for x.

'lower_better'
ydirection str

"lower_better" or "higher_better" for y.

'higher_better'

Returns:

Type Description
ndarray

Integer index array into x/y.

Raises:

Type Description
ValueError

A direction is not "lower_better" or "higher_better".

Source code in src/nicefigs/pareto.py
def frontier(
    x: ArrayLike,
    y: ArrayLike,
    *,
    xdirection: str = "lower_better",
    ydirection: str = "higher_better",
) -> np.ndarray:
    """Indices of the Pareto-optimal points of ``(x, y)``, ordered by ``x``.

    A point is optimal iff no other point is at least as good on both axes and
    strictly better on at least one.

    Args:
        x: First objective, shape ``(n,)``.
        y: Second objective, shape ``(n,)``.
        xdirection: ``"lower_better"`` or ``"higher_better"`` for ``x``.
        ydirection: ``"lower_better"`` or ``"higher_better"`` for ``y``.

    Returns:
        Integer index array into ``x``/``y``.

    Raises:
        ValueError: A direction is not ``"lower_better"`` or ``"higher_better"``.
    """
    for d in (xdirection, ydirection):
        if d not in ("lower_better", "higher_better"):
            raise ValueError(f"a Pareto direction must be 'lower_better' or 'higher_better', got {d!r}")
    minimize_x = xdirection == "lower_better"
    maximize_y = ydirection == "higher_better"
    x = np.asarray(x, dtype=float)
    y = np.asarray(y, dtype=float)
    n = x.size
    keep = np.ones(n, dtype=bool)
    for i in range(n):
        xb = (x <= x[i]) if minimize_x else (x >= x[i])
        yb = (y >= y[i]) if maximize_y else (y <= y[i])
        xs = (x < x[i]) if minimize_x else (x > x[i])
        ys = (y > y[i]) if maximize_y else (y < y[i])
        dominated = xb & yb & (xs | ys)
        dominated[i] = False
        if dominated.any():
            keep[i] = False
    idx = np.where(keep)[0]
    return idx[np.argsort(x[idx])]

pareto

pareto(x: ArrayLike, y: ArrayLike, *, ax: Axes | None = None, group: ArrayLike | None = None, group_colors: Mapping[Any, Any] | None = None, group_markers: Mapping[Any, str] | None = None, hue: ArrayLike | None = None, hue_cmap: str = 'viridis', hue2d: tuple[ArrayLike, ArrayLike] | None = None, cmap2d: str = 'flat', cmap2d_kwargs: Mapping[str, Any] | None = None, size: ArrayLike | None = None, size_range: tuple[float, float] = (15, 140), xdirection: str = 'lower_better', ydirection: str = 'higher_better', frontier: bool = True, frontier_per_group: bool = False, frontier_kwargs: Mapping[str, Any] | None = None, baselines: Mapping[str, float] | None = None, baseline_colors: Mapping[str, Any] | None = None, xlabel: str = '', ylabel: str = '', xunits: str | None = None, yunits: str | None = None, edgecolor: Any = 'white', linewidth: float = 0.6, legend: bool = True, width: float | str = 'half') -> tuple[Axes, np.ndarray | None]

Scatter a trade-off (x, y) with its Pareto frontier and optional baselines.

Parameters:

Name Type Description Default
x ArrayLike

First objective, shape (n,).

required
y ArrayLike

Second objective, shape (n,).

required
ax Axes | None

Axes to draw on; a new figure is created if None.

None
group ArrayLike | None

Categorical labels, shape (n,), mapped to colour and marker.

None
group_colors Mapping[Any, Any] | None

{group: colour}; defaults to the theme palette.

None
group_markers Mapping[Any, str] | None

{group: marker}; defaults rotate :data:nicefigs.scatter.MARKERS.

None
hue ArrayLike | None

Continuous values, shape (n,), coloured on hue_cmap with a colorbar; overrides group colours.

None
hue_cmap str

Colormap for hue.

'viridis'
hue2d tuple[ArrayLike, ArrayLike] | None

Two arrays (u, v), each shape (n,), coloured on a 2-D colormap; overrides hue and group colours.

None
cmap2d str

2-D colormap for hue2d: "flat", "bivariate" or "hsv".

'flat'
cmap2d_kwargs Mapping[str, Any] | None

Forwarded to the 2-D colormap function.

None
size ArrayLike | None

Continuous values, shape (n,), mapped to marker area.

None
size_range tuple[float, float]

Marker area range (pt²) that size is scaled into.

(15, 140)
xdirection str

"lower_better" or "higher_better" for x; sets the frontier and the arrow appended to the x-axis label.

'lower_better'
ydirection str

As xdirection, for y.

'higher_better'
frontier bool

Draw the frontier line.

True
frontier_per_group bool

Draw one colour-matched frontier per group instead of one for all points.

False
frontier_kwargs Mapping[str, Any] | None

Overrides for the frontier :meth:~matplotlib.axes.Axes.plot style.

None
baselines Mapping[str, float] | None

{label: y} labelled horizontal reference lines.

None
baseline_colors Mapping[str, Any] | None

{label: colour} for baselines; defaults to greys.

None
xlabel str

x-axis label.

''
ylabel str

y-axis label.

''
xunits str | None

Unit appended to xlabel.

None
yunits str | None

Unit appended to ylabel.

None
edgecolor Any

Marker edge colour.

'white'
linewidth float

Marker edge width (pt).

0.6
legend bool

Add a group legend.

True
width float | str

Figure width (preset name or inches) when ax is None.

'half'

Returns:

Type Description
Axes

(ax, frontier_indices); the indices are those of :func:frontier for

ndarray | None

all points, or None when frontier is off or frontier_per_group.

Raises:

Type Description
KeyError

cmap2d is not a known 2-D colormap.

Source code in src/nicefigs/pareto.py
def pareto(
    x: ArrayLike,
    y: ArrayLike,
    *,
    ax: Axes | None = None,
    group: ArrayLike | None = None,
    group_colors: Mapping[Any, Any] | None = None,
    group_markers: Mapping[Any, str] | None = None,
    hue: ArrayLike | None = None,
    hue_cmap: str = "viridis",
    hue2d: tuple[ArrayLike, ArrayLike] | None = None,
    cmap2d: str = "flat",
    cmap2d_kwargs: Mapping[str, Any] | None = None,
    size: ArrayLike | None = None,
    size_range: tuple[float, float] = (15, 140),
    xdirection: str = "lower_better",
    ydirection: str = "higher_better",
    frontier: bool = True,
    frontier_per_group: bool = False,
    frontier_kwargs: Mapping[str, Any] | None = None,
    baselines: Mapping[str, float] | None = None,
    baseline_colors: Mapping[str, Any] | None = None,
    xlabel: str = "",
    ylabel: str = "",
    xunits: str | None = None,
    yunits: str | None = None,
    edgecolor: Any = "white",
    linewidth: float = 0.6,
    legend: bool = True,
    width: float | str = "half",
) -> tuple[Axes, np.ndarray | None]:
    """Scatter a trade-off ``(x, y)`` with its Pareto frontier and optional baselines.

    Args:
        x: First objective, shape ``(n,)``.
        y: Second objective, shape ``(n,)``.
        ax: Axes to draw on; a new figure is created if ``None``.
        group: Categorical labels, shape ``(n,)``, mapped to colour and marker.
        group_colors: ``{group: colour}``; defaults to the theme palette.
        group_markers: ``{group: marker}``; defaults rotate :data:`nicefigs.scatter.MARKERS`.
        hue: Continuous values, shape ``(n,)``, coloured on ``hue_cmap`` with a
            colorbar; overrides group colours.
        hue_cmap: Colormap for ``hue``.
        hue2d: Two arrays ``(u, v)``, each shape ``(n,)``, coloured on a 2-D
            colormap; overrides ``hue`` and group colours.
        cmap2d: 2-D colormap for ``hue2d``: ``"flat"``, ``"bivariate"`` or ``"hsv"``.
        cmap2d_kwargs: Forwarded to the 2-D colormap function.
        size: Continuous values, shape ``(n,)``, mapped to marker area.
        size_range: Marker area range (pt²) that ``size`` is scaled into.
        xdirection: ``"lower_better"`` or ``"higher_better"`` for ``x``; sets the
            frontier and the arrow appended to the x-axis label.
        ydirection: As ``xdirection``, for ``y``.
        frontier: Draw the frontier line.
        frontier_per_group: Draw one colour-matched frontier per ``group``
            instead of one for all points.
        frontier_kwargs: Overrides for the frontier :meth:`~matplotlib.axes.Axes.plot` style.
        baselines: ``{label: y}`` labelled horizontal reference lines.
        baseline_colors: ``{label: colour}`` for ``baselines``; defaults to greys.
        xlabel: x-axis label.
        ylabel: y-axis label.
        xunits: Unit appended to ``xlabel``.
        yunits: Unit appended to ``ylabel``.
        edgecolor: Marker edge colour.
        linewidth: Marker edge width (pt).
        legend: Add a group legend.
        width: Figure width (preset name or inches) when ``ax`` is ``None``.

    Returns:
        ``(ax, frontier_indices)``; the indices are those of :func:`frontier` for
        all points, or ``None`` when ``frontier`` is off or ``frontier_per_group``.

    Raises:
        KeyError: ``cmap2d`` is not a known 2-D colormap.
    """
    th = get_theme()
    x = np.asarray(x, dtype=float)
    y = np.asarray(y, dtype=float)
    if ax is None:
        from .figure import subplots

        _, ax = subplots(width=width, aspect=0.85)

    fk = dict(color="#333333", linewidth=1.0, linestyle="--", alpha=0.7, zorder=4)
    fk.update(frontier_kwargs or {})
    sizes = _sizes(size, size_range, x.shape)

    colors2d = None
    if hue2d is not None:
        from .colormaps2d import bivariate_cmap, flat_cmap, hsv_cmap, normalize01

        u, v = hue2d
        u01 = normalize01(u)[0]
        v01 = normalize01(v)[0]
        fn = {"flat": flat_cmap, "bivariate": bivariate_cmap, "hsv": hsv_cmap}[cmap2d]
        colors2d = fn(u01, v01, **(cmap2d_kwargs or {}))

    handles = []
    mappable = None
    if group is not None:
        group = np.asarray(group)
        cats = list(dict.fromkeys(group.tolist()))
        gc = group_colors or dict(zip(cats, qualitative(len(cats))))
        gm = group_markers or {c: MARKERS[i % len(MARKERS)] for i, c in enumerate(cats)}
        for c in cats:
            m = group == c
            ckw = dict(marker=gm[c], edgecolors=edgecolor, linewidths=linewidth, zorder=5)
            if colors2d is not None:
                ax.scatter(x[m], y[m], c=colors2d[m], s=sizes[m], **ckw)
            elif hue is not None:
                mappable = ax.scatter(
                    x[m], y[m], c=np.asarray(hue)[m], cmap=resolve_cmap(hue_cmap), s=sizes[m], **ckw
                )
            else:
                ax.scatter(x[m], y[m], color=gc[c], s=sizes[m], **ckw)
            handles.append(
                mlines.Line2D(
                    [], [], color=gc[c], marker=gm[c], linestyle="None", markersize=np.sqrt(45), label=str(c)
                )
            )
            if frontier and frontier_per_group:
                fi = _compute_frontier(x[m], y[m], xdirection=xdirection, ydirection=ydirection)
                ax.plot(x[m][fi], y[m][fi], **{**fk, "color": gc[c], "alpha": 0.85})
    else:
        ckw = dict(edgecolors=edgecolor, linewidths=linewidth, zorder=5)
        if colors2d is not None:
            ax.scatter(x, y, c=colors2d, s=sizes, **ckw)
        elif hue is not None:
            mappable = ax.scatter(x, y, c=np.asarray(hue), cmap=resolve_cmap(hue_cmap), s=sizes, **ckw)
        else:
            ax.scatter(x, y, color=COLORS["blue"], s=sizes, **ckw)

    front_idx = None
    if frontier and not frontier_per_group:
        front_idx = _compute_frontier(x, y, xdirection=xdirection, ydirection=ydirection)
        ax.plot(x[front_idx], y[front_idx], **fk)

    if baselines:
        bc = baseline_colors or {}
        fallback = ["#888888", "#AAAAAA", "#666666"]
        for i, (label, val) in enumerate(baselines.items()):
            col = bc.get(label, fallback[i % len(fallback)])
            ax.axhline(val, color=col, linestyle=":", linewidth=0.8, zorder=2)
            ax.text(
                0.995,
                val,
                f" {label} ({val:.3g})",
                transform=ax.get_yaxis_transform(),
                ha="right",
                va="bottom",
                fontsize=th.fs_annot,
                color=col,
            )

    from .units import axis_label

    xa = axis_label(xlabel, xunits, direction=xdirection)
    ya = axis_label(ylabel, yunits, direction=ydirection)
    if xlabel:
        ax.set_xlabel(xa)
    if ylabel:
        ax.set_ylabel(ya)
    if legend and handles:
        ax.legend(handles=handles, loc="best", fontsize=th.fs_legend)
    if mappable is not None:
        from .colorbars import colorbar

        colorbar(mappable, ax)
    return ax, front_idx

nicefigs.ranking

ranking

Caterpillar (forest) plots: per-item values with error bars, ranked.

caterpillar

caterpillar(labels: Sequence[Any], values: ArrayLike, *, err: ArrayLike | None = None, lo: ArrayLike | None = None, hi: ArrayLike | None = None, ax: Axes | None = None, sort_by: str | ArrayLike = 'value', ascending: bool = True, orientation: str = 'horizontal', groups: ArrayLike | None = None, group_colors: Mapping[Any, Any] | None = None, color: Any = None, baseline: ArrayLike | tuple[ArrayLike, ArrayLike] | None = None, baseline_label: str = 'baseline', baseline_color: Any = COLORS['neutral'], offset: float = 0.18, ref_line: float | None = 0.0, ref_label: str = '', summary: tuple[str, float] | None = None, value_label: str = '', value_units: str | None = None, annotate_threshold: float | None = None, markersize: float = 4, capsize: float = 1.5, width: float | str = 'half', aspect: float | None = None, legend: bool = True) -> Axes

Ranked per-item value ± error plot.

Parameters:

Name Type Description Default
labels Sequence[Any]

Item names, one per value.

required
values ArrayLike

Central values, shape (n,).

required
err ArrayLike | None

Symmetric error, shape (n,); ignored when lo and hi are given.

None
lo ArrayLike | None

Explicit lower bounds, shape (n,).

None
hi ArrayLike | None

Explicit upper bounds, shape (n,).

None
ax Axes | None

Axes to draw on; a new figure is created if None.

None
sort_by str | ArrayLike

"value" (rank by value), "none" (input order), or an array of sort keys, shape (n,).

'value'
ascending bool

Sort order.

True
orientation str

"horizontal" (items down y, the forest layout) or "vertical" (items along x).

'horizontal'
groups ArrayLike | None

Per-item categorical labels, shape (n,), mapped to colour with a legend.

None
group_colors Mapping[Any, Any] | None

{group: colour}; defaults to the theme palette.

None
color Any

Point colour without groups.

None
baseline ArrayLike | tuple[ArrayLike, ArrayLike] | None

A second series, values or (values, err), drawn offset beside each item.

None
baseline_label str

Legend label of baseline.

'baseline'
baseline_color Any

Colour of baseline.

COLORS['neutral']
offset float

Offset between the series and baseline, in item slots.

0.18
ref_line float | None

Reference line value; None omits it.

0.0
ref_label str

Text placed in the corner to name ref_line.

''
summary tuple[str, float] | None

(name, value) shown as a headline annotation.

None
value_label str

Value-axis label.

''
value_units str | None

Unit appended to value_label.

None
annotate_threshold float | None

Label items whose |value| is at least this.

None
markersize float

Point size (pt).

4
capsize float

Error-bar cap length (pt).

1.5
width float | str

Figure width (preset name or inches) when ax is None.

'half'
aspect float | None

Figure height / width when ax is None; scales with the item count by default.

None
legend bool

Add a legend when groups or baseline is set.

True

Returns:

Type Description
Axes

The axes.

Source code in src/nicefigs/ranking.py
def caterpillar(
    labels: Sequence[Any],
    values: ArrayLike,
    *,
    err: ArrayLike | None = None,
    lo: ArrayLike | None = None,
    hi: ArrayLike | None = None,
    ax: Axes | None = None,
    sort_by: str | ArrayLike = "value",
    ascending: bool = True,
    orientation: str = "horizontal",
    groups: ArrayLike | None = None,
    group_colors: Mapping[Any, Any] | None = None,
    color: Any = None,
    baseline: ArrayLike | tuple[ArrayLike, ArrayLike] | None = None,
    baseline_label: str = "baseline",
    baseline_color: Any = COLORS["neutral"],
    offset: float = 0.18,
    ref_line: float | None = 0.0,
    ref_label: str = "",
    summary: tuple[str, float] | None = None,
    value_label: str = "",
    value_units: str | None = None,
    annotate_threshold: float | None = None,
    markersize: float = 4,
    capsize: float = 1.5,
    width: float | str = "half",
    aspect: float | None = None,
    legend: bool = True,
) -> Axes:
    """Ranked per-item value ± error plot.

    Args:
        labels: Item names, one per value.
        values: Central values, shape ``(n,)``.
        err: Symmetric error, shape ``(n,)``; ignored when ``lo`` and ``hi`` are given.
        lo: Explicit lower bounds, shape ``(n,)``.
        hi: Explicit upper bounds, shape ``(n,)``.
        ax: Axes to draw on; a new figure is created if ``None``.
        sort_by: ``"value"`` (rank by value), ``"none"`` (input order), or an
            array of sort keys, shape ``(n,)``.
        ascending: Sort order.
        orientation: ``"horizontal"`` (items down y, the forest layout) or
            ``"vertical"`` (items along x).
        groups: Per-item categorical labels, shape ``(n,)``, mapped to colour
            with a legend.
        group_colors: ``{group: colour}``; defaults to the theme palette.
        color: Point colour without ``groups``.
        baseline: A second series, ``values`` or ``(values, err)``, drawn offset
            beside each item.
        baseline_label: Legend label of ``baseline``.
        baseline_color: Colour of ``baseline``.
        offset: Offset between the series and ``baseline``, in item slots.
        ref_line: Reference line value; ``None`` omits it.
        ref_label: Text placed in the corner to name ``ref_line``.
        summary: ``(name, value)`` shown as a headline annotation.
        value_label: Value-axis label.
        value_units: Unit appended to ``value_label``.
        annotate_threshold: Label items whose ``|value|`` is at least this.
        markersize: Point size (pt).
        capsize: Error-bar cap length (pt).
        width: Figure width (preset name or inches) when ``ax`` is ``None``.
        aspect: Figure height / width when ``ax`` is ``None``; scales with the
            item count by default.
        legend: Add a legend when ``groups`` or ``baseline`` is set.

    Returns:
        The axes.
    """
    th = get_theme()
    labels = list(labels)
    values, low, high = _resolve_bounds(values, err, lo, hi)
    n = len(labels)

    if isinstance(sort_by, str):
        if sort_by == "value":
            order = np.argsort(values)
            if not ascending:
                order = order[::-1]
        else:
            order = np.arange(n)
    else:
        order = np.argsort(np.asarray(sort_by))
        if not ascending:
            order = order[::-1]

    labels = [labels[i] for i in order]
    values, low, high = values[order], low[order], high[order]
    lo_err = np.clip(values - low, 0, None)
    hi_err = np.clip(high - values, 0, None)
    pos = np.arange(n)

    if ax is None:
        from .figure import subplots

        asp = aspect if aspect is not None else max(0.35, 0.06 * n)
        _, ax = subplots(width=width, aspect=asp)

    if groups is not None:
        groups = np.asarray(groups)[order]
        cats = list(dict.fromkeys(groups.tolist()))
        gc = group_colors or dict(zip(cats, qualitative(len(cats))))
        colors = [gc[g] for g in groups]
    else:
        colors = color or COLORS["blue"]

    horiz = orientation == "horizontal"

    def _errbar(p, v, le, he, c, **kw):
        if horiz:
            ax.errorbar(
                v,
                p,
                xerr=[le, he],
                fmt="o",
                color=c,
                ecolor=c,
                markersize=markersize,
                capsize=capsize,
                elinewidth=0.8,
                markeredgecolor="white",
                markeredgewidth=0.4,
                **kw,
            )
        else:
            ax.errorbar(
                p,
                v,
                yerr=[le, he],
                fmt="o",
                color=c,
                ecolor=c,
                markersize=markersize,
                capsize=capsize,
                elinewidth=0.8,
                markeredgecolor="white",
                markeredgewidth=0.4,
                **kw,
            )

    off = offset if baseline is not None else 0.0
    if groups is not None:
        for c in cats:
            m = np.array([g == c for g in groups])
            _errbar(pos[m] + off, values[m], lo_err[m], hi_err[m], gc[c], zorder=5, label=str(c))
    else:
        _errbar(pos + off, values, lo_err, hi_err, colors, zorder=5)

    if baseline is not None:
        bvals, berr, blo, bhi = _baseline_arrays(baseline, order)
        ble = np.clip(bvals - blo, 0, None)
        bhe = np.clip(bhi - bvals, 0, None)
        _errbar(pos - off, bvals, ble, bhe, baseline_color, zorder=4, alpha=0.8, label=baseline_label)

    if ref_line is not None:
        (ax.axvline if horiz else ax.axhline)(
            ref_line, color=COLORS["neutral"], linestyle="--", linewidth=0.7, zorder=1
        )
        if ref_label:
            ax.text(
                0.99,
                0.99,
                ref_label,
                transform=ax.transAxes,
                ha="right",
                va="top",
                fontsize=th.fs_annot,
                color=COLORS["neutral"],
            )

    from .units import axis_label

    vlabel = axis_label(value_label, value_units) if value_label else ""
    if horiz:
        ax.set_yticks(pos)
        ax.set_yticklabels(labels, fontsize=th.fs_tick)
        ax.set_ylim(-0.6, n - 0.4)
        if vlabel:
            ax.set_xlabel(vlabel)
        ax.grid(axis="x", alpha=0.15, linewidth=0.5)
    else:
        ax.set_xticks(pos)
        ax.set_xticklabels(labels, rotation=45, ha="right", fontsize=th.fs_tick)
        ax.set_xlim(-0.6, n - 0.4)
        if vlabel:
            ax.set_ylabel(vlabel)
        ax.grid(axis="y", alpha=0.15, linewidth=0.5)
    ax.set_axisbelow(True)

    if annotate_threshold is not None:
        for p, v, lab in zip(pos, values, labels):
            if abs(v) >= annotate_threshold:
                xy, xytext, ha, va = (
                    ((v, p), (3, 0), "left", "center") if horiz else ((p, v), (0, 3), "center", "bottom")
                )
                ax.annotate(
                    lab, xy, fontsize=th.fs_annot, xytext=xytext, textcoords="offset points", ha=ha, va=va
                )

    if summary is not None:
        name, val = summary
        ax.text(
            0.02,
            0.98,
            f"{name} = {val:.3g}",
            transform=ax.transAxes,
            va="top",
            ha="left",
            fontsize=th.fs_annot,
            bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="#cccccc", lw=0.4, alpha=0.85),
        )

    if legend and (groups is not None or baseline is not None):
        ax.legend(loc="best", fontsize=th.fs_legend)
    return ax

nicefigs.bars

bars

Grouped (dodged) and stacked bar charts.

grouped

grouped(categories: Sequence[Any], series: Mapping[str, ArrayLike | Mapping[Any, float]], *, ax: Axes | None = None, colors: Mapping[str, Any] | None = None, hatches: Mapping[str, str] | None = None, width_frac: float = 0.8, value_label: str = '', value_units: str | None = None, baseline: float | None = None, legend: bool = True, rotation: float = 0, edgecolor: Any = 'white') -> Axes

Grouped (dodged) bars, one per (category, series) pair.

Parameters:

Name Type Description Default
ax Axes | None

Axes to draw on; a new figure is created if None.

None
categories Sequence[Any]

x-axis category labels.

required
series Mapping[str, ArrayLike | Mapping[Any, float]]

{name: values} with values aligned to categories, or {name: {category: value}} where missing categories count as 0.

required
colors Mapping[str, Any] | None

{name: colour}; defaults to the theme palette.

None
hatches Mapping[str, str] | None

{name: hatch} as a texture encoding.

None
width_frac float

Fraction of each category slot covered by its bars.

0.8
value_label str

y-axis label.

''
value_units str | None

Unit appended to value_label.

None
baseline float | None

y value of a dashed reference line.

None
legend bool

Add a legend when there is more than one series.

True
rotation float

Category tick-label rotation (degrees).

0
edgecolor Any

Bar edge colour.

'white'

Returns:

Type Description
Axes

The axes.

Source code in src/nicefigs/bars.py
def grouped(
    categories: Sequence[Any],
    series: Mapping[str, ArrayLike | Mapping[Any, float]],
    *,
    ax: Axes | None = None,
    colors: Mapping[str, Any] | None = None,
    hatches: Mapping[str, str] | None = None,
    width_frac: float = 0.8,
    value_label: str = "",
    value_units: str | None = None,
    baseline: float | None = None,
    legend: bool = True,
    rotation: float = 0,
    edgecolor: Any = "white",
) -> Axes:
    """Grouped (dodged) bars, one per ``(category, series)`` pair.

    Args:
        ax: Axes to draw on; a new figure is created if ``None``.
        categories: x-axis category labels.
        series: ``{name: values}`` with values aligned to ``categories``, or
            ``{name: {category: value}}`` where missing categories count as 0.
        colors: ``{name: colour}``; defaults to the theme palette.
        hatches: ``{name: hatch}`` as a texture encoding.
        width_frac: Fraction of each category slot covered by its bars.
        value_label: y-axis label.
        value_units: Unit appended to ``value_label``.
        baseline: y value of a dashed reference line.
        legend: Add a legend when there is more than one series.
        rotation: Category tick-label rotation (degrees).
        edgecolor: Bar edge colour.

    Returns:
        The axes.
    """
    if ax is None:
        _, ax = subplots()
    th = get_theme()
    categories = list(categories)
    data = _as_series(series, categories)
    names = list(data)
    cols = colors or dict(zip(names, qualitative(len(names))))
    nser = len(names)
    x = np.arange(len(categories))
    bw = width_frac / max(nser, 1)

    for i, name in enumerate(names):
        offset = (i - (nser - 1) / 2) * bw
        ax.bar(
            x + offset,
            data[name],
            width=bw * 0.92,
            color=cols[name],
            edgecolor=edgecolor,
            linewidth=0.4,
            label=name,
            hatch=(hatches or {}).get(name),
        )

    if baseline is not None:
        ax.axhline(baseline, color=th.spine_color, linestyle="--", linewidth=0.7, alpha=0.6)

    ax.set_xticks(x)
    ax.set_xticklabels(
        categories, rotation=rotation, ha="right" if rotation else "center", fontsize=th.fs_tick
    )
    from .units import axis_label

    if value_label:
        ax.set_ylabel(axis_label(value_label, value_units))
    ax.grid(axis="y", alpha=0.15, linewidth=0.5)
    ax.set_axisbelow(True)
    if legend and nser > 1:
        ax.legend(loc="best", fontsize=th.fs_legend, ncol=min(nser, 3))
    return ax

stacked

stacked(categories: Sequence[Any], segments: Mapping[str, ArrayLike | Mapping[Any, float]], *, ax: Axes | None = None, colors: Mapping[str, Any] | None = None, width_frac: float = 0.7, value_label: str = '', value_units: str | None = None, legend: bool = True, rotation: float = 0, normalize: bool = False, edgecolor: Any = 'white') -> Axes

Stacked bars, segments stacked within each category.

Parameters:

Name Type Description Default
ax Axes | None

Axes to draw on; a new figure is created if None.

None
categories Sequence[Any]

x-axis category labels.

required
segments Mapping[str, ArrayLike | Mapping[Any, float]]

{name: values} with values aligned to categories, or {name: {category: value}} where missing categories count as 0. Stacked bottom-up in mapping order.

required
colors Mapping[str, Any] | None

{name: colour}; defaults to the theme palette.

None
width_frac float

Bar width as a fraction of the category slot.

0.7
value_label str

y-axis label; defaults to "fraction" when normalize.

''
value_units str | None

Unit appended to value_label.

None
legend bool

Add a legend to the right of the axes.

True
rotation float

Category tick-label rotation (degrees).

0
normalize bool

Scale each category's stack to sum to 1; all-zero stacks stay 0.

False
edgecolor Any

Bar edge colour.

'white'

Returns:

Type Description
Axes

The axes.

Source code in src/nicefigs/bars.py
def stacked(
    categories: Sequence[Any],
    segments: Mapping[str, ArrayLike | Mapping[Any, float]],
    *,
    ax: Axes | None = None,
    colors: Mapping[str, Any] | None = None,
    width_frac: float = 0.7,
    value_label: str = "",
    value_units: str | None = None,
    legend: bool = True,
    rotation: float = 0,
    normalize: bool = False,
    edgecolor: Any = "white",
) -> Axes:
    """Stacked bars, ``segments`` stacked within each category.

    Args:
        ax: Axes to draw on; a new figure is created if ``None``.
        categories: x-axis category labels.
        segments: ``{name: values}`` with values aligned to ``categories``, or
            ``{name: {category: value}}`` where missing categories count as 0.
            Stacked bottom-up in mapping order.
        colors: ``{name: colour}``; defaults to the theme palette.
        width_frac: Bar width as a fraction of the category slot.
        value_label: y-axis label; defaults to ``"fraction"`` when ``normalize``.
        value_units: Unit appended to ``value_label``.
        legend: Add a legend to the right of the axes.
        rotation: Category tick-label rotation (degrees).
        normalize: Scale each category's stack to sum to 1; all-zero stacks stay 0.
        edgecolor: Bar edge colour.

    Returns:
        The axes.
    """
    if ax is None:
        _, ax = subplots()
    th = get_theme()
    categories = list(categories)
    data = _as_series(segments, categories)
    names = list(data)
    cols = colors or dict(zip(names, qualitative(len(names))))
    x = np.arange(len(categories))
    mat = np.vstack([data[n] for n in names])  # (nseg, ncat)
    if normalize:
        totals = mat.sum(axis=0)
        mat = np.divide(mat, totals, out=np.zeros_like(mat), where=totals > 0)

    bottom = np.zeros(len(categories))
    for i, name in enumerate(names):
        ax.bar(
            x,
            mat[i],
            bottom=bottom,
            width=width_frac,
            color=cols[name],
            edgecolor=edgecolor,
            linewidth=0.4,
            label=name,
        )
        bottom += mat[i]

    ax.set_xticks(x)
    ax.set_xticklabels(
        categories, rotation=rotation, ha="right" if rotation else "center", fontsize=th.fs_tick
    )
    from .units import axis_label

    if value_label:
        ax.set_ylabel(axis_label(value_label, value_units))
    elif normalize:
        ax.set_ylabel("fraction")
    if legend:
        ax.legend(loc="center left", bbox_to_anchor=(1.01, 0.5), fontsize=th.fs_legend, frameon=False)
    return ax

nicefigs.tables

tables

Scorecards: model × metric tables rendered as coloured-cell figures.

skill_gap

skill_gap(values: ArrayLike, reference: ArrayLike, *, uncertainty: ArrayLike | None = None, direction: str | Sequence[str] = 'higher_better', target: ArrayLike = 0.0) -> np.ndarray

Signed distance of values from a reference, optionally in units of its uncertainty.

Positive is always better, whichever way a metric is read, so one diverging colour scale can carry metrics in different units (see :func:scorecard). A "target" metric is scored by closeness to target: |reference - target| - |values - target|. Dividing by uncertainty turns each gap into a signal-to-noise ratio: -3 is three error bars worse than the reference, 0 indistinguishable from it.

Parameters:

Name Type Description Default
values ArrayLike

(n_rows, n_cols) metric table; a 1-D input is one row.

required
reference ArrayLike

Values to measure against, broadcast against values.

required
uncertainty ArrayLike | None

Scale the gap is divided by, in the metric's units (e.g. a standard error), broadcast against values. Non-positive or non-finite entries give nan. None keeps the metric's units.

None
direction str | Sequence[str]

"higher_better", "lower_better" or "target", for the whole table or one per column.

'higher_better'
target ArrayLike

Value a "target" metric is best at, broadcast against values.

0.0

Returns:

Type Description
ndarray

(n_rows, n_cols) signed gaps, positive where values beat

ndarray

reference; nan wherever an input is missing.

Raises:

Type Description
ValueError

values and reference do not broadcast, a per-column direction does not label every column, or a direction is not in :data:DIRECTIONS.

Source code in src/nicefigs/tables.py
def skill_gap(
    values: ArrayLike,
    reference: ArrayLike,
    *,
    uncertainty: ArrayLike | None = None,
    direction: str | Sequence[str] = "higher_better",
    target: ArrayLike = 0.0,
) -> np.ndarray:
    """Signed distance of ``values`` from a reference, optionally in units of its uncertainty.

    Positive is always better, whichever way a metric is read, so one diverging
    colour scale can carry metrics in different units (see :func:`scorecard`).
    A ``"target"`` metric is scored by closeness to ``target``:
    ``|reference - target| - |values - target|``. Dividing by ``uncertainty``
    turns each gap into a signal-to-noise ratio: ``-3`` is three error bars
    worse than the reference, ``0`` indistinguishable from it.

    Args:
        values: ``(n_rows, n_cols)`` metric table; a 1-D input is one row.
        reference: Values to measure against, broadcast against ``values``.
        uncertainty: Scale the gap is divided by, in the metric's units (e.g. a
            standard error), broadcast against ``values``. Non-positive or
            non-finite entries give ``nan``. ``None`` keeps the metric's units.
        direction: ``"higher_better"``, ``"lower_better"`` or ``"target"``,
            for the whole table or one per column.
        target: Value a ``"target"`` metric is best at, broadcast against ``values``.

    Returns:
        ``(n_rows, n_cols)`` signed gaps, positive where ``values`` beat
        ``reference``; ``nan`` wherever an input is missing.

    Raises:
        ValueError: ``values`` and ``reference`` do not broadcast, a per-column
            ``direction`` does not label every column, or a direction is not in
            :data:`DIRECTIONS`.
    """
    values = np.atleast_2d(np.asarray(values, dtype=float))
    try:
        reference = np.broadcast_to(np.asarray(reference, dtype=float), values.shape)
        target = np.broadcast_to(np.asarray(target, dtype=float), values.shape)
    except ValueError:
        raise ValueError(f"values {values.shape} and reference must broadcast to the same shape") from None

    directions = [direction] * values.shape[1] if isinstance(direction, str) else list(direction)
    if len(directions) != values.shape[1]:
        raise ValueError(f"give one direction per column: {values.shape[1]} columns, {len(directions)} given")
    unknown = sorted({d for d in directions if d not in DIRECTIONS})
    if unknown:
        raise ValueError(f"unknown direction(s) {unknown}; known: {list(DIRECTIONS)}")

    gap = np.full(values.shape, np.nan)
    for j, how in enumerate(directions):
        if how == "higher_better":
            gap[:, j] = values[:, j] - reference[:, j]
        elif how == "lower_better":
            gap[:, j] = reference[:, j] - values[:, j]
        else:  # closeness to the target, so positive is still better
            gap[:, j] = np.abs(reference[:, j] - target[:, j]) - np.abs(values[:, j] - target[:, j])

    if uncertainty is not None:
        scale = np.broadcast_to(np.asarray(uncertainty, dtype=float), values.shape)
        with np.errstate(divide="ignore", invalid="ignore"):
            gap = np.where(np.isfinite(scale) & (scale > 0), gap / scale, np.nan)
    return gap

scorecard

scorecard(values: ArrayLike, *, row_labels: Sequence[str], col_labels: Sequence[str], ax: Axes | None = None, direction: Mapping[str, str] | None = None, per_column_norm: bool = True, highlight_best: bool = True, reference: ArrayLike | None = None, uncertainty: ArrayLike | None = None, target: ArrayLike = 0.0, gap_limit: float = 3.0, cell_format: str = '{:.3g}', col_units: Mapping[str, str] | None = None, cmap: str | Colormap = 'RdYlGn', na_color: Any = '#eeeeee', width: float | str = 'half', title: str = '', norm: Normalize | None = None, square: bool = False, col_groups: Sequence[str] | None = None, group_gap: float = 0.6, colorbar: bool = False, colorbar_label: str = '', colorbar_extend: str | None = None) -> Axes

Render a (n_rows, n_cols) metric table as a coloured-cell figure.

By default each cell is coloured by its per-column goodness rank (green is best) and the best cell per column is outlined. reference colours cells by :func:skill_gap instead, and norm by their raw value.

Parameters:

Name Type Description Default
values ArrayLike

Table of shape (len(row_labels), len(col_labels)); nan cells are drawn blank in na_color.

required
row_labels Sequence[str]

Row (model) names.

required
col_labels Sequence[str]

Column (metric) names.

required
ax Axes | None

Axes to draw on; a new figure is created if None.

None
direction Mapping[str, str] | None

{col_label: direction} with values from :data:~nicefigs.units.DIRECTIONS; unlisted columns are "higher_better". "target" ranks by closeness to target.

None
per_column_norm bool

Rank each column on its own min→max (else one global range). Ignored for colour when norm or reference is given.

True
highlight_best bool

Outline the best cell in each column by the goodness rank.

True
reference ArrayLike | None

Values to measure against, broadcast against values. Cell colour becomes the signed :func:skill_gap on one diverging scale; cell text stays the raw value.

None
uncertainty ArrayLike | None

With reference, the scale each gap is divided by.

None
target ArrayLike

The value a "target" metric is best at, broadcast against values.

0.0
gap_limit float

Gap at which the reference colour scale saturates.

3.0
cell_format str

Format string for the cell text.

'{:.3g}'
col_units Mapping[str, str] | None

{col_label: unit} shown below the column name.

None
cmap str | Colormap

Colormap name or object.

'RdYlGn'
na_color Any

Colour of missing cells.

'#eeeeee'
width float | str

Figure width (preset name or inches) when ax is None.

'half'
title str

Left-aligned title.

''
norm Normalize | None

Normalisation mapping raw values straight to cmap (e.g. from :func:nicefigs.scorecard_diverging_cmap).

None
square bool

Size the figure from the cell count and force square cells; only sizes the figure when ax is None.

False
col_groups Sequence[str] | None

One group name per column; consecutive equal names form a group with a gap before it and a bold title above it.

None
group_gap float

Gap before each group, in cell widths.

0.6
colorbar bool

Add a horizontal colorbar below the table.

False
colorbar_label str

Colorbar label.

''
colorbar_extend str | None

"neither", "min", "max" or "both"; inferred from cmap's under/over colours if None.

None

Returns:

Type Description
Axes

The axes.

Source code in src/nicefigs/tables.py
def scorecard(
    values: ArrayLike,
    *,
    row_labels: Sequence[str],
    col_labels: Sequence[str],
    ax: Axes | None = None,
    direction: Mapping[str, str] | None = None,
    per_column_norm: bool = True,
    highlight_best: bool = True,
    reference: ArrayLike | None = None,
    uncertainty: ArrayLike | None = None,
    target: ArrayLike = 0.0,
    gap_limit: float = 3.0,
    cell_format: str = "{:.3g}",
    col_units: Mapping[str, str] | None = None,
    cmap: str | Colormap = "RdYlGn",
    na_color: Any = "#eeeeee",
    width: float | str = "half",
    title: str = "",
    norm: Normalize | None = None,
    square: bool = False,
    col_groups: Sequence[str] | None = None,
    group_gap: float = 0.6,
    colorbar: bool = False,
    colorbar_label: str = "",
    colorbar_extend: str | None = None,
) -> Axes:
    """Render a ``(n_rows, n_cols)`` metric table as a coloured-cell figure.

    By default each cell is coloured by its per-column goodness rank (green is
    best) and the best cell per column is outlined. ``reference`` colours cells
    by :func:`skill_gap` instead, and ``norm`` by their raw value.

    Args:
        values: Table of shape ``(len(row_labels), len(col_labels))``; ``nan``
            cells are drawn blank in ``na_color``.
        row_labels: Row (model) names.
        col_labels: Column (metric) names.
        ax: Axes to draw on; a new figure is created if ``None``.
        direction: ``{col_label: direction}`` with values from
            :data:`~nicefigs.units.DIRECTIONS`; unlisted columns are
            ``"higher_better"``. ``"target"`` ranks by closeness to ``target``.
        per_column_norm: Rank each column on its own min→max (else one global
            range). Ignored for colour when ``norm`` or ``reference`` is given.
        highlight_best: Outline the best cell in each column by the goodness rank.
        reference: Values to measure against, broadcast against ``values``.
            Cell colour becomes the signed :func:`skill_gap` on one diverging
            scale; cell text stays the raw value.
        uncertainty: With ``reference``, the scale each gap is divided by.
        target: The value a ``"target"`` metric is best at, broadcast against
            ``values``.
        gap_limit: Gap at which the ``reference`` colour scale saturates.
        cell_format: Format string for the cell text.
        col_units: ``{col_label: unit}`` shown below the column name.
        cmap: Colormap name or object.
        na_color: Colour of missing cells.
        width: Figure width (preset name or inches) when ``ax`` is ``None``.
        title: Left-aligned title.
        norm: Normalisation mapping raw ``values`` straight to ``cmap`` (e.g. from
            :func:`nicefigs.scorecard_diverging_cmap`).
        square: Size the figure from the cell count and force square cells;
            only sizes the figure when ``ax`` is ``None``.
        col_groups: One group name per column; consecutive equal names form a
            group with a gap before it and a bold title above it.
        group_gap: Gap before each group, in cell widths.
        colorbar: Add a horizontal colorbar below the table.
        colorbar_label: Colorbar label.
        colorbar_extend: ``"neither"``, ``"min"``, ``"max"`` or ``"both"``;
            inferred from ``cmap``'s under/over colours if ``None``.

    Returns:
        The axes.
    """
    th = get_theme()
    values = np.asarray(values, dtype=float)
    nr, nc = values.shape
    directions = [check_direction((direction or {}).get(c, "higher_better")) for c in col_labels]
    targets = np.broadcast_to(np.asarray(target, dtype=float), values.shape)
    cm = resolve_cmap(cmap)

    lefts = np.zeros(nc)
    cur = 0.0
    for j in range(nc):
        if col_groups is not None and j > 0 and col_groups[j] != col_groups[j - 1]:
            cur += group_gap
        lefts[j] = cur
        cur += 1.0
    total_w = cur

    if ax is None:
        from .figure import subplots

        if square:
            fig_w = max(total_w * _SQUARE_CELL_INCH, 1.0)
            fig_h = max(nr * _SQUARE_CELL_INCH, 1.0)
            _, ax = subplots(width=fig_w, height=fig_h)
        else:
            _, ax = subplots(width=width, aspect=max(0.3, 0.12 * nr + 0.12))
    if square:
        ax.set_aspect("equal")

    # Goodness in [0, 1] (1 = best); drives `highlight_best` whatever colours the cells.
    good = np.full_like(values, 0.5)
    if per_column_norm:
        for j in range(nc):
            col = values[:, j]
            dirn = directions[j]
            metric = np.abs(col - targets[:, j]) if dirn == "target" else col
            finite = metric[np.isfinite(metric)]
            if finite.size == 0:
                continue
            lo, hi = finite.min(), finite.max()
            g = np.zeros_like(metric) if hi <= lo else (metric - lo) / (hi - lo)
            if dirn in ("lower_better", "target"):
                g = 1 - g
            good[:, j] = g
    else:
        finite = values[np.isfinite(values)]
        lo, hi = (finite.min(), finite.max()) if finite.size else (0, 1)
        good = np.zeros_like(values) if hi <= lo else (values - lo) / (hi - lo)

    gap = None
    if reference is not None:
        gap = skill_gap(
            values,
            reference,
            uncertainty=uncertainty,
            direction=directions,
            target=target,
        )
        norm = (
            norm if norm is not None else mcolors.TwoSlopeNorm(vcenter=0.0, vmin=-gap_limit, vmax=gap_limit)
        )

    ax.set_xlim(0, total_w)
    ax.set_ylim(0, nr)
    ax.invert_yaxis()

    for i in range(nr):
        for j in range(nc):
            v = values[i, j]
            if not np.isfinite(v):
                face = na_color
            elif gap is not None:
                face = na_color if not np.isfinite(gap[i, j]) else cm(norm(gap[i, j]))
            elif norm is not None:
                face = cm(norm(v))
            else:
                face = cm(float(np.clip(good[i, j], 0, 1)))
            ax.add_patch(
                mpatches.Rectangle((lefts[j], i), 1, 1, facecolor=face, edgecolor="white", linewidth=1.0)
            )
            if np.isfinite(v):
                r, g, b = face[:3] if isinstance(face, tuple) else (0.5, 0.5, 0.5)
                txtcol = "white" if (0.299 * r + 0.587 * g + 0.114 * b) < 0.5 else "#222222"
                ax.text(
                    lefts[j] + 0.5,
                    i + 0.5,
                    cell_format.format(v),
                    ha="center",
                    va="center",
                    fontsize=th.fs_annot,
                    color=txtcol,
                )

    if highlight_best:
        for j in range(nc):
            col_good = good[:, j].copy()
            col_good[~np.isfinite(values[:, j])] = -np.inf
            if np.isfinite(col_good).any():
                bi = int(np.argmax(col_good))
                ax.add_patch(
                    mpatches.Rectangle(
                        (lefts[j], bi), 1, 1, fill=False, edgecolor=th.spine_color, linewidth=1.6, zorder=5
                    )
                )

    ax.set_xticks(lefts + 0.5)
    heads = []
    for c in col_labels:
        u = (col_units or {}).get(c)
        heads.append(f"{c}\n[{prettify_unit(u)}]" if u else c)
    ax.set_xticklabels(heads, fontsize=th.fs_tick)
    ax.xaxis.set_ticks_position("top")
    ax.set_yticks(np.arange(nr) + 0.5)
    ax.set_yticklabels(row_labels, fontsize=th.fs_tick)
    ax.tick_params(length=0)
    for sp in ax.spines.values():
        sp.set_visible(False)

    if col_groups is not None:
        import matplotlib.transforms as mtransforms

        blend = mtransforms.blended_transform_factory(ax.transData, ax.transAxes)
        j = 0
        while j < nc:
            k = j
            while k + 1 < nc and col_groups[k + 1] == col_groups[j]:
                k += 1
            cx = 0.5 * (lefts[j] + lefts[k] + 1)
            ax.text(
                cx,
                1.22,
                col_groups[j],
                transform=blend,
                ha="center",
                va="bottom",
                fontsize=th.fs_tick,
                fontweight="bold",
            )
            j = k + 1

    if title:
        ax.set_title(title, fontsize=th.fs_title, pad=28 if col_groups is not None else 14, loc="left")

    if colorbar:
        from matplotlib.cm import ScalarMappable

        from .colorbars import colorbar as _draw_colorbar

        cb_norm = norm if norm is not None else mcolors.Normalize(vmin=0.0, vmax=1.0)
        extend = colorbar_extend if colorbar_extend is not None else _cmap_extend(cm)
        sm = ScalarMappable(norm=cb_norm, cmap=cm)
        sm.set_array([])
        _draw_colorbar(sm, ax, location="bottom", extend=extend, label=colorbar_label)

    return ax

nicefigs.sections

sections

Vertical cross-sections (curtains) and vertical profiles.

profile

profile(profiles: Mapping[str, ArrayLike] | ArrayLike, y: ArrayLike | None = None, *, ax: Axes | None = None, invert_y: bool = True, log_y: bool = False, colors: Mapping[str, str] | Sequence[str] | None = None, styles: Mapping[str, str] | None = None, markers: Mapping[str, str] | None = None, marker: str = 'o', baselines: Mapping[str, ArrayLike] | None = None, baseline_colors: Mapping[str, str] | None = None, xlabel: str = '', xunits: str | None = None, ylabel: str = 'level', yunits: str | None = None, title: str = '', legend: bool = True, width: float | str = 'half', aspect: float = 0.95) -> Axes

Draw one or more vertical profiles: value on x, level on y.

The 1-D companion to :func:cross_section. The vertical axis is inverted by default so that descending pressure levels put the surface at the bottom.

Parameters:

Name Type Description Default
profiles Mapping[str, ArrayLike] | ArrayLike

{name: values} along y, or a single array.

required
y ArrayLike | None

Vertical coordinate shared by all profiles (e.g. pressure); defaults to the level index.

None
ax Axes | None

Axes to draw on; a new figure is created when None.

None
invert_y bool

Invert the vertical axis.

True
log_y bool

Log-scale the vertical axis.

False
colors Mapping[str, str] | Sequence[str] | None

{name: colour} or a list cycled over the profiles; defaults to the theme's qualitative palette.

None
styles Mapping[str, str] | None

{name: linestyle}; default solid.

None
markers Mapping[str, str] | None

{name: marker} overriding marker.

None
marker str

Marker used for profiles not in markers.

'o'
baselines Mapping[str, ArrayLike] | None

{name: values} reference profiles drawn dashed.

None
baseline_colors Mapping[str, str] | None

{name: colour} for baselines; default grey.

None
xlabel str

Value-axis label.

''
xunits str | None

Value-axis units, appended to the label.

None
ylabel str

Vertical-axis label.

'level'
yunits str | None

Vertical-axis units, appended to the label.

None
title str

Axes title.

''
legend bool

Draw a legend when there is more than one line.

True
width float | str

Figure width in inches or a :data:nicefigs.figure.WIDTHS name, used only when a new figure is created.

'half'
aspect float

Figure height / width, used only when a new figure is created.

0.95

Returns:

Type Description
Axes

The axes.

Source code in src/nicefigs/sections.py
def profile(
    profiles: Mapping[str, ArrayLike] | ArrayLike,
    y: ArrayLike | None = None,
    *,
    ax: Axes | None = None,
    invert_y: bool = True,
    log_y: bool = False,
    colors: Mapping[str, str] | Sequence[str] | None = None,
    styles: Mapping[str, str] | None = None,
    markers: Mapping[str, str] | None = None,
    marker: str = "o",
    baselines: Mapping[str, ArrayLike] | None = None,
    baseline_colors: Mapping[str, str] | None = None,
    xlabel: str = "",
    xunits: str | None = None,
    ylabel: str = "level",
    yunits: str | None = None,
    title: str = "",
    legend: bool = True,
    width: float | str = "half",
    aspect: float = 0.95,
) -> Axes:
    """Draw one or more vertical profiles: value on x, level on y.

    The 1-D companion to :func:`cross_section`. The vertical axis is inverted by
    default so that descending pressure levels put the surface at the bottom.

    Args:
        profiles: ``{name: values}`` along ``y``, or a single array.
        y: Vertical coordinate shared by all profiles (e.g. pressure); defaults
            to the level index.
        ax: Axes to draw on; a new figure is created when ``None``.
        invert_y: Invert the vertical axis.
        log_y: Log-scale the vertical axis.
        colors: ``{name: colour}`` or a list cycled over the profiles; defaults to
            the theme's qualitative palette.
        styles: ``{name: linestyle}``; default solid.
        markers: ``{name: marker}`` overriding ``marker``.
        marker: Marker used for profiles not in ``markers``.
        baselines: ``{name: values}`` reference profiles drawn dashed.
        baseline_colors: ``{name: colour}`` for ``baselines``; default grey.
        xlabel: Value-axis label.
        xunits: Value-axis units, appended to the label.
        ylabel: Vertical-axis label.
        yunits: Vertical-axis units, appended to the label.
        title: Axes title.
        legend: Draw a legend when there is more than one line.
        width: Figure width in inches or a :data:`nicefigs.figure.WIDTHS` name,
            used only when a new figure is created.
        aspect: Figure ``height / width``, used only when a new figure is created.

    Returns:
        The axes.
    """
    th = get_theme()
    if not isinstance(profiles, dict):
        profiles = {"profile": profiles}
    names = list(profiles)
    palette = colors or {nm: c for nm, c in zip(names, qualitative(len(names)))}
    if not isinstance(palette, dict):
        palette = {nm: palette[i % len(palette)] for i, nm in enumerate(names)}
    marks = markers or {}

    if ax is None:
        _, ax = subplots(width=width, aspect=aspect)

    for nm in names:
        v = np.asarray(profiles[nm], dtype=float)
        yy = np.arange(v.size) if y is None else np.asarray(y, dtype=float)
        ax.plot(
            v,
            yy,
            linestyle=(styles or {}).get(nm, "-"),
            color=palette.get(nm),
            marker=marks.get(nm, marker),
            markersize=4,
            linewidth=th.lw_line,
            label=nm,
        )
    if baselines:
        bpal = baseline_colors or {}
        for nm, v in baselines.items():
            v = np.asarray(v, dtype=float)
            yy = np.arange(v.size) if y is None else np.asarray(y, dtype=float)
            ax.plot(
                v, yy, linestyle="--", color=bpal.get(nm, "#888888"), linewidth=th.lw_line * 0.8, label=nm
            )

    if log_y:
        ax.set_yscale("log")
    if invert_y and not ax.yaxis_inverted():
        ax.invert_yaxis()

    from .units import axis_label

    ax.set_xlabel(axis_label(xlabel, xunits))
    ax.set_ylabel(axis_label(ylabel, yunits))
    if title:
        ax.set_title(title, fontsize=th.fs_title, loc="left", pad=3)
    if legend and (len(names) > 1 or baselines):
        ax.legend(fontsize=th.fs_legend, framealpha=0.9)
    return ax

cross_section

cross_section(x: ArrayLike, y: ArrayLike, z: ArrayLike, *, ax: Axes | None = None, cmap: str | Colormap = 'Spectral_r', vmin: float | None = None, vmax: float | None = None, norm: Normalize | None = None, diff: bool = False, robust: bool = True, invert_y: bool = True, xlabel: str = 'latitude', ylabel: str = 'pressure', xunits: str | None = '°N', yunits: str | None = 'hPa', title: str = '', log_y: bool = False) -> tuple[Axes, QuadMesh]

Draw a field over a horizontal and a vertical coordinate as a curtain.

Parameters:

Name Type Description Default
ax Axes | None

Axes to draw on; a new figure is created if None.

None
x ArrayLike

Horizontal coordinate of length nx (e.g. latitude).

required
y ArrayLike

Vertical coordinate of length ny (e.g. pressure).

required
z ArrayLike

Field of shape (nx, ny).

required
cmap str | Colormap

Colormap name or object.

'Spectral_r'
vmin float | None

Lower colour limit; derived from z if None.

None
vmax float | None

Upper colour limit; derived from z if None.

None
norm Normalize | None

Colour normalisation; overrides vmin/vmax.

None
diff bool

Treat z as a difference: symmetric limits, and RdBu_r when cmap is left at its default.

False
robust bool

Derive limits from the 2nd/98th percentiles instead of min/max.

True
invert_y bool

Invert the vertical axis (surface at the bottom for pressure).

True
xlabel str

x-axis label.

'latitude'
ylabel str

y-axis label.

'pressure'
xunits str | None

Units appended to the x-axis label.

'°N'
yunits str | None

Units appended to the y-axis label.

'hPa'
title str

Axes title.

''
log_y bool

Log-scale the vertical axis.

False

Returns:

Type Description
tuple[Axes, QuadMesh]

(ax, mesh): the axes and the drawn :class:~matplotlib.collections.QuadMesh.

Source code in src/nicefigs/sections.py
def cross_section(
    x: ArrayLike,
    y: ArrayLike,
    z: ArrayLike,
    *,
    ax: Axes | None = None,
    cmap: str | Colormap = "Spectral_r",
    vmin: float | None = None,
    vmax: float | None = None,
    norm: Normalize | None = None,
    diff: bool = False,
    robust: bool = True,
    invert_y: bool = True,
    xlabel: str = "latitude",
    ylabel: str = "pressure",
    xunits: str | None = "°N",
    yunits: str | None = "hPa",
    title: str = "",
    log_y: bool = False,
) -> tuple[Axes, QuadMesh]:
    """Draw a field over a horizontal and a vertical coordinate as a curtain.

    Args:
        ax: Axes to draw on; a new figure is created if ``None``.
        x: Horizontal coordinate of length ``nx`` (e.g. latitude).
        y: Vertical coordinate of length ``ny`` (e.g. pressure).
        z: Field of shape ``(nx, ny)``.
        cmap: Colormap name or object.
        vmin: Lower colour limit; derived from ``z`` if ``None``.
        vmax: Upper colour limit; derived from ``z`` if ``None``.
        norm: Colour normalisation; overrides ``vmin``/``vmax``.
        diff: Treat ``z`` as a difference: symmetric limits, and ``RdBu_r`` when
            ``cmap`` is left at its default.
        robust: Derive limits from the 2nd/98th percentiles instead of min/max.
        invert_y: Invert the vertical axis (surface at the bottom for pressure).
        xlabel: x-axis label.
        ylabel: y-axis label.
        xunits: Units appended to the x-axis label.
        yunits: Units appended to the y-axis label.
        title: Axes title.
        log_y: Log-scale the vertical axis.

    Returns:
        ``(ax, mesh)``: the axes and the drawn :class:`~matplotlib.collections.QuadMesh`.
    """
    if ax is None:
        _, ax = subplots()
    th = get_theme()
    x = np.asarray(x, dtype=float)
    y = np.asarray(y, dtype=float)
    z = np.asarray(z, dtype=float)
    if diff and cmap == "Spectral_r":
        cmap = "RdBu_r"
    if norm is None and (vmin is None or vmax is None):
        if diff:
            lo, hi = symmetric_limits(z)
        elif robust:
            lo, hi = robust_limits(z)
        else:
            lo, hi = float(np.nanmin(z)), float(np.nanmax(z))
        vmin = lo if vmin is None else vmin
        vmax = hi if vmax is None else vmax

    kw = dict(cmap=resolve_cmap(cmap), shading="auto")
    if norm is not None:
        kw["norm"] = norm
    else:
        kw["vmin"], kw["vmax"] = vmin, vmax
    mesh = ax.pcolormesh(x, y, z.T, **kw)

    if log_y:
        ax.set_yscale("log")
    if invert_y and not ax.yaxis_inverted():
        ax.invert_yaxis()

    from .units import axis_label

    ax.set_xlabel(axis_label(xlabel, xunits))
    ax.set_ylabel(axis_label(ylabel, yunits))
    if title:
        ax.set_title(title, fontsize=th.fs_title, loc="left", pad=3)
    return ax, mesh

cross_section_panels

cross_section_panels(fields: Mapping[str, ArrayLike], x: ArrayLike, y: ArrayLike, *, diff_fields: Collection[str] = (), value_cmap: str | Colormap = 'Spectral_r', diff_cmap: str | Colormap = 'RdBu_r', value_label: str = '', diff_label: str = '', invert_y: bool = True, log_y: bool = False, width: float | str = 'full', panel_aspect: float = 0.8, ylabel: str = 'pressure', yunits: str | None = 'hPa', xlabel: str = 'latitude', xunits: str | None = '°N', title: str = '', titles: Sequence[str] | None = None) -> tuple[Figure, np.ndarray]

Lay out curtains in a row with shared value and difference colorbars.

Value fields share robust colour limits and one colorbar; difference fields share symmetric limits and a second, diverging colorbar.

Parameters:

Name Type Description Default
fields Mapping[str, ArrayLike]

{name: (nx, ny) array} in display order.

required
x ArrayLike

Shared horizontal coordinate of length nx.

required
y ArrayLike

Shared vertical coordinate of length ny.

required
diff_fields Collection[str]

Names in fields drawn on the difference scale.

()
value_cmap str | Colormap

Colormap of the value panels.

'Spectral_r'
diff_cmap str | Colormap

Colormap of the difference panel.

'RdBu_r'
value_label str

Label of the value colorbar.

''
diff_label str

Label of the difference colorbar.

''
invert_y bool

Invert the vertical axis.

True
log_y bool

Log-scale the vertical axis.

False
width float | str

Figure width in inches or a :data:nicefigs.figure.WIDTHS name.

'full'
panel_aspect float

Height / width of each panel.

0.8
ylabel str

Vertical-axis label (first panel only).

'pressure'
yunits str | None

Vertical-axis units, appended to the label (first panel only).

'hPa'
xlabel str

Horizontal-axis label.

'latitude'
xunits str | None

Horizontal-axis units, appended to the label.

'°N'
title str

Figure title above the row.

''
titles Sequence[str] | None

Per-panel titles aligned with fields; defaults to the field names.

None

Returns:

Type Description
tuple[Figure, ndarray]

(fig, axes) with axes a flat array, one per field.

Source code in src/nicefigs/sections.py
def cross_section_panels(
    fields: Mapping[str, ArrayLike],
    x: ArrayLike,
    y: ArrayLike,
    *,
    diff_fields: Collection[str] = (),
    value_cmap: str | Colormap = "Spectral_r",
    diff_cmap: str | Colormap = "RdBu_r",
    value_label: str = "",
    diff_label: str = "",
    invert_y: bool = True,
    log_y: bool = False,
    width: float | str = "full",
    panel_aspect: float = 0.8,
    ylabel: str = "pressure",
    yunits: str | None = "hPa",
    xlabel: str = "latitude",
    xunits: str | None = "°N",
    title: str = "",
    titles: Sequence[str] | None = None,
) -> tuple[Figure, np.ndarray]:
    """Lay out curtains in a row with shared value and difference colorbars.

    Value fields share robust colour limits and one colorbar; difference fields
    share symmetric limits and a second, diverging colorbar.

    Args:
        fields: ``{name: (nx, ny) array}`` in display order.
        x: Shared horizontal coordinate of length ``nx``.
        y: Shared vertical coordinate of length ``ny``.
        diff_fields: Names in ``fields`` drawn on the difference scale.
        value_cmap: Colormap of the value panels.
        diff_cmap: Colormap of the difference panel.
        value_label: Label of the value colorbar.
        diff_label: Label of the difference colorbar.
        invert_y: Invert the vertical axis.
        log_y: Log-scale the vertical axis.
        width: Figure width in inches or a :data:`nicefigs.figure.WIDTHS` name.
        panel_aspect: Height / width of each panel.
        ylabel: Vertical-axis label (first panel only).
        yunits: Vertical-axis units, appended to the label (first panel only).
        xlabel: Horizontal-axis label.
        xunits: Horizontal-axis units, appended to the label.
        title: Figure title above the row.
        titles: Per-panel titles aligned with ``fields``; defaults to the field
            names.

    Returns:
        ``(fig, axes)`` with ``axes`` a flat array, one per field.
    """
    from .colorbars import colorbar
    from .figure import subplots

    names = list(fields)
    diff_set = set(diff_fields)
    n = len(names)
    fig, axes = subplots(1, n, width=width, panel_aspect=panel_aspect, wspace=0.14, squeeze=False)
    axes = axes.ravel()

    val_names = [nm for nm in names if nm not in diff_set]
    dif_names = [nm for nm in names if nm in diff_set]
    val_lim = (
        robust_limits(np.concatenate([np.asarray(fields[nm]).ravel() for nm in val_names]))
        if val_names
        else None
    )
    dif_lim = (
        symmetric_limits(np.concatenate([np.asarray(fields[nm]).ravel() for nm in dif_names]))
        if dif_names
        else None
    )

    val_m = dif_m = None
    for i, (ax, nm) in enumerate(zip(axes, names)):
        is_diff = nm in diff_set
        lim = dif_lim if is_diff else val_lim
        panel_title = titles[i] if titles is not None and i < len(titles) else nm
        _, m = cross_section(
            x,
            y,
            fields[nm],
            cmap=(diff_cmap if is_diff else value_cmap),
            vmin=lim[0],
            vmax=lim[1],
            diff=is_diff,
            invert_y=invert_y,
            log_y=log_y,
            xlabel=xlabel,
            xunits=xunits,
            ylabel=(ylabel if ax is axes[0] else ""),
            yunits=yunits,
            title=panel_title,
            ax=ax,
        )
        if ax is not axes[0]:
            ax.set_yticklabels([])
        if is_diff:
            dif_m = m
        else:
            val_m = m
    # Extra pad so the horizontal bars sit clear below the x-axis labels.
    if val_m is not None:
        colorbar(
            val_m,
            [ax for ax, nm in zip(axes, names) if nm not in diff_set],
            label=value_label,
            location="bottom",
            thickness=0.03,
            shrink=0.85,
            pad=0.14,
        )
    if dif_m is not None:
        colorbar(
            dif_m,
            [ax for ax, nm in zip(axes, names) if nm in diff_set],
            label=diff_label,
            location="bottom",
            thickness=0.03,
            shrink=0.85,
            pad=0.14,
        )
    if title:
        # Sit clear above the per-panel titles (the row is short, so 1.02 collides).
        fig.suptitle(title, fontsize=get_theme().fs_panel_label, y=1.16)
    return fig, axes

nicefigs.taylor

taylor

Taylor diagrams: correlation, standard deviation and centred RMS difference in one polar plot.

MARKERS module-attribute

MARKERS: list[str] = ['o', 's', '^', 'D', 'v', 'P', 'X', '*', '<', '>', 'p', 'h']

taylor

taylor(stats: Mapping[str, Any] | Sequence[Mapping[str, Any]], *, ax: Axes | None = None, ref_std: float = 1.0, normalize: bool = True, ref_label: str = 'Reference', std_label: str | None = None, colors: Sequence[Any] | None = None, markers: Sequence[str] | None = None, marker_size: float = 9.0, rms_contours: bool = True, n_rms: int = 4, corr_ticks: Sequence[float] = (0.0, 0.2, 0.4, 0.6, 0.8, 0.9, 0.95, 0.99, 1.0), negative: bool = False, title: str = '', legend: bool = True, width: float | str = 'half') -> Axes

Draw a Taylor diagram of one or more models against a reference.

Each model sits at angle arccos(r) (its correlation r with the reference) and radius equal to its standard deviation; its distance to the reference point is the centred RMS difference.

Parameters:

Name Type Description Default
stats Mapping[str, Any] | Sequence[Mapping[str, Any]]

A list of dicts, one per model, or {name: dict}, or {name: (std, corr)}. Each dict holds name (optional with the mapping forms), a correlation corr (or correlation), and either an absolute std or a normalised std_ratio (σ_model / σ_ref). Other keys are ignored.

required
ax Axes | None

Existing polar axes to draw on; a new one is created if None.

None
ref_std float

Reference standard deviation, in the units of std.

1.0
normalize bool

Divide absolute std values by ref_std so the reference sits at radius 1; otherwise it sits at ref_std and std_ratio values are multiplied by ref_std.

True
ref_label str

Legend label of the reference point.

'Reference'
std_label str | None

Radial-axis label; defaults to "(Normalized) standard deviation".

None
colors Sequence[Any] | None

Per-model colours; defaults to the theme palette.

None
markers Sequence[str] | None

Per-model markers; defaults to :data:MARKERS.

None
marker_size float

Model marker size (pt).

9.0
rms_contours bool

Draw dotted centred-RMS-difference arcs around the reference.

True
n_rms int

Number of RMS arcs.

4
corr_ticks Sequence[float]

Correlations marked on the angular axis.

(0.0, 0.2, 0.4, 0.6, 0.8, 0.9, 0.95, 0.99, 1.0)
negative bool

Extend the diagram to negative correlations (half circle).

False
title str

Left-aligned title.

''
legend bool

Add a legend of the model markers outside the axes.

True
width float | str

Figure width (preset name or inches) when ax is None.

'half'

Returns:

Type Description
Axes

The polar axes.

Raises:

Type Description
KeyError

An entry has no correlation or no standard deviation.

Source code in src/nicefigs/taylor.py
def taylor(
    stats: Mapping[str, Any] | Sequence[Mapping[str, Any]],
    *,
    ax: Axes | None = None,
    ref_std: float = 1.0,
    normalize: bool = True,
    ref_label: str = "Reference",
    std_label: str | None = None,
    colors: Sequence[Any] | None = None,
    markers: Sequence[str] | None = None,
    marker_size: float = 9.0,
    rms_contours: bool = True,
    n_rms: int = 4,
    corr_ticks: Sequence[float] = (0.0, 0.2, 0.4, 0.6, 0.8, 0.9, 0.95, 0.99, 1.0),
    negative: bool = False,
    title: str = "",
    legend: bool = True,
    width: float | str = "half",
) -> Axes:
    """Draw a Taylor diagram of one or more models against a reference.

    Each model sits at angle ``arccos(r)`` (its correlation ``r`` with the
    reference) and radius equal to its standard deviation; its distance to the
    reference point is the centred RMS difference.

    Args:
        stats: A list of dicts, one per model, or ``{name: dict}``, or
            ``{name: (std, corr)}``. Each dict holds ``name`` (optional with the
            mapping forms), a correlation ``corr`` (or ``correlation``), and
            either an absolute ``std`` or a normalised ``std_ratio``
            (σ_model / σ_ref). Other keys are ignored.
        ax: Existing polar axes to draw on; a new one is created if ``None``.
        ref_std: Reference standard deviation, in the units of ``std``.
        normalize: Divide absolute ``std`` values by ``ref_std`` so the
            reference sits at radius 1; otherwise it sits at ``ref_std`` and
            ``std_ratio`` values are multiplied by ``ref_std``.
        ref_label: Legend label of the reference point.
        std_label: Radial-axis label; defaults to "(Normalized) standard deviation".
        colors: Per-model colours; defaults to the theme palette.
        markers: Per-model markers; defaults to :data:`MARKERS`.
        marker_size: Model marker size (pt).
        rms_contours: Draw dotted centred-RMS-difference arcs around the reference.
        n_rms: Number of RMS arcs.
        corr_ticks: Correlations marked on the angular axis.
        negative: Extend the diagram to negative correlations (half circle).
        title: Left-aligned title.
        legend: Add a legend of the model markers outside the axes.
        width: Figure width (preset name or inches) when ``ax`` is ``None``.

    Returns:
        The polar axes.

    Raises:
        KeyError: An entry has no correlation or no standard deviation.
    """
    import matplotlib.pyplot as plt

    rows = _coerce(stats)
    th = get_theme()
    ref = 1.0 if normalize else float(ref_std)

    def _std(row):
        if row["is_ratio"]:
            return row["std"] if normalize else row["std"] * ref_std
        return row["std"] / ref_std if normalize else row["std"]

    stds = [_std(r) for r in rows]

    if ax is None:
        from .figure import figure_size
        from .style import style

        with style(th):
            fig = plt.figure(figsize=figure_size(width, aspect=1.0), dpi=th.dpi)
            ax = fig.add_subplot(111, projection="polar")

    theta_max = np.pi if negative else np.pi / 2.0
    ax.set_thetamin(0)
    ax.set_thetamax(np.degrees(theta_max))
    ax.set_theta_zero_location("E")
    ax.set_theta_direction(1)

    smax = max([ref, *stds]) * 1.3 if stds else ref * 1.3
    ax.set_ylim(0, smax)

    # Angular (correlation) grid.
    ticks = list(corr_ticks)
    if negative:
        ticks = sorted({*ticks, *[-t for t in corr_ticks]})
    ang = np.degrees(np.arccos(np.clip(ticks, -1, 1)))
    ax.set_thetagrids(ang, labels=[f"{t:g}" for t in ticks])
    ax.tick_params(axis="x", labelsize=th.fs_tick)
    ax.tick_params(axis="y", labelsize=th.fs_tick)

    # Centred-RMS-difference arcs, centred on the reference point (ref, 0).
    if rms_contours:
        rmax = np.hypot(smax, ref)
        for R in np.linspace(rmax / (n_rms + 1), rmax, n_rms):
            phi = np.linspace(0, 2 * np.pi, 361)
            x = ref + R * np.cos(phi)
            y = R * np.sin(phi)
            r = np.hypot(x, y)
            t = np.arctan2(y, x)
            keep = (t >= -1e-9) & (t <= theta_max + 1e-9) & (r <= smax)
            if keep.sum() > 1:
                ax.plot(t[keep], r[keep], ls=":", color="#9aa0a6", lw=0.6, zorder=1)

    # Reference point + its std arc.
    ax.plot(
        np.linspace(0, theta_max, 100),
        np.full(100, ref),
        ls="--",
        color=th.spine_color,
        lw=0.6,
        alpha=0.6,
        zorder=1,
    )
    ax.plot(
        0,
        ref,
        marker="*",
        markersize=marker_size + 5,
        color=th.spine_color,
        zorder=6,
        label=ref_label,
        linestyle="none",
    )

    palette = colors or qualitative(len(rows))
    marks = markers or MARKERS
    for i, row in enumerate(rows):
        ax.plot(
            np.arccos(np.clip(row["corr"], -1, 1)),
            _std(row),
            marker=marks[i % len(marks)],
            color=palette[i % len(palette)],
            markersize=marker_size,
            linestyle="none",
            zorder=5,
            label=row["name"] or None,
        )

    ax.set_rlabel_position(90 if negative else 135)
    lbl = std_label or ("Normalized standard deviation" if normalize else "Standard deviation")
    ax.set_ylabel(lbl, fontsize=th.fs_axis_label, labelpad=22)
    ax.text(
        np.radians(45 if not negative else 90),
        smax * 1.12,
        "Correlation",
        ha="center",
        va="center",
        fontsize=th.fs_axis_label,
        rotation=(-45 if not negative else 0),
    )
    if title:
        ax.set_title(title, fontsize=th.fs_title, loc="left", pad=10)
    if legend and any(r["name"] for r in rows):
        ax.legend(
            loc="upper right",
            bbox_to_anchor=(1.32, 1.08),
            fontsize=th.fs_legend,
            framealpha=0.9,
            markerscale=0.6,
        )
    return ax

nicefigs.hovmoller

hovmoller

Hovmöller diagrams: a field over time and one spatial coordinate.

hovmoller

hovmoller(x: ArrayLike, y: ArrayLike, z: ArrayLike, *, ax: Axes | None = None, cmap: str | Colormap = 'Spectral_r', vmin: float | None = None, vmax: float | None = None, norm: Normalize | None = None, diff: bool = False, robust: bool = True, colorbar: bool | None = None, colorbar_label: str = '', xlabel: str = 'time', ylabel: str = 'latitude', xunits: str | None = None, yunits: str | None = '°N', title: str = '', width: float | str = 'full', aspect: float = 0.5) -> tuple[Axes, QuadMesh]

Draw a field over time (x axis) and one spatial coordinate (y axis).

Parameters:

Name Type Description Default
x ArrayLike

Time coordinate of length nt: numbers or datetime objects (dates get a compact date axis).

required
y ArrayLike

Spatial coordinate of length ny, e.g. latitude.

required
z ArrayLike

Field of shape (nt, ny).

required
ax Axes | None

Axes to draw on; a new figure is created when None.

None
cmap str | Colormap

Colormap name or object.

'Spectral_r'
vmin float | None

Lower colour limit; derived from z if None.

None
vmax float | None

Upper colour limit; derived from z if None.

None
norm Normalize | None

Colour normalisation; overrides vmin/vmax.

None
diff bool

Treat z as a difference: symmetric limits, and RdBu_r when cmap is left at its default.

False
robust bool

Derive limits from the 2nd/98th percentiles instead of min/max.

True
colorbar bool | None

Draw a colorbar. Defaults to True when this call creates the figure and False when drawing onto a supplied ax.

None
colorbar_label str

Colorbar label.

''
xlabel str

x-axis label; "" leaves the axis unlabelled.

'time'
ylabel str

y-axis label; "" leaves the axis unlabelled.

'latitude'
xunits str | None

Units appended to the x-axis label.

None
yunits str | None

Units appended to the y-axis label.

'°N'
title str

Axes title.

''
width float | str

Figure width in inches or a :data:nicefigs.figure.WIDTHS name, used only when a new figure is created.

'full'
aspect float

Figure height / width, used only when a new figure is created.

0.5

Returns:

Type Description
tuple[Axes, QuadMesh]

(ax, mesh): the axes and the QuadMesh, for composing colorbars.

Source code in src/nicefigs/hovmoller.py
def hovmoller(
    x: ArrayLike,
    y: ArrayLike,
    z: ArrayLike,
    *,
    ax: Axes | None = None,
    cmap: str | Colormap = "Spectral_r",
    vmin: float | None = None,
    vmax: float | None = None,
    norm: Normalize | None = None,
    diff: bool = False,
    robust: bool = True,
    colorbar: bool | None = None,
    colorbar_label: str = "",
    xlabel: str = "time",
    ylabel: str = "latitude",
    xunits: str | None = None,
    yunits: str | None = "°N",
    title: str = "",
    width: float | str = "full",
    aspect: float = 0.5,
) -> tuple[Axes, QuadMesh]:
    """Draw a field over time (x axis) and one spatial coordinate (y axis).

    Args:
        x: Time coordinate of length ``nt``: numbers or ``datetime`` objects
            (dates get a compact date axis).
        y: Spatial coordinate of length ``ny``, e.g. latitude.
        z: Field of shape ``(nt, ny)``.
        ax: Axes to draw on; a new figure is created when ``None``.
        cmap: Colormap name or object.
        vmin: Lower colour limit; derived from ``z`` if ``None``.
        vmax: Upper colour limit; derived from ``z`` if ``None``.
        norm: Colour normalisation; overrides ``vmin``/``vmax``.
        diff: Treat ``z`` as a difference: symmetric limits, and ``RdBu_r`` when
            ``cmap`` is left at its default.
        robust: Derive limits from the 2nd/98th percentiles instead of min/max.
        colorbar: Draw a colorbar. Defaults to ``True`` when this call creates the
            figure and ``False`` when drawing onto a supplied ``ax``.
        colorbar_label: Colorbar label.
        xlabel: x-axis label; ``""`` leaves the axis unlabelled.
        ylabel: y-axis label; ``""`` leaves the axis unlabelled.
        xunits: Units appended to the x-axis label.
        yunits: Units appended to the y-axis label.
        title: Axes title.
        width: Figure width in inches or a :data:`nicefigs.figure.WIDTHS` name,
            used only when a new figure is created.
        aspect: Figure ``height / width``, used only when a new figure is created.

    Returns:
        ``(ax, mesh)``: the axes and the ``QuadMesh``, for composing colorbars.
    """
    th = get_theme()
    y = np.asarray(y, dtype=float)
    z = np.asarray(z, dtype=float)
    if diff and cmap == "Spectral_r":
        cmap = "RdBu_r"
    if norm is None and (vmin is None or vmax is None):
        if diff:
            lo, hi = symmetric_limits(z)
        elif robust:
            lo, hi = robust_limits(z)
        else:
            lo, hi = float(np.nanmin(z)), float(np.nanmax(z))
        vmin = lo if vmin is None else vmin
        vmax = hi if vmax is None else vmax

    owns_fig = ax is None
    if colorbar is None:
        colorbar = owns_fig
    if ax is None:
        from .figure import subplots

        _, ax = subplots(width=width, aspect=aspect)

    xv = np.asarray(x)
    is_dates = _is_dates(x)
    if is_dates:
        import matplotlib.dates as mdates

        xv = mdates.date2num(list(x))

    kw = dict(cmap=resolve_cmap(cmap), shading="auto")
    if norm is not None:
        kw["norm"] = norm
    else:
        kw["vmin"], kw["vmax"] = vmin, vmax
    mesh = ax.pcolormesh(xv, y, z.T, **kw)

    if is_dates:
        from .timeseries import _format_date_axis

        _format_date_axis(ax, list(x))
    if xlabel:
        ax.set_xlabel(axis_label(xlabel, xunits))
    if ylabel:
        ax.set_ylabel(axis_label(ylabel, yunits))
    if title:
        ax.set_title(title, fontsize=th.fs_title, loc="left", pad=3)
    if colorbar:
        from .colorbars import colorbar as _cbar

        _cbar(mesh, ax, label=colorbar_label)
    return ax, mesh

hovmoller_panels

hovmoller_panels(fields: Mapping[str, ArrayLike], x: ArrayLike, y: ArrayLike, *, diff_fields: Collection[str] = (), value_cmap: str | Colormap = 'Spectral_r', diff_cmap: str | Colormap = 'RdBu_r', value_label: str = '', diff_label: str = '', xlabel: str = 'time', xunits: str | None = None, ylabel: str = 'latitude', yunits: str | None = '°N', width: float | str = 'full', panel_aspect: float = 0.62, title: str = '', titles: Sequence[str] | None = None) -> tuple[Figure, np.ndarray]

Lay out Hovmöller panels in a row with shared value and difference colorbars.

Value fields share robust colour limits and one colorbar; difference fields share symmetric limits and a second, diverging colorbar.

Parameters:

Name Type Description Default
x ArrayLike

Shared time coordinate of length nt.

required
fields Mapping[str, ArrayLike]

{name: (nt, ny) array} in display order.

required
y ArrayLike

Shared spatial coordinate of length ny.

required
diff_fields Collection[str]

Names in fields drawn on the difference scale.

()
value_cmap str | Colormap

Colormap of the value panels.

'Spectral_r'
diff_cmap str | Colormap

Colormap of the difference panel.

'RdBu_r'
value_label str

Label of the value colorbar.

''
diff_label str

Label of the difference colorbar.

''
xlabel str

Time-axis label.

'time'
xunits str | None

Time-axis units, appended to the label.

None
ylabel str

Spatial-axis label (first panel only).

'latitude'
yunits str | None

Spatial-axis units, appended to the label (first panel only).

'°N'
width float | str

Figure width in inches or a :data:nicefigs.figure.WIDTHS name.

'full'
panel_aspect float

Height / width of each panel.

0.62
title str

Figure title above the row.

''
titles Sequence[str] | None

Per-panel titles aligned with fields; defaults to the field names.

None

Returns:

Type Description
tuple[Figure, ndarray]

(fig, axes) with axes a flat array, one per field.

Source code in src/nicefigs/hovmoller.py
def hovmoller_panels(
    fields: Mapping[str, ArrayLike],
    x: ArrayLike,
    y: ArrayLike,
    *,
    diff_fields: Collection[str] = (),
    value_cmap: str | Colormap = "Spectral_r",
    diff_cmap: str | Colormap = "RdBu_r",
    value_label: str = "",
    diff_label: str = "",
    xlabel: str = "time",
    xunits: str | None = None,
    ylabel: str = "latitude",
    yunits: str | None = "°N",
    width: float | str = "full",
    panel_aspect: float = 0.62,
    title: str = "",
    titles: Sequence[str] | None = None,
) -> tuple[Figure, np.ndarray]:
    """Lay out Hovmöller panels in a row with shared value and difference colorbars.

    Value fields share robust colour limits and one colorbar; difference fields
    share symmetric limits and a second, diverging colorbar.

    Args:
        x: Shared time coordinate of length ``nt``.
        fields: ``{name: (nt, ny) array}`` in display order.
        y: Shared spatial coordinate of length ``ny``.
        diff_fields: Names in ``fields`` drawn on the difference scale.
        value_cmap: Colormap of the value panels.
        diff_cmap: Colormap of the difference panel.
        value_label: Label of the value colorbar.
        diff_label: Label of the difference colorbar.
        xlabel: Time-axis label.
        xunits: Time-axis units, appended to the label.
        ylabel: Spatial-axis label (first panel only).
        yunits: Spatial-axis units, appended to the label (first panel only).
        width: Figure width in inches or a :data:`nicefigs.figure.WIDTHS` name.
        panel_aspect: Height / width of each panel.
        title: Figure title above the row.
        titles: Per-panel titles aligned with ``fields``; defaults to the field
            names.

    Returns:
        ``(fig, axes)`` with ``axes`` a flat array, one per field.
    """
    from .colorbars import colorbar
    from .figure import subplots

    names = list(fields)
    diff_set = set(diff_fields)
    n = len(names)
    fig, axes = subplots(1, n, width=width, panel_aspect=panel_aspect, wspace=0.12, squeeze=False)
    axes = axes.ravel()

    val_names = [nm for nm in names if nm not in diff_set]
    dif_names = [nm for nm in names if nm in diff_set]
    val_lim = (
        robust_limits(np.concatenate([np.asarray(fields[nm]).ravel() for nm in val_names]))
        if val_names
        else None
    )
    dif_lim = (
        symmetric_limits(np.concatenate([np.asarray(fields[nm]).ravel() for nm in dif_names]))
        if dif_names
        else None
    )

    val_m = dif_m = None
    for i, (ax, nm) in enumerate(zip(axes, names)):
        is_diff = nm in diff_set
        lim = dif_lim if is_diff else val_lim
        panel_title = titles[i] if titles is not None and i < len(titles) else nm
        _, m = hovmoller(
            x,
            y,
            fields[nm],
            ax=ax,
            cmap=(diff_cmap if is_diff else value_cmap),
            vmin=lim[0],
            vmax=lim[1],
            diff=is_diff,
            colorbar=False,
            xlabel=xlabel,
            xunits=xunits,
            ylabel=(ylabel if ax is axes[0] else ""),
            yunits=yunits,
            title=panel_title,
        )
        if ax is not axes[0]:
            ax.set_yticklabels([])
        if is_diff:
            dif_m = m
        else:
            val_m = m
    if val_m is not None:
        colorbar(
            val_m,
            [ax for ax, nm in zip(axes, names) if nm not in diff_set],
            label=value_label,
            location="bottom",
            thickness=0.03,
            shrink=0.85,
            pad=0.26,
        )
    if dif_m is not None:
        colorbar(
            dif_m,
            [ax for ax, nm in zip(axes, names) if nm in diff_set],
            label=diff_label,
            location="bottom",
            thickness=0.03,
            shrink=0.85,
            pad=0.26,
        )
    if title:
        # Sit clear above the per-panel titles (the row is short, so 1.02 collides).
        fig.suptitle(title, fontsize=get_theme().fs_panel_label, y=1.16)
    return fig, axes

nicefigs.verification

verification

Verification diagrams for probabilistic forecasts and generative models.

:func:reliability plots observed against nominal coverage (or a PIT calibration curve), :func:rank_histogram and :func:rank_histogram_panels draw Talagrand diagrams, and :func:precision_recall draws precision-recall curves and points.

reliability

reliability(nominal: ArrayLike | Mapping[str, ArrayLike], observed: ArrayLike | Mapping[str, ArrayLike], *, ax: Axes | None = None, colors: Mapping[str, str] | None = None, markers: str | Mapping[str, str] | None = None, highlight: float | None = 0.9, highlight_color: str | None = None, diagonal_label: str = 'Perfect calibration', xlabel: str = 'Nominal coverage', ylabel: str = 'Observed coverage', title: str = '', legend: bool = True, marker_size: float = 4.0, width: float | str = 'half') -> Axes

Draw a reliability diagram: observed against nominal coverage, with the 1:1 line.

For a PIT calibration plot, pass probability levels as nominal and the empirical CDF of the PIT values at those levels as observed.

Parameters:

Name Type Description Default
nominal ArrayLike | Mapping[str, ArrayLike]

Nominal levels in [0, 1] shared by all curves, or {name: levels} for curves evaluated at different levels.

required
observed ArrayLike | Mapping[str, ArrayLike]

{name: observed_frequency} aligned with nominal, or one array for a single unnamed curve.

required
ax Axes | None

Axes to draw on; a new square figure is created when None.

None
colors Mapping[str, str] | None

{name: colour}; defaults to the theme's qualitative palette.

None
markers str | Mapping[str, str] | None

{name: marker}, or one marker for all curves; default "o".

None
highlight float | None

Nominal level marked on the diagonal (e.g. 0.90); None omits the mark.

0.9
highlight_color str | None

Colour of the highlight mark; default neutral grey.

None
diagonal_label str

Legend label of the 1:1 line; "" keeps the line but leaves it out of the legend.

'Perfect calibration'
xlabel str

x-axis label.

'Nominal coverage'
ylabel str

y-axis label.

'Observed coverage'
title str

Axes title.

''
legend bool

Draw a legend.

True
marker_size float

Marker size in points.

4.0
width float | str

Figure width in inches or a :data:nicefigs.figure.WIDTHS name, used only when a new figure is created.

'half'

Returns:

Type Description
Axes

The axes.

Source code in src/nicefigs/verification.py
def reliability(
    nominal: ArrayLike | Mapping[str, ArrayLike],
    observed: ArrayLike | Mapping[str, ArrayLike],
    *,
    ax: Axes | None = None,
    colors: Mapping[str, str] | None = None,
    markers: str | Mapping[str, str] | None = None,
    highlight: float | None = 0.90,
    highlight_color: str | None = None,
    diagonal_label: str = "Perfect calibration",
    xlabel: str = "Nominal coverage",
    ylabel: str = "Observed coverage",
    title: str = "",
    legend: bool = True,
    marker_size: float = 4.0,
    width: float | str = "half",
) -> Axes:
    """Draw a reliability diagram: observed against nominal coverage, with the 1:1 line.

    For a PIT calibration plot, pass probability levels as ``nominal`` and the
    empirical CDF of the PIT values at those levels as ``observed``.

    Args:
        nominal: Nominal levels in ``[0, 1]`` shared by all curves, or
            ``{name: levels}`` for curves evaluated at different levels.
        observed: ``{name: observed_frequency}`` aligned with ``nominal``, or one
            array for a single unnamed curve.
        ax: Axes to draw on; a new square figure is created when ``None``.
        colors: ``{name: colour}``; defaults to the theme's qualitative palette.
        markers: ``{name: marker}``, or one marker for all curves; default ``"o"``.
        highlight: Nominal level marked on the diagonal (e.g. ``0.90``); ``None``
            omits the mark.
        highlight_color: Colour of the highlight mark; default neutral grey.
        diagonal_label: Legend label of the 1:1 line; ``""`` keeps the line but
            leaves it out of the legend.
        xlabel: x-axis label.
        ylabel: y-axis label.
        title: Axes title.
        legend: Draw a legend.
        marker_size: Marker size in points.
        width: Figure width in inches or a :data:`nicefigs.figure.WIDTHS` name,
            used only when a new figure is created.

    Returns:
        The axes.
    """
    th = get_theme()
    if not isinstance(observed, dict):
        observed = {"": np.asarray(observed, dtype=float)}
    names = list(observed)
    nom_map = nominal if isinstance(nominal, dict) else None

    if ax is None:
        from .figure import subplots

        _, ax = subplots(width=width, aspect=1.0)

    ax.plot([0, 1], [0, 1], ls="--", lw=0.8, color=COLORS["neutral"], zorder=1, label=diagonal_label or None)

    if highlight is not None:
        ax.plot(
            highlight,
            highlight,
            marker="+",
            markersize=8,
            mew=1.2,
            color=highlight_color or COLORS["neutral"],
            zorder=2,
        )
        ax.axvline(highlight, color=COLORS["neutral"], lw=0.5, ls=":", alpha=0.5, zorder=0)

    cols = colors or dict(zip(names, qualitative(len(names))))
    for name in names:
        x = np.asarray(nom_map[name] if nom_map else nominal, dtype=float)
        y = np.asarray(observed[name], dtype=float)
        mk = markers.get(name) if isinstance(markers, dict) else markers
        ax.plot(
            x,
            y,
            color=cols[name],
            lw=th.lw_line,
            marker=mk or "o",
            markersize=marker_size,
            zorder=4,
            label=name or None,
        )

    ax.set_xlim(0, 1)
    ax.set_ylim(0, 1)
    ax.set_aspect("equal")
    ax.set_xlabel(xlabel)
    ax.set_ylabel(ylabel)
    if title:
        from .panels import clean_title

        clean_title(ax, title)
    ax.grid(alpha=0.15, linewidth=0.5)
    ax.set_axisbelow(True)
    if legend and (len(names) > 1 or (names and names[0]) or diagonal_label):
        ax.legend(loc="best", fontsize=th.fs_legend)
    return ax

rank_histogram

rank_histogram(ranks: ArrayLike, *, n_ranks: int | None = None, ax: Axes | None = None, color: str | None = None, ideal: bool = True, normalize: bool = True, summary: tuple[str, float] | None = None, xlabel: str = 'Rank', ylabel: str = 'Frequency', title: str = '', width: float | str = 'half', aspect: float = 0.72) -> Axes

Draw a rank (Talagrand) histogram of verification ranks.

Parameters:

Name Type Description Default
ranks ArrayLike

Integer rank per event: the 0-indexed position of the truth among the sorted ensemble members, in [0, n_members].

required
n_ranks int | None

Number of rank bins (n_members + 1); defaults to max(ranks) + 1.

None
ax Axes | None

Axes to draw on; a new figure is created when None.

None
color str | None

Bar colour; default the theme's blue.

None
ideal bool

Draw the flat reference a perfectly calibrated ensemble attains.

True
normalize bool

Show frequencies summing to 1 instead of counts.

True
summary tuple[str, float] | None

(name, value) written in the top-left corner, e.g. a calibration statistic.

None
xlabel str

x-axis label.

'Rank'
ylabel str

y-axis label.

'Frequency'
title str

Axes title.

''
width float | str

Figure width in inches or a :data:nicefigs.figure.WIDTHS name, used only when a new figure is created.

'half'
aspect float

Figure height / width, used only when a new figure is created.

0.72

Returns:

Type Description
Axes

The axes.

Source code in src/nicefigs/verification.py
def rank_histogram(
    ranks: ArrayLike,
    *,
    n_ranks: int | None = None,
    ax: Axes | None = None,
    color: str | None = None,
    ideal: bool = True,
    normalize: bool = True,
    summary: tuple[str, float] | None = None,
    xlabel: str = "Rank",
    ylabel: str = "Frequency",
    title: str = "",
    width: float | str = "half",
    aspect: float = 0.72,
) -> Axes:
    """Draw a rank (Talagrand) histogram of verification ranks.

    Args:
        ranks: Integer rank per event: the 0-indexed position of the truth among
            the sorted ensemble members, in ``[0, n_members]``.
        n_ranks: Number of rank bins (``n_members + 1``); defaults to
            ``max(ranks) + 1``.
        ax: Axes to draw on; a new figure is created when ``None``.
        color: Bar colour; default the theme's blue.
        ideal: Draw the flat reference a perfectly calibrated ensemble attains.
        normalize: Show frequencies summing to 1 instead of counts.
        summary: ``(name, value)`` written in the top-left corner, e.g. a
            calibration statistic.
        xlabel: x-axis label.
        ylabel: y-axis label.
        title: Axes title.
        width: Figure width in inches or a :data:`nicefigs.figure.WIDTHS` name,
            used only when a new figure is created.
        aspect: Figure ``height / width``, used only when a new figure is created.

    Returns:
        The axes.
    """
    th = get_theme()
    ranks = np.asarray(ranks, dtype=int)
    n_ranks = n_ranks if n_ranks is not None else int(ranks.max()) + 1 if ranks.size else 1
    counts = np.bincount(ranks, minlength=n_ranks)[:n_ranks].astype(float)
    total = counts.sum()
    if normalize and total > 0:
        counts = counts / total

    if ax is None:
        from .figure import subplots

        _, ax = subplots(width=width, aspect=aspect)

    x = np.arange(n_ranks)
    ax.bar(x, counts, width=0.85, color=color or COLORS["blue"], edgecolor="white", linewidth=0.4, zorder=3)

    if ideal:
        from .panels import reference_line

        flat = (1.0 / n_ranks) if normalize else total / n_ranks
        reference_line(ax, flat, axis="y", label="ideal", linestyle="--", zorder=2)

    ax.set_xlim(-0.6, n_ranks - 0.4)
    ax.set_xlabel(xlabel)
    ax.set_ylabel(ylabel)
    if title:
        from .panels import clean_title

        clean_title(ax, title)
    ax.grid(axis="y", alpha=0.15, linewidth=0.5)
    ax.set_axisbelow(True)

    if summary is not None:
        name, val = summary
        ax.text(
            0.02,
            0.98,
            f"{name} = {val:.3g}",
            transform=ax.transAxes,
            va="top",
            ha="left",
            fontsize=th.fs_annot,
            bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="#cccccc", lw=0.4, alpha=0.85),
        )
    return ax

rank_histogram_panels

rank_histogram_panels(ranks_by_name: Mapping[str, ArrayLike], *, n_ranks: int | None = None, ncols: int = 3, colors: Mapping[str, str] | None = None, ideal: bool = True, normalize: bool = True, summaries: Mapping[str, tuple[str, float]] | None = None, width: float | str = 'full', panel_aspect: float = 0.8, titles: Sequence[str] | None = None) -> tuple[Figure, np.ndarray]

Draw one :func:rank_histogram panel per model as small multiples.

Parameters:

Name Type Description Default
ranks_by_name Mapping[str, ArrayLike]

{name: ranks}; see :func:rank_histogram.

required
n_ranks int | None

Number of rank bins shared by all panels; defaults to the maximum over all models, so the panels stay comparable.

None
ncols int

Panels per row.

3
colors Mapping[str, str] | None

{name: colour}; defaults to the theme's qualitative palette.

None
ideal bool

Draw the flat reference in each panel.

True
normalize bool

Show frequencies instead of counts.

True
summaries Mapping[str, tuple[str, float]] | None

{name: (label, value)} annotation per panel.

None
width float | str

Figure width in inches or a :data:nicefigs.figure.WIDTHS name.

'full'
panel_aspect float

Height / width of each panel.

0.8
titles Sequence[str] | None

Per-panel titles aligned with ranks_by_name; defaults to the model names.

None

Returns:

Type Description
tuple[Figure, ndarray]

(fig, axes) with axes a flat array, one per model.

Source code in src/nicefigs/verification.py
def rank_histogram_panels(
    ranks_by_name: Mapping[str, ArrayLike],
    *,
    n_ranks: int | None = None,
    ncols: int = 3,
    colors: Mapping[str, str] | None = None,
    ideal: bool = True,
    normalize: bool = True,
    summaries: Mapping[str, tuple[str, float]] | None = None,
    width: float | str = "full",
    panel_aspect: float = 0.8,
    titles: Sequence[str] | None = None,
) -> tuple[Figure, np.ndarray]:
    """Draw one :func:`rank_histogram` panel per model as small multiples.

    Args:
        ranks_by_name: ``{name: ranks}``; see :func:`rank_histogram`.
        n_ranks: Number of rank bins shared by all panels; defaults to the
            maximum over all models, so the panels stay comparable.
        ncols: Panels per row.
        colors: ``{name: colour}``; defaults to the theme's qualitative palette.
        ideal: Draw the flat reference in each panel.
        normalize: Show frequencies instead of counts.
        summaries: ``{name: (label, value)}`` annotation per panel.
        width: Figure width in inches or a :data:`nicefigs.figure.WIDTHS` name.
        panel_aspect: Height / width of each panel.
        titles: Per-panel titles aligned with ``ranks_by_name``; defaults to the
            model names.

    Returns:
        ``(fig, axes)`` with ``axes`` a flat array, one per model.
    """
    from .panels import facet

    names = list(ranks_by_name)
    if n_ranks is None:
        n_ranks = max(int(np.max(np.asarray(v))) for v in ranks_by_name.values() if np.size(v)) + 1
    cols = colors or dict(zip(names, qualitative(len(names))))
    summaries = summaries or {}

    def draw(ax: Axes, name: str, _i: int) -> None:
        panel_title = "" if titles is not None else name
        rank_histogram(
            ranks_by_name[name],
            n_ranks=n_ranks,
            ax=ax,
            color=cols[name],
            ideal=ideal,
            normalize=normalize,
            summary=summaries.get(name),
            title=panel_title,
        )

    return facet(names, draw, ncols=ncols, width=width, panel_aspect=panel_aspect, titles=titles)

precision_recall

precision_recall(curves: Mapping[str, tuple[ArrayLike, ArrayLike]], *, ax: Axes | None = None, colors: Mapping[str, str] | None = None, markers: str | Mapping[str, str] | None = None, iso_f1: Sequence[float] | None = (0.2, 0.4, 0.6, 0.8), diagonal: float | None = None, xlabel: str = 'Recall', ylabel: str = 'Precision', title: str = '', legend: bool = True, marker_size: float = 5.0, width: float | str = 'half') -> Axes

Draw a precision-recall diagram for one or more models.

Parameters:

Name Type Description Default
curves Mapping[str, tuple[ArrayLike, ArrayLike]]

{name: (recall, precision)}. Arrays draw a curve swept over a threshold (sorted by recall); scalars draw a single operating point.

required
ax Axes | None

Axes to draw on; a new square figure is created when None.

None
colors Mapping[str, str] | None

{name: colour}; defaults to the theme's qualitative palette.

None
markers str | Mapping[str, str] | None

{name: marker}, or one marker for all entries. Points default to "o"; curves have no markers unless given.

None
iso_f1 Sequence[float] | None

F1 values drawn as dotted iso-F1 reference curves; None or () omits them.

(0.2, 0.4, 0.6, 0.8)
diagonal float | None

Precision of a dashed "no skill" line (e.g. the class prevalence); None omits it.

None
xlabel str

x-axis label.

'Recall'
ylabel str

y-axis label.

'Precision'
title str

Axes title.

''
legend bool

Draw a legend.

True
marker_size float

Operating-point marker size in points.

5.0
width float | str

Figure width in inches or a :data:nicefigs.figure.WIDTHS name, used only when a new figure is created.

'half'

Returns:

Type Description
Axes

The axes.

Source code in src/nicefigs/verification.py
def precision_recall(
    curves: Mapping[str, tuple[ArrayLike, ArrayLike]],
    *,
    ax: Axes | None = None,
    colors: Mapping[str, str] | None = None,
    markers: str | Mapping[str, str] | None = None,
    iso_f1: Sequence[float] | None = (0.2, 0.4, 0.6, 0.8),
    diagonal: float | None = None,
    xlabel: str = "Recall",
    ylabel: str = "Precision",
    title: str = "",
    legend: bool = True,
    marker_size: float = 5.0,
    width: float | str = "half",
) -> Axes:
    """Draw a precision-recall diagram for one or more models.

    Args:
        curves: ``{name: (recall, precision)}``. Arrays draw a curve swept over a
            threshold (sorted by recall); scalars draw a single operating point.
        ax: Axes to draw on; a new square figure is created when ``None``.
        colors: ``{name: colour}``; defaults to the theme's qualitative palette.
        markers: ``{name: marker}``, or one marker for all entries. Points default
            to ``"o"``; curves have no markers unless given.
        iso_f1: F1 values drawn as dotted iso-F1 reference curves; ``None`` or
            ``()`` omits them.
        diagonal: Precision of a dashed "no skill" line (e.g. the class
            prevalence); ``None`` omits it.
        xlabel: x-axis label.
        ylabel: y-axis label.
        title: Axes title.
        legend: Draw a legend.
        marker_size: Operating-point marker size in points.
        width: Figure width in inches or a :data:`nicefigs.figure.WIDTHS` name,
            used only when a new figure is created.

    Returns:
        The axes.
    """
    th = get_theme()
    names = list(curves)
    cols = colors or dict(zip(names, qualitative(len(names))))

    if ax is None:
        from .figure import subplots

        _, ax = subplots(width=width, aspect=1.0)

    if iso_f1:
        rr = np.linspace(1e-3, 1.0, 200)
        for f in iso_f1:
            with np.errstate(divide="ignore", invalid="ignore"):
                pp = f * rr / (2 * rr - f)
            valid = np.isfinite(pp) & (pp > 0) & (pp <= 1.0)
            if valid.sum() > 1:
                ax.plot(rr[valid], pp[valid], color="#cccccc", lw=0.6, ls=":", zorder=0)
                xi, yi = rr[valid][-1], pp[valid][-1]
                ax.annotate(
                    f"F1={f:g}",
                    (xi, yi),
                    fontsize=max(th.fs_annot - 0.5, 4.0),
                    color="#999999",
                    xytext=(-2, 2),
                    textcoords="offset points",
                    ha="right",
                    va="bottom",
                )

    if diagonal is not None:
        ax.axhline(float(diagonal), color=COLORS["neutral"], ls="--", lw=0.7, zorder=1, label="no skill")

    for name in names:
        r, p = curves[name]
        r = np.atleast_1d(np.asarray(r, dtype=float))
        p = np.atleast_1d(np.asarray(p, dtype=float))
        mk = markers.get(name) if isinstance(markers, dict) else markers
        if r.size == 1:
            ax.plot(
                r,
                p,
                marker=mk or "o",
                color=cols[name],
                markersize=marker_size,
                linestyle="none",
                zorder=5,
                label=name,
            )
        else:
            order = np.argsort(r)
            ax.plot(
                r[order],
                p[order],
                color=cols[name],
                lw=th.lw_line,
                marker=mk,
                markersize=3.5 if mk else 0,
                zorder=4,
                label=name,
            )

    ax.set_xlim(0, 1)
    ax.set_ylim(0, 1)
    ax.set_aspect("equal")
    ax.set_xlabel(xlabel)
    ax.set_ylabel(ylabel)
    if title:
        from .panels import clean_title

        clean_title(ax, title)
    ax.grid(alpha=0.15, linewidth=0.5)
    ax.set_axisbelow(True)
    if legend:
        ax.legend(loc="best", fontsize=th.fs_legend)
    return ax

nicefigs.colormaps2d

colormaps2d

Two-dimensional colormaps that encode two continuous variables in one colour.

Three encoders are provided, each with a *_legend renderer for its square key:

  • :func:bivariate_cmap — bilinear blend of four corner colours.
  • :func:hsv_cmap — hue along one axis, saturation along the other, at fixed value.
  • :func:flat_cmap — a constant-lightness CIE-Lab plane (perceptually even).

BIVARIATE_CORNERS module-attribute

BIVARIATE_CORNERS: dict[str, str] = {'ll': '#e8e8e8', 'hl': '#e8b24a', 'lh': '#5ac8c8', 'hh': '#3b1f66'}

__all__ module-attribute

__all__ = ['BIVARIATE_CORNERS', 'normalize01', 'bivariate_cmap', 'hsv_cmap', 'flat_cmap', 'bivariate_legend', 'flat_legend', 'hsv_legend']

normalize01

normalize01(values: ArrayLike, *, method: Literal['quantile', 'linear'] = 'quantile', clip: bool = True) -> tuple[np.ndarray, tuple[float, float]]

Scale values into [0, 1] for use with a 2D colormap.

Parameters:

Name Type Description Default
values ArrayLike

Values of any shape; NaNs are ignored when choosing the range.

required
method Literal['quantile', 'linear']

"quantile" maps the 5th/95th percentiles to 0/1, "linear" the minimum/maximum.

'quantile'
clip bool

Clip the scaled values to [0, 1].

True

Returns:

Type Description
ndarray

(scaled, (lo, hi)): the scaled array (same shape as values) and the

tuple[float, float]

data range mapped to 0 and 1, for legend tick labels.

Source code in src/nicefigs/colormaps2d.py
def normalize01(
    values: ArrayLike, *, method: Literal["quantile", "linear"] = "quantile", clip: bool = True
) -> tuple[np.ndarray, tuple[float, float]]:
    """Scale values into ``[0, 1]`` for use with a 2D colormap.

    Args:
        values: Values of any shape; NaNs are ignored when choosing the range.
        method: ``"quantile"`` maps the 5th/95th percentiles to 0/1, ``"linear"``
            the minimum/maximum.
        clip: Clip the scaled values to ``[0, 1]``.

    Returns:
        ``(scaled, (lo, hi))``: the scaled array (same shape as ``values``) and the
        data range mapped to 0 and 1, for legend tick labels.
    """
    arr = np.asarray(values, dtype=float)
    finite = arr[np.isfinite(arr)]
    if finite.size == 0:
        return np.zeros_like(arr), (0.0, 1.0)
    if method == "quantile":
        lo, hi = float(np.nanpercentile(arr, 5)), float(np.nanpercentile(arr, 95))
    else:
        lo, hi = float(np.nanmin(arr)), float(np.nanmax(arr))
    if hi <= lo:
        hi = lo + 1.0
    scaled = (arr - lo) / (hi - lo)
    return (np.clip(scaled, 0, 1) if clip else scaled), (lo, hi)

bivariate_cmap

bivariate_cmap(x: ArrayLike, y: ArrayLike, *, corners: Mapping[str, str] | None = None, clip: bool = True) -> np.ndarray

Colour (x, y) pairs by bilinear blending of four corner colours.

Parameters:

Name Type Description Default
x ArrayLike

First variable, scaled to [0, 1].

required
y ArrayLike

Second variable, scaled to [0, 1]; same shape as x.

required
corners Mapping[str, str] | None

Colours keyed "ll", "hl", "lh", "hh" (x-low/high, y-low/high). Defaults to :data:BIVARIATE_CORNERS.

None
clip bool

Clip x and y to [0, 1] first.

True

Returns:

Type Description
ndarray

RGB array of shape x.shape + (3,) in [0, 1].

Source code in src/nicefigs/colormaps2d.py
def bivariate_cmap(
    x: ArrayLike, y: ArrayLike, *, corners: Mapping[str, str] | None = None, clip: bool = True
) -> np.ndarray:
    """Colour ``(x, y)`` pairs by bilinear blending of four corner colours.

    Args:
        x: First variable, scaled to ``[0, 1]``.
        y: Second variable, scaled to ``[0, 1]``; same shape as ``x``.
        corners: Colours keyed ``"ll"``, ``"hl"``, ``"lh"``, ``"hh"`` (x-low/high,
            y-low/high). Defaults to :data:`BIVARIATE_CORNERS`.
        clip: Clip ``x`` and ``y`` to ``[0, 1]`` first.

    Returns:
        RGB array of shape ``x.shape + (3,)`` in ``[0, 1]``.
    """
    xs = np.asarray(x, dtype=float)
    ys = np.asarray(y, dtype=float)
    if clip:
        xs, ys = np.clip(xs, 0, 1), np.clip(ys, 0, 1)
    c = {k: np.asarray(mcolors.to_rgb(v)) for k, v in (corners or BIVARIATE_CORNERS).items()}
    w_ll = (1 - xs) * (1 - ys)
    w_hl = xs * (1 - ys)
    w_lh = (1 - xs) * ys
    w_hh = xs * ys
    rgb = (
        w_ll[..., None] * c["ll"]
        + w_hl[..., None] * c["hl"]
        + w_lh[..., None] * c["lh"]
        + w_hh[..., None] * c["hh"]
    )
    return np.clip(rgb, 0, 1)

hsv_cmap

hsv_cmap(hue: ArrayLike, sat: ArrayLike, *, value: float = 0.88, sat_range: tuple[float, float] = (0.5, 1.0), hue_range: tuple[float, float] = (0.55, 1.0), clip: bool = True) -> np.ndarray

Colour pairs by hue along one variable and saturation along the other.

Parameters:

Name Type Description Default
hue ArrayLike

Variable mapped to hue, scaled to [0, 1].

required
sat ArrayLike

Variable mapped to saturation, scaled to [0, 1]; same shape as hue.

required
value float

Fixed HSV value (brightness) in [0, 1].

0.88
sat_range tuple[float, float]

Saturation at sat = 0 and 1.

(0.5, 1.0)
hue_range tuple[float, float]

Hue (fraction of the colour wheel) at hue = 0 and 1.

(0.55, 1.0)
clip bool

Clip hue and sat to [0, 1] first.

True

Returns:

Type Description
ndarray

RGB array of shape hue.shape + (3,) in [0, 1].

Source code in src/nicefigs/colormaps2d.py
def hsv_cmap(
    hue: ArrayLike,
    sat: ArrayLike,
    *,
    value: float = 0.88,
    sat_range: tuple[float, float] = (0.5, 1.0),
    hue_range: tuple[float, float] = (0.55, 1.0),
    clip: bool = True,
) -> np.ndarray:
    """Colour pairs by hue along one variable and saturation along the other.

    Args:
        hue: Variable mapped to hue, scaled to ``[0, 1]``.
        sat: Variable mapped to saturation, scaled to ``[0, 1]``; same shape as ``hue``.
        value: Fixed HSV value (brightness) in ``[0, 1]``.
        sat_range: Saturation at ``sat`` = 0 and 1.
        hue_range: Hue (fraction of the colour wheel) at ``hue`` = 0 and 1.
        clip: Clip ``hue`` and ``sat`` to ``[0, 1]`` first.

    Returns:
        RGB array of shape ``hue.shape + (3,)`` in ``[0, 1]``.
    """
    hs = np.asarray(hue, dtype=float)
    ss = np.asarray(sat, dtype=float)
    if clip:
        hs, ss = np.clip(hs, 0, 1), np.clip(ss, 0, 1)
    h = hue_range[0] + hs * (hue_range[1] - hue_range[0])
    s = sat_range[0] + ss * (sat_range[1] - sat_range[0])
    v = np.full_like(h, value, dtype=float)
    return mcolors.hsv_to_rgb(np.stack([h, s, v], axis=-1))

flat_cmap

flat_cmap(x: ArrayLike, y: ArrayLike, *, L: float = 95.0, chroma: float = 33.0, rotation_deg: float = 0.0, transpose: bool = False) -> np.ndarray

Colour pairs on a constant-lightness CIE-Lab plane.

x maps to a* (green to red) and y to b* (blue to yellow), so every colour has the same perceived lightness.

Parameters:

Name Type Description Default
x ArrayLike

First variable, scaled to [0, 1] (clipped).

required
y ArrayLike

Second variable, scaled to [0, 1] (clipped); same shape as x.

required
L float

CIE-Lab lightness (0-100).

95.0
chroma float

Maximum a/b magnitude; larger is more saturated.

33.0
rotation_deg float

Rotation of the a/b plane in degrees.

0.0
transpose bool

Swap the roles of x and y.

False

Returns:

Type Description
ndarray

float32 RGB array of shape x.shape + (3,) in [0, 1].

Source code in src/nicefigs/colormaps2d.py
def flat_cmap(
    x: ArrayLike,
    y: ArrayLike,
    *,
    L: float = 95.0,
    chroma: float = 33.0,
    rotation_deg: float = 0.0,
    transpose: bool = False,
) -> np.ndarray:
    """Colour pairs on a constant-lightness CIE-Lab plane.

    ``x`` maps to a* (green to red) and ``y`` to b* (blue to yellow), so every
    colour has the same perceived lightness.

    Args:
        x: First variable, scaled to ``[0, 1]`` (clipped).
        y: Second variable, scaled to ``[0, 1]`` (clipped); same shape as ``x``.
        L: CIE-Lab lightness (0-100).
        chroma: Maximum a*/b* magnitude; larger is more saturated.
        rotation_deg: Rotation of the a*/b* plane in degrees.
        transpose: Swap the roles of ``x`` and ``y``.

    Returns:
        ``float32`` RGB array of shape ``x.shape + (3,)`` in ``[0, 1]``.
    """
    x = np.clip(np.asarray(x, dtype=float), 0, 1)
    y = np.clip(np.asarray(y, dtype=float), 0, 1)
    if transpose:
        x, y = y, x
    a = chroma * (2 * x - 1)
    b = chroma * (2 * y - 1)
    if rotation_deg:
        th = np.deg2rad(rotation_deg)
        ca, sa = np.cos(th), np.sin(th)
        a, b = a * ca - b * sa, a * sa + b * ca
    lab = np.stack([np.full_like(a, L), a, b], axis=-1)
    return _lab_to_srgb(lab).astype(np.float32)

bivariate_legend

bivariate_legend(ax: Axes, *, x_label: str = '', y_label: str = '', x_range: tuple[float, float] = (0, 1), y_range: tuple[float, float] = (0, 1), corners: Mapping[str, str] | None = None, n: int = 16, fontsize: float | None = None) -> None

Draw the :func:bivariate_cmap key as a square image on ax.

Parameters:

Name Type Description Default
ax Axes

Axes to draw into, typically a small inset.

required
x_label str

Label of the horizontal (x) axis.

''
y_label str

Label of the vertical (y) axis.

''
x_range tuple[float, float]

Data values at the left/right edges, shown as tick labels.

(0, 1)
y_range tuple[float, float]

Data values at the bottom/top edges, shown as tick labels.

(0, 1)
corners Mapping[str, str] | None

Corner colours, as in :func:bivariate_cmap.

None
n int

Grid resolution of the key per side.

16
fontsize float | None

Label size in points; defaults to the theme's annotation size.

None
Source code in src/nicefigs/colormaps2d.py
def bivariate_legend(
    ax: Axes,
    *,
    x_label: str = "",
    y_label: str = "",
    x_range: tuple[float, float] = (0, 1),
    y_range: tuple[float, float] = (0, 1),
    corners: Mapping[str, str] | None = None,
    n: int = 16,
    fontsize: float | None = None,
) -> None:
    """Draw the :func:`bivariate_cmap` key as a square image on ``ax``.

    Args:
        ax: Axes to draw into, typically a small inset.
        x_label: Label of the horizontal (``x``) axis.
        y_label: Label of the vertical (``y``) axis.
        x_range: Data values at the left/right edges, shown as tick labels.
        y_range: Data values at the bottom/top edges, shown as tick labels.
        corners: Corner colours, as in :func:`bivariate_cmap`.
        n: Grid resolution of the key per side.
        fontsize: Label size in points; defaults to the theme's annotation size.
    """
    th = get_theme()
    gx, gy = np.meshgrid(np.linspace(0, 1, n), np.linspace(0, 1, n))
    rgb = bivariate_cmap(gx.ravel(), gy.ravel(), corners=corners).reshape(n, n, 3)
    _legend_square(ax, rgb, x_label, y_label, x_range, y_range, fontsize or th.fs_annot)

flat_legend

flat_legend(ax: Axes, *, x_label: str = '', y_label: str = '', x_range: tuple[float, float] = (0, 1), y_range: tuple[float, float] = (0, 1), L: float = 95.0, chroma: float = 33.0, rotation_deg: float = 0.0, transpose: bool = False, n: int = 128, fontsize: float | None = None) -> None

Draw the :func:flat_cmap key as a square image on ax.

Parameters:

Name Type Description Default
ax Axes

Axes to draw into, typically a small inset.

required
x_label str

Label of the horizontal (x) axis.

''
y_label str

Label of the vertical (y) axis.

''
x_range tuple[float, float]

Data values at the left/right edges, shown as tick labels.

(0, 1)
y_range tuple[float, float]

Data values at the bottom/top edges, shown as tick labels.

(0, 1)
L float

CIE-Lab lightness, as in :func:flat_cmap.

95.0
chroma float

Maximum a/b magnitude, as in :func:flat_cmap.

33.0
rotation_deg float

Rotation of the a/b plane, as in :func:flat_cmap.

0.0
transpose bool

Swap the two axes, as in :func:flat_cmap.

False
n int

Grid resolution of the key per side.

128
fontsize float | None

Label size in points; defaults to the theme's annotation size.

None
Source code in src/nicefigs/colormaps2d.py
def flat_legend(
    ax: Axes,
    *,
    x_label: str = "",
    y_label: str = "",
    x_range: tuple[float, float] = (0, 1),
    y_range: tuple[float, float] = (0, 1),
    L: float = 95.0,
    chroma: float = 33.0,
    rotation_deg: float = 0.0,
    transpose: bool = False,
    n: int = 128,
    fontsize: float | None = None,
) -> None:
    """Draw the :func:`flat_cmap` key as a square image on ``ax``.

    Args:
        ax: Axes to draw into, typically a small inset.
        x_label: Label of the horizontal (``x``) axis.
        y_label: Label of the vertical (``y``) axis.
        x_range: Data values at the left/right edges, shown as tick labels.
        y_range: Data values at the bottom/top edges, shown as tick labels.
        L: CIE-Lab lightness, as in :func:`flat_cmap`.
        chroma: Maximum a*/b* magnitude, as in :func:`flat_cmap`.
        rotation_deg: Rotation of the a*/b* plane, as in :func:`flat_cmap`.
        transpose: Swap the two axes, as in :func:`flat_cmap`.
        n: Grid resolution of the key per side.
        fontsize: Label size in points; defaults to the theme's annotation size.
    """
    th = get_theme()
    gx, gy = np.meshgrid(np.linspace(0, 1, n), np.linspace(0, 1, n))
    rgb = flat_cmap(
        gx.ravel(), gy.ravel(), L=L, chroma=chroma, rotation_deg=rotation_deg, transpose=transpose
    ).reshape(n, n, 3)
    _legend_square(ax, rgb, x_label, y_label, x_range, y_range, fontsize or th.fs_annot)

hsv_legend

hsv_legend(ax: Axes, *, x_label: str = '', y_label: str = '', x_range: tuple[float, float] = (0, 1), y_range: tuple[float, float] = (0, 1), value: float = 0.88, sat_range: tuple[float, float] = (0.5, 1.0), hue_range: tuple[float, float] = (0.55, 1.0), n: int = 64, fontsize: float | None = None) -> None

Draw the :func:hsv_cmap key as a square image on ax.

Parameters:

Name Type Description Default
ax Axes

Axes to draw into, typically a small inset.

required
x_label str

Label of the horizontal (hue) axis.

''
y_label str

Label of the vertical (saturation) axis.

''
x_range tuple[float, float]

Data values at the left/right edges, shown as tick labels.

(0, 1)
y_range tuple[float, float]

Data values at the bottom/top edges, shown as tick labels.

(0, 1)
value float

HSV value, as in :func:hsv_cmap.

0.88
sat_range tuple[float, float]

Saturation range, as in :func:hsv_cmap.

(0.5, 1.0)
hue_range tuple[float, float]

Hue range, as in :func:hsv_cmap.

(0.55, 1.0)
n int

Grid resolution of the key per side.

64
fontsize float | None

Label size in points; defaults to the theme's annotation size.

None
Source code in src/nicefigs/colormaps2d.py
def hsv_legend(
    ax: Axes,
    *,
    x_label: str = "",
    y_label: str = "",
    x_range: tuple[float, float] = (0, 1),
    y_range: tuple[float, float] = (0, 1),
    value: float = 0.88,
    sat_range: tuple[float, float] = (0.5, 1.0),
    hue_range: tuple[float, float] = (0.55, 1.0),
    n: int = 64,
    fontsize: float | None = None,
) -> None:
    """Draw the :func:`hsv_cmap` key as a square image on ``ax``.

    Args:
        ax: Axes to draw into, typically a small inset.
        x_label: Label of the horizontal (hue) axis.
        y_label: Label of the vertical (saturation) axis.
        x_range: Data values at the left/right edges, shown as tick labels.
        y_range: Data values at the bottom/top edges, shown as tick labels.
        value: HSV value, as in :func:`hsv_cmap`.
        sat_range: Saturation range, as in :func:`hsv_cmap`.
        hue_range: Hue range, as in :func:`hsv_cmap`.
        n: Grid resolution of the key per side.
        fontsize: Label size in points; defaults to the theme's annotation size.
    """
    th = get_theme()
    gx, gy = np.meshgrid(np.linspace(0, 1, n), np.linspace(0, 1, n))
    rgb = hsv_cmap(gx.ravel(), gy.ravel(), value=value, sat_range=sat_range, hue_range=hue_range).reshape(
        n, n, 3
    )
    _legend_square(ax, rgb, x_label, y_label, x_range, y_range, fontsize or th.fs_annot)

nicefigs.artists

artists

Artist-inspired categorical palettes and colormaps.

Hex values of the MetBrewer and MoMAColors palettes are taken verbatim from MetBrewer <https://github.com/BlakeRMills/MetBrewer>_ and MoMAColors <https://github.com/BlakeRMills/MoMAColors>_; "hockney" is sampled from David Hockney's A Bigger Grand Canyon (1998), and "nice" (the default colour cycle) leads with a bright teal followed by the Hockney colours. Continuous versions are registered for :func:nicefigs.cmap, e.g. cmap("hockney_canyon") or cmap("hiroshige").

METBREWER module-attribute

METBREWER: dict[str, list[str]] = {'hokusai1': ['#6d2f20', '#b75347', '#df7e66', '#e09351', '#edc775', '#94b594', '#224b5e'], 'hokusai2': ['#abc9c8', '#72aeb6', '#4692b0', '#2f70a1', '#134b73', '#0a3351'], 'hokusai3': ['#d8d97a', '#95c36e', '#74c8c3', '#5a97c1', '#295384', '#0a2e57'], 'hiroshige': ['#e76254', '#ef8a47', '#f7aa58', '#ffd06f', '#ffe6b7', '#aadce0', '#72bcd5', '#528fad', '#376795', '#1e466e'], 'klimt': ['#df9ed4', '#c93f55', '#eacc62', '#469d76', '#3c4b99', '#924099'], 'vangogh1': ['#2c2d54', '#434475', '#6b6ca3', '#969bc7', '#87bcbd', '#89ab7c', '#6f9954'], 'vangogh2': ['#bd3106', '#d9700e', '#e9a00e', '#eebe04', '#5b7314', '#c3d6ce', '#89a6bb', '#454b87'], 'vangogh3': ['#e7e5cc', '#c2d6a4', '#9cc184', '#669d62', '#3c7c3d', '#1f5b25', '#1e3d14', '#192813'], 'monet': ['#4e6d58', '#749e89', '#abccbe', '#e3cacf', '#c399a2', '#9f6e71', '#41507b', '#7d87b2', '#c2cae3'], 'renoir': ['#17154f', '#2f357c', '#6c5d9e', '#9d9cd5', '#b0799a', '#f6b3b0', '#e48171', '#bf3729', '#e69b00', '#f5bb50', '#ada43b', '#355828'], 'degas': ['#591d06', '#96410e', '#e5a335', '#556219', '#418979', '#2b614e', '#053c29'], 'nizami': ['#dd7867', '#b83326', '#c8570d', '#edb144', '#8cc8bc', '#7da7ea', '#5773c0', '#1d4497'], 'gauguin': ['#b04948', '#811e18', '#9e4013', '#c88a2c', '#4c6216', '#1a472a']}

MOMA module-attribute

MOMA: dict[str, list[str]] = {'warhol': ['#ff0066', '#328c97', '#d1aac2', '#a5506d', '#b3e0bf', '#2A9D3D', '#edf181', '#db7003', '#fba600', '#f8c1a6', '#A30000', '#ff3200', '#011a51', '#97d1d9', '#916c37'], 'klein': ['#ff4d6f', '#579ea4', '#df7713', '#f9c000', '#86ad34', '#5d7298', '#81b28d', '#7e1a2f', '#2d2651', '#c8350d', '#bd777a'], 'picasso': ['#d5968c', '#c2676d', '#5c363a', '#995041', '#45939c', '#0f6a81'], 'koons': ['#d8537d', '#6DC5B2', '#eeca76', '#5d2314', '#b5282a'], 'dali': ['#b4b87f', '#9c913f', '#585b33', '#6ea8ab', '#397893', '#31333f', '#8f5715', '#ba9a44', '#cfbb83'], 'andri': ['#f56455', '#15134b', '#87c785', '#572f30'], 'levine1': ['#E0D9B2', '#818053', '#6B3848', '#8B3E50', '#D5BB6C', '#3F3A4B', '#474C66', '#A5806F'], 'levine2': ['#E3C1CB', '#AD5A6B', '#C993A2', '#365C83', '#384351', '#4D8F8B', '#CDD6AD'], 'momavangogh': ['#c3a016', '#c3d878', '#58a787', '#8ebacd', '#246893', '#163274', '#0C1F4b'], 'ernst': ['#e8e79a', '#c2d89a', '#8cbf9a', '#5fa2a4', '#477b95', '#315b88', '#24396b', '#191f40'], 'flash': ['#e3c0db', '#db95cb', '#cd64b5', '#B83D9F', '#900c7e', '#680369', '#41045a', '#140e3a']}

HOCKNEY_CANYON module-attribute

HOCKNEY_CANYON: list[str] = ['#ef5a1c', '#2c9be2', '#d5388a', '#2fa35c', '#f2ab2e', '#9a4fbc', '#d62f1c', '#3fc6dd', '#b9c233', '#a82c55', '#f5834b', '#2e7d46']

HOCKNEY_CANYON_SEQ module-attribute

HOCKNEY_CANYON_SEQ: list[str] = ['#3a1d5c', '#2c5fb0', '#2c9be2', '#3fc6dd', '#2fa35c', '#b9c233', '#f2ab2e', '#ef5a1c', '#d62f1c', '#d5388a']

NICE module-attribute

NICE: list[str] = ['#25c5a6', '#ef5a1c', '#9a4fbc', '#2c9be2', '#d5388a', '#f2ab2e', '#2fa35c', '#d62f1c', '#b9c233', '#a82c55', '#f5834b', '#2e7d46']

NICE_COLORS module-attribute

NICE_COLORS: dict[str, str] = {'teal': NICE[0], 'orange': NICE[1], 'purple': NICE[2], 'blue': NICE[3], 'magenta': NICE[4], 'gold': NICE[5], 'emerald': NICE[6], 'red': NICE[7], 'lime': NICE[8], 'plum': NICE[9], 'coral': NICE[10], 'pine': NICE[11]}

ARTIST_PALETTES module-attribute

ARTIST_PALETTES: dict[str, list[str]] = {**METBREWER, **MOMA, 'hockney': HOCKNEY_CANYON, 'hockney_canyon': HOCKNEY_CANYON, 'nice': NICE}

light_cmap

light_cmap(color: ColorType, name: str = 'light') -> mcolors.LinearSegmentedColormap

Single-hue sequential colormap from white through color to a darker color.

Suited to density backgrounds (KDEs, hexbins).

Parameters:

Name Type Description Default
color ColorType

Any matplotlib colour.

required
name str

Name of the colormap.

'light'
Source code in src/nicefigs/artists.py
def light_cmap(color: ColorType, name: str = "light") -> mcolors.LinearSegmentedColormap:
    """Single-hue sequential colormap from white through ``color`` to a darker ``color``.

    Suited to density backgrounds (KDEs, hexbins).

    Args:
        color: Any matplotlib colour.
        name: Name of the colormap.
    """
    r, g, b = mcolors.to_rgb(color)
    h, li, s = colorsys.rgb_to_hls(r, g, b)
    dark = colorsys.hls_to_rgb(h, max(li * 0.55, 0.0), s)
    return mcolors.LinearSegmentedColormap.from_list(name, ["#ffffff", color, dark])

artist_palette

artist_palette(name: str, n: int | None = None, *, reverse: bool = False) -> list[str]

Colours of the artist palette name.

Parameters:

Name Type Description Default
name str

A key of :data:ARTIST_PALETTES, case-insensitive.

required
n int | None

Number of colours; the palette repeats if n exceeds its length. None returns the whole palette.

None
reverse bool

Reverse the order before selecting.

False

Returns:

Type Description
list[str]

Hex colour strings.

Raises:

Type Description
KeyError

Unknown palette.

Source code in src/nicefigs/artists.py
def artist_palette(name: str, n: int | None = None, *, reverse: bool = False) -> list[str]:
    """Colours of the artist palette ``name``.

    Args:
        name: A key of :data:`ARTIST_PALETTES`, case-insensitive.
        n: Number of colours; the palette repeats if ``n`` exceeds its length.
            ``None`` returns the whole palette.
        reverse: Reverse the order before selecting.

    Returns:
        Hex colour strings.

    Raises:
        KeyError: Unknown palette.
    """
    try:
        cols = list(ARTIST_PALETTES[name.lower()])
    except KeyError as exc:
        raise KeyError(f"Unknown artist palette {name!r}. Available: {sorted(ARTIST_PALETTES)}") from exc
    if reverse:
        cols = cols[::-1]
    if n is None:
        return cols
    if n <= len(cols):
        return cols[:n]
    return [cols[i % len(cols)] for i in range(n)]

artist_cmap

artist_cmap(name: str, *, reverse: bool = False) -> mcolors.LinearSegmentedColormap

Smooth colormap interpolating an artist palette's colours.

"hockney_canyon" uses :data:HOCKNEY_CANYON_SEQ; other palettes interpolate their colours in order.

Parameters:

Name Type Description Default
name str

A key of :data:ARTIST_PALETTES, case-insensitive.

required
reverse bool

Reverse the colour order.

False

Raises:

Type Description
KeyError

Unknown palette.

Source code in src/nicefigs/artists.py
def artist_cmap(name: str, *, reverse: bool = False) -> mcolors.LinearSegmentedColormap:
    """Smooth colormap interpolating an artist palette's colours.

    ``"hockney_canyon"`` uses :data:`HOCKNEY_CANYON_SEQ`; other palettes
    interpolate their colours in order.

    Args:
        name: A key of :data:`ARTIST_PALETTES`, case-insensitive.
        reverse: Reverse the colour order.

    Raises:
        KeyError: Unknown palette.
    """
    key = name.lower()
    stops = _CONTINUOUS.get(key) or ARTIST_PALETTES.get(key)
    if stops is None:
        raise KeyError(f"Unknown artist palette {name!r}. Available: {sorted(ARTIST_PALETTES)}")
    if reverse:
        stops = list(stops)[::-1]
    return mcolors.LinearSegmentedColormap.from_list(key, list(stops))

nicefigs.animation

animation

Animations of map and image sequences, saved as MP4 (ffmpeg) or GIF (pillow).

:func:animate_maps builds an in-process :class:~matplotlib.animation.FuncAnimation over a (T, H, W) stack with one fixed colour scale; save it with :func:save_animation. :func:render_animation instead draws each frame with a draw(i) -> Figure callback, optionally across processes, and stitches the frames with :func:stitch_frames; use it for expensive or multi-panel frames.

animate_maps

animate_maps(lon: ArrayLike, lat: ArrayLike, frames: ArrayLike, *, cmap: str = 'inferno', vmin: float | None = None, vmax: float | None = None, robust: bool = True, diverging: bool = False, projection: str = 'robinson', antarctica_cutoff: bool = True, extent: Sequence[float] | None = None, land: bool = True, coastline: bool = True, titles: Sequence[str] | None = None, suptitle: str = '', colorbar_label: str = '', fps: float = 8, width: str | float = 'half', aspect: float = 0.62, as_map: bool = True) -> tuple[Figure, Axes, FuncAnimation]

Animate a (T, H, W) stack of gridded fields with one fixed colour scale.

Parameters:

Name Type Description Default
lon ArrayLike

Longitudes in degrees east, 1-D (W,) or 2-D (H, W).

required
lat ArrayLike

Latitudes in degrees north, 1-D (H,) or 2-D (H, W).

required
frames ArrayLike

Field stack of shape (T, H, W).

required
cmap str

Colormap name; "inferno" becomes "RdBu_r" when diverging.

'inferno'
vmin float | None

Lower colour limit; computed over all frames if None.

None
vmax float | None

Upper colour limit; computed over all frames if None.

None
robust bool

Use the 2nd/98th percentiles rather than min/max for missing limits.

True
diverging bool

Use limits symmetric about zero and a diverging colormap.

False
projection str

Map projection name, as in :func:nicefigs.make_map_axes.

'robinson'
antarctica_cutoff bool

Crop the southern edge when extent is None.

True
extent Sequence[float] | None

(lon_min, lon_max, lat_min, lat_max) in degrees.

None
land bool

Draw the land background.

True
coastline bool

Draw coastlines.

True
titles Sequence[str] | None

Per-frame titles (e.g. dates), one per frame.

None
suptitle str

Figure title, constant across frames.

''
colorbar_label str

Colorbar label.

''
fps float

Playback rate in frames per second.

8
width str | float

Figure width, a named width or inches (see :func:nicefigs.figure_size).

'half'
aspect float

Figure height / width.

0.62
as_map bool

Draw on projected map axes; False draws a plain image.

True

Returns:

Type Description
tuple[Figure, Axes, FuncAnimation]

(fig, ax, anim); keep a reference to anim until it is saved.

Source code in src/nicefigs/animation.py
def animate_maps(
    lon: ArrayLike,
    lat: ArrayLike,
    frames: ArrayLike,
    *,
    cmap: str = "inferno",
    vmin: float | None = None,
    vmax: float | None = None,
    robust: bool = True,
    diverging: bool = False,
    projection: str = "robinson",
    antarctica_cutoff: bool = True,
    extent: Sequence[float] | None = None,
    land: bool = True,
    coastline: bool = True,
    titles: Sequence[str] | None = None,
    suptitle: str = "",
    colorbar_label: str = "",
    fps: float = 8,
    width: str | float = "half",
    aspect: float = 0.62,
    as_map: bool = True,
) -> tuple[Figure, Axes, FuncAnimation]:
    """Animate a ``(T, H, W)`` stack of gridded fields with one fixed colour scale.

    Args:
        lon: Longitudes in degrees east, 1-D ``(W,)`` or 2-D ``(H, W)``.
        lat: Latitudes in degrees north, 1-D ``(H,)`` or 2-D ``(H, W)``.
        frames: Field stack of shape ``(T, H, W)``.
        cmap: Colormap name; ``"inferno"`` becomes ``"RdBu_r"`` when ``diverging``.
        vmin: Lower colour limit; computed over all frames if ``None``.
        vmax: Upper colour limit; computed over all frames if ``None``.
        robust: Use the 2nd/98th percentiles rather than min/max for missing limits.
        diverging: Use limits symmetric about zero and a diverging colormap.
        projection: Map projection name, as in :func:`nicefigs.make_map_axes`.
        antarctica_cutoff: Crop the southern edge when ``extent`` is ``None``.
        extent: ``(lon_min, lon_max, lat_min, lat_max)`` in degrees.
        land: Draw the land background.
        coastline: Draw coastlines.
        titles: Per-frame titles (e.g. dates), one per frame.
        suptitle: Figure title, constant across frames.
        colorbar_label: Colorbar label.
        fps: Playback rate in frames per second.
        width: Figure width, a named width or inches (see :func:`nicefigs.figure_size`).
        aspect: Figure height / width.
        as_map: Draw on projected map axes; ``False`` draws a plain image.

    Returns:
        ``(fig, ax, anim)``; keep a reference to ``anim`` until it is saved.
    """
    from matplotlib.animation import FuncAnimation

    th = get_theme()
    frames = np.asarray(frames, dtype=float)
    if cmap == "inferno" and diverging:
        cmap = "RdBu_r"
    if vmin is None or vmax is None:
        if diverging:
            from .palettes import symmetric_limits

            lo, hi = symmetric_limits(frames)
        elif robust:
            lo, hi = robust_limits(frames)
        else:
            lo, hi = float(np.nanmin(frames)), float(np.nanmax(frames))
        vmin = lo if vmin is None else vmin
        vmax = hi if vmax is None else vmax

    from .figure import figure_size

    fig = plt.figure(figsize=figure_size(width, aspect=aspect), dpi=th.dpi)

    lon = np.asarray(lon)
    lat = np.asarray(lat)

    if as_map:
        from .maps import _apply_extent, draw_background, make_map_axes

        ax, ccrs = make_map_axes(fig, 111, projection=projection)
        draw_background(ax, ccrs, land=land, coastline=coastline)
        _apply_extent(ax, ccrs, extent, antarctica_cutoff and extent is None)
        tkw = {"transform": ccrs.PlateCarree()} if ccrs is not None else {}
        mesh = ax.pcolormesh(
            lon, lat, frames[0], cmap=resolve_cmap(cmap), vmin=vmin, vmax=vmax, shading="nearest", **tkw
        )
        is_mesh = True
    else:
        ax = fig.add_subplot(111)
        ax.set_xticks([])
        ax.set_yticks([])
        mesh = ax.imshow(frames[0], cmap=resolve_cmap(cmap), vmin=vmin, vmax=vmax, animated=True)
        is_mesh = False

    from .colorbars import colorbar

    colorbar(mesh, ax, label=colorbar_label)
    ttl = ax.set_title(titles[0] if titles else "", fontsize=th.fs_title, loc="left", pad=3)
    if suptitle:
        fig.suptitle(suptitle, fontsize=th.fs_title)

    def update(i: int) -> tuple:
        if is_mesh:
            mesh.set_array(frames[i].ravel())
        else:
            mesh.set_data(frames[i])
        if titles:
            ttl.set_text(titles[i])
        return (mesh, ttl)

    anim = FuncAnimation(fig, update, frames=len(frames), interval=1000 / fps, blit=False)
    return fig, ax, anim

save_animation

save_animation(anim: Animation, path: str | PathLike, *, fps: float = 8, dpi: float = 120) -> Path

Save an animation as GIF (pillow) or, for any other suffix, as video via ffmpeg.

Parameters:

Name Type Description Default
anim Animation

Animation to save, e.g. from :func:animate_maps.

required
path str | PathLike

Output path; parent directories are created.

required
fps float

Frames per second.

8
dpi float

Resolution in dots per inch.

120

Returns:

Type Description
Path

The path written; a .gif next to path if ffmpeg is unavailable or fails.

Source code in src/nicefigs/animation.py
def save_animation(anim: Animation, path: str | os.PathLike, *, fps: float = 8, dpi: float = 120) -> Path:
    """Save an animation as GIF (pillow) or, for any other suffix, as video via ffmpeg.

    Args:
        anim: Animation to save, e.g. from :func:`animate_maps`.
        path: Output path; parent directories are created.
        fps: Frames per second.
        dpi: Resolution in dots per inch.

    Returns:
        The path written; a ``.gif`` next to ``path`` if ffmpeg is unavailable or fails.
    """
    p = Path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    if p.suffix.lower() == ".gif":
        from matplotlib.animation import PillowWriter

        anim.save(str(p), writer=PillowWriter(fps=fps), dpi=dpi)
    else:
        try:
            from matplotlib.animation import FFMpegWriter

            anim.save(str(p), writer=FFMpegWriter(fps=fps, bitrate=2400), dpi=dpi)
        except (OSError, RuntimeError, subprocess.CalledProcessError):
            gif = p.with_suffix(".gif")
            from matplotlib.animation import PillowWriter

            anim.save(str(gif), writer=PillowWriter(fps=fps), dpi=dpi)
            return gif
    return p

stitch_frames

stitch_frames(frame_dir: str | PathLike, path: str | PathLike, *, fps: float = 8, pattern: str = 'frame_%06d.png', crf: int = 18, preset: str = 'medium') -> Path

Stitch a directory of PNG frames into an MP4 (ffmpeg) or GIF (pillow).

An .mp4 target is encoded with ffmpeg (libx264, yuv420p, padded to even dimensions); any other suffix, or a missing or failing ffmpeg, produces a GIF.

Parameters:

Name Type Description Default
frame_dir str | PathLike

Directory holding frames named per pattern.

required
path str | PathLike

Output path; parent directories are created.

required
fps float

Frames per second.

8
pattern str

printf-style frame filename with one integer field.

'frame_%06d.png'
crf int

ffmpeg constant rate factor; lower is higher quality.

18
preset str

ffmpeg x264 speed preset.

'medium'

Returns:

Type Description
Path

The path written; its suffix is .gif on the GIF fallback.

Raises:

Type Description
FileNotFoundError

No frames match pattern (GIF path).

Source code in src/nicefigs/animation.py
def stitch_frames(
    frame_dir: str | os.PathLike,
    path: str | os.PathLike,
    *,
    fps: float = 8,
    pattern: str = "frame_%06d.png",
    crf: int = 18,
    preset: str = "medium",
) -> Path:
    """Stitch a directory of PNG frames into an MP4 (ffmpeg) or GIF (pillow).

    An ``.mp4`` target is encoded with ffmpeg (libx264, yuv420p, padded to even
    dimensions); any other suffix, or a missing or failing ffmpeg, produces a GIF.

    Args:
        frame_dir: Directory holding frames named per ``pattern``.
        path: Output path; parent directories are created.
        fps: Frames per second.
        pattern: ``printf``-style frame filename with one integer field.
        crf: ffmpeg constant rate factor; lower is higher quality.
        preset: ffmpeg x264 speed preset.

    Returns:
        The path written; its suffix is ``.gif`` on the GIF fallback.

    Raises:
        FileNotFoundError: No frames match ``pattern`` (GIF path).
    """
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)
    if path.suffix.lower() == ".mp4":
        try:
            subprocess.run(
                [
                    "ffmpeg",
                    "-y",
                    "-framerate",
                    str(fps),
                    "-i",
                    os.path.join(str(frame_dir), pattern),
                    "-vf",
                    "pad=ceil(iw/2)*2:ceil(ih/2)*2",
                    "-c:v",
                    "libx264",
                    "-pix_fmt",
                    "yuv420p",
                    "-preset",
                    preset,
                    "-crf",
                    str(crf),
                    str(path),
                ],
                capture_output=True,
                text=True,
                check=True,
            )
            return path
        except (FileNotFoundError, subprocess.CalledProcessError):
            path = path.with_suffix(".gif")
    return _stitch_gif(frame_dir, path, fps=fps, pattern=pattern)

render_animation

render_animation(draw: Callable[[int], Figure], n_frames: int, path: str | PathLike, *, fps: float = 8, dpi: float = 120, max_workers: int | None = None, method: Literal['parallel', 'sequential'] = 'parallel', pattern: str = 'frame_%06d.png', crf: int = 18, preset: str = 'medium') -> Path

Render frames with a draw(i) -> Figure callback and stitch them into a video.

Each returned figure is saved and closed, then all frames are stitched with :func:stitch_frames. Unlike :func:animate_maps, a frame may contain anything.

Parameters:

Name Type Description Default
draw Callable[[int], Figure]

Callable returning the figure for frame i. With method="parallel" it must be picklable: a module-level function or a :func:functools.partial of one, not a lambda or closure.

required
n_frames int

Number of frames.

required
path str | PathLike

Output path (.mp4 preferred, GIF fallback).

required
fps float

Frames per second.

8
dpi float

Frame resolution in dots per inch.

120
max_workers int | None

Process cap for "parallel"; default min(n_frames, cpus, 8).

None
method Literal['parallel', 'sequential']

"parallel" renders in a process pool, "sequential" in-process.

'parallel'
pattern str

printf-style frame filename, as in :func:stitch_frames.

'frame_%06d.png'
crf int

ffmpeg constant rate factor, as in :func:stitch_frames.

18
preset str

ffmpeg x264 preset, as in :func:stitch_frames.

'medium'

Returns:

Type Description
Path

The path written, as returned by :func:stitch_frames.

Source code in src/nicefigs/animation.py
def render_animation(
    draw: Callable[[int], Figure],
    n_frames: int,
    path: str | os.PathLike,
    *,
    fps: float = 8,
    dpi: float = 120,
    max_workers: int | None = None,
    method: Literal["parallel", "sequential"] = "parallel",
    pattern: str = "frame_%06d.png",
    crf: int = 18,
    preset: str = "medium",
) -> Path:
    """Render frames with a ``draw(i) -> Figure`` callback and stitch them into a video.

    Each returned figure is saved and closed, then all frames are stitched with
    :func:`stitch_frames`. Unlike :func:`animate_maps`, a frame may contain anything.

    Args:
        draw: Callable returning the figure for frame ``i``. With
            ``method="parallel"`` it must be picklable: a module-level function or
            a :func:`functools.partial` of one, not a lambda or closure.
        n_frames: Number of frames.
        path: Output path (``.mp4`` preferred, GIF fallback).
        fps: Frames per second.
        dpi: Frame resolution in dots per inch.
        max_workers: Process cap for ``"parallel"``; default ``min(n_frames, cpus, 8)``.
        method: ``"parallel"`` renders in a process pool, ``"sequential"`` in-process.
        pattern: ``printf``-style frame filename, as in :func:`stitch_frames`.
        crf: ffmpeg constant rate factor, as in :func:`stitch_frames`.
        preset: ffmpeg x264 preset, as in :func:`stitch_frames`.

    Returns:
        The path written, as returned by :func:`stitch_frames`.
    """
    import tempfile

    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.TemporaryDirectory(prefix="nicefigs_anim_") as td:
        if method == "parallel" and n_frames > 1:
            from concurrent.futures import ProcessPoolExecutor

            mw = max_workers or min(n_frames, (os.cpu_count() or 4), 8)
            with ProcessPoolExecutor(max_workers=mw) as ex:
                futures = [
                    ex.submit(_render_one, draw, i, os.path.join(td, pattern % i), dpi, True)
                    for i in range(n_frames)
                ]
                for f in futures:
                    f.result()
        else:
            for i in range(n_frames):
                _render_one(draw, i, os.path.join(td, pattern % i), dpi, False)
        return stitch_frames(td, path, fps=fps, pattern=pattern, crf=crf, preset=preset)

nicefigs.data

data

Deterministic synthetic example data for the gallery, docs and tests.

Every generator is numpy-only and reproducible from seed. Fields look geophysical (smooth spatial structure, a land bias, seasonal cycles) but are not real data.

global_points

global_points(n: int = 4000, *, seed: int = 0, land_bias: bool = True, field: str = 'temperature') -> tuple[np.ndarray, np.ndarray, np.ndarray]

Scattered global stations with a synthetic signal.

Parameters:

Name Type Description Default
n int

Number of candidate stations; land_bias keeps a subset of them.

4000
seed int

Random seed.

0
land_bias bool

Thin out stations away from the continents.

True
field str

"temperature" (latitude-driven, °C), "anomaly" (zero-centred, for diverging maps) or "sparse" (non-negative, zero at about half the stations).

'temperature'

Returns:

Type Description
tuple[ndarray, ndarray, ndarray]

(lon, lat, values), 1-D arrays of equal length; degrees.

Raises:

Type Description
ValueError

Unknown field.

Source code in src/nicefigs/data.py
def global_points(
    n: int = 4000, *, seed: int = 0, land_bias: bool = True, field: str = "temperature"
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Scattered global stations with a synthetic signal.

    Args:
        n: Number of candidate stations; ``land_bias`` keeps a subset of them.
        seed: Random seed.
        land_bias: Thin out stations away from the continents.
        field: ``"temperature"`` (latitude-driven, °C), ``"anomaly"``
            (zero-centred, for diverging maps) or ``"sparse"`` (non-negative,
            zero at about half the stations).

    Returns:
        ``(lon, lat, values)``, 1-D arrays of equal length; degrees.

    Raises:
        ValueError: Unknown ``field``.
    """
    rng = _rng(seed)
    lon = rng.uniform(-180, 180, n)
    lat = rng.uniform(-90, 90, n)
    if land_bias:
        keep = rng.uniform(0, 1, n) < (0.25 + 0.75 * _land_weight(lon, lat))
        lon, lat = lon[keep], lat[keep]
    smooth = (
        np.sin(np.deg2rad(lat) * 2)
        + 0.6 * np.cos(np.deg2rad(lon) * 1.5)
        + 0.4 * np.sin(np.deg2rad(lon + lat))
    )
    if field == "temperature":
        vals = 30 - 0.55 * np.abs(lat) + 4 * smooth + _rng(seed + 1).normal(0, 2, lat.shape)
    elif field == "anomaly":
        vals = 2.2 * smooth + _rng(seed + 1).normal(0, 0.8, lat.shape)
    elif field == "sparse":
        base = np.clip(smooth + 1.2, 0, None)
        vals = base * (_rng(seed + 2).uniform(0, 1, lat.shape) < 0.5)
    else:
        raise ValueError(f"Unknown field {field!r}; use 'temperature', 'anomaly' or 'sparse'.")
    return lon, lat, vals

regional_grid

regional_grid(*, seed: int = 0, extent: tuple[float, float, float, float] = (5, 20, 45, 55), res: float = 0.1) -> tuple[np.ndarray, np.ndarray, np.ndarray]

A smooth regional field on a lon/lat grid.

Parameters:

Name Type Description Default
seed int

Random seed.

0
extent tuple[float, float, float, float]

(west, east, south, north) in degrees.

(5, 20, 45, 55)
res float

Grid spacing in degrees.

0.1

Returns:

Type Description
tuple[ndarray, ndarray, ndarray]

(lon2d, lat2d, z), three 2-D arrays of shape (nlat, nlon).

Source code in src/nicefigs/data.py
def regional_grid(
    *, seed: int = 0, extent: tuple[float, float, float, float] = (5, 20, 45, 55), res: float = 0.1
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """A smooth regional field on a lon/lat grid.

    Args:
        seed: Random seed.
        extent: ``(west, east, south, north)`` in degrees.
        res: Grid spacing in degrees.

    Returns:
        ``(lon2d, lat2d, z)``, three 2-D arrays of shape ``(nlat, nlon)``.
    """
    w, e, s, n = extent
    lon = np.arange(w, e, res)
    lat = np.arange(s, n, res)
    lon2d, lat2d = np.meshgrid(lon, lat)
    rng = _rng(seed)
    z = np.zeros_like(lon2d)
    for _ in range(6):
        cx, cy = rng.uniform(w, e), rng.uniform(s, n)
        z += rng.normal() * np.exp(-(((lon2d - cx) / 2) ** 2 + ((lat2d - cy) / 1.5) ** 2))
    return lon2d, lat2d, z

elevation_field

elevation_field(*, seed: int = 3, shape: tuple[int, int] = (180, 360)) -> np.ndarray

A global elevation raster in metres, negative over ocean.

Returns:

Type Description
ndarray

Array of shape (nlat, nlon), north-up, spanning -180..180°E.

Source code in src/nicefigs/data.py
def elevation_field(*, seed: int = 3, shape: tuple[int, int] = (180, 360)) -> np.ndarray:
    """A global elevation raster in metres, negative over ocean.

    Returns:
        Array of ``shape`` ``(nlat, nlon)``, north-up, spanning -180..180°E.
    """
    rng = _rng(seed)
    ny, nx = shape
    lat = np.linspace(90, -90, ny)[:, None]
    lon = np.linspace(-180, 180, nx)[None, :]
    z = 1200 * _land_weight(lon * np.ones_like(lat), lat * np.ones_like(lon)) - 400
    for _ in range(40):
        cx, cy, a, r = rng.uniform(-180, 180), rng.uniform(-60, 70), rng.normal(0, 1500), rng.uniform(3, 20)
        z += a * np.exp(-(((lon - cx) / r) ** 2 + ((lat - cy) / r) ** 2))
    return z

landcover_patch

landcover_patch(*, seed: int = 5, size: int = 96, product: str = 'esa_worldcover') -> np.ndarray

A blobby (size, size) raster of land-cover class codes.

Parameters:

Name Type Description Default
seed int

Random seed.

5
size int

Edge length in pixels.

96
product str

Name of a :func:nicefigs.categorical scheme whose codes are used.

'esa_worldcover'

Returns:

Type Description
ndarray

Integer array of class codes of product.

Source code in src/nicefigs/data.py
def landcover_patch(*, seed: int = 5, size: int = 96, product: str = "esa_worldcover") -> np.ndarray:
    """A blobby ``(size, size)`` raster of land-cover class codes.

    Args:
        seed: Random seed.
        size: Edge length in pixels.
        product: Name of a :func:`nicefigs.categorical` scheme whose codes are used.

    Returns:
        Integer array of class codes of ``product``.
    """
    from .palettes import categorical

    codes = categorical(product).codes
    rng = _rng(seed)
    lab = np.zeros((size, size), dtype=int) + codes[0]
    yy, xx = np.mgrid[0:size, 0:size]
    for _ in range(28):
        cy, cx, r = rng.uniform(0, size), rng.uniform(0, size), rng.uniform(6, 22)
        m = ((xx - cx) ** 2 + (yy - cy) ** 2) < r**2
        lab[m] = rng.choice(codes)
    return lab

rgb_patch

rgb_patch(*, seed: int = 7, size: int = 96) -> np.ndarray

A natural-colour (size, size, 3) uint8 image.

Source code in src/nicefigs/data.py
def rgb_patch(*, seed: int = 7, size: int = 96) -> np.ndarray:
    """A natural-colour ``(size, size, 3)`` ``uint8`` image."""
    rng = _rng(seed)
    yy, xx = np.mgrid[0:size, 0:size] / size
    r = 0.35 + 0.3 * np.sin(6 * xx) + 0.1 * rng.normal(0, 1, (size, size))
    g = 0.5 + 0.3 * np.cos(5 * yy) + 0.1 * rng.normal(0, 1, (size, size))
    b = 0.3 + 0.2 * (xx + yy) + 0.1 * rng.normal(0, 1, (size, size))
    img = np.clip(np.stack([r, g, b], -1), 0, 1)
    return (img * 255).astype(np.uint8)

heatmap_matrix

heatmap_matrix(*, seed: int = 9, shape: tuple[int, int] = (8, 12)) -> np.ndarray

A smoothly structured matrix of shape with values around [0, 1].

Source code in src/nicefigs/data.py
def heatmap_matrix(*, seed: int = 9, shape: tuple[int, int] = (8, 12)) -> np.ndarray:
    """A smoothly structured matrix of ``shape`` with values around ``[0, 1]``."""
    rng = _rng(seed)
    base = np.outer(np.linspace(0.2, 1, shape[0]), np.linspace(1, 0.3, shape[1]))
    return base + rng.normal(0, 0.08, shape)

model_scores

model_scores(*, seed: int = 11, models: Sequence[str] | None = None, metrics: Sequence[str] | None = None, timescales: Sequence[str] | None = None, n: int = 60) -> dict[str, Any]

Per-fold scores of several models, metrics and timescales.

Models differ in skill, and skill decays with the timescale index. Metrics named "RMSE" or starting with "R" get error-like and correlation-like values; any other metric gets bias-like values.

Parameters:

Name Type Description Default
seed int

Random seed.

11
models Sequence[str] | None

Model names; default four forecast-model names.

None
metrics Sequence[str] | None

Metric names; default RMSE, R² and Bias.

None
timescales Sequence[str] | None

Timescale names; default daily, weekly, monthly.

None
n int

Folds per cell.

60

Returns:

Type Description
dict[str, Any]

Dict with scores ({metric: {model: {timescale: (n,) array}}})

dict[str, Any]

and the models, metrics and timescales lists.

Source code in src/nicefigs/data.py
def model_scores(
    *,
    seed: int = 11,
    models: Sequence[str] | None = None,
    metrics: Sequence[str] | None = None,
    timescales: Sequence[str] | None = None,
    n: int = 60,
) -> dict[str, Any]:
    """Per-fold scores of several models, metrics and timescales.

    Models differ in skill, and skill decays with the timescale index. Metrics
    named ``"RMSE"`` or starting with ``"R"`` get error-like and correlation-like
    values; any other metric gets bias-like values.

    Args:
        seed: Random seed.
        models: Model names; default four forecast-model names.
        metrics: Metric names; default ``RMSE``, ``R²`` and ``Bias``.
        timescales: Timescale names; default daily, weekly, monthly.
        n: Folds per cell.

    Returns:
        Dict with ``scores`` (``{metric: {model: {timescale: (n,) array}}}``)
        and the ``models``, ``metrics`` and ``timescales`` lists.
    """
    rng = _rng(seed)
    models = models or ["Persistence", "Climatology", "U-Net", "Transformer"]
    metrics = metrics or ["RMSE", "R$^2$", "Bias"]
    timescales = timescales or ["daily", "weekly", "monthly"]
    known_skill = {"Persistence": 0.35, "Climatology": 0.5, "U-Net": 0.72, "Transformer": 0.8}
    # Custom model names get a skill spread evenly over [0.4, 0.85] by order.
    skill = {
        mdl: known_skill.get(mdl, 0.4 + 0.45 * (i / max(len(models) - 1, 1))) for i, mdl in enumerate(models)
    }
    out = {}
    for m in metrics:
        out[m] = {}
        for mdl in models:
            out[m][mdl] = {}
            for ti, ts in enumerate(timescales):
                s = skill[mdl] * (1 - 0.12 * ti)
                if m == "RMSE":
                    v = (1 - s) * 2 + rng.normal(0, 0.12, n)
                elif m.startswith("R"):
                    v = s + rng.normal(0, 0.06, n)
                else:
                    v = (0.5 - s) * 0.5 + rng.normal(0, 0.08, n)
                out[m][mdl][ts] = v
    return {"scores": out, "models": models, "metrics": metrics, "timescales": timescales}

timeseries

timeseries(*, seed: int = 13, n_days: int = 730, series: Sequence[str] | None = None) -> tuple[list[_dt.date], dict[str, np.ndarray]]

Daily series with a seasonal cycle, a trend and noise, starting 2018-01-01.

The first series is unbiased; later ones carry a random bias and more noise.

Parameters:

Name Type Description Default
seed int

Random seed.

13
n_days int

Length in days.

730
series Sequence[str] | None

Series names; default observed, model A, model B.

None

Returns:

Type Description
tuple[list[date], dict[str, ndarray]]

(dates, {name: (n_days,) array}).

Source code in src/nicefigs/data.py
def timeseries(
    *, seed: int = 13, n_days: int = 730, series: Sequence[str] | None = None
) -> tuple[list[_dt.date], dict[str, np.ndarray]]:
    """Daily series with a seasonal cycle, a trend and noise, starting 2018-01-01.

    The first series is unbiased; later ones carry a random bias and more noise.

    Args:
        seed: Random seed.
        n_days: Length in days.
        series: Series names; default ``observed``, ``model A``, ``model B``.

    Returns:
        ``(dates, {name: (n_days,) array})``.
    """
    rng = _rng(seed)
    series = series or ["observed", "model A", "model B"]
    t = np.arange(n_days)
    seasonal = 12 * np.sin(2 * np.pi * t / 365.25 - 1.3)
    trend = 0.004 * t
    dates = [_dt.date(2018, 1, 1) + _dt.timedelta(days=int(i)) for i in t]
    data = {}
    for i, s in enumerate(series):
        bias = 0 if i == 0 else rng.normal(0, 1.5)
        amp = 1 + 0.1 * i
        data[s] = 15 + amp * seasonal + trend + bias + rng.normal(0, 1.2 + 0.5 * i, n_days)
    return dates, data

xy_agreement

xy_agreement(*, seed: int = 15, n: int = 1500, r: float = 0.9, outlier_frac: float = 0.03) -> tuple[np.ndarray, np.ndarray]

Paired (observed, predicted) samples with a small bias and some outliers.

Parameters:

Name Type Description Default
seed int

Random seed.

15
n int

Number of pairs.

1500
r float

Target correlation before outliers are added.

0.9
outlier_frac float

Fraction of pairs perturbed into outliers.

0.03

Returns:

Type Description
tuple[ndarray, ndarray]

(x, y), two (n,) arrays.

Source code in src/nicefigs/data.py
def xy_agreement(
    *, seed: int = 15, n: int = 1500, r: float = 0.9, outlier_frac: float = 0.03
) -> tuple[np.ndarray, np.ndarray]:
    """Paired ``(observed, predicted)`` samples with a small bias and some outliers.

    Args:
        seed: Random seed.
        n: Number of pairs.
        r: Target correlation before outliers are added.
        outlier_frac: Fraction of pairs perturbed into outliers.

    Returns:
        ``(x, y)``, two ``(n,)`` arrays.
    """
    rng = _rng(seed)
    x = rng.gamma(2.5, 2.0, n)
    noise = rng.normal(0, x.std() * np.sqrt(1 - r**2), n)
    y = r * (x - x.mean()) + x.mean() + noise + 0.6
    n_out = int(outlier_frac * n)
    idx = rng.choice(n, n_out, replace=False)
    y[idx] += rng.normal(0, x.std() * 2, n_out)
    return x, y

ternary_points

ternary_points(*, seed: int = 17, n: int = 800, clusters: int = 3) -> tuple[np.ndarray, np.ndarray]

Clustered three-part compositions and a value per point.

Returns:

Type Description
ndarray

(comp, values): comp of shape (n, 3) with rows summing to 1,

ndarray

values of shape (n,).

Source code in src/nicefigs/data.py
def ternary_points(*, seed: int = 17, n: int = 800, clusters: int = 3) -> tuple[np.ndarray, np.ndarray]:
    """Clustered three-part compositions and a value per point.

    Returns:
        ``(comp, values)``: ``comp`` of shape ``(n, 3)`` with rows summing to 1,
        ``values`` of shape ``(n,)``.
    """
    rng = _rng(seed)
    centers = rng.dirichlet(np.ones(3), clusters)
    which = rng.integers(0, clusters, n)
    comp = np.array([rng.dirichlet(centers[w] * 12 + 0.5) for w in which])
    values = comp[:, 0] * 2 - comp[:, 2] + rng.normal(0, 0.1, n)
    return comp, values

scatter_bubbles

scatter_bubbles(*, seed: int = 19, n: int = 120) -> dict[str, np.ndarray]

Points for a multi-encoding scatter.

Returns:

Type Description
dict[str, ndarray]

Dict of (n,) arrays: x, y, size (marker area, pt²),

dict[str, ndarray]

color (a continuous value) and group ("A", "B" or "C").

Source code in src/nicefigs/data.py
def scatter_bubbles(*, seed: int = 19, n: int = 120) -> dict[str, np.ndarray]:
    """Points for a multi-encoding scatter.

    Returns:
        Dict of ``(n,)`` arrays: ``x``, ``y``, ``size`` (marker area, pt²),
        ``color`` (a continuous value) and ``group`` (``"A"``, ``"B"`` or ``"C"``).
    """
    rng = _rng(seed)
    groups = np.array(["A", "B", "C"])[rng.integers(0, 3, n)]
    x = rng.uniform(0, 10, n)
    y = 0.5 * x + rng.normal(0, 1.5, n) + (groups == "B") * 2 - (groups == "C") * 1.5
    size = rng.uniform(10, 200, n)
    color = x + rng.normal(0, 1, n)
    return {"x": x, "y": y, "size": size, "color": color, "group": groups}

pareto_tradeoff

pareto_tradeoff(*, seed: int = 23, groups: Sequence[str] | None = None, n_per: int = 9) -> dict[str, np.ndarray]

A speed-versus-skill cloud in which each group traces its own trade-off frontier.

Parameters:

Name Type Description Default
seed int

Random seed.

23
groups Sequence[str] | None

Group names; default four model-family names.

None
n_per int

Points per group.

9

Returns:

Type Description
dict[str, ndarray]

Dict of arrays: x (a speed, higher is better), y (a skill,

dict[str, ndarray]

higher is better; it falls as x rises within a group), group

dict[str, ndarray]

(group name) and size (a model size in {256, 512, 1024, 2048}).

Source code in src/nicefigs/data.py
def pareto_tradeoff(
    *, seed: int = 23, groups: Sequence[str] | None = None, n_per: int = 9
) -> dict[str, np.ndarray]:
    """A speed-versus-skill cloud in which each group traces its own trade-off frontier.

    Args:
        seed: Random seed.
        groups: Group names; default four model-family names.
        n_per: Points per group.

    Returns:
        Dict of arrays: ``x`` (a speed, higher is better), ``y`` (a skill,
        higher is better; it falls as ``x`` rises within a group), ``group``
        (group name) and ``size`` (a model size in ``{256, 512, 1024, 2048}``).
    """
    rng = _rng(seed)
    groups = list(groups or ["Linear", "Tree", "CNN", "Transformer"])
    offset = {a: 0.12 * i for i, a in enumerate(groups)}
    x, y, group, size = [], [], [], []
    for a in groups:
        widths = rng.uniform(0.2, 1.0, n_per)
        xa = 0.15 + 0.5 * widths + offset[a] + rng.normal(0, 0.02, n_per)
        ya = 0.95 - 0.6 * widths - 0.5 * offset[a] + rng.normal(0, 0.02, n_per)
        x.extend(xa)
        y.extend(ya)
        group.extend([a] * n_per)
        size.extend(rng.choice([256, 512, 1024, 2048], n_per))
    return {"x": np.array(x), "y": np.array(y), "group": np.array(group), "size": np.array(size)}

site_metrics

site_metrics(*, seed: int = 25, n_sites: int = 28) -> dict[str, Any]

Per-site bias and spread for a caterpillar (forest) plot.

Returns:

Type Description
dict[str, Any]

Dict with labels (list of site names), bias, std and lat

dict[str, Any]

((n_sites,) arrays), layer ("surface" or "free-trop" per

dict[str, Any]

site) and baseline, a (bias, std) pair for a second series.

Source code in src/nicefigs/data.py
def site_metrics(*, seed: int = 25, n_sites: int = 28) -> dict[str, Any]:
    """Per-site bias and spread for a caterpillar (forest) plot.

    Returns:
        Dict with ``labels`` (list of site names), ``bias``, ``std`` and ``lat``
        (``(n_sites,)`` arrays), ``layer`` (``"surface"`` or ``"free-trop"`` per
        site) and ``baseline``, a ``(bias, std)`` pair for a second series.
    """
    rng = _rng(seed)
    labels = [f"{c}{i:02d}" for i, c in zip(range(n_sites), rng.choice(list("ABCDEFG"), n_sites))]
    lat = rng.uniform(-80, 80, n_sites)
    layer = np.where(rng.uniform(0, 1, n_sites) < 0.6, "surface", "free-trop")
    bias = 0.3 * np.sin(np.deg2rad(lat)) + rng.normal(0, 0.35, n_sites) + (layer == "surface") * 0.1
    std = 0.4 + 0.5 * rng.uniform(0, 1, n_sites) + (layer == "surface") * 0.3
    base_bias = bias + rng.normal(0.15, 0.25, n_sites)
    base_std = std * rng.uniform(1.0, 1.4, n_sites)
    return {
        "labels": labels,
        "bias": bias,
        "std": std,
        "lat": lat,
        "layer": layer,
        "baseline": (base_bias, base_std),
    }

cross_section_field

cross_section_field(*, seed: int = 27, n_lat: int = 48, n_lev: int = 20) -> dict[str, np.ndarray]

A latitude-pressure field and its error, for a cross-section.

Returns:

Type Description
dict[str, ndarray]

Dict with lats ((n_lat,), °N), levels ((n_lev,), hPa,

dict[str, ndarray]

descending from the surface) and value and error of shape

dict[str, ndarray]

(n_lat, n_lev).

Source code in src/nicefigs/data.py
def cross_section_field(*, seed: int = 27, n_lat: int = 48, n_lev: int = 20) -> dict[str, np.ndarray]:
    """A latitude-pressure field and its error, for a cross-section.

    Returns:
        Dict with ``lats`` (``(n_lat,)``, °N), ``levels`` (``(n_lev,)``, hPa,
        descending from the surface) and ``value`` and ``error`` of shape
        ``(n_lat, n_lev)``.
    """
    rng = _rng(seed)
    lats = np.linspace(-88, 88, n_lat)
    levels = np.linspace(1000, 50, n_lev)  # hPa, descending (surface→top)
    la, lv = np.meshgrid(lats, levels, indexing="ij")
    value = (
        410
        + 6 * np.cos(np.deg2rad(la))
        - 0.01 * (1000 - lv)
        + 3 * np.exp(-((la - 40) ** 2) / 800) * (lv > 700)
    )
    error = 0.5 + 0.4 * rng.normal(0, 1, (n_lat, n_lev)) + 0.5 * np.exp(-((la) ** 2) / 500)
    return {"lats": lats, "levels": levels, "value": value, "error": error}

global_grid

global_grid(*, seed: int = 29, res: float = 2.0, field: str = 'temperature') -> tuple[np.ndarray, np.ndarray, np.ndarray]

A field on a regular global lon/lat grid.

Parameters:

Name Type Description Default
seed int

Random seed.

29
res float

Grid spacing in degrees.

2.0
field str

"temperature" (°C) or "anomaly" (zero-centred).

'temperature'

Returns:

Type Description
ndarray

(lon, lat, z): 1-D cell-centre lon and lat (north first) and

ndarray

z of shape (nlat, nlon).

Source code in src/nicefigs/data.py
def global_grid(
    *, seed: int = 29, res: float = 2.0, field: str = "temperature"
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """A field on a regular global lon/lat grid.

    Args:
        seed: Random seed.
        res: Grid spacing in degrees.
        field: ``"temperature"`` (°C) or ``"anomaly"`` (zero-centred).

    Returns:
        ``(lon, lat, z)``: 1-D cell-centre ``lon`` and ``lat`` (north first) and
        ``z`` of shape ``(nlat, nlon)``.
    """
    rng = _rng(seed)
    lon = np.arange(-180 + res / 2, 180, res)
    lat = np.arange(90 - res / 2, -90, -res)
    lo, la = np.meshgrid(lon, lat)
    smooth = (
        np.sin(np.deg2rad(la) * 2) + 0.6 * np.cos(np.deg2rad(lo) * 1.5) + 0.4 * np.sin(np.deg2rad(lo + la))
    )
    if field == "anomaly":
        z = 2.2 * smooth + rng.normal(0, 0.3, lo.shape)
    else:
        z = 28 - 0.55 * np.abs(la) + 4 * smooth + rng.normal(0, 1.0, lo.shape)
    return lon, lat, z

global_dataarray

global_dataarray(*, seed: int = 29, res: float = 2.5, name: str = 't2m') -> xr.DataArray

The :func:global_grid temperature field as an xarray DataArray.

Requires xarray. The dims are named lat and lon, so :func:nicefigs.map accepts the array directly.

Source code in src/nicefigs/data.py
def global_dataarray(*, seed: int = 29, res: float = 2.5, name: str = "t2m") -> xr.DataArray:
    """The :func:`global_grid` temperature field as an xarray ``DataArray``.

    Requires xarray. The dims are named ``lat`` and ``lon``, so
    :func:`nicefigs.map` accepts the array directly.
    """
    import xarray as xr

    lon, lat, z = global_grid(seed=seed, res=res)
    return xr.DataArray(z, coords={"lat": lat, "lon": lon}, dims=("lat", "lon"), name=name)

icosahedral_field

icosahedral_field(*, seed: int = 31, n: int = 4000, field: str = 'anomaly') -> tuple[np.ndarray, np.ndarray, np.ndarray]

Values on quasi-uniform unstructured global points (a Fibonacci sphere).

The unstructured-mesh case for :func:nicefigs.tri_map.

Parameters:

Name Type Description Default
seed int

Random seed.

31
n int

Number of points.

4000
field str

"anomaly" (zero-centred) or "temperature" (°C).

'anomaly'

Returns:

Type Description
tuple[ndarray, ndarray, ndarray]

(lon, lat, values), three (n,) arrays; degrees.

Source code in src/nicefigs/data.py
def icosahedral_field(
    *, seed: int = 31, n: int = 4000, field: str = "anomaly"
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Values on quasi-uniform unstructured global points (a Fibonacci sphere).

    The unstructured-mesh case for :func:`nicefigs.tri_map`.

    Args:
        seed: Random seed.
        n: Number of points.
        field: ``"anomaly"`` (zero-centred) or ``"temperature"`` (°C).

    Returns:
        ``(lon, lat, values)``, three ``(n,)`` arrays; degrees.
    """
    rng = _rng(seed)
    i = np.arange(n) + 0.5
    lat = np.degrees(np.arcsin(1 - 2 * i / n))
    lon = (np.degrees(np.pi * (1 + 5**0.5) * i) + 180) % 360 - 180
    smooth = (
        np.sin(np.deg2rad(lat) * 2)
        + 0.6 * np.cos(np.deg2rad(lon) * 1.5)
        + 0.5 * np.sin(np.deg2rad(lon + lat))
    )
    if field == "temperature":
        vals = 28 - 0.5 * np.abs(lat) + 4 * smooth + rng.normal(0, 1.0, n)
    else:
        vals = 2.2 * smooth + rng.normal(0, 0.25, n)
    return lon, lat, vals

map_timeseries_cube

map_timeseries_cube(*, seed: int = 21, n_frames: int = 24, shape: tuple[int, int] = (90, 180)) -> tuple[np.ndarray, np.ndarray, np.ndarray]

A blob drifting around the globe, for animations.

Returns:

Type Description
ndarray

(lon, lat, frames): 2-D lon and lat of shape and

ndarray

frames of shape (n_frames, *shape).

Source code in src/nicefigs/data.py
def map_timeseries_cube(
    *, seed: int = 21, n_frames: int = 24, shape: tuple[int, int] = (90, 180)
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """A blob drifting around the globe, for animations.

    Returns:
        ``(lon, lat, frames)``: 2-D ``lon`` and ``lat`` of ``shape`` and
        ``frames`` of shape ``(n_frames, *shape)``.
    """
    rng = _rng(seed)
    ny, nx = shape
    lat = np.linspace(90, -90, ny)[:, None]
    lon = np.linspace(-180, 180, nx)[None, :]
    frames = np.empty((n_frames, ny, nx))
    for t in range(n_frames):
        cx = -180 + 360 * (t / n_frames)
        cy = 30 * np.sin(2 * np.pi * t / n_frames)
        base = np.exp(-(((lon - cx) / 40) ** 2 + ((lat - cy) / 25) ** 2))
        base += 0.3 * _land_weight(lon * np.ones_like(lat), lat * np.ones_like(lon))
        frames[t] = base + rng.normal(0, 0.02, (ny, nx))
    latg = np.repeat(lat, nx, axis=1)
    long = np.repeat(lon, ny, axis=0)
    return long, latg, frames

taylor_stats

taylor_stats(*, seed: int = 33, n: int = 6, models: Sequence[str] | None = None) -> list[dict[str, Any]]

Per-model statistics for a Taylor diagram.

Parameters:

Name Type Description Default
seed int

Random seed.

33
n int

Number of default models; ignored when models is given.

6
models Sequence[str] | None

Model names.

None

Returns:

Type Description
list[dict[str, Any]]

One dict per model with name, correlation, std_ratio (model

list[dict[str, Any]]

over reference standard deviation) and crmsd (centred RMS

list[dict[str, Any]]

difference, in reference standard deviations), consistent with each

list[dict[str, Any]]

other.

Source code in src/nicefigs/data.py
def taylor_stats(*, seed: int = 33, n: int = 6, models: Sequence[str] | None = None) -> list[dict[str, Any]]:
    """Per-model statistics for a Taylor diagram.

    Args:
        seed: Random seed.
        n: Number of default models; ignored when ``models`` is given.
        models: Model names.

    Returns:
        One dict per model with ``name``, ``correlation``, ``std_ratio`` (model
        over reference standard deviation) and ``crmsd`` (centred RMS
        difference, in reference standard deviations), consistent with each
        other.
    """
    rng = _rng(seed)
    models = (
        models or ["advection", "+ fluxes", "+ convection", "+ diffusion", "+ combined", "reanalysis"][:n]
    )
    out = []
    for i, name in enumerate(models):
        corr = float(np.clip(0.72 + 0.045 * i + rng.normal(0, 0.02), 0, 0.999))
        std_ratio = float(np.clip(0.7 + 0.09 * i + rng.normal(0, 0.05), 0.3, 1.6))
        crmsd = float(np.sqrt(max(1 + std_ratio**2 - 2 * std_ratio * corr, 0)))
        out.append({"name": name, "correlation": corr, "std_ratio": std_ratio, "crmsd": crmsd})
    return out

hovmoller_field

hovmoller_field(*, seed: int = 35, n_time: int = 120, n_lat: int = 48) -> dict[str, Any]

A time-latitude field with a seasonal cycle, and a reference version of it.

value is reference plus a small structured bias and noise.

Returns:

Type Description
dict[str, Any]

Dict with times (n_time datetime objects, 3 days apart from

dict[str, Any]

2020-01-01), lats ((n_lat,), °N) and value and reference

dict[str, Any]

of shape (n_time, n_lat).

Source code in src/nicefigs/data.py
def hovmoller_field(*, seed: int = 35, n_time: int = 120, n_lat: int = 48) -> dict[str, Any]:
    """A time-latitude field with a seasonal cycle, and a reference version of it.

    ``value`` is ``reference`` plus a small structured bias and noise.

    Returns:
        Dict with ``times`` (``n_time`` ``datetime`` objects, 3 days apart from
        2020-01-01), ``lats`` (``(n_lat,)``, °N) and ``value`` and ``reference``
        of shape ``(n_time, n_lat)``.
    """
    rng = _rng(seed)
    lats = np.linspace(-88, 88, n_lat)
    t = np.arange(n_time)
    times = [_dt.datetime(2020, 1, 1) + _dt.timedelta(days=3 * int(k)) for k in t]
    season = np.sin(2 * np.pi * t[:, None] / (365 / 3))
    band = np.cos(np.deg2rad(lats))[None, :]
    reference = 410 + 4 * season * band + 3 * np.cos(np.deg2rad(lats))[None, :]
    value = (
        reference + 0.8 * season * np.sin(np.deg2rad(lats))[None, :] + rng.normal(0, 0.15, (n_time, n_lat))
    )
    return {"times": times, "lats": lats, "value": value, "reference": reference}

vertical_profiles

vertical_profiles(*, seed: int = 37, n_lev: int = 20, names: Sequence[str] | None = None) -> dict[str, Any]

Named vertical profiles and a baseline, for :func:nicefigs.profile.

Returns:

Type Description
dict[str, Any]

Dict with levels ((n_lev,), hPa, descending from the surface),

dict[str, Any]

profiles ({name: (n_lev,) array}) and baseline

dict[str, Any]

((n_lev,)).

Source code in src/nicefigs/data.py
def vertical_profiles(
    *, seed: int = 37, n_lev: int = 20, names: Sequence[str] | None = None
) -> dict[str, Any]:
    """Named vertical profiles and a baseline, for :func:`nicefigs.profile`.

    Returns:
        Dict with ``levels`` (``(n_lev,)``, hPa, descending from the surface),
        ``profiles`` (``{name: (n_lev,) array}``) and ``baseline``
        (``(n_lev,)``).
    """
    rng = _rng(seed)
    names = names or ["model A", "model B", "model C"]
    levels = np.linspace(1000, 50, n_lev)
    shape = np.exp(-((levels - 1000) ** 2) / 6.0e5)
    profiles = {}
    for i, nm in enumerate(names):
        profiles[nm] = 405 + 8 * shape + 0.6 * i + rng.normal(0, 0.3, n_lev)
    baseline = 405 + 8 * shape
    return {"levels": levels, "profiles": profiles, "baseline": baseline}

eigen_spectra

eigen_spectra(*, seed: int = 39, n_features: int = 256, n_samples: int = 192, names: Sequence[str] | None = None, alphas: Sequence[float] | None = None) -> dict[str, np.ndarray]

Named covariance eigenvalue spectra, for :func:nicefigs.spectrum.

Each spectrum holds the eigenvalues, descending, of the sample covariance of n_samples draws whose feature variances decay as k^-alpha. With n_samples < n_features the tail is numerically zero and may be slightly negative, as for a real rank-deficient sample covariance.

Parameters:

Name Type Description Default
seed int

Random seed.

39
n_features int

Number of features, the length of each spectrum.

256
n_samples int

Number of draws per covariance estimate.

192
names Sequence[str] | None

Series names; defaults to three forecast lead times.

None
alphas Sequence[float] | None

Power-law exponent of each series' feature variances.

None

Returns:

Type Description
dict[str, ndarray]

{name: eigenvalues}, each of shape (n_features,).

Source code in src/nicefigs/data.py
def eigen_spectra(
    *,
    seed: int = 39,
    n_features: int = 256,
    n_samples: int = 192,
    names: Sequence[str] | None = None,
    alphas: Sequence[float] | None = None,
) -> dict[str, np.ndarray]:
    """Named covariance eigenvalue spectra, for :func:`nicefigs.spectrum`.

    Each spectrum holds the eigenvalues, descending, of the sample covariance of
    ``n_samples`` draws whose feature variances decay as ``k^-alpha``. With
    ``n_samples < n_features`` the tail is numerically zero and may be slightly
    negative, as for a real rank-deficient sample covariance.

    Args:
        seed: Random seed.
        n_features: Number of features, the length of each spectrum.
        n_samples: Number of draws per covariance estimate.
        names: Series names; defaults to three forecast lead times.
        alphas: Power-law exponent of each series' feature variances.

    Returns:
        ``{name: eigenvalues}``, each of shape ``(n_features,)``.
    """
    rng = _rng(seed)
    names = list(names or ["lead 6 h", "lead 24 h", "lead 72 h"])
    alphas = list(alphas or [1.4, 1.1, 0.8][: len(names)])
    k = np.arange(1, n_features + 1)
    out = {}
    for nm, alpha in zip(names, alphas):
        x = rng.normal(size=(n_samples, n_features)) * k ** (-alpha / 2)
        x -= x.mean(axis=0)
        out[nm] = np.linalg.eigvalsh(x.T @ x / (n_samples - 1))[::-1]
    return out