From f4f0d6bed013df62c2ab59a0a7b9648f9785fdf6 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 19 Mar 2023 17:45:34 +0100 Subject: [PATCH] Add smartCostLimit to site (#6732) --- core/loadpoint.go | 10 +++++++++- core/loadpoint_plan.go | 4 ++-- core/loadpoint_test.go | 28 ++++++++++++++-------------- core/loadpoint_vehicle_test.go | 4 ++-- core/site.go | 23 +++++++++++++++++++++-- core/site/api.go | 4 +++- core/site_api.go | 18 ++++++++++++++++++ evcc.dist.yaml | 3 +++ hems/config.go | 4 ++-- schema.json | 17 +++++++++++++++-- server/http.go | 1 + 11 files changed, 90 insertions(+), 26 deletions(-) diff --git a/core/loadpoint.go b/core/loadpoint.go index ac5e62279..b293b1d0d 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -1441,7 +1441,7 @@ func (lp *Loadpoint) processTasks() { } // Update is the main control function. It reevaluates meters and charger state -func (lp *Loadpoint) Update(sitePower float64, batteryBuffered bool) { +func (lp *Loadpoint) Update(sitePower float64, autoCharge, batteryBuffered bool) { lp.processTasks() mode := lp.GetMode() @@ -1531,6 +1531,14 @@ func (lp *Loadpoint) Update(sitePower float64, batteryBuffered bool) { lp.elapsePVTimer() // let PV mode disable immediately afterwards case mode == api.ModeMinPV || mode == api.ModePV: + // cheap tariff + if autoCharge && lp.GetTargetTime().IsZero() { + err = lp.fastCharging() + lp.resetPhaseTimer() + lp.elapsePVTimer() // let PV mode disable immediately afterwards + break + } + targetCurrent := lp.pvMaxCurrent(mode, sitePower, batteryBuffered) var required bool // false diff --git a/core/loadpoint_plan.go b/core/loadpoint_plan.go index 310cfe621..ed5c90865 100644 --- a/core/loadpoint_plan.go +++ b/core/loadpoint_plan.go @@ -84,7 +84,7 @@ func (lp *Loadpoint) GetPlan(targetTime time.Time, maxPower float64) (time.Durat return requiredDuration, plan, err } -// plannerActive checks if charging plan is active +// plannerActive checks if the charging plan has an active slot func (lp *Loadpoint) plannerActive() (active bool) { defer func() { lp.setPlanActive(active) @@ -110,7 +110,7 @@ func (lp *Loadpoint) plannerActive() (active bool) { requiredDuration.Round(time.Second), lp.targetTime.Round(time.Second).Local(), maxPower, planner.Duration(plan).Round(time.Second), planner.AverageCost(plan)) - // sort plan by time + // log plan for _, slot := range plan { lp.log.TRACE.Printf(" slot from: %v to %v cost %.3f", slot.Start.Round(time.Second).Local(), slot.End.Round(time.Second).Local(), slot.Price) } diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go index 1345f718b..3e9f4aefb 100644 --- a/core/loadpoint_test.go +++ b/core/loadpoint_test.go @@ -181,7 +181,7 @@ func TestUpdatePowerZero(t *testing.T) { } lp.Mode = tc.mode - lp.Update(0, false) // sitePower 0 + lp.Update(0, false, false) // sitePower false,0 ctrl.Finish() } @@ -426,7 +426,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) @@ -434,20 +434,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) @@ -455,7 +455,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() } @@ -495,14 +495,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) @@ -564,14 +564,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") @@ -579,7 +579,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") @@ -587,7 +587,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") @@ -595,7 +595,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") @@ -603,7 +603,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/loadpoint_vehicle_test.go b/core/loadpoint_vehicle_test.go index 2a96c474e..9fa55db73 100644 --- a/core/loadpoint_vehicle_test.go +++ b/core/loadpoint_vehicle_test.go @@ -359,7 +359,7 @@ func TestReconnectVehicle(t *testing.T) { // vehicle not updated yet vehicle.MockChargeState.EXPECT().Status().Return(api.StatusA, nil) - lp.Update(0, false) + lp.Update(0, false, false) ctrl.Finish() // detection started @@ -373,7 +373,7 @@ func TestReconnectVehicle(t *testing.T) { // vehicle not updated yet vehicle.MockChargeState.EXPECT().Status().Return(api.StatusB, nil) - lp.Update(0, false) + lp.Update(0, false, false) ctrl.Finish() // vehicle detected diff --git a/core/site.go b/core/site.go index 6e77f160f..382ef4080 100644 --- a/core/site.go +++ b/core/site.go @@ -27,7 +27,7 @@ const standbyPower = 10 // consider less than 10W as charger in standby // Updater abstracts the Loadpoint implementation for testing type Updater interface { loadpoint.API - Update(availablePower float64, batteryBuffered bool) + Update(availablePower float64, autoCharge, batteryBuffered bool) } // meterMeasurement is used as slice element for publishing structured data @@ -60,6 +60,7 @@ type Site struct { PrioritySoc float64 `mapstructure:"prioritySoc"` // prefer battery up to this Soc BufferSoc float64 `mapstructure:"bufferSoc"` // ignore battery above this Soc MaxGridSupplyWhileBatteryCharging float64 `mapstructure:"maxGridSupplyWhileBatteryCharging"` // ignore battery charging if AC consumption is above this value + SmartCostLimit float64 `mapstructure:"smartCostLimit"` // always charge if cost is below this value // meters gridMeter api.Meter // Grid usage meter @@ -633,8 +634,25 @@ func (site *Site) update(lp Updater) { flexiblePower = site.prioritizer.GetChargePowerFlexibility(lp) } + var autoCharge bool + if tariff := site.GetTariff(PlannerTariff); tariff != nil { + rates, err := tariff.Rates() + + var rate api.Rate + if err == nil { + rate, err = rates.Current(time.Now()) + } + + if err == nil { + limit := site.GetSmartCostLimit() + autoCharge = limit != 0 && rate.Price <= limit + } else { + site.log.ERROR.Println("tariff:", err) + } + } + if sitePower, batteryBuffered, err := site.sitePower(totalChargePower, flexiblePower); err == nil { - lp.Update(sitePower, batteryBuffered) + lp.Update(sitePower, autoCharge, batteryBuffered) // ignore negative pvPower values as that means it is not an energy source but consumption homePower := site.gridPower + math.Max(0, site.pvPower) + site.batteryPower - totalChargePower @@ -664,6 +682,7 @@ func (site *Site) prepare() { site.publish("bufferSoc", site.BufferSoc) site.publish("prioritySoc", site.PrioritySoc) site.publish("residualPower", site.ResidualPower) + site.publish("SmartCostLimit", site.SmartCostLimit) site.publish("currency", site.tariffs.Currency.String()) site.publish("savingsSince", site.savings.Since()) diff --git a/core/site/api.go b/core/site/api.go index 75ecf755e..5b2dc358f 100644 --- a/core/site/api.go +++ b/core/site/api.go @@ -34,9 +34,11 @@ type API interface { GetVehicles() []api.Vehicle // - // Tariffs + // tariffs and costs // // GetTariff returns the respective tariff GetTariff(string) api.Tariff + GetSmartCostLimit() float64 + SetSmartCostLimit(float64) error } diff --git a/core/site_api.go b/core/site_api.go index 60fe359ac..ce20fe5f9 100644 --- a/core/site_api.go +++ b/core/site_api.go @@ -77,6 +77,24 @@ func (site *Site) SetResidualPower(power float64) error { return nil } +// GetSmartCostLimit returns the SmartCostLimit +func (site *Site) GetSmartCostLimit() float64 { + site.Lock() + defer site.Unlock() + return site.SmartCostLimit +} + +// SetSmartCostLimit sets the SmartCostLimit +func (site *Site) SetSmartCostLimit(val float64) error { + site.Lock() + defer site.Unlock() + + site.SmartCostLimit = val + site.publish("smartCostLimit", site.SmartCostLimit) + + return nil +} + // GetVehicles is the list of vehicles func (site *Site) GetVehicles() []api.Vehicle { site.Lock() diff --git a/evcc.dist.yaml b/evcc.dist.yaml index 503ed998c..868ee8bf2 100644 --- a/evcc.dist.yaml +++ b/evcc.dist.yaml @@ -108,8 +108,11 @@ site: battery: - battery # list of battery meters aux: aux # auxiliary meters for adjusting grid operating point + residualPower: # additional household usage margin prioritySoc: # give home battery priority up to this soc (empty to disable) bufferSoc: # ignore home battery discharge above soc (empty to disable) + maxGridSupplyWhileBatteryCharging: # ignore battery charging if AC consumption is above this value + smartCostLimit: # set cost limit for automatic charging in PV mode # loadpoint describes the charger, charge meter and connected vehicle loadpoints: diff --git a/hems/config.go b/hems/config.go index b9320ffdd..a5f88b15b 100644 --- a/hems/config.go +++ b/hems/config.go @@ -4,7 +4,7 @@ import ( "errors" "strings" - "github.com/evcc-io/evcc/core" + "github.com/evcc-io/evcc/core/site" "github.com/evcc-io/evcc/hems/ocpp" "github.com/evcc-io/evcc/hems/semp" "github.com/evcc-io/evcc/server" @@ -16,7 +16,7 @@ type HEMS interface { } // NewFromConfig creates new HEMS from config -func NewFromConfig(typ string, other map[string]interface{}, site *core.Site, httpd *server.HTTPd) (HEMS, error) { +func NewFromConfig(typ string, other map[string]interface{}, site site.API, httpd *server.HTTPd) (HEMS, error) { switch strings.ToLower(typ) { case "sma", "shm", "semp": return semp.New(other, site, httpd) diff --git a/schema.json b/schema.json index e8134cd30..8e126002e 100644 --- a/schema.json +++ b/schema.json @@ -200,8 +200,21 @@ } } }, - "prioritySoc": {}, - "bufferSoc": {} + "residualPower": { + "type": "number" + }, + "prioritySoc": { + "type": "number" + }, + "bufferSoc": { + "type": "number" + }, + "maxGridSupplyWhileBatteryCharging": { + "type": "number" + }, + "autoChargeCostLimit": { + "type": "number" + } } }, "loadpoints": { diff --git a/server/http.go b/server/http.go index ee7100a59..956dd4cbd 100644 --- a/server/http.go +++ b/server/http.go @@ -103,6 +103,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, cache *util.Cache) { "buffersoc": {[]string{"POST", "OPTIONS"}, "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoc, site.GetBufferSoc)}, "prioritysoc": {[]string{"POST", "OPTIONS"}, "/prioritysoc/{value:[0-9.]+}", floatHandler(site.SetPrioritySoc, site.GetPrioritySoc)}, "residualpower": {[]string{"POST", "OPTIONS"}, "/residualpower/{value:[-0-9.]+}", floatHandler(site.SetResidualPower, site.GetResidualPower)}, + "smartcost": {[]string{"POST", "OPTIONS"}, "/smartcostlimit/{value:[-0-9.]+}", floatHandler(site.SetSmartCostLimit, site.GetSmartCostLimit)}, "tariff": {[]string{"GET"}, "/tariff/{tariff:[a-z]+}", tariffHandler(site)}, "sessions": {[]string{"GET"}, "/sessions", sessionHandler}, "session1": {[]string{"PUT"}, "/session/{id:[0-9]+}", updateSessionHandler},