Skip to content

Commit 9ff53c0

Browse files
authored
Merge pull request #87 from euro-cordex/various_fixes
2 parents d8a627e + 0c83f69 commit 9ff53c0

File tree

5 files changed

+385
-96
lines changed

5 files changed

+385
-96
lines changed

cc_plugin_cc6/base.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -903,11 +903,19 @@ def check_variable(self, ds):
903903
for entry in self.CTformulas["formula_entry"].keys():
904904
cvars.append(self.CTformulas["formula_entry"][entry]["out_name"])
905905
cvars = set(cvars)
906-
# Add grid_mapping
906+
# Add grid_mapping and quantization
907907
if len(self.varname) > 0:
908908
crs = getattr(ds.variables[self.varname[0]], "grid_mapping", False)
909909
if crs:
910910
cvars |= {crs}
911+
quant = (
912+
ds.variables[self.varname[0]].getncattr("quantization")
913+
if "quantization" in ds.variables[self.varname[0]].ncattrs()
914+
else False
915+
)
916+
if quant:
917+
cvars |= {quant}
918+
911919
# Identify unknown variables / coordinates
912920
unknown = []
913921
for var in ds.variables.keys():
@@ -1023,18 +1031,25 @@ def check_grid_definition(self, ds):
10231031
messages.append(
10241032
f"Projection y coordinate variable '{y}' should be named '{self.CTgrids['axis_entry']['y_deg']['out_name']}'."
10251033
)
1026-
messages.extend(
1027-
self._verify_attrs(y, self.CTgrids["axis_entry"]["y_deg"])
1028-
)
1034+
y_deg_msgs = self._verify_attrs(y, self.CTgrids["axis_entry"]["y_deg"])
1035+
y_msgs = self._verify_attrs(y, self.CTgrids["axis_entry"]["y"])
1036+
if len(y_deg_msgs) < len(y_msgs):
1037+
messages.extend(y_deg_msgs)
1038+
else:
1039+
messages.extend(y_msgs)
10291040
if "projection_x_coordinate" in self.xrds.cf.standard_names:
10301041
x = self.xrds.cf.standard_names["projection_x_coordinate"][0]
10311042
if x != self.CTgrids["axis_entry"]["x_deg"]["out_name"]:
10321043
messages.append(
10331044
f"Projection x coordinate variable '{x}' should be named '{self.CTgrids['axis_entry']['x_deg']['out_name']}'."
10341045
)
1035-
messages.extend(
1036-
self._verify_attrs(x, self.CTgrids["axis_entry"]["x_deg"])
1037-
)
1046+
1047+
x_deg_msgs = self._verify_attrs(x, self.CTgrids["axis_entry"]["x_deg"])
1048+
x_msgs = self._verify_attrs(x, self.CTgrids["axis_entry"]["x"])
1049+
if len(x_deg_msgs) < len(x_msgs):
1050+
messages.extend(x_deg_msgs)
1051+
else:
1052+
messages.extend(x_msgs)
10381053

10391054
if len(messages) == 0:
10401055
score += 1
@@ -1107,6 +1122,7 @@ def check_variable_definition(self, ds):
11071122
score = 0
11081123
out_of = 1
11091124
messages = []
1125+
low_severity_messages = []
11101126

