Circadian Lighting / calculate colortemp and brightness according to circadian rythm

Hi guys,

I just migrated to OH3 from Homeassistant and already benefited so much from this community that I’d like to give something back, even if its a small thing :slight_smile:

One of the things I liked in HA was the Circadian Lighting (or later Adaptive Light) custom component, that allows you to have your lights automatically adjust according to sun position / time of day. Thus I wanted to build something similar for OH3.

The script I wrote is based on claytonjns work here: GitHub - claytonjn/hass-circadian_lighting: Circadian Lighting custom component for Home Assistant so thanks to him for coming up with it.
The Kelvin -> RGB conversion is based on HA core colorutils: core/color.py at dev · home-assistant/core · GitHub
It does not give super accurate results with my RGB LED strips, but its a start.

Caveats:

  • I use Tasmota flashed Shelly Duo bulbs and Tasmota flashed LED controllers. This means that the colortemp and brightness can be adjusted without them turning on. If your lights do not support this, you have to build a more sophisticated update rule that checks if the light is on before you modify the color/brightness
  • the RGB conversion is not super accurate and has to be tweaked to your RGB lights if you mix them with ColorTemp only lights.
  • I am new to OH, so it could very well be that I miss something important :slight_smile:

So, first I created a few items to store the calculated values:

and Astro items to get sunrise and sunset:

now I create a rule that every 5 minutes re-calcs the values:

and here comes the crucial part, the script that does the calculations:

