diff --git a/assets/js/components/Energy/GridStats.vue b/assets/js/components/Energy/GridStats.vue index ace0c10d15..85b48c777b 100644 --- a/assets/js/components/Energy/GridStats.vue +++ b/assets/js/components/Energy/GridStats.vue @@ -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({ @@ -17,9 +18,12 @@ export default defineComponent({ props: { cost: { type: Object as PropType }, currency: { type: String as PropType, default: CURRENCY.EUR }, + // custom: export split by feed-in tariff, see feedInEeg.ts + feedInEeg: { type: Object as PropType, 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"); @@ -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 + ), + ]; + }, + }, }); diff --git a/assets/js/components/Energy/GroupChart.vue b/assets/js/components/Energy/GroupChart.vue index 233846e48c..dccba857fc 100644 --- a/assets/js/components/Energy/GroupChart.vue +++ b/assets/js/components/Energy/GroupChart.vue @@ -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 @@ -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]!; diff --git a/assets/js/components/Energy/feedInEeg.test.ts b/assets/js/components/Energy/feedInEeg.test.ts new file mode 100644 index 0000000000..500f2d20e3 --- /dev/null +++ b/assets/js/components/Energy/feedInEeg.test.ts @@ -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); + }); +}); diff --git a/assets/js/components/Energy/feedInEeg.ts b/assets/js/components/Energy/feedInEeg.ts new file mode 100644 index 0000000000..07bd8713af --- /dev/null +++ b/assets/js/components/Energy/feedInEeg.ts @@ -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; + +// 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 { + 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]; +} diff --git a/assets/js/views/Energy.vue b/assets/js/views/Energy.vue index 2c71b58fd2..3fbb20067f 100644 --- a/assets/js/views/Energy.vue +++ b/assets/js/views/Energy.vue @@ -109,7 +109,8 @@
- +
@@ -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"; @@ -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() { @@ -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 { return this.entityColors(this.meters); @@ -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[] = [ @@ -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); @@ -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), @@ -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() }, diff --git a/core/fork_hooks_test.go b/core/fork_hooks_test.go index 629e2835b4..713928317c 100644 --- a/core/fork_hooks_test.go +++ b/core/fork_hooks_test.go @@ -64,6 +64,9 @@ var forkUiHooks = []struct { {"assets/js/components/Loadpoints/Mode.vue", []string{"chargerStages: Boolean", "this.switchDevice || this.chargerStages"}}, {"assets/js/components/Config/TariffCard.vue", []string{"