11111127
# Only check if requested variable is identified
11121128
if len(self.varname) == 0:
@@ -1356,10 +1372,17 @@ def check_variable_definition(self, ds):
13561372
vattrCT = self._get_var_attr(vattr, False)
13571373
if vattrCT:
13581374
if vattr == "comment":
1375+
# Low severity
13591376
if vattrCT not in attrs.get("comment", ""):
1360-
messages.append(
1377+
low_severity_messages.append(
13611378
f"The variable attribute '{var}:comment' needs to include the specified comment from the CMOR table."
13621379
)
1380+
elif vattr == "long_name":
1381+
# Low severity
1382+
if vattrCT != attrs.get(vattr, ""):
1383+
low_severity_messages.append(
1384+
f"The variable attribute '{var}:{vattr} = '{attrs.get(vattr, 'unset')}' is not equivalent to the value specified in the CMOR table ('{vattrCT}')."
1385+
)
13631386
elif vattr == "type":
13641387
reqdtype = self.dtypesdict.get(vattrCT, False)
13651388
if vattrCT == "character" and reqdtype:
@@ -1384,6 +1407,17 @@ def check_variable_definition(self, ds):
13841407
if len(messages) == 0:
13851408
score += 1
13861409

1410+
if len(low_severity_messages) > 0:
1411+
high_level_result = self.make_result(level, score, out_of, desc, messages)
1412+
low_level_result = self.make_result(
1413+
BaseCheck.LOW,
1414+
0,
1415+
1,
1416+
desc[:-1] + ", informational - no action required)",
1417+
low_severity_messages,
1418+
)
1419+
return [high_level_result, low_level_result]
1420+
13871421
return self.make_result(level, score, out_of, desc, messages)
13881422

13891423
def check_required_global_attributes(self, ds):

cc_plugin_cc6/cc6.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55
import cf_xarray # noqa
66
import cftime
77
import numpy as np
8-
import xarray as xr
98
from compliance_checker.base import BaseCheck
109

1110
from cc_plugin_cc6 import __version__
1211

1312
from ._constants import deltdic
1413
from .base import MIPCVCheck
1514
from .tables import retrieve
15+
from .utils import crosses_anti_meridian, crosses_zero_meridian
1616

1717
CORDEX_CMIP6_CMOR_TABLES_URL = "https://raw.githubusercontent.com/WCRP-CORDEX/cordex-cmip6-cmor-tables/main/Tables/"
1818

@@ -820,9 +820,21 @@ def check_lon_value_range(self, ds):
820820
else:
821821
return self.make_result(level, out_of, out_of, desc, messages)
822822

823+
# Get domain_id from global attributes
824+
domain_id = self._get_attr("domain_id", default="")
825+
if not isinstance(domain_id, str):
826+
domain_id = ""
827+
823828
# Check if longitude coordinates are strictly monotonically increasing
824829
if lon.ndim != 2:
825830
messages.append("The longitude coordinate should have two dimensions.")
831+
# The polar domains and generally domains crossing both, 0-meridian and anti-meridian, are exempt from monotony tests
832+
elif (
833+
domain_id.startswith("ARC")
834+
or domain_id.startswith("ANT")
835+
or (crosses_anti_meridian(lon) and crosses_zero_meridian(lon))
836+
):
837+
score += 1
826838
else:
827839
increasing_0 = ((lon[1:, :].data - lon[:-1, :].data) > 0).all()
828840
increasing_1 = ((lon[:, 1:].data - lon[:, :-1].data) > 0).all()
@@ -859,13 +871,13 @@ def check_lon_value_range(self, ds):
859871
)
860872

861873
# Check if longitude coordinates have absolute values as small as possible
862-
abs = (lon > 180).any() and (xr.where(lon >= 180, lon - 360, lon) >= -180).all()
863-
if not abs:
864-
score += 1
865-
else:
874+
# If values are monotonic increasing, only the case 180 <= lon [< 360] is problematic
875+
if lon.min() >= 180:
866876
messages.append(
867-
"Longitude values are required to take the smalles absolute value in the range [-180, 360]."
877+
"Longitude values are required to take the smallest absolute value in the range [-180, 360]."
868878
)
879+
else:
880+
score += 1
869881

870882
return self.make_result(level, score, out_of, desc, messages)
871883

cc_plugin_cc6/utils.py

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
import numpy as np
44

5+
###################################
6+
# General Utility Functions
7+
###################################
8+
59

