Load Management and Peak Shaving (#13207)

This commit is contained in:
andig 2024-04-27 10:50:35 +02:00 • committed by GitHub
parent 652cab3192
commit 26973a0d2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1339 additions and 49 deletions

View file

@ -7,7 +7,7 @@ import (
"time"
)
//go:generate mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,PhaseSwitcher,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery,Tariff,BatteryController
//go:generate mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,PhaseSwitcher,Identifier,Meter,MeterEnergy,PhaseCurrents,Vehicle,ChargeRater,Battery,Tariff,BatteryController,Circuit
// Meter provides total active power in W
type Meter interface {
@ -202,3 +202,32 @@ type FeatureDescriber interface {
type CsvWriter interface {
WriteCsv(context.Context, io.Writer) error
}
// CircuitMeasurements is the measurements a circuit or load must deliver
type CircuitMeasurements interface {
GetChargePower() float64
GetMaxPhaseCurrent() float64
}
// CircuitLoad represents a loadpoint attached to a circuit
type CircuitLoad interface {
CircuitMeasurements
GetCircuit() Circuit
}
// Circuit defines the load control domain
type Circuit interface {
CircuitMeasurements
GetTitle() string
SetTitle(string)
GetParent() Circuit
RegisterChild(child Circuit)
HasMeter() bool
GetMaxPower() float64
GetMaxCurrent() float64
SetMaxPower(float64)
SetMaxCurrent(float64)
Update([]CircuitLoad) error
ValidateCurrent(old, new float64) float64
ValidatePower(old, new float64) float64
}

View file

@ -1,9 +1,9 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,CurrentLimiter,PhaseSwitcher,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery,Tariff,BatteryController)
// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,CurrentLimiter,PhaseSwitcher,Identifier,Meter,MeterEnergy,PhaseCurrents,Vehicle,ChargeRater,Battery,Tariff,BatteryController,Circuit)
//
// Generated by this command:
//
// mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,PhaseSwitcher,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery,Tariff,BatteryController
// mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,PhaseSwitcher,Identifier,Meter,MeterEnergy,PhaseCurrents,Vehicle,ChargeRater,Battery,Tariff,BatteryController,Circuit
//
// Package api is a generated GoMock package.
@ -324,6 +324,46 @@ func (mr *MockMeterEnergyMockRecorder) TotalEnergy() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TotalEnergy", reflect.TypeOf((*MockMeterEnergy)(nil).TotalEnergy))
}
// MockPhaseCurrents is a mock of PhaseCurrents interface.
type MockPhaseCurrents struct {
ctrl *gomock.Controller
recorder *MockPhaseCurrentsMockRecorder
}
// MockPhaseCurrentsMockRecorder is the mock recorder for MockPhaseCurrents.
type MockPhaseCurrentsMockRecorder struct {
mock *MockPhaseCurrents
}
// NewMockPhaseCurrents creates a new mock instance.
func NewMockPhaseCurrents(ctrl *gomock.Controller) *MockPhaseCurrents {
mock := &MockPhaseCurrents{ctrl: ctrl}
mock.recorder = &MockPhaseCurrentsMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockPhaseCurrents) EXPECT() *MockPhaseCurrentsMockRecorder {
return m.recorder
}
// Currents mocks base method.
func (m *MockPhaseCurrents) Currents() (float64, float64, float64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Currents")
ret0, _ := ret[0].(float64)
ret1, _ := ret[1].(float64)
ret2, _ := ret[2].(float64)
ret3, _ := ret[3].(error)
return ret0, ret1, ret2, ret3
}
// Currents indicates an expected call of Currents.
func (mr *MockPhaseCurrentsMockRecorder) Currents() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Currents", reflect.TypeOf((*MockPhaseCurrents)(nil).Currents))
}
// MockVehicle is a mock of Vehicle interface.
type MockVehicle struct {
ctrl *gomock.Controller
@ -636,3 +676,214 @@ func (mr *MockBatteryControllerMockRecorder) SetBatteryMode(arg0 any) *gomock.Ca
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetBatteryMode", reflect.TypeOf((*MockBatteryController)(nil).SetBatteryMode), arg0)
}
// MockCircuit is a mock of Circuit interface.
type MockCircuit struct {
ctrl *gomock.Controller
recorder *MockCircuitMockRecorder
}
// MockCircuitMockRecorder is the mock recorder for MockCircuit.
type MockCircuitMockRecorder struct {
mock *MockCircuit
}
// NewMockCircuit creates a new mock instance.
func NewMockCircuit(ctrl *gomock.Controller) *MockCircuit {
mock := &MockCircuit{ctrl: ctrl}
mock.recorder = &MockCircuitMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockCircuit) EXPECT() *MockCircuitMockRecorder {
return m.recorder
}
// GetChargePower mocks base method.
func (m *MockCircuit) GetChargePower() float64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChargePower")
ret0, _ := ret[0].(float64)
return ret0
}
// GetChargePower indicates an expected call of GetChargePower.
func (mr *MockCircuitMockRecorder) GetChargePower() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChargePower", reflect.TypeOf((*MockCircuit)(nil).GetChargePower))
}
// GetMaxCurrent mocks base method.
func (m *MockCircuit) GetMaxCurrent() float64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetMaxCurrent")
ret0, _ := ret[0].(float64)
return ret0
}
// GetMaxCurrent indicates an expected call of GetMaxCurrent.
func (mr *MockCircuitMockRecorder) GetMaxCurrent() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaxCurrent", reflect.TypeOf((*MockCircuit)(nil).GetMaxCurrent))
}
// GetMaxPhaseCurrent mocks base method.
func (m *MockCircuit) GetMaxPhaseCurrent() float64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetMaxPhaseCurrent")
ret0, _ := ret[0].(float64)
return ret0
}
// GetMaxPhaseCurrent indicates an expected call of GetMaxPhaseCurrent.
func (mr *MockCircuitMockRecorder) GetMaxPhaseCurrent() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaxPhaseCurrent", reflect.TypeOf((*MockCircuit)(nil).GetMaxPhaseCurrent))
}
// GetMaxPower mocks base method.
func (m *MockCircuit) GetMaxPower() float64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetMaxPower")
ret0, _ := ret[0].(float64)
return ret0
}
// GetMaxPower indicates an expected call of GetMaxPower.
func (mr *MockCircuitMockRecorder) GetMaxPower() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaxPower", reflect.TypeOf((*MockCircuit)(nil).GetMaxPower))
}
// GetParent mocks base method.
func (m *MockCircuit) GetParent() Circuit {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetParent")
ret0, _ := ret[0].(Circuit)
return ret0
}
// GetParent indicates an expected call of GetParent.
func (mr *MockCircuitMockRecorder) GetParent() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetParent", reflect.TypeOf((*MockCircuit)(nil).GetParent))
}
// GetTitle mocks base method.
func (m *MockCircuit) GetTitle() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTitle")
ret0, _ := ret[0].(string)
return ret0
}
// GetTitle indicates an expected call of GetTitle.
func (mr *MockCircuitMockRecorder) GetTitle() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTitle", reflect.TypeOf((*MockCircuit)(nil).GetTitle))
}
// HasMeter mocks base method.
func (m *MockCircuit) HasMeter() bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HasMeter")
ret0, _ := ret[0].(bool)
return ret0
}
// HasMeter indicates an expected call of HasMeter.
func (mr *MockCircuitMockRecorder) HasMeter() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasMeter", reflect.TypeOf((*MockCircuit)(nil).HasMeter))
}
// RegisterChild mocks base method.
func (m *MockCircuit) RegisterChild(arg0 Circuit) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "RegisterChild", arg0)
}
// RegisterChild indicates an expected call of RegisterChild.
func (mr *MockCircuitMockRecorder) RegisterChild(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterChild", reflect.TypeOf((*MockCircuit)(nil).RegisterChild), arg0)
}
// SetMaxCurrent mocks base method.
func (m *MockCircuit) SetMaxCurrent(arg0 float64) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "SetMaxCurrent", arg0)
}
// SetMaxCurrent indicates an expected call of SetMaxCurrent.
func (mr *MockCircuitMockRecorder) SetMaxCurrent(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetMaxCurrent", reflect.TypeOf((*MockCircuit)(nil).SetMaxCurrent), arg0)
}
// SetMaxPower mocks base method.
func (m *MockCircuit) SetMaxPower(arg0 float64) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "SetMaxPower", arg0)
}
// SetMaxPower indicates an expected call of SetMaxPower.
func (mr *MockCircuitMockRecorder) SetMaxPower(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetMaxPower", reflect.TypeOf((*MockCircuit)(nil).SetMaxPower), arg0)
}
// SetTitle mocks base method.
func (m *MockCircuit) SetTitle(arg0 string) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "SetTitle", arg0)
}
// SetTitle indicates an expected call of SetTitle.
func (mr *MockCircuitMockRecorder) SetTitle(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTitle", reflect.TypeOf((*MockCircuit)(nil).SetTitle), arg0)
}
// Update mocks base method.
func (m *MockCircuit) Update(arg0 []CircuitLoad) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Update", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// Update indicates an expected call of Update.
func (mr *MockCircuitMockRecorder) Update(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockCircuit)(nil).Update), arg0)
}
// ValidateCurrent mocks base method.
func (m *MockCircuit) ValidateCurrent(arg0, arg1 float64) float64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ValidateCurrent", arg0, arg1)
ret0, _ := ret[0].(float64)
return ret0
}
// ValidateCurrent indicates an expected call of ValidateCurrent.
func (mr *MockCircuitMockRecorder) ValidateCurrent(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateCurrent", reflect.TypeOf((*MockCircuit)(nil).ValidateCurrent), arg0, arg1)
}
// ValidatePower mocks base method.
func (m *MockCircuit) ValidatePower(arg0, arg1 float64) float64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ValidatePower", arg0, arg1)
ret0, _ := ret[0].(float64)
return ret0
}
// ValidatePower indicates an expected call of ValidatePower.
func (mr *MockCircuitMockRecorder) ValidatePower(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidatePower", reflect.TypeOf((*MockCircuit)(nil).ValidatePower), arg0, arg1)
}

View file

@ -45,6 +45,7 @@ import (
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"github.com/libp2p/zeroconf/v2"
"github.com/spf13/cast"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/sync/errgroup"
@ -95,6 +96,7 @@ type globalConfig struct {
Tariffs tariffConfig
Site map[string]interface{}
Loadpoints []map[string]interface{}
Circuits []config.Named
}
type mqttConfig struct {
@ -183,6 +185,115 @@ func loadConfigFile(conf *globalConfig) error {
return err
}
func configureCircuits(static []config.Named, names ...string) error {
children := slices.Clone(static)
// TODO: check for circular references
NEXT:
for i, cc := range children {
if cc.Name == "" {
return fmt.Errorf("cannot create circuit: missing name")
}
if err := nameValid(cc.Name); err != nil {
return fmt.Errorf("cannot create circuit: duplicate name: %s", cc.Name)
}
if parent := cast.ToString(cc.Property("parent")); parent != "" {
if _, err := config.Circuits().ByName(parent); err != nil {
continue
}
}
log := util.NewLogger("circuit-" + cc.Name)
instance, err := core.NewCircuitFromConfig(log, cc.Other)
if err != nil {
return fmt.Errorf("cannot create circuit '%s': %w", cc.Name, err)
}
// ensure config has title
if instance.GetTitle() == "" {
//lint:ignore SA1019 as Title is safe on ascii
instance.SetTitle(strings.Title(cc.Name))
}
if err := config.Circuits().Add(config.NewStaticDevice(cc, instance)); err != nil {
return err
}
children = slices.Delete(children, i, i+1)
goto NEXT
}
if len(children) > 0 {
return fmt.Errorf("circuit is missing parent: %s", children[0].Name)
}
// append devices from database
configurable, err := config.ConfigurationsByClass(templates.Circuit)
if err != nil {
return err
}
children2 := slices.Clone(configurable)
NEXT2:
for i, conf := range children2 {
cc := conf.Named()
if len(names) > 0 && !slices.Contains(names, cc.Name) {
return nil
}
if parent := cast.ToString(cc.Property("parent")); parent != "" {
if _, err := config.Circuits().ByName(parent); err != nil {
continue
}
}
log := util.NewLogger("circuit-" + cc.Name)
instance, err := core.NewCircuitFromConfig(log, cc.Other)
if err != nil {
return fmt.Errorf("cannot create circuit '%s': %w", cc.Name, err)
}
// ensure config has title
if instance.GetTitle() == "" {
//lint:ignore SA1019 as Title is safe on ascii
instance.SetTitle(strings.Title(cc.Name))
}
if err := config.Circuits().Add(config.NewConfigurableDevice(conf, instance)); err != nil {
return err
}
children2 = slices.Delete(children2, i, i+1)
goto NEXT2
}
if len(children2) > 0 {
return fmt.Errorf("missing parent circuit: %s", children2[0].Named().Name)
}
var rootFound bool
for _, dev := range config.Circuits().Devices() {
c := dev.Instance()
if c.GetParent() == nil {
if rootFound {
return errors.New("cannot have multiple root circuits")
}
rootFound = true
}
}
if !rootFound && len(config.Circuits().Devices()) > 0 {
return errors.New("root circuit required")
}
return nil
}
func configureMeters(static []config.Named, names ...string) error {
for i, cc := range static {
if cc.Name == "" {
@ -647,6 +758,9 @@ func configureDevices(conf globalConfig) error {
if err := configureChargers(conf.Chargers); err != nil {
return err
}
if err := configureCircuits(conf.Circuits); err != nil {
return err
}
return configureVehicles(conf.Vehicles)
}
@ -665,7 +779,43 @@ func configureSiteAndLoadpoints(conf globalConfig) (*core.Site, error) {
return nil, err
}
return configureSite(conf.Site, loadpoints, tariffs)
site, err := configureSite(conf.Site, loadpoints, tariffs)
if err != nil {
return nil, err
}
if len(config.Circuits().Devices()) > 0 {
if err := validateCircuits(site, loadpoints); err != nil {
return nil, err
}
}
return site, nil
}
func validateCircuits(site site.API, loadpoints []*core.Loadpoint) error {
CONTINUE:
for _, dev := range config.Circuits().Devices() {
instance := dev.Instance()
if instance.HasMeter() || site.GetCircuit() == instance {
continue
}
for _, lp := range loadpoints {
if lp.GetCircuit() == instance {
continue CONTINUE
}
}
return fmt.Errorf("circuit %s has no meter or loadpoint assigned", dev.Config().Name)
}
if site.GetCircuit() == nil {
return errors.New("site has no circuit")
}
return nil
}
func configureSite(conf map[string]interface{}, loadpoints []*core.Loadpoint, tariffs *tariff.Tariffs) (*core.Site, error) {
@ -674,19 +824,25 @@ func configureSite(conf map[string]interface{}, loadpoints []*core.Loadpoint, ta
return nil, fmt.Errorf("failed configuring site: %w", err)
}
if len(config.Circuits().Devices()) > 0 && site.GetCircuit() == nil {
return nil, errors.New("site has no circuit")
}
return site, nil
}
func configureLoadpoints(conf globalConfig) (loadpoints []*core.Loadpoint, err error) {
func configureLoadpoints(conf globalConfig) ([]*core.Loadpoint, error) {
if len(conf.Loadpoints) == 0 {
return nil, errors.New("missing loadpoints")
}
for id, lpc := range conf.Loadpoints {
var loadpoints []*core.Loadpoint
for id, cfg := range conf.Loadpoints {
log := util.NewLoggerWithLoadpoint("lp-"+strconv.Itoa(id+1), id+1)
settings := &core.Settings{Key: "lp" + strconv.Itoa(id+1) + "."}
lp, err := core.NewLoadpointFromConfig(log, settings, lpc)
lp, err := core.NewLoadpointFromConfig(log, settings, cfg)
if err != nil {
return nil, fmt.Errorf("failed configuring loadpoint: %w", err)
}

140
cmd/setup_circuits_test.go Normal file
View file

@ -0,0 +1,140 @@
package cmd
import (
"strings"
"testing"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/meter"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util/config"
"github.com/spf13/viper"
"github.com/stretchr/testify/suite"
"go.uber.org/mock/gomock"
)
func TestSetupCircuits(t *testing.T) {
suite.Run(t, new(circuitsTestSuite))
}
type circuitsTestSuite struct {
suite.Suite
}
func (suite *circuitsTestSuite) SetupSuite() {
db, err := db.New("sqlite", ":memory:")
if err != nil {
suite.T().Fatal(err)
}
config.Init(db)
}
func (suite *circuitsTestSuite) SetupTest() {
config.Reset()
}
func (suite *circuitsTestSuite) TestCircuitConf() {
var conf globalConfig
viper.SetConfigType("yaml")
suite.Require().NoError(viper.ReadConfig(strings.NewReader(`circuits:
- name: master
maxPower: 10000
- name: slave
parent: master
maxPower: 10000
loadpoints:
- charger: test
circuit: slave
`)))
suite.Require().NoError(viper.UnmarshalExact(&conf))
suite.Require().NoError(configureCircuits(conf.Circuits))
suite.Require().Len(config.Circuits().Devices(), 2)
suite.Require().False(config.Circuits().Devices()[0].Instance().HasMeter())
// empty charger
suite.Require().NoError(config.Chargers().Add(config.NewStaticDevice(config.Named{
Name: "test",
}, api.Charger(nil))))
lps, err := configureLoadpoints(conf)
suite.Require().NoError(err)
suite.Require().Len(lps, 1)
suite.Require().NotNil(lps[0].GetCircuit())
}
func (suite *circuitsTestSuite) TestLoadpointMissingCircuitError() {
var conf globalConfig
viper.SetConfigType("yaml")
suite.Require().NoError(viper.ReadConfig(strings.NewReader(`
loadpoints:
- charger: test
`)))
suite.Require().NoError(viper.UnmarshalExact(&conf))
ctrl := gomock.NewController(suite.T())
circuit := api.NewMockCircuit(ctrl)
// mock circuit
suite.Require().NoError(config.Circuits().Add(config.NewStaticDevice(config.Named{
Name: "test",
}, api.Circuit(circuit))))
// mock charger
suite.Require().NoError(config.Chargers().Add(config.NewStaticDevice(config.Named{
Name: "test",
}, api.Charger(nil))))
lps, err := configureLoadpoints(conf)
suite.Require().NoError(err)
site := core.NewSite()
circuit.EXPECT().HasMeter().Return(false)
suite.Require().Error(validateCircuits(site, lps))
}
func (suite *circuitsTestSuite) TestSiteMissingCircuitError() {
var conf globalConfig
viper.SetConfigType("yaml")
suite.Require().NoError(viper.ReadConfig(strings.NewReader(`
loadpoints:
- charger: test
site:
meters:
grid: grid
`)))
suite.Require().NoError(viper.UnmarshalExact(&conf))
lps := []*core.Loadpoint{
new(core.Loadpoint),
}
// mock circuit
suite.Require().NoError(config.Circuits().Add(config.NewStaticDevice(config.Named{
Name: "test",
}, api.Circuit(nil))))
// mock meter
m, _ := meter.NewConfigurable(func() (float64, error) {
return 0, nil
})
suite.Require().NoError(config.Meters().Add(config.NewStaticDevice(config.Named{
Name: "grid",
}, api.Meter(m))))
// mock charger
suite.Require().NoError(config.Chargers().Add(config.NewStaticDevice(config.Named{
Name: "test",
}, api.Charger(nil))))
_, err := configureSite(conf.Site, lps, new(tariff.Tariffs))
suite.Require().Error(err)
}

View file

@ -10,15 +10,12 @@ import (
"github.com/spf13/viper"
)
const sample = `
loadpoints:
- mode: off
`
func TestYamlOff(t *testing.T) {
var conf globalConfig
viper.SetConfigType("yaml")
if err := viper.ReadConfig(strings.NewReader(sample)); err != nil {
if err := viper.ReadConfig(strings.NewReader(`loadpoints:
- mode: off
`)); err != nil {
t.Error(err)
}

308
core/circuit.go Normal file
View file

@ -0,0 +1,308 @@
package core
import (
"fmt"
"sync"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
)
var _ api.Circuit = (*Circuit)(nil)
// the circuit instances to control the load
type Circuit struct {
mu sync.RWMutex
log *util.Logger
title string
parent api.Circuit // parent circuit
children []api.Circuit // child circuits
meter api.Meter // meter to determine current power
maxCurrent float64 // max allowed current
maxPower float64 // max allowed power
current float64
power float64
}
// NewCircuitFromConfig creates a new Circuit
func NewCircuitFromConfig(log *util.Logger, other map[string]interface{}) (api.Circuit, error) {
var cc struct {
Title string `mapstructure:"title"` // title
ParentRef string `mapstructure:"parent"` // parent circuit reference
MeterRef string `mapstructure:"meter"` // meter reference
MaxCurrent float64 `mapstructure:"maxCurrent"` // the max allowed current
MaxPower float64 `mapstructure:"maxPower"` // the max allowed power
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
var meter api.Meter
if cc.MeterRef != "" {
dev, err := config.Meters().ByName(cc.MeterRef)
if err != nil {
return nil, err
}
meter = dev.Instance()
}
circuit, err := NewCircuit(log, cc.Title, cc.MaxCurrent, cc.MaxPower, meter)
if err != nil {
return nil, err
}
if cc.ParentRef != "" {
dev, err := config.Circuits().ByName(cc.ParentRef)
if err != nil {
return nil, err
}
circuit.SetParent(dev.Instance())
}
return circuit, err
}
// NewCircuit creates a circuit
func NewCircuit(log *util.Logger, title string, maxCurrent, maxPower float64, meter api.Meter) (*Circuit, error) {
c := &Circuit{
log: log,
title: title,
maxCurrent: maxCurrent,
maxPower: maxPower,
meter: meter,
}
if maxPower == 0 {
c.log.DEBUG.Printf("validation of max power disabled")
}
if maxCurrent == 0 {
c.log.DEBUG.Printf("validation of max phase current disabled")
} else if _, ok := meter.(api.PhaseCurrents); meter != nil && !ok {
return nil, fmt.Errorf("meter does not support phase currents")
}
return c, nil
}
func (c *Circuit) GetTitle() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.title
}
func (c *Circuit) SetTitle(title string) {
c.mu.Lock()
defer c.mu.Unlock()
c.title = title
}
// GetParent returns the parent circuit
func (c *Circuit) GetParent() api.Circuit {
c.mu.RLock()
defer c.mu.RUnlock()
return c.parent
}
// SetParent set parent circuit
func (c *Circuit) SetParent(parent api.Circuit) {
c.mu.Lock()
defer c.mu.Unlock()
c.parent = parent
if parent != nil {
parent.RegisterChild(c)
}
}
// HasMeter returns the max power setting
func (c *Circuit) HasMeter() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.meter != nil
}
// GetMaxPower returns the max power setting
func (c *Circuit) GetMaxPower() float64 {
c.mu.RLock()
defer c.mu.RUnlock()
return c.maxPower
}
// SetMaxPower sets the max power
func (c *Circuit) SetMaxPower(power float64) {
c.mu.Lock()
defer c.mu.Unlock()
c.maxPower = power
}
// GetMaxCurrent returns the max current setting
func (c *Circuit) GetMaxCurrent() float64 {
c.mu.RLock()
defer c.mu.RUnlock()
return c.maxCurrent
}
// SetMaxCurrent sets the max current
func (c *Circuit) SetMaxCurrent(current float64) {
c.mu.Lock()
defer c.mu.Unlock()
c.maxCurrent = current
}
// RegisterChild registers child circuit
func (c *Circuit) RegisterChild(child api.Circuit) {
c.children = append(c.children, child)
}
func (c *Circuit) updateLoadpoints(loadpoints []api.CircuitLoad) {
c.power = 0
c.current = 0
for _, lp := range loadpoints {
if lp.GetCircuit() != c {
continue
}
c.power += lp.GetChargePower()
c.current += lp.GetMaxPhaseCurrent()
}
}
func (c *Circuit) updateMeters() error {
if f, err := c.meter.CurrentPower(); err == nil {
// TODO handle negative powers
c.power = f
} else {
return fmt.Errorf("circuit power: %w", err)
}
if phaseMeter, ok := c.meter.(api.PhaseCurrents); ok {
if l1, l2, l3, err := phaseMeter.Currents(); err == nil {
// TODO handle negative currents
c.current = max(l1, l2, l3)
} else {
return fmt.Errorf("circuit currents: %w", err)
}
}
return nil
}
func (c *Circuit) Update(loadpoints []api.CircuitLoad) (err error) {
defer func() {
if c.maxPower != 0 && c.power > c.maxPower {
c.log.WARN.Printf("over power detected: %gW > %gW", c.power, c.maxPower)
} else {
c.log.DEBUG.Printf("power: %gW", c.power)
}
if c.maxCurrent != 0 && c.current > c.maxCurrent {
c.log.WARN.Printf("over current detected: %gA > %gA", c.current, c.maxCurrent)
} else {
c.log.DEBUG.Printf("current: %gA", c.current)
}
}()
// update children depth-first
for _, ch := range c.children {
if err := ch.Update(loadpoints); err != nil {
return err
}
}
// meter available
if c.meter != nil {
return c.updateMeters()
}
// no meter available
c.updateLoadpoints(loadpoints)
for _, ch := range c.children {
c.power += ch.GetChargePower()
c.current += ch.GetMaxPhaseCurrent()
}
return nil
}
// GetChargePower returns the actual power
func (c *Circuit) GetChargePower() float64 {
return c.power
}
// GetMaxPhaseCurrent returns the actual current
func (c *Circuit) GetMaxPhaseCurrent() float64 {
return c.current
}
// ValidatePower validates power request
func (c *Circuit) ValidatePower(old, new float64) float64 {
delta := max(0, new-old)
if c.maxPower != 0 {
if c.power+delta > c.maxPower {
new = max(0, c.maxPower-c.power)
c.log.DEBUG.Printf("validate power: %gW -> %gW <= %gW at %gW: capped at %gW", old, new, c.maxPower, c.power, new)
} else {
c.log.TRACE.Printf("validate power: %gW -> %gW <= %gW at %gW: ok", old, new, c.maxPower, c.power)
}
}
if c.parent != nil {
res := c.parent.ValidatePower(c.power, new)
if res != new {
c.log.TRACE.Printf("validate power: %gW -> %gW at %gW: capped at %gW", old, new, c.power, new)
}
return res
}
return new
}
// ValidateCurrent validates current request
func (c *Circuit) ValidateCurrent(old, new float64) (res float64) {
delta := max(0, new-old)
if c.maxCurrent != 0 {
if c.current+delta > c.maxCurrent {
new = max(0, c.maxCurrent-c.current)
c.log.DEBUG.Printf("validate current: %gA -> %gA <= %gA at %gA: capped at %gA", old, new, c.maxCurrent, c.current, new)
} else {
c.log.TRACE.Printf("validate current: %gA -> %gA <= %gA at %gA: ok", old, new, c.maxCurrent, c.current)
}
}
if c.parent != nil {
res := c.parent.ValidateCurrent(c.current, new)
if res != new {
c.log.TRACE.Printf("validate current: %gA -> %gA at %gA: capped by parent at %gA", old, new, c.current, res)
}
return res
}
return new
}
// func (c *Circuit) validate(typ string, current, old, new float64, parentFunc func(o, n float64) float64) float64 {
// delta := max(0, new-old)
// if c.maxPower != 0 {
// if c.power+delta > c.maxPower {
// new = max(0, c.maxPower-c.power)
// c.log.TRACE.Printf("validate power: %g -> %g <= %g at %g: capped at %g", old, new, c.maxPower, c.power, new)
// } else {
// c.log.TRACE.Printf("validate power: %g -> %g <= %g at %g: ok", old, new, c.maxPower, c.power)
// }
// }
// if c.parent != nil {
// return c.parent.ValidatePower(c.power, new)
// }
// return new
// }

123
core/circuit_test.go Normal file
View file

@ -0,0 +1,123 @@
package core
import (
"testing"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)
func TestCircuitPower(t *testing.T) {
log := util.NewLogger("foo")
circ := func(t *testing.T, ctrl *gomock.Controller, maxP float64) (*Circuit, *api.MockMeter) {
m := api.NewMockMeter(ctrl)
c, err := NewCircuit(log, "foo", 0, maxP, m)
require.NoError(t, err)
return c, m
}
for _, tc := range []struct {
pm, cm1, cm2 float64
req, res float64
}{
// no load
{0, 0, 0, 0, 0},
{0, 0, 0, 1, 1},
{0, 0, 0, 2, 1},
// c1 loaded
{0, 1, 0, 0, 0},
{0, 1, 0, 1, 0},
{0, 1, 0, 2, 0},
// pc loaded
{1, 0, 0, 0, 0},
{1, 0, 0, 1, 0},
{1, 0, 0, 2, 0},
} {
ctrl := gomock.NewController(t)
pc, pm := circ(t, ctrl, 1)
c1, cm1 := circ(t, ctrl, 1)
c2, cm2 := circ(t, ctrl, 1)
c1.SetParent(pc)
c2.SetParent(pc)
// update meters
pm.EXPECT().CurrentPower().Return(tc.pm, nil)
cm1.EXPECT().CurrentPower().Return(tc.cm1, nil)
cm2.EXPECT().CurrentPower().Return(tc.cm2, nil)
require.NoError(t, pc.Update(nil))
require.Equal(t, tc.res, c1.ValidatePower(0, tc.req))
ctrl.Finish()
}
}
// func TestCircuitCurrents(t *testing.T) {
// log := util.NewLogger("foo")
// type mockMeter struct {
// *api.MockMeter
// *api.MockPhaseCurrents
// }
// circ := func(t *testing.T, ctrl *gomock.Controller, maxP float64) (*Circuit, *mockMeter) {
// m := api.NewMockMeter(ctrl)
// mc := api.NewMockPhaseCurrents(ctrl)
// mm := &mockMeter{m, mc}
// c, err := NewCircuit(log, 0, maxP, mm)
// require.NoError(t, err)
// return c, mm
// }
// for _, tc := range []struct {
// pm, cm1, cm2 float64
// req, res float64
// }{
// // no load
// {0, 0, 0, 0, 0},
// {0, 0, 0, 1, 1},
// {0, 0, 0, 2, 1},
// // c1 loaded
// {0, 1, 0, 0, 0},
// {0, 1, 0, 1, 0},
// {0, 1, 0, 2, 0},
// // pc loaded
// {1, 0, 0, 0, 0},
// {1, 0, 0, 1, 0},
// {1, 0, 0, 2, 0},
// } {
// ctrl := gomock.NewController(t)
// pc, pm := circ(t, ctrl, 1)
// c1, cm1 := circ(t, ctrl, 1)
// c2, cm2 := circ(t, ctrl, 1)
// c1.SetParent(pc)
// c2.SetParent(pc)
// // update meters
// pm.MockMeter.EXPECT().CurrentPower().Return(tc.pm, nil)
// cm1.MockMeter.EXPECT().CurrentPower().Return(tc.cm1, nil)
// cm2.MockMeter.EXPECT().CurrentPower().Return(tc.cm2, nil)
// // update meters
// pm.MockPhaseCurrents.EXPECT().Currents().Return(tc.pm, tc.pm, tc.pm, nil)
// cm1.MockPhaseCurrents.EXPECT().Currents().Return(tc.cm1, tc.cm1, tc.cm1, nil)
// cm2.MockPhaseCurrents.EXPECT().Currents().Return(tc.cm2, tc.cm2, tc.cm2, nil)
// require.NoError(t, pc.Update(nil))
// require.Equal(t, tc.res, c1.ValidatePower(0, tc.req))
// require.Equal(t, tc.res, c1.ValidateCurrent(0, tc.req))
// ctrl.Finish()
// }
// }

View file

@ -30,6 +30,14 @@ func powerToCurrent(power float64, phases int) float64 {
return power / (float64(phases) * Voltage)
}
// currentToPower is a helper function to convert current to sum power
func currentToPower(current float64, phases int) float64 {
if Voltage == 0 {
panic("Voltage is not set")
}
return current * float64(phases) * Voltage
}
// sitePower returns the available delta power that the charger might additionally consume
// negative value: available power (grid export), positive value: grid import
func sitePower(log *util.Logger, maxGrid, grid, battery, residual float64) float64 {

View file

@ -28,6 +28,7 @@ const (
TariffPriceHome = "tariffPriceHome"
TariffPriceLoadpoints = "tariffPriceLoadpoints"
Vehicles = "vehicles"
Circuits = "circuits"
PasswordConfigured = "passwordConfigured"
Interval = "interval"

View file

@ -108,6 +108,7 @@ type Loadpoint struct {
Title_ string `mapstructure:"title"` // UI title
Priority_ int `mapstructure:"priority"` // Priority
CircuitRef string `mapstructure:"circuit"` // Circuit reference
ChargerRef string `mapstructure:"charger"` // Charger reference
VehicleRef string `mapstructure:"vehicle"` // Vehicle reference
MeterRef string `mapstructure:"meter"` // Charge meter reference
@ -144,6 +145,7 @@ type Loadpoint struct {
chargeRater api.ChargeRater
chargedAtStartup float64 // session energy at startup
circuit api.Circuit // Circuit
chargeMeter api.Meter // Charger usage meter
vehicle api.Vehicle // Currently active vehicle
defaultVehicle api.Vehicle // Default vehicle (disables detection)
@ -203,6 +205,14 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
lp.Soc.Poll.Mode = pollCharging
}
if lp.CircuitRef != "" {
dev, err := config.Circuits().ByName(lp.CircuitRef)
if err != nil {
return nil, err
}
lp.circuit = dev.Instance()
}
if lp.MeterRef != "" {
dev, err := config.Meters().ByName(lp.MeterRef)
if err != nil {
@ -761,6 +771,17 @@ func (lp *Loadpoint) setLimit(chargeCurrent float64) error {
chargeCurrent = math.Trunc(chargeCurrent)
}
// apply circuit limits
if lp.circuit != nil {
currentLimit := lp.circuit.ValidateCurrent(lp.chargeCurrent, chargeCurrent)
activePhases := lp.ActivePhases()
powerLimit := lp.circuit.ValidatePower(lp.chargePower, currentToPower(chargeCurrent, activePhases))
currentLimitViaPower := powerToCurrent(powerLimit, activePhases)
chargeCurrent = min(currentLimit, currentLimitViaPower)
}
// set current
if chargeCurrent != lp.chargeCurrent && chargeCurrent >= lp.effectiveMinCurrent() {
var err error
@ -1293,8 +1314,8 @@ func (lp *Loadpoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64, batter
return targetCurrent
}
// UpdateChargePower updates charge meter power
func (lp *Loadpoint) UpdateChargePower() {
// UpdateChargePowerAndCurrents updates charge meter power and currents for load management
func (lp *Loadpoint) UpdateChargePowerAndCurrents() {
bo := backoff.NewExponentialBackOff()
bo.MaxElapsedTime = time.Second
@ -1313,12 +1334,10 @@ func (lp *Loadpoint) UpdateChargePower() {
lp.log.WARN.Printf("charge power must not be negative: %.0f", power)
}
} else {
lp.log.ERROR.Printf("charge meter: %v", err)
lp.log.ERROR.Printf("charge power: %v", err)
}
}
// updateChargeCurrents uses PhaseCurrents interface to count phases with current >=1A
func (lp *Loadpoint) updateChargeCurrents() {
// update charge currents
lp.chargeCurrents = nil
phaseMeter, ok := lp.chargeMeter.(api.PhaseCurrents)
@ -1326,16 +1345,28 @@ func (lp *Loadpoint) updateChargeCurrents() {
return // don't guess
}
if err := backoff.Retry(func() error {
i1, i2, i3, err := phaseMeter.Currents()
if err != nil {
lp.log.ERROR.Printf("charge meter: %v", err)
return
return err
}
lp.chargeCurrents = []float64{i1, i2, i3}
lp.log.DEBUG.Printf("charge currents: %.3gA", lp.chargeCurrents)
lp.publish(keys.ChargeCurrents, lp.chargeCurrents)
return nil
}, bo); err != nil {
lp.log.ERROR.Printf("charge currents: %v", err)
}
}
// phasesFromChargeCurrents uses PhaseCurrents interface to count phases with current >=1A
func (lp *Loadpoint) phasesFromChargeCurrents() {
if lp.chargeCurrents == nil {
return
}
if lp.charging() && lp.phaseSwitchCompleted() {
var phases int
for _, i := range lp.chargeCurrents {
@ -1555,9 +1586,9 @@ func (lp *Loadpoint) Update(sitePower float64, autoCharge, batteryBuffered, batt
lp.publish(keys.SmartCostActive, autoCharge)
lp.processTasks()
// read and publish meters first- charge power has already been updated by the site
// read and publish meters first- charge power and currents have already been updated by the site
lp.updateChargeVoltages()
lp.updateChargeCurrents()
lp.phasesFromChargeCurrents()
lp.sessionEnergy.SetEnvironment(greenShare, effPrice, effCo2)

View file

@ -124,6 +124,8 @@ type API interface {
GetChargePower() float64
// GetChargePowerFlexibility returns the flexible amount of current charging power
GetChargePowerFlexibility() float64
// GetMaxPhaseCurrent returns max phase current
GetMaxPhaseCurrent() float64
//
// charge progress
@ -146,4 +148,7 @@ type API interface {
SetVehicle(vehicle api.Vehicle)
// StartVehicleDetection allows triggering vehicle detection for debugging purposes
StartVehicleDetection()
// GetCircuit gets the assigned circuit
GetCircuit() api.Circuit
}

View file

@ -138,6 +138,20 @@ func (mr *MockAPIMockRecorder) GetChargePowerFlexibility() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChargePowerFlexibility", reflect.TypeOf((*MockAPI)(nil).GetChargePowerFlexibility))
}
// GetCircuit mocks base method.
func (m *MockAPI) GetCircuit() api.Circuit {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetCircuit")
ret0, _ := ret[0].(api.Circuit)
return ret0
}
// GetCircuit indicates an expected call of GetCircuit.
func (mr *MockAPIMockRecorder) GetCircuit() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCircuit", reflect.TypeOf((*MockAPI)(nil).GetCircuit))
}
// GetDisableThreshold mocks base method.
func (m *MockAPI) GetDisableThreshold() float64 {
m.ctrl.T.Helper()
@ -208,6 +222,20 @@ func (mr *MockAPIMockRecorder) GetMaxCurrent() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaxCurrent", reflect.TypeOf((*MockAPI)(nil).GetMaxCurrent))
}
// GetMaxPhaseCurrent mocks base method.
func (m *MockAPI) GetMaxPhaseCurrent() float64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetMaxPhaseCurrent")
ret0, _ := ret[0].(float64)
return ret0
}
// GetMaxPhaseCurrent indicates an expected call of GetMaxPhaseCurrent.
func (mr *MockAPIMockRecorder) GetMaxPhaseCurrent() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaxPhaseCurrent", reflect.TypeOf((*MockAPI)(nil).GetMaxPhaseCurrent))
}
// GetMinCurrent mocks base method.
func (m *MockAPI) GetMinCurrent() float64 {
m.ctrl.T.Helper()

View file

@ -323,6 +323,16 @@ func (lp *Loadpoint) GetChargePowerFlexibility() float64 {
return max(0, lp.GetChargePower()-lp.EffectiveMinPower())
}
// GetMaxPhaseCurrent returns the current charge power
func (lp *Loadpoint) GetMaxPhaseCurrent() float64 {
lp.RLock()
defer lp.RUnlock()
if lp.chargeCurrents == nil {
return lp.chargeCurrent
}
return max(lp.chargeCurrents[0], lp.chargeCurrents[1], lp.chargeCurrents[2])
}
// GetMinCurrent returns the min loadpoint current
func (lp *Loadpoint) GetMinCurrent() float64 {
lp.RLock()
@ -498,3 +508,16 @@ func (lp *Loadpoint) SetSmartCostLimit(val float64) {
lp.publish(keys.SmartCostLimit, lp.smartCostLimit)
}
}
// GetCircuit returns the assigned circuit
func (lp *Loadpoint) GetCircuit() api.Circuit {
lp.RLock()
defer lp.RUnlock()
// return untyped nil
if lp.circuit == nil {
return nil
}
return lp.circuit
}

View file

@ -68,10 +68,13 @@ type Site struct {
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
Meters MetersConfig `mapstructure:"meters"` // Meter references
CircuitRef string `mapstructure:"circuit"` // Circuit reference
MaxGridSupplyWhileBatteryCharging float64 `mapstructure:"maxGridSupplyWhileBatteryCharging"` // ignore battery charging if AC consumption is above this value
// meters
circuit api.Circuit // Circuit
gridMeter api.Meter // Grid usage meter
pvMeters []api.Meter // PV generation meters
batteryMeters []api.Meter // Battery charging meters
@ -162,6 +165,15 @@ func NewSiteFromConfig(
// add meters from config
site.restoreMeters()
// circuit
if site.CircuitRef != "" {
dev, err := config.Circuits().ByName(site.CircuitRef)
if err != nil {
return nil, err
}
site.circuit = dev.Instance()
}
// grid meter
if site.Meters.GridMeterRef != "" {
dev, err := config.Meters().ByName(site.Meters.GridMeterRef)
@ -753,12 +765,21 @@ func (site *Site) update(lp updater) {
// update all loadpoint's charge power
var totalChargePower float64
for _, lp := range site.loadpoints {
lp.UpdateChargePower()
lp.UpdateChargePowerAndCurrents()
totalChargePower += lp.GetChargePower()
site.prioritizer.UpdateChargePowerFlexibility(lp)
}
// update all circuits' power and currents
if site.circuit != nil {
if err := site.circuit.Update(site.loadpointsAsCircuitDevices()); err != nil {
site.log.ERROR.Println(err)
}
site.publishCircuits()
}
// prioritize if possible
var flexiblePower float64
if lp.GetMode() == api.ModePV {

View file

@ -11,6 +11,9 @@ type API interface {
Loadpoints() []loadpoint.API
Vehicles() Vehicles
// GetCircuit returns the assigned circuit
GetCircuit() api.Circuit
// Meta
GetTitle() string
SetTitle(string)

View file

@ -10,6 +10,7 @@ import (
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/util/config"
"github.com/samber/lo"
)
var _ site.API = (*Site)(nil)
@ -121,13 +122,14 @@ func (site *Site) SetAuxMeterRefs(ref []string) {
settings.SetString(keys.AuxMeters, strings.Join(filterConfigurable(ref), ","))
}
// Loadpoints returns the list loadpoints
// Loadpoints returns the loadpoints as api interfaces
func (site *Site) Loadpoints() []loadpoint.API {
res := make([]loadpoint.API, len(site.loadpoints))
for id, lp := range site.loadpoints {
res[id] = lp
}
return res
return lo.Map(site.loadpoints, func(lp *Loadpoint, _ int) loadpoint.API { return lp })
}
// loadpointsAsCircuitDevices returns the loadpoints as circuit devices
func (site *Site) loadpointsAsCircuitDevices() []api.CircuitLoad {
return lo.Map(site.loadpoints, func(lp *Loadpoint, _ int) api.CircuitLoad { return lp })
}
// Vehicles returns the site vehicles
@ -135,6 +137,15 @@ func (site *Site) Vehicles() site.Vehicles {
return &vehicles{log: site.log}
}
// GetCircuit returns the circuit
func (site *Site) GetCircuit() api.Circuit {
if site.circuit == nil {
// return untyped nil
return nil
}
return site.circuit
}
// GetPrioritySoc returns the PrioritySoc
func (site *Site) GetPrioritySoc() float64 {
site.RLock()

38
core/site_circuits.go Normal file
View file

@ -0,0 +1,38 @@
package core
import (
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
)
type circuitStruct struct {
Power float64 `json:"power"`
Current *float64 `json:"current,omitempty"`
MaxPower float64 `json:"maxPower,omitempty"`
MaxCurrent float64 `json:"maxCurrent,omitempty"`
}
// publishCircuits returns a list of circuit titles
func (site *Site) publishCircuits() {
cc := config.Circuits().Devices()
res := make(map[string]circuitStruct, len(cc))
for _, c := range cc {
instance := c.Instance()
data := circuitStruct{
Power: instance.GetChargePower(),
MaxPower: instance.GetMaxPower(),
MaxCurrent: instance.GetMaxCurrent(),
}
if instance.GetMaxCurrent() > 0 {
data.Current = util.PtrTo(instance.GetMaxPhaseCurrent())
}
res[c.Config().Name] = data
}
site.publish(keys.Circuits, res)
}

43
lm.md Normal file
View file

@ -0,0 +1,43 @@
## Heute (ohne LM)
je Ladepunkt:
- Leistung Site aktualisieren
- Leistung alle Ladepunkte aktualisieren
- Gesamtbudget berechnen
- Aktuellen Ladepunkt steuern
## Lastmanagement #8427
Setup:
- alle Circuits hierarchisch dem Parent vMeter zurodnen
je Ladepunkt:
- Leistung Site aktualisieren
- Leistung aller Ladepunkte aktualisieren
- Gesamtbudget berechnen
- Aktuellen Ladepunkt steuern
- dabei Strom/Leistung durch Circuit begrenzen
- Circuit aktualisieren oder aggregierte Strom/Leistung aus vMeter verwenden
-> u.U. mehrere Circuit-Zähler auszulesen
- eigenen Strom/Leistung an LM zurück melden
## Vorschlag zur Vereinfachung der vMeter
Setup:
- ENTFÄLLT: alle Circuits hierarchisch dem Parent vMeter zuordnen
je Ladepunkt:
- Leistung Site aktualisieren
- Leistung alle Ladepunkte aktualisieren
- NEU: Ströme alle Ladepunkte aktualisieren (falls vorhanden)
- NEU: Leistung aller Circuits depth-first aktualisieren
- dafür Werte der Ladepunkte verwenden wo kein Circuit Meter vorhanden
- Gesamtbudget berechnen
- Aktuellen Ladepunkt steuern
- dabei Strom/Leistung durch Circuit begrenzen
- ENTFÄLLT: Circuit aktualisieren oder aggregierte Strom/Leistung aus vMeter verwenden

View file

@ -6,8 +6,11 @@ import (
"net/http"
"strconv"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/charger"
"github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/meter"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/templates"
"github.com/evcc-io/evcc/vehicle"
@ -50,6 +53,9 @@ func devicesHandler(w http.ResponseWriter, r *http.Request) {
case templates.Vehicle:
res, err = devicesConfig(class, config.Vehicles())
case templates.Circuit:
res, err = devicesConfig(class, config.Circuits())
}
if err != nil {
@ -123,6 +129,9 @@ func deviceConfigHandler(w http.ResponseWriter, r *http.Request) {
case templates.Vehicle:
res, err = deviceConfig(class, id, config.Vehicles())
case templates.Circuit:
res, err = deviceConfig(class, id, config.Circuits())
}
if err != nil {
@ -166,6 +175,9 @@ func deviceStatusHandler(w http.ResponseWriter, r *http.Request) {
case templates.Vehicle:
instance, err = deviceStatus(name, config.Vehicles())
case templates.Circuit:
instance, err = deviceStatus(name, config.Circuits())
}
if err != nil {
@ -218,6 +230,11 @@ func newDeviceHandler(w http.ResponseWriter, r *http.Request) {
case templates.Vehicle:
conf, err = newDevice(class, req, vehicle.NewFromConfig, config.Vehicles())
case templates.Circuit:
conf, err = newDevice(class, req, func(_ string, other map[string]interface{}) (api.Circuit, error) {
return core.NewCircuitFromConfig(util.NewLogger("circuit"), other)
}, config.Circuits())
}
if err != nil {
@ -284,6 +301,11 @@ func updateDeviceHandler(w http.ResponseWriter, r *http.Request) {
case templates.Vehicle:
err = updateDevice(id, class, req, vehicle.NewFromConfig, config.Vehicles())
case templates.Circuit:
err = updateDevice(id, class, req, func(_ string, other map[string]interface{}) (api.Circuit, error) {
return core.NewCircuitFromConfig(util.NewLogger("circuit"), other)
}, config.Circuits())
}
setConfigDirty()
@ -347,6 +369,9 @@ func deleteDeviceHandler(w http.ResponseWriter, r *http.Request) {
case templates.Vehicle:
err = deleteDevice(id, config.Vehicles())
case templates.Circuit:
err = deleteDevice(id, config.Circuits())
}
setConfigDirty()
@ -413,6 +438,9 @@ func testConfigHandler(w http.ResponseWriter, r *http.Request) {
case templates.Vehicle:
instance, err = testConfig(id, class, req, vehicle.NewFromConfig, config.Vehicles())
case templates.Circuit:
err = api.ErrNotAvailable
}
if err != nil {

View file

@ -7,14 +7,22 @@ import (
var bus = evbus.New()
var instance = struct {
var instance struct {
meters *handler[api.Meter]
chargers *handler[api.Charger]
vehicles *handler[api.Vehicle]
}{
meters: &handler[api.Meter]{topic: "meter"},
chargers: &handler[api.Charger]{topic: "charger"},
vehicles: &handler[api.Vehicle]{topic: "vehicle"},
circuits *handler[api.Circuit]
}
func init() {
Reset()
}
func Reset() {
instance.meters = &handler[api.Meter]{topic: "meter"}
instance.chargers = &handler[api.Charger]{topic: "charger"}
instance.vehicles = &handler[api.Vehicle]{topic: "vehicle"}
instance.circuits = &handler[api.Circuit]{topic: "circuit"}
}
type Handler[T any] interface {
@ -37,6 +45,10 @@ func Vehicles() Handler[api.Vehicle] {
return instance.vehicles
}
func Circuits() Handler[api.Circuit] {
return instance.circuits
}
// Instances returns the instances of the given devices
func Instances[T any](devices []Device[T]) []T {
res := make([]T, 0, len(devices))

View file

@ -1,5 +1,9 @@
package config
import (
"strings"
)
type Typed struct {
Type string `json:"type"`
Other map[string]interface{} `mapstructure:",remain"`
@ -10,3 +14,13 @@ type Named struct {
Type string `json:"type"`
Other map[string]interface{} `mapstructure:",remain"`
}
// Property returns the value of the named property
func (n Named) Property(key string) any {
for k, v := range n.Other {
if strings.EqualFold(k, key) {
return v
}
}
return nil
}

15
util/ptr.go Normal file
View file

@ -0,0 +1,15 @@
package util
// PtrTo returns a pointer to the value passed as argument. The zero is returned as nil.
func PtrTo[T comparable](v T) *T {
var zero T
if v == zero {
return nil
}
return &v
}
// PtrToWithZero returns a pointer to the value passed as argument, including its zero value.
func PtrToWithZero[T any](v T) *T {
return &v
}

View file

@ -9,4 +9,5 @@ const (
Meter
Vehicle
Tariff
Circuit
)

View file

@ -7,11 +7,11 @@ import (
"strings"
)
const _ClassName = "ChargerMeterVehicleTariff"
const _ClassName = "ChargerMeterVehicleTariffCircuit"
var _ClassIndex = [...]uint8{0, 7, 12, 19, 25}
var _ClassIndex = [...]uint8{0, 7, 12, 19, 25, 32}
const _ClassLowerName = "chargermetervehicletariff"
const _ClassLowerName = "chargermetervehicletariffcircuit"
func (i Class) String() string {
i -= 1
@ -29,9 +29,10 @@ func _ClassNoOp() {
_ = x[Meter-(2)]
_ = x[Vehicle-(3)]
_ = x[Tariff-(4)]
_ = x[Circuit-(5)]
}
var _ClassValues = []Class{Charger, Meter, Vehicle, Tariff}
var _ClassValues = []Class{Charger, Meter, Vehicle, Tariff, Circuit}
var _ClassNameToValueMap = map[string]Class{
_ClassName[0:7]: Charger,
@ -42,6 +43,8 @@ var _ClassNameToValueMap = map[string]Class{
_ClassLowerName[12:19]: Vehicle,
_ClassName[19:25]: Tariff,
_ClassLowerName[19:25]: Tariff,
_ClassName[25:32]: Circuit,
_ClassLowerName[25:32]: Circuit,
}
var _ClassNames = []string{
@ -49,6 +52,7 @@ var _ClassNames = []string{
_ClassName[7:12],
_ClassName[12:19],
_ClassName[19:25],
_ClassName[25:32],
}
// ClassString retrieves an enum value from the enum constants string name.