API: publish forecast as arrays with unix timestamps (BC) (#32391)

This commit is contained in:
andig 2026-08-06 11:49:33 +02:00 • committed by GitHub
parent 9d0bc5a865
commit 45e781dac0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 361 additions and 223 deletions

View file

@ -117,8 +117,8 @@ export default defineComponent({
return this.gridChargePossible || this.gridChargeLimit !== null;
},
gridChargeTariff() {
const { forecast, smartCostType } = this.state;
return smartCostType === SMART_COST_TYPE.CO2 ? forecast?.co2 : forecast?.grid;
const { co2, grid } = store.uiForecast.value;
return this.state.smartCostType === SMART_COST_TYPE.CO2 ? co2 : grid;
},
smartCostLimitProps() {
return {

View file

@ -53,7 +53,6 @@ import MoreItem from "./MoreItem.vue";
import { defineComponent, type PropType } from "vue";
import type {
FatalError,
Forecast,
Sponsor,
EvOpt,
AuthProviders,
@ -74,7 +73,6 @@ export default defineComponent({
battery: { type: Object as PropType<Battery> },
batteryGridChargeActive: Boolean,
batteryMode: { type: String as PropType<string> },
forecast: { type: Object as PropType<Forecast> },
vehicles: { type: Object as PropType<Record<string, Vehicle>>, default: () => ({}) },
authProviders: { type: Object as PropType<AuthProviders>, default: () => ({}) },
sponsor: { type: Object as PropType<Sponsor>, default: () => ({}) },

View file

@ -38,7 +38,7 @@ import formatter from "@/mixins/formatter";
import minuteTicker from "@/mixins/minuteTicker";
import { optionStep, fmtEnergy } from "@/utils/energyOptions.ts";
import { defineComponent, type PropType } from "vue";
import type { CURRENCY, Forecast, PlanStrategy, Vehicle } from "@/types/evcc";
import type { CURRENCY, UiForecast, PlanStrategy, Vehicle } from "@/types/evcc";
export default defineComponent({
name: "ChargingPlan",
@ -71,7 +71,7 @@ export default defineComponent({
vehicleSoc: Number,
vehicleLimitSoc: Number,
vehicleNotReachable: Boolean,
forecast: Object as PropType<Forecast>,
forecast: Object as PropType<UiForecast>,
},
emits: ["open-modal"],
data() {

View file

@ -48,7 +48,7 @@ import PlansSettings from "./PlansSettings.vue";
import api from "@/api";
import type {
CURRENCY,
Forecast,
UiForecast,
PlanStrategy,
RepeatingPlan,
SMART_COST_TYPE,
@ -70,7 +70,7 @@ export default defineComponent({
vehicles: { type: Array as PropType<Vehicle[]>, default: () => [] },
smartCostType: String as PropType<SMART_COST_TYPE>,
currency: String as PropType<CURRENCY>,
forecast: Object as PropType<Forecast>,
forecast: Object as PropType<UiForecast>,
},
data() {
return {

View file

@ -73,7 +73,7 @@ import { debounceLeading } from "@/utils/debounceLeading";
import { defineComponent, type PropType } from "vue";
import type {
CURRENCY,
Forecast,
UiForecast,
PlanResponse,
PlanStrategy,
PlanWrapper,
@ -115,7 +115,7 @@ export default defineComponent({
vehicle: Object as PropType<Vehicle>,
vehicleLimitSoc: Number,
planOverrun: Number,
forecast: Object as PropType<Forecast>,
forecast: Object as PropType<UiForecast>,
},
emits: [
"static-plan-removed",

View file

@ -328,7 +328,7 @@ import {
type Battery,
type Meter,
type CURRENCY,
type Forecast,
type UiForecast,
type UiLoadpoint,
} from "@/types/evcc";
@ -371,7 +371,7 @@ export default defineComponent({
prioritySoc: { type: Number },
bufferSoc: { type: Number },
bufferStartSoc: { type: Number },
forecast: { type: Object as PropType<Forecast>, default: () => ({}) },
forecast: { type: Object as PropType<UiForecast>, default: () => ({}) },
},
data: () => {
return {

View file

@ -42,7 +42,7 @@ import "chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm";
import { registerChartComponents, commonOptions } from "../Sessions/chartConfig";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import colors, { lighterColor } from "@/colors";
import type { CURRENCY, ForecastSlot, SolarDetails, TimeseriesEntry } from "@/types/evcc";
import type { CURRENCY, UiForecastSlot, UiSolarDetails, UiTimeseriesEntry } from "@/types/evcc";
import { ForecastType, highestSlotIndexByDay } from "@/utils/forecast";
registerChartComponents([
@ -64,9 +64,9 @@ export default defineComponent({
components: { Bar },
mixins: [formatter],
props: {
grid: { type: Array as PropType<ForecastSlot[]> },
solar: { type: Object as PropType<SolarDetails> },
co2: { type: Array as PropType<ForecastSlot[]> },
grid: { type: Array as PropType<UiForecastSlot[]> },
solar: { type: Object as PropType<UiSolarDetails> },
co2: { type: Array as PropType<UiForecastSlot[]> },
currency: { type: String as PropType<CURRENCY> },
selected: { type: String as PropType<ForecastType> },
},
@ -452,7 +452,7 @@ export default defineComponent({
now.setMilliseconds(0);
this.startDate = now;
},
filterSlots(slots: ForecastSlot[] = []) {
filterSlots(slots: UiForecastSlot[] = []) {
if (!slots) {
return undefined;
}
@ -462,7 +462,7 @@ export default defineComponent({
new Date(slot.end) >= this.startDate && new Date(slot.start) <= this.endDate
);
},
filterEntries(entries: TimeseriesEntry[] = []) {
filterEntries(entries: UiTimeseriesEntry[] = []) {
return entries.filter(
(entry) =>
new Date(entry.ts) >= this.startDate && new Date(entry.ts) <= this.endDate
@ -491,7 +491,7 @@ export default defineComponent({
}, 100);
}
},
yMax(slots: ForecastSlot[] = []): number | undefined {
yMax(slots: UiForecastSlot[] = []): number | undefined {
const max = this.maxValue(slots);
if (!max) return undefined;
const fixedValues = slots.every((slot) => slot.value === max);
@ -499,29 +499,29 @@ export default defineComponent({
const topSpace = fixedValues ? 3 : 1.15;
return max * topSpace;
},
yMaxEntry(entries: TimeseriesEntry[] = [], scale: number = 1): number | undefined {
yMaxEntry(entries: UiTimeseriesEntry[] = [], 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: ForecastSlot[] = []) {
maxIndex(slots: UiForecastSlot[] = []) {
return slots.reduce((max, slot, index) => {
return slot.value > (slots[max]?.value || 0) ? index : max;
}, 0);
},
minIndex(slots: ForecastSlot[] = []) {
minIndex(slots: UiForecastSlot[] = []) {
return slots.reduce((min, slot, index) => {
return slot.value < (slots[min]?.value || 0) ? index : min;
}, 0);
},
maxValue(slots: ForecastSlot[] = []) {
maxValue(slots: UiForecastSlot[] = []) {
return slots[this.maxIndex(slots)]?.value || null;
},
maxEntryValue(entries: TimeseriesEntry[] = []) {
maxEntryValue(entries: UiTimeseriesEntry[] = []) {
return entries[this.maxEntryIndex(entries)]?.val || null;
},
maxEntryIndex(entries: TimeseriesEntry[] = []) {
maxEntryIndex(entries: UiTimeseriesEntry[] = []) {
return entries.reduce((max, entry, index) => {
return entry.val > (entries[max]?.val || 0) ? index : max;
}, 0);

View file

@ -50,7 +50,7 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import formatter from "@/mixins/formatter";
import type { CURRENCY, ForecastSlot } from "@/types/evcc";
import type { CURRENCY, UiForecastSlot } from "@/types/evcc";
import { isStaticTariff } from "@/utils/forecast";
const MAX_HOURS = 96;
@ -60,8 +60,8 @@ export default defineComponent({
name: "GridDetails",
mixins: [formatter],
props: {
grid: { type: Array as PropType<ForecastSlot[]> },
feedin: { type: Array as PropType<ForecastSlot[]> },
grid: { type: Array as PropType<UiForecastSlot[]> },
feedin: { type: Array as PropType<UiForecastSlot[]> },
currency: { type: String as PropType<CURRENCY> },
showFeedin: { type: Boolean, default: true },
},
@ -81,7 +81,7 @@ export default defineComponent({
toggleFeedin() {
this.$emit("toggle-feedin");
},
summarize(slots?: ForecastSlot[]): { avg: string; range: string } | null {
summarize(slots?: UiForecastSlot[]): { avg: string; range: string } | null {
const upcoming = this.upcomingSlots(slots);
if (upcoming.length === 0) return null;
const values = upcoming.map((s) => s.value);
@ -94,7 +94,7 @@ export default defineComponent({
const fmtMax = this.fmtPricePerKWh(max, this.currency, false, true);
return { avg: `⌀ ${fmtAvg}`, range: `${fmtMin} – ${fmtMax}` };
},
upcomingSlots(slots?: ForecastSlot[]): ForecastSlot[] {
upcomingSlots(slots?: UiForecastSlot[]): UiForecastSlot[] {
if (!Array.isArray(slots)) return [];
const now = new Date();
return slots

View file

@ -23,32 +23,32 @@ import {
import colors, { lighterColor } from "@/colors";
import formatter from "@/mixins/formatter";
import chartMixin from "./chartMixin";
import type { CURRENCY, ForecastSlot } from "@/types/evcc";
import type { CURRENCY, UiForecastSlot } from "@/types/evcc";
export default defineComponent({
name: "PriceChart",
mixins: [formatter, chartMixin],
props: {
grid: { type: Array as PropType<ForecastSlot[]>, required: true },
feedin: { type: Array as PropType<ForecastSlot[]> },
grid: { type: Array as PropType<UiForecastSlot[]>, required: true },
feedin: { type: Array as PropType<UiForecastSlot[]> },
currency: { type: String as PropType<CURRENCY> },
zoom: { type: Boolean, default: false },
},
computed: {
slots(): ForecastSlot[] {
slots(): UiForecastSlot[] {
return filterForecastSlots(this.grid, this.startDate, this.endDate);
},
feedinSlots(): ForecastSlot[] {
feedinSlots(): UiForecastSlot[] {
return this.feedin
? filterForecastSlots(this.feedin, this.startDate, this.endDate)
: [];
},
markPoints(): { coord: [string, number]; value: string }[] {
markPoints(): { coord: [number, number]; value: string }[] {
const slots = this.slots;
if (!slots.length) return [];
const minIdx = minSlotIndex(slots);
const maxIdx = maxSlotIndex(slots);
const points: { coord: [string, number]; value: string }[] = [];
const points: { coord: [number, number]; value: string }[] = [];
if (slots[minIdx]) {
points.push({
coord: [clampStart(slots[minIdx]!.start, this.startDate), slots[minIdx]!.value],
@ -144,9 +144,9 @@ export default defineComponent({
},
methods: {
priceSeries(
slots: ForecastSlot[],
slots: UiForecastSlot[],
color: string,
points?: { coord: [string, number]; value: string }[]
points?: { coord: [number, number]; value: string }[]
): Record<string, unknown> {
const avg = slots.length ? slots.reduce((a, s) => a + s.value, 0) / slots.length : 0;
const gradientDown = avg >= 0;

View file

@ -19,17 +19,17 @@ import colors, { lighterColor } from "@/colors";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import chartMixin from "./chartMixin";
import { highestSlotIndexByDay } from "@/utils/forecast";
import type { SolarDetails, TimeseriesEntry } from "@/types/evcc";
import type { UiSolarDetails, UiTimeseriesEntry } from "@/types/evcc";
export default defineComponent({
name: "SolarChart",
mixins: [formatter, chartMixin],
props: {
solar: { type: Object as PropType<SolarDetails> },
rawSolar: { type: Object as PropType<SolarDetails> },
solar: { type: Object as PropType<UiSolarDetails> },
rawSolar: { type: Object as PropType<UiSolarDetails> },
},
computed: {
entries(): TimeseriesEntry[] {
entries(): UiTimeseriesEntry[] {
return (this.solar?.timeseries || []).filter(
(e) => new Date(e.ts) >= this.startDate && new Date(e.ts) <= this.endDate
);
@ -43,8 +43,8 @@ export default defineComponent({
}
return max;
},
markPoints(): { coord: [string, number]; value: string }[] {
const points: { coord: [string, number]; value: string }[] = [];
markPoints(): { coord: [number, number]; value: string }[] {
const points: { coord: [number, number]; value: string }[] = [];
const days = [
{ energy: this.solar?.today?.energy, day: 0 },
{ energy: this.solar?.tomorrow?.energy, day: 1 },

View file

@ -19,13 +19,13 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import type { SolarDetails } from "@/types/evcc";
import type { UiSolarDetails } from "@/types/evcc";
export default defineComponent({
name: "SolarDetails",
mixins: [formatter],
props: {
solar: { type: Object as PropType<SolarDetails> },
solar: { type: Object as PropType<UiSolarDetails> },
},
computed: {
days(): {

View file

@ -22,7 +22,7 @@ import {
import colors from "@/colors";
import formatter from "@/mixins/formatter";
import chartMixin from "./chartMixin";
import type { ForecastSlot } from "@/types/evcc";
import type { UiForecastSlot } from "@/types/evcc";
export type ValueChartType = "co2" | "temperature";
@ -31,13 +31,13 @@ export default defineComponent({
mixins: [formatter, chartMixin],
props: {
type: { type: String as PropType<ValueChartType>, required: true },
rates: { type: Array as PropType<ForecastSlot[]>, required: true },
rates: { type: Array as PropType<UiForecastSlot[]>, required: true },
},
computed: {
color(): string {
return (this.type === "co2" ? colors.co2 : colors.temperature) || "";
},
slots(): ForecastSlot[] {
slots(): UiForecastSlot[] {
return filterForecastSlots(this.rates, this.startDate, this.endDate);
},
yMin(): number {
@ -46,7 +46,7 @@ export default defineComponent({
return Math.min(0, Math.floor(Math.min(...this.slots.map((s) => s.value))));
},
markPoints(): {
coord: [string, number];
coord: [number, number];
value: string;
label?: Record<string, unknown>;
}[] {
@ -55,7 +55,7 @@ export default defineComponent({
const minIdx = minSlotIndex(slots);
const maxIdx = maxSlotIndex(slots);
const points: {
coord: [string, number];
coord: [number, number];
value: string;
label?: Record<string, unknown>;
}[] = [];

View file

@ -20,7 +20,7 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import formatter from "@/mixins/formatter";
import type { ForecastSlot } from "@/types/evcc";
import type { UiForecastSlot } from "@/types/evcc";
import type { ValueChartType } from "./ValueChart.vue";
const MAX_HOURS = 96;
@ -31,10 +31,10 @@ export default defineComponent({
mixins: [formatter],
props: {
type: { type: String as PropType<ValueChartType>, required: true },
rates: { type: Array as PropType<ForecastSlot[]> },
rates: { type: Array as PropType<UiForecastSlot[]> },
},
computed: {
upcomingSlots(): ForecastSlot[] {
upcomingSlots(): UiForecastSlot[] {
if (!Array.isArray(this.rates)) return [];
const now = new Date();
return this.rates

View file

@ -1,7 +1,7 @@
import * as echarts from "echarts/core";
import colors from "@/colors";
import escapeHtml from "@/utils/escapeHtml";
import type { ForecastSlot } from "@/types/evcc";
import type { UiForecastSlot } from "@/types/evcc";
import { BarChart, LineChart } from "echarts/charts";
import {
GridComponent,
@ -31,7 +31,7 @@ export const FONT_FAMILY = "Montserrat, sans-serif";
export function markPointLabel(
color: string,
data: { coord: [string, number]; value: string; label?: { offset?: [number, number] } }[],
data: { coord: [number, number]; value: string; label?: { offset?: [number, number] } }[],
startDate?: Date,
endDate?: Date
) {
@ -249,24 +249,24 @@ export function forecastYAxis(overrides: Record<string, unknown> = {}) {
};
}
export function clampStart(ts: string, startDate: Date): string {
return new Date(ts) < startDate ? startDate.toISOString() : ts;
export function clampStart(ts: number, startDate: Date): number {
return Math.max(ts, startDate.getTime());
}
export function filterForecastSlots(
slots: ForecastSlot[],
slots: UiForecastSlot[],
startDate: Date,
endDate: Date
): ForecastSlot[] {
): UiForecastSlot[] {
if (!Array.isArray(slots)) return [];
return slots.filter((s) => new Date(s.end) > startDate && new Date(s.start) <= endDate);
}
export function minSlotIndex(slots: ForecastSlot[]): number {
export function minSlotIndex(slots: UiForecastSlot[]): number {
return slots.reduce((min, s, i) => (s.value < (slots[min]?.value ?? Infinity) ? i : min), 0);
}
export function maxSlotIndex(slots: ForecastSlot[]): number {
export function maxSlotIndex(slots: UiForecastSlot[]): number {
return slots.reduce((max, s, i) => (s.value > (slots[max]?.value || 0) ? i : max), 0);
}

View file

@ -125,7 +125,7 @@ import type {
CHARGER_STATUS_REASON,
Timeout,
Vehicle,
Forecast,
UiForecast,
SMART_COST_TYPE,
BATTERY_MODE,
LoadpointUi,
@ -251,7 +251,7 @@ export default defineComponent({
fullWidth: Boolean,
gridConfigured: Boolean,
pvConfigured: Boolean,
forecast: Object as PropType<Forecast>,
forecast: Object as PropType<UiForecast>,
lastSmartCostLimit: Number,
lastSmartFeedInPriorityLimit: Number,
vehicleKnown: Boolean,

View file

@ -98,7 +98,7 @@ import type {
Timeout,
Vehicle,
BATTERY_MODE,
Forecast,
UiForecast,
CURRENCY,
} from "@/types/evcc";
import ChargingPlanModal from "../ChargingPlans/ChargingPlanModal.vue";
@ -123,7 +123,7 @@ export default defineComponent({
batteryConfigured: Boolean,
batterySoc: Number,
batteryMode: String as PropType<BATTERY_MODE>,
forecast: Object as PropType<Forecast>,
forecast: Object as PropType<UiForecast>,
},
emits: ["id-changed"],
data() {

View file

@ -169,7 +169,7 @@ import SmartCostLimit from "../Tariff/SmartCostLimit.vue";
import SmartFeedInPriority from "../Tariff/SmartFeedInPriority.vue";
import SettingsBatteryBoost from "./SettingsBatteryBoost.vue";
import { defineComponent, type PropType } from "vue";
import { PHASES, CURRENCY, SMART_COST_TYPE, type Forecast, type UiLoadpoint } from "@/types/evcc";
import { PHASES, CURRENCY, SMART_COST_TYPE, type UiForecast, type UiLoadpoint } from "@/types/evcc";
import api from "@/api";
const V = 230;
@ -206,7 +206,7 @@ export default defineComponent({
tariffGrid: Number,
currency: String as PropType<CURRENCY>,
multipleLoadpoints: Boolean,
forecast: Object as PropType<Forecast>,
forecast: Object as PropType<UiForecast>,
},
data() {
return {

View file

@ -268,7 +268,7 @@ import settings from "@/settings.ts";
import { defineComponent, type PropType } from "vue";
import type {
CURRENCY,
Forecast,
UiForecast,
StatisticsIndicator,
StatisticsPeriod,
SelectOption,
@ -293,7 +293,7 @@ export default defineComponent({
sponsor: Object as PropType<SponsorType>,
currency: String as PropType<CURRENCY>,
telemetry: Boolean,
forecast: Object as PropType<Forecast>,
forecast: Object as PropType<UiForecast>,
tariffGrid: Number,
},
data() {

View file

@ -80,7 +80,7 @@ import type {
Battery,
Meter,
CURRENCY,
Forecast,
UiForecast,
Notification,
ConfigStatus,
HemsConfig,
@ -147,7 +147,7 @@ export default defineComponent({
smartCostAvailable: Boolean,
smartFeedInPriorityAvailable: Boolean,
fatal: { type: Array as PropType<FatalError[]>, default: () => [] },
forecast: Object as PropType<Forecast>,
forecast: Object as PropType<UiForecast>,
hems: Object as PropType<ConfigStatus<HemsConfig, HemsStatus>>,
evopt: { type: Object as PropType<EvOpt> },
},

View file

@ -24,7 +24,7 @@ import { defineComponent, type PropType } from "vue";
import api from "@/api";
import { setLoadpointLastSmartCostLimit } from "@/uiLoadpoints";
import settings from "@/settings";
import { type CURRENCY, type ForecastSlot, SMART_COST_TYPE } from "@/types/evcc";
import { type CURRENCY, type UiForecastSlot, SMART_COST_TYPE } from "@/types/evcc";
export default defineComponent({
name: "SmartCostLimit",
@ -41,7 +41,7 @@ export default defineComponent({
loadpointId: String,
possible: Boolean,
lastLimit: Number,
tariff: Array as PropType<ForecastSlot[]>,
tariff: Array as PropType<UiForecastSlot[]>,
},
computed: {
isCo2(): boolean {

View file

@ -44,7 +44,7 @@ export default defineComponent({
co2Configured: store.state.tariffCo2 !== undefined,
currency: store.state.currency,
telemetry: store.state.telemetry,
forecast: store.state.forecast,
forecast: store.uiForecast.value,
tariffGrid: store.state.tariffGrid,
};
},

View file

@ -95,7 +95,7 @@ import { defineComponent, type PropType } from "vue";
import {
CHARGE_MODE,
type BATTERY_MODE,
type Forecast,
type UiForecast,
type VehicleStatus,
type Vehicle,
type LoadpointUi,
@ -146,7 +146,7 @@ export default defineComponent({
chargerStatusReason: String,
phaseAction: String,
phaseRemainingInterpolated: Number,
forecast: Object as PropType<Forecast>,
forecast: Object as PropType<UiForecast>,
planActive: Boolean,
planEnergy: Number,
planProjectedStart: String,

View file

@ -1,7 +1,8 @@
import { reactive } from "vue";
import { computed, reactive } from "vue";
import type { State } from "./types/evcc";
import { convertToUiLoadpoints } from "./uiLoadpoints";
import { useDebouncedComputed } from "./utils/useDebouncedComputed";
import { expandForecast } from "./utils/forecast";
import settings from "./settings";
function setProperty(obj: object, props: string[], value: any) {
@ -43,9 +44,14 @@ const uiLoadpoints = useDebouncedComputed(
50
);
// derived forecast with slots expanded to objects with unix milliseconds; lazy,
// only computed while a forecast consumer is mounted
const uiForecast = computed(() => expandForecast(state.forecast));
export interface Store {
state: State; // raw state from websocket
uiLoadpoints: typeof uiLoadpoints;
uiForecast: typeof uiForecast;
offline(value: boolean): void;
update(msg: any): void;
reset(): void;
@ -54,6 +60,7 @@ export interface Store {
const store: Store = {
state,
uiLoadpoints,
uiForecast,
offline(value: boolean) {
state.offline = value;
},

View file

@ -1272,32 +1272,11 @@ export interface Slot {
selectable?: boolean | null;
}
/** A forecast value at a point in time. */
export interface TimeseriesEntry {
/** Forecast power in W. */
val: number;
/**
* Time of the forecast value.
* @format date-time
*/
ts: string;
}
/** A forecast time slot as [start, end, value] array. Start and end are unix seconds. Value unit depends on the forecast type. */
export type ForecastSlot = [number, number, number];
/** A forecast value for a time slot. */
export interface ForecastSlot {
/**
* Start of the time slot.
* @format date-time
*/
start: string;
/**
* End of the time slot.
* @format date-time
*/
end: string;
/** Forecast value of the time slot. Unit depends on the forecast type. */
value: number;
}
/** A forecast value at a point in time as [ts, val] array. ts is unix seconds, val is power in W. */
export type TimeseriesEntry = [number, number];
/** Expected solar production energy of a day. */
export interface EnergyByDay {
@ -1337,6 +1316,33 @@ export interface Forecast {
temperature?: ForecastSlot[];
}
// Ui* variants of the forecast wire types, expanded to objects with unix
// milliseconds by expandForecast in utils/forecast
export interface UiTimeseriesEntry {
ts: number;
val: number;
}
export interface UiForecastSlot {
start: number;
end: number;
value: number;
}
export interface UiSolarDetails extends Omit<SolarDetails, "timeseries"> {
timeseries?: UiTimeseriesEntry[];
}
export interface UiForecast {
grid?: UiForecastSlot[];
co2?: UiForecastSlot[];
solar?: UiSolarDetails;
planner?: UiForecastSlot[];
feedin?: UiForecastSlot[];
temperature?: UiForecastSlot[];
}
export interface SelectOption<T> {
name: string;
value: T;

View file

@ -1,56 +1,81 @@
import { describe, expect, test } from "vite-plus/test";
import { findLowestSumSlotIndex, isStaticTariff } from "./forecast";
import { expandForecast, findLowestSumSlotIndex, isStaticTariff } from "./forecast";
describe("expandForecast", () => {
test("expands slots to milliseconds", () => {
expect(expandForecast({ grid: [[1735689600, 1735693200, 0.25]] }).grid).toEqual([
{ start: 1735689600000, end: 1735693200000, value: 0.25 },
]);
});
test("expands the solar timeseries", () => {
expect(expandForecast({ solar: { scale: 1, timeseries: [[1735689600, 1000]] } }).solar).toEqual(
{ scale: 1, timeseries: [{ ts: 1735689600000, val: 1000 }] }
);
});
test("handles missing input", () => {
expect(expandForecast(undefined)).toEqual({
grid: undefined,
co2: undefined,
solar: undefined,
planner: undefined,
feedin: undefined,
temperature: undefined,
});
});
});
describe("findLowestSumSlotIndex", () => {
test("finds lowest sum with span of 4", () => {
const slots = [
{ start: "2025-01-01T00:00:00Z", value: 10 },
{ start: "2025-01-01T00:15:00Z", value: 8 },
{ start: "2025-01-01T00:30:00Z", value: 6 },
{ start: "2025-01-01T00:45:00Z", value: 4 }, // sum 28 (index 0)
{ start: "2025-01-01T01:00:00Z", value: 2 }, // sum 20 (index 1)
{ start: "2025-01-01T01:15:00Z", value: 3 }, // sum 14 (index 2)
{ start: "2025-01-01T01:30:00Z", value: 5 }, // sum 13 (index 3) lowest
{ start: "2025-01-01T01:45:00Z", value: 7 }, // sum 17 (index 4)
{ start: 1735689600000, value: 10 },
{ start: 1735690500000, value: 8 },
{ start: 1735691400000, value: 6 },
{ start: 1735692300000, value: 4 }, // sum 28 (index 0)
{ start: 1735693200000, value: 2 }, // sum 20 (index 1)
{ start: 1735694100000, value: 3 }, // sum 14 (index 2)
{ start: 1735695000000, value: 5 }, // sum 13 (index 3) lowest
{ start: 1735695900000, value: 7 }, // sum 17 (index 4)
];
expect(findLowestSumSlotIndex(slots, 4)).toBe(3);
});
test("returns -1 when not enough slots", () => {
const slots = [
{ start: "2025-01-01T00:00:00Z", value: 10 },
{ start: "2025-01-01T00:15:00Z", value: 8 },
{ start: 1735689600000, value: 10 },
{ start: 1735690500000, value: 8 },
];
expect(findLowestSumSlotIndex(slots, 4)).toBe(-1);
});
test("handles exact span length", () => {
const slots = [
{ start: "2025-01-01T00:00:00Z", value: 5 },
{ start: "2025-01-01T00:15:00Z", value: 3 },
{ start: "2025-01-01T00:30:00Z", value: 2 },
{ start: "2025-01-01T00:45:00Z", value: 1 },
{ start: 1735689600000, value: 5 },
{ start: 1735690500000, value: 3 },
{ start: 1735691400000, value: 2 },
{ start: 1735692300000, value: 1 },
];
expect(findLowestSumSlotIndex(slots, 4)).toBe(0);
});
test("finds lowest at end", () => {
const slots = [
{ start: "2025-01-01T00:00:00Z", value: 10 },
{ start: "2025-01-01T00:15:00Z", value: 10 },
{ start: "2025-01-01T00:30:00Z", value: 1 },
{ start: "2025-01-01T00:45:00Z", value: 1 },
{ start: "2025-01-01T01:00:00Z", value: 1 },
{ start: 1735689600000, value: 10 },
{ start: 1735690500000, value: 10 },
{ start: 1735691400000, value: 1 },
{ start: 1735692300000, value: 1 },
{ start: 1735693200000, value: 1 },
];
expect(findLowestSumSlotIndex(slots, 3)).toBe(2);
});
test("returns first index when multiple equal sums", () => {
const slots = [
{ start: "2025-01-01T00:00:00Z", value: 2 },
{ start: "2025-01-01T00:15:00Z", value: 2 }, // sum 4
{ start: "2025-01-01T00:30:00Z", value: 2 }, // sum 4 (same)
{ start: "2025-01-01T00:45:00Z", value: 2 },
{ start: 1735689600000, value: 2 },
{ start: 1735690500000, value: 2 }, // sum 4
{ start: 1735691400000, value: 2 }, // sum 4 (same)
{ start: 1735692300000, value: 2 },
];
expect(findLowestSumSlotIndex(slots, 2)).toBe(0);
});
@ -67,18 +92,18 @@ describe("isStaticTariff", () => {
test("returns true when all values are identical", () => {
const slots = [
{ start: "2025-01-01T00:00:00Z", end: "2025-01-01T00:15:00Z", value: 0.25 },
{ start: "2025-01-01T00:15:00Z", end: "2025-01-01T00:30:00Z", value: 0.25 },
{ start: "2025-01-01T00:30:00Z", end: "2025-01-01T00:45:00Z", value: 0.25 },
{ start: 1735689600000, end: 1735690500000, value: 0.25 },
{ start: 1735690500000, end: 1735691400000, value: 0.25 },
{ start: 1735691400000, end: 1735692300000, value: 0.25 },
];
expect(isStaticTariff(slots)).toBe(true);
});
test("returns false when values differ", () => {
const slots = [
{ start: "2025-01-01T00:00:00Z", end: "2025-01-01T00:15:00Z", value: 0.25 },
{ start: "2025-01-01T00:15:00Z", end: "2025-01-01T00:30:00Z", value: 0.3 },
{ start: "2025-01-01T00:30:00Z", end: "2025-01-01T00:45:00Z", value: 0.25 },
{ start: 1735689600000, end: 1735690500000, value: 0.25 },
{ start: 1735690500000, end: 1735691400000, value: 0.3 },
{ start: 1735691400000, end: 1735692300000, value: 0.25 },
];
expect(isStaticTariff(slots)).toBe(false);
});

View file

@ -1,4 +1,12 @@
import type { ForecastSlot, TimeseriesEntry, SolarDetails } from "@/types/evcc";
import type {
Forecast,
ForecastSlot,
SolarDetails,
UiForecast,
UiForecastSlot,
UiTimeseriesEntry,
UiSolarDetails,
} from "@/types/evcc";
import deepCopy from "./deepClone";
export enum ForecastType {
@ -7,6 +15,34 @@ export enum ForecastType {
Co2 = "co2",
}
function expandSlots(slots?: ForecastSlot[]): UiForecastSlot[] | undefined {
return slots?.map(([start, end, value]) => ({ start: start * 1000, end: end * 1000, value }));
}
function expandSolar(solar?: SolarDetails): UiSolarDetails | undefined {
if (!solar) return undefined;
const { timeseries, ...rest } = solar;
return {
...rest,
timeseries: timeseries?.map(([ts, val]) => ({ ts: ts * 1000, val })),
};
}
// The API publishes forecast slots as [start, end, value] and solar entries as
// [ts, val] with timestamps in unix seconds. Expand them to objects with unix
// milliseconds so components can keep using named fields and `new Date(...)`.
export function expandForecast(forecast?: Forecast): UiForecast {
const { grid, co2, solar, planner, feedin, temperature } = forecast ?? {};
return {
grid: expandSlots(grid),
co2: expandSlots(co2),
solar: expandSolar(solar),
planner: expandSlots(planner),
feedin: expandSlots(feedin),
temperature: expandSlots(temperature),
};
}
// return the date in local YYYY-MM-DD format
function toDayString(date: Date): string {
const year = date.getFullYear();
@ -17,9 +53,9 @@ function toDayString(date: Date): string {
// return only slots that are on a given date, ignores slots that are in the past
export function filterEntriesByDate(
entries: TimeseriesEntry[],
entries: UiTimeseriesEntry[],
dayString: string
): TimeseriesEntry[] {
): UiTimeseriesEntry[] {
const now = new Date();
return entries.filter(({ ts }) => {
const isPast = new Date(ts) < now;
@ -36,7 +72,7 @@ export function dayStringByOffset(day: number): string {
}
// return the highest slot for a given day (0 = today, 1 = tomorrow, etc.)
export function highestSlotIndexByDay(entries: TimeseriesEntry[], day: number = 0): number {
export function highestSlotIndexByDay(entries: UiTimeseriesEntry[], day: number = 0): number {
const dayString = dayStringByOffset(day);
const dayEntries = filterEntriesByDate(entries, dayString);
const sortedEntries = dayEntries.sort((a, b) => b.val - a.val);
@ -45,7 +81,7 @@ export function highestSlotIndexByDay(entries: TimeseriesEntry[], day: number =
return entries.findIndex((entry) => entry.ts === highestEntry.ts);
}
export function adjustedSolar(solar?: SolarDetails): SolarDetails | undefined {
export function adjustedSolar(solar?: UiSolarDetails): UiSolarDetails | undefined {
if (!solar?.scale) return solar;
const { scale } = solar;
@ -65,14 +101,14 @@ export function adjustedSolar(solar?: SolarDetails): SolarDetails | undefined {
return result;
}
export function isStaticTariff(slots?: ForecastSlot[]): boolean {
export function isStaticTariff(slots?: UiForecastSlot[]): boolean {
if (!Array.isArray(slots) || slots.length === 0) return false;
const firstValue = slots[0].value;
return slots.every((slot) => slot.value === firstValue);
}
export interface SlotWithValue {
start: string | Date;
start: string | number | Date;
value: number;
}

View file

@ -75,8 +75,8 @@ export default defineComponent({
return this.gridChargePossible || this.gridChargeLimit !== null;
},
gridChargeTariff() {
const { forecast, smartCostType } = this.state;
return smartCostType === SMART_COST_TYPE.CO2 ? forecast?.co2 : forecast?.grid;
const { co2, grid } = store.uiForecast.value;
return this.state.smartCostType === SMART_COST_TYPE.CO2 ? co2 : grid;
},
smartCostLimitProps() {
return {

View file

@ -173,7 +173,7 @@ export default defineComponent({
},
computed: {
forecast() {
return store.state?.forecast || {};
return store.uiForecast.value;
},
forecastAvailable() {
return !!(

View file

@ -2,6 +2,7 @@
<Site
:notifications="notifications"
v-bind="state"
:forecast="uiForecast"
:selected-loadpoint-index="selectedLoadpointIndex"
/>
</template>
@ -22,6 +23,11 @@ export default defineComponent({
data() {
return store;
},
computed: {
uiForecast() {
return store.uiForecast.value;
},
},
head() {
const title = store.state.siteTitle;
if (title) {

View file

@ -10,6 +10,7 @@ import (
"github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util"
"github.com/jinzhu/now"
"github.com/samber/lo"
)
type solarDetails struct {
@ -25,6 +26,20 @@ type dailyDetails struct {
Complete bool `json:"complete"`
}
// forecastRates publishes rates as [start, end, value] with the timestamps in
// unix seconds. The forecast is the largest payload evcc sends and RFC3339
// timestamps are two thirds of it.
func forecastRates(rr api.Rates) [][]float64 {
// keep nil for empty rates: shards are published without omitempty
if len(rr) == 0 {
return nil
}
return lo.Map(rr, func(r api.Rate, _ int) []float64 {
return []float64{float64(r.Start.Unix()), float64(r.End.Unix()), r.Value}
})
}
// 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
@ -101,18 +116,18 @@ func (site *Site) publishTariffs(greenShareHome float64, greenShareLoadpoints fl
}
fc := struct {
Co2 api.Rates `json:"co2,omitempty"`
FeedIn api.Rates `json:"feedin,omitempty"`
Grid api.Rates `json:"grid,omitempty"`
Planner api.Rates `json:"planner,omitempty"`
Co2 [][]float64 `json:"co2,omitempty"`
FeedIn [][]float64 `json:"feedin,omitempty"`
Grid [][]float64 `json:"grid,omitempty"`
Planner [][]float64 `json:"planner,omitempty"`
Solar *solarDetails `json:"solar,omitempty"`
Temperature api.Rates `json:"temperature,omitempty"`
Temperature [][]float64 `json:"temperature,omitempty"`
}{
Co2: tariff.Rates(site.GetTariff(api.TariffUsageCo2)),
FeedIn: tariff.Rates(site.GetTariff(api.TariffUsageFeedIn)),
Planner: tariff.Rates(site.GetTariff(api.TariffUsagePlanner)),
Grid: tariff.Rates(site.GetTariff(api.TariffUsageGrid)),
Temperature: tariff.Rates(site.GetTariff(api.TariffUsageTemperature)),
Co2: forecastRates(tariff.Rates(site.GetTariff(api.TariffUsageCo2))),
FeedIn: forecastRates(tariff.Rates(site.GetTariff(api.TariffUsageFeedIn))),
Planner: forecastRates(tariff.Rates(site.GetTariff(api.TariffUsagePlanner))),
Grid: forecastRates(tariff.Rates(site.GetTariff(api.TariffUsageGrid))),
Temperature: forecastRates(tariff.Rates(site.GetTariff(api.TariffUsageTemperature))),
}
// calculate adjusted solar rates

69
core/site_tariffs_test.go Normal file
View file

@ -0,0 +1,69 @@
package core
import (
"encoding/json"
"testing"
"time"
"github.com/evcc-io/evcc/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestForecastRates(t *testing.T) {
start := time.Unix(1735689600, 0)
for _, tc := range []struct {
desc string
rates api.Rates
want string
}{
{desc: "nil", rates: nil, want: "null"},
{desc: "empty", rates: api.Rates{}, want: "null"},
{
desc: "slots",
rates: api.Rates{
{Start: start, End: start.Add(time.Hour), Value: 0.25},
{Start: start.Add(time.Hour), End: start.Add(2 * time.Hour), Value: -0.1},
},
want: "[[1735689600,1735693200,0.25],[1735693200,1735696800,-0.1]]",
},
} {
t.Run(tc.desc, func(t *testing.T) {
b, err := json.Marshal(forecastRates(tc.rates))
require.NoError(t, err)
assert.Equal(t, tc.want, string(b))
})
}
}
func TestTimeseriesMarshal(t *testing.T) {
start := time.Unix(1735689600, 0)
for _, tc := range []struct {
desc string
ts timeseries
want string
}{
{desc: "nil", ts: nil, want: "null"},
{desc: "empty", ts: timeseries{}, want: "[]"},
{
desc: "entries",
ts: timeseries{
{Timestamp: start, Value: 1000},
{Timestamp: start.Add(time.Hour), Value: 0},
},
want: "[[1735689600,1000],[1735693200,0]]",
},
} {
t.Run(tc.desc, func(t *testing.T) {
b, err := json.Marshal(tc.ts)
require.NoError(t, err)
assert.Equal(t, tc.want, string(b))
b, err = tc.ts.MarshalBytes()
require.NoError(t, err)
assert.Equal(t, tc.want, string(b))
})
}
}

View file

@ -17,6 +17,20 @@ func (ts timeseries) MarshalBytes() ([]byte, error) {
return json.Marshal(ts)
}
// MarshalJSON publishes entries as [ts, val] with the timestamp in unix seconds
func (ts timeseries) MarshalJSON() ([]byte, error) {
if ts == nil {
return []byte("null"), nil
}
res := make([][]float64, 0, len(ts))
for _, e := range ts {
res = append(res, []float64{float64(e.Timestamp.Unix()), e.Value})
}
return json.Marshal(res)
}
type tsEntry struct {
Timestamp time.Time `json:"ts"`
Value float64 `json:"val"`

View file

@ -113,6 +113,13 @@ function normalizeNullables(schema: AnySchema): void {
node.allOf = rest;
}
}
// openapi 3.0 cannot express tuple positions, document tuples as
// fixed-length uniform arrays; positional meaning lives in the description
if (Array.isArray(node.items)) {
const types = [...new Set(node.items.map((item: AnySchema) => item.type))];
if (types.length !== 1) throw new Error(`tuple with mixed item types not supported`);
node.items = { type: types[0] };
}
// openapi 3.0 has no `const`, use a single-value enum
if ("const" in node) {
node.enum = [node.const];

View file

@ -3320,29 +3320,13 @@
]
},
"ForecastSlot": {
"description": "A forecast value for a time slot.",
"type": "object",
"properties": {
"start": {
"description": "Start of the time slot.",
"type": "string",
"format": "date-time"
},
"end": {
"description": "End of the time slot.",
"type": "string",
"format": "date-time"
},
"value": {
"description": "Forecast value of the time slot. Unit depends on the forecast type.",
"type": "number"
}
"description": "A forecast time slot as [start, end, value] array. Start and end are unix seconds. Value unit depends on the forecast type.",
"type": "array",
"items": {
"type": "number"
},
"required": [
"start",
"end",
"value"
]
"minItems": 3,
"maxItems": 3
},
"EnergyByDay": {
"description": "Expected solar production energy of a day.",
@ -3363,23 +3347,13 @@
]
},
"TimeseriesEntry": {
"description": "A forecast value at a point in time.",
"type": "object",
"properties": {
"val": {
"description": "Forecast power in W.",
"type": "number"
},
"ts": {
"description": "Time of the forecast value.",
"type": "string",
"format": "date-time"
}
"description": "A forecast value at a point in time as [ts, val] array. ts is unix seconds, val is power in W.",
"type": "array",
"items": {
"type": "number"
},
"required": [
"val",
"ts"
]
"minItems": 2,
"maxItems": 2
},
"SolarDetails": {
"description": "Solar production forecast.",

View file

@ -630,24 +630,12 @@ components:
items:
$ref: "#/components/schemas/ForecastSlot"
ForecastSlot:
description: A forecast value for a time slot.
type: object
properties:
start:
description: Start of the time slot.
type: string
format: date-time
end:
description: End of the time slot.
type: string
format: date-time
value:
description: Forecast value of the time slot. Unit depends on the forecast type.
type: number
required:
- start
- end
- value
description: A forecast time slot as [start, end, value] array. Start and end are unix seconds. Value unit depends on the forecast type.
type: array
items:
type: number
minItems: 3
maxItems: 3
GenericConfigStatus:
description: Configuration and runtime status of an integration. Structure depends on configuration.
type: object
@ -1767,19 +1755,12 @@ components:
- chargedKWh
- solarPercentage
TimeseriesEntry:
description: A forecast value at a point in time.
type: object
properties:
val:
description: Forecast power in W.
type: number
ts:
description: Time of the forecast value.
type: string
format: date-time
required:
- val
- ts
description: A forecast value at a point in time as [ts, val] array. ts is unix seconds, val is power in W.
type: array
items:
type: number
minItems: 2
maxItems: 2
Vehicle:
description: A configured vehicle.
type: object