Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions src/xregrid/accessors.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,55 @@ def plot_diagnostics(
regridder = self.get_regridder(target_grid, **kwargs)
return regridder.plot_diagnostics(mode=mode, **kwargs)

def plot_weights(
self, target_grid: xr.Dataset, row_idx: int, mode: str = "static", **kwargs: Any
) -> Any:
"""
Visualize source points contributing to a specific destination point.

Parameters
----------
target_grid : xr.Dataset
The target grid dataset.
row_idx : int
The index of the destination point.
mode : str, default 'static'
The plotting mode: 'static' or 'interactive'.
**kwargs : Any
Arguments passed to Regridder.plot_weights.

Returns
-------
Any
The plot object.
"""
regridder = self.get_regridder(target_grid, **kwargs)
return regridder.plot_weights(row_idx, mode=mode, **kwargs)

def plot_comparison(
self, target_grid: xr.Dataset, mode: str = "static", **kwargs: Any
) -> Any:
"""
Unified comparison plot (Source, Target, Difference).

Parameters
----------
target_grid : xr.Dataset
The target grid dataset.
mode : str, default 'static'
The plotting mode: 'static' or 'interactive'.
**kwargs : Any
Arguments passed to Regridder.plot_comparison.

Returns
-------
Any
The plot object.
"""
regridder = self.get_regridder(target_grid, **kwargs)
da_tgt = regridder(self._obj)
return regridder.plot_comparison(self._obj, da_tgt, mode=mode, **kwargs)


@xr.register_dataset_accessor("regrid")
class RegridDatasetAccessor:
Expand Down Expand Up @@ -175,3 +224,59 @@ def plot_diagnostics(
"""
regridder = self.get_regridder(target_grid, **kwargs)
return regridder.plot_diagnostics(mode=mode, **kwargs)

def plot_weights(
self, target_grid: xr.Dataset, row_idx: int, mode: str = "static", **kwargs: Any
) -> Any:
"""
Visualize source points contributing to a specific destination point.

Parameters
----------
target_grid : xr.Dataset
The target grid dataset.
row_idx : int
The index of the destination point.
mode : str, default 'static'
The plotting mode: 'static' or 'interactive'.
**kwargs : Any
Arguments passed to Regridder.plot_weights.

Returns
-------
Any
The plot object.
"""
regridder = self.get_regridder(target_grid, **kwargs)
return regridder.plot_weights(row_idx, mode=mode, **kwargs)

def plot_comparison(
self,
target_grid: xr.Dataset,
var_name: str,
mode: str = "static",
**kwargs: Any,
) -> Any:
"""
Unified comparison plot (Source, Target, Difference) for a specific variable.

Parameters
----------
target_grid : xr.Dataset
The target grid dataset.
var_name : str
The name of the variable to compare.
mode : str, default 'static'
The plotting mode: 'static' or 'interactive'.
**kwargs : Any
Arguments passed to Regridder.plot_comparison.

Returns
-------
Any
The plot object.
"""
regridder = self.get_regridder(target_grid, **kwargs)
da_src = self._obj[var_name]
da_tgt = regridder(da_src)
return regridder.plot_comparison(da_src, da_tgt, mode=mode, **kwargs)
52 changes: 19 additions & 33 deletions src/xregrid/regridder.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import xarray as xr
from scipy.sparse import coo_matrix

from xregrid.utils import update_history, get_crs_info, is_lazy, is_dask, is_cubed
from xregrid.utils import update_history, get_crs_info, is_dask, is_cubed
from xregrid.constants import (
get_regrid_method_map,
get_extrap_method_map,
Expand Down Expand Up @@ -2034,54 +2034,40 @@ def _detect_periodicity(self, ds: xr.Dataset) -> bool:
True if the grid is detected as periodic.
"""
try:
from xregrid.utils import _find_coord
from xregrid.utils import _find_coord, _get_min_max_lazy_aware

lon = _find_coord(ds, "longitude")
if lon is not None:
# 1. Check metadata
if lon.attrs.get("boundary") == "periodic":
return True

# 2. Check eager values (dimension coordinates are eager in xarray)
lon_for_check = None
if not is_lazy(lon):
# Already in memory
lon_for_check = lon
elif lon.ndim == 1 and lon.name in lon.dims:
# Xarray dimension coordinates are usually eager even if data is lazy,
# but we check is_lazy(lon) above to be sure.
# If we are here, it means it reported as lazy.
lon_for_check = None
else:
# 2. Check eager values (prioritizing Xarray indexes)
lon_min, lon_max, is_eager = _get_min_max_lazy_aware(lon)

if not is_eager:
# Lazy 2D or non-dimension 1D coordinate.
# Aero Protocol: Avoid hidden computes.
# We emit a warning if we are forced to compute to detect periodicity.
# We only trigger a compute if we are on the driver and it's absolutely necessary.
import warnings

warnings.warn(
f"Triggering hidden compute in _detect_periodicity for lazy coordinate '{lon.name}'. "
"To avoid this, provide 'periodic' explicitly in Regridder constructor "
"or set the 'boundary' attribute to 'periodic' in your longitude coordinate."
)
try:
# Sample the first row to check extent cheaply without triggering a full compute.
if lon.ndim == 2:
lon_for_check = lon.isel({lon.dims[0]: 0}).compute()
else:
lon_for_check = lon.compute()
except Exception:
lon_for_check = None

if lon_for_check is not None:
lon_min = float(lon_for_check.min())
lon_max = float(lon_for_check.max())
extent = lon_max - lon_min
# ESMF periodic grids must have extent strictly less than 360
# because the periodicity is handled by connecting the last point to the first.
# If extent is 360, the last point is a duplicate of the first and ESMF will fail.
# Use a tighter bound (354 degrees) to avoid false positives for regional swaths.
if 354.0 <= extent < 360.0:
return True
import dask

results = dask.compute({"min": lon_min, "max": lon_max})[0]
lon_min, lon_max = results["min"], results["max"]

extent = float(lon_max) - float(lon_min)
# ESMF periodic grids must have extent strictly less than 360
# because the periodicity is handled by connecting the last point to the first.
# If extent is 360, the last point is a duplicate of the first and ESMF will fail.
# Use a tighter bound (354 degrees) to avoid false positives for regional swaths.
if 354.0 <= extent < 360.0:
return True

# 3. Last fallback: Check dimension name
if "lon" in lon.dims or "longitude" in lon.dims:
Expand Down
87 changes: 55 additions & 32 deletions src/xregrid/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1130,6 +1130,61 @@ def create_rotated_latlon_grid(
return ds


def _get_min_max_lazy_aware(
da_coord: xr.DataArray,
) -> Tuple[Union[float, Any], Union[float, Any], bool]:
"""
Helper to get min/max from a coordinate DataArray efficiently.

Aero-Optimization: Prioritizes Xarray indexes for 1D dimension coordinates
(zero compute) and uses edge/corner sampling for 2D lazy coordinates to
minimize scan size.

Parameters
----------
da_coord : xr.DataArray
The coordinate DataArray.

Returns
-------
min_val : float or dask.array.Item
The minimum value.
max_val : float or dask.array.Item
The maximum value.
is_eager : bool
True if the values were retrieved without triggering a Dask compute.
"""
# 1. Check if it's already in memory (dimension coordinates or NumPy-backed)
if not is_lazy(da_coord):
return float(da_coord.min()), float(da_coord.max()), True

# 2. Check if it's a dimension coordinate in indexes
if (
da_coord.ndim == 1
and da_coord.name in da_coord.dims
and da_coord.name in da_coord.indexes
):
idx = da_coord.indexes[da_coord.name]
return float(idx.min()), float(idx.max()), True

# 3. Corner/Edge sampling heuristic for 2D lazy coordinates to avoid full scan
if da_coord.ndim == 2:
# Curvilinear grids are typically monotonic along edges.
# Sampling edges is much faster than a full array scan.
edges = [
da_coord.isel({da_coord.dims[0]: 0}),
da_coord.isel({da_coord.dims[0]: -1}),
da_coord.isel({da_coord.dims[1]: 0}),
da_coord.isel({da_coord.dims[1]: -1}),
]
# Use a dummy dimension for concatenation
combined_edges = xr.concat(edges, dim="_pts")
return combined_edges.min(), combined_edges.max(), False

# Fallback: return Dask scalars for later batch compute
return da_coord.min(), da_coord.max(), False


def create_grid_like(
obj: Union[xr.DataArray, xr.Dataset],
res: Union[float, Tuple[float, float]],
Expand Down Expand Up @@ -1247,38 +1302,6 @@ def create_grid_like(
# Discovery logic: we need min/max. We use batch compute if lazy to minimize roundtrips.
# Aero-Optimization: Use Xarray indexes for 1D dimension coordinates to avoid hidden computes.

def _get_min_max_lazy_aware(da_coord):
"""Helper to get min/max from a coordinate DataArray efficiently."""
# 1. Check if it's already in memory (dimension coordinates or NumPy-backed)
if not is_lazy(da_coord):
return float(da_coord.min()), float(da_coord.max()), True

# 2. Check if it's a dimension coordinate in indexes
if (
da_coord.ndim == 1
and da_coord.name in da_coord.dims
and da_coord.name in da_coord.indexes
):
idx = da_coord.indexes[da_coord.name]
return float(idx.min()), float(idx.max()), True

# 3. Corner/Edge sampling heuristic for 2D lazy coordinates to avoid full scan
if da_coord.ndim == 2:
# Curvilinear grids are typically monotonic along edges.
# Sampling edges is much faster than a full array scan.
edges = [
da_coord.isel({da_coord.dims[0]: 0}),
da_coord.isel({da_coord.dims[0]: -1}),
da_coord.isel({da_coord.dims[1]: 0}),
da_coord.isel({da_coord.dims[1]: -1}),
]
# Use a dummy dimension for concatenation
combined_edges = xr.concat(edges, dim="_pts")
return combined_edges.min(), combined_edges.max(), False

# Fallback: return Dask scalars for later batch compute
return da_coord.min(), da_coord.max(), False

# 1. Try to find projected coordinates
try:
x_da = obj.cf["projection_x_coordinate"]
Expand Down
Loading
Loading