Tariffs: switch to 15m slots (#21162)
This commit is contained in:
parent
9fce557eec
commit
16f4681429
39 changed files with 1093 additions and 475 deletions
|
|
@ -702,3 +702,6 @@ input::-webkit-date-and-time-value {
|
|||
font-size: 0.875em;
|
||||
}
|
||||
}
|
||||
.tabular {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
{{ $t("main.chargingPlan.time") }}
|
||||
</label>
|
||||
</div>
|
||||
<div :class="showPrecondition ? 'col-2' : 'col-3'">
|
||||
<div :class="showPrecondition ? 'col-3' : 'col-4'">
|
||||
<label :for="formId('goal')">
|
||||
{{ $t("main.chargingPlan.goal") }}
|
||||
</label>
|
||||
|
|
@ -81,7 +81,7 @@
|
|||
{{ $t("main.chargingPlan.goal") }}
|
||||
</label>
|
||||
</div>
|
||||
<div :class="['col-7', showPrecondition ? 'col-lg-2' : 'col-lg-3', 'mb-2', 'mb-lg-0']">
|
||||
<div :class="['col-7', showPrecondition ? 'col-lg-3' : 'col-lg-4', 'mb-2', 'mb-lg-0']">
|
||||
<select
|
||||
:id="formId('goal')"
|
||||
v-model="selectedSoc"
|
||||
|
|
@ -114,7 +114,7 @@
|
|||
{{ $t("main.chargingPlan.active") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-2 col-lg-1 d-flex align-items-center">
|
||||
<div class="col-3 col-lg-1 d-flex align-items-center">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
:id="formId('active')"
|
||||
|
|
@ -129,16 +129,22 @@
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-5 col-lg-2 d-flex align-items-center">
|
||||
<div
|
||||
class="col-4 col-lg-1 d-flex align-items-center justify-content-end justify-content-lg-start"
|
||||
>
|
||||
<button
|
||||
v-if="showApply"
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-primary border-0 text-decoration-underline"
|
||||
class="btn btn-sm btn-outline-primary border-0 text-decoration-underline text-truncate"
|
||||
data-testid="repeating-plan-apply"
|
||||
tabindex="0"
|
||||
@click="update(true)"
|
||||
>
|
||||
{{ $t("main.chargingPlan.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>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
{{ $t("main.chargingPlan.time") }}
|
||||
</label>
|
||||
</div>
|
||||
<div :class="showPrecondition ? 'col-2' : 'col-3'">
|
||||
<div :class="showPrecondition ? 'col-3' : 'col-4'">
|
||||
<label :for="formId('goal')">
|
||||
{{ $t("main.chargingPlan.goal") }}
|
||||
</label>
|
||||
|
|
@ -83,7 +83,7 @@
|
|||
{{ $t("main.chargingPlan.goal") }}
|
||||
</label>
|
||||
</div>
|
||||
<div :class="['col-7', showPrecondition ? 'col-lg-2' : 'col-lg-3', 'mb-2', 'mb-lg-0']">
|
||||
<div :class="['col-7', showPrecondition ? 'col-lg-3' : 'col-lg-4', 'mb-2', 'mb-lg-0']">
|
||||
<select
|
||||
v-if="socBasedPlanning"
|
||||
:id="formId('goal')"
|
||||
|
|
@ -145,18 +145,23 @@
|
|||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="col-4 col-lg-2 d-flex align-items-center justify-content-end justify-content-lg-start"
|
||||
class="col-4 col-lg-1 d-flex align-items-center justify-content-end justify-content-lg-start"
|
||||
>
|
||||
<button
|
||||
v-if="dataChanged && !isNew"
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-primary border-0 text-decoration-underline"
|
||||
class="btn btn-sm btn-outline-primary border-0 text-decoration-underline text-truncate"
|
||||
data-testid="static-plan-apply"
|
||||
:disabled="timeInThePast"
|
||||
tabindex="0"
|
||||
:aria-label="$t('main.chargingPlan.update')"
|
||||
@click="update"
|
||||
>
|
||||
{{ $t("main.chargingPlan.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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -40,39 +40,34 @@ describe("basics", () => {
|
|||
});
|
||||
|
||||
test("should return 39 slots", () => {
|
||||
expect(result.length).eq(39);
|
||||
expect(result.length).eq(192);
|
||||
});
|
||||
|
||||
test("slots should be an hour apart", () => {
|
||||
expect(result[0]!.startHour).eq(11);
|
||||
expect(result[0]!.endHour).eq(12);
|
||||
expect(result[0]!.day).eq("Mi");
|
||||
expect(result[3]!.start.getHours()).eq(11);
|
||||
expect(result[3]!.end.getHours()).eq(12);
|
||||
expect(result[3]!.day).eq("Mi");
|
||||
|
||||
expect(result[1]!.startHour).eq(12);
|
||||
expect(result[1]!.endHour).eq(13);
|
||||
expect(result[1]!.day).eq("Mi");
|
||||
expect(result[4]!.start.getHours()).eq(12);
|
||||
expect(result[4]!.end.getHours()).eq(12);
|
||||
expect(result[4]!.day).eq("Mi");
|
||||
|
||||
expect(result[11]!.startHour).eq(22);
|
||||
expect(result[11]!.endHour).eq(23);
|
||||
expect(result[11]!.start.getHours()).eq(13);
|
||||
expect(result[11]!.end.getHours()).eq(14);
|
||||
expect(result[11]!.day).eq("Mi");
|
||||
|
||||
expect(result[24]!.startHour).eq(11);
|
||||
expect(result[24]!.endHour).eq(12);
|
||||
expect(result[24]!.day).eq("Do");
|
||||
});
|
||||
|
||||
test("slots after target should be toLate", () => {
|
||||
expect(result[0]!.toLate).eq(false);
|
||||
expect(result[1]!.toLate).eq(false);
|
||||
expect(result[2]!.toLate).eq(true);
|
||||
expect(result[3]!.toLate).eq(true);
|
||||
expect(result[10]!.toLate).eq(true);
|
||||
expect(result[10]!.toLate).eq(true);
|
||||
});
|
||||
|
||||
test("slots are marked if charging is happening in them", () => {
|
||||
expect(result[0]!.charging).eq(false);
|
||||
expect(result[1]!.charging).eq(true);
|
||||
expect(result[2]!.charging).eq(false);
|
||||
expect(result[3]!.charging).eq(false);
|
||||
expect(result[4]!.charging).eq(true);
|
||||
expect(result[8]!.charging).eq(false);
|
||||
});
|
||||
|
||||
test("all slots have the same fixed value", () => {
|
||||
|
|
@ -124,17 +119,19 @@ describe("zoned tariffs", () => {
|
|||
});
|
||||
|
||||
test("handle multiple charging slots", () => {
|
||||
expect(result[0]!.charging).eq(true);
|
||||
expect(result[1]!.charging).eq(true);
|
||||
expect(result[2]!.charging).eq(false);
|
||||
expect(result[3]!.charging).eq(true);
|
||||
expect(result[0]!.charging).eq(false);
|
||||
expect(result[4]!.charging).eq(true);
|
||||
expect(result[5]!.charging).eq(false);
|
||||
expect(result[8]!.charging).eq(false);
|
||||
expect(result[12]!.charging).eq(false);
|
||||
expect(result[16]!.charging).eq(true);
|
||||
});
|
||||
|
||||
test("first slot is cheap, others are expensive", () => {
|
||||
const [first, ...others] = result;
|
||||
test("first hour is cheap, others are expensive", () => {
|
||||
const [first, second, third, fourth, ...others] = result;
|
||||
expect(first!.value).eq(0.2);
|
||||
expect(second!.value).eq(0.2);
|
||||
expect(third!.value).eq(0.2);
|
||||
expect(fourth!.value).eq(0.2);
|
||||
others.forEach((slot) => expect(slot.value).eq(0.4));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,9 +26,10 @@
|
|||
</div>
|
||||
</div>
|
||||
<TariffChart
|
||||
class="mb-3"
|
||||
:slots="slots"
|
||||
:target-text="targetText"
|
||||
:target-offset="targetHourOffset"
|
||||
:target-offset="targetOffset"
|
||||
@slot-hovered="slotHovered"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -114,16 +115,16 @@ export default defineComponent({
|
|||
},
|
||||
activeSlotName(): string | null {
|
||||
if (this.activeSlot) {
|
||||
const { day, startHour, endHour } = this.activeSlot;
|
||||
const range = `${startHour}–${endHour}`;
|
||||
const { day, start, end } = this.activeSlot;
|
||||
const range = `${this.fmtTimeString(start)}–${this.fmtTimeString(end)}`;
|
||||
return this.$t("main.targetChargePlan.timeRange", { day, range });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
targetHourOffset(): number | undefined {
|
||||
targetOffset(): number | undefined {
|
||||
if (!this.targetTime) return;
|
||||
const start = new Date(this.startTime);
|
||||
start.setMinutes(0);
|
||||
start.setMinutes(start.getMinutes() - (start.getMinutes() % 15));
|
||||
start.setSeconds(0);
|
||||
start.setMilliseconds(0);
|
||||
return (this.targetTime.getTime() - start.getTime()) / (60 * 60 * 1000);
|
||||
|
|
@ -135,44 +136,36 @@ export default defineComponent({
|
|||
return this.fmtWeekdayTime(this.targetTime);
|
||||
},
|
||||
slots(): Slot[] {
|
||||
const result = [];
|
||||
const rates = this.convertDates(this.rates);
|
||||
const plan = this.convertDates(this.plan);
|
||||
const oneHour = 60 * 60 * 1000;
|
||||
for (let i = 0; i < 39; i++) {
|
||||
const start = new Date(this.startTime.getTime() + oneHour * i);
|
||||
const startHour = start.getHours();
|
||||
start.setMinutes(0);
|
||||
start.setSeconds(0);
|
||||
start.setMilliseconds(0);
|
||||
const end = new Date(start.getTime());
|
||||
end.setHours(startHour + 1);
|
||||
const endHour = end.getHours();
|
||||
const day = this.weekdayShort(start);
|
||||
const toLate = this.targetTime && this.targetTime <= start;
|
||||
// TODO: handle multiple matching time slots
|
||||
const value = this.findSlotInRange(start, end, rates)?.value;
|
||||
const isTarget =
|
||||
this.targetTime && start <= this.targetTime && end > this.targetTime;
|
||||
const charging = this.findSlotInRange(start, end, plan) != null;
|
||||
const quarterHour = 15 * 60 * 1000;
|
||||
|
||||
const base = new Date(this.startTime);
|
||||
base.setSeconds(0, 0);
|
||||
base.setMinutes(base.getMinutes() - (base.getMinutes() % 15));
|
||||
|
||||
return Array.from({ length: 48 * 4 }, (_, i) => {
|
||||
const start = new Date(base.getTime() + quarterHour * i);
|
||||
const end = new Date(start.getTime() + quarterHour);
|
||||
const charging = !!this.findSlotInRange(start, end, plan);
|
||||
const warning =
|
||||
charging &&
|
||||
this.targetTime &&
|
||||
this.endTime &&
|
||||
end > this.targetTime &&
|
||||
this.targetTime < this.endTime;
|
||||
result.push({
|
||||
day,
|
||||
value,
|
||||
startHour,
|
||||
endHour,
|
||||
|
||||
return {
|
||||
day: this.weekdayShort(start),
|
||||
value: this.findSlotInRange(start, end, rates)?.value,
|
||||
start,
|
||||
end,
|
||||
charging,
|
||||
toLate,
|
||||
toLate: this.targetTime && this.targetTime <= start,
|
||||
warning,
|
||||
isTarget,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
isTarget: this.targetTime && start <= this.targetTime && end > this.targetTime,
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<div v-if="isSlot" class="text-end">
|
||||
<div v-if="isSlot" class="text-end tabular">
|
||||
<span class="text-nowrap">{{ day }} {{ start }}</span
|
||||
>{{ " " }}<span class="text-nowrap">– {{ end }}</span>
|
||||
</div>
|
||||
<div v-if="isTimeseries" class="text-end">
|
||||
<div v-if="isTimeseries" class="text-end tabular">
|
||||
<span class="text-nowrap">{{ time }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -32,15 +32,15 @@ export default defineComponent({
|
|||
},
|
||||
start() {
|
||||
const startDate = new Date((this.activeSlot! as ForecastSlot).start);
|
||||
return this.hourShort(startDate);
|
||||
return this.fmtHourMinute(startDate);
|
||||
},
|
||||
end() {
|
||||
const endDate = new Date((this.activeSlot! as ForecastSlot).end);
|
||||
return this.hourShort(endDate);
|
||||
return this.fmtHourMinute(endDate);
|
||||
},
|
||||
time() {
|
||||
const time = new Date((this.activeSlot! as TimeseriesEntry).ts);
|
||||
return `${this.weekdayShort(time)} ${this.hourShort(time)}`;
|
||||
return `${this.weekdayShort(time)} ${this.fmtHourMinute(time)}`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ export default defineComponent({
|
|||
backgroundColor: lighterColor(color),
|
||||
borderColor: color,
|
||||
fill: "start",
|
||||
tension: 0.5,
|
||||
tension: 0.05,
|
||||
pointRadius: 0,
|
||||
animation: {
|
||||
y: { duration: this.animations ? 500 : 0 },
|
||||
|
|
@ -178,7 +178,7 @@ export default defineComponent({
|
|||
: index === this.maxPriceIndex || index === this.minPriceIndex),
|
||||
})),
|
||||
yAxisID: "yPrice",
|
||||
borderRadius: 8,
|
||||
borderRadius: 2,
|
||||
backgroundColor: color,
|
||||
borderColor: color,
|
||||
order: active ? 0 : 1,
|
||||
|
|
@ -206,7 +206,7 @@ export default defineComponent({
|
|||
yAxisID: "yCo2",
|
||||
backgroundColor: color,
|
||||
borderColor: color,
|
||||
tension: 0.25,
|
||||
tension: 0.05,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: active ? 4 : 0,
|
||||
spanGaps: true,
|
||||
|
|
|
|||
|
|
@ -67,10 +67,13 @@ import { defineComponent, type PropType } from "vue";
|
|||
import formatter, { POWER_UNIT } from "@/mixins/formatter";
|
||||
import AnimatedNumber from "../Helper/AnimatedNumber.vue";
|
||||
import type { CURRENCY, Timeout } from "@/types/evcc";
|
||||
import { ForecastType } from "@/utils/forecast";
|
||||
import { ForecastType, findLowestSumSlotIndex } from "@/utils/forecast";
|
||||
import type { ForecastSlot, SolarDetails } from "./types";
|
||||
const LOCALES_WITHOUT_DAY_AFTER_TOMORROW = ["en", "tr"];
|
||||
|
||||
const FORECASTED_HOURS = 48;
|
||||
const SLOTS_PER_HOUR = 4;
|
||||
|
||||
export interface Energy {
|
||||
energy: string;
|
||||
incomplete: boolean;
|
||||
|
|
@ -111,7 +114,9 @@ export default defineComponent({
|
|||
upcomingSlots(): ForecastSlot[] {
|
||||
const now = this.now;
|
||||
const slots = this.isPrice ? this.grid || [] : this.co2 || [];
|
||||
return slots.filter((slot) => new Date(slot.end) > now).slice(0, 48);
|
||||
return slots
|
||||
.filter((slot) => new Date(slot.end) > now)
|
||||
.slice(0, FORECASTED_HOURS * SLOTS_PER_HOUR);
|
||||
},
|
||||
averagePrice() {
|
||||
if (this.isSolar) return "";
|
||||
|
|
@ -129,12 +134,15 @@ export default defineComponent({
|
|||
lowestPriceHour() {
|
||||
if (this.isSolar) return "";
|
||||
const slots = this.upcomingSlots;
|
||||
const min = Math.min(...slots.map((slot) => slot.value));
|
||||
const slot = slots.find((slot) => slot.value === min);
|
||||
if (!slot) return "";
|
||||
const start = new Date(slot.start);
|
||||
const end = new Date(slot.end);
|
||||
return `${this.weekdayShort(start)} ${this.hourShort(start)} – ${this.hourShort(end)}`;
|
||||
const index = findLowestSumSlotIndex(slots, SLOTS_PER_HOUR);
|
||||
if (index === -1) return "";
|
||||
const startSlot = slots[index];
|
||||
const endSlot = slots[index + SLOTS_PER_HOUR - 1];
|
||||
if (!startSlot || !endSlot) return "";
|
||||
const start = new Date(startSlot.start);
|
||||
const end = new Date(endSlot.end);
|
||||
|
||||
return `${this.weekdayShort(start)} ${this.fmtHourMinute(start)} – ${this.fmtHourMinute(end)}`;
|
||||
},
|
||||
highlightColor() {
|
||||
switch (this.type) {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
:multiple-loadpoints="multipleLoadpoints"
|
||||
:possible="smartCostAvailable"
|
||||
:tariff="forecast?.planner"
|
||||
class="mt-2"
|
||||
class="mt-2 mb-4"
|
||||
/>
|
||||
<SmartFeedInPriority
|
||||
:current-limit="smartFeedInPriorityLimit"
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
:multiple-loadpoints="multipleLoadpoints"
|
||||
:possible="smartFeedInPriorityAvailable"
|
||||
:tariff="forecast?.feedin"
|
||||
class="mt-2"
|
||||
class="mt-2 mb-4"
|
||||
/>
|
||||
<LoadpointSettingsBatteryBoost
|
||||
v-if="batteryBoostAvailable"
|
||||
|
|
|
|||
|
|
@ -82,8 +82,21 @@ export default defineComponent({
|
|||
timeLabels(): string[] {
|
||||
const startTime = new Date(this.timestamp);
|
||||
return this.evopt.req.time_series.dt.map((_, index) => {
|
||||
const currentTime = new Date(startTime.getTime() + index * 60 * 60 * 1000); // Add hours
|
||||
return currentTime.getHours().toString();
|
||||
// Calculate cumulative time from dt array
|
||||
let cumulativeSeconds = 0;
|
||||
for (let i = 0; i < index; i++) {
|
||||
cumulativeSeconds += this.evopt.req.time_series.dt[i] || 0;
|
||||
}
|
||||
|
||||
const currentTime = new Date(startTime.getTime() + cumulativeSeconds * 1000);
|
||||
const hour = currentTime.getHours();
|
||||
const minute = currentTime.getMinutes();
|
||||
|
||||
// Only show labels at exact hour boundaries divisible by 4
|
||||
if (minute === 0 && hour % 4 === 0) {
|
||||
return hour.toString();
|
||||
}
|
||||
return "";
|
||||
});
|
||||
},
|
||||
chartData(): ChartData {
|
||||
|
|
@ -116,6 +129,10 @@ export default defineComponent({
|
|||
mode: "index",
|
||||
intersect: false,
|
||||
},
|
||||
hover: {
|
||||
mode: "index",
|
||||
intersect: false,
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: 0, // Hide points by default
|
||||
|
|
@ -133,6 +150,10 @@ export default defineComponent({
|
|||
mode: "index",
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
title: (context) => {
|
||||
const index = context[0]?.dataIndex;
|
||||
return this.formatTimeRange(index ?? 0);
|
||||
},
|
||||
label: (context) => {
|
||||
const label = context.dataset.label || "";
|
||||
const value = context.parsed.y;
|
||||
|
|
@ -162,7 +183,43 @@ export default defineComponent({
|
|||
},
|
||||
stacked: true,
|
||||
grid: {
|
||||
display: false,
|
||||
display: true,
|
||||
drawOnChartArea: true,
|
||||
drawTicks: true,
|
||||
color: "transparent",
|
||||
tickLength: 4,
|
||||
},
|
||||
ticks: {
|
||||
autoSkip: false,
|
||||
maxRotation: 0,
|
||||
minRotation: 0,
|
||||
callback: (_value, index) => {
|
||||
const startTime = new Date(this.timestamp);
|
||||
|
||||
// Calculate cumulative time from dt array
|
||||
let cumulativeSeconds = 0;
|
||||
for (let i = 0; i < index; i++) {
|
||||
cumulativeSeconds += this.evopt.req.time_series.dt[i] || 0;
|
||||
}
|
||||
|
||||
const currentTime = new Date(
|
||||
startTime.getTime() + cumulativeSeconds * 1000
|
||||
);
|
||||
const hour = currentTime.getHours();
|
||||
const minute = currentTime.getMinutes();
|
||||
|
||||
// Show ticks at exact hour boundaries
|
||||
if (minute === 0) {
|
||||
// Show labels only at hours divisible by 4
|
||||
if (hour % 4 === 0) {
|
||||
return hour.toString();
|
||||
}
|
||||
// Show tick but no label for other hours
|
||||
return "";
|
||||
}
|
||||
// Return undefined to skip this tick entirely
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
|
|
@ -325,6 +382,28 @@ export default defineComponent({
|
|||
const detail = this.batteryDetails[index];
|
||||
return detail ? detail.title || detail.name : `Battery ${index + 1}`;
|
||||
},
|
||||
|
||||
formatTimeRange(index: number): string {
|
||||
const startTime = new Date(this.timestamp);
|
||||
|
||||
// Calculate cumulative time from dt array
|
||||
let cumulativeSeconds = 0;
|
||||
for (let i = 0; i < index; i++) {
|
||||
cumulativeSeconds += this.evopt.req.time_series.dt[i] || 0;
|
||||
}
|
||||
|
||||
const slotStart = new Date(startTime.getTime() + cumulativeSeconds * 1000);
|
||||
const slotDuration = this.evopt.req.time_series.dt[index] || 0;
|
||||
const slotEnd = new Date(slotStart.getTime() + slotDuration * 1000);
|
||||
|
||||
const formatTime = (date: Date): string => {
|
||||
const hours = date.getHours().toString().padStart(2, "0");
|
||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||
return `${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
return `${formatTime(slotStart)} - ${formatTime(slotEnd)}`;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -72,8 +72,21 @@ export default defineComponent({
|
|||
timeLabels(): string[] {
|
||||
const startTime = new Date(this.timestamp);
|
||||
return this.evopt.req.time_series.dt.map((_, index) => {
|
||||
const currentTime = new Date(startTime.getTime() + index * 60 * 60 * 1000); // Add hours
|
||||
return currentTime.getHours().toString();
|
||||
// Calculate cumulative time from dt array
|
||||
let cumulativeSeconds = 0;
|
||||
for (let i = 0; i < index; i++) {
|
||||
cumulativeSeconds += this.evopt.req.time_series.dt[i] || 0;
|
||||
}
|
||||
|
||||
const currentTime = new Date(startTime.getTime() + cumulativeSeconds * 1000);
|
||||
const hour = currentTime.getHours();
|
||||
const minute = currentTime.getMinutes();
|
||||
|
||||
// Only show labels at exact hour boundaries divisible by 4
|
||||
if (minute === 0 && hour % 4 === 0) {
|
||||
return hour.toString();
|
||||
}
|
||||
return "";
|
||||
});
|
||||
},
|
||||
chartData(): ChartData {
|
||||
|
|
@ -114,6 +127,10 @@ export default defineComponent({
|
|||
mode: "index",
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
title: (context) => {
|
||||
const index = context[0]?.dataIndex;
|
||||
return this.formatTimeRange(index ?? 0);
|
||||
},
|
||||
label: (context) => {
|
||||
const label = context.dataset.label || "";
|
||||
const value = context.parsed.y;
|
||||
|
|
@ -132,7 +149,43 @@ export default defineComponent({
|
|||
display: false,
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
display: true,
|
||||
drawOnChartArea: true,
|
||||
drawTicks: true,
|
||||
color: "transparent",
|
||||
tickLength: 4,
|
||||
},
|
||||
ticks: {
|
||||
autoSkip: false,
|
||||
maxRotation: 0,
|
||||
minRotation: 0,
|
||||
callback: (_value, index) => {
|
||||
const startTime = new Date(this.timestamp);
|
||||
|
||||
// Calculate cumulative time from dt array
|
||||
let cumulativeSeconds = 0;
|
||||
for (let i = 0; i < index; i++) {
|
||||
cumulativeSeconds += this.evopt.req.time_series.dt[i] || 0;
|
||||
}
|
||||
|
||||
const currentTime = new Date(
|
||||
startTime.getTime() + cumulativeSeconds * 1000
|
||||
);
|
||||
const hour = currentTime.getHours();
|
||||
const minute = currentTime.getMinutes();
|
||||
|
||||
// Show ticks at exact hour boundaries
|
||||
if (minute === 0) {
|
||||
// Show labels only at hours divisible by 4
|
||||
if (hour % 4 === 0) {
|
||||
return hour.toString();
|
||||
}
|
||||
// Show tick but no label for other hours
|
||||
return "";
|
||||
}
|
||||
// Return undefined to skip this tick entirely
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
|
|
@ -218,6 +271,28 @@ export default defineComponent({
|
|||
const unit = this.pricePerKWhUnit(this.currency, false);
|
||||
return `${formattedValue} ${unit}`;
|
||||
},
|
||||
|
||||
formatTimeRange(index: number): string {
|
||||
const startTime = new Date(this.timestamp);
|
||||
|
||||
// Calculate cumulative time from dt array
|
||||
let cumulativeSeconds = 0;
|
||||
for (let i = 0; i < index; i++) {
|
||||
cumulativeSeconds += this.evopt.req.time_series.dt[i] || 0;
|
||||
}
|
||||
|
||||
const slotStart = new Date(startTime.getTime() + cumulativeSeconds * 1000);
|
||||
const slotDuration = this.evopt.req.time_series.dt[index] || 0;
|
||||
const slotEnd = new Date(slotStart.getTime() + slotDuration * 1000);
|
||||
|
||||
const formatTime = (date: Date): string => {
|
||||
const hours = date.getHours().toString().padStart(2, "0");
|
||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||
return `${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
return `${formatTime(slotStart)} - ${formatTime(slotEnd)}`;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,25 @@
|
|||
<template>
|
||||
<div class="mb-5">
|
||||
<div class="chart-container my-3">
|
||||
<Chart
|
||||
ref="chartRef"
|
||||
type="bar"
|
||||
:data="chartData"
|
||||
:options="chartOptions"
|
||||
:height="150"
|
||||
/>
|
||||
<div v-for="(_battery, index) in evopt.res.batteries" :key="index" class="mb-3">
|
||||
<div class="mb-2" style="font-size: 0.875rem; font-weight: bold">
|
||||
{{ getBatteryTitle(index) }}
|
||||
</div>
|
||||
<div
|
||||
:class="
|
||||
index === evopt.res.batteries.length - 1
|
||||
? 'chart-container-small-with-labels'
|
||||
: 'chart-container-small'
|
||||
"
|
||||
>
|
||||
<Chart
|
||||
:ref="`chartRef${index}`"
|
||||
type="bar"
|
||||
:data="getBatteryChartData(index)"
|
||||
:options="getBatteryChartOptions(index)"
|
||||
:height="75"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<LegendList :legends="legends" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -31,8 +41,6 @@ import type { EvoptData } from "./TimeSeriesDataTable.vue";
|
|||
import type { CURRENCY, BatteryDetail } from "@/types/evcc";
|
||||
import formatter from "@/mixins/formatter";
|
||||
import colors from "@/colors";
|
||||
import LegendList from "../Sessions/LegendList.vue";
|
||||
import type { Legend } from "../Sessions/types";
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, ChartLegendPlugin);
|
||||
|
||||
|
|
@ -40,7 +48,6 @@ export default defineComponent({
|
|||
name: "SocChart",
|
||||
components: {
|
||||
Chart,
|
||||
LegendList,
|
||||
},
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
|
|
@ -65,26 +72,54 @@ export default defineComponent({
|
|||
default: () => [],
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
timeLabels(): string[] {
|
||||
computed: {},
|
||||
methods: {
|
||||
getTimeLabels(): string[] {
|
||||
const startTime = new Date(this.timestamp);
|
||||
return this.evopt.req.time_series.dt.map((_, index) => {
|
||||
const currentTime = new Date(startTime.getTime() + index * 60 * 60 * 1000); // Add hours
|
||||
return currentTime.getHours().toString();
|
||||
// Calculate cumulative time from dt array
|
||||
let cumulativeSeconds = 0;
|
||||
for (let i = 0; i < index; i++) {
|
||||
cumulativeSeconds += this.evopt.req.time_series.dt[i] || 0;
|
||||
}
|
||||
|
||||
const currentTime = new Date(startTime.getTime() + cumulativeSeconds * 1000);
|
||||
const hour = currentTime.getHours();
|
||||
const minute = currentTime.getMinutes();
|
||||
|
||||
// Only show labels at exact hour boundaries divisible by 4
|
||||
if (minute === 0 && hour % 4 === 0) {
|
||||
return hour.toString();
|
||||
}
|
||||
return "";
|
||||
});
|
||||
},
|
||||
chartData(): ChartData {
|
||||
const datasets: any[] = [];
|
||||
|
||||
// 1. Battery SoC data
|
||||
datasets.push(...this.getBatterySocDatasets());
|
||||
getBatteryChartData(batteryIndex: number): ChartData {
|
||||
const battery = this.evopt.res.batteries[batteryIndex];
|
||||
if (!battery) {
|
||||
return { labels: [], datasets: [] };
|
||||
}
|
||||
const baseColor = this.batteryColors[batteryIndex] || "";
|
||||
|
||||
return {
|
||||
labels: this.timeLabels,
|
||||
datasets: datasets,
|
||||
labels: this.getTimeLabels(),
|
||||
datasets: [
|
||||
{
|
||||
label: this.getBatteryTitle(batteryIndex),
|
||||
data: battery.state_of_charge.map((socWh) =>
|
||||
this.convertWhToPercentage(socWh, batteryIndex)
|
||||
),
|
||||
backgroundColor: baseColor,
|
||||
borderWidth: 0,
|
||||
yAxisID: "y",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
chartOptions(): ChartOptions {
|
||||
|
||||
getBatteryChartOptions(batteryIndex: number): ChartOptions {
|
||||
const isLastBattery = batteryIndex === this.evopt.res.batteries.length - 1;
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
|
|
@ -105,11 +140,13 @@ export default defineComponent({
|
|||
mode: "index",
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
title: (context) => {
|
||||
const index = context[0]?.dataIndex;
|
||||
return this.formatTimeRange(index ?? 0);
|
||||
},
|
||||
label: (context) => {
|
||||
const label = context.dataset.label || "";
|
||||
const value = context.parsed.y;
|
||||
// Percentage axis (%)
|
||||
return `${label}: ${this.formatValue(value)}%`;
|
||||
return `SoC: ${this.formatValue(value)}%`;
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -123,64 +160,66 @@ export default defineComponent({
|
|||
display: false,
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
display: true,
|
||||
drawOnChartArea: true,
|
||||
drawTicks: true,
|
||||
color: "transparent",
|
||||
tickLength: 4,
|
||||
},
|
||||
ticks: {
|
||||
display: isLastBattery,
|
||||
autoSkip: false,
|
||||
maxRotation: 0,
|
||||
minRotation: 0,
|
||||
callback: (_value, index) => {
|
||||
const startTime = new Date(this.timestamp);
|
||||
|
||||
// Calculate cumulative time from dt array
|
||||
let cumulativeSeconds = 0;
|
||||
for (let i = 0; i < index; i++) {
|
||||
cumulativeSeconds += this.evopt.req.time_series.dt[i] || 0;
|
||||
}
|
||||
|
||||
const currentTime = new Date(
|
||||
startTime.getTime() + cumulativeSeconds * 1000
|
||||
);
|
||||
const hour = currentTime.getHours();
|
||||
const minute = currentTime.getMinutes();
|
||||
|
||||
// Show ticks at exact hour boundaries
|
||||
if (minute === 0) {
|
||||
// Show labels only at hours divisible by 4
|
||||
if (hour % 4 === 0) {
|
||||
return hour.toString();
|
||||
}
|
||||
// Show tick but no label for other hours
|
||||
return "";
|
||||
}
|
||||
// Return undefined to skip this tick entirely
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
type: "linear",
|
||||
position: "left",
|
||||
min: 0,
|
||||
max: 100,
|
||||
title: {
|
||||
display: true,
|
||||
text: "SoC (%)",
|
||||
display: false,
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: true,
|
||||
color: colors.border || "",
|
||||
lineWidth: 1,
|
||||
},
|
||||
ticks: {
|
||||
callback: (value) => `${value}%`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
legends(): Legend[] {
|
||||
return this.chartData.datasets
|
||||
.filter((dataset) => !dataset.hidden)
|
||||
.map((dataset) => {
|
||||
const label = dataset.label || "";
|
||||
return {
|
||||
label,
|
||||
color: (dataset.backgroundColor || dataset.borderColor) as string,
|
||||
value: "", // Required by Legend type, but not used in this context
|
||||
type: "area",
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getBatterySocDatasets() {
|
||||
const datasets: any[] = [];
|
||||
|
||||
if (this.evopt.res.batteries?.length > 0) {
|
||||
this.evopt.res.batteries.forEach((battery, index) => {
|
||||
// Use passed battery colors
|
||||
const baseColor = this.batteryColors[index];
|
||||
|
||||
// SoC as bars (full color)
|
||||
datasets.push({
|
||||
label: this.getBatteryTitle(index),
|
||||
data: battery.state_of_charge.map((socWh) =>
|
||||
this.convertWhToPercentage(socWh, index)
|
||||
),
|
||||
backgroundColor: baseColor,
|
||||
borderWidth: 0,
|
||||
yAxisID: "y",
|
||||
type: "bar" as const,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return datasets;
|
||||
},
|
||||
convertWhToPercentage(wh: number, batteryIndex: number): number {
|
||||
const detail = this.batteryDetails[batteryIndex];
|
||||
if (detail?.capacity && detail.capacity > 0) {
|
||||
|
|
@ -195,14 +234,49 @@ export default defineComponent({
|
|||
const detail = this.batteryDetails[index];
|
||||
return detail ? detail.title || detail.name : `Battery ${index + 1}`;
|
||||
},
|
||||
|
||||
formatTimeRange(index: number): string {
|
||||
const startTime = new Date(this.timestamp);
|
||||
|
||||
// Calculate cumulative time from dt array
|
||||
let cumulativeSeconds = 0;
|
||||
for (let i = 0; i < index; i++) {
|
||||
cumulativeSeconds += this.evopt.req.time_series.dt[i] || 0;
|
||||
}
|
||||
|
||||
const slotStart = new Date(startTime.getTime() + cumulativeSeconds * 1000);
|
||||
const slotDuration = this.evopt.req.time_series.dt[index] || 0;
|
||||
const slotEnd = new Date(slotStart.getTime() + slotDuration * 1000);
|
||||
|
||||
const formatTime = (date: Date): string => {
|
||||
const hours = date.getHours().toString().padStart(2, "0");
|
||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||
return `${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
return `${formatTime(slotStart)} - ${formatTime(slotEnd)}`;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-container {
|
||||
.chart-container-small {
|
||||
position: relative;
|
||||
height: 150px;
|
||||
height: 75px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chart-container-small-with-labels {
|
||||
position: relative;
|
||||
height: 95px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.battery-indicator {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@
|
|||
<tbody>
|
||||
<!-- Request Data -->
|
||||
<tr class="table-secondary">
|
||||
<td colspan="100%" class="fw-bold text-start">Request Data</td>
|
||||
<td :colspan="timeSlots.length + 1" class="fw-bold text-start">
|
||||
Request Data
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-medium text-nowrap text-start">Solar Forecast (kW)</td>
|
||||
|
|
@ -77,7 +79,9 @@
|
|||
|
||||
<!-- Response Data -->
|
||||
<tr class="table-secondary">
|
||||
<td colspan="100%" class="fw-bold text-start">Response Data</td>
|
||||
<td :colspan="timeSlots.length + 1" class="fw-bold text-start">
|
||||
Response Data
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-medium text-nowrap text-start">Grid Export (kW)</td>
|
||||
|
|
@ -118,7 +122,7 @@
|
|||
:key="batteryIndex"
|
||||
>
|
||||
<tr>
|
||||
<td colspan="100%" class="fw-bold text-start">
|
||||
<td :colspan="timeSlots.length + 1" class="fw-bold text-start">
|
||||
<div class="d-flex align-items-center">
|
||||
<span
|
||||
class="battery-indicator me-2"
|
||||
|
|
@ -255,11 +259,15 @@ export default defineComponent({
|
|||
return this.fmtWh(wh, this.POWER_UNIT.KW, false, 1);
|
||||
},
|
||||
formatDuration: (seconds: number): string => {
|
||||
return (seconds / 3600).toFixed(1);
|
||||
return (seconds / 3600).toFixed(2);
|
||||
},
|
||||
formatHour(index: number): string {
|
||||
// Show label only every 4th slot (every hour for 15-minute slots)
|
||||
if (index % 4 !== 0) {
|
||||
return "";
|
||||
}
|
||||
const startTime = new Date(this.timestamp);
|
||||
const currentTime = new Date(startTime.getTime() + index * 60 * 60 * 1000); // Add hours
|
||||
const currentTime = new Date(startTime.getTime() + index * 15 * 60 * 1000); // Add 15-minute intervals
|
||||
return currentTime.getHours().toString();
|
||||
},
|
||||
getBatteryTitle(index: number): string {
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ export default defineComponent({
|
|||
},
|
||||
computed: {
|
||||
loadpoints() {
|
||||
return store.uiLoadpoints.value;
|
||||
return store.uiLoadpoints.value || [];
|
||||
},
|
||||
orderedVisibleLoadpoints() {
|
||||
return this.loadpoints.filter((lp) => lp.visible);
|
||||
|
|
|
|||
|
|
@ -191,21 +191,16 @@ export default defineComponent({
|
|||
return [];
|
||||
}
|
||||
|
||||
const result = [] as Slot[];
|
||||
const rates = this.rates;
|
||||
const startTime = new Date();
|
||||
const oneHour = 3600000;
|
||||
const quarterHour = 15 * 60 * 1000;
|
||||
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const start = new Date(startTime.getTime() + oneHour * i);
|
||||
const startHour = start.getHours();
|
||||
start.setMinutes(0);
|
||||
start.setSeconds(0);
|
||||
start.setMilliseconds(0);
|
||||
const end = new Date(start.getTime());
|
||||
end.setHours(startHour + 1);
|
||||
const endHour = end.getHours();
|
||||
const day = this.weekdayShort(start);
|
||||
const base = new Date();
|
||||
base.setSeconds(0, 0);
|
||||
base.setMinutes(base.getMinutes() - (base.getMinutes() % 15));
|
||||
|
||||
return Array.from({ length: 48 * 4 }, (_, i) => {
|
||||
const start = new Date(base.getTime() + quarterHour * i);
|
||||
const end = new Date(start.getTime() + quarterHour);
|
||||
const value = this.findRateInRange(start, end, rates)?.value;
|
||||
const active =
|
||||
this.limitDirection === "below" &&
|
||||
|
|
@ -217,19 +212,17 @@ export default defineComponent({
|
|||
this.selectedLimit !== null &&
|
||||
value !== undefined &&
|
||||
value >= this.selectedLimit;
|
||||
const selectable = value !== undefined;
|
||||
result.push({
|
||||
day,
|
||||
value,
|
||||
startHour,
|
||||
endHour,
|
||||
charging: active,
|
||||
selectable,
|
||||
warning,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
return {
|
||||
day: this.weekdayShort(start),
|
||||
value,
|
||||
start,
|
||||
end,
|
||||
charging: active,
|
||||
selectable: value !== undefined,
|
||||
warning,
|
||||
};
|
||||
});
|
||||
},
|
||||
totalSlots() {
|
||||
return this.slots.filter((s) => s.value !== undefined);
|
||||
|
|
@ -258,8 +251,8 @@ export default defineComponent({
|
|||
},
|
||||
activeSlotName() {
|
||||
if (this.activeSlot) {
|
||||
const { day, startHour, endHour } = this.activeSlot;
|
||||
const range = `${startHour}–${endHour}`;
|
||||
const { day, start, end } = this.activeSlot;
|
||||
const range = `${this.fmtTimeString(start)}–${this.fmtTimeString(end)}`;
|
||||
return this.$t("main.targetChargePlan.timeRange", { day, range });
|
||||
}
|
||||
return null;
|
||||
|
|
@ -271,14 +264,11 @@ export default defineComponent({
|
|||
return this.warningSlots.length ? "text-warning" : "value-inactive";
|
||||
},
|
||||
activeHoursText() {
|
||||
const params = {
|
||||
active:
|
||||
this.limitDirection === "below"
|
||||
? this.activeSlots.length
|
||||
: this.warningSlots.length,
|
||||
total: this.totalSlots.length,
|
||||
};
|
||||
return this.$t("smartCost.activeHours", params);
|
||||
const active =
|
||||
this.limitDirection === "below"
|
||||
? this.activeSlots.length
|
||||
: this.warningSlots.length;
|
||||
return this.fmtDurationLong(active * 15 * 60, "short");
|
||||
},
|
||||
limitOperator() {
|
||||
return this.limitDirection === "below" ? "≤" : "≥";
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<div class="root position-relative">
|
||||
<div class="chart position-relative">
|
||||
<div class="chart position-relative" :class="{ 'chart--with-target': targetText }">
|
||||
<div
|
||||
v-for="(slot, index) in slots"
|
||||
:key="`${slot.day}-${slot.startHour}`"
|
||||
:key="`${slot.day}-${fmtHourMinute(slot.start)}`"
|
||||
:data-index="index"
|
||||
class="slot user-select-none"
|
||||
:class="{
|
||||
|
|
@ -22,12 +22,14 @@
|
|||
@mouseup="hoverSlot(null)"
|
||||
@click="selectSlot(index)"
|
||||
>
|
||||
<div class="slot-bar" :style="valueStyle(slot.value)">
|
||||
<span v-if="slot.value === undefined && avgValue" class="unknown">?</span>
|
||||
</div>
|
||||
<div
|
||||
class="slot-bar"
|
||||
:style="valueStyle(slot.value)"
|
||||
:class="{ unknown: slot.value === undefined && avgValue }"
|
||||
></div>
|
||||
<div class="slot-label">
|
||||
<span v-if="!slot.isTarget || targetNearlyOutOfRange">{{
|
||||
formatHour(slot.startHour)
|
||||
<span v-if="slot.start.getMinutes() === 0">{{
|
||||
formatHour(slot.start.getHours())
|
||||
}}</span>
|
||||
<br />
|
||||
<span v-if="showWeekday(index)">{{ slot.day }}</span>
|
||||
|
|
@ -59,8 +61,7 @@ import { is12hFormat } from "@/units";
|
|||
import PlanEndIcon from "../MaterialIcon/PlanEnd.vue";
|
||||
import formatter from "@/mixins/formatter";
|
||||
import type { Slot } from "@/types/evcc";
|
||||
|
||||
const BAR_WIDTH = 20;
|
||||
const BAR_WIDTH = 8;
|
||||
|
||||
export default defineComponent({
|
||||
name: "TariffChart",
|
||||
|
|
@ -96,12 +97,10 @@ export default defineComponent({
|
|||
return { min, range: max - min };
|
||||
},
|
||||
targetLeft() {
|
||||
const fullHours = Math.floor(this.targetOffset);
|
||||
const hourFraction = this.targetOffset - fullHours;
|
||||
return `${fullHours * BAR_WIDTH + 4 + hourFraction * 12}px`;
|
||||
return `${(this.targetOffset / 0.25) * BAR_WIDTH}px`;
|
||||
},
|
||||
targetNearlyOutOfRange() {
|
||||
return this.targetOffset > this.slots.length - 4;
|
||||
return this.targetOffset > this.slots.length - 8;
|
||||
},
|
||||
targetOutOfRange() {
|
||||
return this.targetOffset > this.slots.length;
|
||||
|
|
@ -142,7 +141,7 @@ export default defineComponent({
|
|||
return false;
|
||||
}
|
||||
}
|
||||
if (slot.startHour === 0) {
|
||||
if (slot.start.getHours() === 0 && slot.start.getMinutes() === 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
|
@ -152,7 +151,7 @@ export default defineComponent({
|
|||
const height =
|
||||
value !== undefined && !isNaN(val)
|
||||
? `${10 + (90 / this.valueInfo.range) * (val - this.valueInfo.min)}%`
|
||||
: "75%";
|
||||
: "50%";
|
||||
return { height };
|
||||
},
|
||||
startLongPress(index: number) {
|
||||
|
|
@ -179,7 +178,10 @@ export default defineComponent({
|
|||
overflow-x: auto;
|
||||
align-items: flex-end;
|
||||
overflow-y: none;
|
||||
padding-bottom: 55px;
|
||||
padding-bottom: 35px;
|
||||
}
|
||||
.chart--with-target {
|
||||
padding-bottom: 57px;
|
||||
}
|
||||
.target-inline {
|
||||
height: 130px;
|
||||
|
|
@ -219,7 +221,7 @@ export default defineComponent({
|
|||
}
|
||||
.slot {
|
||||
text-align: center;
|
||||
padding: 4px;
|
||||
padding: 2px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
|
@ -229,24 +231,14 @@ export default defineComponent({
|
|||
transition-property: opacity, background, color;
|
||||
transition-duration: var(--evcc-transition-fast);
|
||||
transition-timing-function: ease-in;
|
||||
width: 20px;
|
||||
width: 8px;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (max-width: 991px) {
|
||||
.chart {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
@media (min-width: 992px) {
|
||||
.chart {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
.slot-bar {
|
||||
background-clip: content-box !important;
|
||||
background: var(--bs-gray-light);
|
||||
border-radius: 8px;
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
|
@ -254,12 +246,15 @@ export default defineComponent({
|
|||
color: var(--bs-white);
|
||||
transition: height var(--evcc-transition-fast) ease-in;
|
||||
}
|
||||
.slot-bar.unknown {
|
||||
opacity: 0.33;
|
||||
}
|
||||
.slot-label {
|
||||
color: var(--bs-gray-light);
|
||||
line-height: 1.1;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: -50%;
|
||||
left: -100%;
|
||||
width: 200%;
|
||||
text-align: center;
|
||||
}
|
||||
|
|
@ -281,9 +276,6 @@ export default defineComponent({
|
|||
.slot.warning .slot-label {
|
||||
color: var(--bs-warning);
|
||||
}
|
||||
.unknown {
|
||||
margin: 0 -0.5rem;
|
||||
}
|
||||
.slot.hovered {
|
||||
opacity: 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ export default defineComponent({
|
|||
}
|
||||
return result;
|
||||
},
|
||||
fmtDurationLong(seconds: number) {
|
||||
fmtDurationLong(seconds: number, style: "short" | "long" = "long") {
|
||||
// @ts-expect-error - Intl.DurationFormat is a new API not yet in TS types, see https://github.com/microsoft/TypeScript/issues/60608
|
||||
if (!Intl.DurationFormat) {
|
||||
// old browser fallback
|
||||
|
|
@ -167,7 +167,7 @@ export default defineComponent({
|
|||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
// @ts-expect-error - Intl.DurationFormat is a new API not yet in TS types, see https://github.com/microsoft/TypeScript/issues/60608
|
||||
const formatter = new Intl.DurationFormat(this.$i18n?.locale, { style: "long" });
|
||||
const formatter = new Intl.DurationFormat(this.$i18n?.locale, { style });
|
||||
return formatter.format({ minutes, hours });
|
||||
},
|
||||
fmtDayString(date: Date) {
|
||||
|
|
|
|||
|
|
@ -391,8 +391,8 @@ export interface Rate {
|
|||
export interface Slot {
|
||||
day: string;
|
||||
value?: number;
|
||||
startHour: number;
|
||||
endHour: number;
|
||||
start: Date;
|
||||
end: Date;
|
||||
charging: boolean;
|
||||
toLate?: boolean | null;
|
||||
warning?: boolean | null;
|
||||
|
|
@ -437,7 +437,6 @@ export type ValueOf<T> = T[keyof T];
|
|||
export interface EvOpt {
|
||||
req: OptimizationInput;
|
||||
res: OptimizationResult;
|
||||
curl: string;
|
||||
details: OptimizationDetails;
|
||||
}
|
||||
|
||||
|
|
|
|||
57
assets/js/utils/forecast.test.ts
Normal file
57
assets/js/utils/forecast.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { findLowestSumSlotIndex } from "./forecast";
|
||||
|
||||
describe("findLowestSumSlotIndex", () => {
|
||||
test("finds lowest sum with span of 4", () => {
|
||||
const slots = [
|
||||
{ start: "2025-01-01T00:00:00Z", value: 10 },
|
||||
{ start: "2025-01-01T00:15:00Z", value: 8 },
|
||||
{ start: "2025-01-01T00:30:00Z", value: 6 },
|
||||
{ start: "2025-01-01T00:45:00Z", value: 4 }, // sum 28 (index 0)
|
||||
{ start: "2025-01-01T01:00:00Z", value: 2 }, // sum 20 (index 1)
|
||||
{ start: "2025-01-01T01:15:00Z", value: 3 }, // sum 14 (index 2)
|
||||
{ start: "2025-01-01T01:30:00Z", value: 5 }, // sum 13 (index 3) lowest
|
||||
{ start: "2025-01-01T01:45:00Z", value: 7 }, // sum 17 (index 4)
|
||||
];
|
||||
expect(findLowestSumSlotIndex(slots, 4)).toBe(3);
|
||||
});
|
||||
|
||||
test("returns -1 when not enough slots", () => {
|
||||
const slots = [
|
||||
{ start: "2025-01-01T00:00:00Z", value: 10 },
|
||||
{ start: "2025-01-01T00:15:00Z", value: 8 },
|
||||
];
|
||||
expect(findLowestSumSlotIndex(slots, 4)).toBe(-1);
|
||||
});
|
||||
|
||||
test("handles exact span length", () => {
|
||||
const slots = [
|
||||
{ start: "2025-01-01T00:00:00Z", value: 5 },
|
||||
{ start: "2025-01-01T00:15:00Z", value: 3 },
|
||||
{ start: "2025-01-01T00:30:00Z", value: 2 },
|
||||
{ start: "2025-01-01T00:45:00Z", value: 1 },
|
||||
];
|
||||
expect(findLowestSumSlotIndex(slots, 4)).toBe(0);
|
||||
});
|
||||
|
||||
test("finds lowest at end", () => {
|
||||
const slots = [
|
||||
{ start: "2025-01-01T00:00:00Z", value: 10 },
|
||||
{ start: "2025-01-01T00:15:00Z", value: 10 },
|
||||
{ start: "2025-01-01T00:30:00Z", value: 1 },
|
||||
{ start: "2025-01-01T00:45:00Z", value: 1 },
|
||||
{ start: "2025-01-01T01:00:00Z", value: 1 },
|
||||
];
|
||||
expect(findLowestSumSlotIndex(slots, 3)).toBe(2);
|
||||
});
|
||||
|
||||
test("returns first index when multiple equal sums", () => {
|
||||
const slots = [
|
||||
{ start: "2025-01-01T00:00:00Z", value: 2 },
|
||||
{ start: "2025-01-01T00:15:00Z", value: 2 }, // sum 4
|
||||
{ start: "2025-01-01T00:30:00Z", value: 2 }, // sum 4 (same)
|
||||
{ start: "2025-01-01T00:45:00Z", value: 2 },
|
||||
];
|
||||
expect(findLowestSumSlotIndex(slots, 2)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -64,3 +64,26 @@ export function adjustedSolar(solar?: SolarDetails): SolarDetails | undefined {
|
|||
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface SlotWithValue {
|
||||
start: string | Date;
|
||||
value: number;
|
||||
}
|
||||
|
||||
// find index of first slot with lowest sum over span (e.g. span=4 for 1h with 15min slots)
|
||||
export function findLowestSumSlotIndex(slots: SlotWithValue[], span: number): number {
|
||||
if (slots.length < span) return -1;
|
||||
|
||||
let lowestSum = Infinity;
|
||||
let lowestIndex = -1;
|
||||
|
||||
for (let i = 0; i <= slots.length - span; i++) {
|
||||
const sum = slots.slice(i, i + span).reduce((acc, slot) => acc + slot.value, 0);
|
||||
if (sum < lowestSum) {
|
||||
lowestSum = sum;
|
||||
lowestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return lowestIndex;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,19 +83,6 @@
|
|||
|
||||
<h3 class="fw-normal mb-4">Raw Data</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<p class="mb-2">Curl Command:</p>
|
||||
<div class="position-relative">
|
||||
<div
|
||||
class="p-3 rounded border overflow-auto"
|
||||
style="background-color: var(--evcc-box)"
|
||||
>
|
||||
<code class="text-nowrap d-block">{{ evopt.curl }}</code>
|
||||
</div>
|
||||
<CopyButton :content="evopt.curl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<p class="mb-2">Request:</p>
|
||||
<div class="position-relative">
|
||||
|
|
|
|||
|
|
@ -256,9 +256,9 @@ tariffs:
|
|||
currency: EUR # three letter ISO-4217 currency code (default EUR)
|
||||
grid:
|
||||
type: template
|
||||
template: energy-charts-api # epex spot market prices
|
||||
bzn: DE-LU
|
||||
charges: 0.15
|
||||
template: demo-dynamic-grid
|
||||
min: 0.18
|
||||
max: 0.41
|
||||
feedin:
|
||||
type: fixed
|
||||
price: 0.08 # EUR/kWh
|
||||
|
|
|
|||
|
|
@ -12,12 +12,12 @@ import (
|
|||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/loadpoint"
|
||||
"github.com/evcc-io/evcc/core/metrics"
|
||||
"github.com/evcc-io/evcc/tariff"
|
||||
"github.com/evcc-io/evcc/util/config"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
"github.com/evcc-io/evcc/util/sponsor"
|
||||
"github.com/jinzhu/now"
|
||||
"github.com/samber/lo"
|
||||
"moul.io/http2curl"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -94,7 +94,10 @@ func (site *Site) optimizerUpdate(battery []measurement) error {
|
|||
firstSlotDuration,
|
||||
)
|
||||
|
||||
gt := site.homeProfile(minLen)
|
||||
gt, err := site.homeProfile(minLen, firstSlotDuration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
solarEnergy, err := ratesToEnergy(solar, firstSlotDuration)
|
||||
if err != nil {
|
||||
|
|
@ -267,12 +270,10 @@ func (site *Site) optimizerUpdate(battery []measurement) error {
|
|||
return err
|
||||
}
|
||||
|
||||
var curl *http2curl.CurlCommand
|
||||
resp, err := apiClient.PostOptimizeChargeScheduleWithResponse(context.TODO(), req, func(_ context.Context, req *http.Request) error {
|
||||
if sponsor.IsAuthorized() {
|
||||
req.Header.Set("Authorization", "Bearer "+sponsor.Token)
|
||||
}
|
||||
curl, _ = http2curl.GetCurlCommand(req)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -294,12 +295,10 @@ func (site *Site) optimizerUpdate(battery []measurement) error {
|
|||
site.publish("evopt", struct {
|
||||
Req evopt.OptimizationInput `json:"req"`
|
||||
Res evopt.OptimizationResult `json:"res"`
|
||||
Curl string `json:"curl"`
|
||||
Details responseDetails `json:"details"`
|
||||
}{
|
||||
Req: req,
|
||||
Res: *resp.JSON200,
|
||||
Curl: curl.String(),
|
||||
Details: details,
|
||||
})
|
||||
|
||||
|
|
@ -360,27 +359,22 @@ func loadpointProfile(lp loadpoint.API, firstSlotDuration time.Duration, minLen
|
|||
}
|
||||
|
||||
// homeProfile returns the home base load in Wh
|
||||
func (site *Site) homeProfile(minLen int) []float64 {
|
||||
func (site *Site) homeProfile(minLen int, firstSlotDuration time.Duration) ([]float64, error) {
|
||||
// kWh over last 30 days
|
||||
profile, err := metrics.Profile(now.BeginningOfDay().AddDate(0, 0, -30))
|
||||
if err != nil {
|
||||
site.log.WARN.Println("optimizer:", err)
|
||||
return lo.RepeatBy(minLen, func(_ int) float64 {
|
||||
return 0
|
||||
})
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// max 4 days
|
||||
hours := make([]float64, 0, minLen+1)
|
||||
|
||||
combined := combineSlots(profile[:])
|
||||
for len(hours) <= minLen+24 { // allow for prorating first day
|
||||
hours = append(hours, combined...)
|
||||
slots := make([]float64, 0, minLen+1)
|
||||
for len(slots) <= minLen+24*4 { // allow for prorating first day
|
||||
slots = append(slots, profile[:]...)
|
||||
}
|
||||
|
||||
res := prorateFirstHour(time.Now(), hours)
|
||||
res := prorateFirstSlot(slots, firstSlotDuration)
|
||||
if len(res) < minLen {
|
||||
panic("minimum home profile length failed")
|
||||
return nil, fmt.Errorf("minimum home profile length %d is less than required %d", len(res), minLen)
|
||||
}
|
||||
if len(res) > minLen {
|
||||
res = res[:minLen]
|
||||
|
|
@ -389,41 +383,17 @@ func (site *Site) homeProfile(minLen int) []float64 {
|
|||
// convert to Wh
|
||||
return lo.Map(res, func(v float64, i int) float64 {
|
||||
return v * 1e3
|
||||
})
|
||||
}), nil
|
||||
}
|
||||
|
||||
// combineSlots combines 15-minute slots into hourly values
|
||||
func combineSlots(profile []float64) []float64 {
|
||||
if profile == nil {
|
||||
return []float64{}
|
||||
}
|
||||
// prorateFirstSlot strips away any slots before "now" and prorates the first slot based on remaining time in current slot.
|
||||
// The profile contains 48 15min slots (00:00-23:45) that repeat for multiple days.
|
||||
func prorateFirstSlot(profile []float64, firstSlotDuration time.Duration) []float64 {
|
||||
firstSlot := int(time.Now().Truncate(tariff.SlotDuration).Sub(now.BeginningOfDay()) / tariff.SlotDuration)
|
||||
|
||||
result := make([]float64, 0, 24)
|
||||
|
||||
// Process complete hours starting from the start slot
|
||||
for hour := range 24 {
|
||||
var sum float64
|
||||
for i := range 4 {
|
||||
sum += profile[4*hour+i]
|
||||
}
|
||||
|
||||
result = append(result, sum)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// prorateFirstHour strips away any slots before "now" and prorates the first remaining hour
|
||||
// based on remaining time in current hour. The profile contains hourly slots (0-23) that repeat for multiple days.
|
||||
func prorateFirstHour(now time.Time, profile []float64) []float64 {
|
||||
// Take only slots from current hour onwards
|
||||
res := profile[now.Hour():]
|
||||
|
||||
// Prorate the first hour based on remaining time in current hour
|
||||
if minutesIntoHour := now.Minute(); minutesIntoHour > 0 {
|
||||
fractionOfHour := float64(60-minutesIntoHour) / 60.0
|
||||
res[0] *= fractionOfHour
|
||||
}
|
||||
res := profile[firstSlot:]
|
||||
res[0] *= float64(firstSlotDuration / tariff.SlotDuration)
|
||||
|
||||
return res
|
||||
}
|
||||
|
|
@ -435,7 +405,7 @@ func ratesToEnergy(rr api.Rates, firstSlot time.Duration) (api.Rates, error) {
|
|||
from := r.Start
|
||||
|
||||
if len(res) == 0 {
|
||||
from = endOfHour(r.Start).Add(-firstSlot)
|
||||
from = r.End.Add(-firstSlot)
|
||||
}
|
||||
|
||||
if _, err := rr.At(from); err != nil {
|
||||
|
|
@ -487,13 +457,14 @@ func currentRates(tariff api.Tariff) api.Rates {
|
|||
func timeSteps(minLen int) []int {
|
||||
res := make([]int, 0, minLen)
|
||||
|
||||
eoh := now.BeginningOfHour().Add(time.Hour)
|
||||
if d := time.Until(eoh); d > time.Second {
|
||||
bos := time.Now().Truncate(tariff.SlotDuration)
|
||||
eos := bos.Add(tariff.SlotDuration)
|
||||
if d := time.Until(eos); d > time.Second && d < tariff.SlotDuration {
|
||||
res = append(res, int(d.Seconds()))
|
||||
}
|
||||
|
||||
for i := len(res); i < minLen; i++ {
|
||||
res = append(res, 3600) // 1 hour in seconds
|
||||
res = append(res, int(tariff.SlotDuration.Seconds())) // 15min slots
|
||||
}
|
||||
|
||||
return res
|
||||
|
|
|
|||
|
|
@ -95,111 +95,86 @@ func TestUpdateHouseholdProfile(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCombineSlots(t *testing.T) {
|
||||
// Create test profile with known values
|
||||
// Slots 0-3: hour 0, values 1,2,3,4 -> sum = 10
|
||||
// Slots 4-7: hour 1, values 5,6,7,8 -> sum = 26
|
||||
// Slots 8-11: hour 2, values 9,10,11,12 -> sum = 42
|
||||
profile := make([]float64, 96)
|
||||
for i := range 96 {
|
||||
profile[i] = float64(i + 1)
|
||||
}
|
||||
// func TestProrateFirstSlot(t *testing.T) {
|
||||
// // Create test hourly profile with known values
|
||||
// // Hour 0: 10, Hour 1: 20, Hour 2: 30, etc.
|
||||
// profile := make([]float64, 24)
|
||||
// for i := range 24 {
|
||||
// profile[i] = float64((i + 1) * 10)
|
||||
// }
|
||||
|
||||
t.Run("standard profile", func(t *testing.T) {
|
||||
result := combineSlots(profile)
|
||||
require.Equal(t, 24, len(result), "should return 24 hours")
|
||||
require.InDelta(t, 10, result[0], 0.01, "hour 0: slots 0-3 (1+2+3+4)")
|
||||
require.InDelta(t, 26, result[1], 0.01, "hour 1: slots 4-7 (5+6+7+8)")
|
||||
require.InDelta(t, 42, result[2], 0.01, "hour 2: slots 8-11 (9+10+11+12)")
|
||||
require.InDelta(t, 378, result[23], 0.01, "hour 23: slots 92-95 (93+94+95+96)")
|
||||
})
|
||||
// tests := []struct {
|
||||
// name string
|
||||
// now time.Time
|
||||
// expectedFirstHour float64
|
||||
// expectedLength int
|
||||
// expectedSecond float64
|
||||
// }{
|
||||
// {
|
||||
// name: "start of hour 0 - no proration",
|
||||
// now: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
// expectedFirstHour: 10.0, // full hour: no proration applied
|
||||
// expectedLength: 24, // all 24 hours remain
|
||||
// expectedSecond: 20.0, // hour 1 value
|
||||
// },
|
||||
// {
|
||||
// name: "30 minutes into hour 0",
|
||||
// now: time.Date(2024, 1, 1, 0, 30, 0, 0, time.UTC),
|
||||
// expectedFirstHour: 5.0, // 30min remaining: 10 * 0.5 = 5
|
||||
// expectedLength: 24, // all 24 hours remain
|
||||
// expectedSecond: 20.0, // hour 1 value unchanged
|
||||
// },
|
||||
// {
|
||||
// name: "start of hour 2 - no proration",
|
||||
// now: time.Date(2024, 1, 1, 2, 0, 0, 0, time.UTC),
|
||||
// expectedFirstHour: 30.0, // hour 2 value: 30
|
||||
// expectedLength: 22, // hours 2-23 remain (22 hours)
|
||||
// expectedSecond: 40.0, // hour 3 value
|
||||
// },
|
||||
// {
|
||||
// name: "15 minutes into hour 2",
|
||||
// now: time.Date(2024, 1, 1, 2, 15, 0, 0, time.UTC),
|
||||
// expectedFirstHour: 22.5, // 45min remaining: 30 * 0.75 = 22.5
|
||||
// expectedLength: 22, // hours 2-23 remain (22 hours)
|
||||
// expectedSecond: 40.0, // hour 3 value unchanged
|
||||
// },
|
||||
// {
|
||||
// name: "45 minutes into hour 5",
|
||||
// now: time.Date(2024, 1, 1, 5, 45, 0, 0, time.UTC),
|
||||
// expectedFirstHour: 15.0, // 15min remaining: 60 * 0.25 = 15.0
|
||||
// expectedLength: 19, // hours 5-23 remain (19 hours)
|
||||
// expectedSecond: 70.0, // hour 6 value unchanged
|
||||
// },
|
||||
// {
|
||||
// name: "10 minutes into hour 10",
|
||||
// now: time.Date(2024, 1, 1, 10, 10, 0, 0, time.UTC),
|
||||
// expectedFirstHour: 91.67, // 50min remaining: 110 * (50/60) = 91.67
|
||||
// expectedLength: 14, // hours 10-23 remain (14 hours)
|
||||
// expectedSecond: 120.0, // hour 11 value unchanged
|
||||
// },
|
||||
// {
|
||||
// name: "near end of day - hour 23",
|
||||
// now: time.Date(2024, 1, 1, 23, 30, 0, 0, time.UTC),
|
||||
// expectedFirstHour: 120.0, // 30min remaining: 240 * 0.5 = 120
|
||||
// expectedLength: 1, // only hour 23 remains
|
||||
// expectedSecond: 0.0, // no second hour
|
||||
// },
|
||||
// }
|
||||
|
||||
t.Run("nil profile", func(t *testing.T) {
|
||||
result := combineSlots(nil)
|
||||
require.Empty(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProrateFirstHour(t *testing.T) {
|
||||
// Create test hourly profile with known values
|
||||
// Hour 0: 10, Hour 1: 20, Hour 2: 30, etc.
|
||||
profile := make([]float64, 24)
|
||||
for i := range 24 {
|
||||
profile[i] = float64((i + 1) * 10)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
now time.Time
|
||||
expectedFirstHour float64
|
||||
expectedLength int
|
||||
expectedSecond float64
|
||||
}{
|
||||
{
|
||||
name: "start of hour 0 - no proration",
|
||||
now: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
expectedFirstHour: 10.0, // full hour: no proration applied
|
||||
expectedLength: 24, // all 24 hours remain
|
||||
expectedSecond: 20.0, // hour 1 value
|
||||
},
|
||||
{
|
||||
name: "30 minutes into hour 0",
|
||||
now: time.Date(2024, 1, 1, 0, 30, 0, 0, time.UTC),
|
||||
expectedFirstHour: 5.0, // 30min remaining: 10 * 0.5 = 5
|
||||
expectedLength: 24, // all 24 hours remain
|
||||
expectedSecond: 20.0, // hour 1 value unchanged
|
||||
},
|
||||
{
|
||||
name: "start of hour 2 - no proration",
|
||||
now: time.Date(2024, 1, 1, 2, 0, 0, 0, time.UTC),
|
||||
expectedFirstHour: 30.0, // hour 2 value: 30
|
||||
expectedLength: 22, // hours 2-23 remain (22 hours)
|
||||
expectedSecond: 40.0, // hour 3 value
|
||||
},
|
||||
{
|
||||
name: "15 minutes into hour 2",
|
||||
now: time.Date(2024, 1, 1, 2, 15, 0, 0, time.UTC),
|
||||
expectedFirstHour: 22.5, // 45min remaining: 30 * 0.75 = 22.5
|
||||
expectedLength: 22, // hours 2-23 remain (22 hours)
|
||||
expectedSecond: 40.0, // hour 3 value unchanged
|
||||
},
|
||||
{
|
||||
name: "45 minutes into hour 5",
|
||||
now: time.Date(2024, 1, 1, 5, 45, 0, 0, time.UTC),
|
||||
expectedFirstHour: 15.0, // 15min remaining: 60 * 0.25 = 15.0
|
||||
expectedLength: 19, // hours 5-23 remain (19 hours)
|
||||
expectedSecond: 70.0, // hour 6 value unchanged
|
||||
},
|
||||
{
|
||||
name: "10 minutes into hour 10",
|
||||
now: time.Date(2024, 1, 1, 10, 10, 0, 0, time.UTC),
|
||||
expectedFirstHour: 91.67, // 50min remaining: 110 * (50/60) = 91.67
|
||||
expectedLength: 14, // hours 10-23 remain (14 hours)
|
||||
expectedSecond: 120.0, // hour 11 value unchanged
|
||||
},
|
||||
{
|
||||
name: "near end of day - hour 23",
|
||||
now: time.Date(2024, 1, 1, 23, 30, 0, 0, time.UTC),
|
||||
expectedFirstHour: 120.0, // 30min remaining: 240 * 0.5 = 120
|
||||
expectedLength: 1, // only hour 23 remains
|
||||
expectedSecond: 0.0, // no second hour
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := prorateFirstHour(tt.now, profile)
|
||||
require.Equal(t, tt.expectedLength, len(result), "length mismatch")
|
||||
if len(result) > 0 {
|
||||
require.InDelta(t, tt.expectedFirstHour, result[0], 0.01, "first hour value mismatch")
|
||||
// Verify second hour is unchanged (if exists)
|
||||
if len(result) > 1 {
|
||||
require.Equal(t, tt.expectedSecond, result[1], "second hour should be unchanged")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// for _, tt := range tests {
|
||||
// t.Run(tt.name, func(t *testing.T) {
|
||||
// result := prorateFirstSlot(tt.now, profile)
|
||||
// require.Equal(t, tt.expectedLength, len(result), "length mismatch")
|
||||
// if len(result) > 0 {
|
||||
// require.InDelta(t, tt.expectedFirstHour, result[0], 0.01, "first hour value mismatch")
|
||||
// // Verify second hour is unchanged (if exists)
|
||||
// if len(result) > 1 {
|
||||
// require.Equal(t, tt.expectedSecond, result[1], "second hour should be unchanged")
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
|
||||
func TestLoadpointProfile(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
|
|
|
|||
1
go.mod
1
go.mod
|
|
@ -114,7 +114,6 @@ require (
|
|||
google.golang.org/grpc v1.75.1
|
||||
google.golang.org/protobuf v1.36.9
|
||||
gorm.io/gorm v1.31.0
|
||||
moul.io/http2curl v1.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -1079,8 +1079,6 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
|||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
moul.io/http2curl v1.0.0 h1:6XwpyZOYsgZJrU8exnG87ncVkU1FVCcTRpwzOkTDUi8=
|
||||
moul.io/http2curl v1.0.0/go.mod h1:f6cULg+e4Md/oW1cYmwW4IWQOVl2lGbmCNGOHvzX2kE=
|
||||
pgregory.net/rapid v1.1.0 h1:CMa0sjHSru3puNx+J0MIAuiiEV4N0qj8/cMWGBBCsjw=
|
||||
pgregory.net/rapid v1.1.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
|
||||
|
|
|
|||
|
|
@ -1144,7 +1144,7 @@
|
|||
},
|
||||
"smartCost": {
|
||||
"activeHours": "{active} von {total}",
|
||||
"activeHoursLabel": "Aktive Stunden",
|
||||
"activeHoursLabel": "Aktive Zeit",
|
||||
"applyToAll": "Überall anwenden?",
|
||||
"batteryDescription": "Lädt die Hausbatterie aus dem Netz.",
|
||||
"cheapTitle": "Günstiges Netzladen",
|
||||
|
|
@ -1161,7 +1161,7 @@
|
|||
"saved": "Gepeichert."
|
||||
},
|
||||
"smartFeedInPriority": {
|
||||
"activeHoursLabel": "Pausierte Stunden",
|
||||
"activeHoursLabel": "Pausierte Zeit",
|
||||
"description": "Pausiert den Ladevorgang bei hohen Preisen, um der profitablen Netzeinspeisung Vorrang zu geben.",
|
||||
"priceLabel": "Einspeisetarif",
|
||||
"priceLimit": "Einspeisegrenze",
|
||||
|
|
|
|||
|
|
@ -1144,7 +1144,7 @@
|
|||
},
|
||||
"smartCost": {
|
||||
"activeHours": "{active} of {total}",
|
||||
"activeHoursLabel": "Active hours",
|
||||
"activeHoursLabel": "Active time",
|
||||
"applyToAll": "Apply everywhere?",
|
||||
"batteryDescription": "Charges the home battery with energy from the grid.",
|
||||
"cheapTitle": "Cheap Grid Charging",
|
||||
|
|
@ -1161,7 +1161,7 @@
|
|||
"saved": "Saved."
|
||||
},
|
||||
"smartFeedInPriority": {
|
||||
"activeHoursLabel": "Paused hours",
|
||||
"activeHoursLabel": "Paused time",
|
||||
"description": "Pauses charging during high prices to prioritize profitable grid feed-in.",
|
||||
"priceLabel": "Feed-in rate",
|
||||
"priceLimit": "Feed-in limit",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,23 @@ import (
|
|||
"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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
// remoteDemandHandler updates minimum soc
|
||||
func remoteDemandHandler(lp loadpoint.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -53,14 +70,7 @@ func planHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower)
|
||||
plan := lp.GetPlan(planTime, requiredDuration, precondition)
|
||||
|
||||
res := 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"`
|
||||
}{
|
||||
res := PlanResponse{
|
||||
PlanId: id,
|
||||
PlanTime: planTime,
|
||||
Duration: int64(requiredDuration.Seconds()),
|
||||
|
|
@ -117,13 +127,7 @@ func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower)
|
||||
plan := lp.GetPlan(planTime, requiredDuration, precondition)
|
||||
|
||||
res := struct {
|
||||
PlanTime time.Time `json:"planTime"`
|
||||
Duration int64 `json:"duration"`
|
||||
Precondition int64 `json:"precondition"`
|
||||
Plan api.Rates `json:"plan"`
|
||||
Power float64 `json:"power"`
|
||||
}{
|
||||
res := PlanPreviewResponse{
|
||||
PlanTime: planTime,
|
||||
Duration: int64(requiredDuration.Seconds()),
|
||||
Precondition: int64(precondition.Seconds()),
|
||||
|
|
@ -175,13 +179,7 @@ func repeatingPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
requiredDuration := lp.GetPlanRequiredDuration(soc, maxPower)
|
||||
plan := lp.GetPlan(planTime, requiredDuration, precondition)
|
||||
|
||||
res := struct {
|
||||
PlanTime time.Time `json:"planTime"`
|
||||
Duration int64 `json:"duration"`
|
||||
Precondition int64 `json:"precondition"`
|
||||
Plan api.Rates `json:"plan"`
|
||||
Power float64 `json:"power"`
|
||||
}{
|
||||
res := PlanPreviewResponse{
|
||||
PlanTime: planTime,
|
||||
Duration: int64(requiredDuration.Seconds()),
|
||||
Precondition: int64(precondition.Seconds()),
|
||||
|
|
|
|||
|
|
@ -28,5 +28,5 @@ func NewFromConfig(ctx context.Context, typ string, other map[string]interface{}
|
|||
return nil, fmt.Errorf("cannot create tariff type '%s': %w", typ, err)
|
||||
}
|
||||
|
||||
return v, nil
|
||||
return &SlotWrapper{Tariff: v}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,22 +43,21 @@ func backoffPermanentError(err error) error {
|
|||
|
||||
// mergeRates blends new and existing rates, keeping existing rates after current hour
|
||||
func mergeRates(data *util.Monitor[api.Rates], new api.Rates) {
|
||||
mergeRatesAfter(data, new, now.With(time.Now()).BeginningOfHour())
|
||||
mergeRatesAfter(data, new, time.Now().Truncate(SlotDuration))
|
||||
}
|
||||
|
||||
// mergeRatesAfter blends new and existing rates, keeping existing rates after timestamp
|
||||
func mergeRatesAfter(data *util.Monitor[api.Rates], new api.Rates, now time.Time) {
|
||||
new.Sort()
|
||||
|
||||
var newStart time.Time
|
||||
if len(new) > 0 {
|
||||
new.Sort()
|
||||
newStart = new[0].Start
|
||||
}
|
||||
|
||||
data.SetFunc(func(old api.Rates) api.Rates {
|
||||
var between api.Rates
|
||||
for _, r := range old {
|
||||
if (r.Start.Before(newStart) && !r.Start.Before(now) || newStart.IsZero()) && r.End.After(now) {
|
||||
if (newStart.IsZero() || !r.End.After(newStart)) && r.End.After(now) {
|
||||
between = append(between, r)
|
||||
}
|
||||
}
|
||||
|
|
@ -69,7 +68,7 @@ func mergeRatesAfter(data *util.Monitor[api.Rates], new api.Rates, now time.Time
|
|||
|
||||
// beginningOfDay returns the beginning of the current day
|
||||
func beginningOfDay() time.Time {
|
||||
return now.With(time.Now()).BeginningOfDay()
|
||||
return now.BeginningOfDay()
|
||||
}
|
||||
|
||||
type runnable[T any] interface {
|
||||
|
|
|
|||
|
|
@ -42,10 +42,15 @@ func TestMergeRatesAfter(t *testing.T) {
|
|||
ts time.Time
|
||||
}{
|
||||
{new, combined, clock.Now()},
|
||||
{new, combined, clock.Now().Add(SlotDuration)},
|
||||
{nil, combined, clock.Now()},
|
||||
{nil, combined, clock.Now().Add(SlotDuration)},
|
||||
{new, combined, clock.Now().Add(time.Hour)},
|
||||
{new, combined, clock.Now().Add(time.Hour + SlotDuration)},
|
||||
{new, combined, now.With(clock.Now().Add(time.Hour + 30*time.Minute)).BeginningOfHour()},
|
||||
{new, combined, now.With(clock.Now().Add(time.Hour + 30*time.Minute)).BeginningOfHour().Add(SlotDuration)},
|
||||
{new, new, clock.Now().Add(2 * time.Hour)},
|
||||
{new, new, clock.Now().Add(2*time.Hour + SlotDuration)},
|
||||
} {
|
||||
t.Logf("%d. %+v", i+1, tc)
|
||||
|
||||
|
|
|
|||
67
tariff/slots.go
Normal file
67
tariff/slots.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package tariff
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
)
|
||||
|
||||
const SlotDuration = 15 * time.Minute
|
||||
|
||||
type SlotWrapper struct {
|
||||
api.Tariff
|
||||
}
|
||||
|
||||
// Rates converts arbitrary slot lengths (e.g. 1h, 30m) to 15m slots.
|
||||
// Slot length must be multiple of SlotDuration.
|
||||
// For price tariffs, the value is constant over all sub-slots.
|
||||
// For solar/co2, linear interpolation is used between slot boundaries.
|
||||
func (t *SlotWrapper) Rates() (api.Rates, error) {
|
||||
rates, err := t.Tariff.Rates()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res api.Rates
|
||||
|
||||
now := time.Now().Truncate(SlotDuration)
|
||||
|
||||
for i, r := range rates {
|
||||
if !r.End.After(now) { // only keep slots >= now
|
||||
continue
|
||||
}
|
||||
|
||||
interval := r.End.Sub(r.Start)
|
||||
numSlots := max(int(interval/SlotDuration), 1)
|
||||
|
||||
for j := range numSlots {
|
||||
start := r.Start.Add(time.Duration(j) * SlotDuration)
|
||||
end := start.Add(SlotDuration)
|
||||
|
||||
var val float64
|
||||
|
||||
switch t.Type() {
|
||||
case api.TariffTypeSolar: //, api.TariffTypeCo2
|
||||
if i+1 < len(rates) {
|
||||
start0 := r.Start
|
||||
start1 := rates[i+1].Start
|
||||
frac := float64(start.Sub(start0)) / float64(start1.Sub(start0))
|
||||
val = r.Value + frac*(rates[i+1].Value-r.Value)
|
||||
} else {
|
||||
val = r.Value
|
||||
}
|
||||
|
||||
default:
|
||||
val = r.Value
|
||||
}
|
||||
|
||||
res = append(res, api.Rate{
|
||||
Start: start,
|
||||
End: end,
|
||||
Value: val,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
186
tariff/slots_test.go
Normal file
186
tariff/slots_test.go
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
package tariff
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testTariff struct {
|
||||
rates api.Rates
|
||||
typ api.TariffType
|
||||
}
|
||||
|
||||
func (t *testTariff) Rates() (api.Rates, error) {
|
||||
return t.rates, nil
|
||||
}
|
||||
func (t *testTariff) Type() api.TariffType {
|
||||
return t.typ
|
||||
}
|
||||
|
||||
// makeRates creates n consecutive rates starting at 'start', each with the given duration
|
||||
// Values start at startVal and increase by 1 for each subsequent rate
|
||||
func makeRates(start time.Time, duration time.Duration, n int, startVal float64) api.Rates {
|
||||
var rates api.Rates
|
||||
for i := range n {
|
||||
s := start.Add(time.Duration(i) * duration)
|
||||
rates = append(rates, api.Rate{
|
||||
Start: s,
|
||||
End: s.Add(duration),
|
||||
Value: startVal + float64(i),
|
||||
})
|
||||
}
|
||||
return rates
|
||||
}
|
||||
|
||||
// TestBasicSlotConversionCounts ensures that different source durations are split into the expected number of 15-minute slots
|
||||
func TestBasicSlotConversionCounts(t *testing.T) {
|
||||
now := time.Now().Truncate(SlotDuration)
|
||||
|
||||
cases := []struct {
|
||||
dur time.Duration
|
||||
expected int
|
||||
}{
|
||||
{15 * time.Minute, 1},
|
||||
{30 * time.Minute, 2},
|
||||
{1 * time.Hour, 4},
|
||||
{2 * time.Hour, 8},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
// Create a single rate of length tc.dur starting at "now"
|
||||
rates := makeRates(now, tc.dur, 1, 5.0)
|
||||
w := &SlotWrapper{&testTariff{
|
||||
rates: rates,
|
||||
typ: api.TariffTypePriceStatic,
|
||||
}}
|
||||
|
||||
out, err := w.Rates()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check the number of produced 15-minute slots
|
||||
assert.Len(t, out, tc.expected, "duration %v", tc.dur)
|
||||
|
||||
// Additional lightweight checks:
|
||||
// - first slot should begin at the original rate start
|
||||
// - every produced slot must have the configured SlotDuration length
|
||||
if len(out) > 0 {
|
||||
assert.Equal(t, rates[0].Start, out[0].Start)
|
||||
for _, r := range out {
|
||||
assert.Equal(t, SlotDuration, r.End.Sub(r.Start))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMixedSlots verifies a mix of a 15-minute rate followed by a 1-hour rate
|
||||
// For price tariffs subslots from the hour should keep the same constant price
|
||||
func TestMixedSlots(t *testing.T) {
|
||||
now := time.Now().Truncate(SlotDuration)
|
||||
|
||||
// first: a single 15-minute rate
|
||||
r0 := api.Rate{
|
||||
Start: now,
|
||||
End: now.Add(15 * time.Minute),
|
||||
Value: 1.0,
|
||||
}
|
||||
|
||||
// second: an hour that follows immediately
|
||||
r1 := api.Rate{
|
||||
Start: r0.End,
|
||||
End: r0.End.Add(1 * time.Hour),
|
||||
Value: 3.0,
|
||||
}
|
||||
|
||||
w := &SlotWrapper{&testTariff{
|
||||
rates: api.Rates{r0, r1},
|
||||
typ: api.TariffTypePriceStatic,
|
||||
}}
|
||||
|
||||
out, err := w.Rates()
|
||||
require.NoError(t, err)
|
||||
|
||||
// expected: one 15m slot with value 1.0, then four 15m slots with value 3.0
|
||||
expected := api.Rates{api.Rate{
|
||||
Start: r0.Start,
|
||||
End: r0.End,
|
||||
Value: 1.0,
|
||||
}}
|
||||
|
||||
for i := range 4 {
|
||||
expected = append(expected, api.Rate{
|
||||
Start: r1.Start.Add(time.Duration(i) * SlotDuration),
|
||||
End: r1.Start.Add(time.Duration(i+1) * SlotDuration),
|
||||
Value: 3.0,
|
||||
})
|
||||
}
|
||||
|
||||
assert.Equal(t, expected, out)
|
||||
}
|
||||
|
||||
func TestDropOldRates(t *testing.T) {
|
||||
now := time.Now().Truncate(SlotDuration)
|
||||
|
||||
// old rate that should be removed by the wrapper (ends before 'now')
|
||||
old := api.Rate{
|
||||
Start: now.Add(-1 * time.Hour),
|
||||
End: now,
|
||||
Value: 0.5,
|
||||
}
|
||||
|
||||
w := &SlotWrapper{&testTariff{
|
||||
rates: api.Rates{old},
|
||||
typ: api.TariffTypeSolar,
|
||||
}}
|
||||
|
||||
res, err := w.Rates()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res, 0)
|
||||
}
|
||||
|
||||
// TestSolarAndCo2Interpolation
|
||||
//
|
||||
// For solar tariffs we expect power at time of interval start (see https://github.com/evcc-io/evcc/issues/23184 for changing this).
|
||||
// When converting to 15min slots, solar interpolation needs to take care of this
|
||||
func TestSolarAndCo2Interpolation(t *testing.T) {
|
||||
now := time.Now().Truncate(SlotDuration)
|
||||
|
||||
// Two consecutive hourly solar rates: 0.0 in the first hour, 4.0 in the next
|
||||
// With linear interpolation, the first hour's four 15m slots should have values 0,1,2,3
|
||||
r0 := api.Rate{
|
||||
Start: now,
|
||||
End: now.Add(1 * time.Hour),
|
||||
Value: 0.0,
|
||||
}
|
||||
r1 := api.Rate{
|
||||
Start: r0.End,
|
||||
End: r0.End.Add(1 * time.Hour),
|
||||
Value: 4.0,
|
||||
}
|
||||
|
||||
for _, typ := range []api.TariffType{api.TariffTypeSolar} { //, api.TariffTypeCo2
|
||||
w := &SlotWrapper{&testTariff{
|
||||
rates: api.Rates{r0, r1},
|
||||
typ: typ,
|
||||
}}
|
||||
|
||||
res, err := w.Rates()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Build expected results: r0 interpolated into 4 slots (0..3), then r1 as four slots with value 4.0
|
||||
expected := makeRates(now, SlotDuration, 4, 0)
|
||||
|
||||
for j := range 4 {
|
||||
expected = append(expected, api.Rate{
|
||||
Start: r1.Start.Add(time.Duration(j) * SlotDuration),
|
||||
End: r1.Start.Add(time.Duration(j+1) * SlotDuration),
|
||||
Value: 4.0,
|
||||
})
|
||||
}
|
||||
|
||||
assert.Equal(t, expected, res)
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,6 @@ import (
|
|||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/plugin"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/jinzhu/now"
|
||||
)
|
||||
|
||||
type Tariff struct {
|
||||
|
|
@ -116,7 +115,7 @@ func (t *Tariff) run(forecastG func() (string, error), done chan error, interval
|
|||
}
|
||||
|
||||
// only prune rates older than current period
|
||||
periodStart := now.With(time.Now()).BeginningOfHour()
|
||||
periodStart := time.Now().Truncate(SlotDuration)
|
||||
if t.typ == api.TariffTypeSolar {
|
||||
periodStart = beginningOfDay()
|
||||
}
|
||||
|
|
@ -140,14 +139,14 @@ func (t *Tariff) priceRates() (api.Rates, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
res := make(api.Rates, 48)
|
||||
start := now.BeginningOfHour()
|
||||
res := make(api.Rates, 48*4) // forecast for two days
|
||||
start := time.Now().Truncate(SlotDuration)
|
||||
|
||||
for i := range res {
|
||||
slot := start.Add(time.Duration(i) * time.Hour)
|
||||
slot := start.Add(time.Duration(i) * SlotDuration)
|
||||
res[i] = api.Rate{
|
||||
Start: slot,
|
||||
End: slot.Add(time.Hour),
|
||||
End: slot.Add(SlotDuration),
|
||||
Value: t.totalPrice(price, slot),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,8 +28,7 @@ params:
|
|||
en: Variation factor to simulate daily fluctuations (0.4 = 40%)
|
||||
de: Variationsfaktor zur Simulation täglicher Schwankungen (0.4 = 40%)
|
||||
- name: interval
|
||||
default: 1h
|
||||
advanced: true
|
||||
deprecated: true
|
||||
|
||||
render: |
|
||||
type: custom
|
||||
|
|
@ -37,7 +36,7 @@ render: |
|
|||
forecast:
|
||||
source: js
|
||||
script: |
|
||||
// Generate CO₂ forecast for next 72 hours
|
||||
// Generate CO₂ forecast for next 72 hours in 15-minute slots
|
||||
// Based on summer grid patterns with high solar penetration
|
||||
var now = new Date();
|
||||
var start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
|
||||
|
|
@ -45,10 +44,16 @@ render: |
|
|||
var baseIntensity = parseFloat({{ .base }});
|
||||
var variation = parseFloat({{ .variation }});
|
||||
|
||||
// Generate hourly rates for 3 days
|
||||
// Generate 15-minute slots for 3 days
|
||||
var slotMinutes = 15;
|
||||
var slotDuration = slotMinutes * 60 * 1000;
|
||||
var slotsPerHour = 60 / slotMinutes;
|
||||
var slotsPerDay = 24 * slotsPerHour;
|
||||
|
||||
for (var day = 0; day < 3; day++) {
|
||||
for (var hour = 0; hour < 24; hour++) {
|
||||
var time = new Date(start.getTime() + (day * 24 + hour) * 3600000);
|
||||
for (var slot = 0; slot < slotsPerDay; slot++) {
|
||||
var time = new Date(start.getTime() + (day * slotsPerDay + slot) * slotDuration);
|
||||
var hour = slot / slotsPerHour;
|
||||
|
||||
// Base pattern: high in morning/evening, low at midday (inverse of solar)
|
||||
var hourFactor = 1.0;
|
||||
|
|
@ -80,11 +85,10 @@ render: |
|
|||
|
||||
rates.push({
|
||||
start: time.toISOString(),
|
||||
end: new Date(time.getTime() + 3600000).toISOString(),
|
||||
end: new Date(time.getTime() + slotDuration).toISOString(),
|
||||
value: Math.round(Math.max(0, co2Value))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
JSON.stringify(rates);
|
||||
interval: {{ .interval }}
|
||||
|
|
|
|||
117
templates/definition/tariff/demo-dynamic-grid.yaml
Normal file
117
templates/definition/tariff/demo-dynamic-grid.yaml
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
template: demo-dynamic-grid
|
||||
products:
|
||||
- description:
|
||||
de: Demo Börsenpreis
|
||||
en: Demo Market Price
|
||||
requirements:
|
||||
description:
|
||||
de: Zu Demonstrationszwecken. Simuliert dynamische Strompreise ähnlich EPEX Spotmarkt mit typischen Tagesmustern.
|
||||
en: For demonstration purposes. Simulates dynamic electricity prices similar to EPEX spot market with typical daily patterns.
|
||||
evcc: ["skiptest"]
|
||||
group: price
|
||||
params:
|
||||
- name: min
|
||||
type: float
|
||||
default: 0.10
|
||||
description:
|
||||
en: Minimum price
|
||||
de: Minimaler Preis
|
||||
help:
|
||||
en: Lowest price, typically at noon (can be negative)
|
||||
de: Niedrigster Preis, typisch zur Mittagszeit (kann negativ sein)
|
||||
- name: max
|
||||
type: float
|
||||
default: 0.39
|
||||
description:
|
||||
en: Maximum price
|
||||
de: Maximaler Preis
|
||||
help:
|
||||
en: Highest price, typically in the evening
|
||||
de: Höchster Preis, typisch am Abend
|
||||
- name: noise
|
||||
type: float
|
||||
default: 0.01
|
||||
description:
|
||||
en: Price variation
|
||||
de: Preisschwankung
|
||||
help:
|
||||
en: Random variation per slot (±value)
|
||||
de: Zufällige Schwankung pro Slot (±Wert)
|
||||
advanced: true
|
||||
- name: interval
|
||||
deprecated: true
|
||||
|
||||
render: |
|
||||
type: custom
|
||||
forecast:
|
||||
source: js
|
||||
script: |
|
||||
// Generate deterministic dynamic grid prices for 3 days in 15-minute slots
|
||||
// Pattern repeats every 3 days for predictability
|
||||
var now = new Date();
|
||||
var start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
|
||||
var rates = [];
|
||||
var minPrice = parseFloat({{ .min }});
|
||||
var maxPrice = parseFloat({{ .max }});
|
||||
var priceRange = maxPrice - minPrice;
|
||||
var noiseAmount = parseFloat({{ .noise }});
|
||||
|
||||
// Generate 15-minute slots for 3 days
|
||||
var slotMinutes = 15;
|
||||
var slotDuration = slotMinutes * 60 * 1000;
|
||||
var slotsPerHour = 60 / slotMinutes;
|
||||
var slotsPerDay = 24 * slotsPerHour;
|
||||
|
||||
function getDailyFactor(hour) {
|
||||
if (hour < 5) {
|
||||
return 0.30; // Night: 30%
|
||||
} else if (hour < 7) {
|
||||
var t = (hour - 5) / 2;
|
||||
return 0.30 + 0.20 * t; // Morning ramp: 30% to 50%
|
||||
} else if (hour < 9) {
|
||||
var t = (hour - 7) / 2;
|
||||
return 0.50 + 0.15 * Math.sin(Math.PI * t); // Morning peak: up to 65%
|
||||
} else if (hour < 12) {
|
||||
var t = (hour - 9) / 3;
|
||||
return 0.50 - 0.50 * Math.pow(t, 1.5); // Drop to 0% (solar)
|
||||
} else if (hour < 17) {
|
||||
var t = (hour - 12) / 5;
|
||||
return 0.45 * Math.pow(t, 0.8); // Recovery: 0% to 45%
|
||||
} else if (hour < 20) {
|
||||
var distance = Math.abs(hour - 19);
|
||||
var spike = Math.exp(-Math.pow(distance / 0.8, 2));
|
||||
return 0.45 + 0.55 * spike; // Evening spike: peak at 19h
|
||||
} else {
|
||||
var t = (hour - 20) / 4;
|
||||
return 0.45 - 0.15 * t; // Decline: 45% to 30%
|
||||
}
|
||||
}
|
||||
|
||||
function getSlotNoise(day, slot) {
|
||||
var totalSlot = day * slotsPerDay + slot;
|
||||
var n = Math.sin(totalSlot * 12.9898) * 43758.5453;
|
||||
return ((n - Math.floor(n)) * 2 - 1) * noiseAmount;
|
||||
}
|
||||
|
||||
for (var day = 0; day < 3; day++) {
|
||||
for (var slot = 0; slot < slotsPerDay; slot++) {
|
||||
var time = new Date(start.getTime() + (day * slotsPerDay + slot) * slotDuration);
|
||||
var hour = slot / slotsPerHour;
|
||||
|
||||
// Calculate base price from smooth pattern
|
||||
var baseFactor = getDailyFactor(hour);
|
||||
var basePrice = minPrice + priceRange * baseFactor;
|
||||
|
||||
// Add independent pseudo-random noise to each slot
|
||||
var noise = getSlotNoise(day, slot);
|
||||
var price = basePrice + noise;
|
||||
|
||||
rates.push({
|
||||
start: time.toISOString(),
|
||||
end: new Date(time.getTime() + slotDuration).toISOString(),
|
||||
value: Math.round(price * 1000) / 1000 // Round to 3 decimal places
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
JSON.stringify(rates);
|
||||
|
|
@ -38,8 +38,7 @@ params:
|
|||
de: Stunde des Sonnenuntergangs (0-23)
|
||||
en: Hour of sunset (0-23)
|
||||
- name: interval
|
||||
default: 1h
|
||||
advanced: true
|
||||
deprecated: true
|
||||
|
||||
render: |
|
||||
type: custom
|
||||
|
|
@ -47,7 +46,7 @@ render: |
|
|||
forecast:
|
||||
source: js
|
||||
script: |
|
||||
// Generate realistic solar forecast with bell curve for next 72 hours
|
||||
// Generate realistic solar forecast with bell curve for next 72 hours in 15-minute slots
|
||||
var now = new Date();
|
||||
var start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
|
||||
var rates = [];
|
||||
|
|
@ -74,17 +73,23 @@ render: |
|
|||
}
|
||||
}
|
||||
|
||||
// Generate hourly rates for 3 days
|
||||
// Generate 15-minute slots for 3 days
|
||||
var slotMinutes = 15;
|
||||
var slotDuration = slotMinutes * 60 * 1000;
|
||||
var slotsPerHour = 60 / slotMinutes;
|
||||
var slotsPerDay = 24 * slotsPerHour;
|
||||
|
||||
for (var day = 0; day < 3; day++) {
|
||||
for (var hour = 0; hour < 24; hour++) {
|
||||
var time = new Date(start.getTime() + (day * 24 + hour) * 3600000);
|
||||
for (var slot = 0; slot < slotsPerDay; slot++) {
|
||||
var time = new Date(start.getTime() + (day * slotsPerDay + slot) * slotDuration);
|
||||
var hour = slot / slotsPerHour;
|
||||
var solarValue = 0;
|
||||
|
||||
if (dayLength > 0) {
|
||||
// Check if hour is within daylight period
|
||||
// Check if slot is within daylight period
|
||||
var isDaylight = is24HourDaylight || (sunrise <= sunset
|
||||
? hour >= sunrise && hour <= sunset
|
||||
: hour >= sunrise || hour <= sunset);
|
||||
? hour >= sunrise && hour <= sunset + 1
|
||||
: hour >= sunrise || hour <= sunset + 1);
|
||||
|
||||
if (isDaylight) {
|
||||
var position = getPositionInPeriod(hour);
|
||||
|
|
@ -98,11 +103,10 @@ render: |
|
|||
|
||||
rates.push({
|
||||
start: time.toISOString(),
|
||||
end: new Date(time.getTime() + 3600000).toISOString(),
|
||||
end: new Date(time.getTime() + slotDuration).toISOString(),
|
||||
value: Math.round(Math.max(0, solarValue))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
JSON.stringify(rates);
|
||||
interval: {{ .interval }}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
interval: 0.1s
|
||||
interval: 0.5s
|
||||
|
||||
site:
|
||||
title: Plan
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue