Optimizer: add static grid export limit (#32541)
This commit is contained in:
parent
f2c45b0d84
commit
3093539af5
25 changed files with 373 additions and 44 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@
|
|||
</DeviceModalActions>
|
||||
</template>
|
||||
</form>
|
||||
<slot name="post-content" :values="values"></slot>
|
||||
</GenericModal>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
21
assets/js/components/Helper/SavingIndicator.vue
Normal file
21
assets/js/components/Helper/SavingIndicator.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ const (
|
|||
BufferSoc = "bufferSoc"
|
||||
BufferStartSoc = "bufferStartSoc"
|
||||
|
||||
// grid settings
|
||||
GridExportLimit = "gridExportLimit"
|
||||
|
||||
// forecast settings
|
||||
SolarAdjusted = "solarAdjusted"
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ type API interface {
|
|||
GetGridPower() float64
|
||||
GetResidualPower() float64
|
||||
SetResidualPower(float64) error
|
||||
GetGridExportLimit() float64
|
||||
SetGridExportLimit(float64) error
|
||||
|
||||
//
|
||||
// tariffs and costs
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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)},
|
||||
|
|
|
|||
24
server/mcp/openapi.json
generated
24
server/mcp/openapi.json
generated
|
|
@ -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
20
server/mcp/openapi.md
generated
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
3
server/openapi.state.yaml
generated
3
server/openapi.state.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue