diff --git a/src/metpy/plots/declarative.py b/src/metpy/plots/declarative.py index 74098c39be..540cb13462 100644 --- a/src/metpy/plots/declarative.py +++ b/src/metpy/plots/declarative.py @@ -22,6 +22,7 @@ from ._mpl import TextCollection from .cartopy_utils import import_cartopy from .patheffects import ColdFront, OccludedFront, StationaryFront, WarmFront +from .skewt import SkewT from .station_plot import StationPlot from ..calc import reduce_point_density, smooth_n_point, zoom_xarray from ..package_tools import Exporter @@ -206,8 +207,49 @@ def copy(self): return copy.copy(self) +class PanelTraits(MetPyHasTraits): + """Represent common traits for panels.""" + + title = Unicode() + title.__doc__ = """A string to set a title for the figure. + + This trait sets a user-defined title that will plot at the top center of the figure. + """ + + title_fontsize = Union([Int(), Float(), Unicode()], allow_none=True, default_value=None) + title_fontsize.__doc__ = """An integer or string value for the font size of the title of + the figure. + + This trait sets the font size for the title that will plot at the top center of the figure. + Accepts size in points or relative size. Allowed relative sizes are those of Matplotlib: + 'xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large'. + """ + + left_title = Unicode(allow_none=True, default_value=None) + left_title.__doc__ = """A string to set a title for the figure with the location on the + top left of the figure. + + This trait sets a user-defined title that will plot at the top left of the figure. + """ + + right_title = Unicode(allow_none=True, default_value=None) + right_title.__doc__ = """A string to set a title for the figure with the location on the + top right of the figure. + + This trait sets a user-defined title that will plot at the top right of the figure. + """ + + plots = List(Any()) + plots.__doc__ = """A list of handles that represent the plots (e.g., `ContourPlot`, + `FilledContourPlot`, `ImagePlot`, `SkewPlot`) to put on a given panel. + + This trait collects the different plots, including contours and images, that are intended + for a given panel. + """ + + @exporter.export -class MapPanel(Panel, ValidationMixin): +class MapPanel(Panel, PanelTraits, ValidationMixin): """Set figure related elements for an individual panel. Parameters that need to be set include collecting all plotting types @@ -229,14 +271,6 @@ class MapPanel(Panel, ValidationMixin): `matplotlib.figure.Figure.add_subplot`. """ - plots = List(Any()) - plots.__doc__ = """A list of handles that represent the plots (e.g., `ContourPlot`, - `FilledContourPlot`, `ImagePlot`) to put on a given panel. - - This trait collects the different plots, including contours and images, that are intended - for a given panel. - """ - _need_redraw = Bool(default_value=True) area = Union([Unicode(), Tuple(Float(), Float(), Float(), Float())], allow_none=True, @@ -323,35 +357,6 @@ class MapPanel(Panel, ValidationMixin): provided by user. """ - title = Unicode() - title.__doc__ = """A string to set a title for the figure. - - This trait sets a user-defined title that will plot at the top center of the figure. - """ - - left_title = Unicode(allow_none=True, default_value=None) - left_title.__doc__ = """A string to set a title for the figure with the location on the - top left of the figure. - - This trait sets a user-defined title that will plot at the top left of the figure. - """ - - right_title = Unicode(allow_none=True, default_value=None) - right_title.__doc__ = """A string to set a title for the figure with the location on the - top right of the figure. - - This trait sets a user-defined title that will plot at the top right of the figure. - """ - - title_fontsize = Union([Int(), Float(), Unicode()], allow_none=True, default_value=None) - title_fontsize.__doc__ = """An integer or string value for the font size of the title of - the figure. - - This trait sets the font size for the title that will plot at the top center of the figure. - Accepts size in points or relative size. Allowed relative sizes are those of Matplotlib: - 'xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large'. - """ - @validate('area') def _valid_area(self, proposal): """Check that proposed string or tuple is valid and turn string into a tuple extent.""" @@ -573,6 +578,125 @@ def copy(self): return copy.copy(self) +@exporter.export +class SkewtPanel(PanelTraits, Panel): + """A class to collect skewt plots and set complete figure related settings (e.g., size).""" + + parent = Instance(PanelContainer, allow_none=True) + + ylimits = Tuple(Int(), Int(), default_value=(1000, 100), allow_none=True) + ylimits.__doc__ = """A tuple of y-axis limits to plot the skew-T. + + Order is in higher pressure to lower pressure. Assumption is that y-limit values are + hPa.""" + + xlimits = Tuple(Int(), Int(), default_value=(-40, 40), allow_none=True) + xlimits.__doc__ = """A tuple of x-axis limits to plot the skew-T. + + Order is lower temperature to higher temperature. Assumption is that x-limit values are + Celsius.""" + + ylabel = Unicode(default_value='pressure [hPa]') + ylabel.__doc__ = """A string to plot for the y-axis label. + + Defaults to 'pressure [hPa]'""" + + xlabel = Unicode(default_value='temperature [\N{DEGREE SIGN}C]') + xlabel.__doc__ = """A string to plot for the y-axis label. + + Defaults to 'temperature [C]'""" + + @observe('plots') + def _plots_changed(self, change): + """Handle when our collection of plots changes.""" + for plot in change.new: + plot.parent = self + plot.observe(self.refresh, names=('_need_redraw')) + self._need_redraw = True + + @observe('parent') + def _parent_changed(self, _): + """Handle when the parent is changed.""" + self.ax = None + + @property + def ax(self): + """Get the :class:`matplotlib.axes.Axes` to draw on. + + Creates a new instance if necessary. + + """ + # If we haven't actually made an instance yet, make one with the right size and + # map projection. + if getattr(self, '_ax', None) is None: + self._ax = SkewT(self.parent.figure, rotation=45) + + return self._ax + + @ax.setter + def ax(self, val): + """Set the :class:`matplotlib.axes.Axes` to draw on. + + Clears existing state as necessary. + + """ + if getattr(self, '_ax', None) is not None: + self._ax.cla() + self._ax = val + + def refresh(self, changed): + """Refresh the drawing if necessary.""" + self._need_redraw = changed.new + + def draw(self): + """Draw the panel.""" + # Only need to run if we've actually changed. + if self._need_redraw: + + skew = self.ax + + # Set the extent as appropriate based on the limits. + xmin, xmax = self.xlimits + ymax, ymin = self.ylimits + skew.ax.set_xlim(xmin, xmax) + skew.ax.set_ylim(ymax, ymin) + skew.ax.set_xlabel(self.xlabel) + skew.ax.set_ylabel(self.ylabel) + + # Draw all of the plots. + for p in self.plots: + with p.hold_trait_notifications(): + p.draw() + + skew.plot_labeled_skewt_lines() + + # Use the set title or generate one. + title = self.title or ',\n'.join(plot.name for plot in self.plots) + skew.ax.set_title(title, fontsize=self.title_fontsize) + self._need_redraw = False + + def __copy__(self): + """Return a copy of this SkewPanel.""" + # Create new, blank instance of MapPanel + cls = self.__class__ + obj = cls.__new__(cls) + + # Copy each attribute from current MapPanel to new MapPanel + for name in self.trait_names(): + # The 'plots' attribute is a list. + # A copy must be made for each plot in the list. + if name == 'plots': + obj.plots = [copy.copy(plot) for plot in self.plots] + else: + setattr(obj, name, getattr(self, name)) + + return obj + + def copy(self): + """Return a copy of the panel.""" + return copy.copy(self) + + class SubsetTraits(MetPyHasTraits): """Represent common traits for subsetting data.""" @@ -2266,3 +2390,194 @@ def _build(self): # Plot strengths if provided if strengthvalues is not None: self._draw_strengths(strengthvalues, lon, lat, **kwargs) + + +@exporter.export +class SkewtPlot(MetPyHasTraits, ValidationMixin): + """A class to set plot charactersitics of skewt data.""" + + temperature_variable = List(Unicode()) + temperature_variable.__doc__ = """A list of string names for plotting variables from + dictinary-like object. + + No order in particular is needed, however, to shade cape or cin the order of temperature, + dewpoint temperature, parcel temperature is required.""" + + vertical_variable = Unicode() + vertical_variable.__doc__ = """A string with the vertical variable name (e.g., 'pressure'). + + """ + + linecolor = List(Unicode(default_value='black')) + linecolor.__doc__ = """A list of color names corresponding to the parameters in + `temperature_variables`. + + A list of the same length as `temperature_variables` is preferred, otherwise, colors will + repeat. The default value is 'black'.""" + + linestyle = List(Unicode(default_value='solid')) + linestyle.__doc__ = """A list of line style names corresponding to the parameters in + `temperature_variables`. + + A list of the same length as `temperature_variables` is preferred, otherwise, colors will + repeat. The default value is 'solid'.""" + + linewidth = List(Union([Int(), Float()]), default_value=[1]) + linewidth.__doc__ = """A list of linewidth values corresponding to the parameters in + `temperature_variables`. + + A list of the same length as `temperature_variables` is preferred, otherwise, colors will + repeat. The default value is 1.""" + + shade_cape = Bool(default_value=False) + shade_cape.__doc__ = """A boolean (True/False) on whether to shade the CAPE for the + sounding. + + This parameter uses the default settings from MetPy for plotting CAPE. In order to shade + CAPE, the `temperature_variables` attribute must be in the order of temperature, dewpoint + temperature, parcel temperature. The default value is `False`.""" + + shade_cin = Bool(default_value=False) + shade_cin.__doc__ = """A boolean (True/False) on whether to shade the CIN for the sounding. + + This parameter uses the default settings from MetPy for plotting CIN using the dewpoint, + so only the CIN between the surface and the LFC is filled. In order to shade CIN, the + `temperature_variables` attribute must be in the order of temperature, dewpoint + temperature, parcel temperature. The default value is `False`.""" + + wind_barb_variables = List(default_value=[None], allow_none=True) + wind_barb_variables.__doc__ = """A list of string names of the u- and v-components of the + wind. + + This attribute requires two string names in the order u-component, v-component for those + respective variables stored in the dictionary-like object.""" + + wind_barb_color = Unicode('black', allow_none=True) + wind_barb_color.__doc__ = """A string declaring the name of the color to plot the wind + barbs. + + The default value is 'black'.""" + + wind_barb_length = Int(default_value=7, allow_none=True) + wind_barb_length.__doc__ = """An integer value for defining the size of the wind barbs. + + The default value is 7.""" + + wind_barb_skip = Int(default_value=1) + wind_barb_skip.__doc__ = """An integer value for skipping the plotting of wind barbs. + + The default value is 1 (no skipping).""" + + wind_barb_position = Float(default_value=1.0) + wind_barb_position.__doc__ = """A float value for defining location of the wind barbs on + the plot. + + The float value describes the location in figure space. The default value is 1.0.""" + + parent = Instance(Panel) + _need_redraw = Bool(default_value=True) + + def clear(self): + """Clear the plot. + + Resets all internal state and sets need for redraw. + + """ + if getattr(self, 'handle', None) is not None: + self.handle.ax.cla() + self.handle = None + self._need_redraw = True + + @observe('parent') + def _parent_changed(self, _): + """Handle setting the parent object for the plot.""" + self.clear() + + @observe('temperature_variable', 'vertical_variable', 'wind_barb_variables') + def _update_data(self, _=None): + """Handle updating the internal cache of data. + + Responds to changes in various subsetting parameters. + + """ + self._xydata = None + self.clear() + + # Can't be a Traitlet because notifications don't work with arrays for traits + # notification never happens + @property + def data(self): + """Dictionary-like data that contains the fields to be plotted.""" + return self._data + + @data.setter + def data(self, val): + self._data = val + self._update_data() + + @property + def name(self): + """Generate a name for the plot.""" + ret = '' + ret += ' and '.join([self.x_variable]) + return ret + + @property + def xydata(self, var): + """Return the internal cached data.""" + if getattr(self, '_xydata', None) is None: + # Use a copy of data so we retain all of the original data passed in unmodified + self._xydata = self.data + return self._xydata[var] + + def draw(self): + """Draw the plot.""" + if self._need_redraw: + if getattr(self, 'handle', None) is None: + self._build() + self._need_redraw = False + + @observe('linecolor', 'linewidth', 'linestyle', 'wind_barb_color', 'wind_barb_length', + 'wind_barb_position', 'wind_barb_skip', 'shade_cape', 'shade_cin') + def _set_need_rebuild(self, _): + """Handle changes to attributes that need to regenerate everything.""" + # Because matplotlib doesn't let you just change these properties, we need + # to trigger a clear and re-call of contour() + self.clear() + + def _build(self): + """Build the plot by calling needed plotting methods as necessary.""" + data = self.data + y = data[self.vertical_variable] + if len(self.temperature_variable) != len(self.linewidth): + self.linewidth *= len(self.temperature_variable) + if len(self.temperature_variable) != len(self.linecolor): + self.linecolor *= len(self.temperature_variable) + if len(self.temperature_variable) != len(self.linestyle): + self.linestyle *= len(self.temperature_variable) + for i in range(len(self.temperature_variable)): + x = data[self.temperature_variable[i]] + + self.parent.ax.plot(y, x, self.linecolor[i], linestyle=self.linestyle[i], + linewidth=self.linewidth[i]) + + if self.wind_barb_variables[0] is not None: + u = data[self.wind_barb_variables[0]] + v = data[self.wind_barb_variables[1]] + barb_skip = slice(None, None, self.wind_barb_skip) + self.parent.ax.plot_barbs(y[barb_skip], u[barb_skip], v[barb_skip], + y_clip_radius=0, xloc=self.wind_barb_position) + + if self.shade_cape: + self.parent.ax.shade_cape(data[self.vertical_variable], + data[self.temperature_variable[0]], + data[self.temperature_variable[2]]) + if self.shade_cin: + self.parent.ax.shade_cin(data[self.vertical_variable], + data[self.temperature_variable[0]], + data[self.temperature_variable[2]], + data[self.temperature_variable[1]]) + + def copy(self): + """Return a copy of the plot.""" + return copy.copy(self) diff --git a/src/metpy/plots/skewt.py b/src/metpy/plots/skewt.py index a349067b9d..1d1beb273c 100644 --- a/src/metpy/plots/skewt.py +++ b/src/metpy/plots/skewt.py @@ -618,6 +618,69 @@ def plot_mixing_lines(self, mixing_ratio=None, pressure=None, **kwargs): self.mixing_lines = self.ax.add_collection(LineCollection(linedata, **kwargs)) return self.mixing_lines + def plot_labeled_skewt_lines(self): + r"""Plot common skewt lines and labels. + + This function plots the three common SkewT lines, dry adiabats, moist adiabats, + and mixing ratio lines with labels using a default coloring. Function assumes + that temperature value limits are in Celsius and pressure value limits are in + hPa. + """ + from metpy.constants import Cp_d, Rd + + # Get axes x, y limits + xmin, xmax = self.ax.get_xlim() + ymax, _ = self.ax.get_ylim() + + # Pressure for plotting mixing ratio lines + pressure = units.Quantity(np.linspace(500, ymax, 25)[::-1], 'hPa') + + # Set plotting certain mixing ratio values + mixing_ratio = units.Quantity(np.array([0.1, 0.2, 0.4, 0.6, 1, 1.5, 2, 3, + 4, 6, 8, 10, 13, 16, 20, 25, 30, 36, 42 + ]).reshape(-1, 1)[:, 0], 'g/kg') + + # Calculate the dewpoint at 500 hPa based on the mixing ratio (for plotting mixing + # ratio values) + plottd = dewpoint(vapor_pressure(units.Quantity(500, 'hPa'), mixing_ratio)) + + # Add the relevant special lines + self.plot_dry_adiabats(t0=units.Quantity(np.arange(xmin + 273.15, xmax + 273.15 + 200, + 10), 'K'), + alpha=0.25, colors='orangered', linewidths=1) + self.plot_moist_adiabats(t0=units.Quantity(np.arange(xmin + 273.15, + xmax + 273.15 + 100, 5), 'K'), + alpha=0.25, colors='tab:green', linewidths=1) + self.plot_mixing_lines(pressure=pressure, mixing_ratio=mixing_ratio, + linestyles='dotted', colors='dodgerblue', linewidths=1) + + # Add isotherm labels + if (xmax % 10) != 0: + xmax += (10 - xmax % 10) + # Set temperature limit at 10 less than the max + tt = np.arange(xmax - 10, -111, -10) + + # Use inverse potential temperature to get pressure levels of temperatures + pp = 1 / (((273.15 + xmax + 5) / (tt + 273.15))**(Cp_d / Rd) / 1000) + + # Color lines according to above, below, or exactly 0 Celsius + for i in range(tt.size): + if tt[i] > 0.: + color = 'tab:red' + elif tt[i] == 0.: + color = '#777777' + else: + color = 'tab:blue' + _ = self.ax.text(tt[i], pp[i], f'{tt[i]:.0f}', ha='center', va='center', + weight='bold', color=color, size=8, rotation=45, clip_on=True) + + # Add saturation mixing ratio labels + for i in range(mixing_ratio.m.size): + val = str(mixing_ratio[i].m) if mixing_ratio.m[i] % 1 else int(mixing_ratio[i].m) + _ = self.ax.text(plottd.m[i], 500, f'{val}', ha='center', va='bottom', + weight='bold', size=8, color='dodgerblue', clip_on=True, + rotation=30) + def shade_area(self, y, x1, x2=0, which='both', **kwargs): r"""Shade area between two curves. diff --git a/tests/plots/baseline/test_declarative_skewt_plot.png b/tests/plots/baseline/test_declarative_skewt_plot.png new file mode 100644 index 0000000000..77c79d8379 Binary files /dev/null and b/tests/plots/baseline/test_declarative_skewt_plot.png differ diff --git a/tests/plots/baseline/test_declarative_skewt_plot_shade_cape.png b/tests/plots/baseline/test_declarative_skewt_plot_shade_cape.png new file mode 100644 index 0000000000..67a7ac0ec5 Binary files /dev/null and b/tests/plots/baseline/test_declarative_skewt_plot_shade_cape.png differ diff --git a/tests/plots/baseline/test_skewt_labeled_lines.png b/tests/plots/baseline/test_skewt_labeled_lines.png new file mode 100644 index 0000000000..6d1dc0e00c Binary files /dev/null and b/tests/plots/baseline/test_skewt_labeled_lines.png differ diff --git a/tests/plots/test_declarative.py b/tests/plots/test_declarative.py index 5ff7736556..da6787ced4 100644 --- a/tests/plots/test_declarative.py +++ b/tests/plots/test_declarative.py @@ -8,6 +8,7 @@ from unittest.mock import patch, PropertyMock import warnings +import matplotlib import matplotlib.pyplot as plt import numpy as np import pandas as pd @@ -21,10 +22,12 @@ from metpy.io.metar import parse_metar_file from metpy.plots import (ArrowPlot, BarbPlot, ContourPlot, FilledContourPlot, ImagePlot, MapPanel, PanelContainer, PlotGeometry, PlotObs, PlotSurfaceAnalysis, - RasterPlot) -from metpy.testing import needs_cartopy, version_check + RasterPlot, SkewtPanel, SkewtPlot) +from metpy.testing import get_upper_air_data, needs_cartopy, version_check from metpy.units import units +MPL_VERSION = matplotlib.__version__[:5] + @pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.02) @needs_cartopy @@ -2046,6 +2049,19 @@ def test_copy(): assert obj is not copied_obj assert obj.time == copied_obj.time + # Copy SkewtPlot + obj = SkewtPlot() + obj.temperature_variable = 'tempeature' + copied_obj = obj.copy() + assert obj is not copied_obj + assert obj.temperature_variable == copied_obj.temperature_variable + + obj = SkewtPanel() + obj.xlabel = 'Temperature' + copied_obj = obj.copy() + assert obj is not copied_obj + assert obj.xlabel == copied_obj.xlabel + # Copies of MapPanel and PanelContainer obj = MapPanel() obj.title = 'Sample Text' @@ -2231,6 +2247,73 @@ def test_declarative_plot_geometry_points(ccrs): return pc.figure +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.03) +def test_declarative_skewt_plot(): + """Test plotting of a simple skewT with declarative.""" + date = datetime(2016, 5, 22, 0) + data = get_upper_air_data(date, 'DDC') + + skew = SkewtPlot() + skew.data = data + skew.temperature_variable = ['temperature', 'dewpoint'] + skew.vertical_variable = 'pressure' + skew.linecolor = ['red', 'green'] + skew.linestyle = ['solid'] + skew.wind_barb_variables = ['u_wind', 'v_wind'] + + skewpanel = SkewtPanel() + skewpanel.xlimits = (-40, 50) + skewpanel.ylimits = (1025, 100) + skewpanel.plots = [skew] + skewpanel.title = f'DDC Sounding {date} UTC' + + panel = PanelContainer() + panel.size = (8.5, 10) + panel.panels = [skewpanel] + panel.draw() + + return panel.figure + + +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.03) +def test_declarative_skewt_plot_shade_cape(): + """Test plotting of a skewT with declarative and shading.""" + from metpy.calc import parcel_profile + + date = datetime(2016, 5, 22, 0) + data = get_upper_air_data(date, 'DDC') + + parcel_temp = parcel_profile(data['pressure'], + data['temperature'][0], + data['dewpoint'][0]).to('degC') + data['parcel_temperature'] = parcel_temp + + skew = SkewtPlot() + skew.data = data + skew.temperature_variable = ['temperature', 'dewpoint', 'parcel_temperature'] + skew.vertical_variable = 'pressure' + skew.linecolor = ['red', 'green', 'black'] + skew.linestyle = ['solid', 'solid'] + skew.wind_barb_variables = ['u_wind', 'v_wind'] + skew.wind_barb_skip = 2 + skew.wind_barb_position = 1 + skew.shade_cape = True + skew.shade_cin = True + + skewpanel = SkewtPanel() + skewpanel.xlimits = (-40, 50) + skewpanel.ylimits = (1025, 100) + skewpanel.plots = [skew] + skewpanel.title = f'DDC Sounding {date} UTC' + + panel = PanelContainer() + panel.size = (8.5, 10) + panel.panels = [skewpanel] + panel.draw() + + return panel.figure + + @needs_cartopy def test_drop_traitlets_dir(): """Test successful drop of inherited members from HasTraits and any '_' or '__' members.""" @@ -2243,7 +2326,8 @@ def test_drop_traitlets_dir(): MapPanel, PanelContainer, PlotGeometry, - PlotObs + PlotObs, + SkewtPlot ): assert dir(plot_obj)[0].startswith('_') assert not dir(plot_obj())[0].startswith('_') diff --git a/tests/plots/test_skewt.py b/tests/plots/test_skewt.py index 2a66389a3f..c69eca5a92 100644 --- a/tests/plots/test_skewt.py +++ b/tests/plots/test_skewt.py @@ -46,6 +46,22 @@ def test_skewt_api(): return fig +@pytest.mark.mpl_image_compare(remove_text=True, style='default', tolerance=0.069) +def test_skewt_labeled_lines(): + """Test the SkewT with the labeled plot lines function.""" + fig = plt.figure(figsize=(8.5, 11)) + skew = SkewT(fig, rotation=45) + + # Set sensible axis limits + skew.ax.set_ylim(1000, 100) + skew.ax.set_xlim(-30, 50) + + # Add the relevant special lines + skew.plot_labeled_skewt_lines() + + return fig + + @pytest.mark.mpl_image_compare(remove_text=True, style='default', tolerance=0.32) def test_skewt_api_units(): """Test the SkewT API when units are provided."""