Integrate external optmizer (experimental) (#22495)
This commit is contained in:
parent
3cb810965a
commit
b5a8a2d3c6
32 changed files with 2325 additions and 54 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
182
assets/js/components/Optimize/BatteryConfigurationTable.vue
Normal file
182
assets/js/components/Optimize/BatteryConfigurationTable.vue
Normal 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>
|
||||
298
assets/js/components/Optimize/ChargeChart.vue
Normal file
298
assets/js/components/Optimize/ChargeChart.vue
Normal 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>
|
||||
231
assets/js/components/Optimize/PriceChart.vue
Normal file
231
assets/js/components/Optimize/PriceChart.vue
Normal 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>
|
||||
221
assets/js/components/Optimize/SocChart.vue
Normal file
221
assets/js/components/Optimize/SocChart.vue
Normal 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>
|
||||
294
assets/js/components/Optimize/TimeSeriesDataTable.vue
Normal file
294
assets/js/components/Optimize/TimeSeriesDataTable.vue
Normal 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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export interface Legend {
|
|||
label: string;
|
||||
color: any;
|
||||
value: string | string[];
|
||||
type?: "area" | "line";
|
||||
}
|
||||
|
||||
export interface Column {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
173
assets/js/views/Optimize.vue
Normal file
173
assets/js/views/Optimize.vue
Normal 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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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
29
core/metrics/types.go
Normal 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
|
||||
}
|
||||
37
core/site.go
37
core/site.go
|
|
@ -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
437
core/site_optimizer.go
Normal 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
215
core/site_optimizer_test.go
Normal 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))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
1
go.mod
|
|
@ -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
2
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue