Refactor charge current calculation (#223)

This commit is contained in:
andig 2020-07-19 20:32:50 +02:00 • committed by GitHub
parent 985a16a91d
commit eb66f6d911
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 156 additions and 208 deletions

View file

@ -11,6 +11,18 @@ import (
"github.com/benbjohnson/clock"
)
//go:generate mockgen -package mock -destination ../mock/mock_chargerhandler.go github.com/andig/evcc/core Handler
// Handler is the charger handler responsible for enabled state, target current and guard durations
type Handler interface {
Prepare()
SyncEnabled()
Enabled() bool
Status() (api.ChargeStatus, error)
TargetCurrent() int64
Ramp(int64, ...bool) error
}
// HandlerConfig contains the public configuration for the ChargerHandler
type HandlerConfig struct {
Sensitivity int64 // Step size of current change
@ -74,8 +86,8 @@ func (lp *ChargerHandler) Prepare() {
lp.bus.Publish(evChargeCurrent, lp.MinCurrent)
}
// SyncSettings synchronizes charger settings to expected state
func (lp *ChargerHandler) SyncSettings() {
// SyncEnabled synchronizes charger settings to expected state
func (lp *ChargerHandler) SyncEnabled() {
enabled, err := lp.charger.Enabled()
if err == nil && enabled != lp.enabled {
lp.log.DEBUG.Printf("sync enabled state to %s", status[lp.enabled])

View file

@ -14,8 +14,13 @@ var (
)
// powerToCurrent is a helper function to convert power to per-phase current
func powerToCurrent(power, voltage float64, phases int64) int64 {
return int64(power / (float64(phases) * voltage))
func powerToCurrent(power float64, phases int64) int64 {
return int64(power / (float64(phases) * Voltage))
}
// currentToPower is a helper function to convert phase current to total power
func currentToPower(current, phases int64) float64 {
return float64(current*phases) * Voltage
}
// consumedPower estimates how much power the charger might have consumed given it was the only load

View file

@ -29,18 +29,6 @@ type ThresholdConfig struct {
Threshold float64
}
//go:generate mockgen -package mock -destination ../mock/mock_chargerhandler.go github.com/andig/evcc/core Handler
// Handler is the charger handler responsible for enabled state, target current and guard durations
type Handler interface {
Prepare()
SyncSettings()
Enabled() bool
Status() (api.ChargeStatus, error)
TargetCurrent() int64
Ramp(int64, ...bool) error
}
// LoadPoint is responsible for controlling charge depending on
// SoC needs and power availability.
type LoadPoint struct {
@ -73,7 +61,6 @@ type LoadPoint struct {
status api.ChargeStatus // Charger status
charging bool // Charging cycle
chargePower float64 // Charging power
sitePower float64 // Available power from site
pvTimer time.Time
}
@ -303,13 +290,11 @@ func (lp *LoadPoint) detectPhases() {
}
}
func (lp *LoadPoint) maxCurrent(mode api.ChargeMode) int64 {
// grid meter will always be available, if as wrapped pv meter
targetPower := lp.chargePower - lp.sitePower
lp.log.DEBUG.Printf("target power: %.0fW = %.0fW charge - %.0fW available", targetPower, lp.chargePower, lp.sitePower)
// get max charge current
targetCurrent := clamp(powerToCurrent(targetPower, Voltage, lp.Phases), 0, lp.MaxCurrent)
// maxCurrent calculates the maximum target current for PV mode
func (lp *LoadPoint) maxCurrent(mode api.ChargeMode, sitePower float64) int64 {
// calculate target charge current from delta power and actual current
deltaCurrent := powerToCurrent(-sitePower, lp.Phases)
targetCurrent := clamp(lp.handler.TargetCurrent()+deltaCurrent, 0, lp.MaxCurrent)
// in MinPV mode return at least minCurrent
if mode == api.ModeMinPV && targetCurrent < lp.MinCurrent {
@ -332,8 +317,8 @@ func (lp *LoadPoint) maxCurrent(mode api.ChargeMode) int64 {
if mode == api.ModePV && enabled && targetCurrent < lp.MinCurrent {
// kick off disable sequence
if lp.sitePower >= lp.Disable.Threshold {
lp.log.DEBUG.Printf("site power %.0f >= disable threshold %.0f", lp.sitePower, lp.Disable.Threshold)
if sitePower >= lp.Disable.Threshold {
lp.log.DEBUG.Printf("site power %.0f >= disable threshold %.0f", sitePower, lp.Disable.Threshold)
if lp.pvTimer.IsZero() {
lp.log.DEBUG.Println("start pv disable timer")
@ -355,8 +340,8 @@ func (lp *LoadPoint) maxCurrent(mode api.ChargeMode) int64 {
if mode == api.ModePV && !enabled {
// kick off enable sequence
if targetCurrent >= lp.MinCurrent ||
(lp.Enable.Threshold != 0 && lp.sitePower <= lp.Enable.Threshold) {
lp.log.DEBUG.Printf("site power %.0f < enable threshold %.0f", lp.sitePower, lp.Enable.Threshold)
(lp.Enable.Threshold != 0 && sitePower <= lp.Enable.Threshold) {
lp.log.DEBUG.Printf("site power %.0f < enable threshold %.0f", sitePower, lp.Enable.Threshold)
if lp.pvTimer.IsZero() {
lp.log.DEBUG.Println("start pv enable timer")
@ -382,40 +367,25 @@ func (lp *LoadPoint) maxCurrent(mode api.ChargeMode) int64 {
return targetCurrent
}
// updateMeter updates and publishes single meter
func (lp *LoadPoint) updateMeter(name string, meter api.Meter, power *float64) error {
value, err := meter.CurrentPower()
if err != nil {
return err
}
*power = value // update value if no error
lp.log.DEBUG.Printf("%s power: %.1fW", name, *power)
lp.publish(name+"Power", *power)
return nil
}
// updateMeter updates and publishes single meter
func (lp *LoadPoint) updateMeters() (err error) {
retryMeter := func(s string, m api.Meter, f *float64) {
if m != nil {
e := retry.Do(func() error {
return lp.updateMeter(s, m, f)
}, retryOptions...)
if e != nil {
err = errors.Wrapf(e, "updating %s meter", s)
lp.log.ERROR.Printf("%v", err)
}
// updateChargeMete updates and publishes single meter
func (lp *LoadPoint) updateChargeMeter() {
err := retry.Do(func() error {
value, err := lp.chargeMeter.CurrentPower()
if err != nil {
return err
}
lp.chargePower = value // update value if no error
lp.log.DEBUG.Printf("charge power: %.1fW", value)
lp.publish("chargePower", value)
return nil
}, retryOptions...)
if err != nil {
err = errors.Wrapf(err, "updating charge meter")
lp.log.ERROR.Printf("%v", err)
}
// read PV meter before charge meter
retryMeter("charge", lp.chargeMeter, &lp.chargePower)
return err
}
// chargeDuration returns for how long the charge cycle has been running
@ -480,11 +450,9 @@ func (lp *LoadPoint) publishSoC() {
}
// Update is the main control function. It reevaluates meters and charger state
func (lp *LoadPoint) Update(mode api.ChargeMode, sitePower float64) float64 {
func (lp *LoadPoint) Update(mode api.ChargeMode, sitePower float64) {
// read and publish meters first
_ = lp.updateMeters()
lp.sitePower = sitePower
lp.updateChargeMeter()
// update ChargeRater here to make sure initial meter update is caught
lp.bus.Publish(evChargeCurrent, lp.handler.TargetCurrent())
@ -497,7 +465,7 @@ func (lp *LoadPoint) Update(mode api.ChargeMode, sitePower float64) float64 {
// read and publish status
if err := retry.Do(lp.updateChargeStatus, retryOptions...); err != nil {
lp.log.ERROR.Printf("charge controller error: %v", err)
return lp.chargePower
return
}
lp.publish("connected", lp.connected())
@ -505,7 +473,7 @@ func (lp *LoadPoint) Update(mode api.ChargeMode, sitePower float64) float64 {
// sync settings with charger
if lp.status != api.StatusA {
lp.handler.SyncSettings()
lp.handler.SyncEnabled()
}
// phase detection - run only when actually charging
@ -530,7 +498,7 @@ func (lp *LoadPoint) Update(mode api.ChargeMode, sitePower float64) float64 {
err = lp.handler.Ramp(current, true)
case api.ModeMinPV, api.ModePV:
targetCurrent := lp.maxCurrent(mode)
targetCurrent := lp.maxCurrent(mode, sitePower)
if !lp.connected() {
// ensure minimum current when not connected
// https://github.com/andig/evcc/issues/105
@ -544,6 +512,4 @@ func (lp *LoadPoint) Update(mode api.ChargeMode, sitePower float64) float64 {
if err != nil {
lp.log.ERROR.Println(err)
}
return lp.chargePower
}

View file

@ -146,10 +146,14 @@ func TestUpdate(t *testing.T) {
attachListeners(lp)
handler.EXPECT().Status().Return(tc.status, nil)
handler.EXPECT().TargetCurrent().Return(int64(-1))
handler.EXPECT().TargetCurrent().Return(int64(0))
if tc.status != api.StatusA {
handler.EXPECT().SyncSettings()
handler.EXPECT().SyncEnabled()
}
if tc.mode == api.ModeMinPV || tc.mode == api.ModePV {
handler.EXPECT().TargetCurrent().Return(int64(0))
}
if tc.expect != nil {
@ -326,11 +330,12 @@ func TestPVHysteresisForStatusC(t *testing.T) {
for step, se := range tc.series {
clck.Set(start.Add(se.delay))
lp.sitePower = se.site
// maxCurrent will read enabled state in PV mode
// maxCurrent will read actual current and enabled state in PV mode
handler.EXPECT().TargetCurrent().Return(int64(0))
handler.EXPECT().Enabled().Return(tc.enabled)
current := lp.maxCurrent(api.ModePV)
current := lp.maxCurrent(api.ModePV, se.site)
if current != se.current {
t.Errorf("step %d: wanted %d, got %d", step, se.current, current)
@ -342,7 +347,6 @@ func TestPVHysteresisForStatusC(t *testing.T) {
}
func TestPVHysteresisForStatusOtherThanC(t *testing.T) {
clck := clock.NewMock()
ctrl := gomock.NewController(t)
handler := mock.NewMockHandler(ctrl)
@ -362,9 +366,12 @@ func TestPVHysteresisForStatusOtherThanC(t *testing.T) {
// not connected, test PV mode logic short-circuited
lp.status = api.StatusA
// maxCurrent will read actual current in PV mode
handler.EXPECT().TargetCurrent().Return(int64(0))
// maxCurrent will read enabled state in PV mode
lp.sitePower = -float64(minA*lp.Phases)*Voltage + 1 // 1W below min power
current := lp.maxCurrent(api.ModePV)
sitePower := -float64(minA*lp.Phases)*Voltage + 1 // 1W below min power
current := lp.maxCurrent(api.ModePV, sitePower)
if current != 0 {
t.Errorf("PV mode could not disable charger as expected. Expected 0, got %d", current)

View file

@ -12,11 +12,11 @@ import (
"github.com/pkg/errors"
)
//go:generate mockgen -package mock -destination ../mock/mock_loadpoint.go github.com/andig/evcc/core LoadPointer
//go:generate mockgen -package mock -destination ../mock/mock_loadpoint.go github.com/andig/evcc/core Updater
// LoadPointer abstracts the LoadPoint implementation for testing
type LoadPointer interface {
Update(api.ChargeMode, float64) float64
// Updater abstracts the LoadPoint implementation for testing
type Updater interface {
Update(api.ChargeMode, float64)
}
// Site is the main configuration container. A site can host multiple loadpoints.
@ -38,7 +38,7 @@ type Site struct {
pvMeter api.Meter // PV generation meter
batteryMeter api.Meter // Battery charging meter
loadpoints []LoadPointer // Loadpoints
loadpoints []*LoadPoint // Loadpoints
// cached state
gridPower float64 // Grid power
@ -136,11 +136,7 @@ func (lp *LoadPoint) hasChargeMeter() bool {
// LoadPoints returns the array of associated loadpoints
func (site *Site) LoadPoints() []*LoadPoint {
res := make([]*LoadPoint, 0, len(site.loadpoints))
for _, lp := range site.loadpoints {
res = append(res, lp.(*LoadPoint))
}
return res
return site.loadpoints
}
// Configuration returns meter configuration
@ -153,9 +149,7 @@ func (site *Site) Configuration() SiteConfiguration {
BatteryMeter: site.batteryMeter != nil,
}
for _, lptr := range site.loadpoints {
lp := lptr.(*LoadPoint)
for _, lp := range site.loadpoints {
lpc := LoadpointConfiguration{
Title: lp.Name(),
Phases: lp.Phases,
@ -184,8 +178,7 @@ func (site *Site) DumpConfig() {
site.log.INFO.Printf(" pv %s", presence[site.pvMeter != nil])
site.log.INFO.Printf(" battery %s", presence[site.batteryMeter != nil])
for i, lptr := range site.loadpoints {
lp := lptr.(*LoadPoint)
for i, lp := range site.loadpoints {
lp.log.INFO.Printf("loadpoint %d config:", i)
lp.log.INFO.Printf(" vehicle %s", presence[lp.vehicle != nil])
@ -302,30 +295,20 @@ func (site *Site) sitePower() (float64, error) {
return sitePower, nil
}
func (site *Site) update() error {
func (site *Site) update(lp Updater) {
mode := site.GetMode()
site.publish("mode", string(mode))
sitePower, err := site.sitePower()
if err != nil {
return err
if sitePower, err := site.sitePower(); err == nil {
lp.Update(mode, sitePower)
}
for idx, lp := range site.loadpoints {
usedPower := lp.Update(mode, sitePower)
remainingPower := sitePower + usedPower
site.log.DEBUG.Printf("lp-%d remaining power: %.0fW = %.0fW - %.0fW", idx+1, remainingPower, sitePower, usedPower)
sitePower = remainingPower
}
return nil
}
// Prepare attaches communication channels to site and loadpoints
func (site *Site) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Event) {
site.uiChan = uiChan
for id, loadPoint := range site.loadpoints {
for id, lp := range site.loadpoints {
lpUIChan := make(chan util.Param)
lpPushChan := make(chan push.Event)
@ -343,7 +326,16 @@ func (site *Site) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Event)
}
}(id)
loadPoint.(*LoadPoint).Prepare(lpUIChan, lpPushChan)
lp.Prepare(lpUIChan, lpPushChan)
}
}
// loopLoadpoints keeps iterating across loadpoints sending the next to the given channel
func (site *Site) loopLoadpoints(next chan<- Updater) {
for {
for _, lp := range site.loadpoints {
next <- lp
}
}
}
@ -354,14 +346,17 @@ func (site *Site) Run(interval time.Duration) {
ticker := time.NewTicker(interval)
site.triggerChan <- struct{}{} // start immediately
loadpointChan := make(chan Updater)
go site.loopLoadpoints(loadpointChan)
for {
select {
case <-ticker.C:
if site.update() != nil {
site.triggerChan <- struct{}{} // restart immediately
}
site.update(<-loadpointChan)
case <-site.triggerChan:
_ = site.update()
for range site.loadpoints {
site.update(<-loadpointChan)
}
ticker.Stop()
ticker = time.NewTicker(interval)
}

View file

@ -2,11 +2,6 @@ package core
import (
"testing"
"github.com/andig/evcc/api"
"github.com/andig/evcc/mock"
"github.com/andig/evcc/util"
"github.com/golang/mock/gomock"
)
func TestSitePower(t *testing.T) {
@ -29,32 +24,3 @@ func TestSitePower(t *testing.T) {
}
}
}
func TestSiteUpdateMultipleLoadpoints(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
sitePower := -1000.0
lps := []float64{300.0, 400.0, 500.0}
gm := mock.NewMockMeter(ctrl)
gm.EXPECT().CurrentPower().Return(sitePower, nil)
site := &Site{
log: util.NewLogger("foo"),
Mode: api.ModePV,
gridMeter: gm,
}
availablePower := sitePower
for _, usedPower := range lps {
lp := mock.NewMockLoadPointer(ctrl)
site.loadpoints = append(site.loadpoints, lp)
lp.EXPECT().Update(api.ModePV, availablePower).Return(usedPower)
availablePower += usedPower
}
_ = site.update()
}

View file

@ -93,16 +93,16 @@ func (mr *MockHandlerMockRecorder) Status() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MockHandler)(nil).Status))
}
// SyncSettings mocks base method
func (m *MockHandler) SyncSettings() {
// SyncEnabled mocks base method
func (m *MockHandler) SyncEnabled() {
m.ctrl.T.Helper()
m.ctrl.Call(m, "SyncSettings")
m.ctrl.Call(m, "SyncEnabled")
}
// SyncSettings indicates an expected call of SyncSettings
func (mr *MockHandlerMockRecorder) SyncSettings() *gomock.Call {
// SyncEnabled indicates an expected call of SyncEnabled
func (mr *MockHandlerMockRecorder) SyncEnabled() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncSettings", reflect.TypeOf((*MockHandler)(nil).SyncSettings))
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncEnabled", reflect.TypeOf((*MockHandler)(nil).SyncEnabled))
}
// TargetCurrent mocks base method

View file

@ -1,5 +1,5 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/andig/evcc/core (interfaces: LoadPointer)
// Source: github.com/andig/evcc/core (interfaces: Updater)
// Package mock is a generated GoMock package.
package mock
@ -10,39 +10,37 @@ import (
reflect "reflect"
)
// MockLoadPointer is a mock of LoadPointer interface
type MockLoadPointer struct {
// MockUpdater is a mock of Updater interface
type MockUpdater struct {
ctrl *gomock.Controller
recorder *MockLoadPointerMockRecorder
recorder *MockUpdaterMockRecorder
}
// MockLoadPointerMockRecorder is the mock recorder for MockLoadPointer
type MockLoadPointerMockRecorder struct {
mock *MockLoadPointer
// MockUpdaterMockRecorder is the mock recorder for MockUpdater
type MockUpdaterMockRecorder struct {
mock *MockUpdater
}
// NewMockLoadPointer creates a new mock instance
func NewMockLoadPointer(ctrl *gomock.Controller) *MockLoadPointer {
mock := &MockLoadPointer{ctrl: ctrl}
mock.recorder = &MockLoadPointerMockRecorder{mock}
// NewMockUpdater creates a new mock instance
func NewMockUpdater(ctrl *gomock.Controller) *MockUpdater {
mock := &MockUpdater{ctrl: ctrl}
mock.recorder = &MockUpdaterMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockLoadPointer) EXPECT() *MockLoadPointerMockRecorder {
func (m *MockUpdater) EXPECT() *MockUpdaterMockRecorder {
return m.recorder
}
// Update mocks base method
func (m *MockLoadPointer) Update(arg0 api.ChargeMode, arg1 float64) float64 {
func (m *MockUpdater) Update(arg0 api.ChargeMode, arg1 float64) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Update", arg0, arg1)
ret0, _ := ret[0].(float64)
return ret0
m.ctrl.Call(m, "Update", arg0, arg1)
}
// Update indicates an expected call of Update
func (mr *MockLoadPointerMockRecorder) Update(arg0, arg1 interface{}) *gomock.Call {
func (mr *MockUpdaterMockRecorder) Update(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockLoadPointer)(nil).Update), arg0, arg1)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockUpdater)(nil).Update), arg0, arg1)
}

View file

@ -3625,47 +3625,46 @@ u5/Ve679nt1z7X+G+k8AAAD//9FsvQIdNQAA
"/js/app.js": {
name: "app.js",
local: "../assets/js/app.js",
size: 7032,
size: 6792,
modtime: 1566640112,
compressed: `
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
Pz54GwAA
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
JiAeF0hccFuj/x0AAP//sWHskogaAAA=
`,
},