From ebe064ad973f5c66a09931f0f3c0a2d656911e4f Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 22 Aug 2026 13:47:27 +0200 Subject: [PATCH] Loadpoint: disable PV mode when idle integrated device lacks surplus (#33073) --- core/loadpoint.go | 5 +++ core/loadpoint_test.go | 59 ++++++++++++++++++++++++++++++++++ core/loadpoint_vehicle_test.go | 9 ++++++ 3 files changed, 73 insertions(+) diff --git a/core/loadpoint.go b/core/loadpoint.go index 85ce2e0f3..aa12b2320 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -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). diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go index ccb160dcf..41f11aae3 100644 --- a/core/loadpoint_test.go +++ b/core/loadpoint_test.go @@ -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") + } + }) + } +} diff --git a/core/loadpoint_vehicle_test.go b/core/loadpoint_vehicle_test.go index 7db0ed926..b5c9c4d8d 100644 --- a/core/loadpoint_vehicle_test.go +++ b/core/loadpoint_vehicle_test.go @@ -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