Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion src/access_moppy/derivations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
"power": lambda a, b: a**b,
"sum": lambda x, **kwargs: x.sum(**kwargs),
"mean": lambda *args: sum(args) / len(args),
"kelvin_to_celsius": lambda x: x - 273.15,
"kelvin_to_celsius": lambda x: _kelvin_to_celsius(x),
"celsius_to_kelvin": lambda x: x + 273.15,
"cli_level_to_height": cli_level_to_height,
"clw_level_to_height": clw_level_to_height,
Expand Down Expand Up @@ -128,6 +128,26 @@
}


def _kelvin_to_celsius(value):
"""Convert Kelvin to Celsius with a fallback for mislabeled Celsius-scale data."""
attrs = getattr(value, "attrs", {}) or {}
units = str(attrs.get("units", "")).strip().lower()

if "celsius" in units or "degc" in units or units in {"c", "deg c"}:
return value

if units in {"k", "kelvin"}:
valid_range = attrs.get("valid_range")
try:
if valid_range is not None and len(valid_range) >= 1:
if float(valid_range[0]) < 100.0:
return value
except Exception:
pass

return value - 273.15


def evaluate_expression(expr, context):
if isinstance(expr, dict):
if "literal" in expr:
Expand Down
7 changes: 5 additions & 2 deletions src/access_moppy/mappings/ACCESS-ESM1-6_mappings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5382,8 +5382,11 @@
"pot_temp"
],
"calculation": {
"type": "direct",
"formula": "pot_temp"
"type": "formula",
"operation": "kelvin_to_celsius",
"operands": [
"pot_temp"
]
},
"CF standard Name": "",
"model_variable_units": {
Expand Down
101 changes: 101 additions & 0 deletions tests/unit/test_derivations.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,107 @@ def test_optional_used_as_kwarg_in_formula(self):
result = evaluate_expression(expr, ctx)
np.testing.assert_allclose(result.values, 2.0)

def test_kelvin_to_celsius_converts_kelvin_values(self):
"""kelvin_to_celsius should convert canonical Kelvin metadata."""
da = xr.DataArray(
np.array([300.0], dtype=np.float32),
attrs={"units": "K", "valid_range": [250.0, 350.0]},
)
result = evaluate_expression(
{"operation": "kelvin_to_celsius", "operands": ["temp"]},
{"temp": da},
)
assert float(result.values[0]) == pytest.approx(26.85, abs=1e-3)

def test_kelvin_to_celsius_skips_celsius_scale_metadata(self):
"""kelvin_to_celsius should not offset known Celsius-scale mislabeled data."""
da = xr.DataArray(
np.array([10.0], dtype=np.float32),
attrs={"units": "K", "valid_range": [-10.0, 500.0]},
)
result = evaluate_expression(
{"operation": "kelvin_to_celsius", "operands": ["temp"]},
{"temp": da},
)
assert float(result.values[0]) == pytest.approx(10.0)

def test_kelvin_to_celsius_passes_through_celsius_units(self):
"""kelvin_to_celsius should leave Celsius-labelled values unchanged."""
da = xr.DataArray(np.array([12.5], dtype=np.float32), attrs={"units": "degC"})
result = evaluate_expression(
{"operation": "kelvin_to_celsius", "operands": ["temp"]},
{"temp": da},
)
assert float(result.values[0]) == pytest.approx(12.5)

def test_kelvin_to_celsius_converts_non_kelvin_units(self):
"""Non-Celsius, non-Kelvin units should still apply the Kelvin offset."""
da = xr.DataArray(np.array([273.15], dtype=np.float32), attrs={"units": "m"})
result = evaluate_expression(
{"operation": "kelvin_to_celsius", "operands": ["temp"]},
{"temp": da},
)
assert float(result.values[0]) == pytest.approx(0.0, abs=1e-3)

def test_kelvin_to_celsius_ignores_malformed_valid_range(self):
"""Malformed valid_range metadata should fall back to the Kelvin offset."""

class BadRange:
def __len__(self):
raise RuntimeError("bad valid_range")

da = xr.DataArray(
np.array([280.0], dtype=np.float32),
attrs={"units": "K", "valid_range": BadRange()},
)
result = evaluate_expression(
{"operation": "kelvin_to_celsius", "operands": ["temp"]},
{"temp": da},
)
assert float(result.values[0]) == pytest.approx(6.85, abs=1e-3)

def test_kelvin_to_celsius_without_valid_range_uses_offset(self):
"""Kelvin units without valid_range metadata should still convert."""
da = xr.DataArray(np.array([280.0], dtype=np.float32), attrs={"units": "K"})
result = evaluate_expression(
{"operation": "kelvin_to_celsius", "operands": ["temp"]},
{"temp": da},
)
assert float(result.values[0]) == pytest.approx(6.85, abs=1e-3)

def test_ocean_floor_then_kelvin_to_celsius(self):
"""The tob-style chain should convert the floor value from Kelvin to Celsius."""
data = xr.DataArray(
np.array(
[[300.0, 280.0], [295.0, np.nan], [290.0, np.nan]], dtype=np.float32
),
dims=["st_ocean", "xt_ocean"],
attrs={"units": "K", "valid_range": [250.0, 350.0]},
)
result = evaluate_expression(
{
"operation": "kelvin_to_celsius",
"operands": [
{"operation": "ocean_floor", "operands": ["temp"]},
],
},
{"temp": data},
)
assert float(result.isel(xt_ocean=0).values) == pytest.approx(16.85, abs=1e-3)
assert float(result.isel(xt_ocean=1).values) == pytest.approx(6.85, abs=1e-3)

def test_list_expression_is_evaluated_recursively(self):
"""evaluate_expression should recurse through list expressions."""
ctx = self._make_context()
result = evaluate_expression(["var1", {"literal": 3}], ctx)
assert result[0] is ctx["var1"]
assert result[1] == 3

def test_unsupported_expression_raises(self):
"""Unsupported expressions should raise a ValueError."""
with pytest.raises(ValueError, match="Unsupported expression"):
evaluate_expression(object(), {})


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