Skip to content
11 changes: 11 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,15 @@ Remapping
.. seealso::

`Remapping User Guide Section <https://uxarray.readthedocs.io/en/latest/user-guide/remapping.html>`_
`Applying External Remap Weights <https://uxarray.readthedocs.io/en/latest/user-guide/remap-weights.html>`_

Helpers
~~~~~~~

.. autosummary::
:toctree: generated/

RemapWeights


UxDataArray
Expand All @@ -474,6 +483,7 @@ UxDataArray
:template: autosummary/accessor_method.rst

UxDataArray.remap
UxDataArray.remap.apply_weights
UxDataArray.remap.nearest_neighbor
UxDataArray.remap.inverse_distance_weighted
UxDataArray.remap.bilinear
Expand All @@ -487,6 +497,7 @@ UxDataset
:template: autosummary/accessor_method.rst

UxDataset.remap
UxDataset.remap.apply_weights
UxDataset.remap.nearest_neighbor
UxDataset.remap.inverse_distance_weighted
UxDataset.remap.bilinear
Expand Down
114 changes: 114 additions & 0 deletions docs/user-guide/remap-weights.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
.. currentmodule:: uxarray
Comment thread
rajeeja marked this conversation as resolved.

Remap with Weights
==================

UXarray can apply precomputed offline remapping weights produced outside of UXarray.
This is useful when weights are generated once with tools such as ESMF or
TempestRemap and then reused many times across multiple ensemble members, time
slices, or variables.

The core workflow is:

1. Generate a weight file for a specific source grid and destination grid.
2. Apply it with :meth:`UxDataArray.remap.apply_weights` or :meth:`UxDataset.remap.apply_weights`.

Basic Usage
-----------

.. code-block:: python

import uxarray as ux

src = ux.open_dataset("source_grid.nc", "source_data.nc")
dst = ux.open_grid("destination_grid.nc")

remapped_temperature = src["temperature"].remap.apply_weights(dst, "map.nc")
remapped_dataset = src.remap.apply_weights(dst, "map.nc")

Repeated calls with the same path reuse a cached sparse operator, so applying the
same file again in one Python session avoids rebuilding the matrix.

What A Weight File Represents
-----------------------------

A remap weight file represents a linear operator from one grid to another:

.. code-block:: text

target_values = W @ source_values

If the source grid has ``4800`` elements and the destination grid has ``11000``
elements, then:

- ``source_values.shape = (4800,)``
- ``W.shape = (11000, 4800)``
- ``target_values.shape = (11000,)``

So the weight file necessarily encodes both the source grid and the destination
grid. It is specific to that grid pair and to the ordering of the source and
destination degrees of freedom.

Supported File Structure
------------------------

UXarray currently supports the standard sparse offline-map structure used by
ESMF-style and TempestRemap-style map files. The essential pieces are:

- ``n_a``: source size
- ``n_b``: destination size
- ``n_s``: number of nonzero entries
- ``row``: destination indices
- ``col``: source indices
- ``S``: sparse weight values

Common aliases are also accepted:

- ``src_grid_size`` and ``dst_grid_size``
- ``src_address`` and ``dst_address``
- ``weights`` instead of ``S``

In full offline map files, these sparse arrays are typically accompanied by
source and destination metadata such as center coordinates, corner coordinates,
areas, masks, and grid-dimension metadata.

Tool Compatibility
------------------

This implementation was verified against real files from both families:

- ESMF-generated offline map files created with ``ESMF_RegridWeightGen``
- TempestRemap-generated offline map files created with ``GenerateOfflineMap``

In practice, UXarray supports the standard full offline map format used by both
tools.

Currently, this API applies externally generated sparse remap files. Generating reusable UXarray weight maps can be added as a future extension.

Current caveats:

- The source data ordering must match the source ordering encoded in the weight file.
- Not every possible file variant is guaranteed yet.
- ESMF ``weight_only`` outputs may require additional handling if they omit
source and destination size metadata.

How It Applies Data
-------------------

When remapping a :class:`UxDataArray` or :class:`UxDataset`, UXarray identifies a
single spatial dimension whose size matches the source size in the loaded
weights. That dimension is remapped to the requested destination dimension
(``faces``, ``edges``, or ``nodes``).

Non-spatial dimensions are preserved, which makes this workflow suitable for
reusing one operator across many time steps, ensemble members, or variables.

Why Use This Workflow
---------------------

This path is useful when:

