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
28 changes: 15 additions & 13 deletions api/globalconfig/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,33 +166,35 @@ func (c Messaging) IsConfigured() bool {
}

type Tariffs struct {
Currency string
Grid config.Typed
FeedIn config.Typed
Co2 config.Typed
Planner config.Typed
Solar []config.Typed
Currency string
Grid config.Typed
FeedIn config.Typed
Co2 config.Typed
Planner config.Typed
Solar []config.Typed
Temperature config.Typed
}

func (c Tariffs) IsConfigured() bool {
return c.Currency != "" || c.Grid.Type != "" || c.FeedIn.Type != "" || c.Co2.Type != "" || c.Planner.Type != "" || len(c.Solar) > 0
}

type TariffRefs struct {
Grid string `json:"grid"`
FeedIn string `json:"feedIn"`
Co2 string `json:"co2"`
Planner string `json:"planner"`
Solar []string `json:"solar"`
Grid string `json:"grid"`
FeedIn string `json:"feedIn"`
Co2 string `json:"co2"`
Planner string `json:"planner"`
Solar []string `json:"solar"`
Temperature string `json:"temperature"`
}

func (refs TariffRefs) IsConfigured() bool {
return refs.Grid != "" || refs.FeedIn != "" || refs.Co2 != "" || refs.Planner != "" || len(refs.Solar) > 0
return refs.Grid != "" || refs.FeedIn != "" || refs.Co2 != "" || refs.Planner != "" || len(refs.Solar) > 0 || refs.Temperature != ""
}

func (refs TariffRefs) Used() iter.Seq[string] {
return func(yield func(string) bool) {
for _, ref := range append([]string{refs.Grid, refs.FeedIn, refs.Co2, refs.Planner}, refs.Solar...) {
for _, ref := range append([]string{refs.Grid, refs.FeedIn, refs.Co2, refs.Planner, refs.Temperature}, refs.Solar...) {
if ref != "" {
if !yield(ref) {
return
Expand Down
2 changes: 2 additions & 0 deletions api/tariff.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const (
TariffTypePriceForecast
TariffTypeCo2
TariffTypeSolar
TariffTypeTemperature // outdoor temperature forecast in °C
)

type TariffUsage int
Expand All @@ -23,4 +24,5 @@ const (
TariffUsageGrid
TariffUsagePlanner
TariffUsageSolar
TariffUsageTemperature // outdoor temperature forecast
)
12 changes: 8 additions & 4 deletions api/tarifftype_enumer.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 8 additions & 4 deletions api/tariffusage_enumer.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,7 @@ func configureTariffs(conf *globalconfig.Tariffs, names ...string) (*tariff.Tari
eg.Go(func() error { return configureTariff(conf.Co2, refs.Co2, &tariffs.Co2) })
eg.Go(func() error { return configureTariff(conf.Planner, refs.Planner, &tariffs.Planner) })
eg.Go(func() error { return configureSolarTariffs(conf.Solar, refs.Solar, &tariffs.Solar) })
eg.Go(func() error { return configureTariff(conf.Temperature, refs.Temperature, &tariffs.Temperature) })
if err := eg.Wait(); err != nil {
return &tariffs, &ClassError{ClassTariff, err}
}
Expand Down
45 changes: 39 additions & 6 deletions core/metrics/db.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package metrics

import (
"database/sql"
"errors"
"time"

Expand All @@ -9,8 +10,17 @@ import (
"gorm.io/gorm"
)

const (
MeterTypeHousehold = 1
MeterTypeLoadpoint = 2

// LoadpointNameHousehold is the identifier for household base load (non-loadpoint consumption)
LoadpointNameHousehold = "_household"
)

type meter struct {
Meter int `json:"meter" gorm:"column:meter;uniqueIndex:meter_ts"`
Loadpoint string `json:"loadpoint" gorm:"column:loadpoint;uniqueIndex:meter_ts;default:'_household'"` // loadpoint name: "_household" for base load, or loadpoint title for heaters
Timestamp time.Time `json:"ts" gorm:"column:ts;uniqueIndex:meter_ts"`
Value float64 `json:"val" gorm:"column:val"`
}
Expand All @@ -23,29 +33,52 @@ func init() {
})
}

// Persist stores 15min consumption in Wh
// Persist stores 15min consumption in Wh for household total
func Persist(ts time.Time, value float64) error {
return db.Instance.Create(meter{
Meter: 1,
Meter: MeterTypeHousehold,
Loadpoint: LoadpointNameHousehold,
Timestamp: ts.Truncate(15 * time.Minute),
Value: value,
}).Error
}

// Profile returns a 15min average meter profile in Wh.
// PersistLoadpoint stores 15min consumption in Wh for a specific loadpoint
func PersistLoadpoint(loadpointName string, ts time.Time, value float64) error {
return db.Instance.Create(meter{
Meter: MeterTypeLoadpoint,
Loadpoint: loadpointName,
Timestamp: ts.Truncate(15 * time.Minute),
Value: value,
}).Error
}

// Profile returns a 15min average meter profile in Wh for household total.
// 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) {
return profileQuery(MeterTypeHousehold, LoadpointNameHousehold, from)
}

// LoadpointProfile returns a 15min average meter profile in Wh for a specific loadpoint.
// Profile is sorted by timestamp starting at 00:00. It is guaranteed to contain 96 15min values.
func LoadpointProfile(loadpointName string, from time.Time) (*[96]float64, error) {
return profileQuery(MeterTypeLoadpoint, loadpointName, from)
}

// profileQuery is the internal implementation for querying meter profiles
func profileQuery(meterType int, loadpointName string, 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
var rows *sql.Rows
rows, err = db.Query(`SELECT min(ts) AS ts, avg(val) AS val
FROM meters
WHERE meter = ? AND ts >= ?
WHERE meter = ? AND loadpoint = ? AND ts >= ?
GROUP BY strftime("%H:%M", ts, 'localtime')
ORDER BY strftime("%H:%M", ts, 'localtime') ASC`, 1, from,
ORDER BY strftime("%H:%M", ts, 'localtime') ASC`, meterType, loadpointName, from,
)
if err != nil {
return nil, err
Expand Down
65 changes: 60 additions & 5 deletions core/site.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,12 @@ type Site struct {
log *util.Logger

// configuration
Title string `mapstructure:"title"` // UI title
Voltage float64 `mapstructure:"voltage"` // Operating voltage. 230V for Germany.
ResidualPower float64 `mapstructure:"residualPower"` // PV meter only: household usage. Grid meter: household safety margin
Meters MetersConfig `mapstructure:"meters"` // Meter references
Title string `mapstructure:"title"` // UI title
Voltage float64 `mapstructure:"voltage"` // Operating voltage. 230V for Germany.
ResidualPower float64 `mapstructure:"residualPower"` // PV meter only: household usage. Grid meter: household safety margin
Meters MetersConfig `mapstructure:"meters"` // Meter references
HeatingThreshold float64 `mapstructure:"heatingThreshold"` // Temperature threshold for heating (°C)
HeatingCoefficient float64 `mapstructure:"heatingCoefficient"` // Heating load adjustment coefficient per degree

// meters
circuit api.Circuit // Circuit
Expand Down Expand Up @@ -91,6 +93,10 @@ type Site struct {
householdEnergy *meterEnergy
householdSlotStart time.Time

// per-loadpoint energy tracking for heating devices
loadpointEnergy map[int]*meterEnergy
loadpointSlotStart map[int]time.Time

// cached state
gridPower float64 // Grid power
pvPower float64 // PV power
Expand Down Expand Up @@ -140,6 +146,16 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
site.prioritizer = prioritizer.New(log)
site.stats = NewStats()

// initialize per-loadpoint energy tracking for heating devices
site.loadpointEnergy = make(map[int]*meterEnergy)
site.loadpointSlotStart = make(map[int]time.Time)
for i, lp := range loadpoints {
if hasFeature(lp.charger, api.Heating) {
site.loadpointEnergy[i] = &meterEnergy{clock: clock.New()}
site.loadpointSlotStart[i] = time.Time{}
}
}

// upload telemetry on shutdown
if telemetry.Enabled() {
shutdown.Register(func() {
Expand Down Expand Up @@ -800,6 +816,39 @@ func (site *Site) updateHomeConsumption(homePower float64) {
}
}

// updateLoadpointConsumption tracks energy consumption for a specific loadpoint (heating devices)
func (site *Site) updateLoadpointConsumption(lpID int, power float64) {
lpEnergy, exists := site.loadpointEnergy[lpID]
if !exists {
return // not a tracked heating loadpoint
}

lpEnergy.AddPower(power)

now := lpEnergy.clock.Now()
if site.loadpointSlotStart[lpID].IsZero() {
site.loadpointSlotStart[lpID] = now
return
}

slotDuration := 15 * time.Minute
slotStart := now.Truncate(slotDuration)

if slotStart.After(site.loadpointSlotStart[lpID]) {
// next slot has started
if slotStart.Sub(site.loadpointSlotStart[lpID]) >= slotDuration {
// more or less full slot
site.log.DEBUG.Printf("15min loadpoint %d consumption: %.0fWh", lpID, lpEnergy.Accumulated)
if err := metrics.PersistLoadpoint(site.loadpoints[lpID].GetTitle(), site.loadpointSlotStart[lpID], lpEnergy.Accumulated); err != nil {
site.log.ERROR.Printf("persist loadpoint %d consumption: %v", lpID, err)
}
}

site.loadpointSlotStart[lpID] = slotStart
lpEnergy.Accumulated = 0
}
}

// sitePower returns
// - the net power exported by the site minus a residual margin
// (negative values mean grid: export, battery: charging
Expand Down Expand Up @@ -875,11 +924,17 @@ func (site *Site) updateLoadpoints(rates api.Rates) float64 {
sum float64
)

for _, lp := range site.loadpoints {
for i, lp := range site.loadpoints {
lpID := i // capture loop variable for goroutine
wg.Go(func() {
power := lp.UpdateChargePowerAndCurrents()
site.prioritizer.UpdateChargePowerFlexibility(lp, rates)

// track heating loadpoint consumption
if _, isHeating := site.loadpointEnergy[lpID]; isHeating && power > 0 {
site.updateLoadpointConsumption(lpID, power)
}

mu.Lock()
sum += power
mu.Unlock()
Expand Down
Loading