Source code for pelagos_py.utils.demo_data

# This file is part of pelagos_py.
#
# Copyright 2025-2026 National Oceanography Centre and The Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Demo OG1 datasets: the registry behind the dashboard's picker, and :func:`get_demo_file`.

``DEMOS`` keys are the lower-case file names, e.g. ``churchill_647`` (delayed mode) and
``churchill_647_r`` (near real time); ``MISSIONS`` groups them by campaign in picker order.
"""

from dataclasses import dataclass
from pathlib import Path

import numpy as np
import requests
import xarray as xr
from tqdm import tqdm

_MODE_LABELS = {"nrt": "NRT", "delayed": "Full"}


@dataclass(frozen=True)
class DemoEntry:
    url: str
    filename: str
    window: tuple[str, str] | None
    label: str  # glider display name, shared by its nrt/delayed variants
    mode: str  # "nrt" or "delayed"

    @property
    def display_label(self) -> str:  # e.g. "Nelson (NRT)"
        return f"{self.label} ({_MODE_LABELS[self.mode]})"


_OG1_NRT = "https://linkedsystems.uk/erddap/files/Public_OG1_Data_001"
_OG1_DELAYED = "https://linkedsystems.uk/erddap/files/Public_OG1_Data_001_Recovery"
_GLIDER_DATA = "https://linkedsystems.uk/erddap/files/Public_Glider_Data_0711"


def _deployment(folder, label, nrt=None, delayed=None, window=None):
    # The NRT and delayed-mode (recovered) files of one deployment, from the BODC OG1 store.
    entries = []
    if nrt:
        entries.append(DemoEntry(f"{_OG1_NRT}/{folder}/{nrt}", nrt, None, label, "nrt"))
    if delayed:
        entries.append(DemoEntry(f"{_OG1_DELAYED}/{folder}/{delayed}", delayed, window, label, "delayed"))
    return entries


# Picker groups, in order.
_MISSION_ENTRIES = {
    "Bio-Carbon": [
        *_deployment("Nelson_20240528", "Nelson", nrt="Nelson_646_R.nc", delayed="Nelson_646.nc"),
        *_deployment("Doombar_20240528", "Doombar", nrt="Doombar_648_R.nc", delayed="Doombar_648.nc"),
        *_deployment("Churchill_20240528", "Churchill", nrt="Churchill_647_R.nc",
                     delayed="Churchill_647.nc", window=("2024-08-01", "2024-09-01")),
        *_deployment("ALR_4_20240609", "ALR 4", nrt="ALR_4_649_R.nc", delayed="ALR_4_649.nc"),
        *_deployment("ALR_6_20240611", "ALR 6", nrt="ALR_6_650_R.nc", delayed="ALR_6_650.nc"),
        *_deployment("Cabot_20240528", "Cabot", nrt="Cabot_645_R.nc", delayed="Cabot_645.nc"),
    ],
    "Custard 1": [
        *_deployment("Churchill_20181204", "Churchill", nrt="Churchill_501_R.nc", delayed="Churchill_501.nc"),
        *_deployment("Pancake_20181209", "Pancake", nrt="Pancake_502_R.nc"),
        # Only hosted on the raw glider-data store, not the OG1 one.
        DemoEntry(f"{_GLIDER_DATA}/Doombar_20181204/Doombar_503_R.nc", "Doombar_503_R.nc",
                  None, "Doombar", "nrt"),
    ],
    "Custard 2": [
        *_deployment("Bellamite_20191206", "Bellamite", nrt="Bellamite_538_R.nc", delayed="Bellamite_538.nc"),
        *_deployment("Zephyr_20191206", "Zephyr", delayed="Zephyr_539.nc"),
    ],
    "ReBELS": [
        *_deployment("Zephyr_20250323", "Zephyr", nrt="Zephyr_675_R.nc", delayed="Zephyr_675.nc"),
        *_deployment("OMG-1_20250324", "OMG-1", nrt="OMG-1_676_R.nc"),
        *_deployment("9JA_20250812", "9JA", nrt="9JA_699_R.nc", delayed="9JA_699.nc"),
        *_deployment("Growler_20250323", "Growler", nrt="Growler_677_R.nc", delayed="Growler_677.nc"),
        *_deployment("Stella_20250323", "Stella", nrt="Stella_678_R.nc", delayed="Stella_678.nc"),
    ],
    "ReBELS 2": [
        *_deployment("Stella_20260403", "Stella", nrt="Stella_713_R.nc"),
    ],
    "VOTO": [
        # Hosted on VOTO's own erddap, not BODC.
        DemoEntry("https://erddap.observations.voiceoftheocean.org/erddap/files/"
                  "OG_complete_SEA063_M75/SEA063_20240724T0737_delayed.nc",
                  "SEA063_20240724T0737_delayed.nc", ("2024-07-25", "2024-08-03"), "SEA063", "delayed"),
    ],
}


def _key(entry):
    # The file name, e.g. "Churchill_647_R.nc" -> "churchill_647_r".
    return Path(entry.filename).stem.lower()


DEMOS = {_key(e): e for entries in _MISSION_ENTRIES.values() for e in entries}
MISSIONS = {mission: [_key(e) for e in entries] for mission, entries in _MISSION_ENTRIES.items()}

DEMO_DATA_DIR = Path(__file__).resolve().parents[3] / "examples" / "data" / "OG1"


[docs] def get_demo_file(name=None, on_progress=None): """ Download a demo OG1 file into ``examples/data/OG1`` (only the first time) and return its path. Long deployments are cut down to a shorter time window after downloading. Call it with no name to list the demos. .. code-block:: python from pelagos_py import Pipeline, get_demo_file Pipeline.make_config(get_demo_file("nelson_646_r")).run() """ if name is None: for mission, keys in MISSIONS.items(): print(f"{mission}: {', '.join(keys)}") return None if name not in DEMOS: raise ValueError(f"Unknown demo '{name}'. Call get_demo_file() to list them.") entry = DEMOS[name] dest = DEMO_DATA_DIR / entry.filename if dest.exists(): return dest DEMO_DATA_DIR.mkdir(parents=True, exist_ok=True) _download(entry.url, dest, on_progress) if entry.window is not None: _cut_to_window(dest, *entry.window) return dest
def _download(url, dest, on_progress=None): # Files are 100s of MB: bound only the connect phase, not the transfer. response = requests.get(url, stream=True, timeout=(15, None)) response.raise_for_status() total = int(response.headers.get("Content-Length") or 0) tmp = dest.with_name(dest.name + ".part") done = 0 try: with open(tmp, "wb") as f, tqdm( total=total, unit="B", unit_scale=True, desc=f"Downloading {dest.name}" ) as bar: for chunk in response.iter_content(chunk_size=1 << 20): f.write(chunk) bar.update(len(chunk)) done += len(chunk) if on_progress: on_progress(done, total) tmp.rename(dest) finally: tmp.unlink(missing_ok=True) # left behind only if the download failed def _cut_to_window(path, start, end): # Keep measurements in [start, end) whose TIME is valid and strictly increasing # (the pipeline requires monotonic, non-NaT time). NaT excludes itself since its # comparisons are False; the running-max test drops any out-of-order sample. with xr.open_dataset(path) as ds: t = ds["TIME"].values idx = np.flatnonzero((t >= np.datetime64(start)) & (t < np.datetime64(end))) if idx.size: tt = t[idx] increasing = np.concatenate(([True], tt[1:] > np.maximum.accumulate(tt)[:-1])) idx = idx[increasing] n_total = ds.sizes["N_MEASUREMENTS"] if idx.size == 0: print(f" no measurements found in {start}..{end}; leaving file unchanged") return print(f" cutting to {start}..{end}: {idx.size} of {n_total} measurements...") # Read the contiguous slice covering the window, then pick out the kept rows # in memory: a fancy-index isel straight off the compressed file stalls for # millions of points. lo, hi = int(idx[0]), int(idx[-1]) + 1 subset = ds.isel(N_MEASUREMENTS=slice(lo, hi)).load().isel(N_MEASUREMENTS=idx - lo) # Drop the source's chunk encoding (its chunksizes were sized for the full # dimension and stall the subset write); re-apply zlib to keep the file small. encoding = {} for v in subset.variables: subset[v].encoding = {} if subset[v].dtype.kind in "fiu": encoding[v] = {"zlib": True, "complevel": 2} # Swap the trimmed file in only once it's fully written, so a failed write never leaves a partial file. tmp = path.with_suffix(".cut.nc") try: subset.to_netcdf(tmp, encoding=encoding) tmp.replace(path) finally: tmp.unlink(missing_ok=True)