Phases: allow 1p scale-down when the loadpoint never disables (#33214)

This commit is contained in:
andig 2026-08-27 08:56:33 +02:00 • committed by GitHub
parent 10b0c99f17
commit 8d425d3920
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 23 additions and 10 deletions

View file

@ -1438,8 +1438,9 @@ func (lp *Loadpoint) minCharging() error {
return lp.setLimit(lp.effectiveMinCurrent()) return lp.setLimit(lp.effectiveMinCurrent())
} }
// pvScalePhases switches phases if necessary and returns number of phases switched to // pvScalePhases switches phases if necessary and returns number of phases switched to.
func (lp *Loadpoint) pvScalePhases(sitePower, minCurrent, maxCurrent float64) int { // 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() phases := lp.GetPhases()
// observed phase state inconsistency // 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 // while charging, scaling down only helps if 1p is sustainable, otherwise it
// merely delays the pv disable timer by the phase timer duration // merely delays the pv disable timer by the phase timer duration. Without a
useful := !lp.enabled || !lp.charging() || powerToCurrent(availablePower, 1) >= minCurrent // 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 { if insufficient && !useful {
lp.log.DEBUG.Printf("available power %.0fW < %.0fW min 1p threshold, disabling instead of scaling down", availablePower, Voltage*minCurrent) 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 // push demand to drain battery
sitePower -= lp.boostPower(batteryPower) 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 // switch phases up/down
var scaledTo int var scaledTo int
if lp.hasPhaseSwitching() && lp.phaseSwitchCompleted() { 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 // 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) targetCurrent := max(effectiveCurrent+deltaCurrent, 0)
// in MinPV mode or under special conditions return at least minCurrent // 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) lp.log.DEBUG.Printf("pv charge current: min %.3gA > %.3gA (%.0fW @ %dp, battery: %t)", minCurrent, targetCurrent, sitePower, activePhases, battery)
return minCurrent return minCurrent
} }

View file

@ -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 // 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 // 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 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) 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 strings.Contains(testExpectation, testDirection) {
if scaled == 0 { if scaled == 0 {
@ -384,6 +384,13 @@ func TestPvScalePhasesTimer(t *testing.T) {
lp.chargePower = 3 * Voltage * minA 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 // 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) { {"3/0->1, not enough power, not charging", 3, 0, 0, 1, 1, func(lp *Loadpoint) {
lp.status = api.StatusB lp.status = api.StatusB
@ -437,7 +444,7 @@ func TestPvScalePhasesTimer(t *testing.T) {
charger.MockPhaseSwitcher.EXPECT().Phases1p3p(tc.toPhases).Return(nil) 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.res, res, tc.desc)
require.Equal(t, tc.toPhases, lp.phases, tc.desc) require.Equal(t, tc.toPhases, lp.phases, tc.desc)
@ -785,7 +792,7 @@ func TestPvScalePhasesCircuitLimits(t *testing.T) {
}{plainCharger, phaseCharger}, }{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() ctrl.Finish()
}) })