mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-15 16:24:04 +02:00
Fix sun-event day-anchoring: curve collapsed to min in summer afternoons
sensor.sun_next_rising flips to tomorrow at sunrise; the old anchor only pulled it back when >12h away. With short June nights tomorrow's sunrise is within 12h by mid-afternoon, so from ~17:30 local until sunset the pair described tomorrow and the curve returned minimum. Replace the clock-distance heuristic with ordering-based anchoring in a new pure helper anchor_sun_events() (color_and_brightness.py): - daytime (sunrise > sunset): pull sunrise back one day, unconditionally - stale "today's sunset" still on yesterday: push sunset forward one day - post-sunset ramp tail (both flipped, still within half_width): pull both back so the down-ramp completes instead of snapping to min 8 regression tests incl. the June-afternoon repro and a winter check.
This commit is contained in:
parent
edacfb98af
commit
5bf28bd729
3 changed files with 172 additions and 13 deletions
|
|
@ -184,6 +184,42 @@ def _tanh_day_curve(
|
|||
)
|
||||
|
||||
|
||||
def anchor_sun_events(
|
||||
t_sunrise: datetime,
|
||||
t_sunset: datetime,
|
||||
now: datetime,
|
||||
half_width: int,
|
||||
) -> tuple[datetime, datetime]:
|
||||
"""Anchor `next_*`-style sensor timestamps to the day surrounding `now`.
|
||||
|
||||
`sensor.sun_next_rising` flips to tomorrow's event the moment today's
|
||||
sunrise passes (and `sun_next_setting` likewise at sunset), so the raw
|
||||
pair often describes the wrong day:
|
||||
|
||||
- Daytime: sunrise has flipped to tomorrow while sunset is still
|
||||
today's → pull sunrise back one day. No "how far ahead" heuristic —
|
||||
on long summer days tomorrow's sunrise is less than 12 h away by
|
||||
mid-afternoon, which is exactly the case a time-distance guard gets
|
||||
wrong.
|
||||
- A stale "today's sunset" sensor still holding yesterday's event →
|
||||
push sunset forward one day.
|
||||
- Just after sunset both have flipped to tomorrow; while `now` is
|
||||
still inside the down-ramp (within `half_width` seconds of the
|
||||
sunset that just passed) → pull both back one day so the ramp
|
||||
completes instead of snapping to the minimum.
|
||||
"""
|
||||
one_day = timedelta(days=1)
|
||||
if t_sunrise > t_sunset:
|
||||
if t_sunset < now:
|
||||
t_sunset += one_day
|
||||
else:
|
||||
t_sunrise -= one_day
|
||||
elif t_sunset - one_day <= now < t_sunset - one_day + timedelta(seconds=half_width):
|
||||
t_sunrise -= one_day
|
||||
t_sunset -= one_day
|
||||
return t_sunrise, t_sunset
|
||||
|
||||
|
||||
def find_a_b(x1: float, x2: float, y1: float, y2: float) -> tuple[float, float]:
|
||||
"""Coefficients `a, b` for y = 0.5 * (tanh(a * (x - b)) + 1) passing through (x1,y1) and (x2,y2)."""
|
||||
a = (math.atanh(2 * y2 - 1) - math.atanh(2 * y1 - 1)) / (x2 - x1)
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ from .adaptation_utils import (
|
|||
has_effect_attribute,
|
||||
prepare_adaptation_data,
|
||||
)
|
||||
from .color_and_brightness import SunLightSettings, lux_reduce
|
||||
from .color_and_brightness import SunLightSettings, anchor_sun_events, lux_reduce
|
||||
from .const import (
|
||||
ADAPT_BRIGHTNESS_SWITCH,
|
||||
ADAPT_COLOR_SWITCH,
|
||||
|
|
@ -873,10 +873,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
def _today_sun_events(self) -> tuple[datetime.datetime, datetime.datetime] | None:
|
||||
"""Read the sunrise and sunset entities and return today's events.
|
||||
|
||||
Handles the case where `sensor.sun_next_rising` (or similar) has
|
||||
already flipped to tomorrow's date by anchoring whichever event is in
|
||||
the past via subtracting a day; returns None if either entity is
|
||||
missing or has an unparseable timestamp.
|
||||
Day-anchoring (the `sensor.sun_next_rising` flipped-to-tomorrow
|
||||
case) is delegated to `anchor_sun_events`; returns None if either
|
||||
entity is missing or has an unparseable timestamp.
|
||||
"""
|
||||
sunrise_state = self.hass.states.get(self._sunrise_entity)
|
||||
sunset_state = self.hass.states.get(self._sunset_entity)
|
||||
|
|
@ -908,14 +907,15 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
sunset_state.state,
|
||||
)
|
||||
return None
|
||||
# Anchor to today: if the entity holds tomorrow's value (typical of
|
||||
# `sensor.sun_next_rising` after sunrise has passed), subtract one day.
|
||||
now = dt_util.utcnow()
|
||||
if t_sunrise > t_sunset and t_sunrise > now + timedelta(hours=12):
|
||||
t_sunrise = t_sunrise - timedelta(days=1)
|
||||
if t_sunset < t_sunrise and t_sunset < now - timedelta(hours=12):
|
||||
t_sunset = t_sunset + timedelta(days=1)
|
||||
return t_sunrise, t_sunset
|
||||
# Anchor to the day surrounding `now`: `sensor.sun_next_rising`
|
||||
# flips to tomorrow's event the moment sunrise passes (likewise for
|
||||
# sunset), so normalize the pair before feeding the curve.
|
||||
return anchor_sun_events(
|
||||
t_sunrise,
|
||||
t_sunset,
|
||||
now=dt_util.utcnow(),
|
||||
half_width=RAMP_HALF_WIDTH_SECONDS,
|
||||
)
|
||||
|
||||
def _get_runtime_range(self, field_key: str) -> int:
|
||||
"""Read the live curve bound from its number entity.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import pytest
|
|||
from custom_components.adaptive_lighting.color_and_brightness import (
|
||||
SunLightSettings,
|
||||
_tanh_day_curve,
|
||||
anchor_sun_events,
|
||||
lux_reduce,
|
||||
)
|
||||
|
||||
|
|
@ -203,3 +204,125 @@ class TestLuxReduce:
|
|||
def test_boundary_just_below_min(self):
|
||||
result = lux_reduce(100.0, 500, 10100, 5)
|
||||
assert result is None # 100 * 500/10100 ≈ 4.95 < 5
|
||||
|
||||
|
||||
class TestAnchorSunEvents:
|
||||
"""Day-anchoring of `next_*`-style sun sensor timestamps.
|
||||
|
||||
Regression for the June bug: `sun_next_rising` flips to tomorrow at
|
||||
sunrise, and on long summer days tomorrow's sunrise is < 12 h away by
|
||||
mid-afternoon — the old `now + 12h` guard then left the pair
|
||||
describing tomorrow, collapsing the curve to minimum around 17:30
|
||||
local instead of ramping down at sunset.
|
||||
"""
|
||||
|
||||
# June day in NL: sunrise 03:20 UTC (05:20 CEST), sunset 20:00 UTC.
|
||||
JUNE_SUNRISE = dt.datetime(2026, 6, 7, 3, 20, tzinfo=UTC)
|
||||
JUNE_SUNSET = dt.datetime(2026, 6, 7, 20, 0, tzinfo=UTC)
|
||||
ONE_DAY = timedelta(days=1)
|
||||
|
||||
def test_morning_before_sunrise_unchanged(self):
|
||||
"""Pre-sunrise both sensors hold today's events — no shift."""
|
||||
now = self.JUNE_SUNRISE - timedelta(hours=2)
|
||||
r, s = anchor_sun_events(
|
||||
self.JUNE_SUNRISE,
|
||||
self.JUNE_SUNSET,
|
||||
now,
|
||||
HALF_WIDTH,
|
||||
)
|
||||
assert (r, s) == (self.JUNE_SUNRISE, self.JUNE_SUNSET)
|
||||
|
||||
def test_daytime_sunrise_flipped_is_pulled_back(self):
|
||||
"""Mid-morning: next_rising points at tomorrow → pulled back a day."""
|
||||
now = self.JUNE_SUNRISE + timedelta(hours=4)
|
||||
r, s = anchor_sun_events(
|
||||
self.JUNE_SUNRISE + self.ONE_DAY,
|
||||
self.JUNE_SUNSET,
|
||||
now,
|
||||
HALF_WIDTH,
|
||||
)
|
||||
assert (r, s) == (self.JUNE_SUNRISE, self.JUNE_SUNSET)
|
||||
|
||||
def test_june_late_afternoon_regression(self):
|
||||
"""17:30 CEST in June: tomorrow's sunrise is < 12 h away.
|
||||
|
||||
The old heuristic refused to pull it back and the curve collapsed
|
||||
to min. The anchored pair must still describe *today*.
|
||||
"""
|
||||
now = dt.datetime(2026, 6, 7, 15, 30, tzinfo=UTC) # 17:30 CEST
|
||||
r, s = anchor_sun_events(
|
||||
self.JUNE_SUNRISE + self.ONE_DAY,
|
||||
self.JUNE_SUNSET,
|
||||
now,
|
||||
HALF_WIDTH,
|
||||
)
|
||||
assert (r, s) == (self.JUNE_SUNRISE, self.JUNE_SUNSET)
|
||||
# And the curve at that moment is full day, not minimum.
|
||||
settings = SunLightSettings(
|
||||
name="t",
|
||||
min_brightness=5,
|
||||
max_brightness=100,
|
||||
min_color_temp=2200,
|
||||
max_color_temp=5500,
|
||||
ramp_half_width_seconds=HALF_WIDTH,
|
||||
)
|
||||
assert settings.brightness_pct(now, r, s) == 100
|
||||
|
||||
def test_post_sunset_ramp_tail_completes(self):
|
||||
"""10 min after sunset both sensors flipped to tomorrow.
|
||||
|
||||
Still inside the down-ramp → both pulled back so the ramp
|
||||
finishes instead of snapping to minimum.
|
||||
"""
|
||||
now = self.JUNE_SUNSET + timedelta(minutes=10)
|
||||
r, s = anchor_sun_events(
|
||||
self.JUNE_SUNRISE + self.ONE_DAY,
|
||||
self.JUNE_SUNSET + self.ONE_DAY,
|
||||
now,
|
||||
HALF_WIDTH,
|
||||
)
|
||||
assert (r, s) == (self.JUNE_SUNRISE, self.JUNE_SUNSET)
|
||||
|
||||
def test_after_ramp_tail_stays_tomorrow(self):
|
||||
"""Past sunset + half_width the tomorrow pair is fine (night = min)."""
|
||||
now = self.JUNE_SUNSET + timedelta(seconds=HALF_WIDTH, minutes=5)
|
||||
r, s = anchor_sun_events(
|
||||
self.JUNE_SUNRISE + self.ONE_DAY,
|
||||
self.JUNE_SUNSET + self.ONE_DAY,
|
||||
now,
|
||||
HALF_WIDTH,
|
||||
)
|
||||
assert (r, s) == (
|
||||
self.JUNE_SUNRISE + self.ONE_DAY,
|
||||
self.JUNE_SUNSET + self.ONE_DAY,
|
||||
)
|
||||
|
||||
def test_after_midnight_unchanged(self):
|
||||
"""00:30: sensors hold today's events again — no shift."""
|
||||
now = dt.datetime(2026, 6, 7, 0, 30, tzinfo=UTC)
|
||||
r, s = anchor_sun_events(
|
||||
self.JUNE_SUNRISE,
|
||||
self.JUNE_SUNSET,
|
||||
now,
|
||||
HALF_WIDTH,
|
||||
)
|
||||
assert (r, s) == (self.JUNE_SUNRISE, self.JUNE_SUNSET)
|
||||
|
||||
def test_winter_afternoon_still_anchors(self):
|
||||
"""Long-night season: the old guard happened to work; new code too."""
|
||||
sunrise = dt.datetime(2026, 12, 21, 7, 30, tzinfo=UTC)
|
||||
sunset = dt.datetime(2026, 12, 21, 15, 30, tzinfo=UTC)
|
||||
now = dt.datetime(2026, 12, 21, 13, 0, tzinfo=UTC)
|
||||
r, s = anchor_sun_events(sunrise + self.ONE_DAY, sunset, now, HALF_WIDTH)
|
||||
assert (r, s) == (sunrise, sunset)
|
||||
|
||||
def test_stale_today_sunset_pushed_forward(self):
|
||||
"""A 'today's sunset' sensor still holding yesterday's event."""
|
||||
now = self.JUNE_SUNRISE + timedelta(hours=4)
|
||||
r, s = anchor_sun_events(
|
||||
self.JUNE_SUNRISE,
|
||||
self.JUNE_SUNSET - self.ONE_DAY,
|
||||
now,
|
||||
HALF_WIDTH,
|
||||
)
|
||||
assert (r, s) == (self.JUNE_SUNRISE, self.JUNE_SUNSET)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue