diff --git a/examples/plots/spc_probabilistic_tornado_outlook.py b/examples/plots/spc_probabilistic_tornado_outlook.py new file mode 100644 index 00000000000..84aea3fcb0c --- /dev/null +++ b/examples/plots/spc_probabilistic_tornado_outlook.py @@ -0,0 +1,56 @@ +# Copyright (c) 2021 MetPy Developers. +# Distributed under the terms of the BSD 3-Clause License. +# SPDX-License-Identifier: BSD-3-Clause +""" +NOAA SPC Probabilistic Outlook +============================== + +Demonstrate the use of geoJSON and shapefile data with PlotGeometry in MetPy's simplified +plotting interface. This example walks through plotting the Day 1 Probabilistic Tornado +Outlook from NOAA Storm Prediction Center. The geoJSON file was retrieved from the +`Storm Prediction Center's archives `_. +""" + +import geopandas + +from metpy.cbook import get_test_data +from metpy.plots import MapPanel, PanelContainer, PlotGeometry + +########################### +# Read in the geoJSON file containing the convective outlook. +day1_outlook = geopandas.read_file( + get_test_data('spc_day1otlk_20210317_1200_torn.lyr.geojson') +) + +########################### +# Preview the data. +day1_outlook + +########################### +# Plot the shapes from the 'geometry' column. Give the shapes their fill and stroke color by +# providing the 'fill' and 'stroke' columns. Use text from the 'LABEL' column as labels for the +# shapes. For the SIG area, remove the fill and label while adding the proper hatch effect. +geo = PlotGeometry() +geo.geometry = day1_outlook['geometry'] +geo.fill = day1_outlook['fill'] +geo.stroke = day1_outlook['stroke'] +geo.labels = day1_outlook['LABEL'] +sig_index = day1_outlook['LABEL'].values.tolist().index('SIGN') +geo.fill[sig_index] = 'none' +geo.labels[sig_index] = None +geo.label_fontsize = 'large' +geo.hatch = ['SS' if label == 'SIGN' else None for label in day1_outlook['LABEL']] + +########################### +# Add the geometry plot to a panel and container. +panel = MapPanel() +panel.title = 'SPC Day 1 Probabilistic Tornado Outlook (Valid 12z Mar 17 2021)' +panel.plots = [geo] +panel.area = [-120, -75, 25, 50] +panel.projection = 'lcc' +panel.layers = ['lakes', 'land', 'ocean', 'states', 'coastline', 'borders'] + +pc = PanelContainer() +pc.size = (12, 8) +pc.panels = [panel] +pc.show() diff --git a/src/metpy/plots/__init__.py b/src/metpy/plots/__init__.py index d345bff98d7..37c9847eb39 100644 --- a/src/metpy/plots/__init__.py +++ b/src/metpy/plots/__init__.py @@ -3,6 +3,8 @@ # SPDX-License-Identifier: BSD-3-Clause r"""Contains functionality for making meteorological plots.""" +import matplotlib.hatch + # Trigger matplotlib wrappers from . import _mpl # noqa: F401 from . import cartopy_utils @@ -25,6 +27,8 @@ set_module(globals()) +matplotlib.hatch._hatch_types.append(SPCHatch) + def __getattr__(name): """Handle warning if Cartopy map features are not available.""" diff --git a/src/metpy/plots/declarative.py b/src/metpy/plots/declarative.py index ac7cc01cdb2..4f8a0763f0f 100644 --- a/src/metpy/plots/declarative.py +++ b/src/metpy/plots/declarative.py @@ -9,6 +9,8 @@ from itertools import cycle import re +import matplotlib.hatch +import matplotlib.patches as mpatches import matplotlib.patheffects as patheffects import matplotlib.pyplot as plt import numpy as np @@ -498,6 +500,23 @@ def lookup_map_feature(feature_name): return feat.with_scale(scaler) +@exporter.export +class SPCHatch(matplotlib.hatch.Shapes): + """Class to create hatching for significant severe areas.""" + + filled = True + size = 1.0 + path = mpatches.Polygon([[0, 0], [0.4, 0.4]], + closed=True, + fill=False).get_path() + + def __init__(self: 'SPCHatch', hatch: str, density: float): + self.num_rows = (hatch.count('S')) * density + self.shape_vertices = self.path.vertices + self.shape_codes = self.path.codes + matplotlib.hatch.Shapes.__init__(self, hatch, density) + + class MetPyHasTraits(HasTraits): """Provides modification layer on HasTraits for declarative classes.""" @@ -1927,6 +1946,16 @@ class PlotGeometry(MetPyHasTraits): object, and so on. Default value is `fill`. """ + hatch = Union([Instance(collections.abc.Iterable), Unicode()], default_value=None, + allow_none=True) + hatch.__doc__ = """Hatch style for plotted polygons. + + A single string or collection of strings for the hatch style If a collection, the first + string corresponds to the hatching of the first Shapely polygon in `geometry`, the second + string corresponds to the label of the second Shapely polygon, and so on. Default value + is `None`. + """ + @staticmethod @validate('geometry') def _valid_geometry(_, proposal): @@ -1975,6 +2004,17 @@ def _update_label_colors(self, change): elif change['name'] == 'stroke' and self.label_facecolor is None: self.label_facecolor = self.stroke + @staticmethod + @validate('hatch') + def _valid_hatch(_, proposal): + """Cast `hatch` into a list once it is provided by user. + + This is necessary because _build() expects to cycle through a list of hatch styles + when assigning them to the geometry. + """ + hatch = proposal['value'] + return list(hatch) if not isinstance(hatch, str) else [hatch] + @property def name(self): """Generate a name for the plot.""" @@ -2065,14 +2105,15 @@ def _build(self): else self.label_edgecolor) self.label_facecolor = (['none'] if self.label_facecolor is None else self.label_facecolor) + self.hatch = [None] if self.hatch is None else self.hatch # Each Shapely object is plotted separately with its corresponding colors and label - for geo_obj, stroke, fill, label, fontcolor, fontoutline in zip( + for geo_obj, stroke, fill, label, fontcolor, fontoutline, hatch in zip( self.geometry, cycle(self.stroke), cycle(self.fill), cycle(self.labels), - cycle(self.label_facecolor), cycle(self.label_edgecolor)): + cycle(self.label_facecolor), cycle(self.label_edgecolor), cycle(self.hatch)): # Plot the Shapely object with the appropriate method and colors if isinstance(geo_obj, (MultiPolygon, Polygon)): - self.parent.ax.add_geometries([geo_obj], edgecolor=stroke, + self.parent.ax.add_geometries([geo_obj], hatch=hatch, edgecolor=stroke, facecolor=fill, crs=ccrs.PlateCarree()) elif isinstance(geo_obj, (MultiLineString, LineString)): self.parent.ax.add_geometries([geo_obj], edgecolor=stroke, diff --git a/src/metpy/static-data-manifest.txt b/src/metpy/static-data-manifest.txt index 83355a9752e..cefca94e226 100644 --- a/src/metpy/static-data-manifest.txt +++ b/src/metpy/static-data-manifest.txt @@ -207,6 +207,7 @@ nov11_sounding.txt 6fa3e0920314a7d55d6e1020eb934e18d9623c5fb1a40aaad546a25ed225e rbf_test.npz f035f4415ea9bf04dcaf8affd7748f6519638655dcce90dad2b54fe0032bf32d sfstns.tbl f665188f5d5f2ffa892e8c082101adf8245b8f03fbb8e740912d618ac46802c7 spc_day1otlk_20210317_1200_lyr.geojson 785f548d059658340b1b70f69924696c1918b36588c3c083675e725090421484 +spc_day1otlk_20210317_1200_torn.lyr.geojson 500d15f3449e8f3d34491ddc007279bb6dc59099025471000cce985d11debd50 station_data.txt 3c1b71abb95ef8fe4adf57e47e2ce67f3529c6fe025b546dd40c862999fc5ffe stations.txt 5052f237edf0d89f4bcb8fc4a338769ad435177b4361a59ffb80cea64e0f2266 timeseries.csv 2d79f8f21ad1fcec12d0e24750d0958631e92c9148adfbd1b7dc8defe8c56fc5 diff --git a/staticdata/spc_day1otlk_20210317_1200_torn.lyr.geojson b/staticdata/spc_day1otlk_20210317_1200_torn.lyr.geojson new file mode 100644 index 00000000000..52243740406 --- /dev/null +++ b/staticdata/spc_day1otlk_20210317_1200_torn.lyr.geojson @@ -0,0 +1 @@ +{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[-85.71543812233286, 29.550281650071128], [-85.767, 29.696], [-85.985, 29.876], [-86.462, 30.068], [-87.771, 29.878], [-88.406, 29.863], [-88.432, 29.714], [-88.695, 29.394], [-88.581, 29.249], [-88.619, 29.022], [-88.937, 28.691], [-89.175, 28.63], [-89.399, 28.577], [-89.712, 28.693], [-89.828, 28.916], [-90.102, 28.755], [-90.54, 28.711], [-90.745, 28.711], [-91.083, 28.732], [-91.315, 28.893], [-91.556, 28.983], [-91.655, 29.121], [-91.742, 29.058], [-92.079, 29.114], [-92.164, 29.181], [-92.629, 29.233], [-93.243, 29.441], [-93.588, 29.43], [-93.777, 29.35], [-93.79386784706185, 29.349816653836285], [-95.26, 30.12], [-95.62, 30.99], [-95.51, 33.3], [-95.95, 35.35], [-96.39, 36.97], [-96.07, 37.81], [-93.57, 38.27], [-89.44, 38.06], [-87.08, 37.25], [-85.28, 35.81], [-83.15, 34.2], [-82.45, 33.45], [-82.09, 32.83], [-82.37, 32.26], [-83.31, 31.96], [-83.8, 31.79], [-84.55, 31.18], [-85.71543812233286, 29.550281650071128]]]]}, "properties": {"DN": 2, "VALID": "202103171200", "EXPIRE": "202103181200", "ISSUE": "202103170552", "LABEL": "0.02", "LABEL2": "2% Tornado Risk", "stroke": "#005500", "fill": "#66A366"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[-85.94673006258833, 29.844400969109632], [-85.985, 29.876], [-86.462, 30.068], [-87.771, 29.878], [-88.406, 29.863], [-88.432, 29.714], [-88.695, 29.394], [-88.581, 29.249], [-88.619, 29.022], [-88.937, 28.691], [-89.175, 28.63], [-89.399, 28.577], [-89.712, 28.693], [-89.828, 28.916], [-90.102, 28.755], [-90.54, 28.711], [-90.623, 28.711], [-91.59277142857142, 29.03425714285714], [-91.655, 29.121], [-91.71741304347826, 29.075804347826086], [-93.2, 29.57], [-94.73, 31.29], [-95.13, 33.76], [-95.69, 35.79], [-95.96, 36.86], [-95.34, 37.43], [-95.09, 37.7], [-94.17, 37.91], [-91.96, 37.95], [-91.16, 37.84], [-89.38, 37.45], [-87.47, 36.59], [-85.52, 35.29], [-84.14, 34.2], [-82.58, 32.8], [-82.89, 32.4], [-83.96, 31.97], [-84.66, 31.4], [-85.94673006258833, 29.844400969109632]]]]}, "properties": {"DN": 5, "VALID": "202103171200", "EXPIRE": "202103181200", "ISSUE": "202103170552", "LABEL": "0.05", "LABEL2": "5% Tornado Risk", "stroke": "#70380f", "fill": "#9d4e15"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[-94.27, 33.9], [-94.39, 35.51], [-94.41, 36.65], [-93.85, 37.18], [-92.93, 37.29], [-92.0, 37.33], [-90.56, 37.15], [-88.38, 36.42], [-85.71, 35.04], [-84.99, 33.96], [-84.58, 33.08], [-84.65, 31.96], [-85.07, 31.47], [-86.45, 30.71], [-88.17, 30.2], [-90.09, 29.87], [-91.34, 29.83], [-92.79, 29.94], [-93.61, 30.64], [-93.99, 31.91], [-94.27, 33.9]]]]}, "properties": {"DN": 10, "VALID": "202103171200", "EXPIRE": "202103181200", "ISSUE": "202103170552", "LABEL": "0.10", "LABEL2": "10% Tornado Risk", "stroke": "#DDAA00", "fill": "#FFE066"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[-92.64, 33.93], [-92.62, 35.22], [-91.77, 36.14], [-90.41, 35.88], [-88.66, 35.48], [-86.14, 34.51], [-85.45, 33.01], [-85.97, 32.07], [-87.05, 31.65], [-88.57, 30.99], [-89.81, 30.81], [-91.8, 30.52], [-92.83, 30.82], [-93.18, 31.8], [-92.65, 33.31], [-92.64, 33.93]]]]}, "properties": {"DN": 15, "VALID": "202103171200", "EXPIRE": "202103181200", "ISSUE": "202103170552", "LABEL": "0.15", "LABEL2": "15% Tornado Risk", "stroke": "#CC0000", "fill": "#E06666"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[-91.91, 33.06], [-89.87, 34.37], [-88.23, 34.22], [-87.71, 33.5], [-88.22, 32.55], [-90.62, 32.03], [-91.75, 32.06], [-91.91, 33.06]]]]}, "properties": {"DN": 30, "VALID": "202103171200", "EXPIRE": "202103181200", "ISSUE": "202103170552", "LABEL": "0.30", "LABEL2": "30% Tornado Risk", "stroke": "#CC00CC", "fill": "#EE99EE"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[-93.04, 32.56], [-92.91, 34.11], [-93.14, 35.76], [-92.16, 36.64], [-91.06, 36.76], [-89.0, 36.28], [-87.76, 35.78], [-85.79, 34.52], [-85.4, 33.51], [-85.34, 32.95], [-85.92, 31.87], [-89.09, 30.42], [-91.29, 30.22], [-92.93, 30.6], [-93.28, 31.54], [-93.04, 32.56]]]]}, "properties": {"DN": 10, "VALID": "202103171200", "EXPIRE": "202103181200", "ISSUE": "202103170552", "LABEL": "SIGN", "LABEL2": "10% Significant Tornado Risk", "stroke": "#000000", "fill": "#888888"}}]} \ No newline at end of file diff --git a/tests/plots/baseline/test_declarative_spc_hatch.png b/tests/plots/baseline/test_declarative_spc_hatch.png new file mode 100644 index 00000000000..e8473a74881 Binary files /dev/null and b/tests/plots/baseline/test_declarative_spc_hatch.png differ diff --git a/tests/plots/test_declarative.py b/tests/plots/test_declarative.py index e72b5c0d45d..25669b46518 100644 --- a/tests/plots/test_declarative.py +++ b/tests/plots/test_declarative.py @@ -1581,6 +1581,38 @@ def test_declarative_plot_geometry_points(ccrs): return pc.figure +@pytest.mark.mpl_image_compare(remove_text=True) +def test_declarative_spc_hatch(): + """Test SPC hatching effect.""" + from shapely.geometry import Polygon + + sig_area_polygon = Polygon([ + (-93.04, 32.56), (-92.91, 34.11), (-93.14, 35.76), (-92.16, 36.64), + (-91.06, 36.76), (-89.0, 36.28), (-87.76, 35.78), (-85.79, 34.52), + (-85.4, 33.51), (-85.34, 32.95), (-85.92, 31.87), (-89.09, 30.42), + (-91.29, 30.22), (-92.93, 30.6), (-93.28, 31.54), (-93.04, 32.56)]) + + geo = PlotGeometry() + geo.geometry = [sig_area_polygon] + geo.fill = 'none' + geo.stroke = '#000000' + geo.labels = None + geo.hatch = 'SS' + + panel = MapPanel() + panel.plots = [geo] + panel.area = [-120, -75, 25, 50] + panel.projection = 'lcc' + panel.layers = ['states', 'coastline', 'borders'] + + pc = PanelContainer() + pc.size = (12, 8) + pc.panels = [panel] + pc.draw() + + return pc.figure + + @needs_cartopy def test_drop_traitlets_dir(): """Test successful drop of inherited members from HasTraits and any '_' or '__' members."""