Skip to content

Commit dc80b5d

Browse files
fix: gridarea handles projected-CRS inputs
raster_utils._reggrid_area assumes lat/lon in degrees; when fed projected coordinates in metres (SVY21, Mollweide, ...) sin(radians(Northing)) returns partly-negative cell areas. This silently corrupts: - mm -> m3/s hydrology forcing conversion (flips precip sign) - setup_emission_raster with area_division=True (banded artefacts) Fix: branch on crs.is_projected and delegate to ds.raster.area_grid().
1 parent 9ded4c6 commit dc80b5d

3 files changed

Lines changed: 69 additions & 7 deletions

File tree

docs/changelog.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ Changed
1717

1818
Fixed
1919
-----
20+
- ``workflows.emissions.gridarea`` now branches on ``crs.is_projected`` and
21+
delegates to ``ds.raster.area_grid()`` for projected-CRS inputs. The previous
22+
implementation called ``raster_utils._reggrid_area`` unconditionally, which
23+
assumes lat/lon in degrees and returns partly-negative cell areas when given
24+
projected coordinates in metres — silently corrupting downstream mm->m3/s
25+
hydrology forcing conversion and ``setup_emission_raster`` with
26+
``area_division=True``.
2027

2128
v0.4.0 (15 April 2026)
2229
========================

hydromt_delwaq/workflows/emissions.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,28 +20,37 @@
2020
def gridarea(ds):
2121
"""Return a DataArray containing the area in m2 of the reference grid ds.
2222
23+
Works for both geographic (lat/lon degrees) and projected (metres) CRS.
24+
The previous implementation called ``_reggrid_area`` unconditionally,
25+
which treats x/y as lat/lon in degrees — fine for global hydromt setups,
26+
but for projected CRS (e.g. SVY21 EPSG:3414, Easting/Northing in metres)
27+
it computes ``sin(radians(Easting))`` and returns periodic, partly-negative
28+
garbage. Delegating to ``ds.raster.area_grid()`` switches on
29+
``crs.is_projected`` and uses ``res_x * res_y * unit_factor**2`` instead.
30+
2331
Parameters
2432
----------
25-
da : xarray.DataArray or xarray.DataSet
26-
DataArray containing reference grid.
33+
ds : xarray.DataArray or xarray.DataSet
34+
Reference grid; must have a CRS set on the raster accessor.
2735
2836
Returns
2937
-------
3038
da_out : xarray.DataArray
31-
DataArray containing area in m2 of the reference grid.
32-
39+
Cell area in m2.
3340
"""
41+
crs = ds.raster.crs
42+
if crs is not None and crs.is_projected:
43+
return ds.raster.area_grid().astype("float32")
44+
3445
realarea = raster_utils._reggrid_area(
3546
ds.raster.ycoords.values, ds.raster.xcoords.values
3647
)
37-
da_out = xr.DataArray(
48+
return xr.DataArray(
3849
data=realarea.astype("float32"),
3950
coords=ds.raster.coords,
4051
dims=ds.raster.dims,
4152
)
4253

43-
return da_out
44-
4554

4655
def gridlength_gridwidth(ds):
4756
"""Return two DataArray for length and width of the grid."""

tests/test_model_methods.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,52 @@
1111
TESTDATADIR = join(dirname(abspath(__file__)), "data")
1212

1313

14+
def _make_grid(crs, x0, y0, res, nx=4, ny=3):
15+
x = x0 + (np.arange(nx) + 0.5) * res
16+
y = y0 - (np.arange(ny) + 0.5) * res # N -> S
17+
da = xr.DataArray(
18+
np.ones((ny, nx), dtype="float32"),
19+
coords={"y": y, "x": x},
20+
dims=("y", "x"),
21+
name="dummy",
22+
)
23+
da.raster.set_crs(crs)
24+
return da
25+
26+
27+
def test_gridarea_projected_returns_uniform_positive_area():
28+
"""Projected CRS (e.g. SVY21 / EPSG:3414) must produce uniform positive areas.
29+
30+
Regression test for the gridarea bug that fed SVY21 Easting metres into
31+
``_reggrid_area`` (which interprets them as longitude degrees). At
32+
Easting=10500-18000 m, ``sin(radians(Easting))`` is a periodic function
33+
of large angles and produces partly-negative garbage, which propagated
34+
through the mm -> m3/s unit conversion and yielded negative precip values
35+
in dynamicdata.nc. The patched gridarea delegates to
36+
``ds.raster.area_grid()`` for projected CRS, which uses res*res*ucf**2.
37+
"""
38+
res = 30.0
39+
da = _make_grid("EPSG:3414", x0=10490.0, y0=39280.0, res=res)
40+
41+
area = gridarea(da)
42+
43+
assert area.shape == da.shape
44+
assert float(area.min()) > 0, "projected cell area must be strictly positive"
45+
expected = res * res # SVY21 linear unit factor is 1.0 (metres)
46+
np.testing.assert_allclose(area.values, expected, rtol=1e-6)
47+
48+
49+
def test_gridarea_geographic_path_unchanged():
50+
"""Geographic CRS path must still go through _reggrid_area (spherical cap)."""
51+
da = _make_grid("EPSG:4326", x0=4.0, y0=52.0, res=0.01)
52+
53+
area = gridarea(da)
54+
55+
assert float(area.min()) > 0
56+
# At ~52N, 0.01deg cells are ~700-800 m on a side -> ~5-7e5 m2.
57+
assert 4e5 < float(area.mean()) < 9e5
58+
59+
1460
def test_setup_grid(example_demission_model):
1561
# Initialize model and read results
1662
mod = example_demission_model

0 commit comments

Comments
 (0)