mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-15 21:02:10 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df6b22498a | |||
| bc6cef2651 | |||
| 9f1f351118 |
@@ -1,4 +1,4 @@
|
||||
import { endOfToday, isSameDay, isToday, startOfToday } from "date-fns";
|
||||
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";
|
||||
@@ -6,27 +6,23 @@ 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 { LinearGradient } from "../../../../resources/echarts/echarts";
|
||||
import "../../../../components/chart/ha-chart-base";
|
||||
import { computeYAxisFractionDigits } from "../../../../components/chart/y-axis-fraction-digits";
|
||||
import "../../../../components/ha-card";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
getEnergyDataCollection,
|
||||
getPowerFromState,
|
||||
validateEnergyCollectionKey,
|
||||
} 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 { getCommonOptions } from "./common/energy-chart-options";
|
||||
import type { HaECOption } from "../../../../resources/echarts/echarts";
|
||||
import { hex2rgb } from "../../../../common/color/convert-color";
|
||||
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
|
||||
import { generatePowerSourcesGraphData } from "./power-sources-graph-data";
|
||||
|
||||
@customElement("hui-power-sources-graph-card")
|
||||
export class HuiPowerSourcesGraphCard
|
||||
@@ -170,254 +166,21 @@ export class HuiPowerSourcesGraphCard
|
||||
);
|
||||
|
||||
private async _getStatistics(energyData: EnergyData): Promise<void> {
|
||||
const datasets: LineSeriesOption[] = [];
|
||||
this._legendData = [];
|
||||
|
||||
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);
|
||||
|
||||
let yMin = Infinity;
|
||||
let yMax = -Infinity;
|
||||
const trackY = (v: number) => {
|
||||
if (v < yMin) yMin = v;
|
||||
if (v > yMax) yMax = v;
|
||||
};
|
||||
|
||||
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") {
|
||||
if (source.stat_rate) {
|
||||
statIds.grid.stats.push(source.stat_rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
const commonSeriesOptions: LineSeriesOption = {
|
||||
type: "line",
|
||||
smooth: 0.4,
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const now = Date.now();
|
||||
const seriesData: Record<
|
||||
string,
|
||||
{
|
||||
colorHex: string;
|
||||
rgb: [number, number, number];
|
||||
positive: [number, number][];
|
||||
negative: [number, number][];
|
||||
}
|
||||
> = {};
|
||||
|
||||
Object.keys(statIds).forEach((key) => {
|
||||
if (statIds[key].stats.length) {
|
||||
const colorHex = computedStyles.getPropertyValue(statIds[key].color);
|
||||
const rgb = hex2rgb(colorHex);
|
||||
// Echarts is supposed to handle that but it is bugged when you use it together with stacking.
|
||||
// The interpolation breaks the stacking, so this positive/negative is a workaround
|
||||
const { positive, negative } = this._processData(
|
||||
statIds[key].stats.map((id: string) => {
|
||||
const stats = energyData.stats[id] ?? [];
|
||||
if (isSameDay(now, this._start) && isSameDay(now, this._end)) {
|
||||
// Append current state if we are showing today
|
||||
const currentStateWatts = getPowerFromState(this.hass.states[id]);
|
||||
if (currentStateWatts !== undefined) {
|
||||
// getPowerFromState returns power in W; convert to kW for this graph
|
||||
stats.push({
|
||||
start: now,
|
||||
end: now,
|
||||
mean: currentStateWatts / 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
}),
|
||||
trackY
|
||||
);
|
||||
|
||||
seriesData[key] = { colorHex, rgb, positive, negative };
|
||||
}
|
||||
const result = generatePowerSourcesGraphData({
|
||||
localize: this.hass.localize,
|
||||
states: this.hass.states,
|
||||
energyData,
|
||||
computedStyles: getComputedStyle(this),
|
||||
start: this._start,
|
||||
end: this._end,
|
||||
now: Date.now(),
|
||||
});
|
||||
|
||||
const pushSeries = (
|
||||
key: string,
|
||||
data: [number, number][],
|
||||
stack: "positive" | "negative",
|
||||
z: number
|
||||
) => {
|
||||
const { colorHex, rgb } = seriesData[key];
|
||||
|
||||
datasets.push({
|
||||
...commonSeriesOptions,
|
||||
id: stack === "positive" ? key : `${key}-negative`,
|
||||
name: statIds[key].name,
|
||||
color: colorHex,
|
||||
stack,
|
||||
areaStyle: {
|
||||
color: new LinearGradient(
|
||||
0,
|
||||
stack === "positive" ? 0 : 1,
|
||||
0,
|
||||
stack === "positive" ? 1 : 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,
|
||||
z,
|
||||
});
|
||||
};
|
||||
|
||||
// Draw in reverse order so 0 value lines are overwritten
|
||||
["solar", "battery", "grid"].forEach((key, i) => {
|
||||
if (seriesData[key]) {
|
||||
pushSeries(key, seriesData[key].positive, "positive", 3 - i);
|
||||
}
|
||||
});
|
||||
|
||||
// Draw in reverse order but above positive series
|
||||
["battery", "grid"].forEach((key, i) => {
|
||||
if (seriesData[key]) {
|
||||
pushSeries(key, seriesData[key].negative, "negative", 4 - i);
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(statIds).forEach((key) => {
|
||||
if (seriesData[key]) {
|
||||
const { colorHex, rgb } = seriesData[key];
|
||||
|
||||
this._legendData!.push({
|
||||
id: key,
|
||||
secondaryIds: key !== "solar" ? [`${key}-negative`] : [],
|
||||
name: statIds[key].name,
|
||||
itemStyle: {
|
||||
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`,
|
||||
borderColor: colorHex,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this._start = energyData.start;
|
||||
this._end = energyData.end || endOfToday();
|
||||
|
||||
this._chartData = fillLineGaps(datasets);
|
||||
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
|
||||
const usageData: NonNullable<LineSeriesOption["data"]> = [];
|
||||
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];
|
||||
let sum = 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];
|
||||
sum += y as number;
|
||||
});
|
||||
// Consumption can't be negative; sources unaccounted for in the
|
||||
// configuration (e.g. solar exporting to grid without a configured
|
||||
// solar source) would otherwise drag the usage line below zero.
|
||||
usageData[i] = [x, Math.max(0, sum)];
|
||||
});
|
||||
this._chartData.push({
|
||||
...commonSeriesOptions,
|
||||
id: "usage",
|
||||
name: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.power_graph.usage"
|
||||
),
|
||||
color: computedStyles.getPropertyValue("--primary-text-color"),
|
||||
lineStyle: {
|
||||
type: [7, 2],
|
||||
width: 1.5,
|
||||
},
|
||||
data: usageData,
|
||||
z: 5,
|
||||
});
|
||||
this._legendData!.push({
|
||||
id: "usage",
|
||||
name: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.power_graph.usage"
|
||||
),
|
||||
itemStyle: {
|
||||
color: computedStyles.getPropertyValue("--primary-text-color"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _processData(stats: StatisticValue[][], trackY: (v: number) => void) {
|
||||
const data: Record<number, number[]> = {};
|
||||
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 sumY = y.reduce((a, b) => a + b, 0);
|
||||
const pos = Math.max(0, sumY);
|
||||
const neg = Math.min(0, sumY);
|
||||
positive.push([ts, pos]);
|
||||
negative.push([ts, neg]);
|
||||
trackY(pos);
|
||||
trackY(neg);
|
||||
});
|
||||
return { positive, negative };
|
||||
this._legendData = result.legendData;
|
||||
this._start = result.start;
|
||||
this._end = result.end;
|
||||
this._chartData = result.chartData;
|
||||
this._yAxisFractionDigits = result.yAxisFractionDigits;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { endOfToday, isSameDay } from "date-fns";
|
||||
import type { LineSeriesOption } from "echarts/charts";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import { hex2rgb } from "../../../../common/color/convert-color";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
|
||||
import { computeYAxisFractionDigits } from "../../../../components/chart/y-axis-fraction-digits";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import { getPowerFromState } from "../../../../data/energy";
|
||||
import type { StatisticValue } from "../../../../data/recorder";
|
||||
import { LinearGradient } from "../../../../resources/echarts/echarts";
|
||||
import { fillLineGaps } from "./common/energy-chart-options";
|
||||
|
||||
export interface PowerSourcesGraphDataParams {
|
||||
localize: LocalizeFunc;
|
||||
states: HassEntities;
|
||||
energyData: EnergyData;
|
||||
computedStyles: CSSStyleDeclaration;
|
||||
/** Previous chart start, used for the "showing today" current-state check. */
|
||||
start: Date;
|
||||
/** Previous chart end, used for the "showing today" current-state check. */
|
||||
end: Date;
|
||||
/** Current time, injected (was `Date.now()`). */
|
||||
now: number;
|
||||
}
|
||||
|
||||
export interface PowerSourcesGraphData {
|
||||
chartData: LineSeriesOption[];
|
||||
legendData: CustomLegendOption["data"];
|
||||
yAxisFractionDigits: number;
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
function processData(
|
||||
stats: StatisticValue[][],
|
||||
trackY: (v: number) => void
|
||||
): { positive: [number, number][]; negative: [number, number][] } {
|
||||
const data: Record<number, number[]> = {};
|
||||
stats.forEach((statSet) => {
|
||||
statSet.forEach((point) => {
|
||||
if (point.mean == null) {
|
||||
return;
|
||||
}
|
||||
const x = (point.start + point.end) / 2;
|
||||
// Append in place instead of rebuilding the bucket array with a spread
|
||||
// on every point (which is O(n^2) for repeated timestamps). Insertion
|
||||
// order is preserved, so the later reduce() sum is bit-identical.
|
||||
(data[x] ??= []).push(point.mean);
|
||||
});
|
||||
});
|
||||
const positive: [number, number][] = [];
|
||||
const negative: [number, number][] = [];
|
||||
Object.entries(data).forEach(([x, y]) => {
|
||||
const ts = Number(x);
|
||||
// Sum the bucket with a plain left-to-right loop (initial 0) instead of
|
||||
// reduce(): most buckets hold a single mean, so reduce() is pure
|
||||
// closure/call overhead per timestamp. The IEEE-754 addition order is
|
||||
// identical, so sumY (and the downstream Math.max/Math.min) is
|
||||
// bit-identical.
|
||||
let sumY = 0;
|
||||
for (const value of y) {
|
||||
sumY += value;
|
||||
}
|
||||
const pos = Math.max(0, sumY);
|
||||
const neg = Math.min(0, sumY);
|
||||
positive.push([ts, pos]);
|
||||
negative.push([ts, neg]);
|
||||
trackY(pos);
|
||||
trackY(neg);
|
||||
});
|
||||
return { positive, negative };
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms an energy collection update (`EnergyData` + prefs) into the
|
||||
* ECharts series, legend, and derived state for the power sources graph card.
|
||||
* Pure data processing: all environment inputs (current time, theme style,
|
||||
* localize, current entity states) are injected so the transform is
|
||||
* deterministic and benchmarkable.
|
||||
*/
|
||||
export function generatePowerSourcesGraphData(
|
||||
params: PowerSourcesGraphDataParams
|
||||
): PowerSourcesGraphData {
|
||||
const { localize, states, energyData, computedStyles } = params;
|
||||
|
||||
const datasets: LineSeriesOption[] = [];
|
||||
const legendData: CustomLegendOption["data"] = [];
|
||||
|
||||
const statIds = {
|
||||
solar: {
|
||||
stats: [] as string[],
|
||||
color: "--energy-solar-color",
|
||||
name: localize("ui.panel.lovelace.cards.energy.power_graph.solar"),
|
||||
},
|
||||
grid: {
|
||||
stats: [] as string[],
|
||||
color: "--energy-grid-consumption-color",
|
||||
name: localize("ui.panel.lovelace.cards.energy.power_graph.grid"),
|
||||
},
|
||||
battery: {
|
||||
stats: [] as string[],
|
||||
color: "--energy-battery-out-color",
|
||||
name: localize("ui.panel.lovelace.cards.energy.power_graph.battery"),
|
||||
},
|
||||
};
|
||||
|
||||
let yMin = Infinity;
|
||||
let yMax = -Infinity;
|
||||
const trackY = (v: number) => {
|
||||
if (v < yMin) yMin = v;
|
||||
if (v > yMax) yMax = v;
|
||||
};
|
||||
|
||||
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") {
|
||||
if (source.stat_rate) {
|
||||
statIds.grid.stats.push(source.stat_rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
const commonSeriesOptions: LineSeriesOption = {
|
||||
type: "line",
|
||||
smooth: 0.4,
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const now = params.now;
|
||||
// Whether we are showing "today" is independent of the stat id, so compute
|
||||
// it once instead of inside the per-id map below.
|
||||
const showingToday =
|
||||
isSameDay(now, params.start) && isSameDay(now, params.end);
|
||||
const seriesData: Record<
|
||||
string,
|
||||
{
|
||||
colorHex: string;
|
||||
rgb: [number, number, number];
|
||||
positive: [number, number][];
|
||||
negative: [number, number][];
|
||||
}
|
||||
> = {};
|
||||
|
||||
Object.keys(statIds).forEach((key) => {
|
||||
const meta = statIds[key];
|
||||
if (meta.stats.length) {
|
||||
const colorHex = computedStyles.getPropertyValue(meta.color);
|
||||
const rgb = hex2rgb(colorHex);
|
||||
// Echarts is supposed to handle that but it is bugged when you use it together with stacking.
|
||||
// The interpolation breaks the stacking, so this positive/negative is a workaround
|
||||
const { positive, negative } = processData(
|
||||
meta.stats.map((id: string) => {
|
||||
const stats = energyData.stats[id] ?? [];
|
||||
if (showingToday) {
|
||||
// Append current state if we are showing today
|
||||
const currentStateWatts = getPowerFromState(states[id]);
|
||||
if (currentStateWatts !== undefined) {
|
||||
// getPowerFromState returns power in W; convert to kW for this graph
|
||||
stats.push({
|
||||
start: now,
|
||||
end: now,
|
||||
mean: currentStateWatts / 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
}),
|
||||
trackY
|
||||
);
|
||||
|
||||
seriesData[key] = { colorHex, rgb, positive, negative };
|
||||
}
|
||||
});
|
||||
|
||||
const pushSeries = (
|
||||
key: string,
|
||||
data: [number, number][],
|
||||
stack: "positive" | "negative",
|
||||
z: number
|
||||
) => {
|
||||
const { colorHex, rgb } = seriesData[key];
|
||||
|
||||
datasets.push({
|
||||
...commonSeriesOptions,
|
||||
id: stack === "positive" ? key : `${key}-negative`,
|
||||
name: statIds[key].name,
|
||||
color: colorHex,
|
||||
stack,
|
||||
areaStyle: {
|
||||
color: new LinearGradient(
|
||||
0,
|
||||
stack === "positive" ? 0 : 1,
|
||||
0,
|
||||
stack === "positive" ? 1 : 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,
|
||||
z,
|
||||
});
|
||||
};
|
||||
|
||||
// Draw in reverse order so 0 value lines are overwritten
|
||||
["solar", "battery", "grid"].forEach((key, i) => {
|
||||
if (seriesData[key]) {
|
||||
pushSeries(key, seriesData[key].positive, "positive", 3 - i);
|
||||
}
|
||||
});
|
||||
|
||||
// Draw in reverse order but above positive series
|
||||
["battery", "grid"].forEach((key, i) => {
|
||||
if (seriesData[key]) {
|
||||
pushSeries(key, seriesData[key].negative, "negative", 4 - i);
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(statIds).forEach((key) => {
|
||||
if (seriesData[key]) {
|
||||
const { colorHex, rgb } = seriesData[key];
|
||||
|
||||
legendData!.push({
|
||||
id: key,
|
||||
secondaryIds: key !== "solar" ? [`${key}-negative`] : [],
|
||||
name: statIds[key].name,
|
||||
itemStyle: {
|
||||
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`,
|
||||
borderColor: colorHex,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const start = energyData.start;
|
||||
const end = energyData.end || endOfToday();
|
||||
|
||||
const chartData = fillLineGaps(datasets);
|
||||
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
|
||||
const usageData: NonNullable<LineSeriesOption["data"]> = [];
|
||||
// fillLineGaps ensures all datasets share the same x values, so iterate the
|
||||
// P points of the first dataset and sum across the D datasets per point.
|
||||
// Use indexed for-loops and cache the per-dataset `data` arrays once
|
||||
// (instead of re-dereferencing `dataset.data![i]` up to four times and
|
||||
// allocating a closure per element) — this P x D loop is the dominant cost
|
||||
// and the savings grow with the series count. Evaluation order, the
|
||||
// typeof/`in` branch outcome, and the float-addition order are all
|
||||
// unchanged, so `usageData` is bit-identical.
|
||||
const firstData = chartData[0]?.data;
|
||||
if (firstData) {
|
||||
const numDatasets = chartData.length;
|
||||
const datasetData: NonNullable<LineSeriesOption["data"]>[] = [];
|
||||
for (let d = 0; d < numDatasets; d++) {
|
||||
datasetData.push(chartData[d].data!);
|
||||
}
|
||||
const numPoints = firstData.length;
|
||||
for (let i = 0; i < numPoints; i++) {
|
||||
const item = firstData[i];
|
||||
const x =
|
||||
typeof item === "object" && "value" in item!
|
||||
? item.value![0]
|
||||
: item![0];
|
||||
let sum = 0;
|
||||
for (let d = 0; d < numDatasets; d++) {
|
||||
const point = datasetData[d][i];
|
||||
const y =
|
||||
typeof point === "object" && "value" in point!
|
||||
? point.value![1]
|
||||
: point![1];
|
||||
sum += y as number;
|
||||
}
|
||||
// Consumption can't be negative; sources unaccounted for in the
|
||||
// configuration (e.g. solar exporting to grid without a configured
|
||||
// solar source) would otherwise drag the usage line below zero.
|
||||
usageData[i] = [x, Math.max(0, sum)];
|
||||
}
|
||||
}
|
||||
chartData.push({
|
||||
...commonSeriesOptions,
|
||||
id: "usage",
|
||||
name: localize("ui.panel.lovelace.cards.energy.power_graph.usage"),
|
||||
color: computedStyles.getPropertyValue("--primary-text-color"),
|
||||
lineStyle: {
|
||||
type: [7, 2],
|
||||
width: 1.5,
|
||||
},
|
||||
data: usageData,
|
||||
z: 5,
|
||||
});
|
||||
legendData!.push({
|
||||
id: "usage",
|
||||
name: localize("ui.panel.lovelace.cards.energy.power_graph.usage"),
|
||||
itemStyle: {
|
||||
color: computedStyles.getPropertyValue("--primary-text-color"),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
chartData,
|
||||
legendData,
|
||||
yAxisFractionDigits,
|
||||
start,
|
||||
end,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { bench, describe } from "vitest";
|
||||
import type { EnergyData } from "../../src/data/energy";
|
||||
import { generatePowerSourcesGraphData } from "../../src/panels/lovelace/cards/energy/power-sources-graph-data";
|
||||
import { createMockComputedStyle } from "../fixtures/computed-style";
|
||||
import { mockLocalize } from "../fixtures/hass";
|
||||
import {
|
||||
generateEnergyData,
|
||||
generateEnergyPreferences,
|
||||
} from "../fixtures/energy";
|
||||
import { generateStatistics } from "../fixtures/statistics";
|
||||
import { FIXED_EPOCH_MS } from "../fixtures/history-states";
|
||||
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
|
||||
const computedStyles = createMockComputedStyle({
|
||||
"--energy-solar-color": "#ff9800",
|
||||
"--energy-grid-consumption-color": "#488fc2",
|
||||
"--energy-battery-out-color": "#4db6ac",
|
||||
"--primary-text-color": "#212121",
|
||||
});
|
||||
|
||||
const RATE_IDS = {
|
||||
grid: "sensor.grid_power",
|
||||
solar: "sensor.solar_power",
|
||||
battery: "sensor.battery_power",
|
||||
};
|
||||
|
||||
const buildEnergyData = (
|
||||
seed: number,
|
||||
days: number,
|
||||
period: "5minute" | "hour" | "day"
|
||||
): EnergyData => {
|
||||
const prefs = generateEnergyPreferences({
|
||||
grid: true,
|
||||
solar: true,
|
||||
battery: true,
|
||||
gas: false,
|
||||
water: false,
|
||||
});
|
||||
const ids: string[] = [];
|
||||
for (const source of prefs.energy_sources) {
|
||||
if (source.type === "grid") {
|
||||
source.stat_rate = RATE_IDS.grid;
|
||||
ids.push(RATE_IDS.grid);
|
||||
} else if (source.type === "solar") {
|
||||
source.stat_rate = RATE_IDS.solar;
|
||||
ids.push(RATE_IDS.solar);
|
||||
} else if (source.type === "battery") {
|
||||
source.stat_rate = RATE_IDS.battery;
|
||||
ids.push(RATE_IDS.battery);
|
||||
}
|
||||
}
|
||||
const base = generateEnergyData(seed, { days, period, prefs });
|
||||
const meanStats = generateStatistics(seed + 100, {
|
||||
ids,
|
||||
period,
|
||||
days,
|
||||
sumStatistics: false,
|
||||
});
|
||||
return { ...base, stats: meanStats };
|
||||
};
|
||||
|
||||
// Many-series scenario: the card collapses sources into at most three keys
|
||||
// (solar/grid/battery), but each key can carry many stat_rate ids that
|
||||
// processData merges per timestamp. The default fixtures only ever wire up one
|
||||
// stat_rate per type, so the per-timestamp bucket summation (and the per-point
|
||||
// usage-sum loop) never sees the series dimension. This builder spreads
|
||||
// `seriesPerType` distinct stat_rate ids across each of the three types
|
||||
// (~3 x seriesPerType total) so the many-series merge path is exercised.
|
||||
const buildManySeriesEnergyData = (
|
||||
seed: number,
|
||||
days: number,
|
||||
period: "5minute" | "hour" | "day",
|
||||
seriesPerType: number
|
||||
): EnergyData => {
|
||||
const prefs = generateEnergyPreferences({
|
||||
grid: true,
|
||||
solar: true,
|
||||
battery: true,
|
||||
gas: false,
|
||||
water: false,
|
||||
});
|
||||
const sources: EnergyData["prefs"]["energy_sources"] = [];
|
||||
const ids: string[] = [];
|
||||
for (const source of prefs.energy_sources) {
|
||||
const type =
|
||||
source.type === "grid"
|
||||
? "grid"
|
||||
: source.type === "solar"
|
||||
? "solar"
|
||||
: "battery";
|
||||
for (let i = 0; i < seriesPerType; i++) {
|
||||
const id = `sensor.${type}_power_${i}`;
|
||||
ids.push(id);
|
||||
sources.push({ ...source, stat_rate: id });
|
||||
}
|
||||
}
|
||||
prefs.energy_sources = sources;
|
||||
const base = generateEnergyData(seed, { days, period, prefs });
|
||||
const meanStats = generateStatistics(seed + 100, {
|
||||
ids,
|
||||
period,
|
||||
days,
|
||||
sumStatistics: false,
|
||||
});
|
||||
return { ...base, stats: meanStats };
|
||||
};
|
||||
|
||||
const small = buildEnergyData(1, 1, "hour");
|
||||
const medium = buildEnergyData(2, 31, "hour");
|
||||
const large = buildEnergyData(3, 14, "5minute");
|
||||
// ~18 stat sets (6 per type) at month-hourly resolution.
|
||||
const manySeries = buildManySeriesEnergyData(4, 31, "hour", 6);
|
||||
|
||||
const base = {
|
||||
localize: mockLocalize,
|
||||
states: {},
|
||||
computedStyles,
|
||||
start: new Date(FIXED_EPOCH_MS),
|
||||
end: new Date(FIXED_EPOCH_MS + 30 * dayMs),
|
||||
now: FIXED_EPOCH_MS + 60 * dayMs,
|
||||
} as const;
|
||||
|
||||
describe("generatePowerSourcesGraphData", () => {
|
||||
bench("small (1 day hourly)", () => {
|
||||
// generatePowerSourcesGraphData pushes a synthetic point into the stats
|
||||
// arrays when showing "today"; clone so iterations stay independent.
|
||||
generatePowerSourcesGraphData({
|
||||
...base,
|
||||
energyData: { ...small, stats: structuredClone(small.stats) },
|
||||
});
|
||||
});
|
||||
|
||||
bench("medium (1 month hourly)", () => {
|
||||
generatePowerSourcesGraphData({
|
||||
...base,
|
||||
energyData: { ...medium, stats: structuredClone(medium.stats) },
|
||||
});
|
||||
});
|
||||
|
||||
bench(
|
||||
"large (2 weeks 5-minute)",
|
||||
() => {
|
||||
generatePowerSourcesGraphData({
|
||||
...base,
|
||||
energyData: { ...large, stats: structuredClone(large.stats) },
|
||||
});
|
||||
},
|
||||
{ time: 1000, warmupIterations: 2 }
|
||||
);
|
||||
|
||||
bench("many series (~18 series, 1 month hourly)", () => {
|
||||
generatePowerSourcesGraphData({
|
||||
...base,
|
||||
energyData: { ...manySeries, stats: structuredClone(manySeries.stats) },
|
||||
});
|
||||
});
|
||||
});
|
||||
+5431
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Characterization tests pinning the exact output of the power sources graph
|
||||
* card data transform. Do NOT update these snapshots to make an optimization
|
||||
* pass — see test/benchmarks/README.md.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import type { EnergyData } from "../../../../../src/data/energy";
|
||||
import { generatePowerSourcesGraphData } from "../../../../../src/panels/lovelace/cards/energy/power-sources-graph-data";
|
||||
import { createMockComputedStyle } from "../../../../fixtures/computed-style";
|
||||
import { digestResult } from "../../../../fixtures/digest";
|
||||
import { createMockEntityState, mockLocalize } from "../../../../fixtures/hass";
|
||||
import {
|
||||
generateEnergyData,
|
||||
generateEnergyPreferences,
|
||||
} from "../../../../fixtures/energy";
|
||||
import { generateStatistics } from "../../../../fixtures/statistics";
|
||||
import { FIXED_EPOCH_MS } from "../../../../fixtures/history-states";
|
||||
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
|
||||
// The power graph reads `source.stat_rate` and looks the id up in
|
||||
// `energyData.stats` expecting `mean`-style values (power rate, kW). The
|
||||
// energy fixtures default to sum-style statistics, so we attach `stat_rate`
|
||||
// ids and overwrite their stats with mean-style data.
|
||||
const RATE_IDS = {
|
||||
grid: "sensor.grid_power",
|
||||
solar: "sensor.solar_power",
|
||||
battery: "sensor.battery_power",
|
||||
};
|
||||
|
||||
// The card resolves these theme colors via getComputedStyle.
|
||||
const computedStyles = createMockComputedStyle({
|
||||
"--energy-solar-color": "#ff9800",
|
||||
"--energy-grid-consumption-color": "#488fc2",
|
||||
"--energy-battery-out-color": "#4db6ac",
|
||||
"--primary-text-color": "#212121",
|
||||
});
|
||||
|
||||
interface BuildOptions {
|
||||
days: number;
|
||||
period?: "5minute" | "hour" | "day";
|
||||
compare?: boolean;
|
||||
grid?: boolean;
|
||||
solar?: boolean;
|
||||
battery?: boolean;
|
||||
}
|
||||
|
||||
const buildEnergyData = (seed: number, o: BuildOptions): EnergyData => {
|
||||
const prefs = generateEnergyPreferences({
|
||||
grid: o.grid,
|
||||
solar: o.solar,
|
||||
battery: o.battery,
|
||||
gas: false,
|
||||
water: false,
|
||||
});
|
||||
const ids: string[] = [];
|
||||
for (const source of prefs.energy_sources) {
|
||||
if (source.type === "grid") {
|
||||
source.stat_rate = RATE_IDS.grid;
|
||||
ids.push(RATE_IDS.grid);
|
||||
} else if (source.type === "solar") {
|
||||
source.stat_rate = RATE_IDS.solar;
|
||||
ids.push(RATE_IDS.solar);
|
||||
} else if (source.type === "battery") {
|
||||
source.stat_rate = RATE_IDS.battery;
|
||||
ids.push(RATE_IDS.battery);
|
||||
}
|
||||
}
|
||||
const base = generateEnergyData(seed, {
|
||||
days: o.days,
|
||||
period: o.period,
|
||||
compare: o.compare,
|
||||
prefs,
|
||||
});
|
||||
// mean-style stats (power rate). Allow some values to dip negative for the
|
||||
// battery/grid export path by subtracting a baseline.
|
||||
const meanStats = generateStatistics(seed + 100, {
|
||||
ids,
|
||||
period: o.period ?? "hour",
|
||||
days: o.days,
|
||||
sumStatistics: false,
|
||||
});
|
||||
return { ...base, stats: meanStats };
|
||||
};
|
||||
|
||||
describe("generatePowerSourcesGraphData", () => {
|
||||
const baseParams = {
|
||||
localize: mockLocalize,
|
||||
states: {} as HassEntities,
|
||||
computedStyles,
|
||||
start: new Date(FIXED_EPOCH_MS),
|
||||
end: new Date(FIXED_EPOCH_MS + dayMs),
|
||||
now: FIXED_EPOCH_MS + dayMs + 60 * 60 * 1000,
|
||||
} as const;
|
||||
|
||||
it("matches snapshot for grid + solar + battery (hour)", () => {
|
||||
const energyData = buildEnergyData(1, {
|
||||
days: 1,
|
||||
period: "hour",
|
||||
grid: true,
|
||||
solar: true,
|
||||
battery: true,
|
||||
});
|
||||
expect(
|
||||
generatePowerSourcesGraphData({ ...baseParams, energyData })
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("matches snapshot for grid-only (hour)", () => {
|
||||
const energyData = buildEnergyData(2, {
|
||||
days: 1,
|
||||
period: "hour",
|
||||
grid: true,
|
||||
solar: false,
|
||||
battery: false,
|
||||
});
|
||||
expect(
|
||||
generatePowerSourcesGraphData({ ...baseParams, energyData })
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("matches snapshot for solar + battery, no grid (hour)", () => {
|
||||
const energyData = buildEnergyData(3, {
|
||||
days: 1,
|
||||
period: "hour",
|
||||
grid: false,
|
||||
solar: true,
|
||||
battery: true,
|
||||
});
|
||||
expect(
|
||||
generatePowerSourcesGraphData({ ...baseParams, energyData })
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("matches snapshot with compare period", () => {
|
||||
const energyData = buildEnergyData(4, {
|
||||
days: 1,
|
||||
period: "hour",
|
||||
compare: true,
|
||||
grid: true,
|
||||
solar: true,
|
||||
battery: true,
|
||||
});
|
||||
expect(
|
||||
generatePowerSourcesGraphData({ ...baseParams, energyData })
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("appends current state when showing today", () => {
|
||||
// prev start/end and now all land on the same UTC day as FIXED_EPOCH_MS
|
||||
const now = FIXED_EPOCH_MS + 12 * 60 * 60 * 1000;
|
||||
const energyData = buildEnergyData(5, {
|
||||
days: 1,
|
||||
period: "hour",
|
||||
grid: true,
|
||||
solar: true,
|
||||
battery: true,
|
||||
});
|
||||
const states: HassEntities = {
|
||||
[RATE_IDS.grid]: createMockEntityState(RATE_IDS.grid, "1500", {
|
||||
unit_of_measurement: "W",
|
||||
}),
|
||||
[RATE_IDS.solar]: createMockEntityState(RATE_IDS.solar, "-800", {
|
||||
unit_of_measurement: "W",
|
||||
}),
|
||||
[RATE_IDS.battery]: createMockEntityState(RATE_IDS.battery, "200", {
|
||||
unit_of_measurement: "W",
|
||||
}),
|
||||
};
|
||||
expect(
|
||||
generatePowerSourcesGraphData({
|
||||
...baseParams,
|
||||
states,
|
||||
energyData,
|
||||
start: new Date(now),
|
||||
end: new Date(now + 1000),
|
||||
now,
|
||||
})
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("does not append current state when not showing today", () => {
|
||||
// prev start/end are 10 days before now -> isSameDay is false
|
||||
const now = FIXED_EPOCH_MS + dayMs;
|
||||
const energyData = buildEnergyData(6, {
|
||||
days: 1,
|
||||
period: "hour",
|
||||
grid: true,
|
||||
solar: true,
|
||||
battery: true,
|
||||
});
|
||||
const states: HassEntities = {
|
||||
[RATE_IDS.grid]: createMockEntityState(RATE_IDS.grid, "9999", {
|
||||
unit_of_measurement: "W",
|
||||
}),
|
||||
};
|
||||
expect(
|
||||
generatePowerSourcesGraphData({
|
||||
...baseParams,
|
||||
states,
|
||||
energyData,
|
||||
start: new Date(now - 10 * dayMs),
|
||||
end: new Date(now - 9 * dayMs),
|
||||
now,
|
||||
})
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("large multi-day 5-minute payload digest is stable", () => {
|
||||
const energyData = buildEnergyData(42, {
|
||||
days: 14,
|
||||
period: "5minute",
|
||||
compare: true,
|
||||
grid: true,
|
||||
solar: true,
|
||||
battery: true,
|
||||
});
|
||||
expect(
|
||||
digestResult(
|
||||
generatePowerSourcesGraphData({
|
||||
...baseParams,
|
||||
energyData,
|
||||
end: new Date(FIXED_EPOCH_MS + 14 * dayMs),
|
||||
})
|
||||
)
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user