evcc-io/core/metrics/db_test.go

268 lines
7.9 KiB
Go

package metrics
import (
"slices"
"testing"
"time"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/server/db"
"github.com/stretchr/testify/require"
)
func TestSqliteTimestamp(t *testing.T) {
clock := clock.NewMock()
clock.Add(time.Hour)
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema())
entity := entity{Name: "foo"}
require.NoError(t, db.Instance.FirstOrCreate(&entity).Error)
persist(entity, clock.Now(), 0, 0)
db, err := db.Instance.DB()
require.NoError(t, err)
var ts SqlTime
for _, sql := range []string{
`SELECT ts FROM meters`,
`SELECT min(ts) FROM meters`,
// `SELECT unixepoch(ts) FROM meters`,
// `SELECT unixepoch(min(ts)) FROM meters`,
`SELECT min(ts) AS ts FROM meters
GROUP BY strftime("%H:%M", ts)
ORDER BY ts`,
} {
require.NoError(t, db.QueryRow(sql).Scan(&ts))
require.True(t, clock.Now().Equal(time.Time(ts)), "expected %v, got %v (%s)", clock.Now().Local(), time.Time(ts).Local(), sql)
}
require.NoError(t, db.QueryRow(`SELECT ts FROM meters WHERE ts >= ?`, clock.Now().Unix()).Scan(&ts))
require.True(t, clock.Now().Equal(time.Time(ts)), "expected %v, got %v", clock.Now().Local(), time.Time(ts).Local())
}
func TestQueryEnergyUTCFilter(t *testing.T) {
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema())
e := entity{Name: Grid, Group: Grid}
require.NoError(t, db.Instance.FirstOrCreate(&e).Error)
// 2 hourly slots at 16:00 and 17:00 local time
loc := time.Now().Location()
base := time.Date(2026, 4, 15, 16, 0, 0, 0, loc)
require.NoError(t, persist(e, base, 0, 1))
require.NoError(t, persist(e, base.Add(time.Hour), 0, 2))
// query with UTC times spanning both slots
from := base.Add(-time.Hour).UTC()
to := base.Add(3 * time.Hour).UTC()
res, err := QueryEnergy(from, to, "hour", false)
require.NoError(t, err)
require.Len(t, res, 1)
require.Len(t, res[0].Data, 2)
require.InDelta(t, 1, res[0].Data[0].ReturnEnergy, 0.001)
require.InDelta(t, 2, res[0].Data[1].ReturnEnergy, 0.001)
}
func TestQueryEnergyGrouped(t *testing.T) {
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema())
// two entities sharing the same group, different names
e1 := entity{Id: 2, Name: "db:12", Group: Grid}
require.NoError(t, db.Instance.Create(&e1).Error)
e2 := entity{Id: 3, Name: "db:13", Group: Grid}
require.NoError(t, db.Instance.Create(&e2).Error)
loc := time.Now().Location()
base := time.Date(2026, 4, 15, 16, 0, 0, 0, loc)
require.NoError(t, persist(e1, base, 1, 0))
require.NoError(t, persist(e2, base, 2, 0))
require.NoError(t, persist(e1, base.Add(time.Hour), 3, 0))
require.NoError(t, persist(e2, base.Add(time.Hour), 4, 0))
from := base.Add(-time.Hour).UTC()
to := base.Add(3 * time.Hour).UTC()
// ungrouped: 2 series
res, err := QueryEnergy(from, to, "hour", false)
require.NoError(t, err)
require.Len(t, res, 2)
// grouped: 1 series, values summed per bucket
res, err = QueryEnergy(from, to, "hour", true)
require.NoError(t, err)
require.Len(t, res, 1)
require.Equal(t, Grid, res[0].Group)
require.Empty(t, res[0].Title)
require.Len(t, res[0].Data, 2)
require.InDelta(t, 1+2, res[0].Data[0].Energy, 0.001)
require.InDelta(t, 3+4, res[0].Data[1].Energy, 0.001)
}
func TestQueryEnergyMultipleSeries(t *testing.T) {
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema())
// 3 entities across 2 groups
eGrid := entity{Id: 2, Name: Grid, Group: Grid}
require.NoError(t, db.Instance.Create(&eGrid).Error)
ePv1 := entity{Id: 4, Name: "pv1", Group: PV}
require.NoError(t, db.Instance.Create(&ePv1).Error)
ePv2 := entity{Id: 5, Name: "pv2", Group: PV}
require.NoError(t, db.Instance.Create(&ePv2).Error)
loc := time.Now().Location()
base := time.Date(2026, 4, 15, 16, 0, 0, 0, loc)
// 2 hourly slots per entity
for i := range 2 {
ts := base.Add(time.Duration(i) * time.Hour)
require.NoError(t, persist(eGrid, ts, float64(1+i), 0))
require.NoError(t, persist(ePv1, ts, 0, float64(10+i)))
require.NoError(t, persist(ePv2, ts, 0, float64(20+i)))
}
from := base.Add(-time.Hour).UTC()
to := base.Add(3 * time.Hour).UTC()
// ungrouped: 3 series, each with 2 slots
res, err := QueryEnergy(from, to, "hour", false)
require.NoError(t, err)
require.Len(t, res, 3)
byTitle := map[string]Series{}
for _, s := range res {
require.Len(t, s.Data, 2)
byTitle[s.Title] = s
}
require.Equal(t, Grid, byTitle[Grid].Group)
require.Equal(t, PV, byTitle["pv1"].Group)
require.Equal(t, PV, byTitle["pv2"].Group)
require.InDelta(t, 1, byTitle[Grid].Data[0].Energy, 0.001)
require.InDelta(t, 2, byTitle[Grid].Data[1].Energy, 0.001)
require.InDelta(t, 10, byTitle["pv1"].Data[0].ReturnEnergy, 0.001)
require.InDelta(t, 21, byTitle["pv2"].Data[1].ReturnEnergy, 0.001)
// grouped: 2 series, pv summed per bucket
res, err = QueryEnergy(from, to, "hour", true)
require.NoError(t, err)
require.Len(t, res, 2)
byGroup := map[string]Series{}
for _, s := range res {
require.Empty(t, s.Title)
require.Len(t, s.Data, 2)
byGroup[s.Group] = s
}
require.Contains(t, byGroup, Grid)
require.Contains(t, byGroup, PV)
require.InDelta(t, 1, byGroup[Grid].Data[0].Energy, 0.001)
require.InDelta(t, 2, byGroup[Grid].Data[1].Energy, 0.001)
require.InDelta(t, 10+20, byGroup[PV].Data[0].ReturnEnergy, 0.001)
require.InDelta(t, 11+21, byGroup[PV].Data[1].ReturnEnergy, 0.001)
}
func TestUpdateProfile(t *testing.T) {
clock := clock.NewMock()
// adjust for 00:00 in local timezone
_, o := clock.Now().Zone()
clock.Add(-time.Duration(o) * time.Second)
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema())
entity := entity{Id: 2, Name: "foo"}
require.NoError(t, db.Instance.FirstOrCreate(&entity).Error)
// 2 days of data
// day 1: 0 ... 95
// day 2: 96 ... 181
for i := range 4 * 2 * 24 {
persist(entity, clock.Now(), float64(i), float64(i))
clock.Add(15 * time.Minute)
}
// validate records written
var count int64
require.NoError(t, db.Instance.Model(new(meter)).Count(&count).Error)
require.Equal(t, int64(24*2*4), count)
{
from := clock.Now().Local().AddDate(0, 0, -2).Add(12 * time.Hour) // 12:00 of day 0
prof, err := energyProfile(entity, 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 := energyProfile(entity, 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 TestTimeMigration(t *testing.T) {
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
mig := db.Instance.Migrator()
require.NoError(t, db.Instance.AutoMigrate(new(entity)))
type v1 struct {
Meter int `json:"meter" gorm:"column:meter;uniqueIndex:idx_meter_ts"`
Timestamp time.Time `json:"ts" gorm:"column:ts;uniqueIndex:idx_meter_ts"`
Entity entity `json:"-" gorm:"foreignkey:Meter;references:Id"`
}
require.NoError(t, db.Instance.AutoMigrate(new(v1)))
{
tables, err := mig.GetTables()
require.NoError(t, err)
require.True(t, slices.Contains(tables, "v1"))
}
require.NoError(t, mig.RenameTable("v1", "v2"))
type v2 struct {
Meter int `json:"meter" gorm:"column:meter;uniqueIndex:idx_meter_ts"`
Timestamp int64 `json:"ts" gorm:"column:ts;uniqueIndex:idx_meter_ts"`
Entity entity `json:"-" gorm:"foreignkey:Meter;references:Id"`
}
require.NoError(t, db.Instance.AutoMigrate(new(v2)))
{
tables, err := mig.GetTables()
require.NoError(t, err)
require.False(t, slices.Contains(tables, "v1"))
require.True(t, slices.Contains(tables, "v2"))
}
}