From 26973a0d2c98a90b504d85dd31162b89b30d5bf3 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 27 Apr 2024 10:50:35 +0200 Subject: [PATCH] Load Management and Peak Shaving (#13207) --- api/api.go | 31 ++- api/mock.go | 255 +++++++++++++++++++++- cmd/setup.go | 164 +++++++++++++- cmd/setup_circuits_test.go | 140 ++++++++++++ cmd/setup_test.go | 9 +- core/circuit.go | 308 +++++++++++++++++++++++++++ core/circuit_test.go | 123 +++++++++++ core/helper.go | 8 + core/keys/site.go | 1 + core/loadpoint.go | 61 ++++-- core/loadpoint/api.go | 5 + core/loadpoint/mock.go | 28 +++ core/loadpoint_api.go | 23 ++ core/site.go | 33 ++- core/site/api.go | 3 + core/site_api.go | 23 +- core/site_circuits.go | 38 ++++ lm.md | 43 ++++ server/http_config_device_handler.go | 28 +++ util/config/instance.go | 22 +- util/config/types.go | 14 ++ util/ptr.go | 15 ++ util/templates/class.go | 1 + util/templates/class_enumer.go | 12 +- 24 files changed, 1339 insertions(+), 49 deletions(-) create mode 100644 cmd/setup_circuits_test.go create mode 100644 core/circuit.go create mode 100644 core/circuit_test.go create mode 100644 core/site_circuits.go create mode 100644 lm.md create mode 100644 util/ptr.go diff --git a/api/api.go b/api/api.go index 77500df08..cc6a9ea7f 100644 --- a/api/api.go +++ b/api/api.go @@ -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 +} diff --git a/api/mock.go b/api/mock.go index c3f79d525..9b81c990a 100644 --- a/api/mock.go +++ b/api/mock.go @@ -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) +} diff --git a/cmd/setup.go b/cmd/setup.go index 144702004..0fa77e762 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -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) } diff --git a/cmd/setup_circuits_test.go b/cmd/setup_circuits_test.go new file mode 100644 index 000000000..ab48fa192 --- /dev/null +++ b/cmd/setup_circuits_test.go @@ -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) +} diff --git a/cmd/setup_test.go b/cmd/setup_test.go index f00f8f5bb..068b7883c 100644 --- a/cmd/setup_test.go +++ b/cmd/setup_test.go @@ -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) } diff --git a/core/circuit.go b/core/circuit.go new file mode 100644 index 000000000..9c4ee28bf --- /dev/null +++ b/core/circuit.go @@ -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 +// } diff --git a/core/circuit_test.go b/core/circuit_test.go new file mode 100644 index 000000000..f29e67d74 --- /dev/null +++ b/core/circuit_test.go @@ -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() +// } +// } diff --git a/core/helper.go b/core/helper.go index 471523d2a..5110d384b 100644 --- a/core/helper.go +++ b/core/helper.go @@ -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 { diff --git a/core/keys/site.go b/core/keys/site.go index e4be858f8..5c88f9cdf 100644 --- a/core/keys/site.go +++ b/core/keys/site.go @@ -28,6 +28,7 @@ const ( TariffPriceHome = "tariffPriceHome" TariffPriceLoadpoints = "tariffPriceLoadpoints" Vehicles = "vehicles" + Circuits = "circuits" PasswordConfigured = "passwordConfigured" Interval = "interval" diff --git a/core/loadpoint.go b/core/loadpoint.go index b5c76e86d..bdb173fe9 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -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 } - i1, i2, i3, err := phaseMeter.Currents() - if err != nil { - lp.log.ERROR.Printf("charge meter: %v", err) + if err := backoff.Retry(func() error { + i1, i2, i3, err := phaseMeter.Currents() + if err != nil { + 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 } - lp.chargeCurrents = []float64{i1, i2, i3} - lp.log.DEBUG.Printf("charge currents: %.3gA", lp.chargeCurrents) - lp.publish(keys.ChargeCurrents, lp.chargeCurrents) - 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) diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go index 567b88d9e..9eb97505a 100644 --- a/core/loadpoint/api.go +++ b/core/loadpoint/api.go @@ -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 } diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go index 0fd00c5b4..cf7578bba 100644 --- a/core/loadpoint/mock.go +++ b/core/loadpoint/mock.go @@ -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() diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index e828215bf..738dc3021 100644 --- a/core/loadpoint_api.go +++ b/core/loadpoint_api.go @@ -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 +} diff --git a/core/site.go b/core/site.go index 21558801b..2b282eac9 100644 --- a/core/site.go +++ b/core/site.go @@ -65,13 +65,16 @@ type Site struct { log *util.Logger // configuration - Title string `mapstructure:"title"` // UI title - Voltage float64 `mapstructure:"voltage"` // Operating voltage. 230V for Germany. - ResidualPower float64 `mapstructure:"residualPower"` // PV meter only: household usage. Grid meter: household safety margin - Meters MetersConfig // Meter references - MaxGridSupplyWhileBatteryCharging float64 `mapstructure:"maxGridSupplyWhileBatteryCharging"` // ignore battery charging if AC consumption is above this value + 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 + 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 { diff --git a/core/site/api.go b/core/site/api.go index 86ce5b3f5..7ea5b476f 100644 --- a/core/site/api.go +++ b/core/site/api.go @@ -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) diff --git a/core/site_api.go b/core/site_api.go index d22d3afd6..7d216998e 100644 --- a/core/site_api.go +++ b/core/site_api.go @@ -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() diff --git a/core/site_circuits.go b/core/site_circuits.go new file mode 100644 index 000000000..1e7338009 --- /dev/null +++ b/core/site_circuits.go @@ -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) +} diff --git a/lm.md b/lm.md new file mode 100644 index 000000000..f7ea066e4 --- /dev/null +++ b/lm.md @@ -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 diff --git a/server/http_config_device_handler.go b/server/http_config_device_handler.go index 74e09c9b9..a28cc5502 100644 --- a/server/http_config_device_handler.go +++ b/server/http_config_device_handler.go @@ -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 { diff --git a/util/config/instance.go b/util/config/instance.go index 2fd2dcfac..d6fbee1b4 100644 --- a/util/config/instance.go +++ b/util/config/instance.go @@ -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)) diff --git a/util/config/types.go b/util/config/types.go index af37a20c5..9e7334b9c 100644 --- a/util/config/types.go +++ b/util/config/types.go @@ -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 +} diff --git a/util/ptr.go b/util/ptr.go new file mode 100644 index 000000000..5525b7545 --- /dev/null +++ b/util/ptr.go @@ -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 +} diff --git a/util/templates/class.go b/util/templates/class.go index faa734e41..8e970ec68 100644 --- a/util/templates/class.go +++ b/util/templates/class.go @@ -9,4 +9,5 @@ const ( Meter Vehicle Tariff + Circuit ) diff --git a/util/templates/class_enumer.go b/util/templates/class_enumer.go index b21ff61de..0891e5c01 100644 --- a/util/templates/class_enumer.go +++ b/util/templates/class_enumer.go @@ -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.