Optimizer: re-run on vehicle changes and include unmodeled loads (#32681)
This commit is contained in:
parent
cc9bfc2556
commit
a237f698d0
10 changed files with 124 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ type API interface {
|
|||
|
||||
Loadpoints() []loadpoint.API
|
||||
Vehicles() Vehicles
|
||||
Optimize() error
|
||||
Optimize()
|
||||
|
||||
// Meta
|
||||
GetTitle() string
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue