Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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
Expand Down Expand Up @@ -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]!;
Expand Down
114 changes: 114 additions & 0 deletions assets/js/components/Energy/feedInEeg.ts
Original file line number Diff line number Diff line change
@@ -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<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[] {
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];
}
67 changes: 64 additions & 3 deletions assets/js/views/Energy.vue
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@
<GroupChart
group="grid"
:color="colors.grid || ''"
:series="gridSeries"
:series="gridChartSeries"
:stacked="!!feedInEeg"
:prices="settings.energyGridPrices ? gridPrices : null"
:currency="currency"
:height="200"
Expand All @@ -140,7 +141,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 @@ -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";
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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<string, string> {
return this.entityColors(this.meters);
Expand Down Expand Up @@ -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[] = [
Expand All @@ -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);
Expand Down Expand Up @@ -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),
Expand All @@ -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() },
Expand Down
10 changes: 7 additions & 3 deletions core/lm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion core/metrics/feedin_eeg_custom.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion core/site_feedin_eeg.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading