Dataset Read Guidelines

Contents

Dataset Read Guidelines#

GLORYS#

The default approach shown for reading and accessing previously-saved GLORYS output is to access the files directly using xr.open_mfdataset() pointing to a list of files. This is ok, but it can generally be slow. If you would like to speed up your access to previously-saved GLORYS files, you can try subchunking (Example Medium article we follow which uses kerchunk). Note that this works because the files are uncompressed; if you have compressed files you would like to try subchunking with, check out this paper: A new sub-chunking strategy for fast netCDF-4 access in local, remote and cloud infrastructures, chunkindex V1.1.0.

Code is sketched out here that you can copy-paste; it isn’t in the ROMS-Tools codebase because it requires several additional libraries and is not required to be used.

This code changes the files from individual and unchunked into a representation that knows where to find the variables so it can preload the necessary index information. Additionally, it “subchunks” by depth which makes these unchunked files smaller and easier to fit into memory. The speed up on the HPC we have been running on is very large — one year of generating boundary forcing files using this subchunked representation takes about 30 minutes and when pointing to the files directly takes 3 hours.

[4]:
"""
Build kerchunk Parquet references for local GLORYS NetCDF4 files.
Adapted from github.com/rsignell/hycom-kerchunk (0_hycom_generate_refs.ipynb).

Assumes GLORYS files are contiguous + uncompressed.

Set depth_factor and lat_factor to select the subchunk variation (see
the GLORYS case in __main__).
"""
import glob
import fsspec
from pathlib import Path
import xarray as xr
from fsspec.implementations.reference import LazyReferenceMapper
from kerchunk.hdf import SingleHdf5ToZarr
from kerchunk.combine import MultiZarrToZarr
from kerchunk.utils import subchunk
from time import time

# for running in a notebook
import nest_asyncio
nest_asyncio.apply()

def dict_to_parquet(refs_dict, output_path, record_size=100_000):
    """Write a kerchunk refs dict to parquet.

    Uses a two-pass write to work around a LazyReferenceMapper bug where
    .zarray metadata written in the same translate() call isn't visible
    via zmetadata until flushed to disk.
    """
    refs = refs_dict.get("refs", {})
    out = LazyReferenceMapper.create(output_path, record_size=record_size)

    for k, v in refs.items():         # pass 1: .zarray / .zattrs / top-level
        if ".z" in k or "/" not in k:
            out[k] = v
    out.flush()                        # flush so zmetadata can find .zarray

    for k, v in refs.items():         # pass 2: chunk references
        if ".z" not in k and "/" in k:
            out[k] = v
    out.flush()


def subchunk_var(refs, var, depth_factor, lat_factor):
    if depth_factor and depth_factor > 1:
        refs = subchunk(refs, var, depth_factor)   # splits outermost non-unit axis = depth
    if lat_factor and lat_factor > 1:
        refs = subchunk(refs, var, lat_factor)     # depth now == 1, so this hits latitude
    return refs


def build_one(path, data_vars_4d, depth_factor, lat_factor, inline_threshold):
    refs = SingleHdf5ToZarr(path, inline_threshold=inline_threshold).translate()
    for v in data_vars_4d:
        refs = subchunk_var(refs, v, depth_factor, lat_factor)
    return refs


def run(
    files,
    out,
    output_format=".parquet",
    data_vars_4d=("thetao", "so", "uo", "vo"),
    concat_dim="time",
    identical_dims=("latitude", "longitude", "depth"),
    inline_threshold=5000,
    depth_factor=None,
    lat_factor=None,
):
    """Build combined kerchunk references for a set of NetCDF4 files.

    Parameters
    ----------
    files : sequence of str
        Paths to the source NetCDF4 files (already sorted as desired).
    out : str
        Output path. For ".parquet"/".json" the appropriate extension
        should already be appended (see __main__). Ignored for "in-memory".
    output_format : {".parquet", ".json", "in-memory"}
        How to emit the combined references.
    data_vars_4d : sequence of str
        4D data variables to subchunk.
    concat_dim : str
        Dimension along which the per-file references are concatenated.
    identical_dims : sequence of str
        Dimensions identical across files (not concatenated).
    inline_threshold : int
        Bytes below which chunks are inlined rather than referenced.
    depth_factor, lat_factor : int or None
        subchunk factors; see subchunk_var.

    Returns
    -------
    dict
        The combined kerchunk references.
    """
    # 1) per-file refs — parallelise with ProcessPoolExecutor for a year / 17 yr
    #    (avoid the deepcopy-and-patch shortcut from HYCOM: it assumes identical
    #     HDF5 byte offsets across files, which is unsafe for NetCDF4)
    single_refs = [
        build_one(p, data_vars_4d, depth_factor, lat_factor, inline_threshold)
        for p in files
    ]

    # 2) combine across files
    combined = MultiZarrToZarr(
        single_refs,
        remote_protocol="file",
        concat_dims=[concat_dim],
        identical_dims=list(identical_dims),
    ).translate()

    # 3) emit
    if output_format == ".json":
        import ujson

        with open(out, "w") as f:
            ujson.dump(combined, f)
        print(f"References written to {out}")

    elif output_format == ".parquet":
        dict_to_parquet(combined, out)
        print(f"References written to {out}")

    return combined


def build_subchunk_refs(
    input_files,
    out,
    output_format=".parquet",
    data_vars_4d=("thetao", "so", "uo", "vo"),
    concat_dim="time",
    identical_dims=("latitude", "longitude", "depth"),
    inline_threshold=5000,
    depth_factor=None,
    lat_factor=1,
    overwrite=False,
):
    """Build (and optionally write) subchunked kerchunk references.

    Wraps :func:`run` with file globbing, output-path handling, an
    auto-detected depth factor, a skip-if-exists guard, and timing.

    Parameters
    ----------
    input_files : str or sequence of str
        A glob pattern (e.g. ``".../GLOBAL_2024????.nc"``) or an explicit,
        already-sorted list of NetCDF4 file paths. A glob is expanded and
        sorted automatically.
    out : str
        Output path *without* extension. The extension is appended from
        ``output_format`` (ignored for ``"in-memory"``).
    output_format : {".parquet", ".json", "in-memory"}
        How to emit the combined references.
    data_vars_4d : sequence of str
        4D data variables to subchunk.
    concat_dim : str
        Dimension along which per-file references are concatenated (time).
    identical_dims : sequence of str
        Dimensions identical across files (not concatenated).
    inline_threshold : int
        Bytes below which chunks are inlined rather than referenced.
    depth_factor : int or None
        subchunk factor along depth. ``None`` (default) auto-detects the
        depth size from the first file, collapsing depth to 1 chunk.
    lat_factor : int
        subchunk factor along latitude (1 = no latitude subchunking).
    overwrite : bool
        If False (default) and the output already exists, skip and return
        its path without rebuilding.

    Returns
    -------
    str or dict
        The output path for ``".parquet"``/``".json"``, or the combined
        references dict for ``"in-memory"``.
    """
    start_time = time()

    # 1) resolve input files
    if isinstance(input_files, str):
        files = sorted(glob.glob(input_files))
    else:
        files = list(input_files)
    if not files:
        raise FileNotFoundError(f"No input files matched: {input_files!r}")

    # 2) resolve output path / skip-if-exists
    if output_format != "in-memory":
        out = out + output_format
        if Path(out).exists() and not overwrite:
            print(f"{out} already exists, skipping (pass overwrite=True to rebuild)")
            return out

    # 3) auto-detect depth factor (collapse depth to 1 chunk) if not given
    if depth_factor is None:
        with xr.open_dataset(files[0]) as ds:
            depth_factor = ds.sizes["depth"]
        print(f"Using depth_factor={depth_factor} (depth size of {files[0]})")

    # 4) build
    result = run(
        files=files,
        out=out,
        output_format=output_format,
        data_vars_4d=list(data_vars_4d),
        concat_dim=concat_dim,
        identical_dims=list(identical_dims),
        inline_threshold=inline_threshold,
        depth_factor=depth_factor,
        lat_factor=lat_factor,
    )

    print(f"Making subchunked file representation took {time() - start_time:.1f} seconds")
    return out if output_format != "in-memory" else result

Example call:

[5]:
out = build_subchunk_refs(
    input_files=(
        "/anvil/scratch/x-kthyng/GLORYS_GLOBAL/"
        "cmems_mod_glo_phy_my_0.083deg_P1D-m_GLOBAL_2024????.nc"
    ),
    out="/anvil/scratch/x-kthyng/GLORYS_GLOBAL/test-2024-subchunk",
    output_format=".parquet",
    data_vars_4d=["thetao", "so", "uo", "vo"],
    # depth_factor=None  -> auto-detect (collapse depth); set lat_factor=13 for 157-row strips
)
Using depth_factor=50 (depth size of /anvil/scratch/x-kthyng/GLORYS_GLOBAL/cmems_mod_glo_phy_my_0.083deg_P1D-m_GLOBAL_20240501.nc)
References written to /anvil/scratch/x-kthyng/GLORYS_GLOBAL/test-2024-subchunk.parquet
Making subchunked file representation took 2.1 seconds
[7]:
loc = "/anvil/scratch/x-kthyng/GLORYS_GLOBAL/test-2024-subchunk.parquet"
ds = xr.open_dataset(loc, chunks={})
ds
[7]:
<xarray.Dataset> Size: 440GB
Dimensions:    (time: 31, depth: 50, latitude: 2041, longitude: 4320)
Coordinates:
  * depth      (depth) float32 200B 0.494 1.541 2.646 ... 5.275e+03 5.728e+03
  * latitude   (latitude) float32 8kB -80.0 -79.92 -79.83 ... 89.83 89.92 90.0
  * longitude  (longitude) float32 17kB -180.0 -179.9 -179.8 ... 179.8 179.9
  * time       (time) datetime64[ns] 248B 2024-05-01 2024-05-02 ... 2024-05-31
Data variables:
    so         (time, depth, latitude, longitude) float64 109GB dask.array<chunksize=(1, 1, 2041, 4320), meta=np.ndarray>
    thetao     (time, depth, latitude, longitude) float64 109GB dask.array<chunksize=(1, 1, 2041, 4320), meta=np.ndarray>
    uo         (time, depth, latitude, longitude) float64 109GB dask.array<chunksize=(1, 1, 2041, 4320), meta=np.ndarray>
    vo         (time, depth, latitude, longitude) float64 109GB dask.array<chunksize=(1, 1, 2041, 4320), meta=np.ndarray>
    zos        (time, latitude, longitude) float64 2GB dask.array<chunksize=(1, 2041, 4320), meta=np.ndarray>
Attributes: (12/25)
    Conventions:               CF-1.4
    bulletin_date:             2021-07-07 00:00:00
    bulletin_type:             operational
    comment:                   CMEMS product
    domain_name:               GL12
    easting:                   longitude
    ...                        ...
    references:                http://www.mercator-ocean.fr
    source:                    MERCATOR GLORYS12V1
    title:                     daily mean fields from Global Ocean Physics An...
    z_max:                     5727.9169921875
    z_min:                     0.49402499198913574
    copernicusmarine_version:  2.3.0

Note that now the variables are chunked in depth which allows for faster processing.

[8]:
ds.thetao.chunks
[8]:
((1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1),
 (1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1,
  1),
 (2041,),
 (4320,))