From 2f1d1d5235bbbf3dd63f3e0dbd467631972e71e1 Mon Sep 17 00:00:00 2001 From: Ryan May Date: Fri, 3 Jan 2025 17:09:18 -0700 Subject: [PATCH 1/7] ENH: Add function to identify peaks This finds local maxima, or minima, for a 2D array. --- docs/_templates/overrides/metpy.calc.rst | 2 + docs/api/references.rst | 5 + src/metpy/calc/tools.py | 112 +++++++++++++++++++++++ tests/calc/test_calc_tools.py | 60 ++++++++++-- 4 files changed, 172 insertions(+), 7 deletions(-) diff --git a/docs/_templates/overrides/metpy.calc.rst b/docs/_templates/overrides/metpy.calc.rst index 09464d6b77..652f1b02b4 100644 --- a/docs/_templates/overrides/metpy.calc.rst +++ b/docs/_templates/overrides/metpy.calc.rst @@ -215,6 +215,7 @@ Other azimuth_range_to_lat_lon find_bounding_indices find_intersections + find_peaks get_layer get_layer_heights get_perturbation @@ -222,5 +223,6 @@ Other isentropic_interpolation_as_dataset nearest_intersection_idx parse_angle + peak_persistence reduce_point_density resample_nn_1d diff --git a/docs/api/references.rst b/docs/api/references.rst index 87af83af82..3234037d1b 100644 --- a/docs/api/references.rst +++ b/docs/api/references.rst @@ -114,6 +114,11 @@ References .. [Holton2004] Holton, J. R., 2004: *An Introduction to Dynamic Meteorology*. 4th ed. Academic Press, 535 pp. +.. [Huber2021] Huber, S., 2020: Persistent Homology in Data Science. + *Proc. Int. Data Sci. Conf.*, doi:`10.1007/978-3-658-32182-6_13 + `_. + `[PDF] `_ + .. [IAPWS1995] The International Association for the Properties of Water and Steam, 1995: `Revised Release on the IAPWS Formulation 1995 for the Thermodynamic Properties of Ordinary Water Substance for General and Scientific Use (updated diff --git a/src/metpy/calc/tools.py b/src/metpy/calc/tools.py index 3f5af337c5..819c8f7dfd 100644 --- a/src/metpy/calc/tools.py +++ b/src/metpy/calc/tools.py @@ -5,6 +5,7 @@ import contextlib import functools from inspect import Parameter, signature +import itertools from operator import itemgetter import textwrap @@ -1946,3 +1947,114 @@ def _remove_nans(*variables): for v in variables: ret.append(v[~mask]) return ret + + +def _neighbor_inds(y, x): + """Generate index (row, col) pairs for each neighbor of (x, y).""" + incs = (-1, 0, 1) + for dx, dy in itertools.product(incs, incs): + yield y + dy, x + dx + + +def _find_uf(uf, item): + """Find the root item for ``item`` in the union find structure ``uf``.""" + # uf is a dictionary mapping item->parent. Loop until we find parent=parent. + while (next_item := uf[item]) != item: + uf[item] = uf[next_item] + item = next_item + return item + + +@exporter.export +def peak_persistence(arr, *, maxima=True): + """Return all local extrema points ordered by their persistence. + + This uses the concept of persistent homology to find peaks ordered by their persistence. + [Huber2021]_ This essentially works akin to a falling water level and seeing how long a + peak persists until the falling level allows connection to a larger, neighboring peak. + NaN points are ignored. + + Parameters + ---------- + arr : array-like + 2-dimensional array of numeric values to search + maxima : bool, optional + Whether to find local maxima, defaults to True. If False, local minima will be found + instead. + + Returns + ------- + list[((int, int), float)] + Point indices and persistence values in descending order of persistence + + See Also + -------- + find_peaks + + """ + # Get the collection of points and values in descending strength of peak. + points = sorted((item for item in np.ndenumerate(arr) if not np.isnan(item[1])), + key=lambda i: i[1], reverse=maxima) + + # The global max will never be merged and thus should be added to the final list + # of persisted points + per = {points[0][0]: np.inf} + + # Loop over all points and add them to the set (a dict storing as a union-find data + # structure) one-by-one + peaks = {} + for pt, val in points: + # Look to see if any neighbors of this point are attached to any existing peaks + if already_done := {_find_uf(peaks, n) for n in _neighbor_inds(*pt) if n in peaks}: + + # Get these peaks in order of value + biggest, *others = sorted(already_done, key=lambda i: arr[i], reverse=maxima) + + # Connect our point to the biggest peak + peaks[pt] = biggest + + # Any other existing peaks will join to this biggest and will end their + # persistence, denoted as the difference between their original level and the + # current level + for neighbor in others: + peaks[neighbor] = biggest + if arr[neighbor] != val: + per[neighbor] = abs(arr[neighbor] - val) + else: + peaks[pt] = pt + + # Return the points in descending order of persistence + return sorted(per.items(), key=lambda i: i[1], reverse=True) + + +@exporter.export +def find_peaks(arr, *, maxima=True, iqr_ratio=2): + """Search array for significant peaks (or valleys). + + Parameters + ---------- + arr: array-like + 2-dimensional array of numeric values to search + maxima: bool, optional + Whether to find local maxima, defaults to True. If False, local minima will be found + instead. + iqr_ratio: float, optional + Ratio of interquantile range (Q1 - Q3) of peak persistence values to use as a + threshold (when added to Q3) for significance of peaks. Defaults to 2. + + Returns + ------- + row indices, column indices + Locations of significant peaks + + See Also + -------- + peak_persistence + + """ + peaks = peak_persistence(arr, maxima=maxima) + q1, q3 = np.percentile([p[-1] for p in peaks], (25, 75)) + thresh = q3 + iqr_ratio * (q3 - q1) + return map(list, + zip(*(it[0] for it in itertools.takewhile(lambda i: i[1] > thresh, peaks)), + strict=True)) diff --git a/tests/calc/test_calc_tools.py b/tests/calc/test_calc_tools.py index c54acb21a0..c2a071bc53 100644 --- a/tests/calc/test_calc_tools.py +++ b/tests/calc/test_calc_tools.py @@ -12,15 +12,16 @@ import pytest import xarray as xr -from metpy.calc import (angle_to_direction, find_bounding_indices, find_intersections, - first_derivative, geospatial_gradient, get_layer, get_layer_heights, - gradient, laplacian, lat_lon_grid_deltas, nearest_intersection_idx, - parse_angle, pressure_to_height_std, reduce_point_density, - resample_nn_1d, second_derivative, vector_derivative) +from metpy.calc import (angle_to_direction, azimuth_range_to_lat_lon, find_bounding_indices, + find_intersections, find_peaks, first_derivative, geospatial_gradient, + get_layer, get_layer_heights, gradient, laplacian, lat_lon_grid_deltas, + nearest_intersection_idx, parse_angle, peak_persistence, + pressure_to_height_std, reduce_point_density, resample_nn_1d, + second_derivative, vector_derivative) from metpy.calc.tools import (_delete_masked_points, _get_bound_pressure_height, _greater_or_close, _less_or_close, _next_non_masked_element, - _remove_nans, azimuth_range_to_lat_lon, BASE_DEGREE_MULTIPLIER, - DIR_STRS, nominal_lat_lon_grid_deltas, parse_grid_arguments, UND) + _remove_nans, BASE_DEGREE_MULTIPLIER, DIR_STRS, + nominal_lat_lon_grid_deltas, parse_grid_arguments, UND) from metpy.testing import (assert_almost_equal, assert_array_almost_equal, assert_array_equal, get_test_data) from metpy.units import units @@ -1557,3 +1558,48 @@ def test_vector_derivative_return_subset(return_only, length): u, v, longitude=lons, latitude=lats, crs=crs, return_only=return_only) assert len(ddx) == length + + +@pytest.fixture +def peak_data(): + """Return data for testing peak finding.""" + arr = np.zeros((4, 4), dtype=np.int64) + arr[1, 1] = 4 + arr[2, 3] = -4 + arr[3, 2] = 2 + arr[3, 0] = -2 + return arr + + +def test_peak_persistence(peak_data): + """Test that peak_persistence correctly orders peaks.""" + per = peak_persistence(peak_data) + assert per == [((1, 1), np.inf), ((3, 2), 2)] + + +def test_peak_persistence_minima(peak_data): + """Test that peak_persistence correctly orders peaks when looking for minima.""" + per = peak_persistence(peak_data, maxima=False) + assert per == [((2, 3), np.inf), ((3, 0), 2)] + + +def test_find_peaks(peak_data): + """Test find_peaks correctly identifies peaks.""" + data = xr.open_dataset(get_test_data('GFS_test.nc', as_file_obj=False)) + hgt = data.Geopotential_height_isobaric.metpy.sel(vertical=850 * units.hPa).squeeze() + yind, xind = find_peaks(hgt, iqr_ratio=3) + assert_array_equal(yind, [34, 29]) + assert_array_equal(xind, [0, 90]) + + # Ensure that indexes are returned in a way suitable for array indexing + assert_array_almost_equal(hgt.metpy.y[yind], [0.541052, 0.628319], 6) + assert_array_almost_equal(hgt.metpy.x[xind], [3.665191, 5.235988], 6) + + +def test_find_peaks_minima(peak_data): + """Test find_peaks correctly identifies peaks.""" + data = xr.open_dataset(get_test_data('GFS_test.nc', as_file_obj=False)) + hgt = data.Geopotential_height_isobaric.metpy.sel(vertical=850 * units.hPa).squeeze() + yind, xind = find_peaks(hgt, maxima=False, iqr_ratio=1) + assert_array_equal(yind, [19, 5, 0, 45]) + assert_array_equal(xind, [55, 100, 0, 100]) From 316ebf81a9ae3a216e394bb4adb52db2bac9198b Mon Sep 17 00:00:00 2001 From: Ryan May Date: Fri, 12 Jan 2024 16:51:48 -0700 Subject: [PATCH 2/7] ENH: Make scattertext() a public API This will make it easier to use for various use cases. --- src/metpy/plots/__init__.py | 4 +- src/metpy/plots/_mpl.py | 261 ----------------- src/metpy/plots/declarative.py | 22 +- src/metpy/plots/station_plot.py | 10 +- src/metpy/plots/text.py | 264 ++++++++++++++++++ .../plots/{test_mpl.py => test_plot_text.py} | 9 +- 6 files changed, 282 insertions(+), 288 deletions(-) delete mode 100644 src/metpy/plots/_mpl.py create mode 100644 src/metpy/plots/text.py rename tests/plots/{test_mpl.py => test_plot_text.py} (73%) diff --git a/src/metpy/plots/__init__.py b/src/metpy/plots/__init__.py index e9b61e7ff9..8205e51f16 100644 --- a/src/metpy/plots/__init__.py +++ b/src/metpy/plots/__init__.py @@ -3,8 +3,6 @@ # SPDX-License-Identifier: BSD-3-Clause r"""Contains functionality for making meteorological plots.""" -# Trigger matplotlib wrappers -from . import _mpl # noqa: F401 from . import cartopy_utils, plot_areas from ._util import (add_metpy_logo, add_timestamp, add_unidata_logo, # noqa: F401 convert_gempak_color) @@ -13,6 +11,7 @@ from .patheffects import * # noqa: F403 from .skewt import * # noqa: F403 from .station_plot import * # noqa: F403 +from .text import * # noqa: F403 from .wx_symbols import * # noqa: F403 from ..package_tools import set_module @@ -21,6 +20,7 @@ __all__.extend(patheffects.__all__) # pylint: disable=undefined-variable __all__.extend(skewt.__all__) # pylint: disable=undefined-variable __all__.extend(station_plot.__all__) # pylint: disable=undefined-variable +__all__.extend(text.__all__) # pylint: disable=undefined-variable __all__.extend(wx_symbols.__all__) # pylint: disable=undefined-variable __all__.extend(['add_metpy_logo', 'add_timestamp', 'add_unidata_logo', 'convert_gempak_color']) diff --git a/src/metpy/plots/_mpl.py b/src/metpy/plots/_mpl.py deleted file mode 100644 index 6d96ae7b56..0000000000 --- a/src/metpy/plots/_mpl.py +++ /dev/null @@ -1,261 +0,0 @@ -# Copyright (c) 2016,2017,2019 MetPy Developers. -# Distributed under the terms of the BSD 3-Clause License. -# SPDX-License-Identifier: BSD-3-Clause -"""Functionality that we have upstreamed or will upstream into matplotlib.""" - -from matplotlib.axes import Axes # noqa: E402, I100, I202 -import matplotlib.transforms as transforms -import numpy as np - -# See if we need to patch in our own scattertext implementation -if not hasattr(Axes, 'scattertext'): - from matplotlib import rcParams - from matplotlib.artist import allow_rasterization - import matplotlib.cbook as cbook - from matplotlib.text import Text - import matplotlib.transforms as mtransforms - - def scattertext(self, x, y, texts, loc=(0, 0), **kw): - """Add text to the axes. - - Add text in string `s` to axis at location `x`, `y`, data - coordinates. - - Parameters - ---------- - x, y : array-like, shape (n, ) - Input positions - - texts : array-like, shape (n, ) - Collection of text that will be plotted at each (x,y) location - - loc : length-2 tuple - Offset (in screen coordinates) from x,y position. Allows - positioning text relative to original point. - - Other Parameters - ---------------- - kwargs : `~matplotlib.text.TextCollection` properties. - Other miscellaneous text parameters. - - Examples - -------- - Individual keyword arguments can be used to override any given - parameter:: - - >>> ax = plt.gca() - >>> ax.scattertext([0.25, 0.75], [0.25, 0.75], ['aa', 'bb'], - ... fontsize=12) #doctest: +ELLIPSIS - TextCollection - - The default setting to to center the text at the specified x, y - locations in data coordinates. The example below places the text - above and to the right by 10 pixels:: - - >>> ax = plt.gca() - >>> ax.scattertext([0.25, 0.75], [0.25, 0.75], ['aa', 'bb'], - ... loc=(10, 10)) #doctest: +ELLIPSIS - TextCollection - - """ - # Start with default args and update from kw - new_kw = { - 'verticalalignment': 'center', - 'horizontalalignment': 'center', - 'transform': self.transData, - 'clip_on': False} - new_kw.update(kw) - - # Handle masked arrays - x, y, texts = cbook.delete_masked_points(x, y, texts) - - # If there is nothing left after deleting the masked points, return None - if x.size == 0: - return None - - # Make the TextCollection object - text_obj = TextCollection(x, y, texts, offset=loc, **new_kw) - - # The margin adjustment is a hack to deal with the fact that we don't - # want to transform all the symbols whose scales are in points - # to data coords to get the exact bounding box for efficiency - # reasons. It can be done right if this is deemed important. - # Also, only bother with this padding if there is anything to draw. - if self._xmargin < 0.05: - self.set_xmargin(0.05) - - if self._ymargin < 0.05: - self.set_ymargin(0.05) - - # Add it to the axes and update range - self.add_artist(text_obj) - - # Matplotlib at least up to 3.2.2 does not properly clip text with paths, so - # work-around by setting to the bounding box of the Axes - # TODO: Remove when fixed in our minimum supported version of matplotlib - text_obj.clipbox = self.bbox - - self.update_datalim(text_obj.get_datalim(self.transData)) - self.autoscale_view() - return text_obj - - class TextCollection(Text): - """Handle plotting a collection of text. - - Text Collection plots text with a collection of similar properties: font, color, - and an offset relative to the x,y data location. - """ - - def __init__(self, x, y, text, offset=(0, 0), **kwargs): - """Initialize an instance of `TextCollection`. - - This class encompasses drawing a collection of text values at a variety - of locations. - - Parameters - ---------- - x : array-like - The x locations, in data coordinates, for the text - - y : array-like - The y locations, in data coordinates, for the text - - text : array-like of str - The string values to draw - - offset : (int, int) - The offset x and y, in normalized coordinates, to draw the text relative - to the data locations. - - kwargs : arbitrary keywords arguments - - """ - Text.__init__(self, **kwargs) - self.x = x - self.y = y - self.text = text - self.offset = offset - - def __str__(self): - """Make a string representation of `TextCollection`.""" - return 'TextCollection' - - __repr__ = __str__ - - def get_datalim(self, transData): # noqa: N803 - """Return the limits of the data. - - Parameters - ---------- - transData : matplotlib.transforms.Transform - - Returns - ------- - matplotlib.transforms.Bbox - The bounding box of the data - - """ - full_transform = self.get_transform() - transData - posx = self.convert_xunits(self.x) - posy = self.convert_yunits(self.y) - XY = full_transform.transform(np.vstack((posx, posy)).T) # noqa: N806 - bbox = transforms.Bbox.null() - bbox.update_from_data_xy(XY, ignore=True) - return bbox - - @allow_rasterization - def draw(self, renderer): - """Draw the :class:`TextCollection` object to the given *renderer*.""" - if renderer is not None: - self._renderer = renderer - if not self.get_visible(): - return - if not any(self.text): - return - - renderer.open_group('text', self.get_gid()) - - trans = self.get_transform() - if self.offset != (0, 0): - scale = self.axes.figure.dpi / 72 - xoff, yoff = self.offset - trans += mtransforms.Affine2D().translate(scale * xoff, - scale * yoff) - - posx = self.convert_xunits(self.x) - posy = self.convert_yunits(self.y) - pts = np.vstack((posx, posy)).T - pts = trans.transform(pts) - _, canvash = renderer.get_canvas_width_height() - - gc = renderer.new_gc() - gc.set_foreground(self.get_color()) - gc.set_alpha(self.get_alpha()) - gc.set_url(self._url) - self._set_gc_clip(gc) - - angle = self.get_rotation() - - for (posx, posy), t in zip(pts, self.text, strict=False): - # Skip empty strings--not only is this a performance gain, but it fixes - # rendering with path effects below. - if not t: - continue - - self._text = t # hack to allow self._get_layout to work - _, info, _ = self._get_layout(renderer) - self._text = '' - - for line, _, x, y in info: - - mtext = self if len(info) == 1 else None - x = x + posx - y = y + posy - if renderer.flipy(): - y = canvash - y - - clean_line, ismath = self._preprocess_math(line) - - if self.get_path_effects(): - from matplotlib.patheffects import PathEffectRenderer - textrenderer = PathEffectRenderer( - self.get_path_effects(), renderer) # noqa: E126 - else: - textrenderer = renderer - - if self.get_usetex(): - textrenderer.draw_tex(gc, x, y, clean_line, - self._fontproperties, angle, - mtext=mtext) - else: - textrenderer.draw_text(gc, x, y, clean_line, - self._fontproperties, angle, - ismath=ismath, mtext=mtext) - - gc.restore() - renderer.close_group('text') - - def set_usetex(self, usetex): - """ - Set this `Text` object to render using TeX (or not). - - If `None` is given, the option will be reset to use the value of - `rcParams['text.usetex']` - """ - self._usetex = None if usetex is None else bool(usetex) - self.stale = True - - def get_usetex(self): - """ - Return whether this `Text` object will render using TeX. - - If the user has not manually set this value, it will default to - the value of `rcParams['text.usetex']` - """ - if self._usetex is None: - return rcParams['text.usetex'] - else: - return self._usetex - - # Monkey-patch scattertext onto Axes - Axes.scattertext = scattertext diff --git a/src/metpy/plots/declarative.py b/src/metpy/plots/declarative.py index 74098c39be..efa2f6c156 100644 --- a/src/metpy/plots/declarative.py +++ b/src/metpy/plots/declarative.py @@ -19,10 +19,10 @@ TraitError, Tuple, Unicode, Union, validate) from . import ctables, wx_symbols -from ._mpl import TextCollection from .cartopy_utils import import_cartopy from .patheffects import ColdFront, OccludedFront, StationaryFront, WarmFront from .station_plot import StationPlot +from .text import scattertext, TextCollection from ..calc import reduce_point_density, smooth_n_point, zoom_xarray from ..package_tools import Exporter from ..units import units @@ -2188,13 +2188,9 @@ def _draw_strengths(self, text, lon, lat, color, offset=None): if offset is None: offset = tuple(x * self.label_fontsize * 0.8 for x in self.strength_offset) - self.parent.ax.scattertext([lon], [lat], [str(text)], - color=color, - loc=offset, - weight='demi', - size=int(self.label_fontsize * 0.7), - transform=ccrs.PlateCarree(), - clip_on=True) + scattertext(self.parent.ax, [lon], [lat], [str(text)], color=color, loc=offset, + weight='demi', size=int(self.label_fontsize * 0.7), + transform=ccrs.PlateCarree(), clip_on=True) def _draw_labels(self, text, lon, lat, color, offset=(0, 0)): """Draw labels in the plot. @@ -2212,13 +2208,9 @@ def _draw_labels(self, text, lon, lat, color, offset=(0, 0)): offset : tuple (default: (0, 0)) A tuple containing the x- and y-offset of the label, respectively """ - self.parent.ax.scattertext([lon], [lat], [str(text)], - color=color, - loc=offset, - weight='demi', - size=self.label_fontsize, - transform=ccrs.PlateCarree(), - clip_on=True) + scattertext(self.parent.ax, [lon], [lat], [str(text)], color=color, loc=offset, + weight='demi', size=self.label_fontsize, transform=ccrs.PlateCarree(), + clip_on=True) def draw(self): """Draw the plot.""" diff --git a/src/metpy/plots/station_plot.py b/src/metpy/plots/station_plot.py index db2689a0bd..971946a945 100644 --- a/src/metpy/plots/station_plot.py +++ b/src/metpy/plots/station_plot.py @@ -7,6 +7,7 @@ import numpy as np +from .text import scattertext from .wx_symbols import (current_weather, high_clouds, low_clouds, mid_clouds, pressure_tendency, sky_cover, wx_symbol_font) from ..package_tools import Exporter @@ -209,7 +210,7 @@ def plot_parameter(self, location, parameter, formatter=None, **kwargs): def plot_text(self, location, text, **kwargs): """At the specified location in the station model plot a collection of text. - This specifies that at the offset `location`, the strings in `text` should be + This specifies that at the offset ``location``, the strings in ``text`` should be plotted. Additional keyword arguments given will be passed onto the actual plotting @@ -220,7 +221,7 @@ def plot_text(self, location, text, **kwargs): Parameters ---------- location : str or tuple[float, float] - The offset (relative to center) to plot this parameter. If str, should be one of + The offset (relative to center) to plot this parameter. If `str`, should be one of 'C', 'N', 'NE', 'E', 'SE', 'S', 'SW', 'W', or 'NW'. Otherwise, should be a tuple specifying the number of increments in the x and y directions; increments are multiplied by `spacing` to give offsets in x and y relative to the center. @@ -237,9 +238,8 @@ def plot_text(self, location, text, **kwargs): location = self._handle_location(location) kwargs = self._make_kwargs(kwargs) - text_collection = self.ax.scattertext(self.x, self.y, text, loc=location, - size=kwargs.pop('fontsize', self.fontsize), - **kwargs) + text_collection = scattertext(self.ax, self.x, self.y, text, loc=location, + size=kwargs.pop('fontsize', self.fontsize), **kwargs) if location in self.items: self.items[location].remove() self.items[location] = text_collection diff --git a/src/metpy/plots/text.py b/src/metpy/plots/text.py new file mode 100644 index 0000000000..50adf5f0ce --- /dev/null +++ b/src/metpy/plots/text.py @@ -0,0 +1,264 @@ +# Copyright (c) 2016,2017,2019 MetPy Developers. +# Distributed under the terms of the BSD 3-Clause License. +# SPDX-License-Identifier: BSD-3-Clause +"""Functionality for doing text-based plotting.""" + +from matplotlib import rcParams +from matplotlib.artist import allow_rasterization +import matplotlib.cbook as cbook +from matplotlib.text import Text +import matplotlib.transforms as mtransforms +import numpy as np + +from ..package_tools import Exporter + +exporter = Exporter(globals()) + + +@exporter.export +def scattertext(ax, x, y, texts, loc=(0, 0), **kw): + """Add text to the axes. + + Add text in string `s` to axis at location `x`, `y`, data + coordinates. + + Parameters + ---------- + ax : `matplotlib.axes.Axes` + Matplotlib Axes to draw on + + x, y : array-like, (N, ) + Input positions + + texts : array-like, (N, ) + Collection of text that will be plotted at each (x,y) location + + loc : tuple[int, int], optional + Offset (in screen coordinates) from x,y position. Allows positioning text relative + to original point. Default is (0, 0), which is no offset. + + Other Parameters + ---------------- + kwargs : `~matplotlib.text.TextCollection` properties. + Other miscellaneous text parameters. + + Examples + -------- + Individual keyword arguments can be used to override any given + parameter:: + + >>> ax = plt.gca() + >>> scattertext(ax, [0.25, 0.75], [0.25, 0.75], ['aa', 'bb'], + ... fontsize=12) #doctest: +ELLIPSIS + TextCollection + + The default setting to to center the text at the specified x, y + locations in data coordinates. The example below places the text + above and to the right by 10 pixels:: + + >>> ax = plt.gca() + >>> scattertext(ax, [0.25, 0.75], [0.25, 0.75], ['aa', 'bb'], + ... loc=(10, 10)) #doctest: +ELLIPSIS + TextCollection + + """ + # Start with default args and update from kw + new_kw = { + 'verticalalignment': 'center', + 'horizontalalignment': 'center', + 'transform': ax.transData, + 'clip_on': False} + new_kw.update(kw) + + # Handle masked arrays + x, y, texts = cbook.delete_masked_points(x, y, texts) + + # If there is nothing left after deleting the masked points, return None + if x.size == 0: + return None + + # Make the TextCollection object + text_obj = TextCollection(x, y, texts, offset=loc, **new_kw) + + # The margin adjustment is a hack to deal with the fact that we don't + # want to transform all the symbols whose scales are in points + # to data coords to get the exact bounding box for efficiency + # reasons. It can be done right if this is deemed important. + # Also, only bother with this padding if there is anything to draw. + xmargin, ymargin = ax.margins() + if xmargin < 0.05: + ax.set_xmargin(0.05) + + if ymargin < 0.05: + ax.set_ymargin(0.05) + + # Add it to the axes and update range + ax.add_artist(text_obj) + + # Matplotlib at least up to 3.2.2 does not properly clip text with paths, so + # work-around by setting to the bounding box of the Axes + # TODO: Remove when fixed in our minimum supported version of matplotlib + text_obj.clipbox = ax.bbox + + ax.update_datalim(text_obj.get_datalim(ax.transData)) + ax.autoscale_view() + return text_obj + + +class TextCollection(Text): + """Handle plotting a collection of text. + + Text Collection plots text with a collection of similar properties: font, color, + and an offset relative to the x,y data location. + """ + + def __init__(self, x, y, text, offset=(0, 0), **kwargs): + """Initialize an instance of `TextCollection`. + + This class encompasses drawing a collection of text values at a variety + of locations. + + Parameters + ---------- + x : array-like + The x locations, in data coordinates, for the text + + y : array-like + The y locations, in data coordinates, for the text + + text : array-like of str + The string values to draw + + offset : (int, int), optional + The offset x and y, in normalized coordinates, to draw the text relative + to the data locations. Defaults to (0, 0), which has no offset. + + kwargs : arbitrary keywords arguments + + """ + Text.__init__(self, **kwargs) + self.x = x + self.y = y + self.text = text + self.offset = offset + + def __str__(self): + """Make a string representation of `TextCollection`.""" + return 'TextCollection' + + __repr__ = __str__ + + def get_datalim(self, transData): # noqa: N803 + """Return the limits of the data. + + Parameters + ---------- + transData : matplotlib.transforms.Transform + + Returns + ------- + matplotlib.transforms.Bbox + The bounding box of the data + + """ + full_transform = self.get_transform() - transData + posx = self.convert_xunits(self.x) + posy = self.convert_yunits(self.y) + XY = full_transform.transform(np.vstack((posx, posy)).T) # noqa: N806 + bbox = mtransforms.Bbox.null() + bbox.update_from_data_xy(XY, ignore=True) + return bbox + + @allow_rasterization + def draw(self, renderer): + """Draw the :class:`TextCollection` object to the given *renderer*.""" + if renderer is not None: + self._renderer = renderer + if not self.get_visible(): + return + if not any(self.text): + return + + renderer.open_group('text', self.get_gid()) + + trans = self.get_transform() + if self.offset != (0, 0): + scale = self.axes.figure.dpi / 72 + xoff, yoff = self.offset + trans += mtransforms.Affine2D().translate(scale * xoff, + scale * yoff) + + posx = self.convert_xunits(self.x) + posy = self.convert_yunits(self.y) + pts = np.vstack((posx, posy)).T + pts = trans.transform(pts) + _, canvash = renderer.get_canvas_width_height() + + gc = renderer.new_gc() + gc.set_foreground(self.get_color()) + gc.set_alpha(self.get_alpha()) + gc.set_url(self._url) + self._set_gc_clip(gc) + + angle = self.get_rotation() + + for (posx, posy), t in zip(pts, self.text): + # Skip empty strings--not only is this a performance gain, but it fixes + # rendering with path effects below. + if not t: + continue + + self._text = t # hack to allow self._get_layout to work + _, info, _ = self._get_layout(renderer) + self._text = '' + + for line, _, x, y in info: + + mtext = self if len(info) == 1 else None + x = x + posx + y = y + posy + if renderer.flipy(): + y = canvash - y + + clean_line, ismath = self._preprocess_math(line) + + if self.get_path_effects(): + from matplotlib.patheffects import PathEffectRenderer + textrenderer = PathEffectRenderer( + self.get_path_effects(), renderer) # noqa: E126 + else: + textrenderer = renderer + + if self.get_usetex(): + textrenderer.draw_tex(gc, x, y, clean_line, + self._fontproperties, angle, + mtext=mtext) + else: + textrenderer.draw_text(gc, x, y, clean_line, + self._fontproperties, angle, + ismath=ismath, mtext=mtext) + + gc.restore() + renderer.close_group('text') + + def set_usetex(self, usetex): + """ + Set this `Text` object to render using TeX (or not). + + If `None` is given, the option will be reset to use the value of + `rcParams['text.usetex']` + """ + self._usetex = None if usetex is None else bool(usetex) + self.stale = True + + def get_usetex(self): + """ + Return whether this `Text` object will render using TeX. + + If the user has not manually set this value, it will default to + the value of `rcParams['text.usetex']` + """ + if self._usetex is None: + return rcParams['text.usetex'] + else: + return self._usetex diff --git a/tests/plots/test_mpl.py b/tests/plots/test_plot_text.py similarity index 73% rename from tests/plots/test_mpl.py rename to tests/plots/test_plot_text.py index 4da999f8cc..9a1cd68e51 100644 --- a/tests/plots/test_mpl.py +++ b/tests/plots/test_plot_text.py @@ -1,15 +1,14 @@ # Copyright (c) 2016 MetPy Developers. # Distributed under the terms of the BSD 3-Clause License. # SPDX-License-Identifier: BSD-3-Clause -"""Tests for the `_mpl` module.""" +"""Tests for the `metpy.plots.text` module.""" from tempfile import TemporaryFile import matplotlib.patheffects as mpatheffects import numpy as np -# Needed to trigger scattertext monkey-patching -import metpy.plots # noqa: F401, I202 +from metpy.plots import scattertext from metpy.testing import autoclose_figure @@ -21,8 +20,8 @@ def test_scattertext_patheffect_empty(): x, y = np.arange(6).reshape(2, 3) with autoclose_figure() as fig: ax = fig.add_subplot(1, 1, 1) - ax.scattertext(x, y, strings, color='white', - path_effects=[mpatheffects.withStroke(linewidth=1, foreground='black')]) + scattertext(ax, x, y, strings, color='white', + path_effects=[mpatheffects.withStroke(linewidth=1, foreground='black')]) # Need to trigger a render with TemporaryFile('wb') as fobj: From eea0ecf36590a6d6858c3e0904939e28c9b79b7a Mon Sep 17 00:00:00 2001 From: Ryan May Date: Tue, 7 Jan 2025 18:59:56 -0700 Subject: [PATCH 3/7] ENH: Allow passing a scalar text value to scattertext() This allows e.g. `scattertext(ax, x, y, 'H')`. --- src/metpy/plots/text.py | 5 ++++- .../baseline/test_scattertext_scalar_text.png | Bin 0 -> 3542 bytes tests/plots/test_plot_text.py | 11 +++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 tests/plots/baseline/test_scattertext_scalar_text.png diff --git a/src/metpy/plots/text.py b/src/metpy/plots/text.py index 50adf5f0ce..16eb81171a 100644 --- a/src/metpy/plots/text.py +++ b/src/metpy/plots/text.py @@ -3,6 +3,8 @@ # SPDX-License-Identifier: BSD-3-Clause """Functionality for doing text-based plotting.""" +import itertools + from matplotlib import rcParams from matplotlib.artist import allow_rasterization import matplotlib.cbook as cbook @@ -202,7 +204,8 @@ def draw(self, renderer): angle = self.get_rotation() - for (posx, posy), t in zip(pts, self.text): + # cycle() makes text repeat to match the points given + for (posx, posy), t in zip(pts, itertools.cycle(self.text)): # Skip empty strings--not only is this a performance gain, but it fixes # rendering with path effects below. if not t: diff --git a/tests/plots/baseline/test_scattertext_scalar_text.png b/tests/plots/baseline/test_scattertext_scalar_text.png new file mode 100644 index 0000000000000000000000000000000000000000..4caf6e0e647a02ac83593dfdd5b93b2738675c9a GIT binary patch literal 3542 zcmd^CYfuws6ulb|1%rUp5s-pbEgjK<_#z^vT70&k;v*8CsYS&o4-pJ#$hH-#qF9SO ze3aBWKno_|13`kxj!{q|6A>i{#w4Z?H9*7+1XyUgAa=}5XPo}S>CWuz%>DM8@9w$h z-gEZI`hZo|R!&v`V7+FwUmySp1wfSJEQrX0`X_%956jrqLGb`KjG-I(omv=0ynJ|# z-?B}ta~<-z0Z*F6)v9@RKaa;cFH~%s-j0r0Uus?CUKB){!a%NGH1j)dlQsSsr;F`q zDgVgfrmy2u&B+3X$*Qt_B?Ap}H!PG~o;?Oo2L;^*Kq;CD2;3a^1)!cJTbOzb%wgR0 z|6SIYRc8T`a{HmTCEPOH4ct_Xke=s02cg^F7eI~Xjq>hy@)6b?_qlY5+R*o z?BCLs9s_8^d+#Cdi~0D zp$k%RGf^;yMx*g|0G5-;Vr>lw2nY*3z+&yZ+QxkCgSv-=g=I&bN#x=<-k1rnC7x8V zT$Yfpt!$hJu=JKCkwtFZ<=J0p?eL;TdK%-ihWS}SU9c2^rXDtJ$I$@7Zy4JGCF^yqxthv-Z1JFl9&BI&Op04->GSpiO zqGqubi$W}*YBR5lEVdmS=&@p@^0jMa2?U^U@GuQ~w^F0*i~nJeXPHi5W+L0KX#%N+!$L;pDgov_{qCea3hN`^@imX)_taA{E+02|H_VlBQ zZji#xYbzflgc6+suFocUj?mTguwe#|)==B8-6-2j8h=`oWVq4T9BU02?u~>n1oCW# zLn1fMId~ljw(hwyTk;_JmatcDsr1f>JX7$%z!pam{r`wA@g&zvV@gok^Gg1{P+77C zU_Y{mZXM0-Fd#}#Pv4uE*i)h{FE5Y0^7vF?VWIJ$J+{NvqtMjKUUYwlJ z(b}3{?}SRH0WDW^{hq!G4`c4Bu;?WdyIcGF`yZ7F1cJRODNlFWVO%$$HAR=VzI<8K zO;*}}?BbH0nVCtGxwyDE@sM8Ul(Dg?5{bmizo9dp!%-wp1Z-CTybGc?LdevWpzO#f z-*3W)aejllmIBxmR*Da?eE7pqv0>%;Hc#Uv)ps7?m8qNnHN=sI$FkV7eW}{}TAn$S zHg6Bg&hI|V?h@Ezan6$Y{`opQO#*0nQ5iwS-?)k}q~v}==!;5zHyKcyE-Qx*Ps-!( zCu2)bzr!6+vd;m(R31&VkwEI5!E;04;Cgy;zbsB&Lhmn z8i3+G*^xo?6HSvvK8SP8rLP+{^$TMv$iOP5Gab{W_4jp5dSw+V%i?S4Qh;9D5_B~C zJWO=wU(Jps^d_6?6lJF`Dl;`Z0O2Pggz>hCx=$F75l(3@n?3llSuPKyP^m^Wbv)de z@pkOI+{md`sTdRrB`KjprBZF=dQRZNr{s-iluD)frveGRtulwL z+P=Q)WbysvmG)<6QaIG-CSFy)%Ggd+z2?%8Ar3L4lJc0-B5RHLD>ES*-;y%W+w}+af)4lSqxN`o#u(Y%2lkEZJ-~gKDGK+x zG8v}kzWFgQ1`?Fw{MA=DBxkjSriZw?syw~}I21?vAROO+tkkee>2p0QwmmxQY{3yA Ot~DzH{P@cwzW*Dl1|~)T literal 0 HcmV?d00001 diff --git a/tests/plots/test_plot_text.py b/tests/plots/test_plot_text.py index 9a1cd68e51..4e11f9ae13 100644 --- a/tests/plots/test_plot_text.py +++ b/tests/plots/test_plot_text.py @@ -6,7 +6,9 @@ from tempfile import TemporaryFile import matplotlib.patheffects as mpatheffects +import matplotlib.pyplot as plt import numpy as np +import pytest from metpy.plots import scattertext from metpy.testing import autoclose_figure @@ -26,3 +28,12 @@ def test_scattertext_patheffect_empty(): # Need to trigger a render with TemporaryFile('wb') as fobj: fig.savefig(fobj) + + +@pytest.mark.mpl_image_compare(remove_text=True, style='default', tolerance=0.069) +def test_scattertext_scalar_text(): + """Test that scattertext can work properly with multiple points but single text.""" + x, y = np.arange(6).reshape(2, 3) + fig, ax = plt.subplots() + scattertext(ax, x, y, 'H') + return fig \ No newline at end of file From 393da7c20a5d3a32e511b835b856cea9f9e79043 Mon Sep 17 00:00:00 2001 From: Ryan May Date: Tue, 7 Jan 2025 19:26:23 -0700 Subject: [PATCH 4/7] ENH: Add formatter support to scattertext() This allows scattertext to handle converting an array of values to a sequence of strings. --- src/metpy/plots/text.py | 17 ++++++++++++++++- tests/plots/test_plot_text.py | 12 +++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/metpy/plots/text.py b/src/metpy/plots/text.py index 16eb81171a..6b0573efb2 100644 --- a/src/metpy/plots/text.py +++ b/src/metpy/plots/text.py @@ -18,7 +18,7 @@ @exporter.export -def scattertext(ax, x, y, texts, loc=(0, 0), **kw): +def scattertext(ax, x, y, texts, loc=(0, 0), formatter=None, **kw): """Add text to the axes. Add text in string `s` to axis at location `x`, `y`, data @@ -39,6 +39,12 @@ def scattertext(ax, x, y, texts, loc=(0, 0), **kw): Offset (in screen coordinates) from x,y position. Allows positioning text relative to original point. Default is (0, 0), which is no offset. + formatter : str or Callable, optional + How to format the each entry in `texts` for plotting. If a string, it should be + compatible with the :func:`format` builtin. If a callable, this should take a + value and return a string. Default is ``None``, which performs no formatting and + requires every object in `texts` to be a `str`. + Other Parameters ---------------- kwargs : `~matplotlib.text.TextCollection` properties. @@ -75,6 +81,15 @@ def scattertext(ax, x, y, texts, loc=(0, 0), **kw): # Handle masked arrays x, y, texts = cbook.delete_masked_points(x, y, texts) + if formatter is not None: + if not callable(formatter): + fmt = formatter + + def formatter(s): + """Turn a format string into a callable.""" + return format(s, fmt) + texts = [formatter(v) for v in texts] + # If there is nothing left after deleting the masked points, return None if x.size == 0: return None diff --git a/tests/plots/test_plot_text.py b/tests/plots/test_plot_text.py index 4e11f9ae13..a3a66fe3a8 100644 --- a/tests/plots/test_plot_text.py +++ b/tests/plots/test_plot_text.py @@ -36,4 +36,14 @@ def test_scattertext_scalar_text(): x, y = np.arange(6).reshape(2, 3) fig, ax = plt.subplots() scattertext(ax, x, y, 'H') - return fig \ No newline at end of file + return fig + + +def test_scattertext_formatter(): + """Test that scattertext supports formatting arguments.""" + x, y = np.arange(6).reshape(2, 3) + vals = [1, 2, 3] + with autoclose_figure() as fig: + ax = fig.add_subplot(1, 1, 1) + tc = scattertext(ax, x, y, vals, formatter='02d') + assert tc.text == ['01', '02', '03'] From 041a077f89bc606dc83a8b8cbff80f0c913c3c51 Mon Sep 17 00:00:00 2001 From: Ryan May Date: Fri, 12 Jan 2024 17:13:03 -0700 Subject: [PATCH 5/7] DOC: Switch example to use scattertext Replaces less intuitive use of StationPlot. --- examples/plots/Plotting_Surface_Analysis.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/examples/plots/Plotting_Surface_Analysis.py b/examples/plots/Plotting_Surface_Analysis.py index 6b4f5fdc7f..88cf04a677 100644 --- a/examples/plots/Plotting_Surface_Analysis.py +++ b/examples/plots/Plotting_Surface_Analysis.py @@ -17,8 +17,8 @@ from metpy.cbook import get_test_data from metpy.io import parse_wpc_surface_bulletin -from metpy.plots import (add_metpy_logo, ColdFront, OccludedFront, StationaryFront, - StationPlot, WarmFront) +from metpy.plots import (add_metpy_logo, ColdFront, OccludedFront, scattertext, + StationaryFront, WarmFront) ########################################### # Define a function that can be used to readily plot a bulletin that has been parsed into a @@ -45,9 +45,10 @@ def plot_bulletin(ax, data): for field in ('HIGH', 'LOW'): rows = data[data.feature == field] x, y = zip(*((pt.x, pt.y) for pt in rows.geometry), strict=False) - sp = StationPlot(ax, x, y, transform=ccrs.PlateCarree(), clip_on=True) - sp.plot_text('C', [field[0]] * len(x), **complete_style[field]) - sp.plot_parameter('S', rows.strength, **complete_style[field]) + scattertext(ax, x, y, field[0], + **complete_style[field], transform=ccrs.PlateCarree(), clip_on=True) + scattertext(ax, x, y, rows.strength, formatter='.0f', loc=(0, -10), + **complete_style[field], transform=ccrs.PlateCarree(), clip_on=True) # Handle all the boundary types for field in ('WARM', 'COLD', 'STNRY', 'OCFNT', 'TROF'): From 03cf123da2ba281109aeed0d29ecf7074659d653 Mon Sep 17 00:00:00 2001 From: Ryan May Date: Tue, 7 Jan 2025 19:48:02 -0700 Subject: [PATCH 6/7] DOC: Add example using high/low identification --- examples/calculations/High_Low_Analysis.py | 62 ++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 examples/calculations/High_Low_Analysis.py diff --git a/examples/calculations/High_Low_Analysis.py b/examples/calculations/High_Low_Analysis.py new file mode 100644 index 0000000000..01e3ac15a2 --- /dev/null +++ b/examples/calculations/High_Low_Analysis.py @@ -0,0 +1,62 @@ +# Copyright (c) 2025 MetPy Developers. +# Distributed under the terms of the BSD 3-Clause License. +# SPDX-License-Identifier: BSD-3-Clause +""" +================= +High/Low Analysis +================= + +This uses MetPy's `find_peaks` function to automatically identify locations of high and low +centers, and then plots them on a map. +""" + +import cartopy.crs as ccrs +import cartopy.feature as cfeature +import matplotlib.pyplot as plt +import xarray as xr + +from metpy.calc import find_peaks +from metpy.cbook import get_test_data +from metpy.plots import add_metpy_logo, scattertext +from metpy.units import units + +########################################### +# Start by loading some data from our sample GFS model dataset. Pull out the geopotential +# heights field for the 850 hPa layer, as well as grab the projection metadata. +data = xr.open_dataset(get_test_data('GFS_test.nc', as_file_obj=False)).metpy.parse_cf() +mslp = data.Geopotential_height_isobaric.metpy.sel(vertical=850 * units.hPa).squeeze() +dataproj = mslp.metpy.cartopy_crs + + +########################################### +# Here we use `find_peaks` to find the locations of the highs and then the lows +h_y, h_x = find_peaks(mslp.values) +l_y, l_x = find_peaks(mslp.values, maxima=False) + +########################################### +# Plot the analyzed locations on top of the contours of height on a map +fig = plt.figure(figsize=(11., 8.)) +ax = fig.add_subplot(1, 1, 1, projection=ccrs.LambertConformal(central_longitude=-95)) +ax.contour(mslp.metpy.x, mslp.metpy.y, mslp, range(0, 2000, 30), + colors='k', linewidths=1.25, linestyles='solid', transform=dataproj) + +# Using scattertext() plot the high centers using a red 'H' and put the height value +# below the 'H' using a smaller font. +scattertext(ax, mslp.metpy.x[h_x], mslp.metpy.y[h_y], 'H', size=20, color='red', + fontweight='bold', transform=dataproj) +scattertext(ax, mslp.metpy.x[h_x], mslp.metpy.y[h_y], mslp.values[h_y, h_x], formatter='.0f', + size=12, color='red', loc=(0, -15), fontweight='bold', transform=dataproj) + +# Now do the same for the lows using a blue 'L' +scattertext(ax, mslp.metpy.x[l_x], mslp.metpy.y[l_y], 'L', size=20, color='blue', + fontweight='bold', transform=dataproj) +scattertext(ax, mslp.metpy.x[l_x], mslp.metpy.y[l_y], mslp.values[l_y, l_x], formatter='.0f', + size=12, color='blue', loc=(0, -15), fontweight='bold', transform=dataproj) + +ax.add_feature(cfeature.OCEAN) +ax.add_feature(cfeature.LAND) +ax.add_feature(cfeature.COASTLINE) + +ax.set_title('Automated 850hPa High and Low Locations') +add_metpy_logo(fig, 275, 295, size='large') +plt.show() From c1256a5b7d1c68f504098c6f1281804b785854aa Mon Sep 17 00:00:00 2001 From: Ryan May Date: Wed, 8 Jan 2025 14:09:25 -0700 Subject: [PATCH 7/7] DOC: Mark GEMPAK HIGH/LOW as implemented This uses our new find_peaks implementation. --- docs/userguide/gempak.rst | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/userguide/gempak.rst b/docs/userguide/gempak.rst index 43eeaff5b9..6c7e219fcb 100644 --- a/docs/userguide/gempak.rst +++ b/docs/userguide/gempak.rst @@ -486,11 +486,11 @@ blue is uncertain of parity, and white is unevaluated. - HIGH(S, RADIUS) - Relative maxima over a grid - - - + HIGH(S, RADIUS) + Relative maxima over a grid + metpy.calc.find_peaks + Yes + No @@ -534,11 +534,11 @@ blue is uncertain of parity, and white is unevaluated. - LOWS(S, RADIUS) - Relative minima over a grid - - - + LOWS(S, RADIUS) + Relative minima over a grid + metpy.calc.find_peaks + Yes + No