ro-climate 0.1.0__py3-none-any.whl
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.
- ro_climate/__init__.py +62 -0
- ro_climate/_chunk_cache.py +369 -0
- ro_climate/_reduce.py +103 -0
- ro_climate/_store.py +123 -0
- ro_climate/archive.py +75 -0
- ro_climate/area.py +898 -0
- ro_climate/client.py +904 -0
- ro_climate/exceptions.py +29 -0
- ro_climate/grid.py +262 -0
- ro_climate/indices.py +64 -0
- ro_climate/point.py +765 -0
- ro_climate/py.typed +0 -0
- ro_climate/variables.py +73 -0
- ro_climate-0.1.0.dist-info/METADATA +539 -0
- ro_climate-0.1.0.dist-info/RECORD +18 -0
- ro_climate-0.1.0.dist-info/WHEEL +5 -0
- ro_climate-0.1.0.dist-info/licenses/LICENSE +21 -0
- ro_climate-0.1.0.dist-info/top_level.txt +1 -0
ro_climate/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Romanian daily gridded climate data from ANM.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from ro_climate import RoClimate
|
|
6
|
+
|
|
7
|
+
with RoClimate() as client:
|
|
8
|
+
p = client.point(
|
|
9
|
+
26.1025, 44.4325, start="2020-01-01", end="2020-12-31"
|
|
10
|
+
)
|
|
11
|
+
print(p.mean())
|
|
12
|
+
print(p.sum("precipitation"))
|
|
13
|
+
p.to_csv("bucharest_2020.csv")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .area import RoClimateArea
|
|
17
|
+
from .client import RoClimate, DEFAULT_STORE, ITEM_URL
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
RoClimateError,
|
|
20
|
+
InvalidTimeRangeError,
|
|
21
|
+
NoDataError,
|
|
22
|
+
NonAdditiveVariableError,
|
|
23
|
+
OutsideGridError,
|
|
24
|
+
StoreUnavailableError,
|
|
25
|
+
UnknownVariableError,
|
|
26
|
+
)
|
|
27
|
+
from .point import RoClimatePoint
|
|
28
|
+
from .variables import (
|
|
29
|
+
ATTRIBUTION,
|
|
30
|
+
DEFAULT_VARIABLES,
|
|
31
|
+
MAX_TEMP,
|
|
32
|
+
MIN_TEMP,
|
|
33
|
+
PRECIPITATION,
|
|
34
|
+
VARIABLES,
|
|
35
|
+
Variable,
|
|
36
|
+
resolve_variables,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0"
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"RoClimate",
|
|
43
|
+
"RoClimatePoint",
|
|
44
|
+
"RoClimateArea",
|
|
45
|
+
"Variable",
|
|
46
|
+
"VARIABLES",
|
|
47
|
+
"DEFAULT_VARIABLES",
|
|
48
|
+
"MAX_TEMP",
|
|
49
|
+
"MIN_TEMP",
|
|
50
|
+
"PRECIPITATION",
|
|
51
|
+
"ATTRIBUTION",
|
|
52
|
+
"resolve_variables",
|
|
53
|
+
"DEFAULT_STORE",
|
|
54
|
+
"ITEM_URL",
|
|
55
|
+
"RoClimateError",
|
|
56
|
+
"OutsideGridError",
|
|
57
|
+
"NoDataError",
|
|
58
|
+
"InvalidTimeRangeError",
|
|
59
|
+
"UnknownVariableError",
|
|
60
|
+
"NonAdditiveVariableError",
|
|
61
|
+
"StoreUnavailableError",
|
|
62
|
+
]
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Private cache for raw Zarr chunks and a caching Zarr store wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pandas as pd
|
|
14
|
+
from zarr.storage import Store
|
|
15
|
+
|
|
16
|
+
from ._store import RequestsStore
|
|
17
|
+
|
|
18
|
+
DEFAULT_CHUNK_CACHE_MAX_MB = 20_000.0
|
|
19
|
+
CHUNK_CACHE_MAX_MB_ENV = "RO_CLIMATE_CHUNK_CACHE_MAX_MB"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def default_chunk_cache_dir() -> Path:
|
|
23
|
+
"""Return the platform-appropriate default chunk-cache root."""
|
|
24
|
+
|
|
25
|
+
override = os.environ.get("RO_CLIMATE_CACHE_DIR")
|
|
26
|
+
if override:
|
|
27
|
+
return Path(override).expanduser()
|
|
28
|
+
if os.name == "nt":
|
|
29
|
+
base = os.environ.get("LOCALAPPDATA")
|
|
30
|
+
if base:
|
|
31
|
+
return Path(base) / "ro_climate" / "cache"
|
|
32
|
+
base = os.environ.get("XDG_CACHE_HOME")
|
|
33
|
+
if base:
|
|
34
|
+
return Path(base) / "ro_climate"
|
|
35
|
+
return Path.home() / ".cache" / "ro_climate"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_chunk_cache_max_mb(value: float | None = None) -> float:
|
|
39
|
+
"""Resolve the chunk-cache limit in megabytes.
|
|
40
|
+
|
|
41
|
+
Precedence is explicit ``value``, then ``RO_CLIMATE_CHUNK_CACHE_MAX_MB``,
|
|
42
|
+
then :data:`DEFAULT_CHUNK_CACHE_MAX_MB`. A value of ``0`` means unlimited.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
if value is None:
|
|
46
|
+
raw = os.environ.get(CHUNK_CACHE_MAX_MB_ENV)
|
|
47
|
+
value = DEFAULT_CHUNK_CACHE_MAX_MB if raw is None else float(raw)
|
|
48
|
+
limit = float(value)
|
|
49
|
+
if limit < 0:
|
|
50
|
+
raise ValueError("chunk cache size must be non-negative")
|
|
51
|
+
return limit
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ChunkCache:
|
|
55
|
+
"""Store compressed Zarr chunk bytes as individual cache files.
|
|
56
|
+
|
|
57
|
+
Only keys of the form ``variable/t.y.x`` are considered chunks. Small
|
|
58
|
+
metadata and frontier keys are always passed through to the remote store.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
directory: str | Path,
|
|
64
|
+
store_url: str,
|
|
65
|
+
variables: tuple[str, ...],
|
|
66
|
+
*,
|
|
67
|
+
max_bytes: int | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Create a chunk cache rooted at ``directory``.
|
|
70
|
+
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
directory:
|
|
74
|
+
Parent directory for chunk files. A store-specific subdirectory is
|
|
75
|
+
created below it.
|
|
76
|
+
store_url:
|
|
77
|
+
Remote store identity; it is included in the cache path.
|
|
78
|
+
variables:
|
|
79
|
+
Variable names whose chunk keys may be cached.
|
|
80
|
+
max_bytes:
|
|
81
|
+
Optional maximum cache size. Oldest files are evicted when the
|
|
82
|
+
limit is exceeded.
|
|
83
|
+
"""
|
|
84
|
+
digest = hashlib.sha256(store_url.encode("utf-8")).hexdigest()[:16]
|
|
85
|
+
self.directory = Path(directory).expanduser() / digest
|
|
86
|
+
self.variables = tuple(variables)
|
|
87
|
+
self.max_bytes = int(max_bytes) if max_bytes is not None else None
|
|
88
|
+
self._size = self._scan_size()
|
|
89
|
+
|
|
90
|
+
def _scan_size(self) -> int:
|
|
91
|
+
"""Return the total size of cached chunk files."""
|
|
92
|
+
if not self.directory.exists():
|
|
93
|
+
return 0
|
|
94
|
+
total = 0
|
|
95
|
+
for path in self.directory.rglob("*"):
|
|
96
|
+
if path.is_file():
|
|
97
|
+
try:
|
|
98
|
+
total += path.stat().st_size
|
|
99
|
+
except OSError:
|
|
100
|
+
pass
|
|
101
|
+
return total
|
|
102
|
+
|
|
103
|
+
def is_chunk_key(self, key: str) -> bool:
|
|
104
|
+
"""Return whether ``key`` is a cacheable Zarr chunk key."""
|
|
105
|
+
if key.count("/") != 1:
|
|
106
|
+
return False
|
|
107
|
+
variable, coordinates = key.split("/", 1)
|
|
108
|
+
if variable not in self.variables:
|
|
109
|
+
return False
|
|
110
|
+
parts = coordinates.split(".")
|
|
111
|
+
return len(parts) == 3 and all(part.isdigit() for part in parts)
|
|
112
|
+
|
|
113
|
+
def path_for(self, key: str) -> Path:
|
|
114
|
+
"""Return the local file path for chunk ``key``."""
|
|
115
|
+
if not self.is_chunk_key(key):
|
|
116
|
+
raise ValueError(f"not a cacheable chunk key: {key!r}")
|
|
117
|
+
variable, coordinates = key.split("/", 1)
|
|
118
|
+
time_part = coordinates.split(".", 1)[0]
|
|
119
|
+
bucket = f"{int(time_part) // 1000:04d}"
|
|
120
|
+
return self.directory / variable / bucket / coordinates
|
|
121
|
+
|
|
122
|
+
def get(self, key: str) -> bytes | None:
|
|
123
|
+
"""Return cached bytes for ``key``, or ``None`` on a miss."""
|
|
124
|
+
path = self.path_for(key)
|
|
125
|
+
try:
|
|
126
|
+
return path.read_bytes()
|
|
127
|
+
except OSError:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
def put(self, key: str, data: bytes) -> Path:
|
|
131
|
+
"""Store chunk bytes atomically and return the cache path."""
|
|
132
|
+
path = self.path_for(key)
|
|
133
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
old_size = 0
|
|
135
|
+
try:
|
|
136
|
+
old_size = path.stat().st_size
|
|
137
|
+
except OSError:
|
|
138
|
+
pass
|
|
139
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
140
|
+
dir=str(path.parent), prefix=path.name + ".", suffix=".tmp"
|
|
141
|
+
)
|
|
142
|
+
os.close(fd)
|
|
143
|
+
tmp = Path(tmp_name)
|
|
144
|
+
try:
|
|
145
|
+
tmp.write_bytes(data)
|
|
146
|
+
os.replace(tmp, path)
|
|
147
|
+
except Exception:
|
|
148
|
+
try:
|
|
149
|
+
tmp.unlink()
|
|
150
|
+
except OSError:
|
|
151
|
+
pass
|
|
152
|
+
raise
|
|
153
|
+
self._size += len(data) - old_size
|
|
154
|
+
self._trim()
|
|
155
|
+
return path
|
|
156
|
+
|
|
157
|
+
def contains(self, key: str) -> bool:
|
|
158
|
+
"""Return whether ``key`` is present in the cache."""
|
|
159
|
+
if not self.is_chunk_key(key):
|
|
160
|
+
return False
|
|
161
|
+
return self.path_for(key).is_file()
|
|
162
|
+
|
|
163
|
+
def invalidate(
|
|
164
|
+
self, variable: str, t0: int, t1: int, chunk_size: int
|
|
165
|
+
) -> int:
|
|
166
|
+
"""Delete cached time chunks overlapping ``t0..t1`` for a variable.
|
|
167
|
+
|
|
168
|
+
Chunk files are named with their first time index, so the requested
|
|
169
|
+
time range is expanded to the full aligned chunk range before files
|
|
170
|
+
are removed.
|
|
171
|
+
"""
|
|
172
|
+
if chunk_size <= 0:
|
|
173
|
+
return 0
|
|
174
|
+
size = int(chunk_size)
|
|
175
|
+
first_chunk = int(t0) // size
|
|
176
|
+
last_chunk = int(t1) // size
|
|
177
|
+
first_time = first_chunk * size
|
|
178
|
+
last_time = (last_chunk + 1) * size - 1
|
|
179
|
+
removed = 0
|
|
180
|
+
for bucket_index in range(first_time // 1000, last_time // 1000 + 1):
|
|
181
|
+
bucket = self.directory / variable / f"{bucket_index:04d}"
|
|
182
|
+
if not bucket.is_dir():
|
|
183
|
+
continue
|
|
184
|
+
for path in bucket.iterdir():
|
|
185
|
+
if not path.is_file():
|
|
186
|
+
continue
|
|
187
|
+
parts = path.name.split(".")
|
|
188
|
+
if len(parts) != 3 or not parts[0].isdigit():
|
|
189
|
+
continue
|
|
190
|
+
time_index = int(parts[0])
|
|
191
|
+
if not first_time <= time_index <= last_time:
|
|
192
|
+
continue
|
|
193
|
+
try:
|
|
194
|
+
file_size = path.stat().st_size
|
|
195
|
+
path.unlink()
|
|
196
|
+
except OSError:
|
|
197
|
+
continue
|
|
198
|
+
self._size = max(0, self._size - file_size)
|
|
199
|
+
removed += 1
|
|
200
|
+
return removed
|
|
201
|
+
|
|
202
|
+
def _trim(self) -> None:
|
|
203
|
+
"""Evict oldest chunks until the configured size limit is met."""
|
|
204
|
+
if self.max_bytes is None or self._size <= self.max_bytes:
|
|
205
|
+
return
|
|
206
|
+
files: list[tuple[float, int, Path]] = []
|
|
207
|
+
for path in self.directory.rglob("*"):
|
|
208
|
+
if not path.is_file():
|
|
209
|
+
continue
|
|
210
|
+
try:
|
|
211
|
+
stat = path.stat()
|
|
212
|
+
except OSError:
|
|
213
|
+
continue
|
|
214
|
+
files.append((stat.st_mtime, stat.st_size, path))
|
|
215
|
+
files.sort()
|
|
216
|
+
for _, size, path in files:
|
|
217
|
+
if self._size <= self.max_bytes:
|
|
218
|
+
break
|
|
219
|
+
try:
|
|
220
|
+
path.unlink()
|
|
221
|
+
except OSError:
|
|
222
|
+
continue
|
|
223
|
+
self._size = max(0, self._size - size)
|
|
224
|
+
|
|
225
|
+
def clear(self) -> int:
|
|
226
|
+
"""Delete every cached chunk and return the number removed."""
|
|
227
|
+
if not self.directory.exists():
|
|
228
|
+
return 0
|
|
229
|
+
count = sum(
|
|
230
|
+
1 for path in self.directory.rglob("*") if path.is_file()
|
|
231
|
+
)
|
|
232
|
+
shutil.rmtree(self.directory, ignore_errors=True)
|
|
233
|
+
self._size = 0
|
|
234
|
+
return count
|
|
235
|
+
|
|
236
|
+
def prune(self, older_than_days: int = 30) -> int:
|
|
237
|
+
"""Delete cached chunks older than ``older_than_days``."""
|
|
238
|
+
if older_than_days < 0:
|
|
239
|
+
raise ValueError("older_than_days must be non-negative")
|
|
240
|
+
cutoff = time.time() - older_than_days * 86400
|
|
241
|
+
removed = 0
|
|
242
|
+
if not self.directory.exists():
|
|
243
|
+
return 0
|
|
244
|
+
for path in self.directory.rglob("*"):
|
|
245
|
+
if not path.is_file():
|
|
246
|
+
continue
|
|
247
|
+
try:
|
|
248
|
+
stat = path.stat()
|
|
249
|
+
if stat.st_mtime < cutoff:
|
|
250
|
+
path.unlink()
|
|
251
|
+
self._size = max(0, self._size - stat.st_size)
|
|
252
|
+
removed += 1
|
|
253
|
+
except OSError:
|
|
254
|
+
pass
|
|
255
|
+
return removed
|
|
256
|
+
|
|
257
|
+
def info(self) -> pd.DataFrame:
|
|
258
|
+
"""Return a per-variable summary of the chunk cache."""
|
|
259
|
+
rows: list[dict] = []
|
|
260
|
+
if self.directory.exists():
|
|
261
|
+
for variable_dir in sorted(self.directory.iterdir()):
|
|
262
|
+
if not variable_dir.is_dir():
|
|
263
|
+
continue
|
|
264
|
+
files = [
|
|
265
|
+
path for path in variable_dir.rglob("*") if path.is_file()
|
|
266
|
+
]
|
|
267
|
+
if not files:
|
|
268
|
+
continue
|
|
269
|
+
sizes = []
|
|
270
|
+
mtimes = []
|
|
271
|
+
for path in files:
|
|
272
|
+
try:
|
|
273
|
+
stat = path.stat()
|
|
274
|
+
except OSError:
|
|
275
|
+
continue
|
|
276
|
+
sizes.append(stat.st_size)
|
|
277
|
+
mtimes.append(stat.st_mtime)
|
|
278
|
+
rows.append(
|
|
279
|
+
{
|
|
280
|
+
"variable": variable_dir.name,
|
|
281
|
+
"chunks": len(sizes),
|
|
282
|
+
"size_mb": round(sum(sizes) / 1_000_000, 3),
|
|
283
|
+
"oldest": datetime.fromtimestamp(
|
|
284
|
+
min(mtimes), timezone.utc
|
|
285
|
+
).isoformat()
|
|
286
|
+
if mtimes
|
|
287
|
+
else None,
|
|
288
|
+
"newest": datetime.fromtimestamp(
|
|
289
|
+
max(mtimes), timezone.utc
|
|
290
|
+
).isoformat()
|
|
291
|
+
if mtimes
|
|
292
|
+
else None,
|
|
293
|
+
}
|
|
294
|
+
)
|
|
295
|
+
return pd.DataFrame(
|
|
296
|
+
rows, columns=["variable", "chunks", "size_mb", "oldest", "newest"]
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class CachingRequestsStore(Store):
|
|
301
|
+
"""Zarr store that caches raw chunks from a :class:`RequestsStore`."""
|
|
302
|
+
|
|
303
|
+
def __init__(self, remote: RequestsStore, cache: ChunkCache) -> None:
|
|
304
|
+
"""Wrap ``remote`` with the local ``cache``."""
|
|
305
|
+
self.remote = remote
|
|
306
|
+
self.cache = cache
|
|
307
|
+
|
|
308
|
+
def __getitem__(self, key: str) -> bytes:
|
|
309
|
+
"""Return bytes for ``key``, using or filling the chunk cache."""
|
|
310
|
+
if self.cache.is_chunk_key(key):
|
|
311
|
+
cached = self.cache.get(key)
|
|
312
|
+
if cached is not None:
|
|
313
|
+
return cached
|
|
314
|
+
data = self.remote[key]
|
|
315
|
+
self.cache.put(key, data)
|
|
316
|
+
return data
|
|
317
|
+
return self.remote[key]
|
|
318
|
+
|
|
319
|
+
def getitems(self, keys, *, contexts=None):
|
|
320
|
+
"""Read several keys, using the chunk cache where possible."""
|
|
321
|
+
result: dict[str, bytes] = {}
|
|
322
|
+
chunk_keys: list[str] = []
|
|
323
|
+
other_keys: list[str] = []
|
|
324
|
+
for key in keys:
|
|
325
|
+
if self.cache.is_chunk_key(key):
|
|
326
|
+
cached = self.cache.get(key)
|
|
327
|
+
if cached is not None:
|
|
328
|
+
result[key] = cached
|
|
329
|
+
else:
|
|
330
|
+
chunk_keys.append(key)
|
|
331
|
+
else:
|
|
332
|
+
other_keys.append(key)
|
|
333
|
+
if other_keys:
|
|
334
|
+
result.update(self.remote.getitems(other_keys, contexts=contexts))
|
|
335
|
+
if chunk_keys:
|
|
336
|
+
fetched = self.remote.getitems(chunk_keys, contexts=contexts)
|
|
337
|
+
for key, data in fetched.items():
|
|
338
|
+
self.cache.put(key, data)
|
|
339
|
+
result[key] = data
|
|
340
|
+
return result
|
|
341
|
+
|
|
342
|
+
def __contains__(self, key: object) -> bool:
|
|
343
|
+
"""Return whether ``key`` exists locally or remotely."""
|
|
344
|
+
if isinstance(key, str) and self.cache.is_chunk_key(key):
|
|
345
|
+
if self.cache.contains(key):
|
|
346
|
+
return True
|
|
347
|
+
return key in self.remote
|
|
348
|
+
|
|
349
|
+
def __setitem__(self, key: str, value: bytes) -> None:
|
|
350
|
+
"""Reject writes through the Zarr store interface."""
|
|
351
|
+
raise TypeError("CachingRequestsStore is read-only")
|
|
352
|
+
|
|
353
|
+
def __delitem__(self, key: str) -> None:
|
|
354
|
+
"""Reject deletions through the Zarr store interface."""
|
|
355
|
+
raise TypeError("CachingRequestsStore is read-only")
|
|
356
|
+
|
|
357
|
+
def __iter__(self):
|
|
358
|
+
"""Delegate key iteration to the remote store."""
|
|
359
|
+
return iter(self.remote)
|
|
360
|
+
|
|
361
|
+
def __len__(self) -> int:
|
|
362
|
+
"""Delegate key count to the remote store."""
|
|
363
|
+
return len(self.remote)
|
|
364
|
+
|
|
365
|
+
def close(self) -> None:
|
|
366
|
+
"""Close the remote store if it supports closing."""
|
|
367
|
+
close = getattr(self.remote, "close", None)
|
|
368
|
+
if close is not None:
|
|
369
|
+
close()
|
ro_climate/_reduce.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Shared temporal reduction helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from .exceptions import NoDataError
|
|
10
|
+
|
|
11
|
+
VALID_HOW = {"mean", "sum", "min", "max", "median", "std", "var", "count"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def reduce_series(
|
|
15
|
+
series: pd.Series, how: str, min_count: int | None
|
|
16
|
+
) -> float:
|
|
17
|
+
"""Reduce one series to a scalar, respecting ``min_count`` valid values."""
|
|
18
|
+
count = int(series.count())
|
|
19
|
+
threshold = 1 if min_count is None else int(min_count)
|
|
20
|
+
if how == "count":
|
|
21
|
+
return count
|
|
22
|
+
if count < threshold:
|
|
23
|
+
return float("nan")
|
|
24
|
+
if how == "sum":
|
|
25
|
+
return float(series.sum(min_count=threshold))
|
|
26
|
+
if how == "mean":
|
|
27
|
+
return float(series.mean())
|
|
28
|
+
if how == "min":
|
|
29
|
+
return float(series.min())
|
|
30
|
+
if how == "max":
|
|
31
|
+
return float(series.max())
|
|
32
|
+
if how == "median":
|
|
33
|
+
return float(series.median())
|
|
34
|
+
if how == "std":
|
|
35
|
+
return float(series.std(ddof=1))
|
|
36
|
+
if how == "var":
|
|
37
|
+
return float(series.var(ddof=1))
|
|
38
|
+
raise ValueError(f"unsupported aggregation {how!r}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def reduce_grouped(grouped, how: str, min_count: int | None):
|
|
42
|
+
"""Apply a named reduction to a pandas groupby or resampler object."""
|
|
43
|
+
if how == "count":
|
|
44
|
+
return grouped.count()
|
|
45
|
+
if how == "sum":
|
|
46
|
+
result = grouped.sum(
|
|
47
|
+
min_count=0 if min_count is None else int(min_count)
|
|
48
|
+
)
|
|
49
|
+
else:
|
|
50
|
+
result = grouped.agg(how)
|
|
51
|
+
if min_count is not None and how != "count":
|
|
52
|
+
counts = grouped.count()
|
|
53
|
+
result = result.where(counts >= int(min_count))
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def extreme_dates(frame: pd.DataFrame) -> pd.DataFrame:
|
|
58
|
+
"""Return minima, maxima, and their dates for each column.
|
|
59
|
+
|
|
60
|
+
Empty or all-NaN columns produce ``NaN`` values and ``NaT`` dates.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
rows: dict[str, dict[str, Any]] = {}
|
|
64
|
+
for column in frame.columns:
|
|
65
|
+
series = frame[column].dropna()
|
|
66
|
+
if series.empty:
|
|
67
|
+
rows[column] = {
|
|
68
|
+
"min": float("nan"),
|
|
69
|
+
"min_date": pd.NaT,
|
|
70
|
+
"max": float("nan"),
|
|
71
|
+
"max_date": pd.NaT,
|
|
72
|
+
}
|
|
73
|
+
continue
|
|
74
|
+
min_index = series.idxmin()
|
|
75
|
+
max_index = series.idxmax()
|
|
76
|
+
rows[column] = {
|
|
77
|
+
"min": float(series.loc[min_index]),
|
|
78
|
+
"min_date": pd.Timestamp(min_index),
|
|
79
|
+
"max": float(series.loc[max_index]),
|
|
80
|
+
"max_date": pd.Timestamp(max_index),
|
|
81
|
+
}
|
|
82
|
+
result = pd.DataFrame.from_dict(
|
|
83
|
+
rows,
|
|
84
|
+
orient="index",
|
|
85
|
+
columns=["min", "min_date", "max", "max_date"],
|
|
86
|
+
)
|
|
87
|
+
result.index.name = "series"
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def extreme_index(series: pd.Series, how: str) -> tuple[Any, float]:
|
|
92
|
+
"""Return the index label and value of a series minimum or maximum."""
|
|
93
|
+
|
|
94
|
+
values = series.dropna()
|
|
95
|
+
if values.empty:
|
|
96
|
+
raise NoDataError("no valid values for the requested extreme")
|
|
97
|
+
if how == "max":
|
|
98
|
+
index = values.idxmax()
|
|
99
|
+
elif how == "min":
|
|
100
|
+
index = values.idxmin()
|
|
101
|
+
else:
|
|
102
|
+
raise ValueError(f"unsupported extreme {how!r}; use 'min' or 'max'")
|
|
103
|
+
return index, float(values.loc[index])
|
ro_climate/_store.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Minimal requests-backed Zarr store.
|
|
2
|
+
|
|
3
|
+
Zarr v2 needs a mapping from keys to bytes. This module keeps that mapping
|
|
4
|
+
private and deliberately small so the package does not require fsspec.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
from zarr.storage import Store
|
|
14
|
+
|
|
15
|
+
from .exceptions import StoreUnavailableError
|
|
16
|
+
|
|
17
|
+
_RETRY_STATUS = {429, 500, 502, 503, 504}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RequestsStore(Store):
|
|
21
|
+
"""Read-only Zarr store backed by plain HTTP requests."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
base_url: str,
|
|
26
|
+
session: requests.Session,
|
|
27
|
+
*,
|
|
28
|
+
timeout: float = 30.0,
|
|
29
|
+
retries: int = 3,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Initialize a read-only store for ``base_url``.
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
base_url:
|
|
36
|
+
Root URL of the Zarr store. A trailing slash is added.
|
|
37
|
+
session:
|
|
38
|
+
HTTP session used for all requests.
|
|
39
|
+
timeout:
|
|
40
|
+
Per-request timeout in seconds.
|
|
41
|
+
retries:
|
|
42
|
+
Number of attempts for each key before giving up.
|
|
43
|
+
"""
|
|
44
|
+
self.base_url = base_url.rstrip("/") + "/"
|
|
45
|
+
self.session = session
|
|
46
|
+
self.timeout = float(timeout)
|
|
47
|
+
self.retries = max(1, int(retries))
|
|
48
|
+
|
|
49
|
+
def __getitem__(self, key: str) -> bytes:
|
|
50
|
+
"""Return the raw bytes stored under ``key``.
|
|
51
|
+
|
|
52
|
+
Transient HTTP failures are retried. A missing key raises
|
|
53
|
+
:class:`KeyError` and other permanent failures raise
|
|
54
|
+
:class:`StoreUnavailableError`.
|
|
55
|
+
"""
|
|
56
|
+
url = self.base_url + key
|
|
57
|
+
for attempt in range(self.retries):
|
|
58
|
+
try:
|
|
59
|
+
response = self.session.get(url, timeout=self.timeout)
|
|
60
|
+
except requests.RequestException as exc:
|
|
61
|
+
if attempt + 1 == self.retries:
|
|
62
|
+
raise StoreUnavailableError(
|
|
63
|
+
f"cannot read {url}: {exc}"
|
|
64
|
+
) from exc
|
|
65
|
+
else:
|
|
66
|
+
if response.status_code == 200:
|
|
67
|
+
return response.content
|
|
68
|
+
if response.status_code == 404:
|
|
69
|
+
raise KeyError(key)
|
|
70
|
+
if response.status_code not in _RETRY_STATUS:
|
|
71
|
+
raise StoreUnavailableError(
|
|
72
|
+
f"HTTP {response.status_code} while reading {url}"
|
|
73
|
+
)
|
|
74
|
+
time.sleep(0.5 * (2**attempt))
|
|
75
|
+
raise StoreUnavailableError(f"cannot read {url}")
|
|
76
|
+
|
|
77
|
+
def getitems(self, keys, *, contexts=None):
|
|
78
|
+
"""Read several chunk keys, skipping missing chunks."""
|
|
79
|
+
|
|
80
|
+
result: dict[str, bytes] = {}
|
|
81
|
+
for key in keys:
|
|
82
|
+
try:
|
|
83
|
+
result[key] = self[key]
|
|
84
|
+
except KeyError:
|
|
85
|
+
continue
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
def __contains__(self, key: object) -> bool:
|
|
89
|
+
"""Return whether ``key`` exists in the remote store."""
|
|
90
|
+
if not isinstance(key, str):
|
|
91
|
+
return False
|
|
92
|
+
try:
|
|
93
|
+
response = self.session.head(
|
|
94
|
+
self.base_url + key, timeout=self.timeout
|
|
95
|
+
)
|
|
96
|
+
except requests.RequestException:
|
|
97
|
+
response = None
|
|
98
|
+
if response is not None:
|
|
99
|
+
if response.status_code == 200:
|
|
100
|
+
return True
|
|
101
|
+
if response.status_code == 404:
|
|
102
|
+
return False
|
|
103
|
+
try:
|
|
104
|
+
self[key]
|
|
105
|
+
except KeyError:
|
|
106
|
+
return False
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
def __setitem__(self, key: str, value: bytes) -> None:
|
|
110
|
+
"""Reject writes because the remote store is read-only."""
|
|
111
|
+
raise TypeError("RequestsStore is read-only")
|
|
112
|
+
|
|
113
|
+
def __delitem__(self, key: str) -> None:
|
|
114
|
+
"""Reject deletions because the remote store is read-only."""
|
|
115
|
+
raise TypeError("RequestsStore is read-only")
|
|
116
|
+
|
|
117
|
+
def __iter__(self) -> Iterator[str]:
|
|
118
|
+
"""Return no keys; consolidated Zarr metadata is used instead."""
|
|
119
|
+
return iter(())
|
|
120
|
+
|
|
121
|
+
def __len__(self) -> int:
|
|
122
|
+
"""Return zero because this store does not list keys."""
|
|
123
|
+
return 0
|