Redesign phase handling (#2613)

This commit is contained in:
andig 2022-02-28 14:40:06 +01:00 • committed by GitHub
parent bf169061c0
commit 63c74bc0a9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 501 additions and 337 deletions

View file

@ -11,7 +11,7 @@ import (
"github.com/gorilla/mux"
)
//go:generate mockgen -package mock -destination ../mock/mock_api.go github.com/evcc-io/evcc/api Charger,ChargeState,ChargePhases,Identifier,Meter,MeterEnergy,Vehicle,VehiclePhases,ChargeRater,Battery
//go:generate mockgen -package mock -destination ../mock/mock_api.go github.com/evcc-io/evcc/api Charger,ChargeState,ChargePhases,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery
// ChargeMode are charge modes modeled after OpenWB
type ChargeMode string
@ -143,6 +143,7 @@ type Vehicle interface {
Battery
Title() string
Capacity() int64
Phases() int
Identifiers() []string
OnIdentified() ActionConfig
}
@ -172,11 +173,6 @@ type VehiclePosition interface {
Position() (float64, float64, error)
}
// VehiclePhases returns the number of supported phases
type VehiclePhases interface {
Phases() int
}
// VehicleStartCharge starts the charging session on the vehicle side
type VehicleStartCharge interface {
StartCharge() error

View file

@ -116,7 +116,7 @@ type LoadPoint struct {
GuardDuration time.Duration // charger enable/disable minimum holding time
enabled bool // Charger enabled state
activePhases int // Charger active phases as used by vehicle
measuredPhases int // Charger physically measured phases
chargeCurrent float64 // Charger current limit
guardUpdated time.Time // Charger enabled/disabled timestamp
socUpdated time.Time // SoC updated timestamp (poll: connected)
@ -235,9 +235,9 @@ func NewLoadPointFromConfig(log *util.Logger, cp configProvider, other map[strin
lp.charger = cp.Charger(lp.ChargerRef)
lp.configureChargerType(lp.charger)
// ensure 1p setup for switchable charger (https://github.com/evcc-io/evcc/issues/1572)
if _, ok := lp.charger.(api.ChargePhases); ok && lp.Phases != 0 {
lp.log.WARN.Printf("ignoring phases config (%dp) for switchable charger", lp.Phases)
// TODO handle delayed scale-down
if _, ok := lp.charger.(api.ChargePhases); ok && lp.GetPhases() != 0 {
lp.log.WARN.Printf("ignoring phases config (%dp) for switchable charger", lp.GetPhases())
lp.setPhases(0)
}
@ -424,6 +424,9 @@ func (lp *LoadPoint) evVehicleConnectHandler() {
func (lp *LoadPoint) evVehicleDisconnectHandler() {
lp.log.INFO.Println("car disconnected")
// phases are unknown when vehicle disconnects
lp.resetMeasuredPhases()
// energy and duration
lp.publish("chargedEnergy", lp.chargedEnergy)
lp.publish("connectedDuration", lp.clock.Since(lp.connectedTime))
@ -476,7 +479,7 @@ func (lp *LoadPoint) evChargeCurrentHandler(current float64) {
// If physical charge meter is present this handler is not used.
// The actual value is published by the evChargeCurrentHandler
func (lp *LoadPoint) evChargeCurrentWrappedMeterHandler(current float64) {
power := current * float64(lp.activePhases) * Voltage
power := current * float64(lp.activePhases()) * Voltage
if !lp.enabled || lp.GetStatus() != api.StatusC {
// if disabled we cannot be charging
@ -517,9 +520,6 @@ func (lp *LoadPoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
lp.pushChan = pushChan
lp.lpChan = lpChan
// assume all phases are active
lp.activePhases = lp.Phases
// event handlers
_ = lp.bus.Subscribe(evChargeStart, lp.evChargeStartHandler)
_ = lp.bus.Subscribe(evChargeStop, lp.evChargeStopHandler)
@ -533,7 +533,7 @@ func (lp *LoadPoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
lp.publish("minCurrent", lp.MinCurrent)
lp.publish("maxCurrent", lp.MaxCurrent)
lp.publish("phases", lp.Phases)
lp.publish("activePhases", lp.activePhases)
lp.publish("activePhases", lp.activePhases())
lp.publish("hasVehicle", len(lp.vehicles) > 0)
lp.Lock()
@ -815,8 +815,6 @@ func (lp *LoadPoint) setActiveVehicle(vehicle api.Vehicle) {
lp.applyAction(vehicle.OnIdentified())
lp.setVehiclePhases()
lp.progress.Reset()
} else {
lp.socEstimator = nil
@ -983,67 +981,36 @@ func (lp *LoadPoint) resetPVTimerIfRunning(typ ...string) {
lp.publishTimer(pvTimer, 0, timerInactive)
}
// setVehiclePhases sets the expected active phases by the vehicle
func (lp *LoadPoint) setVehiclePhases() {
if v, ok := lp.vehicle.(api.VehiclePhases); ok {
if phases := v.Phases(); phases > 0 {
lp.log.DEBUG.Printf("vehicle phases: %dp", phases)
lp.Lock()
defer lp.Unlock()
if phases > lp.Phases {
phases = lp.Phases
}
lp.activePhases = phases
lp.publish("activePhases", lp.activePhases)
}
}
}
// scalePhasesIfAvailable scales if api.ChargePhases is available
func (lp *LoadPoint) scalePhasesIfAvailable(phases int) error {
err := lp.scalePhases(phases)
if errors.Is(err, api.ErrNotAvailable) {
return nil
if _, ok := lp.charger.(api.ChargePhases); ok {
return lp.scalePhases(phases)
}
return err
return nil
}
// setPhases sets the number of enabled phases without modifying the charger
func (lp *LoadPoint) setPhases(phases int) {
lp.Lock()
defer lp.Unlock()
if lp.Phases != phases {
if lp.GetPhases() != phases {
lp.Lock()
lp.Phases = phases
lp.publish("phases", lp.Phases)
lp.phaseTimer = time.Time{}
lp.Unlock()
if phases < lp.activePhases {
// When scaling down, charger will temporarily disable. During this time, activePhases will not be updated
// since all currents are zero. This will lead to inconsistent state when scaling is triggered again
// (1p configured vs 3p active). Update activePhases to reflect the current state of the charger.
lp.activePhases = phases
lp.publish("activePhases", lp.activePhases)
} else {
// When scaling up, charger will offer more phases than vehicle can use.
// Adjust to enable subsequent PV restart at lower powers than full 3p.
lp.setVehiclePhases()
}
lp.publish("phases", lp.Phases)
lp.publishTimer(phaseTimer, 0, timerInactive)
lp.resetMeasuredPhases()
}
}
// scalePhases adjusts the number of active phases and returns the appropriate charging current.
// Returns api.ErrNotAvailable if api.ChargePhases is not available.
func (lp *LoadPoint) scalePhases(phases int) error {
if phases != 1 && phases != 3 {
return fmt.Errorf("invalid number of phases: %d", phases)
}
cp, ok := lp.charger.(api.ChargePhases)
if !ok {
return api.ErrNotAvailable
panic("charger does not implement api.ChargePhases")
}
if lp.GetPhases() != phases {
@ -1060,9 +1027,6 @@ func (lp *LoadPoint) scalePhases(phases int) error {
// update setting
lp.setPhases(phases)
// disable phase timer
lp.phaseTimer = time.Time{}
// allow pv mode to re-enable charger right away
lp.elapsePVTimer()
}
@ -1074,29 +1038,22 @@ func (lp *LoadPoint) scalePhases(phases int) error {
func (lp *LoadPoint) pvScalePhases(availablePower, minCurrent, maxCurrent float64) bool {
phases := lp.GetPhases()
// observed phase state inconsistency (https://github.com/evcc-io/evcc/issues/1572, https://github.com/evcc-io/evcc/issues/2230)
if phases > 0 && phases < lp.activePhases {
lp.log.WARN.Printf("ignoring inconsistent phases: %dp < %dp observed active", phases, lp.activePhases)
// if 3p->1p change is slow and we're no longer charging, we'll correct the observed phases here
if lp.GetStatus() == api.StatusB {
lp.activePhases = 1
}
}
// this can happen the first time for a 1p3p-capable charger, see https://github.com/evcc-io/evcc/issues/2520
if phases == 0 && lp.activePhases == 0 {
lp.log.DEBUG.Printf("assuming initial phase state: 3p")
lp.phaseTimer = elapsed
lp.activePhases = 3
// observed phase state inconsistency
// - https://github.com/evcc-io/evcc/issues/1572
// - https://github.com/evcc-io/evcc/issues/2230
// - https://github.com/evcc-io/evcc/issues/2613
measuredPhases := lp.getMeasuredPhases()
if phases > 0 && phases < measuredPhases {
lp.log.WARN.Printf("ignoring inconsistent phases: %dp < %dp observed active", phases, measuredPhases)
}
var waiting bool
targetCurrent := powerToCurrent(availablePower, lp.activePhases)
activePhases := lp.activePhases()
targetCurrent := powerToCurrent(availablePower, activePhases)
// scale down phases
if targetCurrent < minCurrent && (phases == 0 || phases == 3) && lp.activePhases > 1 {
lp.log.DEBUG.Printf("available power below %dp min threshold of %.0fW", lp.activePhases, float64(lp.activePhases)*Voltage*minCurrent)
if targetCurrent < minCurrent && activePhases > 1 {
lp.log.DEBUG.Printf("available power below %dp min threshold of %.0fW", activePhases, float64(activePhases)*Voltage*minCurrent)
if lp.phaseTimer.IsZero() {
lp.log.DEBUG.Printf("start phase disable timer: %v", lp.Disable.Delay)
@ -1120,8 +1077,11 @@ func (lp *LoadPoint) pvScalePhases(availablePower, minCurrent, maxCurrent float6
lp.log.DEBUG.Printf("phase disable timer remaining: %v", (lp.Disable.Delay - elapsed).Round(time.Second))
}
maxPhases := lp.maxActivePhases()
scalable := maxPhases > 1 && phases < maxPhases
// scale up phases
if min3pCurrent := powerToCurrent(availablePower, 3); min3pCurrent >= minCurrent && (phases == 0 || phases == 1) {
if min3pCurrent := powerToCurrent(availablePower, 3); min3pCurrent >= minCurrent && scalable {
lp.log.DEBUG.Printf("available power above 3p min threshold of %.0fW", 3*Voltage*minCurrent)
if lp.phaseTimer.IsZero() {
@ -1129,10 +1089,10 @@ func (lp *LoadPoint) pvScalePhases(availablePower, minCurrent, maxCurrent float6
lp.phaseTimer = lp.clock.Now()
}
lp.publishTimer(phaseTimer, lp.Disable.Delay, phaseScale3p)
lp.publishTimer(phaseTimer, lp.Enable.Delay, phaseScale3p)
elapsed := lp.clock.Since(lp.phaseTimer)
if elapsed >= lp.Disable.Delay {
if elapsed >= lp.Enable.Delay {
lp.log.DEBUG.Println("phase enable timer elapsed")
if err := lp.scalePhases(3); err == nil {
lp.log.DEBUG.Printf("switched phases: 3p @ %.0fW", availablePower)
@ -1157,6 +1117,7 @@ func (lp *LoadPoint) pvScalePhases(availablePower, minCurrent, maxCurrent float6
return false
}
// TODO move up to timer functions
func (lp *LoadPoint) publishTimer(name string, delay time.Duration, action string) {
timer := lp.pvTimer
if name == phaseTimer {
@ -1196,10 +1157,11 @@ func (lp *LoadPoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64, batter
// calculate target charge current from delta power and actual current
effectiveCurrent := lp.effectiveCurrent()
deltaCurrent := powerToCurrent(-sitePower, lp.activePhases)
activePhases := lp.activePhases()
deltaCurrent := powerToCurrent(-sitePower, activePhases)
targetCurrent := math.Max(effectiveCurrent+deltaCurrent, 0)
lp.log.DEBUG.Printf("max charge current: %.3gA = %.3gA + %.3gA (%.0fW @ %dp)", targetCurrent, effectiveCurrent, deltaCurrent, sitePower, lp.activePhases)
lp.log.DEBUG.Printf("max charge current: %.3gA = %.3gA + %.3gA (%.0fW @ %dp)", targetCurrent, effectiveCurrent, deltaCurrent, sitePower, activePhases)
// in MinPV mode or under special conditions return at least minCurrent
if (mode == api.ModeMinPV || batteryBuffered || lp.climateActive()) && targetCurrent < minCurrent {
@ -1337,9 +1299,12 @@ func (lp *LoadPoint) updateChargeCurrents() {
}
if phases >= 1 {
lp.activePhases = phases
lp.log.DEBUG.Printf("detected phases: %dp %.3gA", lp.activePhases, lp.chargeCurrents)
lp.publish("activePhases", lp.activePhases)
lp.Lock()
lp.measuredPhases = phases
lp.Unlock()
lp.log.DEBUG.Printf("detected phases: %dp", phases)
lp.publish("activePhases", phases)
}
}
}

View file

@ -1,6 +1,7 @@
package core
import (
"fmt"
"time"
"github.com/evcc-io/evcc/api"
@ -106,11 +107,16 @@ func (lp *LoadPoint) GetPhases() int {
// SetPhases sets loadpoint enabled phases
func (lp *LoadPoint) SetPhases(phases int) error {
if _, ok := lp.charger.(api.ChargePhases); !ok {
lp.setPhases(phases)
return nil
if phases != 1 && phases != 3 {
return fmt.Errorf("invalid number of phases: %d", phases)
}
return lp.scalePhases(phases)
if _, ok := lp.charger.(api.ChargePhases); ok {
return lp.scalePhases(phases)
}
lp.setPhases(phases)
return nil
}
// SetTargetCharge sets loadpoint charge targetSoC
@ -209,9 +215,9 @@ func (lp *LoadPoint) GetMinPower() float64 {
return Voltage * lp.GetMinCurrent()
}
// GetMaxPower returns the max loadpoint power taking active phases into account
// GetMaxPower returns the max loadpoint power taking vehicle capabilities and phase scaling into account
func (lp *LoadPoint) GetMaxPower() float64 {
return Voltage * lp.GetMaxCurrent() * float64(lp.GetPhases())
return Voltage * lp.GetMaxCurrent() * float64(lp.maxActivePhases())
}
// setRemainingDuration sets the estimated remaining charging duration

80
core/loadpoint_phases.go Normal file
View file

@ -0,0 +1,80 @@
package core
import (
"math"
"github.com/evcc-io/evcc/api"
)
// resetMeasuredPhases resets measured phases to unknown on vehicle disconnect, phase switch or phase api call
func (lp *LoadPoint) resetMeasuredPhases() {
lp.Lock()
lp.measuredPhases = 0
lp.Unlock()
lp.publish("activePhases", lp.activePhases())
}
// getMeasuredPhases provides synchronized access to measuredPhases
func (lp *LoadPoint) getMeasuredPhases() int {
lp.Lock()
defer lp.Unlock()
return lp.measuredPhases
}
// assume 3p for switchable charger during startup
const unknownPhases = 3
func min(i ...int) int {
v := math.MaxInt
for _, i := range i {
if i < v {
v = i
}
}
return v
}
func expect(phases int) int {
if phases > 0 {
return phases
}
return unknownPhases
}
// activePhases returns the number of expectedly active phases for the meter.
// If unknown for 1p3p chargers during startup it will assume 3p.
func (lp *LoadPoint) activePhases() int {
physical := lp.GetPhases()
vehicle := lp.getVehiclePhases()
measured := lp.getMeasuredPhases()
return min(expect(vehicle), expect(physical), expect(measured))
}
// maxActivePhases returns the maximum number of active phases for the meter.
func (lp *LoadPoint) maxActivePhases() int {
physical := lp.GetPhases()
measured := lp.getMeasuredPhases()
vehicle := lp.getVehiclePhases()
// during 1p or unknown config, 1p measured is not a restriction
if physical <= 1 || vehicle == 1 {
measured = 0
}
// if 1p3p supported then assume 3p
if _, ok := lp.charger.(api.ChargePhases); ok {
physical = 3
}
return min(expect(vehicle), expect(physical), expect(measured))
}
func (lp *LoadPoint) getVehiclePhases() int {
if lp.vehicle != nil {
return lp.vehicle.Phases()
}
return 0
}

View file

@ -0,0 +1,296 @@
package core
import (
"strings"
"testing"
"time"
evbus "github.com/asaskevich/EventBus"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/mock"
"github.com/evcc-io/evcc/util"
"github.com/golang/mock/gomock"
)
type testCase struct {
// capable=0 signals 1p3p as set during loadpoint init
// physical/vehicle=0 signals unknown
// measuredPhases<>0 signals previous measurement
capable, physical, vehicle, measuredPhases, actExpected, maxExpected int
// scaling expectation: d=down, u=up, du=both
scale string
}
var (
phaseTests = []testCase{
// 1p
{1, 1, 0, 0, 1, 1, ""},
{1, 1, 0, 1, 1, 1, ""},
{1, 1, 1, 0, 1, 1, ""},
{1, 1, 2, 0, 1, 1, ""},
{1, 1, 3, 0, 1, 1, ""},
// 3p
{3, 3, 0, 0, unknownPhases, 3, ""},
{3, 3, 0, 1, 1, 1, ""},
{3, 3, 0, 2, 2, 2, ""},
{3, 3, 0, 3, 3, 3, ""},
{3, 3, 1, 0, 1, 1, ""},
{3, 3, 2, 0, 2, 2, ""},
{3, 3, 3, 0, 3, 3, ""},
// 1p3p initial
{0, 0, 0, 0, unknownPhases, 3, "du"},
{0, 0, 0, 1, 1, 3, "u"},
{0, 0, 0, 2, 2, 3, "du"},
{0, 0, 0, 3, 3, 3, "du"},
{0, 0, 1, 0, 1, 1, ""},
{0, 0, 2, 0, 2, 2, "du"},
{0, 0, 3, 0, 3, 3, "du"},
// 1p3p, 1 currently active
{0, 1, 0, 0, 1, 3, "u"},
{0, 1, 0, 1, 1, 3, "u"},
// {0, 1, 0, 2, 2,2,"u"}, // 2p active > 1p configured must not happen
// {0, 1, 0, 3, 3,3,"u"}, // 3p active > 1p configured must not happen
{0, 1, 1, 0, 1, 1, ""},
{0, 1, 2, 0, 1, 2, "u"},
{0, 1, 3, 0, 1, 3, "u"},
// 1p3p, 3 currently active
{0, 3, 0, 0, unknownPhases, 3, "d"},
{0, 3, 0, 1, 1, 1, ""},
{0, 3, 0, 2, 2, 2, "d"},
{0, 3, 0, 3, 3, 3, "d"},
{0, 3, 1, 0, 1, 1, ""},
{0, 3, 2, 0, 2, 2, "d"},
{0, 3, 3, 0, 3, 3, "d"},
}
)
func testScale(t *testing.T, lp *LoadPoint, power float64, direction string, tc testCase) {
act := lp.activePhases()
max := lp.maxActivePhases()
scaled := lp.pvScalePhases(power, minA, maxA)
if strings.Contains(tc.scale, direction[0:1]) {
if !scaled {
t.Errorf("%v act=%d max=%d missing scale %s", tc, act, max, direction)
}
} else if scaled {
t.Errorf("%v act=%d max=%d unexpected scale %s", tc, act, max, direction)
}
}
func TestPvScalePhases(t *testing.T) {
clock := clock.NewMock()
ctrl := gomock.NewController(t)
for _, tc := range phaseTests {
t.Log(tc)
plainCharger := mock.NewMockCharger(ctrl)
plainCharger.EXPECT().Enabled().Return(true, nil)
plainCharger.EXPECT().MaxCurrent(int64(minA)).Return(nil) // MaxCurrentEx not implemented
// 1p3p
var phaseCharger *mock.MockChargePhases
if tc.capable == 0 {
phaseCharger = mock.NewMockChargePhases(ctrl)
}
vehicle := mock.NewMockVehicle(ctrl)
vehicle.EXPECT().Phases().Return(tc.vehicle).MinTimes(1)
lp := &LoadPoint{
log: util.NewLogger("foo"),
bus: evbus.New(),
clock: clock,
chargeMeter: &Null{}, // silence nil panics
chargeRater: &Null{}, // silence nil panics
chargeTimer: &Null{}, // silence nil panics
progress: NewProgress(0, 10), // silence nil panics
wakeUpTimer: NewTimer(), // silence nil panics
Mode: api.ModeNow,
MinCurrent: minA,
MaxCurrent: maxA,
vehicle: vehicle,
Phases: tc.physical,
}
if phaseCharger != nil {
lp.charger = struct {
*mock.MockCharger
*mock.MockChargePhases
}{
plainCharger, phaseCharger,
}
} else {
lp.charger = struct {
*mock.MockCharger
}{
plainCharger,
}
}
attachListeners(t, lp)
lp.measuredPhases = tc.measuredPhases
if tc.measuredPhases > 0 && tc.vehicle > 0 {
t.Fatalf("%v invalid test case", tc)
}
if lp.Phases != tc.physical {
t.Error("wrong phases", lp.Phases, tc.physical)
}
if phs := lp.activePhases(); phs != tc.actExpected {
t.Errorf("expected active %d, got %d", tc.actExpected, phs)
}
if phs := lp.maxActivePhases(); phs != tc.maxExpected {
t.Errorf("expected max %d, got %d", tc.maxExpected, phs)
}
ctrl.Finish()
// scaling
if phaseCharger != nil {
// scale down
min1p := 1 * minA * Voltage
lp.phaseTimer = time.Time{}
plainCharger.EXPECT().Enable(false).Return(nil).MaxTimes(1)
phaseCharger.EXPECT().Phases1p3p(1).Return(nil).MaxTimes(1)
testScale(t, lp, min1p, "down", tc)
ctrl.Finish()
// scale up
min3p := 3 * minA * Voltage
lp.phaseTimer = time.Time{}
lp.Phases = tc.physical // reset to initial state
plainCharger.EXPECT().Enable(false).Return(nil).MaxTimes(1)
phaseCharger.EXPECT().Phases1p3p(3).Return(nil).MaxTimes(1)
testScale(t, lp, min3p, "up", tc)
ctrl.Finish()
}
}
}
func TestPvScalePhasesTimer(t *testing.T) {
ctrl := gomock.NewController(t)
charger := &struct {
*mock.MockCharger
*mock.MockChargePhases
}{
mock.NewMockCharger(ctrl),
mock.NewMockChargePhases(ctrl),
}
dt := time.Minute
Voltage = 230 // V
tc := []struct {
desc string
phases, measuredPhases int
availablePower float64
toPhases int
res bool
prepare func(lp *LoadPoint)
}{
// switch up from 1p/1p configured/active
{"1/1->3, not enough power", 1, 1, 0, 1, false, nil},
{"1/1->3, kickoff", 1, 1, 3 * Voltage * minA, 1, false, func(lp *LoadPoint) {
lp.phaseTimer = time.Time{}
}},
{"1/1->3, timer running", 1, 1, 3 * Voltage * minA, 1, false, func(lp *LoadPoint) {
lp.phaseTimer = lp.clock.Now()
}},
{"1/1->3, timer elapsed", 1, 1, 3 * Voltage * minA, 3, true, func(lp *LoadPoint) {
lp.phaseTimer = lp.clock.Now().Add(-dt)
}},
// omit to switch up (again) from 3p/1p configured/active
{"3/1->3, not enough power", 3, 1, 0, 3, false, nil},
{"3/1->3, kickoff", 3, 1, 3 * Voltage * minA, 3, false, func(lp *LoadPoint) {
lp.phaseTimer = time.Time{}
}},
{"3/1->3, timer running", 3, 1, 3 * Voltage * minA, 3, false, func(lp *LoadPoint) {
lp.phaseTimer = lp.clock.Now()
}},
{"3/1->3, timer elapsed", 3, 1, 3 * Voltage * minA, 3, false, func(lp *LoadPoint) {
lp.phaseTimer = lp.clock.Now().Add(-dt)
}},
// omit to switch down from 3p/1p configured/active
{"3/1->1, not enough power", 3, 1, 0, 3, false, nil},
{"3/1->1, kickoff", 3, 1, 1 * Voltage * minA, 3, false, func(lp *LoadPoint) {
lp.phaseTimer = time.Time{}
}},
{"3/1->1, timer running", 3, 1, 1 * Voltage * minA, 3, false, func(lp *LoadPoint) {
lp.phaseTimer = lp.clock.Now()
}},
{"3/1->1, timer elapsed", 3, 1, 1 * Voltage * minA, 3, false, func(lp *LoadPoint) {
lp.phaseTimer = lp.clock.Now().Add(-dt)
}},
// switch down from 3p/3p configured/active
{"3/3->1, enough power", 3, 3, 1 * Voltage * maxA, 3, false, nil},
{"3/3->1, kickoff", 3, 3, 1 * Voltage * maxA, 3, false, func(lp *LoadPoint) {
lp.phaseTimer = time.Time{}
}},
{"3/3->1, timer running", 3, 3, 1 * Voltage * maxA, 3, false, func(lp *LoadPoint) {
lp.phaseTimer = lp.clock.Now()
}},
{"3/3->1, timer elapsed", 3, 3, 1 * Voltage * maxA, 1, true, func(lp *LoadPoint) {
lp.phaseTimer = lp.clock.Now().Add(-dt)
}},
// error states from 1p/3p misconfig - no correction for time being (stay at 1p)
{"1/3->1, enough power", 1, 3, 1 * Voltage * maxA, 1, false, nil},
{"1/3->1, kickoff, correct phase setting", 1, 3, 1 * Voltage * maxA, 1, false, func(lp *LoadPoint) {
lp.phaseTimer = time.Time{}
}},
{"1/3->1, timer running, correct phase setting", 1, 3, 1 * Voltage * maxA, 1, false, func(lp *LoadPoint) {
lp.phaseTimer = lp.clock.Now()
}},
{"1/3->1, switch not executed", 1, 3, 1 * Voltage * maxA, 1, false, func(lp *LoadPoint) {
lp.phaseTimer = lp.clock.Now().Add(-dt)
}},
}
for _, tc := range tc {
t.Logf("%+v", tc)
clock := clock.NewMock()
lp := &LoadPoint{
log: util.NewLogger("foo"),
clock: clock,
charger: charger,
MinCurrent: minA,
MaxCurrent: maxA,
Phases: tc.phases,
measuredPhases: tc.measuredPhases,
Enable: ThresholdConfig{
Delay: dt,
},
Disable: ThresholdConfig{
Delay: dt,
},
}
if tc.prepare != nil {
tc.prepare(lp)
}
if tc.res {
charger.MockChargePhases.EXPECT().Phases1p3p(tc.toPhases).Return(nil)
}
if res := lp.pvScalePhases(tc.availablePower, minA, maxA); tc.res != res {
t.Errorf("expected %v, got %v", tc.res, res)
} else {
if lp.Phases != tc.toPhases {
t.Errorf("expected %dp, got %dp", tc.toPhases, lp.Phases)
}
}
}
}

View file

@ -176,7 +176,8 @@ func TestUpdatePowerZero(t *testing.T) {
}
func TestPVHysteresis(t *testing.T) {
dt := time.Minute
const dt = time.Minute
const phases = 3
type se struct {
site float64
delay time.Duration // test case delay since start
@ -196,10 +197,10 @@ func TestPVHysteresis(t *testing.T) {
}},
// 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, minA},
{-6 * 100 * phases, 0, 0},
{-6 * 100 * phases, 1, 0},
{-6 * 100 * phases, dt - 1, 0},
{-6 * 100 * phases, dt + 1, minA},
}},
// keep disabled when threshold not configured
{false, 0, 0, []se{
@ -216,11 +217,11 @@ func TestPVHysteresis(t *testing.T) {
{-400, dt + 1, 0},
}},
// keep disabled when threshold (higher minCurrent) not met
{false, -7 * 100 * 10, 0, []se{
{-6 * 100 * 10, 0, 0},
{-6 * 100 * 10, 1, 0},
{-6 * 100 * 10, dt - 1, 0},
{-6 * 100 * 10, dt + 1, 0},
{false, -7 * 100 * phases, 0, []se{
{-6 * 100 * phases, 0, 0},
{-6 * 100 * phases, 1, 0},
{-6 * 100 * phases, dt - 1, 0},
{-6 * 100 * phases, dt + 1, 0},
}},
// enable when threshold met
{false, -500, 0, []se{
@ -231,17 +232,17 @@ func TestPVHysteresis(t *testing.T) {
}},
// keep enabled at max
{true, 500, 0, []se{
{-16 * 100 * 10, 0, maxA},
{-16 * 100 * 10, 1, maxA},
{-16 * 100 * 10, dt - 1, maxA},
{-16 * 100 * 10, dt + 1, maxA},
{-16 * 100 * phases, 0, maxA},
{-16 * 100 * phases, 1, maxA},
{-16 * 100 * phases, dt - 1, maxA},
{-16 * 100 * phases, dt + 1, maxA},
}},
// keep enabled at min
{true, 500, 0, []se{
{-6 * 100 * 10, 0, minA},
{-6 * 100 * 10, 1, minA},
{-6 * 100 * 10, dt - 1, minA},
{-6 * 100 * 10, dt + 1, minA},
{-6 * 100 * phases, 0, minA},
{-6 * 100 * phases, 1, minA},
{-6 * 100 * phases, dt - 1, minA},
{-6 * 100 * phases, dt + 1, minA},
}},
// keep enabled at min (negative threshold)
{true, 0, 500, []se{
@ -269,10 +270,10 @@ func TestPVHysteresis(t *testing.T) {
// 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 + 1, minA},
{-6 * 100 * phases, dt + 1, 0},
{-6 * 100 * phases, dt + 2, 0},
{-6 * 100 * phases, 2 * dt, 0},
{-6 * 100 * phases, 2*dt + 1, minA},
}},
// reset disable timer when threshold not met while timer active
{true, 0, 500, []se{
@ -296,13 +297,13 @@ func TestPVHysteresis(t *testing.T) {
Voltage = 100
lp := &LoadPoint{
log: util.NewLogger("foo"),
clock: clck,
charger: charger,
MinCurrent: minA,
MaxCurrent: maxA,
Phases: 10,
activePhases: 10,
log: util.NewLogger("foo"),
clock: clck,
charger: charger,
MinCurrent: minA,
MaxCurrent: maxA,
Phases: phases,
measuredPhases: phases,
Enable: ThresholdConfig{
Threshold: tc.enable,
Delay: dt,
@ -338,24 +339,26 @@ func TestPVHysteresis(t *testing.T) {
}
func TestPVHysteresisForStatusOtherThanC(t *testing.T) {
const phases = 3
clck := clock.NewMock()
ctrl := gomock.NewController(t)
Voltage = 100
lp := &LoadPoint{
log: util.NewLogger("foo"),
clock: clck,
MinCurrent: minA,
MaxCurrent: maxA,
Phases: 10,
activePhases: 10,
log: util.NewLogger("foo"),
clock: clck,
MinCurrent: minA,
MaxCurrent: maxA,
Phases: phases,
measuredPhases: phases,
}
// not connected, test PV mode logic short-circuited
lp.status = api.StatusA
// maxCurrent will read enabled state in PV mode
sitePower := -float64(lp.Phases)*minA*Voltage + 1 // 1W below min power
sitePower := -float64(phases)*minA*Voltage + 1 // 1W below min power
current := lp.pvMaxCurrent(api.ModePV, sitePower, false)
if current != 0 {
@ -373,6 +376,7 @@ func TestDisableAndEnableAtTargetSoC(t *testing.T) {
// wrap vehicle with estimator
vehicle.EXPECT().Capacity().Return(int64(10))
vehicle.EXPECT().Phases().Return(0).AnyTimes()
socEstimator := soc.NewEstimator(util.NewLogger("foo"), charger, vehicle, false)
lp := &LoadPoint{
@ -817,170 +821,3 @@ func TestVehicleDetectByID(t *testing.T) {
}
}
}
func TestScalePhases(t *testing.T) {
ctrl := gomock.NewController(t)
charger := &struct {
*mock.MockCharger
*mock.MockChargePhases
}{
mock.NewMockCharger(ctrl),
mock.NewMockChargePhases(ctrl),
}
dt := time.Minute
Voltage = 230 // V
tc := []struct {
desc string
phases, activePhases int
availablePower float64
toPhases int
res bool
prepare func(lp *LoadPoint)
}{
// switch up from 1p/1p configured/active
{"1/1->3, not enough power", 1, 1, 0, 1, false, nil},
{"1/1->3, kickoff", 1, 1, 3 * Voltage * minA, 1, false, func(lp *LoadPoint) {
lp.phaseTimer = time.Time{}
}},
{"1/1->3, timer running", 1, 1, 3 * Voltage * minA, 1, false, func(lp *LoadPoint) {
lp.phaseTimer = lp.clock.Now()
}},
{"1/1->3, timer elapsed", 1, 1, 3 * Voltage * minA, 3, true, func(lp *LoadPoint) {
lp.phaseTimer = lp.clock.Now().Add(-dt)
}},
// omit to switch up (again) from 3p/1p configured/active
{"3/1->3, not enough power", 3, 1, 0, 3, false, nil},
{"3/1->3, kickoff", 3, 1, 3 * Voltage * minA, 3, false, func(lp *LoadPoint) {
lp.phaseTimer = time.Time{}
}},
{"3/1->3, timer running", 3, 1, 3 * Voltage * minA, 3, false, func(lp *LoadPoint) {
lp.phaseTimer = lp.clock.Now()
}},
{"3/1->3, timer elapsed", 3, 1, 3 * Voltage * minA, 3, false, func(lp *LoadPoint) {
lp.phaseTimer = lp.clock.Now().Add(-dt)
}},
// omit to switch down from 3p/1p configured/active
{"3/1->1, not enough power", 3, 1, 0, 3, false, nil},
{"3/1->1, kickoff", 3, 1, 1 * Voltage * minA, 3, false, func(lp *LoadPoint) {
lp.phaseTimer = time.Time{}
}},
{"3/1->1, timer running", 3, 1, 1 * Voltage * minA, 3, false, func(lp *LoadPoint) {
lp.phaseTimer = lp.clock.Now()
}},
{"3/1->1, timer elapsed", 3, 1, 1 * Voltage * minA, 3, false, func(lp *LoadPoint) {
lp.phaseTimer = lp.clock.Now().Add(-dt)
}},
// switch down from 3p/3p configured/active
{"3/3->1, enough power", 3, 3, 1 * Voltage * maxA, 3, false, nil},
{"3/3->1, kickoff", 3, 3, 1 * Voltage * maxA, 3, false, func(lp *LoadPoint) {
lp.phaseTimer = time.Time{}
}},
{"3/3->1, timer running", 3, 3, 1 * Voltage * maxA, 3, false, func(lp *LoadPoint) {
lp.phaseTimer = lp.clock.Now()
}},
{"3/3->1, timer elapsed", 3, 3, 1 * Voltage * maxA, 1, true, func(lp *LoadPoint) {
lp.phaseTimer = lp.clock.Now().Add(-dt)
}},
// error states from 1p/3p misconfig - no correction for time being (stay at 1p)
{"1/3->1, enough power", 1, 3, 1 * Voltage * maxA, 1, false, nil},
{"1/3->1, kickoff, correct phase setting", 1, 3, 1 * Voltage * maxA, 1, false, func(lp *LoadPoint) {
lp.phaseTimer = time.Time{}
}},
{"1/3->1, timer running, correct phase setting", 1, 3, 1 * Voltage * maxA, 1, false, func(lp *LoadPoint) {
lp.phaseTimer = lp.clock.Now()
}},
{"1/3->1, switch not executed", 1, 3, 1 * Voltage * maxA, 1, false, func(lp *LoadPoint) {
lp.phaseTimer = lp.clock.Now().Add(-dt)
}},
}
for _, tc := range tc {
t.Logf("%+v", tc)
clock := clock.NewMock()
lp := &LoadPoint{
log: util.NewLogger("foo"),
clock: clock,
charger: charger,
MinCurrent: minA,
MaxCurrent: maxA,
Phases: tc.phases,
activePhases: tc.activePhases,
Enable: ThresholdConfig{
Delay: dt,
},
Disable: ThresholdConfig{
Delay: dt,
},
}
if tc.prepare != nil {
tc.prepare(lp)
}
if tc.res {
charger.MockChargePhases.EXPECT().Phases1p3p(tc.toPhases).Return(nil)
}
if res := lp.pvScalePhases(tc.availablePower, minA, maxA); tc.res != res {
t.Errorf("expected %v, got %v", tc.res, res)
} else {
if lp.Phases != tc.toPhases {
t.Errorf("expected %dp, got %dp", tc.toPhases, lp.Phases)
}
}
}
}
func TestVehiclePhases(t *testing.T) {
ctrl := gomock.NewController(t)
vehicle := &struct {
*mock.MockVehicle
*mock.MockVehiclePhases
}{
mock.NewMockVehicle(ctrl),
mock.NewMockVehiclePhases(ctrl),
}
tc := []struct {
phases, activePhases, vehiclePhases int
res int
}{
{1, 1, 0, 1}, // leave as-is
{3, 1, 0, 1}, // leave as-is
{3, 3, 0, 3}, // leave as-is
{1, 1, 1, 1}, // leave as-is
{3, 1, 1, 1}, // leave as-is
{3, 3, 1, 1}, // limit to 1p
{1, 1, 2, 1}, // leave as-is
{3, 1, 2, 2}, // limit to 2p
{3, 3, 2, 2}, // limit to 2p
{1, 1, 3, 1}, // leave as-is
{3, 1, 3, 3}, // limit to 3p
{3, 3, 3, 3}, // leave as-is
}
for _, tc := range tc {
t.Logf("%+v", tc)
lp := &LoadPoint{
log: util.NewLogger("foo"),
vehicle: vehicle,
Phases: tc.phases,
activePhases: tc.activePhases,
}
vehicle.MockVehiclePhases.EXPECT().Phases().Return(tc.vehiclePhases)
lp.setVehiclePhases()
if lp.activePhases != tc.res {
t.Errorf("expected %v, got %v", tc.res, lp.activePhases)
}
}
}

View file

@ -1,5 +1,5 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,ChargePhases,Identifier,Meter,MeterEnergy,Vehicle,VehiclePhases,ChargeRater,Battery)
// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,ChargePhases,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery)
// Package mock is a generated GoMock package.
package mock
@ -346,6 +346,20 @@ func (mr *MockVehicleMockRecorder) OnIdentified() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnIdentified", reflect.TypeOf((*MockVehicle)(nil).OnIdentified))
}
// Phases mocks base method.
func (m *MockVehicle) Phases() int {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Phases")
ret0, _ := ret[0].(int)
return ret0
}
// Phases indicates an expected call of Phases.
func (mr *MockVehicleMockRecorder) Phases() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Phases", reflect.TypeOf((*MockVehicle)(nil).Phases))
}
// SoC mocks base method.
func (m *MockVehicle) SoC() (float64, error) {
m.ctrl.T.Helper()
@ -375,43 +389,6 @@ func (mr *MockVehicleMockRecorder) Title() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Title", reflect.TypeOf((*MockVehicle)(nil).Title))
}
// MockVehiclePhases is a mock of VehiclePhases interface.
type MockVehiclePhases struct {
ctrl *gomock.Controller
recorder *MockVehiclePhasesMockRecorder
}
// MockVehiclePhasesMockRecorder is the mock recorder for MockVehiclePhases.
type MockVehiclePhasesMockRecorder struct {
mock *MockVehiclePhases
}
// NewMockVehiclePhases creates a new mock instance.
func NewMockVehiclePhases(ctrl *gomock.Controller) *MockVehiclePhases {
mock := &MockVehiclePhases{ctrl: ctrl}
mock.recorder = &MockVehiclePhasesMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockVehiclePhases) EXPECT() *MockVehiclePhasesMockRecorder {
return m.recorder
}
// Phases mocks base method.
func (m *MockVehiclePhases) Phases() int {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Phases")
ret0, _ := ret[0].(int)
return ret0
}
// Phases indicates an expected call of Phases.
func (mr *MockVehiclePhasesMockRecorder) Phases() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Phases", reflect.TypeOf((*MockVehiclePhases)(nil).Phases))
}
// MockChargeRater is a mock of ChargeRater interface.
type MockChargeRater struct {
ctrl *gomock.Controller

View file

@ -20,6 +20,8 @@ func New(w api.Vehicle, err error) (api.Vehicle, error) {
return v, nil
}
var _ api.Vehicle = (*Wrapper)(nil)
// Title implements the api.Vehicle interface
func (v *Wrapper) Title() string {
return "unavailable"
@ -30,6 +32,11 @@ func (v *Wrapper) Capacity() int64 {
return 0
}
// Phases implements the api.Vehicle interface
func (v *Wrapper) Phases() int {
return 0
}
// Identifiers implements the api.Vehicle interface
func (v *Wrapper) Identifiers() []string {
return nil