diff --git a/src/data/energy.ts b/src/data/energy.ts index 480135cdee10..637b9d5411c1 100644 --- a/src/data/energy.ts +++ b/src/data/energy.ts @@ -360,6 +360,35 @@ export const getReferencedStatisticIds = ( return statIDs; }; +export const getReferencedStatisticIdsPower = ( + prefs: EnergyPreferences +): string[] => { + const statIDs: (string | undefined)[] = []; + + for (const source of prefs.energy_sources) { + if (source.type === "gas" || source.type === "water") { + continue; + } + + if (source.type === "solar") { + statIDs.push(source.stat_rate); + continue; + } + + if (source.type === "battery") { + statIDs.push(source.stat_rate); + continue; + } + + if (source.power) { + statIDs.push(...source.power.map((p) => p.stat_rate)); + } + } + statIDs.push(...prefs.device_consumption.map((d) => d.stat_rate)); + + return statIDs.filter(Boolean) as string[]; +}; + export const enum CompareMode { NONE = "", PREVIOUS = "previous", @@ -407,9 +436,10 @@ const getEnergyData = async ( "gas", "device", ]); + const powerStatIds = getReferencedStatisticIdsPower(prefs); const waterStatIds = getReferencedStatisticIds(prefs, info, ["water"]); - const allStatIDs = [...energyStatIds, ...waterStatIds]; + const allStatIDs = [...energyStatIds, ...waterStatIds, ...powerStatIds]; const dayDifference = differenceInDays(end || new Date(), start); const period = @@ -420,6 +450,8 @@ const getEnergyData = async ( : dayDifference > 2 ? "day" : "hour"; + const finePeriod = + dayDifference > 64 ? "day" : dayDifference > 8 ? "hour" : "5minute"; const statsMetadata: Record = {}; const statsMetadataArray = allStatIDs.length @@ -441,6 +473,9 @@ const getEnergyData = async ( ? (gasUnit as (typeof VOLUME_UNITS)[number]) : undefined, }; + const powerUnits: StatisticsUnitConfiguration = { + power: "kW", + }; const waterUnit = getEnergyWaterUnit(hass, prefs, statsMetadata); const waterUnits: StatisticsUnitConfiguration = { volume: waterUnit, @@ -451,6 +486,12 @@ const getEnergyData = async ( "change", ]) : {}; + const _powerStats: Statistics | Promise = powerStatIds.length + ? fetchStatistics(hass!, start, end, powerStatIds, finePeriod, powerUnits, [ + "mean", + ]) + : {}; + const _waterStats: Statistics | Promise = waterStatIds.length ? fetchStatistics(hass!, start, end, waterStatIds, period, waterUnits, [ "change", @@ -557,6 +598,7 @@ const getEnergyData = async ( const [ energyStats, + powerStats, waterStats, energyStatsCompare, waterStatsCompare, @@ -564,13 +606,14 @@ const getEnergyData = async ( fossilEnergyConsumptionCompare, ] = await Promise.all([ _energyStats, + _powerStats, _waterStats, _energyStatsCompare, _waterStatsCompare, _fossilEnergyConsumption, _fossilEnergyConsumptionCompare, ]); - const stats = { ...energyStats, ...waterStats }; + const stats = { ...energyStats, ...waterStats, ...powerStats }; if (compare) { statsCompare = { ...energyStatsCompare, ...waterStatsCompare }; } 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 a48ec81aee2b..e9eee87e6f39 100644 --- a/src/panels/lovelace/cards/energy/common/energy-chart-options.ts +++ b/src/panels/lovelace/cards/energy/common/energy-chart-options.ts @@ -16,8 +16,10 @@ import { import type { BarSeriesOption, CallbackDataParams, + LineSeriesOption, TopLevelFormatterParams, } from "echarts/types/dist/shared"; +import type { LineDataItemOption } from "echarts/types/src/chart/line/LineSeries"; import type { FrontendLocaleData } from "../../../../../data/translation"; import { formatNumber } from "../../../../../common/number/format_number"; import { @@ -170,11 +172,10 @@ function formatTooltip( compare ? `${(showCompareYear ? formatDateShort : formatDateVeryShort)(date, locale, config)}: ` : "" - }${formatTime(date, locale, config)} – ${formatTime( - addHours(date, 1), - locale, - config - )}`; + }${formatTime(date, locale, config)}`; + if (params[0].componentSubType === "bar") { + period += ` – ${formatTime(addHours(date, 1), locale, config)}`; + } } const title = `

${period}

`; @@ -281,6 +282,35 @@ export function fillDataGapsAndRoundCaps(datasets: BarSeriesOption[]) { }); } +export function fillLineGaps(datasets: LineSeriesOption[]) { + const buckets = Array.from( + new Set( + datasets + .map((dataset) => + dataset.data!.map((datapoint) => Number(datapoint![0])) + ) + .flat() + ) + ).sort((a, b) => a - b); + buckets.forEach((bucket, index) => { + for (let i = datasets.length - 1; i >= 0; i--) { + const dataPoint = datasets[i].data![index]; + const item: LineDataItemOption = + dataPoint && typeof dataPoint === "object" && "value" in dataPoint + ? dataPoint + : ({ value: dataPoint } as LineDataItemOption); + const x = item.value?.[0]; + if (x === undefined) { + continue; + } + if (Number(x) !== bucket) { + datasets[i].data?.splice(index, 0, [bucket, 0]); + } + } + }); + return datasets; +} + export function getCompareTransform(start: Date, compareStart?: Date) { if (!compareStart) { return (ts: Date) => ts; diff --git a/src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts b/src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts new file mode 100644 index 000000000000..9b803248e98d --- /dev/null +++ b/src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts @@ -0,0 +1,333 @@ +import { endOfToday, isToday, 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 { customElement, property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import memoizeOne from "memoize-one"; +import type { LineSeriesOption } from "echarts/charts"; +import { graphic } from "echarts"; +import "../../../../components/chart/ha-chart-base"; +import "../../../../components/ha-card"; +import type { EnergyData } from "../../../../data/energy"; +import { getEnergyDataCollection } from "../../../../data/energy"; +import type { StatisticValue } from "../../../../data/recorder"; +import type { FrontendLocaleData } from "../../../../data/translation"; +import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; +import type { HomeAssistant } from "../../../../types"; +import type { LovelaceCard } from "../../types"; +import type { PowerSourcesGraphCardConfig } from "../types"; +import { hasConfigChanged } from "../../common/has-changed"; +import { getCommonOptions, fillLineGaps } from "./common/energy-chart-options"; +import type { ECOption } from "../../../../resources/echarts/echarts"; +import { hex2rgb } from "../../../../common/color/convert-color"; + +@customElement("hui-power-sources-graph-card") +export class HuiPowerSourcesGraphCard + extends SubscribeMixin(LitElement) + implements LovelaceCard +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _config?: PowerSourcesGraphCardConfig; + + @state() private _chartData: LineSeriesOption[] = []; + + @state() private _start = startOfToday(); + + @state() private _end = endOfToday(); + + @state() private _compareStart?: Date; + + @state() private _compareEnd?: Date; + + protected hassSubscribeRequiredHostProps = ["_config"]; + + public hassSubscribe(): UnsubscribeFunc[] { + return [ + getEnergyDataCollection(this.hass, { + key: this._config?.collection_key, + }).subscribe((data) => this._getStatistics(data)), + ]; + } + + public getCardSize(): Promise | number { + return 3; + } + + public setConfig(config: PowerSourcesGraphCardConfig): void { + this._config = config; + } + + protected shouldUpdate(changedProps: PropertyValues): boolean { + return ( + hasConfigChanged(this, changedProps) || + changedProps.size > 1 || + !changedProps.has("hass") + ); + } + + protected render() { + if (!this.hass || !this._config) { + return nothing; + } + + return html` + + ${this._config.title + ? html`

${this._config.title}

` + : ""} +
+ + ${!this._chartData.some((dataset) => dataset.data!.length) + ? html`
+ ${isToday(this._start) + ? this.hass.localize("ui.panel.lovelace.cards.energy.no_data") + : this.hass.localize( + "ui.panel.lovelace.cards.energy.no_data_period" + )} +
` + : nothing} +
+
+ `; + } + + private _createOptions = memoizeOne( + ( + start: Date, + end: Date, + locale: FrontendLocaleData, + config: HassConfig, + compareStart?: Date, + compareEnd?: Date + ): ECOption => + getCommonOptions( + start, + end, + locale, + config, + "kW", + compareStart, + compareEnd + ) + ); + + private async _getStatistics(energyData: EnergyData): Promise { + const datasets: LineSeriesOption[] = []; + + const statIds = { + solar: { + stats: [] as string[], + color: "--energy-solar-color", + name: this.hass.localize( + "ui.panel.lovelace.cards.energy.power_graph.solar" + ), + }, + grid: { + stats: [] as string[], + color: "--energy-grid-consumption-color", + name: this.hass.localize( + "ui.panel.lovelace.cards.energy.power_graph.grid" + ), + }, + battery: { + stats: [] as string[], + color: "--energy-battery-out-color", + name: this.hass.localize( + "ui.panel.lovelace.cards.energy.power_graph.battery" + ), + }, + }; + + const computedStyles = getComputedStyle(this); + + for (const source of energyData.prefs.energy_sources) { + if (source.type === "solar") { + if (source.stat_rate) { + statIds.solar.stats.push(source.stat_rate); + } + continue; + } + + if (source.type === "battery") { + if (source.stat_rate) { + statIds.battery.stats.push(source.stat_rate); + } + continue; + } + + if (source.type === "grid" && source.power) { + statIds.grid.stats.push(...source.power.map((p) => p.stat_rate)); + } + } + const commonSeriesOptions: LineSeriesOption = { + type: "line", + smooth: 0.4, + smoothMonotone: "x", + lineStyle: { + width: 1, + }, + }; + + Object.keys(statIds).forEach((key, keyIndex) => { + if (statIds[key].stats.length) { + const colorHex = computedStyles.getPropertyValue(statIds[key].color); + const rgb = hex2rgb(colorHex); + const { positive, negative } = this._processData( + statIds[key].stats.map((id: string) => energyData.stats[id] ?? []) + ); + datasets.push({ + ...commonSeriesOptions, + id: key, + name: statIds[key].name, + color: colorHex, + stack: "positive", + areaStyle: { + color: new graphic.LinearGradient(0, 0, 0, 1, [ + { + offset: 0, + color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`, + }, + { + offset: 1, + color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.25)`, + }, + ]), + }, + data: positive, + z: 3 - keyIndex, // draw in reverse order so 0 value lines are overwritten + }); + if (key !== "solar") { + datasets.push({ + ...commonSeriesOptions, + id: `${key}-negative`, + name: statIds[key].name, + color: colorHex, + stack: "negative", + areaStyle: { + color: new graphic.LinearGradient(0, 1, 0, 0, [ + { + offset: 0, + color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`, + }, + { + offset: 1, + color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.25)`, + }, + ]), + }, + data: negative, + z: 4 - keyIndex, // draw in reverse order but above positive series + }); + } + } + }); + + this._start = energyData.start; + this._end = energyData.end || endOfToday(); + + this._chartData = fillLineGaps(datasets); + + const usageData: NonNullable = []; + this._chartData[0]?.data!.forEach((item, i) => { + // fillLineGaps ensures all datasets have the same x values + const x = + typeof item === "object" && "value" in item! + ? item.value![0] + : item![0]; + usageData[i] = [x, 0]; + this._chartData.forEach((dataset) => { + const y = + typeof dataset.data![i] === "object" && "value" in dataset.data![i]! + ? dataset.data![i].value![1] + : dataset.data![i]![1]; + usageData[i]![1] += y as number; + }); + }); + this._chartData.push({ + ...commonSeriesOptions, + id: "usage", + name: this.hass.localize( + "ui.panel.lovelace.cards.energy.power_graph.usage" + ), + color: computedStyles.getPropertyValue("--primary-color"), + lineStyle: { width: 2 }, + data: usageData, + z: 5, + }); + } + + private _processData(stats: StatisticValue[][]) { + const data: Record = {}; + stats.forEach((statSet) => { + statSet.forEach((point) => { + if (point.mean == null) { + return; + } + const x = (point.start + point.end) / 2; + data[x] = [...(data[x] ?? []), point.mean]; + }); + }); + const positive: [number, number][] = []; + const negative: [number, number][] = []; + Object.entries(data).forEach(([x, y]) => { + const ts = Number(x); + const meanY = y.reduce((a, b) => a + b, 0) / y.length; + positive.push([ts, Math.max(0, meanY)]); + negative.push([ts, Math.min(0, meanY)]); + }); + return { positive, negative }; + } + + static styles = css` + ha-card { + height: 100%; + } + .card-header { + padding-bottom: 0; + } + .content { + padding: var(--ha-space-4); + } + .has-header { + padding-top: 0; + } + .no-data { + position: absolute; + height: 100%; + top: 0; + left: 0; + right: 0; + display: flex; + justify-content: center; + align-items: center; + padding: 20%; + margin-left: var(--ha-space-8); + margin-inline-start: var(--ha-space-8); + margin-inline-end: initial; + box-sizing: border-box; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-power-sources-graph-card": HuiPowerSourcesGraphCard; + } +} diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 7e7739ed198d..15de4c7f5075 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -230,6 +230,11 @@ export interface EnergySankeyCardConfig extends EnergyCardBaseConfig { group_by_area?: boolean; } +export interface PowerSourcesGraphCardConfig extends EnergyCardBaseConfig { + type: "power-sources-graph"; + title?: string; +} + export interface EntityFilterCardConfig extends LovelaceCardConfig { type: "entity-filter"; entities: (EntityFilterEntityConfig | string)[]; diff --git a/src/panels/lovelace/create-element/create-card-element.ts b/src/panels/lovelace/create-element/create-card-element.ts index 997831667267..c74925da25ea 100644 --- a/src/panels/lovelace/create-element/create-card-element.ts +++ b/src/panels/lovelace/create-element/create-card-element.ts @@ -66,6 +66,8 @@ const LAZY_LOAD_TYPES = { "energy-usage-graph": () => import("../cards/energy/hui-energy-usage-graph-card"), "energy-sankey": () => import("../cards/energy/hui-energy-sankey-card"), + "power-sources-graph": () => + import("../cards/energy/hui-power-sources-graph-card"), "entity-filter": () => import("../cards/hui-entity-filter-card"), error: () => import("../cards/hui-error-card"), "home-summary": () => import("../cards/hui-home-summary-card"), diff --git a/src/panels/lovelace/editor/get-dashboard-documentation-url.ts b/src/panels/lovelace/editor/get-dashboard-documentation-url.ts index c4e66db8aa99..2a3e6f678686 100644 --- a/src/panels/lovelace/editor/get-dashboard-documentation-url.ts +++ b/src/panels/lovelace/editor/get-dashboard-documentation-url.ts @@ -22,6 +22,7 @@ const NON_STANDARD_URLS = { "energy-devices-graph": "energy/#devices-energy-graph", "energy-devices-detail-graph": "energy/#detail-devices-energy-graph", "energy-sankey": "energy/#sankey-energy-graph", + "power-sources-graph": "energy/#power-sources-graph", }; export const getCardDocumentationURL = ( diff --git a/src/translations/en.json b/src/translations/en.json index 4cbd57e3a438..69b0df78fac4 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -7130,6 +7130,12 @@ "low_carbon_energy_consumed": "Low-carbon electricity consumed", "low_carbon_energy_not_calculated": "Consumed low-carbon electricity couldn't be calculated" }, + "power_graph": { + "grid": "Grid", + "solar": "Solar", + "battery": "Battery", + "usage": "Used" + }, "energy_compare": { "info": "You are comparing the period {start} with the period {end}", "compare_previous_year": "Compare previous year",