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,61 @@
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 with general utility functions that operate on xarray.Dataset
9
+ """
10
+
11
+ import logging
12
+ from typing import Any
13
+ from collections.abc import Hashable
14
+
15
+ import xarray as xr
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def ds_to_da(
21
+ ds: xr.Dataset,
22
+ ) -> tuple[xr.DataArray, dict[Hashable, dict[Hashable, Any]]]:
23
+ """
24
+ Transform a dataset containing model data and one or several data
25
+ variables to a dataarray with the variables stacked as a new dimension.
26
+ The function also returns the attributes of the data variables for further
27
+ use.
28
+
29
+ Parameters
30
+ ----------
31
+ ds : xarray.Dataset
32
+ contain one or more variables
33
+
34
+ Returns
35
+ -------
36
+ da : xarray.DataArray
37
+ contains the data variables from the input dataset stacked in a new
38
+ `variable` dimension
39
+ vars_attrs : dictionary
40
+ contains the attributes of each data variables in the input dataset.
41
+
42
+ See also
43
+ --------
44
+ :func:`~grid_process.da_utils.da_to_ds` which is the inverse function
45
+ """
46
+
47
+ vars_attrs = {varname: da.attrs for varname, da in ds.data_vars.items()}
48
+ da = ds.to_array()
49
+ return da, vars_attrs
50
+
51
+
52
+ def cast_dtype(ds_out: xr.Dataset, ds_in: xr.Dataset) -> None:
53
+ """
54
+ Cast back each data variable in the output dataset to the original dtype
55
+ of the input dataset. The operation is done in-place.
56
+ """
57
+
58
+ for varname, da in ds_out.items():
59
+ dtype_in = ds_in[varname].dtype
60
+ if da.dtype != dtype_in:
61
+ ds_out[varname] = da.astype(dtype_in)
@@ -0,0 +1,396 @@
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 to compute ensemble statistics
9
+ """
10
+
11
+ import logging
12
+ from typing import Literal, get_args
13
+ from collections.abc import Callable, Sequence
14
+ from functools import partial
15
+ from warnings import warn
16
+
17
+ import numpy as np
18
+ import xarray as xr
19
+
20
+ from grid_process.helpers import make_accessor, keepattrs
21
+ from grid_process.data_model import get_t_dim, get_spatial_dims, get_ensemble_dim
22
+ from grid_process.aggreg import interval_stat
23
+ from grid_process.da_utils import da_to_ds
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ # Register xarray accessor so module functions can also be called as methods
29
+ # on xr.Dataset objects
30
+ @xr.register_dataset_accessor("ens_stats")
31
+ class EnsStats:
32
+ """
33
+ Xarray accessors class for extracting probabilities from ensemble models
34
+ """
35
+
36
+ def __init__(self, ds: xr.Dataset):
37
+ self._data = ds
38
+
39
+
40
+ dataset_accessor = partial(make_accessor, accessor_class=EnsStats)
41
+
42
+
43
+ NeighborStatMethods = Literal["mean", "sum", "min", "max"]
44
+
45
+
46
+ @dataset_accessor
47
+ def agg_prob(
48
+ ds: xr.Dataset,
49
+ dict_prob: dict,
50
+ agg_func: Callable,
51
+ lt_duplicates: bool = True,
52
+ sign: str = "above",
53
+ ) -> xr.Dataset:
54
+ """
55
+ Compute aggregated probabilities of exceedance. Temporal aggregation is
56
+ done with :func:`~grid_process.aggreg.interval_stat`.
57
+
58
+ Parameters
59
+ ----------
60
+ ds : xarray.Dataset
61
+ Must have valid time and ensemble dimensions and contain a single data variable.
62
+ Typical dimensions names are supported, and can be extended via environment variables:
63
+
64
+ - Valid time: export GRID_PROCESS__data_model__validtime (e.g., '["time", "valid_time", "dummy_time"]')
65
+ - Ensemble: export GRID_PROCESS__data_model__ens_dim (e.g., '["realization", "eps", "epsd_1", "number",
66
+ "dummy_ens_dim"]')
67
+ - When the coordinates variables have a CF compliant `standard_name` attribute, arbitrary names can be
68
+ used directly.
69
+ dict_prob : dict
70
+ holds the keys 'prob_names', 'thresholds', 'granularity', 'interval',
71
+ 'offset' giving the specificities for the aggregated probabilities. If
72
+ 'prob_names' and 'thresholds' contain a list, the probabilites for
73
+ several thresholds but same granularity, interval and offset are
74
+ computed and stored in the same dataset as different variables.
75
+
76
+ - dict_prob['prob_names']: str or list of str. Name(s) given to
77
+ resulting data variable(s)
78
+ - dict_prob['thresholds']: float or list of floats. Threshold to
79
+ compute each probability
80
+ - dict_prob['granularity']: int. Granularity in hours for the
81
+ temporal aggregation
82
+ - dict_prob['interval']: int. Interval in hours for the temporal
83
+ aggregation
84
+ - dict_prob['offset']: int. Offset in hours from 00:00 for the
85
+ temporal aggregation
86
+
87
+ agg_func : callable
88
+ specify temporal aggregation function to use. The function must operate
89
+ on `window` dimension. E.g. ``agg_func`` = lambda x: x.mean('window')
90
+ lt_duplicates : bool, optional
91
+ if True, aggregations are done in the DWH compliant style, which means
92
+ intervals are calculated with respect to both UTC+1 and UTC+2. Default
93
+ is True
94
+ sign : {'above', 'below'}
95
+ specify if we are looking for the probability to be above or below the
96
+ thresholds. Default is 'above'
97
+
98
+ Returns
99
+ -------
100
+ ds_out : xarray.Dataset
101
+ dataset with ensemble members dimension reduced and containing the
102
+ aggregated probabilities of exceedance for each given threshold as data
103
+ variables
104
+
105
+ See also
106
+ --------
107
+ :func:`~grid_process.aggreg.interval_stat`, :func:`ens_prob`
108
+ """
109
+
110
+ agg_ds = interval_stat(
111
+ ds,
112
+ dict_prob["granularity"],
113
+ dict_prob["interval"],
114
+ dict_prob["offset"],
115
+ agg_func,
116
+ lt_duplicates,
117
+ )
118
+
119
+ return ens_prob(agg_ds, dict_prob["thresholds"], dict_prob["prob_names"], sign)
120
+
121
+
122
+ @dataset_accessor
123
+ def ens_prob(
124
+ ds: xr.Dataset,
125
+ thresholds: float | list[float],
126
+ prob_names: str | list[str],
127
+ sign: str = "above",
128
+ ) -> xr.Dataset:
129
+ """
130
+ Compute probabilities of exceedance based on raw members.
131
+
132
+ Parameters
133
+ ----------
134
+ ds : xarray.Dataset
135
+ Must have an ensemble dimension and contain a single data variable.
136
+ Typical ensemble dimension names are supported, and can be extended via environment variables:
137
+
138
+ - Ensemble: export GRID_PROCESS__data_model__ens_dim (e.g., '["realization", "eps", "epsd_1", "number",
139
+ "dummy_ens_dim"]')
140
+ - When the coordinates variables have a CF compliant `standard_name` attribute, arbitrary names can be
141
+ used directly.
142
+ thresholds : float or list of floats
143
+ the threshold(s) to compute the exceedence probabilities. Must be in
144
+ the same units as the data.
145
+ prob_names : str or list of str
146
+ new variable name for each probability threshold
147
+ sign : {'above', 'below'}
148
+ specify if we are looking for the probability to be above or below the
149
+ thresholds. Default is 'above'
150
+
151
+ Returns
152
+ -------
153
+ ds_out : xarray.Dataset
154
+ dataset with the ensemble members dimension reduced and containing the
155
+ probabilities of exceedance for each given threshold as a new data
156
+ variable
157
+ """
158
+
159
+ if not isinstance(thresholds, list):
160
+ thresholds = [thresholds]
161
+ if not isinstance(prob_names, list):
162
+ prob_names = [prob_names]
163
+
164
+ thresh_da = xr.DataArray(thresholds, dims="variable", coords={"variable": prob_names})
165
+
166
+ ens_dim = get_ensemble_dim(ds)
167
+ if sign == "below":
168
+ ds_out = (ds < thresh_da).mean(dim=ens_dim, skipna=False).astype(np.float32) * 100
169
+ else:
170
+ ds_out = (ds >= thresh_da).mean(dim=ens_dim, skipna=False).astype(np.float32) * 100
171
+
172
+ vars_attrs = {varname: {"units": "percent"} for varname in prob_names}
173
+ ds_out = da_to_ds(ds_out[list(ds.data_vars)[0]], vars_attrs)
174
+ return ds_out.assign_attrs(ds.attrs)
175
+
176
+
177
+ @dataset_accessor
178
+ def neighbor_stat(
179
+ ds: xr.Dataset,
180
+ method: NeighborStatMethods,
181
+ w_size_xy: int | None = None,
182
+ w_size_t: int | None = None,
183
+ ) -> xr.Dataset:
184
+ """
185
+ Compute a space x time neighborhood statistic.
186
+
187
+ Parameters
188
+ ----------
189
+ ds : xarray.Dataset
190
+ Must have spatial and/or temporal dimensions.
191
+ Typical dimensions names are supported, and can be extended via environment variables:
192
+
193
+ - Valid time: export GRID_PROCESS__data_model__validtime (e.g., '["time", "valid_time", "dummy_time"]')
194
+ - Lead time: export GRID_PROCESS__data_model__leadtime (e.g., '["lead_time", "step", "dummy_leadtime"]')
195
+ - Y coordinate: export GRID_PROCESS__data_model__grid__y (e.g., '["y", "latitude", "lat", "dummy_y"]')
196
+ - X coordinate: export GRID_PROCESS__data_model__grid__x (e.g., '["x", "longitude", "lon", "dummy_x"]')
197
+ - When the coordinates variables have a CF compliant `standard_name` attribute, arbitrary names can be
198
+ used directly.
199
+ method : {'mean', 'sum', 'min', 'max'}
200
+ statistic applied over the moving window/volume
201
+ w_size_xy : int, optional
202
+ window size in the spatial dimensions
203
+ w_size_t : int, optional
204
+ window size in the temporal dimension
205
+
206
+ Returns
207
+ -------
208
+ ds_out : xarray.Dataset
209
+ each value is the result of the neighborhood statistic
210
+
211
+ Note
212
+ ----
213
+ For a large gain in performence, the function performs separate rolling window
214
+ operations on each of the spatial and temporal dimensions sequentially. Therefore,
215
+ only methods for which this is equivalent than applying the statistic over the
216
+ rolling volume are accepted. Methods such as 'median' or 'std' are thus not accepted.
217
+ """
218
+
219
+ ok_meth = get_args(NeighborStatMethods)
220
+ if method not in ok_meth:
221
+ raise ValueError(f"method must be one of {ok_meth} but was {method!r}")
222
+
223
+ if w_size_xy is not None:
224
+ spatial_dims = get_spatial_dims(ds)
225
+ if len(spatial_dims) == 1:
226
+ warn(
227
+ "Rolling window operations on unstructured grids is not supported."
228
+ " Resample to a 2D grid with grid_interp.interp2grid() first."
229
+ )
230
+ else:
231
+ ds = _rolling_stat(ds, spatial_dims[1], w_size_xy, method)
232
+ ds = _rolling_stat(ds, spatial_dims[0], w_size_xy, method)
233
+ if w_size_t is not None:
234
+ ds = _rolling_stat(ds, get_t_dim(ds), w_size_t, method)
235
+
236
+ return ds
237
+
238
+
239
+ def _rolling_stat(ds: xr.Dataset, dim: str, w_size: int, method: str) -> xr.Dataset:
240
+ """
241
+ Perform rolling window operation along the specified dimension.
242
+ """
243
+
244
+ return getattr(ds.rolling({dim: w_size}, min_periods=1, center=True), method)(skipna=False)
245
+
246
+
247
+ @dataset_accessor
248
+ def upscaled_prob(
249
+ ds: xr.Dataset,
250
+ thresholds: float | list[float],
251
+ prob_names: str | list[str],
252
+ w_size_xy: int | None = None,
253
+ w_size_t: int | None = None,
254
+ ) -> xr.Dataset:
255
+ """
256
+ Compute upscaled probabilities of exceedance. Upscaling is done in the
257
+ space and/or time dimensions.
258
+
259
+ Parameters
260
+ ----------
261
+ ds : xarray.Dataset
262
+ Must have an ensemble member dimension as well as a spatial and/or time one.
263
+ Typical dimensions names are supported, and can be extended via environment variables:
264
+
265
+ - Ensemble: export GRID_PROCESS__data_model__ens_dim (e.g., '["realization", "eps", "epsd_1", "number",
266
+ "dummy_ens_dim"]')
267
+ - Valid time: export GRID_PROCESS__data_model__validtime (e.g., '["time", "valid_time", "dummy_time"]')
268
+ - Lead time: export GRID_PROCESS__data_model__leadtime (e.g., '["lead_time", "step", "dummy_leadtime"]')
269
+ - Y coordinate: export GRID_PROCESS__data_model__grid__y (e.g., '["y", "latitude", "lat", "dummy_y"]')
270
+ - X coordinate: export GRID_PROCESS__data_model__grid__x (e.g., '["x", "longitude", "lon", "dummy_x"]')
271
+ - When the coordinates variables have a CF compliant `standard_name` attribute, arbitrary names can be
272
+ used directly.
273
+ thresholds : float or list of floats
274
+ the threshold(s) to compute the exceedence probabilities. Must be in
275
+ the same units as the data
276
+ prob_names : str or list of str
277
+ new variable name for each probability threshold
278
+ w_size_xy : int, optional
279
+ window size for upscaling in the spatial dimensions
280
+ w_size_t : int, optional
281
+ window size for upscaling in the time dimension
282
+
283
+ Returns
284
+ -------
285
+ ds_out : xarray.Dataset
286
+ dataset with ensemble members dimension reduced and containing the
287
+ upscaled probabilities of exceedance for each given threshold as a new
288
+ data variable
289
+ """
290
+
291
+ ds = neighbor_stat(ds, "max", w_size_xy, w_size_t)
292
+ return ens_prob(ds, thresholds, prob_names)
293
+
294
+
295
+ @dataset_accessor
296
+ def circular_mean(ds: xr.Dataset, dim: str | Sequence[str] | None = None) -> xr.Dataset:
297
+ """
298
+ Compute the mean of circular data between 0 and 360 degrees along the
299
+ specified dimension(s).
300
+
301
+ Parameters
302
+ ----------
303
+ ds : xarray.Dataset
304
+ dim : str or sequence of str, optional
305
+ dimension(s) on which to compute the circular mean. If None (default)
306
+ look for the ensemble members dimension
307
+
308
+ Returns
309
+ -------
310
+ ds_out : xarray.Dataset
311
+ """
312
+
313
+ if dim is None:
314
+ dim = get_ensemble_dim(ds)
315
+
316
+ sin_sum = np.sin(ds * np.pi / 180).sum(dim, skipna=False) # type: ignore[call-overload]
317
+ cos_sum = np.cos(ds * np.pi / 180).sum(dim, skipna=False) # type: ignore[call-overload]
318
+ res = np.arctan2(sin_sum, cos_sum) * 180 / np.pi + 360
319
+ return res % 360
320
+
321
+
322
+ @dataset_accessor
323
+ @keepattrs
324
+ def circular_quantile(
325
+ ds: xr.Dataset,
326
+ q: float | Sequence[float] | np.ndarray,
327
+ method: str = "linear",
328
+ dim: str | None = None,
329
+ wrap_around: bool = True,
330
+ ) -> xr.Dataset:
331
+ """
332
+ Compute the qth quantile(s) of circular data (0 to 360 degrees) along the
333
+ specified dimension.
334
+
335
+ Parameters
336
+ ----------
337
+ ds : xarray.Dataset
338
+ q : float in range of [0,1] or sequence of floats or ndarray of float
339
+ quantile(s) to compute, which must be between 0 and 1 inclusive.
340
+ method : str
341
+ this parameter specifies the interpolation method to use when the desired
342
+ quantile lies between two data points. See numpy.quantile for possible
343
+ options. Default is 'linear'
344
+ dim : str, optional
345
+ dimension on which to compute the quantiles. If None (default) look for
346
+ the ensemble members dimension
347
+ wrap_around : bool
348
+ whether to wrap around the results to the 0 to 360 interval. Default is
349
+ True
350
+
351
+ Returns
352
+ -------
353
+ ds_out : xarray.Dataset
354
+ a `quantile` dimension replaces the dimension being reduced
355
+
356
+ See Also
357
+ --------
358
+ numpy.quantile
359
+
360
+ Note
361
+ ----
362
+ This approach was inspired by this answer on stackoverflow:
363
+ https://stackoverflow.com/a/46453706. The simple intuition is that the
364
+ opposite angle to the mean of the circular samples provides the reference
365
+ point for their ranking.
366
+ """
367
+
368
+ if not isinstance(q, (Sequence, np.ndarray)):
369
+ q = [q]
370
+
371
+ if dim is None:
372
+ dim = get_ensemble_dim(ds)
373
+
374
+ ds_out = xr.Dataset()
375
+
376
+ # compute circular mean
377
+ ds_mean = circular_mean(ds, dim=dim)
378
+
379
+ for varname, da in ds.items():
380
+ # origin is the opposite point to the mean
381
+ rank_origin = (ds_mean[varname] + 180) % 360
382
+ # transform data
383
+ transformed_da = da.where(da >= rank_origin, da + 360)
384
+ # compute quantiles
385
+ new_dims = list(da.dims)
386
+ new_dims.remove(dim)
387
+ new_dims = ["quantile"] + new_dims
388
+ axis = da.get_axis_num(dim)
389
+ tmp = np.quantile(transformed_da.values, q, axis=axis, method=method).astype( # type: ignore[call-overload]
390
+ da.dtype.type, copy=False
391
+ )
392
+ if wrap_around:
393
+ tmp = tmp % 360
394
+ ds_out[varname] = xr.DataArray(tmp, dims=new_dims, coords={"quantile": q})
395
+
396
+ return ds_out.assign_coords(ds_mean.coords)