core: simplify site/optimizer integration (#32646)

This commit is contained in:
andig 2026-08-08 16:44:51 +02:00 • committed by GitHub
parent b70ab27928
commit b29b49e305
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 183 additions and 149 deletions

View file

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

View file

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

View file

@ -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:<id>" / "battery:<name>") 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

View file

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