Skip to content

Commit 42747c9

Browse files
authored
Restore tob Kelvin conversion (#488)
* Remove kelvin_to_celsius from tob mapping * Restore thetao Kelvin conversion with fallback * Restore tob Kelvin conversion * Format array initialization in test_ocean_floor_then_kelvin_to_celsius for better readability * Add Kelvin fallback coverage tests * pre-commit
1 parent b5e3948 commit 42747c9

3 files changed

Lines changed: 127 additions & 3 deletions

File tree

src/access_moppy/derivations/__init__.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
"power": lambda a, b: a**b,
7272
"sum": lambda x, **kwargs: x.sum(**kwargs),
7373
"mean": lambda *args: sum(args) / len(args),
74-
"kelvin_to_celsius": lambda x: x - 273.15,
74+
"kelvin_to_celsius": lambda x: _kelvin_to_celsius(x),
7575
"celsius_to_kelvin": lambda x: x + 273.15,
7676
"cli_level_to_height": cli_level_to_height,
7777
"clw_level_to_height": clw_level_to_height,
@@ -128,6 +128,26 @@
128128
}
129129

130130

131+
def _kelvin_to_celsius(value):
132+
"""Convert Kelvin to Celsius with a fallback for mislabeled Celsius-scale data."""
133+
attrs = getattr(value, "attrs", {}) or {}
134+
units = str(attrs.get("units", "")).strip().lower()
135+
136+
if "celsius" in units or "degc" in units or units in {"c", "deg c"}:
137+
return value
138+
139+
if units in {"k", "kelvin"}:
140+
valid_range = attrs.get("valid_range")
141+
try:
142+
if valid_range is not None and len(valid_range) >= 1:
143+
if float(valid_range[0]) < 100.0:
144+
return value
145+
except Exception:
146+
pass
147+
148+
return value - 273.15
149+
150+
131151
def evaluate_expression(expr, context):
132152
if isinstance(expr, dict):
133153
if "literal" in expr:

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5382,8 +5382,11 @@
53825382
"pot_temp"
53835383
],
53845384
"calculation": {
5385-
"type": "direct",
5386-
"formula": "pot_temp"
5385+
"type": "formula",
5386+
"operation": "kelvin_to_celsius",
5387+
"operands": [
5388+
"pot_temp"
5389+
]
53875390
},
53885391
"CF standard Name": "",
53895392
"model_variable_units": {

tests/unit/test_derivations.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,107 @@ def test_optional_used_as_kwarg_in_formula(self):
6262
result = evaluate_expression(expr, ctx)
6363
np.testing.assert_allclose(result.values, 2.0)
6464

65+
def test_kelvin_to_celsius_converts_kelvin_values(self):
66+
"""kelvin_to_celsius should convert canonical Kelvin metadata."""
67+
da = xr.DataArray(
68+
np.array([300.0], dtype=np.float32),
69+
attrs={"units": "K", "valid_range": [250.0, 350.0]},
70+
)
71+
result = evaluate_expression(
72+
{"operation": "kelvin_to_celsius", "operands": ["temp"]},
73+
{"temp": da},
74+
)
75+
assert float(result.values[0]) == pytest.approx(26.85, abs=1e-3)
76+
77+
def test_kelvin_to_celsius_skips_celsius_scale_metadata(self):
78+
"""kelvin_to_celsius should not offset known Celsius-scale mislabeled data."""
79+
da = xr.DataArray(
80+
np.array([10.0], dtype=np.float32),
81+
attrs={"units": "K", "valid_range": [-10.0, 500.0]},
82+
)
83+
result = evaluate_expression(
84+
{"operation": "kelvin_to_celsius", "operands": ["temp"]},
85+
{"temp": da},
86+
)
87+
assert float(result.values[0]) == pytest.approx(10.0)
88+
89+
def test_kelvin_to_celsius_passes_through_celsius_units(self):
90+
"""kelvin_to_celsius should leave Celsius-labelled values unchanged."""
91+
da = xr.DataArray(np.array([12.5], dtype=np.float32), attrs={"units": "degC"})
92+
result = evaluate_expression(
93+
{"operation": "kelvin_to_celsius", "operands": ["temp"]},
94+
{"temp": da},
95+
)
96+
assert float(result.values[0]) == pytest.approx(12.5)
97+
98+
def test_kelvin_to_celsius_converts_non_kelvin_units(self):
99+
"""Non-Celsius, non-Kelvin units should still apply the Kelvin offset."""
100+
da = xr.DataArray(np.array([273.15], dtype=np.float32), attrs={"units": "m"})
101+
result = evaluate_expression(
102+
{"operation": "kelvin_to_celsius", "operands": ["temp"]},
103+
{"temp": da},
104+
)
105+
assert float(result.values[0]) == pytest.approx(0.0, abs=1e-3)
106+
107+
def test_kelvin_to_celsius_ignores_malformed_valid_range(self):
108+
"""Malformed valid_range metadata should fall back to the Kelvin offset."""
109+
110+
class BadRange:
111+
def __len__(self):
112+
raise RuntimeError("bad valid_range")
113+
114+
da = xr.DataArray(
115+
np.array([280.0], dtype=np.float32),
116+
attrs={"units": "K", "valid_range": BadRange()},
117+
)
118+
result = evaluate_expression(
119+
{"operation": "kelvin_to_celsius", "operands": ["temp"]},
120+
{"temp": da},
121+
)
122+
assert float(result.values[0]) == pytest.approx(6.85, abs=1e-3)
123+
124+
def test_kelvin_to_celsius_without_valid_range_uses_offset(self):
125+
"""Kelvin units without valid_range metadata should still convert."""
126+
da = xr.DataArray(np.array([280.0], dtype=np.float32), attrs={"units": "K"})
127+
result = evaluate_expression(
128+
{"operation": "kelvin_to_celsius", "operands": ["temp"]},
129+
{"temp": da},
130+
)
131+
assert float(result.values[0]) == pytest.approx(6.85, abs=1e-3)
132+
133+
def test_ocean_floor_then_kelvin_to_celsius(self):
134+
"""The tob-style chain should convert the floor value from Kelvin to Celsius."""
135+
data = xr.DataArray(
136+
np.array(
137+
[[300.0, 280.0], [295.0, np.nan], [290.0, np.nan]], dtype=np.float32
138+
),
139+
dims=["st_ocean", "xt_ocean"],
140+
attrs={"units": "K", "valid_range": [250.0, 350.0]},
141+
)
142+
result = evaluate_expression(
143+
{
144+
"operation": "kelvin_to_celsius",
145+
"operands": [
146+
{"operation": "ocean_floor", "operands": ["temp"]},
147+
],
148+
},
149+
{"temp": data},
150+
)
151+
assert float(result.isel(xt_ocean=0).values) == pytest.approx(16.85, abs=1e-3)
152+
assert float(result.isel(xt_ocean=1).values) == pytest.approx(6.85, abs=1e-3)
153+
154+
def test_list_expression_is_evaluated_recursively(self):
155+
"""evaluate_expression should recurse through list expressions."""
156+
ctx = self._make_context()
157+
result = evaluate_expression(["var1", {"literal": 3}], ctx)
158+
assert result[0] is ctx["var1"]
159+
assert result[1] == 3
160+
161+
def test_unsupported_expression_raises(self):
162+
"""Unsupported expressions should raise a ValueError."""
163+
with pytest.raises(ValueError, match="Unsupported expression"):
164+
evaluate_expression(object(), {})
165+
65166

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

0 commit comments

Comments
 (0)