How to fetch the battery capacity as defined in smartshunt-settings?

I had it in mind for a long time already, part of a development project of a virtual battery with State of Energy (SoE%) and State of Health (SoH%) parameters. The detection scheme got to be quite effective in the end.

Anyway, the code could use a little more work to extend the ‘Reset’ part to include an automatic reset when the BMV capacity gets changed. If you want to contribute, that’s where it’s at. :wink:

// Linear search for consistent capacities
var Match = false;
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) {
        Match = true;
        pairMin = Math.min(pairMin, c);
        pairMax = Math.max(pairMax, c);
    }
}

// No match found on a single pair
if ( !Match && (pairMin == pairMax)) {
//    node.warn('No Match single pair, widen range');
    node.status({fill: 'red', shape: 'dot', text: 'wait'});
    pairMin = pairMin - 1;
    pairMax = pairMax + 1;
}

// Reset on inversion
if (pairMin > pairMax) {
//    node.warn('pairMin > pairMax, Skip invalid pair');
    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);

// Handle inversion as inconsistency (reset)
if (store.minCapacity > store.maxCapacity) {
    store.minCapacity = 100;
    store.maxCapacity = 3000;
    node.warn('Inversion detected, resetting bounds');
}

To be honest, I don’t understand most of the logic yet. Might take a while to let it all soak in…

Look at the storeahsoc object in the flow context, that should clear things up

version 1.1 debug + automatic recalibration

// 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
    misMatches: 0,
    capacityStats: {},
    debugStats: {}
};

// debug values
var rawSoc = null;
var rawConsumed = null;
var rawminCapacity = null;
var rawmaxCapacity = null;
var rawexpectedSoc = null;
var rawMatch = null;
var rawDelta = null;
var rawpairMin = null;
var rawpairMax = null;
var rawahSoc = null;

// Optional reset on topic='reset'
if (msg.topic === 'reset') {
    store.minCapacity = 100;
    store.maxCapacity = 3000;
    store.misMatches = 0;
    flow.set('storeahsoc', store);
    node.status({fill: 'red', shape: 'dot', text: 'topic reset'});
    return null;
}

rawSoc = store.latestSoc;
rawConsumed = store.latestConsumed;
// Update based on topic
if (msg.topic === 'soc') {
    rawSoc = msg.payload;
    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') {
    rawConsumed = msg.payload;
    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: 'wait1'});
    return null; // Ignore invalid topic
}

// Compute only if both present
if (store.latestSoc === undefined || store.latestConsumed === undefined) {
    node.status({fill: 'red', shape: 'dot', text: 'wait2'});
    return null;
}
if (store.latestSoc <= 0 || store.latestSoc >= 100) {
    node.status({fill: 'red', shape: 'dot', text: 'wait3'});
    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: 'wait4'});
    return null; // Redundant div-zero guard
}

// Linear search for consistent capacities
rawminCapacity = store.minCapacity;
rawmaxCapacity = store.maxCapacity;
var Match = false;
var pairMin = store.maxCapacity;
var pairMax = store.minCapacity;
for ( var c = store.minCapacity; c <= store.maxCapacity; c++ ) {
    var expectedSoC = 100 * ( 1 + store.latestConsumed / c );
    rawexpectedSoc = expectedSoC;
    expectedSoC = Math.round( 100 * expectedSoC ) / 100;
    rawDelta = expectedSoC - store.latestSoc;
    Match = Math.round( 100 * Math.abs( rawDelta ) ) / 100 <= 0.06;//0.05;
    if ( Match ) {
        pairMin = Math.min(pairMin, c);
        pairMax = Math.max(pairMax, c);
    }
}
rawMatch = Match;
rawpairMin = pairMin;
rawpairMax = pairMax;

// No match found on a single pair
if ( !Match && ( pairMin == pairMax ) ) {
    node.warn('No Match single pair, widen range');
    node.status({fill: 'red', shape: 'dot', text: 'nomatch single'});
    pairMin = pairMin - 1;
//    pairMax = pairMax + 1;
}

// Reset on inversion
if ( pairMin > pairMax ) {
    node.warn('pairMin > pairMax, Skip invalid pair');
    node.status({fill: 'red', shape: 'dot', text: 'skip invalid'});
    return null; // Skip invalid pair
}

// Intersect with global range
store.minCapacity = Math.max( store.minCapacity, pairMin );
store.maxCapacity = Math.min( store.maxCapacity, pairMax );

// Handle inversion as inconsistency (reset)
if ( store.minCapacity > store.maxCapacity ) {
    store.minCapacity = 100;
    store.maxCapacity = 3000;
    store.misMatches = 0;
    node.warn('Inversion detected, resetting bounds');
    node.status({fill: 'red', shape: 'dot', text: 'reset bounds'});
}

// Determine output: capacity if converged, else midpoint
if ( store.minCapacity === store.maxCapacity ) {
    msg.payload = store.minCapacity;
} else if ( store.uniques > 2 ) {
    msg.payload = Math.round( ( store.minCapacity + store.maxCapacity ) / 2 );
} else if ( store.maxCapacity - store.minCapacity > 1 ) {
    msg.payload = Math.round( ( store.minCapacity + store.maxCapacity ) / 2 );
} else {
    msg.payload = store.minCapacity;
}

// High precision ahsoc integration with div-zero guard
store.capacity = msg.payload;
if ( store.capacity !== 0 ) {
    rawahSoc = 100 * ( 1 + ( store.latestConsumed / store.capacity ) );
    store.ahsoc = Math.round(1000 * rawahSoc ) / 1000;
} else {
    store.ahsoc = null;
}

// Compute and store stats KPI under store.capacityStats
var statsuniques = store.minCapacity <= store.maxCapacity ? store.maxCapacity - store.minCapacity + 1 : 0
var statsusingFallback = ( statsuniques > 3 );//store.minCapacity !== store.maxCapacity
store.capacityStats = {
    uniques: statsuniques,
    usingFallback: statsusingFallback 
};

// count mismatches
//var calculatedSoC = Math.round( 10 * store.ahsoc ) / 10;
//var misMatch = calculatedSoC !== store.latestSoc
//var misMatch = !Match;
//if ( misMatch && ( statsuniques <= 2 ) ) {
if ( !Match && statsuniques <= 2 ) {
    store.misMatches = store.misMatches + 1;
    if (store.misMatches >= 5) {
        store.minCapacity = Math.round( 0.95 * store.minCapacity );
        store.maxCapacity = Math.round( 1.05 * store.maxCapacity );
        store.misMatches = 0;
        node.warn('5 mismatches, reset');
        node.status({fill: 'red', shape: 'dot', text: 'mismatch reset'});
    }
}
if ( !Match ) {
    store.debugStats = {
        rawSoc: rawSoc,
        rawConsumed: rawConsumed,
        rawminCapacity: rawminCapacity,
        rawmaxCapacity: rawmaxCapacity,
        rawexpectedSoc: rawexpectedSoc,
        rawMatch: rawMatch,
        rawDelta: rawDelta,
        rawpairMin: rawpairMin,
        rawpairMax: rawpairMax,
        rawahSoc: rawahSoc
    }
}

// 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' ) : 'wait5')
let fill = ( ( (msg.accurate === true) && Match ) ? 'green' : 'red')
let shape = 'dot'
node.status({fill, shape, text});

return msg;

I have installed this Nodes on my NodeRed

for the 230Ah Battery it works, but for the 20Ah Battery it only shows „skip invalid“ and sends no capactiy out.

[I changed min: 100 and max: 3000 to min: 1 and max: 300 for the 20Ah Battery, and waiting bow for a result. I think this was the Problem why it dont get something]

For a battery under 200 Ah there is little use for this code because the real value of knowing your capacity is the ability to calculate a higher precision SoC% when 0.1Ah accounts for less energy than 0.1% SoC. And the algorithm finding the exact value could use some more work but I doubt I’ll get to that soon. If did add some useful debugging values to the object stored in the flow context, feel free to experiment with code that finds the capacity by eliminating all ‘candidates’ below and above the actual capacity as fast as possible. Just getting the final 1.0Ah right turned out to be a bit tricky as it seems the BMV does not always exactly round up and down on the xx.x5% SoC yy.y5Ah boundary. In the end it would be a much better solution if Victron, with exact knowledge of the BMV firmware, could take a look and suggest a more robust solution to determine that least significant capacity bit (in 1.0Ah) or if they really want to be nice, put the capacity calculation into the battery node itself where it belongs IMHO. I see this code only as proof of concept it can be extracted reasonably easily even if the BMV firmware can’t provide it directly.

I am running a few iterations of your code in parallel, just to see the differences.
(To do this, I changed the context storage from flow to node)
The (insignificant) differences indicate that the algorithm is changing with each iteration.
Thought you might like to know this.

You really should try observe the data object in the flow context to figure out what prevents the code to stabilize on a single value. The code zooms in fast enough to the last 2 or 3 ‘candidates’ but if it keeps rejecting the final candidate, you need to set the criteria a little bit ‘wider’ in the code.

The way the code zooms in is simple enough: the first round it simply tests all capacities starting at 100Ah, calculates a test SoC, then rounds that test SoC to 0.1% and compares it with the SoC% received. Then it limits the testrange based on the Ah values that fail the test. The assumption here is that there must be a single capacity value that always passes the test. The issue is that for this to work, the code should use the exact same rounding (of the calculated test SoC% ) as the BMV itself but without access to the BMV firmware that takes a bit of guesswork.

Not sure whether it matters but I always set the BMV Smartshunt to:
Peukert exponent 1.00
Charge efficiency factor 100%
Minor update, see if it helps, version 1.2:

[
    {
        "id": "aca9a41baba8e498",
        "type": "tab",
        "label": "Flow 2",
        "disabled": false,
        "info": "",
        "env": []
    },
    {
        "id": "68b65dc97ef1cc5a",
        "type": "group",
        "z": "aca9a41baba8e498",
        "name": "UpCycle Electric :  Battery Capacity and Precision SoC%",
        "style": {
            "label": true
        },
        "nodes": [
            "cbaa78a710257e7e",
            "115d9f615c2bad76",
            "6384ab7f49dddff4",
            "3e20f1d85f0988bb",
            "0be46f7b216b5462",
            "edb2f2d5990f334b",
            "600d744385e5ff14",
            "7758b585988dfc10",
            "d5afd63c79002b7e"
        ],
        "x": 34,
        "y": 19,
        "w": 442,
        "h": 262
    },
    {
        "id": "cbaa78a710257e7e",
        "type": "victron-input-battery",
        "z": "aca9a41baba8e498",
        "g": "68b65dc97ef1cc5a",
        "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": 120,
        "wires": [
            [
                "0be46f7b216b5462"
            ]
        ]
    },
    {
        "id": "115d9f615c2bad76",
        "type": "victron-input-battery",
        "z": "aca9a41baba8e498",
        "g": "68b65dc97ef1cc5a",
        "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": 180,
        "wires": [
            [
                "6384ab7f49dddff4"
            ]
        ]
    },
    {
        "id": "6384ab7f49dddff4",
        "type": "function",
        "z": "aca9a41baba8e498",
        "g": "68b65dc97ef1cc5a",
        "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    misMatches: 0,\n    capacityStats: {},\n    debugStats: {}\n};\n\n// debug values\nvar rawSoc = null;\nvar rawConsumed = null;\nvar rawminCapacity = null;\nvar rawmaxCapacity = null;\nvar rawexpectedSoc = null;\nvar rawMatch = null;\nvar rawDelta = null;\nvar rawpairMin = null;\nvar rawpairMax = null;\nvar rawahSoc = null;\n\n// Optional reset on topic='reset'\nif (msg.topic === 'reset') {\n    store.minCapacity = 100;\n    store.maxCapacity = 3000;\n    store.misMatches = 0;\n    flow.set('storeahsoc', store);\n    node.status({fill: 'red', shape: 'dot', text: 'topic reset'});\n    return null;\n}\n\nrawSoc = store.latestSoc;\nrawConsumed = store.latestConsumed;\n// Update based on topic\nif (msg.topic === 'soc') {\n    var newSoc = Math.round(1000 * msg.payload) / 1000;\n    rawSoc = newSoc;\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(1000 * msg.payload) / 1000;\n    rawConsumed = newConsumed;\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: 'wait1'});\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: 'wait2'});\n    return null;\n}\nif (store.latestSoc <= 0 || store.latestSoc >= 100) {\n    node.status({fill: 'red', shape: 'dot', text: 'wait3'});\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: 'wait4'});\n    return null; // Redundant div-zero guard\n}\n\n// Linear search for consistent capacities\nrawminCapacity = store.minCapacity;\nrawmaxCapacity = store.maxCapacity;\nvar Match = false;\nvar pairMin = store.maxCapacity;\nvar pairMax = store.minCapacity;\nfor ( var c = store.minCapacity; c <= store.maxCapacity; c++ ) {\n    rawexpectedSoc = 100 * ( 1 + store.latestConsumed / c );\n    rawexpectedSoc = Math.round(1000 * rawexpectedSoc ) / 1000;\n    rawDelta = rawexpectedSoc - store.latestSoc;\n    rawDelta =  Math.round(1000 * rawDelta) / 1000;\n    var expectedSoC = Math.round(10 * rawexpectedSoc ) / 10;\n    var absDelta = Math.abs( rawDelta );\n    Match = absDelta < 0.0505;\n    if ( Match ) {\n        pairMin = Math.min(pairMin, c);\n        pairMax = Math.max(pairMax, c);\n    }\n}\nrawMatch = Match;\nrawpairMin = pairMin;\nrawpairMax = pairMax;\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: 'nomatch single'});\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: 'skip invalid'});\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    store.misMatches = 0;\n    node.warn('Inversion detected, resetting bounds');\n    node.status({fill: 'red', shape: 'dot', text: 'reset bounds'});\n}\n\n// Determine output: capacity if converged, else midpoint\nif ( store.minCapacity === store.maxCapacity ) {\n    msg.payload = store.minCapacity;\n} else if ( store.uniques > 2 ) {\n    msg.payload = Math.round( ( store.minCapacity + store.maxCapacity ) / 2 );\n} else if ( store.maxCapacity - store.minCapacity > 1 ) {\n    msg.payload = Math.round( ( store.minCapacity + store.maxCapacity ) / 2 );\n} else {\n    msg.payload = store.minCapacity;\n}\n\n// High precision ahsoc integration with div-zero guard\nstore.capacity = msg.payload;\nif ( store.capacity !== 0 ) {\n    rawahSoc = 100 * (1 + (store.latestConsumed / store.capacity) );\n    rawahSoc = Math.round(1000 * rawahSoc) / 1000;\n    store.ahsoc = rawahSoc;\n} else {\n    store.ahsoc = null;\n}\n\n// Compute and store stats KPI under store.capacityStats\nvar statsuniques = store.minCapacity <= store.maxCapacity ? store.maxCapacity - store.minCapacity + 1 : 0\nvar statsusingFallback = ( statsuniques > 1 );//store.minCapacity !== store.maxCapacity\nstore.capacityStats = {\n    uniques: statsuniques,\n    usingFallback: statsusingFallback \n};\n\n// count mismatches\n//var calculatedSoC = Math.round( 10 * store.ahsoc ) / 10;\n//var misMatch = calculatedSoC !== store.latestSoc\n//var misMatch = !Match;\n//if ( misMatch && ( statsuniques <= 2 ) ) {\nif ( !Match && statsuniques <= 2 ) {\n    store.misMatches = store.misMatches + 1;\n    if (store.misMatches >= 2) {\n        store.minCapacity = Math.round( 0.9 * store.minCapacity );\n        store.maxCapacity = Math.round( 1.1 * store.maxCapacity );\n        store.misMatches = 0;\n        node.warn('2 mismatches, reset');\n        node.status({fill: 'red', shape: 'dot', text: 'mismatch reset'});\n    }\n}\nif ( !Match ) {\n//if ( true ) {\n    store.debugStats = {\n        rawSoc: rawSoc,\n        rawConsumed: rawConsumed,\n        rawminCapacity: rawminCapacity,\n        rawmaxCapacity: rawmaxCapacity,\n        rawexpectedSoc: rawexpectedSoc,\n        rawMatch: rawMatch,\n        rawDelta: rawDelta,\n        rawpairMin: rawpairMin,\n        rawpairMax: rawpairMax,\n        rawahSoc: rawahSoc\n    }\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 + '% ' + (-rawDelta) +' SoC '+ statsuniques + ' @ '+ msg.capacity + 'Ah'  ) : 'wait')\nlet fill = ( ( (msg.accurate === true) && Match ) ? 'green' : 'red')\nlet shape = 'dot'\nnode.status({fill, shape, text});\n\nreturn msg;",
        "outputs": 1,
        "timeout": "",
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 310,
        "y": 180,
        "wires": [
            [
                "3e20f1d85f0988bb"
            ]
        ]
    },
    {
        "id": "3e20f1d85f0988bb",
        "type": "link out",
        "z": "aca9a41baba8e498",
        "g": "68b65dc97ef1cc5a",
        "name": "link out 86",
        "mode": "link",
        "links": [
            "d5afd63c79002b7e",
            "c1b98e927af57511"
        ],
        "x": 435,
        "y": 180,
        "wires": []
    },
    {
        "id": "0be46f7b216b5462",
        "type": "delay",
        "z": "aca9a41baba8e498",
        "g": "68b65dc97ef1cc5a",
        "name": "",
        "pauseType": "delay",
        "timeout": "10",
        "timeoutUnits": "milliseconds",
        "rate": "1",
        "nbRateUnits": "15",
        "rateUnits": "second",
        "randomFirst": "1",
        "randomLast": "5",
        "randomUnits": "seconds",
        "drop": true,
        "allowrate": false,
        "outputs": 1,
        "x": 290,
        "y": 120,
        "wires": [
            [
                "6384ab7f49dddff4"
            ]
        ]
    },
    {
        "id": "edb2f2d5990f334b",
        "type": "change",
        "z": "aca9a41baba8e498",
        "g": "68b65dc97ef1cc5a",
        "name": "",
        "rules": [
            {
                "t": "delete",
                "p": "storeahsoc",
                "pt": "flow"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 320,
        "y": 60,
        "wires": [
            []
        ]
    },
    {
        "id": "600d744385e5ff14",
        "type": "inject",
        "z": "aca9a41baba8e498",
        "g": "68b65dc97ef1cc5a",
        "name": "",
        "props": [
            {
                "p": "payload"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "x": 140,
        "y": 60,
        "wires": [
            [
                "edb2f2d5990f334b"
            ]
        ]
    },
    {
        "id": "7758b585988dfc10",
        "type": "debug",
        "z": "aca9a41baba8e498",
        "g": "68b65dc97ef1cc5a",
        "name": "AhSoC%",
        "active": true,
        "tosidebar": false,
        "console": false,
        "tostatus": true,
        "complete": "true",
        "targetType": "full",
        "statusVal": "payload",
        "statusType": "msg",
        "x": 340,
        "y": 240,
        "wires": []
    },
    {
        "id": "d5afd63c79002b7e",
        "type": "link in",
        "z": "aca9a41baba8e498",
        "g": "68b65dc97ef1cc5a",
        "name": "link in 102",
        "links": [
            "3e20f1d85f0988bb"
        ],
        "x": 235,
        "y": 240,
        "wires": [
            [
                "7758b585988dfc10"
            ]
        ]
    },
    {
        "id": "ceec3db10f622a01",
        "type": "global-config",
        "env": [],
        "modules": {
            "@victronenergy/node-red-contrib-victron": "1.6.55"
        }
    }
]

Version 1.3, very small change on line 93, increased testwindow: “Match = absDelta < 0.055555;”
Surely there are opportunities to improve on the rounding and selection mechanism (that discards ‘unfitting’ capacity candidates) but this works perfectly for us. The correct capacity is found within a handful of value updates and when I change the capacity in the BMV manually, it automatically re-aligns with the new values as well.

// 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
    misMatches: 0,
    capacityStats: {},
    debugStats: {}
};

// debug values
var rawSoc = null;
var rawConsumed = null;
var rawminCapacity = null;
var rawmaxCapacity = null;
var rawexpectedSoc = null;
var rawMatch = null;
var rawDelta = null;
var rawpairMin = null;
var rawpairMax = null;
var rawahSoc = null;

// Optional reset on topic='reset'
if (msg.topic === 'reset') {
    store.minCapacity = 100;
    store.maxCapacity = 3000;
    store.misMatches = 0;
    flow.set('storeahsoc', store);
    node.status({fill: 'red', shape: 'dot', text: 'topic reset'});
    return null;
}

rawSoc = store.latestSoc;
rawConsumed = store.latestConsumed;
// Update based on topic
if (msg.topic === 'soc') {
    var newSoc = Math.round(1000 * msg.payload) / 1000;
    rawSoc = newSoc;
    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(1000 * msg.payload) / 1000;
    rawConsumed = newConsumed;
    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: 'wait1'});
    return null; // Ignore invalid topic
}

// Compute only if both present
if (store.latestSoc === undefined || store.latestConsumed === undefined) {
    node.status({fill: 'red', shape: 'dot', text: 'wait2'});
    return null;
}
if (store.latestSoc <= 0 || store.latestSoc >= 100) {
    node.status({fill: 'red', shape: 'dot', text: 'wait3'});
    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: 'wait4'});
    return null; // Redundant div-zero guard
}

// Linear search for consistent capacities
rawminCapacity = store.minCapacity;
rawmaxCapacity = store.maxCapacity;
var Match = false;
var pairMin = store.maxCapacity;
var pairMax = store.minCapacity;
for ( var c = store.minCapacity; c <= store.maxCapacity; c++ ) {
    rawexpectedSoc = 100 * ( 1 + store.latestConsumed / c );
    rawexpectedSoc = Math.round(1000 * rawexpectedSoc ) / 1000;
    rawDelta = rawexpectedSoc - store.latestSoc;
    rawDelta =  Math.round(1000 * rawDelta) / 1000;
    var expectedSoC = Math.round(10 * rawexpectedSoc ) / 10;
    var absDelta = Math.abs( rawDelta );
    Match = absDelta < 0.055555;
    if ( Match ) {
        pairMin = Math.min(pairMin, c);
        pairMax = Math.max(pairMax, c);
    }
}
rawMatch = Match;
rawpairMin = pairMin;
rawpairMax = pairMax;

// No match found on a single pair
if ( !Match && ( pairMin == pairMax ) ) {
    node.warn('No Match single pair, widen range');
    node.status({fill: 'red', shape: 'dot', text: 'nomatch single'});
    pairMin = pairMin - 1;
//    pairMax = pairMax + 1;
}

