580 lines
16 KiB
Go
580 lines
16 KiB
Go
package core
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/avast/retry-go/v3"
|
|
"github.com/evcc-io/evcc/api"
|
|
"github.com/evcc-io/evcc/cmd/shutdown"
|
|
"github.com/evcc-io/evcc/core/coordinator"
|
|
"github.com/evcc-io/evcc/core/db"
|
|
"github.com/evcc-io/evcc/core/loadpoint"
|
|
"github.com/evcc-io/evcc/push"
|
|
serverdb "github.com/evcc-io/evcc/server/db"
|
|
"github.com/evcc-io/evcc/tariff"
|
|
"github.com/evcc-io/evcc/util"
|
|
"github.com/evcc-io/evcc/util/telemetry"
|
|
)
|
|
|
|
const standbyPower = 10 // consider less than 10W as charger in standby
|
|
|
|
// Updater abstracts the LoadPoint implementation for testing
|
|
type Updater interface {
|
|
Update(availablePower float64, cheapRate, batteryBuffered bool)
|
|
}
|
|
|
|
// Site is the main configuration container. A site can host multiple loadpoints.
|
|
type Site struct {
|
|
uiChan chan<- util.Param // client push messages
|
|
lpUpdateChan chan *LoadPoint
|
|
|
|
*Health
|
|
|
|
sync.Mutex
|
|
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 // Meter references
|
|
PrioritySoC float64 `mapstructure:"prioritySoC"` // prefer battery up to this SoC
|
|
BufferSoC float64 `mapstructure:"bufferSoC"` // ignore battery above this SoC
|
|
MaxGridSupplyWhileBatteryCharging float64 `mapstructure:"maxGridSupplyWhileBatteryCharging"` // ignore battery charging if AC consumption is above this value
|
|
|
|
// meters
|
|
gridMeter api.Meter // Grid usage meter
|
|
pvMeters []api.Meter // PV generation meters
|
|
batteryMeters []api.Meter // Battery charging meters
|
|
|
|
tariffs tariff.Tariffs // Tariff
|
|
loadpoints []*LoadPoint // Loadpoints
|
|
coordinator *coordinator.Coordinator // Savings
|
|
savings *Savings // Savings
|
|
|
|
// cached state
|
|
gridPower float64 // Grid power
|
|
pvPower float64 // PV power
|
|
batteryPower float64 // Battery charge power
|
|
batteryBuffered bool // Battery buffer active
|
|
}
|
|
|
|
// MetersConfig contains the loadpoint's meter configuration
|
|
type MetersConfig struct {
|
|
GridMeterRef string `mapstructure:"grid"` // Grid usage meter
|
|
PVMeterRef string `mapstructure:"pv"` // PV meter
|
|
PVMetersRef []string `mapstructure:"pvs"` // Multiple PV meters
|
|
BatteryMeterRef string `mapstructure:"battery"` // Battery charging meter
|
|
BatteryMetersRef []string `mapstructure:"batteries"` // Multiple Battery charging meters
|
|
}
|
|
|
|
// NewSiteFromConfig creates a new site
|
|
func NewSiteFromConfig(
|
|
log *util.Logger,
|
|
cp configProvider,
|
|
other map[string]interface{},
|
|
loadpoints []*LoadPoint,
|
|
vehicles []api.Vehicle,
|
|
tariffs tariff.Tariffs,
|
|
) (*Site, error) {
|
|
site := NewSite()
|
|
if err := util.DecodeOther(other, site); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
Voltage = site.Voltage
|
|
site.loadpoints = loadpoints
|
|
site.tariffs = tariffs
|
|
site.coordinator = coordinator.New(log, vehicles)
|
|
site.savings = NewSavings(tariffs)
|
|
|
|
// migrate session log
|
|
if serverdb.Instance != nil {
|
|
var err error
|
|
// TODO deprecate
|
|
if table := "transactions"; serverdb.Instance.Migrator().HasTable(table) {
|
|
err = serverdb.Instance.Migrator().RenameTable(table, new(db.Session))
|
|
}
|
|
if err == nil {
|
|
err = serverdb.Instance.AutoMigrate(new(db.Session))
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// give loadpoints access to vehicles and database
|
|
for _, lp := range loadpoints {
|
|
lp.coordinator = coordinator.NewAdapter(lp, site.coordinator)
|
|
|
|
if serverdb.Instance != nil {
|
|
var err error
|
|
if lp.db, err = db.New(lp.Title); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
shutdown.Register(lp.stopSession)
|
|
}
|
|
}
|
|
|
|
if site.Meters.GridMeterRef != "" {
|
|
var err error
|
|
if site.gridMeter, err = cp.Meter(site.Meters.GridMeterRef); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// multiple pv
|
|
for _, ref := range site.Meters.PVMetersRef {
|
|
pv, err := cp.Meter(ref)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
site.pvMeters = append(site.pvMeters, pv)
|
|
}
|
|
|
|
// single pv
|
|
if site.Meters.PVMeterRef != "" {
|
|
if len(site.pvMeters) > 0 {
|
|
return nil, errors.New("cannot have pv and pvs both")
|
|
}
|
|
pv, err := cp.Meter(site.Meters.PVMeterRef)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
site.pvMeters = append(site.pvMeters, pv)
|
|
}
|
|
|
|
// multiple batteries
|
|
for _, ref := range site.Meters.BatteryMetersRef {
|
|
battery, err := cp.Meter(ref)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
site.batteryMeters = append(site.batteryMeters, battery)
|
|
}
|
|
|
|
// single battery
|
|
if site.Meters.BatteryMeterRef != "" {
|
|
if len(site.batteryMeters) > 0 {
|
|
return nil, errors.New("cannot have battery and batteries both")
|
|
}
|
|
battery, err := cp.Meter(site.Meters.BatteryMeterRef)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
site.batteryMeters = append(site.batteryMeters, battery)
|
|
}
|
|
|
|
// configure meter from references
|
|
if site.gridMeter == nil && len(site.pvMeters) == 0 {
|
|
return nil, errors.New("missing either grid or pv meter")
|
|
}
|
|
|
|
return site, nil
|
|
}
|
|
|
|
// NewSite creates a Site with sane defaults
|
|
func NewSite() *Site {
|
|
lp := &Site{
|
|
log: util.NewLogger("site"),
|
|
Voltage: 230, // V
|
|
}
|
|
|
|
return lp
|
|
}
|
|
|
|
// LoadPoints returns the array of associated loadpoints
|
|
func (site *Site) LoadPoints() []loadpoint.API {
|
|
res := make([]loadpoint.API, len(site.loadpoints))
|
|
for id, lp := range site.loadpoints {
|
|
res[id] = lp
|
|
}
|
|
return res
|
|
}
|
|
|
|
func meterCapabilities(name string, meter interface{}) string {
|
|
_, power := meter.(api.Meter)
|
|
_, energy := meter.(api.MeterEnergy)
|
|
_, currents := meter.(api.MeterCurrent)
|
|
|
|
name += ":"
|
|
return fmt.Sprintf(" %-10s power %s energy %s currents %s",
|
|
name,
|
|
presence[power],
|
|
presence[energy],
|
|
presence[currents],
|
|
)
|
|
}
|
|
|
|
// DumpConfig site configuration
|
|
func (site *Site) DumpConfig() {
|
|
// verify vehicle detection
|
|
if vehicles := site.GetVehicles(); len(vehicles) > 1 {
|
|
for _, v := range vehicles {
|
|
if _, ok := v.(api.ChargeState); !ok {
|
|
site.log.WARN.Printf("vehicle '%s' does not support automatic detection", v.Title())
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
site.log.INFO.Println("site config:")
|
|
site.log.INFO.Printf(" meters: grid %s pv %s battery %s",
|
|
presence[site.gridMeter != nil],
|
|
presence[len(site.pvMeters) > 0],
|
|
presence[len(site.batteryMeters) > 0],
|
|
)
|
|
|
|
if site.gridMeter != nil {
|
|
site.log.INFO.Println(meterCapabilities("grid", site.gridMeter))
|
|
}
|
|
|
|
if len(site.pvMeters) > 0 {
|
|
for i, pv := range site.pvMeters {
|
|
site.log.INFO.Println(meterCapabilities(fmt.Sprintf("pv %d", i+1), pv))
|
|
}
|
|
}
|
|
|
|
if len(site.batteryMeters) > 0 {
|
|
for i, battery := range site.batteryMeters {
|
|
_, ok := battery.(api.Battery)
|
|
site.log.INFO.Println(
|
|
meterCapabilities(fmt.Sprintf("battery %d", i+1), battery),
|
|
fmt.Sprintf("soc %s", presence[ok]),
|
|
)
|
|
}
|
|
}
|
|
|
|
if vehicles := site.GetVehicles(); len(vehicles) > 0 {
|
|
site.log.INFO.Println(" vehicles:")
|
|
|
|
for i, v := range vehicles {
|
|
_, rng := v.(api.VehicleRange)
|
|
_, finish := v.(api.VehicleFinishTimer)
|
|
_, status := v.(api.ChargeState)
|
|
_, climate := v.(api.VehicleClimater)
|
|
_, wakeup := v.(api.Resurrector)
|
|
site.log.INFO.Printf(" vehicle %d: range %s finish %s status %s climate %s wakeup %s",
|
|
i+1, presence[rng], presence[finish], presence[status], presence[climate], presence[wakeup],
|
|
)
|
|
}
|
|
}
|
|
|
|
for i, lp := range site.loadpoints {
|
|
lp.log.INFO.Printf("loadpoint %d:", i+1)
|
|
lp.log.INFO.Printf(" mode: %s", lp.GetMode())
|
|
|
|
_, power := lp.charger.(api.Meter)
|
|
_, energy := lp.charger.(api.MeterEnergy)
|
|
_, currents := lp.charger.(api.MeterCurrent)
|
|
_, phases := lp.charger.(api.PhaseSwitcher)
|
|
_, wakeup := lp.charger.(api.Resurrector)
|
|
|
|
lp.log.INFO.Printf(" charger: power %s energy %s currents %s phases %s wakeup %s",
|
|
presence[power],
|
|
presence[energy],
|
|
presence[currents],
|
|
presence[phases],
|
|
presence[wakeup],
|
|
)
|
|
|
|
lp.log.INFO.Printf(" meters: charge %s", presence[lp.HasChargeMeter()])
|
|
|
|
lp.publish("chargeConfigured", lp.HasChargeMeter())
|
|
if lp.HasChargeMeter() {
|
|
lp.log.INFO.Printf(meterCapabilities("charge", lp.chargeMeter))
|
|
}
|
|
}
|
|
}
|
|
|
|
// publish sends values to UI and databases
|
|
func (site *Site) publish(key string, val interface{}) {
|
|
// test helper
|
|
if site.uiChan == nil {
|
|
return
|
|
}
|
|
|
|
site.uiChan <- util.Param{
|
|
Key: key,
|
|
Val: val,
|
|
}
|
|
}
|
|
|
|
// updateMeter updates and publishes single meter
|
|
func (site *Site) updateMeter(meter api.Meter, power *float64) func() error {
|
|
return func() error {
|
|
value, err := meter.CurrentPower()
|
|
if err == nil {
|
|
*power = value // update value if no error
|
|
}
|
|
|
|
return err
|
|
}
|
|
}
|
|
|
|
// updateMeter updates and publishes single meter
|
|
func (site *Site) updateMeters() error {
|
|
retryMeter := func(name string, meter api.Meter, power *float64) error {
|
|
if meter == nil {
|
|
return nil
|
|
}
|
|
|
|
err := retry.Do(site.updateMeter(meter, power), retryOptions...)
|
|
|
|
if err == nil {
|
|
site.log.DEBUG.Printf("%s power: %.0fW", name, *power)
|
|
site.publish(name+"Power", *power)
|
|
} else {
|
|
err = fmt.Errorf("%s meter: %v", name, err)
|
|
site.log.ERROR.Println(err)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
if len(site.pvMeters) > 0 {
|
|
site.pvPower = 0
|
|
|
|
for id, meter := range site.pvMeters {
|
|
var power float64
|
|
err := retry.Do(site.updateMeter(meter, &power), retryOptions...)
|
|
|
|
if err == nil {
|
|
// ignore negative values which represent self-consumption
|
|
site.pvPower += math.Max(0, power)
|
|
if power < -500 {
|
|
site.log.WARN.Printf("pv %d power: %.0fW is negative - check configuration if sign is correct", id, power)
|
|
}
|
|
} else {
|
|
err = fmt.Errorf("pv meter %d: %v", id, err)
|
|
site.log.ERROR.Println(err)
|
|
}
|
|
}
|
|
|
|
site.log.DEBUG.Printf("pv power: %.0fW", site.pvPower)
|
|
site.publish("pvPower", site.pvPower)
|
|
}
|
|
|
|
if len(site.batteryMeters) > 0 {
|
|
site.batteryPower = 0
|
|
|
|
for id, meter := range site.batteryMeters {
|
|
var power float64
|
|
err := retry.Do(site.updateMeter(meter, &power), retryOptions...)
|
|
|
|
if err == nil {
|
|
site.batteryPower += power
|
|
} else {
|
|
site.log.ERROR.Printf("battery meter %d: %v", id, err)
|
|
}
|
|
}
|
|
|
|
site.log.DEBUG.Printf("battery power: %.0fW", site.batteryPower)
|
|
site.publish("batteryPower", site.batteryPower)
|
|
}
|
|
|
|
err := retryMeter("grid", site.gridMeter, &site.gridPower)
|
|
|
|
// currents
|
|
if phaseMeter, ok := site.gridMeter.(api.MeterCurrent); err == nil && ok {
|
|
i1, i2, i3, err := phaseMeter.Currents()
|
|
if err == nil {
|
|
site.log.DEBUG.Printf("grid currents: %.3gA", []float64{i1, i2, i3})
|
|
site.publish("gridCurrents", []float64{i1, i2, i3})
|
|
} else {
|
|
site.log.ERROR.Printf("grid meter currents: %v", err)
|
|
}
|
|
}
|
|
|
|
// grid energy
|
|
if energyMeter, ok := site.gridMeter.(api.MeterEnergy); ok {
|
|
val, err := energyMeter.TotalEnergy()
|
|
if err == nil {
|
|
site.publish("gridEnergy", val)
|
|
} else {
|
|
site.log.ERROR.Println(fmt.Errorf("grid meter energy: %v", err))
|
|
}
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// sitePower returns the net power exported by the site minus a residual margin.
|
|
// negative values mean grid: export, battery: charging
|
|
func (site *Site) sitePower(totalChargePower float64) (float64, error) {
|
|
if err := site.updateMeters(); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// allow using PV as estimate for grid power
|
|
if site.gridMeter == nil {
|
|
site.gridPower = totalChargePower - site.pvPower
|
|
}
|
|
|
|
// allow using grid and charge as estimate for pv power
|
|
if site.pvMeters == nil {
|
|
site.pvPower = totalChargePower - site.gridPower + site.ResidualPower
|
|
if site.pvPower < 0 {
|
|
site.pvPower = 0
|
|
}
|
|
site.log.DEBUG.Printf("pv power: %.0fW", site.pvPower)
|
|
site.publish("pvPower", site.pvPower)
|
|
}
|
|
|
|
// honour battery priority
|
|
batteryPower := site.batteryPower
|
|
|
|
if len(site.batteryMeters) > 0 {
|
|
var socs float64
|
|
for id, battery := range site.batteryMeters {
|
|
soc, err := battery.(api.Battery).SoC()
|
|
if err != nil {
|
|
err = fmt.Errorf("battery soc %d: %v", id, err)
|
|
site.log.ERROR.Println(err)
|
|
} else {
|
|
site.log.DEBUG.Printf("battery soc %d: %.0f%%", id, soc)
|
|
socs += soc / float64(len(site.batteryMeters))
|
|
}
|
|
}
|
|
site.publish("batterySoC", math.Round(socs))
|
|
|
|
site.Lock()
|
|
defer site.Unlock()
|
|
|
|
// if battery is charging below prioritySoC give it priority
|
|
if socs < site.PrioritySoC && batteryPower < 0 {
|
|
site.log.DEBUG.Printf("giving priority to battery charging at soc: %.0f%%", socs)
|
|
batteryPower = 0
|
|
}
|
|
|
|
// if battery is discharging above bufferSoC ignore it
|
|
site.batteryBuffered = batteryPower > 0 && site.BufferSoC > 0 && socs > site.BufferSoC
|
|
}
|
|
|
|
sitePower := sitePower(site.log, site.MaxGridSupplyWhileBatteryCharging, site.gridPower, batteryPower, site.ResidualPower)
|
|
|
|
site.log.DEBUG.Printf("site power: %.0fW", sitePower)
|
|
|
|
return sitePower, nil
|
|
}
|
|
|
|
func (site *Site) update(lp Updater) {
|
|
site.log.DEBUG.Println("----")
|
|
|
|
var cheap bool
|
|
var err error
|
|
if site.tariffs.Grid != nil {
|
|
cheap, err = site.tariffs.Grid.IsCheap()
|
|
if err != nil {
|
|
cheap = false
|
|
}
|
|
}
|
|
|
|
// update all loadpoint's charge power
|
|
var totalChargePower float64
|
|
for _, lp := range site.loadpoints {
|
|
lp.UpdateChargePower()
|
|
totalChargePower += lp.GetChargePower()
|
|
}
|
|
|
|
if sitePower, err := site.sitePower(totalChargePower); err == nil {
|
|
lp.Update(sitePower, cheap, site.batteryBuffered)
|
|
|
|
// ignore negative pvPower values as that means it is not an energy source but consumption
|
|
homePower := site.gridPower + math.Max(0, site.pvPower) + site.batteryPower - totalChargePower
|
|
homePower = math.Max(homePower, 0)
|
|
site.publish("homePower", homePower)
|
|
|
|
site.Health.Update()
|
|
}
|
|
|
|
// update savings and aggregate telemetry
|
|
// TODO: use energy instead of current power for better results
|
|
deltaCharged, deltaSelf := site.savings.Update(site, site.gridPower, site.pvPower, site.batteryPower, totalChargePower)
|
|
if telemetry.Enabled() && totalChargePower > standbyPower {
|
|
go telemetry.UpdateChargeProgress(site.log, totalChargePower, deltaCharged, deltaSelf)
|
|
}
|
|
}
|
|
|
|
// prepare publishes initial values
|
|
func (site *Site) prepare() {
|
|
site.publish("siteTitle", site.Title)
|
|
|
|
site.publish("gridConfigured", site.gridMeter != nil)
|
|
site.publish("pvConfigured", len(site.pvMeters) > 0)
|
|
site.publish("batteryConfigured", len(site.batteryMeters) > 0)
|
|
site.publish("bufferSoC", site.BufferSoC)
|
|
site.publish("prioritySoC", site.PrioritySoC)
|
|
site.publish("residualPower", site.ResidualPower)
|
|
|
|
site.publish("currency", site.tariffs.Currency.String())
|
|
site.publish("savingsSince", site.savings.Since().Unix())
|
|
|
|
site.publish("vehicles", vehicleTitles(site.GetVehicles()))
|
|
}
|
|
|
|
// Prepare attaches communication channels to site and loadpoints
|
|
func (site *Site) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Event) {
|
|
site.uiChan = uiChan
|
|
site.lpUpdateChan = make(chan *LoadPoint, 1) // 1 capacity to avoid deadlock
|
|
|
|
site.prepare()
|
|
|
|
for id, lp := range site.loadpoints {
|
|
lpUIChan := make(chan util.Param)
|
|
lpPushChan := make(chan push.Event)
|
|
|
|
// pipe messages through go func to add id
|
|
go func(id int) {
|
|
for {
|
|
select {
|
|
case param := <-lpUIChan:
|
|
param.LoadPoint = &id
|
|
uiChan <- param
|
|
case ev := <-lpPushChan:
|
|
ev.LoadPoint = &id
|
|
pushChan <- ev
|
|
}
|
|
}
|
|
}(id)
|
|
|
|
lp.Prepare(lpUIChan, lpPushChan, site.lpUpdateChan)
|
|
}
|
|
}
|
|
|
|
// loopLoadpoints keeps iterating across loadpoints sending the next to the given channel
|
|
func (site *Site) loopLoadpoints(next chan<- Updater) {
|
|
for {
|
|
for _, lp := range site.loadpoints {
|
|
next <- lp
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run is the main control loop. It reacts to trigger events by
|
|
// updating measurements and executing control logic.
|
|
func (site *Site) Run(stopC chan struct{}, interval time.Duration) {
|
|
site.Health = NewHealth(time.Minute + interval)
|
|
|
|
loadpointChan := make(chan Updater)
|
|
go site.loopLoadpoints(loadpointChan)
|
|
|
|
ticker := time.NewTicker(interval)
|
|
site.update(<-loadpointChan) // start immediately
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
site.update(<-loadpointChan)
|
|
case lp := <-site.lpUpdateChan:
|
|
site.update(lp)
|
|
case <-stopC:
|
|
return
|
|
}
|
|
}
|
|
}
|