Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 10 additions & 14 deletions core/metrics/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}

Expand Down
2 changes: 1 addition & 1 deletion core/metrics/db_history.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
42 changes: 42 additions & 0 deletions core/metrics/tariffs.go
Original file line number Diff line number Diff line change
@@ -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
}
44 changes: 44 additions & 0 deletions core/metrics/tariffs_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
8 changes: 1 addition & 7 deletions core/site.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
38 changes: 32 additions & 6 deletions core/site_tariffs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down