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
This commit is contained in:
andig 2020-04-21 21:23:24 +02:00
parent 6cbfc55902
commit bcebf03cdb
2 changed files with 350 additions and 45 deletions

View file

@ -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
}

314
core/loadpoint_test.go Normal file
View file

@ -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()
}
}