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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cavapy
3
- Version: 2.0.7
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: [MIT](LICENSE)
340
+ - License: [Apache 2.0](LICENSE)
342
341
  - Package metadata and build details: [pyproject.toml](pyproject.toml)
343
342
 
@@ -307,5 +307,5 @@ For advanced visualization and reporting, see [CAVAanalytics](https://risk-team.
307
307
 
308
308
  ## Citation and License
309
309
 
310
- - License: [MIT](LICENSE)
310
+ - License: [Apache 2.0](LICENSE)
311
311
  - Package metadata and build details: [pyproject.toml](pyproject.toml)
@@ -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
- logger.handlers = [] # Remove any existing handlers
17
- handler = logging.StreamHandler()
18
- formatter = logging.Formatter(
19
- "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
20
- datefmt="%H:%M:%S",
21
- )
22
- handler.setFormatter(formatter)
23
- for hdlr in logger.handlers[:]: # remove all old handlers
24
- logger.removeHandler(hdlr)
25
- logger.addHandler(handler)
26
- logger.setLevel(logging.DEBUG)
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 _ensure_inventory_not_empty
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
- proj,
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(figsize=figsize, subplot_kw={"projection": ccrs.PlateCarree()})
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 = plt.colorbar(im, ax=ax, shrink=0.8, pad=0.02)
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
- ax.set_title(title, fontsize=14, pad=20)
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
- plt.savefig(save_path, dpi=300, bbox_inches="tight")
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
- # Determine activity filter based on dataset
87
- activity_filter = "FAO" if dataset == "CORDEX-CORE" else "CRDX-ISIMIP-025"
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
- # CORDEX domains data
261
- cordex_domains_df = pd.DataFrame(
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, domain):
327
- """Check if bbox is contained within the domain bounding box."""
333
+ def is_bbox_contained(bbox, extent):
334
+ min_lon, min_lat, max_lon, max_lat = extent
328
335
  return (
329
- bbox[0] >= domain["min_lon"]
330
- and bbox[1] >= domain["min_lat"]
331
- and bbox[2] <= domain["max_lon"]
332
- and bbox[3] <= domain["max_lat"]
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
- if suggested_domains.empty:
351
- raise ValueError(
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
- suggested_domain = suggested_domains.iloc[0]["cordex_domain"]
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"Bounding box {user_bbox} is not within '{cordex_domain}'. Suggested domain: '{suggested_domain}'."
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/Risk-Team/cavapy/issues"
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.7"
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 :: MIT License",
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