Forecast: add adjusted forecast (#18867)
This commit is contained in:
parent
b46962aea0
commit
73b7014704
20 changed files with 734 additions and 308 deletions
|
|
@ -236,7 +236,7 @@ import AnimatedNumber from "../AnimatedNumber.vue";
|
|||
import settings from "../../settings";
|
||||
import { CO2_TYPE } from "../../units";
|
||||
import collector from "../../mixins/collector";
|
||||
import { energyByDay } from "../../utils/forecast";
|
||||
|
||||
export default {
|
||||
name: "Energyflow",
|
||||
components: {
|
||||
|
|
@ -390,9 +390,7 @@ export default {
|
|||
return this.fmtPricePerKWh(this.batteryGridChargeLimit, this.currency, true);
|
||||
},
|
||||
solarForecast() {
|
||||
const slots = this.forecast.solar || [];
|
||||
if (slots.length === 0) return undefined;
|
||||
return energyByDay(slots, 0);
|
||||
return this.forecast?.solar?.today?.energy || undefined;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
|
|
|
|||
|
|
@ -1,23 +1,32 @@
|
|||
<template>
|
||||
<div v-if="activeSlot" class="text-end">
|
||||
<div v-if="isSlot" class="text-end">
|
||||
<span class="text-nowrap">{{ day }} {{ start }}</span
|
||||
>{{ " " }}<span class="text-nowrap">– {{ end }}</span>
|
||||
</div>
|
||||
<div v-if="isTimeseries" class="text-end">
|
||||
<span class="text-nowrap">{{ time }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import type { PropType } from "vue";
|
||||
import { type PriceSlot } from "../utils/forecast";
|
||||
import { type PriceSlot, type TimeseriesEntry } from "../utils/forecast";
|
||||
import formatter from "../mixins/formatter";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ForecastActiveSlot",
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
activeSlot: { type: Object as PropType<PriceSlot | null> },
|
||||
activeSlot: { type: Object as PropType<PriceSlot | TimeseriesEntry | null> },
|
||||
},
|
||||
computed: {
|
||||
isSlot() {
|
||||
return this.activeSlot?.start && this.activeSlot?.end;
|
||||
},
|
||||
isTimeseries() {
|
||||
return this.activeSlot?.ts;
|
||||
},
|
||||
day() {
|
||||
const startDate = new Date(this.activeSlot!.start);
|
||||
return this.weekdayShort(startDate);
|
||||
|
|
@ -30,6 +39,10 @@ export default defineComponent({
|
|||
const endDate = new Date(this.activeSlot!.end);
|
||||
return this.hourShort(endDate);
|
||||
},
|
||||
time() {
|
||||
const time = new Date(this.activeSlot!.ts);
|
||||
return `${this.weekdayShort(time)} ${this.hourShort(time)}`;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -32,10 +32,11 @@ import { registerChartComponents, commonOptions } from "./Sessions/chartConfig";
|
|||
import formatter, { POWER_UNIT } from "../mixins/formatter";
|
||||
import colors, { lighterColor } from "../colors";
|
||||
import {
|
||||
energyByDay,
|
||||
highestSlotIndexByDay,
|
||||
type PriceSlot,
|
||||
ForecastType,
|
||||
type PriceSlot,
|
||||
type SolarDetails,
|
||||
type TimeseriesEntry,
|
||||
} from "../utils/forecast";
|
||||
|
||||
registerChartComponents([
|
||||
|
|
@ -58,7 +59,7 @@ export default defineComponent({
|
|||
mixins: [formatter],
|
||||
props: {
|
||||
grid: { type: Array as PropType<PriceSlot[]> },
|
||||
solar: { type: Array as PropType<PriceSlot[]> },
|
||||
solar: { type: Object as PropType<SolarDetails> },
|
||||
co2: { type: Array as PropType<PriceSlot[]> },
|
||||
currency: { type: String as PropType<string> },
|
||||
selected: { type: String as PropType<ForecastType> },
|
||||
|
|
@ -66,29 +67,29 @@ export default defineComponent({
|
|||
emits: ["selected"],
|
||||
data(): {
|
||||
selectedIndex: number | null;
|
||||
startDate: Date;
|
||||
interval: ReturnType<typeof setTimeout> | null;
|
||||
ignoreEvents: boolean;
|
||||
ignoreEventsTimeout: ReturnType<typeof setTimeout> | null;
|
||||
animations: boolean;
|
||||
} {
|
||||
return {
|
||||
selectedIndex: null,
|
||||
startDate: new Date(),
|
||||
interval: null,
|
||||
ignoreEvents: false,
|
||||
ignoreEventsTimeout: null,
|
||||
animations: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
startDate() {
|
||||
const now = new Date();
|
||||
const slots = this.grid || this.co2 || this.solar || [];
|
||||
const currentSlot = slots.find(({ start, end }) => {
|
||||
return new Date(start) <= now && new Date(end) > now;
|
||||
});
|
||||
if (currentSlot) {
|
||||
return new Date(currentSlot.start);
|
||||
}
|
||||
return now;
|
||||
endDate() {
|
||||
const end = new Date(this.startDate);
|
||||
end.setHours(end.getHours() + 48);
|
||||
return end;
|
||||
},
|
||||
solarSlots() {
|
||||
return this.filterSlots(this.solar);
|
||||
solarEntries() {
|
||||
return this.filterEntries(this.solar?.timeseries || []);
|
||||
},
|
||||
gridSlots() {
|
||||
return this.filterSlots(this.grid);
|
||||
|
|
@ -96,21 +97,6 @@ export default defineComponent({
|
|||
co2Slots() {
|
||||
return this.filterSlots(this.co2);
|
||||
},
|
||||
currentSlots() {
|
||||
switch (this.selected) {
|
||||
case ForecastType.Price:
|
||||
return this.gridSlots;
|
||||
case ForecastType.Solar:
|
||||
return this.solarSlots;
|
||||
case ForecastType.Co2:
|
||||
return this.co2Slots;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
},
|
||||
selectedSlot() {
|
||||
return this.selectedIndex !== null ? this.currentSlots[this.selectedIndex] : null;
|
||||
},
|
||||
maxPriceIndex() {
|
||||
return this.maxIndex(this.gridSlots);
|
||||
},
|
||||
|
|
@ -124,38 +110,54 @@ export default defineComponent({
|
|||
return this.minIndex(this.co2Slots);
|
||||
},
|
||||
maxSolarIndex() {
|
||||
return this.maxIndex(this.solarSlots);
|
||||
return this.maxEntryIndex(this.solarEntries);
|
||||
},
|
||||
solarHighlights() {
|
||||
return [0, 1, 2].map((day) => {
|
||||
const energy = energyByDay(this.solarSlots, day);
|
||||
const index = highestSlotIndexByDay(this.solarSlots, day);
|
||||
return { index, energy };
|
||||
});
|
||||
const { today, tomorrow, dayAfterTomorrow } = this.solar || {};
|
||||
return [
|
||||
{
|
||||
index: highestSlotIndexByDay(this.solarEntries, 0),
|
||||
energy: today?.energy,
|
||||
},
|
||||
{
|
||||
index: highestSlotIndexByDay(this.solarEntries, 1),
|
||||
energy: tomorrow?.energy,
|
||||
},
|
||||
{
|
||||
index: highestSlotIndexByDay(this.solarEntries, 2),
|
||||
energy: dayAfterTomorrow?.energy,
|
||||
},
|
||||
];
|
||||
},
|
||||
chartData() {
|
||||
const datasets: unknown[] = [];
|
||||
if (this.solarSlots.length > 0) {
|
||||
if (this.solarEntries.length > 0) {
|
||||
const active = this.selected === ForecastType.Solar;
|
||||
const color = active ? colors.self : colors.border;
|
||||
datasets.push({
|
||||
label: ForecastType.Solar,
|
||||
type: "line",
|
||||
data: this.solarSlots.map((slot, index) => ({
|
||||
y: slot.price,
|
||||
x: new Date(slot.start),
|
||||
highlight:
|
||||
active &&
|
||||
(this.selectedIndex !== null
|
||||
? this.selectedIndex === index
|
||||
: this.solarHighlights.find(({ index: i }) => i === index)?.energy),
|
||||
})),
|
||||
data: this.solarEntries.map((entry, index) => {
|
||||
return {
|
||||
y: entry.val,
|
||||
x: new Date(entry.ts),
|
||||
highlight:
|
||||
active &&
|
||||
(this.selectedIndex !== null
|
||||
? this.selectedIndex === index
|
||||
: this.solarHighlights.find(({ index: i }) => i === index)
|
||||
?.energy),
|
||||
};
|
||||
}),
|
||||
yAxisID: "yForecast",
|
||||
backgroundColor: lighterColor(color),
|
||||
borderColor: color,
|
||||
fill: "origin",
|
||||
fill: "start",
|
||||
tension: 0.5,
|
||||
pointRadius: 0,
|
||||
animation: {
|
||||
y: { duration: this.animations ? 500 : 0 },
|
||||
},
|
||||
pointHoverRadius: active ? 4 : 0,
|
||||
spanGaps: true,
|
||||
order: active ? 0 : 1,
|
||||
|
|
@ -254,16 +256,27 @@ export default defineComponent({
|
|||
backgroundColor: function (context) {
|
||||
return context.dataset.borderColor;
|
||||
},
|
||||
align: function (context) {
|
||||
// rotate label position to avoid horizontal clipping
|
||||
align: function ({ chart, dataset, dataIndex }) {
|
||||
const { min, max } = chart.scales.x;
|
||||
const time = new Date(dataset.data[dataIndex].x).getTime();
|
||||
|
||||
// percent along the x axis (0: start, 1: end)
|
||||
const percent = (time - min) / (max - min);
|
||||
let adjust = 0;
|
||||
const step = 20;
|
||||
const adjust = {
|
||||
0: step * 2,
|
||||
1: step * 1,
|
||||
46: step * -1,
|
||||
47: step * -2,
|
||||
};
|
||||
return -90 + (adjust[context.dataIndex] || 0);
|
||||
|
||||
// tilt label left/right if it's close to the edge
|
||||
if (percent < 0.02) {
|
||||
adjust = 2;
|
||||
} else if (percent < 0.04) {
|
||||
adjust = 1;
|
||||
} else if (percent > 0.98) {
|
||||
adjust = -2;
|
||||
} else if (percent > 0.96) {
|
||||
adjust = -1;
|
||||
}
|
||||
|
||||
return -90 + adjust * step;
|
||||
},
|
||||
anchor: "end",
|
||||
offset: 8,
|
||||
|
|
@ -310,7 +323,7 @@ export default defineComponent({
|
|||
type: "timeseries",
|
||||
display: true,
|
||||
time: { unit: "day" },
|
||||
border: { display: false },
|
||||
border: { display: true },
|
||||
grid: {
|
||||
display: true,
|
||||
color: colors.border,
|
||||
|
|
@ -323,6 +336,8 @@ export default defineComponent({
|
|||
return Array.isArray(label) ? 1 : 0;
|
||||
},
|
||||
},
|
||||
min: this.startDate,
|
||||
max: this.endDate,
|
||||
ticks: {
|
||||
color: colors.muted,
|
||||
autoSkip: false,
|
||||
|
|
@ -333,6 +348,10 @@ export default defineComponent({
|
|||
callback: function (value) {
|
||||
const date = new Date(value);
|
||||
const hour = date.getHours();
|
||||
const minute = date.getMinutes();
|
||||
if (minute !== 0) {
|
||||
return "";
|
||||
}
|
||||
const hourFmt = vThis.hourShort(date);
|
||||
if (hour === 0) {
|
||||
return [hourFmt, vThis.weekdayShort(date)];
|
||||
|
|
@ -344,21 +363,70 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
},
|
||||
yForecast: { display: false, min: 0, max: this.yMax(this.solarSlots) },
|
||||
yForecast: {
|
||||
display: false,
|
||||
min: 0,
|
||||
max: this.yMaxEntry(this.solarEntries, this.solar?.scale),
|
||||
beginAtZero: true,
|
||||
},
|
||||
yCo2: { display: false, min: 0, max: this.yMax(this.co2Slots) },
|
||||
yPrice: { display: false, min: 0, max: this.yMax(this.gridSlots) },
|
||||
},
|
||||
};
|
||||
},
|
||||
selectedSlot() {
|
||||
if (this.selectedIndex === null || !this.selected) return null;
|
||||
|
||||
const slotMap = {
|
||||
[ForecastType.Solar]: this.solarEntries,
|
||||
[ForecastType.Price]: this.gridSlots,
|
||||
[ForecastType.Co2]: this.co2Slots,
|
||||
};
|
||||
|
||||
return slotMap[this.selected]?.[this.selectedIndex] ?? null;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectedSlot(slot) {
|
||||
this.$emit("selected", slot);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.interval = setTimeout(() => {
|
||||
this.updateStartDate();
|
||||
}, 1000 * 60);
|
||||
this.updateStartDate();
|
||||
setTimeout(() => {
|
||||
this.animations = true;
|
||||
}, 1000);
|
||||
},
|
||||
beforeUnmount() {
|
||||
clearTimeout(this.interval);
|
||||
},
|
||||
methods: {
|
||||
updateStartDate() {
|
||||
const now = new Date();
|
||||
now.setMinutes(0);
|
||||
now.setSeconds(0);
|
||||
now.setMilliseconds(0);
|
||||
this.startDate = now;
|
||||
},
|
||||
filterSlots(slots: PriceSlot[] = []) {
|
||||
return slots.filter((slot) => new Date(slot.end) > this.startDate).slice(0, 48);
|
||||
return slots.filter(
|
||||
(slot) =>
|
||||
new Date(slot.end) >= this.startDate && new Date(slot.start) <= this.endDate
|
||||
);
|
||||
},
|
||||
filterEntries(entries: TimeseriesEntry[] = []) {
|
||||
// include 1 hour before and after
|
||||
const start = new Date(this.startDate);
|
||||
start.setHours(start.getHours() - 1);
|
||||
const end = new Date(this.endDate);
|
||||
end.setHours(end.getHours() + 1);
|
||||
|
||||
return entries.filter(
|
||||
(entry) => new Date(entry.ts) >= start && new Date(entry.ts) <= end
|
||||
);
|
||||
},
|
||||
onMouseLeave() {
|
||||
this.selectIndex(null, true);
|
||||
|
|
@ -387,6 +455,12 @@ export default defineComponent({
|
|||
const value = this.maxValue(slots);
|
||||
return value ? value * 1.15 : undefined;
|
||||
},
|
||||
yMaxEntry(entries: TimeseriesEntry[] = [], scale: number = 1): number | undefined {
|
||||
const maxValue = this.maxEntryValue(entries);
|
||||
if (!maxValue) return undefined;
|
||||
// use scale and unscaled to determine max scale
|
||||
return Math.max(maxValue * scale, maxValue) * 1.15;
|
||||
},
|
||||
maxIndex(slots: PriceSlot[] = []) {
|
||||
return slots.reduce((max, slot, index) => {
|
||||
return slot.price > slots[max].price ? index : max;
|
||||
|
|
@ -400,6 +474,14 @@ export default defineComponent({
|
|||
maxValue(slots: PriceSlot[] = []) {
|
||||
return slots[this.maxIndex(slots)]?.price || null;
|
||||
},
|
||||
maxEntryValue(entries: TimeseriesEntry[] = []) {
|
||||
return entries[this.maxEntryIndex(entries)]?.val || null;
|
||||
},
|
||||
maxEntryIndex(entries: TimeseriesEntry[] = []) {
|
||||
return entries.reduce((max, entry, index) => {
|
||||
return entry.val > entries[max].val ? index : max;
|
||||
}, 0);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
<template>
|
||||
<div v-if="isSolar && solarEnergy" class="row">
|
||||
<div v-if="isSolar && solar" class="row">
|
||||
<div class="col-6 col-sm-4 mb-3 d-flex flex-column">
|
||||
<div class="label">{{ label("today") }}</div>
|
||||
<div class="value d-flex flex-column flex-lg-row gap-lg-2 align-items-lg-baseline">
|
||||
<div class="text-primary text-nowrap">{{ solarEnergy.today.energy }}</div>
|
||||
<div class="text-primary text-nowrap">
|
||||
<AnimatedNumber :to="solar.today?.energy" :format="fmtEnergy" />
|
||||
</div>
|
||||
<div class="extraValue text-nowrap">{{ label("remaining") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -12,8 +14,10 @@
|
|||
<div
|
||||
class="value d-flex flex-column flex-lg-row gap-lg-2 align-items-end align-items-sm-center align-items-lg-baseline"
|
||||
>
|
||||
<div class="text-primary text-nowrap">{{ solarEnergy.tomorrow.energy }}</div>
|
||||
<div v-if="solarEnergy.tomorrow.incomplete" class="extraValue text-nowrap">
|
||||
<div class="text-primary text-nowrap">
|
||||
<AnimatedNumber :to="solar.tomorrow?.energy" :format="fmtEnergy" />
|
||||
</div>
|
||||
<div v-if="!solar.tomorrow?.complete" class="extraValue text-nowrap">
|
||||
{{ label("partly") }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -24,9 +28,9 @@
|
|||
class="value d-flex flex-column flex-lg-row gap-lg-2 align-items-start align-items-sm-end align-items-lg-baseline"
|
||||
>
|
||||
<div class="text-primary text-nowrap">
|
||||
{{ solarEnergy.dayAfterTomorrow.energy }}
|
||||
<AnimatedNumber :to="solar.dayAfterTomorrow?.energy" :format="fmtEnergy" />
|
||||
</div>
|
||||
<div v-if="solarEnergy.dayAfterTomorrow.incomplete" class="extraValue text-nowrap">
|
||||
<div v-if="!solar.dayAfterTomorrow?.complete" class="extraValue text-nowrap">
|
||||
{{ label("partly") }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -59,16 +63,10 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import {
|
||||
energyByDay,
|
||||
dayStringByOffset,
|
||||
filterSlotsByDate,
|
||||
ForecastType,
|
||||
type PriceSlot,
|
||||
} from "../utils/forecast";
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import { ForecastType, type PriceSlot, type SolarDetails } from "../utils/forecast";
|
||||
import formatter, { POWER_UNIT } from "../mixins/formatter";
|
||||
|
||||
import AnimatedNumber from "./AnimatedNumber.vue";
|
||||
const LOCALES_WITHOUT_DAY_AFTER_TOMORROW = ["en", "tr"];
|
||||
|
||||
export interface Energy {
|
||||
|
|
@ -84,39 +82,34 @@ export interface EnergyByDay {
|
|||
|
||||
export default defineComponent({
|
||||
name: "ForecastDetails",
|
||||
components: {
|
||||
AnimatedNumber,
|
||||
},
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
type: { type: String as () => ForecastType, required: true },
|
||||
slots: { type: Array as () => PriceSlot[], default: () => [] },
|
||||
grid: { type: Array as PropType<PriceSlot[]> },
|
||||
co2: { type: Array as PropType<PriceSlot[]> },
|
||||
solar: { type: Object as PropType<SolarDetails> },
|
||||
currency: { type: String },
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
now: new Date(),
|
||||
interval: null as ReturnType<typeof setInterval> | null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isSolar() {
|
||||
return this.type === ForecastType.Solar;
|
||||
},
|
||||
upcomingSlots() {
|
||||
const now = new Date();
|
||||
return this.slots.filter((slot) => new Date(slot.end) > now).slice(0, 48);
|
||||
isPrice() {
|
||||
return this.type === ForecastType.Price;
|
||||
},
|
||||
solarEnergy(): EnergyByDay | undefined {
|
||||
if (!this.isSolar) return;
|
||||
|
||||
const days = ["today", "tomorrow", "dayAfterTomorrow"];
|
||||
return days.reduce((acc, day, index) => {
|
||||
const energy = energyByDay(this.slots, index);
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + index);
|
||||
const dayString = dayStringByOffset(index);
|
||||
const count = filterSlotsByDate(this.slots, dayString).length;
|
||||
const empty = count === 0;
|
||||
const complete = count === 24;
|
||||
const energyFmt = empty ? "-" : this.fmtWh(energy, POWER_UNIT.AUTO);
|
||||
acc[day] = {
|
||||
energy: energyFmt,
|
||||
incomplete: energy && !complete && !empty,
|
||||
};
|
||||
return acc;
|
||||
}, {} as EnergyByDay);
|
||||
upcomingSlots(): PriceSlot[] {
|
||||
const now = this.now;
|
||||
const slots = this.isPrice ? this.grid || [] : this.co2 || [];
|
||||
return slots.filter((slot) => new Date(slot.end) > now).slice(0, 48);
|
||||
},
|
||||
averagePrice() {
|
||||
if (this.isSolar) return "";
|
||||
|
|
@ -149,6 +142,17 @@ export default defineComponent({
|
|||
return colors[this.type];
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.now = new Date();
|
||||
this.interval = setInterval(() => {
|
||||
this.now = new Date();
|
||||
}, 1000 * 60);
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
label(key: string) {
|
||||
// special case "day after tomorrow"
|
||||
|
|
@ -169,6 +173,9 @@ export default defineComponent({
|
|||
}
|
||||
return withUnit ? this.fmtCo2Medium(value) : this.fmtNumber(value, 0);
|
||||
},
|
||||
fmtEnergy(energy: number | undefined) {
|
||||
return !energy ? "-" : this.fmtWh(energy, POWER_UNIT.AUTO);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -7,22 +7,44 @@
|
|||
@open="modalVisible"
|
||||
@closed="modalInvisible"
|
||||
>
|
||||
<div v-if="isModalVisible" class="d-flex justify-content-between align-items-center gap-2">
|
||||
<ForecastTypeSelect v-model="selectedType" :forecast="forecast" />
|
||||
<ForecastActiveSlot :activeSlot="selectedSlot" />
|
||||
<div v-if="isModalVisible">
|
||||
<div class="d-flex justify-content-between align-items-center gap-2">
|
||||
<ForecastTypeSelect v-model="selectedType" :forecast="forecast" />
|
||||
<ForecastActiveSlot :activeSlot="selectedSlot" />
|
||||
</div>
|
||||
|
||||
<ForecastChart
|
||||
class="my-3"
|
||||
:grid="forecast.grid"
|
||||
:solar="solar"
|
||||
:co2="forecast.co2"
|
||||
:currency="currency"
|
||||
:selected="selectedType"
|
||||
@selected="updateSlot"
|
||||
/>
|
||||
<ForecastDetails
|
||||
:type="selectedType"
|
||||
:grid="forecast.grid"
|
||||
:solar="solar"
|
||||
:co2="forecast.co2"
|
||||
:currency="currency"
|
||||
/>
|
||||
<div v-if="showSolarAdjust" class="form-check form-switch mt-4">
|
||||
<input
|
||||
id="solarForecastAdjust"
|
||||
:checked="solarAdjusted"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
@change="changeAdjusted"
|
||||
/>
|
||||
<div class="form-check-label">
|
||||
<label for="solarForecastAdjust"
|
||||
><small>🧪 {{ $t("forecast.solarAdjust") }}</small></label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ForecastChart
|
||||
class="my-3"
|
||||
:grid="forecast.grid"
|
||||
:solar="forecast.solar"
|
||||
:co2="forecast.co2"
|
||||
:currency="currency"
|
||||
:selected="selectedType"
|
||||
@selected="updateSlot"
|
||||
/>
|
||||
|
||||
<ForecastDetails :type="selectedType" :slots="selectedSlots" :currency="currency" />
|
||||
</GenericModal>
|
||||
</template>
|
||||
|
||||
|
|
@ -36,15 +58,15 @@ import ForecastChart from "./ForecastChart.vue";
|
|||
import ForecastTypeSelect from "./ForecastTypeSelect.vue";
|
||||
import ForecastDetails from "./ForecastDetails.vue";
|
||||
import ForecastActiveSlot from "./ForecastActiveSlot.vue";
|
||||
import { type PriceSlot, ForecastType } from "../utils/forecast";
|
||||
import {
|
||||
type PriceSlot,
|
||||
type TimeseriesEntry,
|
||||
type Forecast,
|
||||
ForecastType,
|
||||
adjustedSolar,
|
||||
} from "../utils/forecast";
|
||||
import formatter from "../mixins/formatter";
|
||||
|
||||
interface Forecast {
|
||||
grid?: PriceSlot[];
|
||||
solar?: PriceSlot[];
|
||||
co2?: PriceSlot[];
|
||||
}
|
||||
|
||||
import settings from "../settings";
|
||||
export default defineComponent({
|
||||
name: "ForecastModal",
|
||||
components: {
|
||||
|
|
@ -62,7 +84,7 @@ export default defineComponent({
|
|||
data: function (): {
|
||||
isModalVisible: boolean;
|
||||
selectedType: ForecastType;
|
||||
selectedSlot: PriceSlot | null;
|
||||
selectedSlot: PriceSlot | TimeseriesEntry | null;
|
||||
} {
|
||||
return {
|
||||
isModalVisible: false,
|
||||
|
|
@ -71,13 +93,16 @@ export default defineComponent({
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
selectedSlots() {
|
||||
const slots = {
|
||||
[ForecastType.Solar]: this.forecast.solar,
|
||||
[ForecastType.Price]: this.forecast.grid,
|
||||
[ForecastType.Co2]: this.forecast.co2,
|
||||
};
|
||||
return slots[this.selectedType] || [];
|
||||
solarAdjusted() {
|
||||
return settings.solarAdjusted;
|
||||
},
|
||||
showSolarAdjust() {
|
||||
return !!this.forecast.solar && this.$hiddenFeatures();
|
||||
},
|
||||
solar() {
|
||||
return this.showSolarAdjust && this.solarAdjusted
|
||||
? adjustedSolar(this.forecast.solar)
|
||||
: this.forecast.solar;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
|
|
@ -97,7 +122,7 @@ export default defineComponent({
|
|||
modalInvisible() {
|
||||
this.isModalVisible = false;
|
||||
},
|
||||
updateSlot(slot: PriceSlot | null) {
|
||||
updateSlot(slot: PriceSlot | TimeseriesEntry | null) {
|
||||
this.selectedSlot = slot;
|
||||
},
|
||||
updateSelectedType() {
|
||||
|
|
@ -114,6 +139,9 @@ export default defineComponent({
|
|||
Object.values(ForecastType).find((type) => availableTypes[type]) ||
|
||||
Object.values(ForecastType)[0];
|
||||
},
|
||||
changeAdjusted() {
|
||||
settings.solarAdjusted = !settings.solarAdjusted;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const SAVINGS_PERIOD = "savings_period";
|
|||
const SAVINGS_REGION = "savings_region";
|
||||
const SESSIONS_GROUP = "sessions_group";
|
||||
const SESSIONS_TYPE = "sessions_type";
|
||||
|
||||
const SETTINGS_SOLAR_ADJUSTED = "settings_solar_adjusted";
|
||||
function read(key) {
|
||||
return window.localStorage[key];
|
||||
}
|
||||
|
|
@ -64,6 +64,7 @@ const settings = reactive({
|
|||
savingsRegion: read(SAVINGS_REGION),
|
||||
sessionsGroup: read(SESSIONS_GROUP),
|
||||
sessionsType: read(SESSIONS_TYPE),
|
||||
solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED),
|
||||
});
|
||||
|
||||
watch(() => settings.locale, save(SETTINGS_LOCALE));
|
||||
|
|
@ -77,4 +78,5 @@ watch(() => settings.savingsPeriod, save(SAVINGS_PERIOD));
|
|||
watch(() => settings.savingsRegion, save(SAVINGS_REGION));
|
||||
watch(() => settings.sessionsGroup, save(SESSIONS_GROUP));
|
||||
watch(() => settings.sessionsType, save(SESSIONS_TYPE));
|
||||
watch(() => settings.solarAdjusted, saveBool(SETTINGS_SOLAR_ADJUSTED));
|
||||
export default settings;
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
import { describe, expect, test, vi } from "vitest";
|
||||
import { aggregateEnergy, type PriceSlot } from "./forecast";
|
||||
|
||||
const slots: PriceSlot[] = [
|
||||
{ start: "2050-03-20T10:00:00+01:00", end: "2050-03-20T10:30:00+01:00", price: 2000 }, // 1kWh
|
||||
{ start: "2050-03-20T10:30:00+01:00", end: "2050-03-20T11:00:00+01:00", price: 4000 }, // 2kWh
|
||||
{ start: "2050-03-20T11:00:00+01:00", end: "2050-03-20T11:30:00+01:00", price: 3000 }, // 1.5kWh
|
||||
{ start: "2050-03-20T11:30:00+01:00", end: "2050-03-20T12:00:00+01:00", price: 2000 }, // 1kWh
|
||||
{ start: "2050-03-20T12:00:00+01:00", end: "2050-03-20T12:30:00+01:00", price: 0 }, // 0kWh
|
||||
{ start: "2050-03-20T12:30:00+01:00", end: "2050-03-20T13:00:00+01:00", price: 0 }, // 0kWh
|
||||
];
|
||||
|
||||
describe("aggregateEnergy", () => {
|
||||
test("aggregates energy from power values for 30min slots", () => {
|
||||
const result = aggregateEnergy(slots);
|
||||
expect(result).toBeCloseTo(5500);
|
||||
});
|
||||
test("ignore slots in the past", () => {
|
||||
// will ignore the first two slots
|
||||
vi.setSystemTime("2050-03-20T11:00:00+01:00");
|
||||
const result = aggregateEnergy(slots, true);
|
||||
expect(result).toBeCloseTo(2500);
|
||||
});
|
||||
test("correctly split in-between slots", () => {
|
||||
// start in the middle of the 4th slot
|
||||
vi.setSystemTime("2050-03-20T11:45:00+01:00");
|
||||
const result = aggregateEnergy(slots, true);
|
||||
expect(result).toBeCloseTo(500);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,34 +1,41 @@
|
|||
import deepCopy from "./deepClone";
|
||||
|
||||
export interface TimeseriesEntry {
|
||||
val: number;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
export interface PriceSlot {
|
||||
start: string;
|
||||
end: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface EnergyByDay {
|
||||
energy: number;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export interface SolarDetails {
|
||||
scale?: number;
|
||||
today?: EnergyByDay;
|
||||
tomorrow?: EnergyByDay;
|
||||
dayAfterTomorrow?: EnergyByDay;
|
||||
timeseries?: TimeseriesEntry[];
|
||||
}
|
||||
|
||||
export interface Forecast {
|
||||
grid?: PriceSlot[];
|
||||
co2?: PriceSlot[];
|
||||
solar?: SolarDetails;
|
||||
}
|
||||
|
||||
export enum ForecastType {
|
||||
Solar = "solar",
|
||||
Price = "price",
|
||||
Co2 = "co2",
|
||||
}
|
||||
|
||||
export function aggregateEnergy(slots: PriceSlot[], ignorePast: boolean = false): number {
|
||||
const now = new Date();
|
||||
return slots.reduce((acc: number, { start, end, price: power }: PriceSlot) => {
|
||||
let startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
if (ignorePast) {
|
||||
if (endDate < now) {
|
||||
return acc; // ignore past slots
|
||||
}
|
||||
if (startDate < now) {
|
||||
startDate = now; // count this slot from now on
|
||||
}
|
||||
}
|
||||
const hours = (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60); // convert ms to hours
|
||||
const energy = power * hours; // Wh
|
||||
return acc + energy;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// return the date in local YYYY-MM-DD format
|
||||
function toDayString(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
|
|
@ -38,22 +45,19 @@ function toDayString(date: Date): string {
|
|||
}
|
||||
|
||||
// return only slots that are on a given date, ignores slots that are in the past
|
||||
export function filterSlotsByDate(slots: PriceSlot[], dayString: string): PriceSlot[] {
|
||||
export function filterEntriesByDate(
|
||||
entries: TimeseriesEntry[],
|
||||
dayString: string
|
||||
): TimeseriesEntry[] {
|
||||
const now = new Date();
|
||||
return slots.filter(({ start, end }) => {
|
||||
const isPast = new Date(end) < now;
|
||||
const dateMatches = toDayString(new Date(start)) === dayString;
|
||||
return entries.filter(({ ts }) => {
|
||||
const isPast = new Date(ts) < now;
|
||||
const dateMatches = toDayString(new Date(ts)) === dayString;
|
||||
return !isPast && dateMatches;
|
||||
});
|
||||
}
|
||||
|
||||
// return the energy for a given day (0 = today, 1 = tomorrow, etc.)
|
||||
export function energyByDay(slots: PriceSlot[], day: number = 0): number {
|
||||
const dayString = dayStringByOffset(day);
|
||||
const daySlots = filterSlotsByDate(slots, dayString);
|
||||
return aggregateEnergy(daySlots, true);
|
||||
}
|
||||
|
||||
// return the date in local YYYY-MM-DD format
|
||||
export function dayStringByOffset(day: number): string {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + day);
|
||||
|
|
@ -61,10 +65,23 @@ export function dayStringByOffset(day: number): string {
|
|||
}
|
||||
|
||||
// return the highest slot for a given day (0 = today, 1 = tomorrow, etc.)
|
||||
export function highestSlotIndexByDay(slots: PriceSlot[], day: number = 0): number {
|
||||
export function highestSlotIndexByDay(entries: TimeseriesEntry[], day: number = 0): number {
|
||||
const dayString = dayStringByOffset(day);
|
||||
const daySlots = filterSlotsByDate(slots, dayString);
|
||||
const sortedSlots = daySlots.sort((a, b) => b.price - a.price);
|
||||
const highestSlot = sortedSlots[0] || {};
|
||||
return slots.findIndex((slot) => slot.start === highestSlot.start);
|
||||
const dayEntries = filterEntriesByDate(entries, dayString);
|
||||
const sortedEntries = dayEntries.sort((a, b) => b.val - a.val);
|
||||
const highestEntry = sortedEntries[0] || {};
|
||||
return entries.findIndex((entry) => entry.ts === highestEntry.ts);
|
||||
}
|
||||
|
||||
export function adjustedSolar(solar: SolarDetails | undefined): SolarDetails | undefined {
|
||||
if (!solar?.scale) return solar;
|
||||
|
||||
const { scale } = solar;
|
||||
const result = deepCopy(solar);
|
||||
result.today.energy *= scale;
|
||||
result.tomorrow.energy *= scale;
|
||||
result.dayAfterTomorrow.energy *= scale;
|
||||
result.timeseries?.forEach((entry) => (entry.val *= scale));
|
||||
result.scale = 1 / scale; // invert to allow back-adjustment
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ const (
|
|||
SmartCostType = "smartCostType"
|
||||
Statistics = "statistics"
|
||||
Forecast = "forecast"
|
||||
SolarAccYield = "solarAccYield"
|
||||
SolarAccForecast = "solarAccForecast"
|
||||
TariffCo2 = "tariffCo2"
|
||||
TariffCo2Home = "tariffCo2Home"
|
||||
TariffCo2Loadpoints = "tariffCo2Loadpoints"
|
||||
|
|
|
|||
58
core/meterenergy.go
Normal file
58
core/meterenergy.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/benbjohnson/clock"
|
||||
"github.com/jinzhu/now"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type meterEnergy struct {
|
||||
clock clock.Clock
|
||||
updated time.Time
|
||||
meter *float64 // kWh
|
||||
Accumulated float64 `json:"accumulated"` // kWh
|
||||
}
|
||||
|
||||
// reset resets data
|
||||
func (m *meterEnergy) reset() {
|
||||
m.updated = time.Time{}
|
||||
m.meter = nil
|
||||
m.Accumulated = 0
|
||||
}
|
||||
|
||||
func (m *meterEnergy) AccumulatedEnergy() float64 {
|
||||
return m.Accumulated
|
||||
}
|
||||
|
||||
func (m *meterEnergy) AddMeterTotal(v float64) {
|
||||
defer func() {
|
||||
m.updated = m.clock.Now()
|
||||
m.meter = lo.ToPtr(v)
|
||||
}()
|
||||
|
||||
if m.meter == nil {
|
||||
return
|
||||
}
|
||||
|
||||
m.Accumulated += v - *m.meter
|
||||
}
|
||||
|
||||
func (m *meterEnergy) AddEnergy(v float64) {
|
||||
defer func() { m.updated = m.clock.Now() }()
|
||||
|
||||
if m.updated.IsZero() {
|
||||
return
|
||||
}
|
||||
|
||||
m.Accumulated += v
|
||||
}
|
||||
|
||||
func (m *meterEnergy) AddPower(v float64) {
|
||||
m.AddEnergy(v * m.clock.Since(m.updated).Hours() / 1e3)
|
||||
}
|
||||
|
||||
func beginningOfDay(t time.Time) time.Time {
|
||||
return now.With(t).BeginningOfDay()
|
||||
}
|
||||
31
core/meterenergy_test.go
Normal file
31
core/meterenergy_test.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/benbjohnson/clock"
|
||||
"github.com/jinzhu/now"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMeterEnergy(t *testing.T) {
|
||||
clock := clock.NewMock()
|
||||
clock.Set(now.BeginningOfDay())
|
||||
|
||||
me := &meterEnergy{clock: clock}
|
||||
|
||||
me.AddMeterTotal(10)
|
||||
assert.Equal(t, 0.0, me.AccumulatedEnergy())
|
||||
me.AddMeterTotal(11)
|
||||
assert.Equal(t, 1.0, me.AccumulatedEnergy())
|
||||
me.AddMeterTotal(11)
|
||||
assert.Equal(t, 1.0, me.AccumulatedEnergy())
|
||||
|
||||
me.AddPower(1e3)
|
||||
assert.Equal(t, 1.0, me.AccumulatedEnergy())
|
||||
|
||||
clock.Add(30 * time.Minute)
|
||||
me.AddPower(1e3)
|
||||
assert.Equal(t, 1.5, me.AccumulatedEnergy())
|
||||
}
|
||||
151
core/site.go
151
core/site.go
|
|
@ -9,6 +9,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/benbjohnson/clock"
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/cmd/shutdown"
|
||||
|
|
@ -95,15 +96,18 @@ type Site struct {
|
|||
coordinator *coordinator.Coordinator // Vehicles
|
||||
prioritizer *prioritizer.Prioritizer // Power budgets
|
||||
stats *Stats // Stats
|
||||
fcstEnergy *meterEnergy
|
||||
pvEnergy map[string]*meterEnergy
|
||||
|
||||
// cached state
|
||||
gridPower float64 // Grid power
|
||||
pvPower float64 // PV power
|
||||
excessDCPower float64 // PV excess DC charge power (hybrid only)
|
||||
auxPower float64 // Aux power
|
||||
batteryPower float64 // Battery power (charge negative, discharge positive)
|
||||
batterySoc float64 // Battery soc
|
||||
batteryMode api.BatteryMode // Battery mode (runtime only, not persisted)
|
||||
gridPower float64 // Grid power
|
||||
pvPower float64 // PV power
|
||||
excessDCPower float64 // PV excess DC charge power (hybrid only)
|
||||
auxPower float64 // Aux power
|
||||
batteryPower float64 // Battery power (charge negative, discharge positive)
|
||||
batterySoc float64 // Battery soc
|
||||
batteryCapacity float64 // Battery capacity
|
||||
batteryMode api.BatteryMode // Battery mode (runtime only, not persisted)
|
||||
}
|
||||
|
||||
// MetersConfig contains the site's meter configuration
|
||||
|
|
@ -194,6 +198,9 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
|
|||
return err
|
||||
}
|
||||
site.pvMeters = append(site.pvMeters, dev.Instance())
|
||||
|
||||
// accumulator
|
||||
site.pvEnergy[ref] = &meterEnergy{clock: clock.New()}
|
||||
}
|
||||
|
||||
// multiple batteries
|
||||
|
|
@ -241,12 +248,14 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
|
|||
|
||||
// NewSite creates a Site with sane defaults
|
||||
func NewSite() *Site {
|
||||
lp := &Site{
|
||||
log: util.NewLogger("site"),
|
||||
Voltage: 230, // V
|
||||
site := &Site{
|
||||
log: util.NewLogger("site"),
|
||||
Voltage: 230, // V
|
||||
pvEnergy: make(map[string]*meterEnergy),
|
||||
fcstEnergy: &meterEnergy{clock: clock.New()},
|
||||
}
|
||||
|
||||
return lp
|
||||
return site
|
||||
}
|
||||
|
||||
// restoreMetersAndTitle restores site meter configuration
|
||||
|
|
@ -309,6 +318,23 @@ func (site *Site) restoreSettings() error {
|
|||
site.SetBatteryGridChargeLimit(&v)
|
||||
}
|
||||
|
||||
// restore accumulated energy
|
||||
pvEnergy := make(map[string]float64)
|
||||
fcstEnergy, err := settings.Float(keys.SolarAccForecast)
|
||||
|
||||
if err == nil && settings.Json(keys.SolarAccYield, &pvEnergy) == nil {
|
||||
site.fcstEnergy.Accumulated = fcstEnergy
|
||||
|
||||
for _, name := range site.Meters.PVMetersRef {
|
||||
if fcst, ok := pvEnergy[name]; ok {
|
||||
site.pvEnergy[name].Accumulated = fcst
|
||||
} else {
|
||||
// TODO decide auto-reset?
|
||||
site.log.WARN.Printf("cannot restore accumulated solar yield for: %s (may need to reset solar statistics)", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -513,6 +539,20 @@ func (site *Site) updatePvMeters() {
|
|||
site.publish(keys.PvPower, site.pvPower)
|
||||
site.publish(keys.PvEnergy, totalEnergy)
|
||||
site.publish(keys.Pv, mm)
|
||||
|
||||
// update solar yield
|
||||
for i, name := range site.Meters.PVMetersRef {
|
||||
if mm[i].Energy > 0 {
|
||||
site.pvEnergy[name].AddMeterTotal(mm[i].Energy)
|
||||
} else {
|
||||
site.pvEnergy[name].AddPower(mm[i].Power)
|
||||
}
|
||||
}
|
||||
|
||||
// store
|
||||
if err := settings.SetJson(keys.SolarAccYield, site.pvEnergy); err != nil {
|
||||
site.log.ERROR.Println("accumulated solar yield:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// updateBatteryMeters updates battery meters
|
||||
|
|
@ -564,6 +604,7 @@ func (site *Site) updateBatteryMeters() {
|
|||
totalCapacity = float64(len(site.batteryMeters))
|
||||
}
|
||||
site.batterySoc = batterySocAcc / totalCapacity
|
||||
site.batteryCapacity = totalCapacity
|
||||
|
||||
site.batteryPower = lo.SumBy(mm, func(m measurement) float64 {
|
||||
return m.Power
|
||||
|
|
@ -577,7 +618,7 @@ func (site *Site) updateBatteryMeters() {
|
|||
site.log.DEBUG.Printf("battery soc: %.0f%%", math.Round(site.batterySoc))
|
||||
}
|
||||
|
||||
site.publish(keys.BatteryCapacity, totalCapacity)
|
||||
site.publish(keys.BatteryCapacity, site.batteryCapacity)
|
||||
site.publish(keys.BatterySoc, site.batterySoc)
|
||||
|
||||
site.publish(keys.BatteryPower, site.batteryPower)
|
||||
|
|
@ -746,92 +787,6 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b
|
|||
return sitePower, batteryBuffered, batteryStart, nil
|
||||
}
|
||||
|
||||
// greenShare returns
|
||||
// - the current green share, calculated for the part of the consumption between powerFrom and powerTo
|
||||
// the consumption below powerFrom will get the available green power first
|
||||
func (site *Site) greenShare(powerFrom float64, powerTo float64) float64 {
|
||||
greenPower := math.Max(0, site.pvPower) + math.Max(0, site.batteryPower)
|
||||
greenPowerAvailable := math.Max(0, greenPower-powerFrom)
|
||||
|
||||
power := powerTo - powerFrom
|
||||
share := math.Min(greenPowerAvailable, power) / power
|
||||
|
||||
if math.IsNaN(share) {
|
||||
if greenPowerAvailable > 0 {
|
||||
share = 1
|
||||
} else {
|
||||
share = 0
|
||||
}
|
||||
}
|
||||
|
||||
return share
|
||||
}
|
||||
|
||||
// effectivePrice calculates the real energy price based on self-produced and grid-imported energy.
|
||||
func (site *Site) effectivePrice(greenShare float64) *float64 {
|
||||
if grid, err := tariff.Now(site.GetTariff(api.TariffUsageGrid)); err == nil {
|
||||
feedin, err := tariff.Now(site.GetTariff(api.TariffUsageFeedIn))
|
||||
if err != nil {
|
||||
feedin = 0
|
||||
}
|
||||
effPrice := grid*(1-greenShare) + feedin*greenShare
|
||||
return &effPrice
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// effectiveCo2 calculates the amount of emitted co2 based on self-produced and grid-imported energy.
|
||||
func (site *Site) effectiveCo2(greenShare float64) *float64 {
|
||||
if co2, err := tariff.Now(site.GetTariff(api.TariffUsageCo2)); err == nil {
|
||||
effCo2 := co2 * (1 - greenShare)
|
||||
return &effCo2
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (site *Site) publishTariffs(greenShareHome float64, greenShareLoadpoints float64) {
|
||||
site.publish(keys.GreenShareHome, greenShareHome)
|
||||
site.publish(keys.GreenShareLoadpoints, greenShareLoadpoints)
|
||||
|
||||
if v, err := tariff.Now(site.GetTariff(api.TariffUsageGrid)); err == nil {
|
||||
site.publish(keys.TariffGrid, v)
|
||||
}
|
||||
if v, err := tariff.Now(site.GetTariff(api.TariffUsageFeedIn)); err == nil {
|
||||
site.publish(keys.TariffFeedIn, v)
|
||||
}
|
||||
if v, err := tariff.Now(site.GetTariff(api.TariffUsageCo2)); err == nil {
|
||||
site.publish(keys.TariffCo2, v)
|
||||
}
|
||||
if v, err := tariff.Now(site.GetTariff(api.TariffUsageSolar)); err == nil {
|
||||
site.publish(keys.TariffSolar, v)
|
||||
}
|
||||
if v := site.effectivePrice(greenShareHome); v != nil {
|
||||
site.publish(keys.TariffPriceHome, v)
|
||||
}
|
||||
if v := site.effectiveCo2(greenShareHome); v != nil {
|
||||
site.publish(keys.TariffCo2Home, v)
|
||||
}
|
||||
if v := site.effectivePrice(greenShareLoadpoints); v != nil {
|
||||
site.publish(keys.TariffPriceLoadpoints, v)
|
||||
}
|
||||
if v := site.effectiveCo2(greenShareLoadpoints); v != nil {
|
||||
site.publish(keys.TariffCo2Loadpoints, v)
|
||||
}
|
||||
|
||||
// forecast
|
||||
site.publish(keys.Forecast, struct {
|
||||
Co2 api.Rates `json:"co2,omitempty"`
|
||||
FeedIn api.Rates `json:"feedin,omitempty"`
|
||||
Grid api.Rates `json:"grid,omitempty"`
|
||||
Solar api.Rates `json:"solar,omitempty"`
|
||||
}{
|
||||
Co2: tariff.Forecast(site.GetTariff(api.TariffUsageCo2)),
|
||||
FeedIn: tariff.Forecast(site.GetTariff(api.TariffUsageFeedIn)),
|
||||
Grid: tariff.Forecast(site.GetTariff(api.TariffUsageGrid)),
|
||||
Solar: tariff.Forecast(site.GetTariff(api.TariffUsageSolar)),
|
||||
})
|
||||
}
|
||||
|
||||
// updateLoadpoints updates all loadpoints' charge power
|
||||
func (site *Site) updateLoadpoints(rates api.Rates) float64 {
|
||||
var (
|
||||
|
|
|
|||
219
core/site_tariffs.go
Normal file
219
core/site_tariffs.go
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"math"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/keys"
|
||||
"github.com/evcc-io/evcc/server/db/settings"
|
||||
"github.com/evcc-io/evcc/tariff"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// greenShare returns
|
||||
// - the current green share, calculated for the part of the consumption between powerFrom and powerTo
|
||||
// the consumption below powerFrom will get the available green power first
|
||||
func (site *Site) greenShare(powerFrom float64, powerTo float64) float64 {
|
||||
greenPower := math.Max(0, site.pvPower) + math.Max(0, site.batteryPower)
|
||||
greenPowerAvailable := math.Max(0, greenPower-powerFrom)
|
||||
|
||||
power := powerTo - powerFrom
|
||||
share := math.Min(greenPowerAvailable, power) / power
|
||||
|
||||
if math.IsNaN(share) {
|
||||
if greenPowerAvailable > 0 {
|
||||
share = 1
|
||||
} else {
|
||||
share = 0
|
||||
}
|
||||
}
|
||||
|
||||
return share
|
||||
}
|
||||
|
||||
// effectivePrice calculates the real energy price based on self-produced and grid-imported energy.
|
||||
func (site *Site) effectivePrice(greenShare float64) *float64 {
|
||||
if grid, err := tariff.Now(site.GetTariff(api.TariffUsageGrid)); err == nil {
|
||||
feedin, err := tariff.Now(site.GetTariff(api.TariffUsageFeedIn))
|
||||
if err != nil {
|
||||
feedin = 0
|
||||
}
|
||||
effPrice := grid*(1-greenShare) + feedin*greenShare
|
||||
return &effPrice
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// effectiveCo2 calculates the amount of emitted co2 based on self-produced and grid-imported energy.
|
||||
func (site *Site) effectiveCo2(greenShare float64) *float64 {
|
||||
if co2, err := tariff.Now(site.GetTariff(api.TariffUsageCo2)); err == nil {
|
||||
effCo2 := co2 * (1 - greenShare)
|
||||
return &effCo2
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// accumulatedEnergy calculates the energy consumption between from and to,
|
||||
// assuming the rates containing the power at given timestamp.
|
||||
// Result is in Wh
|
||||
func accumulatedEnergy(rr timeseries, from, to time.Time) float64 {
|
||||
var energy float64
|
||||
var last tsValue
|
||||
|
||||
for _, r := range rr {
|
||||
// fmt.Println(r.Start.Local().Format(time.RFC3339), r.End.Local().Format(time.RFC3339), r.Price)
|
||||
|
||||
if !r.Timestamp.After(from) {
|
||||
last = r
|
||||
continue
|
||||
}
|
||||
|
||||
start := last.Timestamp
|
||||
if start.Before(from) {
|
||||
start = from
|
||||
}
|
||||
|
||||
end := r.Timestamp
|
||||
if end.After(to) {
|
||||
end = to
|
||||
}
|
||||
|
||||
energy += (r.Value + last.Value) / 2 * end.Sub(start).Hours()
|
||||
|
||||
if !r.Timestamp.Before(to) {
|
||||
break
|
||||
}
|
||||
|
||||
last = r
|
||||
}
|
||||
|
||||
return energy
|
||||
}
|
||||
|
||||
type (
|
||||
timeseries []tsValue
|
||||
tsValue struct {
|
||||
Timestamp time.Time `json:"ts"`
|
||||
Value float64 `json:"val"`
|
||||
}
|
||||
)
|
||||
|
||||
func (rr *timeseries) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(rr)
|
||||
}
|
||||
|
||||
func timestampSeries(rr api.Rates) timeseries {
|
||||
return lo.Map(rr, func(r api.Rate, _ int) tsValue {
|
||||
return tsValue{
|
||||
Timestamp: r.Start,
|
||||
Value: r.Price,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (site *Site) publishTariffs(greenShareHome float64, greenShareLoadpoints float64) {
|
||||
site.publish(keys.GreenShareHome, greenShareHome)
|
||||
site.publish(keys.GreenShareLoadpoints, greenShareLoadpoints)
|
||||
|
||||
if v, err := tariff.Now(site.GetTariff(api.TariffUsageGrid)); err == nil {
|
||||
site.publish(keys.TariffGrid, v)
|
||||
}
|
||||
if v, err := tariff.Now(site.GetTariff(api.TariffUsageFeedIn)); err == nil {
|
||||
site.publish(keys.TariffFeedIn, v)
|
||||
}
|
||||
if v, err := tariff.Now(site.GetTariff(api.TariffUsageCo2)); err == nil {
|
||||
site.publish(keys.TariffCo2, v)
|
||||
}
|
||||
if v, err := tariff.Now(site.GetTariff(api.TariffUsageSolar)); err == nil {
|
||||
site.publish(keys.TariffSolar, v)
|
||||
}
|
||||
if v := site.effectivePrice(greenShareHome); v != nil {
|
||||
site.publish(keys.TariffPriceHome, v)
|
||||
}
|
||||
if v := site.effectiveCo2(greenShareHome); v != nil {
|
||||
site.publish(keys.TariffCo2Home, v)
|
||||
}
|
||||
if v := site.effectivePrice(greenShareLoadpoints); v != nil {
|
||||
site.publish(keys.TariffPriceLoadpoints, v)
|
||||
}
|
||||
if v := site.effectiveCo2(greenShareLoadpoints); v != nil {
|
||||
site.publish(keys.TariffCo2Loadpoints, v)
|
||||
}
|
||||
|
||||
type dailyDetails struct {
|
||||
Yield float64 `json:"energy"`
|
||||
Complete bool `json:"complete"`
|
||||
}
|
||||
|
||||
type solarDetails struct {
|
||||
Scale *float64 `json:"scale,omitempty"` // scale factor yield/forecasted today
|
||||
Today dailyDetails `json:"today,omitempty"` // tomorrow
|
||||
Tomorrow dailyDetails `json:"tomorrow,omitempty"` // tomorrow
|
||||
DayAfterTomorrow dailyDetails `json:"dayAfterTomorrow,omitempty"` // day after tomorrow
|
||||
Timeseries timeseries `json:"timeseries,omitempty"` // timeseries of forecasted energy
|
||||
}
|
||||
|
||||
fc := struct {
|
||||
Co2 api.Rates `json:"co2,omitempty"`
|
||||
FeedIn api.Rates `json:"feedin,omitempty"`
|
||||
Grid api.Rates `json:"grid,omitempty"`
|
||||
Solar solarDetails `json:"solar,omitempty"`
|
||||
}{
|
||||
Co2: tariff.Forecast(site.GetTariff(api.TariffUsageCo2)),
|
||||
FeedIn: tariff.Forecast(site.GetTariff(api.TariffUsageFeedIn)),
|
||||
Grid: tariff.Forecast(site.GetTariff(api.TariffUsageGrid)),
|
||||
}
|
||||
|
||||
// calculate adjusted solar forecast
|
||||
solar := timestampSeries(tariff.Forecast(site.GetTariff(api.TariffUsageSolar)))
|
||||
if len(solar) > 0 {
|
||||
fc.Solar.Timeseries = solar
|
||||
|
||||
last := solar[len(solar)-1].Timestamp
|
||||
|
||||
bod := beginningOfDay(time.Now())
|
||||
eod := bod.AddDate(0, 0, 1)
|
||||
eot := eod.AddDate(0, 0, 1)
|
||||
|
||||
remainingToday := accumulatedEnergy(solar, time.Now(), eod)
|
||||
tomorrow := accumulatedEnergy(solar, eod, eot)
|
||||
dayAfterTomorrow := accumulatedEnergy(solar, eot, eot.AddDate(0, 0, 1))
|
||||
|
||||
fc.Solar.Today = dailyDetails{
|
||||
Yield: remainingToday,
|
||||
Complete: !last.Before(eod),
|
||||
}
|
||||
fc.Solar.Tomorrow = dailyDetails{
|
||||
Yield: tomorrow,
|
||||
Complete: !last.Before(eot),
|
||||
}
|
||||
fc.Solar.DayAfterTomorrow = dailyDetails{
|
||||
Yield: dayAfterTomorrow,
|
||||
Complete: !last.Before(eot.AddDate(0, 0, 1)),
|
||||
}
|
||||
|
||||
// scale factor yield/forecasted today
|
||||
const minEnergy = 0.1
|
||||
|
||||
// accumulate forecasted energy since last update
|
||||
site.fcstEnergy.AddEnergy(accumulatedEnergy(solar, site.fcstEnergy.updated, time.Now()) / 1e3)
|
||||
settings.SetFloat(keys.SolarAccForecast, site.fcstEnergy.Accumulated)
|
||||
|
||||
produced := lo.SumBy(slices.Collect(maps.Values(site.pvEnergy)), func(v *meterEnergy) float64 {
|
||||
return v.AccumulatedEnergy()
|
||||
})
|
||||
|
||||
// TODO trace
|
||||
site.log.DEBUG.Printf("solar forecast accumulated: %.1fkWh, produced %.1fkWh, scale %.1f", site.fcstEnergy.Accumulated, produced, produced/site.fcstEnergy.Accumulated)
|
||||
|
||||
if produced > minEnergy /*kWh*/ && site.fcstEnergy.Accumulated > minEnergy /*kWh*/ {
|
||||
fc.Solar.Scale = lo.ToPtr(produced / site.fcstEnergy.Accumulated)
|
||||
}
|
||||
}
|
||||
|
||||
site.publish(keys.Forecast, fc)
|
||||
}
|
||||
43
core/site_tariffs_test.go
Normal file
43
core/site_tariffs_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/benbjohnson/clock"
|
||||
"github.com/jinzhu/now"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAccumulatedEnergy(t *testing.T) {
|
||||
clock := clock.NewMock()
|
||||
clock.Set(now.BeginningOfDay())
|
||||
|
||||
rate := func(start int, val float64) tsValue {
|
||||
return tsValue{
|
||||
Timestamp: clock.Now().Add(time.Duration(start) * time.Hour),
|
||||
Value: val,
|
||||
}
|
||||
}
|
||||
|
||||
rr := timeseries{rate(0, 0), rate(1, 1), rate(2, 2), rate(3, 3), rate(4, 4)}
|
||||
|
||||
for i, tc := range []struct {
|
||||
from, to float64
|
||||
expected float64
|
||||
}{
|
||||
{0, 0, 0},
|
||||
{0, 0.5, 0.25},
|
||||
{0, 1, 0.5},
|
||||
{1, 2, 1.5},
|
||||
{0, 2, 2},
|
||||
} {
|
||||
t.Logf("%d. %+v", i+1, tc)
|
||||
|
||||
from := clock.Now().Add(time.Duration(float64(time.Hour) * tc.from))
|
||||
to := clock.Now().Add(time.Duration(float64(time.Hour) * tc.to))
|
||||
|
||||
res := accumulatedEnergy(rr, from, to)
|
||||
assert.Equal(t, tc.expected, res, "test case %d", i)
|
||||
}
|
||||
}
|
||||
|
|
@ -435,6 +435,7 @@ modalUpdateStatusStart = "Installation started:"
|
|||
|
||||
[forecast]
|
||||
modalTitle = "Forecast"
|
||||
solarAdjust = "Adjust solar forecast based on real production data."
|
||||
|
||||
[forecast.co2]
|
||||
average = "Average"
|
||||
|
|
|
|||
|
|
@ -33,11 +33,9 @@ func (t *combined) Rates() (api.Rates, error) {
|
|||
}
|
||||
}
|
||||
|
||||
keys = slices.SortedFunc(slices.Values(keys), func(a, b time.Time) int {
|
||||
return a.Compare(b)
|
||||
})
|
||||
|
||||
var res api.Rates
|
||||
keys = slices.SortedFunc(slices.Values(keys), time.Time.Compare)
|
||||
|
||||
for _, ts := range keys {
|
||||
var rate api.Rate
|
||||
|
||||
|
|
|
|||
|
|
@ -67,7 +67,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 {
|
||||
// beginningOfDay returns the beginning of the current day
|
||||
func beginningOfDay() time.Time {
|
||||
return now.With(time.Now()).BeginningOfDay()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import (
|
|||
|
||||
func TestMergeRatesAfter(t *testing.T) {
|
||||
clock := clock.NewMock()
|
||||
clock.Set(now.BeginningOfDay())
|
||||
|
||||
rate := func(start int, val float64) api.Rate {
|
||||
return api.Rate{
|
||||
Start: clock.Now().Add(time.Duration(start) * time.Hour),
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ func (t *Solcast) run(interval time.Duration, done chan error) {
|
|||
data = append(data, rr)
|
||||
}
|
||||
|
||||
mergeRatesAfter(t.data, data, BeginningOfDay())
|
||||
mergeRatesAfter(t.data, data, beginningOfDay())
|
||||
once.Do(func() { close(done) })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,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()
|
||||
if t.typ == api.TariffTypeSolar {
|
||||
periodStart = BeginningOfDay()
|
||||
periodStart = beginningOfDay()
|
||||
}
|
||||
mergeRatesAfter(t.data, data, periodStart)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue