diff --git a/core/metrics/collector.go b/core/metrics/collector.go index 1c3f68c4ab9..65de4480db0 100644 --- a/core/metrics/collector.go +++ b/core/metrics/collector.go @@ -9,15 +9,14 @@ import ( const ( // groups - Forecast = "forecast" - Temperature = "temperature" - Battery = "battery" - Grid = "grid" - PV = "pv" - Home = "home" // meter and group (virtual measurement) - Loadpoint = "loadpoint" - Meter = "meter" // additional meter (ext, monitoring only) - Consumer = "consumer" // consumer meter (consumers list or aux) + Forecast = "forecast" + Battery = "battery" + Grid = "grid" + PV = "pv" + Home = "home" // meter and group (virtual measurement) + Loadpoint = "loadpoint" + Meter = "meter" // additional meter (ext, monitoring only) + Consumer = "consumer" // consumer meter (consumers list or aux) ) type Collector struct { @@ -122,12 +121,9 @@ func (c *Collector) persist() error { return persist(c.entity, c.started, c.accu.Energy, c.accu.ReturnEnergy, c.accu.SocTemp) } -// SetSocTemp records the slot-start soc (temperature when isTemp). -// Advances the slot via process() so it can be used without a prior AddEnergy call. +// SetSocTemp records the slot-start soc (temperature when isTemp). Call after AddEnergy. func (c *Collector) SetSocTemp(value float64, isTemp bool) error { - if err := c.process(func() { c.accu.setSocTemp(value) }); err != nil { - return err - } + c.accu.setSocTemp(value) return c.entity.updateIsTemp(isTemp) } diff --git a/core/metrics/db_history.go b/core/metrics/db_history.go index 8aa93cd6f7c..8f4f591556d 100644 --- a/core/metrics/db_history.go +++ b/core/metrics/db_history.go @@ -39,7 +39,7 @@ type SeriesExport []Series // GroupOrder is the canonical display order of metric groups, mirroring the // frontend GROUP_ORDER plus home/forecast. -var GroupOrder = []string{PV, Battery, Grid, Loadpoint, Consumer, Meter, Home, Forecast, Temperature} +var GroupOrder = []string{PV, Battery, Grid, Loadpoint, Consumer, Meter, Home, Forecast} var aggregateFormats = map[string]string{ "15m": "%Y-%m-%d %H:%M", diff --git a/core/metrics/tariffs.go b/core/metrics/tariffs.go new file mode 100644 index 00000000000..66a04b2fb9b --- /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 00000000000..c6714710113 --- /dev/null +++ b/core/metrics/tariffs_test.go @@ -0,0 +1,44 @@ +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) +} diff --git a/core/site.go b/core/site.go index 6ce653e3824..e4fc9a5322c 100644 --- a/core/site.go +++ b/core/site.go @@ -91,6 +91,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 @@ -228,13 +229,6 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif } site.collectors[metrics.Forecast] = fc - // temperature forecast collector (populated when TariffUsageTemperature is configured) - tc, err := metrics.NewCollector(metrics.Temperature, metrics.Temperature, metrics.Temperature) - if err != nil { - return err - } - site.collectors[metrics.Temperature] = tc - // multiple batteries for _, ref := range site.Meters.BatteryMetersRef { dev, err := config.Meters().ByName(ref) diff --git a/core/site_tariffs.go b/core/site_tariffs.go index 939e2a7a3b7..ae0b7537b48 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 { @@ -157,12 +189,6 @@ func (site *Site) solarDetails(solar api.Rates) solarDetails { } } - if r, err := tariff.At(site.GetTariff(api.TariffUsageTemperature), time.Now()); err == nil { - if err := site.collectors[metrics.Temperature].SetSocTemp(r.Value, true); err != nil { - site.log.ERROR.Printf("temperature collector soc_temp: %v", err) - } - } - if scale := site.solarScale(); scale != 1 { res.Scale = &scale }