diff --git a/core/site_optimizer.go b/core/site_optimizer.go index ed29c4415..4a9f6ec64 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -657,8 +657,12 @@ func (site *Site) batteryRequest(dev config.Device[api.Meter], b types.Measureme if m, ok := api.Cap[api.BatterySocLimiter](instance); ok { minSoc, maxSoc := m.GetSocLimits() - bat.SMin = float32(*b.Capacity * minSoc * 10) // Wh - bat.SMax = float32(*b.Capacity * maxSoc * 10) // Wh + if maxSoc == 0 { + maxSoc = 100 // empty/unset maxsoc means no upper limit + } + // clamp against current soc to prevent infeasible if it is outside the configured limits + bat.SMin = min(bat.SInitial, float32(*b.Capacity*minSoc*10)) // Wh + bat.SMax = max(bat.SInitial, float32(*b.Capacity*maxSoc*10)) // Wh } detail := batteryDetail{ diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go index 7b9094a81..92dd3da8c 100644 --- a/core/site_optimizer_test.go +++ b/core/site_optimizer_test.go @@ -6,7 +6,9 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/loadpoint" + "github.com/evcc-io/evcc/core/types" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/config" optimizer "github.com/evcc-io/optimizer/client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -169,6 +171,80 @@ func TestBatteryForecastSocExtremes(t *testing.T) { }) } } + +// TestBatteryRequestSocLimitsClamp ensures the reported soc is always clamped into +// the resulting [SMin, SMax] range, even when it lies outside the configured soc +// limits (e.g. right after a firmware update changed the reported soc or the min/max +// soc settings) - otherwise the optimizer is infeasible from the first slot. +func TestBatteryRequestSocLimitsClamp(t *testing.T) { + newBatteryDevice := func(t *testing.T, minSoc, maxSoc float64) config.Device[api.Meter] { + ctrl := gomock.NewController(t) + + var meter api.Meter + batSocLimit := api.NewMockBatterySocLimiter(ctrl) + batSocLimit.EXPECT().GetSocLimits().Return(minSoc, maxSoc).AnyTimes() + + bat := &struct { + api.Meter + api.BatterySocLimiter + }{ + Meter: meter, + BatterySocLimiter: batSocLimit, + } + + return config.NewStaticDevice(config.Named{}, api.Meter(bat)) + } + + site := &Site{log: util.NewLogger("foo")} + capacity := 10.0 // kWh + + t.Run("soc below minSoc", func(t *testing.T) { + soc := 15.0 + dev := newBatteryDevice(t, 20, 100) + m := types.Measurement{Capacity: &capacity, Soc: &soc} + + req, _ := site.batteryRequest(dev, m, nil, 8, 15*time.Minute) + + assert.Equal(t, float32(1500), req.SMin) + assert.Equal(t, float32(10000), req.SMax) + assert.LessOrEqual(t, req.SMin, req.SInitial) + }) + + t.Run("soc above maxSoc", func(t *testing.T) { + soc := 95.0 + dev := newBatteryDevice(t, 0, 80) + m := types.Measurement{Capacity: &capacity, Soc: &soc} + + req, _ := site.batteryRequest(dev, m, nil, 8, 15*time.Minute) + + assert.Equal(t, float32(0), req.SMin) + assert.Equal(t, float32(9500), req.SMax) + assert.GreaterOrEqual(t, req.SMax, req.SInitial) + }) + + t.Run("soc within limits", func(t *testing.T) { + soc := 50.0 + dev := newBatteryDevice(t, 20, 80) + m := types.Measurement{Capacity: &capacity, Soc: &soc} + + req, _ := site.batteryRequest(dev, m, nil, 8, 15*time.Minute) + + assert.Equal(t, float32(2000), req.SMin) + assert.Equal(t, float32(8000), req.SMax) + }) + + t.Run("empty maxSoc defaults to 100%", func(t *testing.T) { + soc := 50.0 + dev := newBatteryDevice(t, 20, 0) + m := types.Measurement{Capacity: &capacity, Soc: &soc} + + req, _ := site.batteryRequest(dev, m, nil, 8, 15*time.Minute) + + assert.Equal(t, float32(2000), req.SMin) + assert.Equal(t, float32(10000), req.SMax) + }) +} + func TestOptimizerChargingStrategy(t *testing.T) { site := &Site{log: util.NewLogger("foo")}