From b26858558b3cf64d76d8d45ae8b6fa3993bbd622 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 29 Jul 2023 09:29:30 +0100 Subject: [PATCH 1/7] Add `set_U`, `set_V` and `set_C` method to `matplotlib.quiver.Quiver` --- lib/matplotlib/quiver.py | 56 +++++++++++++++++++++++- lib/matplotlib/quiver.pyi | 5 ++- lib/matplotlib/tests/test_collections.py | 51 +++++++++++++++++++++ 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/quiver.py b/lib/matplotlib/quiver.py index 8fa1962d6321..a53778074abd 100644 --- a/lib/matplotlib/quiver.py +++ b/lib/matplotlib/quiver.py @@ -444,8 +444,8 @@ class Quiver(mcollections.PolyCollection): """ Specialized PolyCollection for arrows. - The only API method is set_UVC(), which can be used - to change the size, orientation, and color of the + The API methods are set_UVC(), set_U(), set_V() and set_C(), which + can be used to change the size, orientation, and color of the arrows; their locations are fixed when the class is instantiated. Possibly this method will be useful in animations. @@ -540,7 +540,59 @@ def draw(self, renderer): super().draw(renderer) self.stale = False + def set_U(self, U): + """ + Set x direction components of the arrow vectors. + + Parameters + ---------- + U : array-like or None + The size must the same as the existing U, V or be one. + """ + self.set_UVC(U, None, None) + + def set_V(self, V): + """ + Set y direction components of the arrow vectors. + + Parameters + ---------- + V : array-like or None + The size must the same as the existing U, V or be one. + """ + self.set_UVC(None, V, None) + + def set_C(self, C): + """ + Set the arrow colors. + + Parameters + ---------- + C : array-like or None + The size must the same as the existing U, V or be one. + """ + self.set_UVC(None, None, C) + def set_UVC(self, U, V, C=None): + """ + Set the U, V (x and y direction components of the arrow vectors) and + C (arrow colors) values of the arrows. + + Parameters + ---------- + U : array-like or None + The x direction components of the arrows. If None it is unchanged. + The size must the same as the existing U, V or be one. + V : array-like or None + The y direction components of the arrows. If None it is unchanged. + The size must the same as the existing U, V or be one. + C : array-like or None, optional + The arrow colors. The default is None. + """ + if U is None: + U = self.U + if V is None: + V = self.V # We need to ensure we have a copy, not a reference # to an array that might change before draw(). U = ma.masked_invalid(U, copy=True).ravel() diff --git a/lib/matplotlib/quiver.pyi b/lib/matplotlib/quiver.pyi index 2a043a92b4b5..c86d519fee89 100644 --- a/lib/matplotlib/quiver.pyi +++ b/lib/matplotlib/quiver.pyi @@ -122,8 +122,11 @@ class Quiver(mcollections.PolyCollection): **kwargs ) -> None: ... def get_datalim(self, transData: Transform) -> Bbox: ... + def set_U(self, U: ArrayLike) -> None: ... + def set_V(self, V: ArrayLike) -> None: ... + def set_C(self, C: ArrayLike) -> None: ... def set_UVC( - self, U: ArrayLike, V: ArrayLike, C: ArrayLike | None = ... + self, U: ArrayLike | None, V: ArrayLike | None, C: ArrayLike | None = ... ) -> None: ... class Barbs(mcollections.PolyCollection): diff --git a/lib/matplotlib/tests/test_collections.py b/lib/matplotlib/tests/test_collections.py index 5baaeaa5d388..c80ab7264907 100644 --- a/lib/matplotlib/tests/test_collections.py +++ b/lib/matplotlib/tests/test_collections.py @@ -13,6 +13,7 @@ import matplotlib.collections as mcollections import matplotlib.colors as mcolors import matplotlib.path as mpath +import matplotlib.quiver as mquiver import matplotlib.transforms as mtransforms from matplotlib.collections import (Collection, LineCollection, EventCollection, PolyCollection) @@ -357,6 +358,56 @@ def test_collection_log_datalim(fig_test, fig_ref): ax_ref.plot(x, y, marker="o", ls="") +def test_quiver_offsets(): + fig, ax = plt.subplots() + x = np.arange(-10, 10, 1) + y = np.arange(-10, 10, 1) + U, V = np.meshgrid(x, y) + X = U.ravel() + Y = V.ravel() + qc = mquiver.Quiver(ax, X, Y, U, V) + ax.add_collection(qc) + ax.autoscale_view() + + expected_offsets = np.column_stack([X, Y]) + np.testing.assert_allclose(expected_offsets, qc.get_offsets()) + + new_offsets = np.column_stack([(X + 10).ravel(), Y.ravel()]) + qc.set_offsets(new_offsets) + + np.testing.assert_allclose(qc.get_offsets(), new_offsets) + + +def test_quiver_UVC(): + fig, ax = plt.subplots() + X = np.arange(-10, 10, 1) + Y = np.arange(-10, 10, 1) + U, V = np.meshgrid(X, Y) + M = np.hypot(U, V) + qc = mquiver.Quiver( + ax, X, Y, U, V, M + ) + ax.add_collection(qc) + ax.autoscale_view() + + np.testing.assert_allclose(qc.U, U.ravel()) + np.testing.assert_allclose(qc.V, V.ravel()) + np.testing.assert_allclose(qc.get_array(), M.ravel()) + + qc.set_UVC(U/2, V/3) + np.testing.assert_allclose(qc.U, U.ravel() / 2) + np.testing.assert_allclose(qc.V, V.ravel() / 3) + + qc.set_U(U/4) + np.testing.assert_allclose(qc.U, U.ravel() / 4) + + qc.set_V(V/6) + np.testing.assert_allclose(qc.V, V.ravel() / 6) + + qc.set_C(M/10) + np.testing.assert_allclose(qc.get_array(), M.ravel() / 10) + + def test_quiver_limits(): ax = plt.axes() x, y = np.arange(8), np.arange(10) From 10adf0b6b906f6989fc93a77951679bfb63bde33 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Tue, 5 Sep 2023 20:04:03 +0100 Subject: [PATCH 2/7] Add `set_offsets` to `quiver` and make the attribute (`N` and `XY`) properties to avoid inconsistent state of `quiver` --- lib/matplotlib/quiver.py | 26 +++++++++++++++++++++--- lib/matplotlib/quiver.pyi | 7 +++++-- lib/matplotlib/tests/test_collections.py | 3 +++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/quiver.py b/lib/matplotlib/quiver.py index a53778074abd..54a8e096f219 100644 --- a/lib/matplotlib/quiver.py +++ b/lib/matplotlib/quiver.py @@ -447,7 +447,7 @@ class Quiver(mcollections.PolyCollection): The API methods are set_UVC(), set_U(), set_V() and set_C(), which can be used to change the size, orientation, and color of the arrows; their locations are fixed when the class is - instantiated. Possibly this method will be useful + instantiated. Possibly these methods will be useful in animations. Much of the work in this class is done in the draw() @@ -475,8 +475,6 @@ def __init__(self, ax, *args, X, Y, U, V, C = _parse_args(*args, caller_name='quiver') self.X = X self.Y = Y - self.XY = np.column_stack((X, Y)) - self.N = len(X) self.scale = scale self.headwidth = headwidth self.headlength = float(headlength) @@ -523,6 +521,14 @@ def _init(self): self._dpi_at_last_init = self.axes.figure.dpi + @property + def N(self): + return len(self.X) + + @property + def XY(self): + return np.column_stack((self.X, self.Y)) + def get_datalim(self, transData): trans = self.get_transform() offset_trf = self.get_offset_transform() @@ -588,6 +594,7 @@ def set_UVC(self, U, V, C=None): The size must the same as the existing U, V or be one. C : array-like or None, optional The arrow colors. The default is None. + The size must the same as the existing U, V or be one. """ if U is None: U = self.U @@ -619,6 +626,19 @@ def set_UVC(self, U, V, C=None): self.set_array(C) self.stale = True + def set_offsets(self, xy): + """ + Set the offsets for the arrows. This saves the offsets passed + in and masks them as appropriate for the existing X/Y data. + + Parameters + ---------- + xy : sequence of pairs of floats + """ + self.X, self.Y = xy[:, 0], xy[:, 1] + super().set_offsets(xy) + self.stale = True + def _dots_per_unit(self, units): """Return a scale factor for converting from units to pixels.""" bb = self.axes.bbox diff --git a/lib/matplotlib/quiver.pyi b/lib/matplotlib/quiver.pyi index c86d519fee89..f210dca15b30 100644 --- a/lib/matplotlib/quiver.pyi +++ b/lib/matplotlib/quiver.pyi @@ -54,11 +54,9 @@ class QuiverKey(martist.Artist): class Quiver(mcollections.PolyCollection): X: ArrayLike Y: ArrayLike - XY: ArrayLike U: ArrayLike V: ArrayLike Umask: ArrayLike - N: int scale: float | None headwidth: float headlength: float @@ -121,6 +119,10 @@ class Quiver(mcollections.PolyCollection): pivot: Literal["tail", "mid", "middle", "tip"] = ..., **kwargs ) -> None: ... + @property + def N(self) -> int: ... + @property + def XY(self) -> ArrayLike: ... def get_datalim(self, transData: Transform) -> Bbox: ... def set_U(self, U: ArrayLike) -> None: ... def set_V(self, V: ArrayLike) -> None: ... @@ -128,6 +130,7 @@ class Quiver(mcollections.PolyCollection): def set_UVC( self, U: ArrayLike | None, V: ArrayLike | None, C: ArrayLike | None = ... ) -> None: ... + def set_offsets(self, xy: ArrayLike) -> None: ... class Barbs(mcollections.PolyCollection): sizes: dict[str, float] diff --git a/lib/matplotlib/tests/test_collections.py b/lib/matplotlib/tests/test_collections.py index c80ab7264907..145805e6e816 100644 --- a/lib/matplotlib/tests/test_collections.py +++ b/lib/matplotlib/tests/test_collections.py @@ -376,6 +376,9 @@ def test_quiver_offsets(): qc.set_offsets(new_offsets) np.testing.assert_allclose(qc.get_offsets(), new_offsets) + np.testing.assert_allclose(qc.X, new_offsets[::, 0]) + np.testing.assert_allclose(qc.Y, new_offsets[::, 1]) + np.testing.assert_allclose(qc.XY, new_offsets) def test_quiver_UVC(): From 92848b2162148f351b8ba6b7a484ef8a198dd86d Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Wed, 6 Sep 2023 11:36:17 +0100 Subject: [PATCH 3/7] Add release note --- .../next_whats_new/add_Quiver_setters.rst | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 doc/users/next_whats_new/add_Quiver_setters.rst diff --git a/doc/users/next_whats_new/add_Quiver_setters.rst b/doc/users/next_whats_new/add_Quiver_setters.rst new file mode 100644 index 000000000000..6d9c4e1b76c0 --- /dev/null +++ b/doc/users/next_whats_new/add_Quiver_setters.rst @@ -0,0 +1,50 @@ +Add ``U``, ``V`` and ``C`` setter to ``Quiver`` +----------------------------------------------- + +The ``U``, ``V`` and ``C`` values of the `~matplotlib.quiver.Quiver` +can now be changed after the collection has been created. + +.. plot:: + :include-source: true + + import matplotlib.pyplot as plt + from matplotlib.quiver import Quiver + import numpy as np + + fig, ax = plt.subplots() + X = np.arange(-10, 10, 1) + Y = np.arange(-10, 10, 1) + U, V = np.meshgrid(X, Y) + C = np.hypot(U, V) + qc = ax.quiver(X, Y, U, V, C) + + qc.set_U(U/5) + + +The number of arrows can also be changed. + +.. plot:: + :include-source: true + + import matplotlib.pyplot as plt + from matplotlib.quiver import Quiver + import numpy as np + + fig, ax = plt.subplots() + X = np.arange(-10, 10, 1) + Y = np.arange(-10, 10, 1) + U, V = np.meshgrid(X, Y) + C = np.hypot(U, V) + qc = ax.quiver(X, Y, U, V, C) + + # Get new X, Y, U, V, C + X = np.arange(-10, 10, 2) + Y = np.arange(-10, 10, 2) + U, V = np.meshgrid(X, Y) + C = np.hypot(U, V) + X, Y = np.meshgrid(X, Y) + XY = np.column_stack((X.ravel(), Y.ravel())) + + # Set new values + qc.set_offsets(XY) + qc.set_UVC(U, V, C) From e5b42b18eb46a4e81524a1fc12c10d057f40a95b Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 23 Mar 2024 13:19:10 +0000 Subject: [PATCH 4/7] Add `set_XYUVC` method to be used for all quiver setter --- .../next_whats_new/add_Quiver_setters.rst | 24 +- lib/matplotlib/quiver.py | 291 +++++++++++++----- lib/matplotlib/tests/test_collections.py | 64 +++- lib/matplotlib/tests/test_quiver.py | 6 +- 4 files changed, 275 insertions(+), 110 deletions(-) diff --git a/doc/users/next_whats_new/add_Quiver_setters.rst b/doc/users/next_whats_new/add_Quiver_setters.rst index 6d9c4e1b76c0..5265d3a332fc 100644 --- a/doc/users/next_whats_new/add_Quiver_setters.rst +++ b/doc/users/next_whats_new/add_Quiver_setters.rst @@ -16,35 +16,21 @@ can now be changed after the collection has been created. Y = np.arange(-10, 10, 1) U, V = np.meshgrid(X, Y) C = np.hypot(U, V) + # When X and Y are 1D and U, V are 2D, X, Y are expanded to 2D + # using X, Y = np.meshgrid(X, Y) qc = ax.quiver(X, Y, U, V, C) qc.set_U(U/5) - -The number of arrows can also be changed. - -.. plot:: - :include-source: true - - import matplotlib.pyplot as plt - from matplotlib.quiver import Quiver - import numpy as np - - fig, ax = plt.subplots() - X = np.arange(-10, 10, 1) - Y = np.arange(-10, 10, 1) - U, V = np.meshgrid(X, Y) - C = np.hypot(U, V) - qc = ax.quiver(X, Y, U, V, C) + # The number of arrows can also be changed. # Get new X, Y, U, V, C X = np.arange(-10, 10, 2) Y = np.arange(-10, 10, 2) U, V = np.meshgrid(X, Y) C = np.hypot(U, V) + # Use 2D X, Y coordinate (X, Y will not be expanded to 2D) X, Y = np.meshgrid(X, Y) - XY = np.column_stack((X.ravel(), Y.ravel())) # Set new values - qc.set_offsets(XY) - qc.set_UVC(U, V, C) + qc.set_XYUVC(X, Y, U, V, C) diff --git a/lib/matplotlib/quiver.py b/lib/matplotlib/quiver.py index 54a8e096f219..aa84f0132841 100644 --- a/lib/matplotlib/quiver.py +++ b/lib/matplotlib/quiver.py @@ -15,6 +15,7 @@ """ import math +from numbers import Number import numpy as np from numpy import ma @@ -417,21 +418,30 @@ def _parse_args(*args, caller_name='function'): else: raise _api.nargs_error(caller_name, takes="from 2 to 5", given=nargs) - nr, nc = (1, U.shape[0]) if U.ndim == 1 else U.shape + nr, nc = _extract_nr_nc(U) - if X is not None: - X = X.ravel() - Y = Y.ravel() - if len(X) == nc and len(Y) == nr: - X, Y = [a.ravel() for a in np.meshgrid(X, Y)] - elif len(X) != len(Y): - raise ValueError('X and Y must be the same size, but ' - f'X.size is {X.size} and Y.size is {Y.size}.') - else: + if X is None: indexgrid = np.meshgrid(np.arange(nc), np.arange(nr)) X, Y = [np.ravel(a) for a in indexgrid] - # Size validation for U, V, C is left to the set_UVC method. - return X, Y, U, V, C + # Size validation for U, V, C is left to the set_XYUVC method. + return X, Y, U, V, C, nr, nc + + +def _process_XY(X, Y, nc, nr): + X = X.ravel() + Y = Y.ravel() + if len(X) == nc and len(Y) == nr: + X, Y = [a.ravel() for a in np.meshgrid(X, Y)] + elif len(X) != len(Y): + raise ValueError( + 'X and Y must be the same size, but ' + f'X.size is {X.size} and Y.size is {Y.size}.' + ) + return X, Y + + +def _extract_nr_nc(U): + return (1, U.shape[0]) if U.ndim == 1 else U.shape def _check_consistent_shapes(*arrays): @@ -472,9 +482,7 @@ def __init__(self, ax, *args, %s """ self._axes = ax # The attr actually set by the Artist.axes property. - X, Y, U, V, C = _parse_args(*args, caller_name='quiver') - self.X = X - self.Y = Y + self.scale = scale self.headwidth = headwidth self.headlength = float(headlength) @@ -494,10 +502,16 @@ def __init__(self, ax, *args, self.transform = kwargs.pop('transform', ax.transData) kwargs.setdefault('facecolors', color) kwargs.setdefault('linewidths', (0,)) - super().__init__([], offsets=self.XY, offset_transform=self.transform, - closed=False, **kwargs) + super().__init__( + [], offset_transform=self.transform, closed=False, **kwargs + ) self.polykw = kwargs - self.set_UVC(U, V, C) + + self._U = self._V = self._C = None + X, Y, U, V, C, self._nr, self._nc = _parse_args( + *args, caller_name='quiver()' + ) + self.set_XYUVC(X=X, Y=Y, U=U, V=V, C=C) self._dpi_at_last_init = None def _init(self): @@ -511,29 +525,89 @@ def _init(self): trans = self._set_transform() self.span = trans.inverted().transform_bbox(self.axes.bbox).width if self.width is None: - sn = np.clip(math.sqrt(self.N), 8, 25) + sn = np.clip(math.sqrt(len(self.get_offsets())), 8, 25) self.width = 0.06 * self.span / sn # _make_verts sets self.scale if not already specified if (self._dpi_at_last_init != self.axes.figure.dpi and self.scale is None): - self._make_verts(self.XY, self.U, self.V, self.angles) + self._make_verts(self.get_offsets(), self._U, self._V, self.angles) self._dpi_at_last_init = self.axes.figure.dpi @property def N(self): - return len(self.X) + _api.warn_deprecated("3.9", alternative="get_X().size") + return len(self.get_X()) + + @property + def X(self): + _api.warn_deprecated("3.9", alternative="get_X") + return self.get_X() + + @X.setter + def X(self): + _api.warn_deprecated("3.9", alternative="set_X") + return self.set_X() + + @property + def Y(self): + _api.warn_deprecated("3.9", alternative="get_Y") + return self.get_Y() + + @Y.setter + def Y(self): + _api.warn_deprecated("3.9", alternative="set_Y") + return self.set_Y() + + @property + def U(self): + _api.warn_deprecated("3.9", alternative="get_U") + return self.get_U() + + @U.setter + def U(self): + _api.warn_deprecated("3.9", alternative="set_U") + return self.set_U() + + @property + def V(self): + _api.warn_deprecated("3.9", alternative="get_V") + return self.get_V() + + @V.setter + def V(self): + _api.warn_deprecated("3.9", alternative="set_V") + return self.set_V() + + @property + def C(self): + _api.warn_deprecated("3.9", alternative="get_C") + return self.get_C() + + @C.setter + def C(self): + _api.warn_deprecated("3.9", alternative="set_C") + return self.set_C() @property def XY(self): - return np.column_stack((self.X, self.Y)) + _api.warn_deprecated("3.9", alternative="get_XY") + return self.get_offsets() + + @XY.setter + def XY(self, XY): + _api.warn_deprecated("3.9", alternative="set_XY") + self.set_offsets(offsets=XY) + + def set_offsets(self, offsets): + self.set_XYUVC(X=offsets[:, 0], Y=offsets[:, 1]) def get_datalim(self, transData): trans = self.get_transform() offset_trf = self.get_offset_transform() full_transform = (trans - transData) + (offset_trf - transData) - XY = full_transform.transform(self.XY) + XY = full_transform.transform(self.get_offsets()) bbox = transforms.Bbox.null() bbox.update_from_data_xy(XY, ignore=True) return bbox @@ -541,32 +615,87 @@ def get_datalim(self, transData): @martist.allow_rasterization def draw(self, renderer): self._init() - verts = self._make_verts(self.XY, self.U, self.V, self.angles) + verts = self._make_verts(self.get_offsets(), self._U, self._V, self.angles) self.set_verts(verts, closed=False) super().draw(renderer) self.stale = False + def get_XY(self): + """Returns the positions. Alias for ``get_offsets``.""" + return self.get_offsets() + + def set_XY(self, XY): + """ + Set positions. Alias for ``set_offsets``. If the size + changes and it is not compatible with ``U``, ``V`` or + ``C``, use ``set_XYUVC`` instead. + + Parameters + ---------- + X : array-like + The size must be compatible with ``U``, ``V`` and ``C``. + """ + self.set_offsets(offsets=XY) + + def set_X(self, X): + """ + Set positions in the horizontal direction. + + Parameters + ---------- + X : array-like + The size must the same as the existing Y. + """ + self.set_XYUVC(X=X) + + def get_X(self): + """Returns the positions in the horizontal direction.""" + return self.get_offsets()[..., 0] + + def set_Y(self, Y): + """ + Set positions in the vertical direction. + + Parameters + ---------- + Y : array-like + The size must the same as the existing X. + """ + self.set_XYUVC(Y=Y) + + def get_Y(self): + """Returns the positions in the vertical direction.""" + return self.get_offsets()[..., 1] + def set_U(self, U): """ - Set x direction components of the arrow vectors. + Set horizontal direction components. Parameters ---------- - U : array-like or None - The size must the same as the existing U, V or be one. + U : array-like + The size must the same as the existing X, Y, V or be one. """ - self.set_UVC(U, None, None) + self.set_XYUVC(U=U) + + def get_U(self): + """Returns the horizontal direction components.""" + return self._U def set_V(self, V): """ - Set y direction components of the arrow vectors. + Set vertical direction components. Parameters ---------- - V : array-like or None - The size must the same as the existing U, V or be one. + V : array-like + The size must the same as the existing X, Y, U or be one. """ - self.set_UVC(None, V, None) + self.set_XYUVC(V=V) + + def get_V(self): + """Returns the vertical direction components.""" + return self._V def set_C(self, C): """ @@ -574,44 +703,63 @@ def set_C(self, C): Parameters ---------- - C : array-like or None - The size must the same as the existing U, V or be one. + C : array-like + The size must the same as the existing X, Y, U, V or be one. """ - self.set_UVC(None, None, C) + self.set_XYUVC(C=C) - def set_UVC(self, U, V, C=None): + def get_C(self): + """Returns the arrow colors.""" + return self._C + + def set_XYUVC(self, X=None, Y=None, U=None, V=None, C=None): """ - Set the U, V (x and y direction components of the arrow vectors) and - C (arrow colors) values of the arrows. + Set the positions (X, Y) and components (U, V) of the arrow vectors + and arrow colors (C) values of the arrows. + The size of the array must match with existing values. To change + the size, all arguments must be changed at once and their size + compatible. Parameters ---------- - U : array-like or None - The x direction components of the arrows. If None it is unchanged. - The size must the same as the existing U, V or be one. - V : array-like or None - The y direction components of the arrows. If None it is unchanged. + X, Y : array-like of float or None, optional + The arrow locations in the horizontal and vertical directions. + Any shape is valid so long as X and Y have the same size. + U, V : array-like or None, optional + The horizontal and vertical direction components of the arrows. + If None it is unchanged. The size must the same as the existing U, V or be one. C : array-like or None, optional The arrow colors. The default is None. The size must the same as the existing U, V or be one. """ - if U is None: - U = self.U - if V is None: - V = self.V + + X = self.get_X() if X is None else X + Y = self.get_Y() if Y is None else Y + if U is None or isinstance(U, Number): + nr, nc = (self._nr, self._nc) + else: + nr, nc = _extract_nr_nc(U) + X, Y = _process_XY(X, Y, nc, nr) + N = len(X) + # We need to ensure we have a copy, not a reference # to an array that might change before draw(). - U = ma.masked_invalid(U, copy=True).ravel() - V = ma.masked_invalid(V, copy=True).ravel() - if C is not None: - C = ma.masked_invalid(C, copy=True).ravel() + U = ma.masked_invalid(self._U if U is None else U, copy=True).ravel() + V = ma.masked_invalid(self._V if V is None else V, copy=True).ravel() + if C is not None or self._C is not None: + C = ma.masked_invalid( + self._C if C is None else C, copy=True + ).ravel() for name, var in zip(('U', 'V', 'C'), (U, V, C)): - if not (var is None or var.size == self.N or var.size == 1): - raise ValueError(f'Argument {name} has a size {var.size}' - f' which does not match {self.N},' - ' the number of arrow positions') - + if not (var is None or var.size == N or var.size == 1): + raise ValueError( + f'Argument {name} has a size {var.size}' + f' which does not match {N},' + ' the number of arrow positions' + ) + + # now shapes are validated and we can start assigning things mask = ma.mask_or(U.mask, V.mask, copy=False, shrink=True) if C is not None: mask = ma.mask_or(mask, C.mask, copy=False, shrink=True) @@ -619,24 +767,14 @@ def set_UVC(self, U, V, C=None): C = C.filled() else: C = ma.array(C, mask=mask, copy=False) - self.U = U.filled(1) - self.V = V.filled(1) + self._U = U.filled(1) + self._V = V.filled(1) self.Umask = mask if C is not None: self.set_array(C) - self.stale = True - - def set_offsets(self, xy): - """ - Set the offsets for the arrows. This saves the offsets passed - in and masks them as appropriate for the existing X/Y data. - - Parameters - ---------- - xy : sequence of pairs of floats - """ - self.X, self.Y = xy[:, 0], xy[:, 1] - super().set_offsets(xy) + self._N = N + self._new_UV = True + super().set_offsets(np.column_stack([X, Y])) self.stale = True def _dots_per_unit(self, units): @@ -697,7 +835,7 @@ def _make_verts(self, XY, U, V, angles): a = np.abs(uv) if self.scale is None: - sn = max(10, math.sqrt(self.N)) + sn = max(10, math.sqrt(len(self.get_offsets()))) if self.Umask is not ma.nomask: amean = a[~self.Umask].mean() else: @@ -1000,10 +1138,11 @@ def __init__(self, ax, *args, kwargs['linewidth'] = 1 # Parse out the data arrays from the various configurations supported - x, y, u, v, c = _parse_args(*args, caller_name='barbs') - self.x = x - self.y = y - xy = np.column_stack((x, y)) + x, y, u, v, c, self._nr, self._nc = _parse_args( + *args, caller_name='barbs()' + ) + self.x, self.y = _process_XY(x, y, self._nr, self._nc) + xy = np.column_stack([self.x, self.y]) # Make a collection barb_size = self._length ** 2 / 4 # Empirically determined diff --git a/lib/matplotlib/tests/test_collections.py b/lib/matplotlib/tests/test_collections.py index 145805e6e816..694e73299995 100644 --- a/lib/matplotlib/tests/test_collections.py +++ b/lib/matplotlib/tests/test_collections.py @@ -376,12 +376,39 @@ def test_quiver_offsets(): qc.set_offsets(new_offsets) np.testing.assert_allclose(qc.get_offsets(), new_offsets) - np.testing.assert_allclose(qc.X, new_offsets[::, 0]) - np.testing.assert_allclose(qc.Y, new_offsets[::, 1]) - np.testing.assert_allclose(qc.XY, new_offsets) + np.testing.assert_allclose(qc.get_X(), new_offsets[..., 0]) + np.testing.assert_allclose(qc.get_Y(), new_offsets[..., 1]) + new_X = qc.get_X() + 5 + qc.set_X(new_X) + np.testing.assert_allclose(qc.get_X(), new_X) -def test_quiver_UVC(): + new_Y = qc.get_Y() + 5 + qc.set_Y(new_Y) + np.testing.assert_allclose(qc.get_Y(), new_Y) + + # new length + L = 2 + with pytest.raises(ValueError): + qc.set_X(qc.get_X()[:L]) + + with pytest.raises(ValueError): + qc.set_Y(qc.get_Y()[:L]) + + with pytest.raises(ValueError): + qc.set_offsets(qc.get_offsets()[:L]) + + with pytest.raises(ValueError): + qc.set_XYUVC(X=new_X[:L], Y=new_Y[:L]) + + qc.set_XYUVC(X=X[:L], Y=Y[:L], U=qc.get_U()[:L], V=qc.get_V()[:L]) + np.testing.assert_allclose(qc.get_X(), X[:L]) + np.testing.assert_allclose(qc.get_Y(), Y[:L]) + np.testing.assert_allclose(qc.get_U(), U.ravel()[:L]) + np.testing.assert_allclose(qc.get_V(), V.ravel()[:L]) + + +def test_quiver_change_UVC(): fig, ax = plt.subplots() X = np.arange(-10, 10, 1) Y = np.arange(-10, 10, 1) @@ -393,23 +420,36 @@ def test_quiver_UVC(): ax.add_collection(qc) ax.autoscale_view() - np.testing.assert_allclose(qc.U, U.ravel()) - np.testing.assert_allclose(qc.V, V.ravel()) + np.testing.assert_allclose(qc.get_U(), U.ravel()) + np.testing.assert_allclose(qc.get_V(), V.ravel()) np.testing.assert_allclose(qc.get_array(), M.ravel()) - qc.set_UVC(U/2, V/3) - np.testing.assert_allclose(qc.U, U.ravel() / 2) - np.testing.assert_allclose(qc.V, V.ravel() / 3) + qc.set_XYUVC(U=U/2, V=V/3) + np.testing.assert_allclose(qc.get_U(), U.ravel() / 2) + np.testing.assert_allclose(qc.get_V(), V.ravel() / 3) qc.set_U(U/4) - np.testing.assert_allclose(qc.U, U.ravel() / 4) + np.testing.assert_allclose(qc.get_U(), U.ravel() / 4) qc.set_V(V/6) - np.testing.assert_allclose(qc.V, V.ravel() / 6) + np.testing.assert_allclose(qc.get_V(), V.ravel() / 6) - qc.set_C(M/10) + qc.set_C(C=M/10) np.testing.assert_allclose(qc.get_array(), M.ravel() / 10) + with pytest.raises(ValueError): + qc.set_X(X[:2]) + with pytest.raises(ValueError): + qc.set_Y(Y[:2]) + with pytest.raises(ValueError): + qc.set_U(U[:2]) + with pytest.raises(ValueError): + qc.set_V(V[:2]) + + qc.set_XYUVC() + np.testing.assert_allclose(qc.get_U(), U.ravel() / 4) + np.testing.assert_allclose(qc.get_V(), V.ravel() / 6) + def test_quiver_limits(): ax = plt.axes() diff --git a/lib/matplotlib/tests/test_quiver.py b/lib/matplotlib/tests/test_quiver.py index 7c5a9d343530..5b7262e3e920 100644 --- a/lib/matplotlib/tests/test_quiver.py +++ b/lib/matplotlib/tests/test_quiver.py @@ -24,7 +24,7 @@ def test_quiver_memory_leak(): fig, ax = plt.subplots() Q = draw_quiver(ax) - ttX = Q.X + ttX = Q.get_X() Q.remove() del Q @@ -133,7 +133,7 @@ def test_quiver_copy(): uv = dict(u=np.array([1.1]), v=np.array([2.0])) q0 = ax.quiver([1], [1], uv['u'], uv['v']) uv['v'][0] = 0 - assert q0.V[0] == 2.0 + assert q0.get_V()[0] == 2.0 @image_comparison(['quiver_key_pivot.png'], remove_text=True) @@ -332,4 +332,4 @@ def test_quiver_setuvc_numbers(): U = V = np.ones_like(X) q = ax.quiver(X, Y, U, V) - q.set_UVC(0, 1) + q.set_XYUVC(U=0, V=1) From c8d4924bc4226c85eaa81ac9e087b1369d401d58 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 23 Mar 2024 14:08:00 +0000 Subject: [PATCH 5/7] Privatise `quiver.Quiver.Umask` --- lib/matplotlib/quiver.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/matplotlib/quiver.py b/lib/matplotlib/quiver.py index aa84f0132841..08610e172741 100644 --- a/lib/matplotlib/quiver.py +++ b/lib/matplotlib/quiver.py @@ -325,7 +325,7 @@ def _init(self): self._set_transform() with cbook._setattr_cm(self.Q, pivot=self.pivot[self.labelpos], # Hack: save and restore the Umask - Umask=ma.nomask): + _Umask=ma.nomask): u = self.U * np.cos(np.radians(self.angle)) v = self.U * np.sin(np.radians(self.angle)) self.verts = self.Q._make_verts([[0., 0.]], @@ -469,6 +469,7 @@ class Quiver(mcollections.PolyCollection): """ _PIVOT_VALS = ('tail', 'middle', 'tip') + Umask = _api.deprecate_privatize_attribute("3.9") @_docstring.Substitution(_quiver_doc) def __init__(self, ax, *args, @@ -769,7 +770,7 @@ def set_XYUVC(self, X=None, Y=None, U=None, V=None, C=None): C = ma.array(C, mask=mask, copy=False) self._U = U.filled(1) self._V = V.filled(1) - self.Umask = mask + self._Umask = mask if C is not None: self.set_array(C) self._N = N @@ -836,8 +837,8 @@ def _make_verts(self, XY, U, V, angles): if self.scale is None: sn = max(10, math.sqrt(len(self.get_offsets()))) - if self.Umask is not ma.nomask: - amean = a[~self.Umask].mean() + if self._Umask is not ma.nomask: + amean = a[~self._Umask].mean() else: amean = a.mean() # crude auto-scaling @@ -867,9 +868,9 @@ def _make_verts(self, XY, U, V, angles): theta = theta.reshape((-1, 1)) # for broadcasting xy = (X + Y * 1j) * np.exp(1j * theta) * self.width XY = np.stack((xy.real, xy.imag), axis=2) - if self.Umask is not ma.nomask: + if self._Umask is not ma.nomask: XY = ma.array(XY) - XY[self.Umask] = ma.masked + XY[self._Umask] = ma.masked # This might be handled more efficiently with nans, given # that nans will end up in the paths anyway. From aba8d9be333b2c33695903fe910eaa56242212a5 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 23 Mar 2024 13:09:20 +0000 Subject: [PATCH 6/7] Fix typing --- lib/matplotlib/quiver.pyi | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/quiver.pyi b/lib/matplotlib/quiver.pyi index f210dca15b30..4b80f8940324 100644 --- a/lib/matplotlib/quiver.pyi +++ b/lib/matplotlib/quiver.pyi @@ -56,6 +56,8 @@ class Quiver(mcollections.PolyCollection): Y: ArrayLike U: ArrayLike V: ArrayLike + C: ArrayLike + XY: ArrayLike Umask: ArrayLike scale: float | None headwidth: float @@ -121,16 +123,28 @@ class Quiver(mcollections.PolyCollection): ) -> None: ... @property def N(self) -> int: ... - @property - def XY(self) -> ArrayLike: ... def get_datalim(self, transData: Transform) -> Bbox: ... + def set_offsets(self, offsets: ArrayLike) -> None: ... + def set_XY(self, XY: ArrayLike) -> None: ... + def get_XY(self) -> ArrayLike: ... + def set_X(self, X: ArrayLike) -> None: ... + def get_X(self) -> ArrayLike: ... + def set_Y(self, Y: ArrayLike) -> None: ... + def get_Y(self) -> ArrayLike: ... def set_U(self, U: ArrayLike) -> None: ... + def get_U(self) -> ArrayLike: ... def set_V(self, V: ArrayLike) -> None: ... + def get_V(self) -> ArrayLike: ... def set_C(self, C: ArrayLike) -> None: ... - def set_UVC( - self, U: ArrayLike | None, V: ArrayLike | None, C: ArrayLike | None = ... + def get_C(self) -> ArrayLike: ... + def set_XYUVC( + self, + X: ArrayLike | None = ..., + Y: ArrayLike | None = ..., + U: ArrayLike | None = ..., + V: ArrayLike | None = ..., + C: ArrayLike | None = ... ) -> None: ... - def set_offsets(self, xy: ArrayLike) -> None: ... class Barbs(mcollections.PolyCollection): sizes: dict[str, float] From 9b5bc95614dc182af4420aa2ff773ba0efc17c2a Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Tue, 26 Mar 2024 21:27:48 +0000 Subject: [PATCH 7/7] Don't check length of arguments in quiver collection and increase coverage --- lib/matplotlib/quiver.py | 84 ++++++++-------------- lib/matplotlib/quiver.pyi | 5 +- lib/matplotlib/tests/test_collections.py | 88 ++++++++++++++++-------- 3 files changed, 92 insertions(+), 85 deletions(-) diff --git a/lib/matplotlib/quiver.py b/lib/matplotlib/quiver.py index 08610e172741..20a61fe532dc 100644 --- a/lib/matplotlib/quiver.py +++ b/lib/matplotlib/quiver.py @@ -454,11 +454,10 @@ class Quiver(mcollections.PolyCollection): """ Specialized PolyCollection for arrows. - The API methods are set_UVC(), set_U(), set_V() and set_C(), which - can be used to change the size, orientation, and color of the - arrows; their locations are fixed when the class is - instantiated. Possibly these methods will be useful - in animations. + The API methods are set_XYUVC(), set_X(), set_Y(), set_U() and set_V(), + which can be used to change the size, orientation, and color of the + arrows; their locations are fixed when the class is instantiated. + Possibly these methods will be useful in animations. Much of the work in this class is done in the draw() method so that as much information as possible is available @@ -512,7 +511,7 @@ def __init__(self, ax, *args, X, Y, U, V, C, self._nr, self._nc = _parse_args( *args, caller_name='quiver()' ) - self.set_XYUVC(X=X, Y=Y, U=U, V=V, C=C) + self.set_XYUVC(X=X, Y=Y, U=U, V=V, C=C, check_shape=True) self._dpi_at_last_init = None def _init(self): @@ -547,9 +546,9 @@ def X(self): return self.get_X() @X.setter - def X(self): + def X(self, value): _api.warn_deprecated("3.9", alternative="set_X") - return self.set_X() + return self.set_X(value) @property def Y(self): @@ -557,9 +556,9 @@ def Y(self): return self.get_Y() @Y.setter - def Y(self): + def Y(self, value): _api.warn_deprecated("3.9", alternative="set_Y") - return self.set_Y() + return self.set_Y(value) @property def U(self): @@ -567,9 +566,9 @@ def U(self): return self.get_U() @U.setter - def U(self): + def U(self, value): _api.warn_deprecated("3.9", alternative="set_U") - return self.set_U() + return self.set_U(value) @property def V(self): @@ -577,29 +576,19 @@ def V(self): return self.get_V() @V.setter - def V(self): + def V(self, value): _api.warn_deprecated("3.9", alternative="set_V") - return self.set_V() - - @property - def C(self): - _api.warn_deprecated("3.9", alternative="get_C") - return self.get_C() - - @C.setter - def C(self): - _api.warn_deprecated("3.9", alternative="set_C") - return self.set_C() + return self.set_V(value) @property def XY(self): - _api.warn_deprecated("3.9", alternative="get_XY") + _api.warn_deprecated("3.9", alternative="get_offsets") return self.get_offsets() @XY.setter - def XY(self, XY): - _api.warn_deprecated("3.9", alternative="set_XY") - self.set_offsets(offsets=XY) + def XY(self, value): + _api.warn_deprecated("3.9", alternative="set_offsets") + self.set_offsets(offsets=value) def set_offsets(self, offsets): self.set_XYUVC(X=offsets[:, 0], Y=offsets[:, 1]) @@ -621,23 +610,6 @@ def draw(self, renderer): super().draw(renderer) self.stale = False - def get_XY(self): - """Returns the positions. Alias for ``get_offsets``.""" - return self.get_offsets() - - def set_XY(self, XY): - """ - Set positions. Alias for ``set_offsets``. If the size - changes and it is not compatible with ``U``, ``V`` or - ``C``, use ``set_XYUVC`` instead. - - Parameters - ---------- - X : array-like - The size must be compatible with ``U``, ``V`` and ``C``. - """ - self.set_offsets(offsets=XY) - def set_X(self, X): """ Set positions in the horizontal direction. @@ -711,9 +683,9 @@ def set_C(self, C): def get_C(self): """Returns the arrow colors.""" - return self._C + return self.get_array() - def set_XYUVC(self, X=None, Y=None, U=None, V=None, C=None): + def set_XYUVC(self, X=None, Y=None, U=None, V=None, C=None, check_shape=False): """ Set the positions (X, Y) and components (U, V) of the arrow vectors and arrow colors (C) values of the arrows. @@ -733,6 +705,9 @@ def set_XYUVC(self, X=None, Y=None, U=None, V=None, C=None): C : array-like or None, optional The arrow colors. The default is None. The size must the same as the existing U, V or be one. + check_shape : bool + Whether to check if the shape of the parameters are + consistent. Default is False. """ X = self.get_X() if X is None else X @@ -752,13 +727,14 @@ def set_XYUVC(self, X=None, Y=None, U=None, V=None, C=None): C = ma.masked_invalid( self._C if C is None else C, copy=True ).ravel() - for name, var in zip(('U', 'V', 'C'), (U, V, C)): - if not (var is None or var.size == N or var.size == 1): - raise ValueError( - f'Argument {name} has a size {var.size}' - f' which does not match {N},' - ' the number of arrow positions' - ) + if check_shape: + for name, var in zip(('U', 'V', 'C'), (U, V, C)): + if not (var is None or var.size == N or var.size == 1): + raise ValueError( + f'Argument {name} has a size {var.size}' + f' which does not match {N},' + ' the number of arrow positions' + ) # now shapes are validated and we can start assigning things mask = ma.mask_or(U.mask, V.mask, copy=False, shrink=True) diff --git a/lib/matplotlib/quiver.pyi b/lib/matplotlib/quiver.pyi index 4b80f8940324..33335446047f 100644 --- a/lib/matplotlib/quiver.pyi +++ b/lib/matplotlib/quiver.pyi @@ -125,8 +125,6 @@ class Quiver(mcollections.PolyCollection): def N(self) -> int: ... def get_datalim(self, transData: Transform) -> Bbox: ... def set_offsets(self, offsets: ArrayLike) -> None: ... - def set_XY(self, XY: ArrayLike) -> None: ... - def get_XY(self) -> ArrayLike: ... def set_X(self, X: ArrayLike) -> None: ... def get_X(self) -> ArrayLike: ... def set_Y(self, Y: ArrayLike) -> None: ... @@ -143,7 +141,8 @@ class Quiver(mcollections.PolyCollection): Y: ArrayLike | None = ..., U: ArrayLike | None = ..., V: ArrayLike | None = ..., - C: ArrayLike | None = ... + C: ArrayLike | None = ..., + check_shape: bool = ..., ) -> None: ... class Barbs(mcollections.PolyCollection): diff --git a/lib/matplotlib/tests/test_collections.py b/lib/matplotlib/tests/test_collections.py index 694e73299995..1b63413927de 100644 --- a/lib/matplotlib/tests/test_collections.py +++ b/lib/matplotlib/tests/test_collections.py @@ -389,17 +389,9 @@ def test_quiver_offsets(): # new length L = 2 - with pytest.raises(ValueError): - qc.set_X(qc.get_X()[:L]) - - with pytest.raises(ValueError): - qc.set_Y(qc.get_Y()[:L]) - - with pytest.raises(ValueError): - qc.set_offsets(qc.get_offsets()[:L]) - - with pytest.raises(ValueError): - qc.set_XYUVC(X=new_X[:L], Y=new_Y[:L]) + qc.set_XYUVC(X=new_X[:L], Y=new_Y[:L]) + np.testing.assert_allclose(qc.get_X(), new_X[:L]) + np.testing.assert_allclose(qc.get_Y(), new_Y[:L]) qc.set_XYUVC(X=X[:L], Y=Y[:L], U=qc.get_U()[:L], V=qc.get_V()[:L]) np.testing.assert_allclose(qc.get_X(), X[:L]) @@ -408,23 +400,21 @@ def test_quiver_offsets(): np.testing.assert_allclose(qc.get_V(), V.ravel()[:L]) -def test_quiver_change_UVC(): +def test_quiver_change_XYUVC(): fig, ax = plt.subplots() X = np.arange(-10, 10, 1) Y = np.arange(-10, 10, 1) U, V = np.meshgrid(X, Y) - M = np.hypot(U, V) - qc = mquiver.Quiver( - ax, X, Y, U, V, M - ) + C = np.hypot(U, V) + qc = mquiver.Quiver(ax, X, Y, U, V, C) ax.add_collection(qc) ax.autoscale_view() np.testing.assert_allclose(qc.get_U(), U.ravel()) np.testing.assert_allclose(qc.get_V(), V.ravel()) - np.testing.assert_allclose(qc.get_array(), M.ravel()) + np.testing.assert_allclose(qc.get_C(), C.ravel()) - qc.set_XYUVC(U=U/2, V=V/3) + qc.set(U=U/2, V=V/3) np.testing.assert_allclose(qc.get_U(), U.ravel() / 2) np.testing.assert_allclose(qc.get_V(), V.ravel() / 3) @@ -434,23 +424,65 @@ def test_quiver_change_UVC(): qc.set_V(V/6) np.testing.assert_allclose(qc.get_V(), V.ravel() / 6) - qc.set_C(C=M/10) - np.testing.assert_allclose(qc.get_array(), M.ravel() / 10) + qc.set_C(C/3) + np.testing.assert_allclose(qc.get_C(), C.ravel() / 3) + # check consistency not enable + qc.set_XYUVC(X=X[:2], Y=Y[:2]) with pytest.raises(ValueError): - qc.set_X(X[:2]) - with pytest.raises(ValueError): - qc.set_Y(Y[:2]) - with pytest.raises(ValueError): - qc.set_U(U[:2]) - with pytest.raises(ValueError): - qc.set_V(V[:2]) + # setting only one of the two X, Y fails because X and Y needs + # to be stacked when passed to `offsets` + qc.set(Y=Y[:3]) - qc.set_XYUVC() + qc.set() np.testing.assert_allclose(qc.get_U(), U.ravel() / 4) np.testing.assert_allclose(qc.get_V(), V.ravel() / 6) +def test_quiver_deprecated_attribute(): + fig, ax = plt.subplots() + X = np.arange(-10, 10, 1) + Y = np.arange(-10, 10, 1) + U, V = np.meshgrid(X, Y) + C = np.hypot(U, V) + qc = mquiver.Quiver(ax, X, Y, U, V, C) + ax.add_collection(qc) + ax.autoscale_view() + + with pytest.warns(mpl.MatplotlibDeprecationWarning): + x = qc.X + with pytest.warns(mpl.MatplotlibDeprecationWarning): + qc.X = x * 2 + np.testing.assert_allclose(qc.get_X(), x * 2) + + with pytest.warns(mpl.MatplotlibDeprecationWarning): + y = qc.Y + with pytest.warns(mpl.MatplotlibDeprecationWarning): + qc.Y = y * 2 + np.testing.assert_allclose(qc.get_Y(), y * 2) + + with pytest.warns(mpl.MatplotlibDeprecationWarning): + np.testing.assert_allclose(qc.N, len(qc.get_offsets())) + + with pytest.warns(mpl.MatplotlibDeprecationWarning): + u = qc.U + with pytest.warns(mpl.MatplotlibDeprecationWarning): + qc.U = u * 2 + np.testing.assert_allclose(qc.get_U(), u * 2) + + with pytest.warns(mpl.MatplotlibDeprecationWarning): + v = qc.V + with pytest.warns(mpl.MatplotlibDeprecationWarning): + qc.V = v * 2 + np.testing.assert_allclose(qc.get_V(), v * 2) + + with pytest.warns(mpl.MatplotlibDeprecationWarning): + xy = qc.XY + with pytest.warns(mpl.MatplotlibDeprecationWarning): + qc.XY = xy * 2 + np.testing.assert_allclose(qc.get_offsets(), xy * 2) + + def test_quiver_limits(): ax = plt.axes() x, y = np.arange(8), np.arange(10)