diff --git a/assets/js/components/Battery/BatteryStatusCard.vue b/assets/js/components/Battery/BatteryStatusCard.vue
index 0558edd2b..9bd9693da 100644
--- a/assets/js/components/Battery/BatteryStatusCard.vue
+++ b/assets/js/components/Battery/BatteryStatusCard.vue
@@ -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;
diff --git a/assets/js/components/Battery/BatteryStatusCards.stories.ts b/assets/js/components/Battery/BatteryStatusCards.stories.ts
index 373ba533f..2a6dd9de4 100644
--- a/assets/js/components/Battery/BatteryStatusCards.stories.ts
+++ b/assets/js/components/Battery/BatteryStatusCards.stories.ts
@@ -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
diff --git a/assets/js/components/Battery/BatteryStatusCards.vue b/assets/js/components/Battery/BatteryStatusCards.vue
index 9852dd797..e7b21ec09 100644
--- a/assets/js/components/Battery/BatteryStatusCards.vue
+++ b/assets/js/components/Battery/BatteryStatusCards.vue
@@ -2,7 +2,10 @@
-
+
@@ -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
},
batteryMode: String as PropType,
- suggestion: { type: Object as PropType, 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,
});
}
diff --git a/assets/js/components/Battery/OptimizerInfo.vue b/assets/js/components/Battery/OptimizerInfo.vue
index 3855c4396..986968aed 100644
--- a/assets/js/components/Battery/OptimizerInfo.vue
+++ b/assets/js/components/Battery/OptimizerInfo.vue
@@ -31,13 +31,12 @@
diff --git a/assets/js/components/MaterialIcon/OptimizerPause.vue b/assets/js/components/MaterialIcon/OptimizerPause.vue
new file mode 100644
index 000000000..482f4c3a5
--- /dev/null
+++ b/assets/js/components/MaterialIcon/OptimizerPause.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/assets/js/components/Vehicles/Status.test.ts b/assets/js/components/Vehicles/Status.test.ts
index bf0a14e41..0f625adeb 100644
--- a/assets/js/components/Vehicles/Status.test.ts
+++ b/assets/js/components/Vehicles/Status.test.ts
@@ -23,6 +23,7 @@ const allEntries = {
smartcost: false,
planactive: false,
planstart: false,
+ suggestion: false,
};
const expectEntries = (props: InstanceType["$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";
diff --git a/assets/js/components/Vehicles/Status.vue b/assets/js/components/Vehicles/Status.vue
index 82bd29086..3e7167a83 100644
--- a/assets/js/components/Vehicles/Status.vue
+++ b/assets/js/components/Vehicles/Status.vue
@@ -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,
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,
diff --git a/assets/js/components/Vehicles/Vehicle.stories.ts b/assets/js/components/Vehicles/Vehicle.stories.ts
index 5c6256f67..c02664416 100644
--- a/assets/js/components/Vehicles/Vehicle.stories.ts
+++ b/assets/js/components/Vehicles/Vehicle.stories.ts
@@ -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;
+// 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 = (args) => ({
components: { Vehicle },
setup() {
- return { args };
+ return { args, cardStyle };
},
- template: '',
+ template: '
',
});
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,
diff --git a/assets/js/components/Vehicles/Vehicle.vue b/assets/js/components/Vehicles/Vehicle.vue
index 660fc8548..d80b29a87 100644
--- a/assets/js/components/Vehicles/Vehicle.vue
+++ b/assets/js/components/Vehicles/Vehicle.vue
@@ -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,
socBasedPlanning: Boolean,
tariffCo2: Number,
tariffGrid: Number,
diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts
index 401096f91..ee5a17f83 100644
--- a/assets/js/types/evcc.ts
+++ b/assets/js/types/evcc.ts
@@ -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 {
diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go
index 3384ed4fc..5c6fa4706 100644
--- a/core/keys/loadpoint.go
+++ b/core/keys/loadpoint.go
@@ -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
diff --git a/core/site.go b/core/site.go
index ee9beee95..7eead3fcc 100644
--- a/core/site.go
+++ b/core/site.go
@@ -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)
}
diff --git a/core/site_optimizer.go b/core/site_optimizer.go
index ca7105538..1267fab3d 100644
--- a/core/site_optimizer.go
+++ b/core/site_optimizer.go
@@ -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
}
diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go
index 3a42173a1..ca4de29b3 100644
--- a/core/site_optimizer_test.go
+++ b/core/site_optimizer_test.go
@@ -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))
+}
diff --git a/core/types/types.go b/core/types/types.go
index fe3b3e724..8caeb3ed9 100644
--- a/core/types/types.go
+++ b/core/types/types.go
@@ -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 {
diff --git a/i18n/de.json b/i18n/de.json
index 9831ce86e..62dca9ae4 100644
--- a/i18n/de.json
+++ b/i18n/de.json
@@ -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 …",
diff --git a/i18n/en.json b/i18n/en.json
index 4319bbae8..4a7b4d110 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -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…",
diff --git a/server/mqtt_test.go b/server/mqtt_test.go
index e9def0cda..87b05d1e0 100644
--- a/server/mqtt_test.go
+++ b/server/mqtt_test.go
@@ -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")
}