diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e927940aa1..ba1c75e774e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,12 +140,14 @@ jobs: key: ${{ runner.os }}-pip-tests-${{ hashFiles('ci/*.txt') }} restore-keys: ${{ runner.os }}-pip-tests - # This installs the stuff needed to build and install Shapely and Cartopy from source + # This installs the stuff needed to build and install Shapely and CartoPy from source. + # Need to install numpy first to make CartoPy happy. - name: Install dependencies run: | sudo apt-get install libgeos-dev libproj-dev python -m pip install --upgrade pip setuptools python -m pip install --no-binary :all: shapely + python -m pip install -c ci/${{ matrix.dep-versions }} numpy python -m pip install -r ci/test_requirements.txt -r ci/extra_requirements.txt -c ci/${{ matrix.dep-versions }} ${{ matrix.git-versions }} - name: Install @@ -227,13 +229,15 @@ jobs: key: ${{ runner.os }}-pip-docs-${{ hashFiles('ci/*.txt') }} restore-keys: ${{ runner.os }}-pip-docs - # This installs the stuff needed to build and install Shapely and Cartopy from source + # This installs the stuff needed to build and install Shapely and CartoPy from source. + # Need to install numpy first to make CartoPy happy. - name: Install dependencies (PyPI) if: ${{ runner.os == 'Linux' }} run: | sudo apt-get install libgeos-dev libproj-dev python -m pip install --upgrade pip setuptools python -m pip install --no-binary :all: shapely + python -m pip install -c ci/${{ matrix.dep-versions }} numpy python -m pip install -r ci/doc_requirements.txt -r ci/extra_requirements.txt -c ci/${{ matrix.dep-versions }} ${{ matrix.git-versions }} python -m pip install -f https://unidata-python.s3.amazonaws.com/wheelhouse/index.html sphinx_rtd_theme==0.2.5b1.post1 diff --git a/ci/extra_requirements.txt b/ci/extra_requirements.txt index f2bebc50896..e2080645e89 100644 --- a/ci/extra_requirements.txt +++ b/ci/extra_requirements.txt @@ -1,2 +1,2 @@ -cartopy==0.17.0 +cartopy==0.18.0 pyproj==2.6.1.post1 diff --git a/src/metpy/plots/_mpl.py b/src/metpy/plots/_mpl.py index a65b246bcc7..a7848c92022 100644 --- a/src/metpy/plots/_mpl.py +++ b/src/metpy/plots/_mpl.py @@ -100,6 +100,12 @@ def scattertext(self, x, y, texts, loc=(0, 0), **kw): # Add it to the axes and update range self.add_artist(text_obj) + + # Matplotlib at least up to 3.2.2 does not properly clip text with paths, so + # work-around by setting to the bounding box of the Axes + # TODO: Remove when fixed in our minimum supported version of matplotlib + text_obj.clipbox = self.bbox + self.update_datalim(text_obj.get_datalim(self.transData)) self.autoscale_view() return text_obj diff --git a/src/metpy/plots/cartopy_utils.py b/src/metpy/plots/cartopy_utils.py index 0140946f990..dd7c5891927 100644 --- a/src/metpy/plots/cartopy_utils.py +++ b/src/metpy/plots/cartopy_utils.py @@ -3,28 +3,39 @@ # SPDX-License-Identifier: BSD-3-Clause """Cartopy specific mapping utilities.""" +import cartopy.crs as ccrs import cartopy.feature as cfeature from ..cbook import get_test_data -class MetPyMapFeature(cfeature.NaturalEarthFeature): - """A simple interface to US County shapefiles.""" +class MetPyMapFeature(cfeature.Feature): + """A simple interface to MetPy-included shapefiles.""" def __init__(self, name, scale, **kwargs): - """Create USCountiesFeature instance.""" - super().__init__('', name, scale, **kwargs) + """Create MetPyMapFeature instance.""" + super().__init__(ccrs.PlateCarree(), **kwargs) + self.name = name + + if isinstance(scale, str): + scale = cfeature.Scaler(scale) + self.scaler = scale def geometries(self): """Return an iterator of (shapely) geometries for this feature.""" import cartopy.io.shapereader as shapereader # Ensure that the associated files are in the cache - fname = '{}_{}'.format(self.name, self.scale) + fname = '{}_{}'.format(self.name, self.scaler.scale) for extension in ['.dbf', '.shx']: get_test_data(fname + extension) path = get_test_data(fname + '.shp', as_file_obj=False) return iter(tuple(shapereader.Reader(path).geometries())) + def intersecting_geometries(self, extent): + """Return geometries that intersect the extent.""" + self.scaler.scale_from_extent(extent) + return super().intersecting_geometries(extent) + def with_scale(self, new_scale): """ Return a copy of the feature with a new scale. diff --git a/tests/plots/baseline/test_arrow_projection.png b/tests/plots/baseline/test_arrow_projection.png index e99617251cd..e338acc350c 100644 Binary files a/tests/plots/baseline/test_arrow_projection.png and b/tests/plots/baseline/test_arrow_projection.png differ diff --git a/tests/plots/baseline/test_barb_projection.png b/tests/plots/baseline/test_barb_projection.png index 0b18e53cd81..765921a3266 100644 Binary files a/tests/plots/baseline/test_barb_projection.png and b/tests/plots/baseline/test_barb_projection.png differ diff --git a/tests/plots/baseline/test_colorfill.png b/tests/plots/baseline/test_colorfill.png index 21b19663067..f4b382a8c41 100644 Binary files a/tests/plots/baseline/test_colorfill.png and b/tests/plots/baseline/test_colorfill.png differ diff --git a/tests/plots/baseline/test_colorfill_horiz_colorbar.png b/tests/plots/baseline/test_colorfill_horiz_colorbar.png index 8d3630a6a11..7958a2cc28f 100644 Binary files a/tests/plots/baseline/test_colorfill_horiz_colorbar.png and b/tests/plots/baseline/test_colorfill_horiz_colorbar.png differ diff --git a/tests/plots/baseline/test_colorfill_no_colorbar.png b/tests/plots/baseline/test_colorfill_no_colorbar.png index b70baf7de5d..26da7345d10 100644 Binary files a/tests/plots/baseline/test_colorfill_no_colorbar.png and b/tests/plots/baseline/test_colorfill_no_colorbar.png differ diff --git a/tests/plots/baseline/test_declarative_contour_convert_units.png b/tests/plots/baseline/test_declarative_contour_convert_units.png index a2afdd08056..6e8783aa43f 100644 Binary files a/tests/plots/baseline/test_declarative_contour_convert_units.png and b/tests/plots/baseline/test_declarative_contour_convert_units.png differ diff --git a/tests/plots/baseline/test_declarative_contour_options.png b/tests/plots/baseline/test_declarative_contour_options.png index db82cc68264..1a234544f5c 100644 Binary files a/tests/plots/baseline/test_declarative_contour_options.png and b/tests/plots/baseline/test_declarative_contour_options.png differ diff --git a/tests/plots/test_cartopy_utils.py b/tests/plots/test_cartopy_utils.py index 57259b202b1..db1cc727c7a 100644 --- a/tests/plots/test_cartopy_utils.py +++ b/tests/plots/test_cartopy_utils.py @@ -15,7 +15,8 @@ MPL_VERSION = matplotlib.__version__[:3] -@pytest.mark.mpl_image_compare(tolerance=0.053, remove_text=True) +@pytest.mark.mpl_image_compare(tolerance={'2.1': 0.161}.get(MPL_VERSION, 0.053), + remove_text=True) def test_us_county_defaults(): """Test the default US county plotting.""" proj = ccrs.LambertConformal(central_longitude=-85.0, central_latitude=45.0) @@ -27,7 +28,8 @@ def test_us_county_defaults(): return fig -@pytest.mark.mpl_image_compare(tolerance=0.092, remove_text=True) +@pytest.mark.mpl_image_compare(tolerance={'2.1': 0.1994}.get(MPL_VERSION, 0.092), + remove_text=True) def test_us_county_scales(): """Test US county plotting with all scales.""" proj = ccrs.LambertConformal(central_longitude=-85.0, central_latitude=45.0) @@ -55,7 +57,8 @@ def test_us_states_defaults(): return fig -@pytest.mark.mpl_image_compare(tolerance=0.092, remove_text=True) +@pytest.mark.mpl_image_compare(tolerance={'2.1': 0.991}.get(MPL_VERSION, 0.092), + remove_text=True) def test_us_states_scales(): """Test the default US States plotting with all scales.""" proj = ccrs.LambertConformal(central_longitude=-85.0, central_latitude=45.0) diff --git a/tests/plots/test_declarative.py b/tests/plots/test_declarative.py index 04639f1d50b..6c7c7c55562 100644 --- a/tests/plots/test_declarative.py +++ b/tests/plots/test_declarative.py @@ -10,6 +10,7 @@ import cartopy.crs as ccrs import cartopy.feature as cfeature import matplotlib +import numpy as np import pandas as pd import pytest from traitlets import TraitError @@ -27,8 +28,7 @@ MPL_VERSION = matplotlib.__version__[:3] -@pytest.mark.mpl_image_compare(remove_text=True, - tolerance={'2.0': 3.09}.get(MPL_VERSION, 0.005)) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.005) def test_declarative_image(): """Test making an image plot.""" data = xr.open_dataset(GiniFile(get_test_data('NHEM-MULTICOMP_1km_IR_20151208_2100.gini'))) @@ -50,7 +50,8 @@ def test_declarative_image(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.022) +@pytest.mark.mpl_image_compare(remove_text=True, + tolerance={'2.1': 0.256}.get(MPL_VERSION, 0.022)) def test_declarative_contour(): """Test making a contour plot.""" data = xr.open_dataset(get_test_data('narr_example.nc', as_file_obj=False)) @@ -77,8 +78,23 @@ def test_declarative_contour(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.035) -def test_declarative_contour_options(): +@pytest.fixture +def fix_is_closed_polygon(monkeypatch): + """Fix matplotlib.contour._is_closed_polygons for tests. + + Needed because for Matplotlib<3.3, the internal matplotlib.contour._is_closed_polygon + uses strict floating point equality. This causes the test below to yield different + results for macOS vs. Linux/Windows. + + """ + monkeypatch.setattr(matplotlib.contour, '_is_closed_polygon', + lambda X: np.allclose(X[0], X[-1], rtol=1e-10, atol=1e-13), + raising=False) + + +@pytest.mark.mpl_image_compare(remove_text=True, + tolerance={'2.1': 5.477}.get(MPL_VERSION, 0.035)) +def test_declarative_contour_options(fix_is_closed_polygon): """Test making a contour plot.""" data = xr.open_dataset(get_test_data('narr_example.nc', as_file_obj=False)) @@ -106,8 +122,9 @@ def test_declarative_contour_options(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.035) -def test_declarative_contour_convert_units(): +@pytest.mark.mpl_image_compare(remove_text=True, + tolerance={'2.1': 2.007}.get(MPL_VERSION, 0.035)) +def test_declarative_contour_convert_units(fix_is_closed_polygon): """Test making a contour plot.""" data = xr.open_dataset(get_test_data('narr_example.nc', as_file_obj=False)) @@ -275,7 +292,8 @@ def test_colorfill_horiz_colorbar(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.016) +@pytest.mark.mpl_image_compare(remove_text=True, + tolerance={'2.1': 0.355}.get(MPL_VERSION, 0.016)) def test_colorfill_no_colorbar(): """Test that we can use ContourFillPlot.""" data = xr.open_dataset(get_test_data('narr_example.nc', as_file_obj=False)) @@ -352,7 +370,8 @@ def test_latlon(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.37) +@pytest.mark.mpl_image_compare(remove_text=True, + tolerance={'2.1': 0.418}.get(MPL_VERSION, 0.37)) def test_declarative_barb_options(): """Test making a contour plot.""" data = xr.open_dataset(get_test_data('narr_example.nc', as_file_obj=False)) @@ -380,7 +399,8 @@ def test_declarative_barb_options(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.612) +@pytest.mark.mpl_image_compare(remove_text=True, + tolerance={'2.1': 0.819}.get(MPL_VERSION, 0.612)) def test_declarative_barb_earth_relative(): """Test making a contour plot.""" import numpy as np @@ -474,7 +494,8 @@ def test_declarative_barb_gfs_knots(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.022) +@pytest.mark.mpl_image_compare(remove_text=True, + tolerance={'2.1': 0.407}.get(MPL_VERSION, 0.022)) def test_declarative_sfc_obs(): """Test making a surface observation plot.""" data = pd.read_csv(get_test_data('SFC_obs.csv', as_file_obj=False), @@ -506,7 +527,8 @@ def test_declarative_sfc_obs(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.022) +@pytest.mark.mpl_image_compare(remove_text=True, + tolerance={'2.1': 0.407}.get(MPL_VERSION, 0.022)) def test_declarative_sfc_obs_changes(): """Test making a surface observation plot, changing the field.""" data = pd.read_csv(get_test_data('SFC_obs.csv', as_file_obj=False), @@ -542,7 +564,8 @@ def test_declarative_sfc_obs_changes(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0) +@pytest.mark.mpl_image_compare(remove_text=True, + tolerance={'2.1': 0.378}.get(MPL_VERSION, 0.00586)) def test_declarative_colored_barbs(): """Test making a surface plot with a colored barb (gh-1274).""" data = pd.read_csv(get_test_data('SFC_obs.csv', as_file_obj=False), @@ -575,7 +598,8 @@ def test_declarative_colored_barbs(): @pytest.mark.mpl_image_compare(remove_text=True, - tolerance={'3.1': 9.771, '2.1': 9.771}.get(MPL_VERSION, 0.)) + tolerance={'3.1': 9.771, + '2.1': 9.785}.get(MPL_VERSION, 0.00651)) def test_declarative_sfc_obs_full(): """Test making a full surface observation plot.""" data = pd.read_csv(get_test_data('SFC_obs.csv', as_file_obj=False), diff --git a/tests/plots/test_station_plot.py b/tests/plots/test_station_plot.py index 72c6883ca55..9da67af0fbf 100644 --- a/tests/plots/test_station_plot.py +++ b/tests/plots/test_station_plot.py @@ -17,6 +17,9 @@ from metpy.units import units +MPL_VERSION = matplotlib.__version__[:3] + + @pytest.mark.mpl_image_compare(tolerance=2.444, savefig_kwargs={'dpi': 300}, remove_text=True) def test_stationplot_api(): """Test the StationPlot API.""" @@ -279,7 +282,8 @@ def wind_plot(): return u, v, x, y -@pytest.mark.mpl_image_compare(tolerance=0.00323, remove_text=True) +@pytest.mark.mpl_image_compare(tolerance={'2.1': 0.0423}.get(MPL_VERSION, 0.00434), + remove_text=True) def test_barb_projection(wind_plot): """Test that barbs are properly projected (#598).""" u, v, x, y = wind_plot @@ -287,14 +291,15 @@ def test_barb_projection(wind_plot): # Plot and check barbs (they should align with grid lines) fig = plt.figure() ax = fig.add_subplot(1, 1, 1, projection=ccrs.LambertConformal()) - ax.gridlines(xlocs=[-135, -120, -105, -90, -75, -60, -45]) + ax.gridlines(xlocs=[-120, -105, -90, -75, -60], ylocs=np.arange(24, 55, 6)) sp = StationPlot(ax, x, y, transform=ccrs.PlateCarree()) sp.plot_barb(u, v) return fig -@pytest.mark.mpl_image_compare(tolerance=0.00205, remove_text=True) +@pytest.mark.mpl_image_compare(tolerance={'2.1': 0.0693}.get(MPL_VERSION, 0.00382), + remove_text=True) def test_arrow_projection(wind_plot): """Test that arrows are properly projected.""" u, v, x, y = wind_plot @@ -302,7 +307,7 @@ def test_arrow_projection(wind_plot): # Plot and check barbs (they should align with grid lines) fig = plt.figure() ax = fig.add_subplot(1, 1, 1, projection=ccrs.LambertConformal()) - ax.gridlines(xlocs=[-135, -120, -105, -90, -75, -60, -45]) + ax.gridlines(xlocs=[-120, -105, -90, -75, -60], ylocs=np.arange(24, 55, 6)) sp = StationPlot(ax, x, y, transform=ccrs.PlateCarree()) sp.plot_arrow(u, v) sp.plot_arrow(u, v) # plot_arrow used twice to hit removal if statement