From a6333680a7a94ff111e5c063f56e06179c41bedc Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Sat, 26 Sep 2026 19:32:01 +0200 Subject: [PATCH 1/4] Optimizer: automatic mode passes the fork's gate In automatic mode the optimizer sets the battery mode. The fork keeps its safety limits at execution, as the one hook after upstream decided the mode: - a charge request passes the same gate as the fork's own grid charging (running peak, circuit headroom, charge power setpoint) and becomes hold when refused; so the charge power entity no longer gets 0 W while the optimizer charges - hold gives way to normal while a peak has to be covered, and peak shaving then only discharges for the peak instead of the free value - with a missing or stale optimizer result the fork's grid charging applies as without the optimizer The soc-based grid charging does not switch the battery itself while the optimizer is in control, it is an optimizer input instead. The gate is extracted from the grid charge request unchanged. Without automatic mode nothing changes (contract test). Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 75f9f3bcba7c38864ff14118f85222a7f51aa8ff) --- core/site_battery.go | 1 + core/site_lm.go | 35 +++++++++-- core/site_optimizer_gate.go | 103 +++++++++++++++++++++++++++++++ core/site_optimizer_gate_test.go | 76 +++++++++++++++++++++++ core/site_peakshaving.go | 11 +++- 5 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 core/site_optimizer_gate.go create mode 100644 core/site_optimizer_gate_test.go diff --git a/core/site_battery.go b/core/site_battery.go index 73c957f469..5e0526d7b6 100644 --- a/core/site_battery.go +++ b/core/site_battery.go @@ -62,6 +62,7 @@ func (site *Site) fromTo(requested, m api.BatteryMode) bool { func (site *Site) updateBatteryMode(batteryGridChargeActive, batteryGridDischargeActive bool, rate api.Rate) { batteryMode := site.requiredBatteryMode(batteryGridChargeActive, batteryGridDischargeActive, rate) + batteryMode = site.lmGateBatteryMode(batteryMode, batteryGridChargeActive) // custom: see core/site_optimizer_lm.go // put battery into hold mode when charging is active and HEMS dimmed if dimmed := hems.Dimmed(site.hems); site.fromTo(batteryMode, api.BatteryCharge) && dimmed != nil && *dimmed { diff --git a/core/site_lm.go b/core/site_lm.go index 5a6f33d774..197d23f6e7 100644 --- a/core/site_lm.go +++ b/core/site_lm.go @@ -364,13 +364,27 @@ func (site *Site) batteryGridChargeRequested(rate api.Rate) bool { socActive := site.batterySocChargeActive() onceActive := site.batteryGridChargeOnceActive() + // the optimizer in automatic mode decides, its charge request passes the + // same gate, see site_optimizer_gate.go + if site.optimizerInControl() { + return false + } + + if !socActive && !onceActive && !site.batteryGridChargeActive(rate) { + site.releaseGridCharge() + return false + } + + return site.gridChargeGate() +} + +// gridChargeGate clears a grid charge request against a running peak and the +// circuit headroom and writes the charge power setpoint. A denied request +// releases what the battery had reserved. +func (site *Site) gridChargeGate() bool { // a running demand peak needs the battery for shaving, not charging - if !socActive && !onceActive && !site.batteryGridChargeActive(rate) || site.peakPausesGridCharge() { - // release what the battery had reserved on the circuit, lower priority - // loads would otherwise stay throttled until the reservation expires - lm.Forget(site.lmBattery()) - site.writeChargeValue(0) - site.recordBatteryLimit(0, 0) + if site.peakPausesGridCharge() { + site.releaseGridCharge() return false } @@ -395,6 +409,15 @@ func (site *Site) batteryGridChargeRequested(rate api.Rate) bool { return power > 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_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 } From 9739690afd6746e25a520ab1a75706bb57f5a4a9 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Sat, 26 Sep 2026 19:32:02 +0200 Subject: [PATCH 2/4] Profiles: discharge control left to the optimizer in automatic mode A profile's discharge control is refused while the optimizer decides it; it is skipped with a debug note instead of failing the whole profile. The stored upstream value applies again once automatic mode is off. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 7bc1bddc5fc12a28b0edc01b331bc14041b4a759) --- core/site_lm_profiles.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 { From 3c0f535e5655c71fda820a118d35e9fa10e0af18 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Sat, 26 Sep 2026 19:36:06 +0200 Subject: [PATCH 3/4] Optimizer: visible in the overview, battery page, profiles and settings - the load management overview marks loads the optimizer controls, and shows the battery as grid charging while an optimizer charge request passed the gate - battery page: with automatic mode the soc grid charging card says the optimizer plans the charging (start soc as floor, stop soc at the cheapest time) - profile editor: discharge control is skipped in automatic mode - advanced settings: "Netzlade-Ziel erreichen in" (grid charge window, 1-24 h, default 3) Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 67c2481bdbb1570cb7cac9b7106a2f5af0ead68f) --- .../js/components/Battery/BatterySocGridChargeCard.vue | 9 +++++++++ assets/js/components/Config/LmProfilesModal.vue | 6 ++++++ assets/js/components/LoadManagement/OverviewModal.vue | 6 ++++++ assets/js/views/Battery.vue | 1 + core/site_lm_status.go | 9 +++++++++ i18n/de.json | 10 +++++++--- i18n/en.json | 10 +++++++--- 7 files changed, 45 insertions(+), 6 deletions(-) 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" /> Date: Sat, 26 Sep 2026 20:58:51 +0200 Subject: [PATCH 4/4] docs: optimizer automatic mode gate Co-Authored-By: Claude Opus 5.5 --- core/lm/README.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/core/lm/README.md b/core/lm/README.md index 1fe38f5057..ac5d11c6e9 100644 --- a/core/lm/README.md +++ b/core/lm/README.md @@ -129,6 +129,7 @@ Keep these in mind when merging a new evcc version: | `api/globalconfig/types.go`, `tariff/tariffs.go`, `cmd/setup.go`, `server/http_config_device_handler.go` | `feedInEeg` tariff role: ref field, `Used`/`IsConfigured`, one `configureTariff` call, cleared on delete | | `assets/js/components/Config/TariffModal.vue` | `feedInEeg` offers the price templates | | `core/site_optimizer.go` | `applyLmOptimizerInputs` where the optimizer request is assembled | +| `core/site_battery.go` | `lmGateBatteryMode` after upstream decided the battery mode (optimizer automatic mode, evcc PR 32881) | | `server/http.go` | merges `customSiteRoutes`, one loop | | `assets/js/views/Battery.vue` | mounts the new cards, profile selection at the bottom | | `assets/js/views/Config.vue` | load management details section and its modals, OeMAG modal | @@ -142,7 +143,7 @@ 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_optimizer_gate.go`, `core/site_lm_once.go`, `core/site_lm_priority.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. @@ -265,9 +266,21 @@ settings as inputs, so the plan matches what the fork will actually do, see priorities 0-3/4-6/7-10 become `c_priority` 0/1/2 Without circuits, peak shaving, soc-based and one-time grid charging the -request is unchanged. The optimizer's automatic mode (evcc PR 32881, not -released yet) additionally needs a gate at execution; that is prepared -separately on top of these inputs. +request is unchanged. + +In the optimizer's automatic mode (evcc PR 32881, not released yet; until +then this builds on the branch `preview/optimizer-auto`) it also sets the +battery mode and gates the loadpoints. The fork keeps its safety limits at +execution, see `core/site_optimizer_gate.go`: a charge request passes the same +checks as the fork's own grid charging (running peak, circuit headroom, +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 the peak +instead of writing the free value. Below the reserve the battery stays in +normal mode as before. With a missing or stale optimizer result the fork's +grid charging applies as without the optimizer, which otherwise does not +switch the battery itself while the optimizer is in control. Profiles skip +discharge control, which the optimizer decides. The overview marks what the +optimizer controls. Without automatic mode the battery follows upstream. `TestLmOptimizerReplay` sends a recorded request with these inputs to a running optimizer (`OPTIMIZER_REPLAY`, `OPTIMIZER_URI`, optional