Infer charging state from trend of last 50 data points

When no state field is configured, the panel now uses a least-squares
slope over the most recent ≤ 50 data points to determine whether the
battery is charging, on battery, or stable — instead of relying on the
overall first-to-last rate which could be misleading over long ranges.

Priority order for state resolution:
1. Explicit state field (if enabled and configured)
2. Inferred trend from last ≤ 50 points (always computed)

The slope threshold (0.01) avoids flicker from sensor noise.
Status badge now shows 'On Battery' instead of 'Discharging'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Andy Dixon
2026-04-24 13:20:27 +01:00
co-authored by Copilot
parent da80da71d1
commit 60c787d041
+45 -6
View File
@@ -42,6 +42,40 @@ function slopePercentPerMinute(times: number[], values: number[]): number {
return den === 0 ? 0 : num / den; return den === 0 ? 0 : num / den;
} }
/** Infer battery state from the trend of the last ≤ maxPoints values. */
function inferStateFromTrend(
values: number[],
maxPoints = 50,
threshold = 0.01,
): 'charging' | 'discharging' | 'stable' {
const tail = values.length > maxPoints ? values.slice(-maxPoints) : values;
if (tail.length < 2) {
return 'stable';
}
// Simple least-squares slope over the tail window
const n = tail.length;
let sumX = 0;
let sumY = 0;
let sumXY = 0;
let sumXX = 0;
for (let i = 0; i < n; i++) {
sumX += i;
sumY += tail[i];
sumXY += i * tail[i];
sumXX += i * i;
}
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
if (slope > threshold) {
return 'charging';
}
if (slope < -threshold) {
return 'discharging';
}
return 'stable';
}
/** Find a field by configured name, falling back to first field of given type. */ /** Find a field by configured name, falling back to first field of given type. */
function resolveField( function resolveField(
fields: Field[], fields: Field[],
@@ -325,6 +359,7 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
let hi = 0; let hi = 0;
let loadValue: number | undefined; let loadValue: number | undefined;
let stateFromField: 'charging' | 'discharging' | 'stable' | undefined; let stateFromField: 'charging' | 'discharging' | 'stable' | undefined;
let inferredState: 'charging' | 'discharging' | 'stable' = 'stable';
if (data.series.length > 0) { if (data.series.length > 0) {
const series = data.series[0]; const series = data.series[0];
@@ -361,6 +396,9 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
r = deltaMinutes !== 0 ? deltaPercent / deltaMinutes : 0; r = deltaMinutes !== 0 ? deltaPercent / deltaMinutes : 0;
} }
} }
// Infer state from recent trend (last ≤ 50 points)
inferredState = inferStateFromTrend(values);
} }
} }
@@ -396,14 +434,14 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
} }
} }
return { currentValue: cur, rate: r, low: lo, high: hi, loadValue, stateFromField }; return { currentValue: cur, rate: r, low: lo, high: hi, loadValue, stateFromField, inferredState };
}, [ }, [
data.series, useRegression, enableLoad, enableStateField, data.series, useRegression, enableLoad, enableStateField,
options.chargeField, options.loadField, options.stateField, options.chargeField, options.loadField, options.stateField,
stateChargingValue, stateDischargingValue, stateChargingValue, stateDischargingValue,
]); ]);
const { currentValue, rate, low, high, loadValue, stateFromField } = extracted; const { currentValue, rate, low, high, loadValue, stateFromField, inferredState } = extracted;
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Derived visual values // Derived visual values
@@ -420,9 +458,10 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
let startColour = fillColour; let startColour = fillColour;
let endColour = fillColour; let endColour = fillColour;
// Determine charging state: prefer state field, else use computed rate // Determine charging state: state field → inferred trend from last ≤50 points → stable
const isCharging = stateFromField ? stateFromField === 'charging' : rate > 0; const resolvedState = stateFromField ?? inferredState;
const isDischarging = stateFromField ? stateFromField === 'discharging' : rate < 0; const isCharging = resolvedState === 'charging';
const isDischarging = resolvedState === 'discharging';
if (gradientFill) { if (gradientFill) {
if (isCharging) { if (isCharging) {
@@ -622,7 +661,7 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
{/* Status badge */} {/* Status badge */}
{showStatus && !isCompact && ( {showStatus && !isCompact && (
<div className={statusBadgeStyle}> <div className={statusBadgeStyle}>
{isCharging ? '⚡ Charging' : isDischarging ? '↓ Discharging' : '— Stable'} {isCharging ? '⚡ Charging' : isDischarging ? '🔋 On Battery' : '— Stable'}
</div> </div>
)} )}