Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Quiet the chatty sun.sun #23832

Merged
merged 5 commits into from May 15, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
185 changes: 135 additions & 50 deletions homeassistant/components/sun/__init__.py
Expand Up @@ -6,10 +6,9 @@
CONF_ELEVATION, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET)
from homeassistant.core import callback
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import (
async_track_point_in_utc_time, async_track_utc_time_change)
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.helpers.sun import (
get_astral_location, get_astral_event_next)
get_astral_location, get_location_astral_event_next)
from homeassistant.util import dt as dt_util

_LOGGER = logging.getLogger(__name__)
Expand All @@ -23,24 +22,55 @@

STATE_ATTR_AZIMUTH = 'azimuth'
STATE_ATTR_ELEVATION = 'elevation'
STATE_ATTR_RISING = 'rising'
STATE_ATTR_NEXT_DAWN = 'next_dawn'
STATE_ATTR_NEXT_DUSK = 'next_dusk'
STATE_ATTR_NEXT_MIDNIGHT = 'next_midnight'
STATE_ATTR_NEXT_NOON = 'next_noon'
STATE_ATTR_NEXT_RISING = 'next_rising'
STATE_ATTR_NEXT_SETTING = 'next_setting'

# The algorithm used here is somewhat complicated. It aims to cut down
# the number of sensor updates over the day. It's documented best in
# the PR for the change, see the Discussion section of:
# https://github.com/home-assistant/home-assistant/pull/23832


# As documented in wikipedia: https://en.wikipedia.org/wiki/Twilight
# sun is:
# < -18° of horizon - all stars visible
PHASE_NIGHT = 'night'
# 18°-12° - some stars not visible
PHASE_ASTRONOMICAL_TWILIGHT = 'astronomical_twilight'
# 12°-6° - horizon visible
PHASE_NAUTICAL_TWILIGHT = 'nautical_twilight'
# 6°-0° - objects visible
PHASE_TWILIGHT = 'twilight'
# 0°-10° above horizon, sun low on horizon
PHASE_SMALL_DAY = 'small_day'
# > 10° above horizon
PHASE_DAY = 'day'

# 4 mins is one degree of arc change of the sun on its circle.
# During the night and the middle of the day we don't update
# that much since it's not important.
_PHASE_UPDATES = {
PHASE_NIGHT: timedelta(minutes=4*5),
PHASE_ASTRONOMICAL_TWILIGHT: timedelta(minutes=4*2),
PHASE_NAUTICAL_TWILIGHT: timedelta(minutes=4*2),
PHASE_TWILIGHT: timedelta(minutes=4),
PHASE_SMALL_DAY: timedelta(minutes=2),
PHASE_DAY: timedelta(minutes=4),
}


async def async_setup(hass, config):
"""Track the state of the sun."""
if config.get(CONF_ELEVATION) is not None:
_LOGGER.warning(
"Elevation is now configured in home assistant core. "
"See https://home-assistant.io/docs/configuration/basic/")

sun = Sun(hass, get_astral_location(hass))
sun.point_in_time_listener(dt_util.utcnow())

Sun(hass, get_astral_location(hass))
return True


Expand All @@ -57,8 +87,10 @@ def __init__(self, hass, location):
self.next_dawn = self.next_dusk = None
self.next_midnight = self.next_noon = None
self.solar_elevation = self.solar_azimuth = None
self.rising = self.phase = None

async_track_utc_time_change(hass, self.timer_update, second=30)
self._next_change = None
self.update_events(dt_util.utcnow())

@property
def name(self):
Expand All @@ -83,57 +115,110 @@ def state_attributes(self):
STATE_ATTR_NEXT_NOON: self.next_noon.isoformat(),
STATE_ATTR_NEXT_RISING: self.next_rising.isoformat(),
STATE_ATTR_NEXT_SETTING: self.next_setting.isoformat(),
STATE_ATTR_ELEVATION: round(self.solar_elevation, 2),
STATE_ATTR_AZIMUTH: round(self.solar_azimuth, 2)
STATE_ATTR_ELEVATION: self.solar_elevation,
STATE_ATTR_AZIMUTH: self.solar_azimuth,
STATE_ATTR_RISING: self.rising,
}

