Does anyone know how I can get Node-Red access to the battery settings as defined in Victron Connect for a Smartshunt? I do not like setting the battery capacity (the maximum, not the remaining) as a constant in my flow. I rather get it from the system.
I looked for that too and could not find it. There is a way though, you can reverse-engineer the capacity by tracking consumedAh and SoC, after a (partial) cycle you can calculate it:
capacityAh = ( delta-consumedAh / (delta-Soc%/100%) )
Youâd need only cycle enough to get to INT precision for capacityAh
Pro-tip: For large enough batteries Ah precision is higher than SoC% precision. Calculate your own precision SoC% = 100% + consumedAh / capacityAh
Yes, that was my alternative, too. Will look into it. Thanks!
ps. Would still be nice if these parameters (at least max capacity in Ah) were added by Victron to their Node-Red library. ![]()
Itâs probably a Smartshunt firmware limitation.
In Node-RED you can make a function node, have it store the following values in the flow context:
minSoC
minconsumedAh
maxSoC
maxconsumedAh
capacity
soc_equal
capacity_accurate
ahSoC
Feed the node with SoC (stepsize 0.1%) and consumedAh (stepsize 0.1Ah) retrieve / store those values, calculate capacity (INT) and ahSoC (float)
compare SoC to ( Math.round(ahSoC*10) / 10 and increment soc_equal when identical decrease when not, until enough comparisons are equal, then flag capacity_accurate to be true.
Here a test, might need some debugging:
[
{
"id": "0efc83ba5ec85d05",
"type": "tab",
"label": "Flow 2",
"disabled": false,
"info": "",
"env": []
},
{
"id": "fc1723b39f72c251",
"type": "group",
"z": "0efc83ba5ec85d05",
"style": {
"stroke": "#999999",
"stroke-opacity": "1",
"fill": "none",
"fill-opacity": "1",
"label": true,
"label-position": "nw",
"color": "#a4a4a4"
},
"nodes": [
"0d3687016ef69e50",
"4e8e969bda651c53",
"c9c5d4fce06cb734",
"8e528704d50bcd10"
],
"x": 34,
"y": 19,
"w": 372,
"h": 142
},
{
"id": "0d3687016ef69e50",
"type": "victron-input-battery",
"z": "0efc83ba5ec85d05",
"g": "fc1723b39f72c251",
"service": "com.victronenergy.battery/288",
"path": "/ConsumedAmphours",
"serviceObj": {
"service": "com.victronenergy.battery/288",
"name": "BMV Battery Monitor"
},
"pathObj": {
"path": "/ConsumedAmphours",
"type": "float",
"name": "Consumed Amphours (Ah)"
},
"name": "consumedah",
"onlyChanges": true,
"x": 130,
"y": 60,
"wires": [
[
"c9c5d4fce06cb734"
]
]
},
{
"id": "4e8e969bda651c53",
"type": "victron-input-battery",
"z": "0efc83ba5ec85d05",
"g": "fc1723b39f72c251",
"service": "com.victronenergy.battery/288",
"path": "/Soc",
"serviceObj": {
"service": "com.victronenergy.battery/288",
"name": "BMV Battery Monitor"
},
"pathObj": {
"path": "/Soc",
"type": "float",
"name": "State of charge (%)"
},
"name": "soc",
"onlyChanges": true,
"x": 110,
"y": 120,
"wires": [
[
"c9c5d4fce06cb734"
]
]
},
{
"id": "c9c5d4fce06cb734",
"type": "function",
"z": "0efc83ba5ec85d05",
"g": "fc1723b39f72c251",
"name": "ahsoc",
"func": "let ahsoc = null\nlet capacity = null\nlet testcapacity = null\nlet accurate = null\nlet soc = null\nlet minsoc = null\nlet maxsoc= null\nlet rangesoc = null\nlet consumedah = null\nlet minconsumedah = null\nlet maxconsumedah = null\nlet rangeconsumedah = null\nlet goodcount = 0\n\nlet storeahsoc = flow.get(\"storeahsoc\");\nif ( storeahsoc ) {\n ahsoc = storeahsoc.ahsoc\n capacity = storeahsoc.capacity\n testcapacity = storeahsoc.testcapacity\n accurate = storeahsoc.accurate\n soc = storeahsoc.soc\n minsoc = storeahsoc.minsoc\n maxsoc = storeahsoc.maxsoc\n rangesoc = storeahsoc.rangesoc\n consumedah = storeahsoc.consumedah\n minconsumedah = storeahsoc.minconsumedah\n maxconsumedah = storeahsoc.maxconsumedah\n rangeconsumedah = storeahsoc.rangeconsumedah\n goodcount = storeahsoc.goodcount\n}\n\nif ( msg.topic == 'soc' ) {\n soc = msg.payload\n if ( minsoc === null || soc < minsoc ) {\n minsoc = soc\n } \n if ( maxsoc === null || soc > maxsoc ) {\n maxsoc = soc\n }\n rangesoc = maxsoc - minsoc\n}\n\nif ( msg.topic == 'consumedah' ) {\n consumedah = msg.payload\n if ( minconsumedah === null || consumedah < minconsumedah ) {\n minconsumedah = consumedah\n } \n if ( maxconsumedah === null || consumedah > maxconsumedah ) {\n maxconsumedah = consumedah\n }\n rangeconsumedah = maxconsumedah - minconsumedah\n}\n\nif ( msg.topic == 'consumedah' && rangesoc > 0 && rangeconsumedah > 0) {\n testcapacity = Math.round( rangeconsumedah/(rangesoc/100) )\n let testahsoc = 100 + 100 * (consumedah/testcapacity)\n if ( (Math.round(10*testahsoc)/10) == (Math.round(10*soc)/10) ) {\n goodcount = goodcount + 1\n capacity = testcapacity\n } else {\n goodcount = goodcount - 1\n if (goodcount < 0 ) {\n goodcount = 0\n }\n }\n ahsoc = Math.round( 1000* 100 * (1 + (consumedah/capacity)) ) / 1000\n if ( goodcount >= 10 ) {\n accurate = true\n }\n}\n\nconst result = {\n ahsoc,\n capacity,\n testcapacity,\n accurate,\n soc,\n minsoc,\n maxsoc,\n rangesoc,\n consumedah,\n minconsumedah,\n maxconsumedah,\n rangeconsumedah,\n goodcount\n}\n\n// Store in flow context\nflow.set(\"storeahsoc\", result);\nif (accurate){\n msg.topic = 'ahsoc'\n msg.ahsoc = result\n msg.payload = ahsoc\n return msg\n}\n",
"outputs": 1,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 330,
"y": 60,
"wires": [
[
"8e528704d50bcd10"
]
]
},
{
"id": "8e528704d50bcd10",
"type": "debug",
"z": "0efc83ba5ec85d05",
"g": "fc1723b39f72c251",
"name": "",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": true,
"complete": "true",
"targetType": "full",
"statusVal": "payload",
"statusType": "msg",
"x": 310,
"y": 120,
"wires": []
},
{
"id": "613c97632e1770f6",
"type": "global-config",
"env": [],
"modules": {
"@victronenergy/node-red-contrib-victron": "1.6.52"
}
}
]
Thanks!
tested, it gets close quickly depending on battery size. you need to cycle about 200Ah to become more accurate than the BMV itself. I might run it through xAI later to make the code Node-RED compliant, with all exception and safeguard bells and whistles. But as is its pretty useful.
Here the latest version beta v0.4 15u52 NL
[
{
"id": "614cc2965af6d854",
"type": "tab",
"label": "Flow 4",
"disabled": false,
"info": "",
"env": []
},
{
"id": "cb0f03dbf1a5e600",
"type": "group",
"z": "614cc2965af6d854",
"name": "UpCycle Electric : Battery Capacity and Precision SoC%",
"style": {
"label": true
},
"nodes": [
"6d9a51475369890d",
"0a3031f13eee4fe2",
"fb8926289f53b87b",
"68b48cccc4b970c6"
],
"x": 34,
"y": 39,
"w": 452,
"h": 142
},
{
"id": "6d9a51475369890d",
"type": "victron-input-battery",
"z": "614cc2965af6d854",
"g": "cb0f03dbf1a5e600",
"service": "com.victronenergy.battery/288",
"path": "/ConsumedAmphours",
"serviceObj": {
"service": "com.victronenergy.battery/288",
"name": "BMV Battery Monitor"
},
"pathObj": {
"path": "/ConsumedAmphours",
"type": "float",
"name": "Consumed Amphours (Ah)"
},
"name": "consumedah",
"onlyChanges": true,
"x": 130,
"y": 80,
"wires": [
[
"fb8926289f53b87b"
]
]
},
{
"id": "0a3031f13eee4fe2",
"type": "victron-input-battery",
"z": "614cc2965af6d854",
"g": "cb0f03dbf1a5e600",
"service": "com.victronenergy.battery/288",
"path": "/Soc",
"serviceObj": {
"service": "com.victronenergy.battery/288",
"name": "BMV Battery Monitor"
},
"pathObj": {
"path": "/Soc",
"type": "float",
"name": "State of charge (%)"
},
"name": "soc",
"onlyChanges": true,
"x": 110,
"y": 140,
"wires": [
[
"fb8926289f53b87b"
]
]
},
{
"id": "fb8926289f53b87b",
"type": "function",
"z": "614cc2965af6d854",
"g": "cb0f03dbf1a5e600",
"name": "SoC% @ batteryAh",
"func": "// auto capacity detecting precision state of charge flow for bmv smartshunts\nlet ahsoc = null\nlet capacity = null\nlet testcapacity = null\nlet accurate = null\nlet soc = null\nlet minsoc = null\nlet maxsoc= null\nlet rangesoc = null\nlet consumedah = null\nlet minconsumedah = null\nlet maxconsumedah = null\nlet rangeconsumedah = null\nlet goodcount = 0\n\nlet storeahsoc = flow.get(\"storeahsoc\");\nif ( storeahsoc ) {\n ahsoc = storeahsoc.ahsoc\n capacity = storeahsoc.capacity\n testcapacity = storeahsoc.testcapacity\n accurate = storeahsoc.accurate\n soc = storeahsoc.soc\n minsoc = storeahsoc.minsoc\n maxsoc = storeahsoc.maxsoc\n rangesoc = storeahsoc.rangesoc\n consumedah = storeahsoc.consumedah\n minconsumedah = storeahsoc.minconsumedah\n maxconsumedah = storeahsoc.maxconsumedah\n rangeconsumedah = storeahsoc.rangeconsumedah\n goodcount = storeahsoc.goodcount\n}\n\nif ( msg.topic == 'soc' ) {\n soc = Math.round( 10 * msg.payload) / 10\n if ( minsoc === null || soc < minsoc ) {\n minsoc = soc\n } \n if ( maxsoc === null || soc > maxsoc ) {\n maxsoc = soc\n }\n rangesoc = maxsoc - minsoc\n}\n\nif ( msg.topic == 'consumedah' ) {\n consumedah = Math.round(10 * msg.payload) / 10\n if ( minconsumedah === null || consumedah < minconsumedah ) {\n minconsumedah = consumedah\n } \n if ( maxconsumedah === null || consumedah > maxconsumedah ) {\n maxconsumedah = consumedah\n }\n rangeconsumedah = maxconsumedah - minconsumedah\n}\n\nif ( msg.topic == 'consumedah' && rangesoc > 0 && rangeconsumedah > 0) {\n testcapacity = Math.round( rangeconsumedah/(rangesoc/100) )\n let testahsoc = 100 + 100 * (consumedah/testcapacity)\n if ( (Math.round(10*testahsoc)/10) == (Math.round(10*soc)/10) ) {\n goodcount = goodcount + 1\n capacity = testcapacity\n } else {\n goodcount = goodcount - 1\n if (goodcount < 0 ) {\n accurate = false\n goodcount = 0\n }\n }\n ahsoc = Math.round( 1000* 100 * (1 + (consumedah/capacity)) ) / 1000\n if ( goodcount >= 10 ) {\n accurate = true\n }\n}\n\nconst result = {\n ahsoc,\n capacity,\n testcapacity,\n accurate,\n soc,\n minsoc,\n maxsoc,\n rangesoc,\n consumedah,\n minconsumedah,\n maxconsumedah,\n rangeconsumedah,\n goodcount\n}\n\n// Store in flow context\nflow.set(\"storeahsoc\", result);\n\nlet text = ( ahsoc + '% SoC @ '+ capacity + 'Ah' )\nlet fill = ( (accurate === true) ? 'green' : 'red')\nlet shape = 'dot'\nnode.status({fill, shape, text});\n\nif (accurate){\n msg.topic = 'ahsoc'\n msg.ahsoc = result\n msg.payload = ahsoc\n return msg\n}\n",
"outputs": 1,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 350,
"y": 80,
"wires": [
[
"68b48cccc4b970c6"
]
]
},
{
"id": "68b48cccc4b970c6",
"type": "debug",
"z": "614cc2965af6d854",
"g": "cb0f03dbf1a5e600",
"name": "State of Charge %",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": true,
"complete": "true",
"targetType": "full",
"statusVal": "payload",
"statusType": "msg",
"x": 350,
"y": 140,
"wires": []
},
{
"id": "8faf943d1f4977c7",
"type": "global-config",
"env": [],
"modules": {
"@victronenergy/node-red-contrib-victron": "1.6.52"
}
}
]
Wow, impressive! It works great! Thank you!
beta v0.5 fast convergence. works greaat for large capcity (2000Ah) may need adjustment for small capacity (100Ah)
[
{
"id": "7cd2cb4657547f08",
"type": "victron-input-battery",
"z": "63338afbdee3e45c",
"g": "34bdab3b6a0c90b2",
"service": "com.victronenergy.battery/288",
"path": "/ConsumedAmphours",
"serviceObj": {
"service": "com.victronenergy.battery/288",
"name": "BMV Battery Monitor"
},
"pathObj": {
"path": "/ConsumedAmphours",
"type": "float",
"name": "Consumed Amphours (Ah)"
},
"name": "consumedah",
"onlyChanges": true,
"x": 130,
"y": 80,
"wires": [
[
"2a02f7c1cbb58f23"
]
]
},
{
"id": "e08d5d861c5631bc",
"type": "victron-input-battery",
"z": "63338afbdee3e45c",
"g": "34bdab3b6a0c90b2",
"service": "com.victronenergy.battery/288",
"path": "/Soc",
"serviceObj": {
"service": "com.victronenergy.battery/288",
"name": "BMV Battery Monitor"
},
"pathObj": {
"path": "/Soc",
"type": "float",
"name": "State of charge (%)"
},
"name": "soc",
"onlyChanges": true,
"x": 110,
"y": 140,
"wires": [
[
"2a02f7c1cbb58f23"
]
]
},
{
"id": "2a02f7c1cbb58f23",
"type": "function",
"z": "63338afbdee3e45c",
"g": "34bdab3b6a0c90b2",
"name": "SoC% @ batteryAh",
"func": "// auto capacity detecting precision state of charge flow for bmv smartshunts\nlet ahsoc = null\nlet testahsoc = null\nlet mincapacity = null\nlet capacity = null\nlet maxcapacity = null\nlet testcapacity = null\nlet accurate = null\nlet soc = null\nlet minsoc = null\nlet maxsoc= null\nlet rangesoc = null\nlet consumedah = null\nlet minconsumedah = null\nlet maxconsumedah = null\nlet rangeconsumedah = null\nlet rangefraction = null\nlet testscore = 0\n\nlet storeahsoc = flow.get(\"storeahsoc\");\nif ( storeahsoc ) {\n ahsoc = storeahsoc.ahsoc\n accurate = storeahsoc.accurate\n testscore = storeahsoc.testscore\n soc = storeahsoc.soc\n consumedah = storeahsoc.consumedah\n capacity = storeahsoc.capacity\n minsoc = storeahsoc.minsoc\n maxsoc = storeahsoc.maxsoc\n rangesoc = storeahsoc.rangesoc\n minconsumedah = storeahsoc.minconsumedah\n maxconsumedah = storeahsoc.maxconsumedah\n rangeconsumedah = storeahsoc.rangeconsumedah\n testsoc = storeahsoc.testsoc\n testconsumedah = storeahsoc.testconsumedah\n testcapacity = storeahsoc.testcapacity\n mincapacity = storeahsoc.mincapacity\n maxcapacity = storeahsoc.maxcapacity\n rangecapacity = storeahsoc.rangecapacity\n rangefraction = storeahsoc.rangefraction\n}\n\nif ( msg.topic == \"soc\" ) {\n soc = Math.round( 1000 * msg.payload) / 1000\n if ( minsoc === null || soc < minsoc ) {\n minsoc = soc\n } \n if ( maxsoc === null || soc > maxsoc ) {\n maxsoc = soc\n }\n rangesoc = maxsoc - minsoc\n rangesoc = Math.round( 10 * rangesoc ) / 10\n}\n\nif ( msg.topic == \"consumedah\" ) {\n consumedah = Math.round(1000 * msg.payload) / 1000\n if ( minconsumedah === null || consumedah < minconsumedah ) { \n minconsumedah = consumedah\n } \n if ( maxconsumedah === null || consumedah > maxconsumedah ) {\n maxconsumedah = consumedah\n }\n rangeconsumedah = maxconsumedah - minconsumedah\n rangeconsumedah = Math.round( 1000 * rangeconsumedah ) / 1000\n}\n\nif ( rangesoc > 0 && rangeconsumedah > 0) {\n rangecapacity = rangeconsumedah / ( rangesoc / 100 )\n rangecapacity = Math.round( 1000 * rangecapacity ) / 1000\n rangefraction = rangeconsumedah / rangecapacity\n rangefraction = Math.round( 1000 * rangefraction ) / 1000\n}\n\nif (rangeconsumedah > 0) {\n testcapacity = ( (-1 * consumedah) / (1 - (soc / 100) ) ) \n testcapacity = Math.round( testcapacity)\n testsoc = 100 * ( consumedah / testcapacity + 1 )\n testsoc = Math.round( 10 * testsoc) / 10\n if ( testsoc == soc ) {\n capacity = testcapacity\n if ( mincapacity === null || testcapacity < mincapacity ) {\n mincapacity = testcapacity\n }\n if ( maxcapacity === null || testcapacity > maxcapacity ) {\n maxcapacity = testcapacity\n }\n capacity = ( mincapacity + maxcapacity ) / 2\n capacity = Math.round( capacity )\n testscore = testscore + 1\n if ( testscore >= 10 ) {\n accurate = true\n testscore = 10\n }\n } else {\n testscore = testscore - 1\n if (testscore <= 0 ) {\n accurate = false\n testscore = 0\n mincapacity = null\n maxcapacity = null\n }\n }\n}\n\nif ( msg.topic == \"consumedah\" && capacity !== null ) {\n ahsoc = 100 * ( consumedah / capacity + 1 )\n ahsoc = Math.round( 1000* ahsoc ) / 1000\n}\n\n// mincapacity = ( -1 * minconsumedah / (1 - (minsoc/100) ) ) //Math.round( -1 * minconsumedah / (1 - (minsoc/100) ) )\n// maxcapacity = ( -1 * maxconsumedah / (1 - (maxsoc/100) ) ) //Math.round( -1 * maxconsumedah / (1 - (maxsoc/100) ) )\n\nconst result = {\n ahsoc,\n soc,\n consumedah,\n capacity,\n mincapacity,\n maxcapacity,\n accurate,\n testsoc,\n testcapacity,\n testscore,\n minsoc,\n rangesoc,\n maxsoc,\n minconsumedah,\n rangeconsumedah,\n maxconsumedah,\n rangecapacity,\n rangefraction\n}\n\n// Store in flow context\nflow.set(\"storeahsoc\", result);\n\nlet text = ( (ahsoc !== null) ? ( ahsoc + '% SoC @ '+ capacity + 'Ah' ) : 'wait')\nlet fill = ( (accurate === true) ? 'green' : 'red')\nlet shape = 'dot'\nnode.status({fill, shape, text});\n\nif (accurate){\n msg.topic = 'ahsoc'\n msg.ahsoc = result\n msg.payload = ahsoc\n return msg\n}\n",
"outputs": 1,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 350,
"y": 80,
"wires": [
[
"e7130bbdaed25bdf"
]
]
},
{
"id": "e7130bbdaed25bdf",
"type": "debug",
"z": "63338afbdee3e45c",
"g": "34bdab3b6a0c90b2",
"name": "State of Charge %",
"active": true,
"tosidebar": false,
"console": false,
"tostatus": true,
"complete": "true",
"targetType": "full",
"statusVal": "payload",
"statusType": "msg",
"x": 350,
"y": 140,
"wires": []
},
{
"id": "6814c5674f7b1325",
"type": "global-config",
"env": [],
"modules": {
"@victronenergy/node-red-contrib-victron": "1.6.52"
}
}
]
(I have âunclosedâ the topic)
Thanks, will test. I have close to a 1000Ah, so should be ok.
The capacity is reset upon node-red restart / flow (re-) deploy to NaN (infinity). Anything you can do about that?
No, debug yourself and publish your solution. You could either store a separate permanent capacity value or accept the small wait to recalculate. Which turned out quite quick with the newest version. Try to delete the structure stored in the flow context while charging / decharging and whatch what it does. It only needs a handful datapoints to figure out the capacity by filtering out the rounding errors.
ok, didnât want to mess with your code
If itâs published here it isnât mine anymore, just a proof of concept it is possible to quickly sort out what capacity the BMV uses to calculate SoC based on its consumedAh counter. On a 2000Ah battery I only need about 10 datapoints to figure out it can only be 1998, 1999, 2000, 2001 or 2002Ah as these are the only ones that provide the same SoC (rounded to 0.1%) as the BMV itself. Taking the average from the extremes (1998 + 2002) / 2 = 2000 gives the capacity. You can see for yourself, just delete the flow context object and it starts from scratch again. No matter where you are on the charge curve, you only need to charge . discharge 10 x 0.1Ah or 10 x 0.1% SoC to have hit both the lowest and highest plausible capacity values, and hence the midpoint as the set capacity.
Victron could, if they so please, simply build it into the battery node even when they cannot access the capacity value directly on the BMV, same goes for the higher precision SoC% @dfaber
Here the latest (hardcode capacity in kWh for a 12S Li-ion battery). All the range stuff could be deleted, and once an accurate capacity is found, finding plausible capacities outside the know min/max could be used to restart due to a change of amphours setting on the BMV but Iâll leave to that for somebody else to figure out.
let nominalvoltage = 44
// auto capacity detecting precision state of charge flow for bmv smartshunts
let ahsoc = null
let testsoc = null
let mincapacity = null
let capacity = null
let capacitykw = null
let maxcapacity = null
let rangecapacity = null
let testcapacity = null
let accurate = null
let soc = null
let minsoc = null
let maxsoc= null
let rangesoc = null
let consumedah = null
let minconsumedah = null
let maxconsumedah = null
let rangeconsumedah = null
let rangefraction = null
let testscore = 0
let storeahsoc = flow.get("storeahsoc");
if ( storeahsoc ) {
ahsoc = storeahsoc.ahsoc
accurate = storeahsoc.accurate
testscore = storeahsoc.testscore
soc = storeahsoc.soc
consumedah = storeahsoc.consumedah
capacity = storeahsoc.capacity
// nominalvoltage = storeahsoc.nominalvoltage
capacitykw = storeahsoc.capacitykw
minsoc = storeahsoc.minsoc
maxsoc = storeahsoc.maxsoc
rangesoc = storeahsoc.rangesoc
minconsumedah = storeahsoc.minconsumedah
maxconsumedah = storeahsoc.maxconsumedah
rangeconsumedah = storeahsoc.rangeconsumedah
testsoc = storeahsoc.testsoc
testconsumedah = storeahsoc.testconsumedah
testcapacity = storeahsoc.testcapacity
mincapacity = storeahsoc.mincapacity
maxcapacity = storeahsoc.maxcapacity
rangecapacity = storeahsoc.rangecapacity
rangefraction = storeahsoc.rangefraction
}
if ( msg.topic == "soc" ) {
soc = Math.round( 1000 * msg.payload) / 1000
if ( minsoc === null || soc < minsoc ) {
minsoc = soc
}
if ( maxsoc === null || soc > maxsoc ) {
maxsoc = soc
}
rangesoc = maxsoc - minsoc
rangesoc = Math.round( 10 * rangesoc ) / 10
}
if ( msg.topic == "consumedah" ) {
consumedah = Math.round(1000 * msg.payload) / 1000
if ( minconsumedah === null || consumedah < minconsumedah ) {
minconsumedah = consumedah
}
if ( maxconsumedah === null || consumedah > maxconsumedah ) {
maxconsumedah = consumedah
}
rangeconsumedah = maxconsumedah - minconsumedah
rangeconsumedah = Math.round( 1000 * rangeconsumedah ) / 1000
}
if ( rangesoc > 0 && rangeconsumedah > 0) {
rangecapacity = rangeconsumedah / ( rangesoc / 100 )
rangecapacity = Math.round( 1000 * rangecapacity ) / 1000
rangefraction = rangeconsumedah / rangecapacity
rangefraction = Math.round( 1000 * rangefraction ) / 1000
testcapacity = ( (-1 * consumedah) / (1 - (soc / 100) ) )
testsoc = 100 * ( consumedah / ( Math.round( testcapacity) ) + 1 )
testsoc = Math.round( 10 * testsoc) / 10
if ( testsoc != soc ) {
//
testscore = testscore - 1
if (testscore <= 0 ) {
accurate = false
testscore = 0
}
} else {
// update valid capacity values
if ( mincapacity === null || testcapacity < mincapacity ) {
mincapacity = testcapacity
}
if ( maxcapacity === null || testcapacity > maxcapacity ) {
maxcapacity = testcapacity
}
// test old capacity equal new capacity
if (capacity == Math.round( ( mincapacity + maxcapacity ) / 2 ) ) {
testscore = testscore + 1
} else {
// set new capacity
capacity = ( mincapacity + maxcapacity ) / 2
}
// flag capacity accurate
if ( testscore >= 25 ) {
accurate = true
testscore = 25
}
}
// clean up values
mincapacity = Math.round( 1000 * mincapacity ) / 1000
maxcapacity = Math.round( 1000 * maxcapacity ) / 1000
testcapacity = Math.round( 1000 * testcapacity ) / 1000
capacity = Math.round( capacity )
capacitykw = Math.round( 10 * ( nominalvoltage * capacity / 1000 ) ) / 10
}
if ( msg.topic == "consumedah" && capacity !== null ) {
ahsoc = 100 * ( consumedah / capacity + 1 )
ahsoc = Math.round( 1000* ahsoc ) / 1000
}
const result = {
ahsoc,
soc,
consumedah,
capacity,
nominalvoltage,
capacitykw,
mincapacity,
maxcapacity,
accurate,
testsoc,
testcapacity,
testscore,
minsoc,
rangesoc,
maxsoc,
minconsumedah,
rangeconsumedah,
maxconsumedah,
rangecapacity,
rangefraction
}
// Store in flow context
flow.set("storeahsoc", result);
let text = ( (ahsoc !== null) ? ( ahsoc + '% SoC @ '+ capacity + 'Ah' ) : 'wait')
let fill = ( (accurate === true) ? 'green' : 'red')
let shape = 'dot'
node.status({fill, shape, text});
if (accurate){
msg.topic = 'ahsoc'
msg.ahsoc = result
msg.payload = ahsoc
return msg
}
cleaner:
// auto capacity detecting precision state of charge flow for bmv smartshunts
let accurate = null
let ahsoc = null
let capacity = null
let capacitykw = null
let soc = null
let consumedah = null
let mincapacity = null
let maxcapacity = null
let testscore = 0
let storeahsoc = flow.get("storeahsoc");
if ( storeahsoc ) {
accurate = storeahsoc.accurate
ahsoc = storeahsoc.ahsoc
capacity = storeahsoc.capacity
soc = storeahsoc.soc
consumedah = storeahsoc.consumedah
mincapacity = storeahsoc.mincapacity
maxcapacity = storeahsoc.maxcapacity
testscore = storeahsoc.testscore
}
if ( msg.topic == "soc" ) {
soc = Math.round( 1000 * msg.payload) / 1000
}
if ( msg.topic == "consumedah" ) {
consumedah = Math.round(1000 * msg.payload) / 1000
let testcapacity = ( (-1 * consumedah) / (1 - (soc / 100) ) )
testcapacity = Math.round( testcapacity )
let testsoc = 100 * ( 1 + consumedah / testcapacity )
testsoc = Math.round( 10 * testsoc) / 10
if ( testsoc != soc ) {
//
testscore = testscore - 1
if (testscore <= 0 ) {
accurate = false
testscore = 0
}
} else {
// update valid capacity values
if ( mincapacity === null || testcapacity < mincapacity ) {
mincapacity = testcapacity
}
if ( maxcapacity === null || testcapacity > maxcapacity ) {
maxcapacity = testcapacity
}
// test old capacity equal new capacity
if (capacity == Math.round( ( mincapacity + maxcapacity - 0.1) / 2 ) ) {
testscore = testscore + 1
} else {
// set new capacity
capacity = Math.round( ( mincapacity + maxcapacity - 0.1 ) / 2 )
}
// flag capacity accurate
if ( testscore >= 25 ) {
accurate = true
testscore = 25
}
}
if ( capacity !== null ) {
ahsoc = 100 * ( consumedah / capacity + 1 )
ahsoc = Math.round( 1000* ahsoc ) / 1000
}
}
const result = {
accurate,
ahsoc,
capacity,
soc,
consumedah,
mincapacity,
maxcapacity,
testscore
}
// Store in flow context
flow.set("storeahsoc", result);
let text = ( (ahsoc !== null) ? ( ahsoc + '% SoC @ '+ capacity + 'Ah' ) : 'wait')
let fill = ( (accurate === true) ? 'green' : 'red')
let shape = 'dot'
node.status({fill, shape, text});
if (accurate){
msg.topic = 'ahsoc'
msg.capacity = capacity
msg.payload = ahsoc
return msg
}
Full refactor (OCD is a Biatch)
// Battery Capacity Estimator: Async SoC and ConsumedAh to Filtered Mode with Min-Max Fallback
// Receives msg.topic 'soc' (payload float 0.0-100.0 in 0.1 increments) or 'consumedah' (payload float <=0.0 in 0.1 increments)
// Computes testcapacity if both present and SoC 1-99%; outputs average until single unique at maxFreq>=3, then mode
var store = flow.get('storeahsoc') || {
ahsoc: undefined,
capacity: undefined,
latestSoc: undefined,
latestConsumed: undefined,
minCapacity: Infinity,
maxCapacity: -Infinity,
freqMap: {},
capacityStats: {}
};
// Optional reset on topic='reset'
if (msg.topic === 'reset') {
store.freqMap = {};
store.minCapacity = Infinity;
store.maxCapacity = -Infinity;
flow.set('storeahsoc', store);
return null;
}
// Update based on topic
if (msg.topic === 'soc') {
store.latestSoc = Math.round(10 * msg.payload) / 10;
flow.set('storeahsoc', store); // Persist immediately
} else if (msg.topic === 'consumedah') {
store.latestConsumed = Math.round(10 * msg.payload) / 10;
flow.set('storeahsoc', store); // Persist immediately
} else {
node.status({fill: 'red', shape: 'dot', text: 'wait'});
return null; // Ignore invalid topic
}
// Compute only if both present
if (store.latestSoc === undefined || store.latestConsumed === undefined) {
node.status({fill: 'red', shape: 'dot', text: 'wait'});
return null;
}
if (store.latestSoc <= 0 || store.latestSoc >= 100) {
node.status({fill: 'red', shape: 'dot', text: 'wait'});
return null; // Skip div-zero or invalid
}
if (store.latestSoc < 1 || store.latestSoc > 99) {
node.status({fill: 'red', shape: 'dot', text: 'wait'});
return null; // Skip high-variance regions
}
// Compute testcapacity
var denom = 1 - (store.latestSoc / 100);
if (denom <= 0) {
node.status({fill: 'red', shape: 'dot', text: 'wait'});
return null; // Redundant div-zero guard
}
var testCapacity = Math.round((-1 * store.latestConsumed) / denom);
// Validate new testCapacity
var expectedSoc = Math.round(10 * 100 * (1 + store.latestConsumed / testCapacity)) / 10;
if (Math.abs(expectedSoc - store.latestSoc) > 0.0001) {
node.status({fill: 'red', shape: 'dot', text: 'wait'});
return null; // Skip invalid new entry
}
// Update min/max
if (testCapacity < store.minCapacity || store.minCapacity === Infinity) store.minCapacity = testCapacity;
if (testCapacity > store.maxCapacity || store.maxCapacity === -Infinity) store.maxCapacity = testCapacity;
// Increment freqMap
if (!store.freqMap[testCapacity]) store.freqMap[testCapacity] = 0;
store.freqMap[testCapacity]++;
// Prune if uniques >150: remove lowest freq until <=150
var keys = Object.keys(store.freqMap).map(Number);
if (keys.length > 150) {
var freqList = keys.map(k => ({key: k, freq: store.freqMap[k]}));
freqList.sort((a, b) => a.freq - b.freq); // Ascending
while (keys.length > 150) {
var low = freqList.shift();
delete store.freqMap[low.key];
keys = keys.filter(k => k !== low.key);
}
}
// Compute mode(s)
var maxFreq = 0;
var modes = [];
for (var k in store.freqMap) {
var numK = Number(k);
if (store.freqMap[k] > maxFreq) {
maxFreq = store.freqMap[k];
modes = [numK];
} else if (store.freqMap[k] === maxFreq) {
modes.push(numK);
}
}
// Determine output: average if maxFreq <3 or ties, else single mode
if (store.minCapacity === Infinity) {
node.status({fill: 'red', shape: 'dot', text: 'wait'});
return null; // No data yet
}
if (maxFreq < 3 || modes.length > 1) {
msg.payload = Math.round((store.minCapacity + store.maxCapacity) / 2);
} else {
msg.payload = modes[0];
}
// Compute and store stats KPI under store.capacityStats
var totalCount = Object.values(store.freqMap).reduce((a, b) => a + b, 0);
store.capacityStats = {
uniques: keys.length,
maxFreq: maxFreq,
totalCount: totalCount,
usingFallback: (maxFreq < 3 || modes.length > 1) // Updated for tie condition
};
// precision ahsoc and capacity integration with div-zero guard
store.capacity = msg.payload;
if (store.capacity !== 0) {
store.ahsoc = Math.round( 1000 * 100 * ( 1 + ( store.latestConsumed / store.capacity ) ) ) / 1000;
} else {
store.ahsoc = null;
}
// Persist all state including stats KPI
flow.set('storeahsoc', store);
msg.ahsoc = store.ahsoc;
msg.capacity = store.capacity;
msg.accurate = !store.capacityStats.usingFallback;
msg.payload = msg.ahsoc;
// node status
let text = ( (msg.ahsoc !== undefined) ? ( msg.ahsoc + '% SoC @ '+ msg.capacity + 'Ah' ) : 'wait')
let fill = ( ( msg.accurate === true) ? 'green' : 'red')
let shape = 'dot'
node.status({fill, shape, text});
return msg;
What would be the advantages of changing the defined capacity?
I am looking into the cause of the calculated capacity going to infinity (NaN) when Victron reported SOC is 100% and consumedAh is zero. Battery full to the brim. Should be a divideby zero somewhere, but haven.t found the cause, yet.
O, wow. This new version is getting quite extensive ![]()
long term adaptation for decreasing SoH.
PS code still not satisfactory but Iâll get there
Minute unimportant addition to your new reset command:
Add
node.status({fill: 'red', shape: 'dot', text: 'reset'});
just after
if (msg.topic === âresetâ) {
new approach, ultrafast
release 1.0 you can close this topic now
[
{
"id": "aca9a41baba8e498",
"type": "tab",
"label": "Flow 2",
"disabled": false,
"info": "",
"env": []
},
{
"id": "94127cfb4562fe53",
"type": "group",
"z": "aca9a41baba8e498",
"name": "UpCycle Electric : Battery Capacity and Precision SoC%",
"style": {
"label": true
},
"nodes": [
"8c1672a223109fca",
"b3269873eac10d0f",
"e848a541d3e51b71",
"590191036e5d0675",
"d8a091ca57518a5b",
"ed592eeaa570f1e5"
],
"x": 34,
"y": 39,
"w": 462,
"h": 142
},
{
"id": "8c1672a223109fca",
"type": "victron-input-battery",
"z": "aca9a41baba8e498",
"g": "94127cfb4562fe53",
"service": "com.victronenergy.battery/288",
"path": "/ConsumedAmphours",
"serviceObj": {
"service": "com.victronenergy.battery/288",
"name": "BMV Battery Monitor"
},
"pathObj": {
"path": "/ConsumedAmphours",
"type": "float",
"name": "Consumed Amphours (Ah)"
},
"name": "consumedah",
"onlyChanges": false,
"x": 130,
"y": 80,
"wires": [
[
"e848a541d3e51b71"
]
]
},
{
"id": "b3269873eac10d0f",
"type": "victron-input-battery",
"z": "aca9a41baba8e498",
"g": "94127cfb4562fe53",
"service": "com.victronenergy.battery/288",
"path": "/Soc",
"serviceObj": {
"service": "com.victronenergy.battery/288",
"name": "BMV Battery Monitor"
},
"pathObj": {
"path": "/Soc",
"type": "float",
"name": "State of charge (%)"
},
"name": "soc",
"onlyChanges": false,
"x": 110,
"y": 140,
"wires": [
[
"e848a541d3e51b71"
]
]
},
{
"id": "e848a541d3e51b71",
"type": "function",
"z": "aca9a41baba8e498",
"g": "94127cfb4562fe53",
"name": "SoC% @ batteryAh",
"func": "// Battery Capacity Estimator: Async SoC and ConsumedAh to Bound Intersection\n// Receives msg.topic 'soc' (payload float 0.0-100.0 in 0.1 increments) or 'consumedah' (payload float <=0.0 in 0.1 increments)\n// Computes capacity bounds for each pair, intersects to converge on actual integer capacity\n\nvar store = flow.get('storeahsoc') || {\n ahsoc: undefined,\n capacity: undefined,\n latestSoc: undefined,\n latestConsumed: undefined,\n minCapacity: 100, // Initial wide range min\n maxCapacity: 3000, // Initial wide range max\n capacityStats: {}\n};\n\n// Optional reset on topic='reset'\nif (msg.topic === 'reset') {\n store.minCapacity = 100;\n store.maxCapacity = 3000;\n flow.set('storeahsoc', store);\n return null;\n}\n\n// Update based on topic\nif (msg.topic === 'soc') {\n var newSoc = Math.round(10 * msg.payload) / 10;\n if ( newSoc === store.latestSoc) {\n return null; // Ignore duplicate value\n }\n store.latestSoc = newSoc;\n flow.set('storeahsoc', store); // Persist immediately\n} else if (msg.topic === 'consumedah') {\n var newConsumed = Math.round(10 * msg.payload) / 10;\n if ( newConsumed === store.latestConsumed) {\n return null; // Ignore duplicate value\n }\n store.latestConsumed = newConsumed;\n flow.set('storeahsoc', store); // Persist immediately\n} else {\n node.status({fill: 'red', shape: 'dot', text: 'wait'});\n return null; // Ignore invalid topic\n}\n\n// Compute only if both present\nif (store.latestSoc === undefined || store.latestConsumed === undefined) {\n node.status({fill: 'red', shape: 'dot', text: 'wait'});\n return null;\n}\nif (store.latestSoc <= 0 || store.latestSoc >= 100) {\n node.status({fill: 'red', shape: 'dot', text: 'wait'});\n return null; // Skip div-zero or invalid\n}\n\n// Compute bounds for this pair\nvar denom = 1 - (store.latestSoc / 100);\nif (denom <= 0) {\n node.status({fill: 'red', shape: 'dot', text: 'wait'});\n return null; // Redundant div-zero guard\n}\n\n// Linear search for consistent capacities\n//var pairMin = 3000;\n//var pairMax = 100;\n//for (var c = 100; c <= 3000; c++) {\nvar pairMin = store.maxCapacity;\nvar pairMax = store.minCapacity;\nfor (var c = store.minCapacity; c <= store.maxCapacity; c++) {\n var expectedSoC = Math.round(10 * 100 * (1 + store.latestConsumed / c)) / 10;\n if (expectedSoC === store.latestSoc) {\n pairMin = Math.min(pairMin, c);\n pairMax = Math.max(pairMax, c);\n }\n}\n\nif (pairMin > pairMax) {\n node.status({fill: 'red', shape: 'dot', text: 'wait'});\n return null; // Skip invalid pair\n}\n\n// Intersect with global range\nstore.minCapacity = Math.max(store.minCapacity, pairMin);\nstore.maxCapacity = Math.min(store.maxCapacity, pairMax);\n\n// Reset on inversion\nif (store.minCapacity > store.capacity) {\n store.minCapacity = store.minCapacity - 1\n node.warn('Inversion detected, resetting minCapacity');\n}\nif (store.maxCapacity < store.minCapacity) {\n store.maxCapacity = store.maxCapacity + 1\n node.warn('Inversion detected, resetting maxCapacity');\n}\n\n\n// Determine output: capacity if converged, else midpoint\nif (store.minCapacity === store.maxCapacity) {\n msg.payload = store.minCapacity;\n} else {\n msg.payload = Math.round((store.minCapacity + store.maxCapacity) / 2);\n}\n\n// High precision ahsoc integration with div-zero guard\nstore.capacity = msg.payload;\nif (store.capacity !== 0) {\n store.ahsoc = Math.round( 1000 * 100 * ( 1 + ( store.latestConsumed / store.capacity ) ) ) / 1000;\n} else {\n store.ahsoc = null;\n}\n\n// Compute and store stats KPI under store.capacityStats\nstore.capacityStats = {\n uniques: store.minCapacity <= store.maxCapacity ? store.maxCapacity - store.minCapacity + 1 : 0,\n usingFallback: store.minCapacity !== store.maxCapacity\n};\n\n// Persist all state including stats KPI\nflow.set('storeahsoc', store);\nmsg.ahsoc = store.ahsoc;\nmsg.capacity = store.capacity;\nmsg.accurate = !store.capacityStats.usingFallback;\nmsg.payload = msg.ahsoc;\n\n// node status\nlet text = ( (msg.ahsoc !== undefined) ? ( msg.ahsoc + '% SoC @ '+ msg.capacity + 'Ah' ) : 'wait')\nlet fill = ( ( msg.accurate === true) ? 'green' : 'red')\nlet shape = 'dot'\nnode.status({fill, shape, text});\nreturn msg;",
"outputs": 1,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 330,
"y": 80,
"wires": [
[
"590191036e5d0675"
]
]
},
{
"id": "590191036e5d0675",
"type": "link out",
"z": "aca9a41baba8e498",
"g": "94127cfb4562fe53",
"name": "link out 86",
"mode": "link",
"links": [
"d8a091ca57518a5b",
"c1b98e927af57511"
],
"x": 455,
"y": 80,
"wires": []
},
{
"id": "d8a091ca57518a5b",
"type": "link in",
"z": "aca9a41baba8e498",
"g": "94127cfb4562fe53",
"name": "link in 102",
"links": [
"590191036e5d0675"
],
"x": 255,
"y": 140,
"wires": [
[
"ed592eeaa570f1e5"
]
]
},
{
"id": "ed592eeaa570f1e5",
"type": "debug",
"z": "aca9a41baba8e498",
"g": "94127cfb4562fe53",
"name": "AhSoC%",
"active": true,
"tosidebar": false,
"console": false,
"tostatus": true,
"complete": "true",
"targetType": "full",
"statusVal": "payload",
"statusType": "msg",
"x": 360,
"y": 140,
"wires": []
},
{
"id": "d8bfb64a07523f9a",
"type": "global-config",
"env": [],
"modules": {
"@victronenergy/node-red-contrib-victron": "1.6.52"
}
}
]
// Battery Capacity Estimator: Async SoC and ConsumedAh to Bound Intersection
// Receives msg.topic 'soc' (payload float 0.0-100.0 in 0.1 increments) or 'consumedah' (payload float <=0.0 in 0.1 increments)
// Computes capacity bounds for each pair, intersects to converge on actual integer capacity
var store = flow.get('storeahsoc') || {
ahsoc: undefined,
capacity: undefined,
latestSoc: undefined,
latestConsumed: undefined,
minCapacity: 100, // Initial wide range min
maxCapacity: 3000, // Initial wide range max
capacityStats: {}
};
// Optional reset on topic='reset'
if (msg.topic === 'reset') {
store.minCapacity = 100;
store.maxCapacity = 3000;
flow.set('storeahsoc', store);
return null;
}
// Update based on topic
if (msg.topic === 'soc') {
var newSoc = Math.round(10 * msg.payload) / 10;
if ( newSoc === store.latestSoc) {
return null; // Ignore duplicate value
}
store.latestSoc = newSoc;
flow.set('storeahsoc', store); // Persist immediately
} else if (msg.topic === 'consumedah') {
var newConsumed = Math.round(10 * msg.payload) / 10;
if ( newConsumed === store.latestConsumed) {
return null; // Ignore duplicate value
}
store.latestConsumed = newConsumed;
flow.set('storeahsoc', store); // Persist immediately
} else {
node.status({fill: 'red', shape: 'dot', text: 'wait'});
return null; // Ignore invalid topic
}
// Compute only if both present
if (store.latestSoc === undefined || store.latestConsumed === undefined) {
node.status({fill: 'red', shape: 'dot', text: 'wait'});
return null;
}
if (store.latestSoc <= 0 || store.latestSoc >= 100) {
node.status({fill: 'red', shape: 'dot', text: 'wait'});
return null; // Skip div-zero or invalid
}
// Compute bounds for this pair
var denom = 1 - (store.latestSoc / 100);
if (denom <= 0) {
node.status({fill: 'red', shape: 'dot', text: 'wait'});
return null; // Redundant div-zero guard
}
// Linear search for consistent capacities
//var pairMin = 3000;
//var pairMax = 100;
//for (var c = 100; c <= 3000; c++) {
var pairMin = store.maxCapacity;
var pairMax = store.minCapacity;
for (var c = store.minCapacity; c <= store.maxCapacity; c++) {
var expectedSoC = Math.round(10 * 100 * (1 + store.latestConsumed / c)) / 10;
if (expectedSoC === store.latestSoc) {
pairMin = Math.min(pairMin, c);
pairMax = Math.max(pairMax, c);
}
}
if (pairMin > pairMax) {
node.status({fill: 'red', shape: 'dot', text: 'wait'});
return null; // Skip invalid pair
}
// Intersect with global range
store.minCapacity = Math.max(store.minCapacity, pairMin);
store.maxCapacity = Math.min(store.maxCapacity, pairMax);
// Reset on inversion
if (store.minCapacity > store.capacity) {
store.minCapacity = store.minCapacity - 1
node.warn('Inversion detected, resetting minCapacity');
}
if (store.maxCapacity < store.minCapacity) {
store.maxCapacity = store.maxCapacity + 1
node.warn('Inversion detected, resetting maxCapacity');
}
// Determine output: capacity if converged, else midpoint
if (store.minCapacity === store.maxCapacity) {
msg.payload = store.minCapacity;
} else {
msg.payload = Math.round((store.minCapacity + store.maxCapacity) / 2);
}
// High precision ahsoc integration with div-zero guard
store.capacity = msg.payload;
if (store.capacity !== 0) {
store.ahsoc = Math.round( 1000 * 100 * ( 1 + ( store.latestConsumed / store.capacity ) ) ) / 1000;
} else {
store.ahsoc = null;
}
// Compute and store stats KPI under store.capacityStats
store.capacityStats = {
uniques: store.minCapacity <= store.maxCapacity ? store.maxCapacity - store.minCapacity + 1 : 0,
usingFallback: store.minCapacity !== store.maxCapacity
};
// Persist all state including stats KPI
flow.set('storeahsoc', store);
msg.ahsoc = store.ahsoc;
msg.capacity = store.capacity;
msg.accurate = !store.capacityStats.usingFallback;
msg.payload = msg.ahsoc;
// node status
let text = ( (msg.ahsoc !== undefined) ? ( msg.ahsoc + '% SoC @ '+ msg.capacity + 'Ah' ) : 'wait')
let fill = ( ( msg.accurate === true) ? 'green' : 'red')
let shape = 'dot'
node.status({fill, shape, text});
return msg;
Thanks for all your hard work
![]()