Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion argopy/stores/float/argo_float.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,16 @@ class ArgoFloat(FloatStore):
ds = af.open_dataset('tech')
ds = af.open_dataset('Rtraj')
ds = af.open_dataset('Sprof')

ds = af.open_dataset('Sprof', netCDF4=True) # Return a netCDF4 Dataset instead of an xarray

.. code-block:: python
:caption: Load the BGC-Argo+ dataset (https://www.bgc-argo-plus.info)

# Fetch QC-processed, outlier-removed BGC data from the SOEST FTP server:
ds = af.open_dataset('BGCArgoPlus')
# Pin to a specific version:
ds = af.open_dataset('BGCArgoPlus', version='v0.1_2026_04')

.. code-block:: python
:caption: Other attributes and methods

Expand Down
217 changes: 217 additions & 0 deletions argopy/stores/float/bgcargo_plus.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
"""
BGC-Argo+ dataset store for :class:`argopy.ArgoFloat`.

The BGC-Argo+ dataset (https://www.bgc-argo-plus.info) is a quality-controlled,
outlier-removed version of BGC-Argo float data curated at SOEST / University of
Hawaiʻi at Mānoa (Bushinsky et al, 2025, Bushinsky et al, submitted). Individual float files are served on the SOEST FTP server::

ftp://ftp.soest.hawaii.edu/bgc_argo_plus/outliers_removed/

Usage
-----
The typical access path is through :class:`argopy.ArgoFloat`::

from argopy import ArgoFloat
ds = ArgoFloat(6903091).open_dataset('BGCArgoPlus')

You can also use the store directly::

from argopy.stores.float.bgcargo_plus import BGCArgoPlusStore
store = BGCArgoPlusStore(6903091)
ds = store.open_dataset()
url = store.url
"""

from __future__ import annotations

import logging

import xarray as xr

import numpy as np

from ...stores.implementations.ftp import ftpstore
from ...utils import check_wmo


def _decode_bytes_dataset(ds: xr.Dataset) -> xr.Dataset:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gmaze yes the .argo extension recognizes the dataset. I just created another hardcoded function _decode_bytes_dataset while loading the 'BGCArgoPlus' data to be able to go through .argo.profile2point() and back to .argo.point2profile().

I was thinking you would have something already implemented in argopy utils that handles the decode byte-string variables to str function of _decode_bytes_dataset.

You can start a review, all pytests are working on my local machine.

"""Decode byte-string variables to str.

h5py returns fixed-length HDF5 string variables as numpy bytes dtype
(``'|S...'``) instead of str. This normalises the whole dataset at load
time so that all downstream argopy code can use ordinary string operations.
"""
updates = {}
for name, var in ds.data_vars.items():
if var.dtype.kind == 'S': # fixed-length bytes, e.g. dtype='|S64'
decoded = np.char.decode(var.values, 'utf-8')
updates[name] = var.copy(data=decoded)
elif var.dtype.kind == 'O': # object array — may contain variable-length bytes
first = next(
(
x for x in var.values.flat
if x is not None and not (isinstance(x, float) and np.isnan(x))
),
None,
)
if isinstance(first, bytes):
decoded = np.vectorize(
lambda x: x.decode('utf-8') if isinstance(x, bytes) else x
)(var.values)
updates[name] = var.copy(data=decoded)
if updates:
ds = ds.assign(updates)

# PLATFORM_NUMBER is stored as a numeric string in HDF5 (e.g. '6903091')
# but argopy's uid() arithmetic requires it as an integer.
if 'PLATFORM_NUMBER' in ds.data_vars:
try:
pn = ds['PLATFORM_NUMBER']
ds = ds.assign({
'PLATFORM_NUMBER': pn.copy(
data=np.char.strip(pn.values.astype(str)).astype(np.int64)
)
})
except (ValueError, TypeError):
pass # leave as-is if conversion fails (e.g. non-numeric WMO)

return ds

log = logging.getLogger("argopy.stores.BGCArgoPlusStore")

#: Root of the BGC-Argo+ FTP tree (no trailing slash)
BGCARGO_PLUS_FTP_HOST = "ftp.soest.hawaii.edu"

#: Path template on the FTP server.
#: ``{version}`` can be overridden via :attr:`BGCArgoPlusStore.version`.
BGCARGO_PLUS_PATH_TEMPLATE = (
"/bgc_argo_plus/outliers_removed/{version}/{wmo}_Sprof_BGCArgoPlus.nc" # for versions v0.1_2025_12 and earlier
# "/bgc_argo_plus/outliers_removed/{version}/Individual_Floats/{wmo}_Sprof_BGCArgoPlus.nc" # for version v0.1_2026_04 and later, this is managed in the url function
)

#: Default version tag that maps to the latest production release on the FTP.
BGCARGO_PLUS_DEFAULT_VERSION = "v0.1_2026_04"
SUPPORTED_VERSIONS = {BGCARGO_PLUS_DEFAULT_VERSION, 'v0.1_2025_12', 'v0.0_2025_09'}


def bgcargo_plus_url(wmo: int, version: str = BGCARGO_PLUS_DEFAULT_VERSION) -> str:
"""Return the FTP URL for a BGC-Argo+ float file.

Parameters
----------
wmo : int
Float WMO number.
version : str, optional
Dataset version string, e.g. ``"v0.1_2026_04"``.

Returns
-------
str
Full FTP URL

Examples
--------
>>> from argopy.stores.float.bgcargo_plus import bgcargo_plus_url
>>> bgcargo_plus_url(6903091)
'ftp://ftp.soest.hawaii.edu/bgc_argo_plus/outliers_removed/v0.1_2026_04/6903091_Sprof_BGCArgoPlus.nc'
"""
path = BGCARGO_PLUS_PATH_TEMPLATE.format(wmo=wmo, version=version) if version != 'v0.1_2026_04' else BGCARGO_PLUS_PATH_TEMPLATE.format(wmo=wmo, version=version+"/Individual_Floats")
return f"ftp://{BGCARGO_PLUS_FTP_HOST}{path}"


class BGCArgoPlusStore:
"""Store that fetches BGC-Argo+ individual-float files from SOEST FTP.

Parameters
----------
wmo : int or str
Float WMO number.
version : str, optional
BGC-Argo+ dataset version, default :data:`BGCARGO_PLUS_DEFAULT_VERSION`.
cache : bool, optional
Cache downloaded files locally (passed to :class:`ftpstore`).
cachedir : str, optional
Local cache directory (passed to :class:`ftpstore`).
timeout : int, optional
FTP connection timeout in seconds (passed to :class:`ftpstore`).

Examples
--------
>>> from argopy.stores.float.bgcargo_plus import BGCArgoPlusStore
>>> store = BGCArgoPlusStore(6903091)
>>> ds = store.open_dataset()
>>> store.url
'ftp://ftp.soest.hawaii.edu/bgc_argo_plus/...'
"""

def __init__(
self,
wmo: int | str,
version: str = BGCARGO_PLUS_DEFAULT_VERSION,
cache: bool = False,
cachedir: str = "",
timeout: int = 0,
):
self.WMO = check_wmo(wmo)[0]
self.version = version
self.cache = cache
self.cachedir = cachedir
self.timeout = timeout

if self.version not in SUPPORTED_VERSIONS:
raise ValueError(
f"Unsupported BGC-Argo+ version '{self.version}'. "
f"Supported versions: {sorted(SUPPORTED_VERSIONS)}"
)

# Build the FTP store pointing at the SOEST server
ftp_root = f"{BGCARGO_PLUS_FTP_HOST}"
self._fs = ftpstore(
host=ftp_root,
cache=self.cache,
cachedir=self.cachedir if self.cachedir else None,
timeout=self.timeout if self.timeout else None,
)

@property
def url(self) -> str:
"""Full FTP URL of this float's BGC-Argo+ file."""
return bgcargo_plus_url(self.WMO, version=self.version)

def open_dataset(self, **kwargs) -> xr.Dataset:
"""Download and open the BGC-Argo+ netCDF file for this float.

Parameters
----------
**kwargs
Additional keyword arguments forwarded to
:meth:`argopy.stores.ftpstore.open_dataset` (e.g. ``lazy=True``,
``xr_opts={"decode_times": False}``).

Returns
-------
:class:`xarray.Dataset`

Examples
--------
>>> from argopy.stores.float.bgcargo_plus import BGCArgoPlusStore
>>> ds = BGCArgoPlusStore(6903091).open_dataset()
"""
log.debug("BGCArgoPlusStore: fetching %s", self.url)
try:
ds = _decode_bytes_dataset(self._fs.open_dataset(self.url, **kwargs))
except FileNotFoundError as exc:
raise FileNotFoundError(
f"Could not retrieve BGC-Argo+ file for WMO {self.WMO} "
f"(version='{self.version}') from {self.url}.\n"
f"Original error: {exc}"
) from exc
return ds

def __repr__(self) -> str:
return (
f"<BGCArgoPlusStore>\n"
f" WMO : {self.WMO}\n"
f" version : {self.version}\n"
f" URL : {self.url}\n"
)
62 changes: 53 additions & 9 deletions argopy/stores/float/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from ...utils import check_wmo, argo_split_path, shortcut2gdac
from ...options import OPTIONS
from .. import ArgoIndex
from .bgcargo_plus import BGCArgoPlusStore, BGCARGO_PLUS_DEFAULT_VERSION


log = logging.getLogger("argopy.stores.ArgoFloat")
Expand Down Expand Up @@ -301,30 +302,71 @@ def ls_dataset(self) -> dict:
avail.update({name: file})
return dict(sorted(avail.items()))

def open_dataset(
self, name: str = "prof", cast: bool = True, **kwargs
) -> xr.Dataset:
def open_dataset(self, name: str = "prof", cast: bool = True, **kwargs) -> xr.Dataset:
"""Open and decode a dataset

Parameters
----------
name: str, optional, default = "prof"
Name of the dataset to open. It can be any key from the dictionary returned by :class:`ArgoFloat.ls_dataset`.
Name of the dataset to open. It can be any key from the dictionary
returned by :class:`ArgoFloat.ls_dataset`, or the special value
``"BGCArgoPlus"`` to load the QC-processed file from the BGC-Argo+
dataset (https://www.bgc-argo-plus.info) served on the SOEST FTP.
cast: bool, optional, default = True
Determine if the dataset variables should be cast or not. This is similar to opening the dataset directly with :class:`xr.open_dataset` using the ``engine=`argo``` option.
This will be ignored if the ``netCDF4` kwarg is set to True.
Determine if the dataset variables should be cast or not. This is similar
to opening the dataset directly with :class:`xr.open_dataset` using the
``engine="argo"`` option.
This will be ignored if the ``netCDF4`` kwarg is set to True.
Note: ``cast`` is **not** applied when ``name="BGCArgoPlus"``.
version: str, optional
BGC-Argo+ dataset version tag (only used when ``name='BGCArgoPlus'``).
Defaults to :data:`argopy.stores.float.bgcargo_plus.BGCARGO_PLUS_DEFAULT_VERSION`.
**kwargs
All the other parameters are passed to the GDAC store `open_dataset` method.
All the other parameters are passed to the GDAC store ``open_dataset``
method (or to :class:`argopy.stores.float.bgcargo_plus.BGCArgoPlusStore`
when ``name="BGCArgoPlus"``).

Returns
-------
:class:`xarray.Dataset`

Notes
-----
Use the ``netCDF4=True`` option to return a :class:`netCDF4.Dataset` object instead of a :class:`xarray.Dataset`.
Use the ``netCDF4=True`` option to return a :class:`netCDF4.Dataset` object
instead of a :class:`xarray.Dataset`.

When ``name="BGCArgoPlus"``, the file is fetched from the SOEST FTP server::

ftp://ftp.soest.hawaii.edu/bgc_argo_plus/Individual_Floats/outliers_removed/

and a :class:`FileNotFoundError` is raised if the float is not (yet) part
of the BGC-Argo+ dataset.

Examples
--------
.. code-block:: python

from argopy import ArgoFloat
ds = ArgoFloat(6903091).open_dataset('BGCArgoPlus') # default version is 'v0.1_2026_04'
# Pin to a specific version:
ds = ArgoFloat(6903091).open_dataset('BGCArgoPlus', version='v0.1_2025_12')

"""
# ---- BGC-Argo+ special case -----------------------------------------
if name == "BGCArgoPlus":
version = kwargs.pop("version", BGCARGO_PLUS_DEFAULT_VERSION)
store = BGCArgoPlusStore(
self.WMO,
version=version,
cache=self.cache,
cachedir=self.cachedir,
timeout=self.timeout,
)
ds = store.open_dataset(**kwargs)
self._dataset["BGCArgoPlus"] = ds
return ds
# ---------------------------------------------------------------------

if name not in self.ls_dataset():
raise ValueError(
"Dataset '%s' not found. Available dataset for this float are: %s"
Expand Down Expand Up @@ -404,6 +446,8 @@ def __repr__(self):
summary.append("Number of cycles: %s" % self.N_CYCLES)
if self._online:
summary.append("Dashboard: %s" % dashboard(wmo=self.WMO, url_only=True))
summary.append("Netcdf dataset available: %s" % list(self.ls_dataset().keys()))
summary.append(
"Netcdf dataset available: %s" % list(self.ls_dataset().keys())
)

return "\n".join(summary)
Loading
Loading