From 38d80f89abc4c39d077505c4d6f27c4db699eeee Mon Sep 17 00:00:00 2001 From: ahuang11 Date: Sat, 5 May 2018 11:17:55 -0500 Subject: [PATCH 1/4] Add parse_angle() example to calculations page. --- examples/calculations/Parse_Angles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/calculations/Parse_Angles.py b/examples/calculations/Parse_Angles.py index a58376ca1dd..86cf68fbc06 100644 --- a/examples/calculations/Parse_Angles.py +++ b/examples/calculations/Parse_Angles.py @@ -1,4 +1,4 @@ -# Copyright (c) 2015-2018 MetPy Developers. +# Copyright (c) 2019 MetPy Developers. # Distributed under the terms of the BSD 3-Clause License. # SPDX-License-Identifier: BSD-3-Clause """ From 23667939adab8deb6b3306591d33ec7f3cf799f5 Mon Sep 17 00:00:00 2001 From: ahuang11 Date: Sat, 5 May 2018 11:22:58 -0500 Subject: [PATCH 2/4] Fix example text Optimized parse_angle() for long list of directional strings. Move "from operator import itemgetter" up one line. Revise how DIR_DICT is initialized; will use BASE_DEGREE_MULTIPLIER more in another PR. Remove print in test_tools Add back extra line... Handle np.array and pd.Series for parse_angle() and add new angle_to_dir() Fix example heading section Add full=True example Imperative Mood and newline between import group Update templates doc Fix CI Add back year --- docs/_templates/overrides/metpy.calc.rst | 1 + examples/calculations/Angle_to_Direction.py | 40 ++++++ examples/calculations/Parse_Angles.py | 2 +- metpy/calc/tests/test_calc_tools.py | 143 ++++++++++++++++++-- metpy/calc/tools.py | 142 +++++++++++++++++-- 5 files changed, 309 insertions(+), 19 deletions(-) create mode 100644 examples/calculations/Angle_to_Direction.py diff --git a/docs/_templates/overrides/metpy.calc.rst b/docs/_templates/overrides/metpy.calc.rst index 30403151d8f..732552be4a6 100644 --- a/docs/_templates/overrides/metpy.calc.rst +++ b/docs/_templates/overrides/metpy.calc.rst @@ -173,6 +173,7 @@ Other .. autosummary:: :toctree: ./ + angle_to_direction find_bounding_indices find_intersections get_layer diff --git a/examples/calculations/Angle_to_Direction.py b/examples/calculations/Angle_to_Direction.py new file mode 100644 index 00000000000..780c4ab238f --- /dev/null +++ b/examples/calculations/Angle_to_Direction.py @@ -0,0 +1,40 @@ +# Copyright (c) 2019 MetPy Developers. +# Distributed under the terms of the BSD 3-Clause License. +# SPDX-License-Identifier: BSD-3-Clause +""" +Angle to Direction +================== + +Demonstrate how to convert angles to direction strings. + +The code below shows how to convert angles into directional text. +It also demonstrates the function's flexibility. +""" +import numpy as np + +import metpy.calc as mpcalc +from metpy.units import units + +########################################### +# Create a test value of an angle +angle_deg = 70 * units('degree') +print(angle_deg) + +########################################### +# Now throw that angle into the function to +# get the corresponding direction +dir_str = mpcalc.angle_to_dir(angle_deg) +print(dir_str) + +########################################### +# The function can also handle array of angles, +# rounding to the nearest direction, handling angles > 360, +# and defaulting to degrees if no units are specified. +angle_deg_list = [0, 361, 719] +dir_str_list = mpcalc.angle_to_dir(angle_deg_list) +print(dir_str_list) + +########################################### +# If you want the unabbrieviated version, input full=True +full_dir_str_list = mpcalc.angle_to_dir(angle_deg_list, full=True) +print(full_dir_str_list) diff --git a/examples/calculations/Parse_Angles.py b/examples/calculations/Parse_Angles.py index 86cf68fbc06..a58376ca1dd 100644 --- a/examples/calculations/Parse_Angles.py +++ b/examples/calculations/Parse_Angles.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019 MetPy Developers. +# Copyright (c) 2015-2018 MetPy Developers. # Distributed under the terms of the BSD 3-Clause License. # SPDX-License-Identifier: BSD-3-Clause """ diff --git a/metpy/calc/tests/test_calc_tools.py b/metpy/calc/tests/test_calc_tools.py index 96308ff13bc..e84febe3d65 100644 --- a/metpy/calc/tests/test_calc_tools.py +++ b/metpy/calc/tests/test_calc_tools.py @@ -10,21 +10,24 @@ import pytest import xarray as xr - -from metpy.calc import (find_bounding_indices, find_intersections, first_derivative, get_layer, - get_layer_heights, gradient, grid_deltas_from_dataarray, interp, - interpolate_nans, laplacian, lat_lon_grid_deltas, log_interp, +from metpy.calc import (angle_to_direction, find_bounding_indices, find_intersections, + first_derivative, get_layer, get_layer_heights, gradient, + grid_deltas_from_dataarray, interp, interpolate_nans, + laplacian, lat_lon_grid_deltas, log_interp, nearest_intersection_idx, parse_angle, pressure_to_height_std, 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, - DIR_STRS) + BASE_DEGREE_MULTIPLIER, DIR_STRS, UND) from metpy.deprecation import MetpyDeprecationWarning from metpy.testing import (assert_almost_equal, assert_array_almost_equal, assert_array_equal, check_and_silence_deprecation) from metpy.units import units +FULL_CIRCLE_DEGREES = np.arange(0, 360, BASE_DEGREE_MULTIPLIER.m) * units.degree + + def test_resample_nn(): """Test 1d nearest neighbor functionality.""" a = np.arange(5.) @@ -648,8 +651,8 @@ def test_laplacian_x_deprecation(deriv_2d_data): def test_parse_angle_abbrieviated(): """Test abbrieviated directional text in degrees.""" - expected_angles_degrees = np.arange(0, 360, 22.5) * units.degree - output_angles_degrees = list(map(parse_angle, DIR_STRS)) + expected_angles_degrees = FULL_CIRCLE_DEGREES + output_angles_degrees = list(map(parse_angle, DIR_STRS[:-1])) assert_array_almost_equal(output_angles_degrees, expected_angles_degrees) @@ -659,7 +662,7 @@ def test_parse_angle_ext(): 'easT', 'east south east', 'south east', ' south southeast', 'SOUTH', 'SOUTH SOUTH WEST', 'southWEST', 'WEST south_WEST', 'WeSt', 'WestNorth West', 'North West', 'NORTH north_WeSt'] - expected_angles_degrees = np.arange(0, 360, 22.5) * units.degree + expected_angles_degrees = FULL_CIRCLE_DEGREES output_angles_degrees = list(map(parse_angle, test_dir_strs)) assert_array_almost_equal(output_angles_degrees, expected_angles_degrees) @@ -670,7 +673,42 @@ def test_parse_angle_mix_multiple(): 'easT', 'east se', 'south east', ' south southeast', 'SOUTH', 'SOUTH SOUTH WEST', 'sw', 'WEST south_WEST', 'w', 'wnw', 'North West', 'nnw'] - expected_angles_degrees = np.arange(0, 360, 22.5) * units.degree + expected_angles_degrees = FULL_CIRCLE_DEGREES + output_angles_degrees = parse_angle(test_dir_strs) + assert_array_almost_equal(output_angles_degrees, expected_angles_degrees) + + +def test_parse_angle_none(): + """Test list of extended (unabbrieviated) directional text in degrees in one go.""" + test_dir_strs = None + expected_angles_degrees = np.nan + output_angles_degrees = parse_angle(test_dir_strs) + assert_array_almost_equal(output_angles_degrees, expected_angles_degrees) + + +def test_parse_angle_invalid_number(): + """Test list of extended (unabbrieviated) directional text in degrees in one go.""" + test_dir_strs = 365. + expected_angles_degrees = np.nan + output_angles_degrees = parse_angle(test_dir_strs) + assert_array_almost_equal(output_angles_degrees, expected_angles_degrees) + + +def test_parse_angle_invalid_arr(): + """Test list of extended (unabbrieviated) directional text in degrees in one go.""" + test_dir_strs = ['nan', None, np.nan, 35, 35.5, 'north', 'andrewiscool'] + expected_angles_degrees = [np.nan, np.nan, np.nan, np.nan, np.nan, 0, np.nan] + output_angles_degrees = parse_angle(test_dir_strs) + assert_array_almost_equal(output_angles_degrees, expected_angles_degrees) + + +def test_parse_angle_mix_multiple_arr(): + """Test list of extended (unabbrieviated) directional text in degrees in one go.""" + test_dir_strs = np.array(['NORTH', 'nne', 'ne', 'east north east', + 'easT', 'east se', 'south east', ' south southeast', + 'SOUTH', 'SOUTH SOUTH WEST', 'sw', 'WEST south_WEST', + 'w', 'wnw', 'North West', 'nnw']) + expected_angles_degrees = FULL_CIRCLE_DEGREES output_angles_degrees = parse_angle(test_dir_strs) assert_array_almost_equal(output_angles_degrees, expected_angles_degrees) @@ -746,6 +784,93 @@ def test_bounding_indices_above(): assert_array_equal(good, np.array([[True, False], [False, True]])) +def test_angle_to_direction(): + """Test single angle in degree.""" + expected_dirs = DIR_STRS[:-1] # UND at -1 + output_dirs = [angle_to_direction(angle) for angle in FULL_CIRCLE_DEGREES] + assert_array_equal(output_dirs, expected_dirs) + + +def test_angle_to_direction_edge(): + """Test single angle edge case (360 and no units) in degree.""" + expected_dirs = 'N' + output_dirs = angle_to_direction(360) + assert_array_equal(output_dirs, expected_dirs) + + +def test_angle_to_direction_list(): + """Test list of angles in degree.""" + expected_dirs = DIR_STRS[:-1] + output_dirs = list(angle_to_direction(FULL_CIRCLE_DEGREES)) + assert_array_equal(output_dirs, expected_dirs) + + +def test_angle_to_direction_arr(): + """Test array of angles in degree.""" + expected_dirs = DIR_STRS[:-1] + output_dirs = angle_to_direction(FULL_CIRCLE_DEGREES) + assert_array_equal(output_dirs, expected_dirs) + + +def test_angle_to_direction_full(): + """Test the `full` keyword argument, expecting unabbrieviated output.""" + expected_dirs = [ + 'North', 'North North East', 'North East', 'East North East', + 'East', 'East South East', 'South East', 'South South East', + 'South', 'South South West', 'South West', 'West South West', + 'West', 'West North West', 'North West', 'North North West' + ] + output_dirs = angle_to_direction(FULL_CIRCLE_DEGREES, full=True) + assert_array_equal(output_dirs, expected_dirs) + + +def test_angle_to_direction_invalid_scalar(): + """Test invalid angle.""" + expected_dirs = UND + output_dirs = angle_to_direction(None) + assert_array_equal(output_dirs, expected_dirs) + + +def test_angle_to_direction_invalid_arr(): + """Test array of invalid angles.""" + expected_dirs = ['NE', UND, UND, UND, 'N'] + output_dirs = angle_to_direction(['46', None, np.nan, None, '362.']) + assert_array_equal(output_dirs, expected_dirs) + + +def test_angle_to_direction_level_4(): + """Test non-existent level of complexity.""" + with pytest.raises(ValueError) as exc: + angle_to_direction(FULL_CIRCLE_DEGREES, level=4) + assert 'cannot be less than 1 or greater than 3' in str(exc.value) + + +def test_angle_to_direction_level_3(): + """Test array of angles in degree.""" + expected_dirs = DIR_STRS[:-1] # UND at -1 + output_dirs = angle_to_direction(FULL_CIRCLE_DEGREES, level=3) + assert_array_equal(output_dirs, expected_dirs) + + +def test_angle_to_direction_level_2(): + """Test array of angles in degree.""" + expected_dirs = [ + 'N', 'N', 'NE', 'NE', 'E', 'E', 'SE', 'SE', + 'S', 'S', 'SW', 'SW', 'W', 'W', 'NW', 'NW' + ] + output_dirs = angle_to_direction(FULL_CIRCLE_DEGREES, level=2) + assert_array_equal(output_dirs, expected_dirs) + + +def test_angle_to_direction_level_1(): + """Test array of angles in degree.""" + expected_dirs = [ + 'N', 'N', 'N', 'E', 'E', 'E', 'E', 'S', 'S', 'S', 'S', + 'W', 'W', 'W', 'W', 'N'] + output_dirs = angle_to_direction(FULL_CIRCLE_DEGREES, level=1) + assert_array_equal(output_dirs, expected_dirs) + + def test_3d_gradient_3d_data_no_axes(deriv_4d_data): """Test 3D gradient with 3D data and no axes parameter.""" test = deriv_4d_data[0] diff --git a/metpy/calc/tools.py b/metpy/calc/tools.py index 199e10616f3..fd7bed5bf91 100644 --- a/metpy/calc/tools.py +++ b/metpy/calc/tools.py @@ -1,4 +1,4 @@ -# Copyright (c) 2016,2017,2018 MetPy Developers. +# Copyright (c) 2019 MetPy Developers. # Distributed under the terms of the BSD 3-Clause License. # SPDX-License-Identifier: BSD-3-Clause """Contains a collection of generally useful calculation tools.""" @@ -29,16 +29,21 @@ def normalize_axis_index(a, n): exporter = Exporter(globals()) -DIR_STRS = [ +UND = 'UND' +UND_ANGLE = -999. +DIR_STRS = ( 'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', - 'W', 'WNW', 'NW', 'NNW' -] + 'W', 'WNW', 'NW', 'NNW', + UND +) # note the order matters! +MAX_DEGREE_ANGLE = 360 * units.degree BASE_DEGREE_MULTIPLIER = 22.5 * units.degree DIR_DICT = {dir_str: i * BASE_DEGREE_MULTIPLIER for i, dir_str in enumerate(DIR_STRS)} +DIR_DICT[UND] = np.nan @exporter.export @@ -1322,7 +1327,7 @@ def parse_angle(input_dir): Parameters ---------- - input_dir : string or array-like strings + input_dir : string or array-like Directional text such as west, [south-west, ne], etc Returns @@ -1333,14 +1338,27 @@ def parse_angle(input_dir): """ if isinstance(input_dir, str): # abb_dirs = abbrieviated directions - abb_dirs = [_abbrieviate_direction(input_dir)] - elif isinstance(input_dir, list): - input_dir_str = ','.join(input_dir) + abb_dirs = _clean_direction([_abbrieviate_direction(input_dir)]) + elif hasattr(input_dir, '__len__'): # handle np.array, pd.Series, list, and array-like + input_dir_str = ','.join(_clean_direction(input_dir, preprocess=True)) abb_dir_str = _abbrieviate_direction(input_dir_str) - abb_dirs = abb_dir_str.split(',') + abb_dirs = _clean_direction(abb_dir_str.split(',')) + else: # handle unrecognizable scalar + return np.nan + return itemgetter(*abb_dirs)(DIR_DICT) +def _clean_direction(dir_list, preprocess=False): + """Handle None if preprocess, else handles anything not in DIR_STRS.""" + if preprocess: # primarily to remove None from list so ','.join works + return [UND if not isinstance(the_dir, str) else the_dir + for the_dir in dir_list] + else: # remove extraneous abbrieviated directions + return [UND if the_dir not in DIR_STRS else the_dir + for the_dir in dir_list] + + def _abbrieviate_direction(ext_dir_str): """Convert extended (non-abbrievated) directions to abbrieviation.""" return (ext_dir_str @@ -1353,3 +1371,109 @@ def _abbrieviate_direction(ext_dir_str): .replace('SOUTH', 'S') .replace('WEST', 'W') ) + + +@exporter.export +@preprocess_xarray +def angle_to_direction(input_angle, full=False, level=3): + """Convert the meteorological angle to directional text. + + Works for angles greater than or equal to 360 (360 -> N | 405 -> NE) + and rounds to the nearest angle (355 -> N | 404 -> NNE) + + Parameters + ---------- + input_angle : numeric or array-like numeric + Angles such as 0, 25, 45, 360, 410, etc + full : boolean + True returns full text (South), False returns abbrieviated text (S) + level : int + Level of detail (3 = N/NNE/NE/ENE/E... 2 = N/NE/E/SE... 1 = N/E/S/W) + + Returns + ------- + direction + The directional text + + """ + try: # strip units temporarily + origin_units = input_angle.units + input_angle = input_angle.m + except AttributeError: # no units associated + origin_units = units.degree + + if not hasattr(input_angle, '__len__') or isinstance(input_angle, str): + input_angle = [input_angle] + scalar = True + else: + scalar = False + + # clean any numeric strings, negatives, and None + # does not handle strings with alphabet + input_angle = np.array(input_angle).astype(float) + with np.errstate(invalid='ignore'): # warns about the np.nan + input_angle[np.where(input_angle < 0)] = np.nan + + input_angle = input_angle * origin_units + + # normalizer used for angles > 360 degree to normalize between 0 - 360 + normalizer = np.array(input_angle.m / MAX_DEGREE_ANGLE.m, dtype=int) + norm_angles = abs(input_angle - MAX_DEGREE_ANGLE * normalizer) + + if level == 3: + nskip = 1 + elif level == 2: + nskip = 2 + elif level == 1: + nskip = 4 + else: + err_msg = 'Level of complexity cannot be less than 1 or greater than 3!' + raise ValueError(err_msg) + + angle_dict = {i * BASE_DEGREE_MULTIPLIER.m * nskip: dir_str + for i, dir_str in enumerate(DIR_STRS[::nskip])} + angle_dict[MAX_DEGREE_ANGLE.m] = 'N' # handle edge case of 360. + angle_dict[UND_ANGLE] = UND + + # round to the nearest angles for dict lookup + # 0.001 is subtracted so there's an equal number of dir_str from + # np.arange(0, 360, 22.5), or else some dir_str will be preferred + + # without the 0.001, level=2 would yield: + # ['N', 'N', 'NE', 'E', 'E', 'E', 'SE', 'S', 'S', + # 'S', 'SW', 'W', 'W', 'W', 'NW', 'N'] + + # with the -0.001, level=2 would yield: + # ['N', 'N', 'NE', 'NE', 'E', 'E', 'SE', 'SE', + # 'S', 'S', 'SW', 'SW', 'W', 'W', 'NW', 'NW'] + + multiplier = np.round( + (norm_angles / BASE_DEGREE_MULTIPLIER / nskip) - 0.001).m + round_angles = (multiplier * BASE_DEGREE_MULTIPLIER.m * nskip) + round_angles[np.where(np.isnan(round_angles))] = UND_ANGLE + + dir_str_arr = itemgetter(*round_angles)(angle_dict) # for array + if full: + dir_str_arr = ','.join(dir_str_arr) + dir_str_arr = _unabbrieviate_direction(dir_str_arr) + if not scalar: + dir_str = dir_str_arr.split(',') + else: + dir_str = dir_str_arr.replace(',', ' ') + else: + dir_str = dir_str_arr + + return dir_str + + +def _unabbrieviate_direction(abb_dir_str): + """Convert abbrieviated directions to non-abbrieviated direction.""" + return (abb_dir_str + .upper() + .replace(UND, 'Undefined ') + .replace('N', 'North ') + .replace('E', 'East ') + .replace('S', 'South ') + .replace('W', 'West ') + .replace(' ,', ',') + ).strip() From aaa58bcc3e73ce02729a53384c3724dc4f7b927f Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Sun, 15 Sep 2019 15:16:06 -0500 Subject: [PATCH 3/4] Remove unused import np --- examples/calculations/Angle_to_Direction.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/calculations/Angle_to_Direction.py b/examples/calculations/Angle_to_Direction.py index 780c4ab238f..8c71158b11a 100644 --- a/examples/calculations/Angle_to_Direction.py +++ b/examples/calculations/Angle_to_Direction.py @@ -10,8 +10,6 @@ The code below shows how to convert angles into directional text. It also demonstrates the function's flexibility. """ -import numpy as np - import metpy.calc as mpcalc from metpy.units import units From 33da3f0cb81638c2b32b6debf188b9dc53bb59d9 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Mon, 16 Sep 2019 20:22:45 -0500 Subject: [PATCH 4/4] Fix method names --- examples/calculations/Angle_to_Direction.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/calculations/Angle_to_Direction.py b/examples/calculations/Angle_to_Direction.py index 8c71158b11a..e1068713978 100644 --- a/examples/calculations/Angle_to_Direction.py +++ b/examples/calculations/Angle_to_Direction.py @@ -21,7 +21,7 @@ ########################################### # Now throw that angle into the function to # get the corresponding direction -dir_str = mpcalc.angle_to_dir(angle_deg) +dir_str = mpcalc.angle_to_direction(angle_deg) print(dir_str) ########################################### @@ -29,10 +29,10 @@ # rounding to the nearest direction, handling angles > 360, # and defaulting to degrees if no units are specified. angle_deg_list = [0, 361, 719] -dir_str_list = mpcalc.angle_to_dir(angle_deg_list) +dir_str_list = mpcalc.angle_to_direction(angle_deg_list) print(dir_str_list) ########################################### # If you want the unabbrieviated version, input full=True -full_dir_str_list = mpcalc.angle_to_dir(angle_deg_list, full=True) +full_dir_str_list = mpcalc.angle_to_direction(angle_deg_list, full=True) print(full_dir_str_list)