Skip to content

Commit f3e017a

Browse files
brianpm-ucarclaude
andcommitted
Make domain_stats use spatial_average and handle 0-360 longitudes
domain_stats picked its region with sel(lon=slice(west, east)), so the polar plots' [-180, 180, 45, 90] kept only longitudes 0 to 180 of the usual 0 to 360 data, and the header mean, max and min described half the cap. It also failed on latitude that runs north to south. Pick the region with masks on longitude modulo 360 and on latitude, and take the mean with spatial_average instead of a second copy of the cos(lat) weights. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent dc6508f commit f3e017a

2 files changed

Lines changed: 142 additions & 7 deletions

File tree

‎lib/adf_utils.py‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -974,10 +974,13 @@ def domain_stats(data, domain):
974974
Parameters
975975
----------
976976
data : xarray.DataArray
977-
data values
977+
data values on a 2-D `lat` by `lon` grid (no time or level dimension)
978978
domain : list or tuple or numpy.ndarray
979979
the domain specification as:
980980
[west_longitude, east_longitude, south_latitude, north_latitude]
981+
Longitudes can be given in either -180 to 180 or 0 to 360, whichever
982+
the data uses. If west is greater than east the region crosses
983+
the 0/360 line, and a span of 360 degrees or more is the whole circle.
981984
982985
Returns
983986
-------
@@ -990,18 +993,27 @@ def domain_stats(data, domain):
990993
991994
Notes
992995
-----
993-
Currently assumes 'lat' is a dimension and uses `cos(lat)` as weight.
994-
Should use `spatial_average`
996+
The average is `spatial_average`, so it uses `cos(lat)` weights.
997+
The region is picked with a mask, so latitude can run in either order.
995998
996999
See Also
9971000
--------
9981001
spatial_average
9991002
10001003
"""
1001-
x_region = data.sel(
1002-
lat=slice(domain[2], domain[3]), lon=slice(domain[0], domain[1])
1003-
)
1004-
x_region_mean = x_region.weighted(np.cos(np.deg2rad(x_region["lat"]))).mean().item()
1004+
west, east, south, north = domain
1005+
# Compare longitudes on a common 0-360 basis, so that the data and the
1006+
# domain do not have to use the same convention:
1007+
lon = data["lon"] % 360
1008+
if east - west >= 360:
1009+
lon_mask = xr.ones_like(lon, dtype=bool)
1010+
elif (west % 360) <= (east % 360):
1011+
lon_mask = (lon >= west % 360) & (lon <= east % 360)
1012+
else:
1013+
lon_mask = (lon >= west % 360) | (lon <= east % 360)
1014+
lat_mask = (data["lat"] >= south) & (data["lat"] <= north)
1015+
x_region = data.where(lon_mask & lat_mask, drop=True)
1016+
x_region_mean = spatial_average(x_region).item()
10051017
x_region_min = x_region.min().item()
10061018
x_region_max = x_region.max().item()
10071019
return x_region_mean, x_region_max, x_region_min
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""
2+
Collection of python unit tests
3+
for "adf_utils.domain_stats".
4+
5+
The region mean has to be the same area-weighted mean whichever longitude
6+
convention the data uses and whichever way latitude runs. The polar plots ask
7+
for [-180, 180, 45, 90] while regridded CAM output is on 0 to 360, and a
8+
selection that did not allow for that averaged half of the cap.
9+
10+
NOTE: these tests import adf_utils, which imports xarray, so they are skipped
11+
in CI, which installs only PyYAML and pytest. They run in a full ADF
12+
environment.
13+
"""
14+
15+
import os
16+
import os.path
17+
import sys
18+
import unittest
19+
20+
# Set relevant path variables:
21+
_CURRDIR = os.path.abspath(os.path.dirname(__file__))
22+
_ADF_LIB_DIR = os.path.join(_CURRDIR, os.pardir, os.pardir)
23+
24+
# Add ADF "lib" directory to python path:
25+
sys.path.append(_ADF_LIB_DIR)
26+
27+
try:
28+
import numpy as np
29+
import xarray as xr
30+
from adf_utils import domain_stats, spatial_average
31+
32+
_HAS_ADF_UTILS = True
33+
except ImportError:
34+
_HAS_ADF_UTILS = False
35+
36+
37+
def _field(lon, lat):
38+
"""A field that varies with both longitude and latitude."""
39+
lon = np.asarray(lon, dtype="f8")
40+
lat = np.asarray(lat, dtype="f8")
41+
vals = np.sin(np.deg2rad(lon))[None, :] * 10 + lat[:, None]
42+
return xr.DataArray(vals, dims=("lat", "lon"), coords={"lat": lat, "lon": lon})
43+
44+
45+
def _brute_force(da, lon_ok, lat_ok):
46+
"""cos(lat)-weighted mean over the points picked by two boolean tests."""
47+
lat = da.lat.values
48+
lon = da.lon.values
49+
sel = lat_ok(lat)[:, None] & lon_ok(lon % 360)[None, :]
50+
wgt = np.broadcast_to(np.cos(np.deg2rad(lat))[:, None], da.shape)
51+
return float((da.values * wgt)[sel].sum() / wgt[sel].sum())
52+
53+
54+
@unittest.skipUnless(_HAS_ADF_UTILS, "adf_utils dependencies not available")
55+
class DomainStatsTestRoutine(unittest.TestCase):
56+
"""Unit tests for the regional mean, maximum and minimum."""
57+
58+
lat = np.arange(-89.0, 90.0, 2.0)
59+
60+
def test_same_answer_for_either_longitude_convention(self):
61+
"""The polar cap must not depend on how longitude is numbered."""
62+
east = _field(np.arange(0.0, 360.0, 2.5), self.lat)
63+
west = east.assign_coords(lon=((east.lon + 180) % 360) - 180).sortby("lon")
64+
domain = [-180, 180, 45, 90]
65+
got_east = domain_stats(east, domain)
66+
got_west = domain_stats(west, domain)
67+
expected = _brute_force(east, lambda x: x >= 0, lambda x: x >= 45)
68+
self.assertAlmostEqual(got_east[0], expected, places=10)
69+
np.testing.assert_allclose(got_east, got_west, rtol=1e-12)
70+
71+
def test_latitude_can_descend(self):
72+
"""A north-to-south latitude axis gives the same answer."""
73+
up = _field(np.arange(0.0, 360.0, 5.0), self.lat)
74+
down = up.isel(lat=slice(None, None, -1))
75+
np.testing.assert_allclose(
76+
domain_stats(up, [0, 360, -90, -45]),
77+
domain_stats(down, [0, 360, -90, -45]),
78+
rtol=1e-12,
79+
)
80+
81+
def test_box_that_crosses_the_zero_line(self):
82+
"""West greater than east means the region wraps through 0/360."""
83+
da = _field(np.arange(0.0, 360.0, 2.5), self.lat)
84+
mean, vmax, vmin = domain_stats(da, [350, 10, -30, 30])
85+
expected = _brute_force(
86+
da, lambda x: (x >= 350) | (x <= 10), lambda x: abs(x) <= 30
87+
)
88+
self.assertAlmostEqual(mean, expected, places=10)
89+
self.assertLessEqual(vmax, da.max().item())
90+
self.assertGreaterEqual(vmin, da.min().item())
91+
92+
def test_whole_globe_is_spatial_average(self):
93+
"""A global box reproduces the shared weighted mean."""
94+
da = _field(np.arange(0.0, 360.0, 2.5), self.lat)
95+
mean, vmax, vmin = domain_stats(da, [-180, 180, -90, 90])
96+
self.assertAlmostEqual(mean, spatial_average(da).item(), places=10)
97+
self.assertEqual(vmax, da.max().item())
98+
self.assertEqual(vmin, da.min().item())
99+
100+
def test_missing_values_are_left_out(self):
101+
"""NaN points drop out of the mean rather than counting as zero."""
102+
da = _field(np.arange(0.0, 360.0, 5.0), self.lat)
103+
da[-3:, :10] = np.nan
104+
mean = domain_stats(da, [0, 360, 45, 90])[0]
105+
good = da.notnull().values
106+
wgt = np.broadcast_to(np.cos(np.deg2rad(da.lat.values))[:, None], da.shape)
107+
sel = good & (da.lat.values >= 45)[:, None]
108+
expected = float((da.values * wgt)[sel].sum() / wgt[sel].sum())
109+
self.assertAlmostEqual(mean, expected, places=10)
110+
111+
def test_edges_of_the_longitude_range(self):
112+
"""Boxes ending on 0, 180 or 360 pick the right points."""
113+
da = _field(np.array([0.0, 90.0, 180.0, 270.0, 360.0]), self.lat)
114+
whole = domain_stats(da, [-180, 180, -90, 90])
115+
for domain in ([0, 360, -90, 90], [180, 540, -90, 90]):
116+
np.testing.assert_allclose(domain_stats(da, domain), whole, rtol=1e-12)
117+
mean = domain_stats(da, [0, 180, -90, 90])[0]
118+
expected = _brute_force(da, lambda x: (x >= 0) & (x <= 180), lambda x: x > -91)
119+
self.assertAlmostEqual(mean, expected, places=10)
120+
121+
122+
if __name__ == "__main__":
123+
unittest.main()

0 commit comments

Comments
 (0)