Repository: evcc-io/evcc
Pull request #18234 (2025-01-15 13:52:09)

Loadpoint: validate fixed phase configuration
In case of fixed-phase meters, we could/should validate if loadpoint config matches charger


Patch:
diff --git a/core/loadpoint.go b/core/loadpoint.go
index 906a4c39..8c720ac8 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -623,6 +623,17 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
 	lp.publish(keys.EnableDelay, lp.Enable.Delay)
 	lp.publish(keys.DisableDelay, lp.Disable.Delay)
 
+	if phases := lp.getChargerPhysicalPhases(); phases != 0 {
+		if lp.configuredPhases != phases && lp.configuredPhases != 0 {
+			lp.log.WARN.Printf("configured phases %d do not match physical phases %d", lp.configuredPhases, phases)
+		}
+		lp.phases = phases
+		lp.configuredPhases = phases
+		lp.publish(keys.ChargerPhysicalPhases, phases)
+	} else {
+		lp.publish(keys.ChargerPhysicalPhases, nil)
+	}
+
 	lp.publish(keys.PhasesConfigured, lp.configuredPhases)
 	lp.publish(keys.ChargerPhases1p3p, lp.hasPhaseSwitching())
 	lp.publish(keys.PhasesEnabled, lp.phases)
@@ -630,12 +641,6 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
 	lp.publishTimer(phaseTimer, 0, timerInactive)
 	lp.publishTimer(pvTimer, 0, timerInactive)
 
-	if phases := lp.getChargerPhysicalPhases(); phases != 0 {
-		lp.publish(keys.ChargerPhysicalPhases, phases)
-	} else {
-		lp.publish(keys.ChargerPhysicalPhases, nil)
-	}
-
 	// charger features
 	for _, f := range []api.Feature{api.IntegratedDevice, api.Heating} {
 		lp.publishChargerFeature(f)
diff --git a/core/loadpoint_effective.go b/core/loadpoint_effective.go
index 7f16f5f9..716f7f4e 100644
--- a/core/loadpoint_effective.go
+++ b/core/loadpoint_effective.go
@@ -1,7 +1,7 @@
 package core
 
 import (
-	"sort"
+	"slices"
 	"time"
 
 	"github.com/evcc-io/evcc/api"
@@ -31,20 +31,39 @@ func (lp *Loadpoint) EffectivePriority() int {
 	return lp.GetPriority()
 }
 
+type plan struct {
+	Id    int
+	Start time.Time // last possible start time
+	End   time.Time // user-selected finish time
+	Soc   int
+}
+
+func (lp *Loadpoint) nextActivePlan(maxPower float64, plans []plan) *plan {
+	for i, p := range plans {
+		requiredDuration := lp.GetPlanRequiredDuration(float64(p.Soc), maxPower)
+		plans[i].Start = p.End.Add(-requiredDuration)
+	}
+
+	// sort plans by start time
+	slices.SortStableFunc(plans, func(i, j plan) int {
+		return i.Start.Compare(j.Start)
+	})
+
+	if len(plans) > 0 {
+		return &plans[0]
+	}
+
+	return nil
+}
+
 // nextVehiclePlan returns the next vehicle plan time, soc and id
 func (lp *Loadpoint) nextVehiclePlan() (time.Time, int, int) {
 	if v := lp.GetVehicle(); v != nil {
-		type plan struct {
-			Time time.Time
-			Soc  int
-			Id   int
-		}
-
 		var plans []plan
 
 		// static plan
 		if planTime, soc := vehicle.Settings(lp.log, v).GetPlanSoc(); soc != 0 {
-			plans = append(plans, plan{Id: 1, Soc: soc, Time: planTime})
+			plans = append(plans, plan{Id: 1, Soc: soc, End: planTime})
 		}
 
 		// repeating plans
@@ -59,16 +78,12 @@ func (lp *Loadpoint) nextVehiclePlan() (time.Time, int, int) {
 				continue
 			}
 
-			plans = append(plans, plan{Id: index + 2, Soc: rp.Soc, Time: time})
+			plans = append(plans, plan{Id: index + 2, Soc: rp.Soc, End: time})
 		}
 
-		// sort plans by time
-		sort.Slice(plans, func(i, j int) bool {
-			return plans[i].Time.Before(plans[j].Time)
-		})
-
-		if len(plans) > 0 {
-			return plans[0].Time, plans[0].Soc, plans[0].Id
+		// calculate earliest required plan start
+		if plan := lp.nextActivePlan(lp.EffectiveMaxPower(), plans); plan != nil {
+			return plan.End, plan.Soc, plan.Id
 		}
 	}
 	return time.Time{}, 0, 0
diff --git a/templates/definition/meter/solaredge-inverter.yaml b/templates/definition/meter/solaredge-inverter.yaml
index 3bf5f179..fba5924b 100644
--- a/templates/definition/meter/solaredge-inverter.yaml
+++ b/templates/definition/meter/solaredge-inverter.yaml
@@ -102,4 +102,23 @@ render: |
       - 101:WH
       - 103:WH
     scale: 0.001
+  currents:
+    - source: sunspec
+      {{- include "modbus" . | indent 4 }}
+      timeout: {{ .timeout }}
+      value:
+        - 101:AphA
+        - 103:AphA
+    - source: sunspec
+      {{- include "modbus" . | indent 4 }}
+      timeout: {{ .timeout }}
+      value:
+        - 101:AphB
+        - 103:AphB
+    - source: sunspec
+      {{- include "modbus" . | indent 4 }}
+      timeout: {{ .timeout }}
+      value:
+        - 101:AphC
+        - 103:AphC
   {{- end }}


Test patch:
diff --git a/core/loadpoint_effective_test.go b/core/loadpoint_effective_test.go
index f2165478..80c41e70 100644
--- a/core/loadpoint_effective_test.go
+++ b/core/loadpoint_effective_test.go
@@ -2,10 +2,13 @@ package core
 
 import (
 	"testing"
+	"time"
 
+	"github.com/benbjohnson/clock"
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/util"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 	"go.uber.org/mock/gomock"
 )
 
@@ -68,3 +71,37 @@ func TestEffectiveMinMaxCurrent(t *testing.T) {
 		assert.Equal(t, tc.effectiveMax, lp.effectiveMaxCurrent(), "max")
 	}
 }
+
+func TestNextPlan(t *testing.T) {
+	clock := clock.NewMock()
+
+	ctrl := gomock.NewController(t)
+	lp := NewLoadpoint(util.NewLogger("foo"), nil)
+	lp.charger = api.NewMockCharger(ctrl)
+
+	for _, tc := range []struct {
+		planId int
+		plans  []plan
+	}{
+		{1, []plan{
+			{Id: 1, End: clock.Now().Add(8 * time.Hour), Soc: 10},
+			{Id: 2, End: clock.Now().Add(10 * time.Hour), Soc: 10},
+		}},
+		{1, []plan{
+			{Id: 1, End: clock.Now().Add(8 * time.Hour), Soc: 20},
+			{Id: 2, End: clock.Now().Add(9 * time.Hour), Soc: 20},
+		}},
+		{2, []plan{
+			{Id: 2, End: clock.Now().Add(8 * time.Hour), Soc: 20},
+			{Id: 1, End: clock.Now().Add(9 * time.Hour), Soc: 20},
+		}},
+		{2, []plan{
+			{Id: 1, End: clock.Now().Add(8 * time.Hour), Soc: 10},
+			{Id: 2, End: clock.Now().Add(10 * time.Hour), Soc: 60},
+		}},
+	} {
+		res := lp.nextActivePlan(1e4, tc.plans)
+		require.NotNil(t, res)
+		assert.Equal(t, tc.planId, res.Id)
+	}
+}
diff --git a/tests/plan.spec.js b/tests/plan.spec.js
index e07ecf4c..ffe1eb58 100644
--- a/tests/plan.spec.js
+++ b/tests/plan.spec.js
@@ -497,6 +497,7 @@ test.describe("repeating", async () => {
     const plan1 = modal.getByTestId("plan-entry").nth(0);
     await plan1.getByTestId("static-plan-day").selectOption({ index: 1 });
     await plan1.getByTestId("static-plan-time").fill("09:30");
+    await plan1.getByTestId("static-plan-soc").selectOption("80%");
     await plan1.getByTestId("static-plan-active").click();
 
     // add repeating plan for tomorrow