Optimizer: add static grid export limit (#32541)

This commit is contained in:
andig 2026-08-07 12:58:14 +02:00 • committed by GitHub
parent f2c45b0d84
commit 3093539af5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 373 additions and 44 deletions

View file

@ -586,6 +586,16 @@ small {
.dark .form-switch .form-check-input:checked {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2328293e'/%3e%3c/svg%3e");
}
/* hide number input spinners */
input[type="number"] {
appearance: textfield;
}
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* fix desktop safari formatting */
input::-webkit-datetime-edit {
display: block;
@ -873,6 +883,12 @@ input::-webkit-date-and-time-value {
visibility var(--evcc-transition-fast) ease var(--evcc-transition-fast);
}
/* clearance so overflow-hidden containers don't clip focus rings */
.ring-space {
padding: 0.5rem;
margin: -0.5rem;
}
.sticky-bottom {
bottom: calc(var(--tab-bar-height) + 0.25rem);
}

View file

@ -207,11 +207,6 @@ export default defineComponent({
</script>
<style scoped>
/* overflow-hidden would clip the select focus rings */
.ring-space {
padding: 0.5rem;
margin: -0.5rem;
}
.icon {
transform: rotate(0deg);
transition: transform var(--evcc-transition-medium) ease;

View file

@ -202,6 +202,7 @@
</DeviceModalActions>
</template>
</form>
<slot name="post-content" :values="values"></slot>
</GenericModal>
</template>

View file

@ -10,13 +10,14 @@
:is-yaml-input-type="isYamlInputType"
:on-template-change="handleTemplateChange"
:hide-template-fields="fromYaml"
:show-main-content="!experimental || activeTab === 'dynamic'"
:hide-delete="true"
:hide-info="true"
:keep-open-on-remove="true"
@added="onAdded"
@updated="onUpdated"
@removed="onRemoved"
@open="loadSessions"
@open="onOpen"
@close="$emit('close')"
>
<template v-if="id !== undefined && !fromYaml" #template-action>
@ -31,13 +32,37 @@
<shopicon-regular-edit size="s" class="flex-shrink-0"></shopicon-regular-edit>
</button>
</template>
<template #description>
<template #pre-content>
<p class="mt-0 mb-4">
{{ $t("config.hems.description") }}
<a :href="docsLink" target="_blank" rel="noopener">
{{ $t("config.general.docsLink") }}
</a>
</p>
<ul v-if="experimental" class="nav nav-tabs mb-4">
<li class="nav-item">
<a
class="nav-link"
:class="{ active: activeTab === 'dynamic' }"
href="#"
@click.prevent="activeTab = 'dynamic'"
>
{{ $t("config.hems.dynamicLimits") }}
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
:class="{ active: activeTab === 'static' }"
href="#"
@click.prevent="activeTab = 'static'"
>
{{ $t("config.hems.staticLimits") }} 🧪
</a>
</li>
</ul>
</template>
<template #description>
<div v-if="configured" class="mb-4" data-testid="grid-sessions">
<h6 class="mb-3">{{ $t("config.hems.recordedEvents") }}</h6>
<div class="events-box rounded p-3">
@ -60,6 +85,56 @@
{{ $t("config.general.fromYamlHint") }}
</p>
</template>
<template v-if="experimental && activeTab === 'static'" #post-content>
<div data-testid="grid-export-limit">
<div class="form-check form-switch">
<input
id="hemsExportLimitEnabled"
class="form-check-input"
type="checkbox"
role="switch"
:checked="exportLimitEnabled"
:disabled="exportLimitState === 'saving'"
@change="toggleExportLimit"
/>
<label for="hemsExportLimitEnabled" class="form-check-label">
{{ $t("config.hems.exportLimit") }}
</label>
<div class="ps-2">
<p class="text-muted small mb-2">
{{ $t("config.hems.exportLimitHint") }}
</p>
<div class="collapsible-wrapper" :class="{ open: exportLimitEnabled }">
<div class="collapsible-content ring-space">
<div class="d-flex align-items-center gap-2">
<div class="input-group input-width">
<input
id="hemsExportLimit"
v-model.number="exportLimit"
type="number"
min="0"
:aria-label="$t('config.hems.exportLimit')"
aria-describedby="hemsExportLimitUnit"
class="form-control text-end"
@input="onExportLimitInput"
@blur="commitExportLimit"
@keydown.enter.prevent="commitExportLimit"
/>
<span id="hemsExportLimitUnit" class="input-group-text">
W
</span>
</div>
<SavingIndicator :state="exportLimitState" />
</div>
<div class="form-text evcc-gray mb-2">
{{ $t("config.hems.exportLimitDescription") }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</DeviceModalBase>
</template>
@ -73,13 +148,17 @@ import { customTemplateOption, type TemplateGroup } from "./DeviceModal/Template
import customHemsYaml from "./defaultYaml/customHems.yaml?raw";
import relayHemsYaml from "./defaultYaml/relayHems.yaml?raw";
import api from "../../api";
import store from "@/store";
import { docsPrefix } from "@/i18n";
import DownloadButton from "../Helper/DownloadButton.vue";
import SavingIndicator, { type SavingState } from "../Helper/SavingIndicator.vue";
import formatter from "../../mixins/formatter";
// selector value for the relay variant; both variants save as type custom
const RELAY_OPTION = "relay";
let exportLimitTimer: ReturnType<typeof setTimeout> | undefined;
const initialValues = {
type: ConfigType.Template,
icon: undefined,
@ -90,7 +169,7 @@ const initialValues = {
export default defineComponent({
name: "HemsModal",
components: { DeviceModalBase, DownloadButton },
components: { DeviceModalBase, DownloadButton, SavingIndicator },
mixins: [formatter],
props: {
yamlSource: String as PropType<YamlSource>,
@ -102,6 +181,10 @@ export default defineComponent({
initialValues,
sessions: [] as Array<{ created: string }>,
changing: false,
exportLimit: null as number | null,
exportLimitEnabled: false,
exportLimitState: null as SavingState,
activeTab: "dynamic" as "dynamic" | "static",
};
},
computed: {
@ -127,8 +210,55 @@ export default defineComponent({
docsLink(): string {
return `${docsPrefix()}/docs/external-limit`;
},
experimental(): boolean {
return store.state?.experimental === true;
},
serverExportLimit(): number {
return store.state?.gridExportLimit || 0;
},
exportLimitUnchanged(): boolean {
return (this.exportLimit || 0) === this.serverExportLimit;
},
},
beforeUnmount() {
clearTimeout(exportLimitTimer);
},
methods: {
onOpen() {
this.exportLimit = this.serverExportLimit || null;
this.exportLimitEnabled = this.serverExportLimit > 0;
this.exportLimitState = null;
this.activeTab = "dynamic";
this.loadSessions();
},
onExportLimitInput() {
clearTimeout(exportLimitTimer);
exportLimitTimer = setTimeout(this.commitExportLimit, 2000);
},
commitExportLimit() {
clearTimeout(exportLimitTimer);
if (this.exportLimitState === "saving" || this.exportLimitUnchanged) return;
this.postExportLimit(this.exportLimit || 0);
},
toggleExportLimit($event: Event) {
const { checked } = $event.target as HTMLInputElement;
this.exportLimitEnabled = checked;
this.exportLimitState = null;
if (!checked) {
this.exportLimit = null;
this.commitExportLimit();
}
},
async postExportLimit(value: number) {
this.exportLimitState = "saving";
try {
await api.post(`gridexportlimit/${value}`);
this.exportLimitState = "saved";
} catch (e) {
this.exportLimitState = null;
console.error(e);
}
},
downloadHref(): string {
const params = new URLSearchParams({ lang: this.$i18n?.locale });
return `./api/gridsessions?${params.toString()}`;
@ -198,4 +328,7 @@ export default defineComponent({
.events-box {
background: var(--evcc-background);
}
.input-width {
max-width: 180px;
}
</style>

View file

@ -409,14 +409,6 @@ export default {
</script>
<style scoped>
input[type="number"] {
appearance: textfield;
}
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.w-min-100 {
min-width: min(100px, 100%);
}

View file

@ -216,16 +216,4 @@ export default {
min-width: min(200px, 100%);
}
/* Hide spinner for number input */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
</style>

View file

@ -0,0 +1,21 @@
<template>
<div class="small">
<span v-if="state === 'saving'">{{ $t("config.general.saving") }}</span>
<span v-else-if="state === 'saved'" class="text-primary">
{{ $t("config.general.saved") }}
</span>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
export type SavingState = "saving" | "saved" | null;
export default defineComponent({
name: "SavingIndicator",
props: {
state: { type: String as PropType<SavingState>, default: null },
},
});
</script>

View file

@ -367,14 +367,5 @@ export default defineComponent({
.odometer-input .form-control {
/* fit ~7 digits (e.g. 128222) */
width: calc(7ch + 1rem);
-moz-appearance: textfield;
appearance: textfield;
}
.odometer-input .form-control::-webkit-inner-spin-button,
.odometer-input .form-control::-webkit-outer-spin-button {
-webkit-appearance: none;
appearance: none;
margin: 0;
}
</style>

View file

@ -158,11 +158,6 @@ export default defineComponent({
</script>
<style scoped>
/* overflow-hidden would clip the vehicle select focus ring */
.ring-space {
padding: 0.5rem;
margin: -0.5rem;
}
.vehicle-name {
text-decoration-color: var(--evcc-gray);
}

View file

@ -210,6 +210,8 @@ export interface State {
homePower?: number;
/** Configured grid operating point in W. Positive values maintain grid import. */
residualPower?: number;
/** Static grid export power limit in W used as optimizer constraint, 0 = disabled. An active HEMS curtailment takes precedence. */
gridExportLimit?: number;
/** Share of green energy in home consumption, between 0 and 1. */
greenShareHome?: number;
/** Share of green energy used for charging, between 0 and 1. */

View file

@ -49,6 +49,9 @@ const (
BufferSoc = "bufferSoc"
BufferStartSoc = "bufferStartSoc"
// grid settings
GridExportLimit = "gridExportLimit"
// forecast settings
SolarAdjusted = "solarAdjusted"

View file

@ -88,6 +88,9 @@ type Site struct {
batteryGridChargeLimit *float64 // grid charging limit
batteryGridDischarge bool // allow battery discharge to grid (experimental)
// grid settings
gridExportLimit float64 // static grid export power limit in W, 0 = disabled
// forecast settings
solarAdjusted bool // adjust solar forecast to real production data
@ -396,6 +399,11 @@ func (site *Site) restoreSettings() error {
return err
}
}
if v, err := settings.Float(keys.GridExportLimit); err == nil {
if err := site.SetGridExportLimit(v); err != nil {
return err
}
}
if v, err := settings.Bool(keys.SolarAdjusted); err == nil {
site.SetSolarAdjusted(v)
}
@ -1218,6 +1226,7 @@ func (site *Site) prepare() {
site.publish(keys.BatteryGridDischarge, site.batteryGridDischarge)
site.publish(keys.SolarAdjusted, site.solarAdjusted)
site.publish(keys.ResidualPower, site.GetResidualPower())
site.publish(keys.GridExportLimit, site.GetGridExportLimit())
site.publish(keys.SmartCostAvailable, site.isDynamicTariff(api.TariffUsagePlanner))
site.publish(keys.SmartFeedInPriorityAvailable, site.isDynamicTariff(api.TariffUsageFeedIn))

View file

@ -68,6 +68,8 @@ type API interface {
GetGridPower() float64
GetResidualPower() float64
SetResidualPower(float64) error
GetGridExportLimit() float64
SetGridExportLimit(float64) error
//
// tariffs and costs

View file

@ -336,6 +336,38 @@ func (site *Site) SetResidualPower(power float64) error {
return nil
}
// GetGridExportLimit returns the static grid export power limit in W (0 = disabled)
func (site *Site) GetGridExportLimit() float64 {
site.RLock()
defer site.RUnlock()
return site.gridExportLimit
}
// SetGridExportLimit sets the static grid export power limit in W (0 = disabled)
func (site *Site) SetGridExportLimit(power float64) error {
if power < 0 {
return fmt.Errorf("invalid grid export limit: %g", power)
}
site.Lock()
changed := site.gridExportLimit != power
if changed {
site.gridExportLimit = power
}
site.Unlock()
if changed {
site.log.DEBUG.Println("set grid export limit:", power)
settings.SetFloat(keys.GridExportLimit, power)
site.publish(keys.GridExportLimit, power)
// re-run the optimizer so the new limit takes effect immediately
site.triggerOptimizer()
}
return nil
}
// GetTariff returns the respective tariff if configured or nil
func (site *Site) GetTariff(tariff api.TariffUsage) api.Tariff {
site.RLock()

View file

@ -486,8 +486,14 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
}
}
// soft grid feed-in cap from active HEMS curtailment (e.g. German 70% rule):
// export is capped at this power, excess PV is curtailed instead of exported
// static grid export limit configured in the UI: export is capped at this
// power, excess PV is curtailed instead of exported
if limit := site.GetGridExportLimit(); limit > 0 {
req.Grid.PMaxExp = float32(limit)
}
// soft grid feed-in cap from active HEMS curtailment (e.g. German 70% rule)
// wins over the static limit while active
if curtailed := hems.Curtailed(site.hems); curtailed != nil && *curtailed {
if pMaxExp := site.hems.MaxProductionPower(); pMaxExp != nil {
req.Grid.PMaxExp = float32(*pMaxExp)

View file

@ -274,6 +274,20 @@ func TestOptimizerChargingStrategy(t *testing.T) {
assert.Equal(t, "attenuate_grid_peaks", site.GetOptimizerChargingStrategy())
}
func TestGridExportLimit(t *testing.T) {
site := &Site{log: util.NewLogger("foo")}
// disabled by default
assert.Equal(t, 0.0, site.GetGridExportLimit())
// negative value rejected, limit unchanged
require.Error(t, site.SetGridExportLimit(-1))
assert.Equal(t, 0.0, site.GetGridExportLimit())
require.NoError(t, site.SetGridExportLimit(7000))
assert.Equal(t, 7000.0, site.GetGridExportLimit())
}
func TestBlendMeasured(t *testing.T) {
slots := []float64{100, 100, 100, 100, 100, 100}
blendMeasured(slots, 200, 4)

View file

@ -371,12 +371,17 @@
"customOption": "Benutzerdefinierte Integration",
"description": "Leistungsbegrenzung durch externe Systeme (z.B. §14a EnWG, §9 EEG Schnittstelle oder übergeordnetes Energiemanagementsystem). Spielt mit dem Lastmanagement Feature zusammen. Das Abregeln der Solarproduktion wird ebenfalls für ausgewählte Geräte unterstützt. Der konkrete Abregelungswert muss in der jeweiligen Wechselrichter-Konfiguration eingestellt werden.",
"download": "Download",
"dynamicLimits": "Dynamische Limits",
"eventsRecorded": "{count} Netzbegrenzungen gespeichert.",
"exportLimit": "Einspeiselimit",
"exportLimitDescription": "Der Batterie-Optimierer berücksichtigt dieses Limit um Solarverluste zu vermeiden. Eine aktive Abregelung durch eine Integration der dynamischen Limits hat Vorrang.",
"exportLimitHint": "Statische Einspeisegrenze am Netzanschlusspunkt.",
"generic": "Generische Integrationen",
"integrations": "Integrationen",
"lastEvent": "Letzter Eintrag {timeAgo}.",
"noEvents": "Es wurden noch keine Ereignisse aufgezeichnet.",
"recordedEvents": "Aufgezeichnete Ereignisse",
"staticLimits": "Statische Limits",
"template": "Integration",
"title": "Externe Begrenzung",
"type": {

View file

@ -371,12 +371,17 @@
"customOption": "User-defined integration",
"description": "Power limitation by external systems (e.g. §14a EnWG, §9 EEG interface or higher-level energy management system). Works together with the load management feature. Curtailing solar production is also supported for selected devices. The actual curtailment limit must be set on each inverter configuration.",
"download": "Download",
"dynamicLimits": "Dynamic limits",
"eventsRecorded": "Recorded {count} grid limitation events.",
"exportLimit": "Grid export limit",
"exportLimitDescription": "The battery optimizer respects this limit to avoid solar losses. An active curtailment signal from a dynamic limit integration takes precedence.",
"exportLimitHint": "Static feed-in limit at the grid connection point.",
"generic": "Generic integrations",
"integrations": "Integrations",
"lastEvent": "Most recent {timeAgo}.",
"noEvents": "No events recorded yet.",
"recordedEvents": "Recorded events",
"staticLimits": "Static limits",
"template": "Integration",
"title": "External Limit",
"type": {

View file

@ -151,6 +151,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) {
"batterymodedelete": {"DELETE", "/batterymode", updateBatteryMode(site)},
"prioritysoc": {"POST", "/prioritysoc/{value:[0-9.]+}", floatHandler(site.SetPrioritySoc, site.GetPrioritySoc)},
"residualpower": {"POST", "/residualpower/{value:-?[0-9.]+}", floatHandler(site.SetResidualPower, site.GetResidualPower)},
"gridexportlimit": {"POST", "/gridexportlimit/{value:[0-9.]+}", floatHandler(site.SetGridExportLimit, site.GetGridExportLimit)},
"solaradjusted": {"POST", "/solaradjusted/{value:[01truefalse]+}", boolHandler(pass(site.SetSolarAdjusted), site.GetSolarAdjusted)},
"smartcost": {"POST", "/smartcostlimit/{value:-?[0-9.]+}", updateSmartCostLimit(site, smartCostLimit)},
"smartcostdelete": {"DELETE", "/smartcostlimit", updateSmartCostLimit(site, smartCostLimit)},

View file

@ -1258,6 +1258,26 @@
}
}
},
"/gridexportlimit/{power}": {
"post": {
"operationId": "setGridExportLimit",
"summary": "Set grid export limit",
"description": "Set the static grid export power limit in W used as optimizer constraint. 0 disables the limit. An active HEMS curtailment takes precedence.",
"tags": [
"general"
],
"parameters": [
{
"$ref": "#/components/parameters/power"
}
],
"responses": {
"200": {
"$ref": "#/components/responses/NumberResult"
}
}
}
},
"/solaradjusted/{enable}": {
"post": {
"operationId": "setSolarAdjusted",
@ -4842,6 +4862,10 @@
"description": "Configured grid operating point in W. Positive values maintain grid import.",
"type": "number"
},
"gridExportLimit": {
"description": "Static grid export power limit in W used as optimizer constraint, 0 = disabled. An active HEMS curtailment takes precedence.",
"type": "number"
},
"greenShareHome": {
"description": "Share of green energy in home consumption, between 0 and 1.",
"type": "number"

20
server/mcp/openapi.md generated
View file

@ -436,6 +436,26 @@ call setGlobalSmartFeedInPriorityLimit {
}
```
## setGridExportLimit
Set the static grid export power limit in W used as optimizer constraint. 0 disables the limit. An active HEMS curtailment takes precedence.
**Tags:** general
**Arguments:**
| Name | Type | Description |
|------|------|-------------|
| power | number | Power in W |
**Example call:**
```json
call setGridExportLimit {
"power": 2500
}
```
## assignLoadpointVehicle
Assigns vehicle to loadpoint.

View file

@ -218,6 +218,7 @@ func (m *MQTT) listenSiteSetters(topic string, site site.API) error {
{"batteryGridDischarge", boolSetter(site.SetBatteryGridDischarge)},
{"prioritySoc", floatSetter(site.SetPrioritySoc)},
{"residualPower", floatSetter(site.SetResidualPower)},
{"gridExportLimit", floatSetter(site.SetGridExportLimit)},
{"solarAdjusted", boolSetter(pass(site.SetSolarAdjusted))},
{"smartCostLimit", floatPtrSetter(pass(func(limit *float64) {
for _, lp := range site.Loadpoints() {

View file

@ -100,6 +100,9 @@ components:
residualPower:
description: Configured grid operating point in W. Positive values maintain grid import.
type: number
gridExportLimit:
description: Static grid export power limit in W used as optimizer constraint, 0 = disabled. An active HEMS curtailment takes precedence.
type: number
greenShareHome:
description: Share of green energy in home consumption, between 0 and 1.
type: number

View file

@ -752,6 +752,18 @@ paths:
responses:
"200":
$ref: "#/components/responses/NumberResult"
/gridexportlimit/{power}:
post:
operationId: setGridExportLimit
summary: Set grid export limit
description: "Set the static grid export power limit in W used as optimizer constraint. 0 disables the limit. An active HEMS curtailment takes precedence."
tags:
- general
parameters:
- $ref: "#/components/parameters/power"
responses:
"200":
$ref: "#/components/responses/NumberResult"
/solaradjusted/{enable}:
post:
operationId: setSolarAdjusted

View file

@ -112,6 +112,64 @@ maxconsumptionpower:
await expectModalHidden(hemsModal);
});
test("grid export limit", async ({ page }) => {
await start(CONFIG);
await page.goto("/#/config");
const hemsModal = page.getByTestId("hems-modal");
const section = hemsModal.getByTestId("grid-export-limit");
// hidden without experimental
await page.getByTestId("hems").getByRole("button", { name: "edit" }).click();
await expectModalVisible(hemsModal);
await expect(section).not.toBeVisible();
await hemsModal.getByRole("button", { name: "Close" }).click();
await expectModalHidden(hemsModal);
// enable experimental
await page.getByTestId("generalconfig-experimental").getByRole("button", { name: "edit" }).click();
const experimentalModal = page.getByTestId("experimental-modal");
await expectModalVisible(experimentalModal);
await experimentalModal.getByLabel("Enable experimental features.").click();
await experimentalModal.getByRole("button", { name: "Close" }).click();
await expectModalHidden(experimentalModal);
await page.getByTestId("hems").getByRole("button", { name: "edit" }).click();
await expectModalVisible(hemsModal);
const toggle = section.getByRole("switch", { name: "Grid export limit" });
const input = section.getByRole("spinbutton", { name: "Grid export limit" });
const staticTab = hemsModal.getByRole("link", { name: "Static limits" });
await staticTab.click();
// initially off, input collapsed
await expect(toggle).not.toBeChecked();
await expect(input).not.toBeVisible();
// enable, set value, commit via Enter
await toggle.click();
await expect(input).toBeVisible();
await input.fill("7000");
await input.press("Enter");
await expect(section.getByText("Saved.")).toBeVisible();
await hemsModal.getByRole("button", { name: "Close" }).click();
await expectModalHidden(hemsModal);
// persisted across restart
await restart(CONFIG);
await page.reload();
await page.getByTestId("hems").getByRole("button", { name: "edit" }).click();
await expectModalVisible(hemsModal);
await staticTab.click();
await expect(toggle).toBeChecked();
await expect(input).toHaveValue("7000");
// switch off removes limit, collapse is the feedback
await toggle.click();
await expect(input).not.toBeVisible();
});
test("user-defined relay drives external limit without circuits", async ({ page }) => {
const GRID_CONFIG = "hems-grid.evcc.yaml";
await startSimulator();