Split loadpoint and charger handler (#104)

This commit is contained in:
andig 2020-05-06 18:32:38 +02:00 • committed by GitHub
parent fdfb8dee06
commit d17289d9ce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 503 additions and 133 deletions

View file

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

View file

@ -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
core/chargerhandler.go Normal file
View file

@ -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
core/chargerhandler_test.go Normal file
View file

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

View file

@ -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,13 +66,13 @@ 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
@ -86,17 +80,12 @@ type LoadPoint struct {
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