diff --git a/docs/_templates/overrides/metpy.calc.rst b/docs/_templates/overrides/metpy.calc.rst
index e957b8c7555..9ebf730e7d7 100644
--- a/docs/_templates/overrides/metpy.calc.rst
+++ b/docs/_templates/overrides/metpy.calc.rst
@@ -168,6 +168,17 @@ Standard Atmosphere
height_to_pressure_std
pressure_to_height_std
+Smoothing
+---------
+ .. autosummary::
+ :toctree: ./
+
+ smooth_gaussian
+ smooth_window
+ smooth_rectangular
+ smooth_circular
+ smooth_n_point
+
Other
-----
@@ -185,5 +196,4 @@ Other
parse_angle
reduce_point_density
resample_nn_1d
- smooth_gaussian
- smooth_n_point
+
diff --git a/docs/gempak.rst b/docs/gempak.rst
index 9b3600ca1bc..596c6206ec5 100644
--- a/docs/gempak.rst
+++ b/docs/gempak.rst
@@ -758,19 +758,19 @@ blue is uncertain of parity, and white is unevaluated.
|
- SM5S(S) |
- 5-point smoother |
- Issue #659 |
- |
+ SM5S(S) |
+ 5-point smoother |
+ metpy.calc.smooth_n_point |
|
+ No |
|
- SM9S(S) |
- 9-point smoother |
- Issue #659 |
- |
+ SM9S(S) |
+ 9-point smoother |
+ metpy.calc.smooth_n_point |
|
+ No |
|
@@ -1150,11 +1150,11 @@ blue is uncertain of parity, and white is unevaluated.
|
- SM5V(V) |
- 5-point smoother |
- Issue #659 |
- |
+ SM5V(V) |
+ 5-point smoother |
+ metpy.calc.smooth_n_point |
|
+ No |
|
diff --git a/examples/calculations/Smoothing.py b/examples/calculations/Smoothing.py
new file mode 100644
index 00000000000..2afc2e25535
--- /dev/null
+++ b/examples/calculations/Smoothing.py
@@ -0,0 +1,75 @@
+# Copyright (c) 2015-2018 MetPy Developers.
+# Distributed under the terms of the BSD 3-Clause License.
+# SPDX-License-Identifier: BSD-3-Clause
+"""
+Smoothing
+=========
+
+Using MetPy's smoothing functions.
+
+This example demonstrates the various ways that MetPy's smoothing function
+can be utilized. While this example utilizes basic NumPy arrays, these
+functions all work equally well with Pint Quantities or xarray DataArrays.
+"""
+
+from itertools import product
+
+import matplotlib.pyplot as plt
+import numpy as np
+
+import metpy.calc as mpcalc
+
+###########################################
+# Start with a base pattern with random noise
+np.random.seed(61461542)
+size = 128
+x, y = np.mgrid[:size, :size]
+distance = np.sqrt((x - size / 2) ** 2 + (y - size / 2) ** 2)
+raw_data = np.random.random((size, size)) * 0.3 + distance / distance.max() * 0.7
+
+fig, ax = plt.subplots(1, 1, figsize=(4, 4))
+ax.set_title('Raw Data')
+ax.imshow(raw_data, vmin=0, vmax=1)
+ax.axis('off')
+plt.show()
+
+###########################################
+# Now, create a grid showing different smoothing options
+fig, ax = plt.subplots(3, 3, figsize=(12, 12))
+for i, j in product(range(3), range(3)):
+ ax[i, j].axis('off')
+
+# Gaussian Smoother
+ax[0, 0].imshow(mpcalc.smooth_gaussian(raw_data, 3), vmin=0, vmax=1)
+ax[0, 0].set_title('Gaussian - Low Degree')
+
+ax[0, 1].imshow(mpcalc.smooth_gaussian(raw_data, 8), vmin=0, vmax=1)
+ax[0, 1].set_title('Gaussian - High Degree')
+
+# Rectangular Smoother
+ax[0, 2].imshow(mpcalc.smooth_rectangular(raw_data, (3, 7), 2), vmin=0, vmax=1)
+ax[0, 2].set_title('Rectangular - 3x7 Window\n2 Passes')
+
+# 5-point smoother
+ax[1, 0].imshow(mpcalc.smooth_n_point(raw_data, 5, 1), vmin=0, vmax=1)
+ax[1, 0].set_title('5-Point - 1 Pass')
+
+ax[1, 1].imshow(mpcalc.smooth_n_point(raw_data, 5, 4), vmin=0, vmax=1)
+ax[1, 1].set_title('5-Point - 4 Passes')
+
+# Circular Smoother
+ax[1, 2].imshow(mpcalc.smooth_circular(raw_data, 2, 2), vmin=0, vmax=1)
+ax[1, 2].set_title('Circular - Radius 2\n2 Passes')
+
+# 9-point smoother
+ax[2, 0].imshow(mpcalc.smooth_n_point(raw_data, 9, 1), vmin=0, vmax=1)
+ax[2, 0].set_title('9-Point - 1 Pass')
+
+ax[2, 1].imshow(mpcalc.smooth_n_point(raw_data, 9, 4), vmin=0, vmax=1)
+ax[2, 1].set_title('9-Point - 4 Passes')
+
+# Arbitrary Window Smoother
+ax[2, 2].imshow(mpcalc.smooth_window(raw_data, np.diag(np.ones(5)), 2), vmin=0, vmax=1)
+ax[2, 2].set_title('Custom Window (Diagonal) \n2 Passes')
+
+plt.show()
diff --git a/src/metpy/calc/basic.py b/src/metpy/calc/basic.py
index 55cec0b7249..19005b1eea2 100644
--- a/src/metpy/calc/basic.py
+++ b/src/metpy/calc/basic.py
@@ -9,11 +9,13 @@
* heat index
* windchill
"""
+from itertools import product
import warnings
import numpy as np
from scipy.ndimage import gaussian_filter
+from .tools import wrap_output_like
from .. import constants as mpconsts
from ..package_tools import Exporter
from ..units import atleast_1d, check_units, masked_array, units
@@ -685,8 +687,7 @@ def sigma_to_pressure(sigma, pressure_sfc, pressure_top):
@exporter.export
-@preprocess_xarray
-@units.wraps('=A', ('=A', None))
+@wrap_output_like(argument='scalar_grid', match_unit=True)
def smooth_gaussian(scalar_grid, n):
"""Filter with normal distribution of weights.
@@ -778,15 +779,189 @@ def smooth_gaussian(scalar_grid, n):
@exporter.export
-@preprocess_xarray
-@units.wraps('=A', ('=A', None, None))
+@wrap_output_like(argument='scalar_grid', match_unit=True)
+def smooth_window(scalar_grid, window, passes=1, normalize_weights=True):
+ """Filter with an arbitrary window smoother.
+
+ Parameters
+ ----------
+ scalar_grid : array-like
+ N-dimensional scalar grid to be smoothed.
+
+ window : ndarray
+ The window to use in smoothing. Can have dimension less than or equal to N. If
+ dimension less than N, the scalar grid will be smoothed along its trailing dimensions.
+ Shape along each dimension must be odd.
+
+ passes : int
+ The number of times to apply the filter to the grid. Defaults to 1.
+
+ normalize_weights : bool
+ If true, divide the values in window by the sum of all values in the window to obtain
+ the normalized smoothing weights. If false, use supplied values directly as the
+ weights.
+
+ Returns
+ -------
+ array-like
+ The filtered scalar grid.
+
+ Notes
+ -----
+ This function can be applied multiple times to create a more smoothed field and will only
+ smooth the interior points, leaving the end points with their original values (this
+ function will leave an unsmoothed edge of size `(n - 1) / 2` for each `n` in the shape of
+ `window` around the data). If a masked value or NaN values exists in the array, it will
+ propagate to any point that uses that particular grid point in the smoothing calculation.
+ Applying the smoothing function multiple times will propogate NaNs further throughout the
+ domain.
+
+ See Also
+ --------
+ smooth_rectangular, smooth_circular, smooth_n_point, smooth_gaussian
+
+ """
+ def _pad(n):
+ # Return number of entries to pad given length along dimension.
+ return (n - 1) // 2
+
+ def _zero_to_none(x):
+ # Convert zero values to None, otherwise return what is given.
+ return x if x != 0 else None
+
+ def _offset(pad, k):
+ # Return padded slice offset by k entries
+ return slice(_zero_to_none(pad + k), _zero_to_none(-pad + k))
+
+ def _trailing_dims(indexer):
+ # Add ... to the front of an indexer, since we are working with trailing dimensions.
+ return (Ellipsis,) + tuple(indexer)
+
+ # Verify that shape in all dimensions is odd (need to have a neighboorhood around a
+ # central point)
+ if any((size % 2 == 0) for size in window.shape):
+ raise ValueError('The shape of the smoothing window must be odd in all dimensions.')
+
+ # Optionally normalize the supplied weighting window
+ if normalize_weights:
+ weights = window / np.sum(window)
+ else:
+ weights = window
+
+ # Set indexes
+ # Inner index for the centered array elements that are affected by the smoothing
+ inner_full_index = _trailing_dims(_offset(_pad(n), 0) for n in weights.shape)
+ # Indexes to iterate over each weight
+ weight_indexes = tuple(product(*(range(n) for n in weights.shape)))
+
+ # Index for full array elements, offset by the weight index
+ def offset_full_index(weight_index):
+ return _trailing_dims(_offset(_pad(n), weight_index[i] - _pad(n))
+ for i, n in enumerate(weights.shape))
+
+ # TODO: this is not lazy-loading/dask compatible, as it "densifies" the data
+ data = np.array(scalar_grid)
+ for _ in range(passes):
+ # Set values corresponding to smoothing weights by summing over each weight and
+ # applying offsets in needed dimensions
+ data[inner_full_index] = sum(weights[index] * data[offset_full_index(index)]
+ for index in weight_indexes)
+
+ return data
+
+
+@exporter.export
+def smooth_rectangular(scalar_grid, size, passes=1):
+ """Filter with a rectangular window smoother.
+
+ Parameters
+ ----------
+ scalar_grid : array-like
+ N-dimensional scalar grid to be smoothed.
+
+ size : int or sequence of ints
+ Shape of rectangle along the trailing dimension(s) of the scalar grid.
+
+ passes : int
+ The number of times to apply the filter to the grid. Defaults to 1.
+
+ Returns
+ -------
+ array-like
+ The filtered scalar grid.
+
+ Notes
+ -----
+ This function can be applied multiple times to create a more smoothed field and will only
+ smooth the interior points, leaving the end points with their original values (this
+ function will leave an unsmoothed edge of size `(n - 1) / 2` for each `n` in `size` around
+ the data). If a masked value or NaN values exists in the array, it will propagate to any
+ point that uses that particular grid point in the smoothing calculation. Applying the
+ smoothing function multiple times will propogate NaNs further throughout the domain.
+
+ See Also
+ --------
+ smooth_window, smooth_circular, smooth_n_point, smooth_gaussian
+
+ """
+ return smooth_window(scalar_grid, np.ones(size), passes=passes)
+
+
+@exporter.export
+def smooth_circular(scalar_grid, radius, passes=1):
+ """Filter with a circular window smoother.
+
+ Parameters
+ ----------
+ scalar_grid : array-like
+ N-dimensional scalar grid to be smoothed. If more than two axes, smoothing is only
+ done along the last two.
+
+ radius : int
+ Radius of the circular smoothing window. The "diameter" of the circle (width of
+ smoothing window) is 2 * radius + 1 to provide a smoothing window with odd shape.
+
+ passes : int
+ The number of times to apply the filter to the grid. Defaults to 1.
+
+ Returns
+ -------
+ array-like
+ The filtered scalar grid.
+
+ Notes
+ -----
+ This function can be applied multiple times to create a more smoothed field and will only
+ smooth the interior points, leaving the end points with their original values (this
+ function will leave an unsmoothed edge of size `radius` around the data). If a masked
+ value or NaN values exists in the array, it will propagate to any point that uses that
+ particular grid point in the smoothing calculation. Applying the smoothing function
+ multiple times will propogate NaNs further throughout the domain.
+
+ See Also
+ --------
+ smooth_window, smooth_rectangular, smooth_n_point, smooth_gaussian
+
+ """
+ # Generate the circle
+ size = 2 * radius + 1
+ x, y = np.mgrid[:size, :size]
+ distance = np.sqrt((x - radius) ** 2 + (y - radius) ** 2)
+ circle = distance <= radius
+
+ # Apply smoother
+ return smooth_window(scalar_grid, circle, passes=passes)
+
+
+@exporter.export
def smooth_n_point(scalar_grid, n=5, passes=1):
- """Filter with normal distribution of weights.
+ """Filter with an n-point smoother.
Parameters
----------
scalar_grid : array-like or `pint.Quantity`
- Some 2D scalar grid to be smoothed.
+ N-dimensional scalar grid to be smoothed. If more than two axes, smoothing is only
+ done along the last two.
n: int
The number of points to use in smoothing, only valid inputs
@@ -798,41 +973,37 @@ def smooth_n_point(scalar_grid, n=5, passes=1):
Returns
-------
array-like or `pint.Quantity`
- The filtered 2D scalar grid.
+ The filtered scalar grid.
Notes
-----
- This function is a close replication of the GEMPAK function SM5S
- and SM9S depending on the choice of the number of points to use
- for smoothing. This function can be applied multiple times to
- create a more smoothed field and will only smooth the interior
- points, leaving the end points with their original values. If a
- masked value or NaN values exists in the array, it will propagate
- to any point that uses that particular grid point in the smoothing
- calculation. Applying the smoothing function multiple times will
- propagate NaNs further throughout the domain.
+ This function is a close replication of the GEMPAK function SM5S and SM9S depending on the
+ choice of the number of points to use for smoothing. This function can be applied multiple
+ times to create a more smoothed field and will only smooth the interior points, leaving
+ the end points with their original values (this function will leave an unsmoothed edge of
+ size 1 around the data). If a masked value or NaN values exists in the array, it will
+ propagate to any point that uses that particular grid point in the smoothing calculation.
+ Applying the smoothing function multiple times will propogate NaNs further throughout the
+ domain.
+
+ See Also
+ --------
+ smooth_window, smooth_rectangular, smooth_circular, smooth_gaussian
"""
if n == 9:
- p = 0.25
- q = 0.125
- r = 0.0625
+ weights = np.array([[0.0625, 0.125, 0.0625],
+ [0.125, 0.25, 0.125],
+ [0.0625, 0.125, 0.0625]])
elif n == 5:
- p = 0.5
- q = 0.125
- r = 0.0
+ weights = np.array([[0., 0.125, 0.],
+ [0.125, 0.5, 0.125],
+ [0., 0.125, 0.]])
else:
raise ValueError('The number of points to use in the smoothing '
'calculation must be either 5 or 9.')
- smooth_grid = scalar_grid[:].copy()
- for _i in range(passes):
- smooth_grid[1:-1, 1:-1] = (p * smooth_grid[1:-1, 1:-1]
- + q * (smooth_grid[2:, 1:-1] + smooth_grid[1:-1, 2:]
- + smooth_grid[:-2, 1:-1] + smooth_grid[1:-1, :-2])
- + r * (smooth_grid[2:, 2:] + smooth_grid[2:, :-2] +
- + smooth_grid[:-2, 2:] + smooth_grid[:-2, :-2]))
- return smooth_grid
+ return smooth_window(scalar_grid, window=weights, passes=passes, normalize_weights=False)
@exporter.export
diff --git a/src/metpy/calc/tools.py b/src/metpy/calc/tools.py
index 85b060cc964..8282a7b3313 100644
--- a/src/metpy/calc/tools.py
+++ b/src/metpy/calc/tools.py
@@ -3,6 +3,7 @@
# SPDX-License-Identifier: BSD-3-Clause
"""Contains a collection of generally useful calculation tools."""
import functools
+from inspect import signature
from operator import itemgetter
import warnings
@@ -1474,3 +1475,132 @@ def _remove_nans(*variables):
for v in variables:
ret.append(v[~mask])
return ret
+
+
+def wrap_output_like(**wrap_kwargs):
+ """Wrap the output from a function to be like some other data object type.
+
+ Wraps given data to match the units/coordinates/object type of another array. Currently
+ supports:
+
+ - As input (output from wrapped function):
+
+ * ``pint.Quantity``
+ * ``xarray.DataArray``
+ * any type wrappable by ``pint.Quantity``
+
+ - As matched output (final returned value):
+
+ * ``pint.Quantity``
+ * ``xarray.DataArray``
+
+ (if matched output is not one of these types, we instead treat the match as if it was a
+ dimenionless Quantity.)
+
+ This wrapping/conversion follows the following rules:
+
+ - If match_unit is False, for output of Quantity or DataArary respectively,
+
+ * ndarray becomes dimensionless Quantity or unitless DataArray with matching coords
+ * Quantity is unchanged or becomes DataArray with input units and output coords
+ * DataArray is converted to Quantity by accessor or is unchanged
+
+ - If match_unit is True, for output of Quantity or DataArary respectively, with a given
+ unit,
+
+ * ndarray becomes Quantity or DataArray (with matching coords) with output unit
+ * Quantity is converted to output unit, then returned or converted to DataArray with
+ matching coords
+ * DataArray is has units converted via the accessor, then converted to Quantity via
+ the accessor or returned
+
+ The output to match can be specified two ways:
+
+ - Using the `argument` keyword argument, the output is taken from argument of that name
+ from the wrapped function's signature
+ - Using the `other` keyword argument, the output is given directly
+
+ Parameters
+ ----------
+ argument : str
+ specify the name of a single argument from the function signature from which
+ to take the other data object
+ other : `numpy.ndarray` or `pint.Quantity` or `xarray.DataArray`
+ specify the other data object directly
+ match_unit : bool
+ if True and other data object has units, convert output to those units
+ (defaults to False)
+
+ Notes
+ -----
+ This can be extended in the future to support:
+
+ - ``units.wraps``-like behavior
+ - Python scalars vs. NumPy scalars (Issue #1209)
+ - dask (and other duck array) compatibility
+ - dimensionality reduction (particularly with xarray)
+
+ See Also
+ --------
+ preprocess_xarray
+
+ """
+ def decorator(func):
+ @functools.wraps(func)
+ def wrapper(*args, **kwargs):
+ # Determine other
+ if 'other' in wrap_kwargs:
+ other = wrap_kwargs['other']
+ elif 'argument' in wrap_kwargs:
+ other = signature(func).bind(*args, **kwargs).arguments[
+ wrap_kwargs['argument']]
+ else:
+ raise ValueError('Must specify keyword "other" or "argument".')
+
+ # Get result from wrapped function
+ result = func(*args, **kwargs)
+
+ # Proceed with wrapping rules
+ if wrap_kwargs.get('match_unit', False):
+ return _wrap_output_like_matching_units(result, other)
+ else:
+ return _wrap_output_like_not_matching_units(result, other)
+
+ return wrapper
+ return decorator
+
+
+def _wrap_output_like_matching_units(result, match):
+ """Convert result to be like match with matching units for output wrapper."""
+ match_units = str(getattr(match, 'units', ''))
+ output_xarray = isinstance(match, xr.DataArray)
+ if isinstance(result, xr.DataArray):
+ result.metpy.convert_units(match_units)
+ return result if output_xarray else result.metpy.unit_array
+ else:
+ result = result.m_as(match_units) if isinstance(result, units.Quantity) else result
+ if output_xarray:
+ return xr.DataArray(result, dims=match.dims, coords=match.coords,
+ attrs={'units': match_units})
+ else:
+ return units.Quantity(result, match_units)
+
+
+def _wrap_output_like_not_matching_units(result, match):
+ """Convert result to be like match without matching units for output wrapper."""
+ output_xarray = isinstance(match, xr.DataArray)
+ if isinstance(result, xr.DataArray):
+ return result if output_xarray else result.metpy.unit_array
+ else:
+ if isinstance(result, units.Quantity):
+ result_magnitude = result.magnitude
+ result_units = str(result.units)
+ else:
+ result_magnitude = result
+ result_units = ''
+
+ if output_xarray:
+ return xr.DataArray(result_magnitude, dims=match.dims, coords=match.coords,
+ attrs={'units': result_units})
+ else:
+ return units.Quantity(result_magnitude, result_units)
diff --git a/tests/calc/test_basic.py b/tests/calc/test_basic.py
index eab1b52ac97..560df97b2ef 100644
--- a/tests/calc/test_basic.py
+++ b/tests/calc/test_basic.py
@@ -4,15 +4,17 @@
"""Test the `basic` module."""
import numpy as np
+import pandas as pd
import pytest
+import xarray as xr
from metpy.calc import (add_height_to_pressure, add_pressure_to_height,
altimeter_to_sea_level_pressure, altimeter_to_station_pressure,
apparent_temperature, coriolis_parameter, geopotential_to_height,
heat_index, height_to_geopotential, height_to_pressure_std,
- pressure_to_height_std, sigma_to_pressure, smooth_gaussian,
- smooth_n_point, wind_components, wind_direction, wind_speed,
- windchill)
+ pressure_to_height_std, sigma_to_pressure, smooth_circular,
+ smooth_gaussian, smooth_n_point, smooth_rectangular, smooth_window,
+ wind_components, wind_direction, wind_speed, windchill)
from metpy.testing import (assert_almost_equal, assert_array_almost_equal, assert_array_equal)
from metpy.units import units
@@ -608,6 +610,32 @@ def test_smooth_n_pt_wrong_number():
smooth_n_point(hght, 7)
+def test_smooth_n_pt_3d_units():
+ """Test the smooth_n_point function with a 3D array with units."""
+ hght = [[[5640.0, 5640.0, 5640.0, 5640.0, 5640.0],
+ [5684.0, 5676.0, 5666.0, 5659.0, 5651.0],
+ [5728.0, 5712.0, 5692.0, 5678.0, 5662.0],
+ [5772.0, 5748.0, 5718.0, 5697.0, 5673.0],
+ [5816.0, 5784.0, 5744.0, 5716.0, 5684.0]],
+ [[6768.0, 6768.0, 6768.0, 6768.0, 6768.0],
+ [6820.8, 6811.2, 6799.2, 6790.8, 6781.2],
+ [6873.6, 6854.4, 6830.4, 6813.6, 6794.4],
+ [6926.4, 6897.6, 6861.6, 6836.4, 6807.6],
+ [6979.2, 6940.8, 6892.8, 6859.2, 6820.8]]] * units.m
+ shght = smooth_n_point(hght, 9, 2)
+ s_true = [[[5640., 5640., 5640., 5640., 5640.],
+ [5684., 5675.4375, 5666.9375, 5658.8125, 5651.],
+ [5728., 5710.875, 5693.875, 5677.625, 5662.],
+ [5772., 5746.375, 5720.625, 5696.375, 5673.],
+ [5816., 5784., 5744., 5716., 5684.]],
+ [[6768., 6768., 6768., 6768., 6768.],
+ [6820.8, 6810.525, 6800.325, 6790.575, 6781.2],
+ [6873.6, 6853.05, 6832.65, 6813.15, 6794.4],
+ [6926.4, 6895.65, 6864.75, 6835.65, 6807.6],
+ [6979.2, 6940.8, 6892.8, 6859.2, 6820.8]]] * units.m
+ assert_array_almost_equal(shght, s_true)
+
+
def test_smooth_n_pt_temperature():
"""Test the smooth_n_pt function with temperature units."""
t = np.array([[2.73, 3.43, 6.53, 7.13, 4.83],
@@ -642,6 +670,79 @@ def test_smooth_gaussian_temperature():
assert_array_almost_equal(smooth_t, smooth_t_true, 4)
+def test_smooth_window():
+ """Test smooth_window with default configuration."""
+ hght = [[5640., 5640., 5640., 5640., 5640.],
+ [5684., 5676., 5666., 5659., 5651.],
+ [5728., 5712., 5692., 5678., 5662.],
+ [5772., 5748., 5718., 5697., 5673.],
+ [5816., 5784., 5744., 5716., 5684.]] * units.m
+ smoothed = smooth_window(hght, np.array([[1, 0, 1], [0, 0, 0], [1, 0, 1]]))
+ truth = [[5640., 5640., 5640., 5640., 5640.],
+ [5684., 5675., 5667.5, 5658.5, 5651.],
+ [5728., 5710., 5695., 5677., 5662.],
+ [5772., 5745., 5722.5, 5695.5, 5673.],
+ [5816., 5784., 5744., 5716., 5684.]] * units.m
+ assert_array_almost_equal(smoothed, truth)
+
+
+def test_smooth_window_1d_dataarray():
+ """Test smooth_window on 1D DataArray."""
+ temperature = xr.DataArray(
+ [37., 32., 34., 29., 28., 24., 26., 24., 27., 30.],
+ dims=('time',),
+ coords={'time': pd.date_range('2020-01-01', periods=10, freq='H')},
+ attrs={'units': 'degF'})
+ smoothed = smooth_window(temperature, window=np.ones(3) / 3, normalize_weights=False)
+ truth = xr.DataArray(
+ [37., 34.33333333, 31.66666667, 30.33333333, 27., 26., 24.66666667,
+ 25.66666667, 27., 30.],
+ dims=('time',),
+ coords={'time': pd.date_range('2020-01-01', periods=10, freq='H')},
+ attrs={'units': 'degF'})
+ xr.testing.assert_allclose(smoothed, truth)
+
+
+def test_smooth_rectangular():
+ """Test smooth_rectangular with default configuration."""
+ hght = [[5640., 5640., 5640., 5640., 5640.],
+ [5684., 5676., 5666., 5659., 5651.],
+ [5728., 5712., 5692., 5678., 5662.],
+ [5772., 5748., 5718., 5697., 5673.],
+ [5816., 5784., 5744., 5716., 5684.]] * units.m
+ smoothed = smooth_rectangular(hght, (5, 3))
+ truth = [[5640., 5640., 5640., 5640., 5640.],
+ [5684., 5676., 5666., 5659., 5651.],
+ [5728., 5710.66667, 5694., 5677.33333, 5662.],
+ [5772., 5748., 5718., 5697., 5673.],
+ [5816., 5784., 5744., 5716., 5684.]] * units.m
+ assert_array_almost_equal(smoothed, truth, 4)
+
+
+def test_smooth_circular():
+ """Test smooth_circular with default configuration."""
+ hght = [[5640., 5640., 5640., 5640., 5640.],
+ [5684., 5676., 5666., 5659., 5651.],
+ [5728., 5712., 5692., 5678., 5662.],
+ [5772., 5748., 5718., 5697., 5673.],
+ [5816., 5784., 5744., 5716., 5684.]] * units.m
+ smoothed = smooth_circular(hght, 2, 2)
+ truth = [[5640., 5640., 5640., 5640., 5640.],
+ [5684., 5676., 5666., 5659., 5651.],
+ [5728., 5712., 5693.98817, 5678., 5662.],
+ [5772., 5748., 5718., 5697., 5673.],
+ [5816., 5784., 5744., 5716., 5684.]] * units.m
+ assert_array_almost_equal(smoothed, truth, 4)
+
+
+def test_smooth_window_with_bad_window():
+ """Test smooth_window with a bad window size."""
+ temperature = [37, 32, 34, 29, 28, 24, 26, 24, 27, 30] * units.degF
+ with pytest.raises(ValueError) as exc:
+ smooth_window(temperature, np.ones(4))
+ assert 'must be odd in all dimensions' in str(exc)
+
+
def test_altimeter_to_station_pressure_inhg():
"""Test the altimeter to station pressure function with inches of mercury."""
altim = 29.8 * units.inHg
diff --git a/tests/calc/test_calc_tools.py b/tests/calc/test_calc_tools.py
index bbe52d2284f..88f2387b5e1 100644
--- a/tests/calc/test_calc_tools.py
+++ b/tests/calc/test_calc_tools.py
@@ -19,9 +19,10 @@
reduce_point_density, resample_nn_1d, second_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, BASE_DEGREE_MULTIPLIER, DIR_STRS, UND)
+ _remove_nans, BASE_DEGREE_MULTIPLIER, DIR_STRS, UND,
+ wrap_output_like)
from metpy.testing import (assert_almost_equal, assert_array_almost_equal, assert_array_equal)
-from metpy.units import units
+from metpy.units import DimensionalityError, units
FULL_CIRCLE_DEGREES = np.arange(0, 360, BASE_DEGREE_MULTIPLIER.m) * units.degree
@@ -1260,3 +1261,223 @@ def test_remove_nans():
y_expected = np.array([0, 1, 3, 4])
assert_array_almost_equal(x_expected, x_test, 0)
assert_almost_equal(y_expected, y_test, 0)
+
+
+@pytest.mark.parametrize('test, other, match_unit, expected', [
+ (np.arange(4), np.arange(4), False, np.arange(4) * units('dimensionless')),
+ (np.arange(4), np.arange(4), True, np.arange(4) * units('dimensionless')),
+ (np.arange(4), [0] * units.m, False, np.arange(4) * units('dimensionless')),
+ (np.arange(4), [0] * units.m, True, np.arange(4) * units.m),
+ (
+ np.arange(4),
+ xr.DataArray(
+ np.zeros(4),
+ dims=('x',),
+ coords={'x': np.linspace(0, 1, 4)},
+ attrs={'units': 'meter', 'description': 'Just some zeros'}
+ ),
+ False,
+ xr.DataArray(
+ np.arange(4),
+ dims=('x',),
+ coords={'x': np.linspace(0, 1, 4)},
+ attrs={'units': ''}
+ )
+ ),
+ (
+ np.arange(4),
+ xr.DataArray(
+ np.zeros(4),
+ dims=('x',),
+ coords={'x': np.linspace(0, 1, 4)},
+ attrs={'units': 'meter', 'description': 'Just some zeros'}
+ ),
+ True,
+ xr.DataArray(
+ np.arange(4),
+ dims=('x',),
+ coords={'x': np.linspace(0, 1, 4)},
+ attrs={'units': 'meter'}
+ )
+ ),
+ ([2, 4, 8] * units.kg, [0] * units.m, False, [2, 4, 8] * units.kg),
+ ([2, 4, 8] * units.kg, [0] * units.g, True, [2000, 4000, 8000] * units.g),
+ (
+ [2, 4, 8] * units.kg,
+ xr.DataArray(
+ np.zeros(3),
+ dims=('x',),
+ coords={'x': np.linspace(0, 1, 3)},
+ attrs={'units': 'meter'}
+ ),
+ False,
+ xr.DataArray(
+ [2, 4, 8],
+ dims=('x',),
+ coords={'x': np.linspace(0, 1, 3)},
+ attrs={'units': 'kilogram'}
+ )
+ ),
+ (
+ [2, 4, 8] * units.kg,
+ xr.DataArray(
+ np.zeros(3),
+ dims=('x',),
+ coords={'x': np.linspace(0, 1, 3)},
+ attrs={'units': 'gram'}
+ ),
+ True,
+ xr.DataArray(
+ [2000, 4000, 8000],
+ dims=('x',),
+ coords={'x': np.linspace(0, 1, 3)},
+ attrs={'units': 'gram'}
+ )
+ ),
+ (
+ xr.DataArray(
+ np.linspace(0, 1, 5),
+ attrs={'units': 'meter', 'description': 'A range of values'}
+ ),
+ np.arange(4, dtype=np.float64),
+ False,
+ units.Quantity(np.linspace(0, 1, 5), 'meter')
+ ),
+ (
+ xr.DataArray(
+ np.linspace(0, 1, 5),
+ attrs={'units': 'meter', 'description': 'A range of values'}
+ ),
+ [0] * units.kg,
+ False,
+ np.linspace(0, 1, 5) * units.m
+ ),
+ (
+ xr.DataArray(
+ np.linspace(0, 1, 5),
+ attrs={'units': 'meter', 'description': 'A range of values'}
+ ),
+ [0] * units.cm,
+ True,
+ np.linspace(0, 100, 5) * units.cm
+ ),
+ (
+ xr.DataArray(
+ np.linspace(0, 1, 5),
+ attrs={'units': 'meter', 'description': 'A range of values'}
+ ),
+ xr.DataArray(
+ np.zeros(3),
+ dims=('x',),
+ coords={'x': np.linspace(0, 1, 3)},
+ attrs={'units': 'kilogram', 'description': 'Alternative data'}
+ ),
+ False,
+ xr.DataArray(
+ np.linspace(0, 1, 5),
+ attrs={'units': 'meter', 'description': 'A range of values'}
+ )
+ ),
+ (
+ xr.DataArray(
+ np.linspace(0, 1, 5),
+ attrs={'units': 'meter', 'description': 'A range of values'}
+ ),
+ xr.DataArray(
+ np.zeros(3),
+ dims=('x',),
+ coords={'x': np.linspace(0, 1, 3)},
+ attrs={'units': 'centimeter', 'description': 'Alternative data'}
+ ),
+ True,
+ xr.DataArray(
+ np.linspace(0, 100, 5),
+ attrs={'units': 'centimeter', 'description': 'A range of values'}
+ )
+ ),
+])
+def test_wrap_output_like_with_other_kwarg(test, other, match_unit, expected):
+ """Test the wrap output like decorator when using the output kwarg."""
+ @wrap_output_like(other=other, match_unit=match_unit)
+ def almost_identity(arg):
+ return arg
+
+ result = almost_identity(test)
+
+ if hasattr(expected, 'units'):
+ assert expected.units == result.units
+ if isinstance(expected, xr.DataArray):
+ xr.testing.assert_identical(result, expected)
+ else:
+ assert_array_equal(result, expected)
+
+
+@pytest.mark.parametrize('test, other', [
+ ([2, 4, 8] * units.kg, [0] * units.m),
+ (
+ [2, 4, 8] * units.kg,
+ xr.DataArray(
+ np.zeros(3),
+ dims=('x',),
+ coords={'x': np.linspace(0, 1, 3)},
+ attrs={'units': 'meter'}
+ )
+ ),
+ (
+ xr.DataArray(
+ np.linspace(0, 1, 5),
+ attrs={'units': 'meter'}
+ ),
+ [0] * units.kg
+ ),
+ (
+ xr.DataArray(
+ np.linspace(0, 1, 5),
+ attrs={'units': 'meter'}
+ ),
+ xr.DataArray(
+ np.zeros(3),
+ dims=('x',),
+ coords={'x': np.linspace(0, 1, 3)},
+ attrs={'units': 'kilogram'}
+ )
+ ),
+ (
+ xr.DataArray(
+ np.linspace(0, 1, 5),
+ attrs={'units': 'meter', 'description': 'A range of values'}
+ ),
+ np.arange(4, dtype=np.float64)
+ )
+])
+def test_wrap_output_like_with_other_kwarg_raising_dimensionality_error(test, other):
+ """Test the wrap output like decorator when when a dimensionality error is raised."""
+ @wrap_output_like(other=other, match_unit=True)
+ def almost_identity(arg):
+ return arg
+
+ with pytest.raises(DimensionalityError):
+ almost_identity(test)
+
+
+def test_wrap_output_like_with_argument_kwarg():
+ """Test the wrap output like decorator with signature recognition."""
+ @wrap_output_like(argument='a')
+ def double(a):
+ return units.Quantity(2) * a.metpy.unit_array
+
+ test = xr.DataArray([1, 3, 5, 7], attrs={'units': 'm'})
+ expected = xr.DataArray([2, 6, 10, 14], attrs={'units': 'meter'})
+
+ xr.testing.assert_identical(double(test), expected)
+
+
+def test_wrap_output_like_without_control_kwarg():
+ """Test that the wrap output like decorator fails when not provided a control param."""
+ @wrap_output_like()
+ def func(arg):
+ return np.array(arg)
+
+ with pytest.raises(ValueError) as exc:
+ func(0)
+ assert 'Must specify keyword' in str(exc)
diff --git a/tutorials/xarray_tutorial.py b/tutorials/xarray_tutorial.py
index 6f3e9509a15..05d55f922c9 100644
--- a/tutorials/xarray_tutorial.py
+++ b/tutorials/xarray_tutorial.py
@@ -196,6 +196,12 @@
# - ``normal_component``
# - ``tangential_component``
# - ``absolute_momentum``
+# - Smoothing functions
+# - ``smooth_gaussian``
+# - ``smooth_n_point``
+# - ``smooth_window``
+# - ``smooth_rectangular``
+# - ``smooth_circular``
#
# More details can be found by looking at the documentation for the specific function of
# interest.