diff --git a/custom_components/adaptive_lighting/color_and_brightness.py b/custom_components/adaptive_lighting/color_and_brightness.py index 4645f994..3a4bed0d 100644 --- a/custom_components/adaptive_lighting/color_and_brightness.py +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -445,9 +445,18 @@ class SunLightSettings: color_temp_kelvin = self.color_temp_kelvin(sun_position) r, g, b = color_temperature_to_rgb(color_temp_kelvin) rgb_color = (round(r), round(g), round(b)) + xy_color: tuple[float, float] = color_RGB_to_xy(*rgb_color) + if force_rgb_color: + # 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 = color_xy_to_cct(*xy_color) + color_temp_kelvin = 5 * round( + clamp(cct, self.sleep_color_temp, self.min_color_temp) / 5, + ) # backwards compatibility for versions < 1.3.1 - see #403 color_temp_mired: float = math.floor(1000000 / color_temp_kelvin) - xy_color: tuple[float, float] = color_RGB_to_xy(*rgb_color) hs_color: tuple[float, float] = color_xy_to_hs(*xy_color) return { "brightness_pct": brightness_pct, @@ -594,6 +603,42 @@ def lerp_color_hsv( return cast("tuple[int, int, int]", rgb) +def color_xy_to_cct(x: float, y: float) -> float: + """Return the correlated color temperature (CCT) in Kelvin for a CIE 1931 + xy chromaticity. + + The CCT is found as the color temperature whose RGB representation (via + `color_temperature_to_rgb`, i.e. the same conversion this component uses + to turn a color temperature into an RGB color) is closest to the given + chromaticity in the CIE 1960 uv plane, which is how CCT is defined. + This stays accurate for saturated colors far below the Planckian locus, + where polynomial approximations like McCamy's break down. + """ + + def _uv(x: float, y: float) -> tuple[float, float]: + d = -2 * x + 12 * y + 3 + return 4 * x / d, 6 * y / d + + u0, v0 = _uv(x, y) + + def _dist_sq(kelvin: float) -> float: + xk, yk = color_RGB_to_xy(*color_temperature_to_rgb(kelvin)) + 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). + lo_mired, hi_mired = 1e6 / 13000, 1e6 / 1000 + for _ in range(40): + m1 = lo_mired + (hi_mired - lo_mired) / 3 + m2 = hi_mired - (hi_mired - lo_mired) / 3 + if _dist_sq(1e6 / m1) < _dist_sq(1e6 / m2): + hi_mired = m2 + else: + lo_mired = m1 + return 1e6 / ((lo_mired + hi_mired) / 2) + + def lerp(x: float, x1: float, x2: float, y1: float, y2: float) -> float: """Linearly interpolate between two values.""" return y1 + (x - x1) * (y2 - y1) / (x2 - x1) diff --git a/tests/test_color_and_brightness.py b/tests/test_color_and_brightness.py index 8598b121..349b6b59 100644 --- a/tests/test_color_and_brightness.py +++ b/tests/test_color_and_brightness.py @@ -11,6 +11,7 @@ from homeassistant.components.adaptive_lighting.color_and_brightness import ( SunEvents, SunLightSettings, clamp, + color_xy_to_cct, ) # Create a mock astral location object (its `.observer` is passed to `SunEvents`) @@ -547,3 +548,80 @@ def test_brightness_and_color_on_polar_days(date): at_midnight = settings.brightness_and_color(midnight, is_sleep=False) assert at_midnight["brightness_pct"] == 30 assert at_midnight["color_temp_kelvin"] == 2000 + + +def test_color_xy_to_cct_roundtrip(): + """CCT derived from a blackbody RGB color must match the original kelvins.""" + from homeassistant.util.color import ( + color_RGB_to_xy, + color_temperature_to_rgb, + ) + + for kelvin in (1500, 2000, 2700, 3000, 4000, 5500, 6500): + xy = color_RGB_to_xy(*color_temperature_to_rgb(kelvin)) + cct = color_xy_to_cct(*xy) + assert abs(cct - kelvin) / kelvin < 0.02, (kelvin, cct) + + +def test_adapt_until_sleep_ct_follows_rgb_blend(): + """With `adapt_until_sleep` and an RGB sleep color, color-temp-only lights + must follow the perceived color of the RGB blend (CCT derived from the + blended color) instead of a separate linear interpolation in kelvin space, + which diverges perceptually from the HSV blend used for `rgb_color` (#624). + """ + from homeassistant.util.color import color_RGB_to_xy + + settings = SunLightSettings( + name="test", + astral_observer=location.observer, + adapt_until_sleep=True, + max_brightness=100, + max_color_temp=5500, + min_brightness=30, + min_color_temp=3000, + 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, + ) + 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_kelvin = None + seen_forced = 0 + for fraction in (0.15, 0.3, 0.5, 0.7, 0.9): + moment = sunset + (midnight - sunset) * fraction + result = settings.brightness_and_color(moment, is_sleep=False) + if not result["force_rgb_color"]: + continue + seen_forced += 1 + expected_cct = clamp( + color_xy_to_cct(*color_RGB_to_xy(*result["rgb_color"])), + settings.sleep_color_temp, + settings.min_color_temp, + ) + assert abs(result["color_temp_kelvin"] - expected_cct) <= 5, ( + moment, + result, + ) + assert ( + settings.sleep_color_temp + <= result["color_temp_kelvin"] + <= settings.min_color_temp + ) + if previous_kelvin is not None: + assert result["color_temp_kelvin"] <= previous_kelvin + previous_kelvin = result["color_temp_kelvin"] + assert seen_forced > 0