Improve loadpoint api: add dynamic limits (#1058)

This commit is contained in:
andig 2021-06-05 14:14:56 +02:00 • committed by GitHub
parent adb67a9277
commit c2425489de
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 123 additions and 75 deletions

View file

@ -64,7 +64,7 @@ type ChargeState interface {
Status() (ChargeStatus, error)
}
// Charger is able to provide current charging status and to enable/disabler charging
// Charger is able to provide current charging status and enable/disable charging
type Charger interface {
ChargeState
Enabled() (bool, error)

View file

@ -90,8 +90,8 @@ type LoadPoint struct {
}
Enable, Disable ThresholdConfig
MinCurrent int64 // PV mode: start current Min+PV mode: min current
MaxCurrent int64 // Max allowed current. Physically ensured by the charger
MinCurrent float64 // PV mode: start current Min+PV mode: min current
MaxCurrent float64 // Max allowed current. Physically ensured by the charger
GuardDuration time.Duration // charger enable/disable minimum holding time
enabled bool // Charger enabled state
@ -363,7 +363,7 @@ func (lp *LoadPoint) evChargeCurrentHandler(current float64) {
func (lp *LoadPoint) evChargeCurrentWrappedMeterHandler(current float64) {
power := current * float64(lp.Phases) * Voltage
if !lp.enabled || lp.status != api.StatusC {
if !lp.enabled || lp.GetStatus() != api.StatusC {
// if disabled we cannot be charging
power = 0
}
@ -423,7 +423,7 @@ func (lp *LoadPoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
if lp.enabled = enabled; enabled {
lp.guardUpdated = lp.clock.Now()
// set defined current for use by pv mode
_ = lp.setLimit(float64(lp.MinCurrent), false)
_ = lp.setLimit(lp.GetMinCurrent(), false)
}
} else {
lp.log.ERROR.Printf("charger: %v", err)
@ -435,11 +435,18 @@ func (lp *LoadPoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
}
}
// syncCharger updates charger status and synchronizes it with expectations
func (lp *LoadPoint) syncCharger() {
enabled, err := lp.charger.Enabled()
if err == nil && enabled != lp.enabled {
lp.log.WARN.Printf("charger out of sync: expected %vd, got %vd", status[lp.enabled], status[enabled])
err = lp.charger.Enable(lp.enabled)
if err == nil {
if enabled != lp.enabled {
lp.log.WARN.Printf("charger out of sync: expected %vd, got %vd", status[lp.enabled], status[enabled])
err = lp.charger.Enable(lp.enabled)
}
if !enabled && lp.GetStatus() == api.StatusC {
lp.log.WARN.Println("charger logic error: disabled but charging")
}
}
if err != nil {
@ -447,9 +454,10 @@ func (lp *LoadPoint) syncCharger() {
}
}
// setLimit applies charger current limits and enables/disables accordingly
func (lp *LoadPoint) setLimit(chargeCurrent float64, force bool) (err error) {
// set current
if chargeCurrent != lp.chargeCurrent && chargeCurrent >= float64(lp.MinCurrent) {
if chargeCurrent != lp.chargeCurrent && chargeCurrent >= lp.GetMinCurrent() {
if charger, ok := lp.charger.(api.ChargerEx); ok {
lp.log.DEBUG.Printf("max charge current: %.2g", chargeCurrent)
err = charger.MaxCurrentMillis(chargeCurrent)
@ -467,7 +475,7 @@ func (lp *LoadPoint) setLimit(chargeCurrent float64, force bool) (err error) {
}
// set enabled
if enabled := chargeCurrent >= float64(lp.MinCurrent); enabled != lp.enabled && err == nil {
if enabled := chargeCurrent >= lp.GetMinCurrent(); enabled != lp.enabled && err == nil {
if remaining := (lp.GuardDuration - lp.clock.Since(lp.guardUpdated)).Truncate(time.Second); remaining > 0 && !force {
lp.log.DEBUG.Printf("charger %s: contactor delay %v", status[enabled], remaining)
return nil
@ -503,12 +511,20 @@ func (lp *LoadPoint) setLimit(chargeCurrent float64, force bool) (err error) {
// connected returns the EVs connection state
func (lp *LoadPoint) connected() bool {
return lp.status == api.StatusB || lp.status == api.StatusC
status := lp.GetStatus()
return status == api.StatusB || status == api.StatusC
}
// charging returns the EVs charging state
func (lp *LoadPoint) charging() bool {
return lp.status == api.StatusC
return lp.GetStatus() == api.StatusC
}
// charging returns the EVs charging state
func (lp *LoadPoint) setStatus(status api.ChargeStatus) {
lp.Lock()
defer lp.Unlock()
lp.status = status
}
// targetSocReached checks if target is configured and reached.
@ -662,8 +678,8 @@ func (lp *LoadPoint) updateChargerStatus() error {
lp.log.DEBUG.Printf("charger status: %s", status)
if prevStatus := lp.status; status != prevStatus {
lp.status = status
if prevStatus := lp.GetStatus(); status != prevStatus {
lp.setStatus(status)
// changed from empty (initial startup) - set connected without sending message
if prevStatus == api.StatusNone {
@ -703,9 +719,10 @@ func (lp *LoadPoint) effectiveCurrent() float64 {
return lp.chargeCurrents[0]
}
if lp.status != api.StatusC {
if lp.GetStatus() != api.StatusC {
return 0
}
return lp.chargeCurrent
}
@ -719,17 +736,18 @@ func (lp *LoadPoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64) float6
// calculate target charge current from delta power and actual current
effectiveCurrent := lp.effectiveCurrent()
deltaCurrent := powerToCurrent(-sitePower, lp.Phases)
targetCurrent := math.Max(math.Min(effectiveCurrent+deltaCurrent, float64(lp.MaxCurrent)), 0)
targetCurrent := math.Max(math.Min(effectiveCurrent+deltaCurrent, lp.GetMaxCurrent()), 0)
lp.log.DEBUG.Printf("max charge current: %.1fA = %.1fA + %.1fA (%.0fW @ %dp)", targetCurrent, effectiveCurrent, deltaCurrent, sitePower, lp.Phases)
// in MinPV mode return at least minCurrent
if mode == api.ModeMinPV && targetCurrent < float64(lp.MinCurrent) {
return float64(lp.MinCurrent)
minCurrent := lp.GetMinCurrent()
if mode == api.ModeMinPV && targetCurrent < minCurrent {
return minCurrent
}
// read only once to simplify testing
if mode == api.ModePV && lp.enabled && targetCurrent < float64(lp.MinCurrent) {
if mode == api.ModePV && lp.enabled && targetCurrent < minCurrent {
// kick off disable sequence
if sitePower >= lp.Disable.Threshold {
lp.log.DEBUG.Printf("site power %.0fW >= disable threshold %.0fW", sitePower, lp.Disable.Threshold)
@ -748,15 +766,17 @@ func (lp *LoadPoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64) float6
lp.log.DEBUG.Printf("pv disable timer remaining: %v", (lp.Disable.Delay - elapsed).Round(time.Second))
} else {
// reset timer
lp.log.DEBUG.Printf("reset pv disable timer: %v", lp.Disable.Delay)
lp.pvTimer = lp.clock.Now()
}
return float64(lp.MinCurrent)
lp.log.DEBUG.Println("pv enable timer: keep enabled")
return minCurrent
}
if mode == api.ModePV && !lp.enabled {
// kick off enable sequence
if (lp.Enable.Threshold == 0 && targetCurrent >= float64(lp.MinCurrent)) ||
if (lp.Enable.Threshold == 0 && targetCurrent >= minCurrent) ||
(lp.Enable.Threshold != 0 && sitePower <= lp.Enable.Threshold) {
lp.log.DEBUG.Printf("site power %.0fW < enable threshold %.0fW", sitePower, lp.Enable.Threshold)
@ -768,15 +788,17 @@ func (lp *LoadPoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64) float6
elapsed := lp.clock.Since(lp.pvTimer)
if elapsed >= lp.Enable.Delay {
lp.log.DEBUG.Println("pv enable timer elapsed")
return float64(lp.MinCurrent)
return minCurrent
}
lp.log.DEBUG.Printf("pv enable timer remaining: %v", (lp.Enable.Delay - elapsed).Round(time.Second))
} else {
// reset timer
lp.log.DEBUG.Printf("reset pv enable timer: %v", lp.Enable.Delay)
lp.pvTimer = lp.clock.Now()
}
lp.log.DEBUG.Println("pv enable timer: keep disabled")
return 0
}
@ -980,7 +1002,7 @@ func (lp *LoadPoint) Update(sitePower float64) {
var targetCurrent float64 // zero disables
if lp.climateActive() {
lp.log.DEBUG.Println("climater active")
targetCurrent = float64(lp.MinCurrent)
targetCurrent = lp.GetMinCurrent()
}
err = lp.setLimit(targetCurrent, true)
lp.socTimer.Reset() // once SoC is reached, the target charge request is removed
@ -994,11 +1016,11 @@ func (lp *LoadPoint) Update(sitePower float64) {
err = lp.setLimit(0, true)
case lp.minSocNotReached():
err = lp.setLimit(float64(lp.MaxCurrent), true)
err = lp.setLimit(lp.GetMaxCurrent(), true)
lp.pvDisableTimer() // let PV mode disable immediately afterwards
case mode == api.ModeNow:
err = lp.setLimit(float64(lp.MaxCurrent), true)
err = lp.setLimit(lp.GetMaxCurrent(), true)
// target charging
case lp.socTimer.StartRequired():
@ -1011,7 +1033,7 @@ func (lp *LoadPoint) Update(sitePower float64) {
var required bool // false
if targetCurrent == 0 && lp.climateActive() {
targetCurrent = float64(lp.MinCurrent)
targetCurrent = lp.GetMaxCurrent()
required = true
}

View file

@ -17,6 +17,9 @@ type LoadPointAPI interface {
Name() string
HasChargeMeter() bool
// status
GetStatus() api.ChargeStatus
// settings
GetMode() api.ChargeMode
SetMode(api.ChargeMode)
@ -28,10 +31,19 @@ type LoadPointAPI interface {
RemoteControl(string, RemoteDemand)
// energy
GetMinCurrent() int64
GetMaxCurrent() int64
GetMinPower() int64
GetMaxPower() int64
GetMinCurrent() float64
SetMinCurrent(float64)
GetMaxCurrent() float64
SetMaxCurrent(float64)
GetMinPower() float64
GetMaxPower() float64
}
// GetStatus returns the charging status
func (lp *LoadPoint) GetStatus() api.ChargeStatus {
lp.Lock()
defer lp.Unlock()
return lp.status
}
// GetMode returns loadpoint charge mode
@ -158,22 +170,48 @@ func (lp *LoadPoint) HasChargeMeter() bool {
return lp.chargeMeter != nil && !isWrapped
}
// GetMinCurrent returns the minimal loadpoint current
func (lp *LoadPoint) GetMinCurrent() int64 {
// GetMinCurrent returns the min loadpoint current
func (lp *LoadPoint) GetMinCurrent() float64 {
lp.Lock()
defer lp.Unlock()
return lp.MinCurrent
}
// GetMaxCurrent returns the minimal loadpoint current
func (lp *LoadPoint) GetMaxCurrent() int64 {
// SetMinCurrent returns the min loadpoint current
func (lp *LoadPoint) SetMinCurrent(current float64) {
lp.Lock()
defer lp.Unlock()
if current != lp.MinCurrent {
lp.MinCurrent = current
lp.publish("minCurrent", lp.MinCurrent)
}
}
// GetMaxCurrent returns the max loadpoint current
func (lp *LoadPoint) GetMaxCurrent() float64 {
lp.Lock()
defer lp.Unlock()
return lp.MaxCurrent
}
// GetMinPower returns the minimal loadpoint power for a single phase
func (lp *LoadPoint) GetMinPower() int64 {
return int64(Voltage) * lp.MinCurrent
// SetMaxCurrent returns the max loadpoint current
func (lp *LoadPoint) SetMaxCurrent(current float64) {
lp.Lock()
defer lp.Unlock()
if current != lp.MaxCurrent {
lp.MaxCurrent = current
lp.publish("maxCurrent", lp.MaxCurrent)
}
}
// GetMaxPower returns the minimal loadpoint power taking active phases into account
func (lp *LoadPoint) GetMaxPower() int64 {
return int64(Voltage) * lp.Phases * lp.MaxCurrent
// GetMinPower returns the min loadpoint power for a single phase
func (lp *LoadPoint) GetMinPower() float64 {
return Voltage * lp.GetMinCurrent()
}
// GetMaxPower returns the max loadpoint power taking active phases into account
func (lp *LoadPoint) GetMaxPower() float64 {
return Voltage * lp.GetMaxCurrent() * float64(lp.Phases)
}

View file

@ -15,8 +15,8 @@ import (
)
const (
minA int64 = 6
maxA int64 = 16
minA float64 = 6
maxA float64 = 16
)
type Null struct{}
@ -62,7 +62,7 @@ func attachListeners(t *testing.T, lp *LoadPoint) {
if charger, ok := lp.charger.(*mock.MockCharger); ok && charger != nil {
charger.EXPECT().Enabled().Return(true, nil)
charger.EXPECT().MaxCurrent(lp.MinCurrent).Return(nil)
charger.EXPECT().MaxCurrent(int64(lp.MinCurrent)).Return(nil)
}
lp.Prepare(uiChan, pushChan, lpChan)
@ -111,7 +111,7 @@ func TestUpdatePowerZero(t *testing.T) {
h.EXPECT().Enable(false)
}},
{api.StatusB, api.ModeNow, func(h *mock.MockCharger) {
h.EXPECT().MaxCurrent(maxA) // true
h.EXPECT().MaxCurrent(int64(maxA)) // true
}},
{api.StatusB, api.ModeMinPV, func(h *mock.MockCharger) {
// MaxCurrent omitted since identical value
@ -126,7 +126,7 @@ func TestUpdatePowerZero(t *testing.T) {
h.EXPECT().Enable(false)
}},
{api.StatusC, api.ModeNow, func(h *mock.MockCharger) {
h.EXPECT().MaxCurrent(maxA) // true
h.EXPECT().MaxCurrent(int64(maxA)) // true
}},
{api.StatusC, api.ModeMinPV, func(h *mock.MockCharger) {
// MaxCurrent omitted since identical value
@ -179,7 +179,7 @@ func TestPVHysteresis(t *testing.T) {
type se struct {
site float64
delay time.Duration // test case delay since start
current int64
current float64
}
tc := []struct {
enabled bool
@ -325,8 +325,8 @@ func TestPVHysteresis(t *testing.T) {
lp.enabled = tc.enabled
current := lp.pvMaxCurrent(api.ModePV, se.site)
if current != float64(se.current) {
t.Errorf("step %d: wanted %d, got %.f", step, se.current, current)
if current != se.current {
t.Errorf("step %d: wanted %.1f, got %.1f", step, se.current, current)
}
}
@ -351,10 +351,8 @@ func TestPVHysteresisForStatusOtherThanC(t *testing.T) {
// not connected, test PV mode logic short-circuited
lp.status = api.StatusA
// maxCurrent will read actual current in PV mode
// maxCurrent will read enabled state in PV mode
sitePower := -float64(minA*lp.Phases)*Voltage + 1 // 1W below min power
sitePower := -float64(lp.Phases)*minA*Voltage + 1 // 1W below min power
current := lp.pvMaxCurrent(api.ModePV, sitePower)
if current != 0 {
@ -405,7 +403,7 @@ func TestDisableAndEnableAtTargetSoC(t *testing.T) {
vehicle.EXPECT().SoC().Return(85.0, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().MaxCurrent(maxA).Return(nil)
charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
lp.Update(500)
t.Log("charging above target - soc deactivates charger")
@ -474,7 +472,7 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) {
t.Log("charging at min")
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
charger.EXPECT().MaxCurrent(maxA).Return(nil)
charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
lp.Update(500)
t.Log("switch off when disconnected")

View file

@ -23,7 +23,7 @@ type Adapter interface {
type Timer struct {
Adapter
log *util.Logger
maxCurrent int64
maxCurrent float64
current float64
SoC int
Time time.Time
@ -32,7 +32,7 @@ type Timer struct {
}
// NewTimer creates a Timer
func NewTimer(log *util.Logger, adapter Adapter, maxCurrent int64) *Timer {
func NewTimer(log *util.Logger, adapter Adapter, maxCurrent float64) *Timer {
lp := &Timer{
log: log,
Adapter: adapter,
@ -64,7 +64,7 @@ func (lp *Timer) StartRequired() bool {
return false
}
power := float64(lp.maxCurrent*lp.ActivePhases()) * lp.Voltage()
power := float64(lp.ActivePhases()) * lp.maxCurrent * lp.Voltage()
// time
remainingDuration := se.RemainingChargeDuration(power, lp.SoC)

View file

@ -6,6 +6,7 @@ import (
"strings"
"time"
"github.com/andig/evcc/api"
"github.com/andig/evcc/core"
"github.com/andig/evcc/hems/ocpp/profile"
"github.com/andig/evcc/util"
@ -81,14 +82,12 @@ func (s *OCPP) errorHandler(errC <-chan error) {
// Run executes the OCPP chargepoint client
func (s *OCPP) Run() {
for {
for id := range s.site.LoadPoints() {
for id, lp := range s.site.LoadPoints() {
connector := id + 1
status := ocppcore.ChargePointStatusAvailable
if statusP, err := s.cache.GetChecked(id, "charging"); err == nil {
if statusP.Val.(bool) {
status = ocppcore.ChargePointStatusCharging
}
if lp.GetStatus() == api.StatusC {
status = ocppcore.ChargePointStatusCharging
}
s.log.TRACE.Printf("send: lp-%d status: %+v", connector, status)

View file

@ -428,21 +428,12 @@ func (s *SEMP) allDeviceStatus() (res []DeviceStatus) {
return res
}
// TODO remove GetChecked function
func (s *SEMP) planningRequest(id int, lp core.LoadPointAPI) (res PlanningRequest) {
mode := api.ModeOff
if modeP, err := s.cache.GetChecked(id, "mode"); err == nil {
mode = modeP.Val.(api.ChargeMode)
}
var connected bool
if connectedP, err := s.cache.GetChecked(id, "connected"); err == nil {
connected = connectedP.Val.(bool)
}
var charging bool
if chargingP, err := s.cache.GetChecked(id, "charging"); err == nil {
charging = chargingP.Val.(bool)
}
mode := lp.GetMode()
charging := lp.GetStatus() == api.StatusC
connected := charging || lp.GetStatus() == api.StatusB
chargeEstimate := time.Duration(-1)
if chargeEstimateP, err := s.cache.GetChecked(id, "chargeEstimate"); err == nil {