185 lines
6.1 KiB
Go
185 lines
6.1 KiB
Go
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))
|
||
}
|