Repository: evcc-io/evcc
Pull request #9581 (2023-08-28 14:58:20)

Fix reducing to 1p when vehicle doesn't utilize allowed current (foll…
…ow-up to #9462)

This reverts commit 1faa666265675d75e14738182dafd83867306286.

Refs https://github.com/evcc-io/evcc/issues/9543. 

- [ ] add test

GrimmiMeloni: @andig which tests are missing? Can I help?
andig: Es fehlen alle Randfälle die zum Revert geführt haben. Ist hier nicht dringend, würde gerne erst den letzten offenen 1p3p Bug lösen. 
andig: Ziel ist, bei Fahrzeugen welche die 3p Leistung nicht ausnutzen (wie z.B. der i3) ein 3p->1p zu vermeiden da sonst beim nächsten Timer bei gleichen Bedingungen wieder 1p->3p geschaltet würde. Statt dem theoretischen 3p Limit wird daher auf den Netzbezug geschaut.

@GrimmiMeloni könntest Du mal einen Blick auf die geänderten Tests werfen?

- wir schauen jetzt immer auf sitepower als Indikator
- 3p->1p passiert nur bei Bezug _oder_ wenn der Ladepunkt noch nicht freigegeben ist (also im Zweifel auch kein Bezug stattfinden kann)

Ich würde das so gerne ausprobieren.

Patch:
diff --git a/charger/easee.go b/charger/easee.go
index dd38812a..9c5538cf 100644
--- a/charger/easee.go
+++ b/charger/easee.go
@@ -734,6 +734,10 @@ var _ api.Meter = (*Easee)(nil)
 
 // CurrentPower implements the api.Meter interface
 func (c *Easee) CurrentPower() (float64, error) {
+	if status, err := c.Status(); err != nil || status == api.StatusA {
+		return 0, err
+	}
+
 	c.mux.Lock()
 	defer c.mux.Unlock()
 
diff --git a/core/loadpoint.go b/core/loadpoint.go
index 6030a474..95cbcc3b 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -1025,7 +1025,7 @@ func (lp *Loadpoint) fastCharging() error {
 }
 
 // pvScalePhases switches phases if necessary and returns if switch occurred
-func (lp *Loadpoint) pvScalePhases(availablePower, minCurrent, maxCurrent float64) bool {
+func (lp *Loadpoint) pvScalePhases(sitePower, minCurrent, maxCurrent float64) bool {
 	phases := lp.GetPhases()
 
 	// observed phase state inconsistency
@@ -1042,9 +1042,11 @@ func (lp *Loadpoint) pvScalePhases(availablePower, minCurrent, maxCurrent float6
 
 	var waiting bool
 	activePhases := lp.activePhases()
+	availablePower := lp.chargePower - sitePower
+	scalable := (sitePower > 0 || !lp.enabled) && activePhases > 1 && lp.ConfiguredPhases < 3
 
 	// scale down phases
-	if targetCurrent := powerToCurrent(availablePower, activePhases); targetCurrent < minCurrent && activePhases > 1 && lp.ConfiguredPhases < 3 {
+	if targetCurrent := powerToCurrent(availablePower, activePhases); targetCurrent < minCurrent && scalable {
 		lp.log.DEBUG.Printf("available power %.0fW < %.0fW min %dp threshold", availablePower, float64(activePhases)*Voltage*minCurrent, activePhases)
 
 		if !lp.charging() { // scale immediately if not charging
@@ -1070,7 +1072,7 @@ func (lp *Loadpoint) pvScalePhases(availablePower, minCurrent, maxCurrent float6
 
 	maxPhases := lp.maxActivePhases()
 	target1pCurrent := powerToCurrent(availablePower, 1)
-	scalable := maxPhases > 1 && phases < maxPhases && target1pCurrent > maxCurrent
+	scalable = maxPhases > 1 && phases < maxPhases && target1pCurrent > maxCurrent
 
 	// scale up phases
 	if targetCurrent := powerToCurrent(availablePower, maxPhases); targetCurrent >= minCurrent && scalable {
@@ -1138,8 +1140,7 @@ func (lp *Loadpoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64, batter
 
 	// switch phases up/down
 	if _, ok := lp.charger.(api.PhaseSwitcher); ok {
-		availablePower := -sitePower + lp.chargePower
-		_ = lp.pvScalePhases(availablePower, minCurrent, maxCurrent)
+		_ = lp.pvScalePhases(sitePower, minCurrent, maxCurrent)
 	}
 
 	// calculate target charge current from delta power and actual current


Test patch:
diff --git a/core/loadpoint_phases_test.go b/core/loadpoint_phases_test.go
index 19a83420..efd740ce 100644
--- a/core/loadpoint_phases_test.go
+++ b/core/loadpoint_phases_test.go
@@ -22,48 +22,46 @@ type testCase struct {
 	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"},
-	}
-)
+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 TestMaxActivePhases(t *testing.T) {
 	ctrl := gomock.NewController(t)
@@ -125,7 +123,7 @@ func TestMaxActivePhases(t *testing.T) {
 	}
 }
 
-func testScale(t *testing.T, lp *Loadpoint, power float64, direction string, tc testCase) {
+func testScale(t *testing.T, lp *Loadpoint, sitePower float64, direction string, tc testCase) {
 	act := lp.activePhases()
 	max := lp.maxActivePhases()
 
@@ -136,8 +134,8 @@ func testScale(t *testing.T, lp *Loadpoint, power float64, direction string, tc
 	if testDirection == "u" && strings.Contains(testExpectation, testDirection) {
 		// scale-up should only execute when the 1p max current is exceeded
 		// we're testing this here and remove the upscale expectation for the following test below 1p max current
-		if maxAmp := power / Voltage; maxAmp < maxA {
-			if scaled := lp.pvScalePhases(power, minA, maxAmp-0.0001); !scaled {
+		if maxAmp := -sitePower / Voltage; maxAmp < maxA {
+			if scaled := lp.pvScalePhases(sitePower, minA, maxAmp-0.0001); !scaled {
 				t.Errorf("%v act=%d max=%d missing scale %s at reduced max current %.1fA", tc, act, max, direction, maxAmp)
 			}
 
@@ -146,7 +144,7 @@ func testScale(t *testing.T, lp *Loadpoint, power float64, direction string, tc
 		}
 	}
 
-	scaled := lp.pvScalePhases(power, minA, maxA)
+	scaled := lp.pvScalePhases(sitePower, minA, maxA)
 
 	if strings.Contains(testExpectation, testDirection) {
 		if !scaled {
@@ -232,7 +230,7 @@ func TestPvScalePhases(t *testing.T) {
 		// scaling
 		if phaseCharger != nil {
 			// scale down
-			min1p := 1 * minA * Voltage
+			min1p := 0.1
 			lp.phaseTimer = time.Time{}
 
 			plainCharger.EXPECT().Enable(false).Return(nil).MaxTimes(1)
@@ -252,7 +250,7 @@ func TestPvScalePhases(t *testing.T) {
 			plainCharger.EXPECT().Enable(false).Return(nil).MaxTimes(1)
 			phaseCharger.EXPECT().Phases1p3p(3).Return(nil).MaxTimes(1)
 
-			testScale(t, lp, min3p, "up", tc)
+			testScale(t, lp, -min3p, "up", tc)
 			ctrl.Finish()
 		}
 	}
@@ -274,57 +272,65 @@ func TestPvScalePhasesTimer(t *testing.T) {
 	tc := []struct {
 		desc                   string
 		phases, measuredPhases int
-		availablePower         float64
+		sitePower              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) {
+		{"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) {
+		{"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)
+		{"1/1->3, timer elapsed", 1, 1, -3 * Voltage * minA, 3, true, func(lp *Loadpoint) {
+			lp.phaseTimer = elapsed
 		}},
 
 		// 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) {
+		{"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) {
+		{"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)
+		{"3/1->3, timer elapsed", 3, 1, -3 * Voltage * minA, 3, false, func(lp *Loadpoint) {
+			lp.phaseTimer = elapsed
 		}},
 
 		// 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) {
+		{"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) {
+		{"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)
+		{"3/1->1, timer elapsed", 3, 1, -1 * Voltage * minA, 3, false, func(lp *Loadpoint) {
+			lp.phaseTimer = elapsed
 		}},
 
 		// 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) {
+		{"3/3->1, enough power", 3, 3, 0, 3, false, nil},
+		{"3/3->1, enough power, timer elapsed, load point enabled", 3, 3, 0, 3, false, func(lp *Loadpoint) {
+			lp.phaseTimer = elapsed
+			lp.enabled = true
+		}},
+		{"3/3->1, enough power, timer elapsed, load point disabled", 3, 3, 0, 1, true, func(lp *Loadpoint) {
+			lp.phaseTimer = elapsed
+			lp.enabled = false
+		}},
+		{"3/3->1, kickoff", 3, 3, 0.1, 3, false, func(lp *Loadpoint) {
 			lp.phaseTimer = time.Time{}
 		}},
-		{"3/3->1, timer running", 3, 3, 1 * Voltage * maxA, 3, false, func(lp *Loadpoint) {
+		{"3/3->1, timer running", 3, 3, 0.1, 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)
+		{"3/3->1, timer elapsed", 3, 3, 0.1, 1, true, func(lp *Loadpoint) {
+			lp.phaseTimer = elapsed
 		}},
 
 		// switch down from 3p/0p while not yet charging
@@ -332,20 +338,20 @@ func TestPvScalePhasesTimer(t *testing.T) {
 			lp.status = api.StatusB
 		}},
 		// switch up from 1p/0p while not yet charging
-		{"1/0->3, enough power, not charging", 1, 0, 3 * Voltage * minA, 3, true, func(lp *Loadpoint) {
+		{"1/0->3, enough power, not charging", 1, 0, -3 * Voltage * minA, 3, true, func(lp *Loadpoint) {
 			lp.status = api.StatusB
 		}},
 
-		// 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) {
+		// error states from 1p/3p misconfiguration - 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, 0.1, 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) {
+		{"1/3->1, timer running, correct phase setting", 1, 3, 0.1, 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)
+		{"1/3->1, switch not executed", 1, 3, 0.1, 1, false, func(lp *Loadpoint) {
+			lp.phaseTimer = elapsed
 		}},
 	}
 
@@ -379,7 +385,7 @@ func TestPvScalePhasesTimer(t *testing.T) {
 			charger.MockPhaseSwitcher.EXPECT().Phases1p3p(tc.toPhases).Return(nil)
 		}
 
-		res := lp.pvScalePhases(tc.availablePower, minA, maxA)
+		res := lp.pvScalePhases(tc.sitePower, minA, maxA)
 
 		switch {
 		case tc.res != res: