cavapy 2.0.7__tar.gz → 2.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cavapy-2.0.7 → cavapy-2.1.0}/PKG-INFO +2 -3
- {cavapy-2.0.7 → cavapy-2.1.0}/README.md +1 -1
- {cavapy-2.0.7 → cavapy-2.1.0}/cavapy/cava_config.py +13 -11
- {cavapy-2.0.7 → cavapy-2.1.0}/cavapy/cava_download.py +86 -39
- {cavapy-2.0.7 → cavapy-2.1.0}/cavapy/cava_plot.py +9 -6
- {cavapy-2.0.7 → cavapy-2.1.0}/cavapy/cava_validation.py +117 -116
- {cavapy-2.0.7 → cavapy-2.1.0}/cavapy/cavapy.py +1 -1
- {cavapy-2.0.7 → cavapy-2.1.0}/pyproject.toml +2 -2
- {cavapy-2.0.7 → cavapy-2.1.0}/LICENSE +0 -0
- {cavapy-2.0.7 → cavapy-2.1.0}/cavapy/__init__.py +0 -0
- {cavapy-2.0.7 → cavapy-2.1.0}/cavapy/cava_bias.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cavapy
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: CAVA Python package. Retrive climate data.
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
License-File: LICENSE
|
|
@@ -8,7 +8,6 @@ Author: Riccardo Soldan
|
|
|
8
8
|
Author-email: riccardosoldan@hotmail.it
|
|
9
9
|
Requires-Python: >=3.11,<4.0
|
|
10
10
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
12
11
|
Classifier: Operating System :: OS Independent
|
|
13
12
|
Classifier: Programming Language :: Python :: 3
|
|
14
13
|
Classifier: Programming Language :: Python :: 3.11
|
|
@@ -338,6 +337,6 @@ For advanced visualization and reporting, see [CAVAanalytics](https://risk-team.
|
|
|
338
337
|
|
|
339
338
|
## Citation and License
|
|
340
339
|
|
|
341
|
-
- License: [
|
|
340
|
+
- License: [Apache 2.0](LICENSE)
|
|
342
341
|
- Package metadata and build details: [pyproject.toml](pyproject.toml)
|
|
343
342
|
|
|
@@ -13,17 +13,18 @@ except ImportError:
|
|
|
13
13
|
warnings.filterwarnings("ignore", category=UserWarning, module="cartopy.io")
|
|
14
14
|
|
|
15
15
|
logger = logging.getLogger("climate")
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
logger.
|
|
16
|
+
# Progress messages are part of the tool's UX, so a console handler is attached
|
|
17
|
+
# by default -- but only when the host application has not already configured
|
|
18
|
+
# this logger, and without touching handlers installed by the host.
|
|
19
|
+
if not logger.handlers:
|
|
20
|
+
handler = logging.StreamHandler()
|
|
21
|
+
formatter = logging.Formatter(
|
|
22
|
+
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
23
|
+
datefmt="%H:%M:%S",
|
|
24
|
+
)
|
|
25
|
+
handler.setFormatter(formatter)
|
|
26
|
+
logger.addHandler(handler)
|
|
27
|
+
logger.setLevel(logging.INFO)
|
|
27
28
|
|
|
28
29
|
VARIABLES_MAP = {
|
|
29
30
|
"pr": "tp",
|
|
@@ -45,6 +46,7 @@ VALID_DOMAINS = [
|
|
|
45
46
|
"AUS-22",
|
|
46
47
|
"SAM-22",
|
|
47
48
|
"CAM-22",
|
|
49
|
+
"CAS-22",
|
|
48
50
|
]
|
|
49
51
|
VALID_RCPS = ["rcp26", "rcp85"]
|
|
50
52
|
VALID_GCM = ["MOHC", "MPI", "NCC"]
|
|
@@ -18,12 +18,10 @@ from .cava_config import (
|
|
|
18
18
|
DEFAULT_YEARS_OBS,
|
|
19
19
|
ERA5_DATA_LOCAL_PATH,
|
|
20
20
|
ERA5_DATA_REMOTE_URL,
|
|
21
|
-
INVENTORY_DATA_LOCAL_PATH,
|
|
22
|
-
INVENTORY_DATA_REMOTE_URL,
|
|
23
21
|
VARIABLES_MAP,
|
|
24
22
|
logger,
|
|
25
23
|
)
|
|
26
|
-
from .cava_validation import
|
|
24
|
+
from .cava_validation import _filter_inventory
|
|
27
25
|
|
|
28
26
|
|
|
29
27
|
SPATIAL_COORDS = {
|
|
@@ -68,6 +66,64 @@ def _suppress_stderr_fd():
|
|
|
68
66
|
os.close(saved_fd)
|
|
69
67
|
|
|
70
68
|
|
|
69
|
+
def _ensure_geographic_grid(ds: xr.Dataset, data: xr.DataArray, url: str) -> None:
|
|
70
|
+
"""Fail fast when a file's 1-D coordinates are not geographic degrees.
|
|
71
|
+
|
|
72
|
+
Some server-side files carry 1-D 'longitude'/'latitude' coordinates that
|
|
73
|
+
are actually rotated-pole degrees or projected meters, with the true
|
|
74
|
+
geographic coordinates stored as 2-D 'lat'/'lon' arrays. Subsetting such
|
|
75
|
+
files by degrees would silently return data for the wrong region. The
|
|
76
|
+
check is value-based, so it stops firing automatically once the files are
|
|
77
|
+
interpolated to a regular grid, whatever their metadata says.
|
|
78
|
+
"""
|
|
79
|
+
problems = []
|
|
80
|
+
|
|
81
|
+
lat = data.coords.get("latitude")
|
|
82
|
+
lon = data.coords.get("longitude")
|
|
83
|
+
|
|
84
|
+
# Projected coordinates (meters) are far outside any degree range.
|
|
85
|
+
if lat is not None and (np.abs(np.asarray(lat.values, dtype=float)) > 90).any():
|
|
86
|
+
problems.append("1-D latitude values outside [-90, 90]")
|
|
87
|
+
if lon is not None:
|
|
88
|
+
lon_values = np.asarray(lon.values, dtype=float)
|
|
89
|
+
if (lon_values < -180).any() or (lon_values > 360).any():
|
|
90
|
+
problems.append("1-D longitude values outside [-180, 360]")
|
|
91
|
+
|
|
92
|
+
# Rotated-pole coordinates look like plausible degrees, but such files also
|
|
93
|
+
# carry 2-D geographic 'lat' arrays that disagree with the 1-D axis.
|
|
94
|
+
if (
|
|
95
|
+
not problems
|
|
96
|
+
and lat is not None
|
|
97
|
+
and "lat" in ds.variables
|
|
98
|
+
and ds["lat"].ndim == 2
|
|
99
|
+
):
|
|
100
|
+
deviation = float(np.abs(ds["lat"] - lat).max())
|
|
101
|
+
if deviation > 1.0:
|
|
102
|
+
problems.append(
|
|
103
|
+
"2-D geographic 'lat' deviates from the 1-D latitude axis "
|
|
104
|
+
f"by up to {deviation:.1f} degrees"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if problems:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
f"Dataset is not on a regular longitude/latitude grid "
|
|
110
|
+
f"({'; '.join(problems)}): {url}\n"
|
|
111
|
+
"Subsetting it by geographic coordinates would return data for the "
|
|
112
|
+
"wrong region. This is a server-side issue with this file: it "
|
|
113
|
+
"needs to be interpolated to a regular grid like the other domains."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _floor_time_to_day(data: xr.DataArray) -> xr.DataArray:
|
|
118
|
+
"""Drop the time-of-day component from the time axis.
|
|
119
|
+
|
|
120
|
+
ERA5 stamps daily values at 00:00 while CORDEX models use 12:00, and
|
|
121
|
+
xsdba requires identical time arrays between the reference and the
|
|
122
|
+
training data.
|
|
123
|
+
"""
|
|
124
|
+
return data.assign_coords(time=data["time"].dt.floor("D"))
|
|
125
|
+
|
|
126
|
+
|
|
71
127
|
def _coordinate_slice(coord: xr.DataArray, bounds: tuple[float, float]) -> slice:
|
|
72
128
|
"""Build a label slice matching the coordinate's native order."""
|
|
73
129
|
values = np.asarray(coord.values, dtype=float)
|
|
@@ -193,41 +249,6 @@ def _climate_data_for_variable(
|
|
|
193
249
|
"""Fetch and process one variable, optionally bias-correcting and merging runs."""
|
|
194
250
|
log = logger.getChild(variable)
|
|
195
251
|
|
|
196
|
-
pd.options.mode.chained_assignment = None
|
|
197
|
-
inventory_csv_url = (
|
|
198
|
-
INVENTORY_DATA_REMOTE_URL if remote else INVENTORY_DATA_LOCAL_PATH
|
|
199
|
-
)
|
|
200
|
-
data = pd.read_csv(inventory_csv_url)
|
|
201
|
-
column_to_use = "location" if remote else "hub"
|
|
202
|
-
|
|
203
|
-
# Filter data based on whether we need historical data
|
|
204
|
-
experiments = [rcp]
|
|
205
|
-
if historical or bias_correction:
|
|
206
|
-
experiments.append("historical")
|
|
207
|
-
|
|
208
|
-
# Determine activity filter based on dataset
|
|
209
|
-
activity_filter = "FAO" if dataset == "CORDEX-CORE" else "CRDX-ISIMIP-025"
|
|
210
|
-
|
|
211
|
-
filtered_data = data[
|
|
212
|
-
lambda x: (x["activity"].str.contains(activity_filter, na=False))
|
|
213
|
-
& (x["domain"] == cordex_domain)
|
|
214
|
-
& (x["model"].str.contains(gcm, na=False))
|
|
215
|
-
& (x["rcm"].str.contains(rcm, na=False))
|
|
216
|
-
& (x["experiment"].isin(experiments))
|
|
217
|
-
][["experiment", column_to_use]]
|
|
218
|
-
|
|
219
|
-
# Fail early if nothing is found
|
|
220
|
-
_ensure_inventory_not_empty(
|
|
221
|
-
filtered_data,
|
|
222
|
-
dataset=dataset,
|
|
223
|
-
cordex_domain=cordex_domain,
|
|
224
|
-
gcm=gcm,
|
|
225
|
-
rcm=rcm,
|
|
226
|
-
experiments=experiments,
|
|
227
|
-
activity_filter=activity_filter,
|
|
228
|
-
log=log,
|
|
229
|
-
)
|
|
230
|
-
|
|
231
252
|
future_obs = None
|
|
232
253
|
if obs or bias_correction:
|
|
233
254
|
future_obs = executor.submit(
|
|
@@ -246,6 +267,21 @@ def _climate_data_for_variable(
|
|
|
246
267
|
)
|
|
247
268
|
|
|
248
269
|
if not obs:
|
|
270
|
+
# Which experiments we need from the inventory
|
|
271
|
+
experiments = [rcp]
|
|
272
|
+
if historical or bias_correction:
|
|
273
|
+
experiments.append("historical")
|
|
274
|
+
|
|
275
|
+
filtered_data, column_to_use = _filter_inventory(
|
|
276
|
+
remote=remote,
|
|
277
|
+
dataset=dataset,
|
|
278
|
+
cordex_domain=cordex_domain,
|
|
279
|
+
gcm=gcm,
|
|
280
|
+
rcm=rcm,
|
|
281
|
+
experiments=experiments,
|
|
282
|
+
log=log,
|
|
283
|
+
)
|
|
284
|
+
|
|
249
285
|
download_fn = partial(
|
|
250
286
|
_thread_download_data,
|
|
251
287
|
bbox=bbox,
|
|
@@ -283,6 +319,9 @@ def _climate_data_for_variable(
|
|
|
283
319
|
if bias_correction and historical:
|
|
284
320
|
# Load observations for bias correction
|
|
285
321
|
ref = future_obs.result()
|
|
322
|
+
ref = _floor_time_to_day(ref)
|
|
323
|
+
hist = _floor_time_to_day(hist)
|
|
324
|
+
proj = _floor_time_to_day(proj)
|
|
286
325
|
log.info("Training eqm with leave-one-out cross-validation")
|
|
287
326
|
|
|
288
327
|
# Use leave-one-out cross-validation for historical bias correction
|
|
@@ -314,10 +353,16 @@ def _climate_data_for_variable(
|
|
|
314
353
|
elif bias_correction and not historical:
|
|
315
354
|
# Load observations for bias correction
|
|
316
355
|
ref = future_obs.result()
|
|
356
|
+
ref = _floor_time_to_day(ref)
|
|
357
|
+
hist = _floor_time_to_day(hist)
|
|
358
|
+
proj = _floor_time_to_day(proj)
|
|
317
359
|
log.info("Performing bias correction with eqm")
|
|
360
|
+
# Train on the historical run, which overlaps the ERA5 reference period.
|
|
361
|
+
# Training on the projection would fold the climate-change signal into
|
|
362
|
+
# the estimated bias and remove it from the corrected output.
|
|
318
363
|
QM_mo = sdba.EmpiricalQuantileMapping.train(
|
|
319
364
|
ref,
|
|
320
|
-
|
|
365
|
+
hist,
|
|
321
366
|
group="time.month",
|
|
322
367
|
kind="*" if variable in ["pr", "rsds", "sfcWind"] else "+",
|
|
323
368
|
)
|
|
@@ -496,6 +541,8 @@ def _download_data(
|
|
|
496
541
|
log.exception(msg)
|
|
497
542
|
raise ValueError(msg)
|
|
498
543
|
|
|
544
|
+
_ensure_geographic_grid(ds, ds_var, url)
|
|
545
|
+
|
|
499
546
|
log.info("Connection established")
|
|
500
547
|
ds_cropped = _select_spatial_subset(ds_var, bbox, log)
|
|
501
548
|
|
|
@@ -49,7 +49,11 @@ def plot_spatial_map(
|
|
|
49
49
|
raise ValueError(f"Unsupported aggregation method: {aggregation}")
|
|
50
50
|
|
|
51
51
|
# Create figure with cartopy
|
|
52
|
-
fig, ax = plt.subplots(
|
|
52
|
+
fig, ax = plt.subplots(
|
|
53
|
+
figsize=figsize,
|
|
54
|
+
layout="constrained",
|
|
55
|
+
subplot_kw={"projection": ccrs.PlateCarree()},
|
|
56
|
+
)
|
|
53
57
|
|
|
54
58
|
# Plot data
|
|
55
59
|
im = plot_data.plot(
|
|
@@ -84,7 +88,7 @@ def plot_spatial_map(
|
|
|
84
88
|
gl.bottom_labels = True
|
|
85
89
|
|
|
86
90
|
# Add colorbar
|
|
87
|
-
cbar =
|
|
91
|
+
cbar = fig.colorbar(im, ax=ax, shrink=0.8, pad=0.02)
|
|
88
92
|
if hasattr(plot_data, "units"):
|
|
89
93
|
cbar.set_label(
|
|
90
94
|
f"{plot_data.name} ({plot_data.units})", rotation=270, labelpad=20
|
|
@@ -100,12 +104,11 @@ def plot_spatial_map(
|
|
|
100
104
|
else:
|
|
101
105
|
title = f"{aggregation.title()} {var_name}"
|
|
102
106
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
plt.tight_layout()
|
|
107
|
+
# Avoid Cartopy's gridliner-based title repositioning producing non-finite bounds.
|
|
108
|
+
ax.set_title(title, fontsize=14, pad=20, y=1.0)
|
|
106
109
|
|
|
107
110
|
if save_path:
|
|
108
|
-
|
|
111
|
+
fig.savefig(save_path, dpi=300, bbox_inches="tight")
|
|
109
112
|
|
|
110
113
|
return fig
|
|
111
114
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Validation helpers for input parameters and spatial domain checks."""
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
+
from functools import lru_cache
|
|
4
5
|
|
|
5
6
|
import pandas as pd
|
|
6
7
|
import cartopy.feature as cfeature
|
|
@@ -53,6 +54,68 @@ def _ensure_inventory_not_empty(
|
|
|
53
54
|
raise ValueError(msg)
|
|
54
55
|
|
|
55
56
|
|
|
57
|
+
@lru_cache(maxsize=None)
|
|
58
|
+
def _read_inventory(csv_path_or_url: str) -> pd.DataFrame:
|
|
59
|
+
"""Read the inventory CSV once per process and reuse it across requests."""
|
|
60
|
+
return pd.read_csv(csv_path_or_url)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _filter_inventory(
|
|
64
|
+
*,
|
|
65
|
+
remote: bool,
|
|
66
|
+
dataset: str,
|
|
67
|
+
cordex_domain: str,
|
|
68
|
+
gcm: str,
|
|
69
|
+
rcm: str,
|
|
70
|
+
experiments: list[str],
|
|
71
|
+
log: logging.Logger | None = None,
|
|
72
|
+
) -> tuple[pd.DataFrame, str]:
|
|
73
|
+
"""
|
|
74
|
+
Return the inventory rows matching the request and the URL column to use.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ValueError: If no rows match, or if more than one dataset matches a
|
|
78
|
+
single experiment (the download path uses exactly one per experiment).
|
|
79
|
+
"""
|
|
80
|
+
inventory_csv_url = (
|
|
81
|
+
INVENTORY_DATA_REMOTE_URL if remote else INVENTORY_DATA_LOCAL_PATH
|
|
82
|
+
)
|
|
83
|
+
data = _read_inventory(inventory_csv_url)
|
|
84
|
+
column_to_use = "location" if remote else "hub"
|
|
85
|
+
activity_filter = "FAO" if dataset == "CORDEX-CORE" else "CRDX-ISIMIP-025"
|
|
86
|
+
|
|
87
|
+
filtered_data = data[
|
|
88
|
+
(data["activity"].str.contains(activity_filter, na=False))
|
|
89
|
+
& (data["domain"] == cordex_domain)
|
|
90
|
+
& (data["model"].str.contains(gcm, na=False))
|
|
91
|
+
& (data["rcm"].str.contains(rcm, na=False))
|
|
92
|
+
& (data["experiment"].isin(experiments))
|
|
93
|
+
][["experiment", column_to_use]].copy()
|
|
94
|
+
|
|
95
|
+
_ensure_inventory_not_empty(
|
|
96
|
+
filtered_data,
|
|
97
|
+
dataset=dataset,
|
|
98
|
+
cordex_domain=cordex_domain,
|
|
99
|
+
gcm=gcm,
|
|
100
|
+
rcm=rcm,
|
|
101
|
+
experiments=experiments,
|
|
102
|
+
activity_filter=activity_filter,
|
|
103
|
+
log=log,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
experiment_counts = filtered_data["experiment"].value_counts()
|
|
107
|
+
ambiguous = experiment_counts[experiment_counts > 1]
|
|
108
|
+
if not ambiguous.empty:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"Ambiguous inventory match for domain={cordex_domain}, gcm={gcm}, "
|
|
111
|
+
f"rcm={rcm}: multiple datasets found for experiment(s) "
|
|
112
|
+
f"{sorted(ambiguous.index)}. Please report this at "
|
|
113
|
+
"https://github.com/un-fao/cavapy/issues"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return filtered_data, column_to_use
|
|
117
|
+
|
|
118
|
+
|
|
56
119
|
def _validate_urls(
|
|
57
120
|
gcm: str = None,
|
|
58
121
|
rcm: str = None,
|
|
@@ -70,42 +133,18 @@ def _validate_urls(
|
|
|
70
133
|
log = logger.getChild("URL-validation")
|
|
71
134
|
|
|
72
135
|
if obs is False:
|
|
73
|
-
inventory_csv_url = (
|
|
74
|
-
INVENTORY_DATA_REMOTE_URL if remote else INVENTORY_DATA_LOCAL_PATH
|
|
75
|
-
)
|
|
76
|
-
data = pd.read_csv(inventory_csv_url)
|
|
77
|
-
|
|
78
|
-
# Set the column to use based on whether the data is remote or local
|
|
79
|
-
column_to_use = "location" if remote else "hub"
|
|
80
|
-
|
|
81
136
|
# Define which experiments we need
|
|
82
137
|
experiments = [rcp]
|
|
83
138
|
if historical or bias_correction:
|
|
84
139
|
experiments.append("historical")
|
|
85
140
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
# Filter the data based on the conditions
|
|
90
|
-
filtered_data = data[
|
|
91
|
-
lambda x: (
|
|
92
|
-
x["activity"].str.contains(activity_filter, na=False)
|
|
93
|
-
& (x["domain"] == cordex_domain)
|
|
94
|
-
& (x["model"].str.contains(gcm, na=False))
|
|
95
|
-
& (x["rcm"].str.contains(rcm, na=False))
|
|
96
|
-
& (x["experiment"].isin(experiments))
|
|
97
|
-
)
|
|
98
|
-
][["experiment", column_to_use]]
|
|
99
|
-
|
|
100
|
-
# Fail early if nothing is found
|
|
101
|
-
_ensure_inventory_not_empty(
|
|
102
|
-
filtered_data,
|
|
141
|
+
filtered_data, column_to_use = _filter_inventory(
|
|
142
|
+
remote=remote,
|
|
103
143
|
dataset=dataset,
|
|
104
144
|
cordex_domain=cordex_domain,
|
|
105
145
|
gcm=gcm,
|
|
106
146
|
rcm=rcm,
|
|
107
147
|
experiments=experiments,
|
|
108
|
-
activity_filter=activity_filter,
|
|
109
148
|
log=log,
|
|
110
149
|
)
|
|
111
150
|
|
|
@@ -232,6 +271,19 @@ def _validate_gcm_rcm_combinations(cordex_domain: str, gcm: str, rcm: str):
|
|
|
232
271
|
("MPI", "Reg"),
|
|
233
272
|
("NCC", "Reg"),
|
|
234
273
|
],
|
|
274
|
+
"EUR-22": [
|
|
275
|
+
("MOHC", "Reg"), # Only REMO runs exist for EUR-22
|
|
276
|
+
("MPI", "Reg"),
|
|
277
|
+
("NCC", "Reg"),
|
|
278
|
+
],
|
|
279
|
+
"NAM-22": [
|
|
280
|
+
("MOHC", "Reg"), # Only REMO runs exist for NAM-22
|
|
281
|
+
("MPI", "Reg"),
|
|
282
|
+
("NCC", "Reg"),
|
|
283
|
+
],
|
|
284
|
+
"CAM-22": [
|
|
285
|
+
("NCC", "Reg"), # CAM-22 pairs RegCM4-7 with NOAA-GFDL, not NorESM
|
|
286
|
+
],
|
|
235
287
|
}
|
|
236
288
|
|
|
237
289
|
if cordex_domain in invalid_combinations:
|
|
@@ -255,105 +307,54 @@ def _validate_gcm_rcm_combinations(cordex_domain: str, gcm: str, rcm: str):
|
|
|
255
307
|
)
|
|
256
308
|
|
|
257
309
|
|
|
310
|
+
# Geographic extents (min_lon, min_lat, max_lon, max_lat) of the regridded
|
|
311
|
+
# 0.25-degree products served on THREDDS, measured from the datasets themselves
|
|
312
|
+
# (August 2026). No served domain crosses the antimeridian: AUS-22 and EAS-22
|
|
313
|
+
# are clipped at 180 degrees East.
|
|
314
|
+
CORDEX_DOMAIN_EXTENTS = {
|
|
315
|
+
"NAM-22": (-171.75, 12.25, -22.25, 76.25),
|
|
316
|
+
"EUR-22": (-44.75, 22.00, 65.00, 72.50),
|
|
317
|
+
"SEA-22": (89.25, -15.25, 147.00, 26.50),
|
|
318
|
+
"AUS-22": (86.25, -53.25, 180.00, 12.75),
|
|
319
|
+
"WAS-22": (19.25, -15.75, 116.25, 45.75),
|
|
320
|
+
"EAS-22": (44.75, 0.25, 179.75, 62.25),
|
|
321
|
+
"SAM-22": (-106.25, -58.25, -16.25, 18.75),
|
|
322
|
+
"CAM-22": (-124.75, -19.75, -21.75, 35.25),
|
|
323
|
+
"AFR-22": (-24.25, -46.25, 59.75, 42.75),
|
|
324
|
+
"CAS-22": (10.75, 17.75, 140.25, 69.75),
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
|
|
258
328
|
def _validate_cordex_domain(xlim, ylim, cordex_domain):
|
|
259
329
|
"""Ensure the bbox is fully contained inside the selected CORDEX domain."""
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
{
|
|
263
|
-
"min_lon": [
|
|
264
|
-
-33,
|
|
265
|
-
-28.3,
|
|
266
|
-
89.25,
|
|
267
|
-
86.75,
|
|
268
|
-
19.25,
|
|
269
|
-
44.0,
|
|
270
|
-
-106.25,
|
|
271
|
-
-115.0,
|
|
272
|
-
-24.25,
|
|
273
|
-
10.75,
|
|
274
|
-
],
|
|
275
|
-
"min_lat": [
|
|
276
|
-
-28,
|
|
277
|
-
-23,
|
|
278
|
-
-15.25,
|
|
279
|
-
-54.25,
|
|
280
|
-
-15.75,
|
|
281
|
-
-4.0,
|
|
282
|
-
-58.25,
|
|
283
|
-
-14.5,
|
|
284
|
-
-46.25,
|
|
285
|
-
17.75,
|
|
286
|
-
],
|
|
287
|
-
"max_lon": [
|
|
288
|
-
20,
|
|
289
|
-
18,
|
|
290
|
-
147.0,
|
|
291
|
-
-152.75,
|
|
292
|
-
116.25,
|
|
293
|
-
-172.0,
|
|
294
|
-
-16.25,
|
|
295
|
-
-30.5,
|
|
296
|
-
59.75,
|
|
297
|
-
140.25,
|
|
298
|
-
],
|
|
299
|
-
"max_lat": [
|
|
300
|
-
28,
|
|
301
|
-
21.7,
|
|
302
|
-
26.5,
|
|
303
|
-
13.75,
|
|
304
|
-
45.75,
|
|
305
|
-
65.0,
|
|
306
|
-
18.75,
|
|
307
|
-
28.5,
|
|
308
|
-
42.75,
|
|
309
|
-
69.75,
|
|
310
|
-
],
|
|
311
|
-
"cordex_domain": [
|
|
312
|
-
"NAM-22",
|
|
313
|
-
"EUR-22",
|
|
314
|
-
"SEA-22",
|
|
315
|
-
"AUS-22",
|
|
316
|
-
"WAS-22",
|
|
317
|
-
"EAS-22",
|
|
318
|
-
"SAM-22",
|
|
319
|
-
"CAM-22",
|
|
320
|
-
"AFR-22",
|
|
321
|
-
"CAS-22",
|
|
322
|
-
],
|
|
323
|
-
}
|
|
324
|
-
)
|
|
330
|
+
if cordex_domain not in CORDEX_DOMAIN_EXTENTS:
|
|
331
|
+
raise ValueError(f"CORDEX domain '{cordex_domain}' is not recognized.")
|
|
325
332
|
|
|
326
|
-
def is_bbox_contained(bbox,
|
|
327
|
-
|
|
333
|
+
def is_bbox_contained(bbox, extent):
|
|
334
|
+
min_lon, min_lat, max_lon, max_lat = extent
|
|
328
335
|
return (
|
|
329
|
-
bbox[0] >=
|
|
330
|
-
and bbox[1] >=
|
|
331
|
-
and bbox[2] <=
|
|
332
|
-
and bbox[3] <=
|
|
336
|
+
bbox[0] >= min_lon
|
|
337
|
+
and bbox[1] >= min_lat
|
|
338
|
+
and bbox[2] <= max_lon
|
|
339
|
+
and bbox[3] <= max_lat
|
|
333
340
|
)
|
|
334
341
|
|
|
335
342
|
user_bbox = [xlim[0], ylim[0], xlim[1], ylim[1]]
|
|
336
|
-
domain_row = cordex_domains_df[cordex_domains_df["cordex_domain"] == cordex_domain]
|
|
337
|
-
|
|
338
|
-
if domain_row.empty:
|
|
339
|
-
raise ValueError(f"CORDEX domain '{cordex_domain}' is not recognized.")
|
|
340
|
-
|
|
341
|
-
domain_bbox = domain_row.iloc[0]
|
|
342
|
-
|
|
343
|
-
if not is_bbox_contained(user_bbox, domain_bbox):
|
|
344
|
-
suggested_domains = cordex_domains_df[
|
|
345
|
-
cordex_domains_df.apply(
|
|
346
|
-
lambda row: is_bbox_contained(user_bbox, row), axis=1
|
|
347
|
-
)
|
|
348
|
-
]
|
|
349
343
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
f"The bounding box {user_bbox} is outside of all available CORDEX domains."
|
|
353
|
-
)
|
|
344
|
+
if is_bbox_contained(user_bbox, CORDEX_DOMAIN_EXTENTS[cordex_domain]):
|
|
345
|
+
return
|
|
354
346
|
|
|
355
|
-
|
|
347
|
+
suggested_domains = [
|
|
348
|
+
domain
|
|
349
|
+
for domain, extent in CORDEX_DOMAIN_EXTENTS.items()
|
|
350
|
+
if is_bbox_contained(user_bbox, extent)
|
|
351
|
+
]
|
|
356
352
|
|
|
353
|
+
if not suggested_domains:
|
|
357
354
|
raise ValueError(
|
|
358
|
-
f"
|
|
355
|
+
f"The bounding box {user_bbox} is outside of all available CORDEX domains."
|
|
359
356
|
)
|
|
357
|
+
|
|
358
|
+
raise ValueError(
|
|
359
|
+
f"Bounding box {user_bbox} is not within '{cordex_domain}'. Suggested domain: '{suggested_domains[0]}'."
|
|
360
|
+
)
|
|
@@ -31,7 +31,7 @@ from .cava_validation import (
|
|
|
31
31
|
|
|
32
32
|
_ANNOUNCEMENTS_ATTEMPTED = False
|
|
33
33
|
_ANNOUNCEMENTS_ENV_DISABLE = "CAVAPY_NO_ANNOUNCEMENTS"
|
|
34
|
-
_GITHUB_ISSUES_URL = "https://api.github.com/repos/
|
|
34
|
+
_GITHUB_ISSUES_URL = "https://api.github.com/repos/un-fao/cavapy/issues"
|
|
35
35
|
_ANNOUNCEMENTS_LABEL = "announcement"
|
|
36
36
|
_ANNOUNCEMENTS_TIMEOUT_S = 2
|
|
37
37
|
_ANNOUNCEMENTS_PER_PAGE = 20
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "cavapy"
|
|
3
|
-
version = "2.0
|
|
3
|
+
version = "2.1.0"
|
|
4
4
|
description = "CAVA Python package. Retrive climate data."
|
|
5
5
|
authors = ["Riccardo Soldan <riccardosoldan@hotmail.it>", "Oleh Lokshyn <olokshyn@gmail.com>"]
|
|
6
6
|
readme = "README.md"
|
|
@@ -8,7 +8,7 @@ license = "Apache-2.0"
|
|
|
8
8
|
repository = "https://github.com/un-fao/cavapy"
|
|
9
9
|
classifiers = [
|
|
10
10
|
"Programming Language :: Python :: 3",
|
|
11
|
-
"License :: OSI Approved ::
|
|
11
|
+
"License :: OSI Approved :: Apache Software License",
|
|
12
12
|
"Operating System :: OS Independent",
|
|
13
13
|
]
|
|
14
14
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|