Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add target charging #370

Merged
merged 3 commits into from
Jan 2, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Rebase
  • Loading branch information
andig committed Jan 2, 2021
commit a366b5e8045662b682187e38ba435d75294a0606
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);
32 changes: 26 additions & 6 deletions core/loadpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ type LoadPoint struct {
vehicle api.Vehicle // Currently active vehicle
vehicles []api.Vehicle // Assigned vehicles
socEstimator *wrapper.SocEstimator
socTimer *SoCTimer

// cached state
status api.ChargeStatus // Charger status
Expand Down Expand Up @@ -191,6 +192,9 @@ 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 = &SoCTimer{LoadPoint: lp}

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 @@ -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.maxCurrent
}

// 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 @@ -901,11 +911,21 @@ 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
err = lp.setLimit(0, true)

// target charging
case lp.socTimer.StartRequired():
var pvCurrent float64
// check if pv mode offers higher current
if mode == api.ModeMinPV || mode == api.ModePV {
pvCurrent = lp.pvMaxCurrent(mode, sitePower)
}
err = lp.socTimer.Handle(pvCurrent)

case mode == api.ModeOff:
err = lp.setLimit(0, true)
Expand Down
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
103 changes: 103 additions & 0 deletions core/soctimer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package core

import (
"math"
"time"
)

const (
utilization float64 = 0.6
deviation = 30 * time.Minute
)

// SoCTimer is the target charging handler
type SoCTimer struct {
*LoadPoint
SoC int
Time time.Time
finishAt time.Time
chargeRequired bool
}

// Supported returns true if target charging is possible, i.e. the vehicle soc can be determined
func (lp *SoCTimer) Supported() bool {
return lp.socEstimator != nil
}

// Reset resets the target charging request
func (lp *SoCTimer) Reset() {
if lp != nil {
lp.Time = time.Time{}
lp.SoC = 0
}
}

// active returns true if there is an active target charging request
func (lp *SoCTimer) active() bool {
if lp == nil {
return false
}

inactive := lp.Time.IsZero() || lp.Time.Before(time.Now())
lp.publish("socTimerSet", !inactive)

// reset active
if inactive && lp.chargeRequired {
lp.chargeRequired = false
lp.publish("socTimerActive", lp.chargeRequired)
}

return !inactive
}

// StartRequired calculates remaining charge duration and returns true if charge start is required to achieve target soc in time
func (lp *SoCTimer) StartRequired() bool {
if !lp.active() {
return false
}

current := lp.effectiveCurrent()

// use start current for calculation if currently not charging
if current == 0 {
current = float64(lp.MaxCurrent) * utilization
current = math.Max(math.Min(current, float64(lp.MaxCurrent)), 0)
}

power := current * float64(lp.Phases) * Voltage

// time
remainingDuration := lp.socEstimator.RemainingChargeDuration(power, lp.SoC)
lp.finishAt = time.Now().Add(remainingDuration).Round(time.Minute)
lp.log.DEBUG.Printf("target charging active for %v: projected %v (%v remaining)", lp.Time, lp.finishAt, remainingDuration.Round(time.Minute))

lp.chargeRequired = lp.finishAt.After(lp.Time)
lp.publish("socTimerActive", lp.chargeRequired)

return lp.chargeRequired
}

// Handle adjusts current up/down to achieve desired target time taking.
// PV mode target current into consideration to ensure maximum PV usage.
func (lp *SoCTimer) Handle(pvCurrent float64) error {
current := lp.maxCurrent

switch {
case lp.finishAt.Before(lp.Time.Add(-deviation)):
current--
lp.log.DEBUG.Printf("target charging: slowdown")

case lp.finishAt.After(lp.Time):
current++
lp.log.DEBUG.Printf("target charging: speedup")
}

// use higher-charging pv current if available
if current < pvCurrent {
current = pvCurrent
}

current = math.Max(math.Min(current, float64(lp.MaxCurrent)), 0)

return lp.setLimit(current, false)
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"build": "rimraf dist; parcel build assets/* --public-url ./dist/",
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "prettier assets --write && eslint assets/**/*.js assets/**/*.vue --fix",
"storybook": "start-storybook -p 6006 -s assets,node_modules/bootstrap/dist",
"storybook": "start-storybook -p 6006 -s assets,node_modules/jquery/dist,node_modules/popper.js/dist,node_modules/bootstrap/dist",
"build-storybook": "build-storybook",
"fix-audit": "npm-force-resolutions"
},
Expand Down
Loading