Skip to content

Commit

Permalink
Fix solar event listener for polar locations
Browse files Browse the repository at this point in the history
Some events are undefined closer to the poles during
winter/summer, so we need to fall back to hardcoded
times for such periods of the year.
  • Loading branch information
JakobGM committed May 24, 2018
1 parent b2330f1 commit 95b8b43
Show file tree
Hide file tree
Showing 2 changed files with 103 additions and 13 deletions.
75 changes: 62 additions & 13 deletions astrality/event_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@
"""

import abc
from collections import namedtuple
import logging
import time
from collections import namedtuple
from datetime import datetime, timedelta
from math import inf
from typing import Dict, ClassVar, Tuple, Union
from typing import Dict, ClassVar, Tuple, Union, Optional

import pytz
from astral import Location
from astral import AstralError, Location
from dateutil.tz import tzlocal


EventListenerConfig = Dict[str, Union[str, int, float, None]]
Expand Down Expand Up @@ -97,18 +98,55 @@ def __init__(self, event_listener_config: EventListenerConfig) -> None:
super().__init__(event_listener_config)
self.location = self.construct_astral_location()

def hardcoded_sun(
self,
date: Optional[datetime] = None,
) -> Dict[str, datetime]:
"""
Return hardcoded sun when Astral cannot calculate all solar events.
During summer, closer to the poles, the sun never dips properly below
the horizon. In this case astral throws an AstralError, and we have
to fall back to some hard coded defaults instead.
:param date: Date used for solar events. Defaults to datetime.now().
:return: Dict with event keys and datetime values.
"""
if not date:
date = datetime.now(tzlocal())

d = date.replace(
hour=0,
minute=0,
second=0,
microsecond=0,
)
return {
'dawn': d.replace(hour=5),
'sunrise': d.replace(hour=6),
'noon': d.replace(hour=12),
'sunset': d.replace(hour=22),
'dusk': d.replace(hour=23),
}

def _event(self) -> str:
now = self.now()
"""Return the current, local solar event."""
try:
sun = self.location.sun()
now = self.now()
except AstralError:
sun = self.hardcoded_sun()
now = datetime.now(tzlocal())

if now < self.location.sun()['dawn']:
if now < sun['dawn']:
event = 'night'
elif now < self.location.sun()['sunrise']:
elif now < sun['sunrise']:
event = 'sunrise'
elif now < self.location.sun()['noon']:
elif now < sun['noon']:
event = 'morning'
elif now < self.location.sun()['sunset']:
elif now < sun['sunset']:
event = 'afternoon'
elif now < self.location.sun()['dusk']:
elif now < sun['dusk']:
event = 'sunset'
else:
event = 'night'
Expand All @@ -117,27 +155,38 @@ def _event(self) -> str:

def time_until_next_event(self) -> timedelta:
"""Return timedelta until next solar event."""
now = self.now()
try:
sun = self.location.sun()
now = self.now()
except AstralError:
sun = self.hardcoded_sun()
now = datetime.now(tzlocal())

try:
next_event = min(
utc_time
for utc_time
in self.location.sun().values()
in sun.values()
if now < utc_time
)
except ValueError as exception:
if str(exception) == 'min() arg is an empty sequence':
# None of the solar events this current day are in the future,
# so we need to compare with solar events tomorrow instead.
tomorrow = now + timedelta(days=1, seconds=-1)
try:
sun = self.location.sun(tomorrow)
except AstralError:
sun = self.hardcoded_sun(tomorrow)

next_event = min(
utc_time
for utc_time
in self.location.sun(tomorrow).values()
in sun.values()
if now < utc_time
)
else:
raise RuntimeError('Could not find the time of the next event')
raise

return next_event - now

Expand Down
41 changes: 41 additions & 0 deletions astrality/tests/event_listener/test_solar.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for the solar event listener subclass."""
from datetime import datetime, timedelta

from dateutil.tz import tzlocal
import pytest

from astrality.event_listener import Solar
Expand Down Expand Up @@ -130,3 +131,43 @@ def test_config_event_listener_method():
solar_event_listener_application_config = {'type': 'solar'}
solar_event_listener = Solar(solar_event_listener_application_config)
assert solar_event_listener.event_listener_config['latitude'] == 0


@pytest.mark.parametrize(
'hour,sun',
[
(23, 'night'),
(1, 'night'),
(5, 'sunrise'),
(10, 'morning'),
(13, 'afternoon'),
(22, 'sunset'),
],
)
def test_locations_where_some_events_never_occur(freezer, hour, sun):
"""
Test that locations with missing solar events are handled gracefully.
During summer, closer to the poles, the sun never dips properly below
the horizon. In this case astral throws an AstralError, and we have
to fall back to some hard coded defaults instead.
"""
summer = datetime(
year=2018,
month=5,
day=24,
hour=hour,
minute=0,
tzinfo=tzlocal(),
)
freezer.move_to(summer)

polar_location = {
'type': 'solar',
'latitude': 89,
'longitude': 89,
'elevation': 0,
}
polar_sun = Solar(polar_location)
assert polar_sun.event() == sun
assert 0 < polar_sun.time_until_next_event().total_seconds() < 24 * 60 * 60

0 comments on commit 95b8b43

Please sign in to comment.