From 66fc8b60a610c0a5e5e48fccb42763c01052e8c3 Mon Sep 17 00:00:00 2001 From: Nora Loose Date: Fri, 14 Aug 2026 16:08:05 +0200 Subject: [PATCH 1/4] Fix T-point transport selection for non-lat/lon 1-D coordinates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generic T-point path detected any 1-D y coordinate as a latitude axis and selected data via .sel(y=cell_lat, method='nearest'). For grids whose y coordinate is not latitude (e.g. TOPAZ's polar- stereographic y in the range -55..55), cell_lat values (~79°N) were all snapped to the coordinate maximum, selecting the wrong row for every section cell and producing exactly-zero transports. Fix: guard the coordinate-value selection branch with a range check — only use it when the resolved cell latitudes actually fall within the y coordinate range. When they don't, fall back to integer isel() with the resolved j/i indices, which is always correct. Co-Authored-By: Claude Sonnet 4.6 --- xhycom/_transport.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/xhycom/_transport.py b/xhycom/_transport.py index d9c1eb1..7e0a61b 100644 --- a/xhycom/_transport.py +++ b/xhycom/_transport.py @@ -206,27 +206,41 @@ def transport( if op not in _OPS: raise ValueError(f"Unknown operator {op!r}. Use: {sorted(_OPS)}") - # For regular (1-D coordinate) grids use coordinate-value selection so the + # For regular (1-D lat/lon coordinate) grids use coordinate-value selection so the # result is correct even when ds is a spatial subset of the grid that was # originally passed to resolve() (e.g. HYCOM regridded to a clipped GLORYS). - if resolved.y_dim in ds.coords and ds[resolved.y_dim].ndim == 1: + # Guard: only treat the y dimension as a lat coordinate if cell_lat values + # actually fall within its range — otherwise (e.g. TOPAZ's stereographic y + # coordinate runs -55..55, not -90..90) fall back to integer isel(). + _y_is_latlon = ( + resolved.y_dim in ds.coords + and ds[resolved.y_dim].ndim == 1 + and float(ds[resolved.y_dim].min()) <= float(np.min(resolved.cell_lat)) + and float(np.max(resolved.cell_lat)) <= float(ds[resolved.y_dim].max()) + ) + if _y_is_latlon: _lat_idx = xr.DataArray(resolved.cell_lat, dims="section") _lon_idx = xr.DataArray(resolved.cell_lon, dims="section") sel = {resolved.y_dim: _lat_idx, resolved.x_dim: _lon_idx} _sel_kw: dict = {"method": "nearest"} + _use_isel = False else: j_da = xr.DataArray(resolved.j, dims="section") i_da = xr.DataArray(resolved.i, dims="section") sel = {resolved.y_dim: j_da, resolved.x_dim: i_da} _sel_kw = {} + _use_isel = True + + def _select(da: xr.DataArray) -> xr.DataArray: + return da.isel(**sel) if _use_isel else da.sel(**sel, **_sel_kw) theta = np.radians(resolved.bearing_deg) cos_t = xr.DataArray(np.cos(theta), dims="section") sin_t = xr.DataArray(np.sin(theta), dims="section") w = xr.DataArray(resolved.cell_width_km * 1e3, dims="section") - u = ds[u_var].sel(**sel, **_sel_kw) - v = ds[v_var].sel(**sel, **_sel_kw) + u = _select(ds[u_var]) + v = _select(ds[v_var]) v_normal = u * cos_t - v * sin_t # positive = rightward z_vals = ds[z_dim].values.astype(float) @@ -239,7 +253,7 @@ def transport( if constraints: cmask: xr.DataArray | None = None for cvar, (op, threshold) in constraints.items(): - val = ds[cvar].sel(**sel, **_sel_kw) + val = _select(ds[cvar]) cond: xr.DataArray = { "lt": val < threshold, "le": val <= threshold, @@ -260,14 +274,14 @@ def _tp_integrate(da: xr.DataArray) -> xr.DataArray: _tp_integrate(v_normal) * 1e-6, "volume transport", "Sv" ) if compute_heat: - t = ds[t_var].sel(**sel, **_sel_kw) + t = _select(ds[t_var]) out_vars["heat"] = _attach_attrs( _tp_integrate((t - t_ref) * v_normal) * rho0 * cp * 1e-12, "heat transport", "TW", ) if compute_salt: - s = ds[s_var].sel(**sel, **_sel_kw) + s = _select(ds[s_var]) out_vars["salt"] = _attach_attrs( _tp_integrate(s * v_normal) * rho0 / 1000.0, "salt transport", "kg s-1" ) From 3ed2a7b8496ffc5d8643d6de4b6c657a828b7234 Mon Sep 17 00:00:00 2001 From: Nora Loose Date: Wed, 26 Aug 2026 09:23:03 +0200 Subject: [PATCH 2/4] Regrid to stereographic grids --- tests/test_regrid.py | 99 ++++++++++++++++++++++++++++++++++++++++++++ xhycom/_regrid.py | 79 ++++++++++++++++++++++++++--------- 2 files changed, 158 insertions(+), 20 deletions(-) diff --git a/tests/test_regrid.py b/tests/test_regrid.py index 8215cd6..4a8a7a4 100644 --- a/tests/test_regrid.py +++ b/tests/test_regrid.py @@ -646,6 +646,105 @@ def test_apply_target_mask_surface_only_derived(): ) +def _curvilinear_target(ny: int = 8, nx: int = 8): + """A small synthetic curvilinear (2-D lon/lat) target grid. + + Mimics the structure of a TOPAZ5 stereographic output: dims are ``y`` and + ``x`` (1-D projected coordinates), and ``longitude`` / ``latitude`` are + 2-D non-index coordinates carrying the geographic position of each cell. + """ + x1d = np.linspace(2.0, 8.0, nx) + y1d = np.linspace(42.0, 48.0, ny) + lon2d, lat2d = np.meshgrid(x1d, y1d) + depth = np.array([5.0, 50.0, 200.0]) + return xr.Dataset( + {"thetao": (("depth", "y", "x"), np.ones((len(depth), ny, nx)))}, + coords={ + "y": y1d, + "x": x1d, + "latitude": (("y", "x"), lat2d), + "longitude": (("y", "x"), lon2d), + "depth": depth, + }, + ) + + +@pytest.fixture +def tp5_stereo(tmp_path): + """Small stereographic fixture from tests/data/tp5_stereo_subset.nc.""" + import os + + path = os.path.join(os.path.dirname(__file__), "data", "tp5_stereo_subset.nc") + if not os.path.exists(path): + pytest.skip( + "tp5_stereo_subset.nc not found — regenerate with _subset_tp5_stereo.py" + ) + return xr.open_dataset(path) + + +# --------------------------------------------------------------------------- +# Curvilinear target tests +# --------------------------------------------------------------------------- +def test_subset_target_skips_curvilinear(): + """_subset_target returns the target unchanged for a 2-D curvilinear grid.""" + from xhycom._regrid import _subset_target + + ds = _curvilinear_ds() # source lon 0..10, lat 40..50 + tgt = _curvilinear_target() + result = _subset_target(tgt, ds) + assert result is tgt + + +def test_regrid_horizontal_curvilinear_target_recovers_field(): + """Bilinear regrid to a curvilinear (2-D lon/lat) target recovers a constant field.""" + pytest.importorskip("xesmf") + ds = _curvilinear_ds() # source lon 0..10, lat 40..50, temp = lat + tgt = _curvilinear_target() # target lon 2..8, lat 42..48 — fully inside source + out = xhycom.regrid_horizontal(ds, target=tgt, method="bilinear") + # Dims must be y/x (from the target), not lat/lon. + assert "y" in out.dims and "x" in out.dims + assert "lat" not in out.dims and "lon" not in out.dims + # Geographic coords must be attached with the target's names. + assert "latitude" in out.coords and "longitude" in out.coords + assert out["latitude"].dims == ("y", "x") + # temp == lat everywhere; bilinear must recover latitude at interior points. + np.testing.assert_allclose( + out["temp"].isel(k=0).values, + out["latitude"].values, + atol=5e-3, + ) + + +def test_regrid_horizontal_curvilinear_target_yx_coords_attached(): + """y/x index coordinates from the target are preserved on the output.""" + pytest.importorskip("xesmf") + ds = _curvilinear_ds() + tgt = _curvilinear_target() + out = xhycom.regrid_horizontal(ds, target=tgt, method="bilinear") + assert "y" in out.coords and "x" in out.coords + np.testing.assert_array_equal(out["y"].values, tgt["y"].values) + np.testing.assert_array_equal(out["x"].values, tgt["x"].values) + + +def test_regrid_horizontal_curvilinear_conservative_warns(): + """Requesting conservative for a curvilinear target warns and falls back to bilinear.""" + pytest.importorskip("xesmf") + ds = _curvilinear_ds() + tgt = _curvilinear_target() + with pytest.warns(UserWarning, match="curvilinear"): + out = xhycom.regrid_horizontal(ds, target=tgt, method="conservative") + assert "y" in out.dims # bilinear fallback produced output + + +def test_regrid_horizontal_tp5_stereo_fixture(tp5_stereo): + """Regridding to the bundled TP5 stereographic fixture does not raise.""" + pytest.importorskip("xesmf") + ds = _curvilinear_ds() + out = xhycom.regrid_horizontal(ds, target=tp5_stereo, method="bilinear") + assert "y" in out.dims and "x" in out.dims + assert "latitude" in out.coords and "longitude" in out.coords + + def test_regrid_invalid_order(): ds = _curvilinear_ds() with pytest.raises(ValueError, match="order"): diff --git a/xhycom/_regrid.py b/xhycom/_regrid.py index 497bb7e..7f3863b 100644 --- a/xhycom/_regrid.py +++ b/xhycom/_regrid.py @@ -30,6 +30,7 @@ import hashlib import json import os +import warnings import numpy as np import xarray as xr @@ -77,7 +78,7 @@ def _load_grid(grid: xr.Dataset | str | None) -> xr.Dataset | None: def _target_lonlat(tgt: xr.Dataset) -> tuple[np.ndarray, np.ndarray]: - """1-D target longitudes / latitudes from a grid Dataset.""" + """Target longitudes / latitudes from a grid Dataset (1-D or 2-D).""" lon = tgt["longitude"] if "longitude" in tgt.variables else tgt["lon"] lat = tgt["latitude"] if "latitude" in tgt.variables else tgt["lat"] return np.asarray(lon.values), np.asarray(lat.values) @@ -119,6 +120,10 @@ def _subset_target(tgt: xr.Dataset, ds: xr.Dataset, pad: float = 1.0) -> xr.Data longitude box rather than risk dropping covered cells. """ lon_name, lat_name = _lonlat_names(tgt) + # Curvilinear targets (2-D lon/lat) are already regional; subsetting + # along 1-D dims using a 2-D boolean mask is ill-defined, so skip it. + if tgt[lon_name].ndim > 1: + return tgt tlon = np.asarray(tgt[lon_name].values) tlat = np.asarray(tgt[lat_name].values) slat = np.asarray(ds["lat"].values) @@ -605,21 +610,39 @@ def regrid_horizontal( lon = np.asarray(lon) lat = np.asarray(lat) - target_ds = xr.Dataset({"lat": (["lat"], lat), "lon": (["lon"], lon)}) - - conservative = method.startswith("conservative") - - # Conservative remapping needs cell corner bounds on both grids. - if conservative: - src = _add_source_bounds(src, grid) - # Latitude edges must stay within [-90, 90]: midpoint extrapolation of a - # target row sitting on the pole (e.g. GLORYS' top row at exactly 90 N) - # otherwise lands a cell corner past the pole — an invalid spherical - # coordinate. Clamping caps that cell at the pole instead. - target_ds = target_ds.assign( - lon_b=("lon_b", _edges_1d(lon)), - lat_b=("lat_b", np.clip(_edges_1d(lat), -90.0, 90.0)), - ) + curvilinear = lon.ndim == 2 + + if curvilinear: + # Curvilinear targets (e.g. a stereographic Arctic grid): 2-D lon/lat + # require a 2-D xESMF target dataset. Conservative remapping is not + # supported for curvilinear targets (cell-corner bounds in geographic + # coordinates are non-trivial for projected grids), so fall back to + # bilinear with a warning. + if method.startswith("conservative"): + warnings.warn( + "Conservative regridding is not supported for curvilinear " + "targets (2-D lon/lat); falling back to 'bilinear'.", + UserWarning, + stacklevel=3, + ) + method = "bilinear" + target_ds = xr.Dataset({"lat": (["y", "x"], lat), "lon": (["y", "x"], lon)}) + conservative = False + else: + target_ds = xr.Dataset({"lat": (["lat"], lat), "lon": (["lon"], lon)}) + conservative = method.startswith("conservative") + # Conservative remapping needs cell corner bounds on both grids. + if conservative: + src = _add_source_bounds(src, grid) + # Latitude edges must stay within [-90, 90]: midpoint extrapolation + # of a target row sitting on the pole (e.g. GLORYS' top row at + # exactly 90 N) otherwise lands a cell corner past the pole — an + # invalid spherical coordinate. Clamping caps that cell at the + # pole instead. + target_ds = target_ds.assign( + lon_b=("lon_b", _edges_1d(lon)), + lat_b=("lat_b", np.clip(_edges_1d(lat), -90.0, 90.0)), + ) # Thickness-weight layered fields for conservative remapping so the # volume-integrated content (field * layer thickness) is conserved: remap @@ -664,10 +687,26 @@ def regrid_horizontal( out[v] = out[v] / denom out[v].attrs = layer_attrs[v] - out["lon"].attrs.setdefault("standard_name", "longitude") - out["lon"].attrs.setdefault("units", "degrees_east") - out["lat"].attrs.setdefault("standard_name", "latitude") - out["lat"].attrs.setdefault("units", "degrees_north") + if curvilinear: + # xESMF does not carry non-index geographic coords to the output; + # re-attach them using the target's coordinate names. + lon_cname, lat_cname = ( + _lonlat_names(tgt) if tgt is not None else ("longitude", "latitude") + ) + extra: dict = { + lon_cname: (("y", "x"), lon), + lat_cname: (("y", "x"), lat), + } + if tgt is not None: + for d in ("y", "x"): + if d in tgt.coords and d in out.dims: + extra[d] = tgt[d] + out = out.assign_coords(extra) + else: + out["lon"].attrs.setdefault("standard_name", "longitude") + out["lon"].attrs.setdefault("units", "degrees_east") + out["lat"].attrs.setdefault("standard_name", "latitude") + out["lat"].attrs.setdefault("units", "degrees_north") if tgt is not None and apply_target_mask: out = _apply_target_mask(out, tgt, surface_only=True) From ebdee7e6e05ce6ad66ff224cb7b73c5df8861843 Mon Sep 17 00:00:00 2001 From: Nora Loose Date: Wed, 26 Aug 2026 09:23:58 +0200 Subject: [PATCH 3/4] Test grids --- tests/data/_subset_tp5_stereo.py | 58 +++++++++++++++++++++++++++++++ tests/data/tp5_stereo_subset.nc | Bin 0 -> 42261 bytes 2 files changed, 58 insertions(+) create mode 100644 tests/data/_subset_tp5_stereo.py create mode 100644 tests/data/tp5_stereo_subset.nc diff --git a/tests/data/_subset_tp5_stereo.py b/tests/data/_subset_tp5_stereo.py new file mode 100644 index 0000000..88915dc --- /dev/null +++ b/tests/data/_subset_tp5_stereo.py @@ -0,0 +1,58 @@ +"""Generate the bundled stereographic (TOPAZ5) target-grid test fixture. + +The full TP5 stereographic output is 1137×1185 cells with multiple 3-D fields +(~1 GB). For tests we need only a small curvilinear target covering the TP0 +model domain (Nordic Seas, ~lat 60–80 N, lon -20 to 20 E), spatially coarsened +to a few dozen cells. + +Run from a machine that can see the source file:: + + python tests/data/_subset_tp5_stereo.py + +Source: /cluster/projects/nn2993k/nlo043/TP5a0.06/staged/Hy2.2/archm_1993_01.nc +Re-run only if the fixture needs regenerating; the product is committed. +""" +import os + +import numpy as np +import xarray as xr + +SRC = ("/cluster/projects/nn2993k/nlo043/TP5a0.06/staged/Hy2.2/archm_1993_01.nc") +DST = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "tp5_stereo_subset.nc") + +# Region overlapping the TP0 test domain (lat 60–80 N, lon -20 to 20 E) in +# the stereographic projected grid (y/x in degrees of the projection). +# Indices derived by masking on latitude/longitude of the full grid. +Y_SLICE = slice(139, 639) # ~500 rows covering approx lat 60–80 N +X_SLICE = slice(626, 1126) # ~500 cols covering approx lon -20 to 20 E +STRIDE = 20 # → ~25×25 cells +DEPTH_N = 3 # keep only the shallowest depth levels + + +def main() -> None: + ds = xr.open_dataset(SRC) + sub = ds.isel(y=Y_SLICE, x=X_SLICE).isel( + y=slice(None, None, STRIDE), + x=slice(None, None, STRIDE), + depth=slice(None, DEPTH_N), + time=0, + ) + # Keep geographic coords, depth levels, bathymetry, and one tracer for + # mask derivation (thetao NaN at land and below seafloor). + keep_vars = ["model_depth", "thetao"] + sub = sub[keep_vars] + # Ensure longitude/latitude 2-D coords are included (they are non-index + # coords on the y/x dims — xarray carries them through isel). + encoding = {v: {"zlib": True, "complevel": 4} for v in sub.data_vars} + sub.to_netcdf(DST, encoding=encoding) + print(f"wrote {DST} ({os.path.getsize(DST) / 1e3:.0f} KB)") + print("dims:", dict(sub.sizes)) + print("coords:", list(sub.coords)) + # Sanity: lon/lat should span the TP0 domain. + print(f"lon range: {float(sub.longitude.min()):.1f} .. {float(sub.longitude.max()):.1f}") + print(f"lat range: {float(sub.latitude.min()):.1f} .. {float(sub.latitude.max()):.1f}") + + +if __name__ == "__main__": + main() diff --git a/tests/data/tp5_stereo_subset.nc b/tests/data/tp5_stereo_subset.nc new file mode 100644 index 0000000000000000000000000000000000000000..b3dac1da5842b370044ecc8961a6c99c9edbdca5 GIT binary patch literal 42261 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 Date: Thu, 27 Aug 2026 08:55:54 +0200 Subject: [PATCH 4/4] Add to release notes --- docs/releases.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/releases.md b/docs/releases.md index b38a0cb..e15b834 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -10,6 +10,8 @@ ### 🐛 Bug Fixes +* Fix transport and regrid for non-lat/lon grids (e.g. stereographic) ([#21](https://github.com/nansencenter/xhycom/pull/21)) + ### 📚 Documentation ### 🔧 Internal