From 5bf28bd7290dfcaeae779e05c13f929243e7aa2a Mon Sep 17 00:00:00 2001 From: Casey Date: Sun, 7 Jun 2026 20:26:42 +0200 Subject: [PATCH] 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. --- .../adaptive_lighting/color_and_brightness.py | 36 +++++ custom_components/adaptive_lighting/switch.py | 26 ++-- tests/test_color_and_brightness.py | 123 ++++++++++++++++++ 3 files changed, 172 insertions(+), 13 deletions(-) diff --git a/custom_components/adaptive_lighting/color_and_brightness.py b/custom_components/adaptive_lighting/color_and_brightness.py index 2b00b252..731f8ac1 100644 --- a/custom_components/adaptive_lighting/color_and_brightness.py +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -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) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index ae07f687..ee9ae543 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -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. diff --git a/tests/test_color_and_brightness.py b/tests/test_color_and_brightness.py index 77f29e5b..a724d8d2 100644 --- a/tests/test_color_and_brightness.py +++ b/tests/test_color_and_brightness.py @@ -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)