mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-12 06:44:04 +02:00
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:
parent
55f871fd0c
commit
cc99067c73
2 changed files with 291 additions and 6 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue