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,292 @@
|
|
|
1
|
+
"""Pydantic control file model.
|
|
2
|
+
|
|
3
|
+
Pydantic models for every block of a control file, composed by
|
|
4
|
+
:class:`ControlFile`. Each model carries the checks that belong to it, and
|
|
5
|
+
``ControlFile`` carries the ones that do consistency checks between blocks
|
|
6
|
+
|
|
7
|
+
The plot and domain schemas are not written here: they are assembled at import
|
|
8
|
+
time from the abstract class registry.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
from pydantic import (
|
|
15
|
+
BaseModel,
|
|
16
|
+
ConfigDict,
|
|
17
|
+
Field,
|
|
18
|
+
field_validator,
|
|
19
|
+
model_validator,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from ClimateGraph.data import Data
|
|
23
|
+
from ClimateGraph.domain import Domain
|
|
24
|
+
from ClimateGraph.plot import Plot
|
|
25
|
+
from ClimateGraph.reader import Reader
|
|
26
|
+
from ClimateGraph.utils.general_utils import CRSEnum, manage_path
|
|
27
|
+
|
|
28
|
+
_DATASET_REF_FIELDS = ("base", "superposed", "data")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AnalysisModel(BaseModel):
|
|
32
|
+
"""The ``analysis`` block: run-scoped settings.
|
|
33
|
+
|
|
34
|
+
Forbids extra keys, so a misspelled setting here is an error rather than a
|
|
35
|
+
silent no-op.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
model_config = ConfigDict(extra="forbid")
|
|
39
|
+
output_path: Path
|
|
40
|
+
debug: bool = Field(default=False)
|
|
41
|
+
workers: int | None = Field(default=None, ge=1)
|
|
42
|
+
|
|
43
|
+
@field_validator("output_path")
|
|
44
|
+
@classmethod
|
|
45
|
+
def val_output_path(cls, v: Path):
|
|
46
|
+
"""Resolve the output path, creating the directory if it does not exist."""
|
|
47
|
+
v = v.resolve()
|
|
48
|
+
if not v.exists():
|
|
49
|
+
v.mkdir(parents=True)
|
|
50
|
+
return v
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
PlotModel = Plot.build_config_union()
|
|
54
|
+
DomainModel = Domain.build_config_union()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class VarModel(BaseModel):
|
|
58
|
+
"""One entry of a dataset's ``vars`` mapping.
|
|
59
|
+
|
|
60
|
+
Carries the file-native name to rename from, a pint unit enabling conversion
|
|
61
|
+
at plot time, and arithmetic applied at read time. Forbids extra keys.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
model_config = ConfigDict(extra="forbid")
|
|
65
|
+
# Optional: the file-native name to rename from. A composed variable (one
|
|
66
|
+
# built purely from others via `operation`) has no file name and omits it.
|
|
67
|
+
name: str | None = Field(default=None)
|
|
68
|
+
# Optional: without a unit the variable is left as-is (no conversion) and
|
|
69
|
+
# plot labels omit the unit. Provide it to enable pint unit conversion.
|
|
70
|
+
unit: str | None = Field(default=None)
|
|
71
|
+
# Optional arithmetic applied at reader time. May reference the variable's
|
|
72
|
+
# own file data (`x`) and other base variables by their canonical name, to
|
|
73
|
+
# transform units or compose a new variable. See dataset_utils.apply_operation.
|
|
74
|
+
operation: str | None = Field(default=None)
|
|
75
|
+
|
|
76
|
+
@model_validator(mode="after")
|
|
77
|
+
def check_name_or_operation(self):
|
|
78
|
+
"""Require a ``name``, an ``operation``, or both."""
|
|
79
|
+
if self.name is None and self.operation is None:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
"a variable must declare a 'name' (read from the file) and/or an "
|
|
82
|
+
"'operation' (composed from other variables); got neither."
|
|
83
|
+
)
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class DataModel(BaseModel):
|
|
88
|
+
"""One entry of the ``data`` block: a dataset declaration.
|
|
89
|
+
|
|
90
|
+
The topology and the reader must be consistent — a reader is registered
|
|
91
|
+
against the topology it can read. Variable names declared here are the
|
|
92
|
+
handles used everywhere else in the control file.
|
|
93
|
+
|
|
94
|
+
Allows extra keys on purpose: anything unrecognised is forwarded to the
|
|
95
|
+
reader as a keyword argument, so reader-specific options need no schema
|
|
96
|
+
change here.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
model_config = ConfigDict(extra="allow")
|
|
100
|
+
|
|
101
|
+
topology: str
|
|
102
|
+
reader: str
|
|
103
|
+
path: Path | list[Path]
|
|
104
|
+
# Optional. A dict maps user-facing names to {name, unit, operation} (units
|
|
105
|
+
# optional); a plain list[str] selects file-native names with no units; None
|
|
106
|
+
# (omitted) keeps every variable in the file under its file-native name.
|
|
107
|
+
vars: dict[str, VarModel] | list[str] | None = Field(default=None)
|
|
108
|
+
crs: CRSEnum = Field(default=CRSEnum.platecarree)
|
|
109
|
+
load_mode: Literal["safe", "unsafe"] = Field(default="safe")
|
|
110
|
+
# Per-data download cache override. If unset, Parser falls back to
|
|
111
|
+
# analysis.output_path/.cache/. Reader._resolve_paths is the only
|
|
112
|
+
# site that consumes this; local-only readers ignore it.
|
|
113
|
+
cache_dir: Path | None = Field(default=None)
|
|
114
|
+
# Optional destination for the fully-processed dataset. When set,
|
|
115
|
+
|
|
116
|
+
save_to: Path | None = Field(default=None)
|
|
117
|
+
|
|
118
|
+
@field_validator("topology")
|
|
119
|
+
@classmethod
|
|
120
|
+
def val_data_topology(cls, v: str):
|
|
121
|
+
"""Check the topology is one the Data registry knows."""
|
|
122
|
+
if not Data.check_topology_type(v):
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"Topology type {v} not listed as possible data type.\nPossible data types are: {list(Data.registry.keys())}"
|
|
125
|
+
)
|
|
126
|
+
return v
|
|
127
|
+
|
|
128
|
+
@field_validator("path")
|
|
129
|
+
@classmethod
|
|
130
|
+
def val_file_path(cls, v):
|
|
131
|
+
"""Expand the path or glob and require it to match at least one file."""
|
|
132
|
+
path = manage_path(v)
|
|
133
|
+
if not path:
|
|
134
|
+
raise ValueError(f"No files found for path {v}")
|
|
135
|
+
return path
|
|
136
|
+
|
|
137
|
+
@field_validator("save_to")
|
|
138
|
+
@classmethod
|
|
139
|
+
def val_save_to(cls, v: Path | None):
|
|
140
|
+
"""Require ``save_to`` to name a NetCDF file rather than a directory."""
|
|
141
|
+
# fail at check time for save_to argument
|
|
142
|
+
if v is None:
|
|
143
|
+
return v
|
|
144
|
+
if v.is_dir() or v.suffix.lower() not in Reader.netcdf_suffixes:
|
|
145
|
+
raise ValueError(
|
|
146
|
+
f"save_to must be an exact NetCDF file path "
|
|
147
|
+
f"(one of {Reader.netcdf_suffixes}); got {v!r}."
|
|
148
|
+
)
|
|
149
|
+
return v
|
|
150
|
+
|
|
151
|
+
@model_validator(mode="after")
|
|
152
|
+
def check_type_and_subtype_are_consistent(self):
|
|
153
|
+
"""Check the reader is registered against the declared topology."""
|
|
154
|
+
if not (Reader.check_reader_type(self.topology, self.reader)):
|
|
155
|
+
raise ValueError(
|
|
156
|
+
f"Topology {self.topology} doesn't have reader of type {self.reader}."
|
|
157
|
+
)
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class ControlFile(BaseModel):
|
|
162
|
+
"""The whole control file.
|
|
163
|
+
|
|
164
|
+
Composes the block models and performs inter block checks.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
analysis: AnalysisModel
|
|
168
|
+
data: dict[str, DataModel]
|
|
169
|
+
domains: dict[str, DomainModel] | None = Field(default=None)
|
|
170
|
+
plots: dict[str, PlotModel] | None = Field(default=None)
|
|
171
|
+
# stats
|
|
172
|
+
|
|
173
|
+
@model_validator(mode="after")
|
|
174
|
+
def check_plot_domain_refs(self):
|
|
175
|
+
"""Check every domain a plot references is defined.
|
|
176
|
+
|
|
177
|
+
Without this a typo fell through to the "no domain" branch and the plot
|
|
178
|
+
silently rendered against the full dataset.
|
|
179
|
+
"""
|
|
180
|
+
# Pre-M3 a typo in a plot's `domains:` list silently fell through
|
|
181
|
+
# to the "no domain" branch and the plot rendered against full
|
|
182
|
+
# data — a confusing failure mode. Catch unknown names early.
|
|
183
|
+
if not self.plots:
|
|
184
|
+
return self
|
|
185
|
+
known = set(self.domains or {})
|
|
186
|
+
for plot_name, plot_model in self.plots.items():
|
|
187
|
+
refs = getattr(plot_model, "domains", None) or []
|
|
188
|
+
missing = [d for d in refs if d not in known]
|
|
189
|
+
if missing:
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"Plot {plot_name!r} references unknown domain(s) {missing}. "
|
|
192
|
+
f"Known domains: {sorted(known) or '(none defined)'}."
|
|
193
|
+
)
|
|
194
|
+
return self
|
|
195
|
+
|
|
196
|
+
@model_validator(mode="after")
|
|
197
|
+
def check_plot_var_refs(self):
|
|
198
|
+
"""Catch plot var names that can't resolve in a referenced dataset.
|
|
199
|
+
|
|
200
|
+
With canonical names now optional, if vars was set then performs a check
|
|
201
|
+
against it. If not it ignores and assumes you are referencing a real name in the ds.
|
|
202
|
+
If mistaken a KeyError will occur at runtime.
|
|
203
|
+
"""
|
|
204
|
+
if not self.plots:
|
|
205
|
+
return self
|
|
206
|
+
for plot_name, plot_model in self.plots.items():
|
|
207
|
+
plot_vars = _plot_var_names(getattr(plot_model, "vars", None))
|
|
208
|
+
if not plot_vars:
|
|
209
|
+
continue
|
|
210
|
+
for ds_name in _referenced_datasets(plot_model, _DATASET_REF_FIELDS):
|
|
211
|
+
data_model = self.data.get(ds_name)
|
|
212
|
+
if data_model is None:
|
|
213
|
+
continue
|
|
214
|
+
declared = _declared_var_names(data_model.vars)
|
|
215
|
+
if declared is None:
|
|
216
|
+
continue # vars omitted — defer to runtime
|
|
217
|
+
missing = sorted(plot_vars - declared)
|
|
218
|
+
if missing:
|
|
219
|
+
raise ValueError(
|
|
220
|
+
f"Plot {plot_name!r} references variable(s) {missing} "
|
|
221
|
+
f"not declared in dataset {ds_name!r}. "
|
|
222
|
+
f"Declared vars: {sorted(declared) or '(none)'}."
|
|
223
|
+
)
|
|
224
|
+
return self
|
|
225
|
+
|
|
226
|
+
@model_validator(mode="after")
|
|
227
|
+
def check_custom_subplot_var_refs(self):
|
|
228
|
+
"""Catch ``type: custom`` subplot var names that can't resolve.
|
|
229
|
+
|
|
230
|
+
``check_plot_var_refs`` equivalent for subplots. Checks that every var referenced
|
|
231
|
+
exists in the target Dataset (be it specific to the subplot or at custom plot level)
|
|
232
|
+
If dataset vars are not defined, could lead to a KeyError in get_vars
|
|
233
|
+
"""
|
|
234
|
+
if not self.plots:
|
|
235
|
+
return self
|
|
236
|
+
for plot_name, plot_model in self.plots.items():
|
|
237
|
+
subplots = getattr(plot_model, "subplots", None)
|
|
238
|
+
if not subplots:
|
|
239
|
+
continue
|
|
240
|
+
plot_vars = _plot_var_names(getattr(plot_model, "vars", None))
|
|
241
|
+
for subplot in subplots:
|
|
242
|
+
ds_name = getattr(subplot, "dataset", None)
|
|
243
|
+
data_model = self.data.get(ds_name) if ds_name else None
|
|
244
|
+
if data_model is None:
|
|
245
|
+
continue
|
|
246
|
+
declared = _declared_var_names(data_model.vars)
|
|
247
|
+
if declared is None:
|
|
248
|
+
continue # vars omitted — defer to runtime
|
|
249
|
+
subplot_var = getattr(subplot, "var", None)
|
|
250
|
+
check_vars = {subplot_var} if subplot_var is not None else plot_vars
|
|
251
|
+
missing = sorted(v for v in check_vars if v not in declared)
|
|
252
|
+
if missing:
|
|
253
|
+
raise ValueError(
|
|
254
|
+
f"Plot {plot_name!r} subplot references variable(s) "
|
|
255
|
+
f"{missing} not declared in dataset {ds_name!r}. "
|
|
256
|
+
f"Declared vars: {sorted(declared) or '(none)'}."
|
|
257
|
+
)
|
|
258
|
+
return self
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _plot_var_names(vars: str | list[str] | dict[str, str] | None) -> set[str]:
|
|
262
|
+
"""Variable names a plot references, regardless of the form ``vars`` took."""
|
|
263
|
+
if vars is None:
|
|
264
|
+
return set()
|
|
265
|
+
if isinstance(vars, str):
|
|
266
|
+
return {vars}
|
|
267
|
+
if isinstance(vars, dict):
|
|
268
|
+
return set(vars.keys())
|
|
269
|
+
return set(vars)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _declared_var_names(
|
|
273
|
+
vars: dict[str, VarModel] | list[str] | None,
|
|
274
|
+
) -> set[str] | None:
|
|
275
|
+
"""Names a dataset exposes, or ``None`` when vars was omitted (unknowable)."""
|
|
276
|
+
if isinstance(vars, dict):
|
|
277
|
+
return set(vars.keys())
|
|
278
|
+
if isinstance(vars, list):
|
|
279
|
+
return set(vars)
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _referenced_datasets(plot_model, fields: tuple[str, ...]) -> set[str]:
|
|
284
|
+
"""Collect data-block names a plot points at across its reference fields."""
|
|
285
|
+
names: set[str] = set()
|
|
286
|
+
for field in fields:
|
|
287
|
+
value = getattr(plot_model, field, None)
|
|
288
|
+
if isinstance(value, str):
|
|
289
|
+
names.add(value)
|
|
290
|
+
elif isinstance(value, list):
|
|
291
|
+
names.update(v for v in value if isinstance(v, str))
|
|
292
|
+
return names
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Transforms applied to xarray objects.
|
|
2
|
+
|
|
3
|
+
The steps a dataset passes through between being read and being drawn. Each
|
|
4
|
+
mutating step records itself in the dataset's ``history`` attribute.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
import logging
|
|
9
|
+
import operator as _op
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
|
|
12
|
+
import pint_xarray # noqa: F401
|
|
13
|
+
import xarray as xr
|
|
14
|
+
|
|
15
|
+
from .general_utils import ReductionMethodEnum, manage_time_interval
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _record(obj: xr.Dataset | xr.DataArray, entry: str) -> None:
|
|
21
|
+
"""Append a timestamped entry to obj.attrs['history']."""
|
|
22
|
+
history = obj.attrs.get("history", [])
|
|
23
|
+
if not isinstance(history, list):
|
|
24
|
+
history = [history] # handle pre-existing CF string history
|
|
25
|
+
log_item = f"{datetime.now(tz=UTC).isoformat(timespec='seconds')} {entry}"
|
|
26
|
+
history.append(log_item)
|
|
27
|
+
obj.attrs["history"] = history
|
|
28
|
+
log.debug(log_item)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def dim_reduction(
|
|
32
|
+
obj: xr.Dataset | xr.DataArray,
|
|
33
|
+
spec: dict[str, str | dict],
|
|
34
|
+
name: str = "",
|
|
35
|
+
) -> xr.Dataset | xr.DataArray:
|
|
36
|
+
"""Apply per-dimension reductions/selections to an xarray object.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
obj : xr.Dataset | xr.DataArray
|
|
41
|
+
Data to reduce.
|
|
42
|
+
spec : dict[str, str | dict]
|
|
43
|
+
Mapping of dim name to reduction spec. Each value is either a string
|
|
44
|
+
method name (``"mean"``, ``"min"``, ``"max"``), or a dict with a
|
|
45
|
+
``method`` key plus a ``value`` key for ``isel`` / ``sel``::
|
|
46
|
+
|
|
47
|
+
{"method": "isel", "value": 0}
|
|
48
|
+
{"method": "sel", "value": 5.0}
|
|
49
|
+
{"method": "mean"}
|
|
50
|
+
name : str
|
|
51
|
+
Identifier for history recording (e.g. dataset name or variable name).
|
|
52
|
+
|
|
53
|
+
Returns
|
|
54
|
+
-------
|
|
55
|
+
xr.Dataset | xr.DataArray
|
|
56
|
+
The object with the specified dimensions reduced/selected.
|
|
57
|
+
"""
|
|
58
|
+
for dim, dim_spec in spec.items():
|
|
59
|
+
if dim not in obj.dims:
|
|
60
|
+
continue
|
|
61
|
+
method = (
|
|
62
|
+
dim_spec if isinstance(dim_spec, str) else dim_spec.get("method", "mean")
|
|
63
|
+
)
|
|
64
|
+
value = None if isinstance(dim_spec, str) else dim_spec.get("value")
|
|
65
|
+
if method == "isel":
|
|
66
|
+
idx = value if value is not None else 0
|
|
67
|
+
obj = obj.isel({dim: idx})
|
|
68
|
+
_record(obj, f"{name} selected {dim}={idx} via isel")
|
|
69
|
+
elif method == "sel":
|
|
70
|
+
obj = obj.sel({dim: value})
|
|
71
|
+
_record(obj, f"{name} selected {dim}={value} via sel")
|
|
72
|
+
else:
|
|
73
|
+
func = ReductionMethodEnum(method).func
|
|
74
|
+
obj = obj.reduce(func, dim)
|
|
75
|
+
_record(obj, f"{name} reduced {dim!r} with {method}")
|
|
76
|
+
return obj
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# TODO: make this into accessors
|
|
80
|
+
|
|
81
|
+
# Arithmetic allowed inside a var-level `operation:` expression. Deliberately
|
|
82
|
+
# tiny: binary/unary arithmetic on the variable `x` and numeric constants.
|
|
83
|
+
# No attribute access, calls or names other than `x`, so a config string can't
|
|
84
|
+
# execute arbitrary code.
|
|
85
|
+
_ALLOWED_BINOPS = {
|
|
86
|
+
ast.Add: _op.add,
|
|
87
|
+
ast.Sub: _op.sub,
|
|
88
|
+
ast.Mult: _op.mul,
|
|
89
|
+
ast.Div: _op.truediv,
|
|
90
|
+
ast.Pow: _op.pow,
|
|
91
|
+
ast.Mod: _op.mod,
|
|
92
|
+
}
|
|
93
|
+
_ALLOWED_UNARYOPS = {ast.UAdd: _op.pos, ast.USub: _op.neg}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def apply_operation(operation: str, variables: dict[str, xr.DataArray]) -> xr.DataArray:
|
|
97
|
+
"""Evaluate a small arithmetic "operation" over variables.
|
|
98
|
+
|
|
99
|
+
Two uses:
|
|
100
|
+
- unit conversions pint can't express like operations over the data with constants (+273 for example)
|
|
101
|
+
- composing a new variable from others
|
|
102
|
+
|
|
103
|
+
"operation" is an arithmetic expression whose named vars are looked up in
|
|
104
|
+
variables (which carries the variable's own data under "x" plus the
|
|
105
|
+
canonical names of the available base variables). As a shorthand, a leading
|
|
106
|
+
binary operator implies x on the left: "*3" means "x * 3". Also **, /, and others available
|
|
107
|
+
|
|
108
|
+
Parameters
|
|
109
|
+
----------
|
|
110
|
+
operation : str
|
|
111
|
+
Arithmetic expression (see above).
|
|
112
|
+
variables : dict[str, xr.DataArray]
|
|
113
|
+
Names available to the expression mapped to their data.
|
|
114
|
+
|
|
115
|
+
Returns
|
|
116
|
+
-------
|
|
117
|
+
xr.DataArray
|
|
118
|
+
The evaluated variable.
|
|
119
|
+
|
|
120
|
+
Raises
|
|
121
|
+
------
|
|
122
|
+
ValueError
|
|
123
|
+
If the expression references a name not in variables, or contains
|
|
124
|
+
anything other than arithmetic and numeric constants.
|
|
125
|
+
"""
|
|
126
|
+
expr = operation.strip()
|
|
127
|
+
# Leading-operator shorthand: "*3" -> "x*3". `**` starts with `*` too,
|
|
128
|
+
# so "**2" -> "x**2" falls out for free.
|
|
129
|
+
if expr[:1] in {"*", "/", "+", "-"}:
|
|
130
|
+
expr = "x" + expr
|
|
131
|
+
|
|
132
|
+
def _eval(node):
|
|
133
|
+
if isinstance(node, ast.Expression):
|
|
134
|
+
return _eval(node.body)
|
|
135
|
+
if isinstance(node, ast.BinOp) and type(node.op) in _ALLOWED_BINOPS:
|
|
136
|
+
return _ALLOWED_BINOPS[type(node.op)](_eval(node.left), _eval(node.right))
|
|
137
|
+
if isinstance(node, ast.UnaryOp) and type(node.op) in _ALLOWED_UNARYOPS:
|
|
138
|
+
return _ALLOWED_UNARYOPS[type(node.op)](_eval(node.operand))
|
|
139
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
|
140
|
+
return node.value
|
|
141
|
+
if isinstance(node, ast.Name):
|
|
142
|
+
if node.id in variables:
|
|
143
|
+
return variables[node.id]
|
|
144
|
+
raise ValueError(
|
|
145
|
+
f"Operation {operation!r} references unknown variable "
|
|
146
|
+
f"{node.id!r}; available: {sorted(variables)}."
|
|
147
|
+
)
|
|
148
|
+
raise ValueError(
|
|
149
|
+
f"Unsupported operation {operation!r}: only arithmetic on declared variables and numeric constants is allowed."
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
result = _eval(ast.parse(expr, mode="eval"))
|
|
153
|
+
_record(result, f"computed: {operation}")
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def normalize_vars(
|
|
158
|
+
vars: dict[str, dict] | list[str] | None,
|
|
159
|
+
) -> dict[str, dict] | None:
|
|
160
|
+
"""Coerce the user-supplied ``vars`` into the canonical
|
|
161
|
+
dict-or-None shape the rest of the pipeline expects.
|
|
162
|
+
|
|
163
|
+
- a dict returned unchanged (today's full form, units optional);
|
|
164
|
+
- a plain list[str] of file-native names — expanded to an identity
|
|
165
|
+
mapping with null units, e.g. ``["PM10"] -> {"PM10": {"name": "PM10",
|
|
166
|
+
"unit": None, "operation": None}}``.
|
|
167
|
+
- None (omitted) — returned as ``None`` so the readers keep every
|
|
168
|
+
variable under its file-native name.
|
|
169
|
+
|
|
170
|
+
Parameters
|
|
171
|
+
----------
|
|
172
|
+
vars : dict[str, dict] | list[str] | None
|
|
173
|
+
The raw vars declaration.
|
|
174
|
+
|
|
175
|
+
Returns
|
|
176
|
+
-------
|
|
177
|
+
dict[str, dict] | None
|
|
178
|
+
var dict mapping (cannonical names), or ``None`` when nothing was declared.
|
|
179
|
+
"""
|
|
180
|
+
if isinstance(vars, list):
|
|
181
|
+
return {name: {"name": name, "unit": None, "operation": None} for name in vars}
|
|
182
|
+
return vars
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def variable_aggregation(ds: xr.Dataset, aggregation_dict: dict) -> xr.Dataset:
|
|
186
|
+
"""Creates new variable from variable aggregation.
|
|
187
|
+
|
|
188
|
+
Parameters
|
|
189
|
+
----------
|
|
190
|
+
ds : xr.Dataset
|
|
191
|
+
Dataset to perform aggregations.
|
|
192
|
+
aggregation_dict : dict
|
|
193
|
+
Aggregation dictionary. Maps new variable names a mapping of variable to ponderation weight.
|
|
194
|
+
|
|
195
|
+
Returns
|
|
196
|
+
-------
|
|
197
|
+
xr.Dataset
|
|
198
|
+
Dataset with the aggregated variables included.
|
|
199
|
+
"""
|
|
200
|
+
# key: new variable
|
|
201
|
+
# value: dict with dict(old variable : ponderations to sum)
|
|
202
|
+
new_vars = dict()
|
|
203
|
+
|
|
204
|
+
for new_var, ponderations in aggregation_dict.items():
|
|
205
|
+
xa = None
|
|
206
|
+
for name_var, ponderation in ponderations.items():
|
|
207
|
+
if xa is None:
|
|
208
|
+
xa = ds[name_var].copy() * ponderation
|
|
209
|
+
else:
|
|
210
|
+
xa += ds[name_var] * ponderation
|
|
211
|
+
|
|
212
|
+
new_vars[new_var] = xa
|
|
213
|
+
|
|
214
|
+
return ds.assign(new_vars)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def time_resampling(
|
|
218
|
+
ds: xr.Dataset | xr.DataArray,
|
|
219
|
+
timestep: str | None = None,
|
|
220
|
+
time_interval: str | None = None,
|
|
221
|
+
reduction_method: ReductionMethodEnum = ReductionMethodEnum.mean,
|
|
222
|
+
) -> xr.Dataset | xr.DataArray:
|
|
223
|
+
"""Implements time resampling and alignment.
|
|
224
|
+
|
|
225
|
+
Parameters
|
|
226
|
+
----------
|
|
227
|
+
ds : xr.Dataset | xr.DataArray
|
|
228
|
+
Dataset to be processed.
|
|
229
|
+
timestep : str | None, optional
|
|
230
|
+
Timestep in which to resample the dataset. If None no resampling happens, by default None
|
|
231
|
+
time_interval : str | None, optional
|
|
232
|
+
Time interval to filter the dataset. If None no selection happens, by default None
|
|
233
|
+
reduction_method : ReductionMethodEnum, optional
|
|
234
|
+
Reduction method for the resampling, by default ReductionMethodEnum.mean
|
|
235
|
+
|
|
236
|
+
Returns
|
|
237
|
+
-------
|
|
238
|
+
xr.Dataset | xr.DataArray
|
|
239
|
+
Resampled and aligned dataset.
|
|
240
|
+
"""
|
|
241
|
+
if time_interval is not None:
|
|
242
|
+
start, end = manage_time_interval(time_interval)
|
|
243
|
+
ds = ds.sel({"time": slice(start, end)})
|
|
244
|
+
_record(ds, f"selected time interval {start} to {end}")
|
|
245
|
+
if timestep is not None:
|
|
246
|
+
ds = getattr(ds.resample(time=timestep), reduction_method.value)()
|
|
247
|
+
_record(ds, f"resampled time to {timestep} ({reduction_method.value})")
|
|
248
|
+
return ds
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def change_unit(
|
|
252
|
+
xa: xr.DataArray, src_unit: str | None, dst_unit: str | None
|
|
253
|
+
) -> xr.DataArray:
|
|
254
|
+
"""Unit conversion method for datasets.
|
|
255
|
+
|
|
256
|
+
Parameters
|
|
257
|
+
----------
|
|
258
|
+
xa : xr.DataArray
|
|
259
|
+
Data to convert to other unit.
|
|
260
|
+
src_unit : str
|
|
261
|
+
Unit of the source data.
|
|
262
|
+
dst_unit : str
|
|
263
|
+
Destination unit for the data.
|
|
264
|
+
|
|
265
|
+
Returns
|
|
266
|
+
-------
|
|
267
|
+
xr.DataArray
|
|
268
|
+
Data in the destination measure unit.
|
|
269
|
+
"""
|
|
270
|
+
if src_unit is None or dst_unit is None:
|
|
271
|
+
log.info(
|
|
272
|
+
f"Source unit or destination unit wasn't provided.\nLeaving {xa.name} in the provided unit. This will reflect in graphs."
|
|
273
|
+
)
|
|
274
|
+
return xa
|
|
275
|
+
if src_unit == dst_unit:
|
|
276
|
+
return xa # No sense on performing any operations if it is already in the desired unit.
|
|
277
|
+
|
|
278
|
+
xa_coords = list(xa.coords.keys())
|
|
279
|
+
var_name = xa.name
|
|
280
|
+
xa = xa.reset_coords(drop=False)
|
|
281
|
+
xa[var_name] = xa[var_name].pint.quantify(src_unit)
|
|
282
|
+
xa[var_name] = xa[var_name].pint.to(dst_unit)
|
|
283
|
+
xa[var_name] = xa[var_name].pint.dequantify()
|
|
284
|
+
xa = xa.set_coords(xa_coords)
|
|
285
|
+
xa = xa[var_name]
|
|
286
|
+
_record(xa, f"converted {var_name} from {src_unit} to {dst_unit}")
|
|
287
|
+
return xa
|