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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.5
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ ncfunc = ncfunc:main
3
+