Forecast.solar API call to analyse and display solar forecast in Home Assistant
Forecast Solar API
Forecast Solar is available in Home Assistant as an integration to fetch the current day solar forecast. The integration provides a number of data values, however there exists (as the time of writing) no way of obtaining the forecast data from Home Assistant to use for further analysis.
Questions such as 'at what time can I expect a solar power of at least 2kW' require a call to the API to fetch and process the data directly.
This flow uses the basic free account facility to call the API for one or more solar planes, save the data to context, as well as analysing the forecast to provide a range of details including a 'power level' array.
The flow maintains a record of both the forecast for today and tomorrow, and also a history of yesterday including actual solar production. This is made available for use in Home Assistant to display using an ApexCharts graph.
Required
If using with Home Assistant, then for the 'actual' production a solar energy utility meter entity is required. For a detailed explanation please read actual solar energy entity
The 'date-time' platform is also required, specifically the date_time_iso entity.
The flow is triggered once per hour using a cron-plus node; this can be replaced with a standard inject node set to trigger every 60 minutes. There is no practical benefit gained from running the flow more frequently.
Settings
The Forecast.Solar basic account provides a limited number of API calls per hour. For settings for location, plane orientation, power and other settings see forecast solar
This flow is an updated version of an earlier attempt, and manages multiple solar planes without a need to modify code.
JSONata
This flow is written using JSONata within change nodes (rather than JavaScript in function nodes). As a declarative language JSONata simplifies code, for example with iteration over an array, but can be more complicated to understand, and may therefore present issues with future code maintenance.
Time zones
Computer systems use UTC time to avoid time zone or DST issues. For readability this flow uses local time. Forecast.solar provides both UTC and local time within the API return (based on the given location) and this is used to determine the local time offset from UTC.
Additionally, the local time is obtained from Home Assistant using an 'current state' node, reading the date-time platform 'date_time_iso'.
Date and time processing has been extensively tested, however local issues may arise.
Forecast accuracy
This flow was first created as an attempt to improve the match between forecast and actual Solar PV. There is much discussion around how solar forecasting works, how this particular integration operates within Home Assistant, and the exact nature of the data provided. This flow is coded based on a personal interpretation of the returned hourly watt power values and how this data should be used. This flow is therefore not necessarily correct, and other interpretations are available.
If the forecast appears to be one hour 'out' from actual production, this can be attributed to a range of factors (incorrect settings, DST, point versus period average data / plotting / reporting etc).
Importing the flow
For ease of use, the Home Assistant configurations have been removed, and all Home Assistant nodes disabled. The included Node-RED display nodes have also been disabled. All settings should be checked, Home Assistant server configuration re-attached, and nodes re-enabled as required. The 'damping' and 'horizon' parameters for the planes are examples only and must be either modified or removed!
Support
This flow is provided as an academic example only for the purpose of learning JSONata.
Update 2024-April
This is "version 2" of the flow, and I have recently refactored again to tidy and remove the need for additional nodes and the half-hour offset solar energy sensor. I am now keeping all my work in GibHub repositories, and you can find the later version and better documentation at
https://github.com/oatybiscuit/Node-RED-HA-ForecastSolar-JSONata
[{"id":"a1385ae3163e5fb7","type":"group","z":"088f35b28bce9017","name":"Forecast.solar by API calls","style":{"stroke":"#ffC000","fill":"#ffefbf","label":true,"color":"#6f2fa0"},"nodes":["cf337560acceb64a","a43e0a47447b4560","f527d89dbfa94eb4","54894a7516152677","501a74354a85c941","df8d08051939900c","55f76ba1eb4d240c","ea3720e2536f05bd","4afb9d89b8e36784","f0a07ec90a4950c4","2806715089552e3f","26f90a9d2ea7881c","99bbb40e2951f583","4685904a1b395f0f","6cd59bc71d7d0561","2fcf678ad93ceded","a1657abd3f7fbe07","021dfe3c33b03bd0","964720b6b93ae8d8","300932bb94e9b844","f99731c4c931d09d","b53660619b928eca","14f2e37ab17a5a12","54df8bc58f816a0d","942640410dc2f972","6106d81f794fb33a","f2750452e83cd50b","f234592fc9e52765","ca6144d02b26d86c","2bd2cf5d9a81ea51","52280e7a812a04ab","2bb2e151e4ed1070","256c4a44a625de26","cbaa815ce61e7a73","7d8c556d0ee9de53","5b81da834c65a49a","8c59a5397a90c88f","3434ef7b794bc505","0bb03e96583cf962","f36f3e3e1b923c00","ec32508e339d7262","42be2d7b12872b97","a10cf95229654d4b","fcc95a7b1ccc30fa","70cf7269c18b02c7","af6e2c8fa93af511","46273418991a9fa0"],"x":94,"y":79,"w":1012,"h":702},{"id":"cf337560acceb64a","type":"ui_chart","z":"088f35b28bce9017","d":true,"g":"a1385ae3163e5fb7","name":"Forecast","group":"76e876790e22c53e","order":2,"width":0,"height":0,"label":"SolarForecast (Yesterday-Today-Tomorrow)","chartType":"line","legend":"true","xformat":"HH:mm","interpolate":"cubic","nodata":"","dot":true,"ymin":"","ymax":"","removeOlder":1,"removeOlderPoints":"","removeOlderUnit":"3600","cutout":0,"useOneColor":false,"useUTC":false,"colors":["#1f77b4","#d62728","#ff7f0e","#2ca02c","#98df8a","#d62728","#ff9896","#9467bd","#c5b0d5"],"outputs":1,"useDifferentColor":false,"className":"","x":420,"y":740,"wires":[[]]},{"id":"a43e0a47447b4560","type":"ui_text","z":"088f35b28bce9017","d":true,"g":"a1385ae3163e5fb7","group":"76e876790e22c53e","order":3,"width":5,"height":1,"name":"","label":"Energy Today","format":"{{msg.payload}} kWh","layout":"row-spread","className":"","x":440,"y":660,"wires":[]},{"id":"f527d89dbfa94eb4","type":"ui_text","z":"088f35b28bce9017","d":true,"g":"a1385ae3163e5fb7","group":"76e876790e22c53e","order":5,"width":4,"height":1,"name":"","label":"Date","format":"{{msg.payload.dates.today}}","layout":"row-spread","className":"","x":250,"y":620,"wires":[]},{"id":"54894a7516152677","type":"ui_text","z":"088f35b28bce9017","d":true,"g":"a1385ae3163e5fb7","group":"76e876790e22c53e","order":7,"width":5,"height":1,"name":"","label":"Energy Tomorrow","format":"{{msg.payload}} kWh","layout":"row-spread","className":"","x":450,"y":700,"wires":[]},{"id":"501a74354a85c941","type":"ui_text","z":"088f35b28bce9017","d":true,"g":"a1385ae3163e5fb7","group":"76e876790e22c53e","order":9,"width":4,"height":1,"name":"","label":"Period","format":"{{msg.payload}}","layout":"row-spread","className":"","x":410,"y":540,"wires":[]},{"id":"df8d08051939900c","type":"change","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"Period","rules":[{"t":"set","p":"payload","pt":"msg","to":"$substring(payload.today.start,11,5) & \" to \" & $substring(payload.today.stop,11,5)\t","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":250,"y":540,"wires":[["501a74354a85c941"]]},{"id":"55f76ba1eb4d240c","type":"ui_text","z":"088f35b28bce9017","d":true,"g":"a1385ae3163e5fb7","group":"76e876790e22c53e","order":6,"width":5,"height":1,"name":"","label":"P1 update","format":"{{msg.payload.P1.time}}","layout":"row-spread","className":"","x":430,"y":580,"wires":[]},{"id":"ea3720e2536f05bd","type":"ui_text","z":"088f35b28bce9017","d":true,"g":"a1385ae3163e5fb7","group":"76e876790e22c53e","order":10,"width":5,"height":1,"name":"","label":"P2 update","format":"{{msg.payload.P2.time}}","layout":"row-spread","className":"","x":430,"y":620,"wires":[]},{"id":"4afb9d89b8e36784","type":"change","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"lastRead","rules":[{"t":"set","p":"payload","pt":"msg","to":"payload.lastRead","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":260,"y":580,"wires":[["ea3720e2536f05bd","55f76ba1eb4d240c"]]},{"id":"f0a07ec90a4950c4","type":"comment","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"Rev: May 2023","info":"Complete re-write using JSONata\n- improve plane processing so can add planes more easily\n- improve forecast analysis with extra fields\n- duplicate analysis for tomorrow as well as today\n- tidy date calculations: now use time(local) and time_utc from\n API return to calculate timezone offset and then all dates from that\n- remove all use of moment node (get 'now' using timezone offset)\n- fix bug with actual hour at new day\n- move context read & write to change nodes (easier to select context store)\n\nThis has been well tested but may still raise\nissues from the use of JSONata\nTimezone has been tested for UK (BST = GMT+1)\n\nThere is no error handling where the API call fails, the\nforecast will just not update at that hour. The actual/history\nwill still continue to process however.\n","x":540,"y":120,"wires":[]},{"id":"2806715089552e3f","type":"change","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"Site","rules":[{"t":"set","p":"parm.latitude","pt":"msg","to":"51.3","tot":"num"},{"t":"set","p":"parm.longitude","pt":"msg","to":"0.9","tot":"num"}],"action":"","property":"","from":"","to":"","reg":false,"x":190,"y":180,"wires":[["26f90a9d2ea7881c","4685904a1b395f0f"]]},{"id":"26f90a9d2ea7881c","type":"change","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"P1 East","rules":[{"t":"set","p":"parm.plane","pt":"msg","to":"1","tot":"num"},{"t":"set","p":"parm.elevation","pt":"msg","to":"35","tot":"num"},{"t":"set","p":"parm.azimuth","pt":"msg","to":"-105","tot":"num"},{"t":"set","p":"parm.power","pt":"msg","to":"2.19","tot":"num"},{"t":"set","p":"payload","pt":"msg","to":"{\"damping_morning\":0.4,\"damping_evening\":0.1,\"horizon\":\"20,24,28,32,36,40,44,40,36,32,28,16,16,16,16,16,10,16,16,16,19,19,19,4,4,4,4,16,22,16,16,16,16,16,5,5,5,9,9,9,9,9,18,18,18,17,17,17,6,6,12,11,11,7,10,10,6,6,6,6,6,6,6,6,6,6,6,10,12,14,16,18\"}","tot":"json"}],"action":"","property":"","from":"","to":"","reg":false,"x":360,"y":180,"wires":[["99bbb40e2951f583"]]},{"id":"99bbb40e2951f583","type":"http request","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"API FCS","method":"GET","ret":"obj","paytoqs":"query","url":"https://api.forecast.solar/estimate/{{{parm.latitude}}}/{{{parm.longitude}}}/{{{parm.elevation}}}/{{{parm.azimuth}}}/{{{parm.power}}}","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[],"x":520,"y":180,"wires":[["2fcf678ad93ceded"]]},{"id":"4685904a1b395f0f","type":"delay","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"10s","pauseType":"delay","timeout":"10","timeoutUnits":"seconds","rate":"1","nbRateUnits":"1","rateUnits":"second","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":false,"allowrate":false,"outputs":1,"x":190,"y":220,"wires":[["6cd59bc71d7d0561","300932bb94e9b844"]]},{"id":"6cd59bc71d7d0561","type":"change","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"P2 West","rules":[{"t":"set","p":"parm.plane","pt":"msg","to":"2","tot":"num"},{"t":"set","p":"parm.elevation","pt":"msg","to":"35","tot":"num"},{"t":"set","p":"parm.azimuth","pt":"msg","to":"75","tot":"num"},{"t":"set","p":"parm.power","pt":"msg","to":"2.19","tot":"num"},{"t":"set","p":"payload","pt":"msg","to":"{\"damping_morning\":0.1,\"damping_evening\":0.3,\"horizon\":\"20,24,28,32,36,40,44,40,36,32,28,16,16,16,16,16,10,16,16,16,19,19,19,4,4,4,4,16,22,16,16,16,16,16,5,5,5,9,9,9,9,9,18,18,18,17,17,17,6,6,12,11,11,7,10,10,6,6,6,6,6,6,6,6,6,6,6,10,12,14,16,18\"}","tot":"json"}],"action":"","property":"","from":"","to":"","reg":false,"x":360,"y":220,"wires":[["99bbb40e2951f583"]]},{"id":"2fcf678ad93ceded","type":"switch","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"OK?","property":"payload.message.code","propertyType":"msg","rules":[{"t":"eq","v":"0","vt":"num"},{"t":"else"}],"checkall":"true","repair":false,"outputs":2,"x":650,"y":180,"wires":[["a1657abd3f7fbe07"],[]]},{"id":"a1657abd3f7fbe07","type":"change","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"Dates / Array / Plane","rules":[{"t":"set","p":"payload.dates","pt":"msg","to":"(\t /* FUNCTIONS */\t\t $getdate:=function($ts){$substringBefore($ts,\"T\")};\t $moveday:=function($ts,$by){$fromMillis($toMillis($ts)+$by*86400000)};\t\t /* using returned local and utc timestamps, extract local timezone offset */\t /* note- this is based on solar coordinates given in API call */\t /* if machine is on a different timezone $utclocal will not be correct */\t \t $solarlocal:=payload.message.info.time;\t $solarutc:=payload.message.info.time_utc;\t $timezone:=$substring($solarlocal,19);\t $tssolar:=$substring($solarlocal,0,19);\t $tsutc:=$substring($solarutc,0,19);\t $mssolar:=$toMillis($tssolar)-$toMillis($tsutc);\t\t /* tidy today (timestamp, date) as local time */ \t \t $plainnow:=$replace($tssolar,\"T\",\" \");\t $today:= $getdate($tssolar);\t\t /* get equivalent UTC timestamp adjusted for timezone shift for date (+/- 1 day) calculation */\t /* eg - local 00:10 (+3), UTC 21:10 (yesterday) :: utclocal = 21:10 +3:00 => 00:10 (today) */\t /* - local 00:10 (-3), UTC 03:10 (today) :: utclocal = 03:10 -3:00 => 00:10 (today) */\t \t $utclocal:=$fromMillis($toMillis($tssolar)+$mssolar);\t \t $dates:={\t \"yesterday\": $getdate($moveday($utclocal,-1)),\t \"today\": $today,\t \"tomorrow\": $getdate($moveday($utclocal,1)),\t \"lastApiCall\":$plainnow,\t \"timezone\": $timezone,\t \"msoffset\": $mssolar,\t \"tsDateChange\": $solarlocal \t } \t)","tot":"jsonata"},{"t":"set","p":"payload.dates.fcDateToday","pt":"msg","to":"$keys(payload.result.watt_hours_day)[0]","tot":"jsonata"},{"t":"set","p":"sf","pt":"msg","to":"SolarFC","tot":"flow","dc":true},{"t":"set","p":"SolarFC","pt":"flow","to":"(\t/* April 2023 */\t/* take incoming API from forecast solar and process */\t/* read & write to flow context in change node to */\t/* make it easier to modify storage context settings */\t\t/* FUNCTION: to create a new empty solar table */\t $setTable:=function(){(\t $dtom:=payload.dates.tomorrow;\t $dday:=payload.dates.today;\t $dyes:=payload.dates.yesterday;\t $arr:=[0..71];\t $arr#$i.(\t $ts:= $i<24 ? $dyes : $i<48 ? $dday : $dtom;\t $ts:= $ts & \" \" & $pad($string($i%24),-2,\"0\") & \":00:00\";\t {\"hour\":$i%24,\t \"timestamp\": $ts,\t \"actualWh\": null,\t \"efcWh\": null,\t \"fcW\": null, /* keep as null, issue at start of day when update moves '0' to oldfcw */\t \"oldfcW\": null}\t )\t )};\t\t/* read solar forecast from context or create new array */\t/* if forecast_today after tomorrow, start a fresh array */\t/* if forecast_today = tomorrow, shift array 1 day left */\t/* and reload all dates or just update 'last api call' */\t $sf:= sf;\t $mt:= {\"energy\": 0, \"start\": null, \"stop\": null};\t $not($exists($sf)) ? $sf:={\t \"solarTable\": $setTable(),\t \"today\": $mt,\t \"tomorrow\": $mt,\t \"dates\": payload.dates,\t \"lastRead\": {\"P1\": {\"time\": null, \"watts\": null, \"todayTotal\": null, \"tomorrowTotal\": null}}\t };\t $tabTom:=$sf.dates.tomorrow;\t $fcToday:=payload.dates.fcDateToday;\t $fcToday>$tabTom ? $sf:= $sf ~> | $ | {\"solarTable\": $setTable()} |;\t $fcToday=$tabTom ? $sf:= $sf ~> | $ | {\"solarTable\": $append($sf.solarTable[[24..71]], $setTable()[[48..71]])} |;\t $fcToday>=$tabTom ? $sf:= $sf ~> | $ | {\"dates\": $$.payload.dates} | : $sf:= $sf ~> | dates | {\"lastApiCall\": $$.payload.dates.lastApiCall} |;\t\t/* add in plane results to 'lastRead' in context */\t $p:={\"P\" & parm.plane: {\"time\": payload.dates.lastApiCall,\t \"watts\": payload.result.watts,\t \"todayTotal\": $lookup(payload.result.watt_hours_day,payload.dates.today),\t \"tomorrowTotal\": $lookup(payload.result.watt_hours_day,payload.dates.tomorrow)}};\t $sf ~> | lastRead | $p |; \t)\t","tot":"jsonata"},{"t":"set","p":"payload.power","pt":"msg","to":" /* lookup the current power (watts) using the current hour */\t (\t $hour:=$substringBefore(payload.dates.lastApiCall,\":\") & \":00:00\";\t $power:=$lookup(payload.result.watts,$hour);\t $exists($power) ? $power : 0\t )","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":820,"y":180,"wires":[["14f2e37ab17a5a12","af6e2c8fa93af511"]]},{"id":"021dfe3c33b03bd0","type":"api-current-state","z":"088f35b28bce9017","d":true,"g":"a1385ae3163e5fb7","name":"Solar Act Wh hr30","server":"","version":3,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","entity_id":"sensor.solar_energy_hr30","state_type":"num","blockInputOverrides":false,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entity"}],"for":"0","forType":"num","forUnits":"minutes","override_topic":false,"state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","x":370,"y":360,"wires":[["46273418991a9fa0"]]},{"id":"964720b6b93ae8d8","type":"change","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"Update SFC (act & fc)","rules":[{"t":"set","p":"sf","pt":"msg","to":"SolarFC","tot":"flow","dc":true},{"t":"set","p":"actual.index","pt":"msg","to":"$parseInteger(actual.hour, \"99\")+23\t","tot":"jsonata"},{"t":"set","p":"actual.value","pt":"msg","to":"$number(payload.attributes.last_period)*1000","tot":"jsonata"},{"t":"set","p":"payload","pt":"msg","to":"(\t/* May 2023 */\t\t/* FUNCTION: extract total seconds from string time as hh:mm:ss => ss+(mm+hh*60)*60 */\t $tosec:=function($ts){(\t $a:=$substringAfter($ts,\" \");\t $h:=$substringBefore($a,\":\").$number();\t $b:=$substringAfter($a,\":\");\t $m:=$substringBefore($b,\":\").$number();\t $s:=$substringAfter($b,\":\").$number();\t $number($s+60*($m+60*$h))\t )};\t\t/* FUNCTION: get start or stop times for today or tomorrow. Uses $detail */\t/* in some cases $detail[value=0, date, am/pm] returns an array so assume array and take first */\t/* or last element. This happens when last hour value is 0, eg at 20:00 and at 20:01 sunset =0 */\t $timeis:=function($day, $part){(\t $a:=$detail[value=0 and date=($day=\"today\" ? $today : $tomrw)];\t $b:=$part=\"am\" ? ($a[hour<12].key)[0] : ($a[hour>12].key)[-1];\t )};\t\t/* MAIN code */\t\t $oldsf:=sf;\t $today:=sf.dates.today;\t $tomrw:=sf.dates.tomorrow;\t\t/* save actual (index = last hour hh) this should be energy Wh between hh-:30 and hh+:30 */\t/* also save pre-update forecast value for the last hour to forecast history */\t $index:=actual.index;\t $value:=actual.value;\t $fcw:=$oldsf.solarTable[$index].fcW;\t $sf:= $oldsf ~> | solarTable[$index] | {\"actualWh\": $value, \"oldfcW\": $fcw} |;\t\t/* reset all forecasts (today/tomorrow) to zero, collate forecast watts into detail array */\t $sf:= $sf ~> | solarTable[[24..71]] | {\"fcW\": 0} |;\t\t $watts:=$sf.lastRead.*.watts;\t $detail:=$keys($watts).{\"key\": $,\t \"date\": $.$substringBefore(\" \"),\t \"time\": $.$substring(11,5),\t \"hour\": $number($substring($,11,2)),\t \"value\": $sum($lookup($watts, $))};\t\t/* set start & end times and total energy for today & tomorrow, with delta */\t/* set seconds difference, where +ve is longer day (ealier start/later end) */\t\t $start1:=$timeis(\"today\", \"am\");\t $stop1:= $timeis(\"today\", \"pm\");\t $start2:=$timeis(\"tomrw\", \"am\");\t $stop2:= $timeis(\"tomrw\", \"pm\");\t $obj1:= {\"today\": {\"energy\": $sum($sf.lastRead.*.todayTotal), \"start\": $start1, \"stop\": $stop1}};\t $obj2:= {\"tomorrow\": {\"energy\": $sum($sf.lastRead.*.tomorrowTotal), \"start\": $start2, \"stop\": $stop2,\t \"startdelta\": $tosec($start1)-$tosec($start2), \"stopdelta\": $tosec($stop2)-$tosec($stop1)}};\t\t $sf:= $sf ~> | $ | $obj1 |;\t $sf:= $sf ~> | $ | $obj2 |;\t\t/* add in index reference based on hour - used to update main solar table */\t $detail:= $detail ~> | $ | {\"index\": value>0 ? date=$today ? 24+hour : 48+hour} |;\t\t/* (map over solar table; where detail/table index match, update 'fcW', then replace entire table) */ \t $stable:=$map($sf.solarTable, function($v, $i){$merge([ $v, {\"fcW\": $detail[index=$i].value}])});\t $sf:= $merge($append($spread($sf),{\"solarTable\": $stable})); \t\t/* recalculate watt-hours for each hr:-30 to hr:+30 and post to hh:00 */\t/* using (a+b+b+c)/4, run from $index (last hour) to end of today */\t/* where oldfcW is not null (ie at start index-1) use that and not fcW */\t\t $stable:=$map($sf.solarTable, function($v, $i, $tab){(\t $i>=$index and $i<48 ? (\t $type($tab[$i-1].oldfcW)=\"number\" ? $a:= $tab[$i-1].oldfcW : $a:= $tab[$i-1].fcW;\t $b:=$tab[$i].fcW;\t $c:=$tab[$i+1].fcW;\t $merge([$v, {\"efcWh\": $floor(($a+2*$b+$c)/4)}]);\t ) : $v\t )}\t );\t $merge($append($spread($sf),{\"solarTable\": $stable}))\t)\t","tot":"jsonata"},{"t":"set","p":"SolarFC","pt":"flow","to":"payload","tot":"msg","dc":true}],"action":"","property":"","from":"","to":"","reg":false,"x":740,"y":360,"wires":[["6106d81f794fb33a","52280e7a812a04ab"]]},{"id":"300932bb94e9b844","type":"delay","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"10s","pauseType":"delay","timeout":"10","timeoutUnits":"seconds","rate":"1","nbRateUnits":"1","rateUnits":"second","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":false,"allowrate":false,"outputs":1,"x":190,"y":300,"wires":[["021dfe3c33b03bd0"]]},{"id":"f99731c4c931d09d","type":"change","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"Read SFC","rules":[{"t":"set","p":"payload","pt":"msg","to":"SolarFC","tot":"flow","dc":true},{"t":"set","p":"actual.hour","pt":"msg","to":"$fromMillis($toMillis($now())+payload.dates.msoffset) ~> $substring(11,2)","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":230,"y":500,"wires":[["fcc95a7b1ccc30fa"]]},{"id":"b53660619b928eca","type":"change","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"Chart Array","rules":[{"t":"set","p":"payload","pt":"msg","to":"(\t $table:=payload.solarTable;\t [{\"series\":[\"Forecast\", \"History\", \"Energy\", \"Actual\"],\t \"data\": [[$table.fcW], [$table.oldfcW], [$table.efcWh], [$table.actualWh]],\t \"labels\": [$table.(hour & \":00\")]}];\t)","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":270,"y":740,"wires":[["cf337560acceb64a"]]},{"id":"14f2e37ab17a5a12","type":"switch","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"by plane","property":"parm.plane","propertyType":"msg","rules":[{"t":"eq","v":"1","vt":"num"},{"t":"eq","v":"2","vt":"num"}],"checkall":"false","repair":false,"outputs":2,"x":760,"y":240,"wires":[["f2750452e83cd50b"],["f234592fc9e52765"]]},{"id":"54df8bc58f816a0d","type":"change","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"HA Graph Array","rules":[{"t":"set","p":"payload","pt":"msg","to":"(\t $table:=payload.solarTable;\t $index:=$parseInteger(actual.hour, \"99\")+24;\t\t {\"time\": [$table.timestamp],\t \"forecast\": [$table.fcW],\t \"energyfc\": [$table.efcWh],\t \"actual\": [$table.actualWh],\t \"old\": [$table.oldfcW],\t \"update\": $table[$index].timestamp}\t)","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":700,"y":640,"wires":[["ca6144d02b26d86c"]]},{"id":"942640410dc2f972","type":"change","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"Analyse Forecast","rules":[{"t":"set","p":"payload","pt":"msg","to":"(\t/* April 2023. Node-RED JSONata to analyse solar forecast table into power levels and their associated time periods */\t\t/* FUNCTION: extract total minutes from string time as hh:mm => mm+hh*60 */\t $tomins:=function($ts){$number($substringBefore($ts,\":\"))*60 + $number($substringAfter($ts,\":\"))};\t\t/* FUNCTION: turn total minutes back to string hh:mm */\t $tohm:=function($mins){$pad($string(($mins-$mins%60)/60),-2, \"0\") & \":\" & $pad($string($mins%60),-2, \"0\")};\t\t/* FUNCTION: build power array @ $Pinc W intervals up to $Plim W, with forecast start & stop to record 0 (zero power) */\t $getpower:=function($day){(\t $daystart:= $day=\"today\" ? $substring(payload.today.start,11,5) : $substring(payload.tomorrow.start,11,5);\t $daystop:= $day=\"today\" ? $substring(payload.today.stop,11,5) : $substring(payload.tomorrow.stop,11,5);\t $pzero:=[{\"start\": $tomins($daystart), \"stop\": $tomins($daystop), \"minutes\": 0 }];\t ([0..$Pnum])#$i.{\"power\": $i*$Pinc, \"count\": 0, \"period\": ($i=0 ? $pzero: [])};\t )};\t\t/* FUNCTION: build detail array from each 1h period in solar day - start using an iteration over the solar table array for today */\t/* or tomorrow then add fields based on those previously added (this->last->solar/delta->event->offset, delta->peak/min/max) */\t $getdetail:=function($hrs, $daystart, $daystop){(\t $da:= payload.solarTable[$hrs]#$i.{\t \"index\": $i+24,\t \"hour\": hour,\t \"this\": $Pold ? ($type(oldfcW)=\"number\" ? oldfcW : fcW) : fcW};\t /* add 'last' by mapping over detail array using index & array to get offset value */\t $da:=$map($da, function($v, $i, $a){(\t $merge($append($v, {\"last\": $i>0 ? $a[$i-1].this : 0}));\t )});\t /* construct using tranform operator, as offset array values are not required */\t $da:= $da ~> |$| {\"solar\": this>0 or last>0 ? \"day\" : \"night\",\t \"delta\": this>last ? \"+\" : this<last ? \"-\" : \"=\"}|;\t $da:=$da ~> |$| {\"event\": solar=\"day\" ? last=0 ? \"start\" : \"run\" : \"off\"}|;\t $da:=$da ~> |$| solar=\"day\" and this=0 ? {\"event\": \"stop\"} |;\t /* minutes offset at solar start & end used for calculating power period times */\t $da:=$da ~> |$| {\"offset\": event=\"start\" ? $daystart%60 : event=\"stop\" ? 60-$daystop%60 : 0}|;\t /* compare trends to set maximum (+-) and minimum (-+) peaks in array using this and next value */\t $map($da, function($v, $i, $a){(\t $pair:=$i<$count($a)-1 ? $v.delta & $a[$i+1].delta;\t $merge($append($v, {\"peak\": $pair=\"+-\" ? \"max\" : $pair=\"-+\" ? \"min\" : \"-\"}));\t )}); \t )};\t\t/* FUNCTION: get power levels using the detail array */\t/* obtain array of power-level period start & end times. Between last & this value find */\t/* all Pinc Watt power levels, for each get pro-rata start (rise) & end (fall) times on */\t/* a linear basis. For first and last periods adjust times using start & end mins offset */\t $getpl:=function($det){(\t $pls:=$det[solar=\"day\"]#$i.(\t $last:=last;\t $this:=this;\t $offset:=offset;\t $hour:=hour;\t delta=\"+\" ? (\t $pstart:=$floor((last*$Pfac)/1000)+1;\t $pend:=$floor((this*$Pfac)/1000);\t $loop:=[$pstart..$pend];\t $loop.(\t $plindex:=$;\t $mins:=$floor((($plindex*$Pinc-$last)/($this-$last))*(60-$offset));\t {\"pl\": $plindex, \"power\": $plindex*$Pinc, \"start\": ($hour-1)*60+$mins+$offset};\t );\t ) : delta=\"-\" ? (\t $pstart:=$floor((last*$Pfac)/1000);\t $pend:=$floor((this*$Pfac)/1000)+1;\t $loop:=$reverse([$pend..$pstart]);\t $loop.(\t $plindex:=$;\t $mins:=$floor((($last-$plindex*$Pinc)/($last-$this))*(60-$offset));\t {\"pl\": $plindex, \"power\": $plindex*$Pinc, \"stop\": ($hour-1)*60+$mins};\t );\t ));\t /* sort by power (align starts/stops) then zip together and merge into one period object */\t /* for each power level, multiple periods for a single power level are in one array */\t $pls^(power)\t )};\t\t/* FUNCTION: zip power levels together and merege into one period object */\t /* for each power level, multiple periods for a single power level are in one array */\t $getperiods:=function(){\t $zip($plevels[start>0],$plevels[stop>0]).$merge($)\t };\t\t/* FUNCTION: iterate over power array, replace \"period\" with new period array where power levels match */\t /* use [] at end of period {} to force period object to be in array even for singletons */\t $update:=function($pwrarr, $prds){(\t $pa:=$map($pwrarr, function($v, $i, $a){(\t $period:=$prds[power=$v.power];\t $merge($append($v,{ \"count\": $i=0 ? 1 : $count($period),\t \"period\": $period.{\"start\": start, \"stop\": stop}[]\t }));\t )});\t\t /* tidy by setting duration minutes for each period and reverting start-stop to hh:mm */\t $pa ~> |period| {\"start\": $tohm(start), \"stop\": $tohm(stop), \"minutes\": stop-start} |;\t )};\t\t/* FUNCTION: build output object for the day, uses $powerarray, $detail, $plevels*/\t /* get minhour up front, if this does not exist default to [] */\t $dayis:=function($daydate){(\t $minhour:=$detail[peak=\"min\"].($tohm(hour*60))[];\t $minhour:= $exists($minhour) ? $minhour : [];\t {\"date\": $daydate,\t \"solardaystart\": $powerarray[0].period.start,\t \"solardayend\": $powerarray[0].period.stop,\t \"solardaymins\": $powerarray[0].period.minutes,\t \"fullhourstartat\": $tohm($detail[event=\"start\"].hour*60),\t \"fullhourendat\" : $tohm($detail[event=\"stop\"].(hour-1)*60),\t \"maxpower\": $max($detail.this),\t \"maxlevel\": $max($plevels.power),\t \"dayenergy\": $round($sum($detail.this)/100)/10,\t \"maxhour\": $detail[peak=\"max\"].($tohm(hour*60))[],\t \"minhour\": $exists($minhour) ? $minhour : [],\t \"powerarray\": $powerarray};\t )};\t\t\t\t/* SET PARAMERTERS */\t/* parameters: for setting power level array - increment and limit */\t/* increment should be integer factor of 1000 (25,40,50,100,125,200,250,500) */\t $Pinc:=100;\t $Plim:=4000;\t $Pnum:=$floor($Plim/$Pinc);\t $Pfac:=$floor(1000/$Pinc);\t\t/* parameters: for options */\t $Pold:=true; /* values: use old-forecast (history) where it exists, otherwise latest forecast */\t\t/* MAIN CODE */\t\t/* get power array, detail array, power levels, and power periods for today, build result object */\t $powerarray:=$getpower(\"today\");\t $detail:=$getdetail([24..47],$powerarray[0].period.start,$powerarray[0].period.stop);\t $plevels:=$getpl($detail);\t $periods:=$getperiods();\t $powerarray:=$update($powerarray, $periods);\t $todayis:=$dayis(payload.dates.today);\t\t/* repeat for tomorrow */\t\t $powerarray:=$getpower(\"tomorrow\");\t $detail:=$getdetail([48..71],$powerarray[0].period.start,$powerarray[0].period.stop);\t $plevels:=$getpl($detail);\t $periods:=$getperiods();\t $powerarray:=$update($powerarray, $periods);\t $tomorrowis:=$dayis(payload.dates.tomorrow);\t\t/* return final result object. note the updated is UTC and not local time */\t {\t \"today\": $todayis,\t \"tomorrow\": $tomorrowis,\t \"interval\": $Pinc,\t \"parmvalue\": $Pold ? \"oldvalue\" : \"forecast\",\t \"updated\": $toMillis($now())\t }\t)","tot":"jsonata"},{"t":"set","p":"SolarPT","pt":"flow","to":"payload","tot":"msg","dc":true}],"action":"","property":"","from":"","to":"","reg":false,"x":710,"y":460,"wires":[["2bd2cf5d9a81ea51","cbaa815ce61e7a73","5b81da834c65a49a"]]},{"id":"6106d81f794fb33a","type":"delay","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"5s","pauseType":"delay","timeout":"5","timeoutUnits":"seconds","rate":"1","nbRateUnits":"1","rateUnits":"second","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":false,"allowrate":false,"outputs":1,"x":450,"y":460,"wires":[["fcc95a7b1ccc30fa"]]},{"id":"f2750452e83cd50b","type":"ha-sensor","z":"088f35b28bce9017","d":true,"g":"a1385ae3163e5fb7","name":"Solar FC Power East","entityConfig":"025c69fc2ba0425c","version":0,"state":"payload.power","stateType":"msg","attributes":[],"inputOverride":"allow","outputProperties":[],"x":980,"y":240,"wires":[[]]},{"id":"f234592fc9e52765","type":"ha-sensor","z":"088f35b28bce9017","d":true,"g":"a1385ae3163e5fb7","name":"Solar FC Power West","entityConfig":"4dba971878b0daa9","version":0,"state":"payload.power","stateType":"msg","attributes":[],"inputOverride":"allow","outputProperties":[],"x":980,"y":300,"wires":[[]]},{"id":"ca6144d02b26d86c","type":"ha-sensor","z":"088f35b28bce9017","d":true,"g":"a1385ae3163e5fb7","name":"FC Solar Table","entityConfig":"62602840fda5176b","version":0,"state":"payload.update","stateType":"msg","attributes":[{"property":"FChours","value":"payload.time","valueType":"msg"},{"property":"FCwatts","value":"payload.forecast","valueType":"msg"},{"property":"FCactual","value":"payload.actual","valueType":"msg"},{"property":"FCold","value":"payload.old","valueType":"msg"},{"property":"FCwh","value":"payload.energyfc","valueType":"msg"}],"inputOverride":"block","outputProperties":[],"x":960,"y":640,"wires":[[]]},{"id":"2bd2cf5d9a81ea51","type":"ha-sensor","z":"088f35b28bce9017","d":true,"g":"a1385ae3163e5fb7","name":"FC Estimate Today","entityConfig":"7fcde925c164fddb","version":0,"state":"payload.today.dayenergy","stateType":"msg","attributes":[{"property":"power","value":"payload.today.powerarray","valueType":"msg"},{"property":"start","value":"payload.today.solardaystart","valueType":"msg"},{"property":"stop","value":"payload.today.solardayend","valueType":"msg"},{"property":"maximum","value":"payload.today.maxhour","valueType":"msg"},{"property":"minimum","value":"payload.today.minhour","valueType":"msg"},{"property":"maxPower","value":"payload.today.maxpower","valueType":"msg"},{"property":"date","value":"payload.today.date","valueType":"msg"}],"inputOverride":"block","outputProperties":[],"x":970,"y":460,"wires":[[]]},{"id":"52280e7a812a04ab","type":"debug","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"Debug Solar FC","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":960,"y":360,"wires":[]},{"id":"2bb2e151e4ed1070","type":"inject","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"Manual","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":190,"y":360,"wires":[["021dfe3c33b03bd0"]]},{"id":"256c4a44a625de26","type":"cronplus","z":"088f35b28bce9017","d":true,"g":"a1385ae3163e5fb7","name":"Every Hour @ 2mins past","outputField":"payload","timeZone":"","persistDynamic":false,"commandResponseMsgOutput":"output1","outputs":1,"options":[{"name":"schedule1","topic":"ForecastHourly","payloadType":"default","payload":"","expressionType":"cron","expression":"2 * * * *","location":"","offset":"0","solarType":"all","solarEvents":"sunrise,sunset"}],"x":250,"y":120,"wires":[["2806715089552e3f"]]},{"id":"cbaa815ce61e7a73","type":"debug","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"Debug Solar PT","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":960,"y":420,"wires":[]},{"id":"7d8c556d0ee9de53","type":"inject","z":"088f35b28bce9017","d":true,"g":"a1385ae3163e5fb7","name":"Startup - refresh","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":true,"onceDelay":"2","topic":"","payload":"","payloadType":"date","x":240,"y":460,"wires":[["f99731c4c931d09d"]]},{"id":"5b81da834c65a49a","type":"ha-sensor","z":"088f35b28bce9017","d":true,"g":"a1385ae3163e5fb7","name":"FC Estimate Tomorrow","entityConfig":"eeac2705f3d9286b","version":0,"state":"payload.tomorrow.dayenergy","stateType":"msg","attributes":[{"property":"power","value":"payload.tomorrow.powerarray","valueType":"msg"},{"property":"start","value":"payload.tomorrow.solardaystart","valueType":"msg"},{"property":"stop","value":"payload.tomorrow.solardayend","valueType":"msg"},{"property":"maximum","value":"payload.tomorrow.maxhour","valueType":"msg"},{"property":"minimum","value":"payload.tomorrow.minhour","valueType":"msg"},{"property":"maxpower","value":"payload.tomorrow.maxpower","valueType":"msg"},{"property":"date","value":"payload.tomorrow.date","valueType":"msg"}],"inputOverride":"allow","outputProperties":[],"x":980,"y":520,"wires":[[]]},{"id":"8c59a5397a90c88f","type":"change","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"kWh","rules":[{"t":"set","p":"payload","pt":"msg","to":"$round(payload.today.energy/1000,1)","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":250,"y":660,"wires":[["a43e0a47447b4560"]]},{"id":"3434ef7b794bc505","type":"change","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"kWh","rules":[{"t":"set","p":"payload","pt":"msg","to":"$round(payload.tomorrow.energy/1000,1)","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":250,"y":700,"wires":[["54894a7516152677"]]},{"id":"0bb03e96583cf962","type":"comment","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"Actual energy","info":"Note\nActual reading is taken each hour (h) as the \nlast-reset period from (h-2):30 to (h-1):30\nThe last reset hour is used to access the\nsolar array table, effectively for the \nprevious hour.\nAt 01:mm update, last reset was 00:30 thus\nhour was 00, and index should be 24 (start\nof the current day in the array).\nAt 00:mm update, last reset was 23:30 from\nprior day, hour is 23, and index would be\n47, except that this is the start of a new\nday and the array has just been shifted left\nIndex 47 is now at index 23.\nTo address this, the hour node collects the\nhour value from the (last reset +1) hour, which\nmaps to 00 - 23. Then 23 is added to map from\nindex 23 to 46.\nThis sets the actual into the correct part of \nthe table array for the first hour of the new\nday (index 23).\nGlad I got that one fixed...","x":410,"y":320,"wires":[]},{"id":"f36f3e3e1b923c00","type":"comment","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"Current hour ->\\n forecast power","info":"","x":760,"y":280,"wires":[]},{"id":"ec32508e339d7262","type":"comment","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"Total energy\\n forecasts & table","info":"","x":960,"y":580,"wires":[]},{"id":"42be2d7b12872b97","type":"comment","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"Forcast table for\\n Apex chart graph","info":"","x":960,"y":700,"wires":[]},{"id":"a10cf95229654d4b","type":"junction","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","x":140,"y":540,"wires":[["df8d08051939900c","4afb9d89b8e36784","f527d89dbfa94eb4","8c59a5397a90c88f","3434ef7b794bc505","b53660619b928eca"]]},{"id":"fcc95a7b1ccc30fa","type":"junction","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","x":540,"y":500,"wires":[["942640410dc2f972","a10cf95229654d4b","54df8bc58f816a0d"]]},{"id":"70cf7269c18b02c7","type":"comment","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"Add further planes here","info":"One plane? Disable P2 node\n\nMore than two? Copy and add a Pn node\nto the list. Use a 10 second delay to\nallow time for the API call to return.","x":400,"y":260,"wires":[]},{"id":"af6e2c8fa93af511","type":"debug","z":"088f35b28bce9017","g":"a1385ae3163e5fb7","name":"Debug API & Dates","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":970,"y":120,"wires":[]},{"id":"46273418991a9fa0","type":"api-current-state","z":"088f35b28bce9017","d":true,"g":"a1385ae3163e5fb7","name":"Time","server":"","version":3,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","entity_id":"sensor.date_time_iso","state_type":"str","blockInputOverrides":true,"outputProperties":[{"property":"actual.hour","propertyType":"msg","value":"$substringAfter($entity().state,\"T\")~>$substringBefore(\":\")","valueType":"jsonata"}],"for":"0","forType":"num","forUnits":"minutes","override_topic":false,"state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","x":530,"y":360,"wires":[["964720b6b93ae8d8"]]},{"id":"76e876790e22c53e","type":"ui_group","d":true,"name":"Solar Forecast","tab":"a7de90402f2427f9","order":7,"disp":true,"width":"15","collapse":false,"className":""},{"id":"025c69fc2ba0425c","type":"ha-entity-config","d":true,"server":"762fa2f33bfd32fc","deviceConfig":"","name":"SC Solar FC Power East","version":"6","entityType":"sensor","haConfig":[{"property":"name","value":"Solar FCP East"},{"property":"icon","value":""},{"property":"entity_category","value":""},{"property":"device_class","value":"power"},{"property":"unit_of_measurement","value":"W"},{"property":"state_class","value":"measurement"}],"resend":true,"debugEnabled":false},{"id":"4dba971878b0daa9","type":"ha-entity-config","d":true,"server":"762fa2f33bfd32fc","deviceConfig":"","name":"SC Solar FC Power West","version":"6","entityType":"sensor","haConfig":[{"property":"name","value":"Solar FCP West"},{"property":"icon","value":""},{"property":"entity_category","value":""},{"property":"device_class","value":"power"},{"property":"unit_of_measurement","value":"W"},{"property":"state_class","value":"measurement"}],"resend":true,"debugEnabled":false},{"id":"62602840fda5176b","type":"ha-entity-config","d":true,"server":"762fa2f33bfd32fc","deviceConfig":"","name":"SC FC Solar Table","version":"6","entityType":"sensor","haConfig":[{"property":"name","value":"FC table"},{"property":"icon","value":"mdi:update"},{"property":"entity_category","value":""},{"property":"device_class","value":""},{"property":"unit_of_measurement","value":""},{"property":"state_class","value":""}],"resend":true,"debugEnabled":false},{"id":"7fcde925c164fddb","type":"ha-entity-config","d":true,"server":"762fa2f33bfd32fc","deviceConfig":"","name":"SC FC Today","version":"6","entityType":"sensor","haConfig":[{"property":"name","value":"FC Estimate Today"},{"property":"icon","value":"mdi:counter"},{"property":"entity_category","value":""},{"property":"device_class","value":""},{"property":"unit_of_measurement","value":"kWh"},{"property":"state_class","value":""}],"resend":true,"debugEnabled":false},{"id":"eeac2705f3d9286b","type":"ha-entity-config","d":true,"server":"762fa2f33bfd32fc","deviceConfig":"","name":"SC for FC Tomorrow","version":"6","entityType":"sensor","haConfig":[{"property":"name","value":"FC Estimate Tomorrow"},{"property":"icon","value":"mdi:counter"},{"property":"entity_category","value":""},{"property":"device_class","value":""},{"property":"unit_of_measurement","value":"kWh"},{"property":"state_class","value":""}],"resend":true,"debugEnabled":false},{"id":"a7de90402f2427f9","type":"ui_tab","name":"Solar Forecast","icon":"dashboard","order":2,"disabled":false,"hidden":false}]