Coverage for /usr/lib/python3/dist-packages/matplotlib/streamplot.py: 12%
375 statements
« prev ^ index » next coverage.py v7.4.4, created at 2025-06-14 15:25 +0200
« prev ^ index » next coverage.py v7.4.4, created at 2025-06-14 15:25 +0200
1"""
2Streamline plotting for 2D vector fields.
4"""
6import numpy as np
8import matplotlib as mpl
9from matplotlib import _api, cm, patches
10import matplotlib.colors as mcolors
11import matplotlib.collections as mcollections
12import matplotlib.lines as mlines
15__all__ = ['streamplot']
18def streamplot(axes, x, y, u, v, density=1, linewidth=None, color=None,
19 cmap=None, norm=None, arrowsize=1, arrowstyle='-|>',
20 minlength=0.1, transform=None, zorder=None, start_points=None,
21 maxlength=4.0, integration_direction='both',
22 broken_streamlines=True):
23 """
24 Draw streamlines of a vector flow.
26 Parameters
27 ----------
28 x, y : 1D/2D arrays
29 Evenly spaced strictly increasing arrays to make a grid. If 2D, all
30 rows of *x* must be equal and all columns of *y* must be equal; i.e.,
31 they must be as if generated by ``np.meshgrid(x_1d, y_1d)``.
32 u, v : 2D arrays
33 *x* and *y*-velocities. The number of rows and columns must match
34 the length of *y* and *x*, respectively.
35 density : float or (float, float)
36 Controls the closeness of streamlines. When ``density = 1``, the domain
37 is divided into a 30x30 grid. *density* linearly scales this grid.
38 Each cell in the grid can have, at most, one traversing streamline.
39 For different densities in each direction, use a tuple
40 (density_x, density_y).
41 linewidth : float or 2D array
42 The width of the streamlines. With a 2D array the line width can be
43 varied across the grid. The array must have the same shape as *u*
44 and *v*.
45 color : color or 2D array
46 The streamline color. If given an array, its values are converted to
47 colors using *cmap* and *norm*. The array must have the same shape
48 as *u* and *v*.
49 cmap, norm
50 Data normalization and colormapping parameters for *color*; only used
51 if *color* is an array of floats. See `~.Axes.imshow` for a detailed
52 description.
53 arrowsize : float
54 Scaling factor for the arrow size.
55 arrowstyle : str
56 Arrow style specification.
57 See `~matplotlib.patches.FancyArrowPatch`.
58 minlength : float
59 Minimum length of streamline in axes coordinates.
60 start_points : Nx2 array
61 Coordinates of starting points for the streamlines in data coordinates
62 (the same coordinates as the *x* and *y* arrays).
63 zorder : int
64 The zorder of the streamlines and arrows.
65 Artists with lower zorder values are drawn first.
66 maxlength : float
67 Maximum length of streamline in axes coordinates.
68 integration_direction : {'forward', 'backward', 'both'}, default: 'both'
69 Integrate the streamline in forward, backward or both directions.
70 data : indexable object, optional
71 DATA_PARAMETER_PLACEHOLDER
72 broken_streamlines : boolean, default: True
73 If False, forces streamlines to continue until they
74 leave the plot domain. If True, they may be terminated if they
75 come too close to another streamline.
77 Returns
78 -------
79 StreamplotSet
80 Container object with attributes
82 - ``lines``: `.LineCollection` of streamlines
84 - ``arrows``: `.PatchCollection` containing `.FancyArrowPatch`
85 objects representing the arrows half-way along streamlines.
87 This container will probably change in the future to allow changes
88 to the colormap, alpha, etc. for both lines and arrows, but these
89 changes should be backward compatible.
90 """
91 grid = Grid(x, y)
92 mask = StreamMask(density)
93 dmap = DomainMap(grid, mask)
95 if zorder is None:
96 zorder = mlines.Line2D.zorder
98 # default to data coordinates
99 if transform is None:
100 transform = axes.transData
102 if color is None:
103 color = axes._get_lines.get_next_color()
105 if linewidth is None:
106 linewidth = mpl.rcParams['lines.linewidth']
108 line_kw = {}
109 arrow_kw = dict(arrowstyle=arrowstyle, mutation_scale=10 * arrowsize)
111 _api.check_in_list(['both', 'forward', 'backward'],
112 integration_direction=integration_direction)
114 if integration_direction == 'both':
115 maxlength /= 2.
117 use_multicolor_lines = isinstance(color, np.ndarray)
118 if use_multicolor_lines:
119 if color.shape != grid.shape:
120 raise ValueError("If 'color' is given, it must match the shape of "
121 "the (x, y) grid")
122 line_colors = [[]] # Empty entry allows concatenation of zero arrays.
123 color = np.ma.masked_invalid(color)
124 else:
125 line_kw['color'] = color
126 arrow_kw['color'] = color
128 if isinstance(linewidth, np.ndarray):
129 if linewidth.shape != grid.shape:
130 raise ValueError("If 'linewidth' is given, it must match the "
131 "shape of the (x, y) grid")
132 line_kw['linewidth'] = []
133 else:
134 line_kw['linewidth'] = linewidth
135 arrow_kw['linewidth'] = linewidth
137 line_kw['zorder'] = zorder
138 arrow_kw['zorder'] = zorder
140 # Sanity checks.
141 if u.shape != grid.shape or v.shape != grid.shape:
142 raise ValueError("'u' and 'v' must match the shape of the (x, y) grid")
144 u = np.ma.masked_invalid(u)
145 v = np.ma.masked_invalid(v)
147 integrate = _get_integrator(u, v, dmap, minlength, maxlength,
148 integration_direction)
150 trajectories = []
151 if start_points is None:
152 for xm, ym in _gen_starting_points(mask.shape):
153 if mask[ym, xm] == 0:
154 xg, yg = dmap.mask2grid(xm, ym)
155 t = integrate(xg, yg, broken_streamlines)
156 if t is not None:
157 trajectories.append(t)
158 else:
159 sp2 = np.asanyarray(start_points, dtype=float).copy()
161 # Check if start_points are outside the data boundaries
162 for xs, ys in sp2:
163 if not (grid.x_origin <= xs <= grid.x_origin + grid.width and
164 grid.y_origin <= ys <= grid.y_origin + grid.height):
165 raise ValueError("Starting point ({}, {}) outside of data "
166 "boundaries".format(xs, ys))
168 # Convert start_points from data to array coords
169 # Shift the seed points from the bottom left of the data so that
170 # data2grid works properly.
171 sp2[:, 0] -= grid.x_origin
172 sp2[:, 1] -= grid.y_origin
174 for xs, ys in sp2:
175 xg, yg = dmap.data2grid(xs, ys)
176 # Floating point issues can cause xg, yg to be slightly out of
177 # bounds for xs, ys on the upper boundaries. Because we have
178 # already checked that the starting points are within the original
179 # grid, clip the xg, yg to the grid to work around this issue
180 xg = np.clip(xg, 0, grid.nx - 1)
181 yg = np.clip(yg, 0, grid.ny - 1)
183 t = integrate(xg, yg, broken_streamlines)
184 if t is not None:
185 trajectories.append(t)
187 if use_multicolor_lines:
188 if norm is None:
189 norm = mcolors.Normalize(color.min(), color.max())
190 cmap = cm._ensure_cmap(cmap)
192 streamlines = []
193 arrows = []
194 for t in trajectories:
195 tgx, tgy = t.T
196 # Rescale from grid-coordinates to data-coordinates.
197 tx, ty = dmap.grid2data(tgx, tgy)
198 tx += grid.x_origin
199 ty += grid.y_origin
201 points = np.transpose([tx, ty]).reshape(-1, 1, 2)
202 streamlines.extend(np.hstack([points[:-1], points[1:]]))
204 # Add arrows halfway along each trajectory.
205 s = np.cumsum(np.hypot(np.diff(tx), np.diff(ty)))
206 n = np.searchsorted(s, s[-1] / 2.)
207 arrow_tail = (tx[n], ty[n])
208 arrow_head = (np.mean(tx[n:n + 2]), np.mean(ty[n:n + 2]))
210 if isinstance(linewidth, np.ndarray):
211 line_widths = interpgrid(linewidth, tgx, tgy)[:-1]
212 line_kw['linewidth'].extend(line_widths)
213 arrow_kw['linewidth'] = line_widths[n]
215 if use_multicolor_lines:
216 color_values = interpgrid(color, tgx, tgy)[:-1]
217 line_colors.append(color_values)
218 arrow_kw['color'] = cmap(norm(color_values[n]))
220 p = patches.FancyArrowPatch(
221 arrow_tail, arrow_head, transform=transform, **arrow_kw)
222 arrows.append(p)
224 lc = mcollections.LineCollection(
225 streamlines, transform=transform, **line_kw)
226 lc.sticky_edges.x[:] = [grid.x_origin, grid.x_origin + grid.width]
227 lc.sticky_edges.y[:] = [grid.y_origin, grid.y_origin + grid.height]
228 if use_multicolor_lines:
229 lc.set_array(np.ma.hstack(line_colors))
230 lc.set_cmap(cmap)
231 lc.set_norm(norm)
232 axes.add_collection(lc)
234 ac = mcollections.PatchCollection(arrows)
235 # Adding the collection itself is broken; see #2341.
236 for p in arrows:
237 axes.add_patch(p)
239 axes.autoscale_view()
240 stream_container = StreamplotSet(lc, ac)
241 return stream_container
244class StreamplotSet:
246 def __init__(self, lines, arrows):
247 self.lines = lines
248 self.arrows = arrows
251# Coordinate definitions
252# ========================
254class DomainMap:
255 """
256 Map representing different coordinate systems.
258 Coordinate definitions:
260 * axes-coordinates goes from 0 to 1 in the domain.
261 * data-coordinates are specified by the input x-y coordinates.
262 * grid-coordinates goes from 0 to N and 0 to M for an N x M grid,
263 where N and M match the shape of the input data.
264 * mask-coordinates goes from 0 to N and 0 to M for an N x M mask,
265 where N and M are user-specified to control the density of streamlines.
267 This class also has methods for adding trajectories to the StreamMask.
268 Before adding a trajectory, run `start_trajectory` to keep track of regions
269 crossed by a given trajectory. Later, if you decide the trajectory is bad
270 (e.g., if the trajectory is very short) just call `undo_trajectory`.
271 """
273 def __init__(self, grid, mask):
274 self.grid = grid
275 self.mask = mask
276 # Constants for conversion between grid- and mask-coordinates
277 self.x_grid2mask = (mask.nx - 1) / (grid.nx - 1)
278 self.y_grid2mask = (mask.ny - 1) / (grid.ny - 1)
280 self.x_mask2grid = 1. / self.x_grid2mask
281 self.y_mask2grid = 1. / self.y_grid2mask
283 self.x_data2grid = 1. / grid.dx
284 self.y_data2grid = 1. / grid.dy
286 def grid2mask(self, xi, yi):
287 """Return nearest space in mask-coords from given grid-coords."""
288 return (int(xi * self.x_grid2mask + 0.5),
289 int(yi * self.y_grid2mask + 0.5))
291 def mask2grid(self, xm, ym):
292 return xm * self.x_mask2grid, ym * self.y_mask2grid
294 def data2grid(self, xd, yd):
295 return xd * self.x_data2grid, yd * self.y_data2grid
297 def grid2data(self, xg, yg):
298 return xg / self.x_data2grid, yg / self.y_data2grid
300 def start_trajectory(self, xg, yg, broken_streamlines=True):
301 xm, ym = self.grid2mask(xg, yg)
302 self.mask._start_trajectory(xm, ym, broken_streamlines)
304 def reset_start_point(self, xg, yg):
305 xm, ym = self.grid2mask(xg, yg)
306 self.mask._current_xy = (xm, ym)
308 def update_trajectory(self, xg, yg, broken_streamlines=True):
309 if not self.grid.within_grid(xg, yg):
310 raise InvalidIndexError
311 xm, ym = self.grid2mask(xg, yg)
312 self.mask._update_trajectory(xm, ym, broken_streamlines)
314 def undo_trajectory(self):
315 self.mask._undo_trajectory()
318class Grid:
319 """Grid of data."""
320 def __init__(self, x, y):
322 if np.ndim(x) == 1:
323 pass
324 elif np.ndim(x) == 2:
325 x_row = x[0]
326 if not np.allclose(x_row, x):
327 raise ValueError("The rows of 'x' must be equal")
328 x = x_row
329 else:
330 raise ValueError("'x' can have at maximum 2 dimensions")
332 if np.ndim(y) == 1:
333 pass
334 elif np.ndim(y) == 2:
335 yt = np.transpose(y) # Also works for nested lists.
336 y_col = yt[0]
337 if not np.allclose(y_col, yt):
338 raise ValueError("The columns of 'y' must be equal")
339 y = y_col
340 else:
341 raise ValueError("'y' can have at maximum 2 dimensions")
343 if not (np.diff(x) > 0).all():
344 raise ValueError("'x' must be strictly increasing")
345 if not (np.diff(y) > 0).all():
346 raise ValueError("'y' must be strictly increasing")
348 self.nx = len(x)
349 self.ny = len(y)
351 self.dx = x[1] - x[0]
352 self.dy = y[1] - y[0]
354 self.x_origin = x[0]
355 self.y_origin = y[0]
357 self.width = x[-1] - x[0]
358 self.height = y[-1] - y[0]
360 if not np.allclose(np.diff(x), self.width / (self.nx - 1)):
361 raise ValueError("'x' values must be equally spaced")
362 if not np.allclose(np.diff(y), self.height / (self.ny - 1)):
363 raise ValueError("'y' values must be equally spaced")
365 @property
366 def shape(self):
367 return self.ny, self.nx
369 def within_grid(self, xi, yi):
370 """Return whether (*xi*, *yi*) is a valid index of the grid."""
371 # Note that xi/yi can be floats; so, for example, we can't simply check
372 # `xi < self.nx` since *xi* can be `self.nx - 1 < xi < self.nx`
373 return 0 <= xi <= self.nx - 1 and 0 <= yi <= self.ny - 1
376class StreamMask:
377 """
378 Mask to keep track of discrete regions crossed by streamlines.
380 The resolution of this grid determines the approximate spacing between
381 trajectories. Streamlines are only allowed to pass through zeroed cells:
382 When a streamline enters a cell, that cell is set to 1, and no new
383 streamlines are allowed to enter.
384 """
386 def __init__(self, density):
387 try:
388 self.nx, self.ny = (30 * np.broadcast_to(density, 2)).astype(int)
389 except ValueError as err:
390 raise ValueError("'density' must be a scalar or be of length "
391 "2") from err
392 if self.nx < 0 or self.ny < 0:
393 raise ValueError("'density' must be positive")
394 self._mask = np.zeros((self.ny, self.nx))
395 self.shape = self._mask.shape
397 self._current_xy = None
399 def __getitem__(self, args):
400 return self._mask[args]
402 def _start_trajectory(self, xm, ym, broken_streamlines=True):
403 """Start recording streamline trajectory"""
404 self._traj = []
405 self._update_trajectory(xm, ym, broken_streamlines)
407 def _undo_trajectory(self):
408 """Remove current trajectory from mask"""
409 for t in self._traj:
410 self._mask[t] = 0
412 def _update_trajectory(self, xm, ym, broken_streamlines=True):
413 """
414 Update current trajectory position in mask.
416 If the new position has already been filled, raise `InvalidIndexError`.
417 """
418 if self._current_xy != (xm, ym):
419 if self[ym, xm] == 0:
420 self._traj.append((ym, xm))
421 self._mask[ym, xm] = 1
422 self._current_xy = (xm, ym)
423 else:
424 if broken_streamlines:
425 raise InvalidIndexError
426 else:
427 pass
430class InvalidIndexError(Exception):
431 pass
434class TerminateTrajectory(Exception):
435 pass
438# Integrator definitions
439# =======================
441def _get_integrator(u, v, dmap, minlength, maxlength, integration_direction):
443 # rescale velocity onto grid-coordinates for integrations.
444 u, v = dmap.data2grid(u, v)
446 # speed (path length) will be in axes-coordinates
447 u_ax = u / (dmap.grid.nx - 1)
448 v_ax = v / (dmap.grid.ny - 1)
449 speed = np.ma.sqrt(u_ax ** 2 + v_ax ** 2)
451 def forward_time(xi, yi):
452 if not dmap.grid.within_grid(xi, yi):
453 raise OutOfBounds
454 ds_dt = interpgrid(speed, xi, yi)
455 if ds_dt == 0:
456 raise TerminateTrajectory()
457 dt_ds = 1. / ds_dt
458 ui = interpgrid(u, xi, yi)
459 vi = interpgrid(v, xi, yi)
460 return ui * dt_ds, vi * dt_ds
462 def backward_time(xi, yi):
463 dxi, dyi = forward_time(xi, yi)
464 return -dxi, -dyi
466 def integrate(x0, y0, broken_streamlines=True):
467 """
468 Return x, y grid-coordinates of trajectory based on starting point.
470 Integrate both forward and backward in time from starting point in
471 grid coordinates.
473 Integration is terminated when a trajectory reaches a domain boundary
474 or when it crosses into an already occupied cell in the StreamMask. The
475 resulting trajectory is None if it is shorter than `minlength`.
476 """
478 stotal, xy_traj = 0., []
480 try:
481 dmap.start_trajectory(x0, y0, broken_streamlines)
482 except InvalidIndexError:
483 return None
484 if integration_direction in ['both', 'backward']:
485 s, xyt = _integrate_rk12(x0, y0, dmap, backward_time, maxlength,
486 broken_streamlines)
487 stotal += s
488 xy_traj += xyt[::-1]
490 if integration_direction in ['both', 'forward']:
491 dmap.reset_start_point(x0, y0)
492 s, xyt = _integrate_rk12(x0, y0, dmap, forward_time, maxlength,
493 broken_streamlines)
494 stotal += s
495 xy_traj += xyt[1:]
497 if stotal > minlength:
498 return np.broadcast_arrays(xy_traj, np.empty((1, 2)))[0]
499 else: # reject short trajectories
500 dmap.undo_trajectory()
501 return None
503 return integrate
506@_api.deprecated("3.5")
507def get_integrator(u, v, dmap, minlength, maxlength, integration_direction):
508 xy_traj = _get_integrator(
509 u, v, dmap, minlength, maxlength, integration_direction)
510 return (None if xy_traj is None
511 else ([], []) if not len(xy_traj)
512 else [*zip(*xy_traj)])
515class OutOfBounds(IndexError):
516 pass
519def _integrate_rk12(x0, y0, dmap, f, maxlength, broken_streamlines=True):
520 """
521 2nd-order Runge-Kutta algorithm with adaptive step size.
523 This method is also referred to as the improved Euler's method, or Heun's
524 method. This method is favored over higher-order methods because:
526 1. To get decent looking trajectories and to sample every mask cell
527 on the trajectory we need a small timestep, so a lower order
528 solver doesn't hurt us unless the data is *very* high resolution.
529 In fact, for cases where the user inputs
530 data smaller or of similar grid size to the mask grid, the higher
531 order corrections are negligible because of the very fast linear
532 interpolation used in `interpgrid`.
534 2. For high resolution input data (i.e. beyond the mask
535 resolution), we must reduce the timestep. Therefore, an adaptive
536 timestep is more suited to the problem as this would be very hard
537 to judge automatically otherwise.
539 This integrator is about 1.5 - 2x as fast as RK4 and RK45 solvers (using
540 similar Python implementations) in most setups.
541 """
542 # This error is below that needed to match the RK4 integrator. It
543 # is set for visual reasons -- too low and corners start
544 # appearing ugly and jagged. Can be tuned.
545 maxerror = 0.003
547 # This limit is important (for all integrators) to avoid the
548 # trajectory skipping some mask cells. We could relax this
549 # condition if we use the code which is commented out below to
550 # increment the location gradually. However, due to the efficient
551 # nature of the interpolation, this doesn't boost speed by much
552 # for quite a bit of complexity.
553 maxds = min(1. / dmap.mask.nx, 1. / dmap.mask.ny, 0.1)
555 ds = maxds
556 stotal = 0
557 xi = x0
558 yi = y0
559 xyf_traj = []
561 while True:
562 try:
563 if dmap.grid.within_grid(xi, yi):
564 xyf_traj.append((xi, yi))
565 else:
566 raise OutOfBounds
568 # Compute the two intermediate gradients.
569 # f should raise OutOfBounds if the locations given are
570 # outside the grid.
571 k1x, k1y = f(xi, yi)
572 k2x, k2y = f(xi + ds * k1x, yi + ds * k1y)
574 except OutOfBounds:
575 # Out of the domain during this step.
576 # Take an Euler step to the boundary to improve neatness
577 # unless the trajectory is currently empty.
578 if xyf_traj:
579 ds, xyf_traj = _euler_step(xyf_traj, dmap, f)
580 stotal += ds
581 break
582 except TerminateTrajectory:
583 break
585 dx1 = ds * k1x
586 dy1 = ds * k1y
587 dx2 = ds * 0.5 * (k1x + k2x)
588 dy2 = ds * 0.5 * (k1y + k2y)
590 ny, nx = dmap.grid.shape
591 # Error is normalized to the axes coordinates
592 error = np.hypot((dx2 - dx1) / (nx - 1), (dy2 - dy1) / (ny - 1))
594 # Only save step if within error tolerance
595 if error < maxerror:
596 xi += dx2
597 yi += dy2
598 try:
599 dmap.update_trajectory(xi, yi, broken_streamlines)
600 except InvalidIndexError:
601 break
602 if stotal + ds > maxlength:
603 break
604 stotal += ds
606 # recalculate stepsize based on step error
607 if error == 0:
608 ds = maxds
609 else:
610 ds = min(maxds, 0.85 * ds * (maxerror / error) ** 0.5)
612 return stotal, xyf_traj
615def _euler_step(xyf_traj, dmap, f):
616 """Simple Euler integration step that extends streamline to boundary."""
617 ny, nx = dmap.grid.shape
618 xi, yi = xyf_traj[-1]
619 cx, cy = f(xi, yi)
620 if cx == 0:
621 dsx = np.inf
622 elif cx < 0:
623 dsx = xi / -cx
624 else:
625 dsx = (nx - 1 - xi) / cx
626 if cy == 0:
627 dsy = np.inf
628 elif cy < 0:
629 dsy = yi / -cy
630 else:
631 dsy = (ny - 1 - yi) / cy
632 ds = min(dsx, dsy)
633 xyf_traj.append((xi + cx * ds, yi + cy * ds))
634 return ds, xyf_traj
637# Utility functions
638# ========================
640def interpgrid(a, xi, yi):
641 """Fast 2D, linear interpolation on an integer grid"""
643 Ny, Nx = np.shape(a)
644 if isinstance(xi, np.ndarray):
645 x = xi.astype(int)
646 y = yi.astype(int)
647 # Check that xn, yn don't exceed max index
648 xn = np.clip(x + 1, 0, Nx - 1)
649 yn = np.clip(y + 1, 0, Ny - 1)
650 else:
651 x = int(xi)
652 y = int(yi)
653 # conditional is faster than clipping for integers
654 if x == (Nx - 1):
655 xn = x
656 else:
657 xn = x + 1
658 if y == (Ny - 1):
659 yn = y
660 else:
661 yn = y + 1
663 a00 = a[y, x]
664 a01 = a[y, xn]
665 a10 = a[yn, x]
666 a11 = a[yn, xn]
667 xt = xi - x
668 yt = yi - y
669 a0 = a00 * (1 - xt) + a01 * xt
670 a1 = a10 * (1 - xt) + a11 * xt
671 ai = a0 * (1 - yt) + a1 * yt
673 if not isinstance(xi, np.ndarray):
674 if np.ma.is_masked(ai):
675 raise TerminateTrajectory
677 return ai
680def _gen_starting_points(shape):
681 """
682 Yield starting points for streamlines.
684 Trying points on the boundary first gives higher quality streamlines.
685 This algorithm starts with a point on the mask corner and spirals inward.
686 This algorithm is inefficient, but fast compared to rest of streamplot.
687 """
688 ny, nx = shape
689 xfirst = 0
690 yfirst = 1
691 xlast = nx - 1
692 ylast = ny - 1
693 x, y = 0, 0
694 direction = 'right'
695 for i in range(nx * ny):
696 yield x, y
698 if direction == 'right':
699 x += 1
700 if x >= xlast:
701 xlast -= 1
702 direction = 'up'
703 elif direction == 'up':
704 y += 1
705 if y >= ylast:
706 ylast -= 1
707 direction = 'left'
708 elif direction == 'left':
709 x -= 1
710 if x <= xfirst:
711 xfirst += 1
712 direction = 'down'
713 elif direction == 'down':
714 y -= 1
715 if y <= yfirst:
716 yfirst += 1
717 direction = 'right'