diff --git a/src/data/energy.ts b/src/data/energy.ts index cf49d095c2..54c2577ce5 100644 --- a/src/data/energy.ts +++ b/src/data/energy.ts @@ -1,5 +1,8 @@ import { + addDays, addHours, + addMilliseconds, + addMonths, differenceInDays, endOfToday, endOfYesterday, @@ -14,9 +17,9 @@ import { ConfigEntry, getConfigEntries } from "./config_entries"; import { subscribeEntityRegistry } from "./entity_registry"; import { fetchStatistics, + getStatisticMetadata, Statistics, StatisticsMetaData, - getStatisticMetadata, } from "./history"; const energyCollectionKeys: (string | undefined)[] = []; @@ -232,19 +235,24 @@ export const energySourcesByType = (prefs: EnergyPreferences) => export interface EnergyData { start: Date; end?: Date; + startCompare?: Date; + endCompare?: Date; prefs: EnergyPreferences; info: EnergyInfo; stats: Statistics; + statsCompare: Statistics; co2SignalConfigEntry?: ConfigEntry; co2SignalEntity?: string; fossilEnergyConsumption?: FossilEnergyConsumption; + fossilEnergyConsumptionCompare?: FossilEnergyConsumption; } const getEnergyData = async ( hass: HomeAssistant, prefs: EnergyPreferences, start: Date, - end?: Date + end?: Date, + compare?: boolean ): Promise => { const [configEntries, entityRegistryEntries, info] = await Promise.all([ getConfigEntries(hass, { domain: "co2signal" }), @@ -350,6 +358,8 @@ const getEnergyData = async ( } const dayDifference = differenceInDays(end || new Date(), start); + const period = + dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour"; // Subtract 1 hour from start to get starting point data const startMinHour = addHours(start, -1); @@ -359,10 +369,34 @@ const getEnergyData = async ( startMinHour, end, statIDs, - dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour" + period ); + let statsCompare; + let startCompare; + let endCompare; + if (compare) { + if (dayDifference > 27 && dayDifference < 32) { + // When comparing a month, we want to start at the begining of the month + startCompare = addMonths(start, -1); + } else { + startCompare = addDays(start, (dayDifference + 1) * -1); + } + + const compareStartMinHour = addHours(startCompare, -1); + endCompare = addMilliseconds(start, -1); + + statsCompare = await fetchStatistics( + hass!, + compareStartMinHour, + endCompare, + statIDs, + period + ); + } + let fossilEnergyConsumption: FossilEnergyConsumption | undefined; + let fossilEnergyConsumptionCompare: FossilEnergyConsumption | undefined; if (co2SignalEntity !== undefined) { fossilEnergyConsumption = await getFossilEnergyConsumption( @@ -373,6 +407,16 @@ const getEnergyData = async ( end, dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour" ); + if (compare) { + fossilEnergyConsumptionCompare = await getFossilEnergyConsumption( + hass!, + startCompare, + consumptionStatIDs, + co2SignalEntity, + endCompare, + dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour" + ); + } } Object.values(stats).forEach((stat) => { @@ -388,15 +432,19 @@ const getEnergyData = async ( } }); - const data = { + const data: EnergyData = { start, end, + startCompare, + endCompare, info, prefs, stats, + statsCompare, co2SignalConfigEntry, co2SignalEntity, fossilEnergyConsumption, + fossilEnergyConsumptionCompare, }; return data; @@ -405,9 +453,11 @@ const getEnergyData = async ( export interface EnergyCollection extends Collection { start: Date; end?: Date; + compare?: boolean; prefs?: EnergyPreferences; clearPrefs(): void; setPeriod(newStart: Date, newEnd?: Date): void; + setCompare(compare: boolean): void; _refreshTimeout?: number; _updatePeriodTimeout?: number; _active: number; @@ -478,7 +528,8 @@ export const getEnergyDataCollection = ( hass, collection.prefs, collection.start, - collection.end + collection.end, + collection.compare ); } ) as EnergyCollection; @@ -534,6 +585,9 @@ export const getEnergyDataCollection = ( collection._updatePeriodTimeout = undefined; } }; + collection.setCompare = (compare: boolean) => { + collection.compare = compare; + }; return collection; }; diff --git a/src/panels/energy/ha-panel-energy.ts b/src/panels/energy/ha-panel-energy.ts index fe5c64e361..af114ad97b 100644 --- a/src/panels/energy/ha-panel-energy.ts +++ b/src/panels/energy/ha-panel-energy.ts @@ -1,7 +1,5 @@ import "@polymer/app-layout/app-header/app-header"; import "@polymer/app-layout/app-toolbar/app-toolbar"; -import "@material/mwc-tab"; -import "@material/mwc-tab-bar"; import { css, CSSResultGroup, @@ -12,14 +10,13 @@ import { } from "lit"; import { customElement, property, state } from "lit/decorators"; import "../../components/ha-menu-button"; -import "../../layouts/ha-app-layout"; - -import { haStyle } from "../../resources/styles"; -import "../lovelace/views/hui-view"; -import { HomeAssistant } from "../../types"; -import { Lovelace } from "../lovelace/types"; import { LovelaceConfig } from "../../data/lovelace"; +import "../../layouts/ha-app-layout"; +import { haStyle } from "../../resources/styles"; +import { HomeAssistant } from "../../types"; import "../lovelace/components/hui-energy-period-selector"; +import { Lovelace } from "../lovelace/types"; +import "../lovelace/views/hui-view"; const LOVELACE_CONFIG: LovelaceConfig = { views: [ diff --git a/src/panels/energy/strategies/energy-strategy.ts b/src/panels/energy/strategies/energy-strategy.ts index 4ba3125124..3283c7c8e1 100644 --- a/src/panels/energy/strategies/energy-strategy.ts +++ b/src/panels/energy/strategies/energy-strategy.ts @@ -60,6 +60,11 @@ export class EnergyStrategy { }); } + view.cards!.push({ + type: "energy-compare", + collection_key: "energy_dashboard", + }); + // Only include if we have a grid source. if (hasGrid) { view.cards!.push({ diff --git a/src/panels/lovelace/cards/energy/hui-energy-compare-card.ts b/src/panels/lovelace/cards/energy/hui-energy-compare-card.ts new file mode 100644 index 0000000000..0d49566b04 --- /dev/null +++ b/src/panels/lovelace/cards/energy/hui-energy-compare-card.ts @@ -0,0 +1,106 @@ +import { differenceInDays, endOfDay } from "date-fns"; +import { UnsubscribeFunc } from "home-assistant-js-websocket"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { formatDate } from "../../../../common/datetime/format_date"; +import { EnergyData, getEnergyDataCollection } from "../../../../data/energy"; +import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; +import { HomeAssistant } from "../../../../types"; +import { LovelaceCard } from "../../types"; +import { EnergyCardBaseConfig } from "../types"; + +@customElement("hui-energy-compare-card") +export class HuiEnergyCompareCard + extends SubscribeMixin(LitElement) + implements LovelaceCard +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _config?: EnergyCardBaseConfig; + + @state() private _start?: Date; + + @state() private _end?: Date; + + @state() private _startCompare?: Date; + + @state() private _endCompare?: Date; + + public getCardSize(): Promise | number { + return 1; + } + + public setConfig(config: EnergyCardBaseConfig): void { + this._config = config; + } + + protected hassSubscribeRequiredHostProps = ["_config"]; + + public hassSubscribe(): UnsubscribeFunc[] { + return [ + getEnergyDataCollection(this.hass, { + key: this._config!.collection_key, + }).subscribe((data) => this._update(data)), + ]; + } + + protected render(): TemplateResult { + if (!this._startCompare || !this._endCompare) { + return html``; + } + + const dayDifference = differenceInDays( + this._endCompare, + this._startCompare + ); + + return html` + + You are comparing the period + ${formatDate(this._start!, this.hass.locale)}${dayDifference > 0 + ? ` - + ${formatDate(this._end || endOfDay(new Date()), this.hass.locale)}` + : ""} + with period + ${formatDate(this._startCompare, this.hass.locale)}${dayDifference > + 0 + ? ` - + ${formatDate(this._endCompare, this.hass.locale)}` + : ""} + + `; + } + + private _update(data: EnergyData): void { + this._start = data.start; + this._end = data.end; + this._startCompare = data.startCompare; + this._endCompare = data.endCompare; + } + + private _stopCompare(): void { + const energyCollection = getEnergyDataCollection(this.hass, { + key: this._config!.collection_key, + }); + energyCollection.setCompare(false); + energyCollection.refresh(); + } + + static get styles(): CSSResultGroup { + return css` + mwc-button { + width: max-content; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-energy-compare-card": HuiEnergyCompareCard; + } +} diff --git a/src/panels/lovelace/cards/energy/hui-energy-date-selection-card.ts b/src/panels/lovelace/cards/energy/hui-energy-date-selection-card.ts index 5a2fdedbfd..d064e44d18 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-date-selection-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-date-selection-card.ts @@ -1,8 +1,8 @@ -import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { html, LitElement, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; import { HomeAssistant } from "../../../../types"; import { LovelaceCard } from "../../types"; -import { EnergyDevicesGraphCardConfig } from "../types"; +import { EnergyCardBaseConfig } from "../types"; import "../../components/hui-energy-period-selector"; @customElement("hui-energy-date-selection-card") @@ -12,13 +12,13 @@ export class HuiEnergyDateSelectionCard { @property({ attribute: false }) public hass!: HomeAssistant; - @state() private _config?: EnergyDevicesGraphCardConfig; + @state() private _config?: EnergyCardBaseConfig; public getCardSize(): Promise | number { return 1; } - public setConfig(config: EnergyDevicesGraphCardConfig): void { + public setConfig(config: EnergyCardBaseConfig): void { this._config = config; } @@ -34,10 +34,6 @@ export class HuiEnergyDateSelectionCard > `; } - - static get styles(): CSSResultGroup { - return css``; - } } declare global { diff --git a/src/panels/lovelace/cards/energy/hui-energy-gas-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-gas-graph-card.ts index 113f586fc8..17714b42f9 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-gas-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-gas-graph-card.ts @@ -1,9 +1,3 @@ -import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { customElement, property, state } from "lit/decorators"; -import { UnsubscribeFunc } from "home-assistant-js-websocket"; -import memoizeOne from "memoize-one"; -import { classMap } from "lit/directives/class-map"; -import "../../../../components/ha-card"; import { ChartData, ChartDataset, @@ -13,13 +7,16 @@ import { import { addHours, differenceInDays, + differenceInHours, endOfToday, isToday, startOfToday, -} from "date-fns/esm"; -import { HomeAssistant } from "../../../../types"; -import { LovelaceCard } from "../../types"; -import { EnergyGasGraphCardConfig } from "../types"; +} from "date-fns"; +import { UnsubscribeFunc } from "home-assistant-js-websocket"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import memoizeOne from "memoize-one"; import { hex2rgb, lab2rgb, @@ -27,21 +24,27 @@ import { rgb2lab, } from "../../../../common/color/convert-color"; import { labBrighten, labDarken } from "../../../../common/color/lab"; -import { - EnergyData, - getEnergyDataCollection, - getEnergyGasUnit, - GasSourceTypeEnergyPreference, -} from "../../../../data/energy"; +import { formatDateShort } from "../../../../common/datetime/format_date"; +import { formatTime } from "../../../../common/datetime/format_time"; import { computeStateName } from "../../../../common/entity/compute_state_name"; -import "../../../../components/chart/ha-chart-base"; import { formatNumber, numberFormatToLocale, } from "../../../../common/number/format_number"; -import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; +import "../../../../components/chart/ha-chart-base"; +import "../../../../components/ha-card"; +import { + EnergyData, + GasSourceTypeEnergyPreference, + getEnergyDataCollection, + getEnergyGasUnit, +} from "../../../../data/energy"; +import { Statistics } from "../../../../data/history"; import { FrontendLocaleData } from "../../../../data/translation"; -import { formatTime } from "../../../../common/datetime/format_time"; +import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; +import { HomeAssistant } from "../../../../types"; +import { LovelaceCard } from "../../types"; +import { EnergyGasGraphCardConfig } from "../types"; @customElement("hui-energy-gas-graph-card") export class HuiEnergyGasGraphCard @@ -60,6 +63,10 @@ export class HuiEnergyGasGraphCard @state() private _end = endOfToday(); + @state() private _compareStart?: Date; + + @state() private _compareEnd?: Date; + @state() private _unit?: string; protected hassSubscribeRequiredHostProps = ["_config"]; @@ -101,7 +108,9 @@ export class HuiEnergyGasGraphCard this._start, this._end, this.hass.locale, - this._unit + this._unit, + this._compareStart, + this._compareEnd )} chart-type="bar" > @@ -124,10 +133,24 @@ export class HuiEnergyGasGraphCard start: Date, end: Date, locale: FrontendLocaleData, - unit?: string + unit?: string, + compareStart?: Date, + compareEnd?: Date ): ChartOptions => { const dayDifference = differenceInDays(end, start); - return { + const compare = compareStart !== undefined && compareEnd !== undefined; + if (compare) { + const difference = differenceInHours(end, start); + const differenceCompare = differenceInHours(compareEnd!, compareStart!); + // If the compare period doesn't match the main period, adjust them to match + if (differenceCompare > difference) { + end = addHours(end, differenceCompare - difference); + } else if (difference > differenceCompare) { + compareEnd = addHours(compareEnd!, difference - differenceCompare); + } + } + + const options: ChartOptions = { parsing: false, animation: false, scales: { @@ -193,7 +216,9 @@ export class HuiEnergyGasGraphCard return datasets[0].label; } const date = new Date(datasets[0].parsed.x); - return `${formatTime(date, locale)} – ${formatTime( + return `${ + compare ? `${formatDateShort(date, locale)}: ` : "" + }${formatTime(date, locale)} – ${formatTime( addHours(date, 1), locale )}`; @@ -227,6 +252,15 @@ export class HuiEnergyGasGraphCard // @ts-expect-error locale: numberFormatToLocale(locale), }; + if (compare) { + options.scales!.xAxisCompare = { + ...(options.scales!.x as Record), + suggestedMin: compareStart!.getTime(), + suggestedMax: compareEnd!.getTime(), + display: false, + }; + } + return options; } ); @@ -238,15 +272,58 @@ export class HuiEnergyGasGraphCard this._unit = getEnergyGasUnit(this.hass, energyData.prefs) || "m³"; - const datasets: ChartDataset<"bar">[] = []; + const datasets: ChartDataset<"bar", ScatterDataPoint[]>[] = []; const computedStyles = getComputedStyle(this); const gasColor = computedStyles .getPropertyValue("--energy-gas-color") .trim(); + datasets.push( + ...this._processDataSet(energyData.stats, gasSources, gasColor) + ); + + if (energyData.statsCompare) { + // Add empty dataset to align the bars + datasets.push({ + order: 0, + data: [], + }); + datasets.push({ + order: 999, + data: [], + xAxisID: "xAxisCompare", + }); + + datasets.push( + ...this._processDataSet( + energyData.statsCompare, + gasSources, + gasColor, + true + ) + ); + } + + this._start = energyData.start; + this._end = energyData.end || endOfToday(); + + this._compareStart = energyData.startCompare; + this._compareEnd = energyData.endCompare; + + this._chartData = { + datasets, + }; + } + + private _processDataSet( + statistics: Statistics, + gasSources: GasSourceTypeEnergyPreference[], + gasColor: string, + compare = false + ) { + const data: ChartDataset<"bar", ScatterDataPoint[]>[] = []; gasSources.forEach((source, idx) => { - const data: ChartDataset<"bar" | "line">[] = []; const entity = this.hass.states[source.stat_energy_from]; const modifiedColor = @@ -265,8 +342,8 @@ export class HuiEnergyGasGraphCard const gasConsumptionData: ScatterDataPoint[] = []; // Process gas consumption data. - if (source.stat_energy_from in energyData.stats) { - const stats = energyData.stats[source.stat_energy_from]; + if (source.stat_energy_from in statistics) { + const stats = statistics[source.stat_energy_from]; for (const point of stats) { if (point.sum === null) { @@ -290,26 +367,17 @@ export class HuiEnergyGasGraphCard } } - if (gasConsumptionData.length) { - data.push({ - label: entity ? computeStateName(entity) : source.stat_energy_from, - borderColor, - backgroundColor: borderColor + "7F", - data: gasConsumptionData, - stack: "gas", - }); - } - - // Concat two arrays - Array.prototype.push.apply(datasets, data); + data.push({ + label: entity ? computeStateName(entity) : source.stat_energy_from, + borderColor: compare ? borderColor + "7F" : borderColor, + backgroundColor: compare ? borderColor + "32" : borderColor + "7F", + data: gasConsumptionData, + order: 1, + stack: "gas", + xAxisID: compare ? "xAxisCompare" : undefined, + }); }); - - this._start = energyData.start; - this._end = energyData.end || endOfToday(); - - this._chartData = { - datasets, - }; + return data; } static get styles(): CSSResultGroup { diff --git a/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts index 5c6c2f537c..f3389b657d 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts @@ -7,6 +7,7 @@ import { import { addHours, differenceInDays, + differenceInHours, endOfToday, isToday, startOfToday, @@ -23,6 +24,7 @@ import { rgb2lab, } from "../../../../common/color/convert-color"; import { labBrighten, labDarken } from "../../../../common/color/lab"; +import { formatDateShort } from "../../../../common/datetime/format_date"; import { formatTime } from "../../../../common/datetime/format_time"; import { computeStateName } from "../../../../common/entity/compute_state_name"; import { @@ -38,6 +40,7 @@ import { getEnergySolarForecasts, SolarSourceTypeEnergyPreference, } from "../../../../data/energy"; +import { Statistics } from "../../../../data/history"; import { FrontendLocaleData } from "../../../../data/translation"; import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; import { HomeAssistant } from "../../../../types"; @@ -61,6 +64,10 @@ export class HuiEnergySolarGraphCard @state() private _end = endOfToday(); + @state() private _compareStart?: Date; + + @state() private _compareEnd?: Date; + protected hassSubscribeRequiredHostProps = ["_config"]; public hassSubscribe(): UnsubscribeFunc[] { @@ -99,7 +106,9 @@ export class HuiEnergySolarGraphCard .options=${this._createOptions( this._start, this._end, - this.hass.locale + this.hass.locale, + this._compareStart, + this._compareEnd )} chart-type="bar" > @@ -118,9 +127,27 @@ export class HuiEnergySolarGraphCard } private _createOptions = memoizeOne( - (start: Date, end: Date, locale: FrontendLocaleData): ChartOptions => { + ( + start: Date, + end: Date, + locale: FrontendLocaleData, + compareStart?: Date, + compareEnd?: Date + ): ChartOptions => { const dayDifference = differenceInDays(end, start); - return { + const compare = compareStart !== undefined && compareEnd !== undefined; + if (compare) { + const difference = differenceInHours(end, start); + const differenceCompare = differenceInHours(compareEnd!, compareStart!); + // If the compare period doesn't match the main period, adjust them to match + if (differenceCompare > difference) { + end = addHours(end, differenceCompare - difference); + } else if (difference > differenceCompare) { + compareEnd = addHours(compareEnd!, difference - differenceCompare); + } + } + + const options: ChartOptions = { parsing: false, animation: false, scales: { @@ -163,7 +190,6 @@ export class HuiEnergySolarGraphCard ? "day" : "hour", }, - offset: true, }, y: { stacked: true, @@ -186,7 +212,9 @@ export class HuiEnergySolarGraphCard return datasets[0].label; } const date = new Date(datasets[0].parsed.x); - return `${formatTime(date, locale)} – ${formatTime( + return `${ + compare ? `${formatDateShort(date, locale)}: ` : "" + }${formatTime(date, locale)} – ${formatTime( addHours(date, 1), locale )}`; @@ -224,6 +252,15 @@ export class HuiEnergySolarGraphCard // @ts-expect-error locale: numberFormatToLocale(locale), }; + if (compare) { + options.scales!.xAxisCompare = { + ...(options.scales!.x as Record), + suggestedMin: compareStart!.getTime(), + suggestedMax: compareEnd!.getTime(), + display: false, + }; + } + return options; } ); @@ -244,20 +281,71 @@ export class HuiEnergySolarGraphCard } } - const datasets: ChartDataset<"bar">[] = []; + const datasets: ChartDataset<"bar" | "line">[] = []; const computedStyles = getComputedStyle(this); const solarColor = computedStyles .getPropertyValue("--energy-solar-color") .trim(); - const dayDifference = differenceInDays( - energyData.end || new Date(), - energyData.start + datasets.push( + ...this._processDataSet(energyData.stats, solarSources, solarColor) ); + if (energyData.statsCompare) { + // Add empty dataset to align the bars + datasets.push({ + order: 0, + data: [], + }); + datasets.push({ + order: 999, + data: [], + xAxisID: "xAxisCompare", + }); + + datasets.push( + ...this._processDataSet( + energyData.statsCompare, + solarSources, + solarColor, + true + ) + ); + } + + if (forecasts) { + datasets.push( + ...this._processForecast( + forecasts, + solarSources, + computedStyles.getPropertyValue("--primary-text-color"), + energyData.start, + energyData.end + ) + ); + } + + this._start = energyData.start; + this._end = energyData.end || endOfToday(); + + this._compareStart = energyData.startCompare; + this._compareEnd = energyData.endCompare; + + this._chartData = { + datasets, + }; + } + + private _processDataSet( + statistics: Statistics, + solarSources: SolarSourceTypeEnergyPreference[], + solarColor: string, + compare = false + ) { + const data: ChartDataset<"bar", ScatterDataPoint[]>[] = []; + solarSources.forEach((source, idx) => { - const data: ChartDataset<"bar" | "line">[] = []; const entity = this.hass.states[source.stat_energy_from]; const modifiedColor = @@ -276,8 +364,8 @@ export class HuiEnergySolarGraphCard const solarProductionData: ScatterDataPoint[] = []; // Process solar production data. - if (source.stat_energy_from in energyData.stats) { - const stats = energyData.stats[source.stat_energy_from]; + if (source.stat_energy_from in statistics) { + const stats = statistics[source.stat_energy_from]; for (const point of stats) { if (point.sum === null) { @@ -301,23 +389,41 @@ export class HuiEnergySolarGraphCard } } - if (solarProductionData.length) { - data.push({ - label: this.hass.localize( - "ui.panel.lovelace.cards.energy.energy_solar_graph.production", - { - name: entity ? computeStateName(entity) : source.stat_energy_from, - } - ), - borderColor, - backgroundColor: borderColor + "7F", - data: solarProductionData, - stack: "solar", - }); - } + data.push({ + label: this.hass.localize( + "ui.panel.lovelace.cards.energy.energy_solar_graph.production", + { + name: entity ? computeStateName(entity) : source.stat_energy_from, + } + ), + borderColor: compare ? borderColor + "7F" : borderColor, + backgroundColor: compare ? borderColor + "32" : borderColor + "7F", + data: solarProductionData, + order: 1, + stack: "solar", + xAxisID: compare ? "xAxisCompare" : undefined, + }); + }); + + return data; + } + + private _processForecast( + forecasts: EnergySolarForecasts, + solarSources: SolarSourceTypeEnergyPreference[], + borderColor: string, + start: Date, + end?: Date + ) { + const data: ChartDataset<"line">[] = []; + + const dayDifference = differenceInDays(end || new Date(), start); + + // Process solar forecast data. + solarSources.forEach((source) => { + if (source.config_entry_solar_forecast) { + const entity = this.hass.states[source.stat_energy_from]; - // Process solar forecast data. - if (forecasts && source.config_entry_solar_forecast) { const forecastsData: Record | undefined = {}; source.config_entry_solar_forecast.forEach((configEntryId) => { if (!forecasts![configEntryId]) { @@ -326,10 +432,7 @@ export class HuiEnergySolarGraphCard Object.entries(forecasts![configEntryId].wh_hours).forEach( ([date, value]) => { const dateObj = new Date(date); - if ( - dateObj < energyData.start || - (energyData.end && dateObj > energyData.end) - ) { + if (dateObj < start || (end && dateObj > end)) { return; } if (dayDifference > 35) { @@ -372,9 +475,7 @@ export class HuiEnergySolarGraphCard ), fill: false, stepped: false, - borderColor: computedStyles.getPropertyValue( - "--primary-text-color" - ), + borderColor, borderDash: [7, 5], pointRadius: 0, data: solarForecastData, @@ -382,17 +483,9 @@ export class HuiEnergySolarGraphCard } } } - - // Concat two arrays - Array.prototype.push.apply(datasets, data); }); - this._start = energyData.start; - this._end = energyData.end || endOfToday(); - - this._chartData = { - datasets, - }; + return data; } static get styles(): CSSResultGroup { diff --git a/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts index 8ba70e0269..eea121b0f9 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts @@ -1,7 +1,13 @@ -import { ChartData, ChartDataset, ChartOptions } from "chart.js"; +import { + ChartData, + ChartDataset, + ChartOptions, + ScatterDataPoint, +} from "chart.js"; import { addHours, differenceInDays, + differenceInHours, endOfToday, isToday, startOfToday, @@ -18,6 +24,7 @@ import { rgb2lab, } from "../../../../common/color/convert-color"; import { labBrighten, labDarken } from "../../../../common/color/lab"; +import { formatDateShort } from "../../../../common/datetime/format_date"; import { formatTime } from "../../../../common/datetime/format_time"; import { computeStateName } from "../../../../common/entity/compute_state_name"; import { @@ -27,6 +34,7 @@ import { import "../../../../components/chart/ha-chart-base"; import "../../../../components/ha-card"; import { EnergyData, getEnergyDataCollection } from "../../../../data/energy"; +import { Statistics } from "../../../../data/history"; import { FrontendLocaleData } from "../../../../data/translation"; import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; import { HomeAssistant } from "../../../../types"; @@ -50,6 +58,10 @@ export class HuiEnergyUsageGraphCard @state() private _end = endOfToday(); + @state() private _compareStart?: Date; + + @state() private _compareEnd?: Date; + protected hassSubscribeRequiredHostProps = ["_config"]; public hassSubscribe(): UnsubscribeFunc[] { @@ -88,7 +100,9 @@ export class HuiEnergyUsageGraphCard .options=${this._createOptions( this._start, this._end, - this.hass.locale + this.hass.locale, + this._compareStart, + this._compareEnd )} chart-type="bar" > @@ -107,9 +121,27 @@ export class HuiEnergyUsageGraphCard } private _createOptions = memoizeOne( - (start: Date, end: Date, locale: FrontendLocaleData): ChartOptions => { + ( + start: Date, + end: Date, + locale: FrontendLocaleData, + compareStart?: Date, + compareEnd?: Date + ): ChartOptions => { const dayDifference = differenceInDays(end, start); - return { + const compare = compareStart !== undefined && compareEnd !== undefined; + if (compare) { + const difference = differenceInHours(end, start); + const differenceCompare = differenceInHours(compareEnd!, compareStart!); + // If the compare period doesn't match the main period, adjust them to match + if (differenceCompare > difference) { + end = addHours(end, differenceCompare - difference); + } else if (difference > differenceCompare) { + compareEnd = addHours(compareEnd!, difference - differenceCompare); + } + } + + const options: ChartOptions = { parsing: false, animation: false, scales: { @@ -152,7 +184,6 @@ export class HuiEnergyUsageGraphCard ? "day" : "hour", }, - offset: true, }, y: { stacked: true, @@ -179,7 +210,9 @@ export class HuiEnergyUsageGraphCard return datasets[0].label; } const date = new Date(datasets[0].parsed.x); - return `${formatTime(date, locale)} – ${formatTime( + return `${ + compare ? `${formatDateShort(date, locale)}: ` : "" + }${formatTime(date, locale)} – ${formatTime( addHours(date, 1), locale )}`; @@ -240,13 +273,22 @@ export class HuiEnergyUsageGraphCard // @ts-expect-error locale: numberFormatToLocale(locale), }; + if (compare) { + options.scales!.xAxisCompare = { + ...(options.scales!.x as Record), + suggestedMin: compareStart!.getTime(), + suggestedMax: compareEnd!.getTime(), + display: false, + }; + } + return options; } ); private async _getStatistics(energyData: EnergyData): Promise { - const datasets: ChartDataset<"bar">[] = []; + const datasets: ChartDataset<"bar", ScatterDataPoint[]>[] = []; - const statistics: { + const statIds: { to_grid?: string[]; from_grid?: string[]; solar?: string[]; @@ -256,21 +298,21 @@ export class HuiEnergyUsageGraphCard for (const source of energyData.prefs.energy_sources) { if (source.type === "solar") { - if (statistics.solar) { - statistics.solar.push(source.stat_energy_from); + if (statIds.solar) { + statIds.solar.push(source.stat_energy_from); } else { - statistics.solar = [source.stat_energy_from]; + statIds.solar = [source.stat_energy_from]; } continue; } if (source.type === "battery") { - if (statistics.to_battery) { - statistics.to_battery.push(source.stat_energy_to); - statistics.from_battery!.push(source.stat_energy_from); + if (statIds.to_battery) { + statIds.to_battery.push(source.stat_energy_to); + statIds.from_battery!.push(source.stat_energy_from); } else { - statistics.to_battery = [source.stat_energy_to]; - statistics.from_battery = [source.stat_energy_from]; + statIds.to_battery = [source.stat_energy_to]; + statIds.from_battery = [source.stat_energy_from]; } continue; } @@ -281,41 +323,21 @@ export class HuiEnergyUsageGraphCard // grid source for (const flowFrom of source.flow_from) { - if (statistics.from_grid) { - statistics.from_grid.push(flowFrom.stat_energy_from); + if (statIds.from_grid) { + statIds.from_grid.push(flowFrom.stat_energy_from); } else { - statistics.from_grid = [flowFrom.stat_energy_from]; + statIds.from_grid = [flowFrom.stat_energy_from]; } } for (const flowTo of source.flow_to) { - if (statistics.to_grid) { - statistics.to_grid.push(flowTo.stat_energy_to); + if (statIds.to_grid) { + statIds.to_grid.push(flowTo.stat_energy_to); } else { - statistics.to_grid = [flowTo.stat_energy_to]; + statIds.to_grid = [flowTo.stat_energy_to]; } } } - this._start = energyData.start; - this._end = energyData.end || endOfToday(); - - const combinedData: { - to_grid?: { [statId: string]: { [start: string]: number } }; - to_battery?: { [statId: string]: { [start: string]: number } }; - from_grid?: { [statId: string]: { [start: string]: number } }; - used_grid?: { [statId: string]: { [start: string]: number } }; - used_solar?: { [statId: string]: { [start: string]: number } }; - used_battery?: { [statId: string]: { [start: string]: number } }; - } = {}; - - const summedData: { - to_grid?: { [start: string]: number }; - from_grid?: { [start: string]: number }; - to_battery?: { [start: string]: number }; - from_battery?: { [start: string]: number }; - solar?: { [start: string]: number }; - } = {}; - const computedStyles = getComputedStyle(this); const colors = { to_grid: computedStyles @@ -349,7 +371,88 @@ export class HuiEnergyUsageGraphCard ), }; - Object.entries(statistics).forEach(([key, statIds]) => { + this._start = energyData.start; + this._end = energyData.end || endOfToday(); + + this._compareStart = energyData.startCompare; + this._compareEnd = energyData.endCompare; + + datasets.push( + ...this._processDataSet(energyData.stats, statIds, colors, labels, false) + ); + + if (energyData.statsCompare) { + // Add empty dataset to align the bars + datasets.push({ + order: 0, + data: [], + }); + datasets.push({ + order: 999, + data: [], + xAxisID: "xAxisCompare", + }); + + datasets.push( + ...this._processDataSet( + energyData.statsCompare, + statIds, + colors, + labels, + true + ) + ); + } + + this._chartData = { + datasets, + }; + } + + private _processDataSet( + statistics: Statistics, + statIdsByCat: { + to_grid?: string[] | undefined; + from_grid?: string[] | undefined; + solar?: string[] | undefined; + to_battery?: string[] | undefined; + from_battery?: string[] | undefined; + }, + colors: { + to_grid: string; + to_battery: string; + from_grid: string; + used_grid: string; + used_solar: string; + used_battery: string; + }, + labels: { + used_grid: string; + used_solar: string; + used_battery: string; + }, + compare = false + ) { + const data: ChartDataset<"bar", ScatterDataPoint[]>[] = []; + + const combinedData: { + to_grid?: { [statId: string]: { [start: string]: number } }; + to_battery?: { [statId: string]: { [start: string]: number } }; + from_grid?: { [statId: string]: { [start: string]: number } }; + used_grid?: { [statId: string]: { [start: string]: number } }; + used_solar?: { [statId: string]: { [start: string]: number } }; + used_battery?: { [statId: string]: { [start: string]: number } }; + } = {}; + + const summedData: { + to_grid?: { [start: string]: number }; + from_grid?: { [start: string]: number }; + to_battery?: { [start: string]: number }; + from_battery?: { [start: string]: number }; + solar?: { [start: string]: number }; + } = {}; + + Object.entries(statIdsByCat).forEach(([key, statIds]) => { const sum = [ "solar", "to_grid", @@ -361,7 +464,7 @@ export class HuiEnergyUsageGraphCard const totalStats: { [start: string]: number } = {}; const sets: { [statId: string]: { [start: string]: number } } = {}; statIds!.forEach((id) => { - const stats = energyData.stats[id]; + const stats = statistics[id]; if (!stats) { return; } @@ -477,7 +580,6 @@ export class HuiEnergyUsageGraphCard Object.entries(combinedData).forEach(([type, sources]) => { Object.entries(sources).forEach(([statId, source], idx) => { - const data: ChartDataset<"bar">[] = []; const entity = this.hass.states[statId]; const modifiedColor = @@ -490,6 +592,20 @@ export class HuiEnergyUsageGraphCard ? rgb2hex(lab2rgb(modifiedColor)) : colors[type]; + const points: ScatterDataPoint[] = []; + // Process chart data. + for (const key of uniqueKeys) { + const value = source[key] || 0; + const date = new Date(key); + points.push({ + x: date.getTime(), + y: + value && ["to_grid", "to_battery"].includes(type) + ? -1 * value + : value, + }); + } + data.push({ label: type in labels @@ -499,38 +615,19 @@ export class HuiEnergyUsageGraphCard : statId, order: type === "used_solar" - ? 0 + ? 1 : type === "to_battery" ? Object.keys(combinedData).length - : idx + 1, - borderColor, - backgroundColor: borderColor + "7F", + : idx + 2, + borderColor: compare ? borderColor + "7F" : borderColor, + backgroundColor: compare ? borderColor + "32" : borderColor + "7F", stack: "stack", - data: [], + data: points, + xAxisID: compare ? "xAxisCompare" : undefined, }); - - // Process chart data. - for (const key of uniqueKeys) { - const value = source[key] || 0; - const date = new Date(key); - // @ts-expect-error - data[0].data.push({ - x: date.getTime(), - y: - value && ["to_grid", "to_battery"].includes(type) - ? -1 * value - : value, - }); - } - - // Concat two arrays - Array.prototype.push.apply(datasets, data); }); }); - - this._chartData = { - datasets, - }; + return data; } static get styles(): CSSResultGroup { diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index b4c2a74abc..f596046e63 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -97,6 +97,10 @@ export interface ButtonCardConfig extends LovelaceCardConfig { show_state?: boolean; } +export interface EnergyCardBaseConfig extends LovelaceCardConfig { + collection_key?: string; +} + export interface EnergySummaryCardConfig extends LovelaceCardConfig { type: "energy-summary"; title?: string; diff --git a/src/panels/lovelace/components/hui-energy-period-selector.ts b/src/panels/lovelace/components/hui-energy-period-selector.ts index f6283af3f2..cb906d8758 100644 --- a/src/panels/lovelace/components/hui-energy-period-selector.ts +++ b/src/panels/lovelace/components/hui-energy-period-selector.ts @@ -46,6 +46,8 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { @state() private _period?: "day" | "week" | "month" | "year"; + @state() private _compare? = false; + public connectedCallback() { super.connectedCallback(); toggleAttribute(this, "narrow", this.offsetWidth < 600); @@ -134,6 +136,14 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { dense @value-changed=${this._handleView} > + + Compare data + `; @@ -216,6 +226,7 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { } private _updateDates(energyData: EnergyData): void { + this._compare = energyData.startCompare !== undefined; this._startDate = energyData.start; this._endDate = energyData.end || endOfToday(); const dayDifference = differenceInDays(this._endDate, this._startDate); @@ -231,6 +242,15 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { : undefined; } + private _toggleCompare() { + this._compare = !this._compare; + const energyCollection = getEnergyDataCollection(this.hass, { + key: "energy_dashboard", + }); + energyCollection.setCompare(this._compare); + energyCollection.refresh(); + } + static get styles(): CSSResultGroup { return css` .row { @@ -251,12 +271,37 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { } .period { display: flex; + flex-wrap: wrap; justify-content: flex-end; + align-items: flex-end; + } + mwc-button.active::before { + top: 0; + left: 0; + width: 100%; + height: 100%; + position: absolute; + background-color: currentColor; + opacity: 0; + pointer-events: none; + content: ""; + transition: opacity 15ms linear, background-color 15ms linear; + opacity: var(--mdc-icon-button-ripple-opacity, 0.12); + } + .compare { + position: relative; + margin-left: 8px; + width: max-content; + } + :host([narrow]) .compare { + margin-left: auto; + margin-top: 8px; } :host { --mdc-button-outline-color: currentColor; --primary-color: currentColor; --mdc-theme-primary: currentColor; + --mdc-theme-on-primary: currentColor; --mdc-button-disabled-outline-color: var(--disabled-text-color); --mdc-button-disabled-ink-color: var(--disabled-text-color); --mdc-icon-button-ripple-opacity: 0.2; diff --git a/src/panels/lovelace/create-element/create-card-element.ts b/src/panels/lovelace/create-element/create-card-element.ts index df2eb3b45d..03f22a9bdd 100644 --- a/src/panels/lovelace/create-element/create-card-element.ts +++ b/src/panels/lovelace/create-element/create-card-element.ts @@ -35,6 +35,7 @@ const LAZY_LOAD_TYPES = { calendar: () => import("../cards/hui-calendar-card"), conditional: () => import("../cards/hui-conditional-card"), "empty-state": () => import("../cards/hui-empty-state-card"), + "energy-compare": () => import("../cards/energy/hui-energy-compare-card"), "energy-carbon-consumed-gauge": () => import("../cards/energy/hui-energy-carbon-consumed-gauge-card"), "energy-date-selection": () =>