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 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