From fe07ea7cea9cc0e3ca67422e396bb2a2447f4e6a Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 13 Jun 2026 17:57:50 +0200 Subject: [PATCH] Optimizer: derive per-device action suggestions from result (#30783) Ich verstehe die Frage nicht. Logikfehler? Welcher? --- core/site_optimizer.go | 65 +++++++++++++++++++++++++++++++++++-- core/site_optimizer_test.go | 35 ++++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/core/site_optimizer.go b/core/site_optimizer.go index ab52df9ee..0553833db 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -71,8 +71,61 @@ type batteryDetail struct { type batteryResult struct { batteryDetail - Full time.Time `json:"full,omitzero"` - Empty time.Time `json:"empty,omitzero"` + Full time.Time `json:"full,omitzero"` + Empty time.Time `json:"empty,omitzero"` + Suggestion batterySuggestion `json:"suggestion,omitzero"` +} + +// batterySuggestion is the advisory action derived from the optimizer corner result for the +// current slot. It is published for visibility only and not yet wired into control. +type batterySuggestion struct { + // Action is the recommended action for the current slot. + // home battery: normal|hold|charge|holdcharge; loadpoint/vehicle: charge|stop + Action string `json:"action,omitempty"` + Charge float64 `json:"charge,omitempty"` // recommended charge power, W + Discharge float64 `json:"discharge,omitempty"` // recommended discharge power, W +} + +// suggestionThreshold ignores numerical noise around zero power (W) +const suggestionThreshold = 50 + +// currentSlotSuggestion maps the optimizer's first-slot corner result onto an advisory action. +// Because the optimization is linear, the first slot is at an operating-range extreme, so it +// 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) batterySuggestion { + if slotHours <= 0 || len(res.ChargingPower) == 0 || len(res.DischargingPower) == 0 { + return batterySuggestion{} + } + + charge := float64(res.ChargingPower[0]) / slotHours + discharge := float64(res.DischargingPower[0]) / slotHours + + s := batterySuggestion{Charge: charge, Discharge: discharge} + + if detail.Type == batteryTypeBattery { + idle := charge <= suggestionThreshold && discharge <= suggestionThreshold + switch { + case charge > suggestionThreshold && gridImporting: + // charging while importing means grid charging + s.Action = api.BatteryCharge.String() + case idle && gridImporting: + // idle while importing: discharge is deliberately withheld + s.Action = api.BatteryHold.String() + case idle && gridExporting: + // idle while exporting: surplus is exported instead of charged + s.Action = api.BatteryHoldCharge.String() + default: + s.Action = api.BatteryNormal.String() + } + } else if charge > suggestionThreshold { + s.Action = "charge" + } else { + s.Action = "stop" + } + + return s } type requestDetails struct { @@ -263,18 +316,24 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { Details: details, }) + 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 + var batteries []batteryResult for i, batReq := range req.Batteries { batResp := resp.JSON200.Batteries[i] + detail := details.BatteryDetails[i] batResult := batteryResult{ - batteryDetail: details.BatteryDetails[i], + batteryDetail: detail, Full: matchSoc(batResp.StateOfCharge, func(soc float32) bool { return soc >= batReq.SMax }), Empty: matchSoc(batResp.StateOfCharge, func(soc float32) bool { return soc <= batReq.SMin }), + Suggestion: currentSlotSuggestion(detail, batResp, gridImporting, gridExporting, slotHours), } batteries = append(batteries, batResult) diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go index 9c5091985..9395379e9 100644 --- a/core/site_optimizer_test.go +++ b/core/site_optimizer_test.go @@ -148,3 +148,38 @@ func TestBatteryForecastSocExtremes(t *testing.T) { }) } } + +func TestCurrentSlotSuggestion(t *testing.T) { + // slotHours 1 makes the per-slot Wh values map 1:1 to W + for _, tc := range []struct { + name string + typ batteryType + charge, disch float32 + importing, export bool + want string + }{ + {"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) + assert.Equal(t, tc.want, s.Action) + 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.Equal(t, batterySuggestion{}, currentSlotSuggestion(batteryDetail{Type: batteryTypeBattery}, optimizer.BatteryResult{}, true, false, 1)) +}