// Reset on inversion
if ( pairMin > pairMax ) {
    node.warn('pairMin > pairMax, Skip invalid pair');
    node.status({fill: 'red', shape: 'dot', text: 'skip invalid'});
    return null; // Skip invalid pair
}

// Intersect with global range
store.minCapacity = Math.max( store.minCapacity, pairMin );
store.maxCapacity = Math.min( store.maxCapacity, pairMax );

// Handle inversion as inconsistency (reset)
if ( store.minCapacity > store.maxCapacity ) {
    store.minCapacity = 100;
    store.maxCapacity = 3000;
    store.misMatches = 0;
    node.warn('Inversion detected, resetting bounds');
    node.status({fill: 'red', shape: 'dot', text: 'reset bounds'});
}

// Determine output: capacity if converged, else midpoint
if ( store.minCapacity === store.maxCapacity ) {
    msg.payload = store.minCapacity;
} else if ( store.uniques > 2 ) {
    msg.payload = Math.round( ( store.minCapacity + store.maxCapacity ) / 2 );
} else if ( store.maxCapacity - store.minCapacity > 1 ) {
    msg.payload = Math.round( ( store.minCapacity + store.maxCapacity ) / 2 );
} else {
    msg.payload = store.minCapacity;
}

// High precision ahsoc integration with div-zero guard
store.capacity = msg.payload;
if ( store.capacity !== 0 ) {
    rawahSoc = 100 * (1 + (store.latestConsumed / store.capacity) );
    rawahSoc = Math.round(1000 * rawahSoc) / 1000;
    store.ahsoc = rawahSoc;
} else {
    store.ahsoc = null;
}

// Compute and store stats KPI under store.capacityStats
var statsuniques = store.minCapacity <= store.maxCapacity ? store.maxCapacity - store.minCapacity + 1 : 0
var statsusingFallback = ( statsuniques > 1 );//store.minCapacity !== store.maxCapacity
store.capacityStats = {
    uniques: statsuniques,
    usingFallback: statsusingFallback 
};

// count mismatches
//var calculatedSoC = Math.round( 10 * store.ahsoc ) / 10;
//var misMatch = calculatedSoC !== store.latestSoc
//var misMatch = !Match;
//if ( misMatch && ( statsuniques <= 2 ) ) {
if ( !Match && statsuniques <= 2 ) {
    store.misMatches = store.misMatches + 1;
    if (store.misMatches >= 2) {
        store.minCapacity = Math.round( 0.9 * store.minCapacity );
        store.maxCapacity = Math.round( 1.1 * store.maxCapacity );
        store.misMatches = 0;
        node.warn('2 mismatches, reset');
        node.status({fill: 'red', shape: 'dot', text: 'mismatch reset'});
    }
}
if ( !Match ) {
//if ( true ) {
    store.debugStats = {
        rawSoc: rawSoc,
        rawConsumed: rawConsumed,
        rawminCapacity: rawminCapacity,
        rawmaxCapacity: rawmaxCapacity,
        rawexpectedSoc: rawexpectedSoc,
        rawMatch: rawMatch,
        rawDelta: rawDelta,
        rawpairMin: rawpairMin,
        rawpairMax: rawpairMax,
        rawahSoc: rawahSoc
    }
}

// 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 + '% ' + (-rawDelta) +' SoC '+ statsuniques + ' @ '+ msg.capacity + 'Ah'  ) : 'wait')
let fill = ( ( (msg.accurate === true) && Match ) ? 'green' : 'red')
let shape = 'dot'
node.status({fill, shape, text});

return msg;