diff --git a/assets/js/components/BottomTabs/MoreMenu.vue b/assets/js/components/BottomTabs/MoreMenu.vue
index 53b374709..f27d2704d 100644
--- a/assets/js/components/BottomTabs/MoreMenu.vue
+++ b/assets/js/components/BottomTabs/MoreMenu.vue
@@ -55,7 +55,10 @@
to="/optimize"
active-class="active"
>
- Optimize
+ Optimize 🧪
+
+
+ History 🧪
diff --git a/assets/js/components/History/Chart.vue b/assets/js/components/History/Chart.vue
new file mode 100644
index 000000000..874388cf4
--- /dev/null
+++ b/assets/js/components/History/Chart.vue
@@ -0,0 +1,177 @@
+
+
+
+
+
diff --git a/assets/js/components/History/EnergyChart.vue b/assets/js/components/History/EnergyChart.vue
new file mode 100644
index 000000000..59705dc45
--- /dev/null
+++ b/assets/js/components/History/EnergyChart.vue
@@ -0,0 +1,170 @@
+
+
+
+
+
diff --git a/assets/js/components/Top/Navigation.vue b/assets/js/components/Top/Navigation.vue
index 3db3845c3..11ddb133d 100644
--- a/assets/js/components/Top/Navigation.vue
+++ b/assets/js/components/Top/Navigation.vue
@@ -89,6 +89,11 @@
Optimize 🧪
+
+
+ History 🧪
+
+
diff --git a/assets/js/router.ts b/assets/js/router.ts
index 5f099db0c..f55d4345d 100644
--- a/assets/js/router.ts
+++ b/assets/js/router.ts
@@ -120,6 +120,11 @@ export default function setupRouter(i18n: VueI18nInstance) {
component: () => import("./views/Battery.vue"),
props: true,
},
+ {
+ path: "/history",
+ component: () => import("./views/History.vue"),
+ props: true,
+ },
{
path: "/optimize",
component: () => import("./views/Optimize.vue"),
diff --git a/assets/js/views/History.vue b/assets/js/views/History.vue
new file mode 100644
index 000000000..4d818ebca
--- /dev/null
+++ b/assets/js/views/History.vue
@@ -0,0 +1,103 @@
+
+
+
+
+
+ loading...
+
+
+
+
+ no data
+
+
+
+
+
+
+
+
diff --git a/core/meterenergy.go b/core/meterenergy.go
deleted file mode 100644
index 769834c37..000000000
--- a/core/meterenergy.go
+++ /dev/null
@@ -1,60 +0,0 @@
-package core
-
-import (
- "bytes"
- "fmt"
- "time"
-
- "github.com/benbjohnson/clock"
-)
-
-type meterEnergy struct {
- clock clock.Clock
- updated time.Time
- meter *float64 // kWh
- Accumulated float64 `json:"accumulated"` // kWh
-}
-
-func (m *meterEnergy) String() string {
- b := new(bytes.Buffer)
- fmt.Fprintf(b, "Accumulated: %.3fkWh updated: %v", m.Accumulated, m.updated.Truncate(time.Second))
- if m.meter != nil {
- fmt.Fprintf(b, " meter: %.3fkWh", *m.meter)
- }
- return b.String()
-}
-
-// AccumulatedEnergy returns the accumulated energy in kWh
-func (m *meterEnergy) AccumulatedEnergy() float64 {
- return m.Accumulated
-}
-
-// AddMeterTotal adds the difference to the last total meter value in kWh
-func (m *meterEnergy) AddMeterTotal(v float64) {
- defer func() {
- m.updated = m.clock.Now()
- m.meter = new(v)
- }()
-
- if m.meter == nil {
- return
- }
-
- m.Accumulated += v - *m.meter
-}
-
-// AddEnergy adds the given energy in kWh
-func (m *meterEnergy) AddEnergy(v float64) {
- defer func() { m.updated = m.clock.Now() }()
-
- if m.updated.IsZero() {
- return
- }
-
- m.Accumulated += v
-}
-
-// AddPower adds the given power in W, calculating the energy based on the time since the last update
-func (m *meterEnergy) AddPower(v float64) {
- m.AddEnergy(v * m.clock.Since(m.updated).Hours() / 1e3)
-}
diff --git a/core/metrics/accumulator.go b/core/metrics/accumulator.go
new file mode 100644
index 000000000..623d00e1d
--- /dev/null
+++ b/core/metrics/accumulator.go
@@ -0,0 +1,126 @@
+package metrics
+
+import (
+ "bytes"
+ "fmt"
+ "time"
+
+ "github.com/benbjohnson/clock"
+ "github.com/samber/lo"
+)
+
+type Accumulator struct {
+ clock clock.Clock
+ updated time.Time
+ importMeter *float64 // kWh
+ exportMeter *float64 // kWh
+ Import float64 `json:"import"` // kWh
+ Export float64 `json:"export"` // kWh
+}
+
+func WithClock(clock clock.Clock) func(*Accumulator) {
+ return func(m *Accumulator) {
+ m.clock = clock
+ }
+}
+
+func NewAccumulator(opt ...func(*Accumulator)) *Accumulator {
+ m := &Accumulator{clock: clock.New()}
+ for _, o := range opt {
+ o(m)
+ }
+ return m
+}
+
+func (m *Accumulator) Updated() time.Time {
+ return m.updated
+}
+
+func (m *Accumulator) String() string {
+ b := new(bytes.Buffer)
+ fmt.Fprintf(b, "Accumulated: %.3fkWh pos, %.3fkWh neg, updated: %v", m.Import, m.Export, m.updated.Truncate(time.Second))
+ if m.importMeter != nil || m.exportMeter != nil {
+ fmt.Fprintf(b, " meter: ")
+ if m.importMeter != nil {
+ fmt.Fprintf(b, " %.3fkWh pos", *m.importMeter)
+ }
+ if m.exportMeter != nil {
+ fmt.Fprintf(b, " %.3fkWh pos", *m.exportMeter)
+ }
+ }
+ return b.String()
+}
+
+// Imported returns the accumulated import energy in kWh
+func (m *Accumulator) Imported() float64 {
+ return m.Import
+}
+
+// Exported returns the accumulated export energy in kWh
+func (m *Accumulator) Exported() float64 {
+ return m.Export
+}
+
+// SetImportMeterTotal adds the difference to the last total meter value in kWh
+func (m *Accumulator) SetImportMeterTotal(v float64) {
+ defer func() {
+ m.updated = m.clock.Now()
+ m.importMeter = lo.ToPtr(v)
+ }()
+
+ if m.importMeter == nil {
+ return
+ }
+
+ if v >= *m.importMeter {
+ m.Import += v - *m.importMeter
+ }
+}
+
+// SetExportMeterTotal adds the difference to the last total meter value in kWh
+func (m *Accumulator) SetExportMeterTotal(v float64) {
+ defer func() {
+ m.updated = m.clock.Now()
+ m.exportMeter = lo.ToPtr(v)
+ }()
+
+ if m.exportMeter == nil {
+ return
+ }
+
+ if v >= *m.exportMeter {
+ m.Export += v - *m.exportMeter
+ }
+}
+
+// AddImportEnergy adds the given energy in kWh to the positive meter
+func (m *Accumulator) AddImportEnergy(v float64) {
+ defer func() { m.updated = m.clock.Now() }()
+
+ if m.updated.IsZero() {
+ return
+ }
+
+ m.Import += v
+}
+
+// AddExportEnergy adds the given energy in kWh to the negative meter
+func (m *Accumulator) AddExportEnergy(v float64) {
+ defer func() { m.updated = m.clock.Now() }()
+
+ if m.updated.IsZero() {
+ return
+ }
+
+ m.Export += v
+}
+
+// AddPower adds the given power in W, calculating the energy based on the time since the last update
+func (m *Accumulator) AddPower(v float64) {
+ since := v * m.clock.Since(m.updated).Hours() / 1e3
+ if v >= 0 {
+ m.AddImportEnergy(since)
+ } else {
+ m.AddExportEnergy(-since)
+ }
+}
diff --git a/core/meterenergy_test.go b/core/metrics/accumulator_test.go
similarity index 53%
rename from core/meterenergy_test.go
rename to core/metrics/accumulator_test.go
index 3c0ae42e1..62b496214 100644
--- a/core/meterenergy_test.go
+++ b/core/metrics/accumulator_test.go
@@ -1,4 +1,4 @@
-package core
+package metrics
import (
"testing"
@@ -13,31 +13,31 @@ func TestMeterEnergyMeterTotal(t *testing.T) {
clock := clock.NewMock()
clock.Set(now.BeginningOfDay())
- me := &meterEnergy{clock: clock}
+ me := &Accumulator{clock: clock}
- me.AddMeterTotal(10)
- assert.Equal(t, 0.0, me.AccumulatedEnergy())
- me.AddMeterTotal(11)
- assert.Equal(t, 1.0, me.AccumulatedEnergy())
- me.AddMeterTotal(11)
- assert.Equal(t, 1.0, me.AccumulatedEnergy())
+ me.SetImportMeterTotal(10)
+ assert.Equal(t, 0.0, me.Imported())
+ me.SetImportMeterTotal(11)
+ assert.Equal(t, 1.0, me.Imported())
+ me.SetImportMeterTotal(11)
+ assert.Equal(t, 1.0, me.Imported())
}
func TestMeterEnergyAddPower(t *testing.T) {
clock := clock.NewMock()
clock.Set(now.BeginningOfDay())
- me := &meterEnergy{clock: clock}
+ me := &Accumulator{clock: clock}
clock.Add(60 * time.Minute)
me.AddPower(1e3)
- assert.Equal(t, 0.0, me.AccumulatedEnergy())
+ assert.Equal(t, 0.0, me.Imported())
clock.Add(60 * time.Minute)
me.AddPower(1e3)
- assert.Equal(t, 1.0, me.AccumulatedEnergy())
+ assert.Equal(t, 1.0, me.Imported())
clock.Add(30 * time.Minute)
me.AddPower(1e3)
- assert.Equal(t, 1.5, me.AccumulatedEnergy())
+ assert.Equal(t, 1.5, me.Imported())
}
diff --git a/core/metrics/collector.go b/core/metrics/collector.go
new file mode 100644
index 000000000..5b412ae91
--- /dev/null
+++ b/core/metrics/collector.go
@@ -0,0 +1,126 @@
+package metrics
+
+import (
+ "time"
+
+ "github.com/evcc-io/evcc/server/db"
+ "github.com/evcc-io/evcc/tariff"
+)
+
+const (
+ // groups
+ Grid = "grid"
+ PV = "pv"
+ Home = "home" // meter and group (virtual measurement)
+)
+
+type Collector struct {
+ entity entity
+ accu *Accumulator
+ started time.Time
+}
+
+func NewCollector(group, name string, opt ...func(*Accumulator)) (*Collector, error) {
+ entity, err := createEntity(group, name)
+ if err != nil {
+ return nil, err
+ }
+
+ c := &Collector{
+ entity: entity,
+ accu: NewAccumulator(opt...),
+ }
+
+ return c, nil
+}
+
+func createEntity(group, name string) (entity, error) {
+ entity := entity{
+ Group: group,
+ Name: name,
+ }
+
+ if err := db.Instance.Where(&entity).FirstOrCreate(&entity).Error; err != nil {
+ return entity, err
+ }
+
+ return entity, nil
+}
+
+func (c *Collector) process(fun func()) error {
+ now := c.accu.clock.Now()
+
+ fun()
+
+ if slotStart := now.Truncate(tariff.SlotDuration); slotStart.After(c.started) {
+ // skip incomplete first slot
+ if !c.started.IsZero() {
+ if err := c.persist(); err != nil {
+ return err
+ }
+ }
+
+ c.started = slotStart
+ c.accu.Import = 0
+ c.accu.Export = 0
+ }
+
+ return nil
+}
+
+func (c *Collector) persist() error {
+ return persist(c.entity, c.started, c.accu.Imported(), c.accu.Exported())
+}
+
+func (c *Collector) ImportProfile(from time.Time) (*[96]float64, error) {
+ return importProfile(c.entity, from)
+}
+
+func (c *Collector) AddImportEnergy(v float64) error {
+ return c.process(func() {
+ c.accu.AddImportEnergy(v)
+ })
+}
+
+func (c *Collector) AddExportEnergy(v float64) error {
+ return c.process(func() {
+ c.accu.AddExportEnergy(v)
+ })
+}
+
+func (c *Collector) SetImportMeterTotal(v float64) error {
+ return c.process(func() {
+ c.accu.SetImportMeterTotal(v)
+ })
+}
+
+func (c *Collector) SetExportMeterTotal(v float64) error {
+ return c.process(func() {
+ c.accu.SetExportMeterTotal(v)
+ })
+}
+
+// AddEnergy adds energy using meter totals if available, falling back to power integration.
+func (c *Collector) AddEnergy(importTotal, exportTotal *float64, power float64) error {
+ return c.process(func() {
+ switch {
+ case importTotal != nil && exportTotal != nil:
+ c.accu.SetImportMeterTotal(*importTotal)
+ c.accu.SetExportMeterTotal(*exportTotal)
+ case importTotal != nil:
+ // export via power integration (before meter updates clock)
+ if power < 0 {
+ c.accu.AddPower(power)
+ }
+ c.accu.SetImportMeterTotal(*importTotal)
+ case exportTotal != nil:
+ // import via power integration (before meter updates clock)
+ if power >= 0 {
+ c.accu.AddPower(power)
+ }
+ c.accu.SetExportMeterTotal(*exportTotal)
+ default:
+ c.accu.AddPower(power)
+ }
+ })
+}
diff --git a/core/metrics/collector_test.go b/core/metrics/collector_test.go
new file mode 100644
index 000000000..7e2e5bea7
--- /dev/null
+++ b/core/metrics/collector_test.go
@@ -0,0 +1,132 @@
+package metrics
+
+import (
+ "testing"
+ "time"
+
+ "github.com/benbjohnson/clock"
+ "github.com/evcc-io/evcc/server/db"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCollectorAddEnergy(t *testing.T) {
+ clock := clock.NewMock()
+
+ require.NoError(t, db.NewInstance("sqlite", ":memory:"))
+ require.NoError(t, SetupSchema())
+
+ col, err := NewCollector("foo", "foo", WithClock(clock))
+ require.NoError(t, err)
+ require.True(t, col.accu.updated.IsZero())
+
+ clock.Add(5 * time.Minute)
+ require.NoError(t, col.AddEnergy(nil, nil, 1e3))
+ require.False(t, col.accu.updated.IsZero())
+
+ clock.Add(5 * time.Minute)
+ require.NoError(t, col.AddEnergy(nil, nil, 1e3))
+ require.Equal(t, 1e3*5/60/1e3, col.accu.Imported()) // kWh
+
+ clock.Add(5 * time.Minute)
+ require.NoError(t, col.AddEnergy(nil, nil, 1e3))
+ require.Equal(t, 0.0, col.accu.Imported()) // accumulator reset after 15 minutes
+
+ clock.Add(15 * time.Minute)
+ require.NoError(t, col.AddEnergy(nil, nil, 1e3))
+ require.Equal(t, 0.0, col.accu.Imported()) // accumulator reset after 15 minutes
+}
+
+func f(v float64) *float64 { return &v }
+
+func TestCollectorAddEnergyWithImportMeter(t *testing.T) {
+ clock := clock.NewMock()
+
+ require.NoError(t, db.NewInstance("sqlite", ":memory:"))
+ require.NoError(t, SetupSchema())
+
+ col, err := NewCollector("bar", "bar", WithClock(clock))
+ require.NoError(t, err)
+
+ // first call: seeds meter, no delta yet
+ clock.Add(5 * time.Minute)
+ require.NoError(t, col.AddEnergy(f(50000), nil, 1e3))
+ require.Equal(t, 0.0, col.accu.Imported())
+
+ // second call: meter delta of 0.5 kWh, power ignored for import
+ clock.Add(5 * time.Minute)
+ require.NoError(t, col.AddEnergy(f(50000.5), nil, 1e3))
+ require.Equal(t, 0.5, col.accu.Imported())
+
+ // implausible reading (decreased): ignored by guard
+ clock.Add(5 * time.Minute)
+ require.NoError(t, col.AddEnergy(f(49000), nil, 1e3))
+ require.Equal(t, 0.0, col.accu.Imported()) // reset at slot boundary
+}
+
+func TestCollectorAddEnergyWithImportMeterAndExport(t *testing.T) {
+ clock := clock.NewMock()
+
+ require.NoError(t, db.NewInstance("sqlite", ":memory:"))
+ require.NoError(t, SetupSchema())
+
+ col, err := NewCollector("baz", "baz", WithClock(clock))
+ require.NoError(t, err)
+
+ // seed import meter
+ clock.Add(3 * time.Minute)
+ require.NoError(t, col.AddEnergy(f(1000), nil, 0))
+
+ // positive power: import via meter delta, no export
+ clock.Add(3 * time.Minute)
+ require.NoError(t, col.AddEnergy(f(1000.3), nil, 500))
+ require.InDelta(t, 0.3, col.accu.Imported(), 1e-10)
+ require.Equal(t, 0.0, col.accu.Exported())
+
+ // negative power: import via meter (no change), export via power integration
+ clock.Add(3 * time.Minute)
+ require.NoError(t, col.AddEnergy(f(1000.3), nil, -600))
+ require.InDelta(t, 0.3, col.accu.Imported(), 1e-10)
+ require.InDelta(t, 600.0*3/60/1e3, col.accu.Exported(), 1e-10) // 0.03 kWh
+}
+
+func TestCollectorAddEnergyWithBothMeters(t *testing.T) {
+ clock := clock.NewMock()
+
+ require.NoError(t, db.NewInstance("sqlite", ":memory:"))
+ require.NoError(t, SetupSchema())
+
+ col, err := NewCollector("qux", "qux", WithClock(clock))
+ require.NoError(t, err)
+
+ // seed both meters
+ clock.Add(3 * time.Minute)
+ require.NoError(t, col.AddEnergy(f(1000), f(2000), 0))
+
+ // both deltas used, power ignored
+ clock.Add(3 * time.Minute)
+ require.NoError(t, col.AddEnergy(f(1000.3), f(2000.7), 999))
+ require.InDelta(t, 0.3, col.accu.Imported(), 1e-10)
+ require.InDelta(t, 0.7, col.accu.Exported(), 1e-10)
+}
+
+func TestCollectorSetImportAndExportMeterTotal(t *testing.T) {
+ clock := clock.NewMock()
+
+ require.NoError(t, db.NewInstance("sqlite", ":memory:"))
+ require.NoError(t, SetupSchema())
+
+ col, err := NewCollector("set", "set", WithClock(clock))
+ require.NoError(t, err)
+
+ // seed both import and export
+ clock.Add(5 * time.Minute)
+ require.NoError(t, col.SetImportMeterTotal(1000))
+ require.NoError(t, col.SetExportMeterTotal(2000))
+
+ // both deltas used
+ clock.Add(5 * time.Minute)
+ require.NoError(t, col.SetImportMeterTotal(1000.3))
+ require.NoError(t, col.SetExportMeterTotal(2000.7))
+ require.InDelta(t, 0.3, col.accu.Imported(), 1e-10)
+ require.InDelta(t, 0.7, col.accu.Exported(), 1e-10)
+}
diff --git a/core/metrics/db.go b/core/metrics/db.go
index 4520842bf..e1ef96150 100644
--- a/core/metrics/db.go
+++ b/core/metrics/db.go
@@ -10,42 +10,226 @@ import (
)
type meter struct {
- Meter int `json:"meter" gorm:"column:meter;uniqueIndex:meter_ts"`
- Timestamp time.Time `json:"ts" gorm:"column:ts;uniqueIndex:meter_ts"`
- Value float64 `json:"val" gorm:"column:val"`
+ Meter int `json:"meter" gorm:"column:meter;uniqueIndex:meters_meter_ts"`
+ Timestamp time.Time `json:"ts" gorm:"column:ts;uniqueIndex:meters_meter_ts"` // start of 15min slot
+ Entity entity `json:"-" gorm:"foreignkey:Meter;references:Id"`
+ Import float64 `json:"import" gorm:"column:import"`
+ Export float64 `json:"export" gorm:"column:export"`
+}
+
+type entity struct {
+ Id int `gorm:"column:id;primarykey"`
+ Group string `gorm:"column:group;uniqueIndex:entities_group_name"`
+ Name string `gorm:"column:name;uniqueIndex:entities_group_name"`
}
var ErrIncomplete = errors.New("meter profile incomplete")
+// Slot represents an aggregated energy time slot
+type Slot struct {
+ Start time.Time `json:"start"`
+ End time.Time `json:"end"`
+ Import float64 `json:"import"`
+ Export float64 `json:"export"`
+}
+
+// Series represents a named series of energy slots
+type Series struct {
+ Name string `json:"name"`
+ Data []Slot `json:"data"`
+}
+
+var aggregateFormats = map[string]string{
+ "15m": "%Y-%m-%d %H:%M",
+ "hour": "%Y-%m-%d %H:00",
+ "day": "%Y-%m-%d",
+ "month": "%Y-%m",
+}
+
+var aggregateGoFormats = map[string]string{
+ "15m": "2006-01-02 15:04",
+ "hour": "2006-01-02 15:00",
+ "day": "2006-01-02",
+ "month": "2006-01",
+}
+
+var aggregateDurations = map[string]func(time.Time) time.Time{
+ "15m": func(t time.Time) time.Time { return t.Add(15 * time.Minute) },
+ "hour": func(t time.Time) time.Time { return t.Add(time.Hour) },
+ "day": func(t time.Time) time.Time { return t.AddDate(0, 0, 1) },
+ "month": func(t time.Time) time.Time { return t.AddDate(0, 1, 0) },
+}
+
+// QueryImportEnergy returns aggregated import energy data from the meters table
+func QueryImportEnergy(from, to time.Time, aggregate string) ([]Series, error) {
+ format, ok := aggregateFormats[aggregate]
+ if !ok {
+ return nil, errors.New("invalid aggregate value")
+ }
+
+ addDuration := aggregateDurations[aggregate]
+
+ // use Go's tz offset instead of SQLite's 'localtime'
+ tz := time.Now().Format("-07:00")
+
+ tx := db.Instance.Table("meters m").
+ Select(`e.name AS label, strftime('` + format + `', m.ts, '` + tz + `') AS bucket,
+ COALESCE(SUM(m."import"), 0) AS import, COALESCE(SUM(m.export), 0) AS export`).
+ Joins("JOIN entities e ON m.meter = e.id").
+ Group("label, bucket").
+ Order("label, bucket")
+
+ if !from.IsZero() {
+ tx = tx.Where("m.ts >= ?", from)
+ }
+ if !to.IsZero() {
+ tx = tx.Where("m.ts < ?", to)
+ }
+
+ rows, err := tx.Rows()
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ seriesMap := make(map[string][]Slot)
+ var order []string
+
+ for rows.Next() {
+ var label, bucket string
+ var imp, exp float64
+
+ if err := rows.Scan(&label, &bucket, &imp, &exp); err != nil {
+ return nil, err
+ }
+
+ start, err := time.ParseInLocation(aggregateGoFormats[aggregate], bucket, time.Now().Location())
+ if err != nil {
+ return nil, err
+ }
+
+ if _, exists := seriesMap[label]; !exists {
+ order = append(order, label)
+ }
+
+ seriesMap[label] = append(seriesMap[label], Slot{
+ Start: start,
+ End: addDuration(start),
+ Import: imp,
+ Export: exp,
+ })
+ }
+
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+
+ res := make([]Series, 0, len(order))
+ for _, name := range order {
+ res = append(res, Series{
+ Name: name,
+ Data: seriesMap[name],
+ })
+ }
+
+ return res, nil
+}
+
func init() {
- db.Register(func(db *gorm.DB) error {
- return db.AutoMigrate(new(meter))
+ db.Register(func(_ *gorm.DB) error {
+ return SetupSchema()
})
}
-// Persist stores 15min consumption in Wh
-func Persist(ts time.Time, value float64) error {
- return db.Instance.Create(meter{
- Meter: 1,
- Timestamp: ts.Truncate(15 * time.Minute),
- Value: value,
+// SetupSchema is used for testing
+func SetupSchema() error {
+ m := db.Instance.Migrator()
+
+ // entites: create entity first to make sure foreign keys for existing data work
+ hasTable := m.HasTable(new(entity))
+ if err := db.Instance.AutoMigrate(new(entity)); err != nil {
+ return err
+ }
+
+ // entites: add entity id 1
+ if hasTable {
+ var res entity
+ if err := db.Instance.Where(&entity{Id: 1, Name: Home}).FirstOrCreate(&res).Error; err != nil {
+ return err
+ }
+
+ if res.Group == "" {
+ res.Group = Home
+ if err := db.Instance.Save(&res).Error; err != nil {
+ return err
+ }
+ }
+ }
+
+ // drop obsolete indexes
+ for _, idx := range []struct {
+ name string
+ obj any
+ }{
+ {"name_idx", new(entity)},
+ {"group_name", new(entity)},
+ {"meter_ts", new(meter)},
+ } {
+ if m.HasIndex(idx.obj, idx.name) {
+ if err := m.DropIndex(idx.obj, idx.name); err != nil {
+ return err
+ }
+ }
+ }
+
+ // meter: split energy direction
+ if old := "val"; m.HasColumn(new(meter), old) {
+ if err := m.RenameColumn(new(meter), old, "import"); err != nil {
+ return err
+ }
+ }
+
+ // meter: split energy direction #2
+ if old := "pos"; m.HasColumn(new(meter), old) {
+ if err := m.RenameColumn(new(meter), old, "import"); err != nil {
+ return err
+ }
+ }
+ if old := "neg"; m.HasColumn(new(meter), old) {
+ if err := m.RenameColumn(new(meter), old, "export"); err != nil {
+ return err
+ }
+ }
+
+ return db.Instance.AutoMigrate(new(meter))
+}
+
+// persist stores 15min consumption in kWh
+func persist(entity entity, ts time.Time, imp, exp float64) error {
+ return db.Instance.Create(&meter{
+ Meter: entity.Id,
+ Timestamp: ts.Truncate(tariff.SlotDuration),
+ Import: imp,
+ Export: exp,
}).Error
}
-// Profile returns a 15min average meter profile in Wh.
-// Profile is sorted by timestamp starting at 00:00. It is guaranteed to contain 96 15min values.
-func Profile(from time.Time) (*[96]float64, error) {
+// importProfile returns a 15min average meter profile in Wh. The profile
+// is sorted by timestamp starting at 00:00. It is guaranteed to contain 96 15min values.
+func importProfile(entity entity, from time.Time) (*[96]float64, error) {
db, err := db.Instance.DB()
if err != nil {
return nil, err
}
- // Use 'localtime' in strftime to fix https://github.com/evcc-io/evcc/discussions/23759
- rows, err := db.Query(`SELECT min(ts) AS ts, avg(val) AS val
+ // use Go's tz offset instead of SQLite's 'localtime'
+ tz := from.Format("-07:00")
+
+ rows, err := db.Query(`SELECT min(ts) AS ts, avg(import) AS import
FROM meters
WHERE meter = ? AND ts >= ?
- GROUP BY strftime("%H:%M", ts, 'localtime')
- ORDER BY strftime("%H:%M", ts, 'localtime') ASC`, 1, from,
+ GROUP BY strftime("%H:%M", ts, '`+tz+`')
+ ORDER BY strftime("%H:%M", ts, '`+tz+`') ASC`, entity.Id, from,
)
if err != nil {
return nil, err
diff --git a/core/metrics/db_test.go b/core/metrics/db_test.go
new file mode 100644
index 000000000..e660395cd
--- /dev/null
+++ b/core/metrics/db_test.go
@@ -0,0 +1,102 @@
+package metrics
+
+import (
+ "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
+ val float64
+ )
+
+ for _, sql := range []string{
+ `SELECT ts, import FROM meters`,
+ `SELECT min(ts), import FROM meters`,
+ `SELECT unixepoch(ts), import FROM meters`,
+ `SELECT unixepoch(min(ts)), import FROM meters`,
+ `SELECT min(ts) AS ts, avg(import) AS import
+ 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, import 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 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)
+ }
+
+ {
+ from := clock.Now().Local().AddDate(0, 0, -2).Add(12 * time.Hour) // 12:00 of day 0
+
+ prof, err := importProfile(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 := importProfile(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)
+ }
+}
diff --git a/core/site.go b/core/site.go
index b4596dd8b..be816cfdc 100644
--- a/core/site.go
+++ b/core/site.go
@@ -11,7 +11,6 @@ import (
"testing"
"time"
- "github.com/benbjohnson/clock"
"github.com/cenkalti/backoff/v4"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/cmd/shutdown"
@@ -85,11 +84,10 @@ type Site struct {
coordinator *coordinator.Coordinator // Vehicles
prioritizer *prioritizer.Prioritizer // Power budgets
stats *Stats // Stats
- fcstEnergy *meterEnergy
- pvEnergy map[string]*meterEnergy
+ fcstEnergy *metrics.Accumulator
+ pvEnergy map[string]*metrics.Accumulator
- householdEnergy *meterEnergy
- householdSlotStart time.Time
+ homeEnergy, gridEnergy *metrics.Collector
// cached state
gridPower float64 // Grid power
@@ -140,6 +138,12 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
site.prioritizer = prioritizer.New(log)
site.stats = NewStats()
+ me, err := metrics.NewCollector(metrics.Home, metrics.Home)
+ if err != nil {
+ return err
+ }
+ site.homeEnergy = me
+
// upload telemetry on shutdown
if telemetry.Enabled() {
shutdown.Register(func() {
@@ -180,10 +184,17 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
if err != nil {
return err
}
+
site.gridMeter = dev.Instance()
if site.gridMeter == nil {
return errors.New("missing grid meter instance")
}
+
+ me, err := metrics.NewCollector(metrics.Grid, site.Meters.GridMeterRef)
+ if err != nil {
+ return err
+ }
+ site.gridEnergy = me
}
// multiple pv
@@ -195,7 +206,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
site.pvMeters = append(site.pvMeters, dev)
// accumulator
- site.pvEnergy[ref] = &meterEnergy{clock: clock.New()}
+ site.pvEnergy[ref] = metrics.NewAccumulator()
}
// multiple batteries
@@ -240,11 +251,10 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
// NewSite creates a Site with sane defaults
func NewSite() *Site {
site := &Site{
- log: util.NewLogger("site"),
- Voltage: 230, // V
- pvEnergy: make(map[string]*meterEnergy),
- fcstEnergy: &meterEnergy{clock: clock.New()},
- householdEnergy: &meterEnergy{clock: clock.New()},
+ log: util.NewLogger("site"),
+ Voltage: 230, // V
+ pvEnergy: make(map[string]*metrics.Accumulator),
+ fcstEnergy: metrics.NewAccumulator(),
}
return site
@@ -312,14 +322,14 @@ func (site *Site) restoreSettings() error {
}
// restore accumulated energy
- pvEnergy := make(map[string]meterEnergy)
+ pvEnergy := make(map[string]metrics.Accumulator)
fcstEnergy, err := settings.Float(keys.SolarAccForecast)
if err == nil && settings.Json(keys.SolarAccYield, &pvEnergy) == nil {
var nok bool
for _, name := range site.Meters.PVMetersRef {
if fcst, ok := pvEnergy[name]; ok {
- site.pvEnergy[name].Accumulated = fcst.Accumulated
+ site.pvEnergy[name].Import = fcst.Import
} else {
nok = true
site.log.WARN.Printf("accumulated solar yield: cannot restore %s", name)
@@ -327,7 +337,7 @@ func (site *Site) restoreSettings() error {
}
if !nok {
- site.fcstEnergy.Accumulated = fcstEnergy
+ site.fcstEnergy.Import = fcstEnergy
site.log.DEBUG.Printf("accumulated solar yield: restored %.3fkWh forecasted, %+v produced", fcstEnergy, pvEnergy)
} else {
// reset metrics
@@ -337,7 +347,7 @@ func (site *Site) restoreSettings() error {
settings.Delete(keys.SolarAccYield)
for _, pe := range site.pvEnergy {
- pe.Accumulated = 0
+ pe.Import = 0
}
}
}
@@ -580,15 +590,15 @@ func (site *Site) updatePvMeters() {
// use stored devices, not ui-updated instances!
name := dev.Config().Name
- prev := site.pvEnergy[name].AccumulatedEnergy()
+ prev := site.pvEnergy[name].Imported()
if mm[i].Energy > 0 {
site.log.DEBUG.Printf("!! solar production: accumulate set %s %.3fkWh meter total (was: %s)", name, mm[i].Energy, site.pvEnergy[name])
- site.pvEnergy[name].AddMeterTotal(mm[i].Energy)
+ site.pvEnergy[name].SetImportMeterTotal(mm[i].Energy)
} else {
site.log.DEBUG.Printf("!! solar production: accumulate add %s %.3fW power (was: %s)", name, mm[i].Energy, site.pvEnergy[name])
site.pvEnergy[name].AddPower(mm[i].Power)
}
- site.log.DEBUG.Printf("!! solar production: accumulate moved %s from %.3f to %.3f", name, prev, site.pvEnergy[name].AccumulatedEnergy())
+ site.log.DEBUG.Printf("!! solar production: accumulate moved %s from %.3f to %.3f", name, prev, site.pvEnergy[name].Imported())
}
// store
@@ -751,14 +761,18 @@ func (site *Site) updateGridMeter() error {
}
// grid energy (import)
+ var importEnergy *float64
if energyMeter, ok := api.Cap[api.MeterEnergy](site.gridMeter); ok {
if f, err := energyMeter.TotalEnergy(); err == nil {
mm.Energy = f
+ importEnergy = &f
} else {
site.log.ERROR.Printf("grid energy: %v", err)
}
}
+ site.gridEnergy.AddEnergy(importEnergy, nil, mm.Power)
+
site.publish(keys.Grid, mm)
return nil
@@ -791,33 +805,6 @@ func optimizerEnabled() bool {
return exp && opt
}
-func (site *Site) updateHomeConsumption(homePower float64) {
- site.householdEnergy.AddPower(homePower)
-
- now := site.householdEnergy.clock.Now()
- if site.householdSlotStart.IsZero() {
- site.householdSlotStart = now
- return
- }
-
- slotDuration := 15 * time.Minute
- slotStart := now.Truncate(slotDuration)
-
- if slotStart.After(site.householdSlotStart) {
- // next slot has started
- if slotStart.Sub(site.householdSlotStart) >= slotDuration {
- // more or less full slot
- site.log.DEBUG.Printf("15min household consumption: %.0fWh", site.householdEnergy.Accumulated)
- if err := metrics.Persist(site.householdSlotStart, site.householdEnergy.Accumulated); err != nil {
- site.log.ERROR.Printf("persist household consumption: %v", err)
- }
- }
-
- site.householdSlotStart = slotStart
- site.householdEnergy.Accumulated = 0
- }
-}
-
// sitePower returns
// - the net power exported by the site minus a residual margin
// (negative values mean grid: export, battery: charging
@@ -963,7 +950,9 @@ func (site *Site) update(lp updater) {
site.publish(keys.HomePower, homePower)
if homePower > 0 {
- site.updateHomeConsumption(homePower)
+ if err := site.homeEnergy.AddEnergy(nil, nil, homePower); err != nil {
+ site.log.ERROR.Printf("persist home consumption: %v", err)
+ }
}
// add battery charging power to homePower to ignore all consumption which does not occur on loadpoints
diff --git a/core/site_optimizer.go b/core/site_optimizer.go
index ab6da5d6d..fd5df9ee0 100644
--- a/core/site_optimizer.go
+++ b/core/site_optimizer.go
@@ -15,7 +15,6 @@ import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/loadpoint"
- "github.com/evcc-io/evcc/core/metrics"
"github.com/evcc-io/evcc/core/types"
"github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util/config"
@@ -533,7 +532,7 @@ func loadpointProfile(lp loadpoint.API, minLen int) []float64 {
// homeProfile returns the home base load in Wh
func (site *Site) homeProfile(minLen int) ([]float64, error) {
// kWh over last 30 days
- profile, err := metrics.Profile(now.BeginningOfDay().AddDate(0, 0, -30))
+ profile, err := site.homeEnergy.ImportProfile(now.BeginningOfDay().AddDate(0, 0, -30))
if err != nil {
return nil, err
}
diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go
index ca8e86146..42f17b521 100644
--- a/core/site_optimizer_test.go
+++ b/core/site_optimizer_test.go
@@ -4,99 +4,14 @@ 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)
diff --git a/core/site_tariffs.go b/core/site_tariffs.go
index 28372c618..5c95ec4be 100644
--- a/core/site_tariffs.go
+++ b/core/site_tariffs.go
@@ -8,6 +8,7 @@ import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
+ "github.com/evcc-io/evcc/core/metrics"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util"
@@ -150,20 +151,21 @@ func (site *Site) solarDetails(solar api.Rates) solarDetails {
}
// accumulate forecasted energy since last update
- energy := solarEnergy(solar, site.fcstEnergy.updated, time.Now()) / 1e3
+ fcstUpdated := site.fcstEnergy.Updated()
+ energy := solarEnergy(solar, fcstUpdated, time.Now()) / 1e3
site.log.DEBUG.Printf("solar forecast: accumulated %.3fWh from %v to %v",
- energy, site.fcstEnergy.updated.Truncate(time.Second), time.Now().Truncate(time.Second),
+ energy, fcstUpdated.Truncate(time.Second), time.Now().Truncate(time.Second),
)
- site.fcstEnergy.AddEnergy(energy)
- settings.SetFloat(keys.SolarAccForecast, site.fcstEnergy.Accumulated)
+ site.fcstEnergy.AddImportEnergy(energy)
+ settings.SetFloat(keys.SolarAccForecast, site.fcstEnergy.Imported())
- produced := lo.SumBy(slices.Collect(maps.Values(site.pvEnergy)), func(v *meterEnergy) float64 {
- return v.AccumulatedEnergy()
+ produced := lo.SumBy(slices.Collect(maps.Values(site.pvEnergy)), func(v *metrics.Accumulator) float64 {
+ return v.Imported()
})
site.log.DEBUG.Printf("solar forecast: produced %.3f", produced)
- if fcst := site.fcstEnergy.AccumulatedEnergy(); fcst > 0 {
+ if fcst := site.fcstEnergy.Imported(); fcst > 0 {
scale := produced / fcst
site.log.DEBUG.Printf("solar forecast: accumulated %.3fkWh, produced %.3fkWh, scale %.3f", fcst, produced, scale)
diff --git a/core/site_test.go b/core/site_test.go
index e12b1233f..a1ad32903 100644
--- a/core/site_test.go
+++ b/core/site_test.go
@@ -2,16 +2,11 @@ package core
import (
"testing"
- "time"
- "github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/types"
- "github.com/evcc-io/evcc/server/db"
- "github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestGreenShare(t *testing.T) {
@@ -163,33 +158,3 @@ func TestRequiredBatteryMode(t *testing.T) {
assert.Equal(t, tc.res, res, "expected %s, got %s", tc.res, res)
}
}
-
-func TestUpdateHomeConsumption(t *testing.T) {
- require.NoError(t, db.NewInstance("sqlite", ":memory:"))
-
- clock := clock.NewMock()
-
- s := &Site{
- log: util.NewLogger("foo"),
- gridPower: 4e3,
- householdEnergy: &meterEnergy{clock: clock},
- }
-
- require.True(t, s.householdEnergy.updated.IsZero())
-
- clock.Add(5 * time.Minute)
- s.updateHomeConsumption(1e3)
- require.False(t, s.householdEnergy.updated.IsZero())
-
- clock.Add(5 * time.Minute)
- s.updateHomeConsumption(1e3)
- require.Equal(t, 1e3*5/60/1e3, s.householdEnergy.AccumulatedEnergy()) // kWh
-
- clock.Add(5 * time.Minute)
- s.updateHomeConsumption(1e3)
- require.Equal(t, 0.0, s.householdEnergy.AccumulatedEnergy()) // accumulator reset after 15 minutes
-
- clock.Add(15 * time.Minute)
- s.updateHomeConsumption(1e3)
- require.Equal(t, 0.0, s.householdEnergy.AccumulatedEnergy()) // accumulator reset after 15 minutes
-}
diff --git a/server/http.go b/server/http.go
index a39d2d45a..e9b557a65 100644
--- a/server/http.go
+++ b/server/http.go
@@ -159,6 +159,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) {
"updatesession": {"PUT", "/session/{id:[0-9]+}", updateSessionHandler},
"deletesession": {"DELETE", "/session/{id:[0-9]+}", deleteSessionHandler},
"gridsessions": {"GET", "/gridsessions", gridSessionsHandler},
+ "energyhistory": {"GET", "/history/energy", energyHistoryHandler},
"telemetry2": {"POST", "/settings/telemetry/{value:[01truefalse]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
}
diff --git a/server/http_history_handler.go b/server/http_history_handler.go
new file mode 100644
index 000000000..174e666e1
--- /dev/null
+++ b/server/http_history_handler.go
@@ -0,0 +1,51 @@
+package server
+
+import (
+ "errors"
+ "net/http"
+ "time"
+
+ "github.com/evcc-io/evcc/core/metrics"
+ "github.com/evcc-io/evcc/server/db"
+)
+
+// energyHistoryHandler returns aggregated energy history data
+func energyHistoryHandler(w http.ResponseWriter, r *http.Request) {
+ if db.Instance == nil {
+ jsonError(w, http.StatusBadRequest, errors.New("database offline"))
+ return
+ }
+
+ q := r.URL.Query()
+
+ var from, to time.Time
+
+ if s := q.Get("from"); s != "" {
+ var err error
+ if from, err = time.Parse(time.RFC3339, s); err != nil {
+ jsonError(w, http.StatusBadRequest, errors.New("invalid 'from' parameter"))
+ return
+ }
+ }
+
+ if s := q.Get("to"); s != "" {
+ var err error
+ if to, err = time.Parse(time.RFC3339, s); err != nil {
+ jsonError(w, http.StatusBadRequest, errors.New("invalid 'to' parameter"))
+ return
+ }
+ }
+
+ aggregate := q.Get("aggregate")
+ if aggregate == "" {
+ aggregate = "15m"
+ }
+
+ res, err := metrics.QueryImportEnergy(from, to, aggregate)
+ if err != nil {
+ jsonError(w, http.StatusInternalServerError, err)
+ return
+ }
+
+ jsonWrite(w, res)
+}
diff --git a/tests/energy-history.spec.ts b/tests/energy-history.spec.ts
new file mode 100644
index 000000000..662a5ffe0
--- /dev/null
+++ b/tests/energy-history.spec.ts
@@ -0,0 +1,72 @@
+import { test, expect } from "@playwright/test";
+import { start, stop, baseUrl } from "./evcc";
+
+test.use({ baseURL: baseUrl() });
+
+const from = "2026-03-24T21:00:00+01:00";
+const to = "2026-03-25T01:00:00+01:00";
+
+test.beforeAll(async () => {
+ await start(undefined, "energy-history.sql");
+});
+test.afterAll(async () => {
+ await stop();
+});
+
+test.describe("energy history API", () => {
+ test("15-minute resolution", async ({ request }) => {
+ const res = await request.get(`${baseUrl()}/api/history/energy`, {
+ params: { from, to },
+ });
+ expect(res.ok()).toBeTruthy();
+
+ const data = await res.json();
+ expect(data).toHaveLength(2);
+
+ const grid = data.find((s: any) => s.name === "grid");
+ const home = data.find((s: any) => s.name === "home");
+ expect(grid).toBeDefined();
+ expect(home).toBeDefined();
+
+ expect(grid.data).toHaveLength(6);
+ expect(home.data).toHaveLength(6);
+
+ expect(home.data[0].import).toBeCloseTo(0.1, 4);
+ expect(home.data[0].export).toBeCloseTo(0, 4);
+ expect(grid.data[0].import).toBeCloseTo(0.5, 4);
+ expect(grid.data[0].export).toBeCloseTo(0.1, 4);
+ });
+
+ test("day aggregation", async ({ request }) => {
+ const res = await request.get(`${baseUrl()}/api/history/energy`, {
+ params: { from, to, aggregate: "day" },
+ });
+ expect(res.ok()).toBeTruthy();
+
+ const data = await res.json();
+ expect(data).toHaveLength(2);
+
+ const grid = data.find((s: any) => s.name === "grid");
+ const home = data.find((s: any) => s.name === "home");
+ expect(grid).toBeDefined();
+ expect(home).toBeDefined();
+
+ expect(grid.data).toHaveLength(2);
+ expect(home.data).toHaveLength(2);
+
+ const [homeDay1, homeDay2] = home.data;
+ const [gridDay1, gridDay2] = grid.data;
+
+ // day 1 (2026-03-24): 4 slots
+ expect(homeDay1.import).toBeCloseTo(0.4, 4);
+ expect(homeDay1.export).toBeCloseTo(0, 4);
+ expect(gridDay1.import).toBeCloseTo(2.0, 4);
+ expect(gridDay1.export).toBeCloseTo(0.4, 4);
+
+ // day 2 (2026-03-25): 2 slots
+ expect(homeDay2.import).toBeCloseTo(0.2, 4);
+ expect(homeDay2.export).toBeCloseTo(0, 4);
+ expect(gridDay2.import).toBeCloseTo(1.0, 4);
+ expect(gridDay2.export).toBeCloseTo(0.2, 4);
+ });
+});
diff --git a/tests/energy-history.sql b/tests/energy-history.sql
new file mode 100644
index 000000000..b0aeb0f26
--- /dev/null
+++ b/tests/energy-history.sql
@@ -0,0 +1,42 @@
+DROP TABLE IF EXISTS `meters`;
+DROP TABLE IF EXISTS `entities`;
+
+CREATE TABLE `entities` (
+ `id` integer,
+ `group` text,
+ `name` text,
+ PRIMARY KEY (`id`)
+);
+CREATE UNIQUE INDEX `entities_group_name` ON `entities`(`group`, `name`);
+
+CREATE TABLE `meters` (
+ `meter` integer,
+ `ts` datetime,
+ `import` real,
+ `export` real
+);
+CREATE UNIQUE INDEX `meters_meter_ts` ON `meters`(`meter`, `ts`);
+
+-- entities
+INSERT INTO `entities` VALUES (1, 'virtual', 'home');
+INSERT INTO `entities` VALUES (2, 'grid', 'grid');
+
+-- meter data: 6 slots per entity spanning midnight (local time +01:00)
+-- 2026-03-24: 22:00, 22:15, 22:30, 22:45 (4 slots)
+-- 2026-03-25: 00:00, 00:15 (2 slots)
+
+-- home (id=1): import=0.1, export=0 per slot
+INSERT INTO `meters` VALUES (1, '2026-03-24 22:00:00+01:00', 0.1, 0);
+INSERT INTO `meters` VALUES (1, '2026-03-24 22:15:00+01:00', 0.1, 0);
+INSERT INTO `meters` VALUES (1, '2026-03-24 22:30:00+01:00', 0.1, 0);
+INSERT INTO `meters` VALUES (1, '2026-03-24 22:45:00+01:00', 0.1, 0);
+INSERT INTO `meters` VALUES (1, '2026-03-25 00:00:00+01:00', 0.1, 0);
+INSERT INTO `meters` VALUES (1, '2026-03-25 00:15:00+01:00', 0.1, 0);
+
+-- grid (id=2): import=0.5, export=0.1 per slot
+INSERT INTO `meters` VALUES (2, '2026-03-24 22:00:00+01:00', 0.5, 0.1);
+INSERT INTO `meters` VALUES (2, '2026-03-24 22:15:00+01:00', 0.5, 0.1);
+INSERT INTO `meters` VALUES (2, '2026-03-24 22:30:00+01:00', 0.5, 0.1);
+INSERT INTO `meters` VALUES (2, '2026-03-24 22:45:00+01:00', 0.5, 0.1);
+INSERT INTO `meters` VALUES (2, '2026-03-25 00:00:00+01:00', 0.5, 0.1);
+INSERT INTO `meters` VALUES (2, '2026-03-25 00:15:00+01:00', 0.5, 0.1);