From e278c5e055c80ca90b1fb19eda0b1e44cf228d46 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Sat, 26 Sep 2026 10:54:44 +0200 Subject: [PATCH 1/8] Feed-in: EEG split on the energy page With an EEG counter set, the grid card of the energy page splits the export: the EEG part is stacked as its own lighter bar next to the standard feed-in, the legend lists both, and the revenue tiles show the standard feed-in and the EEG revenue separately, each priced slot by slot by /api/feedinsplit, also without a grid price. Without a counter the page is unchanged and makes no extra request. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit eb491973ce8caa9e85c09705e72f06b2d460ec7e) --- assets/js/components/Energy/GridStats.vue | 58 +++++++++++ assets/js/components/Energy/GroupChart.vue | 3 + assets/js/components/Energy/feedInEeg.ts | 106 +++++++++++++++++++++ assets/js/views/Energy.vue | 64 ++++++++++++- i18n/de.json | 10 +- i18n/en.json | 10 +- 6 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 assets/js/components/Energy/feedInEeg.ts 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.ts b/assets/js/components/Energy/feedInEeg.ts new file mode 100644 index 0000000000..b861ddf8e6 --- /dev/null +++ b/assets/js/components/Energy/feedInEeg.ts @@ -0,0 +1,106 @@ +// 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; + +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[] { + const eeg = new Map(split.map((b) => [bucketKey(b.start, aggregate), b.eeg])); + + const standard = grid.map((s) => ({ + ...s, + title: gridTitle, + data: s.data.map( + (slot): HistorySlot => ({ + ...slot, + returnEnergy: Math.max( + 0, + slot.returnEnergy - (eeg.get(bucketKey(slot.start, aggregate)) || 0) + ), + }) + ), + })); + + 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..c7b28e3886 100644 --- a/assets/js/views/Energy.vue +++ b/assets/js/views/Energy.vue @@ -109,7 +109,8 @@
- +
@@ -442,6 +447,15 @@ 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, + type FeedInSplit, + type FeedInSplitTotals, +} from "../components/Energy/feedInEeg"; import settings from "../settings"; import formatter from "../mixins/formatter"; import store from "../store"; @@ -516,6 +530,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() { @@ -841,6 +856,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 +884,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 +1342,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 +1366,19 @@ 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); + } + }, fetchFlow(from: Date, to: Date) { return api.get("history/flow", { params: { from: from.toISOString(), to: to.toISOString() }, diff --git a/i18n/de.json b/i18n/de.json index a5fbfb07bd..436c8dc86f 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1583,6 +1583,10 @@ "meter": { "energy": "Energie", "returnEnergy": "Energie (rückwärts)" + }, + "eeg": { + "energy": "", + "returnEnergy": "eingespeist" } }, "empty": "Keine Energiedaten für diesen Zeitraum verfügbar.", @@ -1663,7 +1667,11 @@ "savingsTooltip": "Im Vergleich zum Bezug der verbrauchten {energy} aus dem Netz.", "versus": "{value} statt {reference}" }, - "title": "Energie" + "title": "Energie", + "feedInEeg": { + "eeg": "EEG", + "revenue": "EEG-Vergütung" + } }, "issue": { "additional": { diff --git a/i18n/en.json b/i18n/en.json index a752dcd1c5..3f5fd06bba 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1583,6 +1583,10 @@ "meter": { "energy": "Energy", "returnEnergy": "Energy (reverse)" + }, + "eeg": { + "energy": "", + "returnEnergy": "exported" } }, "empty": "No energy data available for this time range.", @@ -1663,7 +1667,11 @@ "savingsTooltip": "Compared to buying the consumed {energy} from the grid.", "versus": "{value} vs. {reference}" }, - "title": "Energy" + "title": "Energy", + "feedInEeg": { + "eeg": "EEG", + "revenue": "EEG revenue" + } }, "issue": { "additional": { From bf2aa0eed974c1d67a0b4c362062bdd6790e8421 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Sat, 26 Sep 2026 11:30:44 +0200 Subject: [PATCH 2/8] Feed-in: EEG counter not listed again among the meters The energy page's grid card already shows the EEG part, so its counter is left out of the additional meters card. The collector is titled with its name for that; the CSV download still contains it. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit c6bd35e3f99efddf5c20d170916ee2cfb50c510c) --- assets/js/components/Energy/feedInEeg.ts | 8 ++++++++ assets/js/views/Energy.vue | 3 ++- core/metrics/feedin_eeg_custom.go | 2 +- core/site_feedin_eeg.go | 3 ++- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/assets/js/components/Energy/feedInEeg.ts b/assets/js/components/Energy/feedInEeg.ts index b861ddf8e6..e9a09e961b 100644 --- a/assets/js/components/Energy/feedInEeg.ts +++ b/assets/js/components/Energy/feedInEeg.ts @@ -21,6 +21,14 @@ export interface FeedInSplit { 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, diff --git a/assets/js/views/Energy.vue b/assets/js/views/Energy.vue index c7b28e3886..fd9e4cea26 100644 --- a/assets/js/views/Energy.vue +++ b/assets/js/views/Energy.vue @@ -453,6 +453,7 @@ import { feedInSplitTotals, fetchFeedInSplit, splitGridSeries, + withoutFeedInEeg, type FeedInSplit, type FeedInSplitTotals, } from "../components/Energy/feedInEeg"; @@ -814,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); diff --git a/core/metrics/feedin_eeg_custom.go b/core/metrics/feedin_eeg_custom.go index 6eccf74421..eee3cf3d16 100644 --- a/core/metrics/feedin_eeg_custom.go +++ b/core/metrics/feedin_eeg_custom.go @@ -16,7 +16,7 @@ import ( "gorm.io/gorm/clause" ) -// FeedInEeg is the name of the EEG counter's collector in group Meter +// FeedInEeg is the name and title of the EEG counter's collector in group Meter const FeedInEeg = "feedin-eeg" type eegPrice struct { diff --git a/core/site_feedin_eeg.go b/core/site_feedin_eeg.go index 8f807a59e7..923b326d5a 100644 --- a/core/site_feedin_eeg.go +++ b/core/site_feedin_eeg.go @@ -142,7 +142,8 @@ func (site *Site) applyFeedInEegEntity(entity string, changed bool) error { opt = append(opt, metrics.WithClock(s.clock)) } - c, err := metrics.NewCollector(metrics.Meter, metrics.FeedInEeg, "EEG", opt...) + // titled with its name, so the energy page can leave it out of its meters + c, err := metrics.NewCollector(metrics.Meter, metrics.FeedInEeg, metrics.FeedInEeg, opt...) if err != nil { return err } From 9ba7c89acd2f9f461260cd412841f5080a477a71 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Sat, 26 Sep 2026 20:55:50 +0200 Subject: [PATCH 3/8] docs: EEG split on the energy page, ui hooks in the contract test README section 15 and the touch point table describe the display on the energy page; forkUiHooks checks the mounts in Energy.vue, GroupChart.vue and GridStats.vue. Co-Authored-By: Claude Opus 5.5 --- core/fork_hooks_test.go | 3 +++ core/lm/README.md | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) 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{" Date: Mon, 5 Oct 2026 17:22:27 +0200 Subject: [PATCH 4/8] Feed-in split: from/to via evcc's historyRange /api/feedinsplit parsed from and to itself because historyRange only existed in evcc PR 33989; evcc has it now, same checks and errors. Co-Authored-By: Claude Opus 5.5 --- server/http_custom.go | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/server/http_custom.go b/server/http_custom.go index ecd442d6bc..feb36a58d0 100644 --- a/server/http_custom.go +++ b/server/http_custom.go @@ -7,7 +7,6 @@ package server import ( "encoding/json" "errors" - "fmt" "io" "net/http" "slices" @@ -18,7 +17,6 @@ import ( "github.com/evcc-io/evcc/core/lm/profile" "github.com/evcc-io/evcc/core/metrics" "github.com/evcc-io/evcc/core/site" - "github.com/evcc-io/evcc/db" "github.com/evcc-io/evcc/util" "github.com/gorilla/mux" ) @@ -332,23 +330,11 @@ func gridChargeOnceCancelHandler(site site.API) http.HandlerFunc { // feedInSplitHandler returns the export split by feed-in tariff in the // buckets of the energy history func feedInSplitHandler(w http.ResponseWriter, r *http.Request) { - if db.Instance == nil { - jsonError(w, http.StatusBadRequest, errors.New("database offline")) + from, to, ok := historyRange(w, r) + if !ok { return } - var from, to time.Time - for name, dst := range map[string]*time.Time{"from": &from, "to": &to} { - if v := r.URL.Query().Get(name); v != "" { - t, err := time.Parse(time.RFC3339, v) - if err != nil { - jsonError(w, http.StatusBadRequest, fmt.Errorf("invalid '%s' parameter", name)) - return - } - *dst = t - } - } - if to.IsZero() { to = time.Now().AddDate(0, 0, 1) } From 39f8dffb5798329d4a3147cfcf1847046db89c97 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Mon, 5 Oct 2026 17:35:13 +0200 Subject: [PATCH 5/8] Feed-in: feedInEeg.ts formatted with evcc's current formatter Co-Authored-By: Claude Opus 5.5 --- assets/js/components/Energy/feedInEeg.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/assets/js/components/Energy/feedInEeg.ts b/assets/js/components/Energy/feedInEeg.ts index e9a09e961b..90053c1af3 100644 --- a/assets/js/components/Energy/feedInEeg.ts +++ b/assets/js/components/Energy/feedInEeg.ts @@ -88,15 +88,13 @@ export function splitGridSeries( const standard = grid.map((s) => ({ ...s, title: gridTitle, - data: s.data.map( - (slot): HistorySlot => ({ - ...slot, - returnEnergy: Math.max( - 0, - slot.returnEnergy - (eeg.get(bucketKey(slot.start, aggregate)) || 0) - ), - }) - ), + data: s.data.map((slot): HistorySlot => ({ + ...slot, + returnEnergy: Math.max( + 0, + slot.returnEnergy - (eeg.get(bucketKey(slot.start, aggregate)) || 0) + ), + })), })); const color = eegColor(); From a944579c931b00eb452360f700bfbcdd8f5ca623 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Mon, 5 Oct 2026 17:36:17 +0200 Subject: [PATCH 6/8] Feed-in split: buckets via evcc's aggregateStarts evcc PR 33989 brought aggregateStarts, the same local-time alignment as our bucketStart; using it keeps /api/feedinsplit on the energy history's buckets if evcc changes them. Co-Authored-By: Claude Opus 5.5 --- core/metrics/feedin_eeg_custom.go | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/core/metrics/feedin_eeg_custom.go b/core/metrics/feedin_eeg_custom.go index eee3cf3d16..92376ba158 100644 --- a/core/metrics/feedin_eeg_custom.go +++ b/core/metrics/feedin_eeg_custom.go @@ -75,22 +75,6 @@ func splitSlot(export float64, eeg *float64) (float64, float64) { return e, max(0, export-e) } -// bucketStart is the start of the bucket holding ts in local time, like the -// energy history's buckets -func bucketStart(ts time.Time, aggregate string) time.Time { - ts = ts.Local() - switch aggregate { - case "hour": - return time.Date(ts.Year(), ts.Month(), ts.Day(), ts.Hour(), 0, 0, 0, time.Local) - case "day": - return time.Date(ts.Year(), ts.Month(), ts.Day(), 0, 0, 0, 0, time.Local) - case "month": - return time.Date(ts.Year(), ts.Month(), 1, 0, 0, 0, 0, time.Local) - default: - return ts - } -} - // QueryFeedInSplit returns the export in [from,to) per bucket of the energy // history (15m, hour, day, month), oldest first. Buckets without export and // without EEG energy are left out. @@ -120,7 +104,7 @@ func QueryFeedInSplit(from, to time.Time, aggregate string) ([]FeedInSplit, erro var res []FeedInSplit for _, s := range slots { - start := bucketStart(time.Unix(s.Ts, 0), aggregate) + start := aggregateStarts[aggregate](time.Unix(s.Ts, 0)) if len(res) == 0 || !res[len(res)-1].Start.Equal(start) { res = append(res, FeedInSplit{Start: start, End: addDuration(start)}) } From c57cb3e52c62af9758925e4d1649dfd7cc537c44 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Mon, 5 Oct 2026 17:37:32 +0200 Subject: [PATCH 7/8] Feed-in: EEG part taken out once after a grid meter swap, no stale split With several grid series in a bucket (grid meter swapped) the EEG part came off each of them; now it is taken out once across them. A failed split request no longer leaves the previous period's split on the page. Co-Authored-By: Claude Opus 5.5 --- assets/js/components/Energy/feedInEeg.test.ts | 45 +++++++++++++++++++ assets/js/components/Energy/feedInEeg.ts | 12 +++-- assets/js/views/Energy.vue | 2 + 3 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 assets/js/components/Energy/feedInEeg.test.ts 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 index 90053c1af3..07bd8713af 100644 --- a/assets/js/components/Energy/feedInEeg.ts +++ b/assets/js/components/Energy/feedInEeg.ts @@ -83,17 +83,21 @@ export function splitGridSeries( 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: Math.max( - 0, - slot.returnEnergy - (eeg.get(bucketKey(slot.start, aggregate)) || 0) - ), + returnEnergy: takeOut(bucketKey(slot.start, aggregate), Math.max(0, slot.returnEnergy)), })), })); diff --git a/assets/js/views/Energy.vue b/assets/js/views/Energy.vue index fd9e4cea26..3fbb20067f 100644 --- a/assets/js/views/Energy.vue +++ b/assets/js/views/Energy.vue @@ -1378,6 +1378,8 @@ export default defineComponent({ 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) { From 10bd8435756e904e44858d33791dee053b5ca6b7 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Mon, 5 Oct 2026 17:39:04 +0200 Subject: [PATCH 8/8] Feed-in split test: compare the 15m bucket start as instant evcc's aggregateStarts keeps the location of time.Unix, which the test's time.Local = UTC swap makes a different pointer than time.UTC. Co-Authored-By: Claude Opus 5.5 --- core/metrics/feedin_eeg_custom_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/metrics/feedin_eeg_custom_test.go b/core/metrics/feedin_eeg_custom_test.go index daa8147ca2..85b610862b 100644 --- a/core/metrics/feedin_eeg_custom_test.go +++ b/core/metrics/feedin_eeg_custom_test.go @@ -86,7 +86,7 @@ func TestQueryFeedInSplit(t *testing.T) { res, err = QueryFeedInSplit(slot(9, 0), slot(9, 4), "15m") require.NoError(t, err) require.Len(t, res, 4) - require.Equal(t, slot(9, 3), res[3].Start) + require.True(t, slot(9, 3).Equal(res[3].Start), "start %v", res[3].Start) require.InDelta(t, 0.2, res[3].Eeg, 1e-9) _, err = QueryFeedInSplit(slot(9, 0), slot(9, 4), "week")