(function(context) {

this.logger = (this.logger === undefined) ? Java.type("org.slf4j.LoggerFactory").getLogger("org.openhab") : this.logger; 

// defaults
var maxColorTemp = 5500;
var minColorTemp = 2000;
var maxBrightness = 100;
var minBrightness =  20;

// timestamps
var oneDay = 86400;
var sunRise =  new Date(ir.getItem("LokaleSonnendaten_Rise_Start").state).getTime()/1000;
var sunSet =  new Date(ir.getItem("LokaleSonnendaten_Set_Start").state).getTime()/1000;
var solarNoon = sunRise + (sunSet - sunRise)/2;
var solarMidnight = sunSet + ((sunRise + oneDay) - sunSet)/2;
var now =     new Date().getTime()/1000;
var todayTimes = {};
todayTimes = {SUN_RISE: sunRise, SUN_SET: sunSet, SOLAR_NOON: solarNoon, SOLAR_MIDNIGHT: solarMidnight};
var yesterdayTimes = {};
var tomorrowTimes = {};

// initial values
var percentage = 100;
var colorTemp = 5500;
var brightness = 100;




// teile der prozent formel
var h = 0;
var k = 0;
var x = 0;
var y = 0;


// rechnen ob wir schon über mitternacht sind und zeiten anpassen
if (now < todayTimes.SUN_RISE) { // It's before sunrise (after midnight)
    yesterdayTimes = {SUN_RISE: sunRise-oneDay, SUN_SET: sunSet-oneDay, SOLAR_NOON: solarNoon-oneDay, SOLAR_MIDNIGHT: solarMidnight-oneDay};        
    this.logger.info("yesterdays sunrise and -set: "+yesterdayTimes);
    
    sunSet = yesterdayTimes.SUN_SET;

    if (todayTimes.SOLAR_MIDNIGHT > todayTimes.SUN_SET && yesterdayTimes.SOLAR_MIDNIGHT > yesterdayTimes.SUN_SET) {
        solarMidnight = yesterdayTimes.SOLAR_MIDNIGHT;
    }
}   
else if (now > todayTimes.SUN_SET) { // It's after sunset (before midnight) 
    //Because it's after sunset (and before midnight) sunrise should happen tomorrow
    tomorrowTimes = {SUN_RISE: sunRise+oneDay, SUN_SET: sunSet+oneDay, SOLAR_NOON: solarNoon+oneDay, SOLAR_MIDNIGHT: solarMidnight+oneDay};        


    this.logger.info("tomorrow sunrise and -set: "+tomorrowTimes);

    sunRise = tomorrowTimes.SUN_RISE;
    if (todayTimes.SOLAR_MIDNIGHT < todayTimes.SUN_RISE && tomorrowTimes.SOLAR_MIDNIGHT < tomorrowTimes.SUN_RISE) {
        // Solar midnight is before sunrise so use tomorrow's time
        solarMidnight = tomorrowTimes.SOLAR_MIDNIGHT;
    }
}


this.logger.info("now: "+now);
this.logger.info("sunRise: "+sunRise);
this.logger.info("sunSet: "+sunSet);
this.logger.info("solar noon: "+solarNoon);
this.logger.info("solar midnight: "+solarMidnight);

// sunrise-sunset parabola
if (now > sunRise && now < sunSet) {
    this.logger.info("sunrise-sunset parabola")
    h = solarNoon;
    k = 100;
    y = 0;
    if (now < solarNoon) {
        x = sunRise;
    } else {
        x = sunSet;
    }

} else if (now > sunSet && now < sunRise) {
  this.logger.info("sunset-sunrise parabola")
    h = solarMidnight
    k = -100
    y = 0
    // parabola before solar_midnight
    if (now < solarMidnight) {
        x = sunSet
    }
    // parabola after solar_midnight
    else{
        x = sunRise;
    }
}
this.logger.info("y: "+y+", k: "+k+", h: "+h+", x:"+x);

var a = (y-k)/Math.pow((h-x),2);
this.logger.info("a: "+a);
percentage = a*Math.pow((now-h),2)+k;

if (percentage > 0) {
    colorTemp = ((maxColorTemp - minColorTemp) * (percentage / 100)) + minColorTemp;
    brightness = maxBrightness;
}
else { 
    colorTemp = minColorTemp;
    brightness = ((maxBrightness - minBrightness) * ((100+percentage) / 100)) + minBrightness;

}
// calculate RGB values for LED Strips
var tempInternal = colorTemp/100
var red = 0;
var green = 0;
var blue = 0;

if (tempInternal <= 66) {
  red = 255
  green = 99.4708025861 * Math.log(tempInternal) - 161.1195681661
} else {
  red = 329.698727446 * Math.pow(tempInternal - 60, -0.1332047592)
  green = 288.1221695283 * Math.pow(tempInternal - 60, -0.0755148492)
}
if (tempInternal >= 66) {
  blue = 255
} else if (tempInternal <= 19) {
  blue = 0
} else {
  blue = 138.5177312231 * Math.log(tempInternal - 10) - 305.0447927307
}
// adjustments to shift color a bit so my RGB LED Strips match the colortemp lights better
red = red - 30
if (red < 0) {
  red = 0;
}
blue = blue +20
if (blue > 255) {
  blue = 255;
}
red = parseInt(red)
blue = parseInt(blue)
green = parseInt(green)
this.logger.info("rot: "+red+", gruen: "+green+", blau: "+blue)

this.logger.info("percentage: "+percentage+", temp: "+colorTemp+", brightness: "+brightness);

events.postUpdate('AdaptivesLichtFarbtemperatur', Math.round(colorTemp));
events.postUpdate('AdaptivesLichtHelligkeit', brightness);
events.postUpdate('AdaptivesLichtProzent', percentage);
events.postUpdate('AdaptiveLichttemperaturHSB', HSBType.fromRGB(red,green,blue))

// I adjust the RGB lights directly here, because I only have two and then I do not need another rule for that
events.sendCommand('Kuchenkastl_ColorValueHSBRGBorCIExyY', HSBType.fromRGB(red,green,blue))
events.sendCommand('Kinderzimmerkasten_ColorValueHSBRGBorCIExyY', HSBType.fromRGB(red,green,blue))
})(this)

So this calculates the values, puts them into the items (and sets the RGB LED strips directly to the desired color, because I had no time yet to adapt the other rule to include the strips).

so thats more or less it. What is now needed is a rule that reacts to changes of the color/brightness items and updates the lights as desired. For this I created three groups:

  • group of colortemps to adjust
  • group of dimmers to adjust
  • group of dimmers to adjust that should not go below 30%

For convenience sake I also created a switch item to disable automatic adjustments. Sometimes in the evening I want to rake up brightness in the livingroom and I do not want this to be overruled by OH again:

image

The rule to update colortemp and brightness:

and the script thats executed by the rule to update the group members. Again, this only works like that for Tasmota lights. For others you have to first check if the state of the light is ON, otherwise they will turn on. Hue lights work like that for example.

gLichttemperaturenDieZuJustierenSind.members.forEach[ licht | licht.sendCommand((1000000/(AdaptivesLichtFarbtemperatur.state as Number))) ]

val zielHelligkeit = AdaptivesLichtHelligkeit.state as Number
gHelligkeitDieZuJustierenSind.members.forEach[ licht | 
licht.sendCommand(zielHelligkeit) ]
if (zielHelligkeit < 30) {
  gHelligkeitDieZuJustierenSindMin30.members.forEach[ licht | 
licht.sendCommand(30) ]
}  else {
  gHelligkeitDieZuJustierenSindMin30.members.forEach[ licht | 
licht.sendCommand(zielHelligkeit) ]
}

I hope this helps somebody :slight_smile:

LG

10 Likes

here you can see how the colortemp progresses over time:

and brightness:

3 Likes

Thanks for posting, this is really excellent stuff.

I’ve a few suggestions that should make it even easier for others to use. Creating libraries takes a certain type of thinking and it can be harder than you might expect.

Some ideas:

  • This could be made into a library (see OH 3 Examples: Writing and using JavaScript Libraries in MainUI created Rules) function and you can pass the data it needs in as arguments. That way you don’t have to hard code the names of Items into it which will allow those who use the code won’t have to modify it to do so.

  • It might be more flexible for users if the Astro Action were used to get sunrise and sunset instead of from Items directly. However, I like the idea of passing in the start times as then users can get the sunrise and sunset from any source, not just Astro and not just Items.

  • If you post this to a repo somewhere then you can continue to develop and add to it over time and users can contribute to it as well. If you don’t want to create your own repo, there are a few other repos you could submit it to including Pull requests · openhab-scripters/openhab-helper-libraries · GitHub or, if you want to get it located somewhere faster I’d accept it as a PR at GitHub - rkoshak/openhab-rules-tools: Library functions, classes, and examples to reuse in the development of new Rules.. At some point we will have a marketplace where we can put stuff like this and users can install them which will be great!

  • If you use a Group:Color to hold your Color Items, you can send the command to the Group and it will be forwarded to the Items. Then users can add as many Color lights as they want to the Group and won’t need to edit the code.

  • You can store some of those values currently in Items as local variables. the less you rely on Items the easier it will be for users to use the code without needing to create new Items or modify the code.

These are just some ideas. Thanks again for posting!

3 Likes

Thanks a lot Rich for your suggestions. As soon as I find time I will for sure start implementing them. Putting it in a repo is also a good idea.
I already made improvements that I would like to share, like per room overrides, etc.

I have just started with OH a few days ago and want to finalize my migration first.

As OH is Java based it will also be easier for me to work on OH directly. If I find enough spare time I think about implementing support for the Netatmo Energy API of nobody else does it first.

1 Like

Nice to see your solution, thanks for sharing. I have a simpler approach running for some years, controlling my Hue lights through their colour temperature scale (which runs from 0 (cold white) to 100 (warm white)), I guess the Hue bridge does similar RGB-calculations as yours.

Sunrise/dawn varies too much over the year in Norway, so I have opted to have this independent of sunrise, the intention is to wake us up in the morning also in the winter, and ease getting to sleep in the night, and my rule is as simple as this:

@rule("Colour temperature changes by clock")
@when("Time cron 0 * * * * ? *")
@when("System started")
def update_colourtemp_circadian(event):
    hour = datetime.datetime.now().hour
    minute = datetime.datetime.now().minute
    if hour < 5:
        events.sendCommand("Lys_fargetemperatur", "100")
    else:
        new_temp = int(((hour + (minute / 60.0)) * 100.0 - 500.0) / 20.0)
        events.sendCommand("Lys_fargetemperatur", str(new_temp))

This makes my colour temperature item being 0 at 05:00, and linearly increasing to 100 at midnight.

I also found this link to be intersting: Color Temperature and Circadian Rhythm - Biological Rhythm and Color Temperature

