ncfunc 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.
- ncfunc/__init__.py +37 -0
- ncfunc/core.py +1000 -0
- ncfunc-0.1.0.dist-info/METADATA +192 -0
- ncfunc-0.1.0.dist-info/RECORD +6 -0
- ncfunc-0.1.0.dist-info/WHEEL +4 -0
- ncfunc-0.1.0.dist-info/entry_points.txt +3 -0
ncfunc/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from .core import (
|
|
2
|
+
DatasetMeta,
|
|
3
|
+
attr_names,
|
|
4
|
+
attr_names_include,
|
|
5
|
+
attr_val,
|
|
6
|
+
create,
|
|
7
|
+
dim_names,
|
|
8
|
+
ndim,
|
|
9
|
+
read,
|
|
10
|
+
read_time,
|
|
11
|
+
read_within,
|
|
12
|
+
save,
|
|
13
|
+
shape,
|
|
14
|
+
var_names,
|
|
15
|
+
var_names_include,
|
|
16
|
+
write,
|
|
17
|
+
write_attr,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"DatasetMeta",
|
|
22
|
+
"attr_names",
|
|
23
|
+
"attr_names_include",
|
|
24
|
+
"attr_val",
|
|
25
|
+
"create",
|
|
26
|
+
"dim_names",
|
|
27
|
+
"ndim",
|
|
28
|
+
"read",
|
|
29
|
+
"read_time",
|
|
30
|
+
"read_within",
|
|
31
|
+
"save",
|
|
32
|
+
"shape",
|
|
33
|
+
"var_names",
|
|
34
|
+
"var_names_include",
|
|
35
|
+
"write",
|
|
36
|
+
"write_attr",
|
|
37
|
+
]
|
ncfunc/core.py
ADDED
|
@@ -0,0 +1,1000 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, ClassVar, Literal, cast
|
|
8
|
+
|
|
9
|
+
import datenum as dn
|
|
10
|
+
import netCDF4 as nc
|
|
11
|
+
import numpy as np
|
|
12
|
+
from numpy.typing import ArrayLike
|
|
13
|
+
|
|
14
|
+
PathLike = str | Path
|
|
15
|
+
StrOrStrs = str | tuple[str, ...]
|
|
16
|
+
IntOrInts = int | tuple[int, ...]
|
|
17
|
+
DTypeSpec = (
|
|
18
|
+
np.dtype[Any]
|
|
19
|
+
| str
|
|
20
|
+
| type[int | float | complex | bool | str | np.bool_ | np.number | np.str_]
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# --------------metadata assets-----------------------
|
|
25
|
+
@dataclass
|
|
26
|
+
class VariableMeta:
|
|
27
|
+
"""static description of a single variable inside a NetCDF file"""
|
|
28
|
+
|
|
29
|
+
shape: tuple[int, ...]
|
|
30
|
+
dimensions: tuple[str, ...]
|
|
31
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
ndim: int = field(init=False)
|
|
33
|
+
|
|
34
|
+
def __post_init__(self) -> None:
|
|
35
|
+
self.ndim = len(self.shape)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DatasetMeta:
|
|
39
|
+
"""
|
|
40
|
+
static description of a NetCDF file: its variables and root attributes.
|
|
41
|
+
|
|
42
|
+
instances are cheap snapshots built without reading any data values.
|
|
43
|
+
a module-level registry keeps the most recently used entries (up to
|
|
44
|
+
MAX_CACHE) keyed by resolved path; use try_from_cache() to reuse or
|
|
45
|
+
refresh them.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
variables: dict[str, VariableMeta]
|
|
49
|
+
attributes: dict[str, Any]
|
|
50
|
+
cached_registry: ClassVar[dict[str, DatasetMeta]] = {}
|
|
51
|
+
MAX_CACHE: ClassVar[int] = 64
|
|
52
|
+
|
|
53
|
+
def __init__(self, path: PathLike) -> None:
|
|
54
|
+
"""
|
|
55
|
+
read the file's structure into this instance and register it in the cache
|
|
56
|
+
|
|
57
|
+
raises FileNotFoundError if the path does not exist,
|
|
58
|
+
RuntimeError if the netcdf4 library cannot open it
|
|
59
|
+
"""
|
|
60
|
+
path = Path(path)
|
|
61
|
+
if not path.is_file():
|
|
62
|
+
raise FileNotFoundError(str(path))
|
|
63
|
+
|
|
64
|
+
self.mtime = path.stat().st_mtime
|
|
65
|
+
self._read(path)
|
|
66
|
+
|
|
67
|
+
# -- uncaching the old entries
|
|
68
|
+
while len(self.cached_registry) >= self.MAX_CACHE:
|
|
69
|
+
self.cached_registry.pop(next(iter(self.cached_registry)))
|
|
70
|
+
|
|
71
|
+
# -- caching
|
|
72
|
+
self.cached_registry[DatasetMeta._to_cache_key(path)] = self
|
|
73
|
+
|
|
74
|
+
def _read(self, path: Path) -> None:
|
|
75
|
+
try:
|
|
76
|
+
with nc.Dataset(path, "r") as h:
|
|
77
|
+
self.variables = {
|
|
78
|
+
name: VariableMeta(
|
|
79
|
+
variable.shape,
|
|
80
|
+
variable.dimensions,
|
|
81
|
+
{
|
|
82
|
+
aname: variable.getncattr(aname)
|
|
83
|
+
for aname in variable.ncattrs()
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
for name, variable in h.variables.items()
|
|
87
|
+
}
|
|
88
|
+
self.attributes = {name: h.getncattr(name) for name in h.ncattrs()}
|
|
89
|
+
|
|
90
|
+
except Exception as e:
|
|
91
|
+
msg = f"the netcdf4 library failed to read {path}"
|
|
92
|
+
raise RuntimeError(msg) from e
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _to_cache_key(path: PathLike) -> str:
|
|
96
|
+
return str(Path(path).resolve())
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def try_from_cache(path: PathLike, cache_only: bool = False) -> DatasetMeta:
|
|
100
|
+
"""
|
|
101
|
+
return the metadata of the file, reusing the cache when possible
|
|
102
|
+
|
|
103
|
+
a fresh DatasetMeta is built when nothing is cached yet; a cached
|
|
104
|
+
entry is re-read when the file's mtime changed since caching.
|
|
105
|
+
|
|
106
|
+
raises KeyError if cache_only=True and the path is not cached,
|
|
107
|
+
FileNotFoundError if the path does not exist
|
|
108
|
+
"""
|
|
109
|
+
path = Path(path)
|
|
110
|
+
key = DatasetMeta._to_cache_key(path)
|
|
111
|
+
ds = DatasetMeta.cached_registry.get(key)
|
|
112
|
+
|
|
113
|
+
if cache_only and ds is None:
|
|
114
|
+
raise KeyError(f"path is not registered in the metadata cache: {key}")
|
|
115
|
+
|
|
116
|
+
if ds is None:
|
|
117
|
+
return DatasetMeta(path)
|
|
118
|
+
|
|
119
|
+
if ds.mtime == path.stat().st_mtime:
|
|
120
|
+
return ds
|
|
121
|
+
|
|
122
|
+
else:
|
|
123
|
+
ds._read(path)
|
|
124
|
+
return ds
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# -------------- helpers ---------------
|
|
128
|
+
def _err_ctx(path: PathLike, var_name: str | None = None) -> str:
|
|
129
|
+
"""standardized context suffix appended to error messages"""
|
|
130
|
+
if var_name is None:
|
|
131
|
+
return f" (path={path})"
|
|
132
|
+
|
|
133
|
+
return f" (path={path}, var='{var_name}')"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _as_str_tuple(value: StrOrStrs) -> tuple[str, ...]:
|
|
137
|
+
return value if isinstance(value, tuple) else (value,)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _as_int_tuple(value: IntOrInts) -> tuple[int, ...]:
|
|
141
|
+
return value if isinstance(value, tuple) else (value,)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _has_variable(path: PathLike, var_name: str) -> bool:
|
|
145
|
+
try:
|
|
146
|
+
_get_variable_meta(path, var_name)
|
|
147
|
+
except (FileNotFoundError, KeyError):
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _get_variable_meta(path: PathLike, var_name: str) -> VariableMeta:
|
|
154
|
+
ds = DatasetMeta.try_from_cache(path)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
return ds.variables[var_name]
|
|
158
|
+
|
|
159
|
+
except KeyError as e:
|
|
160
|
+
available = tuple(ds.variables.keys())
|
|
161
|
+
msg = (
|
|
162
|
+
f"variable '{var_name}' does not exist in {path}, "
|
|
163
|
+
f"available variables: {available}"
|
|
164
|
+
)
|
|
165
|
+
raise KeyError(msg) from e
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _get_attribute_metas(path: PathLike, var_name: str) -> dict[str, Any]:
|
|
169
|
+
if var_name == "/": # root attributes
|
|
170
|
+
ds = DatasetMeta.try_from_cache(path)
|
|
171
|
+
return ds.attributes
|
|
172
|
+
|
|
173
|
+
else:
|
|
174
|
+
return _get_variable_meta(path, var_name).attributes
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ----------- meta data queries ------
|
|
178
|
+
def var_names(path: PathLike) -> tuple[str, ...]:
|
|
179
|
+
"""
|
|
180
|
+
get the variable names in the file, in file order
|
|
181
|
+
|
|
182
|
+
raises FileNotFoundError if the path does not exist
|
|
183
|
+
"""
|
|
184
|
+
ds = DatasetMeta.try_from_cache(path)
|
|
185
|
+
return tuple(ds.variables.keys())
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def dim_names(path: PathLike, var_name: str) -> tuple[str, ...]:
|
|
189
|
+
"""
|
|
190
|
+
get the dimension names of a variable in the file, in dimension order
|
|
191
|
+
|
|
192
|
+
raises KeyError if the variable does not exist, FileNotFoundError if
|
|
193
|
+
the path does not exist
|
|
194
|
+
"""
|
|
195
|
+
return _get_variable_meta(path, var_name).dimensions
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def shape(path: PathLike, var_name: str) -> tuple[int, ...]:
|
|
199
|
+
"""
|
|
200
|
+
get the shape of a variable in the file
|
|
201
|
+
|
|
202
|
+
raises KeyError if the variable does not exist, FileNotFoundError if
|
|
203
|
+
the path does not exist
|
|
204
|
+
"""
|
|
205
|
+
return _get_variable_meta(path, var_name).shape
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def ndim(path: PathLike, var_name: str) -> int:
|
|
209
|
+
"""
|
|
210
|
+
get the ndim (rank) of a variable in the file
|
|
211
|
+
|
|
212
|
+
raises KeyError if the variable does not exist, FileNotFoundError if
|
|
213
|
+
the path does not exist
|
|
214
|
+
"""
|
|
215
|
+
return len(_get_variable_meta(path, var_name).shape)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def attr_names(path: PathLike, var_name: str) -> tuple[str, ...]:
|
|
219
|
+
"""
|
|
220
|
+
get the attribute names of
|
|
221
|
+
the file if var_name == '/'
|
|
222
|
+
the variable else
|
|
223
|
+
|
|
224
|
+
raises KeyError if the variable does not exist, FileNotFoundError if
|
|
225
|
+
the path does not exist
|
|
226
|
+
"""
|
|
227
|
+
attributes = _get_attribute_metas(path, var_name)
|
|
228
|
+
return tuple(attributes.keys())
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def attr_val(path: PathLike, var_name: str, attr_name: str) -> Any:
|
|
232
|
+
"""
|
|
233
|
+
get an attribute value of
|
|
234
|
+
the file if var_name == '/'
|
|
235
|
+
the variable else
|
|
236
|
+
|
|
237
|
+
raises KeyError if the variable or the attribute does not exist,
|
|
238
|
+
FileNotFoundError if the path does not exist
|
|
239
|
+
"""
|
|
240
|
+
attributes = _get_attribute_metas(path, var_name)
|
|
241
|
+
return attributes[attr_name]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def var_names_include(
|
|
245
|
+
path: PathLike,
|
|
246
|
+
name_includes: StrOrStrs,
|
|
247
|
+
accept_ndims: IntOrInts,
|
|
248
|
+
accept_counts: IntOrInts = 1,
|
|
249
|
+
) -> tuple[str, ...]:
|
|
250
|
+
"""
|
|
251
|
+
find variable names containing any of the given substrings
|
|
252
|
+
|
|
253
|
+
name_includes: substring(s) to search for; a plain str is one pattern
|
|
254
|
+
accept_ndims: accepted variable rank(s); a plain int is one value
|
|
255
|
+
accept_counts: acceptable number of matches for an unambiguous result
|
|
256
|
+
|
|
257
|
+
returns all matching variable names in file order
|
|
258
|
+
|
|
259
|
+
raises ValueError unless the number of matches is in accept_counts,
|
|
260
|
+
FileNotFoundError if the path does not exist
|
|
261
|
+
"""
|
|
262
|
+
name_includes = _as_str_tuple(name_includes)
|
|
263
|
+
accept_ndims = _as_int_tuple(accept_ndims)
|
|
264
|
+
accept_counts = _as_int_tuple(accept_counts)
|
|
265
|
+
|
|
266
|
+
ds = DatasetMeta.try_from_cache(path)
|
|
267
|
+
candidates = tuple(
|
|
268
|
+
var_name
|
|
269
|
+
for var_name, var_meta in ds.variables.items()
|
|
270
|
+
if any(ni in var_name for ni in name_includes) and var_meta.ndim in accept_ndims
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
if len(candidates) in accept_counts:
|
|
274
|
+
return candidates
|
|
275
|
+
|
|
276
|
+
msg = (
|
|
277
|
+
f"unable to locate variable names including {name_includes} "
|
|
278
|
+
f"with ndim in {accept_ndims} and count in {accept_counts}, "
|
|
279
|
+
f"found {candidates}{_err_ctx(path)}"
|
|
280
|
+
)
|
|
281
|
+
raise ValueError(msg)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def attr_names_include(
|
|
285
|
+
path: PathLike,
|
|
286
|
+
var_name: str,
|
|
287
|
+
name_includes: StrOrStrs,
|
|
288
|
+
accept_counts: IntOrInts = 1,
|
|
289
|
+
) -> tuple[str, ...]:
|
|
290
|
+
"""
|
|
291
|
+
find attribute names containing any of the given substrings
|
|
292
|
+
|
|
293
|
+
name_includes: substring(s) to search for; a plain str is one pattern
|
|
294
|
+
accept_counts: acceptable number of matches for an unambiguous result;
|
|
295
|
+
pass (0, 1) to tolerate absence
|
|
296
|
+
var_name selects whose attributes to search: '/' for the file's root
|
|
297
|
+
attributes, a variable name otherwise
|
|
298
|
+
|
|
299
|
+
returns all matching attribute names in file order
|
|
300
|
+
|
|
301
|
+
raises ValueError unless the number of matches is in accept_counts,
|
|
302
|
+
KeyError if the variable does not exist, FileNotFoundError if the path
|
|
303
|
+
does not exist
|
|
304
|
+
"""
|
|
305
|
+
name_includes = _as_str_tuple(name_includes)
|
|
306
|
+
accept_counts = _as_int_tuple(accept_counts)
|
|
307
|
+
|
|
308
|
+
candidates = tuple(
|
|
309
|
+
attr_name
|
|
310
|
+
for attr_name in attr_names(path, var_name)
|
|
311
|
+
if any(ni in attr_name for ni in name_includes)
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
if len(candidates) in accept_counts:
|
|
315
|
+
return candidates
|
|
316
|
+
|
|
317
|
+
msg = (
|
|
318
|
+
f"unable to locate attribute names of '{var_name}' including {name_includes} "
|
|
319
|
+
f"with count in {accept_counts}, found {candidates}{_err_ctx(path, var_name)}"
|
|
320
|
+
)
|
|
321
|
+
raise ValueError(msg)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _guess_time_name(path: PathLike) -> str:
|
|
325
|
+
guesses = ("time", "date", "TIME", "DATE", "Time", "Date")
|
|
326
|
+
try:
|
|
327
|
+
return var_names_include(path, guesses, accept_ndims=(1,), accept_counts=(1,))[
|
|
328
|
+
0
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
except ValueError as e:
|
|
332
|
+
available = var_names(path)
|
|
333
|
+
msg = (
|
|
334
|
+
f"unable to guess the time variable among {guesses}, "
|
|
335
|
+
f"available variables: {available}{_err_ctx(path)}"
|
|
336
|
+
)
|
|
337
|
+
raise ValueError(msg) from e
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ----- readers ----
|
|
341
|
+
def read(
|
|
342
|
+
path: PathLike, var_name: str, subsets: tuple[slice, ...] | None = None
|
|
343
|
+
) -> np.ndarray:
|
|
344
|
+
"""
|
|
345
|
+
read a variable into a numpy array
|
|
346
|
+
|
|
347
|
+
subsets: one slice per variable dimension, applied as
|
|
348
|
+
h[var_name][subsets]; None reads the whole variable
|
|
349
|
+
|
|
350
|
+
raises KeyError if the variable does not exist, FileNotFoundError if
|
|
351
|
+
the path does not exist, ValueError if the slices fail or would select
|
|
352
|
+
an empty array
|
|
353
|
+
"""
|
|
354
|
+
# check var_name here and hide the KeyError inside
|
|
355
|
+
var_meta = _get_variable_meta(path, var_name)
|
|
356
|
+
|
|
357
|
+
with nc.Dataset(path, "r") as h:
|
|
358
|
+
if subsets is None:
|
|
359
|
+
data = h[var_name][:]
|
|
360
|
+
else:
|
|
361
|
+
try:
|
|
362
|
+
data = h[var_name][subsets]
|
|
363
|
+
except Exception as e:
|
|
364
|
+
msg = (
|
|
365
|
+
f"unable to read '{var_name}'({var_meta.shape}) "
|
|
366
|
+
f"with {subsets=}{_err_ctx(path, var_name)}"
|
|
367
|
+
)
|
|
368
|
+
raise ValueError(msg) from e
|
|
369
|
+
|
|
370
|
+
if any(s == 0 for s in data.shape):
|
|
371
|
+
raise ValueError(
|
|
372
|
+
f"an empty array of {data.shape} is read with {subsets=} "
|
|
373
|
+
f"for '{var_name}'({var_meta.shape}) in {path}"
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
return np.array(data)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
_DELTA_TABLE: dict[str, tuple[str, float]] = {
|
|
380
|
+
"second": ("day", 1 / 86400),
|
|
381
|
+
"seconds": ("day", 1 / 86400),
|
|
382
|
+
"minute": ("day", 1 / 1440),
|
|
383
|
+
"minutes": ("day", 1 / 1440),
|
|
384
|
+
"hour": ("day", 1 / 24),
|
|
385
|
+
"hours": ("day", 1 / 24),
|
|
386
|
+
"day": ("day", 1),
|
|
387
|
+
"days": ("day", 1),
|
|
388
|
+
"month": ("month", 1),
|
|
389
|
+
"months": ("month", 1),
|
|
390
|
+
"year": ("month", 12),
|
|
391
|
+
"years": ("month", 12),
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
_FIXED_CALENDARS = ("365_day", "365_days", "360_day", "360_days")
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _parse_units(units: str, ctx: str) -> tuple[str, float, str]:
|
|
398
|
+
"""parse CF-style units '<delta> since <epoch>' to (method, factor, epoch-string)"""
|
|
399
|
+
str_delta, sep, str_epoch = units.partition("since")
|
|
400
|
+
|
|
401
|
+
if not sep:
|
|
402
|
+
msg = f"cannot parse {units=} by the 'since' keyword{ctx}"
|
|
403
|
+
raise ValueError(msg)
|
|
404
|
+
|
|
405
|
+
try:
|
|
406
|
+
delta_method, delta = _DELTA_TABLE[str_delta.strip()]
|
|
407
|
+
|
|
408
|
+
except KeyError as e:
|
|
409
|
+
msg = f"unrecognized time unit delta={str_delta!r} in {units=}{ctx}"
|
|
410
|
+
raise ValueError(msg) from e
|
|
411
|
+
|
|
412
|
+
return delta_method, delta, str_epoch.strip()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _epoch_from_string(str_epoch: str) -> float:
|
|
416
|
+
if str_epoch == "1-1-1 00:00:00": # weird origin time (CMIP6 models)
|
|
417
|
+
return dn.from_ymd(1, 1, 1)
|
|
418
|
+
|
|
419
|
+
return dn.from_string(str_epoch)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _decode_fixed_calendar(
|
|
423
|
+
epoch: float, time_value: np.ndarray, calendar: str
|
|
424
|
+
) -> np.ndarray:
|
|
425
|
+
"""decode time values of a fixed-length calendar (360_day or noleap/365_day)"""
|
|
426
|
+
days_per_year = int(calendar[:3])
|
|
427
|
+
|
|
428
|
+
year_epoch = dn.year(epoch)
|
|
429
|
+
remainder_epoch = epoch - dn.from_ymd(year_epoch, 1, 1)
|
|
430
|
+
|
|
431
|
+
years = year_epoch + time_value // days_per_year
|
|
432
|
+
remainders = remainder_epoch + time_value % days_per_year
|
|
433
|
+
|
|
434
|
+
cross_year = remainders >= days_per_year
|
|
435
|
+
years[cross_year] += 1
|
|
436
|
+
remainders[cross_year] -= days_per_year
|
|
437
|
+
|
|
438
|
+
datenums = dn.from_ymd(years, 1, 1) + remainders
|
|
439
|
+
|
|
440
|
+
# get rid of the extra day from Feb29
|
|
441
|
+
datenums[(remainders >= 59) & dn.is_leap_year(datenums)] += 1
|
|
442
|
+
return datenums
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def read_time(
|
|
446
|
+
path: PathLike,
|
|
447
|
+
time_name: str | None = None,
|
|
448
|
+
unit_name: str | None = None,
|
|
449
|
+
calendar_name: str | None = None,
|
|
450
|
+
subset: slice | None = None,
|
|
451
|
+
) -> np.ndarray:
|
|
452
|
+
"""
|
|
453
|
+
read and decode a time coordinate into datenum serial values
|
|
454
|
+
|
|
455
|
+
names of the time variable, of its units attribute and of its calendar
|
|
456
|
+
attribute are guessed when not given. units follow the CF convention
|
|
457
|
+
'<delta> since <epoch>' where delta is seconds/minutes/hours/days/
|
|
458
|
+
months/years. month-based units decode via datenum.add_month; day-based
|
|
459
|
+
ones decode against the calendar attribute:
|
|
460
|
+
|
|
461
|
+
standard/gregorian/... : epoch + value * delta
|
|
462
|
+
360_day or 365_day : fixed-length-calendar decoding
|
|
463
|
+
|
|
464
|
+
subset limits which time values are read.
|
|
465
|
+
|
|
466
|
+
raises ValueError when names cannot be guessed or units cannot be
|
|
467
|
+
parsed, TypeError when the units value is not a string,
|
|
468
|
+
KeyError/FileNotFoundError when variables or attributes do not exist
|
|
469
|
+
"""
|
|
470
|
+
time_name = time_name or _guess_time_name(path)
|
|
471
|
+
|
|
472
|
+
if unit_name is None:
|
|
473
|
+
try:
|
|
474
|
+
unit_name = attr_names_include(
|
|
475
|
+
path, time_name, ("unit", "Unit", "UNIT"), accept_counts=(1,)
|
|
476
|
+
)[0]
|
|
477
|
+
|
|
478
|
+
except ValueError as e:
|
|
479
|
+
msg = f"unable to guess the attribute name of time units{_err_ctx(path, time_name)}"
|
|
480
|
+
raise ValueError(msg) from e
|
|
481
|
+
|
|
482
|
+
subset = subset or slice(None)
|
|
483
|
+
|
|
484
|
+
# ----------------------------------
|
|
485
|
+
# ---- parse the time units to delta and epoch
|
|
486
|
+
units = attr_val(path, time_name, unit_name)
|
|
487
|
+
if not isinstance(units, str):
|
|
488
|
+
msg = (
|
|
489
|
+
f"time units must be a string, found {type(units).__name__}: {units!r} "
|
|
490
|
+
f"(path={path}, var='{time_name}', unit='{unit_name}')"
|
|
491
|
+
)
|
|
492
|
+
raise TypeError(msg)
|
|
493
|
+
|
|
494
|
+
ctx = _err_ctx(path, time_name)
|
|
495
|
+
delta_method, delta, str_epoch = _parse_units(units, ctx)
|
|
496
|
+
epoch = _epoch_from_string(str_epoch)
|
|
497
|
+
|
|
498
|
+
# ----------------------------------
|
|
499
|
+
# ----- lazy guess the calendar name here
|
|
500
|
+
if delta_method == "day" and calendar_name is None:
|
|
501
|
+
try:
|
|
502
|
+
calendar_names = attr_names_include(
|
|
503
|
+
path,
|
|
504
|
+
time_name,
|
|
505
|
+
("calendar", "Calendar", "CALENDAR"),
|
|
506
|
+
accept_counts=(0, 1),
|
|
507
|
+
)
|
|
508
|
+
calendar_name = calendar_names[0] if calendar_names else None
|
|
509
|
+
|
|
510
|
+
except ValueError as e:
|
|
511
|
+
msg = f"unable to guess the attribute name of time calendar{ctx}"
|
|
512
|
+
raise ValueError(msg) from e
|
|
513
|
+
|
|
514
|
+
calendar = (
|
|
515
|
+
attr_val(path, time_name, calendar_name) if calendar_name is not None else None
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
time_value = read(path, time_name, (subset,))
|
|
519
|
+
if delta_method == "month":
|
|
520
|
+
return dn.add_month(epoch, time_value * delta)
|
|
521
|
+
|
|
522
|
+
if isinstance(calendar, str) and calendar in _FIXED_CALENDARS:
|
|
523
|
+
return _decode_fixed_calendar(epoch, time_value, calendar)
|
|
524
|
+
|
|
525
|
+
return epoch + time_value * delta
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _dim_bounds(
|
|
529
|
+
within: tuple[float | None, float | None],
|
|
530
|
+
values: np.ndarray,
|
|
531
|
+
*,
|
|
532
|
+
dim_name: str,
|
|
533
|
+
var_name: str,
|
|
534
|
+
path: PathLike,
|
|
535
|
+
is_time: bool,
|
|
536
|
+
) -> tuple[np.ndarray, slice, bool]:
|
|
537
|
+
"""
|
|
538
|
+
locate the index range covering `within` in an ascending or descending array
|
|
539
|
+
|
|
540
|
+
returns 1) the bounded dimension values 2) the matching slice 3) descending?
|
|
541
|
+
"""
|
|
542
|
+
|
|
543
|
+
def fmt(value): # raw datenum serials are unreadable in error messages
|
|
544
|
+
return dn.to_string(value) if is_time else value
|
|
545
|
+
|
|
546
|
+
bot, top = within
|
|
547
|
+
bot = -np.inf if bot is None else bot
|
|
548
|
+
top = np.inf if top is None else top
|
|
549
|
+
|
|
550
|
+
if bot > top:
|
|
551
|
+
msg = f"lower boundary {bot} exceeds upper boundary {top} in {within}{_err_ctx(path, dim_name)}"
|
|
552
|
+
raise ValueError(msg)
|
|
553
|
+
|
|
554
|
+
ctx = f", dim='{dim_name}' (path={path}, var='{var_name}')"
|
|
555
|
+
value_max = np.max(values)
|
|
556
|
+
if bot > value_max:
|
|
557
|
+
raise ValueError(
|
|
558
|
+
f"requested lower boundary={fmt(bot)} is above the maximum "
|
|
559
|
+
f"dimension value ({fmt(value_max)}){ctx}"
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
value_min = np.min(values)
|
|
563
|
+
if top < value_min:
|
|
564
|
+
raise ValueError(
|
|
565
|
+
f"requested upper boundary={fmt(top)} is below the minimum "
|
|
566
|
+
f"dimension value ({fmt(value_min)}){ctx}"
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
diff = values[1:] - values[:-1]
|
|
570
|
+
if np.all(diff >= 0):
|
|
571
|
+
is_reversed = False
|
|
572
|
+
|
|
573
|
+
elif np.all(diff <= 0):
|
|
574
|
+
is_reversed = True
|
|
575
|
+
|
|
576
|
+
else:
|
|
577
|
+
msg = f"dimension values sometimes increase and sometimes decrease{ctx}"
|
|
578
|
+
raise ValueError(msg)
|
|
579
|
+
|
|
580
|
+
if np.any(diff == 0):
|
|
581
|
+
warnings.warn(
|
|
582
|
+
f"some dimension values neither increase nor decrease{ctx}",
|
|
583
|
+
RuntimeWarning,
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
(is_within,) = np.where((bot <= values) & (values <= top))
|
|
587
|
+
slice_within = slice(
|
|
588
|
+
is_within[0], is_within[-1] + 1
|
|
589
|
+
) # need one more to include the last boundary
|
|
590
|
+
|
|
591
|
+
bounded = values[is_within]
|
|
592
|
+
return bounded[::-1] if is_reversed else bounded, slice_within, is_reversed
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def read_within(
|
|
596
|
+
path: PathLike,
|
|
597
|
+
var_name: str,
|
|
598
|
+
withins: tuple[tuple[float | None, float | None], ...],
|
|
599
|
+
idim_time: int | None = None,
|
|
600
|
+
decode_time: bool = True,
|
|
601
|
+
) -> tuple[np.ndarray, tuple[np.ndarray, ...]]:
|
|
602
|
+
"""
|
|
603
|
+
read a variable within bounds given per dimension, plus the bounded coordinates
|
|
604
|
+
|
|
605
|
+
withins holds one (lower, upper) pair per dimension of the variable;
|
|
606
|
+
None means unbounded on that side. each pair must satisfy
|
|
607
|
+
lower <= upper and intersect the coordinate range.
|
|
608
|
+
|
|
609
|
+
the time dimension is located by idim_time, an index into the
|
|
610
|
+
variable's dimensions, or - by default - by guessing a time-named
|
|
611
|
+
coordinate variable. decode_time=False disables time handling
|
|
612
|
+
entirely; idim_time must then be None. bounds are matched against
|
|
613
|
+
decoded datenum values when time decoding applies.
|
|
614
|
+
|
|
615
|
+
coordinates are returned in ascending order even when stored
|
|
616
|
+
descending; the data is flipped accordingly so it stays aligned.
|
|
617
|
+
|
|
618
|
+
returns (values, coords) with coords a tuple of the bounded coordinate
|
|
619
|
+
array of each dimension, in dimension order
|
|
620
|
+
|
|
621
|
+
raises ValueError on conflicting arguments, a bound-count mismatch,
|
|
622
|
+
invalid or non-intersecting bounds or non-monotonic dimensions,
|
|
623
|
+
KeyError if the variable or one of its coordinates is missing,
|
|
624
|
+
FileNotFoundError if the path does not exist
|
|
625
|
+
"""
|
|
626
|
+
var_meta = _get_variable_meta(path, var_name)
|
|
627
|
+
|
|
628
|
+
### figure out the time dimension name if needed
|
|
629
|
+
if idim_time is not None and not decode_time:
|
|
630
|
+
raise ValueError(f"{idim_time=} conflicts with {decode_time=}")
|
|
631
|
+
|
|
632
|
+
if idim_time is not None:
|
|
633
|
+
try:
|
|
634
|
+
time_name: str | None = var_meta.dimensions[idim_time]
|
|
635
|
+
|
|
636
|
+
except IndexError as e:
|
|
637
|
+
msg = (
|
|
638
|
+
f"'{var_name}' only has ndim={var_meta.ndim} but dimension "
|
|
639
|
+
f"{idim_time} is requested{_err_ctx(path, var_name)}"
|
|
640
|
+
)
|
|
641
|
+
raise IndexError(msg) from e
|
|
642
|
+
|
|
643
|
+
elif decode_time:
|
|
644
|
+
time_name = _guess_time_name(path)
|
|
645
|
+
|
|
646
|
+
else:
|
|
647
|
+
time_name = None
|
|
648
|
+
|
|
649
|
+
### validate the withins
|
|
650
|
+
if len(withins) != var_meta.ndim:
|
|
651
|
+
msg = (
|
|
652
|
+
f"expecting {var_meta.ndim} bounds but received {len(withins)}: "
|
|
653
|
+
f"{withins}, var='{var_name}' (path={path})"
|
|
654
|
+
)
|
|
655
|
+
raise ValueError(msg)
|
|
656
|
+
|
|
657
|
+
### locate the bounds of every dimension
|
|
658
|
+
dims = []
|
|
659
|
+
slices = []
|
|
660
|
+
are_reversed = []
|
|
661
|
+
for dim_name, within in zip(var_meta.dimensions, withins):
|
|
662
|
+
is_time = dim_name == time_name
|
|
663
|
+
values = read_time(path, dim_name) if is_time else read(path, dim_name)
|
|
664
|
+
bounded, slice_within, is_reversed = _dim_bounds(
|
|
665
|
+
within,
|
|
666
|
+
values,
|
|
667
|
+
dim_name=dim_name,
|
|
668
|
+
var_name=var_name,
|
|
669
|
+
path=path,
|
|
670
|
+
is_time=is_time,
|
|
671
|
+
)
|
|
672
|
+
dims.append(bounded)
|
|
673
|
+
slices.append(slice_within)
|
|
674
|
+
are_reversed.append(is_reversed)
|
|
675
|
+
|
|
676
|
+
### read the variable and un-flip dimensions that were reversed
|
|
677
|
+
value = np.flip(
|
|
678
|
+
read(path, var_name, tuple(slices)),
|
|
679
|
+
axis=[i for i, is_reversed in enumerate(are_reversed) if is_reversed],
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
return value, tuple(dims)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
# ----- writers ----
|
|
686
|
+
def _set_my_attrs(h_file: nc.Dataset, name: str) -> None:
|
|
687
|
+
h = h_file[name]
|
|
688
|
+
if name.lower() in ("lon", "longitude", "longitudes"):
|
|
689
|
+
h.axis = "X"
|
|
690
|
+
h.units = "degrees_east"
|
|
691
|
+
h.long_name = "longitude"
|
|
692
|
+
h.standard_name = "longitude"
|
|
693
|
+
|
|
694
|
+
if name.lower() in ("lat", "latitude", "latitudes"):
|
|
695
|
+
h.axis = "Y"
|
|
696
|
+
h.units = "degrees_north"
|
|
697
|
+
h.long_name = "latitude"
|
|
698
|
+
h.standard_name = "latitude"
|
|
699
|
+
|
|
700
|
+
if name.lower() in (
|
|
701
|
+
"lev",
|
|
702
|
+
"level",
|
|
703
|
+
"levs",
|
|
704
|
+
"levels",
|
|
705
|
+
"pressure",
|
|
706
|
+
"plev",
|
|
707
|
+
"dep",
|
|
708
|
+
"depth",
|
|
709
|
+
"depths",
|
|
710
|
+
):
|
|
711
|
+
h.axis = "Z"
|
|
712
|
+
|
|
713
|
+
if any(guess in name.lower() for guess in ("time", "date")):
|
|
714
|
+
h.axis = "T"
|
|
715
|
+
h.units = "days since 2000-01-01 00:00:00"
|
|
716
|
+
h.long_name = "time"
|
|
717
|
+
h.standard_name = "time"
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def write_attr(path: PathLike, var_name: str, attr_name: str, value: Any) -> None:
|
|
721
|
+
"""
|
|
722
|
+
set an attribute of
|
|
723
|
+
the file if var_name == '/'
|
|
724
|
+
the variable else
|
|
725
|
+
|
|
726
|
+
raises KeyError if the variable does not exist, FileNotFoundError if
|
|
727
|
+
the path does not exist
|
|
728
|
+
"""
|
|
729
|
+
# check var_name here; KeyError and FileNotFoundError surface from here
|
|
730
|
+
_get_variable_meta(path, var_name)
|
|
731
|
+
with nc.Dataset(path, "a") as h_file:
|
|
732
|
+
if var_name == "/":
|
|
733
|
+
h_file.setncattr(attr_name, value)
|
|
734
|
+
else:
|
|
735
|
+
setattr(h_file[var_name], attr_name, value)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def write(
|
|
739
|
+
path: PathLike,
|
|
740
|
+
var_name: str,
|
|
741
|
+
data: ArrayLike,
|
|
742
|
+
subsets: tuple[slice, ...] | None = None,
|
|
743
|
+
) -> None:
|
|
744
|
+
"""
|
|
745
|
+
write data into an existing variable, fully or by slices
|
|
746
|
+
|
|
747
|
+
without subsets, data.shape must equal the variable's shape. with
|
|
748
|
+
subsets (one slice per dimension), len(subsets) must equal the
|
|
749
|
+
variable's ndim; netCDF4 then applies its own shape/broadcast rules.
|
|
750
|
+
|
|
751
|
+
raises ValueError on empty data, mismatched shapes or failing slices,
|
|
752
|
+
KeyError if the variable does not exist, FileNotFoundError if the path
|
|
753
|
+
does not exist
|
|
754
|
+
"""
|
|
755
|
+
data = np.asarray(data)
|
|
756
|
+
if any(s == 0 for s in data.shape):
|
|
757
|
+
raise ValueError(
|
|
758
|
+
f"I'm not going to help you write an empty data of shape={data.shape} to {var_name=} in {path!s}"
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
# check var_name here; FileNotFoundError and KeyError surface from here
|
|
762
|
+
var_meta = _get_variable_meta(path, var_name)
|
|
763
|
+
|
|
764
|
+
# check the data shape
|
|
765
|
+
if subsets is None and var_meta.shape != data.shape:
|
|
766
|
+
raise ValueError(
|
|
767
|
+
f"data shapes are different in the input and the file. input={data.shape}, file={var_meta.shape}. {var_name=} in {path!s}"
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
elif subsets is not None:
|
|
771
|
+
# check the ndim
|
|
772
|
+
received = len(subsets)
|
|
773
|
+
expected = var_meta.ndim
|
|
774
|
+
if received != expected:
|
|
775
|
+
raise ValueError(
|
|
776
|
+
f"length of input slices does not match ndim(variable), {expected=} {received=}"
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
with warnings.catch_warnings():
|
|
780
|
+
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
|
781
|
+
with nc.Dataset(path, "a") as h:
|
|
782
|
+
if subsets is None:
|
|
783
|
+
h[var_name][:] = data
|
|
784
|
+
else:
|
|
785
|
+
try:
|
|
786
|
+
h[var_name][subsets] = data
|
|
787
|
+
except Exception as e:
|
|
788
|
+
msg = (
|
|
789
|
+
f"unable to write data of shape={data.shape} to "
|
|
790
|
+
f"'{var_name}'({var_meta.shape}) with {subsets=}"
|
|
791
|
+
f"{_err_ctx(path, var_name)}"
|
|
792
|
+
)
|
|
793
|
+
raise ValueError(msg) from e
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def create(
|
|
797
|
+
path: PathLike,
|
|
798
|
+
var_name: str,
|
|
799
|
+
dim_specs: dict[str, int],
|
|
800
|
+
use_my_attrs: bool = True,
|
|
801
|
+
significant_digits: None | int = None,
|
|
802
|
+
dtype: DTypeSpec = np.float32,
|
|
803
|
+
complevel: Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] | None = 4,
|
|
804
|
+
shuffle: bool = True,
|
|
805
|
+
) -> None:
|
|
806
|
+
"""
|
|
807
|
+
create a variable and its dimensions in a file, skipping existing parts
|
|
808
|
+
|
|
809
|
+
dim_specs maps dimension names to sizes, in the variable's dimension
|
|
810
|
+
order; the variable takes exactly this shape. missing dimensions are
|
|
811
|
+
created first (each as a coordinate variable of the same name); a
|
|
812
|
+
missing file is created on the fly.
|
|
813
|
+
|
|
814
|
+
if the variable already exists, its shape and dimension names must
|
|
815
|
+
match dim_specs exactly and nothing is written - calls are idempotent.
|
|
816
|
+
when the variable doubles as its own first dimension (e.g. a
|
|
817
|
+
coordinate), it is created as part of the dimension step above.
|
|
818
|
+
|
|
819
|
+
use_my_attrs stamps CF-ish axis/units/long_name attributes onto
|
|
820
|
+
created coordinate variables recognised as lon/lat/level/time-like.
|
|
821
|
+
dtype accepts anything np.dtype understands ('f4', np.float32, ...);
|
|
822
|
+
complevel > 0 enables zlib compression with the given level and
|
|
823
|
+
shuffle filter.
|
|
824
|
+
|
|
825
|
+
raises ValueError when an existing variable disagrees with dim_specs
|
|
826
|
+
"""
|
|
827
|
+
shape = tuple(dim_specs.values())
|
|
828
|
+
dim_names = tuple(dim_specs.keys())
|
|
829
|
+
|
|
830
|
+
# ----- check the consistency of shape and dimnames if variable already exists
|
|
831
|
+
try:
|
|
832
|
+
var_meta = _get_variable_meta(path, var_name)
|
|
833
|
+
except (KeyError, FileNotFoundError):
|
|
834
|
+
var_meta = None
|
|
835
|
+
|
|
836
|
+
if var_meta is not None: # if the var_name exists in the file
|
|
837
|
+
if shape != var_meta.shape:
|
|
838
|
+
raise ValueError(
|
|
839
|
+
f"shape of '{var_name}' differs between the input ({shape}) "
|
|
840
|
+
f"and the existing file ({var_meta.shape}) (path={path})"
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
if dim_names != var_meta.dimensions:
|
|
844
|
+
raise ValueError(
|
|
845
|
+
f"dimension names of '{var_name}' differ between the input ({dim_names}) "
|
|
846
|
+
f"and the existing file ({var_meta.dimensions}) (path={path})"
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
return # everything checks out and nothing to do here
|
|
850
|
+
|
|
851
|
+
try:
|
|
852
|
+
existing_names = var_names(path)
|
|
853
|
+
except FileNotFoundError:
|
|
854
|
+
existing_names = ()
|
|
855
|
+
|
|
856
|
+
missing_dims = {
|
|
857
|
+
name: size for name, size in dim_specs.items() if name not in existing_names
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
kwargs: dict[str, Any] = {
|
|
861
|
+
"significant_digits": significant_digits,
|
|
862
|
+
"compression": "zlib",
|
|
863
|
+
"complevel": complevel,
|
|
864
|
+
"shuffle": shuffle,
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
with nc.Dataset(path, "a") as h_file:
|
|
868
|
+
# create the dimension variables first
|
|
869
|
+
# note: nc.DatatypeType only exists in netCDF4's type stubs, hence cast()
|
|
870
|
+
for name, size in missing_dims.items():
|
|
871
|
+
h_file.createDimension(name, size)
|
|
872
|
+
h_file.createVariable(name, cast("nc.DatatypeType", dtype), (name,))
|
|
873
|
+
if use_my_attrs:
|
|
874
|
+
_set_my_attrs(h_file, name)
|
|
875
|
+
|
|
876
|
+
# then the variable itself (unless it is one of the dimensions above,
|
|
877
|
+
# i.e. the variable is its own dimension and already exists)
|
|
878
|
+
if var_name not in missing_dims:
|
|
879
|
+
h_file.createVariable(
|
|
880
|
+
var_name, cast("nc.DatatypeType", dtype), dim_names, **kwargs
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _confirm_overwrite(path: PathLike, var_name: str) -> None:
|
|
885
|
+
"""interactively confirm overwriting; declines after 10 invalid answers"""
|
|
886
|
+
print(f"{var_name=} already exists in the file path {path!s}")
|
|
887
|
+
|
|
888
|
+
valid = ("y", "n", "yes", "no")
|
|
889
|
+
for _ in range(10):
|
|
890
|
+
answer = input("Confirm to overwrite the values in the path? (y/n) ").lower()
|
|
891
|
+
if answer in valid:
|
|
892
|
+
break
|
|
893
|
+
else:
|
|
894
|
+
answer = "n"
|
|
895
|
+
|
|
896
|
+
if answer not in ("y", "yes"):
|
|
897
|
+
raise FileExistsError(f"{var_name=} already exists in {path}")
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def save(
|
|
901
|
+
path: PathLike,
|
|
902
|
+
data: Mapping[str, ArrayLike],
|
|
903
|
+
overwrite_var: bool = False,
|
|
904
|
+
overwrite_dim: bool = False,
|
|
905
|
+
use_my_attrs: bool = True,
|
|
906
|
+
significant_digits: None | int = None,
|
|
907
|
+
dtype: DTypeSpec = np.float32,
|
|
908
|
+
complevel: Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] | None = 4,
|
|
909
|
+
shuffle: bool = True,
|
|
910
|
+
) -> None:
|
|
911
|
+
"""
|
|
912
|
+
save a variable and its dimension coordinates to a file in one call
|
|
913
|
+
|
|
914
|
+
the first entry is the variable, the rest are its dimension
|
|
915
|
+
coordinates (each 1-D), or - when the variable is its own dimension -
|
|
916
|
+
it is the only entry:
|
|
917
|
+
|
|
918
|
+
data = {
|
|
919
|
+
var_name: var_data,
|
|
920
|
+
dim1_name: dim1_data,
|
|
921
|
+
...
|
|
922
|
+
dimN_name: dimN_data,
|
|
923
|
+
}
|
|
924
|
+
data = {var_name: var_data} # variable == its own dimension
|
|
925
|
+
|
|
926
|
+
a missing file is created; existing dimensions are reused.
|
|
927
|
+
|
|
928
|
+
overwrite protection is split by role: overwrite_var guards the
|
|
929
|
+
variable (the FIRST key) and overwrite_dim guards its dimension
|
|
930
|
+
coordinates (the REMAINING keys). when a guarded name already exists
|
|
931
|
+
in the file, _confirm_overwrite asks on the terminal; pass True for a
|
|
932
|
+
role to skip the question. a single-entry data dict has no separate
|
|
933
|
+
dimension coordinates, so overwrite_var covers it alone.
|
|
934
|
+
|
|
935
|
+
remaining parameters pass through to create().
|
|
936
|
+
|
|
937
|
+
raises ValueError when entries are empty, cannot be cast to arrays or
|
|
938
|
+
a dimension is not 1-D, FileExistsError when overwrite is declined
|
|
939
|
+
"""
|
|
940
|
+
names = tuple(data.keys())
|
|
941
|
+
var_name = names[0]
|
|
942
|
+
|
|
943
|
+
# ---- overwrite protection
|
|
944
|
+
if not overwrite_var and _has_variable(path, var_name):
|
|
945
|
+
_confirm_overwrite(path, var_name)
|
|
946
|
+
|
|
947
|
+
if not overwrite_dim:
|
|
948
|
+
for dim_name in names[1:]:
|
|
949
|
+
if _has_variable(path, dim_name):
|
|
950
|
+
_confirm_overwrite(path, dim_name)
|
|
951
|
+
|
|
952
|
+
# ---- data check - can cast to array?
|
|
953
|
+
arrays: dict[str, np.ndarray] = {}
|
|
954
|
+
for name, value in data.items():
|
|
955
|
+
try:
|
|
956
|
+
arrays[name] = np.asarray(value)
|
|
957
|
+
|
|
958
|
+
except Exception as e:
|
|
959
|
+
msg = f"cannot cast data of '{name}' to a numpy array"
|
|
960
|
+
raise ValueError(msg) from e
|
|
961
|
+
|
|
962
|
+
# ---- data check - is ndim(dim) == 1?
|
|
963
|
+
for i, (name, value) in enumerate(arrays.items()):
|
|
964
|
+
if (
|
|
965
|
+
(len(arrays) == 1 and i == 0) # the only entry (variable == dimension)
|
|
966
|
+
or (i > 0) # the dimensions
|
|
967
|
+
) and value.ndim != 1:
|
|
968
|
+
msg = (
|
|
969
|
+
f"expecting dimension '{name}' with ndim==1, found shape={value.shape}"
|
|
970
|
+
)
|
|
971
|
+
raise ValueError(msg)
|
|
972
|
+
|
|
973
|
+
if any(s == 0 for s in value.shape):
|
|
974
|
+
raise ValueError(
|
|
975
|
+
f"I'm not going to help you write an empty data of shape={value.shape} to {name=}"
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
# ---- assign parameters to create and write functions
|
|
979
|
+
names = tuple(data.keys())
|
|
980
|
+
|
|
981
|
+
if len(names) == 1:
|
|
982
|
+
dim_names = (names[0],)
|
|
983
|
+
else:
|
|
984
|
+
dim_names = names[1:]
|
|
985
|
+
|
|
986
|
+
dim_specs = {name: len(arrays[name]) for name in dim_names}
|
|
987
|
+
|
|
988
|
+
create(
|
|
989
|
+
path,
|
|
990
|
+
names[0],
|
|
991
|
+
dim_specs,
|
|
992
|
+
use_my_attrs,
|
|
993
|
+
significant_digits,
|
|
994
|
+
dtype,
|
|
995
|
+
complevel,
|
|
996
|
+
shuffle,
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
for name, array in arrays.items():
|
|
1000
|
+
write(path, name, array)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: ncfunc
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: lkkbox
|
|
6
|
+
Author-email: lkkbox <mail@mail.com>
|
|
7
|
+
Requires-Dist: datenum>=0.1.0
|
|
8
|
+
Requires-Dist: netcdf4>=1.7.3
|
|
9
|
+
Requires-Dist: numpy>=1.24
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# ncfunc
|
|
14
|
+
|
|
15
|
+
Functional-style reading and writing of NetCDF data files.
|
|
16
|
+
|
|
17
|
+
`ncfunc` wraps [netCDF4](https://github.com/Unidata/netcdf4-python) and trades
|
|
18
|
+
its open-handle, object-oriented style for small stateless functions: every
|
|
19
|
+
call opens the file, does one thing, closes it, and leaves nothing behind.
|
|
20
|
+
Along the way it decodes CF time coordinates, subsets by coordinate bounds,
|
|
21
|
+
and turns opaque netCDF4 errors into messages that say what failed, where,
|
|
22
|
+
and why - keeping the original exception chained underneath.
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- **Stateless functions** - no `Dataset` handles to open, close or leak
|
|
27
|
+
- **Readable errors** - Exceptions are raised with details such as file path,
|
|
28
|
+
variable name, dimension name, etc.
|
|
29
|
+
- **Time decoding** - `read_time` decodes CF `'delta since epoch'` units to
|
|
30
|
+
[datenum](https://pypi.org/project/datenum/) serials, including fixed-length
|
|
31
|
+
calendars (`360_day`, `365_day`)
|
|
32
|
+
- **Bounds-based subsetting** - `read_within` slices variables by coordinate
|
|
33
|
+
ranges instead of index arithmetic
|
|
34
|
+
- **Cached metadata** - structure queries reuse a path-keyed cache that
|
|
35
|
+
auto-refreshes when a file's mtime changes (`DatasetMeta`)
|
|
36
|
+
- **One-shot writer** - `save()` creates dimensions, coordinates and the
|
|
37
|
+
variable in a single call, with overwrite protection
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
Requires Python >= 3.10, with dependencies numpy, netCDF4 and datenum installed
|
|
42
|
+
automatically:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
pip install ncfunc
|
|
46
|
+
# or
|
|
47
|
+
uv add ncfunc
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quickstart
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import ncfunc as ncf
|
|
54
|
+
|
|
55
|
+
file = "tests/ersst_2022-2024.nc"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## API overview
|
|
59
|
+
|
|
60
|
+
| function | purpose |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| `var_names(path)` | variable names |
|
|
63
|
+
| `dim_names(path, var)` | dimension names of a variable |
|
|
64
|
+
| `shape(path, var)` / `ndim(path, var)` | shape / rank of a variable |
|
|
65
|
+
| `attr_names(path, var)` / `attr_val(path, var, attr)` | attributes of a variable, or of the file with `var='/'` |
|
|
66
|
+
| `var_names_include(...)` | find variables by substring and rank |
|
|
67
|
+
| `attr_names_include(...)` | find attributes by substring |
|
|
68
|
+
| `read(path, var, subsets?)` | read a variable into `np.ndarray` |
|
|
69
|
+
| `read_time(path, ...)` | read and decode a time coordinate to datenum values |
|
|
70
|
+
| `read_within(path, var, withins, ...)` | bounds-based subset read with coordinates |
|
|
71
|
+
| `write(path, var, data, subsets?)` | write into an existing variable |
|
|
72
|
+
| `create(path, var, dim_specs, ...)` | create a variable (+ dimensions), idempotently |
|
|
73
|
+
| `save(path, data, ...)` | create + write a variable and its coordinates in one call |
|
|
74
|
+
| `write_attr(path, var, attr, value)` | set a variable or root (`'/'`) attribute |
|
|
75
|
+
| `DatasetMeta(path)` | static structural snapshot, cached per resolved path |
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
### Inspect metadata
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
>>> ncf.var_names(file)
|
|
82
|
+
('time', 'lon', 'lat', 'sst', 'ssta')
|
|
83
|
+
|
|
84
|
+
>>> ncf.shape(file, 'sst')
|
|
85
|
+
(36, 121, 240)
|
|
86
|
+
|
|
87
|
+
>>> ncf.dim_names(file, 'sst')
|
|
88
|
+
('time', 'lat', 'lon')
|
|
89
|
+
|
|
90
|
+
>>> ncf.attr_val(file, '/', 'title') # '/' selects the file's root attributes
|
|
91
|
+
'NOAA monthly ERSSTv6 (in situ only)'
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Search helpers locate variables and attributes by substring, and insist on an
|
|
95
|
+
unambiguous match (by rank or count) before returning:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
>>> ncf.var_names_include(file, ('sst',), accept_ndims=3)
|
|
99
|
+
('sst', 'ssta')
|
|
100
|
+
|
|
101
|
+
>>> ncf.var_names_include(file, name_includes=('sst',), accept_ndims=3, accept_counts=(2,))
|
|
102
|
+
('sst', 'ssta')
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Read data
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
sst = ncf.read(file, 'sst') # whole variable
|
|
109
|
+
top = ncf.read(file, 'sst', ((slice(-4, None),) * 3)) # last 4 steps of every dim
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Decode time
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
>>> import datenum as dn
|
|
116
|
+
>>> t = ncf.read_time(file)
|
|
117
|
+
>>> dn.to_string(t[0]), dn.to_string(t[-1])
|
|
118
|
+
('2022-01-15 00:00:00', '2024-12-15 00:00:00')
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The time variable, its `units` attribute and its `calendar` attribute are all
|
|
122
|
+
guessed; pass `time_name`, `unit_name`, `calendar_name` explicitly to override.
|
|
123
|
+
Month/year-based units decode via month arithmetic, day-based ones against the
|
|
124
|
+
declared calendar - including `360_day` and `365_day` fixed calendars.
|
|
125
|
+
|
|
126
|
+
### Subset by bounds, not indices
|
|
127
|
+
|
|
128
|
+
`read_within` takes one `(lower, upper)` pair per dimension of the variable
|
|
129
|
+
(`None` = unbounded), reads only what intersects, and returns both the data
|
|
130
|
+
and the bounded coordinates. Coordinates come back ascending even when stored
|
|
131
|
+
descending; the data is flipped to stay aligned:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
>>> sst, (time, lat, lon) = ncf.read_within(
|
|
135
|
+
... file,
|
|
136
|
+
... 'sst',
|
|
137
|
+
... withins=((None, None), (-30.0, 30.0), (150.0, 210.0)),
|
|
138
|
+
... )
|
|
139
|
+
>>> sst.shape, lat[0], lat[-1]
|
|
140
|
+
((36, 41, 41), -30.0, 30.0)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The time dimension is found by guessing a time-named coordinate; point at it
|
|
144
|
+
explicitly with `idim_time=<index>` if the guess would be wrong, or disable
|
|
145
|
+
time handling with `decode_time=False`.
|
|
146
|
+
|
|
147
|
+
### Write data
|
|
148
|
+
|
|
149
|
+
`save` writes a variable plus its dimensions in one shot. The first entry is
|
|
150
|
+
the variable, the rest are its 1-D dimensions:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
import numpy as np
|
|
154
|
+
|
|
155
|
+
ncf.save(
|
|
156
|
+
"out.nc",
|
|
157
|
+
{
|
|
158
|
+
"tas": np.arange(12, dtype="f4").reshape(3, 4),
|
|
159
|
+
"time": np.array([0, 31, 59]),
|
|
160
|
+
"lon": np.linspace(0.5, 3.5, 4),
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
This creates `out.nc` with dimensions `time` and `lon`, coordinate variables
|
|
166
|
+
stamped with CF-ish attributes (`axis`, `units`, `standard_name`), and the
|
|
167
|
+
compressed `tas` variable. If `tas` already exists in the file, `save` asks
|
|
168
|
+
for confirmation on the terminal; `overwrite_var=True` and `overwrite_dim=True`
|
|
169
|
+
skips the question.
|
|
170
|
+
|
|
171
|
+
For finer control, use the pieces directly:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
ncf.create(path, "tas", {"time": 3, "lon": 4}) # idempotent; missing dims created
|
|
175
|
+
ncf.write(path, "tas", data) # full write, shape must match
|
|
176
|
+
ncf.write(path, "tas", data, ((slice(0, 1), slice(None)),)) # or by slices
|
|
177
|
+
ncf.write_attr(path, "/", "history", "created today") # '/' = root attribute
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Development
|
|
181
|
+
|
|
182
|
+
```sh
|
|
183
|
+
uv sync # install dependencies
|
|
184
|
+
uv run pytest # run the test suite
|
|
185
|
+
uv run ruff check src/ tests/
|
|
186
|
+
uv run ruff format --check src/ tests/
|
|
187
|
+
uv run ty check src/ tests/
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## License
|
|
191
|
+
|
|
192
|
+
MIT - see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
ncfunc/__init__.py,sha256=w3oTvNl5ZoEKDiNsP6SEmhrx0fyjOmnIqfDSW7Cr_0o,541
|
|
2
|
+
ncfunc/core.py,sha256=t_t31ZJ6_TfqO60QlQhKVuA3K1LQClCNaEcDc3xuaVE,32467
|
|
3
|
+
ncfunc-0.1.0.dist-info/WHEEL,sha256=4OL6Foqnnp3xRY5wMkjgc25_i5YJC6dKsC6LPcjqEoU,80
|
|
4
|
+
ncfunc-0.1.0.dist-info/entry_points.txt,sha256=phLr8BiqNnkGv74LUxPiqVzj3hQVLP2wWe2Xq6IBYD8,40
|
|
5
|
+
ncfunc-0.1.0.dist-info/METADATA,sha256=8BXbgzKTwM61TOzGzq1dNCJUWHCeHazlfDeQJZSWwC4,6284
|
|
6
|
+
ncfunc-0.1.0.dist-info/RECORD,,
|