From 8d9640683189ac5db692adb824717fd11f401292 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Sun, 27 Sep 2026 20:57:38 +0200 Subject: [PATCH] Tariff (OeMAG): market price feed-in tariff with retroactive finalization Restores the OeMAG feature removed from load-peak-features, kept here to use it again later: - tariff template "oemag": the latest published OeMAG market price as the running feed-in price - from a finalize day (default 15) on it becomes the final price of the previous month, and the month's persisted 15 minute feed-in rates and the solar share of its charging sessions are recalculated once - finalization dialog and summary in the tariff settings, history of the finalized months, manual finalization via API Co-Authored-By: Claude Opus 5.5 --- .../js/components/Config/FeedInFinalModal.vue | 245 ++++++++++++ .../components/Config/FeedInFinalSummary.vue | 72 ++++ assets/js/components/Config/TariffCard.vue | 6 + .../js/components/Config/feedInFinal.test.ts | 37 ++ assets/js/components/Config/feedInFinal.ts | 36 ++ assets/js/types/evcc.ts | 25 ++ assets/js/views/Config.vue | 3 + core/keys/site_custom.go | 5 + core/lm/README.md | 16 +- core/metrics/feedin_eeg_custom.go | 2 +- core/metrics/tariffs_custom.go | 52 +++ core/site.go | 2 + core/site/api_custom.go | 3 + core/site_feedin.go | 368 ++++++++++++++++++ core/site_feedin_eeg.go | 2 +- core/site_feedin_test.go | 241 ++++++++++++ core/site_lm.go | 3 + core/site_optimizer_lm_test.go | 2 +- i18n/de.json | 27 ++ i18n/en.json | 27 ++ server/http_custom.go | 22 ++ tariff/oemag.go | 175 +++++++++ tariff/oemag_test.go | 98 +++++ tariff/wrapper_custom.go | 24 ++ templates/definition/tariff/oemag.yaml | 45 +++ 25 files changed, 1528 insertions(+), 10 deletions(-) create mode 100644 assets/js/components/Config/FeedInFinalModal.vue create mode 100644 assets/js/components/Config/FeedInFinalSummary.vue create mode 100644 assets/js/components/Config/feedInFinal.test.ts create mode 100644 assets/js/components/Config/feedInFinal.ts create mode 100644 core/metrics/tariffs_custom.go create mode 100644 core/site_feedin.go create mode 100644 core/site_feedin_test.go create mode 100644 tariff/oemag.go create mode 100644 tariff/oemag_test.go create mode 100644 tariff/wrapper_custom.go create mode 100644 templates/definition/tariff/oemag.yaml diff --git a/assets/js/components/Config/FeedInFinalModal.vue b/assets/js/components/Config/FeedInFinalModal.vue new file mode 100644 index 0000000000..7ca6982d6e --- /dev/null +++ b/assets/js/components/Config/FeedInFinalModal.vue @@ -0,0 +1,245 @@ + + + + diff --git a/assets/js/components/Config/FeedInFinalSummary.vue b/assets/js/components/Config/FeedInFinalSummary.vue new file mode 100644 index 0000000000..1ec4037e73 --- /dev/null +++ b/assets/js/components/Config/FeedInFinalSummary.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/assets/js/components/Config/TariffCard.vue b/assets/js/components/Config/TariffCard.vue index 2af69f6082..adc6179d39 100644 --- a/assets/js/components/Config/TariffCard.vue +++ b/assets/js/components/Config/TariffCard.vue @@ -15,6 +15,10 @@ @@ -32,6 +36,7 @@ import { type PropType } from "vue"; import type { TariffType, CURRENCY } from "@/types/evcc"; import DeviceCard from "./DeviceCard.vue"; import DeviceTags from "./DeviceTags.vue"; +import FeedInFinalSummary from "./FeedInFinalSummary.vue"; import FeedInEegSummary from "./FeedInEegSummary.vue"; type ConfigTariff = { @@ -49,6 +54,7 @@ export default { components: { DeviceCard, DeviceTags, + FeedInFinalSummary, FeedInEegSummary, }, props: { diff --git a/assets/js/components/Config/feedInFinal.test.ts b/assets/js/components/Config/feedInFinal.test.ts new file mode 100644 index 0000000000..2ccf405ee7 --- /dev/null +++ b/assets/js/components/Config/feedInFinal.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "vite-plus/test"; +import { feedInNextFinalization, fmtMarketPrice, monthDate, monthKey } from "./feedInFinal"; + +const day = (y: number, m: number, d: number) => new Date(y, m - 1, d); + +describe("feedInNextFinalization", () => { + test("before the finalize day, previous month open: this month", () => { + expect(feedInNextFinalization(day(2026, 9, 10), 15, "2026-07")).toEqual(day(2026, 9, 15)); + }); + test("past the finalize day, previous month still open: due now", () => { + const now = day(2026, 9, 20); + expect(feedInNextFinalization(now, 15, "2026-07")).toEqual(now); + }); + test("previous month done: next month", () => { + expect(feedInNextFinalization(day(2026, 9, 20), 15, "2026-08")).toEqual(day(2026, 10, 15)); + }); + test("nothing finalized yet", () => { + expect(feedInNextFinalization(day(2026, 9, 10), 15)).toEqual(day(2026, 9, 15)); + }); + test("january looks at december", () => { + expect(feedInNextFinalization(day(2027, 1, 20), 15, "2026-12")).toEqual(day(2027, 2, 15)); + }); +}); + +describe("months", () => { + test("round trip", () => { + expect(monthKey(monthDate("2026-08"))).toBe("2026-08"); + expect(monthKey(day(2026, 1, 31))).toBe("2026-01"); + }); +}); + +describe("fmtMarketPrice", () => { + test("three decimals in ct", () => { + expect(fmtMarketPrice(0.08997, "de")).toBe("8,997 ct/kWh"); + expect(fmtMarketPrice(0.09, "en")).toBe("9.000 ct/kWh"); + }); +}); diff --git a/assets/js/components/Config/feedInFinal.ts b/assets/js/components/Config/feedInFinal.ts new file mode 100644 index 0000000000..d55102a484 --- /dev/null +++ b/assets/js/components/Config/feedInFinal.ts @@ -0,0 +1,36 @@ +// Custom extension: helpers for the OeMAG finalization, see core/site_feedin.go + +/** First day of a YYYY-MM month, local time. */ +export function monthDate(month: string): Date { + const [year, m] = month.split("-").map(Number); + return new Date(year ?? 1970, (m ?? 1) - 1, 1); +} + +/** YYYY-MM of a date. */ +export function monthKey(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; +} + +/** + * The day the next automatic finalization runs: this month's finalize day while + * the previous month is still open, else next month's. Past the day with the + * previous month still open, it is due right now. + */ +export function feedInNextFinalization(now: Date, day: number, lastDone?: string): Date { + const previous = monthKey(new Date(now.getFullYear(), now.getMonth() - 1, 1)); + const thisMonth = new Date(now.getFullYear(), now.getMonth(), day); + + if (!lastDone || lastDone < previous) { + return now.getDate() >= day ? now : thisMonth; + } + return new Date(now.getFullYear(), now.getMonth() + 1, day); +} + +/** A market price in EUR/kWh as ct/kWh with the three decimals OeMAG publishes. */ +export function fmtMarketPrice(eur: number, locale?: string): string { + const ct = new Intl.NumberFormat(locale, { + minimumFractionDigits: 3, + maximumFractionDigits: 3, + }).format(eur * 100); + return `${ct} ct/kWh`; +} diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 930b1f0b68..63d84a32bd 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -281,6 +281,29 @@ export interface LmStatus { events: LmEvent[]; } +// custom: a finalized feed-in month, see core/site_feedin.go +export interface FeedInMonth { + /** YYYY-MM. */ + month: string; + /** Market price in EUR/kWh, 0 = unknown. */ + market: number; + /** Feed-in rate applied in EUR/kWh, with charges and tax. */ + price: number; + slots: number; + sessions: number; + skipped: number; + at: string; + manual?: boolean; +} + +export interface FeedInFinal { + finalizeDay: number; + /** Latest published market price in EUR/kWh, 0 = none yet. */ + market: number; + /** Newest first. */ + months: FeedInMonth[]; +} + /** * Complete system state as returned by /api/state and pushed via websocket and MQTT. * This structure mirrors the internal UI state and carries no compatibility promise. @@ -485,6 +508,8 @@ export interface State { lmShedProtected?: string[]; /** Advanced load management settings in effect. */ lmAdvanced?: LmAdvanced; + /** OeMAG finalization: finalize day, published market price and finalized months. */ + feedInFinal?: FeedInFinal; /** Home Assistant counter of the export under the second feed-in tariff (EEG), empty = off. */ feedInEegEntity?: string; /** Current price of the second feed-in tariff. */ diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index e6b541ac82..34b6bd6faa 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -591,6 +591,7 @@ + @@ -658,6 +659,7 @@ import LmShedGuardModal from "../components/Config/LmShedGuardModal.vue"; import LmAdvancedModal from "../components/Config/LmAdvancedModal.vue"; import PeakTariffModal from "../components/Config/PeakTariffModal.vue"; import LmProfilesModal from "../components/Config/LmProfilesModal.vue"; +import FeedInFinalModal from "../components/Config/FeedInFinalModal.vue"; import FeedInEegModal from "../components/Config/FeedInEegModal.vue"; import GridChargeModal from "../components/Config/GridChargeModal.vue"; import HemsIcon from "../components/MaterialIcon/Hems.vue"; @@ -783,6 +785,7 @@ export default defineComponent({ LmAdvancedModal, PeakTariffModal, LmProfilesModal, + FeedInFinalModal, FeedInEegModal, GridChargeModal, HemsIcon, diff --git a/core/keys/site_custom.go b/core/keys/site_custom.go index d1803f133d..5731617859 100644 --- a/core/keys/site_custom.go +++ b/core/keys/site_custom.go @@ -59,6 +59,11 @@ const ( LmProfileActive = "lmProfileActive" LmProfileWallboxes = "lmProfileWallboxes" // loadpoints a profile can set the solar share of + // last month whose feed-in price was finalized, YYYY-MM, see core/site_feedin.go + FeedInFinalized = "feedInFinalized" + FeedInHistory = "feedInHistory" // finalized months, see core/site_feedin.go + FeedInFinal = "feedInFinal" // published: finalize day, market price and history + // export under a second feed-in tariff, see core/site_feedin_eeg.go FeedInEegEntity = "feedInEegEntity" // Home Assistant energy counter of the EEG export TariffFeedInEeg = "tariffFeedInEeg" // published: current EEG price diff --git a/core/lm/README.md b/core/lm/README.md index 09b8488d90..11c9d7a041 100644 --- a/core/lm/README.md +++ b/core/lm/README.md @@ -114,7 +114,8 @@ Every fork feature follows these, so that taking in a new evcc version stays che 4. **Contract tests.** Each hook has a test pinning the upstream behaviour it relies on, and that the fork is inert while its features are off: `TestForkInertWhenUnused`, `TestSetLimitUsesLmCircuit`, - `TestEqualPrioritiesAreUpstream`, `TestPeakReserveKeepsExternalMode`. + `TestEqualPrioritiesAreUpstream`, `TestPeakReserveKeepsExternalMode`, + `TestFinalizeFeedIn` (upstream session and metrics models). ## Upstream touch points @@ -122,7 +123,7 @@ Keep these in mind when merging a new evcc version: | File | Change | | --- | --- | -| `core/site.go` | `lm` import, `LoadManagement`/`loadMgmt`/`peakShaving` fields, two restore calls, `batteryGridChargeRequested`, `updatePeakShaving`, `updateBatteryModePeakAware`, `setPeakGridEnergy` in `updateGridMeter` | +| `core/site.go` | `lm` import, `LoadManagement`/`loadMgmt`/`peakShaving` fields, two restore calls, `batteryGridChargeRequested`, `updatePeakShaving`, `updateFeedInFinalization`, `updateBatteryModePeakAware`, `setPeakGridEnergy` in `updateGridMeter` | | `core/circuit/circuit.go` | over power logged via `overPowerLog()` (INFO, no ui notification), see `circuit_custom.go` | | `core/site_circuits.go` | `circuitLoads()` instead of `loadpointsAsCircuitDevices()` | | `core/loadpoint.go` | `lm` import, `LmPrio` field (yaml fallback), `setLimit` checks against `lp.lmCircuit()` instead of `lp.circuit` (upstream calculation unchanged) and calls `done`, two `lm.Peek*` probes | @@ -134,10 +135,10 @@ Keep these in mind when merging a new evcc version: | `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 | -| `assets/js/views/Config.vue` | load management details section and its modals | +| `assets/js/views/Config.vue` | load management details section and its modals, OeMAG modal | | `assets/js/views/App.vue` | mounts the load management overview and the peak statistics | | `assets/js/components/BottomTabs/MoreMenu.vue` | "Lastmanagement" and "Peak Shaving" entries | -| `assets/js/components/Config/TariffCard.vue` | EEG counter summary in the EEG card | +| `assets/js/components/Config/TariffCard.vue` | OeMAG summary in the feed-in card | | `assets/js/components/Energyflow/Energyflow.vue` | "(Netzladen)" label | | `assets/js/types/evcc.ts`, `i18n/de.json`, `i18n/en.json` | state fields and texts | @@ -145,8 +146,9 @@ Everything else lives in files of its own: `core/lm/`, `core/circuit/circuit_cus `core/site_lm_advanced.go`, `core/site_lm_status.go`, `core/site_lm_profiles.go`, `core/site_lm_follow.go`, `core/site_peak_stats.go`, `assets/js/components/LoadManagement/`, `assets/js/components/PeakShaving/`, `core/site_peakshaving.go`, `core/loadpoint_lm.go`, `charger/switchsocket_lm.go`, `core/keys/site_custom.go`, -`core/site/api_custom.go`, `server/http_custom.go`, `core/site_optimizer_lm.go`, `core/site_lm_once.go`, `core/site_lm_priority.go`, `core/site_feedin_eeg.go`, `core/metrics/feedin_eeg_custom.go` -and the new Vue components. +`core/site/api_custom.go`, `server/http_custom.go`, `core/site_feedin.go`, `core/metrics/tariffs_custom.go`, `core/site_optimizer_lm.go`, `core/site_lm_once.go`, `core/site_lm_priority.go`, `core/site_feedin_eeg.go`, `core/metrics/feedin_eeg_custom.go`, +`tariff/oemag.go`, `tariff/wrapper_custom.go`, `templates/definition/tariff/oemag.yaml` and the new Vue +components. ## Shed guard @@ -277,7 +279,7 @@ agreed power and 2 kW. See `core/site_peak_tariff.go`. ## Second feed-in tariff (EEG) Part of the export can go to an energy community (EEG) at a fixed price, the rest -gets the standard feed-in tariff. Tariff settings: "Einspeisevergütung EEG +gets the standard feed-in tariff (OeMAG). Tariff settings: "Einspeisevergütung EEG hinzufügen" below the feed-in tariff (fixed price, 0 allowed), its card sets the Home Assistant counter of the EEG export (kWh, Wh or MWh). diff --git a/core/metrics/feedin_eeg_custom.go b/core/metrics/feedin_eeg_custom.go index 6eccf74421..a2ad2000b7 100644 --- a/core/metrics/feedin_eeg_custom.go +++ b/core/metrics/feedin_eeg_custom.go @@ -4,7 +4,7 @@ package metrics // core/site_feedin_eeg.go. The EEG counter is persisted by a regular collector // of group "meter" (monitoring only, never part of a balance), its price per // 15 minute slot in a table of its own. The split is derived from the persisted -// slots only: EEG = the counter's slot energy, standard feed-in = grid +// slots only: EEG = the counter's slot energy, standard feed-in (OeMAG) = grid // export minus EEG, clamped at zero. import ( diff --git a/core/metrics/tariffs_custom.go b/core/metrics/tariffs_custom.go new file mode 100644 index 0000000000..1434054a91 --- /dev/null +++ b/core/metrics/tariffs_custom.go @@ -0,0 +1,52 @@ +package metrics + +// Custom extension: rewriting the persisted feed-in rates once a tariff's final +// price becomes known after the fact, see core/site_feedin.go. + +import ( + "time" + + "gorm.io/gorm" +) + +// FeedInAverage returns the average persisted feed-in rate in [from,to), false +// when no slot in that range holds one +func FeedInAverage(tx *gorm.DB, from, to time.Time) (float64, bool, error) { + var res struct { + Avg *float64 + Count int64 + } + + err := tx.Model(new(tariffValue)). + Select("AVG(feedin) AS avg, COUNT(feedin) AS count"). + Where("ts >= ? AND ts < ? AND feedin IS NOT NULL", from.Truncate(15*time.Minute).Unix(), to.Unix()). + Scan(&res).Error + + if err != nil || res.Count == 0 || res.Avg == nil { + return 0, false, err + } + + return *res.Avg, true, nil +} + +// SetFeedIn overwrites the persisted feed-in rates in [from,to) with rate(ts). +// Only slots that hold a feed-in rate are touched. Returns the number of slots. +func SetFeedIn(tx *gorm.DB, from, to time.Time, rate func(time.Time) (float64, error)) (int, error) { + var rows []tariffValue + if err := tx.Where("ts >= ? AND ts < ? AND feedin IS NOT NULL", from.Unix(), to.Unix()).Find(&rows).Error; err != nil { + return 0, err + } + + for _, r := range rows { + v, err := rate(time.Unix(r.Timestamp, 0)) + if err != nil { + return 0, err + } + + if err := tx.Model(new(tariffValue)).Where("ts = ?", r.Timestamp).Update("feedin", v).Error; err != nil { + return 0, err + } + } + + return len(rows), nil +} diff --git a/core/site.go b/core/site.go index df82e6a34e..f59d601252 100644 --- a/core/site.go +++ b/core/site.go @@ -1292,6 +1292,8 @@ func (site *Site) update(lp updater) { // custom: peak shaving, see core/site_peakshaving.go site.updatePeakShaving(state) + // custom: feed-in price published after the fact, see core/site_feedin.go + site.updateFeedInFinalization() // custom: export under a second feed-in tariff, see core/site_feedin_eeg.go site.updateFeedInEeg() } diff --git a/core/site/api_custom.go b/core/site/api_custom.go index 20e86d8a30..7757be286a 100644 --- a/core/site/api_custom.go +++ b/core/site/api_custom.go @@ -43,6 +43,9 @@ type CustomAPI interface { DeleteLmProfile(id string) error ApplyLmProfile(id string) error + // feed-in price published after the fact, see core/site_feedin.go + FinalizeFeedIn(month string, market float64) error + // export under a second feed-in tariff, see core/site_feedin_eeg.go GetFeedInEegEntity() string SetFeedInEegEntity(string) error diff --git a/core/site_feedin.go b/core/site_feedin.go new file mode 100644 index 0000000000..b10f5253ab --- /dev/null +++ b/core/site_feedin.go @@ -0,0 +1,368 @@ +package core + +// Custom extension: a feed-in price that is only published after the fact. +// +// The OeMAG market price of a month becomes known around mid of the following +// month. Until then the latest published value is used as the running feed-in +// price. From the tariff's finalize day on, that value is taken as the final +// price of the previous month, and everything stored with the provisional +// price is recalculated once: +// +// - the persisted 15 minute feed-in rates of that month +// - the price of the charging sessions started in that month, whose solar +// share is valued at the feed-in price +// +// Each month is finalized automatically exactly once, even if the source changes +// its value later: the value on the finalize day is the one that counts. A month +// can be recalculated by hand from the ui, with a corrected price if need be. +// The finalized months are kept as a history the ui shows. + +import ( + "cmp" + "errors" + "fmt" + "slices" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/core/metrics" + "github.com/evcc-io/evcc/core/session" + "github.com/evcc-io/evcc/db" + "github.com/evcc-io/evcc/db/settings" + "github.com/evcc-io/evcc/tariff" + "gorm.io/gorm" +) + +// finalFeedIn is a feed-in tariff whose price for a month is published after it +type finalFeedIn interface { + FinalizeDay() int + MarketPrice() (float64, error) + TotalPrice(market float64, ts time.Time) float64 +} + +// feedInMonth is a finalized month as kept in the history +type feedInMonth struct { + Month string `json:"month"` // YYYY-MM + Market float64 `json:"market"` // market price in EUR/kWh, 0 = unknown + Price float64 `json:"price"` // feed-in rate applied, with charges and tax + Slots int `json:"slots"` // 15 minute rates recalculated + Sessions int `json:"sessions"` // charging sessions recalculated + Skipped int `json:"skipped"` // sessions without stored rates + At time.Time `json:"at"` + Manual bool `json:"manual,omitempty"` +} + +// feedInState is what the ui shows in the feed-in tariff's card +type feedInState struct { + FinalizeDay int `json:"finalizeDay"` + Market float64 `json:"market"` // latest published market price, 0 = none yet + Months []feedInMonth `json:"months"` +} + +// feedInHistoryLength is how many months the history keeps +const feedInHistoryLength = 24 + +// feedInFinalizer returns the tariff's final price capability, looking through +// the wrappers evcc puts around tariffs: one for rates coarser than a slot, one +// for a tariff that could not be created at startup +func feedInFinalizer(t api.Tariff) (finalFeedIn, bool) { + for range 4 { + if f, ok := t.(finalFeedIn); ok { + return f, true + } + + w, ok := t.(interface{ Unwrap() api.Tariff }) + if !ok { + break + } + t = w.Unwrap() + } + + return nil, false +} + +// feedInRetry is how long a failed finalization waits before the next attempt +const feedInRetry = time.Hour + +// updateFeedInFinalization finalizes the previous month's feed-in price once +// the tariff's finalize day has been reached. Called every cycle, cheap when +// there is nothing to do. +func (site *Site) updateFeedInFinalization() { + f, ok := feedInFinalizer(site.GetTariff(api.TariffUsageFeedIn)) + if !ok || db.Instance == nil { + return + } + + site.lms().feedInOnce.Do(site.backfillFeedInHistory) + + // the ui shows the latest market price, which changes a few times a month + if site.feedInMarketChanged(f) { + site.publishFeedIn(f) + } + + now := time.Now() + done, _ := settings.String(keys.FeedInFinalized) + + from, to, month, due := feedInDue(now, f.FinalizeDay(), done) + if !due { + return + } + + s := site.lms() + + s.mu.Lock() + wait := now.Sub(s.feedInTried) < feedInRetry + if !wait { + s.feedInTried = now + } + s.mu.Unlock() + + if wait { + return + } + + market, err := f.MarketPrice() + if err != nil { + site.log.ERROR.Printf("feed-in %s: %v", month, err) + return + } + + if err := site.finalizeFeedInMonth(f, month, from, to, market, false); err != nil { + site.log.ERROR.Printf("feed-in %s: %v", month, err) + } +} + +// finalizeFeedInMonth recalculates a month at the given market price and +// records it in the history +func (site *Site) finalizeFeedInMonth(f finalFeedIn, month string, from, to time.Time, market float64, manual bool) error { + res, err := finalizeFeedIn(db.Instance, from, to, func(ts time.Time) (float64, error) { + return f.TotalPrice(market, ts), nil + }) + if err != nil { + return err + } + + // the automatic finalization skips a month recalculated by hand + if done, _ := settings.String(keys.FeedInFinalized); done < month { + settings.SetString(keys.FeedInFinalized, month) + } + + site.recordFeedInMonth(feedInMonth{ + Month: month, + Market: market, + Price: res.price, + Slots: res.slots, + Sessions: res.sessions, + Skipped: res.skipped, + At: time.Now(), + Manual: manual, + }) + site.publishFeedIn(f) + + site.log.INFO.Printf("feed-in %s finalized at %.5f/kWh: %d rate slots and %d charging sessions recalculated, %d sessions without stored rates left as they were", + month, res.price, res.slots, res.sessions, res.skipped) + + return nil +} + +// feedInHistory returns the finalized months, newest first +func feedInHistory() []feedInMonth { + var res []feedInMonth + _ = settings.Json(keys.FeedInHistory, &res) + return res +} + +// recordFeedInMonth adds a month to the history, replacing an earlier entry +func (site *Site) recordFeedInMonth(m feedInMonth) { + months := slices.DeleteFunc(feedInHistory(), func(e feedInMonth) bool { return e.Month == m.Month }) + months = append(months, m) + + // YYYY-MM sorts in date order + slices.SortFunc(months, func(a, b feedInMonth) int { return cmp.Compare(b.Month, a.Month) }) + if len(months) > feedInHistoryLength { + months = months[:feedInHistoryLength] + } + + if err := settings.SetJson(keys.FeedInHistory, months); err != nil { + site.log.ERROR.Printf("feed-in history: %v", err) + } +} + +// backfillFeedInHistory adds a month finalized before the history existed, with +// the rate its slots hold now +func (site *Site) backfillFeedInHistory() { + done, err := settings.String(keys.FeedInFinalized) + if err != nil || done == "" { + return + } + + if slices.ContainsFunc(feedInHistory(), func(m feedInMonth) bool { return m.Month == done }) { + return + } + + from, err := time.ParseInLocation("2006-01", done, time.Local) + if err != nil { + return + } + + m := feedInMonth{Month: done} + if avg, ok, err := metrics.FeedInAverage(db.Instance, from, from.AddDate(0, 1, 0)); err == nil && ok { + m.Price = avg + } + + site.recordFeedInMonth(m) +} + +// feedInMarketChanged reports whether the market price differs from the one +// last published, or none was published yet +func (site *Site) feedInMarketChanged(f finalFeedIn) bool { + market, _ := f.MarketPrice() + + s := site.lms() + + s.mu.Lock() + defer s.mu.Unlock() + + if s.feedInMarket != nil && *s.feedInMarket == market { + return false + } + s.feedInMarket = &market + + return true +} + +func (site *Site) publishFeedIn(f finalFeedIn) { + market, _ := f.MarketPrice() + + months := feedInHistory() + if months == nil { + months = []feedInMonth{} + } + + site.publish(keys.FeedInFinal, feedInState{ + FinalizeDay: f.FinalizeDay(), + Market: market, + Months: months, + }) +} + +// FinalizeFeedIn recalculates a past month at the given market price in EUR/kWh: +// for a price that was wrong on the finalize day, or a month not finalized yet +func (site *Site) FinalizeFeedIn(month string, market float64) error { + f, ok := feedInFinalizer(site.GetTariff(api.TariffUsageFeedIn)) + if !ok { + return errors.New("the feed-in tariff has no final price") + } + if db.Instance == nil { + return errors.New("no database") + } + + if err := tariff.ValidMarketPrice(market); err != nil { + return err + } + + from, err := time.ParseInLocation("2006-01", month, time.Local) + if err != nil { + return fmt.Errorf("invalid month: %s", month) + } + + now := time.Now() + if !from.Before(time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local)) { + return fmt.Errorf("month is not over yet: %s", month) + } + + return site.finalizeFeedInMonth(f, month, from, from.AddDate(0, 1, 0), market, true) +} + +// feedInDue returns the month to finalize at now: the previous one, once the +// finalize day is reached and unless that month is done already +func feedInDue(now time.Time, day int, done string) (from, to time.Time, month string, due bool) { + from = time.Date(now.Year(), now.Month()-1, 1, 0, 0, 0, 0, now.Location()) + to = from.AddDate(0, 1, 0) + month = from.Format("2006-01") + + // YYYY-MM compares in date order + due = now.Day() >= day && done < month + + return from, to, month, due +} + +type feedInResult struct { + price float64 + slots, sessions, skipped int +} + +// finalizeFeedIn applies the final feed-in price to [from,to) in one +// transaction. Sessions come first, they need the provisional rates the slots +// still hold. +func finalizeFeedIn(conn *gorm.DB, from, to time.Time, rate func(time.Time) (float64, error)) (feedInResult, error) { + var res feedInResult + + price, err := rate(from) + if err != nil { + return res, err + } + res.price = price + + err = conn.Transaction(func(tx *gorm.DB) error { + // without a loadpoint there has never been a session table + var sessions []session.Session + if tx.Migrator().HasTable("sessions") { + if err := tx.Table("sessions").Where("created >= ? AND created < ?", from, to).Find(&sessions).Error; err != nil { + return err + } + } + + for _, s := range sessions { + if s.Price == nil || s.SolarPercentage == nil || *s.SolarPercentage <= 0 || s.ChargedEnergy <= 0 { + continue + } + + finished := s.Finished + if finished.IsZero() || !finished.After(s.Created) { + finished = s.Created.Add(15 * time.Minute) + } + + old, ok, err := metrics.FeedInAverage(tx, s.Created, finished) + if err != nil { + return err + } + if !ok { + // the provisional price it was valued at is unknown + res.skipped++ + continue + } + + final, err := rate(s.Created) + if err != nil { + return err + } + + price, perKWh := revaluedSessionPrice(s, old, final) + + if err := tx.Table("sessions").Where("id = ?", s.ID). + Updates(map[string]any{"price": price, "price_per_kwh": perKWh}).Error; err != nil { + return err + } + + res.sessions++ + } + + n, err := metrics.SetFeedIn(tx, from, to, rate) + res.slots = n + + return err + }) + + return res, err +} + +// revaluedSessionPrice returns a session's price with its solar share valued at +// the final instead of the provisional feed-in rate. evcc prices solar energy at +// the feed-in rate it could otherwise have earned. +func revaluedSessionPrice(s session.Session, old, final float64) (float64, float64) { + solar := s.ChargedEnergy * *s.SolarPercentage / 100 + price := *s.Price + (final-old)*solar + return price, price / s.ChargedEnergy +} diff --git a/core/site_feedin_eeg.go b/core/site_feedin_eeg.go index f00066e873..d9bfcd5578 100644 --- a/core/site_feedin_eeg.go +++ b/core/site_feedin_eeg.go @@ -3,7 +3,7 @@ package core // Custom extension: export sold under two feed-in tariffs. // // Part of the export goes to an energy community (EEG) at a fixed price, the -// rest at the standard feed-in tariff. A Home Assistant energy counter +// rest at the standard feed-in tariff (OeMAG). A Home Assistant energy counter // meters the EEG part. It is recorded like any other meter, per 15 minute slot, // by a collector of group "meter", which upstream keeps out of every balance. // The standard part is the grid meter's export minus EEG, derived from the diff --git a/core/site_feedin_test.go b/core/site_feedin_test.go new file mode 100644 index 0000000000..04d8164e72 --- /dev/null +++ b/core/site_feedin_test.go @@ -0,0 +1,241 @@ +package core + +import ( + "testing" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/core/metrics" + "github.com/evcc-io/evcc/core/session" + "github.com/evcc-io/evcc/db" + "github.com/evcc-io/evcc/db/settings" + "github.com/evcc-io/evcc/tariff" + "github.com/evcc-io/evcc/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFeedInDue(t *testing.T) { + loc := time.Local + at := func(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 12, 0, 0, 0, loc) } + + for _, tc := range []struct { + name string + now time.Time + day int + done string + month string + due bool + }{ + {"before the finalize day", at(2026, 9, 14), 15, "", "2026-08", false}, + {"on the finalize day", at(2026, 9, 15), 15, "", "2026-08", true}, + {"after it, catching up", at(2026, 9, 28), 15, "", "2026-08", true}, + {"already done", at(2026, 9, 20), 15, "2026-08", "2026-08", false}, + {"next month, older one done", at(2026, 10, 15), 15, "2026-08", "2026-09", true}, + {"january finalizes december", at(2027, 1, 15), 15, "2026-11", "2026-12", true}, + {"custom day", at(2026, 9, 3), 3, "", "2026-08", true}, + } { + t.Run(tc.name, func(t *testing.T) { + from, to, month, due := feedInDue(tc.now, tc.day, tc.done) + assert.Equal(t, tc.month, month) + assert.Equal(t, tc.due, due) + assert.Equal(t, 1, from.Day()) + assert.Equal(t, from.AddDate(0, 1, 0), to) + }) + } +} + +// TestFinalizeFeedIn: August was valued at a provisional 7 ct, the final OeMAG +// price is 8.997 ct +func TestFinalizeFeedIn(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, db.Instance.Table("sessions").AutoMigrate(new(session.Session))) + + loc := time.Local + aug := time.Date(2026, 8, 1, 0, 0, 0, 0, loc) + sep := aug.AddDate(0, 1, 0) + + provisional, final := 0.07, 0.08997 + grid := 0.30 + + // provisional feed-in rates for two august afternoons and the first of september + persist := func(from time.Time, hours int) { + for ts := from; ts.Before(from.Add(time.Duration(hours) * time.Hour)); ts = ts.Add(tariff.SlotDuration) { + require.NoError(t, metrics.PersistTariffs(ts, &grid, &provisional, nil, nil)) + } + } + persist(time.Date(2026, 8, 10, 12, 0, 0, 0, loc), 2) + persist(time.Date(2026, 8, 20, 12, 0, 0, 0, loc), 2) + persist(time.Date(2026, 9, 1, 12, 0, 0, 0, loc), 2) + + f := func(v float64) *float64 { return &v } + + // 10 kWh, 60 % solar: 6 kWh at feed-in, 4 kWh at grid price + solarSession := session.Session{ + Created: time.Date(2026, 8, 10, 12, 0, 0, 0, loc), Finished: time.Date(2026, 8, 10, 13, 0, 0, 0, loc), + ChargedEnergy: 10, SolarPercentage: f(60), + Price: f(6*provisional + 4*grid), PricePerKWh: f((6*provisional + 4*grid) / 10), + } + gridSession := session.Session{ + Created: time.Date(2026, 8, 20, 12, 0, 0, 0, loc), Finished: time.Date(2026, 8, 20, 13, 0, 0, 0, loc), + ChargedEnergy: 5, SolarPercentage: f(0), Price: f(5 * grid), PricePerKWh: f(grid), + } + unknownSession := session.Session{ // evcc was not recording rates then + Created: time.Date(2026, 8, 25, 12, 0, 0, 0, loc), Finished: time.Date(2026, 8, 25, 13, 0, 0, 0, loc), + ChargedEnergy: 8, SolarPercentage: f(100), Price: f(8 * provisional), PricePerKWh: f(provisional), + } + septemberSession := session.Session{ + Created: time.Date(2026, 9, 1, 12, 0, 0, 0, loc), Finished: time.Date(2026, 9, 1, 13, 0, 0, 0, loc), + ChargedEnergy: 10, SolarPercentage: f(100), Price: f(10 * provisional), PricePerKWh: f(provisional), + } + + for _, s := range []*session.Session{&solarSession, &gridSession, &unknownSession, &septemberSession} { + require.NoError(t, db.Instance.Table("sessions").Create(s).Error) + } + + rate := func(time.Time) (float64, error) { return final, nil } + + res, err := finalizeFeedIn(db.Instance, aug, sep, rate) + require.NoError(t, err) + + assert.Equal(t, final, res.price) + assert.Equal(t, 16, res.slots, "two august afternoons, 8 slots each") + assert.Equal(t, 1, res.sessions, "only the solar session changes") + assert.Equal(t, 1, res.skipped, "the session without stored rates") + + load := func(id uint) session.Session { + var s session.Session + require.NoError(t, db.Instance.Table("sessions").First(&s, id).Error) + return s + } + + // solar share revalued: 6 kWh x (8.997 - 7) ct = +0.11982 + s := load(solarSession.ID) + assert.InDelta(t, 6*final+4*grid, *s.Price, 1e-9) + assert.InDelta(t, (6*final+4*grid)/10, *s.PricePerKWh, 1e-9) + + assert.InDelta(t, 5*grid, *load(gridSession.ID).Price, 1e-9, "no solar share, unchanged") + assert.InDelta(t, 8*provisional, *load(unknownSession.ID).Price, 1e-9, "old rate unknown, unchanged") + assert.InDelta(t, 10*provisional, *load(septemberSession.ID).Price, 1e-9, "other month, unchanged") + + // the rates of august are final now, september keeps its provisional ones + avg, ok, err := metrics.FeedInAverage(db.Instance, aug, sep) + require.NoError(t, err) + require.True(t, ok) + assert.InDelta(t, final, avg, 1e-9) + + avg, ok, err = metrics.FeedInAverage(db.Instance, sep, sep.AddDate(0, 1, 0)) + require.NoError(t, err) + require.True(t, ok) + assert.InDelta(t, provisional, avg, 1e-9) + + // running it again changes nothing: the sessions are now valued at the rate + // the slots hold + _, err = finalizeFeedIn(db.Instance, aug, sep, rate) + require.NoError(t, err) + assert.InDelta(t, 6*final+4*grid, *load(solarSession.ID).Price, 1e-9) +} + +// a feed-in tariff behind the wrapper of a late created tariff is still found +func TestFeedInFinalizerUnwraps(t *testing.T) { + var direct api.Tariff = &finalTariff{} + _, ok := feedInFinalizer(direct) + assert.True(t, ok) + + _, ok = feedInFinalizer(&wrappedTariff{direct}) + assert.True(t, ok) + + // daily rates are split into slots by evcc, possibly behind a late start + _, ok = feedInFinalizer(&tariff.SlotWrapper{Tariff: direct}) + assert.True(t, ok, "slot wrapper") + + _, ok = feedInFinalizer(&wrappedTariff{&tariff.SlotWrapper{Tariff: direct}}) + assert.True(t, ok, "both wrappers") + + _, ok = feedInFinalizer(&wrappedTariff{nil}) + assert.False(t, ok, "not created yet") + + _, ok = feedInFinalizer(nil) + assert.False(t, ok) +} + +type finalTariff struct{ api.Tariff } + +func (finalTariff) FinalizeDay() int { return 15 } +func (finalTariff) MarketPrice() (float64, error) { return 0.09, nil } +func (finalTariff) TotalPrice(market float64, _ time.Time) float64 { return market - 0.01 } + +type wrappedTariff struct{ t api.Tariff } + +func (w *wrappedTariff) Rates() (api.Rates, error) { return nil, nil } +func (w *wrappedTariff) Type() api.TariffType { return api.TariffTypePriceStatic } +func (w *wrappedTariff) Unwrap() api.Tariff { return w.t } + +// without loadpoints there is no session table, the rates are finalized anyway +func TestFinalizeFeedInWithoutSessions(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + + aug := time.Date(2026, 8, 1, 0, 0, 0, 0, time.Local) + provisional := 0.07 + require.NoError(t, metrics.PersistTariffs(aug.AddDate(0, 0, 9), nil, &provisional, nil, nil)) + + res, err := finalizeFeedIn(db.Instance, aug, aug.AddDate(0, 1, 0), func(time.Time) (float64, error) { return 0.09, nil }) + require.NoError(t, err) + assert.Equal(t, 1, res.slots) + assert.Equal(t, 0, res.sessions) +} + +// TestFeedInHistory: every finalization is recorded, a recalculation by hand +// replaces the month's entry and keeps the automatic one from running again +func TestFeedInHistory(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + settings.SetJson(keys.FeedInHistory, []feedInMonth{}) + settings.SetString(keys.FeedInFinalized, "") + + site := &Site{log: util.NewLogger("test")} + f := &finalTariff{} + + jul := time.Date(2026, 7, 1, 0, 0, 0, 0, time.Local) + aug := jul.AddDate(0, 1, 0) + + provisional := 0.07 + require.NoError(t, metrics.PersistTariffs(aug.AddDate(0, 0, 9), nil, &provisional, nil, nil)) + + // automatic, at the published 9 ct minus 1 ct charges + require.NoError(t, site.finalizeFeedInMonth(f, "2026-08", aug, aug.AddDate(0, 1, 0), 0.09, false)) + // july by hand + require.NoError(t, site.finalizeFeedInMonth(f, "2026-07", jul, aug, 0.085, true)) + + months := feedInHistory() + require.Len(t, months, 2) + assert.Equal(t, "2026-08", months[0].Month, "newest first") + assert.InDelta(t, 0.08, months[0].Price, 1e-9) + assert.Equal(t, 1, months[0].Slots) + assert.False(t, months[0].Manual) + assert.True(t, months[1].Manual) + + done, _ := settings.String(keys.FeedInFinalized) + assert.Equal(t, "2026-08", done, "an older month by hand does not move it back") + + // august corrected by hand: the entry is replaced, the slot holds the new rate + require.NoError(t, site.finalizeFeedInMonth(f, "2026-08", aug, aug.AddDate(0, 1, 0), 0.095, true)) + months = feedInHistory() + require.Len(t, months, 2) + assert.InDelta(t, 0.085, months[0].Price, 1e-9) + assert.True(t, months[0].Manual) + + avg, ok, err := metrics.FeedInAverage(db.Instance, aug, aug.AddDate(0, 1, 0)) + require.NoError(t, err) + require.True(t, ok) + assert.InDelta(t, 0.085, avg, 1e-9) + + // a month finalized before the history existed is taken over once + settings.SetJson(keys.FeedInHistory, []feedInMonth{}) + site.backfillFeedInHistory() + site.backfillFeedInHistory() + months = feedInHistory() + require.Len(t, months, 1) + assert.Equal(t, "2026-08", months[0].Month) + assert.InDelta(t, 0.085, months[0].Price, 1e-9, "the rate the slots hold") +} diff --git a/core/site_lm.go b/core/site_lm.go index 4eaba69f22..d6e9897407 100644 --- a/core/site_lm.go +++ b/core/site_lm.go @@ -63,6 +63,9 @@ type lmState struct { adv lmAdvanced batteryShedUntil time.Time // battery grid charge hold-off after a shed + feedInTried time.Time // last feed-in finalization attempt, see site_feedin.go + feedInOnce sync.Once // feed-in history backfilled + feedInMarket *float64 // market price last published gridOnce gridChargeOnce // one-time grid charging, see site_lm_once.go eeg feedInEegState // second feed-in tariff, see site_feedin_eeg.go batteryCircuit api.Circuit // resolved from the assignment diff --git a/core/site_optimizer_lm_test.go b/core/site_optimizer_lm_test.go index fca376ef29..da1320c709 100644 --- a/core/site_optimizer_lm_test.go +++ b/core/site_optimizer_lm_test.go @@ -222,7 +222,7 @@ func TestLmOptimizerInputsEdgeCases(t *testing.T) { } // The EEG tariff never reaches the optimizer: its feed-in price is the -// standard feed-in tariff. +// standard feed-in tariff (OeMAG). func TestLmOptimizerFeedInWithEeg(t *testing.T) { feedIn, err := tariff.NewFixedFromConfig(map[string]any{"price": 0.09}) require.NoError(t, err) diff --git a/i18n/de.json b/i18n/de.json index a9b28c2662..8ea87cb462 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -358,6 +358,33 @@ "titleAdd": "Zusätzlichen Zähler hinzufügen", "titleEdit": "Zusätzlichen Zähler bearbeiten" }, + "feedinfinal": { + "applied": "Angewendet", + "counts": "{slots} Werte, {sessions} Ladungen", + "countsLabel": "Neu berechnet", + "description": "Die OeMAG veröffentlicht den Marktpreis erst im Folgemonat. Am {day}. wird er für den Vormonat übernommen und dessen Einspeise- und Ladepreise (Solaranteil) neu berechnet.", + "doneMessage": "{month} wurde neu berechnet.", + "edit": "Auswählen", + "invalidPrice": "Bitte einen Preis zwischen 0 und 100 ct/kWh eingeben.", + "lastLabel": "Endgültig", + "market": "Marktpreis", + "month": "Monat", + "monthsLabel": "Berechnete Monate", + "nextLabel": "Nächste Neuberechnung", + "none": "Noch kein Monat", + "nonePublished": "noch keiner", + "priceHelp": "Wie von der OeMAG veröffentlicht. Aufschläge und Steuer aus dem Tarif werden angewendet.", + "priceLabel": "Marktpreis", + "published": "Zuletzt veröffentlicht:", + "recalcHelp": "Etwa bei falschem Wert am Stichtag. Überschreibt die Einspeise- und Ladepreise (Solaranteil) des Monats.", + "recalcTitle": "Monat neu berechnen", + "recalculate": "Neu berechnen", + "show": "Monate anzeigen", + "source": "Art", + "sourceAuto": "automatisch", + "sourceManual": "von Hand", + "title": "OeMAG-Abrechnung" + }, "feedineeg": { "title": "EEG-Zähler", "description": "Home-Assistant-Zähler der Einspeisung in die Energiegemeinschaft. Einspeisevergütung = gesamte Einspeisung minus EEG. Es werden nur Zählerstände ausgewertet, die Regelung bleibt unverändert.", diff --git a/i18n/en.json b/i18n/en.json index 27b6335a71..45cdbc2940 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -358,6 +358,33 @@ "titleAdd": "Add Additional Meter", "titleEdit": "Edit Additional Meter" }, + "feedinfinal": { + "applied": "Applied", + "counts": "{slots} rates, {sessions} sessions", + "countsLabel": "Recalculated", + "description": "OeMAG publishes the market price in the following month. On day {day} it is applied to the previous month, whose feed-in and charging prices (solar share) are recalculated.", + "doneMessage": "{month} recalculated.", + "edit": "Select", + "invalidPrice": "Enter a price between 0 and 100 ct/kWh.", + "lastLabel": "Final", + "market": "Market price", + "month": "Month", + "monthsLabel": "Finalized months", + "nextLabel": "Next recalculation", + "none": "No month yet", + "nonePublished": "none yet", + "priceHelp": "As published by OeMAG. Surcharges and tax from the tariff are applied.", + "priceLabel": "Market price", + "published": "Last published:", + "recalcHelp": "E.g. if the value on the finalize day was wrong. Overwrites the month's feed-in and charging prices (solar share).", + "recalcTitle": "Recalculate month", + "recalculate": "Recalculate", + "show": "Show months", + "source": "Type", + "sourceAuto": "automatic", + "sourceManual": "by hand", + "title": "OeMAG settlement" + }, "feedineeg": { "title": "EEG counter", "description": "Home Assistant counter of the export to the energy community. Export tariff = total export minus EEG. Only counters are evaluated, control stays unchanged.", diff --git a/server/http_custom.go b/server/http_custom.go index 7626b2c110..368636e9f1 100644 --- a/server/http_custom.go +++ b/server/http_custom.go @@ -55,6 +55,9 @@ func customSiteRoutes(site site.API) map[string]route { "lmprofiledelete": {"DELETE", "/lmprofile/{id:[a-z0-9]+}", lmProfileHandler(site.DeleteLmProfile)}, "lmprofileapply": {"POST", "/lmprofile/{id:[a-z0-9]+}/apply", lmProfileHandler(site.ApplyLmProfile)}, + // feed-in price published after the fact, see core/site_feedin.go + "feedinfinalize": {"POST", "/feedinfinalize/{month:[0-9]{4}-[0-9]{2}}/{value:[0-9.]+}", feedInFinalizeHandler(site)}, + // export under a second feed-in tariff, see core/site_feedin_eeg.go "feedineegentity": {"POST", "/feedineegentity/{value:[a-zA-Z0-9_.]+}", stringHandler(site.SetFeedInEegEntity, site.GetFeedInEegEntity)}, "feedineegentitydelete": {"DELETE", "/feedineegentity", stringHandler(site.SetFeedInEegEntity, site.GetFeedInEegEntity)}, @@ -79,6 +82,25 @@ func customSiteRoutes(site site.API) map[string]route { } } +// feedInFinalizeHandler recalculates a past month at the given market price +func feedInFinalizeHandler(site site.API) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + + market, err := strconv.ParseFloat(vars["value"], 64) + if err == nil { + err = site.FinalizeFeedIn(vars["month"], market) + } + + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + jsonWrite(w, market) + } +} + // lmProfileSaveHandler creates or updates a battery profile sent as json func lmProfileSaveHandler(site site.API) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/tariff/oemag.go b/tariff/oemag.go new file mode 100644 index 0000000000..0c5fd120b6 --- /dev/null +++ b/tariff/oemag.go @@ -0,0 +1,175 @@ +package tariff + +// Custom extension: the OeMAG market price, the feed-in compensation of the +// Austrian settlement agency. OeMAG publishes a month's price only after the +// month has ended. The latest published value is used as the running feed-in +// price, and from the finalize day on it is taken as the final price of the +// previous month, which core/site_feedin.go then applies retroactively. + +import ( + "encoding/json" + "errors" + "fmt" + "slices" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/jinzhu/now" +) + +const oemagURI = "https://raw.githubusercontent.com/chrsbrmr/oemag-marktpreis/refs/heads/main/preis.json" + +type Oemag struct { + *embed + log *util.Logger + uri string + finalizeDay int + data *util.Monitor[float64] +} + +var _ api.Tariff = (*Oemag)(nil) + +func init() { + registry.Add("oemag", NewOemagFromConfig) +} + +func NewOemagFromConfig(other map[string]any) (api.Tariff, error) { + cc := struct { + embed `mapstructure:",squash"` + URI string + FinalizeDay int + }{ + URI: oemagURI, + FinalizeDay: 15, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + if err := cc.init(); err != nil { + return nil, err + } + + if cc.FinalizeDay < 1 || cc.FinalizeDay > 28 { + return nil, fmt.Errorf("finalize day must be between 1 and 28: %d", cc.FinalizeDay) + } + + t := &Oemag{ + embed: &cc.embed, + log: util.NewLogger("oemag"), + uri: cc.URI, + finalizeDay: cc.FinalizeDay, + data: util.NewMonitor[float64](25 * time.Hour), + } + + return runOrError(t) +} + +// oemagPrice is the published document +type oemagPrice struct { + Price *float64 `json:"oemag_marktpreis"` + Unit string `json:"unit"` +} + +func (t *Oemag) run(done chan error) { + var once sync.Once + client := request.NewHelper(t.log) + + for tick := time.Tick(3 * time.Hour); ; <-tick { + price, err := backoff.RetryWithData(func() (float64, error) { + var res oemagPrice + if err := client.GetJSON(t.uri, &res); err != nil { + // a malformed document does not get better by asking again + var syntax *json.SyntaxError + var typ *json.UnmarshalTypeError + if errors.As(err, &syntax) || errors.As(err, &typ) { + return 0, backoff.Permanent(err) + } + return 0, backoffPermanentError(err) + } + return validOemagPrice(res) + }, bo()) + if err != nil { + if reportError(&once, done, err) { + return + } + t.log.ERROR.Println(err) + continue + } + + t.data.Set(price) + once.Do(func() { close(done) }) + } +} + +// validOemagPrice guards against a broken scrape: the source is not official +func validOemagPrice(res oemagPrice) (float64, error) { + if res.Price == nil { + return 0, backoff.Permanent(errors.New("missing oemag_marktpreis")) + } + if res.Unit != "" && res.Unit != "EUR/kWh" { + return 0, backoff.Permanent(fmt.Errorf("unexpected unit: %s", res.Unit)) + } + if p := *res.Price; p <= 0 || p >= 1 { + return 0, backoff.Permanent(fmt.Errorf("implausible price: %v EUR/kWh", p)) + } + return *res.Price, nil +} + +// Rates implements the api.Tariff interface: the published price, flat +func (t *Oemag) Rates() (api.Rates, error) { + var price float64 + if err := t.data.GetFunc(func(v float64) { price = v }); err != nil { + return nil, err + } + + var res api.Rates + + start := now.BeginningOfDay() + for i := range 7 { + ts := start.AddDate(0, 0, i) + res = append(res, api.Rate{ + Start: ts, + End: ts.AddDate(0, 0, 1), + Value: t.totalPrice(price, ts), + }) + } + + return slices.Clip(res), nil +} + +// Type implements the api.Tariff interface +func (t *Oemag) Type() api.TariffType { + return api.TariffTypePriceStatic +} + +// FinalizeDay returns the day of the month from which the published price is +// the final price of the previous month +func (t *Oemag) FinalizeDay() int { + return t.finalizeDay +} + +// MarketPrice returns the latest published market price in EUR/kWh, from the +// finalize day on the final price of the previous month +func (t *Oemag) MarketPrice() (float64, error) { + var price float64 + err := t.data.GetFunc(func(v float64) { price = v }) + return price, err +} + +// TotalPrice returns a market price as the feed-in rate at ts, with the +// configured charges and tax applied like for the running rates +func (t *Oemag) TotalPrice(market float64, ts time.Time) float64 { + return t.totalPrice(market, ts) +} + +// ValidMarketPrice checks a market price entered by hand like a published one +func ValidMarketPrice(price float64) error { + _, err := validOemagPrice(oemagPrice{Price: &price, Unit: "EUR/kWh"}) + return err +} diff --git a/tariff/oemag_test.go b/tariff/oemag_test.go new file mode 100644 index 0000000000..3624a06b49 --- /dev/null +++ b/tariff/oemag_test.go @@ -0,0 +1,98 @@ +package tariff + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func oemagServer(t *testing.T, body string) string { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + + return srv.URL +} + +const oemagSample = `{ + "timestamp": "2026-09-23T11:01:07.140613", + "oemag_marktpreis": 0.08997, + "unit": "EUR/kWh", + "raw_scraped_value": "8,997 ct/kWh" +}` + +func TestOemag(t *testing.T) { + tt, err := NewOemagFromConfig(map[string]any{"uri": oemagServer(t, oemagSample)}) + require.NoError(t, err) + + o := tt.(*Oemag) + assert.Equal(t, api.TariffTypePriceStatic, o.Type()) + assert.Equal(t, 15, o.FinalizeDay(), "default finalize day") + + rates, err := o.Rates() + require.NoError(t, err) + require.NotEmpty(t, rates) + + r, err := rates.At(time.Now()) + require.NoError(t, err) + assert.Equal(t, 0.08997, r.Value) + + p, err := o.MarketPrice() + require.NoError(t, err) + assert.Equal(t, 0.08997, p) + assert.Equal(t, 0.08997, o.TotalPrice(p, time.Now())) +} + +func TestOemagChargesAndTax(t *testing.T) { + tt, err := NewOemagFromConfig(map[string]any{ + "uri": oemagServer(t, oemagSample), + "charges": -0.01, + "finalizeday": 20, + }) + require.NoError(t, err) + + o := tt.(*Oemag) + assert.Equal(t, 20, o.FinalizeDay()) + + p, err := o.MarketPrice() + require.NoError(t, err) + assert.Equal(t, 0.08997, p, "the market price as published") + assert.InDelta(t, 0.07997, o.TotalPrice(p, time.Now()), 1e-9, "final price with the same charges as the running rate") + assert.InDelta(t, 0.08, o.TotalPrice(0.09, time.Now()), 1e-9, "a price entered by hand gets them too") +} + +func TestOemagRejects(t *testing.T) { + for _, tc := range []struct { + name string + body string + }{ + {"missing price", `{"unit": "EUR/kWh"}`}, + {"zero", `{"oemag_marktpreis": 0, "unit": "EUR/kWh"}`}, + {"cent instead of euro", `{"oemag_marktpreis": 8.997, "unit": "EUR/kWh"}`}, + {"wrong unit", `{"oemag_marktpreis": 0.08997, "unit": "ct/kWh"}`}, + {"not json", `rate limited`}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := NewOemagFromConfig(map[string]any{"uri": oemagServer(t, tc.body)}) + assert.Error(t, err) + }) + } + + assert.NoError(t, ValidMarketPrice(0.08997)) + assert.Error(t, ValidMarketPrice(8.997), "cent instead of euro") + assert.Error(t, ValidMarketPrice(0)) + + for _, day := range []int{0, 29, 31} { + _, err := NewOemagFromConfig(map[string]any{"uri": oemagServer(t, oemagSample), "finalizeday": day}) + assert.Error(t, err, "finalize day %d", day) + } +} diff --git a/tariff/wrapper_custom.go b/tariff/wrapper_custom.go new file mode 100644 index 0000000000..f0556668e4 --- /dev/null +++ b/tariff/wrapper_custom.go @@ -0,0 +1,24 @@ +package tariff + +// Custom extension: access to wrapped tariffs, so capabilities beyond +// api.Tariff can be found on the tariff underneath, see core/site_feedin.go. + +import "github.com/evcc-io/evcc/api" + +var ( + _ interface{ Unwrap() api.Tariff } = (*Wrapper)(nil) + _ interface{ Unwrap() api.Tariff } = (*SlotWrapper)(nil) +) + +// Unwrap returns the wrapped tariff once it could be created, nil until then +func (v *Wrapper) Unwrap() api.Tariff { + v.mu.Lock() + defer v.mu.Unlock() + + return v.instance() +} + +// Unwrap returns the tariff whose rates are split into slots +func (t *SlotWrapper) Unwrap() api.Tariff { + return t.Tariff +} diff --git a/templates/definition/tariff/oemag.yaml b/templates/definition/tariff/oemag.yaml new file mode 100644 index 0000000000..9ebd9708fd --- /dev/null +++ b/templates/definition/tariff/oemag.yaml @@ -0,0 +1,45 @@ +template: oemag +link: https://www.oem-ag.at/de/marktpreis/ +products: + - brand: OeMAG + description: + de: Marktpreis (Einspeisung) + en: Market price (feed-in) +requirements: + evcc: ["skiptest"] + description: + de: | + Einspeisevergütung zum OeMAG-Marktpreis. Die OeMAG veröffentlicht den Preis eines Monats erst im Folgemonat. Bis dahin gilt der zuletzt veröffentlichte Wert als laufender Einspeisepreis. + Am Stichtag wird der dann veröffentlichte Wert als endgültiger Preis des Vormonats übernommen: Die gespeicherten Einspeisepreise des Vormonats und die Preise der Ladevorgänge des Vormonats (Solaranteil) werden einmalig neu berechnet. + en: | + Feed-in compensation at the OeMAG market price. OeMAG publishes a month's price only in the following month. Until then the latest published value is used as the running feed-in price. + On the finalize day the value published then becomes the final price of the previous month: the stored feed-in rates of the previous month and the prices of its charging sessions (solar share) are recalculated once. +group: price +countries: ["AT"] +params: + - name: finalizeday + type: int + default: 15 + description: + de: Stichtag Neuberechnung + en: Finalize day + help: + de: Tag im Monat (1 bis 28), ab dem der veröffentlichte Preis als endgültiger Preis des Vormonats gilt und der Vormonat neu berechnet wird. Jeder Monat wird nur einmal neu berechnet. + en: Day of the month (1 to 28) from which the published price is the final price of the previous month and the previous month is recalculated. Each month is recalculated only once. + - name: uri + advanced: true + default: https://raw.githubusercontent.com/chrsbrmr/oemag-marktpreis/refs/heads/main/preis.json + description: + de: Datenquelle + en: Data source + help: + de: JSON mit dem Feld oemag_marktpreis in EUR/kWh + en: JSON with the field oemag_marktpreis in EUR/kWh + - preset: tariff-base +render: | + type: oemag + finalizeday: {{ .finalizeday }} + {{- if .uri }} + uri: {{ .uri }} + {{- end }} + {{ include "base" . }}