Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4a2d95c
fix(analysis): compose vector fields onto a caller ax instead of clea…
MAfarrag Sep 20, 2026
e58f234
test(analysis): cover the ascending-y branch of plot_vector_field
MAfarrag Sep 20, 2026
36c8621
docs(analysis): correct plot_vector_field's orientation assumption
MAfarrag Sep 20, 2026
b3bd360
docs(analysis): note solid-colour cmap recipe, thin scope, and repeat…
MAfarrag Sep 20, 2026
ceb41fd
test(analysis): harden plot_vector_field compose/placement tests (rev…
MAfarrag Sep 20, 2026
d441426
test(analysis): assert vector direction on the ascending-y no-flip path
MAfarrag Sep 20, 2026
668ce80
docs(analysis): note a bare color= is dropped; use cmap for solid arrows
MAfarrag Sep 20, 2026
6243135
feat(analysis): accept a solid color= for plot_vector_field arrows
MAfarrag Sep 20, 2026
6a303db
test(analysis): hoist cmap out of the pytest.raises block (SonarCloud…
MAfarrag Sep 20, 2026
3a3fa27
style(analysis): reflow the conflict-test call after hoisting the cmap
MAfarrag Sep 20, 2026
fbe3a8d
docs(analysis): document the color= ValueError and add a solid-colour…
MAfarrag Sep 20, 2026
6785d08
fix(analysis): validate color= at the plot_vector_field boundary (rev…
MAfarrag Sep 20, 2026
94dfe38
fix(analysis): suppress the magnitude colorbar for a solid color= (re…
MAfarrag Sep 20, 2026
a4d1f0a
docs(analysis): color= covers the whole field; note the local matplot…
MAfarrag Sep 20, 2026
0899e5c
fix(analysis): color= conflict keys on a real cmap, and validate befo…
MAfarrag Sep 20, 2026
d07c2e2
docs(analysis): document the invalid-color raise and the solid-vs-Col…
MAfarrag Sep 20, 2026
ec9b85e
test(analysis): cover solid color= across quiver / barbs / streamplot
MAfarrag Sep 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 83 additions & 15 deletions src/pyramids/dataset/engines/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -3694,13 +3694,15 @@ def plot_vector_field(
extra.

The grid is taken from the dataset's 1-D ``x``/``y`` cell-centre
arrays, so an **axis-aligned (north-up, unrotated)** geotransform is
assumed — as elsewhere in pyramids' extent-based plotting. ``v`` is
treated as the northward (``+y``) component. Because ``streamplot``
requires strictly-increasing coordinates while a north-up raster's
``y`` is descending, the axis is flipped to ascending and the data
rows/cols are mirrored to match; this is a pure relabelling, so each
vector stays at its true location for every ``kind``.
arrays, so an **axis-aligned (unrotated)** geotransform is assumed —
the rotation terms are ignored, as elsewhere in pyramids' extent-based
plotting. Orientation is handled, though: ``v`` is treated as the
northward (``+y``) component, and because ``streamplot`` requires
strictly-increasing coordinates, a descending ``x``/``y`` (e.g. a
north-up raster's ``y``) is flipped to ascending with the data
rows/cols mirrored to match — a pure relabelling, so each vector keeps
its true location for every ``kind``, while an already-ascending
(south-up) axis is left as-is.

Args:
u_band (int, optional):
Expand All @@ -3713,14 +3715,33 @@ def plot_vector_field(
ax (matplotlib.axes.Axes, optional):
Draw the vector field into these axes instead of creating them, which is
what lets it be composed onto a shared map (pair it with
``add_colorbar=False``). An axes already carries its figure, so ``ax`` on
its own is sufficient and there is no separate ``fig`` parameter here. A
new figure/axes is created when left unset. Default is ``None``.
``add_colorbar=False``). Any layers already on the axes — e.g. a scalar
:meth:`plot` drawn first — are **preserved**, and the arrows are drawn on
top rather than clearing them. Because the host is preserved, calling
``plot_vector_field`` again on the same ``ax`` **adds** another field on
top rather than replacing the previous one; start from a fresh axes to
redraw. An axes already carries its figure, so ``ax`` on its own is
sufficient and there is no separate ``fig`` parameter here. A new
figure/axes is created when left unset. Default is ``None``.
**kwargs:
Style options forwarded to the ``VectorGlyph`` constructor,
filtered via :meth:`VectorGlyph.filter_kwargs` (e.g.
``density``, ``scale``, ``cmap``, ``add_colorbar``). Pass
``add_colorbar=False`` when composing onto a shared map.
``density``, ``scale``, ``cmap``, ``add_colorbar``, ``thin``).
``thin=n`` draws every nth grid point so a large ``quiver`` /
``barbs`` grid is not one arrow per cell; it applies to
``quiver`` / ``barbs`` only — ``streamplot`` ignores it (with a
warning), use ``density`` there. Arrows are coloured by vector
magnitude through ``cmap``. For a single **solid** colour pass
``color=`` a matplotlib colour (e.g. ``color="black"``): it is
turned into a one-colour colormap, so the whole field (arrows,
barbs, or streamlines) renders in that colour, and the
otherwise-meaningless magnitude colorbar is suppressed by default
(equivalent to ``cmap=matplotlib.colors.ListedColormap(["black"])``
with ``add_colorbar=False``). ``color=`` and ``cmap=`` are
mutually exclusive. (Unlike :meth:`plot`'s ``color=``, which is a
magnitude ``ColorScaling``, here ``color=`` is a solid matplotlib
colour.) Pass ``add_colorbar=False`` when composing onto a shared
map.

Returns:
tuple:
Expand All @@ -3731,8 +3752,10 @@ def plot_vector_field(

Raises:
ValueError: If ``u_band`` or ``v_band`` is out of range for the
dataset, or if ``kind`` is not one of ``"quiver"``,
``"barbs"``, or ``"streamplot"``.
dataset, if ``kind`` is not one of ``"quiver"``, ``"barbs"``,
or ``"streamplot"``, if both ``color=`` and ``cmap=`` are given
(they are mutually exclusive), or if ``color=`` is not a valid
matplotlib colour.

Examples:
- Render a two-band ``(u, v)`` stack as arrows (tagged ``+SKIP``
Expand All @@ -3756,11 +3779,32 @@ def plot_vector_field(
```python
>>> fig, ax, im = ds.plot_vector_field(kind="streamplot", add_colorbar=False) # doctest: +SKIP

```
- Compose the arrows over a scalar map on a shared axes; the scalar
layer is preserved:

```python
>>> import matplotlib.pyplot as plt # doctest: +SKIP
>>> fig, host = plt.subplots() # doctest: +SKIP
>>> ds.plot(band=0, fig=fig, ax=host) # doctest: +SKIP
>>> ds.plot_vector_field(u_band=0, v_band=1, ax=host, add_colorbar=False) # doctest: +SKIP

```
- Draw solid black arrows instead of colouring them by magnitude:

```python
>>> fig, ax, im = ds.plot_vector_field(u_band=0, v_band=1, color="black") # doctest: +SKIP

```
"""
require_cleopatra()
from cleopatra.glyphs.gridded.vector_glyph import VectorGlyph

# Local ([viz]-extra only): matplotlib ships with cleopatra, so it imports
# once require_cleopatra() above passes; a module-level import would break a
# bare install without the [viz] extra (matplotlib is TYPE_CHECKING-only here).
from matplotlib.colors import ListedColormap, is_color_like

band_count = self._ds.band_count
for name, idx in (("u_band", u_band), ("v_band", v_band)):
validate_band_index(
Expand All @@ -3769,6 +3813,25 @@ def plot_vector_field(
name=name,
hint=(" plot_vector_field needs two in-range bands (u, v components)."),
)
# Solid colour: cleopatra colours the field by magnitude through a
# colormap and has no scalar ``color=`` (its ``color=`` is a magnitude
# ``ColorScaling``), so translate a matplotlib colour (``color="black"``)
# into a one-colour colormap — the whole field (arrows, barbs, or
# streamlines) then renders in that colour. Validated here (cheap,
# data-independent) before the band reads below. The conflict guard keys
# on a real colormap, not presence, so a caller's ``cmap=None`` is fine.
color = kwargs.pop("color", None)
if color is not None:
if kwargs.get("cmap") is not None:
raise ValueError(
"pass either color= (a solid arrow colour) or cmap=, not both"
)
if not is_color_like(color):
raise ValueError(f"color= must be a matplotlib colour, got {color!r}")
kwargs["cmap"] = ListedColormap([color])
# A single colour has no magnitude scale, so a magnitude colorbar
# would be misleading; default it off (an explicit add_colorbar wins).
kwargs.setdefault("add_colorbar", False)
u = self._ds.read_array(band=u_band)
v = self._ds.read_array(band=v_band)
x = self._ds.x
Expand All @@ -3788,7 +3851,12 @@ def plot_vector_field(
v = v[:, ::-1]
xx, yy = np.meshgrid(x, y)
glyph = VectorGlyph(xx, yy, u, v, ax=ax, **VectorGlyph.filter_kwargs(kwargs))
result = glyph.plot(kind=kind)
# A caller-supplied ``ax`` is a host to compose onto (e.g. a scalar map
# drawn first), which is the documented reason the parameter exists. Tell
# cleopatra (>=0.39.0) to keep the host's existing artists instead of
# clearing the axes; when we create our own axes there is nothing to
# preserve, so composition stays off.
result = glyph.plot(kind=kind, compose=ax is not None)
return result

def plot(
Expand Down
Loading
Loading