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.
@@ -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
+ )