diff --git a/core/loadpoint.go b/core/loadpoint.go index dc36a456c..d32b7a06b 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -46,6 +46,14 @@ type Config struct { VehicleRef string `mapstructure:"vehicle"` // Vehicle reference Meters MetersConfig // Meter references + + Enable, Disable ThresholdConfig +} + +// ThresholdConfig defines enable/disable hysteresis paramters +type ThresholdConfig struct { + Delay time.Duration + Threshold float64 } // MetersConfig contains the loadpoint's meter configuration @@ -86,6 +94,8 @@ type LoadPoint struct { pvPower float64 // PV power batteryPower float64 // Battery charge power chargePower float64 // Charging power + + pvTimer time.Time } // configProvider gives access to configuration repository @@ -355,15 +365,65 @@ func (lp *LoadPoint) maxCurrent(mode api.ChargeMode) int64 { // get max charge current targetCurrent := clamp(powerToCurrent(targetPower, lp.Voltage, lp.Phases), 0, lp.MaxCurrent) - if targetCurrent < lp.MinCurrent { - switch mode { - case api.ModeMinPV: - targetCurrent = lp.MinCurrent - case api.ModePV: - targetCurrent = 0 - } + + if mode == api.ModeMinPV && targetCurrent < lp.MinCurrent { + return lp.MinCurrent } + if mode == api.ModePV && lp.enabled && targetCurrent < lp.MinCurrent { + sitePower := lp.gridPower + lp.batteryPower + + // kick off disable sequence + if sitePower >= lp.Disable.Threshold { + log.DEBUG.Printf("%s site power %.0f >= disable threshold %.0f", lp.Name, sitePower, lp.Disable.Threshold) + + if lp.pvTimer.IsZero() { + log.DEBUG.Printf("%s start disable timer", lp.Name) + lp.pvTimer = lp.clock.Now() + } + + if lp.clock.Since(lp.pvTimer) >= lp.Disable.Delay { + log.DEBUG.Printf("%s disable timer elapsed", lp.Name) + return 0 + } + } else { + // reset timer + lp.pvTimer = lp.clock.Now() + } + + return lp.MinCurrent + } + + if mode == api.ModePV && !lp.enabled { + sitePower := lp.gridPower + lp.batteryPower + + // kick off enable sequence + if targetCurrent >= lp.MinCurrent || + (lp.Enable.Threshold != 0 && sitePower <= lp.Enable.Threshold) { + log.DEBUG.Printf("%s site power %.0f < enable threshold %.0f", lp.Name, sitePower, lp.Enable.Threshold) + + if lp.pvTimer.IsZero() { + log.DEBUG.Printf("%s start enable timer", lp.Name) + lp.pvTimer = lp.clock.Now() + } + + if lp.clock.Since(lp.pvTimer) >= lp.Enable.Delay { + log.DEBUG.Printf("%s enable timer elapsed", lp.Name) + return lp.MinCurrent + } + } else { + // reset timer + lp.pvTimer = lp.clock.Now() + } + + return 0 + } + + log.DEBUG.Printf("%s timer reset", lp.Name) + + // reset pv timer + lp.pvTimer = time.Time{} + return targetCurrent } diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go index 69684de80..2bd8975ba 100644 --- a/core/loadpoint_test.go +++ b/core/loadpoint_test.go @@ -341,3 +341,146 @@ func TestConsumedPower(t *testing.T) { } } } + +func TestPVHysteresis(t *testing.T) { + dt := time.Minute + type se struct { + site float64 + delay time.Duration // test case delay since start + current int64 + } + tc := []struct { + enabled bool + enable, disable float64 + series []se + }{ + // keep disabled + {false, 0, 0, []se{ + {0, 0, 0}, + {0, 1, 0}, + {0, dt - 1, 0}, + {0, dt + 1, 0}, + }}, + // enable when threshold not configured but min power met + {false, 0, 0, []se{ + {-6 * 100 * 10, 0, 0}, + {-6 * 100 * 10, 1, 0}, + {-6 * 100 * 10, dt - 1, 0}, + {-6 * 100 * 10, dt + 1, lpMinCurrent}, + }}, + // keep disabled when threshold not configured + {false, 0, 0, []se{ + {-400, 0, 0}, + {-400, 1, 0}, + {-400, dt - 1, 0}, + {-400, dt + 1, 0}, + }}, + // keep disabled when threshold not met + {false, -500, 0, []se{ + {-400, 0, 0}, + {-400, 1, 0}, + {-400, dt - 1, 0}, + {-400, dt + 1, 0}, + }}, + // enable when threshold met + {false, -500, 0, []se{ + {-500, 0, 0}, + {-500, 1, 0}, + {-500, dt - 1, 0}, + {-500, dt + 1, lpMinCurrent}, + }}, + // keep enabled at max + {true, 500, 0, []se{ + {-16 * 100 * 10, 0, lpMaxCurrent}, + {-16 * 100 * 10, 1, lpMaxCurrent}, + {-16 * 100 * 10, dt - 1, lpMaxCurrent}, + {-16 * 100 * 10, dt + 1, lpMaxCurrent}, + }}, + // keep enabled at min + {true, 500, 0, []se{ + {-6 * 100 * 10, 0, lpMinCurrent}, + {-6 * 100 * 10, 1, lpMinCurrent}, + {-6 * 100 * 10, dt - 1, lpMinCurrent}, + {-6 * 100 * 10, dt + 1, lpMinCurrent}, + }}, + // keep enabled at min (negative threshold) + {true, 0, 500, []se{ + {-500, 0, lpMinCurrent}, + {-500, 1, lpMinCurrent}, + {-500, dt - 1, lpMinCurrent}, + {-500, dt + 1, lpMinCurrent}, + }}, + // disable when threshold met + {true, 0, 500, []se{ + {500, 0, lpMinCurrent}, + {500, 1, lpMinCurrent}, + {500, dt - 1, lpMinCurrent}, + {500, dt + 1, 0}, + }}, + // reset enable timer when threshold not met while timer active + {false, -500, 0, []se{ + {-500, 0, 0}, + {-500, 1, 0}, + {-499, dt - 1, 0}, // should reset timer + {-500, dt + 1, 0}, // new begin of timer + {-500, 2*dt - 2, 0}, + {-500, 2*dt - 1, lpMinCurrent}, + }}, + // reset enable timer when threshold not met while timer active and threshold not configured + {false, 0, 0, []se{ + {-6*100*10 - 1, dt + 1, 0}, + {-6 * 100 * 10, dt + 1, 0}, + {-6 * 100 * 10, dt + 2, 0}, + {-6 * 100 * 10, 2 * dt, 0}, + {-6 * 100 * 10, 2*dt + 2, lpMinCurrent}, + }}, + // reset disable timer when threshold not met while timer active + {true, 0, 500, []se{ + {500, 0, lpMinCurrent}, + {500, 1, lpMinCurrent}, + {499, dt - 1, lpMinCurrent}, // reset timer + {500, dt + 1, lpMinCurrent}, // within reset timer duration + {500, 2*dt - 2, lpMinCurrent}, // still within reset timer duration + {500, 2*dt - 1, 0}, // reset timer elapsed + }}, + } + + for _, tc := range tc { + t.Log(tc) + + clck := clock.NewMock() + lp := LoadPoint{ + clock: clck, + ChargerHandler: ChargerHandler{ + MinCurrent: lpMinCurrent, + MaxCurrent: lpMaxCurrent, + enabled: tc.enabled, + }, + Config: Config{ + Voltage: 100, + Phases: 10, + Enable: ThresholdConfig{ + Threshold: tc.enable, + Delay: dt, + }, + Disable: ThresholdConfig{ + Threshold: tc.disable, + Delay: dt, + }, + }, + gridPower: 0, + } + + start := clck.Now() + + for step, se := range tc.series { + clck.Set(start.Add(se.delay)) + lp.gridPower = se.site + current := lp.maxCurrent(api.ModePV) + + if current != se.current { + t.Errorf("step %d: wanted %d, got %d", step, se.current, current) + } + } + } +} diff --git a/evcc.dist.yaml b/evcc.dist.yaml index 7fde02ce4..77bc62c12 100644 --- a/evcc.dist.yaml +++ b/evcc.dist.yaml @@ -211,6 +211,12 @@ loadpoints: pv: pv # pv meter battery: battery # battery meter charge: charge # charge meter + enable: # pv mode enable behavior + delay: 1m # threshold must be exceeded for this long + threshold: 0 # minimum export power (W). If zero, export must exceeds minimum charge power to enable + disable: # pv mode disable behavior + delay: 5m # threshold must be exceeded for this long + threshold: 200 # maximum import power (W) guardduration: 10m # switch charger contactor not more often than this (default 10m) maxcurrent: 16 # maximum charge current (default 16A) phases: 3 # ev phases (default 3)