@property
def next_change(self):
"""Datetime when the next change to the state is."""
return min(self.next_dawn, self.next_dusk, self.next_midnight,
self.next_noon, self.next_rising, self.next_setting)
def _check_event(self, utc_point_in_time, event, before):
next_utc = get_location_astral_event_next(
self.location, event, utc_point_in_time)
if next_utc < self._next_change:
self._next_change = next_utc
self.phase = before
return next_utc

@callback
def update_as_of(self, utc_point_in_time):
def update_events(self, utc_point_in_time):
"""Update the attributes containing solar events."""
self.next_dawn = get_astral_event_next(
self.hass, 'dawn', utc_point_in_time)
self.next_dusk = get_astral_event_next(
self.hass, 'dusk', utc_point_in_time)
self.next_midnight = get_astral_event_next(
self.hass, 'solar_midnight', utc_point_in_time)
self.next_noon = get_astral_event_next(
self.hass, 'solar_noon', utc_point_in_time)
self.next_rising = get_astral_event_next(
self.hass, SUN_EVENT_SUNRISE, utc_point_in_time)
self.next_setting = get_astral_event_next(
self.hass, SUN_EVENT_SUNSET, utc_point_in_time)
self._next_change = utc_point_in_time + timedelta(days=400)

# Work our way around the solar cycle, figure out the next
# phase. Some of these are stored.
self.location.solar_depression = 'astronomical'
self._check_event(utc_point_in_time, 'dawn', PHASE_NIGHT)
self.location.solar_depression = 'nautical'
self._check_event(
utc_point_in_time, 'dawn', PHASE_ASTRONOMICAL_TWILIGHT)
self.location.solar_depression = 'civil'
self.next_dawn = self._check_event(
utc_point_in_time, 'dawn', PHASE_NAUTICAL_TWILIGHT)
self.next_rising = self._check_event(
utc_point_in_time, SUN_EVENT_SUNRISE, PHASE_TWILIGHT)
self.location.solar_depression = -10
self._check_event(utc_point_in_time, 'dawn', PHASE_SMALL_DAY)
self.next_noon = self._check_event(
utc_point_in_time, 'solar_noon', None)
self._check_event(utc_point_in_time, 'dusk', PHASE_DAY)
self.next_setting = self._check_event(
utc_point_in_time, SUN_EVENT_SUNSET, PHASE_SMALL_DAY)
self.location.solar_depression = 'civil'
self.next_dusk = self._check_event(
utc_point_in_time, 'dusk', PHASE_TWILIGHT)
self.location.solar_depression = 'nautical'
self._check_event(
utc_point_in_time, 'dusk', PHASE_NAUTICAL_TWILIGHT)
self.location.solar_depression = 'astronomical'
self._check_event(
utc_point_in_time, 'dusk', PHASE_ASTRONOMICAL_TWILIGHT)
self.next_midnight = self._check_event(
utc_point_in_time, 'solar_midnight', None)

# if the event was solar midday or midnight, phase will now
# be None. Solar noon doesn't always happen when the sun is
# even in the day at the poles, so we can't rely on it.
# Need to calculate phase if next is noon or midnight
if self.phase is None:
elevation = self.location.solar_elevation(self._next_change)
if elevation >= 10:
self.phase = PHASE_DAY
elif elevation >= 0:
self.phase = PHASE_SMALL_DAY
elif elevation >= -6:
self.phase = PHASE_TWILIGHT
elif elevation >= -12:
self.phase = PHASE_NAUTICAL_TWILIGHT
elif elevation >= -18:
self.phase = PHASE_ASTRONOMICAL_TWILIGHT
else:
self.phase = PHASE_NIGHT

self.rising = self.next_noon < self.next_midnight

_LOGGER.debug(
"sun phase_update@%s: phase=%s",
utc_point_in_time.isoformat(),
self.phase,
)
self.update_sun_position(utc_point_in_time)

# Set timer for the next solar event
async_track_point_in_utc_time(
self.hass, self.update_events,
self._next_change)
_LOGGER.debug("next time: %s", self._next_change.isoformat())

@callback
def update_sun_position(self, utc_point_in_time):
"""Calculate the position of the sun."""
self.solar_azimuth = self.location.solar_azimuth(utc_point_in_time)
self.solar_elevation = self.location.solar_elevation(utc_point_in_time)

@callback
def point_in_time_listener(self, now):
"""Run when the state of the sun has changed."""
self.update_sun_position(now)
self.update_as_of(now)
self.solar_azimuth = round(
self.location.solar_azimuth(utc_point_in_time), 2)
self.solar_elevation = round(
self.location.solar_elevation(utc_point_in_time), 2)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using hard-coded update times, why not round the angles? Then the state machine would take care of de-duplicating values (because of force_update=False).

The sun position calculation is really quick - so computation time is not a problem. Database storage however is.

We could also have different rounding accuracy based on the value. So for example

  • if abs(elevation) < 0.1: accuracy = 0.1
  • else: accuracy = 1.0

Sounds like a much simpler approach to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did try that. The thing is that the azimuth swings around rapidly at near noon, and so you end up with a bunch of pretty useless updates if you're tracking the azimuth too.

You do need to do that too at high latitudes, at the extreme case if you are at the north pole the elevation doesn't change much during 24hrs, only the azimuth. This affects what windows the dun shines into for example.

That's what lead to total arc of travel, rather than change with respect to the horizon. We do care more about changes near the horizon because they're more important, but mostly we want to know that the sun has moved somewhere new in the sky.

I'm not trying to minimise the calculation, the goal is to reduce the number of writes to the database.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a more detailed description above to what the code is doing. It does take a bit to get your head around 😁


_LOGGER.debug(
"sun position_update@%s: elevation=%s azimuth=%s",
utc_point_in_time.isoformat(),
self.solar_elevation, self.solar_azimuth
)
self.async_write_ha_state()
_LOGGER.debug("sun point_in_time_listener@%s: %s, %s",
now, self.state, self.state_attributes)

# Schedule next update at next_change+1 second so sun state has changed
# Next update as per the current phase
delta = _PHASE_UPDATES[self.phase]
# if the next update is within 1.25 of the next
# position update just drop it
if utc_point_in_time + delta*1.25 > self._next_change:
return
async_track_point_in_utc_time(
self.hass, self.point_in_time_listener,
self.next_change + timedelta(seconds=1))
_LOGGER.debug("next time: %s", self.next_change + timedelta(seconds=1))

@callback
def timer_update(self, time):
"""Needed to update solar elevation and azimuth."""
self.update_sun_position(time)
self.async_write_ha_state()
_LOGGER.debug("sun timer_update@%s: %s, %s",
time, self.state, self.state_attributes)
self.hass, self.update_sun_position,
utc_point_in_time + delta)
13 changes: 11 additions & 2 deletions homeassistant/helpers/sun.py
Expand Up @@ -43,9 +43,18 @@ def get_astral_event_next(
utc_point_in_time: Optional[datetime.datetime] = None,
offset: Optional[datetime.timedelta] = None) -> datetime.datetime:
"""Calculate the next specified solar event."""
from astral import AstralError

location = get_astral_location(hass)
return get_location_astral_event_next(
location, event, utc_point_in_time, offset)


@callback
def get_location_astral_event_next(
location: 'astral.Location', event: str,
utc_point_in_time: Optional[datetime.datetime] = None,
offset: Optional[datetime.timedelta] = None) -> datetime.datetime:
"""Calculate the next specified solar event."""
from astral import AstralError

if offset is None:
offset = datetime.timedelta()
Expand Down