ClimateGraph 0.4.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.
- ClimateGraph/__init__.py +9 -0
- ClimateGraph/appkernel.py +148 -0
- ClimateGraph/cli.py +63 -0
- ClimateGraph/data/__init__.py +8 -0
- ClimateGraph/data/data.py +508 -0
- ClimateGraph/data/point_surface.py +91 -0
- ClimateGraph/data/regular_grid.py +25 -0
- ClimateGraph/data/satellite_swath.py +21 -0
- ClimateGraph/domain/__init__.py +11 -0
- ClimateGraph/domain/domain.py +181 -0
- ClimateGraph/domain/domains.py +279 -0
- ClimateGraph/plot/__init__.py +11 -0
- ClimateGraph/plot/plot.py +402 -0
- ClimateGraph/plot/plots.py +1054 -0
- ClimateGraph/plot/primitive.py +99 -0
- ClimateGraph/plot/primitives.py +160 -0
- ClimateGraph/reader/__init__.py +15 -0
- ClimateGraph/reader/reader/__init__.py +14 -0
- ClimateGraph/reader/reader/point_surface/__init__.py +7 -0
- ClimateGraph/reader/reader/point_surface/csv_base.py +135 -0
- ClimateGraph/reader/reader/point_surface/default.py +36 -0
- ClimateGraph/reader/reader/point_surface/dmc.py +10 -0
- ClimateGraph/reader/reader/point_surface/sinca.py +10 -0
- ClimateGraph/reader/reader/point_surface/single_file.py +60 -0
- ClimateGraph/reader/reader/point_surface/station_per_file.py +55 -0
- ClimateGraph/reader/reader/point_surface/variable_per_file.py +91 -0
- ClimateGraph/reader/reader/reader.py +353 -0
- ClimateGraph/reader/reader/regular_grid/__init__.py +7 -0
- ClimateGraph/reader/reader/regular_grid/chimere.py +24 -0
- ClimateGraph/reader/reader/regular_grid/default.py +51 -0
- ClimateGraph/reader/reader/regular_grid/wrf.py +53 -0
- ClimateGraph/utils/__init__.py +1 -0
- ClimateGraph/utils/control_model.py +292 -0
- ClimateGraph/utils/dataset_utils.py +287 -0
- ClimateGraph/utils/general_utils.py +259 -0
- ClimateGraph/utils/parser.py +269 -0
- ClimateGraph/utils/registry.py +81 -0
- ClimateGraph/utils/resample_engine.py +156 -0
- climategraph-0.4.0.dist-info/METADATA +177 -0
- climategraph-0.4.0.dist-info/RECORD +44 -0
- climategraph-0.4.0.dist-info/WHEEL +5 -0
- climategraph-0.4.0.dist-info/entry_points.txt +2 -0
- climategraph-0.4.0.dist-info/licenses/LICENSE +21 -0
- climategraph-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""CSV layout: one file per variable."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import unicodedata
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import xarray as xr
|
|
10
|
+
|
|
11
|
+
from ..reader import ReadSpec
|
|
12
|
+
from .csv_base import CSVPointSurfaceReader
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class VariablePerFileReader(CSVPointSurfaceReader):
|
|
16
|
+
"""Reader for the variable-per-file CSV layout (one variable per file).
|
|
17
|
+
|
|
18
|
+
Layout: one CSV per variable, each with a time column and one column per
|
|
19
|
+
station (station names as headers), plus a metadata sheet (CSV/Excel). The
|
|
20
|
+
variable a file carries is NOT inferred from its name — the data block
|
|
21
|
+
supplies an explicit ``var_files`` mapping ``{canonical_var: filename}``.
|
|
22
|
+
Originally written for Quito (REMMAQ), whose defaults this keeps.
|
|
23
|
+
|
|
24
|
+
Station names in the CSV headers (e.g. ``ELCAMAL``, ``GUAMANI``) may be the
|
|
25
|
+
ASCII-uppercased, space-stripped forms of accented names in the metadata
|
|
26
|
+
(``El Camal``, ``Guamaní``); ``_norm_site_key`` reconciles them.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
type_aliases = ["variable-per-file"]
|
|
30
|
+
default_station_key = "Estación"
|
|
31
|
+
default_time_col = "Fecha"
|
|
32
|
+
time_format = "%d-%b-%Y %H:%M:%S"
|
|
33
|
+
latlon_aliases = {"Latitud": "latitude", "Longitud": "longitude"}
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def _open_one(cls, path: Path, spec: ReadSpec) -> pd.DataFrame:
|
|
37
|
+
"""Read one variable's CSV into a frame."""
|
|
38
|
+
time_col = spec.extras.get("time_col", cls.default_time_col)
|
|
39
|
+
df = pd.read_csv(path)
|
|
40
|
+
df[time_col] = pd.to_datetime(df[time_col], format=cls.time_format)
|
|
41
|
+
# Resolve which canonical variable this file holds from var_files.
|
|
42
|
+
df.attrs["var"] = cls._var_for(path, spec)
|
|
43
|
+
return df
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def _to_xarray(cls, raw: pd.DataFrame, spec: ReadSpec) -> xr.Dataset:
|
|
47
|
+
"""Convert one variable's frame into a dataset holding that variable."""
|
|
48
|
+
time_col = spec.extras.get("time_col", cls.default_time_col)
|
|
49
|
+
canonical = raw.attrs["var"]
|
|
50
|
+
# Name the single data var as the file-native name so the inherited
|
|
51
|
+
# rename maps it to the canonical name (no-op when they match).
|
|
52
|
+
var_name = canonical
|
|
53
|
+
if spec.vars is not None and canonical in spec.vars:
|
|
54
|
+
var_name = spec.vars[canonical].get("name") or canonical
|
|
55
|
+
|
|
56
|
+
df = raw.set_index(time_col)
|
|
57
|
+
df.index.name = "time"
|
|
58
|
+
df.columns.name = "site"
|
|
59
|
+
da = xr.DataArray(df, name=var_name)
|
|
60
|
+
return da.to_dataset()
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def _join(cls, pieces: list[xr.Dataset], spec: ReadSpec) -> xr.Dataset:
|
|
64
|
+
# Variable-per-file: combine the differing vars on shared time/site.
|
|
65
|
+
"""Merge the per-variable datasets into one."""
|
|
66
|
+
return xr.merge(pieces)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def _var_for(cls, path: Path, spec: ReadSpec) -> str:
|
|
70
|
+
"""Work out which variable a file holds, from its name or the spec."""
|
|
71
|
+
var_files = spec.extras.get("var_files")
|
|
72
|
+
if not var_files:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"{cls.__name__} requires a 'var_files' mapping "
|
|
75
|
+
"{canonical_var: filename} in the data block."
|
|
76
|
+
)
|
|
77
|
+
for canonical, filename in var_files.items():
|
|
78
|
+
if Path(filename).name == path.name:
|
|
79
|
+
return canonical
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"{cls.__name__}: file {path.name!r} not found in var_files {var_files}."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _norm_site_key(value) -> str:
|
|
86
|
+
# Strip accents, drop spaces, uppercase — turns the Excel "Guamaní" /
|
|
87
|
+
# "El Camal" into the CSV headers "GUAMANI" / "ELCAMAL".
|
|
88
|
+
"""Normalize a site identifier so metadata and data join reliably."""
|
|
89
|
+
s = unicodedata.normalize("NFKD", str(value))
|
|
90
|
+
s = "".join(c for c in s if not unicodedata.combining(c))
|
|
91
|
+
return s.upper().replace(" ", "")
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""The read lifecycle.
|
|
2
|
+
|
|
3
|
+
Turns files into an :class:`xarray.Dataset` through a fixed sequence of hooks.
|
|
4
|
+
Subclasses plug into individual steps rather than overriding the walk itself.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from abc import ABC
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
import pandas as pd
|
|
16
|
+
import xarray as xr
|
|
17
|
+
|
|
18
|
+
from ClimateGraph.utils.dataset_utils import _record, apply_operation, dim_reduction
|
|
19
|
+
from ClimateGraph.utils.general_utils import manage_path
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
LoadMode = Literal["safe", "unsafe"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ReadSpec:
|
|
28
|
+
"""Bundle of everything a Reader needs to fulfil a read request.
|
|
29
|
+
|
|
30
|
+
Carried through every lifecycle hook so subclasses can branch on any
|
|
31
|
+
field without growing per-hook signatures.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
paths: list[Path]
|
|
35
|
+
vars: dict[str, dict[str, str]] | None = None
|
|
36
|
+
load_mode: LoadMode = "safe"
|
|
37
|
+
cache_dir: Path | None = None
|
|
38
|
+
engine: str | None = None
|
|
39
|
+
extras: dict[str, Any] = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Reader(ABC):
|
|
43
|
+
"""Reader abstract class.
|
|
44
|
+
|
|
45
|
+
Template-method base: ``read(spec)`` walks a fixed lifecycle and
|
|
46
|
+
subclasses plug into individual hooks. No subclass should override
|
|
47
|
+
``read`` itself.
|
|
48
|
+
|
|
49
|
+
Lifecycle (per-file hooks run in BOTH modes; the only difference is
|
|
50
|
+
who drives the per-file loop)::
|
|
51
|
+
|
|
52
|
+
read(spec):
|
|
53
|
+
paths = _resolve_paths(spec) # download/cache here
|
|
54
|
+
if safe:
|
|
55
|
+
ds = _open_many(paths, spec) # internally calls
|
|
56
|
+
# _to_xarray + _preprocess
|
|
57
|
+
# per file via xarray's
|
|
58
|
+
# open_mfdataset(preprocess=)
|
|
59
|
+
else:
|
|
60
|
+
pieces = []
|
|
61
|
+
for p in paths:
|
|
62
|
+
raw = _open_one(p, spec)
|
|
63
|
+
piece = _to_xarray(raw, spec)
|
|
64
|
+
piece = _preprocess(piece, spec)
|
|
65
|
+
pieces.append(piece)
|
|
66
|
+
ds = _join(pieces, spec)
|
|
67
|
+
ds = _postprocess(ds, spec)
|
|
68
|
+
ds = _finalize(ds, spec) # spec-driven adjustments;
|
|
69
|
+
# runs for every reader
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
registry: dict[str, dict[str, type[Reader]]] = {}
|
|
73
|
+
type_aliases: list[str] = []
|
|
74
|
+
topology: str
|
|
75
|
+
|
|
76
|
+
def __init_subclass__(cls, **kwargs):
|
|
77
|
+
super().__init_subclass__(**kwargs)
|
|
78
|
+
|
|
79
|
+
if not hasattr(cls, "topology"):
|
|
80
|
+
raise TypeError(f"{cls.__name__} must define a 'topology' attribute.")
|
|
81
|
+
|
|
82
|
+
topology = cls.topology.lower()
|
|
83
|
+
if topology not in Reader.registry:
|
|
84
|
+
Reader.registry[topology] = {}
|
|
85
|
+
|
|
86
|
+
Reader.registry[topology][cls.__name__.lower()] = cls
|
|
87
|
+
|
|
88
|
+
for alias in cls.__dict__.get("type_aliases", []):
|
|
89
|
+
Reader.registry[topology][alias.lower()] = cls
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def get_reader_subclass(cls, topology: str, reader: str) -> type[Reader]:
|
|
93
|
+
topology = topology.lower()
|
|
94
|
+
reader = reader.lower()
|
|
95
|
+
if topology not in cls.registry:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"No topology type named {topology}. Please use one of the following: {list(cls.registry.keys())}"
|
|
98
|
+
)
|
|
99
|
+
try:
|
|
100
|
+
return cls.registry[topology][reader]
|
|
101
|
+
except KeyError as err:
|
|
102
|
+
raise ValueError(f"No reader {reader} for topology {topology}") from err
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def check_reader_type(cls, topology: str, reader: str) -> bool:
|
|
106
|
+
return (topology.lower() in cls.registry) and (
|
|
107
|
+
reader.lower() in cls.registry[topology.lower()]
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def read(cls, spec: ReadSpec) -> xr.Dataset:
|
|
112
|
+
"""Walk the lifecycle and return the assembled dataset."""
|
|
113
|
+
local_paths = cls._resolve_paths(spec)
|
|
114
|
+
if not local_paths:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"{cls.__name__}._resolve_paths returned no paths for {spec.paths}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
spec = ReadSpec(
|
|
120
|
+
paths=local_paths,
|
|
121
|
+
vars=spec.vars,
|
|
122
|
+
load_mode=spec.load_mode,
|
|
123
|
+
cache_dir=spec.cache_dir,
|
|
124
|
+
engine=spec.engine,
|
|
125
|
+
extras=spec.extras,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if spec.load_mode == "safe":
|
|
129
|
+
ds = cls._open_many(local_paths, spec)
|
|
130
|
+
elif spec.load_mode == "unsafe":
|
|
131
|
+
pieces = []
|
|
132
|
+
for path in local_paths:
|
|
133
|
+
raw = cls._open_one(path, spec)
|
|
134
|
+
piece = cls._to_xarray(raw, spec)
|
|
135
|
+
piece = cls._preprocess(piece, spec)
|
|
136
|
+
pieces.append(piece)
|
|
137
|
+
ds = cls._join(pieces, spec)
|
|
138
|
+
else:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"Unknown load_mode {spec.load_mode!r}; expected 'safe' or 'unsafe'."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
ds = cls._postprocess(ds, spec)
|
|
144
|
+
ds = cls._finalize(ds, spec)
|
|
145
|
+
return ds
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def _finalize(cls, ds: xr.Dataset, spec: ReadSpec) -> xr.Dataset:
|
|
149
|
+
"""Apply spec-driven adjustments after ``_postprocess``.
|
|
150
|
+
|
|
151
|
+
Grouped here rather than appended to ``read`` so the lifecycle keeps a
|
|
152
|
+
single finalization seam: new config-driven transforms get sequenced
|
|
153
|
+
in this method, not tacked onto ``read``. Runs for every reader,
|
|
154
|
+
independent of which hooks a subclass overrode (e.g. a ``_postprocess``
|
|
155
|
+
override that doesn't call ``super()``).
|
|
156
|
+
|
|
157
|
+
Order: time-coordinate shaping before value shaping, then the
|
|
158
|
+
optional save. The transforms are independent today (offset touches
|
|
159
|
+
only ``time``, operations only data values), but the convention keeps
|
|
160
|
+
the sequence predictable; ``_save`` is queued last so the file on disk
|
|
161
|
+
reflects every adjustment.
|
|
162
|
+
"""
|
|
163
|
+
_record(ds, f"loaded {len(spec.paths)} file(s): {[str(p) for p in spec.paths]}")
|
|
164
|
+
ds = cls._apply_time_offset(ds, spec)
|
|
165
|
+
ds = cls._apply_operations(ds, spec)
|
|
166
|
+
ds = cls._dim_reduce(ds, spec)
|
|
167
|
+
ds = cls._save(ds, spec)
|
|
168
|
+
return ds
|
|
169
|
+
|
|
170
|
+
netcdf_suffixes: tuple[str, ...] = (".nc", ".nc4", ".netcdf", ".cdf")
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def _save(cls, ds: xr.Dataset, spec: ReadSpec) -> xr.Dataset:
|
|
174
|
+
"""Write the finalized dataset to ``spec.extras['save_to']`` as NetCDF.
|
|
175
|
+
|
|
176
|
+
``save_to`` must be an exact NetCDF file path.
|
|
177
|
+
|
|
178
|
+
Returns ``ds`` unchanged so it stays chainable in ``_finalize``.
|
|
179
|
+
"""
|
|
180
|
+
save_to = spec.extras.get("save_to")
|
|
181
|
+
if not save_to:
|
|
182
|
+
return ds
|
|
183
|
+
target = Path(save_to)
|
|
184
|
+
if target.is_dir() or target.suffix.lower() not in cls.netcdf_suffixes:
|
|
185
|
+
raise ValueError(
|
|
186
|
+
f"save_to must be an exact NetCDF file path "
|
|
187
|
+
f"(one of {cls.netcdf_suffixes}); got {save_to!r}."
|
|
188
|
+
)
|
|
189
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
log.info(f"saved to {target}")
|
|
191
|
+
ds.to_netcdf(target)
|
|
192
|
+
return ds
|
|
193
|
+
|
|
194
|
+
@staticmethod
|
|
195
|
+
def _dim_reduce(ds: xr.Dataset, spec: ReadSpec) -> xr.Dataset:
|
|
196
|
+
"""Apply per-dimension reductions from ``spec.extras['dim_reduce']``.
|
|
197
|
+
|
|
198
|
+
No-op when the key is absent.
|
|
199
|
+
"""
|
|
200
|
+
dr = spec.extras.get("dim_reduce")
|
|
201
|
+
if not dr:
|
|
202
|
+
return ds
|
|
203
|
+
return dim_reduction(ds, dr, name=str(spec.paths[0].stem))
|
|
204
|
+
|
|
205
|
+
@staticmethod
|
|
206
|
+
def _apply_operations(ds: xr.Dataset, spec: ReadSpec) -> xr.Dataset:
|
|
207
|
+
"""Materialize each variable's optional "operation".
|
|
208
|
+
|
|
209
|
+
Single pass, two phases. Variables without an "operation" (the base
|
|
210
|
+
vars) are exposed first under their canonical name; then each variable
|
|
211
|
+
with an "operation" is evaluated against those base vars
|
|
212
|
+
(no operation can reference a composed var, only "pure" vars)
|
|
213
|
+
"""
|
|
214
|
+
if not spec.vars:
|
|
215
|
+
return ds
|
|
216
|
+
|
|
217
|
+
def _key(var_name: str, var_spec: dict) -> str | None:
|
|
218
|
+
"""The dataset key holding this var (canonical after rename, else the
|
|
219
|
+
file name), or None when it isn't in the dataset."""
|
|
220
|
+
if var_name in ds:
|
|
221
|
+
return var_name
|
|
222
|
+
native = var_spec.get("name")
|
|
223
|
+
return native if native in ds else None
|
|
224
|
+
|
|
225
|
+
base: dict[str, xr.DataArray] = dict()
|
|
226
|
+
for var_name, var_spec in spec.vars.items():
|
|
227
|
+
if var_spec.get("operation"):
|
|
228
|
+
continue
|
|
229
|
+
key = _key(var_name, var_spec)
|
|
230
|
+
if key is not None:
|
|
231
|
+
base[var_name] = ds[key]
|
|
232
|
+
|
|
233
|
+
for var_name, var_spec in spec.vars.items():
|
|
234
|
+
operation = var_spec.get("operation")
|
|
235
|
+
if not operation:
|
|
236
|
+
continue
|
|
237
|
+
namespace = base
|
|
238
|
+
self_key = _key(var_name, var_spec)
|
|
239
|
+
if self_key is not None:
|
|
240
|
+
namespace["x"] = ds[self_key]
|
|
241
|
+
namespace[var_name] = ds[self_key]
|
|
242
|
+
result = apply_operation(operation, namespace)
|
|
243
|
+
ds[self_key if self_key is not None else var_name] = result
|
|
244
|
+
_record(ds, f"applied operation on {var_name!r}: {operation}")
|
|
245
|
+
return ds
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _apply_time_offset(ds: xr.Dataset, spec: ReadSpec) -> xr.Dataset:
|
|
249
|
+
"""Shift the ``time`` coordinate by ``spec.extras['time_offset']`` hours.
|
|
250
|
+
|
|
251
|
+
Optional, off by default. Meant for datasets stored in UTC that the
|
|
252
|
+
user wants on a local schedule — e.g. ``time_offset: -3`` in the data
|
|
253
|
+
block reads a UTC file as UTC-3 (Chile). A no-op when the key is
|
|
254
|
+
absent or zero, or when the dataset has no ``time`` coordinate.
|
|
255
|
+
Fractional hours are allowed (e.g. ``5.5``).
|
|
256
|
+
"""
|
|
257
|
+
offset = spec.extras.get("time_offset")
|
|
258
|
+
if not offset or "time" not in ds.coords:
|
|
259
|
+
return ds
|
|
260
|
+
ds = ds.assign_coords(
|
|
261
|
+
time=ds["time"] + pd.to_timedelta(float(offset), unit="h")
|
|
262
|
+
)
|
|
263
|
+
_record(ds, f"applied time offset {offset}h")
|
|
264
|
+
return ds
|
|
265
|
+
|
|
266
|
+
@classmethod
|
|
267
|
+
def _resolve_paths(cls, spec: ReadSpec) -> list[Path]:
|
|
268
|
+
"""Turn the user-supplied path spec into a list of local paths.
|
|
269
|
+
|
|
270
|
+
Default behaviour: glob-expand local paths via ``manage_path``.
|
|
271
|
+
Sorted lexicographically for the unsafe path (which concats in
|
|
272
|
+
input order) so an out-of-order glob doesn't silently produce a
|
|
273
|
+
non-monotonic time axis. Safe path keeps native glob order
|
|
274
|
+
because ``xr.open_mfdataset``'s ``combine="by_coords"`` aligns
|
|
275
|
+
on coords anyway.
|
|
276
|
+
"""
|
|
277
|
+
return manage_path(spec.paths, sort=spec.load_mode == "unsafe")
|
|
278
|
+
|
|
279
|
+
open_engine: str = "netcdf4"
|
|
280
|
+
|
|
281
|
+
@classmethod
|
|
282
|
+
def _open_many(cls, paths: list[Path], spec: ReadSpec) -> xr.Dataset:
|
|
283
|
+
"""Open all files (safe path). Default implementation routes
|
|
284
|
+
per-file ``_to_xarray`` + ``_preprocess`` through
|
|
285
|
+
``xr.open_mfdataset(preprocess=)`` so they run in parallel inside
|
|
286
|
+
xarray's machinery and unused variables get dropped *before*
|
|
287
|
+
the cross-file combine. Non-NetCDF readers override this entirely.
|
|
288
|
+
|
|
289
|
+
``parallel`` is disabled for ``netcdf4`` because libnetcdf is not
|
|
290
|
+
thread-safe — concurrent opens raise ``NetCDF: HDF error``. Other
|
|
291
|
+
engines (e.g. ``h5netcdf``) get parallel opens, so a user who
|
|
292
|
+
opts into a non-default engine via the YAML ``engine:`` knob can
|
|
293
|
+
try to amortise opens across threads.
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
def per_file(ds: xr.Dataset) -> xr.Dataset:
|
|
297
|
+
ds = cls._to_xarray(ds, spec)
|
|
298
|
+
ds = cls._preprocess(ds, spec)
|
|
299
|
+
return ds
|
|
300
|
+
|
|
301
|
+
engine = spec.engine or cls.open_engine
|
|
302
|
+
return xr.open_mfdataset(
|
|
303
|
+
paths,
|
|
304
|
+
chunks={"time": "auto"},
|
|
305
|
+
engine=engine,
|
|
306
|
+
parallel=engine != "netcdf4",
|
|
307
|
+
preprocess=per_file,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
@classmethod
|
|
311
|
+
def _open_one(cls, path: Path, spec: ReadSpec) -> Any:
|
|
312
|
+
"""Open a single file (unsafe path)."""
|
|
313
|
+
engine = spec.engine or cls.open_engine
|
|
314
|
+
return xr.open_dataset(path, chunks={"time": "auto"}, engine=engine)
|
|
315
|
+
|
|
316
|
+
@classmethod
|
|
317
|
+
def _to_xarray(cls, raw: Any, spec: ReadSpec) -> xr.Dataset:
|
|
318
|
+
"""Coerce the raw object returned by ``_open_*`` into an
|
|
319
|
+
``xr.Dataset``. No-op for NetCDF readers. CSV/HDF5/GRIB readers
|
|
320
|
+
override this.
|
|
321
|
+
"""
|
|
322
|
+
if not isinstance(raw, xr.Dataset):
|
|
323
|
+
raise TypeError(
|
|
324
|
+
f"{cls.__name__}._to_xarray default expects xr.Dataset; "
|
|
325
|
+
f"got {type(raw).__name__}. Override _to_xarray to convert."
|
|
326
|
+
)
|
|
327
|
+
return raw
|
|
328
|
+
|
|
329
|
+
@classmethod
|
|
330
|
+
def _preprocess(cls, ds: xr.Dataset, spec: ReadSpec) -> xr.Dataset:
|
|
331
|
+
"""Rename/drop/index. Default: pass-through. Most subclasses override."""
|
|
332
|
+
return ds
|
|
333
|
+
|
|
334
|
+
@classmethod
|
|
335
|
+
def _join(cls, pieces: list[xr.Dataset], spec: ReadSpec) -> xr.Dataset:
|
|
336
|
+
"""Combine per-file pieces (unsafe path only).
|
|
337
|
+
|
|
338
|
+
Default uses ``xr.combine_nested(pieces, concat_dim="time")``,
|
|
339
|
+
which trusts the input order — safe here because
|
|
340
|
+
``_resolve_paths`` sorts paths when ``load_mode == "unsafe"``.
|
|
341
|
+
Readers whose files share dims but hold different variables
|
|
342
|
+
(variable-per-file layouts) should override this and call
|
|
343
|
+
``xr.merge(pieces)`` instead.
|
|
344
|
+
"""
|
|
345
|
+
if len(pieces) == 1:
|
|
346
|
+
return pieces[0]
|
|
347
|
+
return xr.combine_nested(pieces, concat_dim="time")
|
|
348
|
+
|
|
349
|
+
@classmethod
|
|
350
|
+
def _postprocess(cls, ds: xr.Dataset, spec: ReadSpec) -> xr.Dataset:
|
|
351
|
+
"""Whole-dataset cleanup that must run regardless of load mode.
|
|
352
|
+
Default: pass-through."""
|
|
353
|
+
return ds
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Reader for CHIMERE model output."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import xarray as xr
|
|
6
|
+
|
|
7
|
+
from ..reader import ReadSpec
|
|
8
|
+
from .default import DefaultRegularGridReader
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Chimere(DefaultRegularGridReader):
|
|
14
|
+
rename = {
|
|
15
|
+
"nav_lat": "latitude",
|
|
16
|
+
"nav_lon": "longitude",
|
|
17
|
+
"time_counter": "time",
|
|
18
|
+
"bottom_top": "z",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def _postprocess(cls, ds: xr.Dataset, spec: ReadSpec) -> xr.Dataset:
|
|
23
|
+
"""Shape CHIMERE output into the common grid layout."""
|
|
24
|
+
return ds
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Base reader for gridded NetCDF output."""
|
|
2
|
+
|
|
3
|
+
import xarray as xr
|
|
4
|
+
|
|
5
|
+
from ..reader import Reader, ReadSpec
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DefaultRegularGridReader(Reader):
|
|
9
|
+
type_aliases = ["DefaultRegularGrid", "DefaultGrid"]
|
|
10
|
+
topology = "RegularGrid"
|
|
11
|
+
|
|
12
|
+
# Subclasses set this to map source dim/var names → canonical names.
|
|
13
|
+
rename: dict[str, str] = {}
|
|
14
|
+
|
|
15
|
+
# When True, drop rename keys absent from the piece before renaming. Left
|
|
16
|
+
# False by default so a missing variable still raises (a useful error).
|
|
17
|
+
# Readers whose map covers alternative source layouts (e.g. WRF, where the
|
|
18
|
+
# time coordinate is `Time` in cleaned files and `XTIME` in raw output) set
|
|
19
|
+
# it True, since only one of the alternatives is ever present.
|
|
20
|
+
restrict_rename_to_present: bool = False
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def _preprocess(cls, ds: xr.Dataset, spec: ReadSpec) -> xr.Dataset:
|
|
24
|
+
"""Apply the variable renaming and selection declared in the spec."""
|
|
25
|
+
rename = dict(cls.rename)
|
|
26
|
+
if cls.restrict_rename_to_present:
|
|
27
|
+
# Only the class map is filtered — it is the one spelling out
|
|
28
|
+
# alternative source layouts. Names the *user* declared stay in, so a
|
|
29
|
+
# typo in a `vars:` entry still raises here rather than surfacing
|
|
30
|
+
# later as a mysteriously missing variable. Dims as well as
|
|
31
|
+
# variables: a grid's rename map keys on both.
|
|
32
|
+
rename = {
|
|
33
|
+
k: v for k, v in rename.items() if k in ds.variables or k in ds.dims
|
|
34
|
+
}
|
|
35
|
+
rename.update(spec.extras.get("rename", {}))
|
|
36
|
+
if spec.vars is not None:
|
|
37
|
+
# Skip composed vars with no file name
|
|
38
|
+
rename.update(
|
|
39
|
+
{d["name"]: name for name, d in spec.vars.items() if d.get("name")}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
ds = ds.rename(rename)
|
|
43
|
+
ds = ds.reset_coords()
|
|
44
|
+
|
|
45
|
+
if spec.vars is not None:
|
|
46
|
+
keep = set(spec.vars.keys()) | {"longitude", "latitude", "time"}
|
|
47
|
+
drop = set(ds.data_vars) - keep
|
|
48
|
+
ds = ds.drop_vars(drop, errors="ignore")
|
|
49
|
+
|
|
50
|
+
ds = ds.set_coords(["latitude", "longitude"])
|
|
51
|
+
return ds
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Reader for WRF model output."""
|
|
2
|
+
|
|
3
|
+
import xarray as xr
|
|
4
|
+
|
|
5
|
+
from ..reader import ReadSpec
|
|
6
|
+
from .default import DefaultRegularGridReader
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Wrf(DefaultRegularGridReader):
|
|
10
|
+
# `Time` and `XTIME` are alternatives, never both: cleaned NetCDF carries an
|
|
11
|
+
# indexed `Time` coordinate, while raw wrfout leaves `Time` a bare dimension
|
|
12
|
+
# and puts the valid times in `XTIME`. restrict_rename_to_present keeps
|
|
13
|
+
# whichever the file actually has.
|
|
14
|
+
rename = {
|
|
15
|
+
"Time": "time",
|
|
16
|
+
"XTIME": "time",
|
|
17
|
+
"south_north": "y",
|
|
18
|
+
"west_east": "x",
|
|
19
|
+
"bottom_top": "z",
|
|
20
|
+
"XLONG": "longitude",
|
|
21
|
+
"XLAT": "latitude",
|
|
22
|
+
}
|
|
23
|
+
restrict_rename_to_present = True
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def _preprocess(cls, ds: xr.Dataset, spec: ReadSpec) -> xr.Dataset:
|
|
27
|
+
"""Rename/drop/index, plus the two shapes raw wrfout differs in.
|
|
28
|
+
|
|
29
|
+
1. Renaming ``XTIME`` does not carry an index across, so ``time`` comes
|
|
30
|
+
out as a plain coordinate. Without a dimension coordinate
|
|
31
|
+
``open_mfdataset`` cannot order the files and fails with "Could not
|
|
32
|
+
find any dimension coordinates to use to order the Dataset objects
|
|
33
|
+
for concatenation".
|
|
34
|
+
2. WRF writes ``XLAT``/``XLONG`` once per output time even though the
|
|
35
|
+
grid is static, leaving them 3-D — which breaks the
|
|
36
|
+
``SwathDefinition`` that ``RegularGrid._set_geom`` builds for
|
|
37
|
+
resampling.
|
|
38
|
+
|
|
39
|
+
Both steps are no-ops on input that already has an indexed ``time`` and
|
|
40
|
+
2-D lat/lon.
|
|
41
|
+
"""
|
|
42
|
+
ds = super()._preprocess(ds, spec)
|
|
43
|
+
|
|
44
|
+
# reset_coords() in the parent demotes a non-index `time` to a data
|
|
45
|
+
# variable; promoting it back here is what actually builds the index.
|
|
46
|
+
if "time" in ds.variables and "time" not in ds.indexes:
|
|
47
|
+
ds = ds.set_index(time="time")
|
|
48
|
+
|
|
49
|
+
for coord in ("latitude", "longitude"):
|
|
50
|
+
if coord in ds.coords and "time" in ds[coord].dims:
|
|
51
|
+
ds = ds.assign_coords({coord: ds[coord].isel(time=0, drop=True)})
|
|
52
|
+
|
|
53
|
+
return ds
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared utilities: configuration, registries, transforms and resampling."""
|