Tariffs: switch to 15m slots (#21162)

This commit is contained in:
Maschga 2025-10-03 16:45:56 +02:00 • committed by GitHub
parent 9fce557eec
commit 16f4681429
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1093 additions and 475 deletions

View file

@ -702,3 +702,6 @@ input::-webkit-date-and-time-value {
font-size: 0.875em;
}
}
.tabular {
font-variant-numeric: tabular-nums;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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" ? "≤" : "≥";

View file

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

View file

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

View file

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

View 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);
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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);

View file

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

View file

@ -1,4 +1,4 @@
interval: 0.1s
interval: 0.5s
site:
title: Plan