Compare commits

...
Author SHA1 Message Date
Petar Petrov cb1ac2161f Show battery discharge per battery in energy usage graph 2026-09-24 11:47:15 +01:00
5 changed files with 165 additions and 105 deletions
@@ -0,0 +1,45 @@
import { round } from "../../../../common/number/round";
/**
* In periods where part of the sources' energy did not reach the home (grid
* charging a battery, a battery exporting), per-source attribution is
* ambiguous if multiple sources have data in the same period. Values that
* round to 0 Wh are float noise from subtracting sums.
* Rewrites single-source periods in place on `bySource`, and returns a
* combined used map for multi-source periods. Returns undefined when no
* combined series is needed so the chart does not add an empty legend item.
*/
export function buildCombinedUsed(
bySource: Record<string, Record<number, number>>,
notUsed: Record<number, number>,
used: Record<number, number>
): Record<number, number> | undefined {
const combined: Record<number, number> = {};
for (const [start, notUsedInPeriod] of Object.entries(notUsed)) {
if (!round(notUsedInPeriod, 3)) {
continue;
}
let noOfSources = 0;
let source: string | undefined;
for (const [key, stats] of Object.entries(bySource)) {
if (stats[start]) {
source = key;
noOfSources++;
}
if (noOfSources > 1) {
break;
}
}
if (noOfSources === 1 && source) {
bySource[source][start] = used[start];
} else {
Object.values(bySource).forEach((stats) => {
delete stats[start];
});
if (round(used[start], 3)) {
combined[start] = used[start];
}
}
}
return Object.keys(combined).length > 0 ? combined : undefined;
}
@@ -1,39 +0,0 @@
/**
* When battery is charging from grid, per-source import attribution is
* ambiguous if multiple grid sources have data in the same period.
* Rewrites single-source periods in place on `fromGridBySource`, and returns
* a combined used-grid map for multi-source periods. Returns undefined when
* no combined series is needed so the chart does not add an empty legend item.
*/
export function buildCombinedUsedGrid(
fromGridBySource: Record<string, Record<number, number>>,
gridToBattery: Record<number, number>,
usedGrid: Record<number, number>
): Record<number, number> | undefined {
const used_grid: Record<number, number> = {};
for (const [start, grid_to_battery] of Object.entries(gridToBattery)) {
if (!grid_to_battery) {
continue;
}
let noOfSources = 0;
let source: string | undefined;
for (const [key, stats] of Object.entries(fromGridBySource)) {
if (stats[start]) {
source = key;
noOfSources++;
}
if (noOfSources > 1) {
break;
}
}
if (noOfSources === 1 && source) {
fromGridBySource[source][start] = usedGrid[start];
} else {
Object.values(fromGridBySource).forEach((stats) => {
delete stats[start];
});
used_grid[start] = usedGrid[start];
}
}
return Object.keys(used_grid).length > 0 ? used_grid : undefined;
}
@@ -44,7 +44,7 @@ import {
} from "./common/energy-chart-options";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
import { buildCombinedUsedGrid } from "./energy-usage-graph-used-grid";
import { buildCombinedUsed } from "./energy-usage-graph-combined-used";
const colorPropertyMap = {
to_grid: "--energy-grid-return-color",
@@ -52,6 +52,7 @@ const colorPropertyMap = {
from_grid: "--energy-grid-consumption-color",
used_grid: "--energy-grid-consumption-color",
used_solar: "--energy-solar-color",
from_battery: "--energy-battery-out-color",
used_battery: "--energy-battery-out-color",
};
@@ -59,6 +60,7 @@ const stackOrder = {
to_battery: 1,
to_grid: 2,
used_solar: 3,
from_battery: 4,
used_battery: 4,
from_grid: 5,
used_grid: 5,
@@ -297,10 +299,12 @@ export class HuiEnergyUsageGraphCard
to_grid: Record<string, string>;
from_grid: Record<string, string>;
to_battery: Record<string, string>;
from_battery: Record<string, string>;
} = {
to_grid: {},
from_grid: {},
to_battery: {},
from_battery: {},
};
// Grid sources can be import-only or export-only; assign color indices by
@@ -335,6 +339,10 @@ export class HuiEnergyUsageGraphCard
"ui.panel.lovelace.cards.energy.energy_sources_table.named_battery_charged",
{ name: source.name }
);
statLabels.from_battery[source.stat_energy_from] = this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_sources_table.named_battery_discharged",
{ name: source.name }
);
}
continue;
}
@@ -543,6 +551,7 @@ export class HuiEnergyUsageGraphCard
to_grid: Record<string, string>;
from_grid: Record<string, string>;
to_battery: Record<string, string>;
from_battery: Record<string, string>;
},
trackY: (v: number) => void,
compare = false
@@ -553,13 +562,16 @@ export class HuiEnergyUsageGraphCard
to_grid?: Record<string, Record<number, number>>;
to_battery?: Record<string, Record<number, number>>;
from_grid?: Record<string, Record<number, number>>;
from_battery?: Record<string, Record<number, number>>;
used_grid?: Record<string, Record<number, number>>;
used_solar?: Record<string, Record<number, number>>;
used_battery?: Record<string, Record<number, number>>;
} = {};
Object.entries(statIdsByCat).forEach(([key, statIds]) => {
if (!["to_grid", "from_grid", "to_battery"].includes(key)) {
if (
!["to_grid", "from_grid", "to_battery", "from_battery"].includes(key)
) {
return;
}
const sets: Record<string, Record<number, number>> = {};
@@ -584,21 +596,34 @@ export class HuiEnergyUsageGraphCard
combinedData[key] = sets;
});
// Only add solar/battery consumption series when such a source is
// actually configured, otherwise the legend shows empty solar/battery
// entries for grid-only setups. Combined used_grid is a fallback for
// multi-source battery charging; skip it when it has no points.
// Only add the solar consumption series when solar is configured,
// otherwise the legend shows an empty solar entry for grid-only setups.
// Combined used_grid and used_battery are fallbacks for periods that
// can't be split per source; skip them when they have no points.
if (statIdsByCat.solar) {
combinedData.used_solar = { used_solar: consumptionData.used_solar };
}
if (statIdsByCat.from_battery) {
combinedData.used_battery = {
used_battery: consumptionData.used_battery,
};
if (combinedData.from_battery) {
// Discharge that did not reach the home
const batteryNotUsed: Record<number, number> = {};
for (const start of summedData.timestamps) {
batteryNotUsed[start] =
(summedData.from_battery?.[start] ?? 0) -
consumptionData.used_battery[start];
}
const used_battery = buildCombinedUsed(
combinedData.from_battery,
batteryNotUsed,
consumptionData.used_battery
);
if (used_battery) {
combinedData.used_battery = { used_battery };
}
}
if (combinedData.from_grid && summedData.to_battery) {
const used_grid = buildCombinedUsedGrid(
const used_grid = buildCombinedUsed(
combinedData.from_grid,
consumptionData.grid_to_battery,
consumptionData.used_grid
@@ -0,0 +1,84 @@
/**
* Protects the energy usage graph from adding an empty combined Grid or
* Battery legend item, while still combining sources when multiple grid
* imports share a battery-charging period or multiple batteries share an
* exporting period.
*/
import { assert, describe, it } from "vitest";
import { buildCombinedUsed } from "../../../../../src/panels/lovelace/cards/energy/energy-usage-graph-combined-used";
const t = 1_700_000_000_000;
describe("buildCombinedUsed", () => {
it("does not add a combined series for a single grid source charging a battery", () => {
const fromGridBySource = {
"sensor.grid_import": { [t]: 10 },
};
const result = buildCombinedUsed(fromGridBySource, { [t]: 3 }, { [t]: 7 });
assert.isUndefined(result);
assert.equal(fromGridBySource["sensor.grid_import"][t], 7);
});
it("combines overlapping grid sources and removes per-source points", () => {
const fromGridBySource = {
"sensor.grid_import_a": { [t]: 6 },
"sensor.grid_import_b": { [t]: 4 },
};
const result = buildCombinedUsed(fromGridBySource, { [t]: 3 }, { [t]: 7 });
assert.deepEqual(result, { [t]: 7 });
assert.isUndefined(fromGridBySource["sensor.grid_import_a"][t]);
assert.isUndefined(fromGridBySource["sensor.grid_import_b"][t]);
});
it("does not add a combined series when battery is present but not charging from grid", () => {
const fromGridBySource = {
"sensor.grid_import": { [t]: 10 },
};
const result = buildCombinedUsed(fromGridBySource, {}, { [t]: 10 });
assert.isUndefined(result);
assert.equal(fromGridBySource["sensor.grid_import"][t], 10);
});
it("does not add a combined series when the home used none of the energy", () => {
const fromBatteryBySource = {
"sensor.battery_a_out": { [t]: 2 },
"sensor.battery_b_out": { [t]: 1 },
};
// All discharge exported; the model leaves 2.8e-17 as "used"
const result = buildCombinedUsed(
fromBatteryBySource,
{ [t]: 3 },
{ [t]: 2.7755575615628914e-17 }
);
assert.isUndefined(result);
assert.isUndefined(fromBatteryBySource["sensor.battery_a_out"][t]);
assert.isUndefined(fromBatteryBySource["sensor.battery_b_out"][t]);
});
it("treats float noise in the unused energy as zero", () => {
const fromGridBySource = {
"sensor.grid_import_a": { [t]: 0.06 },
"sensor.grid_import_b": { [t]: 0.04 },
};
// 0.06 + 0.04 + 0.4 solar - 0.4 to battery leaves 2.8e-17 "grid to battery"
const result = buildCombinedUsed(
fromGridBySource,
{ [t]: 2.7755575615628914e-17 },
{ [t]: 0.1 }
);
assert.isUndefined(result);
assert.equal(fromGridBySource["sensor.grid_import_a"][t], 0.06);
assert.equal(fromGridBySource["sensor.grid_import_b"][t], 0.04);
});
});
@@ -1,55 +0,0 @@
/**
* Protects the energy usage graph from adding an empty combined Grid
* legend item for single-source + battery setups, while still combining
* sources when multiple grid imports share a battery-charging period.
*/
import { assert, describe, it } from "vitest";
import { buildCombinedUsedGrid } from "../../../../../src/panels/lovelace/cards/energy/energy-usage-graph-used-grid";
const t = 1_700_000_000_000;
describe("buildCombinedUsedGrid", () => {
it("does not add a combined series for a single grid source charging a battery", () => {
const fromGridBySource = {
"sensor.grid_import": { [t]: 10 },
};
const result = buildCombinedUsedGrid(
fromGridBySource,
{ [t]: 3 },
{ [t]: 7 }
);
assert.isUndefined(result);
assert.equal(fromGridBySource["sensor.grid_import"][t], 7);
});
it("combines overlapping grid sources and removes per-source points", () => {
const fromGridBySource = {
"sensor.grid_import_a": { [t]: 6 },
"sensor.grid_import_b": { [t]: 4 },
};
const result = buildCombinedUsedGrid(
fromGridBySource,
{ [t]: 3 },
{ [t]: 7 }
);
assert.deepEqual(result, { [t]: 7 });
assert.isUndefined(fromGridBySource["sensor.grid_import_a"][t]);
assert.isUndefined(fromGridBySource["sensor.grid_import_b"][t]);
});
it("does not add a combined series when battery is present but not charging from grid", () => {
const fromGridBySource = {
"sensor.grid_import": { [t]: 10 },
};
const result = buildCombinedUsedGrid(fromGridBySource, {}, { [t]: 10 });
assert.isUndefined(result);
assert.equal(fromGridBySource["sensor.grid_import"][t], 10);
});
});