From bb6854b1edb9be1898e84479becfef40e67ff4e0 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 16 Aug 2026 17:18:34 +0200 Subject: [PATCH] Optimizer: respect session energy limit when vehicle soc is unknown (#32895) --- core/loadpoint/api.go | 2 ++ core/loadpoint/mock.go | 14 +++++++++++ core/optimizer.md | 4 ++++ core/site_optimizer.go | 27 ++++++++++++--------- core/site_optimizer_test.go | 48 +++++++++++++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 11 deletions(-) diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go index ef4b5b05a..93a4dd36a 100644 --- a/core/loadpoint/api.go +++ b/core/loadpoint/api.go @@ -219,6 +219,8 @@ type API interface { GetRemainingDuration() time.Duration // GetRemainingEnergy is the remaining charge energy in kWh GetRemainingEnergy() float64 + // GetChargedEnergy returns session charge energy in Wh + GetChargedEnergy() float64 // // vehicles diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go index c156ad5a2..b2509b049 100644 --- a/core/loadpoint/mock.go +++ b/core/loadpoint/mock.go @@ -221,6 +221,20 @@ func (mr *MockAPIMockRecorder) GetChargePowerFlexibility(rates any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChargePowerFlexibility", reflect.TypeOf((*MockAPI)(nil).GetChargePowerFlexibility), rates) } +// GetChargedEnergy mocks base method. +func (m *MockAPI) GetChargedEnergy() float64 { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChargedEnergy") + ret0, _ := ret[0].(float64) + return ret0 +} + +// GetChargedEnergy indicates an expected call of GetChargedEnergy. +func (mr *MockAPIMockRecorder) GetChargedEnergy() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChargedEnergy", reflect.TypeOf((*MockAPI)(nil).GetChargedEnergy)) +} + // GetChargerRef mocks base method. func (m *MockAPI) GetChargerRef() string { m.ctrl.T.Helper() diff --git a/core/optimizer.md b/core/optimizer.md index c792b84cc..f403699a5 100644 --- a/core/optimizer.md +++ b/core/optimizer.md @@ -47,3 +47,7 @@ Use minimum of energy consumption cost. - home battery or loadpoint/vehicle... - capacity, soc and charge goals - charge/discharge power limits and efficiency + +Without vehicle capacity or soc a configured session energy limit is modelled instead: +state is the session's charged energy, goal is the limit. Loadpoints with neither are +not modelled at all- their power is added to the base load. diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 0af96ef38..3952062ba 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -522,8 +522,9 @@ func (site *Site) optimizerRequest(battery []types.Measurement) (optimizer.Optim continue } - // unknown vehicle capacity: account for the consumption as uncontrollable load - if v := lp.GetVehicle(); v == nil || v.Capacity() == 0 { + // no vehicle capacity and no session energy limit to model against: + // account for the consumption as uncontrollable load + if v := lp.GetVehicle(); v == nil || (v.Capacity() == 0 && lp.GetLimitEnergy() == 0) { unmodelled += unmodelledPower(lp) continue } @@ -790,18 +791,22 @@ func (site *Site) loadpointRequest(lp loadpoint.API, minLen int, firstSlotDurati // vehicle v := lp.GetVehicle() - maxSoc := v.Capacity() * 1e3 // Wh - if v := lp.EffectiveLimitSoc(); v > 0 { - maxSoc *= float64(v) / 100 - } else if v := lp.GetLimitEnergy(); v > 0 { - maxSoc = v * 1e3 + capacity := v.Capacity() // kWh + soc := lp.GetSoc() // percent + + // without capacity or soc there is no battery state to model, but a session energy + // limit still bounds the charge- use charged energy as state (see remainingLimitEnergy) + if limit := lp.GetLimitEnergy(); limit > 0 && (capacity == 0 || soc == 0) { + bat.SInitial = float32(lp.GetChargedEnergy()) // Wh + bat.SMax = max(bat.SInitial, float32(limit*1e3)) // prevent infeasible if limit already exceeded + } else { + maxSoc := capacity * float64(lp.EffectiveLimitSoc()) * 10 // Wh + bat.SInitial = float32(capacity * soc * 10) // Wh + bat.SMax = max(bat.SInitial, float32(maxSoc)) // prevent infeasible if current soc above maximum } - bat.SInitial = float32(v.Capacity() * lp.GetSoc() * 10) // Wh - bat.SMax = max(bat.SInitial, float32(maxSoc)) // prevent infeasible if current soc above maximum - detail.Type = batteryTypeVehicle - detail.Capacity = v.Capacity() + detail.Capacity = capacity if vt := v.GetTitle(); vt != "" { if detail.Title != "" { diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go index 634e90ebe..446e8889c 100644 --- a/core/site_optimizer_test.go +++ b/core/site_optimizer_test.go @@ -320,6 +320,54 @@ func TestBatteryRequestSocLimitsClamp(t *testing.T) { }) } +// charge goal for vehicles with and without known capacity/soc, see #32890 +func TestLoadpointRequestChargeGoal(t *testing.T) { + site := &Site{log: util.NewLogger("foo")} + + for _, tc := range []struct { + name string + capacity, soc float64 // kWh, percent + limitSoc int // percent + limitEnergy, charged float64 // kWh, Wh + wantInitial, wantSMax float32 // Wh + }{ + {"soc limit", 50, 20, 80, 0, 0, 10000, 40000}, + {"no capacity, energy limit", 0, 0, 100, 10, 0, 0, 10000}, + {"no capacity, energy limit partially charged", 0, 0, 100, 10, 4000, 4000, 10000}, + {"no capacity, limit exceeded", 0, 0, 100, 10, 11000, 11000, 11000}, + {"capacity but no soc, energy limit", 50, 0, 100, 10, 0, 0, 10000}, + {"capacity but no soc, no energy limit", 50, 0, 100, 0, 0, 0, 50000}, + } { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + + v := api.NewMockVehicle(ctrl) + v.EXPECT().Capacity().Return(tc.capacity).AnyTimes() + v.EXPECT().GetTitle().Return("").AnyTimes() + + lp := loadpoint.NewMockAPI(ctrl) + lp.EXPECT().GetVehicle().Return(v).AnyTimes() + lp.EXPECT().GetSoc().Return(tc.soc).AnyTimes() + lp.EXPECT().EffectiveLimitSoc().Return(tc.limitSoc).AnyTimes() + lp.EXPECT().GetLimitEnergy().Return(tc.limitEnergy).AnyTimes() + lp.EXPECT().GetChargedEnergy().Return(tc.charged).AnyTimes() + lp.EXPECT().GetTitle().Return("lp").AnyTimes() + lp.EXPECT().EffectiveMinPower().Return(1380.0).AnyTimes() + lp.EXPECT().EffectiveMaxPower().Return(11000.0).AnyTimes() + lp.EXPECT().GetMode().Return(api.ModePV).AnyTimes() + lp.EXPECT().GetStatus().Return(api.StatusB).AnyTimes() + lp.EXPECT().GetSmartCostLimit().Return(nil).AnyTimes() + lp.EXPECT().EffectivePlanStrategy().Return(api.PlanStrategy{}).AnyTimes() + lp.EXPECT().GetPlanGoal().Return(0.0, false).AnyTimes() + + req, _ := site.loadpointRequest(lp, 8, 15*time.Minute, nil) + + assert.Equal(t, tc.wantInitial, req.SInitial) + assert.Equal(t, tc.wantSMax, req.SMax) + }) + } +} + func TestOptimizerChargingStrategy(t *testing.T) { site := &Site{log: util.NewLogger("foo")}