#!/usr/bin/env python3
#
# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab
#########################################################################
# Copyright 2011-2014 Marcus Popp marcus@popp.mx
# Copyright 2021-2022 Bernd Meiners Bernd.Meiners@mail.de
#########################################################################
# This file is part of SmartHomeNG. https://github.com/smarthomeNG//
#
# SmartHomeNG is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# SmartHomeNG is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with SmartHomeNG. If not, see <http://www.gnu.org/licenses/>.
##########################################################################
import logging
import datetime
import math
logger = logging.getLogger(__name__)
try:
import ephem
except ImportError as e:
ephem = None # noqa
import dateutil.relativedelta
from dateutil.tz import tzutc
"""
This library contains a class Orb for calculating sun or moon related events.
Currently it uses ephem for calculation of the sky bound events.
"""
[Doku]class Orb():
"""
Save an observers location and the name of a celestial body for future use
The Methods internally use PyEphem for computation
An `Observer` instance allows to compute the positions of
celestial bodies as seen from a particular position on the Earth's surface.
Following attributes can be set after creation (used defaults are given):
`date` - the moment the `Observer` is created
`lat` - zero degrees latitude
`lon` - zero degrees longitude
`elevation` - 0 meters above sea level
`horizon` - 0 degrees
`epoch` - J2000
`temp` - 15 degrees Celsius
`pressure` - 1010 mBar
"""
def __init__(self, orb, lon, lat, elev=False):
"""
Save location and celestial body
:param orb: either 'sun' or 'moon'
:param lon: longitude of observer in degrees
:param lat: latitude of observer in degrees
:param elev: elevation of observer in meters
"""
if ephem is None:
logger.warning("Could not find/use ephem!")
return
self.orb = orb
self.lat = lat
self.lon = lon
self.elev = elev
[Doku] def get_observer_and_orb(self):
"""
Return a tuple of an instance of an observer with location information
and a celestial body
Both returned objects are uniquely created to prevent errors in computation
See also this thread at `Stackoverflow <https://stackoverflow.com/questions/26428904/pyephem-advances-observer-date-on-neveruperror>`_
dated back to 2015 where the creator of pyephem writes:
> Second answer: As long as each thread has its own Moon and Observer objects,
it should be able to do its own computations without ruining those of any other threads.
:return: tuple of observer and celestial body
"""
observer = ephem.Observer()
# ephem expects lat and lon as strings
observer.long = str(self.lon)
observer.lat = str(self.lat)
if self.elev:
observer.elevation = float(self.elev)
if self.orb == 'sun':
orb = ephem.Sun()
elif self.orb == 'moon':
orb = ephem.Moon()
self.phase = self._phase
self.light = self._light
return observer,orb
def _avoid_neverup(self, dt, date_utc, doff):
"""
When specifying an offset for e.g. a sunset or a sunrise it might well be that the
offset is too high to be ever reached for a specific location and time
Therefore this function will limit this offset and return it to the calling function
:param dt: starting point for calculation
:type dt: datetime
:param date_utc: a datetime with utc time
:type date_utc: datetime
:param doff: offset in degrees
:type doff: float
:return: corrected offset in degrees
:rtype: float
"""
originaldoff = doff
# Get times for noon and midnight
midnight = self.midnight(0, 0, dt=dt)
noon = self.noon(0, 0, dt=dt)
# If the altitudes are calculated from previous or next day, set the correct day for the observer query
noon = noon if noon >= date_utc else \
self.noon(0, 0, dt=date_utc + dateutil.relativedelta.relativedelta(days=1))
midnight = midnight if midnight >= date_utc else \
self.midnight(0, 0, dt=date_utc - dateutil.relativedelta.relativedelta(days=1))
# Get lowest and highest altitudes of the relevant day/night
max_altitude = self.pos(offset=None, degree=True, dt=midnight)[1] if doff <= 0 else \
self.pos(offset=None, degree=True, dt=noon)[1]
# Limit degree offset to the highest or lowest possible for the given date
doff = max(doff, max_altitude + 0.00001) if doff < 0 else min(doff, max_altitude - 0.00001) if doff > 0 else doff
if not originaldoff == doff:
logger.notice(f"offset {originaldoff} truncated to {doff}")
return doff
[Doku] def noon(self, doff=0, moff=0, dt=None):
observer, orb = self.get_observer_and_orb()
if dt is not None:
observer.date = dt - dt.utcoffset() - dateutil.relativedelta.relativedelta(minutes=moff)
date_utc = (observer.date.datetime()).replace(tzinfo=tzutc())
else:
observer.date = datetime.datetime.utcnow() - dateutil.relativedelta.relativedelta(minutes=moff) + dateutil.relativedelta.relativedelta(seconds=2)
date_utc = (observer.date.datetime()).replace(tzinfo=tzutc())
if not doff == 0:
doff = self._avoid_neverup(dt, date_utc, doff)
observer.horizon = str(doff)
next_transit = observer.next_transit(orb).datetime()
next_transit = next_transit + dateutil.relativedelta.relativedelta(minutes=moff)
next_transit = next_transit.replace(tzinfo=tzutc())
logger.debug(f"ephem: noon for {self.orb} with doff={doff}, moff={moff}, dt={dt} will be {next_transit}")
return next_transit
[Doku] def midnight(self, doff=0, moff=0, dt=None):
observer, orb = self.get_observer_and_orb()
if dt is not None:
observer.date = dt - dt.utcoffset() - dateutil.relativedelta.relativedelta(minutes=moff)
date_utc = (observer.date.datetime()).replace(tzinfo=tzutc())
else:
observer.date = datetime.datetime.utcnow() - dateutil.relativedelta.relativedelta(minutes=moff) + dateutil.relativedelta.relativedelta(seconds=2)
date_utc = (observer.date.datetime()).replace(tzinfo=tzutc())
if not doff == 0:
doff = self._avoid_neverup(dt, date_utc, doff)
observer.horizon = str(doff)
next_antitransit = observer.next_antitransit(orb).datetime()
next_antitransit = next_antitransit + dateutil.relativedelta.relativedelta(minutes=moff)
next_antitransit = next_antitransit.replace(tzinfo=tzutc())
logger.debug(f"ephem: midnight for {self.orb} with doff={doff}, moff={moff}, dt={dt} will be {next_antitransit}")
return next_antitransit
[Doku] def rise(self, doff=0, moff=0, center=True, dt=None):
"""
Computes the rise of either sun or moon
:param doff: degrees offset for the observers horizon
:param moff: minutes offset from time of rise (either before or after)
:param center: if True then the centerpoint of either sun or moon will be considered to make the transit otherwise the upper limb will be considered
:param dt: start time for the search for a rise, if not given the current time will be used
:return:
"""
observer, orb = self.get_observer_and_orb()
# workaround if rise is 0.001 seconds in the past
if dt is not None:
observer.date = dt - dt.utcoffset() - dateutil.relativedelta.relativedelta(minutes=moff)
date_utc = (observer.date.datetime()).replace(tzinfo=tzutc())
else:
observer.date = datetime.datetime.utcnow() - dateutil.relativedelta.relativedelta(minutes=moff) + dateutil.relativedelta.relativedelta(seconds=2)
date_utc = (observer.date.datetime()).replace(tzinfo=tzutc())
if not doff == 0:
doff = self._avoid_neverup(dt, date_utc, doff)
observer.horizon = str(doff)
if not doff == 0:
next_rising = observer.next_rising(orb, use_center=center).datetime()
else:
next_rising = observer.next_rising(orb).datetime()
next_rising = next_rising + dateutil.relativedelta.relativedelta(minutes=moff)
next_rising = next_rising.replace(tzinfo=tzutc())
logger.debug(f"ephem: next_rising for {self.orb} with doff={doff}, moff={moff}, center={center}, dt={dt} will be {next_rising}")
return next_rising
[Doku] def set(self, doff=0, moff=0, center=True, dt=None):
"""
Computes the setting of either sun or moon
:param doff: degrees offset for the observers horizon
:param moff: minutes offset from time of setting (either before or after)
:param center: if True then the centerpoint of either sun or moon will be considered to make the transit otherwise the upper limb will be considered
:param dt: start time for the search for a setting, if not given the current time will be used
:return:
"""
observer, orb = self.get_observer_and_orb()
# workaround if set is 0.001 seconds in the past
if dt is not None:
observer.date = dt - dt.utcoffset() - dateutil.relativedelta.relativedelta(minutes=moff)
date_utc = (observer.date.datetime()).replace(tzinfo=tzutc())
else:
observer.date = datetime.datetime.utcnow() - dateutil.relativedelta.relativedelta(minutes=moff) + dateutil.relativedelta.relativedelta(seconds=2)
date_utc = (observer.date.datetime()).replace(tzinfo=tzutc())
# avoid NeverUp error
if not doff == 0:
doff = self._avoid_neverup(dt, date_utc, doff)
observer.horizon = str(doff)
if not doff == 0:
next_setting = observer.next_setting(orb, use_center=center).datetime()
else:
next_setting = observer.next_setting(orb).datetime()
next_setting = next_setting + dateutil.relativedelta.relativedelta(minutes=moff)
next_setting = next_setting.replace(tzinfo=tzutc())
logger.debug(f"ephem: next_setting for {self.orb} with doff={doff}, moff={moff}, center={center}, dt={dt} will be {next_setting}")
return next_setting
[Doku] def pos(self, offset=None, degree=False, dt=None):
"""
Calculates the position of either sun or moon
:param offset: given in minutes, shifts the time of calculation by some minutes back or forth
:param degree: if True: return the position of either sun or moon from the observer as degrees, otherwise as radians
:param dt: time for which the position needs to be calculated
:return: a tuple with azimuth and elevation
"""
observer, orb = self.get_observer_and_orb()
if dt is None:
date = datetime.datetime.utcnow()
else:
date = dt.replace(tzinfo=tzutc())
if offset:
date += dateutil.relativedelta.relativedelta(minutes=offset)
observer.date = date
orb.compute(observer)
if degree:
return (math.degrees(orb.az), math.degrees(orb.alt))
else:
return (orb.az, orb.alt)
def _light(self, offset=None):
"""
Applies only for moon, returns fraction of lunar surface illuminated when viewed from earth
for the current time plus an offset
:param offset: an offset given in minutes
"""
observer, orb = self.get_observer_and_orb()
date = datetime.datetime.utcnow()
if offset:
date += dateutil.relativedelta.relativedelta(minutes=offset)
observer.date = date
orb.compute(observer)
light = int(round(orb.moon_phase * 100))
return light
def _phase(self, offset=None):
"""
Applies only for moon, returns the moon phase related to a cycle of approx. 29.5 days
for the current time plus an offset
:param offset: an offset given in minutes
"""
observer, orb = self.get_observer_and_orb()
date = datetime.datetime.utcnow()
cycle = 29.530588861
if offset:
date += dateutil.relativedelta.relativedelta(minutes=offset)
observer.date = date
orb.compute(observer)
last = ephem.previous_new_moon(observer.date)
frac = (observer.date - last) / cycle
phase = int(round(frac * 8))
return phase