basnijholt / adaptive-lighting

Adaptive Lighting custom component for Home Assistant
https://basnijholt.github.io/adaptive-lighting/
Apache License 2.0
1.9k stars 136 forks source link

feat: add different brightness ramping mechanisms #699

Closed basnijholt closed 1 year ago

basnijholt commented 1 year ago

This PR allows setting different brightness_modes which determine how the brightness changes around sunrise and sunset. Here brightness_mode can be either "default" (current behavior), "linear", or "tanh". Additionally, when not using "default", you can set brightness_mode_time_dark and brightness_mode_time_light.

with brightness_mode: "linear":

with brightness_mode: "tanh" it uses the smooth shape of a hyperbolic tangent function:

Closes https://github.com/basnijholt/adaptive-lighting/issues/616, https://github.com/basnijholt/adaptive-lighting/issues/437, https://github.com/basnijholt/adaptive-lighting/issues/218, https://github.com/basnijholt/adaptive-lighting/issues/242, https://github.com/basnijholt/adaptive-lighting/issues/127, https://github.com/basnijholt/adaptive-lighting/issues/94, https://github.com/basnijholt/adaptive-lighting/issues/72

basnijholt commented 1 year ago

Some code to plot the results:

import numpy as np
import matplotlib.pyplot as plt
import math
from typing import Tuple

import mplcyberpunk

plt.style.use("cyberpunk")

def lerp(x, x1, x2, y1, y2):
    """Linearly interpolate between two values."""
    return y1 + (x - x1) * (y2 - y1) / (x2 - x1)

def clamp(value: float, minimum: float, maximum: float) -> float:
    """Clamp value between minimum and maximum."""
    return max(minimum, min(value, maximum))

def find_a_b(x1: float, x2: float, y1: float, y2: float) -> Tuple[float, float]:
    a = (math.atanh(2 * y2 - 1) - math.atanh(2 * y1 - 1)) / (x2 - x1)
    b = x1 - (math.atanh(2 * y1 - 1) / a)
    return a, b

def scaled_tanh(
    x: float,
    a: float,
    b: float,
    y_min: float = 0.0,
    y_max: float = 1.0,
) -> float:
    """Apply a scaled and shifted tanh function to a given input."""
    return y_min + (y_max - y_min) * 0.5 * (math.tanh(a * (x - b)) + 1)

def is_closer_to_sunrise_than_sunset(time, sunrise_time, sunset_time):
    """Return True if the time is closer to sunrise than sunset."""
    return abs(time - sunrise_time) < abs(time - sunset_time)

def brightness_linear(
    time,
    sunrise_time,
    sunset_time,
    time_light,
    time_dark,
    max_brightness,
    min_brightness,
):
    """Calculate the brightness for the 'linear' mode."""
    closer_to_sunrise = is_closer_to_sunrise_than_sunset(
        time, sunrise_time, sunset_time
    )
    if closer_to_sunrise:
        brightness = lerp(
            time,
            x1=sunrise_time - time_dark,
            x2=sunrise_time + time_light,
            y1=min_brightness,
            y2=max_brightness,
        )
    else:
        brightness = lerp(
            time,
            x1=sunset_time - time_light,
            x2=sunset_time + time_dark,
            y1=max_brightness,
            y2=min_brightness,
        )
    return clamp(brightness, min_brightness, max_brightness)

def brightness_tanh(
    time,
    sunrise_time,
    sunset_time,
    time_light,
    time_dark,
    max_brightness,
    min_brightness,
):
    """Calculate the brightness for the 'tanh' mode."""
    closer_to_sunrise = is_closer_to_sunrise_than_sunset(
        time, sunrise_time, sunset_time
    )
    if closer_to_sunrise:
        a, b = find_a_b(
            x1=-time_dark,
            x2=time_light,
            y1=0.05,  # be at 5% of range at x1
            y2=0.95,  # be at 95% of range at x2
        )
        brightness = scaled_tanh(
            time - sunrise_time,
            a=a,
            b=b,
            y_min=min_brightness,
            y_max=max_brightness,
        )
    else:
        a, b = find_a_b(
            x1=-time_light,  # shifted timestamp for the start of sunset
            x2=time_dark,  # shifted timestamp for the end of sunset
            y1=0.95,  # be at 95% of range at the start of sunset
            y2=0.05,  # be at 5% of range at the end of sunset
        )
        brightness = scaled_tanh(
            time - sunset_time,
            a=a,
            b=b,
            y_min=min_brightness,
            y_max=max_brightness,
        )
    return clamp(brightness, min_brightness, max_brightness)

# Define the constants
sunrise_time = 6  # 6 AM
sunset_time = 18  # 6 PM
max_brightness = 1.0  # 100%
min_brightness = 0.3  # 30%
brightness_mode_time_dark = 3.0
brightness_mode_time_light = 0.5

for min_brightness, brightness_mode_time_dark, brightness_mode_time_light in [
    (0.0, 2, 0),
    (0.0, 0, 2),
    (0.3, 3.0, 0.5),
    (0.1, 4, 4)
]:
    # Define the time range for our simulation
    time_range = np.linspace(0, 24, 1000)  # From 0 to 24 hours

    # Calculate the brightness for each time in the time range for both modes
    brightness_linear_values = [
        brightness_linear(
            time,
            sunrise_time,
            sunset_time,
            brightness_mode_time_light,
            brightness_mode_time_dark,
            max_brightness,
            min_brightness,
        )
        for time in time_range
    ]
    brightness_tanh_values = [
        brightness_tanh(
            time,
            sunrise_time,
            sunset_time,
            brightness_mode_time_light,
            brightness_mode_time_dark,
            max_brightness,
            min_brightness,
        )
        for time in time_range
    ]

    # Plot the brightness over time for both modes
    plt.figure(figsize=(10, 6))
    plt.plot(time_range, brightness_linear_values, label="Linear Mode")
    plt.plot(time_range, brightness_tanh_values, label="Tanh Mode")
    plt.vlines(sunrise_time, 0, 1, color="C2", label="Sunrise", linestyles="dashed")
    plt.vlines(sunset_time, 0, 1, color="C3", label="Sunset", linestyles="dashed")
    plt.xlim(0, 24)
    plt.xticks(np.arange(0, 25, 1))
    yticks = np.arange(0, 1.05, 0.05)
    ytick_labels = [f"{100*label:.0f}%" for label in yticks]
    plt.yticks(yticks, ytick_labels)
    plt.xlabel("Time (hours)")
    plt.ylabel("Brightness")
    plt.title("Brightness over Time for Different Modes")
    # Add text box
    textstr = "\n".join(
        (
            f"Sunrise Time = {sunrise_time}:00:00",
            f"Sunset Time = {sunset_time}:00:00",
            f"Max Brightness = {max_brightness*100:.0f}%",
            f"Min Brightness = {min_brightness*100:.0f}%",
            f"Time Light = {brightness_mode_time_light} hours",
            f"Time Dark = {brightness_mode_time_dark} hours",
        )
    )

    # these are matplotlib.patch.Patch properties
    props = dict(boxstyle="round", facecolor="wheat", alpha=0.5)

    plt.legend()
    plt.grid(True)
    mplcyberpunk.add_glow_effects()

    # place a text box in upper left in axes coords
    plt.gca().text(
        0.4,
        0.55,
        textstr,
        transform=plt.gca().transAxes,
        fontsize=10,
        verticalalignment="center",
        bbox=props,
    )

    plt.show()
basnijholt commented 1 year ago

Some more examples: Notice the values of brightness_mode_time_light and brightness_mode_time_dark in the text box. image image image image

pacjo commented 1 year ago

Can we have something like this for temperature too?

basnijholt commented 1 year ago

Yes that might be possible but I would like to make something to go between a set of custom colors or color temperatures. However, I need to think of a good interface to set it.

pacjo commented 1 year ago

I need to think of a good interface to set it.

How about just allowing users to put in a specific formula and then maybe add some examples to readme (with link in the HA options menu)?

basnijholt commented 1 year ago

I think this will lead to a lot of trouble and confusion because the math is likely non-trivial for most folks. For example check the tanh derivation: https://github.com/basnijholt/adaptive-lighting/blob/104664d3588442730f7ea9e811dd1986aca0c2e8/custom_components/adaptive_lighting/helpers.py#L19-L102

basnijholt commented 1 year ago

Check out this new webapp to visualize the parameters https://basnijholt.github.io/adaptive-lighting/

adaptive-lighting

https://github.com/basnijholt/adaptive-lighting/assets/6897215/68908f7d-fbf1-4991-98ce-3f2af6df996f

funkytwig commented 1 year ago

Yes that might be possible but I would like to make something to go between a set of custom colors or color temperatures. However, I need to think of a good interface to set it.

Not sure if I got this correct but a while aso I was thinking it would be great if it used colour temp during the day and then RGB colours at night. This may already be done through the sleep settings but I dont quite understand how this works (from a user POV). I often set lights to amber at night and doing this automatialy would be really nice.

basnijholt commented 1 year ago

@funkytwig, that is a good suggestion and close to my plan.

I think one should be able to provide 3 or 5 RGB colors or color temperatures and it will continuously interpolate between those colors.

funkytwig commented 1 year ago

@basnijholt not sure if I quite follow. My thought was something like a checkbox that if you are using temp it cross fades to night colour or another defined colour from temp. The duration of the cross fade ime not sure about but it could be the last 25 present of brightness could be a nightcolour_percent setting. We may be getting too many settings. Makes me think of a verry old book called 'inmates are running the asylum' about UI design. https://www.oreilly.com/library/view/inmates-are-running/0672326140/. A verry interesting read. Sets out how to design mobile and web apps years before they existed.

The fact that I have done video colour grading and stage lighting may explain my approach and why I love your add-on.

basnijholt commented 1 year ago

You are right about the number of parameters/variables getting out of control, which is why I would like to keep it "simple".

I will check out that book, thanks for the suggestion!

protyposis commented 1 year ago

The tanh mode is a great addition. I was wondering though where the default values for brightness_mode_time_[dark|light] (900/3600) come from, and why you preferred them over the defaults in the simulator app (10800/1800)?

The defaults shift the adaptation quite a bit into the ~night~ day compared to the default mode, see screenshot below vs https://github.com/basnijholt/adaptive-lighting/pull/699#issuecomment-1670507893.

image

I guess what I am actually asking here is if there's data which indicates a preference, physiologically.

funkytwig commented 1 year ago

You are right about the number of parameters/variables getting out of control, which is why I would like to keep it "simple".

I will check out that book, thanks for the suggestion!

This is why I thought a single checkbox 'Use night color at night if using color temp' was the way to do it.

image

This would use the color set above for the night. Crossfading from color temp to night color at night. It may be that this would only be supported if using the brightness_mode_time settings.

I think one of the problems is parameters need a bit more explanation. Maybe add a description field for each parameter and if the text is in this field it is displayed between the current parameter names and the value (maybe in a thin box).

I think sections would also be useful with section titles and some text under them. I thought this would make the page a lot longer but think it is worth it.

I think the first section (Basic Settings) would be up to prefer_rgb_colours and the second section Sleep Settings....

If you want to do this I can help work out what text to put in and what sections to have.

funkytwig commented 1 year ago

The tanh mode is a great addition. I was wondering though where the default values for brightness_mode_time_[dark|light] (900/3600) come from, and why you preferred them over the defaults in the simulator app (10800/1800)?

The defaults shift the adaptation quite a bit into the night compared to the default mode, see screenshot below vs #699 (comment).

image

I guess what I am actually asking here is if there's data that indicates a preference, physiologically.

Hi. That was my idea. May be worth reading this thread to understand the logic. Basically, I wanted to be able to easily define the duration from the 100% daytime setting to the minimum % setting. Basically, I wanted the lights to start dimming 45 mins before sunset and end dimming 45 minutes after it (but the actual durations depend on how close/far you are from the equator). I wanted the lights to be at their dimmest for the whole time the sun was totaly set and not have lights coming back up until sun started rising. I was finding early in the morning before the sun started rising, the lights were coming up too much for me. So having a sunset/sunrise offset did not help. Hope this makes sence.

protyposis commented 1 year ago

Thanks, that makes sense (for reference, the mentioned thread is https://github.com/basnijholt/adaptive-lighting/issues/616).

having a sunset/sunrise offset did not help

Regarding the "too many parameters" discussion above, adding a sunrise/sunset duration parameter to the offset would fix that and simplify the two brightness_mode_time_* parameters down to one sunset_sunrise_duration. Maybe something to consider for v2 :)

funkytwig commented 1 year ago

Thanks, that makes sense (for reference, the mentioned thread is #616).

having a sunset/sunrise offset did not help

Regarding the "too many parameters" discussion above, adding a sunrise/sunset duration parameter to the offset would fix that and simplify the two brightness_mode_time_* parameters down to one sunset_sunrise_duration. Maybe something to consider for v2 :)

It simply moves the the curve. It does not change how quickly the transition from brightest to darkest happens for a light. Have a look at https://basnijholt.github.io/adaptive-lighting/. This is why the devs implemented it. Maybe they will chip in to clarify but simply changing the offsets does not do what I wanted. This has all been implemented in the beta (and possibly in the main verison now).

funkytwig commented 1 year ago

PS Sorry, misread your comment. I personally prefer absolute numbers to offsets. They are easier (certainly for me) to get my head around. I think the reason they opted for two parameters is because it gives more flexibility (this may not be a good thing, see 'The inmates are running the asylum' reference above, https://www.oreilly.com/library/view/inmates-are-running/0672326140/). I would be very happy with just a single parameter as an absolute but as I said not like offsets as much. However, removing parameters makes migration tricky.

funkytwig commented 1 year ago

A bit of context te ''The inmates are running the asylum'. It's a book written about UI design that was written when there were barely any web apps, let alone mobile apps. The general gist has to do with the 80/20 rule (although I am not sure if it actually references it directly). Basically, 80% of the users of most software use only 20% of its functionality. Like 80% of your shopping in a supermarket trolly accounts for 20% of the cost. The remaining 20% accounts for 80% of the cost. So what has happened with web apps, and mobile apps, is they tend to concentrate on the 20% of the functionality that 80% of people use. Streamlining and simplifying them compared with traditional computer programs. If you get computer developers to design software and UI they tend to put as much functionality in as possible which can be confusing to a lot of users. If you get others (UI designers/analysts/software designers etc.) to decide the functionality/UI you end up with less confusing software which more people will use/want to use). Anyway, that is the general idea. The book is a great read, the other book that is very interesting is They Mythical Man Month by Brooks, but will leave that for another day ;).

basnijholt commented 1 year ago

Regarding the "too many parameters" discussion above, adding a sunrise/sunset duration parameter to the offset would fix that and simplify the two brightness_mode_time_* parameters down to one sunset_sunrise_duration. Maybe something to consider for v2 :)

Initially, I considered an offset too. However, this would only allow for a symmetric offset around the sunset or sunrise. To allow for asymmetric offsets, one has to introduce yet another parameter to apply a shift in the offset, which also results in two parameters.

Regarding the defaults, I have not given this a lot of thought at all, I only made them sufficiently different in the app to highlight that one can apply a different offset to before and after sunset/sunrise. I’m happy to change them to a perhaps more sensible default if you have a suggestion.

protyposis commented 1 year ago

However, this would only allow for a symmetric offset around the sunset or sunrise. To allow for asymmetric offsets, one has to introduce yet another parameter to apply a shift in the offset, which also results in two parameters.

Hmm, I recognized this advantage of the chosen parameter design and actually wanted to configure an asymmetric curve, but it always looks symmetric to me, at least in the simulator (which is super helpful!). Here's the curve with dark/light at 3600/3600: image

Now when I configure them extremely asymmetric, e.g. 1/7199, it still yields a symmetric curve: image (With the additional sunrise/sunset shift by one hour, the curve is exactly the same as above. Hence, sunrise + offset + duration is currently equivalent to sunrise + offset + dark + light, where the asymmetry between dark and light is just another offset to the offset.)

If your "allow for asymmetric offsets" was actually just teasing a future feature, please excuse my misunderstanding.

TheWanderer1983 commented 1 year ago

Is it possible to have the ramping for "RGB/colour Intensity over time" follow the same curve as the brightness? I would like my colour temperature to be at the highest intensity for much longer then is possible currently.