Planner: don't touch plan when disabling due to soc/energy limits reached (#11030)
This commit is contained in:
parent
256a604566
commit
e1e703c4a7
23 changed files with 734 additions and 223 deletions
|
|
@ -26,8 +26,11 @@
|
|||
--evcc-dark-green: #0fde41;
|
||||
--evcc-darker-green: #0ba631;
|
||||
--evcc-darkest-green: #076f20;
|
||||
--evcc-darkest-green-rgb: 11, 166, 49;
|
||||
--evcc-yellow: #faf000;
|
||||
--evcc-dark-yellow: #bbb400;
|
||||
--evcc-orange: #ff9000;
|
||||
--evcc-orange-rgb: 255, 144, 0;
|
||||
--bs-gray-deep: #010322;
|
||||
--bs-gray-dark: #28293e;
|
||||
--bs-gray-medium: #93949e;
|
||||
|
|
@ -52,7 +55,10 @@
|
|||
--evcc-transition-very-fast: 100ms;
|
||||
|
||||
--bs-primary: var(--evcc-darker-green);
|
||||
--bs-primary-rgb: 11, 166, 49;
|
||||
--bs-primary-rgb: var(--evcc-darker-green-rgb);
|
||||
|
||||
--bs-warning: var(--evcc-orange);
|
||||
--bs-warning-rgb: var(--evcc-orange-rgb);
|
||||
|
||||
--bs-body-font-size: 14px;
|
||||
--bs-font-sans-serif: Montserrat, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@
|
|||
<div class="justify-content-between mb-2 d-flex justify-content-between">
|
||||
<div class="text-start">
|
||||
<div class="label">{{ $t("main.targetChargePlan.chargeDuration") }}</div>
|
||||
<div class="value text-primary d-sm-flex align-items-baseline">
|
||||
<div
|
||||
:class="`value d-sm-flex align-items-baseline ${
|
||||
timeWarning ? 'text-warning' : 'text-primary'
|
||||
}`"
|
||||
>
|
||||
<div>{{ planDuration }}</div>
|
||||
<div v-if="fmtPower" class="extraValue text-nowrap ms-sm-1">
|
||||
{{ fmtPower }}
|
||||
|
|
@ -47,6 +51,19 @@ export default {
|
|||
return { activeIndex: null, startTime: new Date() };
|
||||
},
|
||||
computed: {
|
||||
endTime() {
|
||||
if (!this.plan?.length) {
|
||||
return null;
|
||||
}
|
||||
const end = this.plan[this.plan.length - 1].end;
|
||||
return end ? new Date(end) : null;
|
||||
},
|
||||
timeWarning() {
|
||||
if (this.targetTime && this.endTime) {
|
||||
return this.targetTime < this.endTime;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
planDuration() {
|
||||
return this.fmtDuration(this.duration);
|
||||
},
|
||||
|
|
@ -112,11 +129,16 @@ export default {
|
|||
end.setHours(startHour + 1);
|
||||
const endHour = end.getHours();
|
||||
const day = this.weekdayShort(start);
|
||||
const toLate = this.targetTime && this.targetTime.getTime() <= start.getTime();
|
||||
const toLate = this.targetTime && this.targetTime <= start;
|
||||
// TODO: handle multiple matching time slots
|
||||
const price = this.findSlotInRange(start, end, rates)?.price;
|
||||
const charging = this.findSlotInRange(start, end, plan) != null;
|
||||
result.push({ day, price, startHour, endHour, charging, toLate });
|
||||
const warning =
|
||||
charging &&
|
||||
this.targetTime &&
|
||||
end > this.targetTime &&
|
||||
this.targetTime < this.endTime;
|
||||
result.push({ day, price, startHour, endHour, charging, toLate, warning });
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,14 +12,15 @@
|
|||
:soc-based-planning="socBasedPlanning"
|
||||
@plan-updated="(data) => updatePlan({ index: 0, ...data })"
|
||||
@plan-removed="() => removePlan(0)"
|
||||
@plan-preview="previewPlan"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ChargingPlanWarnings v-bind="chargingPlanWarningsProps" class="mb-4" />
|
||||
<hr />
|
||||
<h5>
|
||||
<div class="inner">
|
||||
{{ $t(`main.targetCharge.${plans.length ? "currentPlan" : "noActivePlan"}`) }}
|
||||
<div class="inner" data-testid="plan-preview-title">
|
||||
{{ $t(`main.targetCharge.${isPreview ? "preview" : "currentPlan"}`) }}
|
||||
</div>
|
||||
</h5>
|
||||
<ChargingPlanPreview v-if="chargingPlanPreviewProps" v-bind="chargingPlanPreviewProps" />
|
||||
|
|
@ -33,6 +34,7 @@ import ChargingPlanWarnings from "./ChargingPlanWarnings.vue";
|
|||
import formatter from "../mixins/formatter";
|
||||
import collector from "../mixins/collector";
|
||||
import api from "../api";
|
||||
import deepEqual from "../utils/deepEqual";
|
||||
|
||||
const DEFAULT_TARGET_TIME = "7:00";
|
||||
const LAST_TARGET_TIME_KEY = "last_target_time";
|
||||
|
|
@ -67,6 +69,7 @@ export default {
|
|||
plan: {},
|
||||
activeTab: "time",
|
||||
loading: false,
|
||||
isPreview: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -74,9 +77,9 @@ export default {
|
|||
return this.collectProps(ChargingPlanWarnings);
|
||||
},
|
||||
chargingPlanPreviewProps: function () {
|
||||
const targetTime = this.effectivePlanTime ? new Date(this.effectivePlanTime) : null;
|
||||
const { rates } = this.tariff;
|
||||
const { duration, plan, power } = this.plan;
|
||||
const { duration, plan, power, planTime } = this.plan;
|
||||
const targetTime = planTime ? new Date(planTime) : null;
|
||||
const { currency, smartCostType } = this;
|
||||
return rates
|
||||
? { duration, plan, power, rates, targetTime, currency, smartCostType }
|
||||
|
|
@ -84,22 +87,46 @@ export default {
|
|||
},
|
||||
},
|
||||
watch: {
|
||||
plans: {
|
||||
handler() {
|
||||
plans(newPlans, oldPlans) {
|
||||
if (!deepEqual(newPlans, oldPlans) && newPlans.length > 0) {
|
||||
this.fetchPlan();
|
||||
},
|
||||
deep: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.fetchPlan();
|
||||
if (this.plans.length > 0) {
|
||||
this.fetchPlan();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchPlan: async function () {
|
||||
fetchActivePlan: async function () {
|
||||
return await api.get(`loadpoints/${this.id}/plan`);
|
||||
},
|
||||
fetchPlanPreviewSoc: async function (soc, time) {
|
||||
const timeISO = time.toISOString();
|
||||
return await api.get(`loadpoints/${this.id}/plan/preview/soc/${soc}/${timeISO}`);
|
||||
},
|
||||
fetchPlanPreviewEnergy: async function (energy, time) {
|
||||
const timeISO = time.toISOString();
|
||||
return await api.get(`loadpoints/${this.id}/plan/preview/energy/${energy}/${timeISO}`);
|
||||
},
|
||||
fetchPlan: async function (preview) {
|
||||
if (!this.loading) {
|
||||
try {
|
||||
this.loading = true;
|
||||
this.plan = (await api.get(`loadpoints/${this.id}/plan`)).data.result;
|
||||
|
||||
let planRes = null;
|
||||
if (preview && this.socBasedPlanning) {
|
||||
planRes = await this.fetchPlanPreviewSoc(preview.soc, preview.time);
|
||||
this.isPreview = true;
|
||||
} else if (preview && !this.socBasedPlanning) {
|
||||
planRes = await this.fetchPlanPreviewEnergy(preview.energy, preview.time);
|
||||
this.isPreview = true;
|
||||
} else {
|
||||
planRes = await this.fetchActivePlan();
|
||||
this.isPreview = false;
|
||||
}
|
||||
this.plan = planRes.data.result;
|
||||
|
||||
const tariffRes = await api.get(`tariff/planner`, {
|
||||
validateStatus: function (status) {
|
||||
|
|
@ -143,6 +170,9 @@ export default {
|
|||
updatePlan: function (data) {
|
||||
this.$emit("plan-updated", data);
|
||||
},
|
||||
previewPlan: function (data) {
|
||||
this.fetchPlan(data);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
v-model="selectedDay"
|
||||
class="form-select me-2"
|
||||
data-testid="plan-day"
|
||||
@change="preview"
|
||||
>
|
||||
<option v-for="opt in dayOptions()" :key="opt.value" :value="opt.value">
|
||||
{{ opt.name }}
|
||||
|
|
@ -52,6 +53,7 @@
|
|||
:step="60 * 5"
|
||||
data-testid="plan-time"
|
||||
required
|
||||
@change="preview"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-6 d-lg-none col-form-label">
|
||||
|
|
@ -66,6 +68,7 @@
|
|||
v-model="selectedSoc"
|
||||
class="form-select mx-0"
|
||||
data-testid="plan-soc"
|
||||
@change="preview"
|
||||
>
|
||||
<option v-for="opt in socOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.name }}
|
||||
|
|
@ -77,6 +80,7 @@
|
|||
v-model="selectedEnergy"
|
||||
class="form-select mx-0"
|
||||
data-testid="plan-energy"
|
||||
@change="preview"
|
||||
>
|
||||
<option v-for="opt in energyOptions" :key="opt.energy" :value="opt.energy">
|
||||
{{ opt.text }}
|
||||
|
|
@ -113,7 +117,7 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-0">
|
||||
<p class="mb-0" data-testid="plan-entry-warnings">
|
||||
<span v-if="timeInThePast" class="d-block text-danger my-2">
|
||||
{{ $t("main.targetCharge.targetIsInThePast") }}
|
||||
</span>
|
||||
|
|
@ -129,6 +133,8 @@ import formatter from "../mixins/formatter";
|
|||
import { energyOptions } from "../utils/energyOptions";
|
||||
|
||||
const LAST_TARGET_TIME_KEY = "last_target_time";
|
||||
const LAST_SOC_GOAL_KEY = "last_soc_goal";
|
||||
const LAST_ENERGY_GOAL_KEY = "last_energy_goal";
|
||||
const DEFAULT_TARGET_TIME = "7:00";
|
||||
|
||||
export default {
|
||||
|
|
@ -144,7 +150,7 @@ export default {
|
|||
vehicleCapacity: Number,
|
||||
socBasedPlanning: Boolean,
|
||||
},
|
||||
emits: ["plan-updated", "plan-removed"],
|
||||
emits: ["plan-updated", "plan-removed", "plan-preview"],
|
||||
data: function () {
|
||||
return {
|
||||
selectedDay: null,
|
||||
|
|
@ -231,16 +237,18 @@ export default {
|
|||
},
|
||||
initInputFields: function () {
|
||||
if (!this.selectedSoc) {
|
||||
this.selectedSoc = 100;
|
||||
this.selectedSoc = window.localStorage[LAST_SOC_GOAL_KEY] || 100;
|
||||
}
|
||||
if (!this.selectedEnergy) {
|
||||
this.selectedEnergy = this.vehicleCapacity || 10;
|
||||
this.selectedEnergy =
|
||||
window.localStorage[LAST_ENERGY_GOAL_KEY] || this.vehicleCapacity || 10;
|
||||
}
|
||||
|
||||
let time = this.time;
|
||||
if (!time) {
|
||||
// no time but existing selection, keep it
|
||||
if (this.selectedDay && this.selectedTime) {
|
||||
this.preview();
|
||||
return;
|
||||
}
|
||||
time = this.defaultTime();
|
||||
|
|
@ -248,6 +256,10 @@ export default {
|
|||
const date = new Date(time);
|
||||
this.selectedDay = this.fmtDayString(date);
|
||||
this.selectedTime = this.fmtTimeString(date);
|
||||
|
||||
if (this.isNew || this.dataChanged) {
|
||||
this.preview();
|
||||
}
|
||||
},
|
||||
dayOptions: function () {
|
||||
const options = [];
|
||||
|
|
@ -276,6 +288,12 @@ export default {
|
|||
const hours = this.selectedDate.getHours();
|
||||
const minutes = this.selectedDate.getMinutes();
|
||||
window.localStorage[LAST_TARGET_TIME_KEY] = `${hours}:${minutes}`;
|
||||
if (this.selectedSoc) {
|
||||
window.localStorage[LAST_SOC_GOAL_KEY] = this.selectedSoc;
|
||||
}
|
||||
if (this.selectedEnergy) {
|
||||
window.localStorage[LAST_ENERGY_GOAL_KEY] = this.selectedEnergy;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
|
|
@ -285,6 +303,13 @@ export default {
|
|||
energy: this.selectedEnergy,
|
||||
});
|
||||
},
|
||||
preview: function () {
|
||||
this.$emit("plan-preview", {
|
||||
time: this.selectedDate,
|
||||
soc: this.selectedSoc,
|
||||
energy: this.selectedEnergy,
|
||||
});
|
||||
},
|
||||
toggle: function (e) {
|
||||
const { checked } = e.target;
|
||||
if (checked) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
<template>
|
||||
<p class="mb-0">
|
||||
<span v-if="targetIsAboveVehicleLimit" class="d-block text-danger mb-1">
|
||||
{{ $t("main.targetCharge.targetIsAboveVehicleLimit", { limit: vehicleLimitFmt }) }}
|
||||
</span>
|
||||
<p class="mb-0" data-testid="plan-warnings">
|
||||
<span v-if="targetIsAboveLimit" class="d-block text-secondary mb-1">
|
||||
{{ $t("main.targetCharge.targetIsAboveLimit", { limit: limitFmt }) }}
|
||||
</span>
|
||||
|
|
@ -15,6 +12,12 @@
|
|||
<span v-if="costLimitExists" class="d-block text-secondary mb-1">
|
||||
{{ $t("main.targetCharge.costLimitIgnore", { limit: costLimitText }) }}
|
||||
</span>
|
||||
<span v-if="notReachableInTime" class="d-block text-warning mb-1">
|
||||
{{ $t("main.targetCharge.notReachableInTime", { endTime: endTimeFmt }) }}
|
||||
</span>
|
||||
<span v-if="targetIsAboveVehicleLimit" class="d-block text-danger mb-1">
|
||||
{{ $t("main.targetCharge.targetIsAboveVehicleLimit", { limit: vehicleLimitFmt }) }}
|
||||
</span>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
|
|
@ -40,9 +43,23 @@ export default {
|
|||
currency: String,
|
||||
mode: String,
|
||||
tariff: Object,
|
||||
plan: Object,
|
||||
vehicleTargetSoc: Number,
|
||||
},
|
||||
computed: {
|
||||
endTime: function () {
|
||||
if (!this.plan?.plan?.length) {
|
||||
return null;
|
||||
}
|
||||
const { plan } = this.plan;
|
||||
return plan[plan.length - 1].end;
|
||||
},
|
||||
endTimeFmt: function () {
|
||||
if (!this.endTime) {
|
||||
return "";
|
||||
}
|
||||
return this.fmtAbsoluteDate(new Date(this.endTime));
|
||||
},
|
||||
timeTooFarInTheFuture: function () {
|
||||
if (!this.effectivePlanTime) {
|
||||
return false;
|
||||
|
|
@ -56,6 +73,13 @@ export default {
|
|||
}
|
||||
return false;
|
||||
},
|
||||
notReachableInTime: function () {
|
||||
const { planTime } = this.plan || {};
|
||||
if (planTime && this.endTime) {
|
||||
return new Date(planTime) < new Date(this.endTime);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
targetIsAboveLimit: function () {
|
||||
if (this.socBasedPlanning) {
|
||||
return this.effectivePlanSoc > this.effectiveLimitSoc;
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@
|
|||
:data-index="index"
|
||||
class="slot user-select-none"
|
||||
:class="{
|
||||
active: isActive(index),
|
||||
active: slot.charging,
|
||||
hovered: activeIndex === index,
|
||||
toLate: slot.toLate,
|
||||
warning: slot.warning,
|
||||
'cursor-pointer': slot.selectable,
|
||||
faded: activeIndex !== null && activeIndex !== index,
|
||||
}"
|
||||
@touchstart="hoverSlot(index)"
|
||||
@mouseenter="hoverSlot(index)"
|
||||
|
|
@ -84,11 +87,6 @@ export default {
|
|||
this.$emit("slot-selected", index);
|
||||
}
|
||||
},
|
||||
isActive(index) {
|
||||
return this.activeIndex !== null
|
||||
? this.activeIndex === index
|
||||
: this.slots[index].charging;
|
||||
},
|
||||
priceStyle(price) {
|
||||
const value = price === undefined ? this.avgPrice : price;
|
||||
const height =
|
||||
|
|
@ -118,6 +116,7 @@ export default {
|
|||
flex-direction: column;
|
||||
position: relative;
|
||||
opacity: 1;
|
||||
transition: opacity var(--evcc-transition-fast) linear;
|
||||
}
|
||||
@media (max-width: 991px) {
|
||||
.chart {
|
||||
|
|
@ -171,7 +170,19 @@ export default {
|
|||
.slot.active {
|
||||
opacity: 1;
|
||||
}
|
||||
.slot.warning .slot-bar {
|
||||
background: var(--bs-warning);
|
||||
}
|
||||
.slot.warning .slot-label {
|
||||
color: var(--bs-warning);
|
||||
}
|
||||
.unknown {
|
||||
margin: 0 -0.5rem;
|
||||
}
|
||||
.slot.hovered {
|
||||
opacity: 1;
|
||||
}
|
||||
.slot.faded {
|
||||
opacity: 0.33;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@
|
|||
@limit-soc-drag="limitSocDrag"
|
||||
@plan-clicked="openPlanModal"
|
||||
/>
|
||||
|
||||
<div class="details d-flex flex-wrap justify-content-between">
|
||||
<LabelAndValue
|
||||
v-if="socBasedCharging"
|
||||
|
|
@ -173,7 +172,7 @@ export default {
|
|||
},
|
||||
rangePerSoc: function () {
|
||||
if (this.vehicleSoc > 10 && this.range) {
|
||||
return this.range / this.vehicleSoc;
|
||||
return Math.round((this.range / this.vehicleSoc) * 1e2) / 1e2;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
|
|
|||
3
assets/js/utils/deepEqual.js
Normal file
3
assets/js/utils/deepEqual.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function (obj1, obj2) {
|
||||
return JSON.stringify(obj1) === JSON.stringify(obj2);
|
||||
}
|
||||
|
|
@ -495,6 +495,10 @@ func (lp *Loadpoint) evVehicleDisconnectHandler() {
|
|||
// reset session
|
||||
lp.SetLimitSoc(0)
|
||||
lp.SetLimitEnergy(0)
|
||||
|
||||
// mark plan slot as inactive
|
||||
// this will force a deletion of an outdated plan once plan time is expired in GetPlan()
|
||||
lp.setPlanActive(false)
|
||||
}
|
||||
|
||||
// evVehicleSocProgressHandler sends external start event
|
||||
|
|
@ -839,9 +843,6 @@ func (lp *Loadpoint) disableUnlessClimater() error {
|
|||
current = lp.effectiveMinCurrent()
|
||||
}
|
||||
|
||||
// reset plan once charge goal is met
|
||||
lp.setPlanActive(false)
|
||||
|
||||
return lp.setLimit(current, true)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -82,8 +82,14 @@ type API interface {
|
|||
GetPlanEnergy() (time.Time, float64)
|
||||
// SetPlanEnergy sets the charge plan energy
|
||||
SetPlanEnergy(time.Time, float64) error
|
||||
// GetPlanGoal returns the plan goal and if the goal is soc based
|
||||
GetPlanGoal() (float64, bool)
|
||||
// GetPlanRequiredDuration returns required duration of plan to reach the goal from current state
|
||||
GetPlanRequiredDuration(goal, maxPower float64) time.Duration
|
||||
// SocBasedPlanning determines if the planner is soc based
|
||||
SocBasedPlanning() bool
|
||||
// GetPlan creates a charging plan
|
||||
GetPlan(targetTime time.Time, maxPower float64) (time.Duration, api.Rates, error)
|
||||
GetPlan(targetTime time.Time, requiredDuration time.Duration) (api.Rates, error)
|
||||
|
||||
// GetEnableThreshold gets the loadpoint enable threshold
|
||||
GetEnableThreshold() float64
|
||||
|
|
|
|||
|
|
@ -232,13 +232,12 @@ func (mr *MockAPIMockRecorder) GetPhases() *gomock.Call {
|
|||
}
|
||||
|
||||
// GetPlan mocks base method.
|
||||
func (m *MockAPI) GetPlan(arg0 time.Time, arg1 float64) (time.Duration, api.Rates, error) {
|
||||
func (m *MockAPI) GetPlan(arg0 time.Time, arg1 time.Duration) (api.Rates, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetPlan", arg0, arg1)
|
||||
ret0, _ := ret[0].(time.Duration)
|
||||
ret1, _ := ret[1].(api.Rates)
|
||||
ret2, _ := ret[2].(error)
|
||||
return ret0, ret1, ret2
|
||||
ret0, _ := ret[0].(api.Rates)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetPlan indicates an expected call of GetPlan.
|
||||
|
|
@ -276,6 +275,35 @@ func (mr *MockAPIMockRecorder) GetPlanEnergy() *gomock.Call {
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanEnergy", reflect.TypeOf((*MockAPI)(nil).GetPlanEnergy))
|
||||
}
|
||||
|
||||
// GetPlanGoal mocks base method.
|
||||
func (m *MockAPI) GetPlanGoal() (float64, bool) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetPlanGoal")
|
||||
ret0, _ := ret[0].(float64)
|
||||
ret1, _ := ret[1].(bool)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetPlanGoal indicates an expected call of GetPlanGoal.
|
||||
func (mr *MockAPIMockRecorder) GetPlanGoal() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanGoal", reflect.TypeOf((*MockAPI)(nil).GetPlanGoal))
|
||||
}
|
||||
|
||||
// GetPlanRequiredDuration mocks base method.
|
||||
func (m *MockAPI) GetPlanRequiredDuration(arg0, arg1 float64) time.Duration {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetPlanRequiredDuration", arg0, arg1)
|
||||
ret0, _ := ret[0].(time.Duration)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// GetPlanRequiredDuration indicates an expected call of GetPlanRequiredDuration.
|
||||
func (mr *MockAPIMockRecorder) GetPlanRequiredDuration(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanRequiredDuration", reflect.TypeOf((*MockAPI)(nil).GetPlanRequiredDuration), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetPriority mocks base method.
|
||||
func (m *MockAPI) GetPriority() int {
|
||||
m.ctrl.T.Helper()
|
||||
|
|
@ -520,6 +548,20 @@ func (mr *MockAPIMockRecorder) SetVehicle(arg0 interface{}) *gomock.Call {
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetVehicle", reflect.TypeOf((*MockAPI)(nil).SetVehicle), arg0)
|
||||
}
|
||||
|
||||
// SocBasedPlanning mocks base method.
|
||||
func (m *MockAPI) SocBasedPlanning() bool {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SocBasedPlanning")
|
||||
ret0, _ := ret[0].(bool)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// SocBasedPlanning indicates an expected call of SocBasedPlanning.
|
||||
func (mr *MockAPIMockRecorder) SocBasedPlanning() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SocBasedPlanning", reflect.TypeOf((*MockAPI)(nil).SocBasedPlanning))
|
||||
}
|
||||
|
||||
// StartVehicleDetection mocks base method.
|
||||
func (m *MockAPI) StartVehicleDetection() {
|
||||
m.ctrl.T.Helper()
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ func (lp *Loadpoint) EffectivePlanTime() time.Time {
|
|||
return ts
|
||||
}
|
||||
|
||||
// SocBasedPlanning returns true if soc based planning is enabled
|
||||
func (lp *Loadpoint) SocBasedPlanning() bool {
|
||||
return lp.socBasedPlanning()
|
||||
}
|
||||
|
||||
// effectiveMinCurrent returns the effective min current
|
||||
func (lp *Loadpoint) effectiveMinCurrent() float64 {
|
||||
if v := lp.GetVehicle(); v != nil {
|
||||
|
|
|
|||
|
|
@ -28,51 +28,57 @@ func (lp *Loadpoint) setPlanActive(active bool) {
|
|||
}
|
||||
}
|
||||
|
||||
// deletePlan deletes the charging plan, either loadpoint or vehicle
|
||||
func (lp *Loadpoint) deletePlan() {
|
||||
if !lp.socBasedPlanning() {
|
||||
lp.setPlanEnergy(time.Time{}, 0)
|
||||
} else if v := lp.GetVehicle(); v != nil {
|
||||
vehicle.Settings(lp.log, v).SetPlanSoc(time.Time{}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// remainingPlanEnergy returns missing energy amount in kWh
|
||||
func (lp *Loadpoint) remainingPlanEnergy() (float64, bool) {
|
||||
func (lp *Loadpoint) remainingPlanEnergy(planEnergy float64) float64 {
|
||||
return max(0, planEnergy-lp.getChargedEnergy()/1e3)
|
||||
}
|
||||
|
||||
// GetPlanRequiredDuration is the estimated total charging duration
|
||||
func (lp *Loadpoint) GetPlanRequiredDuration(goal, maxPower float64) time.Duration {
|
||||
lp.RLock()
|
||||
defer lp.RUnlock()
|
||||
|
||||
if lp.socBasedPlanning() {
|
||||
if lp.socEstimator == nil {
|
||||
return 0
|
||||
}
|
||||
return lp.socEstimator.RemainingChargeDuration(int(goal), maxPower)
|
||||
}
|
||||
|
||||
energy := lp.remainingPlanEnergy(goal)
|
||||
return time.Duration(energy * 1e3 / maxPower * float64(time.Hour))
|
||||
}
|
||||
|
||||
// GetPlanGoal returns the plan goal and if the goal is soc based
|
||||
func (lp *Loadpoint) GetPlanGoal() (float64, bool) {
|
||||
lp.RLock()
|
||||
defer lp.RUnlock()
|
||||
|
||||
if lp.socBasedPlanning() {
|
||||
_, soc := vehicle.Settings(lp.log, lp.GetVehicle()).GetPlanSoc()
|
||||
return float64(soc), true
|
||||
}
|
||||
|
||||
_, limit := lp.GetPlanEnergy()
|
||||
return max(0, limit-lp.getChargedEnergy()/1e3),
|
||||
limit > 0 && !lp.socBasedPlanning()
|
||||
return limit, false
|
||||
}
|
||||
|
||||
// planRequiredDuration is the estimated total charging duration
|
||||
func (lp *Loadpoint) planRequiredDuration(maxPower float64) time.Duration {
|
||||
if energy, ok := lp.remainingPlanEnergy(); ok {
|
||||
return time.Duration(energy * 1e3 / maxPower * float64(time.Hour))
|
||||
}
|
||||
|
||||
v := lp.GetVehicle()
|
||||
if v == nil || lp.socEstimator == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
_, soc := vehicle.Settings(lp.log, v).GetPlanSoc()
|
||||
|
||||
return lp.socEstimator.RemainingChargeDuration(soc, maxPower)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// GetPlan creates a charging plan for given time and duration
|
||||
func (lp *Loadpoint) GetPlan(targetTime time.Time, requiredDuration time.Duration) (api.Rates, error) {
|
||||
if lp.planner == nil || targetTime.IsZero() {
|
||||
return 0, nil, nil
|
||||
return 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
|
||||
plan.Sort()
|
||||
|
||||
return requiredDuration, plan, err
|
||||
return lp.planner.Plan(requiredDuration, targetTime)
|
||||
}
|
||||
|
||||
// plannerActive checks if the charging plan has a currently active slot
|
||||
|
|
@ -86,28 +92,34 @@ func (lp *Loadpoint) plannerActive() (active bool) {
|
|||
lp.publish(keys.PlanProjectedStart, planStart)
|
||||
}()
|
||||
|
||||
maxPower := lp.EffectiveMaxPower()
|
||||
planTime := lp.EffectivePlanTime()
|
||||
if lp.clock.Until(planTime) < 0 && !lp.planActive {
|
||||
lp.deletePlan()
|
||||
return false
|
||||
}
|
||||
|
||||
requiredDuration, plan, err := lp.GetPlan(lp.EffectivePlanTime(), maxPower)
|
||||
goal, _ := lp.GetPlanGoal()
|
||||
maxPower := lp.EffectiveMaxPower()
|
||||
requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower)
|
||||
if requiredDuration <= 0 {
|
||||
lp.deletePlan()
|
||||
return false
|
||||
}
|
||||
|
||||
plan, err := lp.GetPlan(planTime, requiredDuration)
|
||||
if err != nil {
|
||||
lp.log.ERROR.Println("planner:", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// nothing to do
|
||||
if requiredDuration == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
var requiredString string
|
||||
if req := requiredDuration.Round(time.Second); req > planner.Duration(plan).Round(time.Second) {
|
||||
requiredString = fmt.Sprintf(" (required: %v)", req)
|
||||
var overrun string
|
||||
if excessDuration := requiredDuration - lp.clock.Until(planTime); excessDuration > 0 {
|
||||
overrun = fmt.Sprintf("overruns by %v, ", excessDuration.Round(time.Second))
|
||||
}
|
||||
|
||||
planStart = planner.Start(plan)
|
||||
planTime := lp.EffectivePlanTime()
|
||||
lp.log.DEBUG.Printf("plan: charge %v%s starting at %v until %v (power: %.0fW, avg cost: %.3f)",
|
||||
planner.Duration(plan).Round(time.Second), requiredString, planStart.Round(time.Second).Local(), planTime.Round(time.Second).Local(),
|
||||
lp.log.DEBUG.Printf("plan: charge %v starting at %v until %v (%spower: %.0fW, avg cost: %.3f)",
|
||||
planner.Duration(plan).Round(time.Second), planStart.Round(time.Second).Local(), planTime.Round(time.Second).Local(), overrun,
|
||||
maxPower, planner.AverageCost(plan))
|
||||
|
||||
// log plan
|
||||
|
|
|
|||
|
|
@ -16,13 +16,26 @@ type Planner struct {
|
|||
tariff api.Tariff
|
||||
}
|
||||
|
||||
// WithClock sets a mockable clock
|
||||
func WithClock(clock clock.Clock) func(t *Planner) {
|
||||
return func(t *Planner) {
|
||||
t.clock = clock
|
||||
}
|
||||
}
|
||||
|
||||
// New creates a price planner
|
||||
func New(log *util.Logger, tariff api.Tariff) *Planner {
|
||||
return &Planner{
|
||||
func New(log *util.Logger, tariff api.Tariff, opt ...func(t *Planner)) *Planner {
|
||||
p := &Planner{
|
||||
log: log,
|
||||
clock: clock.New(),
|
||||
tariff: tariff,
|
||||
}
|
||||
|
||||
for _, o := range opt {
|
||||
o(p)
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// plan creates a lowest-cost plan or required duration.
|
||||
|
|
@ -76,14 +89,70 @@ func (t *Planner) plan(rates api.Rates, requiredDuration time.Duration, targetTi
|
|||
return plan
|
||||
}
|
||||
|
||||
// Plan creates a lowest-cost charging plan, considering edge conditions
|
||||
// Plan creates a continuous emergency charging plan
|
||||
func (t *Planner) continuousPlan(rates api.Rates, start, end time.Time) api.Rates {
|
||||
rates.Sort()
|
||||
|
||||
res := make(api.Rates, 0, len(rates)+2)
|
||||
for _, r := range rates {
|
||||
// slot before continuous plan
|
||||
if r.End.Before(start) || r.End.Equal(start) {
|
||||
continue
|
||||
}
|
||||
|
||||
// slot after continuous plan
|
||||
if r.Start.After(end) || r.Start.Equal(end) {
|
||||
continue
|
||||
}
|
||||
|
||||
// adjust first slot
|
||||
if r.Start.Before(start) && r.End.After(start) {
|
||||
r.Start = start
|
||||
}
|
||||
|
||||
// adjust last slot
|
||||
if r.Start.Before(end) && r.End.After(end) {
|
||||
r.End = end
|
||||
}
|
||||
|
||||
res = append(res, r)
|
||||
}
|
||||
|
||||
if len(res) == 0 {
|
||||
res = append(res, api.Rate{
|
||||
Start: start,
|
||||
End: end,
|
||||
})
|
||||
} else {
|
||||
// prepend missing slot
|
||||
if res[0].Start.After(start) {
|
||||
res = slices.Insert(res, 0, api.Rate{
|
||||
Start: start,
|
||||
End: res[0].Start,
|
||||
})
|
||||
}
|
||||
// append missing slot
|
||||
if last := res[len(res)-1]; last.End.Before(end) {
|
||||
res = append(res, api.Rate{
|
||||
Start: last.End,
|
||||
End: end,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func (t *Planner) Plan(requiredDuration time.Duration, targetTime time.Time) (api.Rates, error) {
|
||||
if t == nil || requiredDuration <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// calculate start time
|
||||
latestStart := targetTime.Add(-requiredDuration)
|
||||
if latestStart.Before(t.clock.Now()) {
|
||||
latestStart = t.clock.Now()
|
||||
targetTime = latestStart.Add(requiredDuration)
|
||||
}
|
||||
|
||||
// simplePlan only considers time, but not cost
|
||||
simplePlan := api.Rates{
|
||||
|
|
@ -106,8 +175,8 @@ func (t *Planner) Plan(requiredDuration time.Duration, targetTime time.Time) (ap
|
|||
}
|
||||
|
||||
// consume remaining time
|
||||
if t.clock.Now().After(latestStart) || t.clock.Now().Equal(latestStart) {
|
||||
requiredDuration = t.clock.Until(targetTime)
|
||||
if t.clock.Until(targetTime) <= requiredDuration {
|
||||
return t.continuousPlan(rates, latestStart, targetTime), nil
|
||||
}
|
||||
|
||||
// rates are by default sorted by date, oldest to newest
|
||||
|
|
@ -132,5 +201,10 @@ func (t *Planner) Plan(requiredDuration time.Duration, targetTime time.Time) (ap
|
|||
requiredDuration -= durationAfterRates
|
||||
}
|
||||
|
||||
return t.plan(rates, requiredDuration, targetTime), nil
|
||||
plan := t.plan(rates, requiredDuration, targetTime)
|
||||
|
||||
// sort plan by time
|
||||
plan.Sort()
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,5 +10,4 @@ The developed plan is then evaluated in terms of total cost and being "active".
|
|||
|
||||
## Edge cases
|
||||
|
||||
- Target time elapsed: inactive
|
||||
- No tariff: active if after or equal start time
|
||||
If time goal can not be met, the planner creates a continuous plan until up to required duration.
|
||||
|
|
|
|||
|
|
@ -135,11 +135,12 @@ func TestNilTariff(t *testing.T) {
|
|||
|
||||
plan, err := p.Plan(time.Hour, clock.Now().Add(30*time.Minute))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, SlotAt(clock.Now(), plan).IsEmpty(), "should start past start time")
|
||||
|
||||
plan, err = p.Plan(time.Hour, clock.Now().Add(-30*time.Minute))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, SlotAt(clock.Now(), plan).IsEmpty(), "should not start past target time")
|
||||
assert.Equal(t, api.Rates{
|
||||
{
|
||||
Start: clock.Now(),
|
||||
End: clock.Now().Add(60 * time.Minute),
|
||||
},
|
||||
}, plan, "expected simple plan")
|
||||
}
|
||||
|
||||
func TestFlatTariffTargetInThePast(t *testing.T) {
|
||||
|
|
@ -155,13 +156,20 @@ func TestFlatTariffTargetInThePast(t *testing.T) {
|
|||
tariff: trf,
|
||||
}
|
||||
|
||||
simplePlan := api.Rates{
|
||||
{
|
||||
Start: clock.Now(),
|
||||
End: clock.Now().Add(60 * time.Minute),
|
||||
},
|
||||
}
|
||||
|
||||
plan, err := p.Plan(time.Hour, clock.Now().Add(30*time.Minute))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, SlotAt(clock.Now(), plan).IsEmpty(), "should start past start time")
|
||||
assert.Equal(t, simplePlan, plan, "expected simple plan")
|
||||
|
||||
plan, err = p.Plan(time.Hour, clock.Now().Add(-30*time.Minute))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, SlotAt(clock.Now(), plan).IsEmpty(), "should not start past target time")
|
||||
assert.Equal(t, simplePlan, plan, "expected simple plan")
|
||||
}
|
||||
|
||||
func TestFlatTariffLongSlots(t *testing.T) {
|
||||
|
|
@ -227,11 +235,75 @@ func TestChargeAfterTargetTime(t *testing.T) {
|
|||
tariff: trf,
|
||||
}
|
||||
|
||||
simplePlan := api.Rates{
|
||||
{
|
||||
Start: clock.Now(),
|
||||
End: clock.Now().Add(60 * time.Minute),
|
||||
},
|
||||
}
|
||||
|
||||
plan, err := p.Plan(time.Hour, clock.Now())
|
||||
require.NoError(t, err)
|
||||
assert.True(t, SlotAt(clock.Now(), plan).IsEmpty(), "should not start past target time")
|
||||
assert.Equal(t, simplePlan, plan, "expected simple plan")
|
||||
|
||||
plan, err = p.Plan(time.Hour, clock.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, SlotAt(clock.Now(), plan).IsEmpty(), "should not start past target time")
|
||||
assert.Equal(t, simplePlan, plan, "expected simple plan")
|
||||
}
|
||||
|
||||
func TestContinuousPlanNoTariff(t *testing.T) {
|
||||
clock := clock.NewMock()
|
||||
|
||||
p := &Planner{
|
||||
log: util.NewLogger("foo"),
|
||||
clock: clock,
|
||||
}
|
||||
|
||||
plan, err := p.Plan(time.Hour, clock.Now())
|
||||
require.NoError(t, err)
|
||||
|
||||
// single-slot plan
|
||||
assert.Len(t, plan, 1)
|
||||
assert.Equal(t, clock.Now(), SlotAt(clock.Now(), plan).Start)
|
||||
assert.Equal(t, clock.Now().Add(time.Hour), SlotAt(clock.Now(), plan).End)
|
||||
}
|
||||
|
||||
func TestContinuousPlan(t *testing.T) {
|
||||
clock := clock.NewMock()
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
trf := api.NewMockTariff(ctrl)
|
||||
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clock.Now().Add(time.Hour), time.Hour), nil)
|
||||
|
||||
p := &Planner{
|
||||
log: util.NewLogger("foo"),
|
||||
clock: clock,
|
||||
tariff: trf,
|
||||
}
|
||||
|
||||
plan, err := p.Plan(150*time.Minute, clock.Now())
|
||||
require.NoError(t, err)
|
||||
|
||||
// 3-slot plan
|
||||
assert.Len(t, plan, 3)
|
||||
}
|
||||
|
||||
func TestContinuousPlanOutsideRates(t *testing.T) {
|
||||
clock := clock.NewMock()
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
trf := api.NewMockTariff(ctrl)
|
||||
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clock.Now().Add(time.Hour), time.Hour), nil)
|
||||
|
||||
p := &Planner{
|
||||
log: util.NewLogger("foo"),
|
||||
clock: clock,
|
||||
tariff: trf,
|
||||
}
|
||||
|
||||
plan, err := p.Plan(30*time.Minute, clock.Now())
|
||||
require.NoError(t, err)
|
||||
|
||||
// 3-slot plan
|
||||
assert.Len(t, plan, 1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ func (s *Estimator) RemainingChargeDuration(targetSoc int, chargePower float64)
|
|||
t2 = (float64(targetSoc) - max(s.vehicleSoc, rrp)) / minChargeSoc * s.virtualCapacity / ((chargePower-s.minChargePower)/2 + s.minChargePower)
|
||||
}
|
||||
|
||||
return time.Duration(float64(time.Hour) * (t1 + t2)).Round(time.Second)
|
||||
return max(0, time.Duration(float64(time.Hour)*(t1+t2))).Round(time.Second)
|
||||
}
|
||||
|
||||
// RemainingChargeEnergy returns the remaining charge energy in kWh
|
||||
|
|
|
|||
|
|
@ -250,16 +250,17 @@ logout = "abmelden"
|
|||
activate = "Aktivieren"
|
||||
co2Limit = "CO₂-Grenze von {co2}"
|
||||
costLimitIgnore = "Die eingestellte {limit} wird in diesem Zeitraum ignoriert."
|
||||
currentPlan = "Geplante Zeiten"
|
||||
currentPlan = "Aktiver Plan"
|
||||
descriptionEnergy = "Bis wann sollen {targetEnergy} ins Fahrzeug geladen sein?"
|
||||
descriptionSoc = "Wann soll das Fahrzeug auf {targetSoc}% geladen sein?"
|
||||
inactiveLabel = "Zielzeit"
|
||||
noActivePlan = "Kein aktiver Plan"
|
||||
notReachableInTime = "Zielzeit nicht erreichbar. Voraussichtliches Ende: {endTime}."
|
||||
onlyInPvMode = "Ladeplanung ist nur im PV-Modus aktiv."
|
||||
planDuration = "Ladedauer"
|
||||
planPeriodLabel = "Zeitraum"
|
||||
planPeriodValue = "{start} bis {end}"
|
||||
planUnknown = "noch unbekannt"
|
||||
preview = "Plan Vorschau"
|
||||
priceLimit = "Preisgrenze von {price}"
|
||||
remove = "Entfernen"
|
||||
setTargetTime = "keine"
|
||||
|
|
|
|||
|
|
@ -247,16 +247,17 @@ logout = "log out"
|
|||
activate = "Activate"
|
||||
co2Limit = "CO₂ limit of {co2}"
|
||||
costLimitIgnore = "The configured {limit} will be ignored during this period."
|
||||
currentPlan = "Charging schedule"
|
||||
currentPlan = "Active plan"
|
||||
descriptionEnergy = "Until when should {targetEnergy} be loaded into the vehicle?"
|
||||
descriptionSoc = "When should the vehicle be charged to {targetSoc}%?"
|
||||
inactiveLabel = "Target time"
|
||||
noActivePlan = "No Active Plan"
|
||||
notReachableInTime = "Goal not reachable in time. Estimated finish: {endTime}."
|
||||
onlyInPvMode = "Charging plan only works in solar mode."
|
||||
planDuration = "Charging time"
|
||||
planPeriodLabel = "Period"
|
||||
planPeriodValue = "{start} to {end}"
|
||||
planUnknown = "not known yet"
|
||||
preview = "Preview plan"
|
||||
priceLimit = "price limit of {price}"
|
||||
remove = "Remove"
|
||||
setTargetTime = "none"
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, cache *util.Cache) {
|
|||
"maxcurrent": {[]string{"POST", "OPTIONS"}, "/maxcurrent/{value:[0-9.]+}", floatHandler(pass(lp.SetMaxCurrent), lp.GetMaxCurrent)},
|
||||
"phases": {[]string{"POST", "OPTIONS"}, "/phases/{value:[0-9]+}", phasesHandler(lp)},
|
||||
"plan": {[]string{"GET"}, "/plan", planHandler(lp)},
|
||||
"planpreview": {[]string{"GET"}, "/plan/preview/{type:(?:soc|energy)}/{value:[0-9.]+}/{time:[0-9TZ:.-]+}", planPreviewHandler(lp)},
|
||||
"planenergy": {[]string{"POST", "OPTIONS"}, "/plan/energy/{value:[0-9.]+}/{time:[0-9TZ:.-]+}", planEnergyHandler(lp)},
|
||||
"planenergy2": {[]string{"DELETE", "OPTIONS"}, "/plan/energy", planRemoveHandler(lp)},
|
||||
"vehicle": {[]string{"POST", "OPTIONS"}, "/vehicle/{name:[a-zA-Z0-9_.:-]+}", vehicleSelectHandler(site, lp)},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
|
@ -83,25 +85,87 @@ func remoteDemandHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// planHandler returns the current effective plan
|
||||
// planHandler returns the current plan
|
||||
func planHandler(lp loadpoint.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
maxPower := lp.EffectiveMaxPower()
|
||||
planTime := lp.EffectivePlanTime()
|
||||
power := lp.EffectiveMaxPower()
|
||||
requiredDuration, plan, err := lp.GetPlan(planTime, power)
|
||||
|
||||
goal, _ := lp.GetPlanGoal()
|
||||
requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower)
|
||||
plan, err := lp.GetPlan(planTime, requiredDuration)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
res := struct {
|
||||
PlanTime time.Time `json:"planTime"`
|
||||
Duration int64 `json:"duration"`
|
||||
Plan api.Rates `json:"plan"`
|
||||
Power float64 `json:"power"`
|
||||
}{
|
||||
PlanTime: planTime,
|
||||
Duration: int64(requiredDuration.Seconds()),
|
||||
Plan: plan,
|
||||
Power: power,
|
||||
Power: maxPower,
|
||||
}
|
||||
|
||||
jsonResult(w, res)
|
||||
}
|
||||
}
|
||||
|
||||
// planPreviewHandler returns a plan preview for given parameters
|
||||
func planPreviewHandler(lp loadpoint.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
planTime, err := time.Parse(time.RFC3339, vars["time"])
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
goal, err := strconv.ParseFloat(vars["value"], 64)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
switch typ := vars["type"]; typ {
|
||||
case "soc":
|
||||
if !lp.SocBasedPlanning() {
|
||||
jsonError(w, http.StatusBadRequest, errors.New("soc planning only available for vehicles with known soc and capacity"))
|
||||
return
|
||||
}
|
||||
case "energy":
|
||||
if lp.SocBasedPlanning() {
|
||||
jsonError(w, http.StatusBadRequest, errors.New("energy planning not available for vehicles with known soc and capacity"))
|
||||
return
|
||||
}
|
||||
default:
|
||||
jsonError(w, http.StatusBadRequest, fmt.Errorf("invalid plan type: %s", typ))
|
||||
return
|
||||
}
|
||||
|
||||
maxPower := lp.EffectiveMaxPower()
|
||||
requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower)
|
||||
plan, err := lp.GetPlan(planTime, requiredDuration)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
res := struct {
|
||||
PlanTime time.Time `json:"planTime"`
|
||||
Duration int64 `json:"duration"`
|
||||
Plan api.Rates `json:"plan"`
|
||||
Power float64 `json:"power"`
|
||||
}{
|
||||
PlanTime: planTime,
|
||||
Duration: int64(requiredDuration.Seconds()),
|
||||
Plan: plan,
|
||||
Power: maxPower,
|
||||
}
|
||||
|
||||
jsonResult(w, res)
|
||||
|
|
|
|||
|
|
@ -84,3 +84,20 @@ vehicles:
|
|||
source: js
|
||||
script: |
|
||||
50
|
||||
- name: vehicleWithMassiveCapacity
|
||||
type: custom
|
||||
title: Vehicle with SoC with Massive Capacity
|
||||
capacity: 1000
|
||||
soc:
|
||||
source: js
|
||||
script: |
|
||||
50
|
||||
|
||||
tariffs:
|
||||
currency: EUR
|
||||
grid:
|
||||
type: fixed
|
||||
price: 0.4 # EUR/kWh
|
||||
zones:
|
||||
- hours: 1-6
|
||||
price: 0.2
|
||||
|
|
|
|||
|
|
@ -66,80 +66,207 @@ test.describe("basic functionality", async () => {
|
|||
});
|
||||
});
|
||||
|
||||
test.describe("guest vehicle", async () => {
|
||||
test("kWh based plan and limit", async ({ page }) => {
|
||||
test.describe("vehicle variations", async () => {
|
||||
test.describe("guest vehicle", async () => {
|
||||
test("kWh based plan and limit", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp1 = await page.getByTestId("loadpoint").first();
|
||||
|
||||
// change vehicle
|
||||
await lp1.getByTestId("change-vehicle").click();
|
||||
await lp1.getByRole("button", { name: "Guest vehicle" }).click();
|
||||
|
||||
// kWh based limit
|
||||
await lp1.getByTestId("limit-energy").getByRole("combobox").selectOption("50 kWh");
|
||||
|
||||
// kWh based plan
|
||||
await setAndVerifyPlan(page, lp1, { energy: "25 kWh" });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("vehicle no soc no capacity", async () => {
|
||||
test("kWh based plan and limit", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp1 = await page.getByTestId("loadpoint").first();
|
||||
|
||||
// change vehicle
|
||||
await lp1.getByTestId("change-vehicle").click();
|
||||
await lp1.getByRole("button", { name: "Vehicle no SoC no Capacity" }).click();
|
||||
|
||||
// kWh based limit
|
||||
await lp1.getByTestId("limit-energy").getByRole("combobox").selectOption("50 kWh");
|
||||
|
||||
// kWh based plan
|
||||
await setAndVerifyPlan(page, lp1, { energy: "25 kWh" });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("vehicle no soc with capacity", async () => {
|
||||
test("kWh based plan and limit", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp1 = await page.getByTestId("loadpoint").first();
|
||||
|
||||
// change vehicle
|
||||
await lp1.getByTestId("change-vehicle").click();
|
||||
await lp1.getByRole("button", { name: "Vehicle no SoC with Capacity" }).click();
|
||||
|
||||
// kWh based limit
|
||||
await lp1.getByTestId("limit-energy").getByRole("combobox").selectOption("50 kWh (+50%)");
|
||||
|
||||
// kWh based plan
|
||||
await setAndVerifyPlan(page, lp1, { energy: "25 kWh" });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("vehicle with soc no capacity", async () => {
|
||||
test("kWh based plan and soc based limit", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp1 = await page.getByTestId("loadpoint").first();
|
||||
|
||||
// change vehicle
|
||||
await lp1.getByTestId("change-vehicle").click();
|
||||
await lp1.getByRole("button", { name: "Vehicle with SoC no Capacity" }).click();
|
||||
|
||||
// soc based limit
|
||||
await lp1.getByTestId("limit-soc").getByRole("combobox").selectOption("80%");
|
||||
|
||||
// soc based plan
|
||||
await setAndVerifyPlan(page, lp1, { energy: "50 kWh" });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("vehicle with soc with capacity", async () => {
|
||||
test("soc based plan and limit", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp1 = await page.getByTestId("loadpoint").first();
|
||||
|
||||
// change vehicle
|
||||
await lp1.getByTestId("change-vehicle").click();
|
||||
await lp1.getByRole("button", { name: "Vehicle with SoC with Capacity" }).click();
|
||||
|
||||
// soc based limit
|
||||
await lp1.getByTestId("limit-soc").getByRole("combobox").selectOption("80%");
|
||||
|
||||
// soc based plan
|
||||
await setAndVerifyPlan(page, lp1, { soc: "60%" });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("loadpoint with soc, guest vehicle", async () => {
|
||||
test("kWh based plan and soc based limit", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp2 = await page.getByTestId("loadpoint").last();
|
||||
|
||||
// change vehicle
|
||||
await lp2.getByTestId("change-vehicle").click();
|
||||
await lp2.getByRole("button", { name: "Guest Vehicle" }).click();
|
||||
|
||||
// soc based limit
|
||||
await lp2.getByTestId("limit-soc").getByRole("combobox").selectOption("80%");
|
||||
|
||||
// soc based plan
|
||||
await setAndVerifyPlan(page, lp2, { energy: "50 kWh" });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("loadpoint with soc, vehicle with capacity", async () => {
|
||||
test("soc based plan and limit", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp2 = await page.getByTestId("loadpoint").last();
|
||||
|
||||
// change vehicle
|
||||
await lp2.getByTestId("change-vehicle").click();
|
||||
await lp2.getByRole("button", { name: "Vehicle no SoC with Capacity" }).click();
|
||||
|
||||
// soc based limit
|
||||
await lp2.getByTestId("limit-soc").getByRole("combobox").selectOption("80%");
|
||||
|
||||
// soc based plan
|
||||
await setAndVerifyPlan(page, lp2, { soc: "60%" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("preview", async () => {
|
||||
const cases = [
|
||||
{
|
||||
szenario: "kWh based plan",
|
||||
vehicle: "Vehicle no SoC with Capacity",
|
||||
goalId: "plan-energy",
|
||||
},
|
||||
{
|
||||
szenario: "soc based plan",
|
||||
vehicle: "Vehicle with SoC with Capacity",
|
||||
goalId: "plan-soc",
|
||||
},
|
||||
];
|
||||
|
||||
cases.forEach((c) => {
|
||||
test(c.szenario, async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp1 = await page.getByTestId("loadpoint").first();
|
||||
|
||||
// change vehicle
|
||||
await lp1.getByTestId("change-vehicle").click();
|
||||
await lp1.getByRole("button", { name: c.vehicle }).click();
|
||||
|
||||
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
|
||||
|
||||
// initial set -> preview plan
|
||||
await page.getByTestId("plan-day").selectOption({ index: 1 });
|
||||
await page.getByTestId("plan-time").fill("09:30");
|
||||
await page.getByTestId(c.goalId).selectOption("80");
|
||||
await expect(page.getByTestId("plan-preview-title")).toHaveText("Preview plan");
|
||||
|
||||
// activate -> active plan
|
||||
await page.getByTestId("plan-active").click();
|
||||
await expect(page.getByTestId("plan-preview-title")).toHaveText("Active plan");
|
||||
|
||||
// change -> preview plan
|
||||
await page.getByTestId(c.goalId).selectOption("90");
|
||||
await expect(page.getByTestId("plan-preview-title")).toHaveText("Preview plan");
|
||||
|
||||
// apply -> active plan
|
||||
await expect(page.getByTestId("plan-apply")).toBeVisible();
|
||||
await page.getByTestId("plan-apply").click();
|
||||
await expect(page.getByTestId("plan-preview-title")).toHaveText("Active plan");
|
||||
|
||||
// deactivate -> stay in preview
|
||||
await page.getByTestId("plan-time").fill("23:30");
|
||||
await expect(page.getByTestId("plan-preview-title")).toHaveText("Preview plan");
|
||||
await expect(page.getByTestId("plan-apply")).toBeVisible();
|
||||
await page.getByTestId("plan-active").click();
|
||||
await expect(page.getByTestId("plan-preview-title")).toHaveText("Preview plan");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("warnings", async () => {
|
||||
test("goal not reachable in time", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp1 = await page.getByTestId("loadpoint").first();
|
||||
|
||||
// change vehicle
|
||||
await lp1.getByTestId("change-vehicle").click();
|
||||
await lp1.getByRole("button", { name: "Guest vehicle" }).click();
|
||||
await lp1.getByRole("button", { name: "Vehicle with SoC with massive Capacity" }).click();
|
||||
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
|
||||
|
||||
// kWh based limit
|
||||
await lp1.getByTestId("limit-energy").getByRole("combobox").selectOption("50 kWh");
|
||||
await page.getByTestId("plan-active").click();
|
||||
|
||||
// kWh based plan
|
||||
await setAndVerifyPlan(page, lp1, { energy: "25 kWh" });
|
||||
await expect(page.getByTestId("plan-warnings")).toContainText(
|
||||
"Goal not reachable in time. Estimated finish"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("vehicle no soc no capacity", async () => {
|
||||
test("kWh based plan and limit", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp1 = await page.getByTestId("loadpoint").first();
|
||||
|
||||
// change vehicle
|
||||
await lp1.getByTestId("change-vehicle").click();
|
||||
await lp1.getByRole("button", { name: "Vehicle no SoC no Capacity" }).click();
|
||||
|
||||
// kWh based limit
|
||||
await lp1.getByTestId("limit-energy").getByRole("combobox").selectOption("50 kWh");
|
||||
|
||||
// kWh based plan
|
||||
await setAndVerifyPlan(page, lp1, { energy: "25 kWh" });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("vehicle no soc with capacity", async () => {
|
||||
test("kWh based plan and limit", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp1 = await page.getByTestId("loadpoint").first();
|
||||
|
||||
// change vehicle
|
||||
await lp1.getByTestId("change-vehicle").click();
|
||||
await lp1.getByRole("button", { name: "Vehicle no SoC with Capacity" }).click();
|
||||
|
||||
// kWh based limit
|
||||
await lp1.getByTestId("limit-energy").getByRole("combobox").selectOption("50 kWh (+50%)");
|
||||
|
||||
// kWh based plan
|
||||
await setAndVerifyPlan(page, lp1, { energy: "25 kWh" });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("vehicle with soc no capacity", async () => {
|
||||
test("kWh based plan and soc based limit", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp1 = await page.getByTestId("loadpoint").first();
|
||||
|
||||
// change vehicle
|
||||
await lp1.getByTestId("change-vehicle").click();
|
||||
await lp1.getByRole("button", { name: "Vehicle with SoC no Capacity" }).click();
|
||||
|
||||
// soc based limit
|
||||
await lp1.getByTestId("limit-soc").getByRole("combobox").selectOption("80%");
|
||||
|
||||
// soc based plan
|
||||
await setAndVerifyPlan(page, lp1, { energy: "50 kWh" });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("vehicle with soc with capacity", async () => {
|
||||
test("soc based plan and limit", async ({ page }) => {
|
||||
test("time in the past", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp1 = await page.getByTestId("loadpoint").first();
|
||||
|
|
@ -147,47 +274,16 @@ test.describe("vehicle with soc with capacity", async () => {
|
|||
// change vehicle
|
||||
await lp1.getByTestId("change-vehicle").click();
|
||||
await lp1.getByRole("button", { name: "Vehicle with SoC with Capacity" }).click();
|
||||
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
|
||||
|
||||
// soc based limit
|
||||
await lp1.getByTestId("limit-soc").getByRole("combobox").selectOption("80%");
|
||||
await expect(page.getByTestId("plan-entry-warnings")).not.toBeVisible();
|
||||
|
||||
// soc based plan
|
||||
await setAndVerifyPlan(page, lp1, { soc: "60%" });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("loadpoint with soc, guest vehicle", async () => {
|
||||
test("kWh based plan and soc based limit", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp2 = await page.getByTestId("loadpoint").last();
|
||||
|
||||
// change vehicle
|
||||
await lp2.getByTestId("change-vehicle").click();
|
||||
await lp2.getByRole("button", { name: "Guest Vehicle" }).click();
|
||||
|
||||
// soc based limit
|
||||
await lp2.getByTestId("limit-soc").getByRole("combobox").selectOption("80%");
|
||||
|
||||
// soc based plan
|
||||
await setAndVerifyPlan(page, lp2, { energy: "50 kWh" });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("loadpoint with soc, vehicle with capacity", async () => {
|
||||
test("soc based plan and limit", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp2 = await page.getByTestId("loadpoint").last();
|
||||
|
||||
// change vehicle
|
||||
await lp2.getByTestId("change-vehicle").click();
|
||||
await lp2.getByRole("button", { name: "Vehicle no SoC with Capacity" }).click();
|
||||
|
||||
// soc based limit
|
||||
await lp2.getByTestId("limit-soc").getByRole("combobox").selectOption("80%");
|
||||
|
||||
// soc based plan
|
||||
await setAndVerifyPlan(page, lp2, { soc: "60%" });
|
||||
await page.getByTestId("plan-day").selectOption({ index: 0 });
|
||||
await page.getByTestId("plan-time").fill("00:01");
|
||||
|
||||
await expect(page.getByTestId("plan-entry-warnings")).toContainText(
|
||||
"Pick a time in the future, Marty."
|
||||
);
|
||||
await page.getByTestId("plan-time").fill("00:01");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue