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.