From e3446b20dacddf5d9b33303c8866ad012c2af6e2 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Mon, 8 Dec 2025 12:32:01 -0600 Subject: [PATCH 01/11] =?UTF-8?q?o=20Fix=20#1393,=20=5Fensure=5Fdimensions?= =?UTF-8?q?=20now=20squeezes=20away=20extra=20length=E2=80=911=20axes=20an?= =?UTF-8?q?d=20returns=20that=201D=20view.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/test_plot.py | 16 ++++++++++++++++ uxarray/core/dataarray.py | 4 ++-- uxarray/plot/matplotlib.py | 18 +++++++++++------- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/test/test_plot.py b/test/test_plot.py index 8af794c90..7b3be886c 100644 --- a/test/test_plot.py +++ b/test/test_plot.py @@ -108,6 +108,22 @@ def test_to_raster(gridpath): assert isinstance(raster, np.ndarray) +def test_to_raster_with_extra_dims(gridpath): + fig, ax = plt.subplots( + subplot_kw={'projection': ccrs.Robinson()}, + constrained_layout=True, + figsize=(10, 5), + ) + + mesh_path = gridpath("mpas", "QU", "oQU480.231010.nc") + uxds = ux.open_dataset(mesh_path, mesh_path) + + da = uxds['bottomDepth'].expand_dims(time=[0]) + raster = da.isel(time=slice(0, 1)).to_raster(ax=ax) + + assert isinstance(raster, np.ndarray) + + def test_to_raster_reuse_mapping(gridpath, tmpdir): diff --git a/uxarray/core/dataarray.py b/uxarray/core/dataarray.py index 7ad62050c..63ffc5b54 100644 --- a/uxarray/core/dataarray.py +++ b/uxarray/core/dataarray.py @@ -375,7 +375,7 @@ def to_raster( _RasterAxAttrs, ) - _ensure_dimensions(self) + data = _ensure_dimensions(self) if not isinstance(ax, GeoAxes): raise TypeError("`ax` must be an instance of cartopy.mpl.geoaxes.GeoAxes") @@ -405,7 +405,7 @@ def to_raster( pixel_mapping = np.asarray(pixel_mapping, dtype=INT_DTYPE) raster, pixel_mapping_np = _nearest_neighbor_resample( - self, + data, ax, pixel_ratio=pixel_ratio, pixel_mapping=pixel_mapping, diff --git a/uxarray/plot/matplotlib.py b/uxarray/plot/matplotlib.py index adb0a8697..5e43a30af 100644 --- a/uxarray/plot/matplotlib.py +++ b/uxarray/plot/matplotlib.py @@ -22,17 +22,21 @@ def _ensure_dimensions(data: UxDataArray) -> UxDataArray: ValueError If the sole dimension is not named "n_face". """ - # Check dimensionality - if data.ndim != 1: + # Allow extra singleton dimensions as long as there's exactly one non-singleton dim + non_trivial_dims = [dim for dim, size in zip(data.dims, data.shape) if size != 1] + + if len(non_trivial_dims) != 1: raise ValueError( - f"Expected a 1D DataArray over 'n_face', but got {data.ndim} dimensions: {data.dims}" + "Expected data with a single dimension (other axes may be length 1), " + f"but got dims {data.dims} with shape {data.shape}" ) - # Check dimension name - if data.dims[0] != "n_face": - raise ValueError(f"Expected dimension 'n_face', but got '{data.dims[0]}'") + sole_dim = non_trivial_dims[0] + if sole_dim != "n_face": + raise ValueError(f"Expected dimension 'n_face', but got '{sole_dim}'") - return data + # Squeeze any singleton axes to ensure we return a true 1D array over n_face + return data.squeeze() class _RasterAxAttrs(NamedTuple): From e8c519826e1a63d020ca52676e1a05f5c2505088 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Wed, 17 Dec 2025 13:14:53 -0600 Subject: [PATCH 02/11] Try to set extent if none provided - xarray style --- uxarray/core/dataarray.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/uxarray/core/dataarray.py b/uxarray/core/dataarray.py index 4e6046247..d99394a31 100644 --- a/uxarray/core/dataarray.py +++ b/uxarray/core/dataarray.py @@ -380,6 +380,24 @@ def to_raster( if not isinstance(ax, GeoAxes): raise TypeError("`ax` must be an instance of cartopy.mpl.geoaxes.GeoAxes") + auto_extent_applied = False + if pixel_mapping is None: + xlim, ylim = ax.get_xlim(), ax.get_ylim() + if np.allclose(xlim, (0.0, 1.0)) and np.allclose(ylim, (0.0, 1.0)): + try: + import cartopy.crs as ccrs + + lon = self.uxgrid.node_lon.values + lat = self.uxgrid.node_lat.values + lon_min, lon_max = float(np.nanmin(lon)), float(np.nanmax(lon)) + lat_min, lat_max = float(np.nanmin(lat)), float(np.nanmax(lat)) + ax.set_extent( + (lon_min, lon_max, lat_min, lat_max), crs=ccrs.PlateCarree() + ) + auto_extent_applied = True + except Exception: + pass + pixel_ratio_set = pixel_ratio is not None if not pixel_ratio_set: pixel_ratio = 1.0 @@ -403,6 +421,12 @@ def to_raster( + input_ax_attrs._value_comparison_message(pm_ax_attrs) ) pixel_mapping = np.asarray(pixel_mapping, dtype=INT_DTYPE) + elif auto_extent_applied: + warn( + "Axes extent was default; auto-setting from grid lon/lat bounds for rasterization. " + "Set the extent explicitly to control this.", + stacklevel=2, + ) raster, pixel_mapping_np = _nearest_neighbor_resample( data, From 0026deb469bf4f77b65a4c8fbdf510729699d892 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Wed, 17 Dec 2025 17:21:28 -0600 Subject: [PATCH 03/11] to_raster: compute extent bounds via xarray --- uxarray/core/dataarray.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/uxarray/core/dataarray.py b/uxarray/core/dataarray.py index d99394a31..714b6de5c 100644 --- a/uxarray/core/dataarray.py +++ b/uxarray/core/dataarray.py @@ -387,12 +387,22 @@ def to_raster( try: import cartopy.crs as ccrs - lon = self.uxgrid.node_lon.values - lat = self.uxgrid.node_lat.values - lon_min, lon_max = float(np.nanmin(lon)), float(np.nanmax(lon)) - lat_min, lat_max = float(np.nanmin(lat)), float(np.nanmax(lat)) + lon_min = self.uxgrid.node_lon.min(skipna=True) + lon_max = self.uxgrid.node_lon.max(skipna=True) + lat_min = self.uxgrid.node_lat.min(skipna=True) + lat_max = self.uxgrid.node_lat.max(skipna=True) + + lon_min, lon_max = ( + float(lon_min.to_numpy().item()), + float(lon_max.to_numpy().item()), + ) + lat_min, lat_max = ( + float(lat_min.to_numpy().item()), + float(lat_max.to_numpy().item()), + ) ax.set_extent( - (lon_min, lon_max, lat_min, lat_max), crs=ccrs.PlateCarree() + (lon_min, lon_max, lat_min, lat_max), + crs=ccrs.PlateCarree(), ) auto_extent_applied = True except Exception: From e4d0d4c17a49699403331bd9e6e8707d57282bb8 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Wed, 17 Dec 2025 17:27:58 -0600 Subject: [PATCH 04/11] to_raster: align auto-extent with pixel_mapping flow --- uxarray/core/dataarray.py | 61 +++++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/uxarray/core/dataarray.py b/uxarray/core/dataarray.py index 714b6de5c..1a4cbef13 100644 --- a/uxarray/core/dataarray.py +++ b/uxarray/core/dataarray.py @@ -380,8 +380,30 @@ def to_raster( if not isinstance(ax, GeoAxes): raise TypeError("`ax` must be an instance of cartopy.mpl.geoaxes.GeoAxes") - auto_extent_applied = False - if pixel_mapping is None: + pixel_ratio_set = pixel_ratio is not None + if not pixel_ratio_set: + pixel_ratio = 1.0 + if pixel_mapping is not None: + input_ax_attrs = _RasterAxAttrs.from_ax(ax, pixel_ratio=pixel_ratio) + if isinstance(pixel_mapping, xr.DataArray): + pixel_ratio_input = pixel_ratio + pixel_ratio = pixel_mapping.attrs["pixel_ratio"] + if pixel_ratio_set and pixel_ratio_input != pixel_ratio: + warn( + "Pixel ratio mismatch: " + f"{pixel_ratio_input} passed but {pixel_ratio} in pixel_mapping. " + "Using the pixel_mapping attribute.", + stacklevel=2, + ) + input_ax_attrs = _RasterAxAttrs.from_ax(ax, pixel_ratio=pixel_ratio) + pm_ax_attrs = _RasterAxAttrs.from_xr_attrs(pixel_mapping.attrs) + if input_ax_attrs != pm_ax_attrs: + raise ValueError( + "Pixel mapping incompatible with ax. " + + input_ax_attrs._value_comparison_message(pm_ax_attrs) + ) + pixel_mapping = np.asarray(pixel_mapping, dtype=INT_DTYPE) + else: xlim, ylim = ax.get_xlim(), ax.get_ylim() if np.allclose(xlim, (0.0, 1.0)) and np.allclose(ylim, (0.0, 1.0)): try: @@ -404,39 +426,14 @@ def to_raster( (lon_min, lon_max, lat_min, lat_max), crs=ccrs.PlateCarree(), ) - auto_extent_applied = True - except Exception: - pass - - pixel_ratio_set = pixel_ratio is not None - if not pixel_ratio_set: - pixel_ratio = 1.0 - input_ax_attrs = _RasterAxAttrs.from_ax(ax, pixel_ratio=pixel_ratio) - if pixel_mapping is not None: - if isinstance(pixel_mapping, xr.DataArray): - pixel_ratio_input = pixel_ratio - pixel_ratio = pixel_mapping.attrs["pixel_ratio"] - if pixel_ratio_set and pixel_ratio_input != pixel_ratio: warn( - "Pixel ratio mismatch: " - f"{pixel_ratio_input} passed but {pixel_ratio} in pixel_mapping. " - "Using the pixel_mapping attribute.", + "Axes extent was default; auto-setting from grid lon/lat bounds for rasterization. " + "Set the extent explicitly to control this.", stacklevel=2, ) - input_ax_attrs = _RasterAxAttrs.from_ax(ax, pixel_ratio=pixel_ratio) - pm_ax_attrs = _RasterAxAttrs.from_xr_attrs(pixel_mapping.attrs) - if input_ax_attrs != pm_ax_attrs: - raise ValueError( - "Pixel mapping incompatible with ax. " - + input_ax_attrs._value_comparison_message(pm_ax_attrs) - ) - pixel_mapping = np.asarray(pixel_mapping, dtype=INT_DTYPE) - elif auto_extent_applied: - warn( - "Axes extent was default; auto-setting from grid lon/lat bounds for rasterization. " - "Set the extent explicitly to control this.", - stacklevel=2, - ) + except Exception: + pass + input_ax_attrs = _RasterAxAttrs.from_ax(ax, pixel_ratio=pixel_ratio) raster, pixel_mapping_np = _nearest_neighbor_resample( data, From cc0aab7cc6cc865fbf07e5905ad534b611c996c2 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Wed, 17 Dec 2025 17:28:37 -0600 Subject: [PATCH 05/11] to_raster: clarify auto-extent warning --- uxarray/core/dataarray.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/uxarray/core/dataarray.py b/uxarray/core/dataarray.py index 1a4cbef13..b98d491ec 100644 --- a/uxarray/core/dataarray.py +++ b/uxarray/core/dataarray.py @@ -428,7 +428,8 @@ def to_raster( ) warn( "Axes extent was default; auto-setting from grid lon/lat bounds for rasterization. " - "Set the extent explicitly to control this.", + "Set the extent explicitly to control this, e.g. via ax.set_global(), " + "ax.set_extent(...), or ax.set_xlim(...) + ax.set_ylim(...).", stacklevel=2, ) except Exception: From 9d74d3851bee9e7a0e7803a6cc6bb40a792eb986 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Wed, 17 Dec 2025 17:29:53 -0600 Subject: [PATCH 06/11] tests: simplify extra-dims raster case --- test/test_plot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_plot.py b/test/test_plot.py index 7b3be886c..422941424 100644 --- a/test/test_plot.py +++ b/test/test_plot.py @@ -119,7 +119,7 @@ def test_to_raster_with_extra_dims(gridpath): uxds = ux.open_dataset(mesh_path, mesh_path) da = uxds['bottomDepth'].expand_dims(time=[0]) - raster = da.isel(time=slice(0, 1)).to_raster(ax=ax) + raster = da.to_raster(ax=ax) assert isinstance(raster, np.ndarray) From a689170b8c92647b93101f35187a7ab806c6085e Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Wed, 17 Dec 2025 17:39:04 -0600 Subject: [PATCH 07/11] tests: assert auto-extent warning --- test/test_plot.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/test_plot.py b/test/test_plot.py index 422941424..90e568483 100644 --- a/test/test_plot.py +++ b/test/test_plot.py @@ -103,7 +103,8 @@ def test_to_raster(gridpath): mesh_path = gridpath("mpas", "QU", "oQU480.231010.nc") uxds = ux.open_dataset(mesh_path, mesh_path) - raster = uxds['bottomDepth'].to_raster(ax=ax) + with pytest.warns(UserWarning, match=r"Axes extent was default"): + raster = uxds['bottomDepth'].to_raster(ax=ax) assert isinstance(raster, np.ndarray) @@ -119,7 +120,8 @@ def test_to_raster_with_extra_dims(gridpath): uxds = ux.open_dataset(mesh_path, mesh_path) da = uxds['bottomDepth'].expand_dims(time=[0]) - raster = da.to_raster(ax=ax) + with pytest.warns(UserWarning, match=r"Axes extent was default"): + raster = da.to_raster(ax=ax) assert isinstance(raster, np.ndarray) @@ -137,9 +139,10 @@ def test_to_raster_reuse_mapping(gridpath, tmpdir): uxds = ux.open_dataset(mesh_path, mesh_path) # Returning - raster1, pixel_mapping = uxds['bottomDepth'].to_raster( - ax=ax, pixel_ratio=0.5, return_pixel_mapping=True - ) + with pytest.warns(UserWarning, match=r"Axes extent was default"): + raster1, pixel_mapping = uxds['bottomDepth'].to_raster( + ax=ax, pixel_ratio=0.5, return_pixel_mapping=True + ) assert isinstance(raster1, np.ndarray) assert isinstance(pixel_mapping, xr.DataArray) From 84102242839af627b772994182632c14e241fdae Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Wed, 17 Dec 2025 18:39:35 -0600 Subject: [PATCH 08/11] to_raster: detect default extent via autoscale Cartopy can set projection limits while autoscale stays on, so the old (0,1)-only check missed default extents. Using autoscale plus a global PlateCarree extent keeps the warning firing when users never set an extent. --- uxarray/core/dataarray.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/uxarray/core/dataarray.py b/uxarray/core/dataarray.py index b98d491ec..f05fed077 100644 --- a/uxarray/core/dataarray.py +++ b/uxarray/core/dataarray.py @@ -404,8 +404,23 @@ def to_raster( ) pixel_mapping = np.asarray(pixel_mapping, dtype=INT_DTYPE) else: - xlim, ylim = ax.get_xlim(), ax.get_ylim() - if np.allclose(xlim, (0.0, 1.0)) and np.allclose(ylim, (0.0, 1.0)): + + def _is_default_extent() -> bool: + # Default extents can be (0, 1) or projection limits while autoscale stays on. + if not ax.get_autoscale_on(): + return False + try: + import cartopy.crs as ccrs + + extent = ax.get_extent(ccrs.PlateCarree()) + except Exception: + xlim, ylim = ax.get_xlim(), ax.get_ylim() + return np.allclose(xlim, (0.0, 1.0)) and np.allclose( + ylim, (0.0, 1.0) + ) + return np.allclose(extent, (-180.0, 180.0, -90.0, 90.0), atol=1.0) + + if _is_default_extent(): try: import cartopy.crs as ccrs From f260bde1a2fbcd6fa514ebf01d20f9180b136ae6 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Thu, 18 Dec 2025 14:02:18 -0600 Subject: [PATCH 09/11] o Fix to_raster extent calculation with simpler scalar conversion --- uxarray/core/dataarray.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/uxarray/core/dataarray.py b/uxarray/core/dataarray.py index f05fed077..76482999c 100644 --- a/uxarray/core/dataarray.py +++ b/uxarray/core/dataarray.py @@ -424,19 +424,10 @@ def _is_default_extent() -> bool: try: import cartopy.crs as ccrs - lon_min = self.uxgrid.node_lon.min(skipna=True) - lon_max = self.uxgrid.node_lon.max(skipna=True) - lat_min = self.uxgrid.node_lat.min(skipna=True) - lat_max = self.uxgrid.node_lat.max(skipna=True) - - lon_min, lon_max = ( - float(lon_min.to_numpy().item()), - float(lon_max.to_numpy().item()), - ) - lat_min, lat_max = ( - float(lat_min.to_numpy().item()), - float(lat_max.to_numpy().item()), - ) + lon_min = float(self.uxgrid.node_lon.min(skipna=True).values) + lon_max = float(self.uxgrid.node_lon.max(skipna=True).values) + lat_min = float(self.uxgrid.node_lat.min(skipna=True).values) + lat_max = float(self.uxgrid.node_lat.max(skipna=True).values) ax.set_extent( (lon_min, lon_max, lat_min, lat_max), crs=ccrs.PlateCarree(), From 92c83f08353ff80131681ebc030280a2a5389667 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Thu, 18 Dec 2025 18:14:46 -0600 Subject: [PATCH 10/11] fix extent again --- uxarray/core/dataarray.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/uxarray/core/dataarray.py b/uxarray/core/dataarray.py index 76482999c..2c52710d1 100644 --- a/uxarray/core/dataarray.py +++ b/uxarray/core/dataarray.py @@ -406,19 +406,12 @@ def to_raster( else: def _is_default_extent() -> bool: - # Default extents can be (0, 1) or projection limits while autoscale stays on. + # Default extents are indicated by xlim/ylim being (0, 1) + # when autoscale is still on (no extent has been explicitly set) if not ax.get_autoscale_on(): return False - try: - import cartopy.crs as ccrs - - extent = ax.get_extent(ccrs.PlateCarree()) - except Exception: - xlim, ylim = ax.get_xlim(), ax.get_ylim() - return np.allclose(xlim, (0.0, 1.0)) and np.allclose( - ylim, (0.0, 1.0) - ) - return np.allclose(extent, (-180.0, 180.0, -90.0, 90.0), atol=1.0) + xlim, ylim = ax.get_xlim(), ax.get_ylim() + return np.allclose(xlim, (0.0, 1.0)) and np.allclose(ylim, (0.0, 1.0)) if _is_default_extent(): try: @@ -438,8 +431,11 @@ def _is_default_extent() -> bool: "ax.set_extent(...), or ax.set_xlim(...) + ax.set_ylim(...).", stacklevel=2, ) - except Exception: - pass + except Exception as e: + warn( + f"Failed to auto-set extent from grid bounds: {e}", + stacklevel=2, + ) input_ax_attrs = _RasterAxAttrs.from_ax(ax, pixel_ratio=pixel_ratio) raster, pixel_mapping_np = _nearest_neighbor_resample( From 1926b045eaf6c8ecab513790216adb3b4e983c44 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Fri, 19 Dec 2025 14:03:36 -0600 Subject: [PATCH 11/11] o Remove broken link for link check to pass [Poster](https://www-pequan.lip6.fr/~graillat/papers/posterRNC7.pdf) --- uxarray/utils/computing.py | 1 - 1 file changed, 1 deletion(-) diff --git a/uxarray/utils/computing.py b/uxarray/utils/computing.py index 88699f3db..ca5ca5183 100644 --- a/uxarray/utils/computing.py +++ b/uxarray/utils/computing.py @@ -102,7 +102,6 @@ def dot_fma(v1, v2): ---------- S. Graillat, Ph. Langlois, and N. Louvet. "Accurate dot products with FMA." Presented at RNC 7, 2007, Nancy, France. DALI-LP2A Laboratory, University of Perpignan, France. - [Poster](https://www-pequan.lip6.fr/~graillat/papers/posterRNC7.pdf) """ if len(v1) != len(v2): raise ValueError("Input vectors must be of the same length")