From d17289d9ce7e6114e436ea6a8697296eb0d7f2ba Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 6 May 2020 18:32:38 +0200 Subject: [PATCH] Split loadpoint and charger handler (#104) --- cmd/config.go | 3 +- cmd/setup.go | 6 +- core/chargerhandler.go | 143 ++++++++++++++++ core/chargerhandler_test.go | 332 ++++++++++++++++++++++++++++++++++++ core/loadpoint.go | 152 +++-------------- 5 files changed, 503 insertions(+), 133 deletions(-) create mode 100644 core/chargerhandler.go create mode 100644 core/chargerhandler_test.go diff --git a/cmd/config.go b/cmd/config.go index ae40111c7c..285cd93827 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -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 { diff --git a/cmd/setup.go b/cmd/setup.go index feac9fdbe7..693cdc3a90 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -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) } diff --git a/core/chargerhandler.go b/core/chargerhandler.go new file mode 100644 index 0000000000..ea6b3c930e --- /dev/null +++ b/core/chargerhandler.go @@ -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) +} diff --git a/core/chargerhandler_test.go b/core/chargerhandler_test.go new file mode 100644 index 0000000000..48ff588680 --- /dev/null +++ b/core/chargerhandler_test.go @@ -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 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() + } +} diff --git a/core/loadpoint.go b/core/loadpoint.go index 71ed1848d0..cca544ac5f 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -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