xhycom 0.1.0__py3-none-any.whl
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/__init__.py +400 -0
- xhycom/_abfile.py +433 -0
- xhycom/_discovery.py +73 -0
- xhycom/_postprocess.py +173 -0
- xhycom/_reader.py +961 -0
- xhycom/_regrid.py +1303 -0
- xhycom/_time.py +129 -0
- xhycom-0.1.0.dist-info/METADATA +93 -0
- xhycom-0.1.0.dist-info/RECORD +12 -0
- xhycom-0.1.0.dist-info/WHEEL +5 -0
- xhycom-0.1.0.dist-info/licenses/LICENSE +21 -0
- xhycom-0.1.0.dist-info/top_level.txt +1 -0
xhycom/__init__.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""xhycom — xarray interface for HYCOM a.b binary output files.
|
|
2
|
+
|
|
3
|
+
Public API
|
|
4
|
+
----------
|
|
5
|
+
open_dataset(path, ...) Open any HYCOM .ab file pair (archv, grid, bathy).
|
|
6
|
+
open_mfdataset(paths, ...) Open a time series of archive snapshots.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import warnings
|
|
12
|
+
from typing import Iterable, Union
|
|
13
|
+
|
|
14
|
+
import xarray as xr
|
|
15
|
+
|
|
16
|
+
from ._abfile import ABFile
|
|
17
|
+
from ._discovery import find_archv_files
|
|
18
|
+
from ._postprocess import postprocess
|
|
19
|
+
from ._reader import (
|
|
20
|
+
_build_mf_lazy,
|
|
21
|
+
_read_archv_meta,
|
|
22
|
+
detect_filetype,
|
|
23
|
+
read_archv,
|
|
24
|
+
read_ave,
|
|
25
|
+
read_bathy,
|
|
26
|
+
read_grid,
|
|
27
|
+
)
|
|
28
|
+
from ._regrid import (
|
|
29
|
+
regrid,
|
|
30
|
+
regrid_horizontal,
|
|
31
|
+
regrid_to_hycom,
|
|
32
|
+
regrid_vertical,
|
|
33
|
+
velocities_east_north,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Private alias so the public `postprocess` name can also be a keyword argument
|
|
37
|
+
# on open_dataset / open_mfdataset without shadowing the function.
|
|
38
|
+
_postprocess_ds = postprocess
|
|
39
|
+
|
|
40
|
+
__version__ = "0.1.0"
|
|
41
|
+
__all__ = [
|
|
42
|
+
"open_dataset",
|
|
43
|
+
"open_mfdataset",
|
|
44
|
+
"postprocess",
|
|
45
|
+
"read_ave",
|
|
46
|
+
"regrid",
|
|
47
|
+
"regrid_horizontal",
|
|
48
|
+
"regrid_to_hycom",
|
|
49
|
+
"regrid_vertical",
|
|
50
|
+
"velocities_east_north",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
# A grid argument is either a path to ``regional.grid`` or a pre-loaded Dataset.
|
|
54
|
+
GridArg = Union[str, xr.Dataset, None]
|
|
55
|
+
# ``chunks`` is forwarded to ``Dataset.chunk`` (int, mapping, "auto", or None).
|
|
56
|
+
Chunks = Union[int, dict, str, None]
|
|
57
|
+
|
|
58
|
+
# Layer-velocity names and their barotropic partners. ``postprocess`` turns the
|
|
59
|
+
# baroclinic ``archv`` velocities into the total current by adding the barotropic
|
|
60
|
+
# part, so when velocities are requested via ``variables=`` we must also read it.
|
|
61
|
+
_VEL_TOTAL = ("u-vel.", "v-vel.")
|
|
62
|
+
_VEL_BTROP = ("u_btrop", "v_btrop")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _augment_velocity_vars(variables: list[str] | None, postprocess: bool):
|
|
66
|
+
"""Pull in the barotropic velocity when velocities are requested + postprocess.
|
|
67
|
+
|
|
68
|
+
Returns ``(variables_to_read, auto_added)``; *auto_added* are barotropic
|
|
69
|
+
names added only to build the total current, which the caller drops after
|
|
70
|
+
postprocess so an explicit ``variables=`` list is honoured.
|
|
71
|
+
"""
|
|
72
|
+
if not postprocess or variables is None:
|
|
73
|
+
return variables, []
|
|
74
|
+
if not set(variables) & set(_VEL_TOTAL):
|
|
75
|
+
return variables, []
|
|
76
|
+
auto = [b for b in _VEL_BTROP if b not in variables]
|
|
77
|
+
return list(variables) + auto, auto
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _drop_auto(ds: xr.Dataset, auto: list[str]) -> xr.Dataset:
|
|
81
|
+
"""Drop the barotropic variables that were auto-added just to form the total."""
|
|
82
|
+
drop = [v for v in auto if v in ds.variables]
|
|
83
|
+
return ds.drop_vars(drop) if drop else ds
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _load_grid(grid: GridArg, endian: str) -> xr.Dataset | None:
|
|
87
|
+
"""Accept a path string or pre-loaded Dataset; return a Dataset."""
|
|
88
|
+
if grid is None:
|
|
89
|
+
return None
|
|
90
|
+
if isinstance(grid, xr.Dataset):
|
|
91
|
+
return grid
|
|
92
|
+
return open_dataset(grid, endian=endian)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def open_dataset(
|
|
96
|
+
path: str,
|
|
97
|
+
grid: GridArg = None,
|
|
98
|
+
endian: str = "big",
|
|
99
|
+
chunks: Chunks = None,
|
|
100
|
+
variables: list[str] | None = None,
|
|
101
|
+
postprocess: bool = False,
|
|
102
|
+
) -> xr.Dataset:
|
|
103
|
+
"""Open a HYCOM ``.ab`` file pair as an ``xr.Dataset``.
|
|
104
|
+
|
|
105
|
+
Automatically detects the file type (archive, grid, or bathymetry) from
|
|
106
|
+
the ``.b`` header, so the same function works for all HYCOM output files.
|
|
107
|
+
|
|
108
|
+
If *path* is a glob pattern or directory, it is forwarded to
|
|
109
|
+
:func:`open_mfdataset` automatically.
|
|
110
|
+
|
|
111
|
+
Parameters
|
|
112
|
+
----------
|
|
113
|
+
path : str
|
|
114
|
+
Path to the file. The ``.a`` / ``.b`` extension is optional.
|
|
115
|
+
Glob patterns (``*``, ``?``, ``[``) and directory paths are forwarded
|
|
116
|
+
to :func:`open_mfdataset`.
|
|
117
|
+
grid : str or xr.Dataset, optional
|
|
118
|
+
Path to ``regional.grid`` (without extension), or a Dataset already
|
|
119
|
+
returned by a previous ``open_dataset`` call on a grid file.
|
|
120
|
+
|
|
121
|
+
* For **archive** files: attaches ``lon`` / ``lat`` as non-dimension
|
|
122
|
+
coordinates on every variable.
|
|
123
|
+
* For **bathymetry** files: required (grid dimensions and coordinates
|
|
124
|
+
are not stored in the bathymetry file itself).
|
|
125
|
+
* For **grid** files: ignored.
|
|
126
|
+
|
|
127
|
+
endian : str
|
|
128
|
+
Byte order: ``"big"`` (default), ``"little"``, or ``"native"``.
|
|
129
|
+
chunks : int, dict, or "auto", optional
|
|
130
|
+
If provided, the returned Dataset is chunked with Dask. Passed
|
|
131
|
+
directly to ``ds.chunk()``. Example: ``chunks={"k": 1}`` to chunk
|
|
132
|
+
one layer at a time.
|
|
133
|
+
postprocess : bool
|
|
134
|
+
If ``True``, convert native units to physical ones and add derived
|
|
135
|
+
fields via :func:`xhycom.postprocess` — e.g. sea-surface height and
|
|
136
|
+
layer thicknesses in metres, plus ``area`` / ``landmask``. Default
|
|
137
|
+
``False`` (data are returned exactly as stored on disk).
|
|
138
|
+
|
|
139
|
+
Returns
|
|
140
|
+
-------
|
|
141
|
+
xr.Dataset
|
|
142
|
+
Contents depend on file type:
|
|
143
|
+
|
|
144
|
+
**Archive** (``archv.YYYY_DDD_HH``)
|
|
145
|
+
* ``time`` dimension of size 1.
|
|
146
|
+
* 2-D fields on ``(time, y, x)``.
|
|
147
|
+
* Layered fields on ``(time, k, y, x)`` with ``k`` (layer index,
|
|
148
|
+
1-based) and ``dens`` (target sigma-2 density) coordinates.
|
|
149
|
+
* Global attributes ``iversn``, ``iexpt``, ``yrflag``.
|
|
150
|
+
|
|
151
|
+
**Grid** (``regional.grid``)
|
|
152
|
+
* All 19 grid variables on ``(y, x)``: ``plon``, ``plat``,
|
|
153
|
+
``ulon``, ``ulat``, ``vlon``, ``vlat``, ``qlon``, ``qlat``,
|
|
154
|
+
``pang``, ``scpx``, ``scpy``, ``scqx``, ``scqy``, ``scux``,
|
|
155
|
+
``scuy``, ``scvx``, ``scvy``, ``cori``, ``pasp``.
|
|
156
|
+
|
|
157
|
+
**Bathymetry** (``depth_*``)
|
|
158
|
+
* Single ``depth`` variable (metres) on ``(y, x)``.
|
|
159
|
+
|
|
160
|
+
``lon`` / ``lat`` non-dimension coordinates are attached to every
|
|
161
|
+
variable when *grid* is supplied (archive and bathymetry files).
|
|
162
|
+
|
|
163
|
+
Raises
|
|
164
|
+
------
|
|
165
|
+
ValueError
|
|
166
|
+
If the file type cannot be detected, or if *grid* is not provided for
|
|
167
|
+
a bathymetry file.
|
|
168
|
+
|
|
169
|
+
Examples
|
|
170
|
+
--------
|
|
171
|
+
Open the grid:
|
|
172
|
+
|
|
173
|
+
>>> grid = xhycom.open_dataset("topo/regional.grid")
|
|
174
|
+
|
|
175
|
+
Open the bathymetry (grid required for dimensions and coordinates):
|
|
176
|
+
|
|
177
|
+
>>> bathy = xhycom.open_dataset("topo/depth_TP2a0.10_04",
|
|
178
|
+
... grid="topo/regional.grid")
|
|
179
|
+
|
|
180
|
+
Open a single archive snapshot with grid coordinates:
|
|
181
|
+
|
|
182
|
+
>>> ds = xhycom.open_dataset("data/archv.2020_001_00",
|
|
183
|
+
... grid="topo/regional.grid")
|
|
184
|
+
|
|
185
|
+
Re-use a pre-loaded grid Dataset to avoid reading the file twice:
|
|
186
|
+
|
|
187
|
+
>>> grid = xhycom.open_dataset("topo/regional.grid")
|
|
188
|
+
>>> bathy = xhycom.open_dataset("topo/depth_TP2a0.10_04", grid=grid)
|
|
189
|
+
>>> ds = xhycom.open_dataset("data/archv.2020_001_00", grid=grid)
|
|
190
|
+
"""
|
|
191
|
+
path = str(path)
|
|
192
|
+
|
|
193
|
+
# Forward globs and directories to open_mfdataset.
|
|
194
|
+
import os as _os
|
|
195
|
+
|
|
196
|
+
if any(c in path for c in "*?[") or _os.path.isdir(path):
|
|
197
|
+
return open_mfdataset(
|
|
198
|
+
path,
|
|
199
|
+
grid=grid,
|
|
200
|
+
endian=endian,
|
|
201
|
+
chunks=chunks,
|
|
202
|
+
variables=variables,
|
|
203
|
+
postprocess=postprocess,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
basename = ABFile.strip_ab_ending(path)
|
|
207
|
+
filetype = detect_filetype(basename)
|
|
208
|
+
|
|
209
|
+
grid_ds = _load_grid(grid, endian)
|
|
210
|
+
|
|
211
|
+
if filetype == "grid":
|
|
212
|
+
ds = read_grid(basename, endian=endian)
|
|
213
|
+
elif filetype == "archv":
|
|
214
|
+
# chunks is handled inside read_archv: data is never loaded eagerly
|
|
215
|
+
# when chunks is set — Dask tasks are created instead.
|
|
216
|
+
aug, auto = _augment_velocity_vars(variables, postprocess)
|
|
217
|
+
ds = read_archv(
|
|
218
|
+
basename, grid_ds=grid_ds, endian=endian, chunks=chunks, variables=aug
|
|
219
|
+
)
|
|
220
|
+
if postprocess:
|
|
221
|
+
ds = _drop_auto(_postprocess_ds(ds), auto)
|
|
222
|
+
return ds
|
|
223
|
+
elif filetype == "ave":
|
|
224
|
+
ds = read_ave(
|
|
225
|
+
basename, grid_ds=grid_ds, endian=endian, chunks=chunks, variables=variables
|
|
226
|
+
)
|
|
227
|
+
return ds
|
|
228
|
+
elif filetype == "bathy":
|
|
229
|
+
if grid_ds is None:
|
|
230
|
+
raise ValueError(
|
|
231
|
+
"grid= is required to open a bathymetry file — it provides "
|
|
232
|
+
"the grid dimensions (idm, jdm) and lon/lat coordinates.\n"
|
|
233
|
+
"Example: open_dataset('depth_...', grid='regional.grid')"
|
|
234
|
+
)
|
|
235
|
+
ds = read_bathy(basename, grid_ds=grid_ds, endian=endian)
|
|
236
|
+
else:
|
|
237
|
+
raise ValueError(f"Unsupported file type {filetype!r} for open_dataset.")
|
|
238
|
+
|
|
239
|
+
if postprocess:
|
|
240
|
+
ds = _postprocess_ds(ds)
|
|
241
|
+
return ds.chunk(chunks) if chunks is not None else ds
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def open_mfdataset(
|
|
245
|
+
paths: str | Iterable[str],
|
|
246
|
+
grid: GridArg = None,
|
|
247
|
+
endian: str = "big",
|
|
248
|
+
skip_errors: bool = False,
|
|
249
|
+
chunks: Chunks = None,
|
|
250
|
+
variables: list[str] | None = None,
|
|
251
|
+
postprocess: bool = False,
|
|
252
|
+
) -> xr.Dataset:
|
|
253
|
+
"""Open multiple HYCOM archive ``.ab`` file pairs as a single ``xr.Dataset``.
|
|
254
|
+
|
|
255
|
+
Snapshots are concatenated along a ``time`` dimension in chronological
|
|
256
|
+
order.
|
|
257
|
+
|
|
258
|
+
Parameters
|
|
259
|
+
----------
|
|
260
|
+
paths : str or list of str
|
|
261
|
+
One of:
|
|
262
|
+
|
|
263
|
+
* A directory path — all ``archv.`` / ``archm.YYYY_DDD_HH.[ab]``
|
|
264
|
+
pairs found inside are used.
|
|
265
|
+
* A glob pattern such as ``"data/archm.1993_*.a"``.
|
|
266
|
+
* An explicit list of archive basenames or filenames.
|
|
267
|
+
|
|
268
|
+
grid : str or xr.Dataset, optional
|
|
269
|
+
Grid file path or pre-loaded Dataset. Loaded once and shared across
|
|
270
|
+
all files.
|
|
271
|
+
endian : str
|
|
272
|
+
Byte order.
|
|
273
|
+
skip_errors : bool
|
|
274
|
+
If ``True``, files that fail to open are skipped with a warning
|
|
275
|
+
rather than raising an exception. Default ``False``.
|
|
276
|
+
chunks : int, dict, or "auto", optional
|
|
277
|
+
If provided, the returned Dataset is chunked with Dask. Passed
|
|
278
|
+
directly to ``ds.chunk()``. Example: ``chunks={"time": 1}``.
|
|
279
|
+
variables : list of str, optional
|
|
280
|
+
If provided, only these variables are included in the returned Dataset.
|
|
281
|
+
Reduces the Dask graph size proportionally — useful when working with
|
|
282
|
+
large archives that contain many variables (e.g. BGC runs).
|
|
283
|
+
Variables not present in the archive are skipped with a warning.
|
|
284
|
+
postprocess : bool
|
|
285
|
+
If ``True``, apply :func:`xhycom.postprocess` to the combined Dataset
|
|
286
|
+
(native-unit conversions + derived fields). Default ``False``.
|
|
287
|
+
|
|
288
|
+
Returns
|
|
289
|
+
-------
|
|
290
|
+
xr.Dataset
|
|
291
|
+
Combined Dataset with a ``time`` dimension spanning all snapshots.
|
|
292
|
+
|
|
293
|
+
Examples
|
|
294
|
+
--------
|
|
295
|
+
Open all snapshots in a directory:
|
|
296
|
+
|
|
297
|
+
>>> ds = xhycom.open_mfdataset("data/", grid="topo/regional.grid")
|
|
298
|
+
|
|
299
|
+
Open a subset using a glob:
|
|
300
|
+
|
|
301
|
+
>>> ds = xhycom.open_mfdataset("data/archv.2020_*.a",
|
|
302
|
+
... grid="topo/regional.grid")
|
|
303
|
+
|
|
304
|
+
Compute time-mean surface salinity:
|
|
305
|
+
|
|
306
|
+
>>> ds["saln"].isel(k=0).mean("time").plot(x="lon", y="lat")
|
|
307
|
+
"""
|
|
308
|
+
if isinstance(paths, str):
|
|
309
|
+
basenames = find_archv_files(paths)
|
|
310
|
+
else:
|
|
311
|
+
basenames = [ABFile.strip_ab_ending(str(p)) for p in paths]
|
|
312
|
+
|
|
313
|
+
grid_ds = _load_grid(grid, endian)
|
|
314
|
+
|
|
315
|
+
# When velocities are requested with postprocess, also read the barotropic
|
|
316
|
+
# part so the total current can be formed, then drop it afterwards.
|
|
317
|
+
aug, auto = _augment_velocity_vars(variables, postprocess)
|
|
318
|
+
|
|
319
|
+
if chunks is not None:
|
|
320
|
+
# Lazy path: parse all .b headers in parallel (no .a I/O), then build
|
|
321
|
+
# a combined Dask Dataset in one pass — avoids xr.concat overhead.
|
|
322
|
+
try:
|
|
323
|
+
import dask # noqa: F401
|
|
324
|
+
except ImportError:
|
|
325
|
+
raise ImportError(
|
|
326
|
+
"Dask is required for lazy/chunked loading. "
|
|
327
|
+
"Install it with: pip install dask"
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
331
|
+
|
|
332
|
+
meta_map: dict = {}
|
|
333
|
+
with ThreadPoolExecutor() as executor:
|
|
334
|
+
future_to_base = {
|
|
335
|
+
executor.submit(_read_archv_meta, bn, endian): bn for bn in basenames
|
|
336
|
+
}
|
|
337
|
+
for future in as_completed(future_to_base):
|
|
338
|
+
bn = future_to_base[future]
|
|
339
|
+
try:
|
|
340
|
+
meta_map[bn] = future.result()
|
|
341
|
+
except Exception as exc:
|
|
342
|
+
if skip_errors:
|
|
343
|
+
warnings.warn(f"Skipping {bn!r}: {exc}", stacklevel=2)
|
|
344
|
+
else:
|
|
345
|
+
raise
|
|
346
|
+
|
|
347
|
+
# Restore chronological order, dropping any skipped files.
|
|
348
|
+
valid_basenames = [bn for bn in basenames if bn in meta_map]
|
|
349
|
+
metas = [meta_map[bn] for bn in valid_basenames]
|
|
350
|
+
|
|
351
|
+
if not metas:
|
|
352
|
+
raise RuntimeError("No files were successfully opened.")
|
|
353
|
+
|
|
354
|
+
# Extract the integer time chunk size so the graph is built with the
|
|
355
|
+
# right granularity — avoids creating 1-file tasks and then rechunking.
|
|
356
|
+
time_chunk = 1
|
|
357
|
+
if isinstance(chunks, dict) and isinstance(chunks.get("time"), int):
|
|
358
|
+
time_chunk = chunks["time"]
|
|
359
|
+
elif isinstance(chunks, int):
|
|
360
|
+
time_chunk = chunks
|
|
361
|
+
|
|
362
|
+
ds = _build_mf_lazy(
|
|
363
|
+
valid_basenames,
|
|
364
|
+
metas,
|
|
365
|
+
grid_ds,
|
|
366
|
+
endian,
|
|
367
|
+
variables=aug,
|
|
368
|
+
time_chunk=time_chunk,
|
|
369
|
+
)
|
|
370
|
+
if postprocess:
|
|
371
|
+
ds = _drop_auto(_postprocess_ds(ds), auto)
|
|
372
|
+
return ds.chunk(chunks)
|
|
373
|
+
|
|
374
|
+
else:
|
|
375
|
+
# Eager path: read each file and concatenate.
|
|
376
|
+
datasets = []
|
|
377
|
+
for basename in basenames:
|
|
378
|
+
try:
|
|
379
|
+
datasets.append(
|
|
380
|
+
read_archv(basename, grid_ds=grid_ds, endian=endian, variables=aug)
|
|
381
|
+
)
|
|
382
|
+
except Exception as exc:
|
|
383
|
+
if skip_errors:
|
|
384
|
+
warnings.warn(f"Skipping {basename!r}: {exc}", stacklevel=2)
|
|
385
|
+
else:
|
|
386
|
+
raise
|
|
387
|
+
|
|
388
|
+
if not datasets:
|
|
389
|
+
raise RuntimeError("No files were successfully opened.")
|
|
390
|
+
|
|
391
|
+
ds = xr.concat(
|
|
392
|
+
datasets,
|
|
393
|
+
dim="time",
|
|
394
|
+
data_vars="minimal",
|
|
395
|
+
coords="minimal",
|
|
396
|
+
compat="override",
|
|
397
|
+
)
|
|
398
|
+
if postprocess:
|
|
399
|
+
return _drop_auto(_postprocess_ds(ds), auto)
|
|
400
|
+
return ds
|