diff --git a/src/data/energy.ts b/src/data/energy.ts index 06498ace9b..463b6bb4b6 100644 --- a/src/data/energy.ts +++ b/src/data/energy.ts @@ -1118,3 +1118,88 @@ export const formatConsumptionShort = ( pickedUnit ); }; + +export const calculateSolarConsumedGauge = ( + hasBattery: boolean, + data: EnergySumData +): number | undefined => { + if (!data.total.solar) { + return undefined; + } + const { consumption, compareConsumption: _ } = computeConsumptionData( + data, + undefined + ); + if (!hasBattery) { + const solarProduction = data.total.solar; + return (consumption.total.used_solar / solarProduction) * 100; + } + + let solarConsumed = 0; + let solarReturned = 0; + const batteryLifo: { type: "solar" | "grid"; value: number }[] = []; + + // Here we will attempt to track consumed solar energy, as it routes through the battery and ultimately to consumption or grid. + // At each timestamp we will track energy added to the battery (and its source), and we will drain this in Last-in/First-out order. + // Energy leaving the battery when the stack is empty will just be ignored, as we cannot determine where it came from. + // This is likely energy stored during a previous period. + + data.timestamps.forEach((t) => { + solarConsumed += consumption.used_solar[t] ?? 0; + solarReturned += consumption.solar_to_grid[t] ?? 0; + + if (consumption.grid_to_battery[t]) { + batteryLifo.push({ + type: "grid", + value: consumption.grid_to_battery[t], + }); + } + if (consumption.solar_to_battery[t]) { + batteryLifo.push({ + type: "solar", + value: consumption.solar_to_battery[t], + }); + } + + let batteryToGrid = consumption.battery_to_grid[t] ?? 0; + let usedBattery = consumption.used_battery[t] ?? 0; + + const drainBattery = function (amount: number): { + energy: number; + type: "solar" | "grid"; + } { + const lastLifo = batteryLifo[batteryLifo.length - 1]; + const type = lastLifo.type; + if (amount >= lastLifo.value) { + const energy = lastLifo.value; + batteryLifo.pop(); + return { energy, type }; + } + lastLifo.value -= amount; + return { energy: amount, type }; + }; + + while (usedBattery > 0 && batteryLifo.length) { + const { energy, type } = drainBattery(usedBattery); + if (type === "solar") { + solarConsumed += energy; + } + usedBattery -= energy; + } + + while (batteryToGrid > 0 && batteryLifo.length) { + const { energy, type } = drainBattery(batteryToGrid); + if (type === "solar") { + solarReturned += energy; + } + batteryToGrid -= energy; + } + }); + + const totalProduction = solarConsumed + solarReturned; + const hasSolarProduction = !!totalProduction; + if (hasSolarProduction) { + return (solarConsumed / totalProduction) * 100; + } + return undefined; +}; diff --git a/src/panels/lovelace/cards/energy/hui-energy-solar-consumed-gauge-card.ts b/src/panels/lovelace/cards/energy/hui-energy-solar-consumed-gauge-card.ts index 83e5e32c72..0ccc962fa7 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-solar-consumed-gauge-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-solar-consumed-gauge-card.ts @@ -9,6 +9,7 @@ import "../../../../components/ha-gauge"; import "../../../../components/ha-svg-icon"; import type { EnergyData } from "../../../../data/energy"; import { + calculateSolarConsumedGauge, getEnergyDataCollection, getSummedData, } from "../../../../data/energy"; @@ -78,18 +79,12 @@ class HuiEnergySolarGaugeCard return nothing; } - const totalSolarProduction = summedData.total.solar; - const productionReturnedToGrid = summedData.total.to_grid ?? null; let value: number | undefined; - - if (productionReturnedToGrid !== null && totalSolarProduction) { - const consumedSolar = Math.max( - 0, - totalSolarProduction - productionReturnedToGrid - ); - value = (consumedSolar / totalSolarProduction) * 100; + if (productionReturnedToGrid !== null) { + const hasBattery = !!summedData.to_battery || !!summedData.from_battery; + value = calculateSolarConsumedGauge(hasBattery, summedData); } return html` @@ -125,7 +120,7 @@ class HuiEnergySolarGaugeCard )} ` - : totalSolarProduction === 0 + : productionReturnedToGrid !== null ? this.hass.localize( "ui.panel.lovelace.cards.energy.solar_consumed_gauge.not_produced_solar_energy" ) diff --git a/test/data/energy.test.ts b/test/data/energy.test.ts index ba8d159e08..d393f01624 100644 --- a/test/data/energy.test.ts +++ b/test/data/energy.test.ts @@ -11,6 +11,7 @@ import { import { computeConsumptionSingle, formatConsumptionShort, + calculateSolarConsumedGauge, } from "../../src/data/energy"; import type { HomeAssistant } from "../../src/types"; @@ -506,3 +507,266 @@ describe("Energy Usage Calculation Tests", () => { ); }); }); + +describe("Self-consumed solar gauge tests", () => { + it("no battery", () => { + const hasBattery = false; + assert.deepEqual( + calculateSolarConsumedGauge(hasBattery, { + total: {}, + timestamps: [0], + }), + undefined + ); + assert.deepEqual( + calculateSolarConsumedGauge(hasBattery, { + solar: { + "0": 0, + }, + total: { + solar: 0, + }, + timestamps: [0], + }), + undefined + ); + assert.deepEqual( + calculateSolarConsumedGauge(hasBattery, { + solar: { + "0": 1, + "1": 3, + }, + total: { + solar: 4, + }, + timestamps: [0, 1], + }), + 100 + ); + assert.deepEqual( + calculateSolarConsumedGauge(hasBattery, { + solar: { + "0": 1, + "1": 3, + }, + to_grid: { + "1": 1, + }, + total: { + solar: 4, + to_grid: 1, + }, + timestamps: [0, 1], + }), + 75 + ); + assert.deepEqual( + calculateSolarConsumedGauge(hasBattery, { + solar: { + "0": 1, + "1": 3, + }, + to_grid: { + "0": 1, + "1": 3, + }, + total: { + solar: 4, + to_grid: 4, + }, + timestamps: [0, 1], + }), + 0 + ); + }); + it("with battery", () => { + const hasBattery = true; + assert.deepEqual( + calculateSolarConsumedGauge(hasBattery, { + total: {}, + timestamps: [0], + }), + undefined + ); + assert.deepEqual( + calculateSolarConsumedGauge(hasBattery, { + solar: { + "0": 0, + }, + total: { + solar: 0, + }, + timestamps: [0], + }), + undefined + ); + assert.deepEqual( + calculateSolarConsumedGauge(hasBattery, { + solar: { + "0": 1, + "1": 3, + }, + total: { + solar: 4, + }, + timestamps: [0, 1], + }), + 100 + ); + assert.deepEqual( + calculateSolarConsumedGauge(hasBattery, { + solar: { + "0": 1, + "1": 3, + }, + to_grid: { + "1": 1, + }, + total: { + solar: 4, + }, + timestamps: [0, 1], + }), + 75 + ); + assert.deepEqual( + calculateSolarConsumedGauge(hasBattery, { + solar: { + "10": 1, + }, + to_grid: { + "0": 1, + "1": 1, + "2": 1, + "3": 1, + }, + from_battery: { + "0": 1, + "1": 1, + "2": 1, + "3": 1, + }, + total: { + solar: 1, + }, + timestamps: [0, 1, 2, 3, 10], + }), + // As the battery is discharged from unknown source, it does not affect solar production number. + 100 + ); + assert.deepEqual( + calculateSolarConsumedGauge(hasBattery, { + solar: { + "0": 10, + }, + to_battery: { + "0": 10, + }, + to_grid: { + "1": 3, + "3": 1, + }, + from_battery: { + "1": 3, + "2": 2, + "3": 2, + "4": 3, + "5": 100, // Unknown source, not counted + }, + total: { + solar: 10, + }, + timestamps: [0, 1, 2, 3, 4, 5], + }), + // As the battery is discharged from unknown source, it does not affect solar production number. + 60 + ); + }); + it("complex battery/solar/grid", () => { + const hasBattery = true; + + const value = calculateSolarConsumedGauge(hasBattery, { + solar: { + "1": 6, + "2": 0, + "3": 7, + }, + to_battery: { + "1": 5, + "2": 5, + "3": 7, + }, + to_grid: { + "0": 5, + "10": 1, + "11": 1, + "12": 5, + "13": 3, + }, + from_grid: { + "2": 5, + }, + from_battery: { + "0": 5, + "10": 3, + "11": 4, + "12": 5, + "13": 5, + }, + total: { + // Total is mostly don't care when hasBattery, only hourly values are used + solar: 13, + }, + timestamps: [0, 1, 2, 3, 10, 11, 12, 13], + }); + // "1" - consumed 1 solar, 5 sent to battery + // "10" - consumed 2/3 of solar energy stored in battery + // "11" - consumed 3/4 of solar energy stored in battery + // "12" - skipped as this is energy from grid, not counted + // "13" - consumed 2/5 of solar energy stored in battery + const expectedNumerator = 1 + 2 + 3 + 0 + 2; // 8 + const expectedDenominator = 1 + 3 + 4 + 0 + 5; // 13 + assert.equal( + Math.round(value!), + Math.round((expectedNumerator / expectedDenominator) * 100) + ); + }); + + it("complex battery/solar/grid #2", () => { + const hasBattery = true; + const value = calculateSolarConsumedGauge(hasBattery, { + solar: { + "0": 100, + "2": 100, + }, + to_battery: { + "0": 100, + "1": 100, + "2": 100, + }, + to_grid: { + "10": 50, + }, + from_grid: { + "1": 100, + }, + from_battery: { + "10": 300, + }, + total: { + solar: 200, + to_battery: 300, + to_grid: 50, + from_grid: 100, + from_battery: 300, + }, + timestamps: [0, 1, 2, 10], + }); + const expectedNumerator = 200 - 50; + const expectedDenominator = 200; // ignoring 100 from grid + assert.equal( + Math.round(value!), + Math.round((expectedNumerator / expectedDenominator) * 100) + ); + }); +});