Thanks for the great code, wzbfyb. I agree to rlkoshak, it would make sense to develop this script on his repo at Github.

The calculation worked out of the box for me, but I added a Group item for my hue items, which need the Color Temperature as a dimmer in percentage, where 100% = maximum color temperature. Therefore I added another calculation an the Group item itself to the script (sendCommand instead of postUpdate).

Zirkadian.items:

Switch              AutomatischeLichtanpassungSchalter      "Zirkadiane Beleuchtung"        <switch>
Number              AdaptivesLichtHelligkeit                "Helligkeit"                    <light>
Number              AdaptivesLichtProzent                   "Prozent"                       <light>
Number              AdaptivesLichtFarbtemperatur            "Farbtemperatur"                <colorpicker>
Group:Dimmer        AdaptivesLichtFarbtemperaturProzent     "Farbtemperatur"                <colorpicker>
Color               AdaptiveLichttemperaturHSB              "Farbe"                         <colorpicker>

DateTime            LokaleSonnendaten_Rise_Start            "Sonnenaufgang"                 <sunrise>               { channel="astro:sun:local:rise#start" }           
DateTime            LokaleSonnendaten_MorningNight_Start    "Mitternacht"                   <moon>                  { channel="astro:sun:local:morningNight#start" }
DateTime            LokaleSonnendaten_Noon_Start            "Mittag"                        <sun>                   { channel="astro:sun:local:noon#start" }
DateTime            LokaleSonnendaten_Set_Start             "Sonnenuntergang"               <sunset>                { channel="astro:sun:local:set#start" }
(function(context) {

this.logger = (this.logger === undefined) ? Java.type("org.slf4j.LoggerFactory").getLogger("org.openhab") : this.logger; 

// defaults
var maxColorTemp = 5500;
var minColorTemp = 2000;
var maxBrightness = 100;
var minBrightness =  20;

// timestamps
var oneDay = 86400;
var sunRise =  new Date(ir.getItem("LokaleSonnendaten_Rise_Start").state).getTime()/1000;
var sunSet =  new Date(ir.getItem("LokaleSonnendaten_Set_Start").state).getTime()/1000;
var solarNoon = sunRise + (sunSet - sunRise)/2;
var solarMidnight = sunSet + ((sunRise + oneDay) - sunSet)/2;
var now =     new Date().getTime()/1000;
var todayTimes = {};
todayTimes = {SUN_RISE: sunRise, SUN_SET: sunSet, SOLAR_NOON: solarNoon, SOLAR_MIDNIGHT: solarMidnight};
var yesterdayTimes = {};
var tomorrowTimes = {};

// initial values
var percentage = 100;
var colorTemp = 5500;
var brightness = 100;
var colorTempperc = 100;




// teile der prozent formel
var h = 0;
var k = 0;
var x = 0;
var y = 0;


// rechnen ob wir schon über mitternacht sind und zeiten anpassen
if (now < todayTimes.SUN_RISE) { // It's before sunrise (after midnight)
    yesterdayTimes = {SUN_RISE: sunRise-oneDay, SUN_SET: sunSet-oneDay, SOLAR_NOON: solarNoon-oneDay, SOLAR_MIDNIGHT: solarMidnight-oneDay};        
    this.logger.info("yesterdays sunrise and -set: "+yesterdayTimes);
    
    sunSet = yesterdayTimes.SUN_SET;

    if (todayTimes.SOLAR_MIDNIGHT > todayTimes.SUN_SET && yesterdayTimes.SOLAR_MIDNIGHT > yesterdayTimes.SUN_SET) {
        solarMidnight = yesterdayTimes.SOLAR_MIDNIGHT;
    }
}   
else if (now > todayTimes.SUN_SET) { // It's after sunset (before midnight) 
    //Because it's after sunset (and before midnight) sunrise should happen tomorrow
    tomorrowTimes = {SUN_RISE: sunRise+oneDay, SUN_SET: sunSet+oneDay, SOLAR_NOON: solarNoon+oneDay, SOLAR_MIDNIGHT: solarMidnight+oneDay};        


    this.logger.info("tomorrow sunrise and -set: "+tomorrowTimes);

    sunRise = tomorrowTimes.SUN_RISE;
    if (todayTimes.SOLAR_MIDNIGHT < todayTimes.SUN_RISE && tomorrowTimes.SOLAR_MIDNIGHT < tomorrowTimes.SUN_RISE) {
        // Solar midnight is before sunrise so use tomorrow's time
        solarMidnight = tomorrowTimes.SOLAR_MIDNIGHT;
    }
}


this.logger.info("now: "+now);
this.logger.info("sunRise: "+sunRise);
this.logger.info("sunSet: "+sunSet);
this.logger.info("solar noon: "+solarNoon);
this.logger.info("solar midnight: "+solarMidnight);

// sunrise-sunset parabola
if (now > sunRise && now < sunSet) {
    this.logger.info("sunrise-sunset parabola")
    h = solarNoon;
    k = 100;
    y = 0;
    if (now < solarNoon) {
        x = sunRise;
    } else {
        x = sunSet;
    }

} else if (now > sunSet && now < sunRise) {
  this.logger.info("sunset-sunrise parabola")
    h = solarMidnight
    k = -100
    y = 0
    // parabola before solar_midnight
    if (now < solarMidnight) {
        x = sunSet
    }
    // parabola after solar_midnight
    else{
        x = sunRise;
    }
}
this.logger.info("y: "+y+", k: "+k+", h: "+h+", x:"+x);

var a = (y-k)/Math.pow((h-x),2);
this.logger.info("a: "+a);
percentage = a*Math.pow((now-h),2)+k;

if (percentage > 0) {
    colorTemp = ((maxColorTemp - minColorTemp) * (percentage / 100)) + minColorTemp;
    brightness = maxBrightness;
}
else { 
    colorTemp = minColorTemp;
    brightness = ((maxBrightness - minBrightness) * ((100+percentage) / 100)) + minBrightness;

}
// calculate RGB values for LED Strips
var tempInternal = colorTemp/100
var red = 0;
var green = 0;
var blue = 0;

if (tempInternal <= 66) {
  red = 255
  green = 99.4708025861 * Math.log(tempInternal) - 161.1195681661
} else {
  red = 329.698727446 * Math.pow(tempInternal - 60, -0.1332047592)
  green = 288.1221695283 * Math.pow(tempInternal - 60, -0.0755148492)
}
if (tempInternal >= 66) {
  blue = 255
} else if (tempInternal <= 19) {
  blue = 0
} else {
  blue = 138.5177312231 * Math.log(tempInternal - 10) - 305.0447927307
}
// adjustments to shift color a bit so my RGB LED Strips match the colortemp lights better
red = red - 30
if (red < 0) {
  red = 0;
}
blue = blue +20
if (blue > 255) {
  blue = 255;
}
red = parseInt(red)
blue = parseInt(blue)
green = parseInt(green)
this.logger.info("rot: "+red+", gruen: "+green+", blau: "+blue)

// calculate Color Temperature (Percentage for hue) 
colorTempperc = (((maxColorTemp - minColorTemp) - (colorTemp - minColorTemp)) / ((maxColorTemp - minColorTemp))) * 100;
  
this.logger.info("percentage: "+percentage+", temp: "+colorTemp+", temp percentage: "+colorTempperc+", brightness: "+brightness);

events.postUpdate('AdaptivesLichtFarbtemperatur', Math.round(colorTemp));
events.sendCommand('AdaptivesLichtFarbtemperaturProzent', Math.round(colorTempperc));
events.postUpdate('AdaptivesLichtHelligkeit', brightness);
events.postUpdate('AdaptivesLichtProzent', percentage);
events.postUpdate('AdaptiveLichttemperaturHSB', HSBType.fromRGB(red,green,blue));

// I adjust the RGB lights directly here, because I only have two and then I do not need another rule for that
/// events.sendCommand('Kuchenkastl_ColorValueHSBRGBorCIExyY', HSBType.fromRGB(red,green,blue))

})(this)
1 Like

Bernhard, do you mind putting this up on the new marketplace so more people can easily install and benefit from this great stuff ?

Yes I’d like to do that but I have to check what I have to adapt to make it usable. I am currently quite time Constrained, so if somebody else wants to take credit for it, feel free.