Skip to content

Commit

Permalink
Add SPC hatch effect for PlotGeometry
Browse files Browse the repository at this point in the history
Add hatching effect to mimic hatching used for SPC probabilistic severe
weather outlooks. Testing and an example were also added.

Closes Unidata#2063
  • Loading branch information
nawendt committed Sep 8, 2021
1 parent a3424de commit 847d2eb
Show file tree
Hide file tree
Showing 7 changed files with 137 additions and 3 deletions.
56 changes: 56 additions & 0 deletions examples/plots/spc_probabilistic_tornado_outlook.py
Original file line number Diff line number Diff line change
@@ -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 <https://www.spc.noaa.gov/archive/>`_.
"""

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()
4 changes: 4 additions & 0 deletions src/metpy/plots/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand Down
47 changes: 44 additions & 3 deletions src/metpy/plots/declarative.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/metpy/static-data-manifest.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions staticdata/spc_day1otlk_20210317_1200_torn.lyr.geojson
Original file line number Diff line number Diff line change
@@ -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"}}]}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 31 additions & 0 deletions tests/plots/test_declarative.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from io import BytesIO
import warnings

import geopandas
import matplotlib
import numpy as np
import pandas as pd
Expand Down Expand Up @@ -1581,6 +1582,36 @@ 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."""
day1_outlook = geopandas.read_file(
get_test_data('spc_day1otlk_20210317_1200_torn.lyr.geojson')
)

sig_area = day1_outlook.loc[day1_outlook.LABEL == 'SIGN', :]

geo = PlotGeometry()
geo.geometry = sig_area['geometry']
geo.fill = 'none'
geo.stroke = sig_area['stroke']
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."""
Expand Down

0 comments on commit 847d2eb

Please sign in to comment.