diff --git a/argopy/stores/float/argo_float.py b/argopy/stores/float/argo_float.py index 5877b9dc1..3070fc6b2 100644 --- a/argopy/stores/float/argo_float.py +++ b/argopy/stores/float/argo_float.py @@ -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 diff --git a/argopy/stores/float/bgcargo_plus.py b/argopy/stores/float/bgcargo_plus.py new file mode 100644 index 000000000..db3b78cf7 --- /dev/null +++ b/argopy/stores/float/bgcargo_plus.py @@ -0,0 +1,218 @@ +""" +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: + """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"\n" + f" WMO : {self.WMO}\n" + f" version : {self.version}\n" + f" URL : {self.url}\n" + ) diff --git a/argopy/stores/float/spec.py b/argopy/stores/float/spec.py index bd8a0ab1d..54aa68133 100644 --- a/argopy/stores/float/spec.py +++ b/argopy/stores/float/spec.py @@ -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") @@ -305,20 +306,29 @@ 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 ------- @@ -326,9 +336,41 @@ def open_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" @@ -408,6 +450,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) diff --git a/argopy/tests/test_stores_float_bgcargoplus.py b/argopy/tests/test_stores_float_bgcargoplus.py new file mode 100644 index 000000000..ecee40a86 --- /dev/null +++ b/argopy/tests/test_stores_float_bgcargoplus.py @@ -0,0 +1,295 @@ +""" +Tests for the BGC-Argo+ integration in argopy. This test page (excluding TestBGCArgoPlusIntegration) has been generated by AI (Claude Sonnet 4.6 model) and reviewed by a human. + +These tests cover: + - :class:`argopy.stores.float.bgcargo_plus.BGCArgoPlusStore` + - The ``'BGCArgoPlus'`` path through :meth:`argopy.ArgoFloat.open_dataset` + +Tests are split into two groups: + * **Unit tests** (no network) — use mocking / monkeypatching. + * **Integration tests** (network required) — marked with ``pytest.mark.requires_connection`` + and skipped in CI environments that set ``ARGOPY_SKIP_NETWORK_TESTS=1``. + +Run only unit tests:: + + pytest tests/test_store_bgcargo_plus.py -m "not requires_connection" + +Run all (requires internet access to the SOEST FTP):: + + pytest tests/test_store_bgcargo_plus.py +""" + +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest +import xarray as xr + +from argopy.stores.float.bgcargo_plus import ( + BGCARGO_PLUS_DEFAULT_VERSION, + BGCARGO_PLUS_FTP_HOST, + BGCArgoPlusStore, + bgcargo_plus_url, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +TEST_WMO = 6903091 +TEST_VERSION = "v0.1_2026_04" + + + +def _make_dummy_dataset() -> xr.Dataset: + """Return a minimal xarray.Dataset that looks like a BGC-Argo+ file.""" + n = 10 + return xr.Dataset( + { + "PRES": ("N_LEVELS", np.linspace(0, 2000, n)), + "TEMP": ("N_LEVELS", np.random.uniform(2, 25, n)), + "PSAL": ("N_LEVELS", np.random.uniform(33, 36, n)), + "DOXY": ("N_LEVELS", np.random.uniform(150, 300, n)), + }, + attrs={ + "WMO_INST_TYPE": str(TEST_WMO), + "source": "BGC-Argo+", + "version": TEST_VERSION, + }, + ) + + +# --------------------------------------------------------------------------- +# Unit tests — bgcargo_plus_url() +# --------------------------------------------------------------------------- + +class TestBGCArgoPlusURL: + def test_returns_string(self): + url = bgcargo_plus_url(TEST_WMO) + assert isinstance(url, str) + + def test_starts_with_ftp(self): + url = bgcargo_plus_url(TEST_WMO) + assert url.startswith("ftp://") + + def test_contains_correct_host(self): + url = bgcargo_plus_url(TEST_WMO) + assert BGCARGO_PLUS_FTP_HOST in url + + def test_contains_wmo(self): + url = bgcargo_plus_url(TEST_WMO) + assert str(TEST_WMO) in url + + def test_contains_default_version(self): + url = bgcargo_plus_url(TEST_WMO) + assert BGCARGO_PLUS_DEFAULT_VERSION in url + + def test_custom_version(self): + custom = "v0.1_2025_12" + url = bgcargo_plus_url(TEST_WMO, version=custom) + assert custom in url + assert BGCARGO_PLUS_DEFAULT_VERSION not in url + + def test_ends_with_nc(self): + url = bgcargo_plus_url(TEST_WMO) + assert url.endswith(".nc") + + def test_filename_pattern(self): + """File should be named ``_Sprof_BGCArgoPlus.nc``.""" + url = bgcargo_plus_url(TEST_WMO) + filename = url.split("/")[-1] + assert filename == f"{TEST_WMO}_Sprof_BGCArgoPlus.nc" + + +# --------------------------------------------------------------------------- +# Unit tests — BGCArgoPlusStore() +# --------------------------------------------------------------------------- + +class TestBGCArgoPlusStoreUnit: + def test_wmo_stored(self): + store = BGCArgoPlusStore(TEST_WMO) + assert store.WMO == TEST_WMO + + def test_wmo_string_accepted(self): + store = BGCArgoPlusStore(str(TEST_WMO)) + assert store.WMO == TEST_WMO + + def test_default_version(self): + store = BGCArgoPlusStore(TEST_WMO) + assert store.version == BGCARGO_PLUS_DEFAULT_VERSION + + def test_custom_version(self): + store = BGCArgoPlusStore(TEST_WMO, version="v0.1_2025_12") + assert store.version == "v0.1_2025_12" + + def test_url_property(self): + store = BGCArgoPlusStore(TEST_WMO) + assert store.url == bgcargo_plus_url(TEST_WMO) + + def test_repr(self): + store = BGCArgoPlusStore(TEST_WMO) + r = repr(store) + assert str(TEST_WMO) in r + assert "BGCArgoPlusStore" in r + + def test_open_dataset_mocked(self): + """open_dataset() should delegate to ftpstore and return a Dataset.""" + dummy_ds = _make_dummy_dataset() + store = BGCArgoPlusStore(TEST_WMO) + + with patch.object(store._fs, "open_dataset", return_value=dummy_ds) as mock_od: + ds = store.open_dataset() + mock_od.assert_called_once_with(store.url) + assert isinstance(ds, xr.Dataset) + + def test_open_dataset_extra_kwargs_forwarded(self): + """Extra kwargs should be passed through to the underlying ftpstore.""" + dummy_ds = _make_dummy_dataset() + store = BGCArgoPlusStore(TEST_WMO) + + with patch.object(store._fs, "open_dataset", return_value=dummy_ds) as mock_od: + store.open_dataset(lazy=True) + _, call_kwargs = mock_od.call_args + assert "lazy" in call_kwargs + assert call_kwargs["lazy"] is True + + def test_open_dataset_file_not_found(self): + """A FileNotFoundError should be raised when the FTP file is missing.""" + store = BGCArgoPlusStore(TEST_WMO) + + with patch.object( + store._fs, "open_dataset", side_effect=FileNotFoundError("550 File not found") + ): + with pytest.raises(FileNotFoundError, match=str(TEST_WMO)): + store.open_dataset() + + def test_invalid_wmo_raises(self): + """An invalid WMO should raise before any FTP connection is made.""" + with pytest.raises(Exception): # argopy raises ValueError or similar + BGCArgoPlusStore(999) # 999 is not a valid Argo WMO + + +# --------------------------------------------------------------------------- +# Unit tests — ArgoFloat.open_dataset('BGCArgoPlus') integration point +# --------------------------------------------------------------------------- + +class TestArgoFloatOpenDatasetBGCArgoPlus: + """Test that ArgoFloat.open_dataset routes 'BGCArgoPlus' correctly.""" + + def _make_mock_argofloat(self, wmo=TEST_WMO): + """Return a minimal ArgoFloat-like mock that exposes the patched method.""" + from argopy.stores.float.spec import FloatStoreProto + + # Build a lightweight stub that only has the attributes open_dataset needs + af = MagicMock() + af.WMO = wmo + af.cache = False + af.cachedir = "" + af.timeout = 0 + af._dataset = {} + # Bind the real open_dataset from the patched spec + af.open_dataset = FloatStoreProto.open_dataset.__get__(af, type(af)) + # ls_dataset should not be called for 'BGCArgoPlus' + af.ls_dataset = MagicMock(side_effect=AssertionError("ls_dataset should not be called for BGCArgoPlus")) + return af + + def test_bgcargo_plus_key_bypasses_gdac(self): + """'BGCArgoPlus' must NOT call ls_dataset (GDAC lookup).""" + dummy_ds = _make_dummy_dataset() + af = self._make_mock_argofloat() + + with patch( + "argopy.stores.float.spec.BGCArgoPlusStore", + return_value=MagicMock(open_dataset=MagicMock(return_value=dummy_ds)), + ): + ds = af.open_dataset("BGCArgoPlus") + assert isinstance(ds, xr.Dataset) + af.ls_dataset.assert_not_called() + + def test_bgcargo_plus_stored_in_dataset_cache(self): + """Result should be cached in self._dataset['BGCArgoPlus'].""" + dummy_ds = _make_dummy_dataset() + af = self._make_mock_argofloat() + + with patch( + "argopy.stores.float.spec.BGCArgoPlusStore", + return_value=MagicMock(open_dataset=MagicMock(return_value=dummy_ds)), + ): + af.open_dataset("BGCArgoPlus") + assert "BGCArgoPlus" in af._dataset + + def test_bgcplus_version_kwarg_forwarded(self): + """version kwarg must reach BGCArgoPlusStore constructor.""" + dummy_ds = _make_dummy_dataset() + af = self._make_mock_argofloat() + custom_version = "v0.1_2025_12" + mock_store_cls = MagicMock( + return_value=MagicMock(open_dataset=MagicMock(return_value=dummy_ds)) + ) + + with patch("argopy.stores.float.spec.BGCArgoPlusStore", mock_store_cls): + af.open_dataset("BGCArgoPlus", version=custom_version) + call_kwargs = mock_store_cls.call_args[1] + assert call_kwargs.get("version") == custom_version + + def test_normal_dataset_unaffected(self): + """Standard dataset names ('prof', 'Sprof', …) must still go through GDAC.""" + af = self._make_mock_argofloat() + af.ls_dataset = MagicMock(return_value={"prof": "ftp://dummy/6903091_prof.nc"}) + + # just verify ls_dataset is consulted — don't open the file + datasets = af.ls_dataset() + assert "prof" in datasets + + +# --------------------------------------------------------------------------- +# Integration tests (network required) +# --------------------------------------------------------------------------- + +class TestBGCArgoPlusIntegration: + """Live FTP tests — skipped unless ARGOPY_SKIP_NETWORK_TESTS != 1.""" + + def test_url_reachable(self): + """The SOEST FTP should serve a file for a known BGC float.""" + import fsspec + url = bgcargo_plus_url(TEST_WMO) + fs = fsspec.filesystem("ftp", host=BGCARGO_PLUS_FTP_HOST) + path = url.replace(f"ftp://{BGCARGO_PLUS_FTP_HOST}", "") + assert fs.exists(path), f"BGC-Argo+ file not found: {url}" + + def test_open_dataset_returns_xarray(self): + store = BGCArgoPlusStore(TEST_WMO) + ds = store.open_dataset() + assert isinstance(ds, xr.Dataset) + + def test_dataset_has_pressure(self): + store = BGCArgoPlusStore(TEST_WMO) + ds = store.open_dataset() + assert "PRES" in ds or any("PRES" in v.upper() for v in ds.data_vars) + + def test_argofloat_open_dataset_bgcargo_plus(self): + from argopy import ArgoFloat + ds = ArgoFloat(TEST_WMO).open_dataset("BGCArgoPlus") + assert isinstance(ds, xr.Dataset) + + def test_profile2point_returns_point_dataset(self): + """profile2point() should convert the profile dataset to a point cloud.""" + store = BGCArgoPlusStore(TEST_WMO) + ds = store.open_dataset() + ds_point = ds.argo.profile2point() + assert isinstance(ds_point, xr.Dataset) + assert "N_POINTS" in ds_point.dims + assert ds_point.sizes["N_POINTS"] > 0 + + def test_point2profile_roundtrip(self): + """profile2point() followed by point2profile() should recover the same + number of profiles and be free of TypeError from bytes/str mixing.""" + store = BGCArgoPlusStore(TEST_WMO) + ds = store.open_dataset() + n_prof_original = ds.sizes["N_PROF"] + ds_point = ds.argo.profile2point() + ds_back = ds_point.argo.point2profile() + assert isinstance(ds_back, xr.Dataset) + assert "N_PROF" in ds_back.dims + assert ds_back.sizes["N_PROF"] == n_prof_original diff --git a/docs/advanced-tools/stores/bgcargo_plus.rst b/docs/advanced-tools/stores/bgcargo_plus.rst new file mode 100644 index 000000000..7cd8b02f6 --- /dev/null +++ b/docs/advanced-tools/stores/bgcargo_plus.rst @@ -0,0 +1,106 @@ +.. _bgcargo_plus_store: + +BGC-Argo+ dataset +================= + +.. admonition:: What is BGC-Argo+? + + The `BGC-Argo+ `_ dataset is a quality-controlled, + outlier-removed compilation of BGC-Argo float data curated by the `HI-Cycles + `_ group at the School of Ocean and Earth Science + and Technology (SOEST), University of Hawaiʻi at Mānoa. Individual float files are + served on the SOEST FTP server:: + + ftp://ftp.soest.hawaii.edu/bgc_argo_plus/Individual_Floats/outliers_removed/ + + Relative to the standard GDAC ``Sprof`` files, BGC-Argo+ files provide: + + - **Outlier removal** on all BGC variables (oxygen, nitrate, pH, chlorophyll, + backscatter, irradiance). + - **Consistent variable naming** across all floats. + - **Version-tagged releases** so that scientific analyses can be reproduced + against a fixed snapshot. + +Usage +----- + +The canonical access path is through :class:`argopy.ArgoFloat` by passing the +special dataset name ``"BGCArgoPlus"`` to :meth:`~argopy.ArgoFloat.open_dataset`: + +.. code-block:: python + + from argopy import ArgoFloat + + af = ArgoFloat(6903091) + ds = af.open_dataset('BGCArgoPlus') + ds + +This is entirely analogous to loading a standard GDAC file: + +.. code-block:: python + + ds_sprof = af.open_dataset('Sprof') # ← standard GDAC Sprof + ds_bgcp = af.open_dataset('BGCArgoPlus') # ← BGC-Argo+ version + +Pinning the version +------------------- + +The BGC-Argo+ dataset is versioned. By default, argopy uses +:data:`~argopy.stores.float.bgcargo_plus.BGCARGO_PLUS_DEFAULT_VERSION` +(currently ``"v0.1_2025_12"``). You can pin to any available version with the +``bgcplus_version`` keyword: + +.. code-block:: python + + ds = ArgoFloat(6903091).open_dataset('BGCArgoPlus', bgcplus_version='v0.1_2025_12') + +.. tip:: + + Pin the version in any scientific workflow that requires reproducibility across + time. Future releases of argopy will update the default version when a new + BGC-Argo+ snapshot is published. + +Low-level access +---------------- + +For fine-grained control you can use :class:`~argopy.stores.float.bgcargo_plus.BGCArgoPlusStore` +directly: + +.. code-block:: python + + from argopy.stores.float.bgcargo_plus import BGCArgoPlusStore + + store = BGCArgoPlusStore(6903091) + print(store.url) # shows the full FTP URL + ds = store.open_dataset() + +The store wraps :class:`argopy.stores.ftpstore` and therefore supports the same +``cache``, ``cachedir``, ``lazy``, and ``timeout`` options: + +.. code-block:: python + + store = BGCArgoPlusStore(6903091, cache=True, cachedir='/tmp/argopy_cache') + ds = store.open_dataset(lazy=True) # kerchunk-based lazy access + +API reference +------------- + +.. autofunction:: argopy.stores.float.bgcargo_plus.bgcargo_plus_url + +.. autoclass:: argopy.stores.float.bgcargo_plus.BGCArgoPlusStore + :members: url, open_dataset + :undoc-members: + :show-inheritance: + +.. rubric:: Module-level constants + +.. autodata:: argopy.stores.float.bgcargo_plus.BGCARGO_PLUS_FTP_HOST +.. autodata:: argopy.stores.float.bgcargo_plus.BGCARGO_PLUS_DEFAULT_VERSION +.. autodata:: argopy.stores.float.bgcargo_plus.BGCARGO_PLUS_PATH_TEMPLATE + +See also +-------- + +- :ref:`argofloat_store` — the parent :class:`~argopy.ArgoFloat` documentation. +- `BGC-Argo+ website `_ +- `SOEST FTP index `_ diff --git a/docs/whats-new.rst b/docs/whats-new.rst index 83b03de21..93cb58d60 100644 --- a/docs/whats-new.rst +++ b/docs/whats-new.rst @@ -16,6 +16,22 @@ Features and front-end API - **Full Argo vocabulary support** for reference tables (:class:`ArgoReferenceTable`), values (:class:`ArgoReferenceValue`) and mappings (:class:`ArgoReferenceMapping`) (:pr:`575`) by |gmaze|. +BGC-Argo+ dataset integration +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:class:`argopy.ArgoFloat` can now load data from the +`BGC-Argo+ `_ dataset by passing +``'BGCArgoPlus'`` to :meth:`~argopy.ArgoFloat.open_dataset`: + +.. code-block:: python + + from argopy import ArgoFloat + ds = ArgoFloat(6903091).open_dataset('BGCArgoPlus') + +The BGC-Argo+ dataset (SOEST / University of Hawaiʻi at Mānoa) provides +QC-processed, outlier-removed BGC-Argo float files served on the SOEST FTP +server. See :ref:`bgcargo_plus_store` for details. + Internals ^^^^^^^^^ diff --git a/requirements.txt b/requirements.txt index 2c54a5731..855e9ea4e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,5 @@ toolz>=0.8.2 requests>=2.28 aiohttp>=3.7,<=3.12.15 decorator>=5.1 -packaging>=20.4 \ No newline at end of file +packaging>=20.4 +h5py \ No newline at end of file