diff --git a/docs/_templates/overrides/metpy.calc.rst b/docs/_templates/overrides/metpy.calc.rst index c5920a81505..6df3dd542fa 100644 --- a/docs/_templates/overrides/metpy.calc.rst +++ b/docs/_templates/overrides/metpy.calc.rst @@ -205,6 +205,7 @@ Other azimuth_range_to_lat_lon find_bounding_indices find_intersections + find_local_extrema get_layer get_layer_heights get_perturbation diff --git a/src/metpy/calc/tools.py b/src/metpy/calc/tools.py index 895ad216d7f..648aad4b2ca 100644 --- a/src/metpy/calc/tools.py +++ b/src/metpy/calc/tools.py @@ -781,6 +781,44 @@ def take(indexer): return take +@exporter.export +def find_local_extrema(var, nsize=15, extrema='max'): + r"""Find the local extreme (max/min) values of a 2D array. + + Parameters + ---------- + var : `numpy.array` + The variable to locate the local extrema using the nearest method + from the maximum_filter or minimum_filter from the scipy.ndimage module. + nsize : int + The minimum number of grid points between each local extrema. + Default value is 15. + extrema: str + The value 'max' for local maxima or 'min' for local minima. + Default value is 'max'. + + Returns + ------- + extrema_mask: `numpy.array` + The boolean array of the local extrema. + + See Also + -------- + :func:`~metpy.plots.plot_local_extrema` + + """ + from scipy.ndimage import maximum_filter, minimum_filter + + if extrema == 'max': + extreme_val = maximum_filter(var, nsize, mode='nearest') + elif extrema == 'min': + extreme_val = minimum_filter(var, nsize, mode='nearest') + else: + raise ValueError(f'Invalid value for "extrema": {extrema}. ' + 'Valid options are "max" or "min".') + return var == extreme_val + + @exporter.export @preprocess_and_wrap() def lat_lon_grid_deltas(longitude, latitude, x_dim=-1, y_dim=-2, geod=None): diff --git a/src/metpy/plots/__init__.py b/src/metpy/plots/__init__.py index e9b61e7ff96..c105f0d90d0 100644 --- a/src/metpy/plots/__init__.py +++ b/src/metpy/plots/__init__.py @@ -7,7 +7,7 @@ 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) + convert_gempak_color, plot_local_extrema) from .ctables import * # noqa: F403 from .declarative import * # noqa: F403 from .patheffects import * # noqa: F403 @@ -23,7 +23,7 @@ __all__.extend(station_plot.__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']) + 'convert_gempak_color', 'plot_local_extrema']) set_module(globals()) diff --git a/src/metpy/plots/_util.py b/src/metpy/plots/_util.py index 1135d538e65..28f2605e5fb 100644 --- a/src/metpy/plots/_util.py +++ b/src/metpy/plots/_util.py @@ -283,3 +283,79 @@ def normalize(x): except TypeError: res = cols[normalize(c)] return res + + +def plot_local_extrema(ax, extrema_mask, vals, x, y, symbol, plot_val=True, **kwargs): + """Plot the local extreme (max/min) values of a 2D array. + + The behavior of the plotting will have the symbol horizontal/vertical alignment + be center/bottom and any value plotted will be center/top. The default text size of plotted + values is 0.65 of the symbol size. + + Parameters + ---------- + ax: `matplotlib.axes` + The axes which to plot the local extrema + extrema_mask : `numpy.array` + A boolean array that contains the variable local extrema + vals : `numpy.array` + The variable associated with the extrema_mask + x : `numpy.array` + The x-dimension variable associated with the extrema_vals + y : `numpy.array` + The y-dimension variable associated with the extrema_vals + symbol : str + The text or other string to plot at the local extrema location + plot_val: bool + Whether to plot the local extreme value (default is True) + textsize: int (optional) + Size of plotted extreme values, Default is 0.65 * size + + Returns + ------- + Plots local extrema on the plot axes + + Other Parameters + ---------------- + kwargs : `matplotlib.pyplot.text` properties. + Other valid `matplotlib.pyplot.text` kwargs can be specified + except verticalalalignment if plotting both a symbol and the value. + + Default kwargs: + size : 20 + color : 'black' + fontweight : 'bold' + horizontalalignment : 'center' + verticalalignment : 'center' + transform : None + + See Also + -------- + :func:`~metpy.calc.find_local_extrema` + + """ + defaultkwargs = {'size': 20, 'color': 'black', 'fontweight': 'bold', + 'horizontalalignment': 'center', 'verticalalignment': 'center'} + kwargs = {**defaultkwargs, **kwargs} + if plot_val: + kwargs.pop('verticalalignment') + size = kwargs.pop('size') + textsize = kwargs.pop('textsize', size * 0.65) + + extreme_vals = vals[extrema_mask] + if x.ndim == 1: + xx, yy = np.meshgrid(x, y) + else: + xx = x + yy = y + extreme_x = xx[extrema_mask] + extreme_y = yy[extrema_mask] + for extrema, ex_x, ex_y in zip(extreme_vals, extreme_x, extreme_y): + if plot_val: + ax.text(ex_x, ex_y, symbol, clip_on=True, clip_box=ax.bbox, size=size, + verticalalignment='bottom', **kwargs) + ax.text(ex_x, ex_y, f'{extrema:.0f}', clip_on=True, clip_box=ax.bbox, + size=textsize, verticalalignment='top', **kwargs) + else: + ax.text(ex_x, ex_y, symbol, clip_on=True, clip_box=ax.bbox, size=size, + **kwargs) diff --git a/tests/calc/test_calc_tools.py b/tests/calc/test_calc_tools.py index 4eeda3f40fe..7e493fe91d2 100644 --- a/tests/calc/test_calc_tools.py +++ b/tests/calc/test_calc_tools.py @@ -13,10 +13,11 @@ 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) + find_local_extrema, 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.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, @@ -475,6 +476,48 @@ def test_get_layer_heights_agl_bottom_no_interp(): assert_array_almost_equal(data_true, data, 6) +@pytest.fixture +def local_extrema_data(): + """Test data for local extrema finding.""" + data = xr.DataArray( + np.array([[101628.24, 101483.67, 101366.06, 101287.55, 101233.45], + [101637.19, 101515.555, 101387.164, 101280.32, 101210.15], + [101581.78, 101465.234, 101342., 101233.22, 101180.25], + [101404.31, 101318.4, 101233.18, 101166.445, 101159.93], + [101280.586, 101238.445, 101195.234, 101183.34, 101212.8]]), + name='mslp', + dims=('lat', 'lon'), + coords={'lat': xr.DataArray(np.array([45., 43., 41., 39., 37.]), + dims=('lat',), attrs={'units': 'degrees_north'}), + 'lon': xr.DataArray(np.array([265., 267., 269., 271., 273.]), + dims=('lon',), attrs={'units': 'degrees_east'})}, + attrs={'units': 'Pa'} + ) + return data + + +def test_find_local_extrema(local_extrema_data): + """Test find_local_extrema function for maximum.""" + local_max = find_local_extrema(local_extrema_data.values, 3, 'max') + local_min = find_local_extrema(local_extrema_data.values, 3, 'min') + + max_truth = np.array([[False, False, False, False, False], + [True, False, False, False, False], + [False, False, False, False, False], + [False, False, False, False, False], + [False, False, False, False, True]]) + min_truth = np.array([[False, False, False, False, False], + [False, False, False, False, False], + [False, False, False, False, False], + [False, False, False, False, True], + [False, False, False, False, False]]) + assert_array_almost_equal(local_max, max_truth) + assert_array_almost_equal(local_min, min_truth) + + with pytest.raises(ValueError): + find_local_extrema(local_extrema_data, 3, 'large') + + def test_lat_lon_grid_deltas_1d(): """Test for lat_lon_grid_deltas for variable grid.""" lat = np.arange(40, 50, 2.5) diff --git a/tests/plots/baseline/test_plot_extrema.png b/tests/plots/baseline/test_plot_extrema.png new file mode 100644 index 00000000000..0ef47eefbb4 Binary files /dev/null and b/tests/plots/baseline/test_plot_extrema.png differ diff --git a/tests/plots/test_util.py b/tests/plots/test_util.py index 3dab3dcd868..6aee06ba4c5 100644 --- a/tests/plots/test_util.py +++ b/tests/plots/test_util.py @@ -11,7 +11,9 @@ import pytest import xarray as xr -from metpy.plots import add_metpy_logo, add_timestamp, add_unidata_logo, convert_gempak_color +from metpy.calc import find_local_extrema +from metpy.plots import (add_metpy_logo, add_timestamp, add_unidata_logo, convert_gempak_color, + plot_local_extrema) from metpy.testing import get_test_data MPL_VERSION = matplotlib.__version__[:3] @@ -154,3 +156,28 @@ def test_gempak_color_scalar(): mplc = convert_gempak_color(6) truth = 'cyan' assert mplc == truth + + +@pytest.mark.mpl_image_compare(remove_text=True) +def test_plot_extrema(): + """Test plotting of local max/min values.""" + data = xr.open_dataset(get_test_data('GFS_test.nc', as_file_obj=False)) + + mslp = data.Pressure_reduced_to_MSL_msl.squeeze().metpy.convert_units('hPa') + relmax2d = find_local_extrema(mslp.values, 10, 'max') + relmin2d = find_local_extrema(mslp.values, 15, 'min') + + fig = plt.figure(figsize=(8., 8.)) + ax = fig.add_subplot(1, 1, 1) + + # Plot MSLP + clevmslp = np.arange(800., 1120., 4) + ax.contour(mslp.lon, mslp.lat, mslp, + clevmslp, colors='k', linewidths=1.25, linestyles='solid') + + plot_local_extrema(ax, relmax2d, mslp.values, mslp.lon, mslp.lat, + 'H', plot_val=False, color='tab:red') + plot_local_extrema(ax, relmin2d, mslp.values, mslp.lon, mslp.lat, + 'L', color='tab:blue') + + return fig