Skip to content

Commit 847d2eb

Browse files
committed
Add SPC hatch effect for PlotGeometry
Add hatching effect to mimic hatching used for SPC probabilistic severe weather outlooks. Testing and an example were also added. Closes Unidata#2063
1 parent a3424de commit 847d2eb

7 files changed

+137
-3
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Copyright (c) 2021 MetPy Developers.
2+
# Distributed under the terms of the BSD 3-Clause License.
3+
# SPDX-License-Identifier: BSD-3-Clause
4+
"""
5+
NOAA SPC Probabilistic Outlook
6+
==============================
7+
8+
Demonstrate the use of geoJSON and shapefile data with PlotGeometry in MetPy's simplified
9+
plotting interface. This example walks through plotting the Day 1 Probabilistic Tornado
10+
Outlook from NOAA Storm Prediction Center. The geoJSON file was retrieved from the
11+
`Storm Prediction Center's archives <https://www.spc.noaa.gov/archive/>`_.
12+
"""
13+
14+
import geopandas
15+
16+
from metpy.cbook import get_test_data
17+
from metpy.plots import MapPanel, PanelContainer, PlotGeometry
18+
19+
###########################
20+
# Read in the geoJSON file containing the convective outlook.
21+
day1_outlook = geopandas.read_file(
22+
get_test_data('spc_day1otlk_20210317_1200_torn.lyr.geojson')
23+
)
24+
25+
###########################
26+
# Preview the data.
27+
day1_outlook
28+
29+
###########################
30+
# Plot the shapes from the 'geometry' column. Give the shapes their fill and stroke color by
31+
# providing the 'fill' and 'stroke' columns. Use text from the 'LABEL' column as labels for the
32+
# shapes. For the SIG area, remove the fill and label while adding the proper hatch effect.
33+
geo = PlotGeometry()
34+
geo.geometry = day1_outlook['geometry']
35+
geo.fill = day1_outlook['fill']
36+
geo.stroke = day1_outlook['stroke']
37+
geo.labels = day1_outlook['LABEL']
38+
sig_index = day1_outlook['LABEL'].values.tolist().index('SIGN')
39+
geo.fill[sig_index] = 'none'
40+
geo.labels[sig_index] = None
41+
geo.label_fontsize = 'large'
42+
geo.hatch = ['SS' if label == 'SIGN' else None for label in day1_outlook['LABEL']]
43+
44+
###########################
45+
# Add the geometry plot to a panel and container.
46+
panel = MapPanel()
47+
panel.title = 'SPC Day 1 Probabilistic Tornado Outlook (Valid 12z Mar 17 2021)'
48+
panel.plots = [geo]
49+
panel.area = [-120, -75, 25, 50]
50+
panel.projection = 'lcc'
51+
panel.layers = ['lakes', 'land', 'ocean', 'states', 'coastline', 'borders']
52+
53+
pc = PanelContainer()
54+
pc.size = (12, 8)
55+
pc.panels = [panel]
56+
pc.show()

src/metpy/plots/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
# SPDX-License-Identifier: BSD-3-Clause
44
r"""Contains functionality for making meteorological plots."""
55

6+
import matplotlib.hatch
7+
68
# Trigger matplotlib wrappers
79
from . import _mpl # noqa: F401
810
from . import cartopy_utils
@@ -25,6 +27,8 @@
2527

2628
set_module(globals())
2729

30+
matplotlib.hatch._hatch_types.append(SPCHatch)
31+
2832

2933
def __getattr__(name):
3034
"""Handle warning if Cartopy map features are not available."""

src/metpy/plots/declarative.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from itertools import cycle
1010
import re
1111

12+
import matplotlib.hatch
13+
import matplotlib.patches as mpatches
1214
import matplotlib.patheffects as patheffects
1315
import matplotlib.pyplot as plt
1416
import numpy as np
@@ -498,6 +500,23 @@ def lookup_map_feature(feature_name):
498500
return feat.with_scale(scaler)
499501

500502

503+
@exporter.export
504+
class SPCHatch(matplotlib.hatch.Shapes):
505+
"""Class to create hatching for significant severe areas."""
506+
507+
filled = True
508+
size = 1.0
509+
path = mpatches.Polygon([[0, 0], [0.4, 0.4]],
510+
closed=True,
511+
fill=False).get_path()
512+
513+
def __init__(self: 'SPCHatch', hatch: str, density: float):
514+
self.num_rows = (hatch.count('S')) * density
515+
self.shape_vertices = self.path.vertices
516+
self.shape_codes = self.path.codes
517+
matplotlib.hatch.Shapes.__init__(self, hatch, density)
518+
519+
501520
class MetPyHasTraits(HasTraits):
502521
"""Provides modification layer on HasTraits for declarative classes."""
503522

@@ -1927,6 +1946,16 @@ class PlotGeometry(MetPyHasTraits):
19271946
object, and so on. Default value is `fill`.
19281947
"""
19291948

1949+
hatch = Union([Instance(collections.abc.Iterable), Unicode()], default_value=None,
1950+
allow_none=True)
1951+
hatch.__doc__ = """Hatch style for plotted polygons.
1952+
1953+
A single string or collection of strings for the hatch style If a collection, the first
1954+
string corresponds to the hatching of the first Shapely polygon in `geometry`, the second
1955+
string corresponds to the label of the second Shapely polygon, and so on. Default value
1956+
is `None`.
1957+
"""
1958+
19301959
@staticmethod
19311960
@validate('geometry')
19321961
def _valid_geometry(_, proposal):
@@ -1975,6 +2004,17 @@ def _update_label_colors(self, change):
19752004
elif change['name'] == 'stroke' and self.label_facecolor is None:
19762005
self.label_facecolor = self.stroke
19772006

2007+
@staticmethod
2008+
@validate('hatch')
2009+
def _valid_hatch(_, proposal):
2010+
"""Cast `hatch` into a list once it is provided by user.
2011+
2012+
This is necessary because _build() expects to cycle through a list of hatch styles
2013+
when assigning them to the geometry.
2014+
"""
2015+
hatch = proposal['value']
2016+
return list(hatch) if not isinstance(hatch, str) else [hatch]
2017+
19782018
@property
19792019
def name(self):
19802020
"""Generate a name for the plot."""
@@ -2065,14 +2105,15 @@ def _build(self):
20652105
else self.label_edgecolor)
20662106
self.label_facecolor = (['none'] if self.label_facecolor is None
20672107
else self.label_facecolor)
2108+
self.hatch = [None] if self.hatch is None else self.hatch
20682109

20692110
# Each Shapely object is plotted separately with its corresponding colors and label
2070-
for geo_obj, stroke, fill, label, fontcolor, fontoutline in zip(
2111+
for geo_obj, stroke, fill, label, fontcolor, fontoutline, hatch in zip(
20712112
self.geometry, cycle(self.stroke), cycle(self.fill), cycle(self.labels),
2072-
cycle(self.label_facecolor), cycle(self.label_edgecolor)):
2113+
cycle(self.label_facecolor), cycle(self.label_edgecolor), cycle(self.hatch)):
20732114
# Plot the Shapely object with the appropriate method and colors
20742115
if isinstance(geo_obj, (MultiPolygon, Polygon)):
2075-
self.parent.ax.add_geometries([geo_obj], edgecolor=stroke,
2116+
self.parent.ax.add_geometries([geo_obj], hatch=hatch, edgecolor=stroke,
20762117
facecolor=fill, crs=ccrs.PlateCarree())
20772118
elif isinstance(geo_obj, (MultiLineString, LineString)):
20782119
self.parent.ax.add_geometries([geo_obj], edgecolor=stroke,

src/metpy/static-data-manifest.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ nov11_sounding.txt 6fa3e0920314a7d55d6e1020eb934e18d9623c5fb1a40aaad546a25ed225e
207207
rbf_test.npz f035f4415ea9bf04dcaf8affd7748f6519638655dcce90dad2b54fe0032bf32d
208208
sfstns.tbl f665188f5d5f2ffa892e8c082101adf8245b8f03fbb8e740912d618ac46802c7
209209
spc_day1otlk_20210317_1200_lyr.geojson 785f548d059658340b1b70f69924696c1918b36588c3c083675e725090421484
210+
spc_day1otlk_20210317_1200_torn.lyr.geojson 500d15f3449e8f3d34491ddc007279bb6dc59099025471000cce985d11debd50
210211
station_data.txt 3c1b71abb95ef8fe4adf57e47e2ce67f3529c6fe025b546dd40c862999fc5ffe
211212
stations.txt 5052f237edf0d89f4bcb8fc4a338769ad435177b4361a59ffb80cea64e0f2266
212213
timeseries.csv 2d79f8f21ad1fcec12d0e24750d0958631e92c9148adfbd1b7dc8defe8c56fc5
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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

tests/plots/test_declarative.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from io import BytesIO
88
import warnings
99

10+
import geopandas
1011
import matplotlib
1112
import numpy as np
1213
import pandas as pd
@@ -1581,6 +1582,36 @@ def test_declarative_plot_geometry_points(ccrs):
15811582
return pc.figure
15821583

15831584

1585+
@pytest.mark.mpl_image_compare(remove_text=True)
1586+
def test_declarative_spc_hatch():
1587+
"""Test SPC hatching effect."""
1588+
day1_outlook = geopandas.read_file(
1589+
get_test_data('spc_day1otlk_20210317_1200_torn.lyr.geojson')
1590+
)
1591+
1592+
sig_area = day1_outlook.loc[day1_outlook.LABEL == 'SIGN', :]
1593+
1594+
geo = PlotGeometry()
1595+
geo.geometry = sig_area['geometry']
1596+
geo.fill = 'none'
1597+
geo.stroke = sig_area['stroke']
1598+
geo.labels = None
1599+
geo.hatch = 'SS'
1600+
1601+
panel = MapPanel()
1602+
panel.plots = [geo]
1603+
panel.area = [-120, -75, 25, 50]
1604+
panel.projection = 'lcc'
1605+
panel.layers = ['states', 'coastline', 'borders']
1606+
1607+
pc = PanelContainer()
1608+
pc.size = (12, 8)
1609+
pc.panels = [panel]
1610+
pc.draw()
1611+
1612+
return pc.figure
1613+
1614+
15841615
@needs_cartopy
15851616
def test_drop_traitlets_dir():
15861617
"""Test successful drop of inherited members from HasTraits and any '_' or '__' members."""

0 commit comments

Comments
 (0)