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; return this.gridChargePossible || this.gridChargeLimit !== null;
}, },
gridChargeTariff() { gridChargeTariff() {
const { forecast, smartCostType } = this.state; const { co2, grid } = store.uiForecast.value;
return smartCostType === SMART_COST_TYPE.CO2 ? forecast?.co2 : forecast?.grid; return this.state.smartCostType === SMART_COST_TYPE.CO2 ? co2 : grid;
}, },
smartCostLimitProps() { smartCostLimitProps() {
return { return {

View file

@ -53,7 +53,6 @@ import MoreItem from "./MoreItem.vue";
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import type { import type {
FatalError, FatalError,
Forecast,
Sponsor, Sponsor,
EvOpt, EvOpt,
AuthProviders, AuthProviders,
@ -74,7 +73,6 @@ export default defineComponent({
battery: { type: Object as PropType<Battery> }, battery: { type: Object as PropType<Battery> },
batteryGridChargeActive: Boolean, batteryGridChargeActive: Boolean,
batteryMode: { type: String as PropType<string> }, batteryMode: { type: String as PropType<string> },
forecast: { type: Object as PropType<Forecast> },
vehicles: { type: Object as PropType<Record<string, Vehicle>>, default: () => ({}) }, vehicles: { type: Object as PropType<Record<string, Vehicle>>, default: () => ({}) },
authProviders: { type: Object as PropType<AuthProviders>, default: () => ({}) }, authProviders: { type: Object as PropType<AuthProviders>, default: () => ({}) },
sponsor: { type: Object as PropType<Sponsor>, 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 minuteTicker from "@/mixins/minuteTicker";
import { optionStep, fmtEnergy } from "@/utils/energyOptions.ts"; import { optionStep, fmtEnergy } from "@/utils/energyOptions.ts";
import { defineComponent, type PropType } from "vue"; 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({ export default defineComponent({
name: "ChargingPlan", name: "ChargingPlan",
@ -71,7 +71,7 @@ export default defineComponent({
vehicleSoc: Number, vehicleSoc: Number,
vehicleLimitSoc: Number, vehicleLimitSoc: Number,
vehicleNotReachable: Boolean, vehicleNotReachable: Boolean,
forecast: Object as PropType<Forecast>, forecast: Object as PropType<UiForecast>,
}, },
emits: ["open-modal"], emits: ["open-modal"],
data() { data() {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import * as echarts from "echarts/core"; import * as echarts from "echarts/core";
import colors from "@/colors"; import colors from "@/colors";
import escapeHtml from "@/utils/escapeHtml"; import escapeHtml from "@/utils/escapeHtml";
import type { ForecastSlot } from "@/types/evcc"; import type { UiForecastSlot } from "@/types/evcc";
import { BarChart, LineChart } from "echarts/charts"; import { BarChart, LineChart } from "echarts/charts";
import { import {
GridComponent, GridComponent,
@ -31,7 +31,7 @@ export const FONT_FAMILY = "Montserrat, sans-serif";
export function markPointLabel( export function markPointLabel(
color: string, color: string,
data: { coord: [string, number]; value: string; label?: { offset?: [number, number] } }[], data: { coord: [number, number]; value: string; label?: { offset?: [number, number] } }[],
startDate?: Date, startDate?: Date,
endDate?: Date endDate?: Date
) { ) {
@ -249,24 +249,24 @@ export function forecastYAxis(overrides: Record<string, unknown> = {}) {
}; };
} }
export function clampStart(ts: string, startDate: Date): string { export function clampStart(ts: number, startDate: Date): number {
return new Date(ts) < startDate ? startDate.toISOString() : ts; return Math.max(ts, startDate.getTime());
} }
export function filterForecastSlots( export function filterForecastSlots(
slots: ForecastSlot[], slots: UiForecastSlot[],
startDate: Date, startDate: Date,
endDate: Date endDate: Date
): ForecastSlot[] { ): UiForecastSlot[] {
if (!Array.isArray(slots)) return []; if (!Array.isArray(slots)) return [];
return slots.filter((s) => new Date(s.end) > startDate && new Date(s.start) <= endDate); 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); 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); 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, CHARGER_STATUS_REASON,
Timeout, Timeout,
Vehicle, Vehicle,
Forecast, UiForecast,
SMART_COST_TYPE, SMART_COST_TYPE,
BATTERY_MODE, BATTERY_MODE,
LoadpointUi, LoadpointUi,
@ -251,7 +251,7 @@ export default defineComponent({
fullWidth: Boolean, fullWidth: Boolean,
gridConfigured: Boolean, gridConfigured: Boolean,
pvConfigured: Boolean, pvConfigured: Boolean,
forecast: Object as PropType<Forecast>, forecast: Object as PropType<UiForecast>,
lastSmartCostLimit: Number, lastSmartCostLimit: Number,
lastSmartFeedInPriorityLimit: Number, lastSmartFeedInPriorityLimit: Number,
vehicleKnown: Boolean, vehicleKnown: Boolean,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,8 @@
import { reactive } from "vue"; import { computed, reactive } from "vue";
import type { State } from "./types/evcc"; import type { State } from "./types/evcc";
import { convertToUiLoadpoints } from "./uiLoadpoints"; import { convertToUiLoadpoints } from "./uiLoadpoints";
import { useDebouncedComputed } from "./utils/useDebouncedComputed"; import { useDebouncedComputed } from "./utils/useDebouncedComputed";
import { expandForecast } from "./utils/forecast";
import settings from "./settings"; import settings from "./settings";
function setProperty(obj: object, props: string[], value: any) { function setProperty(obj: object, props: string[], value: any) {
@ -43,9 +44,14 @@ const uiLoadpoints = useDebouncedComputed(
50 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 { export interface Store {
state: State; // raw state from websocket state: State; // raw state from websocket
uiLoadpoints: typeof uiLoadpoints; uiLoadpoints: typeof uiLoadpoints;
uiForecast: typeof uiForecast;
offline(value: boolean): void; offline(value: boolean): void;
update(msg: any): void; update(msg: any): void;
reset(): void; reset(): void;
@ -54,6 +60,7 @@ export interface Store {
const store: Store = { const store: Store = {
state, state,
uiLoadpoints, uiLoadpoints,
uiForecast,
offline(value: boolean) { offline(value: boolean) {
state.offline = value; state.offline = value;
}, },

View file

@ -1272,32 +1272,11 @@ export interface Slot {
selectable?: boolean | null; selectable?: boolean | null;
} }
/** A forecast value at a point in time. */ /** A forecast time slot as [start, end, value] array. Start and end are unix seconds. Value unit depends on the forecast type. */
export interface TimeseriesEntry { export type ForecastSlot = [number, number, number];
/** Forecast power in W. */
val: number;
/**
* Time of the forecast value.
* @format date-time
*/
ts: string;
}
/** A forecast value for a time slot. */ /** A forecast value at a point in time as [ts, val] array. ts is unix seconds, val is power in W. */
export interface ForecastSlot { export type TimeseriesEntry = [number, number];
/**
* 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;
}
/** Expected solar production energy of a day. */ /** Expected solar production energy of a day. */
export interface EnergyByDay { export interface EnergyByDay {
@ -1337,6 +1316,33 @@ export interface Forecast {
temperature?: ForecastSlot[]; 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> { export interface SelectOption<T> {
name: string; name: string;
value: T; value: T;

View file

@ -1,56 +1,81 @@
import { describe, expect, test } from "vite-plus/test"; 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", () => { describe("findLowestSumSlotIndex", () => {
test("finds lowest sum with span of 4", () => { test("finds lowest sum with span of 4", () => {
const slots = [ const slots = [
{ start: "2025-01-01T00:00:00Z", value: 10 }, { start: 1735689600000, value: 10 },
{ start: "2025-01-01T00:15:00Z", value: 8 }, { start: 1735690500000, value: 8 },
{ start: "2025-01-01T00:30:00Z", value: 6 }, { start: 1735691400000, value: 6 },
{ start: "2025-01-01T00:45:00Z", value: 4 }, // sum 28 (index 0) { start: 1735692300000, value: 4 }, // sum 28 (index 0)
{ start: "2025-01-01T01:00:00Z", value: 2 }, // sum 20 (index 1) { start: 1735693200000, value: 2 }, // sum 20 (index 1)
{ start: "2025-01-01T01:15:00Z", value: 3 }, // sum 14 (index 2) { start: 1735694100000, value: 3 }, // sum 14 (index 2)
{ start: "2025-01-01T01:30:00Z", value: 5 }, // sum 13 (index 3) lowest { start: 1735695000000, value: 5 }, // sum 13 (index 3) lowest
{ start: "2025-01-01T01:45:00Z", value: 7 }, // sum 17 (index 4) { start: 1735695900000, value: 7 }, // sum 17 (index 4)
]; ];
expect(findLowestSumSlotIndex(slots, 4)).toBe(3); expect(findLowestSumSlotIndex(slots, 4)).toBe(3);
}); });
test("returns -1 when not enough slots", () => { test("returns -1 when not enough slots", () => {
const slots = [ const slots = [
{ start: "2025-01-01T00:00:00Z", value: 10 }, { start: 1735689600000, value: 10 },
{ start: "2025-01-01T00:15:00Z", value: 8 }, { start: 1735690500000, value: 8 },
]; ];
expect(findLowestSumSlotIndex(slots, 4)).toBe(-1); expect(findLowestSumSlotIndex(slots, 4)).toBe(-1);
}); });
test("handles exact span length", () => { test("handles exact span length", () => {
const slots = [ const slots = [
{ start: "2025-01-01T00:00:00Z", value: 5 }, { start: 1735689600000, value: 5 },
{ start: "2025-01-01T00:15:00Z", value: 3 }, { start: 1735690500000, value: 3 },
{ start: "2025-01-01T00:30:00Z", value: 2 }, { start: 1735691400000, value: 2 },
{ start: "2025-01-01T00:45:00Z", value: 1 }, { start: 1735692300000, value: 1 },
]; ];
expect(findLowestSumSlotIndex(slots, 4)).toBe(0); expect(findLowestSumSlotIndex(slots, 4)).toBe(0);
}); });
test("finds lowest at end", () => { test("finds lowest at end", () => {
const slots = [ const slots = [
{ start: "2025-01-01T00:00:00Z", value: 10 }, { start: 1735689600000, value: 10 },
{ start: "2025-01-01T00:15:00Z", value: 10 }, { start: 1735690500000, value: 10 },
{ start: "2025-01-01T00:30:00Z", value: 1 }, { start: 1735691400000, value: 1 },
{ start: "2025-01-01T00:45:00Z", value: 1 }, { start: 1735692300000, value: 1 },
{ start: "2025-01-01T01:00:00Z", value: 1 }, { start: 1735693200000, value: 1 },
]; ];
expect(findLowestSumSlotIndex(slots, 3)).toBe(2); expect(findLowestSumSlotIndex(slots, 3)).toBe(2);
}); });
test("returns first index when multiple equal sums", () => { test("returns first index when multiple equal sums", () => {
const slots = [ const slots = [
{ start: "2025-01-01T00:00:00Z", value: 2 }, { start: 1735689600000, value: 2 },
{ start: "2025-01-01T00:15:00Z", value: 2 }, // sum 4 { start: 1735690500000, value: 2 }, // sum 4
{ start: "2025-01-01T00:30:00Z", value: 2 }, // sum 4 (same) { start: 1735691400000, value: 2 }, // sum 4 (same)
{ start: "2025-01-01T00:45:00Z", value: 2 }, { start: 1735692300000, value: 2 },
]; ];
expect(findLowestSumSlotIndex(slots, 2)).toBe(0); expect(findLowestSumSlotIndex(slots, 2)).toBe(0);
}); });
@ -67,18 +92,18 @@ describe("isStaticTariff", () => {
test("returns true when all values are identical", () => { test("returns true when all values are identical", () => {
const slots = [ const slots = [
{ start: "2025-01-01T00:00:00Z", end: "2025-01-01T00:15:00Z", value: 0.25 }, { start: 1735689600000, end: 1735690500000, value: 0.25 },
{ start: "2025-01-01T00:15:00Z", end: "2025-01-01T00:30:00Z", value: 0.25 }, { start: 1735690500000, end: 1735691400000, value: 0.25 },
{ start: "2025-01-01T00:30:00Z", end: "2025-01-01T00:45:00Z", value: 0.25 }, { start: 1735691400000, end: 1735692300000, value: 0.25 },
]; ];
expect(isStaticTariff(slots)).toBe(true); expect(isStaticTariff(slots)).toBe(true);
}); });
test("returns false when values differ", () => { test("returns false when values differ", () => {
const slots = [ const slots = [
{ start: "2025-01-01T00:00:00Z", end: "2025-01-01T00:15:00Z", value: 0.25 }, { start: 1735689600000, end: 1735690500000, value: 0.25 },
{ start: "2025-01-01T00:15:00Z", end: "2025-01-01T00:30:00Z", value: 0.3 }, { start: 1735690500000, end: 1735691400000, value: 0.3 },
{ start: "2025-01-01T00:30:00Z", end: "2025-01-01T00:45:00Z", value: 0.25 }, { start: 1735691400000, end: 1735692300000, value: 0.25 },
]; ];
expect(isStaticTariff(slots)).toBe(false); 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"; import deepCopy from "./deepClone";
export enum ForecastType { export enum ForecastType {
@ -7,6 +15,34 @@ export enum ForecastType {
Co2 = "co2", 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 // return the date in local YYYY-MM-DD format
function toDayString(date: Date): string { function toDayString(date: Date): string {
const year = date.getFullYear(); 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 // return only slots that are on a given date, ignores slots that are in the past
export function filterEntriesByDate( export function filterEntriesByDate(
entries: TimeseriesEntry[], entries: UiTimeseriesEntry[],
dayString: string dayString: string
): TimeseriesEntry[] { ): UiTimeseriesEntry[] {
const now = new Date(); const now = new Date();
return entries.filter(({ ts }) => { return entries.filter(({ ts }) => {
const isPast = new Date(ts) < now; 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.) // 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 dayString = dayStringByOffset(day);
const dayEntries = filterEntriesByDate(entries, dayString); const dayEntries = filterEntriesByDate(entries, dayString);
const sortedEntries = dayEntries.sort((a, b) => b.val - a.val); 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); 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; if (!solar?.scale) return solar;
const { scale } = solar; const { scale } = solar;
@ -65,14 +101,14 @@ export function adjustedSolar(solar?: SolarDetails): SolarDetails | undefined {
return result; return result;
} }
export function isStaticTariff(slots?: ForecastSlot[]): boolean { export function isStaticTariff(slots?: UiForecastSlot[]): boolean {
if (!Array.isArray(slots) || slots.length === 0) return false; if (!Array.isArray(slots) || slots.length === 0) return false;
const firstValue = slots[0].value; const firstValue = slots[0].value;
return slots.every((slot) => slot.value === firstValue); return slots.every((slot) => slot.value === firstValue);
} }
export interface SlotWithValue { export interface SlotWithValue {
start: string | Date; start: string | number | Date;
value: number; value: number;
} }

View file

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

View file

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

View file

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

View file

@ -10,6 +10,7 @@ import (
"github.com/evcc-io/evcc/tariff" "github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util"
"github.com/jinzhu/now" "github.com/jinzhu/now"
"github.com/samber/lo"
) )
type solarDetails struct { type solarDetails struct {
@ -25,6 +26,20 @@ type dailyDetails struct {
Complete bool `json:"complete"` 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 // greenShare returns
// - the current green share, calculated for the part of the consumption between powerFrom and powerTo // - 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 // 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 { fc := struct {
Co2 api.Rates `json:"co2,omitempty"` Co2 [][]float64 `json:"co2,omitempty"`
FeedIn api.Rates `json:"feedin,omitempty"` FeedIn [][]float64 `json:"feedin,omitempty"`
Grid api.Rates `json:"grid,omitempty"` Grid [][]float64 `json:"grid,omitempty"`
Planner api.Rates `json:"planner,omitempty"` Planner [][]float64 `json:"planner,omitempty"`
Solar *solarDetails `json:"solar,omitempty"` Solar *solarDetails `json:"solar,omitempty"`
Temperature api.Rates `json:"temperature,omitempty"` Temperature [][]float64 `json:"temperature,omitempty"`
}{ }{
Co2: tariff.Rates(site.GetTariff(api.TariffUsageCo2)), Co2: forecastRates(tariff.Rates(site.GetTariff(api.TariffUsageCo2))),
FeedIn: tariff.Rates(site.GetTariff(api.TariffUsageFeedIn)), FeedIn: forecastRates(tariff.Rates(site.GetTariff(api.TariffUsageFeedIn))),
Planner: tariff.Rates(site.GetTariff(api.TariffUsagePlanner)), Planner: forecastRates(tariff.Rates(site.GetTariff(api.TariffUsagePlanner))),
Grid: tariff.Rates(site.GetTariff(api.TariffUsageGrid)), Grid: forecastRates(tariff.Rates(site.GetTariff(api.TariffUsageGrid))),
Temperature: tariff.Rates(site.GetTariff(api.TariffUsageTemperature)), Temperature: forecastRates(tariff.Rates(site.GetTariff(api.TariffUsageTemperature))),
} }
// calculate adjusted solar rates // 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) 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 { type tsEntry struct {
Timestamp time.Time `json:"ts"` Timestamp time.Time `json:"ts"`
Value float64 `json:"val"` Value float64 `json:"val"`

View file

@ -113,6 +113,13 @@ function normalizeNullables(schema: AnySchema): void {
node.allOf = rest; 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 // openapi 3.0 has no `const`, use a single-value enum
if ("const" in node) { if ("const" in node) {
node.enum = [node.const]; node.enum = [node.const];

View file

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

View file

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