Skip to content

Commit

Permalink
Split loadpoint and charger handler (#104)
Browse files Browse the repository at this point in the history
andig authored May 6, 2020

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
1 parent fdfb8de commit d17289d
Showing 5 changed files with 503 additions and 133 deletions.
3 changes: 1 addition & 2 deletions cmd/config.go
Original file line number Diff line number Diff line change
@@ -5,7 +5,6 @@ import (

"github.com/andig/evcc/api"
"github.com/andig/evcc/charger"
"github.com/andig/evcc/core"
"github.com/andig/evcc/meter"
"github.com/andig/evcc/push"
"github.com/andig/evcc/server"
@@ -23,7 +22,7 @@ type config struct {
Meters []qualifiedConfig
Chargers []qualifiedConfig
Vehicles []qualifiedConfig
LoadPoints []core.Config
LoadPoints []map[string]interface{}
}

type qualifiedConfig struct {
6 changes: 3 additions & 3 deletions cmd/setup.go
Original file line number Diff line number Diff line change
@@ -45,10 +45,10 @@ func loadConfig(conf config, eventsChan chan push.Event) (loadPoints []*core.Loa
}

// decode slice into slice of maps
var lpm []map[string]interface{}
util.DecodeOther(log, lps, &lpm)
var lpc []map[string]interface{}
util.DecodeOther(log, lps, &lpc)

for _, lpc := range lpm {
for _, lpc := range lpc {
lp := core.NewLoadPointFromConfig(log, cp, lpc)
loadPoints = append(loadPoints, lp)
}
143 changes: 143 additions & 0 deletions core/chargerhandler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package core

import (
"fmt"
"time"

"github.com/andig/evcc/api"

evbus "github.com/asaskevich/EventBus"
"github.com/benbjohnson/clock"
)

// ChargerHandler handles steering of the charger state and allowed current
type ChargerHandler struct {
clock clock.Clock // mockable time
bus evbus.Bus // event bus

charger api.Charger // Charger

Name string
Sensitivity int64 // Step size of current change
MinCurrent int64 // PV mode: start current Min+PV mode: min current
MaxCurrent int64 // Max allowed current. Physically ensured by the charge controller
GuardDuration time.Duration // charger enable/disable minimum holding time

enabled bool // Charger enabled state
targetCurrent int64

// contactor switch guard
guardUpdated time.Time // charger enabled/disabled timestamp
}

// NewChargerHandler creates handler with default settings
func NewChargerHandler(name string, clock clock.Clock, bus evbus.Bus) ChargerHandler {
return ChargerHandler{
Name: name,
clock: clock, // mockable time
bus: bus, // event bus
MinCurrent: 6, // A
MaxCurrent: 16, // A
Sensitivity: 10, // A
GuardDuration: 5 * time.Minute,
}
}

// chargerEnable switches charging on or off. Minimum cycle duration is guaranteed.
func (lp *ChargerHandler) chargerEnable(enable bool) error {
if lp.targetCurrent != 0 && lp.targetCurrent != lp.MinCurrent {
log.FATAL.Fatal("charger enable/disable called without setting min current first")
}

if remaining := (lp.GuardDuration - lp.clock.Since(lp.guardUpdated)).Truncate(time.Second); remaining > 0 {
log.DEBUG.Printf("%s charger %s - contactor delay %v", lp.Name, status[enable], remaining)
return nil
}

if lp.enabled != enable {
if err := lp.charger.Enable(enable); err != nil {
return fmt.Errorf("%s charge controller error: %v", lp.Name, err)
}

lp.enabled = enable // cache
log.INFO.Printf("%s charger %s", lp.Name, status[enable])
lp.guardUpdated = lp.clock.Now()
} else {
log.DEBUG.Printf("%s charger %s", lp.Name, status[enable])
}

// if not enabled, current will be reduced to 0 in handler
lp.bus.Publish(evChargeCurrent, lp.MinCurrent)

return nil
}

// setTargetCurrent guards setting current against changing to identical value
// and violating MaxCurrent
func (lp *ChargerHandler) setTargetCurrent(targetCurrentIn int64) error {
targetCurrent := clamp(targetCurrentIn, lp.MinCurrent, lp.MaxCurrent)
if targetCurrent != targetCurrentIn {
log.WARN.Printf("%s hard limit charge current: %dA", lp.Name, targetCurrent)
}

if lp.targetCurrent != targetCurrent {
log.DEBUG.Printf("%s set charge current: %dA", lp.Name, targetCurrent)
if err := lp.charger.MaxCurrent(targetCurrent); err != nil {
return fmt.Errorf("%s charge controller error: %v", lp.Name, err)
}

lp.targetCurrent = targetCurrent // cache
}

lp.bus.Publish(evChargeCurrent, targetCurrent)

return nil
}

// rampUpDown moves stepwise towards target current.
// It does not enable or disable the charger.
func (lp *ChargerHandler) rampUpDown(target int64) error {
current := lp.targetCurrent
if current == target {
return nil
}

var step int64
if current < target {
step = min(current+lp.Sensitivity, target)
} else if current > target {
step = max(current-lp.Sensitivity, target)
}

step = clamp(step, lp.MinCurrent, lp.MaxCurrent)

return lp.setTargetCurrent(step)
}

// rampOff disables charger after setting minCurrent.
// Setting current and disabling are two steps. If already disabled, this is a nop.
func (lp *ChargerHandler) rampOff() error {
if lp.enabled {
if lp.targetCurrent == lp.MinCurrent {
return lp.chargerEnable(false)
}

return lp.setTargetCurrent(lp.MinCurrent)
}

return nil
}

// rampOn enables charger immediately after setting minCurrent.
// If already enabled, target will be set.
func (lp *ChargerHandler) rampOn(target int64) error {
if !lp.enabled {
if err := lp.setTargetCurrent(lp.MinCurrent); err != nil {
return err
}

return lp.chargerEnable(true)
}

return lp.setTargetCurrent(target)
}
332 changes: 332 additions & 0 deletions core/chargerhandler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,332 @@
package core

import (
"testing"
"time"

"github.com/andig/evcc/api"
"github.com/andig/evcc/mock"
evbus "github.com/asaskevich/EventBus"
"github.com/benbjohnson/clock"
"github.com/golang/mock/gomock"
)

const (
minA int64 = 6
maxA int64 = 16
sensitivity = 1
dt = time.Hour
)

func newChargerHandler(clock clock.Clock, mc api.Charger) ChargerHandler {
r := NewChargerHandler("", clock, evbus.New())

r.charger = mc
r.Sensitivity = sensitivity
r.guardUpdated = clock.Now()

return r
}

func TestNewChargerHandler(t *testing.T) {
r := NewChargerHandler("", nil, nil)

if r.MinCurrent != minA {
t.Errorf("expected %v, got %v", minA, r.MinCurrent)
}
if r.MaxCurrent != maxA {
t.Errorf("expected %v, got %v", maxA, r.MaxCurrent)
}
if r.Sensitivity != 10 {
t.Errorf("expected %v, got %v", 10, r.Sensitivity)
}
if r.GuardDuration != 5*time.Minute {
t.Errorf("expected %v, got %v", 5*time.Minute, r.GuardDuration)
}
}

func TestEnable(t *testing.T) {
tc := []struct {
enabledI bool
dt time.Duration
enable bool
targetCurrentI int64
expect func(*mock.MockCharger)
}{
// any test with current != 0 or min will fail
{false, 0, false, 0, func(mc *mock.MockCharger) {
// nop
}},
{false, 0, true, 0, func(mc *mock.MockCharger) {
// nop
}},
{false, dt, true, 0, func(mc *mock.MockCharger) {
mc.EXPECT().Enable(true).Return(nil)
}},
{false, 0, true, minA, func(mc *mock.MockCharger) {
// nop
}},
{false, dt, true, minA, func(mc *mock.MockCharger) {
mc.EXPECT().Enable(true).Return(nil)
}},
{true, 0, false, minA, func(mc *mock.MockCharger) {
// nop
}},
{true, dt, false, minA, func(mc *mock.MockCharger) {
mc.EXPECT().Enable(false).Return(nil)
}},
{true, 0, true, minA, func(mc *mock.MockCharger) {
// nop
}},
{true, dt, true, minA, func(mc *mock.MockCharger) {
// nop
}},
}

for _, tc := range tc {
ctrl := gomock.NewController(t)
mc := mock.NewMockCharger(ctrl)

t.Log(tc)

clock := clock.NewMock()
r := newChargerHandler(clock, mc)
r.enabled = tc.enabledI
r.targetCurrent = tc.targetCurrentI

tc.expect(mc)
clock.Add(tc.dt)

if err := r.chargerEnable(tc.enable); err != nil {
t.Error(err)
}

ctrl.Finish()
}
}

func TestSetCurrent(t *testing.T) {
tc := []struct {
targetCurrentI, targetCurrent, targetCurrentO int64
expect func(*mock.MockCharger)
}{
{0, 0, minA, func(mc *mock.MockCharger) {
mc.EXPECT().MaxCurrent(minA).Return(nil)
}},
{minA, minA, minA, func(mc *mock.MockCharger) {
// we are at min: current call omitted
}},
{minA, 0, minA, func(mc *mock.MockCharger) {
// we are at min: current call omitted
}},
{minA, maxA, maxA, func(mc *mock.MockCharger) {
mc.EXPECT().MaxCurrent(maxA).Return(nil)
}},
{maxA, maxA, maxA, func(mc *mock.MockCharger) {
// we are at min: current call omitted
}},
{minA, 2 * maxA, maxA, func(mc *mock.MockCharger) {
mc.EXPECT().MaxCurrent(maxA).Return(nil)
}},
}

for _, tc := range tc {
ctrl := gomock.NewController(t)
mc := mock.NewMockCharger(ctrl)

t.Log(tc)

clock := clock.NewMock()
r := newChargerHandler(clock, mc)
r.targetCurrent = tc.targetCurrentI

tc.expect(mc)

if err := r.setTargetCurrent(tc.targetCurrent); err != nil {
t.Error(err)
}

if r.targetCurrent != tc.targetCurrentO {
t.Errorf("targetCurrent: expected %d, got %d", tc.targetCurrentO, r.targetCurrent)
}

ctrl.Finish()
}
}

func TestRampOn(t *testing.T) {
tc := []struct {
enabledI bool
targetCurrentI, targetCurrent int64
dt time.Duration
expect func(*mock.MockCharger)
}{
// off at zero: set min
{false, 0, minA, 0, func(mc *mock.MockCharger) {
mc.EXPECT().MaxCurrent(minA).Return(nil)
// guard duration
}},
{false, 0, minA, dt, func(mc *mock.MockCharger) {
mc.EXPECT().MaxCurrent(minA).Return(nil)
mc.EXPECT().Enable(true).Return(nil)
}},
// off at max: set min
{false, maxA, minA, 0, func(mc *mock.MockCharger) {
mc.EXPECT().MaxCurrent(minA).Return(nil)
// guard duration
}},
{false, maxA, minA, dt, func(mc *mock.MockCharger) {
mc.EXPECT().MaxCurrent(minA).Return(nil)
mc.EXPECT().Enable(true).Return(nil)
}},
// off at min: set on
{false, minA, minA, 0, func(mc *mock.MockCharger) {
// we are at min: current call omitted
// guard duration
}},
{false, minA, minA, dt, func(mc *mock.MockCharger) {
// we are at min: current call omitted
mc.EXPECT().Enable(true).Return(nil)
}},
// on at min, set min: set min
{true, minA, minA, 0, func(mc *mock.MockCharger) {
// we are at min: current call omitted
// we are enabled: enable call omitted
}},
// on at max, set min: set min
{true, maxA, minA, 0, func(mc *mock.MockCharger) {
mc.EXPECT().MaxCurrent(minA).Return(nil)
// we are enabled: enable call omitted
}},
}

for _, tc := range tc {
ctrl := gomock.NewController(t)
mc := mock.NewMockCharger(ctrl)

t.Log(tc)

clock := clock.NewMock()
r := newChargerHandler(clock, mc)
r.enabled = tc.enabledI
r.targetCurrent = tc.targetCurrentI

tc.expect(mc)
clock.Add(tc.dt)

if err := r.rampOn(tc.targetCurrent); err != nil {
t.Error(err)
}

ctrl.Finish()
}
}

func TestRampOff(t *testing.T) {
tc := []struct {
enabledI bool
targetCurrentI int64
dt time.Duration
expect func(*mock.MockCharger)
}{
// off at zero
{false, 0, 0, func(mc *mock.MockCharger) {
// we are off: enable call omitted
}},
// off at min
{false, minA, 0, func(mc *mock.MockCharger) {
// we are off: enable call omitted
}},
// off at max
{false, maxA, 0, func(mc *mock.MockCharger) {
// we are off: enable call omitted
}},
// on at min, disable
{true, minA, 0, func(mc *mock.MockCharger) {
// we are at min: current call omitted
// guard duration
}},
{true, minA, dt, func(mc *mock.MockCharger) {
// we are at min: current call omitted
mc.EXPECT().Enable(false).Return(nil)
}},
// on at max, set min
{true, maxA, 0, func(mc *mock.MockCharger) {
mc.EXPECT().MaxCurrent(minA).Return(nil)
// we are not at min: enable call omitted
}},
}

for _, tc := range tc {
ctrl := gomock.NewController(t)
mc := mock.NewMockCharger(ctrl)

t.Log(tc)

clock := clock.NewMock()
r := newChargerHandler(clock, mc)
r.enabled = tc.enabledI
r.targetCurrent = tc.targetCurrentI

tc.expect(mc)
clock.Add(tc.dt)

if err := r.rampOff(); err != nil {
t.Error(err)
}

ctrl.Finish()
}
}

func TestRampUpDown(t *testing.T) {
tc := []struct {
targetCurrentI, targetCurrent int64
expect func(*mock.MockCharger)
}{
// no change at 0: nop
{0, 0, func(mc *mock.MockCharger) {
// nop
}},
// no change at min: nop
{minA, minA, func(mc *mock.MockCharger) {
// nop
}},
// at min: set <min
{minA, minA - 100, func(mc *mock.MockCharger) {
// nop
}},
// at min: set max
{minA, maxA, func(mc *mock.MockCharger) {
mc.EXPECT().MaxCurrent(minA + sensitivity).Return(nil)
}},
// at max: set min
{maxA, minA, func(mc *mock.MockCharger) {
mc.EXPECT().MaxCurrent(maxA - sensitivity).Return(nil)
}},
// at max: set >max
{maxA, maxA + 100, func(mc *mock.MockCharger) {
// nop
}},
}

for _, tc := range tc {
ctrl := gomock.NewController(t)
mc := mock.NewMockCharger(ctrl)

t.Log(tc)

clock := clock.NewMock()
r := newChargerHandler(clock, mc)
r.enabled = true
r.targetCurrent = tc.targetCurrentI

tc.expect(mc)

if err := r.rampUpDown(tc.targetCurrent); err != nil {
t.Error(err)
}

ctrl.Finish()
}
}
152 changes: 24 additions & 128 deletions core/loadpoint.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package core

import (
"fmt"
"math"
"sync"
"time"
@@ -36,22 +35,17 @@ func powerToCurrent(power, voltage float64, phases int64) int64 {

// Config contains the public loadpoint configuration
type Config struct {
Name string
Mode api.ChargeMode // Charge mode, guarded by mutex

// options
Sensitivity int64 // Step size of current change
Phases int64 // Phases- required for converting power and current.
MinCurrent int64 // PV mode: start current Min+PV mode: min current
MaxCurrent int64 // Max allowed current. Physically ensured by the charge controller
Voltage float64 // Operating voltage. 230V for Germany.
ResidualPower float64 // PV meter only: household usage. Grid meter: household safety margin

ChargerRef string `mapstructure:"charger"` // Charger reference
VehicleRef string `mapstructure:"vehicle"` // Vehicle reference
Meters MetersConfig // Meter references
ChargerRef string `mapstructure:"charger"` // Charger reference
VehicleRef string `mapstructure:"vehicle"` // Vehicle reference

GuardDuration time.Duration // charger enable/disable minimum holding time
Meters MetersConfig // Meter references
}

// MetersConfig contains the loadpoint's meter configuration
@@ -72,31 +66,26 @@ type LoadPoint struct {
notificationChan chan<- push.Event // notifications
uiChan chan<- Param // client push messages

Config `mapstructure:",squash"` // exposed public configuration
Config `mapstructure:",squash"` // exposed public configuration
ChargerHandler `mapstructure:",squash"` // handle charger state and current

chargeTimer api.ChargeTimer
chargeRater api.ChargeRater

// meters
charger api.Charger // Charger
gridMeter api.Meter // Grid usage meter
pvMeter api.Meter // PV generation meter
batteryMeter api.Meter // Battery charging meter
chargeMeter api.Meter // Charger usage meter
vehicle api.Vehicle // Vehicle

// cached state
status api.ChargeStatus // Charger status
targetCurrent int64 // Allowed current. Between MinCurrent and MaxCurrent.
enabled bool // Charger enabled state
charging bool // Charging cycle
gridPower float64 // Grid power
pvPower float64 // PV power
batteryPower float64 // Battery charge power
chargePower float64 // Charging power

// contactor switch guard
guardUpdated time.Time // charger enabled/disabled timestamp
status api.ChargeStatus // Charger status
charging bool // Charging cycle
gridPower float64 // Grid power
pvPower float64 // PV power
batteryPower float64 // Battery charge power
chargePower float64 // Charging power
}

// configProvider gives access to configuration repository
@@ -140,23 +129,23 @@ func NewLoadPointFromConfig(log *util.Logger, cp configProvider, other map[strin

// NewLoadPoint creates a LoadPoint with sane defaults
func NewLoadPoint() *LoadPoint {
return &LoadPoint{
clock: clock.New(),
bus: evbus.New(),
clock := clock.New()
bus := evbus.New()

lp := &LoadPoint{
clock: clock, // mockable time
bus: bus, // event bus
triggerChan: make(chan struct{}, 1),
Config: Config{
Name: "main",
Mode: api.ModeOff,
Phases: 1,
Voltage: 230, // V
MinCurrent: 6, // A
MaxCurrent: 16, // A
Sensitivity: 10, // A
GuardDuration: 10 * time.Minute,
Mode: api.ModeOff,
Phases: 1,
Voltage: 230, // V
},
status: api.StatusNone,
targetCurrent: 0, // A
status: api.StatusNone,
ChargerHandler: NewChargerHandler("main", clock, bus),
}

return lp
}

// notify sends push messages to clients
@@ -302,32 +291,6 @@ func (lp *LoadPoint) connected() bool {
return lp.status == api.StatusB || lp.status == api.StatusC
}

// chargerEnable switches charging on or off. Minimum cycle duration is guaranteed.
func (lp *LoadPoint) chargerEnable(enable bool) error {
if lp.targetCurrent != 0 && lp.targetCurrent != lp.MinCurrent {
log.FATAL.Fatal("charger enable/disable called without setting min current first")
}

if remaining := (lp.GuardDuration - time.Since(lp.guardUpdated)).Truncate(time.Second); remaining > 0 {
log.DEBUG.Printf("%s charger %s - contactor delay %v", lp.Name, status[enable], remaining)
return nil
}

err := lp.charger.Enable(enable)
if err == nil {
lp.enabled = enable // cache
log.INFO.Printf("%s charger %s", lp.Name, status[enable])
lp.guardUpdated = lp.clock.Now()

// if not enabled, current will be reduced to 0 in handler
lp.bus.Publish(evChargeCurrent, lp.MinCurrent)
} else {
log.DEBUG.Printf("%s charger %s", lp.Name, status[enable])
}

return err
}

// chargingCycle detects charge cycle start and stop events and manages the
// charge energy counter and charge timer. It guards against duplicate invocation.
func (lp *LoadPoint) chargingCycle(enable bool) {
@@ -382,73 +345,6 @@ func (lp *LoadPoint) updateChargeStatus() api.ChargeStatus {
return status
}

// setTargetCurrent guards setting current against changing to identical value
// and violating MaxCurrent
func (lp *LoadPoint) setTargetCurrent(targetCurrentIn int64) error {
targetCurrent := clamp(targetCurrentIn, lp.MinCurrent, lp.MaxCurrent)
if targetCurrent != targetCurrentIn {
log.WARN.Printf("%s hard limit charge current: %dA", lp.Name, targetCurrent)
}

if lp.targetCurrent != targetCurrent {
log.DEBUG.Printf("%s set charge current: %dA", lp.Name, targetCurrent)
if err := lp.charger.MaxCurrent(targetCurrent); err != nil {
return fmt.Errorf("%s charge controller error: %v", lp.Name, err)
}

lp.targetCurrent = targetCurrent // cache
}

lp.bus.Publish(evChargeCurrent, targetCurrent)

return nil
}

// rampUpDown moves stepwise towards target current
func (lp *LoadPoint) rampUpDown(target int64) error {
current := lp.targetCurrent
if current == target {
return nil
}

var step int64
if current < target {
step = min(current+lp.Sensitivity, target)
} else if current > target {
step = max(current-lp.Sensitivity, target)
}

step = clamp(step, lp.MinCurrent, lp.MaxCurrent)

return lp.setTargetCurrent(step)
}

// rampOff disables charger after setting minCurrent. If already disables, this is a nop.
func (lp *LoadPoint) rampOff() error {
if lp.enabled {
if lp.targetCurrent == lp.MinCurrent {
return lp.chargerEnable(false)
}

return lp.setTargetCurrent(lp.MinCurrent)
}

return nil
}

// 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 {
return err
}

return lp.chargerEnable(true)
}

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

0 comments on commit d17289d

Please sign in to comment.