diff --git a/core/fork_hooks_test.go b/core/fork_hooks_test.go index 88390f2d9a..bb6f55d983 100644 --- a/core/fork_hooks_test.go +++ b/core/fork_hooks_test.go @@ -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"}}, diff --git a/core/lm/README.md b/core/lm/README.md index b853062199..003e294bd6 100644 --- a/core/lm/README.md +++ b/core/lm/README.md @@ -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 @@ -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` | @@ -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` | diff --git a/core/site.go b/core/site.go index 5a9257b625..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) @@ -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 @@ -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 diff --git a/core/site_peak_readings.go b/core/site_peak_readings.go new file mode 100644 index 0000000000..c840e27aa4 --- /dev/null +++ b/core/site_peak_readings.go @@ -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 +} diff --git a/core/site_peak_readings_test.go b/core/site_peak_readings_test.go new file mode 100644 index 0000000000..0f79f6812e --- /dev/null +++ b/core/site_peak_readings_test.go @@ -0,0 +1,433 @@ +package core + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "testing" + "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 +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 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(gridRead, batteryRead 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(gridRead, batteryRead bool, grid, battery float64) bool { + clk.Add(30 * time.Second) + sc.site.setPeakGridEnergy(nil, gridRead) + sc.site.setPeakMeterRead("battery", 0, readError(batteryRead)) + 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, 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, 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, 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, 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, true, 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, 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, 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, true, 6000, battery) + } + assert.Empty(t, *writes) + + // a value that moves again ends it + step(true, 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, 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, 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 38a027f474..d650ba0c9e 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, 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 + + 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.",