Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add different brightness ramping mechanisms #699

Merged
merged 6 commits into from
Aug 5, 2023

Conversation

basnijholt
Copy link
Owner

@basnijholt basnijholt commented Aug 4, 2023

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":

  • During sunset the brightness will start adapting constantly from max_brightness at time=sunset_time - brightness_mode_time_light to min_brightness at time=sunset_time + brightness_mode_time_dark.
  • During sunrise the brightness will start adapting constantly from min_brightness at time=sunrise_time - brightness_mode_time_dark to max_brightness at time=sunrise_time + brightness_mode_time_light.

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

  • During sunset the brightness will start adapting from 95% of the max_brightness at time=sunset_time - brightness_mode_time_light to 5% of the min_brightness at time=sunset_time + brightness_mode_time_dark.
  • During sunrise the brightness will start adapting from 5% of the min_brightness at time=sunrise_time - brightness_mode_time_dark to 95% of the max_brightness at time=sunrise_time + brightness_mode_time_light.

Closes #616, #437, #218, #242, #127, #94, #72

@basnijholt
Copy link
Owner Author

basnijholt commented Aug 4, 2023

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
Copy link
Owner Author

basnijholt commented Aug 4, 2023

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

@basnijholt basnijholt enabled auto-merge (squash) August 5, 2023 20:12
@basnijholt basnijholt merged commit a1cec19 into main Aug 5, 2023
14 checks passed
@basnijholt basnijholt deleted the brightness-control branch August 5, 2023 20:14
@pacjo
Copy link

pacjo commented Aug 6, 2023

Can we have something like this for temperature too?

@basnijholt
Copy link
Owner Author

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
Copy link

pacjo commented Aug 6, 2023

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
Copy link
Owner Author

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:

def find_a_b(x1: float, x2: float, y1: float, y2: float) -> tuple[float, float]:
"""Compute the values of 'a' and 'b' for a scaled and shifted tanh function.
Given two points (x1, y1) and (x2, y2), this function calculates the coefficients 'a' and 'b'
for a tanh function of the form y = 0.5 * (tanh(a * (x - b)) + 1) that passes through these points.
The derivation is as follows:
1. Start with the equation of the tanh function:
y = 0.5 * (tanh(a * (x - b)) + 1)
2. Rearrange the equation to isolate tanh:
tanh(a * (x - b)) = 2*y - 1
3. Take the inverse tanh (or artanh) on both sides to solve for 'a' and 'b':
a * (x - b) = artanh(2*y - 1)
4. Plug in the points (x1, y1) and (x2, y2) to get two equations.
Using these, we can solve for 'a' and 'b' as:
a = (artanh(2*y2 - 1) - artanh(2*y1 - 1)) / (x2 - x1)
b = x1 - (artanh(2*y1 - 1) / a)
Parameters
----------
x1
x-coordinate of the first point.
x2
x-coordinate of the second point.
y1
y-coordinate of the first point (should be between 0 and 1).
y2
y-coordinate of the second point (should be between 0 and 1).
Returns
-------
a
Coefficient 'a' for the tanh function.
b
Coefficient 'b' for the tanh function.
Notes
-----
The values of y1 and y2 should lie between 0 and 1, inclusive.
"""
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 = 100.0,
) -> float:
"""Apply a scaled and shifted tanh function to a given input.
This function represents a transformation of the tanh function that scales and shifts
the output to lie between y_min and y_max. For values of 'x' close to 'x1' and 'x2'
(used to calculate 'a' and 'b'), the output of this function will be close to 'y_min'
and 'y_max', respectively.
The equation of the function is as follows:
y = y_min + (y_max - y_min) * 0.5 * (tanh(a * (x - b)) + 1)
Parameters
----------
x
The input to the function.
a
The scale factor for the tanh function, found using 'find_a_b' function.
b
The shift factor for the tanh function, found using 'find_a_b' function.
y_min
The minimum value of the output range. Defaults to 0.
y_max
The maximum value of the output range. Defaults to 100.
Returns
-------
float: The output of the function, which lies in the range [y_min, y_max].
"""
return y_min + (y_max - y_min) * 0.5 * (math.tanh(a * (x - b)) + 1)

@basnijholt
Copy link
Owner Author

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

adaptive-lighting

adaptive-lighting.mp4

@funkytwig
Copy link

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
Copy link
Owner Author

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

funkytwig commented Aug 9, 2023

@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
Copy link
Owner Author

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
Copy link
Collaborator

protyposis commented Aug 24, 2023

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 #699 (comment).

image

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

@funkytwig
Copy link

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
Copy link

funkytwig commented Aug 24, 2023

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
Copy link
Collaborator

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 :)

@funkytwig
Copy link

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
Copy link

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
Copy link

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
Copy link
Owner Author

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
Copy link
Collaborator

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
Copy link

TheWanderer1983 commented Aug 28, 2023

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Speed of sunset/sunrise setting
5 participants