Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/panels/lovelace/cards/energy/common/energy-chart-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,13 @@ export { fillDataGapsAndRoundCaps } from "../../../../../components/chart/round-
* [0] displayX - bar position (midpoint for sub-daily periods, start otherwise)
* [1] value - the energy value
* [2] originalStart - original period start timestamp, used for tooltips
* [3] rawUnclamped - (optional) raw negative untracked value before clamping
*/
export type EnergyDataPoint = [
displayX: number,
value: number,
originalStart: number,
rawUnclamped?: number,
];

// Number of days of padding when showing time axis in months
Expand Down Expand Up @@ -251,6 +253,15 @@ function formatTooltip(
const values = params
.map((param) => {
const y = param.value?.[1] as number;
// A negative 4th element indicates the untracked value was clamped to
// zero — show the original value so users understand the adjustment.
const rawUnclamped = param.value?.[3] as number | undefined;
if (rawUnclamped != null && rawUnclamped < 0) {
const excessFormatted = formatNumber(Math.abs(rawUnclamped), locale, {
maximumFractionDigits: 3,
});
return `${param.marker} Tracked devices exceeded grid consumption by ${excessFormatted} ${unit}`;
}
const value = formatNumber(
y,
locale,
Expand Down Expand Up @@ -347,6 +358,41 @@ export function computeStatMidpoint(
return (start + end) / 2;
}

export interface UntrackedConsumptionResult {
/** Untracked consumption per timestamp, clamped to >= 0. */
values: Record<number, number>;
/** Raw (unclamped) values for timestamps where the value was negative. */
rawNegatives: Record<number, number>;
}

/**
* Compute untracked energy consumption per timestamp.
*
* For each timestamp in `usedTotal`, subtracts the sum of tracked device
* consumption and clamps the result to zero. Negative untracked values are
* physically impossible — they arise from meter resolution mismatches
* (e.g., integer grid meter vs fractional device sensors).
*
* Returns the clamped values and the raw negative values for timestamps
* where clamping occurred, so callers can surface per-period indicators.
*/
export function computeUntrackedConsumption(
usedTotal: Record<number, number>,
totalDeviceConsumption: Record<number, number>
): UntrackedConsumptionResult {
const values: Record<number, number> = {};
const rawNegatives: Record<number, number> = {};
for (const time of Object.keys(usedTotal)) {
const ts = Number(time);
const raw = usedTotal[ts] - (totalDeviceConsumption[ts] || 0);
if (raw < 0) {
rawNegatives[ts] = raw;
}
values[ts] = Math.max(0, raw);
}
return { values, rawNegatives };
}

export function getCompareTransform(start: Date, compareStart?: Date) {
if (!compareStart) {
return (ts: Date) => ts;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { endOfToday, startOfToday } from "date-fns";
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import {
type PropertyValues,
type TemplateResult,
css,
html,
LitElement,
nothing,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import type { BarSeriesOption } from "echarts/charts";
import { getGraphColorByIndex } from "../../../../common/color/colors";
import { getEnergyColor } from "./common/color";
import "../../../../components/ha-card";
import "../../../../components/ha-alert";
import "../../../../components/chart/ha-chart-base";
import type {
DeviceConsumptionEnergyPreference,
Expand All @@ -34,6 +41,7 @@ import type { EnergyDevicesDetailGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import {
computeStatMidpoint,
computeUntrackedConsumption,
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
getCommonOptions,
Expand Down Expand Up @@ -84,6 +92,8 @@ export class HuiEnergyDevicesDetailGraphCard

@state() private _compareEnd?: Date;

@state() private _hasClampedNegatives = false;

@state()
@storage({
key: "energy-devices-hidden-stats",
Expand Down Expand Up @@ -159,11 +169,25 @@ export class HuiEnergyDevicesDetailGraphCard
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
></ha-chart-base>
${this._renderClampedAlert()}
</div>
</ha-card>
`;
}

private _renderClampedAlert(): TemplateResult | typeof nothing {
if (!this._hasClampedNegatives) {
return nothing;
}
return html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_adjusted"
)}
</ha-alert>
`;
}

private _formatTotal = (total: number) =>
this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_usage_graph.total_consumed",
Expand Down Expand Up @@ -306,6 +330,8 @@ export class HuiEnergyDevicesDetailGraphCard
? computeConsumptionData(summedData, compareSummedData)
: { consumption: undefined, compareConsumption: undefined };

let compareHasClampedNegatives = false;

if (compareData) {
const processedCompareData = this._processDataSet(
computedStyle,
Expand All @@ -320,13 +346,17 @@ export class HuiEnergyDevicesDetailGraphCard
datasets.push(...processedCompareData);

if (showUntracked) {
const untrackedCompareData = this._processUntracked(
const {
dataset: untrackedCompareData,
hasClampedNegatives: compareHasClamped,
} = this._processUntracked(
computedStyle,
processedCompareData,
consumptionCompareData,
true
);
datasets.push(untrackedCompareData);
compareHasClampedNegatives = compareHasClamped;
}
}

Expand Down Expand Up @@ -360,12 +390,13 @@ export class HuiEnergyDevicesDetailGraphCard
}));

if (showUntracked) {
const untrackedData = this._processUntracked(
computedStyle,
processedData,
consumptionData,
false
);
const { dataset: untrackedData, hasClampedNegatives: mainHasClamped } =
this._processUntracked(
computedStyle,
processedData,
consumptionData,
false
);
datasets.push(untrackedData);
this._legendData.push({
id: untrackedData.id as string,
Expand All @@ -376,6 +407,9 @@ export class HuiEnergyDevicesDetailGraphCard
borderColor: untrackedData.itemStyle?.borderColor as string,
},
});
this._hasClampedNegatives = mainHasClamped || compareHasClampedNegatives;
} else {
this._hasClampedNegatives = false;
}

fillDataGapsAndRoundCaps(datasets);
Expand All @@ -387,7 +421,7 @@ export class HuiEnergyDevicesDetailGraphCard
processedData,
consumptionData,
compare: boolean
): BarSeriesOption {
): { dataset: BarSeriesOption; hasClampedNegatives: boolean } {
const totalDeviceConsumption: Record<number, number> = {};

processedData.forEach((device) => {
Expand All @@ -412,11 +446,23 @@ export class HuiEnergyDevicesDetailGraphCard
(period === "hour" || period === "5minute") && sortedTimes.length >= 2
? (Number(sortedTimes[1]) - Number(sortedTimes[0])) / 2
: 0;
const { values: untrackedValues, rawNegatives } =
computeUntrackedConsumption(
consumptionData.used_total,
totalDeviceConsumption
);
const hasClampedNegatives = Object.keys(rawNegatives).length > 0;
sortedTimes.forEach((time) => {
const ts = Number(time);
const value =
consumptionData.used_total[time] - (totalDeviceConsumption[time] || 0);
const dataPoint: EnergyDataPoint = [ts + periodOffset, value, ts];
const dataPoint: EnergyDataPoint = [
ts + periodOffset,
untrackedValues[ts],
ts,
];
// Carry the raw negative value so the tooltip can show it
if (ts in rawNegatives) {
dataPoint[3] = rawNegatives[ts];
}
if (compare) {
dataPoint[0] = compareTransform(new Date(ts)).getTime() + periodOffset;
}
Expand Down Expand Up @@ -451,7 +497,7 @@ export class HuiEnergyDevicesDetailGraphCard
data: untrackedConsumption,
stack: compare ? "devicesCompare" : "devices",
};
return dataset;
return { dataset, hasClampedNegatives };
}

private _processDataSet(
Expand Down
1 change: 1 addition & 0 deletions src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -8503,6 +8503,7 @@
"energy_devices_detail_graph": {
"untracked_consumption": "Untracked consumption",
"untracked": "untracked",
"untracked_adjusted": "During some periods, tracked devices reported more energy than total grid consumption. This is common when energy meters report in whole-number increments. Untracked consumption has been adjusted to zero for these periods. Hover over a bar to see the specific value.",
"other": "Other"
},
"carbon_consumed_gauge": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { assert, describe, it } from "vitest";
import type { BarSeriesOption, LineSeriesOption } from "echarts/charts";

import {
computeUntrackedConsumption,
fillDataGapsAndRoundCaps,
fillLineGaps,
getCompareTransform,
Expand Down Expand Up @@ -533,3 +534,76 @@ describe("getCompareTransform", () => {
assert.equal(result.getDate(), 20);
});
});

describe("computeUntrackedConsumption", () => {
it("returns positive untracked when grid exceeds devices", () => {
const usedTotal = { 1000: 5, 2000: 3 };
const deviceTotal = { 1000: 2, 2000: 1 };
const result = computeUntrackedConsumption(usedTotal, deviceTotal);
assert.deepEqual(result.values, { 1000: 3, 2000: 2 });
assert.deepEqual(result.rawNegatives, {});
});

it("clamps negative untracked to zero and records raw negatives", () => {
// Device sensors report more than the integer grid meter
const usedTotal = { 1000: 0, 2000: 1 };
const deviceTotal = { 1000: 0.3, 2000: 1.7 };
const result = computeUntrackedConsumption(usedTotal, deviceTotal);
assert.equal(result.values[1000], 0);
assert.equal(result.values[2000], 0);
assert.approximately(result.rawNegatives[1000], -0.3, 0.001);
assert.approximately(result.rawNegatives[2000], -0.7, 0.001);
});

it("returns zero when grid equals devices", () => {
const usedTotal = { 1000: 2.5 };
const deviceTotal = { 1000: 2.5 };
const result = computeUntrackedConsumption(usedTotal, deviceTotal);
assert.equal(result.values[1000], 0);
assert.deepEqual(result.rawNegatives, {});
});

it("returns full grid value when no device data exists for timestamp", () => {
const usedTotal = { 1000: 4 };
const deviceTotal = {};
const result = computeUntrackedConsumption(usedTotal, deviceTotal);
assert.equal(result.values[1000], 4);
assert.deepEqual(result.rawNegatives, {});
});

it("ignores device timestamps not present in usedTotal", () => {
const usedTotal = { 1000: 2 };
const deviceTotal = { 1000: 1, 9999: 5 };
const result = computeUntrackedConsumption(usedTotal, deviceTotal);
assert.deepEqual(result.values, { 1000: 1 });
assert.deepEqual(result.rawNegatives, {});
});

it("handles mixed positive and negative across timestamps", () => {
const usedTotal = { 1000: 0, 2000: 3, 3000: 1 };
const deviceTotal = { 1000: 0.5, 2000: 1, 3000: 2 };
const result = computeUntrackedConsumption(usedTotal, deviceTotal);
assert.equal(result.values[1000], 0); // clamped
assert.equal(result.values[2000], 2); // positive
assert.equal(result.values[3000], 0); // clamped
assert.approximately(result.rawNegatives[1000], -0.5, 0.001);
assert.approximately(result.rawNegatives[3000], -1, 0.001);
assert.isUndefined(result.rawNegatives[2000]); // positive, not in rawNegatives
});

it("returns empty result for empty inputs", () => {
const result = computeUntrackedConsumption({}, {});
assert.deepEqual(result.values, {});
assert.deepEqual(result.rawNegatives, {});
});

it("does not mutate input objects", () => {
const usedTotal = { 1000: 5 };
const deviceTotal = { 1000: 2 };
const usedCopy = { ...usedTotal };
const deviceCopy = { ...deviceTotal };
computeUntrackedConsumption(usedTotal, deviceTotal);
assert.deepEqual(usedTotal, usedCopy);
assert.deepEqual(deviceTotal, deviceCopy);
});
});
Loading