Loadpoint: disable PV mode when idle integrated device lacks surplus (#33073)

This commit is contained in:
andig 2026-08-22 13:47:27 +02:00 • committed by GitHub
parent 469ccb83ab
commit ebe064ad97
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 73 additions and 0 deletions

View file

@ -1657,6 +1657,11 @@ func (lp *Loadpoint) pvMaxCurrent(mode api.ChargeMode, sitePower, batteryPower f
// notes: activePhases can be 1, 2 or 3 and phaseTimer can only be active if lp current is already at minCurrent
projectedSitePower -= Voltage * minCurrent * float64(activePhases-1)
}
// a continuous device that is enabled but not consuming keeps its demand out
// of site power, hiding insufficient surplus until it starts (#32282)
if lp.chargerHasFeature(api.Continuous) && !lp.charging() {
projectedSitePower += currentToPower(minCurrent, lp.minActivePhases())
}
// kick off disable sequence, unless climater keep-alive is holding
// charging at minCurrent — otherwise the "pausing soon" badge would
// flash on/off forever while climater is active (issue #29834).

View file

@ -908,3 +908,62 @@ func TestNewLoadpointFromConfigDisabledVehicle(t *testing.T) {
// disabled vehicle is filtered from instances
require.Empty(t, config.Instances(config.Vehicles().Devices()))
}
// TestPVDisableIdleContinuousDevice is a regression test for #32282: a continuous
// device that is enabled but not consuming (heat pump boost signalled, compressor
// idle) does not show its demand in site power. Without projecting it, the disable
// gate only trips on grid consumption which the idle device never causes.
func TestPVDisableIdleContinuousDevice(t *testing.T) {
const dt = time.Minute
tc := []struct {
name string
status api.ChargeStatus
chargePower float64
site float64
current float64
}{
// idle device, export below its 600W demand: disable
{"idle, insufficient surplus", api.StatusB, 0, -400, 0},
// idle device, export covers its demand: keep enabled
{"idle, sufficient surplus", api.StatusB, 0, -700, 7},
// consuming device: measured power reflects demand, keep lenient hysteresis
{"consuming, exporting", api.StatusC, 300, -200, minA},
}
for _, tc := range tc {
t.Run(tc.name, func(t *testing.T) {
clock := clock.NewMock()
Voltage = 100
lp := &Loadpoint{
log: util.NewLogger("foo"),
clock: clock,
charger: &continuousCharger{},
minCurrent: minA,
maxCurrent: maxA,
phases: 1,
phasesConfigured: 1,
measuredPhases: 1,
status: tc.status,
enabled: true,
chargePower: tc.chargePower,
Disable: loadpoint.ThresholdConfig{Delay: dt},
}
start := clock.Now()
for _, delay := range []time.Duration{0, dt + 1} {
clock.Set(start.Add(delay))
current := lp.pvMaxCurrent(api.ModePV, tc.site, 0, false, false)
// before the disable delay elapses the device keeps running
if delay == 0 {
assert.Equal(t, max(tc.current, minA), current, "before disable delay")
continue
}
assert.Equal(t, tc.current, current, "after disable delay")
}
})
}
}

View file

@ -464,6 +464,15 @@ func (c *integratedDeviceCharger) Features() []api.Feature {
return []api.Feature{api.IntegratedDevice}
}
// continuousCharger is a minimal charger advertising the Continuous feature.
type continuousCharger struct {
integratedDeviceCharger
}
func (c *continuousCharger) Features() []api.Feature {
return []api.Feature{api.IntegratedDevice, api.Continuous}
}
// TestDisconnectIntegratedDeviceKeepsMode is a regression test for #30187:
// switching an integrated-device loadpoint to "off" makes a switch socket report
// StatusA (disconnect). The disconnect handler must NOT reset the mode to the