Planner: add continuous strategy (BC) (#24423)
This commit is contained in:
parent
f93b15a8c1
commit
291898fea9
68 changed files with 2245 additions and 950 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
167
assets/js/components/ChargingPlans/PlanStrategy.vue
Normal file
167
assets/js/components/ChargingPlans/PlanStrategy.vue
Normal 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>
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ const baseState = {
|
|||
features: [],
|
||||
name: "",
|
||||
repeatingPlans: [],
|
||||
planPrecondition: 0,
|
||||
planContinuous: false,
|
||||
},
|
||||
enabled: false,
|
||||
connected: true,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -57,7 +57,11 @@ const (
|
|||
EffectivePlanSoc = "effectivePlanSoc" // effective plan soc
|
||||
EffectiveMinCurrent = "effectiveMinCurrent" // effective min current
|
||||
EffectiveMaxCurrent = "effectiveMaxCurrent" // effective max current
|
||||
EffectiveLimitSoc = "effectiveLimitSoc" // effective limit soc
|
||||
|
||||
EffectiveLimitSoc = "effectiveLimitSoc" // effective limit soc
|
||||
EffectivePlanStrategy = "effectivePlanStrategy" // effective plan strategy (deprecated, use individual fields)
|
||||
EffectivePlanPrecondition = "effectivePlanPrecondition" // effective plan precondition duration
|
||||
EffectivePlanContinuous = "effectivePlanContinuous" // effective plan continuous planning
|
||||
|
||||
// measurements
|
||||
ChargePower = "chargePower" // charge power
|
||||
|
|
@ -73,15 +77,17 @@ const (
|
|||
ChargeRemainingEnergy = "chargeRemainingEnergy" // charge remaining energy
|
||||
|
||||
// plan
|
||||
Plan = "plan" // charge plan time slots
|
||||
PlanTime = "planTime" // charge plan finish time goal
|
||||
PlanEnergy = "planEnergy" // charge plan energy goal
|
||||
PlanSoc = "planSoc" // charge plan soc goal
|
||||
PlanPrecondition = "planPrecondition" // charge plan precondition duration
|
||||
PlanActive = "planActive" // charge plan has determined current slot to be an active slot
|
||||
PlanProjectedStart = "planProjectedStart" // charge plan start time (earliest slot)
|
||||
PlanProjectedEnd = "planProjectedEnd" // charge plan ends (end of last slot)
|
||||
PlanOverrun = "planOverrun" // charge plan goal not reachable in time
|
||||
Plan = "plan" // charge plan time slots
|
||||
PlanStrategy = "planStrategy" // charge plan strategy (precondition, continuous)
|
||||
PlanPrecondition = "planPrecondition" // charge plan precondition duration
|
||||
PlanContinuous = "planContinuous" // charge plan continuous planning
|
||||
|
||||
// repeating plans
|
||||
RepeatingPlans = "repeatingPlans" // key to access all repeating plans in db
|
||||
|
|
|
|||
|
|
@ -145,13 +145,13 @@ type Loadpoint struct {
|
|||
|
||||
// charge planning
|
||||
planner *planner.Planner
|
||||
planTime time.Time // time goal
|
||||
planPrecondition time.Duration // precondition duration
|
||||
planEnergy float64 // Plan charge energy in kWh (dumb vehicles)
|
||||
planEnergyOffset float64 // already charged energy in kWh when plan was set
|
||||
planSlotEnd time.Time // current plan slot end time
|
||||
planActive bool // charge plan exists and has a currently active slot
|
||||
planOverrunSent bool // notification has been sent already
|
||||
planTime time.Time // time goal
|
||||
planStrategy api.PlanStrategy // plan strategy (precondition, continuous)
|
||||
planEnergy float64 // Plan charge energy in kWh (dumb vehicles)
|
||||
planEnergyOffset float64 // already charged energy in kWh when plan was set
|
||||
planSlotEnd time.Time // current plan slot end time
|
||||
planActive bool // charge plan exists and has a currently active slot
|
||||
planOverrunSent bool // notification has been sent already
|
||||
|
||||
// cached state
|
||||
status api.ChargeStatus // Charger status
|
||||
|
|
@ -370,9 +370,14 @@ func (lp *Loadpoint) restoreSettings() {
|
|||
|
||||
t, err1 := lp.settings.Time(keys.PlanTime)
|
||||
v, err2 := lp.settings.Float(keys.PlanEnergy)
|
||||
d, _ := lp.settings.Int(keys.PlanPrecondition)
|
||||
if err1 == nil && err2 == nil {
|
||||
lp.setPlanEnergy(t, time.Duration(d)*time.Second, v)
|
||||
lp.setPlanEnergy(t, v)
|
||||
}
|
||||
|
||||
// load plan strategy (continuous mode and precondition duration)
|
||||
var planStrategy api.PlanStrategy
|
||||
if err := lp.settings.Json(keys.PlanStrategy, &planStrategy); err == nil {
|
||||
lp.setPlanStrategy(planStrategy)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -686,7 +691,8 @@ func (lp *Loadpoint) Prepare(site site.API, uiChan chan<- util.Param, pushChan c
|
|||
// restored settings
|
||||
lp.publish(keys.PlanTime, lp.planTime)
|
||||
lp.publish(keys.PlanEnergy, lp.planEnergy)
|
||||
lp.publish(keys.PlanPrecondition, lp.planPrecondition)
|
||||
lp.publish(keys.PlanPrecondition, int64(lp.planStrategy.Precondition.Seconds()))
|
||||
lp.publish(keys.PlanContinuous, lp.planStrategy.Continuous)
|
||||
lp.publish(keys.LimitSoc, lp.limitSoc)
|
||||
lp.publish(keys.LimitEnergy, lp.limitEnergy)
|
||||
|
||||
|
|
@ -983,7 +989,7 @@ func (lp *Loadpoint) repeatingPlanning() bool {
|
|||
if !lp.socBasedPlanning() {
|
||||
return false
|
||||
}
|
||||
_, _, _, id := lp.NextVehiclePlan()
|
||||
_, _, id := lp.NextVehiclePlan()
|
||||
return id > 1
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
|
||||
// PublishEffectiveValues publishes all effective values
|
||||
func (lp *Loadpoint) PublishEffectiveValues() {
|
||||
strategy := lp.EffectivePlanStrategy()
|
||||
lp.publish(keys.EffectivePriority, lp.EffectivePriority())
|
||||
lp.publish(keys.EffectivePlanId, lp.EffectivePlanId())
|
||||
lp.publish(keys.EffectivePlanTime, lp.EffectivePlanTime())
|
||||
|
|
@ -19,6 +20,8 @@ func (lp *Loadpoint) PublishEffectiveValues() {
|
|||
lp.publish(keys.EffectiveMinCurrent, lp.effectiveMinCurrent())
|
||||
lp.publish(keys.EffectiveMaxCurrent, lp.effectiveMaxCurrent())
|
||||
lp.publish(keys.EffectiveLimitSoc, lp.EffectiveLimitSoc())
|
||||
lp.publish(keys.EffectivePlanPrecondition, int64(strategy.Precondition.Seconds()))
|
||||
lp.publish(keys.EffectivePlanContinuous, strategy.Continuous)
|
||||
}
|
||||
|
||||
// EffectivePriority returns the effective priority
|
||||
|
|
@ -32,11 +35,10 @@ func (lp *Loadpoint) EffectivePriority() int {
|
|||
}
|
||||
|
||||
type plan struct {
|
||||
Id int
|
||||
Start time.Time // last possible start time
|
||||
End time.Time // user-selected finish time
|
||||
Precondition time.Duration
|
||||
Soc int
|
||||
Id int
|
||||
Start time.Time // last possible start time
|
||||
End time.Time // user-selected finish time
|
||||
Soc int
|
||||
}
|
||||
|
||||
func (lp *Loadpoint) nextActivePlan(maxPower float64, plans []plan) *plan {
|
||||
|
|
@ -60,20 +62,20 @@ func (lp *Loadpoint) nextActivePlan(maxPower float64, plans []plan) *plan {
|
|||
}
|
||||
|
||||
// NextVehiclePlan returns the next vehicle plan time, soc and id
|
||||
func (lp *Loadpoint) NextVehiclePlan() (time.Time, time.Duration, int, int) {
|
||||
func (lp *Loadpoint) NextVehiclePlan() (time.Time, int, int) {
|
||||
lp.RLock()
|
||||
defer lp.RUnlock()
|
||||
return lp.nextVehiclePlan()
|
||||
}
|
||||
|
||||
// nextVehiclePlan returns the next vehicle plan time, precondition duration, soc and id
|
||||
func (lp *Loadpoint) nextVehiclePlan() (time.Time, time.Duration, int, int) {
|
||||
// nextVehiclePlan returns the next vehicle plan time, soc, id
|
||||
func (lp *Loadpoint) nextVehiclePlan() (time.Time, int, int) {
|
||||
if v := lp.GetVehicle(); v != nil {
|
||||
var plans []plan
|
||||
|
||||
// static plan
|
||||
if planTime, precondition, soc := vehicle.Settings(lp.log, v).GetPlanSoc(); soc != 0 {
|
||||
plans = append(plans, plan{Id: 1, Precondition: precondition, Soc: soc, End: planTime})
|
||||
if planTime, soc := vehicle.Settings(lp.log, v).GetPlanSoc(); soc != 0 {
|
||||
plans = append(plans, plan{Id: 1, Soc: soc, End: planTime})
|
||||
}
|
||||
|
||||
// repeating plans
|
||||
|
|
@ -88,28 +90,27 @@ func (lp *Loadpoint) nextVehiclePlan() (time.Time, time.Duration, int, int) {
|
|||
continue
|
||||
}
|
||||
|
||||
precondition := time.Duration(rp.Precondition) * time.Second
|
||||
plans = append(plans, plan{Id: index + 2, Precondition: precondition, Soc: rp.Soc, End: planTime})
|
||||
plans = append(plans, plan{Id: index + 2, Soc: rp.Soc, End: planTime})
|
||||
}
|
||||
|
||||
// calculate earliest required plan start
|
||||
if plan := lp.nextActivePlan(lp.effectiveMaxPower(), plans); plan != nil {
|
||||
return plan.End, plan.Precondition, plan.Soc, plan.Id
|
||||
return plan.End, plan.Soc, plan.Id
|
||||
}
|
||||
}
|
||||
return time.Time{}, 0, 0, 0
|
||||
return time.Time{}, 0, 0
|
||||
}
|
||||
|
||||
// EffectivePlanSoc returns the soc target for the current plan
|
||||
func (lp *Loadpoint) EffectivePlanSoc() int {
|
||||
_, _, soc, _ := lp.NextVehiclePlan()
|
||||
_, soc, _ := lp.NextVehiclePlan()
|
||||
return soc
|
||||
}
|
||||
|
||||
// EffectivePlanId returns the id for the current plan
|
||||
func (lp *Loadpoint) EffectivePlanId() int {
|
||||
if lp.socBasedPlanning() {
|
||||
_, _, _, id := lp.NextVehiclePlan()
|
||||
_, _, id := lp.NextVehiclePlan()
|
||||
return id
|
||||
}
|
||||
if lp.planEnergy > 0 {
|
||||
|
|
@ -122,11 +123,11 @@ func (lp *Loadpoint) EffectivePlanId() int {
|
|||
// EffectivePlanTime returns the effective plan time
|
||||
func (lp *Loadpoint) EffectivePlanTime() time.Time {
|
||||
if lp.socBasedPlanning() {
|
||||
ts, _, _, _ := lp.NextVehiclePlan()
|
||||
ts, _, _ := lp.NextVehiclePlan()
|
||||
return ts
|
||||
}
|
||||
|
||||
ts, _, _ := lp.GetPlanEnergy()
|
||||
ts, _ := lp.GetPlanEnergy()
|
||||
return ts
|
||||
}
|
||||
|
||||
|
|
@ -239,3 +240,20 @@ func (lp *Loadpoint) effectiveMaxPower() float64 {
|
|||
}
|
||||
return Voltage * lp.effectiveMaxCurrent() * float64(lp.maxActivePhases())
|
||||
}
|
||||
|
||||
// EffectivePlanStrategy returns the effective plan strategy
|
||||
func (lp *Loadpoint) EffectivePlanStrategy() api.PlanStrategy {
|
||||
lp.RLock()
|
||||
defer lp.RUnlock()
|
||||
return lp.getEffectivePlanStrategy()
|
||||
}
|
||||
|
||||
func (lp *Loadpoint) getEffectivePlanStrategy() api.PlanStrategy {
|
||||
if v := lp.GetVehicle(); v != nil {
|
||||
if lp.socBasedPlanning() {
|
||||
return vehicle.Settings(lp.log, v).GetPlanStrategy()
|
||||
}
|
||||
}
|
||||
|
||||
return lp.getPlanStrategy()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
717
core/planner/planner_continuous_test.go
Normal file
717
core/planner/planner_continuous_test.go
Normal 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)")
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,24 +14,26 @@ import (
|
|||
|
||||
type planStruct struct {
|
||||
Soc int `json:"soc"`
|
||||
Continuous bool `json:"continuous"`
|
||||
Precondition int64 `json:"precondition"`
|
||||
Time time.Time `json:"time"`
|
||||
}
|
||||
|
||||
type vehicleStruct struct {
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Capacity float64 `json:"capacity,omitempty"`
|
||||
Phases int `json:"phases,omitempty"`
|
||||
MinSoc int `json:"minSoc,omitempty"`
|
||||
LimitSoc int `json:"limitSoc,omitempty"`
|
||||
MinCurrent float64 `json:"minCurrent,omitempty"`
|
||||
MaxCurrent float64 `json:"maxCurrent,omitempty"`
|
||||
MaxPower float64 `json:"maxPower,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Features []string `json:"features,omitempty"`
|
||||
Plan *planStruct `json:"plan,omitempty"`
|
||||
RepeatingPlans []api.RepeatingPlan `json:"repeatingPlans"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Capacity float64 `json:"capacity,omitempty"`
|
||||
Phases int `json:"phases,omitempty"`
|
||||
MinSoc int `json:"minSoc,omitempty"`
|
||||
LimitSoc int `json:"limitSoc,omitempty"`
|
||||
MinCurrent float64 `json:"minCurrent,omitempty"`
|
||||
MaxCurrent float64 `json:"maxCurrent,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Features []string `json:"features,omitempty"`
|
||||
Plan *planStruct `json:"plan,omitempty"`
|
||||
RepeatingPlans []api.RepeatingPlan `json:"repeatingPlans"`
|
||||
PlanPrecondition int64 `json:"planPrecondition"`
|
||||
PlanContinuous bool `json:"planContinuous"`
|
||||
}
|
||||
|
||||
// publishVehicles returns a list of vehicle titles
|
||||
|
|
@ -46,30 +48,32 @@ func (site *Site) publishVehicles() {
|
|||
}
|
||||
|
||||
ac := instance.OnIdentified()
|
||||
strategy := v.GetPlanStrategy()
|
||||
|
||||
var plan *planStruct
|
||||
if time, precondition, soc := v.GetPlanSoc(); !time.IsZero() {
|
||||
plan = &planStruct{Soc: soc, Precondition: int64(precondition.Seconds()), Time: time}
|
||||
if time, soc := v.GetPlanSoc(); !time.IsZero() {
|
||||
plan = &planStruct{Soc: soc, Precondition: int64(strategy.Precondition.Seconds()), Time: time}
|
||||
}
|
||||
|
||||
res[v.Name()] = vehicleStruct{
|
||||
Title: instance.GetTitle(),
|
||||
Icon: instance.Icon(),
|
||||
Capacity: instance.Capacity(),
|
||||
Phases: instance.Phases(),
|
||||
MinSoc: v.GetMinSoc(),
|
||||
LimitSoc: v.GetLimitSoc(),
|
||||
MinCurrent: ac.MinCurrent,
|
||||
MaxCurrent: ac.MaxCurrent,
|
||||
MaxPower: ac.MaxPower,
|
||||
Priority: ac.Priority,
|
||||
Features: lo.Map(instance.Features(), func(f api.Feature, _ int) string { return f.String() }),
|
||||
Plan: plan,
|
||||
RepeatingPlans: v.GetRepeatingPlans(),
|
||||
Title: instance.GetTitle(),
|
||||
Icon: instance.Icon(),
|
||||
Capacity: instance.Capacity(),
|
||||
Phases: instance.Phases(),
|
||||
MinSoc: v.GetMinSoc(),
|
||||
LimitSoc: v.GetLimitSoc(),
|
||||
MinCurrent: ac.MinCurrent,
|
||||
MaxCurrent: ac.MaxCurrent,
|
||||
Priority: ac.Priority,
|
||||
Features: lo.Map(instance.Features(), func(f api.Feature, _ int) string { return f.String() }),
|
||||
Plan: plan,
|
||||
RepeatingPlans: v.GetRepeatingPlans(),
|
||||
PlanPrecondition: int64(strategy.Precondition.Seconds()),
|
||||
PlanContinuous: strategy.Continuous,
|
||||
}
|
||||
|
||||
if lp := site.coordinator.Owner(instance); lp != nil {
|
||||
lp.PublishEffectiveValues()
|
||||
go lp.PublishEffectiveValues()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
11
i18n/bg.json
11
i18n/bg.json
|
|
@ -636,11 +636,12 @@
|
|||
"modalTitle": "План за зареждане",
|
||||
"none": "няма",
|
||||
"planNumber": "План {number}",
|
||||
"preconditionDescription": "Зареди за {duration} преди тръгване, за да бъде кондиционирана батерията на автомобила.",
|
||||
"preconditionLong": "Късно зареждане",
|
||||
"preconditionOptionAll": "всичко",
|
||||
"preconditionOptionNo": "не",
|
||||
"preconditionShort": "Късно",
|
||||
"precondition": {
|
||||
"description": "Зареди за {duration} преди тръгване, за да бъде кондиционирана батерията на автомобила.",
|
||||
"label": "Късно зареждане",
|
||||
"optionAll": "всичко",
|
||||
"optionNo": "не"
|
||||
},
|
||||
"remove": "Премахване",
|
||||
"repeating": "повтарящ се",
|
||||
"repeatingPlans": "Повтарящи се планове",
|
||||
|
|
|
|||
11
i18n/cs.json
11
i18n/cs.json
|
|
@ -814,11 +814,12 @@
|
|||
"modalTitle": "Plán nabíjení",
|
||||
"none": "Žádný",
|
||||
"planNumber": "Plán {number}",
|
||||
"preconditionDescription": "Nabíjení bude dokončeno {duration} před časem odjezdu. Vhodné v zimě pro zahřátí baterie či její šetření při vyšším stavu nabití.",
|
||||
"preconditionLong": "Odložené nabíjení",
|
||||
"preconditionOptionAll": "všechen",
|
||||
"preconditionOptionNo": "ne",
|
||||
"preconditionShort": "Odložené nabíjení",
|
||||
"precondition": {
|
||||
"description": "Nabíjení bude dokončeno {duration} před časem odjezdu. Vhodné v zimě pro zahřátí baterie či její šetření při vyšším stavu nabití.",
|
||||
"label": "Odložené nabíjení",
|
||||
"optionAll": "všechen",
|
||||
"optionNo": "ne"
|
||||
},
|
||||
"remove": "Odebrat",
|
||||
"repeating": "opakující se",
|
||||
"repeatingPlans": "Opakující se plány",
|
||||
|
|
|
|||
11
i18n/da.json
11
i18n/da.json
|
|
@ -889,11 +889,12 @@
|
|||
"modalTitle": "Lade plan",
|
||||
"none": "ingen",
|
||||
"planNumber": "Plan {number}",
|
||||
"preconditionDescription": "Oplad {duration} før afgang til batterikonditionering.",
|
||||
"preconditionLong": "Sen opladning",
|
||||
"preconditionOptionAll": "alt",
|
||||
"preconditionOptionNo": "nej",
|
||||
"preconditionShort": "Sen",
|
||||
"precondition": {
|
||||
"description": "Oplad {duration} før afgang til batterikonditionering.",
|
||||
"label": "Sen opladning",
|
||||
"optionAll": "alt",
|
||||
"optionNo": "nej"
|
||||
},
|
||||
"remove": "Fjern",
|
||||
"repeating": "Gentager",
|
||||
"repeatingPlans": "Gentager plan",
|
||||
|
|
|
|||
17
i18n/de.json
17
i18n/de.json
|
|
@ -877,16 +877,23 @@
|
|||
"goal": "Ladeziel",
|
||||
"modalTitle": "Ladeplanung",
|
||||
"none": "keiner",
|
||||
"optimization": {
|
||||
"cheapest": "günstigst",
|
||||
"continuous": "kontinuierlich",
|
||||
"label": "Optimierung"
|
||||
},
|
||||
"planNumber": "Plan {number}",
|
||||
"preconditionDescription": "Lade {duration} vor Abfahrt zur Batterie-Vorkonditionierung.",
|
||||
"preconditionLong": "Spätes Laden",
|
||||
"preconditionOptionAll": "alles",
|
||||
"preconditionOptionNo": "nein",
|
||||
"preconditionShort": "Spät",
|
||||
"precondition": {
|
||||
"description": "Lade {duration} vor Abfahrt zur Batterie-Vorkonditionierung.",
|
||||
"label": "Spätes Laden",
|
||||
"optionAll": "alles",
|
||||
"optionNo": "nein"
|
||||
},
|
||||
"remove": "Entfernen",
|
||||
"repeating": "wiederholend",
|
||||
"repeatingPlans": "Wiederholende Pläne",
|
||||
"selectAll": "Alle wählen",
|
||||
"strategySettings": "Strategie-Einstellungen",
|
||||
"time": "Zeit",
|
||||
"title": "Plan",
|
||||
"titleMinSoc": "Min. Ladung",
|
||||
|
|
|
|||
11
i18n/el.json
11
i18n/el.json
|
|
@ -798,11 +798,12 @@
|
|||
"modalTitle": "Πρόγραμμα Φόρτισης",
|
||||
"none": "κανένα",
|
||||
"planNumber": "Πρόγραμμα {number}",
|
||||
"preconditionDescription": "Φορτίστε {duration} πριν από την αναχώρηση για προετοιμασία μπαταρίας.",
|
||||
"preconditionLong": "Καθυστερημένη φόρτιση",
|
||||
"preconditionOptionAll": "Όλα",
|
||||
"preconditionOptionNo": "Όχι",
|
||||
"preconditionShort": "Αργά",
|
||||
"precondition": {
|
||||
"description": "Φορτίστε {duration} πριν από την αναχώρηση για προετοιμασία μπαταρίας.",
|
||||
"label": "Καθυστερημένη φόρτιση",
|
||||
"optionAll": "Όλα",
|
||||
"optionNo": "Όχι"
|
||||
},
|
||||
"remove": "Αφαίρεση",
|
||||
"repeating": "επαναλαμβανόμενο",
|
||||
"repeatingPlans": "Επαναλαμβανόμενα προγράμματα",
|
||||
|
|
|
|||
17
i18n/en.json
17
i18n/en.json
|
|
@ -877,16 +877,23 @@
|
|||
"goal": "Charging goal",
|
||||
"modalTitle": "Charging Plan",
|
||||
"none": "none",
|
||||
"optimization": {
|
||||
"cheapest": "cheapest",
|
||||
"continuous": "continuous",
|
||||
"label": "Optimization"
|
||||
},
|
||||
"planNumber": "Plan {number}",
|
||||
"preconditionDescription": "Charge {duration} before departure for battery preconditioning.",
|
||||
"preconditionLong": "Late Charging",
|
||||
"preconditionOptionAll": "everything",
|
||||
"preconditionOptionNo": "no",
|
||||
"preconditionShort": "Late",
|
||||
"precondition": {
|
||||
"description": "Charge {duration} before departure for battery preconditioning.",
|
||||
"label": "Late Charging",
|
||||
"optionAll": "everything",
|
||||
"optionNo": "no"
|
||||
},
|
||||
"remove": "Remove",
|
||||
"repeating": "repeating",
|
||||
"repeatingPlans": "Repeating plans",
|
||||
"selectAll": "Select all",
|
||||
"strategySettings": "Strategy settings",
|
||||
"time": "Time",
|
||||
"title": "Plan",
|
||||
"titleMinSoc": "Min charge",
|
||||
|
|
|
|||
11
i18n/fi.json
11
i18n/fi.json
|
|
@ -878,11 +878,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",
|
||||
|
|
|
|||
11
i18n/fr.json
11
i18n/fr.json
|
|
@ -888,11 +888,12 @@
|
|||
"modalTitle": "Planification de la charge",
|
||||
"none": "aucune",
|
||||
"planNumber": "Planning {number}",
|
||||
"preconditionDescription": "Charge {duration} avant le départ pour pré-conditionner la batterie.",
|
||||
"preconditionLong": "Chargement tardif",
|
||||
"preconditionOptionAll": "tous",
|
||||
"preconditionOptionNo": "non",
|
||||
"preconditionShort": "Tardif",
|
||||
"precondition": {
|
||||
"description": "Charge {duration} avant le départ pour pré-conditionner la batterie.",
|
||||
"label": "Chargement tardif",
|
||||
"optionAll": "tous",
|
||||
"optionNo": "non"
|
||||
},
|
||||
"remove": "Enlever",
|
||||
"repeating": "récurrent",
|
||||
"repeatingPlans": "Plannings récurrents",
|
||||
|
|
|
|||
11
i18n/hr.json
11
i18n/hr.json
|
|
@ -818,11 +818,12 @@
|
|||
"modalTitle": "Plan punjenja",
|
||||
"none": "nema, dodaj",
|
||||
"planNumber": "Plan {number}",
|
||||
"preconditionDescription": "Punjenje {duration} prije polaska kako bi se izvršilo predkondicioniranje baterije.",
|
||||
"preconditionLong": "Kasnije punjenje",
|
||||
"preconditionOptionAll": "sve",
|
||||
"preconditionOptionNo": "ne",
|
||||
"preconditionShort": "Kasno",
|
||||
"precondition": {
|
||||
"description": "Punjenje {duration} prije polaska kako bi se izvršilo predkondicioniranje baterije.",
|
||||
"label": "Kasnije punjenje",
|
||||
"optionAll": "sve",
|
||||
"optionNo": "ne"
|
||||
},
|
||||
"remove": "Ukloni",
|
||||
"repeating": "ponavljanje",
|
||||
"repeatingPlans": "Ponavljajući planovi",
|
||||
|
|
|
|||
11
i18n/hu.json
11
i18n/hu.json
|
|
@ -786,11 +786,12 @@
|
|||
"modalTitle": "Töltési Tervezet",
|
||||
"none": "nincs",
|
||||
"planNumber": "Terv {number}",
|
||||
"preconditionDescription": "Töltse {duration} indulás előtt az akkumulátor előkondícionálásához.",
|
||||
"preconditionLong": "Késleltetett Töltés",
|
||||
"preconditionOptionAll": "minden",
|
||||
"preconditionOptionNo": "nem",
|
||||
"preconditionShort": "Később",
|
||||
"precondition": {
|
||||
"description": "Töltse {duration} indulás előtt az akkumulátor előkondícionálásához.",
|
||||
"label": "Késleltetett Töltés",
|
||||
"optionAll": "minden",
|
||||
"optionNo": "nem"
|
||||
},
|
||||
"remove": "Törlés",
|
||||
"repeating": "ismétlődő",
|
||||
"repeatingPlans": "Ismétlődő tervek",
|
||||
|
|
|
|||
11
i18n/it.json
11
i18n/it.json
|
|
@ -616,11 +616,12 @@
|
|||
"modalTitle": "Piano di ricarica",
|
||||
"none": "nessuno",
|
||||
"planNumber": "Piano {number}",
|
||||
"preconditionDescription": "Carica {duration} prima della partenza per pre-condizionare la batteria.",
|
||||
"preconditionLong": "Carica ritardata",
|
||||
"preconditionOptionAll": "tutto",
|
||||
"preconditionOptionNo": "no",
|
||||
"preconditionShort": "Ritardata",
|
||||
"precondition": {
|
||||
"description": "Carica {duration} prima della partenza per pre-condizionare la batteria.",
|
||||
"label": "Carica ritardata",
|
||||
"optionAll": "tutto",
|
||||
"optionNo": "no"
|
||||
},
|
||||
"remove": "Rimuovi",
|
||||
"repeating": "ricorrente",
|
||||
"repeatingPlans": "Piani ricorrenti",
|
||||
|
|
|
|||
11
i18n/lb.json
11
i18n/lb.json
|
|
@ -887,11 +887,12 @@
|
|||
"modalTitle": "Luedplanifikatioun",
|
||||
"none": "keng",
|
||||
"planNumber": "Planifkatioun {number}",
|
||||
"preconditionDescription": "Luetzäit {duration} virum Fortfuere fir Batterie-Prekonditionéierung.",
|
||||
"preconditionLong": "Verspéit Lueden",
|
||||
"preconditionOptionAll": "Alles",
|
||||
"preconditionOptionNo": "Nee",
|
||||
"preconditionShort": "Spéit",
|
||||
"precondition": {
|
||||
"description": "Luetzäit {duration} virum Fortfuere fir Batterie-Prekonditionéierung.",
|
||||
"label": "Verspéit Lueden",
|
||||
"optionAll": "Alles",
|
||||
"optionNo": "Nee"
|
||||
},
|
||||
"remove": "Ewechhuelen",
|
||||
"repeating": "widderhuelend",
|
||||
"repeatingPlans": "Widderhuelend Planifikatiounen",
|
||||
|
|
|
|||
11
i18n/lt.json
11
i18n/lt.json
|
|
@ -889,11 +889,12 @@
|
|||
"modalTitle": "Įkrovimo planas",
|
||||
"none": "nėra",
|
||||
"planNumber": "Planas {number}",
|
||||
"preconditionDescription": "Įkrovimas {duration} prieš išvykstant, baterijos paruošimui.",
|
||||
"preconditionLong": "Vėlyvas įkrovimas",
|
||||
"preconditionOptionAll": "viskas",
|
||||
"preconditionOptionNo": "ne",
|
||||
"preconditionShort": "Vėlai",
|
||||
"precondition": {
|
||||
"description": "Įkrovimas {duration} prieš išvykstant, baterijos paruošimui.",
|
||||
"label": "Vėlyvas įkrovimas",
|
||||
"optionAll": "viskas",
|
||||
"optionNo": "ne"
|
||||
},
|
||||
"remove": "Pašalinti",
|
||||
"repeating": "kartojasi",
|
||||
"repeatingPlans": "Pasikartojantys planai",
|
||||
|
|
|
|||
11
i18n/nl.json
11
i18n/nl.json
|
|
@ -888,11 +888,12 @@
|
|||
"modalTitle": "Laad Plan",
|
||||
"none": "geen",
|
||||
"planNumber": "Schema {number}",
|
||||
"preconditionDescription": "Laad {duration} voor vertrek voor accu voorbereiding.",
|
||||
"preconditionLong": "Laat Opladen",
|
||||
"preconditionOptionAll": "alles",
|
||||
"preconditionOptionNo": "nee",
|
||||
"preconditionShort": "Laat",
|
||||
"precondition": {
|
||||
"description": "Laad {duration} voor vertrek voor accu voorbereiding.",
|
||||
"label": "Laat Opladen",
|
||||
"optionAll": "alles",
|
||||
"optionNo": "nee"
|
||||
},
|
||||
"remove": "Verwijderen",
|
||||
"repeating": "herhalend",
|
||||
"repeatingPlans": "Herhalende schema's",
|
||||
|
|
|
|||
11
i18n/pl.json
11
i18n/pl.json
|
|
@ -509,11 +509,12 @@
|
|||
"goal": "Cel ładowania",
|
||||
"modalTitle": "Plan ładowania",
|
||||
"none": "brak",
|
||||
"preconditionDescription": "Naładuj {duration} przed wyjazdem dla wstępnego kondycjonowania akumulatora.",
|
||||
"preconditionLong": "Późniejsze ładowanie",
|
||||
"preconditionOptionAll": "wszystko",
|
||||
"preconditionOptionNo": "nie",
|
||||
"preconditionShort": "Później",
|
||||
"precondition": {
|
||||
"description": "Naładuj {duration} przed wyjazdem dla wstępnego kondycjonowania akumulatora.",
|
||||
"label": "Późniejsze ładowanie",
|
||||
"optionAll": "wszystko",
|
||||
"optionNo": "nie"
|
||||
},
|
||||
"remove": "Wymaż",
|
||||
"repeating": "powtórzenie",
|
||||
"repeatingPlans": "Powtarzające się plany",
|
||||
|
|
|
|||
11
i18n/pt.json
11
i18n/pt.json
|
|
@ -887,11 +887,12 @@
|
|||
"modalTitle": "Plano de carga",
|
||||
"none": "nenhum",
|
||||
"planNumber": "Plano {number}",
|
||||
"preconditionDescription": "Carregar {duration} antes da partida para pré-condicionamento da bateria.",
|
||||
"preconditionLong": "Carga tardia",
|
||||
"preconditionOptionAll": "tudo",
|
||||
"preconditionOptionNo": "não",
|
||||
"preconditionShort": "Tarde",
|
||||
"precondition": {
|
||||
"description": "Carregar {duration} antes da partida para pré-condicionamento da bateria.",
|
||||
"label": "Carga tardia",
|
||||
"optionAll": "tudo",
|
||||
"optionNo": "não"
|
||||
},
|
||||
"remove": "Apagar",
|
||||
"repeating": "recorrente",
|
||||
"repeatingPlans": "Repetição de planos",
|
||||
|
|
|
|||
11
i18n/sv.json
11
i18n/sv.json
|
|
@ -879,11 +879,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",
|
||||
|
|
|
|||
11
i18n/ta.json
11
i18n/ta.json
|
|
@ -683,11 +683,12 @@
|
|||
"modalTitle": "மின்சேர்வி திட்டம்",
|
||||
"none": "எதுவுமில்லை",
|
||||
"planNumber": "திட்டம் {number}",
|
||||
"preconditionDescription": "பேட்டரி முன்நிபந்தனைக்கு புறப்படுவதற்கு முன் {duration} சார்ஜ் செய்யவும்.",
|
||||
"preconditionLong": "தாமதமாக சார்சிங்",
|
||||
"preconditionOptionAll": "எல்லாம்",
|
||||
"preconditionOptionNo": "இல்லை",
|
||||
"preconditionShort": "தாமதமாக",
|
||||
"precondition": {
|
||||
"description": "பேட்டரி முன்நிபந்தனைக்கு புறப்படுவதற்கு முன் {duration} சார்ஜ் செய்யவும்.",
|
||||
"label": "தாமதமாக சார்சிங்",
|
||||
"optionAll": "எல்லாம்",
|
||||
"optionNo": "இல்லை"
|
||||
},
|
||||
"remove": "அகற்று",
|
||||
"repeating": "மறுநிகழ்தல்",
|
||||
"repeatingPlans": "மறுநிகழ்வு திட்டங்கள்",
|
||||
|
|
|
|||
11
i18n/tr.json
11
i18n/tr.json
|
|
@ -817,11 +817,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”",
|
||||
|
|
|
|||
11
i18n/uk.json
11
i18n/uk.json
|
|
@ -889,11 +889,12 @@
|
|||
"modalTitle": "Тарифний план",
|
||||
"none": "немає",
|
||||
"planNumber": "План {number}",
|
||||
"preconditionDescription": "Зарядіть {duration} перед відправленням для попередньої підготовки акумулятора.",
|
||||
"preconditionLong": "Пізня зарядка",
|
||||
"preconditionOptionAll": "все",
|
||||
"preconditionOptionNo": "ні",
|
||||
"preconditionShort": "Пізно",
|
||||
"precondition": {
|
||||
"description": "Зарядіть {duration} перед відправленням для попередньої підготовки акумулятора.",
|
||||
"label": "Пізня зарядка",
|
||||
"optionAll": "все",
|
||||
"optionNo": "ні"
|
||||
},
|
||||
"remove": "Yсувати",
|
||||
"repeating": "повторення",
|
||||
"repeatingPlans": "Повторювані плани",
|
||||
|
|
|
|||
|
|
@ -610,11 +610,12 @@
|
|||
"modalTitle": "充电计划",
|
||||
"none": "无",
|
||||
"planNumber": "计划 {number}",
|
||||
"preconditionDescription": "出发前充电 {duration} 以进行电池预处理。",
|
||||
"preconditionLong": "延迟充电",
|
||||
"preconditionOptionAll": "全部",
|
||||
"preconditionOptionNo": "否",
|
||||
"preconditionShort": "延迟",
|
||||
"precondition": {
|
||||
"description": "出发前充电 {duration} 以进行电池预处理。",
|
||||
"label": "延迟充电",
|
||||
"optionAll": "全部",
|
||||
"optionNo": "否"
|
||||
},
|
||||
"remove": "移除",
|
||||
"repeating": "重复",
|
||||
"repeatingPlans": "重复计划",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)},
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,31 +5,31 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/loadpoint"
|
||||
"github.com/evcc-io/evcc/core/site"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type PlanResponse struct {
|
||||
PlanId int `json:"planId"`
|
||||
PlanTime time.Time `json:"planTime"`
|
||||
Duration int64 `json:"duration"`
|
||||
Precondition int64 `json:"precondition"`
|
||||
Plan api.Rates `json:"plan"`
|
||||
Power float64 `json:"power"`
|
||||
PlanId int `json:"planId"`
|
||||
PlanTime time.Time `json:"planTime"`
|
||||
Duration int64 `json:"duration"`
|
||||
Plan api.Rates `json:"plan"`
|
||||
Power float64 `json:"power"`
|
||||
}
|
||||
|
||||
type PlanPreviewResponse struct {
|
||||
PlanTime time.Time `json:"planTime"`
|
||||
Duration int64 `json:"duration"`
|
||||
Precondition int64 `json:"precondition"`
|
||||
Plan api.Rates `json:"plan"`
|
||||
Power float64 `json:"power"`
|
||||
PlanTime time.Time `json:"planTime"`
|
||||
Duration int64 `json:"duration"`
|
||||
Plan api.Rates `json:"plan"`
|
||||
Power float64 `json:"power"`
|
||||
|
||||
// TODO remove
|
||||
Continuous bool `json:"continuous"`
|
||||
Precondition int64 `json:"precondition"`
|
||||
}
|
||||
|
||||
// planHandler returns the current plan
|
||||
|
|
@ -40,17 +40,16 @@ func planHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
id := lp.EffectivePlanId()
|
||||
|
||||
goal, _ := lp.GetPlanGoal()
|
||||
precondition := lp.GetPlanPreCondDuration()
|
||||
requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower)
|
||||
plan := lp.GetPlan(planTime, requiredDuration, precondition)
|
||||
strategy := lp.EffectivePlanStrategy()
|
||||
plan := lp.GetPlan(planTime, requiredDuration, strategy.Precondition, strategy.Continuous)
|
||||
|
||||
res := PlanResponse{
|
||||
PlanId: id,
|
||||
PlanTime: planTime,
|
||||
Duration: int64(requiredDuration.Seconds()),
|
||||
Precondition: int64(precondition.Seconds()),
|
||||
Plan: plan,
|
||||
Power: maxPower,
|
||||
PlanId: id,
|
||||
PlanTime: planTime,
|
||||
Duration: int64(requiredDuration.Seconds()),
|
||||
Plan: plan,
|
||||
Power: maxPower,
|
||||
}
|
||||
|
||||
jsonWrite(w, res)
|
||||
|
|
@ -61,7 +60,6 @@ func planHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
query := r.URL.Query()
|
||||
|
||||
planTime, err := time.ParseInLocation(time.RFC3339, vars["time"], nil)
|
||||
if err != nil {
|
||||
|
|
@ -75,12 +73,6 @@ func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
precondition, err := parseDuration(query.Get("precondition"))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
switch typ := vars["type"]; typ {
|
||||
case "soc":
|
||||
if !lp.SocBasedPlanning() {
|
||||
|
|
@ -99,66 +91,19 @@ func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
|
||||
maxPower := lp.EffectiveMaxPower()
|
||||
requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower)
|
||||
plan := lp.GetPlan(planTime, requiredDuration, precondition)
|
||||
strategy := lp.EffectivePlanStrategy()
|
||||
|
||||
plan := lp.GetPlan(planTime, requiredDuration, strategy.Precondition, strategy.Continuous)
|
||||
|
||||
res := PlanPreviewResponse{
|
||||
PlanTime: planTime,
|
||||
Duration: int64(requiredDuration.Seconds()),
|
||||
Precondition: int64(precondition.Seconds()),
|
||||
Plan: plan,
|
||||
Power: maxPower,
|
||||
}
|
||||
PlanTime: planTime,
|
||||
Duration: int64(requiredDuration.Seconds()),
|
||||
Plan: plan,
|
||||
Power: maxPower,
|
||||
|
||||
jsonWrite(w, res)
|
||||
}
|
||||
}
|
||||
|
||||
func repeatingPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
query := r.URL.Query()
|
||||
|
||||
hourMinute := vars["time"]
|
||||
tz := vars["tz"]
|
||||
|
||||
var weekdays []int
|
||||
for weekdayStr := range strings.SplitSeq(vars["weekdays"], ",") {
|
||||
weekday, err := strconv.Atoi(weekdayStr)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, fmt.Errorf("invalid weekdays format"))
|
||||
return
|
||||
}
|
||||
weekdays = append(weekdays, weekday)
|
||||
}
|
||||
|
||||
soc, err := strconv.ParseFloat(vars["soc"], 64)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
planTime, err := util.GetNextOccurrence(weekdays, hourMinute, tz)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
precondition, err := parseDuration(query.Get("precondition"))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
maxPower := lp.EffectiveMaxPower()
|
||||
requiredDuration := lp.GetPlanRequiredDuration(soc, maxPower)
|
||||
plan := lp.GetPlan(planTime, requiredDuration, precondition)
|
||||
|
||||
res := PlanPreviewResponse{
|
||||
PlanTime: planTime,
|
||||
Duration: int64(requiredDuration.Seconds()),
|
||||
Precondition: int64(precondition.Seconds()),
|
||||
Plan: plan,
|
||||
Power: maxPower,
|
||||
// TODO remove
|
||||
Continuous: strategy.Continuous,
|
||||
Precondition: int64(strategy.Precondition.Seconds()),
|
||||
}
|
||||
|
||||
jsonWrite(w, res)
|
||||
|
|
@ -169,7 +114,6 @@ func repeatingPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
func planEnergyHandler(lp loadpoint.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
query := r.URL.Query()
|
||||
|
||||
ts, err := time.ParseInLocation(time.RFC3339, vars["time"], nil)
|
||||
if err != nil {
|
||||
|
|
@ -183,27 +127,19 @@ func planEnergyHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
precondition, err := parseDuration(query.Get("precondition"))
|
||||
if err != nil {
|
||||
if err := lp.SetPlanEnergy(ts, val); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := lp.SetPlanEnergy(ts, precondition, val); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
ts, precondition, energy := lp.GetPlanEnergy()
|
||||
ts, energy := lp.GetPlanEnergy()
|
||||
|
||||
res := struct {
|
||||
Energy float64 `json:"energy"`
|
||||
Precondition int64 `json:"precondition"`
|
||||
Time time.Time `json:"time"`
|
||||
Energy float64 `json:"energy"`
|
||||
Time time.Time `json:"time"`
|
||||
}{
|
||||
Energy: energy,
|
||||
Precondition: int64(precondition.Seconds()),
|
||||
Time: ts,
|
||||
Energy: energy,
|
||||
Time: ts,
|
||||
}
|
||||
|
||||
jsonWrite(w, res)
|
||||
|
|
@ -213,13 +149,12 @@ func planEnergyHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
// planRemoveHandler removes plan time
|
||||
func planRemoveHandler(lp loadpoint.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := lp.SetPlanEnergy(time.Time{}, 0, 0); err != nil {
|
||||
if err := lp.SetPlanEnergy(time.Time{}, 0); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
res := struct{}{}
|
||||
jsonWrite(w, res)
|
||||
jsonWrite(w, struct{}{})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -251,8 +186,7 @@ func vehicleSelectHandler(site site.API, lp loadpoint.API) http.HandlerFunc {
|
|||
func vehicleRemoveHandler(lp loadpoint.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
lp.SetVehicle(nil)
|
||||
res := struct{}{}
|
||||
jsonWrite(w, res)
|
||||
jsonWrite(w, struct{}{})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -260,7 +194,20 @@ func vehicleRemoveHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
func vehicleDetectHandler(lp loadpoint.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
lp.StartVehicleDetection()
|
||||
res := struct{}{}
|
||||
jsonWrite(w, struct{}{})
|
||||
}
|
||||
}
|
||||
|
||||
// planStrategyHandler updates plan strategy for loadpoint
|
||||
func planStrategyHandler(lp loadpoint.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := planStrategyHandlerSetter(r, lp.SetPlanStrategy); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
res := planStrategyPayloadFromApi(lp.GetPlanStrategy())
|
||||
|
||||
jsonWrite(w, res)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}{})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
24
server/types.go
Normal 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"`
|
||||
}
|
||||
|
|
@ -16,7 +16,9 @@ meters:
|
|||
loadpoints:
|
||||
- title: Loadpoint
|
||||
charger: charger
|
||||
mode: pv
|
||||
- title: Loadpoint with SoC
|
||||
mode: pv
|
||||
charger: chargerSoc
|
||||
|
||||
chargers:
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue