Tariffs: persist tariff values in 15min intervals (#31845)
Some checks failed
Release / call-build-workflow (push) Has been cancelled
Release / Publish Docker :release (push) Has been cancelled
Release / Github & APT (push) Has been cancelled
Release / Demo (push) Has been cancelled
Release / Hassio Addon (push) Has been cancelled

This commit is contained in:
Michael Geers 2026-07-17 15:00:23 +02:00 • committed by GitHub
parent d68e5e5809
commit 661af417a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 130 additions and 0 deletions

42
core/metrics/tariffs.go Normal file
View file

@ -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
}

View file

@ -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)
}

View file

@ -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

View file

@ -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 {