Compare commits

...

3 Commits

Author SHA1 Message Date
Petar Petrov df6b22498a Optimize power sources graph card data generation 2026-06-15 16:27:14 +03:00
Petar Petrov bc6cef2651 Add characterization tests and benchmark for power sources graph card data 2026-06-15 16:17:11 +03:00
Petar Petrov 9f1f351118 Extract power sources graph card series generation into a pure module 2026-06-15 16:17:11 +03:00
5 changed files with 6162 additions and 253 deletions
@@ -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) },
});
});
});
@@ -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();
});
});