Skip to content

Commit

Permalink
Traitlets validation of MapPanel's 'area' trait
Browse files Browse the repository at this point in the history
TraitError thrown when bad inputs are provided to 'area' trait. Code to determine map extent using 'area' string is removed from draw() and included in the validation method, _valid_area().
  • Loading branch information
23ccozad committed Jun 17, 2021
1 parent dbc74c0 commit bc1e05a
Show file tree
Hide file tree
Showing 2 changed files with 64 additions and 36 deletions.
83 changes: 47 additions & 36 deletions src/metpy/plots/declarative.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@
# SPDX-License-Identifier: BSD-3-Clause
"""Declarative plotting tools."""

from collections import Counter
import contextlib
import copy
from datetime import datetime, timedelta

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from traitlets import (Any, Bool, Float, HasTraits, Instance, Int, List, observe, Tuple,
Unicode, Union)
import re
from traitlets import (Any, Bool, Float, HasTraits, Instance, Int, List, observe, TraitError,
Tuple, Unicode, Union, validate)

from . import ctables
from . import wx_symbols
Expand Down Expand Up @@ -651,6 +651,39 @@ class MapPanel(Panel):
This trait sets a user-defined title that will plot at the top center of the figure.
"""

@validate('area')
def _valid_area(self, proposal):
"""Check that proposed string or tuple is valid and turn string into a tuple extent."""
area = proposal['value']

# Parse string, check that string is valid, and determine extent based on string
if isinstance(area, str):
try:
region, modifier = re.match(r'(\w+)([-+]*)$', area).groups()
except AttributeError:
raise TraitError('"' + area + '" is not a valid string area.')

if region.lower() not in _areas.keys() and region != 'global':
raise TraitError('"' + area + '" is not a valid string area.')
elif region == 'global':
extent = 'global'
else:
extent = _areas[region.lower()]
zoom = modifier.count('+') - modifier.count('-')
extent = self._zoom_extent(extent, zoom)
# Otherwise, assume area is a tuple and check that latitudes/longitudes are valid
else:
west_lon, east_lon, south_lat, north_lat = area
valid_west = -180 <= west_lon <= 180
valid_east = -180 <= east_lon <= 180
valid_south = -90 <= south_lat <= 90
valid_north = -90 <= north_lat <= 90
if not valid_west or not valid_east or not valid_south or not valid_north:
raise TraitError(str(area) + ' is not a valid latitude/longitude extent.')
extent = area

return extent

@observe('plots')
def _plots_changed(self, change):
"""Handle when our collection of plots changes."""
Expand Down Expand Up @@ -715,24 +748,17 @@ def _zoom_extent(extent, zoom):
If ``zoom`` < 0, the returned extent will be larger (zoomed out)
"""
# Measure the current extent
center_lon = (extent[0] + extent[1]) / 2
center_lat = (extent[2] + extent[3]) / 2
lon_range = extent[1] - extent[0]
lat_range = extent[3] - extent[2]

# Transforming zoom by e^-x prevents multiplication by zero or a negative number below
zoom_multiplier = np.exp(-0.5 * zoom)
west_lon, east_lon, south_lat, north_lat = extent

# Calculate "width" and "height" of new, zoomed extent
new_lon_range = lon_range * zoom_multiplier
new_lat_range = lat_range * zoom_multiplier
# Turn number of pluses and minuses into a number than can scale the latitudes and
# longitudes of our extent
zoom_multiplier = (1 - 2**-zoom) / 2

# Calculate bounds for new, zoomed extent with new "width" and "height"
new_west_lon = center_lon - 0.5 * new_lon_range
new_east_lon = center_lon + 0.5 * new_lon_range
new_south_lat = center_lat - 0.5 * new_lat_range
new_north_lat = center_lat + 0.5 * new_lat_range
# Calculate bounds for new, zoomed extent
new_north_lat = north_lat + (south_lat - north_lat) * zoom_multiplier
new_south_lat = south_lat - (south_lat - north_lat) * zoom_multiplier
new_east_lon = east_lon + (west_lon - east_lon) * zoom_multiplier
new_west_lon = west_lon - (west_lon - east_lon) * zoom_multiplier

return (new_west_lon, new_east_lon, new_south_lat, new_north_lat)

Expand Down Expand Up @@ -770,26 +796,11 @@ def draw(self):
# Only need to run if we've actually changed.
if self._need_redraw:

# Set the extent as appropriate based on the area. One special case for 'global'
# Set the extent as appropriate based on the area. One special case for 'global'.
if self.area == 'global':
self.ax.set_global()
elif self.area is not None:
# Get extent from specified area and zoom in/out with '+' or '-' suffix
if isinstance(self.area, str) and ('+' in self.area or '-' in self.area):
pos = [self.area.find('+'), self.area.find('-')]
split_pos = min([i for i in pos if i > 0])
area = self.area[:split_pos]
modifier = self.area[split_pos:]
extent = _areas[area.lower()]
zoom = Counter(modifier)['+'] - Counter(modifier)['-']
extent = self._zoom_extent(extent, zoom)
# Get extent from specified area
elif isinstance(self.area, str):
extent = _areas[self.area.lower()]
# Otherwise, assume we have a tuple to use as the extent
else:
extent = self.area
self.ax.set_extent(extent, ccrs.PlateCarree())
self.ax.set_extent(self.area, ccrs.PlateCarree())

# Draw all of the plots.
for p in self.plots:
Expand Down
17 changes: 17 additions & 0 deletions tests/plots/test_declarative.py
Original file line number Diff line number Diff line change
Expand Up @@ -1198,6 +1198,23 @@ def test_declarative_region_modifier_zoom_out():
return pc.figure


def test_declarative_bad_area():
"""Test that a invalid string or tuple provided to the area trait raises an error."""
panel = MapPanel()

# Test for string that cannot be grouped into a region and a modifier by regex
with pytest.raises(TraitError):
panel.area = 'a$z+'

# Test for string that is not in our list of areas
with pytest.raises(TraitError):
panel.area = 'PS'

# Test for nonsense coordinates
with pytest.raises(TraitError):
panel.area = (136, -452, -65, -88)


def test_save():
"""Test that our saving function works."""
pc = PanelContainer()
Expand Down

0 comments on commit bc1e05a

Please sign in to comment.