Add charge planner (#5445)

This commit is contained in:
andig 2022-12-23 10:52:54 +01:00 • committed by GitHub
parent 03d6d1418a
commit a9666b57ee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 701 additions and 423 deletions

View file

@ -12,7 +12,7 @@ import (
"github.com/fatih/structs"
)
//go:generate mockgen -package mock -destination ../mock/mock_api.go github.com/evcc-io/evcc/api Charger,ChargeState,PhaseSwitcher,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery
//go:generate mockgen -package mock -destination ../mock/mock_api.go github.com/evcc-io/evcc/api Charger,ChargeState,PhaseSwitcher,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery,Tariff
// ChargeMode is the charge operation mode. Valid values are off, now, minpv and pv
type ChargeMode string
@ -218,10 +218,18 @@ type Resurrector interface {
WakeUp() error
}
// Tariff is the grid tariff
// Rate is a grid tariff rate
type Rate struct {
Start, End time.Time
Price float64
}
// Rates is a slice of (future) tariff rates
type Rates []Rate
// Tariff is a tariff capable of retrieving tariff rates
type Tariff interface {
IsCheap() (bool, error)
CurrentPrice() (float64, error) // EUR/kWh, CHF/kWh, ...
Rates() (Rates, error)
}
// AuthProvider is the ability to provide OAuth authentication through the ui

View file

@ -2,8 +2,10 @@ package api
import (
"encoding"
"errors"
"fmt"
"strings"
"time"
)
// ChargeModeString converts string to ChargeMode
@ -36,3 +38,30 @@ func (c *ChargeMode) UnmarshalText(text []byte) error {
return nil
}
// Current returns the rates current rate or error
func (r Rates) Current(now time.Time) (Rate, error) {
for _, rr := range r {
if (rr.Start.Before(now) || rr.Start.Equal(now)) && rr.End.After(now) {
return rr, nil
}
}
return Rate{}, errors.New("no matching rate")
}
// implement sort.Interface
func (r Rates) Len() int {
return len(r)
}
func (r Rates) Less(i, j int) bool {
if r[i].Price == r[j].Price {
return r[i].Start.After(r[j].Start)
}
return r[i].Price < r[j].Price
}
func (r Rates) Swap(i, j int) {
r[i], r[j] = r[j], r[i]
}

28
api/impl_test.go Normal file
View file

@ -0,0 +1,28 @@
package api
import (
"sort"
"testing"
"time"
"github.com/benbjohnson/clock"
"github.com/stretchr/testify/assert"
)
func TestRatesSort(t *testing.T) {
clock := clock.NewMock()
r := Rates{
{
Price: 1,
Start: clock.Now(),
},
{
Price: 1,
Start: clock.Now().Add(time.Hour),
},
}
sort.Sort(r)
assert.Equal(t, clock.Now(), r[1].Start)
}

View file

@ -9,5 +9,11 @@ const (
vehicleRange = "vehicleRange" // vehicle range
vehicleOdometer = "vehicleOdometer" // vehicle odometer
vehicleSoc = "vehicleSoc" // vehicle soc
vehicleTargetSoc = "vehicleTargetSoc" // vehicle soc limit
minSoc = "minSoc" // min soc goal
targetSoc = "targetSoc" // target charging soc goal
targetTime = "targetTime" // target charging finish time goal
targetTimeActive = "targetTimeActive" // target charging plan has determined current slot to be an active slot
)

View file

@ -14,6 +14,7 @@ import (
"github.com/evcc-io/evcc/core/coordinator"
"github.com/evcc-io/evcc/core/db"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/planner"
"github.com/evcc-io/evcc/core/soc"
"github.com/evcc-io/evcc/core/wrapper"
"github.com/evcc-io/evcc/provider"
@ -139,7 +140,12 @@ type Loadpoint struct {
defaultVehicle api.Vehicle // Default vehicle (disables detection)
coordinator coordinator.API
socEstimator *soc.Estimator
socTimer *soc.Timer
// target charging
planner *planner.Planner
targetTime time.Time // time goal
planSlotEnd time.Time // current plan slot end time
planActive bool // plan is active
// cached state
status api.ChargeStatus // Charger status
@ -287,9 +293,6 @@ func NewLoadpoint(log *util.Logger) *Loadpoint {
tasks: util.NewQueue[Task](), // task queue
}
// allow target charge handler to access loadpoint
lp.socTimer = soc.NewTimer(lp.log, &adapter{Loadpoint: lp})
return lp
}
@ -478,8 +481,8 @@ func (lp *Loadpoint) evVehicleDisconnectHandler() {
// soc update reset
lp.socUpdated = time.Time{}
// reset timer when vehicle is removed
lp.socTimer.Reset()
// reset plan once charge goal is met
lp.setPlanActive(false)
}
// evVehicleSocProgressHandler sends external start event
@ -570,8 +573,8 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
lp.Lock()
lp.publish("mode", lp.Mode)
lp.publish("targetSoc", lp.Soc.target)
lp.publish("minSoc", lp.Soc.min)
lp.publish(targetSoc, lp.Soc.target)
lp.publish(minSoc, lp.Soc.min)
lp.Unlock()
// reset detection state
@ -702,11 +705,16 @@ func (lp *Loadpoint) setStatus(status api.ChargeStatus) {
lp.status = status
}
// remainingChargeEnergy returns missing energy amount in kWh if vehicle has a valid energy target
func (lp *Loadpoint) remainingChargeEnergy() (float64, bool) {
return float64(lp.targetEnergy) - lp.getChargedEnergy()/1e3,
(lp.vehicle == nil || lp.vehicleHasFeature(api.Offline)) && lp.targetEnergy > 0
}
// targetEnergyReached checks if target is configured and reached
func (lp *Loadpoint) targetEnergyReached() bool {
return (lp.vehicle == nil || lp.vehicleHasFeature(api.Offline)) &&
lp.targetEnergy > 0 &&
lp.getChargedEnergy()/1e3 >= float64(lp.targetEnergy)
f, ok := lp.remainingChargeEnergy()
return ok && f <= 0
}
// targetSocReached checks if target is configured and reached.
@ -726,6 +734,60 @@ func (lp *Loadpoint) minSocNotReached() bool {
lp.vehicleSoc < float64(lp.Soc.min)
}
// setPlanActive updates plan active flag
func (lp *Loadpoint) setPlanActive(active bool) {
if !active {
lp.planSlotEnd = time.Time{}
}
lp.planActive = active
lp.publish("planActive", lp.planActive)
}
// plannerActive checks if charging plan is active
func (lp *Loadpoint) plannerActive() (active bool) {
defer func() {
lp.publish(targetTimeActive, active)
}()
if lp.planner == nil || lp.targetTime.IsZero() {
return false
}
var requiredDuration time.Duration
if energy, ok := lp.remainingChargeEnergy(); ok {
if energy > 0 {
requiredDuration = time.Duration(energy * 1e3 / lp.GetMaxPower() * float64(time.Hour))
}
} else {
// TODO vehicle soc limit
targetSoc := 100
if lp.Soc.target > 0 {
targetSoc = lp.Soc.target
}
requiredDuration = lp.socEstimator.RemainingChargeDuration(targetSoc, lp.GetMaxPower())
}
requiredDuration = time.Duration(float64(requiredDuration) / soc.ChargeEfficiency)
slotEnd, active, err := lp.planner.Active(requiredDuration, lp.targetTime)
if err != nil {
lp.log.ERROR.Println("planner:", err)
return false
}
// if the plan did not (entirely) work, we may still be charging beyond plan end- in that case, continue charging
if active {
// remember last active plan's end time
lp.setPlanActive(true)
lp.planSlotEnd = slotEnd
} else if lp.planActive && ((lp.clock.Now().Before(lp.planSlotEnd) && !lp.planSlotEnd.IsZero()) ||
(lp.clock.Now().After(lp.targetTime) && !lp.targetTime.IsZero())) {
// if slot still not completed or past target time continue charging
active = true
}
return active
}
// climateActive checks if vehicle has active climate request
func (lp *Loadpoint) climateActive() bool {
if cl, ok := lp.vehicle.(api.VehicleClimater); ok {
@ -764,7 +826,10 @@ func (lp *Loadpoint) disableUnlessClimater() error {
lp.log.DEBUG.Println("climater active")
current = lp.GetMinCurrent()
}
lp.socTimer.Reset() // once Soc is reached, the target charge request is removed
// reset plan once charge goal is met
lp.setPlanActive(false)
return lp.setLimit(current, true)
}
@ -946,11 +1011,12 @@ func (lp *Loadpoint) wakeUpVehicle() {
func (lp *Loadpoint) unpublishVehicle() {
lp.vehicleSoc = 0
lp.publish("vehicleSoc", 0.0)
lp.publish(vehicleSoc, 0.0)
lp.publish(vehicleRange, int64(0))
lp.publish(vehicleTargetSoc, 0.0)
lp.setRemainingDuration(-1)
lp.setRemainingEnergy(0)
lp.setRemainingDuration(0)
lp.vehiclePublishFeature(api.Offline)
}
@ -1579,22 +1645,15 @@ func (lp *Loadpoint) socProvidedByCharger() bool {
// publish state of charge, remaining charge duration and range
func (lp *Loadpoint) publishSocAndRange() {
// guard for socEstimator removed by api
if lp.socEstimator == nil {
return
}
if lp.socPollAllowed() || lp.socProvidedByCharger() {
var f float64
var err error
// guard for socEstimator removed by api
if se := lp.socEstimator; se != nil {
lp.socUpdated = lp.clock.Now()
f, err = se.Soc(lp.getChargedEnergy())
} else {
return
}
lp.socUpdated = lp.clock.Now()
f, err := lp.socEstimator.Soc(lp.getChargedEnergy())
if err != nil {
if errors.Is(err, api.ErrMustRetry) {
lp.socUpdated = time.Time{}
@ -1607,41 +1666,41 @@ func (lp *Loadpoint) publishSocAndRange() {
lp.vehicleSoc = math.Trunc(f)
lp.log.DEBUG.Printf("vehicle soc: %.0f%%", lp.vehicleSoc)
lp.publish("vehicleSoc", lp.vehicleSoc)
lp.publish(vehicleSoc, lp.vehicleSoc)
// vehicle target soc
targetSoc := 100.0
targetSoc := 100
if vs, ok := lp.vehicle.(api.SocLimiter); ok {
var err error
if targetSoc, err = vs.TargetSoc(); err == nil {
lp.log.DEBUG.Printf("vehicle target soc: %.0f%%", targetSoc)
lp.publish(vehicleTargetSoc, targetSoc)
if limit, err := vs.TargetSoc(); err == nil {
targetSoc = int(math.Trunc(limit))
lp.log.DEBUG.Printf("vehicle soc limit: %.0f%%", limit)
lp.publish(vehicleTargetSoc, limit)
} else {
lp.log.ERROR.Printf("vehicle soc limit: %v", err)
}
}
// use minimum of vehicle and loadpoint
socLimit := int(math.Round(targetSoc))
socLimit := targetSoc
if lp.Soc.target < socLimit {
socLimit = lp.Soc.target
}
if se := lp.socEstimator; se != nil {
if lp.charging() {
lp.setRemainingDuration(se.RemainingChargeDuration(lp.chargePower, socLimit))
} else {
lp.setRemainingDuration(-1)
}
var d time.Duration
if lp.charging() {
d = lp.socEstimator.RemainingChargeDuration(socLimit, lp.chargePower)
}
lp.SetRemainingDuration(d)
if se := lp.socEstimator; se != nil {
lp.setRemainingEnergy(1e3 * se.RemainingChargeEnergy(socLimit))
}
lp.SetRemainingEnergy(1e3 * lp.socEstimator.RemainingChargeEnergy(socLimit))
// range
if vs, ok := lp.vehicle.(api.VehicleRange); ok {
if rng, err := vs.Range(); err == nil {
lp.log.DEBUG.Printf("vehicle range: %dkm", rng)
lp.publish(vehicleRange, rng)
} else {
lp.log.ERROR.Printf("vehicle range: %v", err)
}
}
@ -1674,7 +1733,7 @@ func (lp *Loadpoint) processTasks() {
}
// Update is the main control function. It reevaluates meters and charger state
func (lp *Loadpoint) Update(sitePower float64, cheap, batteryBuffered bool) {
func (lp *Loadpoint) Update(sitePower float64, batteryBuffered bool) {
lp.processTasks()
mode := lp.GetMode()
@ -1724,9 +1783,6 @@ func (lp *Loadpoint) Update(sitePower float64, cheap, batteryBuffered bool) {
// track if remote disabled is actually active
remoteDisabled := loadpoint.RemoteEnable
// reset detection if soc timer needs be deactivated after evaluating the loading strategy
lp.socTimer.MustValidateDemand()
// execute loading strategy
switch {
case !lp.connected():
@ -1762,20 +1818,13 @@ func (lp *Loadpoint) Update(sitePower float64, cheap, batteryBuffered bool) {
}
lp.elapsePVTimer() // let PV mode disable immediately afterwards
case mode == api.ModeNow:
// immediate or target charging
case mode == api.ModeNow || lp.plannerActive():
// 3p if available
if err = lp.scalePhasesIfAvailable(3); err == nil {
err = lp.setLimit(lp.GetMaxCurrent(), true)
}
// target charging
case lp.socTimer.DemandActive():
// 3p if available
if err = lp.scalePhasesIfAvailable(3); err == nil {
targetCurrent := lp.socTimer.Handle()
err = lp.setLimit(targetCurrent, true)
}
case mode == api.ModeMinPV || mode == api.ModePV:
targetCurrent := lp.pvMaxCurrent(mode, sitePower, batteryBuffered)
@ -1786,13 +1835,6 @@ func (lp *Loadpoint) Update(sitePower float64, cheap, batteryBuffered bool) {
required = true
}
// tariff
if cheap {
targetCurrent = lp.GetMaxCurrent()
lp.log.DEBUG.Printf("cheap tariff: %.3gA", targetCurrent)
required = true
}
// Sunny Home Manager
if lp.remoteControlled(loadpoint.RemoteSoftDisable) {
remoteDisabled = loadpoint.RemoteSoftDisable
@ -1809,11 +1851,6 @@ func (lp *Loadpoint) Update(sitePower float64, cheap, batteryBuffered bool) {
lp.wakeUpVehicle()
}
// stop an active target charging session if not currently evaluated
if !lp.socTimer.DemandValidated() {
lp.socTimer.Stop()
}
// effective disabled status
if remoteDisabled != loadpoint.RemoteEnable {
lp.publish("remoteDisabled", remoteDisabled)

View file

@ -1,19 +0,0 @@
package core
import (
"github.com/evcc-io/evcc/core/soc"
)
var _ soc.Adapter = (*adapter)(nil)
type adapter struct {
*Loadpoint
}
func (a *adapter) Publish(key string, val interface{}) {
a.Loadpoint.publish(key, val)
}
func (a *adapter) SocEstimator() *soc.Estimator {
return a.Loadpoint.socEstimator
}

View file

@ -74,10 +74,6 @@ func (lp *Loadpoint) GetTargetEnergy() float64 {
// setTargetEnergy sets loadpoint charge target energy (no mutex)
func (lp *Loadpoint) setTargetEnergy(energy float64) {
lp.targetEnergy = energy
// TODO soctimer
// if lp.socTimer != nil {
// lp.socTimer.Energy = energy
// }
lp.publish("targetEnergy", energy)
}
@ -105,11 +101,7 @@ func (lp *Loadpoint) GetTargetSoc() int {
// setTargetSoc sets loadpoint charge target soc (no mutex)
func (lp *Loadpoint) setTargetSoc(soc int) {
lp.Soc.target = soc
// test guard
if lp.socTimer != nil {
lp.socTimer.Soc = soc
}
lp.publish("targetSoc", soc)
lp.publish(targetSoc, soc)
}
// SetTargetSoc sets loadpoint charge target soc
@ -136,7 +128,7 @@ func (lp *Loadpoint) GetMinSoc() int {
// setMinSoc sets loadpoint charge min soc (no mutex)
func (lp *Loadpoint) setMinSoc(soc int) {
lp.Soc.min = soc
lp.publish("minSoc", soc)
lp.publish(minSoc, soc)
}
// SetMinSoc sets loadpoint charge minimum soc
@ -197,8 +189,8 @@ func (lp *Loadpoint) SetTargetCharge(finishAt time.Time, soc int) error {
lp.log.DEBUG.Printf("set target charge: %d @ %v", soc, finishAt)
// apply immediately
if lp.socTimer.Time != finishAt || lp.Soc.target != soc {
lp.socTimer.Set(finishAt)
if !lp.targetTime.Equal(finishAt) || lp.Soc.target != soc {
lp.setTargetTime(finishAt)
// don't remove soc
if !finishAt.IsZero() {
@ -210,6 +202,19 @@ func (lp *Loadpoint) SetTargetCharge(finishAt time.Time, soc int) error {
return nil
}
// SetTargetTime sets the charge target time
func (lp *Loadpoint) SetTargetTime(finishAt time.Time) {
lp.Lock()
defer lp.Unlock()
lp.setTargetTime(finishAt)
}
// setTargetTime sets the charge target time
func (lp *Loadpoint) setTargetTime(finishAt time.Time) {
lp.targetTime = finishAt
lp.publish(targetTime, finishAt)
}
// RemoteControl sets remote status demand
func (lp *Loadpoint) RemoteControl(source string, demand loadpoint.RemoteDemand) {
lp.Lock()
@ -291,7 +296,14 @@ func (lp *Loadpoint) GetMaxPower() float64 {
return Voltage * lp.GetMaxCurrent() * float64(lp.maxActivePhases())
}
// setRemainingDuration sets the estimated remaining charging duration
// SetRemainingDuration sets the estimated remaining charging duration
func (lp *Loadpoint) SetRemainingDuration(chargeRemainingDuration time.Duration) {
lp.Lock()
defer lp.Unlock()
lp.setRemainingDuration(chargeRemainingDuration)
}
// setRemainingDuration sets the estimated remaining charging duration (no mutex)
func (lp *Loadpoint) setRemainingDuration(chargeRemainingDuration time.Duration) {
if lp.chargeRemainingDuration != chargeRemainingDuration {
lp.chargeRemainingDuration = chargeRemainingDuration
@ -306,11 +318,15 @@ func (lp *Loadpoint) GetRemainingDuration() time.Duration {
return lp.chargeRemainingDuration
}
// setRemainingEnergy sets the remaining charge energy in Wh
func (lp *Loadpoint) setRemainingEnergy(chargeRemainingEnergy float64) {
// SetRemainingEnergy sets the remaining charge energy in Wh
func (lp *Loadpoint) SetRemainingEnergy(chargeRemainingEnergy float64) {
lp.Lock()
defer lp.Unlock()
lp.setRemainingEnergy(chargeRemainingEnergy)
}
// setRemainingEnergy sets the remaining charge energy in Wh (no mutex)
func (lp *Loadpoint) setRemainingEnergy(chargeRemainingEnergy float64) {
if lp.chargeRemainingEnergy != chargeRemainingEnergy {
lp.chargeRemainingEnergy = chargeRemainingEnergy
lp.publish("chargeRemainingEnergy", chargeRemainingEnergy)

View file

@ -180,7 +180,7 @@ func TestUpdatePowerZero(t *testing.T) {
}
lp.Mode = tc.mode
lp.Update(0, false, false) // sitePower 0
lp.Update(0, false) // sitePower 0
ctrl.Finish()
}
@ -425,7 +425,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, false)
lp.Update(500, false)
t.Log("charging above target - soc deactivates charger")
clock.Add(5 * time.Minute)
@ -433,20 +433,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, false)
lp.Update(500, 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, false)
lp.Update(-5000, 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, false)
lp.Update(-5000, false)
t.Log("soc has fallen below target - soc update timer expired")
clock.Add(pollInterval)
@ -454,7 +454,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, false)
lp.Update(-5000, false)
ctrl.Finish()
}
@ -494,14 +494,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, false)
lp.Update(500, 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, false)
lp.Update(-3000, false)
if lp.Mode != api.ModeOff {
t.Error("unexpected mode", lp.Mode)
@ -563,14 +563,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, false)
lp.Update(-1, 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, false)
lp.Update(-1, false)
expectCache("chargedEnergy", 5000.0)
t.Log("at 1:00h stop charging at 5 kWh")
@ -578,7 +578,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, false)
lp.Update(-1, false)
expectCache("chargedEnergy", 5000.0)
t.Log("at 1:00h restart charging at 5 kWh")
@ -586,7 +586,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, false)
lp.Update(-1, false)
expectCache("chargedEnergy", 5000.0)
t.Log("at 1:30h continue charging at 7.5 kWh")
@ -594,7 +594,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, false)
lp.Update(-1, false)
expectCache("chargedEnergy", 7500.0)
t.Log("at 2:00h stop charging at 10 kWh")
@ -602,7 +602,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, false)
lp.Update(-1, false)
expectCache("chargedEnergy", 10000.0)
ctrl.Finish()

View file

@ -372,7 +372,7 @@ func TestReconnectVehicle(t *testing.T) {
// vehicle not updated yet
vehicle.MockChargeState.EXPECT().Status().Return(api.StatusA, nil)
lp.Update(0, false, false)
lp.Update(0, false)
ctrl.Finish()
// detection started
@ -390,7 +390,7 @@ func TestReconnectVehicle(t *testing.T) {
// vehicle not updated yet
vehicle.MockChargeState.EXPECT().Status().Return(api.StatusB, nil)
lp.Update(0, false, false)
lp.Update(0, false)
ctrl.Finish()
// vehicle detected

122
core/planner/planner.go Normal file
View file

@ -0,0 +1,122 @@
package planner
import (
"sort"
"time"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/soc"
"github.com/evcc-io/evcc/util"
)
// Planner plans a series of charging slots for a given (variable) tariff
type Planner struct {
log *util.Logger
clock clock.Clock // mockable time
tariff api.Tariff
}
// New creates a price planner
func New(log *util.Logger, tariff api.Tariff) *Planner {
return &Planner{
log: log,
clock: clock.New(),
tariff: tariff,
}
}
// Active determines if current slot should be used for charging for a total required duration until target time
func (t *Planner) Active(requiredDuration time.Duration, targetTime time.Time) (time.Time, bool, error) {
if t == nil || requiredDuration <= 0 {
return time.Time{}, false, nil
}
// calculate start time
requiredDuration = time.Duration(float64(requiredDuration) / soc.ChargeEfficiency)
latestStart := targetTime.Add(-requiredDuration)
startElapsed := t.clock.Now().After(latestStart)
// target charging without tariff
if t.tariff == nil || startElapsed {
return time.Time{}, startElapsed, nil
}
rates, err := t.tariff.Rates()
if err != nil {
return time.Time{}, false, err
}
// treat like normal target charging if we don't have rates
if len(rates) == 0 {
return time.Time{}, startElapsed, nil
}
// rates are by default sorted by date, oldest to newest
last := rates[len(rates)-1].End
// sort rates by price and time
sort.Sort(rates)
// reduce planning horizon to available rates
if targetTime.After(last) {
// there is enough time for charging after end of current rates
durationAfterRates := targetTime.Sub(last)
if durationAfterRates >= requiredDuration {
return time.Time{}, false, nil
}
// need to use some of the available slots
t.log.DEBUG.Printf("target time beyond available slots- reducing plan horizon from %v to %v", requiredDuration.Round(time.Minute), durationAfterRates.Round(time.Minute))
targetTime = last
requiredDuration -= durationAfterRates
}
t.log.DEBUG.Printf("planning %s until %v", requiredDuration.Round(time.Minute), targetTime.Round(time.Minute))
var active bool
var plannedSlots, currentSlot int
var planDuration time.Duration
var planSlotEnd time.Time
var planCost float64
for _, slot := range rates {
// slot not relevant
if slot.Start.After(targetTime) || slot.Start.Equal(targetTime) || slot.End.Before(t.clock.Now()) {
continue
}
plannedSlots++
// slot covers current timestamp
if (slot.Start.Before(t.clock.Now()) || slot.Start.Equal(t.clock.Now())) && slot.End.After(t.clock.Now()) {
active = true
slot.Start = t.clock.Now()
planSlotEnd = slot.End
currentSlot = plannedSlots
}
planDuration += slot.End.Sub(slot.Start)
planCost += float64(slot.End.Sub(slot.Start)) / float64(time.Hour) * slot.Price
t.log.TRACE.Printf(" slot from: %v to %v cost %.2f, duration running total %s, active: %t",
slot.Start.Round(time.Minute), slot.End.Round(time.Minute),
slot.Price, planDuration.Round(time.Second), active)
// we found all necessary cheap slots
if planDuration >= requiredDuration {
break
}
}
// delay start of most expensive slot if it is not the last and only slot
if currentSlot == plannedSlots && plannedSlots > 1 && planDuration > requiredDuration {
t.log.DEBUG.Printf("delaying expensive slot for %s", (planDuration - requiredDuration).Round(time.Minute))
active = false
}
t.log.DEBUG.Printf("total plan duration: %v, cost: %.2f", planDuration.Round(time.Minute), planCost)
return planSlotEnd, active, nil
}

View file

@ -0,0 +1,209 @@
package planner
import (
"testing"
"time"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/mock"
"github.com/evcc-io/evcc/util"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
)
func rates(prices []float64, start time.Time) api.Rates {
res := make(api.Rates, 0, len(prices))
for i, v := range prices {
slotStart := start.Add(time.Duration(i) * time.Hour)
ar := api.Rate{
Start: slotStart,
End: slotStart.Add(1 * time.Hour),
Price: v,
}
res = append(res, ar)
}
return res
}
func TestPlanner(t *testing.T) {
dt := time.Hour
ctrl := gomock.NewController(t)
type se struct {
delay time.Duration
cDuration time.Duration
res bool
}
tc := []struct {
desc string
prices []float64
end time.Duration
series []se
}{
{"falling prices", []float64{5, 4, 3, 2, 1, 0, 0, 0, 10}, 5 * time.Hour, []se{
{1*dt - 1, 20 * time.Minute, false},
{2*dt - 1, 20 * time.Minute, false},
{3*dt - 1, 20 * time.Minute, false},
{3 * dt, 20 * time.Minute, false},
{4*dt - 1, 20 * time.Minute, false},
{4*dt - 30*time.Minute, 20 * time.Minute, false}, // start as late as possible
{5*dt - 20*time.Minute, 20 * time.Minute, true},
{5 * dt, 5 * time.Minute, true}, // after desired charge timer,
}},
{"rising prices", []float64{1, 2, 3, 4, 5, 6, 7, 8}, 5 * time.Hour, []se{
{1*dt - 1, time.Hour, true},
{2*dt - 1, 5 * time.Minute, true}, // charging took longer than expected
{3*dt - 1, 0, false},
{5 * dt, 0, false}, // after desired charge timer
}},
{"last slot", []float64{5, 2, 5, 4, 3, 5, 5, 5, 10}, 5 * time.Hour, []se{
{1*dt - 1, 70 * time.Minute, false},
{2*dt - 1, 70 * time.Minute, true},
{3*dt - 1, 20 * time.Minute, false},
{4*dt - 1, 20 * time.Minute, false},
{4 * dt, 20 * time.Minute, true}, // start as late as possible
{4*dt + 40*time.Minute, 20 * time.Minute, true},
}},
{"don't pause last slot", []float64{5, 4, 5, 3, 2, 5, 5, 5, 10}, 5 * time.Hour, []se{
{1*dt - 1, 70 * time.Minute, false},
{2*dt - 1, 70 * time.Minute, false},
{3*dt - 1, 70 * time.Minute, false},
{4*dt - 1, 20 * time.Minute, false},
{4 * dt, 20 * time.Minute, true}, // don't pause last slot
}},
{"delay expensive middle", []float64{5, 4, 3, 5, 5, 5, 5, 5, 10}, 5 * time.Hour, []se{
{1*dt - 1, 70 * time.Minute, false},
{1 * dt, 70 * time.Minute, false},
{2*dt - 1, 61 * time.Minute, true}, // delayed start on expensive slot
{3*dt - 1, 60 * time.Minute, true}, // cheapest slot
}},
{"fixed tariff", []float64{2}, 30 * time.Minute, []se{
{1, 2 * time.Hour, true},
{1, 10 * time.Minute, true},
}},
{"always expensive", []float64{5, 4, 3, 2, 1, 0, 0, 0, 10}, 5 * time.Hour, []se{
{1*dt - 1, time.Minute, false},
{2*dt - 1, time.Minute, false},
{3*dt - 1, time.Minute, false},
{4*dt - 1, time.Minute, false},
{5*dt - 1, time.Minute, true}, // cheapest price
}},
}
clck := clock.NewMock()
for _, tc := range tc {
t.Run(tc.desc, func(t *testing.T) {
t.Logf("set: %v", clck.Now())
trf := mock.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates(tc.prices, clck.Now()), nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clck,
tariff: trf,
}
start := clck.Now()
for idx, se := range tc.series {
clck.Set(start.Add(se.delay))
_, res, err := p.Active(se.cDuration, start.Add(tc.end))
assert.NoError(t, err)
assert.Equalf(t, se.res, res, "%s case %d: expected %v, got %v", tc.desc, idx+1, se.res, res)
}
})
}
}
func TestNilTariff(t *testing.T) {
clck := clock.NewMock()
p := &Planner{
log: util.NewLogger("foo"),
clock: clck,
}
_, res, err := p.Active(time.Hour, clck.Now().Add(30*time.Minute))
assert.NoError(t, err)
assert.True(t, res, "should start past start time")
_, res, err = p.Active(time.Hour, clck.Now().Add(-30*time.Minute))
assert.NoError(t, err)
assert.True(t, res, "should start past target time")
}
func TestFlatTariffTargetInThePast(t *testing.T) {
clck := clock.NewMock()
ctrl := gomock.NewController(t)
trf := mock.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clck.Now()), nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clck,
tariff: trf,
}
_, res, err := p.Active(time.Hour, clck.Now().Add(30*time.Minute))
assert.NoError(t, err)
assert.True(t, res, "should start past start time")
_, res, err = p.Active(time.Hour, clck.Now().Add(-30*time.Minute))
assert.NoError(t, err)
assert.True(t, res, "should start past target time")
}
func TestTargetAfterKnownPrices(t *testing.T) {
clck := clock.NewMock()
ctrl := gomock.NewController(t)
trf := mock.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clck.Now()), nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clck,
tariff: trf,
}
_, res, err := p.Active(40*time.Minute, clck.Now().Add(2*time.Hour)) // charge efficiency does not allow to test with 1h
assert.NoError(t, err)
assert.False(t, res, "should not start if car can be charged completely after known prices ")
_, res, err = p.Active(2*time.Hour, clck.Now().Add(2*time.Hour))
assert.NoError(t, err)
assert.True(t, res, "should plan if car can not be charged completely after known prices ")
}
func TestChargeAfterTargetTime(t *testing.T) {
clck := clock.NewMock()
ctrl := gomock.NewController(t)
trf := mock.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0, 0, 0, 0}, clck.Now()), nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clck,
tariff: trf,
}
_, res, err := p.Active(time.Minute, clck.Now())
assert.NoError(t, err)
assert.True(t, res, "should start when target time reached and car not fully charged")
_, res, err = p.Active(time.Hour, clck.Now().Add(-time.Hour))
assert.NoError(t, err)
assert.True(t, res, "should start when target time past and car not fully charged")
_, res, err = p.Active(0, clck.Now().Add(-time.Hour))
assert.NoError(t, err)
assert.False(t, res, "should not start when fully charged")
}

View file

@ -5,6 +5,7 @@ import (
"time"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/tariff"
)
@ -113,22 +114,23 @@ func (s *Savings) shareOfSelfProducedEnergy(gridPower, pvPower, batteryPower flo
return share
}
func (s *Savings) currentGridPrice() float64 {
if s.tariffs.Grid != nil {
if gridPrice, err := s.tariffs.Grid.CurrentPrice(); err == nil {
return gridPrice
func currentPrice(t api.Tariff, dfltPrice float64) float64 {
if t != nil {
if rr, err := t.Rates(); err == nil {
if r, err := rr.Current(time.Now()); err == nil {
return r.Price
}
}
}
return DefaultGridPrice
return dfltPrice
}
func (s *Savings) currentGridPrice() float64 {
return currentPrice(s.tariffs.Grid, DefaultGridPrice)
}
func (s *Savings) currentFeedInPrice() float64 {
if s.tariffs.FeedIn != nil {
if gridPrice, err := s.tariffs.FeedIn.CurrentPrice(); err == nil {
return gridPrice
}
}
return DefaultFeedInPrice
return currentPrice(s.tariffs.FeedIn, DefaultFeedInPrice)
}
func (s *Savings) updatePrices(p publisher) (float64, float64) {

View file

@ -13,6 +13,7 @@ import (
"github.com/evcc-io/evcc/core/coordinator"
"github.com/evcc-io/evcc/core/db"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/planner"
"github.com/evcc-io/evcc/push"
serverdb "github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/tariff"
@ -24,7 +25,7 @@ const standbyPower = 10 // consider less than 10W as charger in standby
// Updater abstracts the Loadpoint implementation for testing
type Updater interface {
Update(availablePower float64, cheapRate, batteryBuffered bool)
Update(availablePower float64, batteryBuffered bool)
}
// meterMeasurement is used as slice element for publishing structured data
@ -64,7 +65,7 @@ type Site struct {
tariffs tariff.Tariffs // Tariff
loadpoints []*Loadpoint // Loadpoints
coordinator *coordinator.Coordinator // Savings
coordinator *coordinator.Coordinator // Vehicles
savings *Savings // Savings
// cached state
@ -130,6 +131,13 @@ func NewSiteFromConfig(
for _, lp := range loadpoints {
lp.coordinator = coordinator.NewAdapter(lp, site.coordinator)
// planner
gridTariff := site.tariffs.Grid
if gridTariff == nil {
gridTariff = new(tariff.Fixed)
}
lp.planner = planner.New(lp.log, gridTariff)
if serverdb.Instance != nil {
var err error
if lp.db, err = db.New(lp.Title); err != nil {
@ -141,6 +149,7 @@ func NewSiteFromConfig(
}
}
// grid meter
if site.Meters.GridMeterRef != "" {
var err error
if site.gridMeter, err = cp.Meter(site.Meters.GridMeterRef); err != nil {
@ -419,7 +428,7 @@ func (site *Site) updateMeters() error {
site.batterySoc /= float64(len(site.batteryMeters))
site.log.DEBUG.Printf("battery soc: %.0f%%", math.Round(site.batterySoc))
site.publish("batterySoC", math.Round(site.batterySoc))
site.publish("batterySoc", math.Round(site.batterySoc))
site.log.DEBUG.Printf("battery power: %.0fW", site.batteryPower)
site.publish("batteryPower", site.batteryPower)
@ -488,7 +497,7 @@ func (site *Site) sitePower(totalChargePower float64) (float64, error) {
batteryPower = 0
}
// if battery is discharging above bufferSoC ignore it
// if battery is discharging above bufferSoc ignore it
site.batteryBuffered = batteryPower > 0 && site.BufferSoc > 0 && site.batterySoc > site.BufferSoc
}
@ -502,15 +511,6 @@ func (site *Site) sitePower(totalChargePower float64) (float64, error) {
func (site *Site) update(lp Updater) {
site.log.DEBUG.Println("----")
var cheap bool
var err error
if site.tariffs.Grid != nil {
cheap, err = site.tariffs.Grid.IsCheap()
if err != nil {
cheap = false
}
}
// update all loadpoint's charge power
var totalChargePower float64
for _, lp := range site.loadpoints {
@ -519,7 +519,7 @@ func (site *Site) update(lp Updater) {
}
if sitePower, err := site.sitePower(totalChargePower); err == nil {
lp.Update(sitePower, cheap, site.batteryBuffered)
lp.Update(sitePower, site.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

View file

@ -1,10 +0,0 @@
package soc
import "github.com/evcc-io/evcc/core/loadpoint"
// Adapter provides the required methods for interacting with the loadpoint
type Adapter interface {
loadpoint.API
Publish(key string, val interface{})
SocEstimator() *Estimator
}

View file

@ -9,7 +9,7 @@ import (
"github.com/evcc-io/evcc/util"
)
const chargeEfficiency = 0.9 // assume charge 90% efficiency
const ChargeEfficiency = 0.9 // assume charge 90% efficiency
// Estimator provides vehicle soc and charge duration
// Vehicle Soc can be estimated to provide more granularity
@ -49,47 +49,17 @@ func (s *Estimator) Reset() {
s.prevChargedEnergy = 0
s.initialSoc = 0
s.capacity = float64(s.vehicle.Capacity()) * 1e3 // cache to simplify debugging
s.virtualCapacity = s.capacity / chargeEfficiency // initial capacity taking efficiency into account
s.virtualCapacity = s.capacity / ChargeEfficiency // initial capacity taking efficiency into account
s.energyPerSocStep = s.virtualCapacity / 100
}
// AssumedChargeDuration estimates charge duration up to targetSoc based on virtual capacity
func (s *Estimator) AssumedChargeDuration(targetSoc int, chargePower float64) time.Duration {
percentRemaining := float64(targetSoc) - s.vehicleSoc
if percentRemaining <= 0 || s.virtualCapacity <= 0 {
return 0
// RemainingChargeDuration returns the estimated remaining duration
func (s *Estimator) RemainingChargeDuration(targetSoc int, chargePower float64) time.Duration {
hours := s.RemainingChargeEnergy(targetSoc) * 1e3 / chargePower
if math.IsInf(hours, 0) {
hours = 0
}
whRemaining := percentRemaining / 100 * s.virtualCapacity
return time.Duration(float64(time.Hour) * whRemaining / chargePower).Round(time.Second)
}
// RemainingChargeDuration returns the remaining duration estimate based on Soc, target and charge power
func (s *Estimator) RemainingChargeDuration(chargePower float64, targetSoc int) time.Duration {
if chargePower > 0 {
percentRemaining := float64(targetSoc) - s.vehicleSoc
if percentRemaining <= 0 {
return 0
}
// use vehicle api if available
if vr, ok := s.vehicle.(api.VehicleFinishTimer); ok {
finishTime, err := vr.FinishTime()
if err == nil {
timeRemaining := time.Until(finishTime)
return time.Duration(float64(timeRemaining) * percentRemaining / (100 - s.vehicleSoc))
}
if !errors.Is(err, api.ErrNotAvailable) {
s.log.WARN.Printf("updating remaining time failed: %v", err)
}
}
return s.AssumedChargeDuration(targetSoc, chargePower)
}
return -1
return time.Duration(float64(time.Hour) * hours).Round(time.Second)
}
// RemainingChargeEnergy returns the remaining charge energy in kWh

View file

@ -24,8 +24,8 @@ func TestRemainingChargeDuration(t *testing.T) {
chargePower := 1000.0
targetSoc := 80
if remaining := ce.RemainingChargeDuration(chargePower, targetSoc); remaining != 6*time.Hour {
t.Error("wrong remaining charge duration")
if remaining := ce.RemainingChargeDuration(targetSoc, chargePower); remaining != 6*time.Hour {
t.Errorf("wrong remaining charge duration: %v", remaining)
}
}
@ -106,7 +106,7 @@ func TestSocEstimation(t *testing.T) {
remainingHours := (float64(targetSoc) - soc) / 100 * tc.virtualCapacity / chargePower
remainingDuration := time.Duration(float64(time.Hour) * remainingHours).Round(time.Second)
if rm := ce.RemainingChargeDuration(chargePower, targetSoc); rm != remainingDuration {
if rm := ce.RemainingChargeDuration(targetSoc, chargePower); rm != remainingDuration {
t.Errorf("expected estimated duration: %v, got: %v", remainingDuration, rm)
}
}
@ -202,7 +202,7 @@ func TestSocFromChargerAndVehicleWithErrors(t *testing.T) {
remainingHours := (float64(targetSoc) - soc) / 100 * tc.virtualCapacity / chargePower
remainingDuration := time.Duration(float64(time.Hour) * remainingHours).Round(time.Second)
if rm := ce.RemainingChargeDuration(chargePower, targetSoc); rm != remainingDuration {
if rm := ce.RemainingChargeDuration(targetSoc, chargePower); rm != remainingDuration {
t.Errorf("expected estimated duration: %v, got: %v", remainingDuration, rm)
}
}

View file

@ -1,169 +0,0 @@
package soc
import (
"math"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
)
const (
deviation = 30 * time.Minute
)
// Timer is the target charging handler
type Timer struct {
Adapter
log *util.Logger
current float64
Soc int
Time time.Time
finishAt time.Time
active bool
validated bool
}
// NewTimer creates a Timer
func NewTimer(log *util.Logger, api Adapter) *Timer {
lp := &Timer{
log: log,
Adapter: api,
}
return lp
}
// MustValidateDemand resets the flag for detecting if DemandActive has been called
func (lp *Timer) MustValidateDemand() {
if lp == nil {
return
}
lp.validated = false
}
// DemandValidated returns if DemandActive has been called
func (lp *Timer) DemandValidated() bool {
if lp == nil {
return false
}
return lp.validated
}
// Stop stops the target charging request
func (lp *Timer) Stop() {
if lp == nil {
return
}
if lp.active {
lp.active = false
lp.Publish("targetTimeActive", lp.active)
lp.log.DEBUG.Println("target charging: disable")
}
}
// Set sets the target charging time
func (lp *Timer) Set(t time.Time) {
if lp == nil {
return
}
lp.Time = t
if lp.Time.IsZero() {
lp.Publish("targetTime", nil)
lp.Publish("targetTimeProjectedStart", nil)
} else {
lp.Publish("targetTime", lp.Time)
}
}
// Reset resets the target charging request
func (lp *Timer) Reset() {
if lp == nil {
return
}
lp.Set(time.Time{})
lp.Stop()
}
// DemandActive calculates remaining charge duration and returns true if charge start is required to achieve target soc in time
func (lp *Timer) DemandActive() bool {
if lp == nil || lp.Time.IsZero() {
return false
}
// demand validation has been called
lp.validated = true
// power
power := lp.GetMaxPower()
if lp.active {
power *= lp.current / lp.GetMaxCurrent()
}
se := lp.SocEstimator()
if se == nil {
lp.log.WARN.Println("target charging: not possible")
return false
}
// time
remainingDuration := time.Duration(float64(se.AssumedChargeDuration(lp.Soc, power)) / chargeEfficiency)
lp.finishAt = time.Now().Add(remainingDuration).Round(time.Minute)
lp.log.DEBUG.Printf("estimated charge duration: %v to %d%% at %.0fW", remainingDuration.Round(time.Minute), lp.Soc, power)
if lp.active {
lp.log.DEBUG.Printf("projected end: %v", lp.finishAt)
lp.log.DEBUG.Printf("desired finish time: %v", lp.Time)
lp.Publish("targetTimeProjectedStart", nil)
} else {
projectedStart := lp.Time.Add(-remainingDuration)
lp.log.DEBUG.Printf("projected start: %v", projectedStart)
lp.Publish("targetTimeProjectedStart", projectedStart)
}
// timer charging is already active- only deactivate once charging has stopped
if lp.active {
if time.Now().After(lp.Time) && lp.GetStatus() != api.StatusC {
lp.Stop()
}
return lp.active
}
// check if charging need be activated
if active := lp.finishAt.After(lp.Time); active {
lp.active = active
lp.Publish("targetTimeActive", lp.active)
lp.current = lp.GetMaxCurrent()
lp.log.INFO.Printf("target charging active for %v: projected %v (%v remaining)", lp.Time.Local(), lp.finishAt.Local(), remainingDuration.Round(time.Minute))
}
return lp.active
}
// Handle adjusts current up/down to achieve desired target time taking.
func (lp *Timer) Handle() float64 {
action := "steady"
switch {
case lp.finishAt.Before(lp.Time.Add(-deviation)):
lp.current--
action = "slowdown"
case lp.finishAt.After(lp.Time):
lp.current++
action = "speedup"
}
lp.current = math.Max(math.Min(lp.current, lp.GetMaxCurrent()), lp.GetMinCurrent())
lp.log.DEBUG.Printf("target charging: %s (%.3gA)", action, lp.current)
return lp.current
}

View file

@ -24,9 +24,9 @@ type Subscription struct {
ID string
Status string
PriceInfo struct {
Current PriceInfo
Today []PriceInfo
// Tomorrow []PriceInfo
Current PriceInfo
Today []PriceInfo
Tomorrow []PriceInfo
}
}

View file

@ -1,5 +1,5 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,PhaseSwitcher,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery)
// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,PhaseSwitcher,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery,Tariff)
// Package mock is a generated GoMock package.
package mock
@ -478,3 +478,41 @@ func (mr *MockBatteryMockRecorder) Soc() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Soc", reflect.TypeOf((*MockBattery)(nil).Soc))
}
// MockTariff is a mock of Tariff interface.
type MockTariff struct {
ctrl *gomock.Controller
recorder *MockTariffMockRecorder
}
// MockTariffMockRecorder is the mock recorder for MockTariff.
type MockTariffMockRecorder struct {
mock *MockTariff
}
// NewMockTariff creates a new mock instance.
func NewMockTariff(ctrl *gomock.Controller) *MockTariff {
mock := &MockTariff{ctrl: ctrl}
mock.recorder = &MockTariffMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockTariff) EXPECT() *MockTariffMockRecorder {
return m.recorder
}
// Rates mocks base method.
func (m *MockTariff) Rates() (api.Rates, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Rates")
ret0, _ := ret[0].(api.Rates)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Rates indicates an expected call of Rates.
func (mr *MockTariffMockRecorder) Rates() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Rates", reflect.TypeOf((*MockTariff)(nil).Rates))
}

View file

@ -1,7 +1,6 @@
package tariff
import (
"errors"
"fmt"
"strings"
"sync"
@ -14,18 +13,17 @@ import (
)
type Awattar struct {
mux sync.Mutex
log *util.Logger
uri string
cheap float64
data []awattar.PriceInfo
mux sync.Mutex
log *util.Logger
uri string
data api.Rates
}
var _ api.Tariff = (*Awattar)(nil)
func NewAwattar(other map[string]interface{}) (*Awattar, error) {
cc := struct {
Cheap float64
Cheap any // TODO deprecated
Region string
}{
Region: "DE",
@ -36,9 +34,13 @@ func NewAwattar(other map[string]interface{}) (*Awattar, error) {
}
t := &Awattar{
log: util.NewLogger("awattar"),
cheap: cc.Cheap,
uri: fmt.Sprintf(awattar.RegionURI, strings.ToLower(cc.Region)),
log: util.NewLogger("awattar"),
uri: fmt.Sprintf(awattar.RegionURI, strings.ToLower(cc.Region)),
}
// TODO deprecated
if cc.Cheap != nil {
t.log.WARN.Println("cheap rate configuration has been replaced by target charging and is deprecated")
}
go t.Run()
@ -57,27 +59,24 @@ func (t *Awattar) Run() {
}
t.mux.Lock()
t.data = res.Data
t.data = make(api.Rates, 0, len(res.Data))
for _, r := range res.Data {
ar := api.Rate{
Start: r.StartTimestamp,
End: r.EndTimestamp,
Price: r.Marketprice / 1e3,
}
t.data = append(t.data, ar)
}
t.mux.Unlock()
}
}
func (t *Awattar) CurrentPrice() (float64, error) {
// Rates implements the api.Tariff interface
func (t *Awattar) Rates() (api.Rates, error) {
t.mux.Lock()
defer t.mux.Unlock()
for i := len(t.data) - 1; i >= 0; i-- {
pi := t.data[i]
if pi.StartTimestamp.Before(time.Now()) && pi.EndTimestamp.After(time.Now()) {
return pi.Marketprice / 1000, nil // convert EUR/MWh to EUR/KWh
}
}
return 0, errors.New("unable to find current awattar price")
}
func (t *Awattar) IsCheap() (bool, error) {
price, err := t.CurrentPrice()
return price <= t.cheap, err
return append([]api.Rate{}, t.data...), nil
}

View file

@ -1,6 +1,8 @@
package tariff
import (
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
)
@ -21,10 +23,14 @@ func NewFixed(other map[string]interface{}) (*Fixed, error) {
return &cc, nil
}
func (t *Fixed) CurrentPrice() (float64, error) {
return t.Price, nil
}
// Rates implements the api.Tariff interface
func (t *Fixed) Rates() (api.Rates, error) {
start := time.Now().Truncate(time.Hour)
rr := api.Rates{{
Start: start,
End: start.Add(time.Duration(24*7) * time.Hour),
Price: t.Price,
}}
func (t *Fixed) IsCheap() (bool, error) {
return false, nil
return rr, nil
}

View file

@ -11,8 +11,6 @@ type Tariffs struct {
FeedIn api.Tariff
}
var _ api.Tariff = (*Fixed)(nil)
func NewTariffs(currency currency.Unit, grid api.Tariff, feedin api.Tariff) *Tariffs {
t := Tariffs{}
t.Currency = currency

View file

@ -17,9 +17,8 @@ type Tibber struct {
mux sync.Mutex
log *util.Logger
homeID string
cheap float64
client *tibber.Client
data []tibber.PriceInfo
data api.Rates
}
var _ api.Tariff = (*Tibber)(nil)
@ -28,7 +27,7 @@ func NewTibber(other map[string]interface{}) (*Tibber, error) {
var cc struct {
Token string
HomeID string
Cheap float64
Cheap any // TODO deprecated
}
if err := util.DecodeOther(other, &cc); err != nil {
@ -44,7 +43,6 @@ func NewTibber(other map[string]interface{}) (*Tibber, error) {
t := &Tibber{
log: log,
homeID: cc.HomeID,
cheap: cc.Cheap,
client: tibber.NewClient(log, cc.Token),
}
@ -55,6 +53,11 @@ func NewTibber(other map[string]interface{}) (*Tibber, error) {
}
}
// TODO deprecated
if cc.Cheap != nil {
t.log.WARN.Println("cheap rate configuration has been replaced by target charging and is deprecated")
}
go t.Run()
return t, nil
@ -86,26 +89,31 @@ func (t *Tibber) Run() {
}
t.mux.Lock()
t.data = res.Viewer.Home.CurrentSubscription.PriceInfo.Today
pi := res.Viewer.Home.CurrentSubscription.PriceInfo
t.data = make(api.Rates, 0, len(pi.Today)+len(pi.Tomorrow))
t.data = append(t.rates(pi.Today), t.rates(pi.Tomorrow)...)
t.mux.Unlock()
}
}
func (t *Tibber) CurrentPrice() (float64, error) {
func (t *Tibber) rates(pi []tibber.PriceInfo) api.Rates {
data := make(api.Rates, 0, len(pi))
for _, r := range pi {
ar := api.Rate{
Start: r.StartsAt,
End: r.StartsAt.Add(time.Hour),
Price: r.Total,
}
data = append(data, ar)
}
return data
}
// Rates implements the api.Tariff interface
func (t *Tibber) Rates() (api.Rates, error) {
t.mux.Lock()
defer t.mux.Unlock()
for i := len(t.data) - 1; i >= 0; i-- {
pi := t.data[i]
if pi.StartsAt.Before(time.Now()) {
return pi.Total, nil
}
}
return 0, errors.New("unable to find current tibber price")
}
func (t *Tibber) IsCheap() (bool, error) {
price, err := t.CurrentPrice()
return price <= t.cheap, err
return append([]api.Rate{}, t.data...), nil
}