diff --git a/api/plans.go b/api/plans.go index 33ae03b25..65eb9e40e 100644 --- a/api/plans.go +++ b/api/plans.go @@ -1,17 +1,10 @@ package api -import "time" - type RepeatingPlan struct { Weekdays []int `json:"weekdays"` // 0-6 (Sunday-Saturday) Time string `json:"time"` // HH:MM Tz string `json:"tz"` // timezone in IANA format Soc int `json:"soc"` // target soc + Precondition int64 `json:"precondition"` // precondition duration in seconds Active bool `json:"active"` // active flag - Precondition int64 `json:"-" todo:"..."` // TODO deprecated -} - -type PlanStrategy struct { - Continuous bool `json:"continuous"` // force continuous planning - Precondition time.Duration `json:"precondition"` // precondition duration in seconds } diff --git a/assets/js/components/ChargingPlans/ChargingPlan.vue b/assets/js/components/ChargingPlans/ChargingPlan.vue index 61739d167..d043c4a28 100644 --- a/assets/js/components/ChargingPlans/ChargingPlan.vue +++ b/assets/js/components/ChargingPlans/ChargingPlan.vue @@ -69,7 +69,6 @@ @static-plan-updated="updateStaticPlan" @static-plan-removed="removeStaticPlan" @repeating-plans-updated="updateRepeatingPlans" - @plan-strategy-updated="updatePlanStrategy" /> 0 ) { - return [...(this.vehicle.repeatingPlans || [])]; + return [...this.vehicle.repeatingPlans]; } return []; }, @@ -292,12 +286,15 @@ export default defineComponent({ }, updateStaticPlan(plan: StaticPlan): void { const timeISO = plan.time.toISOString(); + const params = plan.precondition ? { precondition: plan.precondition } : undefined; if (this.socBasedPlanning) { const p = plan as StaticSocPlan; - api.post(`${this.apiVehicle}plan/soc/${p.soc}/${timeISO}`, null); + api.post(`${this.apiVehicle}plan/soc/${p.soc}/${timeISO}`, null, { params }); } else { const p = plan as StaticEnergyPlan; - api.post(`${this.apiLoadpoint}plan/energy/${p.energy}/${timeISO}`, null); + api.post(`${this.apiLoadpoint}plan/energy/${p.energy}/${timeISO}`, null, { + params, + }); } }, removeStaticPlan(): void { @@ -310,13 +307,6 @@ export default defineComponent({ updateRepeatingPlans(plans: RepeatingPlan[]): void { api.post(`${this.apiVehicle}plan/repeating`, plans); }, - updatePlanStrategy(strategy: PlanStrategy): void { - if (this.socBasedPlanning) { - api.post(`${this.apiVehicle}plan/strategy`, strategy); - } else { - api.post(`${this.apiLoadpoint}plan/strategy`, strategy); - } - }, setMinSoc(soc: number): void { api.post(`${this.apiVehicle}minsoc/${soc}`); }, diff --git a/assets/js/components/ChargingPlans/PlanRepeatingSettings.vue b/assets/js/components/ChargingPlans/PlanRepeatingSettings.vue index a2122d815..f02f2f54b 100644 --- a/assets/js/components/ChargingPlans/PlanRepeatingSettings.vue +++ b/assets/js/components/ChargingPlans/PlanRepeatingSettings.vue @@ -24,12 +24,17 @@ {{ $t("main.chargingPlan.time") }} -
+
-
+
+ +
+
@@ -76,7 +81,7 @@ {{ $t("main.chargingPlan.goal") }}
-
+
+
+ +
+
+ +
+ +
+ +
- - diff --git a/assets/js/components/ChargingPlans/PlansRepeatingSettings.vue b/assets/js/components/ChargingPlans/PlansRepeatingSettings.vue index 4162830ac..c07921acc 100644 --- a/assets/js/components/ChargingPlans/PlansRepeatingSettings.vue +++ b/assets/js/components/ChargingPlans/PlansRepeatingSettings.vue @@ -8,6 +8,7 @@ :formIdPrefix="formIdPrefix" v-bind="plan" :rangePerSoc="rangePerSoc" + :showPrecondition="showPrecondition" @updated="updatePlan(index, $event)" @removed="removePlan(index)" /> @@ -48,6 +49,7 @@ export default defineComponent({ id: [Number, String], rangePerSoc: Number, plans: { type: Array as PropType, default: () => [] }, + showPrecondition: Boolean, }, emits: ["updated"], computed: { diff --git a/assets/js/components/ChargingPlans/PlansSettings.vue b/assets/js/components/ChargingPlans/PlansSettings.vue index 85f462b25..bc6199e1e 100644 --- a/assets/js/components/ChargingPlans/PlansSettings.vue +++ b/assets/js/components/ChargingPlans/PlansSettings.vue @@ -11,8 +11,9 @@ :soc-per-kwh="socPerKwh" :soc-based-planning="socBasedPlanning" :multiple-plans="multiplePlans" - @static-plan-updated="updateStaticPlan" - @static-plan-removed="removeStaticPlan" + :show-precondition="showPrecondition" + @static-plan-updated="(data) => updateStaticPlan({ index: 0, ...data })" + @static-plan-removed="() => removeStaticPlan(0)" @plan-preview="previewStaticPlan" />
@@ -29,6 +30,7 @@ :id="id" :rangePerSoc="rangePerSoc" :plans="repeatingPlans" + :show-precondition="showPrecondition" @updated="updateRepeatingPlans" />
@@ -36,32 +38,29 @@
-
+
{{ $t(`main.targetCharge.${noActivePlan ? "preview" : "currentPlan"}`) }} - {{ $t("main.targetCharge.preview") }} #1 - {{ $t("main.targetCharge.goalReached") }} - {{ nextPlanTitle }} - + + {{ selectedPreviewPlanTitle }} + + + + {{ $t("main.targetCharge.goalReached") }} + + + {{ nextPlanTitle }} +
- @@ -71,16 +70,16 @@ import "@h2d2/shopicons/es/regular/plus"; import Preview from "./Preview.vue"; import PlanStaticSettings from "./PlanStaticSettings.vue"; -import ChargingPlanStrategy from "./PlanStrategy.vue"; import RepeatingSettings from "./PlansRepeatingSettings.vue"; import Warnings from "./Warnings.vue"; import formatter from "@/mixins/formatter"; import collector from "@/mixins/collector"; import api from "@/api"; +import CustomSelect from "../Helper/CustomSelect.vue"; import deepEqual from "@/utils/deepEqual"; import convertRates from "@/utils/convertRates"; import { defineComponent, type PropType } from "vue"; -import type { Vehicle, Timeout, CURRENCY, Forecast } from "@/types/evcc"; +import type { Vehicle, PartialBy, Timeout, SelectOption, CURRENCY, Forecast } from "@/types/evcc"; import type { StaticPlan, RepeatingPlan, @@ -88,7 +87,6 @@ import type { StaticSocPlan, StaticEnergyPlan, PlanResponse, - PlanStrategy, } from "./types"; export default defineComponent({ @@ -96,9 +94,9 @@ export default defineComponent({ components: { ChargingPlanPreview: Preview, ChargingPlanStaticSettings: PlanStaticSettings, - ChargingPlanStrategy, ChargingPlansRepeatingSettings: RepeatingSettings, ChargingPlanWarnings: Warnings, + CustomSelect, }, mixins: [formatter, collector], props: { @@ -108,8 +106,6 @@ export default defineComponent({ effectiveLimitSoc: Number, effectivePlanTime: String, effectivePlanSoc: Number, - effectivePlanPrecondition: Number, - effectivePlanContinuous: Boolean, planEnergy: Number, limitEnergy: Number, socBasedPlanning: Boolean, @@ -124,20 +120,15 @@ export default defineComponent({ planOverrun: Number, forecast: Object as PropType, }, - emits: [ - "static-plan-removed", - "static-plan-updated", - "repeating-plans-updated", - "plan-strategy-updated", - ], + emits: ["static-plan-removed", "static-plan-updated", "repeating-plans-updated"], data() { return { staticPlanPreview: {} as StaticPlan, plan: {} as PlanWrapper, activeTab: "time", debounceTimer: null as Timeout, + selectedPreviewId: 1, nextPlanId: 0, - strategyOpen: false, }; }, computed: { @@ -147,6 +138,9 @@ export default defineComponent({ multiplePlans(): boolean { return this.repeatingPlans.length !== 0; }, + selectedPreviewPlanTitle(): string { + return this.previewPlanOptions[this.selectedPreviewId - 1]?.name || ""; + }, chargingPlanWarningsProps(): any { return this.collectProps(Warnings); }, @@ -160,12 +154,23 @@ export default defineComponent({ ? { duration, plan, power, rates, targetTime, currency, smartCostType } : null; }, - chargingPlanStrategyProps(): any { - return { - id: this.id, - precondition: this.effectivePlanPrecondition, - continuous: this.effectivePlanContinuous, - }; + previewPlanOptions(): SelectOption[] { + const name = (n: number) => `${this.$t("main.targetCharge.preview")} #${n}`; + + // static plan + const options = [{ value: 1, name: name(1) }] as SelectOption[]; + + // repeating plans + this.repeatingPlans.forEach((plan, index) => { + const number = index + 2; + options.push({ + value: number, + name: name(number), + disabled: !plan.weekdays.length, + }); + }); + + return options; }, alreadyReached(): boolean { return this.plan.duration === 0; @@ -173,7 +178,7 @@ export default defineComponent({ nextPlanTitle(): string { return `${this.$t("main.targetCharge.nextPlan")} #${this.nextPlanId}`; }, - showStrategy(): boolean { + showPrecondition(): boolean { // only show option if planner forecast has different values const slots = this.forecast?.planner || []; const values = new Set(slots.map(({ value }) => value)); @@ -186,12 +191,6 @@ export default defineComponent({ this.updatePlanDebounced(); } }, - effectivePlanPrecondition() { - this.updatePlanDebounced(); - }, - effectivePlanContinuous() { - this.updatePlanDebounced(); - }, staticPlan: { deep: true, handler(vNew: StaticPlan, vOld: StaticPlan) { @@ -204,6 +203,7 @@ export default defineComponent({ deep: true, handler(vNew: RepeatingPlan[], vOld: RepeatingPlan[]) { if (!deepEqual(vNew, vOld)) { + this.adjustPreviewId(); this.updatePlanDebounced(); } }, @@ -213,6 +213,10 @@ export default defineComponent({ this.updatePlanDebounced(); }, methods: { + selectPreviewPlan(id: number): void { + this.selectedPreviewId = id; + this.updatePlanDebounced(); + }, async updatePlanDebounced() { if (this.noActivePlan) { await this.updatePlanPreviewDebounced(); @@ -220,6 +224,11 @@ export default defineComponent({ await this.updateActivePlanDebounced(); } }, + adjustPreviewId(): void { + if (this.selectedPreviewId > this.previewPlanOptions.length) { + this.selectedPreviewId = this.previewPlanOptions.length; + } + }, async updateActivePlan(): Promise { try { const res = await this.apiFetchPlan(`loadpoints/${this.id}/plan`); @@ -231,15 +240,22 @@ export default defineComponent({ }, async fetchStaticPreviewSoc(plan: StaticSocPlan): Promise { const timeISO = plan.time.toISOString(); - const params: Record = {}; + const params = plan.precondition ? { precondition: plan.precondition } : undefined; return await this.apiFetchPlan( `loadpoints/${this.id}/plan/static/preview/soc/${plan.soc}/${timeISO}`, params ); }, + async fetchRepeatingPreview( + plan: PartialBy + ): Promise { + return await this.apiFetchPlan( + `loadpoints/${this.id}/plan/repeating/preview/${plan.soc}/${plan.weekdays}/${plan.time}/${encodeURIComponent(plan.tz)}` + ); + }, async fetchStaticPreviewEnergy(plan: StaticEnergyPlan): Promise { const timeISO = plan.time.toISOString(); - const params: Record = {}; + const params = plan.precondition ? { precondition: plan.precondition } : undefined; return await this.apiFetchPlan( `loadpoints/${this.id}/plan/static/preview/energy/${plan.energy}/${timeISO}`, params @@ -269,7 +285,8 @@ export default defineComponent({ try { let planRes: PlanResponse | undefined = undefined; - if (this.staticPlanPreview) { + + if (this.selectedPreviewId < 2 && this.staticPlanPreview) { // static plan let plan = this.staticPlanPreview; if (this.socBasedPlanning) { @@ -277,14 +294,33 @@ export default defineComponent({ planRes = await this.fetchStaticPreviewSoc({ soc: plan.soc, time: plan.time, + precondition: plan.precondition, }); } else { plan = plan as StaticEnergyPlan; planRes = await this.fetchStaticPreviewEnergy({ energy: plan.energy, time: plan.time, + precondition: plan.precondition, }); } + } else { + // repeating plan + const plan = this.repeatingPlans[this.selectedPreviewId - 2]; + if (!plan) { + return; + } + const { weekdays, soc, time, tz, precondition } = plan; + if (weekdays.length === 0) { + return; + } + planRes = await this.fetchRepeatingPreview({ + weekdays, + soc, + time, + tz, + precondition, + }); } this.plan = planRes?.data ?? ({} as PlanWrapper); } catch (e) { @@ -307,8 +343,8 @@ export default defineComponent({ clearTimeout(this.debounceTimer); this.debounceTimer = setTimeout(async () => await this.updateActivePlan(), 1000); }, - removeStaticPlan(): void { - this.$emit("static-plan-removed"); + removeStaticPlan(index: number): void { + this.$emit("static-plan-removed", index); }, updateStaticPlan(plan: StaticPlan): void { this.$emit("static-plan-updated", plan); @@ -320,9 +356,6 @@ export default defineComponent({ this.staticPlanPreview = plan; this.updatePlanPreviewDebounced(); }, - updatePlanStrategy(strategy: PlanStrategy): void { - this.$emit("plan-strategy-updated", strategy); - }, }, }); @@ -331,13 +364,13 @@ export default defineComponent({ h5 { position: relative; display: flex; - top: -33px; + top: -25px; margin-bottom: -0.5rem; padding: 0 0.5rem; justify-content: center; } h5 .inner { - padding: 0 1rem; + padding: 0 0.5rem; background-color: var(--evcc-box); font-weight: normal; color: var(--evcc-gray); diff --git a/assets/js/components/ChargingPlans/PreconditionSelect.vue b/assets/js/components/ChargingPlans/PreconditionSelect.vue new file mode 100644 index 000000000..b132e642f --- /dev/null +++ b/assets/js/components/ChargingPlans/PreconditionSelect.vue @@ -0,0 +1,134 @@ + + + + diff --git a/assets/js/components/ChargingPlans/types.d.ts b/assets/js/components/ChargingPlans/types.d.ts index 91031a05c..2a667897f 100644 --- a/assets/js/components/ChargingPlans/types.d.ts +++ b/assets/js/components/ChargingPlans/types.d.ts @@ -6,6 +6,7 @@ export interface RepeatingPlan { tz: string; // timezone like "Europe/Berlin" soc: number; active: boolean; + precondition: number; } export interface PlanWrapper { @@ -26,14 +27,11 @@ export type StaticPlan = StaticSocPlan | StaticEnergyPlan; export interface StaticSocPlan { soc: number; time: Date; + precondition: number; } export interface StaticEnergyPlan { energy: number; time: Date; -} - -export interface PlanStrategy { - continuous: boolean; precondition: number; } diff --git a/assets/js/components/Loadpoints/Loadpoint.stories.ts b/assets/js/components/Loadpoints/Loadpoint.stories.ts index 08238fad5..f953f1281 100644 --- a/assets/js/components/Loadpoints/Loadpoint.stories.ts +++ b/assets/js/components/Loadpoints/Loadpoint.stories.ts @@ -52,7 +52,6 @@ const baseState = { planEnergy: 0, planOverrun: 0, planPrecondition: 0, - planContinuous: false, planProjectedEnd: undefined, planProjectedStart: undefined, planTime: undefined, @@ -83,8 +82,6 @@ const baseState = { capacity: 8, features: ["Offline"], repeatingPlans: [], - planPrecondition: 0, - planContinuous: false, }, { name: "vehicle_4", @@ -93,8 +90,6 @@ const baseState = { capacity: 80, features: ["Offline"], repeatingPlans: [], - planPrecondition: 0, - planContinuous: false, }, ], smartCostType: SMART_COST_TYPE.PRICE_FORECAST, diff --git a/assets/js/components/Loadpoints/Loadpoint.vue b/assets/js/components/Loadpoints/Loadpoint.vue index 907136581..63197b9a9 100644 --- a/assets/js/components/Loadpoints/Loadpoint.vue +++ b/assets/js/components/Loadpoints/Loadpoint.vue @@ -178,11 +178,10 @@ export default defineComponent({ planProjectedEnd: String as PropType, planOverrun: { type: Number, default: 0 }, planEnergy: Number, + planPrecondition: Number, planTime: String as PropType, effectivePlanTime: String as PropType, effectivePlanSoc: Number, - effectivePlanPrecondition: Number, - effectivePlanContinuous: Boolean, vehicleProviderLoggedIn: Boolean, vehicleProviderLoginPath: String, vehicleProviderLogoutPath: String, diff --git a/assets/js/components/Loadpoints/Loadpoints.stories.ts b/assets/js/components/Loadpoints/Loadpoints.stories.ts index 1137b279c..d303d680b 100644 --- a/assets/js/components/Loadpoints/Loadpoints.stories.ts +++ b/assets/js/components/Loadpoints/Loadpoints.stories.ts @@ -48,8 +48,6 @@ const baseArgs = { capacity: 8, features: ["Offline"], repeatingPlans: [], - planPrecondition: 0, - planContinuous: false, }, { name: "vehicle_4", @@ -58,8 +56,6 @@ const baseArgs = { capacity: 80, features: ["Offline"], repeatingPlans: [], - planPrecondition: 0, - planContinuous: false, }, { name: "vehicle_5", @@ -68,8 +64,6 @@ const baseArgs = { capacity: 0.46, features: ["Offline"], repeatingPlans: [], - planPrecondition: 0, - planContinuous: false, }, ], smartCostType: SMART_COST_TYPE.PRICE_FORECAST, diff --git a/assets/js/components/Vehicles/Vehicle.stories.ts b/assets/js/components/Vehicles/Vehicle.stories.ts index d26fa0111..b1f0b54a0 100644 --- a/assets/js/components/Vehicles/Vehicle.stories.ts +++ b/assets/js/components/Vehicles/Vehicle.stories.ts @@ -10,8 +10,6 @@ const baseState = { features: [], name: "", repeatingPlans: [], - planPrecondition: 0, - planContinuous: false, }, enabled: false, connected: true, diff --git a/assets/js/components/Vehicles/Vehicle.vue b/assets/js/components/Vehicles/Vehicle.vue index acd160a69..d2bdfe30a 100644 --- a/assets/js/components/Vehicles/Vehicle.vue +++ b/assets/js/components/Vehicles/Vehicle.vue @@ -103,8 +103,6 @@ export default defineComponent({ effectiveLimitSoc: Number, effectivePlanSoc: Number, effectivePlanTime: String, - effectivePlanPrecondition: Number, - effectivePlanContinuous: Boolean, batteryBoostActive: Boolean, enabled: Boolean, heating: Boolean, @@ -122,6 +120,7 @@ export default defineComponent({ planProjectedEnd: String, planTime: String, planTimeUnreachable: Boolean, + planPrecondition: Number, planOverrun: Number, pvAction: String, pvRemainingInterpolated: Number, diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 1d3569526..03f5c85f2 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -246,8 +246,6 @@ export interface Loadpoint { effectivePlanId: number; effectivePlanSoc: number; effectivePlanTime: string | null; - effectivePlanPrecondition: number; - effectivePlanContinuous: boolean; effectivePriority: number; enableDelay: number; enableThreshold: number; @@ -266,7 +264,6 @@ export interface Loadpoint { planEnergy: number; planOverrun: number; planPrecondition: number; - planContinuous: boolean; planProjectedEnd: string | null; planProjectedStart: string | null; planTime: string | null; @@ -476,8 +473,6 @@ export interface Vehicle { limitSoc?: number; plan?: StaticPlan; repeatingPlans: RepeatingPlan[] | null; - planPrecondition: number; - planContinuous: boolean; title: string; features?: string[]; capacity?: number; diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go index 7b6598515..dc59e5d92 100644 --- a/core/keys/loadpoint.go +++ b/core/keys/loadpoint.go @@ -57,11 +57,7 @@ const ( EffectivePlanSoc = "effectivePlanSoc" // effective plan soc EffectiveMinCurrent = "effectiveMinCurrent" // effective min current EffectiveMaxCurrent = "effectiveMaxCurrent" // effective max current - - EffectiveLimitSoc = "effectiveLimitSoc" // effective limit soc - EffectivePlanStrategy = "effectivePlanStrategy" // effective plan strategy (deprecated, use individual fields) - EffectivePlanPrecondition = "effectivePlanPrecondition" // effective plan precondition duration - EffectivePlanContinuous = "effectivePlanContinuous" // effective plan continuous planning + EffectiveLimitSoc = "effectiveLimitSoc" // effective limit soc // measurements ChargePower = "chargePower" // charge power @@ -77,17 +73,15 @@ const ( ChargeRemainingEnergy = "chargeRemainingEnergy" // charge remaining energy // plan - Plan = "plan" // charge plan time slots PlanTime = "planTime" // charge plan finish time goal PlanEnergy = "planEnergy" // charge plan energy goal PlanSoc = "planSoc" // charge plan soc goal + PlanPrecondition = "planPrecondition" // charge plan precondition duration PlanActive = "planActive" // charge plan has determined current slot to be an active slot PlanProjectedStart = "planProjectedStart" // charge plan start time (earliest slot) PlanProjectedEnd = "planProjectedEnd" // charge plan ends (end of last slot) PlanOverrun = "planOverrun" // charge plan goal not reachable in time - PlanStrategy = "planStrategy" // charge plan strategy (precondition, continuous) - PlanPrecondition = "planPrecondition" // charge plan precondition duration - PlanContinuous = "planContinuous" // charge plan continuous planning + Plan = "plan" // charge plan time slots // repeating plans RepeatingPlans = "repeatingPlans" // key to access all repeating plans in db diff --git a/core/loadpoint.go b/core/loadpoint.go index 207befe21..2929db407 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -145,13 +145,13 @@ type Loadpoint struct { // charge planning planner *planner.Planner - planTime time.Time // time goal - planStrategy api.PlanStrategy // plan strategy (precondition, continuous) - planEnergy float64 // Plan charge energy in kWh (dumb vehicles) - planEnergyOffset float64 // already charged energy in kWh when plan was set - planSlotEnd time.Time // current plan slot end time - planActive bool // charge plan exists and has a currently active slot - planOverrunSent bool // notification has been sent already + planTime time.Time // time goal + planPrecondition time.Duration // precondition duration + planEnergy float64 // Plan charge energy in kWh (dumb vehicles) + planEnergyOffset float64 // already charged energy in kWh when plan was set + planSlotEnd time.Time // current plan slot end time + planActive bool // charge plan exists and has a currently active slot + planOverrunSent bool // notification has been sent already // cached state status api.ChargeStatus // Charger status @@ -370,14 +370,9 @@ func (lp *Loadpoint) restoreSettings() { t, err1 := lp.settings.Time(keys.PlanTime) v, err2 := lp.settings.Float(keys.PlanEnergy) + d, _ := lp.settings.Int(keys.PlanPrecondition) if err1 == nil && err2 == nil { - lp.setPlanEnergy(t, v) - } - - // load plan strategy (continuous mode and precondition duration) - var planStrategy api.PlanStrategy - if err := lp.settings.Json(keys.PlanStrategy, &planStrategy); err == nil { - lp.setPlanStrategy(planStrategy) + lp.setPlanEnergy(t, time.Duration(d)*time.Second, v) } } @@ -691,8 +686,7 @@ func (lp *Loadpoint) Prepare(site site.API, uiChan chan<- util.Param, pushChan c // restored settings lp.publish(keys.PlanTime, lp.planTime) lp.publish(keys.PlanEnergy, lp.planEnergy) - lp.publish(keys.PlanPrecondition, int64(lp.planStrategy.Precondition.Seconds())) - lp.publish(keys.PlanContinuous, lp.planStrategy.Continuous) + lp.publish(keys.PlanPrecondition, lp.planPrecondition) lp.publish(keys.LimitSoc, lp.limitSoc) lp.publish(keys.LimitEnergy, lp.limitEnergy) @@ -989,7 +983,7 @@ func (lp *Loadpoint) repeatingPlanning() bool { if !lp.socBasedPlanning() { return false } - _, _, id := lp.NextVehiclePlan() + _, _, _, id := lp.NextVehiclePlan() return id > 1 } diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go index 55ba3b84e..036cfe80f 100644 --- a/core/loadpoint/api.go +++ b/core/loadpoint/api.go @@ -108,8 +108,6 @@ type API interface { EffectiveMinPower() float64 // EffectiveMaxPower returns the max charging power taking active phases into account EffectiveMaxPower() float64 - // EffectivePlanStrategy returns the effective plan strategy - EffectivePlanStrategy() api.PlanStrategy // PublishEffectiveValues publishes effective values for currently attached vehicle PublishEffectiveValues() @@ -118,21 +116,19 @@ type API interface { // // GetPlanEnergy returns the charge plan energy - GetPlanEnergy() (time.Time, float64) + GetPlanEnergy() (time.Time, time.Duration, float64) // SetPlanEnergy sets the charge plan energy - SetPlanEnergy(time.Time, float64) error + SetPlanEnergy(time.Time, time.Duration, 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 - // GetPlanStrategy returns the plan strategy - GetPlanStrategy() api.PlanStrategy - // SetPlanStrategy sets the plan strategy - SetPlanStrategy(api.PlanStrategy) error + // GetPlanPreCondDuration returns the precondition duration + GetPlanPreCondDuration() time.Duration // SocBasedPlanning determines if the planner is soc based SocBasedPlanning() bool // GetPlan creates a charging plan - GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration, continuous bool) api.Rates + GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration) api.Rates // GetSocConfig returns the soc poll settings GetSocConfig() SocConfig diff --git a/core/loadpoint/config.go b/core/loadpoint/config.go index 86e326f46..3bc38d83d 100644 --- a/core/loadpoint/config.go +++ b/core/loadpoint/config.go @@ -27,11 +27,10 @@ type DynamicConfig struct { SmartFeedInPriorityLimit *float64 `json:"smartFeedInPriorityLimit"` PlanEnergy float64 `json:"planEnergy"` PlanTime time.Time `json:"planTime"` + PlanPrecondition int64 `json:"planPrecondition"` LimitEnergy float64 `json:"limitEnergy"` LimitSoc int `json:"limitSoc"` - PlanStrategy api.PlanStrategy `json:"planStrategy"` - Thresholds ThresholdsConfig `json:"thresholds"` Soc SocConfig `json:"soc"` } @@ -60,8 +59,7 @@ func (payload DynamicConfig) Apply(lp API) error { lp.SetSmartCostLimit(payload.SmartCostLimit) lp.SetSmartFeedInPriorityLimit(payload.SmartFeedInPriorityLimit) lp.SetThresholds(payload.Thresholds) - lp.SetPlanEnergy(payload.PlanTime, payload.PlanEnergy) - lp.SetPlanStrategy(payload.PlanStrategy) + lp.SetPlanEnergy(payload.PlanTime, time.Duration(payload.PlanPrecondition)*time.Second, payload.PlanEnergy) lp.SetLimitEnergy(payload.LimitEnergy) lp.SetLimitSoc(payload.LimitSoc) diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go index 61e93af70..c4436f062 100644 --- a/core/loadpoint/mock.go +++ b/core/loadpoint/mock.go @@ -111,20 +111,6 @@ func (mr *MockAPIMockRecorder) EffectivePlanId() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectivePlanId", reflect.TypeOf((*MockAPI)(nil).EffectivePlanId)) } -// EffectivePlanStrategy mocks base method. -func (m *MockAPI) EffectivePlanStrategy() api.PlanStrategy { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "EffectivePlanStrategy") - ret0, _ := ret[0].(api.PlanStrategy) - return ret0 -} - -// EffectivePlanStrategy indicates an expected call of EffectivePlanStrategy. -func (mr *MockAPIMockRecorder) EffectivePlanStrategy() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectivePlanStrategy", reflect.TypeOf((*MockAPI)(nil).EffectivePlanStrategy)) -} - // EffectivePlanTime mocks base method. func (m *MockAPI) EffectivePlanTime() time.Time { m.ctrl.T.Helper() @@ -448,26 +434,27 @@ func (mr *MockAPIMockRecorder) GetPhasesConfigured() *gomock.Call { } // GetPlan mocks base method. -func (m *MockAPI) GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration, continuous bool) api.Rates { +func (m *MockAPI) GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration) api.Rates { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetPlan", targetTime, requiredDuration, precondition, continuous) + ret := m.ctrl.Call(m, "GetPlan", targetTime, requiredDuration, precondition) ret0, _ := ret[0].(api.Rates) return ret0 } // GetPlan indicates an expected call of GetPlan. -func (mr *MockAPIMockRecorder) GetPlan(targetTime, requiredDuration, precondition, continuous any) *gomock.Call { +func (mr *MockAPIMockRecorder) GetPlan(targetTime, requiredDuration, precondition any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlan", reflect.TypeOf((*MockAPI)(nil).GetPlan), targetTime, requiredDuration, precondition, continuous) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlan", reflect.TypeOf((*MockAPI)(nil).GetPlan), targetTime, requiredDuration, precondition) } // GetPlanEnergy mocks base method. -func (m *MockAPI) GetPlanEnergy() (time.Time, float64) { +func (m *MockAPI) GetPlanEnergy() (time.Time, time.Duration, float64) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPlanEnergy") ret0, _ := ret[0].(time.Time) - ret1, _ := ret[1].(float64) - return ret0, ret1 + ret1, _ := ret[1].(time.Duration) + ret2, _ := ret[2].(float64) + return ret0, ret1, ret2 } // GetPlanEnergy indicates an expected call of GetPlanEnergy. @@ -491,6 +478,20 @@ func (mr *MockAPIMockRecorder) GetPlanGoal() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanGoal", reflect.TypeOf((*MockAPI)(nil).GetPlanGoal)) } +// GetPlanPreCondDuration mocks base method. +func (m *MockAPI) GetPlanPreCondDuration() time.Duration { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPlanPreCondDuration") + ret0, _ := ret[0].(time.Duration) + return ret0 +} + +// GetPlanPreCondDuration indicates an expected call of GetPlanPreCondDuration. +func (mr *MockAPIMockRecorder) GetPlanPreCondDuration() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanPreCondDuration", reflect.TypeOf((*MockAPI)(nil).GetPlanPreCondDuration)) +} + // GetPlanRequiredDuration mocks base method. func (m *MockAPI) GetPlanRequiredDuration(goal, maxPower float64) time.Duration { m.ctrl.T.Helper() @@ -505,20 +506,6 @@ func (mr *MockAPIMockRecorder) GetPlanRequiredDuration(goal, maxPower any) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanRequiredDuration", reflect.TypeOf((*MockAPI)(nil).GetPlanRequiredDuration), goal, maxPower) } -// GetPlanStrategy mocks base method. -func (m *MockAPI) GetPlanStrategy() api.PlanStrategy { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetPlanStrategy") - ret0, _ := ret[0].(api.PlanStrategy) - return ret0 -} - -// GetPlanStrategy indicates an expected call of GetPlanStrategy. -func (mr *MockAPIMockRecorder) GetPlanStrategy() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanStrategy", reflect.TypeOf((*MockAPI)(nil).GetPlanStrategy)) -} - // GetPriority mocks base method. func (m *MockAPI) GetPriority() int { m.ctrl.T.Helper() @@ -914,31 +901,17 @@ func (mr *MockAPIMockRecorder) SetPhasesConfigured(arg0 any) *gomock.Call { } // SetPlanEnergy mocks base method. -func (m *MockAPI) SetPlanEnergy(arg0 time.Time, arg1 float64) error { +func (m *MockAPI) SetPlanEnergy(arg0 time.Time, arg1 time.Duration, arg2 float64) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetPlanEnergy", arg0, arg1) + ret := m.ctrl.Call(m, "SetPlanEnergy", arg0, arg1, arg2) ret0, _ := ret[0].(error) return ret0 } // SetPlanEnergy indicates an expected call of SetPlanEnergy. -func (mr *MockAPIMockRecorder) SetPlanEnergy(arg0, arg1 any) *gomock.Call { +func (mr *MockAPIMockRecorder) SetPlanEnergy(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanEnergy", reflect.TypeOf((*MockAPI)(nil).SetPlanEnergy), arg0, arg1) -} - -// SetPlanStrategy mocks base method. -func (m *MockAPI) SetPlanStrategy(arg0 api.PlanStrategy) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetPlanStrategy", arg0) - ret0, _ := ret[0].(error) - return ret0 -} - -// SetPlanStrategy indicates an expected call of SetPlanStrategy. -func (mr *MockAPIMockRecorder) SetPlanStrategy(arg0 any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanStrategy", reflect.TypeOf((*MockAPI)(nil).SetPlanStrategy), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanEnergy", reflect.TypeOf((*MockAPI)(nil).SetPlanEnergy), arg0, arg1, arg2) } // SetPriority mocks base method. diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index 4eb9e8eac..b9d4e5273 100644 --- a/core/loadpoint_api.go +++ b/core/loadpoint_api.go @@ -343,19 +343,19 @@ func (lp *Loadpoint) SetLimitEnergy(energy float64) { } // GetPlanEnergy returns plan target energy -func (lp *Loadpoint) GetPlanEnergy() (time.Time, float64) { +func (lp *Loadpoint) GetPlanEnergy() (time.Time, time.Duration, float64) { lp.RLock() defer lp.RUnlock() return lp.getPlanEnergy() } // getPlanEnergy returns plan target energy -func (lp *Loadpoint) getPlanEnergy() (time.Time, float64) { - return lp.planTime, lp.planEnergy +func (lp *Loadpoint) getPlanEnergy() (time.Time, time.Duration, float64) { + return lp.planTime, lp.planPrecondition, lp.planEnergy } // setPlanEnergy sets plan target energy (no mutex) -func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, energy float64) { +func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, precondition time.Duration, energy float64) { lp.planEnergy = energy lp.publish(keys.PlanEnergy, energy) lp.settings.SetFloat(keys.PlanEnergy, energy) @@ -363,12 +363,16 @@ func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, energy float64) { // remove plan if energy == 0 { finishAt = time.Time{} + precondition = 0 } lp.planTime = finishAt + lp.planPrecondition = precondition lp.planEnergyOffset = lp.getChargedEnergy() / 1e3 lp.publish(keys.PlanTime, finishAt) + lp.publish(keys.PlanPrecondition, precondition) lp.settings.SetTime(keys.PlanTime, finishAt) + lp.settings.SetInt(keys.PlanPrecondition, int64(precondition.Seconds())) if finishAt.IsZero() { lp.setPlanActive(false) @@ -376,7 +380,7 @@ func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, energy float64) { } // SetPlanEnergy sets plan target energy -func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, energy float64) error { +func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, precondition time.Duration, energy float64) error { lp.Lock() defer lp.Unlock() @@ -387,51 +391,14 @@ func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, energy float64) error { lp.log.DEBUG.Printf("set plan energy: %.3gkWh @ %v", energy, finishAt.Round(time.Second).Local()) // apply immediately - if lp.planEnergy != energy || !lp.planTime.Equal(finishAt) { - lp.setPlanEnergy(finishAt, energy) + if lp.planEnergy != energy || lp.planPrecondition != precondition || !lp.planTime.Equal(finishAt) { + lp.setPlanEnergy(finishAt, precondition, energy) lp.requestUpdate() } return nil } -// setPlanStrategy sets the plan strategy (no mutex) -func (lp *Loadpoint) setPlanStrategy(strategy api.PlanStrategy) error { - if err := lp.settings.SetJson(keys.PlanStrategy, strategy); err != nil { - return err - } - - lp.planStrategy = strategy - lp.publish(keys.PlanPrecondition, int64(strategy.Precondition.Seconds())) - lp.publish(keys.PlanContinuous, strategy.Continuous) - - lp.requestUpdate() - - return nil -} - -// SetPlanStrategy sets the plan strategy -func (lp *Loadpoint) SetPlanStrategy(strategy api.PlanStrategy) error { - lp.Lock() - defer lp.Unlock() - - lp.log.DEBUG.Printf("set plan strategy: continuous=%v, precondition=%v", strategy.Continuous, strategy.Precondition) - - return lp.setPlanStrategy(strategy) -} - -// getPlanStrategy returns the plan strategy (no mutex) -func (lp *Loadpoint) getPlanStrategy() api.PlanStrategy { - return lp.planStrategy -} - -// GetPlanStrategy returns the plan strategy -func (lp *Loadpoint) GetPlanStrategy() api.PlanStrategy { - lp.RLock() - defer lp.RUnlock() - return lp.getPlanStrategy() -} - // GetSoc returns the PV mode threshold settings func (lp *Loadpoint) GetSocConfig() loadpoint.SocConfig { lp.RLock() diff --git a/core/loadpoint_effective.go b/core/loadpoint_effective.go index 358f1d038..010454b84 100644 --- a/core/loadpoint_effective.go +++ b/core/loadpoint_effective.go @@ -12,7 +12,6 @@ import ( // PublishEffectiveValues publishes all effective values func (lp *Loadpoint) PublishEffectiveValues() { - strategy := lp.EffectivePlanStrategy() lp.publish(keys.EffectivePriority, lp.EffectivePriority()) lp.publish(keys.EffectivePlanId, lp.EffectivePlanId()) lp.publish(keys.EffectivePlanTime, lp.EffectivePlanTime()) @@ -20,8 +19,6 @@ func (lp *Loadpoint) PublishEffectiveValues() { lp.publish(keys.EffectiveMinCurrent, lp.effectiveMinCurrent()) lp.publish(keys.EffectiveMaxCurrent, lp.effectiveMaxCurrent()) lp.publish(keys.EffectiveLimitSoc, lp.EffectiveLimitSoc()) - lp.publish(keys.EffectivePlanPrecondition, int64(strategy.Precondition.Seconds())) - lp.publish(keys.EffectivePlanContinuous, strategy.Continuous) } // EffectivePriority returns the effective priority @@ -35,10 +32,11 @@ func (lp *Loadpoint) EffectivePriority() int { } type plan struct { - Id int - Start time.Time // last possible start time - End time.Time // user-selected finish time - Soc int + Id int + Start time.Time // last possible start time + End time.Time // user-selected finish time + Precondition time.Duration + Soc int } func (lp *Loadpoint) nextActivePlan(maxPower float64, plans []plan) *plan { @@ -62,20 +60,20 @@ func (lp *Loadpoint) nextActivePlan(maxPower float64, plans []plan) *plan { } // NextVehiclePlan returns the next vehicle plan time, soc and id -func (lp *Loadpoint) NextVehiclePlan() (time.Time, int, int) { +func (lp *Loadpoint) NextVehiclePlan() (time.Time, time.Duration, int, int) { lp.RLock() defer lp.RUnlock() return lp.nextVehiclePlan() } -// nextVehiclePlan returns the next vehicle plan time, soc, id -func (lp *Loadpoint) nextVehiclePlan() (time.Time, int, int) { +// nextVehiclePlan returns the next vehicle plan time, precondition duration, soc and id +func (lp *Loadpoint) nextVehiclePlan() (time.Time, time.Duration, int, int) { if v := lp.GetVehicle(); v != nil { var plans []plan // static plan - if planTime, soc := vehicle.Settings(lp.log, v).GetPlanSoc(); soc != 0 { - plans = append(plans, plan{Id: 1, Soc: soc, End: planTime}) + if planTime, precondition, soc := vehicle.Settings(lp.log, v).GetPlanSoc(); soc != 0 { + plans = append(plans, plan{Id: 1, Precondition: precondition, Soc: soc, End: planTime}) } // repeating plans @@ -90,27 +88,28 @@ func (lp *Loadpoint) nextVehiclePlan() (time.Time, int, int) { continue } - plans = append(plans, plan{Id: index + 2, Soc: rp.Soc, End: planTime}) + precondition := time.Duration(rp.Precondition) * time.Second + plans = append(plans, plan{Id: index + 2, Precondition: precondition, Soc: rp.Soc, End: planTime}) } // calculate earliest required plan start if plan := lp.nextActivePlan(lp.effectiveMaxPower(), plans); plan != nil { - return plan.End, plan.Soc, plan.Id + return plan.End, plan.Precondition, plan.Soc, plan.Id } } - return time.Time{}, 0, 0 + return time.Time{}, 0, 0, 0 } // EffectivePlanSoc returns the soc target for the current plan func (lp *Loadpoint) EffectivePlanSoc() int { - _, soc, _ := lp.NextVehiclePlan() + _, _, soc, _ := lp.NextVehiclePlan() return soc } // EffectivePlanId returns the id for the current plan func (lp *Loadpoint) EffectivePlanId() int { if lp.socBasedPlanning() { - _, _, id := lp.NextVehiclePlan() + _, _, _, id := lp.NextVehiclePlan() return id } if lp.planEnergy > 0 { @@ -123,11 +122,11 @@ func (lp *Loadpoint) EffectivePlanId() int { // EffectivePlanTime returns the effective plan time func (lp *Loadpoint) EffectivePlanTime() time.Time { if lp.socBasedPlanning() { - ts, _, _ := lp.NextVehiclePlan() + ts, _, _, _ := lp.NextVehiclePlan() return ts } - ts, _ := lp.GetPlanEnergy() + ts, _, _ := lp.GetPlanEnergy() return ts } @@ -240,20 +239,3 @@ func (lp *Loadpoint) effectiveMaxPower() float64 { } return Voltage * lp.effectiveMaxCurrent() * float64(lp.maxActivePhases()) } - -// EffectivePlanStrategy returns the effective plan strategy -func (lp *Loadpoint) EffectivePlanStrategy() api.PlanStrategy { - lp.RLock() - defer lp.RUnlock() - return lp.getEffectivePlanStrategy() -} - -func (lp *Loadpoint) getEffectivePlanStrategy() api.PlanStrategy { - if v := lp.GetVehicle(); v != nil { - if lp.socBasedPlanning() { - return vehicle.Settings(lp.log, v).GetPlanStrategy() - } - } - - return lp.getPlanStrategy() -} diff --git a/core/loadpoint_plan.go b/core/loadpoint_plan.go index 7e2839966..608ff85b9 100644 --- a/core/loadpoint_plan.go +++ b/core/loadpoint_plan.go @@ -30,9 +30,9 @@ func (lp *Loadpoint) finishPlan() { if lp.repeatingPlanning() { return // noting to do } else if !lp.socBasedPlanning() { - lp.setPlanEnergy(time.Time{}, 0) + lp.setPlanEnergy(time.Time{}, 0, 0) } else if v := lp.GetVehicle(); v != nil { - vehicle.Settings(lp.log, v).SetPlanSoc(time.Time{}, 0) + vehicle.Settings(lp.log, v).SetPlanSoc(time.Time{}, 0, 0) } } @@ -67,25 +67,36 @@ func (lp *Loadpoint) GetPlanGoal() (float64, bool) { defer lp.RUnlock() if lp.socBasedPlanning() { - _, soc, _ := lp.nextVehiclePlan() + _, _, soc, _ := lp.nextVehiclePlan() return float64(soc), true } - _, limit := lp.getPlanEnergy() + _, _, limit := lp.getPlanEnergy() return limit, false } +// GetPlanPreCondDuration returns the plan precondition duration +func (lp *Loadpoint) GetPlanPreCondDuration() time.Duration { + lp.RLock() + defer lp.RUnlock() + + if lp.socBasedPlanning() { + _, precondition, _, _ := lp.nextVehiclePlan() + return precondition + } + + _, precondition, _ := lp.getPlanEnergy() + return precondition +} + // GetPlan creates a charging plan for given time and duration // The plan is sorted by time -func (lp *Loadpoint) GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration, continuous bool) api.Rates { +func (lp *Loadpoint) GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration) api.Rates { if lp.planner == nil || targetTime.IsZero() { return nil } - lp.log.TRACE.Printf("plan: creating plan with continuous=%v, precondition=%v, duration=%v, target=%v", - continuous, precondition, requiredDuration.Round(time.Second), targetTime.Round(time.Second).Local()) - - return lp.planner.Plan(requiredDuration, precondition, targetTime, continuous) + return lp.planner.Plan(requiredDuration, precondition, targetTime) } // plannerActive checks if the charging plan has a currently active slot @@ -135,9 +146,7 @@ func (lp *Loadpoint) plannerActive() (active bool) { return false } - strategy := lp.getEffectivePlanStrategy() - - plan = lp.GetPlan(planTime, requiredDuration, strategy.Precondition, strategy.Continuous) + plan = lp.GetPlan(planTime, requiredDuration, lp.GetPlanPreCondDuration()) if plan == nil { return false } diff --git a/core/planner/helper.go b/core/planner/helper.go index 1b12f5818..aebf48061 100644 --- a/core/planner/helper.go +++ b/core/planner/helper.go @@ -4,7 +4,6 @@ import ( "time" "github.com/evcc-io/evcc/api" - "github.com/samber/lo" ) // Start returns the earliest slot's start time @@ -47,9 +46,6 @@ func AverageCost(plan api.Rates) float64 { duration += slotDuration cost += float64(slotDuration) * slot.Value } - if duration == 0 { - return 0 - } return cost / float64(duration) } @@ -84,78 +80,3 @@ func IsFirst(r api.Rate, plan api.Rates) bool { } return true } - -// clampRates filters rates to the given time window and adjusts boundary slots -func clampRates(rates api.Rates, start, end time.Time) api.Rates { - res := make(api.Rates, 0, len(rates)+2) - - for _, r := range rates { - // slot before continuous plan - if !r.End.After(start) { - continue - } - - // slot after continuous plan - if !r.Start.Before(end) { - continue - } - - // calculate adjusted bounds - adjustedStart := r.Start - if adjustedStart.Before(start) { - adjustedStart = start - } - - adjustedEnd := r.End - if adjustedEnd.After(end) { - adjustedEnd = end - } - - // skip if adjustment would create invalid slot - if !adjustedEnd.After(adjustedStart) { - continue - } - - slot := r - slot.Start = adjustedStart - slot.End = adjustedEnd - res = append(res, slot) - } - - return res -} - -// findContinuousWindow finds the cheapest continuous window of slots for the given duration. -// - rates are filtered to [now, targetTime] window by caller -// Returns the selected rates. -func findContinuousWindow(rates api.Rates, effectiveDuration time.Duration, targetTime time.Time) api.Rates { - var bestCost *float64 - var bestIndex *int - - for i := range rates { - windowEnd := rates[i].Start.Add(effectiveDuration) - if windowEnd.After(targetTime) { - break - } - - cost := lo.SumBy(clampRates(rates[i:], rates[i].Start, windowEnd), func(r api.Rate) float64 { - return float64(r.End.Sub(r.Start)) * r.Value - }) - - // Prefer later start if equal cost - if bestCost == nil || cost <= *bestCost { - bestCost = &cost - bestIndex = &i - } - } - - // No valid window found - if bestIndex == nil { - return nil - } - - // Build the best window only once - windowEnd := rates[*bestIndex].Start.Add(effectiveDuration) - - return clampRates(rates[*bestIndex:], rates[*bestIndex].Start, windowEnd) -} diff --git a/core/planner/helper_test.go b/core/planner/helper_test.go index 92d656225..2206e7a17 100644 --- a/core/planner/helper_test.go +++ b/core/planner/helper_test.go @@ -47,49 +47,3 @@ func TestIsFirst(t *testing.T) { // ensure single slot is always first require.True(t, IsFirst(first, []api.Rate{first})) } - -func TestDuration(t *testing.T) { - now := time.Now() - plan := api.Rates{ - {Start: now, End: now.Add(time.Hour)}, - {Start: now.Add(time.Hour), End: now.Add(time.Hour)}, // zero - without impact - {Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour)}, - } - require.Equal(t, 2*time.Hour, Duration(plan)) - require.Equal(t, time.Duration(0), Duration(api.Rates{})) -} - -func TestAverageCost(t *testing.T) { - now := time.Now() - plan := api.Rates{ - {Start: now, End: now.Add(30 * time.Minute), Value: 10.0}, // 0.5h * 10 = 5 - {Start: now, End: now, Value: 999.0}, // zero - ignored - {Start: now.Add(30 * time.Minute), End: now.Add(2 * time.Hour), Value: 20.0}, // 1.5h * 20 = 30 - } - require.Equal(t, 17.5, AverageCost(plan)) // (5 + 30) / 2h = 17.5 - require.Equal(t, 0.0, AverageCost(api.Rates{})) - require.Equal(t, 0.0, AverageCost(api.Rates{{Start: now, End: now, Value: 10}})) -} - -func TestStartEnd(t *testing.T) { - now := time.Now() - plan := api.Rates{ - {Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour)}, - {Start: now, End: now.Add(time.Hour)}, - } - require.Equal(t, now, Start(plan)) - require.Equal(t, now.Add(3*time.Hour), End(plan)) - require.True(t, Start(api.Rates{}).IsZero()) - require.True(t, End(api.Rates{}).IsZero()) -} - -func TestSlotAt(t *testing.T) { - now := time.Now() - plan := api.Rates{ - {Start: now, End: now.Add(time.Hour), Value: 1}, - {Start: now.Add(time.Hour), End: now.Add(2 * time.Hour), Value: 2}, - } - require.Equal(t, 1.0, SlotAt(now.Add(30*time.Minute), plan).Value) - require.Equal(t, 2.0, SlotAt(now.Add(90*time.Minute), plan).Value) - require.True(t, SlotAt(now.Add(3*time.Hour), plan).IsZero()) -} diff --git a/core/planner/planner.go b/core/planner/planner.go index d27a2f88b..626898162 100644 --- a/core/planner/planner.go +++ b/core/planner/planner.go @@ -32,13 +32,27 @@ func New(log *util.Logger, tariff api.Tariff, opt ...func(t *Planner)) *Planner } // plan creates a lowest-cost plan or required duration. -// It MUST already be established that: +// It MUST already established that // - rates are sorted in ascending order by cost and descending order by start time (prefer late slots) -// - rates are filtered to [now, targetTime] window by caller +// - target time and required duration are before end of rates func (t *Planner) plan(rates api.Rates, requiredDuration time.Duration, targetTime time.Time) api.Rates { var plan api.Rates - for _, slot := range rates { + for _, source := range rates { + // slot not relevant + if !(source.End.After(t.clock.Now()) && source.Start.Before(targetTime)) { + continue + } + + // adjust slot start and end + slot := source + if slot.Start.Before(t.clock.Now()) { + slot.Start = t.clock.Now() + } + if slot.End.After(targetTime) { + slot.End = targetTime + } + slotDuration := slot.End.Sub(slot.Start) requiredDuration -= slotDuration @@ -51,6 +65,10 @@ func (t *Planner) plan(rates api.Rates, requiredDuration time.Duration, targetTi slot.End = slot.End.Add(requiredDuration) } requiredDuration = 0 + + if slot.End.Before(slot.Start) { + panic("slot end before start") + } } plan = append(plan, slot) @@ -64,9 +82,34 @@ func (t *Planner) plan(rates api.Rates, requiredDuration time.Duration, targetTi return plan } -// continuousPlan creates a continuous emergency charging plan -func continuousPlan(rates api.Rates, start, end time.Time) api.Rates { - res := clampRates(rates, start, end) +// 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.After(start) { + continue + } + + // slot after continuous plan + if !r.Start.Before(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 { return []api.Rate{{ @@ -93,16 +136,14 @@ func continuousPlan(rates api.Rates, start, end time.Time) api.Rates { return res } -func (t *Planner) Plan(requiredDuration, precondition time.Duration, targetTime time.Time, continuous bool) api.Rates { +func (t *Planner) Plan(requiredDuration, precondition time.Duration, targetTime time.Time) api.Rates { if t == nil || requiredDuration <= 0 { return nil } - now := t.clock.Now().Truncate(time.Second) - latestStart := targetTime.Add(-requiredDuration) - if latestStart.Before(now) { - latestStart = now + if latestStart.Before(t.clock.Now()) { + latestStart = t.clock.Now() targetTime = latestStart.Add(requiredDuration) } @@ -128,7 +169,15 @@ func (t *Planner) Plan(requiredDuration, precondition time.Duration, targetTime // consume remaining time if t.clock.Until(targetTime) <= requiredDuration { - return continuousPlan(rates, latestStart, targetTime) + return t.continuousPlan(rates, latestStart, targetTime) + } + + // cut off all rates after target time + for i := 1; i < len(rates); i++ { + if !rates[i].Start.Before(targetTime) { + rates = rates[:i] + break + } } // rates are by default sorted by date, oldest to newest @@ -143,7 +192,7 @@ func (t *Planner) Plan(requiredDuration, precondition time.Duration, targetTime } // need to use some of the available slots - t.log.DEBUG.Printf("planner: target time beyond available slots- reducing plan horizon from %v to %v", + t.log.DEBUG.Printf("target time beyond available slots- reducing plan horizon from %v to %v", requiredDuration.Round(time.Second), durationAfterRates.Round(time.Second)) targetTime = last @@ -151,51 +200,22 @@ func (t *Planner) Plan(requiredDuration, precondition time.Duration, targetTime precondition = max(precondition-durationAfterRates, 0) } - rates = clampRates(rates, now, targetTime) - // don't precondition longer than charging duration precondition = min(precondition, requiredDuration) // reduce target time by precondition duration targetTime = targetTime.Add(-precondition) + requiredDuration = max(requiredDuration-precondition, 0) // separate precond rates, to be appended to plan afterwards var precond api.Rates if precondition > 0 { rates, precond = splitPreconditionSlots(rates, targetTime) - - // reduce required duration by precondition, skip planning if required - requiredDuration = max(requiredDuration-precondition, 0) - if requiredDuration == 0 { - return precond - } } // create plan unless only precond slots remaining var plan api.Rates - if continuous { - // check if available tariff slots span is sufficient for sliding window algorithm - // verify that actual tariff data covers enough duration (may have gaps or start late) - if len(rates) > 0 { - start := rates[0].Start - if start.Before(now) { - start = now - } - - end := rates[len(rates)-1].End - if end.After(targetTime) { - end = targetTime - } - - // available window too small for sliding window - charge continuously from now to target - if end.Sub(start) < requiredDuration { - return continuousPlan(append(rates, precond...), now, targetTime.Add(precondition)) - } - } - - // find cheapest continuous window - plan = findContinuousWindow(rates, requiredDuration, targetTime) - } else { + if requiredDuration > 0 { // sort rates by price and time slices.SortStableFunc(rates, sortByCost) diff --git a/core/planner/planner_continuous_test.go b/core/planner/planner_continuous_test.go deleted file mode 100644 index ff383ea87..000000000 --- a/core/planner/planner_continuous_test.go +++ /dev/null @@ -1,717 +0,0 @@ -package planner - -import ( - "testing" - "time" - - "github.com/benbjohnson/clock" - "github.com/evcc-io/evcc/api" - "github.com/evcc-io/evcc/tariff" - "github.com/evcc-io/evcc/util" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" -) - -func TestContinuous_CheapestContiguousSlots(t *testing.T) { - now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC) - c := clock.NewMock() - c.Set(now) - ctrl := gomock.NewController(t) - - rates := api.Rates{ - {Start: now, End: now.Add(1 * time.Hour), Value: 0.09}, - {Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), Value: 0.20}, - {Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.10}, - {Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.11}, - {Start: now.Add(4 * time.Hour), End: now.Add(5 * time.Hour), Value: 0.11}, - {Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.25}, - } - - trf := api.NewMockTariff(ctrl) - trf.EXPECT().Rates().AnyTimes().Return(rates, nil) - - p := &Planner{ - log: util.NewLogger("foo"), - clock: c, - tariff: trf, - } - - plan := p.Plan(2*time.Hour, 0, now.Add(6*time.Hour), true) - - require.Len(t, plan, 2) - assert.Equal(t, rates[2].Start, plan[0].Start) - assert.Equal(t, rates[3].End, plan[len(plan)-1].End) - assert.Equal(t, rates[2].Value, plan[0].Value) - assert.Equal(t, rates[3].Value, plan[1].Value) -} - -func TestContinuous_WindowWithPastRates(t *testing.T) { - now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC) - c := clock.NewMock() - c.Set(now) - - ctrl := gomock.NewController(t) - - rates := api.Rates{ - {Start: now.Add(-3 * time.Hour), End: now.Add(-2 * time.Hour), Value: 0.05}, - {Start: now.Add(-2 * time.Hour), End: now.Add(-1 * time.Hour), Value: 0.08}, - {Start: now.Add(-1 * time.Hour), End: now, Value: 0.07}, - {Start: now, End: now.Add(1 * time.Hour), Value: 0.20}, - {Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), Value: 0.09}, - {Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.10}, - {Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.15}, - {Start: now.Add(4 * time.Hour), End: now.Add(5 * time.Hour), Value: 0.11}, - {Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.25}, - } - - trf := api.NewMockTariff(ctrl) - trf.EXPECT().Rates().AnyTimes().Return(rates, nil) - - p := &Planner{ - log: util.NewLogger("foo"), - clock: c, - tariff: trf, - } - - targetTime := now.Add(6 * time.Hour) - requiredDuration := 2 * time.Hour - - plan := p.Plan(requiredDuration, 0, targetTime, true) - - require.NotEmpty(t, plan) - require.Len(t, plan, 2) - assert.False(t, plan[0].Start.Before(now)) - assert.Equal(t, now.Add(1*time.Hour), plan[0].Start) - assert.Equal(t, now.Add(3*time.Hour), plan[len(plan)-1].End) - assert.Equal(t, 0.09, plan[0].Value) - assert.Equal(t, 0.10, plan[1].Value) -} - -func TestContinuous_WindowAllRatesInPast(t *testing.T) { - now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC) - c := clock.NewMock() - c.Set(now) - - ctrl := gomock.NewController(t) - - rates := api.Rates{ - {Start: now.Add(-6 * time.Hour), End: now.Add(-5 * time.Hour), Value: 0.05}, - {Start: now.Add(-5 * time.Hour), End: now.Add(-4 * time.Hour), Value: 0.08}, - {Start: now.Add(-4 * time.Hour), End: now.Add(-3 * time.Hour), Value: 0.07}, - {Start: now.Add(-3 * time.Hour), End: now.Add(-2 * time.Hour), Value: 0.09}, - {Start: now.Add(-2 * time.Hour), End: now.Add(-1 * time.Hour), Value: 0.10}, - {Start: now.Add(-1 * time.Hour), End: now, Value: 0.11}, - } - - trf := api.NewMockTariff(ctrl) - trf.EXPECT().Rates().AnyTimes().Return(rates, nil) - - p := &Planner{ - log: util.NewLogger("foo"), - clock: c, - tariff: trf, - } - - targetTime := now.Add(3 * time.Hour) - requiredDuration := 2 * time.Hour - - plan := p.Plan(requiredDuration, 0, targetTime, true) // continuous mode - - // When all rates are in the past and target is in future, expect nil plan - assert.Empty(t, plan, "plan should be nil when all rates are in the past") -} - -// TestContinuous_WindowRatesSpanningPastAndFuture tests continuous mode with rates -// spanning from past to future, where the optimal window would start in the past -func TestContinuous_WindowRatesSpanningPastAndFuture(t *testing.T) { - now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC) - c := clock.NewMock() - c.Set(now) - - ctrl := gomock.NewController(t) - - // Rates spanning from 3h before now to 6h after now - // The cheapest window would be -3h to -1h, but that's in the past - rates := api.Rates{ - {Start: now.Add(-3 * time.Hour), End: now.Add(-2 * time.Hour), Value: 0.05}, // cheapest, but past - {Start: now.Add(-2 * time.Hour), End: now.Add(-1 * time.Hour), Value: 0.06}, // cheap, but past - {Start: now.Add(-1 * time.Hour), End: now, Value: 0.12}, // partially past - {Start: now, End: now.Add(1 * time.Hour), Value: 0.15}, - {Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), Value: 0.08}, // cheapest future - {Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.09}, // second cheapest future - {Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.18}, - {Start: now.Add(4 * time.Hour), End: now.Add(5 * time.Hour), Value: 0.14}, - {Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.20}, - } - - trf := api.NewMockTariff(ctrl) - trf.EXPECT().Rates().AnyTimes().Return(rates, nil) - - p := &Planner{ - log: util.NewLogger("foo"), - clock: c, - tariff: trf, - } - - targetTime := now.Add(6 * time.Hour) - requiredDuration := 2 * time.Hour - - plan := p.Plan(requiredDuration, 0, targetTime, true) // continuous mode - - require.NotEmpty(t, plan) - require.Len(t, plan, 2) - - // Critical: plan must start at or after now, even if cheaper rates existed in the past - assert.False(t, plan[0].Start.Before(now), "plan must not start in the past") - - // Should find cheapest 2-hour window starting from now or later - // Expected: 1h-3h window (two slots with prices 0.08 and 0.09) - assert.Equal(t, now.Add(1*time.Hour), plan[0].Start, "start should be at cheapest future window") - assert.Equal(t, now.Add(3*time.Hour), plan[len(plan)-1].End, "end should match 2-hour window") - assert.Equal(t, 0.08, plan[0].Value, "first slot should have actual price") - assert.Equal(t, 0.09, plan[1].Value, "second slot should have actual price") -} - -// TestContinuous_WindowRatesStartInFuture tests continuous mode when tariff data -// starts in the future, but target time is within the tariff data range -func TestContinuous_WindowRatesStartInFuture(t *testing.T) { - now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC) - c := clock.NewMock() - c.Set(now) - - ctrl := gomock.NewController(t) - - rates := api.Rates{ - {Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), Value: 0.20}, - {Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.08}, - {Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.09}, - {Start: now.Add(4 * time.Hour), End: now.Add(5 * time.Hour), Value: 0.15}, - {Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.18}, - } - - trf := api.NewMockTariff(ctrl) - trf.EXPECT().Rates().AnyTimes().Return(rates, nil) - - p := &Planner{ - log: util.NewLogger("foo"), - clock: c, - tariff: trf, - } - - targetTime := now.Add(5 * time.Hour) - requiredDuration := 2 * time.Hour - - plan := p.Plan(requiredDuration, 0, targetTime, true) // continuous mode - - require.NotEmpty(t, plan) - require.Len(t, plan, 2) - - // Plan must not start in the past - assert.False(t, plan[0].Start.Before(now), "plan must not start in the past") - - // Should find cheapest 2-hour window within available rates - // Expected: 2h-4h window (two slots with prices 0.08 and 0.09) - assert.Equal(t, now.Add(2*time.Hour), plan[0].Start, "start should be at cheapest window in future rates") - assert.Equal(t, now.Add(4*time.Hour), plan[len(plan)-1].End, "end should match 2-hour window") - assert.Equal(t, 0.08, plan[0].Value, "first slot should have actual price") - assert.Equal(t, 0.09, plan[1].Value, "second slot should have actual price") -} - -func TestContinuous_WindowLateChargingPreference(t *testing.T) { - now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC) - c := clock.NewMock() - c.Set(now) - - ctrl := gomock.NewController(t) - - rates := api.Rates{ - {Start: now, End: now.Add(1 * time.Hour), Value: 0.10}, - {Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), Value: 0.10}, - {Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.10}, - {Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.10}, - {Start: now.Add(4 * time.Hour), End: now.Add(5 * time.Hour), Value: 0.10}, - {Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.15}, - } - - trf := api.NewMockTariff(ctrl) - trf.EXPECT().Rates().AnyTimes().Return(rates, nil) - - p := &Planner{ - log: util.NewLogger("foo"), - clock: c, - tariff: trf, - } - - targetTime := now.Add(6 * time.Hour) - requiredDuration := 2 * time.Hour - - plan := p.Plan(requiredDuration, 0, targetTime, true) // continuous mode - - require.NotEmpty(t, plan) - require.Len(t, plan, 2) - - // Should select the latest window with equal cost (3h-5h) - // All windows from 0h-2h, 1h-3h, 2h-4h, and 3h-5h have the same total cost - // But we prefer late charging, so 3h-5h should be selected - assert.Equal(t, now.Add(3*time.Hour), plan[0].Start, "should select latest window with equal cost") - assert.Equal(t, now.Add(5*time.Hour), plan[len(plan)-1].End, "end should be 2 hours after start") - assert.Equal(t, 0.10, plan[0].Value, "first slot should have actual price") - assert.Equal(t, 0.10, plan[1].Value, "second slot should have actual price") -} - -func TestContinuous_TargetAfterKnownPrices(t *testing.T) { - clock := clock.NewMock() - ctrl := gomock.NewController(t) - - trf := api.NewMockTariff(ctrl) - trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clock.Now(), time.Hour), nil) - - p := &Planner{ - log: util.NewLogger("foo"), - clock: clock, - tariff: trf, - } - - plan := p.Plan(40*time.Minute, 0, clock.Now().Add(2*time.Hour), true) // charge efficiency does not allow to test with 1h - assert.False(t, !SlotAt(clock.Now(), plan).IsZero(), "should not start if car can be charged completely after known prices ") - - plan = p.Plan(2*time.Hour, 0, clock.Now().Add(2*time.Hour), true) - assert.True(t, !SlotAt(clock.Now(), plan).IsZero(), "should start if car can not be charged completely after known prices ") -} - -func TestContinuous_Precondition(t *testing.T) { - clock := clock.NewMock() - ctrl := gomock.NewController(t) - trf := api.NewMockTariff(ctrl) - - trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{1, 2, 3, 4}, clock.Now(), tariff.SlotDuration), nil) - - p := &Planner{ - log: util.NewLogger("foo"), - clock: clock, - tariff: trf, - } - - plan := p.Plan(tariff.SlotDuration, tariff.SlotDuration, clock.Now().Add(4*tariff.SlotDuration), true) - assert.Equal(t, api.Rates{ - { - Start: clock.Now().Add(3 * tariff.SlotDuration), - End: clock.Now().Add(4 * tariff.SlotDuration), - Value: 4, - }, - }, plan, "expected last slot") - - plan = p.Plan(2*tariff.SlotDuration, tariff.SlotDuration, clock.Now().Add(4*tariff.SlotDuration), true) - assert.Equal(t, api.Rates{ - { - Start: clock.Now(), - End: clock.Now().Add(1 * tariff.SlotDuration), - Value: 1, - }, - { - Start: clock.Now().Add(3 * tariff.SlotDuration), - End: clock.Now().Add(4 * tariff.SlotDuration), - Value: 4, - }, - }, plan, "expected two slots") - - plan = p.Plan(time.Duration(1.5*float64(tariff.SlotDuration)), tariff.SlotDuration, clock.Now().Add(4*tariff.SlotDuration), true) - assert.Equal(t, api.Rates{ - { - Start: clock.Now(), - End: clock.Now().Add(time.Duration(0.5 * float64(tariff.SlotDuration))), - Value: 1, - }, - { - Start: clock.Now().Add(3 * tariff.SlotDuration), - End: clock.Now().Add(4 * tariff.SlotDuration), - Value: 4, - }, - }, plan, "expected trimmed slot at beginning and precondition slot") - - plan = p.Plan(tariff.SlotDuration, 24*time.Hour, clock.Now().Add(time.Hour), true) - assert.Equal(t, api.Rates{ - { - Start: clock.Now().Add(3 * tariff.SlotDuration), - End: clock.Now().Add(4 * tariff.SlotDuration), - Value: 4, - }, - }, plan, "all precondition") -} - -func TestContinuous_Precondition_NonSlotBoundary(t *testing.T) { - clock := clock.NewMock() - ctrl := gomock.NewController(t) - trf := api.NewMockTariff(ctrl) - - slotDuration := 15 * time.Minute - - // Create rates with 15-minute slots covering 8 hours (32 slots) - prices := make([]float64, 32) - for i := range prices { - prices[i] = float64(i + 1) - } - trf.EXPECT().Rates().AnyTimes().Return(rates(prices, clock.Now(), slotDuration), nil) - - p := &Planner{ - log: util.NewLogger("foo"), - clock: clock, - tariff: trf, - } - - // Target time at 7:20 (non-slot boundary, between 7:15 and 7:30) - // 7:20 is 29 slots + 5 minutes from now - targetTime := clock.Now().Add(29*slotDuration + 5*time.Minute) - - // 30 minutes preconditioning, 1 hour charging - precondition := 30 * time.Minute - requiredDuration := 1 * time.Hour - - plan := p.Plan(requiredDuration, precondition, targetTime, true) - - // Verify precondition ends exactly at target time - require.NotEmpty(t, plan) - lastSlot := plan[len(plan)-1] - assert.Equal(t, targetTime, lastSlot.End, "precondition should end exactly at target time") - - // Calculate total precondition duration - var precondDuration time.Duration - // Precondition starts at targetTime - 30min = 6:50 - precondStart := targetTime.Add(-precondition) - for _, slot := range plan { - if !slot.Start.Before(precondStart) { - precondDuration += slot.End.Sub(slot.Start) - } - } - assert.Equal(t, precondition, precondDuration, "total precondition duration should be exactly 30 minutes") - - // In continuous mode, find cheapest continuous 30min window (after precondition reduction) - // Cheapest window: 01:00-01:30 (slots 0-1, prices 1+2) - // Precondition: 07:50-08:20 (exactly 30min before target at 08:20) - expectedPlan := api.Rates{ - // Charging slots (cheapest continuous 30 minutes) - {Start: clock.Now(), End: clock.Now().Add(slotDuration), Value: 1}, - {Start: clock.Now().Add(slotDuration), End: clock.Now().Add(2 * slotDuration), Value: 2}, - // Precondition slots (exactly 30min before target, trimmed at both ends) - {Start: targetTime.Add(-precondition), End: clock.Now().Add(28 * slotDuration), Value: 28}, - {Start: clock.Now().Add(28 * slotDuration), End: clock.Now().Add(29 * slotDuration), Value: 29}, - {Start: clock.Now().Add(29 * slotDuration), End: targetTime, Value: 30}, - } - - assert.Equal(t, expectedPlan, plan, "expected continuous charging slots and trimmed precondition slots") -} - -func TestPrecondition_Everything(t *testing.T) { - clock := clock.NewMock() - ctrl := gomock.NewController(t) - trf := api.NewMockTariff(ctrl) - - // Create 8 hours of rates with varying prices (cheaper toward the end) - prices := []float64{10, 9, 8, 7, 6, 5, 4, 3} - trf.EXPECT().Rates().AnyTimes().Return(rates(prices, clock.Now(), tariff.SlotDuration), nil) - - p := &Planner{ - log: util.NewLogger("test"), - clock: clock, - tariff: trf, - } - - targetTime := clock.Now().Add(8 * tariff.SlotDuration) // 8 hours from now - requiredDuration := 2 * tariff.SlotDuration // need 2 hours - precondition := 7 * 24 * time.Hour // "everything" = 7 days - - // Test with continuous=false (cheapest mode - should be ignored) - plan := p.Plan(requiredDuration, precondition, targetTime, false) - - require.NotEmpty(t, plan, "plan should not be empty") - - // Plan should end exactly at target time - assert.Equal(t, targetTime, plan[len(plan)-1].End, "plan should end at target time") - - // Plan should have total duration = requiredDuration (NOT precondition duration) - totalDuration := Duration(plan) - assert.Equal(t, requiredDuration, totalDuration, "plan duration should equal required duration, not precondition") - - // Plan should start at latest possible time (targetTime - requiredDuration) - expectedStart := targetTime.Add(-requiredDuration) - assert.Equal(t, expectedStart, plan[0].Start, "plan should start at latest possible time") - - // Should contain actual rate data (slots 6-7 with prices 4, 3) - assert.Len(t, plan, 2, "should have 2 slots for 30-minute duration") - assert.Equal(t, 4.0, plan[0].Value, "should have actual rate value from slot 6") - assert.Equal(t, 3.0, plan[1].Value, "should have actual rate value from slot 7") - - // Test with continuous=true (should also be ignored when precondition=everything) - planContinuous := p.Plan(requiredDuration, precondition, targetTime, true) - assert.Equal(t, plan, planContinuous, "continuous flag should be ignored when precondition=everything") -} - -func TestContinuous_ContinuousPlanNoTariff(t *testing.T) { - clock := clock.NewMock() - - p := &Planner{ - log: util.NewLogger("foo"), - clock: clock, - } - - plan := p.Plan(time.Hour, 0, clock.Now(), true) - - // 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 TestContinuous_ContinuousPlan(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 := p.Plan(150*time.Minute, 0, clock.Now(), true) - - // 3-slot plan - assert.Len(t, plan, 3) -} - -func TestContinuous_ContinuousPlanOutsideRates(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 := p.Plan(30*time.Minute, 0, clock.Now(), true) - - // 3-slot plan - assert.Len(t, plan, 1) -} - -// TestContinuous_StartBeforeRates tests that when current time is before -// the first available rate, the planner waits and starts charging when -// rates become available, as long as there's enough time to reach the target -func TestContinuous_StartBeforeRates(t *testing.T) { - now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC) - c := clock.NewMock() - c.Set(now) - - ctrl := gomock.NewController(t) - log := util.NewLogger("test") - - // Rates start 2 hours in the future (gap from now until first rate) - rates := api.Rates{ - {Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.10}, - {Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.15}, - {Start: now.Add(4 * time.Hour), End: now.Add(5 * time.Hour), Value: 0.08}, // cheapest - {Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.20}, - } - - trf := api.NewMockTariff(ctrl) - trf.EXPECT().Rates().AnyTimes().Return(rates, nil) - - planner := &Planner{ - log: log, - clock: c, - tariff: trf, - } - - targetTime := now.Add(6 * time.Hour) - requiredDuration := time.Hour - - plan := planner.Plan(requiredDuration, 0, targetTime, true) // continuous mode - - require.NotEmpty(t, plan, "plan should not be empty") - require.Len(t, plan, 1, "should create single slot with actual price") - - // Should wait until rates are available and pick the cheapest slot - assert.Equal(t, now.Add(4*time.Hour), plan[0].Start, "should start at cheapest available rate") - assert.Equal(t, now.Add(5*time.Hour), plan[0].End, "should end after required duration") - assert.Equal(t, 0.08, plan[0].Value, "should have actual price from cheapest slot") - - // Plan must not start before rates are available - assert.False(t, plan[0].Start.Before(rates[0].Start), "plan must not start before first available rate") -} - -// TestContinuous_StartBeforeRatesInsufficientTime tests that when current time -// is before the first available rate AND there's not enough time after rates -// start to complete charging before target, the planner starts charging as soon -// as rates become available (best effort approach) -func TestContinuous_StartBeforeRatesInsufficientTime(t *testing.T) { - now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC) - c := clock.NewMock() - c.Set(now) - - ctrl := gomock.NewController(t) - log := util.NewLogger("test") - - // Rates start 2 hours in the future, but we need 3 hours to charge - // and target is only 4 hours away (not enough time to fully charge) - rates := api.Rates{ - {Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.10}, - {Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.15}, - } - - trf := api.NewMockTariff(ctrl) - trf.EXPECT().Rates().AnyTimes().Return(rates, nil) - - planner := &Planner{ - log: log, - clock: c, - tariff: trf, - } - - targetTime := now.Add(4 * time.Hour) - requiredDuration := 3 * time.Hour // Need 3h but only 2h available after rates start - - plan := planner.Plan(requiredDuration, 0, targetTime, true) // continuous mode - - require.NotEmpty(t, plan, "plan should not be empty") - - // Best effort: start immediately to maximize charging time - assert.Equal(t, now, plan[0].Start, "should start immediately") - assert.Equal(t, 0.0, plan[0].Value, "gap-filling slot before rates has no price") -} - -// TestContinuous_StartBeforeRatesSufficientTime tests that when current time -// is before the first available rate AND there IS enough time to complete -// charging, the planner finds the cheapest continuous window -func TestContinuous_StartBeforeRatesSufficientTime(t *testing.T) { - now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC) - c := clock.NewMock() - c.Set(now) - - ctrl := gomock.NewController(t) - log := util.NewLogger("test") - - // Rates start 2 hours in the future, we need 2 hours to charge - // and target is 8 hours away (enough time to optimize) - rates := api.Rates{ - {Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.20}, - {Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.15}, - {Start: now.Add(4 * time.Hour), End: now.Add(5 * time.Hour), Value: 0.10}, // cheapest - {Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.08}, // cheapest - {Start: now.Add(6 * time.Hour), End: now.Add(7 * time.Hour), Value: 0.12}, - {Start: now.Add(7 * time.Hour), End: now.Add(8 * time.Hour), Value: 0.25}, - } - - trf := api.NewMockTariff(ctrl) - trf.EXPECT().Rates().AnyTimes().Return(rates, nil) - - planner := &Planner{ - log: log, - clock: c, - tariff: trf, - } - - targetTime := now.Add(8 * time.Hour) - requiredDuration := 2 * time.Hour - - plan := planner.Plan(requiredDuration, 0, targetTime, true) // continuous mode - - require.NotEmpty(t, plan, "plan should not be empty") - require.Len(t, plan, 2, "should find 2-hour continuous window") - - // Should find cheapest continuous 2-hour window (04:00-06:00) - assert.Equal(t, now.Add(4*time.Hour), plan[0].Start, "should start at cheapest window") - assert.Equal(t, 0.10, plan[0].Value, "first slot should have cheapest window price") - assert.Equal(t, 0.08, plan[1].Value, "second slot should have cheapest window price") -} - -// the target time (even at non-slot boundaries) by starting early -func TestContinuous_ExcessTimeFinishesAtTarget(t *testing.T) { - now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC) - c := clock.NewMock() - c.Set(now) - ctrl := gomock.NewController(t) - log := util.NewLogger("test") - slotDuration := 15 * time.Minute - - // Create 20 slots of 15 minutes each (5 hours total) - // Prices: cheaper in the middle slots - prices := []float64{ - 0.30, 0.30, 0.30, 0.30, // 00:00-01:00 expensive - 0.15, 0.10, 0.10, 0.10, // 01:00-02:00 medium+cheap - 0.08, 0.08, 0.08, 0.08, // 02:00-03:00 cheapest - 0.12, 0.12, 0.12, 0.12, // 03:00-04:00 medium - 0.20, 0.20, 0.20, 0.20, // 04:00-05:00 expensive - } - - trf := api.NewMockTariff(ctrl) - trf.EXPECT().Rates().AnyTimes().Return(rates(prices, now, slotDuration), nil) - - planner := &Planner{ - log: log, - clock: c, - tariff: trf, - } - - // Target at 03:10 (non-slot boundary - 10 minutes into the 03:00-03:15 slot) - targetTime := now.Add(3*time.Hour + 10*time.Minute) - requiredDuration := 2*time.Hour + 5*time.Minute // need 2h5m, have 3h10m available - - plan := planner.Plan(requiredDuration, 0, targetTime, true) // continuous, no precondition - - require.NotEmpty(t, plan) - - // Plan must not extend beyond target - lastSlot := plan[len(plan)-1] - assert.False(t, lastSlot.End.After(targetTime), - "plan must not extend beyond target") - - // Total duration must equal required duration - assert.Equal(t, requiredDuration, Duration(plan), "plan duration must match required") - - // Plan should use the cheapest slots (02:00-03:00 range, prices 0.08) - avgCost := AverageCost(plan) - assert.Less(t, avgCost, 0.12, "plan should use cheaper slots") - - // Target at 03:10 (non-slot boundary - must finish before target) - requiredDurationShort := 12 * time.Minute // need 12m, have 3h10m available - plan = planner.Plan(requiredDurationShort, 0, targetTime, true) // continuous, no precondition - - require.NotEmpty(t, plan) - - // Plan must not extend beyond target - lastSlotShort := plan[len(plan)-1] - assert.False(t, lastSlotShort.End.After(targetTime), - "plan must not extend beyond target") - - // Total duration must equal required duration - assert.Equal(t, requiredDurationShort, Duration(plan), "plan (short) duration must match required") - - // Plan should use the cheapest slots - avgCostShort := AverageCost(plan) - assert.Equal(t, 0.08, avgCostShort, "plan (short) should use cheapest slots (0.08)") - - // Target at 03:10 (non-slot boundary - must finish before target) - requiredDurationMedium := 27 * time.Minute // need 27m, have 3h10m available - plan = planner.Plan(requiredDurationMedium, 0, targetTime, true) // continuous, no precondition - - require.NotEmpty(t, plan) - - // Plan must not extend beyond target - lastSlotMedium := plan[len(plan)-1] - assert.False(t, lastSlotMedium.End.After(targetTime), - "plan must not extend beyond target") - - // Total duration must equal required duration - assert.Equal(t, requiredDurationMedium, Duration(plan), "plan (medium) duration must match required") - - // Plan should use the cheapest slots - avgCostMedium := AverageCost(plan) - assert.Equal(t, 0.08, avgCostMedium, "plan (medium) should use cheapest slots (0.08)") -} diff --git a/core/planner/planner_test.go b/core/planner/planner_test.go index e3825e21b..b2f28d2d7 100644 --- a/core/planner/planner_test.go +++ b/core/planner/planner_test.go @@ -7,7 +7,6 @@ import ( "github.com/benbjohnson/clock" "github.com/evcc-io/evcc/api" - "github.com/evcc-io/evcc/tariff" "github.com/evcc-io/evcc/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -18,7 +17,7 @@ func rates(prices []float64, start time.Time, slotDuration time.Duration) api.Ra res := make(api.Rates, 0, len(prices)) for i, v := range prices { - slotStart := start.Add(time.Duration(i) * slotDuration) + slotStart := start.Add(time.Duration(i) * time.Hour) ar := api.Rate{ Start: slotStart, End: slotStart.Add(slotDuration), @@ -30,16 +29,15 @@ func rates(prices []float64, start time.Time, slotDuration time.Duration) api.Ra return res } -func TestClampRates(t *testing.T) { - clock := clock.NewMock() - rr := rates([]float64{0, 1}, clock.Now(), time.Hour) +// func dumpRates(rr api.Rates) string { +// var b []byte +// for _, r := range rr { +// b = fmt.Appendf(b, "%+v\n", r) +// } +// return string(b) +// } - assert.Equal(t, rr, clampRates(rr, clock.Now(), clock.Now().Add(2*time.Hour))) - assert.Equal(t, rates([]float64{0}, clock.Now(), time.Hour), clampRates(rr, clock.Now(), clock.Now().Add(time.Hour))) - - exp := api.Rates{{Start: clock.Now().Add(time.Hour), End: clock.Now().Add(2 * time.Hour), Value: 1}} - assert.Equal(t, exp, clampRates(rr, clock.Now().Add(time.Hour), clock.Now().Add(2*time.Hour))) -} +// TODO start before start of rates func TestPlan(t *testing.T) { clock := clock.NewMock() @@ -59,8 +57,7 @@ func TestPlan(t *testing.T) { slices.SortStableFunc(rates, sortByCost) { - // filter rates to [now, now] window - should return empty - plan := p.plan(clampRates(rates, clock.Now(), clock.Now()), time.Hour, clock.Now()) + plan := p.plan(rates, time.Hour, clock.Now()) assert.Empty(t, plan) } @@ -128,8 +125,7 @@ func TestPlan(t *testing.T) { for i, tc := range tc { t.Log(tc.desc) clock.Set(tc.now) - // filter rates to [now, target] window as caller would do - plan := p.plan(clampRates(rates, tc.now, tc.target), tc.duration, tc.target) + plan := p.plan(rates, tc.duration, tc.target) assert.Equalf(t, tc.planStart.UTC(), Start(plan).UTC(), "case %d start", i) assert.Equalf(t, tc.duration, Duration(plan), "case %d duration", i) @@ -145,7 +141,7 @@ func TestNilTariff(t *testing.T) { clock: clock, } - plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute), false) + plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute)) assert.Equal(t, api.Rates{ { Start: clock.Now(), @@ -167,7 +163,7 @@ func TestRatesError(t *testing.T) { tariff: trf, } - plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute), false) + plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute)) assert.Equal(t, api.Rates{ { Start: clock.Now(), @@ -196,10 +192,10 @@ func TestFlatTariffTargetInThePast(t *testing.T) { }, } - plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute), false) + plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute)) assert.Equal(t, simplePlan, plan, "expected simple plan") - plan = p.Plan(time.Hour, 0, clock.Now().Add(-30*time.Minute), false) + plan = p.Plan(time.Hour, 0, clock.Now().Add(-30*time.Minute)) assert.Equal(t, simplePlan, plan, "expected simple plan") } @@ -220,12 +216,12 @@ func TestFlatTariffLongSlots(t *testing.T) { // that slots are not longer than 1 hour and with that context this is not a problem // expect 00:00-01:00 UTC - plan := p.Plan(time.Hour, 0, clock.Now().Add(2*time.Hour), false) + plan := p.Plan(time.Hour, 0, clock.Now().Add(2*time.Hour)) assert.Equal(t, api.Rate{Start: clock.Now(), End: clock.Now().Add(time.Hour)}, SlotAt(clock.Now(), plan)) assert.Equal(t, api.Rate{}, SlotAt(clock.Now().Add(time.Hour), plan)) // expect 00:00-01:00 UTC - plan = p.Plan(time.Hour, 0, clock.Now().Add(time.Hour), false) + plan = p.Plan(time.Hour, 0, clock.Now().Add(time.Hour)) assert.Equal(t, api.Rate{Start: clock.Now(), End: clock.Now().Add(time.Hour)}, SlotAt(clock.Now(), plan)) } @@ -242,10 +238,10 @@ func TestTargetAfterKnownPrices(t *testing.T) { tariff: trf, } - plan := p.Plan(40*time.Minute, 0, clock.Now().Add(2*time.Hour), false) // charge efficiency does not allow to test with 1h + plan := p.Plan(40*time.Minute, 0, clock.Now().Add(2*time.Hour)) // charge efficiency does not allow to test with 1h assert.False(t, !SlotAt(clock.Now(), plan).IsZero(), "should not start if car can be charged completely after known prices ") - plan = p.Plan(2*time.Hour, 0, clock.Now().Add(2*time.Hour), false) + plan = p.Plan(2*time.Hour, 0, clock.Now().Add(2*time.Hour)) assert.True(t, !SlotAt(clock.Now(), plan).IsZero(), "should start if car can not be charged completely after known prices ") } @@ -269,18 +265,19 @@ func TestChargeAfterTargetTime(t *testing.T) { }, } - plan := p.Plan(time.Hour, 0, clock.Now(), false) + plan := p.Plan(time.Hour, 0, clock.Now()) assert.Equal(t, simplePlan, plan, "expected simple plan") - plan = p.Plan(time.Hour, 0, clock.Now().Add(-time.Hour), false) + plan = p.Plan(time.Hour, 0, clock.Now().Add(-time.Hour)) assert.Equal(t, simplePlan, plan, "expected simple plan") } func TestPrecondition(t *testing.T) { clock := clock.NewMock() ctrl := gomock.NewController(t) + trf := api.NewMockTariff(ctrl) - trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{1, 2, 3, 4}, clock.Now(), tariff.SlotDuration), nil) + trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{1, 2, 3, 4}, clock.Now(), time.Hour), nil) p := &Planner{ log: util.NewLogger("foo"), @@ -288,120 +285,54 @@ func TestPrecondition(t *testing.T) { tariff: trf, } - plan := p.Plan(tariff.SlotDuration, tariff.SlotDuration, clock.Now().Add(4*tariff.SlotDuration), false) + plan := p.Plan(time.Hour, time.Hour, clock.Now().Add(4*time.Hour)) assert.Equal(t, api.Rates{ { - Start: clock.Now().Add(3 * tariff.SlotDuration), - End: clock.Now().Add(4 * tariff.SlotDuration), + Start: clock.Now().Add(3 * time.Hour), + End: clock.Now().Add(4 * time.Hour), Value: 4, }, }, plan, "expected last slot") - plan = p.Plan(2*tariff.SlotDuration, tariff.SlotDuration, clock.Now().Add(4*tariff.SlotDuration), false) - + // NOTE: with 15min slots we no longer expect late start of the slot + plan = p.Plan(2*time.Hour, time.Hour, clock.Now().Add(4*time.Hour)) assert.Equal(t, api.Rates{ { Start: clock.Now(), - End: clock.Now().Add(1 * tariff.SlotDuration), + End: clock.Now().Add(1 * time.Hour), Value: 1, }, { - Start: clock.Now().Add(3 * tariff.SlotDuration), - End: clock.Now().Add(4 * tariff.SlotDuration), + Start: clock.Now().Add(3 * time.Hour), + End: clock.Now().Add(4 * time.Hour), Value: 4, }, }, plan, "expected two slots") - plan = p.Plan(time.Duration(1.5*float64(tariff.SlotDuration)), tariff.SlotDuration, clock.Now().Add(4*tariff.SlotDuration), false) + plan = p.Plan(time.Hour, 30*time.Minute, clock.Now().Add(4*time.Hour)) assert.Equal(t, api.Rates{ { Start: clock.Now(), - End: clock.Now().Add(time.Duration(0.5 * float64(tariff.SlotDuration))), + End: clock.Now().Add(30 * time.Minute), Value: 1, }, { - Start: clock.Now().Add(3 * tariff.SlotDuration), - End: clock.Now().Add(4 * tariff.SlotDuration), + Start: clock.Now().Add(210 * time.Minute), // 3.5h + End: clock.Now().Add(4 * time.Hour), // 4.0h Value: 4, }, - }, plan, "expected trimmed slot at beginning and precondition slot") + }, plan, "expected short early and split late slot") - plan = p.Plan(tariff.SlotDuration, 24*time.Hour, clock.Now().Add(time.Hour), false) + plan = p.Plan(time.Hour, 24*time.Hour, clock.Now().Add(4*time.Hour)) assert.Equal(t, api.Rates{ { - Start: clock.Now().Add(3 * tariff.SlotDuration), - End: clock.Now().Add(4 * tariff.SlotDuration), + Start: clock.Now().Add(3 * time.Hour), + End: clock.Now().Add(4 * time.Hour), Value: 4, }, }, plan, "all precondition") } -func TestPrecondition_NonSlotBoundary(t *testing.T) { - clock := clock.NewMock() - ctrl := gomock.NewController(t) - trf := api.NewMockTariff(ctrl) - - slotDuration := 15 * time.Minute - - // Create rates with 15-minute slots covering 8 hours (32 slots) - prices := make([]float64, 32) - for i := range prices { - prices[i] = float64(i + 1) - } - trf.EXPECT().Rates().AnyTimes().Return(rates(prices, clock.Now(), slotDuration), nil) - - p := &Planner{ - log: util.NewLogger("foo"), - clock: clock, - tariff: trf, - } - - // Target time at 7:20 (non-slot boundary, between 7:15 and 7:30) - // 7:20 is 29 slots + 5 minutes from now - targetTime := clock.Now().Add(29*slotDuration + 5*time.Minute) - - // 30 minutes preconditioning, 1 hour charging - precondition := 30 * time.Minute - requiredDuration := 1 * time.Hour - - plan := p.Plan(requiredDuration, precondition, targetTime, false) - - // Verify precondition ends exactly at target time - require.NotEmpty(t, plan) - lastSlot := plan[len(plan)-1] - assert.Equal(t, targetTime, lastSlot.End, "precondition should end exactly at target time") - - // Calculate total precondition duration - var precondDuration time.Duration - // Precondition starts at targetTime - 30min = 6:50 - precondStart := targetTime.Add(-precondition) - for _, slot := range plan { - if !slot.Start.Before(precondStart) { - precondDuration += slot.End.Sub(slot.Start) - } - } - assert.Equal(t, precondition, precondDuration, "total precondition duration should be exactly 30 minutes") - - // Verify expected slots structure - // Note: precondition (30min) reduces effective required duration from 1h to 30min - // Cheapest 30min charging: slots at 01:00-01:30 (slots 0-1, prices 1,2) - // Precondition: 07:50-08:20 (exactly 30min before target at 08:20) - // - 07:45-08:00 (slot 27, price 28) -> trimmed to 07:50-08:00 (10min) - // - 08:00-08:15 (slot 28, price 29) -> full slot (15min) - // - 08:15-08:30 (slot 29, price 30) -> trimmed to 08:15-08:20 (5min) - expectedPlan := api.Rates{ - // Charging slots (cheapest 30 minutes after precondition reduction) - {Start: clock.Now(), End: clock.Now().Add(slotDuration), Value: 1}, - {Start: clock.Now().Add(slotDuration), End: clock.Now().Add(2 * slotDuration), Value: 2}, - // Precondition slots (exactly 30min before target, trimmed at both ends) - {Start: targetTime.Add(-precondition), End: clock.Now().Add(28 * slotDuration), Value: 28}, - {Start: clock.Now().Add(28 * slotDuration), End: clock.Now().Add(29 * slotDuration), Value: 29}, - {Start: clock.Now().Add(29 * slotDuration), End: targetTime, Value: 30}, - } - - assert.Equal(t, expectedPlan, plan, "expected charging slots and trimmed precondition slots") -} - func TestContinuousPlanNoTariff(t *testing.T) { clock := clock.NewMock() @@ -410,7 +341,7 @@ func TestContinuousPlanNoTariff(t *testing.T) { clock: clock, } - plan := p.Plan(time.Hour, 0, clock.Now(), false) + plan := p.Plan(time.Hour, 0, clock.Now()) // single-slot plan assert.Len(t, plan, 1) @@ -431,7 +362,7 @@ func TestContinuousPlan(t *testing.T) { tariff: trf, } - plan := p.Plan(150*time.Minute, 0, clock.Now(), false) + plan := p.Plan(150*time.Minute, 0, clock.Now()) // 3-slot plan assert.Len(t, plan, 3) @@ -450,93 +381,8 @@ func TestContinuousPlanOutsideRates(t *testing.T) { tariff: trf, } - plan := p.Plan(30*time.Minute, 0, clock.Now(), false) + plan := p.Plan(30*time.Minute, 0, clock.Now()) // 3-slot plan assert.Len(t, plan, 1) } - -// TestStartBeforeRates tests that when current time is before -// the first available rate, the planner waits and starts charging when -// rates become available, as long as there's enough time to reach the target -func TestStartBeforeRates(t *testing.T) { - now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC) - c := clock.NewMock() - c.Set(now) - - ctrl := gomock.NewController(t) - log := util.NewLogger("test") - - // Rates start 2 hours in the future (gap from now until first rate) - rates := api.Rates{ - {Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.10}, - {Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.15}, - {Start: now.Add(4 * time.Hour), End: now.Add(5 * time.Hour), Value: 0.08}, // cheapest - {Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.20}, - } - - trf := api.NewMockTariff(ctrl) - trf.EXPECT().Rates().AnyTimes().Return(rates, nil) - - planner := &Planner{ - log: log, - clock: c, - tariff: trf, - } - - targetTime := now.Add(6 * time.Hour) - requiredDuration := time.Hour - - plan := planner.Plan(requiredDuration, 0, targetTime, true) // continuous mode - - require.NotEmpty(t, plan, "plan should not be empty") - require.Len(t, plan, 1, "should create single slot with actual price") - - // Should wait until rates are available and pick the cheapest slot - assert.Equal(t, now.Add(4*time.Hour), plan[0].Start, "should start at cheapest available rate") - assert.Equal(t, now.Add(5*time.Hour), plan[0].End, "should end after required duration") - assert.Equal(t, 0.08, plan[0].Value, "should have actual price from cheapest slot") - - // Plan must not start before rates are available - assert.False(t, plan[0].Start.Before(rates[0].Start), "plan must not start before first available rate") -} - -// TestStartBeforeRatesInsufficientTime tests that when current time -// is before the first available rate AND there's not enough time after rates -// start to complete charging before target, the planner starts charging as soon -// as rates become available (best effort approach) -func TestStartBeforeRatesInsufficientTime(t *testing.T) { - now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC) - c := clock.NewMock() - c.Set(now) - - ctrl := gomock.NewController(t) - log := util.NewLogger("test") - - // Rates start 2 hours in the future, but we need 3 hours to charge - // and target is only 4 hours away (not enough time to fully charge) - rates := api.Rates{ - {Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.10}, - {Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.15}, - } - - trf := api.NewMockTariff(ctrl) - trf.EXPECT().Rates().AnyTimes().Return(rates, nil) - - planner := &Planner{ - log: log, - clock: c, - tariff: trf, - } - - targetTime := now.Add(4 * time.Hour) - requiredDuration := 3 * time.Hour // Need 3h but only 2h available after rates start - - plan := planner.Plan(requiredDuration, 0, targetTime, false) // dispersed mode - - require.NotEmpty(t, plan, "plan should not be empty - starts when rates become available") - - // Best effort: start as soon as rates are available - assert.Equal(t, now.Add(2*time.Hour), plan[0].Start, "should start at first available rate") - assert.Equal(t, 0.10, plan[0].Value, "should use first available rate price") -} diff --git a/core/site_vehicles.go b/core/site_vehicles.go index f4fc55215..043a669f4 100644 --- a/core/site_vehicles.go +++ b/core/site_vehicles.go @@ -14,26 +14,24 @@ import ( type planStruct struct { Soc int `json:"soc"` - Continuous bool `json:"continuous"` Precondition int64 `json:"precondition"` Time time.Time `json:"time"` } type vehicleStruct struct { - Title string `json:"title"` - Icon string `json:"icon,omitempty"` - Capacity float64 `json:"capacity,omitempty"` - Phases int `json:"phases,omitempty"` - MinSoc int `json:"minSoc,omitempty"` - LimitSoc int `json:"limitSoc,omitempty"` - MinCurrent float64 `json:"minCurrent,omitempty"` - MaxCurrent float64 `json:"maxCurrent,omitempty"` - Priority int `json:"priority,omitempty"` - Features []string `json:"features,omitempty"` - Plan *planStruct `json:"plan,omitempty"` - RepeatingPlans []api.RepeatingPlan `json:"repeatingPlans"` - PlanPrecondition int64 `json:"planPrecondition"` - PlanContinuous bool `json:"planContinuous"` + Title string `json:"title"` + Icon string `json:"icon,omitempty"` + Capacity float64 `json:"capacity,omitempty"` + Phases int `json:"phases,omitempty"` + MinSoc int `json:"minSoc,omitempty"` + LimitSoc int `json:"limitSoc,omitempty"` + MinCurrent float64 `json:"minCurrent,omitempty"` + MaxCurrent float64 `json:"maxCurrent,omitempty"` + MaxPower float64 `json:"maxPower,omitempty"` + Priority int `json:"priority,omitempty"` + Features []string `json:"features,omitempty"` + Plan *planStruct `json:"plan,omitempty"` + RepeatingPlans []api.RepeatingPlan `json:"repeatingPlans"` } // publishVehicles returns a list of vehicle titles @@ -48,32 +46,30 @@ func (site *Site) publishVehicles() { } ac := instance.OnIdentified() - strategy := v.GetPlanStrategy() var plan *planStruct - if time, soc := v.GetPlanSoc(); !time.IsZero() { - plan = &planStruct{Soc: soc, Precondition: int64(strategy.Precondition.Seconds()), Time: time} + if time, precondition, soc := v.GetPlanSoc(); !time.IsZero() { + plan = &planStruct{Soc: soc, Precondition: int64(precondition.Seconds()), Time: time} } res[v.Name()] = vehicleStruct{ - Title: instance.GetTitle(), - Icon: instance.Icon(), - Capacity: instance.Capacity(), - Phases: instance.Phases(), - MinSoc: v.GetMinSoc(), - LimitSoc: v.GetLimitSoc(), - MinCurrent: ac.MinCurrent, - MaxCurrent: ac.MaxCurrent, - Priority: ac.Priority, - Features: lo.Map(instance.Features(), func(f api.Feature, _ int) string { return f.String() }), - Plan: plan, - RepeatingPlans: v.GetRepeatingPlans(), - PlanPrecondition: int64(strategy.Precondition.Seconds()), - PlanContinuous: strategy.Continuous, + Title: instance.GetTitle(), + Icon: instance.Icon(), + Capacity: instance.Capacity(), + Phases: instance.Phases(), + MinSoc: v.GetMinSoc(), + LimitSoc: v.GetLimitSoc(), + MinCurrent: ac.MinCurrent, + MaxCurrent: ac.MaxCurrent, + MaxPower: ac.MaxPower, + Priority: ac.Priority, + Features: lo.Map(instance.Features(), func(f api.Feature, _ int) string { return f.String() }), + Plan: plan, + RepeatingPlans: v.GetRepeatingPlans(), } if lp := site.coordinator.Owner(instance); lp != nil { - go lp.PublishEffectiveValues() + lp.PublishEffectiveValues() } } diff --git a/core/vehicle/adapter.go b/core/vehicle/adapter.go index 40fbaf5eb..e801c26f5 100644 --- a/core/vehicle/adapter.go +++ b/core/vehicle/adapter.go @@ -71,20 +71,24 @@ func (v *adapter) SetLimitSoc(soc int) { } // GetPlanSoc returns the charge plan soc -func (v *adapter) GetPlanSoc() (time.Time, int) { +func (v *adapter) GetPlanSoc() (time.Time, time.Duration, int) { var ts time.Time if v, err := settings.Time(v.key() + keys.PlanTime); err == nil { ts = v } + var precondition time.Duration + if v, err := settings.Int(v.key() + keys.PlanPrecondition); err == nil { + precondition = time.Duration(v) * time.Second + } var soc int if v, err := settings.Int(v.key() + keys.PlanSoc); err == nil { soc = int(v) } - return ts, soc + return ts, precondition, soc } // SetPlanSoc sets the charge plan soc -func (v *adapter) SetPlanSoc(ts time.Time, soc int) error { +func (v *adapter) SetPlanSoc(ts time.Time, precondition time.Duration, soc int) error { if !ts.IsZero() && ts.Before(time.Now()) { return errors.New("timestamp is in the past") } @@ -94,10 +98,11 @@ func (v *adapter) SetPlanSoc(ts time.Time, soc int) error { ts = time.Time{} v.log.DEBUG.Printf("delete %s plan", v.name) } else { - v.log.DEBUG.Printf("set %s plan soc: %d @ %v", v.name, soc, ts.Round(time.Second).Local()) + v.log.DEBUG.Printf("set %s plan soc: %d @ %v (precondition: %v)", v.name, soc, ts.Round(time.Second).Local(), precondition) } settings.SetTime(v.key()+keys.PlanTime, ts) + settings.SetInt(v.key()+keys.PlanPrecondition, int64(precondition.Seconds())) settings.SetInt(v.key()+keys.PlanSoc, int64(soc)) v.publish() @@ -120,11 +125,10 @@ func (v *adapter) SetRepeatingPlans(plans []api.RepeatingPlan) error { } } - if err := settings.SetJson(v.key()+keys.RepeatingPlans, plans); err != nil { - return err - } - v.log.DEBUG.Printf("update repeating plans for %s to: %v", v.name, plans) + + settings.SetJson(v.key()+keys.RepeatingPlans, plans) + v.publish() return nil @@ -139,22 +143,3 @@ func (v *adapter) GetRepeatingPlans() []api.RepeatingPlan { return plans } - -func (v *adapter) GetPlanStrategy() api.PlanStrategy { - var strategy api.PlanStrategy - if err := settings.Json(v.key()+keys.PlanStrategy, &strategy); err != nil { - return api.PlanStrategy{} - } - return strategy -} - -func (v *adapter) SetPlanStrategy(planStrategy api.PlanStrategy) error { - if err := settings.SetJson(v.key()+keys.PlanStrategy, planStrategy); err != nil { - return err - } - - v.log.DEBUG.Printf("update plan strategy for vehicle %s (precondition: %vs, continuous: %v)", v.name, planStrategy.Continuous, planStrategy.Precondition) - v.publish() - - return nil -} diff --git a/core/vehicle/api.go b/core/vehicle/api.go index 5dde92b6d..da134d852 100644 --- a/core/vehicle/api.go +++ b/core/vehicle/api.go @@ -39,20 +39,15 @@ type API interface { SetLimitSoc(soc int) // GetPlanSoc returns the charge plan soc - GetPlanSoc() (time.Time, int) + GetPlanSoc() (time.Time, time.Duration, int) // SetPlanSoc sets the charge plan time and soc - SetPlanSoc(time.Time, int) error + SetPlanSoc(time.Time, time.Duration, int) error // GetRepeatingPlans returns every repeating plan GetRepeatingPlans() []api.RepeatingPlan // SetRepeatingPlans stores every repeating plan SetRepeatingPlans([]api.RepeatingPlan) error - // GetPlanStrategy returns the plan strategy - GetPlanStrategy() api.PlanStrategy - // SetPlanStrategy sets the plan strategy - SetPlanStrategy(api.PlanStrategy) error - // // GetMinCurrent returns the min charging current // GetMinCurrent() float64 // // SetMinCurrent sets the min charging current diff --git a/core/vehicle/dummy.go b/core/vehicle/dummy.go index fe76e9381..a648d7830 100644 --- a/core/vehicle/dummy.go +++ b/core/vehicle/dummy.go @@ -39,12 +39,12 @@ func (v *dummy) SetLimitSoc(soc int) { } // GetPlanSoc returns the charge plan soc -func (v *dummy) GetPlanSoc() (time.Time, int) { - return time.Time{}, 0 +func (v *dummy) GetPlanSoc() (time.Time, time.Duration, int) { + return time.Time{}, 0, 0 } // SetPlanSoc sets the charge plan soc -func (v *dummy) SetPlanSoc(ts time.Time, soc int) error { +func (v *dummy) SetPlanSoc(ts time.Time, precondition time.Duration, soc int) error { return nil } @@ -56,11 +56,3 @@ func (v *dummy) SetRepeatingPlans(plans []api.RepeatingPlan) error { func (v *dummy) GetRepeatingPlans() []api.RepeatingPlan { return nil } - -func (v *dummy) GetPlanStrategy() api.PlanStrategy { - return api.PlanStrategy{} -} - -func (v *dummy) SetPlanStrategy(strategy api.PlanStrategy) error { - return nil -} diff --git a/core/vehicle/mock.go b/core/vehicle/mock.go index 5db465b22..120335104 100644 --- a/core/vehicle/mock.go +++ b/core/vehicle/mock.go @@ -70,12 +70,13 @@ func (mr *MockAPIMockRecorder) GetMinSoc() *gomock.Call { } // GetPlanSoc mocks base method. -func (m *MockAPI) GetPlanSoc() (time.Time, int) { +func (m *MockAPI) GetPlanSoc() (time.Time, time.Duration, int) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPlanSoc") ret0, _ := ret[0].(time.Time) - ret1, _ := ret[1].(int) - return ret0, ret1 + ret1, _ := ret[1].(time.Duration) + ret2, _ := ret[2].(int) + return ret0, ret1, ret2 } // GetPlanSoc indicates an expected call of GetPlanSoc. @@ -84,20 +85,6 @@ func (mr *MockAPIMockRecorder) GetPlanSoc() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanSoc", reflect.TypeOf((*MockAPI)(nil).GetPlanSoc)) } -// GetPlanStrategy mocks base method. -func (m *MockAPI) GetPlanStrategy() api.PlanStrategy { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetPlanStrategy") - ret0, _ := ret[0].(api.PlanStrategy) - return ret0 -} - -// GetPlanStrategy indicates an expected call of GetPlanStrategy. -func (mr *MockAPIMockRecorder) GetPlanStrategy() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanStrategy", reflect.TypeOf((*MockAPI)(nil).GetPlanStrategy)) -} - // GetRepeatingPlans mocks base method. func (m *MockAPI) GetRepeatingPlans() []api.RepeatingPlan { m.ctrl.T.Helper() @@ -165,31 +152,17 @@ func (mr *MockAPIMockRecorder) SetMinSoc(soc any) *gomock.Call { } // SetPlanSoc mocks base method. -func (m *MockAPI) SetPlanSoc(arg0 time.Time, arg1 int) error { +func (m *MockAPI) SetPlanSoc(arg0 time.Time, arg1 time.Duration, arg2 int) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetPlanSoc", arg0, arg1) + ret := m.ctrl.Call(m, "SetPlanSoc", arg0, arg1, arg2) ret0, _ := ret[0].(error) return ret0 } // SetPlanSoc indicates an expected call of SetPlanSoc. -func (mr *MockAPIMockRecorder) SetPlanSoc(arg0, arg1 any) *gomock.Call { +func (mr *MockAPIMockRecorder) SetPlanSoc(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanSoc", reflect.TypeOf((*MockAPI)(nil).SetPlanSoc), arg0, arg1) -} - -// SetPlanStrategy mocks base method. -func (m *MockAPI) SetPlanStrategy(arg0 api.PlanStrategy) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetPlanStrategy", arg0) - ret0, _ := ret[0].(error) - return ret0 -} - -// SetPlanStrategy indicates an expected call of SetPlanStrategy. -func (mr *MockAPIMockRecorder) SetPlanStrategy(arg0 any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanStrategy", reflect.TypeOf((*MockAPI)(nil).SetPlanStrategy), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanSoc", reflect.TypeOf((*MockAPI)(nil).SetPlanSoc), arg0, arg1, arg2) } // SetRepeatingPlans mocks base method. diff --git a/i18n/bg.json b/i18n/bg.json index 44dfb1319..1601f5d67 100644 --- a/i18n/bg.json +++ b/i18n/bg.json @@ -636,12 +636,11 @@ "modalTitle": "План за зареждане", "none": "няма", "planNumber": "План {number}", - "precondition": { - "description": "Зареди за {duration} преди тръгване, за да бъде кондиционирана батерията на автомобила.", - "label": "Късно зареждане", - "optionAll": "всичко", - "optionNo": "не" - }, + "preconditionDescription": "Зареди за {duration} преди тръгване, за да бъде кондиционирана батерията на автомобила.", + "preconditionLong": "Късно зареждане", + "preconditionOptionAll": "всичко", + "preconditionOptionNo": "не", + "preconditionShort": "Късно", "remove": "Премахване", "repeating": "повтарящ се", "repeatingPlans": "Повтарящи се планове", diff --git a/i18n/cs.json b/i18n/cs.json index 34295adae..5aaabcc04 100644 --- a/i18n/cs.json +++ b/i18n/cs.json @@ -814,12 +814,11 @@ "modalTitle": "Plán nabíjení", "none": "Žádný", "planNumber": "Plán {number}", - "precondition": { - "description": "Nabíjení bude dokončeno {duration} před časem odjezdu. Vhodné v zimě pro zahřátí baterie či její šetření při vyšším stavu nabití.", - "label": "Odložené nabíjení", - "optionAll": "všechen", - "optionNo": "ne" - }, + "preconditionDescription": "Nabíjení bude dokončeno {duration} před časem odjezdu. Vhodné v zimě pro zahřátí baterie či její šetření při vyšším stavu nabití.", + "preconditionLong": "Odložené nabíjení", + "preconditionOptionAll": "všechen", + "preconditionOptionNo": "ne", + "preconditionShort": "Odložené nabíjení", "remove": "Odebrat", "repeating": "opakující se", "repeatingPlans": "Opakující se plány", diff --git a/i18n/da.json b/i18n/da.json index 22954c773..7eee310c7 100644 --- a/i18n/da.json +++ b/i18n/da.json @@ -889,12 +889,11 @@ "modalTitle": "Lade plan", "none": "ingen", "planNumber": "Plan {number}", - "precondition": { - "description": "Oplad {duration} før afgang til batterikonditionering.", - "label": "Sen opladning", - "optionAll": "alt", - "optionNo": "nej" - }, + "preconditionDescription": "Oplad {duration} før afgang til batterikonditionering.", + "preconditionLong": "Sen opladning", + "preconditionOptionAll": "alt", + "preconditionOptionNo": "nej", + "preconditionShort": "Sen", "remove": "Fjern", "repeating": "Gentager", "repeatingPlans": "Gentager plan", diff --git a/i18n/de.json b/i18n/de.json index a740889dc..b1baa2971 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -877,23 +877,16 @@ "goal": "Ladeziel", "modalTitle": "Ladeplanung", "none": "keiner", - "optimization": { - "cheapest": "günstigst", - "continuous": "kontinuierlich", - "label": "Optimierung" - }, "planNumber": "Plan {number}", - "precondition": { - "description": "Lade {duration} vor Abfahrt zur Batterie-Vorkonditionierung.", - "label": "Spätes Laden", - "optionAll": "alles", - "optionNo": "nein" - }, + "preconditionDescription": "Lade {duration} vor Abfahrt zur Batterie-Vorkonditionierung.", + "preconditionLong": "Spätes Laden", + "preconditionOptionAll": "alles", + "preconditionOptionNo": "nein", + "preconditionShort": "Spät", "remove": "Entfernen", "repeating": "wiederholend", "repeatingPlans": "Wiederholende Pläne", "selectAll": "Alle wählen", - "strategySettings": "Strategie-Einstellungen", "time": "Zeit", "title": "Plan", "titleMinSoc": "Min. Ladung", diff --git a/i18n/el.json b/i18n/el.json index 9a61de87f..a6e106648 100644 --- a/i18n/el.json +++ b/i18n/el.json @@ -798,12 +798,11 @@ "modalTitle": "Πρόγραμμα Φόρτισης", "none": "κανένα", "planNumber": "Πρόγραμμα {number}", - "precondition": { - "description": "Φορτίστε {duration} πριν από την αναχώρηση για προετοιμασία μπαταρίας.", - "label": "Καθυστερημένη φόρτιση", - "optionAll": "Όλα", - "optionNo": "Όχι" - }, + "preconditionDescription": "Φορτίστε {duration} πριν από την αναχώρηση για προετοιμασία μπαταρίας.", + "preconditionLong": "Καθυστερημένη φόρτιση", + "preconditionOptionAll": "Όλα", + "preconditionOptionNo": "Όχι", + "preconditionShort": "Αργά", "remove": "Αφαίρεση", "repeating": "επαναλαμβανόμενο", "repeatingPlans": "Επαναλαμβανόμενα προγράμματα", diff --git a/i18n/en.json b/i18n/en.json index 3d1f141c4..c9c06b6bb 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -877,23 +877,16 @@ "goal": "Charging goal", "modalTitle": "Charging Plan", "none": "none", - "optimization": { - "cheapest": "cheapest", - "continuous": "continuous", - "label": "Optimization" - }, "planNumber": "Plan {number}", - "precondition": { - "description": "Charge {duration} before departure for battery preconditioning.", - "label": "Late Charging", - "optionAll": "everything", - "optionNo": "no" - }, + "preconditionDescription": "Charge {duration} before departure for battery preconditioning.", + "preconditionLong": "Late Charging", + "preconditionOptionAll": "everything", + "preconditionOptionNo": "no", + "preconditionShort": "Late", "remove": "Remove", "repeating": "repeating", "repeatingPlans": "Repeating plans", "selectAll": "Select all", - "strategySettings": "Strategy settings", "time": "Time", "title": "Plan", "titleMinSoc": "Min charge", diff --git a/i18n/fi.json b/i18n/fi.json index 6869bfeb5..06a653849 100644 --- a/i18n/fi.json +++ b/i18n/fi.json @@ -888,12 +888,11 @@ "modalTitle": "Lataussuunnitelma", "none": "ei mitään", "planNumber": "Suunnitelma {number}", - "precondition": { - "description": "Ladataan {duration} ennen lähtöä akunesilämmitystä varten.", - "label": "Viimehetken lataus", - "optionAll": "koko latausmäärä", - "optionNo": "ei" - }, + "preconditionDescription": "Ladataan {duration} ennen lähtöä akunesilämmitystä varten.", + "preconditionLong": "Viimehetken lataus", + "preconditionOptionAll": "koko latausmäärä", + "preconditionOptionNo": "ei", + "preconditionShort": "Viimehetkellinen", "remove": "Poista", "repeating": "toistetaan", "repeatingPlans": "Toistuvat suunnitelmat", diff --git a/i18n/fr.json b/i18n/fr.json index 8f2689346..bd11a5454 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -888,12 +888,11 @@ "modalTitle": "Planification de la charge", "none": "aucune", "planNumber": "Planning {number}", - "precondition": { - "description": "Charge {duration} avant le départ pour pré-conditionner la batterie.", - "label": "Chargement tardif", - "optionAll": "tous", - "optionNo": "non" - }, + "preconditionDescription": "Charge {duration} avant le départ pour pré-conditionner la batterie.", + "preconditionLong": "Chargement tardif", + "preconditionOptionAll": "tous", + "preconditionOptionNo": "non", + "preconditionShort": "Tardif", "remove": "Enlever", "repeating": "récurrent", "repeatingPlans": "Plannings récurrents", diff --git a/i18n/hr.json b/i18n/hr.json index 6b23f3d6a..102944abf 100644 --- a/i18n/hr.json +++ b/i18n/hr.json @@ -818,12 +818,11 @@ "modalTitle": "Plan punjenja", "none": "nema, dodaj", "planNumber": "Plan {number}", - "precondition": { - "description": "Punjenje {duration} prije polaska kako bi se izvršilo predkondicioniranje baterije.", - "label": "Kasnije punjenje", - "optionAll": "sve", - "optionNo": "ne" - }, + "preconditionDescription": "Punjenje {duration} prije polaska kako bi se izvršilo predkondicioniranje baterije.", + "preconditionLong": "Kasnije punjenje", + "preconditionOptionAll": "sve", + "preconditionOptionNo": "ne", + "preconditionShort": "Kasno", "remove": "Ukloni", "repeating": "ponavljanje", "repeatingPlans": "Ponavljajući planovi", diff --git a/i18n/hu.json b/i18n/hu.json index 3fc41e2c1..7af003e47 100644 --- a/i18n/hu.json +++ b/i18n/hu.json @@ -786,12 +786,11 @@ "modalTitle": "Töltési Tervezet", "none": "nincs", "planNumber": "Terv {number}", - "precondition": { - "description": "Töltse {duration} indulás előtt az akkumulátor előkondícionálásához.", - "label": "Késleltetett Töltés", - "optionAll": "minden", - "optionNo": "nem" - }, + "preconditionDescription": "Töltse {duration} indulás előtt az akkumulátor előkondícionálásához.", + "preconditionLong": "Késleltetett Töltés", + "preconditionOptionAll": "minden", + "preconditionOptionNo": "nem", + "preconditionShort": "Később", "remove": "Törlés", "repeating": "ismétlődő", "repeatingPlans": "Ismétlődő tervek", diff --git a/i18n/it.json b/i18n/it.json index 6c1e9711e..8d4fed497 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -616,12 +616,11 @@ "modalTitle": "Piano di ricarica", "none": "nessuno", "planNumber": "Piano {number}", - "precondition": { - "description": "Carica {duration} prima della partenza per pre-condizionare la batteria.", - "label": "Carica ritardata", - "optionAll": "tutto", - "optionNo": "no" - }, + "preconditionDescription": "Carica {duration} prima della partenza per pre-condizionare la batteria.", + "preconditionLong": "Carica ritardata", + "preconditionOptionAll": "tutto", + "preconditionOptionNo": "no", + "preconditionShort": "Ritardata", "remove": "Rimuovi", "repeating": "ricorrente", "repeatingPlans": "Piani ricorrenti", diff --git a/i18n/lb.json b/i18n/lb.json index b321d6c82..6e0fff1d8 100644 --- a/i18n/lb.json +++ b/i18n/lb.json @@ -887,12 +887,11 @@ "modalTitle": "Luedplanifikatioun", "none": "keng", "planNumber": "Planifkatioun {number}", - "precondition": { - "description": "Luetzäit {duration} virum Fortfuere fir Batterie-Prekonditionéierung.", - "label": "Verspéit Lueden", - "optionAll": "Alles", - "optionNo": "Nee" - }, + "preconditionDescription": "Luetzäit {duration} virum Fortfuere fir Batterie-Prekonditionéierung.", + "preconditionLong": "Verspéit Lueden", + "preconditionOptionAll": "Alles", + "preconditionOptionNo": "Nee", + "preconditionShort": "Spéit", "remove": "Ewechhuelen", "repeating": "widderhuelend", "repeatingPlans": "Widderhuelend Planifikatiounen", diff --git a/i18n/lt.json b/i18n/lt.json index 9446a6ca0..11962b699 100644 --- a/i18n/lt.json +++ b/i18n/lt.json @@ -889,12 +889,11 @@ "modalTitle": "Įkrovimo planas", "none": "nėra", "planNumber": "Planas {number}", - "precondition": { - "description": "Įkrovimas {duration} prieš išvykstant, baterijos paruošimui.", - "label": "Vėlyvas įkrovimas", - "optionAll": "viskas", - "optionNo": "ne" - }, + "preconditionDescription": "Įkrovimas {duration} prieš išvykstant, baterijos paruošimui.", + "preconditionLong": "Vėlyvas įkrovimas", + "preconditionOptionAll": "viskas", + "preconditionOptionNo": "ne", + "preconditionShort": "Vėlai", "remove": "Pašalinti", "repeating": "kartojasi", "repeatingPlans": "Pasikartojantys planai", diff --git a/i18n/nl.json b/i18n/nl.json index 1b9432a6f..a70a2a9ea 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -888,12 +888,11 @@ "modalTitle": "Laad Plan", "none": "geen", "planNumber": "Schema {number}", - "precondition": { - "description": "Laad {duration} voor vertrek voor accu voorbereiding.", - "label": "Laat Opladen", - "optionAll": "alles", - "optionNo": "nee" - }, + "preconditionDescription": "Laad {duration} voor vertrek voor accu voorbereiding.", + "preconditionLong": "Laat Opladen", + "preconditionOptionAll": "alles", + "preconditionOptionNo": "nee", + "preconditionShort": "Laat", "remove": "Verwijderen", "repeating": "herhalend", "repeatingPlans": "Herhalende schema's", diff --git a/i18n/pl.json b/i18n/pl.json index 29bb36cb9..c66725ab1 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -509,12 +509,11 @@ "goal": "Cel ładowania", "modalTitle": "Plan ładowania", "none": "brak", - "precondition": { - "description": "Naładuj {duration} przed wyjazdem dla wstępnego kondycjonowania akumulatora.", - "label": "Późniejsze ładowanie", - "optionAll": "wszystko", - "optionNo": "nie" - }, + "preconditionDescription": "Naładuj {duration} przed wyjazdem dla wstępnego kondycjonowania akumulatora.", + "preconditionLong": "Późniejsze ładowanie", + "preconditionOptionAll": "wszystko", + "preconditionOptionNo": "nie", + "preconditionShort": "Później", "remove": "Wymaż", "repeating": "powtórzenie", "repeatingPlans": "Powtarzające się plany", diff --git a/i18n/pt.json b/i18n/pt.json index ea3e27b77..ddb8320dc 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -887,12 +887,11 @@ "modalTitle": "Plano de carga", "none": "nenhum", "planNumber": "Plano {number}", - "precondition": { - "description": "Carregar {duration} antes da partida para pré-condicionamento da bateria.", - "label": "Carga tardia", - "optionAll": "tudo", - "optionNo": "não" - }, + "preconditionDescription": "Carregar {duration} antes da partida para pré-condicionamento da bateria.", + "preconditionLong": "Carga tardia", + "preconditionOptionAll": "tudo", + "preconditionOptionNo": "não", + "preconditionShort": "Tarde", "remove": "Apagar", "repeating": "recorrente", "repeatingPlans": "Repetição de planos", diff --git a/i18n/sv.json b/i18n/sv.json index 1f31cf92b..3ecf08cfb 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -889,12 +889,11 @@ "modalTitle": "Laddplan", "none": "ingen", "planNumber": "Plan {number}", - "precondition": { - "description": "Ladda {duration} före avfärd för batteriuppvärmning.", - "label": "Sen laddning", - "optionAll": "allt", - "optionNo": "nej" - }, + "preconditionDescription": "Ladda {duration} före avfärd för batteriuppvärmning.", + "preconditionLong": "Sen laddning", + "preconditionOptionAll": "allt", + "preconditionOptionNo": "nej", + "preconditionShort": "Sen", "remove": "Ta bort", "repeating": "återkommande", "repeatingPlans": "Återkommande planer", diff --git a/i18n/ta.json b/i18n/ta.json index 6abfd6e87..f4117f4e4 100644 --- a/i18n/ta.json +++ b/i18n/ta.json @@ -683,12 +683,11 @@ "modalTitle": "மின்சேர்வி திட்டம்", "none": "எதுவுமில்லை", "planNumber": "திட்டம் {number}", - "precondition": { - "description": "பேட்டரி முன்நிபந்தனைக்கு புறப்படுவதற்கு முன் {duration} சார்ஜ் செய்யவும்.", - "label": "தாமதமாக சார்சிங்", - "optionAll": "எல்லாம்", - "optionNo": "இல்லை" - }, + "preconditionDescription": "பேட்டரி முன்நிபந்தனைக்கு புறப்படுவதற்கு முன் {duration} சார்ஜ் செய்யவும்.", + "preconditionLong": "தாமதமாக சார்சிங்", + "preconditionOptionAll": "எல்லாம்", + "preconditionOptionNo": "இல்லை", + "preconditionShort": "தாமதமாக", "remove": "அகற்று", "repeating": "மறுநிகழ்தல்", "repeatingPlans": "மறுநிகழ்வு திட்டங்கள்", diff --git a/i18n/tr.json b/i18n/tr.json index 97f8211d0..366b84418 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -887,12 +887,11 @@ "modalTitle": "Doldurma Planı", "none": "yok", "planNumber": "Plan {number}", - "precondition": { - "description": "Batarya ön ısıtması için kalkıştan önce {duration} doldur.", - "label": "Geç doldurma", - "optionAll": "hepsi", - "optionNo": "hayır" - }, + "preconditionDescription": "Batarya ön ısıtması için kalkıştan önce {duration} doldur.", + "preconditionLong": "Geç doldurma", + "preconditionOptionAll": "hepsi", + "preconditionOptionNo": "hayır", + "preconditionShort": "Geç", "remove": "Kaldır", "repeating": "“tekrarlanan”", "repeatingPlans": "“Tekrarlanan planlar”", diff --git a/i18n/uk.json b/i18n/uk.json index 399d38bce..608d412a8 100644 --- a/i18n/uk.json +++ b/i18n/uk.json @@ -889,12 +889,11 @@ "modalTitle": "Тарифний план", "none": "немає", "planNumber": "План {number}", - "precondition": { - "description": "Зарядіть {duration} перед відправленням для попередньої підготовки акумулятора.", - "label": "Пізня зарядка", - "optionAll": "все", - "optionNo": "ні" - }, + "preconditionDescription": "Зарядіть {duration} перед відправленням для попередньої підготовки акумулятора.", + "preconditionLong": "Пізня зарядка", + "preconditionOptionAll": "все", + "preconditionOptionNo": "ні", + "preconditionShort": "Пізно", "remove": "Yсувати", "repeating": "повторення", "repeatingPlans": "Повторювані плани", diff --git a/i18n/zh-Hans.json b/i18n/zh-Hans.json index 0db908a02..33d9d2f57 100644 --- a/i18n/zh-Hans.json +++ b/i18n/zh-Hans.json @@ -610,12 +610,11 @@ "modalTitle": "充电计划", "none": "无", "planNumber": "计划 {number}", - "precondition": { - "description": "出发前充电 {duration} 以进行电池预处理。", - "label": "延迟充电", - "optionAll": "全部", - "optionNo": "否" - }, + "preconditionDescription": "出发前充电 {duration} 以进行电池预处理。", + "preconditionLong": "延迟充电", + "preconditionOptionAll": "全部", + "preconditionOptionNo": "否", + "preconditionShort": "延迟", "remove": "移除", "repeating": "重复", "repeatingPlans": "重复计划", diff --git a/push/hub.go b/push/hub.go index addb76219..2a3e96f0e 100644 --- a/push/hub.go +++ b/push/hub.go @@ -85,7 +85,7 @@ func (h *Hub) apply(ev Event, tmpl string) (string, error) { if v, err := h.vehicles.ByName(name); err == nil { attr["vehicleLimitSoc"] = v.GetLimitSoc() attr["vehicleMinSoc"] = v.GetMinSoc() - attr["vehiclePlanTime"], attr["vehiclePlanSoc"] = v.GetPlanSoc() + attr["vehiclePlanTime"], _, attr["vehiclePlanSoc"] = v.GetPlanSoc() instance := v.Instance() attr["vehicleTitle"] = instance.GetTitle() diff --git a/server/helper.go b/server/helper.go index ed6c84428..da270c65a 100644 --- a/server/helper.go +++ b/server/helper.go @@ -9,6 +9,7 @@ import ( "slices" "strconv" "strings" + "time" ) // pass converts a simple api without return value to api with nil error return value @@ -28,6 +29,21 @@ func parseFloat(payload string) (float64, error) { return f, err } +// parseDuration parses a duration string as seconds +func parseDuration(payload string) (time.Duration, error) { + if payload == "" { + return 0, nil + } + v, err := strconv.Atoi(payload) + if err != nil { + return 0, err + } + if v < 0 { + return 0, fmt.Errorf("invalid duration: %s", payload) + } + return time.Duration(v) * time.Second, err +} + // jsonDecoder returns a json decoder with disallowed unknown fields func jsonDecoder(r io.Reader) *json.Decoder { dec := json.NewDecoder(r) diff --git a/server/http.go b/server/http.go index 1579deb67..7aa0b8bf6 100644 --- a/server/http.go +++ b/server/http.go @@ -173,7 +173,6 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param) "plan": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/soc/{value:[0-9]+}/{time:[0-9TZ:.+-]+}", planSocHandler(site)}, "plan2": {"DELETE", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/soc", planSocRemoveHandler(site)}, "repeatingPlans": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/repeating", addRepeatingPlansHandler(site)}, - "planStrategy": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/strategy", updatePlanStrategyHandler(site)}, // config ui // "mode": {"POST", "/mode/{value:[a-z]+}", chargeModeHandler(v)}, @@ -200,9 +199,9 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param) "phases": {"POST", "/phases/{value:[0-9]+}", intHandler(lp.SetPhasesConfigured, lp.GetPhasesConfigured)}, "plan": {"GET", "/plan", planHandler(lp)}, "staticPlanPreview": {"GET", "/plan/static/preview/{type:(?:soc|energy)}/{value:[0-9.]+}/{time:[0-9TZ:.+-]+}", staticPlanPreviewHandler(lp)}, + "repeatingPlanPreview": {"GET", "/plan/repeating/preview/{soc:[0-9]+}/{weekdays:[0-6,]+}/{time:[0-2][0-9]:[0-5][0-9]}/{tz:[a-zA-Z0-9_./:-]+}", repeatingPlanPreviewHandler(lp)}, "planenergy": {"POST", "/plan/energy/{value:[0-9.]+}/{time:[0-9TZ:.+-]+}", planEnergyHandler(lp)}, "planenergy2": {"DELETE", "/plan/energy", planRemoveHandler(lp)}, - "planStrategy": {"POST", "/plan/strategy", planStrategyHandler(lp)}, "vehicle": {"POST", "/vehicle/{name:[a-zA-Z0-9_.:-]+}", vehicleSelectHandler(site, lp)}, "vehicle2": {"DELETE", "/vehicle", vehicleRemoveHandler(lp)}, "vehicleDetect": {"PATCH", "/vehicle", vehicleDetectHandler(lp)}, diff --git a/server/http_config_loadpoint_handler.go b/server/http_config_loadpoint_handler.go index af4441b60..90972c6bb 100644 --- a/server/http_config_loadpoint_handler.go +++ b/server/http_config_loadpoint_handler.go @@ -27,7 +27,7 @@ func getLoadpointStaticConfig(lp loadpoint.API) loadpoint.StaticConfig { } func getLoadpointDynamicConfig(lp loadpoint.API) loadpoint.DynamicConfig { - planTime, planEnergy := lp.GetPlanEnergy() + planTime, planPrecondition, planEnergy := lp.GetPlanEnergy() return loadpoint.DynamicConfig{ Title: lp.GetTitle(), DefaultMode: string(lp.GetDefaultMode()), @@ -41,7 +41,7 @@ func getLoadpointDynamicConfig(lp loadpoint.API) loadpoint.DynamicConfig { Soc: lp.GetSocConfig(), PlanEnergy: planEnergy, PlanTime: planTime, - PlanStrategy: lp.GetPlanStrategy(), + PlanPrecondition: int64(planPrecondition.Seconds()), LimitEnergy: lp.GetLimitEnergy(), LimitSoc: lp.GetLimitSoc(), } diff --git a/server/http_loadpoint_handler.go b/server/http_loadpoint_handler.go index 92fbf7d03..a8deae57c 100644 --- a/server/http_loadpoint_handler.go +++ b/server/http_loadpoint_handler.go @@ -5,31 +5,31 @@ import ( "fmt" "net/http" "strconv" + "strings" "time" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/loadpoint" "github.com/evcc-io/evcc/core/site" + "github.com/evcc-io/evcc/util" "github.com/gorilla/mux" ) type PlanResponse struct { - PlanId int `json:"planId"` - PlanTime time.Time `json:"planTime"` - Duration int64 `json:"duration"` - Plan api.Rates `json:"plan"` - Power float64 `json:"power"` + PlanId int `json:"planId"` + PlanTime time.Time `json:"planTime"` + Duration int64 `json:"duration"` + Precondition int64 `json:"precondition"` + Plan api.Rates `json:"plan"` + Power float64 `json:"power"` } type PlanPreviewResponse struct { - PlanTime time.Time `json:"planTime"` - Duration int64 `json:"duration"` - Plan api.Rates `json:"plan"` - Power float64 `json:"power"` - - // TODO remove - Continuous bool `json:"continuous"` - Precondition int64 `json:"precondition"` + PlanTime time.Time `json:"planTime"` + Duration int64 `json:"duration"` + Precondition int64 `json:"precondition"` + Plan api.Rates `json:"plan"` + Power float64 `json:"power"` } // planHandler returns the current plan @@ -40,16 +40,17 @@ func planHandler(lp loadpoint.API) http.HandlerFunc { id := lp.EffectivePlanId() goal, _ := lp.GetPlanGoal() + precondition := lp.GetPlanPreCondDuration() requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower) - strategy := lp.EffectivePlanStrategy() - plan := lp.GetPlan(planTime, requiredDuration, strategy.Precondition, strategy.Continuous) + plan := lp.GetPlan(planTime, requiredDuration, precondition) res := PlanResponse{ - PlanId: id, - PlanTime: planTime, - Duration: int64(requiredDuration.Seconds()), - Plan: plan, - Power: maxPower, + PlanId: id, + PlanTime: planTime, + Duration: int64(requiredDuration.Seconds()), + Precondition: int64(precondition.Seconds()), + Plan: plan, + Power: maxPower, } jsonWrite(w, res) @@ -60,6 +61,7 @@ func planHandler(lp loadpoint.API) http.HandlerFunc { func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) + query := r.URL.Query() planTime, err := time.ParseInLocation(time.RFC3339, vars["time"], nil) if err != nil { @@ -73,6 +75,12 @@ func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc { return } + precondition, err := parseDuration(query.Get("precondition")) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + switch typ := vars["type"]; typ { case "soc": if !lp.SocBasedPlanning() { @@ -91,19 +99,66 @@ func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc { maxPower := lp.EffectiveMaxPower() requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower) - strategy := lp.EffectivePlanStrategy() - - plan := lp.GetPlan(planTime, requiredDuration, strategy.Precondition, strategy.Continuous) + plan := lp.GetPlan(planTime, requiredDuration, precondition) res := PlanPreviewResponse{ - PlanTime: planTime, - Duration: int64(requiredDuration.Seconds()), - Plan: plan, - Power: maxPower, + PlanTime: planTime, + Duration: int64(requiredDuration.Seconds()), + Precondition: int64(precondition.Seconds()), + Plan: plan, + Power: maxPower, + } - // TODO remove - Continuous: strategy.Continuous, - Precondition: int64(strategy.Precondition.Seconds()), + jsonWrite(w, res) + } +} + +func repeatingPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + query := r.URL.Query() + + hourMinute := vars["time"] + tz := vars["tz"] + + var weekdays []int + for weekdayStr := range strings.SplitSeq(vars["weekdays"], ",") { + weekday, err := strconv.Atoi(weekdayStr) + if err != nil { + jsonError(w, http.StatusBadRequest, fmt.Errorf("invalid weekdays format")) + return + } + weekdays = append(weekdays, weekday) + } + + soc, err := strconv.ParseFloat(vars["soc"], 64) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + planTime, err := util.GetNextOccurrence(weekdays, hourMinute, tz) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + precondition, err := parseDuration(query.Get("precondition")) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + maxPower := lp.EffectiveMaxPower() + requiredDuration := lp.GetPlanRequiredDuration(soc, maxPower) + plan := lp.GetPlan(planTime, requiredDuration, precondition) + + res := PlanPreviewResponse{ + PlanTime: planTime, + Duration: int64(requiredDuration.Seconds()), + Precondition: int64(precondition.Seconds()), + Plan: plan, + Power: maxPower, } jsonWrite(w, res) @@ -114,6 +169,7 @@ func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc { func planEnergyHandler(lp loadpoint.API) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) + query := r.URL.Query() ts, err := time.ParseInLocation(time.RFC3339, vars["time"], nil) if err != nil { @@ -127,19 +183,27 @@ func planEnergyHandler(lp loadpoint.API) http.HandlerFunc { return } - if err := lp.SetPlanEnergy(ts, val); err != nil { + precondition, err := parseDuration(query.Get("precondition")) + if err != nil { jsonError(w, http.StatusBadRequest, err) return } - ts, energy := lp.GetPlanEnergy() + if err := lp.SetPlanEnergy(ts, precondition, val); err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + ts, precondition, energy := lp.GetPlanEnergy() res := struct { - Energy float64 `json:"energy"` - Time time.Time `json:"time"` + Energy float64 `json:"energy"` + Precondition int64 `json:"precondition"` + Time time.Time `json:"time"` }{ - Energy: energy, - Time: ts, + Energy: energy, + Precondition: int64(precondition.Seconds()), + Time: ts, } jsonWrite(w, res) @@ -149,12 +213,13 @@ func planEnergyHandler(lp loadpoint.API) http.HandlerFunc { // planRemoveHandler removes plan time func planRemoveHandler(lp loadpoint.API) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if err := lp.SetPlanEnergy(time.Time{}, 0); err != nil { + if err := lp.SetPlanEnergy(time.Time{}, 0, 0); err != nil { jsonError(w, http.StatusBadRequest, err) return } - jsonWrite(w, struct{}{}) + res := struct{}{} + jsonWrite(w, res) } } @@ -186,7 +251,8 @@ func vehicleSelectHandler(site site.API, lp loadpoint.API) http.HandlerFunc { func vehicleRemoveHandler(lp loadpoint.API) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { lp.SetVehicle(nil) - jsonWrite(w, struct{}{}) + res := struct{}{} + jsonWrite(w, res) } } @@ -194,20 +260,7 @@ func vehicleRemoveHandler(lp loadpoint.API) http.HandlerFunc { func vehicleDetectHandler(lp loadpoint.API) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { lp.StartVehicleDetection() - jsonWrite(w, struct{}{}) - } -} - -// planStrategyHandler updates plan strategy for loadpoint -func planStrategyHandler(lp loadpoint.API) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if err := planStrategyHandlerSetter(r, lp.SetPlanStrategy); err != nil { - jsonError(w, http.StatusBadRequest, err) - return - } - - res := planStrategyPayloadFromApi(lp.GetPlanStrategy()) - + res := struct{}{} jsonWrite(w, res) } } diff --git a/server/http_vehicle_handler.go b/server/http_vehicle_handler.go index e857296c6..6a2f9169e 100644 --- a/server/http_vehicle_handler.go +++ b/server/http_vehicle_handler.go @@ -73,6 +73,7 @@ func limitSocHandler(site site.API) http.HandlerFunc { func planSocHandler(site site.API) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) + query := r.URL.Query() v, err := site.Vehicles().ByName(vars["name"]) if err != nil { @@ -92,52 +93,28 @@ func planSocHandler(site site.API) http.HandlerFunc { return } - if err := v.SetPlanSoc(ts, soc); err != nil { - jsonError(w, http.StatusBadRequest, err) - return - } - - ts, soc = v.GetPlanSoc() - - res := struct { - Soc int `json:"soc"` - Time time.Time `json:"time"` - }{ - Soc: soc, - Time: ts, - } - - jsonWrite(w, res) - } -} - -func planStrategyHandlerSetter(r *http.Request, set func(api.PlanStrategy) error) error { - var planStrategy planStrategyPayload - if err := json.NewDecoder(r.Body).Decode(&planStrategy); err != nil { - return err - } - return set(api.PlanStrategy{ - Continuous: planStrategy.Continuous, - Precondition: time.Duration(planStrategy.Precondition) * time.Second, - }) -} - -// updatePlanStrategyHandler updates plan strategy -func updatePlanStrategyHandler(site site.API) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - v, err := site.Vehicles().ByName(vars["name"]) + precondition, err := parseDuration(query.Get("precondition")) if err != nil { jsonError(w, http.StatusBadRequest, err) return } - if err := planStrategyHandlerSetter(r, v.SetPlanStrategy); err != nil { + if err := v.SetPlanSoc(ts, precondition, soc); err != nil { jsonError(w, http.StatusBadRequest, err) return } - res := planStrategyPayloadFromApi(v.GetPlanStrategy()) + ts, precondition, soc = v.GetPlanSoc() + + res := struct { + Soc int `json:"soc"` + Precondition int64 `json:"precondition"` + Time time.Time `json:"time"` + }{ + Soc: soc, + Precondition: int64(precondition.Seconds()), + Time: ts, + } jsonWrite(w, res) } @@ -180,11 +157,12 @@ func planSocRemoveHandler(site site.API) http.HandlerFunc { return } - if err := v.SetPlanSoc(time.Time{}, 0); err != nil { + if err := v.SetPlanSoc(time.Time{}, 0, 0); err != nil { jsonError(w, http.StatusBadRequest, err) return } - jsonWrite(w, struct{}{}) + res := struct{}{} + jsonWrite(w, res) } } diff --git a/server/mcp/openapi.json b/server/mcp/openapi.json index fd302146b..937c2281b 100644 --- a/server/mcp/openapi.json +++ b/server/mcp/openapi.json @@ -594,21 +594,6 @@ }, "type": "object" }, - "PlanStrategy": { - "description": "Charging plan strategy configuration", - "properties": { - "continuous": { - "description": "Force continuous planning", - "type": "boolean" - }, - "precondition": { - "description": "Precondition duration in seconds", - "minimum": 0, - "type": "integer" - } - }, - "type": "object" - }, "Power": { "description": "Power in W", "example": 2500, @@ -653,6 +638,9 @@ "description": "Set plan active.", "type": "boolean" }, + "precondition": { + "$ref": "#/components/schemas/Precondition" + }, "soc": { "$ref": "#/components/schemas/Soc" }, @@ -687,6 +675,9 @@ "energy": { "$ref": "#/components/schemas/Energy" }, + "precondition": { + "$ref": "#/components/schemas/Precondition" + }, "time": { "$ref": "#/components/schemas/Timestamp" } @@ -698,6 +689,9 @@ "url": "https://docs.evcc.io/en/docs/features/plans#create-charging-plan" }, "properties": { + "precondition": { + "$ref": "#/components/schemas/Precondition" + }, "soc": { "$ref": "#/components/schemas/Soc" }, @@ -1429,6 +1423,9 @@ "properties": { "planId": { "$ref": "#/components/schemas/Id" + }, + "precondition": { + "$ref": "#/components/schemas/Precondition" } }, "type": "object" @@ -1609,51 +1606,6 @@ ] } }, - "/loadpoints/{id}/plan/strategy": { - "post": { - "description": "Updates the charging plan strategy for the loadpoint.", - "externalDocs": { - "url": "https://docs.evcc.io/en/docs/features/plans" - }, - "operationId": "setLoadpointPlanStrategy", - "parameters": [ - { - "$ref": "#/components/parameters/id" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PlanStrategy" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "properties": { - "result": { - "$ref": "#/components/schemas/PlanStrategy" - } - }, - "type": "object" - } - } - }, - "description": "Success" - } - }, - "summary": "Set plan strategy", - "tags": [ - "loadpoints" - ] - } - }, "/loadpoints/{id}/priority/{priority}": { "post": { "description": "Set loadpoint priority.", @@ -2550,6 +2502,13 @@ }, { "$ref": "#/components/parameters/timestamp" + }, + { + "in": "query", + "name": "precondition", + "schema": { + "$ref": "#/components/schemas/Precondition" + } } ], "responses": { @@ -2559,15 +2518,7 @@ "schema": { "properties": { "result": { - "properties": { - "soc": { - "$ref": "#/components/schemas/Soc" - }, - "time": { - "$ref": "#/components/schemas/Timestamp" - } - }, - "type": "object" + "$ref": "#/components/schemas/StaticSocPlan" } }, "type": "object" @@ -2582,51 +2533,6 @@ "vehicles" ] } - }, - "/vehicles/{name}/plan/strategy": { - "post": { - "description": "Updates the charging plan strategy for the vehicle.", - "externalDocs": { - "url": "https://docs.evcc.io/en/docs/features/plans" - }, - "operationId": "setVehiclePlanStrategy", - "parameters": [ - { - "$ref": "#/components/parameters/vehicleName" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PlanStrategy" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "properties": { - "result": { - "$ref": "#/components/schemas/PlanStrategy" - } - }, - "type": "object" - } - } - }, - "description": "Success" - } - }, - "summary": "Set plan strategy", - "tags": [ - "vehicles" - ] - } } }, "tags": [ diff --git a/server/mcp/openapi.md b/server/mcp/openapi.md index 98cf41e7f..8200af16a 100644 --- a/server/mcp/openapi.md +++ b/server/mcp/openapi.md @@ -730,28 +730,6 @@ call setLoadpointPhases { } ``` -## setLoadpointPlanStrategy - -Updates the charging plan strategy for the loadpoint. - -**Tags:** loadpoints - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| id | integer | Loadpoint index starting at 1 | -| requestBody | object | The JSON request body. | - -**Example call:** - -```json -call setLoadpointPlanStrategy { - "id": 123, - "requestBody": "..." -} -``` - ## setLoadpointPriority Set loadpoint priority. @@ -1076,28 +1054,6 @@ call setVehicleMinSoc { } ``` -## setVehiclePlanStrategy - -Updates the charging plan strategy for the vehicle. - -**Tags:** vehicles - -**Arguments:** - -| Name | Type | Description | -|------|------|-------------| -| name | string | Vehicle name | -| requestBody | object | The JSON request body. | - -**Example call:** - -```json -call setVehiclePlanStrategy { - "name": "example", - "requestBody": "..." -} -``` - ## setVehicleSocLimit Charging will stop when this SoC is reached. @@ -1131,6 +1087,7 @@ Create charging plan with fixed time and SoC target. | Name | Type | Description | |------|------|-------------| | name | string | Vehicle name | +| precondition | integer | Late charging duration in seconds. | | soc | number | SOC in % | | timestamp | string | Timestamp in RFC3339 format | @@ -1139,6 +1096,7 @@ Create charging plan with fixed time and SoC target. ```json call setVehicleSocPlan { "name": "example", + "precondition": 123, "soc": 123.45, "timestamp": "example" } diff --git a/server/mqtt.go b/server/mqtt.go index b33bcb43a..d3556916c 100644 --- a/server/mqtt.go +++ b/server/mqtt.go @@ -1,6 +1,7 @@ package server import ( + "encoding/json" "fmt" "reflect" "strconv" @@ -251,8 +252,18 @@ func (m *MQTT) listenLoadpointSetters(topic string, site site.API, lp loadpoint. {"smartCostLimit", floatPtrSetter(pass(lp.SetSmartCostLimit))}, {"smartFeedInPriorityLimit", floatPtrSetter(pass(lp.SetSmartFeedInPriorityLimit))}, {"batteryBoost", boolSetter(lp.SetBatteryBoost)}, - {"planStrategy", planStrategySetter(lp.SetPlanStrategy)}, - {"planEnergy", planGoalSetter(lp.SetPlanEnergy)}, + {"planEnergy", func(payload string) error { + var plan struct { + Time time.Time `json:"time"` + Precondition int64 `json:"precondition"` + Value float64 `json:"value"` + } + err := json.Unmarshal([]byte(payload), &plan) + if err == nil { + err = lp.SetPlanEnergy(plan.Time, time.Duration(plan.Precondition)*time.Second, plan.Value) + } + return err + }}, {"vehicle", func(payload string) error { // https://github.com/evcc-io/evcc/issues/11184 empty payload is swallowed by listener if isEmpty(payload) { @@ -278,8 +289,18 @@ func (m *MQTT) listenVehicleSetters(topic string, v vehicle.API) error { for _, s := range []setter{ {"limitSoc", intSetter(pass(v.SetLimitSoc))}, {"minSoc", intSetter(pass(v.SetMinSoc))}, - {"planStrategy", planStrategySetter(v.SetPlanStrategy)}, - {"planSoc", planGoalSetter(v.SetPlanSoc)}, + {"planSoc", func(payload string) error { + var plan struct { + Time time.Time `json:"time"` + Precondition int64 `json:"precondition"` + Value int `json:"value"` + } + err := json.Unmarshal([]byte(payload), &plan) + if err == nil { + err = v.SetPlanSoc(plan.Time, time.Duration(plan.Precondition)*time.Second, plan.Value) + } + return err + }}, } { if err := m.Handler.ListenSetter(topic+"/"+s.topic, s.fun); err != nil { return err diff --git a/server/mqtt_setter.go b/server/mqtt_setter.go index 68fb38259..8541260b6 100644 --- a/server/mqtt_setter.go +++ b/server/mqtt_setter.go @@ -1,12 +1,10 @@ package server import ( - "encoding/json" "slices" "strconv" "time" - "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" "github.com/spf13/cast" ) @@ -65,28 +63,3 @@ func boolSetter(set func(bool) error) func(string) error { func durationSetter(set func(time.Duration) error) func(string) error { return setterFunc(util.ParseDuration, set) } - -func planStrategySetter(set func(api.PlanStrategy) error) func(string) error { - return func(payload string) error { - var planStrategy planStrategyPayload - if err := json.Unmarshal([]byte(payload), &planStrategy); err != nil { - return err - } - - return set(api.PlanStrategy{ - Continuous: planStrategy.Continuous, - Precondition: time.Duration(planStrategy.Precondition) * time.Second, - }) - } -} - -func planGoalSetter[T any](set func(time.Time, T) error) func(string) error { - return func(payload string) error { - var plan planGoal[T] - if err := json.Unmarshal([]byte(payload), &plan); err != nil { - return err - } - - return set(plan.Time, plan.Value) - } -} diff --git a/server/openapi.yaml b/server/openapi.yaml index 45aeb155d..80765b72e 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -398,6 +398,8 @@ paths: properties: planId: $ref: "#/components/schemas/Id" + precondition: + $ref: "#/components/schemas/Precondition" - $ref: "#/components/schemas/PlanRates" /loadpoints/{id}/plan/energy: delete: @@ -486,33 +488,6 @@ paths: responses: "200": $ref: "#/components/responses/PlanRatesResult" - /loadpoints/{id}/plan/strategy: - post: - operationId: setLoadpointPlanStrategy - summary: Set plan strategy - description: "Updates the charging plan strategy for the loadpoint." - externalDocs: - url: https://docs.evcc.io/en/docs/features/plans - tags: - - loadpoints - parameters: - - $ref: "#/components/parameters/id" - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/PlanStrategy" - responses: - 200: - description: Success - content: - application/json: - schema: - type: object - properties: - result: - $ref: "#/components/schemas/PlanStrategy" /loadpoints/{id}/priority/{priority}: post: operationId: setLoadpointPriority @@ -1105,6 +1080,10 @@ paths: - $ref: "#/components/parameters/vehicleName" - $ref: "#/components/parameters/soc" - $ref: "#/components/parameters/timestamp" + - in: query + name: precondition + schema: + $ref: "#/components/schemas/Precondition" responses: "200": description: Success @@ -1114,39 +1093,7 @@ paths: type: object properties: result: - type: object - properties: - soc: - $ref: "#/components/schemas/Soc" - time: - $ref: "#/components/schemas/Timestamp" - /vehicles/{name}/plan/strategy: - post: - operationId: setVehiclePlanStrategy - summary: Set plan strategy - description: "Updates the charging plan strategy for the vehicle." - externalDocs: - url: https://docs.evcc.io/en/docs/features/plans - tags: - - vehicles - parameters: - - $ref: "#/components/parameters/vehicleName" - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/PlanStrategy" - responses: - 200: - description: Success - content: - application/json: - schema: - type: object - properties: - result: - $ref: "#/components/schemas/PlanStrategy" + $ref: "#/components/schemas/StaticSocPlan" components: schemas: BatteryMode: @@ -1357,17 +1304,6 @@ components: type: array items: $ref: "#/components/schemas/Rate" - PlanStrategy: - description: Charging plan strategy configuration - type: object - properties: - continuous: - description: "Force continuous planning" - type: boolean - precondition: - description: "Precondition duration in seconds" - type: integer - minimum: 0 RepeatingPlan: externalDocs: url: https://docs.evcc.io/en/docs/features/plans#repeating-plans @@ -1376,6 +1312,8 @@ components: active: description: "Set plan active." type: boolean + precondition: + $ref: "#/components/schemas/Precondition" soc: $ref: "#/components/schemas/Soc" time: @@ -1400,6 +1338,8 @@ components: properties: energy: $ref: "#/components/schemas/Energy" + precondition: + $ref: "#/components/schemas/Precondition" time: $ref: "#/components/schemas/Timestamp" StaticSocPlan: @@ -1407,6 +1347,8 @@ components: url: https://docs.evcc.io/en/docs/features/plans#create-charging-plan type: object properties: + precondition: + $ref: "#/components/schemas/Precondition" soc: $ref: "#/components/schemas/Soc" time: diff --git a/server/types.go b/server/types.go deleted file mode 100644 index cb5d9c973..000000000 --- a/server/types.go +++ /dev/null @@ -1,24 +0,0 @@ -package server - -import ( - "time" - - "github.com/evcc-io/evcc/api" -) - -type planStrategyPayload struct { - Continuous bool `json:"continuous"` - Precondition int64 `json:"precondition"` -} - -func planStrategyPayloadFromApi(ps api.PlanStrategy) planStrategyPayload { - return planStrategyPayload{ - Continuous: ps.Continuous, - Precondition: int64(ps.Precondition.Seconds()), - } -} - -type planGoal[T any] struct { - Time time.Time `json:"time"` - Value T `json:"value"` -} diff --git a/tests/plan.evcc.yaml b/tests/plan.evcc.yaml index 127688fd1..344bbedca 100755 --- a/tests/plan.evcc.yaml +++ b/tests/plan.evcc.yaml @@ -16,9 +16,7 @@ meters: loadpoints: - title: Loadpoint charger: charger - mode: pv - title: Loadpoint with SoC - mode: pv charger: chargerSoc chargers: diff --git a/tests/plan.spec.ts b/tests/plan.spec.ts index 5a80050e1..b2d649510 100644 --- a/tests/plan.spec.ts +++ b/tests/plan.spec.ts @@ -79,22 +79,24 @@ test.describe("basic functionality", async () => { await page.getByTestId("static-plan-day").selectOption({ index: 1 }); await page.getByTestId("static-plan-time").fill("09:30"); await page.getByTestId("static-plan-soc").selectOption("80%"); - await page.getByRole("button", { name: "Strategy settings" }).click(); - await expect(page.getByLabel("Late Charging")).toBeVisible(); - await page.getByLabel("Late Charging").selectOption("1 hour"); + await page.getByTestId("static-plan-precondition-lg-toggle").click(); + await page + .getByTestId("static-plan-precondition-lg-select") + .getByRole("combobox") + .selectOption("1 hour"); await page.getByTestId("static-plan-active").click(); await page.getByRole("button", { name: "Close" }).click(); await expect(lp1.getByTestId("plan-marker")).toBeVisible(); await expect(lp1.getByTestId("charging-plan").getByRole("button")).toHaveText( - ["tomorrow 09:30", "80%"].join("") + "tomorrow 09:3080%" ); await expect(lp1.getByTestId("vehicle-status-charger")).toHaveText("Connected."); await expect(lp1.getByTestId("vehicle-status-planstart")).toHaveText(/tomorrow .*/); await expect(lp1.getByTestId("plan-marker")).toBeVisible(); await expect(lp1.getByTestId("charging-plan").getByRole("button")).toHaveText( - ["tomorrow 09:30", "80%"].join("") + "tomorrow 09:3080%" ); await lp1.getByTestId("charging-plan").getByRole("button").click(); await expect(page.getByTestId("static-plan-soc")).toHaveValue("80"); @@ -482,17 +484,26 @@ test.describe("repeating", async () => { await modal.getByRole("checkbox", { name: "Select all" }).check(); await modal.getByTestId("repeating-plan-time").fill("11:11"); - // with multiple plans, preview shows first plan + // switch between previews await page.waitForLoadState("networkidle"); - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Preview plan #1"); + await modal + .getByTestId("plan-preview-title") + .getByRole("combobox") + .selectOption("Preview plan #2"); + await expect(modal.getByTestId("target-text")).toContainText("11:11"); + + await modal + .getByTestId("plan-preview-title") + .getByRole("combobox") + .selectOption("Preview plan #1"); await expect(modal.getByTestId("target-text")).toContainText("09:00"); - // activate #1 - should show next plan #1 + // activate #1 await modal.getByTestId("static-plan-active").click(); await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #1"); await expect(modal.getByTestId("target-text")).toContainText("09:00"); - // deactivate #1, activate #2 - should show next plan #2 + // activate #2 await modal.getByTestId("static-plan-active").click(); await modal.getByTestId("repeating-plan-active").click(); await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); @@ -500,7 +511,9 @@ test.describe("repeating", async () => { // back to preview if no active plan await modal.getByTestId("repeating-plan-active").click(); - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Preview plan #1"); + await expect(modal.getByTestId("plan-preview-title").locator("option:checked")).toHaveText( + "Preview plan #1" + ); await expect(modal.getByTestId("target-text")).toContainText("9:00"); }); @@ -650,9 +663,11 @@ test.describe("repeating", async () => { await plan.getByRole("checkbox", { name: "Select all" }).click(); // uncheck all await plan.getByRole("checkbox", { name: tomorrow }).check(); await plan.getByTestId("repeating-plan-time").fill("09:20"); - await modal.getByRole("button", { name: "Strategy settings" }).click(); - await expect(modal.getByLabel("Late Charging")).toBeVisible(); - await modal.getByLabel("Late Charging").selectOption("2 hours"); + await plan.getByTestId("repeating-plan-precondition-lg-toggle").click(); + await plan + .getByTestId("repeating-plan-precondition-lg-select") + .getByRole("combobox") + .selectOption("2 hours"); await plan.getByTestId("repeating-plan-active").click(); await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); await expect(modal.getByTestId("target-text")).toContainText("09:20"); @@ -674,55 +689,26 @@ test.describe("repeating", async () => { await expect(modal.getByTestId("plan-entry")).toHaveCount(2); await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); await expect(modal.getByTestId("target-text")).toContainText("09:20"); - await expect(modal.getByLabel("Late Charging")).toHaveValue("7200"); + await expect(modal.getByTestId("repeating-plan-precondition-lg-toggle")).toBeChecked(); + await expect( + modal.getByTestId("repeating-plan-precondition-lg-select").locator("option:checked") + ).toHaveText("2 hours"); }); }); // add test for precondition, start with basic.evcc.yaml and verify that precondition toggle element is not visible. make dedicated describe block -test.describe("plan strategy", async () => { +test.describe("precondition", async () => { test("only if dynamic tariff exists", async ({ page }) => { await restart(CONFIG_NO_TARIFF); await page.goto("/"); const lp1 = await page.getByTestId("loadpoint").first(); await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); - const modal = page.getByTestId("charging-plan-modal"); - await expect(modal.getByTestId("static-plan-active")).toBeVisible(); - // Strategy toggle should not be visible when no dynamic tariff exists - await expect(modal.getByRole("button", { name: "Strategy settings" })).not.toBeVisible(); - }); + await expect(page.getByTestId("static-plan-active")).toBeVisible(); + await expect(page.getByTestId("static-plan-precondition-lg-toggle")).not.toBeVisible(); + await expect(page.getByTestId("static-plan-precondition-lg-select")).not.toBeVisible(); - test("visible and functional on mobile", async ({ page }) => { - await page.goto("/"); - - // Set mobile viewport + // verify small viewport await page.setViewportSize(mobile); - - const lp1 = await page.getByTestId("loadpoint").first(); - await lp1 - .getByTestId("change-vehicle") - .locator("select") - .selectOption("Vehicle with SoC with Capacity"); - - await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); - - const modal = page.getByTestId("charging-plan-modal"); - - // Strategy toggle should be visible on mobile - await expect(modal.getByRole("button", { name: "Strategy settings" })).toBeVisible(); - - // Open strategy panel - await modal.getByRole("button", { name: "Strategy settings" }).click(); - - // Strategy controls should be visible and functional - await expect(modal.getByLabel("Optimization")).toBeVisible(); - await expect(modal.getByLabel("Late Charging")).toBeVisible(); - - // Test changing strategy on mobile - await modal.getByLabel("Optimization").selectOption("true"); - await modal.getByLabel("Late Charging").selectOption("1 hour"); - - // Verify the selections work - await expect(modal.getByLabel("Optimization")).toHaveValue("true"); - await expect(modal.getByLabel("Late Charging")).toHaveValue("3600"); + await expect(page.getByTestId("static-plan-precondition-select")).not.toBeVisible(); }); });