Optimizer: derive per-device action suggestions from result (#30834)

This commit is contained in:
andig 2026-07-10 16:07:21 +02:00 • committed by GitHub
parent 4dc9775ea3
commit ab3220c475
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 428 additions and 72 deletions

View file

@ -47,8 +47,7 @@ import Card from "../Helper/Card.vue";
import LabelAndValue from "../Helper/LabelAndValue.vue";
import SocGauge from "./SocGauge.vue";
import OptimizerInfo from "./OptimizerInfo.vue";
import { BATTERY_MODE, type BatteryForecast } from "@/types/evcc";
import type { BatterySuggestion } from "./types";
import { BATTERY_MODE, type BatteryForecast, type BatterySuggestion } from "@/types/evcc";
export default defineComponent({
name: "BatteryStatusCard",
@ -131,6 +130,11 @@ export default defineComponent({
align-items: start;
gap: 0.5rem;
}
.optimizer {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--bs-border-color);
}
.values :deep(.root) {
margin-bottom: 0;
min-width: 0;

View file

@ -42,12 +42,20 @@ export const Multiple = Template.bind({});
Multiple.args = {
battery: {
...battery([
device({ title: "Sungrow", soc: 76, power: 800, capacity: 13.5, controllable: true }),
device({
title: "Sungrow",
soc: 76,
power: 800,
capacity: 13.5,
controllable: true,
suggestion: { action: "hold" },
}),
device({
title: "Anker SOLIX X1 Home Energy Storage System Extended Pro Max",
soc: 40,
power: -1200,
capacity: 7.5,
suggestion: { action: "normal" },
}),
]),
soc: 63,
@ -57,11 +65,21 @@ Multiple.args = {
// single battery: optimizer gets its own card, showing suggestion + high + low
export const WithForecast = Template.bind({});
WithForecast.args = {
battery: battery([device({ title: "Battery", soc: 62, power: 900, capacity: 10 })], {
highest: { soc: 100, time: "2026-07-01T16:30:00+02:00" },
lowest: { soc: 12, time: "2026-07-02T06:00:00+02:00" },
}),
suggestion: { action: "charge" },
battery: battery(
[
device({
title: "Battery",
soc: 62,
power: 900,
capacity: 10,
suggestion: { action: "charge" },
}),
],
{
highest: { soc: 100, time: "2026-07-01T16:30:00+02:00" },
lowest: { soc: 12, time: "2026-07-02T06:00:00+02:00" },
}
),
};
// forecast is a site-aggregate value, so it lands on the combined card

View file

@ -2,7 +2,10 @@
<div v-if="showSplit" class="cards cards--split gap-4">
<BatteryStatusCard v-bind="cards[0]" :battery-mode="batteryMode" />
<Card data-testid="battery-optimizer-card">
<OptimizerInfo :suggestion="suggestion" :forecast="batteryForecast" />
<OptimizerInfo
:suggestion="devices[0]?.suggestion ?? null"
:forecast="batteryForecast"
/>
</Card>
</div>
<div v-else class="cards gap-4">
@ -22,7 +25,7 @@ import type { BATTERY_MODE, Battery, BatteryForecast, BatteryMeter } from "@/typ
import Card from "../Helper/Card.vue";
import BatteryStatusCard from "./BatteryStatusCard.vue";
import OptimizerInfo from "./OptimizerInfo.vue";
import type { BatterySuggestion, BatteryStatusCardModel } from "./types";
import type { BatteryStatusCardModel } from "./types";
// One battery + forecast splits into battery and optimizer cards; several batteries prepend a
// combined aggregate. Stateless, derived from the battery object.
@ -32,7 +35,6 @@ export default defineComponent({
props: {
battery: { type: Object as PropType<Battery> },
batteryMode: String as PropType<BATTERY_MODE>,
suggestion: { type: Object as PropType<BatterySuggestion | null>, default: null },
},
computed: {
devices(): BatteryMeter[] {
@ -43,7 +45,9 @@ export default defineComponent({
return fc?.highest || fc?.lowest ? fc : null;
},
showSplit(): boolean {
return this.devices.length === 1 && !!this.batteryForecast;
return (
this.devices.length === 1 && !!(this.batteryForecast || this.devices[0]?.suggestion)
);
},
cards(): BatteryStatusCardModel[] {
const multiple = this.devices.length > 1;
@ -55,7 +59,8 @@ export default defineComponent({
capacity: d.capacity || 0,
color: batteryColor(i),
controllable: d.controllable,
suggestion: null, // per-battery suggestion not wired yet
// single battery shows the suggestion on the dedicated optimizer card
suggestion: multiple ? (d.suggestion ?? null) : null,
forecast: null, // aggregate forecast lives on the combined / dedicated card
}));
// combined uses the site aggregate soc/power, not per-device sums; the site-wide
@ -69,7 +74,7 @@ export default defineComponent({
capacity: this.devices.reduce((s, d) => s + (d.capacity || 0), 0),
color: batteryColor(0),
controllable: true,
suggestion: this.suggestion,
suggestion: null, // no aggregate; device cards show their own
forecast: this.batteryForecast,
});
}

View file

@ -31,13 +31,12 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import formatter from "@/mixins/formatter";
import type { BatteryForecast, BatteryForecastPoint } from "@/types/evcc";
import type { BatteryForecast, BatteryForecastPoint, BatterySuggestion } from "@/types/evcc";
import BatteryIcon from "../Energyflow/BatteryIcon.vue";
import Optimizer from "../MaterialIcon/Optimizer.vue";
import type { BatterySuggestion } from "./types";
// Optimizer rows: suggestion (not yet wired) plus the site battery high/low
// soc forecast. Simple bold-label / muted-value layout.
// Optimizer rows: suggestion plus the battery high/low soc forecast.
// Simple bold-label / muted-value layout.
export default defineComponent({
name: "OptimizerInfo",
components: { BatteryIcon, Optimizer },
@ -48,9 +47,9 @@ export default defineComponent({
},
computed: {
recommendation(): string | null {
const s = this.suggestion;
if (!s) return null;
return this.$t(`battery.optimizer.action.${s.action}`);
const action = this.suggestion?.action;
if (!action) return null;
return this.$t(`battery.optimizer.action.${action}`);
},
},
methods: {

View file

@ -1,6 +1,6 @@
// Shared view-model types for the experimental battery page.
import type { BatteryForecast } from "@/types/evcc";
import type { BatteryForecast, BatterySuggestion } from "@/types/evcc";
export interface SocPoint {
t: number; // epoch ms
@ -16,10 +16,6 @@ export interface BatterySeries {
forecast: SocPoint[]; // ascending t, >= now (may be empty)
}
export interface BatterySuggestion {
action: "normal" | "hold" | "charge" | "holdcharge";
}
export interface BatteryStatusCardModel {
id: string;
title: string;

View file

@ -121,6 +121,7 @@ import type {
SMART_COST_TYPE,
BATTERY_MODE,
LoadpointUi,
LoadpointSuggestion,
} from "@/types/evcc";
import type { PlanStrategy } from "@/components/ChargingPlans/types";
@ -229,6 +230,7 @@ export default defineComponent({
smartFeedInPriorityAvailable: Boolean,
smartFeedInPriorityActive: Boolean,
smartFeedInPriorityNextStart: String as PropType<string | null>,
suggestion: Object as PropType<LoadpointSuggestion | null>,
tariffGrid: Number,
tariffFeedIn: Number,
tariffCo2: Number,

View file

@ -2,7 +2,7 @@
<svg :style="svgStyle" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M10.45 15.5q.6.6 1.55.588t1.4-.688l4.225-6.325q.225-.35-.062-.638t-.638-.062L10.6 12.6q-.675.45-.712 1.375t.562 1.525M12 4q.9 0 1.775.15t1.7.475q.4.15.85.563t.25.787t-.9.5t-1.125-.025q-.625-.225-1.262-.337T12 6Q8.675 6 6.337 8.338T4 14q0 1.05.288 2.075T5.1 18h13.8q.575-.95.838-1.975T20 13.9q0-.65-.113-1.275T19.55 11.4q-.15-.425-.05-.825t.45-.675q.325-.25.713-.15t.537.45q.375.875.575 1.788T22 13.85q.025 1.425-.325 2.725T20.65 19.05q-.275.45-.75.7t-1 .25H5.1q-.525 0-1-.25t-.75-.7q-.65-1.125-1-2.387T2 14q0-2.075.788-3.887t2.15-3.175t3.187-2.15T12 4m.175 7.825"
d="M21.375 8.625L20 8l1.375-.625L22 6l.625 1.375L24 8l-1.375.625L22 10l-.625-1.375ZM18.05 3.95L16 3l2.05-.95L19 0l.95 2.05L22 3l-2.05.95L19 6l-.95-2.05ZM9 22q-.825 0-1.413-.588T7 20h4q0 .825-.588 1.413T9 22Zm-3-3q-.425 0-.713-.288T5 18q0-.425.288-.713T6 17h6q.425 0 .713.288T13 18q0 .425-.288.713T12 19H6Zm-.75-3q-1.725-1.025-2.738-2.75T1.5 9.5q0-3.125 2.188-5.313T9 2q3.125 0 5.313 2.188T16.5 9.5q0 2.025-1.012 3.75T12.75 16h-7.5Zm.6-2h6.3q1.125-.8 1.738-1.975T14.5 9.5q0-2.3-1.6-3.9T9 4Q6.7 4 5.1 5.6T3.5 9.5q0 1.35.612 2.525T5.85 14ZM9 14Z"
/>
</svg>
</template>

View file

@ -0,0 +1,19 @@
<template>
<svg :style="svgStyle" viewBox="0 0 134 134">
<path
fill="currentColor"
fill-rule="evenodd"
d="M118.75,47.917l-7.639,-3.472l7.639,-3.472l3.472,-7.639l3.472,7.639l7.639,3.472l-7.639,3.472l-3.472,7.639l-3.472,-7.639Zm-18.472,-25.972l-11.389,-5.278l11.389,-5.278l5.278,-11.389l5.278,11.389l11.389,5.278l-11.389,5.278l-5.278,11.389l-5.278,-11.389Zm-50.278,100.278c-3.056,0 -5.672,-1.089 -7.85,-3.267c-2.178,-2.178 -3.265,-4.793 -3.261,-7.844l22.222,0c0,3.056 -1.089,5.672 -3.267,7.85c-2.178,2.178 -4.793,3.265 -7.844,3.261Zm-16.667,-16.667c-1.574,0 -2.894,-0.533 -3.961,-1.6c-1.067,-1.067 -1.598,-2.385 -1.594,-3.956c0,-1.574 0.533,-2.894 1.6,-3.961c1.067,-1.067 2.385,-1.598 3.956,-1.594l33.333,0c1.574,0 2.894,0.533 3.961,1.6c1.067,1.067 1.598,2.385 1.594,3.956c0,1.574 -0.533,2.894 -1.6,3.961c-1.067,1.067 -2.385,1.598 -3.956,1.594l-33.333,0Zm-4.167,-16.667c-6.389,-3.796 -11.459,-8.889 -15.211,-15.278c-3.752,-6.389 -5.626,-13.333 -5.622,-20.833c0,-11.574 4.052,-21.413 12.156,-29.517c8.104,-8.104 17.941,-12.154 29.511,-12.15c11.574,0 21.413,4.052 29.517,12.156c8.104,8.104 12.154,17.941 12.15,29.511c0,7.5 -1.874,14.444 -5.622,20.833c-3.748,6.389 -8.819,11.481 -15.211,15.278l-41.667,0Zm3.333,-11.111l35,0c4.167,-2.963 7.385,-6.62 9.656,-10.972c2.27,-4.352 3.404,-9.028 3.4,-14.028c0,-8.519 -2.963,-15.741 -8.889,-21.667c-5.926,-5.926 -13.148,-8.889 -21.667,-8.889c-8.519,0 -15.741,2.963 -21.667,8.889c-5.926,5.926 -8.889,13.148 -8.889,21.667c0,5 1.133,9.676 3.4,14.028c2.267,4.352 5.485,8.009 9.656,10.972Zm74.236,26.319l-11.409,0c-1.472,0 -2.544,-0.643 -3.217,-1.928c-0.672,-1.286 -0.581,-2.544 0.272,-3.776l18.402,-26.314c0.613,-0.736 1.32,-0.981 2.12,-0.736c0.8,0.245 1.197,0.859 1.192,1.84l0,16.193l11.409,0c1.472,0 2.547,0.645 3.224,1.936c0.677,1.291 0.584,2.547 -0.28,3.769l-18.402,26.314c-0.613,0.736 -1.32,0.981 -2.12,0.736c-0.8,-0.245 -1.197,-0.859 -1.192,-1.84l0,-16.193Z"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "OptimizerCharge",
mixins: [icon],
});
</script>

View file

@ -0,0 +1,18 @@
<template>
<svg :style="svgStyle" viewBox="0 0 134 134">
<path
fill="currentColor"
d="M118.75,47.917l-7.639,-3.472l7.639,-3.472l3.472,-7.639l3.472,7.639l7.639,3.472l-7.639,3.472l-3.472,7.639l-3.472,-7.639Zm-18.472,-25.972l-11.389,-5.278l11.389,-5.278l5.278,-11.389l5.278,11.389l11.389,5.278l-11.389,5.278l-5.278,11.389l-5.278,-11.389Zm-50.278,100.278c-3.056,0 -5.672,-1.089 -7.85,-3.267c-2.178,-2.178 -3.265,-4.793 -3.261,-7.844l22.222,0c0,3.056 -1.089,5.672 -3.267,7.85c-2.178,2.178 -4.793,3.265 -7.844,3.261Zm-16.667,-16.667c-1.574,0 -2.894,-0.533 -3.961,-1.6c-1.067,-1.067 -1.598,-2.385 -1.594,-3.956c0,-1.574 0.533,-2.894 1.6,-3.961c1.067,-1.067 2.385,-1.598 3.956,-1.594l33.333,0c1.574,0 2.894,0.533 3.961,1.6c1.067,1.067 1.598,2.385 1.594,3.956c0,1.574 -0.533,2.894 -1.6,3.961c-1.067,1.067 -2.385,1.598 -3.956,1.594l-33.333,0Zm-4.167,-16.667c-6.389,-3.796 -11.459,-8.889 -15.211,-15.278c-3.752,-6.389 -5.626,-13.333 -5.622,-20.833c0,-11.574 4.052,-21.413 12.156,-29.517c8.104,-8.104 17.941,-12.154 29.511,-12.15c11.574,0 21.413,4.052 29.517,12.156c8.104,8.104 12.154,17.941 12.15,29.511c0,7.5 -1.874,14.444 -5.622,20.833c-3.748,6.389 -8.819,11.481 -15.211,15.278l-41.667,0Zm3.333,-11.111l35,0c4.167,-2.963 7.385,-6.62 9.656,-10.972c2.27,-4.352 3.404,-9.028 3.4,-14.028c0,-8.519 -2.963,-15.741 -8.889,-21.667c-5.926,-5.926 -13.148,-8.889 -21.667,-8.889c-8.519,0 -15.741,2.963 -21.667,8.889c-5.926,5.926 -8.889,13.148 -8.889,21.667c0,5 1.133,9.676 3.4,14.028c2.267,4.352 5.485,8.009 9.656,10.972Zm64.722,44.444c-1.574,0 -2.893,-0.533 -3.956,-1.6c-1.063,-1.067 -1.596,-2.385 -1.6,-3.956l0,-27.778c0,-1.574 0.533,-2.893 1.6,-3.956c1.067,-1.063 2.385,-1.596 3.956,-1.6c1.57,-0.004 2.891,0.53 3.961,1.6c1.07,1.07 1.602,2.389 1.594,3.956l0,27.778c0,1.574 -0.533,2.894 -1.6,3.961c-1.067,1.067 -2.385,1.598 -3.956,1.594Zm22.222,0c-1.574,0 -2.893,-0.533 -3.956,-1.6c-1.063,-1.067 -1.596,-2.385 -1.6,-3.956l0,-27.778c0,-1.574 0.533,-2.893 1.6,-3.956c1.067,-1.063 2.385,-1.596 3.956,-1.6c1.57,-0.004 2.891,0.53 3.961,1.6c1.07,1.07 1.602,2.389 1.594,3.956l0,27.778c0,1.574 -0.533,2.894 -1.6,3.961c-1.067,1.067 -2.385,1.598 -3.956,1.594Z"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "OptimizerPause",
mixins: [icon],
});
</script>

View file

@ -23,6 +23,7 @@ const allEntries = {
smartcost: false,
planactive: false,
planstart: false,
suggestion: false,
};
const expectEntries = (props: InstanceType<typeof Status>["$props"], entries: object) => {
@ -80,6 +81,37 @@ describe("min charge", () => {
});
});
describe("optimizer suggestion", () => {
test("charge suggested while not charging", () => {
expectEntries(
{ connected: true, suggestion: { action: "charge" } },
{ charger: "Connected.", suggestion: true }
);
});
test("stop suggested while charging", () => {
expectEntries(
{ connected: true, enabled: true, charging: true, suggestion: { action: "stop" } },
{ charger: "Charging…", suggestion: true }
);
});
test("hidden when suggestion matches state", () => {
expectEntries(
{ connected: true, enabled: true, charging: true, suggestion: { action: "charge" } },
{ charger: "Charging…", suggestion: false }
);
expectEntries(
{ connected: true, suggestion: { action: "stop" } },
{ charger: "Connected.", suggestion: false }
);
});
test("hidden when disconnected", () => {
expectEntries(
{ connected: false, suggestion: { action: "charge" } },
{ charger: "Disconnected.", suggestion: false }
);
});
});
describe("plan", () => {
const effectivePlanTime = "2020-03-16T06:00:00Z";
const planProjectedStart = "2020-03-16T02:00:00Z";

View file

@ -61,10 +61,18 @@ import { DEFAULT_LOCALE } from "@/i18n.ts";
import formatter from "@/mixins/formatter";
import minuteTicker from "@/mixins/minuteTicker";
import { defineComponent, type PropType } from "vue";
import { SMART_COST_TYPE, type CURRENCY, type VehicleStatus, type Timeout } from "@/types/evcc";
import {
SMART_COST_TYPE,
type CURRENCY,
type LoadpointSuggestion,
type VehicleStatus,
type Timeout,
} from "@/types/evcc";
import ClimaterIcon from "../MaterialIcon/Climater.vue";
import DynamicPriceIcon from "../MaterialIcon/DynamicPrice.vue";
import OptimizerChargeIcon from "../MaterialIcon/OptimizerCharge.vue";
import OptimizerPauseIcon from "../MaterialIcon/OptimizerPause.vue";
import PlanEndIcon from "../MaterialIcon/PlanEnd.vue";
import PlanStartIcon from "../MaterialIcon/PlanStart.vue";
import ReconnectIcon from "../MaterialIcon/Reconnect.vue";
@ -125,6 +133,7 @@ export default defineComponent({
smartFeedInPriorityDisabled: Boolean,
smartFeedInPriorityLimit: { type: Number, default: null },
smartFeedInPriorityNextStart: String,
suggestion: Object as PropType<LoadpointSuggestion | null>,
tariffCo2: { type: Number, default: 0 },
tariffGrid: { type: Number, default: 0 },
tariffFeedIn: { type: Number, default: 0 },
@ -166,6 +175,12 @@ export default defineComponent({
vehicleLimitWarning() {
return this.effectivePlanSoc > this.vehicleLimitSoc;
},
showSuggestions(): boolean {
if (!this.connected) return false;
// only advise when the suggestion differs from the current charging state
const action = this.suggestion?.action;
return (action === "charge" && !this.enabled) || (action === "stop" && this.enabled);
},
smartCostPrice() {
return this.smartCostType !== SMART_COST_TYPE.CO2;
},
@ -349,6 +364,20 @@ export default defineComponent({
clickable: true,
clickHandler: () => this.openLoadpointSettings(),
},
{
id: "suggestion",
visible: this.showSuggestions,
tooltipContent: this.translateStatus(
this.suggestion?.action === "charge"
? "suggestionStartTooltip"
: "suggestionStopTooltip"
),
iconComponent:
this.suggestion?.action === "charge"
? OptimizerChargeIcon
: OptimizerPauseIcon,
testId: "vehicle-status-suggestion",
},
{
id: "planActive",
visible: this.planProjectedEnd && this.planActive && !this.chargingPlanDisabled,

View file

@ -2,6 +2,13 @@ import { CHARGE_MODE } from "@/types/evcc";
import Vehicle from "./Vehicle.vue";
import type { Meta, StoryFn } from "@storybook/vue3";
function getFutureTime(hours: number, minutes: number) {
const now = new Date();
now.setHours(now.getHours() + hours);
now.setMinutes(now.getMinutes() + minutes);
return now.toISOString();
}
const baseState = {
vehicle: {
title: "Mein Auto",
@ -56,12 +63,15 @@ export default {
},
} as Meta<typeof Vehicle>;
// white box background as in the app's loadpoint card
const cardStyle = "padding: 1rem; background: var(--evcc-box); border-radius: 0.5rem;";
const Template: StoryFn<typeof Vehicle> = (args) => ({
components: { Vehicle },
setup() {
return { args };
return { args, cardStyle };
},
template: '<Vehicle v-bind="args" />',
template: '<div :style="cardStyle"><Vehicle v-bind="args" /></div>',
});
export const Disconnected = Template.bind({});
@ -175,6 +185,35 @@ SmartChargeCostLimitActive.args = {
mode: CHARGE_MODE.PV,
};
export const SuggestionCharge = Template.bind({});
SuggestionCharge.args = {
...baseState,
suggestion: { action: "charge" },
};
export const SuggestionPause = Template.bind({});
SuggestionPause.args = {
...baseState,
enabled: true,
charging: true,
suggestion: { action: "stop" },
};
export const SuggestionCombination = Template.bind({});
SuggestionCombination.args = {
...baseState,
enabled: true,
charging: true,
suggestion: { action: "stop" },
currency: "EUR",
tariffGrid: 0.32,
smartCostLimit: 0.12,
smartCostType: "price",
planActive: true,
effectivePlanTime: getFutureTime(6, 54),
planProjectedEnd: getFutureTime(5, 43),
};
export const PvEnableTimer = Template.bind({});
PvEnableTimer.args = {
...baseState,

View file

@ -98,6 +98,7 @@ import {
type VehicleStatus,
type Vehicle,
type LoadpointUi,
type LoadpointSuggestion,
} from "@/types/evcc";
import type { PlanStrategy } from "@/components/ChargingPlans/types";
import BatteryBoostButton from "../Loadpoints/BatteryBoostButton.vue";
@ -162,6 +163,7 @@ export default defineComponent({
smartFeedInPriorityNextStart: String,
smartFeedInPriorityLimit: Number,
socBasedCharging: Boolean,
suggestion: Object as PropType<LoadpointSuggestion | null>,
socBasedPlanning: Boolean,
tariffCo2: Number,
tariffGrid: Number,

View file

@ -367,6 +367,7 @@ export interface Loadpoint {
smartFeedInPriorityActive: boolean;
smartFeedInPriorityLimit: number | null;
smartFeedInPriorityNextStart: string | null;
suggestion?: LoadpointSuggestion | null;
title: string;
vehicleClimaterActive: boolean | null;
vehicleDetectionActive: boolean;
@ -669,10 +670,23 @@ export interface Battery {
forecast?: BatteryForecast;
}
export interface BatterySuggestion {
action: "normal" | "hold" | "charge" | "holdcharge";
charge?: number; // recommended charge power, W
discharge?: number; // recommended discharge power, W
}
export interface LoadpointSuggestion {
action: "charge" | "stop";
charge?: number; // recommended charge power, W
discharge?: number; // recommended discharge power, W
}
export interface BatteryMeter extends Meter {
soc: number;
controllable: boolean;
capacity: number; // 0 when not specified
suggestion?: BatterySuggestion;
}
export interface Vehicle {

View file

@ -45,6 +45,9 @@ const (
// loadpoint setpoint
OfferedCurrent = "offeredCurrent" // offered current
// optimizer
Suggestion = "suggestion" // optimizer's advisory suggestion for the current slot
// smart charging
SmartCostActive = "smartCostActive" // smart cost active
SmartCostLimit = "smartCostLimit" // smart cost limit, fast charge when costs are below

View file

@ -93,14 +93,15 @@ type Site struct {
collectors map[string]*metrics.Collector // keyed by meter ref
// cached state
gridPower float64 // Grid power
pvPower float64 // PV power
excessDCPower float64 // PV excess DC charge power (hybrid only)
auxPower float64 // Aux power
battery types.BatteryState // Battery cached and published state
batteryMode api.BatteryMode // Battery mode (runtime only, not persisted)
batteryModeExternal api.BatteryMode // Battery mode (external, runtime only, not persisted)
batteryModeExternalTimer time.Time // Battery mode timer for external control
gridPower float64 // Grid power
pvPower float64 // PV power
excessDCPower float64 // PV excess DC charge power (hybrid only)
auxPower float64 // Aux power
battery types.BatteryState // Battery cached and published state
batteryMode api.BatteryMode // Battery mode (runtime only, not persisted)
batteryModeExternal api.BatteryMode // Battery mode (external, runtime only, not persisted)
batteryModeExternalTimer time.Time // Battery mode timer for external control
batterySuggestions map[string]types.Suggestion // Optimizer suggestions by battery meter name
}
// MetersConfig contains the site's meter configuration
@ -517,6 +518,16 @@ func (site *Site) Publish(key string, val any) {
site.publish(key, val)
}
// publishLoadpoint sends a value into the given loadpoint's state
func (site *Site) publishLoadpoint(id int, key string, val any) {
// test helper
if site.valueChan == nil {
return
}
site.valueChan <- util.Param{Loadpoint: &id, Key: key, Val: val}
}
// clearPlanLocks clears locked plan goals for all loadpoints
func (site *Site) clearPlanLocks() {
for _, lp := range site.Loadpoints() {
@ -725,6 +736,15 @@ func (site *Site) updateBatteryMeters() {
}
}
site.publishBattery()
}
// publishBattery applies the optimizer suggestions and publishes the battery state
func (site *Site) publishBattery() {
for i, d := range site.battery.Devices {
site.battery.Devices[i].Suggestion = site.batterySuggestion(d.Name)
}
site.publish(keys.Battery, site.battery)
}

View file

@ -95,6 +95,8 @@ type batteryDetail struct {
Title string `json:"title,omitempty"`
Name string `json:"name,omitempty"`
Capacity float64 `json:"capacity,omitempty"`
loadpoint *int // originating loadpoint id for loadpoint/vehicle entries
}
type batteryResult struct {
@ -103,6 +105,77 @@ type batteryResult struct {
Empty time.Time `json:"empty,omitzero"`
}
// suggestionThreshold ignores numerical noise around zero power (W)
const suggestionThreshold = 50
// currentSlotSuggestion maps the optimizer's first-slot corner result onto an advisory action.
// Because the optimization is linear, the first slot is at an operating-range extreme, so it
// maps cleanly onto the discrete battery mode / loadpoint intent that control would later apply.
// An idle battery is interpreted from the grid flow: importing means discharge is withheld
// (hold), exporting means charging is withheld (holdcharge).
func currentSlotSuggestion(detail batteryDetail, res optimizer.BatteryResult, gridImporting, gridExporting bool, slotHours float64) types.Suggestion {
if slotHours <= 0 || len(res.ChargingPower) == 0 || len(res.DischargingPower) == 0 {
return types.Suggestion{}
}
charge := float64(res.ChargingPower[0]) / slotHours
discharge := float64(res.DischargingPower[0]) / slotHours
s := types.Suggestion{Charge: charge, Discharge: discharge}
if detail.Type == batteryTypeBattery {
idle := charge <= suggestionThreshold && discharge <= suggestionThreshold
switch {
case charge > suggestionThreshold && gridImporting:
// charging while importing means grid charging
s.Action = api.BatteryCharge.String()
case idle && gridImporting:
// idle while importing: discharge is deliberately withheld
s.Action = api.BatteryHold.String()
case idle && gridExporting:
// idle while exporting: surplus is exported instead of charged
s.Action = api.BatteryHoldCharge.String()
default:
s.Action = api.BatteryNormal.String()
}
} else if charge > suggestionThreshold {
s.Action = "charge"
} else {
s.Action = "stop"
}
return s
}
// setBatterySuggestions replaces the suggestions applied on each battery publish
func (site *Site) setBatterySuggestions(suggestions map[string]types.Suggestion) {
site.Lock()
defer site.Unlock()
site.batterySuggestions = suggestions
}
// batterySuggestion returns the optimizer suggestion for the given battery meter
func (site *Site) batterySuggestion(name string) *types.Suggestion {
site.RLock()
defer site.RUnlock()
if s, ok := site.batterySuggestions[name]; ok {
return &s
}
return nil
}
// clearSuggestions removes all suggestions when the optimizer result is stale
func (site *Site) clearSuggestions() {
site.setBatterySuggestions(nil)
site.publishBattery()
for id := range site.Loadpoints() {
site.publishLoadpoint(id, keys.Suggestion, nil)
}
}
type requestDetails struct {
Timestamps []time.Time `json:"timestamp"`
BatteryDetails []batteryDetail `json:"batteryDetails"`
@ -140,6 +213,9 @@ func (site *Site) optimizerUpdateAsync() {
if err != nil {
site.log.ERROR.Println("optimizer:", err)
// stale advice must not linger
site.clearSuggestions()
}
}()
@ -237,7 +313,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
details.BatteryDetails = append(details.BatteryDetails, detail)
}
for _, lp := range site.Loadpoints() {
for id, lp := range site.Loadpoints() {
// ignore disconnected loadpoints, including StatusNone
if s := lp.GetStatus(); s != api.StatusB && s != api.StatusC {
continue
@ -249,6 +325,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
// skip disabled loadpoints
if req, detail := site.loadpointRequest(lp, minLen, firstSlotDuration, grid); req.CMax > 0 {
detail.loadpoint = &id
add(req, detail)
}
}
@ -309,12 +386,20 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
Details: details,
})
slotHours := firstSlotDuration.Hours()
gridImporting := len(resp.JSON200.GridImport) > 0 && resp.JSON200.GridImport[0] > 0
gridExporting := len(resp.JSON200.GridExport) > 0 && resp.JSON200.GridExport[0] > 0
var batteries []batteryResult
suggestions := make(map[string]types.Suggestion, len(req.Batteries))
lpSuggestions := make(map[int]types.Suggestion)
for i, batReq := range req.Batteries {
batResp := resp.JSON200.Batteries[i]
detail := details.BatteryDetails[i]
batResult := batteryResult{
batteryDetail: details.BatteryDetails[i],
batteryDetail: detail,
Full: matchSoc(batResp.StateOfCharge, func(soc float32) bool {
return soc >= batReq.SMax
}),
@ -324,13 +409,33 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
}
batteries = append(batteries, batResult)
suggestion := currentSlotSuggestion(detail, batResp, gridImporting, gridExporting, slotHours)
if suggestion.Action == "" {
continue
}
if detail.Type == batteryTypeBattery {
suggestions[detail.Name] = suggestion
} else if detail.loadpoint != nil {
lpSuggestions[*detail.loadpoint] = suggestion
}
}
site.publish("evopt-batteries", batteries)
site.setBatterySuggestions(suggestions)
site.battery.Forecast = site.addBatteryForecastTotals(req.Batteries, resp.JSON200.Batteries)
site.publish(keys.Battery, site.battery)
site.publishBattery()
// publish for all loadpoints so suggestions of dropped-out loadpoints clear
for id := range site.Loadpoints() {
var val any
if s, ok := lpSuggestions[id]; ok {
val = s
}
site.publishLoadpoint(id, keys.Suggestion, val)
}
return nil
}

View file

@ -163,3 +163,38 @@ func TestOptimizerChargingStrategy(t *testing.T) {
require.NoError(t, site.SetOptimizerChargingStrategy(string(optimizer.OptimizerStrategyChargingStrategyAttenuateGridPeaks)))
assert.Equal(t, "attenuate_grid_peaks", site.GetOptimizerChargingStrategy())
}
func TestCurrentSlotSuggestion(t *testing.T) {
// slotHours 1 makes the per-slot Wh values map 1:1 to W
for _, tc := range []struct {
name string
typ batteryType
charge, disch float32
importing, export bool
want string
}{
{"battery grid charge", batteryTypeBattery, 3000, 0, true, false, "charge"},
{"battery pv charge (no import)", batteryTypeBattery, 3000, 0, false, true, "normal"},
{"battery hold (idle while importing)", batteryTypeBattery, 0, 0, true, false, "hold"},
{"battery holdcharge (idle while exporting)", batteryTypeBattery, 0, 0, false, true, "holdcharge"},
{"battery discharge", batteryTypeBattery, 0, 2000, true, false, "normal"},
{"battery idle balanced", batteryTypeBattery, 0, 0, false, false, "normal"},
{"loadpoint charge", batteryTypeLoadpoint, 11000, 0, false, false, "charge"},
{"loadpoint stop", batteryTypeLoadpoint, 0, 0, false, false, "stop"},
{"vehicle below threshold is stop", batteryTypeVehicle, 40, 0, false, false, "stop"},
} {
t.Run(tc.name, func(t *testing.T) {
res := optimizer.BatteryResult{
ChargingPower: []float32{tc.charge},
DischargingPower: []float32{tc.disch},
}
s := currentSlotSuggestion(batteryDetail{Type: tc.typ}, res, tc.importing, tc.export, 1)
assert.Equal(t, tc.want, s.Action)
assert.InDelta(t, tc.charge, s.Charge, 1e-3)
assert.InDelta(t, tc.disch, s.Discharge, 1e-3)
})
}
// no result yields an empty suggestion
assert.Empty(t, currentSlotSuggestion(batteryDetail{Type: batteryTypeBattery}, optimizer.BatteryResult{}, true, false, 1))
}

View file

@ -8,18 +8,28 @@ import (
// Measurement is the device measurements struct
type Measurement struct {
Name string `json:"name,omitempty"`
Title string `json:"title,omitempty"`
Icon string `json:"icon,omitempty"`
Power float64 `json:"power"`
Energy *float64 `json:"energy,omitempty"`
ReturnEnergy *float64 `json:"returnEnergy,omitempty"`
Powers []float64 `json:"powers,omitempty"`
Currents []float64 `json:"currents,omitempty"`
ExcessDCPower float64 `json:"excessdcpower,omitempty"`
Capacity *float64 `json:"capacity,omitempty"`
Soc *float64 `json:"soc,omitempty"`
Controllable *bool `json:"controllable,omitempty"`
Name string `json:"name,omitempty"`
Title string `json:"title,omitempty"`
Icon string `json:"icon,omitempty"`
Power float64 `json:"power"`
Energy *float64 `json:"energy,omitempty"`
ReturnEnergy *float64 `json:"returnEnergy,omitempty"`
Powers []float64 `json:"powers,omitempty"`
Currents []float64 `json:"currents,omitempty"`
ExcessDCPower float64 `json:"excessdcpower,omitempty"`
Capacity *float64 `json:"capacity,omitempty"`
Soc *float64 `json:"soc,omitempty"`
Controllable *bool `json:"controllable,omitempty"`
Suggestion *Suggestion `json:"suggestion,omitempty"`
}
// Suggestion is the optimizer's advisory action for the current slot
type Suggestion struct {
// Action is the recommended action for the current slot.
// home battery: normal|hold|charge|holdcharge; loadpoint/vehicle: charge|stop
Action string `json:"action"`
Charge float64 `json:"charge"` // recommended charge power, W
Discharge float64 `json:"discharge"` // recommended discharge power, W
}
type BatteryForecast struct {

View file

@ -48,17 +48,16 @@
},
"optimizer": {
"action": {
"charge": "Netzladen",
"hold": "Entladung gesperrt",
"holdcharge": "Laden pausiert",
"normal": "PV-Überschuss folgen"
"charge": "Aus dem Netz laden",
"hold": "Entladen verhindern",
"holdcharge": "Laden verhindern",
"normal": "Normaler Betrieb"
},
"empty": "leer",
"full": "voll",
"highest": "Höchststand ({value})",
"lowest": "Tiefststand ({value})",
"suggestion": "Vorschlag",
"title": "Optimizer"
"suggestion": "Vorschlag"
}
},
"batterySettings": {
@ -1297,6 +1296,8 @@
"heatingStatus": {
"charging": "Heize …",
"connected": "Standby.",
"suggestionStartTooltip": "Der Optimizer empfiehlt, jetzt zu heizen.",
"suggestionStopTooltip": "Der Optimizer empfiehlt, das Heizen zu pausieren.",
"vehicleLimit": "Heizungslimit",
"waitForVehicle": "Bereit zum Heizen …"
},
@ -1487,6 +1488,8 @@
"pvEnable": "Überschuss verfügbar. Ladung wird gleich fortgesetzt.",
"scale1p": "Reduziere gleich auf 1-phasiges Laden.",
"scale3p": "Erhöhe gleich auf 3-phasiges Laden.",
"suggestionStartTooltip": "Der Optimizer empfiehlt, jetzt zu laden.",
"suggestionStopTooltip": "Der Optimizer empfiehlt, das Laden zu pausieren.",
"targetChargeActive": "Ladeplan aktiv. Geschätztes Ende in {duration}.",
"targetChargePlanned": "Ladeplan startet in {duration}.",
"targetChargeWaitForVehicle": "Ladeplan bereit. Warte auf Fahrzeug …",

View file

@ -48,17 +48,16 @@
},
"optimizer": {
"action": {
"charge": "Grid charging",
"hold": "Discharge locked",
"holdcharge": "Charging paused",
"normal": "Following solar surplus"
"charge": "Charge from grid",
"hold": "Prevent discharging",
"holdcharge": "Prevent charging",
"normal": "Normal operation"
},
"empty": "empty",
"full": "full",
"highest": "Highest ({value})",
"lowest": "Lowest ({value})",
"suggestion": "Suggestion",
"title": "Optimizer"
"suggestion": "Suggestion"
}
},
"batterySettings": {
@ -1297,6 +1296,8 @@
"heatingStatus": {
"charging": "Heating…",
"connected": "Standby.",
"suggestionStartTooltip": "Optimizer recommends heating now.",
"suggestionStopTooltip": "Optimizer recommends pausing the heating.",
"vehicleLimit": "Heater limit",
"waitForVehicle": "Ready to heat…"
},
@ -1487,6 +1488,8 @@
"pvEnable": "Surplus available. Starting soon.",
"scale1p": "Reducing to 1-phase charging soon.",
"scale3p": "Increasing to 3-phase charging soon.",
"suggestionStartTooltip": "Optimizer recommends charging now.",
"suggestionStopTooltip": "Optimizer recommends pausing the charge.",
"targetChargeActive": "Charging plan active. Estimated finish in {duration}.",
"targetChargePlanned": "Charging plan starts in {duration}.",
"targetChargeWaitForVehicle": "Charging plan ready. Waiting for vehicle…",

View file

@ -106,32 +106,32 @@ func (suite *mqttSuite) TestNilInterface() {
func (suite *mqttSuite) TestMeasurement() {
topics := lo.Map([]string{
"name", "title", "icon", "power", "energy", "returnEnergy", "powers", "currents", "excessDCPower", "capacity", "soc", "controllable",
"name", "title", "icon", "power", "energy", "returnEnergy", "powers", "currents", "excessDCPower", "capacity", "soc", "controllable", "suggestion",
}, func(s string, _ int) string {
return "test/" + s
})
suite.publish("test", false, types.Measurement{})
suite.Equal(topics, suite.topics, "topics")
suite.Equal([]string{"", "", "", "0", "", "", "", "", "", "", "", ""}, suite.payloads, "empty payloads")
suite.Equal([]string{"", "", "", "0", "", "", "", "", "", "", "", "", ""}, suite.payloads, "empty payloads")
suite.publish("test", false, types.Measurement{Energy: new(1.0)})
suite.Equal(topics, suite.topics, "topics")
suite.Equal([]string{"", "", "", "0", "1", "", "", "", "", "", "", ""}, suite.payloads, "energy payloads")
suite.Equal([]string{"", "", "", "0", "1", "", "", "", "", "", "", "", ""}, suite.payloads, "energy payloads")
suite.publish("test", false, types.Measurement{Controllable: new(false)})
suite.Equal(topics, suite.topics, "topics")
suite.Equal([]string{"", "", "", "0", "", "", "", "", "", "", "", "false"}, suite.payloads, "controllable payloads")
suite.Equal([]string{"", "", "", "0", "", "", "", "", "", "", "", "false", ""}, suite.payloads, "controllable payloads")
suite.publish("test", false, types.Measurement{Currents: []float64{1, 2, 3}})
suite.Equal(append(topics, "test/currents/1", "test/currents/2", "test/currents/3"), suite.topics, "currents topics")
suite.Equal([]string{"", "", "", "0", "", "", "", "3", "", "", "", "", "1", "2", "3"}, suite.payloads, "currents payloads")
suite.Equal([]string{"", "", "", "0", "", "", "", "3", "", "", "", "", "", "1", "2", "3"}, suite.payloads, "currents payloads")
}
func (suite *mqttSuite) TestBatteryState() {
topics := lo.Map([]string{
"power", "energy", "capacity", "soc",
"devices", "devices/1/name", "devices/1/title", "devices/1/icon", "devices/1/power", "devices/1/energy", "devices/1/returnEnergy", "devices/1/powers", "devices/1/currents", "devices/1/excessDCPower", "devices/1/capacity", "devices/1/soc", "devices/1/controllable",
"devices", "devices/1/name", "devices/1/title", "devices/1/icon", "devices/1/power", "devices/1/energy", "devices/1/returnEnergy", "devices/1/powers", "devices/1/currents", "devices/1/excessDCPower", "devices/1/capacity", "devices/1/soc", "devices/1/controllable", "devices/1/suggestion",
"forecast",
}, func(s string, _ int) string {
return "test/" + s
@ -147,5 +147,5 @@ func (suite *mqttSuite) TestBatteryState() {
})
suite.Equal(topics, suite.topics, "topics")
suite.Equal([]string{"2", "", "", "20", "1", "", "", "", "1", "", "", "", "", "", "", "10", "", ""}, suite.payloads, "payloads")
suite.Equal([]string{"2", "", "", "20", "1", "", "", "", "1", "", "", "", "", "", "", "10", "", "", ""}, suite.payloads, "payloads")
}