From 29137181f1b520dd733de538d47f739b982679a6 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 8 Aug 2026 12:58:44 +0200 Subject: [PATCH] Optimizer: stop demanding charge once the vehicle is full (#32633) --- core/site_optimizer.go | 25 ++++++++++- core/site_optimizer_demand_test.go | 66 ++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 core/site_optimizer_demand_test.go diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 17d50e69c..87027d4b6 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -800,12 +800,35 @@ func (site *Site) loadpointRequest(lp loadpoint.API, minLen int, firstSlotDurati } if demand != nil { - bat.PDemand = prorate(demand, firstSlotDuration) + // after prorate, so the shortened first slot counts with the energy it really carries + bat.PDemand = clearDemandWhenFull(prorate(demand, firstSlotDuration), bat.SMax-bat.SInitial) } return bat, detail } +// clearDemandWhenFull zeroes the charge demand from the slot the accumulated energy fills the +// vehicle. The optimizer drops the demand at s_max anyway, but pays two binaries per slot to +// detect it, so slots that cannot bind are worth not asking about. Losses are accounted for. +// +// The cut assumes the demand is met every slot. A grid import limit can throttle charging below +// it, moving the real fill point later than the estimate - the next request corrects that from +// the measured soc, and the near slots are never affected because the cut sits a full charge away. +func clearDemandWhenFull(demand []float32, headroom float32) []float32 { + res := slices.Clone(demand) + + var acc float32 + for i, d := range res { + if acc >= headroom { + res[i] = 0 + continue + } + acc += d * eta + } + + return res +} + func (site *Site) batteryRequest(dev config.Device[api.Meter], b types.Measurement, grid api.Rates, minLen int, firstSlotDuration time.Duration) (optimizer.BatteryConfig, batteryDetail) { bat := optimizer.BatteryConfig{ CMax: batteryPower, diff --git a/core/site_optimizer_demand_test.go b/core/site_optimizer_demand_test.go new file mode 100644 index 000000000..4fa89f024 --- /dev/null +++ b/core/site_optimizer_demand_test.go @@ -0,0 +1,66 @@ +package core + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestClearDemandWhenFull(t *testing.T) { + for _, tc := range []struct { + name string + demand []float32 + headroom float32 + expected []float32 + }{ + { + // 345Wh demanded per slot stores 310.5Wh, so 1000Wh of headroom is covered + // during the fourth slot- that slot still binds, the ones after it cannot + "cut once the accumulated energy fills the vehicle", + []float32{345, 345, 345, 345, 345, 345}, + 1000, + []float32{345, 345, 345, 345, 0, 0}, + }, + { + "headroom beyond the horizon leaves the demand untouched", + []float32{345, 345, 345}, + 1e6, + []float32{345, 345, 345}, + }, + { + // already at the soc limit: every slot is relaxed at s_max anyway + "no headroom clears the whole demand", + []float32{345, 345, 345}, + 0, + []float32{0, 0, 0}, + }, + { + // the shortened first slot carries less energy and must not pull the cut in + "partial first slot counts with its own energy", + []float32{100, 345, 345, 345}, + 600, + []float32{100, 345, 345, 0}, + }, + { + "empty demand stays empty", + []float32{}, + 1000, + []float32{}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, clearDemandWhenFull(tc.demand, tc.headroom)) + }) + } +} + +// the request contract requires every series to have the same length +func TestClearDemandWhenFullKeepsLength(t *testing.T) { + demand := make([]float32, 96) + for i := range demand { + demand[i] = 345 + } + + assert.Len(t, clearDemandWhenFull(demand, 1000), len(demand)) + assert.Len(t, clearDemandWhenFull(demand, 0), len(demand)) +}