Compare commits

...

1 Commits

Author SHA1 Message Date
Franck Nijhof b517ca1987 Convert power source statistics to the graph unit
The power sources graph plots in kW. The live current state was already
normalized to kW, but the historical statistics were used in the sensor's
native unit without any conversion. A sensor reporting in W was therefore
plotted a thousand times too high.

Normalize each statistic to kW based on its unit, the same way the current
state is handled, so sensors in W, kW, or MW all plot consistently.
2026-06-19 13:27:45 +00:00
2 changed files with 77 additions and 2 deletions
@@ -5,6 +5,7 @@ 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 { normalizeValueBySIPrefix } from "../../../../common/number/normalize-by-si-prefix";
import type { EnergyData } from "../../../../data/energy";
import { getPowerFromState } from "../../../../data/energy";
import type { StatisticValue } from "../../../../data/recorder";
@@ -165,7 +166,18 @@ export function generatePowerSourcesGraphData(
// 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] ?? [])];
// Stats are stored in the sensor's native unit (W, kW, MW, …).
// Normalize them to kW so a sensor reporting in W is not read as kW.
const statUnit =
energyData.statsMetadata[id]?.statistics_unit_of_measurement ??
undefined;
const stats = (energyData.stats[id] ?? []).map((point) => ({
...point,
mean:
point.mean == null
? point.mean
: normalizeValueBySIPrefix(point.mean, statUnit) / 1000,
}));
if (showingToday) {
// Append current state if we are showing today
const currentStateWatts = getPowerFromState(states[id]);
@@ -6,6 +6,8 @@
import { describe, expect, it } from "vitest";
import type { HassEntities } from "home-assistant-js-websocket";
import type { EnergyData } from "../../../../../src/data/energy";
import type { StatisticsMetaData } from "../../../../../src/data/recorder";
import { StatisticMeanType } from "../../../../../src/data/recorder";
import { generatePowerSourcesGraphData } from "../../../../../src/panels/lovelace/cards/energy/power-sources-graph-data";
import { createMockComputedStyle } from "../../../../fixtures/computed-style";
import { digestResult } from "../../../../fixtures/digest";
@@ -44,8 +46,30 @@ interface BuildOptions {
grid?: boolean;
solar?: boolean;
battery?: boolean;
// Native unit the rate sensors report their statistics in. The graph plots
// in kW, so a "kW" sensor needs no conversion while a "W" sensor must be
// divided by 1000.
unit?: string;
}
const buildStatsMetadata = (
ids: string[],
unit: string
): Record<string, StatisticsMetaData> =>
Object.fromEntries(
ids.map((id) => [
id,
{
statistic_id: id,
source: "recorder",
statistics_unit_of_measurement: unit,
unit_class: "power",
has_sum: false,
mean_type: StatisticMeanType.ARITHMETIC,
},
])
);
const buildEnergyData = (seed: number, o: BuildOptions): EnergyData => {
const prefs = generateEnergyPreferences({
grid: o.grid,
@@ -81,7 +105,11 @@ const buildEnergyData = (seed: number, o: BuildOptions): EnergyData => {
days: o.days,
sumStatistics: false,
});
return { ...base, stats: meanStats };
return {
...base,
stats: meanStats,
statsMetadata: buildStatsMetadata(ids, o.unit ?? "kW"),
};
};
describe("generatePowerSourcesGraphData", () => {
@@ -207,6 +235,41 @@ describe("generatePowerSourcesGraphData", () => {
).toMatchSnapshot();
});
it("converts W-unit stats to kW (divides by 1000)", () => {
const options: BuildOptions = {
days: 1,
period: "hour",
grid: true,
solar: true,
battery: true,
};
const kwResult = generatePowerSourcesGraphData({
...baseParams,
energyData: buildEnergyData(7, { ...options, unit: "kW" }),
});
const wResult = generatePowerSourcesGraphData({
...baseParams,
energyData: buildEnergyData(7, { ...options, unit: "W" }),
});
// The series carry gap-fill nulls and derived lines, so compare the
// overall magnitude: the same readings in W must plot 1000x smaller than
// in kW.
const maxAbsValue = (result: { chartData: { data?: unknown }[] }): number =>
Math.max(
...result.chartData.flatMap((series) =>
(series.data as [number, number | null][])
.map((point) => point?.[1])
.filter((value): value is number => typeof value === "number")
.map(Math.abs)
)
);
const kwMax = maxAbsValue(kwResult);
expect(kwMax).toBeGreaterThan(0);
expect(maxAbsValue(wResult)).toBeCloseTo(kwMax / 1000, 10);
});
it("large multi-day 5-minute payload digest is stable", () => {
const energyData = buildEnergyData(42, {
days: 14,