Add charge planner (#5445)
This commit is contained in:
parent
03d6d1418a
commit
a9666b57ee
23 changed files with 701 additions and 423 deletions
16
api/api.go
16
api/api.go
|
|
@ -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
|
||||
|
|
|
|||
29
api/impl.go
29
api/impl.go
|
|
@ -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
28
api/impl_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
122
core/planner/planner.go
Normal 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
|
||||
}
|
||||
209
core/planner/planner_test.go
Normal file
209
core/planner/planner_test.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
28
core/site.go
28
core/site.go
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue