From 661af417a1005ca7120ead37e571651e1f5b7ab2 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Fri, 17 Jul 2026 15:00:23 +0200 Subject: [PATCH] Tariffs: persist tariff values in 15min intervals (#31845) --- core/metrics/tariffs.go | 42 +++++++++++++++++++++++++++ core/metrics/tariffs_test.go | 55 ++++++++++++++++++++++++++++++++++++ core/site.go | 1 + core/site_tariffs.go | 32 +++++++++++++++++++++ 4 files changed, 130 insertions(+) create mode 100644 core/metrics/tariffs.go create mode 100644 core/metrics/tariffs_test.go diff --git a/core/metrics/tariffs.go b/core/metrics/tariffs.go new file mode 100644 index 000000000..66a04b2fb --- /dev/null +++ b/core/metrics/tariffs.go @@ -0,0 +1,42 @@ +package metrics + +import ( + "time" + + "github.com/evcc-io/evcc/server/db" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type tariffValue struct { + Timestamp int64 `gorm:"column:ts;uniqueIndex"` // 15min boundary + Grid *float64 `gorm:"column:grid"` + FeedIn *float64 `gorm:"column:feedin"` + Co2 *float64 `gorm:"column:co2"` + Temperature *float64 `gorm:"column:temperature"` +} + +func (tariffValue) TableName() string { + return "tariffs" +} + +func init() { + db.Register(func(_ *gorm.DB) error { + return db.Instance.AutoMigrate(new(tariffValue)) + }) +} + +// PersistTariffs stores the tariff values at the given 15min boundary, nil values omitted +func PersistTariffs(ts time.Time, grid, feedin, co2, temperature *float64) error { + if grid == nil && feedin == nil && co2 == nil && temperature == nil { + return nil + } + + return db.Instance.Clauses(clause.OnConflict{DoNothing: true}).Create(&tariffValue{ + Timestamp: ts.Unix(), + Grid: grid, + FeedIn: feedin, + Co2: co2, + Temperature: temperature, + }).Error +} diff --git a/core/metrics/tariffs_test.go b/core/metrics/tariffs_test.go new file mode 100644 index 000000000..03fb0953e --- /dev/null +++ b/core/metrics/tariffs_test.go @@ -0,0 +1,55 @@ +package metrics + +import ( + "testing" + "time" + + "github.com/evcc-io/evcc/server/db" + "github.com/stretchr/testify/require" +) + +func TestPersistTariffs(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, db.Instance.AutoMigrate(new(tariffValue))) + + slot := time.Date(2026, 4, 15, 16, 15, 0, 0, time.UTC) + grid, co2 := 0.3, 250.0 + + // nil values omitted + require.NoError(t, PersistTariffs(slot, &grid, nil, &co2, nil)) + + var res tariffValue + require.NoError(t, db.Instance.First(&res).Error) + require.Equal(t, slot.Unix(), res.Timestamp) + require.InDelta(t, 0.3, *res.Grid, 0.001) + require.Nil(t, res.FeedIn) + require.InDelta(t, 250, *res.Co2, 0.001) + require.Nil(t, res.Temperature) + + // duplicate slot ignored, first values kept + other := 0.4 + require.NoError(t, PersistTariffs(slot, &other, nil, nil, nil)) + + var count int64 + require.NoError(t, db.Instance.Model(new(tariffValue)).Count(&count).Error) + require.Equal(t, int64(1), count) + + require.NoError(t, db.Instance.First(&res).Error) + require.InDelta(t, 0.3, *res.Grid, 0.001) + + // all nil: no row + require.NoError(t, PersistTariffs(slot.Add(15*time.Minute), nil, nil, nil, nil)) + require.NoError(t, db.Instance.Model(new(tariffValue)).Count(&count).Error) + require.Equal(t, int64(1), count) + + // all values set: each column mapped independently + next := slot.Add(30 * time.Minute) + feedin, temp := 0.08, 21.5 + require.NoError(t, PersistTariffs(next, &grid, &feedin, &co2, &temp)) + + require.NoError(t, db.Instance.Where("ts = ?", next.Unix()).First(&res).Error) + require.InDelta(t, 0.3, *res.Grid, 0.001) + require.InDelta(t, 0.08, *res.FeedIn, 0.001) + require.InDelta(t, 250, *res.Co2, 0.001) + require.InDelta(t, 21.5, *res.Temperature, 0.001) +} diff --git a/core/site.go b/core/site.go index b60b6bac3..f03e24cfb 100644 --- a/core/site.go +++ b/core/site.go @@ -94,6 +94,7 @@ type Site struct { stats *Stats // Stats collectors map[string]*metrics.Collector // keyed by meter ref + tariffSlot time.Time // last persisted tariff slot // cached state gridPower float64 // Grid power diff --git a/core/site_tariffs.go b/core/site_tariffs.go index aff0a3612..14c2bdb79 100644 --- a/core/site_tariffs.go +++ b/core/site_tariffs.go @@ -121,6 +121,38 @@ func (site *Site) publishTariffs(greenShareHome float64, greenShareLoadpoints fl } site.publish(keys.Forecast, util.NewSharder(keys.Forecast, fc)) + + site.persistTariffs() +} + +// persistTariffs stores tariff values once per 15min boundary. Like the meter +// collectors it is driven by the update loop, skipping the partial boot slot. +func (site *Site) persistTariffs() { + slot := time.Now().Truncate(tariff.SlotDuration) + + last := site.tariffSlot + site.tariffSlot = slot + + // skip repeat ticks within the slot and the partial boot slot + if last.IsZero() || !slot.After(last) { + return + } + + value := func(u api.TariffUsage) *float64 { + if r, err := tariff.At(site.GetTariff(u), slot); err == nil { + return &r.Value + } + return nil + } + + if err := metrics.PersistTariffs(slot, + value(api.TariffUsageGrid), + value(api.TariffUsageFeedIn), + value(api.TariffUsageCo2), + value(api.TariffUsageTemperature), + ); err != nil { + site.log.ERROR.Printf("persist tariffs: %v", err) + } } func (site *Site) solarDetails(solar api.Rates) solarDetails {