evcc-io/core/site_optimizer_test.go
2026-04-03 14:52:13 +00:00

197 lines
4.8 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/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/metrics"
"github.com/evcc-io/evcc/server/db"
optimizer "github.com/evcc-io/optimizer/client"
"github.com/jinzhu/now"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)
func TestSqliteTimestamp(t *testing.T) {
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
clock := clock.NewMock()
clock.Add(time.Hour)
metrics.Persist(clock.Now(), 0)
db, err := db.Instance.DB()
require.NoError(t, err)
var (
ts metrics.SqlTime
val float64
)
for _, sql := range []string{
`SELECT ts, val FROM meters`,
`SELECT min(ts), val FROM meters`,
`SELECT unixepoch(ts), val FROM meters`,
`SELECT unixepoch(min(ts)), val FROM meters`,
`SELECT min(ts) AS ts, avg(val) AS val
FROM meters
GROUP BY strftime("%H:%M", ts)
ORDER BY ts`,
} {
require.NoError(t, db.QueryRow(sql).Scan(&ts, &val))
require.True(t, clock.Now().Equal(time.Time(ts)), "expected %v, got %v", clock.Now().Local(), time.Time(ts).Local())
}
require.NoError(t, db.QueryRow(`SELECT ts, val FROM meters WHERE ts >= ?`, clock.Now()).Scan(&ts, &val))
require.True(t, clock.Now().Equal(time.Time(ts)), "expected %v, got %v", clock.Now().Local(), time.Time(ts).Local())
}
func TestUpdateHouseholdProfile(t *testing.T) {
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
// make sure test data added starting 00:00 local time
clock := clock.NewMock()
clock.Set(now.With(clock.Now()).BeginningOfDay())
// 2 days of data
// day 1: 0 ... 95
// day 2: 96 ... 181
for i := range 4 * 2 * 24 {
metrics.Persist(clock.Now(), float64(i))
clock.Add(15 * time.Minute)
}
{
from := clock.Now().Local().AddDate(0, 0, -2).Add(12 * time.Hour) // 12:00 of day 0
prof, err := metrics.Profile(from)
require.NoError(t, err)
var expected [96]float64
for i := range expected {
if i < 48 {
expected[i] = float64(48+i+144+i) / 2
continue
}
expected[i] = float64(96 - 48 + i)
}
require.Equal(t, expected, *prof, "partial profile: expected %v, got %v", expected, *prof)
}
{
from := clock.Now().Local().AddDate(0, 0, -3).Add(12 * time.Hour) // 12:00 of day -1
prof, err := metrics.Profile(from)
require.NoError(t, err)
var expected [96]float64
for i := range expected {
expected[i] = float64(0+96+2*i) / 2
}
require.Equal(t, expected, *prof, "full profile: expected %v, got %v", expected, *prof)
}
}
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 TestBatteryForecastTotals(t *testing.T) {
site := new(Site)
req := []optimizer.BatteryConfig{
{SMax: 80},
{SMax: 80},
}
const zero = -1
for _, tc := range []struct {
name string
bat1, bat2 []float32
full, empty int
}{
{
"never full",
[]float32{0, 0},
[]float32{0, 0},
zero, 0,
},
{
"never empty",
[]float32{100, 100},
[]float32{100, 100},
0, zero,
},
{
"first full then empty",
[]float32{100, 0},
[]float32{100, 0},
0, 1,
},
{
"first full finally empty",
[]float32{100, 100, 0},
[]float32{100, 0, 0},
0, 2,
},
{
"first empty then full",
[]float32{0, 100},
[]float32{0, 100},
1, 0,
},
{
"first empty finally full",
[]float32{0, 100, 100},
[]float32{0, 0, 100},
2, 0,
},
} {
t.Run(tc.name, func(t *testing.T) {
resp := []optimizer.BatteryResult{
{StateOfCharge: tc.bat1},
{StateOfCharge: tc.bat2},
}
full, empty := site.batteryForecastFullAndEmptySlots(req, resp)
assert.Equal(t, tc.full, full, "full")
assert.Equal(t, tc.empty, empty, "empty")
})
}
}