From 0a768be866d3f4bc74791ca55ce19c5f193fe141 Mon Sep 17 00:00:00 2001 From: fir3man Date: Mon, 5 Oct 2026 06:34:45 +0200 Subject: [PATCH] fix: handle black sleep color and anchor match_rgb trajectory at sunset Address review findings on transition_until_sleep_ct_mode: match_rgb: - A black `sleep_rgb_color` (or a black blend) has no chromaticity, so there is no color temperature to match. Fall back to the linear kelvin interpolation so color-temp-only lights end at `sleep_color_temp` instead of being clamped to `min_color_temp`. - The kelvin -> RGB -> CCT round trip is approximate, so the CCT of the RGB color shown right after sunset is not exactly `min_color_temp`. Anchor the start of the trajectory to that CCT so there is no color temperature step at sunset. - Document the accepted range (0.1-10) of `transition_until_sleep_ct_curve` in README.md and docs/configuration.md, as the table formatter does. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- .../adaptive_lighting/color_and_brightness.py | 70 ++++++++++----- docs/configuration.md | 2 +- tests/test_color_and_brightness.py | 87 ++++++++++++++++++- 4 files changed, 135 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 14214863..56062805 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ The YAML and frontend configuration methods support all of the options listed be | `sleep_transition` | Duration of transition when "sleep mode" is toggled in seconds. 😴 | `1` | `float` 0-6553 | | `transition_until_sleep` | When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙 | `False` | `bool` | | `transition_until_sleep_ct_mode` | How color-temperature-only lights transition after sunset when `transition_until_sleep` is enabled and `sleep_rgb_or_color_temp` is `rgb_color`. `linear`: interpolate `min_color_temp` → `sleep_color_temp` in Kelvin. `match_rgb`: use the color temperature of the RGB blend that RGB lights show at the same moment, so mixed setups look uniform. 🌡️ | `linear` | one of `['linear', 'match_rgb']` | -| `transition_until_sleep_ct_curve` | Only used with `transition_until_sleep_ct_mode: match_rgb`. Calibrates how fast color-temperature-only lights warm up towards `sleep_color_temp` compared to the CCT of the RGB blend: `1` follows the CCT, `>1` warms up earlier (use if your color-temp lights still look colder than your RGB lights), `<1` later. 🎛️ | `1.0` | `float` | +| `transition_until_sleep_ct_curve` | Only used with `transition_until_sleep_ct_mode: match_rgb`. Calibrates how fast color-temperature-only lights warm up towards `sleep_color_temp` compared to the CCT of the RGB blend: `1` follows the CCT, `>1` warms up earlier (use if your color-temp lights still look colder than your RGB lights), `<1` later. 🎛️ | `1.0` | `float` 0.1-10 | | `sunrise_time` | Set a fixed time (HH:MM:SS) for sunrise. 🌅 | `None` | `str` | | `min_sunrise_time` | Set the earliest virtual sunrise time (HH:MM:SS), allowing for later sunrises. 🌅 | `None` | `str` | | `max_sunrise_time` | Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅 | `None` | `str` | diff --git a/custom_components/adaptive_lighting/color_and_brightness.py b/custom_components/adaptive_lighting/color_and_brightness.py index 5ee06eff..8beb5d57 100644 --- a/custom_components/adaptive_lighting/color_and_brightness.py +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -412,6 +412,47 @@ class SunLightSettings: msg = "Should not happen" raise ValueError(msg) + def _color_temp_kelvin_matching_rgb( + self, + rgb_color: tuple[int, int, int], + xy_color: tuple[float, float], + linear_color_temp_kelvin: int, + ) -> int: + """Color temperature for color-temp-only lights following the RGB blend. + + Keeps color-temp-only lights on the same perceived trajectory as the + RGB blend after sunset: the CCT is derived from the blended color + instead of interpolating in kelvin space, which diverges perceptually + from the HSV interpolation used for `rgb_color` (see #624). + """ + if not any(self.sleep_rgb_color) or not any(rgb_color): + # Black has no chromaticity, so there is no color temperature to + # match; fall back to the linear interpolation, which ends at + # `sleep_color_temp` when the blend reaches black. + return linear_color_temp_kelvin + mired_min = 1e6 / self.min_color_temp + mired_sleep = 1e6 / self.sleep_color_temp + mired = 1e6 / color_xy_to_cct(*xy_color) + # The kelvin -> RGB -> CCT round trip is approximate, so the CCT of the + # RGB color shown right after sunset is not exactly `min_color_temp`. + # Anchor the start of the trajectory to that CCT, so color-temp-only + # lights keep `min_color_temp` at sunset instead of stepping to a + # slightly different value. + r, g, b = color_temperature_to_rgb(self.min_color_temp) + min_rgb_xy = color_RGB_to_xy(round(r), round(g), round(b)) + mired_start = 1e6 / color_xy_to_cct(*min_rgb_xy) + if mired_sleep <= mired_start: + # Degenerate configuration (sleep_color_temp >= min_color_temp). + cct = clamp(1e6 / mired, self.sleep_color_temp, self.min_color_temp) + return 5 * round(cct / 5) + progress = clamp((mired - mired_start) / (mired_sleep - mired_start), 0, 1) + # Optional calibration: reshape the progress towards + # `sleep_color_temp` in mired space (perceptually uniform), so that + # `>1` reaches warm tones earlier and `<1` later. + progress **= 1 / self.adapt_until_sleep_ct_curve + cct = 1e6 / (mired_min + progress * (mired_sleep - mired_min)) + return 5 * round(cct / 5) + def brightness_and_color( self, dt: datetime.datetime, @@ -449,28 +490,11 @@ class SunLightSettings: rgb_color = (round(r), round(g), round(b)) xy_color: tuple[float, float] = color_RGB_to_xy(*rgb_color) if force_rgb_color and self.adapt_until_sleep_ct_mode == "match_rgb": - # Keep color-temp-only lights on the same perceived trajectory as - # the RGB blend: derive the CCT from the blended color instead of - # interpolating in kelvin space, which diverges perceptually from - # the HSV interpolation used for `rgb_color` (see #624). - cct = clamp( - color_xy_to_cct(*xy_color), - self.sleep_color_temp, - self.min_color_temp, + color_temp_kelvin = self._color_temp_kelvin_matching_rgb( + rgb_color, + xy_color, + color_temp_kelvin, ) - if self.adapt_until_sleep_ct_curve != 1: - # Optional calibration: reshape the progress towards - # `sleep_color_temp` in mired space (perceptually uniform), so - # that `>1` reaches warm tones earlier and `<1` later. - mired_min = 1e6 / self.min_color_temp - mired_sleep = 1e6 / self.sleep_color_temp - if mired_sleep != mired_min: - progress = (1e6 / cct - mired_min) / (mired_sleep - mired_min) - progress = clamp(progress, 0, 1) ** ( - 1 / self.adapt_until_sleep_ct_curve - ) - cct = 1e6 / (mired_min + progress * (mired_sleep - mired_min)) - color_temp_kelvin = 5 * round(cct / 5) # backwards compatibility for versions < 1.3.1 - see #403 color_temp_mired: float = math.floor(1000000 / color_temp_kelvin) hs_color: tuple[float, float] = color_xy_to_hs(*xy_color) @@ -641,8 +665,8 @@ def color_xy_to_cct(x: float, y: float) -> float: uk, vk = _uv(xk, yk) return (u0 - uk) ** 2 + (v0 - vk) ** 2 - # Ternary search in mired space (perceptually uniform) over the range - # supported by `color_temperature_to_rgb` (1000-40000 K). + # Ternary search in mired space (perceptually uniform). 1000-13000 K covers + # every configurable color temperature with a wide margin. lo_mired, hi_mired = 1e6 / 13000, 1e6 / 1000 for _ in range(40): m1 = lo_mired + (hi_mired - lo_mired) / 3 diff --git a/docs/configuration.md b/docs/configuration.md index b07e62f0..72dbb4c9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -50,7 +50,7 @@ All configuration options are listed below with their default values. These opti | `sleep_transition` | Duration of transition when "sleep mode" is toggled in seconds. 😴 | `1` | `float` 0-6553 | | `transition_until_sleep` | When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙 | `False` | `bool` | | `transition_until_sleep_ct_mode` | How color-temperature-only lights transition after sunset when `transition_until_sleep` is enabled and `sleep_rgb_or_color_temp` is `rgb_color`. `linear`: interpolate `min_color_temp` → `sleep_color_temp` in Kelvin. `match_rgb`: use the color temperature of the RGB blend that RGB lights show at the same moment, so mixed setups look uniform. 🌡️ | `linear` | one of `['linear', 'match_rgb']` | -| `transition_until_sleep_ct_curve` | Only used with `transition_until_sleep_ct_mode: match_rgb`. Calibrates how fast color-temperature-only lights warm up towards `sleep_color_temp` compared to the CCT of the RGB blend: `1` follows the CCT, `>1` warms up earlier (use if your color-temp lights still look colder than your RGB lights), `<1` later. 🎛️ | `1.0` | `float` | +| `transition_until_sleep_ct_curve` | Only used with `transition_until_sleep_ct_mode: match_rgb`. Calibrates how fast color-temperature-only lights warm up towards `sleep_color_temp` compared to the CCT of the RGB blend: `1` follows the CCT, `>1` warms up earlier (use if your color-temp lights still look colder than your RGB lights), `<1` later. 🎛️ | `1.0` | `float` 0.1-10 | | `sunrise_time` | Set a fixed time (HH:MM:SS) for sunrise. 🌅 | `None` | `str` | | `min_sunrise_time` | Set the earliest virtual sunrise time (HH:MM:SS), allowing for later sunrises. 🌅 | `None` | `str` | | `max_sunrise_time` | Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅 | `None` | `str` | diff --git a/tests/test_color_and_brightness.py b/tests/test_color_and_brightness.py index 0529ed74..082a3193 100644 --- a/tests/test_color_and_brightness.py +++ b/tests/test_color_and_brightness.py @@ -613,7 +613,10 @@ def test_adapt_until_sleep_ct_follows_rgb_blend(): settings.sleep_color_temp, settings.min_color_temp, ) - assert abs(result["color_temp_kelvin"] - expected_cct) <= 5, ( + # The trajectory is anchored at `min_color_temp` for the RGB color + # shown at sunset (whose CCT is only approximately `min_color_temp`), + # so allow for that small, constant offset. + assert abs(result["color_temp_kelvin"] - expected_cct) <= 40, ( moment, result, ) @@ -723,3 +726,85 @@ def test_adapt_until_sleep_ct_mode_linear_is_default_and_unchanged(): assert result["color_temp_kelvin"] == settings.color_temp_kelvin( result["sun_position"], ) + + +def _settings_until_sleep(**overrides): + kwargs = { + "name": "test", + "astral_observer": location.observer, + "adapt_until_sleep": True, + "max_brightness": 100, + "max_color_temp": 5500, + "min_brightness": 30, + "min_color_temp": 2700, + "sleep_brightness": 1, + "sleep_rgb_or_color_temp": "rgb_color", + "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": dt.timezone.utc, + "adapt_until_sleep_ct_mode": "match_rgb", + } + kwargs.update(overrides) + return SunLightSettings(**kwargs) + + +@pytest.mark.parametrize("min_color_temp", [2000, 2700, 3000, 4000]) +@pytest.mark.parametrize("curve", [1.0, 2.5]) +def test_adapt_until_sleep_match_rgb_no_step_at_sunset(min_color_temp, curve): + """`match_rgb` must keep `min_color_temp` right after sunset. + + The kelvin -> RGB -> CCT round trip is approximate, so without anchoring + the trajectory the color temperature would step to a slightly different + value the moment the sun goes below the horizon. + """ + settings = _settings_until_sleep( + min_color_temp=min_color_temp, + adapt_until_sleep_ct_curve=curve, + ) + date = dt.datetime(2022, 6, 1, tzinfo=dt.timezone.utc) + sunset = settings.sun.sunset(date.date()) + at_sunset = settings.brightness_and_color(sunset, is_sleep=False) + assert not at_sunset["force_rgb_color"] + assert at_sunset["color_temp_kelvin"] == min_color_temp + just_after = settings.brightness_and_color( + sunset + dt.timedelta(seconds=1), + is_sleep=False, + ) + assert just_after["force_rgb_color"] + assert just_after["color_temp_kelvin"] == min_color_temp + + +def test_adapt_until_sleep_match_rgb_black_sleep_color(): + """A black `sleep_rgb_color` has no chromaticity to match. + + Color-temp-only lights must still end at `sleep_color_temp` (not at + `min_color_temp`) and warm up monotonically in between. + """ + settings = _settings_until_sleep(sleep_rgb_color=(0, 0, 0)) + date = dt.datetime(2022, 6, 1, tzinfo=dt.timezone.utc) + sunset = settings.sun.sunset(date.date()) + _, midnight = settings.sun.noon_and_midnight(date) + if midnight < sunset: + midnight += dt.timedelta(days=1) + + previous = None + for fraction in (0.01, 0.25, 0.5, 0.75, 0.99, 1.0): + result = settings.brightness_and_color( + sunset + (midnight - sunset) * fraction, + is_sleep=False, + ) + assert result["force_rgb_color"] + assert 1000 <= result["color_temp_kelvin"] <= 2700 + if previous is not None: + assert result["color_temp_kelvin"] <= previous + previous = result["color_temp_kelvin"] + assert result["rgb_color"] == (0, 0, 0) + assert result["color_temp_kelvin"] == settings.sleep_color_temp