🌀 New Feature: Curved Quiver #370
cvanelteren
announced in
Announcements
Replies: 1 comment
-
Code# %%
"""
Concise, well-commented example: curved_quiver on a geo wind map
What this does:
- Downloads near-surface winds (U/V) via OPeNDAP (NOAA NCEP/NCAR Reanalysis).
- Prepares a rectilinear lon/lat grid ([-180, 180]) and ensures lat is ascending.
- Computes speed for coloring, and places curved quiver arrows along local flow.
- Draws a PlateCarree map with a translucent speed background and curved arrows.
How to zoom/punch-in:
- Edit lon_bounds / lat_bounds below to focus on a region of interest.
- Optionally reduce/increase grains and scale to tune curvature fidelity.
Requires:
- xarray, numpy, cartopy, and ultraplot
- If cartopy is missing: conda install -c conda-forge cartopy
"""
import numpy as np
import xarray as xr
import ultraplot as uplt
import cartopy.crs as ccrs
import cartopy.feature as cfeature
# ---------------------------------------------------------------------
# 1) Load dataset: U/V winds (near-surface) via OPeNDAP
# ---------------------------------------------------------------------
# Notes:
# - Using 2025 sigma-level 0.995 winds, which behave like 10 m winds.
# - You can swap the year in the URLs or replace with ERA5/GFS sources.
u_url = "https://psl.noaa.gov/thredds/dodsC/Datasets/ncep.reanalysis/surface/uwnd.sig995.2025.nc"
v_url = "https://psl.noaa.gov/thredds/dodsC/Datasets/ncep.reanalysis/surface/vwnd.sig995.2025.nc"
ds_u = xr.open_dataset(u_url)
ds_v = xr.open_dataset(v_url)
ds = xr.merge([ds_u, ds_v]) # variables: uwnd, vwnd
# Put lon into [-180, 180] so it aligns naturally with PlateCarree maps
ds = ds.assign_coords(lon=((ds.lon + 180) % 360) - 180).sortby("lon")
# ---------------------------------------------------------------------
# 2) Choose time and region, then gently coarsen to avoid over-plotting
# ---------------------------------------------------------------------
when = "2025-01-15T12:00"
# Edit these to "punch in" on the streams you care about
lon_bounds = (-100, -70)
lat_bounds = (20, 45)
# Many reanalysis datasets have descending latitude; slice with (max, min)
dsi = ds.sel(
time=when,
lat=slice(lat_bounds[1], lat_bounds[0]),
lon=slice(lon_bounds[0], lon_bounds[1]),
)
# Coarsen the grid to keep the plot clean (tweak factors as needed)
U = dsi["uwnd"].coarsen(lat=2, lon=2, boundary="trim").mean()
V = dsi["vwnd"].coarsen(lat=2, lon=2, boundary="trim").mean()
# Ensure latitude is ascending for consistent grid geometry
if U.lat.values[0] > U.lat.values[-1]:
U = U.sortby("lat")
V = V.sortby("lat")
# Compute speed for coloring
speed = np.hypot(U, V)
# 1D rectilinear coordinate vectors (curved_quiver works with these)
lon1d = U.lon.values
lat1d = U.lat.values
U2d = U.values
V2d = V.values
# ---------------------------------------------------------------------
# 3) Seed placement: use cell centers to keep seeds strictly interior
# ---------------------------------------------------------------------
def make_cell_center_seed_points(
x1d: np.ndarray, y1d: np.ndarray, grains: int = 8
) -> np.ndarray:
"""
Create evenly spaced seed points at cell centers so they are strictly interior.
- x1d: 1D longitudes (ascending)
- y1d: 1D latitudes (ascending)
- grains: number of seeds per dimension (capped by number of centers)
"""
# Need at least two points to define cell centers
if x1d.size < 2 or y1d.size < 2:
# Fallback: build interior linspace if the grid is too small
eps = 1e-9
xs = np.linspace(float(x1d.min()) + eps, float(x1d.max()) - eps, max(1, grains))
ys = np.linspace(float(y1d.min()) + eps, float(y1d.max()) - eps, max(1, grains))
Xs, Ys = np.meshgrid(xs, ys)
return np.c_[Xs.ravel(), Ys.ravel()]
xcent = 0.5 * (x1d[:-1] + x1d[1:])
ycent = 0.5 * (y1d[:-1] + y1d[1:])
nx = int(np.clip(grains, 1, xcent.size))
ny = int(np.clip(grains, 1, ycent.size))
xi = np.linspace(0, xcent.size - 1, nx).astype(int)
yi = np.linspace(0, ycent.size - 1, ny).astype(int)
Xs, Ys = np.meshgrid(xcent[xi], ycent[yi])
return np.c_[Xs.ravel(), Ys.ravel()]
# Curved quiver parameters:
# - grains controls curvature fidelity (integration resolution), not seed count here
# - scale controls arc length (how far to "bend")
cq_grains = 8
cq_scale = 2.0
seeds = make_cell_center_seed_points(lon1d, lat1d, grains=8)
# ---------------------------------------------------------------------
# 4) Plot: PlateCarree map with a translucent speed background + curved arrows
# ---------------------------------------------------------------------
fig, ax = uplt.subplots(projection=ccrs.PlateCarree(), figsize=(9, 6))
# Set the visible extent in lon/lat (PlateCarree)
ax.set_extent(
[lon_bounds[0], lon_bounds[1], lat_bounds[0], lat_bounds[1]], crs=ccrs.PlateCarree()
)
# Add simple map context
ax.add_feature(cfeature.LAND.with_scale("50m"), facecolor="#f1f1f1", edgecolor="none")
ax.add_feature(cfeature.COASTLINE.with_scale("50m"), linewidth=0.8)
ax.add_feature(cfeature.BORDERS.with_scale("50m"), linewidth=0.5)
# Translucent speed magnitude background (1D x/y with shading='auto' works well)
ax.pcolormesh(
lon1d,
lat1d,
speed.values,
cmap="viko",
alpha=0.35,
shading="auto",
transform=ccrs.PlateCarree(),
)
# Curved quiver: lon/lat coords with PlateCarree transform
cq = ax.curved_quiver(
lon1d,
lat1d,
U2d,
V2d,
color=speed.values, # color arrows by magnitude
cmap="viko",
arrow_at_end=True,
arrowsize=1.2,
grains=cq_grains,
scale=cq_scale,
start_points=seeds, # strictly interior seeds at cell centers
transform=ccrs.PlateCarree(),
)
# Colorbar for curved arrows (use the lines handle)
fig.colorbar(cq.lines, ax=ax, label="Near-surface wind speed (m/s)")
# Labels and title
ax.set_title(
f"NCEP/NCAR Reanalysis — Near-surface winds — {np.datetime_as_string(np.datetime64(when), unit='m')}"
)
ax.format(lonlabels=True, latlabels=True, xlabel="Longitude", ylabel="Latitude")
# Save and/or show
fig.savefig("wind_curved_quiver_map.png", dpi=150)
# fig.show() # uncomment if running interactively
# %%
|
Beta Was this translation helpful? Give feedback.
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment

Uh oh!
There was an error while loading. Please reload this page.
-
This release introduces
curved_quiver, a new plotting primitive that renders compact, curved arrows following the local direction of a vector field. It’s designed to bridge the gap betweenquiver(straight, local glyphs) andstreamplot(continuous, global trajectories): you retain the discrete arrow semantics ofquiver, but you gain local curvature that more faithfully communicates directional change.What it does
Under the hood, the implementation follows the same robust foundations as
matplotlib’s streamplot, adapted to generate short, curved arrow segments instead of full streamlines. As such it can be seen as in betweenstreamplotandquiverplots, see figure below and above.The core types live in
ultraplot/axes/plot_types/curved_quiver.pyand are centered onCurvedQuiverSolver, which coordinates grid/coordinate mapping, seed point generation, trajectory integration, and spacing control:_CurvedQuiverGridvalidates and models the input grid. It ensures the x grid is rectilinear with equal rows and the y grid with equal columns, computesdx/dy, and exposes grid shape and extent. This meanscurved_quiveris designed for rectilinear grids where rows/columns ofx/yare consistent, matching the expectations of stream/line-based vector plotting._DomainMapmaintains transformations among data-, grid-, and mask-coordinates. Velocity components are rescaled into grid-coordinates for integration, and speed is normalized to axes-coordinates so that step sizes and error metrics align with the visual output (this is important for smooth curves at different figure sizes and grid densities). It also owns bookkeeping for the spacing mask._StreamMaskenforces spacing between trajectories at a coarse mask resolution, much likestreamplotspacing. As a trajectory advances, the mask is filled where the curve passes, preventing new trajectories from entering already-occupied cells. This avoids over-plotting and stabilizes density in a way that feels consistent withstreamplotoutput while still generating discrete arrows.Integration is handled by a second-order Runge–Kutta method with adaptive step sizing, implemented in
CurvedQuiverSolver.integrate_rk12. This “improved Euler” approach is chosen for a balance of speed and visual smoothness. It uses an error metric in axes-coordinates to adapt the step sizeds. A maximum step (maxds) is also enforced to prevent skipping mask cells. The integration proceeds forward from each seed point, terminating when any of the following hold: the curve exits the domain, an intermediate integration step would go out of bounds (in which case a single Euler step to the boundary is taken for neatness), a local zero-speed region is detected, or the path reaches the target arc length set by the visual resolution. Internally, that arc length is bounded by a threshold proportional to the mean of the sampled magnitudes along the curve, which is howscaleeffectively maps to a “how far to bend” control in physical units.Seed points are generated uniformly over the data extent via
CurvedQuiverSolver.gen_starting_points, usinggrains × grainspositions. Increasinggrainsincreases the number of potential arrow locations and produces smoother paths because more micro-steps are used to sample curvature. During integration, the solver marks the mask progressively via_DomainMap.update_trajectory, and very short trajectories are rejected with_DomainMap.undo_trajectory()to avoid clutter.The final artist returned to you is a
CurvedQuiverSet(a small dataclass aligned withmatplotlib.streamplot.StreamplotSet) exposinglines(the curved paths) andarrows(the arrowheads). This mirrors familiarstreamplotergonomics. For example, you can attach a colorbar to.lines, as shown in the figures.From a user perspective, you call
ax.curved_quiver(X, Y, U, V, ...)just as you wouldquiver, optionally passingcoloras a scalar field to map magnitude,cmapfor color mapping,arrow_at_end=Trueandarrowsizeto emphasize direction, and the two most impactful shape controls:grainsandscale. Usecurved_quiverwhen you want to reveal local turning behavior—vortices, shear zones, near saddles, or flow deflection around obstacles—without committing to global streamlines. If your field is highly curved in localized pockets where straight arrows are misleading butstreamplotfeels too continuous or dense,curved_quiveris the right middle ground.Performance
Performance-wise, runtime scales with the number of glyphs and the micro-steps (
grains). The default values are a good balance for most grids; for very dense fields, you can either reducegrainsor down-sample the input grid. The API is fully additive and doesn’t introduce any breaking changes, and it integrates with existing colorbar and colormap workflows.Parameters
There are two main parameters that affect the plots visually. The
grainsparameters controls the density of the grid by interpolating between the input grid. Setting a higher grid will fill the space with more streams. See for a full function description the documentation.The
sizeparameter will multiply the magnitude of the stream. Setting this value higher will make it look more similar tostreamplot.Acknowledgements
Special thanks to @veenstrajelmer for his implementation (https://github.com/Deltares/dfm_tools) and @Yefee for his suggestion to add this to UltraPlot!
What's Changed
curved_quiver— Curved Vector Field Arrows for 2D Plots by @cvanelteren in Addcurved_quiver— Curved Vector Field Arrows for 2D Plots #361Suggestions or feedback
Do you have suggestion or feedback? Checkout our discussion on this release.
Full Changelog: v1.62.0...v1.63.0
This discussion was created from the release 🌀 New Feature: Curved Quiver.
Beta Was this translation helpful? Give feedback.
All reactions