diff --git a/core/site.go b/core/site.go index 329f464a6..ad83a1420 100644 --- a/core/site.go +++ b/core/site.go @@ -34,7 +34,6 @@ import ( "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/config" "github.com/evcc-io/evcc/util/modbus" - "github.com/evcc-io/evcc/util/sponsor" "github.com/evcc-io/evcc/util/telemetry" "github.com/samber/lo" "github.com/smallnest/chanx" @@ -116,9 +115,11 @@ type Site struct { batteryMode api.BatteryMode // Battery mode (runtime only, not persisted) batteryModeExternal api.BatteryMode // Battery mode (external, runtime only, not persisted) batteryModeExternalTimer time.Time // Battery mode timer for external control - batterySuggestions map[string]types.Suggestion // Optimizer suggestions by battery meter name - loadpointSuggestions map[int]types.Suggestion // Optimizer suggestions by loadpoint id + suggestions map[string]types.Suggestion // Optimizer suggestions by device key suggestionActions map[string]string // last notified actionable optimizer action by device key + + optimizerMu sync.Mutex // guards optimizer runs + optimizerUpdated time.Time // last optimizer run, guarded by optimizerMu } // MetersConfig contains the site's meter configuration @@ -788,8 +789,9 @@ func (site *Site) updateBatteryMeters() { // publishBattery applies the optimizer suggestions and publishes the battery state func (site *Site) publishBattery() { + mode := site.GetBatteryMode().String() for i, d := range site.battery.Devices { - site.battery.Devices[i].Suggestion = site.batterySuggestion(d.Name) + site.battery.Devices[i].Suggestion = site.suggestion(batteryKey(d.Name), mode) } site.publish(keys.Battery, site.battery) @@ -959,9 +961,7 @@ func (site *Site) updateMeters() error { return err } - if sponsor.IsAuthorized() && optimizerEnabled() && time.Since(optimizerUpdated) >= tariff.SlotDuration { - go site.optimizerUpdateAsync() - } + go site.optimizerUpdateAsync(tariff.SlotDuration) return nil } diff --git a/core/site_api.go b/core/site_api.go index e6830c562..eb755a8cd 100644 --- a/core/site_api.go +++ b/core/site_api.go @@ -44,7 +44,7 @@ func (site *Site) Optimize() error { return api.ErrNotAvailable } - go site.optimizerUpdateAsync() + go site.optimizerUpdateAsync(optimizerDebounce) return nil } @@ -369,7 +369,7 @@ func (site *Site) SetGridExportLimit(power float64) error { site.publish(keys.GridExportLimit, power) // re-run the optimizer so the new limit takes effect immediately - site.triggerOptimizer() + go site.optimizerUpdateAsync(0) } return nil @@ -519,7 +519,7 @@ func (site *Site) SetOptimizerChargingStrategy(strategy string) error { site.publish(keys.OptimizerChargingStrategy, strategy) // re-run the optimizer so the new strategy takes effect immediately - site.triggerOptimizer() + go site.optimizerUpdateAsync(0) } return nil diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 87027d4b6..bdd34a44b 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -10,7 +10,6 @@ import ( "os" "slices" "strings" - "sync" "time" "github.com/evcc-io/evcc/api" @@ -30,12 +29,15 @@ import ( "golang.org/x/exp/constraints" ) -var ( - eta = float32(0.9) // efficiency of the battery charging/discharging - batteryPower = float32(6000) // default power of the battery in W +const ( + // eta is the efficiency of the battery charging/discharging + eta = 0.9 - mu sync.Mutex - optimizerUpdated time.Time + // 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 @@ -53,23 +55,6 @@ const defaultOptimizerChargingStrategy = string(optimizer.OptimizerStrategyCharg // optimizerDecaySlots is the number of slots over which measured values decay into the forecast const optimizerDecaySlots = 4 -// triggerOptimizer re-runs the optimizer immediately so a changed setting takes -// effect without waiting for the next slot. It is a no-op when the optimizer is -// not active or a run is already in progress; the running update reflects the -// change on its next slot. -func (site *Site) triggerOptimizer() { - if !sponsor.IsAuthorized() || !optimizerEnabled() { - return - } - if !mu.TryLock() { - return - } - optimizerUpdated = time.Time{} // bypass the slot/debounce gate - mu.Unlock() - - go site.optimizerUpdateAsync() -} - // optimizerResult wraps the optimizer publish payload to implement BytesMarshaler. // This ensures publishComplex serializes it as a single JSON message instead of // recursively decomposing each struct field and array element into individual MQTT @@ -104,7 +89,34 @@ type batteryDetail struct { Capacity float64 `json:"capacity,omitempty"` loadpoint *int // originating loadpoint id for loadpoint/vehicle entries - controllable bool // battery exposes a controller; only these get suggestions + controllable bool // device can act on suggestions +} + +// batteryKey and loadpointKey build the canonical device keys used for +// suggestion routing and notifications +func batteryKey(name string) string { return "battery:" + name } +func loadpointKey(id int) string { return fmt.Sprintf("loadpoint:%d", id) } + +// key identifies the device across optimizer runs; an empty key means the +// device can't act on a suggestion. +func (d batteryDetail) key() string { + switch { + case d.Type == batteryTypeBattery: + return batteryKey(d.Name) + case d.loadpoint != nil: + return loadpointKey(*d.loadpoint) + default: + return "" + } +} + +// currentAction returns the device's current operating mode for suggestion +// comparison. Must only be called for devices with a non-empty key. +func (d batteryDetail) currentAction(site *Site) string { + if d.Type == batteryTypeBattery { + return site.GetBatteryMode().String() + } + return loadpointCurrentAction(site.loadpoints[*d.loadpoint]) } type batteryResult struct { @@ -136,10 +148,8 @@ type pendingSuggestion struct { event messenger.Event } -// suggestionEvent builds the notification key and event for a device suggestion. -// The key ("loadpoint:" / "battery:") identifies the device across -// runs; an empty key means the device can't act on a suggestion. -func suggestionEvent(detail batteryDetail, s types.Suggestion) (string, messenger.Event) { +// suggestionEvent builds the notification event for a device suggestion +func suggestionEvent(detail batteryDetail, s types.Suggestion) messenger.Event { ev := messenger.Event{Event: evSuggestion, Attributes: map[string]any{ "suggestionAction": s.Action, "suggestionTitle": detail.Title, @@ -148,14 +158,12 @@ func suggestionEvent(detail batteryDetail, s types.Suggestion) (string, messenge switch { case detail.Type == batteryTypeBattery: ev.Attributes["suggestionName"] = detail.Name - return "battery:" + detail.Name, ev case detail.loadpoint != nil: id := *detail.loadpoint ev.Loadpoint = &id - return fmt.Sprintf("loadpoint:%d", id), ev - default: - return "", ev } + + return ev } // currentSlotSuggestion maps the optimizer's first-slot corner result onto an advisory action. @@ -216,55 +224,35 @@ func loadpointCurrentAction(lp *Loadpoint) string { } // setSuggestions replaces the suggestions applied on each publish -func (site *Site) setSuggestions(batteries map[string]types.Suggestion, loadpoints map[int]types.Suggestion) { +func (site *Site) setSuggestions(suggestions map[string]types.Suggestion) { site.Lock() defer site.Unlock() - site.batterySuggestions = batteries - site.loadpointSuggestions = loadpoints + site.suggestions = suggestions } -// batterySuggestion returns the optimizer suggestion for the given battery meter. -// The actionable flag is evaluated on read since the battery mode changes between -// optimizer runs. -func (site *Site) batterySuggestion(name string) *types.Suggestion { - mode := site.GetBatteryMode().String() - +// suggestion returns the optimizer suggestion for the given device key. +// The actionable flag is evaluated on read against the device's current +// action since that changes between optimizer runs. +func (site *Site) suggestion(key, currentAction string) *types.Suggestion { site.RLock() - defer site.RUnlock() - - s, ok := site.batterySuggestions[name] - if !ok { - return nil - } - - s.Actionable = s.Action != mode - - return &s -} - -// loadpointSuggestion returns the optimizer suggestion for the given loadpoint. -// The actionable flag is evaluated on read since the loadpoint's action changes -// between optimizer runs. -func (site *Site) loadpointSuggestion(id int) *types.Suggestion { - site.RLock() - s, ok := site.loadpointSuggestions[id] + s, ok := site.suggestions[key] site.RUnlock() if !ok { return nil } - s.Actionable = s.Action != loadpointCurrentAction(site.loadpoints[id]) + s.Actionable = s.Action != currentAction return &s } // publishSuggestions publishes the loadpoints' suggestions func (site *Site) publishSuggestions() { - for id := range site.loadpoints { + for id, lp := range site.loadpoints { var val any - if s := site.loadpointSuggestion(id); s != nil { + if s := site.suggestion(loadpointKey(id), loadpointCurrentAction(lp)); s != nil { val = *s } site.publishLoadpoint(id, keys.Suggestion, val) @@ -274,7 +262,7 @@ func (site *Site) publishSuggestions() { // clearSuggestions removes all suggestions and the battery forecast when the // optimizer result is stale func (site *Site) clearSuggestions() { - site.setSuggestions(nil, nil) + site.setSuggestions(nil) site.battery.Forecast = nil site.publishBattery() @@ -291,21 +279,17 @@ func (site *Site) pendingSuggestions(details []batteryDetail) map[string]pending pending := make(map[string]pendingSuggestion, len(details)) for _, detail := range details { - var s *types.Suggestion - - switch { - case detail.Type == batteryTypeBattery: - s = site.batterySuggestion(detail.Name) - case detail.loadpoint != nil: - s = site.loadpointSuggestion(*detail.loadpoint) + key := detail.key() + if key == "" { + continue } + s := site.suggestion(key, detail.currentAction(site)) if s == nil { continue } - key, ev := suggestionEvent(detail, *s) - pending[key] = pendingSuggestion{suggestion: *s, event: ev} + pending[key] = pendingSuggestion{suggestion: *s, event: suggestionEvent(detail, *s)} } return pending @@ -346,19 +330,41 @@ type requestDetails struct { BatteryDetails []batteryDetail `json:"batteryDetails"` } +// optimizerBattery pairs a battery request entry with its device detail +type optimizerBattery struct { + cfg optimizer.BatteryConfig + detail batteryDetail +} + +func optimizerURI() string { + return cmp.Or(os.Getenv("OPTIMIZER_URI"), OPTIMIZER_URI) +} + const slotsPerHour = float64(time.Hour / tariff.SlotDuration) // errOptimizerNotReady means battery measurements aren't available yet (e.g. at // startup); the slot gate is left open so the next cycle retries. var errOptimizerNotReady = errors.New("battery measurements not ready") -func (site *Site) optimizerUpdateAsync() { - if !mu.TryLock() { +// optimizerUpdateAsync runs the optimizer unless the last run is younger than +// minAge. Pass 0 to force a run, e.g. when a changed setting should take effect +// without waiting for the next slot. It is a no-op when the optimizer is not +// active or a run is already in progress; the running update reflects the +// change on its next slot. +func (site *Site) optimizerUpdateAsync(minAge time.Duration) { + if !sponsor.IsAuthorized() || !optimizerEnabled() { return } - defer mu.Unlock() - if time.Since(optimizerUpdated) < 2*time.Minute { + if !site.optimizerMu.TryLock() { + return + } + defer site.optimizerMu.Unlock() + + if minAge == 0 { + // keep the gate open so a not-ready run is retried on the next cycle + site.optimizerUpdated = time.Time{} + } else if time.Since(site.optimizerUpdated) < minAge { return } @@ -374,7 +380,7 @@ func (site *Site) optimizerUpdateAsync() { return } - optimizerUpdated = time.Now() + site.optimizerUpdated = time.Now() if err != nil { site.log.ERROR.Println("optimizer:", err) @@ -387,7 +393,12 @@ func (site *Site) optimizerUpdateAsync() { err = site.optimizerUpdate(site.battery.Devices) } -func (site *Site) optimizerUpdate(battery []types.Measurement) error { +// optimizerRequest assembles the optimizer request and the matching device +// details from tariffs, home profile, loadpoints and battery meters +func (site *Site) optimizerRequest(battery []types.Measurement) (optimizer.OptimizationInput, requestDetails, error) { + var req optimizer.OptimizationInput + var details requestDetails + solarTariff := site.GetTariff(api.TariffUsageSolar) solar := currentRates(solarTariff) @@ -400,17 +411,16 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { minLen = min(minLen, len(solar)) } - uri := cmp.Or(os.Getenv("OPTIMIZER_URI"), OPTIMIZER_URI) - if uri == OPTIMIZER_URI { + if optimizerURI() == OPTIMIZER_URI { // limit to 2 days for sake of performance minLen = min(2*96, minLen) } if expectedSlots := 8; minLen < expectedSlots { if solarTariff != nil { - return fmt.Errorf("not enough forecast slots for meaningful optimization: %d < %d (grid=%d, feedIn=%d, solar=%d)", minLen, expectedSlots, len(grid), len(feedIn), len(solar)) + return req, details, fmt.Errorf("not enough forecast slots for meaningful optimization: %d < %d (grid=%d, feedIn=%d, solar=%d)", minLen, expectedSlots, len(grid), len(feedIn), len(solar)) } - return fmt.Errorf("not enough forecast slots for meaningful optimization: %d < %d (grid=%d, feedIn=%d)", minLen, expectedSlots, len(grid), len(feedIn)) + return req, details, fmt.Errorf("not enough forecast slots for meaningful optimization: %d < %d (grid=%d, feedIn=%d)", minLen, expectedSlots, len(grid), len(feedIn)) } now := time.Now() @@ -426,7 +436,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { gt, err := site.homeProfile(minLen) if err != nil { - return err + return req, details, err } // blend measured energy of the last metrics slot into the first slots @@ -441,7 +451,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { if solarTariff != nil && len(solar) > 0 { solarEnergy, err := solarRatesToEnergy(solar) if err != nil { - return err + return req, details, err } scale := site.effectiveSolarScale() @@ -456,7 +466,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { ft = prorate(ftSlots, firstSlotDuration) } - req := optimizer.OptimizationInput{ + req = optimizer.OptimizationInput{ Strategy: optimizer.OptimizerStrategy{ ChargingStrategy: optimizer.OptimizerStrategyChargingStrategy(site.GetOptimizerChargingStrategy()), DischargingStrategy: optimizer.OptimizerStrategyDischargingStrategyDischargeBeforeImport, @@ -475,7 +485,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { // end of horizon Wh value pa := lo.Min(req.TimeSeries.PN) * eta * 0.99 - details := requestDetails{ + details = requestDetails{ Timestamps: asTimestamps(dt, now), } @@ -500,11 +510,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { } } - add := func(battery optimizer.BatteryConfig, detail batteryDetail) { - battery.PA = pa - req.Batteries = append(req.Batteries, battery) - details.BatteryDetails = append(details.BatteryDetails, detail) - } + var batteries []optimizerBattery for id, lp := range site.Loadpoints() { // ignore disconnected loadpoints, including StatusNone @@ -517,9 +523,9 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { } // skip disabled loadpoints - if req, detail := site.loadpointRequest(lp, minLen, firstSlotDuration, grid); req.CMax > 0 { + if cfg, detail := site.loadpointRequest(lp, minLen, firstSlotDuration, grid); cfg.CMax > 0 { detail.loadpoint = &id - add(req, detail) + batteries = append(batteries, optimizerBattery{cfg, detail}) } } @@ -534,7 +540,23 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { continue } - add(site.batteryRequest(dev, b, grid, minLen, firstSlotDuration)) + cfg, detail := site.batteryRequest(dev, b, grid, minLen, firstSlotDuration) + batteries = append(batteries, optimizerBattery{cfg, detail}) + } + + for _, b := range batteries { + b.cfg.PA = pa + req.Batteries = append(req.Batteries, b.cfg) + details.BatteryDetails = append(details.BatteryDetails, b.detail) + } + + return req, details, nil +} + +func (site *Site) optimizerUpdate(battery []types.Measurement) error { + req, details, err := site.optimizerRequest(battery) + if err != nil { + return err } if len(req.Batteries) == 0 { @@ -549,7 +571,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { httpClient := request.NewClient(site.log) httpClient.Timeout = 90 * time.Second - apiClient, err := optimizer.NewClientWithResponses(uri, optimizer.WithHTTPClient(httpClient)) + apiClient, err := optimizer.NewClientWithResponses(optimizerURI(), optimizer.WithHTTPClient(httpClient)) if err != nil { return err } @@ -581,51 +603,50 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { return errors.New(string(resp.JSON200.Status)) } - slotHours := firstSlotDuration.Hours() - gridImporting := len(resp.JSON200.GridImport) > 0 && resp.JSON200.GridImport[0] > 0 - gridExporting := len(resp.JSON200.GridExport) > 0 && resp.JSON200.GridExport[0] > 0 + site.applyOptimizerResult(req, details.BatteryDetails, *resp.JSON200) + + return nil +} + +// applyOptimizerResult maps the optimizer response onto suggestions, battery +// forecast and notifications +func (site *Site) applyOptimizerResult(req optimizer.OptimizationInput, details []batteryDetail, res optimizer.OptimizationResult) { + slotHours := (time.Duration(req.TimeSeries.Dt[0]) * time.Second).Hours() + gridImporting := len(res.GridImport) > 0 && res.GridImport[0] > 0 + gridExporting := len(res.GridExport) > 0 && res.GridExport[0] > 0 var batteries []batteryResult suggestions := make(map[string]types.Suggestion, len(req.Batteries)) - lpSuggestions := make(map[int]types.Suggestion) for i, batReq := range req.Batteries { - batResp := resp.JSON200.Batteries[i] - detail := details.BatteryDetails[i] + batRes := res.Batteries[i] + detail := details[i] - batResult := batteryResult{ + batteries = append(batteries, batteryResult{ batteryDetail: detail, - Full: matchSoc(batResp.StateOfCharge, func(soc float32) bool { + Full: matchSoc(batRes.StateOfCharge, func(soc float32) bool { return soc >= batReq.SMax }), - Empty: matchSoc(batResp.StateOfCharge, func(soc float32) bool { + Empty: matchSoc(batRes.StateOfCharge, func(soc float32) bool { return soc <= batReq.SMin }), - } + }) - batteries = append(batteries, batResult) - - suggestion := currentSlotSuggestion(detail, batResp, gridImporting, gridExporting, slotHours) + suggestion := currentSlotSuggestion(detail, batRes, gridImporting, gridExporting, slotHours) if suggestion.Action == "" { continue } - switch { - case detail.Type == batteryTypeBattery: - // uncontrollable batteries can't act on a suggestion - if !detail.controllable { - continue - } - suggestions[detail.Name] = suggestion - case detail.loadpoint != nil: - lpSuggestions[*detail.loadpoint] = suggestion + // uncontrollable devices can't act on a suggestion + if key := detail.key(); key != "" && detail.controllable { + suggestions[key] = suggestion } } site.publish("evopt-batteries", batteries) - site.setSuggestions(suggestions, lpSuggestions) - site.battery.Forecast = site.addBatteryForecastTotals(req.Batteries, resp.JSON200.Batteries) + site.setSuggestions(suggestions) + site.battery.Forecast = site.addBatteryForecastTotals(req.Batteries, res.Batteries) site.publishBattery() @@ -633,11 +654,9 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { site.publishSuggestions() // notify on actionable suggestion changes (advisory only, see #31903) - for _, ev := range site.diffSuggestions(site.pendingSuggestions(details.BatteryDetails)) { + for _, ev := range site.diffSuggestions(site.pendingSuggestions(details)) { site.pushEvent(ev) } - - return nil } func (site *Site) addBatteryForecastTotals(req []optimizer.BatteryConfig, resp []optimizer.BatteryResult) *types.BatteryForecast { @@ -741,8 +760,9 @@ func (site *Site) loadpointRequest(lp loadpoint.API, minLen int, firstSlotDurati } detail := batteryDetail{ - Type: batteryTypeLoadpoint, - Title: lp.GetTitle(), + Type: batteryTypeLoadpoint, + Title: lp.GetTitle(), + controllable: true, } // vehicle diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go index 2d5133ba9..9e8d5bbb6 100644 --- a/core/site_optimizer_test.go +++ b/core/site_optimizer_test.go @@ -355,40 +355,49 @@ func TestSuggestionActionable(t *testing.T) { batteryMode: api.BatteryNormal, loadpoints: []*Loadpoint{lp}, } - site.setSuggestions( - map[string]types.Suggestion{"bat": {Action: api.BatteryCharge.String()}}, - map[int]types.Suggestion{0: {Action: actionCharge}}, - ) + site.setSuggestions(map[string]types.Suggestion{ + batteryKey("bat"): {Action: api.BatteryCharge.String()}, + loadpointKey(0): {Action: actionCharge}, + }) + + batterySuggestion := func(name string) *types.Suggestion { + return site.suggestion(batteryKey(name), site.GetBatteryMode().String()) + } + loadpointSuggestion := func(id int) *types.Suggestion { + return site.suggestion(loadpointKey(id), loadpointCurrentAction(lp)) + } // battery mode differs from suggestion - s := site.batterySuggestion("bat") + s := batterySuggestion("bat") require.NotNil(t, s) assert.True(t, s.Actionable) site.batteryMode = api.BatteryCharge - assert.False(t, site.batterySuggestion("bat").Actionable) + assert.False(t, batterySuggestion("bat").Actionable) - assert.Nil(t, site.batterySuggestion("unknown")) + assert.Nil(t, batterySuggestion("unknown")) // loadpoint stopped, suggestion is to charge - s = site.loadpointSuggestion(0) + s = loadpointSuggestion(0) require.NotNil(t, s) assert.True(t, s.Actionable) // loadpoint charging matches the suggestion lp.enabled = true lp.status = api.StatusC - assert.False(t, site.loadpointSuggestion(0).Actionable) + assert.False(t, loadpointSuggestion(0).Actionable) - assert.Nil(t, site.loadpointSuggestion(1)) + assert.Nil(t, loadpointSuggestion(1)) } func TestSuggestionEvent(t *testing.T) { id := 2 // battery: no loadpoint id, carries name - key, ev := suggestionEvent(batteryDetail{Type: batteryTypeBattery, Name: "home", Title: "Home"}, types.Suggestion{Action: api.BatteryCharge.String()}) - assert.Equal(t, "battery:home", key) + detail := batteryDetail{Type: batteryTypeBattery, Name: "home", Title: "Home"} + assert.Equal(t, "battery:home", detail.key()) + + ev := suggestionEvent(detail, types.Suggestion{Action: api.BatteryCharge.String()}) assert.Nil(t, ev.Loadpoint) assert.Equal(t, evSuggestion, ev.Event) assert.Equal(t, api.BatteryCharge.String(), ev.Attributes["suggestionAction"]) @@ -396,18 +405,23 @@ func TestSuggestionEvent(t *testing.T) { assert.Equal(t, "Home", ev.Attributes["suggestionTitle"]) // loadpoint: carries id, no name - key, ev = suggestionEvent(batteryDetail{Type: batteryTypeVehicle, loadpoint: &id, Title: "Garage"}, types.Suggestion{Action: actionCharge}) - assert.Equal(t, "loadpoint:2", key) + detail = batteryDetail{Type: batteryTypeVehicle, loadpoint: &id, Title: "Garage"} + assert.Equal(t, "loadpoint:2", detail.key()) + + ev = suggestionEvent(detail, types.Suggestion{Action: actionCharge}) require.NotNil(t, ev.Loadpoint) assert.Equal(t, id, *ev.Loadpoint) assert.NotContains(t, ev.Attributes, "suggestionName") + + // vehicle without loadpoint can't act on a suggestion + assert.Empty(t, batteryDetail{Type: batteryTypeVehicle}.key()) } func TestDiffSuggestions(t *testing.T) { site := &Site{} pending := func(s types.Suggestion) map[string]pendingSuggestion { - _, ev := suggestionEvent(batteryDetail{loadpoint: new(int)}, s) + ev := suggestionEvent(batteryDetail{loadpoint: new(int)}, s) return map[string]pendingSuggestion{"loadpoint:0": {suggestion: s, event: ev}} }