From 479c9b2cb37365d539dba429c110fb3dfb8b30b7 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Sat, 26 Sep 2026 11:10:19 +0200 Subject: [PATCH 1/3] Load management: battery and peak limit in the planner's ledger The planner shares circuit capacity through a ledger of loadpoint plans. Two inputs of the fork are added to it, refreshed every cycle and released as soon as they no longer apply: - the battery while it grid charges, for the running slot, ranked by its priority, so lower ranked plans go around it - while peak shaving is on, the part of the site circuit above the peak limit, ranked above everything, so plans stay within the limit Without circuits, battery circuit and peak shaving nothing is added and the planner behaves as upstream. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 137499bfeb6bace7e0041b875ff3c3287c1ebd75) --- core/site.go | 1 + core/site_lm.go | 18 +++--- core/site_lm_planner.go | 111 +++++++++++++++++++++++++++++++++++ core/site_lm_planner_test.go | 85 +++++++++++++++++++++++++++ core/site_peakshaving.go | 1 + 5 files changed, 208 insertions(+), 8 deletions(-) create mode 100644 core/site_lm_planner.go create mode 100644 core/site_lm_planner_test.go diff --git a/core/site.go b/core/site.go index 61eb5a9795..ee552b8bf6 100644 --- a/core/site.go +++ b/core/site.go @@ -244,6 +244,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif // give loadpoints access to vehicles and database ledger := planner.NewLedger() + site.setLedger(ledger) // custom: the fork's loads in the ledger, see core/site_lm_planner.go for i, lp := range site.activeLoadpoints() { lp.coordinator = coordinator.NewAdapter(lp, site.coordinator) lp.planner = planner.New(lp.log, tariff, planner.WithLedger(ledger, plannerOwner(i, lp))) diff --git a/core/site_lm.go b/core/site_lm.go index 5a6f33d774..f072f8c70e 100644 --- a/core/site_lm.go +++ b/core/site_lm.go @@ -25,6 +25,7 @@ import ( "github.com/evcc-io/evcc/core/keys" "github.com/evcc-io/evcc/core/lm" "github.com/evcc-io/evcc/core/loadpoint" + "github.com/evcc-io/evcc/core/planner" "github.com/evcc-io/evcc/db/settings" "github.com/evcc-io/evcc/util/config" ) @@ -55,14 +56,15 @@ type lmState struct { advMu sync.Mutex 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 - batteryCircuitRef string // what batteryCircuit was resolved from + 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 + ledger *planner.Ledger // planner's circuit ledger, see site_lm_planner.go + eeg feedInEegState // second feed-in tariff, see site_feedin_eeg.go + batteryCircuit api.Circuit // resolved from the assignment + batteryCircuitRef string // what batteryCircuit was resolved from batteryLoad *batteryLoad } diff --git a/core/site_lm_planner.go b/core/site_lm_planner.go new file mode 100644 index 0000000000..b65efd04ca --- /dev/null +++ b/core/site_lm_planner.go @@ -0,0 +1,111 @@ +package core + +// Custom extension: the fork's loads in the planner's circuit ledger (evcc PR +// 34044), ranked by the same priority as everywhere else, see site_lm_priority.go. +// +// The planner only knows loadpoint plans. Two inputs are added to its ledger, +// both released as soon as they no longer apply: +// +// - the battery while it grid charges, for the running slot, ranked by its +// priority, so lower ranked plans go around it +// - while peak shaving is on, the part of the root circuit above the peak +// limit, ranked above everything, so plans stay within the limit +// +// Without circuits, battery circuit and peak shaving the ledger holds nothing +// of ours and the planner behaves as upstream. + +import ( + "math" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/core/planner" + "github.com/evcc-io/evcc/tariff" +) + +// ledger owner ids of the fork's reservations, below the loadpoints' 0..n +const ( + ledgerBatteryId = -1 + ledgerPeakId = -2 +) + +// ledgerHorizon is how far ahead the peak limit is reserved +const ledgerHorizon = 48 * time.Hour + +// setLedger keeps the planner's ledger for the fork's reservations +func (site *Site) setLedger(l *planner.Ledger) { + s := site.lms() + + s.mu.Lock() + defer s.mu.Unlock() + + s.ledger = l +} + +// updateLmLedger refreshes the fork's reservations in the planner's ledger. +// Called once per cycle. +func (site *Site) updateLmLedger(gridCharge bool) { + s := site.lms() + + s.mu.Lock() + l := s.ledger + s.mu.Unlock() + + if l == nil { + return + } + + now := time.Now() + slot := now.Truncate(tariff.SlotDuration) + + site.reserveBattery(l, gridCharge, slot) + site.reservePeakLimit(l, slot) +} + +// reserveBattery holds the running slot for the battery while it grid charges +// on a circuit +func (site *Site) reserveBattery(l *planner.Ledger, gridCharge bool, slot time.Time) { + owner := planner.Owner{Id: ledgerBatteryId} + + c := site.lmBatteryCircuit() + power, _ := site.lmBatteryChargePower() + + if !gridCharge || c == nil || power <= 0 { + l.Reserve(owner, nil, nil) + return + } + + end := slot.Add(tariff.SlotDuration) + + owner.Priority = site.lmBatteryPriority() + owner.Target = end + owner.Circuit = c + owner.MaxPower = power + + l.Reserve(owner, api.Rates{{Start: slot, End: end}}, nil) +} + +// reservePeakLimit holds the part of the root circuit above the peak limit while +// peak shaving is on +func (site *Site) reservePeakLimit(l *planner.Ledger, slot time.Time) { + owner := planner.Owner{Id: ledgerPeakId} + + root := site.circuit + limit := site.GetPeakShavingLimit() + + var above float64 + if root != nil && site.GetPeakShaving() && limit > 0 { + above = root.GetMaxPower() - limit + } + + if above <= 0 { + l.Reserve(owner, nil, nil) + return + } + + owner.Priority = math.MaxInt + owner.Circuit = root + owner.MaxPower = above + + l.Reserve(owner, api.Rates{{Start: slot, End: slot.Add(ledgerHorizon)}}, nil) +} diff --git a/core/site_lm_planner_test.go b/core/site_lm_planner_test.go new file mode 100644 index 0000000000..36b8651d72 --- /dev/null +++ b/core/site_lm_planner_test.go @@ -0,0 +1,85 @@ +package core + +import ( + "math" + "testing" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/core/circuit" + "github.com/evcc-io/evcc/core/planner" + "github.com/evcc-io/evcc/tariff" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The battery holds the running slot while it grid charges, ranked by its +// priority; peak shaving holds everything above the limit. +func TestLmLedger(t *testing.T) { + config.Reset() + t.Cleanup(config.Reset) + + root, err := circuit.New(util.NewLogger("test"), "main", 0, 11000, nil, 0) + require.NoError(t, err) + require.NoError(t, config.Circuits().Add(config.NewStaticDevice(config.Named{Name: "main"}, api.Circuit(root)))) + + site := &Site{log: util.NewLogger("test"), circuit: root} + site.LoadManagement.Battery.CircuitRef = "main" + site.LoadManagement.Battery.Power = 5000 + site.LoadManagement.Battery.Priority = 4 + + ledger := planner.NewLedger() + site.setLedger(ledger) + + slot := time.Now().Truncate(tariff.SlotDuration) + rate := api.Rate{Start: slot, End: slot.Add(tariff.SlotDuration)} + ev := func(prio int) planner.Owner { + return planner.Owner{Id: 0, Priority: prio, Circuit: root, MaxPower: 11000} + } + + assert.Equal(t, 11000.0, ledger.Available(ev(2), rate), "nothing reserved") + + site.updateLmLedger(true) + assert.Equal(t, 6000.0, ledger.Available(ev(2), rate), "battery outranks") + assert.Equal(t, 11000.0, ledger.Available(ev(5), rate), "battery ranks lower") + later := api.Rate{Start: rate.End, End: rate.End.Add(tariff.SlotDuration)} + assert.Equal(t, 11000.0, ledger.Available(ev(2), later), "only the running slot") + + site.updateLmLedger(false) + assert.Equal(t, 11000.0, ledger.Available(ev(2), rate), "released") + + s := site.peak() + s.enabled, s.limit = true, 7000 + + site.updateLmLedger(false) + assert.Equal(t, 7000.0, ledger.Available(ev(10), rate), "peak limit outranks all") + day := api.Rate{Start: slot.Add(24 * time.Hour), End: slot.Add(24*time.Hour + tariff.SlotDuration)} + assert.Equal(t, 7000.0, ledger.Available(ev(10), day)) + + site.updateLmLedger(true) + assert.Equal(t, 2000.0, ledger.Available(ev(2), rate), "peak limit and battery") + + s.enabled = false + site.updateLmLedger(false) + assert.Equal(t, 11000.0, ledger.Available(ev(10), rate), "released") +} + +// Without circuits and peak shaving the fork adds nothing to the ledger. +func TestLmLedgerInertWhenUnused(t *testing.T) { + config.Reset() + t.Cleanup(config.Reset) + + (&Site{log: util.NewLogger("test")}).updateLmLedger(true) // no ledger: no-op + + site := &Site{log: util.NewLogger("test")} + ledger := planner.NewLedger() + site.setLedger(ledger) + + site.updateLmLedger(true) + + slot := time.Now().Truncate(tariff.SlotDuration) + rate := api.Rate{Start: slot, End: slot.Add(tariff.SlotDuration)} + assert.True(t, math.IsInf(ledger.Available(planner.Owner{Priority: 0}, rate), 1)) +} diff --git a/core/site_peakshaving.go b/core/site_peakshaving.go index 4196abff0a..d9a582f6df 100644 --- a/core/site_peakshaving.go +++ b/core/site_peakshaving.go @@ -812,6 +812,7 @@ func (site *Site) updateBatteryModePeakAware(gridCharge, gridDischarge bool, rat defer site.publishLmStatus(gridCharge) defer site.publishLmWallboxes() defer site.checkLmFollowing() + defer site.updateLmLedger(gridCharge) if gridCharge || !site.peakShavingActive() || site.GetBatteryModeExternal() != api.BatteryUnknown { site.updateBatteryMode(gridCharge, gridDischarge, rate) From ac1fdfe0334b2edc9c3efddb55a4d8818086c510 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Sat, 26 Sep 2026 20:57:33 +0200 Subject: [PATCH 2/3] Battery: one-time grid charging with the planner returning shares The planner sharing circuit capacity returns the reduced power of shared slots next to the plan; the one-time grid charging only needs the plan. Co-Authored-By: Claude Opus 5.5 --- core/site_lm_once.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/site_lm_once.go b/core/site_lm_once.go index d9ca2d073f..55c41d4582 100644 --- a/core/site_lm_once.go +++ b/core/site_lm_once.go @@ -181,7 +181,8 @@ func (site *Site) onceSlotActive(o gridChargeOnce, soc float64) bool { tariff = site.GetTariff(api.TariffUsagePlanner) } - plan := planner.New(util.NewLogger("gridcharge"), tariff).Plan(required, 0, o.Until, false) + // the shares of other loadpoints' plans do not apply to the battery + plan, _ := planner.New(util.NewLogger("gridcharge"), tariff).Plan(required, 0, o.Until, false) now := time.Now() for _, slot := range plan { From 93e17cde21789346a1f2ffe9c64862d5690080e9 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Sat, 26 Sep 2026 20:57:42 +0200 Subject: [PATCH 3/3] docs: the planner ledger Co-Authored-By: Claude Opus 5.5 --- core/lm/README.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/core/lm/README.md b/core/lm/README.md index 1fe38f5057..cb66eb093c 100644 --- a/core/lm/README.md +++ b/core/lm/README.md @@ -120,7 +120,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`, `updateFeedInFinalization`, `updateBatteryModePeakAware`, `setPeakGridEnergy` in `updateGridMeter` | +| `core/site.go` | `setLedger` in `Boot` (planner ledger, evcc PR 34044), `lm` import, `LoadManagement`/`loadMgmt`/`peakShaving` fields, two restore calls, `batteryGridChargeRequested`, `updatePeakShaving`, `updateFeedInFinalization`, `updateBatteryModePeakAware`, `setPeakGridEnergy` in `updateGridMeter` | | `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 | | `charger/switchsocket.go` | `RatedPower` config field, stands in for a missing power sensor | @@ -142,10 +142,25 @@ Everything else lives in files of its own: `core/lm/`, `core/site_lm.go`, `core/ `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_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`, +`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_lm_planner.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. +## Planner + +With the planner sharing circuit capacity (evcc PR 34044, not released yet; +until then this builds on the branch `preview/planner-ledger`), plans are made +around the reservations of higher ranked loadpoints. Two inputs are added to +its ledger every cycle, see `core/site_lm_planner.go`: + +- the battery while it grid charges, for the running slot, ranked by its + priority: lower ranked plans go around it +- while peak shaving is on, the part of the site circuit above the peak limit, + ranked above everything: plans stay within the limit + +Both are released as soon as they no longer apply. Without circuits, battery +circuit and peak shaving the ledger holds nothing of ours. + ## Shed guard A loadpoint that load management had to switch off can be held off for a set