diff --git a/core/loadpoint.go b/core/loadpoint.go index 7dc2bf611..957e026b2 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -1438,8 +1438,9 @@ func (lp *Loadpoint) minCharging() error { 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 { +// pvScalePhases switches phases if necessary and returns number of phases switched to. +// mayDisable indicates that insufficient surplus can stop charging via the pv disable timer. +func (lp *Loadpoint) pvScalePhases(sitePower, minCurrent, maxCurrent float64, mayDisable bool) int { phases := lp.GetPhases() // observed phase state inconsistency @@ -1466,8 +1467,9 @@ func (lp *Loadpoint) pvScalePhases(sitePower, minCurrent, maxCurrent float64) in } // while charging, scaling down only helps if 1p is sustainable, otherwise it - // merely delays the pv disable timer by the phase timer duration - useful := !lp.enabled || !lp.charging() || powerToCurrent(availablePower, 1) >= minCurrent + // merely delays the pv disable timer by the phase timer duration. Without a + // disable to wait for, scaling down is the only way to reduce power (#33208). + useful := !lp.enabled || !lp.charging() || !mayDisable || powerToCurrent(availablePower, 1) >= minCurrent if insufficient && !useful { lp.log.DEBUG.Printf("available power %.0fW < %.0fW min 1p threshold, disabling instead of scaling down", availablePower, Voltage*minCurrent) } @@ -1638,10 +1640,14 @@ func (lp *Loadpoint) pvMaxCurrent(mode api.ChargeMode, sitePower, batteryPower f // push demand to drain battery sitePower -= lp.boostPower(batteryPower) + // minpv and the battery conditions hold charging at min current, no disable can follow + battery := batteryStart || batteryBuffered && lp.charging() || lp.GetBatteryBoost() == boostContinue + mayDisable := mode == api.ModePV && !battery + // switch phases up/down var scaledTo int if lp.hasPhaseSwitching() && lp.phaseSwitchCompleted() { - scaledTo = lp.pvScalePhases(sitePower, minCurrent, maxCurrent) + scaledTo = lp.pvScalePhases(sitePower, minCurrent, maxCurrent, mayDisable) } // calculate target charge current from delta power and actual current @@ -1659,7 +1665,7 @@ func (lp *Loadpoint) pvMaxCurrent(mode api.ChargeMode, sitePower, batteryPower f targetCurrent := max(effectiveCurrent+deltaCurrent, 0) // in MinPV mode or under special conditions return at least minCurrent - if battery := batteryStart || batteryBuffered && lp.charging() || lp.GetBatteryBoost() == boostContinue; (mode == api.ModeMinPV || battery) && targetCurrent < minCurrent { + if (mode == api.ModeMinPV || battery) && targetCurrent < minCurrent { lp.log.DEBUG.Printf("pv charge current: min %.3gA > %.3gA (%.0fW @ %dp, battery: %t)", minCurrent, targetCurrent, sitePower, activePhases, battery) return minCurrent } diff --git a/core/loadpoint_phases_test.go b/core/loadpoint_phases_test.go index cc723ea14..e1542925b 100644 --- a/core/loadpoint_phases_test.go +++ b/core/loadpoint_phases_test.go @@ -178,7 +178,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 != 3 { + if scaled := lp.pvScalePhases(sitePower, minA, maxAmp-0.0001, true); scaled != 3 { t.Errorf("%v act=%d max=%d missing scale %s at reduced max current %.1fA", tc, act, max, direction, maxAmp) } @@ -187,7 +187,7 @@ func testScale(t *testing.T, lp *Loadpoint, sitePower float64, direction string, } } - scaled := lp.pvScalePhases(sitePower, minA, maxA) + scaled := lp.pvScalePhases(sitePower, minA, maxA, true) if strings.Contains(testExpectation, testDirection) { if scaled == 0 { @@ -384,6 +384,13 @@ func TestPvScalePhasesTimer(t *testing.T) { lp.chargePower = 3 * Voltage * minA }}, + // minpv never disables, so scale down even if 1p is not sustainable (#33208) + {"3/3->1, insufficient for 1p, charging, minpv", 3, 3, 0.1, 1, 1, func(lp *Loadpoint) { + lp.phaseTimer = elapsed + lp.enabled = true + lp.mode = api.ModeMinPV + }}, + // switch down from 3p/0p while not yet charging {"3/0->1, not enough power, not charging", 3, 0, 0, 1, 1, func(lp *Loadpoint) { lp.status = api.StatusB @@ -437,7 +444,7 @@ func TestPvScalePhasesTimer(t *testing.T) { charger.MockPhaseSwitcher.EXPECT().Phases1p3p(tc.toPhases).Return(nil) } - res := lp.pvScalePhases(tc.sitePower, minA, maxA) + res := lp.pvScalePhases(tc.sitePower, minA, maxA, lp.mode != api.ModeMinPV) require.Equal(t, tc.res, res, tc.desc) require.Equal(t, tc.toPhases, lp.phases, tc.desc) @@ -785,7 +792,7 @@ func TestPvScalePhasesCircuitLimits(t *testing.T) { }{plainCharger, phaseCharger}, } - require.Equal(t, tc.expectedPhases, lp.pvScalePhases(tc.sitePower, lp.minCurrent, lp.maxCurrent)) + require.Equal(t, tc.expectedPhases, lp.pvScalePhases(tc.sitePower, lp.minCurrent, lp.maxCurrent, true)) ctrl.Finish() })