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 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_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 { 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)