diff --git a/core/site.go b/core/site.go index 84d2ed773..0abbed8b2 100644 --- a/core/site.go +++ b/core/site.go @@ -111,6 +111,7 @@ type Site struct { 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 } // MetersConfig contains the site's meter configuration @@ -1182,6 +1183,9 @@ func (site *Site) update(lp updater) { site.publish(keys.BatteryGridChargeActive, batteryGridChargeActive) site.updateBatteryMode(batteryGridChargeActive, rate) + // re-evaluate against the updated loadpoint state + site.publishSuggestions() + site.stats.Update(site) } diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 85ff2b828..6922221f5 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -124,7 +124,7 @@ const ( // maps cleanly onto the discrete battery mode / loadpoint intent that control would later apply. // An idle battery is interpreted from the grid flow: importing means discharge is withheld // (hold), exporting means charging is withheld (holdcharge). -func currentSlotSuggestion(detail batteryDetail, res optimizer.BatteryResult, gridImporting, gridExporting bool, slotHours float64, current string) types.Suggestion { +func currentSlotSuggestion(detail batteryDetail, res optimizer.BatteryResult, gridImporting, gridExporting bool, slotHours float64) types.Suggestion { if slotHours <= 0 || len(res.ChargingPower) == 0 || len(res.DischargingPower) == 0 { return types.Suggestion{} } @@ -155,9 +155,6 @@ func currentSlotSuggestion(detail batteryDetail, res optimizer.BatteryResult, gr s.Action = actionStop } - // actionable when the suggested action differs from the current operating mode - s.Actionable = s.Action != current - return s } @@ -176,33 +173,68 @@ func loadpointCurrentAction(lp *Loadpoint) string { return actionStop } -// setBatterySuggestions replaces the suggestions applied on each battery publish -func (site *Site) setBatterySuggestions(suggestions map[string]types.Suggestion) { +// setSuggestions replaces the suggestions applied on each publish +func (site *Site) setSuggestions(batteries map[string]types.Suggestion, loadpoints map[int]types.Suggestion) { site.Lock() defer site.Unlock() - site.batterySuggestions = suggestions + site.batterySuggestions = batteries + site.loadpointSuggestions = loadpoints } -// batterySuggestion returns the optimizer suggestion for the given battery meter +// 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() + site.RLock() defer site.RUnlock() - if s, ok := site.batterySuggestions[name]; ok { - return &s + 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] + site.RUnlock() + + if !ok { + return nil + } + + s.Actionable = s.Action != loadpointCurrentAction(site.loadpoints[id]) + + return &s +} + +// publishSuggestions publishes the loadpoints' suggestions +func (site *Site) publishSuggestions() { + for id := range site.loadpoints { + var val any + if s := site.loadpointSuggestion(id); s != nil { + val = *s + } + site.publishLoadpoint(id, keys.Suggestion, val) } - return nil } // clearSuggestions removes all suggestions when the optimizer result is stale func (site *Site) clearSuggestions() { - site.setBatterySuggestions(nil) - site.publishBattery() + site.setSuggestions(nil, nil) - for id := range site.Loadpoints() { - site.publishLoadpoint(id, keys.Suggestion, nil) - } + site.publishBattery() + site.publishSuggestions() } type requestDetails struct { @@ -461,15 +493,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { batteries = append(batteries, batResult) - // current operating mode to detect an actionable change - var current string - if detail.Type == batteryTypeBattery { - current = site.GetBatteryMode().String() - } else if detail.loadpoint != nil { - current = loadpointCurrentAction(site.loadpoints[*detail.loadpoint]) - } - - suggestion := currentSlotSuggestion(detail, batResp, gridImporting, gridExporting, slotHours, current) + suggestion := currentSlotSuggestion(detail, batResp, gridImporting, gridExporting, slotHours) if suggestion.Action == "" { continue } @@ -485,19 +509,13 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { site.publish("evopt-batteries", batteries) - site.setBatterySuggestions(suggestions) + site.setSuggestions(suggestions, lpSuggestions) site.battery.Forecast = site.addBatteryForecastTotals(req.Batteries, resp.JSON200.Batteries) site.publishBattery() // publish for all loadpoints so suggestions of dropped-out loadpoints clear - for id := range site.Loadpoints() { - var val any - if s, ok := lpSuggestions[id]; ok { - val = s - } - site.publishLoadpoint(id, keys.Suggestion, val) - } + site.publishSuggestions() return nil } diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go index 8b667b7c3..aa0fb9559 100644 --- a/core/site_optimizer_test.go +++ b/core/site_optimizer_test.go @@ -289,36 +289,67 @@ func TestCurrentSlotSuggestion(t *testing.T) { typ batteryType charge, disch float32 importing, export bool - current string // current operating mode want string - wantActionable bool }{ - {"battery grid charge", batteryTypeBattery, 3000, 0, true, false, "normal", "charge", true}, - {"battery grid charge unchanged", batteryTypeBattery, 3000, 0, true, false, "charge", "charge", false}, - {"battery pv charge (no import)", batteryTypeBattery, 3000, 0, false, true, "normal", "normal", false}, - {"battery hold (idle while importing)", batteryTypeBattery, 0, 0, true, false, "normal", "hold", true}, - {"battery holdcharge (idle while exporting)", batteryTypeBattery, 0, 0, false, true, "normal", "holdcharge", true}, - {"battery discharge", batteryTypeBattery, 0, 2000, true, false, "normal", "normal", false}, - {"battery idle balanced", batteryTypeBattery, 0, 0, false, false, "normal", "normal", false}, - {"loadpoint charge", batteryTypeLoadpoint, 11000, 0, false, false, "stop", "charge", true}, - {"loadpoint charge unchanged", batteryTypeLoadpoint, 11000, 0, false, false, "charge", "charge", false}, - {"loadpoint stop", batteryTypeLoadpoint, 0, 0, false, false, "charge", "stop", true}, - {"loadpoint stop unchanged", batteryTypeLoadpoint, 0, 0, false, false, "stop", "stop", false}, - {"vehicle below threshold is stop", batteryTypeVehicle, 40, 0, false, false, "charge", "stop", true}, + {"battery grid charge", batteryTypeBattery, 3000, 0, true, false, "charge"}, + {"battery pv charge (no import)", batteryTypeBattery, 3000, 0, false, true, "normal"}, + {"battery hold (idle while importing)", batteryTypeBattery, 0, 0, true, false, "hold"}, + {"battery holdcharge (idle while exporting)", batteryTypeBattery, 0, 0, false, true, "holdcharge"}, + {"battery discharge", batteryTypeBattery, 0, 2000, true, false, "normal"}, + {"battery idle balanced", batteryTypeBattery, 0, 0, false, false, "normal"}, + {"loadpoint charge", batteryTypeLoadpoint, 11000, 0, false, false, "charge"}, + {"loadpoint stop", batteryTypeLoadpoint, 0, 0, false, false, "stop"}, + {"vehicle below threshold is stop", batteryTypeVehicle, 40, 0, false, false, "stop"}, } { t.Run(tc.name, func(t *testing.T) { res := optimizer.BatteryResult{ ChargingPower: []float32{tc.charge}, DischargingPower: []float32{tc.disch}, } - s := currentSlotSuggestion(batteryDetail{Type: tc.typ}, res, tc.importing, tc.export, 1, tc.current) + s := currentSlotSuggestion(batteryDetail{Type: tc.typ}, res, tc.importing, tc.export, 1) assert.Equal(t, tc.want, s.Action) - assert.Equal(t, tc.wantActionable, s.Actionable) assert.InDelta(t, tc.charge, s.Charge, 1e-3) assert.InDelta(t, tc.disch, s.Discharge, 1e-3) }) } // no result yields an empty suggestion - assert.Empty(t, currentSlotSuggestion(batteryDetail{Type: batteryTypeBattery}, optimizer.BatteryResult{}, true, false, 1, "")) + assert.Empty(t, currentSlotSuggestion(batteryDetail{Type: batteryTypeBattery}, optimizer.BatteryResult{}, true, false, 1)) +} + +// TestSuggestionActionable ensures the actionable flag follows the current state +// instead of the state at optimizer run time +func TestSuggestionActionable(t *testing.T) { + lp := NewLoadpoint(util.NewLogger("foo"), nil) + + site := &Site{ + batteryMode: api.BatteryNormal, + loadpoints: []*Loadpoint{lp}, + } + site.setSuggestions( + map[string]types.Suggestion{"bat": {Action: api.BatteryCharge.String()}}, + map[int]types.Suggestion{0: {Action: actionCharge}}, + ) + + // battery mode differs from suggestion + s := site.batterySuggestion("bat") + require.NotNil(t, s) + assert.True(t, s.Actionable) + + site.batteryMode = api.BatteryCharge + assert.False(t, site.batterySuggestion("bat").Actionable) + + assert.Nil(t, site.batterySuggestion("unknown")) + + // loadpoint stopped, suggestion is to charge + s = site.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.Nil(t, site.loadpointSuggestion(1)) }