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. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue