@@ -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
66167class TestCalculateMonthlyMinimum :
67168 """Tests for calculate_monthly_minimum() — covers decode_cf and ME frequency fix."""
0 commit comments