From 93ee5de1b4fc783fa13d0363877323f41ee8b793 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Wed, 28 Aug 2024 05:24:19 -0700 Subject: [PATCH] Plot 'untracked consumption' on devices detail energy graph (#21632) * Plot 'untracked consumption' on devices detail energy graph * skip when there are no energy sources * rename variable --- src/data/energy.ts | 145 ++++++++++++++++++ .../hui-energy-devices-detail-graph-card.ts | 116 +++++++++++++- src/translations/en.json | 3 +- 3 files changed, 256 insertions(+), 8 deletions(-) diff --git a/src/data/energy.ts b/src/data/energy.ts index c4d6611074..fb5d030fe4 100644 --- a/src/data/energy.ts +++ b/src/data/energy.ts @@ -11,6 +11,7 @@ import { isLastDayOfMonth, } from "date-fns"; import { Collection, getCollection } from "home-assistant-js-websocket"; +import memoizeOne from "memoize-one"; import { calcDate, calcDateProperty, @@ -791,3 +792,147 @@ export const getEnergyWaterUnit = (hass: HomeAssistant): string => export const energyStatisticHelpUrl = "/docs/energy/faq/#troubleshooting-missing-entities"; + +interface EnergySumData { + to_grid?: { [start: number]: number }; + from_grid?: { [start: number]: number }; + to_battery?: { [start: number]: number }; + from_battery?: { [start: number]: number }; + solar?: { [start: number]: number }; +} + +interface EnergyConsumptionData { + total: { [start: number]: number }; +} + +export const getSummedData = memoizeOne( + ( + data: EnergyData + ): { summedData: EnergySumData; compareSummedData?: EnergySumData } => { + const summedData = getSummedDataPartial(data); + const compareSummedData = data.statsCompare + ? getSummedDataPartial(data, true) + : undefined; + return { summedData, compareSummedData }; + } +); + +const getSummedDataPartial = ( + data: EnergyData, + compare?: boolean +): EnergySumData => { + const statIds: { + to_grid?: string[]; + from_grid?: string[]; + solar?: string[]; + to_battery?: string[]; + from_battery?: string[]; + } = {}; + + for (const source of data.prefs.energy_sources) { + if (source.type === "solar") { + if (statIds.solar) { + statIds.solar.push(source.stat_energy_from); + } else { + statIds.solar = [source.stat_energy_from]; + } + continue; + } + + if (source.type === "battery") { + if (statIds.to_battery) { + statIds.to_battery.push(source.stat_energy_to); + statIds.from_battery!.push(source.stat_energy_from); + } else { + statIds.to_battery = [source.stat_energy_to]; + statIds.from_battery = [source.stat_energy_from]; + } + continue; + } + + if (source.type !== "grid") { + continue; + } + + // grid source + for (const flowFrom of source.flow_from) { + if (statIds.from_grid) { + statIds.from_grid.push(flowFrom.stat_energy_from); + } else { + statIds.from_grid = [flowFrom.stat_energy_from]; + } + } + for (const flowTo of source.flow_to) { + if (statIds.to_grid) { + statIds.to_grid.push(flowTo.stat_energy_to); + } else { + statIds.to_grid = [flowTo.stat_energy_to]; + } + } + } + + const summedData: EnergySumData = {}; + Object.entries(statIds).forEach(([key, subStatIds]) => { + const totalStats: { [start: number]: number } = {}; + const sets: { [statId: string]: { [start: number]: number } } = {}; + subStatIds!.forEach((id) => { + const stats = compare ? data.statsCompare[id] : data.stats[id]; + if (!stats) { + return; + } + + const set = {}; + stats.forEach((stat) => { + if (stat.change === null || stat.change === undefined) { + return; + } + const val = stat.change; + // Get total of solar and to grid to calculate the solar energy used + totalStats[stat.start] = + stat.start in totalStats ? totalStats[stat.start] + val : val; + }); + sets[id] = set; + }); + summedData[key] = totalStats; + }); + + return summedData; +}; + +export const computeConsumptionData = memoizeOne( + ( + data: EnergySumData, + compareData?: EnergySumData + ): { + consumption: EnergyConsumptionData; + compareConsumption?: EnergyConsumptionData; + } => { + const consumption = computeConsumptionDataPartial(data); + const compareConsumption = compareData + ? computeConsumptionDataPartial(compareData) + : undefined; + return { consumption, compareConsumption }; + } +); + +const computeConsumptionDataPartial = ( + data: EnergySumData +): EnergyConsumptionData => { + const outData: EnergyConsumptionData = { total: {} }; + + Object.keys(data).forEach((type) => { + Object.keys(data[type]).forEach((start) => { + if (outData.total[start] === undefined) { + const consumption = + (data.from_grid?.[start] || 0) + + (data.solar?.[start] || 0) + + (data.from_battery?.[start] || 0) - + (data.to_grid?.[start] || 0) - + (data.to_battery?.[start] || 0); + outData.total[start] = consumption; + } + }); + }); + + return outData; +}; 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 6482315e58..6952b58b66 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 @@ -18,12 +18,15 @@ import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import memoizeOne from "memoize-one"; import { getGraphColorByIndex } from "../../../../common/color/colors"; +import { getEnergyColor } from "./common/color"; import { ChartDatasetExtra } from "../../../../components/chart/ha-chart-base"; import "../../../../components/ha-card"; import { DeviceConsumptionEnergyPreference, EnergyData, getEnergyDataCollection, + getSummedData, + computeConsumptionData, } from "../../../../data/energy"; import { calculateStatisticSumGrowth, @@ -75,6 +78,8 @@ export class HuiEnergyDevicesDetailGraphCard }) private _hiddenStats: string[] = []; + private _untrackedIndex?: number; + protected hassSubscribeRequiredHostProps = ["_config"]; public hassSubscribe(): UnsubscribeFunc[] { @@ -152,17 +157,22 @@ export class HuiEnergyDevicesDetailGraphCard } private _datasetHidden(ev) { - this._hiddenStats = [ - ...this._hiddenStats, - this._data!.prefs.device_consumption[ev.detail.index].stat_consumption, - ]; + const hiddenEntity = + ev.detail.index === this._untrackedIndex + ? "untracked" + : this._data!.prefs.device_consumption[ev.detail.index] + .stat_consumption; + this._hiddenStats = [...this._hiddenStats, hiddenEntity]; } private _datasetUnhidden(ev) { + const hiddenEntity = + ev.detail.index === this._untrackedIndex + ? "untracked" + : this._data!.prefs.device_consumption[ev.detail.index] + .stat_consumption; this._hiddenStats = this._hiddenStats.filter( - (stat) => - stat !== - this._data!.prefs.device_consumption[ev.detail.index].stat_consumption + (stat) => stat !== hiddenEntity ); } @@ -257,6 +267,33 @@ export class HuiEnergyDevicesDetailGraphCard datasetExtras.push(...processedDataExtras); + const { summedData, compareSummedData } = getSummedData(energyData); + + const showUntracked = + "from_grid" in summedData || + "solar" in summedData || + "from_battery" in summedData; + + const { + consumption: consumptionData, + compareConsumption: consumptionCompareData, + } = showUntracked + ? computeConsumptionData(summedData, compareSummedData) + : { consumption: undefined, compareConsumption: undefined }; + + if (showUntracked) { + this._untrackedIndex = datasets.length; + const { dataset: untrackedData, datasetExtra: untrackedDataExtra } = + this._processUntracked( + computedStyle, + processedData, + consumptionData, + false + ); + datasets.push(untrackedData); + datasetExtras.push(untrackedDataExtra); + } + if (compareData) { // Add empty dataset to align the bars datasets.push({ @@ -289,6 +326,20 @@ export class HuiEnergyDevicesDetailGraphCard datasets.push(...processedCompareData); datasetExtras.push(...processedCompareDataExtras); + + if (showUntracked) { + const { + dataset: untrackedCompareData, + datasetExtra: untrackedCompareDataExtra, + } = this._processUntracked( + computedStyle, + processedCompareData, + consumptionCompareData, + true + ); + datasets.push(untrackedCompareData); + datasetExtras.push(untrackedCompareDataExtra); + } } this._start = energyData.start; @@ -303,6 +354,57 @@ export class HuiEnergyDevicesDetailGraphCard this._chartDatasetExtra = datasetExtras; } + private _processUntracked( + computedStyle: CSSStyleDeclaration, + processedData, + consumptionData, + compare: boolean + ): { dataset; datasetExtra } { + const totalDeviceConsumption: { [start: number]: number } = {}; + + processedData.forEach((device) => { + device.data.forEach((datapoint) => { + totalDeviceConsumption[datapoint.x] = + (totalDeviceConsumption[datapoint.x] || 0) + datapoint.y; + }); + }); + + const untrackedConsumption: { x: number; y: number }[] = []; + Object.keys(consumptionData.total).forEach((time) => { + untrackedConsumption.push({ + x: Number(time), + y: consumptionData.total[time] - (totalDeviceConsumption[time] || 0), + }); + }); + const dataset = { + label: this.hass.localize("ui.panel.energy.charts.untracked_consumption"), + hidden: this._hiddenStats.includes("untracked"), + borderColor: getEnergyColor( + computedStyle, + this.hass.themes.darkMode, + false, + compare, + "--state-unavailable-color" + ), + backgroundColor: getEnergyColor( + computedStyle, + this.hass.themes.darkMode, + true, + compare, + "--state-unavailable-color" + ), + data: untrackedConsumption, + order: 1 + this._untrackedIndex!, + stack: "devices", + pointStyle: compare ? false : "circle", + xAxisID: compare ? "xAxisCompare" : undefined, + }; + const datasetExtra = { + show_legend: !compare, + }; + return { dataset, datasetExtra }; + } + private _processDataSet( computedStyle: CSSStyleDeclaration, statistics: Statistics, diff --git a/src/translations/en.json b/src/translations/en.json index 0ae75011f0..1f5f07b145 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -7155,7 +7155,8 @@ "charts": { "stat_house_energy_meter": "Total energy consumption", "solar": "Solar", - "by_device": "Consumption by device" + "by_device": "Consumption by device", + "untracked_consumption": "Untracked consumption" }, "cards": { "energy_usage_graph_title": "Energy usage",