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>
This commit is contained in:
fir3man 2026-10-05 06:34:45 +02:00
commit 0a768be866
4 changed files with 135 additions and 26 deletions

View file

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

View file

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

View file

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

View file

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