Optimizer: stop demanding charge once the vehicle is full (#32633)

This commit is contained in:
andig 2026-08-08 12:58:44 +02:00 • committed by GitHub
parent 6a976286d1
commit 29137181f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 90 additions and 1 deletions

View file

@ -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,

View file

@ -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))
}