Forecast: add adjusted forecast (#18867)

This commit is contained in:
andig 2025-03-06 14:53:16 +01:00 • committed by GitHub
parent b46962aea0
commit 73b7014704
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 734 additions and 308 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -435,6 +435,7 @@ modalUpdateStatusStart = "Installation started:"
[forecast]
modalTitle = "Forecast"
solarAdjust = "Adjust solar forecast based on real production data."
[forecast.co2]
average = "Average"

View file

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

View file

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

View file

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

View file

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

View file

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