Repository: evcc-io/evcc
Pull request #13295 (2024-04-04 18:54:08)

Limit PV mode current when scaling phases up
Refs https://github.com/evcc-io/evcc/discussions/13182, replaces https://github.com/evcc-io/evcc/pull/13254


Patch:
diff --git a/core/loadpoint.go b/core/loadpoint.go
index eb5d5858..bdd1fa3f 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -1062,8 +1062,8 @@ func (lp *Loadpoint) fastCharging() error {
 	return err
 }
 
-// pvScalePhases switches phases if necessary and returns if switch occurred
-func (lp *Loadpoint) pvScalePhases(sitePower, minCurrent, maxCurrent float64) bool {
+// pvScalePhases switches phases if necessary and returns number of phases switched to
+func (lp *Loadpoint) pvScalePhases(sitePower, minCurrent, maxCurrent float64) int {
 	phases := lp.GetPhases()
 
 	// observed phase state inconsistency
@@ -1102,7 +1102,7 @@ func (lp *Loadpoint) pvScalePhases(sitePower, minCurrent, maxCurrent float64) bo
 			if err := lp.scalePhases(1); err != nil {
 				lp.log.ERROR.Println(err)
 			}
-			return true
+			return 1
 		}
 
 		waiting = true
@@ -1131,7 +1131,10 @@ func (lp *Loadpoint) pvScalePhases(sitePower, minCurrent, maxCurrent float64) bo
 			if err := lp.scalePhases(3); err != nil {
 				lp.log.ERROR.Println(err)
 			}
-			return true
+			if err := lp.scalePhases(3); err != nil {
+				lp.log.ERROR.Println(err)
+			}
+			return 3
 		}
 
 		waiting = true
@@ -1142,7 +1145,7 @@ func (lp *Loadpoint) pvScalePhases(sitePower, minCurrent, maxCurrent float64) bo
 		lp.resetPhaseTimer()
 	}
 
-	return false
+	return 0
 }
 
 // TODO move up to timer functions
@@ -1174,13 +1177,18 @@ func (lp *Loadpoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64, batter
 	maxCurrent := lp.effectiveMaxCurrent()
 
 	// switch phases up/down
+	var scaledTo int
 	if lp.hasPhaseSwitching() && lp.phaseSwitchCompleted() {
-		_ = lp.pvScalePhases(sitePower, minCurrent, maxCurrent)
+		scaledTo = lp.pvScalePhases(sitePower, minCurrent, maxCurrent)
 	}
 
 	// calculate target charge current from delta power and actual current
-	effectiveCurrent := lp.effectiveCurrent()
 	activePhases := lp.ActivePhases()
+	effectiveCurrent := lp.effectiveCurrent()
+	if scaledTo == 3 {
+		// if we did scale, adjust the effective current to the new phase count
+		effectiveCurrent /= 3.0
+	}
 	deltaCurrent := powerToCurrent(-sitePower, activePhases)
 	targetCurrent := max(effectiveCurrent+deltaCurrent, 0)
 


Test patch:
diff --git a/core/loadpoint_phases_test.go b/core/loadpoint_phases_test.go
index 9e05d37d..a4fdc96f 100644
--- a/core/loadpoint_phases_test.go
+++ b/core/loadpoint_phases_test.go
@@ -9,6 +9,7 @@ import (
 	"github.com/benbjohnson/clock"
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/util"
+	"github.com/stretchr/testify/require"
 	"go.uber.org/mock/gomock"
 )
 
@@ -115,9 +116,7 @@ func TestMaxActivePhases(t *testing.T) {
 				expectedPhases = dflt
 			}
 
-			if phs := lp.maxActivePhases(); phs != expectedPhases {
-				t.Errorf("expected max %d, got %d", expectedPhases, phs)
-			}
+			require.Equal(t, expectedPhases, lp.maxActivePhases(), "expected max active phases")
 		}
 	}
 }
@@ -136,7 +135,7 @@ func testScale(t *testing.T, lp *Loadpoint, sitePower float64, direction string,
 		// 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 := -sitePower / Voltage; maxAmp < maxA {
-			if scaled := lp.pvScalePhases(sitePower, minA, maxAmp-0.0001); !scaled {
+			if scaled := lp.pvScalePhases(sitePower, minA, maxAmp-0.0001); scaled != 3 {
 				t.Errorf("%v act=%d max=%d missing scale %s at reduced max current %.1fA", tc, act, max, direction, maxAmp)
 			}
 
@@ -148,10 +147,10 @@ func testScale(t *testing.T, lp *Loadpoint, sitePower float64, direction string,
 	scaled := lp.pvScalePhases(sitePower, minA, maxA)
 
 	if strings.Contains(testExpectation, testDirection) {
-		if !scaled {
+		if scaled == 0 {
 			t.Errorf("%v act=%d max=%d missing scale %s", tc, act, max, direction)
 		}
-	} else if scaled {
+	} else if scaled != 0 {
 		t.Errorf("%v act=%d max=%d unexpected scale %s", tc, act, max, direction)
 	}
 }
@@ -217,16 +216,10 @@ func TestPvScalePhases(t *testing.T) {
 			t.Fatalf("%v invalid test case", tc)
 		}
 
-		if lp.phases != tc.physical {
-			t.Error("wrong phases", lp.phases, tc.physical)
-		}
+		require.Equal(t, tc.physical, lp.phases, "wrong phases")
+		require.Equal(t, tc.actExpected, lp.ActivePhases(), "expected active phases")
+		require.Equal(t, tc.maxExpected, lp.maxActivePhases(), "expected max active phases")
 
-		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
@@ -276,83 +269,83 @@ func TestPvScalePhasesTimer(t *testing.T) {
 		phases, measuredPhases int
 		sitePower              float64
 		toPhases               int
-		res                    bool
+		res                    int
 		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, not enough power", 1, 1, 0, 1, 0, nil},
+		{"1/1->3, kickoff", 1, 1, -3 * Voltage * minA, 1, 0, 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, 0, func(lp *Loadpoint) {
 			lp.phaseTimer = lp.clock.Now()
 		}},
-		{"1/1->3, timer elapsed", 1, 1, -3 * Voltage * minA, 3, true, func(lp *Loadpoint) {
+		{"1/1->3, timer elapsed", 1, 1, -3 * Voltage * minA, 3, 3, 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) {
+		// omit to switch up (again) from 3p/1p configured/a0ctive
+		{"3/1->3, not enough power", 3, 1, 0, 3, 0, nil},
+		{"3/1->3, kickoff", 3, 1, -3 * Voltage * minA, 3, 0, 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, 0, func(lp *Loadpoint) {
 			lp.phaseTimer = lp.clock.Now()
 		}},
-		{"3/1->3, timer elapsed", 3, 1, -3 * Voltage * minA, 3, false, func(lp *Loadpoint) {
+		{"3/1->3, timer elapsed", 3, 1, -3 * Voltage * minA, 3, 0, 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, not enough power", 3, 1, 0, 3, 0, nil},
+		{"3/1->1, kickoff", 3, 1, -1 * Voltage * minA, 3, 0, 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, 0, func(lp *Loadpoint) {
 			lp.phaseTimer = lp.clock.Now()
 		}},
-		{"3/1->1, timer elapsed", 3, 1, -1 * Voltage * minA, 3, false, func(lp *Loadpoint) {
+		{"3/1->1, timer elapsed", 3, 1, -1 * Voltage * minA, 3, 0, func(lp *Loadpoint) {
 			lp.phaseTimer = elapsed
 		}},
 
 		// switch down from 3p/3p configured/active
-		{"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) {
+		{"3/3->1, enough power", 3, 3, 0, 3, 0, nil},
+		{"3/3->1, enough power, timer elapsed, load point enabled", 3, 3, 0, 3, 0, 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) {
+		{"3/3->1, enough power, timer elapsed, load point disabled", 3, 3, 0, 1, 1, func(lp *Loadpoint) {
 			lp.phaseTimer = elapsed
 			lp.enabled = false
 		}},
-		{"3/3->1, kickoff", 3, 3, 0.1, 3, false, func(lp *Loadpoint) {
+		{"3/3->1, kickoff", 3, 3, 0.1, 3, 0, func(lp *Loadpoint) {
 			lp.phaseTimer = time.Time{}
 		}},
-		{"3/3->1, timer running", 3, 3, 0.1, 3, false, func(lp *Loadpoint) {
+		{"3/3->1, timer running", 3, 3, 0.1, 3, 0, func(lp *Loadpoint) {
 			lp.phaseTimer = lp.clock.Now()
 		}},
-		{"3/3->1, timer elapsed", 3, 3, 0.1, 1, true, func(lp *Loadpoint) {
+		{"3/3->1, timer elapsed", 3, 3, 0.1, 1, 1, func(lp *Loadpoint) {
 			lp.phaseTimer = elapsed
 		}},
 
 		// switch down from 3p/0p while not yet charging
-		{"3/0->1, not enough power, not charging", 3, 0, 0, 1, true, func(lp *Loadpoint) {
+		{"3/0->1, not enough power, not charging", 3, 0, 0, 1, 1, func(lp *Loadpoint) {
 			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, 3, func(lp *Loadpoint) {
 			lp.status = api.StatusB
 		}},
 
 		// 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) {
+		{"1/3->1, enough power", 1, 3, -1 * Voltage * maxA, 1, 0, nil},
+		{"1/3->1, kickoff, correct phase setting", 1, 3, 0.1, 1, 0, func(lp *Loadpoint) {
 			lp.phaseTimer = time.Time{}
 		}},
-		{"1/3->1, timer running, correct phase setting", 1, 3, 0.1, 1, false, func(lp *Loadpoint) {
+		{"1/3->1, timer running, correct phase setting", 1, 3, 0.1, 1, 0, func(lp *Loadpoint) {
 			lp.phaseTimer = lp.clock.Now()
 		}},
-		{"1/3->1, switch not executed", 1, 3, 0.1, 1, false, func(lp *Loadpoint) {
+		{"1/3->1, switch not executed", 1, 3, 0.1, 1, 0, func(lp *Loadpoint) {
 			lp.phaseTimer = elapsed
 		}},
 	}
@@ -383,18 +376,14 @@ func TestPvScalePhasesTimer(t *testing.T) {
 			tc.prepare(lp)
 		}
 
-		if tc.res {
+		if tc.res != 0 {
 			charger.MockPhaseSwitcher.EXPECT().Phases1p3p(tc.toPhases).Return(nil)
 		}
 
 		res := lp.pvScalePhases(tc.sitePower, minA, maxA)
 
-		switch {
-		case tc.res != res:
-			t.Errorf("expected %v, got %v", tc.res, res)
-		case lp.phases != tc.toPhases:
-			t.Errorf("expected %dp, got %dp", tc.toPhases, lp.phases)
-		}
+		require.Equal(t, tc.res, res, tc.desc)
+		require.Equal(t, tc.toPhases, lp.phases, tc.desc)
 	}
 }