Source code for pyedautils.data_prep.sun
# -*- coding: utf-8 -*-
"""Solar geometry helpers (sun elevation and azimuth) based on PyEphem."""
import math
from datetime import datetime
from typing import Tuple, Union
import ephem
import pandas as pd
__all__ = ["sun_position"]
# Standard sea-level pressure in mbar/hPa. ephem uses this to model atmospheric
# refraction; setting the observer pressure to 0 disables refraction and yields
# the true geometric position.
_STANDARD_PRESSURE = 1013.25
def _make_observer(latitude: float, longitude: float, altitude: float, refraction: bool) -> ephem.Observer:
obs = ephem.Observer()
# ephem interprets float angles as radians but strings as degrees, so pass
# the coordinates as strings to keep them in decimal degrees.
obs.lat = str(float(latitude))
obs.lon = str(float(longitude))
obs.elevation = float(altitude)
obs.pressure = _STANDARD_PRESSURE if refraction else 0
return obs
def _to_utc_naive_index(timestamp) -> pd.DatetimeIndex:
"""Normalise any datetime-like input to a timezone-naive UTC DatetimeIndex.
Timezone-aware values are converted to UTC; naive values are assumed to
already be UTC (ephem expects UTC).
"""
dti = pd.DatetimeIndex(pd.to_datetime(timestamp))
if dti.tz is not None:
dti = dti.tz_convert("UTC").tz_localize(None)
return dti
[docs]
def sun_position(
timestamp,
latitude: float,
longitude: float,
*,
altitude: float = 0.0,
refraction: bool = True,
) -> Union[Tuple[float, float], pd.DataFrame]:
"""Solar elevation and azimuth for the given time(s) and location.
Args:
timestamp: A single datetime/``pandas.Timestamp`` or a collection of
timestamps (``pandas.Series``, ``DatetimeIndex``, list or array).
Timezone-aware values are converted to UTC; naive values are
assumed to be UTC.
latitude: Observer latitude in decimal degrees (north positive).
longitude: Observer longitude in decimal degrees (east positive).
altitude: Observer height above sea level in meters. Default 0.
refraction: If True (default), return the apparent position including
atmospheric refraction. If False, return the true geometric
position (no refraction).
Returns:
For scalar input: a ``(elevation, azimuth)`` tuple in degrees.
For a collection: a ``pandas.DataFrame`` with columns ``elevation`` and
``azimuth`` (degrees), indexed like the input (a ``Series`` keeps its
index, otherwise a UTC-naive ``DatetimeIndex`` is used).
Elevation is the angle above the horizon (negative when the sun is
below the horizon). Azimuth is measured clockwise from geographic
north (0=N, 90=E, 180=S, 270=W).
"""
obs = _make_observer(latitude, longitude, altitude, refraction)
sun = ephem.Sun()
# Scalar input -> return a single (elevation, azimuth) tuple.
if isinstance(timestamp, (datetime, pd.Timestamp, str)):
ts = pd.Timestamp(timestamp)
if ts.tz is not None:
ts = ts.tz_convert("UTC").tz_localize(None)
obs.date = ts.to_pydatetime()
sun.compute(obs)
return math.degrees(float(sun.alt)), math.degrees(float(sun.az))
# Collection input -> compute per timestamp and return a DataFrame.
# Keep the caller's original index/labels; only the values fed to ephem are
# normalised to UTC.
if isinstance(timestamp, pd.Series):
out_index = timestamp.index
elif isinstance(timestamp, pd.DatetimeIndex):
out_index = timestamp
else:
out_index = None
dti = _to_utc_naive_index(timestamp)
if out_index is None:
out_index = dti
elevations = []
azimuths = []
for dt in dti.to_pydatetime():
obs.date = dt
sun.compute(obs)
elevations.append(math.degrees(float(sun.alt)))
azimuths.append(math.degrees(float(sun.az)))
return pd.DataFrame(
{"elevation": elevations, "azimuth": azimuths},
index=out_index,
)