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 +61 -0
- acmaddl/_paths.py +51 -0
- acmaddl/adapters/__init__.py +34 -0
- acmaddl/adapters/_issuance.py +124 -0
- acmaddl/adapters/_robust.py +152 -0
- acmaddl/adapters/base.py +60 -0
- acmaddl/adapters/ccsr.py +239 -0
- acmaddl/adapters/cds.py +270 -0
- acmaddl/adapters/cpc_binary.py +102 -0
- acmaddl/adapters/http.py +465 -0
- acmaddl/adapters/icechunk.py +172 -0
- acmaddl/adapters/iridl.py +141 -0
- acmaddl/adapters/mars.py +124 -0
- acmaddl/adapters/ncei.py +209 -0
- acmaddl/adapters/opendap.py +272 -0
- acmaddl/adapters/s3.py +159 -0
- acmaddl/adapters/sheerwater.py +175 -0
- acmaddl/assemble.py +93 -0
- acmaddl/catalog.py +90 -0
- acmaddl/catalog.yaml +1613 -0
- acmaddl/cli.py +36 -0
- acmaddl/cpc_nmme.py +123 -0
- acmaddl/errors.py +33 -0
- acmaddl/fetch.py +573 -0
- acmaddl/health.py +88 -0
- acmaddl/normalize.py +438 -0
- acmaddl/nuthatch.toml +22 -0
- acmaddl/region.py +111 -0
- acmaddl/storage.py +79 -0
- acmaddl/validate.py +423 -0
- acmaddl/zonal.py +265 -0
- acmaddl-0.2.0.dist-info/METADATA +338 -0
- acmaddl-0.2.0.dist-info/RECORD +37 -0
- acmaddl-0.2.0.dist-info/WHEEL +4 -0
- acmaddl-0.2.0.dist-info/entry_points.txt +2 -0
- acmaddl-0.2.0.dist-info/licenses/LICENSE +21 -0
- rosetta/__init__.py +65 -0
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
|
+
)
|
acmaddl/adapters/base.py
ADDED
|
@@ -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
|
+
}
|