diff --git a/docs/user-guide/mpl.ipynb b/docs/user-guide/mpl.ipynb index c61d436a3..2dfd8c1ed 100644 --- a/docs/user-guide/mpl.ipynb +++ b/docs/user-guide/mpl.ipynb @@ -12,7 +12,7 @@ "This guide covers:\n", "1. Rasterizing Data onto a Cartopy {class}`~cartopy.mpl.geoaxes.GeoAxes`\n", "2. Visualizing Data with {class}`~matplotlib.collections.PolyCollection`\n", - "3. Visualizing Grid Topology with {class}`~matplotlib.collections.LineCollection`" + "3. Visualizing Grid Topology" ] }, { @@ -353,7 +353,8 @@ "metadata": {}, "outputs": [], "source": [ - "poly_collection = uxds[\"bottomDepth\"].to_polycollection()" + "projection = ccrs.Robinson()\n", + "poly_collection = uxds[\"bottomDepth\"].to_polycollection(projection=projection)" ] }, { @@ -363,9 +364,6 @@ "metadata": {}, "outputs": [], "source": [ - "# disables grid lines\n", - "poly_collection.set_antialiased(False)\n", - "\n", "poly_collection.set_cmap(\"Blues\")\n", "\n", "fig, ax = plt.subplots(\n", @@ -373,7 +371,7 @@ " 1,\n", " facecolor=\"w\",\n", " constrained_layout=True,\n", - " subplot_kw=dict(projection=ccrs.Robinson()),\n", + " subplot_kw=dict(projection=projection),\n", ")\n", "\n", "ax.add_feature(cfeature.COASTLINE)\n", @@ -389,6 +387,10 @@ "id": "22", "metadata": {}, "source": [ + "```{important}\n", + "By default, `periodic_elements` is set to `\"exclude\"`. \n", + "```\n", + "\n", "To reduce the number of polygons in the collection, you can [subset](./subset) before converting." ] }, @@ -401,7 +403,7 @@ "source": [ "lon_bounds = (-50, 50)\n", "lat_bounds = (-20, 20)\n", - "b = 5 # buffer for the selection so we can fill the plot area\n", + "b = 15 # buffer for the selection so we can fill the plot area\n", "\n", "poly_collection = (\n", " uxds[\"bottomDepth\"]\n", @@ -409,7 +411,7 @@ " lon_bounds=(lon_bounds[0] - b, lon_bounds[1] + b),\n", " lat_bounds=(lat_bounds[0] - b, lat_bounds[1] + b),\n", " )\n", - " .to_polycollection()\n", + " .to_polycollection(projection=projection)\n", ")\n", "\n", "poly_collection.set_cmap(\"Blues\")\n", @@ -420,16 +422,26 @@ " figsize=(7, 3.5),\n", " facecolor=\"w\",\n", " constrained_layout=True,\n", - " subplot_kw=dict(projection=ccrs.Robinson()),\n", + " subplot_kw=dict(projection=projection),\n", ")\n", "\n", - "ax.set_extent(lon_bounds + lat_bounds, crs=PlateCarree())\n", + "ax.set_extent(lon_bounds + lat_bounds)\n", "\n", "ax.add_feature(cfeature.COASTLINE)\n", "ax.add_feature(cfeature.BORDERS)\n", "\n", "ax.add_collection(poly_collection)\n", - "plt.title(\"PolyCollection\")" + "plt.title(\"subset PolyCollection\")" + ] + }, + { + "cell_type": "markdown", + "id": "206c73f6", + "metadata": {}, + "source": [ + "```{tip}\n", + "When working with a fine grid, matplotlib's anti-alias algorithm can sometimes produce unexpected visualization effects that look like missing patches or a white \"hazing\" from the fine slivers between the polygons. Try using `poly_collection.set_edgecolor('face)` and adjusting with `poly_collection.set_linewidth()` if needed.\n", + "```" ] }, { @@ -437,13 +449,54 @@ "id": "24", "metadata": {}, "source": [ - "### Visualize Grid Topology with `LineCollection`\n", + "### Visualize Grid Topology\n" + ] + }, + { + "cell_type": "markdown", + "id": "36ddaa74", + "metadata": {}, + "source": [ + "#### Using `PolyCollection`\n", + "To visualize the unstructured grid geometry, you can set the edge color on an existing `PolyCollection`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6e1970f9", + "metadata": {}, + "outputs": [], + "source": [ + "projection = ccrs.Robinson()\n", + "poly_collection = uxds[\"bottomDepth\"].to_polycollection(projection=projection)\n", "\n", - "To visualize the unstructured grid geometry, you can convert a {class}`~uxarray.Grid` into a {class}`~matplotlib.collections.LineCollection`, which stores the edges of the unstructured grid.\n", + "poly_collection.set_cmap(\"Blues\")\n", + "poly_collection.set_edgecolor(\"black\")\n", "\n", - "```{important}\n", - "Since the transform for the {class}`~matplotlib.collections.LineCollection` and {class}`~matplotlib.collections.PolyCollection` are set to `ccrs.Geodetic()`, the edges and polygons are drawn correctly on the surface of a sphere and properly at the antimeridian.\n", - "```" + "fig, ax = plt.subplots(\n", + " 1,\n", + " 1,\n", + " facecolor=\"w\",\n", + " constrained_layout=True,\n", + " subplot_kw=dict(projection=projection),\n", + ")\n", + "\n", + "ax.add_feature(cfeature.COASTLINE)\n", + "ax.add_feature(cfeature.BORDERS)\n", + "\n", + "ax.add_collection(poly_collection)\n", + "ax.set_global()\n", + "plt.title(\"PolyCollection with set_edgecolor\")" + ] + }, + { + "cell_type": "markdown", + "id": "f45dbc72", + "metadata": {}, + "source": [ + "#### Using `LineCollection`\n", + "You can also convert a {class}`~uxarray.Grid` into a {class}`~matplotlib.collections.LineCollection`, which stores the edges of the unstructured grid." ] }, { @@ -453,7 +506,9 @@ "metadata": {}, "outputs": [], "source": [ - "line_collection = uxds.uxgrid.to_linecollection(colors=\"black\", linewidths=0.5)" + "line_collection = uxds.uxgrid.to_linecollection(\n", + " colors=\"black\", linewidths=0.5, periodic_elements=\"split\"\n", + ")" ] }, { @@ -481,7 +536,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "uxarray-viz-testing", "language": "python", "name": "python3" }, @@ -495,7 +550,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.5" + "version": "3.13.11" } }, "nbformat": 4, diff --git a/test/core/test_dataarray.py b/test/core/test_dataarray.py index 49061748f..b53bc74ef 100644 --- a/test/core/test_dataarray.py +++ b/test/core/test_dataarray.py @@ -71,7 +71,7 @@ def test_to_polycollection(gridpath, datasetpath): uxds_geoflow['v1'].to_polycollection() # grid conversion - pc_geoflow_grid = uxds_geoflow.uxgrid.to_polycollection() + pc_geoflow_grid = uxds_geoflow.uxgrid.to_polycollection(periodic_elements="ignore") # number of elements assert len(pc_geoflow_grid._paths) == uxds_geoflow.uxgrid.n_face diff --git a/test/test_plot.py b/test/test_plot.py index 90e568483..f7cf29561 100644 --- a/test/test_plot.py +++ b/test/test_plot.py @@ -227,12 +227,3 @@ def test_to_raster_pixel_ratio(gridpath, r1, r2): f = r2 / r1 d = np.array(raster2.shape) - f * np.array(raster1.shape) assert (d >= 0).all() and (d <= f - 1).all() - - -def test_collections_projection_kwarg(gridpath): - import cartopy.crs as ccrs - uxgrid = ux.open_grid(gridpath("ugrid", "outCSne30", "outCSne30.ug")) - - with pytest.warns(FutureWarning): - pc = uxgrid.to_polycollection(projection=ccrs.PlateCarree()) - lc = uxgrid.to_linecollection(projection=ccrs.PlateCarree()) diff --git a/uxarray/core/dataarray.py b/uxarray/core/dataarray.py index 2c52710d1..64bbeacb4 100644 --- a/uxarray/core/dataarray.py +++ b/uxarray/core/dataarray.py @@ -2,9 +2,10 @@ import warnings from html import escape -from typing import TYPE_CHECKING, Any, Hashable, Literal, Mapping +from typing import TYPE_CHECKING, Any, Hashable, Literal, Mapping, Optional from warnings import warn +import cartopy.crs as ccrs import numpy as np import xarray as xr from cartopy.mpl.geoaxes import GeoAxes @@ -275,6 +276,11 @@ def to_geodataframe( def to_polycollection( self, + periodic_elements: Optional[str] = "exclude", + projection: Optional[ccrs.Projection] = None, + return_indices: Optional[bool] = False, + cache: Optional[bool] = True, + override: Optional[bool] = False, **kwargs, ): """Constructs a ``matplotlib.collections.PolyCollection``` consisting @@ -283,8 +289,19 @@ def to_polycollection( Parameters ---------- - **kwargs: dict - Key word arguments to pass into the construction of a PolyCollection + periodic_elements : str, optional + Method for handling periodic elements. One of ['exclude', 'split', or 'ignore']: + - 'exclude': Periodic elements will be identified and excluded from the GeoDataFrame + - 'split': Periodic elements will be identified and split using the ``antimeridian`` package + - 'ignore': No processing will be applied to periodic elements. + projection: ccrs.Projection + Cartopy geographic projection to use + return_indices: bool + Flag to indicate whether to return the indices of corrected polygons, if any exist + cache: bool + Flag to indicate whether to cache the computed PolyCollection + override: bool + Flag to indicate whether to override a cached PolyCollection, if it exists """ # data is multidimensional, must be a 1D slice if self.values.ndim > 1: @@ -293,11 +310,53 @@ def to_polycollection( f"for face-centered data." ) - poly_collection = self.uxgrid.to_polycollection(**kwargs) + if self._face_centered(): + poly_collection, corrected_to_original_faces = ( + self.uxgrid.to_polycollection( + override=override, + cache=cache, + periodic_elements=periodic_elements, + return_indices=True, + projection=projection, + **kwargs, + ) + ) - poly_collection.set_array(self.data) + if periodic_elements == "exclude": + # index data to ignore data mapped to periodic elements + _data = np.delete( + self.values, + self.uxgrid._poly_collection_cached_parameters[ + "antimeridian_face_indices" + ], + axis=0, + ) + elif periodic_elements == "split": + _data = self.values[corrected_to_original_faces] + else: + _data = self.values - return poly_collection + if ( + self.uxgrid._poly_collection_cached_parameters[ + "non_nan_polygon_indices" + ] + is not None + ): + # index data to ignore NaN polygons + _data = _data[ + self.uxgrid._poly_collection_cached_parameters[ + "non_nan_polygon_indices" + ] + ] + + poly_collection.set_array(_data) + + if return_indices: + return poly_collection, corrected_to_original_faces + else: + return poly_collection + else: + raise ValueError("Data variable must be face centered.") def to_raster( self, diff --git a/uxarray/grid/geometry.py b/uxarray/grid/geometry.py index dd3899b2c..b7a0c5c51 100644 --- a/uxarray/grid/geometry.py +++ b/uxarray/grid/geometry.py @@ -449,7 +449,7 @@ def _grid_to_matplotlib_polycollection( # Handle unsupported configuration: splitting periodic elements with projection if periodic_elements == "split" and projection is not None: raise ValueError( - "Explicitly projecting lines is not supported. Please pass in your projection" + "Explicitly projecting lines is not supported. Please pass in your projection " "using the 'transform' parameter" ) @@ -475,15 +475,6 @@ def _grid_to_matplotlib_polycollection( central_longitude=central_longitude, ) - # Filter out degenerate polygons, which cause issues with Cartopy 0.25 - # fmt: off - degenerate = ( - (polygon_shells[:,0,0][:,np.newaxis] == polygon_shells[:,1:,0]).all(axis=1) - | (polygon_shells[:,0,1][:,np.newaxis] == polygon_shells[:,1:,1]).all(axis=1) - ) - # fmt: on - polygon_shells = polygon_shells[~degenerate, ...] - # Projected polygon shells if a projection is specified if projection is not None: projected_polygon_shells = _build_polygon_shells( @@ -516,6 +507,13 @@ def _grid_to_matplotlib_polycollection( does_not_contain_nan = ~np.isnan(shells_d).any(axis=(1, 2)) non_nan_polygon_indices = np.where(does_not_contain_nan)[0] + grid._poly_collection_cached_parameters["non_nan_polygon_indices"] = ( + non_nan_polygon_indices + ) + grid._poly_collection_cached_parameters["antimeridian_face_indices"] = ( + antimeridian_face_indices + ) + # Select which shells to use: projected or original if projected_polygon_shells is not None: shells_to_use = projected_polygon_shells diff --git a/uxarray/grid/grid.py b/uxarray/grid/grid.py index 16c012552..1559e3df1 100644 --- a/uxarray/grid/grid.py +++ b/uxarray/grid/grid.py @@ -1,9 +1,10 @@ import copy import os from html import escape -from typing import Sequence +from typing import Optional, Sequence from warnings import warn +import cartopy.crs as ccrs import numpy as np import xarray as xr from xarray.core.options import OPTIONS @@ -207,9 +208,22 @@ def __init__( "antimeridian_face_indices": None, } - # Cached matplotlib data structures - self._cached_poly_collection = None - self._cached_line_collection = None + # cached parameters for PolyCollection conversions + self._poly_collection_cached_parameters = { + "poly_collection": None, + "periodic_elements": None, + "projection": None, + "corrected_to_original_faces": None, + "non_nan_polygon_indices": None, + "antimeridian_face_indices": None, + } + + # cached parameters for LineCollection conversions + self._line_collection_cached_parameters = { + "line_collection": None, + "periodic_elements": None, + "projection": None, + } self._raster_data_id = None @@ -2285,89 +2299,147 @@ def to_geodataframe( def to_polycollection( self, + periodic_elements: Optional[str] = "exclude", + projection: Optional[ccrs.Projection] = None, + return_indices: Optional[bool] = False, + cache: Optional[bool] = True, + override: Optional[bool] = False, + return_non_nan_polygon_indices: Optional[bool] = False, **kwargs, ): """Constructs a ``matplotlib.collections.PolyCollection``` consisting - of polygons representing the faces of the unstructured grid. + of polygons representing the faces of the current ``Grid`` Parameters ---------- + periodic_elements : str, optional + Method for handling periodic elements. One of ['exclude', 'split', or 'ignore']: + - 'exclude': Periodic elements will be identified and excluded from the GeoDataFrame + - 'split': Periodic elements will be identified and split using the ``antimeridian`` package + - 'ignore': No processing will be applied to periodic elements. + projection: ccrs.Projection + Cartopy geographic projection to use + return_indices: bool + Flag to indicate whether to return the indices of corrected polygons, if any exist + cache: bool + Flag to indicate whether to cache the computed PolyCollection + override: bool + Flag to indicate whether to override a cached PolyCollection, if it exists **kwargs: dict Key word arguments to pass into the construction of a PolyCollection """ - import cartopy.crs as ccrs - if "projection" in kwargs: - proj = kwargs.pop("projection") - warn( - ( - "'projection' is not a supported argument and will be ignored. " - "Define the desired projection on the GeoAxes that this collection will be added to. " - "Example:\n" - " fig, ax = plt.subplots(subplot_kw={'projection': ccrs.Robinson()})\n" - " ax.add_collection(poly_collection)\n" - f"(received projection={proj!r})" - ), - category=FutureWarning, - stacklevel=2, + if periodic_elements not in ["ignore", "exclude", "split"]: + raise ValueError( + f"Invalid value for 'periodic_elements'. Expected one of ['include', 'exclude', 'split'] but received: {periodic_elements}" ) - if self._cached_poly_collection: - return copy.deepcopy(self._cached_poly_collection) + if self._poly_collection_cached_parameters["poly_collection"] is not None: + if ( + self._poly_collection_cached_parameters["periodic_elements"] + != periodic_elements + or self._poly_collection_cached_parameters["projection"] != projection + ): + # cached PolyCollection has a different projection or periodic element handling method + override = True + + if ( + self._poly_collection_cached_parameters["poly_collection"] is not None + and not override + ): + # use cached PolyCollection + if return_indices: + return copy.deepcopy( + self._poly_collection_cached_parameters["poly_collection"] + ), self._poly_collection_cached_parameters[ + "corrected_to_original_faces" + ] + else: + return copy.deepcopy( + self._poly_collection_cached_parameters["poly_collection"] + ) ( poly_collection, corrected_to_original_faces, ) = _grid_to_matplotlib_polycollection( - self, periodic_elements="ignore", projection=None, **kwargs + self, periodic_elements, projection, **kwargs ) - poly_collection.set_transform(ccrs.Geodetic()) - self._cached_poly_collection = poly_collection + if cache: + # cache PolyCollection, indices, and state + self._poly_collection_cached_parameters["poly_collection"] = poly_collection + self._poly_collection_cached_parameters["corrected_to_original_faces"] = ( + corrected_to_original_faces + ) + self._poly_collection_cached_parameters["periodic_elements"] = ( + periodic_elements + ) + self._poly_collection_cached_parameters["projection"] = projection - return copy.deepcopy(poly_collection) + if return_indices: + return copy.deepcopy(poly_collection), corrected_to_original_faces + else: + return copy.deepcopy(poly_collection) def to_linecollection( self, + periodic_elements: Optional[str] = "exclude", + projection: Optional[ccrs.Projection] = None, + cache: Optional[bool] = True, + override: Optional[bool] = False, **kwargs, ): """Constructs a ``matplotlib.collections.LineCollection``` consisting - of lines representing the edges of the unstructured grid. + of lines representing the edges of the current unstructured grid. Parameters ---------- + periodic_elements : str, optional + Method for handling periodic elements. One of ['exclude', 'split', or 'ignore']: + - 'exclude': Periodic elements will be identified and excluded from the GeoDataFrame + - 'split': Periodic elements will be identified and split using the ``antimeridian`` package + - 'ignore': No processing will be applied to periodic elements. + projection: ccrs.Projection + Cartopy geographic projection to use + cache: bool + Flag to indicate whether to cache the computed LineCollection + override: bool + Flag to indicate whether to override a cached LineCollection, if it exists **kwargs: dict Key word arguments to pass into the construction of a LineCollection """ - import cartopy.crs as ccrs - - if "projection" in kwargs: - proj = kwargs.pop("projection") - warn( - ( - "'projection' is not a supported argument and will be ignored. " - "Define the desired projection on the GeoAxes that this collection will be added to. " - "Example:\n" - " fig, ax = plt.subplots(subplot_kw={'projection': ccrs.Robinson()})\n" - " ax.add_collection(line_collection)\n" - f"(received projection={proj!r})" - ), - category=FutureWarning, - stacklevel=2, + if periodic_elements not in ["ignore", "exclude", "split"]: + raise ValueError( + f"Invalid value for 'periodic_elements'. Expected one of ['ignore', 'exclude', 'split'] but received: {periodic_elements}" ) - if self._cached_line_collection: - return copy.deepcopy(self._cached_line_collection) + + if self._line_collection_cached_parameters["line_collection"] is not None: + if ( + self._line_collection_cached_parameters["periodic_elements"] + != periodic_elements + or self._line_collection_cached_parameters["projection"] != projection + ): + override = True + + if not override: + return self._line_collection_cached_parameters["line_collection"] line_collection = _grid_to_matplotlib_linecollection( grid=self, - periodic_elements="ingore", - projection=None, + periodic_elements=periodic_elements, + projection=projection, **kwargs, ) - line_collection.set_transform(ccrs.Geodetic()) - - self._cached_line_collection = line_collection + if cache: + self._line_collection_cached_parameters["line_collection"] = line_collection + self._line_collection_cached_parameters["periodic_elements"] = ( + periodic_elements + ) + self._line_collection_cached_parameters["periodic_elements"] = ( + periodic_elements + ) return copy.deepcopy(line_collection)