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