diff --git a/src/panels/lovelace/cards/energy/common/energy-chart-options.ts b/src/panels/lovelace/cards/energy/common/energy-chart-options.ts index 9b959ec79059..e1abb9800a6b 100644 --- a/src/panels/lovelace/cards/energy/common/energy-chart-options.ts +++ b/src/panels/lovelace/cards/energy/common/energy-chart-options.ts @@ -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 @@ -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, @@ -347,6 +358,41 @@ export function computeStatMidpoint( return (start + end) / 2; } +export interface UntrackedConsumptionResult { + /** Untracked consumption per timestamp, clamped to >= 0. */ + values: Record; + /** Raw (unclamped) values for timestamps where the value was negative. */ + rawNegatives: Record; +} + +/** + * 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, + totalDeviceConsumption: Record +): UntrackedConsumptionResult { + const values: Record = {}; + const rawNegatives: Record = {}; + 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; diff --git a/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts index 5f6fafef218d..75cdbf3234de 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts @@ -1,7 +1,13 @@ 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"; @@ -9,6 +15,7 @@ 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, @@ -34,6 +41,7 @@ import type { EnergyDevicesDetailGraphCardConfig } from "../types"; import { hasConfigChanged } from "../../common/has-changed"; import { computeStatMidpoint, + computeUntrackedConsumption, type EnergyDataPoint, fillDataGapsAndRoundCaps, getCommonOptions, @@ -84,6 +92,8 @@ export class HuiEnergyDevicesDetailGraphCard @state() private _compareEnd?: Date; + @state() private _hasClampedNegatives = false; + @state() @storage({ key: "energy-devices-hidden-stats", @@ -159,11 +169,25 @@ export class HuiEnergyDevicesDetailGraphCard @dataset-hidden=${this._datasetHidden} @dataset-unhidden=${this._datasetUnhidden} > + ${this._renderClampedAlert()} `; } + private _renderClampedAlert(): TemplateResult | typeof nothing { + if (!this._hasClampedNegatives) { + return nothing; + } + return html` + + ${this.hass.localize( + "ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_adjusted" + )} + + `; + } + private _formatTotal = (total: number) => this.hass.localize( "ui.panel.lovelace.cards.energy.energy_usage_graph.total_consumed", @@ -306,6 +330,8 @@ export class HuiEnergyDevicesDetailGraphCard ? computeConsumptionData(summedData, compareSummedData) : { consumption: undefined, compareConsumption: undefined }; + let compareHasClampedNegatives = false; + if (compareData) { const processedCompareData = this._processDataSet( computedStyle, @@ -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; } } @@ -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, @@ -376,6 +407,9 @@ export class HuiEnergyDevicesDetailGraphCard borderColor: untrackedData.itemStyle?.borderColor as string, }, }); + this._hasClampedNegatives = mainHasClamped || compareHasClampedNegatives; + } else { + this._hasClampedNegatives = false; } fillDataGapsAndRoundCaps(datasets); @@ -387,7 +421,7 @@ export class HuiEnergyDevicesDetailGraphCard processedData, consumptionData, compare: boolean - ): BarSeriesOption { + ): { dataset: BarSeriesOption; hasClampedNegatives: boolean } { const totalDeviceConsumption: Record = {}; processedData.forEach((device) => { @@ -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; } @@ -451,7 +497,7 @@ export class HuiEnergyDevicesDetailGraphCard data: untrackedConsumption, stack: compare ? "devicesCompare" : "devices", }; - return dataset; + return { dataset, hasClampedNegatives }; } private _processDataSet( diff --git a/src/translations/en.json b/src/translations/en.json index 90fe89714e1e..c720d7a49895 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -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": { diff --git a/test/panels/lovelace/cards/energy/common/energy-chart-options.test.ts b/test/panels/lovelace/cards/energy/common/energy-chart-options.test.ts index 7ac775051972..9fe7ebd8f41a 100644 --- a/test/panels/lovelace/cards/energy/common/energy-chart-options.test.ts +++ b/test/panels/lovelace/cards/energy/common/energy-chart-options.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "vitest"; import type { BarSeriesOption, LineSeriesOption } from "echarts/charts"; import { + computeUntrackedConsumption, fillDataGapsAndRoundCaps, fillLineGaps, getCompareTransform, @@ -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); + }); +});