Repository: xgcm/xgcm (python)

Issue:
grid.interp_like()
I propose a small addition to xgcm Axis/Grid. 

`grid.interp_like(da, da_target)` (adopting naming from xarray) would determine which axes are found in both `da` and `da_target` and if da is not on the same axis position as `da_target` use interpolation along the axis to 'move' `da` to the adjacent coordinate.

I found this would be very helpful in many contexts that I have encountered in the past. 

Happy to put in a PR soon, but wanted to discuss  first.

Pull request description:
add interp metric method
 - [x] Closes #234 
 - [x] Tests added (test_metrics.py/test_interp_metrics)
 - [x] Tests added (test_grid.py/test_interp_like)

Patch:
diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml
new file mode 100644
index 0000000..1ff499b
--- /dev/null
+++ b/.github/workflows/stale.yaml
@@ -0,0 +1,18 @@
+name: "Close stale issues"
+on:
+  schedule:
+  - cron: "0 12 * * *"
+
+jobs:
+  stale:
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/stale@v3
+      with:
+        repo-token: ${{ secrets.GITHUB_TOKEN }}
+        exempt-issue-labels: 'keepOpen,In Progress'
+        exempt-pr-labels: 'keepOpen,In Progress'
+        stale-issue-message: "This issue has been marked 'stale' due to lack of recent activity. If there is no further activity, the issue will be closed in another 30 days. Thank you for your contribution!"
+        close-issue-message: "This issue has been closed due to inactivity. If you feel this is in error, please reopen the issue or file a new issue with the relevant details."
+        stale-pr-message: "This pull request has been marked 'stale' due to lack of recent activity. If there is no further activity, the pull request will be closed in another 30 days. Thank you for your contribution!"
+        close-pr-message: "This pull request has been closed due to inactivity. If you feel this is in error, please reopen the pull request."
diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index 59b66d4..c9ce5ab 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -11,7 +11,7 @@ v0.6.0 (unreleased)
 New Features
 ~~~~~~~~~~~~
 - :py:meth:`~xgcm.grid.Grid.set_metrics` now enables adding metrics to a grid object (:pull:`336`, :issue:`199`).
-  By `Dianne Deauna <https://github.com/jdldeauna>`_.
+  By `Dianne Deauna <https://github.com/jdldeauna>`_ under the `SIParCS internship <https://www2.cisl.ucar.edu/siparcs-2021-projects#8>`_.
 
 
 v0.5.2 (2021/5/27)
diff --git a/xgcm/grid.py b/xgcm/grid.py
index cf188c4..21f2b81 100644
--- a/xgcm/grid.py
+++ b/xgcm/grid.py
@@ -37,7 +37,7 @@ class Axis:
     """
     An object that represents a group of coordinates that all lie along the same
     physical dimension but at different positions with respect to a grid cell.
-    There are four possible positions::
+    There are four possible positions:
 
          Center
          |------o-------|------o-------|------o-------|------o-------|
@@ -1261,9 +1261,11 @@ class Grid:
     def set_metrics(self, key, value):
 
         metric_axes = frozenset(_maybe_promote_str_to_list(key))
-        if not all([ma in self.axes for ma in metric_axes]):
+        axes_not_found = [ma for ma in metric_axes if ma not in self.axes]
+
+        if len(axes_not_found) > 0:
             raise KeyError(
-                f"Metric axes {metric_axes!r} not compatible with grid axes {tuple(self.axes)!r}"
+                f"Metric axes {axes_not_found!r} not compatible with grid axes {tuple(self.axes)!r}"
             )
 
         # initialize empty list
@@ -1352,13 +1354,50 @@ class Grid:
                 pass
         if metric_vars is None:
             raise KeyError(
-                "Unable to find any combinations of metrics for "
-                "array dims %r and axes %r" % (array_dims, axes)
+                f"Unable to find any combinations of metrics for array dims {array_dims!r} and axes {axes!r}"
             )
 
         # return the product of the metrics
         return functools.reduce(operator.mul, metric_vars, 1)
 
+    @docstrings.dedent
+    def interp_like(self, array, like):
+        """Compares positions between two data arrays and interpolates array to the position of like if necessary
+
+        Parameters
+        ----------
+        array : DataArray
+            DataArray to interpolate to the position of like
+        like : DataArray
+            DataArray with desired grid positions for source array
+
+        Returns
+        -------
+        array : DataArray
+            Source data array with updated positions along axes matching with target array
+        """
+
+        for axname, axis in self.axes.items():
+            try:
+                position_array, _ = axis._get_axis_coord(array)
+                position_like, _ = axis._get_axis_coord(like)
+            # This will raise a KeyError if you have multiple axes contained in self,
+            # since the for-loop will go through all axes, but the method is applied for only 1 axis at a time
+            except KeyError:
+                continue
+            if position_like != position_array:
+                array = self.interp(array, axname)
+
+        return array
+
+    def _interp_metric(self, da, axes):
+        metric_available = self._metrics.get(frozenset(axes), None)
+        if metric_available is not None:
+            # this function works with only one metric at a time
+            metric = metric_available[0]
+            metric_interp = self.interp_like(metric, da)
+        return metric_interp
+
     def __repr__(self):
         summary = ["<xgcm.Grid>"]
         for name, axis in self.axes.items():


Test patch:
diff --git a/xgcm/test/test_grid.py b/xgcm/test/test_grid.py
index 2eca278..1a6d0ce 100644
--- a/xgcm/test/test_grid.py
+++ b/xgcm/test/test_grid.py
@@ -782,3 +782,38 @@ def test_boundary_kwarg_same_as_grid_constructor_kwarg():
     actual2 = grid2.interp(ds.data_g, ("X", "Y"))
 
     xr.testing.assert_identical(actual1, actual2)
+
+
+@pytest.mark.parametrize(
+    "metric_axes,metric_name",
+    [("X", "dx_t"), ("Y", "dy_ne"), (["Y", "X"], "dy_n")],
+)
+def test_interp_like(metric_axes, metric_name):
+
+    ds, coords, _ = datasets_grid_metric("C")
+    grid = Grid(ds, coords=coords)
+    grid.set_metrics(metric_axes, metric_name)
+    metric_available = grid._metrics.get(frozenset(metric_axes), None)
+    metric_available = metric_available[0]
+    interp_metric = grid.interp_like(metric_available, ds.u)
+    test_metric = grid.interp(ds[metric_name], metric_axes)
+
+    xr.testing.assert_equal(interp_metric, test_metric)
+    xr.testing.assert_allclose(interp_metric, test_metric)
+
+
+@pytest.mark.parametrize(
+    "var_name,like_name,var_axes",
+    [
+        ("tracer", "u", "X"),
+    ],
+)
+def test_interp_like_var(var_name, like_name, var_axes):
+
+    ds, coords, metrics = datasets_grid_metric("C")
+    grid = Grid(ds, coords=coords, metrics=metrics)
+    interp_var = grid.interp_like(ds[var_name], ds[like_name])
+    test_var = grid.interp(ds[var_name], var_axes)
+
+    xr.testing.assert_equal(interp_var, test_var)
+    xr.testing.assert_allclose(interp_var, test_var)
diff --git a/xgcm/test/test_metrics.py b/xgcm/test/test_metrics.py
index d2ec52d..f148feb 100644
--- a/xgcm/test/test_metrics.py
+++ b/xgcm/test/test_metrics.py
@@ -163,3 +163,23 @@ def test_set_metric():
 
         for metric_expected, metric in zip(v, grid._metrics[k]):
             xr.testing.assert_equal(metric_expected.reset_coords(drop=True), metric)
+
+
+@pytest.mark.parametrize(
+    "metric_axes,metric_name",
+    [
+        ("X", "dx_t"),
+        ("Y", "dy_ne"),
+    ],
+)
+def test_interp_metrics(metric_axes, metric_name):
+
+    ds, coords, _ = datasets_grid_metric("C")
+    grid = Grid(ds, coords=coords)
+    grid.set_metrics(metric_axes, metric_name)
+    interp_metric = grid._interp_metric(ds.u, metric_axes)
+
+    test_metric = grid.interp(ds[metric_name], metric_axes)
+
+    xr.testing.assert_equal(interp_metric, test_metric)
+    xr.testing.assert_allclose(interp_metric, test_metric)