New solar-consumed-gauge algorithm (#25326)

* New solar-consumed-gauge algorithm

* update tests

* Apply suggestion

* reduce duplicate code
This commit is contained in:
karwosts 2025-05-07 07:35:24 -07:00 committed by GitHub
parent 6d931b9e37
commit 821a0bc418
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 354 additions and 10 deletions

View File

@ -1118,3 +1118,88 @@ export const formatConsumptionShort = (
pickedUnit 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;
};

View File

@ -9,6 +9,7 @@ import "../../../../components/ha-gauge";
import "../../../../components/ha-svg-icon"; import "../../../../components/ha-svg-icon";
import type { EnergyData } from "../../../../data/energy"; import type { EnergyData } from "../../../../data/energy";
import { import {
calculateSolarConsumedGauge,
getEnergyDataCollection, getEnergyDataCollection,
getSummedData, getSummedData,
} from "../../../../data/energy"; } from "../../../../data/energy";
@ -78,18 +79,12 @@ class HuiEnergySolarGaugeCard
return nothing; return nothing;
} }
const totalSolarProduction = summedData.total.solar;
const productionReturnedToGrid = summedData.total.to_grid ?? null; const productionReturnedToGrid = summedData.total.to_grid ?? null;
let value: number | undefined; let value: number | undefined;
if (productionReturnedToGrid !== null) {
if (productionReturnedToGrid !== null && totalSolarProduction) { const hasBattery = !!summedData.to_battery || !!summedData.from_battery;
const consumedSolar = Math.max( value = calculateSolarConsumedGauge(hasBattery, summedData);
0,
totalSolarProduction - productionReturnedToGrid
);
value = (consumedSolar / totalSolarProduction) * 100;
} }
return html` return html`
@ -125,7 +120,7 @@ class HuiEnergySolarGaugeCard
)} )}
</div> </div>
` `
: totalSolarProduction === 0 : productionReturnedToGrid !== null
? this.hass.localize( ? this.hass.localize(
"ui.panel.lovelace.cards.energy.solar_consumed_gauge.not_produced_solar_energy" "ui.panel.lovelace.cards.energy.solar_consumed_gauge.not_produced_solar_energy"
) )

View File

@ -11,6 +11,7 @@ import {
import { import {
computeConsumptionSingle, computeConsumptionSingle,
formatConsumptionShort, formatConsumptionShort,
calculateSolarConsumedGauge,
} from "../../src/data/energy"; } from "../../src/data/energy";
import type { HomeAssistant } from "../../src/types"; 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)
);
});
});