evcc-io/core/site_optimizer_test.go

324 lines
11 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"
"github.com/evcc-io/evcc/core/types"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
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 TestLoadpointCurrentAction(t *testing.T) {
for _, tc := range []struct {
name string
enabled bool
status api.ChargeStatus
soc float64
want string
}{
{"charging", true, api.StatusC, 0, actionCharge},
{"enabled but idle (e.g. vehicle finished at limit)", true, api.StatusB, 0, actionStop},
{"disabled", false, api.StatusB, 0, actionStop},
{"charging at 100% soc with no explicit limit", true, api.StatusC, 100, actionStop},
} {
t.Run(tc.name, func(t *testing.T) {
lp := &Loadpoint{enabled: tc.enabled, status: tc.status, vehicleSoc: tc.soc}
assert.Equal(t, tc.want, loadpointCurrentAction(lp))
})
}
}
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")
}
})
}
}
// TestBatteryRequestSocLimitsClamp ensures the reported soc is always clamped into
// the resulting [SMin, SMax] range, even when it lies outside the configured soc
// limits (e.g. right after a firmware update changed the reported soc or the min/max
// soc settings) - otherwise the optimizer is infeasible from the first slot.
func TestBatteryRequestSocLimitsClamp(t *testing.T) {
newBatteryDevice := func(t *testing.T, minSoc, maxSoc float64) config.Device[api.Meter] {
ctrl := gomock.NewController(t)
var meter api.Meter
batSocLimit := api.NewMockBatterySocLimiter(ctrl)
batSocLimit.EXPECT().GetSocLimits().Return(minSoc, maxSoc).AnyTimes()
bat := &struct {
api.Meter
api.BatterySocLimiter
}{
Meter: meter,
BatterySocLimiter: batSocLimit,
}
return config.NewStaticDevice(config.Named{}, api.Meter(bat))
}
site := &Site{log: util.NewLogger("foo")}
capacity := 10.0 // kWh
t.Run("soc below minSoc", func(t *testing.T) {
soc := 15.0
dev := newBatteryDevice(t, 20, 100)
m := types.Measurement{Capacity: &capacity, Soc: &soc}
req, _ := site.batteryRequest(dev, m, nil, 8, 15*time.Minute)
assert.Equal(t, float32(1500), req.SMin)
assert.Equal(t, float32(10000), req.SMax)
assert.LessOrEqual(t, req.SMin, req.SInitial)
})
t.Run("soc above maxSoc", func(t *testing.T) {
soc := 95.0
dev := newBatteryDevice(t, 0, 80)
m := types.Measurement{Capacity: &capacity, Soc: &soc}
req, _ := site.batteryRequest(dev, m, nil, 8, 15*time.Minute)
assert.Equal(t, float32(0), req.SMin)
assert.Equal(t, float32(9500), req.SMax)
assert.GreaterOrEqual(t, req.SMax, req.SInitial)
})
t.Run("soc within limits", func(t *testing.T) {
soc := 50.0
dev := newBatteryDevice(t, 20, 80)
m := types.Measurement{Capacity: &capacity, Soc: &soc}
req, _ := site.batteryRequest(dev, m, nil, 8, 15*time.Minute)
assert.Equal(t, float32(2000), req.SMin)
assert.Equal(t, float32(8000), req.SMax)
})
t.Run("empty maxSoc defaults to 100%", func(t *testing.T) {
soc := 50.0
dev := newBatteryDevice(t, 20, 0)
m := types.Measurement{Capacity: &capacity, Soc: &soc}
req, _ := site.batteryRequest(dev, m, nil, 8, 15*time.Minute)
assert.Equal(t, float32(2000), req.SMin)
assert.Equal(t, float32(10000), req.SMax)
})
}
func TestOptimizerChargingStrategy(t *testing.T) {
site := &Site{log: util.NewLogger("foo")}
// default when unset
assert.Equal(t, defaultOptimizerChargingStrategy, site.GetOptimizerChargingStrategy())
// invalid value rejected, strategy unchanged
require.Error(t, site.SetOptimizerChargingStrategy("bogus"))
assert.Equal(t, defaultOptimizerChargingStrategy, site.GetOptimizerChargingStrategy())
// valid change is applied (re-trigger is gated on sponsor/enabled, not unit-tested here)
require.NoError(t, site.SetOptimizerChargingStrategy(string(optimizer.OptimizerStrategyChargingStrategyAttenuateGridPeaks)))
assert.Equal(t, "attenuate_grid_peaks", site.GetOptimizerChargingStrategy())
}
func TestBlendMeasured(t *testing.T) {
slots := []float64{100, 100, 100, 100, 100, 100}
blendMeasured(slots, 200, 4)
assert.Equal(t, []float64{200, 175, 150, 125, 100, 100}, slots)
// fewer slots than decay length
short := []float32{100, 100}
blendMeasured(short, 200, 4)
assert.Equal(t, []float32{200, 175}, short)
}
func TestBlendScale(t *testing.T) {
slots := []float32{100, 100, 100, 100, 100, 100}
blendScale(slots, 2, 4)
assert.Equal(t, []float32{200, 175, 150, 125, 100, 100}, slots)
// fewer slots than decay length
short := []float64{100, 100}
blendScale(short, 0.5, 4)
assert.Equal(t, []float64{50, 62.5}, short)
}
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
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},
} {
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)
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, ""))
}