From cf394757a2d5968260079e8b5dd8c528a7bf3b3d Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Sun, 11 Oct 2026 22:33:07 +0200 Subject: [PATCH 1/2] Peak shaving: a grid meter without power or with a frozen value counts as an outage (B4) A grid meter that answers "not available" (Home Assistant sensor unavailable or unknown) is no failure for evcc, which keeps the last grid power. Peak shaving renewed its time of the last meter values anyway, so the meters-lost path never fired and the setpoint integrated on the old value cycle by cycle (1000, 2034, 3105 ... W) until the reserve was gone before the real peak. The hook setPeakGridEnergy reports now whether the power was read this cycle (powerRead in updateGridMeter). Without it the cycle meters nothing, keeps the setpoint and does not renew the time of the last values, so after 2 minutes the free value is written and grid charging pauses, as for a failing meter. A grid power that is a real number but stays exactly the same for over 2 minutes while the battery power moves by more than 500 W counts as the same outage, from the start of the standstill. Logged once per cause. README section 11 and the help text of the energy counter field: template sensors have to report unavailable on failure, not 0. Co-Authored-By: Claude Sonnet 5.5 --- core/fork_hooks_test.go | 2 +- core/lm/README.md | 19 ++- core/site.go | 4 +- core/site_peak_readings.go | 116 +++++++++++++++ core/site_peak_readings_test.go | 254 ++++++++++++++++++++++++++++++++ core/site_peakshaving.go | 31 +++- core/site_peakshaving_test.go | 2 +- i18n/de.json | 2 +- i18n/en.json | 2 +- 9 files changed, 418 insertions(+), 14 deletions(-) create mode 100644 core/site_peak_readings.go create mode 100644 core/site_peak_readings_test.go diff --git a/core/fork_hooks_test.go b/core/fork_hooks_test.go index 88390f2d9a..16d2546b73 100644 --- a/core/fork_hooks_test.go +++ b/core/fork_hooks_test.go @@ -21,7 +21,7 @@ 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.update", []string{"updateCustom", "batteryGridChargeRequested", "updateBatteryModePeakAware"}}, {"core/site.go", "Site.updateLoadpoints", []string{"meteredPower"}}, {"core/site_circuits.go", "Site.updateCircuits", []string{"circuitLoads"}}, diff --git a/core/lm/README.md b/core/lm/README.md index b853062199..b948926515 100644 --- a/core/lm/README.md +++ b/core/lm/README.md @@ -288,6 +288,21 @@ 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`). +Two 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. 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 @@ -799,7 +814,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); `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` | @@ -839,7 +854,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` | diff --git a/core/site.go b/core/site.go index 5a9257b625..1a96363922 100644 --- a/core/site.go +++ b/core/site.go @@ -1016,8 +1016,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 @@ -1074,7 +1076,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 diff --git a/core/site_peak_readings.go b/core/site_peak_readings.go new file mode 100644 index 0000000000..0f85b8b5e3 --- /dev/null +++ b/core/site_peak_readings.go @@ -0,0 +1,116 @@ +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. Two 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. +// +// Both 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" +) + +// 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 + + 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, "" + } + + 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 +} diff --git a/core/site_peak_readings_test.go b/core/site_peak_readings_test.go new file mode 100644 index 0000000000..0f1217e33e --- /dev/null +++ b/core/site_peak_readings_test.go @@ -0,0 +1,254 @@ +package core + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "testing" + "time" + + "github.com/benbjohnson/clock" + "github.com/evcc-io/evcc/core/peak" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// readingsFunc returns the declaration of a method of Site in one of evcc's files +func readingsFunc(t *testing.T, file, name string) *ast.FuncDecl { + t.Helper() + + f, err := parser.ParseFile(token.NewFileSet(), filepath.Join("..", filepath.FromSlash(file)), nil, 0) + require.NoError(t, err) + + for _, d := range f.Decls { + if fd, ok := d.(*ast.FuncDecl); ok && fd.Name.Name == name && fd.Recv != nil && fd.Body != nil { + return fd + } + } + + require.Failf(t, "function not found", "%s in %s", name, file) + return nil +} + +// TestPeakGridHookReportsRead pins the hook in updateGridMeter: peak shaving gets +// the grid power as read only if the power was read, which is set where evcc +// takes the value over. Without it evcc keeps the old value for a meter that is +// not available, and peak shaving would count it as a reading. +func TestPeakGridHookReportsRead(t *testing.T) { + fd := readingsFunc(t, "core/site.go", "updateGridMeter") + + var ( + hook *ast.CallExpr + declared bool + assigned []*ast.AssignStmt + success *ast.IfStmt // the branch taking the power over + ) + + ast.Inspect(fd.Body, func(n ast.Node) bool { + switch n := n.(type) { + case *ast.CallExpr: + if sel, ok := n.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "setPeakGridEnergy" { + hook = n + } + + case *ast.ValueSpec: + for _, id := range n.Names { + declared = declared || id.Name == "powerRead" + } + + case *ast.AssignStmt: + for _, l := range n.Lhs { + if id, ok := l.(*ast.Ident); ok && id.Name == "powerRead" { + assigned = append(assigned, n) + } + } + + case *ast.IfStmt: + // if res, err := ...CurrentPower...; err == nil { + if cond, ok := n.Cond.(*ast.BinaryExpr); ok && cond.Op == token.EQL && n.Init != nil { + if x, ok := cond.X.(*ast.Ident); ok && x.Name == "err" { + ast.Inspect(n.Init, func(m ast.Node) bool { + if id, ok := m.(*ast.Ident); ok && id.Name == "CurrentPower" { + success = n + } + return true + }) + } + } + } + + return true + }) + + require.NotNil(t, hook, "updateGridMeter no longer calls setPeakGridEnergy") + require.Len(t, hook.Args, 2, "setPeakGridEnergy takes the counter and whether the power was read") + + arg, ok := hook.Args[1].(*ast.Ident) + require.True(t, ok && arg.Name == "powerRead", "the hook is not given powerRead") + assert.True(t, declared, "powerRead is not declared") + + require.NotNil(t, success, "the branch reading the grid power moved") + require.Len(t, assigned, 1, "powerRead is set in one place only") + + // set to true, and inside the branch taking the power over + rhs, ok := assigned[0].Rhs[0].(*ast.Ident) + assert.True(t, ok && rhs.Name == "true", "powerRead is not set to true") + assert.True(t, assigned[0].Pos() > success.Body.Pos() && assigned[0].End() < success.Body.End(), + "powerRead is set outside the branch that takes the power over") + + // reported after the branch, not before it ran + assert.Greater(t, hook.Pos(), success.End(), "the hook runs before the power is read") +} + +// peakOutageSite sets up a scenario below the reserve on a mock clock, and +// returns a step of 30s as a cycle runs it: the hook of updateGridMeter, then +// the peak shaving cycle with the given values. Writes to the entity are +// recorded in writes. +func peakOutageSite(t *testing.T) (sc *scenario, s *peakState, step func(read bool, grid, battery float64) bool, writes *[]float64) { + t.Helper() + + sc = newScenario(t) + clk := clock.NewMock() + + writes = new([]float64) + + s = sc.site.peak() + s.clock = clk + s.set = func(v float64) error { *writes = append(*writes, v); return nil } + + step = func(read bool, grid, battery float64) bool { + clk.Add(30 * time.Second) + sc.site.setPeakGridEnergy(nil, read) + return sc.cycle(20, grid, battery) + } + + return sc, s, step, writes +} + +// TestPeakGridUnreadIsAnOutage: a grid meter that has no power (a Home Assistant +// sensor unavailable) is no failure for evcc, which keeps the last value. For +// peak shaving it is an outage like a failing meter: the setpoint stays, after +// peak.MaxGap the free value is written once and grid charging pauses. +func TestPeakGridUnreadIsAnOutage(t *testing.T) { + for _, tc := range []struct { + name string + grid float64 + setpoint float64 + charging bool // grid charging runs: a peak pauses it + }{ + // the old value is above the limit: the setpoint would grow cycle by cycle + {"old value above the limit", 6000, 1000, false}, + // below it: the old value would keep grid charging going for good + {"old value below the limit", 3000, 0, true}, + } { + t.Run(tc.name, func(t *testing.T) { + sc, s, step, writes := peakOutageSite(t) + + assert.Equal(t, tc.charging, step(true, tc.grid, 0)) + assert.Equal(t, []float64{tc.setpoint}, *writes) + + // unavailable up to peak.MaxGap: nothing is metered or written + *writes = nil + start := s.window + for range 4 { + assert.Equal(t, tc.charging, step(false, tc.grid, tc.setpoint)) + } + assert.Empty(t, *writes, "the setpoint stays") + assert.Equal(t, start, s.window, "the stale value is not metered") + assert.True(t, sc.site.peakShavingActive()) + + // beyond it: free value once, no grid charging + assert.False(t, step(false, tc.grid, tc.setpoint)) + assert.False(t, step(false, tc.grid, tc.setpoint)) + assert.Equal(t, []float64{peak.DefaultFreeValue}, *writes) + assert.False(t, sc.site.peakShavingActive()) + assert.True(t, s.metersLost) + + // back: the setpoint again + *writes = nil + step(true, 3000, 0) + assert.Equal(t, []float64{0}, *writes) + assert.False(t, s.metersLost) + assert.True(t, sc.site.peakShavingActive()) + }) + } +} + +// TestPeakGridUnreadIgnoredWhenOff: with peak shaving off an unavailable grid meter +// changes nothing and logs nothing, the window is metered as before +func TestPeakGridUnreadIgnoredWhenOff(t *testing.T) { + sc, s, step, writes := peakOutageSite(t) + s.enabled = false + + for range 8 { + step(false, 6000, 0) + } + + assert.Empty(t, *writes) + assert.Empty(t, s.read.lost) + assert.False(t, sc.site.peakShavingActive()) + assert.Positive(t, s.window.DrawnWs, "the window is metered") +} + +// TestPeakGridFrozenIsAnOutage: a grid power that is a number but stands still +// while the battery moves is no reading (a template sensor or an mqtt topic that +// does not expire). Without the check the setpoint grows by what the stale value +// exceeds the allowed power, every cycle. +func TestPeakGridFrozenIsAnOutage(t *testing.T) { + sc, s, step, writes := peakOutageSite(t) + + // the grid power stays at 6000W, the battery follows the setpoint + var battery float64 + for range 5 { + step(true, 6000, battery) + battery = (*writes)[len(*writes)-1] + } + assert.Greater(t, battery, 3000.0, "the setpoint grows while the value is trusted") + assert.True(t, sc.site.peakShavingActive()) + + // over peak.MaxGap it counts as an outage: free value at once, as it began + // with the standstill + *writes = nil + assert.False(t, step(true, 6000, battery)) + assert.Equal(t, []float64{peak.DefaultFreeValue}, *writes) + assert.False(t, sc.site.peakShavingActive()) + assert.True(t, s.metersLost) + + // and stays one while it stands still + *writes = nil + for range 3 { + step(true, 6000, battery) + } + assert.Empty(t, *writes) + + // a value that moves again ends it + step(true, 4000, 0) + assert.False(t, s.metersLost) + assert.Empty(t, s.read.lost) + assert.True(t, sc.site.peakShavingActive()) +} + +// TestPeakGridFollowingBatteryIsNoOutage: the grid power that follows the battery +// is a reading, also when the battery moves, and so is a value that stands still +// while the battery does +func TestPeakGridFollowingBatteryIsNoOutage(t *testing.T) { + sc, s, step, _ := peakOutageSite(t) + + // demand 6000W, the battery covers what exceeds the allowed power + var battery float64 + for range 12 { + step(true, 6000-battery, battery) + battery = val(sc.peak) + } + assert.True(t, sc.site.peakShavingActive()) + assert.False(t, s.metersLost) + + // a grid power at a standstill with a battery that stands still as well + sc, s, step, _ = peakOutageSite(t) + for range 12 { + step(true, 4000, 800) + } + assert.True(t, sc.site.peakShavingActive()) + assert.False(t, s.metersLost) +} diff --git a/core/site_peakshaving.go b/core/site_peakshaving.go index 38a027f474..0a2ed31d64 100644 --- a/core/site_peakshaving.go +++ b/core/site_peakshaving.go @@ -97,8 +97,9 @@ type peakState struct { demand float64 // grid demand without the battery in W, from the last cycle chargePause time.Time // grid charging gives way to peak shaving until then - updated time.Time // last cycle with meter values, see peakCheckMeters - metersLost bool // the meters failed for longer than peak.MaxGap + updated time.Time // last cycle with meter values, see peakCheckMeters + metersLost bool // the meters failed for longer than peak.MaxGap + read peakReadings // outages evcc does not report, see site_peak_readings.go set func(float64) error // resolved from config @@ -535,16 +536,29 @@ func (site *Site) updatePeakShaving(state siteState) { s := site.peak() defer site.savePeakMonths() + ok, back := site.peakReadingsOk(state) + s.mu.Lock() - s.updated = s.clock.Now() - recovered := s.metersLost - s.metersLost = false + recovered := back || (ok && s.metersLost) + if ok { + s.metersLost = false + } s.mu.Unlock() if recovered { site.log.INFO.Println("peak shaving: meters back") } + if !ok { + // the meter values cannot be trusted (no grid power, a grid power that stands + // still): like for a failing meter nothing is metered and the setpoint stays, + // see peakCheckMeters + site.updatePeakFollow() + site.applyCircuitLimits() // load management switch and follow circuit, see site_lm_switch.go + + return + } + site.updatePeakWindow(state.gridPower, state.battery.Power) site.updatePeakFollow() site.applyCircuitLimits() // load management switch and follow circuit, see site_lm_switch.go @@ -841,14 +855,17 @@ func (site *Site) peakEnergy() (float64, string) { } // setPeakGridEnergy takes the grid meter's import counter of this cycle, nil if -// the meter has none -func (site *Site) setPeakGridEnergy(kWh *float64) { +// the meter has none, and whether the grid power was read: evcc keeps the last +// value when it is not available, which peak shaving must not take for a reading, +// see peakReadingsOk +func (site *Site) setPeakGridEnergy(kWh *float64, powerRead bool) { s := site.peak() s.mu.Lock() defer s.mu.Unlock() s.gridEnergy = kWh + s.read.gridUnread = !powerRead } // updatePeakWindow meters the running 15 minute window with the energy source of diff --git a/core/site_peakshaving_test.go b/core/site_peakshaving_test.go index 68115cea1a..aa7b3d350a 100644 --- a/core/site_peakshaving_test.go +++ b/core/site_peakshaving_test.go @@ -451,7 +451,7 @@ func peakWindowSite(t *testing.T, minute int) (*Site, *clock.Mock) { // power and the grid meter's counter, nil = meter without counter func energyStep(site *Site, clk *clock.Mock, grid float64, meter *float64) { clk.Add(30 * time.Second) - site.setPeakGridEnergy(meter) + site.setPeakGridEnergy(meter, true) site.updatePeakWindow(grid, 0) } diff --git a/i18n/de.json b/i18n/de.json index bbab1626c5..41d0ea5245 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1074,7 +1074,7 @@ "circuitNoneConfigured": "Es sind keine Stromkreise konfiguriert.", "circuitTitle": "Batterie-Stromkreis", "description": "evcc gibt Home Assistant vor, was zum Kappen nötig ist. Wie, hängt vom Batterietyp ab.", - "energyEntityHelp": "Zählerstand Netzbezug in kWh oder Wh. Nur nötig, wenn der Netzzähler in evcc keinen Energiezähler liefert.", + "energyEntityHelp": "Zählerstand Netzbezug in kWh oder Wh. Nur nötig, wenn der Netzzähler in evcc keinen Energiezähler liefert. Template-Sensoren müssen bei Ausfall unavailable liefern, nicht 0.", "energyEntityLabel": "Energiezähler Netzbezug", "entityHelp": "Erhält den Sollwert in W, passend zu Min, Max und Schritt der Entität.", "entityInvalid": "Entität konnte nicht gesetzt werden.", diff --git a/i18n/en.json b/i18n/en.json index 9e205bbc1b..e0173f6886 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1074,7 +1074,7 @@ "circuitNoneConfigured": "No circuits are configured.", "circuitTitle": "Battery circuit", "description": "evcc tells Home Assistant what peak shaving needs. How depends on the battery type.", - "energyEntityHelp": "Grid import counter in kWh or Wh. Only needed when the grid meter in evcc has no energy counter.", + "energyEntityHelp": "Grid import counter in kWh or Wh. Only needed when the grid meter in evcc has no energy counter. Template sensors must report unavailable on failure, not 0.", "energyEntityLabel": "Grid import energy counter", "entityHelp": "Receives the setpoint in W, fitted to the entity's min, max and step.", "entityInvalid": "Could not set the entity.", From 89a0941af56c3d531ac3e29ce7ea417b197275f5 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Sun, 11 Oct 2026 22:35:42 +0200 Subject: [PATCH 2/2] Peak shaving: a battery power that cannot be read counts as an outage (B10) When reading the battery power fails, evcc leaves it at 0 W without a mark, for an unavailable Home Assistant sensor without even a log line. The setpoint adds the battery power back, so with a battery that keeps following it the setpoint jumped between the value with and without the battery (3000, 0, 3107, 0 W), wrote to Home Assistant every cycle and ended the window above the limit. The meters-lost path only sees the grid meter. New hook setPeakMeterRead in collectMeters takes the result of each power read (the failure is only known there); a battery that cannot be read counts as a meter outage like the grid cases of the change before: the cycle keeps the last setpoint, after 2 minutes the free value is written and grid charging pauses. A read that fails now and then, with values in between, hands nothing back. Logged once per cause. Co-Authored-By: Claude Sonnet 5.5 --- core/fork_hooks_test.go | 1 + core/lm/README.md | 13 +- core/site.go | 1 + core/site_peak_readings.go | 45 ++++++- core/site_peak_readings_test.go | 213 +++++++++++++++++++++++++++++--- core/site_peakshaving.go | 4 +- 6 files changed, 248 insertions(+), 29 deletions(-) diff --git a/core/fork_hooks_test.go b/core/fork_hooks_test.go index 16d2546b73..bb6f55d983 100644 --- a/core/fork_hooks_test.go +++ b/core/fork_hooks_test.go @@ -22,6 +22,7 @@ var forkHooks = []struct { }{ {"core/site.go", "Site.restoreSettings", []string{"restoreCustom"}}, {"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"}}, diff --git a/core/lm/README.md b/core/lm/README.md index b948926515..003e294bd6 100644 --- a/core/lm/README.md +++ b/core/lm/README.md @@ -288,7 +288,7 @@ 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`). -Two outages come without an error from the meter and count the same +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 @@ -298,8 +298,13 @@ 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. Logged once per cause as a -warning. Template sensors for the grid power and the energy counter have to +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. @@ -814,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` (with `powerRead`: whether the grid power was read, evcc keeps the old value for a meter that is not available); `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` | diff --git a/core/site.go b/core/site.go index 1a96363922..d2520f5f73 100644 --- a/core/site.go +++ b/core/site.go @@ -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) diff --git a/core/site_peak_readings.go b/core/site_peak_readings.go index 0f85b8b5e3..c840e27aa4 100644 --- a/core/site_peak_readings.go +++ b/core/site_peak_readings.go @@ -3,16 +3,19 @@ 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. Two kinds +// 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. +// 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. // -// Both count as a meter outage: the cycle keeps the setpoint, and once peak.MaxGap +// 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. @@ -32,13 +35,15 @@ 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" + 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 + 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 @@ -58,6 +63,12 @@ func (s *peakState) outage(now time.Time, gridPower, batteryPower float64) (caus 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 @@ -114,3 +125,25 @@ func (site *Site) peakReadingsOk(state siteState) (ok, back bool) { 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 +} diff --git a/core/site_peak_readings_test.go b/core/site_peak_readings_test.go index 0f1217e33e..0f79f6812e 100644 --- a/core/site_peak_readings_test.go +++ b/core/site_peak_readings_test.go @@ -9,9 +9,13 @@ import ( "time" "github.com/benbjohnson/clock" + "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/peak" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" ) // readingsFunc returns the declaration of a method of Site in one of evcc's files @@ -102,10 +106,10 @@ func TestPeakGridHookReportsRead(t *testing.T) { } // peakOutageSite sets up a scenario below the reserve on a mock clock, and -// returns a step of 30s as a cycle runs it: the hook of updateGridMeter, then -// the peak shaving cycle with the given values. Writes to the entity are +// returns a step of 30s as a cycle runs it: the hooks of updateGridMeter and +// collectMeters, then the peak shaving cycle with the given values. Writes to the entity are // recorded in writes. -func peakOutageSite(t *testing.T) (sc *scenario, s *peakState, step func(read bool, grid, battery float64) bool, writes *[]float64) { +func peakOutageSite(t *testing.T) (sc *scenario, s *peakState, step func(gridRead, batteryRead bool, grid, battery float64) bool, writes *[]float64) { t.Helper() sc = newScenario(t) @@ -117,9 +121,10 @@ func peakOutageSite(t *testing.T) (sc *scenario, s *peakState, step func(read bo s.clock = clk s.set = func(v float64) error { *writes = append(*writes, v); return nil } - step = func(read bool, grid, battery float64) bool { + step = func(gridRead, batteryRead bool, grid, battery float64) bool { clk.Add(30 * time.Second) - sc.site.setPeakGridEnergy(nil, read) + sc.site.setPeakGridEnergy(nil, gridRead) + sc.site.setPeakMeterRead("battery", 0, readError(batteryRead)) return sc.cycle(20, grid, battery) } @@ -145,29 +150,29 @@ func TestPeakGridUnreadIsAnOutage(t *testing.T) { t.Run(tc.name, func(t *testing.T) { sc, s, step, writes := peakOutageSite(t) - assert.Equal(t, tc.charging, step(true, tc.grid, 0)) + assert.Equal(t, tc.charging, step(true, true, tc.grid, 0)) assert.Equal(t, []float64{tc.setpoint}, *writes) // unavailable up to peak.MaxGap: nothing is metered or written *writes = nil start := s.window for range 4 { - assert.Equal(t, tc.charging, step(false, tc.grid, tc.setpoint)) + assert.Equal(t, tc.charging, step(false, true, tc.grid, tc.setpoint)) } assert.Empty(t, *writes, "the setpoint stays") assert.Equal(t, start, s.window, "the stale value is not metered") assert.True(t, sc.site.peakShavingActive()) // beyond it: free value once, no grid charging - assert.False(t, step(false, tc.grid, tc.setpoint)) - assert.False(t, step(false, tc.grid, tc.setpoint)) + assert.False(t, step(false, true, tc.grid, tc.setpoint)) + assert.False(t, step(false, true, tc.grid, tc.setpoint)) assert.Equal(t, []float64{peak.DefaultFreeValue}, *writes) assert.False(t, sc.site.peakShavingActive()) assert.True(t, s.metersLost) // back: the setpoint again *writes = nil - step(true, 3000, 0) + step(true, true, 3000, 0) assert.Equal(t, []float64{0}, *writes) assert.False(t, s.metersLost) assert.True(t, sc.site.peakShavingActive()) @@ -182,7 +187,7 @@ func TestPeakGridUnreadIgnoredWhenOff(t *testing.T) { s.enabled = false for range 8 { - step(false, 6000, 0) + step(false, true, 6000, 0) } assert.Empty(t, *writes) @@ -201,7 +206,7 @@ func TestPeakGridFrozenIsAnOutage(t *testing.T) { // the grid power stays at 6000W, the battery follows the setpoint var battery float64 for range 5 { - step(true, 6000, battery) + step(true, true, 6000, battery) battery = (*writes)[len(*writes)-1] } assert.Greater(t, battery, 3000.0, "the setpoint grows while the value is trusted") @@ -210,7 +215,7 @@ func TestPeakGridFrozenIsAnOutage(t *testing.T) { // over peak.MaxGap it counts as an outage: free value at once, as it began // with the standstill *writes = nil - assert.False(t, step(true, 6000, battery)) + assert.False(t, step(true, true, 6000, battery)) assert.Equal(t, []float64{peak.DefaultFreeValue}, *writes) assert.False(t, sc.site.peakShavingActive()) assert.True(t, s.metersLost) @@ -218,12 +223,12 @@ func TestPeakGridFrozenIsAnOutage(t *testing.T) { // and stays one while it stands still *writes = nil for range 3 { - step(true, 6000, battery) + step(true, true, 6000, battery) } assert.Empty(t, *writes) // a value that moves again ends it - step(true, 4000, 0) + step(true, true, 4000, 0) assert.False(t, s.metersLost) assert.Empty(t, s.read.lost) assert.True(t, sc.site.peakShavingActive()) @@ -238,7 +243,7 @@ func TestPeakGridFollowingBatteryIsNoOutage(t *testing.T) { // demand 6000W, the battery covers what exceeds the allowed power var battery float64 for range 12 { - step(true, 6000-battery, battery) + step(true, true, 6000-battery, battery) battery = val(sc.peak) } assert.True(t, sc.site.peakShavingActive()) @@ -247,8 +252,182 @@ func TestPeakGridFollowingBatteryIsNoOutage(t *testing.T) { // a grid power at a standstill with a battery that stands still as well sc, s, step, _ = peakOutageSite(t) for range 12 { - step(true, 4000, 800) + step(true, true, 4000, 800) } assert.True(t, sc.site.peakShavingActive()) assert.False(t, s.metersLost) } + +// readError returns the result of a power read that worked or did not +func readError(ok bool) error { + if ok { + return nil + } + return api.ErrNotAvailable +} + +// TestPeakMeterHookReportsRead pins the hook in collectMeters: it is given the +// result of the power read right where evcc has it, before evcc turns a failed +// read into 0 W without a mark, and for every meter. +func TestPeakMeterHookReportsRead(t *testing.T) { + fd := readingsFunc(t, "core/site.go", "collectMeters") + + // the read is in the function run for each meter + var body *ast.BlockStmt + ast.Inspect(fd.Body, func(n ast.Node) bool { + if fl, ok := n.(*ast.FuncLit); ok && body == nil { + body = fl.Body + } + return body == nil + }) + require.NotNil(t, body, "collectMeters no longer reads each meter in a function of its own") + + callee := func(s ast.Stmt) string { + var e ast.Expr + switch s := s.(type) { + case *ast.ExprStmt: + e = s.X + case *ast.AssignStmt: + e = s.Rhs[0] + default: + return "" + } + + if c, ok := e.(*ast.CallExpr); ok { + switch f := c.Fun.(type) { + case *ast.SelectorExpr: + return f.Sel.Name + case *ast.IndexListExpr: + if sel, ok := f.X.(*ast.SelectorExpr); ok { + return sel.Sel.Name + } + } + } + return "" + } + + for i, s := range body.List { + if callee(s) != "RetryWithData" { + continue + } + + // power, err := backoff.RetryWithData(meter.CurrentPower, ...) + read := s.(*ast.AssignStmt) + require.Len(t, read.Lhs, 2) + errName := read.Lhs[1].(*ast.Ident).Name + + require.Less(t, i+1, len(body.List)) + hook, ok := body.List[i+1].(*ast.ExprStmt) + require.True(t, ok && callee(hook) == "setPeakMeterRead", "setPeakMeterRead does not directly follow the power read") + + args := hook.X.(*ast.CallExpr).Args + require.Len(t, args, 3) + for j, want := range []string{"key", "i", errName} { + id, ok := args[j].(*ast.Ident) + assert.True(t, ok && id.Name == want, "argument %d of setPeakMeterRead is not %s", j+1, want) + } + + return + } + + require.Fail(t, "the power read in collectMeters moved") +} + +// TestPeakMeterHookRecordsBatteryReads: collectMeters tells peak shaving which +// battery meters could not be read, and only those +func TestPeakMeterHookRecordsBatteryReads(t *testing.T) { + ctrl := gomock.NewController(t) + + meter := func(err error) config.Device[api.Meter] { + m := api.NewMockMeter(ctrl) + m.EXPECT().CurrentPower().Return(0.0, err).AnyTimes() + return config.NewStaticDevice(config.Named{Name: "bat"}, api.Meter(m)) + } + + good, bad := meter(nil), meter(api.ErrNotAvailable) + site := &Site{log: util.NewLogger("test")} + s := site.peak() + + site.collectMeters("battery", []config.Device[api.Meter]{good, bad}) + assert.Equal(t, map[int]bool{0: false, 1: true}, s.read.batteryFailed) + + // the next cycle reads both + site.collectMeters("battery", []config.Device[api.Meter]{good, good}) + assert.Equal(t, map[int]bool{0: false, 1: false}, s.read.batteryFailed) + + // other meters are not peak shaving's business + s.read.batteryFailed = nil + site.collectMeters("pv", []config.Device[api.Meter]{bad}) + assert.Nil(t, s.read.batteryFailed) +} + +// TestPeakBatteryUnreadIsAnOutage: a battery power that cannot be read stays 0 in +// evcc, and the setpoint, which adds the battery power back, jumps between the +// value with and without it (3000, 0, 3107, 0 ...). It counts as an outage: the +// setpoint stays, after peak.MaxGap the free value is written and grid charging +// pauses. +func TestPeakBatteryUnreadIsAnOutage(t *testing.T) { + sc, s, step, writes := peakOutageSite(t) + + // a peak 3 kW above the limit, the battery covers it + step(true, true, 8000, 0) + assert.Equal(t, []float64{3000}, *writes) + + // the battery follows, its power reads 0: the grid shows 5000W + *writes = nil + for range 4 { + step(true, false, 5000, 0) + } + assert.Empty(t, *writes, "the setpoint stays") + assert.True(t, sc.site.peakShavingActive()) + + // beyond peak.MaxGap: free value once + step(true, false, 5000, 0) + step(true, false, 5000, 0) + assert.Equal(t, []float64{peak.DefaultFreeValue}, *writes) + assert.False(t, sc.site.peakShavingActive()) + assert.True(t, s.metersLost) + assert.True(t, sc.site.peakPausesGridCharge()) + + // readable again: the setpoint from the real battery power + *writes = nil + step(true, true, 5000, 3000) + require.Len(t, *writes, 1) + assert.InDelta(t, 3000, (*writes)[0], 150) + assert.False(t, s.metersLost) + assert.True(t, sc.site.peakShavingActive()) +} + +// TestPeakBatteryUnreadOnceKeepsRunning: a read that fails now and then, with +// values in between, never reaches peak.MaxGap and hands nothing back +func TestPeakBatteryUnreadOnceKeepsRunning(t *testing.T) { + sc, s, step, writes := peakOutageSite(t) + + step(true, true, 8000, 0) + for range 6 { + step(true, false, 5000, 0) + step(true, true, 5000, 3000) + } + + assert.True(t, sc.site.peakShavingActive()) + assert.False(t, s.metersLost) + for _, v := range *writes { + assert.Less(t, v, peak.DefaultFreeValue) + } +} + +// TestPeakBatteryUnreadWithTwoBatteries: one battery that cannot be read is +// enough, the sum of the battery power is wrong then +func TestPeakBatteryUnreadWithTwoBatteries(t *testing.T) { + site := &Site{log: util.NewLogger("test")} + s := site.peak() + + site.setPeakMeterRead("battery", 0, nil) + site.setPeakMeterRead("battery", 1, api.ErrNotAvailable) + cause, _ := s.outage(time.Now(), 5000, 0) + assert.Equal(t, outageBatteryUnread, cause) + + site.setPeakMeterRead("battery", 1, nil) + cause, _ = s.outage(time.Now(), 5000, 0) + assert.Empty(t, cause) +} diff --git a/core/site_peakshaving.go b/core/site_peakshaving.go index 0a2ed31d64..d650ba0c9e 100644 --- a/core/site_peakshaving.go +++ b/core/site_peakshaving.go @@ -551,8 +551,8 @@ func (site *Site) updatePeakShaving(state siteState) { if !ok { // the meter values cannot be trusted (no grid power, a grid power that stands - // still): like for a failing meter nothing is metered and the setpoint stays, - // see peakCheckMeters + // still, a battery power that cannot be read): like for a failing meter nothing + // is metered and the setpoint stays, see peakCheckMeters site.updatePeakFollow() site.applyCircuitLimits() // load management switch and follow circuit, see site_lm_switch.go