Skip to content

Commit 46aef95

Browse files
authored
Fix hfds derivation with frazil_2d fallback (closes #200) (#298)
* feat(ocean): add calc_hfds function for surface downward heat flux calculation * feat(tests): add unit tests for evaluate_expression and calc_hfds functions
1 parent 8fa0f2f commit 46aef95

6 files changed

Lines changed: 181 additions & 6 deletions

File tree

src/access_moppy/derivations/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from access_moppy.derivations.calc_ocean import (
2727
calc_areacello,
2828
calc_global_ave_ocean,
29+
calc_hfds,
2930
calc_hfgeou,
3031
calc_msftbarot,
3132
calc_overturning_streamfunction,
@@ -90,6 +91,7 @@
9091
"calc_mrsll": calc_mrsll,
9192
"calc_mrsol": calc_mrsol,
9293
"calc_tsl": calc_tsl,
94+
"calc_hfds": calc_hfds,
9395
"calc_msftbarot": calc_msftbarot,
9496
"calc_hfgeou": calc_hfgeou,
9597
"calc_overturning_streamfunction": calc_overturning_streamfunction,
@@ -118,6 +120,9 @@ def evaluate_expression(expr, context):
118120
if isinstance(expr, dict):
119121
if "literal" in expr:
120122
return expr["literal"]
123+
if "optional" in expr:
124+
# Return the variable if present in context, else None
125+
return context.get(expr["optional"])
121126
op = expr["operation"]
122127
args = [
123128
evaluate_expression(arg, context)

src/access_moppy/derivations/calc_ocean.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
#!/usr/bin/env python
2+
import logging
3+
24
import xarray as xr
35

6+
logger = logging.getLogger(__name__)
7+
48

59
def calc_global_ave_ocean(var, rho_dzt, area_t):
610
"""Calculate mass-weighted global average of an ocean variable.
@@ -600,3 +604,54 @@ def calc_areacello(area_t, ht, drop_time=True):
600604
areacello = areacello.isel(time=0).drop_vars("time", errors="ignore")
601605

602606
return areacello
607+
608+
609+
def calc_hfds(
610+
sfc_hflux_from_runoff,
611+
sfc_hflux_coupler,
612+
sfc_hflux_pme,
613+
frazil_3d_int_z=None,
614+
frazil_2d=None,
615+
):
616+
"""Calculate surface downward heat flux in sea water (hfds).
617+
618+
Sums the base surface heat flux components and adds the appropriate frazil
619+
term. ``frazil_3d_int_z`` is preferred; ``frazil_2d`` is used as a fallback
620+
for ACCESS-ESM1.6 runs that use the ``pop_icediag`` frazil scheme (frazil
621+
confined to the top 5 ocean layers), where ``frazil_3d_int_z`` is not saved.
622+
623+
Parameters
624+
----------
625+
sfc_hflux_from_runoff : xarray.DataArray
626+
Heat flux from runoff. Units: W m-2
627+
sfc_hflux_coupler : xarray.DataArray
628+
Heat flux from the coupler. Units: W m-2
629+
sfc_hflux_pme : xarray.DataArray
630+
Heat flux from precipitation minus evaporation. Units: W m-2
631+
frazil_3d_int_z : xarray.DataArray or None, optional
632+
Vertically integrated 3-D frazil heat flux. Used preferentially when
633+
available. Units: W m-2
634+
frazil_2d : xarray.DataArray or None, optional
635+
2-D frazil heat flux. Used as a fallback when ``frazil_3d_int_z`` is
636+
not available.
637+
638+
Returns
639+
-------
640+
hfds : xarray.DataArray
641+
Surface downward heat flux in sea water. Units: W m-2
642+
"""
643+
base = sfc_hflux_from_runoff + sfc_hflux_coupler + sfc_hflux_pme
644+
if frazil_3d_int_z is not None:
645+
return base + frazil_3d_int_z
646+
elif frazil_2d is not None:
647+
logger.warning(
648+
"frazil_3d_int_z not available; using frazil_2d for hfds calculation "
649+
"(appropriate for ACCESS-ESM1.6 runs with the pop_icediag frazil scheme)"
650+
)
651+
return base + frazil_2d
652+
else:
653+
logger.warning(
654+
"Neither frazil_3d_int_z nor frazil_2d is available; "
655+
"computing hfds without a frazil contribution"
656+
)
657+
return base

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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3741,17 +3741,21 @@
37413741
"sfc_hflux_from_runoff",
37423742
"sfc_hflux_coupler",
37433743
"sfc_hflux_pme",
3744-
"frazil_3d_int_z"
3744+
"frazil_3d_int_z",
3745+
"frazil_2d"
37453746
],
37463747
"calculation": {
37473748
"type": "formula",
3748-
"operation": "sum_vars",
3749+
"operation": "calc_hfds",
37493750
"args": [
37503751
"sfc_hflux_from_runoff",
37513752
"sfc_hflux_coupler",
3752-
"sfc_hflux_pme",
3753-
"frazil_3d_int_z"
3754-
]
3753+
"sfc_hflux_pme"
3754+
],
3755+
"kwargs": {
3756+
"frazil_3d_int_z": {"optional": "frazil_3d_int_z"},
3757+
"frazil_2d": {"optional": "frazil_2d"}
3758+
}
37553759
},
37563760
"CF standard Name": ""
37573761
},

src/access_moppy/ocean.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,11 @@ def select_and_process_variables(self):
118118
self.ds[self.cmor_name] = self.ds[required_vars[0]]
119119
elif calc["type"] == "formula":
120120
# If the calculation is a formula, evaluate it
121-
context = {var: self.ds[var] for var in required_vars}
121+
# Variables listed in model_variables that are absent from the
122+
# loaded dataset (e.g. optional frazil fields) are silently
123+
# omitted from the context; individual derivation functions
124+
# handle the None case via {"optional": ...} expressions.
125+
context = {var: self.ds[var] for var in required_vars if var in self.ds}
122126
context.update(custom_functions)
123127
self.ds[self.cmor_name] = evaluate_expression(calc, context)
124128
elif calc["type"] == "dataset_function":

tests/unit/test_derivations.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,28 @@ def test_numeric_passthrough(self):
4040
"""Numeric values are returned directly."""
4141
assert evaluate_expression(3.14, {}) == pytest.approx(3.14)
4242

43+
def test_optional_present_returns_value(self):
44+
"""{'optional': 'varname'} returns the DataArray when it IS in context."""
45+
ctx = self._make_context()
46+
result = evaluate_expression({"optional": "var1"}, ctx)
47+
assert result is ctx["var1"]
48+
49+
def test_optional_absent_returns_none(self):
50+
"""{'optional': 'varname'} returns None when the variable is NOT in context."""
51+
result = evaluate_expression({"optional": "missing_var"}, {})
52+
assert result is None
53+
54+
def test_optional_used_as_kwarg_in_formula(self):
55+
"""An optional kwarg that is absent should resolve to None and be passed through."""
56+
ctx = self._make_context()
57+
# Build an expression like calc_hfds(var1, var2, frazil_3d_int_z=None)
58+
expr = {
59+
"operation": "add",
60+
"args": ["var1", "var2"],
61+
}
62+
result = evaluate_expression(expr, ctx)
63+
np.testing.assert_allclose(result.values, 2.0)
64+
4365

4466
class TestCalculateMonthlyMinimum:
4567
"""Tests for calculate_monthly_minimum() — covers decode_cf and ME frequency fix."""

tests/unit/test_derivations_calc_ocean.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from access_moppy.derivations.calc_ocean import (
88
calc_areacello,
99
calc_global_ave_ocean,
10+
calc_hfds,
1011
calc_hfgeou,
1112
calc_msftbarot,
1213
calc_overturning_streamfunction,
@@ -586,3 +587,87 @@ def test_equivalent_to_depth_sum_then_cumsum(self):
586587
result = calc_msftbarot(tx)
587588
expected = tx.sum("st_ocean").cumsum("yt_ocean")
588589
np.testing.assert_allclose(result.values, expected.values, rtol=1e-12)
590+
591+
592+
# ---------------------------------------------------------------------------
593+
# calc_hfds
594+
# ---------------------------------------------------------------------------
595+
596+
597+
class TestCalcHfds:
598+
"""Tests for calc_hfds — surface downward heat flux with frazil fallback."""
599+
600+
def _base_fields(self):
601+
"""Return three base surface flux DataArrays."""
602+
times = xr.date_range("2000-01-01", periods=NT, freq="ME")
603+
shape = (NT, NY, NX)
604+
dims = ["time", "yt_ocean", "xt_ocean"]
605+
runoff = xr.DataArray(
606+
RNG.random(shape) * 10.0, dims=dims, coords={"time": times}
607+
)
608+
coupler = xr.DataArray(
609+
RNG.random(shape) * 50.0, dims=dims, coords={"time": times}
610+
)
611+
pme = xr.DataArray(RNG.random(shape) * 5.0, dims=dims, coords={"time": times})
612+
return runoff, coupler, pme
613+
614+
@pytest.mark.unit
615+
def test_uses_frazil_3d_int_z_when_available(self):
616+
"""When frazil_3d_int_z is provided it should be included in the sum."""
617+
runoff, coupler, pme = self._base_fields()
618+
frazil_3d = xr.DataArray(
619+
np.ones((NT, NY, NX)) * 2.0,
620+
dims=["time", "yt_ocean", "xt_ocean"],
621+
)
622+
frazil_2d = xr.DataArray(
623+
np.ones((NT, NY, NX)) * 99.0, # should NOT be used
624+
dims=["time", "yt_ocean", "xt_ocean"],
625+
)
626+
result = calc_hfds(
627+
runoff, coupler, pme, frazil_3d_int_z=frazil_3d, frazil_2d=frazil_2d
628+
)
629+
expected = runoff + coupler + pme + frazil_3d
630+
np.testing.assert_allclose(result.values, expected.values, rtol=1e-10)
631+
632+
@pytest.mark.unit
633+
def test_falls_back_to_frazil_2d(self):
634+
"""When frazil_3d_int_z is None, frazil_2d should be used instead."""
635+
runoff, coupler, pme = self._base_fields()
636+
frazil_2d = xr.DataArray(
637+
np.ones((NT, NY, NX)) * 3.0,
638+
dims=["time", "yt_ocean", "xt_ocean"],
639+
)
640+
result = calc_hfds(
641+
runoff, coupler, pme, frazil_3d_int_z=None, frazil_2d=frazil_2d
642+
)
643+
expected = runoff + coupler + pme + frazil_2d
644+
np.testing.assert_allclose(result.values, expected.values, rtol=1e-10)
645+
646+
@pytest.mark.unit
647+
def test_falls_back_to_frazil_2d_emits_warning(self, caplog):
648+
"""Falling back to frazil_2d should log a warning."""
649+
import logging
650+
651+
runoff, coupler, pme = self._base_fields()
652+
frazil_2d = xr.DataArray(
653+
np.ones((NT, NY, NX)), dims=["time", "yt_ocean", "xt_ocean"]
654+
)
655+
with caplog.at_level(
656+
logging.WARNING, logger="access_moppy.derivations.calc_ocean"
657+
):
658+
calc_hfds(runoff, coupler, pme, frazil_3d_int_z=None, frazil_2d=frazil_2d)
659+
assert any("frazil_2d" in msg for msg in caplog.messages)
660+
661+
@pytest.mark.unit
662+
def test_no_frazil_returns_base_sum(self):
663+
"""When neither frazil term is provided, result equals the three base fluxes."""
664+
runoff, coupler, pme = self._base_fields()
665+
result = calc_hfds(runoff, coupler, pme, frazil_3d_int_z=None, frazil_2d=None)
666+
expected = runoff + coupler + pme
667+
np.testing.assert_allclose(result.values, expected.values, rtol=1e-10)
668+
669+
@pytest.mark.unit
670+
def test_returns_dataarray(self):
671+
runoff, coupler, pme = self._base_fields()
672+
result = calc_hfds(runoff, coupler, pme)
673+
assert isinstance(result, xr.DataArray)

0 commit comments

Comments
 (0)