- weight generation is expensive and should be done once
- remapping needs to be repeated many times
- external tools already produce trusted offline maps
- you want to stay in Python for applying the map and preserving array metadata
6 changes: 6 additions & 0 deletions docs/user-guide/remapping.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,12 @@
"It would be helpful to recall the data array contents again:"
]
},
{
"cell_type": "markdown",
"id": "d5559d1e",
"source": "## Remap with Precomputed Weights\n\nUse `.remap.apply_weights(destination_grid, weight_file)` when weights were generated externally with tools such as ESMF or TempestRemap. This path reuses a sparse offline map instead of constructing weights inside UXarray.\n\n```python\nremapped = source.remap.apply_weights(destination_grid, \"map.nc\")\n```\n\nSee [Remap with Weights](./remap-weights.rst) for file format details and examples.",
"metadata": {}
},
{
"cell_type": "code",
"execution_count": null,
Expand Down
4 changes: 4 additions & 0 deletions docs/userguide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ These user guides provide detailed explanations of the core functionality in UXa
`Remapping <user-guide/remapping.ipynb>`_
Remap (a.k.a Regrid) between unstructured grids

`Applying External Remap Weights <user-guide/remap-weights.rst>`_
Apply precomputed ESMF or TempestRemap offline map files

`Topological Aggregations <user-guide/topological-aggregations.ipynb>`_
Aggregate data across grid dimensions

Expand Down Expand Up @@ -119,6 +122,7 @@ These user guides provide additional details about specific features in UXarray.
user-guide/zonal-average.ipynb
user-guide/azimuthal-average.ipynb
user-guide/remapping.ipynb
user-guide/remap-weights.rst
user-guide/topological-aggregations.ipynb
user-guide/weighted_mean.ipynb
user-guide/vector_calculus.ipynb
Expand Down
193 changes: 193 additions & 0 deletions test/precomputed_weights_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
from pathlib import Path

import numpy as np
import numpy.testing as nt
import pytest
import uxarray as ux
import xarray as xr

from uxarray.remap import RemapWeights, clear_remap_weights_cache, load_remap_weights
from uxarray.remap.weights import _WEIGHTS_CACHE, _WEIGHTS_CACHE_MAXSIZE, _normalize_indices


def _write_sparse_map(path: Path, source_size: int, destination_size: int) -> Path:
rows = np.arange(1, destination_size + 1, dtype=np.int32)
cols = np.arange(source_size, 0, -1, dtype=np.int32)
values = np.ones(destination_size, dtype=np.float64)

ds = xr.Dataset(
data_vars={
"row": (("n_s",), rows),
"col": (("n_s",), cols),
"S": (("n_s",), values),
},
coords={"n_s": np.arange(destination_size, dtype=np.int32)},
)
ds = ds.assign_coords(
n_a=np.arange(source_size, dtype=np.int32),
n_b=np.arange(destination_size, dtype=np.int32),
)
ds.to_netcdf(path)
return path


def test_load_remap_weights_and_apply_vector(tmp_path, gridpath):
grid = ux.open_grid(gridpath("ugrid", "quad-hexagon", "grid.nc"))
weight_file = _write_sparse_map(
tmp_path / "reverse_map.nc", grid.n_face, grid.n_face
)

weights = load_remap_weights(weight_file)
result = weights.apply(np.arange(grid.n_face, dtype=np.float64))

nt.assert_equal(weights.source_size, grid.n_face)
nt.assert_equal(weights.destination_size, grid.n_face)
nt.assert_array_equal(result, np.arange(grid.n_face, dtype=np.float64)[::-1])
assert isinstance(weights, RemapWeights)


def test_apply_weights_to_uxdataarray(tmp_path, gridpath):
grid = ux.open_grid(gridpath("ugrid", "quad-hexagon", "grid.nc"))
weight_file = _write_sparse_map(
tmp_path / "reverse_map.nc", grid.n_face, grid.n_face
)

source = ux.UxDataArray(
xr.DataArray(
np.arange(grid.n_face, dtype=np.float64),
dims=["n_face"],
name="temperature",
attrs={"units": "K"},
),
uxgrid=grid,
)

remapped = source.remap.apply_weights(grid, weight_file)

nt.assert_array_equal(remapped.values, source.values[::-1])
nt.assert_equal(remapped.attrs["units"], "K")
nt.assert_equal(remapped.uxgrid, grid)


def test_apply_weights_reuses_loaded_operator(tmp_path, gridpath):
grid = ux.open_grid(gridpath("ugrid", "quad-hexagon", "grid.nc"))
weight_file = _write_sparse_map(
tmp_path / "reverse_map.nc", grid.n_face, grid.n_face
)
weights = load_remap_weights(weight_file)
cached_weights = load_remap_weights(weight_file)

source = ux.UxDataset(
xr.Dataset(
data_vars={
"a": (
("time", "n_face"),
np.arange(2 * grid.n_face).reshape(2, grid.n_face),
),
"flag": (("time",), np.array([1, 0], dtype=np.int32)),
},
coords={"time": np.array([0, 1], dtype=np.int32)},
),
uxgrid=grid,
)

remapped = source.remap.apply_weights(grid, weights)
remapped_again = source["a"].remap.apply_weights(grid, weights)

assert cached_weights is weights
nt.assert_array_equal(remapped["a"].values, source["a"].values[:, ::-1])
nt.assert_array_equal(remapped["flag"].values, source["flag"].values)
nt.assert_array_equal(remapped_again.values, source["a"].values[:, ::-1])


def test_normalize_indices_respects_start_index_attr():
# 0-based array with an explicit start_index=0 attr — must not shift.
arr = xr.DataArray(np.array([0, 1, 2], dtype=np.int32), attrs={"start_index": 0})
nt.assert_array_equal(_normalize_indices(arr, 4, "Row"), np.array([0, 1, 2]))

# 1-based array with explicit start_index=1 attr.
arr1 = xr.DataArray(np.array([1, 2, 3], dtype=np.int32), attrs={"start_index": 1})
nt.assert_array_equal(_normalize_indices(arr1, 3, "Row"), np.array([0, 1, 2]))


def test_normalize_indices_partial_zero_based_not_shifted():
# 0-based partial coverage: min=1, max < size. Previous heuristic
# would have wrongly shifted to -1; new heuristic keeps as 0-based.
arr = np.array([1, 2, 3], dtype=np.int32)
nt.assert_array_equal(_normalize_indices(arr, 10, "Row"), arr)


def test_normalize_indices_one_based_detected_by_max():
arr = np.array([1, 2, 3, 4], dtype=np.int32)
nt.assert_array_equal(
_normalize_indices(arr, 4, "Row"), np.array([0, 1, 2, 3])
)


def test_normalize_indices_out_of_bounds_raises():
with pytest.raises(ValueError, match="out of bounds"):
_normalize_indices(np.array([-1, 0, 1]), 4, "Row")


def test_apply_weights_rejects_non_spatial_source_dim(tmp_path, gridpath):
grid = ux.open_grid(gridpath("ugrid", "quad-hexagon", "grid.nc"))
weight_file = _write_sparse_map(
tmp_path / "reverse_map.nc", grid.n_face, grid.n_face
)

source = ux.UxDataArray(
xr.DataArray(
np.arange(grid.n_face, dtype=np.float64),
dims=["n_face"],
name="t",
),
uxgrid=grid,
)

with pytest.raises(ValueError, match="not a spatial dimension"):
source.remap.apply_weights(grid, weight_file, source_dim="time")


def test_apply_weights_preserves_aux_coords(tmp_path, gridpath):
grid = ux.open_grid(gridpath("ugrid", "quad-hexagon", "grid.nc"))
weight_file = _write_sparse_map(
tmp_path / "reverse_map.nc", grid.n_face, grid.n_face
)

nt_steps = 3
da = xr.DataArray(
np.arange(nt_steps * grid.n_face, dtype=np.float64).reshape(
nt_steps, grid.n_face
),
dims=("time", "n_face"),
coords={
"time": np.array([10, 20, 30], dtype=np.int64),
"time_label": ("time", np.array(["a", "b", "c"])),
},
name="t",
)
source = ux.UxDataArray(da, uxgrid=grid)
remapped = source.remap.apply_weights(grid, weight_file)
assert "time_label" in remapped.coords
nt.assert_array_equal(remapped["time_label"].values, np.array(["a", "b", "c"]))


def test_clear_remap_weights_cache(tmp_path, gridpath):
grid = ux.open_grid(gridpath("ugrid", "quad-hexagon", "grid.nc"))
weight_file = _write_sparse_map(
tmp_path / "reverse_map.nc", grid.n_face, grid.n_face
)
load_remap_weights(weight_file)
assert len(_WEIGHTS_CACHE) > 0
clear_remap_weights_cache()
assert len(_WEIGHTS_CACHE) == 0


def test_remap_weights_cache_is_lru_bounded(tmp_path, gridpath):
grid = ux.open_grid(gridpath("ugrid", "quad-hexagon", "grid.nc"))
clear_remap_weights_cache()
for i in range(_WEIGHTS_CACHE_MAXSIZE + 5):
path = tmp_path / f"map_{i}.nc"
_write_sparse_map(path, grid.n_face, grid.n_face)
load_remap_weights(path)
assert len(_WEIGHTS_CACHE) == _WEIGHTS_CACHE_MAXSIZE
6 changes: 6 additions & 0 deletions uxarray/remap/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
from .apply_weights import _apply_weights
from .inverse_distance_weighted import _inverse_distance_weighted_remap
from .nearest_neighbor import _nearest_neighbor_remap
from .weights import RemapWeights, clear_remap_weights_cache, load_remap_weights

__all__ = (
"RemapWeights",
"load_remap_weights",
"clear_remap_weights_cache",
"_apply_weights",
"_nearest_neighbor_remap",
"_inverse_distance_weighted_remap",
)
Loading
Loading