From 63c74bc0a9909615fcf2e9f964e65ada4edac556 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 28 Feb 2022 14:40:06 +0100 Subject: [PATCH] Redesign phase handling (#2613) --- api/api.go | 8 +- core/loadpoint.go | 133 ++++++--------- core/loadpoint_api.go | 18 ++- core/loadpoint_phases.go | 80 +++++++++ core/loadpoint_phases_test.go | 296 ++++++++++++++++++++++++++++++++++ core/loadpoint_test.go | 243 +++++----------------------- mock/mock_api.go | 53 ++---- vehicle/wrapper/wrapper.go | 7 + 8 files changed, 501 insertions(+), 337 deletions(-) create mode 100644 core/loadpoint_phases.go create mode 100644 core/loadpoint_phases_test.go diff --git a/api/api.go b/api/api.go index a4d89be88..08bf32640 100644 --- a/api/api.go +++ b/api/api.go @@ -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 diff --git a/core/loadpoint.go b/core/loadpoint.go index 80ff7ae48..6211cc3d8 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -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) } } } diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index 4d459475e..dc6cb1bc1 100644 --- a/core/loadpoint_api.go +++ b/core/loadpoint_api.go @@ -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 diff --git a/core/loadpoint_phases.go b/core/loadpoint_phases.go new file mode 100644 index 000000000..cf4f8ba37 --- /dev/null +++ b/core/loadpoint_phases.go @@ -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 +} diff --git a/core/loadpoint_phases_test.go b/core/loadpoint_phases_test.go new file mode 100644 index 000000000..fb1e3226a --- /dev/null +++ b/core/loadpoint_phases_test.go @@ -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) + } + } + } +} diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go index 6f5e1be43..16464b22e 100644 --- a/core/loadpoint_test.go +++ b/core/loadpoint_test.go @@ -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) - } - } -} diff --git a/mock/mock_api.go b/mock/mock_api.go index 6ca0847fa..0e8405bcc 100644 --- a/mock/mock_api.go +++ b/mock/mock_api.go @@ -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 diff --git a/vehicle/wrapper/wrapper.go b/vehicle/wrapper/wrapper.go index b41d58898..44e4fbc12 100644 --- a/vehicle/wrapper/wrapper.go +++ b/vehicle/wrapper/wrapper.go @@ -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