From 0bbf4dc5c75c8fb047fead62096feb90661ed05f Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 16 Aug 2026 09:52:52 +0200 Subject: [PATCH] Optimizer: consider planner precondition (late charging) (#32872) --- core/site_optimizer.go | 50 +++++++++++++++++++++++++++++++++++-- core/site_optimizer_test.go | 34 +++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 809866eb8..0af96ef38 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -832,13 +832,15 @@ func (site *Site) loadpointRequest(lp loadpoint.API, minLen int, firstSlotDurati case api.ModeMinPV: // forced min charging demand = continuousDemand(lp, minLen) - // add smartcost limit and plan goal, if configured + // add smartcost limit, precondition and plan goal, if configured demand = applySmartCostLimit(lp, demand, grid, minLen) + demand = applyPrecondition(lp, demand, minLen) site.applyPlanGoal(lp, &bat, minLen) case api.ModePV: - // add smartcost limit and plan goal, if configured + // add smartcost limit, precondition and plan goal, if configured demand = applySmartCostLimit(lp, nil, grid, minLen) + demand = applyPrecondition(lp, demand, minLen) site.applyPlanGoal(lp, &bat, minLen) } @@ -1222,6 +1224,50 @@ func applySmartCostLimit(lp loadpoint.API, demand []float32, grid api.Rates, min return demand } +// applyPrecondition forces max charging power during the planner's precondition window +// ("late charging"), i.e. the last precondition duration before the plan time +func applyPrecondition(lp loadpoint.API, demand []float32, minLen int) []float32 { + precondition := lp.EffectivePlanStrategy().Precondition + if precondition <= 0 { + return demand + } + + ts := lp.EffectivePlanTime() + if ts.IsZero() { + return demand + } + + // TODO precise slot placement + end := time.Until(ts) + start := end - precondition + if end <= 0 { + return demand + } + + first := max(int(start/tariff.SlotDuration), 0) + if first >= minLen { + return demand + } + + if demand == nil { + demand = make([]float32, minLen) + } + + energy := float32(lp.EffectiveMaxPower() / slotsPerHour) + + for i := first; i < minLen; i++ { + slotStart := time.Duration(i) * tariff.SlotDuration + overlap := min(end, slotStart+tariff.SlotDuration) - max(start, slotStart) + if overlap <= 0 { + break + } + + demand[i] = max(demand[i], energy*float32(overlap)/float32(tariff.SlotDuration)) + } + + return demand +} + func (site *Site) applyBatteryGridChargeLimit(cMax float32, grid api.Rates, minLen int) []float32 { limit := site.GetBatteryGridChargeLimit() if limit == nil { diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go index a50549bff..634e90ebe 100644 --- a/core/site_optimizer_test.go +++ b/core/site_optimizer_test.go @@ -29,6 +29,40 @@ func TestLoadpointProfile(t *testing.T) { require.Equal(t, []float64{250, 250, 250, 250, 250, 250, 250, 50}, loadpointProfile(lp, 8)) } +func TestApplyPrecondition(t *testing.T) { + ctrl := gomock.NewController(t) + + lp := loadpoint.NewMockAPI(ctrl) + lp.EXPECT().EffectiveMaxPower().Return(8000.0).AnyTimes() // 2 kWh per slot + + // no precondition configured + lp.EXPECT().EffectivePlanStrategy().Return(api.PlanStrategy{}).Times(1) + assert.Nil(t, applyPrecondition(lp, nil, 8)) + + // no plan + lp.EXPECT().EffectivePlanStrategy().Return(api.PlanStrategy{Precondition: time.Hour}).Times(1) + lp.EXPECT().EffectivePlanTime().Return(time.Time{}).Times(1) + assert.Nil(t, applyPrecondition(lp, nil, 8)) + + // plan in 1h, 40min precondition: slots 1 (10min) and 2, 3 (full) + lp.EXPECT().EffectivePlanTime().Return(time.Now().Add(time.Hour)).Times(1) + lp.EXPECT().EffectivePlanStrategy().Return(api.PlanStrategy{Precondition: 40 * time.Minute}).Times(1) + res := applyPrecondition(lp, nil, 8) + require.Len(t, res, 8) + assert.InDeltaSlice(t, []float32{0, 2000. / 1.5, 2000, 2000, 0, 0, 0, 0}, res, 1) + + // existing demand is kept where higher + lp.EXPECT().EffectivePlanTime().Return(time.Now().Add(time.Hour)).Times(1) + lp.EXPECT().EffectivePlanStrategy().Return(api.PlanStrategy{Precondition: 30 * time.Minute}).Times(1) + res = applyPrecondition(lp, []float32{3000, 3000, 3000, 3000, 0, 0, 0, 0}, 8) + assert.InDeltaSlice(t, []float32{3000, 3000, 3000, 3000, 0, 0, 0, 0}, res, 1) + + // plan beyond horizon + lp.EXPECT().EffectivePlanTime().Return(time.Now().Add(24 * time.Hour)).Times(1) + lp.EXPECT().EffectivePlanStrategy().Return(api.PlanStrategy{Precondition: time.Hour}).Times(1) + assert.Nil(t, applyPrecondition(lp, nil, 8)) +} + func TestLoadpointCurrentAction(t *testing.T) { for _, tc := range []struct { name string