diff --git a/src/metpy/plots/skewt.py b/src/metpy/plots/skewt.py index a349067b9d..ce42c9c685 100644 --- a/src/metpy/plots/skewt.py +++ b/src/metpy/plots/skewt.py @@ -1,10 +1,10 @@ # Copyright (c) 2014,2015,2016,2017,2019 MetPy Developers. # Distributed under the terms of the BSD 3-Clause License. # SPDX-License-Identifier: BSD-3-Clause -"""Make Skew-T Log-P based plots. +"""Make thermodynamic diagrams. -Contain tools for making Skew-T Log-P plots, including the base plotting class, -`SkewT`, as well as a class for making a `Hodograph`. +Contain tools for making thermodynamic diagrams, including the base plotting class, +`SkewT`, derived `Stuve` and `Emagram` classes, and a class for making a `Hodograph`. """ from contextlib import ExitStack @@ -744,6 +744,104 @@ def shade_cin(self, pressure, t, t_parcel, dewpoint=None, **kwargs): **kwargs) +@exporter.export +class Stuve(SkewT): + r"""Make Stüve plots of data. + + Stüve plots are are a thermodynamic diagram with temperature on the x-axis and + pressure scaled by p^(R/cp)=p^0.286 on the y-axis. This class is derived from the + SkewT class and has the same capabilities for plotting data, wind barbs, + dry and saturated adiabats, and mixing ratio lines. + + Attributes + ---------- + ax : `matplotlib.axes.Axes` + The underlying Axes instance, which can be used for calling additional + plot functions (e.g. `axvline`) + + """ + + def __init__(self, fig=None, subplot=None, rect=None, aspect='auto'): + r"""Create Stüve plots. + + Parameters + ---------- + fig : matplotlib.figure.Figure, optional + Source figure to use for plotting. If none is given, a new + :class:`matplotlib.figure.Figure` instance will be created. + subplot : tuple[int, int, int] or `matplotlib.gridspec.SubplotSpec` instance, optional + Controls the size/position of the created subplot. This allows creating + the skewT as part of a collection of subplots. If subplot is a tuple, it + should conform to the specification used for + :meth:`matplotlib.figure.Figure.add_subplot`. The + :class:`matplotlib.gridspec.SubplotSpec` + can be created by using :class:`matplotlib.gridspec.GridSpec`. + rect : tuple[float, float, float, float], optional + Rectangle (left, bottom, width, height) in which to place the axes. This + allows the user to place the axes at an arbitrary point on the figure. + aspect : float, int, or Literal['auto'], optional + Aspect ratio (i.e. ratio of y-scale to x-scale) to maintain in the plot. + Defaults to ``'auto'`` which tells matplotlib to handle + the aspect ratio automatically. + + """ + super().__init__(fig=fig, rotation=0, subplot=subplot, rect=rect, aspect=aspect) + + # Forward (f) and inverse (g) functions for Stuve pressure coordinate scaling + def f(p): + return p**0.286 + + def g(p): + return p**(1 / 0.286) + + # Set the yaxis as Stuve + self.ax.set_yscale('function', functions=(f, g)) + + +@exporter.export +class Emagram(SkewT): + r"""Make Emagram plots of data. + + Emagram plots are T log-P thermodynamic diagrams. They differ from SkewT + in that the T axis is not skewed. This class is derived from the SkewT class and + has the same capabilities for plotting data, wind barbs, dry and saturated + adiabats, and mixing ratio lines. + + Attributes + ---------- + ax : `matplotlib.axes.Axes` + The underlying Axes instance, which can be used for calling additional + plot functions (e.g. `axvline`) + + """ + + def __init__(self, fig=None, subplot=None, rect=None, aspect='auto'): + r"""Create Emagram plots. + + Parameters + ---------- + fig : matplotlib.figure.Figure, optional + Source figure to use for plotting. If none is given, a new + :class:`matplotlib.figure.Figure` instance will be created. + subplot : tuple[int, int, int] or `matplotlib.gridspec.SubplotSpec` instance, optional + Controls the size/position of the created subplot. This allows creating + the skewT as part of a collection of subplots. If subplot is a tuple, it + should conform to the specification used for + :meth:`matplotlib.figure.Figure.add_subplot`. The + :class:`matplotlib.gridspec.SubplotSpec` + can be created by using :class:`matplotlib.gridspec.GridSpec`. + rect : tuple[float, float, float, float], optional + Rectangle (left, bottom, width, height) in which to place the axes. This + allows the user to place the axes at an arbitrary point on the figure. + aspect : float, int, or Literal['auto'], optional + Aspect ratio (i.e. ratio of y-scale to x-scale) to maintain in the plot. + Defaults to ``'auto'`` which tells matplotlib to handle + the aspect ratio automatically. + + """ + super().__init__(fig=fig, rotation=0, subplot=subplot, rect=rect, aspect=aspect) + + @exporter.export class Hodograph: r"""Make a hodograph of wind data. diff --git a/tests/plots/baseline/test_emagram_api.png b/tests/plots/baseline/test_emagram_api.png new file mode 100644 index 0000000000..7934f38f6c Binary files /dev/null and b/tests/plots/baseline/test_emagram_api.png differ diff --git a/tests/plots/baseline/test_emagram_arbitrary_rect.png b/tests/plots/baseline/test_emagram_arbitrary_rect.png new file mode 100644 index 0000000000..b2857008fe Binary files /dev/null and b/tests/plots/baseline/test_emagram_arbitrary_rect.png differ diff --git a/tests/plots/baseline/test_emagram_default_aspect_empty.png b/tests/plots/baseline/test_emagram_default_aspect_empty.png new file mode 100644 index 0000000000..5a0b0f13ac Binary files /dev/null and b/tests/plots/baseline/test_emagram_default_aspect_empty.png differ diff --git a/tests/plots/baseline/test_emagram_gridspec.png b/tests/plots/baseline/test_emagram_gridspec.png new file mode 100644 index 0000000000..1e45e45411 Binary files /dev/null and b/tests/plots/baseline/test_emagram_gridspec.png differ diff --git a/tests/plots/baseline/test_emagram_mixing_line_args.png b/tests/plots/baseline/test_emagram_mixing_line_args.png new file mode 100644 index 0000000000..5a0b0f13ac Binary files /dev/null and b/tests/plots/baseline/test_emagram_mixing_line_args.png differ diff --git a/tests/plots/baseline/test_emagram_shade_area.png b/tests/plots/baseline/test_emagram_shade_area.png new file mode 100644 index 0000000000..9450516e65 Binary files /dev/null and b/tests/plots/baseline/test_emagram_shade_area.png differ diff --git a/tests/plots/baseline/test_emagram_shade_cape_cin.png b/tests/plots/baseline/test_emagram_shade_cape_cin.png new file mode 100644 index 0000000000..812da00e54 Binary files /dev/null and b/tests/plots/baseline/test_emagram_shade_cape_cin.png differ diff --git a/tests/plots/baseline/test_emagram_subplot.png b/tests/plots/baseline/test_emagram_subplot.png new file mode 100644 index 0000000000..7b85138a05 Binary files /dev/null and b/tests/plots/baseline/test_emagram_subplot.png differ diff --git a/tests/plots/baseline/test_emagram_tight_bbox.png b/tests/plots/baseline/test_emagram_tight_bbox.png new file mode 100644 index 0000000000..49119c60b5 Binary files /dev/null and b/tests/plots/baseline/test_emagram_tight_bbox.png differ diff --git a/tests/plots/baseline/test_emagram_wide_aspect_ratio.png b/tests/plots/baseline/test_emagram_wide_aspect_ratio.png new file mode 100644 index 0000000000..ab4e62f6c2 Binary files /dev/null and b/tests/plots/baseline/test_emagram_wide_aspect_ratio.png differ diff --git a/tests/plots/baseline/test_stuve_api.png b/tests/plots/baseline/test_stuve_api.png new file mode 100644 index 0000000000..d2a1fe60f3 Binary files /dev/null and b/tests/plots/baseline/test_stuve_api.png differ diff --git a/tests/plots/baseline/test_stuve_arbitrary_rect.png b/tests/plots/baseline/test_stuve_arbitrary_rect.png new file mode 100644 index 0000000000..c3f56c8077 Binary files /dev/null and b/tests/plots/baseline/test_stuve_arbitrary_rect.png differ diff --git a/tests/plots/baseline/test_stuve_default_aspect_empty.png b/tests/plots/baseline/test_stuve_default_aspect_empty.png new file mode 100644 index 0000000000..ba1886589c Binary files /dev/null and b/tests/plots/baseline/test_stuve_default_aspect_empty.png differ diff --git a/tests/plots/baseline/test_stuve_gridspec.png b/tests/plots/baseline/test_stuve_gridspec.png new file mode 100644 index 0000000000..4c33a0eed9 Binary files /dev/null and b/tests/plots/baseline/test_stuve_gridspec.png differ diff --git a/tests/plots/baseline/test_stuve_mixing_line_args.png b/tests/plots/baseline/test_stuve_mixing_line_args.png new file mode 100644 index 0000000000..ba1886589c Binary files /dev/null and b/tests/plots/baseline/test_stuve_mixing_line_args.png differ diff --git a/tests/plots/baseline/test_stuve_shade_area.png b/tests/plots/baseline/test_stuve_shade_area.png new file mode 100644 index 0000000000..c9727635df Binary files /dev/null and b/tests/plots/baseline/test_stuve_shade_area.png differ diff --git a/tests/plots/baseline/test_stuve_shade_cape_cin.png b/tests/plots/baseline/test_stuve_shade_cape_cin.png new file mode 100644 index 0000000000..af0dff51dd Binary files /dev/null and b/tests/plots/baseline/test_stuve_shade_cape_cin.png differ diff --git a/tests/plots/baseline/test_stuve_subplot.png b/tests/plots/baseline/test_stuve_subplot.png new file mode 100644 index 0000000000..9814a22563 Binary files /dev/null and b/tests/plots/baseline/test_stuve_subplot.png differ diff --git a/tests/plots/baseline/test_stuve_tight_bbox.png b/tests/plots/baseline/test_stuve_tight_bbox.png new file mode 100644 index 0000000000..64f7b95ecc Binary files /dev/null and b/tests/plots/baseline/test_stuve_tight_bbox.png differ diff --git a/tests/plots/baseline/test_stuve_wide_aspect_ratio.png b/tests/plots/baseline/test_stuve_wide_aspect_ratio.png new file mode 100644 index 0000000000..334427ffed Binary files /dev/null and b/tests/plots/baseline/test_stuve_wide_aspect_ratio.png differ diff --git a/tests/plots/test_skewt.py b/tests/plots/test_skewt.py index 2a66389a3f..4f4b801a84 100644 --- a/tests/plots/test_skewt.py +++ b/tests/plots/test_skewt.py @@ -10,7 +10,7 @@ import numpy as np import pytest -from metpy.plots import Hodograph, SkewT +from metpy.plots import Emagram, Hodograph, SkewT, Stuve from metpy.testing import autoclose_figure, version_check from metpy.units import units @@ -576,3 +576,313 @@ def test_hodograph_range_with_units(): with autoclose_figure(figsize=(6, 6)) as fig: ax = fig.add_subplot(1, 1, 1) Hodograph(ax, component_range=60. * units.knots) + + +@pytest.mark.mpl_image_compare(remove_text=True, style='default', tolerance=0.069) +def test_stuve_api(): + """Test the Stuve API.""" + with matplotlib.rc_context({'axes.autolimit_mode': 'data'}): + fig = plt.figure(figsize=(9, 9)) + stuve = Stuve(fig, aspect='auto') + + # Plot the data using normal plotting functions, in this case using + # log scaling in Y, as dictated by the typical meteorological plot + p = np.linspace(1000, 100, 10) + t = np.linspace(20, -20, 10) + u = np.linspace(-10, 10, 10) + stuve.plot(p, t, 'r') + stuve.plot_barbs(p, u, u) + + stuve.ax.set_xlim(-20, 30) + stuve.ax.set_ylim(1000, 100) + + # Add the relevant special lines + stuve.plot_dry_adiabats() + stuve.plot_moist_adiabats() + stuve.plot_mixing_lines() + + # Call again to hit removal statements + stuve.plot_dry_adiabats() + stuve.plot_moist_adiabats() + stuve.plot_mixing_lines() + + return fig + + +@pytest.mark.mpl_image_compare(tolerance=0., remove_text=True, style='default') +def test_stuve_default_aspect_empty(): + """Test Stuve with default aspect and no plots, only special lines.""" + fig = plt.figure(figsize=(12, 9)) + stuve = Stuve(fig) + stuve.plot_dry_adiabats() + stuve.plot_moist_adiabats() + stuve.plot_mixing_lines() + return fig + + +@pytest.mark.mpl_image_compare(tolerance=0., remove_text=True, style='default') +def test_stuve_mixing_line_args(): + """Test plot_mixing_lines accepting kwargs for mixing ratio and pressure levels.""" + fig = plt.figure(figsize=(12, 9)) + stuve = Stuve(fig) + mlines = np.array([0.0004, 0.001, 0.002, 0.004, 0.007, 0.01, 0.016, 0.024, 0.032]) + press = units.Quantity(np.linspace(600, max(stuve.ax.get_ylim())), 'mbar') + stuve.plot_dry_adiabats() + stuve.plot_moist_adiabats() + stuve.plot_mixing_lines(mixing_ratio=mlines, pressure=press) + return fig + + +@pytest.mark.mpl_image_compare(tolerance=0., remove_text=False, style='default', + savefig_kwargs={'bbox_inches': 'tight'}) +def test_stuve_tight_bbox(): + """Test Stuve when saved with `savefig(..., bbox_inches='tight')`.""" + fig = plt.figure(figsize=(12, 9)) + Stuve(fig) + return fig + + +@pytest.mark.mpl_image_compare(tolerance=0.811, remove_text=True, style='default') +def test_stuve_subplot(): + """Test using Stuve on a sub-plot.""" + fig = plt.figure(figsize=(9, 9)) + Stuve(fig, subplot=(2, 2, 1), aspect='auto') + return fig + + +@pytest.mark.mpl_image_compare(tolerance=0, remove_text=True, style='default') +def test_stuve_gridspec(): + """Test using Stuve on a GridSpec sub-plot.""" + fig = plt.figure(figsize=(9, 9)) + gs = GridSpec(1, 2) + Stuve(fig, subplot=gs[0, 1], aspect='auto') + return fig + + +def test_stuve_with_grid_enabled(): + """Test using Stuve when gridlines are already enabled (#271).""" + with plt.rc_context(rc={'axes.grid': True}): + # Also tests when we don't pass in Figure + s = Stuve(aspect='auto') + plt.close(s.ax.figure) + + +@pytest.mark.mpl_image_compare(tolerance=0., remove_text=True, style='default') +def test_stuve_arbitrary_rect(): + """Test placing the Stuve in an arbitrary rectangle.""" + fig = plt.figure(figsize=(9, 9)) + Stuve(fig, rect=(0.15, 0.35, 0.8, 0.3), aspect='auto') + return fig + + +@pytest.mark.mpl_image_compare(tolerance=.033, remove_text=True, style='default') +def test_stuve_shade_cape_cin(test_profile): + """Test shading CAPE and CIN on a Stuve plot.""" + p, t, td, tp = test_profile + + with matplotlib.rc_context({'axes.autolimit_mode': 'data'}): + fig = plt.figure(figsize=(9, 9)) + stuve = Stuve(fig, aspect='auto') + stuve.plot(p, t, 'r') + stuve.plot(p, tp, 'k') + stuve.shade_cape(p, t, tp) + stuve.shade_cin(p, t, tp, td) + stuve.ax.set_xlim(-50, 50) + stuve.ax.set_ylim(1000, 100) + + # This works around the fact that newer pint versions default to degrees_Celsius + stuve.ax.set_xlabel('degC') + + return fig + + +@pytest.mark.mpl_image_compare(tolerance=0.033, remove_text=True, style='default') +def test_stuve_shade_area(test_profile): + """Test shading areas on a Stuve plot.""" + p, t, _, tp = test_profile + + with matplotlib.rc_context({'axes.autolimit_mode': 'data'}): + fig = plt.figure(figsize=(9, 9)) + stuve = Stuve(fig, aspect='auto') + stuve.plot(p, t, 'r') + stuve.plot(p, tp, 'k') + stuve.shade_area(p, t, tp) + stuve.ax.set_xlim(-50, 50) + stuve.ax.set_ylim(1000, 100) + + # This works around the fact that newer pint versions default to degrees_Celsius + stuve.ax.set_xlabel('degC') + + return fig + + +@pytest.mark.mpl_image_compare(tolerance=0.039, remove_text=True, style='default') +def test_stuve_wide_aspect_ratio(test_profile): + """Test plotting a stuveT with a wide aspect ratio.""" + p, t, _, tp = test_profile + + fig = plt.figure(figsize=(12.5, 3)) + stuve = Stuve(fig, aspect='auto') + stuve.plot(p, t, 'r') + stuve.plot(p, tp, 'k') + stuve.ax.set_xlim(-30, 50) + stuve.ax.set_ylim(1050, 700) + + # This works around the fact that newer pint versions default to degrees_Celsius + stuve.ax.set_xlabel('degC') + return fig + + +@pytest.mark.mpl_image_compare(remove_text=True, style='default', tolerance=0.069) +def test_emagram_api(): + """Test the Emagram API.""" + with matplotlib.rc_context({'axes.autolimit_mode': 'data'}): + fig = plt.figure(figsize=(9, 9)) + emagram = Emagram(fig, aspect='auto') + + # Plot the data using normal plotting functions, in this case using + # log scaling in Y, as dictated by the typical meteorological plot + p = np.linspace(1000, 100, 10) + t = np.linspace(20, -20, 10) + u = np.linspace(-10, 10, 10) + emagram.plot(p, t, 'r') + emagram.plot_barbs(p, u, u) + + emagram.ax.set_xlim(-20, 30) + emagram.ax.set_ylim(1000, 100) + + # Add the relevant special lines + emagram.plot_dry_adiabats() + emagram.plot_moist_adiabats() + emagram.plot_mixing_lines() + + # Call again to hit removal statements + emagram.plot_dry_adiabats() + emagram.plot_moist_adiabats() + emagram.plot_mixing_lines() + + return fig + + +@pytest.mark.mpl_image_compare(tolerance=0.001, remove_text=True, style='default') +def test_emagram_default_aspect_empty(): + """Test Emagram with default aspect and no plots, only special lines.""" + fig = plt.figure(figsize=(12, 9)) + emagram = Emagram(fig) + emagram.plot_dry_adiabats() + emagram.plot_moist_adiabats() + emagram.plot_mixing_lines() + return fig + + +@pytest.mark.mpl_image_compare(tolerance=0.001, remove_text=True, style='default') +def test_emagram_mixing_line_args(): + """Test plot_mixing_lines accepting kwargs for mixing ratio and pressure levels.""" + fig = plt.figure(figsize=(12, 9)) + emagram = Emagram(fig) + mlines = np.array([0.0004, 0.001, 0.002, 0.004, 0.007, 0.01, 0.016, 0.024, 0.032]) + press = units.Quantity(np.linspace(600, max(emagram.ax.get_ylim())), 'mbar') + emagram.plot_dry_adiabats() + emagram.plot_moist_adiabats() + emagram.plot_mixing_lines(mixing_ratio=mlines, pressure=press) + return fig + + +@pytest.mark.mpl_image_compare(tolerance=0., remove_text=False, style='default', + savefig_kwargs={'bbox_inches': 'tight'}) +def test_emagram_tight_bbox(): + """Test Emagram when saved with `savefig(..., bbox_inches='tight')`.""" + fig = plt.figure(figsize=(12, 9)) + Emagram(fig) + return fig + + +@pytest.mark.mpl_image_compare(tolerance=0.811, remove_text=True, style='default') +def test_emagram_subplot(): + """Test using Emagram on a sub-plot.""" + fig = plt.figure(figsize=(9, 9)) + Emagram(fig, subplot=(2, 2, 1), aspect='auto') + return fig + + +@pytest.mark.mpl_image_compare(tolerance=0, remove_text=True, style='default') +def test_emagram_gridspec(): + """Test using Emagram on a GridSpec sub-plot.""" + fig = plt.figure(figsize=(9, 9)) + gs = GridSpec(1, 2) + Emagram(fig, subplot=gs[0, 1], aspect='auto') + return fig + + +def test_emagram_with_grid_enabled(): + """Test using Emagram when gridlines are already enabled (#271).""" + with plt.rc_context(rc={'axes.grid': True}): + # Also tests when we don't pass in Figure + s = Emagram(aspect='auto') + plt.close(s.ax.figure) + + +@pytest.mark.mpl_image_compare(tolerance=0., remove_text=True, style='default') +def test_emagram_arbitrary_rect(): + """Test placing the Emagram in an arbitrary rectangle.""" + fig = plt.figure(figsize=(9, 9)) + Emagram(fig, rect=(0.15, 0.35, 0.8, 0.3), aspect='auto') + return fig + + +@pytest.mark.mpl_image_compare(tolerance=.033, remove_text=True, style='default') +def test_emagram_shade_cape_cin(test_profile): + """Test shading CAPE and CIN on an Emagram plot.""" + p, t, td, tp = test_profile + + with matplotlib.rc_context({'axes.autolimit_mode': 'data'}): + fig = plt.figure(figsize=(9, 9)) + emagram = Emagram(fig, aspect='auto') + emagram.plot(p, t, 'r') + emagram.plot(p, tp, 'k') + emagram.shade_cape(p, t, tp) + emagram.shade_cin(p, t, tp, td) + emagram.ax.set_xlim(-50, 50) + emagram.ax.set_ylim(1000, 100) + + # This works around the fact that newer pint versions default to degrees_Celsius + emagram.ax.set_xlabel('degC') + + return fig + + +@pytest.mark.mpl_image_compare(tolerance=0.033, remove_text=True, style='default') +def test_emagram_shade_area(test_profile): + """Test shading areas on an Emagram plot.""" + p, t, _, tp = test_profile + + with matplotlib.rc_context({'axes.autolimit_mode': 'data'}): + fig = plt.figure(figsize=(9, 9)) + emagram = Emagram(fig, aspect='auto') + emagram.plot(p, t, 'r') + emagram.plot(p, tp, 'k') + emagram.shade_area(p, t, tp) + emagram.ax.set_xlim(-50, 50) + emagram.ax.set_ylim(1000, 100) + + # This works around the fact that newer pint versions default to degrees_Celsius + emagram.ax.set_xlabel('degC') + + return fig + + +@pytest.mark.mpl_image_compare(tolerance=0.039, remove_text=True, style='default') +def test_emagram_wide_aspect_ratio(test_profile): + """Test plotting an Emagram with a wide aspect ratio.""" + p, t, _, tp = test_profile + + fig = plt.figure(figsize=(12.5, 3)) + emagram = Emagram(fig, aspect='auto') + emagram.plot(p, t, 'r') + emagram.plot(p, tp, 'k') + emagram.ax.set_xlim(-30, 50) + emagram.ax.set_ylim(1050, 700) + + # This works around the fact that newer pint versions default to degrees_Celsius + emagram.ax.set_xlabel('degC') + return fig