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
9 changes: 9 additions & 0 deletions assets/js/components/Battery/BatterySocGridChargeCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@
</div>
<!-- custom: one-time grid charging, see core/site_lm_once.go -->
<BatteryGridChargeOnce />
<p
v-if="optimizer"
class="small text-muted mt-3 mb-0"
data-testid="battery-soc-grid-charge-optimizer"
>
{{ $t("battery.socGridCharge.optimizer") }}
</p>
</Card>
</template>

Expand All @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions assets/js/components/Config/LmProfilesModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@
</div>

<p class="small text-muted mb-2">{{ $t("config.lmprofiles.hint") }}</p>
<p v-if="optimizerAutomatic" class="small text-muted mb-2">
{{ $t("config.lmprofiles.optimizer") }}
</p>

<section v-for="group in groups" :key="group.key" class="group">
<h6 class="mt-3 mb-2">{{ $t(`config.lmprofiles.group.${group.key}`) }}</h6>
Expand Down Expand Up @@ -205,6 +208,9 @@ export default {
profiles() {
return store.state?.lmProfiles || [];
},
optimizerAutomatic() {
return !!store.state?.optimizerAutomatic;
},
wallboxes() {
return store.state?.lmProfileWallboxes || [];
},
Expand Down
6 changes: 6 additions & 0 deletions assets/js/components/LoadManagement/OverviewModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@
class="evcc-gray lock"
:title="$t('lmoverview.protected')"
></shopicon-regular-lock>
<span
v-if="l.optimizer"
class="badge rounded-pill text-bg-light ms-1"
:title="$t('lmoverview.optimizerHint')"
>{{ $t("lmoverview.optimizer") }}</span
>
</td>
<td class="evcc-gray text-nowrap">
{{ $t("lmoverview.priority", { priority: l.priority }) }}
Expand Down
1 change: 1 addition & 0 deletions assets/js/views/Battery.vue
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
:active="socGridChargeEnabled && !!state.batteryGridChargeActive"
:start-soc="state.batterySocGridChargeStart ?? 20"
:stop-soc="state.batterySocGridChargeStop ?? 80"
:optimizer="!!state.optimizerAutomatic"
/>

<BatteryPeakShavingCard
Expand Down
21 changes: 17 additions & 4 deletions core/lm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions core/site_battery.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
35 changes: 29 additions & 6 deletions core/site_lm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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) {
Expand Down
7 changes: 6 additions & 1 deletion core/site_lm_profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions core/site_lm_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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()
Expand Down
103 changes: 103 additions & 0 deletions core/site_optimizer_gate.go
Original file line number Diff line number Diff line change
@@ -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
}
76 changes: 76 additions & 0 deletions core/site_optimizer_gate_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading
Loading