"""Retrieve and store daily ECCO2 cube92 ocean state estimate data.
Unlike the GLORYS12/Mercator THREDDS archive (one file per day, per
variable, discovered through a rolling filename), the APDRC GDS server
exposes each ECCO2 cube92 variable as a single OPeNDAP dataset spanning
the full 1992-01-04 -- 2025-12-31 daily record. A requested date is
selected from that record by nearest-neighbour lookup on the remote
``time`` coordinate and cached locally as a single-day Zarr store so
repeated access does not re-hit the network.
ECCO2 cube92 is also a plain regular lat/lon grid (0.25 deg, global),
not a curvilinear NEMO grid, so there are no separate mesh_hgr/mesh_zgr
files to derive geometry from. :func:`grid_definition` instead computes
cell areas analytically from the lat/lon spacing and derives the bottom
level index ``kmt`` from the land/below-seafloor pattern of a reference
``uvel`` snapshot (see :func:`calc_kmt_dzt`).
The server's native ``lon`` runs 0.125..359.875 (Prime Meridian at the
array seam); :func:`_center_on_prime_meridian` rolls every cached field to
-179.875..179.875 so 0 deg sits at the array centre, matching the
convention used elsewhere in the codebase.
"""
import pathlib
import time
import warnings
from concurrent.futures import ThreadPoolExecutor
import dask
import numpy as np
import pandas as pd
import requests
import xarray as xr
import zarr
from dask.diagnostics import ProgressBar
from xarray.backends import PydapDataStore
from tracmass.seagrid import coast
from tracmass.seagrid.debug import trace_lines
EARTH_RADIUS = 6_371_000.0 # meters
# (connect, read) timeout, in seconds, for every OPeNDAP request made against
# the APDRC GDS server. A short connect timeout fails fast against an
# unreachable host; the read timeout bounds how long a single stalled chunk
# request (one dask chunk's worth of array data) may block once connected.
# Paired with a low retry count below, a genuinely dead/unreachable server
# now fails within roughly a minute instead of retrying for 10+ minutes per
# chunk (each retry re-running the full connect+read timeout).
_OPENDAP_TIMEOUT = (10, 60)
# These three warnings are permanent, unfixable quirks of the APDRC GDS
# server (and of the local Zarr caching this module builds around it) —
# never a sign of an actual problem — so they are silenced globally rather
# than scoped per call site. A `with warnings.catch_warnings():` block is
# not reliable here: the Zarr writes below run on dask's threaded scheduler,
# which can execute the lazy `time` CF-decode in a worker thread after a
# `with` block in the calling thread has already restored the filters —
# `catch_warnings` is documented as not thread-safe.
warnings.filterwarnings("ignore", category=xr.SerializationWarning)
warnings.filterwarnings(
"ignore", message="Consolidated metadata is currently not part"
)
warnings.filterwarnings(
"ignore", message="PyDAP was unable to determine the DAP protocol"
)
settings = dict(
host="http://apdrc.soest.hawaii.edu:80/dods",
product="public_data/ECCO/ECCO2/cube92_daily",
timestep=pd.Timedelta(1, "D"),
disk="raid",
)
vardict = dict(
uvel=["U", "uvel"],
vvel=["V", "vvel"],
wvel=["W", "wvel"],
temp=["T", "theta"],
salt=["S", "salt"],
)
def basedir():
path = pathlib.Path(f"/data/{settings['disk']}/ecco2/cube92_daily")
path.mkdir(parents=True, exist_ok=True)
return path
[docs]
def datadir(dtm="2020-01-01"):
"""Return the local data directory for a given date, creating it if needed.
Parameters
----------
dtm : str or datetime-like
Date string or datetime-like object.
Returns
-------
pathlib.Path
Path to the year-specific data directory.
"""
dtm = pd.to_datetime(dtm)
path = basedir() / str(dtm.year)
path = pathlib.Path(path)
return path
[docs]
def filename(dtm="2020-01-01", data_var="uvel"):
"""Build the Zarr filename for a given date and variable.
Parameters
----------
dtm : str or datetime-like
Date string or datetime-like object.
data_var : str
Variable key (one of 'uvel', 'vvel', 'wvel', 'temp', 'salt').
Returns
-------
str
Filename string in the form ``CUBE92_<date>_<data_var>.zarr``.
"""
dtm = pd.to_datetime(dtm)
return f"CUBE92_{dtm.date()}_{data_var}.zarr"
def _center_on_prime_meridian(ds):
"""Roll the lon axis so the grid is centred on 0 deg (Prime Meridian).
ECCO2 cube92 serves ``lon`` as 0.125..359.875: the array seam falls on
the Prime Meridian and the antimeridian sits at the array centre.
Rolling to -179.875..179.875 matches the convention used elsewhere in
the codebase (e.g. ``gcm.glorys``) and keeps Atlantic/European regions
contiguous for ``i1:i2`` subsetting instead of wrapping around the
array edge.
"""
n_roll = int((ds.lon.values > 180).argmax())
ds = ds.roll(lon=-n_roll, roll_coords=True)
ds = ds.assign_coords(lon=(((ds.lon + 180) % 360) - 180))
return ds
def _prepare_write_job(dtm, data_var, force=False):
"""Open the remote OPeNDAP dataset and return a pending dask write job.
Returns ``None`` if the local Zarr file already exists and ``force`` is
False. Intended to be called from a thread pool so that multiple
OPeNDAP connections can be set up concurrently.
Parameters
----------
dtm : pd.Timestamp
Date for the desired time step (already converted).
data_var : str
Variable key (one of 'uvel', 'vvel', 'wvel', 'temp', 'salt').
force : bool
If True, re-download even if the file already exists.
Returns
-------
dask.delayed or None
Pending ``to_zarr`` compute job, or ``None`` if the file is cached.
"""
fname = filename(dtm=dtm, data_var=data_var)
ddir = datadir(dtm=dtm)
if (ddir / fname).exists() and not force:
return None
chunks = {"lev": 10, "lat": 360, "lon": 720}
session = requests.Session()
adapter = requests.adapters.HTTPAdapter(
max_retries=requests.adapters.Retry(total=2, backoff_factor=1),
pool_connections=8,
pool_maxsize=16,
)
session.mount("http://", adapter)
url = f"{settings['host']}/{settings['product']}/{vardict[data_var][1]}"
# PydapDataStore's own suggested fix for the DAP-protocol warning
# (prefixing the URL with `dap2://`) makes pydap rewrite the scheme to
# `https` internally, which would bypass the `session.mount("http://",
# ...)` retry adapter above — so the warning is silenced globally
# instead (see the module-level filterwarnings calls).
store = PydapDataStore.open(url, session=session, timeout=_OPENDAP_TIMEOUT)
ds = xr.open_dataset(store, chunks=chunks).astype("float32")
dtm_index = pd.to_datetime(ds.time).normalize()
iloc = np.argmin(np.abs(dtm_index - dtm))
da = ds[[vardict[data_var][1]]].isel(time=iloc)
da = _center_on_prime_meridian(da)
return da.to_zarr(str(ddir / fname), mode="w", compute=False)
def _compute_with_retry(*jobs, max_retries=3, backoff=10, **kwargs):
"""Compute dask jobs, retrying on transient OPeNDAP connection drops.
ECCO2 cube92 daily fetches are large (order 100-200 MB per variable)
over a slow remote link, and occasionally fail mid-transfer with a
``requests.exceptions.ChunkedEncodingError``/``IncompleteRead`` from
the APDRC server. ``session.mount("http://", adapter)`` in
:func:`_prepare_write_job` only retries failures during connection
setup (via urllib3's ``Retry``); a dropped connection while streaming
an already-established response — which is what happens here, deep
inside pydap's ``r.content`` — is not covered by that and otherwise
kills a long loop over many dates partway through.
Parameters
----------
*jobs
Dask-computable objects, as passed to ``dask.compute``.
max_retries : int, optional
Number of retries after the initial attempt. Default 3.
backoff : float, optional
Seconds to wait before retry *n*, scaled by *n*. Default 10.
**kwargs
Forwarded to ``dask.compute`` (e.g. ``scheduler``, ``num_workers``).
"""
for attempt in range(max_retries + 1):
try:
return dask.compute(*jobs, **kwargs)
except requests.exceptions.RequestException as exc:
if attempt == max_retries:
raise
wait = backoff * (attempt + 1)
print(
f"OPeNDAP connection dropped mid-transfer ({exc!r}); "
f"retrying in {wait}s ({attempt + 1}/{max_retries}) …",
flush=True,
)
time.sleep(wait)
[docs]
def retrieve(dtm="2020-01-01", data_var="uvel", force=False):
"""Download a single daily field from the APDRC GDS server and save it as Zarr.
Opens the remote ECCO2 cube92 dataset via OPeNDAP, selects the time
step closest to ``dtm``, and writes the result to a local Zarr store.
Parameters
----------
dtm : str or datetime-like
Date string or datetime-like object for the desired time step.
data_var : str
Variable key (one of 'uvel', 'vvel', 'wvel', 'temp', 'salt').
force : bool
If True, re-download the file even if it already exists locally.
"""
dtm = pd.to_datetime(dtm)
write_job = _prepare_write_job(dtm, data_var, force)
if write_job is None:
return
with ProgressBar():
_compute_with_retry(write_job, scheduler="threads", num_workers=8)
def _cell_edges(centers):
"""Return cell-boundary coordinates bracketing a 1-D array of centres."""
centers = np.asarray(centers)
edges = np.empty(centers.size + 1)
edges[1:-1] = 0.5 * (centers[:-1] + centers[1:])
edges[0] = centers[0] - (edges[1] - centers[0])
edges[-1] = centers[-1] + (centers[-1] - edges[-2])
return edges
[docs]
def calc_grid_spacing(lat, lon):
"""Compute T-cell face widths on a regular lat/lon grid.
Parameters
----------
lat : np.ndarray
1-D array of cell-centre latitudes in degrees, shape ``(jmt,)``.
lon : np.ndarray
1-D array of cell-centre longitudes in degrees, shape ``(imt,)``.
Returns
-------
dxv : np.ndarray
Zonal cell width in meters (face crossed by meridional flux),
shape ``(jmt, imt)``.
dyu : np.ndarray
Meridional cell width in meters (face crossed by zonal flux),
shape ``(jmt, imt)``.
"""
lat_edges = _cell_edges(lat)
lon_edges = _cell_edges(lon)
dlat = np.deg2rad(np.diff(lat_edges)) # (jmt,)
dlon = np.deg2rad(np.diff(lon_edges)) # (imt,)
jmt, imt = lat.size, lon.size
dyu = np.broadcast_to(EARTH_RADIUS * dlat[:, None], (jmt, imt))
dxv = EARTH_RADIUS * np.cos(np.deg2rad(lat))[:, None] * dlon[None, :]
return dxv, np.ascontiguousarray(dyu)
[docs]
def calc_kmt_dzt(uvel0, lev):
"""Derive bottom-level index and layer thickness from a reference snapshot.
ECCO2 cube92 has no separate bathymetry file; land and below-seafloor
cells carry an exact-zero velocity (see Notes), so the ocean depth at
each column is recovered by counting non-zero levels.
Parameters
----------
uvel0 : np.ndarray
Zonal velocity at a single reference time, shape ``(km, jmt, imt)``.
lev : np.ndarray
1-D array of depth-level centres in meters, shape ``(km,)``.
Returns
-------
kmt : np.ndarray
1-based bottom level index per column, shape ``(jmt, imt)``.
dzt : np.ndarray
Layer thickness, zeroed below the seafloor, shape ``(km, jmt, imt)``.
Notes
-----
The APDRC GDS gateway declares ``missing_value``/``_FillValue`` as
``-1e23`` but actually serves exact ``0.0`` for land and below-seafloor
cells (verified against known land and shallow-shelf columns), so the
mask is built from exact equality rather than NaN/CF decoding.
"""
kmt = (uvel0 != 0.0).sum(axis=0).astype("int32") # (jmt, imt)
edges = _cell_edges(lev)
edges[0] = 0.0 # surface
dz = np.diff(edges) # (km,)
km, jmt, imt = uvel0.shape
dzt = np.broadcast_to(dz[:, None, None], (km, jmt, imt)).copy()
k_index = np.arange(1, km + 1)[:, None, None]
dzt[k_index > kmt[None, :, :]] = 0.0
return kmt, dzt
[docs]
def grid_definition(dtm="2020-01-01", dask=True, force=False):
"""Build (and cache) the ECCO2 cube92 grid geometry.
Parameters
----------
dtm : str or datetime-like, optional
Reference date used to derive ``kmt`` from the land/underwater NaN
pattern of a single ``uvel`` snapshot. Default ``"2020-01-01"``.
dask : bool, optional
If True (default), open the cached grid file with Dask chunking.
force : bool, optional
If True, rebuild the grid file even if it already exists.
Returns
-------
xarray.Dataset
Dataset with ``dxdy``, ``dxv``, ``dyu``, ``dzt``, ``kmt``, ``lon``,
``lat``.
"""
gridfile = basedir() / "trm_cube92_grid_def.zarr"
if not gridfile.is_dir() or force:
ref = open_dataset(dtm=dtm, data_var="uvel", dask=False)
lat = ref["y"].values
lon = ref["x"].values
lev = ref["z"].values
kmt, dzt = calc_kmt_dzt(ref["uvel"].values, lev)
dxv, dyu = calc_grid_spacing(lat, lon)
ds = xr.Dataset(
{
"dxdy": (("y", "x"), dxv * dyu),
"dxv": (("y", "x"), dxv),
"dyu": (("y", "x"), dyu),
"dzt": (("z", "y", "x"), dzt),
"kmt": (("y", "x"), kmt),
},
coords={"lon": ("x", lon), "lat": ("y", lat), "z": ("z", lev)},
)
ds.to_zarr(gridfile)
zarr.consolidate_metadata(gridfile)
chunks = {} if dask else None
return xr.open_dataset(gridfile, chunks=chunks)
[docs]
def landmask(k: int = 1) -> xr.DataArray:
"""ECCO2 land mask on the T-cells of :func:`grid_definition`.
Parameters
----------
k : int, optional
1-based model level. Default ``1`` (surface): land is ``kmt == 0``.
Deeper, the sea floor counts as land too (``kmt < k``).
Returns
-------
xr.DataArray
bool ``(y, x)``, ``True`` on land, with ``lon`` / ``lat``
coordinates. See :func:`tracmass.seagrid.coast.landmask`.
"""
return coast.landmask(grid_definition(), k)
[docs]
def distance_to_land(k: int = 1) -> xr.DataArray:
"""Distance (km) from each ECCO2 T-cell centre to the nearest land cell.
Parameters
----------
k : int, optional
1-based model level whose land mask is used (see :func:`landmask`).
Default ``1`` (surface).
Returns
-------
xr.DataArray
float32 ``(y, x)`` great-circle distance in km, 0 on land, with
``lon`` / ``lat`` coordinates. See
:func:`tracmass.seagrid.coast.distance_to_land`.
"""
return coast.distance_to_land(landmask(k))
# @trace_lines
[docs]
def open_dataset(dtm="2020-01-01", data_var=None, force=False, dask=True):
"""Open an ECCO2 cube92 dataset, downloading it first if necessary.
Parameters
----------
dtm : str or datetime-like
Date string or datetime-like object for the desired time step.
data_var : str
Variable key (one of 'uvel', 'vvel', 'wvel', 'temp', 'salt'). If ``None``,
both velocity components are fetched and merged.
force : bool
If True, re-download the file even if it already exists locally.
dask : bool
If True (default), open the cached Zarr with Dask chunking.
Returns
-------
xarray.Dataset
Dataset containing the requested variable(s) for the given date,
with dims renamed to ``(z, y, x)``.
"""
if data_var is None:
dtm_ts = pd.to_datetime(dtm)
n_missing = sum(
not (datadir(dtm=dtm_ts) / filename(dtm=dtm_ts, data_var=var)).exists()
for var in ("uvel", "vvel")
)
if n_missing:
print(
f"Connecting to OPeNDAP for {n_missing} file(s) ({dtm_ts.date()}) …",
flush=True,
)
# Open both OPeNDAP connections concurrently (network-bound).
with ThreadPoolExecutor(max_workers=2) as pool:
futs = {
var: pool.submit(_prepare_write_job, dtm_ts, var, force)
for var in ("uvel", "vvel")
}
write_jobs = [futs[var].result() for var in ("uvel", "vvel")]
pending = [j for j in write_jobs if j is not None]
if pending:
print("Writing …", flush=True)
with ProgressBar():
_compute_with_retry(*pending, scheduler="threads", num_workers=8)
ds = open_dataset(dtm=dtm, data_var="uvel", dask=dask)
ds["vvel"] = open_dataarray(dtm=dtm, data_var="vvel", dask=dask)
return ds
dtm = pd.to_datetime(dtm)
fname = datadir(dtm=dtm) / filename(dtm=dtm, data_var=data_var)
if not fname.exists() or force:
retrieve(dtm=dtm, data_var=data_var, force=force)
if dask:
ds = xr.open_dataset(fname, chunks={}, consolidated=True)
else:
ds = xr.open_dataset(fname, consolidated=True)
ds = ds.rename({vardict[data_var][1]: data_var, "lev": "z", "lat": "y", "lon": "x"})
ds.attrs["timestep"] = settings["timestep"]
ds = ds.assign_coords({"time": dtm})
return ds
[docs]
def open_dataarray(dtm="2020-01-01", data_var="uvel", force=False, dask=True):
"""Open an ECCO2 cube92 dataarray, downloading it first if necessary.
Parameters
----------
dtm : str or datetime-like
Date string or datetime-like object for the desired time step.
data_var : str
Variable key (one of 'uvel', 'vvel', 'wvel', 'temp', 'salt').
force : bool
If True, re-download the file even if it already exists locally.
dask : bool
If True (default), open the cached Zarr with Dask chunking.
Returns
-------
xarray.DataArray
Data array for the requested variable and date.
"""
ds = open_dataset(dtm=dtm, data_var=data_var, force=force, dask=dask)
return ds[data_var]