fix: keep color-temp-only lights on the RGB sleep blend trajectory

When `transition_until_sleep` is enabled with `sleep_rgb_or_color_temp:
rgb_color`, RGB-capable lights blend toward `sleep_rgb_color` in HSV
space, while color-temp-only lights interpolate min_color_temp ->
sleep_color_temp linearly in kelvin space. The two trajectories diverge
perceptually, so CT-only bulbs look noticeably colder than RGB bulbs all
evening (follow-up to #624, which introduced the RGB blend).

Derive the color temperature for CT-only lights from the blended RGB
color itself: find the CCT (nearest point on the Planckian locus in CIE
1960 uv, consistent with color_temperature_to_rgb) of the blended color,
clamped between sleep_color_temp and min_color_temp. This also removes
the CT discontinuity at sunset since CCT(rgb(min_color_temp)) ==
min_color_temp by construction.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
fir3man 2026-09-30 21:55:49 +02:00
commit e3cbb4c0b3
2 changed files with 124 additions and 1 deletions

View file

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

View file

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