Refactor target charging apis (#5649)

This commit is contained in:
andig 2023-01-19 19:01:22 +01:00 • committed by GitHub
parent 8d805306ff
commit dcaadb783f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 799 additions and 642 deletions

View file

@ -15,20 +15,3 @@ func (r Rates) Current(now time.Time) (Rate, error) {
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]
}

View file

@ -1,29 +0,0 @@
package api
import (
"sort"
"testing"
"time"
"github.com/benbjohnson/clock"
"github.com/stretchr/testify/assert"
)
func TestRatesSortLateSlotsFirst(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().Add(time.Hour), r[0].Start)
assert.Equal(t, clock.Now(), r[1].Start)
}

View file

@ -160,6 +160,7 @@ a:hover {
color: inherit !important;
background-color: inherit !important;
border-color: inherit !important;
opacity: 0.2;
}
.btn-outline-primary,

View file

@ -155,9 +155,9 @@ export default {
vehicleFeatureOffline: Boolean,
vehicles: Array,
minSoc: Number,
planActive: Boolean,
planProjectedStart: String,
targetTime: String,
targetTimeActive: Boolean,
targetTimeProjectedStart: String,
vehicleProviderLoggedIn: Boolean,
vehicleProviderLoginPath: String,
vehicleProviderLogoutPath: String,
@ -256,10 +256,10 @@ export default {
api.post(this.apiPath("mode") + "/" + mode);
},
setTargetSoc: function (soc) {
api.post(this.apiPath("targetsoc") + "/" + soc);
api.post(this.apiPath("target/soc") + "/" + soc);
},
setTargetEnergy: function (kWh) {
api.post(this.apiPath("targetenergy") + "/" + kWh);
api.post(this.apiPath("target/energy") + "/" + kWh);
},
setMaxCurrent: function (maxCurrent) {
api.post(this.apiPath("maxcurrent") + "/" + maxCurrent);
@ -274,10 +274,10 @@ export default {
api.post(this.apiPath("minsoc") + "/" + soc);
},
setTargetTime: function (date) {
api.post(`${this.apiPath("targetcharge")}/${this.targetSoc}/${date.toISOString()}`);
api.post(`${this.apiPath("target/time")}/${date.toISOString()}`);
},
removeTargetTime: function () {
api.delete(this.apiPath("targetcharge"));
api.delete(this.apiPath("target/time"));
},
changeVehicle(index) {
api.post(this.apiPath("vehicle") + `/${index}`);

View file

@ -43,10 +43,23 @@
</div>
<form @submit.prevent="setTargetTime">
<div class="modal-body">
<div class="form-group">
<div class="form-group mb-2">
<!-- eslint-disable vue/no-v-html -->
<label for="targetTimeLabel" class="mb-3">
{{ $t("main.targetCharge.description", { targetSoc }) }}
<span v-if="socBasedCharging">
{{
$t("main.targetCharge.descriptionSoc", {
targetSoc,
})
}}
</span>
<span v-else>
{{
$t("main.targetCharge.descriptionEnergy", {
targetEnergy: targetEnergyFormatted,
})
}}
</span>
</label>
<!-- eslint-enable vue/no-v-html -->
<div
@ -79,23 +92,14 @@
<p v-if="!selectedTargetTimeValid" class="text-danger mb-0">
{{ $t("main.targetCharge.targetIsInThePast") }}
</p>
<p class="small mt-3 text-muted mb-0">
<strong class="text-evcc">
{{ $t("main.targetCharge.experimentalLabel") }}:
</strong>
{{ $t("main.targetCharge.experimentalText") }}
<a
href="https://github.com/evcc-io/evcc/discussions/1433"
target="_blank"
>GitHub Discussions</a
>.
</p>
<TargetChargePlanMinimal v-else-if="plan.duration" v-bind="plan" />
</div>
<div class="modal-footer d-flex justify-content-between">
<button
type="button"
class="btn btn-outline-secondary"
data-bs-dismiss="modal"
:disabled="!targetTime"
@click="removeTargetTime"
>
{{ $t("main.targetCharge.remove") }}
@ -122,6 +126,8 @@ import Modal from "bootstrap/js/dist/modal";
import "@h2d2/shopicons/es/filled/plus";
import "@h2d2/shopicons/es/filled/edit";
import LabelAndValue from "./LabelAndValue.vue";
import TargetChargePlanMinimal from "./TargetChargePlanMinimal.vue";
import api from "../api";
import formatter from "../mixins/formatter";
@ -130,18 +136,20 @@ const LAST_TARGET_TIME_KEY = "last_target_time";
export default {
name: "TargetCharge",
components: { LabelAndValue },
components: { LabelAndValue, TargetChargePlanMinimal },
mixins: [formatter],
props: {
id: [String, Number],
planActive: Boolean,
targetTime: String,
targetTimeActive: Boolean,
targetSoc: Number,
targetEnergy: Number,
socBasedCharging: Boolean,
disabled: Boolean,
},
emits: ["target-time-updated", "target-time-removed"],
data: function () {
return { selectedDay: null, selectedTime: null };
return { selectedDay: null, selectedTime: null, plan: {} };
},
computed: {
targetChargeEnabled: function () {
@ -157,6 +165,9 @@ export default {
modalId: function () {
return `targetChargeModal_${this.id}`;
},
targetEnergyFormatted: function () {
return this.fmtKWh(this.targetEnergy * 1e3, true, true, 1);
},
},
watch: {
targetTimeLabel: function () {
@ -167,12 +178,32 @@ export default {
},
targetTime() {
this.initInputFields();
this.updatePlan();
},
selectedTargetTime() {
this.updatePlan();
},
targetSoc() {
this.updatePlan();
},
targetEnergy() {
this.updatePlan();
},
},
mounted: function () {
this.initInputFields();
},
methods: {
updatePlan: async function () {
if (this.selectedTargetTimeValid && (this.targetEnergy || this.targetSoc)) {
try {
const response = await api.get(`/loadpoints/${this.id}/target/plan`, {
params: { targetTime: this.selectedTargetTime },
});
this.plan = response.data.result;
} catch (e) {
console.error(e);
}
}
},
// not computed because it needs to update over time
targetTimeLabel: function () {
if (this.targetChargeEnabled) {
@ -245,6 +276,7 @@ export default {
},
openModal() {
const modal = Modal.getOrCreateInstance(document.getElementById(this.modalId));
this.initInputFields();
modal.show();
},
},

View file

@ -0,0 +1,63 @@
<template>
<div if="plan" class="small text-muted">
<div>{{ $t("main.targetCharge.planDuration") }}: {{ planDuration }}</div>
<div>
{{ $t("main.targetCharge.planPeriodLabel") }}:
<span v-if="planStart && planEnd">
{{
$t("main.targetCharge.planPeriodValue", { start: planStart, end: planEnd })
}}</span
>
<span v-else>{{ $t("main.targetCharge.planUnknown") }}:</span>
</div>
</div>
</template>
<script>
import formatter from "../mixins/formatter";
export default {
name: "TargetChargePlanMinimal",
mixins: [formatter],
props: {
duration: Number,
plan: Array,
unit: String,
power: Number,
},
computed: {
planDuration() {
return this.fmtShortDuration(this.duration, true);
},
lastSlot() {
if (this.plan?.length) {
return this.plan[this.plan?.length - 1];
}
return null;
},
firstSlot() {
if (this.plan?.length) {
return this.plan[0];
}
return null;
},
planStart() {
if (this.firstSlot) {
return this.weekdayTime(new Date(this.firstSlot.start));
}
return null;
},
planEnd() {
if (this.lastSlot) {
return this.weekdayTime(new Date(this.lastSlot.end));
}
return null;
},
durationHours() {
return this.duration / 3.6e12;
},
},
};
</script>
<style scoped></style>

View file

@ -31,7 +31,6 @@
align="start"
/>
<TargetCharge
v-if="socBasedCharging"
class="flex-grow-1 text-center target-charge"
v-bind="targetCharge"
:disabled="targetChargeDisabled"
@ -100,9 +99,9 @@ export default {
vehicleIcon: String,
vehicleCapacity: Number,
socBasedCharging: Boolean,
targetTimeActive: Boolean,
planActive: Boolean,
planProjectedStart: String,
targetTime: String,
targetTimeProjectedStart: String,
targetSoc: Number,
targetEnergy: Number,
chargedEnergy: Number,
@ -163,7 +162,22 @@ export default {
return value > 1 ? `+${Math.round(value)}%` : null;
},
targetChargeDisabled: function () {
return !this.connected || !["pv", "minpv"].includes(this.mode);
if (!this.connected) {
return true;
}
if (["off", "now"].includes(this.mode)) {
return true;
}
// enabled for vehicles with Soc
if (this.socBasedCharging) {
return false;
}
// disabled of no energy target is set (offline or guest vehicles)
if (!this.targetEnergy) {
return true;
}
return false;
},
},
watch: {

View file

@ -16,11 +16,12 @@ export default {
connected: Boolean,
charging: Boolean,
targetTime: String,
targetTimeProjectedStart: String,
planProjectedStart: String,
phaseAction: String,
phaseRemainingInterpolated: Number,
pvAction: String,
pvRemainingInterpolated: Number,
targetChargeDisabled: Boolean,
},
computed: {
phaseTimerActive() {
@ -50,16 +51,16 @@ export default {
}
// target charge
if (this.targetTime) {
if (this.targetTime && !this.targetChargeDisabled) {
if (this.charging) {
return t("targetChargeActive");
}
if (this.enabled) {
return t("targetChargeWaitForVehicle");
}
if (this.targetTimeProjectedStart) {
if (this.planProjectedStart) {
return t("targetChargePlanned", {
time: this.fmtAbsoluteDate(new Date(this.targetTimeProjectedStart)),
time: this.fmtAbsoluteDate(new Date(this.planProjectedStart)),
});
}
}

View file

@ -61,6 +61,7 @@ export default {
if (duration <= 0) {
return "—";
}
duration = Math.round(duration);
var seconds = duration % 60;
var minutes = Math.floor(duration / 60) % 60;
var hours = Math.floor(duration / 3600);
@ -124,6 +125,13 @@ export default {
weekday: "short",
}).format(date);
},
weekdayTime: function (date) {
return new Intl.DateTimeFormat(this.$i18n.locale, {
weekday: "short",
hour: "numeric",
minute: "numeric",
}).format(date);
},
fmtAbsoluteDate: function (date) {
const weekday = this.weekdayPrefix(date);
const hour = new Intl.DateTimeFormat(this.$i18n.locale, {

View file

@ -227,3 +227,15 @@ loadpoints:
mode: "off"
meter: meter_charger_2
vehicle: vehicle_2
tariffs:
currency: EUR # three letter ISO-4217 currency code (default EUR)
grid:
type: fixed
price: 0.399 # EUR/kWh
feedin:
type: fixed
price: 0.08 # EUR/kWh
planner:
type: grünstromindex
zip: 12349

View file

@ -16,9 +16,9 @@ const (
vehiclePresent = "vehiclePresent" // vehicle detected
vehicleTitle = "vehicleTitle" // vehicle title
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
targetTimeProjectedStart = "targetTimeProjectedStart" // target charging plan start time (earliest slot)
minSoc = "minSoc" // min soc goal
targetSoc = "targetSoc" // target charging soc goal
targetTime = "targetTime" // target charging finish time goal
planActive = "planActive" // target charging plan has determined current slot to be an active slot
planProjectedStart = "planProjectedStart" // target charging plan start time (earliest slot)
)

View file

@ -118,7 +118,7 @@ type Loadpoint struct {
Enable, Disable ThresholdConfig
ResetOnDisconnect bool `mapstructure:"resetOnDisconnect"`
onDisconnect api.ActionConfig
targetEnergy float64 // Target charge energy for dumb vehicles
targetEnergy float64 // Target charge energy for dumb vehicles in kWh
MinCurrent float64 // PV mode: start current Min+PV mode: min current
MaxCurrent float64 // Max allowed current. Physically ensured by the charger
@ -485,6 +485,7 @@ func (lp *Loadpoint) evVehicleDisconnectHandler() {
lp.socUpdated = time.Time{}
// reset plan once charge goal is met
lp.setTargetTime(time.Time{})
lp.setPlanActive(false)
}
@ -740,97 +741,6 @@ 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.setPlanActive(active)
lp.publish(targetTimeActive, active)
}()
if lp.planner == nil || lp.socEstimator == nil || lp.targetTime.IsZero() {
return false
}
targetSoc := 100
maxPower := lp.GetMaxPower()
var requiredDuration time.Duration
if energy, ok := lp.remainingChargeEnergy(); ok {
if energy > 0 {
requiredDuration = time.Duration(energy * 1e3 / maxPower * float64(time.Hour))
}
} else {
// TODO vehicle soc limit
if lp.Soc.target > 0 {
targetSoc = lp.Soc.target
}
requiredDuration = lp.socEstimator.RemainingChargeDuration(targetSoc, maxPower)
}
requiredDuration = time.Duration(float64(requiredDuration) / soc.ChargeEfficiency)
// anticipate lower charge rates at end of charging curve
var additionalTime time.Duration
if targetSoc > 80 && maxPower > 15000 {
additionalTime = 5 * time.Duration(float64(targetSoc-80)/(float64(targetSoc)-lp.vehicleSoc)*float64(requiredDuration)*(1-soc.ChargeEfficiency))
lp.log.DEBUG.Printf("add additional charging time %v for soc > 80%%", additionalTime.Round(time.Minute))
} else if targetSoc > 90 && maxPower > 4000 {
additionalTime = 3 * time.Duration(float64(targetSoc-90)/(float64(targetSoc)-lp.vehicleSoc)*float64(requiredDuration)*(1-soc.ChargeEfficiency))
lp.log.DEBUG.Printf("add additional charging time %v for soc > 90%%", additionalTime.Round(time.Minute))
}
requiredDuration += additionalTime
lp.log.DEBUG.Printf("planning %v until %v at %.0fW", requiredDuration.Round(time.Second), lp.targetTime.Round(time.Second).Local(), maxPower)
planStart, slotEnd, active, err := lp.planner.Active(requiredDuration, lp.targetTime)
if err != nil {
lp.log.ERROR.Println("planner:", err)
return false
}
lp.publish(targetTimeProjectedStart, planStart)
if active {
// ignore short plans if not already active
if !lp.planActive && lp.clock.Until(slotEnd) < smallSlotDuration {
lp.log.DEBUG.Printf("plan too short- ignoring remaining %v", requiredDuration.Round(time.Second))
return false
}
// remember last active plan's end time
lp.planSlotEnd = slotEnd
} else if lp.planActive {
// planner was active (any slot, not necessarily previous slot) and charge goal has not yet been met
switch {
case lp.clock.Now().After(lp.targetTime) && !lp.targetTime.IsZero():
// if the plan did not (entirely) work, we may still be charging beyond plan end- in that case, continue charging
// TODO check when schedule is implemented
lp.log.DEBUG.Println("continuing after target time")
return true
case lp.clock.Now().Before(lp.planSlotEnd) && !lp.planSlotEnd.IsZero():
// don't stop an already running slot if goal was not met
lp.log.DEBUG.Println("continuing until end of slot")
return true
case requiredDuration < 30*time.Minute:
lp.log.DEBUG.Printf("continuing for remaining %v", requiredDuration.Round(time.Second))
return true
case lp.clock.Until(planStart) < smallSlotDuration:
lp.log.DEBUG.Printf("plan will re-start shortly, continuing for remaining %v", lp.clock.Until(planStart).Round(time.Second))
return true
}
}
return active
}
// climateActive checks if vehicle has active climate request
func (lp *Loadpoint) climateActive() bool {
if cl, ok := lp.vehicle.(api.VehicleClimater); ok {

View file

@ -31,14 +31,6 @@ type API interface {
GetMode() api.ChargeMode
// SetMode sets the charge mode
SetMode(api.ChargeMode)
// GetTargetEnergy returns the charge target energy
GetTargetEnergy() float64
// SetTargetEnergy sets the charge target energy
SetTargetEnergy(float64)
// GetTargetSoc returns the charge target soc
GetTargetSoc() int
// SetTargetSoc sets the charge target soc
SetTargetSoc(int)
// GetMinSoc returns the charge minimum soc
GetMinSoc() int
// SetMinSoc sets the charge minimum soc
@ -48,8 +40,23 @@ type API interface {
// SetPhases sets the enabled phases
SetPhases(int) error
// SetTargetCharge sets the charge targetSoc
SetTargetCharge(time.Time, int) error
// GetTargetTime returns the target time
GetTargetTime() time.Time
// SetTargetTime sets the target time
SetTargetTime(time.Time) error
// GetTargetEnergy returns the charge target energy
GetTargetEnergy() float64
// SetTargetEnergy sets the charge target energy
SetTargetEnergy(float64)
// GetTargetSoc returns the charge target soc
GetTargetSoc() int
// SetTargetSoc sets the charge target soc
SetTargetSoc(int)
// GetPlannerUnit returns the planning tariffs unit
GetPlannerUnit() string
// GetPlan creates a charging plan
GetPlan(targetTime time.Time, maxPower float64) (time.Duration, api.Rates, error)
// RemoteControl sets remote status demand
RemoteControl(string, RemoteDemand)

View file

@ -184,42 +184,35 @@ func (lp *Loadpoint) SetPhases(phases int) error {
return nil
}
// SetTargetCharge sets loadpoint charge targetSoc
func (lp *Loadpoint) SetTargetCharge(finishAt time.Time, soc int) error {
// GetTargetTime returns the target time
func (lp *Loadpoint) GetTargetTime() time.Time {
lp.Lock()
defer lp.Unlock()
return lp.targetTime
}
// SetTargetTime sets the charge target time
func (lp *Loadpoint) SetTargetTime(finishAt time.Time) error {
if !finishAt.IsZero() && finishAt.Before(time.Now()) {
return errors.New("timestamp is in the past")
}
lp.log.DEBUG.Printf("set target charge: %d%% @ %v", soc, finishAt)
// apply immediately
if !lp.targetTime.Equal(finishAt) || lp.Soc.target != soc {
lp.setTargetTime(finishAt)
// don't remove soc
if !finishAt.IsZero() {
lp.setTargetSoc(soc)
lp.requestUpdate()
}
}
return nil
}
// SetTargetTime sets the charge target time
func (lp *Loadpoint) SetTargetTime(finishAt time.Time) {
lp.Lock()
defer lp.Unlock()
lp.setTargetTime(finishAt)
return nil
}
// setTargetTime sets the charge target time
func (lp *Loadpoint) setTargetTime(finishAt time.Time) {
lp.targetTime = finishAt
lp.publish(targetTime, finishAt)
// TODO planActive is not guarded by mutex
if finishAt.IsZero() {
lp.setPlanActive(false)
}
}
// RemoteControl sets remote status demand

148
core/loadpoint_plan.go Normal file
View file

@ -0,0 +1,148 @@
package core
import (
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/planner"
"github.com/evcc-io/evcc/core/soc"
"golang.org/x/exp/slices"
)
// 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)
}
// planRequiredDuration is the estimated total charging duration
func (lp *Loadpoint) planRequiredDuration(maxPower float64) time.Duration {
var requiredDuration time.Duration
if energy, ok := lp.remainingChargeEnergy(); ok {
requiredDuration = time.Duration(energy * 1e3 / maxPower * float64(time.Hour))
} else if lp.socEstimator != nil {
// TODO vehicle soc limit
targetSoc := lp.Soc.target
if targetSoc == 0 {
targetSoc = 100
}
requiredDuration = lp.socEstimator.RemainingChargeDuration(targetSoc, maxPower)
// anticipate lower charge rates at end of charging curve
var additionalDuration time.Duration
if targetSoc > 80 && maxPower > 15000 {
additionalDuration = 5 * time.Duration(float64(targetSoc-80)/(float64(targetSoc)-lp.vehicleSoc)*float64(requiredDuration))
lp.log.DEBUG.Printf("add additional charging time %v for soc > 80%%", additionalDuration.Round(time.Minute))
} else if targetSoc > 90 && maxPower > 4000 {
additionalDuration = 3 * time.Duration(float64(targetSoc-90)/(float64(targetSoc)-lp.vehicleSoc)*float64(requiredDuration))
lp.log.DEBUG.Printf("add additional charging time %v for soc > 90%%", additionalDuration.Round(time.Minute))
}
requiredDuration += additionalDuration
}
requiredDuration = time.Duration(float64(requiredDuration) / soc.ChargeEfficiency)
return requiredDuration
}
func (lp *Loadpoint) GetPlannerUnit() string {
return lp.planner.Unit()
}
// GetPlan creates a charging plan
//
// Results:
// - required total charging duration
// - actual charging plan as rate table
func (lp *Loadpoint) GetPlan(targetTime time.Time, maxPower float64) (time.Duration, api.Rates, error) {
if lp.planner == nil || targetTime.IsZero() {
return 0, nil, nil
}
// don't start planning into the past
if targetTime.Before(lp.clock.Now()) && !lp.planActive {
return 0, nil, nil
}
requiredDuration := lp.planRequiredDuration(maxPower)
plan, err := lp.planner.Plan(requiredDuration, targetTime)
// sort plan by time
slices.SortStableFunc(plan, planner.SortByTime)
return requiredDuration, plan, err
}
// plannerActive checks if charging plan is active
func (lp *Loadpoint) plannerActive() (active bool) {
defer func() {
lp.setPlanActive(active)
}()
maxPower := lp.GetMaxPower()
requiredDuration, plan, err := lp.GetPlan(lp.GetTargetTime(), maxPower)
if err != nil {
lp.log.ERROR.Println("planner:", err)
return false
}
// nothing to do
if requiredDuration == 0 {
return false
}
planStart := planner.Start(plan)
lp.publish(planProjectedStart, planStart)
lp.log.DEBUG.Printf("planned %v until %v at %.0fW: total plan duration: %v, avg cost: %.3f",
requiredDuration.Round(time.Second), lp.targetTime.Round(time.Second).Local(), maxPower,
planner.Duration(plan).Round(time.Second), planner.AverageCost(plan))
// sort plan by time
for _, slot := range plan {
lp.log.TRACE.Printf(" slot from: %v to %v cost %.3f", slot.Start.Round(time.Second).Local(), slot.End.Round(time.Second).Local(), slot.Price)
}
activeSlot := planner.ActiveSlot(lp.clock, plan)
active = !activeSlot.End.IsZero()
if active {
// ignore short plans if not already active
if !lp.planActive && lp.clock.Until(activeSlot.End) < smallSlotDuration {
lp.log.DEBUG.Printf("plan too short- ignoring remaining %v", requiredDuration.Round(time.Second))
return false
}
// remember last active plan's end time
lp.setPlanActive(true)
lp.planSlotEnd = activeSlot.End
} else if lp.planActive {
// planner was active (any slot, not necessarily previous slot) and charge goal has not yet been met
switch {
case lp.clock.Now().After(lp.targetTime) && !lp.targetTime.IsZero():
// if the plan did not (entirely) work, we may still be charging beyond plan end- in that case, continue charging
// TODO check when schedule is implemented
lp.log.DEBUG.Println("continuing after target time")
return true
case lp.clock.Now().Before(lp.planSlotEnd) && !lp.planSlotEnd.IsZero():
// don't stop an already running slot if goal was not met
lp.log.DEBUG.Println("continuing until end of slot")
return true
case requiredDuration < 30*time.Minute:
lp.log.DEBUG.Printf("continuing for remaining %v", requiredDuration.Round(time.Second))
return true
case lp.clock.Until(planStart) < smallSlotDuration:
lp.log.DEBUG.Printf("plan will re-start shortly, continuing for remaining %v", lp.clock.Until(planStart).Round(time.Second))
return true
}
}
return active
}

View file

@ -3,6 +3,7 @@ package planner
import (
"time"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
)
@ -25,11 +26,22 @@ func Duration(plan api.Rates) time.Duration {
return duration
}
func Cost(plan api.Rates) float64 {
func AverageCost(plan api.Rates) float64 {
var cost float64
var duration time.Duration
for _, slot := range plan {
slotDuration := slot.End.Sub(slot.Start)
cost += float64(slotDuration) / float64(time.Hour) * slot.Price
duration += slotDuration
cost += float64(slotDuration) * slot.Price
}
return cost
return cost / float64(duration)
}
func ActiveSlot(clock clock.Clock, plan api.Rates) api.Rate {
for _, slot := range plan {
if (slot.Start.Before(clock.Now()) || slot.Start.Equal(clock.Now())) && slot.End.After(clock.Now()) {
return slot
}
}
return api.Rate{}
}

View file

@ -1,13 +1,13 @@
package planner
import (
"sort"
"time"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/jinzhu/copier"
"golang.org/x/exp/slices"
)
// Planner plans a series of charging slots for a given (variable) tariff
@ -26,11 +26,11 @@ func New(log *util.Logger, tariff api.Tariff) *Planner {
}
}
// Plan creates a lowest-cost plan or required duration.
// plan creates a lowest-cost plan or required duration.
// It MUST already established that
// - rates are sorted in ascending order by cost and descending order by start time (prefer late slots)
// - target time and required duration are before end of rates
func (t *Planner) Plan(rates api.Rates, requiredDuration time.Duration, targetTime time.Time) api.Rates {
func (t *Planner) plan(rates api.Rates, requiredDuration time.Duration, targetTime time.Time) api.Rates {
var plan api.Rates
for _, source := range rates {
@ -61,12 +61,11 @@ func (t *Planner) Plan(rates api.Rates, requiredDuration time.Duration, targetTi
requiredDuration = 0
if slot.End.Before(slot.Start) {
t.log.ERROR.Print("slot end before start")
panic("slot end before start")
}
}
plan = append(plan, slot)
t.log.TRACE.Printf(" slot from: %v to %v cost %.3f", slot.Start.Round(time.Second).Local(), slot.End.Round(time.Second).Local(), slot.Price)
// we found all necessary slots
if requiredDuration == 0 {
@ -77,41 +76,59 @@ func (t *Planner) Plan(rates api.Rates, requiredDuration time.Duration, targetTi
return plan
}
// 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, time.Time, bool, error) {
func (t *Planner) Unit() string {
if t.tariff == nil {
return ""
}
return t.tariff.Unit()
}
// Plan creates a lowest-cost charging plan, considering edge conditions
func (t *Planner) Plan(requiredDuration time.Duration, targetTime time.Time) (api.Rates, error) {
if t == nil || requiredDuration <= 0 {
return time.Time{}, time.Time{}, false, nil
return nil, nil
}
// calculate start time
latestStart := targetTime.Add(-requiredDuration)
afterStart := t.clock.Now().After(latestStart) || t.clock.Now().Equal(latestStart)
beforeTarget := t.clock.Now().Before(targetTime)
// simplePlan only considers time, but not cost
simplePlan := api.Rates{
api.Rate{
Start: latestStart,
End: targetTime,
},
}
// target charging without tariff or late start
if t.tariff == nil || afterStart {
return latestStart, targetTime, afterStart && beforeTarget, nil
if t.tariff == nil {
return simplePlan, nil
}
rates, err := t.tariff.Rates()
// treat like normal target charging if we don't have rates
if len(rates) == 0 || err != nil {
return latestStart, targetTime, afterStart && beforeTarget, err
return simplePlan, err
}
// consume remaining time
if t.clock.Now().After(latestStart) || t.clock.Now().Equal(latestStart) {
requiredDuration = t.clock.Until(targetTime)
}
// 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)
slices.SortStableFunc(rates, sortByCost)
// 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{}, time.Time{}, false, nil
return nil, nil
}
// need to use some of the available slots
@ -122,28 +139,5 @@ func (t *Planner) Active(requiredDuration time.Duration, targetTime time.Time) (
requiredDuration -= durationAfterRates
}
plan := t.Plan(rates, requiredDuration, targetTime)
var activeSlot api.Rate
var planStart time.Time
var planDuration time.Duration
var planCost float64
for _, slot := range plan {
slotDuration := slot.End.Sub(slot.Start)
planDuration += slotDuration
planCost += float64(slotDuration) / float64(time.Hour) * slot.Price
if planStart.IsZero() || slot.Start.Before(planStart) {
planStart = slot.Start
}
if (slot.Start.Before(t.clock.Now()) || slot.Start.Equal(t.clock.Now())) && slot.End.After(t.clock.Now()) {
activeSlot = slot
}
}
t.log.DEBUG.Printf("total plan duration: %v, cost: %.2f", planDuration.Round(time.Second), planCost)
return planStart, activeSlot.End, !activeSlot.End.IsZero(), nil
return t.plan(rates, requiredDuration, targetTime), nil
}

View file

@ -1,7 +1,6 @@
package planner
import (
"sort"
"testing"
"time"
@ -11,6 +10,7 @@ import (
"github.com/evcc-io/evcc/util"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"golang.org/x/exp/slices"
)
func rates(prices []float64, start time.Time, slotEndFunc ...func(time.Time) time.Time) api.Rates {
@ -40,24 +40,24 @@ func rates(prices []float64, start time.Time, slotEndFunc ...func(time.Time) tim
// TODO start before start of rates
func TestPlan(t *testing.T) {
clck := clock.NewMock()
clock := clock.NewMock()
ctrl := gomock.NewController(t)
trf := mock.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{20, 60, 10, 80, 40, 90}, clck.Now()), nil)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{20, 60, 10, 80, 40, 90}, clock.Now()), nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clck,
clock: clock,
}
rates, err := trf.Rates()
assert.NoError(t, err)
sort.Sort(rates)
slices.SortStableFunc(rates, sortByCost)
{
plan := p.Plan(rates, time.Hour, clck.Now())
plan := p.plan(rates, time.Hour, clock.Now())
assert.Equal(t, 0, len(plan))
}
@ -76,27 +76,27 @@ func TestPlan(t *testing.T) {
{
"plan 0-0-60-0-0-0",
time.Hour,
clck.Now(),
clck.Now().Add(6 * time.Hour),
clck.Now().Add(2 * time.Hour),
clock.Now(),
clock.Now().Add(6 * time.Hour),
clock.Now().Add(2 * time.Hour),
time.Hour,
10,
},
{
"plan 60-0-60-0-0-0",
2 * time.Hour,
clck.Now(),
clck.Now().Add(6 * time.Hour),
clck.Now().Add(0 * time.Hour),
clock.Now(),
clock.Now().Add(6 * time.Hour),
clock.Now().Add(0 * time.Hour),
2 * time.Hour,
30,
},
{
"plan (30)30-0-60-0-0-0",
time.Duration(90 * time.Minute),
clck.Now(),
clck.Now().Add(6 * time.Hour),
clck.Now().Add(30 * time.Minute),
clock.Now(),
clock.Now().Add(6 * time.Hour),
clock.Now().Add(30 * time.Minute),
time.Duration(90 * time.Minute),
20,
},
@ -104,27 +104,27 @@ func TestPlan(t *testing.T) {
{
"plan 0-0-60-0-0-0",
time.Hour,
clck.Now().Add(30 * time.Minute),
clck.Now().Add(6 * time.Hour),
clck.Now().Add(2 * time.Hour),
clock.Now().Add(30 * time.Minute),
clock.Now().Add(6 * time.Hour),
clock.Now().Add(2 * time.Hour),
time.Hour,
10,
},
{
"plan (30)30-0-60-0-30(30)-0",
2 * time.Hour,
clck.Now().Add(30 * time.Minute),
clck.Now().Add(6 * time.Hour),
clck.Now().Add(30 * time.Minute),
clock.Now().Add(30 * time.Minute),
clock.Now().Add(6 * time.Hour),
clock.Now().Add(30 * time.Minute),
2 * time.Hour,
40,
},
{
"plan (30)30-0-60-0-0-0",
time.Duration(90 * time.Minute),
clck.Now().Add(30 * time.Minute),
clck.Now().Add(6 * time.Hour),
clck.Now().Add(30 * time.Minute),
clock.Now().Add(30 * time.Minute),
clock.Now().Add(6 * time.Hour),
clock.Now().Add(30 * time.Minute),
time.Duration(90 * time.Minute),
20,
},
@ -132,118 +132,118 @@ func TestPlan(t *testing.T) {
for i, tc := range tc {
t.Log(tc.desc)
clck.Set(tc.now)
plan := p.Plan(rates, tc.duration, tc.target)
clock.Set(tc.now)
plan := p.plan(rates, tc.duration, tc.target)
assert.Equalf(t, tc.planStart.UTC(), Start(plan).UTC(), "case %d start", i)
assert.Equalf(t, tc.planDuration, Duration(plan), "case %d duration", i)
assert.Equalf(t, tc.planCost, Cost(plan), "case %d cost", i)
assert.Equalf(t, tc.planCost, AverageCost(plan)*float64(Duration(plan))/float64(time.Hour), "case %d cost", i)
}
}
func TestNilTariff(t *testing.T) {
clck := clock.NewMock()
clock := clock.NewMock()
p := &Planner{
log: util.NewLogger("foo"),
clock: clck,
clock: clock,
}
_, _, res, err := p.Active(time.Hour, clck.Now().Add(30*time.Minute))
plan, err := p.Plan(time.Hour, clock.Now().Add(30*time.Minute))
assert.NoError(t, err)
assert.True(t, res, "should start past start time")
assert.True(t, !ActiveSlot(clock, plan).End.IsZero(), "should start past start time")
_, _, res, err = p.Active(time.Hour, clck.Now().Add(-30*time.Minute))
plan, err = p.Plan(time.Hour, clock.Now().Add(-30*time.Minute))
assert.NoError(t, err)
assert.False(t, res, "should not start past target time")
assert.False(t, !ActiveSlot(clock, plan).End.IsZero(), "should not start past target time")
}
func TestFlatTariffTargetInThePast(t *testing.T) {
clck := clock.NewMock()
clock := clock.NewMock()
ctrl := gomock.NewController(t)
trf := mock.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clck.Now()), nil)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clock.Now()), nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clck,
clock: clock,
tariff: trf,
}
_, _, res, err := p.Active(time.Hour, clck.Now().Add(30*time.Minute))
plan, err := p.Plan(time.Hour, clock.Now().Add(30*time.Minute))
assert.NoError(t, err)
assert.True(t, res, "should start past start time")
assert.True(t, !ActiveSlot(clock, plan).End.IsZero(), "should start past start time")
_, _, res, err = p.Active(time.Hour, clck.Now().Add(-30*time.Minute))
plan, err = p.Plan(time.Hour, clock.Now().Add(-30*time.Minute))
assert.NoError(t, err)
assert.False(t, res, "should not start past target time")
assert.False(t, !ActiveSlot(clock, plan).End.IsZero(), "should not start past target time")
}
func TestFlatTariffLongSlots(t *testing.T) {
clck := clock.NewMock()
clock := clock.NewMock()
ctrl := gomock.NewController(t)
trf := mock.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clck.Now(), func(start time.Time) time.Time {
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clock.Now(), func(start time.Time) time.Time {
return start.Add(24 * time.Hour)
}), nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clck,
clock: clock,
tariff: trf,
}
_, _, res, err := p.Active(time.Hour, clck.Now().Add(2*time.Hour))
plan, err := p.Plan(time.Hour, clock.Now().Add(2*time.Hour))
assert.NoError(t, err)
assert.False(t, res, "should not start long last slot before due time")
assert.False(t, !ActiveSlot(clock, plan).End.IsZero(), "should not start long last slot before due time")
_, _, res, err = p.Active(time.Hour, clck.Now().Add(time.Hour))
plan, err = p.Plan(time.Hour, clock.Now().Add(time.Hour))
assert.NoError(t, err)
assert.True(t, res, "should start long last slot after due time")
assert.True(t, !ActiveSlot(clock, plan).End.IsZero(), "should start long last slot after due time")
}
func TestTargetAfterKnownPrices(t *testing.T) {
clck := clock.NewMock()
clock := clock.NewMock()
ctrl := gomock.NewController(t)
trf := mock.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clck.Now()), nil)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clock.Now()), nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clck,
clock: clock,
tariff: trf,
}
_, _, res, err := p.Active(40*time.Minute, clck.Now().Add(2*time.Hour)) // charge efficiency does not allow to test with 1h
plan, err := p.Plan(40*time.Minute, clock.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 ")
assert.False(t, !ActiveSlot(clock, plan).End.IsZero(), "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))
plan, err = p.Plan(2*time.Hour, clock.Now().Add(2*time.Hour))
assert.NoError(t, err)
assert.True(t, res, "should start if car can not be charged completely after known prices ")
assert.True(t, !ActiveSlot(clock, plan).End.IsZero(), "should start if car can not be charged completely after known prices ")
}
func TestChargeAfterTargetTime(t *testing.T) {
clck := clock.NewMock()
clock := clock.NewMock()
ctrl := gomock.NewController(t)
trf := mock.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0, 0, 0, 0}, clck.Now()), nil)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0, 0, 0, 0}, clock.Now()), nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clck,
clock: clock,
tariff: trf,
}
_, _, res, err := p.Active(time.Hour, clck.Now())
plan, err := p.Plan(time.Hour, clock.Now())
assert.NoError(t, err)
assert.False(t, res, "should not start past target time")
assert.False(t, !ActiveSlot(clock, plan).End.IsZero(), "should not start past target time")
_, _, res, err = p.Active(time.Hour, clck.Now().Add(-time.Hour))
plan, err = p.Plan(time.Hour, clock.Now().Add(-time.Hour))
assert.NoError(t, err)
assert.False(t, res, "should not start past target time")
assert.False(t, !ActiveSlot(clock, plan).End.IsZero(), "should not start past target time")
}

17
core/planner/sort.go Normal file
View file

@ -0,0 +1,17 @@
package planner
import "github.com/evcc-io/evcc/api"
// SortByTime is a sortFunc for slices.Sort
func SortByTime(i, j api.Rate) bool {
return i.Start.Before(j.Start)
}
// sortByCost is a sortFunc for slices.Sort
func sortByCost(i, j api.Rate) bool {
if i.Price == j.Price {
return i.Start.After(j.Start)
}
return i.Price < j.Price
}

50
core/planner/sort_test.go Normal file
View file

@ -0,0 +1,50 @@
package planner
import (
"testing"
"time"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/stretchr/testify/assert"
"golang.org/x/exp/slices"
)
func testRates(clock clock.Clock) api.Rates {
return api.Rates{
{
Price: 2,
Start: clock.Now().Add(2 * time.Hour),
},
{
Price: 2,
Start: clock.Now(),
},
{
Price: 1,
Start: clock.Now().Add(time.Hour),
},
}
}
func TestRatesSortByTime(t *testing.T) {
clock := clock.NewMock()
r := testRates(clock)
slices.SortStableFunc(r, SortByTime)
assert.Equal(t, clock.Now(), r[0].Start)
assert.Equal(t, clock.Now().Add(time.Hour), r[1].Start) // late slots first
assert.Equal(t, clock.Now().Add(2*time.Hour), r[2].Start)
}
func TestRatesSortByCost(t *testing.T) {
clock := clock.NewMock()
r := testRates(clock)
slices.SortStableFunc(r, sortByCost)
assert.Equal(t, clock.Now().Add(time.Hour), r[0].Start)
assert.Equal(t, clock.Now().Add(2*time.Hour), r[1].Start)
assert.Equal(t, clock.Now(), r[2].Start)
}

View file

@ -128,10 +128,7 @@ func NewSiteFromConfig(
})
}
tariff := site.tariffs.Grid
if site.tariffs.Planner != nil {
tariff = site.tariffs.Planner
}
tariff := site.GetTariff(PlannerTariff)
// give loadpoints access to vehicles and database
for _, lp := range loadpoints {

View file

@ -34,9 +34,9 @@ type API interface {
GetVehicles() []api.Vehicle
//
// tariffs
// Tariffs
//
// GetTariff returns the tariffs rates
GetTariff(tariff string) (api.Rates, error)
// GetTariff returns the respective tariff
GetTariff(string) api.Tariff
}

View file

@ -9,6 +9,12 @@ import (
var _ site.API = (*Site)(nil)
const (
GridTariff = "grid"
FeedinTariff = "feedin"
PlannerTariff = "planner"
)
// GetPrioritySoc returns the PrioritySoc
func (site *Site) GetPrioritySoc() float64 {
site.Lock()
@ -78,27 +84,22 @@ func (site *Site) GetVehicles() []api.Vehicle {
return site.coordinator.GetVehicles()
}
// GetTariff returns the tariffs rates
func (site *Site) GetTariff(name string) (api.Rates, error) {
// GetTariff returns the respective tariff if configured or nil
func (site *Site) GetTariff(tariff string) api.Tariff {
site.Lock()
defer site.Unlock()
var tariff api.Tariff
switch name {
case "grid":
tariff = site.tariffs.Grid
case "feedin":
tariff = site.tariffs.FeedIn
case "planner":
if tariff = site.tariffs.Planner; tariff == nil {
tariff = site.tariffs.Grid
var t api.Tariff
switch tariff {
case GridTariff:
t = site.tariffs.Grid
case FeedinTariff:
t = site.tariffs.FeedIn
case PlannerTariff:
if t = site.tariffs.Planner; t == nil {
t = site.tariffs.Grid
}
}
if tariff == nil {
return nil, errors.New("invalid tariff")
}
return tariff.Rates()
return t
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

116
dist/assets/index.75753078.js vendored Normal file

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View file

@ -20,8 +20,8 @@
<meta name="theme-color" content="#020318" />
<title>evcc</title>
<script type="module" crossorigin src="./assets/index.1481ecc6.js"></script>
<link rel="stylesheet" href="./assets/index.1581b26b.css">
<script type="module" crossorigin src="./assets/index.75753078.js"></script>
<link rel="stylesheet" href="./assets/index.5861eb97.css">
</head>
<body>

View file

@ -175,13 +175,11 @@ title = "Måltid"
inactiveLabel = "Måltid"
activeLabel = "{time}"
modalTitle = "Sæt måltid"
description = "Hvornår skal køretøjet oplades til {targetSoc} %?"
descriptionSoc = "Hvornår skal køretøjet oplades til {targetSoc} %?"
tomorrow = "i morgen"
activate = "Tænd"
experimentalLabel = "Eksperimentel"
today = "i dag"
targetIsInThePast = "Vælg et tidspunkt i fremtiden, Marty."
experimentalText = "Dette virker, men er ikke perfekt. Rapportér venligst uventet adfærd til"
setTargetTime = "ingen"
[startupError]

View file

@ -186,14 +186,17 @@ inactiveLabel = "Zielzeit"
activeLabel = "{time}"
modalTitle = "Zielzeit festlegen"
setTargetTime = "keine"
description = "Wann soll das Fahrzeug auf {targetSoc}% geladen sein?"
descriptionSoc = "Wann soll das Fahrzeug auf {targetSoc}% geladen sein?"
descriptionEnergy = "Bis wann sollen {targetEnergy} ins Fahrzeug geladen sein?"
today = "heute"
tomorrow = "morgen"
targetIsInThePast = "Wähle einen Zeitpunkt in der Zukunft, Marty."
remove = "Entfernen"
activate = "Aktivieren"
experimentalLabel = "Experimentell"
experimentalText = "Dieses Feature funktioniert, ist aber noch nicht perfekt. Bitte melde unerwartetes Verhalten in unseren"
planUnknown = "unbekannt"
planDuration = "Ladezeit"
planPeriodLabel = "Zeitraum"
planPeriodValue = "{start} bis {end}"
[main.targetEnergy]
label = "Ladeziel"

View file

@ -186,14 +186,17 @@ inactiveLabel = "Target time"
activeLabel = "{time}"
modalTitle = "Set Target Time"
setTargetTime = "none"
description = "When should the vehicle be charged to {targetSoc}%?"
descriptionSoc = "When should the vehicle be charged to {targetSoc}%?"
descriptionEnergy = "Until when should {targetEnergy} be loaded into the vehicle?"
today = "today"
tomorrow = "tomorrow"
targetIsInThePast = "Pick a time in the future, Marty."
remove = "Remove"
activate = "Activate"
experimentalLabel = "Experimental"
experimentalText = "This works, but isn't perfect. Please report unexpected behaviour to"
planUnknown = "unknown"
planDuration = "Charging time"
planPeriodLabel = "Period"
planPeriodValue = "{start} to {end}"
[main.targetEnergy]
label = "Limit"

View file

@ -170,14 +170,12 @@ inactiveLabel = "tiempo objetivo"
activeLabel = "{time}"
modalTitle = "Establecer el tiempo objetivo"
setTargetTime = "niguna"
description = "¿Cuándo se debe cargar el vehículo al {targetSoc}%?"
descriptionSoc = "¿Cuándo se debe cargar el vehículo al {targetSoc}%?"
today = "hoy"
tomorrow = "mañana"
targetIsInThePast = "Elegir un tiempo en el futuro, Marty."
remove = "retirar"
activate = "Activar"
experimentalLabel = "Experimental"
experimentalText = "Esto funciona, pero no es perfecto. Informe cualquier comportamiento inesperado a"
[main.targetEnergy]
label = "Objetivo de carga"

View file

@ -154,9 +154,7 @@ activeLabel = "{time}"
modalTitle = "Aseta tavoiteaika"
targetIsInThePast = "Valitse aika tulevaisuudessa"
activate = "Aktivoi"
experimentalLabel = "Kokeellinen"
experimentalText = "Tämä toimii, mutta ei ole täydellinen. Ilmoita odottamattomasta toiminnasta"
description = "Milloin ajoneuvon tulee olla ladattu {targetSoc}%?"
descriptionSoc = "Milloin ajoneuvon tulee olla ladattu {targetSoc}%?"
today = "tänään"
setTargetTime = "ei mitään"
tomorrow = "huomenna"

View file

@ -170,14 +170,12 @@ inactiveLabel = "Temps visé"
activeLabel = "{time}"
modalTitle = "Définir l'heure cible"
setTargetTime = "Non"
description = "Quand le véhicule doit-il être rechargé à {targetSoc}%?"
descriptionSoc = "Quand le véhicule doit-il être rechargé à {targetSoc}%?"
today = "Aujourd'hui"
tomorrow = "Matin"
targetIsInThePast = "Choisissez une heure dans le futur, Marty."
remove = "Ôter"
activate = "Activer"
experimentalLabel = "Expérimental"
experimentalText = "Cela fonctionne, mais n'est pas parfait. Veuillez signaler tout comportement inattendu à"
[main.targetEnergy]
label = "Cible de chargement"

View file

@ -191,12 +191,10 @@ scale1p = "Smanjuje se na jednofaznu struju za {remaining}…"
scale3p = "Povećava se na trofaznu struju za {remaining}…"
[main.targetCharge]
description = "Kada bi se vozilo trebalo napojiti na {targetSoc} %?"
descriptionSoc = "Kada bi se vozilo trebalo napojiti na {targetSoc} %?"
today = "danas"
targetIsInThePast = "Odaberi vrijeme u budućnosti."
activate = "Aktiviraj"
experimentalLabel = "Eksperimentalno"
experimentalText = "Ovo radi, ali nije savršeno. Prijavi neočekivano ponašanje našem timu"
title = "Ciljano vrijeme"
setTargetTime = "ništa"
inactiveLabel = "Ciljano vrijeme"

View file

@ -187,11 +187,9 @@ inactiveLabel = "Tempo obiettivo"
activeLabel = "{time}"
modalTitle = "Imposta orario target"
setTargetTime = "nessuno"
description = "Quando dovrebbe essere addebitato il veicolo a {targetSoc}%?"
descriptionSoc = "Quando dovrebbe essere addebitato il veicolo a {targetSoc}%?"
tomorrow = "domani"
targetIsInThePast = "Scegli un momento nel futuro, Marty."
experimentalLabel = "Sperimentale"
experimentalText = "Funziona, ma non è perfetto. Segnala un comportamento imprevisto a"
today = "oggi"
[sessions.csv]

View file

@ -115,13 +115,11 @@ targetIsInThePast = "Déi gewielt Zäit ass an der Vergaangenheet."
activate = "Aktivéieren"
title = "Zilzäit"
setTargetTime = "keen"
experimentalLabel = "Experimentell"
description = "Wéini soll d'Gefier op {targetSoc}% gelueden ginn?"
descriptionSoc = "Wéini soll d'Gefier op {targetSoc}% gelueden ginn?"
modalTitle = "Zielzäit setzen"
today = "haut"
tomorrow = "muer"
inactiveLabel = "Zilzäit"
experimentalText = "Dëst funktionnéiert, awer ass net perfekt. Meld w.e.g. onerwaart Verhalen un"
activeLabel = "{time}"
[main.targetEnergy]

View file

@ -170,14 +170,12 @@ inactiveLabel = "Suplanuotas įkrovimas"
activeLabel = "{time}"
modalTitle = "Nustatyti įkrovimo pabaigos laiką"
setTargetTime = "nenustatytas"
description = "Kada automobilis turėtų būti įkrautas iki {targetSoc}%?"
descriptionSoc = "Kada automobilis turėtų būti įkrautas iki {targetSoc}%?"
today = "šiandien"
tomorrow = "rytoj"
targetIsInThePast = "Pasirinkite laiką ateityje."
remove = "Panaikinti"
activate = "Aktyvuoti"
experimentalLabel = "Eksperimentinė"
experimentalText = "Ši funkcija veikia, bet nėra tobula. Apie netikėtą elgesį praneškite mūsų"
[main.targetEnergy]
label = "Limitas"

View file

@ -170,14 +170,12 @@ inactiveLabel = "Ingestelde tijd"
activeLabel = "{time}"
modalTitle = "Stel tijd in"
setTargetTime = "geen"
description = "Tegen wanneer moet het voertuig geladen zijn naar {targetSoc}%?"
descriptionSoc = "Tegen wanneer moet het voertuig geladen zijn naar {targetSoc}%?"
today = "Vandaag"
tomorrow = "Morgen"
targetIsInThePast = "Pick een tijd in de toekomst, Marty."
remove = "Verwijder"
activate = "Activeer"
experimentalLabel = "Experimenteel"
experimentalText = "Dit werkt, maar is niet perfect. Meld onverwacht gedrag aan"
[main.targetEnergy]
label = "Limiet"

View file

@ -137,11 +137,9 @@ targetChargeWaitForVehicle = "Mållading klar. Venter på kjøretøy..."
[main.targetCharge]
inactiveLabel = "Mål-tid"
activeLabel = "{time}"
description = "Når skal kjøretøyet lades til {targetSoc}%?"
descriptionSoc = "Når skal kjøretøyet lades til {targetSoc}%?"
tomorrow = "i morgen"
experimentalLabel = "Eksperimentelt"
activate = "Aktiver"
experimentalText = "Dette fungerer, men er ikke perfekt. Rapporter uventet oppførsel til"
title = "Mål-tid"
modalTitle = "Sett mål-tid"
setTargetTime = "ingen"

View file

@ -170,14 +170,12 @@ inactiveLabel = "Czas docelowy"
activeLabel = "{time}"
modalTitle = "Ustaw docelowy czas"
setTargetTime = "brak"
description = "Kiedy pojazd powinien zostać naladowany do {targetSoc}%?"
descriptionSoc = "Kiedy pojazd powinien zostać naladowany do {targetSoc}%?"
today = "dzisiaj"
tomorrow = "jutro"
targetIsInThePast = "Wybierz czas w przyszłości."
remove = "Usuń"
activate = "Aktywować"
experimentalLabel = "Eksperymentalna"
experimentalText = "Ta funkcja działa, ale nie jest jeszcze doskonała. Zgłoś nieoczekiwane zachowanie do naszego"
[main.targetEnergy]
label = "Ograniczenie"

View file

@ -170,14 +170,12 @@ inactiveLabel = "Tempo alvo"
activeLabel = "{time}"
modalTitle = "Definir hora-alvo"
setTargetTime = "sem"
description = "Quando o veículo deve ser carregado para {targetSoc}%?"
descriptionSoc = "Quando o veículo deve ser carregado para {targetSoc}%?"
today = "hoje"
tomorrow = "amanhã"
targetIsInThePast = "Escolha um momento no futuro, Marty."
remove = "Remover"
activate = "Ativar"
experimentalLabel = "Experimental"
experimentalText = "Isto funciona, mas não é perfeito. Relate comportamento inesperado para"
[main.targetEnergy]
label = "Alvo de carregamento"

View file

@ -170,10 +170,8 @@ title = "Timp targetat"
modalTitle = "Setare timp targetat"
targetIsInThePast = "Alege un timp in viitor, Costele."
today = "azi"
experimentalLabel = "Experimental"
experimentalText = "Functioneaza, dar nu e perfect inca. Te rog raporteaza orice comportament neateptat la"
inactiveLabel = "Timp targetat"
description = "Cand ar trebui vehiculul sa fie incarcat la {targetSoc}%?"
descriptionSoc = "Cand ar trebui vehiculul sa fie incarcat la {targetSoc}%?"
activeLabel = "{time}"
tomorrow = "maine"
activate = "Activeaza"

View file

@ -174,15 +174,13 @@ logout = "выйти"
today = "сегодня"
tomorrow = "завтра"
activate = "Активировать"
experimentalLabel = "Экспериментально"
title = "Запланированное время"
inactiveLabel = "Запланированное время"
activeLabel = "{time}"
modalTitle = "Установить запланированное время"
setTargetTime = "ни одного"
description = "Когда транспортное средство следует зарядить до {targetSoc}%?"
descriptionSoc = "Когда транспортное средство следует зарядить до {targetSoc}%?"
targetIsInThePast = "Выбери время в будущем."
experimentalText = "Оно работает, но не идеально. Пожалуйста сообщите о проблемах"
[main.targetEnergy]
label = "Лимит"

View file

@ -99,13 +99,11 @@ title = "Ciljni čas"
inactiveLabel = "Ciljni čas"
activeLabel = "{time}"
setTargetTime = "brez"
description = "Kdaj želite, da je vozilo napolnjeno na {targetSoc}%?"
descriptionSoc = "Kdaj želite, da je vozilo napolnjeno na {targetSoc}%?"
today = "danes"
tomorrow = "jutri"
targetIsInThePast = "Izberi čas v prihodnosti, Marty."
activate = "Aktiviraj"
experimentalLabel = "Eksperimentalno"
experimentalText = "To deluje, vendar ni popolno. Nepričakovano obnašanje sporočite na"
[sessions]
title = "Seje polnjenja"

View file

@ -211,8 +211,6 @@ today = "сьогодні"
targetIsInThePast = "Виберіть час у майбутньому."
activate = "Активувати"
inactiveLabel = "Запланований час"
experimentalLabel = "Експериментальний"
experimentalText = "Це працює, але не ідеально. Будь ласка, повідомте про несподівану поведінку"
description = "Коли транспортний засіб повинен бути заряджений на {targetSoc}%?"
[main.loadpoint]

View file

@ -51,11 +51,11 @@ func NewTibberFromConfig(other map[string]interface{}) (api.Meter, error) {
qclient := tibber.NewClient(t.log, cc.Token)
if cc.HomeID == "" {
if home, err := qclient.DefaultHome(""); err != nil {
home, err := qclient.DefaultHome("")
if err != nil {
return nil, err
} else {
cc.HomeID = home.ID
}
cc.HomeID = home.ID
}
var res struct {

View file

@ -100,11 +100,11 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, cache *util.Cache) {
"buffersoc": {[]string{"POST", "OPTIONS"}, "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoc, site.GetBufferSoc)},
"prioritysoc": {[]string{"POST", "OPTIONS"}, "/prioritysoc/{value:[0-9.]+}", floatHandler(site.SetPrioritySoc, site.GetPrioritySoc)},
"residualpower": {[]string{"POST", "OPTIONS"}, "/residualpower/{value:[-0-9.]+}", floatHandler(site.SetResidualPower, site.GetResidualPower)},
"tariff": {[]string{"GET"}, "/tariff/{tariff:[a-z]+}", tariffHandler(site)},
"sessions": {[]string{"GET"}, "/sessions", sessionHandler},
"sessions2": {[]string{"DELETE"}, "/sessions/{id:[0-9]+}", sessionHandler},
"telemetry": {[]string{"GET"}, "/settings/telemetry", boolGetHandler(telemetry.Enabled)},
"telemetry2": {[]string{"POST", "OPTIONS"}, "/settings/telemetry/{value:[a-z]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
"tariff": {[]string{"GET"}, "/tariff/{tariff:[a-z]+}", tariffHandler(site)},
}
for _, r := range routes {
@ -117,14 +117,15 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, cache *util.Cache) {
routes := map[string]route{
"mode": {[]string{"POST", "OPTIONS"}, "/mode/{value:[a-z]+}", chargeModeHandler(lp)},
"targetenergy": {[]string{"POST", "OPTIONS"}, "/targetenergy/{value:[0-9.]+}", floatHandler(pass(lp.SetTargetEnergy), lp.GetTargetEnergy)},
"targetsoc": {[]string{"POST", "OPTIONS"}, "/targetsoc/{value:[0-9]+}", intHandler(pass(lp.SetTargetSoc), lp.GetTargetSoc)},
"minsoc": {[]string{"POST", "OPTIONS"}, "/minsoc/{value:[0-9]+}", intHandler(pass(lp.SetMinSoc), lp.GetMinSoc)},
"mincurrent": {[]string{"POST", "OPTIONS"}, "/mincurrent/{value:[0-9.]+}", floatHandler(pass(lp.SetMinCurrent), lp.GetMinCurrent)},
"maxcurrent": {[]string{"POST", "OPTIONS"}, "/maxcurrent/{value:[0-9.]+}", floatHandler(pass(lp.SetMaxCurrent), lp.GetMaxCurrent)},
"phases": {[]string{"POST", "OPTIONS"}, "/phases/{value:[0-9]+}", phasesHandler(lp)},
"targetcharge": {[]string{"POST", "OPTIONS"}, "/targetcharge/{soc:[0-9]+}/{time:[0-9TZ:.-]+}", targetChargeHandler(lp)},
"targetcharge2": {[]string{"DELETE", "OPTIONS"}, "/targetcharge", targetChargeRemoveHandler(lp)},
"targetenergy": {[]string{"POST", "OPTIONS"}, "/target/energy/{value:[0-9.]+}", floatHandler(pass(lp.SetTargetEnergy), lp.GetTargetEnergy)},
"targetsoc": {[]string{"POST", "OPTIONS"}, "/target/soc/{value:[0-9]+}", intHandler(pass(lp.SetTargetSoc), lp.GetTargetSoc)},
"targettime": {[]string{"POST", "OPTIONS"}, "/target/time/{time:[0-9TZ:.-]+}", targetTimeHandler(lp)},
"targettime2": {[]string{"DELETE", "OPTIONS"}, "/target/time", targetTimeRemoveHandler(lp)},
"plan": {[]string{"GET"}, "/target/plan", planHandler(lp)},
"vehicle": {[]string{"POST", "OPTIONS"}, "/vehicle/{vehicle:[1-9][0-9]*}", vehicleHandler(site, lp)},
"vehicle2": {[]string{"DELETE", "OPTIONS"}, "/vehicle", vehicleRemoveHandler(lp)},
"vehicleDetect": {[]string{"PATCH", "OPTIONS"}, "/vehicle", vehicleDetectHandler(lp)},

View file

@ -86,19 +86,6 @@ func csvResult(ctx context.Context, w http.ResponseWriter, res any) {
}
}
// healthHandler returns current charge mode
func healthHandler(site site.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if site == nil || !site.Healthy() {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "OK")
}
}
// pass converts a simple api without return value to api with nil error return value
func pass[T any](f func(T)) func(T) error {
return func(v T) error {
@ -198,20 +185,44 @@ func stateHandler(cache *util.Cache) http.HandlerFunc {
}
}
// tariffHandler returns the selected tariff
func tariffHandler(site site.API) http.HandlerFunc {
// healthHandler returns current charge mode
func healthHandler(site site.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
tariff, ok := vars["tariff"]
rates, err := site.GetTariff(tariff)
if !ok || err != nil {
w.WriteHeader(http.StatusBadRequest)
if site == nil || !site.Healthy() {
w.WriteHeader(http.StatusInternalServerError)
return
}
jsonResult(w, rates)
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "OK")
}
}
// tariffHandler returns the configured tariff
func tariffHandler(site site.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
tariff := vars["tariff"]
t := site.GetTariff(tariff)
if t == nil {
jsonError(w, http.StatusBadRequest, errors.New("tariff not available"))
return
}
rates, err := t.Rates()
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
res := struct {
Rates api.Rates `json:"rates"`
}{
Rates: rates,
}
jsonResult(w, res)
}
}
@ -319,19 +330,11 @@ func remoteDemandHandler(lp loadpoint.API) http.HandlerFunc {
}
}
// targetChargeHandler updates target soc
func targetChargeHandler(loadpoint targetCharger) http.HandlerFunc {
// targetTimeHandler updates target soc
func targetTimeHandler(lp loadpoint.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
socS, ok := vars["soc"]
socV, err := strconv.Atoi(socS)
if !ok || err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
timeS, ok := vars["time"]
timeV, err := time.Parse(time.RFC3339, timeS)
@ -340,27 +343,29 @@ func targetChargeHandler(loadpoint targetCharger) http.HandlerFunc {
return
}
if err := loadpoint.SetTargetCharge(timeV, socV); err != nil {
if err := lp.SetTargetTime(timeV); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
res := struct {
Soc int `json:"soc"`
Time time.Time `json:"time"`
Soc int `json:"soc"`
Energy float64 `json:"energy"`
Time time.Time `json:"time"`
}{
Soc: socV,
Time: timeV,
Soc: lp.GetTargetSoc(),
Energy: lp.GetTargetEnergy(),
Time: lp.GetTargetTime(),
}
jsonResult(w, res)
}
}
// targetChargeRemoveHandler removes target soc
func targetChargeRemoveHandler(loadpoint loadpoint.API) http.HandlerFunc {
// targetTimeRemoveHandler removes target soc
func targetTimeRemoveHandler(lp loadpoint.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := loadpoint.SetTargetCharge(time.Time{}, 0); err != nil {
if err := lp.SetTargetTime(time.Time{}); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
@ -371,7 +376,7 @@ func targetChargeRemoveHandler(loadpoint loadpoint.API) http.HandlerFunc {
}
// vehicleHandler sets active vehicle
func vehicleHandler(site site.API, loadpoint loadpoint.API) http.HandlerFunc {
func vehicleHandler(site site.API, lp loadpoint.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
@ -385,7 +390,7 @@ func vehicleHandler(site site.API, loadpoint loadpoint.API) http.HandlerFunc {
}
v := vehicles[val-1]
loadpoint.SetVehicle(v)
lp.SetVehicle(v)
res := struct {
Vehicle string `json:"vehicle"`
@ -398,32 +403,63 @@ func vehicleHandler(site site.API, loadpoint loadpoint.API) http.HandlerFunc {
}
// vehicleRemoveHandler removes vehicle
func vehicleRemoveHandler(loadpoint loadpoint.API) http.HandlerFunc {
func vehicleRemoveHandler(lp loadpoint.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
loadpoint.SetVehicle(nil)
lp.SetVehicle(nil)
res := struct{}{}
jsonResult(w, res)
}
}
// vehicleDetectHandler starts vehicle detection
func vehicleDetectHandler(loadpoint loadpoint.API) http.HandlerFunc {
func vehicleDetectHandler(lp loadpoint.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
loadpoint.StartVehicleDetection()
lp.StartVehicleDetection()
res := struct{}{}
jsonResult(w, res)
}
}
// planHandler starts vehicle detection
func planHandler(lp loadpoint.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var err error
targetTime := lp.GetTargetTime()
if t := r.URL.Query().Get("targetTime"); t != "" {
targetTime, err = time.Parse(time.RFC3339, t)
}
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
power := lp.GetMaxPower()
requiredDuration, plan, err := lp.GetPlan(targetTime, power)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
res := struct {
Duration int64 `json:"duration"`
Plan api.Rates `json:"plan"`
Unit string `json:"unit"`
Power float64 `json:"power"`
}{
Duration: int64(requiredDuration.Seconds()),
Plan: plan,
Unit: lp.GetPlannerUnit(),
Power: power,
}
jsonResult(w, res)
}
}
// socketHandler attaches websocket handler to uri
func socketHandler(hub *SocketHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ServeWebsocket(hub, w, r)
}
}
// TargetCharger defines target charge related loadpoint operations
type targetCharger interface {
// SetTargetCharge sets the charge targetSoc
SetTargetCharge(time.Time, int) error
}

View file

@ -1,80 +1,12 @@
package server
import (
"fmt"
"math"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
)
type mockLoadpoint struct {
Soc int
TargetTime time.Time
}
func (lp *mockLoadpoint) SetTargetCharge(time time.Time, soc int) error {
lp.Soc = soc
lp.TargetTime = time
return nil
}
func TestTargetChargeHandler(t *testing.T) {
tc := []struct {
inSoc string
inTime string
statusCode int
outSoc int
outTime time.Time
}{
{
"70", "2022-05-17T06:20:59.509Z", http.StatusOK,
70, time.Date(2022, 0o5, 17, 0o6, 20, 59, 509000000, time.UTC),
},
{"foo", "2022-05-17T06:20:59.509Z", http.StatusBadRequest, 0, time.Time{}},
{"70", "2022-05-17 06:20:59", http.StatusBadRequest, 0, time.Time{}},
}
for _, tc := range tc {
mockLp := &mockLoadpoint{}
handler := http.HandlerFunc(targetChargeHandler(mockLp))
req, err := http.NewRequest("GET", fmt.Sprintf("/targetcharge/%s/%s", tc.inSoc, tc.inTime), nil)
if err != nil {
t.Fatal(err)
}
vars := map[string]string{
"soc": tc.inSoc,
"time": tc.inTime,
}
req = mux.SetURLVars(req, vars)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if status := rr.Code; status != tc.statusCode {
t.Errorf("handler returned wrong status code: got %v want %v",
status, tc.statusCode)
}
if mockLp.Soc != tc.outSoc {
t.Errorf("wrong target soc: got %v want %v", mockLp.Soc, tc.outSoc)
}
isoFormat := "2006-01-02T15:04:05.999Z07:00"
if !mockLp.TargetTime.Equal(tc.outTime) {
t.Errorf("wrong target time year: got %v want %v", mockLp.TargetTime.UTC().Format(isoFormat), tc.outTime.Format(isoFormat))
}
}
}
func TestNaNInf(t *testing.T) {
c := map[string]any{
"foo": math.NaN(),

View file

@ -83,8 +83,8 @@ func (t *Awattar) run(done chan error) {
t.data = make(api.Rates, 0, len(res.Data))
for _, r := range res.Data {
ar := api.Rate{
Start: r.StartTimestamp,
End: r.EndTimestamp,
Start: r.StartTimestamp.Local(),
End: r.EndTimestamp.Local(),
Price: r.Marketprice / 1e3,
}
t.data = append(t.data, ar)

View file

@ -114,8 +114,8 @@ func (t *ElectricityMaps) Rates() (api.Rates, error) {
res := make(api.Rates, 0, len(t.data))
for _, r := range t.data {
ar := api.Rate{
Start: r.Datetime,
End: r.Datetime.Add(time.Hour),
Start: r.Datetime.Local(),
End: r.Datetime.Add(time.Hour).Local(),
Price: r.CarbonIntensity,
}
res = append(res, ar)

View file

@ -114,8 +114,8 @@ func (t *GrünStromIndex) run(done chan error) {
for _, r := range res.Forecast {
t.data = append(t.data, api.Rate{
Price: float64(r.Co2GStandard),
Start: time.UnixMilli(r.Timeframe.Start),
End: time.UnixMilli(r.Timeframe.End),
Start: time.UnixMilli(r.Timeframe.Start).Local(),
End: time.UnixMilli(r.Timeframe.End).Local(),
})
}

View file

@ -126,8 +126,8 @@ func (t *Tibber) rates(pi []tibber.Price) 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),
Start: r.StartsAt.Local(),
End: r.StartsAt.Add(time.Hour).Local(),
Price: r.Total,
}
data = append(data, ar)