From dc6d0565380b8266f3af636a4be5546f45bcf2fe Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 31 Dec 2025 13:19:49 +0100 Subject: [PATCH 1/2] Reapply "Planner: add continuous strategy (BC) (#24423)" This reverts commit 7acc133f3cfa2050b0a9d027d4d5f2e9e79f7b48. --- api/plans.go | 9 +- .../components/ChargingPlans/ChargingPlan.vue | 30 +- .../ChargingPlans/PlanRepeatingSettings.vue | 57 +- .../ChargingPlans/PlanStaticSettings.vue | 62 +- .../components/ChargingPlans/PlanStrategy.vue | 167 ++++ .../ChargingPlans/PlansRepeatingSettings.vue | 2 - .../ChargingPlans/PlansSettings.vue | 149 ++-- .../ChargingPlans/PreconditionSelect.vue | 134 ---- assets/js/components/ChargingPlans/types.d.ts | 6 +- .../Loadpoints/Loadpoint.stories.ts | 5 + assets/js/components/Loadpoints/Loadpoint.vue | 3 +- .../Loadpoints/Loadpoints.stories.ts | 6 + .../js/components/Vehicles/Vehicle.stories.ts | 2 + assets/js/components/Vehicles/Vehicle.vue | 3 +- assets/js/types/evcc.ts | 5 + core/keys/loadpoint.go | 12 +- core/loadpoint.go | 28 +- core/loadpoint/api.go | 14 +- core/loadpoint/config.go | 6 +- core/loadpoint/mock.go | 79 +- core/loadpoint_api.go | 55 +- core/loadpoint_effective.go | 54 +- core/loadpoint_plan.go | 33 +- core/planner/helper.go | 79 ++ core/planner/helper_test.go | 46 ++ core/planner/planner.go | 108 ++- core/planner/planner_continuous_test.go | 717 ++++++++++++++++++ core/planner/planner_test.go | 238 +++++- core/site_vehicles.go | 62 +- core/vehicle/adapter.go | 39 +- core/vehicle/api.go | 9 +- core/vehicle/dummy.go | 14 +- core/vehicle/mock.go | 43 +- i18n/bg.json | 11 +- i18n/cs.json | 11 +- i18n/da.json | 11 +- i18n/de.json | 17 +- i18n/el.json | 11 +- i18n/en.json | 17 +- i18n/fi.json | 11 +- i18n/fr.json | 11 +- i18n/hr.json | 11 +- i18n/hu.json | 11 +- i18n/it.json | 11 +- i18n/lb.json | 11 +- i18n/lt.json | 11 +- i18n/nl.json | 11 +- i18n/pl.json | 11 +- i18n/pt.json | 11 +- i18n/sv.json | 11 +- i18n/ta.json | 11 +- i18n/tr.json | 11 +- i18n/uk.json | 11 +- i18n/zh-Hans.json | 11 +- push/hub.go | 2 +- server/helper.go | 16 - server/http.go | 3 +- server/http_config_loadpoint_handler.go | 4 +- server/http_loadpoint_handler.go | 159 ++-- server/http_vehicle_handler.go | 56 +- server/mcp/openapi.json | 134 +++- server/mcp/openapi.md | 46 +- server/mqtt.go | 29 +- server/mqtt_setter.go | 27 + server/openapi.yaml | 84 +- server/types.go | 24 + tests/plan.evcc.yaml | 2 + tests/plan.spec.ts | 90 ++- 68 files changed, 2245 insertions(+), 950 deletions(-) create mode 100644 assets/js/components/ChargingPlans/PlanStrategy.vue delete mode 100644 assets/js/components/ChargingPlans/PreconditionSelect.vue create mode 100644 core/planner/planner_continuous_test.go create mode 100644 server/types.go diff --git a/api/plans.go b/api/plans.go index 65eb9e40e..33ae03b25 100644 --- a/api/plans.go +++ b/api/plans.go @@ -1,10 +1,17 @@ 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 d043c4a28..61739d167 100644 --- a/assets/js/components/ChargingPlans/ChargingPlan.vue +++ b/assets/js/components/ChargingPlans/ChargingPlan.vue @@ -69,6 +69,7 @@ @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 []; }, @@ -286,15 +292,12 @@ 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, { params }); + api.post(`${this.apiVehicle}plan/soc/${p.soc}/${timeISO}`, null); } else { const p = plan as StaticEnergyPlan; - api.post(`${this.apiLoadpoint}plan/energy/${p.energy}/${timeISO}`, null, { - params, - }); + api.post(`${this.apiLoadpoint}plan/energy/${p.energy}/${timeISO}`, null); } }, removeStaticPlan(): void { @@ -307,6 +310,13 @@ 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 f02f2f54b..a2122d815 100644 --- a/assets/js/components/ChargingPlans/PlanRepeatingSettings.vue +++ b/assets/js/components/ChargingPlans/PlanRepeatingSettings.vue @@ -24,17 +24,12 @@ {{ $t("main.chargingPlan.time") }} -
+
-
- -
-
+
@@ -81,7 +76,7 @@ {{ $t("main.chargingPlan.goal") }}
-
+
-
- -
-
- -
- -
- -
+ + diff --git a/assets/js/components/ChargingPlans/PlansRepeatingSettings.vue b/assets/js/components/ChargingPlans/PlansRepeatingSettings.vue index c07921acc..4162830ac 100644 --- a/assets/js/components/ChargingPlans/PlansRepeatingSettings.vue +++ b/assets/js/components/ChargingPlans/PlansRepeatingSettings.vue @@ -8,7 +8,6 @@ :formIdPrefix="formIdPrefix" v-bind="plan" :rangePerSoc="rangePerSoc" - :showPrecondition="showPrecondition" @updated="updatePlan(index, $event)" @removed="removePlan(index)" /> @@ -49,7 +48,6 @@ 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 bc6199e1e..85f462b25 100644 --- a/assets/js/components/ChargingPlans/PlansSettings.vue +++ b/assets/js/components/ChargingPlans/PlansSettings.vue @@ -11,9 +11,8 @@ :soc-per-kwh="socPerKwh" :soc-based-planning="socBasedPlanning" :multiple-plans="multiplePlans" - :show-precondition="showPrecondition" - @static-plan-updated="(data) => updateStaticPlan({ index: 0, ...data })" - @static-plan-removed="() => removeStaticPlan(0)" + @static-plan-updated="updateStaticPlan" + @static-plan-removed="removeStaticPlan" @plan-preview="previewStaticPlan" />
@@ -30,7 +29,6 @@ :id="id" :rangePerSoc="rangePerSoc" :plans="repeatingPlans" - :show-precondition="showPrecondition" @updated="updateRepeatingPlans" />
@@ -38,29 +36,32 @@
-
+
{{ $t(`main.targetCharge.${noActivePlan ? "preview" : "currentPlan"}`) }} - {{ $t("main.targetCharge.preview") }} #1 + {{ $t("main.targetCharge.goalReached") }} + {{ nextPlanTitle }} +
+ @@ -70,16 +71,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, PartialBy, Timeout, SelectOption, CURRENCY, Forecast } from "@/types/evcc"; +import type { Vehicle, Timeout, CURRENCY, Forecast } from "@/types/evcc"; import type { StaticPlan, RepeatingPlan, @@ -87,6 +88,7 @@ import type { StaticSocPlan, StaticEnergyPlan, PlanResponse, + PlanStrategy, } from "./types"; export default defineComponent({ @@ -94,9 +96,9 @@ export default defineComponent({ components: { ChargingPlanPreview: Preview, ChargingPlanStaticSettings: PlanStaticSettings, + ChargingPlanStrategy, ChargingPlansRepeatingSettings: RepeatingSettings, ChargingPlanWarnings: Warnings, - CustomSelect, }, mixins: [formatter, collector], props: { @@ -106,6 +108,8 @@ export default defineComponent({ effectiveLimitSoc: Number, effectivePlanTime: String, effectivePlanSoc: Number, + effectivePlanPrecondition: Number, + effectivePlanContinuous: Boolean, planEnergy: Number, limitEnergy: Number, socBasedPlanning: Boolean, @@ -120,15 +124,20 @@ export default defineComponent({ planOverrun: Number, forecast: Object as PropType, }, - emits: ["static-plan-removed", "static-plan-updated", "repeating-plans-updated"], + emits: [ + "static-plan-removed", + "static-plan-updated", + "repeating-plans-updated", + "plan-strategy-updated", + ], data() { return { staticPlanPreview: {} as StaticPlan, plan: {} as PlanWrapper, activeTab: "time", debounceTimer: null as Timeout, - selectedPreviewId: 1, nextPlanId: 0, + strategyOpen: false, }; }, computed: { @@ -138,9 +147,6 @@ 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); }, @@ -154,23 +160,12 @@ export default defineComponent({ ? { duration, plan, power, rates, targetTime, currency, smartCostType } : null; }, - 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; + chargingPlanStrategyProps(): any { + return { + id: this.id, + precondition: this.effectivePlanPrecondition, + continuous: this.effectivePlanContinuous, + }; }, alreadyReached(): boolean { return this.plan.duration === 0; @@ -178,7 +173,7 @@ export default defineComponent({ nextPlanTitle(): string { return `${this.$t("main.targetCharge.nextPlan")} #${this.nextPlanId}`; }, - showPrecondition(): boolean { + showStrategy(): boolean { // only show option if planner forecast has different values const slots = this.forecast?.planner || []; const values = new Set(slots.map(({ value }) => value)); @@ -191,6 +186,12 @@ export default defineComponent({ this.updatePlanDebounced(); } }, + effectivePlanPrecondition() { + this.updatePlanDebounced(); + }, + effectivePlanContinuous() { + this.updatePlanDebounced(); + }, staticPlan: { deep: true, handler(vNew: StaticPlan, vOld: StaticPlan) { @@ -203,7 +204,6 @@ export default defineComponent({ deep: true, handler(vNew: RepeatingPlan[], vOld: RepeatingPlan[]) { if (!deepEqual(vNew, vOld)) { - this.adjustPreviewId(); this.updatePlanDebounced(); } }, @@ -213,10 +213,6 @@ export default defineComponent({ this.updatePlanDebounced(); }, methods: { - selectPreviewPlan(id: number): void { - this.selectedPreviewId = id; - this.updatePlanDebounced(); - }, async updatePlanDebounced() { if (this.noActivePlan) { await this.updatePlanPreviewDebounced(); @@ -224,11 +220,6 @@ 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`); @@ -240,22 +231,15 @@ export default defineComponent({ }, async fetchStaticPreviewSoc(plan: StaticSocPlan): Promise { const timeISO = plan.time.toISOString(); - const params = plan.precondition ? { precondition: plan.precondition } : undefined; + const params: Record = {}; 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 = plan.precondition ? { precondition: plan.precondition } : undefined; + const params: Record = {}; return await this.apiFetchPlan( `loadpoints/${this.id}/plan/static/preview/energy/${plan.energy}/${timeISO}`, params @@ -285,8 +269,7 @@ export default defineComponent({ try { let planRes: PlanResponse | undefined = undefined; - - if (this.selectedPreviewId < 2 && this.staticPlanPreview) { + if (this.staticPlanPreview) { // static plan let plan = this.staticPlanPreview; if (this.socBasedPlanning) { @@ -294,33 +277,14 @@ 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) { @@ -343,8 +307,8 @@ export default defineComponent({ clearTimeout(this.debounceTimer); this.debounceTimer = setTimeout(async () => await this.updateActivePlan(), 1000); }, - removeStaticPlan(index: number): void { - this.$emit("static-plan-removed", index); + removeStaticPlan(): void { + this.$emit("static-plan-removed"); }, updateStaticPlan(plan: StaticPlan): void { this.$emit("static-plan-updated", plan); @@ -356,6 +320,9 @@ export default defineComponent({ this.staticPlanPreview = plan; this.updatePlanPreviewDebounced(); }, + updatePlanStrategy(strategy: PlanStrategy): void { + this.$emit("plan-strategy-updated", strategy); + }, }, }); @@ -364,13 +331,13 @@ export default defineComponent({ h5 { position: relative; display: flex; - top: -25px; + top: -33px; margin-bottom: -0.5rem; padding: 0 0.5rem; justify-content: center; } h5 .inner { - padding: 0 0.5rem; + padding: 0 1rem; 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 deleted file mode 100644 index b132e642f..000000000 --- a/assets/js/components/ChargingPlans/PreconditionSelect.vue +++ /dev/null @@ -1,134 +0,0 @@ - - - - diff --git a/assets/js/components/ChargingPlans/types.d.ts b/assets/js/components/ChargingPlans/types.d.ts index 2a667897f..91031a05c 100644 --- a/assets/js/components/ChargingPlans/types.d.ts +++ b/assets/js/components/ChargingPlans/types.d.ts @@ -6,7 +6,6 @@ export interface RepeatingPlan { tz: string; // timezone like "Europe/Berlin" soc: number; active: boolean; - precondition: number; } export interface PlanWrapper { @@ -27,11 +26,14 @@ 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 f953f1281..08238fad5 100644 --- a/assets/js/components/Loadpoints/Loadpoint.stories.ts +++ b/assets/js/components/Loadpoints/Loadpoint.stories.ts @@ -52,6 +52,7 @@ const baseState = { planEnergy: 0, planOverrun: 0, planPrecondition: 0, + planContinuous: false, planProjectedEnd: undefined, planProjectedStart: undefined, planTime: undefined, @@ -82,6 +83,8 @@ const baseState = { capacity: 8, features: ["Offline"], repeatingPlans: [], + planPrecondition: 0, + planContinuous: false, }, { name: "vehicle_4", @@ -90,6 +93,8 @@ 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 63197b9a9..907136581 100644 --- a/assets/js/components/Loadpoints/Loadpoint.vue +++ b/assets/js/components/Loadpoints/Loadpoint.vue @@ -178,10 +178,11 @@ 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 d303d680b..1137b279c 100644 --- a/assets/js/components/Loadpoints/Loadpoints.stories.ts +++ b/assets/js/components/Loadpoints/Loadpoints.stories.ts @@ -48,6 +48,8 @@ const baseArgs = { capacity: 8, features: ["Offline"], repeatingPlans: [], + planPrecondition: 0, + planContinuous: false, }, { name: "vehicle_4", @@ -56,6 +58,8 @@ const baseArgs = { capacity: 80, features: ["Offline"], repeatingPlans: [], + planPrecondition: 0, + planContinuous: false, }, { name: "vehicle_5", @@ -64,6 +68,8 @@ 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 b1f0b54a0..d26fa0111 100644 --- a/assets/js/components/Vehicles/Vehicle.stories.ts +++ b/assets/js/components/Vehicles/Vehicle.stories.ts @@ -10,6 +10,8 @@ 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 d2bdfe30a..acd160a69 100644 --- a/assets/js/components/Vehicles/Vehicle.vue +++ b/assets/js/components/Vehicles/Vehicle.vue @@ -103,6 +103,8 @@ export default defineComponent({ effectiveLimitSoc: Number, effectivePlanSoc: Number, effectivePlanTime: String, + effectivePlanPrecondition: Number, + effectivePlanContinuous: Boolean, batteryBoostActive: Boolean, enabled: Boolean, heating: Boolean, @@ -120,7 +122,6 @@ 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 03f5c85f2..1d3569526 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -246,6 +246,8 @@ export interface Loadpoint { effectivePlanId: number; effectivePlanSoc: number; effectivePlanTime: string | null; + effectivePlanPrecondition: number; + effectivePlanContinuous: boolean; effectivePriority: number; enableDelay: number; enableThreshold: number; @@ -264,6 +266,7 @@ export interface Loadpoint { planEnergy: number; planOverrun: number; planPrecondition: number; + planContinuous: boolean; planProjectedEnd: string | null; planProjectedStart: string | null; planTime: string | null; @@ -473,6 +476,8 @@ 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 dc59e5d92..7b6598515 100644 --- a/core/keys/loadpoint.go +++ b/core/keys/loadpoint.go @@ -57,7 +57,11 @@ const ( EffectivePlanSoc = "effectivePlanSoc" // effective plan soc EffectiveMinCurrent = "effectiveMinCurrent" // effective min current EffectiveMaxCurrent = "effectiveMaxCurrent" // effective max current - EffectiveLimitSoc = "effectiveLimitSoc" // effective limit soc + + 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 // measurements ChargePower = "chargePower" // charge power @@ -73,15 +77,17 @@ 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 - Plan = "plan" // charge plan time slots + PlanStrategy = "planStrategy" // charge plan strategy (precondition, continuous) + PlanPrecondition = "planPrecondition" // charge plan precondition duration + PlanContinuous = "planContinuous" // charge plan continuous planning // repeating plans RepeatingPlans = "repeatingPlans" // key to access all repeating plans in db diff --git a/core/loadpoint.go b/core/loadpoint.go index 2929db407..207befe21 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 - 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 + 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 // cached state status api.ChargeStatus // Charger status @@ -370,9 +370,14 @@ 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, time.Duration(d)*time.Second, v) + 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) } } @@ -686,7 +691,8 @@ 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, lp.planPrecondition) + lp.publish(keys.PlanPrecondition, int64(lp.planStrategy.Precondition.Seconds())) + lp.publish(keys.PlanContinuous, lp.planStrategy.Continuous) lp.publish(keys.LimitSoc, lp.limitSoc) lp.publish(keys.LimitEnergy, lp.limitEnergy) @@ -983,7 +989,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 036cfe80f..55ba3b84e 100644 --- a/core/loadpoint/api.go +++ b/core/loadpoint/api.go @@ -108,6 +108,8 @@ 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() @@ -116,19 +118,21 @@ type API interface { // // GetPlanEnergy returns the charge plan energy - GetPlanEnergy() (time.Time, time.Duration, float64) + GetPlanEnergy() (time.Time, float64) // SetPlanEnergy sets the charge plan energy - SetPlanEnergy(time.Time, time.Duration, float64) error + SetPlanEnergy(time.Time, float64) error // GetPlanGoal returns the plan goal and if the goal is soc based GetPlanGoal() (float64, bool) // GetPlanRequiredDuration returns required duration of plan to reach the goal from current state GetPlanRequiredDuration(goal, maxPower float64) time.Duration - // GetPlanPreCondDuration returns the precondition duration - GetPlanPreCondDuration() time.Duration + // GetPlanStrategy returns the plan strategy + GetPlanStrategy() api.PlanStrategy + // SetPlanStrategy sets the plan strategy + SetPlanStrategy(api.PlanStrategy) error // SocBasedPlanning determines if the planner is soc based SocBasedPlanning() bool // GetPlan creates a charging plan - GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration) api.Rates + GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration, continuous bool) api.Rates // GetSocConfig returns the soc poll settings GetSocConfig() SocConfig diff --git a/core/loadpoint/config.go b/core/loadpoint/config.go index 3bc38d83d..86e326f46 100644 --- a/core/loadpoint/config.go +++ b/core/loadpoint/config.go @@ -27,10 +27,11 @@ 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"` } @@ -59,7 +60,8 @@ func (payload DynamicConfig) Apply(lp API) error { lp.SetSmartCostLimit(payload.SmartCostLimit) lp.SetSmartFeedInPriorityLimit(payload.SmartFeedInPriorityLimit) lp.SetThresholds(payload.Thresholds) - lp.SetPlanEnergy(payload.PlanTime, time.Duration(payload.PlanPrecondition)*time.Second, payload.PlanEnergy) + lp.SetPlanEnergy(payload.PlanTime, payload.PlanEnergy) + lp.SetPlanStrategy(payload.PlanStrategy) lp.SetLimitEnergy(payload.LimitEnergy) lp.SetLimitSoc(payload.LimitSoc) diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go index c4436f062..61e93af70 100644 --- a/core/loadpoint/mock.go +++ b/core/loadpoint/mock.go @@ -111,6 +111,20 @@ 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() @@ -434,27 +448,26 @@ func (mr *MockAPIMockRecorder) GetPhasesConfigured() *gomock.Call { } // GetPlan mocks base method. -func (m *MockAPI) GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration) api.Rates { +func (m *MockAPI) GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration, continuous bool) api.Rates { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetPlan", targetTime, requiredDuration, precondition) + ret := m.ctrl.Call(m, "GetPlan", targetTime, requiredDuration, precondition, continuous) ret0, _ := ret[0].(api.Rates) return ret0 } // GetPlan indicates an expected call of GetPlan. -func (mr *MockAPIMockRecorder) GetPlan(targetTime, requiredDuration, precondition any) *gomock.Call { +func (mr *MockAPIMockRecorder) GetPlan(targetTime, requiredDuration, precondition, continuous any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlan", reflect.TypeOf((*MockAPI)(nil).GetPlan), targetTime, requiredDuration, precondition) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlan", reflect.TypeOf((*MockAPI)(nil).GetPlan), targetTime, requiredDuration, precondition, continuous) } // GetPlanEnergy mocks base method. -func (m *MockAPI) GetPlanEnergy() (time.Time, time.Duration, float64) { +func (m *MockAPI) GetPlanEnergy() (time.Time, float64) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPlanEnergy") ret0, _ := ret[0].(time.Time) - ret1, _ := ret[1].(time.Duration) - ret2, _ := ret[2].(float64) - return ret0, ret1, ret2 + ret1, _ := ret[1].(float64) + return ret0, ret1 } // GetPlanEnergy indicates an expected call of GetPlanEnergy. @@ -478,20 +491,6 @@ 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() @@ -506,6 +505,20 @@ 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() @@ -901,17 +914,31 @@ func (mr *MockAPIMockRecorder) SetPhasesConfigured(arg0 any) *gomock.Call { } // SetPlanEnergy mocks base method. -func (m *MockAPI) SetPlanEnergy(arg0 time.Time, arg1 time.Duration, arg2 float64) error { +func (m *MockAPI) SetPlanEnergy(arg0 time.Time, arg1 float64) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetPlanEnergy", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "SetPlanEnergy", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // SetPlanEnergy indicates an expected call of SetPlanEnergy. -func (mr *MockAPIMockRecorder) SetPlanEnergy(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockAPIMockRecorder) SetPlanEnergy(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanEnergy", reflect.TypeOf((*MockAPI)(nil).SetPlanEnergy), arg0, arg1, arg2) + 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) } // SetPriority mocks base method. diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index b9d4e5273..4eb9e8eac 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, time.Duration, float64) { +func (lp *Loadpoint) GetPlanEnergy() (time.Time, float64) { lp.RLock() defer lp.RUnlock() return lp.getPlanEnergy() } // getPlanEnergy returns plan target energy -func (lp *Loadpoint) getPlanEnergy() (time.Time, time.Duration, float64) { - return lp.planTime, lp.planPrecondition, lp.planEnergy +func (lp *Loadpoint) getPlanEnergy() (time.Time, float64) { + return lp.planTime, lp.planEnergy } // setPlanEnergy sets plan target energy (no mutex) -func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, precondition time.Duration, energy float64) { +func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, energy float64) { lp.planEnergy = energy lp.publish(keys.PlanEnergy, energy) lp.settings.SetFloat(keys.PlanEnergy, energy) @@ -363,16 +363,12 @@ func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, precondition time.Duratio // 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) @@ -380,7 +376,7 @@ func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, precondition time.Duratio } // SetPlanEnergy sets plan target energy -func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, precondition time.Duration, energy float64) error { +func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, energy float64) error { lp.Lock() defer lp.Unlock() @@ -391,14 +387,51 @@ func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, precondition time.Duratio lp.log.DEBUG.Printf("set plan energy: %.3gkWh @ %v", energy, finishAt.Round(time.Second).Local()) // apply immediately - if lp.planEnergy != energy || lp.planPrecondition != precondition || !lp.planTime.Equal(finishAt) { - lp.setPlanEnergy(finishAt, precondition, energy) + if lp.planEnergy != energy || !lp.planTime.Equal(finishAt) { + lp.setPlanEnergy(finishAt, 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 010454b84..358f1d038 100644 --- a/core/loadpoint_effective.go +++ b/core/loadpoint_effective.go @@ -12,6 +12,7 @@ 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()) @@ -19,6 +20,8 @@ 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 @@ -32,11 +35,10 @@ func (lp *Loadpoint) EffectivePriority() int { } type plan struct { - Id int - Start time.Time // last possible start time - End time.Time // user-selected finish time - Precondition time.Duration - Soc int + Id int + Start time.Time // last possible start time + End time.Time // user-selected finish time + Soc int } func (lp *Loadpoint) nextActivePlan(maxPower float64, plans []plan) *plan { @@ -60,20 +62,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, time.Duration, int, int) { +func (lp *Loadpoint) NextVehiclePlan() (time.Time, int, int) { lp.RLock() defer lp.RUnlock() return lp.nextVehiclePlan() } -// nextVehiclePlan returns the next vehicle plan time, precondition duration, soc and id -func (lp *Loadpoint) nextVehiclePlan() (time.Time, time.Duration, int, int) { +// nextVehiclePlan returns the next vehicle plan time, soc, id +func (lp *Loadpoint) nextVehiclePlan() (time.Time, int, int) { if v := lp.GetVehicle(); v != nil { var plans []plan // static plan - if planTime, precondition, soc := vehicle.Settings(lp.log, v).GetPlanSoc(); soc != 0 { - plans = append(plans, plan{Id: 1, Precondition: precondition, Soc: soc, End: planTime}) + if planTime, soc := vehicle.Settings(lp.log, v).GetPlanSoc(); soc != 0 { + plans = append(plans, plan{Id: 1, Soc: soc, End: planTime}) } // repeating plans @@ -88,28 +90,27 @@ func (lp *Loadpoint) nextVehiclePlan() (time.Time, time.Duration, int, int) { continue } - precondition := time.Duration(rp.Precondition) * time.Second - plans = append(plans, plan{Id: index + 2, Precondition: precondition, Soc: rp.Soc, End: planTime}) + plans = append(plans, plan{Id: index + 2, Soc: rp.Soc, End: planTime}) } // calculate earliest required plan start if plan := lp.nextActivePlan(lp.effectiveMaxPower(), plans); plan != nil { - return plan.End, plan.Precondition, plan.Soc, plan.Id + return plan.End, plan.Soc, plan.Id } } - return time.Time{}, 0, 0, 0 + return time.Time{}, 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 { @@ -122,11 +123,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 } @@ -239,3 +240,20 @@ 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 608ff85b9..7e2839966 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, 0) + lp.setPlanEnergy(time.Time{}, 0) } else if v := lp.GetVehicle(); v != nil { - vehicle.Settings(lp.log, v).SetPlanSoc(time.Time{}, 0, 0) + vehicle.Settings(lp.log, v).SetPlanSoc(time.Time{}, 0) } } @@ -67,36 +67,25 @@ 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) api.Rates { +func (lp *Loadpoint) GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration, continuous bool) api.Rates { if lp.planner == nil || targetTime.IsZero() { return nil } - return lp.planner.Plan(requiredDuration, precondition, targetTime) + 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) } // plannerActive checks if the charging plan has a currently active slot @@ -146,7 +135,9 @@ func (lp *Loadpoint) plannerActive() (active bool) { return false } - plan = lp.GetPlan(planTime, requiredDuration, lp.GetPlanPreCondDuration()) + strategy := lp.getEffectivePlanStrategy() + + plan = lp.GetPlan(planTime, requiredDuration, strategy.Precondition, strategy.Continuous) if plan == nil { return false } diff --git a/core/planner/helper.go b/core/planner/helper.go index aebf48061..1b12f5818 100644 --- a/core/planner/helper.go +++ b/core/planner/helper.go @@ -4,6 +4,7 @@ import ( "time" "github.com/evcc-io/evcc/api" + "github.com/samber/lo" ) // Start returns the earliest slot's start time @@ -46,6 +47,9 @@ func AverageCost(plan api.Rates) float64 { duration += slotDuration cost += float64(slotDuration) * slot.Value } + if duration == 0 { + return 0 + } return cost / float64(duration) } @@ -80,3 +84,78 @@ 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 2206e7a17..92d656225 100644 --- a/core/planner/helper_test.go +++ b/core/planner/helper_test.go @@ -47,3 +47,49 @@ 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 626898162..d27a2f88b 100644 --- a/core/planner/planner.go +++ b/core/planner/planner.go @@ -32,27 +32,13 @@ 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 established that +// It MUST already be established that: // - rates are sorted in ascending order by cost and descending order by start time (prefer late slots) -// - target time and required duration are before end of rates +// - rates are filtered to [now, targetTime] window by caller func (t *Planner) plan(rates api.Rates, requiredDuration time.Duration, targetTime time.Time) api.Rates { var plan api.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 - } - + for _, slot := range rates { slotDuration := slot.End.Sub(slot.Start) requiredDuration -= slotDuration @@ -65,10 +51,6 @@ 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) @@ -82,34 +64,9 @@ func (t *Planner) plan(rates api.Rates, requiredDuration time.Duration, targetTi return plan } -// 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) - } +// continuousPlan creates a continuous emergency charging plan +func continuousPlan(rates api.Rates, start, end time.Time) api.Rates { + res := clampRates(rates, start, end) if len(res) == 0 { return []api.Rate{{ @@ -136,14 +93,16 @@ func (t *Planner) continuousPlan(rates api.Rates, start, end time.Time) api.Rate return res } -func (t *Planner) Plan(requiredDuration, precondition time.Duration, targetTime time.Time) api.Rates { +func (t *Planner) Plan(requiredDuration, precondition time.Duration, targetTime time.Time, continuous bool) api.Rates { if t == nil || requiredDuration <= 0 { return nil } + now := t.clock.Now().Truncate(time.Second) + latestStart := targetTime.Add(-requiredDuration) - if latestStart.Before(t.clock.Now()) { - latestStart = t.clock.Now() + if latestStart.Before(now) { + latestStart = now targetTime = latestStart.Add(requiredDuration) } @@ -169,15 +128,7 @@ func (t *Planner) Plan(requiredDuration, precondition time.Duration, targetTime // consume remaining time if t.clock.Until(targetTime) <= requiredDuration { - 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 - } + return continuousPlan(rates, latestStart, targetTime) } // rates are by default sorted by date, oldest to newest @@ -192,7 +143,7 @@ func (t *Planner) Plan(requiredDuration, precondition time.Duration, targetTime } // need to use some of the available slots - t.log.DEBUG.Printf("target time beyond available slots- reducing plan horizon from %v to %v", + t.log.DEBUG.Printf("planner: target time beyond available slots- reducing plan horizon from %v to %v", requiredDuration.Round(time.Second), durationAfterRates.Round(time.Second)) targetTime = last @@ -200,22 +151,51 @@ 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 requiredDuration > 0 { + 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 { // 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 new file mode 100644 index 000000000..ff383ea87 --- /dev/null +++ b/core/planner/planner_continuous_test.go @@ -0,0 +1,717 @@ +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 b2f28d2d7..e3825e21b 100644 --- a/core/planner/planner_test.go +++ b/core/planner/planner_test.go @@ -7,6 +7,7 @@ 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" @@ -17,7 +18,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) * time.Hour) + slotStart := start.Add(time.Duration(i) * slotDuration) ar := api.Rate{ Start: slotStart, End: slotStart.Add(slotDuration), @@ -29,15 +30,16 @@ func rates(prices []float64, start time.Time, slotDuration time.Duration) api.Ra return res } -// func dumpRates(rr api.Rates) string { -// var b []byte -// for _, r := range rr { -// b = fmt.Appendf(b, "%+v\n", r) -// } -// return string(b) -// } +func TestClampRates(t *testing.T) { + clock := clock.NewMock() + rr := rates([]float64{0, 1}, clock.Now(), time.Hour) -// TODO start before start of rates + 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))) +} func TestPlan(t *testing.T) { clock := clock.NewMock() @@ -57,7 +59,8 @@ func TestPlan(t *testing.T) { slices.SortStableFunc(rates, sortByCost) { - plan := p.plan(rates, time.Hour, clock.Now()) + // filter rates to [now, now] window - should return empty + plan := p.plan(clampRates(rates, clock.Now(), clock.Now()), time.Hour, clock.Now()) assert.Empty(t, plan) } @@ -125,7 +128,8 @@ func TestPlan(t *testing.T) { for i, tc := range tc { t.Log(tc.desc) clock.Set(tc.now) - plan := p.plan(rates, tc.duration, tc.target) + // filter rates to [now, target] window as caller would do + plan := p.plan(clampRates(rates, tc.now, tc.target), 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) @@ -141,7 +145,7 @@ func TestNilTariff(t *testing.T) { clock: clock, } - plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute)) + plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute), false) assert.Equal(t, api.Rates{ { Start: clock.Now(), @@ -163,7 +167,7 @@ func TestRatesError(t *testing.T) { tariff: trf, } - plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute)) + plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute), false) assert.Equal(t, api.Rates{ { Start: clock.Now(), @@ -192,10 +196,10 @@ func TestFlatTariffTargetInThePast(t *testing.T) { }, } - plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute)) + plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute), false) assert.Equal(t, simplePlan, plan, "expected simple plan") - plan = p.Plan(time.Hour, 0, clock.Now().Add(-30*time.Minute)) + plan = p.Plan(time.Hour, 0, clock.Now().Add(-30*time.Minute), false) assert.Equal(t, simplePlan, plan, "expected simple plan") } @@ -216,12 +220,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)) + plan := p.Plan(time.Hour, 0, clock.Now().Add(2*time.Hour), false) 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)) + plan = p.Plan(time.Hour, 0, clock.Now().Add(time.Hour), false) assert.Equal(t, api.Rate{Start: clock.Now(), End: clock.Now().Add(time.Hour)}, SlotAt(clock.Now(), plan)) } @@ -238,10 +242,10 @@ func TestTargetAfterKnownPrices(t *testing.T) { tariff: trf, } - plan := p.Plan(40*time.Minute, 0, clock.Now().Add(2*time.Hour)) // charge efficiency does not allow to test with 1h + plan := p.Plan(40*time.Minute, 0, clock.Now().Add(2*time.Hour), false) // 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)) + plan = p.Plan(2*time.Hour, 0, clock.Now().Add(2*time.Hour), false) assert.True(t, !SlotAt(clock.Now(), plan).IsZero(), "should start if car can not be charged completely after known prices ") } @@ -265,19 +269,18 @@ func TestChargeAfterTargetTime(t *testing.T) { }, } - plan := p.Plan(time.Hour, 0, clock.Now()) + plan := p.Plan(time.Hour, 0, clock.Now(), false) assert.Equal(t, simplePlan, plan, "expected simple plan") - plan = p.Plan(time.Hour, 0, clock.Now().Add(-time.Hour)) + plan = p.Plan(time.Hour, 0, clock.Now().Add(-time.Hour), false) 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(), time.Hour), nil) + trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{1, 2, 3, 4}, clock.Now(), tariff.SlotDuration), nil) p := &Planner{ log: util.NewLogger("foo"), @@ -285,54 +288,120 @@ func TestPrecondition(t *testing.T) { tariff: trf, } - plan := p.Plan(time.Hour, time.Hour, clock.Now().Add(4*time.Hour)) + plan := p.Plan(tariff.SlotDuration, tariff.SlotDuration, clock.Now().Add(4*tariff.SlotDuration), false) assert.Equal(t, api.Rates{ { - Start: clock.Now().Add(3 * time.Hour), - End: clock.Now().Add(4 * time.Hour), + Start: clock.Now().Add(3 * tariff.SlotDuration), + End: clock.Now().Add(4 * tariff.SlotDuration), Value: 4, }, }, plan, "expected last slot") - // 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)) + plan = p.Plan(2*tariff.SlotDuration, tariff.SlotDuration, clock.Now().Add(4*tariff.SlotDuration), false) + assert.Equal(t, api.Rates{ { Start: clock.Now(), - End: clock.Now().Add(1 * time.Hour), + End: clock.Now().Add(1 * tariff.SlotDuration), Value: 1, }, { - Start: clock.Now().Add(3 * time.Hour), - End: clock.Now().Add(4 * time.Hour), + Start: clock.Now().Add(3 * tariff.SlotDuration), + End: clock.Now().Add(4 * tariff.SlotDuration), Value: 4, }, }, plan, "expected two slots") - plan = p.Plan(time.Hour, 30*time.Minute, clock.Now().Add(4*time.Hour)) + plan = p.Plan(time.Duration(1.5*float64(tariff.SlotDuration)), tariff.SlotDuration, clock.Now().Add(4*tariff.SlotDuration), false) assert.Equal(t, api.Rates{ { Start: clock.Now(), - End: clock.Now().Add(30 * time.Minute), + End: clock.Now().Add(time.Duration(0.5 * float64(tariff.SlotDuration))), Value: 1, }, { - Start: clock.Now().Add(210 * time.Minute), // 3.5h - End: clock.Now().Add(4 * time.Hour), // 4.0h + Start: clock.Now().Add(3 * tariff.SlotDuration), + End: clock.Now().Add(4 * tariff.SlotDuration), Value: 4, }, - }, plan, "expected short early and split late slot") + }, plan, "expected trimmed slot at beginning and precondition slot") - plan = p.Plan(time.Hour, 24*time.Hour, clock.Now().Add(4*time.Hour)) + plan = p.Plan(tariff.SlotDuration, 24*time.Hour, clock.Now().Add(time.Hour), false) assert.Equal(t, api.Rates{ { - Start: clock.Now().Add(3 * time.Hour), - End: clock.Now().Add(4 * time.Hour), + Start: clock.Now().Add(3 * tariff.SlotDuration), + End: clock.Now().Add(4 * tariff.SlotDuration), 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() @@ -341,7 +410,7 @@ func TestContinuousPlanNoTariff(t *testing.T) { clock: clock, } - plan := p.Plan(time.Hour, 0, clock.Now()) + plan := p.Plan(time.Hour, 0, clock.Now(), false) // single-slot plan assert.Len(t, plan, 1) @@ -362,7 +431,7 @@ func TestContinuousPlan(t *testing.T) { tariff: trf, } - plan := p.Plan(150*time.Minute, 0, clock.Now()) + plan := p.Plan(150*time.Minute, 0, clock.Now(), false) // 3-slot plan assert.Len(t, plan, 3) @@ -381,8 +450,93 @@ func TestContinuousPlanOutsideRates(t *testing.T) { tariff: trf, } - plan := p.Plan(30*time.Minute, 0, clock.Now()) + plan := p.Plan(30*time.Minute, 0, clock.Now(), false) // 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 043a669f4..f4fc55215 100644 --- a/core/site_vehicles.go +++ b/core/site_vehicles.go @@ -14,24 +14,26 @@ 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"` - 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"` + 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"` } // publishVehicles returns a list of vehicle titles @@ -46,30 +48,32 @@ func (site *Site) publishVehicles() { } ac := instance.OnIdentified() + strategy := v.GetPlanStrategy() var plan *planStruct - if time, precondition, soc := v.GetPlanSoc(); !time.IsZero() { - plan = &planStruct{Soc: soc, Precondition: int64(precondition.Seconds()), Time: time} + if time, soc := v.GetPlanSoc(); !time.IsZero() { + plan = &planStruct{Soc: soc, Precondition: int64(strategy.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, - 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(), + 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, } if lp := site.coordinator.Owner(instance); lp != nil { - lp.PublishEffectiveValues() + go lp.PublishEffectiveValues() } } diff --git a/core/vehicle/adapter.go b/core/vehicle/adapter.go index e801c26f5..40fbaf5eb 100644 --- a/core/vehicle/adapter.go +++ b/core/vehicle/adapter.go @@ -71,24 +71,20 @@ func (v *adapter) SetLimitSoc(soc int) { } // GetPlanSoc returns the charge plan soc -func (v *adapter) GetPlanSoc() (time.Time, time.Duration, int) { +func (v *adapter) GetPlanSoc() (time.Time, 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, precondition, soc + return ts, soc } // SetPlanSoc sets the charge plan soc -func (v *adapter) SetPlanSoc(ts time.Time, precondition time.Duration, soc int) error { +func (v *adapter) SetPlanSoc(ts time.Time, soc int) error { if !ts.IsZero() && ts.Before(time.Now()) { return errors.New("timestamp is in the past") } @@ -98,11 +94,10 @@ func (v *adapter) SetPlanSoc(ts time.Time, precondition time.Duration, soc int) ts = time.Time{} v.log.DEBUG.Printf("delete %s plan", v.name) } else { - v.log.DEBUG.Printf("set %s plan soc: %d @ %v (precondition: %v)", v.name, soc, ts.Round(time.Second).Local(), precondition) + v.log.DEBUG.Printf("set %s plan soc: %d @ %v", v.name, soc, ts.Round(time.Second).Local()) } 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() @@ -125,10 +120,11 @@ 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 @@ -143,3 +139,22 @@ 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 da134d852..5dde92b6d 100644 --- a/core/vehicle/api.go +++ b/core/vehicle/api.go @@ -39,15 +39,20 @@ type API interface { SetLimitSoc(soc int) // GetPlanSoc returns the charge plan soc - GetPlanSoc() (time.Time, time.Duration, int) + GetPlanSoc() (time.Time, int) // SetPlanSoc sets the charge plan time and soc - SetPlanSoc(time.Time, time.Duration, int) error + SetPlanSoc(time.Time, 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 a648d7830..fe76e9381 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, time.Duration, int) { - return time.Time{}, 0, 0 +func (v *dummy) GetPlanSoc() (time.Time, int) { + return time.Time{}, 0 } // SetPlanSoc sets the charge plan soc -func (v *dummy) SetPlanSoc(ts time.Time, precondition time.Duration, soc int) error { +func (v *dummy) SetPlanSoc(ts time.Time, soc int) error { return nil } @@ -56,3 +56,11 @@ 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 120335104..5db465b22 100644 --- a/core/vehicle/mock.go +++ b/core/vehicle/mock.go @@ -70,13 +70,12 @@ func (mr *MockAPIMockRecorder) GetMinSoc() *gomock.Call { } // GetPlanSoc mocks base method. -func (m *MockAPI) GetPlanSoc() (time.Time, time.Duration, int) { +func (m *MockAPI) GetPlanSoc() (time.Time, int) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPlanSoc") ret0, _ := ret[0].(time.Time) - ret1, _ := ret[1].(time.Duration) - ret2, _ := ret[2].(int) - return ret0, ret1, ret2 + ret1, _ := ret[1].(int) + return ret0, ret1 } // GetPlanSoc indicates an expected call of GetPlanSoc. @@ -85,6 +84,20 @@ 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() @@ -152,17 +165,31 @@ func (mr *MockAPIMockRecorder) SetMinSoc(soc any) *gomock.Call { } // SetPlanSoc mocks base method. -func (m *MockAPI) SetPlanSoc(arg0 time.Time, arg1 time.Duration, arg2 int) error { +func (m *MockAPI) SetPlanSoc(arg0 time.Time, arg1 int) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetPlanSoc", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "SetPlanSoc", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // SetPlanSoc indicates an expected call of SetPlanSoc. -func (mr *MockAPIMockRecorder) SetPlanSoc(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockAPIMockRecorder) SetPlanSoc(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanSoc", reflect.TypeOf((*MockAPI)(nil).SetPlanSoc), arg0, arg1, arg2) + 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) } // SetRepeatingPlans mocks base method. diff --git a/i18n/bg.json b/i18n/bg.json index 1601f5d67..44dfb1319 100644 --- a/i18n/bg.json +++ b/i18n/bg.json @@ -636,11 +636,12 @@ "modalTitle": "План за зареждане", "none": "няма", "planNumber": "План {number}", - "preconditionDescription": "Зареди за {duration} преди тръгване, за да бъде кондиционирана батерията на автомобила.", - "preconditionLong": "Късно зареждане", - "preconditionOptionAll": "всичко", - "preconditionOptionNo": "не", - "preconditionShort": "Късно", + "precondition": { + "description": "Зареди за {duration} преди тръгване, за да бъде кондиционирана батерията на автомобила.", + "label": "Късно зареждане", + "optionAll": "всичко", + "optionNo": "не" + }, "remove": "Премахване", "repeating": "повтарящ се", "repeatingPlans": "Повтарящи се планове", diff --git a/i18n/cs.json b/i18n/cs.json index 5aaabcc04..34295adae 100644 --- a/i18n/cs.json +++ b/i18n/cs.json @@ -814,11 +814,12 @@ "modalTitle": "Plán nabíjení", "none": "Žádný", "planNumber": "Plán {number}", - "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í", + "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" + }, "remove": "Odebrat", "repeating": "opakující se", "repeatingPlans": "Opakující se plány", diff --git a/i18n/da.json b/i18n/da.json index 7eee310c7..22954c773 100644 --- a/i18n/da.json +++ b/i18n/da.json @@ -889,11 +889,12 @@ "modalTitle": "Lade plan", "none": "ingen", "planNumber": "Plan {number}", - "preconditionDescription": "Oplad {duration} før afgang til batterikonditionering.", - "preconditionLong": "Sen opladning", - "preconditionOptionAll": "alt", - "preconditionOptionNo": "nej", - "preconditionShort": "Sen", + "precondition": { + "description": "Oplad {duration} før afgang til batterikonditionering.", + "label": "Sen opladning", + "optionAll": "alt", + "optionNo": "nej" + }, "remove": "Fjern", "repeating": "Gentager", "repeatingPlans": "Gentager plan", diff --git a/i18n/de.json b/i18n/de.json index b1baa2971..a740889dc 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -877,16 +877,23 @@ "goal": "Ladeziel", "modalTitle": "Ladeplanung", "none": "keiner", + "optimization": { + "cheapest": "günstigst", + "continuous": "kontinuierlich", + "label": "Optimierung" + }, "planNumber": "Plan {number}", - "preconditionDescription": "Lade {duration} vor Abfahrt zur Batterie-Vorkonditionierung.", - "preconditionLong": "Spätes Laden", - "preconditionOptionAll": "alles", - "preconditionOptionNo": "nein", - "preconditionShort": "Spät", + "precondition": { + "description": "Lade {duration} vor Abfahrt zur Batterie-Vorkonditionierung.", + "label": "Spätes Laden", + "optionAll": "alles", + "optionNo": "nein" + }, "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 a6e106648..9a61de87f 100644 --- a/i18n/el.json +++ b/i18n/el.json @@ -798,11 +798,12 @@ "modalTitle": "Πρόγραμμα Φόρτισης", "none": "κανένα", "planNumber": "Πρόγραμμα {number}", - "preconditionDescription": "Φορτίστε {duration} πριν από την αναχώρηση για προετοιμασία μπαταρίας.", - "preconditionLong": "Καθυστερημένη φόρτιση", - "preconditionOptionAll": "Όλα", - "preconditionOptionNo": "Όχι", - "preconditionShort": "Αργά", + "precondition": { + "description": "Φορτίστε {duration} πριν από την αναχώρηση για προετοιμασία μπαταρίας.", + "label": "Καθυστερημένη φόρτιση", + "optionAll": "Όλα", + "optionNo": "Όχι" + }, "remove": "Αφαίρεση", "repeating": "επαναλαμβανόμενο", "repeatingPlans": "Επαναλαμβανόμενα προγράμματα", diff --git a/i18n/en.json b/i18n/en.json index c9c06b6bb..3d1f141c4 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -877,16 +877,23 @@ "goal": "Charging goal", "modalTitle": "Charging Plan", "none": "none", + "optimization": { + "cheapest": "cheapest", + "continuous": "continuous", + "label": "Optimization" + }, "planNumber": "Plan {number}", - "preconditionDescription": "Charge {duration} before departure for battery preconditioning.", - "preconditionLong": "Late Charging", - "preconditionOptionAll": "everything", - "preconditionOptionNo": "no", - "preconditionShort": "Late", + "precondition": { + "description": "Charge {duration} before departure for battery preconditioning.", + "label": "Late Charging", + "optionAll": "everything", + "optionNo": "no" + }, "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 06a653849..6869bfeb5 100644 --- a/i18n/fi.json +++ b/i18n/fi.json @@ -888,11 +888,12 @@ "modalTitle": "Lataussuunnitelma", "none": "ei mitään", "planNumber": "Suunnitelma {number}", - "preconditionDescription": "Ladataan {duration} ennen lähtöä akunesilämmitystä varten.", - "preconditionLong": "Viimehetken lataus", - "preconditionOptionAll": "koko latausmäärä", - "preconditionOptionNo": "ei", - "preconditionShort": "Viimehetkellinen", + "precondition": { + "description": "Ladataan {duration} ennen lähtöä akunesilämmitystä varten.", + "label": "Viimehetken lataus", + "optionAll": "koko latausmäärä", + "optionNo": "ei" + }, "remove": "Poista", "repeating": "toistetaan", "repeatingPlans": "Toistuvat suunnitelmat", diff --git a/i18n/fr.json b/i18n/fr.json index bd11a5454..8f2689346 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -888,11 +888,12 @@ "modalTitle": "Planification de la charge", "none": "aucune", "planNumber": "Planning {number}", - "preconditionDescription": "Charge {duration} avant le départ pour pré-conditionner la batterie.", - "preconditionLong": "Chargement tardif", - "preconditionOptionAll": "tous", - "preconditionOptionNo": "non", - "preconditionShort": "Tardif", + "precondition": { + "description": "Charge {duration} avant le départ pour pré-conditionner la batterie.", + "label": "Chargement tardif", + "optionAll": "tous", + "optionNo": "non" + }, "remove": "Enlever", "repeating": "récurrent", "repeatingPlans": "Plannings récurrents", diff --git a/i18n/hr.json b/i18n/hr.json index 102944abf..6b23f3d6a 100644 --- a/i18n/hr.json +++ b/i18n/hr.json @@ -818,11 +818,12 @@ "modalTitle": "Plan punjenja", "none": "nema, dodaj", "planNumber": "Plan {number}", - "preconditionDescription": "Punjenje {duration} prije polaska kako bi se izvršilo predkondicioniranje baterije.", - "preconditionLong": "Kasnije punjenje", - "preconditionOptionAll": "sve", - "preconditionOptionNo": "ne", - "preconditionShort": "Kasno", + "precondition": { + "description": "Punjenje {duration} prije polaska kako bi se izvršilo predkondicioniranje baterije.", + "label": "Kasnije punjenje", + "optionAll": "sve", + "optionNo": "ne" + }, "remove": "Ukloni", "repeating": "ponavljanje", "repeatingPlans": "Ponavljajući planovi", diff --git a/i18n/hu.json b/i18n/hu.json index 7af003e47..3fc41e2c1 100644 --- a/i18n/hu.json +++ b/i18n/hu.json @@ -786,11 +786,12 @@ "modalTitle": "Töltési Tervezet", "none": "nincs", "planNumber": "Terv {number}", - "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", + "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" + }, "remove": "Törlés", "repeating": "ismétlődő", "repeatingPlans": "Ismétlődő tervek", diff --git a/i18n/it.json b/i18n/it.json index 8d4fed497..6c1e9711e 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -616,11 +616,12 @@ "modalTitle": "Piano di ricarica", "none": "nessuno", "planNumber": "Piano {number}", - "preconditionDescription": "Carica {duration} prima della partenza per pre-condizionare la batteria.", - "preconditionLong": "Carica ritardata", - "preconditionOptionAll": "tutto", - "preconditionOptionNo": "no", - "preconditionShort": "Ritardata", + "precondition": { + "description": "Carica {duration} prima della partenza per pre-condizionare la batteria.", + "label": "Carica ritardata", + "optionAll": "tutto", + "optionNo": "no" + }, "remove": "Rimuovi", "repeating": "ricorrente", "repeatingPlans": "Piani ricorrenti", diff --git a/i18n/lb.json b/i18n/lb.json index 6e0fff1d8..b321d6c82 100644 --- a/i18n/lb.json +++ b/i18n/lb.json @@ -887,11 +887,12 @@ "modalTitle": "Luedplanifikatioun", "none": "keng", "planNumber": "Planifkatioun {number}", - "preconditionDescription": "Luetzäit {duration} virum Fortfuere fir Batterie-Prekonditionéierung.", - "preconditionLong": "Verspéit Lueden", - "preconditionOptionAll": "Alles", - "preconditionOptionNo": "Nee", - "preconditionShort": "Spéit", + "precondition": { + "description": "Luetzäit {duration} virum Fortfuere fir Batterie-Prekonditionéierung.", + "label": "Verspéit Lueden", + "optionAll": "Alles", + "optionNo": "Nee" + }, "remove": "Ewechhuelen", "repeating": "widderhuelend", "repeatingPlans": "Widderhuelend Planifikatiounen", diff --git a/i18n/lt.json b/i18n/lt.json index 11962b699..9446a6ca0 100644 --- a/i18n/lt.json +++ b/i18n/lt.json @@ -889,11 +889,12 @@ "modalTitle": "Įkrovimo planas", "none": "nėra", "planNumber": "Planas {number}", - "preconditionDescription": "Įkrovimas {duration} prieš išvykstant, baterijos paruošimui.", - "preconditionLong": "Vėlyvas įkrovimas", - "preconditionOptionAll": "viskas", - "preconditionOptionNo": "ne", - "preconditionShort": "Vėlai", + "precondition": { + "description": "Įkrovimas {duration} prieš išvykstant, baterijos paruošimui.", + "label": "Vėlyvas įkrovimas", + "optionAll": "viskas", + "optionNo": "ne" + }, "remove": "Pašalinti", "repeating": "kartojasi", "repeatingPlans": "Pasikartojantys planai", diff --git a/i18n/nl.json b/i18n/nl.json index a70a2a9ea..1b9432a6f 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -888,11 +888,12 @@ "modalTitle": "Laad Plan", "none": "geen", "planNumber": "Schema {number}", - "preconditionDescription": "Laad {duration} voor vertrek voor accu voorbereiding.", - "preconditionLong": "Laat Opladen", - "preconditionOptionAll": "alles", - "preconditionOptionNo": "nee", - "preconditionShort": "Laat", + "precondition": { + "description": "Laad {duration} voor vertrek voor accu voorbereiding.", + "label": "Laat Opladen", + "optionAll": "alles", + "optionNo": "nee" + }, "remove": "Verwijderen", "repeating": "herhalend", "repeatingPlans": "Herhalende schema's", diff --git a/i18n/pl.json b/i18n/pl.json index c66725ab1..29bb36cb9 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -509,11 +509,12 @@ "goal": "Cel ładowania", "modalTitle": "Plan ładowania", "none": "brak", - "preconditionDescription": "Naładuj {duration} przed wyjazdem dla wstępnego kondycjonowania akumulatora.", - "preconditionLong": "Późniejsze ładowanie", - "preconditionOptionAll": "wszystko", - "preconditionOptionNo": "nie", - "preconditionShort": "Później", + "precondition": { + "description": "Naładuj {duration} przed wyjazdem dla wstępnego kondycjonowania akumulatora.", + "label": "Późniejsze ładowanie", + "optionAll": "wszystko", + "optionNo": "nie" + }, "remove": "Wymaż", "repeating": "powtórzenie", "repeatingPlans": "Powtarzające się plany", diff --git a/i18n/pt.json b/i18n/pt.json index ddb8320dc..ea3e27b77 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -887,11 +887,12 @@ "modalTitle": "Plano de carga", "none": "nenhum", "planNumber": "Plano {number}", - "preconditionDescription": "Carregar {duration} antes da partida para pré-condicionamento da bateria.", - "preconditionLong": "Carga tardia", - "preconditionOptionAll": "tudo", - "preconditionOptionNo": "não", - "preconditionShort": "Tarde", + "precondition": { + "description": "Carregar {duration} antes da partida para pré-condicionamento da bateria.", + "label": "Carga tardia", + "optionAll": "tudo", + "optionNo": "não" + }, "remove": "Apagar", "repeating": "recorrente", "repeatingPlans": "Repetição de planos", diff --git a/i18n/sv.json b/i18n/sv.json index 3ecf08cfb..1f31cf92b 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -889,11 +889,12 @@ "modalTitle": "Laddplan", "none": "ingen", "planNumber": "Plan {number}", - "preconditionDescription": "Ladda {duration} före avfärd för batteriuppvärmning.", - "preconditionLong": "Sen laddning", - "preconditionOptionAll": "allt", - "preconditionOptionNo": "nej", - "preconditionShort": "Sen", + "precondition": { + "description": "Ladda {duration} före avfärd för batteriuppvärmning.", + "label": "Sen laddning", + "optionAll": "allt", + "optionNo": "nej" + }, "remove": "Ta bort", "repeating": "återkommande", "repeatingPlans": "Återkommande planer", diff --git a/i18n/ta.json b/i18n/ta.json index f4117f4e4..6abfd6e87 100644 --- a/i18n/ta.json +++ b/i18n/ta.json @@ -683,11 +683,12 @@ "modalTitle": "மின்சேர்வி திட்டம்", "none": "எதுவுமில்லை", "planNumber": "திட்டம் {number}", - "preconditionDescription": "பேட்டரி முன்நிபந்தனைக்கு புறப்படுவதற்கு முன் {duration} சார்ஜ் செய்யவும்.", - "preconditionLong": "தாமதமாக சார்சிங்", - "preconditionOptionAll": "எல்லாம்", - "preconditionOptionNo": "இல்லை", - "preconditionShort": "தாமதமாக", + "precondition": { + "description": "பேட்டரி முன்நிபந்தனைக்கு புறப்படுவதற்கு முன் {duration} சார்ஜ் செய்யவும்.", + "label": "தாமதமாக சார்சிங்", + "optionAll": "எல்லாம்", + "optionNo": "இல்லை" + }, "remove": "அகற்று", "repeating": "மறுநிகழ்தல்", "repeatingPlans": "மறுநிகழ்வு திட்டங்கள்", diff --git a/i18n/tr.json b/i18n/tr.json index 366b84418..97f8211d0 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -887,11 +887,12 @@ "modalTitle": "Doldurma Planı", "none": "yok", "planNumber": "Plan {number}", - "preconditionDescription": "Batarya ön ısıtması için kalkıştan önce {duration} doldur.", - "preconditionLong": "Geç doldurma", - "preconditionOptionAll": "hepsi", - "preconditionOptionNo": "hayır", - "preconditionShort": "Geç", + "precondition": { + "description": "Batarya ön ısıtması için kalkıştan önce {duration} doldur.", + "label": "Geç doldurma", + "optionAll": "hepsi", + "optionNo": "hayır" + }, "remove": "Kaldır", "repeating": "“tekrarlanan”", "repeatingPlans": "“Tekrarlanan planlar”", diff --git a/i18n/uk.json b/i18n/uk.json index 608d412a8..399d38bce 100644 --- a/i18n/uk.json +++ b/i18n/uk.json @@ -889,11 +889,12 @@ "modalTitle": "Тарифний план", "none": "немає", "planNumber": "План {number}", - "preconditionDescription": "Зарядіть {duration} перед відправленням для попередньої підготовки акумулятора.", - "preconditionLong": "Пізня зарядка", - "preconditionOptionAll": "все", - "preconditionOptionNo": "ні", - "preconditionShort": "Пізно", + "precondition": { + "description": "Зарядіть {duration} перед відправленням для попередньої підготовки акумулятора.", + "label": "Пізня зарядка", + "optionAll": "все", + "optionNo": "ні" + }, "remove": "Yсувати", "repeating": "повторення", "repeatingPlans": "Повторювані плани", diff --git a/i18n/zh-Hans.json b/i18n/zh-Hans.json index 33d9d2f57..0db908a02 100644 --- a/i18n/zh-Hans.json +++ b/i18n/zh-Hans.json @@ -610,11 +610,12 @@ "modalTitle": "充电计划", "none": "无", "planNumber": "计划 {number}", - "preconditionDescription": "出发前充电 {duration} 以进行电池预处理。", - "preconditionLong": "延迟充电", - "preconditionOptionAll": "全部", - "preconditionOptionNo": "否", - "preconditionShort": "延迟", + "precondition": { + "description": "出发前充电 {duration} 以进行电池预处理。", + "label": "延迟充电", + "optionAll": "全部", + "optionNo": "否" + }, "remove": "移除", "repeating": "重复", "repeatingPlans": "重复计划", diff --git a/push/hub.go b/push/hub.go index 2a3e96f0e..addb76219 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 da270c65a..ed6c84428 100644 --- a/server/helper.go +++ b/server/helper.go @@ -9,7 +9,6 @@ import ( "slices" "strconv" "strings" - "time" ) // pass converts a simple api without return value to api with nil error return value @@ -29,21 +28,6 @@ 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 7aa0b8bf6..1579deb67 100644 --- a/server/http.go +++ b/server/http.go @@ -173,6 +173,7 @@ 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)}, @@ -199,9 +200,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 90972c6bb..af4441b60 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, planPrecondition, planEnergy := lp.GetPlanEnergy() + planTime, 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, - PlanPrecondition: int64(planPrecondition.Seconds()), + PlanStrategy: lp.GetPlanStrategy(), LimitEnergy: lp.GetLimitEnergy(), LimitSoc: lp.GetLimitSoc(), } diff --git a/server/http_loadpoint_handler.go b/server/http_loadpoint_handler.go index a8deae57c..92fbf7d03 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"` - Precondition int64 `json:"precondition"` - Plan api.Rates `json:"plan"` - Power float64 `json:"power"` + PlanId int `json:"planId"` + PlanTime time.Time `json:"planTime"` + Duration int64 `json:"duration"` + Plan api.Rates `json:"plan"` + Power float64 `json:"power"` } type PlanPreviewResponse struct { - PlanTime time.Time `json:"planTime"` - Duration int64 `json:"duration"` - Precondition int64 `json:"precondition"` - Plan api.Rates `json:"plan"` - Power float64 `json:"power"` + 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"` } // planHandler returns the current plan @@ -40,17 +40,16 @@ func planHandler(lp loadpoint.API) http.HandlerFunc { id := lp.EffectivePlanId() goal, _ := lp.GetPlanGoal() - precondition := lp.GetPlanPreCondDuration() requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower) - plan := lp.GetPlan(planTime, requiredDuration, precondition) + strategy := lp.EffectivePlanStrategy() + plan := lp.GetPlan(planTime, requiredDuration, strategy.Precondition, strategy.Continuous) res := PlanResponse{ - PlanId: id, - PlanTime: planTime, - Duration: int64(requiredDuration.Seconds()), - Precondition: int64(precondition.Seconds()), - Plan: plan, - Power: maxPower, + PlanId: id, + PlanTime: planTime, + Duration: int64(requiredDuration.Seconds()), + Plan: plan, + Power: maxPower, } jsonWrite(w, res) @@ -61,7 +60,6 @@ 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 { @@ -75,12 +73,6 @@ 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() { @@ -99,66 +91,19 @@ func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc { maxPower := lp.EffectiveMaxPower() requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower) - plan := lp.GetPlan(planTime, requiredDuration, precondition) + strategy := lp.EffectivePlanStrategy() + + plan := lp.GetPlan(planTime, requiredDuration, strategy.Precondition, strategy.Continuous) res := PlanPreviewResponse{ - PlanTime: planTime, - Duration: int64(requiredDuration.Seconds()), - Precondition: int64(precondition.Seconds()), - Plan: plan, - Power: maxPower, - } + PlanTime: planTime, + Duration: int64(requiredDuration.Seconds()), + Plan: plan, + Power: maxPower, - 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, + // TODO remove + Continuous: strategy.Continuous, + Precondition: int64(strategy.Precondition.Seconds()), } jsonWrite(w, res) @@ -169,7 +114,6 @@ func repeatingPlanPreviewHandler(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 { @@ -183,27 +127,19 @@ func planEnergyHandler(lp loadpoint.API) http.HandlerFunc { return } - precondition, err := parseDuration(query.Get("precondition")) - if err != nil { + if err := lp.SetPlanEnergy(ts, val); err != nil { jsonError(w, http.StatusBadRequest, err) return } - if err := lp.SetPlanEnergy(ts, precondition, val); err != nil { - jsonError(w, http.StatusBadRequest, err) - return - } - - ts, precondition, energy := lp.GetPlanEnergy() + ts, energy := lp.GetPlanEnergy() res := struct { - Energy float64 `json:"energy"` - Precondition int64 `json:"precondition"` - Time time.Time `json:"time"` + Energy float64 `json:"energy"` + Time time.Time `json:"time"` }{ - Energy: energy, - Precondition: int64(precondition.Seconds()), - Time: ts, + Energy: energy, + Time: ts, } jsonWrite(w, res) @@ -213,13 +149,12 @@ 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, 0); err != nil { + if err := lp.SetPlanEnergy(time.Time{}, 0); err != nil { jsonError(w, http.StatusBadRequest, err) return } - res := struct{}{} - jsonWrite(w, res) + jsonWrite(w, struct{}{}) } } @@ -251,8 +186,7 @@ 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) - res := struct{}{} - jsonWrite(w, res) + jsonWrite(w, struct{}{}) } } @@ -260,7 +194,20 @@ func vehicleRemoveHandler(lp loadpoint.API) http.HandlerFunc { func vehicleDetectHandler(lp loadpoint.API) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { lp.StartVehicleDetection() - res := struct{}{} + 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()) + jsonWrite(w, res) } } diff --git a/server/http_vehicle_handler.go b/server/http_vehicle_handler.go index 6a2f9169e..e857296c6 100644 --- a/server/http_vehicle_handler.go +++ b/server/http_vehicle_handler.go @@ -73,7 +73,6 @@ 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 { @@ -93,28 +92,52 @@ func planSocHandler(site site.API) http.HandlerFunc { return } - precondition, err := parseDuration(query.Get("precondition")) + 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"]) if err != nil { jsonError(w, http.StatusBadRequest, err) return } - if err := v.SetPlanSoc(ts, precondition, soc); err != nil { + if err := planStrategyHandlerSetter(r, v.SetPlanStrategy); err != nil { jsonError(w, http.StatusBadRequest, err) return } - 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, - } + res := planStrategyPayloadFromApi(v.GetPlanStrategy()) jsonWrite(w, res) } @@ -157,12 +180,11 @@ func planSocRemoveHandler(site site.API) http.HandlerFunc { return } - if err := v.SetPlanSoc(time.Time{}, 0, 0); err != nil { + if err := v.SetPlanSoc(time.Time{}, 0); err != nil { jsonError(w, http.StatusBadRequest, err) return } - res := struct{}{} - jsonWrite(w, res) + jsonWrite(w, struct{}{}) } } diff --git a/server/mcp/openapi.json b/server/mcp/openapi.json index 937c2281b..fd302146b 100644 --- a/server/mcp/openapi.json +++ b/server/mcp/openapi.json @@ -594,6 +594,21 @@ }, "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, @@ -638,9 +653,6 @@ "description": "Set plan active.", "type": "boolean" }, - "precondition": { - "$ref": "#/components/schemas/Precondition" - }, "soc": { "$ref": "#/components/schemas/Soc" }, @@ -675,9 +687,6 @@ "energy": { "$ref": "#/components/schemas/Energy" }, - "precondition": { - "$ref": "#/components/schemas/Precondition" - }, "time": { "$ref": "#/components/schemas/Timestamp" } @@ -689,9 +698,6 @@ "url": "https://docs.evcc.io/en/docs/features/plans#create-charging-plan" }, "properties": { - "precondition": { - "$ref": "#/components/schemas/Precondition" - }, "soc": { "$ref": "#/components/schemas/Soc" }, @@ -1423,9 +1429,6 @@ "properties": { "planId": { "$ref": "#/components/schemas/Id" - }, - "precondition": { - "$ref": "#/components/schemas/Precondition" } }, "type": "object" @@ -1606,6 +1609,51 @@ ] } }, + "/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.", @@ -2502,13 +2550,6 @@ }, { "$ref": "#/components/parameters/timestamp" - }, - { - "in": "query", - "name": "precondition", - "schema": { - "$ref": "#/components/schemas/Precondition" - } } ], "responses": { @@ -2518,7 +2559,15 @@ "schema": { "properties": { "result": { - "$ref": "#/components/schemas/StaticSocPlan" + "properties": { + "soc": { + "$ref": "#/components/schemas/Soc" + }, + "time": { + "$ref": "#/components/schemas/Timestamp" + } + }, + "type": "object" } }, "type": "object" @@ -2533,6 +2582,51 @@ "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 8200af16a..98cf41e7f 100644 --- a/server/mcp/openapi.md +++ b/server/mcp/openapi.md @@ -730,6 +730,28 @@ 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. @@ -1054,6 +1076,28 @@ 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. @@ -1087,7 +1131,6 @@ 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 | @@ -1096,7 +1139,6 @@ 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 d3556916c..b33bcb43a 100644 --- a/server/mqtt.go +++ b/server/mqtt.go @@ -1,7 +1,6 @@ package server import ( - "encoding/json" "fmt" "reflect" "strconv" @@ -252,18 +251,8 @@ 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)}, - {"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 - }}, + {"planStrategy", planStrategySetter(lp.SetPlanStrategy)}, + {"planEnergy", planGoalSetter(lp.SetPlanEnergy)}, {"vehicle", func(payload string) error { // https://github.com/evcc-io/evcc/issues/11184 empty payload is swallowed by listener if isEmpty(payload) { @@ -289,18 +278,8 @@ func (m *MQTT) listenVehicleSetters(topic string, v vehicle.API) error { for _, s := range []setter{ {"limitSoc", intSetter(pass(v.SetLimitSoc))}, {"minSoc", intSetter(pass(v.SetMinSoc))}, - {"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 - }}, + {"planStrategy", planStrategySetter(v.SetPlanStrategy)}, + {"planSoc", planGoalSetter(v.SetPlanSoc)}, } { 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 8541260b6..68fb38259 100644 --- a/server/mqtt_setter.go +++ b/server/mqtt_setter.go @@ -1,10 +1,12 @@ package server import ( + "encoding/json" "slices" "strconv" "time" + "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" "github.com/spf13/cast" ) @@ -63,3 +65,28 @@ 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 80765b72e..45aeb155d 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -398,8 +398,6 @@ paths: properties: planId: $ref: "#/components/schemas/Id" - precondition: - $ref: "#/components/schemas/Precondition" - $ref: "#/components/schemas/PlanRates" /loadpoints/{id}/plan/energy: delete: @@ -488,6 +486,33 @@ 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 @@ -1080,10 +1105,6 @@ 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 @@ -1093,7 +1114,39 @@ paths: type: object properties: result: - $ref: "#/components/schemas/StaticSocPlan" + 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" components: schemas: BatteryMode: @@ -1304,6 +1357,17 @@ 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 @@ -1312,8 +1376,6 @@ components: active: description: "Set plan active." type: boolean - precondition: - $ref: "#/components/schemas/Precondition" soc: $ref: "#/components/schemas/Soc" time: @@ -1338,8 +1400,6 @@ components: properties: energy: $ref: "#/components/schemas/Energy" - precondition: - $ref: "#/components/schemas/Precondition" time: $ref: "#/components/schemas/Timestamp" StaticSocPlan: @@ -1347,8 +1407,6 @@ 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 new file mode 100644 index 000000000..cb5d9c973 --- /dev/null +++ b/server/types.go @@ -0,0 +1,24 @@ +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 344bbedca..127688fd1 100755 --- a/tests/plan.evcc.yaml +++ b/tests/plan.evcc.yaml @@ -16,7 +16,9 @@ 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 b2d649510..5a80050e1 100644 --- a/tests/plan.spec.ts +++ b/tests/plan.spec.ts @@ -79,24 +79,22 @@ 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.getByTestId("static-plan-precondition-lg-toggle").click(); - await page - .getByTestId("static-plan-precondition-lg-select") - .getByRole("combobox") - .selectOption("1 hour"); + 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-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:3080%" + ["tomorrow 09:30", "80%"].join("") ); 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:3080%" + ["tomorrow 09:30", "80%"].join("") ); await lp1.getByTestId("charging-plan").getByRole("button").click(); await expect(page.getByTestId("static-plan-soc")).toHaveValue("80"); @@ -484,26 +482,17 @@ test.describe("repeating", async () => { await modal.getByRole("checkbox", { name: "Select all" }).check(); await modal.getByTestId("repeating-plan-time").fill("11:11"); - // switch between previews + // with multiple plans, preview shows first plan await page.waitForLoadState("networkidle"); - 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("plan-preview-title")).toHaveText("Preview plan #1"); await expect(modal.getByTestId("target-text")).toContainText("09:00"); - // activate #1 + // activate #1 - should show next plan #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"); - // activate #2 + // deactivate #1, activate #2 - should show next plan #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"); @@ -511,9 +500,7 @@ 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").locator("option:checked")).toHaveText( - "Preview plan #1" - ); + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Preview plan #1"); await expect(modal.getByTestId("target-text")).toContainText("9:00"); }); @@ -663,11 +650,9 @@ 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 plan.getByTestId("repeating-plan-precondition-lg-toggle").click(); - await plan - .getByTestId("repeating-plan-precondition-lg-select") - .getByRole("combobox") - .selectOption("2 hours"); + 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-active").click(); await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); await expect(modal.getByTestId("target-text")).toContainText("09:20"); @@ -689,26 +674,55 @@ 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.getByTestId("repeating-plan-precondition-lg-toggle")).toBeChecked(); - await expect( - modal.getByTestId("repeating-plan-precondition-lg-select").locator("option:checked") - ).toHaveText("2 hours"); + await expect(modal.getByLabel("Late Charging")).toHaveValue("7200"); }); }); // add test for precondition, start with basic.evcc.yaml and verify that precondition toggle element is not visible. make dedicated describe block -test.describe("precondition", async () => { +test.describe("plan strategy", 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(); - 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(); + 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(); + }); - // verify small viewport + test("visible and functional on mobile", async ({ page }) => { + await page.goto("/"); + + // Set mobile viewport await page.setViewportSize(mobile); - await expect(page.getByTestId("static-plan-precondition-select")).not.toBeVisible(); + + 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"); }); }); From fba4cb48034d86132e6a2be4137777b855136647 Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 31 Dec 2025 13:20:57 +0100 Subject: [PATCH 2/2] Planner: fix backwards compatibility --- core/loadpoint.go | 2 +- core/loadpoint/config.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/core/loadpoint.go b/core/loadpoint.go index 207befe21..201205938 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -184,7 +184,7 @@ type Loadpoint struct { func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, other map[string]any) (*Loadpoint, error) { lp := NewLoadpoint(log, settings) if err := util.DecodeOther(other, lp); err != nil { - return nil, err + return lp, err } // set vehicle polling mode diff --git a/core/loadpoint/config.go b/core/loadpoint/config.go index 86e326f46..8a307e460 100644 --- a/core/loadpoint/config.go +++ b/core/loadpoint/config.go @@ -27,6 +27,7 @@ type DynamicConfig struct { SmartFeedInPriorityLimit *float64 `json:"smartFeedInPriorityLimit"` PlanEnergy float64 `json:"planEnergy"` PlanTime time.Time `json:"planTime"` + PlanPrecondition_ int64 `json:"planPrecondition" mapstructure:"planPrecondition"` // TODO deprecated, keep for compatibility LimitEnergy float64 `json:"limitEnergy"` LimitSoc int `json:"limitSoc"`