feat: add transition_until_sleep_ct_curve to calibrate CT-only lights

Deriving the CCT from the RGB blend is colorimetrically correct, but a
color-temperature-only bulb cannot reproduce the saturation of the
blended orange, and real bulbs render nominal kelvins differently. In
practice CT-only lights can still look colder than the RGB lights next
to them.

Add an optional `transition_until_sleep_ct_curve` (default 1.0 = follow
the CCT exactly) that reshapes the progress towards `sleep_color_temp`
in mired space: `>1` reaches warm tones earlier, `<1` later. The
endpoints (min_color_temp at sunset, sleep_color_temp at sleep) are
unchanged, so there is still no jump at sunset.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
fir3man 2026-09-30 22:12:13 +02:00
commit ba441747f7
9 changed files with 106 additions and 9 deletions

View file

@ -145,6 +145,7 @@ The YAML and frontend configuration methods support all of the options listed be
| `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈 | `[255, 56, 0]` | RGB color |
| `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_curve` | Only used with `transition_until_sleep` and `sleep_rgb_or_color_temp: rgb_color`. Shapes 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 look colder than your RGB lights in the evening), `<1` later. 🌡️ | `1.0` | `float` |
| `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

@ -297,6 +297,7 @@ class SunLightSettings:
brightness_mode_time_dark: datetime.timedelta
brightness_mode_time_light: datetime.timedelta
brightness_mode: Literal["default", "linear", "tanh"] = "default"
adapt_until_sleep_ct_curve: float = 1.0
sunrise_offset: datetime.timedelta = datetime.timedelta()
sunset_offset: datetime.timedelta = datetime.timedelta()
timezone: datetime.tzinfo = UTC
@ -451,10 +452,24 @@ class SunLightSettings:
# 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,
cct = clamp(
color_xy_to_cct(*xy_color),
self.sleep_color_temp,
self.min_color_temp,
)
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)
@ -604,8 +619,7 @@ def lerp_color_hsv(
def color_xy_to_cct(x: float, y: float) -> float:
"""Return the correlated color temperature (CCT) in Kelvin for a CIE 1931
xy chromaticity.
"""Return the correlated color temperature (CCT) in Kelvin of a CIE 1931 xy.
The CCT is found as the color temperature whose RGB representation (via
`color_temperature_to_rgb`, i.e. the same conversion this component uses

View file

@ -58,8 +58,7 @@ DOCS[CONF_INCLUDE_CONFIG_IN_ATTRIBUTES] = (
CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION = "initial_transition", 1
DOCS[CONF_INITIAL_TRANSITION] = (
"Duration of the first transition when lights turn "
"from `off` to `on` in seconds. ⏲️"
"Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️"
)
CONF_SLEEP_TRANSITION, DEFAULT_SLEEP_TRANSITION = "sleep_transition", 1
@ -162,8 +161,7 @@ DOCS[CONF_MIN_SUNRISE_TIME] = (
CONF_MAX_SUNRISE_TIME = "max_sunrise_time"
DOCS[CONF_MAX_SUNRISE_TIME] = (
"Set the latest virtual sunrise time (HH:MM:SS), allowing"
" for earlier sunrises. 🌅"
"Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅"
)
CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET = "sunset_offset", 0
@ -235,6 +233,18 @@ DOCS[CONF_ADAPT_UNTIL_SLEEP] = (
"transitioning to these values after sunset. 🌙"
)
CONF_ADAPT_UNTIL_SLEEP_CT_CURVE, DEFAULT_ADAPT_UNTIL_SLEEP_CT_CURVE = (
"transition_until_sleep_ct_curve",
1.0,
)
DOCS[CONF_ADAPT_UNTIL_SLEEP_CT_CURVE] = (
"Only used with `transition_until_sleep` and `sleep_rgb_or_color_temp: "
"rgb_color`. Shapes 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 look colder than "
"your RGB lights in the evening), `<1` later. 🌡️"
)
CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY = "adapt_delay", 0
DOCS[CONF_ADAPT_DELAY] = (
"Wait time (seconds) between light turn on and Adaptive Lighting applying "
@ -390,6 +400,11 @@ VALIDATION_TUPLES: list[tuple[str, Any, Any]] = [
),
(CONF_SLEEP_TRANSITION, DEFAULT_SLEEP_TRANSITION, VALID_TRANSITION),
(CONF_ADAPT_UNTIL_SLEEP, DEFAULT_ADAPT_UNTIL_SLEEP, bool),
(
CONF_ADAPT_UNTIL_SLEEP_CT_CURVE,
DEFAULT_ADAPT_UNTIL_SLEEP_CT_CURVE,
vol.All(vol.Coerce(float), vol.Range(min=0.1, max=10)),
),
(CONF_SUNRISE_TIME, NONE_STR, str),
(CONF_MIN_SUNRISE_TIME, NONE_STR, str),
(CONF_MAX_SUNRISE_TIME, NONE_STR, str),

View file

@ -53,6 +53,7 @@
"sleep_rgb_color": "sleep_rgb_color",
"sleep_transition": "sleep_transition",
"transition_until_sleep": "transition_until_sleep: When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙",
"transition_until_sleep_ct_curve": "transition_until_sleep_ct_curve",
"sunrise_time": "sunrise_time",
"min_sunrise_time": "min_sunrise_time",
"max_sunrise_time": "max_sunrise_time",
@ -86,6 +87,7 @@
"sleep_rgb_or_color_temp": "Use either `\"rgb_color\"` or `\"color_temp\"` in sleep mode. 🌙",
"sleep_rgb_color": "RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is \"rgb_color\"). 🌈",
"sleep_transition": "Duration of transition when \"sleep mode\" is toggled in seconds. 😴",
"transition_until_sleep_ct_curve": "Only used with `transition_until_sleep` and `sleep_rgb_or_color_temp: rgb_color`. Shapes 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 look colder than your RGB lights in the evening), `<1` later. 🌡️",
"sunrise_time": "Set a fixed time (HH:MM:SS) for sunrise. 🌅",
"min_sunrise_time": "Set the earliest virtual sunrise time (HH:MM:SS), allowing for later sunrises. 🌅",
"max_sunrise_time": "Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅",

View file

@ -96,6 +96,7 @@ from .const import (
CONF_ADAPT_DELAY,
CONF_ADAPT_ONLY_ON_BARE_TURN_ON,
CONF_ADAPT_UNTIL_SLEEP,
CONF_ADAPT_UNTIL_SLEEP_CT_CURVE,
CONF_AUTORESET_CONTROL,
CONF_BRIGHTNESS_MODE,
CONF_BRIGHTNESS_MODE_TIME_DARK,
@ -1018,6 +1019,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
name=self._name,
astral_observer=observer,
adapt_until_sleep=data[CONF_ADAPT_UNTIL_SLEEP],
adapt_until_sleep_ct_curve=data[CONF_ADAPT_UNTIL_SLEEP_CT_CURVE],
max_brightness=data[CONF_MAX_BRIGHTNESS],
max_color_temp=data[CONF_MAX_COLOR_TEMP],
min_brightness=data[CONF_MIN_BRIGHTNESS],

View file

@ -54,6 +54,7 @@
"sleep_rgb_color": "sleep_rgb_color",
"sleep_transition": "sleep_transition",
"transition_until_sleep": "transition_until_sleep: When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙",
"transition_until_sleep_ct_curve": "transition_until_sleep_ct_curve",
"sunrise_time": "sunrise_time",
"min_sunrise_time": "min_sunrise_time",
"max_sunrise_time": "max_sunrise_time",
@ -87,6 +88,7 @@
"sleep_rgb_or_color_temp": "Use either `\"rgb_color\"` or `\"color_temp\"` in sleep mode. 🌙",
"sleep_rgb_color": "RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is \"rgb_color\"). 🌈",
"sleep_transition": "Duration of transition when \"sleep mode\" is toggled in seconds. 😴",
"transition_until_sleep_ct_curve": "Only used with `transition_until_sleep` and `sleep_rgb_or_color_temp: rgb_color`. Shapes 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 look colder than your RGB lights in the evening), `<1` later. 🌡️",
"sunrise_time": "Set a fixed time (HH:MM:SS) for sunrise. 🌅",
"min_sunrise_time": "Set the earliest virtual sunrise time (HH:MM:SS), allowing for later sunrises. 🌅",
"max_sunrise_time": "Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅",

View file

@ -43,6 +43,7 @@
"prefer_rgb_color": "prefer_rgb_color: Czy w miarę możliwości preferować regulację kolorów RGB zamiast regulacji temperatury barwowej światła. 🌈",
"sleep_transition": "sleep_transition: When 'sleep_state' changes. (sekund)",
"transition_until_sleep": "transition_until_sleep: Gdy włączone, Adaptacyjne oświetlenie będzie traktowało ustawienia spania jako minimalne i przejdzie do nich po zachodzie słońca. 🌙",
"transition_until_sleep_ct_curve": "transition_until_sleep_ct_curve",
"sunrise_time": "sunrise_time: Manual override of the sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)",
"sunrise_offset": "sunrise_offset: How long before(-) or after(+) to define the sunrise point of the cycle (+/- sekund)",
"sunset_time": "sunset_time: Manual override of the sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)",
@ -62,6 +63,7 @@
"sleep_rgb_or_color_temp": "Użyj `\"rgb_color\"` albo `\"color_temp\"` w trybie spania. 🌙",
"sleep_rgb_color": "Kolor RGB w trybie spania (używane, gdy `sleep_rgb_or_color_temp` jest `rgb_color`). 🌈",
"sleep_transition": "Długość przejścia, gdy nastąpi włączenie/wyłączenie \"trybu spania\" (w sekundach). 😴",
"transition_until_sleep_ct_curve": "Używane tylko z `transition_until_sleep` i `sleep_rgb_or_color_temp: rgb_color`. Określa, jak szybko żarówki obsługujące tylko temperaturę barwową ocieplają się w stronę `sleep_color_temp` względem CCT mieszanki RGB: `1` podąża za CCT, `>1` ociepla wcześniej (użyj, gdy żarówki CT wyglądają wieczorem chłodniej niż RGB), `<1` później. 🌡️",
"sunrise_time": "Ustaw stały czas wschodu słońca (HH:MM:SS). 🌅",
"min_sunrise_time": "Ustaw czas najwcześniejszego wirtualnego wschodu słońca (HH:MM:SS), pozwala na opóźnienie wschodu słońca. 🌅",
"max_sunrise_time": "Ustaw czas najpóźniejszego wirtualnego wschodu słońca (HH:MM:SS), pozwala na przyspieszenie wschodu słońca. 🌅",

View file

@ -49,6 +49,7 @@ All configuration options are listed below with their default values. These opti
| `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈 | `[255, 56, 0]` | RGB color |
| `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_curve` | Only used with `transition_until_sleep` and `sleep_rgb_or_color_temp: rgb_color`. Shapes 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 look colder than your RGB lights in the evening), `<1` later. 🌡️ | `1.0` | `float` |
| `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

@ -625,3 +625,61 @@ def test_adapt_until_sleep_ct_follows_rgb_blend():
assert result["color_temp_kelvin"] <= previous_kelvin
previous_kelvin = result["color_temp_kelvin"]
assert seen_forced > 0
@pytest.mark.parametrize(("curve", "expect_warmer"), [(3.0, True), (0.5, False)])
def test_adapt_until_sleep_ct_curve(curve, expect_warmer):
"""`transition_until_sleep_ct_curve` reshapes the color-temp trajectory:
`>1` makes CT-only lights warmer than the plain CCT of the RGB blend at the
same moment, `<1` colder, while the endpoints (sunset/sleep) are unchanged.
"""
kwargs = {
"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,
}
plain = SunLightSettings(**kwargs)
shaped = SunLightSettings(**kwargs, adapt_until_sleep_ct_curve=curve)
date = dt.datetime(2022, 6, 1, tzinfo=dt.timezone.utc)
sunset = plain.sun.sunset(date.date())
_, midnight = plain.sun.noon_and_midnight(date)
if midnight < sunset:
midnight += dt.timedelta(days=1)
checked = 0
for fraction in (0.3, 0.5, 0.7):
moment = sunset + (midnight - sunset) * fraction
a = plain.brightness_and_color(moment, is_sleep=False)
b = shaped.brightness_and_color(moment, is_sleep=False)
if not a["force_rgb_color"]:
continue
assert a["rgb_color"] == b["rgb_color"]
if 1000 < a["color_temp_kelvin"] < 3000:
checked += 1
if expect_warmer:
assert b["color_temp_kelvin"] < a["color_temp_kelvin"]
else:
assert b["color_temp_kelvin"] > a["color_temp_kelvin"]
assert 1000 <= b["color_temp_kelvin"] <= 3000
assert checked > 0
# endpoints unchanged
at_sunset = shaped.brightness_and_color(sunset, is_sleep=False)
assert at_sunset["color_temp_kelvin"] == 3000