From 545d239495564d0f35c65ebe5021908cc48444b6 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 31 May 2026 11:45:04 +0200 Subject: [PATCH] Loadpoint: scale down to 1p for feed-in priority in min+pv mode (#30327) --- core/loadpoint.go | 21 +++++++++++-- core/loadpoint_phases_test.go | 56 +++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/core/loadpoint.go b/core/loadpoint.go index 6b77ec77c..9ad9002dc 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -1324,6 +1324,19 @@ func (lp *Loadpoint) fastCharging() error { return lp.setLimit(lp.effectiveMaxCurrent()) } +// minCharging scales to 1p if available and sets minimum current +func (lp *Loadpoint) minCharging() error { + if lp.hasPhaseSwitching() { + // ignore api.ErrNotAvailable: the phase switch could not be performed + // right now, continue with the current phase configuration + if err := lp.scalePhasesIfAvailable(1); err != nil && !errors.Is(err, api.ErrNotAvailable) { + return err + } + } + + return lp.setLimit(lp.effectiveMinCurrent()) +} + // pvScalePhases switches phases if necessary and returns number of phases switched to func (lp *Loadpoint) pvScalePhases(sitePower, minCurrent, maxCurrent float64) int { phases := lp.GetPhases() @@ -2096,11 +2109,13 @@ func (lp *Loadpoint) Update(sitePower, batteryBoostPower float64, consumption, f rate, _ := feedin.At(time.Now()) lp.log.DEBUG.Printf("smart feed-in active: %.2f", rate.Value) - var targetCurrent float64 if mode == api.ModeMinPV { - targetCurrent = lp.GetMinCurrent() + // minimize self-consumption to maximize feed-in by scaling down + // to the absolute minimum of 1 phase at min current + err = lp.minCharging() + } else { + err = lp.setLimit(0) } - err = lp.setLimit(targetCurrent) lp.resetPhaseTimer() lp.elapsePVTimer() // let PV mode disable immediately afterwards diff --git a/core/loadpoint_phases_test.go b/core/loadpoint_phases_test.go index 9d82bc866..21afac059 100644 --- a/core/loadpoint_phases_test.go +++ b/core/loadpoint_phases_test.go @@ -515,6 +515,62 @@ func TestScalePhasesNotAvailable(t *testing.T) { require.Equal(t, 1, lp.GetPhases()) } +// TestMinChargingPhaseScaling verifies that minCharging scales down to 1 phase +// (the absolute minimum) when phase switching is available, so feed-in priority +// in min+pv mode drops to 1p min current instead of staying on 3p (issue #30298). +func TestMinChargingPhaseScaling(t *testing.T) { + Voltage = 230 + + tc := []struct { + desc string + phasesConfigured int + phases int + expectedPhases int + }{ + {desc: "auto, 3p active scales down to 1p", phasesConfigured: 0, phases: 3, expectedPhases: 1}, + {desc: "auto, already 1p", phasesConfigured: 0, phases: 1, expectedPhases: 1}, + {desc: "fixed 3p cannot scale down", phasesConfigured: 3, phases: 3, expectedPhases: 3}, + {desc: "fixed 1p stays 1p", phasesConfigured: 1, phases: 1, expectedPhases: 1}, + } + + for _, tc := range tc { + t.Run(tc.desc, func(t *testing.T) { + ctrl := gomock.NewController(t) + + lp := NewLoadpoint(util.NewLogger("foo"), nil) + lp.minCurrent = 6 + lp.maxCurrent = 16 + lp.phasesConfigured = tc.phasesConfigured + lp.phases = tc.phases + lp.offeredCurrent = 0 // ensure MaxCurrent is called + lp.wakeUpTimer = NewTimer() + + plainCharger := api.NewMockCharger(ctrl) + phaseCharger := api.NewMockPhaseSwitcher(ctrl) + lp.charger = struct { + *api.MockCharger + *api.MockPhaseSwitcher + }{plainCharger, phaseCharger} + + plainCharger.EXPECT().Enabled().Return(true, nil).AnyTimes() + plainCharger.EXPECT().Enable(gomock.Any()).Return(nil).AnyTimes() + + if tc.phases != tc.expectedPhases { + phaseCharger.EXPECT().Phases1p3p(tc.expectedPhases).Return(nil) + } + + // minimum current is offered at the scaled phase count + plainCharger.EXPECT().MaxCurrent(int64(lp.minCurrent)).Return(nil) + + err := lp.minCharging() + require.NoError(t, err) + require.Equal(t, tc.expectedPhases, lp.phases, tc.desc) + + ctrl.Finish() + }) + } +} + func TestFastChargingCircuitBasedPhaseScaling(t *testing.T) { Voltage = 230