382 lines
9.5 KiB
Go
382 lines
9.5 KiB
Go
package core
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/andig/evcc/api"
|
|
"github.com/andig/evcc/mock"
|
|
"github.com/andig/evcc/push"
|
|
"github.com/andig/evcc/util"
|
|
evbus "github.com/asaskevich/EventBus"
|
|
"github.com/benbjohnson/clock"
|
|
"github.com/golang/mock/gomock"
|
|
)
|
|
|
|
const (
|
|
lpMinCurrent int64 = 6
|
|
lpMaxCurrent int64 = 16
|
|
)
|
|
|
|
type Null struct{}
|
|
|
|
func (n *Null) CurrentPower() (float64, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
func (n *Null) ChargedEnergy() (float64, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
func (n *Null) ChargingTime() (time.Duration, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
func attachListeners(lp *LoadPoint) {
|
|
uiChan := make(chan util.Param)
|
|
pushChan := make(chan push.Event)
|
|
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-uiChan:
|
|
case <-pushChan:
|
|
}
|
|
}
|
|
}()
|
|
|
|
lp.uiChan = uiChan
|
|
lp.pushChan = pushChan
|
|
}
|
|
|
|
func TestNew(t *testing.T) {
|
|
lp := NewLoadPoint(util.NewLogger("foo"))
|
|
|
|
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.Sensitivity != 10 {
|
|
t.Errorf("Sensitivity %v", lp.Sensitivity)
|
|
}
|
|
if lp.status != api.StatusNone {
|
|
t.Errorf("status %v", lp.status)
|
|
}
|
|
if lp.charging {
|
|
t.Errorf("charging %v", lp.charging)
|
|
}
|
|
}
|
|
|
|
func TestUpdate(t *testing.T) {
|
|
tc := []struct {
|
|
status api.ChargeStatus
|
|
mode api.ChargeMode
|
|
expect func(h *mock.MockHandler)
|
|
}{
|
|
{api.StatusA, api.ModeOff, func(h *mock.MockHandler) {
|
|
h.EXPECT().Ramp(int64(0), true)
|
|
}},
|
|
{api.StatusA, api.ModeNow, func(h *mock.MockHandler) {
|
|
h.EXPECT().Ramp(lpMinCurrent, true)
|
|
}},
|
|
{api.StatusA, api.ModeMinPV, func(h *mock.MockHandler) {
|
|
h.EXPECT().Ramp(lpMinCurrent)
|
|
}},
|
|
{api.StatusA, api.ModePV, func(h *mock.MockHandler) {
|
|
h.EXPECT().Ramp(int64(0)) // zero since update called with 0
|
|
// h.EXPECT().Enabled().Return(false) // short-circuited due to status != C
|
|
}},
|
|
|
|
{api.StatusB, api.ModeOff, func(h *mock.MockHandler) {
|
|
h.EXPECT().Ramp(int64(0), true)
|
|
}},
|
|
{api.StatusB, api.ModeNow, func(h *mock.MockHandler) {
|
|
h.EXPECT().Ramp(lpMaxCurrent, true)
|
|
}},
|
|
{api.StatusB, api.ModeMinPV, func(h *mock.MockHandler) {
|
|
h.EXPECT().Ramp(lpMinCurrent) // min since update called with 0
|
|
}},
|
|
{api.StatusB, api.ModePV, func(h *mock.MockHandler) {
|
|
h.EXPECT().Ramp(int64(0)) // zero since update called with 0
|
|
// h.EXPECT().Enabled().Return(false) // short-circuited due to status != C
|
|
}},
|
|
|
|
{api.StatusC, api.ModeOff, func(h *mock.MockHandler) {
|
|
h.EXPECT().Ramp(int64(0), true)
|
|
}},
|
|
{api.StatusC, api.ModeNow, func(h *mock.MockHandler) {
|
|
h.EXPECT().Ramp(lpMaxCurrent, true)
|
|
}},
|
|
{api.StatusC, api.ModeMinPV, func(h *mock.MockHandler) {
|
|
h.EXPECT().Ramp(lpMinCurrent) // min since update called with 0
|
|
}},
|
|
{api.StatusC, api.ModePV, func(h *mock.MockHandler) {
|
|
h.EXPECT().Ramp(int64(0)) // zero since update called with 0
|
|
h.EXPECT().Enabled().Return(false)
|
|
}},
|
|
}
|
|
|
|
for _, tc := range tc {
|
|
t.Log(tc)
|
|
|
|
clck := clock.NewMock()
|
|
ctrl := gomock.NewController(t)
|
|
handler := mock.NewMockHandler(ctrl)
|
|
|
|
lp := &LoadPoint{
|
|
log: util.NewLogger("foo"),
|
|
bus: evbus.New(),
|
|
clock: clck,
|
|
chargeMeter: &Null{}, //silence nil panics
|
|
chargeRater: &Null{}, //silence nil panics
|
|
chargeTimer: &Null{}, //silence nil panics
|
|
HandlerConfig: HandlerConfig{
|
|
MinCurrent: lpMinCurrent,
|
|
MaxCurrent: lpMaxCurrent,
|
|
},
|
|
handler: handler,
|
|
status: tc.status, // no status change
|
|
}
|
|
|
|
attachListeners(lp)
|
|
|
|
handler.EXPECT().Status().Return(tc.status, nil)
|
|
handler.EXPECT().TargetCurrent().Return(int64(0))
|
|
|
|
if tc.status != api.StatusA {
|
|
handler.EXPECT().SyncEnabled()
|
|
}
|
|
|
|
if tc.mode == api.ModeMinPV || tc.mode == api.ModePV {
|
|
handler.EXPECT().TargetCurrent().Return(int64(0))
|
|
}
|
|
|
|
if tc.expect != nil {
|
|
tc.expect(handler)
|
|
}
|
|
|
|
lp.Mode = tc.mode
|
|
lp.Update(0)
|
|
|
|
ctrl.Finish()
|
|
}
|
|
}
|
|
|
|
// func TestConsumedPower(t *testing.T) {
|
|
// tc := []struct {
|
|
// grid, pv, battery, consumed float64
|
|
// }{
|
|
// {0, 0, 0, 0}, // silent night
|
|
// {1, 0, 0, 1}, // grid import
|
|
// {0, 1, 0, 1}, // pv sign ignored
|
|
// {0, -1, 0, 1}, // pv sign ignored
|
|
// {1, 1, 0, 2}, // grid import + pv, pv sign ignored
|
|
// {1, -1, 0, 2}, // grid import + pv, pv sign ignored
|
|
// {0, 0, 1, 1}, // battery discharging
|
|
// {0, 0, -1, -1}, // battery charging -> negative result cannot occur in reality
|
|
// {1, -3, 1, 5}, // grid import + pv + battery discharging
|
|
// {1, -3, -1, 3}, // grid import + pv + battery charging -> should not happen in reality
|
|
// {0, -3, -1, 2}, // pv + battery charging
|
|
// {-1, -4, -1, 2}, // grid export + pv + battery charging
|
|
// {-1, -4, 0, 3}, // grid export + pv
|
|
// }
|
|
|
|
// for _, tc := range tc {
|
|
// res := consumedPower(tc.pv, tc.battery, tc.grid)
|
|
// if res != tc.consumed {
|
|
// t.Errorf("consumedPower wanted %.f, got %.f", tc.consumed, res)
|
|
// }
|
|
// }
|
|
// }
|
|
|
|
func TestPVHysteresisForStatusC(t *testing.T) {
|
|
dt := time.Minute
|
|
type se struct {
|
|
site float64
|
|
delay time.Duration // test case delay since start
|
|
current int64
|
|
}
|
|
tc := []struct {
|
|
enabled bool
|
|
enable, disable float64
|
|
series []se
|
|
}{
|
|
// keep disabled
|
|
{false, 0, 0, []se{
|
|
{0, 0, 0},
|
|
{0, 1, 0},
|
|
{0, dt - 1, 0},
|
|
{0, dt + 1, 0},
|
|
}},
|
|
// enable when threshold not configured but min power met
|
|
{false, 0, 0, []se{
|
|
{-6 * 100 * 10, 0, 0},
|
|
{-6 * 100 * 10, 1, 0},
|
|
{-6 * 100 * 10, dt - 1, 0},
|
|
{-6 * 100 * 10, dt + 1, lpMinCurrent},
|
|
}},
|
|
// keep disabled when threshold not configured
|
|
{false, 0, 0, []se{
|
|
{-400, 0, 0},
|
|
{-400, 1, 0},
|
|
{-400, dt - 1, 0},
|
|
{-400, dt + 1, 0},
|
|
}},
|
|
// keep disabled when threshold not met
|
|
{false, -500, 0, []se{
|
|
{-400, 0, 0},
|
|
{-400, 1, 0},
|
|
{-400, dt - 1, 0},
|
|
{-400, dt + 1, 0},
|
|
}},
|
|
// enable when threshold met
|
|
{false, -500, 0, []se{
|
|
{-500, 0, 0},
|
|
{-500, 1, 0},
|
|
{-500, dt - 1, 0},
|
|
{-500, dt + 1, lpMinCurrent},
|
|
}},
|
|
// keep enabled at max
|
|
{true, 500, 0, []se{
|
|
{-16 * 100 * 10, 0, lpMaxCurrent},
|
|
{-16 * 100 * 10, 1, lpMaxCurrent},
|
|
{-16 * 100 * 10, dt - 1, lpMaxCurrent},
|
|
{-16 * 100 * 10, dt + 1, lpMaxCurrent},
|
|
}},
|
|
// keep enabled at min
|
|
{true, 500, 0, []se{
|
|
{-6 * 100 * 10, 0, lpMinCurrent},
|
|
{-6 * 100 * 10, 1, lpMinCurrent},
|
|
{-6 * 100 * 10, dt - 1, lpMinCurrent},
|
|
{-6 * 100 * 10, dt + 1, lpMinCurrent},
|
|
}},
|
|
// keep enabled at min (negative threshold)
|
|
{true, 0, 500, []se{
|
|
{-500, 0, lpMinCurrent},
|
|
{-500, 1, lpMinCurrent},
|
|
{-500, dt - 1, lpMinCurrent},
|
|
{-500, dt + 1, lpMinCurrent},
|
|
}},
|
|
// disable when threshold met
|
|
{true, 0, 500, []se{
|
|
{500, 0, lpMinCurrent},
|
|
{500, 1, lpMinCurrent},
|
|
{500, dt - 1, lpMinCurrent},
|
|
{500, dt + 1, 0},
|
|
}},
|
|
// reset enable timer when threshold not met while timer active
|
|
{false, -500, 0, []se{
|
|
{-500, 0, 0},
|
|
{-500, 1, 0},
|
|
{-499, dt - 1, 0}, // should reset timer
|
|
{-500, dt + 1, 0}, // new begin of timer
|
|
{-500, 2*dt - 2, 0},
|
|
{-500, 2*dt - 1, lpMinCurrent},
|
|
}},
|
|
// reset enable timer when threshold not met while timer active and threshold not configured
|
|
{false, 0, 0, []se{
|
|
{-6*100*10 - 1, dt + 1, 0},
|
|
{-6 * 100 * 10, dt + 1, 0},
|
|
{-6 * 100 * 10, dt + 2, 0},
|
|
{-6 * 100 * 10, 2 * dt, 0},
|
|
{-6 * 100 * 10, 2*dt + 2, lpMinCurrent},
|
|
}},
|
|
// reset disable timer when threshold not met while timer active
|
|
{true, 0, 500, []se{
|
|
{500, 0, lpMinCurrent},
|
|
{500, 1, lpMinCurrent},
|
|
{499, dt - 1, lpMinCurrent}, // reset timer
|
|
{500, dt + 1, lpMinCurrent}, // within reset timer duration
|
|
{500, 2*dt - 2, lpMinCurrent}, // still within reset timer duration
|
|
{500, 2*dt - 1, 0}, // reset timer elapsed
|
|
}},
|
|
}
|
|
|
|
for _, tc := range tc {
|
|
t.Log(tc)
|
|
|
|
clck := clock.NewMock()
|
|
ctrl := gomock.NewController(t)
|
|
handler := mock.NewMockHandler(ctrl)
|
|
|
|
Voltage = 100
|
|
lp := &LoadPoint{
|
|
log: util.NewLogger("foo"),
|
|
clock: clck,
|
|
HandlerConfig: HandlerConfig{
|
|
MinCurrent: lpMinCurrent,
|
|
MaxCurrent: lpMaxCurrent,
|
|
},
|
|
handler: handler,
|
|
Phases: 10,
|
|
Enable: ThresholdConfig{
|
|
Threshold: tc.enable,
|
|
Delay: dt,
|
|
},
|
|
Disable: ThresholdConfig{
|
|
Threshold: tc.disable,
|
|
Delay: dt,
|
|
},
|
|
}
|
|
|
|
// charging, otherwise PV mode logic is short-circuited
|
|
lp.status = api.StatusC
|
|
|
|
start := clck.Now()
|
|
|
|
for step, se := range tc.series {
|
|
clck.Set(start.Add(se.delay))
|
|
|
|
// 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, se.site)
|
|
|
|
if current != se.current {
|
|
t.Errorf("step %d: wanted %d, got %d", step, se.current, current)
|
|
}
|
|
}
|
|
|
|
ctrl.Finish()
|
|
}
|
|
}
|
|
|
|
func TestPVHysteresisForStatusOtherThanC(t *testing.T) {
|
|
clck := clock.NewMock()
|
|
ctrl := gomock.NewController(t)
|
|
handler := mock.NewMockHandler(ctrl)
|
|
|
|
Voltage = 100
|
|
lp := &LoadPoint{
|
|
log: util.NewLogger("foo"),
|
|
clock: clck,
|
|
HandlerConfig: HandlerConfig{
|
|
MinCurrent: lpMinCurrent,
|
|
MaxCurrent: lpMaxCurrent,
|
|
},
|
|
handler: handler,
|
|
Phases: 10,
|
|
}
|
|
|
|
// 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
|
|
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)
|
|
}
|
|
|
|
ctrl.Finish()
|
|
}
|