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:
Casey 2026-06-07 20:26:42 +02:00
commit 5bf28bd729
3 changed files with 172 additions and 13 deletions

View file

@ -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)

View file

@ -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.