Skip to content
Merged
58 changes: 58 additions & 0 deletions assets/js/components/Energy/GridStats.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import StatCards from "./StatCards.vue";
import formatter from "@/mixins/formatter";
import { CURRENCY } from "@/types/evcc";
import type { FlowCost, StatItem } from "./types";
import type { FeedInSplitTotals } from "./feedInEeg";

// what import cost and export earned, pointing at the tariff settings without one
export default defineComponent({
Expand All @@ -17,9 +18,12 @@ export default defineComponent({
props: {
cost: { type: Object as PropType<FlowCost> },
currency: { type: String as PropType<CURRENCY>, default: CURRENCY.EUR },
// custom: export split by feed-in tariff, see feedInEeg.ts
feedInEeg: { type: Object as PropType<FeedInSplitTotals | null>, default: null },
},
computed: {
stats(): StatItem[] {
if (this.feedInEeg) return this.feedInEegStats(this.feedInEeg); // custom
const cost = this.cost;
if (!cost) {
const empty = this.$t("energy.stat.noPrice");
Expand Down Expand Up @@ -49,5 +53,59 @@ export default defineComponent({
];
},
},
methods: {
// custom: revenue per feed-in tariff, priced slot by slot on its own and so
// also shown without a grid price, see feedInEeg.ts
feedInEegStats(e: FeedInSplitTotals): StatItem[] {
const empty = this.$t("energy.stat.noPrice");
const money = (
key: string,
label: string,
amount: number,
energy: number
): StatItem => ({
key,
label,
number: amount,
format: (v: number) => this.fmtMoneyWithSymbol(v, this.currency),
sub: energy ? `ø ${this.fmtPricePerKWh(amount / energy, this.currency)}` : "",
});
const revenue = (
key: string,
label: string,
amount: number,
energy: number,
priced: number
): StatItem =>
energy > 0 && !priced
? { key, label, empty }
: { ...money(key, label, amount, priced), accent: "text-accent1" };
const cost = this.cost;
return [
cost
? money(
"gridImport",
this.$t("energy.grid.cost"),
cost.import,
cost.importEnergy
)
: { key: "gridImport", label: this.$t("energy.grid.cost"), empty },
revenue(
"gridExport",
this.$t("energy.grid.revenue"),
e.standardRevenue,
e.standard,
e.standardPriced
),
revenue(
"gridExportEeg",
this.$t("energy.feedInEeg.revenue"),
e.eegRevenue,
e.eeg,
e.eegPriced
),
];
},
},
});
</script>
3 changes: 3 additions & 0 deletions assets/js/components/Energy/GroupChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export interface HistorySeries {
virtual?: boolean;
// explicit color, skips palette resolution
color?: string;
// custom: export color of a split export, see components/Energy/feedInEeg.ts
returnColor?: string;
// socTemp holds a temperature, the entity heats instead of charging
isTemp?: boolean;
// Stable index into the palette, preserved across navigations even when the
Expand Down Expand Up @@ -554,6 +556,7 @@ export default defineComponent({
this.series.forEach((s, i) => {
const c = this.entryColors[i] || this.color;
const returnEnergyColor =
s.returnColor || // custom: split export, see feedInEeg.ts
(s.group === "grid" && colors.export) ||
(s.group === "battery" ? setAlpha(c, "cc") || c : c);
const energyValues = energyByEntity[i]!;
Expand Down
45 changes: 45 additions & 0 deletions assets/js/components/Energy/feedInEeg.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, test } from "vite-plus/test";
import { splitGridSeries, type FeedInSplit } from "./feedInEeg";
import type { HistorySeries } from "./GroupChart.vue";

const start = "2026-10-05T10:00:00Z";
const end = "2026-10-05T10:15:00Z";

const grid = (title: string, returnEnergy: number): HistorySeries => ({
title,
group: "grid",
data: [{ start, end, energy: 0, returnEnergy }],
});

const split = (eeg: number): FeedInSplit[] => [
{
start,
end,
export: 0,
eeg,
standard: 0,
eegRevenue: 0,
standardRevenue: 0,
eegPriced: 0,
standardPriced: 0,
},
];

describe("splitGridSeries", () => {
test("takes the EEG part out of the export", () => {
const [eeg, std] = splitGridSeries([grid("grid", 3)], split(1), "15m", "Netz");
expect(eeg!.data[0]!.returnEnergy).toBe(1);
expect(std!.data[0]!.returnEnergy).toBe(2);
});

test("after a grid meter swap the EEG part comes off only once", () => {
const [, a, b] = splitGridSeries([grid("old", 1), grid("new", 2)], split(1.5), "15m", "Netz");
expect(a!.data[0]!.returnEnergy).toBe(0);
expect(b!.data[0]!.returnEnergy).toBe(1.5);
});

test("never below zero", () => {
const [, std] = splitGridSeries([grid("grid", 1)], split(2), "15m", "Netz");
expect(std!.data[0]!.returnEnergy).toBe(0);
});
});
116 changes: 116 additions & 0 deletions assets/js/components/Energy/feedInEeg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Custom extension: export sold under two feed-in tariffs, see
// core/site_feedin_eeg.go. The energy page splits the grid export into the
// standard feed-in tariff and the EEG part metered by a Home Assistant counter,
// in the chart, the legend and the revenue tiles. Without a counter nothing
// changes.
import api from "@/api";
import colors, { lighten } from "@/colors";
import type { HistorySeries, HistorySlot } from "./GroupChart.vue";

export interface FeedInSplit {
start: string;
end: string;
export: number; // kWh, grid meter
eeg: number; // kWh, EEG counter
standard: number; // kWh, export minus EEG
eegRevenue: number;
standardRevenue: number;
eegPriced: number; // kWh with a known EEG price
standardPriced: number;
}

export type FeedInSplitTotals = Omit<FeedInSplit, "start" | "end">;

// title of the EEG counter's own series, see metrics.FeedInEeg
const FEED_IN_EEG = "feedin-eeg";

// the EEG counter is shown in the grid card, not again among the meters
export function withoutFeedInEeg(meters: HistorySeries[]): HistorySeries[] {
return meters.filter((s) => s.title !== FEED_IN_EEG);
}

export async function fetchFeedInSplit(
from: Date,
to: Date,
aggregate: string
): Promise<FeedInSplit[]> {
const { data } = await api.get("feedinsplit", {
params: { from: from.toISOString(), to: to.toISOString(), aggregate },
});
return data || [];
}

export function feedInSplitTotals(split: FeedInSplit[]): FeedInSplitTotals {
const sum = (key: keyof FeedInSplitTotals) => split.reduce((acc, b) => acc + b[key], 0);
return {
export: sum("export"),
eeg: sum("eeg"),
standard: sum("standard"),
eegRevenue: sum("eegRevenue"),
standardRevenue: sum("standardRevenue"),
eegPriced: sum("eegPriced"),
standardPriced: sum("standardPriced"),
};
}

// the EEG part next to the standard export, a lighter shade of the export color
export function eegColor(): string {
return lighten(colors.export || "", 0.5);
}

// bucket key independent of the first slot a history bucket starts with
function bucketKey(start: string, aggregate: string): string {
const d = new Date(start);
const pad = (n: number) => String(n).padStart(2, "0");
const day = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
switch (aggregate) {
case "month":
return day.slice(0, 7);
case "day":
return day;
case "hour":
return `${day} ${pad(d.getHours())}`;
default:
return String(d.getTime());
}
}

// Grid series with the EEG part taken out of the export, plus the EEG part as a
// series of its own, stacked below it in the export direction.
export function splitGridSeries(
grid: HistorySeries[],
split: FeedInSplit[],
aggregate: string,
gridTitle: string
): HistorySeries[] {
// EEG left to take out per bucket: after a grid meter swap a bucket has
// export on several grid series, the EEG part comes off only once
const eeg = new Map(split.map((b) => [bucketKey(b.start, aggregate), b.eeg]));
const takeOut = (key: string, export_: number) => {
const part = Math.min(export_, eeg.get(key) || 0);
eeg.set(key, (eeg.get(key) || 0) - part);
return export_ - part;
};

const standard = grid.map((s) => ({
...s,
title: gridTitle,
data: s.data.map((slot): HistorySlot => ({
...slot,
returnEnergy: takeOut(bucketKey(slot.start, aggregate), Math.max(0, slot.returnEnergy)),
})),
}));

const color = eegColor();
const eegSeries: HistorySeries = {
title: "EEG",
group: "eeg",
color,
returnColor: color,
data: split
.filter((b) => b.eeg > 0)
.map((b) => ({ start: b.start, end: b.end, energy: 0, returnEnergy: b.eeg })),
};

return [eegSeries, ...standard];
}
69 changes: 66 additions & 3 deletions assets/js/views/Energy.vue
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@
<GroupChart
group="grid"
:color="colors.grid || ''"
:series="gridSeries"
:series="gridChartSeries"
:stacked="!!feedInEeg"
:prices="chartPrices"
:currency="currency"
:height="200"
Expand All @@ -122,7 +123,11 @@
</Card>
</div>
<div class="col-12 col-lg-3 col-xxl-2">
<GridStats :cost="flow?.cost" :currency="currency" />
<GridStats
:cost="flow?.cost"
:currency="currency"
:feed-in-eeg="feedInEeg"
/>
</div>
</div>
</div>
Expand Down Expand Up @@ -442,6 +447,16 @@ import {
import type { DeviceColors, UiSolarDetails } from "@/types/evcc";
import { CURRENCY } from "@/types/evcc";
import api from "../api";
// custom: export split by feed-in tariff, see core/site_feedin_eeg.go
import {
eegColor,
feedInSplitTotals,
fetchFeedInSplit,
splitGridSeries,
withoutFeedInEeg,
type FeedInSplit,
type FeedInSplitTotals,
} from "../components/Energy/feedInEeg";
import settings from "../settings";
import formatter from "../mixins/formatter";
import store from "../store";
Expand Down Expand Up @@ -516,6 +531,7 @@ export default defineComponent({
focusedPv: null as number | null,
sourcesDetail: { title: "", rows: [] as SourceRow[] },
startDate: new Date(2020, 0, 1),
feedInSplit: [] as FeedInSplit[], // custom: see feedInEeg.ts
};
},
head() {
Expand Down Expand Up @@ -799,7 +815,7 @@ export default defineComponent({
return this.withData("battery");
},
meters(): HistorySeries[] {
return this.withData("meter");
return withoutFeedInEeg(this.withData("meter")); // custom: see feedInEeg.ts
},
meterColors(): Record<string, string> {
return this.entityColors(this.meters);
Expand Down Expand Up @@ -841,6 +857,20 @@ export default defineComponent({
gridSeries(): HistorySeries[] {
return this.withData("grid");
},
// custom: export split by feed-in tariff, see feedInEeg.ts
feedInEeg(): FeedInSplitTotals | null {
if (!this.feedInSplit.length) return null;
return feedInSplitTotals(this.feedInSplit);
},
gridChartSeries(): HistorySeries[] {
if (!this.feedInEeg) return this.gridSeries;
return splitGridSeries(
this.gridSeries,
this.feedInSplit,
this.aggregate,
this.$t("energy.group.grid")
);
},
// with the price overlay on, both prices as lines with their range over the period
gridLegends(): Legend[] {
const list: Legend[] = [
Expand All @@ -855,6 +885,23 @@ export default defineComponent({
value: this.fmtKWh(this.gridExport),
},
];
// custom: export split by feed-in tariff, see feedInEeg.ts
if (this.feedInEeg) {
list.splice(
1,
1,
{
label: this.$t("energy.grid.revenue"),
color: colors.export || "",
value: this.fmtKWh(this.feedInEeg.standard),
},
{
label: this.$t("energy.feedInEeg.eeg"),
color: eegColor(),
value: this.fmtKWh(this.feedInEeg.eeg),
}
);
}
const prices = this.gridPrices;
const range = (band: PriceBand) => {
const known = (v: (number | null)[]) => v.filter((x): x is number => x !== null);
Expand Down Expand Up @@ -1296,6 +1343,7 @@ export default defineComponent({
async fetchData() {
this.loading = true;
const requestKey = this.fetchKey;
this.loadFeedInSplit(requestKey); // custom: see feedInEeg.ts
try {
const [flow, energy, tariffs] = await Promise.all([
this.fetchFlow(this.from, this.to),
Expand All @@ -1319,6 +1367,21 @@ export default defineComponent({
if (requestKey === this.fetchKey) this.loading = false;
}
},
// custom: export split by feed-in tariff, only with an EEG counter
async loadFeedInSplit(requestKey: string) {
if (!store.state.feedInEegEntity) {
this.feedInSplit = [];
return;
}
try {
const split = await fetchFeedInSplit(this.from, this.to, this.aggregate);
if (requestKey === this.fetchKey) this.feedInSplit = split;
} catch (e) {
console.error("Failed to load feed-in split", e);
// no split of another period on this one
if (requestKey === this.fetchKey) this.feedInSplit = [];
}
},
fetchFlow(from: Date, to: Date) {
return api.get("history/flow", {
params: { from: from.toISOString(), to: to.toISOString() },
Expand Down
Loading
Loading