Skip to content

Commit 4ff0026

Browse files
authored
Fix calc_zostoga: reference thickness, temperature-dependent alpha, optional temp_ref (#288)
* fix: update calc_zostoga to use reference thickness and add temp_ref handling * fix: convert pot_temp and temp_ref from K to °C in calc_zostoga The mapping declares pot_temp in K, but calc_zostoga was passing the raw value directly into the Gill 1982 α(T) formula and the (T − T_ref) anomaly, both of which require °C. With values of ~280–300 K the thermal expansion coefficient and steric height were substantially wrong. Changes: - Convert pot_temp K→°C internally (pot_temp_c = pot_temp - 273.15). - Change temp_ref unit contract from °C to K (matching pot_temp) and convert temp_ref K→°C internally (temp_ref_c = temp_ref - 273.15). This eliminates the asymmetry where a caller building temp_ref as a time-mean of pot_temp would naturally have it in K and would silently pass the wrong units. - Update fallback default from 4.0 °C → 277.15 K (= 4 °C). - Update docstring and warning message to reflect the new K contract. - Update unit tests: pot_temp values +273.15 K, temp_ref=277.15 K. * fix: add area_t to model_variables and calculation args in ACCESS-ESM1.6 mappings
1 parent 30c7ed6 commit 4ff0026

3 files changed

Lines changed: 126 additions & 46 deletions

File tree

src/access_moppy/derivations/calc_ocean.py

Lines changed: 64 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#!/usr/bin/env python
22
import logging
3+
import warnings
34

45
import xarray as xr
56

@@ -81,63 +82,99 @@ def calc_rsdoabsorb(sw_heat: xr.DataArray, swflux: xr.DataArray) -> xr.DataArray
8182
return rsdoabsorb
8283

8384

84-
def calc_zostoga(pot_temp, dzt, areacello, depth_coord="st_ocean"):
85+
def calc_zostoga(pot_temp, dzt_ref, areacello, temp_ref=None, depth_coord="st_ocean"):
8586
"""Calculate Global Average Thermosteric Sea Level Change.
8687
87-
This function computes thermosteric sea level change by comparing
88-
in-situ density to reference density at 4°C, integrating over depth,
89-
and computing the global average.
88+
Computes thermosteric sea level using a temperature-dependent thermal
89+
expansion coefficient integrated against a reference layer-thickness field.
90+
Using a fixed reference thickness (rather than the time-varying model dzt)
91+
is required for Boussinesq models such as MOM5 to isolate the thermosteric
92+
signal from the barotropic free-surface contribution.
9093
91-
Uses simplified density calculations suitable for thermosteric computations.
94+
All operations are dask-lazy: no ``.compute()`` or ``.values`` calls are
95+
made, so large datasets can be processed out-of-core.
9296
9397
Parameters
9498
----------
9599
pot_temp : xarray.DataArray
96-
Potential temperature in degrees Celsius
100+
Potential temperature in Kelvin (K), as provided by the model.
101+
The function converts to degrees Celsius internally before applying
102+
the Gill 1982 thermal expansion formula.
97103
Dimensions: (time, depth, lat, lon)
98-
dzt : xarray.DataArray
99-
Model level thickness with same dimensions as pot_temp
104+
dzt_ref : xarray.DataArray
105+
Reference (time-invariant) model level thickness.
106+
Dimensions: (depth, lat, lon) or (depth,)
100107
Units: m
101108
areacello : xarray.DataArray
102-
Ocean grid cell areas
109+
Ocean grid cell areas.
103110
Dimensions: (lat, lon)
104111
Units: m²
112+
temp_ref : float or xarray.DataArray or None, optional
113+
Reference temperature in Kelvin (K), matching the units of ``pot_temp``.
114+
If None (default), falls back to 277.15 K (= 4 °C) with a scientific
115+
warning. Using a scalar 277.15 K is a temporary approximation — it
116+
computes an absolute steric height relative to 4 °C rather than a
117+
temporal anomaly, which is not CMIP-compliant. The result will carry
118+
a large, physically meaningless baseline offset whose magnitude depends
119+
on how far the mean ocean temperature departs from 4 °C. For a
120+
physically correct result, pass a 3-D reference-period mean temperature
121+
field with dimensions (depth, lat, lon) in K (e.g. the piControl
122+
year-1 mean of ``pot_temp``).
105123
depth_coord : str, optional
106124
Name of the depth coordinate, default 'st_ocean'
107125
108126
Returns
109127
-------
110128
zostoga : xarray.DataArray
111-
Global Average Thermosteric Sea Level Change
129+
Global Average Thermosteric Sea Level Change.
112130
Dimensions: (time,)
113131
Units: m
114132
115133
Notes
116134
-----
117-
Uses simplified seawater density approximation:
118-
- Reference salinity: 35 PSU
119-
- Reference temperature: 4°C
120-
- Linear thermal expansion coefficient: ~2e-4 /°C
135+
Uses a temperature-dependent thermal expansion coefficient (Gill 1982):
136+
α(T) ≈ 5.27×10⁻⁵ + 7.1×10⁻⁶·T − 4×10⁻⁸·T² [°C⁻¹]
137+
valid for S ≈ 35 PSU at surface pressure. This avoids the large errors
138+
introduced by a constant α in cold deep and polar waters.
121139
"""
140+
if temp_ref is None:
141+
warnings.warn(
142+
"calc_zostoga: 'temp_ref' was not provided. Falling back to a "
143+
"scalar reference temperature of 277.15 K (= 4 °C).\n"
144+
"Scientific implications:\n"
145+
" * zostoga will be an ABSOLUTE steric height relative to 4 °C, "
146+
"not a temporal anomaly as required by CMIP. The result will "
147+
"carry a large, physically meaningless baseline offset whose "
148+
"magnitude depends on how far the mean ocean temperature departs "
149+
"from 4 °C.\n"
150+
" * Differences between time steps are still meaningful, but the "
151+
"absolute values and any multi-model comparison will be incorrect.\n"
152+
"To fix this, pass a 3-D reference-period mean temperature field "
153+
"in K (e.g. the piControl year-1 mean of pot_temp) as 'temp_ref'.",
154+
UserWarning,
155+
stacklevel=2,
156+
)
157+
temp_ref = 277.15
122158

123-
# Simple approximation for seawater density temperature dependence
124-
# ρ(T) ≈ ρ₀[1 - α(T - T₀)] where α ≈ 2e-4 /°C for seawater
125-
rho_0 = 1025.0 # Reference density at 4°C, kg/m³
126-
temp_ref = 4.0 # Reference temperature, °C
127-
alpha = 2e-4 # Thermal expansion coefficient, /°C
159+
# Convert both pot_temp and temp_ref from Kelvin to Celsius.
160+
# All inputs are expected in K (matching the model/mapping convention);
161+
# the Gill 1982 α(T) formula and the (T − T_ref) anomaly both require °C.
162+
pot_temp_c = pot_temp - 273.15
163+
temp_ref_c = temp_ref - 273.15
128164

129-
# Calculate density at in-situ temperature
130-
rho_insitu = rho_0 * (1.0 - alpha * (pot_temp - temp_ref))
165+
# Temperature-dependent thermal expansion coefficient (Gill 1982, simplified)
166+
# α(T) ≈ 5.27e-5 + 7.1e-6·T − 4e-8·T² [°C⁻¹], valid for S≈35 PSU
167+
alpha = 5.27e-5 + 7.1e-6 * pot_temp_c - 4e-8 * pot_temp_c**2
131168

132-
# Calculate reference density at 4°C
133-
rho_ref = rho_0 # At reference temperature
169+
# Thermosteric height contribution: α(T) × (T − T_ref) × dz_ref
170+
# dzt_ref is time-invariant so it does not carry the free-surface
171+
# barotropic signal that is present in the model's time-varying dzt.
172+
thermo_height = alpha * (pot_temp_c - temp_ref_c) * dzt_ref
134173

135-
# Calculate thermosteric height change and integrate over depth
136-
# (1 - ρ/ρ₄) gives fractional density difference
137-
thermo_height = (1.0 - rho_insitu / rho_ref) * dzt
174+
# Integrate over depth (lazy with dask)
138175
integrated_height = thermo_height.sum(dim=depth_coord, skipna=True)
139176

140-
# Calculate area-weighted global average
177+
# Area-weighted global average (lazy with dask)
141178
zostoga = integrated_height.weighted(areacello).mean(dim=["yt_ocean", "xt_ocean"])
142179

143180
return zostoga

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5570,20 +5570,23 @@
55705570
"positive": null,
55715571
"model_variables": [
55725572
"pot_temp",
5573-
"dzt"
5573+
"dzt",
5574+
"area_t"
55745575
],
55755576
"calculation": {
55765577
"type": "formula",
55775578
"operation": "calc_zostoga",
55785579
"args": [
55795580
"pot_temp",
5580-
"dzt"
5581+
"dzt",
5582+
"area_t"
55815583
]
55825584
},
55835585
"CF standard Name": "",
55845586
"model_variable_units": {
55855587
"pot_temp": "K",
5586-
"dzt": "m"
5588+
"dzt": "m",
5589+
"area_t": "m2"
55875590
}
55885591
}
55895592
},

tests/unit/test_derivations_calc_ocean.py

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for access_moppy.derivations.calc_ocean."""
22

3+
import dask.array as da
34
import numpy as np
45
import pytest
56
import xarray as xr
@@ -165,55 +166,94 @@ class TestCalcZostoga:
165166
@pytest.mark.unit
166167
def test_returns_dataarray(self):
167168
pot_temp = xr.DataArray(
168-
np.ones((NT, NZ, NY, NX)) * 10.0,
169+
np.ones((NT, NZ, NY, NX)) * 283.15, # 10 °C in K
169170
dims=["time", "st_ocean", "yt_ocean", "xt_ocean"],
170171
)
171-
dzt = xr.DataArray(
172-
np.ones((NT, NZ, NY, NX)) * 100.0,
173-
dims=["time", "st_ocean", "yt_ocean", "xt_ocean"],
172+
dzt_ref = xr.DataArray(
173+
np.ones((NZ, NY, NX)) * 100.0,
174+
dims=["st_ocean", "yt_ocean", "xt_ocean"],
174175
)
175176
areacello = xr.DataArray(
176177
np.ones((NY, NX)) * 1e10,
177178
dims=["yt_ocean", "xt_ocean"],
178179
)
179-
result = calc_zostoga(pot_temp, dzt, areacello)
180+
result = calc_zostoga(pot_temp, dzt_ref, areacello)
180181
assert isinstance(result, xr.DataArray)
181182

182183
@pytest.mark.unit
183184
def test_time_dim_preserved(self):
184185
pot_temp = xr.DataArray(
185-
np.ones((NT, NZ, NY, NX)) * 10.0,
186+
np.ones((NT, NZ, NY, NX)) * 283.15, # 10 °C in K
186187
dims=["time", "st_ocean", "yt_ocean", "xt_ocean"],
187188
)
188-
dzt = xr.DataArray(
189-
np.ones((NT, NZ, NY, NX)),
190-
dims=["time", "st_ocean", "yt_ocean", "xt_ocean"],
189+
dzt_ref = xr.DataArray(
190+
np.ones((NZ, NY, NX)),
191+
dims=["st_ocean", "yt_ocean", "xt_ocean"],
191192
)
192193
areacello = xr.DataArray(
193194
np.ones((NY, NX)),
194195
dims=["yt_ocean", "xt_ocean"],
195196
)
196-
result = calc_zostoga(pot_temp, dzt, areacello)
197+
result = calc_zostoga(pot_temp, dzt_ref, areacello)
197198
assert "time" in result.dims
198199

199200
@pytest.mark.unit
200201
def test_zero_thermosteric_at_reference_temp(self):
201-
"""At the reference temperature (4°C), thermosteric change should be ~0."""
202+
"""When pot_temp == temp_ref, thermosteric change should be ~0."""
202203
pot_temp = xr.DataArray(
203-
np.ones((NT, NZ, NY, NX)) * 4.0, # exactly at reference
204+
np.ones((NT, NZ, NY, NX)) * 277.15, # 4 °C in K
204205
dims=["time", "st_ocean", "yt_ocean", "xt_ocean"],
205206
)
206-
dzt = xr.DataArray(
207-
np.ones((NT, NZ, NY, NX)) * 10.0,
208-
dims=["time", "st_ocean", "yt_ocean", "xt_ocean"],
207+
dzt_ref = xr.DataArray(
208+
np.ones((NZ, NY, NX)) * 10.0,
209+
dims=["st_ocean", "yt_ocean", "xt_ocean"],
209210
)
210211
areacello = xr.DataArray(
211212
np.ones((NY, NX)),
212213
dims=["yt_ocean", "xt_ocean"],
213214
)
214-
result = calc_zostoga(pot_temp, dzt, areacello)
215+
# Pass temp_ref in K (same units as pot_temp) to avoid the fallback warning
216+
result = calc_zostoga(pot_temp, dzt_ref, areacello, temp_ref=277.15)
215217
np.testing.assert_allclose(result.values, 0.0, atol=1e-10)
216218

219+
@pytest.mark.unit
220+
def test_warns_when_temp_ref_not_provided(self):
221+
"""A UserWarning must be raised when temp_ref is omitted."""
222+
pot_temp = xr.DataArray(
223+
np.ones((NT, NZ, NY, NX)) * 283.15, # 10 °C in K
224+
dims=["time", "st_ocean", "yt_ocean", "xt_ocean"],
225+
)
226+
dzt_ref = xr.DataArray(
227+
np.ones((NZ, NY, NX)) * 10.0,
228+
dims=["st_ocean", "yt_ocean", "xt_ocean"],
229+
)
230+
areacello = xr.DataArray(
231+
np.ones((NY, NX)),
232+
dims=["yt_ocean", "xt_ocean"],
233+
)
234+
with pytest.warns(UserWarning, match="temp_ref.*not provided"):
235+
calc_zostoga(pot_temp, dzt_ref, areacello)
236+
237+
@pytest.mark.unit
238+
def test_dask_lazy(self):
239+
"""Result should remain dask-backed (lazy) when inputs are dask arrays."""
240+
pot_temp = xr.DataArray(
241+
da.from_array(
242+
np.ones((NT, NZ, NY, NX)) * 283.15, chunks=(1, NZ, NY, NX)
243+
), # 10 °C in K
244+
dims=["time", "st_ocean", "yt_ocean", "xt_ocean"],
245+
)
246+
dzt_ref = xr.DataArray(
247+
da.from_array(np.ones((NZ, NY, NX)) * 10.0, chunks=(NZ, NY, NX)),
248+
dims=["st_ocean", "yt_ocean", "xt_ocean"],
249+
)
250+
areacello = xr.DataArray(
251+
da.from_array(np.ones((NY, NX)), chunks=(NY, NX)),
252+
dims=["yt_ocean", "xt_ocean"],
253+
)
254+
result = calc_zostoga(pot_temp, dzt_ref, areacello)
255+
assert isinstance(result.data, da.Array)
256+
217257

218258
# ---------------------------------------------------------------------------
219259
# calc_overturning_streamfunction

0 commit comments

Comments
 (0)