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
19 changes: 17 additions & 2 deletions core/lm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions core/site.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
18 changes: 10 additions & 8 deletions core/site_lm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
}

Expand Down
3 changes: 2 additions & 1 deletion core/site_lm_once.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
111 changes: 111 additions & 0 deletions core/site_lm_planner.go
Original file line number Diff line number Diff line change
@@ -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)
}
85 changes: 85 additions & 0 deletions core/site_lm_planner_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
1 change: 1 addition & 0 deletions core/site_peakshaving.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading