Skip to content
Merged
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
3 changes: 2 additions & 1 deletion core/fork_hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ var forkHooks = []struct {
names []string
}{
{"core/site.go", "Site.restoreSettings", []string{"restoreCustom"}},
{"core/site.go", "Site.updateGridMeter", []string{"setPeakGridEnergy"}},
{"core/site.go", "Site.updateGridMeter", []string{"setPeakGridEnergy", "powerRead"}},
{"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_circuits.go", "Site.updateCircuits", []string{"circuitLoads"}},
Expand Down
24 changes: 22 additions & 2 deletions core/lm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,26 @@ charging pauses until they are back: the battery covers any demand by ordinary
self-consumption, where a stale setpoint of 0 would keep it blocked
(`TestPeakMetersLost`).

Three outages come without an error from the meter and count the same
(`core/site_peak_readings.go`). A grid meter that has no power (a Home Assistant
sensor `unavailable` or `unknown`): evcc keeps the last value, the hook
`setPeakGridEnergy` reports that the power was not read, and the cycle meters
nothing and keeps the setpoint instead of integrating on the old value
(`TestPeakGridUnreadIsAnOutage`). A grid power that is a number but stays
exactly the same for over 2 minutes while the battery power moves by more than
500 W (a template sensor or an mqtt topic that does not expire): a battery that
follows the demand shifts the grid power, so it is no reading
(`TestPeakGridFrozenIsAnOutage`, `TestPeakGridFollowingBatteryIsNoOutage`); the
outage then counts from the start of the standstill. A battery whose
power cannot be read (evcc leaves it at 0 W, for an unavailable Home Assistant
sensor without a log line; `setPeakMeterRead` in `collectMeters` takes the
result of each read): the setpoint adds the battery power back, so it would
jump between the value with and without it every cycle (3000, 0, 3107, 0 W) and
write to Home Assistant each time (`TestPeakBatteryUnreadIsAnOutage`,
`TestPeakMeterHookReportsRead`). Logged once per cause as a warning. Template sensors for the grid power and the energy counter have to
report `unavailable` or `unknown` when their source fails (`has_value()`), not
0: a 0 is a reading, evcc cannot tell it from a real one.

The window, the allowed power, the setpoint and the hysteresis are in
`core/peak`, which knows nothing about the site and has its own tests;
`core/site_peakshaving.go` feeds it and handles settings, Home Assistant and the
Expand Down Expand Up @@ -799,7 +819,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` in `restoreSettings`; `updateCustom` after `updatePower`; `setPeakGridEnergy` in `updateGridMeter`; `batteryGridChargeRequested` and `updateBatteryModePeakAware` in place of evcc's calls |
| `core/site.go` | `custom` field; `meteredPower` in `updateLoadpoints` (an assumed heater power stays in the home consumption); `restoreCustom` in `restoreSettings`; `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_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 @@ -839,7 +859,7 @@ Every change in an evcc file. Check these when merging a new evcc version.
| switch devices, stages | `charger/switchsocket_lm.go`, `charger/switchstages.go`, `core/loadpoint_stages.go`, `server/http_config_custom.go`, `templates/definition/charger/homeassistant-stages.yaml` |
| phase switching | `core/loadpoint_phasecurrents.go`, `core/loadpoint/config_custom.go`, `core/keys/loadpoint_custom.go` |
| grid charging | `core/site_lm_once.go` (soc-based in `core/site_lm.go`) |
| peak shaving | `core/peak/`, `core/site_peakshaving.go`, `core/site_peak_omni.go`, `core/site_peak_follow.go`, `core/site_peak_stats.go`, `core/site_peak_tariff.go` |
| peak shaving | `core/peak/`, `core/site_peakshaving.go`, `core/site_peak_readings.go`, `core/site_peak_omni.go`, `core/site_peak_follow.go`, `core/site_peak_stats.go`, `core/site_peak_tariff.go` |
| profiles | `core/lm/profile/`, `core/site_lm_profiles.go` |
| forecast | `core/site_load_weekday.go`, `core/site_load_manual.go`, `core/metrics/profile_custom.go` |
| battery identification | `core/site_battery_ident.go`, `core/metrics/slots_custom.go` |
Expand Down
5 changes: 4 additions & 1 deletion core/site.go
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,7 @@ func (site *Site) collectMeters(key string, meters []config.Device[api.Meter]) [
// power
var b bytes.Buffer
power, err := backoff.RetryWithData(meter.CurrentPower, modbus.Backoff())
site.setPeakMeterRead(key, i, err) // custom: a failed power read leaves 0 below, see core/site_peak_readings.go
if err == nil {
mm[i].Power = power
site.log.DEBUG.Printf("%s %d power: %.0fW", key, i+1, power)
Expand Down Expand Up @@ -1016,8 +1017,10 @@ func (site *Site) updateGridMeter() error {

meter := site.gridMeter.Instance()

var powerRead bool // custom: the power was read this cycle, see setPeakGridEnergy
if res, err := backoff.RetryWithData(meter.CurrentPower, modbus.Backoff()); err == nil {
mm.Power = res
powerRead = true // custom: see above

site.Lock()
site.gridPower = res
Expand Down Expand Up @@ -1074,7 +1077,7 @@ func (site *Site) updateGridMeter() error {
c.AddEnergy(mm.Energy, mm.ReturnEnergy, mm.Power)
}

site.setPeakGridEnergy(mm.Energy) // custom: peak shaving window, see core/site_peakshaving.go
site.setPeakGridEnergy(mm.Energy, powerRead) // custom: peak shaving window and meter outage, see core/site_peakshaving.go
site.publish(keys.Grid, mm)

return nil
Expand Down
149 changes: 149 additions & 0 deletions core/site_peak_readings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package core

// Custom extension: peak shaving and meter values evcc does not report as a failure.
//
// evcc reports a meter that fails with an error: the cycle then never reaches peak
// shaving, and peakCheckMeters hands the battery back after peak.MaxGap. Three kinds
// of outage come without an error and would keep the setpoint running on old values:
//
// - the grid meter has no power (Home Assistant sensor unavailable or unknown, a
// device answering "not available"): evcc keeps the last grid power, see
// setPeakGridEnergy;
// - the grid power is a real number that stands still, e.g. a template sensor or
// an mqtt topic that does not expire: the battery moves, the grid power does not;
// - the battery power cannot be read: evcc leaves it at 0 and the setpoint, which adds
// the battery power back, jumps between the value with and without it, see
// setPeakMeterRead.
//
// All three count as a meter outage: the cycle keeps the setpoint, and once peak.MaxGap
// has passed without good values peakCheckMeters writes the free value and grid
// charging pauses, as for a meter failing with an error.

import (
"fmt"
"math"
"time"

"github.com/evcc-io/evcc/core/peak"
)

// peakFrozenBatteryDelta is how far in W the battery power has to move while the
// grid power stands still for peak.MaxGap, to count the standstill as an outage:
// a battery that follows the demand shifts the grid power, so a grid power that
// stays exactly the same cannot be a reading
const peakFrozenBatteryDelta = 500.0

// Why the meter values of a cycle are not used
const (
outageGridUnread = "the grid meter delivered no power"
outageGridFrozen = "the grid power stands still while the battery power moves"
outageBatteryUnread = "the battery power could not be read"
)

// peakReadings is what peak shaving knows about the meter values of the cycle
type peakReadings struct {
gridUnread bool // the grid meter had no power this cycle, set by setPeakGridEnergy
batteryFailed map[int]bool // by battery meter: its power could not be read, set by setPeakMeterRead

gridBits uint64 // the grid power the standstill is counted for
gridSince time.Time // it has had this value since
batteryMin float64 // the range of the battery power since
batteryMax float64

lost string // why the values were not used in the last cycle, empty = they were
}

// outage returns why this cycle's meter values must not be used, empty if they
// can, and a detail for the log. A grid power that stands still moves updated back
// to its start, as the outage began with it. Called with mu held.
func (s *peakState) outage(now time.Time, gridPower, batteryPower float64) (cause, detail string) {
r := &s.read

if r.gridUnread {
return outageGridUnread, ""
}

for _, failed := range r.batteryFailed {
if failed {
return outageBatteryUnread, ""
}
}

if bits := math.Float64bits(gridPower); r.gridSince.IsZero() || bits != r.gridBits {
r.gridBits, r.gridSince = bits, now
r.batteryMin, r.batteryMax = batteryPower, batteryPower
return "", ""
}

r.batteryMin, r.batteryMax = min(r.batteryMin, batteryPower), max(r.batteryMax, batteryPower)

if moved := r.batteryMax - r.batteryMin; now.Sub(r.gridSince) > peak.MaxGap && moved > peakFrozenBatteryDelta {
if s.updated.IsZero() || r.gridSince.Before(s.updated) {
s.updated = r.gridSince
}

return outageGridFrozen, fmt.Sprintf(" (%.0fW since %s, the battery moved by %.0fW)", gridPower, r.gridSince.Format("15:04:05"), moved)
}

return "", ""
}

// peakReadingsOk checks the meter values of the cycle before peak shaving works
// with them. Good values renew the time of the last values, see metersStale;
// without them the cycle keeps the setpoint of the last good one and leaves the
// rest to peakCheckMeters. back reports that the values are back after an outage,
// also one shorter than peak.MaxGap, which metersLost does not see.
func (site *Site) peakReadingsOk(state siteState) (ok, back bool) {
s := site.peak()

s.mu.Lock()
defer s.mu.Unlock()

now := s.clock.Now()

// peak shaving off or not set up: nothing to keep and nothing to report, the
// window is metered as before
if !s.enabled || !s.configured() {
s.read.lost = ""
s.updated = now
return true, false
}

cause, detail := s.outage(now, state.gridPower, state.battery.Power)

previous := s.read.lost
s.read.lost = cause

if cause == "" {
s.updated = now
return true, previous != ""
}

if cause != previous {
site.log.WARN.Printf("peak shaving: %s%s, the setpoint stays as it is and the battery runs freely after %s without values", cause, detail, peak.MaxGap)
}

return false, false
}

// setPeakMeterRead takes the result of reading the power of a meter. evcc leaves
// the power of a meter it cannot read at 0 without a mark, and for a Home
// Assistant sensor that is unavailable not even a log line. Only the battery
// matters here: the setpoint adds its power back, so a battery that is
// discharging but reads 0 makes the setpoint jump from cycle to cycle.
func (site *Site) setPeakMeterRead(key string, i int, err error) {
if key != "battery" {
return
}

s := site.peak()

s.mu.Lock()
defer s.mu.Unlock()

if s.read.batteryFailed == nil {
s.read.batteryFailed = make(map[int]bool)
}

s.read.batteryFailed[i] = err != nil
}
Loading
Loading