question

andy156 avatar image
andy156 asked

Roll your own dynamic ESS

For those wanting to roll your own. Here are parts of what I use. I can't just upload my project as there are too many sensitive links/passwords for me to easily sanitise it. However it will give a starting point.

I have an Economy 10 tariff so there are 3 cheap and 3 expensive periods in the day. expensive 0030-0430,0730-1330,1630-2030. The aim is to fill the batteries with enough solar/cheap electricity to bridge the expensive periods. The usage is calculated based on the solar forcast, the temperature forecast (electricity usage for heat pump) and a baseline usage. Minimising charge rates and other little things are done for efficiencies which probably add to a few £100 in savings a year.

figure 1.

screenshot-2023-10-21-at-155722.png

Setting up cheap/exppensive periods. I am on E10. Also, SOC during cheap periods can be overridden.

figure 2.


screenshot-2023-10-21-at-155730.png

the main loop which takes inputs from the system and decides how to control the quattro based on charging/discharging/holding charge to achieve a calculated SOC. After the master loop certain things can be altered to allow charging Eddie and Zappie.

figure 3.

screenshot-2023-10-21-at-155738.png

Determining the SOC to charge to based on the forecasts.

MultiPlus Quattro Inverter Charger
2 |3000

Up to 8 attachments (including images) can be used with a maximum of 190.8 MiB each and 286.6 MiB total.

6 Answers
andy156 avatar image
andy156 answered ·

Master loop code in figure 2

  1. /*
  2. IMPROVEMENTS:
  3. 1. Look ahead to see what predicted solar is. If it is going to be a lot later then consider using the power available in the battery
  4.  
  5. 2. Change the charge rate to be based on a fixed time period from when the SOC last changed to avoid the step in charge rate
  6.  
  7. 3. Ensure the battery is fully charged every few days
  8.  
  9. 4. Speed up the control loop using MQTT
  10.  
  11. 5. If using 1000, then look to delay so that it charges at the end of the period
  12.  
  13. 6. Look to add 4kWh for frost forecast
  14.  
  15. 7. Add logic so that batteries dont sit at 100% all day
  16.  
  17. 8. Dump extra into car each night during the summer months
  18. */
  19.  
  20.  
  21. const maxPowerHouseIn=230*60
  22. function log(text) {
  23. // text = text
  24. // node.warn(text)
  25. }
  26.  
  27. function efficiencyAt(power) {
  28. power = power / 1000
  29. const a = (power * .1) / 6
  30. const eff = 0.9366 - a //.966 was the original number
  31. return eff
  32. }
  33.  
  34. class ChargeState {
  35. static HoldCharge = new ChargeState('HoldCharge');
  36. static UseBattery = new ChargeState('UseBattery');
  37. static EmptyBattey = new ChargeState('EmptyBattery')
  38. static ExpensivePeriod = new ChargeState('ExpensivePeriod')
  39. static ChargeBattery = new ChargeState('ChargeBattery');
  40. constructor(name) {
  41. this.name = name;
  42. }
  43. toString() {
  44. return `Color.${this.name}`;
  45. }
  46. }
  47.  
  48. var debug={};
  49.  
  50. const values=msg.values
  51.  
  52. const battery = values.battery;
  53. const SOC = values.SOC;
  54. const batteryPower = values.batteryPower;
  55. const setpoint = values.setpoint;
  56. const mppt = values.mppt;
  57. const DCvoltage = values.voltage
  58. const globalState= global.get("state")
  59. // const PV_Power = msg.payload["PV_SE"] + mppt;
  60. const PV_Power = values.PV_Power;
  61. const consumption = values.consumption;
  62. var currentSchedule = global.get("CurrentSchedule");
  63. const SOCInfo = values.SOCInfo
  64. const grid = values.grid
  65. const voltageIn = values.voltageIn
  66.  
  67.  
  68. var maxInverterPower = 0;
  69. var newSetpoint = 20;
  70. var nodeWarn="green";
  71.  
  72. debug["Default"]={"battery":battery,"SOC":SOC,"setpoint":setpoint};
  73.  
  74. function getChargeStateInCheapPeriod() {
  75. var state =0
  76. var desiredSOC
  77. // const notBelowSOC = SOCInfo.notBelowDuringCharge
  78. const notBelowSOC = SOCInfo.nextSOC
  79.  
  80. if (currentSchedule.override)
  81. desiredSOC=currentSchedule.SOC
  82. else
  83. {
  84.  
  85. desiredSOC = notBelowSOC
  86. desiredSOC = desiredSOC > 100 ? 100 : desiredSOC
  87. }
  88. // desiredSOC = SOCInfo.nextSOC // THis is enough to get through the period
  89.  
  90.  
  91. log("SOC:"+SOC+"notbel"+notBelowSOC+"desired"+desiredSOC)
  92. // if(SOCInfo.maxSOCToday==100)
  93. // {
  94. // state = ChargeState.EmptyBattey
  95. // }else
  96. // This needs a lot of tweaking... look ahead to see if any charge needed and also if not then dont use battery
  97. if ((SOC > notBelowSOC || SOCInfo.maxSOCToday==100) && SOC>desiredSOC && SOCInfo.correction==0)
  98. state = ChargeState.UseBattery
  99. else if (SOC >= desiredSOC)
  100. state = ChargeState.HoldCharge
  101. else
  102. state = ChargeState.ChargeBattery
  103.  
  104.  
  105. return state
  106. }
  107.  
  108. //New charge state c
  109. var state = ChargeState.ChargeBattery
  110.  
  111. if (currentSchedule.cheapPeriod){
  112. state=getChargeStateInCheapPeriod();
  113. } else {
  114. state= ChargeState.ExpensivePeriod
  115. }
  116.  
  117. // This will aim to hit the SOC at the end of the period
  118. function getChargeRateToMeetSOC(desiredSOC){
  119. // Do the math to find the new charge rate
  120. var now = new Date();
  121. var hoursNow = now.getHours();
  122. var minutesNow = now.getMinutes();
  123.  
  124. var startTimeStr = currentSchedule["start"];
  125. var startT = startTimeStr.split(":");
  126.  
  127. var startHour = startT[0];
  128. var startMinute = startT[1];
  129.  
  130. var startTime = new Date();
  131. startTime.setHours(startHour)
  132. startTime.setMinutes(startMinute); //if after midnight sets to today
  133.  
  134. var timeR = currentSchedule["length"].split(":");
  135. var diff = timeR[0] * 60 + timeR[1] * 1;
  136.  
  137. var endTime = new Date(startTime.valueOf() + diff * 60000);
  138. // endtime-nowtime>24h
  139.  
  140. // //Correct for times running over midnight
  141. // if (endTime.valueOf() < now.valueOf()) {
  142. if (endTime.valueOf() - now.valueOf() > 86400000){
  143. var etv = endTime.valueOf() - 24 * 60 * 60 * 1000;
  144. endTime = new Date(etv);
  145. }
  146.  
  147. //
  148.  
  149. // var remainingTime = (endTime.valueOf() - now.valueOf()) / 60000;
  150. var SOCChangedTime=flow.get("timeSOCChanged")
  151. var remainingTime = (endTime.valueOf() - SOCChangedTime) / 60000;
  152. var remainingSOC = desiredSOC - SOC;
  153.  
  154. var powerRemaining = remainingSOC / 100 * 4800 * 8;
  155. var powerPerHour = Math.round(powerRemaining / remainingTime * 60);
  156. // How much needs to go into the battery.
  157. return powerPerHour
  158. }
  159.  
  160. // Meets the battery charge discharge based on grid only.
  161. function setPointToMeetBattery(chargeRate){
  162. // node.warn(PV_Power)
  163. if (chargeRate>0) {
  164. chargeRate = chargeRate / efficiencyAt(chargeRate)
  165. } else {
  166. chargeRate = chargeRate / efficiencyAt(chargeRate)
  167. }
  168.  
  169. // If this isn't added then the battery will discharge at about 100W
  170. // This currently causes oscillations.. needs damping
  171. if (chargeRate==0){
  172. const battery = global.get("state_Battery")
  173. chargeRate=-battery.power*.8
  174. }
  175.  
  176. // new set point
  177. // Problem is the mppt... In the morning it is the only power and causes issues
  178. const setPoint= consumption - PV_Power + chargeRate;
  179. return setPoint
  180. }
  181.  
  182. function clampValue(value,min,max){
  183. if (value <min)
  184. value = min
  185. else if (value>max)
  186. value = max
  187.  
  188. return value
  189. }
  190.  
  191. var powerPerHour=0
  192. var chargerOn=false
  193.  
  194. function isSunny(){
  195. return msg.payload.PV_State != 0
  196. }
  197.  
  198. log("state"+state.name)
  199. //If we are not in a charge schedule then return 20 which will be the default state to use the battery
  200. switch (state.name) {
  201. case ChargeState.ExpensivePeriod.name:
  202. {
  203. log ("expensive charge: "+0)
  204. if (isSunny())
  205. chargerOn = true // This is required if pv is greater than load
  206. else
  207. chargerOn= false // It is dark so we won't be charging
  208. maxInverterPower = -1
  209. newSetpoint = 0 // Don't allow power in from the grid. uSe the battery.
  210. log("max inverter"+ maxInverterPower)
  211. break
  212. }
  213. case ChargeState.EmptyBattey.name:
  214. {
  215. maxInverterPower=10000
  216. // var chargeRate= getChargeRateToMeetSOC(SOCInfo.nextSOC)
  217. newSetpoint=setPointToMeetBattery(-maxInverterPower)
  218. chargerOn=false
  219. break
  220. }
  221.  
  222. case ChargeState.UseBattery.name:
  223. {
  224. log("usebattery"+ 0)
  225. if (isSunny())
  226. {
  227. maxInverterPower = -1 // Without this the load won't be powered by mppt
  228. chargerOn = true // The battery won't be powered by excess sunlight without this.
  229. }else
  230. {
  231. var chargeRate = getChargeRateToMeetSOC(SOCInfo.notBelowDuringCharge)
  232. maxInverterPower = -1 //clampValue(chargeRate, 1000, 9000) // Limit lower level to 1000 since it gets very inefficient below this level.
  233. chargerOn=false
  234. }
  235. newSetpoint = 0
  236. break
  237. }
  238. case ChargeState.HoldCharge.name:
  239. {
  240. chargerOn = msg.payload.PV_State > 0 // Charge if there is PV
  241. // node.warn(chargerOn);
  242. // We have a minimum value of 0 so that we are not exporting. This doesn't work.
  243. if(msg.payload.PV_State!=0 && mppt>500) //if day
  244. maxInverterPower=-1 // Do this or we won't export any extra power.
  245. else
  246. maxInverterPower=0 // Night time freeze the inverter
  247. if (SOC>98) {
  248. newSetpoint = clampValue(setPointToMeetBattery(0), 0, maxPowerHouseIn) +100
  249. }
  250. else {
  251. //was clamped to 9000 for inverter. this is worng
  252. newSetpoint=clampValue(setPointToMeetBattery(0),0,maxPowerHouseIn)
  253. }
  254. break
  255. }
  256. case ChargeState.ChargeBattery.name:
  257. {
  258. //need to implemnt hold charge at zero until minimum time to achive
  259.  
  260. chargerOn=true
  261. maxInverterPower = -1 // depends on sunshine and loads
  262.  
  263. var nextSOC=SOCInfo.nextSOC>100?100:SOCInfo.nextSOC //cap at 100 %
  264. // nextSOC=SOCInfo.notBelowDuringCharge
  265.  
  266. if (currentSchedule.override){
  267. nextSOC=currentSchedule.SOC
  268. nodeWarn="blue"
  269. }
  270. const minimumChargeRate = 1000
  271. const now = new Date();
  272. var chargeRate = getChargeRateToMeetSOC(nextSOC)
  273.  
  274. // change timesocchanged if no tick in expected period. If we aren't charging as quickly as expected then give it a little boost
  275. var lastTick=flow.get("timeSOCChanged")
  276. var timePeriodForPercent=384/chargeRate * (60000*60)
  277. if (now.valueOf()-lastTick>timePeriodForPercent){
  278. flow.set("timeSOCChanged",now.valueOf())
  279. }
  280.  
  281. /* Do nothing until charge rate is 1000.
  282. ie leave as late as possible to allow solar to charge.
  283. If there is DC solar then use that as it is more efficient to direct charge with it
  284. then power a load.
  285. */
  286. if (chargeRate<minimumChargeRate) {
  287. //reset the time
  288. flow.set("timeSOCChanged", now.valueOf())
  289. // chargeRate=Math.min(mppt,chargeRate) //More efficient to charge with the mppt
  290. newSetpoint=0 // this would not work as we would be using the battery
  291. chargeRate=0 // keep at zero until minimum charge rate is required to hit the charge rate
  292. } else {
  293. chargeRate = clampValue(chargeRate, minimumChargeRate, 7300) // Limit lower level to 1000 since it gets very inefficient below this level.
  294. powerPerHour = chargeRate // set this value for the node status
  295. newSetpoint=clampValue(setPointToMeetBattery(chargeRate),0,12000) // Be nice to the house input and make sure we don't export
  296. }
  297. // if (SOC>90) {
  298. // chargeRate=chargeRate+400
  299. // }
  300. break
  301. }
  302. }
  303.  
  304. //fixes the battery voltage so that power is diverted. Leaving the battery at the voltage the zappie charging was started.
  305. // var correction=0
  306. // if (batteryPower>0) // Charging
  307. // correction = batteryPower/10000+.05
  308. // else if (batteryPower<0) {
  309. // correction = batteryPower/10000-.04
  310. // }
  311.  
  312. // if (grid<0){
  313. // correction = grid/3000
  314. // }
  315. // msg4.payload=voltage-correction
  316.  
  317. // node.warn(state.name);
  318. // node.warn(newSetpoint)
  319. newSetpoint = Math.round(newSetpoint);
  320. // node.warn(newSetpoint)
  321. msg.debug = debug;
  322.  
  323. // msg2.payload = currentSchedule["SOC"];
  324.  
  325. /*Value types
  326.  
  327. 0 - Charge allowed
  328. 1 - Charge disabled
  329. */
  330. log("chargeron:"+chargerOn)
  331. var dvcc=-1
  332. log("max inverter"+maxInverterPower)
  333. const text = state.name + " SOC:" + SOCInfo.nextSOC + "PowerRq: "+powerPerHour+"W,Set: "+newSetpoint
  334. node.status({fill:nodeWarn,shape:"dot",text: text});
  335.  
  336. msg.payload={
  337. setpoint:newSetpoint,
  338. essChargeFlag: chargerOn ? 0 : 1,
  339. maxInverterPower:maxInverterPower,
  340. dvcc:dvcc,
  341. chargeState:state.name
  342. }
  343.  
  344. // For ess
  345. // set point, min soc, inverter level,feedin.
  346. return msg
2 |3000

Up to 8 attachments (including images) can be used with a maximum of 190.8 MiB each and 286.6 MiB total.

andy156 avatar image
andy156 answered ·

figure 3. Calculate SOC to target in powerusage2

  1. /*
  2. TODO
  3.  
  4. calculate minimum daily consumption.. ie don't charge up too much when it isn't necessary
  5. calculate estimated charge for 0730 in the morning so we can charge
  6. the car.
  7.  
  8. */
  9.  
  10.  
  11. //sunrise
  12. //stop time
  13. //msg.forecast [ time:dt:temp:heatingLoad:baseLoad:] 30 minute segments
  14. //global.pvobject.forecasts[pv_estimate:period_end:]
  15.  
  16. const minSOCOnBattery=20
  17. const isToday = (someDate) => {
  18. const today = new Date()
  19. return someDate.getDate() == today.getDate() &&
  20. someDate.getMonth() == today.getMonth() &&
  21. someDate.getFullYear() == today.getFullYear()
  22. }
  23.  
  24.  
  25. var pv=global.get("pvobject").forecasts
  26.  
  27. //remove all the out of date objects
  28. var now=new Date( Date.now())
  29.  
  30.  
  31. // now.setMinutes(30-now.getMinutes())
  32. const nowM=now.getMinutes()
  33. now.setMinutes(nowM + (30 - nowM % 30))
  34. now.setSeconds(0)
  35. now.setMilliseconds(0)
  36.  
  37. var nowV=now.valueOf()
  38.  
  39. pv=pv.map(value=>{
  40. var time=new Date(value.period_end)
  41. var dt = time.valueOf()
  42. return {
  43. pv:value.pv_estimate,
  44. time:value.period_end,
  45. dt:dt
  46. }
  47. })
  48.  
  49. pv=pv.filter(n=>n)
  50.  
  51. const tdt=nowV
  52.  
  53. var forecast=msg.forecast
  54. var length=Math.min(forecast.length,pv.length)
  55.  
  56. // node.warn(length)
  57. forecast.length=length
  58. pv.length=length
  59. // node.warn(now);
  60. var forecast = msg.forecast.filter(value=>{
  61. return value.dt>=nowV
  62. })
  63.  
  64. // node.warn(forecast)
  65. const SOC = global.get("SOC")
  66. // const SOC = 0
  67.  
  68. //merge together the two objects
  69. var result =forecast.map(value=>{
  70. var result = value
  71. var pvItem = pv.find(value2=>{
  72. // node.warn(value.dt+" "+ value2.dt)
  73. return value.dt==value2.dt}
  74. )
  75. if (pvItem)
  76. result.pv=pvItem.pv*1000
  77. else
  78. result.pv=0
  79. result.expectedPowerUsage=result.baseLoad+result.heatingLoad-result.pv
  80. result.load=result.baseLoad+result.heatingLoad
  81.  
  82. result.SOC = SOC // Forward SOC
  83. return result
  84. }
  85. )
  86.  
  87.  
  88. // Calculate the virtual battery going forward
  89. // This is based on the SOC a the start.... should this be the next period SOC?
  90. const totalBattery= 4800*8
  91. // Work backwards for min SOC to hit in each expensive period
  92. result = result.reduceRight((accum, value) => {
  93.  
  94. var SOCUsed = 0
  95. var resultArray = accum.arr
  96. // node.warn(accum)
  97. var runningTotalInCurrentPeriod = accum.runningTotalInCurrentPeriod
  98. var runningTotalForAllPeriods = accum.runningTotalForAllPeriods
  99. // node.warn(runningTotalForAllPeriods)
  100. SOCUsed = value.expectedPowerUsage * 50 / totalBattery
  101. var before=runningTotalForAllPeriods
  102. if (!value.isCheap) {
  103. if (resultArray.length == 0 || resultArray.slice(-1)[0].isCheap)
  104. runningTotalInCurrentPeriod = 0
  105.  
  106. // SOCUsed = value.expectedPowerUsage * 50 / totalBattery //isnt including stuff at the end of the period in the morning
  107. runningTotalInCurrentPeriod = max(0,runningTotalInCurrentPeriod + SOCUsed)
  108. runningTotalForAllPeriods = Math.min(80, max(0, runningTotalForAllPeriods + SOCUsed))
  109. // Math.min(80, max(0, runningTotalForAllPeriods + SOCUsed))
  110. } else {
  111.  
  112. // Set to the last if in a cheap period. ie the value we need to charge to
  113. if(resultArray.length>0)
  114. runningTotalInCurrentPeriod = resultArray.slice(-1)[0].minSOCInPeriod
  115.  
  116. if (SOCUsed<0) {
  117. runningTotalForAllPeriods = max(0, runningTotalForAllPeriods + SOCUsed) //Take into account afternoon generation in the cheap period.
  118. }
  119. }
  120. var wt = value.isCheap ? "Cheap" : "expensive"
  121. // node.warn(wt+runningTotalForAllPeriods)
  122.  
  123. value.minSOCInPeriod = runningTotalInCurrentPeriod
  124. var after = runningTotalForAllPeriods
  125. value.minSOCToMeetFuturePeriods = runningTotalForAllPeriods
  126. // node.warn("soc"+SOCUsed+"before:"+before+"after"+after)
  127. resultArray.push(value)
  128. result = {
  129. arr: resultArray,
  130. runningTotalInCurrentPeriod: runningTotalInCurrentPeriod,
  131. runningTotalForAllPeriods: runningTotalForAllPeriods
  132. }
  133. return result
  134. }, {
  135. arr: [],
  136. runningTotalInCurrentPeriod: 0,
  137. runningTotalForAllPeriods: 0
  138. })// accum: result,
  139. // Working backwards gives the total required for the whole period.... but what about the other way?
  140. result = result.arr.reverse()
  141.  
  142.  
  143. result = result.reduce((result,value)=>{
  144. //initial values
  145. var lastSOC=SOC
  146. var lastSOC0=0
  147. var lastcappedSOC=SOC // This value is capped at 100% whereas the other isn't giving an idea of lost power
  148. var lastItem
  149. if (result.length>0) {
  150. lastItem = {...result.slice(-1)}
  151. lastSOC=lastItem[0]["SOC"]
  152. lastcappedSOC = lastItem[0]["cappedSOC"]
  153. lastSOC0 = lastItem[0]["SOC0"]
  154. }
  155. const change = value.expectedPowerUsage*50 / totalBattery //.5*100 to get %
  156. value.SOC=lastSOC-change
  157. value.cappedSOC=lastcappedSOC-change
  158. value.SOC0=lastSOC0-change
  159. if (value.SOC0<20)
  160. value.SOC0=20
  161. if (value.SOC0>100)
  162. value.SOC0=100
  163.  
  164. if (value.SOC<minSOCOnBattery)
  165. value.SOC=minSOCOnBattery
  166. if(value.cappedSOC>100)
  167. value.cappedSOC=100
  168. if (value.cappedSOC<=minSOCOnBattery)
  169. value.cappedSOC=minSOCOnBattery
  170. result.push({...value})
  171. return result
  172. },[])
  173.  
  174. // Maximum SOC before midnight
  175. const maxSOCToday=result.filter(value=>{
  176. return isToday(new Date(value.dt))
  177. }).reduce(
  178. (accum,value)=>{
  179. if (value.SOC>accum)
  180. accum=value.SOC
  181. return accum
  182. }
  183. ,0)
  184.  
  185. const spareSolarToday = result.filter(value => {
  186. return isToday(new Date(value.dt))
  187. }).reduce(
  188. (accum, value) => {
  189. if (value.SOC>=100 && value.expectedPowerUsage <0)
  190. accum = accum-value.expectedPowerUsage
  191. return accum
  192. }
  193. , 0)/2.0
  194.  
  195. const maxCappedSOC = result.filter(value => {
  196. return isToday(new Date(value.dt))
  197. }).reduce(
  198. (accum, value) => {
  199. if (value.cappedSOC > accum)
  200. accum = value.cappedSOC
  201. return accum
  202. }
  203. , 0)
  204.  
  205. const minSOC = result.reduce(
  206. (accum, value) => {
  207. if (value.SOC < accum)
  208. accum = value.cappedSOC
  209. return accum
  210. }
  211. , 100)
  212.  
  213.  
  214. // what is the maximum in the next 24 hrs
  215. const maxSOCForCorrection = result.filter(value=>{
  216. return value.dt<(now.valueOf()+60*60824*1000) // Values in next 24 hours
  217. }).reduce(
  218. (accum, value) => {
  219. // node.warn(value.time);
  220. if (value.SOC0 > accum)
  221. accum = value.SOC0
  222. return accum
  223. }
  224. , 0)
  225.  
  226. const maxSOC0 = result.filter(value => {
  227. return value.dt < (now.valueOf() + 60 * 60*24 * 1000) // Values in next 24 hours
  228. }).reduce(
  229. (accum, value) => {
  230. // node.warn(value.time);
  231. if (value.SOC0 > accum)
  232. accum = value.SOC0
  233. return accum
  234. }
  235. , 0)
  236. const minSOC0 = result.reduce(
  237. (accum, value) => {
  238. if (value.SOC0 < accum)
  239. accum = value.SOC0
  240. return accum
  241. }
  242. , 100)
  243.  
  244. function max(a,b){
  245. if (a>b)
  246. return a
  247. else
  248. return b
  249. }
  250.  
  251. var SOCInfo2 = flow.get("SOCInfo")
  252.  
  253. SOCInfo2["correction"] = 0
  254. // look at the max SOC in the next 24 hours. This doesn't work when SOC is high now and only decreasing.
  255. //. e.g. 2130 soc is 70 and tomorrow remaining at 20 all day. This should really be a case for not
  256. // discharging as we will only need to charge agaain tomorrow
  257.  
  258. // if (maxSOCForCorrection < 50) {
  259. // var correction =(100 - maxSOCForCorrection-20)/2
  260. // SOCInfo2["correction"] = correction
  261. // result.map(value => {
  262. // value.minSOCInPeriod =(Math.ceil(value.minSOCInPeriod) +value.minSOCToMeetFuturePeriods)/2
  263. // })
  264. // }
  265. var spare = Math.max(0, (100 - maxSOC0) - 20)
  266. //Correct the minsoc in period
  267. result = result.map(value => {
  268. const before = value.minSOCInPeriod
  269. value.minSOCInPeriodO=value.minSOCInPeriod
  270.  
  271. // minsoc to meet future periods... always this since we don't need to be operating at minimum
  272. //
  273. // value.minSOCInPeriod = (Math.ceil( value.minSOCToMeetFuturePeriods) / 2 + spare
  274.  
  275. // we want a minimum of value to meet. if the spare is more than we can start with this in the battery as well.
  276. value.CalculatedSOCForPeriod = Math.max(Math.ceil(value.minSOCToMeetFuturePeriods),spare)
  277. value.spare=spare
  278. // node.warn(value);
  279. // node.warn("Before"+before+" after:"+value.minSOCInPeriod+"minsoctomeet"+value.minSOCToMeetFuturePeriods+"spare"+spare+"soc0"+maxSOC0)
  280. return value
  281. })
  282.  
  283. flow.set("SOCInfo", SOCInfo2)
  284. msg.maxSOCToday = maxSOCToday
  285. msg.maxCappedSOC = maxCappedSOC
  286.  
  287. now=new Date()
  288.  
  289. if (now.getHours()<5)
  290. flow.set("earlyForecast",result)
  291. msg.payload={
  292. forecast:result,
  293. maxSOCToday:maxSOCToday,
  294. maxCappedSOC:maxCappedSOC,
  295. minSOC:minSOC,
  296. minSOC0:minSOC0,
  297. maxSOC0:maxSOC0,
  298. spareSolarToday:spareSolarToday
  299. }
  300. return msg;


2 |3000

Up to 8 attachments (including images) can be used with a maximum of 190.8 MiB each and 286.6 MiB total.

andy156 avatar image
andy156 answered ·

Usage is also logged to allow graphing such as the following in influx and grafana. This took ages to get right and the code required to generate the graph is produced in the following block.

screenshot-2023-10-21-at-161105.png

  1. function log(message) {
  2. if (flow.get("smaFlag")) {
  3. node.warn(message)
  4. }
  5. }
  6. var payload =
  7. {
  8. "solaredgepower": 5004,
  9. "BatteryPower": 6064, //- discharge, positive charge
  10. "GridIn": 20,
  11. "acconsumption": 6310,
  12. "accoupled 1": 5004,
  13. "mpptspower": 7448.75,
  14. "vebus_power": -1286, //inverter -> acout/grid
  15. "dc_system": null
  16. }
  17.  
  18. payload = msg.payload
  19. payload.mpptspower = payload.mpptspowerL + payload.mpptspowerR
  20.  
  21.  
  22. //Info i want
  23. // Consumption = Grid + Battery + Solar
  24. // Solar -> Feed+Battery+Grid
  25. var solar = {
  26. "SolarToGrid": 0,
  27. "SolarToBattery": 0,
  28. "SolarToLoad": 0
  29. }
  30. var consumption = {
  31. "GridToBattery": 0,
  32. "GridToLoad": 0,
  33. "SolarToLoad": 0,
  34. "BatteryToLoad": 0
  35. }
  36.  
  37. // grid = battery - solar + load
  38. const inverter = {
  39. "inverter": payload.vebus_power,
  40. dischargingSolar: 0,
  41. dischargingBattery: 0,
  42. chargingFromGrid: 0,
  43. chargingFromSolar: 0
  44. }
  45.  
  46. var pvSE= flow.get("solaredgepower")
  47. var opvSE = flow.get("Osolaredgepower")
  48.  
  49. if (pvSE>5000 && opvSE)
  50. pvSE = opvSE
  51. if (pvSE==0 && opvSE>100)
  52. pvSE=opvSE
  53. flow.set("solaredgepower",0)
  54. flow.set("Osolaredgepower",pvSE)
  55.  
  56. const input = {
  57. mpptpv: payload.mpptspowerL + payload.mpptspowerR,
  58. pvSE: pvSE,
  59. pv: pvSE + payload.mpptspower,
  60. battery: payload.BatteryPower,
  61. load: payload.acconsumption,
  62. grid: payload.GridIn,
  63. SOC: payload.SOC,
  64. inverter: inverter.inverter
  65. }
  66.  
  67. /*
  68. solar = battery-grid+load
  69. solar to battery = solar +grid-load
  70. solar to grid = battery+load-solar
  71. solar to load = solar -battery-load
  72. */
  73. var output = {
  74. B2L: 0,
  75. B2G: 0,
  76. S2L: 0,
  77. S2G: 0,
  78. S2B: 0,
  79. S2I: 0,
  80. B2I: 0,
  81. G2L: 0,
  82. G2I: 0,
  83. G2B: 0
  84. }
  85. var B2L = 0
  86. var B2G = 0
  87. var S2L = 0
  88. var S2G = 0
  89. var S2B = 0
  90. var S2I = 0
  91. var B2I = 0
  92. var G2L = 0
  93. var G2I = 0
  94. var G2B = 0
  95. var sectionText = ""
  96. log("start sma")
  97.  
  98. function calculateSMAValues(input) {
  99. const battery = input.battery
  100. var inverter = input.inverter
  101. const grid = input.grid
  102. const load = input.load
  103. const mpptpv = input.mpptpv
  104. const pvSE = input.pvSE
  105. const inverterBacking=mpptpv-battery
  106. // Sanity check on inverter...
  107. // if (inverter<0 && -inverter>inverterBacking+1000){
  108. // inverter=inverter/inverter*inverterBacking
  109. // }
  110.  
  111. if (inverter < 0) { // discharging
  112. sectionText = "inverter"
  113. B2I = (battery >= 0) ? 0.0 : (-battery)
  114. S2I = (battery >= 0) ? mpptpv - battery : mpptpv // If battery is charging then it is coming from mppt
  115. S2B = (battery > 0) ? battery : 0.0
  116. const ratio = S2I / (B2I + S2I)
  117.  
  118.  
  119. S2I = inverter * ratio // If S2I -1000
  120. B2I = inverter - S2I //-1000--1000
  121.  
  122. log("ratio :" + ratio)
  123. if (ratio > 1 || S2I > 16000) {
  124. var tmp = "ratio>1:" + ratio + "B2I" + B2I + "S2I" + S2I
  125. log(tmp)
  126. global.set("debug", tmp)
  127.  
  128. }
  129. // log("B2i: "+B2I)
  130. //correct for losses through inverter
  131. // var ratio=-inverter/(S2I+B2I)
  132. // S2I=S2I
  133. // B2I = B2I
  134. // S2B=S2B
  135.  
  136. // log("grid:"+grid)
  137.  
  138. if (grid == 0) {
  139. sectionText = sectionText + " da"
  140. log("da")
  141. S2L = pvSE - S2I
  142. B2L = -B2I
  143. } else if (grid > 0) {// grid coming in
  144. sectionText = sectionText + " db"
  145. log("db")
  146. log("pvse:" + pvSE + " s2i:" + S2I + " load:" + load + " grid:" + grid + " B2I:" + B2I)
  147.  
  148. G2L = grid
  149. S2L = pvSE - S2I
  150. B2L = -B2I //load - grid - S2L //S2I+pvSE
  151. B2L = B2L < 0 ? 0 : B2L
  152. log("S2B:" + S2B + " G2L:" + G2L + " S2L:" + S2L + " b2l:" + B2L)
  153. //Sanity check on inverter. If it is greater than sum of solar and battery then reduce it.
  154.  
  155.  
  156. if (S2B > 0 && G2L > 0) {
  157. if (S2B > G2L) {
  158. sectionText = sectionText + " s2b>g2l"
  159. log("s2b>g2l")
  160. S2B = S2B - G2L
  161. S2L = S2L + G2L
  162. G2B = G2L
  163. G2L = 0
  164. } else { //S2B<G2L
  165. sectionText = sectionText + " s2b<g2l"
  166. log("s2b<g2L")
  167. G2B = S2B
  168. S2L = S2L + S2B
  169. G2L = G2L - S2B
  170. S2B = 0
  171. }
  172. }
  173. } else if (grid < 0) { // grid going out + discharging
  174. log("pvse:" + pvSE + " s2i:" + S2I + " load:" + load + " grid:" + grid + " B2I:" + B2I)
  175. if ((pvSE - S2I) <= load) {
  176. sectionText = sectionText + " dc"
  177. // (load = S2L+B2L)
  178. S2L = pvSE - S2I
  179. B2L = load - S2L
  180. B2G = -grid
  181. }
  182. else if ((pvSE - S2I) > load) {
  183. sectionText = sectionText + " dd"
  184. log("dd")
  185. S2L = load
  186. // S2G=pvSE+S2I-load //Causes fluctiations when sunny
  187. // pvSE + S2I
  188.  
  189. S2G = pvSE - S2I - load
  190. if (S2G>-grid)
  191. S2G = -grid
  192.  
  193. B2G = -grid - S2G
  194. log("B2G" + B2G)
  195. }
  196. }
  197. } else if (inverter == 0) {
  198. if (pvSE >= load) {
  199. sectionText = sectionText + " ia"
  200. log("ia")
  201. S2L = load
  202. S2G = grid
  203. } else {
  204. sectionText = sectionText + " ib"
  205. log("ib")
  206. S2L = pvSE
  207. G2L = grid
  208. }
  209. S2B = mpptpv
  210. } else if (inverter > 0) //charging
  211. {
  212. if (pvSE >= load) {
  213. S2L = load
  214. if (grid > 0) { //Incoming grid
  215. sectionText = sectionText + " ca"
  216. log("ca")
  217. G2I = grid
  218. S2I = pvSE - load
  219. } else { //Exporting
  220. sectionText = sectionText + " cb"
  221. log("cb")
  222. S2G = -grid
  223. S2I = -grid - load + pvSE //grid-load+pvSE = inverter-pvSe+load
  224. }
  225.  
  226. //s2l = 5001
  227.  
  228. } else if (pvSE < load) {
  229. S2L = pvSE
  230. if (grid >= 0) { //grid coming in.
  231. sectionText = sectionText + " cc"
  232. log("cc")
  233. G2L = load - pvSE
  234. G2I = grid - G2L
  235. }
  236. }
  237.  
  238. const ratio = inverter / (G2I + S2I)
  239. G2I = G2I * ratio
  240. S2I = S2I * ratio
  241. // charging so whole load will be grid+S2L at this point
  242. var diff = load-S2L // guardanteed to be positive
  243. if (mpptpv+S2I>=diff) {
  244. S2L=load
  245. S2B=mpptpv+S2I-diff
  246. G2L=G2L-diff
  247. G2B=G2I+diff
  248. } else {
  249. S2L=S2L+mpptpv+S2I
  250. S2B=0
  251. G2L=G2L-mpptpv-S2I
  252. G2B=G2I+mpptpv+S2I
  253. }
  254. // Need S2L to be as big as possible. currently G2b is first.
  255.  
  256. }
  257. output = {
  258. section: sectionText,
  259. B2L: B2L,
  260. B2G: B2G,
  261. S2L: S2L,
  262. S2G: S2G,
  263. S2B: S2B,
  264. S2I: S2I,
  265. B2I: B2I,
  266. G2L: G2L,
  267. G2I: G2I,
  268. G2B: G2B,
  269. load: load
  270.  
  271. }
  272.  
  273. return output
  274.  
  275. }
  276.  
  277. output = calculateSMAValues(input)
  278.  
  279. var error = false
  280. var errorText=""
  281. //Check for errors:
  282. if (output.S2L + output.B2L + output.G2L != output.load) {
  283. error = true
  284. errorText = "load inputs greater than load"
  285. }
  286. if ((pvSE+input.mpptpv)> 16500 || output.S2B+output.S2G+output.S2L>16500){
  287. error=true
  288. errorText="Excessive PV production"
  289. }
  290.  
  291.  
  292. if (error){
  293. // node.warn("Error")
  294. var errorsArray = flow.get("errors")
  295. const time = new Date(Date.now())
  296. const result = {
  297. error: errorText,
  298. time:time.toLocaleTimeString(),
  299. input: input,
  300. output: output
  301. }
  302.  
  303. // node.warn(errorsArray)
  304. // node.warn(result)
  305. if (!errorsArray)
  306. errorsArray = [result]
  307. else
  308. errorsArray.push(result)
  309. flow.set("errors", errorsArray)
  310. }
  311. // zeros and nulls zero
  312. function nz(value, name) {
  313. if (value < 0)
  314. log("WARNING negative value for. " + name + " " + value)
  315. value= value < 0 ? 0 : value
  316. // if (value == 0)
  317. // value=null
  318. return value
  319. }
  320.  
  321. S2G = nz(output.S2G, "S2G")
  322. S2B = nz(output.S2B, "S2B")
  323. S2B = nz(output.S2B, "S2B")
  324. S2L = nz(output.S2L, "S2L")
  325. G2L = nz(output.G2L, "G2L")
  326. B2L = nz(output.B2L, "B2L")
  327. B2G = nz(output.B2G, "B2G")
  328. G2B = nz(output.G2B, "G2B")
  329.  
  330. B2G=B2G<50?0:B2G
  331.  
  332. var load = nz(output.load)
  333.  
  334. var result = {
  335. solar2Grid: S2G,
  336. solar2Battery: S2B,
  337. solar2Load: S2L,
  338. battery2Load: B2L,
  339. "grid2Load": G2L,
  340. grid2Battery: G2B,
  341. battery2Grid: B2G,
  342. SOC : input.SOC,
  343. totalLoad: load
  344. }
  345. log ("sectiontext:"+sectionText)
  346. msg.payload.pvse = pvSE
  347. msg.payload.sectionText = sectionText
  348. msg.powerRouting = result
  349. msg.payload2 = msg.payload // The raw values
  350.  
  351. msg.payload = [result, {
  352. device: "cerbo"
  353. }];
  354. msg.measurement = "SMAView";
  355.  
  356. log("end sma")
  357. return msg

2 |3000

Up to 8 attachments (including images) can be used with a maximum of 190.8 MiB each and 286.6 MiB total.

andy156 avatar image
andy156 answered ·

If you have a lot of solar panels and a large inverter you might want to protect things in your house in the UK. The limit these days seems to be 266V and a lot of things will die at this voltage. I dynamically limit the export with the figure 2 limit export node


  1. /*
  2. battery:,
  3. SOC:msg.payload["BatterySOC"],
  4. batteryPower:msg.payload["BatteryPower"],
  5. setpoint :msg.payload["ESS_Setpoint"],
  6. mppt : mppt,
  7. voltage :msg.payload["BatteryVoltage"],
  8. globalState : global.get("state"),
  9. PV_Power : (pvse + mppt),
  10. consumption : msg.payload["consumption"],
  11. rrentSchedule : global.get("CurrentSchedule"),
  12. SOCInfo : flow.get("SOCInfo"),
  13. grid : msg.payload["grid"],
  14. voltageIn : msg.payload["voltageIn"]
  15. }
  16. */
  17. var maxV=251
  18. var state = msg.payload
  19. let pvse=state["PV_SE"]
  20. let consumption=state["consumption"]
  21. // This line ensures that we don't go over 253 volts.
  22. var feedIn = context.get("feedIn")
  23. var vDiff = maxV - state.voltageIn
  24. var gridDiff = feedIn + state.grid
  25. var correction = 0
  26. const ovdiff=context.get("vdiff")
  27.  
  28. if (vDiff < 0) {
  29. // Greater than the voltage we want. make a big correction
  30. if (ovdiff>=0) {
  31. feedIn = feedIn-200
  32. } else {
  33. feedIn = feedIn + vDiff * 400
  34. }
  35. } else if (vDiff > .55 && gridDiff < 500) {
  36. // Creep up on the export figure from below. Can go faster at the start.
  37. correction = (30 * Math.pow(vDiff, 2))
  38. feedIn = feedIn + correction
  39. }
  40. context.set("vdiff",vDiff)
  41. // node.warn(feedIn + ":" + vDiff + ":" + gridDiff + "c:" + correction)
  42. feedIn = Math.round(clampValue(feedIn, 0, 13000))
  43.  
  44. // always feed in a trickle from the inverter so that we don't over charge battery
  45. // feedIn = Math.max(Math.max(pvse-consumption,0)+250,500)
  46.  
  47. context.set("feedIn", feedIn)
  48. msg.payload = feedIn
  49.  
  50.  
  51. node.status({ fill: "green", shape: "dot", text: feedIn +"kW"});
  52.  
  53. return msg;
  54.  
  55.  
  56. function clampValue(value, min, max) {
  57.  
  58. if (value < min)
  59. value = min
  60. else if (value > max)
  61. value = max
  62.  
  63. return value
  64. }
2 |3000

Up to 8 attachments (including images) can be used with a maximum of 190.8 MiB each and 286.6 MiB total.

kevgermany avatar image
kevgermany answered ·

@Andy156

Looks good, but I didn't bother to read the code.

Moving to modifications as it's Node Red.

1 comment
2 |3000

Up to 8 attachments (including images) can be used with a maximum of 190.8 MiB each and 286.6 MiB total.

andy156 avatar image andy156 commented ·
No problem. Thanks. Just posted as someone wanted it. Might help someone get started :P I'd probably suggest that most should use the built in. This is handy if extra flexibility is required.
0 Likes 0 ·
wkirby avatar image
wkirby answered ·

This is really nice to see.
I have a much smaller flow which helps me to optimise Octopus Go (4 hours of sensibly priced electricity). It sits idle for at least six months of the year but it's started working again now that the days are getting shorter and more rubbish.

2 |3000

Up to 8 attachments (including images) can be used with a maximum of 190.8 MiB each and 286.6 MiB total.