acmadDL 0.2.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.
acmaddl/__init__.py ADDED
@@ -0,0 +1,61 @@
1
+ """acmadDL — federated data integration for seasonal climate forecasting.
2
+
3
+ Provides a unified fetch() API across CDS, OPeNDAP, NCEI, S3, HTTP, and
4
+ Sheerwater data sources. All outputs are CF-aligned xarray Datasets.
5
+
6
+ Quick start:
7
+ import acmaddl
8
+ ds = acmaddl.fetch("c3s/ecmwf", variable="precip", init="2025-02",
9
+ target="MAM", region=[-12, 6, 28, 42], hindcast=(2010, 2015))
10
+ """
11
+
12
+ import os as _os
13
+ from pathlib import Path as _Path
14
+
15
+ # Pin nuthatch's cache to a local directory at import time.
16
+ #
17
+ # nuthatch discovers its config by walking *up* the filesystem from the calling
18
+ # module and adopting the first pyproject.toml / nuthatch.toml it finds. When
19
+ # acmaddl is pip-installed, that search leaves site-packages/acmaddl/ and can pick
20
+ # up a nuthatch.toml shipped by a co-installed package — notably `sheerwater`, which
21
+ # points the cache root at a private GCS bucket (gs://sheerwater-datalake/...).
22
+ # Anyone without those credentials then hits a 401 / interactive prompt on their
23
+ # first fetch(). We defend against that on two fronts:
24
+ # 1. Ship src/acmaddl/nuthatch.toml (see there) so the upward search finds *our*
25
+ # local config first and never reaches the ambient one.
26
+ # 2. Pin the root/local cache filesystem here via the standard NUTHATCH_* env
27
+ # vars, since an installed package's own config is demoted to a "mirror" and
28
+ # cannot set the actual root.
29
+ # The file:// prefix is required: without it fsspec's split_protocol() mis-parses
30
+ # the '://' that adapter source URLs embed in cache-key paths. setdefault() means
31
+ # ACMADDL_CACHE_DIR, the NUTHATCH_* env vars, and ~/.nuthatch.toml all still win.
32
+ from ._paths import env_with_legacy as _env_with_legacy
33
+
34
+ _cache_dir = _Path(
35
+ _env_with_legacy("ACMADDL_CACHE_DIR") or _Path.home() / ".nuthatch" / "caches"
36
+ ).expanduser()
37
+ _cache_uri = f"file://{_cache_dir}"
38
+ _os.environ.setdefault("NUTHATCH_ROOT_FILESYSTEM", _cache_uri)
39
+ _os.environ.setdefault("NUTHATCH_LOCAL_FILESYSTEM", _cache_uri)
40
+
41
+ from . import catalog
42
+ from .errors import VariableNotSupported
43
+ from .fetch import fetch, parse_target, parse_init, season_to_months
44
+ from .cpc_nmme import cpc_nmme_predictor # noqa: F401
45
+ from .assemble import assemble, obs_predictor
46
+ from .zonal import zonal
47
+ from .health import check_product, check_all_products
48
+
49
+ __all__ = [
50
+ "catalog",
51
+ "fetch",
52
+ "VariableNotSupported",
53
+ "zonal",
54
+ "parse_target",
55
+ "season_to_months",
56
+ "parse_init",
57
+ "assemble",
58
+ "obs_predictor",
59
+ "check_product",
60
+ "check_all_products",
61
+ ]
acmaddl/_paths.py ADDED
@@ -0,0 +1,51 @@
1
+ """Filesystem locations used by acmaddl adapters."""
2
+ import os
3
+ import warnings
4
+
5
+ # Pre-rename spellings of the env vars below. The package was renamed
6
+ # rosetta -> acmaddl in 0.2.0; the old names are still honored so an exported
7
+ # ROSETTA_CACHE_DIR does not silently relocate an existing cache.
8
+ _LEGACY_ENV = {
9
+ "ACMADDL_CACHE_DIR": "ROSETTA_CACHE_DIR",
10
+ "ACMADDL_TMP_DIR": "ROSETTA_TMP_DIR",
11
+ }
12
+
13
+
14
+ def env_with_legacy(name: str) -> str | None:
15
+ """Read ``name``, falling back to its deprecated ``ROSETTA_*`` spelling.
16
+
17
+ Returns ``None`` if neither is set. Reading the legacy name emits a
18
+ ``DeprecationWarning`` — it still works, but it is on its way out.
19
+ """
20
+ value = os.environ.get(name)
21
+ if value:
22
+ return value
23
+ legacy = _LEGACY_ENV[name]
24
+ value = os.environ.get(legacy)
25
+ if value:
26
+ warnings.warn(
27
+ f"{legacy} is deprecated and will be removed in a future release; "
28
+ f"use {name} instead.",
29
+ DeprecationWarning,
30
+ stacklevel=3,
31
+ )
32
+ return value or None
33
+
34
+
35
+ def get_tmpdir() -> str:
36
+ """Return the directory adapters should use for scratch downloads.
37
+
38
+ Defaults to ``~/.nuthatch/acmaddl/_tmp/`` (co-located with the nuthatch
39
+ cache so scratch and cache live in the same tree). Override via the
40
+ ``ACMADDL_TMP_DIR`` environment variable (or the deprecated
41
+ ``ROSETTA_TMP_DIR``).
42
+
43
+ Adapters MUST use this rather than the system tempdir: macOS reaps
44
+ ``/var/folders/.../T/`` on its own schedule, which broke cache entries
45
+ that pickled lazy datasets referencing those temp files (issue #24).
46
+ """
47
+ path = env_with_legacy("ACMADDL_TMP_DIR") or os.path.expanduser(
48
+ "~/.nuthatch/acmaddl/_tmp"
49
+ )
50
+ os.makedirs(path, exist_ok=True)
51
+ return path
@@ -0,0 +1,34 @@
1
+ from .cds import CDSAdapter
2
+ from .opendap import OPeNDAPAdapter
3
+ from .http import HTTPAdapter
4
+ from .iridl import IRIDLAdapter
5
+ from .mars import MARSAdapter
6
+ from .ncei import NCEIAdapter
7
+ from .s3 import S3Adapter
8
+ from .sheerwater import SheerwaterAdapter
9
+ from .ccsr import CCSRAdapter
10
+ from .cpc_binary import CPCBinaryAdapter
11
+ # IcechunkAdapter imports the `icechunk` package lazily (inside its methods), so
12
+ # registering it here is safe without that optional dependency installed; a
13
+ # fetch only needs it when an icechunk-backed product is actually used.
14
+ from .icechunk import IcechunkAdapter
15
+
16
+ _ADAPTERS = {
17
+ "cds": CDSAdapter,
18
+ "opendap": OPeNDAPAdapter,
19
+ "http": HTTPAdapter,
20
+ "iridl": IRIDLAdapter,
21
+ "mars": MARSAdapter,
22
+ "ncei": NCEIAdapter,
23
+ "s3": S3Adapter,
24
+ "sheerwater": SheerwaterAdapter,
25
+ "ccsr": CCSRAdapter,
26
+ "cpc_binary": CPCBinaryAdapter,
27
+ "icechunk": IcechunkAdapter,
28
+ }
29
+
30
+
31
+ def get_adapter(name):
32
+ if name not in _ADAPTERS:
33
+ raise KeyError(f"Unknown adapter: {name}")
34
+ return _ADAPTERS[name]()
@@ -0,0 +1,124 @@
1
+ """Enumerating the files of an issuance-keyed forecast archive.
2
+
3
+ Observational archives are keyed by the time an observation describes. Forecast
4
+ archives are keyed by *two* times: when the forecast was issued, and what it is
5
+ about. CHC's CHIRPS-GEFS puts the first in the directory path and the second in
6
+ the filename::
7
+
8
+ .../CHIRPS-GEFS/v3/daily/global/2026/07/05/c3g_2026.07.20.tif
9
+ ^^^^^^^^^^ ^^^^^^^^^^
10
+ issued 5 July valid 20 July, lead 15
11
+
12
+ Other archives put the lead number in the filename, or the init date in both
13
+ places, or serve one accumulated file per issuance with no lead at all. Rather
14
+ than a new adapter per layout, a catalog entry declares an ``issuance`` block
15
+ and gets `strftime` templating over three names — ``init``, ``valid`` and
16
+ ``lead``::
17
+
18
+ issuance:
19
+ path_pattern: "{init:%Y}/{init:%m}/{init:%d}"
20
+ file_pattern: "c3g_{valid:%Y}.{valid:%m}.{valid:%d}.tif"
21
+ leads: [0, 15]
22
+ lead_units: days
23
+
24
+ This module turns that declaration plus a list of issuance dates into the file
25
+ list, and the file list back into the ``(init_time, lead_time)`` coordinates the
26
+ rest of acmadDL expects. It knows nothing about HTTP, so an S3 or OPeNDAP
27
+ archive with the same shape can reuse it.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass
32
+ from datetime import datetime, timedelta
33
+
34
+ import numpy as np
35
+
36
+ _LEAD_UNITS = {
37
+ "days": timedelta(days=1),
38
+ "hours": timedelta(hours=1),
39
+ }
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class IssuanceFile:
44
+ """One remote file, and the coordinates it will occupy."""
45
+
46
+ url: str
47
+ init: datetime
48
+ lead: int
49
+
50
+
51
+ def issuance_config(product_config: dict) -> dict | None:
52
+ """The validated ``issuance`` block, or None for a plain time-series product."""
53
+ block = product_config.get("issuance")
54
+ if block is None:
55
+ return None
56
+ if "file_pattern" not in block:
57
+ raise ValueError("issuance block requires 'file_pattern'")
58
+ leads = block.get("leads", [0, 0])
59
+ if len(leads) != 2 or leads[0] > leads[1]:
60
+ raise ValueError(
61
+ f"issuance 'leads' must be an inclusive [min, max] pair, got {leads!r}"
62
+ )
63
+ units = block.get("lead_units", "days")
64
+ if units not in _LEAD_UNITS:
65
+ raise ValueError(
66
+ f"issuance 'lead_units' must be one of {sorted(_LEAD_UNITS)}, got {units!r}"
67
+ )
68
+ return {
69
+ "path_pattern": block.get("path_pattern", ""),
70
+ "file_pattern": block["file_pattern"],
71
+ "leads": list(range(int(leads[0]), int(leads[1]) + 1)),
72
+ "lead_units": units,
73
+ "lead_step": _LEAD_UNITS[units],
74
+ }
75
+
76
+
77
+ def parse_init_dates(init_dates) -> list[datetime]:
78
+ """Normalize whatever the caller passed as issuance dates to datetimes."""
79
+ parsed = []
80
+ for value in init_dates:
81
+ if isinstance(value, datetime):
82
+ parsed.append(value)
83
+ else:
84
+ text = str(value)
85
+ if len(text) != 10:
86
+ raise ValueError(
87
+ f"issuance dates must be full YYYY-MM-DD dates, got {value!r}. "
88
+ "An issuance-keyed forecast has a day, not just a month."
89
+ )
90
+ parsed.append(datetime.strptime(text, "%Y-%m-%d"))
91
+ return sorted(set(parsed))
92
+
93
+
94
+ def enumerate_files(base_url: str, issuance: dict, init_dates) -> list[IssuanceFile]:
95
+ """Every (issuance, lead) file implied by the config, in a stable order."""
96
+ inits = parse_init_dates(init_dates)
97
+ if not inits:
98
+ raise ValueError("no issuance dates given")
99
+
100
+ step = issuance["lead_step"]
101
+ files = []
102
+ for init in inits:
103
+ for lead in issuance["leads"]:
104
+ valid = init + lead * step
105
+ fields = {"init": init, "valid": valid, "lead": lead}
106
+ parts = [base_url.rstrip("/")]
107
+ path = issuance["path_pattern"].format(**fields)
108
+ if path:
109
+ parts.append(path.strip("/"))
110
+ parts.append(issuance["file_pattern"].format(**fields))
111
+ files.append(IssuanceFile("/".join(parts), init, lead))
112
+ return files
113
+
114
+
115
+ def lead_timedelta(leads, lead_units: str) -> np.ndarray:
116
+ """Lead numbers -> numpy timedeltas, so ``lead_time`` carries its own units.
117
+
118
+ An integer ``lead_time`` is ambiguous the moment two products disagree on
119
+ whether it counts days or hours; a timedelta cannot be misread.
120
+ """
121
+ unit = "D" if lead_units == "days" else "h"
122
+ return np.array([np.timedelta64(int(lead), unit) for lead in leads]).astype(
123
+ "timedelta64[ns]"
124
+ )
@@ -0,0 +1,152 @@
1
+ """Shared retry / rate-limit infrastructure for acmadDL adapters.
2
+
3
+ Extracted from the HTTP adapter so that OPeNDAP and CCSR adapters can also
4
+ retry transient network failures (e.g. ``OSError: NetCDF: I/O failure`` from
5
+ a brief IRIDL hiccup) without duplicating logic.
6
+ """
7
+ import random
8
+ import re
9
+ import threading
10
+ import time
11
+
12
+ _DEFAULT_MAX_RETRIES = 3
13
+ _DEFAULT_RETRY_BACKOFF = 1.0
14
+
15
+
16
+ class _RateLimiter:
17
+ """Thread-safe minimum-interval gate between requests.
18
+
19
+ Used to pace per-file opens so we don't blow through a server's
20
+ requests-per-second budget (CHIRPS rate-limits at >~60 requests/sec when
21
+ GDAL/vsicurl drives many range reads in quick succession).
22
+ """
23
+
24
+ def __init__(self, min_interval):
25
+ self.min_interval = float(min_interval or 0.0)
26
+ self._last = 0.0
27
+ self._lock = threading.Lock()
28
+
29
+ def wait(self):
30
+ if self.min_interval <= 0:
31
+ return
32
+ with self._lock:
33
+ elapsed = time.monotonic() - self._last
34
+ if elapsed < self.min_interval:
35
+ time.sleep(self.min_interval - elapsed)
36
+ self._last = time.monotonic()
37
+
38
+
39
+ # HTTP 4xx ("client error") responses indicate a problem with our request
40
+ # (URL doesn't exist, unauthorized, etc.) and won't fix themselves on retry.
41
+ # 5xx and network errors are the ones worth retrying. GDAL/rasterio and
42
+ # urllib surface them with different wording, so we match both.
43
+ _PERMANENT_HTTP_PATTERNS = (
44
+ re.compile(r"HTTP response code: 4\d{2}"), # rasterio / vsicurl
45
+ re.compile(r"HTTP Error 4\d{2}"), # urllib
46
+ )
47
+
48
+
49
+ def _is_transient(exc):
50
+ """Return True if ``exc`` is worth retrying.
51
+
52
+ Any exception whose message does NOT match a known-permanent HTTP 4xx
53
+ pattern is considered transient — this covers ``OSError`` (netcdf I/O
54
+ failure), ``TimeoutError``, and server-side 5xx errors alike.
55
+ """
56
+ msg = str(exc)
57
+ for pat in _PERMANENT_HTTP_PATTERNS:
58
+ if pat.search(msg):
59
+ return False
60
+ return True
61
+
62
+
63
+ def _with_retry(fn, max_retries, backoff, label, verbose=True, on_failure=None,
64
+ is_transient=_is_transient):
65
+ """Call fn(), retrying transient failures with exponential backoff + jitter.
66
+
67
+ Only retries when `is_transient(exc)` is True — by default that means
68
+ "anything except an HTTP 4xx response," since 4xx errors are permanent
69
+ (404 won't become 200 in 2 seconds). Jitter (random factor in [0.5, 1.5))
70
+ desynchronizes retries when many workers fail simultaneously against a
71
+ rate-limited server. `on_failure` runs after each failed attempt before
72
+ the sleep.
73
+
74
+ Covers both HTTP adapter downloads and OPeNDAP/CCSR ``xr.open_dataset``
75
+ opens, since ``OSError`` (e.g. ``NetCDF: I/O failure``) is not a 4xx and
76
+ is therefore treated as transient.
77
+ """
78
+ last_err = None
79
+ for attempt in range(max_retries + 1):
80
+ try:
81
+ return fn()
82
+ except Exception as e:
83
+ last_err = e
84
+ if not is_transient(e):
85
+ raise
86
+ if attempt >= max_retries:
87
+ raise
88
+ if on_failure is not None:
89
+ try:
90
+ on_failure()
91
+ except Exception:
92
+ pass
93
+ wait = backoff * (2 ** attempt) * (0.5 + random.random())
94
+ if verbose:
95
+ print(f"[acmaddl] {label} failed "
96
+ f"(attempt {attempt + 1}/{max_retries + 1}): {e}; "
97
+ f"retrying in {wait:.2f}s")
98
+ time.sleep(wait)
99
+ raise last_err # unreachable, but keeps type-checkers honest
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Degenerate-response detection
104
+ # ---------------------------------------------------------------------------
105
+ # A large OPeNDAP/DAP request can fail mid-transfer and the netCDF/DAP client
106
+ # silently substitutes a constant (typically all-zero) array instead of
107
+ # raising. That corruption then gets memoized by the fetch cache and poisons
108
+ # every downstream computation (a zero-variance predictor makes CCA singular,
109
+ # NaN-ing out every skill score). This detector lets the fetch path reject such
110
+ # a response *before* it is cached, for any adapter — generalizing what the
111
+ # OPeNDAP adapter previously did only for its own obs-chunk path.
112
+ import numpy as _np
113
+
114
+
115
+ class DegenerateResponseError(RuntimeError):
116
+ """A fetched field is bitwise-constant (or all-NaN): a truncated/zero-filled response, not data."""
117
+
118
+
119
+ def reject_if_degenerate(ds, variable, label, *, reject_all_nan=True):
120
+ """Raise :class:`DegenerateResponseError` if a multi-value field is degenerate.
121
+
122
+ A multi-cell, multi-timestep geophysical field is never a single repeated value, so a
123
+ bitwise-constant response (the common all-zero case included) is a truncated DAP packet or a
124
+ zero-filled server error, not real data.
125
+
126
+ ``reject_all_nan`` controls the all-NaN case: keep it True where a variable is expected present
127
+ over its domain (the OPeNDAP obs path); set it False on the general fetch path, where an
128
+ all-NaN result can be a legitimately land-masked region (e.g. SST over a land-only box).
129
+ """
130
+ if variable not in getattr(ds, "data_vars", {}) and variable not in getattr(ds, "coords", {}):
131
+ # tolerate callers passing the sole data var implicitly
132
+ try:
133
+ values = ds[variable].values
134
+ except Exception:
135
+ return
136
+ else:
137
+ values = ds[variable].values
138
+ if values.size < 2:
139
+ return
140
+ finite = _np.isfinite(values)
141
+ if not finite.any():
142
+ if reject_all_nan:
143
+ raise DegenerateResponseError(
144
+ f"{label}: {variable!r} returned no finite values — a truncated or empty response."
145
+ )
146
+ return
147
+ if _np.unique(values[finite]).size == 1:
148
+ raise DegenerateResponseError(
149
+ f"{label}: constant {variable!r} field (every value is {values[finite].flat[0]}). "
150
+ "This is the signature of a truncated DAP response or a zero-filled server error, not "
151
+ "real data."
152
+ )
@@ -0,0 +1,60 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class AdapterBase(ABC):
5
+ @abstractmethod
6
+ def fetch_data(self, product_config, variable, date_range=None, region=None):
7
+ ...
8
+
9
+ def _resolve_streams(self, product_config, date_range):
10
+ """Resolve a request into [(stream, sub_range)] segments.
11
+
12
+ Mirrors today's start-year stream selection. When the product sets
13
+ ``append_streams`` and the requested range spans the end of
14
+ ``grid.hindcast_range``, return both a hindcast and a forecast segment so
15
+ the adapter can fetch and concatenate them on time.
16
+
17
+ The forecast segment's start is ``grid.forecast_range[0]`` when the
18
+ product declares one, else ``hindcast_range[1] + 1`` (legacy default for
19
+ products that don't set forecast_range). For most products these two
20
+ streams are disjoint (forecast starts the year after the hindcast ends),
21
+ so this is a no-op vs. the old ``hr[1] + 1`` logic. But some datasets
22
+ (e.g. IRI NMME CFSv2) split their two OPeNDAP streams by MONTH, not by
23
+ year -- the hindcast stream runs through ~March of its final year and the
24
+ forecast stream starts ~April of that SAME year. For those, the catalog
25
+ sets ``hindcast_range`` and ``forecast_range`` to OVERLAP at that split
26
+ year (e.g. hindcast_range=[1982, 2011], forecast_range=[2011, null]), so
27
+ the year appears in BOTH segments below; each stream's own init-time (S)
28
+ filter then naturally contributes only the inits it actually has, and the
29
+ adapter unions the two fetches. Backward-compatible: when the ranges do
30
+ NOT overlap (forecast_range[0] > hindcast_range[1], or forecast_range is
31
+ absent), the segments are exactly as before -- no shared year.
32
+ """
33
+ if not product_config.get("split_streams"):
34
+ return [("hindcast", date_range)]
35
+ grid = product_config.get("grid") or {}
36
+ hr = grid.get("hindcast_range")
37
+ fr = grid.get("forecast_range")
38
+ fstart = fr[0] if fr else (hr[1] + 1 if hr else None)
39
+ if (product_config.get("append_streams") and hr and fstart is not None and date_range
40
+ and date_range[0] <= hr[1] and fstart <= date_range[1]):
41
+ segs = []
42
+ if date_range[0] <= hr[1]:
43
+ segs.append(("hindcast", (date_range[0], min(date_range[1], hr[1]))))
44
+ if date_range[1] >= fstart:
45
+ segs.append(("forecast", (max(date_range[0], fstart), date_range[1])))
46
+ return segs
47
+ is_forecast = bool(date_range and hr and date_range[0] > hr[1])
48
+ return [("forecast" if is_forecast else "hindcast", date_range)]
49
+
50
+ def health_check(self, product_config, probe_remote=False):
51
+ """Return adapter health metadata.
52
+
53
+ Subclasses should override this to provide adapter-specific checks.
54
+ """
55
+ return {
56
+ "healthy": True,
57
+ "kind": "config",
58
+ "message": "No adapter-specific health check implemented.",
59
+ "probe_remote": bool(probe_remote),
60
+ }