Repository: evcc-io/evcc (go)

Issue:
Easee wallbox stoppt Ladung nach Phasenumschaltung
**Describe the bug**
Das Laden startet bei "Min+PV" 3-phasig, da genug Überschuss vorhanden.
Dann sinkt der PV Output und es erfolgt ein Wechsel auf 1P.
Es erscheint die Warnung ```ignoring inconsistent phases: 1p < 3p observed active``` und die Ladung wird nicht wieder gestartet.

Zum Log unten folgende Zeiten, wann der Modus umgestellt wurde:

12:19:35 STOP
12:20:35 Min+PV

**To Reproduce**
Steps to reproduce the behavior:
1. Starte Ladung mit Min+PV
2. PV Output sinkt unter 3 kW
3. Wallbox bekommt Anweisung 3P->1P
4. Wallbox stoppt
5. Warnung ```ignoring inconsistent phases: 1p < 3p observed active``` erscheint

**Expected behavior**
Nach dem Stopp sollte die Ladung mit 1P fortgesetzt werden.

**EVCC details:**
Show output of <code>evcc -v</code>:
```
evcc version 0.82 (fbd0d655)

```

<details><summary>Show output of <code>evcc dump -c configfile</code>:</summary><br><pre>
config
======

grid: grid3
-----------
Power: -31W

pv 0: pv1
---------
Power: 2097W

battery 0: battery2
-------------------
Power: -277W
SoC:   17%

loadpoint 1
===========

charger: wallbox5
-----------------
Power:          1312W
Current L1..L3: 5.71A 0.008A 0.008A
Charge status:  C
Enabled:        true
Charged:        1.1kWh

vehicle 0
---------
SoC:            58%
Charge status:  C
Range:          189km
Odometer:       not available
Finish time:    2022-02-08 07:29:00 +0100 CET
Climate active: false
Outside temp:   19.5°C
Target temp:    19.5°C
Capacity:       58kWh
OnIdentified:   TargetSoC:100, MinCurrent:6, MaxCurrent:16
</pre></details>

<details><summary>Show evcc configuration file <code>evcc.yaml</code>:</summary><br><pre>
uri: 0.0.0.0:7070 # uri for ui
interval: 10s # control cycle interval

log: trace #info
levels:
  modbus: debug

meters:
- type: custom
  name: pv1 
  power:
    source: calc
    add:
    - source: modbus
      model: sunspec
      value: 160:1:DCW # SunSpec Model 160 MPP string 1 DCW
      uri: 192.168.178.46:1502
      id: 71
    - source: modbus
      model: sunspec
      value: 160:2:DCW # SunSpec Model 160 MPP string 2 DCW
      uri: 192.168.178.46:1502
      id: 71
- type: template
  template: kostal-plenticore
  usage: battery 
  modbus: tcpip 
  name: battery2 
  id: 71 
  host: 192.168.178.46 
  port: 1502
- type: template
  template: kostal-ksem-inverter
  usage: grid 
  modbus: tcpip 
  name: grid3 
  id: 71 
  host: 192.168.178.46 
  port: 1502

chargers:
- type: template
  template: easee
  user: xxx
  password: xxx 
  charger: xxx
  name: wallbox5

vehicles:
- type: template
  template: id
  title: ID.3 
  user: xxx
  password: xxx
  vin: xxx
  capacity: 58 
  targetSoC: 100 
  name: ev4

loadpoints:
- title: Garage
  charger: wallbox5
  vehicles:
  - ev4
  mode: minpv
  phases: 3
  mincurrent: 6
  maxcurrent: 16
  resetOnDisconnect: true

site:
  title: Zuhause
  meters:
    grid: grid3
    pvs:
    - pv1
    batteries:
    - battery2
  bufferSoC: 5

sponsortoken: xxx

</pre></details>

Show evcc log output with <code>--log trace</code>:

https://pastes.io/f54w84upoa




Pull request description:
Redesign phase handling
Fix #2519, #2525, #2638

Dieser PR implementiert vieles neu was mit Phasennutzung, -messung und -umschaltung zu tun hat:
- konfigurierte, gemessene und erwartete Phasenwerte sind nun klar logisch voneinander getrennt
- unbekannte Phasenkonfigurationen und Schaltzustände werden wenn nötig konservativ als 3p Fahrzeug/Anschluss interpretiert - dadurch startet z. B. die Ladung im PV-Modus im ungünstigsten Fall erst mit höheren Überschüssen als idealerweise nötig (siehe dazu auch nächster Punkt!)
- Konfiguration der `phases` am Ladepunkt und am Fahrzeug optimiert die Funktion in allen Szenarien. Bitte diese Optionen wo immer möglich nutzen!
- Der Zustand von externen 1p3p-Umschaltern (aka "Lasttrennschalter") kann nun via `phases` API berücksichtigt werden
- die Möglichkeit Ladepunkte mit interner 1p3p-Umschaltung auch manuell auf 1p oder 3p festzulegen liefern wir mit separatem PR nach
- die Änderungen sind jetzt mit automatisierten Softwaretests validiert um die Qualität zu verbessern

Patch:
diff --git a/api/api.go b/api/api.go
index a4d89be8..08bf3264 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 80ff7ae4..6211cc3d 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.phaseTimer = time.Time{}
+		lp.Unlock()
+
 		lp.publish("phases", lp.Phases)
+		lp.publishTimer(phaseTimer, 0, timerInactive)
 
-		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.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 4d459475..dc6cb1bc 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 00000000..cf4f8ba3
--- /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/soc/estimator.go b/core/soc/estimator.go
index 0f09686d..b79e4ac4 100644
--- a/core/soc/estimator.go
+++ b/core/soc/estimator.go
@@ -57,7 +57,7 @@ func (s *Estimator) Reset() {
 func (s *Estimator) AssumedChargeDuration(targetSoC int, chargePower float64) time.Duration {
 	percentRemaining := float64(targetSoC) - s.vehicleSoc
 
-	if percentRemaining <= 0 {
+	if percentRemaining <= 0 || s.virtualCapacity <= 0 {
 		return 0
 	}
 
@@ -95,7 +95,7 @@ func (s *Estimator) RemainingChargeDuration(chargePower float64, targetSoC int)
 // RemainingChargeEnergy returns the remaining charge energy in kWh
 func (s *Estimator) RemainingChargeEnergy(targetSoC int) float64 {
 	percentRemaining := float64(targetSoC) - s.vehicleSoc
-	if percentRemaining <= 0 {
+	if percentRemaining <= 0 || s.virtualCapacity <= 0 {
 		return 0
 	}
 
@@ -151,7 +151,7 @@ func (s *Estimator) SoC(chargedEnergy float64) (float64, error) {
 		s.vehicleSoc = f
 	}
 
-	if s.estimate {
+	if s.estimate && s.virtualCapacity > 0 {
 		socDelta := s.vehicleSoc - s.prevSoc
 		energyDelta := math.Max(chargedEnergy, 0) - s.prevChargedEnergy
 
diff --git a/mock/mock_api.go b/mock/mock_api.go
index 6ca0847f..0e8405bc 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 b41d5889..44e4fbc1 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


Test patch:
diff --git a/core/loadpoint_phases_test.go b/core/loadpoint_phases_test.go
new file mode 100644
index 00000000..fb1e3226
--- /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 6f5e1be4..16464b22 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)
-		}
-	}
-}