Skip to content

Commit ffbcff2

Browse files
authored
Add rootd mapping for ACCESS-ESM1.6 (#343)
* Implement rootd calculation based on #335 * Add unit tests for calc_rootd * Dynamically determine pseudo-level dimension in calc_rootd function * Add calc_rootd to derivations module and custom functions * Use positional vegetation tile selection in calc_rootd * Cover calc_rootd missing pseudo_level branch
1 parent 09eb890 commit ffbcff2

4 files changed

Lines changed: 229 additions & 1 deletion

File tree

src/access_moppy/derivations/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
calc_mrsll,
1919
calc_mrsol,
2020
calc_nitrogen_pool_kg_m2,
21+
calc_rootd,
2122
calc_topsoil,
2223
calc_tsl,
2324
extract_tilefrac,
@@ -93,6 +94,7 @@
9394
"calc_mrsfl": calc_mrsfl,
9495
"calc_mrsll": calc_mrsll,
9596
"calc_mrsol": calc_mrsol,
97+
"calc_rootd": calc_rootd,
9698
"calc_tsl": calc_tsl,
9799
"calc_hfds": calc_hfds,
98100
"calc_msftbarot": calc_msftbarot,

src/access_moppy/derivations/calc_land.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,67 @@ def calc_mrsol(var1):
639639
return result
640640

641641

642+
def calc_rootd(tilefrac):
643+
"""
644+
Calculate maximum root depth (rootd) as a fixed (fx) field.
645+
646+
Since all plant functional types (PFTs, tiles 1-13) in ACCESS-ESM1.6 can
647+
access the lowest (6th) soil layer, rootd equals the total soil column
648+
depth of 4.6 m wherever any vegetated tile exists at any timestep. Grid
649+
cells with land but no vegetation (tiles 14-17 only) get 0 m, and
650+
ocean/no-land cells get the missing value (NaN).
651+
652+
Agreed calculation from issue #335 (pseudocode by har917)::
653+
654+
rootd = 4.6 if any(tilefrac[t, i, lat, lon] > 0, i=1..13)
655+
rootd = 0 if all(tilefrac[t, i, lat, lon] == 0, i=1..13)
656+
and any(tilefrac[t, :, lat, lon] > 0)
657+
rootd = NaN if all(tilefrac[t, :, lat, lon] == 0)
658+
659+
Parameters
660+
----------
661+
tilefrac : xarray.DataArray
662+
Tile fraction variable (fractional, 0-1) with a pseudo-level
663+
dimension as the second axis. Corresponds to STASH ``fld_s03i317``.
664+
665+
Returns
666+
-------
667+
xarray.DataArray
668+
Maximum root depth in metres (m) without a time dimension.
669+
"""
670+
TOTAL_SOIL_DEPTH = 4.6 # metres — full depth of the 6-layer soil column
671+
672+
# Find the pseudo-level dimension dynamically (could be "pseudo_level_0" or "pseudo_level_1")
673+
pseudo_level_dims = [d for d in tilefrac.dims if "pseudo_level" in d]
674+
if not pseudo_level_dims:
675+
raise ValueError(
676+
f"No pseudo_level dimension found in tilefrac dims: {tilefrac.dims}"
677+
)
678+
pseudo_level = pseudo_level_dims[0]
679+
680+
# Vegetation occupies the first 13 slices of the tile axis. Use positional
681+
# indexing so this works whether tile coordinates are 0-based, 1-based, or
682+
# absent.
683+
veg_tiles = tilefrac.isel({pseudo_level: slice(0, 13)})
684+
685+
# Lazy boolean reduction over tiles (and any remaining non-spatial dims)
686+
has_veg = (veg_tiles > 0.0).any(dim=pseudo_level)
687+
has_land = (tilefrac > 0.0).any(dim=pseudo_level)
688+
689+
# Reduce over all remaining non-spatial dimensions (e.g. time)
690+
spatial_dims = {"lat", "lon", "latitude", "longitude"}
691+
for dim in list(has_veg.dims):
692+
if dim not in spatial_dims:
693+
has_veg = has_veg.any(dim=dim)
694+
has_land = has_land.any(dim=dim)
695+
696+
# Build rootd: 4.6 m where vegetated, 0 m where land-only, NaN where ocean
697+
rootd = has_veg.astype(float) * TOTAL_SOIL_DEPTH
698+
rootd = rootd.where(has_land)
699+
700+
return rootd
701+
702+
642703
def calc_tsl(var1):
643704
"""
644705
This function takes soil temperature (fld_s08i225) and transforms the

src/access_moppy/mappings/ACCESS-ESM1.6_mappings.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3404,6 +3404,28 @@
34043404
"fld_s03i395": "1"
34053405
}
34063406
},
3407+
"rootd": {
3408+
"dimensions": {
3409+
"lat": "lat",
3410+
"lon": "lon"
3411+
},
3412+
"units": "m",
3413+
"positive": null,
3414+
"model_variables": [
3415+
"fld_s03i317"
3416+
],
3417+
"calculation": {
3418+
"type": "formula",
3419+
"operation": "calc_rootd",
3420+
"args": [
3421+
"fld_s03i317"
3422+
]
3423+
},
3424+
"CF standard Name": "root_depth",
3425+
"model_variable_units": {
3426+
"fld_s03i317": "1"
3427+
}
3428+
},
34073429
"sftgif": {
34083430
"dimensions": {
34093431
"lat": "lat",

tests/unit/test_derivations_calc_land.py

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import pytest
55
import xarray as xr
66

7-
from access_moppy.derivations.calc_land import calc_snc
7+
from access_moppy.derivations.calc_land import calc_rootd, calc_snc
88

99
# ---------------------------------------------------------------------------
1010
# Helpers
@@ -161,3 +161,146 @@ def test_mismatched_pseudo_level_name_handled(self):
161161
result = calc_snc(tf, sn, lf)
162162

163163
np.testing.assert_allclose(result.values, 100.0)
164+
165+
166+
# ---------------------------------------------------------------------------
167+
# Helpers for calc_rootd
168+
# ---------------------------------------------------------------------------
169+
170+
NT_R = 2 # time steps
171+
N_TILES = 17 # tiles 1-17 (pseudo_level_1 coordinate values)
172+
NJ_R = 3
173+
NI_R = 4
174+
175+
176+
def _make_tilefrac(data):
177+
"""Build a tilefrac DataArray with pseudo_level_1 coords 1-17."""
178+
times = xr.date_range("2000-01-01", periods=NT_R, freq="ME")
179+
return xr.DataArray(
180+
data,
181+
dims=["time", "pseudo_level_1", "lat", "lon"],
182+
coords={
183+
"time": times,
184+
"pseudo_level_1": np.arange(1, N_TILES + 1),
185+
},
186+
attrs={"units": "1"},
187+
)
188+
189+
190+
# ---------------------------------------------------------------------------
191+
# Tests
192+
# ---------------------------------------------------------------------------
193+
194+
195+
class TestCalcRootd:
196+
"""Tests for calc_rootd() — issue #335."""
197+
198+
def test_vegetated_returns_4p6(self):
199+
"""Grid cells with any vegetated tile (1-13) get 4.6 m."""
200+
data = np.zeros((NT_R, N_TILES, NJ_R, NI_R))
201+
data[:, 0, :, :] = 0.5 # tile 1 (index 0) is vegetated
202+
tilefrac = _make_tilefrac(data)
203+
204+
result = calc_rootd(tilefrac)
205+
206+
assert result.dims == ("lat", "lon")
207+
np.testing.assert_allclose(result.values, 4.6)
208+
209+
def test_non_veg_land_returns_zero(self):
210+
"""Land cells with only non-veg tiles (14-17) get 0 m."""
211+
data = np.zeros((NT_R, N_TILES, NJ_R, NI_R))
212+
data[:, 13, :, :] = 1.0 # tile 14 (index 13) — ice/urban/barren/lake
213+
tilefrac = _make_tilefrac(data)
214+
215+
result = calc_rootd(tilefrac)
216+
217+
assert result.dims == ("lat", "lon")
218+
np.testing.assert_allclose(result.values, 0.0)
219+
220+
def test_ocean_returns_nan(self):
221+
"""Cells with all-zero tile fractions (ocean) get NaN (missing)."""
222+
data = np.zeros((NT_R, N_TILES, NJ_R, NI_R))
223+
tilefrac = _make_tilefrac(data)
224+
225+
result = calc_rootd(tilefrac)
226+
227+
assert result.dims == ("lat", "lon")
228+
assert np.all(np.isnan(result.values))
229+
230+
def test_mixed_grid(self):
231+
"""Spatial mix: veg / non-veg land / ocean all in the same array."""
232+
data = np.zeros((NT_R, N_TILES, NJ_R, NI_R))
233+
# lat 0: vegetated
234+
data[:, 0, 0, :] = 0.8 # tile 1
235+
# lat 1: non-veg land only (tile 14)
236+
data[:, 13, 1, :] = 1.0
237+
# lat 2: ocean — all zeros
238+
239+
tilefrac = _make_tilefrac(data)
240+
result = calc_rootd(tilefrac)
241+
242+
np.testing.assert_allclose(result.values[0, :], 4.6) # veg
243+
np.testing.assert_allclose(result.values[1, :], 0.0) # non-veg land
244+
assert np.all(np.isnan(result.values[2, :])) # ocean
245+
246+
def test_veg_appears_in_only_one_timestep(self):
247+
"""Vegetation present in any timestep is enough to report 4.6 m."""
248+
data = np.zeros((NT_R, N_TILES, NJ_R, NI_R))
249+
data[0, 2, :, :] = 0.3 # tile 3, timestep 0 only
250+
tilefrac = _make_tilefrac(data)
251+
252+
result = calc_rootd(tilefrac)
253+
254+
np.testing.assert_allclose(result.values, 4.6)
255+
256+
def test_no_time_dimension_removed(self):
257+
"""Output has no time dimension regardless of input."""
258+
data = np.zeros((NT_R, N_TILES, NJ_R, NI_R))
259+
data[:, 0, :, :] = 0.5
260+
tilefrac = _make_tilefrac(data)
261+
262+
result = calc_rootd(tilefrac)
263+
264+
assert "time" not in result.dims
265+
266+
def test_dask_compatible(self):
267+
"""Function returns a lazy dask-backed array without calling compute."""
268+
pytest.importorskip("dask")
269+
270+
data = np.zeros((NT_R, N_TILES, NJ_R, NI_R))
271+
data[:, 0, :, :] = 0.5
272+
tilefrac = _make_tilefrac(data).chunk({"time": 1})
273+
274+
result = calc_rootd(tilefrac)
275+
276+
assert result.chunks is not None, "result should be dask-backed (lazy)"
277+
np.testing.assert_allclose(result.compute().values, 4.6)
278+
279+
def test_pseudo_level_0_without_coord_is_supported(self):
280+
"""Regression: works when tile dimension is pseudo_level_0 with no coord."""
281+
times = xr.date_range("2000-01-01", periods=NT_R, freq="ME")
282+
data = np.zeros((NT_R, N_TILES, NJ_R, NI_R))
283+
data[:, 0, :, :] = 0.5
284+
tilefrac = xr.DataArray(
285+
data,
286+
dims=["time", "pseudo_level_0", "lat", "lon"],
287+
coords={"time": times},
288+
)
289+
290+
result = calc_rootd(tilefrac)
291+
292+
assert result.dims == ("lat", "lon")
293+
np.testing.assert_allclose(result.values, 4.6)
294+
295+
def test_missing_pseudo_level_dimension_raises(self):
296+
"""Defensive path: raise when tilefrac has no pseudo_level dimension."""
297+
times = xr.date_range("2000-01-01", periods=NT_R, freq="ME")
298+
data = np.zeros((NT_R, NJ_R, NI_R))
299+
tilefrac = xr.DataArray(
300+
data,
301+
dims=["time", "lat", "lon"],
302+
coords={"time": times},
303+
)
304+
305+
with pytest.raises(ValueError, match="No pseudo_level dimension found"):
306+
calc_rootd(tilefrac)

0 commit comments

Comments
 (0)