Skip to content

Commit

Permalink
Add target charging (evcc-io#370)
Browse files Browse the repository at this point in the history
  • Loading branch information
andig authored Jan 2, 2021
1 parent 398738c commit ccdb1cb
Show file tree
Hide file tree
Showing 14 changed files with 15,496 additions and 7,353 deletions.
15 changes: 15 additions & 0 deletions assets/js/components/Loadpoint.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@
:caption="true"
v-on:updated="setTargetSoC"
></Soc>
<!-- <div class="btn-group btn-group-toggle bg-white shadow-none">
<label class="btn btn-outline-primary">
<input
type="checkbox"
class="disabled"
v-on:click="alert('not implemented - use api')"
/>
<fa-icon
icon="clock"
v-bind:class="{ fas: socTimerActive, far: !socTimerActive }"
></fa-icon>
</label>
</div> -->
</div>
</div>

Expand Down Expand Up @@ -160,6 +173,8 @@ export default {
socTitle: String,
socCharge: Number,
minSoC: Number,
socTimerSet: Boolean,
socTimerActive: Boolean,
// details
chargePower: Number,
Expand Down
12 changes: 12 additions & 0 deletions assets/js/components/LoadpointDetails.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,15 @@ VehicleClimater.args = {
chargeEstimate: 5 * 3600,
climater: "on",
};

export const VehicleTimer = Template.bind({});
VehicleTimer.args = {
chargePower: 2800,
chargedEnergy: 11e3,
chargeDuration: 95 * 60,
soc: true,
range: 240.123,
chargeEstimate: 5 * 3600,
socTimerSet: true,
socTimerActive: true,
};
5 changes: 5 additions & 0 deletions assets/js/components/LoadpointDetails.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
<div class="col-6 col-md-3 mt-3">
<div class="mb-2 value">
Leistung
<fa-icon class="text-primary ml-1" icon="clock" v-if="socTimerActive"></fa-icon>
<fa-icon class="text-secondary ml-1" icon="clock" v-else-if="socTimerSet"></fa-icon>

<fa-icon
class="text-primary ml-1"
icon="temperature-low"
Expand Down Expand Up @@ -69,6 +72,8 @@ export default {
chargedEnergy: Number,
chargeDuration: Number,
soc: Boolean,
socTimerActive: Boolean,
socTimerSet: Boolean,
climater: String,
range: Number,
chargeEstimate: Number,
Expand Down
28 changes: 15 additions & 13 deletions assets/js/icons.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
import Vue from "vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faSun } from "@fortawesome/free-solid-svg-icons/faSun";
import { faArrowUp } from "@fortawesome/free-solid-svg-icons/faArrowUp";
import { faArrowDown } from "@fortawesome/free-solid-svg-icons/faArrowDown";
import { faArrowUp } from "@fortawesome/free-solid-svg-icons/faArrowUp";
import { faBatteryThreeQuarters } from "@fortawesome/free-solid-svg-icons/faBatteryThreeQuarters";
import { faTemperatureLow } from "@fortawesome/free-solid-svg-icons/faTemperatureLow";
import { faTemperatureHigh } from "@fortawesome/free-solid-svg-icons/faTemperatureHigh";
import { faThermometerHalf } from "@fortawesome/free-solid-svg-icons/faThermometerHalf";
import { faLeaf } from "@fortawesome/free-solid-svg-icons/faLeaf";
import { faChevronUp } from "@fortawesome/free-solid-svg-icons/faChevronUp";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons/faChevronDown";
import { faClock } from "@fortawesome/free-solid-svg-icons";
import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons/faExclamationTriangle";
import { faLeaf } from "@fortawesome/free-solid-svg-icons/faLeaf";
import { faSun } from "@fortawesome/free-solid-svg-icons/faSun";
import { faTemperatureLow } from "@fortawesome/free-solid-svg-icons/faTemperatureLow";
import { faTemperatureHigh } from "@fortawesome/free-solid-svg-icons/faTemperatureHigh";
import { faThermometerHalf } from "@fortawesome/free-solid-svg-icons/faThermometerHalf";

library.add(
faSun,
faArrowUp,
faArrowDown,
faArrowUp,
faBatteryThreeQuarters,
faChevronDown,
faChevronUp,
faClock,
faExclamationTriangle,
faLeaf,
faSun,
faTemperatureLow,
faTemperatureHigh,
faThermometerHalf,
faLeaf,
faChevronUp,
faChevronDown,
faExclamationTriangle
faThermometerHalf
);

Vue.component("fa-icon", FontAwesomeIcon);
58 changes: 37 additions & 21 deletions core/loadpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"time"

"github.com/andig/evcc/api"
"github.com/andig/evcc/core/soc"
"github.com/andig/evcc/core/wrapper"
"github.com/andig/evcc/push"
"github.com/andig/evcc/util"
Expand Down Expand Up @@ -94,10 +95,10 @@ type LoadPoint struct {
MaxCurrent int64 // Max allowed current. Physically ensured by the charger
GuardDuration time.Duration // charger enable/disable minimum holding time

enabled bool // Charger enabled state
maxCurrent float64 // Charger current limit
guardUpdated time.Time // Charger enabled/disabled timestamp
socUpdated time.Time // SoC updated timestamp (poll: connected)
enabled bool // Charger enabled state
chargeCurrent float64 // Charger current limit
guardUpdated time.Time // Charger enabled/disabled timestamp
socUpdated time.Time // SoC updated timestamp (poll: connected)

charger api.Charger
chargeTimer api.ChargeTimer
Expand All @@ -106,7 +107,8 @@ type LoadPoint struct {
chargeMeter api.Meter // Charger usage meter
vehicle api.Vehicle // Currently active vehicle
vehicles []api.Vehicle // Assigned vehicles
socEstimator *wrapper.SocEstimator
socEstimator *soc.Estimator
socTimer *soc.Timer

// cached state
status api.ChargeStatus // Charger status
Expand Down Expand Up @@ -191,6 +193,8 @@ func NewLoadPointFromConfig(log *util.Logger, cp configProvider, other map[strin
lp.charger = cp.Charger(lp.ChargerRef)
lp.configureChargerType(lp.charger)

// allow target charge handler to access loadpoint
lp.socTimer = soc.NewTimer(lp.log, lp.adapter(), lp.MaxCurrent)
if lp.Enable.Threshold > lp.Disable.Threshold {
log.WARN.Printf("PV mode enable threshold (%.0fW) is larger than disable threshold (%.0fW)", lp.Enable.Threshold, lp.Disable.Threshold)
}
Expand Down Expand Up @@ -435,23 +439,23 @@ func (lp *LoadPoint) syncCharger() {
}
}

func (lp *LoadPoint) setLimit(maxCurrent float64, force bool) (err error) {
func (lp *LoadPoint) setLimit(chargeCurrent float64, force bool) (err error) {
// set current
if maxCurrent != lp.maxCurrent && maxCurrent >= float64(lp.MinCurrent) {
if chargeCurrent != lp.chargeCurrent && chargeCurrent >= float64(lp.MinCurrent) {
if charger, ok := lp.charger.(api.ChargerEx); ok {
err = charger.MaxCurrentMillis(maxCurrent)
err = charger.MaxCurrentMillis(chargeCurrent)
} else {
err = lp.charger.MaxCurrent(int64(maxCurrent))
err = lp.charger.MaxCurrent(int64(chargeCurrent))
}

if err == nil {
lp.maxCurrent = maxCurrent
lp.bus.Publish(evChargeCurrent, maxCurrent)
lp.chargeCurrent = chargeCurrent
lp.bus.Publish(evChargeCurrent, chargeCurrent)
}
}

// set enabled
if enabled := maxCurrent != 0; enabled != lp.enabled && err == nil {
if enabled := chargeCurrent != 0; enabled != lp.enabled && err == nil {
if remaining := (lp.GuardDuration - lp.clock.Since(lp.guardUpdated)).Truncate(time.Second); remaining > 0 && !force {
lp.log.DEBUG.Printf("charger %s - contactor delay %v", status[enabled], remaining)
return nil
Expand All @@ -461,7 +465,7 @@ func (lp *LoadPoint) setLimit(maxCurrent float64, force bool) (err error) {
lp.enabled = enabled
lp.guardUpdated = lp.clock.Now()
lp.log.DEBUG.Printf("charger %s", status[enabled])
lp.bus.Publish(evChargeCurrent, maxCurrent)
lp.bus.Publish(evChargeCurrent, chargeCurrent)
}
}

Expand Down Expand Up @@ -539,7 +543,7 @@ func (lp *LoadPoint) setActiveVehicle(vehicle api.Vehicle) {
}

lp.vehicle = vehicle
lp.socEstimator = wrapper.NewSocEstimator(lp.log, vehicle, lp.SoC.Estimate)
lp.socEstimator = soc.NewEstimator(lp.log, vehicle, lp.SoC.Estimate)

lp.publish("socTitle", lp.vehicle.Title())
lp.publish("socCapacity", lp.vehicle.Capacity())
Expand Down Expand Up @@ -615,7 +619,7 @@ func (lp *LoadPoint) updateChargerStatus() error {
}

// update whenever there is a state change
lp.bus.Publish(evChargeCurrent, lp.maxCurrent)
lp.bus.Publish(evChargeCurrent, lp.chargeCurrent)
}

return nil
Expand Down Expand Up @@ -655,6 +659,15 @@ func (lp *LoadPoint) detectPhases() {
}
}

// effectiveCurrent returns the currently effective charging current
// it does not take measured currents into account
func (lp *LoadPoint) effectiveCurrent() float64 {
if lp.status != api.StatusC {
return 0
}
return lp.chargeCurrent
}

// pvDisableTimer puts the pv enable/disable timer into elapsed state
func (lp *LoadPoint) pvDisableTimer() {
lp.pvTimer = time.Now().Add(-lp.Disable.Delay)
Expand All @@ -663,10 +676,7 @@ func (lp *LoadPoint) pvDisableTimer() {
// pvMaxCurrent calculates the maximum target current for PV mode
func (lp *LoadPoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64) float64 {
// calculate target charge current from delta power and actual current
effectiveCurrent := lp.maxCurrent
if lp.status != api.StatusC {
effectiveCurrent = 0
}
effectiveCurrent := lp.effectiveCurrent()
deltaCurrent := powerToCurrent(-sitePower, lp.Phases)
targetCurrent := math.Max(math.Min(effectiveCurrent+deltaCurrent, float64(lp.MaxCurrent)), 0)

Expand Down Expand Up @@ -852,7 +862,7 @@ func (lp *LoadPoint) Update(sitePower float64) {
lp.updateChargeMeter()

// update ChargeRater here to make sure initial meter update is caught
lp.bus.Publish(evChargeCurrent, lp.maxCurrent)
lp.bus.Publish(evChargeCurrent, lp.chargeCurrent)
lp.bus.Publish(evChargePower, lp.chargePower)

// update progress and soc before status is updated
Expand Down Expand Up @@ -901,8 +911,9 @@ func (lp *LoadPoint) Update(sitePower float64) {
targetCurrent = float64(lp.MinCurrent)
}
err = lp.setLimit(targetCurrent, true)
lp.socTimer.Reset() // once SoC is reached, the target charge request is removed

// OCPP
// OCPP has priority over target charging
case lp.remoteControlled(RemoteHardDisable):
remoteDisabled = RemoteHardDisable
fallthrough
Expand All @@ -917,6 +928,11 @@ func (lp *LoadPoint) Update(sitePower float64) {
case mode == api.ModeNow:
err = lp.setLimit(float64(lp.MaxCurrent), true)

// target charging
case lp.socTimer.StartRequired():
targetCurrent := lp.socTimer.Handle()
err = lp.setLimit(targetCurrent, false)

case mode == api.ModeMinPV || mode == api.ModePV:
targetCurrent := lp.pvMaxCurrent(mode, sitePower)
lp.log.DEBUG.Printf("target charge current: %.2gA", targetCurrent)
Expand Down
27 changes: 27 additions & 0 deletions core/loadpoint_adapter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package core

import "github.com/andig/evcc/core/soc"

type adapter struct {
lp *LoadPoint
}

func (lp *LoadPoint) adapter() soc.Adapter {
return &adapter{lp: lp}
}

func (a *adapter) Publish(key string, val interface{}) {
a.lp.publish(key, val)
}

func (a *adapter) SocEstimator() *soc.Estimator {
return a.lp.socEstimator
}

func (a *adapter) ActivePhases() int64 {
return a.lp.Phases
}

func (a *adapter) Voltage() float64 {
return Voltage
}
18 changes: 18 additions & 0 deletions core/loadpoint_api.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package core

import (
"time"

"github.com/andig/evcc/api"
"github.com/andig/evcc/core/wrapper"
)
Expand All @@ -17,6 +19,7 @@ type LoadPointAPI interface {
SetTargetSoC(int) error
GetMinSoC() int
SetMinSoC(int) error
SetTargetCharge(time.Time, int)
RemoteControl(string, RemoteDemand)

// energy
Expand Down Expand Up @@ -104,6 +107,21 @@ func (lp *LoadPoint) SetMinSoC(soc int) error {
return nil
}

// SetTargetCharge sets loadpoint charge targetSoC
func (lp *LoadPoint) SetTargetCharge(finishAt time.Time, targetSoC int) {
lp.Lock()
defer lp.Unlock()

lp.log.INFO.Printf("set target charge: %d @ %v", targetSoC, finishAt)

// apply immediately
// TODO check reset of targetSoC
lp.publish("targetTime", finishAt)
lp.publish("targetSoC", targetSoC)

lp.requestUpdate()
}

// RemoteControl sets remote status demand
func (lp *LoadPoint) RemoteControl(source string, demand RemoteDemand) {
lp.Lock()
Expand Down
10 changes: 5 additions & 5 deletions core/loadpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
"time"

"github.com/andig/evcc/api"
"github.com/andig/evcc/core/wrapper"
"github.com/andig/evcc/core/soc"
"github.com/andig/evcc/mock"
"github.com/andig/evcc/push"
"github.com/andig/evcc/util"
Expand Down Expand Up @@ -365,7 +365,7 @@ func TestDisableAndEnableAtTargetSoC(t *testing.T) {

// wrap vehicle with estimator
vehicle.EXPECT().Capacity().Return(int64(10))
socEstimator := wrapper.NewSocEstimator(util.NewLogger("foo"), vehicle, false)
socEstimator := soc.NewEstimator(util.NewLogger("foo"), vehicle, false)

lp := &LoadPoint{
log: util.NewLogger("foo"),
Expand All @@ -392,7 +392,7 @@ func TestDisableAndEnableAtTargetSoC(t *testing.T) {
attachListeners(t, lp)

lp.enabled = true
lp.maxCurrent = float64(minA)
lp.chargeCurrent = float64(minA)

t.Log("charging below soc target")
vehicle.EXPECT().ChargeState().Return(85.0, nil)
Expand Down Expand Up @@ -461,7 +461,7 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) {
attachListeners(t, lp)

lp.enabled = true
lp.maxCurrent = float64(minA)
lp.chargeCurrent = float64(minA)
lp.Mode = api.ModeNow

t.Log("charging at min")
Expand Down Expand Up @@ -526,7 +526,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
attachListeners(t, lp)

lp.enabled = true
lp.maxCurrent = float64(maxA)
lp.chargeCurrent = float64(maxA)
lp.Mode = api.ModeNow

// attach cache for verifying values
Expand Down
Loading

0 comments on commit ccdb1cb

Please sign in to comment.