Optimizer: evaluate suggestion actionable flag on publish (#32045)

This commit is contained in:
andig 2026-07-22 13:26:51 +02:00 • committed by GitHub
parent ea6cc99094
commit de4c63d182
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 103 additions and 50 deletions

View file

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

View file

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

View file

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