610
def convert_posix_to_python(posix_regex):
711
"""
@@ -19,6 +23,13 @@ def convert_posix_to_python(posix_regex):
1923
if not isinstance(posix_regex, str):
2024
raise ValueError("Input must be a string")
2125

26+
# [a,b] -> [a b]
27+
posix_regex = re.sub(
28+
r"(?<!\\)\[(.*?)(?<!\\)\]",
29+
lambda m: "[" + m.group(1).replace(",", " ") + "]",
30+
posix_regex,
31+
)
32+
2233
# Dictionary of POSIX to Python character class conversions
2334
posix_to_python_classes = {
2435
r"[[:alnum:]]": r"[a-zA-Z0-9]",
@@ -40,6 +51,20 @@ def convert_posix_to_python(posix_regex):
4051
# Replace POSIX quantifiers with Python equivalents
4152
posix_regex = posix_regex.replace(r"\{", "{").replace(r"\}", "}")
4253

54+
# Deal with groups
55+
# Temporarily mark existing escaped parentheses that should become groups, e.g., \(
56+
posix_regex = re.sub(r"\\\(", "__GROUP_LEFTPARENTHESIS__", posix_regex)
57+
posix_regex = re.sub(r"\\\)", "__GROUP_RIGHTPARENTHESIS__", posix_regex)
58+
# Escape all remaining unescaped parentheses (-> literal parentheses)
59+
posix_regex = re.sub(r"(?<!\\)\(", r"\(", posix_regex)
60+
posix_regex = re.sub(r"(?<!\\)\)", r"\)", posix_regex)
61+
# Restore the placeholders as unescaped parentheses (grouping)
62+
posix_regex = posix_regex.replace("__GROUP_LEFTPARENTHESIS__", "(")
63+
posix_regex = posix_regex.replace("__GROUP_RIGHTPARENTHESIS__", ")")
64+
65+
# Replace "{1,}" with "+" to matchone or more repetitions
66+
posix_regex = re.sub(r"\{1,\}", "+", posix_regex)
67+
4368
return posix_regex
4469

4570

@@ -54,13 +79,21 @@ def match_pattern_or_string(pattern, target):
5479
Returns:
5580
bool: True if the target matches the regex pattern or is equal to the string.
5681
"""
57-
return bool(
58-
re.fullmatch(convert_posix_to_python(pattern), target, flags=re.ASCII)
59-
) or (
60-
pattern == target
61-
and convert_posix_to_python(target) == target
62-
and ".*" not in target
63-
)
82+
try:
83+
return bool(
84+
re.fullmatch(convert_posix_to_python(pattern), target, flags=re.ASCII)
85+
) or (
86+
pattern == target
87+
and convert_posix_to_python(target) == target
88+
and ".*" not in target
89+
)
90+
except re.error:
91+
# Invalid regex treat pattern as literal string
92+
return (
93+
pattern == target
94+
and convert_posix_to_python(target) == target
95+
and ".*" not in target
96+
)
6497

6598

6699
def to_str(val):
@@ -90,3 +123,58 @@ def sanitize(obj):
90123
if isinstance(obj, (np.ndarray,)):
91124
return obj.tolist()
92125
return obj
126+
127+
128+
###################################
129+
# Coordinate Utility Functions
130+
###################################
131+
132+
133+
def convert_lon_360(lon):
134+
"""Convert longitude to [0, 360)."""
135+
lon = np.asarray(lon)
136+
return lon % 360.0
137+
138+
139+
def convert_lon_180(lon):
140+
"""Convert longitude to [-180, 180)."""
141+
lon = np.asarray(lon)
142+
return ((lon + 180.0) % 360.0) - 180.0
143+
144+
145+
def crosses_zero_meridian(lon, intv=5.0):
146+
"""
147+
Check if longitude crosses 0-meridian.
148+
149+
Args:
150+
lon (numpy.ndarray): Array of longitudes.
151+
intv (float, optional): Requiring longitude in interval [-intv, 0] and [0,intv]
152+
to be classified as crossing 0-meridian. Default is 5.
153+
154+
Returns:
155+
bool: True if longitude crosses 0-meridian.
156+
"""
157+
lon180 = convert_lon_180(lon)
158+
return bool(
159+
np.any((lon180 > -intv) & (lon180 < 0))
160+
and np.any((lon180 > 0) & (lon180 < intv))
161+
)
162+
163+
164+
def crosses_anti_meridian(lon, intv=5.0):
165+
"""
166+
Check if longitude crosses anti-meridian.
167+
168+
Args:
169+
lon (numpy.ndarray): Array of longitudes.
170+
intv (float, optional): Requiring longitude in interval [-intv, 0] and [0,intv]
171+
to be classified as crossing 0-meridian. Default is 5.
172+
173+
Returns:
174+
bool: True if longitude crosses anti-meridian.
175+
"""
176+
lon360 = convert_lon_360(lon)
177+
return bool(
178+
np.any((lon360 > 180 - intv) & (lon360 < 180))
179+
and np.any((lon360 > 180) & (lon360 < 180 + intv))
180+
)

0 commit comments

Comments
 (0)