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,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