1502 lines
45 KiB
Go
1502 lines
45 KiB
Go
package core
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/cenkalti/backoff/v4"
|
|
"github.com/evcc-io/evcc/api"
|
|
"github.com/evcc-io/evcc/cmd/shutdown"
|
|
"github.com/evcc-io/evcc/core/circuit"
|
|
"github.com/evcc-io/evcc/core/coordinator"
|
|
"github.com/evcc-io/evcc/core/keys"
|
|
"github.com/evcc-io/evcc/core/loadpoint"
|
|
"github.com/evcc-io/evcc/core/metrics"
|
|
"github.com/evcc-io/evcc/core/planner"
|
|
"github.com/evcc-io/evcc/core/prioritizer"
|
|
"github.com/evcc-io/evcc/core/session"
|
|
"github.com/evcc-io/evcc/core/site"
|
|
"github.com/evcc-io/evcc/core/soc"
|
|
"github.com/evcc-io/evcc/core/types"
|
|
"github.com/evcc-io/evcc/core/vehicle"
|
|
"github.com/evcc-io/evcc/hems/hems"
|
|
"github.com/evcc-io/evcc/messenger"
|
|
"github.com/evcc-io/evcc/server/db"
|
|
"github.com/evcc-io/evcc/server/db/settings"
|
|
"github.com/evcc-io/evcc/tariff"
|
|
"github.com/evcc-io/evcc/util"
|
|
"github.com/evcc-io/evcc/util/config"
|
|
"github.com/evcc-io/evcc/util/modbus"
|
|
"github.com/evcc-io/evcc/util/telemetry"
|
|
"github.com/jinzhu/now"
|
|
"github.com/samber/lo"
|
|
"github.com/smallnest/chanx"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
const standbyPower = 10 // consider less than 10W as charger in standby
|
|
|
|
// updater abstracts the Loadpoint implementation for testing
|
|
type updater interface {
|
|
loadpoint.API
|
|
Update(sitePower, batteryPower float64, consumption, feedin api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effectivePrice, effectiveCo2 *float64, dim *bool)
|
|
}
|
|
|
|
var _ site.API = (*Site)(nil)
|
|
|
|
// Site is the main configuration container. A site can host multiple loadpoints.
|
|
type Site struct {
|
|
valueChan chan<- util.Param // client push messages
|
|
pushChan chan<- messenger.Event // notification events
|
|
lpUpdateChan chan *Loadpoint
|
|
|
|
sync.RWMutex
|
|
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
|
|
CurtailersRef []string `mapstructure:"curtailers"` // Curtailment device references
|
|
|
|
// meters
|
|
circuit api.Circuit // Circuit
|
|
hems api.HEMS // HEMS (set by configureHEMS at boot)
|
|
gridMeter config.Device[api.Meter] // Grid usage meter
|
|
pvMeters []config.Device[api.Meter] // PV generation meters
|
|
batteryMeters []config.Device[api.Meter] // Battery charging meters
|
|
extMeters []config.Device[api.Meter] // External meters - for monitoring only
|
|
auxMeters []config.Device[api.Meter] // Auxiliary meters
|
|
consumerMeters []config.Device[api.Meter] // Consumer meters
|
|
curtailers []config.Device[api.Curtailer]
|
|
|
|
// last applied HEMS state, nil until applied or after a failed attempt
|
|
dimmed *bool
|
|
curtailPercent *int
|
|
|
|
// battery settings
|
|
prioritySoc float64 // prefer battery up to this Soc
|
|
bufferSoc float64 // continue charging on battery above this Soc
|
|
bufferStartSoc float64 // start charging on battery above this Soc
|
|
batteryDischargeControl bool // prevent battery discharge for fast and planned charging
|
|
batteryGridChargeLimit *float64 // grid charging limit
|
|
batteryGridDischarge bool // allow battery discharge to grid (experimental)
|
|
|
|
// grid settings
|
|
gridExportLimit float64 // static grid export power limit in W, 0 = disabled
|
|
|
|
// forecast settings
|
|
solarAdjusted bool // adjust solar forecast to real production data
|
|
|
|
// optimizer settings
|
|
optimizerChargingStrategy string // optimizer grid charging strategy
|
|
|
|
loadpoints []*Loadpoint // Loadpoints
|
|
tariffs *tariff.Tariffs // Tariffs
|
|
coordinator *coordinator.Coordinator // Vehicles
|
|
prioritizer *prioritizer.Prioritizer // Power budgets
|
|
stats *Stats // Stats
|
|
|
|
collectors map[string]*metrics.Collector // keyed by meter ref
|
|
tariffSlot time.Time // last persisted tariff slot
|
|
|
|
// cached measurement state, guarded by RWMutex
|
|
siteState
|
|
|
|
batteryMaxDischargePower *float64 // Max discharge power of all battery meters
|
|
batteryMode api.BatteryMode // Battery mode (runtime only, not persisted)
|
|
batteryModeExternal api.BatteryMode // Battery mode (external, runtime only, not persisted)
|
|
batteryModeExternalTimer time.Time // Battery mode timer for external control
|
|
batteryModeApplied map[string]api.BatteryMode // Battery mode last applied per battery meter
|
|
suggestions map[string]types.Suggestion // Optimizer suggestions by device key
|
|
suggestionActions map[string]string // last notified actionable optimizer action by device key
|
|
|
|
optimizerMu sync.Mutex // guards optimizer runs
|
|
optimizerUpdated time.Time // last optimizer run, guarded by optimizerMu
|
|
|
|
solarScaleCached func() (float64, error) // util.Cached wrapper around querySolarScale
|
|
}
|
|
|
|
// siteState is the site's cached measurement state, updated once per meter cycle
|
|
type siteState struct {
|
|
gridPower float64 // Grid power
|
|
pvPower float64 // PV power
|
|
excessDCPower float64 // PV excess DC charge power (hybrid only)
|
|
auxPower float64 // Aux power
|
|
battery types.BatteryState // Battery cached and published state
|
|
}
|
|
|
|
// state returns a copy of the cached measurement state
|
|
func (site *Site) state() siteState {
|
|
site.RLock()
|
|
defer site.RUnlock()
|
|
return site.siteState
|
|
}
|
|
|
|
// MetersConfig contains the site's meter configuration
|
|
type MetersConfig struct {
|
|
GridMeterRef string `mapstructure:"grid"` // Grid usage meter
|
|
PVMetersRef []string `mapstructure:"pv"` // PV meter
|
|
BatteryMetersRef []string `mapstructure:"battery"` // Battery charging meter
|
|
ExtMetersRef []string `mapstructure:"ext"` // Meters used only for monitoring
|
|
AuxMetersRef []string `mapstructure:"aux"` // Auxiliary meters
|
|
ConsumerMetersRef []string `mapstructure:"consumer"` // Consumer meters
|
|
}
|
|
|
|
// NewSiteFromConfig creates a new site
|
|
func NewSiteFromConfig(other map[string]any) (*Site, error) {
|
|
site := NewSite()
|
|
|
|
// TODO remove
|
|
if err := util.DecodeOther(other, site); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// add meters from config
|
|
site.restoreMetersAndTitle()
|
|
|
|
// TODO title
|
|
Voltage = site.Voltage
|
|
|
|
return site, nil
|
|
}
|
|
|
|
func activeMeters(refs []string) ([]config.Device[api.Meter], error) {
|
|
var res []config.Device[api.Meter]
|
|
for _, ref := range refs {
|
|
dev, err := config.Meters().ByName(ref)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if dev.Instance() == nil {
|
|
continue
|
|
}
|
|
|
|
res = append(res, dev)
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tariff.Tariffs) error {
|
|
site.loadpoints = loadpoints
|
|
site.tariffs = tariffs
|
|
|
|
handler := config.Vehicles()
|
|
site.coordinator = coordinator.New(log, config.Instances(handler.Devices()))
|
|
handler.Subscribe(site.updateVehicles)
|
|
|
|
site.prioritizer = prioritizer.New(log)
|
|
site.stats = NewStats()
|
|
|
|
me, err := metrics.NewCollector(metrics.Home, metrics.Home, metrics.Home)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
site.collectors[metrics.Home] = me
|
|
|
|
// reload history in the UI on each persisted 15min slot instead of polling
|
|
metrics.OnPersist = func(slot time.Time) { site.publish(keys.HistoryUpdated, slot) }
|
|
|
|
// upload telemetry on shutdown
|
|
if telemetry.Enabled() {
|
|
shutdown.Register(func() {
|
|
telemetry.Persist(log)
|
|
})
|
|
}
|
|
|
|
tariff := site.GetTariff(api.TariffUsagePlanner)
|
|
|
|
// give loadpoints access to vehicles and database
|
|
for _, lp := range site.activeLoadpoints() {
|
|
lp.coordinator = coordinator.NewAdapter(lp, site.coordinator)
|
|
lp.planner = planner.New(lp.log, tariff)
|
|
|
|
if db.Instance != nil {
|
|
var err error
|
|
if lp.db, err = session.NewStore(lp.GetTitle(), db.Instance); err != nil {
|
|
return err
|
|
}
|
|
// Fix any dangling history
|
|
if err := lp.db.ClosePendingSessionsInHistory(lp.chargeMeterTotal()); err != nil {
|
|
return err
|
|
}
|
|
|
|
// NOTE: this requires stopSession to respect async access
|
|
shutdown.Register(lp.stopSession)
|
|
}
|
|
}
|
|
|
|
// circuit
|
|
if c := circuit.Root(); c != nil {
|
|
site.circuit = c
|
|
}
|
|
|
|
// grid meter
|
|
if site.Meters.GridMeterRef != "" {
|
|
dev, err := config.Meters().ByName(site.Meters.GridMeterRef)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if dev.Instance() == nil {
|
|
site.log.WARN.Println("missing grid meter instance")
|
|
} else {
|
|
site.gridMeter = dev
|
|
|
|
me, err := metrics.NewCollector(metrics.Grid, site.Meters.GridMeterRef, metrics.Grid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
site.collectors[site.Meters.GridMeterRef] = me
|
|
}
|
|
}
|
|
|
|
// multiple pv
|
|
for _, ref := range site.Meters.PVMetersRef {
|
|
dev, err := config.Meters().ByName(ref)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if dev.Instance() == nil {
|
|
continue
|
|
}
|
|
site.pvMeters = append(site.pvMeters, dev)
|
|
|
|
// energy collector (for history persistence and forecast scaling)
|
|
me, err := metrics.NewCollector(metrics.PV, ref, deviceTitleOrName(dev))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
site.collectors[ref] = me
|
|
}
|
|
|
|
// solar forecast collector (mirrors PV history shape, used for scale lookup)
|
|
fc, err := metrics.NewCollector(metrics.Forecast, metrics.Forecast, metrics.Forecast)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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
|
|
mm, err := activeMeters(site.Meters.BatteryMetersRef)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
site.batteryMeters = mm
|
|
for _, dev := range mm {
|
|
ref := dev.Config().Name
|
|
me, err := metrics.NewCollector(metrics.Battery, ref, deviceTitleOrName(dev))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
site.collectors[ref] = me
|
|
}
|
|
|
|
// additional meters used only for monitoring
|
|
mm, err = activeMeters(site.Meters.ExtMetersRef)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
site.extMeters = mm
|
|
for _, dev := range mm {
|
|
ref := dev.Config().Name
|
|
me, err := metrics.NewCollector(metrics.Meter, ref, deviceTitleOrName(dev))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
site.collectors[ref] = me
|
|
}
|
|
|
|
// auxiliary meters (consumers)
|
|
mm, err = activeMeters(site.Meters.AuxMetersRef)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
site.auxMeters = mm
|
|
for _, dev := range mm {
|
|
ref := dev.Config().Name
|
|
me, err := metrics.NewCollector(metrics.Consumer, ref, deviceTitleOrName(dev))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
site.collectors[ref] = me
|
|
}
|
|
|
|
// consumer meters
|
|
mm, err = activeMeters(site.Meters.ConsumerMetersRef)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
site.consumerMeters = mm
|
|
for _, dev := range mm {
|
|
ref := dev.Config().Name
|
|
me, err := metrics.NewCollector(metrics.Consumer, ref, deviceTitleOrName(dev))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
site.collectors[ref] = me
|
|
}
|
|
|
|
// curtailment devices
|
|
for _, ref := range site.CurtailersRef {
|
|
dev, err := config.Curtailers().ByName(ref)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
site.curtailers = append(site.curtailers, dev)
|
|
}
|
|
|
|
// revert battery mode on shutdown
|
|
shutdown.Register(func() {
|
|
if mode := site.GetBatteryMode(); batteryModeModified(mode) {
|
|
if err := site.applyBatteryMode(api.BatteryNormal); err != nil {
|
|
site.log.ERROR.Println("battery mode:", err)
|
|
}
|
|
}
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// NewSite creates a Site with sane defaults
|
|
func NewSite() *Site {
|
|
site := &Site{
|
|
log: util.NewLogger("site"),
|
|
Voltage: 230, // V
|
|
collectors: make(map[string]*metrics.Collector),
|
|
}
|
|
|
|
// the result only depends on completed days, so it cannot change within a day
|
|
site.solarScaleCached = util.Cached(func() (float64, error) {
|
|
scale, err := site.querySolarScale(now.BeginningOfDay())
|
|
if err != nil {
|
|
site.log.ERROR.Printf("solar scale percentile: %v, falling back to unadjusted forecast", err)
|
|
}
|
|
return scale, err
|
|
}, 24*time.Hour)
|
|
|
|
return site
|
|
}
|
|
|
|
// restoreMetersAndTitle restores site meter configuration
|
|
func (site *Site) restoreMetersAndTitle() {
|
|
if testing.Testing() {
|
|
return
|
|
}
|
|
if v, err := settings.String(keys.Title); err == nil {
|
|
site.Title = v
|
|
}
|
|
if v, err := settings.String(keys.GridMeter); err == nil && v != "" {
|
|
site.Meters.GridMeterRef = v
|
|
}
|
|
if v, err := settings.String(keys.PvMeters); err == nil && v != "" {
|
|
site.Meters.PVMetersRef = append(site.Meters.PVMetersRef, filterConfigurableMeter(strings.Split(v, ","))...)
|
|
}
|
|
if v, err := settings.String(keys.BatteryMeters); err == nil && v != "" {
|
|
site.Meters.BatteryMetersRef = append(site.Meters.BatteryMetersRef, filterConfigurableMeter(strings.Split(v, ","))...)
|
|
}
|
|
if v, err := settings.String(keys.ExtMeters); err == nil && v != "" {
|
|
site.Meters.ExtMetersRef = append(site.Meters.ExtMetersRef, filterConfigurableMeter(strings.Split(v, ","))...)
|
|
}
|
|
if v, err := settings.String(keys.AuxMeters); err == nil && v != "" {
|
|
site.Meters.AuxMetersRef = append(site.Meters.AuxMetersRef, filterConfigurableMeter(strings.Split(v, ","))...)
|
|
}
|
|
if v, err := settings.String(keys.ConsumerMeters); err == nil && v != "" {
|
|
site.Meters.ConsumerMetersRef = append(site.Meters.ConsumerMetersRef, filterConfigurableMeter(strings.Split(v, ","))...)
|
|
}
|
|
if v, err := settings.String(keys.Curtailers); err == nil && v != "" {
|
|
site.CurtailersRef = append(site.CurtailersRef, filterConfigurableCurtailers(strings.Split(v, ","))...)
|
|
}
|
|
}
|
|
|
|
// restoreSettings restores site settings
|
|
func (site *Site) restoreSettings() error {
|
|
if testing.Testing() {
|
|
return nil
|
|
}
|
|
if v, err := settings.Float(keys.BufferSoc); err == nil {
|
|
if err := site.SetBufferSoc(v); err != nil && !errors.Is(err, ErrBatteryNotConfigured) {
|
|
return err
|
|
}
|
|
}
|
|
if v, err := settings.Float(keys.BufferStartSoc); err == nil {
|
|
if err := site.SetBufferStartSoc(v); err != nil && !errors.Is(err, ErrBatteryNotConfigured) {
|
|
return err
|
|
}
|
|
}
|
|
if v, err := settings.Float(keys.PrioritySoc); err == nil {
|
|
if err := site.SetPrioritySoc(v); err != nil && !errors.Is(err, ErrBatteryNotConfigured) {
|
|
return err
|
|
}
|
|
}
|
|
if v, err := settings.Bool(keys.BatteryDischargeControl); err == nil {
|
|
if err := site.SetBatteryDischargeControl(v); err != nil && !errors.Is(err, ErrBatteryControlNotAvailable) {
|
|
return err
|
|
}
|
|
}
|
|
if v, err := settings.Bool(keys.BatteryGridDischarge); err == nil {
|
|
if err := site.SetBatteryGridDischarge(v); err != nil && !errors.Is(err, ErrBatteryControlNotAvailable) {
|
|
return err
|
|
}
|
|
}
|
|
if v, err := settings.Float(keys.ResidualPower); err == nil {
|
|
if err := site.SetResidualPower(v); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if v, err := settings.Float(keys.BatteryGridChargeLimit); err == nil {
|
|
if err := site.SetBatteryGridChargeLimit(&v); err != nil && !errors.Is(err, ErrBatteryControlNotAvailable) {
|
|
return err
|
|
}
|
|
}
|
|
if v, err := settings.Float(keys.GridExportLimit); err == nil {
|
|
if err := site.SetGridExportLimit(v); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if v, err := settings.Bool(keys.SolarAdjusted); err == nil {
|
|
site.SetSolarAdjusted(v)
|
|
}
|
|
if v, err := settings.String(keys.OptimizerChargingStrategy); err == nil && v != "" {
|
|
if err := site.SetOptimizerChargingStrategy(v); err != nil {
|
|
site.log.WARN.Printf("optimizer charging strategy: %v", err)
|
|
}
|
|
}
|
|
site.publish(keys.OptimizerChargingStrategy, site.GetOptimizerChargingStrategy())
|
|
site.publish(keys.OptimizerChargingStrategies, optimizerChargingStrategies)
|
|
|
|
// drop legacy accumulator-based forecast settings (now stored via metrics collector)
|
|
settings.Delete("solarAccForecast")
|
|
settings.Delete("solarAccYield")
|
|
settings.Delete("solarAccDay")
|
|
|
|
return nil
|
|
}
|
|
|
|
func meterCapabilities(name string, meter any) string {
|
|
power := api.HasCap[api.Meter](meter)
|
|
|
|
if !power {
|
|
panic("not a meter: " + name)
|
|
}
|
|
|
|
energy := api.HasCap[api.MeterEnergy](meter)
|
|
currents := api.HasCap[api.PhaseCurrents](meter)
|
|
|
|
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.Vehicles().Instances(); len(vehicles) > 1 {
|
|
for _, v := range vehicles {
|
|
if !api.HasCap[api.ChargeState](v) && len(v.Identifiers()) == 0 {
|
|
site.log.INFO.Printf("vehicle '%s' does not support automatic detection", v.GetTitle())
|
|
}
|
|
}
|
|
}
|
|
|
|
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.Instance()))
|
|
}
|
|
|
|
if len(site.pvMeters) > 0 {
|
|
for i, pv := range site.pvMeters {
|
|
site.log.INFO.Println(meterCapabilities(fmt.Sprintf("pv %d", i+1), pv.Instance()))
|
|
}
|
|
}
|
|
|
|
if len(site.batteryMeters) > 0 {
|
|
for i, dev := range site.batteryMeters {
|
|
battery := dev.Instance()
|
|
isBattery := api.HasCap[api.Battery](battery)
|
|
hasCapacity := api.HasCap[api.BatteryCapacity](battery)
|
|
|
|
site.log.INFO.Println(
|
|
meterCapabilities(fmt.Sprintf("battery %d", i+1), battery),
|
|
fmt.Sprintf("soc %s capacity %s", presence[isBattery], presence[hasCapacity]),
|
|
)
|
|
}
|
|
}
|
|
|
|
if vehicles := site.Vehicles().Instances(); len(vehicles) > 0 {
|
|
site.log.INFO.Println(" vehicles:")
|
|
|
|
for i, v := range vehicles {
|
|
_, rng := api.Cap[api.VehicleRange](v)
|
|
_, finish := api.Cap[api.VehicleFinishTimer](v)
|
|
_, status := api.Cap[api.ChargeState](v)
|
|
_, climate := api.Cap[api.VehicleClimater](v)
|
|
_, wakeup := api.Cap[api.Resurrector](v)
|
|
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],
|
|
)
|
|
}
|
|
}
|
|
|
|
site.log.INFO.Println(" tariffs:")
|
|
trf := func(u api.TariffUsage) string {
|
|
if t := site.GetTariff(u); t != nil {
|
|
return t.Type().String()
|
|
}
|
|
return presence[false]
|
|
}
|
|
site.log.INFO.Printf(" grid: %s", trf(api.TariffUsageGrid))
|
|
site.log.INFO.Printf(" feed-in: %s", trf(api.TariffUsageFeedIn))
|
|
site.log.INFO.Printf(" co2: %s", presence[site.GetTariff(api.TariffUsageCo2) != nil])
|
|
site.log.INFO.Printf(" solar: %s", presence[site.GetTariff(api.TariffUsageSolar) != nil])
|
|
|
|
for i, lp := range site.loadpoints {
|
|
if lp == nil {
|
|
continue
|
|
}
|
|
|
|
lp.log.INFO.Printf("loadpoint %d:", i+1)
|
|
lp.log.INFO.Printf(" mode: %s", lp.GetMode())
|
|
|
|
_, power := api.Cap[api.Meter](lp.charger)
|
|
_, energy := api.Cap[api.MeterEnergy](lp.charger)
|
|
_, currents := api.Cap[api.PhaseCurrents](lp.charger)
|
|
_, phases := api.Cap[api.PhaseSwitcher](lp.charger)
|
|
_, wakeup := api.Cap[api.Resurrector](lp.charger)
|
|
|
|
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()])
|
|
|
|
if lp.HasChargeMeter() {
|
|
lp.log.INFO.Println(meterCapabilities("charge", lp.chargeMeter))
|
|
}
|
|
}
|
|
}
|
|
|
|
// publish sends values to UI and databases
|
|
func (site *Site) publish(key string, val any) {
|
|
// test helper
|
|
if site.valueChan == nil {
|
|
return
|
|
}
|
|
|
|
site.valueChan <- util.Param{Key: key, Val: val}
|
|
}
|
|
|
|
// publish sends values to UI and databases
|
|
func (site *Site) Publish(key string, val any) {
|
|
site.publish(key, val)
|
|
}
|
|
|
|
// publishLoadpoint sends a value into the given loadpoint's state
|
|
func (site *Site) publishLoadpoint(id int, key string, val any) {
|
|
// test helper
|
|
if site.valueChan == nil {
|
|
return
|
|
}
|
|
|
|
site.valueChan <- util.Param{Loadpoint: &id, Key: key, Val: val}
|
|
}
|
|
|
|
// clearPlanLocks clears locked plan goals for all loadpoints
|
|
func (site *Site) clearPlanLocks() {
|
|
for _, lp := range site.activeLoadpoints() {
|
|
lp.ClearPlanLock()
|
|
}
|
|
}
|
|
|
|
func (site *Site) collectMeters(key string, meters []config.Device[api.Meter]) []types.Measurement {
|
|
mm := make([]types.Measurement, len(meters))
|
|
|
|
fun := func(i int, dev config.Device[api.Meter]) {
|
|
meter := dev.Instance()
|
|
|
|
props := deviceProperties(dev)
|
|
mm[i] = types.Measurement{
|
|
Name: dev.Config().Name,
|
|
Title: props.Title,
|
|
Icon: props.Icon,
|
|
}
|
|
|
|
// power
|
|
var b bytes.Buffer
|
|
power, err := backoff.RetryWithData(meter.CurrentPower, modbus.Backoff())
|
|
if err == nil {
|
|
mm[i].Power = power
|
|
site.log.DEBUG.Printf("%s %d power: %.0fW", key, i+1, power)
|
|
} else if !errors.Is(err, api.ErrNotAvailable) {
|
|
if b.Len() > 0 {
|
|
site.log.ERROR.Println("\n" + b.String())
|
|
}
|
|
site.log.ERROR.Printf("%s %d power: %v", key, i+1, err)
|
|
}
|
|
|
|
// energy (production); ignore spurious zero readings (NaN-derived or nightly reset, #30950)
|
|
if m, ok := api.Cap[api.MeterEnergy](meter); ok {
|
|
if f, err := nonZeroEnergy(m.TotalEnergy()); err == nil {
|
|
mm[i].Energy = new(f)
|
|
} else if !errors.Is(err, api.ErrNotAvailable) {
|
|
site.log.ERROR.Printf("%s %d energy: %v", key, i+1, err)
|
|
}
|
|
}
|
|
|
|
// return energy (export); ignore spurious zero readings as above
|
|
if m, ok := api.Cap[api.MeterReturnEnergy](meter); ok {
|
|
if f, err := nonZeroEnergy(m.ReturnEnergy()); err == nil {
|
|
mm[i].ReturnEnergy = new(f)
|
|
} else if !errors.Is(err, api.ErrNotAvailable) {
|
|
site.log.ERROR.Printf("%s %d return energy: %v", key, i+1, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
for i, meter := range meters {
|
|
wg.Go(func() {
|
|
fun(i, meter)
|
|
})
|
|
}
|
|
wg.Wait()
|
|
|
|
return mm
|
|
}
|
|
|
|
// updatePvMeters updates pv meters. All measurements are optional.
|
|
func (site *Site) updatePvMeters() {
|
|
if len(site.pvMeters) == 0 {
|
|
return
|
|
}
|
|
|
|
mm := site.collectMeters("pv", site.pvMeters)
|
|
|
|
for i, dev := range site.pvMeters {
|
|
meter := dev.Instance()
|
|
|
|
power := mm[i].Power
|
|
if power < -500 {
|
|
site.log.WARN.Printf("pv %d power: %.0fW is negative - check configuration if sign is correct", i+1, power)
|
|
}
|
|
|
|
if m, ok := api.Cap[api.MaxACPowerGetter](meter); ok {
|
|
// unknown rating (0) must not turn the entire production into excess DC
|
|
if maxACPower := m.MaxACPower(); maxACPower > 0 && power > maxACPower {
|
|
dc := power - maxACPower
|
|
mm[i].ExcessDCPower = dc
|
|
site.log.DEBUG.Printf("pv %d excess DC: %.0fW", i+1, dc)
|
|
}
|
|
}
|
|
}
|
|
|
|
pvPower := lo.SumBy(mm, func(m types.Measurement) float64 {
|
|
return max(0, m.Power)
|
|
})
|
|
excessDCPower := lo.SumBy(mm, func(m types.Measurement) float64 {
|
|
return math.Abs(m.ExcessDCPower)
|
|
})
|
|
totalEnergy := lo.SumBy(mm, func(m types.Measurement) float64 {
|
|
if m.Energy == nil {
|
|
return 0
|
|
}
|
|
return *m.Energy
|
|
})
|
|
|
|
site.Lock()
|
|
site.pvPower, site.excessDCPower = pvPower, excessDCPower
|
|
site.Unlock()
|
|
|
|
if len(site.pvMeters) > 1 {
|
|
var excessStr string
|
|
if excessDCPower > 0 {
|
|
excessStr = fmt.Sprintf(" (includes %.0fW excess DC)", excessDCPower)
|
|
}
|
|
|
|
site.log.DEBUG.Printf("pv power: %.0fW"+excessStr, pvPower)
|
|
}
|
|
|
|
site.publish(keys.PvPower, pvPower)
|
|
site.publish(keys.PvEnergy, totalEnergy)
|
|
site.publish(keys.Pv, mm)
|
|
|
|
// persist per-meter PV energy slots (used for history and forecast scaling)
|
|
for i, dev := range site.pvMeters {
|
|
c := site.collectors[dev.Config().Name]
|
|
if err := c.AddEnergy(mm[i].Energy, mm[i].ReturnEnergy, mm[i].Power); err != nil {
|
|
site.log.ERROR.Printf("persist pv %d energy: %v", i+1, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// updateBatteryMeters updates battery meters
|
|
func (site *Site) updateBatteryMeters() {
|
|
if len(site.batteryMeters) == 0 {
|
|
return
|
|
}
|
|
|
|
mm := site.collectMeters("battery", site.batteryMeters)
|
|
|
|
var maxDischargePower float64
|
|
for i, dev := range site.batteryMeters {
|
|
meter := dev.Instance()
|
|
|
|
// battery soc and capacity
|
|
if m, ok := api.Cap[api.Battery](meter); ok {
|
|
batSoc, err := soc.Guard(m.Soc())
|
|
if err == nil {
|
|
mm[i].Soc = new(batSoc)
|
|
|
|
if bc, ok := api.Cap[api.BatteryCapacity](meter); ok {
|
|
mm[i].Capacity = new(bc.Capacity())
|
|
}
|
|
|
|
site.log.DEBUG.Printf("battery %d soc: %.0f%%", i+1, batSoc)
|
|
} else {
|
|
site.log.ERROR.Printf("battery %d soc: %v", i+1, err)
|
|
}
|
|
}
|
|
|
|
if bpl, ok := api.Cap[api.BatteryPowerLimiter](meter); ok && maxDischargePower >= 0 {
|
|
var empty bool
|
|
if bsl, ok := api.Cap[api.BatterySocLimiter](meter); ok {
|
|
minSoc, _ := bsl.GetSocLimits()
|
|
if mm[i].Soc != nil && *mm[i].Soc <= minSoc {
|
|
empty = true
|
|
}
|
|
}
|
|
|
|
if !empty {
|
|
_, discharge := bpl.GetPowerLimits()
|
|
maxDischargePower += discharge
|
|
}
|
|
} else {
|
|
maxDischargePower = -1 // any battery without a limit disables the cap
|
|
}
|
|
|
|
_, controllable := api.Cap[api.BatteryController](meter)
|
|
mm[i].Controllable = new(controllable)
|
|
}
|
|
|
|
// retain the last known soc when all reads failed, so that a transient
|
|
// meter error does not report the pack as empty (0%)
|
|
socFailed := lo.EveryBy(mm, func(m types.Measurement) bool { return m.Soc == nil })
|
|
|
|
// written from the meter goroutine, read via state and GetBatteryMaxDischargePower
|
|
site.Lock()
|
|
|
|
if maxDischargePower >= 0 {
|
|
site.batteryMaxDischargePower = &maxDischargePower
|
|
} else {
|
|
site.batteryMaxDischargePower = nil
|
|
}
|
|
|
|
if !socFailed {
|
|
var batterySocAcc float64
|
|
var totalCapacity float64
|
|
|
|
if lo.SomeBy(mm, func(m types.Measurement) bool { return m.Capacity == nil || *m.Capacity <= 0 }) {
|
|
// any capacity is missing
|
|
batterySocAcc = sumOfSocs(mm)
|
|
totalCapacity = float64(len(site.batteryMeters))
|
|
} else {
|
|
// all capacities available - weigh soc by capacity
|
|
batterySocAcc = weightedSumOfSocs(mm)
|
|
totalCapacity = lo.SumBy(mm, func(m types.Measurement) float64 { return *m.Capacity })
|
|
}
|
|
|
|
site.battery.Soc = math.Min(100, batterySocAcc/totalCapacity)
|
|
site.battery.Capacity = totalCapacity
|
|
}
|
|
|
|
site.battery.Power = lo.SumBy(mm, func(m types.Measurement) float64 {
|
|
return m.Power
|
|
})
|
|
site.battery.Energy = lo.SumBy(mm, func(m types.Measurement) float64 {
|
|
if m.Energy == nil {
|
|
return 0
|
|
}
|
|
return *m.Energy
|
|
})
|
|
site.battery.Devices = mm
|
|
|
|
battery := site.battery
|
|
site.Unlock()
|
|
|
|
if socFailed {
|
|
site.log.WARN.Printf("battery soc: read failed, keeping last %.0f%%", battery.Soc)
|
|
}
|
|
|
|
if len(site.batteryMeters) > 1 {
|
|
site.log.DEBUG.Printf("battery power: %.0fW", battery.Power)
|
|
site.log.DEBUG.Printf("battery soc: %.0f%%", math.Round(battery.Soc))
|
|
}
|
|
|
|
// accumulate per-battery energy (charging = import, discharging = export — from battery POV toward grid root)
|
|
for i, dev := range site.batteryMeters {
|
|
ref := dev.Config().Name
|
|
c, ok := site.collectors[ref]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if err := c.AddEnergy(mm[i].ReturnEnergy, mm[i].Energy, -mm[i].Power); err != nil {
|
|
site.log.ERROR.Printf("persist battery %d energy: %v", i+1, err)
|
|
}
|
|
if mm[i].Soc != nil {
|
|
c.SetSocTemp(*mm[i].Soc, false)
|
|
}
|
|
}
|
|
|
|
site.publishBattery()
|
|
}
|
|
|
|
// publishBattery applies the optimizer suggestions and publishes the battery state
|
|
func (site *Site) publishBattery() {
|
|
mode := site.GetBatteryMode().String()
|
|
|
|
battery := site.state().battery
|
|
for i, d := range battery.Devices {
|
|
battery.Devices[i].Suggestion = site.suggestion(batteryKey(d.Name), mode)
|
|
}
|
|
|
|
site.publish(keys.Battery, battery)
|
|
}
|
|
|
|
func sumOfSocs(mm []types.Measurement) float64 {
|
|
return lo.SumBy(mm, func(m types.Measurement) float64 {
|
|
if m.Soc == nil {
|
|
return 0
|
|
}
|
|
return *m.Soc
|
|
})
|
|
}
|
|
|
|
func weightedSumOfSocs(mm []types.Measurement) float64 {
|
|
return lo.SumBy(mm, func(m types.Measurement) float64 {
|
|
if m.Soc == nil {
|
|
return 0
|
|
}
|
|
// weigh soc by capacity
|
|
return *m.Soc * *m.Capacity
|
|
})
|
|
}
|
|
|
|
// addMeterEnergy persists per-meter energy (positive power = import).
|
|
func (site *Site) addMeterEnergy(meters []config.Device[api.Meter], mm []types.Measurement) {
|
|
for i, dev := range meters {
|
|
ref := dev.Config().Name
|
|
c, ok := site.collectors[ref]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if err := c.AddEnergy(mm[i].Energy, mm[i].ReturnEnergy, mm[i].Power); err != nil {
|
|
site.log.ERROR.Printf("persist meter %s energy: %v", ref, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// updateAuxMeters updates aux meters
|
|
func (site *Site) updateAuxMeters() {
|
|
if len(site.auxMeters) == 0 {
|
|
return
|
|
}
|
|
|
|
mm := site.collectMeters("aux", site.auxMeters)
|
|
auxPower := lo.SumBy(mm, func(m types.Measurement) float64 {
|
|
return m.Power
|
|
})
|
|
|
|
site.Lock()
|
|
site.auxPower = auxPower
|
|
site.Unlock()
|
|
|
|
if len(site.auxMeters) > 1 {
|
|
site.log.DEBUG.Printf("aux power: %.0fW", auxPower)
|
|
}
|
|
|
|
site.addMeterEnergy(site.auxMeters, mm)
|
|
|
|
site.publish(keys.AuxPower, auxPower)
|
|
site.publish(keys.Aux, mm)
|
|
}
|
|
|
|
// updateConsumerMeters updates consumer meters
|
|
func (site *Site) updateConsumerMeters() {
|
|
if len(site.consumerMeters) == 0 {
|
|
return
|
|
}
|
|
|
|
mm := site.collectMeters("consumer", site.consumerMeters)
|
|
|
|
site.addMeterEnergy(site.consumerMeters, mm)
|
|
|
|
site.publish(keys.Consumers, mm)
|
|
}
|
|
|
|
// updateExtMeters updates ext meters
|
|
func (site *Site) updateExtMeters() {
|
|
if len(site.extMeters) == 0 {
|
|
return
|
|
}
|
|
|
|
mm := site.collectMeters("ext", site.extMeters)
|
|
|
|
site.addMeterEnergy(site.extMeters, mm)
|
|
|
|
site.publish(keys.Ext, mm)
|
|
}
|
|
|
|
// updateGridMeter updates grid meter
|
|
func (site *Site) updateGridMeter() error {
|
|
if site.gridMeter == nil {
|
|
return nil
|
|
}
|
|
|
|
mm := types.Measurement{Name: site.gridMeter.Config().Name}
|
|
|
|
meter := site.gridMeter.Instance()
|
|
|
|
if res, err := backoff.RetryWithData(meter.CurrentPower, modbus.Backoff()); err == nil {
|
|
mm.Power = res
|
|
|
|
site.Lock()
|
|
site.gridPower = res
|
|
site.Unlock()
|
|
|
|
site.log.DEBUG.Printf("grid power: %.0fW", res)
|
|
} else if !errors.Is(err, api.ErrNotAvailable) {
|
|
return fmt.Errorf("grid power: %v", err)
|
|
}
|
|
|
|
// grid phase currents (signed)
|
|
if phaseMeter, ok := api.Cap[api.PhaseCurrents](meter); ok {
|
|
// grid phase powers
|
|
var p1, p2, p3 float64
|
|
if phaseMeter, ok := api.Cap[api.PhasePowers](meter); ok {
|
|
var err error // phases needed for signed currents
|
|
if p1, p2, p3, err = phaseMeter.Powers(); err == nil {
|
|
mm.Powers = []float64{p1, p2, p3}
|
|
site.log.DEBUG.Printf("grid powers: %.0fW", mm.Powers)
|
|
} else if !errors.Is(err, api.ErrNotAvailable) {
|
|
site.log.ERROR.Printf("grid powers: %v", err)
|
|
}
|
|
}
|
|
|
|
if i1, i2, i3, err := phaseMeter.Currents(); err == nil {
|
|
mm.Currents = []float64{util.SignFromPower(i1, p1), util.SignFromPower(i2, p2), util.SignFromPower(i3, p3)}
|
|
site.log.DEBUG.Printf("grid currents: %.3gA", mm.Currents)
|
|
} else if !errors.Is(err, api.ErrNotAvailable) {
|
|
site.log.ERROR.Printf("grid currents: %v", err)
|
|
}
|
|
}
|
|
|
|
// grid energy (import); nil when the device has no MeterEnergy capability or the read fails
|
|
// ignore spurious zero readings (NaN-derived or nightly reset, #30950)
|
|
if energyMeter, ok := api.Cap[api.MeterEnergy](meter); ok {
|
|
if f, err := nonZeroEnergy(energyMeter.TotalEnergy()); err == nil {
|
|
mm.Energy = &f
|
|
} else if !errors.Is(err, api.ErrNotAvailable) {
|
|
site.log.ERROR.Printf("grid energy: %v", err)
|
|
}
|
|
}
|
|
|
|
// grid return energy (export); nil when the device has no MeterReturnEnergy capability or the read fails
|
|
// ignore spurious zero readings as above
|
|
if returnEnergyMeter, ok := api.Cap[api.MeterReturnEnergy](meter); ok {
|
|
if f, err := nonZeroEnergy(returnEnergyMeter.ReturnEnergy()); err == nil {
|
|
mm.ReturnEnergy = &f
|
|
} else if !errors.Is(err, api.ErrNotAvailable) {
|
|
site.log.ERROR.Printf("grid return energy: %v", err)
|
|
}
|
|
}
|
|
|
|
if c, ok := site.collectors[site.gridMeter.Config().Name]; ok {
|
|
c.AddEnergy(mm.Energy, mm.ReturnEnergy, mm.Power)
|
|
}
|
|
|
|
site.publish(keys.Grid, mm)
|
|
|
|
return nil
|
|
}
|
|
|
|
// updateMeters reads all meters and returns the updated measurement state
|
|
func (site *Site) updateMeters() (siteState, error) {
|
|
var eg errgroup.Group
|
|
|
|
eg.Go(func() error { site.updatePvMeters(); return nil })
|
|
eg.Go(func() error { site.updateBatteryMeters(); return nil })
|
|
eg.Go(func() error { site.updateAuxMeters(); return nil })
|
|
eg.Go(func() error { site.updateConsumerMeters(); return nil })
|
|
eg.Go(func() error { site.updateExtMeters(); return nil })
|
|
|
|
eg.Go(site.updateGridMeter)
|
|
|
|
if err := eg.Wait(); err != nil {
|
|
return siteState{}, err
|
|
}
|
|
|
|
return site.state(), nil
|
|
}
|
|
|
|
func optimizerEnabled() bool {
|
|
exp, _ := settings.Bool(keys.Experimental)
|
|
opt, _ := settings.Bool(keys.Optimizer)
|
|
return exp && opt
|
|
}
|
|
|
|
// sitePowerResult is the outcome of the site power calculation
|
|
type sitePowerResult struct {
|
|
// measured state, including the estimates for missing meters
|
|
state siteState
|
|
|
|
// net power exported by the site minus a residual margin
|
|
// (negative values mean grid: export, battery: charging)
|
|
power float64
|
|
|
|
// battery buffer can be used for charging
|
|
batteryBuffered bool
|
|
|
|
// charging may start off the battery
|
|
batteryStart bool
|
|
|
|
// adjustment applied for battery priority below prioritySoc; adding it back
|
|
// restores the unadjusted site power for a battery-boosting loadpoint
|
|
priorityAdjustment float64
|
|
}
|
|
|
|
// sitePower calculates the site power balance from the measured state
|
|
func (site *Site) sitePower(state siteState, totalChargePower, flexiblePower float64) sitePowerResult {
|
|
// ensure safe default for residual power
|
|
residualPower := site.GetResidualPower()
|
|
|
|
site.RLock()
|
|
prioritySoc, bufferSoc, bufferStartSoc := site.prioritySoc, site.bufferSoc, site.bufferStartSoc
|
|
site.RUnlock()
|
|
|
|
// allow using PV as estimate for grid power
|
|
if site.gridMeter == nil {
|
|
state.gridPower = totalChargePower - state.pvPower
|
|
site.publish(keys.Grid, types.Measurement{Power: state.gridPower})
|
|
}
|
|
|
|
// sitePower adjustment applied for battery priority
|
|
var priorityAdjustment float64
|
|
|
|
hasBattery := len(site.batteryMeters) > 0
|
|
if hasBattery && state.battery.Soc < prioritySoc && residualPower <= 0 {
|
|
priorityAdjustment += residualPower - 100
|
|
residualPower = 100 // W
|
|
}
|
|
|
|
// allow using grid, charge and battery power as estimate for pv power
|
|
// needs a grid meter, otherwise grid power is itself derived from pv power (see above)
|
|
if site.pvMeters == nil && site.gridMeter != nil {
|
|
state.pvPower = max(0, totalChargePower-state.gridPower-state.battery.Power+residualPower)
|
|
site.log.DEBUG.Printf("pv power: %.0fW", state.pvPower)
|
|
site.publish(keys.PvPower, state.pvPower)
|
|
}
|
|
|
|
// honour battery priority
|
|
batteryPower := state.battery.Power
|
|
excessDCPower := state.excessDCPower
|
|
|
|
// handed to loadpoint
|
|
var batteryBuffered, batteryStart bool
|
|
|
|
if hasBattery {
|
|
// if battery is charging below prioritySoc give it priority
|
|
if state.battery.Soc < prioritySoc && batteryPower < 0 {
|
|
site.log.DEBUG.Printf("battery has priority at soc %.0f%% (< %.0f%%)", state.battery.Soc, prioritySoc)
|
|
priorityAdjustment += batteryPower + excessDCPower
|
|
batteryPower = 0
|
|
excessDCPower = 0
|
|
} else {
|
|
// if battery is above bufferSoc allow using it for charging
|
|
batteryBuffered = bufferSoc > 0 && state.battery.Soc > bufferSoc
|
|
batteryStart = bufferStartSoc > 0 && state.battery.Soc >= bufferStartSoc
|
|
}
|
|
}
|
|
|
|
sitePower := state.gridPower + batteryPower + excessDCPower + residualPower - state.auxPower - flexiblePower
|
|
|
|
// handle priority
|
|
var flexStr string
|
|
if flexiblePower > 0 {
|
|
flexStr = fmt.Sprintf(" (including %.0fW prioritized power)", flexiblePower)
|
|
}
|
|
|
|
site.log.DEBUG.Printf("site power: %.0fW"+flexStr, sitePower)
|
|
|
|
return sitePowerResult{
|
|
state: state,
|
|
power: sitePower,
|
|
batteryBuffered: batteryBuffered,
|
|
batteryStart: batteryStart,
|
|
priorityAdjustment: priorityAdjustment,
|
|
}
|
|
}
|
|
|
|
// updateLoadpoints updates all loadpoints' charge power
|
|
func (site *Site) updateLoadpoints(rates api.Rates) float64 {
|
|
var (
|
|
wg sync.WaitGroup
|
|
mu sync.Mutex
|
|
sum float64
|
|
)
|
|
|
|
for _, lp := range site.activeLoadpoints() {
|
|
wg.Go(func() {
|
|
power := lp.UpdateChargePowerAndCurrents()
|
|
site.prioritizer.UpdateChargePowerFlexibility(lp, rates)
|
|
|
|
mu.Lock()
|
|
sum += power
|
|
mu.Unlock()
|
|
})
|
|
}
|
|
wg.Wait()
|
|
|
|
return sum
|
|
}
|
|
|
|
// reservedPVPower returns the anticipated surplus claimed by higher-priority PV loadpoints
|
|
// that are starting up, so lower-priority loadpoints defer enabling against it (#31194).
|
|
func (site *Site) reservedPVPower(lp updater) float64 {
|
|
if lp.GetMode() != api.ModePV {
|
|
return 0
|
|
}
|
|
|
|
prio := lp.EffectivePriority()
|
|
|
|
var reserved float64
|
|
for _, other := range site.activeLoadpoints() {
|
|
if other == lp {
|
|
continue
|
|
}
|
|
if other.EffectivePriority() > prio && other.PvChargeStarting() {
|
|
reserved += other.EffectiveMaxPower()
|
|
}
|
|
}
|
|
|
|
if reserved > 0 {
|
|
site.log.DEBUG.Printf("lp %s reserves %.0fW for higher-priority loadpoints starting up", lp.GetTitle(), reserved)
|
|
}
|
|
|
|
return reserved
|
|
}
|
|
|
|
func (site *Site) update(lp updater) {
|
|
site.log.DEBUG.Println("----")
|
|
|
|
// smart cost and battery mode handling
|
|
consumption, err := site.tariffRates(api.TariffUsagePlanner)
|
|
if err != nil {
|
|
site.log.WARN.Println("planner:", err)
|
|
}
|
|
|
|
feedin, err := site.tariffRates(api.TariffUsageFeedIn)
|
|
if err != nil {
|
|
site.log.WARN.Println("feed-in:", err)
|
|
}
|
|
|
|
// update loadpoints
|
|
totalChargePower := site.updateLoadpoints(consumption)
|
|
|
|
site.updateCircuits()
|
|
site.applyHemsLimits()
|
|
|
|
if state, err := site.updateMeters(); err != nil {
|
|
site.log.ERROR.Println(err)
|
|
} else {
|
|
go site.optimizerUpdateAsync(tariff.SlotDuration)
|
|
|
|
site.updatePower(lp, state, totalChargePower, consumption, feedin)
|
|
}
|
|
|
|
// smart grid charging
|
|
rate := site.currentRate(consumption)
|
|
|
|
// update battery after reading meters to ensure that (modbus) connection is open
|
|
batteryGridChargeActive := site.batteryGridChargeActive(rate)
|
|
site.publish(keys.BatteryGridChargeActive, batteryGridChargeActive)
|
|
site.updateBatteryMode(batteryGridChargeActive, rate)
|
|
|
|
// re-evaluate against the updated loadpoint state
|
|
site.publishSuggestions()
|
|
|
|
site.stats.Update(site)
|
|
}
|
|
|
|
// updatePower calculates the site power balance and updates the given loadpoint
|
|
func (site *Site) updatePower(lp updater, state siteState, totalChargePower float64, consumption, feedin api.Rates) {
|
|
// prioritize if possible
|
|
var flexiblePower float64
|
|
if lp != nil && lp.GetMode() == api.ModePV {
|
|
flexiblePower = site.prioritizer.GetChargePowerFlexibility(lp)
|
|
}
|
|
|
|
res := site.sitePower(state, totalChargePower, flexiblePower)
|
|
state = res.state
|
|
|
|
// retain the estimates for the api getters and the green share
|
|
if site.gridMeter == nil || site.pvMeters == nil {
|
|
site.Lock()
|
|
site.gridPower, site.pvPower = state.gridPower, state.pvPower
|
|
site.Unlock()
|
|
}
|
|
|
|
// ignore negative pvPower values as that means it is not an energy source but consumption
|
|
homePower := state.gridPower + max(0, state.pvPower) + state.battery.Power - totalChargePower
|
|
homePower = max(homePower, 0)
|
|
site.publish(keys.HomePower, homePower)
|
|
|
|
if homePower > 0 {
|
|
if err := site.collectors[metrics.Home].AddEnergy(nil, nil, homePower); err != nil {
|
|
site.log.ERROR.Printf("persist home consumption: %v", err)
|
|
}
|
|
}
|
|
|
|
// add battery charging power to homePower to ignore all consumption which does not occur on loadpoints
|
|
// fix for: https://github.com/evcc-io/evcc/issues/11032
|
|
nonChargePower := homePower + max(0, -state.battery.Power)
|
|
greenShareHome := site.greenShare(0, homePower)
|
|
greenShareLoadpoints := site.greenShare(nonChargePower, nonChargePower+totalChargePower)
|
|
|
|
// TODO
|
|
if lp != nil {
|
|
sitePower := res.power
|
|
|
|
// reserve surplus claimed by higher-priority loadpoints that are starting up (#31194)
|
|
sitePower += site.reservedPVPower(lp)
|
|
|
|
// battery boost deliberately drains the battery, hence battery priority
|
|
// below prioritySoc does not apply to the boosting loadpoint (#30541)
|
|
if lp.GetBatteryBoost() != boostDisabled {
|
|
sitePower += res.priorityAdjustment
|
|
}
|
|
|
|
lp.Update(
|
|
sitePower, state.battery.Power, consumption, feedin, res.batteryBuffered, res.batteryStart,
|
|
greenShareLoadpoints, site.effectivePrice(greenShareLoadpoints), site.effectiveCo2(greenShareLoadpoints),
|
|
hems.Dimmed(site.hems),
|
|
)
|
|
}
|
|
|
|
site.publishTariffs(greenShareHome, greenShareLoadpoints)
|
|
|
|
if telemetry.Enabled() && totalChargePower > standbyPower {
|
|
go telemetry.UpdateChargeProgress(site.log, totalChargePower, greenShareLoadpoints)
|
|
}
|
|
}
|
|
|
|
// currentRate returns the rate for the current time, warning if the rates don't cover it
|
|
func (site *Site) currentRate(rates api.Rates) api.Rate {
|
|
rate, err := rates.At(time.Now())
|
|
if rates == nil || err == nil {
|
|
return rate
|
|
}
|
|
|
|
msg := fmt.Sprintf("no matching rate for: %s", time.Now().Format(time.RFC3339))
|
|
if len(rates) > 0 {
|
|
msg += fmt.Sprintf(", %d consumption rates (%s to %s)", len(rates),
|
|
rates[0].Start.Local().Format(time.RFC3339),
|
|
rates[len(rates)-1].End.Local().Format(time.RFC3339),
|
|
)
|
|
}
|
|
|
|
site.log.WARN.Println("planner:", msg)
|
|
|
|
return rate
|
|
}
|
|
|
|
// prepare publishes initial values
|
|
func (site *Site) prepare() {
|
|
if err := site.restoreSettings(); err != nil {
|
|
site.log.ERROR.Println(err)
|
|
}
|
|
|
|
site.publish(keys.SiteTitle, site.Title)
|
|
|
|
site.publish(keys.GridConfigured, site.gridMeter != nil)
|
|
site.publish(keys.Grid, api.Meter(nil))
|
|
site.publish(keys.Pv, []api.Meter{})
|
|
site.publish(keys.Aux, []api.Meter{})
|
|
site.publish(keys.Ext, []api.Meter{})
|
|
site.publish(keys.Battery, nil)
|
|
site.publish(keys.PrioritySoc, site.prioritySoc)
|
|
site.publish(keys.BufferSoc, site.bufferSoc)
|
|
site.publish(keys.BufferStartSoc, site.bufferStartSoc)
|
|
site.publish(keys.BatteryMode, site.batteryMode)
|
|
site.publish(keys.BatteryDischargeControl, site.batteryDischargeControl)
|
|
site.publish(keys.BatteryGridDischarge, site.batteryGridDischarge)
|
|
site.publish(keys.SolarAdjusted, site.solarAdjusted)
|
|
site.publish(keys.ResidualPower, site.GetResidualPower())
|
|
site.publish(keys.GridExportLimit, site.GetGridExportLimit())
|
|
site.publish(keys.SmartCostAvailable, site.isDynamicTariff(api.TariffUsagePlanner))
|
|
site.publish(keys.SmartFeedInPriorityAvailable, site.isDynamicTariff(api.TariffUsageFeedIn))
|
|
|
|
site.publish(keys.Currency, site.tariffs.Currency)
|
|
if tariff := site.GetTariff(api.TariffUsagePlanner); tariff != nil {
|
|
site.publish(keys.SmartCostType, tariff.Type())
|
|
} else {
|
|
site.publish(keys.SmartCostType, nil)
|
|
}
|
|
|
|
site.publishVehicles()
|
|
site.publishTariffs(0, 0)
|
|
vehicle.Publish = site.publishVehicles
|
|
vehicle.ClearPlanLocks = site.clearPlanLocks
|
|
}
|
|
|
|
// pushEvent queues the event in the value stream. The cache attaches its state
|
|
// when the event reaches its position, so the message renders exactly the
|
|
// values published before the event was raised.
|
|
func (site *Site) pushEvent(ev messenger.Event) {
|
|
pushChan := site.pushChan
|
|
if pushChan == nil {
|
|
return
|
|
}
|
|
|
|
site.valueChan <- util.Param{Val: util.Snapshot(func(state []util.Param) {
|
|
ev.State = state
|
|
pushChan <- ev
|
|
})}
|
|
}
|
|
|
|
// Prepare attaches communication channels to site and loadpoints
|
|
func (site *Site) Prepare(valueChan chan<- util.Param, pushChan chan<- messenger.Event) {
|
|
site.pushChan = pushChan
|
|
// https://github.com/evcc-io/evcc/issues/11191 prevent deadlock
|
|
// https://github.com/evcc-io/evcc/pull/11675 maintain message order
|
|
|
|
// infinite queue with channel semantics
|
|
ch := chanx.NewUnboundedChan[util.Param](context.Background(), 2)
|
|
|
|
// use ch.In for writing
|
|
site.valueChan = ch.In
|
|
|
|
// use ch.Out for reading
|
|
go func() {
|
|
for p := range ch.Out {
|
|
valueChan <- p
|
|
}
|
|
}()
|
|
|
|
site.lpUpdateChan = make(chan *Loadpoint, 1) // 1 capacity to avoid deadlock
|
|
|
|
site.prepare()
|
|
|
|
lpDevices := config.Loadpoints().Devices()
|
|
|
|
for id, lp := range site.loadpoints {
|
|
// publish name on the loadpoint's behalf — it doesn't know its own
|
|
if id < len(lpDevices) {
|
|
site.valueChan <- util.Param{Loadpoint: &id, Key: keys.Name, Val: lpDevices[id].Config().Name}
|
|
}
|
|
|
|
// disabled loadpoint- publish minimal placeholder to keep indexes stable
|
|
if lp == nil {
|
|
if id < len(lpDevices) {
|
|
title, _ := lpDevices[id].Config().Other["title"].(string)
|
|
site.valueChan <- util.Param{Loadpoint: &id, Key: keys.Title, Val: title}
|
|
}
|
|
site.valueChan <- util.Param{Loadpoint: &id, Key: keys.Disabled, Val: true}
|
|
continue
|
|
}
|
|
|
|
lpUIChan := make(chan util.Param)
|
|
lpPushChan := make(chan messenger.Event)
|
|
|
|
// pipe messages through go func to add id
|
|
go func(id int) {
|
|
for {
|
|
select {
|
|
case param := <-lpUIChan:
|
|
param.Loadpoint = &id
|
|
site.valueChan <- param
|
|
case ev := <-lpPushChan:
|
|
ev.Loadpoint = &id
|
|
site.pushEvent(ev)
|
|
}
|
|
}
|
|
}(id)
|
|
|
|
lp.Prepare(site, lpUIChan, lpPushChan, site.lpUpdateChan)
|
|
}
|
|
}
|
|
|
|
// loopLoadpoints keeps iterating across loadpoints sending the next to the given channel
|
|
func (site *Site) loopLoadpoints(next chan<- updater) {
|
|
var logOnce sync.Once
|
|
active := site.activeLoadpoints()
|
|
|
|
for {
|
|
if len(active) == 0 {
|
|
logOnce.Do(func() {
|
|
site.log.INFO.Println("no loadpoints configured, running in meter-only mode")
|
|
})
|
|
next <- nil
|
|
} else {
|
|
for _, lp := range active {
|
|
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) {
|
|
if max := 30 * time.Second; interval < max {
|
|
site.log.INFO.Printf("interval <%.0fs can lead to unexpected behavior, see https://docs.evcc.io/docs/reference/configuration/interval", max.Seconds())
|
|
}
|
|
|
|
loadpointChan := make(chan updater)
|
|
if site.IsConfigured() {
|
|
go site.loopLoadpoints(loadpointChan)
|
|
}
|
|
|
|
site.update(<-loadpointChan) // start immediately
|
|
|
|
for tick := time.Tick(interval); ; {
|
|
select {
|
|
case <-tick:
|
|
site.update(<-loadpointChan)
|
|
case lp := <-site.lpUpdateChan:
|
|
site.update(lp)
|
|
case <-stopC:
|
|
return
|
|
}
|
|
}
|
|
}
|