From e1e703c4a71bfd820e2b87fd746fea7673833b01 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 19 Dec 2023 18:10:01 +0100 Subject: [PATCH] Planner: don't touch plan when disabling due to soc/energy limits reached (#11030) --- assets/css/app.css | 8 +- assets/js/components/ChargingPlanPreview.vue | 28 +- assets/js/components/ChargingPlanSettings.vue | 52 +++- .../components/ChargingPlanSettingsEntry.vue | 33 ++- assets/js/components/ChargingPlanWarnings.vue | 32 ++- assets/js/components/TariffChart.vue | 23 +- assets/js/components/Vehicle.vue | 3 +- assets/js/utils/deepEqual.js | 3 + core/loadpoint.go | 7 +- core/loadpoint/api.go | 8 +- core/loadpoint/mock.go | 52 +++- core/loadpoint_effective.go | 5 + core/loadpoint_plan.go | 100 ++++--- core/planner/planner.go | 88 +++++- core/planner/planner.md | 3 +- core/planner/planner_test.go | 90 ++++++- core/soc/estimator.go | 2 +- i18n/de.toml | 5 +- i18n/en.toml | 5 +- server/http.go | 1 + server/http_loadpoint_handler.go | 72 ++++- tests/plan.evcc.yaml | 17 ++ tests/plan.spec.js | 254 ++++++++++++------ 23 files changed, 701 insertions(+), 190 deletions(-) create mode 100644 assets/js/utils/deepEqual.js diff --git a/assets/css/app.css b/assets/css/app.css index 79011c50c0..4bf4818f71 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -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", diff --git a/assets/js/components/ChargingPlanPreview.vue b/assets/js/components/ChargingPlanPreview.vue index 9478348acd..bd78a052e3 100644 --- a/assets/js/components/ChargingPlanPreview.vue +++ b/assets/js/components/ChargingPlanPreview.vue @@ -3,7 +3,11 @@
{{ $t("main.targetChargePlan.chargeDuration") }}
-
+
{{ planDuration }}
{{ 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; }, diff --git a/assets/js/components/ChargingPlanSettings.vue b/assets/js/components/ChargingPlanSettings.vue index 511b48361c..8f0373fa35 100644 --- a/assets/js/components/ChargingPlanSettings.vue +++ b/assets/js/components/ChargingPlanSettings.vue @@ -12,14 +12,15 @@ :soc-based-planning="socBasedPlanning" @plan-updated="(data) => updatePlan({ index: 0, ...data })" @plan-removed="() => removePlan(0)" + @plan-preview="previewPlan" />

-
- {{ $t(`main.targetCharge.${plans.length ? "currentPlan" : "noActivePlan"}`) }} +
+ {{ $t(`main.targetCharge.${isPreview ? "preview" : "currentPlan"}`) }}
@@ -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); + }, }, }; diff --git a/assets/js/components/ChargingPlanSettingsEntry.vue b/assets/js/components/ChargingPlanSettingsEntry.vue index f3270fe753..e3cacb7264 100644 --- a/assets/js/components/ChargingPlanSettingsEntry.vue +++ b/assets/js/components/ChargingPlanSettingsEntry.vue @@ -32,6 +32,7 @@ v-model="selectedDay" class="form-select me-2" data-testid="plan-day" + @change="preview" >
@@ -66,6 +68,7 @@ v-model="selectedSoc" class="form-select mx-0" data-testid="plan-soc" + @change="preview" >
-

+

{{ $t("main.targetCharge.targetIsInThePast") }} @@ -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) { diff --git a/assets/js/components/ChargingPlanWarnings.vue b/assets/js/components/ChargingPlanWarnings.vue index 3b41133dc0..31c4ec7bed 100644 --- a/assets/js/components/ChargingPlanWarnings.vue +++ b/assets/js/components/ChargingPlanWarnings.vue @@ -1,8 +1,5 @@ @@ -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; diff --git a/assets/js/components/TariffChart.vue b/assets/js/components/TariffChart.vue index 2c09dc5d0c..3d3040564d 100644 --- a/assets/js/components/TariffChart.vue +++ b/assets/js/components/TariffChart.vue @@ -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; +} diff --git a/assets/js/components/Vehicle.vue b/assets/js/components/Vehicle.vue index a7424265d6..d2db98f385 100644 --- a/assets/js/components/Vehicle.vue +++ b/assets/js/components/Vehicle.vue @@ -14,7 +14,6 @@ @limit-soc-drag="limitSocDrag" @plan-clicked="openPlanModal" /> -

10 && this.range) { - return this.range / this.vehicleSoc; + return Math.round((this.range / this.vehicleSoc) * 1e2) / 1e2; } return null; }, diff --git a/assets/js/utils/deepEqual.js b/assets/js/utils/deepEqual.js new file mode 100644 index 0000000000..62623d7631 --- /dev/null +++ b/assets/js/utils/deepEqual.js @@ -0,0 +1,3 @@ +export default function (obj1, obj2) { + return JSON.stringify(obj1) === JSON.stringify(obj2); +} diff --git a/core/loadpoint.go b/core/loadpoint.go index 08a290226c..d4c2e415f6 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -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) } diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go index a72ee18848..d3d480fc31 100644 --- a/core/loadpoint/api.go +++ b/core/loadpoint/api.go @@ -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 diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go index 0e52557dbc..091a65932d 100644 --- a/core/loadpoint/mock.go +++ b/core/loadpoint/mock.go @@ -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() diff --git a/core/loadpoint_effective.go b/core/loadpoint_effective.go index 1e50cf42f5..acc0f52d2c 100644 --- a/core/loadpoint_effective.go +++ b/core/loadpoint_effective.go @@ -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 { diff --git a/core/loadpoint_plan.go b/core/loadpoint_plan.go index f91742cdbf..8fedb3f138 100644 --- a/core/loadpoint_plan.go +++ b/core/loadpoint_plan.go @@ -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) { - _, limit := lp.GetPlanEnergy() - return max(0, limit-lp.getChargedEnergy()/1e3), - limit > 0 && !lp.socBasedPlanning() +func (lp *Loadpoint) remainingPlanEnergy(planEnergy float64) float64 { + return max(0, planEnergy-lp.getChargedEnergy()/1e3) } -// 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)) - } +// GetPlanRequiredDuration is the estimated total charging duration +func (lp *Loadpoint) GetPlanRequiredDuration(goal, maxPower float64) time.Duration { + lp.RLock() + defer lp.RUnlock() - v := lp.GetVehicle() - if v == nil || lp.socEstimator == nil { - return 0 + if lp.socBasedPlanning() { + if lp.socEstimator == nil { + return 0 + } + return lp.socEstimator.RemainingChargeDuration(int(goal), maxPower) } - _, soc := vehicle.Settings(lp.log, v).GetPlanSoc() - - return lp.socEstimator.RemainingChargeDuration(soc, maxPower) + energy := lp.remainingPlanEnergy(goal) + return time.Duration(energy * 1e3 / maxPower * float64(time.Hour)) } -// GetPlan creates a charging plan -// -// Results: -// - required total charging duration -// - actual charging plan as rate table -func (lp *Loadpoint) GetPlan(targetTime time.Time, maxPower float64) (time.Duration, api.Rates, error) { - if lp.planner == nil || targetTime.IsZero() { - return 0, nil, nil - } +// GetPlanGoal returns the plan goal and if the goal is soc based +func (lp *Loadpoint) GetPlanGoal() (float64, bool) { + lp.RLock() + defer lp.RUnlock() - // don't start planning into the past - if targetTime.Before(lp.clock.Now()) && !lp.planActive { - return 0, nil, nil + if lp.socBasedPlanning() { + _, soc := vehicle.Settings(lp.log, lp.GetVehicle()).GetPlanSoc() + return float64(soc), true } - requiredDuration := lp.planRequiredDuration(maxPower) - plan, err := lp.planner.Plan(requiredDuration, targetTime) + _, limit := lp.GetPlanEnergy() + return limit, false +} - // sort plan by time - plan.Sort() +// 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 nil, nil + } - 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) - if err != nil { - lp.log.ERROR.Println("planner:", err) + goal, _ := lp.GetPlanGoal() + maxPower := lp.EffectiveMaxPower() + requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower) + if requiredDuration <= 0 { + lp.deletePlan() return false } - // nothing to do - if requiredDuration == 0 { + plan, err := lp.GetPlan(planTime, requiredDuration) + if err != nil { + lp.log.ERROR.Println("planner:", err) 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 diff --git a/core/planner/planner.go b/core/planner/planner.go index 431930544e..feb57845e6 100644 --- a/core/planner/planner.go +++ b/core/planner/planner.go @@ -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 } diff --git a/core/planner/planner.md b/core/planner/planner.md index a26f7405eb..f678463428 100644 --- a/core/planner/planner.md +++ b/core/planner/planner.md @@ -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. diff --git a/core/planner/planner_test.go b/core/planner/planner_test.go index 61ceb906ab..507311fdd3 100644 --- a/core/planner/planner_test.go +++ b/core/planner/planner_test.go @@ -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) } diff --git a/core/soc/estimator.go b/core/soc/estimator.go index 61cd702a10..db01b571af 100644 --- a/core/soc/estimator.go +++ b/core/soc/estimator.go @@ -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 diff --git a/i18n/de.toml b/i18n/de.toml index e9881ac336..44beb45c85 100644 --- a/i18n/de.toml +++ b/i18n/de.toml @@ -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" diff --git a/i18n/en.toml b/i18n/en.toml index 9be6deb050..d8590900c5 100644 --- a/i18n/en.toml +++ b/i18n/en.toml @@ -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" diff --git a/server/http.go b/server/http.go index 915504b2d1..cade80bfef 100644 --- a/server/http.go +++ b/server/http.go @@ -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)}, diff --git a/server/http_loadpoint_handler.go b/server/http_loadpoint_handler.go index b3f8638d81..6b4c6ec5b7 100644 --- a/server/http_loadpoint_handler.go +++ b/server/http_loadpoint_handler.go @@ -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: 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: power, + Power: maxPower, } jsonResult(w, res) diff --git a/tests/plan.evcc.yaml b/tests/plan.evcc.yaml index 2930ae09d2..e0f2fb4800 100755 --- a/tests/plan.evcc.yaml +++ b/tests/plan.evcc.yaml @@ -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 diff --git a/tests/plan.spec.js b/tests/plan.spec.js index 3997bdeedd..b6be2d2d42 100644 --- a/tests/plan.spec.js +++ b/tests/plan.spec.js @@ -66,128 +66,224 @@ test.describe("basic functionality", async () => { }); }); -test.describe("guest vehicle", async () => { - test("kWh based plan and limit", async ({ page }) => { - await page.goto("/"); +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(); + const lp1 = await page.getByTestId("loadpoint").first(); - // change vehicle - await lp1.getByTestId("change-vehicle").click(); - await lp1.getByRole("button", { name: "Guest vehicle" }).click(); + // 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 limit + await lp1.getByTestId("limit-energy").getByRole("combobox").selectOption("50 kWh"); - // kWh based plan - await setAndVerifyPlan(page, lp1, { energy: "25 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("/"); + 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(); + 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(); + // 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 limit + await lp1.getByTestId("limit-energy").getByRole("combobox").selectOption("50 kWh"); - // kWh based plan - await setAndVerifyPlan(page, lp1, { energy: "25 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("/"); + 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(); + 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(); + // 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 limit + await lp1.getByTestId("limit-energy").getByRole("combobox").selectOption("50 kWh (+50%)"); - // kWh based plan - await setAndVerifyPlan(page, lp1, { energy: "25 kWh" }); + // 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("/"); + 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(); + 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(); + // 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 limit + await lp1.getByTestId("limit-soc").getByRole("combobox").selectOption("80%"); - // soc based plan - await setAndVerifyPlan(page, lp1, { energy: "50 kWh" }); + // 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("/"); + 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(); + 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(); + // 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 limit + await lp1.getByTestId("limit-soc").getByRole("combobox").selectOption("80%"); - // soc based plan - await setAndVerifyPlan(page, lp1, { soc: "60%" }); + // 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("loadpoint with soc, guest vehicle", async () => { - test("kWh based plan and soc based limit", async ({ page }) => { +test.describe("warnings", async () => { + test("goal not reachable in time", async ({ page }) => { await page.goto("/"); - const lp2 = await page.getByTestId("loadpoint").last(); + const lp1 = await page.getByTestId("loadpoint").first(); // change vehicle - await lp2.getByTestId("change-vehicle").click(); - await lp2.getByRole("button", { name: "Guest Vehicle" }).click(); + await lp1.getByTestId("change-vehicle").click(); + await lp1.getByRole("button", { name: "Vehicle with SoC with massive Capacity" }).click(); + await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); - // soc based limit - await lp2.getByTestId("limit-soc").getByRole("combobox").selectOption("80%"); + await page.getByTestId("plan-active").click(); - // soc based plan - await setAndVerifyPlan(page, lp2, { energy: "50 kWh" }); + await expect(page.getByTestId("plan-warnings")).toContainText( + "Goal not reachable in time. Estimated finish" + ); }); -}); - -test.describe("loadpoint with soc, vehicle with capacity", async () => { - test("soc based plan and limit", async ({ page }) => { + test("time in the past", async ({ page }) => { await page.goto("/"); - const lp2 = await page.getByTestId("loadpoint").last(); + const lp1 = await page.getByTestId("loadpoint").first(); // change vehicle - await lp2.getByTestId("change-vehicle").click(); - await lp2.getByRole("button", { name: "Vehicle no SoC with Capacity" }).click(); + 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(); + + await expect(page.getByTestId("plan-entry-warnings")).not.toBeVisible(); - // soc based limit - await lp2.getByTestId("limit-soc").getByRole("combobox").selectOption("80%"); + await page.getByTestId("plan-day").selectOption({ index: 0 }); + await page.getByTestId("plan-time").fill("00:01"); - // soc based plan - await setAndVerifyPlan(page, lp2, { soc: "60%" }); + await expect(page.getByTestId("plan-entry-warnings")).toContainText( + "Pick a time in the future, Marty." + ); + await page.getByTestId("plan-time").fill("00:01"); }); });