Integrate external optmizer (experimental) (#22495)

This commit is contained in:
andig 2025-08-17 11:39:11 +02:00 • committed by GitHub
parent 3cb810965a
commit b5a8a2d3c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 2325 additions and 54 deletions

View file

@ -76,13 +76,16 @@ changelog:
groups:
- title: "Breaking Changes 🚨"
regexp: '\(BC\)'
order: 1
order: 10
- title: "Bug Fixes 🐞"
regexp: '^fix:|\bfix\b'
order: 3
order: 30
- title: "New Features 💫"
regexp: '^feat:|\bfeature\b'
order: 2
order: 20
- title: "Experimental Features 🧪"
regexp: '^experimental:|\bexperimental\b'
order: 25
nfpms:
- id: default

View file

@ -186,7 +186,7 @@ This file provides guidance to AI coding agents when working with code in this r
### Essential Commands
- Must build before testing: `make ui build`
- Must build before testing executing playwright `make ui build` since it uses the binary. For manual testing assets are build and reloaded automatically (vite dev).
- Run tests: `npm run playwright` or `npx playwright test`
- Debug: `npx playwright test --debug`
- Specific test: `npx playwright test tests/config-loadpoint.spec.ts`

View file

@ -0,0 +1,182 @@
<template>
<div class="mb-4">
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th scope="col">Battery</th>
<th scope="col">State of Charge</th>
<th scope="col">SoC Range</th>
<th scope="col">Energy Value</th>
<th scope="col">Power Range</th>
<th scope="col">Max Discharge</th>
<th scope="col">Grid Interaction</th>
<th scope="col">Demand Profile</th>
<th scope="col">SoC Goals</th>
</tr>
</thead>
<tbody>
<tr v-for="(battery, index) in batteries" :key="index">
<th scope="row">{{ getBatteryTitle(index) }}</th>
<td>
<div>{{ formatStateOfCharge(battery.s_initial, index) }}</div>
<div class="text-muted small">
{{ formatInitialSocPercentage(battery.s_initial, index) }}
</div>
</td>
<td>
<div>{{ formatEnergyRange(battery.s_min, battery.s_max) }}</div>
<div class="text-muted small">
{{ formatSocRangePercentage(battery.s_min, battery.s_max, index) }}
</div>
</td>
<td>
<div>{{ formatEnergyValue(battery.p_a) }}</div>
<div class="text-muted small">
{{ formatTotalEnergyValue(battery.p_a, index) }}
</div>
</td>
<td>
{{ formatPowerRange(battery.c_min, battery.c_max) }}
</td>
<td>{{ formatPower(battery.d_max) }}</td>
<td>
{{ formatGridInteraction(battery) }}
</td>
<td>
<span v-if="battery.p_demand?.length" class="badge bg-info">
{{ battery.p_demand.length }} steps
</span>
<span v-else class="text-muted">None</span>
</td>
<td>
<span v-if="battery.s_goal?.length" class="badge bg-warning">
{{ battery.s_goal.length }} goals
</span>
<span v-else class="text-muted">None</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import formatter from "@/mixins/formatter";
import type { CURRENCY, BatteryDetail } from "@/types/evcc";
export interface BatteryConfig {
c_min: number;
c_max: number;
d_max: number;
s_min: number;
s_max: number;
s_initial: number;
p_a: number;
charge_from_grid?: boolean;
discharge_to_grid?: boolean;
p_demand?: number[];
s_goal?: number[];
}
export default defineComponent({
name: "BatteryConfigurationTable",
mixins: [formatter],
props: {
batteries: {
type: Array as PropType<BatteryConfig[]>,
required: true,
},
batteryDetails: {
type: Array as PropType<BatteryDetail[]>,
required: true,
},
currency: {
type: String as PropType<CURRENCY>,
required: true,
},
},
methods: {
formatPower(watts: number): string {
return this.fmtW(watts, this.POWER_UNIT.KW, true, 1);
},
formatEnergy(wh: number): string {
return this.fmtWh(wh, this.POWER_UNIT.KW, true, 1);
},
formatPowerRange(min: number, max: number): string {
const minValue = this.fmtW(min, this.POWER_UNIT.KW, false, 1);
const maxValue = this.fmtW(max, this.POWER_UNIT.KW, true, 1);
return `${minValue} – ${maxValue}`;
},
formatEnergyRange(min: number, max: number): string {
const minValue = this.fmtWh(min, this.POWER_UNIT.KW, false, 1);
const maxValue = this.fmtWh(max, this.POWER_UNIT.KW, true, 1);
return `${minValue} – ${maxValue}`;
},
formatEnergyValue(valuePerWh: number): string {
return this.fmtPricePerKWh(valuePerWh * 1000, this.currency, false, true);
},
getBatteryTitle(index: number): string {
const detail = this.batteryDetails[index];
return detail ? detail.title || detail.name : `Battery ${index + 1}`;
},
formatStateOfCharge(initialSocWh: number, index: number): string {
const detail = this.batteryDetails[index];
if (detail?.capacity) {
const initialSocKWh = this.fmtWh(initialSocWh, this.POWER_UNIT.KW, false, 1);
const capacityKWh = this.fmtWh(detail.capacity * 1000, this.POWER_UNIT.KW, true, 1);
return `${initialSocKWh} of ${capacityKWh}`;
}
return "-";
},
formatInitialSocPercentage(initialSocWh: number, index: number): string {
const detail = this.batteryDetails[index];
if (detail?.capacity && detail.capacity > 0) {
const percentage = (initialSocWh / 1000 / detail.capacity) * 100;
return this.fmtPercentage(percentage, 0);
}
return "";
},
formatSocRangePercentage(minSocWh: number, maxSocWh: number, index: number): string {
const detail = this.batteryDetails[index];
if (detail?.capacity && detail.capacity > 0) {
const minPercentage = (minSocWh / 1000 / detail.capacity) * 100;
const maxPercentage = (maxSocWh / 1000 / detail.capacity) * 100;
return `${this.fmtPercentage(minPercentage, 0)} – ${this.fmtPercentage(maxPercentage, 0)}`;
}
return "";
},
formatTotalEnergyValue(valuePerWh: number, index: number): string {
const detail = this.batteryDetails[index];
if (detail?.capacity && detail.capacity > 0) {
const totalValue = valuePerWh * detail.capacity * 1000; // Convert kWh to Wh for calculation
return this.fmtMoney(totalValue, this.currency, true, true);
}
return "";
},
formatGridInteraction(battery: BatteryConfig): string {
const canCharge = battery.charge_from_grid;
const canDischarge = battery.discharge_to_grid;
if (canCharge && canDischarge) {
return "Charge / Discharge";
} else if (canCharge) {
return "Charge";
} else if (canDischarge) {
return "Discharge";
} else {
return "None";
}
},
},
});
</script>
<style scoped>
.table td,
.table th {
font-variant-numeric: tabular-nums;
}
</style>

View file

@ -0,0 +1,298 @@
<template>
<div class="mb-5">
<div class="chart-container my-3">
<Chart
ref="chartRef"
type="bar"
:data="chartData"
:options="chartOptions"
:height="300"
/>
</div>
<LegendList :legends="legends" />
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarController,
BarElement,
LineElement,
PointElement,
Title,
Tooltip,
Legend as ChartLegendPlugin,
type ChartOptions,
type ChartData,
} from "chart.js";
import { Chart } from "vue-chartjs";
import type { EvoptData } from "./TimeSeriesDataTable.vue";
import type { CURRENCY, BatteryDetail } from "@/types/evcc";
import formatter from "@/mixins/formatter";
import colors from "@/colors";
import LegendList from "../Sessions/LegendList.vue";
import type { Legend } from "../Sessions/types";
ChartJS.register(
CategoryScale,
LinearScale,
BarController,
BarElement,
LineElement,
PointElement,
Title,
Tooltip,
ChartLegendPlugin
);
export default defineComponent({
name: "ChargeChart",
components: {
Chart,
LegendList,
},
mixins: [formatter],
props: {
evopt: {
type: Object as PropType<EvoptData>,
required: true,
},
batteryDetails: {
type: Array as PropType<BatteryDetail[]>,
required: true,
},
timestamp: {
type: String,
required: true,
},
currency: {
type: String as PropType<CURRENCY>,
required: true,
},
batteryColors: {
type: Array as PropType<string[]>,
required: true,
},
},
computed: {
timeLabels(): string[] {
const startTime = new Date(this.timestamp);
return this.evopt.req.time_series.dt.map((_, index) => {
const currentTime = new Date(startTime.getTime() + index * 60 * 60 * 1000); // Add hours
return currentTime.getHours().toString();
});
},
chartData(): ChartData {
const datasets: any[] = [];
// 1. Solar Forecast (first, with increased tension)
datasets.push(...this.getSolarDatasets());
// 2. Battery power data
datasets.push(...this.getBatteryPowerDatasets());
// 3. Household Demand (power)
datasets.push(...this.getHouseholdDatasets());
return {
labels: this.timeLabels,
datasets: datasets,
};
},
chartOptions(): ChartOptions {
return {
responsive: true,
maintainAspectRatio: false,
color: colors.text || "",
animation: false,
interaction: {
mode: "index",
intersect: false,
},
elements: {
point: {
radius: 0, // Hide points by default
hoverRadius: 6, // Show points on hover
},
},
plugins: {
title: { display: false },
legend: { display: false },
tooltip: {
backgroundColor: "#000000cc",
boxPadding: 5,
usePointStyle: false,
borderWidth: 0.00001,
mode: "index",
intersect: false,
callbacks: {
label: (context) => {
const label = context.dataset.label || "";
const value = context.parsed.y;
// Power axis (kW)
return `${label}: ${this.formatValue(value)} kW`;
},
},
},
datalabels: {
display: false,
},
},
scales: {
x: {
title: {
display: false,
},
stacked: true,
grid: {
display: false,
},
},
y: {
type: "linear",
position: "left",
title: {
display: true,
text: "Power (kW)",
},
stacked: true,
grid: {
drawOnChartArea: true,
color: (context: any) => {
// Make zero axis black to highlight
if (context.tick?.value === 0) {
return "#000000";
}
return colors.border || "#e0e0e0";
},
lineWidth: (context: any) => {
// Make zero axis slightly thicker
if (context.tick?.value === 0) {
return 2;
}
return 1;
},
},
// Keep scales purely based on values, no fixed boundaries
},
},
};
},
legends(): Legend[] {
return this.chartData.datasets
.filter((dataset) => !dataset.hidden)
.map((dataset) => {
const label = dataset.label || "";
const isLine = dataset.type === "line";
return {
label,
color: (dataset.backgroundColor || dataset.borderColor) as string,
value: "", // Required by Legend type, but not used in this context
type: isLine ? "line" : "area",
};
});
},
},
methods: {
getSolarDatasets() {
return [
{
label: "Solar Forecast",
data: this.evopt.req.time_series.ft.map(this.convertWToKW),
borderColor: colors.self,
backgroundColor: colors.self,
fill: false,
tension: 0.25,
borderJoinStyle: "round",
borderCapStyle: "round",
pointRadius: 0,
pointHoverRadius: 6,
borderWidth: 3,
yAxisID: "y",
type: "line" as const,
},
];
},
getBatteryPowerDatasets() {
const datasets: any[] = [];
if (this.evopt.res.batteries?.length > 0) {
this.evopt.res.batteries.forEach((battery, index) => {
// Use passed battery colors (same as SoC)
const baseColor = this.batteryColors[index];
// Combined charging/discharging power as one line (same color as SoC)
// Charging = positive, Discharging = negative
const combinedPower = battery.charging_power.map((chargingPower, timeIndex) => {
const dischargingPower = battery.discharging_power[timeIndex] || 0;
const chargingKW = this.convertWToKW(chargingPower);
const dischargingKW = this.convertWToKW(dischargingPower);
// Return charging as positive, discharging as negative
// One should be zero, the other should have the value
return chargingKW > 0 ? chargingKW : -dischargingKW;
});
datasets.push({
label: `${this.getBatteryTitle(index)} Power`,
data: combinedPower,
backgroundColor: baseColor,
borderWidth: 0,
yAxisID: "y",
type: "bar" as const,
stack: "power",
});
});
}
return datasets;
},
getHouseholdDatasets() {
// Household Demand (as power, not energy like in SocChart)
const householdPower = this.evopt.req.time_series.gt.map(this.convertWToKW);
// Use the next color in the palette after all battery colors
const batteryCount = this.batteryColors.length;
const householdColor = colors.palette[batteryCount % colors.palette.length];
return [
{
label: "Household Demand",
data: householdPower,
backgroundColor: householdColor,
borderWidth: 0,
yAxisID: "y",
type: "bar" as const,
stack: "power",
},
];
},
convertWToKW: (watts: number): number => {
return watts / 1000;
},
formatValue: (value: number): string => {
return value.toFixed(2);
},
getBatteryTitle(index: number): string {
const detail = this.batteryDetails[index];
return detail ? detail.title || detail.name : `Battery ${index + 1}`;
},
},
});
</script>
<style scoped>
.chart-container {
position: relative;
height: 300px;
width: 100%;
}
</style>

View file

@ -0,0 +1,231 @@
<template>
<div class="mb-5">
<div class="chart-container my-3">
<Chart
ref="chartRef"
type="line"
:data="chartData"
:options="chartOptions"
:height="150"
/>
</div>
<LegendList :legends="legends" />
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
LineElement,
PointElement,
Title,
Tooltip,
Legend as ChartLegendPlugin,
type ChartOptions,
type ChartData,
} from "chart.js";
import { Chart } from "vue-chartjs";
import type { EvoptData } from "./TimeSeriesDataTable.vue";
import type { CURRENCY } from "@/types/evcc";
import formatter from "@/mixins/formatter";
import colors from "@/colors";
import LegendList from "../Sessions/LegendList.vue";
import type { Legend } from "../Sessions/types";
const tension = 0;
ChartJS.register(
CategoryScale,
LinearScale,
LineElement,
PointElement,
Title,
Tooltip,
ChartLegendPlugin
);
export default defineComponent({
name: "PriceChart",
components: {
Chart,
LegendList,
},
mixins: [formatter],
props: {
evopt: {
type: Object as PropType<EvoptData>,
required: true,
},
timestamp: {
type: String,
required: true,
},
currency: {
type: String as PropType<CURRENCY>,
required: true,
},
},
computed: {
timeLabels(): string[] {
const startTime = new Date(this.timestamp);
return this.evopt.req.time_series.dt.map((_, index) => {
const currentTime = new Date(startTime.getTime() + index * 60 * 60 * 1000); // Add hours
return currentTime.getHours().toString();
});
},
chartData(): ChartData {
const datasets: any[] = [];
// Price data only
datasets.push(...this.getPriceDatasets());
return {
labels: this.timeLabels,
datasets: datasets,
};
},
chartOptions(): ChartOptions {
return {
responsive: true,
maintainAspectRatio: false,
color: colors.text || "",
animation: false,
interaction: {
mode: "index",
intersect: false,
},
elements: {
point: {
radius: 0, // Hide points by default
hoverRadius: 6, // Show points on hover
},
},
plugins: {
title: { display: false },
legend: { display: false },
tooltip: {
backgroundColor: "#000000cc",
boxPadding: 5,
usePointStyle: false,
borderWidth: 0.00001,
mode: "index",
intersect: false,
callbacks: {
label: (context) => {
const label = context.dataset.label || "";
const value = context.parsed.y;
// Price axis (currency/kWh)
return `${label}: ${this.formatPrice(value)}`;
},
},
},
datalabels: {
display: false,
},
},
scales: {
x: {
title: {
display: false,
},
grid: {
display: false,
},
},
y: {
type: "linear",
position: "left",
title: {
display: true,
text: `Price (${this.pricePerKWhUnit(this.currency, false)})`,
},
grid: {
drawOnChartArea: true,
},
// Keep scales purely based on values, no fixed boundaries
},
},
};
},
legends(): Legend[] {
return this.chartData.datasets
.filter((dataset) => !dataset.hidden)
.map((dataset) => {
const label = dataset.label || "";
return {
label,
color: (dataset.backgroundColor || dataset.borderColor) as string,
value: "", // Required by Legend type, but not used in this context
type: "line",
};
});
},
},
methods: {
getPriceDatasets() {
const datasets: any[] = [];
// Convert prices from raw format (€/Wh) to proper format (€/kWh) - same as table
const convertPrice = (price: number): number => price * 1000;
// Grid Import Price (solid line, price color)
datasets.push({
label: "Import",
data: this.evopt.req.time_series.p_N.map(convertPrice),
borderColor: colors.grid,
backgroundColor: colors.grid,
fill: false,
tension,
stepped: true,
borderJoinStyle: "round",
borderCapStyle: "round",
pointRadius: 0,
pointHoverRadius: 6,
borderWidth: 2,
yAxisID: "y",
type: "line" as const,
});
// Grid Export Price (solid line, price color)
datasets.push({
label: "Export",
data: this.evopt.req.time_series.p_E.map(convertPrice),
borderColor: colors.price,
backgroundColor: colors.price,
fill: false,
tension,
stepped: true,
borderJoinStyle: "round",
borderCapStyle: "round",
pointRadius: 0,
pointHoverRadius: 6,
borderWidth: 2,
yAxisID: "y",
type: "line" as const,
});
return datasets;
},
formatPrice(price: number): string {
// Use the exact same logic as the data table: fmtPricePerKWh(value * 1000, currency, false, false)
// But since we already multiplied by 1000 in the dataset, we use the value directly
// and add the unit manually to match the tooltip format
const formattedValue = this.fmtPricePerKWh(price, this.currency, false, false);
const unit = this.pricePerKWhUnit(this.currency, false);
return `${formattedValue} ${unit}`;
},
},
});
</script>
<style scoped>
.chart-container {
position: relative;
height: 150px;
width: 100%;
}
</style>

View file

@ -0,0 +1,221 @@
<template>
<div class="mb-5">
<div class="chart-container my-3">
<Chart
ref="chartRef"
type="bar"
:data="chartData"
:options="chartOptions"
:height="150"
/>
</div>
<LegendList :legends="legends" />
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend as ChartLegendPlugin,
type ChartOptions,
type ChartData,
} from "chart.js";
import { Chart } from "vue-chartjs";
import type { EvoptData } from "./TimeSeriesDataTable.vue";
import type { CURRENCY, BatteryDetail } from "@/types/evcc";
import formatter from "@/mixins/formatter";
import colors from "@/colors";
import LegendList from "../Sessions/LegendList.vue";
import type { Legend } from "../Sessions/types";
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, ChartLegendPlugin);
export default defineComponent({
name: "SocChart",
components: {
Chart,
LegendList,
},
mixins: [formatter],
props: {
evopt: {
type: Object as PropType<EvoptData>,
required: true,
},
batteryDetails: {
type: Array as PropType<BatteryDetail[]>,
required: true,
},
timestamp: {
type: String,
required: true,
},
currency: {
type: String as PropType<CURRENCY>,
required: true,
},
batteryColors: {
type: Array as PropType<string[]>,
required: true,
},
},
computed: {
timeLabels(): string[] {
const startTime = new Date(this.timestamp);
return this.evopt.req.time_series.dt.map((_, index) => {
const currentTime = new Date(startTime.getTime() + index * 60 * 60 * 1000); // Add hours
return currentTime.getHours().toString();
});
},
chartData(): ChartData {
const datasets: any[] = [];
// 1. Battery SoC data
datasets.push(...this.getBatterySocDatasets());
return {
labels: this.timeLabels,
datasets: datasets,
};
},
chartOptions(): ChartOptions {
return {
responsive: true,
maintainAspectRatio: false,
color: colors.text || "",
animation: false,
interaction: {
mode: "index",
intersect: false,
},
plugins: {
title: { display: false },
legend: { display: false },
tooltip: {
backgroundColor: "#000000cc",
boxPadding: 5,
usePointStyle: false,
borderWidth: 0.00001,
mode: "index",
intersect: false,
callbacks: {
label: (context) => {
const label = context.dataset.label || "";
const value = context.parsed.y;
// Percentage axis (%)
return `${label}: ${this.formatValue(value)}%`;
},
},
},
datalabels: {
display: false,
},
},
scales: {
x: {
title: {
display: false,
},
grid: {
display: false,
},
},
y: {
type: "linear",
position: "left",
title: {
display: true,
text: "SoC (%)",
},
grid: {
drawOnChartArea: true,
color: (context: any) => {
// Make zero axis black to highlight
if (context.tick?.value === 0) {
return "#000000";
}
return colors.border || "#e0e0e0";
},
lineWidth: (context: any) => {
// Make zero axis slightly thicker
if (context.tick?.value === 0) {
return 2;
}
return 1;
},
},
// Remove fixed minimum to allow negative values
},
},
};
},
legends(): Legend[] {
return this.chartData.datasets
.filter((dataset) => !dataset.hidden)
.map((dataset) => {
const label = dataset.label || "";
return {
label,
color: (dataset.backgroundColor || dataset.borderColor) as string,
value: "", // Required by Legend type, but not used in this context
type: "area",
};
});
},
},
methods: {
getBatterySocDatasets() {
const datasets: any[] = [];
if (this.evopt.res.batteries?.length > 0) {
this.evopt.res.batteries.forEach((battery, index) => {
// Use passed battery colors
const baseColor = this.batteryColors[index];
// SoC as bars (full color)
datasets.push({
label: this.getBatteryTitle(index),
data: battery.state_of_charge.map((socWh) =>
this.convertWhToPercentage(socWh, index)
),
backgroundColor: baseColor,
borderWidth: 0,
yAxisID: "y",
type: "bar" as const,
});
});
}
return datasets;
},
convertWhToPercentage(wh: number, batteryIndex: number): number {
const detail = this.batteryDetails[batteryIndex];
if (detail?.capacity && detail.capacity > 0) {
return (wh / 1000 / detail.capacity) * 100;
}
return 0;
},
formatValue: (value: number): string => {
return value.toFixed(2);
},
getBatteryTitle(index: number): string {
const detail = this.batteryDetails[index];
return detail ? detail.title || detail.name : `Battery ${index + 1}`;
},
},
});
</script>
<style scoped>
.chart-container {
position: relative;
height: 150px;
width: 100%;
}
</style>

View file

@ -0,0 +1,294 @@
<template>
<div class="mb-4">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th scope="col" class="text-nowrap">Data Series</th>
<th
v-for="(_, index) in timeSlots"
:key="index"
scope="col"
:class="['text-end']"
>
{{ formatHour(index) }}
</th>
</tr>
</thead>
<tbody>
<!-- Request Data -->
<tr class="table-secondary">
<td colspan="100%" class="fw-bold text-start">Request Data</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">Solar Forecast (kW)</td>
<td
v-for="(value, index) in evopt.req.time_series.ft"
:key="index"
:class="['text-end', { 'text-muted': value === 0 }]"
>
{{ formatPower(value) }}
</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">
{{ gridFeedinPriceLabel }}
</td>
<td
v-for="(value, index) in evopt.req.time_series.p_E"
:key="index"
:class="['text-end', { 'text-muted': value === 0 }]"
>
{{ fmtPricePerKWh(value * 1000, currency, false, false) }}
</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">
{{ gridImportPriceLabel }}
</td>
<td
v-for="(value, index) in evopt.req.time_series.p_N"
:key="index"
:class="['text-end', { 'text-muted': value === 0 }]"
>
{{ fmtPricePerKWh(value * 1000, currency, false, false) }}
</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">Household Demand (kW)</td>
<td
v-for="(value, index) in evopt.req.time_series.gt"
:key="index"
:class="['text-end', { 'text-muted': value === 0 }]"
>
{{ formatPower(value) }}
</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">Time Step Duration (h)</td>
<td
v-for="(value, index) in evopt.req.time_series.dt"
:key="index"
:class="['text-end']"
>
{{ formatDuration(value) }}
</td>
</tr>
<!-- Response Data -->
<tr class="table-secondary">
<td colspan="100%" class="fw-bold text-start">Response Data</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">Grid Export (kW)</td>
<td
v-for="(value, index) in evopt.res.grid_export"
:key="index"
:class="['text-end', { 'text-muted': value === 0 }]"
>
{{ formatPower(value) }}
</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">Grid Import (kW)</td>
<td
v-for="(value, index) in evopt.res.grid_import"
:key="index"
:class="['text-end', { 'text-muted': value === 0 }]"
>
{{ formatPower(value) }}
</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">⬇ Import / ⬆ Export</td>
<td
v-for="(value, index) in evopt.res.flow_direction"
:key="index"
:class="['text-end']"
>
<span :title="value === 1 ? 'Export to Grid' : 'Import from Grid'">
{{ value === 1 ? "⬆" : "⬇" }}
</span>
</td>
</tr>
<!-- Battery Response Data -->
<template
v-for="(battery, batteryIndex) in evopt.res.batteries"
:key="batteryIndex"
>
<tr>
<td colspan="100%" class="fw-bold text-start">
<div class="d-flex align-items-center">
<span
class="battery-indicator me-2"
:style="{ backgroundColor: batteryColors[batteryIndex] }"
></span>
{{ getBatteryTitle(batteryIndex) }} Response
</div>
</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">Charging Power (kW)</td>
<td
v-for="(value, index) in battery.charging_power"
:key="index"
:class="['text-end', { 'text-muted': value === 0 }]"
>
{{ formatPower(value) }}
</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">Discharging Power (kW)</td>
<td
v-for="(value, index) in battery.discharging_power"
:key="index"
:class="['text-end', { 'text-muted': value === 0 }]"
>
{{ formatPower(value) }}
</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">State of Charge (kWh)</td>
<td
v-for="(value, index) in battery.state_of_charge"
:key="index"
:class="['text-end', { 'text-muted': value === 0 }]"
>
{{ formatEnergy(value) }}
</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">State of Charge (%)</td>
<td
v-for="(value, index) in battery.state_of_charge"
:key="index"
:class="['text-end', { 'text-muted': value === 0 }]"
>
{{ formatSocPercentage(value, batteryIndex) }}
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import formatter from "@/mixins/formatter";
import type { CURRENCY, BatteryDetail } from "@/types/evcc";
export interface EvoptData {
req: {
time_series: {
ft: number[];
p_E: number[];
p_N: number[];
gt: number[];
dt: number[];
};
};
res: {
grid_export: number[];
grid_import: number[];
flow_direction: number[];
batteries: Array<{
charging_power: number[];
discharging_power: number[];
state_of_charge: number[];
}>;
};
}
export default defineComponent({
name: "TimeSeriesDataTable",
mixins: [formatter],
props: {
evopt: {
type: Object as PropType<EvoptData>,
required: true,
},
batteryDetails: {
type: Array as PropType<BatteryDetail[]>,
required: true,
},
timestamp: {
type: String,
required: true,
},
currency: {
type: String as PropType<CURRENCY>,
required: true,
},
batteryColors: {
type: Array as PropType<string[]>,
required: true,
},
dimmedBatteryColors: {
type: Array as PropType<string[]>,
required: true,
},
},
computed: {
timeSlots() {
// Use the dt array length to determine number of time slots
return this.evopt?.req.time_series.dt || [];
},
gridFeedinPriceLabel() {
return `Grid Feedin (${this.pricePerKWhUnit(this.currency)})`;
},
gridImportPriceLabel() {
return `Grid Import (${this.pricePerKWhUnit(this.currency)})`;
},
},
methods: {
formatPower(watts: number): string {
return this.fmtW(watts, this.POWER_UNIT.KW, false, 1);
},
formatEnergy(wh: number): string {
return this.fmtWh(wh, this.POWER_UNIT.KW, false, 1);
},
formatDuration: (seconds: number): string => {
return (seconds / 3600).toFixed(1);
},
formatHour(index: number): string {
const startTime = new Date(this.timestamp);
const currentTime = new Date(startTime.getTime() + index * 60 * 60 * 1000); // Add hours
return currentTime.getHours().toString();
},
getBatteryTitle(index: number): string {
const detail = this.batteryDetails[index];
return detail ? detail.title || detail.name : `Battery ${index + 1}`;
},
formatSocPercentage(socWh: number, batteryIndex: number): string {
const detail = this.batteryDetails[batteryIndex];
if (detail?.capacity && detail.capacity > 0) {
const percentage = (socWh / 1000 / detail.capacity) * 100;
return this.fmtNumber(percentage, 0);
}
return "-";
},
},
});
</script>
<style scoped>
.table td,
.table th {
font-variant-numeric: tabular-nums;
}
.table td:not(:first-child) {
padding-left: 1rem;
}
.battery-indicator {
width: 1rem;
height: 1rem;
border-radius: 50%;
flex-shrink: 0;
}
</style>

View file

@ -196,6 +196,7 @@ export default defineComponent({
legends() {
return this.chartData.datasets.map((dataset) => {
let value = null;
let type: "area" | "line" = "area";
// line chart handling
if ((dataset as any).type === "line") {
@ -210,6 +211,7 @@ export default defineComponent({
: this.fmtGrams(value, false);
};
value = `${format(min, false)} – ${format(max, true)}`;
type = "line";
} else {
const total = dataset.data.reduce((acc, curr) => acc + curr, 0);
value =
@ -221,6 +223,7 @@ export default defineComponent({
label: dataset.label || "",
color: dataset.backgroundColor,
value,
type,
};
});
},

View file

@ -14,7 +14,11 @@
<div
v-if="legend.color"
class="legend-color align-self-center me-1"
:style="{ backgroundColor: legend.color }"
:class="colorClass(legend)"
:style="{
backgroundColor: legend.color,
borderColor: legend.color,
}"
></div>
<div class="legend-label text-nowrap">{{ legend.label }}</div>
<div
@ -44,6 +48,9 @@ export default defineComponent({
if (!value) return [];
return Array.isArray(value) ? value : [value];
},
colorClass(legend: Legend) {
return legend.type === "line" ? "legend-color--line" : "legend-color--area";
},
},
});
</script>
@ -56,8 +63,18 @@ export default defineComponent({
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.legend-color--area {
border-radius: 50%;
}
.legend-color--line {
height: 2px;
border-radius: 1px;
align-self: center;
}
.legend-label {
flex-shrink: 0;
flex-grow: 0;

View file

@ -20,6 +20,7 @@ export interface Legend {
label: string;
color: any;
value: string | string[];
type?: "area" | "line";
}
export interface Column {

View file

@ -93,6 +93,7 @@ import type {
SMART_COST_TYPE,
Sponsor,
FatalError,
EvOpt,
} from "@/types/evcc";
import type { Grid } from "./types";
@ -156,6 +157,7 @@ export default defineComponent({
fatal: { type: Array as PropType<FatalError[]>, default: () => [] },
forecast: Object as PropType<Forecast>,
telemetry: Boolean,
evopt: { type: Object as PropType<EvOpt> },
},
computed: {
batteryConfigured() {

View file

@ -73,6 +73,12 @@
{{ $t("log.title") }}
</router-link>
</li>
<li v-if="optimizeAvailable">
<router-link class="dropdown-item" to="/optimize" active-class="active">
Optimize 🧪
</router-link>
</li>
<li><hr class="dropdown-divider" /></li>
<template v-if="providerLogins.length > 0">
<li>
@ -134,7 +140,7 @@ import baseAPI from "./baseapi";
import { isApp, sendToApp } from "@/utils/native";
import { isUserConfigError } from "@/utils/fatal";
import { defineComponent, type PropType } from "vue";
import type { FatalError, Sponsor, AuthProviders } from "@/types/evcc";
import type { FatalError, Sponsor, AuthProviders, EvOpt } from "@/types/evcc";
import type { Provider as Provider } from "./types";
export default defineComponent({
@ -145,6 +151,7 @@ export default defineComponent({
sponsor: { type: Object as PropType<Sponsor>, default: () => ({}) },
forecast: Object,
battery: Array,
evopt: { type: Object as PropType<EvOpt>, required: false },
fatal: { type: Array as PropType<FatalError[]>, default: () => [] },
},
data() {
@ -188,6 +195,9 @@ export default defineComponent({
const { grid, solar, co2 } = this.forecast || {};
return grid || solar || co2;
},
optimizeAvailable() {
return !!this.evopt && this.$hiddenFeatures();
},
showLogout() {
return isLoggedIn();
},

View file

@ -77,6 +77,11 @@ export default function setupRouter(i18n: VueI18nInstance) {
component: () => import("./views/Energy.vue"),
props: true,
},
{
path: "/optimize",
component: () => import("./views/Optimize.vue"),
props: true,
},
{
path: "/log",
component: () => import("./views/Log.vue"),

View file

@ -51,6 +51,7 @@ export interface State {
currency?: CURRENCY;
fatal?: FatalError[];
authProviders?: AuthProviders;
evopt?: EvOpt;
version?: string;
battery?: Battery[];
tariffGrid?: number;
@ -337,3 +338,94 @@ export interface SiteConfig {
}
export type ValueOf<T> = T[keyof T];
// EvOpt interfaces matching OpenAPI spec exactly
export interface EvOpt {
req: OptimizationInput;
res: OptimizationResult;
details: OptimizationDetails;
}
// Request payload for /optimize/charge-schedule
export interface OptimizationInput {
batteries: BatteryConfig[]; // Battery configurations
time_series: TimeSeries; // Time series data
eta_c?: number; // Charging efficiency (0-1), default 0.95
eta_d?: number; // Discharging efficiency (0-1), default 0.95
M?: number; // Big M value for MILP constraints
}
// Battery configuration
export interface BatteryConfig {
s_min: number; // Min state of charge (Wh)
s_max: number; // Max state of charge (Wh)
s_initial: number; // Initial state of charge (Wh)
c_min: number; // Min charge power (W)
c_max: number; // Max charge power (W)
d_max: number; // Max discharge power (W)
p_a: number; // Energy value per Wh at end
charge_from_grid?: boolean; // Can charge from grid
discharge_to_grid?: boolean; // Can discharge to grid
p_demand?: number[]; // Min charge demand per step (Wh)
s_goal?: number[]; // Goal state of charge per step (Wh)
}
// Time series data
export interface TimeSeries {
dt: number[]; // Duration per time step (seconds)
gt: number[]; // Household demand per step (Wh)
ft: number[]; // Energy generation forecast per step (Wh)
p_N: number[]; // Grid import price per step (currency/Wh)
p_E: number[]; // Grid export price per step (currency/Wh)
}
// Solver status enum
export enum OptimizationStatus {
OPTIMAL = "Optimal",
INFEASIBLE = "Infeasible",
UNBOUNDED = "Unbounded",
UNDEFINED = "Undefined",
NOT_SOLVED = "Not Solved",
}
// Flow direction enum
export enum FlowDirection {
IMPORT = 0, // Import from grid
EXPORT = 1, // Export to grid
}
// Response from /optimize/charge-schedule
export interface OptimizationResult {
status: OptimizationStatus; // Solver status
objective_value: number | null; // Economic benefit (null if not optimal)
batteries: BatteryResult[]; // Results per battery
grid_import: number[]; // Grid import per step (Wh)
grid_export: number[]; // Grid export per step (Wh)
flow_direction: FlowDirection[]; // Flow direction per step (0=import, 1=export)
}
// Battery optimization results
export interface BatteryResult {
charging_power: number[]; // Charging energy per step (Wh)
discharging_power: number[]; // Discharging energy per step (Wh)
state_of_charge: number[]; // State of charge per step (Wh)
}
// Battery detail information for optimization
export interface BatteryDetail {
type: "vehicle" | "battery"; // Type of battery
title: string; // Display title
name: string; // Internal name/identifier
capacity: number; // Battery capacity (kWh)
}
// Optimization details with timestamps and battery information
export interface OptimizationDetails {
timestamp: string[]; // Array of ISO timestamp strings
batteryDetails: BatteryDetail[]; // Array of battery detail objects
}
// Error response
export interface Error {
message: string; // Error description
}

View file

@ -0,0 +1,173 @@
<template>
<div class="container px-4 safe-area-inset">
<TopHeader title="Optimize" />
<div class="alert alert-light mb-5">
This page is for development purposes only. Gives insights into the upcoming
optimization algorithm.
</div>
<div class="row">
<main class="col-12">
<div v-if="evopt">
<!-- Optimizer Plan -->
<section class="mb-5">
<h3
class="fw-normal d-flex gap-3 flex-wrap d-flex align-items-baseline overflow-hidden mb-4"
>
<span class="d-block no-wrap text-truncate">Result: Charging Plan</span>
<small class="d-block no-wrap text-truncate">
{{ evopt.res.status }} ・
{{ fmtMoney(evopt.res.objective_value || 0, currency, true, true) }}
saved
</small>
</h3>
<ChargeChart
:evopt="evopt"
:battery-details="evopt.details.batteryDetails"
:timestamp="evopt.details.timestamp[0]"
:currency="currency"
:battery-colors="batteryColors"
/>
<h3 class="fw-normal mb-4">Result: SoC Projection</h3>
<SocChart
:evopt="evopt"
:battery-details="evopt.details.batteryDetails"
:timestamp="evopt.details.timestamp[0]"
:currency="currency"
:battery-colors="batteryColors"
/>
</section>
<!-- Input Parameters -->
<section class="mb-5">
<h3 class="fw-normal mb-4">Input: Grid Prices</h3>
<PriceChart
:evopt="evopt"
:timestamp="evopt.details.timestamp[0]"
:currency="currency"
/>
<h3
class="fw-normal d-flex gap-3 flex-wrap d-flex align-items-baseline overflow-hidden mb-4"
>
<span class="d-block no-wrap text-truncate"> Input: Battery </span>
<small class="d-block no-wrap text-truncate">
{{ fmtPercentage((evopt.req.eta_c || 1) * 100, 1) }} charge
efficiency ・
{{ fmtPercentage((evopt.req.eta_d || 1) * 100, 1) }} discharge
efficiency
</small>
</h3>
<BatteryConfigurationTable
:batteries="evopt.req.batteries"
:battery-details="evopt.details.batteryDetails"
:currency="currency"
/>
</section>
<hr class="my-5" />
<!-- Debugging -->
<section class="mb-5">
<h3 class="fw-normal mb-4">Time Series</h3>
<TimeSeriesDataTable
:evopt="evopt"
:battery-details="evopt.details.batteryDetails"
:timestamp="evopt.details.timestamp[0]"
:currency="currency"
:battery-colors="batteryColors"
:dimmed-battery-colors="dimmedBatteryColors"
/>
<details class="mb-4">
<summary class="btn btn-link text-decoration-none p-0 mb-3">
<h3 class="fw-normal text-muted text-decoration-underline">
Show Raw Data
</h3>
</summary>
<div>
<p>Request:</p>
<pre>{{ JSON.stringify(evopt.req, null, 2) }}</pre>
<p>Response:</p>
<pre>{{ JSON.stringify(evopt.res, null, 2) }}</pre>
</div>
</details>
</section>
</div>
<div v-else>
<p>nothing to see here</p>
</div>
</main>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import Header from "../components/Top/Header.vue";
import BatteryConfigurationTable from "../components/Optimize/BatteryConfigurationTable.vue";
import SocChart from "../components/Optimize/SocChart.vue";
import ChargeChart from "../components/Optimize/ChargeChart.vue";
import PriceChart from "../components/Optimize/PriceChart.vue";
import TimeSeriesDataTable from "../components/Optimize/TimeSeriesDataTable.vue";
import store from "../store";
import formatter from "../mixins/formatter";
import colors from "../colors";
import { CURRENCY } from "../types/evcc";
export default defineComponent({
name: "Optimize",
components: {
TopHeader: Header,
BatteryConfigurationTable,
SocChart,
ChargeChart,
PriceChart,
TimeSeriesDataTable,
},
mixins: [formatter],
head() {
return { title: this.$t("energy.title") };
},
computed: {
evopt() {
return store.state.evopt;
},
currency() {
return store.state.currency || CURRENCY.EUR;
},
statusBadgeClass() {
if (!this.evopt?.res.status) return "bg-secondary";
switch (this.evopt.res.status) {
case "Optimal":
return "bg-success";
case "Infeasible":
return "bg-danger";
case "Unbounded":
return "bg-warning";
default:
return "bg-secondary";
}
},
batteryColors() {
if (!this.evopt?.res.batteries) return [];
return this.evopt.res.batteries.map(
(_, index) => colors.palette[index % colors.palette.length]
);
},
dimmedBatteryColors() {
return this.batteryColors.map((color) => this.dimColorBy25Percent(color));
},
},
methods: {
dimColorBy25Percent(color: string): string {
// Convert color to 25% opacity (40 in hex = 25% of 255)
return color?.toLowerCase().replace(/ff$/, "40") || color;
},
},
});
</script>

View file

@ -161,11 +161,11 @@ type Loadpoint struct {
wakeUpTimer *Timer // Vehicle wake-up timeout
// charge progress
vehicleSoc float64 // Vehicle Soc
vehicleSoc float64 // Vehicle or charger soc
chargeDuration time.Duration // Charge duration
energyMetrics EnergyMetrics // Stats for charged energy by session
chargeRemainingDuration time.Duration // Remaining charge duration
chargeRemainingEnergy float64 // Remaining charge energy in Wh
chargeRemainingEnergy float64 // Remaining charge energy in kWh
progress *Progress // Step-wise progress indicator
// session log
@ -1738,7 +1738,7 @@ func (lp *Loadpoint) publishSocAndRange() {
}
lp.SetRemainingDuration(d)
lp.SetRemainingEnergy(1e3 * socEstimator.RemainingChargeEnergy(limitSoc))
lp.SetRemainingEnergy(socEstimator.RemainingChargeEnergy(limitSoc))
// range
if vs, ok := lp.GetVehicle().(api.VehicleRange); ok {

View file

@ -197,7 +197,7 @@ type API interface {
IsFastChargingActive() bool
// GetRemainingDuration is the estimated remaining charging duration
GetRemainingDuration() time.Duration
// GetRemainingEnergy is the remaining charge energy in Wh
// GetRemainingEnergy is the remaining charge energy in kWh
GetRemainingEnergy() float64
//
@ -210,4 +210,6 @@ type API interface {
SetVehicle(vehicle api.Vehicle)
// StartVehicleDetection allows triggering vehicle detection for debugging purposes
StartVehicleDetection()
// GetSoc returns the last vehicle or charger soc in %
GetSoc() float64
}

View file

@ -562,6 +562,20 @@ func (mr *MockAPIMockRecorder) GetSmartFeedInPriorityLimit() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSmartFeedInPriorityLimit", reflect.TypeOf((*MockAPI)(nil).GetSmartFeedInPriorityLimit))
}
// GetSoc mocks base method.
func (m *MockAPI) GetSoc() float64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetSoc")
ret0, _ := ret[0].(float64)
return ret0
}
// GetSoc indicates an expected call of GetSoc.
func (mr *MockAPIMockRecorder) GetSoc() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSoc", reflect.TypeOf((*MockAPI)(nil).GetSoc))
}
// GetSocConfig mocks base method.
func (m *MockAPI) GetSocConfig() SocConfig {
m.ctrl.T.Helper()

View file

@ -737,25 +737,25 @@ func (lp *Loadpoint) setRemainingDuration(remainingDuration time.Duration) {
}
}
// GetRemainingEnergy is the remaining charge energy in Wh
// GetRemainingEnergy is the remaining charge energy in kWh
func (lp *Loadpoint) GetRemainingEnergy() float64 {
lp.RLock()
defer lp.RUnlock()
return lp.chargeRemainingEnergy
}
// SetRemainingEnergy sets the remaining charge energy in Wh
// SetRemainingEnergy sets the remaining charge energy in kWh
func (lp *Loadpoint) SetRemainingEnergy(chargeRemainingEnergy float64) {
lp.Lock()
defer lp.Unlock()
lp.setRemainingEnergy(chargeRemainingEnergy)
}
// setRemainingEnergy sets the remaining charge energy in Wh (no mutex)
// setRemainingEnergy sets the remaining charge energy in kWh (no mutex)
func (lp *Loadpoint) setRemainingEnergy(chargeRemainingEnergy float64) {
if lp.chargeRemainingEnergy != chargeRemainingEnergy {
lp.chargeRemainingEnergy = chargeRemainingEnergy
lp.publish(keys.ChargeRemainingEnergy, chargeRemainingEnergy)
lp.publish(keys.ChargeRemainingEnergy, chargeRemainingEnergy*1e3)
}
}
@ -778,6 +778,14 @@ func (lp *Loadpoint) SetVehicle(vehicle api.Vehicle) {
lp.stopVehicleDetection()
}
// GetSoc returns the estimated vehicle soc in %
func (lp *Loadpoint) GetSoc() float64 {
lp.vmu.RLock()
defer lp.vmu.RUnlock()
return lp.vehicleSoc
}
// StartVehicleDetection allows triggering vehicle detection for debugging purposes
func (lp *Loadpoint) StartVehicleDetection() {
// reset vehicle

View file

@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/assert"
)
func TestMeterEnergy(t *testing.T) {
func TestMeterEnergyMeterTotal(t *testing.T) {
clock := clock.NewMock()
clock.Set(now.BeginningOfDay())
@ -21,7 +21,19 @@ func TestMeterEnergy(t *testing.T) {
assert.Equal(t, 1.0, me.AccumulatedEnergy())
me.AddMeterTotal(11)
assert.Equal(t, 1.0, me.AccumulatedEnergy())
}
func TestMeterEnergyAddPower(t *testing.T) {
clock := clock.NewMock()
clock.Set(now.BeginningOfDay())
me := &meterEnergy{clock: clock}
clock.Add(60 * time.Minute)
me.AddPower(1e3)
assert.Equal(t, 0.0, me.AccumulatedEnergy())
clock.Add(60 * time.Minute)
me.AddPower(1e3)
assert.Equal(t, 1.0, me.AccumulatedEnergy())

View file

@ -28,8 +28,9 @@ func Persist(ts time.Time, value float64) error {
}).Error
}
// Profile returns a 15min average meter profile in Wh
func Profile() (*[96]float64, error) {
// Profile returns a 15min average meter profile in Wh.
// Profile is sorted by timestamp starting at 00:00. It is guaranteed to contain 96 15min values.
func Profile(from time.Time) (*[96]float64, error) {
db, err := db.Instance.DB()
if err != nil {
return nil, err
@ -37,9 +38,10 @@ func Profile() (*[96]float64, error) {
rows, err := db.Query(`SELECT min(ts) AS ts, avg(val) AS val
FROM meters
WHERE meter = ?
WHERE meter = ? AND ts >= ?
GROUP BY strftime("%H:%M", ts)
ORDER BY ts`, 1)
ORDER BY strftime("%H:%M", ts) ASC`, 1, from,
)
if err != nil {
return nil, err
}
@ -48,26 +50,28 @@ func Profile() (*[96]float64, error) {
res := make([]float64, 0, 96)
for rows.Next() {
var (
ts time.Time
val float64
)
var ts SqlTime
var val float64
if err := rows.Scan(&ts, &val); err != nil {
return nil, err
}
hour := ts.Hour()
minute := ts.Minute() / 15
if len(res) != hour*4+minute {
return nil, ErrIncomplete
}
res = append(res, val)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(res) != 96 {
return nil, ErrIncomplete
}
return (*[96]float64)(res), nil
}
func SlotNum(ts time.Time) int {
ts = ts.Local()
return ts.Hour()*4 + ts.Minute()/15
}

29
core/metrics/types.go Normal file
View file

@ -0,0 +1,29 @@
package metrics
import (
"database/sql"
"errors"
"time"
)
type SqlTime time.Time
var _ sql.Scanner = (*SqlTime)(nil)
func (st *SqlTime) Scan(value any) error {
switch v := value.(type) {
case time.Time:
*st = SqlTime(v)
case int64:
*st = SqlTime(time.Unix(v, 0))
case string:
t, err := time.Parse(time.DateTime+"-07:00", v)
if err == nil {
*st = SqlTime(t)
}
return err
default:
return errors.New("unsupported timestamp type")
}
return nil
}

View file

@ -33,6 +33,7 @@ import (
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/modbus"
"github.com/evcc-io/evcc/util/sponsor"
"github.com/evcc-io/evcc/util/telemetry"
"github.com/samber/lo"
"github.com/smallnest/chanx"
@ -611,9 +612,9 @@ func (site *Site) updatePvMeters() {
}
// updateBatteryMeters updates battery meters
func (site *Site) updateBatteryMeters() {
func (site *Site) updateBatteryMeters() []measurement {
if len(site.batteryMeters) == 0 {
return
return nil
}
mm := site.collectMeters("battery", site.batteryMeters)
@ -681,6 +682,8 @@ func (site *Site) updateBatteryMeters() {
site.publish(keys.BatteryPower, site.batteryPower)
site.publish(keys.BatteryEnergy, totalEnergy)
site.publish(keys.Battery, mm)
return mm
}
// updateAuxMeters updates aux meters
@ -767,26 +770,30 @@ func (site *Site) updateGridMeter() error {
func (site *Site) updateMeters() error {
var eg errgroup.Group
var battery []measurement
eg.Go(func() error { site.updatePvMeters(); return nil })
eg.Go(func() error { site.updateBatteryMeters(); return nil })
eg.Go(func() error { battery = site.updateBatteryMeters(); return nil })
eg.Go(func() error { site.updateAuxMeters(); return nil })
eg.Go(func() error { site.updateExtMeters(); return nil })
eg.Go(site.updateGridMeter)
return eg.Wait()
}
func (site *Site) updateHouseholdConsumption(totalChargePower float64) {
householdPower := site.gridPower + site.pvPower + site.batteryPower - totalChargePower
if householdPower <= 0 {
return
if err := eg.Wait(); err != nil {
return err
}
site.householdEnergy.AddPower(householdPower)
if sponsor.IsAuthorized() {
go site.optimizerUpdateAsync(battery)
}
return nil
}
func (site *Site) updateHomeConsumption(homePower float64) {
site.householdEnergy.AddPower(homePower)
now := site.householdEnergy.clock.Now()
if site.householdSlotStart.IsZero() {
site.householdSlotStart = now
return
@ -955,6 +962,10 @@ func (site *Site) update(lp updater) {
homePower = max(homePower, 0)
site.publish(keys.HomePower, homePower)
if homePower > 0 {
site.updateHomeConsumption(homePower)
}
// add battery charging power to homePower to ignore all consumption which does not occur on loadpoints
// fix for: https://github.com/evcc-io/evcc/issues/11032
nonChargePower := homePower + max(0, -site.batteryPower)
@ -978,8 +989,6 @@ func (site *Site) update(lp updater) {
site.log.ERROR.Println(err)
}
site.updateHouseholdConsumption(totalChargePower)
site.stats.Update(site)
}

437
core/site_optimizer.go Normal file
View file

@ -0,0 +1,437 @@
package core
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"time"
evopt "github.com/andig/evopt/client"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/metrics"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/sponsor"
"github.com/jinzhu/now"
"github.com/samber/lo"
)
var (
eta = float32(0.9) // efficiency of the battery charging/discharging
batteryPower = float32(6000) // power of the battery in W
pa = float32(0.3 / 1e3) // Value per Wh at end of time horizon
updated time.Time
)
type batteryType string
const (
batteryTypeLoadpoint batteryType = "loadpoint"
batteryTypeVehicle batteryType = "vehicle"
batteryTypeBattery batteryType = "battery"
)
type batteryDetail struct {
Type batteryType `json:"type"`
Title string `json:"title,omitempty"`
Name string `json:"name,omitempty"`
Capacity float64 `json:"capacity,omitempty"`
}
type responseDetails struct {
Timestamps []time.Time `json:"timestamp"`
BatteryDetails []batteryDetail `json:"batteryDetails"`
}
func (site *Site) optimizerUpdateAsync(battery []measurement) {
site.log.ERROR.Println("optimizer:", site.optimizerUpdate(battery))
}
func (site *Site) optimizerUpdate(battery []measurement) error {
uri := os.Getenv("EVOPT_URI")
if uri == "" {
return nil
}
if time.Since(updated) < time.Minute {
return nil
}
defer func() {
updated = time.Now()
}()
solarTariff := site.GetTariff(api.TariffUsageSolar)
solarRates, err := solarTariff.Rates()
if err != nil {
return err
}
solar := currentSlots(solarTariff)
grid := currentSlots(site.GetTariff(api.TariffUsageGrid))
feedIn := currentSlots(site.GetTariff(api.TariffUsageFeedIn))
minLen := lo.Min([]int{len(grid), len(feedIn), len(solar)})
if minLen < 8 {
return fmt.Errorf("not enough slots for optimization: %d", minLen)
}
dt := timeSteps(minLen)
firstSlotDuration := time.Duration(dt[0]) * time.Second
site.log.DEBUG.Printf("optimizer: optimizing %d slots until %v: grid=%d, feedIn=%d, solar=%d, first slot: %v",
minLen,
grid[minLen-1].End.Local(),
len(grid), len(feedIn), len(solar),
firstSlotDuration,
)
gt := site.homeProfile(minLen)
solarEnergy, err := ratesToEnergy(solarRates, firstSlotDuration)
if err != nil {
return err
}
req := evopt.OptimizationInput{
EtaC: &eta,
EtaD: &eta,
TimeSeries: evopt.TimeSeries{
Dt: dt,
Gt: asFloat32(gt),
PN: maxValues(grid, 1e3, minLen),
PE: maxValues(feedIn, 1e3, minLen),
Ft: maxValues(solarEnergy, 1, minLen),
},
}
details := responseDetails{
Timestamps: asTimestamps(dt),
}
for _, lp := range site.Loadpoints() {
bat := evopt.BatteryConfig{
ChargeFromGrid: lo.ToPtr(true),
CMin: float32(lp.EffectiveMinPower()),
CMax: float32(lp.EffectiveMaxPower()),
DMax: 0,
SMin: 0,
PA: pa,
}
if profile := loadpointProfile(lp, firstSlotDuration, minLen); profile != nil {
bat.PDemand = lo.ToPtr(asFloat32(profile))
}
detail := batteryDetail{Type: batteryTypeLoadpoint}
if v := lp.GetVehicle(); v != nil {
bat.SMax = float32(v.Capacity() * 1e3) // Wh
bat.SInitial = float32(v.Capacity() * lp.GetSoc() * 10) // Wh
detail.Type = batteryTypeVehicle
detail.Title = v.GetTitle()
detail.Capacity = v.Capacity()
// find vehicle name/id
for _, dev := range config.Vehicles().Devices() {
if dev.Instance() == v {
detail.Name = dev.Config().Name
}
}
}
req.Batteries = append(req.Batteries, bat)
details.BatteryDetails = append(details.BatteryDetails, detail)
}
for i, b := range battery {
if b.Capacity == nil || b.Soc == nil {
continue
}
dev := site.batteryMeters[i]
bat := evopt.BatteryConfig{
CMin: 0,
CMax: batteryPower,
DMax: batteryPower,
SMin: 0,
SMax: float32(*b.Capacity * 1e3), // Wh
SInitial: float32(*b.Capacity * *b.Soc * 10), // Wh
PA: pa,
}
// TODO atm we cannot cannot control charge from grid speed
if _, ok := (dev.Instance()).(api.BatteryController); ok {
bat.ChargeFromGrid = lo.ToPtr(true)
}
req.Batteries = append(req.Batteries, bat)
details.BatteryDetails = append(details.BatteryDetails, batteryDetail{
Type: batteryTypeBattery,
Name: dev.Config().Name,
Capacity: *b.Capacity,
})
}
httpClient := request.NewClient(site.log)
httpClient.Timeout = 30 * time.Second
apiClient, err := evopt.NewClientWithResponses(uri, evopt.WithHTTPClient(httpClient))
if err != nil {
return err
}
resp, err := apiClient.PostOptimizeChargeScheduleWithResponse(context.TODO(), req, func(_ context.Context, req *http.Request) error {
if sponsor.IsAuthorized() {
req.Header.Set("Authorization", "Bearer "+sponsor.Token)
}
// command, _ := http2curl.GetCurlCommand(req)
// log.TRACE.Println("\n" + command.String())
return nil
})
if err != nil {
return err
}
if resp.StatusCode() == http.StatusInternalServerError && resp.JSON500.Message != nil {
return errors.New(*resp.JSON500.Message)
}
if resp.StatusCode() == http.StatusBadRequest && resp.JSON400.Message != nil {
return errors.New(*resp.JSON400.Message)
}
if resp.StatusCode() != http.StatusOK {
return fmt.Errorf("invalid status: %d", resp.StatusCode())
}
site.publish("evopt", struct {
Req evopt.OptimizationInput `json:"req"`
Res evopt.OptimizationResult `json:"res"`
Details responseDetails `json:"details"`
}{
Req: req,
Res: *resp.JSON200,
Details: details,
})
return nil
}
// TODO consider charging efficiency
func loadpointProfile(lp loadpoint.API, firstSlotDuration time.Duration, minLen int) []float64 {
mode := lp.GetMode()
status := lp.GetStatus()
if status != api.StatusC || (mode != api.ModeMinPV && mode != api.ModeNow) {
return nil
}
power := lp.GetChargePower()
if minP := lp.EffectiveMinPower(); mode == api.ModeMinPV && minP < power {
power = minP
}
energy := lp.GetRemainingEnergy() * 1e3 // Wh
energyKnown := energy > 0
res := make([]float64, 0, minLen)
for i := range minLen {
d := 1.0 // hours
if i == 0 {
d = firstSlotDuration.Hours()
}
deltaEnergy := power * d // Wh
if energyKnown && deltaEnergy >= energy {
deltaEnergy = energy
}
energy -= deltaEnergy
res = append(res, deltaEnergy)
}
return res
}
func (site *Site) homeProfile(minLen int) []float64 {
now := time.Now().Truncate(time.Hour)
profile, err := metrics.Profile(now.AddDate(0, 0, -30))
if err != nil {
site.log.ERROR.Printf("household metrics profile: %v", err)
return lo.RepeatBy(minLen, func(_ int) float64 {
return 0
})
}
res := slotsToHours(now, profile)
for len(res) < minLen {
res = append(res, profile[:]...)
}
if len(res) > minLen {
res = res[:minLen]
}
return res
}
// slotsToHours converts a daily consumption profile consisting of 96 15min slots
// to an hourly profile by totaling the values per hour and returning the first minLen values.
// the first value is fractional part of the the current hour, prorated.
func slotsToHours(now time.Time, profile *[96]float64) []float64 {
if profile == nil {
return []float64{}
}
// Calculate current 15-minute slot within the day (0-95)
currentMinute := now.Hour()*60 + now.Minute()
currentSlot := currentMinute / 15
// Calculate remaining minutes in current hour for prorating
minutesIntoHour := now.Minute()
remainingMinutesInHour := 60 - minutesIntoHour
var result []float64
// Handle the partial current hour first
if remainingMinutesInHour > 0 && currentSlot < 96 {
var partialHourValue float64
slotsInCurrentHour := remainingMinutesInHour / 15
if remainingMinutesInHour%15 != 0 {
slotsInCurrentHour++
}
// Sum the remaining slots in the current hour
for i := 0; i < slotsInCurrentHour && currentSlot+i < 96; i++ {
if currentSlot+i >= 0 {
partialHourValue += profile[currentSlot+i]
}
}
// Prorate based on the fraction of the hour remaining
fractionOfHour := float64(remainingMinutesInHour) / 60.0
partialHourValue *= fractionOfHour
result = append(result, float64(partialHourValue))
}
// Process complete hours starting from the next hour
nextHourSlot := (currentSlot/4 + 1) * 4
// Don't wrap around at end of day - only process remaining hours
for hourSlot := nextHourSlot; len(result) < 24 && hourSlot < 96; hourSlot += 4 {
var hourValue float64
// Sum 4 slots (4 × 15min = 60min = 1 hour)
for i := 0; i < 4 && hourSlot+i < 96; i++ {
hourValue += profile[hourSlot+i]
}
result = append(result, float64(hourValue))
}
return result
}
func ratesToEnergy(rr api.Rates, firstSlot time.Duration) (api.Rates, error) {
res := make(api.Rates, 0, len(rr))
for _, r := range rr {
from := r.Start
if len(res) == 0 {
from = endOfHour(r.Start).Add(-firstSlot)
}
if _, err := rr.At(from); err != nil {
return nil, fmt.Errorf("missing solar data for: %v", from)
}
energy := solarEnergy(rr, from, r.End)
if energy < 0 {
return nil, fmt.Errorf("negative solar energy from %v to %v: %.3f", from, r.End, energy)
}
res = append(res, api.Rate{
Start: from,
End: r.End,
Value: energy,
})
}
return res, nil
}
func asFloat32(gt []float64) []float32 {
return lo.Map(gt, func(v float64, i int) float32 {
return float32(v)
})
}
func endOfHour(ts time.Time) time.Time {
return ts.Truncate(time.Hour).Add(time.Hour)
}
func currentSlots(tariff api.Tariff) []api.Rate {
if tariff == nil {
return nil
}
rates, err := tariff.Rates()
if err != nil {
return nil
}
now := now.BeginningOfHour()
return lo.Filter(rates, func(slot api.Rate, _ int) bool {
return !slot.End.Before(now) // filter past slots
})
}
func timeSteps(minLen int) []int {
res := make([]int, 0, minLen)
eoh := now.BeginningOfHour().Add(time.Hour)
if d := time.Until(eoh); d > time.Second {
res = append(res, int(d.Seconds()))
}
for i := len(res); i < minLen; i++ {
res = append(res, 3600) // 1 hour in seconds
}
return res
}
func asTimestamps(dt []int) []time.Time {
res := make([]time.Time, 0, len(dt))
eoh := endOfHour(time.Now())
res = append(res, eoh.Add(-time.Duration(dt[0])*time.Second))
for i := range len(res) - 1 {
res = append(res, eoh.Add(time.Duration(dt[i+1])*time.Second))
}
return res
}
func maxValues(rates []api.Rate, div float64, maxLen int) []float32 {
res := make([]float32, 0, maxLen)
for _, slot := range rates {
res = append(res, float32(slot.Value/div))
if len(res) >= maxLen {
break
}
}
return res
}

215
core/site_optimizer_test.go Normal file
View file

@ -0,0 +1,215 @@
package core
import (
"testing"
"time"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/metrics"
"github.com/evcc-io/evcc/server/db"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)
func TestSqliteTimestamp(t *testing.T) {
clock := clock.NewMock()
clock.Add(time.Hour)
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, metrics.Init())
metrics.Persist(clock.Now(), 0)
db, err := db.Instance.DB()
require.NoError(t, err)
var (
ts metrics.SqlTime
val float64
)
for _, sql := range []string{
`SELECT ts, val FROM meters`,
`SELECT min(ts), val FROM meters`,
`SELECT unixepoch(ts), val FROM meters`,
`SELECT unixepoch(min(ts)), val FROM meters`,
`SELECT min(ts) AS ts, avg(val) AS val
FROM meters
GROUP BY strftime("%H:%M", ts)
ORDER BY ts`,
} {
require.NoError(t, db.QueryRow(sql).Scan(&ts, &val))
require.True(t, clock.Now().Equal(time.Time(ts)), "expected %v, got %v", clock.Now().Local(), time.Time(ts).Local())
}
require.NoError(t, db.QueryRow(`SELECT ts, val FROM meters WHERE ts >= ?`, clock.Now()).Scan(&ts, &val))
require.True(t, clock.Now().Equal(time.Time(ts)), "expected %v, got %v", clock.Now().Local(), time.Time(ts).Local())
}
func TestUpdateHouseholdProfile(t *testing.T) {
clock := clock.NewMock()
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
metrics.Init()
// 2 days of data
// day 1: 0 ... 95
// day 2: 96 ... 181
for i := range 4 * 2 * 24 {
metrics.Persist(clock.Now(), float64(i))
clock.Add(15 * time.Minute)
}
{
from := clock.Now().Local().AddDate(0, 0, -2).Add(12 * time.Hour) // 12:00 of day 0
prof, err := metrics.Profile(from)
require.NoError(t, err)
var expected [96]float64
for i := range expected {
if i < 48 {
expected[i] = float64(48+i+144+i) / 2
continue
}
expected[i] = float64(96 - 48 + i)
}
require.Equal(t, expected, *prof, "partial profile: expected %v, got %v", expected, *prof)
}
{
from := clock.Now().Local().AddDate(0, 0, -3).Add(12 * time.Hour) // 12:00 of day -1
prof, err := metrics.Profile(from)
require.NoError(t, err)
var expected [96]float64
for i := range expected {
expected[i] = float64(0+96+2*i) / 2
}
require.Equal(t, expected, *prof, "full profile: expected %v, got %v", expected, *prof)
}
}
func TestSlotsToHours(t *testing.T) {
// Create a test profile with known values
// Slots 0-3: hour 0 (00:00-01:00), values 1,2,3,4 -> sum = 10
// Slots 4-7: hour 1 (01:00-02:00), values 5,6,7,8 -> sum = 26
// etc.
var profile [96]float64
for i := range 96 {
profile[i] = float64(i + 1)
}
tests := []struct {
name string
now time.Time
expected []float32
}{
{
name: "start of hour",
now: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), // 00:00
expected: []float32{
10, // hour 0: slots 0-3 (1+2+3+4)
26, // hour 1: slots 4-7 (5+6+7+8)
42, // hour 2: slots 8-11 (9+10+11+12)
},
},
{
name: "middle of hour",
now: time.Date(2024, 1, 1, 0, 30, 0, 0, time.UTC), // 00:30
expected: []float32{
3.5, // remaining 30min of hour 0: slots 2-3 (3+4) * 0.5 = 7 * 0.5 = 3.5
26, // hour 1: slots 4-7 (5+6+7+8)
42, // hour 2: slots 8-11 (9+10+11+12)
},
},
{
name: "quarter past hour",
now: time.Date(2024, 1, 1, 0, 15, 0, 0, time.UTC), // 00:15
expected: []float32{
6.75, // remaining 45min of hour 0: slots 1-3 (2+3+4) * 0.75 = 9 * 0.75 = 6.75
26, // hour 1: slots 4-7 (5+6+7+8)
42, // hour 2: slots 8-11 (9+10+11+12)
},
},
{
name: "middle of slot early",
now: time.Date(2024, 1, 1, 0, 10, 0, 0, time.UTC), // 00:10 (middle of slot 0)
expected: []float32{
8.33, // remaining 50min of hour 0: slots 0-3 (1+2+3+4) * (50/60) = 10 * 0.833 = 8.33
26, // hour 1: slots 4-7 (5+6+7+8)
42, // hour 2: slots 8-11 (9+10+11+12)
},
},
{
name: "middle of slot late",
now: time.Date(2024, 1, 1, 0, 50, 0, 0, time.UTC), // 00:50 (middle of slot 3)
expected: []float32{
0.67, // remaining 10min of hour 0: slot 3 (4) * (10/60) = 4 * 0.167 = 0.67
26, // hour 1: slots 4-7 (5+6+7+8)
42, // hour 2: slots 8-11 (9+10+11+12)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slotsToHours(tt.now, &profile)
require.GreaterOrEqual(t, len(result), len(tt.expected), "insufficient result length")
for i, expected := range tt.expected {
require.InDelta(t, expected, result[i], 0.01, "mismatch at index %d", i)
}
})
}
}
func TestSlotsToHoursEdgeCases(t *testing.T) {
var profile [96]float64
for i := range 96 {
profile[i] = float64(i + 1)
}
t.Run("nil profile", func(t *testing.T) {
result := slotsToHours(time.Now(), nil)
require.Empty(t, result)
})
t.Run("end of day", func(t *testing.T) {
now := time.Date(2024, 1, 1, 23, 45, 0, 0, time.UTC) // 23:45
result := slotsToHours(now, &profile)
// Should only return the remaining 15min of the current hour
expected := []float32{24} // slot 95 (value 96) * 0.25 (15min/60min)
require.Equal(t, len(expected), len(result))
require.InDelta(t, expected[0], result[0], 0.01)
})
t.Run("exact hour boundary", func(t *testing.T) {
now := time.Date(2024, 1, 1, 1, 0, 0, 0, time.UTC) // 01:00
result := slotsToHours(now, &profile)
// Should start from hour 1 (slots 4-7)
require.GreaterOrEqual(t, len(result), 1)
require.InDelta(t, float32(26), result[0], 0.01) // 5+6+7+8 = 26
})
}
func TestLoadpointProfile(t *testing.T) {
ctrl := gomock.NewController(t)
lp := loadpoint.NewMockAPI(ctrl)
lp.EXPECT().GetMode().Return(api.ModeMinPV).AnyTimes()
lp.EXPECT().GetStatus().Return(api.StatusC).AnyTimes()
lp.EXPECT().GetChargePower().Return(10000.0).AnyTimes() // 1 0kW
lp.EXPECT().EffectiveMinPower().Return(1000.0).AnyTimes() // 1 kW
lp.EXPECT().GetRemainingEnergy().Return(2.0).AnyTimes() // 2 kWh
// expected slots: 0.25/ 1.0 / 0.75 kWh
require.Equal(t, []float64{250, 1000, 750}, loadpointProfile(lp, 15*time.Minute, 3))
}

View file

@ -162,7 +162,7 @@ func TestRequiredBatteryMode(t *testing.T) {
}
}
func TestUpdateHouseholdConsumption(t *testing.T) {
func TestUpdateHomeConsumption(t *testing.T) {
clock := clock.NewMock()
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
@ -174,19 +174,21 @@ func TestUpdateHouseholdConsumption(t *testing.T) {
householdEnergy: &meterEnergy{clock: clock},
}
require.True(t, s.householdEnergy.updated.IsZero())
clock.Add(5 * time.Minute)
s.updateHouseholdConsumption(1e3)
s.updateHomeConsumption(1e3)
require.False(t, s.householdEnergy.updated.IsZero())
clock.Add(5 * time.Minute)
s.updateHouseholdConsumption(1e3)
require.Equal(t, 0.25, s.householdEnergy.AccumulatedEnergy())
s.updateHomeConsumption(1e3)
require.Equal(t, 1e3*5/60/1e3, s.householdEnergy.AccumulatedEnergy()) // kWh
clock.Add(5 * time.Minute)
s.updateHouseholdConsumption(1e3)
s.updateHomeConsumption(1e3)
require.Equal(t, 0.0, s.householdEnergy.AccumulatedEnergy()) // accumulator reset after 15 minutes
clock.Add(15 * time.Minute)
s.updateHouseholdConsumption(1e3)
s.updateHomeConsumption(1e3)
require.Equal(t, 0.0, s.householdEnergy.AccumulatedEnergy()) // accumulator reset after 15 minutes
}

View file

@ -38,6 +38,10 @@ func interpolate(rr api.Rates, i int, ts time.Time) float64 {
func solarEnergy(rr api.Rates, from, to time.Time) float64 {
var energy float64
if from.After(to) {
panic("from cannot be after to")
}
idx, ok := search(rr, from)
if !ok {
switch {

1
go.mod
View file

@ -9,6 +9,7 @@ require (
github.com/Masterminds/sprig/v3 v3.3.0
github.com/PuerkitoBio/goquery v1.10.3
github.com/WulfgarW/sensonet v0.0.4
github.com/andig/evopt v0.0.0-20250806061439-fc50d99582e9
github.com/andig/go-powerwall v0.2.1-0.20230808194509-dd70cdb6e140
github.com/andig/gosunspec v0.0.0-20240918203654-860ce51d602b
github.com/andig/mbserver v0.0.0-20230310211055-1d29cbb5820e

2
go.sum
View file

@ -33,6 +33,8 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/andig/evopt v0.0.0-20250806061439-fc50d99582e9 h1:tWGkO2RipG162BGfxuLj3TbxA2BiI/oHH7ftQGUJ/VI=
github.com/andig/evopt v0.0.0-20250806061439-fc50d99582e9/go.mod h1:TE4sjD3Fxu4Jn49ecE6H6EdHliJA5KnkRfY6WGdHBu0=
github.com/andig/go-powerwall v0.2.1-0.20230808194509-dd70cdb6e140 h1:C93T8vg7CN4Q4BBDbBMOvBoTYBA+CaBEvlY2SZF8aa0=
github.com/andig/go-powerwall v0.2.1-0.20230808194509-dd70cdb6e140/go.mod h1:Xk09mD+7RTCuuHMX5RxlqgEUeh9oZdIX0rL0qiY6l/4=
github.com/andig/gosunspec v0.0.0-20240918203654-860ce51d602b h1:81UMfM949I7StrRay7YDUZazY8M1u/JHkzwcFEjiilQ=

View file

@ -447,7 +447,7 @@ func (s *SEMP) planningRequest(id int, lp loadpoint.API) (res PlanningRequest) {
}
// remaining max energy demand in Wh
chargeRemainingEnergy := lp.GetRemainingEnergy()
chargeRemainingEnergy := lp.GetRemainingEnergy() * 1e3
maxEnergy := int(chargeRemainingEnergy)
// add 1kWh in case we're charging but battery claims full

View file

@ -77,7 +77,7 @@ test.describe("fatal config handling", async () => {
await meterModal.getByLabel("IP address or hostname").fill(simulatorHost());
await meterModal.getByRole("button", { name: "Validate & save" }).click();
await expectModalHidden(meterModal);
await expectModalVisible(lpModal);
await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal);
await expect(page.getByTestId("loadpoint")).toContainText("Test Carport");

View file

@ -50,15 +50,15 @@ export async function expectModalHidden(modal: Locator): Promise<void> {
export async function editorClear(editor: Locator, iterations = 10): Promise<void> {
for (let i = 0; i < iterations; i++) {
await editor.locator(".view-line").nth(0).click();
await editor.page().keyboard.press("ControlOrMeta+KeyA", { delay: 10 });
await editor.page().keyboard.press("Backspace", { delay: 10 });
await editor.page().keyboard.press("ControlOrMeta+KeyA", { delay: 50 });
await editor.page().keyboard.press("Backspace", { delay: 50 });
}
}
export async function editorPaste(editor: Locator, page: Page, text: string): Promise<void> {
await editor.locator(".view-line").nth(0).click();
await page.evaluate((text) => navigator.clipboard.writeText(text), text);
await page.keyboard.press("ControlOrMeta+KeyV", { delay: 50 });
await page.keyboard.press("ControlOrMeta+KeyV", { delay: 100 });
}
export enum LoadpointType {