Curtailment devices: new device class for feed-in limitation (BC) (#32416)
This commit is contained in:
parent
7c0ddf1828
commit
8661c66fc7
40 changed files with 819 additions and 149 deletions
97
assets/js/components/Config/CurtailerModal.vue
Normal file
97
assets/js/components/Config/CurtailerModal.vue
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<template>
|
||||
<DeviceModalBase
|
||||
:id="id"
|
||||
name="curtailer"
|
||||
device-type="curtailer"
|
||||
:modal-title="$t(`config.curtailer.${isNew ? 'titleAdd' : 'titleEdit'}`)"
|
||||
:provide-template-options="provideTemplateOptions"
|
||||
:initial-values="initialValues"
|
||||
:on-template-change="handleTemplateChange"
|
||||
:preserve-on-template-change="preserveFields"
|
||||
@added="(name) => $emit('changed', { action: 'added', name })"
|
||||
@updated="$emit('changed', { action: 'updated' })"
|
||||
@removed="$emit('changed', { action: 'removed' })"
|
||||
>
|
||||
<template #description>
|
||||
<p class="mt-0 mb-4">{{ $t("config.curtailer.description") }}</p>
|
||||
</template>
|
||||
|
||||
<template #before-template="{ values }">
|
||||
<FormRow id="curtailerParamDeviceTitle" :label="$t('config.general.title')">
|
||||
<PropertyField
|
||||
id="curtailerParamDeviceTitle"
|
||||
v-model.trim="values.deviceTitle"
|
||||
type="String"
|
||||
size="w-100"
|
||||
class="me-2"
|
||||
required
|
||||
/>
|
||||
</FormRow>
|
||||
</template>
|
||||
</DeviceModalBase>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import DeviceModalBase from "./DeviceModal/DeviceModalBase.vue";
|
||||
import FormRow from "./FormRow.vue";
|
||||
import PropertyField from "./PropertyField.vue";
|
||||
import type { DeviceValues, Product } from "./DeviceModal";
|
||||
import { type TemplateGroup, customTemplateOption } from "./DeviceModal/TemplateSelector.vue";
|
||||
import { ConfigType } from "@/types/evcc";
|
||||
import defaultCurtailerYaml from "./defaultYaml/curtailer.yaml?raw";
|
||||
import { getModal } from "@/configModal";
|
||||
|
||||
const initialValues = {
|
||||
type: ConfigType.Template,
|
||||
deviceTitle: "",
|
||||
template: null,
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: "CurtailerModal",
|
||||
components: {
|
||||
DeviceModalBase,
|
||||
FormRow,
|
||||
PropertyField,
|
||||
},
|
||||
emits: ["changed"],
|
||||
data() {
|
||||
return {
|
||||
initialValues,
|
||||
preserveFields: ["deviceTitle"],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
id(): number | undefined {
|
||||
return getModal("curtailer")?.id;
|
||||
},
|
||||
isNew(): boolean {
|
||||
return this.id === undefined;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
provideTemplateOptions(products: Product[]): TemplateGroup[] {
|
||||
return [
|
||||
{
|
||||
label: "generic",
|
||||
options: [
|
||||
...products.filter((p: Product) => p.group === "generic"),
|
||||
customTemplateOption(this.$t("config.general.customOption")),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "specific",
|
||||
options: products.filter((p: Product) => p.group !== "generic"),
|
||||
},
|
||||
];
|
||||
},
|
||||
handleTemplateChange(value: string, values: DeviceValues) {
|
||||
if (value === ConfigType.Custom) {
|
||||
values.type = ConfigType.Custom;
|
||||
values.yaml = defaultCurtailerYaml;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
tabindex="0"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<shopicon-regular-plus class="me-1"></shopicon-regular-plus>
|
||||
<shopicon-regular-plus class="me-1 flex-shrink-0"></shopicon-regular-plus>
|
||||
<span class="text-start">{{ title }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
|
|
|||
28
assets/js/components/Config/defaultYaml/curtailer.yaml
Normal file
28
assets/js/components/Config/defaultYaml/curtailer.yaml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
## required attributes
|
||||
# feed-in limit in percent of nominal production power (0..100, 100 = uncurtailed)
|
||||
curtail: # set feed-in limit
|
||||
source: js
|
||||
script: console.log(curtail)
|
||||
curtailed: # current feed-in limit
|
||||
source: const
|
||||
value: 100
|
||||
|
||||
## alternative examples
|
||||
|
||||
#curtail:
|
||||
# source: http
|
||||
# uri: http://inverter.local/api/limit?percent={{ .curtail }}
|
||||
# method: POST
|
||||
#curtailed:
|
||||
# source: http
|
||||
# uri: http://inverter.local/api/limit
|
||||
# jq: .percent
|
||||
|
||||
#curtail:
|
||||
# source: modbus
|
||||
# uri: 192.168.179.200:502
|
||||
# id: 1
|
||||
# register: { type: writesingle, address: 40016, encoding: uint16 }
|
||||
#curtailed:
|
||||
# source: error
|
||||
# error: ErrNotAvailable
|
||||
18
assets/js/components/MaterialIcon/Curtail.vue
Normal file
18
assets/js/components/MaterialIcon/Curtail.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<template>
|
||||
<svg :style="svgStyle" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12.825 15.8q.75-.35 1.113-1.2t-.163-1.6q-1.1-1.575-2.212-3.062T9.3 6.95q-.275-.35-.687-.162t-.313.637q.4 1.825.85 3.625t1 3.65q.25.875 1.038 1.163t1.637-.063m-7.7 4.2q-.525 0-1.012-.238t-.738-.712q-.7-1.15-1.025-2.437T2.025 14q0-1.725.588-3.463t1.862-3.112q.375-.425.8-.412t.7.262t.35.625t-.225.725q-.95 1.05-1.512 2.475T4.025 14q0 1.025.263 2.038T5.125 18H18.9q.525-.9.813-1.925T20 14q0-1.875-.812-3.475T17.025 7.8q-1.05-.875-2.375-1.35T11.95 6q-.4 0-.712-.262t-.363-.663t.15-.725t.6-.35q1.825-.075 3.563.525T18.35 6.3q1.675 1.4 2.663 3.375T22 14q0 1.4-.35 2.663t-1 2.387q-.275.475-.75.713t-1 .237zm6.9-6.025"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import icon from "@/mixins/icon";
|
||||
|
||||
export default defineComponent({
|
||||
name: "Curtail",
|
||||
mixins: [icon],
|
||||
});
|
||||
</script>
|
||||
|
|
@ -460,6 +460,10 @@ export enum ConfigType {
|
|||
export type ConfigVehicle = Entity;
|
||||
export type ConfigMessenger = Entity;
|
||||
|
||||
export interface ConfigCurtailer extends Entity {
|
||||
deviceTitle?: string;
|
||||
}
|
||||
|
||||
export interface ConfigHems extends Entity {
|
||||
deviceProduct?: string;
|
||||
}
|
||||
|
|
@ -1368,7 +1372,8 @@ export type DeviceType =
|
|||
| "loadpoint"
|
||||
| "messenger"
|
||||
| "tariff"
|
||||
| "hems";
|
||||
| "hems"
|
||||
| "curtailer";
|
||||
export type MeterType = "grid" | "pv" | "battery" | "charge" | "aux" | "ext" | "consumer";
|
||||
export type MeterTemplateUsage = "grid" | "pv" | "battery" | "charge" | "aux";
|
||||
export type TariffType = "grid" | "feedIn" | "co2" | "planner" | "solar" | "temperature";
|
||||
|
|
@ -1385,6 +1390,7 @@ export interface SiteConfig {
|
|||
aux: string[] | null;
|
||||
ext: string[] | null;
|
||||
consumer: string[] | null;
|
||||
curtail: string[] | null;
|
||||
}
|
||||
|
||||
export type ValueOf<T> = T[keyof T];
|
||||
|
|
|
|||
|
|
@ -150,6 +150,31 @@
|
|||
data-testid="add-grid"
|
||||
@click="openModal('meter', { type: 'grid' })"
|
||||
/>
|
||||
<DeviceCard
|
||||
v-for="curtailer in curtailerDevices"
|
||||
:id="`curtailer_${curtailer.name}`"
|
||||
:key="curtailer.name"
|
||||
:name="curtailer.name"
|
||||
:title="curtailerTitle(curtailer)"
|
||||
:editable="!!curtailer.id"
|
||||
:error="hasDeviceError('curtailer', curtailer.name)"
|
||||
:banner="curtailmentBanner('curtailer', curtailer.name)"
|
||||
data-testid="curtailer"
|
||||
@edit="openModal('curtailer', { id: curtailer.id })"
|
||||
>
|
||||
<template #icon>
|
||||
<CurtailIcon />
|
||||
</template>
|
||||
<template #tags>
|
||||
<DeviceTags :tags="deviceTags('curtailer', curtailer.name)" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<NewDeviceButton
|
||||
v-if="pvMeters.length > 0"
|
||||
:title="$t('config.main.addCurtailer')"
|
||||
data-testid="add-curtailer"
|
||||
@click="openModal('curtailer')"
|
||||
/>
|
||||
</div>
|
||||
</ConfigSection>
|
||||
|
||||
|
|
@ -162,7 +187,7 @@
|
|||
meter-type="pv"
|
||||
:has-error="hasDeviceError('meter', meter.name)"
|
||||
:tags="deviceTags('meter', meter.name)"
|
||||
:banner="meterBanner(meter.name)"
|
||||
:banner="curtailmentBanner('meter', meter.name)"
|
||||
@edit="(type, id) => openModal('meter', { type, id })"
|
||||
@enable="handleDisable('meter', meter.id, false)"
|
||||
/>
|
||||
|
|
@ -525,6 +550,7 @@
|
|||
<MessagingLegacyModal @changed="loadDirty" />
|
||||
<MessagingModal :messengers="messengers" @changed="loadDirty" />
|
||||
<MessengerModal @changed="messengerChanged" />
|
||||
<CurtailerModal @changed="curtailerChanged" />
|
||||
<TariffsLegacyModal @changed="loadDirty" />
|
||||
<TariffModal
|
||||
:currency="currency"
|
||||
|
|
@ -579,6 +605,8 @@ import CircuitsModal from "../components/Config/CircuitsModal.vue";
|
|||
import CircuitTags from "../components/Config/CircuitTags.vue";
|
||||
import collector from "../mixins/collector";
|
||||
import ControlModal from "../components/Config/ControlModal.vue";
|
||||
import CurtailerModal from "../components/Config/CurtailerModal.vue";
|
||||
import CurtailIcon from "../components/MaterialIcon/Curtail.vue";
|
||||
import DeviceCard from "../components/Config/DeviceCard.vue";
|
||||
import DeviceTags from "../components/Config/DeviceTags.vue";
|
||||
import EebusIcon from "../components/MaterialIcon/Eebus.vue";
|
||||
|
|
@ -633,6 +661,7 @@ import type {
|
|||
ConfigCharger,
|
||||
ConfigVehicle,
|
||||
ConfigCircuit,
|
||||
ConfigCurtailer,
|
||||
ConfigMessenger,
|
||||
ConfigHems,
|
||||
ConfigLoadpoint,
|
||||
|
|
@ -691,6 +720,8 @@ export default defineComponent({
|
|||
CircuitsModal,
|
||||
CircuitTags,
|
||||
ControlModal,
|
||||
CurtailerModal,
|
||||
CurtailIcon,
|
||||
DeviceCard,
|
||||
DeviceTags,
|
||||
EebusIcon,
|
||||
|
|
@ -749,6 +780,7 @@ export default defineComponent({
|
|||
data() {
|
||||
return {
|
||||
messengers: [] as ConfigMessenger[],
|
||||
curtailers: [] as ConfigCurtailer[],
|
||||
vehicles: [] as ConfigVehicle[],
|
||||
meters: [] as ConfigMeter[],
|
||||
loadpoints: [] as ConfigLoadpoint[],
|
||||
|
|
@ -772,6 +804,7 @@ export default defineComponent({
|
|||
aux: null as string[] | null,
|
||||
ext: null as string[] | null,
|
||||
consumer: null as string[] | null,
|
||||
curtail: null as string[] | null,
|
||||
} as SiteConfig,
|
||||
deviceValueTimeout: null as Timeout,
|
||||
deviceValues: {
|
||||
|
|
@ -781,6 +814,7 @@ export default defineComponent({
|
|||
loadpoint: {},
|
||||
messenger: {},
|
||||
tariff: {},
|
||||
curtailer: {},
|
||||
} as DeviceValuesMap,
|
||||
isComponentMounted: true,
|
||||
isPageVisible: true,
|
||||
|
|
@ -868,8 +902,10 @@ export default defineComponent({
|
|||
{
|
||||
slug: "grid",
|
||||
icon: "shopicon-regular-powersupply",
|
||||
count: this.gridMeter ? 1 : 0,
|
||||
error: !!this.gridMeter && this.hasDeviceError("meter", this.gridMeter.name),
|
||||
count: (this.gridMeter ? 1 : 0) + this.curtailerDevices.length,
|
||||
error:
|
||||
(!!this.gridMeter && this.hasDeviceError("meter", this.gridMeter.name)) ||
|
||||
this.curtailerDevices.some((c) => this.hasDeviceError("curtailer", c.name)),
|
||||
},
|
||||
{
|
||||
slug: "pv-battery",
|
||||
|
|
@ -877,7 +913,7 @@ export default defineComponent({
|
|||
count: pvAndBattery.length,
|
||||
error: meterError(pvAndBattery),
|
||||
warning:
|
||||
this.pvMeters.some((m) => this.meterBanner(m.name)) ||
|
||||
this.pvMeters.some((m) => this.curtailmentBanner("meter", m.name)) ||
|
||||
meterDisabled(pvAndBattery),
|
||||
},
|
||||
{
|
||||
|
|
@ -956,6 +992,12 @@ export default defineComponent({
|
|||
const names = this.site?.ext;
|
||||
return this.getMetersByNames(names);
|
||||
},
|
||||
curtailerDevices(): ConfigCurtailer[] {
|
||||
const names = this.site?.curtail || [];
|
||||
return names
|
||||
.map((name) => this.curtailers.find((c) => c.name === name))
|
||||
.filter((c): c is ConfigCurtailer => c !== undefined);
|
||||
},
|
||||
consumerMeters() {
|
||||
return this.getMetersByNames(this.site?.consumer);
|
||||
},
|
||||
|
|
@ -1291,6 +1333,7 @@ export default defineComponent({
|
|||
await this.loadLoadpoints();
|
||||
await this.loadCircuits();
|
||||
await this.loadMessengers();
|
||||
await this.loadCurtailers();
|
||||
await this.loadTariffs();
|
||||
await this.loadTariffRefs();
|
||||
await this.loadHems();
|
||||
|
|
@ -1311,6 +1354,9 @@ export default defineComponent({
|
|||
async loadMessengers() {
|
||||
this.messengers = (await this.loadConfig("devices/messenger")) || [];
|
||||
},
|
||||
async loadCurtailers() {
|
||||
this.curtailers = (await this.loadConfig("devices/curtailer")) || [];
|
||||
},
|
||||
async loadVehicles() {
|
||||
this.vehicles = (await this.loadConfig("devices/vehicle")) || [];
|
||||
},
|
||||
|
|
@ -1464,6 +1510,25 @@ export default defineComponent({
|
|||
this.loadMessengers();
|
||||
this.loadDirty();
|
||||
},
|
||||
curtailerTitle(curtailer: ConfigCurtailer): string {
|
||||
return (
|
||||
curtailer.deviceTitle ||
|
||||
curtailer.config?.template ||
|
||||
this.$t("config.curtailer.title")
|
||||
);
|
||||
},
|
||||
async curtailerChanged(result: ModalResult) {
|
||||
if (result.action === "added" && result.name) {
|
||||
this.site.curtail = [...(this.site.curtail || []), result.name];
|
||||
await this.saveSite("curtail");
|
||||
}
|
||||
if (result.action === "removed") {
|
||||
await this.loadSite();
|
||||
}
|
||||
await this.loadCurtailers();
|
||||
await this.loadDirty();
|
||||
this.updateValues();
|
||||
},
|
||||
siteChanged() {
|
||||
this.loadDirty();
|
||||
},
|
||||
|
|
@ -1504,6 +1569,7 @@ export default defineComponent({
|
|||
vehicle: this.vehicles,
|
||||
charger: this.chargers,
|
||||
tariff: this.tariffs,
|
||||
curtailer: this.curtailers,
|
||||
} as Record<DeviceType, any[]>;
|
||||
for (const type in devices) {
|
||||
for (const device of devices[type as DeviceType]) {
|
||||
|
|
@ -1523,9 +1589,10 @@ export default defineComponent({
|
|||
deviceTags(type: DeviceType, id: string) {
|
||||
return this.deviceValues[type][id] || {};
|
||||
},
|
||||
meterBanner(name: string): string | undefined {
|
||||
// the tag is only present while curtailing, a zero percent limit is still one
|
||||
return this.deviceTags("meter", name)["curtailed"]?.value !== undefined
|
||||
curtailmentBanner(type: DeviceType, name: string): string | undefined {
|
||||
// devices report the allowed feed-in percent, 100 = uncurtailed
|
||||
const value = this.deviceTags(type, name)["curtailed"]?.value;
|
||||
return typeof value === "number" && value < 100
|
||||
? this.$t("config.deviceValue.productionLimited")
|
||||
: undefined;
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue