Skip to content
Closed
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 @@ -70,6 +77,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_gate.go
optimizer: Boolean,
},
data() {
return {
Expand Down
8 changes: 8 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 @@ -182,6 +185,7 @@
import GenericModal from "../Helper/GenericModal.vue";
import ProfileIcon, { PROFILE_ICONS } from "../Battery/ProfileIcon.vue";
import store from "@/store";
import { OPTIMIZER_AUTOMATIC } from "@/types/evcc";
import api from "@/api";

const MAX_PROFILES = 10;
Expand All @@ -205,6 +209,10 @@ export default {
profiles() {
return store.state?.lmProfiles || [];
},
optimizerAutomatic() {
const level = store.state?.optimizerAutomatic;
return !!level && level !== OPTIMIZER_AUTOMATIC.OFF;
},
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 @@ -89,6 +89,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
>
</span>
</td>
<td class="text-end text-nowrap">{{ fmtW(l.power) }}</td>
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 @@ -47,6 +47,7 @@
:active="socGridChargeEnabled && !!state.batteryGridChargeActive"
:start-soc="state.batterySocGridChargeStart ?? 20"
:stop-soc="state.batterySocGridChargeStop ?? 80"
:optimizer="optimizerAutomatic"
/>

<BatteryPeakShavingCard
Expand Down
1 change: 1 addition & 0 deletions core/fork_hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ var forkHooks = []struct {
{"core/site.go", "Site.collectMeters", []string{"setPeakMeterRead"}},
{"core/site.go", "Site.update", []string{"updateCustom", "batteryGridChargeRequested", "updateBatteryModePeakAware"}},
{"core/site.go", "Site.updateLoadpoints", []string{"meteredPower"}},
{"core/site_battery.go", "Site.updateBatteryMode", []string{"lmGateBatteryMode"}},
{"core/site_circuits.go", "Site.updateCircuits", []string{"circuitLoads"}},
{"core/site_load_predictor.go", "Site.homeProfile", []string{"homeProfileCustom"}},
{"core/site_optimizer.go", "Site.optimizerUpdateAsync", []string{"lmOptimizeLater", "lmOptimizeAgain"}},
Expand Down
19 changes: 16 additions & 3 deletions core/lm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -717,8 +717,20 @@ matches what the fork will do (`core/site_optimizer_lm.go`):
forced run arriving during a run follows right after it.

Without circuits, peak shaving, soc or one-time grid charging and snow on pv the
request is unchanged. The optimizer's automatic mode (evcc PR 32881) needs a gate at
execution, prepared separately on top of these inputs.
request is unchanged.

In the optimizer's automatic mode (evcc PR 32881) 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.

### Export forecast

Expand Down Expand Up @@ -977,6 +989,7 @@ Every change in an evcc file. Check these when merging a new evcc version.
| File | Change |
| --- | --- |
| `core/site.go` | `custom` field; `meteredPower` in `updateLoadpoints` (an assumed heater power stays in the home consumption); `restoreCustom` deferred at the start of `restoreSettings` (after the guard for tests, so it also runs when an evcc restore step returns an error); `updateCustom` after `updatePower`; `setPeakGridEnergy` in `updateGridMeter` (with `powerRead`: whether the grid power was read, evcc keeps the old value for a meter that is not available); `setPeakMeterRead` in `collectMeters` (the result of each power read: evcc leaves a failed read at 0 W without a mark); `batteryGridChargeRequested` and `updateBatteryModePeakAware` in place of evcc's calls |
| `core/site_battery.go` | `lmGateBatteryMode` in `updateBatteryMode` after evcc decided the mode (optimizer automatic mode) |
| `core/site_circuits.go` | `circuitLoads()` instead of `loadpointsAsCircuitDevices()` (adds the battery) |
| `core/site_load_predictor.go` | `homeProfileCustom` call in `homeProfile` |
| `core/site_optimizer.go` | `optimizerGridTariff` for the grid price, `applyLmOptimizerInputs` where the request is assembled, `lmOptimizerResult` after the solve, `lmForecastLowest` for the forecast, `lmOptimizeLater`/`lmOptimizeAgain` in `optimizerUpdateAsync` |
Expand Down Expand Up @@ -1024,7 +1037,7 @@ Every change in an evcc file. Check these when merging a new evcc version.
| EEG | `core/site_feedin_eeg.go`, `core/metrics/feedin_eeg_custom.go`, `assets/js/components/Energy/feedInEeg.ts` |
| log file | `util/logstash/file_custom.go`, `core/site_logfile.go`, `assets/js/components/LogFile/` |
| snow on pv | `core/site_snow.go`, `core/site_snow_auto.go`, `core/testdata/open-meteo-snow-tirol.json` (recorded answer, added with `git add -f` as `*.json` is ignored), `assets/js/components/Forecast/SnowCoverSwitch.vue` |
| optimizer | `core/site_optimizer_lm.go`, `core/site_optimizer_reserve_pass.go`, `core/site_optimizer_soc_pass.go`, `core/site_lm_export_forecast.go`, `tariff/homeassistant_forecast.go`, `templates/definition/tariff/homeassistant-forecast.yaml` |
| optimizer | `core/site_optimizer_lm.go`, `core/site_optimizer_gate.go`, `core/site_optimizer_reserve_pass.go`, `core/site_optimizer_soc_pass.go`, `core/site_lm_export_forecast.go`, `tariff/homeassistant_forecast.go`, `templates/definition/tariff/homeassistant-forecast.yaml` |
| api, keys | `core/site/api_custom.go`, `server/http_custom.go`, `core/keys/site_custom.go` |
| ui | `assets/js/types/evcc-lm.ts`, `assets/js/utils/lmPriorityOrder.ts`, `assets/js/utils/socSteps.ts`, `assets/js/components/LoadManagement/`, `assets/js/components/PeakShaving/`, the battery cards in `assets/js/components/Battery/` (`BatterySocGridChargeCard`, `BatteryGridChargeOnce`, `PowerIcon`, `BatteryPeakShavingCard`, `BatteryProfileCard`, `ProfileIcon`), the config components in `assets/js/components/Config/` (`PeakShavingConfig`, `LmConfigModals` and its dialogs, `FeedInEegSummary`, `PhaseSwitchFields`, `CircuitPeakHint`), `assets/js/utils/circuitFromPeak.ts` |
| build | `.github/workflows/custom-image.yml` |
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_gate.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 @@ -433,13 +433,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
site.lmm().Forget(site.lmBattery())
site.writeChargeValue(0)
site.recordBatteryLimit(0, 0)
if site.peakPausesGridCharge() {
site.releaseGridCharge()
return false
}

Expand All @@ -464,6 +478,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() {
site.lmm().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(site.lmm(), 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: site.lmm().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.window.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
}
Loading
Loading