Quellcode für lib.orb

#!/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-2025 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
import dateutil.relativedelta

from dateutil.tz import tzutc

from lib.shtime import Shtime

logger = logging.getLogger(__name__)

try:
    import ephem
except ImportError:
    ephem = None  # noqa


"""
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 All calculations by ephem will be based on utc time. Changelog of pypehem: > Version 4.1.1 (2021 November 27) > When you provide PyEphem with a Python datetime that has a time zone attached, > PyEphem now detects the time zone and converts the date and time to UTC automatically. To prevent side effects by this behaviour every datetime object given to any function in class Orb will be converted to utc. In case the given datetime is - naive (no timezone attached) --> the current timezone of SmartHomeNG will be used and the datetime will be converted to utc - has a timezone other than utc --> dt will be converted to utc - has utc timezone --> dt will not be changed and has always an offset of 0:00 TODO: It can be that datetime conversion from and to utc is ambigous: Imagine October 27th, in 2024, at 2:30 in the night. dt = datetime(2024, 10, 27, 2, 30, tzinfo=berlin) This is ambigous because it could well be summertime having still utc+2 hours or wintertime with utc+1 hours This ambiguity is not handled right now """ def __init__(self, orb, lon, lat, elev=False, neverup_delta=0.00001): """ 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.shtime = Shtime.get_instance() self.orb = orb if orb != 'sun' and orb != 'moon': logger.error("neither 'sun' nor 'moon' given as parameter for creation of Orb object") self.lat = lat self.lon = lon self.elev = elev self.neverup_delta = None if self.orb == 'sun': self.neverup_delta = neverup_delta if not neverup_delta == 0.00001: logger.notice(f"neverup_delta was adjusted to {neverup_delta} for sun calculations") elif self.orb == 'moon': self.phase = self._phase self.light = self._light logger.debug(f"Orb object {orb=} created with location {lon=},{lat=},{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() logger.debug("'sun' object requested in function get_observer_and_orb()") elif self.orb == 'moon': orb = ephem.Moon() logger.debug("'moon' object requested in function get_observer_and_orb()") else: logger.error("neither 'sun' nor 'moon' requested in function get_observer_and_orb()") return observer, orb
[Doku] def unaware_datetime_to_utc(self, naive_dt): local_aware = naive_dt.astimezone() return local_aware.astimezone(datetime.timezone.utc)
[Doku] def aware_datetime_to_utc(self, aware_dt): return aware_dt.astimezone(datetime.timezone.utc)
[Doku] def utc_to_local(self, utc_dt): return utc_dt.astimezone()
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 + self.neverup_delta) if doff < 0 else min(doff, max_altitude - self.neverup_delta) 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): """ calculate the time of next transit starting with dt. If dt is None the the time of this function call will be used :param doff: degrees offset, defaults to 0 :type doff: float, optional :param moff: minutes offset, defaults to 0 :type moff: float, optional :param dt: datetime object to start calculation with, defaults to None :type dt: datetime, optional :return: datetime of next transit :rtype: datetime """ observer, orb = self.get_observer_and_orb() if dt is None: dt = self.shtime.utcnow() - dateutil.relativedelta.relativedelta(minutes=moff) + dateutil.relativedelta.relativedelta(seconds=2) observer.date = dt logger.debug(f"ephem: noon for {self.orb} with doff={doff}, moff={moff}, dt is None, using observer.date={observer.date}") else: if dt.tzinfo is None: # unaware datetime logger.debug(f"ephem: noon for {self.orb} with doff={doff}, moff={moff}, dt={dt}, dt is unaware of timezone, assuming local time") observer.date = self.unaware_datetime_to_utc(dt) - dateutil.relativedelta.relativedelta(minutes=moff) else: logger.debug(f"ephem: noon for {self.orb} with doff={doff}, moff={moff}, dt={dt}, dt timezone is {dt.tzinfo}") observer.date = self.aware_datetime_to_utc(dt) - dateutil.relativedelta.relativedelta(minutes=moff) # observer.date.datetime will be utc but it might be without tzinfo date_utc = (observer.date.datetime()).replace(tzinfo=tzutc()) # attention: _avoid_neverup itself calls noon(), this might well get circular in some circumstances 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): """ Calculate the time of next antitransit starting with dt. If dt is None the the time of this function call will be used :param doff: degrees offset, defaults to 0 :type doff: float, optional :param moff: minutes offset, defaults to 0 :type moff: float, optional :param dt: datetime object to start calculation with, defaults to None :type dt: datetime, optional :return: datetime of next antitransit :rtype: datetime """ observer, orb = self.get_observer_and_orb() if dt is None: observer.date = self.shtime.utcnow() - dateutil.relativedelta.relativedelta(minutes=moff) + dateutil.relativedelta.relativedelta(seconds=2) logger.debug(f"ephem: midnight for {self.orb} with doff={doff}, moff={moff}, dt is None, using observer.date={observer.date}") else: if dt.tzinfo is None: # unaware datetime logger.debug(f"ephem: midnight for {self.orb} with doff={doff}, moff={moff}, dt={dt}, dt is unaware of timezone, assuming local time") observer.date = self.unaware_datetime_to_utc(dt) - dateutil.relativedelta.relativedelta(minutes=moff) else: logger.debug(f"ephem: midnight for {self.orb} with doff={doff}, moff={moff}, dt={dt}, dt timezone is {dt.tzinfo}") observer.date = self.aware_datetime_to_utc(dt) - dateutil.relativedelta.relativedelta(minutes=moff) date_utc = (observer.date.datetime()).replace(tzinfo=tzutc()) # attention: _avoid_neverup itself calls noon(), this might well get circular in some circumstances 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: datetime of next rising in utc timezone """ observer, orb = self.get_observer_and_orb() # workaround if rise is 0.001 seconds in the past if dt is None: observer.date = self.shtime.utcnow() + dateutil.relativedelta.relativedelta(seconds=2) logger.debug(f"ephem: rise for {self.orb} with doff={doff}, moff={moff}, dt is None, using observer.date={observer.date}") else: if dt.tzinfo is None: # unaware datetime logger.debug(f"ephem: rise for {self.orb} with doff={doff}, moff={moff}, dt={dt}, dt is unaware of timezone, assuming local time") observer.date = self.unaware_datetime_to_utc(dt) else: logger.debug(f"ephem: rise for {self.orb} with doff={doff}, moff={moff}, dt={dt}, dt timezone is {dt.tzinfo}") observer.date = self.aware_datetime_to_utc(dt) date_utc = (observer.date.datetime()).replace(tzinfo=tzutc()) # attention: _avoid_neverup itself calls noon(), this might well get circular in some circumstances 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() observer.horizon = 0 next_real_rising = observer.next_rising(orb, use_center=center).datetime().replace(tzinfo=tzutc()) else: next_rising = observer.next_rising(orb).datetime() next_real_rising = next_rising.replace(tzinfo=tzutc()) next_rising = next_rising + dateutil.relativedelta.relativedelta(minutes=moff) next_rising = next_rising.replace(tzinfo=tzutc()) if doff < 0 and next_rising > next_real_rising: logger.debug(f"ephem: adjusted next_rising {next_rising} to previous day as it is later than {next_real_rising}") next_rising -= datetime.timedelta(days=1) 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: datetime of next setting in utc timezone """ observer, orb = self.get_observer_and_orb() # workaround if set is 0.001 seconds in the past if dt is None: observer.date = self.shtime.utcnow() + dateutil.relativedelta.relativedelta(seconds=2) logger.debug(f"ephem: set for {self.orb} with doff={doff}, moff={moff}, dt is None, using observer.date={observer.date}") else: if dt.tzinfo is None: # unaware datetime logger.debug(f"ephem: set for {self.orb} with doff={doff}, moff={moff}, dt={dt}, dt is unaware of timezone, assuming local time") observer.date = self.unaware_datetime_to_utc(dt) else: logger.debug(f"ephem: set for {self.orb} with doff={doff}, moff={moff}, dt={dt}, dt timezone is {dt.tzinfo}") observer.date = self.aware_datetime_to_utc(dt) 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() observer.horizon = 0 next_real_setting = observer.next_setting(orb, use_center=center).datetime().replace(tzinfo=tzutc()) else: next_setting = observer.next_setting(orb).datetime() next_real_setting = next_setting.replace(tzinfo=tzutc()) next_setting = next_setting + dateutil.relativedelta.relativedelta(minutes=moff) next_setting = next_setting.replace(tzinfo=tzutc()) if doff < 0 and next_setting < next_real_setting: logger.debug(f"ephem: adjusted next_setting {next_setting} to next day as it is earlier than actual sunset at {next_real_setting}") next_setting += datetime.timedelta(days=1) 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 = self.shtime.utcnow() logger.debug(f"ephem: pos for {self.orb} with offset={offset}, degree={degree}, dt is None, using observer.date={observer.date}") else: if dt.tzinfo is None: # unaware datetime logger.debug(f"ephem: pos for {self.orb} with offset={offset}, degree={degree}, dt={dt}, dt is unaware of timezone, assuming local time") date = self.unaware_datetime_to_utc(dt) else: logger.debug(f"ephem: pos for {self.orb} with offset={offset}, degree={degree}, dt={dt}, dt timezone is {dt.tzinfo}") date = self.aware_datetime_to_utc(dt) 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 = self.shtime.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 = self.shtime.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