mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-10-28 06:07:13 +01:00
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:
parent
d8774dcd25
commit
0a768be866
4 changed files with 135 additions and 26 deletions
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue