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;