grid-process 1.0.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.
- grid_process/__init__.py +14 -0
- grid_process/aggreg.py +447 -0
- grid_process/blending.py +206 -0
- grid_process/config/__init__.py +5 -0
- grid_process/config/grid_process.yaml +20 -0
- grid_process/config/library_settings.py +68 -0
- grid_process/da_utils.py +104 -0
- grid_process/data_model.py +531 -0
- grid_process/ds_utils.py +61 -0
- grid_process/ens_stats.py +396 -0
- grid_process/grid_interp.py +2016 -0
- grid_process/grid_interp_utils.py +208 -0
- grid_process/helpers.py +174 -0
- grid_process/optimization.py +50 -0
- grid_process/sun.py +502 -0
- grid_process/time_utils.py +110 -0
- grid_process/timeseries.py +547 -0
- grid_process/units_conv.py +209 -0
- grid_process-1.0.0.dist-info/METADATA +155 -0
- grid_process-1.0.0.dist-info/RECORD +23 -0
- grid_process-1.0.0.dist-info/WHEEL +4 -0
- grid_process-1.0.0.dist-info/licenses/AUTHORS +11 -0
- grid_process-1.0.0.dist-info/licenses/LICENSE +29 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
# Copyright (c) 2026 MeteoSwiss, contributors listed in AUTHORS
|
|
2
|
+
#
|
|
3
|
+
# Distributed under the terms of the BSD 3-Clause License.
|
|
4
|
+
#
|
|
5
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
Module for operations on timeseries data
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from functools import partial, reduce
|
|
13
|
+
from typing import Any, Literal, get_args
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
import xarray as xr
|
|
19
|
+
from xarray.core.types import InterpOptions
|
|
20
|
+
|
|
21
|
+
from grid_process.helpers import make_accessor, keepattrs
|
|
22
|
+
from grid_process.data_model import (
|
|
23
|
+
get_t_dim,
|
|
24
|
+
get_temporal_coords,
|
|
25
|
+
get_temporal_dims,
|
|
26
|
+
get_latlon_coords,
|
|
27
|
+
VALIDTIME,
|
|
28
|
+
)
|
|
29
|
+
from grid_process.ds_utils import cast_dtype
|
|
30
|
+
from grid_process.sun import daytime, zenith
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Register xarray accessor so module functions can also be called as methods
|
|
36
|
+
# on xr.Dataset objects
|
|
37
|
+
@xr.register_dataset_accessor("timeseries")
|
|
38
|
+
class Timeseries:
|
|
39
|
+
"""
|
|
40
|
+
Xarray accessors class for operations on timeseries
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, ds: xr.Dataset):
|
|
44
|
+
self._data = ds
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
dataset_accessor = partial(make_accessor, accessor_class=Timeseries)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
SunInterpOptions = Literal["sundur", "sunrad"]
|
|
51
|
+
ReindexOptions = Literal["nearest", "bfill", "ffill"]
|
|
52
|
+
InterpMethods = Literal["bcum", "fillval"] | SunInterpOptions | ReindexOptions | InterpOptions
|
|
53
|
+
FillnaMethods = Literal["bfill", "ffill", "fillval"] | InterpOptions
|
|
54
|
+
XR_INTERP_METHODS = reduce(lambda a, b: get_args(a) + get_args(b), get_args(InterpOptions))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataset_accessor
|
|
58
|
+
def interptogranularity(
|
|
59
|
+
ds: xr.Dataset,
|
|
60
|
+
granularity: str = "1h",
|
|
61
|
+
method: InterpMethods = "linear",
|
|
62
|
+
**kwargs: Any,
|
|
63
|
+
) -> xr.Dataset:
|
|
64
|
+
"""
|
|
65
|
+
Interpolate the dataset to the specified time granularity. Where
|
|
66
|
+
the dataset granularity is smaller than the specified one, the dataset
|
|
67
|
+
granularity remains (no aggregation is performed).
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
ds : xarray.Dataset
|
|
72
|
+
Must have a temporal dimension expressed as numpy.datetime64 (valid time) or numpy.timedelta64 (lead time).
|
|
73
|
+
Typical temporal dimensions names are supported, and can be extended via environment variables:
|
|
74
|
+
|
|
75
|
+
- Valid time: export GRID_PROCESS__data_model__validtime (e.g., '["time", "valid_time", "dummy_time"]')
|
|
76
|
+
- Lead time: export GRID_PROCESS__data_model__leadtime (e.g., '["lead_time", "step", "dummy_leadtime"]')
|
|
77
|
+
- Forecast reference time: export GRID_PROCESS__data_model__reftime (e.g., '["forecast_reference_time",
|
|
78
|
+
"ref_time", "reftime", "dummy_reftime"]')
|
|
79
|
+
- When the temporal coordinates variables have a CF compliant `standard_name` attribute, arbitrary names can
|
|
80
|
+
be used directly.
|
|
81
|
+
granularity : str, optional
|
|
82
|
+
(maximum) granularity of the resulting time series. Must be one of the
|
|
83
|
+
pandas.date_range ``freq`` options. Default is '1h'
|
|
84
|
+
method : str, optional
|
|
85
|
+
interpolation method for filling the time series. One of {'nearest', 'bfill',
|
|
86
|
+
'ffill', 'bcum', 'fillval', 'sundur', 'sunrad', 'linear', 'zero', 'slinear',
|
|
87
|
+
'quadratic', 'cubic', 'polynomial', 'barycentric', 'krogh', 'pchip', 'spline',
|
|
88
|
+
'akima'}. Default is 'linear'
|
|
89
|
+
**kwargs : any
|
|
90
|
+
keyword arguments passed to the respective interpolation sub-function defined
|
|
91
|
+
by the ``method``
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
ds_out : xarray.Dataset
|
|
96
|
+
contains the full time series resulting from the time interpolation at
|
|
97
|
+
the specified granularity
|
|
98
|
+
|
|
99
|
+
See Also
|
|
100
|
+
--------
|
|
101
|
+
:func:`fill_na`, xarray.Dataset.reindex, xarray.Dataset.interp
|
|
102
|
+
|
|
103
|
+
Note
|
|
104
|
+
----
|
|
105
|
+
Consider using 'slinear' instead of 'linear' for similar results with possibly
|
|
106
|
+
faster computations and lower memory footprint.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
t_dim = get_t_dim(ds)
|
|
110
|
+
if ds[t_dim].dtype.kind == "M": # t is datetime64
|
|
111
|
+
start_t = pd.to_datetime(min(ds[t_dim].values)).ceil(granularity)
|
|
112
|
+
stop_t = pd.to_datetime(max(ds[t_dim].values))
|
|
113
|
+
time_vec = pd.date_range(start_t, stop_t, freq=granularity)
|
|
114
|
+
elif ds[t_dim].dtype.kind == "m": # t is timedelta64
|
|
115
|
+
start_t = pd.to_timedelta(min(ds[t_dim].values)).ceil(granularity)
|
|
116
|
+
stop_t = pd.to_timedelta(max(ds[t_dim].values))
|
|
117
|
+
time_vec = pd.timedelta_range(start_t, stop_t, freq=granularity)
|
|
118
|
+
else:
|
|
119
|
+
raise ValueError(f"Temporal dimension {t_dim} must be of numpy dtype datetime64 or timedelta64")
|
|
120
|
+
|
|
121
|
+
if time_vec.isin(ds[t_dim].values).all(): # nothing to interpolate
|
|
122
|
+
return ds
|
|
123
|
+
|
|
124
|
+
time_vec = time_vec.union(ds[t_dim].values)
|
|
125
|
+
t_coords = get_temporal_coords(ds)
|
|
126
|
+
ref_t, lead_t, valid_t = t_coords
|
|
127
|
+
|
|
128
|
+
if method == "bcum":
|
|
129
|
+
ds_out = _bcum(ds, time_vec, t_dim, **kwargs)
|
|
130
|
+
|
|
131
|
+
elif method == "fillval":
|
|
132
|
+
ds_out = _fillval(ds, time_vec, (t_dim, ref_t, valid_t), **kwargs)
|
|
133
|
+
|
|
134
|
+
elif method in get_args(SunInterpOptions):
|
|
135
|
+
ds_out = _interp_sun(ds, time_vec, t_dim, method, **kwargs) # type: ignore[arg-type]
|
|
136
|
+
|
|
137
|
+
elif method in get_args(ReindexOptions):
|
|
138
|
+
ds_out = ds.reindex({t_dim: time_vec}, method=method, **kwargs) # type: ignore[arg-type]
|
|
139
|
+
|
|
140
|
+
elif method in XR_INTERP_METHODS:
|
|
141
|
+
# datetime-like variables along t_dim (e.g. reference time) are not interpolated: older xarray versions
|
|
142
|
+
# drop them, recent ones interpolate them. Drop them explicitly, the reference time is reindexed below.
|
|
143
|
+
datetime_vars = [
|
|
144
|
+
name for name, var in ds.variables.items() if name != t_dim and t_dim in var.dims and var.dtype.kind in "Mm"
|
|
145
|
+
]
|
|
146
|
+
src = ds.drop_vars(datetime_vars)
|
|
147
|
+
ds_out = src.interp({t_dim: time_vec}, method, assume_sorted=True, kwargs=kwargs) # type: ignore[arg-type]
|
|
148
|
+
cast_dtype(ds_out, ds)
|
|
149
|
+
|
|
150
|
+
else:
|
|
151
|
+
raise ValueError(f"Temporal interpolation method {method!r} not supported")
|
|
152
|
+
|
|
153
|
+
if (t_dim != valid_t) and (None not in t_coords):
|
|
154
|
+
ds_out = ds_out.assign_coords({valid_t: ds[ref_t] + ds_out[lead_t]})
|
|
155
|
+
elif (ref_t is not None) and (valid_t in ds[ref_t].dims) and (ref_t not in ds_out.coords):
|
|
156
|
+
ds_out = ds_out.assign_coords({ref_t: ds[ref_t].reindex({valid_t: time_vec})})
|
|
157
|
+
|
|
158
|
+
return ds_out
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _fillval(
|
|
162
|
+
ds: xr.Dataset,
|
|
163
|
+
time_vec: pd.TimedeltaIndex,
|
|
164
|
+
t_dim_coords: tuple[str, str | None, str | None],
|
|
165
|
+
fill_value: int | float | dict = 0.0,
|
|
166
|
+
**reindex_kwargs: Any,
|
|
167
|
+
) -> xr.Dataset:
|
|
168
|
+
"""
|
|
169
|
+
Temporal interpolation with specified fill value(s).
|
|
170
|
+
Wrapper to xarray.Dataset.reindex with method=None.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
dim, ref_t, valid_t = t_dim_coords
|
|
174
|
+
if (valid_t is not None) and (dim != valid_t):
|
|
175
|
+
to_drop = [valid_t]
|
|
176
|
+
elif (ref_t is not None) and (valid_t in ds[ref_t].dims):
|
|
177
|
+
to_drop = [ref_t]
|
|
178
|
+
else:
|
|
179
|
+
to_drop = []
|
|
180
|
+
|
|
181
|
+
return ds.drop_vars(to_drop).reindex({dim: time_vec}, fill_value=fill_value, **reindex_kwargs)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@keepattrs
|
|
185
|
+
def _bcum(
|
|
186
|
+
ds: xr.Dataset,
|
|
187
|
+
time_vec: pd.TimedeltaIndex,
|
|
188
|
+
dim: str,
|
|
189
|
+
**reindex_kwargs: Any,
|
|
190
|
+
) -> xr.Dataset:
|
|
191
|
+
"""
|
|
192
|
+
Temporal interpolation for accumulated quantities (such as precipitation amount).
|
|
193
|
+
Values must represent the accumulated quantity since the last timestamp.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
if ds.sizes[dim] <= 1:
|
|
197
|
+
return ds
|
|
198
|
+
|
|
199
|
+
model_t = ds[dim]
|
|
200
|
+
time_vec_array = xr.DataArray(time_vec, coords={dim: time_vec})
|
|
201
|
+
intervals = model_t.diff(dim) / time_vec_array.diff(dim)
|
|
202
|
+
first = intervals[0].assign_coords({dim: model_t[0]})
|
|
203
|
+
intervals = xr.concat((first, intervals), dim=dim).astype(np.float32)
|
|
204
|
+
return (ds / intervals).reindex({dim: time_vec}, method="backfill", **reindex_kwargs)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _interp_sun( # pylint: disable=too-many-locals
|
|
208
|
+
ds: xr.Dataset,
|
|
209
|
+
time_vec: pd.TimedeltaIndex,
|
|
210
|
+
dim: str,
|
|
211
|
+
method: SunInterpOptions,
|
|
212
|
+
lat: xr.DataArray | None = None,
|
|
213
|
+
lon: xr.DataArray | None = None,
|
|
214
|
+
**reindex_kwargs: Any,
|
|
215
|
+
) -> xr.Dataset:
|
|
216
|
+
"""
|
|
217
|
+
Temporal interpolation for solar radiation and sunshine duration variables.
|
|
218
|
+
Values must represent the accumulated quantity since the last timestamp.
|
|
219
|
+
However, for solar radiation variables, it is assumed that values
|
|
220
|
+
were transformed into fluxes beforehand (i.e. divided by the time delta). This
|
|
221
|
+
is the convention used in MCH to have units of [W / m2] instead of [J / m2]
|
|
222
|
+
to follow CF standards. This method disagregates accumulated values only to
|
|
223
|
+
daytime timestamps and, for solar radiation, also rescale them according to
|
|
224
|
+
the cosine of the solar hour angle. Accumulation is preserved.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
if ds[dim].dtype.kind != "M":
|
|
228
|
+
raise ValueError(
|
|
229
|
+
f"Temporal interpolation with {method} method requires a temporal"
|
|
230
|
+
" dimension with numpy.datetime64 (valid time) coordinates. Try to"
|
|
231
|
+
" call the function setindex_validtime() first."
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if ds.sizes[dim] <= 1:
|
|
235
|
+
return ds
|
|
236
|
+
|
|
237
|
+
if lat is None or lon is None:
|
|
238
|
+
latlon = get_latlon_coords(ds, nondim=False)
|
|
239
|
+
if latlon is not None:
|
|
240
|
+
lat = ds[latlon[0]]
|
|
241
|
+
lon = ds[latlon[1]]
|
|
242
|
+
else:
|
|
243
|
+
raise ValueError("Could not find latitude and longitude coordinates in the dataset")
|
|
244
|
+
|
|
245
|
+
time_vec_array = xr.DataArray(time_vec, coords={dim: time_vec})
|
|
246
|
+
first_interp = np.nonzero(~time_vec_array.isin(ds[dim]).values)[0][0]
|
|
247
|
+
|
|
248
|
+
# cut ds in two parts. Only ds_end contains NaNs to be filled
|
|
249
|
+
ds_start = ds.isel(time=slice(None, first_interp))
|
|
250
|
+
ds_end = ds.isel(time=slice(first_interp, None))
|
|
251
|
+
time_vec_end = time_vec_array.isel(time=slice(first_interp, None))
|
|
252
|
+
|
|
253
|
+
# compute middle timestamp
|
|
254
|
+
delta_t = time_vec_end.diff(dim).values
|
|
255
|
+
delta_t = time_vec_end.values - np.concatenate((delta_t[[0]], delta_t)) / 2
|
|
256
|
+
delta_t = xr.DataArray(delta_t, coords={dim: delta_t})
|
|
257
|
+
|
|
258
|
+
# check daytime for middle timestamp and initialise correction factors
|
|
259
|
+
if method == "sunrad":
|
|
260
|
+
cor_fact = (
|
|
261
|
+
np.cos(zenith(delta_t, lat, lon, refrac_cor=True).assign_coords({dim: time_vec_end}) * (np.pi / 180.0))
|
|
262
|
+
.astype(np.float32)
|
|
263
|
+
.clip(min=0.0)
|
|
264
|
+
)
|
|
265
|
+
isday = cor_fact > 0.0
|
|
266
|
+
cor_stat = "mean" # as radiation vars are transformed to flux in the internal grid database
|
|
267
|
+
else: # method is "sundur"
|
|
268
|
+
isday = daytime(delta_t, lat, lon, refrac_cor=False).assign_coords( # type: ignore[assignment]
|
|
269
|
+
{dim: time_vec_end}
|
|
270
|
+
)
|
|
271
|
+
cor_fact = isday.astype(np.float32)
|
|
272
|
+
cor_stat = "sum"
|
|
273
|
+
|
|
274
|
+
# get model times for the end part
|
|
275
|
+
model_t = [ds_start[dim][-1].values] + list(ds_end[dim].values)
|
|
276
|
+
|
|
277
|
+
# divide factors by the mean of daytime timestamps between two model_t
|
|
278
|
+
cor_list = []
|
|
279
|
+
for i, t in enumerate(model_t[1:]):
|
|
280
|
+
fact = cor_fact.isel( # type: ignore[attr-defined]
|
|
281
|
+
time=np.logical_and(time_vec_end > model_t[i], time_vec_end <= t)
|
|
282
|
+
)
|
|
283
|
+
fact /= getattr(fact, cor_stat)(dim)
|
|
284
|
+
cor_list.append(fact)
|
|
285
|
+
|
|
286
|
+
cor_fact = xr.concat(cor_list, dim=dim, coords="minimal", compat="override")
|
|
287
|
+
|
|
288
|
+
# multiply radiation data by the scale factor
|
|
289
|
+
ds_end = ds_end.reindex({dim: time_vec_end.values}, method="backfill", **reindex_kwargs) * cor_fact
|
|
290
|
+
ds_end = ds_end.where(isday, 0.0) # set night-time timestamps to zero
|
|
291
|
+
|
|
292
|
+
return xr.concat((ds_start, ds_end), dim=dim, coords="minimal", compat="override")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@dataset_accessor
|
|
296
|
+
def fill_na(ds: xr.Dataset, method: FillnaMethods = "linear", **kwargs: Any) -> xr.Dataset:
|
|
297
|
+
"""
|
|
298
|
+
Fill NaNs in the temporal dimension. Wrapper for different NaN
|
|
299
|
+
interpolation routines.
|
|
300
|
+
|
|
301
|
+
Parameters
|
|
302
|
+
----------
|
|
303
|
+
ds : xarray.Dataset
|
|
304
|
+
Must have a temporal dimension expressed as numpy.datetime64 (valid time) or numpy.timedelta64 (lead time).
|
|
305
|
+
Typical temporal dimensions names are supported, and can be extended via environment variables:
|
|
306
|
+
|
|
307
|
+
- Valid time: export GRID_PROCESS__data_model__validtime (e.g., '["time", "valid_time", "dummy_time"]')
|
|
308
|
+
- Lead time: export GRID_PROCESS__data_model__leadtime (e.g., '["lead_time", "step", "dummy_leadtime"]')
|
|
309
|
+
- When the temporal coordinates variables have a CF compliant `standard_name` attribute, arbitrary names can
|
|
310
|
+
be used directly.
|
|
311
|
+
method : str
|
|
312
|
+
interpolation method for filling the time series. One of {'bfill', 'ffill',
|
|
313
|
+
'fillval', 'linear', 'nearest', 'zero', 'slinear', 'quadratic', 'cubic',
|
|
314
|
+
'polynomial', 'barycentric', 'krogh', 'pchip', 'spline', 'akima'}.
|
|
315
|
+
Default is 'linear'
|
|
316
|
+
**kwargs : any
|
|
317
|
+
arguments passed to the respective NaN interpolation sub-function defined
|
|
318
|
+
by the ``method``
|
|
319
|
+
|
|
320
|
+
Returns
|
|
321
|
+
-------
|
|
322
|
+
ds_out : xarray.Dataset
|
|
323
|
+
with NaNs filled. No extrapolation is performed, so NaNs on the side of
|
|
324
|
+
the first and last real values remain NaNs
|
|
325
|
+
|
|
326
|
+
See Also
|
|
327
|
+
--------
|
|
328
|
+
:func:`interptogranularity`, xarray.Dataset.interpolate_na, xarray.Dataset.bfill
|
|
329
|
+
xarray.Dataset.ffill, xarray.Dataset.fillna
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
t_dim = get_t_dim(ds)
|
|
333
|
+
|
|
334
|
+
custom_func = {
|
|
335
|
+
"bfill": _bfill, # type: ignore[dict-item]
|
|
336
|
+
"ffill": _ffill, # type: ignore[dict-item]
|
|
337
|
+
"fillval": _fillna_val,
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if method in custom_func:
|
|
341
|
+
return custom_func[method](ds, t_dim, **kwargs)
|
|
342
|
+
|
|
343
|
+
if method in XR_INTERP_METHODS:
|
|
344
|
+
return ds.interpolate_na(t_dim, method, **kwargs) # type: ignore[arg-type]
|
|
345
|
+
|
|
346
|
+
raise ValueError(f"Temporal NaN interpolation method {method!r} not supported")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _bfill(ds: xr.Dataset, dim: str, **bfill_kwargs: Any) -> xr.Dataset:
|
|
350
|
+
"""
|
|
351
|
+
Back fill NaNs in the temporal dimension. Wrapper to xarray.Dataset.bfill
|
|
352
|
+
that prevents extrapolation at the begining of the timeseries.
|
|
353
|
+
"""
|
|
354
|
+
|
|
355
|
+
def _set_nans(da_out: xr.DataArray) -> xr.DataArray:
|
|
356
|
+
first_notnan = ds[da_out.name].isnull().argmin(dim)
|
|
357
|
+
indexer = ds[dim].isel({dim: first_notnan})
|
|
358
|
+
return da_out.where(da_out[dim] >= indexer)
|
|
359
|
+
|
|
360
|
+
ds_out = ds.bfill(dim, **bfill_kwargs)
|
|
361
|
+
return ds_out.map(_set_nans, keep_attrs=True)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _ffill(ds: xr.Dataset, dim: str, **ffill_kwargs: Any) -> xr.Dataset:
|
|
365
|
+
"""
|
|
366
|
+
Forward fill NaNs in the temporal dimension. Wrapper to xarray.Dataset.ffill
|
|
367
|
+
that prevents extrapolation at the end of the timeseries.
|
|
368
|
+
"""
|
|
369
|
+
|
|
370
|
+
def _set_nans(da_out: xr.DataArray) -> xr.DataArray:
|
|
371
|
+
last_notnan = ds[da_out.name].isel({dim: slice(None, None, -1)}).isnull().argmin(dim)
|
|
372
|
+
last_notnan = da_out.sizes[dim] - last_notnan - 1
|
|
373
|
+
indexer = ds[dim].isel({dim: last_notnan})
|
|
374
|
+
return da_out.where(da_out[dim] <= indexer)
|
|
375
|
+
|
|
376
|
+
ds_out = ds.ffill(dim, **ffill_kwargs)
|
|
377
|
+
return ds_out.map(_set_nans, keep_attrs=True)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _fillna_val(ds: xr.Dataset, dim: str, fill_value: float | dict[str, float] = 0.0) -> xr.Dataset:
|
|
381
|
+
"""
|
|
382
|
+
Fill NaNs in the temporal dimension with specified value(s).
|
|
383
|
+
Wrapper to xarray.Dataset.fillna that prevents extrapolation at the begining
|
|
384
|
+
and end of the timeseries.
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
def _set_nans(da_out: xr.DataArray) -> xr.DataArray:
|
|
388
|
+
bool_da = ds[da_out.name].isnull()
|
|
389
|
+
first_notnan = bool_da.argmin(dim)
|
|
390
|
+
indexer_first = ds[dim].isel({dim: first_notnan})
|
|
391
|
+
size_t = bool_da.sizes[dim]
|
|
392
|
+
last_notnan = size_t - bool_da.isel({dim: np.arange(size_t)[::-1]}).argmin(dim) - 1
|
|
393
|
+
indexer_last = ds[dim].isel({dim: last_notnan})
|
|
394
|
+
return da_out.where((da_out[dim] >= indexer_first) & (da_out[dim] <= indexer_last))
|
|
395
|
+
|
|
396
|
+
ds_out = ds.fillna(fill_value)
|
|
397
|
+
return ds_out.map(_set_nans, keep_attrs=True)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@dataset_accessor
|
|
401
|
+
def setindex_validtime(ds: xr.Dataset, last_ref: bool = True) -> xr.Dataset:
|
|
402
|
+
"""
|
|
403
|
+
Assign valid time dimension and coordinates (i.e. numpy.datetime64). The dataset
|
|
404
|
+
can have zero, one or several forecast reference time and one or several
|
|
405
|
+
lead time. In case several ref time and lead time are present, the function
|
|
406
|
+
combines the different model runs by selecting from the most recent one where
|
|
407
|
+
timestamps overlap.
|
|
408
|
+
|
|
409
|
+
Parameters
|
|
410
|
+
----------
|
|
411
|
+
ds : xarray.Dataset
|
|
412
|
+
Must have a forecast reference time dimension and/or a lead time one
|
|
413
|
+
Typical temporal dimensions names are supported, and can be extended via environment variables:
|
|
414
|
+
|
|
415
|
+
- Lead time: export GRID_PROCESS__data_model__leadtime (e.g., '["lead_time", "step", "dummy_leadtime"]')
|
|
416
|
+
- Forecast reference time: export GRID_PROCESS__data_model__reftime (e.g., '["forecast_reference_time",
|
|
417
|
+
"ref_time", "reftime", "dummy_reftime"]')
|
|
418
|
+
- When the temporal coordinates variables have a CF compliant `standard_name` attribute, arbitrary names can
|
|
419
|
+
be used directly.
|
|
420
|
+
last_ref : bool
|
|
421
|
+
whether to add the last forecast reference time. Used only if dimensions
|
|
422
|
+
forecast reference time and lead time both exists and have length greater
|
|
423
|
+
than 1. Default is True.
|
|
424
|
+
|
|
425
|
+
Returns
|
|
426
|
+
-------
|
|
427
|
+
ds_out : xarray.Dataset
|
|
428
|
+
with a valid time dimension labelled as numpy.datetime64.
|
|
429
|
+
|
|
430
|
+
Note
|
|
431
|
+
----
|
|
432
|
+
This function can be used to combine many runs of the same model into a
|
|
433
|
+
continuous timeseries
|
|
434
|
+
"""
|
|
435
|
+
|
|
436
|
+
def _slice_next_t(ds: xr.Dataset, ref: np.datetime64, next_t: np.datetime64) -> xr.Dataset:
|
|
437
|
+
"""
|
|
438
|
+
Slice lead time up to the first timestamp of the next forecast reference time.
|
|
439
|
+
"""
|
|
440
|
+
|
|
441
|
+
ds = ds.sel({ref_dim: ref})
|
|
442
|
+
return ds.sel({lead_dim: (ds[valid_coord].values < next_t)})
|
|
443
|
+
|
|
444
|
+
t_coords = get_temporal_coords(ds)
|
|
445
|
+
ref_coord, lead_coord, valid_coord = t_coords
|
|
446
|
+
ref_dim, lead_dim, valid_dim = get_temporal_dims(ds)
|
|
447
|
+
|
|
448
|
+
if valid_dim is not None:
|
|
449
|
+
return ds
|
|
450
|
+
|
|
451
|
+
if sum(t is None for t in t_coords) > 1:
|
|
452
|
+
raise ValueError(
|
|
453
|
+
"The dataset needs at least two out of the 3 following temporal"
|
|
454
|
+
" coordinates: valid time, ref time and lead time"
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
# single dim (lead_time or ref_time)
|
|
458
|
+
if ref_dim is None:
|
|
459
|
+
return _validtime_from_single_dim(ds, lead_dim, (valid_coord, ref_coord)) # type: ignore[arg-type]
|
|
460
|
+
if lead_dim is None:
|
|
461
|
+
return _validtime_from_single_dim(ds, ref_dim, (valid_coord, lead_coord), True, True)
|
|
462
|
+
|
|
463
|
+
# single dim with size greater than one (lead_time or ref_time)
|
|
464
|
+
if ds.sizes[ref_dim] == 1 and ds.sizes[lead_dim] >= 1:
|
|
465
|
+
return ds.squeeze(ref_dim).timeseries.setindex_validtime()
|
|
466
|
+
if ds.sizes[ref_dim] > 1 and ds.sizes[lead_dim] == 1:
|
|
467
|
+
return ds.squeeze(lead_dim).timeseries.setindex_validtime()
|
|
468
|
+
|
|
469
|
+
# both lead_time and ref_time dims have size greater than one
|
|
470
|
+
if valid_coord is None:
|
|
471
|
+
valid_coord = VALIDTIME[0]
|
|
472
|
+
ds = ds.assign_coords({valid_coord: ds[ref_dim] + ds[lead_dim]})
|
|
473
|
+
|
|
474
|
+
data = [
|
|
475
|
+
_slice_next_t(ds, ref, next_t).timeseries.setindex_validtime()
|
|
476
|
+
for ref, next_t in zip(ds[ref_dim].values, ds[valid_coord].isel({lead_dim: 0}).values[1:])
|
|
477
|
+
]
|
|
478
|
+
|
|
479
|
+
if last_ref:
|
|
480
|
+
data.append(ds.isel({ref_dim: -1}).timeseries.setindex_validtime())
|
|
481
|
+
|
|
482
|
+
return xr.concat(data, dim=valid_coord, coords=[ref_dim], compat="override")
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _validtime_from_single_dim(
|
|
486
|
+
ds: xr.Dataset,
|
|
487
|
+
dim: str,
|
|
488
|
+
coords: tuple[str | None, str | None],
|
|
489
|
+
drop_coord: bool = False,
|
|
490
|
+
dim_as_coord: bool = False,
|
|
491
|
+
) -> xr.Dataset:
|
|
492
|
+
"""
|
|
493
|
+
Assign valid time dimension and coordinates for cases when only a lead time or reftime dimension is present.
|
|
494
|
+
"""
|
|
495
|
+
|
|
496
|
+
valid_coord, other_coord = coords
|
|
497
|
+
if valid_coord is None:
|
|
498
|
+
times = (ds[dim] + ds[other_coord]).values
|
|
499
|
+
valid_coord = VALIDTIME[0]
|
|
500
|
+
else:
|
|
501
|
+
times = ds[valid_coord].values
|
|
502
|
+
ds = ds.drop_vars(valid_coord)
|
|
503
|
+
|
|
504
|
+
ds_out = ds.rename({dim: valid_coord}).assign_coords({valid_coord: times})
|
|
505
|
+
if dim_as_coord:
|
|
506
|
+
ds_out = ds_out.assign_coords({dim: (valid_coord, ds[dim].values)})
|
|
507
|
+
|
|
508
|
+
if other_coord is None:
|
|
509
|
+
return ds_out
|
|
510
|
+
|
|
511
|
+
if drop_coord:
|
|
512
|
+
return ds_out.drop_vars(other_coord)
|
|
513
|
+
|
|
514
|
+
return ds_out.assign_coords(
|
|
515
|
+
{
|
|
516
|
+
other_coord: (
|
|
517
|
+
valid_coord,
|
|
518
|
+
[ds[other_coord].values] * len(times),
|
|
519
|
+
)
|
|
520
|
+
}
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@dataset_accessor
|
|
525
|
+
def smoothing(ds: xr.Dataset, w: Sequence[float]) -> xr.Dataset:
|
|
526
|
+
"""
|
|
527
|
+
Smooth timeseries by applying a moving weighted average.
|
|
528
|
+
|
|
529
|
+
Parameters
|
|
530
|
+
----------
|
|
531
|
+
ds : xarray.Dataset
|
|
532
|
+
Must have a valid time or lead time dimension
|
|
533
|
+
w : sequence of floats
|
|
534
|
+
weights of the moving window
|
|
535
|
+
|
|
536
|
+
Returns
|
|
537
|
+
-------
|
|
538
|
+
ds : xarray.Dataset
|
|
539
|
+
contains the smoothed timeseries
|
|
540
|
+
"""
|
|
541
|
+
|
|
542
|
+
dim = get_t_dim(ds)
|
|
543
|
+
w_da = xr.DataArray(w / np.sum(w), dims=["window"]).astype(np.float32)
|
|
544
|
+
w_len = len(w_da)
|
|
545
|
+
return (
|
|
546
|
+
ds.rolling({dim: w_len}, center=True).construct("window").map(lambda x: x.dot(w_da), keep_attrs=True).fillna(ds)
|
|
547
|
+
)
|