Skip to content

Commit 6d6b688

Browse files
authored
Merge pull request #1020 from PCMDI/feature/1012_lee1043_stats-MoV_xcdat
Update MoV code to use xCDAT
2 parents 81635a7 + 2bd8aec commit 6d6b688

38 files changed

+2643
-1671
lines changed

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ repos:
2525
- id: black
2626

2727
- repo: https://github.com/timothycrosley/isort
28-
rev: 5.12.0
28+
rev: 5.13.2
2929
hooks:
3030
- id: isort
3131
args: ["--honor-noqa"]
@@ -34,7 +34,7 @@ repos:
3434
# Python linting
3535
# =======================
3636
- repo: https://github.com/pycqa/flake8
37-
rev: 6.0.0
37+
rev: 7.0.0
3838
hooks:
3939
- id: flake8
4040
args: ["--config=setup.cfg"]

conda-env/dev.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ dependencies:
1818
- genutil=8.2.1
1919
- cdutil=8.2.1
2020
- cdp=1.7.0
21-
- eofs=1.4.0
21+
- eofs=1.4.1
2222
- seaborn=0.12.2
2323
- enso_metrics=1.1.1
24-
- xcdat>=0.6.1
24+
- xcdat>=0.7.0
2525
- xmltodict=0.13.0
2626
- setuptools=67.7.2
2727
- netcdf4=1.6.3

doc/jupyter/Demo/Demo_4_modes_of_variability.ipynb

Lines changed: 452 additions & 650 deletions
Large diffs are not rendered by default.

pcmdi_metrics/graphics/portrait_plot/portrait_plot_lib.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -342,12 +342,12 @@ def portrait_plot(
342342
# ----------------------------------------------------------------------
343343
def prepare_data(data, xaxis_labels, yaxis_labels, debug=False):
344344
# In case data was given as list of arrays, convert it to numpy (stacked) array
345-
if type(data) == list:
345+
if isinstance(data, list):
346346
if debug:
347347
print("data type is list")
348348
print("len(data):", len(data))
349349
if len(data) == 1: # list has only 1 array as element
350-
if (type(data[0]) == np.ndarray) and (len(data[0].shape) == 2):
350+
if isinstance(data[0], np.ndarray) and (len(data[0].shape) == 2):
351351
data = data[0]
352352
num_divide = 1
353353
else:
@@ -366,7 +366,7 @@ def prepare_data(data, xaxis_labels, yaxis_labels, debug=False):
366366
if data.shape[-2] != len(yaxis_labels) and len(yaxis_labels) > 0:
367367
sys.exit("Error: Number of elements in yaxis_label mismatchs to the data")
368368

369-
if type(data) == np.ndarray:
369+
if isinstance(data, np.ndarray):
370370
# data = np.squeeze(data)
371371
if len(data.shape) == 2:
372372
num_divide = 1

pcmdi_metrics/io/__init__.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
# init for pcmdi_metrics.io
22
from .xcdat_openxml import xcdat_open # noqa # isort:skip
3+
from .string_constructor import StringConstructor, fill_template # noqa # isort:skip
34
from . import base # noqa
45
from .base import MV2Json # noqa
5-
from .default_regions_define import load_regions_specs # noqa
6-
from .default_regions_define import region_subset # noqa
7-
from .xcdat_dataset_io import ( # noqa
6+
from .xcdat_dataset_io import ( # noqa # isort:skip
7+
da_to_ds,
88
get_axis_list,
99
get_data_list,
10+
get_grid,
1011
get_latitude_bounds_key,
1112
get_latitude_key,
1213
get_latitude,
@@ -21,3 +22,4 @@
2122
get_time_key,
2223
select_subset,
2324
)
25+
from .regions import load_regions_specs, region_subset # noqa

pcmdi_metrics/io/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
import pcmdi_metrics
2121
from pcmdi_metrics import LOG_LEVEL
22-
from pcmdi_metrics.utils import StringConstructor
22+
from pcmdi_metrics.io import StringConstructor
2323

2424
value = 0
2525
cdms2.setNetcdfShuffleFlag(value) # where value is either 0 or 1
Lines changed: 82 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
from typing import Union
2+
3+
import xarray as xr
14
import xcdat as xc
25

6+
from pcmdi_metrics.io import da_to_ds, get_longitude, select_subset
7+
38

4-
def load_regions_specs():
9+
def load_regions_specs() -> dict:
510
regions_specs = {
611
# Mean Climate
712
"global": {},
@@ -35,7 +40,10 @@ def load_regions_specs():
3540
"NAO": {"domain": {"latitude": (20.0, 80), "longitude": (-90, 40)}},
3641
"SAM": {"domain": {"latitude": (-20.0, -90), "longitude": (0, 360)}},
3742
"PNA": {"domain": {"latitude": (20.0, 85), "longitude": (120, 240)}},
43+
"NPO": {"domain": {"latitude": (20.0, 85), "longitude": (120, 240)}},
3844
"PDO": {"domain": {"latitude": (20.0, 70), "longitude": (110, 260)}},
45+
"NPGO": {"domain": {"latitude": (20.0, 70), "longitude": (110, 260)}},
46+
"AMO": {"domain": {"latitude": (0.0, 70), "longitude": (-80, 0)}},
3947
# Monsoon domains for Wang metrics
4048
# All monsoon domains
4149
"AllMW": {"domain": {"latitude": (-40.0, 45.0), "longitude": (0.0, 360.0)}},
@@ -45,7 +53,8 @@ def load_regions_specs():
4553
# South American Monsoon
4654
"SAMM": {"domain": {"latitude": (-45.0, 0.0), "longitude": (240.0, 330.0)}},
4755
# North African Monsoon
48-
"NAFM": {"domain": {"latitude": (0.0, 45.0), "longitude": (310.0, 60.0)}},
56+
# "NAFM": {"domain": {"latitude": (0.0, 45.0), "longitude": (310.0, 60.0)}},
57+
"NAFM": {"domain": {"latitude": (0.0, 45.0), "longitude": (-50.0, 60.0)}},
4958
# South African Monsoon
5059
"SAFM": {"domain": {"latitude": (-45.0, 0.0), "longitude": (0.0, 90.0)}},
5160
# Asian Summer Monsoon
@@ -70,55 +79,77 @@ def load_regions_specs():
7079
return regions_specs
7180

7281

73-
def region_subset(ds, regions_specs, region=None):
74-
"""
75-
d: xarray.Dataset
76-
regions_specs: dict
77-
region: string
82+
def region_subset(
83+
ds: Union[xr.Dataset, xr.DataArray],
84+
region: str,
85+
data_var: str = "variable",
86+
regions_specs: dict = None,
87+
debug: bool = False,
88+
) -> Union[xr.Dataset, xr.DataArray]:
89+
"""_summary_
90+
91+
Parameters
92+
----------
93+
ds : Union[xr.Dataset, xr.DataArray]
94+
_description_
95+
region : str
96+
_description_
97+
data_var : str, optional
98+
_description_, by default None
99+
regions_specs : dict, optional
100+
_description_, by default None
101+
debug: bool, optional
102+
Turn on debug print, by default False
103+
104+
Returns
105+
-------
106+
Union[xr.Dataset, xr.DataArray]
107+
_description_
78108
"""
109+
if isinstance(ds, xr.DataArray):
110+
is_dataArray = True
111+
ds = da_to_ds(ds, data_var)
112+
else:
113+
is_dataArray = False
114+
115+
if regions_specs is None:
116+
regions_specs = load_regions_specs()
117+
118+
if "domain" in regions_specs[region]:
119+
if "latitude" in regions_specs[region]["domain"]:
120+
lat0 = regions_specs[region]["domain"]["latitude"][0]
121+
lat1 = regions_specs[region]["domain"]["latitude"][1]
122+
# proceed subset
123+
ds = select_subset(ds, lat=(min(lat0, lat1), max(lat0, lat1)))
124+
if debug:
125+
print("region_subset, latitude subsetted, ds:", ds)
126+
127+
if "longitude" in regions_specs[region]["domain"]:
128+
lon0 = regions_specs[region]["domain"]["longitude"][0]
129+
lon1 = regions_specs[region]["domain"]["longitude"][1]
130+
131+
# check original dataset longitude range
132+
lon_min = get_longitude(ds).min().values.item()
133+
lon_max = get_longitude(ds).max().values.item()
134+
135+
# Check if longitude range swap is needed
136+
if min(lon0, lon1) < 0:
137+
# when subset region lon is defined in (-180, 180) range
138+
if min(lon_min, lon_max) < 0:
139+
# if original data lon range is (-180, 180), no treatment needed
140+
pass
141+
else:
142+
# if original data lon range is (0, 360), convert and swap lon
143+
ds = xc.swap_lon_axis(ds, to=(-180, 180))
144+
145+
# proceed subset
146+
# ds = select_subset(ds, lon=(min(lon0, lon1), max(lon0, lon1)))
147+
ds = select_subset(ds, lon=(lon0, lon1))
148+
if debug:
149+
print("region_subset, longitude subsetted, ds:", ds)
79150

80-
if (region is None) or (
81-
(region is not None) and (region not in list(regions_specs.keys()))
82-
):
83-
print("Error: region not defined")
151+
# return the same type
152+
if is_dataArray:
153+
return ds[data_var]
84154
else:
85-
if "domain" in list(regions_specs[region].keys()):
86-
if "latitude" in list(regions_specs[region]["domain"].keys()):
87-
lat0 = regions_specs[region]["domain"]["latitude"][0]
88-
lat1 = regions_specs[region]["domain"]["latitude"][1]
89-
# proceed subset
90-
if "latitude" in (ds.coords.dims):
91-
ds = ds.sel(latitude=slice(lat0, lat1))
92-
elif "lat" in (ds.coords.dims):
93-
ds = ds.sel(lat=slice(lat0, lat1))
94-
95-
if "longitude" in list(regions_specs[region]["domain"].keys()):
96-
lon0 = regions_specs[region]["domain"]["longitude"][0]
97-
lon1 = regions_specs[region]["domain"]["longitude"][1]
98-
99-
# check original dataset longitude range
100-
if "longitude" in (ds.coords.dims):
101-
lon_min = ds.longitude.min()
102-
lon_max = ds.longitude.max()
103-
elif "lon" in (ds.coords.dims):
104-
lon_min = ds.lon.min()
105-
lon_max = ds.lon.max()
106-
107-
# longitude range swap if needed
108-
if (
109-
min(lon0, lon1) < 0
110-
): # when subset region lon is defined in (-180, 180) range
111-
if (
112-
min(lon_min, lon_max) < 0
113-
): # if original data lon range is (-180, 180) no treatment needed
114-
pass
115-
else: # if original data lon range is (0, 360), convert swap lon
116-
ds = xc.swap_lon_axis(ds, to=(-180, 180))
117-
118-
# proceed subset
119-
if "longitude" in (ds.coords.dims):
120-
ds = ds.sel(longitude=slice(lon0, lon1))
121-
elif "lon" in (ds.coords.dims):
122-
ds = ds.sel(lon=slice(lon0, lon1))
123-
124-
return ds
155+
return ds
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import warnings
2+
3+
4+
class StringConstructor:
5+
"""
6+
This class aims at spotting keywords in a string and replacing them.
7+
"""
8+
9+
def __init__(self, template=None):
10+
"""
11+
Instantiates a StringConstructor object.
12+
"""
13+
self.template = template
14+
# Generate the keys and set them to empty
15+
keys = self.keys()
16+
for k in keys:
17+
setattr(self, k, "")
18+
19+
def keys(self, template=None):
20+
if template is None:
21+
template = self.template
22+
if template is None:
23+
return []
24+
# Determine the keywords in the template
25+
keys = []
26+
template_split = template.split("%(")[1:]
27+
if len(template_split) > 0:
28+
for k in template_split:
29+
sp = k.split(")")
30+
if sp[0] not in keys:
31+
keys.append(sp[0])
32+
return keys
33+
34+
def construct(self, template=None, **kw):
35+
"""
36+
Accepts a string with an unlimited number of keywords to replace.
37+
"""
38+
if template is None:
39+
template = self.template
40+
# Replace the keywords with their values
41+
for k in self.keys():
42+
if k not in kw:
43+
warnings.warn(f"Keyword '{k}' not provided for filling the template.")
44+
template = template.replace("%(" + k + ")", kw.get(k, getattr(self, k, "")))
45+
return template
46+
47+
def reverse(self, name, debug=False):
48+
"""
49+
The reverse function attempts to take a template and derive its keyword values based on name parameter.
50+
"""
51+
out = {}
52+
template = self.template
53+
for k in self.keys():
54+
sp = template.split("%%(%s)" % k)
55+
i1 = name.find(sp[0]) + len(sp[0])
56+
j1 = sp[1].find("%(")
57+
if j1 == -1:
58+
if sp[1] == "":
59+
val = name[i1:]
60+
else:
61+
i2 = name.find(sp[1])
62+
val = name[i1:i2]
63+
else:
64+
i2 = name[i1:].find(sp[1][:j1])
65+
val = name[i1 : i1 + i2]
66+
template = template.replace("%%(%s)" % k, val)
67+
out[k] = val
68+
if self.construct(self.template, **out) != name:
69+
raise ValueError("Invalid pattern sent")
70+
return out
71+
72+
def __call__(self, *args, **kw):
73+
"""default call is construct function"""
74+
return self.construct(*args, **kw)
75+
76+
77+
def fill_template(template: str, **kwargs) -> str:
78+
"""
79+
Fill in a template string with keyword values.
80+
81+
Parameters
82+
----------
83+
- template (str): The template string containing keywords of the form '%(keyword)'.
84+
- kwargs (dict): Keyword arguments with values to replace in the template.
85+
86+
Returns
87+
-------
88+
- str: The filled-in string with replaced keywords.
89+
90+
Examples
91+
--------
92+
>>> from pcmdi_metrics.utils import fill_template
93+
>>> template = "This is a %(adjective) %(noun) that %(verb)."
94+
>>> filled_string = fill_template(template, adjective="great", noun="example", verb="works")
95+
>>> print(filled_string) # It will print "This is a great example that works."
96+
"""
97+
filler = StringConstructor(template)
98+
filled_template = filler.construct(**kwargs)
99+
return filled_template

0 commit comments

Comments
 (0)