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") }