Refactor target charging apis (#5649)
This commit is contained in:
parent
8d805306ff
commit
dcaadb783f
53 changed files with 799 additions and 642 deletions
17
api/rates.go
17
api/rates.go
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -160,6 +160,7 @@ a:hover {
|
|||
color: inherit !important;
|
||||
background-color: inherit !important;
|
||||
border-color: inherit !important;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.btn-outline-primary,
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
},
|
||||
|
|
|
|||
63
assets/js/components/TargetChargePlanMinimal.vue
Normal file
63
assets/js/components/TargetChargePlanMinimal.vue
Normal 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>
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
148
core/loadpoint_plan.go
Normal 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
|
||||
}
|
||||
|
|
@ -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{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
17
core/planner/sort.go
Normal 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
50
core/planner/sort_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
116
dist/assets/index.1481ecc6.js
vendored
116
dist/assets/index.1481ecc6.js
vendored
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
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
4
dist/index.html
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -174,15 +174,13 @@ logout = "выйти"
|
|||
today = "сегодня"
|
||||
tomorrow = "завтра"
|
||||
activate = "Активировать"
|
||||
experimentalLabel = "Экспериментально"
|
||||
title = "Запланированное время"
|
||||
inactiveLabel = "Запланированное время"
|
||||
activeLabel = "{time}"
|
||||
modalTitle = "Установить запланированное время"
|
||||
setTargetTime = "ни одного"
|
||||
description = "Когда транспортное средство следует зарядить до {targetSoc}%?"
|
||||
descriptionSoc = "Когда транспортное средство следует зарядить до {targetSoc}%?"
|
||||
targetIsInThePast = "Выбери время в будущем."
|
||||
experimentalText = "Оно работает, но не идеально. Пожалуйста сообщите о проблемах"
|
||||
|
||||
[main.targetEnergy]
|
||||
label = "Лимит"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -211,8 +211,6 @@ today = "сьогодні"
|
|||
targetIsInThePast = "Виберіть час у майбутньому."
|
||||
activate = "Активувати"
|
||||
inactiveLabel = "Запланований час"
|
||||
experimentalLabel = "Експериментальний"
|
||||
experimentalText = "Це працює, але не ідеально. Будь ласка, повідомте про несподівану поведінку"
|
||||
description = "Коли транспортний засіб повинен бути заряджений на {targetSoc}%?"
|
||||
|
||||
[main.loadpoint]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue