Skip to content

Plotting

Publication figures from a FieldDataset. Requires the plot extra (matplotlib); the 3D surface in pypic.plotting.pyvista requires 3d.

Beyond field slices and comparisons, this subpackage covers line plots and time series, kymographs, quiver and streamline overlays, scatter plots, power spectra, Poincaré sections, and a theme system with annotation helpers (badges, planets, contours, insets, legends).

Themes

set_theme / use_theme select a bundled theme; available_themes lists them. load_theme and save_theme read and write theme TOML, and export_themes emits the bundle that the webpic viewer consumes.

Themes are looked up in $PYPIC_THEME_DIR when that variable is set, otherwise under $XDG_CONFIG_HOME/pypic/themes (falling back to ~/.config/pypic/themes), and finally the themes bundled with the package.

plotting

Plotting utilities for pypic field data.

Requires matplotlib (optional dependency). Install with::

pip install "pypic-plasma[plot]"

This module can be imported for type checking without matplotlib installed. Actual plotting functions call ensure_matplotlib() at entry.

BadgeLoc = Literal['upper left', 'upper right', 'lower left', 'lower right', 'upper center']

Canonical overlay location strings shared by both plotting backends.

Space-separated form (e.g. "upper right") matches matplotlib's own AnchoredOffsetbox convention. The legacy pyvista underscore form ("upper_right") is still accepted by _normalize_loc for backward compatibility.

ThemeArg = PlotTheme | str | None

Accepted type for the theme parameter across all plotting functions.

LegendEntry dataclass

One row in a vector legend: a sample line and its label.

Source code in src/pypic/plotting/_badge.py
456
457
458
459
460
461
462
463
464
@dataclass(frozen=True, slots=True)
class LegendEntry:
    """One row in a vector legend: a sample line and its label."""

    label: str
    color: str | None = None
    linewidth: float = 1.0
    linestyle: str = "-"
    alpha: float = 1.0

PlotTheme dataclass

Immutable collection of matplotlib styling for pypic plots.

Parameters:

Name Type Description Default
name str

Human-readable theme name.

required
rcparams Mapping[str, Any]

Full set of matplotlib rcParams to apply (must not contain axes.prop_cycle — use color_cycle instead). Stored read-only; derive a changed theme with customize.

required
sequential_cmaps tuple[str, ...]

Colormap preference list for positive-definite fields (first is default).

('inferno',)
diverging_cmaps tuple[str, ...]

Colormap preference list for signed fields (first is default).

('RdBu_r',)
grid_color tuple[float, float, float, float]

RGBA color for grid lines; the alpha channel carries the built-in opacity (default (0.0, 0.0, 0.0, 0.08)).

(0.0, 0.0, 0.0, 0.08)
color_cycle tuple[str, ...]

Hex color wheel for sequential visual elements (lines, scatter, categories). Built into axes.prop_cycle at theme-application time so that cycler is not required at import.

()
Source code in src/pypic/plotting/styles.py
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
@dataclass(frozen=True, slots=True)
class PlotTheme:
    """Immutable collection of matplotlib styling for pypic plots.

    Parameters
    ----------
    name : str
        Human-readable theme name.
    rcparams : Mapping[str, Any]
        Full set of matplotlib rcParams to apply (must not contain
        ``axes.prop_cycle`` — use *color_cycle* instead). Stored
        read-only; derive a changed theme with `customize`.
    sequential_cmaps : tuple[str, ...]
        Colormap preference list for positive-definite fields (first is default).
    diverging_cmaps : tuple[str, ...]
        Colormap preference list for signed fields (first is default).
    grid_color : tuple[float, float, float, float]
        RGBA color for grid lines; the alpha channel carries the
        built-in opacity (default ``(0.0, 0.0, 0.0, 0.08)``).
    color_cycle : tuple[str, ...]
        Hex color wheel for sequential visual elements (lines, scatter,
        categories). Built into ``axes.prop_cycle`` at theme-application
        time so that ``cycler`` is not required at import.
    """

    name: str
    rcparams: Mapping[str, Any]  # read-only via __post_init__

    # Colors — RGBA tuples (r, g, b, a) with built-in opacity.
    # String colors (background, accent) are opaque and don't need alpha.
    text_color: tuple[float, float, float, float] = (0.88, 0.88, 0.88, 0.9)
    secondary_text_color: tuple[float, float, float, float] = (0.53, 0.53, 0.53, 0.8)
    grid_color: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.08)
    overlay_color: tuple[float, float, float, float] = (0.07, 0.07, 0.07, 0.65)
    overlay_text_color: tuple[float, float, float, float] = (0.88, 0.88, 0.88, 0.8)
    overlay_alt_color: tuple[float, float, float, float] = (0.12, 0.12, 0.12, 0.55)
    overlay_alt_text_color: tuple[float, float, float, float] = (0.88, 0.88, 0.88, 0.8)
    overlay_border_color: tuple[float, float, float, float] = (0.3, 0.3, 0.3, 0.2)
    overlay_alt_border_color: tuple[float, float, float, float] = (0.5, 0.5, 0.5, 0.3)
    track_color: tuple[float, float, float, float] = (0.3, 0.3, 0.3, 0.3)
    track_alt_color: tuple[float, float, float, float] = (0.5, 0.5, 0.5, 0.4)
    accent_color: str = "#e8913a"
    color_cycle: tuple[str, ...] = ()

    # Colormaps (preference lists; first is default)
    sequential_cmaps: tuple[str, ...] = ("inferno",)
    diverging_cmaps: tuple[str, ...] = ("RdBu_r",)

    # Font
    font_family: tuple[str, ...] = ("DejaVu Serif", "Computer Modern", "Times", "serif")
    font_title: float = 12.0
    font_label: float = 11.0
    font_tick: float = 10.0
    font_overlay: float = 9.0

    # Overlay (badges, legends, inset colorbars)
    overlay_rounding: float = 0.6
    overlay_padding: float = 0.4
    overlay_margin: float = 0.03

    # Lines & arrows
    line_width: float = 1.5
    arrow_size: float = 4.0
    arrow_style: str = "triangle"

    # Axes (3D triad)
    axis_x_color: str = "#d63031"
    axis_y_color: str = "#00b894"
    axis_z_color: str = "#0984e3"
    axis_arrows: bool = True

    # Ticks
    tick_direction: str = "in"  # "in", "out", or "inout"
    tick_major_length: float = 4.0
    tick_major_width: float = 0.6
    tick_minor_length: float = 2.0
    tick_minor_width: float = 0.4

    # Grid
    grid_major_width: float = 0.5
    grid_minor_width: float = 0.3
    grid_style: str = "solid"

    # Colorbar
    colorbar_width: str = "4%"
    colorbar_outline_width: float = 0.3
    colorbar_tick_length: float = 2.0
    colorbar_pad: float = 0.05
    colorbar_title_font_scale: float = 1.4
    colorbar_tick_font_scale: float = 1.2

    # Progress bar
    progress_bar_width: float = 80.0
    progress_bar_height: float = 4.0
    progress_bar_rounding: float = 2.0
    badge_font_scale: float = 2.2

    # Pyvista axes (3D triad and equatorial grid)
    axis_triad_font_scale: float = 3.0
    grid_label_font_scale: float = 2.8

    # Multi-panel grid layout (near-square panels at journal column width)
    figsize_per_col: float = 4.5
    figsize_per_row: float = 4.0
    # Panel label font multiplier (tuned for readability in single- vs multi-row)
    panel_label_scale_sparse: float = 1.5
    panel_label_scale_dense: float = 1.8

    # Contour overlay
    contour_label_fontsize: float = 7.0

    # Data-space annotations (reference circles, error labels, etc.)
    annotation_fontsize: float = 7.5

    # Plot area
    plot_rounding: float = 0.0

    def __post_init__(self) -> None:
        # get_theme() hands every caller the same instance, so a mutable
        # rcparams would let one plot restyle all the others.
        object.__setattr__(self, "rcparams", MappingProxyType(dict(self.rcparams)))

    @property
    def sequential_cmap(self) -> str:
        """Default sequential colormap (first in preference list)."""
        return self.sequential_cmaps[0]

    @property
    def diverging_cmap(self) -> str:
        """Default diverging colormap (first in preference list)."""
        return self.diverging_cmaps[0]

    def customize(self, **overrides: Any) -> PlotTheme:  # noqa: ANN401
        r"""Return a new theme with selected fields overridden.

        Pass any `PlotTheme` field name as a keyword argument.
        Unknown keys with underscores are converted to matplotlib
        rcParam keys and merged into ``rcparams``
        (``figure_dpi=200`` → ``"figure.dpi": 200``).

        Returns
        -------
        PlotTheme

        Examples
        --------
        >>> t = get_theme()
        >>> big = t.customize(font_title=16.0)
        >>> big.font_title
        16.0
        >>> wide = t.customize(figsize_per_col=6.0)
        >>> wide.figsize_per_col
        6.0
        """
        import copy

        field_names = {f.name for f in fields(self)}
        theme_kw: dict[str, Any] = {}
        rc_kw: dict[str, Any] = {}

        for key, value in overrides.items():
            if key in field_names:
                theme_kw[key] = value
            else:
                rc_kw[key.replace("_", ".")] = value

        if rc_kw:
            theme_kw["rcparams"] = {**self.rcparams, **rc_kw}

        return copy.replace(self, **theme_kw)

sequential_cmap property

Default sequential colormap (first in preference list).

diverging_cmap property

Default diverging colormap (first in preference list).

customize(**overrides)

Return a new theme with selected fields overridden.

Pass any PlotTheme field name as a keyword argument. Unknown keys with underscores are converted to matplotlib rcParam keys and merged into rcparams (figure_dpi=200"figure.dpi": 200).

Returns:

Type Description
PlotTheme

Examples:

>>> t = get_theme()
>>> big = t.customize(font_title=16.0)
>>> big.font_title
16.0
>>> wide = t.customize(figsize_per_col=6.0)
>>> wide.figsize_per_col
6.0
Source code in src/pypic/plotting/styles.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
def customize(self, **overrides: Any) -> PlotTheme:  # noqa: ANN401
    r"""Return a new theme with selected fields overridden.

    Pass any `PlotTheme` field name as a keyword argument.
    Unknown keys with underscores are converted to matplotlib
    rcParam keys and merged into ``rcparams``
    (``figure_dpi=200`` → ``"figure.dpi": 200``).

    Returns
    -------
    PlotTheme

    Examples
    --------
    >>> t = get_theme()
    >>> big = t.customize(font_title=16.0)
    >>> big.font_title
    16.0
    >>> wide = t.customize(figsize_per_col=6.0)
    >>> wide.figsize_per_col
    6.0
    """
    import copy

    field_names = {f.name for f in fields(self)}
    theme_kw: dict[str, Any] = {}
    rc_kw: dict[str, Any] = {}

    for key, value in overrides.items():
        if key in field_names:
            theme_kw[key] = value
        else:
            rc_kw[key.replace("_", ".")] = value

    if rc_kw:
        theme_kw["rcparams"] = {**self.rcparams, **rc_kw}

    return copy.replace(self, **theme_kw)

add_badge(ax, text=None, *, step=None, time=None, time_units='', step_range=None, label=None, show_max=True, progress=None, variant=None, loc=None, fontsize=None, bar_color=None, bar_alpha=0.8, bar_width=None, bar_height=None, bg_color=None, bg_alpha=None, text_color=None, text_alpha=0.8, track_color=None)

Add a status badge overlay to an axes.

Renders simulation step, time, or custom text as a rounded box. A progress bar is shown when step_range or progress is set.

Parameters:

Name Type Description Default
ax Axes

Target axes for the badge.

required
text str

Direct custom text. When provided, step/time/label are ignored for text generation (but step_range/progress still drive the progress bar).

None
step int

Current simulation step number.

None
time float or str

Simulation time. A float is auto-formatted; a string is used verbatim (e.g. "13:34").

None
time_units str

Unit label appended to the time value (e.g. "ns").

''
step_range tuple[int, int]

(start, end) step range for auto-computing progress.

None
label str or None

Custom label prefix (e.g. "Cycle", "Step"). None uses auto-labels ("step" / "t"). "" suppresses.

None
show_max bool

When step_range is set, show "X / Y" if True.

True
progress float or None

Explicit progress fraction (0.0 to 1.0). Overrides step_range auto-computation when both are given.

None
variant "darker", "lighter", "alt", or None

"darker": darken axes facecolor for overlay bg. "lighter": lighten it. None (default): auto-detect.

None
loc BadgeLoc or None

Badge placement. None auto-selects (prefers upper right, avoids corners already claimed by other overlays).

None
fontsize float

Font size for the status text.

None
bar_color str

Fill color for the progress bar.

None
bar_alpha float

Opacity of the progress bar fill.

0.8
bar_width float

Width of the progress bar in points.

None
bar_height float

Height of the progress bar in points.

None
bg_color str, tuple, or None

Box background color override. None derives from variant.

None
bg_alpha float

Box background opacity.

None
text_color str, tuple, or None

Text color override. None uses rcParams text color.

None
text_alpha float

Text opacity (default 0.85 for subtle softening).

0.8
track_color str, tuple, or None

Progress bar track color override.

None

Returns:

Type Description
AnchoredOffsetbox

The badge artist added to the axes.

Examples:

>>> import matplotlib.pyplot as plt
>>> fig, ax = plt.subplots()
>>> _ = add_badge(ax, step=42)
>>> _ = add_badge(ax, step=100, label="Cycle")
>>> _ = add_badge(ax, time="13:34")
>>> _ = add_badge(ax, "Harris sheet, δ = 0.5 d_i")
>>> plt.close(fig)
Source code in src/pypic/plotting/_badge.py
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
def add_badge(
    ax: Axes,
    text: str | None = None,
    *,
    step: int | None = None,
    time: float | str | None = None,
    time_units: str = "",
    step_range: tuple[int, int] | None = None,
    label: str | None = None,
    show_max: bool = True,
    progress: float | None = None,
    variant: OverlayVariant | None = None,
    loc: BadgeLoc | None = None,
    fontsize: float | None = None,
    bar_color: str | None = None,
    bar_alpha: float = 0.8,
    bar_width: float | None = None,
    bar_height: float | None = None,
    bg_color: str | tuple[float, ...] | None = None,
    bg_alpha: float | None = None,
    text_color: str | tuple[float, ...] | None = None,
    text_alpha: float = 0.8,
    track_color: str | tuple[float, ...] | None = None,
) -> AnchoredOffsetbox:
    r"""Add a status badge overlay to an axes.

    Renders simulation step, time, or custom text as a rounded box.
    A progress bar is shown when *step_range* or *progress* is set.

    Parameters
    ----------
    ax : Axes
        Target axes for the badge.
    text : str, optional
        Direct custom text. When provided, *step*/*time*/*label* are
        ignored for text generation (but *step_range*/*progress* still
        drive the progress bar).
    step : int, optional
        Current simulation step number.
    time : float or str, optional
        Simulation time. A float is auto-formatted; a string is used
        verbatim (e.g. ``"13:34"``).
    time_units : str
        Unit label appended to the time value (e.g. ``"ns"``).
    step_range : tuple[int, int], optional
        ``(start, end)`` step range for auto-computing progress.
    label : str or None
        Custom label prefix (e.g. ``"Cycle"``, ``"Step"``). ``None``
        uses auto-labels (``"step"`` / ``"t"``). ``""`` suppresses.
    show_max : bool
        When ``step_range`` is set, show ``"X / Y"`` if True.
    progress : float or None
        Explicit progress fraction (0.0 to 1.0). Overrides *step_range*
        auto-computation when both are given.
    variant : "darker", "lighter", "alt", or None
        ``"darker"``: darken axes facecolor for overlay bg.
        ``"lighter"``: lighten it. ``None`` (default): auto-detect.
    loc : BadgeLoc or None
        Badge placement. ``None`` auto-selects (prefers upper right,
        avoids corners already claimed by other overlays).
    fontsize : float
        Font size for the status text.
    bar_color : str
        Fill color for the progress bar.
    bar_alpha : float
        Opacity of the progress bar fill.
    bar_width : float
        Width of the progress bar in points.
    bar_height : float
        Height of the progress bar in points.
    bg_color : str, tuple, or None
        Box background color override. ``None`` derives from *variant*.
    bg_alpha : float
        Box background opacity.
    text_color : str, tuple, or None
        Text color override. ``None`` uses rcParams text color.
    text_alpha : float
        Text opacity (default 0.85 for subtle softening).
    track_color : str, tuple, or None
        Progress bar track color override.

    Returns
    -------
    AnchoredOffsetbox
        The badge artist added to the axes.

    Examples
    --------
    >>> import matplotlib.pyplot as plt
    >>> fig, ax = plt.subplots()
    >>> _ = add_badge(ax, step=42)
    >>> _ = add_badge(ax, step=100, label="Cycle")
    >>> _ = add_badge(ax, time="13:34")
    >>> _ = add_badge(ax, "Harris sheet, δ = 0.5 d_i")
    >>> plt.close(fig)
    """
    ensure_matplotlib()

    from matplotlib.offsetbox import TextArea, VPacker

    from pypic.plotting.styles import _theme_val

    actual_loc = _claim_corner(ax, "upper right", loc)

    default_bg, default_fg, overlay_alpha = _detect_overlay_defaults(variant)

    if fontsize is None:
        fontsize = _theme_val("font_overlay", 9.0)
    if bar_color is None:
        bar_color = _theme_val("accent_color", "#e8913a")
    auto_bar_width = bar_width is None
    if bar_width is None:
        bar_width = _theme_val("progress_bar_width", 80.0)
    if bar_height is None:
        bar_height = _theme_val("progress_bar_height", 4.0)

    bg_rgba = resolve_rgba_override(bg_color, bg_alpha, (*default_bg, overlay_alpha))
    resolved_text = resolve_rgba_override(text_color, text_alpha, (*default_fg, 0.65))

    if track_color is None:
        track_key = "track_alt_color" if variant == "alt" else "track_color"
        track_rgba = _theme_val(track_key, (0.3, 0.3, 0.3, 0.3))
    else:
        effective_alpha = overlay_alpha if bg_alpha is None else bg_alpha
        track_rgba = resolve_rgba_override(
            track_color, effective_alpha * 0.4, (*default_fg, 0.65)
        )

    status_text = _format_status_text(
        text=text,
        step=step,
        time=time,
        time_units=time_units,
        step_range=step_range,
        label=label,
        show_max=show_max,
    )

    if not status_text:
        msg = "Provide text, step, or time for the badge"
        raise ValueError(msg)

    text_props = {"fontsize": fontsize, "color": resolved_text}
    text_area = TextArea(status_text, textprops=text_props)

    # Determine progress fraction
    fraction: float | None = progress
    if fraction is None and step_range is not None and step is not None:
        start, end = step_range
        if start > end:
            msg = f"step_range start must be <= end, got ({start}, {end})"
            raise ValueError(msg)
        fraction = max(0.0, min(1.0, (step - start) / max(end - start, 1)))

    child: OffsetBox
    if fraction is not None:
        # Scale bar width to match text length when using the default
        if auto_bar_width:
            estimated = len(status_text) * fontsize * _CHAR_WIDTH_RATIO
            bar_width = max(_BAR_WIDTH_MIN, min(_BAR_WIDTH_MAX, estimated))
        bar = _build_progress_bar(
            max(0.0, min(1.0, fraction)),
            bar_width,
            bar_height,
            bar_color,
            bar_alpha,
            track_rgba,
            variant=variant,
        )
        child = VPacker(
            children=[text_area, bar], pad=0, sep=_OVERLAY_SEP, align="left"
        )
    else:
        child = text_area

    return _make_overlay_box(ax, child, actual_loc, bg_rgba, variant=variant)

add_label(ax, label, *, variant=None, loc=None, fontsize=None, fontweight='bold', ha='left', bg_color=None, bg_alpha=None, text_color=None, text_alpha=0.8)

Add a bold panel letter (a, b, c, ...) overlay to an axes.

Styled with the same rounded box as add_badge.

Parameters:

Name Type Description Default
ax Axes

Target axes.

required
label str

Panel label text (e.g. "a", "b").

required
variant "darker", "lighter", "alt", or None

"darker": darken axes facecolor for overlay bg. "lighter": lighten it. None (default): auto-detect.

None
loc BadgeLoc or None

Placement. None auto-selects (prefers upper left).

None
fontsize float

Label font size.

None
fontweight str

Font weight (default "bold").

'bold'
bg_color str | tuple[float, ...] | None

Color overrides; same semantics as add_badge.

None
bg_alpha str | tuple[float, ...] | None

Color overrides; same semantics as add_badge.

None
text_color str | tuple[float, ...] | None

Color overrides; same semantics as add_badge.

None
text_alpha float

Text opacity (default 0.85 for subtle softening).

0.8

Returns:

Type Description
AnchoredOffsetbox
Source code in src/pypic/plotting/_badge.py
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
def add_label(
    ax: Axes,
    label: str,
    *,
    variant: OverlayVariant | None = None,
    loc: BadgeLoc | None = None,
    fontsize: float | None = None,
    fontweight: str = "bold",
    ha: str = "left",
    bg_color: str | tuple[float, ...] | None = None,
    bg_alpha: float | None = None,
    text_color: str | tuple[float, ...] | None = None,
    text_alpha: float = 0.8,
) -> AnchoredOffsetbox:
    r"""Add a bold panel letter (a, b, c, ...) overlay to an axes.

    Styled with the same rounded box as `add_badge`.

    Parameters
    ----------
    ax : Axes
        Target axes.
    label : str
        Panel label text (e.g. ``"a"``, ``"b"``).
    variant : "darker", "lighter", "alt", or None
        ``"darker"``: darken axes facecolor for overlay bg.
        ``"lighter"``: lighten it. ``None`` (default): auto-detect.
    loc : BadgeLoc or None
        Placement. ``None`` auto-selects (prefers upper left).
    fontsize : float
        Label font size.
    fontweight : str
        Font weight (default ``"bold"``).
    bg_color, bg_alpha, text_color
        Color overrides; same semantics as `add_badge`.
    text_alpha : float
        Text opacity (default 0.85 for subtle softening).

    Returns
    -------
    AnchoredOffsetbox
    """
    ensure_matplotlib()
    from matplotlib.offsetbox import HPacker, TextArea

    from pypic.plotting.styles import _theme_val

    actual_loc = _claim_corner(ax, "upper left", loc)

    is_phrase = " " in label
    if fontsize is None:
        fontsize = _theme_val("font_label" if is_phrase else "font_title", 12.0)
    if fontweight == "bold" and is_phrase:
        fontweight = "normal"

    default_bg, default_fg, overlay_alpha = _detect_overlay_defaults(variant)

    bg_rgba = resolve_rgba_override(bg_color, bg_alpha, (*default_bg, overlay_alpha))
    resolved_text = resolve_rgba_override(text_color, text_alpha, (*default_fg, 0.65))

    props = {
        "fontsize": fontsize,
        "fontweight": fontweight,
        "color": resolved_text,
        "ha": ha,
    }
    text_area = TextArea(label, textprops=props, multilinebaseline=True)

    # Extra horizontal padding so the box looks square for single letters
    pad = 0 if is_phrase else fontsize * _SINGLE_CHAR_PAD
    child = HPacker(children=[text_area], pad=pad, sep=0, align="center")

    return _make_overlay_box(ax, child, actual_loc, bg_rgba, variant=variant)

add_legend(ax, entries, *, color=None, linewidth=1.0, entry_alpha=1.0, variant=None, loc=None, fontsize=None, sample_width=20, bg_color=None, bg_alpha=None, text_color=None, text_alpha=0.8)

Add a vector legend overlay showing colored line samples with labels.

Each entry renders as a short line segment (using the entry's color, linewidth, linestyle, alpha) next to its label text. Multiple entries are stacked vertically. The box style matches add_badge.

For a single entry, pass a string label directly::

add_legend(ax, "J", color="white")

Parameters:

Name Type Description Default
ax Axes

Target axes.

required
entries str, LegendEntry, or list[LegendEntry]

A field label string, a single entry, or multiple entries.

required
color str or None

Line color when entries is a string.

None
linewidth float

Line width when entries is a string.

1.0
entry_alpha float

Line opacity when entries is a string.

1.0
variant "darker", "lighter", "alt", or None

"darker": darken axes facecolor for overlay bg. "lighter": lighten it. None (default): auto-detect.

None
loc BadgeLoc or None

Placement. None auto-selects (prefers lower left).

None
fontsize float

Label font size.

None
sample_width float

Width of the sample line in points.

20
bg_color str | tuple[float, ...] | None

Color overrides; same semantics as add_badge.

None
bg_alpha str | tuple[float, ...] | None

Color overrides; same semantics as add_badge.

None
text_color str | tuple[float, ...] | None

Color overrides; same semantics as add_badge.

None
text_alpha float

Text opacity (default 0.85 for subtle softening).

0.8

Returns:

Type Description
AnchoredOffsetbox
Source code in src/pypic/plotting/_badge.py
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
def add_legend(
    ax: Axes,
    entries: str | LegendEntry | list[LegendEntry],
    *,
    color: str | None = None,
    linewidth: float = 1.0,
    entry_alpha: float = 1.0,
    variant: OverlayVariant | None = None,
    loc: BadgeLoc | None = None,
    fontsize: float | None = None,
    sample_width: float = 20,
    bg_color: str | tuple[float, ...] | None = None,
    bg_alpha: float | None = None,
    text_color: str | tuple[float, ...] | None = None,
    text_alpha: float = 0.8,
) -> AnchoredOffsetbox:
    r"""Add a vector legend overlay showing colored line samples with labels.

    Each entry renders as a short line segment (using the entry's color,
    linewidth, linestyle, alpha) next to its label text.  Multiple entries
    are stacked vertically.  The box style matches `add_badge`.

    For a single entry, pass a string label directly::

        add_legend(ax, "J", color="white")

    Parameters
    ----------
    ax : Axes
        Target axes.
    entries : str, LegendEntry, or list[LegendEntry]
        A field label string, a single entry, or multiple entries.
    color : str or None
        Line color when *entries* is a string.
    linewidth : float
        Line width when *entries* is a string.
    entry_alpha : float
        Line opacity when *entries* is a string.
    variant : "darker", "lighter", "alt", or None
        ``"darker"``: darken axes facecolor for overlay bg.
        ``"lighter"``: lighten it. ``None`` (default): auto-detect.
    loc : BadgeLoc or None
        Placement. ``None`` auto-selects (prefers lower left).
    fontsize : float
        Label font size.
    sample_width : float
        Width of the sample line in points.
    bg_color, bg_alpha, text_color
        Color overrides; same semantics as `add_badge`.
    text_alpha : float
        Text opacity (default 0.85 for subtle softening).

    Returns
    -------
    AnchoredOffsetbox
    """
    ensure_matplotlib()
    from matplotlib.lines import Line2D
    from matplotlib.offsetbox import (
        DrawingArea,
        HPacker,
        TextArea,
        VPacker,
    )
    from matplotlib.patches import FancyArrowPatch

    from pypic.plotting.styles import _theme_val

    actual_loc = _claim_corner(ax, "lower left", loc)

    if fontsize is None:
        fontsize = _theme_val("font_overlay", 9.0)

    if isinstance(entries, str):
        entries = [
            LegendEntry(
                label=entries,
                color=color,
                linewidth=linewidth,
                alpha=entry_alpha,
            )
        ]
    elif isinstance(entries, LegendEntry):
        entries = [entries]

    # Auto-select overlay variant for best contrast with entry colors
    if variant is None:
        from matplotlib.colors import to_rgba

        entry_rgbs = [to_rgba(e.color)[:3] for e in entries if e.color is not None]
        if entry_rgbs:
            variant = _pick_overlay_variant(entry_rgbs)

    default_bg, default_fg, overlay_alpha = _detect_overlay_defaults(variant)

    bg_rgba = resolve_rgba_override(bg_color, bg_alpha, (*default_bg, overlay_alpha))
    resolved_text = resolve_rgba_override(text_color, text_alpha, (*default_fg, 0.65))

    rows: list[Artist] = []
    line_height = fontsize * _LINE_HEIGHT_RATIO
    for entry in entries:
        drawing = DrawingArea(sample_width, line_height)
        mid_y = line_height / 2
        base_arrow: float = _theme_val("arrow_size", 4.0)
        arrow_size = max(base_arrow, entry.linewidth * _ARROW_SCALE)
        line = Line2D(
            [0, sample_width - arrow_size],
            [mid_y, mid_y],
            color=entry.color,
            linewidth=entry.linewidth,
            linestyle=entry.linestyle,
            alpha=entry.alpha,
        )
        drawing.add_artist(line)
        arrow = FancyArrowPatch(
            (sample_width - arrow_size, mid_y),
            (sample_width, mid_y),
            arrowstyle="-|>",
            mutation_scale=arrow_size * _ARROW_MUTATION,
            color=entry.color,
            linewidth=entry.linewidth,
            alpha=entry.alpha,
        )
        drawing.add_artist(arrow)

        text = TextArea(
            entry.label,
            textprops={
                "fontsize": fontsize,
                "fontweight": "bold",
                "color": resolved_text,
            },
        )
        row = HPacker(children=[drawing, text], pad=0, sep=_OVERLAY_SEP, align="center")
        rows.append(row)

    legend_child: OffsetBox
    if len(rows) > 1:
        legend_child = VPacker(children=rows, pad=0, sep=_OVERLAY_SEP, align="left")
    else:
        legend_child = rows[0]  # type: ignore[assignment]  # HPacker is an OffsetBox

    return _make_overlay_box(
        ax,
        legend_child,
        actual_loc,
        bg_rgba,
        variant=variant,
    )

add_colorbar(fig, ax, mappable, label, *, extend='both', extremes='semi')

Add a colorbar with styled over/under extensions and subtle outline.

Parameters:

Name Type Description Default
fig Figure

Figure the colorbar is drawn on.

required
ax Axes

Axes the colorbar is attached to.

required
mappable ScalarMappable

The image or collection the colorbar describes.

required
label str

Colorbar label text.

required
extend str

Which ends to draw out-of-range indicators on: "neither", "both", "min", or "max".

'both'
extremes "semi", "transparent", "darken", or None

"semi" (default) — semi-transparent extension colors. "transparent" — fully transparent extensions. "darken" — darkened endpoint colors. None — matplotlib defaults (no modification).

'semi'

Returns:

Type Description
Colorbar

The attached colorbar, already styled.

Notes

Colors for the outline, ticks, and label are read from the active matplotlib rcParams so that custom themes propagate automatically.

Source code in src/pypic/plotting/_colorbar.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
def add_colorbar(
    fig: Figure,
    ax: Axes,
    mappable: ScalarMappable,
    label: str,
    *,
    extend: str = "both",
    extremes: ExtremesMode = "semi",
) -> Colorbar:
    """Add a colorbar with styled over/under extensions and subtle outline.

    Parameters
    ----------
    fig : Figure
        Figure the colorbar is drawn on.
    ax : Axes
        Axes the colorbar is attached to.
    mappable : ScalarMappable
        The image or collection the colorbar describes.
    label : str
        Colorbar label text.
    extend : str
        Which ends to draw out-of-range indicators on:
        ``"neither"``, ``"both"``, ``"min"``, or ``"max"``.
    extremes : "semi", "transparent", "darken", or None
        ``"semi"`` (default) — semi-transparent extension colors.
        ``"transparent"`` — fully transparent extensions.
        ``"darken"`` — darkened endpoint colors.
        ``None`` — matplotlib defaults (no modification).

    Returns
    -------
    Colorbar
        The attached colorbar, already styled.

    Notes
    -----
    Colors for the outline, ticks, and label are read from the active
    matplotlib rcParams so that custom themes propagate automatically.
    """
    from mpl_toolkits.axes_grid1 import make_axes_locatable

    from pypic.plotting.styles import _theme_val

    _apply_extremes(mappable, mode=extremes)
    if extremes is None:
        extend = "neither"

    divider = make_axes_locatable(ax)
    cax = divider.append_axes(
        "right",
        size=_theme_val("colorbar_width", "4%"),
        pad=_theme_val("colorbar_pad", 0.05),
    )
    cb = fig.colorbar(mappable, cax=cax, extend=extend)
    _style_colorbar(cb, label)
    return cb

add_inset_colorbar(ax, mappable, label='', *, loc=None, variant=None, width=0.3, height=0.02, pad=None, extend='both', extremes='semi', n_ticks=3, ticks=None, fontsize=None, bg_color=None, bg_alpha=None, text_color=None, text_alpha=0.8)

Add a horizontal colorbar rendered inside the plot axes.

Uses ax.inset_axes() for the colorbar with a rounded background patch. Visually matches the badge style from add_badge.

Parameters:

Name Type Description Default
ax Axes

Target axes.

required
mappable object

A ScalarMappable (e.g. from pcolormesh or streamplot).

required
label str

Colorbar label text.

''
loc BadgeLoc or None

Inset placement. None auto-selects (prefers lower right).

None
variant "darker", "lighter", "alt", or None

"darker": darken axes facecolor for overlay bg. "lighter": lighten it. None (default): auto-detect.

None
width float

Bar width as a fraction of axes width.

0.3
height float

Bar height as a fraction of axes height.

0.02
pad float

Padding from axes edge as a fraction.

None
extend str

Colorbar extension ("both", "min", "max", "neither").

'both'
n_ticks int

Maximum number of colorbar tick intervals.

3
fontsize float

Font size for tick labels and colorbar label.

None
bg_color str, tuple, or None

Background box color override.

None
bg_alpha float

Background box opacity.

None
text_color str, tuple, or None

Tick and label color override.

None
text_alpha float

Text opacity (default 0.85 for subtle softening).

0.8
ticks Sequence[float] or None

Explicit tick positions. None (default) lets matplotlib choose.

None

Returns:

Type Description
Colorbar
Source code in src/pypic/plotting/_colorbar.py
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
def add_inset_colorbar(
    ax: Axes,
    mappable: ScalarMappable,
    label: str = "",
    *,
    loc: BadgeLoc | None = None,
    variant: OverlayVariant | None = None,
    width: float = 0.3,
    height: float = 0.02,
    pad: float | None = None,
    extend: str = "both",
    extremes: ExtremesMode = "semi",
    n_ticks: int = 3,
    ticks: list[float] | None = None,
    fontsize: float | None = None,
    bg_color: str | tuple[float, ...] | None = None,
    bg_alpha: float | None = None,
    text_color: str | tuple[float, ...] | None = None,
    text_alpha: float = 0.8,
) -> Colorbar:
    """Add a horizontal colorbar rendered inside the plot axes.

    Uses ``ax.inset_axes()`` for the colorbar with a rounded background
    patch. Visually matches the badge style from
    [`add_badge`][pypic.plotting.add_badge].

    Parameters
    ----------
    ax : Axes
        Target axes.
    mappable : object
        A ``ScalarMappable`` (e.g. from ``pcolormesh`` or ``streamplot``).
    label : str
        Colorbar label text.
    loc : BadgeLoc or None
        Inset placement. ``None`` auto-selects (prefers lower right).
    variant : "darker", "lighter", "alt", or None
        ``"darker"``: darken axes facecolor for overlay bg.
        ``"lighter"``: lighten it. ``None`` (default): auto-detect.
    width : float
        Bar width as a fraction of axes width.
    height : float
        Bar height as a fraction of axes height.
    pad : float
        Padding from axes edge as a fraction.
    extend : str
        Colorbar extension (``"both"``, ``"min"``, ``"max"``, ``"neither"``).
    n_ticks : int
        Maximum number of colorbar tick intervals.
    fontsize : float
        Font size for tick labels and colorbar label.
    bg_color : str, tuple, or None
        Background box color override.
    bg_alpha : float
        Background box opacity.
    text_color : str, tuple, or None
        Tick and label color override.
    text_alpha : float
        Text opacity (default 0.85 for subtle softening).
    ticks : Sequence[float] or None
        Explicit tick positions. ``None`` (default) lets matplotlib
        choose.

    Returns
    -------
    Colorbar
    """
    from matplotlib.patches import FancyBboxPatch
    from matplotlib.ticker import FuncFormatter, MaxNLocator

    from pypic.plotting._badge import (
        _claim_corner,
        _detect_overlay_defaults,
        _resolve_border,
    )
    from pypic.plotting._overlay_common import resolve_rgba_override
    from pypic.plotting.styles import _theme_val

    actual_loc = _claim_corner(ax, "lower right", loc)

    default_bg, default_fg, overlay_alpha = _detect_overlay_defaults(variant)

    bg_rgba = resolve_rgba_override(bg_color, bg_alpha, (*default_bg, overlay_alpha))
    fg_rgba = resolve_rgba_override(text_color, text_alpha, (*default_fg, 0.65))

    rounding: float = _theme_val("overlay_rounding", 0.6)
    overlay_pad: float = _theme_val("overlay_padding", 0.4)
    if pad is None:
        pad = _theme_val("overlay_margin", 0.03) * 0.5

    box_pad = overlay_pad * _PAD_TO_AXES_FRAC

    if fontsize is None:
        fontsize = _theme_val("font_overlay", 9.0)

    fig = ax.get_figure()
    if fig is None:
        msg = "axes has no parent figure"
        raise RuntimeError(msg)

    _apply_extremes(mappable, mode=extremes)
    if extremes is None:
        extend = "neither"

    # Pass 1: provisional colorbar at an arbitrary position, for measurement.
    _prov = 0.3  # arbitrary axes-fraction origin; overwritten in pass 2
    prov_cax = ax.inset_axes((_prov, _prov, width, height), zorder=5)
    cb = fig.colorbar(mappable, cax=prov_cax, orientation="horizontal", extend=extend)
    if ticks is not None:
        cb.set_ticks(ticks)
    else:
        cb.locator = MaxNLocator(nbins=n_ticks)
        cb.update_ticks()
    _style_colorbar(cb, tick_color=fg_rgba)
    prov_cax.xaxis.set_major_formatter(FuncFormatter(_compact_formatter))
    prov_cax.xaxis.get_offset_text().set_visible(False)
    tick_w: float = _theme_val("colorbar_outline_width", 0.3) * 2
    prov_cax.tick_params(
        labelsize=fontsize,
        colors=fg_rgba,
        top=True,
        bottom=False,
        labeltop=False,
        labelbottom=True,
        direction="in",
        width=tick_w,
    )
    if label:
        prov_cax.set_title(label, fontsize=fontsize, color=fg_rgba, pad=4)

    renderer = fig.canvas.get_renderer()  # type: ignore[attr-defined]  # mpl backend stub gap
    fig.draw(renderer)
    bbox_disp = prov_cax.get_tightbbox(renderer)
    bbox_ax = bbox_disp.transformed(ax.transAxes.inverted())  # type: ignore[union-attr]

    # Overhangs: how much text extends beyond the bar on each side
    overhang_left = _prov - bbox_ax.x0
    overhang_right = bbox_ax.x1 - (_prov + width)
    overhang_bottom = _prov - bbox_ax.y0
    overhang_top = bbox_ax.y1 - (_prov + height)

    # Ensure content width accommodates the title if it's wider than the bar.
    # get_tightbbox can underestimate title extent, so measure explicitly.
    if label and prov_cax.title.get_text():
        title_bbox = prov_cax.title.get_window_extent(renderer)
        title_w_ax = title_bbox.transformed(ax.transAxes.inverted()).width
        # Add a small buffer (half a character width) for font rendering variance
        char_w = title_w_ax / max(len(label), 1)
        title_overhang = max(0, (title_w_ax + char_w - width) / 2)
        overhang_left = max(overhang_left, title_overhang)
        overhang_right = max(overhang_right, title_overhang)

    content_w = width + overhang_left + overhang_right
    content_h = height + overhang_top + overhang_bottom
    total_w = content_w + 2 * box_pad
    total_h = content_h + 2 * box_pad

    # Remove provisional colorbar axes
    prov_cax.remove()

    # Pass 2: place at the correct position.
    if "right" in actual_loc:
        bg_x = 1.0 - pad - total_w
    elif "center" in actual_loc:
        bg_x = 0.5 - total_w / 2
    else:
        bg_x = pad
    bg_y = 1.0 - pad - total_h if "upper" in actual_loc else pad

    bar_x = bg_x + box_pad + overhang_left
    bar_y = bg_y + box_pad + overhang_bottom
    cax = ax.inset_axes((bar_x, bar_y, width, height), zorder=5)
    cb = fig.colorbar(mappable, cax=cax, orientation="horizontal", extend=extend)
    if ticks is not None:
        cb.set_ticks(ticks)
    else:
        cb.locator = MaxNLocator(nbins=n_ticks)
        cb.update_ticks()
    _style_colorbar(cb, tick_color=fg_rgba)
    cax.xaxis.set_major_formatter(FuncFormatter(_compact_formatter))
    cax.xaxis.get_offset_text().set_visible(False)
    cax.tick_params(
        labelsize=fontsize,
        colors=fg_rgba,
        top=True,
        bottom=False,
        labeltop=False,
        labelbottom=True,
        direction="in",
        width=tick_w,
    )
    if label:
        cax.set_title(label, fontsize=fontsize, color=fg_rgba, pad=4)
    cax.set_facecolor("none")

    # Background patch — initial size from provisional measurement
    has_bg = bg_rgba[3] >= ALPHA_VISIBLE
    bg_patch = FancyBboxPatch(
        (bg_x, bg_y),
        total_w,
        total_h,
        boxstyle=f"round,pad=0,rounding_size={rounding * _ROUNDING_TO_AXES_FRAC:.4f}",
        facecolor=bg_rgba if has_bg else "none",
        edgecolor=_resolve_border(variant),
        linewidth=0.5,  # matches _badge._OVERLAY_BORDER_LW
        transform=ax.transAxes,
        zorder=4.9,
    )
    ax.add_patch(bg_patch)

    # At draw time, re-measure content and reposition both the background
    # patch and the colorbar axes so that the overlay respects the margin
    # even after set_xlim / set_aspect changes the axes layout.
    _loc_str = actual_loc
    # Store the original bar position in axes fraction for absolute
    # repositioning (avoids accumulating deltas across multiple draws).
    _orig_bar_x = bar_x
    _orig_bar_y = bar_y

    # Remove the inset locator so set_position sticks across draws.
    # mpl stubs declare locator as non-Optional but None is the documented
    # way to clear it; cast away the bogus strictness.
    cax.set_axes_locator(None)  # type: ignore[arg-type]

    def _resize_bg(_event: object) -> None:
        r = fig.canvas.get_renderer()  # type: ignore[attr-defined]  # mpl backend stub gap

        # Temporarily put cax back at its original position to get a
        # stable tightbbox measurement (avoids feedback loops).
        ax_pos = ax.get_position()
        cax.set_position(
            (
                ax_pos.x0 + _orig_bar_x * ax_pos.width,
                ax_pos.y0 + _orig_bar_y * ax_pos.height,
                width * ax_pos.width,
                height * ax_pos.height,
            )
        )

        tb = cax.get_tightbbox(r)
        if tb is None:
            return
        tb_ax = tb.transformed(ax.transAxes.inverted())
        new_w = tb_ax.width + 2 * box_pad
        new_h = tb_ax.height + 2 * box_pad

        # Where the background should be (anchored to corner with margin)
        if "right" in _loc_str:
            new_bg_x = 1.0 - pad - new_w
        elif "center" in _loc_str:
            new_bg_x = 0.5 - new_w / 2
        else:
            new_bg_x = pad
        new_bg_y = 1.0 - pad - new_h if "upper" in _loc_str else pad

        bg_patch.set_bounds(new_bg_x, new_bg_y, new_w, new_h)

        # Shift cax so content is centered in the background
        content_cx = tb_ax.x0 + tb_ax.width / 2
        content_cy = tb_ax.y0 + tb_ax.height / 2
        target_cx = new_bg_x + new_w / 2
        target_cy = new_bg_y + new_h / 2
        new_bar_x = _orig_bar_x + (target_cx - content_cx)
        new_bar_y = _orig_bar_y + (target_cy - content_cy)
        cax.set_position(
            (
                ax_pos.x0 + new_bar_x * ax_pos.width,
                ax_pos.y0 + new_bar_y * ax_pos.height,
                width * ax_pos.width,
                height * ax_pos.height,
            )
        )

    fig.canvas.mpl_connect("draw_event", _resize_bg)

    return cb

auto_clim(values, *, positive_definite)

Compute auto color limits using 3\(\sigma\) from the median.

Positive-definite fields get (0, round_nice(median + 3*sigma)). Signed fields get (-spread, spread) where spread = round_nice(3*sigma), symmetric around zero so the colormap's zero-crossing aligns with physical zero.

Parameters:

Name Type Description Default
values FloatArray

Field values (may contain NaN).

required
positive_definite bool

Whether the field is positive-definite.

required

Returns:

Type Description
tuple[float, float]

(vmin, vmax)

Source code in src/pypic/plotting/_colormaps.py
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
def auto_clim(
    values: FloatArray,
    *,
    positive_definite: bool,
) -> tuple[float, float]:
    r"""Compute auto color limits using 3$\sigma$ from the median.

    Positive-definite fields get ``(0, round_nice(median + 3*sigma))``.
    Signed fields get ``(-spread, spread)`` where
    ``spread = round_nice(3*sigma)``, symmetric around zero so the
    colormap's zero-crossing aligns with physical zero.

    Parameters
    ----------
    values : FloatArray
        Field values (may contain NaN).
    positive_definite : bool
        Whether the field is positive-definite.

    Returns
    -------
    tuple[float, float]
        ``(vmin, vmax)``
    """
    finite = values[np.isfinite(values)]
    if finite.size == 0:
        return (-1e-8, 1e-8) if not positive_definite else (0.0, 1e-8)

    median = float(np.median(finite))
    sigma = float(np.std(finite))

    if sigma == 0.0:
        if positive_definite:
            return (0.0, round_nice(abs(median)) if median != 0 else 1e-8)
        absmax = abs(median) if median != 0 else 1e-8
        return (-round_nice(absmax), round_nice(absmax))

    if positive_definite:
        vmax = round_nice(median + 3.0 * sigma)
        return (0.0, vmax if vmax > 0 else 1e-8)

    spread = round_nice(3.0 * sigma)
    return (-spread, spread)

round_nice(value)

Round a positive value to the nearest 1-2-5 \(\times 10^n\) step.

The 1-2-5 sequence (..., 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, ...) produces clean colorbar tick labels and avoids awkward intermediate values like 3 or 7.

Parameters:

Name Type Description Default
value float

Positive value to round. Zero returns 0.

required

Returns:

Type Description
float

Examples:

>>> round_nice(0.7)
0.5
>>> round_nice(3.5)
5.0
>>> round_nice(150.0)
200.0
Source code in src/pypic/plotting/_colormaps.py
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
def round_nice(value: float) -> float:
    r"""Round a positive value to the nearest 1-2-5 $\times 10^n$ step.

    The 1-2-5 sequence (..., 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, ...)
    produces clean colorbar tick labels and avoids awkward intermediate
    values like 3 or 7.

    Parameters
    ----------
    value : float
        Positive value to round. Zero returns 0.

    Returns
    -------
    float

    Examples
    --------
    >>> round_nice(0.7)
    0.5
    >>> round_nice(3.5)
    5.0
    >>> round_nice(150.0)
    200.0
    """
    if value <= 0:
        return 0.0
    import math

    exponent = math.floor(math.log10(value))
    mantissa = value / 10**exponent
    candidates = (1.0, 2.0, 5.0, 10.0)
    # On tie, pick the larger candidate (wider color range is safer)
    best = min(candidates, key=lambda c: (abs(c - mantissa), -c))
    return float(best * 10**exponent)

available_themes()

Return all available themes from user and package directories.

User themes override bundled themes of the same name. Each call reads fresh from disk.

Returns:

Type Description
dict[str, PlotTheme]

Mapping from theme name to loaded theme.

Source code in src/pypic/plotting/_theme_io.py
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
def available_themes() -> dict[str, PlotTheme]:
    """Return all available themes from user and package directories.

    User themes override bundled themes of the same name.  Each call
    reads fresh from disk.

    Returns
    -------
    dict[str, PlotTheme]
        Mapping from theme name to loaded theme.
    """
    themes: dict[str, PlotTheme] = {}
    for path in _iter_theme_files():
        try:
            theme = load_theme(path)
        except Exception:  # noqa: BLE001 — one bad theme file must not hide the rest
            _log.warning("failed to load theme from %s", path, exc_info=True)
            continue
        themes[path.stem.lower()] = theme
    return themes

export_themes(directory=None, *, overwrite=False)

Copy bundled themes to a directory for editing.

Parameters:

Name Type Description Default
directory str | Path | None

Target directory. None uses the default user theme directory (~/.config/pypic/themes/).

None
overwrite bool

If False (default), skip files that already exist.

False

Returns:

Type Description
Path

The directory themes were written to.

Source code in src/pypic/plotting/_theme_io.py
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
def export_themes(
    directory: str | Path | None = None,
    *,
    overwrite: bool = False,
) -> Path:
    """Copy bundled themes to a directory for editing.

    Parameters
    ----------
    directory : str | Path | None
        Target directory.  ``None`` uses the default user theme
        directory (``~/.config/pypic/themes/``).
    overwrite : bool
        If ``False`` (default), skip files that already exist.

    Returns
    -------
    Path
        The directory themes were written to.
    """
    target = Path(directory) if directory is not None else _default_theme_dir()
    target.mkdir(parents=True, exist_ok=True)

    bundled = _bundled_theme_dir()
    for src in sorted(bundled.glob("*.toml")):
        dst = target / src.name
        if dst.exists() and not overwrite:
            _log.info("skipping existing %s", dst)
            continue
        shutil.copy2(src, dst)
        _log.info("wrote %s", dst)

    return target

load_theme(path)

Load a PlotTheme from a TOML file.

Only name and [colors] (with at least background and text) are required. All other keys fall back to PlotTheme defaults, and sections other tools own ([webpic], ...) are ignored.

Raises:

Type Description
ValueError

If a key holds the wrong TOML type (a string where a number or boolean belongs, ...).

Parameters:

Name Type Description Default
path str or Path

Path to the .toml theme file.

required

Returns:

Type Description
PlotTheme

Examples:

>>> from pypic.plotting._theme_io import _bundled_theme_dir, load_theme
>>> t = load_theme(_bundled_theme_dir() / "dark.toml")
>>> t.name
'dark'
Source code in src/pypic/plotting/_theme_io.py
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
def load_theme(path: str | Path) -> PlotTheme:
    r"""Load a `PlotTheme` from a TOML file.

    Only ``name`` and ``[colors]`` (with at least ``background`` and
    ``text``) are required.  All other keys fall back to `PlotTheme`
    defaults, and sections other tools own (``[webpic]``, ...) are
    ignored.

    Raises
    ------
    ValueError
        If a key holds the wrong TOML type (a string where a number or
        boolean belongs, ...).

    Parameters
    ----------
    path : str or Path
        Path to the ``.toml`` theme file.

    Returns
    -------
    PlotTheme

    Examples
    --------
    >>> from pypic.plotting._theme_io import _bundled_theme_dir, load_theme
    >>> t = load_theme(_bundled_theme_dir() / "dark.toml")
    >>> t.name
    'dark'
    """
    path = Path(path)
    with path.open("rb") as f:
        raw = tomllib.load(f)

    values: dict[str, Any] = {}
    for section, key, field, kind in _THEME_FIELDS:
        table = raw
        for part in section.split("."):
            table = table.get(part, {})
        if key not in table:
            continue
        value = table[key]
        if not isinstance(value, _TOML_TYPES[kind]) or (
            kind == "float" and isinstance(value, bool)
        ):
            msg = f"{path}: [{section}] {key} = {value!r} is not a valid {kind}"
            raise ValueError(msg)
        values[field] = _decode(kind, value, field)

    rc: dict[str, Any] = {**_COMMON_RC}
    background = raw.get("colors", {}).get("background")
    if background is not None:
        rc.update(dict.fromkeys(_FACECOLOR_KEYS, background))
    padding = values.get("overlay_padding", _DEFAULTS["overlay_padding"])
    rc["legend.borderaxespad"] = padding * 1.5
    return PlotTheme(name=raw.get("name", path.stem), rcparams=rc, **values)

save_theme(theme, path)

Save a PlotTheme to a TOML file.

The output uses the cross-project schema with RGBA color tuples.

Parameters:

Name Type Description Default
theme PlotTheme

Theme to serialize.

required
path str or Path

Output file path.

required

Examples:

>>> import tempfile
>>> from pathlib import Path
>>> from pypic.plotting._theme_io import _bundled_theme_dir, load_theme, save_theme
>>> theme = load_theme(_bundled_theme_dir() / "light.toml")
>>> with tempfile.TemporaryDirectory() as tmp:
...     out = Path(tmp) / "light.toml"
...     save_theme(theme, out)
...     load_theme(out).name == theme.name
True
Source code in src/pypic/plotting/_theme_io.py
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
def save_theme(theme: PlotTheme, path: str | Path) -> None:
    r"""Save a `PlotTheme` to a TOML file.

    The output uses the cross-project schema with RGBA color tuples.

    Parameters
    ----------
    theme : PlotTheme
        Theme to serialize.
    path : str or Path
        Output file path.

    Examples
    --------
    >>> import tempfile
    >>> from pathlib import Path
    >>> from pypic.plotting._theme_io import _bundled_theme_dir, load_theme, save_theme
    >>> theme = load_theme(_bundled_theme_dir() / "light.toml")
    >>> with tempfile.TemporaryDirectory() as tmp:
    ...     out = Path(tmp) / "light.toml"
    ...     save_theme(theme, out)
    ...     load_theme(out).name == theme.name
    True
    """
    background = theme.rcparams.get("figure.facecolor", "white")
    lines = [f"name = {_toml_value(theme.name)}"]
    section = ""
    for field_section, key, field, _kind in _THEME_FIELDS:
        if field_section != section:
            section = field_section
            lines += ["", f"[{section}]"]
            if section == "colors":
                lines.append(f"background = {_toml_value(background)}")
        lines.append(f"{key} = {_toml_value(getattr(theme, field))}")
    Path(path).write_text("\n".join(lines) + "\n")

add_circle(ax, radius, center=(0.0, 0.0), *, label=None, label_position=45.0, color=None, alpha=0.6, linestyle='--', linewidth=0.8, fontsize=None, text_alpha=0.9, variant=None, zorder=5)

Draw a reference circle with an optional radius label.

Parameters:

Name Type Description Default
ax Axes

Target axes.

required
radius float

Circle radius in data units.

required
center tuple[float, float]

Circle center in data coordinates.

(0.0, 0.0)
label str

Text placed on the circle perimeter (e.g. "5 R_E"). None suppresses the label.

None
label_position float

Angle in degrees (CCW from +x) where the label sits.

45.0
color str

Line color. Defaults to the theme's grid color at higher opacity.

None
alpha float

Line opacity.

0.6
linestyle str

Line style ("--", ":", "-.", etc.).

'--'
linewidth float

Line width.

0.8
fontsize float | None

Label font size. None reads from the active theme's annotation_fontsize field.

None
text_alpha float

Label opacity.

0.9
variant 'alt'

Overlay variant for label styling. "alt" uses the alternate overlay colors from the active theme.

"alt"
zorder int

Drawing order.

5

Returns:

Type Description
Circle

The circle patch.

Source code in src/pypic/plotting/annotations.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
def add_circle(
    ax: Axes,
    radius: float,
    center: tuple[float, float] = (0.0, 0.0),
    *,
    label: str | None = None,
    label_position: float = 45.0,
    color: str | tuple[float, float, float] | None = None,
    alpha: float = 0.6,
    linestyle: str = "--",
    linewidth: float = 0.8,
    fontsize: float | None = None,
    text_alpha: float = 0.9,
    variant: OverlayVariant | None = None,
    zorder: int = 5,
) -> Circle:
    """Draw a reference circle with an optional radius label.

    Parameters
    ----------
    ax : Axes
        Target axes.
    radius : float
        Circle radius in data units.
    center : tuple[float, float]
        Circle center in data coordinates.
    label : str, optional
        Text placed on the circle perimeter (e.g. ``"5 R_E"``).
        ``None`` suppresses the label.
    label_position : float
        Angle in degrees (CCW from +x) where the label sits.
    color : str, optional
        Line color. Defaults to the theme's grid color at higher opacity.
    alpha : float
        Line opacity.
    linestyle : str
        Line style (``"--"``, ``":"``, ``"-."``, etc.).
    linewidth : float
        Line width.
    fontsize : float | None
        Label font size. ``None`` reads from the active theme's
        ``annotation_fontsize`` field.
    text_alpha : float
        Label opacity.
    variant : {"alt"} or None
        Overlay variant for label styling. ``"alt"`` uses the alternate
        overlay colors from the active theme.
    zorder : int
        Drawing order.

    Returns
    -------
    Circle
        The circle patch.
    """
    ensure_matplotlib()
    import math

    from matplotlib.patches import Circle

    # Resolve circle color from theme grid color (with more visibility)
    if color is None:
        grid_rgba = _theme_val("grid_color", (0.0, 0.0, 0.0, 0.08))
        color = grid_rgba[:3]  # RGB only, alpha controlled separately

    circle = Circle(
        center,
        radius,
        facecolor="none",
        edgecolor=color,
        alpha=alpha,
        linestyle=linestyle,
        linewidth=linewidth,
        zorder=zorder,
    )
    ax.add_patch(circle)

    if label is not None:
        # Resolve overlay colors based on variant
        suffix = "_alt" if variant == "alt" else ""
        bg_key = f"overlay{suffix}_color"
        fg_key = f"overlay{suffix}_text_color"
        bg_color = _theme_val(bg_key, (0.07, 0.07, 0.07, 0.65))
        fg_color = _theme_val(fg_key, (0.88, 0.88, 0.88, 0.8))

        default_fs = _theme_val("annotation_fontsize", 7.5)
        fs = fontsize if fontsize is not None else default_fs

        angle_rad = math.radians(label_position)
        tx = center[0] + radius * math.cos(angle_rad)
        ty = center[1] + radius * math.sin(angle_rad)
        ax.text(
            tx,
            ty,
            label,
            fontsize=fs,
            alpha=text_alpha,
            color=fg_color,
            ha="center",
            va="center",
            rotation=label_position - 90,
            rotation_mode="anchor",
            zorder=zorder,
            bbox={
                "boxstyle": "round,pad=0.15",
                "facecolor": bg_color,
                "edgecolor": "none",
            },
        )

    return circle

add_planet(ax, center=(0.0, 0.0), radius=1.0, *, sun_direction='left', day_color='#f0f0f0', night_color='#1a1a2e', edgecolor='#888888', linewidth=0.5, zorder=10)

Draw a day/night planet marker at a data-space position.

Parameters:

Name Type Description Default
ax Axes

Target axes.

required
center tuple[float, float]

Planet center in data coordinates.

(0.0, 0.0)
radius float

Planet radius in data units.

1.0
sun_direction ('left', 'right', 'up', 'down')

Which side of the plot the Sun is on.

"left"
day_color str

Face colors for the sunlit and dark hemispheres.

'#f0f0f0'
night_color str

Face colors for the sunlit and dark hemispheres.

'#f0f0f0'
edgecolor str

Border color for both semicircles.

'#888888'
linewidth float

Border width.

0.5
zorder int

Drawing order (default 10, above most plot elements).

10

Returns:

Type Description
tuple[Wedge, Wedge]

The (day, night) wedge patches.

Source code in src/pypic/plotting/annotations.py
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def add_planet(
    ax: Axes,
    center: tuple[float, float] = (0.0, 0.0),
    radius: float = 1.0,
    *,
    sun_direction: SunDirection = "left",
    day_color: str = "#f0f0f0",
    night_color: str = "#1a1a2e",
    edgecolor: str = "#888888",
    linewidth: float = 0.5,
    zorder: int = 10,
) -> tuple[Wedge, Wedge]:
    """Draw a day/night planet marker at a data-space position.

    Parameters
    ----------
    ax : Axes
        Target axes.
    center : tuple[float, float]
        Planet center in data coordinates.
    radius : float
        Planet radius in data units.
    sun_direction : {"left", "right", "up", "down"}
        Which side of the plot the Sun is on.
    day_color, night_color : str
        Face colors for the sunlit and dark hemispheres.
    edgecolor : str
        Border color for both semicircles.
    linewidth : float
        Border width.
    zorder : int
        Drawing order (default 10, above most plot elements).

    Returns
    -------
    tuple[Wedge, Wedge]
        The (day, night) wedge patches.
    """
    ensure_matplotlib()
    from matplotlib.patches import Wedge

    sun_angle = _SUN_ANGLES[sun_direction]

    day = Wedge(
        center,
        radius,
        sun_angle - 90,
        sun_angle + 90,
        facecolor=day_color,
        edgecolor=edgecolor,
        linewidth=linewidth,
        zorder=zorder,
    )
    night = Wedge(
        center,
        radius,
        sun_angle + 90,
        sun_angle + 270,
        facecolor=night_color,
        edgecolor=edgecolor,
        linewidth=linewidth,
        zorder=zorder,
    )
    ax.add_patch(day)
    ax.add_patch(night)
    return day, night

plot_comparison(data_a, data_b, field, *, plane=None, units=None, coord_units=None, theme=None, cmap=None, diff_cmap=None, vmin=None, vmax=None, diff_vmin=None, diff_vmax=None, labels=('A', 'B'), alpha=1.0, symmetric=None, log_scale=False, symlog=False, linthresh=None, step=None, time=None, colorbar=True, extremes='semi', show_error=False, ax=None, save=None, figsize=None, title=None)

Three-panel comparison: dataset A, dataset B, and their difference.

Parameters:

Name Type Description Default
data_a FieldDataset

Two datasets to compare (must share compatible grids).

required
data_b FieldDataset

Two datasets to compare (must share compatible grids).

required
field str

Field name — canonical, alias, or derived.

required
plane PlaneSelection | None

Plane selection for 3D data. None auto-slices at midplane.

None
units str | None

Display units for field values.

None
coord_units str | None

Display units for coordinate axes.

None
theme PlotTheme | None

Plot theme. None uses DEFAULT.

None
cmap str | Colormap | None

Override colormap for A/B panels.

None
diff_cmap str | Colormap | None

Override colormap for difference panel. Defaults to diverging.

None
vmin float | None

Color limits for A/B panels. None for auto.

None
vmax float | None

Color limits for A/B panels. None for auto.

None
diff_vmin float | None

Color limits for the difference panel. None uses symmetric limits from symmetric_clim.

None
diff_vmax float | None

Color limits for the difference panel. None uses symmetric limits from symmetric_clim.

None
labels tuple[str, str]

Panel labels for A and B.

('A', 'B')
alpha float

Mesh transparency (0 = invisible, 1 = opaque).

1.0
symmetric bool | None

Force symmetric color limits on A/B panels. None auto-detects (symmetric for signed fields). True forces symmetric, False disables.

None
log_scale bool

Use logarithmic color mapping on A/B panels. The difference panel always uses linear scale. Ignored when symmetric is active.

False
step int | None

Timestep number for the suptitle.

None
time float | None

Simulation time for the suptitle.

None
colorbar bool or 'inset'

True for side colorbars, "inset" for overlay colorbars, False to disable.

True
extremes "semi", "transparent", "darken", or None

Colorbar out-of-range indicator style. "semi" (default) uses semi-transparent extension colors; "transparent" hides them; "darken" darkens the endpoint colors; None leaves matplotlib defaults untouched.

'semi'
show_error bool

When True, display the relative L2 error on the difference panel as a text annotation.

False
ax tuple[Axes, Axes, Axes] or None

Existing axes for A, B and the difference, in that order; the figure they belong to is then left to the caller to lay out. None creates a one-row, three-panel figure.

None
figsize tuple[float, float] | None

Figure size override. Defaults to (14, 4).

None
title str | None

Override auto-generated suptitle.

None
data_a FieldDataset

Left-hand dataset (panel A).

required
data_b FieldDataset

Right-hand dataset (panel B); must share A's grid.

required
vmin float or None

Lower color limit. None (default) autoscales.

None
vmax float or None

Upper color limit. None (default) autoscales.

None
diff_vmin float or None

Lower color limit for the difference panel. None (default) autoscales symmetrically about zero.

None
diff_vmax float or None

Upper color limit for the difference panel. None (default) autoscales symmetrically about zero.

None
symlog bool

Use a symmetric-log color scale, for signed fields spanning several decades.

False
linthresh float or None

Linear threshold for symlog. None (default) auto-detects from the data.

None
save str or Path or None

Path to write the figure to. When given, the figure is saved and closed; when None (default) it is left open for the caller to display or modify further.

None

Returns:

Type Description
tuple[Figure, dict[str, Axes]]

Figure and dict with keys "a", "b", "diff".

Source code in src/pypic/plotting/comparison.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
def plot_comparison(
    data_a: FieldDataset,
    data_b: FieldDataset,
    field: str,
    *,
    plane: PlaneSelection | None = None,
    units: str | None = None,
    coord_units: str | tuple[str, str] | None = None,
    theme: ThemeArg = None,
    cmap: str | Colormap | None = None,
    diff_cmap: str | Colormap | None = None,
    vmin: float | None = None,
    vmax: float | None = None,
    diff_vmin: float | None = None,
    diff_vmax: float | None = None,
    labels: tuple[str, str] = ("A", "B"),
    alpha: float = 1.0,
    symmetric: bool | None = None,
    log_scale: bool = False,
    symlog: bool = False,
    linthresh: float | None = None,
    step: int | None = None,
    time: float | None = None,
    colorbar: bool | Literal["inset"] = True,
    extremes: ExtremesMode = "semi",
    show_error: bool = False,
    ax: tuple[Axes, Axes, Axes] | None = None,
    save: str | None = None,
    figsize: tuple[float, float] | None = None,
    title: str | None = None,
) -> tuple[Figure, dict[str, Axes]]:
    r"""Three-panel comparison: dataset A, dataset B, and their difference.

    Parameters
    ----------
    data_a, data_b : FieldDataset
        Two datasets to compare (must share compatible grids).
    field : str
        Field name — canonical, alias, or derived.
    plane : PlaneSelection | None
        Plane selection for 3D data. ``None`` auto-slices at midplane.
    units : str | None
        Display units for field values.
    coord_units : str | None
        Display units for coordinate axes.
    theme : PlotTheme | None
        Plot theme. ``None`` uses ``DEFAULT``.
    cmap : str | Colormap | None
        Override colormap for A/B panels.
    diff_cmap : str | Colormap | None
        Override colormap for difference panel. Defaults to diverging.
    vmin, vmax : float | None
        Color limits for A/B panels. ``None`` for auto.
    diff_vmin, diff_vmax : float | None
        Color limits for the difference panel. ``None`` uses symmetric
        limits from `symmetric_clim`.
    labels : tuple[str, str]
        Panel labels for A and B.
    alpha : float
        Mesh transparency (0 = invisible, 1 = opaque).
    symmetric : bool | None
        Force symmetric color limits on A/B panels. ``None``
        auto-detects (symmetric for signed fields). ``True`` forces
        symmetric, ``False`` disables.
    log_scale : bool
        Use logarithmic color mapping on A/B panels. The difference
        panel always uses linear scale. Ignored when *symmetric* is
        active.
    step : int | None
        Timestep number for the suptitle.
    time : float | None
        Simulation time for the suptitle.
    colorbar : bool or "inset"
        ``True`` for side colorbars, ``"inset"`` for overlay colorbars,
        ``False`` to disable.
    extremes : "semi", "transparent", "darken", or None
        Colorbar out-of-range indicator style. ``"semi"`` (default)
        uses semi-transparent extension colors; ``"transparent"``
        hides them; ``"darken"`` darkens the endpoint colors;
        ``None`` leaves matplotlib defaults untouched.
    show_error : bool
        When ``True``, display the relative L2 error on the difference
        panel as a text annotation.
    ax : tuple[Axes, Axes, Axes] or None
        Existing axes for A, B and the difference, in that order; the
        figure they belong to is then left to the caller to lay out.
        ``None`` creates a one-row, three-panel figure.
    figsize : tuple[float, float] | None
        Figure size override. Defaults to ``(14, 4)``.
    title : str | None
        Override auto-generated suptitle.
    data_a : FieldDataset
        Left-hand dataset (panel A).
    data_b : FieldDataset
        Right-hand dataset (panel B); must share A's grid.
    vmin : float or None
        Lower color limit. ``None`` (default) autoscales.
    vmax : float or None
        Upper color limit. ``None`` (default) autoscales.
    diff_vmin : float or None
        Lower color limit for the difference panel. ``None``
        (default) autoscales symmetrically about zero.
    diff_vmax : float or None
        Upper color limit for the difference panel. ``None``
        (default) autoscales symmetrically about zero.
    symlog : bool
        Use a symmetric-log color scale, for signed fields
        spanning several decades.
    linthresh : float or None
        Linear threshold for ``symlog``. ``None`` (default)
        auto-detects from the data.
    save : str or Path or None
        Path to write the figure to. When given, the figure is saved
        and closed; when ``None`` (default) it is left open for the
        caller to display or modify further.

    Returns
    -------
    tuple[Figure, dict[str, Axes]]
        Figure and dict with keys ``"a"``, ``"b"``, ``"diff"``.
    """
    ensure_matplotlib()

    import matplotlib.pyplot as plt
    import numpy as np
    from matplotlib.colors import Normalize

    from pypic.plotting._colorbar import attach_colorbar
    from pypic.plotting._colormaps import (
        is_positive_definite,
        resolve_field_colormap,
        resolve_norm,
        symmetric_clim,
    )
    from pypic.plotting._labels import field_label, figure_title
    from pypic.plotting._resolve import (
        default_midplane,
        get_or_create_axes,
        maybe_save,
        plane_axis_labels,
        require_plottable_grid,
        resolve_field_values,
    )
    from pypic.plotting.styles import (
        _resolve_theme_arg,
        apply_grid,
        apply_rounding,
        use_theme,
    )

    theme = _resolve_theme_arg(theme)

    if plane is None:
        plane = default_midplane(data_a)
    if plane is not None:
        data_a = plane.apply(data_a)
        data_b = plane.apply(data_b)
    require_plottable_grid(data_a)

    values_a = resolve_field_values(data_a, field, units)
    values_b = resolve_field_values(data_b, field, units)
    if values_a.shape != values_b.shape:
        msg = (
            f"Grid shape mismatch: {values_a.shape} vs {values_b.shape}. "
            f"Regrid datasets to a common grid before comparing."
        )
        raise ValueError(msg)
    diff = values_a - values_b

    info = data_a.field_info(field)
    coords = data_a.grid.coordinate_arrays()
    xlabel, ylabel = plane_axis_labels(data_a, coord_units)

    _, colormap = resolve_field_colormap(field, values_a, theme, info=info, cmap=cmap)
    if symmetric is None:
        symmetric = not is_positive_definite(field, values_a, info)
    # One norm over both inputs, so A and B share a color scale.
    norm = resolve_norm(
        np.concatenate([values_a.ravel(), values_b.ravel()]),
        symmetric=symmetric,
        log_scale=log_scale,
        symlog=symlog,
        vmin=vmin,
        vmax=vmax,
        linthresh=linthresh,
    )

    if diff_vmin is None or diff_vmax is None:
        auto_dvmin, auto_dvmax = symmetric_clim(diff)
        diff_vmin = diff_vmin if diff_vmin is not None else auto_dvmin
        diff_vmax = diff_vmax if diff_vmax is not None else auto_dvmax
    diff_norm = Normalize(vmin=diff_vmin, vmax=diff_vmax)

    unit_str = units or ""
    cb_label = field_label(info, unit_str=unit_str)

    with use_theme(theme):
        if ax is None:
            fig, axes_dict = plt.subplot_mosaic(
                [["a", "b", "diff"]],
                figsize=figsize or (14, 4),
            )
        else:
            fig, _ = get_or_create_axes(theme, ax[0], None)
            axes_dict = dict(zip(("a", "b", "diff"), ax, strict=True))

        diff_title = f"{labels[0]} \u2212 {labels[1]}"
        panels = [
            ("a", values_a, labels[0], colormap, norm),
            ("b", values_b, labels[1], colormap, norm),
            (
                "diff",
                diff,
                diff_title,
                diff_cmap if diff_cmap is not None else theme.diverging_cmap,
                diff_norm,
            ),
        ]

        for key, values, panel_title, panel_cmap, panel_norm in panels:
            panel_ax = axes_dict[key]
            mesh = panel_ax.pcolormesh(
                coords[0],
                coords[1],
                values.T,
                shading="auto",
                cmap=panel_cmap,
                alpha=alpha,
                norm=panel_norm,
            )
            label = f"\u0394 {cb_label}" if key == "diff" else cb_label
            attach_colorbar(fig, panel_ax, mesh, label, colorbar, extremes=extremes)
            panel_ax.set_xlabel(xlabel)
            panel_ax.set_ylabel(ylabel)
            panel_ax.set_aspect("equal")
            apply_grid(panel_ax, theme)
            panel_ax.set_title(panel_title)

        if show_error:
            import matplotlib as mpl

            from pypic.diagnostics import l2_relative_error

            l2 = l2_relative_error(values_a, values_b)
            diff_ax = axes_dict["diff"]
            bg = mpl.rcParams.get("axes.facecolor", "white")
            tc = mpl.rcParams.get("xtick.color", "0.4")
            diff_ax.text(
                0.02,
                0.98,
                f"$L_2$ = {l2:.2e}",
                transform=diff_ax.transAxes,
                fontsize=theme.annotation_fontsize,
                va="top",
                ha="left",
                color=tc,
                bbox={"facecolor": bg, "alpha": 0.7, "edgecolor": "none"},
            )

        if title is not None:
            fig.suptitle(title)
        else:
            fig.suptitle(figure_title(info, step=step, time=time))

        if ax is None:
            fig.tight_layout()
        for panel_ax in axes_dict.values():
            apply_rounding(panel_ax)

    maybe_save(fig, save)
    return fig, axes_dict

plot_cross_section(data, field, *, cut_axis, cut_index=None, plane=None, units=None, coord_units=None, theme=None, cmap=None, vmin=None, vmax=None, alpha=1.0, symmetric=None, log_scale=False, title=None, step=None, time=None, colorbar=True, extremes='semi', cut_color=None, cut_linestyle='--', ax=None, save=None, figsize=None)

Two-panel figure: 2D field slice with a 1D cut profile below.

The top panel shows the scalar field via plot_field_slice with a dashed line marking the cut location. The bottom panel shows the 1D profile along that cut.

Parameters:

Name Type Description Default
data FieldDataset

Input dataset (2D or 3D).

required
field str

Scalar field name.

required
cut_axis str

Axis along which to extract the 1D profile (e.g. "x"). The cut is taken at cut_index along the perpendicular axis.

required
cut_index int | None

Index on the perpendicular axis where the cut is made. None uses the midplane.

None
plane PlaneSelection | None

Plane selection for 3D data. None auto-slices at midplane.

None
units str | None

Display units for field values.

None
coord_units str, tuple[str, str], or None

Display units for coordinate axes.

None
theme PlotTheme | None

Plot theme. None uses DEFAULT.

None
cmap str | Colormap | None

Override colormap.

None
vmin float | None

Color limits.

None
vmax float | None

Color limits.

None
log_scale bool

Logarithmic color mapping.

False
title str | None

Override auto-generated title.

None
step int | None

Timestep number.

None
time float | None

Simulation time.

None
colorbar bool or 'inset'

Colorbar mode.

True
extremes "semi", "transparent", "darken", or None

Colorbar out-of-range indicator style. "semi" (default) uses semi-transparent extension colors; "transparent" hides them; "darken" darkens the endpoint colors; None leaves matplotlib defaults untouched.

'semi'
cut_color str or None

Color for the cut line marker on the 2D panel. None uses the theme's accent color.

None
cut_linestyle str

Line style for the cut marker.

'--'
ax tuple[Axes, Axes] or None

Existing (ax_2d, ax_1d) pair to draw into; the figure is then left to the caller to lay out. None creates a two-row figure whose panels share the cut axis.

None
figsize tuple[float, float] | None

Figure size override. None uses (7, 8).

None
vmin float or None

Lower color limit. None (default) autoscales.

None
vmax float or None

Upper color limit. None (default) autoscales.

None
alpha float

Opacity of the field image, in [0, 1].

1.0
symmetric bool or None

Force symmetric color limits about zero. None (default) decides from whether the field is positive-definite.

None
save str or Path or None

Path to write the figure to. When given, the figure is saved and closed; when None (default) it is left open for the caller to display or modify further.

None

Returns:

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

Figure and (ax_2d, ax_1d) axes pair.

Source code in src/pypic/plotting/cross_section.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
def plot_cross_section(
    data: FieldDataset,
    field: str,
    *,
    cut_axis: str,
    cut_index: int | None = None,
    plane: PlaneSelection | None = None,
    units: str | None = None,
    coord_units: str | tuple[str, str] | None = None,
    theme: ThemeArg = None,
    cmap: str | Colormap | None = None,
    vmin: float | None = None,
    vmax: float | None = None,
    alpha: float = 1.0,
    symmetric: bool | None = None,
    log_scale: bool = False,
    title: str | None = None,
    step: int | None = None,
    time: float | None = None,
    colorbar: bool | Literal["inset"] = True,
    extremes: ExtremesMode = "semi",
    cut_color: str | None = None,
    cut_linestyle: str = "--",
    ax: tuple[Axes, Axes] | None = None,
    save: str | None = None,
    figsize: tuple[float, float] | None = None,
) -> tuple[Figure, tuple[Axes, Axes]]:
    r"""Two-panel figure: 2D field slice with a 1D cut profile below.

    The top panel shows the scalar field via `plot_field_slice`
    with a dashed line marking the cut location. The bottom panel
    shows the 1D profile along that cut.

    Parameters
    ----------
    data : FieldDataset
        Input dataset (2D or 3D).
    field : str
        Scalar field name.
    cut_axis : str
        Axis along which to extract the 1D profile (e.g. ``"x"``).
        The cut is taken at *cut_index* along the perpendicular axis.
    cut_index : int | None
        Index on the perpendicular axis where the cut is made.
        ``None`` uses the midplane.
    plane : PlaneSelection | None
        Plane selection for 3D data. ``None`` auto-slices at midplane.
    units : str | None
        Display units for field values.
    coord_units : str, tuple[str, str], or None
        Display units for coordinate axes.
    theme : PlotTheme | None
        Plot theme. ``None`` uses ``DEFAULT``.
    cmap : str | Colormap | None
        Override colormap.
    vmin, vmax : float | None
        Color limits.
    log_scale : bool
        Logarithmic color mapping.
    title : str | None
        Override auto-generated title.
    step : int | None
        Timestep number.
    time : float | None
        Simulation time.
    colorbar : bool or "inset"
        Colorbar mode.
    extremes : "semi", "transparent", "darken", or None
        Colorbar out-of-range indicator style. ``"semi"`` (default)
        uses semi-transparent extension colors; ``"transparent"``
        hides them; ``"darken"`` darkens the endpoint colors;
        ``None`` leaves matplotlib defaults untouched.
    cut_color : str or None
        Color for the cut line marker on the 2D panel. ``None`` uses
        the theme's accent color.
    cut_linestyle : str
        Line style for the cut marker.
    ax : tuple[Axes, Axes] or None
        Existing ``(ax_2d, ax_1d)`` pair to draw into; the figure is then
        left to the caller to lay out. ``None`` creates a two-row figure
        whose panels share the cut axis.
    figsize : tuple[float, float] | None
        Figure size override. ``None`` uses ``(7, 8)``.
    vmin : float or None
        Lower color limit. ``None`` (default) autoscales.
    vmax : float or None
        Upper color limit. ``None`` (default) autoscales.
    alpha : float
        Opacity of the field image, in ``[0, 1]``.
    symmetric : bool or None
        Force symmetric color limits about zero. ``None``
        (default) decides from whether the field is
        positive-definite.
    save : str or Path or None
        Path to write the figure to. When given, the figure is saved
        and closed; when ``None`` (default) it is left open for the
        caller to display or modify further.

    Returns
    -------
    tuple[Figure, tuple[Axes, Axes]]
        Figure and ``(ax_2d, ax_1d)`` axes pair.
    """
    ensure_matplotlib()

    import matplotlib.pyplot as plt
    import numpy as np

    from pypic.plotting._labels import axis_label, field_label
    from pypic.plotting._resolve import (
        get_or_create_axes,
        maybe_save,
        prepare_data,
        resolve_coord_units,
        resolve_field_values,
    )
    from pypic.plotting.slices import plot_field_slice
    from pypic.plotting.styles import (
        _resolve_theme_arg,
        apply_grid,
        apply_rounding,
        use_theme,
    )

    theme = _resolve_theme_arg(theme)
    if cut_color is None:
        cut_color = theme.accent_color
    data = prepare_data(data, plane)

    surviving = data.grid.surviving_axis_names
    if cut_axis not in surviving:
        msg = f"cut_axis={cut_axis!r} not in dataset axes {list(surviving)}"
        raise ValueError(msg)

    # Determine which axis is the cut direction and which is perpendicular
    cut_dim_idx = surviving.index(cut_axis)
    perp_dim_idx = 1 - cut_dim_idx  # only works for 2D

    coords = data.grid.coordinate_arrays()
    if cut_index is None:
        cut_index = data.grid.dimensions[perp_dim_idx] // 2

    dim_size = data.grid.dimensions[perp_dim_idx]
    if cut_index < 0 or cut_index >= dim_size:
        perp_name = surviving[perp_dim_idx]
        msg = (
            f"cut_index {cut_index} out of bounds for axis "
            f"{perp_name!r} with dimension {dim_size}"
        )
        raise ValueError(msg)

    # Cut position in physical coordinates
    cut_coord = float(coords[perp_dim_idx][cut_index])

    owns_figure = ax is None
    with use_theme(theme):
        if ax is None:
            fig, (ax_2d, ax_1d) = plt.subplots(
                2,
                1,
                figsize=figsize or (7, 8),
                height_ratios=[2, 1],
                sharex=(cut_dim_idx == 0),
            )
        else:
            ax_2d, ax_1d = ax
            fig, _ = get_or_create_axes(theme, ax_2d, None)

        # Top panel: 2D slice
        plot_field_slice(
            data,
            field,
            units=units,
            coord_units=coord_units,
            theme=theme,
            cmap=cmap,
            vmin=vmin,
            vmax=vmax,
            alpha=alpha,
            symmetric=symmetric,
            log_scale=log_scale,
            title=title,
            step=step,
            time=time,
            ax=ax_2d,
            colorbar=colorbar,
            extremes=extremes,
        )

        # Suppress redundant xlabel on top panel when sharing x-axis
        if owns_figure and cut_dim_idx == 0:
            ax_2d.set_xlabel("")

        # Mark the cut line on the 2D panel
        if cut_dim_idx == 0:
            ax_2d.axhline(cut_coord, color=cut_color, linestyle=cut_linestyle, lw=1)
        else:
            ax_2d.axvline(cut_coord, color=cut_color, linestyle=cut_linestyle, lw=1)

        # Bottom panel: 1D profile along the cut
        import warnings

        values = resolve_field_values(data, field, units)
        profile = values[:, cut_index] if cut_dim_idx == 0 else values[cut_index, :]
        cut_coords = coords[cut_dim_idx]

        if np.all(np.isnan(profile)):
            warnings.warn(
                f"Profile along {cut_axis!r} at index {cut_index} is entirely NaN",
                stacklevel=2,
            )

        ax_1d.plot(cut_coords, profile, color=cut_color)
        info = data.field_info(field)
        cu_x, cu_y = resolve_coord_units(coord_units)
        cut_unit = cu_x if cut_dim_idx == 0 else cu_y
        ax_1d.set_xlabel(axis_label(cut_axis, unit_str=cut_unit))
        ax_1d.set_ylabel(field_label(info, unit_str=units or ""))
        apply_grid(ax_1d, theme)

        if np.any(np.isfinite(profile)):
            ax_1d.set_xlim(cut_coords[0], cut_coords[-1])

        if owns_figure:
            fig.tight_layout()
        apply_rounding(ax_2d)

    maybe_save(fig, save)
    return fig, (ax_2d, ax_1d)

plot_kymograph(values, coords, times, *, label='', units=None, xlabel=None, ylabel=None, theme=None, cmap=None, vmin=None, vmax=None, symmetric=None, log_scale=False, title=None, ax=None, colorbar=True, extremes='semi', save=None, figsize=None)

Plot a time-distance (kymograph) diagram.

Displays a 1D spatial profile at each timestep as a 2D color map, with the spatial coordinate on the x-axis and time on the y-axis. Common in reconnection, wave propagation, and shock studies.

Parameters:

Name Type Description Default
values FloatArray

2D array of shape (n_times, n_x) — one row per timestep.

required
coords FloatArray

1D spatial coordinate array of length n_x.

required
times FloatArray

1D time array of length n_times.

required
label str

Colorbar label (e.g. "$B_z$").

''
units str | None

Units values are already in, appended to the colorbar label as [units]. The values are drawn as given, not converted.

None
xlabel str | None

X-axis label. None defaults to empty.

None
ylabel str | None

Y-axis label. None defaults to "time".

None
theme PlotTheme | None

Plot theme. None uses DEFAULT.

None
cmap str | None

Colormap override. None auto-selects (diverging if data contains negative values, sequential otherwise).

None
vmin float | None

Color limits. None for auto.

None
vmax float | None

Color limits. None for auto.

None
symmetric bool | None

Force symmetric color limits around zero. None auto-detects.

None
log_scale bool

Use logarithmic color mapping. Ignored when symmetric.

False
title str | None

Axes title.

None
ax Axes | None

Existing axes. None creates a new figure.

None
colorbar bool or 'inset'

Colorbar mode.

True
extremes "semi", "transparent", "darken", or None

How to style values outside [vmin, vmax].

'semi'
save str | None

Save figure to this path.

None
figsize tuple[float, float] | None

Figure size override.

None
vmin float or None

Lower color limit. None (default) autoscales.

None
vmax float or None

Upper color limit. None (default) autoscales.

None

Returns:

Type Description
tuple[Figure, Axes]

Raises:

Type Description
ValueError

If values is not 2D or shapes are inconsistent.

Source code in src/pypic/plotting/kymograph.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
def plot_kymograph(
    values: FloatArray,
    coords: FloatArray,
    times: FloatArray,
    *,
    label: str = "",
    units: str | None = None,
    xlabel: str | None = None,
    ylabel: str | None = None,
    theme: ThemeArg = None,
    cmap: str | None = None,
    vmin: float | None = None,
    vmax: float | None = None,
    symmetric: bool | None = None,
    log_scale: bool = False,
    title: str | None = None,
    ax: Axes | None = None,
    colorbar: bool | Literal["inset"] = True,
    extremes: ExtremesMode = "semi",
    save: str | None = None,
    figsize: tuple[float, float] | None = None,
) -> tuple[Figure, Axes]:
    r"""Plot a time-distance (kymograph) diagram.

    Displays a 1D spatial profile at each timestep as a 2D color map,
    with the spatial coordinate on the x-axis and time on the y-axis.
    Common in reconnection, wave propagation, and shock studies.

    Parameters
    ----------
    values : FloatArray
        2D array of shape ``(n_times, n_x)`` — one row per timestep.
    coords : FloatArray
        1D spatial coordinate array of length ``n_x``.
    times : FloatArray
        1D time array of length ``n_times``.
    label : str
        Colorbar label (e.g. ``"$B_z$"``).
    units : str | None
        Units *values* are already in, appended to the colorbar label as
        ``[units]``. The values are drawn as given, not converted.
    xlabel : str | None
        X-axis label. ``None`` defaults to empty.
    ylabel : str | None
        Y-axis label. ``None`` defaults to ``"time"``.
    theme : PlotTheme | None
        Plot theme. ``None`` uses ``DEFAULT``.
    cmap : str | None
        Colormap override. ``None`` auto-selects (diverging if data
        contains negative values, sequential otherwise).
    vmin, vmax : float | None
        Color limits. ``None`` for auto.
    symmetric : bool | None
        Force symmetric color limits around zero. ``None`` auto-detects.
    log_scale : bool
        Use logarithmic color mapping. Ignored when *symmetric*.
    title : str | None
        Axes title.
    ax : Axes | None
        Existing axes. ``None`` creates a new figure.
    colorbar : bool or "inset"
        Colorbar mode.
    extremes : "semi", "transparent", "darken", or None
        How to style values outside ``[vmin, vmax]``.
    save : str | None
        Save figure to this path.
    figsize : tuple[float, float] | None
        Figure size override.
    vmin : float or None
        Lower color limit. ``None`` (default) autoscales.
    vmax : float or None
        Upper color limit. ``None`` (default) autoscales.

    Returns
    -------
    tuple[Figure, Axes]

    Raises
    ------
    ValueError
        If *values* is not 2D or shapes are inconsistent.
    """
    ensure_matplotlib()

    from pypic.plotting._colorbar import attach_colorbar
    from pypic.plotting._colormaps import resolve_norm
    from pypic.plotting._resolve import finish_axes, get_or_create_axes, maybe_save
    from pypic.plotting.styles import _resolve_theme_arg, use_theme

    if values.ndim != 2:
        msg = f"values must be 2D (n_times, n_x), got {values.ndim}D"
        raise ValueError(msg)
    n_times, n_x = values.shape
    if coords.shape != (n_x,):
        msg = f"coords length {coords.shape[0]} != values columns {n_x}"
        raise ValueError(msg)
    if times.shape != (n_times,):
        msg = f"times length {times.shape[0]} != values rows {n_times}"
        raise ValueError(msg)

    owns_figure = ax is None
    theme = _resolve_theme_arg(theme)
    if symmetric is None:
        symmetric = bool(np.any(values < 0))
    if cmap is None:
        cmap = theme.diverging_cmap if symmetric else theme.sequential_cmap
    norm = resolve_norm(
        values, symmetric=symmetric, log_scale=log_scale, vmin=vmin, vmax=vmax
    )

    with use_theme(theme):
        fig, ax = get_or_create_axes(theme, ax, figsize)
        mesh = ax.pcolormesh(
            coords, times, values, shading="auto", cmap=cmap, norm=norm
        )

        cb_label = f"{label} [{units}]" if units else label
        attach_colorbar(fig, ax, mesh, cb_label, colorbar, extremes=extremes)
        finish_axes(
            fig,
            ax,
            theme,
            owns_figure=owns_figure,
            xlabel=xlabel or "",
            ylabel=ylabel if ylabel is not None else "time",
            title=title,
        )

    maybe_save(fig, save)
    return fig, ax

plot_line(data, field, *, axis=None, index=None, units=None, coord_units=None, theme=None, label=None, title=None, step=None, time=None, ax=None, save=None, figsize=None, **kwargs)

Plot a 1D spatial profile of a field along one axis.

For 2D/3D data, other axes are sliced at the given index values (default: midplane). For 1D data, the single axis is used directly.

Parameters:

Name Type Description Default
data FieldDataset

Input dataset (1D, 2D, or 3D).

required
field str

Field name — canonical, alias, or derived (e.g. "|B|").

required
axis str | None

Axis to plot along (e.g. "x"). Required for 2D/3D data. None auto-selects the only axis for 1D data.

None
index dict[str, int] | None

Fix non-plot axes at these integer indices. None uses midplane for each sliced axis.

None
units str | None

Display units for field values (e.g. "nT").

None
coord_units str | None

Display units for the coordinate axis.

None
theme PlotTheme | None

Plot theme. None uses DEFAULT.

None
label str | None

Legend label. None omits legend entry.

None
title str | None

Override auto-generated title.

None
step int | None

Timestep number for the title.

None
time float | None

Simulation time for the title.

None
ax Axes | None

Existing axes to draw on. None creates a new figure.

None
figsize tuple[float, float] | None

Figure size override.

None
**kwargs Any

Passed to ax.plot() (color, linestyle, linewidth, etc.).

{}
save str or Path or None

Path to write the figure to. When given, the figure is saved and closed; when None (default) it is left open for the caller to display or modify further.

None

Returns:

Type Description
tuple[Figure, Axes]
Source code in src/pypic/plotting/lines.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
def plot_line(
    data: FieldDataset,
    field: str,
    *,
    axis: str | None = None,
    index: dict[str, int] | None = None,
    units: str | None = None,
    coord_units: str | None = None,
    theme: ThemeArg = None,
    label: str | None = None,
    title: str | None = None,
    step: int | None = None,
    time: float | None = None,
    ax: Axes | None = None,
    save: str | None = None,
    figsize: tuple[float, float] | None = None,
    **kwargs: Any,  # noqa: ANN401 — matplotlib passthrough
) -> tuple[Figure, Axes]:
    r"""Plot a 1D spatial profile of a field along one axis.

    For 2D/3D data, other axes are sliced at the given *index* values
    (default: midplane). For 1D data, the single axis is used directly.

    Parameters
    ----------
    data : FieldDataset
        Input dataset (1D, 2D, or 3D).
    field : str
        Field name — canonical, alias, or derived (e.g. ``"|B|"``).
    axis : str | None
        Axis to plot along (e.g. ``"x"``). Required for 2D/3D data.
        ``None`` auto-selects the only axis for 1D data.
    index : dict[str, int] | None
        Fix non-plot axes at these integer indices. ``None`` uses
        midplane for each sliced axis.
    units : str | None
        Display units for field values (e.g. ``"nT"``).
    coord_units : str | None
        Display units for the coordinate axis.
    theme : PlotTheme | None
        Plot theme. ``None`` uses ``DEFAULT``.
    label : str | None
        Legend label. ``None`` omits legend entry.
    title : str | None
        Override auto-generated title.
    step : int | None
        Timestep number for the title.
    time : float | None
        Simulation time for the title.
    ax : Axes | None
        Existing axes to draw on. ``None`` creates a new figure.
    figsize : tuple[float, float] | None
        Figure size override.
    **kwargs
        Passed to ``ax.plot()`` (color, linestyle, linewidth, etc.).
    save : str or Path or None
        Path to write the figure to. When given, the figure is saved
        and closed; when ``None`` (default) it is left open for the
        caller to display or modify further.

    Returns
    -------
    tuple[Figure, Axes]
    """
    ensure_matplotlib()

    from pypic.plotting._labels import axis_label, field_label, figure_title
    from pypic.plotting._resolve import (
        finish_axes,
        get_or_create_axes,
        maybe_save,
        resolve_field_values,
    )
    from pypic.plotting.styles import _resolve_theme_arg, style_legend, use_theme

    owns_figure = ax is None
    theme = _resolve_theme_arg(theme)

    ndim = len(data.grid.dimensions)
    axis_names = list(data.grid.surviving_axis_names)

    if ndim == 1:
        plot_axis = axis_names[0]
    elif axis is not None:
        if axis not in axis_names:
            msg = f"axis={axis!r} not in dataset axes {axis_names}"
            raise ValueError(msg)
        plot_axis = axis
    else:
        msg = f"axis is required for {ndim}D data (axes: {axis_names})"
        raise ValueError(msg)

    slice_axes = [a for a in axis_names if a != plot_axis]
    if slice_axes:
        indexers: dict[str, int] = {}
        for a in slice_axes:
            if index is not None and a in index:
                indexers[a] = index[a]
            else:
                dim_idx = axis_names.index(a)
                indexers[a] = data.grid.dimensions[dim_idx] // 2
        data = data.isel(indexers)

    import warnings

    import numpy as np

    values = resolve_field_values(data, field, units)

    if values.shape[0] < 2:
        warnings.warn(
            f"Field {field!r} has fewer than 2 points; line will not be visible",
            stacklevel=2,
        )

    if np.all(np.isnan(values)):
        warnings.warn(f"Field {field!r} is entirely NaN", stacklevel=2)

    info = data.field_info(field)
    coord = data.grid.coordinate_arrays()[0]
    if title is None and (step is not None or time is not None):
        title = figure_title(info, step=step, time=time)

    with use_theme(theme):
        fig, ax = get_or_create_axes(theme, ax, figsize)

        ax.plot(coord, values, label=label, **kwargs)
        if label is not None:
            ax.legend()
            style_legend(ax)
        finish_axes(
            fig,
            ax,
            theme,
            owns_figure=owns_figure,
            xlabel=axis_label(plot_axis, unit_str=coord_units or ""),
            ylabel=field_label(info, unit_str=units or ""),
            title=title,
        )

    maybe_save(fig, save)
    return fig, ax

plot_line_comparison(datasets, field, *, axis=None, index=None, labels=None, units=None, coord_units=None, theme=None, title=None, ax=None, save=None, figsize=None, **kwargs)

Compare 1D profiles of the same field from multiple datasets.

Overlays one line per dataset with automatic color cycling and legend. Useful for comparing simulation runs with different parameters or resolutions.

Parameters:

Name Type Description Default
datasets list[FieldDataset]

Datasets to compare (must share compatible axes).

required
field str

Field name to plot from each dataset.

required
axis str | None

Axis to plot along (required for 2D/3D data).

None
index dict[str, int] | None

Fix non-plot axes at these indices.

None
labels list[str] | None

Legend labels (one per dataset). None uses "run 0", "run 1", etc.

None
units str | None

Display units for field values.

None
coord_units str | None

Display units for the coordinate axis.

None
theme PlotTheme | None

Plot theme. None uses default.

None
title str | None

Axes title.

None
ax Axes | None

Existing axes. None creates a new figure.

None
save str | None

Save figure to this path.

None
figsize tuple[float, float] | None

Figure size override.

None
**kwargs Any

Passed to ax.plot() (linestyle, linewidth, etc.).

{}

Returns:

Type Description
tuple[Figure, Axes]
Source code in src/pypic/plotting/lines.py
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
def plot_line_comparison(
    datasets: list[FieldDataset],
    field: str,
    *,
    axis: str | None = None,
    index: dict[str, int] | None = None,
    labels: list[str] | None = None,
    units: str | None = None,
    coord_units: str | None = None,
    theme: ThemeArg = None,
    title: str | None = None,
    ax: Axes | None = None,
    save: str | None = None,
    figsize: tuple[float, float] | None = None,
    **kwargs: Any,  # noqa: ANN401 — matplotlib passthrough
) -> tuple[Figure, Axes]:
    r"""Compare 1D profiles of the same field from multiple datasets.

    Overlays one line per dataset with automatic color cycling and
    legend. Useful for comparing simulation runs with different
    parameters or resolutions.

    Parameters
    ----------
    datasets : list[FieldDataset]
        Datasets to compare (must share compatible axes).
    field : str
        Field name to plot from each dataset.
    axis : str | None
        Axis to plot along (required for 2D/3D data).
    index : dict[str, int] | None
        Fix non-plot axes at these indices.
    labels : list[str] | None
        Legend labels (one per dataset). ``None`` uses ``"run 0"``,
        ``"run 1"``, etc.
    units : str | None
        Display units for field values.
    coord_units : str | None
        Display units for the coordinate axis.
    theme : PlotTheme | None
        Plot theme. ``None`` uses default.
    title : str | None
        Axes title.
    ax : Axes | None
        Existing axes. ``None`` creates a new figure.
    save : str | None
        Save figure to this path.
    figsize : tuple[float, float] | None
        Figure size override.
    **kwargs
        Passed to ``ax.plot()`` (linestyle, linewidth, etc.).

    Returns
    -------
    tuple[Figure, Axes]
    """
    if labels is None:
        labels = [f"run {i}" for i in range(len(datasets))]

    fig = None
    for ds, lbl in zip(datasets, labels, strict=True):
        fig, ax = plot_line(
            ds,
            field,
            axis=axis,
            index=index,
            units=units,
            coord_units=coord_units,
            theme=theme,
            label=lbl,
            title=title,
            ax=ax,
            figsize=figsize,
            **kwargs,
        )

    assert fig is not None
    from pypic.plotting._resolve import maybe_save

    maybe_save(fig, save)
    return fig, ax  # type: ignore[return-value]

plot_lines(data, fields, *, axis=None, index=None, labels=None, units=None, coord_units=None, theme=None, title=None, step=None, time=None, ax=None, save=None, figsize=None, **kwargs)

Plot multiple fields overlaid on the same axes.

Convenience wrapper around plot_line that handles axes reuse, automatic color cycling, and legend display.

Parameters:

Name Type Description Default
data FieldDataset

Input dataset.

required
fields list[str]

Field names to plot (one line per field).

required
axis str | None

Axis to plot along (required for 2D/3D data).

None
index dict[str, int] | None

Fix non-plot axes at these indices.

None
labels list[str] | None

Legend labels. None uses field names.

None
units str | None

Display units for field values.

None
coord_units str | None

Display units for coordinate axis.

None
theme PlotTheme | None

Plot theme. None uses default.

None
title str | None

Axes title.

None
step int | None

Timestep number for the title.

None
time float | None

Simulation time for the title.

None
ax Axes | None

Existing axes. None creates a new figure.

None
save str | None

Save figure to this path (and close).

None
figsize tuple[float, float] | None

Figure size override.

None
**kwargs Any

Passed to ax.plot() (color, linestyle, etc.).

{}

Returns:

Type Description
tuple[Figure, Axes]
Source code in src/pypic/plotting/lines.py
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
def plot_lines(
    data: FieldDataset,
    fields: list[str],
    *,
    axis: str | None = None,
    index: dict[str, int] | None = None,
    labels: list[str] | None = None,
    units: str | None = None,
    coord_units: str | None = None,
    theme: ThemeArg = None,
    title: str | None = None,
    step: int | None = None,
    time: float | None = None,
    ax: Axes | None = None,
    save: str | None = None,
    figsize: tuple[float, float] | None = None,
    **kwargs: Any,  # noqa: ANN401 — matplotlib passthrough
) -> tuple[Figure, Axes]:
    r"""Plot multiple fields overlaid on the same axes.

    Convenience wrapper around `plot_line` that handles axes
    reuse, automatic color cycling, and legend display.

    Parameters
    ----------
    data : FieldDataset
        Input dataset.
    fields : list[str]
        Field names to plot (one line per field).
    axis : str | None
        Axis to plot along (required for 2D/3D data).
    index : dict[str, int] | None
        Fix non-plot axes at these indices.
    labels : list[str] | None
        Legend labels. ``None`` uses field names.
    units : str | None
        Display units for field values.
    coord_units : str | None
        Display units for coordinate axis.
    theme : PlotTheme | None
        Plot theme. ``None`` uses default.
    title : str | None
        Axes title.
    step : int | None
        Timestep number for the title.
    time : float | None
        Simulation time for the title.
    ax : Axes | None
        Existing axes. ``None`` creates a new figure.
    save : str | None
        Save figure to this path (and close).
    figsize : tuple[float, float] | None
        Figure size override.
    **kwargs
        Passed to ``ax.plot()`` (color, linestyle, etc.).

    Returns
    -------
    tuple[Figure, Axes]
    """
    if labels is None:
        labels = fields

    fig = None
    for field_name, lbl in zip(fields, labels, strict=True):
        fig, ax = plot_line(
            data,
            field_name,
            axis=axis,
            index=index,
            units=units,
            coord_units=coord_units,
            theme=theme,
            label=lbl,
            title=title,
            step=step,
            time=time,
            ax=ax,
            figsize=figsize,
            **kwargs,
        )

    assert fig is not None
    from pypic.plotting._resolve import maybe_save

    maybe_save(fig, save)
    return fig, ax  # type: ignore[return-value]

plot_time_series(data, columns, *, x_column=None, theme=None, labels=None, title=None, xlabel=None, ylabel=None, ax=None, save=None, figsize=None, legend=True, **kwargs)

Plot one or more columns from tabular data as time series.

Parameters:

Name Type Description Default
data TabularData

Tabular data source (e.g. conserved quantities).

required
columns str | list[str]

Column name(s) to plot.

required
x_column str | None

Column for the x-axis. None uses data.index.

None
theme PlotTheme | None

Plot theme. None uses DEFAULT.

None
labels list[str] | None

Legend labels. None uses column names.

None
title str | None

Axes title.

None
xlabel str | None

X-axis label. None uses x_column name or "Cycle".

None
ylabel str | None

Y-axis label.

None
ax Axes | None

Existing axes to draw on. None creates a new figure.

None
figsize tuple[float, float] | None

Figure size override.

None
legend bool

Whether to show a legend (default True).

True
**kwargs Any

Passed to ax.plot() (color, linestyle, linewidth, etc.).

{}
save str or Path or None

Path to write the figure to. When given, the figure is saved and closed; when None (default) it is left open for the caller to display or modify further.

None

Returns:

Type Description
tuple[Figure, Axes]
Source code in src/pypic/plotting/lines.py
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
def plot_time_series(
    data: TabularData,
    columns: str | list[str],
    *,
    x_column: str | None = None,
    theme: ThemeArg = None,
    labels: list[str] | None = None,
    title: str | None = None,
    xlabel: str | None = None,
    ylabel: str | None = None,
    ax: Axes | None = None,
    save: str | None = None,
    figsize: tuple[float, float] | None = None,
    legend: bool = True,
    **kwargs: Any,  # noqa: ANN401 — matplotlib passthrough
) -> tuple[Figure, Axes]:
    r"""Plot one or more columns from tabular data as time series.

    Parameters
    ----------
    data : TabularData
        Tabular data source (e.g. conserved quantities).
    columns : str | list[str]
        Column name(s) to plot.
    x_column : str | None
        Column for the x-axis. ``None`` uses ``data.index``.
    theme : PlotTheme | None
        Plot theme. ``None`` uses ``DEFAULT``.
    labels : list[str] | None
        Legend labels. ``None`` uses column names.
    title : str | None
        Axes title.
    xlabel : str | None
        X-axis label. ``None`` uses *x_column* name or ``"Cycle"``.
    ylabel : str | None
        Y-axis label.
    ax : Axes | None
        Existing axes to draw on. ``None`` creates a new figure.
    figsize : tuple[float, float] | None
        Figure size override.
    legend : bool
        Whether to show a legend (default ``True``).
    **kwargs
        Passed to ``ax.plot()`` (color, linestyle, linewidth, etc.).
    save : str or Path or None
        Path to write the figure to. When given, the figure is saved
        and closed; when ``None`` (default) it is left open for the
        caller to display or modify further.

    Returns
    -------
    tuple[Figure, Axes]
    """
    ensure_matplotlib()

    from pypic.plotting._resolve import finish_axes, get_or_create_axes, maybe_save
    from pypic.plotting.styles import _resolve_theme_arg, style_legend, use_theme

    owns_figure = ax is None
    theme = _resolve_theme_arg(theme)

    if isinstance(columns, str):
        columns = [columns]

    x = data[x_column] if x_column is not None else data.index

    if labels is None:
        labels = columns
    if xlabel is None:
        xlabel = x_column or data.index_column or "Cycle"

    with use_theme(theme):
        fig, ax = get_or_create_axes(theme, ax, figsize)

        for col, lbl in zip(columns, labels, strict=True):
            ax.plot(x, data[col], label=lbl, **kwargs)
        if legend and (len(columns) > 1 or labels != columns):
            ax.legend()
            style_legend(ax)
        finish_axes(
            fig,
            ax,
            theme,
            owns_figure=owns_figure,
            xlabel=xlabel,
            ylabel=ylabel,
            title=title,
        )

    maybe_save(fig, save)
    return fig, ax

plot_field_grid(data, fields, *, ncols=3, plane=None, units=None, coord_units=None, theme=None, cmap=None, vmin=None, vmax=None, colorbar=True, extremes='semi', alpha=1.0, symmetric=None, log_scale=False, step=None, time=None, save=None, figsize=None, panel_labels=True, suptitle=None, ax=None)

Plot multiple fields in an auto-arranged grid with panel labels.

Creates a figure with ceil(len(fields) / ncols) rows and ncols columns. Each panel shows one field via plot_field_slice, with optional (a), (b), (c) labels.

Parameters:

Name Type Description Default
data FieldDataset

Input dataset (2D or 3D).

required
fields list[str]

Field names to plot, one per panel.

required
ncols int

Number of columns in the grid.

3
plane PlaneSelection | None

Plane selection for 3D data (applied to all panels).

None
units dict[str, str] | None

Per-field display units, keyed by field name. Fields not in the dict use code units.

None
coord_units str, tuple[str, str], or None

Coordinate axis units (shared across panels).

None
theme PlotTheme | None

Plot theme. None uses DEFAULT.

None
cmap str, Colormap, dict, or None

Colormap override. A single string or Colormap applies to all panels; a dict maps field names to per-field colormaps.

None
vmin float | None

Shared color limits across all panels. None for auto.

None
vmax float | None

Shared color limits across all panels. None for auto.

None
colorbar bool or 'inset'

Colorbar mode for each panel.

True
extremes "semi", "transparent", "darken", or None

Colorbar out-of-range indicator style. "semi" (default) uses semi-transparent extension colors; "transparent" hides them; "darken" darkens the endpoint colors; None leaves matplotlib defaults untouched.

'semi'
log_scale bool

Use logarithmic color mapping for all panels.

False
step int | None

Timestep number (shown in suptitle if suptitle is not set).

None
time float | None

Simulation time (shown in suptitle if suptitle is not set).

None
figsize tuple[float, float] | None

Figure size override. None auto-scales based on grid size.

None
panel_labels bool

Add (a), (b), … labels to each panel.

True
suptitle str | None

Figure super-title. None generates from step/time.

None
ax Sequence[Axes] or None

Existing axes, one per field in order; ncols and figsize are then unused and the figure is left to the caller to lay out. None creates the grid.

None
vmin float or None

Lower color limit. None (default) autoscales.

None
vmax float or None

Upper color limit. None (default) autoscales.

None
alpha float

Opacity of the field image, in [0, 1].

1.0
symmetric bool or None

Force symmetric color limits about zero. None (default) decides from whether the field is positive-definite.

None
save str or Path or None

Path to write the figure to. When given, the figure is saved and closed; when None (default) it is left open for the caller to display or modify further.

None

Returns:

Type Description
tuple[Figure, list[Axes]]

Figure and flat list of axes (one per field).

Source code in src/pypic/plotting/panels.py
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
def plot_field_grid(
    data: FieldDataset,
    fields: list[str],
    *,
    ncols: int = 3,
    plane: PlaneSelection | None = None,
    units: dict[str, str] | None = None,
    coord_units: str | tuple[str, str] | None = None,
    theme: ThemeArg = None,
    cmap: str | Colormap | dict[str, str | Colormap] | None = None,
    vmin: float | None = None,
    vmax: float | None = None,
    colorbar: bool | Literal["inset"] = True,
    extremes: ExtremesMode = "semi",
    alpha: float = 1.0,
    symmetric: bool | None = None,
    log_scale: bool = False,
    step: int | None = None,
    time: float | None = None,
    save: str | None = None,
    figsize: tuple[float, float] | None = None,
    panel_labels: bool = True,
    suptitle: str | None = None,
    ax: Sequence[Axes] | None = None,
) -> tuple[Figure, list[Axes]]:
    r"""Plot multiple fields in an auto-arranged grid with panel labels.

    Creates a figure with ``ceil(len(fields) / ncols)`` rows and
    *ncols* columns. Each panel shows one field via
    `plot_field_slice`, with optional ``(a)``, ``(b)``, ``(c)``
    labels.

    Parameters
    ----------
    data : FieldDataset
        Input dataset (2D or 3D).
    fields : list[str]
        Field names to plot, one per panel.
    ncols : int
        Number of columns in the grid.
    plane : PlaneSelection | None
        Plane selection for 3D data (applied to all panels).
    units : dict[str, str] | None
        Per-field display units, keyed by field name. Fields not in the
        dict use code units.
    coord_units : str, tuple[str, str], or None
        Coordinate axis units (shared across panels).
    theme : PlotTheme | None
        Plot theme. ``None`` uses ``DEFAULT``.
    cmap : str, Colormap, dict, or None
        Colormap override. A single string or Colormap applies to all
        panels; a dict maps field names to per-field colormaps.
    vmin, vmax : float | None
        Shared color limits across all panels. ``None`` for auto.
    colorbar : bool or "inset"
        Colorbar mode for each panel.
    extremes : "semi", "transparent", "darken", or None
        Colorbar out-of-range indicator style. ``"semi"`` (default)
        uses semi-transparent extension colors; ``"transparent"``
        hides them; ``"darken"`` darkens the endpoint colors;
        ``None`` leaves matplotlib defaults untouched.
    log_scale : bool
        Use logarithmic color mapping for all panels.
    step : int | None
        Timestep number (shown in suptitle if *suptitle* is not set).
    time : float | None
        Simulation time (shown in suptitle if *suptitle* is not set).
    figsize : tuple[float, float] | None
        Figure size override. ``None`` auto-scales based on grid size.
    panel_labels : bool
        Add ``(a)``, ``(b)``, … labels to each panel.
    suptitle : str | None
        Figure super-title. ``None`` generates from step/time.
    ax : Sequence[Axes] or None
        Existing axes, one per field in order; *ncols* and *figsize* are
        then unused and the figure is left to the caller to lay out.
        ``None`` creates the grid.
    vmin : float or None
        Lower color limit. ``None`` (default) autoscales.
    vmax : float or None
        Upper color limit. ``None`` (default) autoscales.
    alpha : float
        Opacity of the field image, in ``[0, 1]``.
    symmetric : bool or None
        Force symmetric color limits about zero. ``None``
        (default) decides from whether the field is
        positive-definite.
    save : str or Path or None
        Path to write the figure to. When given, the figure is saved
        and closed; when ``None`` (default) it is left open for the
        caller to display or modify further.

    Returns
    -------
    tuple[Figure, list[Axes]]
        Figure and flat list of axes (one per field).
    """
    ensure_matplotlib()

    import matplotlib.pyplot as plt

    from pypic.plotting._badge import add_label
    from pypic.plotting._resolve import get_or_create_axes, maybe_save, prepare_data
    from pypic.plotting.slices import plot_field_slice
    from pypic.plotting.styles import _resolve_theme_arg, apply_rounding, use_theme

    theme = _resolve_theme_arg(theme)

    if not fields:
        msg = "fields list must not be empty"
        raise ValueError(msg)
    if ax is not None and len(ax) != len(fields):
        msg = f"ax holds {len(ax)} axes for {len(fields)} fields"
        raise ValueError(msg)

    data = prepare_data(data, plane)

    nrows = math.ceil(len(fields) / ncols)
    if figsize is None:
        figsize = (theme.figsize_per_col * ncols, theme.figsize_per_row * nrows)

    # Scale overlay text for dense grids (harder to read at reduced size)
    label_scale = (
        theme.panel_label_scale_sparse if nrows == 1 else theme.panel_label_scale_dense
    )
    label_fontsize = theme.font_overlay * label_scale

    with use_theme(theme):
        if ax is None:
            fig, axes_arr = plt.subplots(nrows, ncols, figsize=figsize, squeeze=False)
            axes_flat = list(axes_arr.flat)
        else:
            axes_flat = list(ax)
            fig, _ = get_or_create_axes(theme, axes_flat[0], None)
        panels = axes_flat[: len(fields)]

        for i, (field_name, panel_ax) in enumerate(zip(fields, panels, strict=True)):
            field_units = (units or {}).get(field_name)
            panel_cmap = cmap.get(field_name) if isinstance(cmap, dict) else cmap
            plot_field_slice(
                data,
                field_name,
                units=field_units,
                coord_units=coord_units,
                theme=theme,
                cmap=panel_cmap,
                vmin=vmin,
                vmax=vmax,
                alpha=alpha,
                symmetric=symmetric,
                log_scale=log_scale,
                ax=panel_ax,
                colorbar=colorbar,
                extremes=extremes,
                step=step,
                time=time,
            )
            if panel_labels:
                add_label(panel_ax, chr(ord("a") + i), fontsize=label_fontsize)

        # Hide unused axes
        for j in range(len(fields), len(axes_flat)):
            axes_flat[j].set_visible(False)

        if suptitle is not None:
            fig.suptitle(suptitle)
        elif step is not None or time is not None:
            parts: list[str] = []
            if step is not None:
                parts.append(f"step {step}")
            if time is not None:
                parts.append(f"t = {time:.2f}")
            fig.suptitle(", ".join(parts))

        if ax is None:
            fig.tight_layout()
        for panel_ax in panels:
            apply_rounding(panel_ax)

    maybe_save(fig, save)
    return fig, panels

plot_poincare_section(section, *, ax=None, theme=None, color_by_seed=True, marker_size=2.0, alpha=0.7, title=None, save=None, figsize=None)

Scatter the puncture cloud of a PoincareSection.

Each seed's punctures get a distinct color from the active theme's cycle when color_by_seed is true — co-orbital points appear as same-color closed curves (islands / KAM surfaces) and chaotic seeds fill 2D regions in a single color.

Parameters:

Name Type Description Default
section PoincareSection required
ax Axes | None

Existing axes. None creates a new figure.

None
theme PlotTheme | None

Plot theme. None uses the active default.

None
color_by_seed bool

Cycle a distinct theme color per seed. When false, all punctures share one color (cleaner for very dense clouds).

True
marker_size float

Marker size in points².

2.0
alpha float

Point transparency.

0.7
title str | None

Axes title. Defaults to f"Poincaré section: {surface.name}" when section.surface.name is set.

None
save str or None

Path to write the figure to. When given, the figure is saved and closed; when None (default) it is left open.

None
figsize tuple[float, float] | None

Figure size override.

None

Returns:

Type Description
tuple[Figure, Axes]
Source code in src/pypic/plotting/poincare.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
def plot_poincare_section(
    section: PoincareSection,
    *,
    ax: Axes | None = None,
    theme: ThemeArg = None,
    color_by_seed: bool = True,
    marker_size: float = 2.0,
    alpha: float = 0.7,
    title: str | None = None,
    save: str | None = None,
    figsize: tuple[float, float] | None = None,
) -> tuple[Figure, Axes]:
    r"""Scatter the puncture cloud of a `PoincareSection`.

    Each seed's punctures get a distinct color from the active theme's
    cycle when ``color_by_seed`` is true — co-orbital points appear as
    same-color closed curves (islands / KAM surfaces) and chaotic seeds
    fill 2D regions in a single color.

    Parameters
    ----------
    section : PoincareSection
        Output of [`pypic.traces.poincare_section`][pypic.traces.poincare_section].
    ax : Axes | None
        Existing axes. ``None`` creates a new figure.
    theme : PlotTheme | None
        Plot theme. ``None`` uses the active default.
    color_by_seed : bool
        Cycle a distinct theme color per seed. When false, all punctures
        share one color (cleaner for very dense clouds).
    marker_size : float
        Marker size in points².
    alpha : float
        Point transparency.
    title : str | None
        Axes title. Defaults to ``f"Poincaré section: {surface.name}"``
        when ``section.surface.name`` is set.
    save : str or None
        Path to write the figure to. When given, the figure is saved
        and closed; when ``None`` (default) it is left open.
    figsize : tuple[float, float] | None
        Figure size override.

    Returns
    -------
    tuple[Figure, Axes]
    """
    ensure_matplotlib()

    from pypic.plotting._resolve import get_or_create_axes, maybe_save
    from pypic.plotting.styles import _resolve_theme_arg, apply_grid, use_theme

    resolved_theme = _resolve_theme_arg(theme)
    owned = ax is None

    with use_theme(resolved_theme) if owned else nullcontext():
        fig, ax = get_or_create_axes(resolved_theme, ax, figsize)
        cycle = resolved_theme.color_cycle
        n_colors = len(cycle) if cycle else 1

        if color_by_seed:
            for k, pts in enumerate(section.punctures_2d):
                if pts.shape[0] == 0:
                    continue
                color = cycle[k % n_colors] if cycle else None
                ax.scatter(
                    pts[:, 0],
                    pts[:, 1],
                    s=marker_size,
                    alpha=alpha,
                    color=color,
                    edgecolors="none",
                    label=f"seed {k}" if section.n_seeds <= 8 else None,
                )
        else:
            cloud = section.all_punctures_2d
            color = cycle[0] if cycle else None
            ax.scatter(
                cloud[:, 0],
                cloud[:, 1],
                s=marker_size,
                alpha=alpha,
                color=color,
                edgecolors="none",
            )

        ax.set_aspect("equal", adjustable="box")
        ax.set_xlabel("$u$")
        ax.set_ylabel("$v$")
        if title is None and section.surface.name is not None:
            title = f"Poincaré section: {section.surface.name}"
        if title is not None:
            ax.set_title(title)
        apply_grid(ax, resolved_theme)

    maybe_save(fig, save)
    return fig, ax

plot_scatter(data, field_x, field_y, *, plane=None, color_field=None, units_x=None, units_y=None, color_units=None, theme=None, cmap=None, vmin=None, vmax=None, alpha=0.3, marker_size=1.0, density=False, bins=80, log_x=False, log_y=False, title=None, ax=None, colorbar=True, save=None, figsize=None)

Scatter or density plot of two field quantities.

Plots every grid point's value of field_x against field_y. Useful for equation-of-state analysis (\(P\) vs \(\rho\)), anisotropy studies (\(P_\parallel\) vs \(P_\perp\)), and general correlation exploration.

Parameters:

Name Type Description Default
data FieldDataset

Input dataset (2D or 3D).

required
field_x str

Field for the horizontal axis.

required
field_y str

Field for the vertical axis.

required
plane PlaneSelection | None

Plane selection for 3D data. None auto-slices at midplane.

None
color_field str | None

Optional third field for point color (e.g. "beta"). None uses uniform color in scatter mode, or count density in density=True mode.

None
units_x str | None

Display units for field_x.

None
units_y str | None

Display units for field_y.

None
color_units str | None

Display units for color_field.

None
theme PlotTheme | None

Plot theme. None uses DEFAULT.

None
cmap str | None

Colormap override for colored scatter or density mode.

None
vmin float | None

Color limits for color_field or the density counts. None (default) autoscales.

None
vmax float | None

Color limits for color_field or the density counts. None (default) autoscales.

None
alpha float

Point transparency (scatter mode only).

0.3
marker_size float

Marker size in points² (scatter mode only).

1.0
density bool

Use a hexbin density plot instead of a scatter plot. Better for large grids where individual points overlap heavily.

False
bins int

Hexbin grid size (density=True only).

80
log_x bool

Logarithmic x-axis.

False
log_y bool

Logarithmic y-axis.

False
title str | None

Axes title.

None
ax Axes | None

Existing axes. None creates a new figure.

None
colorbar bool

Show a colorbar when color_field is set or density=True.

True
save str | None

Save figure to this path.

None
figsize tuple[float, float] | None

Figure size override.

None

Returns:

Type Description
tuple[Figure, Axes]
Source code in src/pypic/plotting/scatter.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
def plot_scatter(
    data: FieldDataset,
    field_x: str,
    field_y: str,
    *,
    plane: PlaneSelection | None = None,
    color_field: str | None = None,
    units_x: str | None = None,
    units_y: str | None = None,
    color_units: str | None = None,
    theme: ThemeArg = None,
    cmap: str | None = None,
    vmin: float | None = None,
    vmax: float | None = None,
    alpha: float = 0.3,
    marker_size: float = 1.0,
    density: bool = False,
    bins: int = 80,
    log_x: bool = False,
    log_y: bool = False,
    title: str | None = None,
    ax: Axes | None = None,
    colorbar: bool = True,
    save: str | None = None,
    figsize: tuple[float, float] | None = None,
) -> tuple[Figure, Axes]:
    r"""Scatter or density plot of two field quantities.

    Plots every grid point's value of *field_x* against *field_y*.
    Useful for equation-of-state analysis ($P$ vs $\rho$), anisotropy
    studies ($P_\parallel$ vs $P_\perp$), and general correlation
    exploration.

    Parameters
    ----------
    data : FieldDataset
        Input dataset (2D or 3D).
    field_x : str
        Field for the horizontal axis.
    field_y : str
        Field for the vertical axis.
    plane : PlaneSelection | None
        Plane selection for 3D data. ``None`` auto-slices at midplane.
    color_field : str | None
        Optional third field for point color (e.g. ``"beta"``).
        ``None`` uses uniform color in scatter mode, or count density
        in ``density=True`` mode.
    units_x : str | None
        Display units for *field_x*.
    units_y : str | None
        Display units for *field_y*.
    color_units : str | None
        Display units for *color_field*.
    theme : PlotTheme | None
        Plot theme. ``None`` uses ``DEFAULT``.
    cmap : str | None
        Colormap override for colored scatter or density mode.
    vmin, vmax : float | None
        Color limits for *color_field* or the density counts. ``None``
        (default) autoscales.
    alpha : float
        Point transparency (scatter mode only).
    marker_size : float
        Marker size in points² (scatter mode only).
    density : bool
        Use a hexbin density plot instead of a scatter plot. Better
        for large grids where individual points overlap heavily.
    bins : int
        Hexbin grid size (``density=True`` only).
    log_x : bool
        Logarithmic x-axis.
    log_y : bool
        Logarithmic y-axis.
    title : str | None
        Axes title.
    ax : Axes | None
        Existing axes. ``None`` creates a new figure.
    colorbar : bool
        Show a colorbar when *color_field* is set or ``density=True``.
    save : str | None
        Save figure to this path.
    figsize : tuple[float, float] | None
        Figure size override.

    Returns
    -------
    tuple[Figure, Axes]
    """
    ensure_matplotlib()

    from pypic.plotting._colorbar import _style_colorbar
    from pypic.plotting._labels import field_label
    from pypic.plotting._resolve import (
        finish_axes,
        get_or_create_axes,
        maybe_save,
        prepare_data,
        resolve_field_values,
    )
    from pypic.plotting.styles import _resolve_theme_arg, use_theme

    owns_figure = ax is None
    theme = _resolve_theme_arg(theme)
    data = prepare_data(data, plane)

    x = resolve_field_values(data, field_x, units_x).ravel()
    y = resolve_field_values(data, field_y, units_y).ravel()

    color = None
    if color_field is not None:
        color = resolve_field_values(data, color_field, color_units).ravel()

    # Filter NaN
    mask = np.isfinite(x) & np.isfinite(y)
    if color is not None:
        mask &= np.isfinite(color)
    x, y = x[mask], y[mask]
    if color is not None:
        color = color[mask]

    info_x = data.field_info(field_x)
    info_y = data.field_info(field_y)

    if cmap is None:
        cmap = theme.sequential_cmap

    with use_theme(theme):
        fig, ax = get_or_create_axes(theme, ax, figsize)

        mappable: ScalarMappable | None = None
        if density:
            hb = ax.hexbin(
                x,
                y,
                C=color,
                gridsize=bins,
                cmap=cmap,
                vmin=vmin,
                vmax=vmax,
                mincnt=1,
                xscale="log" if log_x else "linear",
                yscale="log" if log_y else "linear",
            )
            mappable = hb
        else:
            if color is not None:
                sc = ax.scatter(
                    x,
                    y,
                    c=color,
                    s=marker_size,
                    alpha=alpha,
                    cmap=cmap,
                    vmin=vmin,
                    vmax=vmax,
                    edgecolors="none",
                )
                mappable = sc
            else:
                ax.scatter(
                    x,
                    y,
                    s=marker_size,
                    alpha=alpha,
                    edgecolors="none",
                )

        if log_x and not density:
            ax.set_xscale("log")
        if log_y and not density:
            ax.set_yscale("log")

        if colorbar and mappable is not None:
            from mpl_toolkits.axes_grid1 import make_axes_locatable

            from pypic.plotting.styles import _theme_val

            divider = make_axes_locatable(ax)
            cax = divider.append_axes(
                "right",
                size=_theme_val("colorbar_width", "4%"),
                pad=_theme_val("colorbar_pad", 0.05),
            )
            cb = fig.colorbar(mappable, cax=cax)
            if color_field is not None:
                info_c = data.field_info(color_field)
                cb_label = field_label(info_c, unit_str=color_units or "")
            elif density:
                cb_label = "count"
            else:
                cb_label = ""
            _style_colorbar(cb, cb_label)

        finish_axes(
            fig,
            ax,
            theme,
            owns_figure=owns_figure,
            xlabel=field_label(info_x, unit_str=units_x or ""),
            ylabel=field_label(info_y, unit_str=units_y or ""),
            title=title,
        )

    maybe_save(fig, save)
    return fig, ax

add_contours(ax, data, field, *, plane=None, units=None, levels=5, colors=None, linewidths=0.5, alpha=0.7, linestyles='solid', labels=False, label_fontsize=None, **kwargs)

Add contour lines to existing axes from a scalar field.

Works as an overlay on plot_field_slice or any other 2D plot.

Parameters:

Name Type Description Default
ax Axes

Target axes (must already have coordinate limits set).

required
data FieldDataset

Input dataset (2D or 3D).

required
field str

Scalar field name for contouring.

required
plane PlaneSelection | None

Plane selection for 3D data. None auto-slices at midplane.

None
units str | None

Display units for field values.

None
levels int or list[float]

Number of contour levels, or explicit level values.

5
colors str, list[str], or None

Line color(s). None uses the first color from the theme's color cycle (falls back to "black").

None
linewidths float

Contour line width.

0.5
alpha float

Line transparency.

0.7
linestyles str

Line style ("solid", "dashed", "dotted").

'solid'
labels bool

Whether to add inline contour labels.

False
label_fontsize float or None

Font size for contour labels (when labels is True). None (default) reads from theme.contour_label_fontsize.

None
**kwargs Any

Passed to ax.contour().

{}

Returns:

Type Description
QuadContourSet

The contour set added to ax.

Examples:

>>> fig, ax = plot_field_slice(data, "|B|")
>>> add_contours(ax, data, "P", levels=8, colors="white")
Source code in src/pypic/plotting/slices.py
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
def add_contours(
    ax: Axes,
    data: FieldDataset,
    field: str,
    *,
    plane: PlaneSelection | None = None,
    units: str | None = None,
    levels: int | list[float] = 5,
    colors: str | list[str] | None = None,
    linewidths: float = 0.5,
    alpha: float = 0.7,
    linestyles: str = "solid",
    labels: bool = False,
    label_fontsize: float | None = None,
    **kwargs: Any,  # noqa: ANN401 — contour passthrough
) -> QuadContourSet:
    r"""Add contour lines to existing axes from a scalar field.

    Works as an overlay on `plot_field_slice` or any other 2D plot.

    Parameters
    ----------
    ax : Axes
        Target axes (must already have coordinate limits set).
    data : FieldDataset
        Input dataset (2D or 3D).
    field : str
        Scalar field name for contouring.
    plane : PlaneSelection | None
        Plane selection for 3D data. ``None`` auto-slices at midplane.
    units : str | None
        Display units for field values.
    levels : int or list[float]
        Number of contour levels, or explicit level values.
    colors : str, list[str], or None
        Line color(s). ``None`` uses the first color from the theme's
        color cycle (falls back to ``"black"``).
    linewidths : float
        Contour line width.
    alpha : float
        Line transparency.
    linestyles : str
        Line style (``"solid"``, ``"dashed"``, ``"dotted"``).
    labels : bool
        Whether to add inline contour labels.
    label_fontsize : float or None
        Font size for contour labels (when *labels* is ``True``).
        ``None`` (default) reads from ``theme.contour_label_fontsize``.
    **kwargs
        Passed to ``ax.contour()``.

    Returns
    -------
    QuadContourSet
        The contour set added to *ax*.

    Examples
    --------
    >>> fig, ax = plot_field_slice(data, "|B|")  # doctest: +SKIP
    >>> add_contours(ax, data, "P", levels=8, colors="white")  # doctest: +SKIP
    """
    ensure_matplotlib()

    from pypic.plotting._resolve import prepare_data, resolve_field_values
    from pypic.plotting.styles import _theme_val

    if colors is None:
        cycle = _theme_val("color_cycle", ())
        colors = cycle[0] if cycle else "black"

    data = prepare_data(data, plane)
    values = resolve_field_values(data, field, units)
    coords = data.grid.coordinate_arrays()

    cs = ax.contour(
        coords[0],
        coords[1],
        values.T,
        levels=levels,
        colors=colors,
        linewidths=linewidths,
        alpha=alpha,
        linestyles=linestyles,
        **kwargs,
    )

    if labels:
        from pypic.plotting.styles import _theme_val

        fs = (
            label_fontsize
            if label_fontsize is not None
            else _theme_val("contour_label_fontsize", 7.0)
        )
        ax.clabel(cs, inline=True, fontsize=fs)

    return cs

plot_field_slice(data, field, *, plane=None, units=None, coord_units=None, theme=None, cmap=None, vmin=None, vmax=None, alpha=1.0, symmetric=None, log_scale=False, symlog=False, linthresh=None, title=None, step=None, time=None, ax=None, colorbar=True, colorbar_label=None, colorbar_variant=None, colorbar_ticks=None, extremes='semi', badge=False, save=None, figsize=None)

Plot a 2D slice of a scalar field.

If data is 3D and no plane is given, defaults to a midplane slice along the last axis (PlaneSelection(normal=axis_names[2])).

Parameters:

Name Type Description Default
data FieldDataset

Input dataset (2D or 3D).

required
field str

Field name — canonical, alias, or derived (e.g. "|B|").

required
plane PlaneSelection | None

Plane selection for 3D data. None auto-slices at midplane.

None
units str | None

Display units for field values (e.g. "nT").

None
coord_units str, tuple[str, str], or None

Display units for coordinate axes. A single string applies to both axes; a tuple (x_unit, y_unit) labels each independently.

None
theme PlotTheme | None

Plot theme. None uses DEFAULT.

None
cmap str | Colormap | None

Override automatic colormap selection.

None
vmin float | None

Color limits. None for auto.

None
vmax float | None

Color limits. None for auto.

None
alpha float

Mesh transparency (0 = invisible, 1 = opaque). Useful for overlaying semi-transparent scalar fields.

1.0
symmetric bool | None

Force symmetric color limits around zero. None auto-detects (True when diverging colormap is selected).

None
log_scale bool

Use logarithmic color mapping (LogNorm). Non-positive values are masked with NaN. Ignored when symmetric is True.

False
symlog bool

Use symmetric-log color mapping (SymLogNorm). Combines a linear region around zero with logarithmic tails — ideal for signed fields with large dynamic range (current density, vorticity). Mutually exclusive with log_scale.

False
linthresh float | None

Linear threshold for symlog. Values within [-linthresh, linthresh] are mapped linearly; outside is logarithmic. None auto-detects from median(|nonzero values|).

None
title str | None

Override auto-generated title.

None
step int | None

Timestep number for the title.

None
time float | None

Simulation time for the title.

None
ax Axes | None

Existing axes to draw on. None creates a new figure.

None
colorbar bool

Whether to add a colorbar.

True
extremes "semi", "transparent", "darken", or None

How to style values outside [vmin, vmax]. "semi" (default) — semi-transparent (~30% opacity). "transparent" — fully invisible. "darken" — darkened endpoint colors. None — matplotlib default (no modification).

'semi'
figsize tuple[float, float] | None

Figure size override.

None
vmin float or None

Lower color limit. None (default) autoscales.

None
vmax float or None

Upper color limit. None (default) autoscales.

None
colorbar_label str or None

Override the colorbar label. None (default) builds one from the field's registry metadata and units.

None
colorbar_variant str or None

Colorbar placement variant. None (default) uses the active theme's choice.

None
colorbar_ticks Sequence[float] or None

Explicit colorbar tick positions. None (default) lets matplotlib choose.

None
badge str or None

Corner badge text (run label, timestamp, ...). None (default) draws no badge.

False
save str or Path or None

Path to write the figure to. When given, the figure is saved and closed; when None (default) it is left open for the caller to display or modify further.

None

Returns:

Type Description
tuple[Figure, Axes]
Source code in src/pypic/plotting/slices.py
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
def plot_field_slice(
    data: FieldDataset,
    field: str,
    *,
    plane: PlaneSelection | None = None,
    units: str | None = None,
    coord_units: str | tuple[str, str] | None = None,
    theme: ThemeArg = None,
    cmap: str | Colormap | None = None,
    vmin: float | None = None,
    vmax: float | None = None,
    alpha: float = 1.0,
    symmetric: bool | None = None,
    log_scale: bool = False,
    symlog: bool = False,
    linthresh: float | None = None,
    title: str | None = None,
    step: int | None = None,
    time: float | None = None,
    ax: Axes | None = None,
    colorbar: bool | Literal["inset"] = True,
    colorbar_label: str | None = None,
    colorbar_variant: OverlayVariant | None = None,
    colorbar_ticks: list[float] | None = None,
    extremes: ExtremesMode = "semi",
    badge: bool = False,
    save: str | None = None,
    figsize: tuple[float, float] | None = None,
) -> tuple[Figure, Axes]:
    r"""Plot a 2D slice of a scalar field.

    If *data* is 3D and no *plane* is given, defaults to a midplane
    slice along the last axis (``PlaneSelection(normal=axis_names[2])``).

    Parameters
    ----------
    data : FieldDataset
        Input dataset (2D or 3D).
    field : str
        Field name — canonical, alias, or derived (e.g. ``"|B|"``).
    plane : PlaneSelection | None
        Plane selection for 3D data. ``None`` auto-slices at midplane.
    units : str | None
        Display units for field values (e.g. ``"nT"``).
    coord_units : str, tuple[str, str], or None
        Display units for coordinate axes. A single string applies to
        both axes; a tuple ``(x_unit, y_unit)`` labels each independently.
    theme : PlotTheme | None
        Plot theme. ``None`` uses ``DEFAULT``.
    cmap : str | Colormap | None
        Override automatic colormap selection.
    vmin, vmax : float | None
        Color limits. ``None`` for auto.
    alpha : float
        Mesh transparency (0 = invisible, 1 = opaque). Useful for
        overlaying semi-transparent scalar fields.
    symmetric : bool | None
        Force symmetric color limits around zero. ``None`` auto-detects
        (``True`` when diverging colormap is selected).
    log_scale : bool
        Use logarithmic color mapping (``LogNorm``). Non-positive values
        are masked with NaN. Ignored when *symmetric* is ``True``.
    symlog : bool
        Use symmetric-log color mapping (``SymLogNorm``). Combines a
        linear region around zero with logarithmic tails — ideal for
        signed fields with large dynamic range (current density,
        vorticity). Mutually exclusive with *log_scale*.
    linthresh : float | None
        Linear threshold for symlog. Values within ``[-linthresh,
        linthresh]`` are mapped linearly; outside is logarithmic.
        ``None`` auto-detects from ``median(|nonzero values|)``.
    title : str | None
        Override auto-generated title.
    step : int | None
        Timestep number for the title.
    time : float | None
        Simulation time for the title.
    ax : Axes | None
        Existing axes to draw on. ``None`` creates a new figure.
    colorbar : bool
        Whether to add a colorbar.
    extremes : "semi", "transparent", "darken", or None
        How to style values outside ``[vmin, vmax]``.
        ``"semi"`` (default) — semi-transparent (~30% opacity).
        ``"transparent"`` — fully invisible.
        ``"darken"`` — darkened endpoint colors.
        ``None`` — matplotlib default (no modification).
    figsize : tuple[float, float] | None
        Figure size override.
    vmin : float or None
        Lower color limit. ``None`` (default) autoscales.
    vmax : float or None
        Upper color limit. ``None`` (default) autoscales.
    colorbar_label : str or None
        Override the colorbar label. ``None`` (default) builds
        one from the field's registry metadata and units.
    colorbar_variant : str or None
        Colorbar placement variant. ``None`` (default) uses
        the active theme's choice.
    colorbar_ticks : Sequence[float] or None
        Explicit colorbar tick positions. ``None`` (default)
        lets matplotlib choose.
    badge : str or None
        Corner badge text (run label, timestamp, ...). ``None``
        (default) draws no badge.
    save : str or Path or None
        Path to write the figure to. When given, the figure is saved
        and closed; when ``None`` (default) it is left open for the
        caller to display or modify further.

    Returns
    -------
    tuple[Figure, Axes]
    """
    ensure_matplotlib()

    import warnings

    import numpy as np

    from pypic.plotting._colorbar import attach_colorbar
    from pypic.plotting._colormaps import (
        is_positive_definite,
        resolve_field_colormap,
        resolve_norm,
    )
    from pypic.plotting._labels import field_label, figure_title
    from pypic.plotting._resolve import (
        finish_axes,
        get_or_create_axes,
        maybe_save,
        plane_axis_labels,
        prepare_data,
        resolve_field_values,
    )
    from pypic.plotting.styles import _resolve_theme_arg, use_theme

    owns_figure = ax is None
    theme = _resolve_theme_arg(theme)
    data = prepare_data(data, plane)
    values = resolve_field_values(data, field, units)

    if np.all(np.isnan(values)):
        warnings.warn(f"Field {field!r} is entirely NaN", stacklevel=2)

    info = data.field_info(field)
    coords = data.grid.coordinate_arrays()

    _, colormap = resolve_field_colormap(field, values, theme, info=info, cmap=cmap)
    if symmetric is None:
        symmetric = not is_positive_definite(field, values, info)
    norm = resolve_norm(
        values,
        symmetric=symmetric,
        log_scale=log_scale,
        symlog=symlog,
        vmin=vmin,
        vmax=vmax,
        linthresh=linthresh,
    )

    # Transparent extremes: mask out-of-range values so pcolormesh
    # renders them as truly transparent (not black)
    if extremes == "transparent" and norm.vmin is not None and norm.vmax is not None:
        in_range = (values >= norm.vmin) & (values <= norm.vmax)
        values = np.where(in_range, values, np.nan)

    with use_theme(theme):
        fig, ax = get_or_create_axes(theme, ax, figsize)
        mesh = ax.pcolormesh(
            coords[0],
            coords[1],
            values.T,
            shading="auto",
            cmap=colormap,
            alpha=alpha,
            norm=norm,
        )

        unit_str = units or ""
        cb_label = (
            colorbar_label
            if colorbar_label is not None
            else field_label(info, unit_str=unit_str)
        )
        attach_colorbar(
            fig,
            ax,
            mesh,
            cb_label,
            colorbar,
            extremes=extremes,
            variant=colorbar_variant,
            ticks=colorbar_ticks,
        )

        if title is None:
            title = figure_title(info, step=step, time=time)
        xlabel, ylabel = plane_axis_labels(data, coord_units)
        finish_axes(
            fig,
            ax,
            theme,
            owns_figure=owns_figure,
            xlabel=xlabel,
            ylabel=ylabel,
            title=title,
            aspect="equal",
            badge=badge,
            step=step,
            time=time,
        )

    maybe_save(fig, save)
    return fig, ax

plot_power_spectrum(k, power, *, label=None, compensated=None, reference_slopes=None, theme=None, xlabel=None, ylabel=None, title=None, ax=None, save=None, figsize=None, **kwargs)

Plot a power spectrum on log-log axes.

Designed for output from power_spectrum_1d or power_spectrum_2d.

Parameters:

Name Type Description Default
k FloatArray

Wavenumber array.

required
power FloatArray

Power spectral density.

required
label str | None

Legend label for this spectrum.

None
compensated float | None

Multiply power by \(k^n\) before plotting (e.g. 5/3 for Kolmogorov compensation). None plots raw power.

None
reference_slopes list[float] | None

Draw dashed reference lines with these slopes on the log-log plot (e.g. [-5/3, -3]). Each line is auto-positioned to pass through the geometric midpoint of the spectrum.

None
theme PlotTheme | None

Plot theme. None uses DEFAULT.

None
xlabel str | None

X-axis label. None defaults to "$k$".

None
ylabel str | None

Y-axis label. None auto-generates based on compensated.

None
title str | None

Axes title.

None
ax Axes | None

Existing axes. None creates a new figure.

None
save str | None

Save figure to this path.

None
figsize tuple[float, float] | None

Figure size override.

None
**kwargs Any

Passed to ax.loglog() (color, linestyle, linewidth, etc.).

{}

Returns:

Type Description
tuple[Figure, Axes]
Source code in src/pypic/plotting/spectral.py
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
def plot_power_spectrum(
    k: FloatArray,
    power: FloatArray,
    *,
    label: str | None = None,
    compensated: float | None = None,
    reference_slopes: list[float] | None = None,
    theme: ThemeArg = None,
    xlabel: str | None = None,
    ylabel: str | None = None,
    title: str | None = None,
    ax: Axes | None = None,
    save: str | None = None,
    figsize: tuple[float, float] | None = None,
    **kwargs: Any,  # noqa: ANN401 — matplotlib passthrough
) -> tuple[Figure, Axes]:
    r"""Plot a power spectrum on log-log axes.

    Designed for output from [`power_spectrum_1d`][pypic.spectral.power_spectrum_1d]
    or [`power_spectrum_2d`][pypic.spectral.power_spectrum_2d].

    Parameters
    ----------
    k : FloatArray
        Wavenumber array.
    power : FloatArray
        Power spectral density.
    label : str | None
        Legend label for this spectrum.
    compensated : float | None
        Multiply power by $k^n$ before plotting (e.g. ``5/3`` for
        Kolmogorov compensation). ``None`` plots raw power.
    reference_slopes : list[float] | None
        Draw dashed reference lines with these slopes on the log-log
        plot (e.g. ``[-5/3, -3]``). Each line is auto-positioned to
        pass through the geometric midpoint of the spectrum.
    theme : PlotTheme | None
        Plot theme. ``None`` uses ``DEFAULT``.
    xlabel : str | None
        X-axis label. ``None`` defaults to ``"$k$"``.
    ylabel : str | None
        Y-axis label. ``None`` auto-generates based on *compensated*.
    title : str | None
        Axes title.
    ax : Axes | None
        Existing axes. ``None`` creates a new figure.
    save : str | None
        Save figure to this path.
    figsize : tuple[float, float] | None
        Figure size override.
    **kwargs
        Passed to ``ax.loglog()`` (color, linestyle, linewidth, etc.).

    Returns
    -------
    tuple[Figure, Axes]
    """
    ensure_matplotlib()

    from pypic.plotting._resolve import finish_axes, get_or_create_axes, maybe_save
    from pypic.plotting.styles import _resolve_theme_arg, style_legend, use_theme

    owns_figure = ax is None
    theme = _resolve_theme_arg(theme)

    plot_power = power * k**compensated if compensated is not None else power
    if ylabel is None:
        ylabel = (
            f"$k^{{{compensated:.2g}}} P(k)$" if compensated is not None else "$P(k)$"
        )

    with use_theme(theme):
        fig, ax = get_or_create_axes(theme, ax, figsize)

        ax.loglog(k, plot_power, label=label, **kwargs)

        # Reference slope lines
        if reference_slopes:
            mid_idx = len(k) // 2
            k_mid = k[mid_idx]
            p_mid = plot_power[mid_idx]

            for slope in reference_slopes:
                ref_line = p_mid * (k / k_mid) ** slope
                # Format slope as fraction where possible
                if abs(slope - round(slope)) < 1e-10:
                    slope_str = f"{round(slope)}"
                else:
                    from fractions import Fraction

                    frac = Fraction(slope).limit_denominator(10)
                    slope_str = f"{frac.numerator}/{frac.denominator}"
                ax.loglog(
                    k,
                    ref_line,
                    "--",
                    alpha=0.4,
                    color="gray",
                    linewidth=0.8,
                    label=f"$k^{{{slope_str}}}$",
                )

        if label is not None or reference_slopes:
            ax.legend()
            style_legend(ax)
        finish_axes(
            fig,
            ax,
            theme,
            owns_figure=owns_figure,
            xlabel=xlabel if xlabel is not None else "$k$",
            ylabel=ylabel,
            title=title,
            minor_grid=True,
        )

    maybe_save(fig, save)
    return fig, ax

apply_grid(ax, theme, *, minor=False)

Enable subtle grid lines styled for theme.

Parameters:

Name Type Description Default
ax Axes

Target axes.

required
theme PlotTheme

Theme providing grid_color.

required
minor bool

If True, also draw minor grid lines.

False
Source code in src/pypic/plotting/styles.py
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
def apply_grid(ax: Axes, theme: PlotTheme, *, minor: bool = False) -> None:
    """Enable subtle grid lines styled for *theme*.

    Parameters
    ----------
    ax : Axes
        Target axes.
    theme : PlotTheme
        Theme providing ``grid_color``.
    minor : bool
        If ``True``, also draw minor grid lines.
    """
    gc = theme.grid_color
    ax.grid(
        which="major",
        linewidth=theme.grid_major_width,
        linestyle=theme.grid_style,
        alpha=gc[3],
        color=gc[:3],
    )
    if minor:
        ax.minorticks_on()
        ax.grid(
            which="minor",
            linewidth=theme.grid_minor_width,
            linestyle=theme.grid_style,
            alpha=gc[3],
            color=gc[:3],
        )

apply_theme_to_figure(fig, theme)

Set figure facecolors, text colors, and tick colors to match theme.

Useful when the figure was created outside use_theme(). Applies background, text, tick, spine, and legend colors to all axes on the figure.

Source code in src/pypic/plotting/styles.py
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
def apply_theme_to_figure(fig: Figure, theme: PlotTheme) -> None:
    """Set figure facecolors, text colors, and tick colors to match *theme*.

    Useful when the figure was created outside ``use_theme()``.  Applies
    background, text, tick, spine, and legend colors to all axes on the
    figure.
    """
    fc = theme.rcparams.get("figure.facecolor", "white")
    fig.set_facecolor(fc)
    afc = theme.rcparams.get("axes.facecolor", "white")

    tc = theme.text_color
    label_c = theme.text_color
    tick_c = theme.secondary_text_color
    legend_label_c = theme.secondary_text_color

    for text in fig.texts:
        text.set_color(tc)

    for ax in fig.get_axes():
        ax.set_facecolor(afc)
        ax.title.set_color(tc)
        ax.xaxis.label.set_color(label_c)
        ax.yaxis.label.set_color(label_c)
        ax.tick_params(colors=tick_c, labelcolor=tick_c)
        for spine_name in ("left", "right", "top", "bottom"):
            visible = theme.rcparams.get(f"axes.spines.{spine_name}", True)
            ax.spines[spine_name].set_visible(visible)
            if visible:
                edge_c = theme.rcparams.get("axes.edgecolor", "black")
                ax.spines[spine_name].set_edgecolor(edge_c)
        legend = ax.get_legend()
        if legend is not None:
            for text in legend.get_texts():
                text.set_color(legend_label_c)

get_active_theme()

Return the theme set by the innermost use_theme context, or None.

Source code in src/pypic/plotting/styles.py
301
302
303
def get_active_theme() -> PlotTheme | None:
    """Return the theme set by the innermost `use_theme` context, or ``None``."""
    return _active_theme

get_theme()

Return the current default theme.

Loads light from the theme directory on first call if no default has been set.

Examples:

>>> get_theme().name
'light'
Source code in src/pypic/plotting/styles.py
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
def get_theme() -> PlotTheme:
    """Return the current default theme.

    Loads ``light`` from the theme directory on first call if no
    default has been set.

    Examples
    --------
    >>> get_theme().name
    'light'
    """
    global DEFAULT
    if DEFAULT is None:
        from pypic.plotting._theme_io import _find_theme, load_theme

        DEFAULT = load_theme(_find_theme(_DEFAULT_THEME_NAME))
    return DEFAULT

set_theme(theme)

Set the default theme for all pypic plot functions.

Parameters:

Name Type Description Default
theme ThemeArg

Theme name, path, or object.

required

Examples:

>>> set_theme("dark")
>>> get_theme().name
'dark'
>>> set_theme("light")
Source code in src/pypic/plotting/styles.py
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
def set_theme(theme: ThemeArg) -> None:
    """Set the default theme for all pypic plot functions.

    Parameters
    ----------
    theme : ThemeArg
        Theme name, path, or object.

    Examples
    --------
    >>> set_theme("dark")
    >>> get_theme().name
    'dark'
    >>> set_theme("light")
    """
    global DEFAULT
    DEFAULT = _resolve_theme_arg(theme)

style_legend(ax)

Round the legend box corners if a legend is present.

Source code in src/pypic/plotting/styles.py
584
585
586
587
588
589
590
591
def style_legend(ax: Axes) -> None:
    """Round the legend box corners if a legend is present."""
    legend = ax.get_legend()
    if legend is not None:
        # Legend frame is a FancyBboxPatch at runtime, not the base
        # Rectangle the matplotlib stubs claim.
        frame = cast("FancyBboxPatch", legend.get_frame())
        frame.set_boxstyle(_overlay_box_style())

use_theme(theme)

Temporarily apply theme rcParams, restoring originals on exit.

Accepts a PlotTheme, a theme name string, a .toml file path, or None (uses the current default).

Source code in src/pypic/plotting/styles.py
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
@contextmanager
def use_theme(theme: ThemeArg) -> Generator[None]:
    """Temporarily apply *theme* rcParams, restoring originals on exit.

    Accepts a `PlotTheme`, a theme name string, a ``.toml``
    file path, or ``None`` (uses the current default).
    """
    global _active_theme
    import matplotlib as mpl

    resolved = _resolve_theme_arg(theme)
    rc = dict(resolved.rcparams)

    # Inject font family and sizes from theme fields.
    # Filter to available fonts to suppress matplotlib findfont warnings.
    rc["font.family"] = _available_fonts(resolved.font_family)
    rc["font.size"] = resolved.font_label
    rc["axes.titlesize"] = resolved.font_title
    rc["axes.labelsize"] = resolved.font_label
    rc["xtick.labelsize"] = resolved.font_tick
    rc["ytick.labelsize"] = resolved.font_tick
    rc["legend.fontsize"] = resolved.font_overlay

    # Tick geometry
    rc["xtick.direction"] = resolved.tick_direction
    rc["ytick.direction"] = resolved.tick_direction
    rc["xtick.major.size"] = resolved.tick_major_length
    rc["ytick.major.size"] = resolved.tick_major_length
    rc["xtick.major.width"] = resolved.tick_major_width
    rc["ytick.major.width"] = resolved.tick_major_width
    rc["xtick.minor.size"] = resolved.tick_minor_length
    rc["ytick.minor.size"] = resolved.tick_minor_length
    rc["xtick.minor.width"] = resolved.tick_minor_width
    rc["ytick.minor.width"] = resolved.tick_minor_width

    if resolved.color_cycle:
        from cycler import cycler

        rc["axes.prop_cycle"] = cycler("color", list(resolved.color_cycle))

    # Inject RGBA colors from theme into rcParams
    rc["text.color"] = resolved.text_color
    rc["axes.edgecolor"] = resolved.text_color[:3]
    rc["axes.labelcolor"] = resolved.text_color
    rc["xtick.color"] = resolved.secondary_text_color
    rc["ytick.color"] = resolved.secondary_text_color
    rc["legend.facecolor"] = resolved.overlay_color
    rc["legend.labelcolor"] = resolved.secondary_text_color

    old = {k: mpl.rcParams[k] for k in rc if k in mpl.rcParams}
    # Always capture prop_cycle so themes without color_cycle restore it
    if "axes.prop_cycle" not in old:
        old["axes.prop_cycle"] = mpl.rcParams["axes.prop_cycle"]
    prev_theme = _active_theme
    _active_theme = resolved
    mpl.rcParams.update(rc)
    try:
        yield
    finally:
        _active_theme = prev_theme
        mpl.rcParams.update(old)

plot_quiver(data, field, *, plane=None, color=None, color_field=None, units=None, coord_units=None, alpha=1.0, theme=None, cmap=None, vmin=None, vmax=None, stride=1, scale=None, title=None, step=None, time=None, ax=None, colorbar=True, extremes='semi', legend=True, badge=False, save=None, figsize=None, **kwargs)

Plot a quiver (arrow) field on a 2D slice.

By default, arrows are colored by in-plane magnitude through a colormap. Pass color (e.g. "black") for uniform-color arrows — useful as overlays on scalar field plots.

If data is 3D and no plane is given, defaults to a midplane slice along the last axis.

Parameters:

Name Type Description Default
data FieldDataset

Input dataset (2D or 3D).

required
field str

Vector field prefix — "B", "V", "J", etc.

required
plane PlaneSelection | None

Plane selection for 3D data. None auto-slices at midplane.

None
color str | None

Uniform arrow color (e.g. "black", "white"). None maps color to magnitude or color_field via colormap.

None
color_field str | None

Scalar field for arrow color. None uses in-plane magnitude. Ignored when color is set.

None
units str | None

Display units for the color field.

None
coord_units str, tuple[str, str], or None

Display units for coordinate axes. A single string applies to both axes; a tuple (x_unit, y_unit) labels each independently.

None
alpha float

Arrow transparency (0 = invisible, 1 = opaque).

1.0
theme PlotTheme | None

Plot theme. None uses DEFAULT.

None
cmap str | Colormap | None

Override automatic colormap selection.

None
vmin float or None

Color limits for the arrow colors. None (default) autoscales. Ignored when color is set.

None
vmax float or None

Color limits for the arrow colors. None (default) autoscales. Ignored when color is set.

None
stride int | tuple[int, int]

Subsample every N grid points. Scalar or (stride_x, stride_y).

1
scale float | None

Quiver scale factor (passed to ax.quiver). None for auto.

None
title str | None

Override auto-generated title.

None
step int | None

Timestep number for the title.

None
time float | None

Simulation time for the title.

None
ax Axes | None

Existing axes to draw on. None creates a new figure.

None
colorbar bool

Whether to add a colorbar. Ignored when color is set.

True
extremes "semi", "transparent", "darken", or None

Colorbar out-of-range indicator style. "semi" (default) uses semi-transparent extension colors; "transparent" hides them; "darken" darkens the endpoint colors; None leaves matplotlib defaults untouched.

'semi'
legend bool or str

When color is set (uniform mode), add a vector legend overlay. True uses the field prefix as label, a string overrides it. False disables. Ignored when using colormap mode.

True
figsize tuple[float, float] | None

Figure size override.

None
**kwargs Any

Passed to ax.quiver() (headwidth, headlength, headaxislength, pivot, minshaft, minlength, units, angles, width, etc.).

{}
badge str or None

Corner badge text (run label, timestamp, ...). None (default) draws no badge.

False
save str or Path or None

Path to write the figure to. When given, the figure is saved and closed; when None (default) it is left open for the caller to display or modify further.

None

Returns:

Type Description
tuple[Figure, Axes]
Source code in src/pypic/plotting/vectors.py
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
def plot_quiver(
    data: FieldDataset,
    field: str,
    *,
    plane: PlaneSelection | None = None,
    color: str | None = None,
    color_field: str | None = None,
    units: str | None = None,
    coord_units: str | tuple[str, str] | None = None,
    alpha: float = 1.0,
    theme: ThemeArg = None,
    cmap: str | Colormap | None = None,
    vmin: float | None = None,
    vmax: float | None = None,
    stride: int | tuple[int, int] = 1,
    scale: float | None = None,
    title: str | None = None,
    step: int | None = None,
    time: float | None = None,
    ax: Axes | None = None,
    colorbar: bool | Literal["inset"] = True,
    extremes: ExtremesMode = "semi",
    legend: bool | str = True,
    badge: bool = False,
    save: str | None = None,
    figsize: tuple[float, float] | None = None,
    **kwargs: Any,  # noqa: ANN401 — quiver passthrough
) -> tuple[Figure, Axes]:
    r"""Plot a quiver (arrow) field on a 2D slice.

    By default, arrows are colored by in-plane magnitude through a
    colormap. Pass *color* (e.g. ``"black"``) for uniform-color
    arrows — useful as overlays on scalar field plots.

    If *data* is 3D and no *plane* is given, defaults to a midplane
    slice along the last axis.

    Parameters
    ----------
    data : FieldDataset
        Input dataset (2D or 3D).
    field : str
        Vector field prefix — ``"B"``, ``"V"``, ``"J"``, etc.
    plane : PlaneSelection | None
        Plane selection for 3D data. ``None`` auto-slices at midplane.
    color : str | None
        Uniform arrow color (e.g. ``"black"``, ``"white"``).
        ``None`` maps color to magnitude or *color_field* via colormap.
    color_field : str | None
        Scalar field for arrow color. ``None`` uses in-plane magnitude.
        Ignored when *color* is set.
    units : str | None
        Display units for the color field.
    coord_units : str, tuple[str, str], or None
        Display units for coordinate axes. A single string applies to
        both axes; a tuple ``(x_unit, y_unit)`` labels each independently.
    alpha : float
        Arrow transparency (0 = invisible, 1 = opaque).
    theme : PlotTheme | None
        Plot theme. ``None`` uses ``DEFAULT``.
    cmap : str | Colormap | None
        Override automatic colormap selection.
    vmin, vmax : float or None
        Color limits for the arrow colors. ``None`` (default) autoscales.
        Ignored when *color* is set.
    stride : int | tuple[int, int]
        Subsample every N grid points. Scalar or ``(stride_x, stride_y)``.
    scale : float | None
        Quiver scale factor (passed to ``ax.quiver``). ``None`` for auto.
    title : str | None
        Override auto-generated title.
    step : int | None
        Timestep number for the title.
    time : float | None
        Simulation time for the title.
    ax : Axes | None
        Existing axes to draw on. ``None`` creates a new figure.
    colorbar : bool
        Whether to add a colorbar. Ignored when *color* is set.
    extremes : "semi", "transparent", "darken", or None
        Colorbar out-of-range indicator style. ``"semi"`` (default)
        uses semi-transparent extension colors; ``"transparent"``
        hides them; ``"darken"`` darkens the endpoint colors;
        ``None`` leaves matplotlib defaults untouched.
    legend : bool or str
        When *color* is set (uniform mode), add a vector legend overlay.
        ``True`` uses the field prefix as label, a string overrides it.
        ``False`` disables. Ignored when using colormap mode.
    figsize : tuple[float, float] | None
        Figure size override.
    **kwargs
        Passed to ``ax.quiver()`` (``headwidth``, ``headlength``,
        ``headaxislength``, ``pivot``, ``minshaft``, ``minlength``,
        ``units``, ``angles``, ``width``, etc.).
    badge : str or None
        Corner badge text (run label, timestamp, ...). ``None``
        (default) draws no badge.
    save : str or Path or None
        Path to write the figure to. When given, the figure is saved
        and closed; when ``None`` (default) it is left open for the
        caller to display or modify further.

    Returns
    -------
    tuple[Figure, Axes]
    """
    ensure_matplotlib()

    owns_figure = ax is None
    theme = _resolve_theme_arg(theme)
    inputs = _vector_prelude(
        data,
        field,
        plane=plane,
        color=color,
        color_field=color_field,
        units=units,
        theme=theme,
        cmap=cmap,
    )
    data, u, v, color_values = inputs.data, inputs.u, inputs.v, inputs.color_values
    coords = data.grid.coordinate_arrays()

    # Stride dispatch
    match stride:
        case int():
            s0, s1 = stride, stride
        case (s0, s1):
            pass

    x_sub = coords[0][::s0]
    y_sub = coords[1][::s1]
    u_sub = u[::s0, ::s1]
    v_sub = v[::s0, ::s1]

    xx, yy = np.meshgrid(x_sub, y_sub, indexing="ij")

    with use_theme(theme):
        fig, ax = get_or_create_axes(theme, ax, figsize)

        if color_values is not None:
            from matplotlib.colors import Normalize

            quiv = ax.quiver(
                xx.T,
                yy.T,
                u_sub.T,
                v_sub.T,
                color_values[::s0, ::s1].T,
                cmap=inputs.colormap,
                norm=Normalize(vmin=vmin, vmax=vmax),
                scale=scale,
                alpha=alpha,
                **kwargs,
            )
        else:
            quiv = ax.quiver(
                xx.T,
                yy.T,
                u_sub.T,
                v_sub.T,
                color=color,
                scale=scale,
                alpha=alpha,
                **kwargs,
            )

        if color_values is not None and colorbar:
            cb_label = field_label(inputs.info, unit_str=units or "")
            attach_colorbar(fig, ax, quiv, cb_label, colorbar, extremes=extremes)
        if color is not None and legend is not False:
            entry = LegendEntry(
                label=legend if isinstance(legend, str) else field,
                color=color,
                alpha=alpha,
            )
            add_legend(ax, entry)

        if title is None:
            title = figure_title(inputs.info, step=step, time=time)
        xlabel, ylabel = plane_axis_labels(data, coord_units)
        finish_axes(
            fig,
            ax,
            theme,
            owns_figure=owns_figure,
            xlabel=xlabel,
            ylabel=ylabel,
            title=title,
            aspect="equal",
            badge=badge,
            step=step,
            time=time,
        )

    maybe_save(fig, save)
    return fig, ax

plot_streamlines(data, field, *, plane=None, color=None, color_field=None, units=None, coord_units=None, alpha=1.0, theme=None, cmap=None, vmin=None, vmax=None, density=1.5, downsample=1, smooth=None, magnitude_min=None, magnitude_max=None, linewidth=None, arrowsize=1.0, arrowstyle='-|>', title=None, step=None, time=None, ax=None, colorbar=True, extremes='semi', legend=True, badge=False, save=None, figsize=None, **kwargs)

Plot streamlines of a 2D vector field.

By default, lines are colored by in-plane magnitude through a colormap. Pass color (e.g. "black") for uniform-color streamlines — useful as overlays on scalar field plots.

If data is 3D and no plane is given, defaults to a midplane slice along the last axis.

Parameters:

Name Type Description Default
data FieldDataset

Input dataset (2D or 3D).

required
field str

Vector field prefix — "B", "V", "J", etc.

required
plane PlaneSelection | None

Plane selection for 3D data. None auto-slices at midplane.

None
color str | None

Uniform line color (e.g. "black", "white", "#3399ff"). None maps color to magnitude or color_field via colormap.

None
color_field str | None

Scalar field for line color (e.g. "|B|", "beta"). None uses in-plane magnitude. Ignored when color is set.

None
units str | None

Display units for the color field.

None
coord_units str, tuple[str, str], or None

Display units for coordinate axes. A single string applies to both axes; a tuple (x_unit, y_unit) labels each independently.

None
alpha float

Line and arrow transparency (0 = invisible, 1 = opaque).

1.0
theme PlotTheme | None

Plot theme. None uses DEFAULT.

None
cmap str | Colormap | None

Override automatic colormap selection.

None
density float

Streamline density (passed to ax.streamplot).

1.5
downsample int

Downsample the vector grid by this factor before tracing streamlines. Values > 1 speed up streamplot significantly on large grids with no visible difference.

1
smooth float or None

Gaussian smoothing sigma in grid cells, applied to the vector components before tracing. Suppresses grid-scale noise (useful for PIC moment data). None disables smoothing.

None
linewidth float | tuple[float, float] | None

Fixed linewidth, or (min, max) tuple to scale by magnitude. None defaults to (0.5, 2.0) scaled by magnitude.

None
arrowsize float

Arrow size scaling for streamplot.

1.0
arrowstyle str

Arrow style string (default "-|>"). Common alternatives: "->" (thinner), "fancy", "simple".

'-|>'
title str | None

Override auto-generated title.

None
step int | None

Timestep number for the title.

None
time float | None

Simulation time for the title.

None
ax Axes | None

Existing axes to draw on. None creates a new figure.

None
colorbar bool

Whether to add a colorbar. Ignored when color is set.

True
extremes "semi", "transparent", "darken", or None

Colorbar out-of-range indicator style. "semi" (default) uses semi-transparent extension colors; "transparent" hides them; "darken" darkens the endpoint colors; None leaves matplotlib defaults untouched.

'semi'
legend bool or str

When color is set (uniform mode), add a vector legend overlay. True uses the field prefix as label, a string overrides it. False disables. Ignored when using colormap mode.

True
figsize tuple[float, float] | None

Figure size override.

None
**kwargs Any

Passed to ax.streamplot() (minlength, maxlength, start_points, integration_direction, broken_streamlines, etc.).

{}
vmin float or None

Lower color limit. None (default) autoscales.

None
vmax float or None

Upper color limit. None (default) autoscales.

None
magnitude_min float or None

Lower bound on vector magnitude; weaker vectors are masked out. None (default) keeps all.

None
magnitude_max float or None

Upper bound on vector magnitude; stronger vectors are masked out. None (default) keeps all.

None
badge str or None

Corner badge text (run label, timestamp, ...). None (default) draws no badge.

False
save str or Path or None

Path to write the figure to. When given, the figure is saved and closed; when None (default) it is left open for the caller to display or modify further.

None

Returns:

Type Description
tuple[Figure, Axes]
Source code in src/pypic/plotting/vectors.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
def plot_streamlines(
    data: FieldDataset,
    field: str,
    *,
    plane: PlaneSelection | None = None,
    color: str | None = None,
    color_field: str | None = None,
    units: str | None = None,
    coord_units: str | tuple[str, str] | None = None,
    alpha: float = 1.0,
    theme: ThemeArg = None,
    cmap: str | Colormap | None = None,
    vmin: float | None = None,
    vmax: float | None = None,
    density: float = 1.5,
    downsample: int = 1,
    smooth: float | None = None,
    magnitude_min: float | None = None,
    magnitude_max: float | None = None,
    linewidth: float | tuple[float, float] | None = None,
    arrowsize: float = 1.0,
    arrowstyle: str = "-|>",
    title: str | None = None,
    step: int | None = None,
    time: float | None = None,
    ax: Axes | None = None,
    colorbar: bool | Literal["inset"] = True,
    extremes: ExtremesMode = "semi",
    legend: bool | str = True,
    badge: bool = False,
    save: str | None = None,
    figsize: tuple[float, float] | None = None,
    **kwargs: Any,  # noqa: ANN401 — streamplot passthrough
) -> tuple[Figure, Axes]:
    r"""Plot streamlines of a 2D vector field.

    By default, lines are colored by in-plane magnitude through a
    colormap. Pass *color* (e.g. ``"black"``) for uniform-color
    streamlines — useful as overlays on scalar field plots.

    If *data* is 3D and no *plane* is given, defaults to a midplane
    slice along the last axis.

    Parameters
    ----------
    data : FieldDataset
        Input dataset (2D or 3D).
    field : str
        Vector field prefix — ``"B"``, ``"V"``, ``"J"``, etc.
    plane : PlaneSelection | None
        Plane selection for 3D data. ``None`` auto-slices at midplane.
    color : str | None
        Uniform line color (e.g. ``"black"``, ``"white"``, ``"#3399ff"``).
        ``None`` maps color to magnitude or *color_field* via colormap.
    color_field : str | None
        Scalar field for line color (e.g. ``"|B|"``, ``"beta"``).
        ``None`` uses in-plane magnitude. Ignored when *color* is set.
    units : str | None
        Display units for the color field.
    coord_units : str, tuple[str, str], or None
        Display units for coordinate axes. A single string applies to
        both axes; a tuple ``(x_unit, y_unit)`` labels each independently.
    alpha : float
        Line and arrow transparency (0 = invisible, 1 = opaque).
    theme : PlotTheme | None
        Plot theme. ``None`` uses ``DEFAULT``.
    cmap : str | Colormap | None
        Override automatic colormap selection.
    density : float
        Streamline density (passed to ``ax.streamplot``).
    downsample : int
        Downsample the vector grid by this factor before tracing
        streamlines. Values > 1 speed up ``streamplot`` significantly
        on large grids with no visible difference.
    smooth : float or None
        Gaussian smoothing sigma in grid cells, applied to the vector
        components before tracing. Suppresses grid-scale noise (useful
        for PIC moment data). ``None`` disables smoothing.
    linewidth : float | tuple[float, float] | None
        Fixed linewidth, or ``(min, max)`` tuple to scale by magnitude.
        ``None`` defaults to ``(0.5, 2.0)`` scaled by magnitude.
    arrowsize : float
        Arrow size scaling for streamplot.
    arrowstyle : str
        Arrow style string (default ``"-|>"``). Common alternatives:
        ``"->"`` (thinner), ``"fancy"``, ``"simple"``.
    title : str | None
        Override auto-generated title.
    step : int | None
        Timestep number for the title.
    time : float | None
        Simulation time for the title.
    ax : Axes | None
        Existing axes to draw on. ``None`` creates a new figure.
    colorbar : bool
        Whether to add a colorbar. Ignored when *color* is set.
    extremes : "semi", "transparent", "darken", or None
        Colorbar out-of-range indicator style. ``"semi"`` (default)
        uses semi-transparent extension colors; ``"transparent"``
        hides them; ``"darken"`` darkens the endpoint colors;
        ``None`` leaves matplotlib defaults untouched.
    legend : bool or str
        When *color* is set (uniform mode), add a vector legend overlay.
        ``True`` uses the field prefix as label, a string overrides it.
        ``False`` disables. Ignored when using colormap mode.
    figsize : tuple[float, float] | None
        Figure size override.
    **kwargs
        Passed to ``ax.streamplot()`` (``minlength``, ``maxlength``,
        ``start_points``, ``integration_direction``,
        ``broken_streamlines``, etc.).
    vmin : float or None
        Lower color limit. ``None`` (default) autoscales.
    vmax : float or None
        Upper color limit. ``None`` (default) autoscales.
    magnitude_min : float or None
        Lower bound on vector magnitude; weaker vectors are
        masked out. ``None`` (default) keeps all.
    magnitude_max : float or None
        Upper bound on vector magnitude; stronger vectors are
        masked out. ``None`` (default) keeps all.
    badge : str or None
        Corner badge text (run label, timestamp, ...). ``None``
        (default) draws no badge.
    save : str or Path or None
        Path to write the figure to. When given, the figure is saved
        and closed; when ``None`` (default) it is left open for the
        caller to display or modify further.

    Returns
    -------
    tuple[Figure, Axes]
    """
    ensure_matplotlib()

    owns_figure = ax is None
    theme = _resolve_theme_arg(theme)
    inputs = _vector_prelude(
        data,
        field,
        plane=plane,
        color=color,
        color_field=color_field,
        units=units,
        theme=theme,
        cmap=cmap,
    )
    data, u, v, color_values = inputs.data, inputs.u, inputs.v, inputs.color_values
    magnitude = np.hypot(u, v)
    coords = data.grid.coordinate_arrays()

    # Linewidth dispatch
    match linewidth:
        case None:
            lw_range: tuple[float, float] | None = (0.5, 2.0)
        case (lo, hi):
            lw_range = (lo, hi)
        case _:
            lw_range = None

    if lw_range is not None:
        lw_mid = 0.5 * (lw_range[0] + lw_range[1])
        mag_min, mag_max = float(np.nanmin(magnitude)), float(np.nanmax(magnitude))
        if mag_max > mag_min:
            lw_scaled = lw_range[0] + (lw_range[1] - lw_range[0]) * (
                (magnitude - mag_min) / (mag_max - mag_min)
            )
            lw_scaled = np.nan_to_num(lw_scaled, nan=lw_mid)
        else:
            lw_scaled = np.full_like(magnitude, lw_mid)
        lw_arg = lw_scaled.T
    else:
        lw_arg = linewidth  # type: ignore[assignment]

    # Smooth vector components to suppress grid-scale noise
    if smooth is not None and smooth > 0:
        from scipy.ndimage import gaussian_filter

        nan_mask = np.isnan(u) | np.isnan(v)
        u = gaussian_filter(np.nan_to_num(u, nan=0.0), sigma=smooth)
        v = gaussian_filter(np.nan_to_num(v, nan=0.0), sigma=smooth)
        u[nan_mask] = np.nan
        v[nan_mask] = np.nan

    # Downsample vector grid for faster streamline tracing
    s = downsample
    if s > 1:
        coords = (coords[0][::s], coords[1][::s])
        u = u[::s, ::s]
        v = v[::s, ::s]
        if color_values is not None:
            color_values = color_values[::s, ::s]
        if isinstance(lw_arg, np.ndarray):
            lw_arg = lw_arg[::s, ::s]

    # Zero out vectors outside the magnitude range so streamplot skips them.
    # When units is provided, thresholds are in display units — convert to
    # code units via the same factor used by in_units().
    if magnitude_min is not None or magnitude_max is not None:
        if units is not None:
            comp_u = inputs.components[0]
            code = resolve_field_values(data, comp_u, None)
            display = resolve_field_values(data, comp_u, units)
            nonzero = np.abs(code) > 0
            if np.any(nonzero):
                scale = float(np.nanmedian(display[nonzero] / code[nonzero]))
            else:
                scale = 1.0
            if magnitude_min is not None:
                magnitude_min = magnitude_min / scale
            if magnitude_max is not None:
                magnitude_max = magnitude_max / scale

        mag = np.sqrt(u**2 + v**2)
        mask = np.ones_like(mag, dtype=bool)
        if magnitude_min is not None:
            mask &= mag >= magnitude_min
        if magnitude_max is not None:
            mask &= mag <= magnitude_max
        u = np.where(mask, u, 0.0)
        v = np.where(mask, v, 0.0)

    with use_theme(theme):
        fig, ax = get_or_create_axes(theme, ax, figsize)

        if color_values is not None:
            from matplotlib.colors import Normalize

            norm = (
                Normalize(vmin=vmin, vmax=vmax)
                if vmin is not None or vmax is not None
                else None
            )
            stream = ax.streamplot(
                coords[0],
                coords[1],
                u.T,
                v.T,
                color=color_values.T,
                cmap=inputs.colormap,
                norm=norm,
                density=density,
                linewidth=lw_arg,
                arrowsize=arrowsize,
                arrowstyle=arrowstyle,
                **kwargs,
            )
        else:
            stream = ax.streamplot(
                coords[0],
                coords[1],
                u.T,
                v.T,
                color=color,
                density=density,
                linewidth=lw_arg,
                arrowsize=arrowsize,
                arrowstyle=arrowstyle,
                **kwargs,
            )

        if alpha < 1.0:
            stream.lines.set_alpha(alpha)
            # stream.arrows.set_alpha doesn't work — arrows are individual
            # FancyArrowPatch children
            from matplotlib.patches import FancyArrowPatch

            for child in ax.get_children():
                if isinstance(child, FancyArrowPatch):
                    child.set_alpha(alpha)

        if color_values is not None and colorbar:
            cb_label = field_label(inputs.info, unit_str=units or "")
            attach_colorbar(
                fig, ax, stream.lines, cb_label, colorbar, extremes=extremes
            )
        if color is not None and legend is not False:
            default_lw: float = _theme_val("line_width", 1.0)
            lw = linewidth if isinstance(linewidth, (int, float)) else default_lw
            entry = LegendEntry(
                label=legend if isinstance(legend, str) else field,
                color=color,
                linewidth=lw,
                alpha=alpha,
            )
            add_legend(ax, entry)

        if title is None:
            title = figure_title(inputs.info, step=step, time=time)
        xlabel, ylabel = plane_axis_labels(data, coord_units)
        finish_axes(
            fig,
            ax,
            theme,
            owns_figure=owns_figure,
            xlabel=xlabel,
            ylabel=ylabel,
            title=title,
            aspect="equal",
            badge=badge,
            step=step,
            time=time,
        )

    maybe_save(fig, save)
    return fig, ax