Source code for grid_process.sun

# Copyright (c) 2026 MeteoSwiss, contributors listed in AUTHORS
#
# Distributed under the terms of the BSD 3-Clause License.
#
# SPDX-License-Identifier: BSD-3-Clause

"""
Module to wrap some sun position utility functions
"""

import logging
from typing import cast

import numpy as np
from pandas import date_range, Timedelta, offsets
import xarray as xr

from grid_process.data_model import get_validtime

logger = logging.getLogger(__name__)


[docs] def zenith(times: xr.DataArray, lat: xr.DataArray, lon: xr.DataArray, refrac_cor: bool = True) -> xr.DataArray: """ Analytical expression of solar zenith angle based on spherical trigonometry. Parameters ---------- times: xarray.DataArray, 1D contains timestamps to be checked in UTC lat: xarray.DataArray 1D or 2D contains latitudes of the points to be checked lon: xarray.DataArray 1D or 2D contains longitudes of the points to be checked refrac_cor: bool whether to account for atmospheric refraction. It is significant only when the sun is close to horizon, in which case atmospheric refraction correction is about 0.6 [degrees]. Default is True Returns ------- zen : xarray.DataArray 2D or 3D zenith in [degrees] for each time/point couple References ---------- * J. A. Duffie and W. A. Beckman, "Solar Engineering of Thermal Processes, 3rd Edition" pp. 14, J. Wiley and Sons, New York (2006) * J. H. Seinfeld and S. N. Pandis, "Atmospheric Chemistry and Physics" p. 132, J. Wiley (1998) * Daryl R. Myers, "Solar Radiation: Practical Modeling for Renewable Energy Applications", p. 5 CRC Press (2013) * `Wikipedia: Solar Zenith Angle <https://en.wikipedia.org/wiki/Solar_zenith_angle>`_ * `PVCDROM: Sun's Position <http://www.pveducation.org/pvcdrom/2-properties-sunlight/ suns-position>`_ See Also -------- :func:`azim_elev`, :func:`elevation` """ zen = _zenith(*params_zenith(times, lat, lon)) * (180.0 / np.pi) if refrac_cor: zen -= _atmo_refraction_corr(90 - zen) return zen
def _zenith(decl: xr.DataArray, lat_rad: xr.DataArray, h_angle: xr.DataArray) -> xr.DataArray: """ Compute the formula for the solar zenith angle. The returned angle is in radians """ return np.arccos(np.cos(decl) * np.cos(lat_rad) * np.cos(h_angle) + np.sin(decl) * np.sin(lat_rad))
[docs] def cos_zenith(times: xr.DataArray, lat: xr.DataArray, lon: xr.DataArray) -> xr.DataArray: """ Compute the cosine of the solar zenith angle. Atmospheric refraction is NOT considered """ decl, lat_rad, h_angle = params_zenith(times, lat, lon) return np.cos(decl) * np.cos(lat_rad) * np.cos(h_angle) + np.sin(decl) * np.sin(lat_rad)
[docs] def params_zenith( times: xr.DataArray, lat: xr.DataArray, lon: xr.DataArray ) -> tuple[xr.DataArray, xr.DataArray, xr.DataArray]: """ get the different parameters to compute zenith angle """ decl = _declination(times.dt.dayofyear) h_angle = hour_angle(times, lon) lat_rad = lat * (np.pi / 180.0) return decl, lat_rad, h_angle
def _declination(dayofyear: xr.DataArray) -> xr.DataArray: """ Solar declination from Duffie & Beckman and attributed to Spencer (1971) and Iqbal (1983). """ day_angle = (2.0 * np.pi / 365.0) * (dayofyear - 1) return ( 0.006918 # type: ignore[return-value] - 0.399912 * np.cos(day_angle) + 0.070257 * np.sin(day_angle) - 0.006758 * np.cos(2.0 * day_angle) + 0.000907 * np.sin(2.0 * day_angle) - 0.002697 * np.cos(3.0 * day_angle) + 0.00148 * np.sin(3.0 * day_angle) ) def _equation_of_time(dayofyear: xr.DataArray) -> xr.DataArray: """ Difference in time between solar time and mean solar time in minutes. From .. _PVCDROM: http://www.pveducation.org/pvcdrom/2-properties-sunlight/solar-time """ bday = (2.0 * np.pi / 365.0) * (dayofyear - 81) return 9.87 * np.sin(2.0 * bday) - 7.53 * np.cos(bday) - 1.5 * np.sin(bday) # type: ignore[return-value]
[docs] def hour_angle(times: xr.DataArray, lon: xr.DataArray) -> xr.DataArray: """ Hour angle [radians] in local solar time. Zero at local solar noon. Parameters ---------- times: xarray.DataArray, 1D contains timestamps in UTC for which to calculate the hour angle lon: xarray.DataArray 1D or 2D contains longitudes of the points to be checked """ hour = (times - times.dt.floor("D")) / np.timedelta64(1, "h") eot = _equation_of_time(times.dt.dayofyear) return (15.0 * hour + eot / 4.0 + (lon - 180.0)) * (np.pi / 180.0)
def _atmo_refraction_corr( elev: xr.DataArray | np.ndarray, ) -> xr.DataArray | np.ndarray: """atmospheric refraction correction [degrees]""" elev = elev.clip(-1.0, 90.0) return (1.02 / np.tan((elev + 10.3 / (elev + 5.11)) * (np.pi / 180.0)) + 0.0019279) / 60
[docs] def elevation(times: xr.DataArray, lat: xr.DataArray, lon: xr.DataArray, refrac_cor: bool = True) -> xr.DataArray: """ Analytical expression of solar elevation angle based on spherical trigonometry Parameters ---------- times: xarray.DataArray, 1D contains timestamps to be checked in UTC lat: xarray.DataArray 1D or 2D contains latitudes of the points to be checked lon: xarray.DataArray 1D or 2D contains longitudes of the points to be checked refrac_cor: bool whether to account for atmospheric refraction. It is significant only when the sun is close to horizon, in which case atmospheric refraction correction is about 0.6 [degrees]. Default is True Returns ------- elev : xarray.DataArray 2D or 3D elevation in [degrees] for each time/point couple See Also -------- :func:`azim_elev`, :func:`zenith` """ elev = 90.0 - zenith(times, lat, lon, refrac_cor=False) if refrac_cor: elev += _atmo_refraction_corr(elev) return elev
[docs] def azim_elev( times: xr.DataArray, lat: xr.DataArray, lon: xr.DataArray, refrac_cor: bool = True ) -> tuple[xr.DataArray, xr.DataArray]: """ Analytical expression of solar azimuth and elevation angles based on spherical trigonometry Parameters ---------- times: xarray.DataArray, 1D contains timestamps to be checked in UTC lat: xarray.DataArray 1D or 2D contains latitudes of the points to be checked lon: xarray.DataArray 1D or 2D contains longitudes of the points to be checked refrac_cor: bool whether to account for atmospheric refraction. It is significant only when the sun is close to horizon, in which case atmospheric refraction correction is about 0.6 [degrees]. Default is True Returns ------- azim : xarray.DataArray 2D or 3D azimuth in [degrees] for each time/point couple elev : xarray.DataArray 2D or 3D elevation in [degrees] for each time/point couple References ---------- * J. A. Duffie and W. A. Beckman, "Solar Engineering of Thermal Processes, 3rd Edition" pp. 14, J. Wiley and Sons, New York (2006) * J. H. Seinfeld and S. N. Pandis, "Atmospheric Chemistry and Physics" p. 132, J. Wiley (1998) * `Wikipedia: Solar Azimuth Angle <https://en.wikipedia.org/wiki/Solar_azimuth_angle>`_ * `PVCDROM: Azimuth Angle <http://www.pveducation.org/pvcdrom/2- properties-sunlight/azimuth-angle>`_ See Also -------- :func:`elevation`, :func:`zenith` """ decl, lat_rad, h_angle = params_zenith(times, lat, lon) zen = _zenith(decl, lat_rad, h_angle) numer = np.cos(zen) * np.sin(lat_rad) - np.sin(decl) denom = np.sin(zen) * np.cos(lat_rad) # cases that would generate new NaN values are safely ignored here # since they are dealt with further below with np.errstate(invalid="ignore", divide="ignore"): cos_azi = numer / denom # when zero division occurs, use the limit value of the analytical # expression cos_azi = cos_azi.where(~np.isclose(denom, 0.0, rtol=0.0, atol=1e-8), 1.0) cos_azi = cos_azi.clip(-1.0, 1.0) azim = np.sign(h_angle) * np.arccos(cos_azi) * (180.0 / np.pi) + 180.0 elev = 90 - zen * (180.0 / np.pi) if refrac_cor: elev += _atmo_refraction_corr(elev) return azim, elev
[docs] def rise_set( times: xr.DataArray, lat: xr.DataArray, lon: xr.DataArray, refrac_cor: bool = True ) -> tuple[xr.DataArray, xr.DataArray]: """ Geometric calculation of solar sunrise and sunset times .. warning:: The geometric calculation assumes a circular earth orbit with the sun as a point source at its center, and neglects the effect of atmospheric refraction on zenith. The error depends on location and time of year but is of order 10 minutes. Parameters ---------- times: xarray.DataArray, 1D contains timestamps to be checked in UTC lat: xarray.DataArray 1D or 2D contains latitudes of the points to be checked lon: xarray.DataArray 1D or 2D contains longitudes of the points to be checked refrac_cor: bool whether to account for atmospheric refraction. When true, the sunrise / sunset occurs about 4 minutes earlier / later. Default is True Returns ------- sunrise : xarray.DataArray 2D or 3D sunrise times for each time/point couple in UTC sunset : xarray.DataArray 2D or 3D sunset times for each time/point couple in UTC References ---------- * J. A. Duffie and W. A. Beckman, "Solar Engineering of Thermal Processes, 3rd Edition," J. Wiley and Sons, New York (2006) * Frank Vignola et al., "Solar And Infrared Radiation Measurements," CRC Press (2012) """ times_floor, ind_inv = np.unique(times.dt.floor("D").values, return_inverse=True) times_floor = xr.DataArray(times_floor, dims=("dayofyear",)) ind_inv = xr.DataArray(ind_inv, coords=times.coords) dayofyear = times_floor.dt.dayofyear lat_rad = lat * (np.pi / 180.0) decl = _declination(dayofyear) eot = _equation_of_time(dayofyear) cos_angle = -np.tan(decl) * np.tan(lat_rad) if refrac_cor: zen_cor = 90.574 * (np.pi / 180.0) # true zenith when apparent zenith = 0 cos_angle += np.cos(zen_cor) / (np.cos(decl) * np.cos(lat_rad)) # numpy ufuncs applied to a DataArray return a DataArray, which numpy type stubs do not know sunset_angle = cast(xr.DataArray, np.arccos(cos_angle)) * (180.0 / np.pi) sunrise = _angle_to_timedelta(-sunset_angle, lon, eot) + times_floor sunset = _angle_to_timedelta(sunset_angle, lon, eot) + times_floor return sunrise[{"dayofyear": ind_inv}], sunset[{"dayofyear": ind_inv}]
def _angle_to_timedelta(angle: xr.DataArray, lon: xr.DataArray, eot: xr.DataArray) -> xr.DataArray: """converts angles in [degrees] to np.timedelta64""" return ((angle - (lon - 180.0) - eot / 4.0) * (3.6e12 / 15.0)).astype(np.dtype("timedelta64[ns]"))
[docs] def daytime(times: xr.DataArray, lat: xr.DataArray, lon: xr.DataArray, refrac_cor: bool = True) -> xr.DataArray: """ Return a boolean xarray.DataArray specifying whether times are daytime for each point defined by the lat and lon arrays. Daytime is defined here as the center of the sun being above the non-topographic flat horizon (i.e. sun elevation > 0) Parameters ---------- times: xarray.DataArray, 1D contains timestamps to be checked in UTC lat: xarray.DataArray 1D or 2D contains latitudes of the points to be checked lon: xarray.DataArray 1D or 2D contains longitudes of the points to be checked refrac_cor: bool whether to account for atmospheric refraction. When true, the sunrise / sunset occurs about 4 minutes earlier / later. Default is True Returns ------- da_out : xarray.DataArray of bool indicates for each time/point couple whether it is daytime See also -------- :func:`rise_set` """ day = xr.DataArray(np.unique(times.dt.floor("D").values), dims=("dayofyear",)) sunrise, sunset = rise_set(day.assign_coords({"dayofyear": day.dt.dayofyear.values}), lat, lon, refrac_cor) isday = xr.apply_ufunc( lambda x, r, s: np.logical_and(x > r, x < s), times.groupby(times.dt.dayofyear), sunrise, sunset, ).drop_vars("dayofyear") isday.name = None # recent xarray versions keep the name of `times` return isday
[docs] def topo_rise_set(times: xr.DataArray, lat: xr.DataArray, lon: xr.DataArray, horizon: xr.DataArray) -> xr.Dataset: """ Computes topographic sunrise and sunset based on horizon data. Sunrise / sunset is defined as the first / last time the sun path cross the horizon line Parameters ---------- times: xarray.DataArray, 1D contains timestamps to be checked in UTC. Must be regularly sampled and cover full daytime periods lat: xarray.DataArray 1D or 2D contains latitudes of the points to be checked lon: xarray.DataArray 1D or 2D contains longitudes of the points to be checked horizon : xarray.DataArray 2D or 3D topographic horizon in [degrees] of the points to be checked, regularly sampled along a dimension `azimuth` Returns ------- ds : xarray.Dataset 2D or 3D contains topographic sunrise and sunset in UTC for each point and day of year in input ``times`` See Also -------- :func:`azim_elev`, :func:`rise_set`, :func:`daytime` Note ---- The higher is the time frequency of ``times``, the more accurate will be the sunrise and sunset times as it is interpolated between the two time points when the sun cross the topographic horizon. """ horizon = horizon.transpose("azimuth", ...) azim, elev = azim_elev(times, lat, lon, refrac_cor=True) horizon = horizon.sel(azimuth=azim, method="nearest").drop_vars("azimuth") shadow_mask = elev > horizon # False when shadow, True when sun ds = xr.Dataset({"elev": elev, "hori": horizon, "shadow": shadow_mask}) t_dim = get_validtime(times, raise_notfound=True) return ds.groupby(f"{t_dim}.dayofyear").map(_topo_suntimes, args=(t_dim,))
def _topo_suntimes(ds: xr.Dataset, t_dim: str) -> xr.Dataset: """ Return a dataset with topographic sunrise and sunset given a input dataset with sun elevation, topographic horizon and shadow mask data variables. """ def _suntime(mode: str) -> xr.DataArray: """ Return topographic sunrise/sunset if mode is "rise"/"set" respectively. """ if mode == "rise": first_after = ds.shadow.argmax(t_dim) first_after = first_after.where(has_sun, 1) else: # sunset n_t = ds.sizes[t_dim] first_after = n_t - ds.shadow.isel({t_dim: slice(None, None, -1)}).argmax(t_dim) first_after = first_after.where(has_sun, n_t - 1) indexer = xr.concat([first_after - 1, first_after], t_dim) switch = ds[["elev", "hori"]].isel({t_dim: indexer}) diff = switch.diff(t_dim).isel({t_dim: 0}) dt = switch[t_dim].diff(t_dim).isel({t_dim: 0}) switch = switch.isel({t_dim: 0}) return switch[t_dim] + dt * (switch.hori - switch.elev) / (diff.elev - diff.hori) has_sun = ds.shadow.any(t_dim) return xr.Dataset({"sunrise": _suntime("rise"), "sunset": _suntime("set")}).where(has_sun)
[docs] def topo_shading_cor( times: xr.DataArray, lat: xr.DataArray, lon: xr.DataArray, horizon: xr.DataArray, sampling_freq: str = "10min", ) -> xr.DataArray: """ Computes topographic shading correction factor for variables Parameters ---------- times: xarray.DataArray, 1D contains timestamps for which correction factor are computed. Must have constant time granularity and be in UTC lat: xarray.DataArray 1D or 2D contains latitudes of the points to be checked lon: xarray.DataArray 1D or 2D contains longitudes of the points to be checked horizon : xarray.DataArray 2D or 3D topographic horizon in [degrees] of the points to be checked, regularly sampled along a dimension `azimuth` sampling_freq : frequency string as accepted by pandas date functionalities time frequency to compare sun elevation with topographic horizon. The higher the more precise will be the correction factors but at the cost of longer computation time. Should be at least higher than the frequency of the input ``times`` array Returns ------- da_out : xarray.DataArray 2D or 3D contains topographic correction factors for each timestamps in input arg ``times`` See Also -------- :func:`azim_elev`, :func:`topo_rise_set`, :func:`daytime` """ t_dim = get_validtime(times, raise_notfound=True) out_freq = offsets.Second(times[:2].diff(t_dim).values[0] / Timedelta("1s")).freqstr sample_t = date_range(start=times[0].values, end=times[-1].values, freq=sampling_freq) sample_t = xr.DataArray(sample_t, coords={t_dim: sample_t}) azim, elev = azim_elev(sample_t, lat, lon, refrac_cor=True) horizon = horizon.sel(azimuth=azim, method="nearest").drop_vars("azimuth") shadow_mask = elev > horizon # False when shadow, True when sun return ( shadow_mask.resample({t_dim: out_freq}, label="right", closed="right").sum(skipna=False) * (Timedelta(sampling_freq) / Timedelta(out_freq)) ).astype(np.float32)