mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-10-27 21:57:14 +01:00
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:
parent
283fec08ae
commit
e3cbb4c0b3
2 changed files with 124 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue