diff --git a/core/loadpoint.go b/core/loadpoint.go index d69261c12..51d24c08c 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -953,7 +953,7 @@ func (lp *LoadPoint) pvScalePhases(availablePower, minCurrent, maxCurrent float6 } // pvMaxCurrent calculates the maximum target current for PV mode -func (lp *LoadPoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64) float64 { +func (lp *LoadPoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64, batteryBuffered bool) float64 { // read only once to simplify testing minCurrent := lp.GetMinCurrent() maxCurrent := lp.GetMaxCurrent() @@ -976,7 +976,7 @@ func (lp *LoadPoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64) float6 } // in MinPV mode return at least minCurrent - if mode == api.ModeMinPV && targetCurrent < minCurrent { + if (mode == api.ModeMinPV || batteryBuffered) && targetCurrent < minCurrent { return minCurrent } @@ -1206,7 +1206,7 @@ func (lp *LoadPoint) publishSoCAndRange() { } // Update is the main control function. It reevaluates meters and charger state -func (lp *LoadPoint) Update(sitePower float64, cheap bool) { +func (lp *LoadPoint) Update(sitePower float64, cheap bool, batteryBuffered bool) { mode := lp.GetMode() lp.publish("mode", mode) @@ -1302,7 +1302,7 @@ func (lp *LoadPoint) Update(sitePower float64, cheap bool) { err = lp.setLimit(targetCurrent, true) case mode == api.ModeMinPV || mode == api.ModePV: - targetCurrent := lp.pvMaxCurrent(mode, sitePower) + targetCurrent := lp.pvMaxCurrent(mode, sitePower, batteryBuffered) lp.log.DEBUG.Printf("pv max charge current: %.3gA", targetCurrent) var required bool // false diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go index a5dfcaf90..aace072f3 100644 --- a/core/loadpoint_test.go +++ b/core/loadpoint_test.go @@ -168,7 +168,7 @@ func TestUpdatePowerZero(t *testing.T) { } lp.Mode = tc.mode - lp.Update(0, false) // sitePower 0 + lp.Update(0, false, false) // sitePower 0 ctrl.Finish() } @@ -324,7 +324,7 @@ func TestPVHysteresis(t *testing.T) { // charger.EXPECT().Enabled().Return(tc.enabled, nil) lp.enabled = tc.enabled - current := lp.pvMaxCurrent(api.ModePV, se.site) + current := lp.pvMaxCurrent(api.ModePV, se.site, false) if current != se.current { t.Errorf("step %d: wanted %.1f, got %.1f", step, se.current, current) @@ -355,7 +355,7 @@ func TestPVHysteresisForStatusOtherThanC(t *testing.T) { // maxCurrent will read enabled state in PV mode sitePower := -float64(lp.Phases)*minA*Voltage + 1 // 1W below min power - current := lp.pvMaxCurrent(api.ModePV, sitePower) + current := lp.pvMaxCurrent(api.ModePV, sitePower, false) if current != 0 { t.Errorf("PV mode could not disable charger as expected. Expected 0, got %.f", current) @@ -406,7 +406,7 @@ func TestDisableAndEnableAtTargetSoC(t *testing.T) { charger.EXPECT().Status().Return(api.StatusC, nil) charger.EXPECT().Enabled().Return(lp.enabled, nil) charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil) - lp.Update(500, false) + lp.Update(500, false, false) t.Log("charging above target - soc deactivates charger") clock.Add(5 * time.Minute) @@ -414,20 +414,20 @@ func TestDisableAndEnableAtTargetSoC(t *testing.T) { charger.EXPECT().Status().Return(api.StatusC, nil) charger.EXPECT().Enabled().Return(lp.enabled, nil) charger.EXPECT().Enable(false).Return(nil) - lp.Update(500, false) + lp.Update(500, false, false) t.Log("deactivated charger changes status to B") clock.Add(5 * time.Minute) vehicle.EXPECT().SoC().Return(95.0, nil) charger.EXPECT().Status().Return(api.StatusB, nil) charger.EXPECT().Enabled().Return(lp.enabled, nil) - lp.Update(-5000, false) + lp.Update(-5000, false, false) t.Log("soc has fallen below target - soc update prevented by timer") clock.Add(5 * time.Minute) charger.EXPECT().Status().Return(api.StatusB, nil) charger.EXPECT().Enabled().Return(lp.enabled, nil) - lp.Update(-5000, false) + lp.Update(-5000, false, false) t.Log("soc has fallen below target - soc update timer expired") clock.Add(pollInterval) @@ -435,7 +435,7 @@ func TestDisableAndEnableAtTargetSoC(t *testing.T) { charger.EXPECT().Status().Return(api.StatusB, nil) charger.EXPECT().Enabled().Return(lp.enabled, nil) charger.EXPECT().Enable(true).Return(nil) - lp.Update(-5000, false) + lp.Update(-5000, false, false) ctrl.Finish() } @@ -475,14 +475,14 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) { charger.EXPECT().Enabled().Return(lp.enabled, nil) charger.EXPECT().Status().Return(api.StatusC, nil) charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil) - lp.Update(500, false) + lp.Update(500, false, false) t.Log("switch off when disconnected") clock.Add(5 * time.Minute) charger.EXPECT().Enabled().Return(lp.enabled, nil) charger.EXPECT().Status().Return(api.StatusA, nil) charger.EXPECT().Enable(false).Return(nil) - lp.Update(-3000, false) + lp.Update(-3000, false, false) if lp.Mode != api.ModeOff { t.Error("unexpected mode", lp.Mode) @@ -543,14 +543,14 @@ func TestChargedEnergyAtDisconnect(t *testing.T) { rater.EXPECT().ChargedEnergy().Return(0.0, nil) charger.EXPECT().Enabled().Return(lp.enabled, nil) charger.EXPECT().Status().Return(api.StatusC, nil) - lp.Update(-1, false) + lp.Update(-1, false, false) t.Log("at 1:00h charging at 5 kWh") clock.Add(time.Hour) rater.EXPECT().ChargedEnergy().Return(5.0, nil) charger.EXPECT().Enabled().Return(lp.enabled, nil) charger.EXPECT().Status().Return(api.StatusC, nil) - lp.Update(-1, false) + lp.Update(-1, false, false) expectCache("chargedEnergy", 5000.0) t.Log("at 1:00h stop charging at 5 kWh") @@ -558,7 +558,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) { rater.EXPECT().ChargedEnergy().Return(5.0, nil) charger.EXPECT().Enabled().Return(lp.enabled, nil) charger.EXPECT().Status().Return(api.StatusB, nil) - lp.Update(-1, false) + lp.Update(-1, false, false) expectCache("chargedEnergy", 5000.0) t.Log("at 1:00h restart charging at 5 kWh") @@ -566,7 +566,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) { rater.EXPECT().ChargedEnergy().Return(5.0, nil) charger.EXPECT().Enabled().Return(lp.enabled, nil) charger.EXPECT().Status().Return(api.StatusC, nil) - lp.Update(-1, false) + lp.Update(-1, false, false) expectCache("chargedEnergy", 5000.0) t.Log("at 1:30h continue charging at 7.5 kWh") @@ -574,7 +574,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) { rater.EXPECT().ChargedEnergy().Return(7.5, nil) charger.EXPECT().Enabled().Return(lp.enabled, nil) charger.EXPECT().Status().Return(api.StatusC, nil) - lp.Update(-1, false) + lp.Update(-1, false, false) expectCache("chargedEnergy", 7500.0) t.Log("at 2:00h stop charging at 10 kWh") @@ -582,7 +582,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) { rater.EXPECT().ChargedEnergy().Return(10.0, nil) charger.EXPECT().Enabled().Return(lp.enabled, nil) charger.EXPECT().Status().Return(api.StatusB, nil) - lp.Update(-1, false) + lp.Update(-1, false, false) expectCache("chargedEnergy", 10000.0) ctrl.Finish() diff --git a/core/site.go b/core/site.go index 55176ad43..05ade4081 100644 --- a/core/site.go +++ b/core/site.go @@ -14,11 +14,9 @@ import ( "github.com/evcc-io/evcc/util" ) -//go:generate mockgen -package mock -destination ../mock/mock_loadpoint.go github.com/evcc-io/evcc/core Updater - // Updater abstracts the LoadPoint implementation for testing type Updater interface { - Update(float64, bool) + Update(availablePower float64, cheapRate bool, batteryBuffered bool) } // Site is the main configuration container. A site can host multiple loadpoints. @@ -37,6 +35,7 @@ type Site struct { ResidualPower float64 `mapstructure:"residualPower"` // PV meter only: household usage. Grid meter: household safety margin Meters MetersConfig // Meter references PrioritySoC float64 `mapstructure:"prioritySoC"` // prefer battery up to this SoC + BufferSoC float64 `mapstructure:"bufferSoC"` // ignore battery above this SoC // meters gridMeter api.Meter // Grid usage meter @@ -47,9 +46,10 @@ type Site struct { loadpoints []*LoadPoint // Loadpoints // cached state - gridPower float64 // Grid power - pvPower float64 // PV power - batteryPower float64 // Battery charge power + gridPower float64 // Grid power + pvPower float64 // PV power + batteryPower float64 // Battery charge power + batteryBuffered bool // Battery buffer active } // MetersConfig contains the loadpoint's meter configuration @@ -323,11 +323,14 @@ func (site *Site) sitePower() (float64, error) { site.Lock() defer site.Unlock() - // if battery is charging give it priority + // if battery is charging below prioritySoC give it priority if soc < site.PrioritySoC && batteryPower < 0 { - site.log.DEBUG.Printf("giving priority to battery at soc: %.0f", soc) + site.log.DEBUG.Printf("giving priority to battery charging at soc: %.0f", soc) batteryPower = 0 } + + // if battery is discharging above bufferSoC ignore it + site.batteryBuffered = batteryPower > 0 && site.BufferSoC > 0 && soc > site.BufferSoC } } @@ -346,7 +349,7 @@ func (site *Site) update(lp Updater) { } if sitePower, err := site.sitePower(); err == nil { - lp.Update(sitePower, cheap) + lp.Update(sitePower, cheap, site.batteryBuffered) site.Health.Update() } } diff --git a/evcc.dist.yaml b/evcc.dist.yaml index 501543056..4a09d556f 100644 --- a/evcc.dist.yaml +++ b/evcc.dist.yaml @@ -61,7 +61,8 @@ site: pvs: - pv # list of pv inverters/ meters battery: battery # battery meter - prioritySoC: 60 # give home battery priority up to this soc (0 to disable) + prioritySoC: # give home battery priority up to this soc (empty to disable) + bufferSoC: # ignore home battery discharge above soc (empty to disable) # loadpoint describes the charger, charge meter and connected vehicle loadpoints: diff --git a/mock/mock_loadpoint.go b/mock/mock_loadpoint.go index c0807ccbc..7f488e1aa 100644 --- a/mock/mock_loadpoint.go +++ b/mock/mock_loadpoint.go @@ -34,13 +34,13 @@ func (m *MockUpdater) EXPECT() *MockUpdaterMockRecorder { } // Update mocks base method. -func (m *MockUpdater) Update(arg0 float64, arg1 bool) { +func (m *MockUpdater) Update(arg0 float64, arg1, arg2 bool) { m.ctrl.T.Helper() - m.ctrl.Call(m, "Update", arg0, arg1) + m.ctrl.Call(m, "Update", arg0, arg1, arg2) } // Update indicates an expected call of Update. -func (mr *MockUpdaterMockRecorder) Update(arg0, arg1 interface{}) *gomock.Call { +func (mr *MockUpdaterMockRecorder) Update(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockUpdater)(nil).Update), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockUpdater)(nil).Update), arg0, arg1, arg2) }