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 a4fc769107..8f699c1309 100644 --- a/assets/js/components/Energy/GroupChart.vue +++ b/assets/js/components/Energy/GroupChart.vue @@ -49,6 +49,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 @@ -563,6 +565,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..e9a09e961b --- /dev/null +++ b/assets/js/components/Energy/feedInEeg.ts @@ -0,0 +1,114 @@ +// 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[] { + 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 5400263d6e..ccc593a9c5 100644 --- a/assets/js/views/Energy.vue +++ b/assets/js/views/Energy.vue @@ -127,7 +127,8 @@
- +
@@ -395,6 +400,16 @@ import { import type { DeviceColors } 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"; @@ -465,6 +480,7 @@ export default defineComponent({ loading: false, focusedPv: null as number | null, startDate: new Date(2020, 0, 1), + feedInSplit: [] as FeedInSplit[], // custom: see feedInEeg.ts }; }, head() { @@ -692,7 +708,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); @@ -725,6 +741,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[] = [ @@ -739,6 +769,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 = settings.energyGridPrices ? this.gridPrices : null; const range = (band: PriceBand) => { const known = (v: (number | null)[]) => v.filter((x): x is number => x !== null); @@ -1132,6 +1179,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), @@ -1155,6 +1203,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/core/lm/README.md b/core/lm/README.md index 1fe38f5057..04c2a34834 100644 --- a/core/lm/README.md +++ b/core/lm/README.md @@ -128,6 +128,7 @@ Keep these in mind when merging a new evcc version: | `core/site/api.go` | embeds `CustomAPI`, one line | | `api/globalconfig/types.go`, `tariff/tariffs.go`, `cmd/setup.go`, `server/http_config_device_handler.go` | `feedInEeg` tariff role: ref field, `Used`/`IsConfigured`, one `configureTariff` call, cleared on delete | | `assets/js/components/Config/TariffModal.vue` | `feedInEeg` offers the price templates | +| `assets/js/views/Energy.vue`, `assets/js/components/Energy/GroupChart.vue`, `assets/js/components/Energy/GridStats.vue` | EEG split of the grid card: series, legend, `returnColor`, revenue tiles, meters without the EEG counter | | `core/site_optimizer.go` | `applyLmOptimizerInputs` where the optimizer request is assembled | | `server/http.go` | merges `customSiteRoutes`, one loop | | `assets/js/views/Battery.vue` | mounts the new cards, profile selection at the bottom | @@ -239,9 +240,12 @@ Home Assistant counter of the EEG export (kWh, Wh or MWh). - Only counters are used. The grid meter power that drives PV control, load management and peak shaving is untouched; self-consumption and the solar share of sessions stay valued at the standard feed-in tariff. -- The display on the new energy page (evcc PR 33989, not released yet) is - prepared separately; until then the data is recorded and available via the - api. +- The energy page (evcc PR 33989, not released yet; until then this builds on + the branch `preview/energy-page`) shows the split in its grid card: EEG as + its own lighter export bar, both amounts in the legend, and the revenue of + the standard feed-in and of EEG as separate tiles, also without a grid + price. The counter is not listed again among the additional meters (its + collector is titled `feedin-eeg` for that). Without a counter nothing runs and evcc behaves as upstream. diff --git a/core/metrics/feedin_eeg_custom.go b/core/metrics/feedin_eeg_custom.go index a2ad2000b7..97611b42d9 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 d9bfcd5578..a63d4c81ae 100644 --- a/core/site_feedin_eeg.go +++ b/core/site_feedin_eeg.go @@ -137,7 +137,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 } diff --git a/i18n/de.json b/i18n/de.json index a73597071d..a38172f4e2 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1480,6 +1480,10 @@ "meter": { "energy": "Energie", "returnEnergy": "Energie (rückwärts)" + }, + "eeg": { + "energy": "", + "returnEnergy": "eingespeist" } }, "empty": "Keine Energiedaten für diesen Zeitraum verfügbar.", @@ -1552,7 +1556,11 @@ "savings": "Ersparnis", "savingsTooltip": "Im Vergleich zum Bezug der verbrauchten {energy} aus dem Netz." }, - "title": "Energie" + "title": "Energie", + "feedInEeg": { + "eeg": "EEG", + "revenue": "EEG-Vergütung" + } }, "issue": { "additional": { diff --git a/i18n/en.json b/i18n/en.json index c8b0bebcaf..c1ea1e88c4 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1480,6 +1480,10 @@ "meter": { "energy": "Energy", "returnEnergy": "Energy (reverse)" + }, + "eeg": { + "energy": "", + "returnEnergy": "exported" } }, "empty": "No energy data available for this time range.", @@ -1552,7 +1556,11 @@ "savings": "Savings", "savingsTooltip": "Compared to buying the consumed {energy} from the grid." }, - "title": "Energy" + "title": "Energy", + "feedInEeg": { + "eeg": "EEG", + "revenue": "EEG revenue" + } }, "issue": { "additional": {