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.
Files changed (44) hide show
  1. ClimateGraph/__init__.py +9 -0
  2. ClimateGraph/appkernel.py +148 -0
  3. ClimateGraph/cli.py +63 -0
  4. ClimateGraph/data/__init__.py +8 -0
  5. ClimateGraph/data/data.py +508 -0
  6. ClimateGraph/data/point_surface.py +91 -0
  7. ClimateGraph/data/regular_grid.py +25 -0
  8. ClimateGraph/data/satellite_swath.py +21 -0
  9. ClimateGraph/domain/__init__.py +11 -0
  10. ClimateGraph/domain/domain.py +181 -0
  11. ClimateGraph/domain/domains.py +279 -0
  12. ClimateGraph/plot/__init__.py +11 -0
  13. ClimateGraph/plot/plot.py +402 -0
  14. ClimateGraph/plot/plots.py +1054 -0
  15. ClimateGraph/plot/primitive.py +99 -0
  16. ClimateGraph/plot/primitives.py +160 -0
  17. ClimateGraph/reader/__init__.py +15 -0
  18. ClimateGraph/reader/reader/__init__.py +14 -0
  19. ClimateGraph/reader/reader/point_surface/__init__.py +7 -0
  20. ClimateGraph/reader/reader/point_surface/csv_base.py +135 -0
  21. ClimateGraph/reader/reader/point_surface/default.py +36 -0
  22. ClimateGraph/reader/reader/point_surface/dmc.py +10 -0
  23. ClimateGraph/reader/reader/point_surface/sinca.py +10 -0
  24. ClimateGraph/reader/reader/point_surface/single_file.py +60 -0
  25. ClimateGraph/reader/reader/point_surface/station_per_file.py +55 -0
  26. ClimateGraph/reader/reader/point_surface/variable_per_file.py +91 -0
  27. ClimateGraph/reader/reader/reader.py +353 -0
  28. ClimateGraph/reader/reader/regular_grid/__init__.py +7 -0
  29. ClimateGraph/reader/reader/regular_grid/chimere.py +24 -0
  30. ClimateGraph/reader/reader/regular_grid/default.py +51 -0
  31. ClimateGraph/reader/reader/regular_grid/wrf.py +53 -0
  32. ClimateGraph/utils/__init__.py +1 -0
  33. ClimateGraph/utils/control_model.py +292 -0
  34. ClimateGraph/utils/dataset_utils.py +287 -0
  35. ClimateGraph/utils/general_utils.py +259 -0
  36. ClimateGraph/utils/parser.py +269 -0
  37. ClimateGraph/utils/registry.py +81 -0
  38. ClimateGraph/utils/resample_engine.py +156 -0
  39. climategraph-0.4.0.dist-info/METADATA +177 -0
  40. climategraph-0.4.0.dist-info/RECORD +44 -0
  41. climategraph-0.4.0.dist-info/WHEEL +5 -0
  42. climategraph-0.4.0.dist-info/entry_points.txt +2 -0
  43. climategraph-0.4.0.dist-info/licenses/LICENSE +21 -0
  44. 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,7 @@
1
+ """Readers for gridded data."""
2
+
3
+ import importlib
4
+ import pkgutil
5
+
6
+ for module in pkgutil.iter_modules(__path__):
7
+ importlib.import_module(f"{__name__}.{module.name}")
@@ -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."""