xhycom 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
xhycom-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nora Loose
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
xhycom-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: xhycom
3
+ Version: 0.1.0
4
+ Summary: xarray interface for HYCOM a.b binary output files
5
+ Author-email: Nora Loose <nora.loose@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/nansencenter/xhycom
8
+ Project-URL: Documentation, https://xhycom.readthedocs.io
9
+ Project-URL: Bug Tracker, https://github.com/nansencenter/xhycom/issues
10
+ Project-URL: Source, https://github.com/nansencenter/xhycom
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Requires-Python: >=3.8
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: numpy
25
+ Requires-Dist: xarray
26
+ Requires-Dist: cftime
27
+ Requires-Dist: dask
28
+ Requires-Dist: xgcm
29
+ Requires-Dist: numba
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest; extra == "dev"
32
+ Requires-Dist: pytest-cov; extra == "dev"
33
+ Requires-Dist: netCDF4; extra == "dev"
34
+ Requires-Dist: pre-commit; extra == "dev"
35
+ Provides-Extra: docs
36
+ Requires-Dist: sphinx; extra == "docs"
37
+ Requires-Dist: sphinx-book-theme; extra == "docs"
38
+ Requires-Dist: myst-nb; extra == "docs"
39
+ Requires-Dist: sphinx-copybutton; extra == "docs"
40
+ Requires-Dist: sphinx-design; extra == "docs"
41
+ Dynamic: license-file
42
+
43
+ # xhycom
44
+
45
+ [![Run Tests](https://github.com/nansencenter/xhycom/actions/workflows/tests.yml/badge.svg)](https://github.com/nansencenter/xhycom/actions/workflows/tests.yml?query=branch%3Amain)
46
+ [![codecov](https://codecov.io/gh/nansencenter/xhycom/graph/badge.svg?token=5S1oNu39xE)](https://codecov.io/gh/nansencenter/xhycom)
47
+ [![Documentation Status](https://readthedocs.org/projects/xhycom/badge/?version=latest)](https://xhycom.readthedocs.io/en/latest/?badge=latest)
48
+
49
+ xhycom integrates HYCOM model output with [xarray](https://docs.xarray.dev) —
50
+ giving every field a name, coordinates, units, and lazy out-of-memory access,
51
+ directly from the native `.ab` format.
52
+
53
+ ## Installation
54
+
55
+ ```bash
56
+ pip install git+https://github.com/nansencenter/xhycom.git
57
+ ```
58
+
59
+ **Dependencies:** `numpy`, `xarray`, `cftime`, `dask`, `xgcm` — no Fortran compiler
60
+ or external binary readers required. This covers reading, lazy/out-of-memory
61
+ loading, and vertical regridding.
62
+
63
+ Horizontal regridding additionally needs [xESMF](https://xesmf.readthedocs.io),
64
+ whose ESMF/esmpy backend is conda-forge only. `ci/environment-regrid.yml` creates
65
+ the environment and `pip install`s xhycom itself (in editable mode) into it in one
66
+ step:
67
+
68
+ ```bash
69
+ conda env create -f ci/environment-regrid.yml
70
+ conda activate hycom-analysis-env
71
+ ```
72
+
73
+ See [Installation](https://xhycom.readthedocs.io/en/latest/installation.html) for
74
+ details, including setup on the Olivia and Betzy HPC clusters.
75
+
76
+ ## Quick example
77
+
78
+ ```python
79
+ import xhycom
80
+
81
+ # Single snapshot — auto-detects file type, attaches lon/lat/time/dens
82
+ ds = xhycom.open_dataset("archv.2020_001_00", grid="regional.grid")
83
+ ds["temp"].isel(time=0, k=0).plot()
84
+
85
+ # Multi-year time series — lazy, out-of-memory, no data loaded until .compute()
86
+ ds = xhycom.open_mfdataset("data/", grid="regional.grid", chunks={"time": 1})
87
+ ds["temp"].isel(k=0).mean("time").compute()
88
+ ```
89
+
90
+ ## Documentation
91
+
92
+ Full documentation — why xarray, worked examples, API reference, and a
93
+ how-it-works guide — is at **[https://xhycom.readthedocs.io](https://xhycom.readthedocs.io/en/latest/)**.
xhycom-0.1.0/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # xhycom
2
+
3
+ [![Run Tests](https://github.com/nansencenter/xhycom/actions/workflows/tests.yml/badge.svg)](https://github.com/nansencenter/xhycom/actions/workflows/tests.yml?query=branch%3Amain)
4
+ [![codecov](https://codecov.io/gh/nansencenter/xhycom/graph/badge.svg?token=5S1oNu39xE)](https://codecov.io/gh/nansencenter/xhycom)
5
+ [![Documentation Status](https://readthedocs.org/projects/xhycom/badge/?version=latest)](https://xhycom.readthedocs.io/en/latest/?badge=latest)
6
+
7
+ xhycom integrates HYCOM model output with [xarray](https://docs.xarray.dev) —
8
+ giving every field a name, coordinates, units, and lazy out-of-memory access,
9
+ directly from the native `.ab` format.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ pip install git+https://github.com/nansencenter/xhycom.git
15
+ ```
16
+
17
+ **Dependencies:** `numpy`, `xarray`, `cftime`, `dask`, `xgcm` — no Fortran compiler
18
+ or external binary readers required. This covers reading, lazy/out-of-memory
19
+ loading, and vertical regridding.
20
+
21
+ Horizontal regridding additionally needs [xESMF](https://xesmf.readthedocs.io),
22
+ whose ESMF/esmpy backend is conda-forge only. `ci/environment-regrid.yml` creates
23
+ the environment and `pip install`s xhycom itself (in editable mode) into it in one
24
+ step:
25
+
26
+ ```bash
27
+ conda env create -f ci/environment-regrid.yml
28
+ conda activate hycom-analysis-env
29
+ ```
30
+
31
+ See [Installation](https://xhycom.readthedocs.io/en/latest/installation.html) for
32
+ details, including setup on the Olivia and Betzy HPC clusters.
33
+
34
+ ## Quick example
35
+
36
+ ```python
37
+ import xhycom
38
+
39
+ # Single snapshot — auto-detects file type, attaches lon/lat/time/dens
40
+ ds = xhycom.open_dataset("archv.2020_001_00", grid="regional.grid")
41
+ ds["temp"].isel(time=0, k=0).plot()
42
+
43
+ # Multi-year time series — lazy, out-of-memory, no data loaded until .compute()
44
+ ds = xhycom.open_mfdataset("data/", grid="regional.grid", chunks={"time": 1})
45
+ ds["temp"].isel(k=0).mean("time").compute()
46
+ ```
47
+
48
+ ## Documentation
49
+
50
+ Full documentation — why xarray, worked examples, API reference, and a
51
+ how-it-works guide — is at **[https://xhycom.readthedocs.io](https://xhycom.readthedocs.io/en/latest/)**.
@@ -0,0 +1,81 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "xhycom"
7
+ version = "0.1.0"
8
+ description = "xarray interface for HYCOM a.b binary output files"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Nora Loose", email = "nora.loose@gmail.com" }]
12
+ requires-python = ">=3.8"
13
+ dependencies = [
14
+ "numpy",
15
+ "xarray",
16
+ "cftime",
17
+ "dask",
18
+ "xgcm",
19
+ "numba", # required by xgcm's Grid.transform for method="conservative"
20
+ # (regrid_vertical's default)
21
+ ]
22
+ classifiers = [
23
+ "Development Status :: 3 - Alpha",
24
+ "Intended Audience :: Science/Research",
25
+ "Topic :: Scientific/Engineering :: Atmospheric Science",
26
+ "License :: OSI Approved :: MIT License",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3.8",
29
+ "Programming Language :: Python :: 3.9",
30
+ "Programming Language :: Python :: 3.10",
31
+ "Programming Language :: Python :: 3.11",
32
+ "Programming Language :: Python :: 3.12",
33
+ ]
34
+
35
+ [project.optional-dependencies]
36
+ # Lateral regridding (regrid_horizontal / the full regrid wrapper) needs
37
+ # xESMF. Its ESMF/esmpy backend has no PyPI wheels, so it must come from
38
+ # conda-forge -- there is no pip-only path, and it stays optional since it
39
+ # can conflict with other ESMF installs on some platforms. Ready-made env:
40
+ # conda env create -f ci/environment-regrid.yml
41
+ dev = ["pytest", "pytest-cov", "netCDF4", "pre-commit"]
42
+ docs = [
43
+ "sphinx",
44
+ "sphinx-book-theme",
45
+ "myst-nb",
46
+ "sphinx-copybutton",
47
+ "sphinx-design",
48
+ ]
49
+
50
+ [project.urls]
51
+ Homepage = "https://github.com/nansencenter/xhycom"
52
+ Documentation = "https://xhycom.readthedocs.io"
53
+ "Bug Tracker" = "https://github.com/nansencenter/xhycom/issues"
54
+ Source = "https://github.com/nansencenter/xhycom"
55
+
56
+ [tool.setuptools.packages.find]
57
+ where = ["."]
58
+ include = ["xhycom*"]
59
+
60
+ [tool.pytest.ini_options]
61
+ testpaths = ["tests"]
62
+ # dask and xgcm are core dependencies, so reader/postprocess/vertical-regrid
63
+ # tests run on the base + dev install. The lateral-regrid tests still
64
+ # self-skip when xesmf is unavailable (conda-forge only, see above).
65
+
66
+ [tool.coverage.run]
67
+ source = ["xhycom"]
68
+ omit = ["tests/*"]
69
+
70
+ [tool.ruff]
71
+ fix = true
72
+ show-fixes = true
73
+ line-length = 88
74
+ exclude = ["docs"]
75
+
76
+ [tool.ruff.lint]
77
+ extend-select = ["I", "E", "W", "F", "D", "RUF", "UP"]
78
+ ignore = ["E501", "D205", "D102", "D100", "D401", "D101", "D105", "D103", "D104", "UP040", "RUF043", "RUF059", "UP031", "RUF001", "RUF002", "RUF003", "RUF012"]
79
+
80
+ [tool.ruff.lint.pydocstyle]
81
+ convention = "pep257"
xhycom-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,146 @@
1
+ """Tests for xhycom.postprocess (unit conversions + derived fields)."""
2
+
3
+ import numpy as np
4
+ import pytest
5
+ import xarray as xr
6
+
7
+ import xhycom
8
+ from xhycom._postprocess import _G, _ONEM, _reconcile_velocities, postprocess
9
+ from xhycom._regrid import layer_centre_depth
10
+
11
+
12
+ def test_ssh_converted_to_metres():
13
+ # srfhgt is geopotential g*eta; 1.5 m of SSH -> stored as g*1.5
14
+ srfhgt = xr.DataArray(
15
+ np.full((2, 3), _G * 1.5), dims=("y", "x"), name="srfhgt", attrs={"units": "Pa"}
16
+ )
17
+ out = postprocess(xr.Dataset({"srfhgt": srfhgt}))
18
+ np.testing.assert_allclose(out["srfhgt"].values, 1.5)
19
+ assert out["srfhgt"].attrs["units"] == "m"
20
+ assert "converted from" in out["srfhgt"].attrs["comment"]
21
+
22
+
23
+ def test_thknss_converted_with_onem_not_g():
24
+ thknss = xr.DataArray(
25
+ np.full((1, 2, 2), 10.0 * _ONEM),
26
+ dims=("k", "y", "x"),
27
+ name="thknss",
28
+ attrs={"units": "Pa"},
29
+ )
30
+ out = postprocess(xr.Dataset({"thknss": thknss}))
31
+ np.testing.assert_allclose(out["thknss"].values, 10.0)
32
+ assert out["thknss"].attrs["units"] == "m"
33
+
34
+
35
+ def test_area_added_from_scales():
36
+ ds = xr.Dataset(
37
+ {
38
+ "scpx": (("y", "x"), np.full((2, 2), 100.0)),
39
+ "scpy": (("y", "x"), np.full((2, 2), 200.0)),
40
+ }
41
+ )
42
+ out = postprocess(ds)
43
+ assert "area" in out
44
+ np.testing.assert_allclose(out["area"].values, 20000.0)
45
+ assert out["area"].attrs["units"] == "m2"
46
+
47
+
48
+ def test_landmask_from_depth():
49
+ depth = xr.DataArray(
50
+ np.array([[10.0, np.nan], [5.0, 3.0]]),
51
+ dims=("y", "x"),
52
+ name="depth",
53
+ attrs={"units": "m"},
54
+ )
55
+ out = postprocess(xr.Dataset({"depth": depth}))
56
+ assert "landmask" in out
57
+ np.testing.assert_array_equal(out["landmask"].values, [[1, 0], [1, 1]])
58
+
59
+
60
+ def test_idempotent_does_not_double_convert():
61
+ srfhgt = xr.DataArray(
62
+ np.full((2, 2), _G), dims=("y", "x"), name="srfhgt", attrs={"units": "Pa"}
63
+ )
64
+ once = postprocess(xr.Dataset({"srfhgt": srfhgt}))
65
+ twice = postprocess(once)
66
+ np.testing.assert_allclose(twice["srfhgt"].values, once["srfhgt"].values)
67
+
68
+
69
+ def test_postprocess_exported():
70
+ assert xhycom.postprocess is postprocess
71
+
72
+
73
+ def test_regrid_vertical_unit_aware_after_postprocess():
74
+ # thknss in Pa vs converted to m must yield identical layer-centre depths
75
+ thk_pa = xr.DataArray(
76
+ np.full((4, 1, 1), 10.0 * _ONEM),
77
+ dims=("k", "y", "x"),
78
+ name="thknss",
79
+ attrs={"units": "Pa"},
80
+ )
81
+ thk_m = postprocess(xr.Dataset({"thknss": thk_pa}))["thknss"]
82
+ z_pa = layer_centre_depth(thk_pa).values
83
+ z_m = layer_centre_depth(thk_m).values
84
+ np.testing.assert_allclose(z_pa, z_m)
85
+ np.testing.assert_allclose(z_pa[:, 0, 0], [5, 15, 25, 35], atol=1e-3)
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Velocity convention: archv stores baroclinic, archm stores total
90
+ # ---------------------------------------------------------------------------
91
+ def _vel_ds(archive_type, with_barotropic=True):
92
+ data = {
93
+ "u-vel.": xr.DataArray(np.full((2, 2, 2), 0.3), dims=("k", "y", "x")),
94
+ "v-vel.": xr.DataArray(np.full((2, 2, 2), -0.2), dims=("k", "y", "x")),
95
+ }
96
+ if with_barotropic:
97
+ data["u_btrop"] = xr.DataArray(np.full((2, 2), 0.1), dims=("y", "x"))
98
+ data["v_btrop"] = xr.DataArray(np.full((2, 2), 0.05), dims=("y", "x"))
99
+ return xr.Dataset(data, attrs={"archive_type": archive_type})
100
+
101
+
102
+ def test_velocity_total_for_instantaneous_archive():
103
+ out = postprocess(_vel_ds("instantaneous"))
104
+ np.testing.assert_allclose(
105
+ out["u-vel."].values, 0.4
106
+ ) # 0.3 baroclinic + 0.1 barotropic
107
+ np.testing.assert_allclose(out["v-vel."].values, -0.15)
108
+ assert out["u-vel."].attrs["hycom_velocity"] == "total"
109
+ assert "barotropic" in out["u-vel."].attrs["comment"]
110
+
111
+
112
+ def test_velocity_total_for_mean_archive():
113
+ out = postprocess(_vel_ds("mean"))
114
+ np.testing.assert_allclose(out["u-vel."].values, 0.3) # already total, unchanged
115
+ assert out["u-vel."].attrs["hycom_velocity"] == "total"
116
+
117
+
118
+ def test_velocity_left_baroclinic_without_barotropic():
119
+ with pytest.warns(UserWarning, match="baroclinic"):
120
+ out = postprocess(_vel_ds("instantaneous", with_barotropic=False))
121
+ np.testing.assert_allclose(out["u-vel."].values, 0.3) # unchanged
122
+ assert out["u-vel."].attrs["hycom_velocity"] == "baroclinic"
123
+
124
+
125
+ def test_velocity_reconcile_idempotent():
126
+ out = postprocess(postprocess(_vel_ds("instantaneous")))
127
+ np.testing.assert_allclose(out["u-vel."].values, 0.4) # not 0.5
128
+
129
+
130
+ def test_velocity_untouched_without_archive_type():
131
+ ds = xr.Dataset({"u-vel.": xr.DataArray(np.full((2, 2), 0.3), dims=("y", "x"))})
132
+ out = _reconcile_velocities(ds)
133
+ assert "hycom_velocity" not in out["u-vel."].attrs
134
+
135
+
136
+ def test_augment_velocity_vars():
137
+ from xhycom import _augment_velocity_vars
138
+
139
+ aug, auto = _augment_velocity_vars(["temp", "u-vel.", "v-vel."], True)
140
+ assert set(auto) == {"u_btrop", "v_btrop"}
141
+ assert {"temp", "u-vel.", "v-vel.", "u_btrop", "v_btrop"} == set(aug)
142
+ assert _augment_velocity_vars(["u-vel."], False) == (["u-vel."], [])
143
+ assert _augment_velocity_vars(None, True) == (None, [])
144
+ assert _augment_velocity_vars(["temp"], True) == (["temp"], [])
145
+ aug2, auto2 = _augment_velocity_vars(["u-vel.", "u_btrop"], True)
146
+ assert auto2 == ["v_btrop"]
@@ -0,0 +1,204 @@
1
+ """Reader tests against synthetic .ab fixtures (see conftest.py)."""
2
+
3
+ import numpy as np
4
+ import pytest
5
+
6
+ import xhycom
7
+ from xhycom._reader import detect_filetype, read_ave
8
+
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # detect_filetype
12
+ # ---------------------------------------------------------------------------
13
+ def test_detect_filetype(grid_file, bathy_file, archive_file):
14
+ assert detect_filetype(grid_file[0]) == "grid"
15
+ assert detect_filetype(bathy_file[0]) == "bathy"
16
+ assert detect_filetype(archive_file) == "archv"
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Grid
21
+ # ---------------------------------------------------------------------------
22
+ def test_open_grid(grid_file):
23
+ base, fields = grid_file
24
+ ds = xhycom.open_dataset(base)
25
+ for name, arr in fields.items():
26
+ assert name in ds, name
27
+ np.testing.assert_allclose(ds[name].values, arr, rtol=1e-5)
28
+ assert ds["plon"].dims == ("y", "x")
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Bathymetry
33
+ # ---------------------------------------------------------------------------
34
+ def test_open_bathy_requires_grid(bathy_file):
35
+ with pytest.raises(ValueError, match="grid="):
36
+ xhycom.open_dataset(bathy_file[0])
37
+
38
+
39
+ def test_open_bathy(bathy_file, grid_file):
40
+ base, depth = bathy_file
41
+ grid = xhycom.open_dataset(grid_file[0])
42
+ ds = xhycom.open_dataset(base, grid=grid)
43
+ wet = depth < 1e30
44
+ np.testing.assert_allclose(ds["depth"].values[wet], depth[wet], rtol=1e-5)
45
+ assert np.isnan(ds["depth"].values[0, 0]) # land point -> NaN
46
+ assert "lon" in ds.coords and "lat" in ds.coords
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Archive
51
+ # ---------------------------------------------------------------------------
52
+ def test_open_archive_structure(archive_file, grid_file):
53
+ grid = xhycom.open_dataset(grid_file[0])
54
+ ds = xhycom.open_dataset(archive_file, grid=grid)
55
+ assert set(ds["temp"].dims) == {"time", "k", "y", "x"}
56
+ assert ds["srfhgt"].dims == ("time", "y", "x")
57
+ np.testing.assert_array_equal(ds["k"].values, [1, 2, 3])
58
+ np.testing.assert_allclose(ds["dens"].values, [28.0, 29.0, 30.0])
59
+ # temp[k] == 10 - k
60
+ np.testing.assert_allclose(
61
+ ds["temp"].isel(time=0, y=0, x=0).values,
62
+ [9.0, 8.0, 7.0],
63
+ rtol=1e-5,
64
+ )
65
+ assert ds["time"].size == 1
66
+
67
+
68
+ def test_open_archive_variables_filter(archive_file, grid_file):
69
+ grid = xhycom.open_dataset(grid_file[0])
70
+ ds = xhycom.open_dataset(archive_file, grid=grid, variables=["temp", "thknss"])
71
+ assert "temp" in ds and "thknss" in ds
72
+ assert "salin" not in ds
73
+
74
+
75
+ def test_open_mfdataset_concats_time(archive_pair, grid_file):
76
+ grid = xhycom.open_dataset(grid_file[0])
77
+ ds = xhycom.open_mfdataset(archive_pair, grid=grid)
78
+ assert ds["time"].size == 2
79
+ assert ds["temp"].sizes["time"] == 2
80
+
81
+
82
+ def test_open_mfdataset_lazy_chunks(archive_pair, grid_file):
83
+ pytest.importorskip("dask")
84
+ grid = xhycom.open_dataset(grid_file[0])
85
+ ds = xhycom.open_mfdataset(archive_pair, grid=grid, chunks={"time": 1})
86
+ assert ds["temp"].chunks is not None
87
+ np.testing.assert_allclose(
88
+ ds["temp"].isel(time=0, y=0, x=0).values,
89
+ [9.0, 8.0, 7.0],
90
+ rtol=1e-5,
91
+ )
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # postprocess threaded through the real read path
96
+ # ---------------------------------------------------------------------------
97
+ def test_postprocess_through_open_archive(archive_file, grid_file):
98
+ grid = xhycom.open_dataset(grid_file[0])
99
+ ds = xhycom.open_dataset(archive_file, grid=grid, postprocess=True)
100
+ assert ds["srfhgt"].attrs["units"] == "m"
101
+ np.testing.assert_allclose(
102
+ ds["srfhgt"].isel(time=0, y=0, x=0).values, 0.5, rtol=1e-4
103
+ )
104
+ assert ds["thknss"].attrs["units"] == "m"
105
+ np.testing.assert_allclose(
106
+ ds["thknss"].isel(time=0, y=0, x=0).values, [10.0, 10.0, 10.0], rtol=1e-4
107
+ )
108
+
109
+
110
+ def test_postprocess_through_open_grid(grid_file):
111
+ base, _ = grid_file
112
+ ds = xhycom.open_dataset(base, postprocess=True)
113
+ assert "area" in ds
114
+ np.testing.assert_allclose(ds["area"].values, 100.0 * 200.0, rtol=1e-5)
115
+
116
+
117
+ def test_postprocess_through_open_bathy(bathy_file, grid_file):
118
+ grid = xhycom.open_dataset(grid_file[0])
119
+ ds = xhycom.open_dataset(bathy_file[0], grid=grid, postprocess=True)
120
+ assert "landmask" in ds
121
+ assert ds["landmask"].values[0, 0] == 0 # land
122
+ assert ds["landmask"].values[-1, -1] == 1 # ocean
123
+
124
+
125
+ def test_archive_type_instantaneous(archive_file):
126
+ # The synthetic fixture writes a "... model day" header -> instantaneous archv.
127
+ ds = xhycom.open_dataset(archive_file)
128
+ assert ds.attrs["archive_type"] == "instantaneous"
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # AVE (hycave/ensave monthly average)
133
+ # ---------------------------------------------------------------------------
134
+ def test_detect_filetype_ave(ave_file: str) -> None:
135
+ """AVE header (has 'iversn' + 'kdm ') is distinguished from archv."""
136
+ assert detect_filetype(ave_file) == "ave"
137
+
138
+
139
+ def test_open_ave_structure(ave_file: str, grid_file: tuple) -> None:
140
+ """2-D and 3-D AVE fields land on the expected dims; archive_type is set."""
141
+ grid = xhycom.open_dataset(grid_file[0])
142
+ ds = xhycom.open_dataset(ave_file, grid=grid)
143
+ assert "ssh" in ds
144
+ assert "temp" in ds
145
+ assert ds["ssh"].dims == ("time", "y", "x")
146
+ assert set(ds["temp"].dims) == {"time", "k", "y", "x"}
147
+ np.testing.assert_array_equal(ds["k"].values, [1, 2, 3])
148
+ assert ds["time"].size == 1
149
+ assert ds.attrs["archive_type"] == "time_average"
150
+
151
+
152
+ def test_open_ave_time_from_filename(ave_file: str) -> None:
153
+ """Time coordinate is parsed from _YYYY_MM suffix, not from model-day (always 0)."""
154
+ import cftime
155
+
156
+ ds = xhycom.open_dataset(ave_file)
157
+ t = ds["time"].values[0]
158
+ assert isinstance(t, cftime.datetime)
159
+ assert t.year == 1991
160
+ assert t.month == 1
161
+ assert t.day == 1
162
+
163
+
164
+ def test_open_ave_variables_filter(ave_file: str) -> None:
165
+ """variables= kwarg restricts which AVE fields are loaded."""
166
+ ds = xhycom.open_dataset(ave_file, variables=["temp"])
167
+ assert "temp" in ds
168
+ assert "ssh" not in ds
169
+
170
+
171
+ def test_open_ave_no_time_without_pattern(tmp_path, grid_file: tuple) -> None:
172
+ """Basenames without _YYYY_MM suffix produce a Dataset with no time dim."""
173
+ from conftest import _write_ave
174
+
175
+ base = str(tmp_path / "EXPAVE_nodate")
176
+ _write_ave(base)
177
+ ds = xhycom.open_dataset(base)
178
+ assert "time" not in ds.dims
179
+
180
+
181
+ def test_read_ave_public_api(ave_file: str) -> None:
182
+ """read_ave() is importable from xhycom._reader and returns a Dataset."""
183
+ ds = read_ave(ave_file)
184
+ assert "ssh" in ds and "temp" in ds
185
+
186
+
187
+ def test_archive_type_mean(tp0):
188
+ # The bundled archm fixture writes a "... mean day" header -> mean archive,
189
+ # whose u-vel./v-vel. are already the total current.
190
+ ds = xhycom.open_dataset(tp0.archive, postprocess=True)
191
+ assert ds.attrs["archive_type"] == "mean"
192
+ assert ds["u-vel."].attrs["hycom_velocity"] == "total"
193
+
194
+
195
+ def test_open_ave_chunks(ave_file: str) -> None:
196
+ """read_ave with chunks= loads lazily via Dask."""
197
+ pytest.importorskip("dask")
198
+ ds = read_ave(ave_file, chunks={"k": 1})
199
+ assert ds["temp"].chunks is not None
200
+ np.testing.assert_allclose(
201
+ ds["temp"].isel(time=0, y=0, x=0).values,
202
+ [9.0, 8.0, 7.0],
203
+ rtol=1e-5,
204
+ )