Skip to content

Commit 7fe40b6

Browse files
authored
Merge pull request #3100 from samsrabin/subset-data-lon-3093
Fix Longitude comparison error for regional subset_data
2 parents 41ae069 + 3fa2980 commit 7fe40b6

15 files changed

+915
-76
lines changed

.git-blame-ignore-revs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,6 @@ e8fc526e0d7818d45f171488c78392c4ff63902a
6464
cdf40d265cc82775607a1bf25f5f527bacc97405
6565
251e389b361ba673b508e07d04ddcc06b2681989
6666
8ec50135eca1b99c8b903ecdaa1bd436644688bd
67+
3b7a2876933263f8986e4069f5d23bd45635756f
68+
3dd489af7ebe06566e2c6a1c7ade18550f1eb4ba
69+
742cfa606039ab89602fde5fef46458516f56fd4

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
*.nc
33
# but don't ignore netcdf files here:
44
!/python/ctsm/test/testinputs/*.nc
5+
!/python/ctsm/test/testinputs/**/*.nc
56

67
# editor files
78
*.swp

python/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ ifneq ($(verbose), not-set)
1919
endif
2020

2121
PYLINT=pylint
22-
PYLINT_ARGS=-j 4 --rcfile=ctsm/.pylintrc --fail-under=0
22+
PYLINT_ARGS=-j 4 --rcfile=ctsm/.pylintrc
2323
PYLINT_SRC = \
2424
ctsm
2525
# NOTE: These don't pass pylint checking and should be added when we put into effort to get them to pass

python/ctsm/longitude.py

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
"""
44

55
import logging
6+
from argparse import ArgumentTypeError
7+
import numpy as np
68

79
logger = logging.getLogger(__name__)
810

@@ -11,16 +13,22 @@ def _check_lon_type_180(lon_in):
1113
"""
1214
Checks value range of longitude with type 180
1315
"""
14-
if not -180 <= lon_in <= 180:
15-
raise ValueError(f"lon_in needs to be in the range [-180, 180]: {lon_in}")
16+
lon_min = np.min(lon_in)
17+
lon_max = np.max(lon_in)
18+
for lon in [lon_min, lon_max]:
19+
if not -180 <= lon <= 180:
20+
raise ValueError("(All values of) lon_in must be in the range [-180, 180]")
1621

1722

1823
def _check_lon_type_360(lon_in):
1924
"""
2025
Checks value range of longitude with type 360
2126
"""
22-
if not 0 <= lon_in <= 360:
23-
raise ValueError(f"lon_in needs to be in the range [0, 360]: {lon_in}")
27+
lon_min = np.min(lon_in)
28+
lon_max = np.max(lon_in)
29+
for lon in [lon_min, lon_max]:
30+
if not 0 <= lon <= 360:
31+
raise ValueError("(All values of) lon_in must be in the range [0, 360]")
2432

2533

2634
def _check_lon_value_given_type(lon_in, lon_type_in):
@@ -50,6 +58,31 @@ def _convert_lon_type_180_to_360(lon_in):
5058
return lon_out
5159

5260

61+
def _detect_lon_type(lon_in):
62+
"""
63+
Detect longitude type of a given numeric. If lon_in contains more than one number (as in a list
64+
or Numpy array), this function will assume all members are of the same type if (a) there is at
65+
least one unambiguous member and (b) all unambiguous members are of the same type.
66+
"""
67+
lon_min = np.min(lon_in)
68+
lon_max = np.max(lon_in)
69+
if lon_min < -180:
70+
raise ValueError(f"(Minimum) longitude < -180: {lon_min}")
71+
if lon_max > 360:
72+
raise ValueError(f"(Maximum) longitude > 360: {lon_max}")
73+
min_type_180 = lon_min < 0
74+
max_type_360 = lon_max > 180
75+
if min_type_180 and max_type_360:
76+
raise RuntimeError("Longitude array contains values of both types 180 and 360")
77+
if not min_type_180 and not max_type_360:
78+
raise ArgumentTypeError("Longitude(s) ambiguous; could be type 180 or 360")
79+
if min_type_180:
80+
lon_type = 180
81+
else:
82+
lon_type = 360
83+
return lon_type
84+
85+
5386
def _convert_lon_type_360_to_180(lon_in):
5487
"""
5588
Convert a longitude from type 360 to type 180
@@ -70,6 +103,22 @@ def _convert_lon_type_360_to_180(lon_in):
70103
return lon_out
71104

