diff --git a/assets/js/components/Battery/BatterySocGridChargeCard.vue b/assets/js/components/Battery/BatterySocGridChargeCard.vue
index 2b848027b8..2d1461ab50 100644
--- a/assets/js/components/Battery/BatterySocGridChargeCard.vue
+++ b/assets/js/components/Battery/BatterySocGridChargeCard.vue
@@ -45,6 +45,13 @@
+
{{ $t(`config.lmprofiles.group.${group.key}`) }}
@@ -205,6 +208,9 @@ export default {
profiles() {
return store.state?.lmProfiles || [];
},
+ optimizerAutomatic() {
+ return !!store.state?.optimizerAutomatic;
+ },
wallboxes() {
return store.state?.lmProfileWallboxes || [];
},
diff --git a/assets/js/components/LoadManagement/OverviewModal.vue b/assets/js/components/LoadManagement/OverviewModal.vue
index 461340891e..603cd801da 100644
--- a/assets/js/components/LoadManagement/OverviewModal.vue
+++ b/assets/js/components/LoadManagement/OverviewModal.vue
@@ -65,6 +65,12 @@
class="evcc-gray lock"
:title="$t('lmoverview.protected')"
>
+ {{ $t("lmoverview.optimizer") }}
{{ $t("lmoverview.priority", { priority: l.priority }) }}
diff --git a/assets/js/views/Battery.vue b/assets/js/views/Battery.vue
index 9beeff7136..a21ec9bd20 100644
--- a/assets/js/views/Battery.vue
+++ b/assets/js/views/Battery.vue
@@ -46,6 +46,7 @@
:active="socGridChargeEnabled && !!state.batteryGridChargeActive"
:start-soc="state.batterySocGridChargeStart ?? 20"
:stop-soc="state.batterySocGridChargeStop ?? 80"
+ :optimizer="!!state.optimizerAutomatic"
/>
0
}
+// releaseGridCharge stops grid charging: the charge power goes to 0 and what
+// the battery had reserved on the circuit is released, lower priority loads
+// would otherwise stay throttled until the reservation expires
+func (site *Site) releaseGridCharge() {
+ lm.Forget(site.lmBattery())
+ site.writeChargeValue(0)
+ site.recordBatteryLimit(0, 0)
+}
+
// recordBatteryLimit keeps what the battery may grid-charge with, which load
// management compares with what it draws, see core/lm/follow.go
func (site *Site) recordBatteryLimit(requested, allowed float64) {
diff --git a/core/site_lm_profiles.go b/core/site_lm_profiles.go
index 1a00d2b269..3cf9c46dee 100644
--- a/core/site_lm_profiles.go
+++ b/core/site_lm_profiles.go
@@ -132,7 +132,12 @@ func (site *Site) ApplyLmProfile(id string) error {
site.applyProfileBatteryUsage(p, add)
if v := p.DischargeControl; v != nil {
- add("discharge control", site.SetBatteryDischargeControl(*v))
+ // the optimizer decides it in automatic mode, the stored value applies again without
+ if err := site.SetBatteryDischargeControl(*v); errors.Is(err, ErrOptimizerAutomatic) {
+ site.log.DEBUG.Printf("profile %s: discharge control left to the optimizer", p.Name)
+ } else {
+ add("discharge control", err)
+ }
}
if v := p.PeakLimit; v != nil {
diff --git a/core/site_lm_status.go b/core/site_lm_status.go
index f092a50abf..eec3fd6070 100644
--- a/core/site_lm_status.go
+++ b/core/site_lm_status.go
@@ -8,6 +8,7 @@ package core
import (
"time"
+ "github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/lm"
"github.com/evcc-io/evcc/util/config"
@@ -34,6 +35,7 @@ type lmLoadStatus struct {
Requested float64 `json:"requested,omitempty"` // asked for in W
Allowed float64 `json:"allowed,omitempty"` // allowed in W
Until *time.Time `json:"until,omitempty"` // shed or paused until
+ Optimizer bool `json:"optimizer,omitempty"` // the optimizer decides, load management limits
}
type lmStatus struct {
@@ -80,6 +82,7 @@ func (site *Site) publishLmStatus(gridCharge bool) {
Power: lp.GetChargePower(),
}
st.State, st.Requested, st.Allowed, st.Until = lmLoadpointState(lp, st.Power, now)
+ st.Optimizer = lp.optimizerControlled()
res.Loads = append(res.Loads, st)
}
@@ -118,6 +121,12 @@ func (site *Site) lmBatteryStatus(now time.Time, gridCharge bool) lmLoadStatus {
Battery: true,
Priority: lm.Priority(bat),
State: lmStateOff,
+ // the optimizer's charge request passed the gate, see site_optimizer_gate.go
+ Optimizer: site.optimizerInControl(),
+ }
+
+ if st.Optimizer && site.GetBatteryMode() == api.BatteryCharge {
+ gridCharge = true
}
p := site.peak()
diff --git a/core/site_optimizer_gate.go b/core/site_optimizer_gate.go
new file mode 100644
index 0000000000..018127da7c
--- /dev/null
+++ b/core/site_optimizer_gate.go
@@ -0,0 +1,103 @@
+package core
+
+// Custom extension: the gate the optimizer's automatic mode passes.
+//
+// In automatic mode the optimizer sets the battery mode. Its charge request
+// passes the same gate as the fork's own grid charging (peak, circuit, charge
+// power setpoint) and becomes hold when refused. Hold gives way to normal
+// while a peak has to be covered, and peak shaving then only covers peaks.
+// When the optimizer result is missing or stale, the fork's grid charging
+// applies as without the optimizer. Without automatic mode the battery follows
+// upstream.
+
+import "github.com/evcc-io/evcc/api"
+
+// optimizerInControl reports whether the optimizer in automatic mode decides
+// the battery mode right now: automatic mode with a current result
+func (site *Site) optimizerInControl() bool {
+ if !site.Automatic() || site.GetBatteryModeExternal() != api.BatteryUnknown {
+ return false
+ }
+ _, ok := site.batterySuggestionMode()
+ return ok
+}
+
+// optimizerCharges reports whether the optimizer in control wants grid charging
+func (site *Site) optimizerCharges() bool {
+ if !site.optimizerInControl() {
+ return false
+ }
+ mode, _ := site.batterySuggestionMode()
+ return mode == api.BatteryCharge
+}
+
+// optimizerHolds reports whether the optimizer in control withholds discharging
+func (site *Site) optimizerHolds() bool {
+ if !site.optimizerInControl() {
+ return false
+ }
+ mode, _ := site.batterySuggestionMode()
+ return mode == api.BatteryHold || mode == api.BatteryHoldCharge
+}
+
+// peakNeedsBattery reports whether the demand exceeds what may be drawn from
+// the grid right now, so the battery has to discharge
+func (site *Site) peakNeedsBattery() bool {
+ s := site.peak()
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ return s.enabled && s.set != nil && s.demand > s.allowed
+}
+
+// lmGateBatteryMode passes the battery mode upstream decided in automatic mode:
+// a charge request the gate refuses becomes hold, hold gives way to normal
+// while a peak has to be covered, and without a current optimizer result the
+// fork's grid charging applies. Unknown means no change.
+func (site *Site) lmGateBatteryMode(mode api.BatteryMode, gridCharge bool) api.BatteryMode {
+ if !site.Automatic() || site.GetBatteryModeExternal() != api.BatteryUnknown {
+ return mode
+ }
+
+ current := site.GetBatteryMode()
+ change := func(m api.BatteryMode) api.BatteryMode {
+ if m == current {
+ return api.BatteryUnknown
+ }
+ return m
+ }
+
+ // missing or stale result: the fork's own grid charging, as without the optimizer
+ if !site.optimizerInControl() {
+ if gridCharge {
+ return change(api.BatteryCharge)
+ }
+ return mode
+ }
+
+ target := mode
+ if target == api.BatteryUnknown {
+ target = current
+ }
+
+ switch target {
+ case api.BatteryCharge:
+ if !site.gridChargeGate() {
+ site.log.DEBUG.Println("battery mode: optimizer charge refused by load management or peak shaving, holding")
+ return change(api.BatteryHold)
+ }
+
+ case api.BatteryHold, api.BatteryHoldCharge:
+ site.releaseGridCharge()
+ if site.peakNeedsBattery() {
+ site.log.DEBUG.Println("battery mode: optimizer hold, covering a peak")
+ return change(api.BatteryNormal)
+ }
+
+ default:
+ site.releaseGridCharge()
+ }
+
+ return mode
+}
diff --git a/core/site_optimizer_gate_test.go b/core/site_optimizer_gate_test.go
new file mode 100644
index 0000000000..a2d06241d6
--- /dev/null
+++ b/core/site_optimizer_gate_test.go
@@ -0,0 +1,76 @@
+package core
+
+import (
+ "testing"
+
+ "github.com/evcc-io/evcc/api"
+ "github.com/evcc-io/evcc/core/types"
+ "github.com/evcc-io/evcc/util"
+ "github.com/evcc-io/evcc/util/config"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func automaticSite(t *testing.T, action string) *Site {
+ t.Helper()
+ enableAutomatic(t)
+
+ bat := &scenarioBattery{}
+ site := &Site{
+ log: util.NewLogger("test"),
+ batteryMeters: []config.Device[api.Meter]{config.NewStaticDevice(config.Named{Name: "bat"}, api.Meter(bat))},
+ }
+ if action != "" {
+ site.setSuggestions(map[string]types.Suggestion{batteryKey("bat"): {Action: action}})
+ }
+ return site
+}
+
+// The optimizer's charge request passes the fork's gate.
+func TestLmGateOptimizerCharge(t *testing.T) {
+ site := automaticSite(t, api.BatteryCharge.String())
+ require.True(t, site.optimizerInControl())
+
+ assert.Equal(t, api.BatteryCharge, site.lmGateBatteryMode(api.BatteryCharge, false), "allowed")
+
+ // a running peak refuses it
+ setPeakShaving(site, 7000, 30)
+ site.peak().demand = 9000
+ assert.Equal(t, api.BatteryHold, site.lmGateBatteryMode(api.BatteryCharge, false), "held during a peak")
+
+ // the fork's own grid charge flag does not decide while the optimizer is in control
+ assert.False(t, site.batteryGridChargeRequested(api.Rate{}))
+}
+
+// Hold gives way to normal while a peak has to be covered.
+func TestLmGateOptimizerHoldDuringPeak(t *testing.T) {
+ site := automaticSite(t, api.BatteryHold.String())
+
+ assert.Equal(t, api.BatteryHold, site.lmGateBatteryMode(api.BatteryHold, false), "no peak shaving")
+
+ setPeakShaving(site, 7000, 30)
+ s := site.peak()
+ s.demand, s.allowed = 6000, 7000
+ assert.Equal(t, api.BatteryHold, site.lmGateBatteryMode(api.BatteryHold, false), "no peak")
+ assert.True(t, site.optimizerHolds())
+
+ s.demand = 9000
+ assert.Equal(t, api.BatteryNormal, site.lmGateBatteryMode(api.BatteryHold, false), "covering a peak")
+}
+
+// Without a current result the fork's grid charging applies as without the optimizer.
+func TestLmGateOptimizerStale(t *testing.T) {
+ site := automaticSite(t, "")
+ require.False(t, site.optimizerInControl())
+
+ assert.Equal(t, api.BatteryCharge, site.lmGateBatteryMode(api.BatteryNormal, true))
+ assert.Equal(t, api.BatteryNormal, site.lmGateBatteryMode(api.BatteryNormal, false))
+}
+
+// Outside automatic mode the gate passes upstream's decision unchanged.
+func TestLmGateInertWithoutAutomatic(t *testing.T) {
+ site := &Site{log: util.NewLogger("test")}
+ for _, m := range []api.BatteryMode{api.BatteryUnknown, api.BatteryNormal, api.BatteryHold, api.BatteryCharge} {
+ assert.Equal(t, m, site.lmGateBatteryMode(m, true))
+ }
+}
diff --git a/core/site_peakshaving.go b/core/site_peakshaving.go
index 4196abff0a..59cc9820dd 100644
--- a/core/site_peakshaving.go
+++ b/core/site_peakshaving.go
@@ -464,6 +464,10 @@ func (site *Site) updatePeakShaving(state siteState) {
case shaving:
value = peakSetpoint(state.gridPower, state.battery.Power, allowed)
+
+ // the optimizer withholds discharging: only a peak is covered, see site_optimizer_lm.go
+ case site.optimizerHolds():
+ value = peakSetpoint(state.gridPower, state.battery.Power, allowed)
}
// log the start of a peak, not every cycle of it
@@ -813,11 +817,16 @@ func (site *Site) updateBatteryModePeakAware(gridCharge, gridDischarge bool, rat
defer site.publishLmWallboxes()
defer site.checkLmFollowing()
- if gridCharge || !site.peakShavingActive() || site.GetBatteryModeExternal() != api.BatteryUnknown {
+ if gridCharge || site.optimizerCharges() || !site.peakShavingActive() || site.GetBatteryModeExternal() != api.BatteryUnknown {
site.updateBatteryMode(gridCharge, gridDischarge, rate)
return
}
+ // the optimizer's requests pass the gate only on the path above
+ if site.optimizerInControl() {
+ site.releaseGridCharge()
+ }
+
if site.GetBatteryMode() == api.BatteryNormal {
return
}
diff --git a/i18n/de.json b/i18n/de.json
index 190f510d48..431f8c6e1a 100644
--- a/i18n/de.json
+++ b/i18n/de.json
@@ -79,7 +79,8 @@
"enable": "Hausbatterie abhängig vom Ladestand aus dem Netz laden.",
"off": "Inaktiv",
"range": "Laden startet bei {start} oder darunter und stoppt bei {stop}.",
- "waiting": "Wartet auf den Startladestand"
+ "waiting": "Wartet auf den Startladestand",
+ "optimizer": "Mit Optimizer-Automatik plant der Optimizer das Laden: unter den Start-Ladestand fällt der Speicher nicht, bis zum Stopp-Ladestand lädt er zur günstigsten Zeit."
},
"gridChargeOnce": {
"title": "Einmalig laden",
@@ -605,7 +606,8 @@
"title": "Profile",
"unchanged": "nicht ändern",
"valueCount": "{count} Werte",
- "yes": "Ja"
+ "yes": "Ja",
+ "optimizer": "Mit Optimizer-Automatik wird die Entladesteuerung übersprungen, sie entscheidet der Optimizer."
},
"lmshedguard": {
"description": "Schaltet das Lastmanagement einen geschützten Ladepunkt ab, bleibt er für die Sperrzeit aus. Konnte er gar nicht erst starten, wird er nicht gesperrt.",
@@ -1569,7 +1571,9 @@
"throttled": "Gedrosselt · {allowed} von {requested}",
"waiting": "Wartet · braucht {requested}, {allowed} frei"
},
- "title": "Lastmanagement"
+ "title": "Lastmanagement",
+ "optimizer": "Optimizer",
+ "optimizerHint": "Der Optimizer entscheidet, das Lastmanagement begrenzt."
},
"log": {
"areaLabel": "Nach Bereich filtern",
diff --git a/i18n/en.json b/i18n/en.json
index a98b246e62..0b4dd0340f 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -79,7 +79,8 @@
"enable": "Charge the home battery from the grid based on its state of charge.",
"off": "Inactive",
"range": "Charging starts at {start} or below and stops at {stop}.",
- "waiting": "Waiting for the start state of charge"
+ "waiting": "Waiting for the start state of charge",
+ "optimizer": "With optimizer automatic mode the optimizer plans the charging: the battery does not fall below the start soc, and charges to the stop soc at the cheapest time."
},
"gridChargeOnce": {
"title": "Charge once",
@@ -605,7 +606,8 @@
"title": "Profiles",
"unchanged": "unchanged",
"valueCount": "Values: {count}",
- "yes": "Yes"
+ "yes": "Yes",
+ "optimizer": "With optimizer automatic mode discharge control is skipped, the optimizer decides it."
},
"lmshedguard": {
"description": "A protected loadpoint switched off by load management stays off for the lock time. One that could not start at all is not locked.",
@@ -1569,7 +1571,9 @@
"throttled": "Throttled · {allowed} of {requested}",
"waiting": "Waiting · needs {requested}, {allowed} free"
},
- "title": "Load management"
+ "title": "Load management",
+ "optimizer": "Optimizer",
+ "optimizerHint": "The optimizer decides, load management limits."
},
"log": {
"areaLabel": "Filter by area",
|