fix: keep adapting during polar night and midnight sun (#1489)

Handle missing polar sunrise and sunset with a shared fallback. Bound offsets between actual solar anchors while preserving the daily lighting cycle and configured time behavior.

Co-authored-by: Oscar Pacheco <oscar@gigadefense.com.mx>
This commit is contained in:
Bas Nijholt 2026-09-06 09:23:21 +02:00 committed by GitHub
commit cc99067c73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 291 additions and 6 deletions

View file

@ -35,6 +35,12 @@ class SunEvent(str, Enum):
_ORDER = (SunEvent.SUNRISE, SunEvent.NOON, SunEvent.SUNSET, SunEvent.MIDNIGHT)
_ALLOWED_ORDERS = {_ORDER[i:] + _ORDER[:i] for i in range(len(_ORDER))}
# On polar days without a sunrise/sunset, synthetic sun events are placed this
# far from solar noon (polar night) or solar midnight (midnight sun), giving a
# 1-hour synthetic "day" or "night" so the adaptation cycle keeps working.
_POLAR_SUN_EVENT_OFFSET = timedelta(minutes=30)
_POLAR_SUN_EVENT_EPSILON = timedelta(seconds=1)
utcnow: partial[datetime.datetime] = partial(datetime.datetime.now, UTC)
utcnow.__doc__ = "Get now in UTC time."
@ -57,13 +63,73 @@ class SunEvents:
sunset_offset: datetime.timedelta = datetime.timedelta()
timezone: datetime.tzinfo = UTC
def _astral_sunrise_or_sunset(
self,
dt: datetime.date,
event: Literal[SunEvent.SUNRISE, SunEvent.SUNSET],
offset: datetime.timedelta,
) -> datetime.datetime:
"""Return the astral sunrise/sunset, with a fallback for polar regions.
Above the polar circle the sun never crosses the horizon during polar
night and midnight sun, and `astral` raises a `ValueError` (see #1485).
On such days, synthesize a 1-hour "day" around solar noon (polar night)
or a 1-hour "night" around solar midnight (midnight sun), so the
adaptation cycle keeps working. The `(min/max)_(sunrise/sunset)_time`
options are applied on top of these synthetic times and can be used to
shape the resulting schedule. Configured offsets are limited to the
surrounding solar midnight/noon interval so they cannot invert the
required event order.
"""
astral_event = (
astral.sun.sunrise if event == SunEvent.SUNRISE else astral.sun.sunset
)
try:
return astral_event(self.astral_observer, dt) + offset
except ValueError:
noon = astral.sun.noon(self.astral_observer, dt)
midnight = astral.sun.midnight(self.astral_observer, dt)
next_midnight = astral.sun.midnight(
self.astral_observer,
dt + timedelta(days=1),
)
noon_elevation = astral.sun.elevation(self.astral_observer, noon)
midnight_elevation = astral.sun.elevation(self.astral_observer, midnight)
# The sum of the sun's highest and lowest elevation of the day is
# ≈2x the solar declination, so its sign robustly distinguishes
# midnight sun from polar night, even on the boundary days where
# one elevation hovers around the horizon.
if noon_elevation + midnight_elevation > 0:
# Midnight sun: the sun stays above the horizon all day.
synthetic = (
midnight + _POLAR_SUN_EVENT_OFFSET
if event == SunEvent.SUNRISE
else next_midnight - _POLAR_SUN_EVENT_OFFSET
)
else:
# Polar night: the sun stays below the horizon all day.
sign = -1 if event == SunEvent.SUNRISE else 1
synthetic = noon + sign * _POLAR_SUN_EVENT_OFFSET
lower, upper = (
(midnight, noon) if event == SunEvent.SUNRISE else (noon, next_midnight)
)
return min(
max(synthetic + offset, lower + _POLAR_SUN_EVENT_EPSILON),
upper - _POLAR_SUN_EVENT_EPSILON,
)
def sunrise(self, dt: datetime.date) -> datetime.datetime:
"""Return the (adjusted) sunrise time for the given datetime."""
sunrise = (
astral.sun.sunrise(self.astral_observer, dt)
self._astral_sunrise_or_sunset(
dt,
SunEvent.SUNRISE,
self.sunrise_offset,
)
if self.sunrise_time is None
else self._replace_time(dt, self.sunrise_time)
) + self.sunrise_offset
else self._replace_time(dt, self.sunrise_time) + self.sunrise_offset
)
if self.min_sunrise_time is not None:
min_sunrise = self._replace_time(dt, self.min_sunrise_time)
sunrise = max(min_sunrise, sunrise)
@ -75,10 +141,14 @@ class SunEvents:
def sunset(self, dt: datetime.date) -> datetime.datetime:
"""Return the (adjusted) sunset time for the given datetime."""
sunset = (
astral.sun.sunset(self.astral_observer, dt)
self._astral_sunrise_or_sunset(
dt,
SunEvent.SUNSET,
self.sunset_offset,
)
if self.sunset_time is None
else self._replace_time(dt, self.sunset_time)
) + self.sunset_offset
else self._replace_time(dt, self.sunset_time) + self.sunset_offset
)
if self.min_sunset_time is not None:
min_sunset = self._replace_time(dt, self.min_sunset_time)
sunset = max(min_sunset, sunset)

View file

@ -1,10 +1,12 @@
import datetime as dt
import zoneinfo
import astral.sun
import pytest
from astral import LocationInfo
from astral.location import Location
from homeassistant.components.adaptive_lighting.color_and_brightness import (
_POLAR_SUN_EVENT_OFFSET,
SunEvent,
SunEvents,
SunLightSettings,
@ -297,3 +299,216 @@ def test_brightness_pct_varies_with_inverted_brightness_bounds(
assert len({round(value) for value in samples}) > 1, samples
assert all(15 <= value <= 100 for value in samples), samples
# Tromsø, Norway (69.6°N) has polar night (Nov-Jan) and midnight sun (May-Jul).
TROMSO = Location(
LocationInfo(
name="Tromsø",
region="Norway",
timezone="Europe/Oslo",
latitude=69.6489,
longitude=18.9551,
),
)
POLAR_NIGHT_DATE = dt.date(2026, 1, 7)
MIDNIGHT_SUN_DATE = dt.date(2026, 7, 7)
MCMURDO = Location(
LocationInfo(
name="McMurdo Station",
region="Antarctica",
timezone="Antarctica/McMurdo",
latitude=-77.8419,
longitude=166.6863,
),
)
def _polar_sun_events(location=TROMSO, **kwargs):
defaults = {
"name": "test",
"astral_observer": location.observer,
"sunrise_time": None,
"min_sunrise_time": None,
"max_sunrise_time": None,
"sunset_time": None,
"min_sunset_time": None,
"max_sunset_time": None,
"timezone": zoneinfo.ZoneInfo(location.timezone),
}
return SunEvents(**{**defaults, **kwargs})
def test_polar_night_synthesizes_short_day():
# `astral` cannot compute sunrise/sunset (the sun never rises), see #1485
with pytest.raises(ValueError): # noqa: PT011
astral.sun.sunrise(TROMSO.observer, POLAR_NIGHT_DATE)
sun_events = _polar_sun_events()
noon = astral.sun.noon(TROMSO.observer, POLAR_NIGHT_DATE)
assert sun_events.sunrise(POLAR_NIGHT_DATE) == noon - _POLAR_SUN_EVENT_OFFSET
assert sun_events.sunset(POLAR_NIGHT_DATE) == noon + _POLAR_SUN_EVENT_OFFSET
def test_midnight_sun_synthesizes_short_night():
# `astral` cannot compute sunrise/sunset (the sun never sets), see #1485
with pytest.raises(ValueError): # noqa: PT011
astral.sun.sunset(TROMSO.observer, MIDNIGHT_SUN_DATE)
sun_events = _polar_sun_events()
midnight = astral.sun.midnight(TROMSO.observer, MIDNIGHT_SUN_DATE)
next_midnight = astral.sun.midnight(
TROMSO.observer,
MIDNIGHT_SUN_DATE + dt.timedelta(days=1),
)
assert sun_events.sunrise(MIDNIGHT_SUN_DATE) == midnight + _POLAR_SUN_EVENT_OFFSET
assert (
sun_events.sunset(MIDNIGHT_SUN_DATE) == next_midnight - _POLAR_SUN_EVENT_OFFSET
)
@pytest.mark.parametrize(
("date", "midnight_sun"),
[(dt.date(2026, 1, 7), True), (dt.date(2026, 7, 7), False)],
)
def test_polar_fallback_handles_southern_hemisphere(date, midnight_sun):
sun_events = _polar_sun_events(MCMURDO)
noon = astral.sun.noon(MCMURDO.observer, date)
midnight = astral.sun.midnight(MCMURDO.observer, date)
next_midnight = astral.sun.midnight(MCMURDO.observer, date + dt.timedelta(days=1))
if midnight_sun:
assert sun_events.sunrise(date) == midnight + _POLAR_SUN_EVENT_OFFSET
assert sun_events.sunset(date) == next_midnight - _POLAR_SUN_EVENT_OFFSET
else:
assert sun_events.sunrise(date) == noon - _POLAR_SUN_EVENT_OFFSET
assert sun_events.sunset(date) == noon + _POLAR_SUN_EVENT_OFFSET
def test_boundary_day_with_real_sunrise_and_synthetic_sunset():
# At the start of the midnight sun period, `astral` computes a real
# sunrise for this date but raises for sunset (this exact date depends on
# astral's numerics). The synthetic sunset must stay consistent with the
# nearly 24-hour day instead of collapsing into a polar-night day.
date = dt.date(2026, 5, 18)
astral.sun.sunrise(TROMSO.observer, date) # does not raise
with pytest.raises(ValueError): # noqa: PT011
astral.sun.sunset(TROMSO.observer, date)
sun_events = _polar_sun_events()
day_length = sun_events.sunset(date) - sun_events.sunrise(date)
assert day_length > dt.timedelta(hours=22)
@pytest.mark.parametrize("date", [POLAR_NIGHT_DATE, MIDNIGHT_SUN_DATE])
def test_sun_position_on_polar_days(date):
sun_events = _polar_sun_events()
datetime = dt.datetime(date.year, date.month, date.day, tzinfo=dt.timezone.utc)
noon, midnight = sun_events.noon_and_midnight(datetime)
assert sun_events.sun_position(noon) == 1
assert sun_events.sun_position(midnight) == -1
assert sun_events.sun_position(sun_events.sunrise(date)) == 0
assert sun_events.sun_position(sun_events.sunset(date)) == 0
def test_polar_night_min_max_times_shape_the_synthetic_day():
# The (min/max)_(sunrise/sunset)_time options apply on top of the
# synthetic sun events, so users can still shape their schedule.
sun_events = _polar_sun_events(
max_sunrise_time=dt.time(9, 0),
min_sunset_time=dt.time(17, 0),
timezone=dt.timezone.utc,
)
expected_sunrise = dt.datetime(2026, 1, 7, 9, 0, tzinfo=dt.timezone.utc)
expected_sunset = dt.datetime(2026, 1, 7, 17, 0, tzinfo=dt.timezone.utc)
assert sun_events.sunrise(POLAR_NIGHT_DATE) == expected_sunrise
assert sun_events.sunset(POLAR_NIGHT_DATE) == expected_sunset
@pytest.mark.parametrize("date", [POLAR_NIGHT_DATE, MIDNIGHT_SUN_DATE])
@pytest.mark.parametrize(
("sunrise_offset", "sunset_offset"),
[
(dt.timedelta(hours=-20), dt.timedelta(hours=-20)),
(dt.timedelta(hours=-20), dt.timedelta(hours=20)),
(dt.timedelta(hours=20), dt.timedelta(hours=-20)),
(dt.timedelta(hours=20), dt.timedelta(hours=20)),
],
)
def test_polar_offsets_cannot_invert_event_order(
date,
sunrise_offset,
sunset_offset,
):
sun_events = _polar_sun_events(
sunrise_offset=sunrise_offset,
sunset_offset=sunset_offset,
)
events = dict(
sun_events.sun_events(dt.datetime.combine(date, dt.time(), tzinfo=dt.UTC)),
)
midnight = dt.datetime.fromtimestamp(events[SunEvent.MIDNIGHT], tz=dt.UTC)
next_midnight = astral.sun.midnight(TROMSO.observer, date + dt.timedelta(days=1))
noon = dt.datetime.fromtimestamp(events[SunEvent.NOON], tz=dt.UTC)
sunrise = dt.datetime.fromtimestamp(events[SunEvent.SUNRISE], tz=dt.UTC)
sunset = dt.datetime.fromtimestamp(events[SunEvent.SUNSET], tz=dt.UTC)
assert midnight < sunrise < noon < sunset < next_midnight
def test_polar_fallback_applies_offsets_within_solar_anchors():
offset = dt.timedelta(minutes=15)
plain = _polar_sun_events()
shifted = _polar_sun_events(
sunrise_offset=offset,
sunset_offset=offset,
)
assert (
shifted.sunrise(MIDNIGHT_SUN_DATE) - plain.sunrise(MIDNIGHT_SUN_DATE) == offset
)
assert shifted.sunset(MIDNIGHT_SUN_DATE) - plain.sunset(MIDNIGHT_SUN_DATE) == offset
def test_sun_position_all_year_in_polar_region():
# Covers the transitions into and out of polar night and midnight sun;
# `sun_position` internally validates the order of the sun events.
sun_events = _polar_sun_events()
datetime = dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc)
end = dt.datetime(2027, 1, 1, tzinfo=dt.timezone.utc)
while datetime < end:
position = sun_events.sun_position(datetime)
assert -1 <= position <= 1
datetime += dt.timedelta(hours=8)
@pytest.mark.parametrize("date", [POLAR_NIGHT_DATE, MIDNIGHT_SUN_DATE])
def test_brightness_and_color_on_polar_days(date):
settings = SunLightSettings(
name="test",
astral_observer=TROMSO.observer,
adapt_until_sleep=False,
max_brightness=100,
max_color_temp=5500,
min_brightness=30,
min_color_temp=2000,
sleep_brightness=1,
sleep_rgb_or_color_temp="color_temp",
sleep_color_temp=1000,
sleep_rgb_color=(255, 56, 0),
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
sunset_time=None,
min_sunset_time=None,
max_sunset_time=None,
brightness_mode_time_dark=dt.timedelta(hours=1),
brightness_mode_time_light=dt.timedelta(hours=1),
timezone=zoneinfo.ZoneInfo("Europe/Oslo"),
)
datetime = dt.datetime(date.year, date.month, date.day, tzinfo=dt.timezone.utc)
noon, midnight = settings.sun.noon_and_midnight(datetime)
at_noon = settings.brightness_and_color(noon, is_sleep=False)
assert at_noon["brightness_pct"] == 100
assert at_noon["color_temp_kelvin"] == 5500
at_midnight = settings.brightness_and_color(midnight, is_sleep=False)
assert at_midnight["brightness_pct"] == 30
assert at_midnight["color_temp_kelvin"] == 2000