"""Retrieve and store daily ocean reanalysis data from Mercator Ocean."""
import pathlib
import warnings
from concurrent.futures import ThreadPoolExecutor
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
settings = dict(
host="dap2://tds.mercator-ocean.fr",
product="glorys12v1",
timestep=pd.Timedelta(1, "D"),
disk="raid",
)
vardict = dict(
uvel=["U", "vozocrtx"],
vvel=["V", "vomecrty"],
wvel=["W", "vovecrtz"],
temp=["T", "votemper"],
salt=["S", "vosaline"],
)
# (connect, read) timeout, in seconds, for every OPeNDAP request made against
# the Mercator THREDDS 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)
# NetCDF default fill value (9.96921e36). The Mercator OPeNDAP velocities carry it at
# masked (land) points, and xarray does not decode it there, so it lands in
# the cached Zarrs as data. Anything at or above _FILL_THRESHOLD is fill.
_FILL_THRESHOLD = 1e30
def _mask_fill(da, other):
"""Replace fill values in *da* with *other* (lazy; keeps Dask chunks)."""
return da.where(abs(da) < _FILL_THRESHOLD, other)
[docs]
def mask_fill(da):
"""Replace the fill values (land) in a GLORYS variable (lazy).
Masked points hold the fill value: no flow through land (velocities
``uvel`` / ``vvel`` / ``wvel`` → 0); tracers have no land value (→ NaN).
Parameters
----------
da : xr.DataArray
A variable from :func:`open_dataset`, named by its key (``"uvel"``,
``"temp"``, ...).
Returns
-------
xr.DataArray
Same shape and Dask chunks.
"""
return _mask_fill(da, 0 if da.name in ("uvel", "vvel", "wvel") else np.nan)
def basedir():
path = pathlib.Path(f"/data/{settings['disk']}/mercator/{settings['product']}")
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 dat 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 ``GLORYS12V1_<date>_grid<X>.zarr``.
"""
def dtmstr(dtm):
return f"{dtm.year}{dtm.month:02}{dtm.day:02}"
dtm = pd.to_datetime(dtm)
chr = vardict[data_var][0]
if settings["product"] == "psy4v3r1":
# ext-PSY4V3R1_1dAV_20121123_20121124_gridW_R20121128.nc
fn = (
"ext-PSY4V3R1_1dAV_"
+ f"{dtmstr(dtm)}_{dtmstr(dtm + pd.DateOffset(1, 'D'))}_grid{chr}_"
+ f"R{dtmstr(pd.Timestamp.now())}.zarr"
)
else:
fn = f"{settings['product'].upper()}_{dtm.date()}_grid{chr}.zarr"
return fn
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
# wvel is on depthw: without its own key it got one 50-level chunk.
chunks = {"time_counter": 1, "deptht": 10, "depthw": 10, "y": 1000, "x": 1000}
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)
path = f"/thredds/dodsC/{settings['product']}-daily-grid"
url = settings["host"] + path + vardict[data_var][0]
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_counter).normalize()
iloc = np.argmin(np.abs(dtm_index - dtm))
da = ds[vardict[data_var][1]].isel(time_counter=iloc).to_dataset()
return da.to_zarr(str(ddir / fname), mode="w", compute=False)
[docs]
def retrieve(dtm="2020-01-01", data_var="uvel", force=False):
"""Download a single daily field from the Mercator THREDDS server and save it as Zarr.
Opens the remote 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 warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message="Consolidated metadata is currently not part",
)
with ProgressBar():
write_job.compute(scheduler="threads", num_workers=8)
def calc_e3t(ds):
e3t_0 = ds.e3t_0.isel(t=0).values # (z,)
e3t_ps = ds.e3t_ps.isel(t=0).values # (y, x)
mbathy = ds.mbathy.isel(t=0).values # (y, x), 1-based level index of bottom cell
nz = len(ds.nav_lev)
ny, nx = e3t_ps.shape
# k_index = np.arange(1, nz + 1)[:, None, None] * np.ones((nz, ny, nx))
k_index = np.arange(1, nz + 1)[:, None, None] # shape (nz, 1, 1)
# e3t_3d = np.broadcast_to(e3t_0[:, None, None], (nz, ny, nx)).copy()
e3t_3d = np.empty((nz, ny, nx))
e3t_3d[:] = e3t_0[:, None, None]
bottom_mask = k_index == mbathy[None, :, :]
e3t_3d[bottom_mask] = np.broadcast_to(e3t_ps[None, :, :], (nz, ny, nx))[bottom_mask]
e3t_3d[k_index > mbathy[None, :, :]] = 0.0
e3t = xr.DataArray(
e3t_3d,
dims=["z", "y", "x"],
coords={"nav_lev": ds.nav_lev},
attrs={"units": "m", "long_name": "T-cell thickness"},
)
return e3t
def grid_definition(dask=True, force=False):
gridfile = basedir() / f"trm_{settings['product']}_grid_def.zarr"
if not gridfile.is_dir() or force:
ds = xr.Dataset()
chunks = {} if dask else None
zgr = xr.open_dataset(basedir() / "ext-PSY4V3R1_mesh_zgr.nc", chunks=chunks)
hgr = xr.open_dataset(basedir() / "ext-PSY4V3R1_mesh_hgr.nc", chunks=chunks)
ds["dxv"] = hgr["e1v"]
ds["dyu"] = hgr["e2u"]
ds["dxdy"] = ds["dxv"] * ds["dyu"]
ds["lon"] = hgr["nav_lon"]
ds["lat"] = hgr["nav_lat"]
ds["kmt"] = zgr["mbathy"]
ds = ds.set_coords(["lon", "lat"])
ds["dzt"] = calc_e3t(zgr)
ds.to_zarr(gridfile)
zarr.consolidate_metadata(gridfile)
# chunks={} honours the zarr store's native layout (200×200 after rechunking).
# chunks="auto" would collapse each variable into a single global slab, forcing
# expensive Dask rechunks when multiplying grid fields against the velocity arrays.
ds = xr.open_dataset(gridfile, chunks={})
# Grid files built before 2026-10-03 had every zero (land kmt, land dzt)
# overwritten with the fill value; land must be kmt == 0 / dzt == 0.
return ds.map(_mask_fill, keep_attrs=True, other=0)
[docs]
def landmask(k: int = 1) -> xr.DataArray:
"""GLORYS12 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 GLORYS12 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, mask=True):
"""Open a mercator 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').
force : bool
If True, re-download the file even if it already exists locally.
mask : bool, optional
Replace fill values (land) with 0 for velocities and NaN for
tracers. Default True. ``False`` leaves them in, for callers that
first cut out a region and then mask just that with
:func:`mask_fill` (masking a whole Zarr chunk to keep a small part
of it is several times slower).
Returns
-------
xarray.Dataset
Dataset containing the requested variable for the given date.
"""
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", "wvel")
)
if n_missing:
print(
f"Connecting to OPeNDAP for {n_missing} file(s) ({dtm_ts.date()}) …",
flush=True,
)
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message="Consolidated metadata is currently not part",
)
# Open all OPeNDAP connections concurrently (network-bound).
# The warning filter must be active before threads start because
# zarr fires ZarrUserWarning during to_zarr(..., compute=False).
with ThreadPoolExecutor(max_workers=3) as pool:
futs = {
var: pool.submit(_prepare_write_job, dtm_ts, var, force)
for var in ("uvel", "vvel", "wvel")
}
write_jobs = [futs[var].result() for var in ("uvel", "vvel", "wvel")]
# Filter out cached files, then compute all pending writes together
# under a single ProgressBar so output is never interleaved.
pending = [j for j in write_jobs if j is not None]
if pending:
import dask
print("Writing …", flush=True)
with ProgressBar():
dask.compute(*pending, scheduler="threads", num_workers=8)
ds = open_dataset(dtm=dtm, data_var="uvel", mask=mask)
ds["vvel"] = open_dataarray(dtm=dtm, data_var="vvel", mask=mask)
ds["wvel"] = open_dataarray(dtm=dtm, data_var="wvel", mask=mask)
ds = ds.rename_dims({"deptht": "z", "depthw": "z"})
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)
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message="Consolidated metadata is currently not part",
)
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,
"nav_lat": "lat_" + data_var[0],
"nav_lon": "lon_" + data_var[0],
}
)
ds.attrs["timestep"] = settings["timestep"]
ds = ds.assign_coords({"time": dtm}).drop_vars("time_counter")
ds = ds.assign_coords({"time": dtm})
if mask:
ds[data_var] = mask_fill(ds[data_var])
return ds
[docs]
def rechunk_wvel(dtm1, dtm2=None, depth_chunk: int = 10, path=None) -> list:
"""Rewrite cached ``wvel`` Zarr stores with *depth_chunk*-level chunks.
``wvel`` stores downloaded before 2026-10-08 hold each 1000 x 1000 tile
as one 50-level chunk (the download chunked ``deptht`` but ``wvel`` is
on ``depthw``), so reading a few levels decompresses all 50 in one
thread. The x/y chunks are kept. Each store is written next to the
original, checked to hold identical values, and only then swapped in;
stores that already have *depth_chunk* levels per chunk are skipped.
Do not run it on days a tracking run is reading at the same time. A
run opens each day's store (and its chunk layout) a window before it
reads the data; if the store is swapped in between, the read fails
with ``cannot reshape array of size 10000000 into shape
(50,1000,1000)``. A run is usually faster than the rechunk, so a run
that starts behind it catches up. Rechunk first, or rechunk days the
run has already passed.
Parameters
----------
dtm1 : str or datetime-like
First day.
dtm2 : str or datetime-like or None, optional
Last day (inclusive). Default: only *dtm1*.
depth_chunk : int, optional
Levels per chunk. Default 10, as ``uvel`` / ``vvel``.
path : str or pathlib.Path or None, optional
Rechunk this one store instead of the cached ``wvel`` of
*dtm1* .. *dtm2* (for testing on a copy).
Returns
-------
list of pathlib.Path
The stores that were rewritten.
Raises
------
RuntimeError
If a rewritten store does not match its original; the original is
then left untouched.
Examples
--------
>>> rechunk_wvel("2010-06-01", "2011-06-01")
"""
import shutil
if path is not None:
paths = [pathlib.Path(path)]
else:
days = pd.date_range(dtm1, dtm2 if dtm2 is not None else dtm1, freq="D")
paths = [datadir(dtm=d) / filename(dtm=d, data_var="wvel") for d in days]
var = vardict["wvel"][1]
done = []
for i, store in enumerate(paths, 1):
if not store.exists():
print(f"[{i}/{len(paths)}] {store.name}: missing, skipped")
continue
src = zarr.open_group(str(store), mode="r")
if src[var].chunks[0] == depth_chunk:
print(f"[{i}/{len(paths)}] {store.name}: already rechunked")
continue
print(f"[{i}/{len(paths)}] {store.name} ...", flush=True)
tmp = store.with_name(store.name + ".tmp")
if tmp.exists():
shutil.rmtree(tmp)
try:
with warnings.catch_warnings():
warnings.filterwarnings("ignore")
ds = xr.open_dataset(store, chunks={}, consolidated=True)
da = ds[var]
chunks = (depth_chunk,) + tuple(src[var].chunks[1:])
ds = ds.chunk(dict(zip(da.dims, chunks)))
for v in ds.variables:
ds[v].encoding.pop("chunks", None)
ds[v].encoding.pop("preferred_chunks", None)
ds.to_zarr(
str(tmp), mode="w", consolidated=True,
encoding={var: {"chunks": chunks}},
zarr_format=src.metadata.zarr_format, # keep v2 stores v2
)
new = xr.open_dataset(
tmp, chunks={}, consolidated=True, engine="zarr"
)[var]
same = bool(
((new == da) | (new.isnull() & da.isnull())).all().compute()
)
except BaseException:
shutil.rmtree(tmp, ignore_errors=True)
raise
if not same:
shutil.rmtree(tmp)
raise RuntimeError(
f"rechunk_wvel: {tmp} differs from {store}; kept original"
)
# Swap by renames, so the store is missing only between two renames
# (deleting the original first left it missing for the whole rmtree).
old = store.with_name(store.name + ".old")
if old.exists():
shutil.rmtree(old)
store.rename(old)
tmp.rename(store)
shutil.rmtree(old)
done.append(store)
return done
[docs]
def open_dataarray(dtm="2020-01-01", data_var="uvel", force=False, mask=True):
"""Open a mercator 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.
mask : bool, optional
Mask fill values, see :func:`open_dataset`. Default True.
Returns
-------
xarray.Dataset
Dataset containing the requested variable for the given date.
"""
ds = open_dataset(dtm=dtm, data_var=data_var, force=force, mask=mask)
return ds[data_var]