Add option to compare energy graphs with previous period (#12723)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
Bram Kragten 2022-05-20 06:20:18 +02:00 committed by GitHub
parent a0a7ce014f
commit 6ab19d66d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 648 additions and 182 deletions

View File

@ -1,5 +1,8 @@
import { import {
addDays,
addHours, addHours,
addMilliseconds,
addMonths,
differenceInDays, differenceInDays,
endOfToday, endOfToday,
endOfYesterday, endOfYesterday,
@ -14,9 +17,9 @@ import { ConfigEntry, getConfigEntries } from "./config_entries";
import { subscribeEntityRegistry } from "./entity_registry"; import { subscribeEntityRegistry } from "./entity_registry";
import { import {
fetchStatistics, fetchStatistics,
getStatisticMetadata,
Statistics, Statistics,
StatisticsMetaData, StatisticsMetaData,
getStatisticMetadata,
} from "./history"; } from "./history";
const energyCollectionKeys: (string | undefined)[] = []; const energyCollectionKeys: (string | undefined)[] = [];
@ -232,19 +235,24 @@ export const energySourcesByType = (prefs: EnergyPreferences) =>
export interface EnergyData { export interface EnergyData {
start: Date; start: Date;
end?: Date; end?: Date;
startCompare?: Date;
endCompare?: Date;
prefs: EnergyPreferences; prefs: EnergyPreferences;
info: EnergyInfo; info: EnergyInfo;
stats: Statistics; stats: Statistics;
statsCompare: Statistics;
co2SignalConfigEntry?: ConfigEntry; co2SignalConfigEntry?: ConfigEntry;
co2SignalEntity?: string; co2SignalEntity?: string;
fossilEnergyConsumption?: FossilEnergyConsumption; fossilEnergyConsumption?: FossilEnergyConsumption;
fossilEnergyConsumptionCompare?: FossilEnergyConsumption;
} }
const getEnergyData = async ( const getEnergyData = async (
hass: HomeAssistant, hass: HomeAssistant,
prefs: EnergyPreferences, prefs: EnergyPreferences,
start: Date, start: Date,
end?: Date end?: Date,
compare?: boolean
): Promise<EnergyData> => { ): Promise<EnergyData> => {
const [configEntries, entityRegistryEntries, info] = await Promise.all([ const [configEntries, entityRegistryEntries, info] = await Promise.all([
getConfigEntries(hass, { domain: "co2signal" }), getConfigEntries(hass, { domain: "co2signal" }),
@ -350,6 +358,8 @@ const getEnergyData = async (
} }
const dayDifference = differenceInDays(end || new Date(), start); 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 // Subtract 1 hour from start to get starting point data
const startMinHour = addHours(start, -1); const startMinHour = addHours(start, -1);
@ -359,10 +369,34 @@ const getEnergyData = async (
startMinHour, startMinHour,
end, end,
statIDs, 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 fossilEnergyConsumption: FossilEnergyConsumption | undefined;
let fossilEnergyConsumptionCompare: FossilEnergyConsumption | undefined;
if (co2SignalEntity !== undefined) { if (co2SignalEntity !== undefined) {
fossilEnergyConsumption = await getFossilEnergyConsumption( fossilEnergyConsumption = await getFossilEnergyConsumption(
@ -373,6 +407,16 @@ const getEnergyData = async (
end, end,
dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour" 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) => { Object.values(stats).forEach((stat) => {
@ -388,15 +432,19 @@ const getEnergyData = async (
} }
}); });
const data = { const data: EnergyData = {
start, start,
end, end,
startCompare,
endCompare,
info, info,
prefs, prefs,
stats, stats,
statsCompare,
co2SignalConfigEntry, co2SignalConfigEntry,
co2SignalEntity, co2SignalEntity,
fossilEnergyConsumption, fossilEnergyConsumption,
fossilEnergyConsumptionCompare,
}; };
return data; return data;
@ -405,9 +453,11 @@ const getEnergyData = async (
export interface EnergyCollection extends Collection<EnergyData> { export interface EnergyCollection extends Collection<EnergyData> {
start: Date; start: Date;
end?: Date; end?: Date;
compare?: boolean;
prefs?: EnergyPreferences; prefs?: EnergyPreferences;
clearPrefs(): void; clearPrefs(): void;
setPeriod(newStart: Date, newEnd?: Date): void; setPeriod(newStart: Date, newEnd?: Date): void;
setCompare(compare: boolean): void;
_refreshTimeout?: number; _refreshTimeout?: number;
_updatePeriodTimeout?: number; _updatePeriodTimeout?: number;
_active: number; _active: number;
@ -478,7 +528,8 @@ export const getEnergyDataCollection = (
hass, hass,
collection.prefs, collection.prefs,
collection.start, collection.start,
collection.end collection.end,
collection.compare
); );
} }
) as EnergyCollection; ) as EnergyCollection;
@ -534,6 +585,9 @@ export const getEnergyDataCollection = (
collection._updatePeriodTimeout = undefined; collection._updatePeriodTimeout = undefined;
} }
}; };
collection.setCompare = (compare: boolean) => {
collection.compare = compare;
};
return collection; return collection;
}; };

View File

@ -1,7 +1,5 @@
import "@polymer/app-layout/app-header/app-header"; import "@polymer/app-layout/app-header/app-header";
import "@polymer/app-layout/app-toolbar/app-toolbar"; import "@polymer/app-layout/app-toolbar/app-toolbar";
import "@material/mwc-tab";
import "@material/mwc-tab-bar";
import { import {
css, css,
CSSResultGroup, CSSResultGroup,
@ -12,14 +10,13 @@ import {
} from "lit"; } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import "../../components/ha-menu-button"; 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 { 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/components/hui-energy-period-selector";
import { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
const LOVELACE_CONFIG: LovelaceConfig = { const LOVELACE_CONFIG: LovelaceConfig = {
views: [ views: [

View File

@ -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. // Only include if we have a grid source.
if (hasGrid) { if (hasGrid) {
view.cards!.push({ view.cards!.push({

View File

@ -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> | 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`
<ha-alert dismissable @alert-dismissed-clicked=${this._stopCompare}>
You are comparing the period
<b
>${formatDate(this._start!, this.hass.locale)}${dayDifference > 0
? ` -
${formatDate(this._end || endOfDay(new Date()), this.hass.locale)}`
: ""}</b
>
with period
<b
>${formatDate(this._startCompare, this.hass.locale)}${dayDifference >
0
? ` -
${formatDate(this._endCompare, this.hass.locale)}`
: ""}</b
>
</ha-alert>
`;
}
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;
}
}

View File

@ -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 { customElement, property, state } from "lit/decorators";
import { HomeAssistant } from "../../../../types"; import { HomeAssistant } from "../../../../types";
import { LovelaceCard } from "../../types"; import { LovelaceCard } from "../../types";
import { EnergyDevicesGraphCardConfig } from "../types"; import { EnergyCardBaseConfig } from "../types";
import "../../components/hui-energy-period-selector"; import "../../components/hui-energy-period-selector";
@customElement("hui-energy-date-selection-card") @customElement("hui-energy-date-selection-card")
@ -12,13 +12,13 @@ export class HuiEnergyDateSelectionCard
{ {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: EnergyDevicesGraphCardConfig; @state() private _config?: EnergyCardBaseConfig;
public getCardSize(): Promise<number> | number { public getCardSize(): Promise<number> | number {
return 1; return 1;
} }
public setConfig(config: EnergyDevicesGraphCardConfig): void { public setConfig(config: EnergyCardBaseConfig): void {
this._config = config; this._config = config;
} }
@ -34,10 +34,6 @@ export class HuiEnergyDateSelectionCard
></hui-energy-period-selector> ></hui-energy-period-selector>
`; `;
} }
static get styles(): CSSResultGroup {
return css``;
}
} }
declare global { declare global {

View File

@ -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 { import {
ChartData, ChartData,
ChartDataset, ChartDataset,
@ -13,13 +7,16 @@ import {
import { import {
addHours, addHours,
differenceInDays, differenceInDays,
differenceInHours,
endOfToday, endOfToday,
isToday, isToday,
startOfToday, startOfToday,
} from "date-fns/esm"; } from "date-fns";
import { HomeAssistant } from "../../../../types"; import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { LovelaceCard } from "../../types"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { EnergyGasGraphCardConfig } from "../types"; import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { import {
hex2rgb, hex2rgb,
lab2rgb, lab2rgb,
@ -27,21 +24,27 @@ import {
rgb2lab, rgb2lab,
} from "../../../../common/color/convert-color"; } from "../../../../common/color/convert-color";
import { labBrighten, labDarken } from "../../../../common/color/lab"; import { labBrighten, labDarken } from "../../../../common/color/lab";
import { import { formatDateShort } from "../../../../common/datetime/format_date";
EnergyData, import { formatTime } from "../../../../common/datetime/format_time";
getEnergyDataCollection,
getEnergyGasUnit,
GasSourceTypeEnergyPreference,
} from "../../../../data/energy";
import { computeStateName } from "../../../../common/entity/compute_state_name"; import { computeStateName } from "../../../../common/entity/compute_state_name";
import "../../../../components/chart/ha-chart-base";
import { import {
formatNumber, formatNumber,
numberFormatToLocale, numberFormatToLocale,
} from "../../../../common/number/format_number"; } 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 { 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") @customElement("hui-energy-gas-graph-card")
export class HuiEnergyGasGraphCard export class HuiEnergyGasGraphCard
@ -60,6 +63,10 @@ export class HuiEnergyGasGraphCard
@state() private _end = endOfToday(); @state() private _end = endOfToday();
@state() private _compareStart?: Date;
@state() private _compareEnd?: Date;
@state() private _unit?: string; @state() private _unit?: string;
protected hassSubscribeRequiredHostProps = ["_config"]; protected hassSubscribeRequiredHostProps = ["_config"];
@ -101,7 +108,9 @@ export class HuiEnergyGasGraphCard
this._start, this._start,
this._end, this._end,
this.hass.locale, this.hass.locale,
this._unit this._unit,
this._compareStart,
this._compareEnd
)} )}
chart-type="bar" chart-type="bar"
></ha-chart-base> ></ha-chart-base>
@ -124,10 +133,24 @@ export class HuiEnergyGasGraphCard
start: Date, start: Date,
end: Date, end: Date,
locale: FrontendLocaleData, locale: FrontendLocaleData,
unit?: string unit?: string,
compareStart?: Date,
compareEnd?: Date
): ChartOptions => { ): ChartOptions => {
const dayDifference = differenceInDays(end, start); 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, parsing: false,
animation: false, animation: false,
scales: { scales: {
@ -193,7 +216,9 @@ export class HuiEnergyGasGraphCard
return datasets[0].label; return datasets[0].label;
} }
const date = new Date(datasets[0].parsed.x); 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), addHours(date, 1),
locale locale
)}`; )}`;
@ -227,6 +252,15 @@ export class HuiEnergyGasGraphCard
// @ts-expect-error // @ts-expect-error
locale: numberFormatToLocale(locale), locale: numberFormatToLocale(locale),
}; };
if (compare) {
options.scales!.xAxisCompare = {
...(options.scales!.x as Record<string, any>),
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³"; this._unit = getEnergyGasUnit(this.hass, energyData.prefs) || "m³";
const datasets: ChartDataset<"bar">[] = []; const datasets: ChartDataset<"bar", ScatterDataPoint[]>[] = [];
const computedStyles = getComputedStyle(this); const computedStyles = getComputedStyle(this);
const gasColor = computedStyles const gasColor = computedStyles
.getPropertyValue("--energy-gas-color") .getPropertyValue("--energy-gas-color")
.trim(); .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) => { gasSources.forEach((source, idx) => {
const data: ChartDataset<"bar" | "line">[] = [];
const entity = this.hass.states[source.stat_energy_from]; const entity = this.hass.states[source.stat_energy_from];
const modifiedColor = const modifiedColor =
@ -265,8 +342,8 @@ export class HuiEnergyGasGraphCard
const gasConsumptionData: ScatterDataPoint[] = []; const gasConsumptionData: ScatterDataPoint[] = [];
// Process gas consumption data. // Process gas consumption data.
if (source.stat_energy_from in energyData.stats) { if (source.stat_energy_from in statistics) {
const stats = energyData.stats[source.stat_energy_from]; const stats = statistics[source.stat_energy_from];
for (const point of stats) { for (const point of stats) {
if (point.sum === null) { if (point.sum === null) {
@ -290,26 +367,17 @@ export class HuiEnergyGasGraphCard
} }
} }
if (gasConsumptionData.length) { data.push({
data.push({ label: entity ? computeStateName(entity) : source.stat_energy_from,
label: entity ? computeStateName(entity) : source.stat_energy_from, borderColor: compare ? borderColor + "7F" : borderColor,
borderColor, backgroundColor: compare ? borderColor + "32" : borderColor + "7F",
backgroundColor: borderColor + "7F", data: gasConsumptionData,
data: gasConsumptionData, order: 1,
stack: "gas", stack: "gas",
}); xAxisID: compare ? "xAxisCompare" : undefined,
} });
// Concat two arrays
Array.prototype.push.apply(datasets, data);
}); });
return data;
this._start = energyData.start;
this._end = energyData.end || endOfToday();
this._chartData = {
datasets,
};
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {

View File

@ -7,6 +7,7 @@ import {
import { import {
addHours, addHours,
differenceInDays, differenceInDays,
differenceInHours,
endOfToday, endOfToday,
isToday, isToday,
startOfToday, startOfToday,
@ -23,6 +24,7 @@ import {
rgb2lab, rgb2lab,
} from "../../../../common/color/convert-color"; } from "../../../../common/color/convert-color";
import { labBrighten, labDarken } from "../../../../common/color/lab"; import { labBrighten, labDarken } from "../../../../common/color/lab";
import { formatDateShort } from "../../../../common/datetime/format_date";
import { formatTime } from "../../../../common/datetime/format_time"; import { formatTime } from "../../../../common/datetime/format_time";
import { computeStateName } from "../../../../common/entity/compute_state_name"; import { computeStateName } from "../../../../common/entity/compute_state_name";
import { import {
@ -38,6 +40,7 @@ import {
getEnergySolarForecasts, getEnergySolarForecasts,
SolarSourceTypeEnergyPreference, SolarSourceTypeEnergyPreference,
} from "../../../../data/energy"; } from "../../../../data/energy";
import { Statistics } from "../../../../data/history";
import { FrontendLocaleData } from "../../../../data/translation"; import { FrontendLocaleData } from "../../../../data/translation";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { HomeAssistant } from "../../../../types"; import { HomeAssistant } from "../../../../types";
@ -61,6 +64,10 @@ export class HuiEnergySolarGraphCard
@state() private _end = endOfToday(); @state() private _end = endOfToday();
@state() private _compareStart?: Date;
@state() private _compareEnd?: Date;
protected hassSubscribeRequiredHostProps = ["_config"]; protected hassSubscribeRequiredHostProps = ["_config"];
public hassSubscribe(): UnsubscribeFunc[] { public hassSubscribe(): UnsubscribeFunc[] {
@ -99,7 +106,9 @@ export class HuiEnergySolarGraphCard
.options=${this._createOptions( .options=${this._createOptions(
this._start, this._start,
this._end, this._end,
this.hass.locale this.hass.locale,
this._compareStart,
this._compareEnd
)} )}
chart-type="bar" chart-type="bar"
></ha-chart-base> ></ha-chart-base>
@ -118,9 +127,27 @@ export class HuiEnergySolarGraphCard
} }
private _createOptions = memoizeOne( 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); 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, parsing: false,
animation: false, animation: false,
scales: { scales: {
@ -163,7 +190,6 @@ export class HuiEnergySolarGraphCard
? "day" ? "day"
: "hour", : "hour",
}, },
offset: true,
}, },
y: { y: {
stacked: true, stacked: true,
@ -186,7 +212,9 @@ export class HuiEnergySolarGraphCard
return datasets[0].label; return datasets[0].label;
} }
const date = new Date(datasets[0].parsed.x); 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), addHours(date, 1),
locale locale
)}`; )}`;
@ -224,6 +252,15 @@ export class HuiEnergySolarGraphCard
// @ts-expect-error // @ts-expect-error
locale: numberFormatToLocale(locale), locale: numberFormatToLocale(locale),
}; };
if (compare) {
options.scales!.xAxisCompare = {
...(options.scales!.x as Record<string, any>),
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 computedStyles = getComputedStyle(this);
const solarColor = computedStyles const solarColor = computedStyles
.getPropertyValue("--energy-solar-color") .getPropertyValue("--energy-solar-color")
.trim(); .trim();
const dayDifference = differenceInDays( datasets.push(
energyData.end || new Date(), ...this._processDataSet(energyData.stats, solarSources, solarColor)
energyData.start
); );
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) => { solarSources.forEach((source, idx) => {
const data: ChartDataset<"bar" | "line">[] = [];
const entity = this.hass.states[source.stat_energy_from]; const entity = this.hass.states[source.stat_energy_from];
const modifiedColor = const modifiedColor =
@ -276,8 +364,8 @@ export class HuiEnergySolarGraphCard
const solarProductionData: ScatterDataPoint[] = []; const solarProductionData: ScatterDataPoint[] = [];
// Process solar production data. // Process solar production data.
if (source.stat_energy_from in energyData.stats) { if (source.stat_energy_from in statistics) {
const stats = energyData.stats[source.stat_energy_from]; const stats = statistics[source.stat_energy_from];
for (const point of stats) { for (const point of stats) {
if (point.sum === null) { if (point.sum === null) {
@ -301,23 +389,41 @@ export class HuiEnergySolarGraphCard
} }
} }
if (solarProductionData.length) { data.push({
data.push({ label: this.hass.localize(
label: this.hass.localize( "ui.panel.lovelace.cards.energy.energy_solar_graph.production",
"ui.panel.lovelace.cards.energy.energy_solar_graph.production", {
{ name: entity ? computeStateName(entity) : source.stat_energy_from,
name: entity ? computeStateName(entity) : source.stat_energy_from, }
} ),
), borderColor: compare ? borderColor + "7F" : borderColor,
borderColor, backgroundColor: compare ? borderColor + "32" : borderColor + "7F",
backgroundColor: borderColor + "7F", data: solarProductionData,
data: solarProductionData, order: 1,
stack: "solar", 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<string, number> | undefined = {}; const forecastsData: Record<string, number> | undefined = {};
source.config_entry_solar_forecast.forEach((configEntryId) => { source.config_entry_solar_forecast.forEach((configEntryId) => {
if (!forecasts![configEntryId]) { if (!forecasts![configEntryId]) {
@ -326,10 +432,7 @@ export class HuiEnergySolarGraphCard
Object.entries(forecasts![configEntryId].wh_hours).forEach( Object.entries(forecasts![configEntryId].wh_hours).forEach(
([date, value]) => { ([date, value]) => {
const dateObj = new Date(date); const dateObj = new Date(date);
if ( if (dateObj < start || (end && dateObj > end)) {
dateObj < energyData.start ||
(energyData.end && dateObj > energyData.end)
) {
return; return;
} }
if (dayDifference > 35) { if (dayDifference > 35) {
@ -372,9 +475,7 @@ export class HuiEnergySolarGraphCard
), ),
fill: false, fill: false,
stepped: false, stepped: false,
borderColor: computedStyles.getPropertyValue( borderColor,
"--primary-text-color"
),
borderDash: [7, 5], borderDash: [7, 5],
pointRadius: 0, pointRadius: 0,
data: solarForecastData, data: solarForecastData,
@ -382,17 +483,9 @@ export class HuiEnergySolarGraphCard
} }
} }
} }
// Concat two arrays
Array.prototype.push.apply(datasets, data);
}); });
this._start = energyData.start; return data;
this._end = energyData.end || endOfToday();
this._chartData = {
datasets,
};
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {

View File

@ -1,7 +1,13 @@
import { ChartData, ChartDataset, ChartOptions } from "chart.js"; import {
ChartData,
ChartDataset,
ChartOptions,
ScatterDataPoint,
} from "chart.js";
import { import {
addHours, addHours,
differenceInDays, differenceInDays,
differenceInHours,
endOfToday, endOfToday,
isToday, isToday,
startOfToday, startOfToday,
@ -18,6 +24,7 @@ import {
rgb2lab, rgb2lab,
} from "../../../../common/color/convert-color"; } from "../../../../common/color/convert-color";
import { labBrighten, labDarken } from "../../../../common/color/lab"; import { labBrighten, labDarken } from "../../../../common/color/lab";
import { formatDateShort } from "../../../../common/datetime/format_date";
import { formatTime } from "../../../../common/datetime/format_time"; import { formatTime } from "../../../../common/datetime/format_time";
import { computeStateName } from "../../../../common/entity/compute_state_name"; import { computeStateName } from "../../../../common/entity/compute_state_name";
import { import {
@ -27,6 +34,7 @@ import {
import "../../../../components/chart/ha-chart-base"; import "../../../../components/chart/ha-chart-base";
import "../../../../components/ha-card"; import "../../../../components/ha-card";
import { EnergyData, getEnergyDataCollection } from "../../../../data/energy"; import { EnergyData, getEnergyDataCollection } from "../../../../data/energy";
import { Statistics } from "../../../../data/history";
import { FrontendLocaleData } from "../../../../data/translation"; import { FrontendLocaleData } from "../../../../data/translation";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { HomeAssistant } from "../../../../types"; import { HomeAssistant } from "../../../../types";
@ -50,6 +58,10 @@ export class HuiEnergyUsageGraphCard
@state() private _end = endOfToday(); @state() private _end = endOfToday();
@state() private _compareStart?: Date;
@state() private _compareEnd?: Date;
protected hassSubscribeRequiredHostProps = ["_config"]; protected hassSubscribeRequiredHostProps = ["_config"];
public hassSubscribe(): UnsubscribeFunc[] { public hassSubscribe(): UnsubscribeFunc[] {
@ -88,7 +100,9 @@ export class HuiEnergyUsageGraphCard
.options=${this._createOptions( .options=${this._createOptions(
this._start, this._start,
this._end, this._end,
this.hass.locale this.hass.locale,
this._compareStart,
this._compareEnd
)} )}
chart-type="bar" chart-type="bar"
></ha-chart-base> ></ha-chart-base>
@ -107,9 +121,27 @@ export class HuiEnergyUsageGraphCard
} }
private _createOptions = memoizeOne( 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); 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, parsing: false,
animation: false, animation: false,
scales: { scales: {
@ -152,7 +184,6 @@ export class HuiEnergyUsageGraphCard
? "day" ? "day"
: "hour", : "hour",
}, },
offset: true,
}, },
y: { y: {
stacked: true, stacked: true,
@ -179,7 +210,9 @@ export class HuiEnergyUsageGraphCard
return datasets[0].label; return datasets[0].label;
} }
const date = new Date(datasets[0].parsed.x); 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), addHours(date, 1),
locale locale
)}`; )}`;
@ -240,13 +273,22 @@ export class HuiEnergyUsageGraphCard
// @ts-expect-error // @ts-expect-error
locale: numberFormatToLocale(locale), locale: numberFormatToLocale(locale),
}; };
if (compare) {
options.scales!.xAxisCompare = {
...(options.scales!.x as Record<string, any>),
suggestedMin: compareStart!.getTime(),
suggestedMax: compareEnd!.getTime(),
display: false,
};
}
return options;
} }
); );
private async _getStatistics(energyData: EnergyData): Promise<void> { private async _getStatistics(energyData: EnergyData): Promise<void> {
const datasets: ChartDataset<"bar">[] = []; const datasets: ChartDataset<"bar", ScatterDataPoint[]>[] = [];
const statistics: { const statIds: {
to_grid?: string[]; to_grid?: string[];
from_grid?: string[]; from_grid?: string[];
solar?: string[]; solar?: string[];
@ -256,21 +298,21 @@ export class HuiEnergyUsageGraphCard
for (const source of energyData.prefs.energy_sources) { for (const source of energyData.prefs.energy_sources) {
if (source.type === "solar") { if (source.type === "solar") {
if (statistics.solar) { if (statIds.solar) {
statistics.solar.push(source.stat_energy_from); statIds.solar.push(source.stat_energy_from);
} else { } else {
statistics.solar = [source.stat_energy_from]; statIds.solar = [source.stat_energy_from];
} }
continue; continue;
} }
if (source.type === "battery") { if (source.type === "battery") {
if (statistics.to_battery) { if (statIds.to_battery) {
statistics.to_battery.push(source.stat_energy_to); statIds.to_battery.push(source.stat_energy_to);
statistics.from_battery!.push(source.stat_energy_from); statIds.from_battery!.push(source.stat_energy_from);
} else { } else {
statistics.to_battery = [source.stat_energy_to]; statIds.to_battery = [source.stat_energy_to];
statistics.from_battery = [source.stat_energy_from]; statIds.from_battery = [source.stat_energy_from];
} }
continue; continue;
} }
@ -281,41 +323,21 @@ export class HuiEnergyUsageGraphCard
// grid source // grid source
for (const flowFrom of source.flow_from) { for (const flowFrom of source.flow_from) {
if (statistics.from_grid) { if (statIds.from_grid) {
statistics.from_grid.push(flowFrom.stat_energy_from); statIds.from_grid.push(flowFrom.stat_energy_from);
} else { } else {
statistics.from_grid = [flowFrom.stat_energy_from]; statIds.from_grid = [flowFrom.stat_energy_from];
} }
} }
for (const flowTo of source.flow_to) { for (const flowTo of source.flow_to) {
if (statistics.to_grid) { if (statIds.to_grid) {
statistics.to_grid.push(flowTo.stat_energy_to); statIds.to_grid.push(flowTo.stat_energy_to);
} else { } 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 computedStyles = getComputedStyle(this);
const colors = { const colors = {
to_grid: computedStyles 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 = [ const sum = [
"solar", "solar",
"to_grid", "to_grid",
@ -361,7 +464,7 @@ export class HuiEnergyUsageGraphCard
const totalStats: { [start: string]: number } = {}; const totalStats: { [start: string]: number } = {};
const sets: { [statId: string]: { [start: string]: number } } = {}; const sets: { [statId: string]: { [start: string]: number } } = {};
statIds!.forEach((id) => { statIds!.forEach((id) => {
const stats = energyData.stats[id]; const stats = statistics[id];
if (!stats) { if (!stats) {
return; return;
} }
@ -477,7 +580,6 @@ export class HuiEnergyUsageGraphCard
Object.entries(combinedData).forEach(([type, sources]) => { Object.entries(combinedData).forEach(([type, sources]) => {
Object.entries(sources).forEach(([statId, source], idx) => { Object.entries(sources).forEach(([statId, source], idx) => {
const data: ChartDataset<"bar">[] = [];
const entity = this.hass.states[statId]; const entity = this.hass.states[statId];
const modifiedColor = const modifiedColor =
@ -490,6 +592,20 @@ export class HuiEnergyUsageGraphCard
? rgb2hex(lab2rgb(modifiedColor)) ? rgb2hex(lab2rgb(modifiedColor))
: colors[type]; : 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({ data.push({
label: label:
type in labels type in labels
@ -499,38 +615,19 @@ export class HuiEnergyUsageGraphCard
: statId, : statId,
order: order:
type === "used_solar" type === "used_solar"
? 0 ? 1
: type === "to_battery" : type === "to_battery"
? Object.keys(combinedData).length ? Object.keys(combinedData).length
: idx + 1, : idx + 2,
borderColor, borderColor: compare ? borderColor + "7F" : borderColor,
backgroundColor: borderColor + "7F", backgroundColor: compare ? borderColor + "32" : borderColor + "7F",
stack: "stack", 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);
}); });
}); });
return data;
this._chartData = {
datasets,
};
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {

View File

@ -97,6 +97,10 @@ export interface ButtonCardConfig extends LovelaceCardConfig {
show_state?: boolean; show_state?: boolean;
} }
export interface EnergyCardBaseConfig extends LovelaceCardConfig {
collection_key?: string;
}
export interface EnergySummaryCardConfig extends LovelaceCardConfig { export interface EnergySummaryCardConfig extends LovelaceCardConfig {
type: "energy-summary"; type: "energy-summary";
title?: string; title?: string;

View File

@ -46,6 +46,8 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
@state() private _period?: "day" | "week" | "month" | "year"; @state() private _period?: "day" | "week" | "month" | "year";
@state() private _compare? = false;
public connectedCallback() { public connectedCallback() {
super.connectedCallback(); super.connectedCallback();
toggleAttribute(this, "narrow", this.offsetWidth < 600); toggleAttribute(this, "narrow", this.offsetWidth < 600);
@ -134,6 +136,14 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
dense dense
@value-changed=${this._handleView} @value-changed=${this._handleView}
></ha-button-toggle-group> ></ha-button-toggle-group>
<mwc-button
class="compare ${this._compare ? "active" : ""}"
@click=${this._toggleCompare}
dense
outlined
>
Compare data
</mwc-button>
</div> </div>
</div> </div>
`; `;
@ -216,6 +226,7 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
} }
private _updateDates(energyData: EnergyData): void { private _updateDates(energyData: EnergyData): void {
this._compare = energyData.startCompare !== undefined;
this._startDate = energyData.start; this._startDate = energyData.start;
this._endDate = energyData.end || endOfToday(); this._endDate = energyData.end || endOfToday();
const dayDifference = differenceInDays(this._endDate, this._startDate); const dayDifference = differenceInDays(this._endDate, this._startDate);
@ -231,6 +242,15 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
: undefined; : 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 { static get styles(): CSSResultGroup {
return css` return css`
.row { .row {
@ -251,12 +271,37 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
} }
.period { .period {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: flex-end; 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 { :host {
--mdc-button-outline-color: currentColor; --mdc-button-outline-color: currentColor;
--primary-color: currentColor; --primary-color: currentColor;
--mdc-theme-primary: currentColor; --mdc-theme-primary: currentColor;
--mdc-theme-on-primary: currentColor;
--mdc-button-disabled-outline-color: var(--disabled-text-color); --mdc-button-disabled-outline-color: var(--disabled-text-color);
--mdc-button-disabled-ink-color: var(--disabled-text-color); --mdc-button-disabled-ink-color: var(--disabled-text-color);
--mdc-icon-button-ripple-opacity: 0.2; --mdc-icon-button-ripple-opacity: 0.2;

View File

@ -35,6 +35,7 @@ const LAZY_LOAD_TYPES = {
calendar: () => import("../cards/hui-calendar-card"), calendar: () => import("../cards/hui-calendar-card"),
conditional: () => import("../cards/hui-conditional-card"), conditional: () => import("../cards/hui-conditional-card"),
"empty-state": () => import("../cards/hui-empty-state-card"), "empty-state": () => import("../cards/hui-empty-state-card"),
"energy-compare": () => import("../cards/energy/hui-energy-compare-card"),
"energy-carbon-consumed-gauge": () => "energy-carbon-consumed-gauge": () =>
import("../cards/energy/hui-energy-carbon-consumed-gauge-card"), import("../cards/energy/hui-energy-carbon-consumed-gauge-card"),
"energy-date-selection": () => "energy-date-selection": () =>