evcc-io/core/site_optimizer_test.go
andig fe07ea7cea
Optimizer: derive per-device action suggestions from result (#30783)
Ich verstehe die Frage nicht. Logikfehler? Welcher?
2026-06-13 17:57:50 +02:00

185 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"testing"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
optimizer "github.com/evcc-io/optimizer/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)
func TestLoadpointProfile(t *testing.T) {
ctrl := gomock.NewController(t)
lp := loadpoint.NewMockAPI(ctrl)
lp.EXPECT().GetMode().Return(api.ModeMinPV).AnyTimes()
lp.EXPECT().GetStatus().Return(api.StatusC).AnyTimes()
lp.EXPECT().GetChargePower().Return(10000.0).AnyTimes() // 10 kW
lp.EXPECT().EffectiveMinPower().Return(1000.0).AnyTimes() // 1 kW
lp.EXPECT().GetRemainingEnergy().Return(1.8).AnyTimes() // 1.8 kWh
// expected slots: 0.25 kWh...
require.Equal(t, []float64{250, 250, 250, 250, 250, 250, 250, 50}, loadpointProfile(lp, 8))
}
func TestAsTimestamps(t *testing.T) {
// now is 10 minutes into a 15-minute slot
now := time.Date(2025, 1, 1, 12, 10, 0, 0, time.UTC)
// dt[0]=300 means first event is 300s (5min) before end of current slot
// dt[1..] just mark subsequent slot boundaries
dt := []int{60 * 5, 60 * 15, 60 * 15}
got := asTimestamps(dt, now)
// current slot: 12:00–12:15
// first timestamp: 12:15 - 5min = 12:10
// subsequent: 12:15, 12:30
assert.Equal(t, []time.Time{
time.Date(2025, 1, 1, 12, 10, 0, 0, time.UTC),
time.Date(2025, 1, 1, 12, 15, 0, 0, time.UTC),
time.Date(2025, 1, 1, 12, 30, 0, 0, time.UTC),
}, got)
}
func TestBatteryForecastSocExtremes(t *testing.T) {
for _, tc := range []struct {
name string
req []optimizer.BatteryConfig
soc [][]float32
high, low *batteryForecastSlot
}{
{
"no home battery",
[]optimizer.BatteryConfig{{SMax: 80}}, // SCapacity unset → vehicle
[][]float32{{1000, 2000}},
nil, nil,
},
{
"single home battery rising — reaches full",
[]optimizer.BatteryConfig{{SCapacity: 1000, SMax: 1000}},
[][]float32{{200, 500, 1000}},
&batteryForecastSlot{slot: 2, soc: 100, limit: true},
&batteryForecastSlot{slot: 0, soc: 20, limit: false},
},
{
"single home battery falling — reaches empty",
[]optimizer.BatteryConfig{{SCapacity: 1000, SMax: 1000}},
[][]float32{{900, 500, 0}},
&batteryForecastSlot{slot: 0, soc: 90, limit: false},
&batteryForecastSlot{slot: 2, soc: 0, limit: true},
},
{
"single home battery — local extremes (no limit reached)",
[]optimizer.BatteryConfig{{SCapacity: 1000, SMax: 900, SMin: 100}},
[][]float32{{500, 800, 200}},
&batteryForecastSlot{slot: 1, soc: 80, limit: false},
&batteryForecastSlot{slot: 2, soc: 20, limit: false},
},
{
"two home batteries aggregated",
[]optimizer.BatteryConfig{
{SCapacity: 1000, SMax: 1000},
{SCapacity: 1000, SMax: 1000},
},
[][]float32{
{200, 400, 1000},
{800, 400, 1000},
},
&batteryForecastSlot{slot: 2, soc: 100, limit: true},
&batteryForecastSlot{slot: 1, soc: 40, limit: false},
},
{
"vehicle and home battery — vehicle ignored",
[]optimizer.BatteryConfig{
{SMax: 80}, // vehicle
{SCapacity: 1000, SMax: 1000}, // home
},
[][]float32{
{0, 0, 80},
{200, 500, 900},
},
&batteryForecastSlot{slot: 2, soc: 90, limit: false},
&batteryForecastSlot{slot: 0, soc: 20, limit: false},
},
{
"first slot at SMax wins for highest",
[]optimizer.BatteryConfig{{SCapacity: 1000, SMax: 1000}},
[][]float32{{1000, 1000, 500}},
&batteryForecastSlot{slot: 0, soc: 100, limit: true},
&batteryForecastSlot{slot: 2, soc: 50, limit: false},
},
{
"near SMax is not full",
[]optimizer.BatteryConfig{{SCapacity: 1000, SMax: 1000}},
[][]float32{{500, 999, 800}},
&batteryForecastSlot{slot: 1, soc: 99.9, limit: false},
&batteryForecastSlot{slot: 0, soc: 50, limit: false},
},
} {
t.Run(tc.name, func(t *testing.T) {
resp := make([]optimizer.BatteryResult, len(tc.soc))
for i, s := range tc.soc {
resp[i] = optimizer.BatteryResult{StateOfCharge: s}
}
high, low := batteryForecastSocExtremes(tc.req, resp)
if tc.high == nil {
assert.Nil(t, high, "high")
} else {
require.NotNil(t, high, "high")
assert.Equal(t, tc.high.slot, high.slot, "high.slot")
assert.InDelta(t, tc.high.soc, high.soc, 1e-3, "high.soc")
assert.Equal(t, tc.high.limit, high.limit, "high.limit")
}
if tc.low == nil {
assert.Nil(t, low, "low")
} else {
require.NotNil(t, low, "low")
assert.Equal(t, tc.low.slot, low.slot, "low.slot")
assert.InDelta(t, tc.low.soc, low.soc, 1e-3, "low.soc")
assert.Equal(t, tc.low.limit, low.limit, "low.limit")
}
})
}
}
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))
}