Add target charging (#370)
This commit is contained in:
parent
398738cb36
commit
ccdb1cba48
14 changed files with 15496 additions and 7353 deletions
|
|
@ -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>
|
||||
|
||||
|
|
@ -160,6 +173,8 @@ export default {
|
|||
socTitle: String,
|
||||
socCharge: Number,
|
||||
minSoC: Number,
|
||||
socTimerSet: Boolean,
|
||||
socTimerActive: Boolean,
|
||||
|
||||
// details
|
||||
chargePower: Number,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -69,6 +72,8 @@ export default {
|
|||
chargedEnergy: Number,
|
||||
chargeDuration: Number,
|
||||
soc: Boolean,
|
||||
socTimerActive: Boolean,
|
||||
socTimerSet: Boolean,
|
||||
climater: String,
|
||||
range: Number,
|
||||
chargeEstimate: Number,
|
||||
|
|
|
|||
|
|
@ -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 { 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";
|
||||
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 { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons/faExclamationTriangle";
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
27
core/loadpoint_adapter.go
Normal file
27
core/loadpoint_adapter.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/andig/evcc/api"
|
||||
"github.com/andig/evcc/core/wrapper"
|
||||
)
|
||||
|
|
@ -17,6 +19,7 @@ type LoadPointAPI interface {
|
|||
SetTargetSoC(int) error
|
||||
GetMinSoC() int
|
||||
SetMinSoC(int) error
|
||||
SetTargetCharge(time.Time, int)
|
||||
RemoteControl(string, RemoteDemand)
|
||||
|
||||
// energy
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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"),
|
||||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package wrapper
|
||||
package soc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
|
@ -11,9 +11,9 @@ import (
|
|||
|
||||
const chargeEfficiency = 0.9 // assume charge 90% efficiency
|
||||
|
||||
// SocEstimator provides vehicle soc and charge duration
|
||||
// Estimator provides vehicle soc and charge duration
|
||||
// Vehicle SoC can be estimated to provide more granularity
|
||||
type SocEstimator struct {
|
||||
type Estimator struct {
|
||||
log *util.Logger
|
||||
vehicle api.Vehicle
|
||||
estimate bool
|
||||
|
|
@ -26,9 +26,9 @@ type SocEstimator struct {
|
|||
energyPerSocStep float64 // Energy per SoC percent in Wh
|
||||
}
|
||||
|
||||
// NewSocEstimator creates new estimator
|
||||
func NewSocEstimator(log *util.Logger, vehicle api.Vehicle, estimate bool) *SocEstimator {
|
||||
s := &SocEstimator{
|
||||
// NewEstimator creates new estimator
|
||||
func NewEstimator(log *util.Logger, vehicle api.Vehicle, estimate bool) *Estimator {
|
||||
s := &Estimator{
|
||||
log: log,
|
||||
vehicle: vehicle,
|
||||
estimate: estimate,
|
||||
|
|
@ -40,7 +40,7 @@ func NewSocEstimator(log *util.Logger, vehicle api.Vehicle, estimate bool) *SocE
|
|||
}
|
||||
|
||||
// Reset resets the estimation process to default values
|
||||
func (s *SocEstimator) Reset() {
|
||||
func (s *Estimator) Reset() {
|
||||
s.prevSoC = 0
|
||||
s.prevChargedEnergy = 0
|
||||
s.capacity = float64(s.vehicle.Capacity()) * 1e3 // cache to simplify debugging
|
||||
|
|
@ -49,7 +49,7 @@ func (s *SocEstimator) Reset() {
|
|||
}
|
||||
|
||||
// RemainingChargeDuration returns the remaining duration estimate based on SoC, target and charge power
|
||||
func (s *SocEstimator) RemainingChargeDuration(chargePower float64, targetSoC int) time.Duration {
|
||||
func (s *Estimator) RemainingChargeDuration(chargePower float64, targetSoC int) time.Duration {
|
||||
if chargePower > 0 {
|
||||
percentRemaining := float64(targetSoC) - s.socCharge
|
||||
if percentRemaining <= 0 {
|
||||
|
|
@ -78,7 +78,7 @@ func (s *SocEstimator) RemainingChargeDuration(chargePower float64, targetSoC in
|
|||
}
|
||||
|
||||
// RemainingChargeEnergy returns the remaining charge energy in kWh
|
||||
func (s *SocEstimator) RemainingChargeEnergy(targetSoC int) float64 {
|
||||
func (s *Estimator) RemainingChargeEnergy(targetSoC int) float64 {
|
||||
percentRemaining := float64(targetSoC) - s.socCharge
|
||||
if percentRemaining <= 0 {
|
||||
return 0
|
||||
|
|
@ -90,7 +90,7 @@ func (s *SocEstimator) RemainingChargeEnergy(targetSoC int) float64 {
|
|||
}
|
||||
|
||||
// SoC implements Vehicle.ChargeState with addition of given charged energy
|
||||
func (s *SocEstimator) SoC(chargedEnergy float64) (float64, error) {
|
||||
func (s *Estimator) SoC(chargedEnergy float64) (float64, error) {
|
||||
f, err := s.vehicle.ChargeState()
|
||||
if err != nil {
|
||||
s.log.WARN.Printf("updating soc failed: %v", err)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package wrapper
|
||||
package soc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
|
@ -15,7 +15,7 @@ func TestRemainingChargeDuration(t *testing.T) {
|
|||
//9 kWh userBatCap => 10 kWh virtualBatCap
|
||||
vehicle.EXPECT().Capacity().Return(int64(9))
|
||||
|
||||
ce := NewSocEstimator(util.NewLogger("foo"), vehicle, false)
|
||||
ce := NewEstimator(util.NewLogger("foo"), vehicle, false)
|
||||
ce.socCharge = 20.0
|
||||
|
||||
chargePower := 1000.0
|
||||
|
|
@ -34,7 +34,7 @@ func TestSoCEstimation(t *testing.T) {
|
|||
var capacity int64 = 9
|
||||
vehicle.EXPECT().Capacity().Return(capacity)
|
||||
|
||||
ce := NewSocEstimator(util.NewLogger("foo"), vehicle, true)
|
||||
ce := NewEstimator(util.NewLogger("foo"), vehicle, true)
|
||||
ce.socCharge = 20.0
|
||||
|
||||
tc := []struct {
|
||||
109
core/soc/soctimer.go
Normal file
109
core/soc/soctimer.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package soc
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/andig/evcc/util"
|
||||
)
|
||||
|
||||
const (
|
||||
deviation = 30 * time.Minute
|
||||
)
|
||||
|
||||
// Adapter provides the required methods for interacting with the loadpoint
|
||||
type Adapter interface {
|
||||
Publish(key string, val interface{})
|
||||
SocEstimator() *Estimator
|
||||
ActivePhases() int64
|
||||
Voltage() float64
|
||||
}
|
||||
|
||||
// Timer is the target charging handler
|
||||
type Timer struct {
|
||||
Adapter
|
||||
log *util.Logger
|
||||
maxCurrent int64
|
||||
current float64
|
||||
SoC int
|
||||
Time time.Time
|
||||
finishAt time.Time
|
||||
chargeRequired bool
|
||||
}
|
||||
|
||||
// NewTimer creates a Timer
|
||||
func NewTimer(log *util.Logger, adapter Adapter, maxCurrent int64) *Timer {
|
||||
lp := &Timer{
|
||||
log: log,
|
||||
Adapter: adapter,
|
||||
maxCurrent: maxCurrent,
|
||||
}
|
||||
|
||||
return lp
|
||||
}
|
||||
|
||||
// Reset resets the target charging request
|
||||
func (lp *Timer) Reset() {
|
||||
if lp == nil {
|
||||
return
|
||||
}
|
||||
|
||||
lp.current = float64(lp.maxCurrent)
|
||||
lp.Time = time.Time{}
|
||||
lp.SoC = 0
|
||||
}
|
||||
|
||||
// StartRequired calculates remaining charge duration and returns true if charge start is required to achieve target soc in time
|
||||
func (lp *Timer) StartRequired() bool {
|
||||
if lp == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
se := lp.SocEstimator()
|
||||
if !lp.active() || se == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
power := float64(lp.maxCurrent*lp.ActivePhases()) * lp.Voltage()
|
||||
|
||||
// time
|
||||
remainingDuration := se.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("timerActive", lp.chargeRequired)
|
||||
|
||||
return lp.chargeRequired
|
||||
}
|
||||
|
||||
// active returns true if there is an active target charging request
|
||||
func (lp *Timer) active() bool {
|
||||
inactive := lp.Time.IsZero() || lp.Time.Before(time.Now())
|
||||
lp.Publish("timerSet", !inactive)
|
||||
|
||||
// reset active
|
||||
if inactive && lp.chargeRequired {
|
||||
lp.chargeRequired = false
|
||||
lp.Publish("timerActive", lp.chargeRequired)
|
||||
}
|
||||
|
||||
return !inactive
|
||||
}
|
||||
|
||||
// Handle adjusts current up/down to achieve desired target time taking.
|
||||
func (lp *Timer) Handle() float64 {
|
||||
switch {
|
||||
case lp.finishAt.Before(lp.Time.Add(-deviation)):
|
||||
lp.current--
|
||||
lp.log.DEBUG.Printf("target charging: slowdown")
|
||||
|
||||
case lp.finishAt.After(lp.Time):
|
||||
lp.current++
|
||||
lp.log.DEBUG.Printf("target charging: speedup")
|
||||
}
|
||||
|
||||
lp.current = math.Max(math.Min(lp.current, float64(lp.maxCurrent)), 0)
|
||||
|
||||
return lp.current
|
||||
}
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
22475
server/assets.go
22475
server/assets.go
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
|
|
@ -219,6 +220,7 @@ func MinSoCHandler(loadpoint core.LoadPointAPI) http.HandlerFunc {
|
|||
}
|
||||
|
||||
if !ok || err != nil {
|
||||
log.DEBUG.Printf("parse soc: %v", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
|
@ -256,6 +258,53 @@ func RemoteDemandHandler(loadpoint core.LoadPointAPI) http.HandlerFunc {
|
|||
Source: source,
|
||||
Demand: demand,
|
||||
}
|
||||
|
||||
jsonResponse(w, r, res)
|
||||
}
|
||||
}
|
||||
|
||||
func timezone() *time.Location {
|
||||
tz := os.Getenv("TZ")
|
||||
if tz == "" {
|
||||
tz = "Local"
|
||||
}
|
||||
|
||||
loc, _ := time.LoadLocation(tz)
|
||||
return loc
|
||||
}
|
||||
|
||||
// TargetChargeHandler updates target soc
|
||||
func TargetChargeHandler(loadpoint core.LoadPointAPI) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
socS, ok := vars["soc"]
|
||||
socV, err := strconv.ParseInt(socS, 10, 32)
|
||||
|
||||
if !ok || err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
timeS, ok := vars["time"]
|
||||
timeV, err := time.ParseInLocation("2006-01-02T15:04:05", timeS, timezone())
|
||||
|
||||
if !ok || err != nil || timeV.Before(time.Now()) {
|
||||
log.DEBUG.Printf("parse time: %v", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
loadpoint.SetTargetCharge(timeV, int(socV))
|
||||
|
||||
res := struct {
|
||||
SoC int64 `json:"soc"`
|
||||
Time time.Time `json:"time"`
|
||||
}{
|
||||
SoC: socV,
|
||||
Time: timeV,
|
||||
}
|
||||
|
||||
jsonResponse(w, r, res)
|
||||
}
|
||||
}
|
||||
|
|
@ -317,13 +366,14 @@ func NewHTTPd(url string, site core.SiteAPI, hub *SocketHub, cache *util.Cache)
|
|||
lpAPI := api.PathPrefix(fmt.Sprintf("/loadpoints/%d", id)).Subrouter()
|
||||
|
||||
routes := map[string]route{
|
||||
"getmode": {[]string{"GET"}, "/mode", CurrentChargeModeHandler(lp)},
|
||||
"setmode": {[]string{"POST", "OPTIONS"}, "/mode/{mode:[a-z]+}", ChargeModeHandler(lp)},
|
||||
"gettargetsoc": {[]string{"GET"}, "/targetsoc", CurrentTargetSoCHandler(lp)},
|
||||
"settargetsoc": {[]string{"POST", "OPTIONS"}, "/targetsoc/{soc:[0-9]+}", TargetSoCHandler(lp)},
|
||||
"getminsoc": {[]string{"GET"}, "/minsoc", CurrentMinSoCHandler(lp)},
|
||||
"setminsoc": {[]string{"POST", "OPTIONS"}, "/minsoc/{soc:[0-9]+}", MinSoCHandler(lp)},
|
||||
"remotedemand": {[]string{"POST", "OPTIONS"}, "/remotedemand/{demand:[a-z]+}/{source}", RemoteDemandHandler(lp)},
|
||||
"getmode": {[]string{"GET"}, "/mode", CurrentChargeModeHandler(lp)},
|
||||
"setmode": {[]string{"POST", "OPTIONS"}, "/mode/{mode:[a-z]+}", ChargeModeHandler(lp)},
|
||||
"gettargetsoc": {[]string{"GET"}, "/targetsoc", CurrentTargetSoCHandler(lp)},
|
||||
"settargetsoc": {[]string{"POST", "OPTIONS"}, "/targetsoc/{soc:[0-9]+}", TargetSoCHandler(lp)},
|
||||
"getminsoc": {[]string{"GET"}, "/minsoc", CurrentMinSoCHandler(lp)},
|
||||
"setminsoc": {[]string{"POST", "OPTIONS"}, "/minsoc/{soc:[0-9]+}", MinSoCHandler(lp)},
|
||||
"settargetcharge": {[]string{"POST", "OPTIONS"}, "/targetcharge/{soc:[0-9]+}/{time:[0-9TZ:-]+}", TargetChargeHandler(lp)},
|
||||
"remotedemand": {[]string{"POST", "OPTIONS"}, "/remotedemand/{demand:[a-z]+}/{source}", RemoteDemandHandler(lp)},
|
||||
}
|
||||
|
||||
for _, r := range routes {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue