API: publish forecast as arrays with unix timestamps (BC) (#32391)
This commit is contained in:
parent
9d0bc5a865
commit
45e781dac0
35 changed files with 361 additions and 223 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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: () => ({}) },
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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(): {
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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> },
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,7 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
forecast() {
|
forecast() {
|
||||||
return store.state?.forecast || {};
|
return store.uiForecast.value;
|
||||||
},
|
},
|
||||||
forecastAvailable() {
|
forecastAvailable() {
|
||||||
return !!(
|
return !!(
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
69
core/site_tariffs_test.go
Normal 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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
50
server/mcp/openapi.json
generated
50
server/mcp/openapi.json
generated
|
|
@ -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.",
|
||||||
|
|
|
||||||
43
server/openapi.state.yaml
generated
43
server/openapi.state.yaml
generated
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue