mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-25 22:40:24 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb1ac2161f |
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user