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("battery.socGridCharge.optimizer") }} +

@@ -68,6 +75,8 @@ export default defineComponent({ active: Boolean, startSoc: { type: Number, default: 20 }, stopSoc: { type: Number, default: 80 }, + // the optimizer in automatic mode plans the charging, see core/site_optimizer_lm.go + optimizer: Boolean, }, data() { return { diff --git a/assets/js/components/Config/LmProfilesModal.vue b/assets/js/components/Config/LmProfilesModal.vue index fcb70a1f07..3f77250aff 100644 --- a/assets/js/components/Config/LmProfilesModal.vue +++ b/assets/js/components/Config/LmProfilesModal.vue @@ -85,6 +85,9 @@

{{ $t("config.lmprofiles.hint") }}

+

+ {{ $t("config.lmprofiles.optimizer") }} +

{{ $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",