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/sun.py ADDED
@@ -0,0 +1,502 @@
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 wrap some sun position utility functions
9
+ """
10
+
11
+ import logging
12
+ from typing import cast
13
+
14
+ import numpy as np
15
+ from pandas import date_range, Timedelta, offsets
16
+ import xarray as xr
17
+
18
+ from grid_process.data_model import get_validtime
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def zenith(times: xr.DataArray, lat: xr.DataArray, lon: xr.DataArray, refrac_cor: bool = True) -> xr.DataArray:
24
+ """
25
+ Analytical expression of solar zenith angle based on spherical
26
+ trigonometry.
27
+
28
+ Parameters
29
+ ----------
30
+ times: xarray.DataArray, 1D
31
+ contains timestamps to be checked in UTC
32
+ lat: xarray.DataArray 1D or 2D
33
+ contains latitudes of the points to be checked
34
+ lon: xarray.DataArray 1D or 2D
35
+ contains longitudes of the points to be checked
36
+ refrac_cor: bool
37
+ whether to account for atmospheric refraction. It is significant only when
38
+ the sun is close to horizon, in which case atmospheric refraction correction
39
+ is about 0.6 [degrees]. Default is True
40
+
41
+ Returns
42
+ -------
43
+ zen : xarray.DataArray 2D or 3D
44
+ zenith in [degrees] for each time/point couple
45
+
46
+ References
47
+ ----------
48
+ * J. A. Duffie and W. A. Beckman, "Solar Engineering of Thermal
49
+ Processes, 3rd Edition" pp. 14, J. Wiley and Sons, New York (2006)
50
+
51
+ * J. H. Seinfeld and S. N. Pandis, "Atmospheric Chemistry and
52
+ Physics" p. 132, J. Wiley (1998)
53
+
54
+ * Daryl R. Myers, "Solar Radiation: Practical Modeling for
55
+ Renewable Energy Applications", p. 5 CRC Press (2013)
56
+
57
+ * `Wikipedia: Solar Zenith Angle
58
+ <https://en.wikipedia.org/wiki/Solar_zenith_angle>`_
59
+
60
+ * `PVCDROM: Sun's Position
61
+ <http://www.pveducation.org/pvcdrom/2-properties-sunlight/
62
+ suns-position>`_
63
+
64
+ See Also
65
+ --------
66
+ :func:`azim_elev`, :func:`elevation`
67
+ """
68
+
69
+ zen = _zenith(*params_zenith(times, lat, lon)) * (180.0 / np.pi)
70
+ if refrac_cor:
71
+ zen -= _atmo_refraction_corr(90 - zen)
72
+
73
+ return zen
74
+
75
+
76
+ def _zenith(decl: xr.DataArray, lat_rad: xr.DataArray, h_angle: xr.DataArray) -> xr.DataArray:
77
+ """
78
+ Compute the formula for the solar zenith angle. The returned angle is in radians
79
+ """
80
+
81
+ return np.arccos(np.cos(decl) * np.cos(lat_rad) * np.cos(h_angle) + np.sin(decl) * np.sin(lat_rad))
82
+
83
+
84
+ def cos_zenith(times: xr.DataArray, lat: xr.DataArray, lon: xr.DataArray) -> xr.DataArray:
85
+ """
86
+ Compute the cosine of the solar zenith angle. Atmospheric refraction is NOT
87
+ considered
88
+ """
89
+
90
+ decl, lat_rad, h_angle = params_zenith(times, lat, lon)
91
+ return np.cos(decl) * np.cos(lat_rad) * np.cos(h_angle) + np.sin(decl) * np.sin(lat_rad)
92
+
93
+
94
+ def params_zenith(
95
+ times: xr.DataArray, lat: xr.DataArray, lon: xr.DataArray
96
+ ) -> tuple[xr.DataArray, xr.DataArray, xr.DataArray]:
97
+ """
98
+ get the different parameters to compute zenith angle
99
+ """
100
+
101
+ decl = _declination(times.dt.dayofyear)
102
+ h_angle = hour_angle(times, lon)
103
+ lat_rad = lat * (np.pi / 180.0)
104
+
105
+ return decl, lat_rad, h_angle
106
+
107
+
108
+ def _declination(dayofyear: xr.DataArray) -> xr.DataArray:
109
+ """
110
+ Solar declination from Duffie & Beckman and attributed to Spencer (1971)
111
+ and Iqbal (1983).
112
+ """
113
+
114
+ day_angle = (2.0 * np.pi / 365.0) * (dayofyear - 1)
115
+ return (
116
+ 0.006918 # type: ignore[return-value]
117
+ - 0.399912 * np.cos(day_angle)
118
+ + 0.070257 * np.sin(day_angle)
119
+ - 0.006758 * np.cos(2.0 * day_angle)
120
+ + 0.000907 * np.sin(2.0 * day_angle)
121
+ - 0.002697 * np.cos(3.0 * day_angle)
122
+ + 0.00148 * np.sin(3.0 * day_angle)
123
+ )
124
+
125
+
126
+ def _equation_of_time(dayofyear: xr.DataArray) -> xr.DataArray:
127
+ """
128
+ Difference in time between solar time and mean solar time in minutes. From
129
+ .. _PVCDROM: http://www.pveducation.org/pvcdrom/2-properties-sunlight/solar-time
130
+ """
131
+
132
+ bday = (2.0 * np.pi / 365.0) * (dayofyear - 81)
133
+ return 9.87 * np.sin(2.0 * bday) - 7.53 * np.cos(bday) - 1.5 * np.sin(bday) # type: ignore[return-value]
134
+
135
+
136
+ def hour_angle(times: xr.DataArray, lon: xr.DataArray) -> xr.DataArray:
137
+ """
138
+ Hour angle [radians] in local solar time. Zero at local solar noon.
139
+
140
+ Parameters
141
+ ----------
142
+ times: xarray.DataArray, 1D
143
+ contains timestamps in UTC for which to calculate the hour angle
144
+ lon: xarray.DataArray 1D or 2D
145
+ contains longitudes of the points to be checked
146
+ """
147
+
148
+ hour = (times - times.dt.floor("D")) / np.timedelta64(1, "h")
149
+ eot = _equation_of_time(times.dt.dayofyear)
150
+ return (15.0 * hour + eot / 4.0 + (lon - 180.0)) * (np.pi / 180.0)
151
+
152
+
153
+ def _atmo_refraction_corr(
154
+ elev: xr.DataArray | np.ndarray,
155
+ ) -> xr.DataArray | np.ndarray:
156
+ """atmospheric refraction correction [degrees]"""
157
+
158
+ elev = elev.clip(-1.0, 90.0)
159
+ return (1.02 / np.tan((elev + 10.3 / (elev + 5.11)) * (np.pi / 180.0)) + 0.0019279) / 60
160
+
161
+
162
+ def elevation(times: xr.DataArray, lat: xr.DataArray, lon: xr.DataArray, refrac_cor: bool = True) -> xr.DataArray:
163
+ """
164
+ Analytical expression of solar elevation angle based on spherical
165
+ trigonometry
166
+
167
+ Parameters
168
+ ----------
169
+ times: xarray.DataArray, 1D
170
+ contains timestamps to be checked in UTC
171
+ lat: xarray.DataArray 1D or 2D
172
+ contains latitudes of the points to be checked
173
+ lon: xarray.DataArray 1D or 2D
174
+ contains longitudes of the points to be checked
175
+ refrac_cor: bool
176
+ whether to account for atmospheric refraction. It is significant only when
177
+ the sun is close to horizon, in which case atmospheric refraction correction
178
+ is about 0.6 [degrees]. Default is True
179
+
180
+ Returns
181
+ -------
182
+ elev : xarray.DataArray 2D or 3D
183
+ elevation in [degrees] for each time/point couple
184
+
185
+ See Also
186
+ --------
187
+ :func:`azim_elev`, :func:`zenith`
188
+ """
189
+
190
+ elev = 90.0 - zenith(times, lat, lon, refrac_cor=False)
191
+ if refrac_cor:
192
+ elev += _atmo_refraction_corr(elev)
193
+
194
+ return elev
195
+
196
+
197
+ def azim_elev(
198
+ times: xr.DataArray, lat: xr.DataArray, lon: xr.DataArray, refrac_cor: bool = True
199
+ ) -> tuple[xr.DataArray, xr.DataArray]:
200
+ """
201
+ Analytical expression of solar azimuth and elevation angles based on spherical
202
+ trigonometry
203
+
204
+ Parameters
205
+ ----------
206
+ times: xarray.DataArray, 1D
207
+ contains timestamps to be checked in UTC
208
+ lat: xarray.DataArray 1D or 2D
209
+ contains latitudes of the points to be checked
210
+ lon: xarray.DataArray 1D or 2D
211
+ contains longitudes of the points to be checked
212
+ refrac_cor: bool
213
+ whether to account for atmospheric refraction. It is significant only when
214
+ the sun is close to horizon, in which case atmospheric refraction correction
215
+ is about 0.6 [degrees]. Default is True
216
+
217
+ Returns
218
+ -------
219
+ azim : xarray.DataArray 2D or 3D
220
+ azimuth in [degrees] for each time/point couple
221
+ elev : xarray.DataArray 2D or 3D
222
+ elevation in [degrees] for each time/point couple
223
+
224
+ References
225
+ ----------
226
+ * J. A. Duffie and W. A. Beckman, "Solar Engineering of Thermal
227
+ Processes, 3rd Edition" pp. 14, J. Wiley and Sons, New York (2006)
228
+
229
+ * J. H. Seinfeld and S. N. Pandis, "Atmospheric Chemistry and Physics"
230
+ p. 132, J. Wiley (1998)
231
+
232
+ * `Wikipedia: Solar Azimuth Angle
233
+ <https://en.wikipedia.org/wiki/Solar_azimuth_angle>`_
234
+
235
+ * `PVCDROM: Azimuth Angle <http://www.pveducation.org/pvcdrom/2-
236
+ properties-sunlight/azimuth-angle>`_
237
+
238
+ See Also
239
+ --------
240
+ :func:`elevation`, :func:`zenith`
241
+ """
242
+
243
+ decl, lat_rad, h_angle = params_zenith(times, lat, lon)
244
+ zen = _zenith(decl, lat_rad, h_angle)
245
+
246
+ numer = np.cos(zen) * np.sin(lat_rad) - np.sin(decl)
247
+ denom = np.sin(zen) * np.cos(lat_rad)
248
+
249
+ # cases that would generate new NaN values are safely ignored here
250
+ # since they are dealt with further below
251
+ with np.errstate(invalid="ignore", divide="ignore"):
252
+ cos_azi = numer / denom
253
+
254
+ # when zero division occurs, use the limit value of the analytical
255
+ # expression
256
+ cos_azi = cos_azi.where(~np.isclose(denom, 0.0, rtol=0.0, atol=1e-8), 1.0)
257
+ cos_azi = cos_azi.clip(-1.0, 1.0)
258
+
259
+ azim = np.sign(h_angle) * np.arccos(cos_azi) * (180.0 / np.pi) + 180.0
260
+ elev = 90 - zen * (180.0 / np.pi)
261
+ if refrac_cor:
262
+ elev += _atmo_refraction_corr(elev)
263
+
264
+ return azim, elev
265
+
266
+
267
+ def rise_set(
268
+ times: xr.DataArray, lat: xr.DataArray, lon: xr.DataArray, refrac_cor: bool = True
269
+ ) -> tuple[xr.DataArray, xr.DataArray]:
270
+ """
271
+ Geometric calculation of solar sunrise and sunset times
272
+
273
+ .. warning:: The geometric calculation assumes a circular earth orbit with
274
+ the sun as a point source at its center, and neglects the effect of
275
+ atmospheric refraction on zenith. The error depends on location and
276
+ time of year but is of order 10 minutes.
277
+
278
+ Parameters
279
+ ----------
280
+ times: xarray.DataArray, 1D
281
+ contains timestamps to be checked in UTC
282
+ lat: xarray.DataArray 1D or 2D
283
+ contains latitudes of the points to be checked
284
+ lon: xarray.DataArray 1D or 2D
285
+ contains longitudes of the points to be checked
286
+ refrac_cor: bool
287
+ whether to account for atmospheric refraction. When true, the sunrise / sunset
288
+ occurs about 4 minutes earlier / later. Default is True
289
+
290
+ Returns
291
+ -------
292
+ sunrise : xarray.DataArray 2D or 3D
293
+ sunrise times for each time/point couple in UTC
294
+ sunset : xarray.DataArray 2D or 3D
295
+ sunset times for each time/point couple in UTC
296
+
297
+ References
298
+ ----------
299
+ * J. A. Duffie and W. A. Beckman, "Solar Engineering of Thermal
300
+ Processes, 3rd Edition," J. Wiley and Sons, New York (2006)
301
+
302
+ * Frank Vignola et al., "Solar And Infrared Radiation Measurements,"
303
+ CRC Press (2012)
304
+ """
305
+
306
+ times_floor, ind_inv = np.unique(times.dt.floor("D").values, return_inverse=True)
307
+ times_floor = xr.DataArray(times_floor, dims=("dayofyear",))
308
+ ind_inv = xr.DataArray(ind_inv, coords=times.coords)
309
+ dayofyear = times_floor.dt.dayofyear
310
+
311
+ lat_rad = lat * (np.pi / 180.0)
312
+ decl = _declination(dayofyear)
313
+ eot = _equation_of_time(dayofyear)
314
+
315
+ cos_angle = -np.tan(decl) * np.tan(lat_rad)
316
+ if refrac_cor:
317
+ zen_cor = 90.574 * (np.pi / 180.0) # true zenith when apparent zenith = 0
318
+ cos_angle += np.cos(zen_cor) / (np.cos(decl) * np.cos(lat_rad))
319
+
320
+ # numpy ufuncs applied to a DataArray return a DataArray, which numpy type stubs do not know
321
+ sunset_angle = cast(xr.DataArray, np.arccos(cos_angle)) * (180.0 / np.pi)
322
+
323
+ sunrise = _angle_to_timedelta(-sunset_angle, lon, eot) + times_floor
324
+ sunset = _angle_to_timedelta(sunset_angle, lon, eot) + times_floor
325
+
326
+ return sunrise[{"dayofyear": ind_inv}], sunset[{"dayofyear": ind_inv}]
327
+
328
+
329
+ def _angle_to_timedelta(angle: xr.DataArray, lon: xr.DataArray, eot: xr.DataArray) -> xr.DataArray:
330
+ """converts angles in [degrees] to np.timedelta64"""
331
+
332
+ return ((angle - (lon - 180.0) - eot / 4.0) * (3.6e12 / 15.0)).astype(np.dtype("timedelta64[ns]"))
333
+
334
+
335
+ def daytime(times: xr.DataArray, lat: xr.DataArray, lon: xr.DataArray, refrac_cor: bool = True) -> xr.DataArray:
336
+ """
337
+ Return a boolean xarray.DataArray specifying whether times are daytime for
338
+ each point defined by the lat and lon arrays. Daytime is defined here as the
339
+ center of the sun being above the non-topographic flat horizon (i.e. sun elevation > 0)
340
+
341
+ Parameters
342
+ ----------
343
+ times: xarray.DataArray, 1D
344
+ contains timestamps to be checked in UTC
345
+ lat: xarray.DataArray 1D or 2D
346
+ contains latitudes of the points to be checked
347
+ lon: xarray.DataArray 1D or 2D
348
+ contains longitudes of the points to be checked
349
+ refrac_cor: bool
350
+ whether to account for atmospheric refraction. When true, the sunrise / sunset
351
+ occurs about 4 minutes earlier / later. Default is True
352
+
353
+ Returns
354
+ -------
355
+ da_out : xarray.DataArray of bool
356
+ indicates for each time/point couple whether it is daytime
357
+
358
+ See also
359
+ --------
360
+ :func:`rise_set`
361
+ """
362
+
363
+ day = xr.DataArray(np.unique(times.dt.floor("D").values), dims=("dayofyear",))
364
+ sunrise, sunset = rise_set(day.assign_coords({"dayofyear": day.dt.dayofyear.values}), lat, lon, refrac_cor)
365
+
366
+ isday = xr.apply_ufunc(
367
+ lambda x, r, s: np.logical_and(x > r, x < s),
368
+ times.groupby(times.dt.dayofyear),
369
+ sunrise,
370
+ sunset,
371
+ ).drop_vars("dayofyear")
372
+ isday.name = None # recent xarray versions keep the name of `times`
373
+ return isday
374
+
375
+
376
+ def topo_rise_set(times: xr.DataArray, lat: xr.DataArray, lon: xr.DataArray, horizon: xr.DataArray) -> xr.Dataset:
377
+ """
378
+ Computes topographic sunrise and sunset based on horizon data. Sunrise / sunset
379
+ is defined as the first / last time the sun path cross the horizon line
380
+
381
+ Parameters
382
+ ----------
383
+ times: xarray.DataArray, 1D
384
+ contains timestamps to be checked in UTC. Must be regularly sampled and
385
+ cover full daytime periods
386
+ lat: xarray.DataArray 1D or 2D
387
+ contains latitudes of the points to be checked
388
+ lon: xarray.DataArray 1D or 2D
389
+ contains longitudes of the points to be checked
390
+ horizon : xarray.DataArray 2D or 3D
391
+ topographic horizon in [degrees] of the points to be checked, regularly
392
+ sampled along a dimension `azimuth`
393
+
394
+ Returns
395
+ -------
396
+ ds : xarray.Dataset 2D or 3D
397
+ contains topographic sunrise and sunset in UTC for each point and day of
398
+ year in input ``times``
399
+
400
+ See Also
401
+ --------
402
+ :func:`azim_elev`, :func:`rise_set`, :func:`daytime`
403
+
404
+ Note
405
+ ----
406
+ The higher is the time frequency of ``times``, the more accurate will be the
407
+ sunrise and sunset times as it is interpolated between the two time points
408
+ when the sun cross the topographic horizon.
409
+ """
410
+
411
+ horizon = horizon.transpose("azimuth", ...)
412
+ azim, elev = azim_elev(times, lat, lon, refrac_cor=True)
413
+ horizon = horizon.sel(azimuth=azim, method="nearest").drop_vars("azimuth")
414
+ shadow_mask = elev > horizon # False when shadow, True when sun
415
+ ds = xr.Dataset({"elev": elev, "hori": horizon, "shadow": shadow_mask})
416
+
417
+ t_dim = get_validtime(times, raise_notfound=True)
418
+ return ds.groupby(f"{t_dim}.dayofyear").map(_topo_suntimes, args=(t_dim,))
419
+
420
+
421
+ def _topo_suntimes(ds: xr.Dataset, t_dim: str) -> xr.Dataset:
422
+ """
423
+ Return a dataset with topographic sunrise and sunset given a input dataset
424
+ with sun elevation, topographic horizon and shadow mask data variables.
425
+ """
426
+
427
+ def _suntime(mode: str) -> xr.DataArray:
428
+ """
429
+ Return topographic sunrise/sunset if mode is "rise"/"set" respectively.
430
+ """
431
+
432
+ if mode == "rise":
433
+ first_after = ds.shadow.argmax(t_dim)
434
+ first_after = first_after.where(has_sun, 1)
435
+ else: # sunset
436
+ n_t = ds.sizes[t_dim]
437
+ first_after = n_t - ds.shadow.isel({t_dim: slice(None, None, -1)}).argmax(t_dim)
438
+ first_after = first_after.where(has_sun, n_t - 1)
439
+
440
+ indexer = xr.concat([first_after - 1, first_after], t_dim)
441
+ switch = ds[["elev", "hori"]].isel({t_dim: indexer})
442
+ diff = switch.diff(t_dim).isel({t_dim: 0})
443
+ dt = switch[t_dim].diff(t_dim).isel({t_dim: 0})
444
+ switch = switch.isel({t_dim: 0})
445
+
446
+ return switch[t_dim] + dt * (switch.hori - switch.elev) / (diff.elev - diff.hori)
447
+
448
+ has_sun = ds.shadow.any(t_dim)
449
+
450
+ return xr.Dataset({"sunrise": _suntime("rise"), "sunset": _suntime("set")}).where(has_sun)
451
+
452
+
453
+ def topo_shading_cor(
454
+ times: xr.DataArray,
455
+ lat: xr.DataArray,
456
+ lon: xr.DataArray,
457
+ horizon: xr.DataArray,
458
+ sampling_freq: str = "10min",
459
+ ) -> xr.DataArray:
460
+ """
461
+ Computes topographic shading correction factor for variables
462
+
463
+ Parameters
464
+ ----------
465
+ times: xarray.DataArray, 1D
466
+ contains timestamps for which correction factor are computed. Must have
467
+ constant time granularity and be in UTC
468
+ lat: xarray.DataArray 1D or 2D
469
+ contains latitudes of the points to be checked
470
+ lon: xarray.DataArray 1D or 2D
471
+ contains longitudes of the points to be checked
472
+ horizon : xarray.DataArray 2D or 3D
473
+ topographic horizon in [degrees] of the points to be checked, regularly
474
+ sampled along a dimension `azimuth`
475
+ sampling_freq : frequency string as accepted by pandas date functionalities
476
+ time frequency to compare sun elevation with topographic horizon. The
477
+ higher the more precise will be the correction factors but at the cost
478
+ of longer computation time. Should be at least higher than the frequency
479
+ of the input ``times`` array
480
+
481
+ Returns
482
+ -------
483
+ da_out : xarray.DataArray 2D or 3D
484
+ contains topographic correction factors for each timestamps in input arg
485
+ ``times``
486
+
487
+ See Also
488
+ --------
489
+ :func:`azim_elev`, :func:`topo_rise_set`, :func:`daytime`
490
+ """
491
+
492
+ t_dim = get_validtime(times, raise_notfound=True)
493
+ out_freq = offsets.Second(times[:2].diff(t_dim).values[0] / Timedelta("1s")).freqstr
494
+ sample_t = date_range(start=times[0].values, end=times[-1].values, freq=sampling_freq)
495
+ sample_t = xr.DataArray(sample_t, coords={t_dim: sample_t})
496
+ azim, elev = azim_elev(sample_t, lat, lon, refrac_cor=True)
497
+ horizon = horizon.sel(azimuth=azim, method="nearest").drop_vars("azimuth")
498
+ shadow_mask = elev > horizon # False when shadow, True when sun
499
+ return (
500
+ shadow_mask.resample({t_dim: out_freq}, label="right", closed="right").sum(skipna=False)
501
+ * (Timedelta(sampling_freq) / Timedelta(out_freq))
502
+ ).astype(np.float32)
@@ -0,0 +1,110 @@
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 wrap some time utility functions
9
+ """
10
+
11
+ import logging
12
+ from functools import partial
13
+
14
+ from numpy import ndarray
15
+ from pandas import to_datetime, to_timedelta, DatetimeIndex
16
+ import xarray as xr
17
+
18
+ from grid_process.helpers import make_accessor
19
+ from grid_process.data_model import get_validtime
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ # Register xarray accessor so module functions can also be called as methods
25
+ # on xr.Dataset objects
26
+ @xr.register_dataset_accessor("time_utils")
27
+ class TimeUtils:
28
+ """
29
+ Xarray accessors class for time utility methods
30
+ """
31
+
32
+ def __init__(self, ds: xr.Dataset):
33
+ self._data = ds
34
+
35
+
36
+ dataset_accessor = partial(make_accessor, accessor_class=TimeUtils)
37
+
38
+
39
+ def to_datetime_tzaware(timestamps: ndarray) -> DatetimeIndex:
40
+ """
41
+ Converts an array of numpy.datetime64 objects to a timezone-aware
42
+ pandas.DatetimeIndex representing time in UTC.
43
+
44
+ Parameters
45
+ ----------
46
+ timestamps : array of naive numpy.datetime64
47
+ assumed to be in UTC time
48
+
49
+ Returns
50
+ --------
51
+ out : pandas.DatetimeIndex
52
+ UTC timezone aware
53
+ """
54
+
55
+ return to_datetime(timestamps).tz_localize("UTC")
56
+
57
+
58
+ @dataset_accessor
59
+ def set_daily(ds: xr.Dataset, lt_duplicates: bool = True, lt_hr: int = 0) -> xr.Dataset:
60
+ """
61
+ Sets timestamps of daily aggregations at the start of the day (i.e. 00:00).
62
+ Daily parameters are stored in the DWH following this convention. If
63
+ ``lt_duplicates=True``, selects correct validtimes between summer and
64
+ winter local time duplicates first, otherwise assume daily aggregation are
65
+ for UTC periods.
66
+
67
+ Parameters
68
+ ----------
69
+ ds : xarray.Dataset
70
+ with a valid time dimension (numpy.datetime64). When ``lt_duplicates=True``,
71
+ duplicated timestamps corresponding to summer (UTC+2) and winter
72
+ (UTC+1) localtime periods are expected as for a dataset returned by
73
+ :func:`~grid_process.aggreg.interval_stat` with arg ``lt_duplicates``
74
+ set to True
75
+ lt_duplicates : bool
76
+ whether the dataset contains duplicated timestamps corresponding
77
+ to summer (UTC+2) and winter (UTC+1) localtime periods. Default is True
78
+ lt_hr : int
79
+ hour of the day representing the end of the daily period in localtime.
80
+ Only used when ``lt_duplicates=True``. Default is 0, meaning the end
81
+ of the period is 00:00 J+1 LT
82
+
83
+ Returns
84
+ -------
85
+ ds_out : xarray.Dataset
86
+ dataset where daily timestamps are set to 00:00 of the respective
87
+ day. Optionally, selection of correct timestamps between summer/winter
88
+ duplicates based on day of year is done
89
+
90
+ See also
91
+ --------
92
+ :func:`~grid_process.aggreg.interval_stat`
93
+ """
94
+
95
+ validtime = get_validtime(ds, raise_notfound=True)
96
+ if lt_duplicates:
97
+ lt_dates = to_datetime_tzaware(ds[validtime].values).tz_convert("Europe/Zurich")
98
+
99
+ ds_out = ds.isel({validtime: (lt_dates.hour == lt_hr)})
100
+ return ds_out.assign_coords({validtime: ds_out[validtime].dt.floor("D")})
101
+
102
+ return ds.assign_coords(
103
+ {
104
+ validtime: xr.where(
105
+ ds[validtime].dt.hour == 0,
106
+ ds[validtime] - to_timedelta("24h"),
107
+ ds[validtime].dt.floor("D"),
108
+ )
109
+ }
110
+ )