Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/upstream-check/watch.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@
"keywords": ["SetMaxPower"],
"paths": ["core/circuit/", "core/site.go", "server/"]
},
{
"name": "Kreis-Leistungsgrenze aus der Peak-Grenze (dynamische Kreisgrenze; evcc könnte sie aus Tarifen oder Plugins anbieten)",
"files": ["core/site_lm_switch.go", "core/site_lm_advanced.go"],
"keywords": ["getMaxPower", "GetMaxPower", "MaxPowerFrom", "maxPowerFrom", "dynamic limit"],
"paths": ["core/circuit/", "core/site_circuits.go", "core/site.go"]
},
{
"name": "Log-Datei (Tagesdateien)",
"files": ["util/logstash/file_custom.go", "core/site_logfile.go"],
Expand Down
14 changes: 14 additions & 0 deletions assets/js/components/Battery/BatteryPeakShavingCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@
</i18n-t>
</div>

<!-- custom: the load management circuit takes the peak limit, see core/site_lm_switch.go -->
<p
v-if="alsoCircuit"
class="small evcc-gray mt-3 mb-0"
data-testid="battery-peak-shaving-circuit"
>
{{ $t("battery.peakShaving.alsoCircuit", { name: alsoCircuit }) }}
</p>

<div v-if="error" class="alert alert-danger mt-3 mb-0 py-2 small">{{ error }}</div>

<div
Expand Down Expand Up @@ -71,6 +80,7 @@ import api from "@/api";
import store from "@/store";
import { socSteps, MARSTEK_MIN_RESERVE } from "@/utils/socSteps";
import { peakShavingSetUp } from "@/utils/peakShaving";
import { peakShavingCircuitTitle } from "@/utils/circuitFromPeak";
import Card from "../Helper/Card.vue";
import InlineSocSelect from "./InlineSocSelect.vue";

Expand Down Expand Up @@ -122,6 +132,10 @@ export default defineComponent({
entityMissing(): boolean {
return !peakShavingSetUp({ ...store.state, peakShavingEntity: this.entity });
},
// the circuit whose power limit is this limit right now, empty = none
alsoCircuit(): string {
return peakShavingCircuitTitle(store.state);
},
followOn(): boolean {
return !!this.follow?.enabled;
},
Expand Down
4 changes: 4 additions & 0 deletions assets/js/components/Config/CircuitModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
</FormRow>
</template>
<template #before-actions="{ values }">
<!-- custom: the circuit's limit is the peak limit, see core/site_lm_switch.go -->
<CircuitPeakHint :id="id" :circuits="circuits" />
<div :class="{ 'mt-4': values.type === ConfigType.Custom }">
<FormRow
v-if="!hasParentCircuit"
Expand Down Expand Up @@ -115,6 +117,7 @@ import { meterTitle } from "@/utils/circuits.ts";
import FormRow from "./FormRow.vue";
import PropertyField from "./PropertyField.vue";
import DeviceRefBox from "./DeviceRefBox.vue";
import CircuitPeakHint from "./CircuitPeakHint.vue"; // custom

enum MeterSelection {
NONE = "none",
Expand All @@ -129,6 +132,7 @@ export default defineComponent({
FormRow,
PropertyField,
DeviceRefBox,
CircuitPeakHint, // custom
},
emits: ["changed"],
props: {
Expand Down
31 changes: 31 additions & 0 deletions assets/js/components/Config/CircuitPeakHint.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<template>
<p v-if="limit !== undefined" class="small evcc-gray" data-testid="circuit-from-peak-hint">
{{ $t("config.circuit.fromPeakHint", { limit: fmtW(limit) }) }}
</p>
</template>

<script lang="ts">
import { defineComponent, type PropType } from "vue";
import type { ConfigCircuit } from "@/types/evcc";
import formatter from "@/mixins/formatter";
import store from "@/store";
import { circuitPeakHintLimit } from "@/utils/circuitFromPeak";

// Custom extension: hint in the dialog of the load management (peak) circuit
// while its power limit is replaced by the peak limit, see core/site_lm_switch.go.
// Mounted in CircuitModal.vue; shows nothing for any other circuit.
export default defineComponent({
name: "CircuitPeakHint",
mixins: [formatter],
props: {
id: { type: Number },
circuits: { type: Array as PropType<ConfigCircuit[]>, default: () => [] },
},
computed: {
limit(): number | undefined {
const name = this.circuits.find((c) => c.id === this.id)?.name;
return circuitPeakHintLimit(store.state, name);
},
},
});
</script>
32 changes: 32 additions & 0 deletions assets/js/components/Config/LmAdvancedModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,26 @@
</select>
</FormRow>

<!-- custom: the circuit's limit is the peak limit, see core/site_lm_switch.go -->
<FormRow
v-if="g === 'lm'"
id="lmAdvanced-circuitFromPeak"
:label="$t('config.lmadvanced.circuitFromPeakLabel')"
:help="$t('config.lmadvanced.circuitFromPeakHelp')"
>
<div class="form-check form-switch mb-0">
<input
id="lmAdvanced-circuitFromPeak"
v-model="circuitFromPeak"
class="form-check-input"
type="checkbox"
role="switch"
:disabled="!circuit"
data-testid="lmadvanced-circuitFromPeak"
/>
</div>
</FormRow>

<FormRow
v-for="field in fieldsOf(g)"
:id="`lmAdvanced-${field.name}`"
Expand Down Expand Up @@ -217,6 +237,7 @@
saving ||
(!changed.length &&
!circuitChanged &&
!circuitFromPeakChanged &&
!percentileChanged &&
!exportForecastChanged)
"
Expand Down Expand Up @@ -305,6 +326,8 @@ export default {
initial: {},
circuit: "",
initialCircuit: "",
circuitFromPeak: false,
initialCircuitFromPeak: false,
percentile: 0,
initialPercentile: 0,
exportForecast: "",
Expand Down Expand Up @@ -353,6 +376,9 @@ export default {
circuitChanged() {
return this.circuit !== this.initialCircuit;
},
circuitFromPeakChanged() {
return this.circuitFromPeak !== this.initialCircuitFromPeak;
},
// circuits with a power limit, configured or changed at runtime
circuitOptions() {
const configured = store.state?.lmOff?.limits || {};
Expand Down Expand Up @@ -386,6 +412,8 @@ export default {
this.initial = { ...values };
this.circuit = store.state?.lmOff?.circuit || "";
this.initialCircuit = this.circuit;
this.circuitFromPeak = !!state.circuitFromPeak;
this.initialCircuitFromPeak = this.circuitFromPeak;
this.percentile = store.state?.profilePercentile ?? 0;
this.initialPercentile = this.percentile;
this.exportForecast = state.exportForecastEntity || "";
Expand Down Expand Up @@ -462,6 +490,9 @@ export default {
await api.delete("lmcircuit");
}
}
if (this.circuitFromPeakChanged) {
await api.post(`lmadvanced/circuitFromPeak/${this.circuitFromPeak ? 1 : 0}`);
}
if (this.percentileChanged) {
if (this.percentile) {
await api.post(`profilepercentile/${this.percentile}`);
Expand All @@ -481,6 +512,7 @@ export default {
// the others are saved: only the export field is left to save
this.initial = { ...this.values };
this.initialCircuit = this.circuit;
this.initialCircuitFromPeak = this.circuitFromPeak;
this.initialPercentile = this.percentile;
this.$emit("changed");

Expand Down
5 changes: 5 additions & 0 deletions assets/js/components/LoadManagement/OverviewModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,11 @@ export default {
let note = "";
if (lifted) {
note = this.$t("lmoverview.circuitLifted", { limit: this.fmtW(limit) });
} else if (this.state?.lmOff?.fromPeak && name === this.lmCircuit) {
// custom: the limit is the peak limit, see core/site_lm_switch.go
note = this.$t("lmoverview.circuitFromPeak", {
limit: this.fmtW(configured[name] > 0 ? configured[name] : c.maxPower),
});
} else if (configured[name] > 0 && configured[name] !== c.maxPower) {
note = this.$t("lmoverview.circuitFollows", {
limit: this.fmtW(configured[name]),
Expand Down
4 changes: 4 additions & 0 deletions assets/js/types/evcc-lm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export interface LmAdvanced {
gridChargeWindow: number;
/** Home Assistant entity the optimizer's planned grid export is written to, empty = off. */
exportForecastEntity?: string;
/** The load management (peak) circuit's power limit is the peak limit while peak shaving is on. */
circuitFromPeak?: boolean;
}

// a battery profile, see core/lm/profile. Values left out are not changed.
Expand Down Expand Up @@ -229,6 +231,8 @@ export interface LmState {
limits: Record<string, number>;
dynamic: string[];
circuit: string;
/** The circuit's limit is the peak limit right now. */
fromPeak?: boolean;
};
/** Capacity tariff with each month's cost with and without the battery, a zero price is off. */
peakTariff?: PeakTariff;
Expand Down
50 changes: 50 additions & 0 deletions assets/js/utils/circuitFromPeak.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, test } from "vite-plus/test";
import { circuitPeakHintLimit, peakShavingCircuitTitle } from "./circuitFromPeak";

describe("circuitPeakHintLimit", () => {
const on = {
lmOff: { circuit: "main" },
lmAdvanced: { circuitFromPeak: true },
peakShavingLimit: 3500,
};

test("the load management circuit shows the peak limit while the switch is on", () => {
expect(circuitPeakHintLimit(on, "main")).toBe(3500);
expect(circuitPeakHintLimit({ ...on, peakShavingLimit: undefined }, "main")).toBe(0);
});

test("nothing changes without the switch, for other circuits and without a circuit", () => {
expect(circuitPeakHintLimit(on, "garage")).toBeUndefined();
expect(circuitPeakHintLimit(on, undefined)).toBeUndefined();
expect(circuitPeakHintLimit({ ...on, lmAdvanced: { circuitFromPeak: false } }, "main")).toBe(
undefined
);
expect(circuitPeakHintLimit({ ...on, lmAdvanced: {} }, "main")).toBeUndefined();
expect(circuitPeakHintLimit({ ...on, lmOff: { circuit: "" } }, "main")).toBeUndefined();
expect(circuitPeakHintLimit(undefined, "main")).toBeUndefined();
expect(circuitPeakHintLimit({}, "main")).toBeUndefined();
});
});

describe("peakShavingCircuitTitle", () => {
const on = {
lmOff: { circuit: "main", fromPeak: true },
peakShaving: true,
circuits: { main: { title: "Hausanschluss" } },
};

test("names the circuit whose limit is the peak limit right now", () => {
expect(peakShavingCircuitTitle(on)).toBe("Hausanschluss");
expect(peakShavingCircuitTitle({ ...on, circuits: {} })).toBe("main");
});

test("empty otherwise", () => {
expect(peakShavingCircuitTitle({ ...on, lmOff: { circuit: "main", fromPeak: false } })).toBe(
""
);
expect(peakShavingCircuitTitle({ ...on, lmOff: { fromPeak: true } })).toBe("");
expect(peakShavingCircuitTitle({ ...on, peakShaving: false })).toBe("");
expect(peakShavingCircuitTitle(undefined)).toBe("");
expect(peakShavingCircuitTitle({})).toBe("");
});
});
28 changes: 28 additions & 0 deletions assets/js/utils/circuitFromPeak.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
interface CircuitFromPeakState {
lmOff?: { circuit?: string; fromPeak?: boolean };
lmAdvanced?: { circuitFromPeak?: boolean };
peakShavingLimit?: number;
peakShaving?: boolean;
circuits?: Record<string, { title?: string }>;
}

// The limit shown in the dialog of the load management (peak) circuit while its
// power limit is replaced by the peak limit (Erweitert → Grenze =
// Peak-Shaving-Grenze, see core/site_lm_switch.go), in W. Undefined for every
// other circuit and while the switch is off, so the dialog stays as evcc has it.
export function circuitPeakHintLimit(
state: CircuitFromPeakState | undefined,
circuit: string | undefined
): number | undefined {
if (!circuit || circuit !== state?.lmOff?.circuit) return undefined;
if (!state.lmAdvanced?.circuitFromPeak) return undefined;
return state.peakShavingLimit || 0;
}

// The circuit the peak shaving card names: the one whose limit is the peak limit
// right now, empty otherwise.
export function peakShavingCircuitTitle(state: CircuitFromPeakState | undefined): string {
const name = state?.lmOff?.circuit;
if (!name || !state?.lmOff?.fromPeak || !state.peakShaving) return "";
return state.circuits?.[name]?.title || name;
}
1 change: 1 addition & 0 deletions core/fork_hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ var forkUiHooks = []struct {
{"assets/js/views/Config.vue", []string{"<PeakShavingConfig", "<LmConfigModals", "feedInEegTariff"}},
{"assets/js/views/Battery.vue", []string{"<BatterySocGridChargeCard", "<BatteryPeakShavingCard", "<BatteryProfileCard"}},
{"assets/js/components/BottomTabs/MoreMenu.vue", []string{"<LmMoreMenuItems"}},
{"assets/js/components/Config/CircuitModal.vue", []string{"<CircuitPeakHint", "CircuitPeakHint,"}},
{"assets/js/components/Config/LoadpointModal.vue", []string{"<PhaseSwitchFields", "emptyUnsetPhaseSwitch", "chargerIsStages"}},
{"assets/js/components/Loadpoints/Loadpoint.vue", []string{"chargerStages: Boolean", "chargePowerEstimate"}},
{"assets/js/components/Site/Site.vue", []string{"loadpoints: measuredLoadpoints(this.enabledLoadpoints)"}},
Expand Down
25 changes: 23 additions & 2 deletions core/lm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,25 @@ charging are no longer throttled for it. Fuse current limits, a HEMS limit
are left alone. On restores the configured or followed limit. Survives a
restart (`lmOff`). See `core/site_lm_switch.go`.

*Erweitert → Grenze = Peak-Shaving-Grenze* (`circuitFromPeak`, default off, so
nothing changes unconfigured): the chosen circuit's power limit is the peak
limit from the battery tab while peak shaving is on, with follow the peak and
also below the configured value. A change of the peak limit (setter, profile,
follow, peak shaving on or off) is taken at once, otherwise with the next cycle;
after a restart it applies once the peak settings are back, before the first
cycle. The configured value is not overwritten: it applies again with the switch or
peak shaving off, and *Lastmanagement* off still lifts the limit. Without a
circuit chosen the switch has no effect (not on all circuits); a circuit
without a power limit or with one from a plugin is left alone. Fuse current
limits and §14a apply as always. `lmOff.fromPeak` tells the ui whether the
limit is the peak limit right now. The ui shows where it comes from: the
overview (*from peak shaving, configured …*), the circuit dialog (hint under the
power limit, `CircuitPeakHint.vue`, the field stays editable) and the peak
shaving card of the battery tab (*Also applies to circuit …*); the configured
value stays stored. With the 3.5 kW of a small peak limit the
minimum of a 3-phase wallbox (6 A × 3 = 4.1 kW) no longer fits under the circuit:
it charges 1-phase or with the battery's help until the reserve is reached.

## 8. Overview

*Mehr → Lastmanagement (Peak)* shows circuit load, peak shaving, every load
Expand Down Expand Up @@ -640,12 +659,13 @@ values are not stored, so a changed default applies.
| `gridChargeWindow` | hours to reach the stop soc in the plan | 3 | 1-24 |
| `homeForecast` | home forecast: evcc, per weekday, manual | 0 | 0-2 |
| `exportForecastEntity` | text, not a number: entity for the export forecast, see [16](#export-forecast) | empty = off | `sensor.<name>` |
| `circuitFromPeak` | the load management circuit's limit is the peak limit, see [7](#7-load-management-circuit-and-switch) | 0 (off) | 0-1 |

Also there: the load management circuit ([7](#7-load-management-circuit-and-switch))
and evcc's `profilePercentile` ([13](#13-home-consumption-forecast)).

The dialog shows the settings in four closed groups, so it stays short on a
phone: *Lastmanagement* (circuit, `timeout`, `followCycles`), *Peak Shaving*
phone: *Lastmanagement* (circuit, `circuitFromPeak`, `timeout`, `followCycles`), *Peak Shaving*
(`hysteresis`, `freeValue`, `peakFreeze`, `peakCap`), *Batterie-Netzladen*
(`holdOff`, `gridChargeWindow`, `phases`) and *Optimizer* (`homeForecast`, load
profile, `profilePercentile`, `exportForecastEntity`). A rejected value opens its group.
Expand Down Expand Up @@ -821,6 +841,7 @@ Every change in an evcc file. Check these when merging a new evcc version.
| `assets/js/views/Forecast.vue` | evcc's *adjust* switch in the solar card header gets `d-none`; `Forecast/SnowCoverSwitch.vue` under the chart shows it with the snow switches in one row, getting label, state and `changeAdjusted` from Forecast.vue |
| `assets/js/views/Config.vue` | *Lastmanagement-Details* section, `LmConfigModals.vue`, EEG tariff card and add button |
| `assets/js/components/BottomTabs/MoreMenu.vue` | mounts `LoadManagement/MoreMenuItems.vue` |
| `assets/js/components/Config/CircuitModal.vue` | mounts `CircuitPeakHint.vue` at the start of `before-actions`: the hint under the power limit while the circuit takes the peak limit |
| `assets/js/components/Config/LoadpointModal.vue` | mounts `PhaseSwitchFields.vue`, 3-phase labels and minimum while it is shown; default mode labels Aus/Smart/Ein for a heater in stages (`chargerIsStages`) |
| `assets/js/components/Loadpoints/Loadpoint.vue`, `Mode.vue` | `chargerStages` prop, mode labels Aus/Smart/Ein for a heater in stages; `chargePowerEstimate` shown as "≈ 6 kW" (whole kW, fits a phone) |
| `assets/js/components/Site/Site.vue` | energy flow gets `measuredLoadpoints(…)` (`utils/lmEnergyflow.ts`): a loadpoint with `chargePowerEstimated` is left out |
Expand Down Expand Up @@ -848,7 +869,7 @@ Every change in an evcc file. Check these when merging a new evcc version.
| snow on pv | `core/site_snow.go`, `core/site_snow_auto.go`, `core/testdata/open-meteo-snow-tirol.json` (recorded answer, added with `git add -f` as `*.json` is ignored), `assets/js/components/Forecast/SnowCoverSwitch.vue` |
| optimizer | `core/site_optimizer_lm.go`, `core/site_optimizer_reserve_pass.go`, `core/site_optimizer_soc_pass.go`, `core/site_lm_export_forecast.go`, `tariff/homeassistant_forecast.go`, `templates/definition/tariff/homeassistant-forecast.yaml` |
| api, keys | `core/site/api_custom.go`, `server/http_custom.go`, `core/keys/site_custom.go` |
| ui | `assets/js/types/evcc-lm.ts`, `assets/js/utils/lmPriorityOrder.ts`, `assets/js/utils/socSteps.ts`, `assets/js/components/LoadManagement/`, `assets/js/components/PeakShaving/`, the battery cards in `assets/js/components/Battery/` (`BatterySocGridChargeCard`, `BatteryGridChargeOnce`, `PowerIcon`, `BatteryPeakShavingCard`, `BatteryProfileCard`, `ProfileIcon`), the config components in `assets/js/components/Config/` (`PeakShavingConfig`, `LmConfigModals` and its dialogs, `FeedInEegSummary`, `PhaseSwitchFields`) |
| ui | `assets/js/types/evcc-lm.ts`, `assets/js/utils/lmPriorityOrder.ts`, `assets/js/utils/socSteps.ts`, `assets/js/components/LoadManagement/`, `assets/js/components/PeakShaving/`, the battery cards in `assets/js/components/Battery/` (`BatterySocGridChargeCard`, `BatteryGridChargeOnce`, `PowerIcon`, `BatteryPeakShavingCard`, `BatteryProfileCard`, `ProfileIcon`), the config components in `assets/js/components/Config/` (`PeakShavingConfig`, `LmConfigModals` and its dialogs, `FeedInEegSummary`, `PhaseSwitchFields`, `CircuitPeakHint`), `assets/js/utils/circuitFromPeak.ts` |
| build | `.github/workflows/custom-image.yml` |
| start page | `.github/README.md` (GitHub shows it instead of the evcc `README.md`; its feature list follows part 1) |

Expand Down
4 changes: 4 additions & 0 deletions core/site_lm.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type lmState struct {
offLimits map[string]float64 // lifted power limits by circuit name
offDynamic map[string]bool // circuits with a plugin limit that stays
offPublished bool
offFromPeak bool // the circuit's limit is the peak limit, as published
circuit string // the load management (peak) circuit, empty = all

guardMinutes int // shed guard, see site_lm_guard.go
Expand Down Expand Up @@ -101,6 +102,9 @@ type siteCustom struct {
func (site *Site) restoreCustom() {
site.restoreLmSettings()
site.restorePeakSettings()
// the circuit may take the peak limit: restoreLmSettings ran before the peak
// settings were back, so the limit applies here, not with the first cycle
site.applyCircuitLimits()
site.restoreFeedInEeg()
site.restoreLogFile()
site.restoreSnowCover()
Expand Down
Loading
Loading