Implementation
Because I like NodeRed, I decided to look around for the NodeRed based solution. I was ready to write the whole day cycle on my own. But started google first. I found a circadian node but it doesn’t report the data I expected. Also I needed something to “draw” the curve of light intensity and warmth.
Finally I have found two great NodeRed contributions:
SunPosition - it contains a lot of useful nodes, incl very complex roller-shutter/blinds control node taking into account a lot of sun attributes, incl azimuth. Initially I thought to use that somehow but I found it too complex to adapt quickly.
For this project I found sun-position node to be most useful. It provides all Sun events for the current day in a single bulk of data.
Note, that nodes from this package require your location to be configured to work. This part of configuration is not being exported with the code due to privacy concerns. Once profile is created, it’s ready to use with every node from the package. You have to do that on your own after importing my code provided below.
SplineCurve - I would love a bezier curve more, but spline does the job. Just to spoil it, you can configure it by just drawing a curve (almost)
Interestingly, documentation of spline-curve contains a picture of Circadian implementation. Unfortunately I found no source code for that. But it gave me an exact idea of how it can be done.
Day cycle
I started checking data generated by the sun-position node. I copied single bulk of data into a table:
It helped me a lot in taking decisions on how to split a day down into configurable parts. I tried two approaches, finally ending up with night, morning, day and evening. But in fact it can be split into as many parts as you want. It could be even a single part but we need to be in sync with sun events, right?
On top of that, one can add events based on fixed time. For example using midnight or other arbitrary point(s). TBH the longer I think about it the more confident I am about splitting night into 2 parts: before and after midnight. It’s because of scaling time and moving sunrise and sunset events during the year.
Anyway, currently I proposed 4 ranges:
-
night: from astronomicalDusk to astronomicalDawn
-
morning: from astronomicalDawn to goldenHourDawnEnd
-
day: from goldenHourDawnEnd to sunsetStart
-
evening: from sunsetStart to astronomicalDusk
The whole magic is done by basically two nodes with help of switch one. And you know what? Its hard to believe how simple it can be:
[{"id":"26c6c4d5.3a01e4","type":"subflow","name":"Sub Circadian NR","info":"","category":"","in":[{"x":60,"y":160,"wires":[{"id":"7d4ca80d.e23468"}]}],"out":[{"x":1120,"y":140,"wires":[{"id":"e64dbfa.021204","port":0}]},{"x":1120,"y":340,"wires":[{"id":"d9f0035a.28956","port":0}]},{"x":500,"y":500,"wires":[{"id":"667d6dd.334db94","port":0}]}],"env":[],"color":"#DDAA99"},{"id":"dc97f68a.35ea2","type":"switch","z":"26c6c4d5.3a01e4","name":"curve switch","property":"payload.times_of_day.active.name","propertyType":"msg","rules":[{"t":"eq","v":"night","vt":"str"},{"t":"eq","v":"morning","vt":"str"},{"t":"eq","v":"day","vt":"str"},{"t":"eq","v":"evening","vt":"str"}],"checkall":"true","repair":false,"outputs":4,"x":510,"y":240,"wires":[["ce91189c.8bc7d8","bd8db343.5462f8"],["ad9966f3.3fced","4db18fbe.9ffb6"],["c2d43d53.d7ddf8","838233f4.7ffed"],["eef1dc11.39342","2e9ef8a9.d7fd88"]]},{"id":"ce91189c.8bc7d8","type":"spline-curve","z":"26c6c4d5.3a01e4","name":"night curve","output_key":"","input_key":"payload.times_of_day.active.perc","points":[{"x":0,"y":0.3},{"x":0.333,"y":0.037},{"x":0.477,"y":0},{"x":0.783,"y":0},{"x":1,"y":0}],"x":750,"y":80,"wires":[["e64dbfa.021204"]]},{"id":"ad9966f3.3fced","type":"spline-curve","z":"26c6c4d5.3a01e4","name":"morning curve","output_key":"","input_key":"payload.times_of_day.active.perc","points":[{"x":0,"y":0.01},{"x":0.132,"y":0.01},{"x":0.269,"y":0.027},{"x":0.449,"y":0.124},{"x":0.676,"y":0.334},{"x":1,"y":0.75}],"x":760,"y":120,"wires":[["e64dbfa.021204"]]},{"id":"eef1dc11.39342","type":"spline-curve","z":"26c6c4d5.3a01e4","name":"evening curve","output_key":"","input_key":"payload.times_of_day.active.perc","points":[{"x":0,"y":0.85},{"x":0.036,"y":0.78},{"x":0.176,"y":0.63},{"x":1,"y":0.3}],"x":760,"y":200,"wires":[["e64dbfa.021204"]]},{"id":"c2d43d53.d7ddf8","type":"spline-curve","z":"26c6c4d5.3a01e4","name":"daylight curve","output_key":"","input_key":"payload.times_of_day.active.perc","points":[{"x":0,"y":0.744},{"x":0.03,"y":0.838},{"x":0.097,"y":0.941},{"x":0.183,"y":1},{"x":0.86,"y":0.995},{"x":0.943,"y":0.931},{"x":1,"y":0.85}],"x":760,"y":160,"wires":[["e64dbfa.021204"]]},{"id":"bd8db343.5462f8","type":"spline-curve","z":"26c6c4d5.3a01e4","name":"night curve","output_key":"","input_key":"payload.times_of_day.active.perc","points":[{"x":0,"y":0.5},{"x":0.067,"y":0.368},{"x":0.133,"y":0.265},{"x":0.27,"y":0.171},{"x":0.333,"y":0.16},{"x":0.506,"y":0.124},{"x":0.606,"y":0}],"x":750,"y":280,"wires":[["d9f0035a.28956"]]},{"id":"4db18fbe.9ffb6","type":"spline-curve","z":"26c6c4d5.3a01e4","name":"morning curve","output_key":"","input_key":"payload.times_of_day.active.perc","points":[{"x":0,"y":0},{"x":0.42,"y":0.018},{"x":0.54,"y":0.051},{"x":0.587,"y":0.101},{"x":0.817,"y":0.675},{"x":1,"y":0.85}],"x":760,"y":320,"wires":[["d9f0035a.28956"]]},{"id":"2e9ef8a9.d7fd88","type":"spline-curve","z":"26c6c4d5.3a01e4","name":"evening curve","output_key":"","input_key":"payload.times_of_day.active.perc","points":[{"x":0,"y":0.85},{"x":0.787,"y":0.598},{"x":1,"y":0.5}],"x":760,"y":400,"wires":[["d9f0035a.28956"]]},{"id":"838233f4.7ffed","type":"spline-curve","z":"26c6c4d5.3a01e4","name":"daylight curve","output_key":"","input_key":"payload.times_of_day.active.perc","points":[{"x":0,"y":0.85},{"x":0.123,"y":0.927},{"x":0.5,"y":1},{"x":0.92,"y":0.904},{"x":1,"y":0.85}],"x":760,"y":360,"wires":[["d9f0035a.28956"]]},{"id":"667d6dd.334db94","type":"function","z":"26c6c4d5.3a01e4","name":"Times of Day","func":"var newmsg = JSON.parse(JSON.stringify(msg));\nnewmsg.payload.times_of_day = {};\n\nprocessTimeOfDay = function(nme, obj, name_start, name_end) {\n var ret = {};\n ret.name = nme;\n ret.start = obj.times[name_start].ts;\n ret.end = obj.times[name_end].ts;\n ret.perc = (obj.ts - ret.start) / (ret.end - ret.start);\n \n \n // to cover range starting before midnight and ending after it.\n if (ret.start > ret.end)\n {\n var a;\n\n // before midnight\n if (ret.start < obj.ts) \n {\n a = new Date(ret.end);\n a.setUTCDate(a.getUTCDate() + 1);\n ret.end = a.getTime();\n \n \n }\n else if (ret.end > obj.ts) \n {\n a = new Date(ret.start);\n a.setUTCDate(a.getUTCDate() - 1);\n ret.start = a.getTime();\n\n }\n\n }\n \n ret.perc = (obj.ts - ret.start) / (ret.end - ret.start);\n \n obj.times_of_day[nme] = ret;\n if (ret.start <= obj.ts && obj.ts < ret.end ) obj.times_of_day.active = ret;\n}\n\n\nprocessTimeOfDay('night', newmsg.payload, \"astronomicalDusk\", \"astronomicalDawn\");\nprocessTimeOfDay('morning', newmsg.payload, \"astronomicalDawn\", \"goldenHourDawnEnd\");\nprocessTimeOfDay('day', newmsg.payload, \"goldenHourDawnEnd\", \"sunsetStart\");\nprocessTimeOfDay('evening', newmsg.payload, \"sunsetStart\", \"astronomicalDusk\");\n\n\nreturn newmsg;\n","outputs":1,"noerr":0,"initialize":"","finalize":"","x":290,"y":240,"wires":[["dc97f68a.35ea2"]]},{"id":"e64dbfa.021204","type":"range","z":"26c6c4d5.3a01e4","minin":"0","maxin":"1","minout":"1","maxout":"100","action":"scale","round":true,"property":"payload","name":"","x":980,"y":140,"wires":[[]]},{"id":"d9f0035a.28956","type":"range","z":"26c6c4d5.3a01e4","minin":"0","maxin":"1","minout":"2000","maxout":"5500","action":"scale","round":true,"property":"payload","name":"","x":990,"y":340,"wires":[[]]},{"id":"7d4ca80d.e23468","type":"sun-position","z":"26c6c4d5.3a01e4","name":"","positionConfig":"26f98e1b.bd1812","rules":[],"onlyOnChange":"true","topic":"","outputs":1,"start":"","startType":"none","startOffset":0,"startOffsetType":"num","startOffsetMultiplier":60000,"end":"","endType":"none","endOffset":0,"endOffsetType":"num","endOffsetMultiplier":60000,"x":190,"y":160,"wires":[["667d6dd.334db94"]]},{"id":"26f98e1b.bd1812","type":"position-config","name":"","isValide":"true","longitude":"0","latitude":"0","angleType":"deg","timeZoneOffset":"99","timeZoneDST":"0","stateTimeFormat":"3","stateDateFormat":"12"},{"id":"7c36779e.82d778","type":"subflow:26c6c4d5.3a01e4","z":"98a83ef1.1c6e2","name":"","env":[],"x":350,"y":180,"wires":[["f5dfa977.44ef5"],["2dee4f11.beb6e"],["73ce5946.a872a"]],"outputLabels":["Bigthness [%]","Warmth [K]","Debug"],"icon":"node-red-node-suncalc/sun.png"}]
Sun-postion node is triggered by any input message. The function node TimesofDay creates mentioned time ranges from data received from sun-position node. It also selects current range and calculates the percentage of elapsed time for it.
The switch passes data retrieved from the function node to a matching SplineCurve node.
Because I wanted different curves for light intensity and warmth, I created 2 sets of curves.
The spline-curve node takes decimal values from range 0-1 outputting value based on the created curve (also range 0-1).
The tricky part is to define nice slopes. Because particular parts of the day have different lengths, the curve in the editor mustn’t look like generated result. The result might be compressed or expanded, affecting steepness of the slope. Here is how particular parts looks in editor (brightness):
See the end of this post to see how it does translate into Brightness curve.
The result generated by curve nodes is scaled up to required range: 1-100 for brightness and 2000-5500 for warmth. Note that I chose 1 as the lower boundary for brightness. It’s because I don’t want to turn the light off. If I wanted I would choose a switch for that.
It’s easier to create curves hitting 0 value rather than 0.01. Depending on needs it might be done the opposite way of course.
As you can see from the image I implemented it as a subnode. It’s not mandatory approach, but I wanted to try this feature. It significantly reduces clutter. Also it allows to reuse this part of code just like another node (see image below).
I also wrote a simulator - something which allows me to generate whole day cycle curves during several minutes. I will get back to it later.
And here is how it is connected to sensors. Inject node just triggers the subnode.
[{"id":"26c6c4d5.3a01e4","type":"subflow","name":"Sub Circadian NR","info":"","category":"","in":[{"x":60,"y":160,"wires":[{"id":"7d4ca80d.e23468"}]}],"out":[{"x":1120,"y":140,"wires":[{"id":"e64dbfa.021204","port":0}]},{"x":1120,"y":340,"wires":[{"id":"d9f0035a.28956","port":0}]},{"x":500,"y":500,"wires":[{"id":"667d6dd.334db94","port":0}]}],"env":[],"color":"#DDAA99"},{"id":"dc97f68a.35ea2","type":"switch","z":"26c6c4d5.3a01e4","name":"curve switch","property":"payload.times_of_day.active.name","propertyType":"msg","rules":[{"t":"eq","v":"night","vt":"str"},{"t":"eq","v":"morning","vt":"str"},{"t":"eq","v":"day","vt":"str"},{"t":"eq","v":"evening","vt":"str"}],"checkall":"true","repair":false,"outputs":4,"x":510,"y":240,"wires":[["ce91189c.8bc7d8","bd8db343.5462f8"],["ad9966f3.3fced","4db18fbe.9ffb6"],["c2d43d53.d7ddf8","838233f4.7ffed"],["eef1dc11.39342","2e9ef8a9.d7fd88"]]},{"id":"ce91189c.8bc7d8","type":"spline-curve","z":"26c6c4d5.3a01e4","name":"night curve","output_key":"","input_key":"payload.times_of_day.active.perc","points":[{"x":0,"y":0.3},{"x":0.333,"y":0.037},{"x":0.477,"y":0},{"x":0.783,"y":0},{"x":1,"y":0}],"x":750,"y":80,"wires":[["e64dbfa.021204"]]},{"id":"ad9966f3.3fced","type":"spline-curve","z":"26c6c4d5.3a01e4","name":"morning curve","output_key":"","input_key":"payload.times_of_day.active.perc","points":[{"x":0,"y":0.01},{"x":0.132,"y":0.01},{"x":0.269,"y":0.027},{"x":0.449,"y":0.124},{"x":0.676,"y":0.334},{"x":1,"y":0.75}],"x":760,"y":120,"wires":[["e64dbfa.021204"]]},{"id":"eef1dc11.39342","type":"spline-curve","z":"26c6c4d5.3a01e4","name":"evening curve","output_key":"","input_key":"payload.times_of_day.active.perc","points":[{"x":0,"y":0.85},{"x":0.036,"y":0.78},{"x":0.176,"y":0.63},{"x":1,"y":0.3}],"x":760,"y":200,"wires":[["e64dbfa.021204"]]},{"id":"c2d43d53.d7ddf8","type":"spline-curve","z":"26c6c4d5.3a01e4","name":"daylight curve","output_key":"","input_key":"payload.times_of_day.active.perc","points":[{"x":0,"y":0.744},{"x":0.03,"y":0.838},{"x":0.097,"y":0.941},{"x":0.183,"y":1},{"x":0.86,"y":0.995},{"x":0.943,"y":0.931},{"x":1,"y":0.85}],"x":760,"y":160,"wires":[["e64dbfa.021204"]]},{"id":"bd8db343.5462f8","type":"spline-curve","z":"26c6c4d5.3a01e4","name":"night curve","output_key":"","input_key":"payload.times_of_day.active.perc","points":[{"x":0,"y":0.5},{"x":0.067,"y":0.368},{"x":0.133,"y":0.265},{"x":0.27,"y":0.171},{"x":0.333,"y":0.16},{"x":0.506,"y":0.124},{"x":0.606,"y":0}],"x":750,"y":280,"wires":[["d9f0035a.28956"]]},{"id":"4db18fbe.9ffb6","type":"spline-curve","z":"26c6c4d5.3a01e4","name":"morning curve","output_key":"","input_key":"payload.times_of_day.active.perc","points":[{"x":0,"y":0},{"x":0.42,"y":0.018},{"x":0.54,"y":0.051},{"x":0.587,"y":0.101},{"x":0.817,"y":0.675},{"x":1,"y":0.85}],"x":760,"y":320,"wires":[["d9f0035a.28956"]]},{"id":"2e9ef8a9.d7fd88","type":"spline-curve","z":"26c6c4d5.3a01e4","name":"evening curve","output_key":"","input_key":"payload.times_of_day.active.perc","points":[{"x":0,"y":0.85},{"x":0.787,"y":0.598},{"x":1,"y":0.5}],"x":760,"y":400,"wires":[["d9f0035a.28956"]]},{"id":"838233f4.7ffed","type":"spline-curve","z":"26c6c4d5.3a01e4","name":"daylight curve","output_key":"","input_key":"payload.times_of_day.active.perc","points":[{"x":0,"y":0.85},{"x":0.123,"y":0.927},{"x":0.5,"y":1},{"x":0.92,"y":0.904},{"x":1,"y":0.85}],"x":760,"y":360,"wires":[["d9f0035a.28956"]]},{"id":"667d6dd.334db94","type":"function","z":"26c6c4d5.3a01e4","name":"Times of Day","func":"var newmsg = JSON.parse(JSON.stringify(msg));\nnewmsg.payload.times_of_day = {};\n\nprocessTimeOfDay = function(nme, obj, name_start, name_end) {\n var ret = {};\n ret.name = nme;\n ret.start = obj.times[name_start].ts;\n ret.end = obj.times[name_end].ts;\n ret.perc = (obj.ts - ret.start) / (ret.end - ret.start);\n \n \n // to cover range starting before midnight and ending after it.\n if (ret.start > ret.end)\n {\n var a;\n\n // before midnight\n if (ret.start < obj.ts) \n {\n a = new Date(ret.end);\n a.setUTCDate(a.getUTCDate() + 1);\n ret.end = a.getTime();\n \n \n }\n else if (ret.end > obj.ts) \n {\n a = new Date(ret.start);\n a.setUTCDate(a.getUTCDate() - 1);\n ret.start = a.getTime();\n\n }\n\n }\n \n ret.perc = (obj.ts - ret.start) / (ret.end - ret.start);\n \n obj.times_of_day[nme] = ret;\n if (ret.start <= obj.ts && obj.ts < ret.end ) obj.times_of_day.active = ret;\n}\n\n\nprocessTimeOfDay('night', newmsg.payload, \"astronomicalDusk\", \"astronomicalDawn\");\nprocessTimeOfDay('morning', newmsg.payload, \"astronomicalDawn\", \"goldenHourDawnEnd\");\nprocessTimeOfDay('day', newmsg.payload, \"goldenHourDawnEnd\", \"sunsetStart\");\nprocessTimeOfDay('evening', newmsg.payload, \"sunsetStart\", \"astronomicalDusk\");\n\n\nreturn newmsg;\n","outputs":1,"noerr":0,"initialize":"","finalize":"","x":290,"y":240,"wires":[["dc97f68a.35ea2"]]},{"id":"e64dbfa.021204","type":"range","z":"26c6c4d5.3a01e4","minin":"0","maxin":"1","minout":"1","maxout":"100","action":"scale","round":true,"property":"payload","name":"","x":980,"y":140,"wires":[[]]},{"id":"d9f0035a.28956","type":"range","z":"26c6c4d5.3a01e4","minin":"0","maxin":"1","minout":"2000","maxout":"5500","action":"scale","round":true,"property":"payload","name":"","x":990,"y":340,"wires":[[]]},{"id":"7d4ca80d.e23468","type":"sun-position","z":"26c6c4d5.3a01e4","name":"","positionConfig":"26f98e1b.bd1812","rules":[],"onlyOnChange":"true","topic":"","outputs":1,"start":"","startType":"none","startOffset":0,"startOffsetType":"num","startOffsetMultiplier":60000,"end":"","endType":"none","endOffset":0,"endOffsetType":"num","endOffsetMultiplier":60000,"x":190,"y":160,"wires":[["667d6dd.334db94"]]},{"id":"26f98e1b.bd1812","type":"position-config","name":"","isValide":"true","longitude":"0","latitude":"0","angleType":"deg","timeZoneOffset":"99","timeZoneDST":"0","stateTimeFormat":"3","stateDateFormat":"12"},{"id":"bfbe267f.ea05f8","type":"inject","z":"98a83ef1.1c6e2","name":"","props":[{"p":"payload"}],"repeat":"60","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":170,"y":480,"wires":[["ecf00c0c.9acbc"]]},{"id":"ecf00c0c.9acbc","type":"subflow:26c6c4d5.3a01e4","z":"98a83ef1.1c6e2","name":"","env":[],"x":390,"y":480,"wires":[["ef0a208b.3ff8e8"],["f5e1d08b.a8cd58"],[]],"outputLabels":["Bigthness [%]","Warmth [K]","Debug"],"icon":"node-red-node-suncalc/sun.png"},{"id":"ef0a208b.3ff8e8","type":"ha-entity","z":"98a83ef1.1c6e2","name":"Gain [%]","server":"6cdd0bc8.b8e434","version":1,"debugenabled":false,"outputs":1,"entityType":"sensor","config":[{"property":"name","value":"nr_circadian_gain"},{"property":"device_class","value":""},{"property":"icon","value":"mdi:brightness-percent"},{"property":"unit_of_measurement","value":"%"}],"state":"payload","stateType":"msg","attributes":[],"resend":true,"outputLocation":"","outputLocationType":"none","inputOverride":"allow","outputOnStateChange":false,"outputPayload":"","outputPayloadType":"str","x":620,"y":440,"wires":[["fd265ff2.b898f8"]]},{"id":"f5e1d08b.a8cd58","type":"ha-entity","z":"98a83ef1.1c6e2","name":"Warmth [K]","server":"6cdd0bc8.b8e434","version":1,"debugenabled":false,"outputs":1,"entityType":"sensor","config":[{"property":"name","value":"nr_circadian_warmth"},{"property":"device_class","value":""},{"property":"icon","value":"mdi:brightness-6"},{"property":"unit_of_measurement","value":"K"}],"state":"payload","stateType":"msg","attributes":[],"resend":true,"outputLocation":"payload","outputLocationType":"msg","inputOverride":"allow","outputOnStateChange":false,"outputPayload":"","outputPayloadType":"str","x":630,"y":520,"wires":[["13779c9.87835e3"]]},{"id":"6cdd0bc8.b8e434","type":"server","name":"Home Assistant"}]
The code of this part seems to be obvious. Period of triggering subnode is up to your preferences. I have it set to 1 min. But 5 or 10 minnutes is good enough too IMO. Especially if your lights can apply smooth transitions to requested changes.
Presented earlier brightness curves translate to the result shown on picture below. It doesn’t look as fancy as regular sinuses. But it really doesn’t matter since it works well. At the end it’s not a graphs competition
As you can see I achieved following things:
- Brightness during most of night (especially its second part) is dimmed to minimum
- Brightness and warmth both increases fast during sunrise to almost maximum values
- Brightness and warmth starts decreasing with sunset, but the process is slow providing enough proper light for normal evening live.
- You can see drop of warmth from 2500K to 2000K after midnight. It’s because we found yet warmer light during this part of night more comfortable
Here is mini-graph code for completion:
type: 'custom:mini-graph-card'
name: Circadian NR
show:
icon: false
labels: false
font_size: 80
font_size_header: 10
hour24: true
hours_to_show: 24
points_per_hour: 60
line_width: 2
lower_bound: 1
upper_bound: 100
smoothing: true
entities:
- entity: sensor.nr_circadian_gain
name: Brightness NR
- entity: sensor.nr_circadian_warmth
name: Warmth NR
show_state: true
y_axis: secondary