From fe66a3226118114b2988964fde54fea37c377282 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Sat, 10 Jan 2026 12:18:48 +0000 Subject: [PATCH 01/18] o First pass at YAC - expose remap, nnn and conservative --- .github/workflows/yac-optional.yml | 114 +++++++++++ test/test_remap_yac.py | 64 +++++++ uxarray/remap/accessor.py | 88 ++++++++- uxarray/remap/yac.py | 294 +++++++++++++++++++++++++++++ 4 files changed, 555 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/yac-optional.yml create mode 100644 test/test_remap_yac.py create mode 100644 uxarray/remap/yac.py diff --git a/.github/workflows/yac-optional.yml b/.github/workflows/yac-optional.yml new file mode 100644 index 000000000..1a88d5720 --- /dev/null +++ b/.github/workflows/yac-optional.yml @@ -0,0 +1,114 @@ +name: YAC Optional CI + +on: + pull_request: + paths: + - ".github/workflows/yac-optional.yml" + - "uxarray/remap/**" + - "test/test_remap_yac.py" + workflow_dispatch: + +jobs: + yac-optional: + name: YAC v3.9.3 (Ubuntu) + runs-on: ubuntu-latest + defaults: + run: + shell: bash -l {0} + env: + YAC_VERSION: v3.9.3 + YAXT_VERSION: v0.11.5 + steps: + - name: checkout + uses: actions/checkout@v4 + with: + token: ${{ github.token }} + + - name: conda_setup + uses: conda-incubator/setup-miniconda@v3 + with: + activate-environment: uxarray_build + channel-priority: strict + python-version: "3.11" + channels: conda-forge + environment-file: ci/environment.yml + miniforge-variant: Miniforge3 + miniforge-version: latest + + - name: Install build dependencies (apt) + run: | + sudo apt-get update + sudo apt-get install -y \ + autoconf \ + automake \ + gawk \ + gfortran \ + libfyaml-dev \ + libnetcdf-dev \ + libopenmpi-dev \ + libtool \ + make \ + openmpi-bin \ + pkg-config + + - name: Install Python build dependencies + run: | + python -m pip install --upgrade pip + python -m pip install cython mpi4py wheel + + - name: Build and install YAXT + run: | + set -euxo pipefail + YAC_PREFIX="${GITHUB_WORKSPACE}/yac_prefix" + echo "YAC_PREFIX=${YAC_PREFIX}" >> "${GITHUB_ENV}" + git clone --depth 1 --branch "${YAXT_VERSION}" https://gitlab.dkrz.de/dkrz-sw/yaxt.git + if [ ! -x yaxt/configure ]; then + (cd yaxt && ./autogen.sh) + fi + mkdir -p yaxt/build + cd yaxt/build + ../configure --prefix="${YAC_PREFIX}" CC=mpicc FC=mpif90 + make -j2 + make install + + - name: Build and install YAC + run: | + set -euxo pipefail + git clone --depth 1 --branch "${YAC_VERSION}" https://gitlab.dkrz.de/dkrz-sw/yac.git + if [ ! -x yac/configure ]; then + (cd yac && ./autogen.sh) + fi + mkdir -p yac/build + cd yac/build + ../configure \ + --prefix="${YAC_PREFIX}" \ + --with-yaxt-root="${YAC_PREFIX}" \ + --with-netcdf-root="${CONDA_PREFIX}" \ + --with-fyaml-root=/usr \ + --enable-python-bindings \ + CC=mpicc \ + FC=mpif90 + make -j2 + make install + + - name: Configure YAC runtime paths + run: | + set -euxo pipefail + PY_VER="$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" + echo "LD_LIBRARY_PATH=${YAC_PREFIX}/lib:${LD_LIBRARY_PATH:-}" >> "${GITHUB_ENV}" + echo "PYTHONPATH=${YAC_PREFIX}/lib/python${PY_VER}/site-packages:${YAC_PREFIX}/lib/python${PY_VER}/dist-packages:${PYTHONPATH:-}" >> "${GITHUB_ENV}" + + - name: Verify YAC Python bindings + run: | + python - <<'PY' + import yac + print("YAC version:", getattr(yac, "__version__", "unknown")) + PY + + - name: Install uxarray + run: | + python -m pip install . --no-deps + + - name: Run tests (uxarray with YAC) + run: | + python -m pytest test/test_remap_yac.py diff --git a/test/test_remap_yac.py b/test/test_remap_yac.py new file mode 100644 index 000000000..4316c7eb2 --- /dev/null +++ b/test/test_remap_yac.py @@ -0,0 +1,64 @@ +import numpy as np +import pytest + +import uxarray as ux + + +yac = pytest.importorskip("yac") + + +def test_yac_nnn_node_remap(gridpath, datasetpath): + grid_path = gridpath("ugrid", "geoflow-small", "grid.nc") + uxds = ux.open_dataset(grid_path, datasetpath("ugrid", "geoflow-small", "v1.nc")) + dest = ux.open_grid(grid_path) + + out = uxds["v1"].remap.nearest_neighbor( + destination_grid=dest, + remap_to="nodes", + backend="yac", + yac_method="nnn", + yac_options={"n": 1}, + ) + assert out.size > 0 + assert "n_node" in out.dims + + +def test_yac_conservative_face_remap(gridpath): + mesh_path = gridpath("mpas", "QU", "mesh.QU.1920km.151026.nc") + uxds = ux.open_dataset(mesh_path, mesh_path) + dest = ux.open_grid(mesh_path) + + out = uxds["latCell"].remap.nearest_neighbor( + destination_grid=dest, + remap_to="faces", + backend="yac", + yac_method="conservative", + yac_options={"order": 1}, + ) + assert out.size == dest.n_face + + +def test_yac_matches_uxarray_nearest_neighbor(): + verts = np.array([(0.0, 90.0), (-180.0, 0.0), (0.0, -90.0)]) + grid = ux.open_grid(verts) + da = ux.UxDataArray( + np.asarray([1.0, 2.0, 3.0]), + dims=["n_node"], + coords={"n_node": [0, 1, 2]}, + uxgrid=grid, + ) + + ux_out = da.remap.nearest_neighbor( + destination_grid=grid, + remap_to="nodes", + backend="uxarray", + ) + yac_out = da.remap.nearest_neighbor( + destination_grid=grid, + remap_to="nodes", + backend="yac", + yac_method="nnn", + yac_options={"n": 1}, + ) + assert ux_out.shape == yac_out.shape + assert (ux_out.values == yac_out.values).all() diff --git a/uxarray/remap/accessor.py b/uxarray/remap/accessor.py index ebf74ffa4..b0da01f18 100644 --- a/uxarray/remap/accessor.py +++ b/uxarray/remap/accessor.py @@ -27,17 +27,36 @@ def __repr__(self) -> str: + " • inverse_distance_weighted(destination_grid, remap_to='faces', power=2, k=8)\n" ) - def __call__(self, *args, **kwargs) -> UxDataArray | UxDataset: + def __call__( + self, + *args, + backend: str = "uxarray", + yac_method: str | None = None, + yac_options: dict | None = None, + **kwargs, + ) -> UxDataArray | UxDataset: """ Shortcut for nearest-neighbor remapping. Calling `.remap(...)` with no explicit method will invoke `nearest_neighbor(...)`. """ - return self.nearest_neighbor(*args, **kwargs) + return self.nearest_neighbor( + *args, + backend=backend, + yac_method=yac_method, + yac_options=yac_options, + **kwargs, + ) def nearest_neighbor( - self, destination_grid: Grid, remap_to: str = "faces", **kwargs + self, + destination_grid: Grid, + remap_to: str = "faces", + backend: str = "uxarray", + yac_method: str | None = "nnn", + yac_options: dict | None = None, + **kwargs, ) -> UxDataArray | UxDataset: """ Perform nearest-neighbor remapping. @@ -51,16 +70,39 @@ def nearest_neighbor( remap_to : {'nodes', 'edges', 'faces'}, default='faces' Which grid element receives the remapped values. + backend : {'uxarray', 'yac'}, default='uxarray' + Remapping backend to use. When set to 'yac', requires YAC to be + available on PYTHONPATH. + yac_method : {'nnn', 'conservative'}, optional + YAC interpolation method. Defaults to 'nnn' when backend='yac'. + yac_options : dict, optional + YAC interpolation configuration options. + Returns ------- UxDataArray or UxDataset A new object with data mapped onto `destination_grid`. """ + if backend == "yac": + from uxarray.remap.yac import _yac_remap + + yac_kwargs = yac_options or {} + return _yac_remap( + self.ux_obj, destination_grid, remap_to, yac_method, yac_kwargs + ) return _nearest_neighbor_remap(self.ux_obj, destination_grid, remap_to) def inverse_distance_weighted( - self, destination_grid: Grid, remap_to: str = "faces", power=2, k=8, **kwargs + self, + destination_grid: Grid, + remap_to: str = "faces", + power=2, + k=8, + backend: str = "uxarray", + yac_method: str | None = None, + yac_options: dict | None = None, + **kwargs, ) -> UxDataArray | UxDataset: """ Perform inverse-distance-weighted (IDW) remapping. @@ -80,18 +122,39 @@ def inverse_distance_weighted( k : int, default=8 Number of nearest source points to include in the weighted average. + backend : {'uxarray', 'yac'}, default='uxarray' + Remapping backend to use. When set to 'yac', requires YAC to be + available on PYTHONPATH. + yac_method : {'nnn', 'conservative'}, optional + YAC interpolation method. Required when backend='yac'. + yac_options : dict, optional + YAC interpolation configuration options. + Returns ------- UxDataArray or UxDataset A new object with data mapped onto `destination_grid`. """ + if backend == "yac": + from uxarray.remap.yac import _yac_remap + + yac_kwargs = yac_options or {} + return _yac_remap( + self.ux_obj, destination_grid, remap_to, yac_method, yac_kwargs + ) return _inverse_distance_weighted_remap( self.ux_obj, destination_grid, remap_to, power, k ) def bilinear( - self, destination_grid: Grid, remap_to: str = "faces", **kwargs + self, + destination_grid: Grid, + remap_to: str = "faces", + backend: str = "uxarray", + yac_method: str | None = None, + yac_options: dict | None = None, + **kwargs, ) -> UxDataArray | UxDataset: """ Perform bilinear remapping. @@ -103,10 +166,25 @@ def bilinear( remap_to : {'nodes', 'edges', 'faces'}, default='faces' Which grid element receives the remapped values. + backend : {'uxarray', 'yac'}, default='uxarray' + Remapping backend to use. When set to 'yac', requires YAC to be + available on PYTHONPATH. + yac_method : {'nnn', 'conservative'}, optional + YAC interpolation method. Required when backend='yac'. + yac_options : dict, optional + YAC interpolation configuration options. + Returns ------- UxDataArray or UxDataset A new object with data mapped onto `destination_grid`. """ + if backend == "yac": + from uxarray.remap.yac import _yac_remap + + yac_kwargs = yac_options or {} + return _yac_remap( + self.ux_obj, destination_grid, remap_to, yac_method, yac_kwargs + ) return _bilinear(self.ux_obj, destination_grid, remap_to) diff --git a/uxarray/remap/yac.py b/uxarray/remap/yac.py new file mode 100644 index 000000000..5daad01e1 --- /dev/null +++ b/uxarray/remap/yac.py @@ -0,0 +1,294 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from uuid import uuid4 + +import numpy as np + +import uxarray.core.dataarray + +from uxarray.remap.utils import ( + LABEL_TO_COORD, + _assert_dimension, + _construct_remapped_ds, + _get_remap_dims, + _to_dataset, +) + + +class YacNotAvailableError(RuntimeError): + """Raised when the YAC backend is requested but unavailable.""" + + +@dataclass +class _YacOptions: + method: str + kwargs: dict[str, Any] + + +def _import_yac(): + try: + import yac # type: ignore + except Exception as exc: # pragma: no cover - import failure handled in tests + raise YacNotAvailableError( + "YAC backend requested but 'yac' is not available. " + "Build YAC with Python bindings and ensure it is on PYTHONPATH." + ) from exc + return yac + + +def _get_lon_lat(grid, dim_kind: str) -> tuple[np.ndarray, np.ndarray]: + if dim_kind == "node": + for prefix in ("node", "vertex"): + lon = getattr(grid, f"{prefix}_lon", None) + lat = getattr(grid, f"{prefix}_lat", None) + if lon is not None and lat is not None: + return np.asarray(lon, dtype=np.float64), np.asarray(lat, dtype=np.float64) + raise AttributeError("Grid has neither node_lon/node_lat nor vertex_lon/vertex_lat") + if dim_kind == "edge": + lon = getattr(grid, "edge_lon", None) + lat = getattr(grid, "edge_lat", None) + if lon is None or lat is None: + raise AttributeError("Grid does not provide edge_lon/edge_lat") + return np.asarray(lon, dtype=np.float64), np.asarray(lat, dtype=np.float64) + if dim_kind == "face": + lon = getattr(grid, "face_lon", None) + lat = getattr(grid, "face_lat", None) + if lon is None or lat is None: + raise AttributeError("Grid does not provide face_lon/face_lat") + return np.asarray(lon, dtype=np.float64), np.asarray(lat, dtype=np.float64) + raise ValueError(f"Unsupported grid dimension kind: {dim_kind!r}") + + +def _get_connectivity(grid) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + try: + from uxarray import INT_FILL_VALUE + + fill_value = INT_FILL_VALUE + except Exception: + fill_value = -1 + connectivity = np.asarray(getattr(grid, "face_node_connectivity")).astype(np.int64) + num_vertices = np.sum(connectivity != fill_value, axis=1).astype(np.intc) + cell_to_vertex = connectivity[connectivity != fill_value].astype(np.intc) + return connectivity, num_vertices, cell_to_vertex + + +def _build_unstructured_grid(yac, grid, grid_name: str): + node_lon, node_lat = _get_lon_lat(grid, "node") + _, num_vertices, cell_to_vertex = _get_connectivity(grid) + return yac.UnstructuredGrid( + grid_name, + num_vertices, + np.deg2rad(node_lon), + np.deg2rad(node_lat), + cell_to_vertex, + use_ll_edges=False, + ) + + +def _normalize_yac_method(yac_method: str | None) -> _YacOptions: + if not yac_method: + raise ValueError( + "backend='yac' requires yac_method to be set to 'nnn' or 'conservative'." + ) + method = yac_method.lower() + if method not in {"nnn", "conservative"}: + raise ValueError(f"Unsupported YAC method: {yac_method!r}") + return _YacOptions(method=method, kwargs={}) + + +class _YacRemapper: + def __init__( + self, + src_grid, + tgt_grid, + src_dim: str, + tgt_dim: str, + yac_method: str, + yac_kwargs: dict[str, Any], + ): + yac = _import_yac() + self._yac = yac + yac.def_calendar(yac.Calendar.PROLEPTIC_GREGORIAN) + self._yac_inst = yac.YAC(default_instance=True) + self._yac_inst.def_datetime("2000-01-01T00:00:00", "2000-01-01T00:01:00") + + unique = uuid4().hex + self._comp_name = f"uxarray_yac_{unique}" + self._comp = self._yac_inst.def_comp(self._comp_name) + self._src_grid_name = f"src_{unique}" + self._tgt_grid_name = f"tgt_{unique}" + + self._src_points, self._tgt_points = self._build_points( + src_grid, tgt_grid, src_dim, tgt_dim, yac_method + ) + + self._src_field = yac.Field.create( + "src_field", + self._comp, + self._src_points, + 1, + "1", + yac.TimeUnit.SECOND, + ) + self._tgt_field = yac.Field.create( + "tgt_field", + self._comp, + self._tgt_points, + 1, + "1", + yac.TimeUnit.SECOND, + ) + + stack = yac.InterpolationStack() + if yac_method == "nnn": + reduction = yac_kwargs.get("reduction_type", yac.NNNReductionType.AVG) + if isinstance(reduction, str): + reduction = yac.NNNReductionType[reduction.upper()] + stack.add_nnn( + reduction_type=reduction, + n=yac_kwargs.get("n", 1), + max_search_distance=yac_kwargs.get("max_search_distance", 0.0), + scale=yac_kwargs.get("scale", 1.0), + ) + elif yac_method == "conservative": + normalisation = yac_kwargs.get( + "normalisation", yac.ConservNormalizationType.DESTAREA + ) + if isinstance(normalisation, str): + normalisation = yac.ConservNormalizationType[normalisation.upper()] + stack.add_conservative( + order=yac_kwargs.get("order", 1), + enforced_conserv=yac_kwargs.get("enforced_conserv", False), + partial_coverage=yac_kwargs.get("partial_coverage", False), + normalisation=normalisation, + ) + + self._yac_inst.def_couple( + self._comp_name, + self._src_grid_name, + "src_field", + self._comp_name, + self._tgt_grid_name, + "tgt_field", + "1", + yac.TimeUnit.SECOND, + yac.Reduction.TIME_NONE, + stack, + ) + self._yac_inst.enddef() + + def _build_points(self, src_grid, tgt_grid, src_dim, tgt_dim, yac_method): + yac = self._yac + if yac_method == "conservative": + if src_dim != "n_face" or tgt_dim != "n_face": + raise ValueError( + "YAC conservative remapping only supports face-centered data." + ) + self._src_grid = _build_unstructured_grid( + yac, src_grid, self._src_grid_name + ) + self._tgt_grid = _build_unstructured_grid( + yac, tgt_grid, self._tgt_grid_name + ) + src_lon, src_lat = _get_lon_lat(src_grid, "face") + tgt_lon, tgt_lat = _get_lon_lat(tgt_grid, "face") + src_points = self._src_grid.def_points( + yac.Location.CELL, np.deg2rad(src_lon), np.deg2rad(src_lat) + ) + tgt_points = self._tgt_grid.def_points( + yac.Location.CELL, np.deg2rad(tgt_lon), np.deg2rad(tgt_lat) + ) + return src_points, tgt_points + + src_kind = src_dim.replace("n_", "") + tgt_kind = tgt_dim.replace("n_", "") + src_lon, src_lat = _get_lon_lat(src_grid, src_kind) + tgt_lon, tgt_lat = _get_lon_lat(tgt_grid, tgt_kind) + self._src_grid = yac.CloudGrid( + self._src_grid_name, np.deg2rad(src_lon), np.deg2rad(src_lat) + ) + self._tgt_grid = yac.CloudGrid( + self._tgt_grid_name, np.deg2rad(tgt_lon), np.deg2rad(tgt_lat) + ) + src_points = self._src_grid.def_points( + np.deg2rad(src_lon), np.deg2rad(src_lat) + ) + tgt_points = self._tgt_grid.def_points( + np.deg2rad(tgt_lon), np.deg2rad(tgt_lat) + ) + return src_points, tgt_points + + def remap(self, values: np.ndarray) -> np.ndarray: + values = np.ascontiguousarray(values, dtype=np.float64).reshape(-1) + if values.size != self._src_field.size: + raise ValueError( + f"YAC remap expects {self._src_field.size} values, got {values.size}." + ) + self._src_field.put(values) + out, _ = self._tgt_field.get() + return np.asarray(out, dtype=np.float64).reshape(-1) + + def close(self) -> None: + self._yac_inst.cleanup() + + +def _yac_remap(source, destination_grid, remap_to: str, yac_method: str, yac_kwargs): + _assert_dimension(remap_to) + destination_dim = LABEL_TO_COORD[remap_to] + options = _normalize_yac_method(yac_method) + options.kwargs.update(yac_kwargs or {}) + ds, is_da, name = _to_dataset(source) + dims_to_remap = _get_remap_dims(ds) + remappers: dict[str, _YacRemapper] = {} + remapped_vars = {} + + for src_dim in dims_to_remap: + remappers[src_dim] = _YacRemapper( + ds.uxgrid, + destination_grid, + src_dim, + destination_dim, + options.method, + options.kwargs, + ) + + try: + for var_name, da in ds.data_vars.items(): + src_dim = next((d for d in da.dims if d in dims_to_remap), None) + if src_dim is None: + remapped_vars[var_name] = da + continue + + other_dims = [d for d in da.dims if d != src_dim] + da_t = da.transpose(*other_dims, src_dim) + src_values = np.asarray(da_t.values) + flat_src = src_values.reshape(-1, src_values.shape[-1]) + remapper = remappers[src_dim] + out_flat = np.empty( + (flat_src.shape[0], remapper._tgt_field.size), dtype=np.float64 + ) + for idx in range(flat_src.shape[0]): + out_flat[idx] = remapper.remap(flat_src[idx]) + + out_shape = src_values.shape[:-1] + (remapper._tgt_field.size,) + out_values = out_flat.reshape(out_shape) + coords = {dim: da.coords[dim] for dim in other_dims if dim in da.coords} + da_out = uxarray.core.dataarray.UxDataArray( + out_values, + dims=other_dims + [destination_dim], + coords=coords, + name=da.name, + attrs=da.attrs, + uxgrid=destination_grid, + ) + remapped_vars[var_name] = da_out + finally: + for remapper in remappers.values(): + remapper.close() + + ds_remapped = _construct_remapped_ds( + source, remapped_vars, destination_grid, destination_dim + ) + return ds_remapped[name] if is_da else ds_remapped From ca75ae4b0925c6a152b284ad5523214dcfcf1499 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Sat, 10 Jan 2026 12:44:08 +0000 Subject: [PATCH 02/18] o Fix actions --- .github/workflows/yac-optional.yml | 12 ++++++++++-- uxarray/remap/yac.py | 17 ++++++++--------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/yac-optional.yml b/.github/workflows/yac-optional.yml index 1a88d5720..5b40ef9a9 100644 --- a/.github/workflows/yac-optional.yml +++ b/.github/workflows/yac-optional.yml @@ -63,7 +63,11 @@ jobs: echo "YAC_PREFIX=${YAC_PREFIX}" >> "${GITHUB_ENV}" git clone --depth 1 --branch "${YAXT_VERSION}" https://gitlab.dkrz.de/dkrz-sw/yaxt.git if [ ! -x yaxt/configure ]; then - (cd yaxt && ./autogen.sh) + if [ -x yaxt/autogen.sh ]; then + (cd yaxt && ./autogen.sh) + else + (cd yaxt && autoreconf -i) + fi fi mkdir -p yaxt/build cd yaxt/build @@ -76,7 +80,11 @@ jobs: set -euxo pipefail git clone --depth 1 --branch "${YAC_VERSION}" https://gitlab.dkrz.de/dkrz-sw/yac.git if [ ! -x yac/configure ]; then - (cd yac && ./autogen.sh) + if [ -x yac/autogen.sh ]; then + (cd yac && ./autogen.sh) + else + (cd yac && autoreconf -i) + fi fi mkdir -p yac/build cd yac/build diff --git a/uxarray/remap/yac.py b/uxarray/remap/yac.py index 5daad01e1..7f6f6f236 100644 --- a/uxarray/remap/yac.py +++ b/uxarray/remap/yac.py @@ -7,7 +7,6 @@ import numpy as np import uxarray.core.dataarray - from uxarray.remap.utils import ( LABEL_TO_COORD, _assert_dimension, @@ -44,8 +43,12 @@ def _get_lon_lat(grid, dim_kind: str) -> tuple[np.ndarray, np.ndarray]: lon = getattr(grid, f"{prefix}_lon", None) lat = getattr(grid, f"{prefix}_lat", None) if lon is not None and lat is not None: - return np.asarray(lon, dtype=np.float64), np.asarray(lat, dtype=np.float64) - raise AttributeError("Grid has neither node_lon/node_lat nor vertex_lon/vertex_lat") + return np.asarray(lon, dtype=np.float64), np.asarray( + lat, dtype=np.float64 + ) + raise AttributeError( + "Grid has neither node_lon/node_lat nor vertex_lon/vertex_lat" + ) if dim_kind == "edge": lon = getattr(grid, "edge_lon", None) lat = getattr(grid, "edge_lat", None) @@ -212,12 +215,8 @@ def _build_points(self, src_grid, tgt_grid, src_dim, tgt_dim, yac_method): self._tgt_grid = yac.CloudGrid( self._tgt_grid_name, np.deg2rad(tgt_lon), np.deg2rad(tgt_lat) ) - src_points = self._src_grid.def_points( - np.deg2rad(src_lon), np.deg2rad(src_lat) - ) - tgt_points = self._tgt_grid.def_points( - np.deg2rad(tgt_lon), np.deg2rad(tgt_lat) - ) + src_points = self._src_grid.def_points(np.deg2rad(src_lon), np.deg2rad(src_lat)) + tgt_points = self._tgt_grid.def_points(np.deg2rad(tgt_lon), np.deg2rad(tgt_lat)) return src_points, tgt_points def remap(self, values: np.ndarray) -> np.ndarray: From 45da06f0c7e2d2114ac75726b598bbc1d6e91558 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Sat, 10 Jan 2026 14:01:01 +0000 Subject: [PATCH 03/18] o Add MPI flags for CI --- .github/workflows/yac-optional.yml | 246 +++++++++++++++-------------- 1 file changed, 124 insertions(+), 122 deletions(-) diff --git a/.github/workflows/yac-optional.yml b/.github/workflows/yac-optional.yml index 5b40ef9a9..aa7ff5869 100644 --- a/.github/workflows/yac-optional.yml +++ b/.github/workflows/yac-optional.yml @@ -1,122 +1,124 @@ -name: YAC Optional CI - -on: - pull_request: - paths: - - ".github/workflows/yac-optional.yml" - - "uxarray/remap/**" - - "test/test_remap_yac.py" - workflow_dispatch: - -jobs: - yac-optional: - name: YAC v3.9.3 (Ubuntu) - runs-on: ubuntu-latest - defaults: - run: - shell: bash -l {0} - env: - YAC_VERSION: v3.9.3 - YAXT_VERSION: v0.11.5 - steps: - - name: checkout - uses: actions/checkout@v4 - with: - token: ${{ github.token }} - - - name: conda_setup - uses: conda-incubator/setup-miniconda@v3 - with: - activate-environment: uxarray_build - channel-priority: strict - python-version: "3.11" - channels: conda-forge - environment-file: ci/environment.yml - miniforge-variant: Miniforge3 - miniforge-version: latest - - - name: Install build dependencies (apt) - run: | - sudo apt-get update - sudo apt-get install -y \ - autoconf \ - automake \ - gawk \ - gfortran \ - libfyaml-dev \ - libnetcdf-dev \ - libopenmpi-dev \ - libtool \ - make \ - openmpi-bin \ - pkg-config - - - name: Install Python build dependencies - run: | - python -m pip install --upgrade pip - python -m pip install cython mpi4py wheel - - - name: Build and install YAXT - run: | - set -euxo pipefail - YAC_PREFIX="${GITHUB_WORKSPACE}/yac_prefix" - echo "YAC_PREFIX=${YAC_PREFIX}" >> "${GITHUB_ENV}" - git clone --depth 1 --branch "${YAXT_VERSION}" https://gitlab.dkrz.de/dkrz-sw/yaxt.git - if [ ! -x yaxt/configure ]; then - if [ -x yaxt/autogen.sh ]; then - (cd yaxt && ./autogen.sh) - else - (cd yaxt && autoreconf -i) - fi - fi - mkdir -p yaxt/build - cd yaxt/build - ../configure --prefix="${YAC_PREFIX}" CC=mpicc FC=mpif90 - make -j2 - make install - - - name: Build and install YAC - run: | - set -euxo pipefail - git clone --depth 1 --branch "${YAC_VERSION}" https://gitlab.dkrz.de/dkrz-sw/yac.git - if [ ! -x yac/configure ]; then - if [ -x yac/autogen.sh ]; then - (cd yac && ./autogen.sh) - else - (cd yac && autoreconf -i) - fi - fi - mkdir -p yac/build - cd yac/build - ../configure \ - --prefix="${YAC_PREFIX}" \ - --with-yaxt-root="${YAC_PREFIX}" \ - --with-netcdf-root="${CONDA_PREFIX}" \ - --with-fyaml-root=/usr \ - --enable-python-bindings \ - CC=mpicc \ - FC=mpif90 - make -j2 - make install - - - name: Configure YAC runtime paths - run: | - set -euxo pipefail - PY_VER="$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" - echo "LD_LIBRARY_PATH=${YAC_PREFIX}/lib:${LD_LIBRARY_PATH:-}" >> "${GITHUB_ENV}" - echo "PYTHONPATH=${YAC_PREFIX}/lib/python${PY_VER}/site-packages:${YAC_PREFIX}/lib/python${PY_VER}/dist-packages:${PYTHONPATH:-}" >> "${GITHUB_ENV}" - - - name: Verify YAC Python bindings - run: | - python - <<'PY' - import yac - print("YAC version:", getattr(yac, "__version__", "unknown")) - PY - - - name: Install uxarray - run: | - python -m pip install . --no-deps - - - name: Run tests (uxarray with YAC) - run: | - python -m pytest test/test_remap_yac.py +name: YAC Optional CI + +on: + pull_request: + paths: + - ".github/workflows/yac-optional.yml" + - "uxarray/remap/**" + - "test/test_remap_yac.py" + workflow_dispatch: + +jobs: + yac-optional: + name: YAC v3.9.3 (Ubuntu) + runs-on: ubuntu-latest + defaults: + run: + shell: bash -l {0} + env: + YAC_VERSION: v3.9.3 + YAXT_VERSION: v0.11.5 + OMPI_ALLOW_RUN_AS_ROOT: 1 + OMPI_ALLOW_RUN_AS_ROOT_CONFIRM: 1 + steps: + - name: checkout + uses: actions/checkout@v4 + with: + token: ${{ github.token }} + + - name: conda_setup + uses: conda-incubator/setup-miniconda@v3 + with: + activate-environment: uxarray_build + channel-priority: strict + python-version: "3.11" + channels: conda-forge + environment-file: ci/environment.yml + miniforge-variant: Miniforge3 + miniforge-version: latest + + - name: Install build dependencies (apt) + run: | + sudo apt-get update + sudo apt-get install -y \ + autoconf \ + automake \ + gawk \ + gfortran \ + libfyaml-dev \ + libnetcdf-dev \ + libopenmpi-dev \ + libtool \ + make \ + openmpi-bin \ + pkg-config + + - name: Install Python build dependencies + run: | + python -m pip install --upgrade pip + python -m pip install cython mpi4py wheel + + - name: Build and install YAXT + run: | + set -euxo pipefail + YAC_PREFIX="${GITHUB_WORKSPACE}/yac_prefix" + echo "YAC_PREFIX=${YAC_PREFIX}" >> "${GITHUB_ENV}" + git clone --depth 1 --branch "${YAXT_VERSION}" https://gitlab.dkrz.de/dkrz-sw/yaxt.git + if [ ! -x yaxt/configure ]; then + if [ -x yaxt/autogen.sh ]; then + (cd yaxt && ./autogen.sh) + else + (cd yaxt && autoreconf -i) + fi + fi + mkdir -p yaxt/build + cd yaxt/build + ../configure --prefix="${YAC_PREFIX}" CC=mpicc FC=mpif90 + make -j2 + make install + + - name: Build and install YAC + run: | + set -euxo pipefail + git clone --depth 1 --branch "${YAC_VERSION}" https://gitlab.dkrz.de/dkrz-sw/yac.git + if [ ! -x yac/configure ]; then + if [ -x yac/autogen.sh ]; then + (cd yac && ./autogen.sh) + else + (cd yac && autoreconf -i) + fi + fi + mkdir -p yac/build + cd yac/build + ../configure \ + --prefix="${YAC_PREFIX}" \ + --with-yaxt-root="${YAC_PREFIX}" \ + --with-netcdf-root="${CONDA_PREFIX}" \ + --with-fyaml-root=/usr \ + --enable-python-bindings \ + CC=mpicc \ + FC=mpif90 + make -j2 + make install + + - name: Configure YAC runtime paths + run: | + set -euxo pipefail + PY_VER="$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" + echo "LD_LIBRARY_PATH=${YAC_PREFIX}/lib:${LD_LIBRARY_PATH:-}" >> "${GITHUB_ENV}" + echo "PYTHONPATH=${YAC_PREFIX}/lib/python${PY_VER}/site-packages:${YAC_PREFIX}/lib/python${PY_VER}/dist-packages:${PYTHONPATH:-}" >> "${GITHUB_ENV}" + + - name: Verify YAC Python bindings + run: | + python - <<'PY' + import yac + print("YAC version:", getattr(yac, "__version__", "unknown")) + PY + + - name: Install uxarray + run: | + python -m pip install . --no-deps + + - name: Run tests (uxarray with YAC) + run: | + python -m pytest test/test_remap_yac.py From 9849d147afde5507c713eead649c636369c3b07d Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Sat, 10 Jan 2026 14:06:51 +0000 Subject: [PATCH 04/18] o Explicitly set MPI --- .github/workflows/yac-optional.yml | 48 +++++++++++++++++++----------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/.github/workflows/yac-optional.yml b/.github/workflows/yac-optional.yml index aa7ff5869..112aec3f1 100644 --- a/.github/workflows/yac-optional.yml +++ b/.github/workflows/yac-optional.yml @@ -15,9 +15,14 @@ jobs: defaults: run: shell: bash -l {0} - env: - YAC_VERSION: v3.9.3 - YAXT_VERSION: v0.11.5 + env: + YAC_VERSION: v3.9.3 + YAXT_VERSION: v0.11.5 + MPIEXEC: /usr/bin/mpirun + MPIRUN: /usr/bin/mpirun + MPICC: /usr/bin/mpicc + MPIFC: /usr/bin/mpif90 + MPIF90: /usr/bin/mpif90 OMPI_ALLOW_RUN_AS_ROOT: 1 OMPI_ALLOW_RUN_AS_ROOT_CONFIRM: 1 steps: @@ -37,10 +42,10 @@ jobs: miniforge-variant: Miniforge3 miniforge-version: latest - - name: Install build dependencies (apt) - run: | - sudo apt-get update - sudo apt-get install -y \ + - name: Install build dependencies (apt) + run: | + sudo apt-get update + sudo apt-get install -y \ autoconf \ automake \ gawk \ @@ -51,7 +56,16 @@ jobs: libtool \ make \ openmpi-bin \ - pkg-config + pkg-config + + - name: Verify MPI tools + run: | + which mpirun + which mpicc + which mpif90 + mpirun --version + mpicc --version + mpif90 --version - name: Install Python build dependencies run: | @@ -73,7 +87,7 @@ jobs: fi mkdir -p yaxt/build cd yaxt/build - ../configure --prefix="${YAC_PREFIX}" CC=mpicc FC=mpif90 + ../configure --prefix="${YAC_PREFIX}" CC="${MPICC}" FC="${MPIF90}" make -j2 make install @@ -90,14 +104,14 @@ jobs: fi mkdir -p yac/build cd yac/build - ../configure \ - --prefix="${YAC_PREFIX}" \ - --with-yaxt-root="${YAC_PREFIX}" \ - --with-netcdf-root="${CONDA_PREFIX}" \ - --with-fyaml-root=/usr \ - --enable-python-bindings \ - CC=mpicc \ - FC=mpif90 + ../configure \ + --prefix="${YAC_PREFIX}" \ + --with-yaxt-root="${YAC_PREFIX}" \ + --with-netcdf-root="${CONDA_PREFIX}" \ + --with-fyaml-root=/usr \ + --enable-python-bindings \ + CC="${MPICC}" \ + FC="${MPIF90}" make -j2 make install From e88975e4044b86f17f4b9a9069bc43dab2ea2cad Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Sat, 10 Jan 2026 14:22:21 +0000 Subject: [PATCH 05/18] o Use --without-regard-for-quality flag --- .github/workflows/yac-optional.yml | 215 ++++++++++++++--------------- 1 file changed, 106 insertions(+), 109 deletions(-) diff --git a/.github/workflows/yac-optional.yml b/.github/workflows/yac-optional.yml index 112aec3f1..87e37d9c2 100644 --- a/.github/workflows/yac-optional.yml +++ b/.github/workflows/yac-optional.yml @@ -1,20 +1,20 @@ -name: YAC Optional CI - -on: - pull_request: - paths: - - ".github/workflows/yac-optional.yml" - - "uxarray/remap/**" - - "test/test_remap_yac.py" - workflow_dispatch: - -jobs: - yac-optional: - name: YAC v3.9.3 (Ubuntu) - runs-on: ubuntu-latest - defaults: - run: - shell: bash -l {0} +name: YAC Optional CI + +on: + pull_request: + paths: + - ".github/workflows/yac-optional.yml" + - "uxarray/remap/**" + - "test/test_remap_yac.py" + workflow_dispatch: + +jobs: + yac-optional: + name: YAC v3.9.3 (Ubuntu) + runs-on: ubuntu-latest + defaults: + run: + shell: bash -l {0} env: YAC_VERSION: v3.9.3 YAXT_VERSION: v0.11.5 @@ -23,41 +23,40 @@ jobs: MPICC: /usr/bin/mpicc MPIFC: /usr/bin/mpif90 MPIF90: /usr/bin/mpif90 - OMPI_ALLOW_RUN_AS_ROOT: 1 - OMPI_ALLOW_RUN_AS_ROOT_CONFIRM: 1 - steps: - - name: checkout - uses: actions/checkout@v4 - with: - token: ${{ github.token }} - - - name: conda_setup - uses: conda-incubator/setup-miniconda@v3 - with: - activate-environment: uxarray_build - channel-priority: strict - python-version: "3.11" - channels: conda-forge - environment-file: ci/environment.yml - miniforge-variant: Miniforge3 - miniforge-version: latest - + OMPI_ALLOW_RUN_AS_ROOT: 1 + OMPI_ALLOW_RUN_AS_ROOT_CONFIRM: 1 + steps: + - name: checkout + uses: actions/checkout@v4 + with: + token: ${{ github.token }} + + - name: conda_setup + uses: conda-incubator/setup-miniconda@v3 + with: + activate-environment: uxarray_build + channel-priority: strict + python-version: "3.11" + channels: conda-forge + environment-file: ci/environment.yml + miniforge-variant: Miniforge3 + miniforge-version: latest + - name: Install build dependencies (apt) run: | sudo apt-get update sudo apt-get install -y \ - autoconf \ - automake \ - gawk \ - gfortran \ - libfyaml-dev \ - libnetcdf-dev \ - libopenmpi-dev \ - libtool \ - make \ - openmpi-bin \ + autoconf \ + automake \ + gawk \ + gfortran \ + libfyaml-dev \ + libnetcdf-dev \ + libopenmpi-dev \ + libtool \ + make \ + openmpi-bin \ pkg-config - - name: Verify MPI tools run: | which mpirun @@ -66,73 +65,71 @@ jobs: mpirun --version mpicc --version mpif90 --version - - - name: Install Python build dependencies - run: | - python -m pip install --upgrade pip - python -m pip install cython mpi4py wheel - - - name: Build and install YAXT - run: | - set -euxo pipefail - YAC_PREFIX="${GITHUB_WORKSPACE}/yac_prefix" - echo "YAC_PREFIX=${YAC_PREFIX}" >> "${GITHUB_ENV}" - git clone --depth 1 --branch "${YAXT_VERSION}" https://gitlab.dkrz.de/dkrz-sw/yaxt.git - if [ ! -x yaxt/configure ]; then - if [ -x yaxt/autogen.sh ]; then - (cd yaxt && ./autogen.sh) - else - (cd yaxt && autoreconf -i) - fi - fi - mkdir -p yaxt/build - cd yaxt/build - ../configure --prefix="${YAC_PREFIX}" CC="${MPICC}" FC="${MPIF90}" - make -j2 - make install - - - name: Build and install YAC - run: | - set -euxo pipefail - git clone --depth 1 --branch "${YAC_VERSION}" https://gitlab.dkrz.de/dkrz-sw/yac.git - if [ ! -x yac/configure ]; then - if [ -x yac/autogen.sh ]; then - (cd yac && ./autogen.sh) - else - (cd yac && autoreconf -i) - fi - fi - mkdir -p yac/build - cd yac/build + - name: Install Python build dependencies + run: | + python -m pip install --upgrade pip + python -m pip install cython mpi4py wheel + - name: Build and install YAXT + run: | + set -euxo pipefail + YAC_PREFIX="${GITHUB_WORKSPACE}/yac_prefix" + echo "YAC_PREFIX=${YAC_PREFIX}" >> "${GITHUB_ENV}" + git clone --depth 1 --branch "${YAXT_VERSION}" https://gitlab.dkrz.de/dkrz-sw/yaxt.git + if [ ! -x yaxt/configure ]; then + if [ -x yaxt/autogen.sh ]; then + (cd yaxt && ./autogen.sh) + else + (cd yaxt && autoreconf -i) + fi + fi + mkdir -p yaxt/build + cd yaxt/build + ../configure \ + --prefix="${YAC_PREFIX}" \ + --without-regard-for-quality \ + CC="${MPICC}" \ + FC="${MPIF90}" + make -j2 + make install + - name: Build and install YAC + run: | + set -euxo pipefail + git clone --depth 1 --branch "${YAC_VERSION}" https://gitlab.dkrz.de/dkrz-sw/yac.git + if [ ! -x yac/configure ]; then + if [ -x yac/autogen.sh ]; then + (cd yac && ./autogen.sh) + else + (cd yac && autoreconf -i) + fi + fi + mkdir -p yac/build + cd yac/build ../configure \ --prefix="${YAC_PREFIX}" \ --with-yaxt-root="${YAC_PREFIX}" \ --with-netcdf-root="${CONDA_PREFIX}" \ --with-fyaml-root=/usr \ + --without-regard-for-quality \ --enable-python-bindings \ CC="${MPICC}" \ FC="${MPIF90}" - make -j2 - make install - - - name: Configure YAC runtime paths - run: | - set -euxo pipefail - PY_VER="$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" - echo "LD_LIBRARY_PATH=${YAC_PREFIX}/lib:${LD_LIBRARY_PATH:-}" >> "${GITHUB_ENV}" - echo "PYTHONPATH=${YAC_PREFIX}/lib/python${PY_VER}/site-packages:${YAC_PREFIX}/lib/python${PY_VER}/dist-packages:${PYTHONPATH:-}" >> "${GITHUB_ENV}" - - - name: Verify YAC Python bindings - run: | - python - <<'PY' - import yac - print("YAC version:", getattr(yac, "__version__", "unknown")) - PY - - - name: Install uxarray - run: | - python -m pip install . --no-deps - - - name: Run tests (uxarray with YAC) - run: | - python -m pytest test/test_remap_yac.py + make -j2 + make install + - name: Configure YAC runtime paths + run: | + set -euxo pipefail + PY_VER="$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" + echo "LD_LIBRARY_PATH=${YAC_PREFIX}/lib:${LD_LIBRARY_PATH:-}" >> "${GITHUB_ENV}" + echo "PYTHONPATH=${YAC_PREFIX}/lib/python${PY_VER}/site-packages:${YAC_PREFIX}/lib/python${PY_VER}/dist-packages:${PYTHONPATH:-}" >> "${GITHUB_ENV}" + - name: Verify YAC Python bindings + run: | + python - <<'PY' + import yac + print("YAC version:", getattr(yac, "__version__", "unknown")) + PY + - name: Install uxarray + run: | + python -m pip install . --no-deps + - name: Run tests (uxarray with YAC) + run: | + python -m pytest test/test_remap_yac.py From 50498d1c1a3e584f6a10c74cf91f7c45007bb507 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Thu, 26 Mar 2026 17:40:56 -0500 Subject: [PATCH 06/18] Switch YAC remap backend to yac.core --- .github/workflows/yac-optional.yml | 32 +-- test/test_remap_yac.py | 6 +- uxarray/remap/yac.py | 329 +++++++++++++---------------- 3 files changed, 170 insertions(+), 197 deletions(-) diff --git a/.github/workflows/yac-optional.yml b/.github/workflows/yac-optional.yml index 87e37d9c2..4966ce2ae 100644 --- a/.github/workflows/yac-optional.yml +++ b/.github/workflows/yac-optional.yml @@ -10,14 +10,14 @@ on: jobs: yac-optional: - name: YAC v3.9.3 (Ubuntu) + name: YAC core v3.14.0_p1 (Ubuntu) runs-on: ubuntu-latest defaults: run: shell: bash -l {0} env: - YAC_VERSION: v3.9.3 - YAXT_VERSION: v0.11.5 + YAC_VERSION: v3.14.0_p1 + YAXT_VERSION: v0.11.5.1 MPIEXEC: /usr/bin/mpirun MPIRUN: /usr/bin/mpirun MPICC: /usr/bin/mpicc @@ -50,8 +50,6 @@ jobs: automake \ gawk \ gfortran \ - libfyaml-dev \ - libnetcdf-dev \ libopenmpi-dev \ libtool \ make \ @@ -68,7 +66,7 @@ jobs: - name: Install Python build dependencies run: | python -m pip install --upgrade pip - python -m pip install cython mpi4py wheel + python -m pip install cython wheel - name: Build and install YAXT run: | set -euxo pipefail @@ -86,7 +84,6 @@ jobs: cd yaxt/build ../configure \ --prefix="${YAC_PREFIX}" \ - --without-regard-for-quality \ CC="${MPICC}" \ FC="${MPIF90}" make -j2 @@ -107,9 +104,11 @@ jobs: ../configure \ --prefix="${YAC_PREFIX}" \ --with-yaxt-root="${YAC_PREFIX}" \ - --with-netcdf-root="${CONDA_PREFIX}" \ - --with-fyaml-root=/usr \ - --without-regard-for-quality \ + --disable-mci \ + --disable-utils \ + --disable-examples \ + --disable-tools \ + --disable-netcdf \ --enable-python-bindings \ CC="${MPICC}" \ FC="${MPIF90}" @@ -121,11 +120,18 @@ jobs: PY_VER="$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" echo "LD_LIBRARY_PATH=${YAC_PREFIX}/lib:${LD_LIBRARY_PATH:-}" >> "${GITHUB_ENV}" echo "PYTHONPATH=${YAC_PREFIX}/lib/python${PY_VER}/site-packages:${YAC_PREFIX}/lib/python${PY_VER}/dist-packages:${PYTHONPATH:-}" >> "${GITHUB_ENV}" - - name: Verify YAC Python bindings + - name: Verify YAC core Python bindings run: | python - <<'PY' - import yac - print("YAC version:", getattr(yac, "__version__", "unknown")) + from pathlib import Path + import sys + candidates = [] + for entry in sys.path: + pkg = Path(entry) / "yac" + candidates.extend(pkg.glob("core*.so")) + candidates.extend(pkg.glob("core*.pyd")) + assert candidates, "yac.core extension not found on sys.path" + print("Found yac.core extension:", candidates[0]) PY - name: Install uxarray run: | diff --git a/test/test_remap_yac.py b/test/test_remap_yac.py index 4316c7eb2..26f859d02 100644 --- a/test/test_remap_yac.py +++ b/test/test_remap_yac.py @@ -2,9 +2,13 @@ import pytest import uxarray as ux +from uxarray.remap.yac import YacNotAvailableError, _import_yac -yac = pytest.importorskip("yac") +try: + _import_yac() +except YacNotAvailableError: + pytest.skip("yac.core is not available", allow_module_level=True) def test_yac_nnn_node_remap(gridpath, datasetpath): diff --git a/uxarray/remap/yac.py b/uxarray/remap/yac.py index 7f6f6f236..8bede9b8b 100644 --- a/uxarray/remap/yac.py +++ b/uxarray/remap/yac.py @@ -1,6 +1,11 @@ from __future__ import annotations from dataclasses import dataclass +import importlib +import importlib.util +from pathlib import Path +import sys +from types import ModuleType from typing import Any from uuid import uuid4 @@ -26,74 +31,60 @@ class _YacOptions: kwargs: dict[str, Any] +def _load_yac_core_from_file() -> ModuleType | None: + if "yac.core" in sys.modules: + return sys.modules["yac.core"] + + for path_entry in sys.path: + pkg_dir = Path(path_entry) / "yac" + if not pkg_dir.is_dir(): + continue + + matches = sorted(pkg_dir.glob("core*.so")) + if not matches: + matches = sorted(pkg_dir.glob("core*.pyd")) + if not matches: + continue + + pkg = sys.modules.get("yac") + if pkg is None: + pkg = ModuleType("yac") + sys.modules["yac"] = pkg + pkg.__path__ = [str(pkg_dir)] + + spec = importlib.util.spec_from_file_location("yac.core", matches[0]) + if spec is None or spec.loader is None: + continue + + module = importlib.util.module_from_spec(spec) + sys.modules["yac.core"] = module + spec.loader.exec_module(module) + setattr(pkg, "core", module) + return module + + return None + + def _import_yac(): + module = _load_yac_core_from_file() + if module is not None: + return module + try: - import yac # type: ignore - except Exception as exc: # pragma: no cover - import failure handled in tests + return importlib.import_module("yac.core") + except Exception as exc: # pragma: no cover - fallback depends on local install raise YacNotAvailableError( - "YAC backend requested but 'yac' is not available. " - "Build YAC with Python bindings and ensure it is on PYTHONPATH." + "YAC backend requested but 'yac.core' is not available. " + "Build YAC with Python bindings and ensure its site-packages and " + "shared libraries are discoverable." ) from exc - return yac - - -def _get_lon_lat(grid, dim_kind: str) -> tuple[np.ndarray, np.ndarray]: - if dim_kind == "node": - for prefix in ("node", "vertex"): - lon = getattr(grid, f"{prefix}_lon", None) - lat = getattr(grid, f"{prefix}_lat", None) - if lon is not None and lat is not None: - return np.asarray(lon, dtype=np.float64), np.asarray( - lat, dtype=np.float64 - ) - raise AttributeError( - "Grid has neither node_lon/node_lat nor vertex_lon/vertex_lat" - ) - if dim_kind == "edge": - lon = getattr(grid, "edge_lon", None) - lat = getattr(grid, "edge_lat", None) - if lon is None or lat is None: - raise AttributeError("Grid does not provide edge_lon/edge_lat") - return np.asarray(lon, dtype=np.float64), np.asarray(lat, dtype=np.float64) - if dim_kind == "face": - lon = getattr(grid, "face_lon", None) - lat = getattr(grid, "face_lat", None) - if lon is None or lat is None: - raise AttributeError("Grid does not provide face_lon/face_lat") - return np.asarray(lon, dtype=np.float64), np.asarray(lat, dtype=np.float64) - raise ValueError(f"Unsupported grid dimension kind: {dim_kind!r}") - - -def _get_connectivity(grid) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - try: - from uxarray import INT_FILL_VALUE - - fill_value = INT_FILL_VALUE - except Exception: - fill_value = -1 - connectivity = np.asarray(getattr(grid, "face_node_connectivity")).astype(np.int64) - num_vertices = np.sum(connectivity != fill_value, axis=1).astype(np.intc) - cell_to_vertex = connectivity[connectivity != fill_value].astype(np.intc) - return connectivity, num_vertices, cell_to_vertex - - -def _build_unstructured_grid(yac, grid, grid_name: str): - node_lon, node_lat = _get_lon_lat(grid, "node") - _, num_vertices, cell_to_vertex = _get_connectivity(grid) - return yac.UnstructuredGrid( - grid_name, - num_vertices, - np.deg2rad(node_lon), - np.deg2rad(node_lat), - cell_to_vertex, - use_ll_edges=False, - ) def _normalize_yac_method(yac_method: str | None) -> _YacOptions: if not yac_method: raise ValueError( - "backend='yac' requires yac_method to be set to 'nnn' or 'conservative'." + "backend='yac' requires yac_method to be set to 'nnn' or " + "'conservative'." ) method = yac_method.lower() if method not in {"nnn", "conservative"}: @@ -101,6 +92,32 @@ def _normalize_yac_method(yac_method: str | None) -> _YacOptions: return _YacOptions(method=method, kwargs={}) +def _get_location(yac_core, dim: str): + mapping = { + "n_face": yac_core.yac_location.YAC_LOC_CELL, + "n_node": yac_core.yac_location.YAC_LOC_CORNER, + "n_edge": yac_core.yac_location.YAC_LOC_EDGE, + } + try: + return mapping[dim] + except KeyError as exc: + raise ValueError(f"Unsupported remap dimension for YAC: {dim!r}") from exc + + +def _coerce_enum(enum_type, value: Any): + if not isinstance(value, str): + return value + + normalized = value.upper() + for member in enum_type: + if member.name == normalized or member.name.endswith(f"_{normalized}"): + return member + + raise ValueError( + f"Unsupported value {value!r} for enum {enum_type.__name__}." + ) + + class _YacRemapper: def __init__( self, @@ -111,56 +128,54 @@ def __init__( yac_method: str, yac_kwargs: dict[str, Any], ): - yac = _import_yac() - self._yac = yac - yac.def_calendar(yac.Calendar.PROLEPTIC_GREGORIAN) - self._yac_inst = yac.YAC(default_instance=True) - self._yac_inst.def_datetime("2000-01-01T00:00:00", "2000-01-01T00:01:00") + yac_core = _import_yac() + self._src_location = _get_location(yac_core, src_dim) + self._tgt_location = _get_location(yac_core, tgt_dim) + define_edges = "n_edge" in (src_dim, tgt_dim) unique = uuid4().hex - self._comp_name = f"uxarray_yac_{unique}" - self._comp = self._yac_inst.def_comp(self._comp_name) - self._src_grid_name = f"src_{unique}" - self._tgt_grid_name = f"tgt_{unique}" - - self._src_points, self._tgt_points = self._build_points( - src_grid, tgt_grid, src_dim, tgt_dim, yac_method + self._src_grid = yac_core.BasicGrid.from_uxgrid( + f"uxarray_src_{unique}", + src_grid, + def_edges=define_edges, + ) + self._tgt_grid = yac_core.BasicGrid.from_uxgrid( + f"uxarray_tgt_{unique}", + tgt_grid, + def_edges=define_edges, ) - self._src_field = yac.Field.create( - "src_field", - self._comp, - self._src_points, - 1, - "1", - yac.TimeUnit.SECOND, + self._src_field = yac_core.InterpField( + self._src_grid.add_coordinates(self._src_location) ) - self._tgt_field = yac.Field.create( - "tgt_field", - self._comp, - self._tgt_points, - 1, - "1", - yac.TimeUnit.SECOND, + self._tgt_field = yac_core.InterpField( + self._tgt_grid.add_coordinates(self._tgt_location) ) - stack = yac.InterpolationStack() + stack = yac_core.InterpolationStack() if yac_method == "nnn": - reduction = yac_kwargs.get("reduction_type", yac.NNNReductionType.AVG) - if isinstance(reduction, str): - reduction = yac.NNNReductionType[reduction.upper()] + weight_type = _coerce_enum( + yac_core.yac_interp_nnn_weight_type, + yac_kwargs.get("reduction_type", yac_kwargs.get("nnn_type")), + ) + if weight_type is None: + weight_type = yac_core.yac_interp_nnn_weight_type.YAC_INTERP_NNN_AVG stack.add_nnn( - reduction_type=reduction, + nnn_type=weight_type, n=yac_kwargs.get("n", 1), max_search_distance=yac_kwargs.get("max_search_distance", 0.0), scale=yac_kwargs.get("scale", 1.0), ) elif yac_method == "conservative": - normalisation = yac_kwargs.get( - "normalisation", yac.ConservNormalizationType.DESTAREA + normalisation = _coerce_enum( + yac_core.yac_interp_method_conserv_normalisation, + yac_kwargs.get("normalisation"), ) - if isinstance(normalisation, str): - normalisation = yac.ConservNormalizationType[normalisation.upper()] + if normalisation is None: + normalisation = ( + yac_core.yac_interp_method_conserv_normalisation + .YAC_INTERP_CONSERV_DESTAREA + ) stack.add_conservative( order=yac_kwargs.get("order", 1), enforced_conserv=yac_kwargs.get("enforced_conserv", False), @@ -168,70 +183,24 @@ def __init__( normalisation=normalisation, ) - self._yac_inst.def_couple( - self._comp_name, - self._src_grid_name, - "src_field", - self._comp_name, - self._tgt_grid_name, - "tgt_field", - "1", - yac.TimeUnit.SECOND, - yac.Reduction.TIME_NONE, + self._weights = yac_core.compute_weights( stack, + self._src_field, + self._tgt_field, ) - self._yac_inst.enddef() - - def _build_points(self, src_grid, tgt_grid, src_dim, tgt_dim, yac_method): - yac = self._yac - if yac_method == "conservative": - if src_dim != "n_face" or tgt_dim != "n_face": - raise ValueError( - "YAC conservative remapping only supports face-centered data." - ) - self._src_grid = _build_unstructured_grid( - yac, src_grid, self._src_grid_name - ) - self._tgt_grid = _build_unstructured_grid( - yac, tgt_grid, self._tgt_grid_name - ) - src_lon, src_lat = _get_lon_lat(src_grid, "face") - tgt_lon, tgt_lat = _get_lon_lat(tgt_grid, "face") - src_points = self._src_grid.def_points( - yac.Location.CELL, np.deg2rad(src_lon), np.deg2rad(src_lat) - ) - tgt_points = self._tgt_grid.def_points( - yac.Location.CELL, np.deg2rad(tgt_lon), np.deg2rad(tgt_lat) - ) - return src_points, tgt_points - - src_kind = src_dim.replace("n_", "") - tgt_kind = tgt_dim.replace("n_", "") - src_lon, src_lat = _get_lon_lat(src_grid, src_kind) - tgt_lon, tgt_lat = _get_lon_lat(tgt_grid, tgt_kind) - self._src_grid = yac.CloudGrid( - self._src_grid_name, np.deg2rad(src_lon), np.deg2rad(src_lat) - ) - self._tgt_grid = yac.CloudGrid( - self._tgt_grid_name, np.deg2rad(tgt_lon), np.deg2rad(tgt_lat) - ) - src_points = self._src_grid.def_points(np.deg2rad(src_lon), np.deg2rad(src_lat)) - tgt_points = self._tgt_grid.def_points(np.deg2rad(tgt_lon), np.deg2rad(tgt_lat)) - return src_points, tgt_points + self._interpolation = self._weights.get_interpolation(collection_size=1) + self._src_size = self._src_grid.get_data_size(self._src_location) + self._tgt_size = self._tgt_grid.get_data_size(self._tgt_location) def remap(self, values: np.ndarray) -> np.ndarray: - values = np.ascontiguousarray(values, dtype=np.float64).reshape(-1) - if values.size != self._src_field.size: + values = np.ascontiguousarray(values, dtype=np.float64).reshape(1, -1) + if values.shape[1] != self._src_size: raise ValueError( - f"YAC remap expects {self._src_field.size} values, got {values.size}." + f"YAC remap expects {self._src_size} values, got {values.shape[1]}." ) - self._src_field.put(values) - out, _ = self._tgt_field.get() + out = self._interpolation(values) return np.asarray(out, dtype=np.float64).reshape(-1) - def close(self) -> None: - self._yac_inst.cleanup() - def _yac_remap(source, destination_grid, remap_to: str, yac_method: str, yac_kwargs): _assert_dimension(remap_to) @@ -253,39 +222,33 @@ def _yac_remap(source, destination_grid, remap_to: str, yac_method: str, yac_kwa options.kwargs, ) - try: - for var_name, da in ds.data_vars.items(): - src_dim = next((d for d in da.dims if d in dims_to_remap), None) - if src_dim is None: - remapped_vars[var_name] = da - continue - - other_dims = [d for d in da.dims if d != src_dim] - da_t = da.transpose(*other_dims, src_dim) - src_values = np.asarray(da_t.values) - flat_src = src_values.reshape(-1, src_values.shape[-1]) - remapper = remappers[src_dim] - out_flat = np.empty( - (flat_src.shape[0], remapper._tgt_field.size), dtype=np.float64 - ) - for idx in range(flat_src.shape[0]): - out_flat[idx] = remapper.remap(flat_src[idx]) - - out_shape = src_values.shape[:-1] + (remapper._tgt_field.size,) - out_values = out_flat.reshape(out_shape) - coords = {dim: da.coords[dim] for dim in other_dims if dim in da.coords} - da_out = uxarray.core.dataarray.UxDataArray( - out_values, - dims=other_dims + [destination_dim], - coords=coords, - name=da.name, - attrs=da.attrs, - uxgrid=destination_grid, - ) - remapped_vars[var_name] = da_out - finally: - for remapper in remappers.values(): - remapper.close() + for var_name, da in ds.data_vars.items(): + src_dim = next((d for d in da.dims if d in dims_to_remap), None) + if src_dim is None: + remapped_vars[var_name] = da + continue + + other_dims = [d for d in da.dims if d != src_dim] + da_t = da.transpose(*other_dims, src_dim) + src_values = np.asarray(da_t.values) + flat_src = src_values.reshape(-1, src_values.shape[-1]) + remapper = remappers[src_dim] + out_flat = np.empty((flat_src.shape[0], remapper._tgt_size), dtype=np.float64) + for idx in range(flat_src.shape[0]): + out_flat[idx] = remapper.remap(flat_src[idx]) + + out_shape = src_values.shape[:-1] + (remapper._tgt_size,) + out_values = out_flat.reshape(out_shape) + coords = {dim: da.coords[dim] for dim in other_dims if dim in da.coords} + da_out = uxarray.core.dataarray.UxDataArray( + out_values, + dims=other_dims + [destination_dim], + coords=coords, + name=da.name, + attrs=da.attrs, + uxgrid=destination_grid, + ) + remapped_vars[var_name] = da_out ds_remapped = _construct_remapped_ds( source, remapped_vars, destination_grid, destination_dim From d0febaec70d4f4637964886338b761efdcbf2559 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Fri, 27 Mar 2026 01:51:16 -0500 Subject: [PATCH 07/18] Fix YAC and Windows CI workflows --- .github/workflows/ci.yml | 2 ++ .github/workflows/yac-optional.yml | 1 + uxarray/remap/yac.py | 16 +++++----------- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a6476c49..b14d38d96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,8 @@ jobs: # github.repository == 'UXARRAY/uxarray' name: Python (${{ matrix.python-version }}, ${{ matrix.os }}) runs-on: ${{ matrix.os }} + env: + MPLBACKEND: Agg defaults: run: shell: bash -l {0} diff --git a/.github/workflows/yac-optional.yml b/.github/workflows/yac-optional.yml index 4966ce2ae..ab0f7003b 100644 --- a/.github/workflows/yac-optional.yml +++ b/.github/workflows/yac-optional.yml @@ -84,6 +84,7 @@ jobs: cd yaxt/build ../configure \ --prefix="${YAC_PREFIX}" \ + --without-regard-for-quality \ CC="${MPICC}" \ FC="${MPIF90}" make -j2 diff --git a/uxarray/remap/yac.py b/uxarray/remap/yac.py index 8bede9b8b..eb0fe5292 100644 --- a/uxarray/remap/yac.py +++ b/uxarray/remap/yac.py @@ -1,10 +1,10 @@ from __future__ import annotations -from dataclasses import dataclass import importlib import importlib.util -from pathlib import Path import sys +from dataclasses import dataclass +from pathlib import Path from types import ModuleType from typing import Any from uuid import uuid4 @@ -83,8 +83,7 @@ def _import_yac(): def _normalize_yac_method(yac_method: str | None) -> _YacOptions: if not yac_method: raise ValueError( - "backend='yac' requires yac_method to be set to 'nnn' or " - "'conservative'." + "backend='yac' requires yac_method to be set to 'nnn' or 'conservative'." ) method = yac_method.lower() if method not in {"nnn", "conservative"}: @@ -113,9 +112,7 @@ def _coerce_enum(enum_type, value: Any): if member.name == normalized or member.name.endswith(f"_{normalized}"): return member - raise ValueError( - f"Unsupported value {value!r} for enum {enum_type.__name__}." - ) + raise ValueError(f"Unsupported value {value!r} for enum {enum_type.__name__}.") class _YacRemapper: @@ -172,10 +169,7 @@ def __init__( yac_kwargs.get("normalisation"), ) if normalisation is None: - normalisation = ( - yac_core.yac_interp_method_conserv_normalisation - .YAC_INTERP_CONSERV_DESTAREA - ) + normalisation = yac_core.yac_interp_method_conserv_normalisation.YAC_INTERP_CONSERV_DESTAREA stack.add_conservative( order=yac_kwargs.get("order", 1), enforced_conserv=yac_kwargs.get("enforced_conserv", False), From 34ac28278c3aac3be5ef168a6a6e24c510b2d32d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:06:17 +0000 Subject: [PATCH 08/18] Clarify remap docstring and add conservative face-only validation Agent-Logs-Url: https://github.com/UXARRAY/uxarray/sessions/72fb3867-9fe5-4b03-84df-804cb8a333c0 Co-authored-by: rljacob <947068+rljacob@users.noreply.github.com> --- uxarray/remap/yac.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/uxarray/remap/yac.py b/uxarray/remap/yac.py index eb0fe5292..a818ff537 100644 --- a/uxarray/remap/yac.py +++ b/uxarray/remap/yac.py @@ -187,6 +187,24 @@ def __init__( self._tgt_size = self._tgt_grid.get_data_size(self._tgt_location) def remap(self, values: np.ndarray) -> np.ndarray: + """Apply the pre-computed interpolation weights to *values*. + + The interpolation method (NNN or conservative) is determined by + *yac_method* passed to the constructor and is fixed for the lifetime of + this remapper instance. This method simply executes the weight + application; it does not select or alter the interpolation algorithm. + + Parameters + ---------- + values : np.ndarray + 1-D array of source-grid values with length equal to the number of + source points registered with YAC (``self._src_size``). + + Returns + ------- + np.ndarray + 1-D array of remapped values on the destination grid. + """ values = np.ascontiguousarray(values, dtype=np.float64).reshape(1, -1) if values.shape[1] != self._src_size: raise ValueError( @@ -203,6 +221,22 @@ def _yac_remap(source, destination_grid, remap_to: str, yac_method: str, yac_kwa options.kwargs.update(yac_kwargs or {}) ds, is_da, name = _to_dataset(source) dims_to_remap = _get_remap_dims(ds) + + if options.method == "conservative": + if destination_dim != "n_face": + raise ValueError( + "YAC conservative remapping requires the destination to be " + "face-centered (remap_to='faces'). " + f"Got remap_to={remap_to!r} which maps to dimension {destination_dim!r}." + ) + non_face_src = dims_to_remap - {"n_face"} + if non_face_src: + raise ValueError( + "YAC conservative remapping requires all source data to be " + f"face-centered (dimension 'n_face'). " + f"Found non-face source dimension(s): {non_face_src}. " + "Use yac_method='nnn' for node- or edge-centered data." + ) remappers: dict[str, _YacRemapper] = {} remapped_vars = {} From ecbde03cc774692180260d9d7d9e004be8f0bf7d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:08:48 +0000 Subject: [PATCH 09/18] fix: only pass yac_method to nearest_neighbor when it's not None in __call__ Agent-Logs-Url: https://github.com/UXARRAY/uxarray/sessions/a6202885-cbf5-4760-816d-85f25f6fcf16 Co-authored-by: rljacob <947068+rljacob@users.noreply.github.com> --- uxarray/remap/accessor.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/uxarray/remap/accessor.py b/uxarray/remap/accessor.py index b0da01f18..9186841a3 100644 --- a/uxarray/remap/accessor.py +++ b/uxarray/remap/accessor.py @@ -41,13 +41,10 @@ def __call__( Calling `.remap(...)` with no explicit method will invoke `nearest_neighbor(...)`. """ - return self.nearest_neighbor( - *args, - backend=backend, - yac_method=yac_method, - yac_options=yac_options, - **kwargs, - ) + nn_kwargs: dict = {"backend": backend, "yac_options": yac_options} + if yac_method is not None: + nn_kwargs["yac_method"] = yac_method + return self.nearest_neighbor(*args, **nn_kwargs, **kwargs) def nearest_neighbor( self, From 4446c6c52b3d590e7b44fb2e9cdf3edfdec842e3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:15:32 +0000 Subject: [PATCH 10/18] fix: validate backend parameter in RemapAccessor methods, raise ValueError for unknown values Agent-Logs-Url: https://github.com/UXARRAY/uxarray/sessions/228d9dce-524e-47fd-aadd-749b46604550 Co-authored-by: rljacob <947068+rljacob@users.noreply.github.com> --- uxarray/remap/accessor.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/uxarray/remap/accessor.py b/uxarray/remap/accessor.py index 9186841a3..806dbbc48 100644 --- a/uxarray/remap/accessor.py +++ b/uxarray/remap/accessor.py @@ -11,6 +11,15 @@ from uxarray.remap.inverse_distance_weighted import _inverse_distance_weighted_remap from uxarray.remap.nearest_neighbor import _nearest_neighbor_remap +_VALID_BACKENDS = ("uxarray", "yac") + + +def _validate_backend(backend: str) -> None: + if backend not in _VALID_BACKENDS: + raise ValueError( + f"Invalid backend '{backend}'. Expected one of {_VALID_BACKENDS}." + ) + class RemapAccessor: """Expose remapping methods on UxDataArray and UxDataset objects.""" @@ -81,6 +90,7 @@ def nearest_neighbor( A new object with data mapped onto `destination_grid`. """ + _validate_backend(backend) if backend == "yac": from uxarray.remap.yac import _yac_remap @@ -133,6 +143,7 @@ def inverse_distance_weighted( A new object with data mapped onto `destination_grid`. """ + _validate_backend(backend) if backend == "yac": from uxarray.remap.yac import _yac_remap @@ -177,6 +188,7 @@ def bilinear( A new object with data mapped onto `destination_grid`. """ + _validate_backend(backend) if backend == "yac": from uxarray.remap.yac import _yac_remap From 5fc0399dae217b480bcfbb3e6f6649ecbb1801a6 Mon Sep 17 00:00:00 2001 From: Robert Jacob Date: Sat, 28 Mar 2026 19:51:52 -0500 Subject: [PATCH 11/18] Update uxarray/remap/accessor.py Raise NotImplementedError/ValueError for backend='yac' here until a real YAC-IDW implementation exists. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- uxarray/remap/accessor.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/uxarray/remap/accessor.py b/uxarray/remap/accessor.py index 806dbbc48..8225a42fc 100644 --- a/uxarray/remap/accessor.py +++ b/uxarray/remap/accessor.py @@ -145,11 +145,12 @@ def inverse_distance_weighted( _validate_backend(backend) if backend == "yac": - from uxarray.remap.yac import _yac_remap - - yac_kwargs = yac_options or {} - return _yac_remap( - self.ux_obj, destination_grid, remap_to, yac_method, yac_kwargs + raise NotImplementedError( + "inverse_distance_weighted with backend='yac' is not implemented. " + "The YAC backend currently supports only 'nnn' and 'conservative' " + "methods and will not perform inverse-distance-weighted remapping. " + "Use backend='uxarray' for IDW, or choose a different remapping " + "method that is supported by YAC." ) return _inverse_distance_weighted_remap( self.ux_obj, destination_grid, remap_to, power, k From 7079b6e3186e14f88562978cd155e6fe89c47842 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:53:48 +0000 Subject: [PATCH 12/18] fix: raise NotImplementedError for bilinear with backend='yac' Agent-Logs-Url: https://github.com/UXARRAY/uxarray/sessions/2bbea446-87e6-475a-9f23-bff5112349c6 Co-authored-by: rljacob <947068+rljacob@users.noreply.github.com> --- uxarray/remap/accessor.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/uxarray/remap/accessor.py b/uxarray/remap/accessor.py index 8225a42fc..28d6f1e84 100644 --- a/uxarray/remap/accessor.py +++ b/uxarray/remap/accessor.py @@ -161,8 +161,6 @@ def bilinear( destination_grid: Grid, remap_to: str = "faces", backend: str = "uxarray", - yac_method: str | None = None, - yac_options: dict | None = None, **kwargs, ) -> UxDataArray | UxDataset: """ @@ -175,13 +173,9 @@ def bilinear( remap_to : {'nodes', 'edges', 'faces'}, default='faces' Which grid element receives the remapped values. - backend : {'uxarray', 'yac'}, default='uxarray' - Remapping backend to use. When set to 'yac', requires YAC to be - available on PYTHONPATH. - yac_method : {'nnn', 'conservative'}, optional - YAC interpolation method. Required when backend='yac'. - yac_options : dict, optional - YAC interpolation configuration options. + backend : {'uxarray'}, default='uxarray' + Remapping backend to use. The YAC backend does not support bilinear + remapping; use ``backend='uxarray'`` (the default). Returns ------- @@ -191,10 +185,11 @@ def bilinear( _validate_backend(backend) if backend == "yac": - from uxarray.remap.yac import _yac_remap - - yac_kwargs = yac_options or {} - return _yac_remap( - self.ux_obj, destination_grid, remap_to, yac_method, yac_kwargs + raise NotImplementedError( + "bilinear with backend='yac' is not implemented. " + "The YAC backend currently supports only 'nnn' and 'conservative' " + "methods and will not perform bilinear remapping. " + "Use backend='uxarray' for bilinear, or choose a different remapping " + "method that is supported by YAC." ) return _bilinear(self.ux_obj, destination_grid, remap_to) From 96d33b190d427466c597e2f1a56aeb534a9ec95a Mon Sep 17 00:00:00 2001 From: Robert Jacob Date: Sat, 28 Mar 2026 20:07:50 -0500 Subject: [PATCH 13/18] Fix argument in uxarray/remap/yac.py Pass the original remap_to label into _construct_remapped_ds instead of destination_dim. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- uxarray/remap/yac.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uxarray/remap/yac.py b/uxarray/remap/yac.py index a818ff537..427329a2a 100644 --- a/uxarray/remap/yac.py +++ b/uxarray/remap/yac.py @@ -279,6 +279,6 @@ def _yac_remap(source, destination_grid, remap_to: str, yac_method: str, yac_kwa remapped_vars[var_name] = da_out ds_remapped = _construct_remapped_ds( - source, remapped_vars, destination_grid, destination_dim + source, remapped_vars, destination_grid, remap_to ) return ds_remapped[name] if is_da else ds_remapped From 00b15065e829aa241f8ddfdce5bce15af9b506ca Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Sat, 28 Mar 2026 22:53:35 -0500 Subject: [PATCH 14/18] Finish YAC review follow-ups --- test/test_remap_yac.py | 155 ++++++++++++++++++++++++++++++++++++++++- uxarray/remap/yac.py | 38 +++++++--- 2 files changed, 181 insertions(+), 12 deletions(-) diff --git a/test/test_remap_yac.py b/test/test_remap_yac.py index 26f859d02..59fcce41f 100644 --- a/test/test_remap_yac.py +++ b/test/test_remap_yac.py @@ -32,7 +32,7 @@ def test_yac_conservative_face_remap(gridpath): uxds = ux.open_dataset(mesh_path, mesh_path) dest = ux.open_grid(mesh_path) - out = uxds["latCell"].remap.nearest_neighbor( + out = uxds["latCell"].remap( destination_grid=dest, remap_to="faces", backend="yac", @@ -66,3 +66,156 @@ def test_yac_matches_uxarray_nearest_neighbor(): ) assert ux_out.shape == yac_out.shape assert (ux_out.values == yac_out.values).all() + + +def test_yac_call_defaults_to_nnn(): + verts = np.array([(0.0, 90.0), (-180.0, 0.0), (0.0, -90.0)]) + grid = ux.open_grid(verts) + da = ux.UxDataArray( + np.asarray([1.0, 2.0, 3.0]), + dims=["n_node"], + coords={"n_node": [0, 1, 2]}, + uxgrid=grid, + ) + + out = da.remap( + destination_grid=grid, + remap_to="nodes", + backend="yac", + ) + + assert out.shape == da.shape + np.testing.assert_array_equal(out.values, da.values) + + +def test_yac_invalid_backend_raises(): + verts = np.array([(0.0, 90.0), (-180.0, 0.0), (0.0, -90.0)]) + grid = ux.open_grid(verts) + da = ux.UxDataArray( + np.asarray([1.0, 2.0, 3.0]), + dims=["n_node"], + coords={"n_node": [0, 1, 2]}, + uxgrid=grid, + ) + + with pytest.raises(ValueError, match="Invalid backend"): + da.remap.nearest_neighbor( + destination_grid=grid, + remap_to="nodes", + backend="bogus", + ) + + +def test_yac_idw_not_implemented(): + verts = np.array([(0.0, 90.0), (-180.0, 0.0), (0.0, -90.0)]) + grid = ux.open_grid(verts) + da = ux.UxDataArray( + np.asarray([1.0, 2.0, 3.0]), + dims=["n_node"], + coords={"n_node": [0, 1, 2]}, + uxgrid=grid, + ) + + with pytest.raises(NotImplementedError, match="inverse_distance_weighted"): + da.remap.inverse_distance_weighted( + destination_grid=grid, + remap_to="nodes", + backend="yac", + yac_method="nnn", + yac_options={"n": 1}, + ) + + +def test_yac_bilinear_not_implemented(): + verts = np.array([(0.0, 90.0), (-180.0, 0.0), (0.0, -90.0)]) + grid = ux.open_grid(verts) + da = ux.UxDataArray( + np.asarray([1.0, 2.0, 3.0]), + dims=["n_node"], + coords={"n_node": [0, 1, 2]}, + uxgrid=grid, + ) + + with pytest.raises(NotImplementedError, match="bilinear"): + da.remap.bilinear( + destination_grid=grid, + remap_to="nodes", + backend="yac", + ) + + +def test_yac_conservative_rejects_non_face_data(): + verts = np.array([(0.0, 90.0), (-180.0, 0.0), (0.0, -90.0)]) + grid = ux.open_grid(verts) + da = ux.UxDataArray( + np.asarray([1.0, 2.0, 3.0]), + dims=["n_node"], + coords={"n_node": [0, 1, 2]}, + uxgrid=grid, + ) + + with pytest.raises(ValueError, match="face-centered"): + da.remap.nearest_neighbor( + destination_grid=grid, + remap_to="nodes", + backend="yac", + yac_method="conservative", + yac_options={"order": 1}, + ) + + +def test_yac_preserves_spatial_coordinate_remap(): + verts = np.array([(0.0, 90.0), (-180.0, 0.0), (0.0, -90.0)]) + grid = ux.open_grid(verts) + da = ux.UxDataArray( + np.asarray([1.0, 2.0, 3.0]), + dims=["n_node"], + coords={ + "n_node": [0, 1, 2], + "node_lon": ( + "n_node", + np.array([0.0, -180.0, 0.0]), + {"standard_name": "longitude", "units": "degrees_east"}, + ), + "node_lat": ( + "n_node", + np.array([90.0, 0.0, -90.0]), + {"standard_name": "latitude", "units": "degrees_north"}, + ), + }, + uxgrid=grid, + ) + + out = da.remap.nearest_neighbor( + destination_grid=grid, + remap_to="nodes", + backend="yac", + yac_method="nnn", + yac_options={"n": 1}, + ) + + np.testing.assert_array_equal(out.values, da.values) + assert "node_lon" in out.coords + assert "node_lat" in out.coords + + +def test_yac_batched_remap_with_extra_dimension(): + verts = np.array([(0.0, 90.0), (-180.0, 0.0), (0.0, -90.0)]) + grid = ux.open_grid(verts) + da = ux.UxDataArray( + np.asarray([[1.0, 2.0, 3.0], [10.0, 20.0, 30.0]]), + dims=["time", "n_node"], + coords={"time": [0, 1], "n_node": [0, 1, 2]}, + uxgrid=grid, + ) + + out = da.remap.nearest_neighbor( + destination_grid=grid, + remap_to="nodes", + backend="yac", + yac_method="nnn", + yac_options={"n": 1}, + ) + + assert out.shape == da.shape + np.testing.assert_array_equal(out.values, da.values) diff --git a/uxarray/remap/yac.py b/uxarray/remap/yac.py index 427329a2a..1a6833bb7 100644 --- a/uxarray/remap/yac.py +++ b/uxarray/remap/yac.py @@ -182,11 +182,11 @@ def __init__( self._src_field, self._tgt_field, ) - self._interpolation = self._weights.get_interpolation(collection_size=1) + self._interpolations: dict[int, Any] = {} self._src_size = self._src_grid.get_data_size(self._src_location) self._tgt_size = self._tgt_grid.get_data_size(self._tgt_location) - def remap(self, values: np.ndarray) -> np.ndarray: + def apply(self, values: np.ndarray) -> np.ndarray: """Apply the pre-computed interpolation weights to *values*. The interpolation method (NNN or conservative) is determined by @@ -197,21 +197,39 @@ def remap(self, values: np.ndarray) -> np.ndarray: Parameters ---------- values : np.ndarray - 1-D array of source-grid values with length equal to the number of - source points registered with YAC (``self._src_size``). + 1-D or 2-D array of source-grid values. The trailing dimension must + equal the number of source points registered with YAC + (``self._src_size``). When 2-D, the leading dimension is treated as + the YAC collection size and is remapped in one batched call. Returns ------- np.ndarray - 1-D array of remapped values on the destination grid. + Array of remapped values on the destination grid with the same + number of leading collections as the input. """ - values = np.ascontiguousarray(values, dtype=np.float64).reshape(1, -1) + values = np.ascontiguousarray(values, dtype=np.float64) + if values.ndim == 1: + values = values.reshape(1, -1) + elif values.ndim != 2: + raise ValueError( + f"YAC remap expects a 1-D or 2-D array, got {values.ndim}-D input." + ) if values.shape[1] != self._src_size: raise ValueError( f"YAC remap expects {self._src_size} values, got {values.shape[1]}." ) - out = self._interpolation(values) - return np.asarray(out, dtype=np.float64).reshape(-1) + + collection_size = values.shape[0] + interpolation = self._interpolations.get(collection_size) + if interpolation is None: + interpolation = self._weights.get_interpolation( + collection_size=collection_size + ) + self._interpolations[collection_size] = interpolation + + out = interpolation(values) + return np.asarray(out, dtype=np.float64) def _yac_remap(source, destination_grid, remap_to: str, yac_method: str, yac_kwargs): @@ -261,9 +279,7 @@ def _yac_remap(source, destination_grid, remap_to: str, yac_method: str, yac_kwa src_values = np.asarray(da_t.values) flat_src = src_values.reshape(-1, src_values.shape[-1]) remapper = remappers[src_dim] - out_flat = np.empty((flat_src.shape[0], remapper._tgt_size), dtype=np.float64) - for idx in range(flat_src.shape[0]): - out_flat[idx] = remapper.remap(flat_src[idx]) + out_flat = remapper.apply(flat_src) out_shape = src_values.shape[:-1] + (remapper._tgt_size,) out_values = out_flat.reshape(out_shape) From 032bed68484b42f22fac67bcb046e475766df95a Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Mon, 30 Mar 2026 07:43:42 -0500 Subject: [PATCH 15/18] Add YAC conservative fallback and mask support --- test/test_remap_yac.py | 27 +++++++++++++++++++ uxarray/remap/yac.py | 61 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/test/test_remap_yac.py b/test/test_remap_yac.py index 59fcce41f..222b5201c 100644 --- a/test/test_remap_yac.py +++ b/test/test_remap_yac.py @@ -219,3 +219,30 @@ def test_yac_batched_remap_with_extra_dimension(): assert out.shape == da.shape np.testing.assert_array_equal(out.values, da.values) + + +def test_yac_batched_remap_with_fractional_mask(): + verts = np.array([(0.0, 90.0), (-180.0, 0.0), (0.0, -90.0)]) + grid = ux.open_grid(verts) + da = ux.UxDataArray( + np.asarray([[1.0, 2.0, 3.0], [10.0, 20.0, 30.0]]), + dims=["time", "n_node"], + coords={"time": [0, 1], "n_node": [0, 1, 2]}, + uxgrid=grid, + ) + frac_mask = np.ones_like(da.values, dtype=np.float64) + + out = da.remap.nearest_neighbor( + destination_grid=grid, + remap_to="nodes", + backend="yac", + yac_method="nnn", + yac_options={ + "n": 1, + "frac_mask_fallback_value": 0.0, + "frac_mask": frac_mask, + }, + ) + + assert out.shape == da.shape + np.testing.assert_array_equal(out.values, da.values) diff --git a/uxarray/remap/yac.py b/uxarray/remap/yac.py index 1a6833bb7..9cb809332 100644 --- a/uxarray/remap/yac.py +++ b/uxarray/remap/yac.py @@ -126,6 +126,7 @@ def __init__( yac_kwargs: dict[str, Any], ): yac_core = _import_yac() + self._frac_mask_fallback_value = yac_kwargs.get("frac_mask_fallback_value") self._src_location = _get_location(yac_core, src_dim) self._tgt_location = _get_location(yac_core, tgt_dim) @@ -176,6 +177,9 @@ def __init__( partial_coverage=yac_kwargs.get("partial_coverage", False), normalisation=normalisation, ) + fixed_value = yac_kwargs.get("fixed_value", 0.0) + if fixed_value is not None: + stack.add_fixed(float(fixed_value)) self._weights = yac_core.compute_weights( stack, @@ -186,7 +190,9 @@ def __init__( self._src_size = self._src_grid.get_data_size(self._src_location) self._tgt_size = self._tgt_grid.get_data_size(self._tgt_location) - def apply(self, values: np.ndarray) -> np.ndarray: + def apply( + self, values: np.ndarray, frac_mask: np.ndarray | None = None + ) -> np.ndarray: """Apply the pre-computed interpolation weights to *values*. The interpolation method (NNN or conservative) is determined by @@ -201,6 +207,9 @@ def apply(self, values: np.ndarray) -> np.ndarray: equal the number of source points registered with YAC (``self._src_size``). When 2-D, the leading dimension is treated as the YAC collection size and is remapped in one batched call. + frac_mask : np.ndarray, optional + Optional fractional source mask with the same shape as ``values``. + When provided, it is forwarded to YAC's interpolation call. Returns ------- @@ -220,18 +229,53 @@ def apply(self, values: np.ndarray) -> np.ndarray: f"YAC remap expects {self._src_size} values, got {values.shape[1]}." ) + if frac_mask is not None: + frac_mask = np.ascontiguousarray(frac_mask, dtype=np.float64) + if frac_mask.ndim == 1: + frac_mask = frac_mask.reshape(1, -1) + elif frac_mask.ndim != 2: + raise ValueError( + "YAC fractional mask expects a 1-D or 2-D array, " + f"got {frac_mask.ndim}-D input." + ) + if frac_mask.shape != values.shape: + raise ValueError( + "YAC fractional mask must match remap input shape. " + f"Got mask shape {frac_mask.shape} and value shape {values.shape}." + ) + collection_size = values.shape[0] interpolation = self._interpolations.get(collection_size) if interpolation is None: interpolation = self._weights.get_interpolation( - collection_size=collection_size + collection_size=collection_size, + frac_mask_fallback_value=self._frac_mask_fallback_value, ) self._interpolations[collection_size] = interpolation - out = interpolation(values) + out = ( + interpolation(values, frac_mask=frac_mask) + if frac_mask is not None + else interpolation(values) + ) return np.asarray(out, dtype=np.float64) +def _prepare_frac_mask(frac_mask, da_t, src_values, src_dim: str) -> np.ndarray: + if hasattr(frac_mask, "dims"): + other_dims = [d for d in da_t.dims if d != src_dim] + frac_mask_values = np.asarray(frac_mask.transpose(*other_dims, src_dim).values) + else: + frac_mask_values = np.asarray(frac_mask) + + if frac_mask_values.shape != src_values.shape: + raise ValueError( + "YAC fractional mask must match the remapped source variable shape. " + f"Got mask shape {frac_mask_values.shape} and source shape {src_values.shape}." + ) + return frac_mask_values.reshape(-1, frac_mask_values.shape[-1]) + + def _yac_remap(source, destination_grid, remap_to: str, yac_method: str, yac_kwargs): _assert_dimension(remap_to) destination_dim = LABEL_TO_COORD[remap_to] @@ -278,8 +322,17 @@ def _yac_remap(source, destination_grid, remap_to: str, yac_method: str, yac_kwa da_t = da.transpose(*other_dims, src_dim) src_values = np.asarray(da_t.values) flat_src = src_values.reshape(-1, src_values.shape[-1]) + frac_masks = yac_kwargs.get("frac_masks") + frac_mask = ( + frac_masks.get(var_name) + if isinstance(frac_masks, dict) and var_name in frac_masks + else yac_kwargs.get("frac_mask") + ) + flat_frac_mask = None + if frac_mask is not None: + flat_frac_mask = _prepare_frac_mask(frac_mask, da_t, src_values, src_dim) remapper = remappers[src_dim] - out_flat = remapper.apply(flat_src) + out_flat = remapper.apply(flat_src, frac_mask=flat_frac_mask) out_shape = src_values.shape[:-1] + (remapper._tgt_size,) out_values = out_flat.reshape(out_shape) From ecb5bf584973c4ab976690028890db744c3b705d Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Mon, 30 Mar 2026 09:04:32 -0500 Subject: [PATCH 16/18] Add YAC average bilinear support --- test/test_remap_yac.py | 24 +++++++++------------ uxarray/remap/accessor.py | 20 +++++++++-------- uxarray/remap/yac.py | 45 +++++++++++++++++++++++++++++++++++---- 3 files changed, 62 insertions(+), 27 deletions(-) diff --git a/test/test_remap_yac.py b/test/test_remap_yac.py index 222b5201c..135b8abdd 100644 --- a/test/test_remap_yac.py +++ b/test/test_remap_yac.py @@ -126,22 +126,18 @@ def test_yac_idw_not_implemented(): ) -def test_yac_bilinear_not_implemented(): - verts = np.array([(0.0, 90.0), (-180.0, 0.0), (0.0, -90.0)]) - grid = ux.open_grid(verts) - da = ux.UxDataArray( - np.asarray([1.0, 2.0, 3.0]), - dims=["n_node"], - coords={"n_node": [0, 1, 2]}, - uxgrid=grid, +def test_yac_bilinear_face_remap(gridpath): + mesh_path = gridpath("mpas", "QU", "mesh.QU.1920km.151026.nc") + uxds = ux.open_dataset(mesh_path, mesh_path) + dest = ux.open_grid(mesh_path) + + out = uxds["latCell"].remap.bilinear( + destination_grid=dest, + remap_to="faces", + backend="yac", ) - with pytest.raises(NotImplementedError, match="bilinear"): - da.remap.bilinear( - destination_grid=grid, - remap_to="nodes", - backend="yac", - ) + assert out.size == dest.n_face def test_yac_conservative_rejects_non_face_data(): diff --git a/uxarray/remap/accessor.py b/uxarray/remap/accessor.py index 28d6f1e84..d53cde9ab 100644 --- a/uxarray/remap/accessor.py +++ b/uxarray/remap/accessor.py @@ -161,6 +161,7 @@ def bilinear( destination_grid: Grid, remap_to: str = "faces", backend: str = "uxarray", + yac_options: dict | None = None, **kwargs, ) -> UxDataArray | UxDataset: """ @@ -173,9 +174,11 @@ def bilinear( remap_to : {'nodes', 'edges', 'faces'}, default='faces' Which grid element receives the remapped values. - backend : {'uxarray'}, default='uxarray' - Remapping backend to use. The YAC backend does not support bilinear - remapping; use ``backend='uxarray'`` (the default). + backend : {'uxarray', 'yac'}, default='uxarray' + Remapping backend to use. When set to 'yac', bilinear remapping is + routed through YAC's average interpolation. + yac_options : dict, optional + YAC interpolation configuration options for the average method. Returns ------- @@ -185,11 +188,10 @@ def bilinear( _validate_backend(backend) if backend == "yac": - raise NotImplementedError( - "bilinear with backend='yac' is not implemented. " - "The YAC backend currently supports only 'nnn' and 'conservative' " - "methods and will not perform bilinear remapping. " - "Use backend='uxarray' for bilinear, or choose a different remapping " - "method that is supported by YAC." + from uxarray.remap.yac import _yac_remap + + yac_kwargs = yac_options or {} + return _yac_remap( + self.ux_obj, destination_grid, remap_to, "average", yac_kwargs ) return _bilinear(self.ux_obj, destination_grid, remap_to) diff --git a/uxarray/remap/yac.py b/uxarray/remap/yac.py index 9cb809332..6da975415 100644 --- a/uxarray/remap/yac.py +++ b/uxarray/remap/yac.py @@ -83,10 +83,10 @@ def _import_yac(): def _normalize_yac_method(yac_method: str | None) -> _YacOptions: if not yac_method: raise ValueError( - "backend='yac' requires yac_method to be set to 'nnn' or 'conservative'." + "backend='yac' requires yac_method to be set to 'nnn', 'average', or 'conservative'." ) method = yac_method.lower() - if method not in {"nnn", "conservative"}: + if method not in {"nnn", "average", "conservative"}: raise ValueError(f"Unsupported YAC method: {yac_method!r}") return _YacOptions(method=method, kwargs={}) @@ -103,6 +103,28 @@ def _get_location(yac_core, dim: str): raise ValueError(f"Unsupported remap dimension for YAC: {dim!r}") from exc +def _get_lon_lat(grid, dim: str) -> tuple[np.ndarray, np.ndarray]: + attr_map = { + "n_face": ("face_lon", "face_lat"), + "n_node": ("node_lon", "node_lat"), + "n_edge": ("edge_lon", "edge_lat"), + } + try: + lon_attr, lat_attr = attr_map[dim] + except KeyError as exc: + raise ValueError(f"Unsupported remap dimension for YAC: {dim!r}") from exc + + lon = getattr(grid, lon_attr, None) + lat = getattr(grid, lat_attr, None) + if lon is None or lat is None: + raise ValueError( + f"Grid does not provide {lon_attr}/{lat_attr} required for YAC remapping." + ) + return np.deg2rad(np.asarray(lon.values, dtype=np.float64)), np.deg2rad( + np.asarray(lat.values, dtype=np.float64) + ) + + def _coerce_enum(enum_type, value: Any): if not isinstance(value, str): return value @@ -142,12 +164,14 @@ def __init__( tgt_grid, def_edges=define_edges, ) + src_lon, src_lat = _get_lon_lat(src_grid, src_dim) + tgt_lon, tgt_lat = _get_lon_lat(tgt_grid, tgt_dim) self._src_field = yac_core.InterpField( - self._src_grid.add_coordinates(self._src_location) + self._src_grid.add_coordinates(self._src_location, src_lon, src_lat) ) self._tgt_field = yac_core.InterpField( - self._tgt_grid.add_coordinates(self._tgt_location) + self._tgt_grid.add_coordinates(self._tgt_location, tgt_lon, tgt_lat) ) stack = yac_core.InterpolationStack() @@ -164,6 +188,19 @@ def __init__( max_search_distance=yac_kwargs.get("max_search_distance", 0.0), scale=yac_kwargs.get("scale", 1.0), ) + elif yac_method == "average": + reduction_type = _coerce_enum( + yac_core.yac_interp_avg_weight_type, + yac_kwargs.get("reduction_type", yac_kwargs.get("weight_type")), + ) + if reduction_type is None: + reduction_type = ( + yac_core.yac_interp_avg_weight_type.YAC_INTERP_AVG_ARITHMETIC + ) + stack.add_average( + reduction_type=reduction_type, + partial_coverage=yac_kwargs.get("partial_coverage", False), + ) elif yac_method == "conservative": normalisation = _coerce_enum( yac_core.yac_interp_method_conserv_normalisation, From caf4a5e67c428307d0e7de5bfea39f22e6c29c01 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Fri, 3 Apr 2026 11:08:35 -0500 Subject: [PATCH 17/18] Address review comments and add docs --- docs/user-guide/remapping.ipynb | 18 ++++++++++++++++-- test/test_remap_yac.py | 14 ++++++++++++++ uxarray/remap/accessor.py | 22 +++++++++++++++++++--- uxarray/remap/yac.py | 17 +++++++++++++++++ 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/docs/user-guide/remapping.ipynb b/docs/user-guide/remapping.ipynb index cd187b2e5..1a103ece6 100644 --- a/docs/user-guide/remapping.ipynb +++ b/docs/user-guide/remapping.ipynb @@ -15,7 +15,11 @@ "\n", "- **Nearest Neighbor** \n", "- **Inverse Distance Weighted**\n", - "- **Bilinear**\n" + "- **Bilinear**\n", + "\n", + "UXarray uses its native remapping backend by default. For `.remap(...)`, `.remap.nearest_neighbor(...)`, and `.remap.bilinear(...)`, you can also set `backend=\"yac\"` to route the operation through YAC when `yac.core` is installed.\n", + "\n", + "When `backend=\"yac\"`, the `yac_method` parameter selects the YAC interpolation method. Supported values are `nnn`, `average`, and `conservative`. `inverse_distance_weighted()` remains UXarray-only, and `bilinear(..., backend=\"yac\")` is a convenience wrapper for `yac_method=\"average\"`.\n" ] }, { @@ -132,6 +136,14 @@ "- **remap_to** \n", " The grid element where values should be placed, one of `faces`, `edges`, or `nodes`.\n", "\n", + "- **backend** \n", + " The remapping backend to use. The default is `\"uxarray\"`; set `backend=\"yac\"` to route the remap through YAC.\n", + "\n", + "- **yac_method** \n", + " Required only when `backend=\"yac\"`. Supported values are `nnn`, `average`, and `conservative`; `nearest_neighbor()` defaults to `nnn`.\n", + "\n", + "- **yac_options** \n", + " Optional dictionary of method-specific keyword arguments forwarded to the selected YAC interpolation routine.\n", "\n", "```{warning}\n", "Nearest-neighbor remapping is fast and simple, but it does **not** conserve integrated quantities\n", @@ -480,7 +492,9 @@ "id": "6bec26ce-67b6-4300-a310-63cbac2b289a", "metadata": {}, "source": [ - "Bilinear remapping breaks down the grid into triangles, and then finds the triangle that contains each point on the destinitation grid. It then uses the values stored at each vertex to bilinearly find a value for the point, depending on it's postion inside the triangle." + "Bilinear remapping breaks down the grid into triangles, and then finds the triangle that contains each point on the destinitation grid. It then uses the values stored at each vertex to bilinearly find a value for the point, depending on it's postion inside the triangle.\n", + "\n", + "When `backend=\"yac\"`, `remap.bilinear()` delegates to YAC's `average` method. This is the only YAC method exposed through the bilinear convenience accessor; use `.remap(..., backend=\"yac\", yac_method=...)` if you need to select another YAC method explicitly." ] }, { diff --git a/test/test_remap_yac.py b/test/test_remap_yac.py index 135b8abdd..7d71e2656 100644 --- a/test/test_remap_yac.py +++ b/test/test_remap_yac.py @@ -140,6 +140,20 @@ def test_yac_bilinear_face_remap(gridpath): assert out.size == dest.n_face +def test_yac_bilinear_rejects_non_average_method(gridpath): + mesh_path = gridpath("mpas", "QU", "mesh.QU.1920km.151026.nc") + uxds = ux.open_dataset(mesh_path, mesh_path) + dest = ux.open_grid(mesh_path) + + with pytest.raises(ValueError, match="only supports yac_method='average'"): + uxds["latCell"].remap.bilinear( + destination_grid=dest, + remap_to="faces", + backend="yac", + yac_method="conservative", + ) + + def test_yac_conservative_rejects_non_face_data(): verts = np.array([(0.0, 90.0), (-180.0, 0.0), (0.0, -90.0)]) grid = ux.open_grid(verts) diff --git a/uxarray/remap/accessor.py b/uxarray/remap/accessor.py index d53cde9ab..0ca94e8c0 100644 --- a/uxarray/remap/accessor.py +++ b/uxarray/remap/accessor.py @@ -49,6 +49,9 @@ def __call__( Calling `.remap(...)` with no explicit method will invoke `nearest_neighbor(...)`. + + When ``backend="yac"``, this generic entrypoint can also be used to + select a YAC-specific interpolation method through ``yac_method``. """ nn_kwargs: dict = {"backend": backend, "yac_options": yac_options} if yac_method is not None: @@ -147,8 +150,8 @@ def inverse_distance_weighted( if backend == "yac": raise NotImplementedError( "inverse_distance_weighted with backend='yac' is not implemented. " - "The YAC backend currently supports only 'nnn' and 'conservative' " - "methods and will not perform inverse-distance-weighted remapping. " + "UXarray currently exposes only YAC's 'nnn', 'average', and " + "'conservative' methods through the YAC backend. " "Use backend='uxarray' for IDW, or choose a different remapping " "method that is supported by YAC." ) @@ -161,6 +164,7 @@ def bilinear( destination_grid: Grid, remap_to: str = "faces", backend: str = "uxarray", + yac_method: str | None = "average", yac_options: dict | None = None, **kwargs, ) -> UxDataArray | UxDataset: @@ -177,6 +181,9 @@ def bilinear( backend : {'uxarray', 'yac'}, default='uxarray' Remapping backend to use. When set to 'yac', bilinear remapping is routed through YAC's average interpolation. + yac_method : {'average'}, optional + YAC interpolation method for the bilinear convenience wrapper. + Only ``'average'`` is supported here. yac_options : dict, optional YAC interpolation configuration options for the average method. @@ -190,8 +197,17 @@ def bilinear( if backend == "yac": from uxarray.remap.yac import _yac_remap + if yac_method not in (None, "average"): + raise ValueError( + "bilinear with backend='yac' only supports yac_method='average'. " + "Use .remap(..., backend='yac', yac_method=...) for other YAC methods." + ) yac_kwargs = yac_options or {} return _yac_remap( - self.ux_obj, destination_grid, remap_to, "average", yac_kwargs + self.ux_obj, + destination_grid, + remap_to, + yac_method or "average", + yac_kwargs, ) return _bilinear(self.ux_obj, destination_grid, remap_to) diff --git a/uxarray/remap/yac.py b/uxarray/remap/yac.py index 6da975415..4bbe3899b 100644 --- a/uxarray/remap/yac.py +++ b/uxarray/remap/yac.py @@ -138,6 +138,14 @@ def _coerce_enum(enum_type, value: Any): class _YacRemapper: + """Build and reuse YAC interpolation weights for one source dimension. + + Each instance owns the YAC source/target field registration for a single + source location type (faces, nodes, or edges) and one requested YAC method. + The resulting weights can then be applied repeatedly to batches of values + that share the same source dimension. + """ + def __init__( self, src_grid, @@ -299,6 +307,7 @@ def apply( def _prepare_frac_mask(frac_mask, da_t, src_values, src_dim: str) -> np.ndarray: + """Normalize a fractional mask to the flattened shape expected by YAC.""" if hasattr(frac_mask, "dims"): other_dims = [d for d in da_t.dims if d != src_dim] frac_mask_values = np.asarray(frac_mask.transpose(*other_dims, src_dim).values) @@ -314,6 +323,14 @@ def _prepare_frac_mask(frac_mask, da_t, src_values, src_dim: str) -> np.ndarray: def _yac_remap(source, destination_grid, remap_to: str, yac_method: str, yac_kwargs): + """Remap a UXarray object through YAC and reconstruct the UXarray result. + + This is the main integration boundary between the public UXarray remap + accessor and the lower-level ``yac.core`` bindings. It normalizes the + requested YAC method, validates method-specific constraints, batches each + remapped variable by its source dimension, and returns a remapped + ``UxDataArray`` or ``UxDataset`` with UXarray metadata preserved. + """ _assert_dimension(remap_to) destination_dim = LABEL_TO_COORD[remap_to] options = _normalize_yac_method(yac_method) From b86cbb6fd448afafbfeaf25badb510561459e3f3 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Fri, 3 Apr 2026 15:05:53 -0500 Subject: [PATCH 18/18] Reword error --- uxarray/remap/accessor.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/uxarray/remap/accessor.py b/uxarray/remap/accessor.py index 0ca94e8c0..d39f21048 100644 --- a/uxarray/remap/accessor.py +++ b/uxarray/remap/accessor.py @@ -149,11 +149,10 @@ def inverse_distance_weighted( _validate_backend(backend) if backend == "yac": raise NotImplementedError( - "inverse_distance_weighted with backend='yac' is not implemented. " - "UXarray currently exposes only YAC's 'nnn', 'average', and " - "'conservative' methods through the YAC backend. " - "Use backend='uxarray' for IDW, or choose a different remapping " - "method that is supported by YAC." + "inverse_distance_weighted with backend='yac' is not currently " + "exposed through the UXarray YAC accessor. " + "Use backend='uxarray' for IDW, or use the YAC backend through " + ".remap(..., backend='yac', yac_method=..., yac_options=...)." ) return _inverse_distance_weighted_remap( self.ux_obj, destination_grid, remap_to, power, k