Optimizer: re-run on vehicle changes and include unmodeled loads (#32681)

This commit is contained in:
andig 2026-08-10 11:03:57 +02:00 • committed by GitHub
parent cc9bfc2556
commit a237f698d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 124 additions and 13 deletions

View file

@ -573,6 +573,9 @@ func (lp *Loadpoint) evVehicleConnectHandler() {
// reset energy-based charging plan offset
lp.planEnergyOffset = 0
// connect adds the loadpoint's demand
lp.triggerOptimizer()
}
// evVehicleDisconnectHandler sends external start event
@ -636,6 +639,16 @@ func (lp *Loadpoint) evVehicleDisconnectHandler() {
// mark plan slot as inactive
// this will force a deletion of an outdated plan once plan time is expired in GetPlan()
lp.setPlanActive(false)
// disconnect removes the loadpoint's demand
lp.triggerOptimizer()
}
// triggerOptimizer re-runs the optimizer when the loadpoint's profile changed
func (lp *Loadpoint) triggerOptimizer() {
if lp.site != nil {
lp.site.Optimize()
}
}
// evVehicleSocProgressHandler sends external start event

View file

@ -13,6 +13,11 @@ type mockSite struct {
site.API
maxDischargePower float64
residualPower float64
optimized int
}
func (m *mockSite) Optimize() {
m.optimized++
}
func (m *mockSite) GetBatteryMaxDischargePower() float64 {

View file

@ -178,6 +178,9 @@ func (lp *Loadpoint) setActiveVehicle(v api.Vehicle) {
// re-assigning the same default vehicle on reconnect must keep a known soc.
if prev != v {
lp.unpublishVehicle()
// vehicle change alters the loadpoint's optimizer profile
lp.triggerOptimizer()
}
// publish effective values

View file

@ -399,6 +399,36 @@ func TestReassignActiveVehicleKeepsSoc(t *testing.T) {
assert.Equal(t, 0.0, lp.vehicleSoc, "soc must clear on vehicle change")
}
// TestActiveVehicleChangeTriggersOptimizer ensures the optimizer is re-run when
// the detected vehicle changes, as the loadpoint profile depends on it.
func TestActiveVehicleChangeTriggersOptimizer(t *testing.T) {
ctrl := gomock.NewController(t)
vehicle := api.NewMockVehicle(ctrl)
vehicle.EXPECT().GetTitle().Return("target").AnyTimes()
vehicle.EXPECT().Icon().Return("").AnyTimes()
vehicle.EXPECT().Capacity().AnyTimes()
vehicle.EXPECT().Phases().AnyTimes()
vehicle.EXPECT().OnIdentified().AnyTimes()
lp := NewLoadpoint(util.NewLogger("foo"), settings.NewDatabaseSettingsAdapter("foo"))
s := new(mockSite)
lp.site = s
x, y, z := createChannels(t)
attachChannels(lp, x, y, z)
lp.setActiveVehicle(vehicle)
assert.Equal(t, 1, s.optimized, "vehicle detected")
// re-assigning the same vehicle is not a change
lp.setActiveVehicle(vehicle)
assert.Equal(t, 1, s.optimized, "same vehicle re-assigned")
lp.setActiveVehicle(nil)
assert.Equal(t, 2, s.optimized, "vehicle removed")
}
// integratedDeviceCharger is a minimal charger advertising the IntegratedDevice feature.
type integratedDeviceCharger struct{}

View file

@ -16,7 +16,7 @@ type API interface {
Loadpoints() []loadpoint.API
Vehicles() Vehicles
Optimize() error
Optimize()
// Meta
GetTitle() string

View file

@ -13,7 +13,6 @@ import (
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/sponsor"
"github.com/samber/lo"
)
@ -39,13 +38,8 @@ func filterConfigurable(ref []string) []string {
}
// Optimize updates the optimizer
func (site *Site) Optimize() error {
if !sponsor.IsAuthorized() || !optimizerEnabled() {
return api.ErrNotAvailable
}
go site.optimizerUpdateAsync(optimizerDebounce)
return nil
func (site *Site) Optimize() {
go site.optimizerUpdateAsync(0)
}
// GetTitle returns the title

View file

@ -35,9 +35,6 @@ const (
// batteryPower is the default power of the battery in W
batteryPower = 6000
// optimizerDebounce limits how often on-demand optimizer runs execute
optimizerDebounce = 2 * time.Minute
)
// optimizerChargingStrategies are the valid grid charging strategies; the first
@ -512,13 +509,18 @@ func (site *Site) optimizerRequest(battery []types.Measurement) (optimizer.Optim
var batteries []optimizerBattery
// uncontrollable power of loadpoints that cannot be modelled as storage
var unmodelled float64
for id, lp := range site.Loadpoints() {
// ignore disconnected loadpoints, including StatusNone
if s := lp.GetStatus(); s != api.StatusB && s != api.StatusC {
continue
}
// unknown vehicle capacity: account for the consumption as uncontrollable load
if v := lp.GetVehicle(); v == nil || v.Capacity() == 0 {
unmodelled += unmodelledPower(lp)
continue
}
@ -529,6 +531,21 @@ func (site *Site) optimizerRequest(battery []types.Measurement) (optimizer.Optim
}
}
// home profile subtracts all loadpoint power, so unmodelled loadpoints would
// leave the optimizer planning against surplus that is already consumed. Their
// forecast is zero, so the measured power only decays into the near slots -
// without a capacity there is no fill point to assert it any further.
if unmodelled > 0 {
load := make([]float64, minLen)
blendMeasured(load, unmodelled/slotsPerHour, optimizerDecaySlots)
site.log.DEBUG.Printf("optimizer: home slots updated with unmodelled %.0fW loadpoint load: %.0f", unmodelled, load[:min(optimizerDecaySlots, len(load))])
for i, v := range prorate(load, firstSlotDuration) {
req.TimeSeries.Gt[i] += v
}
}
for i, dev := range site.batteryMeters {
// measurements may lag the configured meters on an off-cycle trigger
if i >= len(battery) {
@ -960,6 +977,20 @@ func loadpointProfile(lp loadpoint.API, minLen int) []float64 {
return res
}
// unmodelledPower returns the uncontrollable power of a connected loadpoint that
// cannot be modelled as storage because the vehicle capacity is unknown
func unmodelledPower(lp loadpoint.API) float64 {
power := lp.GetChargePower()
// minpv keeps drawing at least min power while the vehicle is connected,
// even before the charge meter has caught up
if lp.GetMode() == api.ModeMinPV && lp.GetStatus() == api.StatusC {
power = max(power, lp.EffectiveMinPower())
}
return max(0, power)
}
// homeProfile returns the home base load in Wh
func (site *Site) homeProfile(minLen int) ([]float64, error) {
// kWh over last 30 days

View file

@ -69,6 +69,33 @@ func TestAsTimestamps(t *testing.T) {
}, got)
}
func TestUnmodelledPower(t *testing.T) {
ctrl := gomock.NewController(t)
for _, tc := range []struct {
name string
mode api.ChargeMode
status api.ChargeStatus
power, minPower float64
expected float64
}{
{"pv charging", api.ModePV, api.StatusC, 4000, 1380, 4000},
{"pv connected", api.ModePV, api.StatusB, 0, 1380, 0},
{"minpv floor before meter caught up", api.ModeMinPV, api.StatusC, 0, 4000, 4000},
{"minpv floor must not lower measured", api.ModeMinPV, api.StatusC, 4000, 1000, 4000},
{"minpv floor only applies while charging", api.ModeMinPV, api.StatusB, 0, 4000, 0},
{"negative measurement clamped", api.ModePV, api.StatusC, -100, 0, 0},
} {
lp := loadpoint.NewMockAPI(ctrl)
lp.EXPECT().GetMode().Return(tc.mode).AnyTimes()
lp.EXPECT().GetStatus().Return(tc.status).AnyTimes()
lp.EXPECT().GetChargePower().Return(tc.power).AnyTimes()
lp.EXPECT().EffectiveMinPower().Return(tc.minPower).AnyTimes()
assert.Equal(t, tc.expected, unmodelledPower(lp), tc.name)
}
}
func TestBatteryForecastSocExtremes(t *testing.T) {
for _, tc := range []struct {
name string

View file

@ -197,7 +197,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) {
"deletesession": {"DELETE", "/session/{id:[0-9]+}", deleteSessionHandler},
"gridsessions": {"GET", "/gridsessions", gridSessionsHandler},
"energyhistory": {"GET", "/history/energy", energyHistoryHandler},
"optimize": {"POST", "/optimize", getHandler(site.Optimize)},
"optimize": {"POST", "/optimize", callHandler(site.Optimize)},
"telemetry2": {"POST", "/settings/telemetry/{value:[01truefalse]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
"devicecolors": {"PUT", "/devicecolors", updateDeviceColor(site)},

View file

@ -191,6 +191,14 @@ func getHandler[T any](get func() T) http.HandlerFunc {
}
}
// callHandler invokes an api function without result
func callHandler(fun func()) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fun()
jsonWrite(w, nil)
}
}
// updateSmartCostLimit sets the smart cost limit globally
func updateSmartCostLimit(site site.API, setLimit func(loadpoint.API, *float64)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {