diff --git a/core/loadpoint.go b/core/loadpoint.go index e05835cb7..8a2aba36d 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -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 diff --git a/core/loadpoint_boost_test.go b/core/loadpoint_boost_test.go index 78594c4fb..3a2fa09cc 100644 --- a/core/loadpoint_boost_test.go +++ b/core/loadpoint_boost_test.go @@ -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 { diff --git a/core/loadpoint_vehicle.go b/core/loadpoint_vehicle.go index c7d55c67d..1860df617 100644 --- a/core/loadpoint_vehicle.go +++ b/core/loadpoint_vehicle.go @@ -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 diff --git a/core/loadpoint_vehicle_test.go b/core/loadpoint_vehicle_test.go index e03d21746..028e447c9 100644 --- a/core/loadpoint_vehicle_test.go +++ b/core/loadpoint_vehicle_test.go @@ -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{} diff --git a/core/site/api.go b/core/site/api.go index df9a6d17f..373584e06 100644 --- a/core/site/api.go +++ b/core/site/api.go @@ -16,7 +16,7 @@ type API interface { Loadpoints() []loadpoint.API Vehicles() Vehicles - Optimize() error + Optimize() // Meta GetTitle() string diff --git a/core/site_api.go b/core/site_api.go index eb755a8cd..f25806ea3 100644 --- a/core/site_api.go +++ b/core/site_api.go @@ -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 diff --git a/core/site_optimizer.go b/core/site_optimizer.go index ef1a7b983..61da1a354 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -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 diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go index 9e8d5bbb6..a50549bff 100644 --- a/core/site_optimizer_test.go +++ b/core/site_optimizer_test.go @@ -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 diff --git a/server/http.go b/server/http.go index 7b91dabc0..cb08a71dc 100644 --- a/server/http.go +++ b/server/http.go @@ -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)}, diff --git a/server/http_site_handler.go b/server/http_site_handler.go index 1bfd7daf3..c6446205a 100644 --- a/server/http_site_handler.go +++ b/server/http_site_handler.go @@ -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) {