Planner: don't touch plan when disabling due to soc/energy limits reached (#11030)

This commit is contained in:
andig 2023-12-19 18:10:01 +01:00 • committed by GitHub
parent 256a604566
commit e1e703c4a7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 734 additions and 223 deletions

View file

@ -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",

View file

@ -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;
},

View file

@ -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>

View file

@ -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) {

View file

@ -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;

View file

@ -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>

View file

@ -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;
},

View file

@ -0,0 +1,3 @@
export default function (obj1, obj2) {
return JSON.stringify(obj1) === JSON.stringify(obj2);
}

View file

@ -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)
}

View file

@ -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

View file

@ -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()

View file

@ -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 {

View file

@ -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

View file

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

View file

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

View file

@ -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)
}

View file

@ -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

View file

@ -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"

View file

@ -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"

View file

@ -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)},

View file

@ -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)

View file

@ -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

View file

@ -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");
});
});