72105

106+
def check_other_is_lontype(other):
107+
"""
108+
Used in comparison operators to throw an error if the "other" object being compared isn't also
109+
a Longitude object. This makes it so that comparing longitudes requires that both sides of the
110+
comparison must be Longitude objects and thus must have a specified longitude type (180 or 360).
111+
112+
We could try to coerce non-Longitude `other` to Longitude, but that might result in
113+
situations where tests think everything works but code will fail if `other` is
114+
ambiguous.
115+
"""
116+
if not isinstance(other, Longitude):
117+
raise TypeError(
118+
f"Comparison not supported between instances of 'Longitude' and '{type(other)}'"
119+
)
120+
121+
73122
class Longitude:
74123
"""
75124
A class to keep track of a longitude and its type
@@ -93,6 +142,41 @@ def __init__(self, lon, lon_type):
93142
self._lon = lon
94143
self._lon_type = lon_type
95144

145+
def _check_lons_same_type(self, other):
146+
"""
147+
If you're comparing two Longitudes of different types in different hemispheres, then
148+
`lon1 > lon2` and `lon2 < lon1` will incorrectly give different answers! We could make it so
149+
that this doesn't fail as long as symmetricality isn't violated, but that might lead to
150+
unexpected failures in practice.
151+
"""
152+
if self.lon_type() != other.lon_type():
153+
raise TypeError("Comparison not supported between Longitudes of different types")
154+
155+
# __eq__ makes it so that == and != both work.
156+
def __eq__(self, other):
157+
check_other_is_lontype(other)
158+
return self._lon == other.get(self._lon_type)
159+
160+
def __lt__(self, other):
161+
check_other_is_lontype(other)
162+
self._check_lons_same_type(other)
163+
return self._lon < other._lon
164+
165+
def __gt__(self, other):
166+
check_other_is_lontype(other)
167+
self._check_lons_same_type(other)
168+
return self._lon > other._lon
169+
170+
def __le__(self, other):
171+
check_other_is_lontype(other)
172+
self._check_lons_same_type(other)
173+
return self._lon <= other._lon
174+
175+
def __ge__(self, other):
176+
check_other_is_lontype(other)
177+
self._check_lons_same_type(other)
178+
return self._lon >= other._lon
179+
96180
def get(self, lon_type_out):
97181
"""
98182
Get the longitude value, converting longitude type if needed
@@ -104,3 +188,9 @@ def get(self, lon_type_out):
104188
if lon_type_out == 360:
105189
return _convert_lon_type_180_to_360(self._lon)
106190
raise RuntimeError(f"Add handling for lon_type_out {lon_type_out}")
191+
192+
def lon_type(self):
193+
"""
194+
Getter method for self._lon_type
195+
"""
196+
return self._lon_type

python/ctsm/site_and_regional/regional_case.py

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from ctsm.utils import add_tag_to_filename
2020
from ctsm.utils import abort
2121
from ctsm.config_utils import check_lon1_lt_lon2
22+
from ctsm.longitude import Longitude, _detect_lon_type
2223

2324
logger = logging.getLogger(__name__)
2425

@@ -132,6 +133,33 @@ def __init__(
132133
self.ni = None
133134
self.nj = None
134135

136+
def _subset_lon_lat(self, x_dim, y_dim, f_in):
137+
"""
138+
subset longitude and latitude arrays
139+
"""
140+
lat = f_in["lat"]
141+
lon = f_in["lon"]
142+
143+
# Detect longitude type (180 or 360) of input file, throwing a helpful error if it can't be
144+
# determined.
145+
f_lon_type = _detect_lon_type(lon)
146+
lon1_type = self.lon1.lon_type()
147+
lon2_type = self.lon2.lon_type()
148+
if lon1_type != lon2_type:
149+
raise RuntimeError(f"lon1 type ({lon1_type}) doesn't match lon2 type ({lon2_type})")
150+
if f_lon_type != lon1_type:
151+
# This may be overly strict; we might want to allow conversion to lon1 type.
152+
raise RuntimeError(
153+
f"File lon type ({f_lon_type}) doesn't match boundary lon type ({lon1_type})"
154+
)
155+
156+
# Convert input file longitudes to Longitude class, then trim where it's in region bounds
157+
lon = Longitude(lon, lon1_type)
158+
xind = np.where((lon >= self.lon1) & (lon <= self.lon2))[0]
159+
yind = np.where((lat >= self.lat1) & (lat <= self.lat2))[0]
160+
f_out = f_in.isel({y_dim: yind, x_dim: xind})
161+
return f_out
162+
135163
def create_tag(self):
136164
"""
137165
Create a tag for a region which is either the region name
@@ -192,14 +220,12 @@ def create_domain_at_reg(self, indir, file):
192220
logger.info("Creating domain file at region: %s", self.tag)
193221

194222
# create 1d coordinate variables to enable sel() method
195-
f_in = self.create_1d_coord(fdomain_in, "xc", "yc", "ni", "nj")
196-
lat = f_in["lat"]
197-
lon = f_in["lon"]
223+
x_dim = "ni"
224+
y_dim = "nj"
225+
f_in = self.create_1d_coord(fdomain_in, "xc", "yc", x_dim, y_dim)
198226

199227
# subset longitude and latitude arrays
200-
xind = np.where((lon >= self.lon1) & (lon <= self.lon2))[0]
201-
yind = np.where((lat >= self.lat1) & (lat <= self.lat2))[0]
202-
f_out = f_in.isel(nj=yind, ni=xind)
228+
f_out = self._subset_lon_lat(x_dim, y_dim, f_in)
203229

204230
# update attributes
205231
self.update_metadata(f_out)
@@ -240,14 +266,12 @@ def create_surfdata_at_reg(self, indir, file, user_mods_dir, specify_fsurf_out):
240266
logger.info("fsurf_out: %s", os.path.join(self.out_dir, fsurf_out))
241267

242268
# create 1d coordinate variables to enable sel() method
243-
f_in = self.create_1d_coord(fsurf_in, "LONGXY", "LATIXY", "lsmlon", "lsmlat")
244-
lat = f_in["lat"]
245-
lon = f_in["lon"]
269+
x_dim = "lsmlon"
270+
y_dim = "lsmlat"
271+
f_in = self.create_1d_coord(fsurf_in, "LONGXY", "LATIXY", x_dim, y_dim)
246272

247273
# subset longitude and latitude arrays
248-
xind = np.where((lon >= self.lon1) & (lon <= self.lon2))[0]
249-
yind = np.where((lat >= self.lat1) & (lat <= self.lat2))[0]
250-
f_out = f_in.isel(lsmlat=yind, lsmlon=xind)
274+
f_out = self._subset_lon_lat(x_dim, y_dim, f_in)
251275

252276
# update attributes
253277
self.update_metadata(f_out)
@@ -304,14 +328,12 @@ def create_landuse_at_reg(self, indir, file, user_mods_dir):
304328
logger.info("fluse_out: %s", os.path.join(self.out_dir, fluse_out))
305329

306330
# create 1d coordinate variables to enable sel() method
307-
f_in = self.create_1d_coord(fluse_in, "LONGXY", "LATIXY", "lsmlon", "lsmlat")
308-
lat = f_in["lat"]
309-
lon = f_in["lon"]
331+
x_dim = "lsmlon"
332+
y_dim = "lsmlat"
333+
f_in = self.create_1d_coord(fluse_in, "LONGXY", "LATIXY", x_dim, y_dim)
310334

311335
# subset longitude and latitude arrays
312-
xind = np.where((lon >= self.lon1) & (lon <= self.lon2))[0]
313-
yind = np.where((lat >= self.lat1) & (lat <= self.lat2))[0]
314-
f_out = f_in.isel(lsmlat=yind, lsmlon=xind)
336+
f_out = self._subset_lon_lat(x_dim, y_dim, f_in)
315337

316338
# update attributes
317339
self.update_metadata(f_out)

python/ctsm/subset_data.py

Lines changed: 31 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
from ctsm.path_utils import path_to_ctsm_root
7070
from ctsm.utils import abort
7171
from ctsm.config_utils import check_lon1_lt_lon2
72-
from ctsm.longitude import Longitude
72+
from ctsm.longitude import Longitude, _detect_lon_type
7373

7474
# -- import ctsm logging flags
7575
from ctsm.ctsm_logging import (
@@ -626,46 +626,23 @@ def setup_files(args, defaults, cesmroot):
626626
logger.info("clmforcingindir does not exist: %s", clmforcingindir)
627627
abort("inputdata directory does not exist")
628628

629+
file_dict = {"main_dir": clmforcingindir}
630+
629631
# DATM data
630632
# TODO Issue #2960: Make datm_type a user option at the command
631633
# line. For reference, this option affects three .cfg files:
632634
# tools/site_and_regional/default_data_1850.cfg
633635
# tools/site_and_regional/default_data_2000.cfg
634636
# python/ctsm/test/testinputs/default_data.cfg
635-
datm_type = "datm_crujra" # also available: datm_type = "datm_gswp3"
636-
dir_output_datm = "datmdata"
637-
dir_input_datm = os.path.join(clmforcingindir, defaults.get(datm_type, "dir"))
638637
if args.create_datm:
638+
datm_type = "datm_crujra" # also available: datm_type = "datm_gswp3"
639+
dir_output_datm = "datmdata"
640+
dir_input_datm = os.path.join(clmforcingindir, defaults.get(datm_type, "dir"))
639641
if not os.path.isdir(os.path.join(args.out_dir, dir_output_datm)):
640642
os.mkdir(os.path.join(args.out_dir, dir_output_datm))
641643
logger.info("dir_input_datm : %s", dir_input_datm)
642644
logger.info("dir_output_datm: %s", os.path.join(args.out_dir, dir_output_datm))
643-
644-
# if the crop flag is on - we need to use a different land use and surface data file
645-
num_pft = determine_num_pft(args.crop_flag)
646-
647-
fsurf_in = defaults.get("surfdat", "surfdat_" + num_pft + "pft")
648-
fluse_in = defaults.get("landuse", "landuse_" + num_pft + "pft")
649-
if args.out_surface:
650-
fsurf_out = args.out_surface
651-
else:
652-
fsurf_out = None
653-
654-
file_dict = {
655-
"main_dir": clmforcingindir,
656-
"fdomain_in": defaults.get("domain", "file"),
657-
"fsurf_dir": os.path.join(
658-
clmforcingindir,
659-
os.path.join(defaults.get("surfdat", "dir")),
660-
),
661-
"fluse_dir": os.path.join(
662-
clmforcingindir,
663-
os.path.join(defaults.get("landuse", "dir")),
664-
),
665-
"fsurf_in": fsurf_in,
666-
"fsurf_out": fsurf_out,
667-
"fluse_in": fluse_in,
668-
"datm_tuple": DatmFiles(
645+
file_dict["datm_tuple"] = DatmFiles(
669646
dir_input_datm,
670647
dir_output_datm,
671648
defaults.get(datm_type, "domain"),
@@ -678,8 +655,30 @@ def setup_files(args, defaults, cesmroot):
678655
defaults.get(datm_type, "solarname"),
679656
defaults.get(datm_type, "precname"),
680657
defaults.get(datm_type, "tpqwname"),
681-
),
682-
}
658+
)
659+
660+
# if the crop flag is on - we need to use a different land use and surface data file
661+
num_pft = determine_num_pft(args.crop_flag)
662+
663+
if args.create_domain:
664+
file_dict["fdomain_in"] = defaults.get("domain", "file")
665+
if args.create_surfdata:
666+
file_dict["fsurf_dir"] = os.path.join(
667+
clmforcingindir,
668+
os.path.join(defaults.get("surfdat", "dir")),
669+
)
670+
file_dict["fsurf_in"] = defaults.get("surfdat", "surfdat_" + num_pft + "pft")
671+
if args.out_surface:
672+
fsurf_out = args.out_surface
673+
else:
674+
fsurf_out = None
675+
file_dict["fsurf_out"] = fsurf_out
676+
if args.create_landuse:
677+
file_dict["fluse_in"] = defaults.get("landuse", "landuse_" + num_pft + "pft")
678+
file_dict["fluse_dir"] = os.path.join(
679+
clmforcingindir,
680+
os.path.join(defaults.get("landuse", "dir")),
681+
)
683682

684683
return file_dict
685684

@@ -821,17 +820,6 @@ def subset_region(args, file_dict: dict):
821820
logger.info("Successfully ran script for a regional case.")
822821

823822

824-
def _detect_lon_type(lon_in):
825-
if lon_in < 0:
826-
lon_type = 180
827-
elif lon_in > 180:
828-
lon_type = 360
829-
else:
830-
msg = "When providing an ambiguous longitude, you must specify --lon-type 180 or 360"
831-
raise argparse.ArgumentTypeError(msg)
832-
return lon_type
833-
834-
835823
def process_args(args):
836824
"""
837825
Process arguments after parsing

0 commit comments

Comments
 (0)