This is very interesting. Can you modify that to allow an additional SoC setpoint in the (near, with known price schedule timeframe) future? I have been begging for a port of that function from Node-RED DESS to VRM DESS for ages. Where do start if I’d want to port your code to Node-RED?
PS: weekend bonus flow (Automatic Battery Capacity detecting High Precision SoC% flow for BMV Smartshunts)
[
{
"id": "c164cb7636efd2df",
"type": "group",
"z": "aca9a41baba8e498",
"name": "UpCycle Electric : Battery Capacity and Precision SoC%",
"style": {
"label": true
},
"nodes": [
"082878b0754a90a8",
"f81e692fc62703d7",
"2866f23c9718c311",
"80a55421dc061bb4",
"6d2f544603bebb6e",
"064a69f1ecebd72d"
],
"x": 34,
"y": 39,
"w": 462,
"h": 142
},
{
"id": "082878b0754a90a8",
"type": "victron-input-battery",
"z": "aca9a41baba8e498",
"g": "c164cb7636efd2df",
"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": [
[
"2866f23c9718c311"
]
]
},
{
"id": "f81e692fc62703d7",
"type": "victron-input-battery",
"z": "aca9a41baba8e498",
"g": "c164cb7636efd2df",
"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": [
[
"2866f23c9718c311"
]
]
},
{
"id": "2866f23c9718c311",
"type": "function",
"z": "aca9a41baba8e498",
"g": "c164cb7636efd2df",
"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 node.status({fill: 'red', shape: 'dot', text: 'wait'});\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\nvar Match = false;\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 Match = true;\n pairMin = Math.min(pairMin, c);\n pairMax = Math.max(pairMax, c);\n }\n}\n\n// No match found on a single pair\nif ( !Match && (pairMin == pairMax)) {\n// node.warn('No Match single pair, widen range');\n node.status({fill: 'red', shape: 'dot', text: 'wait'});\n pairMin = pairMin - 1;\n pairMax = pairMax + 1;\n}\n\n// Reset on inversion\nif (pairMin > pairMax) {\n// node.warn('pairMin > pairMax, Skip invalid pair');\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// Handle inversion as inconsistency (reset)\nif (store.minCapacity > store.maxCapacity) {\n store.minCapacity = 100;\n store.maxCapacity = 3000;\n node.warn('Inversion detected, resetting bounds');\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": [
[
"80a55421dc061bb4"
]
]
},
{
"id": "80a55421dc061bb4",
"type": "link out",
"z": "aca9a41baba8e498",
"g": "c164cb7636efd2df",
"name": "link out 86",
"mode": "link",
"links": [
"6d2f544603bebb6e",
"c1b98e927af57511"
],
"x": 455,
"y": 80,
"wires": []
},
{
"id": "6d2f544603bebb6e",
"type": "link in",
"z": "aca9a41baba8e498",
"g": "c164cb7636efd2df",
"name": "link in 102",
"links": [
"80a55421dc061bb4"
],
"x": 255,
"y": 140,
"wires": [
[
"064a69f1ecebd72d"
]
]
},
{
"id": "064a69f1ecebd72d",
"type": "debug",
"z": "aca9a41baba8e498",
"g": "c164cb7636efd2df",
"name": "AhSoC%",
"active": true,
"tosidebar": false,
"console": false,
"tostatus": true,
"complete": "true",
"targetType": "full",
"statusVal": "payload",
"statusType": "msg",
"x": 360,
"y": 140,
"wires": []
},
{
"id": "580227e4bbf43e36",
"type": "global-config",
"env": [],
"modules": {
"@victronenergy/node-red-contrib-victron": "1.6.52"
}
}
]
version 1.0, for code see also: How to fetch the battery capacity as defined in smartshunt-settings? - #19 by UpCycleElectric