Reapply "Planner: add continuous strategy (BC) (#24423)"

This reverts commit 7acc133f3c.
This commit is contained in:
andig 2025-12-31 13:19:49 +01:00 • committed by andig
parent 916de19f4f
commit dc6d056538
68 changed files with 2245 additions and 950 deletions

View file

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

View file

@ -69,6 +69,7 @@
@static-plan-updated="updateStaticPlan"
@static-plan-removed="removeStaticPlan"
@repeating-plans-updated="updateRepeatingPlans"
@plan-strategy-updated="updatePlanStrategy"
/>
<ChargingPlanArrival
v-if="arrivalTabActive"
@ -95,7 +96,13 @@ import api from "@/api";
import { optionStep, fmtEnergy } from "@/utils/energyOptions.ts";
import { defineComponent, type PropType } from "vue";
import type { CURRENCY, Timeout, Vehicle } from "@/types/evcc";
import type { StaticPlan, StaticSocPlan, StaticEnergyPlan, RepeatingPlan } from "./types";
import type {
StaticPlan,
StaticSocPlan,
StaticEnergyPlan,
RepeatingPlan,
PlanStrategy,
} from "./types";
import type { Forecast } from "@/types/evcc.ts";
const ONE_MINUTE = 60 * 1000;
@ -114,6 +121,8 @@ export default defineComponent({
effectiveLimitSoc: Number,
effectivePlanSoc: Number,
effectivePlanTime: String,
effectivePlanPrecondition: Number,
effectivePlanContinuous: Boolean,
id: [String, Number],
limitEnergy: Number,
mode: String,
@ -121,7 +130,6 @@ export default defineComponent({
planEnergy: Number,
planTime: String,
planTimeUnreachable: Boolean,
planPrecondition: { type: Number, default: 0 },
planOverrun: Number,
rangePerSoc: Number,
smartCostType: String,
@ -166,7 +174,6 @@ export default defineComponent({
return {
soc: plan.soc,
time: new Date(plan.time),
precondition: plan.precondition,
};
}
return null;
@ -175,7 +182,6 @@ export default defineComponent({
return {
energy: this.planEnergy,
time: new Date(this.planTime),
precondition: this.planPrecondition,
};
}
return null;
@ -186,7 +192,7 @@ export default defineComponent({
this.vehicle.repeatingPlans &&
this.vehicle.repeatingPlans.length > 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}`);
},

View file

@ -24,17 +24,12 @@
{{ $t("main.chargingPlan.time") }}
</label>
</div>
<div :class="showPrecondition ? 'col-3' : 'col-4'">
<div class="col-3">
<label :for="formId('goal')">
{{ $t("main.chargingPlan.goal") }}
</label>
</div>
<div v-if="showPrecondition" class="col-1">
<label :for="formId('precondition')">
{{ $t("main.chargingPlan.preconditionShort") }}
</label>
</div>
<div class="col-1">
<div class="col-2">
<label :for="formId('active')"> {{ $t("main.chargingPlan.active") }} </label>
</div>
</div>
@ -81,7 +76,7 @@
{{ $t("main.chargingPlan.goal") }}
</label>
</div>
<div :class="['col-7', showPrecondition ? 'col-lg-3' : 'col-lg-4', 'mb-2', 'mb-lg-0']">
<div class="col-7 col-lg-3 mb-2 mb-lg-0">
<select
:id="formId('goal')"
v-model="selectedSoc"
@ -94,21 +89,6 @@
</option>
</select>
</div>
<div v-if="showPrecondition" class="col-5 d-lg-none col-form-label">
<label :for="formId('precondition')">
{{ $t("main.chargingPlan.preconditionLong") }}
</label>
</div>
<div
v-if="showPrecondition"
class="col-7 col-lg-1 mb-2 mb-lg-0 d-flex align-items-center"
>
<PreconditionSelect
:id="formId('precondition')"
v-model="selectedPrecondition"
testid="repeating-plan-precondition"
/>
</div>
<div class="col-5 d-lg-none col-form-label">
<label :for="formId('active')">
{{ $t("main.chargingPlan.active") }}
@ -130,7 +110,7 @@
</div>
</div>
<div
class="col-4 col-lg-1 d-flex align-items-center justify-content-end justify-content-lg-start"
class="col-4 col-lg-2 d-flex align-items-center justify-content-end justify-content-lg-start"
>
<button
v-if="showApply"
@ -140,11 +120,7 @@
tabindex="0"
@click="update(true)"
>
<span class="d-lg-none">{{ $t("main.chargingPlan.update") }}</span>
<shopicon-regular-checkmark
size="s"
class="flex-shrink-0 d-none d-lg-block"
></shopicon-regular-checkmark>
{{ $t("main.chargingPlan.update") }}
</button>
<button
v-else
@ -158,31 +134,22 @@
</button>
</div>
</div>
<!-- Large screen precondition description -->
<div class="plan-id-inset">
<PreconditionSelect
:id="formId('precondition')"
v-model="selectedPrecondition"
testid="repeating-plan-precondition"
description-lg-only
/>
</div>
</div>
</template>
<script lang="ts">
import "@h2d2/shopicons/es/regular/trash";
import "@h2d2/shopicons/es/regular/checkmark";
import { distanceUnit } from "@/units";
import MultiSelect from "../Helper/MultiSelect.vue";
import formatter from "@/mixins/formatter";
import deepEqual from "@/utils/deepEqual";
import PreconditionSelect from "./PreconditionSelect.vue";
import type { SelectOption } from "@/types/evcc";
import { defineComponent, type PropType } from "vue";
export default defineComponent({
name: "ChargingPlanRepeatingSettings",
components: { MultiSelect, PreconditionSelect },
components: { MultiSelect },
mixins: [formatter],
props: {
number: Number,
@ -190,12 +157,10 @@ export default defineComponent({
time: String,
tz: String,
soc: Number,
precondition: Number,
showHeader: Boolean,
active: Boolean,
rangePerSoc: Number,
formIdPrefix: String,
showPrecondition: Boolean,
},
emits: ["updated", "removed"],
data() {
@ -204,7 +169,6 @@ export default defineComponent({
selectedTime: this.time,
selectedSoc: this.soc,
selectedActive: this.active,
selectedPrecondition: this.precondition,
};
},
computed: {
@ -213,8 +177,7 @@ export default defineComponent({
!deepEqual(this.weekdays, this.selectedWeekdays) ||
this.time !== this.selectedTime ||
this.soc !== this.selectedSoc ||
this.active !== this.selectedActive ||
this.precondition !== this.selectedPrecondition
this.active !== this.selectedActive
);
},
showApply(): boolean {
@ -248,9 +211,6 @@ export default defineComponent({
active(newValue: boolean) {
this.selectedActive = newValue;
},
precondition(newValue: number) {
this.selectedPrecondition = newValue;
},
},
methods: {
id(): number {
@ -274,7 +234,6 @@ export default defineComponent({
soc: this.selectedSoc,
tz: this.tz,
active: this.selectedActive,
precondition: this.selectedPrecondition,
};
if (forceSave || !this.selectedActive) {

View file

@ -22,17 +22,12 @@
{{ $t("main.chargingPlan.time") }}
</label>
</div>
<div :class="showPrecondition ? 'col-3' : 'col-4'">
<div class="col-3">
<label :for="formId('goal')">
{{ $t("main.chargingPlan.goal") }}
</label>
</div>
<div v-if="showPrecondition" class="col-1">
<label :for="formId('precondition')">
{{ $t("main.chargingPlan.preconditionShort") }}
</label>
</div>
<div class="col-1">
<div class="col-2">
<label :for="formId('active')"> {{ $t("main.chargingPlan.active") }} </label>
</div>
</div>
@ -83,7 +78,7 @@
{{ $t("main.chargingPlan.goal") }}
</label>
</div>
<div :class="['col-7', showPrecondition ? 'col-lg-3' : 'col-lg-4', 'mb-2', 'mb-lg-0']">
<div class="col-7 col-lg-3 mb-2 mb-lg-0">
<select
v-if="socBasedPlanning"
:id="formId('goal')"
@ -109,21 +104,6 @@
</option>
</select>
</div>
<div v-if="showPrecondition" class="col-5 d-lg-none col-form-label">
<label :for="formId('precondition')">
{{ $t("main.chargingPlan.preconditionLong") }}
</label>
</div>
<div
v-if="showPrecondition"
class="col-7 col-lg-1 mb-2 mb-lg-0 d-flex align-items-center"
>
<PreconditionSelect
:id="formId('precondition')"
v-model="selectedPrecondition"
testid="static-plan-precondition"
/>
</div>
<div class="col-5 d-lg-none col-form-label">
<label :for="formId('active')">
{{ $t("main.chargingPlan.active") }}
@ -145,7 +125,7 @@
</div>
</div>
<div
class="col-4 col-lg-1 d-flex align-items-center justify-content-end justify-content-lg-start"
class="col-4 col-lg-2 d-flex align-items-center justify-content-end justify-content-lg-start"
>
<button
v-if="dataChanged && !isNew"
@ -154,26 +134,12 @@
data-testid="static-plan-apply"
:disabled="timeInThePast"
tabindex="0"
:aria-label="$t('main.chargingPlan.update')"
@click="update"
>
<span class="d-lg-none">{{ $t("main.chargingPlan.update") }}</span>
<shopicon-regular-checkmark
size="s"
class="flex-shrink-0 d-none d-lg-block"
></shopicon-regular-checkmark>
{{ $t("main.chargingPlan.update") }}
</button>
</div>
</div>
<!-- Large screen precondition description -->
<div :class="multiplePlans ? 'plan-id-insert' : ''">
<PreconditionSelect
:id="formId('precondition')"
v-model="selectedPrecondition"
testid="static-plan-precondition"
description-lg-only
/>
</div>
<p class="mb-0" data-testid="plan-entry-warnings">
<span v-if="timeInThePast" class="d-block text-danger my-2">
{{ $t("main.targetCharge.targetIsInThePast") }}
@ -183,13 +149,11 @@
</template>
<script lang="ts">
import "@h2d2/shopicons/es/regular/checkmark";
import { distanceUnit } from "@/units";
import formatter from "@/mixins/formatter";
import { energyOptions } from "@/utils/energyOptions.ts";
import { defineComponent } from "vue";
import PreconditionSelect from "./PreconditionSelect.vue";
const LAST_TARGET_TIME_KEY = "last_target_time";
const LAST_SOC_GOAL_KEY = "last_soc_goal";
@ -198,7 +162,6 @@ const DEFAULT_TARGET_TIME = "7:00";
export default defineComponent({
name: "ChargingPlanStaticSettings",
components: { PreconditionSelect },
mixins: [formatter],
props: {
id: [String, Number],
@ -210,8 +173,6 @@ export default defineComponent({
capacity: Number,
socBasedPlanning: Boolean,
multiplePlans: Boolean,
precondition: Number,
showPrecondition: Boolean,
},
emits: ["static-plan-updated", "static-plan-removed", "plan-preview"],
data() {
@ -221,7 +182,6 @@ export default defineComponent({
selectedSoc: this.soc,
selectedEnergy: this.energy,
active: false,
selectedPrecondition: this.precondition,
};
},
computed: {
@ -265,7 +225,6 @@ export default defineComponent({
energy: this.energy,
day: this.fmtDayString(t),
time: this.fmtTimeString(t),
precondition: this.precondition,
};
},
dataChanged() {
@ -275,8 +234,7 @@ export default defineComponent({
const goalChanged = this.socBasedPlanning
? this.originalData.soc != this.selectedSoc
: this.originalData.energy != this.selectedEnergy;
const preconditionChanged = this.originalData.precondition != this.selectedPrecondition;
return dateChanged || goalChanged || preconditionChanged;
return dateChanged || goalChanged;
},
isNew() {
return !this.time && (!this.soc || !this.energy);
@ -303,12 +261,6 @@ export default defineComponent({
isNew(value) {
this.active = !value;
},
precondition(value) {
this.selectedPrecondition = value;
},
selectedPrecondition() {
this.preview();
},
},
mounted() {
this.initInputFields();
@ -383,7 +335,6 @@ export default defineComponent({
time: this.selectedDate,
soc: this.selectedSoc,
energy: this.selectedEnergy,
precondition: this.selectedPrecondition,
});
},
preview(force = false) {
@ -394,7 +345,6 @@ export default defineComponent({
time: this.selectedDate,
soc: this.selectedSoc,
energy: this.selectedEnergy,
precondition: this.selectedPrecondition,
});
},
toggle(e: Event) {

View file

@ -0,0 +1,167 @@
<template>
<div class="strategy-wrapper" :class="{ open: show }">
<div class="strategy-content">
<div class="row">
<div class="col-12 col-sm-6 col-lg-3 offset-lg-3 mb-3">
<div class="row">
<label :for="formId('continuous')" class="col-form-label col-5 col-sm-12">
{{ $t("main.chargingPlan.optimization.label") }}
</label>
<div class="col-7 col-sm-12">
<select
:id="formId('continuous')"
v-model="localContinuous"
class="form-select"
@change="updateStrategy"
>
<option :value="false">
{{ $t("main.chargingPlan.optimization.cheapest") }}
</option>
<option :value="true">
{{ $t("main.chargingPlan.optimization.continuous") }}
</option>
</select>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3 mb-3">
<div class="row">
<label :for="formId('precondition')" class="col-form-label col-5 col-sm-12">
{{ $t("main.chargingPlan.precondition.label") }}
</label>
<div class="col-7 col-sm-12">
<select
:id="formId('precondition')"
v-model="localPrecondition"
class="form-select"
@change="updateStrategy"
>
<option :value="0">
{{ $t("main.chargingPlan.precondition.optionNo") }}
</option>
<option
v-for="opt in preconditionOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.name }}
</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import formatter from "@/mixins/formatter";
import type { PlanStrategy } from "./types";
export default defineComponent({
name: "ChargingPlanStrategy",
mixins: [formatter],
props: {
id: [String, Number],
show: Boolean,
precondition: { type: Number, default: 0 },
continuous: { type: Boolean, default: false },
},
emits: ["update"],
data() {
return {
localPrecondition: this.precondition,
localContinuous: this.continuous,
};
},
computed: {
preconditionOptions() {
const HOUR = 60 * 60;
const QUARTER_HOUR = 0.25 * HOUR;
const HALF_HOUR = 0.5 * HOUR;
const ONE_HOUR = 1 * HOUR;
const TWO_HOURS = 2 * HOUR;
const EVERYTHING = 7 * 24 * HOUR;
const options = [QUARTER_HOUR, HALF_HOUR, ONE_HOUR, TWO_HOURS, EVERYTHING];
// support custom values (via API)
if (this.localPrecondition && !options.includes(this.localPrecondition)) {
options.push(this.localPrecondition);
}
return options.map((s) => ({
value: s,
name:
s === EVERYTHING
? this.$t("main.chargingPlan.precondition.optionAll")
: this.fmtDurationLong(s),
}));
},
},
watch: {
precondition: {
handler(newValue: number) {
// Only update if value actually changed from external source
if (newValue !== this.localPrecondition) {
this.localPrecondition = newValue;
}
},
immediate: true,
},
continuous: {
handler(newValue: boolean) {
// Only update if value actually changed from external source
if (newValue !== this.localContinuous) {
this.localContinuous = newValue;
}
},
immediate: true,
},
},
methods: {
formId(name: string) {
return `chargingplan-${this.id}-${name}`;
},
updateStrategy(): void {
const strategy: PlanStrategy = {
continuous: this.localContinuous,
precondition: this.localPrecondition,
};
this.$emit("update", strategy);
},
},
});
</script>
<style scoped>
.strategy-wrapper {
display: grid;
grid-template-rows: 0fr;
margin-bottom: 0;
transition:
grid-template-rows 0.3s ease 0.2s,
margin-bottom 0.3s ease 0.2s;
}
.strategy-wrapper.open {
grid-template-rows: 1fr;
margin-bottom: 1rem;
transition:
grid-template-rows 0.3s ease,
margin-bottom 0.3s ease;
}
.strategy-content {
overflow: hidden;
opacity: 0;
transition: opacity 0.2s ease;
}
.open .strategy-content {
opacity: 1;
transition: opacity 0.3s ease 0.2s;
}
</style>

View file

@ -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<RepeatingPlan[]>, default: () => [] },
showPrecondition: Boolean,
},
emits: ["updated"],
computed: {

View file

@ -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"
/>
<div v-if="socBasedPlanning">
@ -30,7 +29,6 @@
:id="id"
:rangePerSoc="rangePerSoc"
:plans="repeatingPlans"
:show-precondition="showPrecondition"
@updated="updateRepeatingPlans"
/>
</div>
@ -38,29 +36,32 @@
</div>
<hr />
<h5>
<div class="inner" data-testid="plan-preview-title">
<div class="inner d-flex align-items-center gap-1" data-testid="plan-preview-title">
<span v-if="!multiplePlans">
{{ $t(`main.targetCharge.${noActivePlan ? "preview" : "currentPlan"}`) }}
</span>
<CustomSelect
v-else-if="noActivePlan"
:options="previewPlanOptions"
:selected="selectedPreviewId"
data-testid="preview-plan-select"
@change="selectPreviewPlan($event.target.value)"
<span v-else-if="noActivePlan">{{ $t("main.targetCharge.preview") }} #1</span>
<span v-else-if="alreadyReached">{{ $t("main.targetCharge.goalReached") }}</span>
<span v-else>{{ nextPlanTitle }}</span>
<button
v-if="showStrategy"
type="button"
class="btn btn-sm"
:class="strategyOpen ? 'btn-secondary' : 'evcc-gray'"
:aria-label="$t('main.chargingPlan.strategySettings')"
tabindex="0"
@click="strategyOpen = !strategyOpen"
>
<span class="text-decoration-underline">
{{ selectedPreviewPlanTitle }}
</span>
</CustomSelect>
<span v-else-if="alreadyReached">
{{ $t("main.targetCharge.goalReached") }}
</span>
<span v-else>
{{ nextPlanTitle }}
</span>
<shopicon-regular-adjust size="s"></shopicon-regular-adjust>
</button>
</div>
</h5>
<ChargingPlanStrategy
v-if="showStrategy"
v-bind="chargingPlanStrategyProps"
:show="strategyOpen"
@update="updatePlanStrategy"
/>
<ChargingPlanPreview v-bind="chargingPlanPreviewProps" />
<ChargingPlanWarnings v-bind="chargingPlanWarningsProps" />
</div>
@ -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<Forecast>,
},
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<number>[] {
const name = (n: number) => `${this.$t("main.targetCharge.preview")} #${n}`;
// static plan
const options = [{ value: 1, name: name(1) }] as SelectOption<number>[];
// 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<void> {
try {
const res = await this.apiFetchPlan(`loadpoints/${this.id}/plan`);
@ -240,22 +231,15 @@ export default defineComponent({
},
async fetchStaticPreviewSoc(plan: StaticSocPlan): Promise<PlanResponse | undefined> {
const timeISO = plan.time.toISOString();
const params = plan.precondition ? { precondition: plan.precondition } : undefined;
const params: Record<string, unknown> = {};
return await this.apiFetchPlan(
`loadpoints/${this.id}/plan/static/preview/soc/${plan.soc}/${timeISO}`,
params
);
},
async fetchRepeatingPreview(
plan: PartialBy<RepeatingPlan, "active">
): Promise<PlanResponse | undefined> {
return await this.apiFetchPlan(
`loadpoints/${this.id}/plan/repeating/preview/${plan.soc}/${plan.weekdays}/${plan.time}/${encodeURIComponent(plan.tz)}`
);
},
async fetchStaticPreviewEnergy(plan: StaticEnergyPlan): Promise<PlanResponse | undefined> {
const timeISO = plan.time.toISOString();
const params = plan.precondition ? { precondition: plan.precondition } : undefined;
const params: Record<string, unknown> = {};
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);
},
},
});
</script>
@ -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);

View file

@ -1,134 +0,0 @@
<template>
<!-- small screen (select, description), large screen (checkbox only) -->
<template v-if="!descriptionLgOnly">
<div class="form-check d-none d-lg-block" :style="{ padding: '2px' }">
<input
:id="id"
class="form-check-input d-none d-lg-block"
type="checkbox"
tabindex="0"
:data-testid="`${testid}-lg-toggle`"
:checked="enabled"
@change="toggle"
/>
</div>
<div class="d-flex flex-column w-100">
<select
:id="`${id}-sm`"
v-model="localValue"
class="form-select mx-0 d-lg-none"
:data-testid="`${testid}-select`"
@change="onChange"
>
<option :value="0">
{{ $t("main.chargingPlan.preconditionOptionNo") }}
</option>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.name }}
</option>
</select>
<small v-if="enabled" class="d-block d-lg-none mt-1 mb-2">
{{ $t("main.chargingPlan.preconditionDescription", { duration: valueFmt }) }}
</small>
</div>
</template>
<!-- large screen (description with select) -->
<template v-else>
<p v-if="enabled" class="m-2 d-none d-lg-block">
<strong>{{ $t("main.chargingPlan.preconditionLong") }}: </strong>
<i18n-t tag="span" scope="global" keypath="main.chargingPlan.preconditionDescription">
<template #duration>
<CustomSelect
:id="`${id}-lg`"
class="d-inline-flex"
:options="options"
:selected="localValue"
:data-testid="`${testid}-lg-select`"
@change="onChange"
>
<u>{{ valueFmt }}</u>
</CustomSelect>
</template>
</i18n-t>
</p>
</template>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import CustomSelect from "../Helper/CustomSelect.vue";
import formatter from "@/mixins/formatter";
export default defineComponent({
name: "PreconditionSelect",
components: { CustomSelect },
mixins: [formatter],
props: {
id: String,
modelValue: { type: Number, default: 0 },
testid: String,
descriptionLgOnly: Boolean,
},
emits: ["update:modelValue"],
data() {
return {
localValue: this.modelValue,
};
},
computed: {
enabled() {
return this.localValue > 0;
},
options() {
const HOUR = 60 * 60;
const HALF_HOUR = 0.5 * HOUR;
const ONE_HOUR = 1 * HOUR;
const TWO_HOURS = 2 * HOUR;
const EVERYTHING = 7 * 24 * HOUR;
const options = [HALF_HOUR, ONE_HOUR, TWO_HOURS, EVERYTHING];
// support custom values (via API)
if (this.localValue && !options.includes(this.localValue)) {
options.push(this.localValue);
}
return options.map((s) => ({
value: s,
name:
s === EVERYTHING
? this.$t("main.chargingPlan.preconditionOptionAll")
: this.fmtDurationLong(s),
}));
},
valueFmt() {
return this.options.find((o: { value: number }) => o.value === this.localValue)?.name;
},
},
watch: {
modelValue(newValue) {
this.localValue = newValue;
},
},
methods: {
toggle() {
const DEFAULT_PRECONDITION = 3600; // 1 hour
const newValue = this.localValue > 0 ? 0 : DEFAULT_PRECONDITION;
this.localValue = newValue;
this.$emit("update:modelValue", newValue);
},
onChange(event: Event) {
const value = parseInt((event.target as HTMLSelectElement).value);
this.$emit("update:modelValue", value);
},
},
});
</script>
<style scoped>
.form-check-input {
margin-left: 0.1rem;
}
</style>

View file

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

View file

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

View file

@ -178,10 +178,11 @@ export default defineComponent({
planProjectedEnd: String as PropType<string | null>,
planOverrun: { type: Number, default: 0 },
planEnergy: Number,
planPrecondition: Number,
planTime: String as PropType<string | null>,
effectivePlanTime: String as PropType<string | null>,
effectivePlanSoc: Number,
effectivePlanPrecondition: Number,
effectivePlanContinuous: Boolean,
vehicleProviderLoggedIn: Boolean,
vehicleProviderLoginPath: String,
vehicleProviderLogoutPath: String,

View file

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

View file

@ -10,6 +10,8 @@ const baseState = {
features: [],
name: "",
repeatingPlans: [],
planPrecondition: 0,
planContinuous: false,
},
enabled: false,
connected: true,

View file

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

View file

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

View file

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

View file

@ -146,7 +146,7 @@ type Loadpoint struct {
// charge planning
planner *planner.Planner
planTime time.Time // time goal
planPrecondition time.Duration // precondition duration
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
@ -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
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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
@ -35,7 +38,6 @@ type plan struct {
Id int
Start time.Time // last possible start time
End time.Time // user-selected finish time
Precondition time.Duration
Soc int
}
@ -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()
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,6 +14,7 @@ import (
type planStruct struct {
Soc int `json:"soc"`
Continuous bool `json:"continuous"`
Precondition int64 `json:"precondition"`
Time time.Time `json:"time"`
}
@ -27,11 +28,12 @@ type vehicleStruct struct {
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"`
PlanPrecondition int64 `json:"planPrecondition"`
PlanContinuous bool `json:"planContinuous"`
}
// publishVehicles returns a list of vehicle titles
@ -46,10 +48,11 @@ 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{
@ -61,15 +64,16 @@ func (site *Site) publishVehicles() {
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(),
PlanPrecondition: int64(strategy.Precondition.Seconds()),
PlanContinuous: strategy.Continuous,
}
if lp := site.coordinator.Owner(instance); lp != nil {
lp.PublishEffectiveValues()
go lp.PublishEffectiveValues()
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -636,11 +636,12 @@
"modalTitle": "План за зареждане",
"none": "няма",
"planNumber": "План {number}",
"preconditionDescription": "Зареди за {duration} преди тръгване, за да бъде кондиционирана батерията на автомобила.",
"preconditionLong": "Късно зареждане",
"preconditionOptionAll": "всичко",
"preconditionOptionNo": "не",
"preconditionShort": "Късно",
"precondition": {
"description": "Зареди за {duration} преди тръгване, за да бъде кондиционирана батерията на автомобила.",
"label": "Късно зареждане",
"optionAll": "всичко",
"optionNo": "не"
},
"remove": "Премахване",
"repeating": "повтарящ се",
"repeatingPlans": "Повтарящи се планове",

View file

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

View file

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

View file

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

View file

@ -798,11 +798,12 @@
"modalTitle": "Πρόγραμμα Φόρτισης",
"none": "κανένα",
"planNumber": "Πρόγραμμα {number}",
"preconditionDescription": "Φορτίστε {duration} πριν από την αναχώρηση για προετοιμασία μπαταρίας.",
"preconditionLong": "Καθυστερημένη φόρτιση",
"preconditionOptionAll": "Όλα",
"preconditionOptionNo": "Όχι",
"preconditionShort": "Αργά",
"precondition": {
"description": "Φορτίστε {duration} πριν από την αναχώρηση για προετοιμασία μπαταρίας.",
"label": "Καθυστερημένη φόρτιση",
"optionAll": "Όλα",
"optionNo": "Όχι"
},
"remove": "Αφαίρεση",
"repeating": "επαναλαμβανόμενο",
"repeatingPlans": "Επαναλαμβανόμενα προγράμματα",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -683,11 +683,12 @@
"modalTitle": "மின்சேர்வி திட்டம்",
"none": "எதுவுமில்லை",
"planNumber": "திட்டம் {number}",
"preconditionDescription": "பேட்டரி முன்நிபந்தனைக்கு புறப்படுவதற்கு முன் {duration} சார்ஜ் செய்யவும்.",
"preconditionLong": "தாமதமாக சார்சிங்",
"preconditionOptionAll": "எல்லாம்",
"preconditionOptionNo": "இல்லை",
"preconditionShort": "தாமதமாக",
"precondition": {
"description": "பேட்டரி முன்நிபந்தனைக்கு புறப்படுவதற்கு முன் {duration} சார்ஜ் செய்யவும்.",
"label": "தாமதமாக சார்சிங்",
"optionAll": "எல்லாம்",
"optionNo": "இல்லை"
},
"remove": "அகற்று",
"repeating": "மறுநிகழ்தல்",
"repeatingPlans": "மறுநிகழ்வு திட்டங்கள்",

View file

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

View file

@ -889,11 +889,12 @@
"modalTitle": "Тарифний план",
"none": "немає",
"planNumber": "План {number}",
"preconditionDescription": "Зарядіть {duration} перед відправленням для попередньої підготовки акумулятора.",
"preconditionLong": "Пізня зарядка",
"preconditionOptionAll": "все",
"preconditionOptionNo": "ні",
"preconditionShort": "Пізно",
"precondition": {
"description": "Зарядіть {duration} перед відправленням для попередньої підготовки акумулятора.",
"label": "Пізня зарядка",
"optionAll": "все",
"optionNo": "ні"
},
"remove": "Yсувати",
"repeating": "повторення",
"repeatingPlans": "Повторювані плани",

View file

@ -610,11 +610,12 @@
"modalTitle": "充电计划",
"none": "无",
"planNumber": "计划 {number}",
"preconditionDescription": "出发前充电 {duration} 以进行电池预处理。",
"preconditionLong": "延迟充电",
"preconditionOptionAll": "全部",
"preconditionOptionNo": "否",
"preconditionShort": "延迟",
"precondition": {
"description": "出发前充电 {duration} 以进行电池预处理。",
"label": "延迟充电",
"optionAll": "全部",
"optionNo": "否"
},
"remove": "移除",
"repeating": "重复",
"repeatingPlans": "重复计划",

View file

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

View file

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

View file

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

View file

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

View file

@ -5,13 +5,11 @@ 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"
)
@ -19,7 +17,6 @@ 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"`
}
@ -27,9 +24,12 @@ type PlanResponse struct {
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"`
// TODO remove
Continuous bool `json:"continuous"`
Precondition int64 `json:"precondition"`
}
// planHandler returns the current plan
@ -40,15 +40,14 @@ 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,
}
@ -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,
}
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,26 +127,18 @@ 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: energy,
Precondition: int64(precondition.Seconds()),
Time: ts,
}
@ -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)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

24
server/types.go Normal file
View file

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

View file

@ -16,7 +16,9 @@ meters:
loadpoints:
- title: Loadpoint
charger: charger
mode: pv
- title: Loadpoint with SoC
mode: pv
charger: chargerSoc
chargers:

View file

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