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 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