From bcebf03cdb6a61c130c35848e1b6bc5f7f1d7b0c Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 21 Apr 2020 21:23:24 +0200 Subject: [PATCH] Improve charge current handling Prevent disabling charger in same update call as reaching minimum current Speed up reaching maximum current Fix loadpoint crashing if pv meter not configured --- core/loadpoint.go | 81 +++++------ core/loadpoint_test.go | 314 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 350 insertions(+), 45 deletions(-) create mode 100644 core/loadpoint_test.go diff --git a/core/loadpoint.go b/core/loadpoint.go index 9978e0307..e3ce15574 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -336,12 +336,11 @@ func (lp *LoadPoint) setTargetCurrent(targetCurrentIn int64) error { return nil } -// rampUpDown moves stepwise towards target current. If target current is reached -// during this process, true is returned otherwise false. -func (lp *LoadPoint) rampUpDown(target int64) (bool, error) { +// rampUpDown moves stepwise towards target current +func (lp *LoadPoint) rampUpDown(target int64) error { current := lp.targetCurrent if current == target { - return true, nil + return nil } var step int64 @@ -353,35 +352,23 @@ func (lp *LoadPoint) rampUpDown(target int64) (bool, error) { step = clamp(step, lp.MinCurrent, lp.MaxCurrent) - if err := lp.setTargetCurrent(step); err != nil { - return false, err - } - - // end of ramp reached? - if step == target { - return true, nil - } - - return false, nil + return lp.setTargetCurrent(step) } -// rampOff ramps down charging current to minimum and then turns off +// rampOff disables charger after setting minCurrent. If already disables, this is a nop. func (lp *LoadPoint) rampOff() error { if lp.enabled { - finished, err := lp.rampUpDown(lp.MinCurrent) - if err != nil { - return err - } - - if finished { + if lp.targetCurrent == lp.MinCurrent { return lp.chargerEnable(false) } + + return lp.setTargetCurrent(lp.MinCurrent) } return nil } -// rampUp ramps up charging current to maximum and then turns off +// rampOn enables charger after setting minCurrent. If already enabled, target will be set. func (lp *LoadPoint) rampOn(target int64) error { if !lp.enabled { if err := lp.setTargetCurrent(lp.MinCurrent); err != nil { @@ -391,34 +378,37 @@ func (lp *LoadPoint) rampOn(target int64) error { return lp.chargerEnable(true) } - _, err := lp.rampUpDown(target) - return err + return lp.setTargetCurrent(target) } // updateModePV sets "minpv" or "pv" load modes func (lp *LoadPoint) updateModePV(mode api.ChargeMode) error { // grid meter will always be available, if as wrapped pv meter - targetChargePower := lp.chargePower - lp.gridPower - lp.ResidualPower - log.DEBUG.Printf("%s target power: %.0fW = %.0fW charge - %.0fW grid - %.0fW residual", lp.Name, targetChargePower, lp.chargePower, lp.gridPower, lp.ResidualPower) + targetPower := lp.chargePower - lp.gridPower - lp.ResidualPower + log.DEBUG.Printf("%s target power: %.0fW = %.0fW charge - %.0fW grid - %.0fW residual", lp.Name, targetPower, lp.chargePower, lp.gridPower, lp.ResidualPower) // get max charge current - targetChargeCurrent := clamp(powerToCurrent(targetChargePower, lp.Voltage, lp.Phases), 0, lp.MaxCurrent) - if targetChargeCurrent < lp.MinCurrent { + targetCurrent := clamp(powerToCurrent(targetPower, lp.Voltage, lp.Phases), 0, lp.MaxCurrent) + if targetCurrent < lp.MinCurrent { switch mode { case api.ModeMinPV: - targetChargeCurrent = lp.MinCurrent + targetCurrent = lp.MinCurrent case api.ModePV: - targetChargeCurrent = 0 + targetCurrent = 0 } } - log.DEBUG.Printf("%s target charge current: %dA", lp.Name, targetChargeCurrent) + log.DEBUG.Printf("%s target charge current: %dA", lp.Name, targetCurrent) - if targetChargeCurrent == 0 { + if targetCurrent == 0 { return lp.rampOff() } - return lp.rampOn(targetChargeCurrent) + if !lp.enabled { + return lp.rampOn(targetCurrent) + } + + return lp.rampUpDown(targetCurrent) } // updateMeter updates and publishes single meter @@ -438,22 +428,23 @@ func (lp *LoadPoint) updateMeter(name string, meter api.Meter, power *float64) e // updateMeter updates and publishes single meter func (lp *LoadPoint) updateMeters() (err error) { - retry := func(s string, m api.Meter, f *float64) { - e := retry.Do(func() error { - return lp.updateMeter(s, m, f) - }, retry.Attempts(3)) - if e != nil { - err = errors.Wrapf(e, "updating %s meter", s) - log.ERROR.Printf("%s %v", lp.Name, err) + retryMeter := func(s string, m api.Meter, f *float64) { + if m != nil { + e := retry.Do(func() error { + return lp.updateMeter(s, m, f) + }, retry.Attempts(3)) + + if e != nil { + err = errors.Wrapf(e, "updating %s meter", s) + log.ERROR.Printf("%s %v", lp.Name, err) + } } } // read PV meter before charge meter - retry("grid", lp.GridMeter, &lp.gridPower) - if lp.PVMeter != nil { - retry("pv", lp.PVMeter, &lp.pvPower) - } - retry("charge", lp.ChargeMeter, &lp.chargePower) + retryMeter("grid", lp.GridMeter, &lp.gridPower) + retryMeter("pv", lp.PVMeter, &lp.pvPower) + retryMeter("charge", lp.ChargeMeter, &lp.chargePower) return err } diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go new file mode 100644 index 000000000..1ba04982c --- /dev/null +++ b/core/loadpoint_test.go @@ -0,0 +1,314 @@ +package core + +import ( + "reflect" + "testing" + "time" + + "github.com/andig/evcc/api" + "github.com/andig/evcc/mock" + "github.com/andig/evcc/provider" + "github.com/andig/evcc/push" + "github.com/benbjohnson/clock" + "github.com/golang/mock/gomock" +) + +const ( + lpMinCurrent int64 = 6 + lpMaxCurrent int64 = 16 +) + +func TestNew(t *testing.T) { + lp := NewLoadPoint() + + if lp.Mode != api.ModeOff { + t.Errorf("Mode %v", lp.Mode) + } + if lp.Phases != 1 { + t.Errorf("Phases %v", lp.Phases) + } + if lp.MinCurrent != lpMinCurrent { + t.Errorf("MinCurrent %v", lp.MinCurrent) + } + if lp.MaxCurrent != lpMaxCurrent { + t.Errorf("MaxCurrent %v", lp.MaxCurrent) + } + if lp.Steepness != 10 { + t.Errorf("Steepness %v", lp.Steepness) + } + if lp.status != api.StatusNone { + t.Errorf("status %v", lp.status) + } + if lp.enabled { + t.Errorf("enabled %v", lp.enabled) + } + if lp.charging { + t.Errorf("charging %v", lp.charging) + } + if lp.targetCurrent != 0 { + t.Errorf("targetCurrent %v", lp.targetCurrent) + } +} + +func newLoadPoint(charger api.Charger, pv, gm, cm api.Meter) *LoadPoint { + lp := NewLoadPoint() + lp.clock = clock.NewMock() + lp.clock.(*clock.Mock).Add(time.Hour) + + lp.Charger = charger + lp.PVMeter = pv + lp.GridMeter = gm + + // prevent assigning a nil pointer sake of + // https://groups.google.com/forum/#!topic/golang-nuts/wnH302gBa4I/discussion + if !(cm == nil || reflect.ValueOf(cm).IsNil()) { + lp.ChargeMeter = cm + } + + uiChan := make(chan Param) + notificationChan := make(chan push.Event) + + lp.Prepare(uiChan, notificationChan) + + go func() { + for { + select { + case <-uiChan: + case <-notificationChan: + } + } + }() + + return lp +} + +func newEnvironment(t *testing.T, ctrl *gomock.Controller, pm, gm, cm api.Meter) (*LoadPoint, *mock.MockCharger) { + wb := mock.NewMockCharger(ctrl) + + wb.EXPECT().Enabled().Return(true, nil) // initial alignment with wb + wb.EXPECT().MaxCurrent(lpMinCurrent) // initial alignment with wb + + lp := newLoadPoint(wb, pm, gm, cm) + if !lp.enabled { + t.Errorf("enabled %v", lp.enabled) + } + if lp.guardUpdated != lp.clock.Now() { + t.Errorf("guardUpdated %v", lp.guardUpdated) + } + + return lp, wb +} + +func TestMeterConfigurations(t *testing.T) { + tc := []struct { + gm, cm, pm bool + }{ + // {false, false, false}, // no meter + // {false, true, false}, // cm only + {false, false, true}, // pm only + {true, false, false}, // gm only + {true, true, false}, // gm + cm + {true, false, true}, // gm + pm + {true, true, true}, // gm + cm + pm + } + + fg := provider.FloatGetter(func() (float64, error) { + return 1, nil + }) + + for _, tc := range tc { + t.Logf("gm: %+v cm: %v pm: %v", tc.gm, tc.cm, tc.pm) + + var gm, pm, cm api.Meter + if tc.gm { + gm = NewMeter(fg) + } + if tc.cm { + cm = NewMeter(fg) + } + if tc.pm { + pm = NewMeter(fg) + } + + ctrl := gomock.NewController(t) + lp, wb := newEnvironment(t, ctrl, pm, gm, cm) + wb.EXPECT().Status().Return(api.StatusA, nil) + + lp.update() + } +} + +func TestInitialUpdate(t *testing.T) { + tc := []struct { + status api.ChargeStatus + mode api.ChargeMode + }{ + {status: api.StatusA, mode: api.ModeOff}, + {status: api.StatusA, mode: api.ModeNow}, + {status: api.StatusA, mode: api.ModeMinPV}, + {status: api.StatusA, mode: api.ModePV}, + + {status: api.StatusB, mode: api.ModeOff}, + {status: api.StatusB, mode: api.ModeNow}, + {status: api.StatusB, mode: api.ModeMinPV}, + {status: api.StatusB, mode: api.ModePV}, + + {status: api.StatusC, mode: api.ModeOff}, + {status: api.StatusC, mode: api.ModeNow}, + {status: api.StatusC, mode: api.ModeMinPV}, + {status: api.StatusC, mode: api.ModePV}, + } + + for _, tc := range tc { + t.Logf("%+v\n", tc) + + ctrl := gomock.NewController(t) + + pm := mock.NewMockMeter(ctrl) + gm := mock.NewMockMeter(ctrl) + cm := mock.NewMockMeter(ctrl) + // cm = nil + + lp, wb := newEnvironment(t, ctrl, pm, gm, cm) + lp.Mode = tc.mode + + wb.EXPECT().Status().Return(tc.status, nil) + + // values are relevant for PV case + minPower := float64(lpMinCurrent) * lp.Voltage + pm.EXPECT().CurrentPower().Return(minPower, nil) + gm.EXPECT().CurrentPower().Return(float64(0), nil) + if cm != nil { + cm.EXPECT().CurrentPower().Return(minPower, nil) + } + + // disable if not connected + if tc.status != api.StatusA && tc.mode == api.ModeOff { + wb.EXPECT().Enable(false) + } + + // power up if now + if tc.status != api.StatusA && tc.mode == api.ModeNow { + wb.EXPECT().MaxCurrent(lpMaxCurrent) + } + + lp.update() + + // max current if connected & mode now + if tc.status != api.StatusA && tc.mode == api.ModeNow { + if lp.targetCurrent != lpMaxCurrent { + t.Errorf("targetCurrent %v", lp.targetCurrent) + } + } + + // min current in first cycle + if tc.mode != api.ModeNow { + if lp.targetCurrent != lpMinCurrent { + t.Errorf("targetCurrent %v", lp.targetCurrent) + } + } + + // status c means charging + if lp.charging != (tc.status == api.StatusC) { + t.Errorf("charging %v", lp.charging) + } + + ctrl.Finish() + } +} + +func TestImmediateOnOff(t *testing.T) { + tc := []struct { + status api.ChargeStatus + mode api.ChargeMode + }{ + {status: api.StatusC, mode: api.ModePV}, + } + + for _, tc := range tc { + t.Logf("%+v\n", tc) + + ctrl := gomock.NewController(t) + + pm := mock.NewMockMeter(ctrl) + gm := mock.NewMockMeter(ctrl) + cm := mock.NewMockMeter(ctrl) + // cm = nil + + lp, wb := newEnvironment(t, ctrl, pm, gm, cm) + lp.Mode = tc.mode + + // -- round 1 + wb.EXPECT().Status().Return(tc.status, nil) + + // values are relevant for PV case + minPower := float64(lpMinCurrent) * lp.Voltage * float64(lp.Phases) + pm.EXPECT().CurrentPower().Return(minPower, nil) + gm.EXPECT().CurrentPower().Return(0.0, nil) + if cm != nil { + cm.EXPECT().CurrentPower().Return(minPower, nil) + } + + // disable if not connected + if tc.status != api.StatusA && tc.mode == api.ModeOff { + wb.EXPECT().Enable(false) + } + + // power up if now + if tc.status != api.StatusA && tc.mode == api.ModeNow { + wb.EXPECT().MaxCurrent(lpMaxCurrent) + } + + lp.update() + + // max current if connected & mode now + if tc.status != api.StatusA && tc.mode == api.ModeNow { + if lp.targetCurrent != lpMaxCurrent { + t.Errorf("targetCurrent %v", lp.targetCurrent) + } + } + + // min current in first cycle + if tc.mode != api.ModeNow { + if lp.targetCurrent != lpMinCurrent { + t.Errorf("targetCurrent %v", lp.targetCurrent) + } + } + + // status c means charging + if lp.charging != (tc.status == api.StatusC) { + t.Errorf("charging %v", lp.charging) + } + + // -- round 2 + wb.EXPECT().Status().Return(tc.status, nil) + + pm.EXPECT().CurrentPower().Return(minPower, nil) + gm.EXPECT().CurrentPower().Return(-2*minPower, nil) + if cm != nil { + cm.EXPECT().CurrentPower().Return(1.0, nil) + } + + wb.EXPECT().MaxCurrent(2 * lpMinCurrent) + + lp.update() + + // -- round 3 + t.Logf("%+v - 3 (status: %v, enabled: %v, current %d)\n", tc, lp.status, lp.enabled, lp.targetCurrent) + + wb.EXPECT().Status().Return(tc.status, nil) + + pm.EXPECT().CurrentPower().Return(minPower, nil) + gm.EXPECT().CurrentPower().Return(-2*minPower, nil) + if cm != nil { + cm.EXPECT().CurrentPower().Return(1.0, nil) + } + + wb.EXPECT().MaxCurrent(lpMinCurrent) + + lp.SetMode(api.ModeOff) + lp.update() + + ctrl.Finish() + } +}