From a9666b57ee1cafbafe81d4a98ecba2c8bcb1d297 Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 23 Dec 2022 10:52:54 +0100 Subject: [PATCH] Add charge planner (#5445) --- api/api.go | 16 ++- api/impl.go | 29 +++++ api/impl_test.go | 28 +++++ core/const.go | 6 + core/loadpoint.go | 167 ++++++++++++++++---------- core/loadpoint_adapter.go | 19 --- core/loadpoint_api.go | 46 +++++--- core/loadpoint_test.go | 28 ++--- core/loadpoint_vehicle_test.go | 4 +- core/planner/planner.go | 122 +++++++++++++++++++ core/planner/planner_test.go | 209 +++++++++++++++++++++++++++++++++ core/savings.go | 24 ++-- core/site.go | 28 ++--- core/soc/adapter.go | 10 -- core/soc/estimator.go | 46 ++------ core/soc/estimator_test.go | 8 +- core/soc/timer.go | 169 -------------------------- meter/tibber/types.go | 6 +- mock/mock_api.go | 40 ++++++- tariff/awattar.go | 53 ++++----- tariff/fixed.go | 16 ++- tariff/tariffs.go | 2 - tariff/tibber.go | 48 ++++---- 23 files changed, 701 insertions(+), 423 deletions(-) create mode 100644 api/impl_test.go delete mode 100644 core/loadpoint_adapter.go create mode 100644 core/planner/planner.go create mode 100644 core/planner/planner_test.go delete mode 100644 core/soc/adapter.go delete mode 100644 core/soc/timer.go diff --git a/api/api.go b/api/api.go index 4c6ebbf26..0e5d381c0 100644 --- a/api/api.go +++ b/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 diff --git a/api/impl.go b/api/impl.go index 3cc5ad6c0..b20212f31 100644 --- a/api/impl.go +++ b/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] +} diff --git a/api/impl_test.go b/api/impl_test.go new file mode 100644 index 000000000..f90bcd220 --- /dev/null +++ b/api/impl_test.go @@ -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) +} diff --git a/core/const.go b/core/const.go index 963555c59..bfd0bb607 100644 --- a/core/const.go +++ b/core/const.go @@ -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 ) diff --git a/core/loadpoint.go b/core/loadpoint.go index 59619ccea..ad9002589 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -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) diff --git a/core/loadpoint_adapter.go b/core/loadpoint_adapter.go deleted file mode 100644 index 175f74c8a..000000000 --- a/core/loadpoint_adapter.go +++ /dev/null @@ -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 -} diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index 6251d5ff6..97968581a 100644 --- a/core/loadpoint_api.go +++ b/core/loadpoint_api.go @@ -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) diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go index a08007b5b..4fe17c423 100644 --- a/core/loadpoint_test.go +++ b/core/loadpoint_test.go @@ -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() diff --git a/core/loadpoint_vehicle_test.go b/core/loadpoint_vehicle_test.go index 38282dc19..f13981f20 100644 --- a/core/loadpoint_vehicle_test.go +++ b/core/loadpoint_vehicle_test.go @@ -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 diff --git a/core/planner/planner.go b/core/planner/planner.go new file mode 100644 index 000000000..864b4279b --- /dev/null +++ b/core/planner/planner.go @@ -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 +} diff --git a/core/planner/planner_test.go b/core/planner/planner_test.go new file mode 100644 index 000000000..0ad889dfb --- /dev/null +++ b/core/planner/planner_test.go @@ -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") +} diff --git a/core/savings.go b/core/savings.go index 9cf181ccb..93d4e08b7 100644 --- a/core/savings.go +++ b/core/savings.go @@ -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) { diff --git a/core/site.go b/core/site.go index fc04652f4..85a7d5040 100644 --- a/core/site.go +++ b/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 diff --git a/core/soc/adapter.go b/core/soc/adapter.go deleted file mode 100644 index 6605a2977..000000000 --- a/core/soc/adapter.go +++ /dev/null @@ -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 -} diff --git a/core/soc/estimator.go b/core/soc/estimator.go index 0c46d8139..f014f67f2 100644 --- a/core/soc/estimator.go +++ b/core/soc/estimator.go @@ -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 diff --git a/core/soc/estimator_test.go b/core/soc/estimator_test.go index 2128d3d56..4a41033e5 100644 --- a/core/soc/estimator_test.go +++ b/core/soc/estimator_test.go @@ -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) } } diff --git a/core/soc/timer.go b/core/soc/timer.go deleted file mode 100644 index a6fbbff95..000000000 --- a/core/soc/timer.go +++ /dev/null @@ -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 -} diff --git a/meter/tibber/types.go b/meter/tibber/types.go index 36b52fba1..8c669d488 100644 --- a/meter/tibber/types.go +++ b/meter/tibber/types.go @@ -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 } } diff --git a/mock/mock_api.go b/mock/mock_api.go index 7affb237d..4cc75eac3 100644 --- a/mock/mock_api.go +++ b/mock/mock_api.go @@ -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)) +} diff --git a/tariff/awattar.go b/tariff/awattar.go index 2549e450f..485663ca5 100644 --- a/tariff/awattar.go +++ b/tariff/awattar.go @@ -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 } diff --git a/tariff/fixed.go b/tariff/fixed.go index 7d365b120..78dfe318c 100644 --- a/tariff/fixed.go +++ b/tariff/fixed.go @@ -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 } diff --git a/tariff/tariffs.go b/tariff/tariffs.go index 3aedb207c..0bcb72f48 100644 --- a/tariff/tariffs.go +++ b/tariff/tariffs.go @@ -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 diff --git a/tariff/tibber.go b/tariff/tibber.go index 670d674de..f80634bd6 100644 --- a/tariff/tibber.go +++ b/tariff/tibber.go @@ -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 }