+
+
0 ? configured[name] : c.maxPower),
+ });
} else if (configured[name] > 0 && configured[name] !== c.maxPower) {
note = this.$t("lmoverview.circuitFollows", {
limit: this.fmtW(configured[name]),
diff --git a/assets/js/types/evcc-lm.ts b/assets/js/types/evcc-lm.ts
index 00921dd340..a97dc1be30 100644
--- a/assets/js/types/evcc-lm.ts
+++ b/assets/js/types/evcc-lm.ts
@@ -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.
@@ -229,6 +231,8 @@ export interface LmState {
limits: Record;
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;
diff --git a/assets/js/utils/circuitFromPeak.test.ts b/assets/js/utils/circuitFromPeak.test.ts
new file mode 100644
index 0000000000..24767e9b9c
--- /dev/null
+++ b/assets/js/utils/circuitFromPeak.test.ts
@@ -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("");
+ });
+});
diff --git a/assets/js/utils/circuitFromPeak.ts b/assets/js/utils/circuitFromPeak.ts
new file mode 100644
index 0000000000..aaf04a8b56
--- /dev/null
+++ b/assets/js/utils/circuitFromPeak.ts
@@ -0,0 +1,28 @@
+interface CircuitFromPeakState {
+ lmOff?: { circuit?: string; fromPeak?: boolean };
+ lmAdvanced?: { circuitFromPeak?: boolean };
+ peakShavingLimit?: number;
+ peakShaving?: boolean;
+ circuits?: Record;
+}
+
+// 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;
+}
diff --git a/core/fork_hooks_test.go b/core/fork_hooks_test.go
index 88390f2d9a..f9bde4811d 100644
--- a/core/fork_hooks_test.go
+++ b/core/fork_hooks_test.go
@@ -67,6 +67,7 @@ var forkUiHooks = []struct {
{"assets/js/views/Config.vue", []string{"` |
+| `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.
@@ -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 |
@@ -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) |
diff --git a/core/site_lm.go b/core/site_lm.go
index bb363aa87b..8df1d4d4e3 100644
--- a/core/site_lm.go
+++ b/core/site_lm.go
@@ -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
@@ -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()
diff --git a/core/site_lm_advanced.go b/core/site_lm_advanced.go
index 35212f6ba7..974d55966d 100644
--- a/core/site_lm_advanced.go
+++ b/core/site_lm_advanced.go
@@ -37,6 +37,9 @@ type lmAdvanced struct {
// Home Assistant entity the optimizer's planned grid export is written to, empty = off,
// see site_lm_export_forecast.go
ExportForecastEntity string `json:"exportForecastEntity,omitempty"`
+ // the load management (peak) circuit's power limit is the peak limit while peak
+ // shaving is on, see site_lm_switch.go. Set through the number route, 1 = on.
+ CircuitFromPeak bool `json:"circuitFromPeak,omitempty"`
}
// home consumption forecasts, see HomeForecast
@@ -61,6 +64,7 @@ type lmAdvancedState struct {
HomeWeekday bool `json:"homeWeekday"`
HomeForecast int `json:"homeForecast"`
ExportForecastEntity string `json:"exportForecastEntity"`
+ CircuitFromPeak bool `json:"circuitFromPeak"`
}
// lmAdvancedLimit is a setting's valid range
@@ -82,6 +86,7 @@ var lmAdvancedLimits = map[string]lmAdvancedLimit{
"gridChargeWindow": {1, 24, true},
"homeWeekday": {0, 1, true},
"homeForecast": {0, 2, true},
+ "circuitFromPeak": {0, 1, true},
}
// restoreLmAdvanced restores the persisted advanced settings
@@ -127,6 +132,7 @@ func (site *Site) publishLmAdvanced() {
HomeWeekday: site.homeWeekday(),
HomeForecast: site.homeForecast(),
ExportForecastEntity: site.exportForecastEntity(),
+ CircuitFromPeak: site.advanced().CircuitFromPeak,
})
}
@@ -191,6 +197,8 @@ func (site *Site) SetLmAdvanced(name string, value float64) error {
case "homeWeekday", "homeForecast":
s.adv.HomeForecast = &v
s.adv.HomeWeekday = nil
+ case "circuitFromPeak":
+ s.adv.CircuitFromPeak = value == 1
}
adv := s.adv
s.advMu.Unlock()
@@ -207,6 +215,9 @@ func (site *Site) SetLmAdvanced(name string, value float64) error {
if name == "homeWeekday" || name == "homeForecast" {
site.Optimize() // the home demand forecast changed
}
+ if name == "circuitFromPeak" {
+ site.applyCircuitLimits()
+ }
site.publishLmAdvanced()
diff --git a/core/site_lm_advanced_test.go b/core/site_lm_advanced_test.go
index e8fe36adf8..7d9552523b 100644
--- a/core/site_lm_advanced_test.go
+++ b/core/site_lm_advanced_test.go
@@ -70,6 +70,8 @@ func TestLmAdvancedSettings(t *testing.T) {
{"peakCap", 11},
{"writeTolerance", 1001},
{"writeTolerance", 2.5},
+ {"circuitFromPeak", 2},
+ {"circuitFromPeak", 0.5},
{"unknown", 1},
} {
assert.Error(t, site.SetLmAdvanced(tc.name, tc.value), "%s = %g", tc.name, tc.value)
diff --git a/core/site_lm_switch.go b/core/site_lm_switch.go
index ce48f89ec7..5649eac137 100644
--- a/core/site_lm_switch.go
+++ b/core/site_lm_switch.go
@@ -1,6 +1,6 @@
package core
-// Custom extension: runtime changes to the circuits' power limits, from two
+// Custom extension: runtime changes to the circuits' power limits, from three
// sources, without touching the configuration:
//
// - load management off (Mehr → Lastmanagement (Peak)): the power limit of
@@ -10,13 +10,18 @@ package core
// - follow the peak: the load management (peak) circuit's limit rises with
// the raised peak limit, never below its configured value, see
// site_peak_follow.go
+// - the peak limit as the circuit's limit (Erweitert → Grenze =
+// Peak-Shaving-Grenze): while peak shaving is on, the load management (peak)
+// circuit's limit is the peak limit, with follow the peak and also below the
+// configured value. Needs the circuit chosen; the configured value applies
+// again once peak shaving is off.
//
// The load management (peak) circuit is chosen under Lastmanagement-Details →
// Erweitert, so a circuit for the fuse can sit beside it untouched.
//
// The current limits (fuses) and a HEMS consumption limit (§14a) always apply.
// The configured value is kept when a circuit is first changed and put back
-// once neither source needs a change. Circuits whose limit comes from a plugin
+// once no source needs a change. Circuits whose limit comes from a plugin
// are left alone. Battery peak shaving is separate and keeps running.
import (
@@ -34,6 +39,8 @@ type lmSwitchState struct {
Limits map[string]float64 `json:"limits"` // W, the configured power limits of the changed circuits
Dynamic []string `json:"dynamic"` // circuits whose limit comes from a plugin and stays
Circuit string `json:"circuit"` // the load management (peak) circuit, empty = all
+ // FromPeak: the circuit's limit is the peak limit right now, see circuitFromPeak
+ FromPeak bool `json:"fromPeak"`
}
// restoreLmSwitch continues switched off load management across a restart
@@ -60,13 +67,16 @@ func (site *Site) restoreLmSwitch() {
}
// circuitLimitWanted returns the power limit a circuit should have for its
-// configured value, 0 = unlimited
-func circuitLimitWanted(configured float64, off, follow bool, followLimit float64) float64 {
+// configured value, 0 = unlimited. The peak limit already includes follow the
+// peak.
+func circuitLimitWanted(configured float64, off, fromPeak, follow bool, peakLimit float64) float64 {
switch {
case off:
return 0
+ case fromPeak:
+ return peakLimit
case follow:
- return max(configured, followLimit)
+ return max(configured, peakLimit)
default:
return configured
}
@@ -78,7 +88,8 @@ func (site *Site) applyCircuitLimits() {
p := site.peak()
p.mu.Lock()
following := p.follow && p.limit > p.followBase
- followLimit := p.limit
+ peakLimit := p.limit
+ fromPeak := site.advanced().CircuitFromPeak && p.enabled && p.limit > 0
p.mu.Unlock()
s := site.lms()
@@ -92,10 +103,14 @@ func (site *Site) applyCircuitLimits() {
lmCircuit = ""
}
}
- followCircuit := ""
+ followCircuit, peakCircuit := "", ""
if following {
followCircuit = lmCircuit
}
+ if fromPeak {
+ peakCircuit = lmCircuit
+ }
+ var peakConfigured float64
if s.offLimits == nil {
s.offLimits = make(map[string]float64)
}
@@ -111,7 +126,10 @@ func (site *Site) applyCircuitLimits() {
}
lift := off && (lmCircuit == "" || name == lmCircuit)
- wanted := circuitLimitWanted(configured, lift, name == followCircuit, followLimit)
+ wanted := circuitLimitWanted(configured, lift, name == peakCircuit, name == followCircuit, peakLimit)
+ if name == peakCircuit {
+ peakConfigured = configured
+ }
if !touched {
if wanted == configured || configured <= 0 || s.offDynamic[name] {
@@ -151,12 +169,19 @@ func (site *Site) applyCircuitLimits() {
changed = true
}
- if !off && followCircuit == "" && len(s.offDynamic) > 0 {
+ if !off && followCircuit == "" && peakCircuit == "" && len(s.offDynamic) > 0 {
s.offDynamic = nil
changed = true
}
- res := lmSwitchState{Enabled: !off, Limits: make(map[string]float64, len(s.offLimits)), Dynamic: make([]string, 0), Circuit: s.circuit}
+ // a circuit without a power limit is left alone, one with a plugin limit stays
+ fromPeakNow := peakCircuit != "" && !off && peakConfigured > 0 && !s.offDynamic[peakCircuit]
+ if fromPeakNow != s.offFromPeak {
+ s.offFromPeak = fromPeakNow
+ changed = true
+ }
+
+ res := lmSwitchState{Enabled: !off, Limits: make(map[string]float64, len(s.offLimits)), Dynamic: make([]string, 0), Circuit: s.circuit, FromPeak: fromPeakNow}
for name, v := range s.offLimits {
res.Limits[name] = v
}
diff --git a/core/site_lm_switch_test.go b/core/site_lm_switch_test.go
index d280f770f2..838b72b0fa 100644
--- a/core/site_lm_switch_test.go
+++ b/core/site_lm_switch_test.go
@@ -1,6 +1,7 @@
package core
import (
+ "context"
"testing"
"time"
@@ -17,10 +18,12 @@ import (
func TestCircuitLimitWanted(t *testing.T) {
noSettingsDB(t)
- assert.Equal(t, 10000.0, circuitLimitWanted(10000, false, false, 0), "as configured")
- assert.Equal(t, 0.0, circuitLimitWanted(10000, true, true, 11300), "off: lifted")
- assert.Equal(t, 11300.0, circuitLimitWanted(10000, false, true, 11300), "follows the peak")
- assert.Equal(t, 22000.0, circuitLimitWanted(22000, false, true, 11300), "never below the configured value")
+ assert.Equal(t, 10000.0, circuitLimitWanted(10000, false, false, false, 0), "as configured")
+ assert.Equal(t, 0.0, circuitLimitWanted(10000, true, true, true, 11300), "off: lifted")
+ assert.Equal(t, 11300.0, circuitLimitWanted(10000, false, false, true, 11300), "follows the peak")
+ assert.Equal(t, 22000.0, circuitLimitWanted(22000, false, false, true, 11300), "never below the configured value")
+ assert.Equal(t, 3500.0, circuitLimitWanted(10000, false, true, false, 3500), "the peak limit, below the configured value")
+ assert.Equal(t, 3500.0, circuitLimitWanted(10000, false, true, true, 3500), "the peak limit wins over following it")
}
// Switching load management off lifts the power limits and keeps the current
@@ -140,3 +143,214 @@ func TestLmCircuitTakenOver(t *testing.T) {
site.restoreLmSwitch()
assert.Empty(t, site.GetLmCircuit())
}
+
+// lmSwitchCircuits adds the circuits "main" (10 kW) and "garage" (7 kW) and
+// returns a site with a battery and peak shaving on at limit
+func lmSwitchCircuits(t *testing.T, limit float64) (*Site, *circuit.Circuit, *circuit.Circuit) {
+ t.Helper()
+
+ noSettingsDB(t)
+ keepSettings(t)
+ config.Reset()
+ t.Cleanup(config.Reset)
+
+ log := util.NewLogger("test")
+ main, err := circuit.New(log, "peak", 32, 10000, nil, 0)
+ require.NoError(t, err)
+ require.NoError(t, config.Circuits().Add(config.NewStaticDevice(config.Named{Name: "main"}, api.Circuit(main))))
+ sub, err := circuit.New(log, "garage", 0, 7000, nil, 0)
+ require.NoError(t, err)
+ require.NoError(t, config.Circuits().Add(config.NewStaticDevice(config.Named{Name: "garage"}, api.Circuit(sub))))
+
+ site := &Site{
+ log: log,
+ batteryMeters: []config.Device[api.Meter]{config.NewStaticDevice(config.Named{Name: "bat"}, api.Meter(&scenarioBattery{}))},
+ }
+ site.peak().clock = clock.NewMock()
+ setPeakShaving(site, limit, 20)
+
+ return site, main, sub
+}
+
+// The peak limit as the load management (peak) circuit's limit: off by default,
+// on it replaces the configured value, also below it, follows every change of
+// the peak limit and is lifted with load management. Peak shaving off returns
+// the configured value.
+func TestLmCircuitFromPeak(t *testing.T) {
+ site, main, sub := lmSwitchCircuits(t, 3500)
+ p := site.peak()
+ clk := p.clock.(*clock.Mock)
+ clk.Set(time.Date(2026, 9, 20, 12, 0, 0, 0, time.Local))
+ p.followBuffer = defaultPeakFollowBuffer
+
+ // default off: nothing changes
+ require.NoError(t, site.SetLmCircuit("main"))
+ assert.Equal(t, 10000.0, main.GetMaxPower())
+ assert.False(t, site.advanced().CircuitFromPeak)
+
+ // on without a circuit chosen: no effect, not on all circuits
+ require.NoError(t, site.SetLmCircuit(""))
+ require.NoError(t, site.SetLmAdvanced("circuitFromPeak", 1))
+ assert.Equal(t, 10000.0, main.GetMaxPower())
+ assert.Equal(t, 7000.0, sub.GetMaxPower())
+
+ // on with the circuit chosen: the peak limit, the other circuit stays
+ require.NoError(t, site.SetLmCircuit("main"))
+ assert.Equal(t, 3500.0, main.GetMaxPower())
+ assert.Equal(t, 7000.0, sub.GetMaxPower(), "not the load management circuit")
+ assert.Equal(t, 32.0, main.GetMaxCurrent(), "the fuse stays")
+
+ // the peak limit set by hand is taken at once, also above the configured value
+ require.NoError(t, site.SetPeakShavingLimit(5000))
+ assert.Equal(t, 5000.0, main.GetMaxPower())
+ require.NoError(t, site.SetPeakShavingLimit(12000))
+ assert.Equal(t, 12000.0, main.GetMaxPower())
+
+ // a change of the limit without the setter is taken with the next call
+ p.mu.Lock()
+ p.limit = 4000
+ p.mu.Unlock()
+ assert.Equal(t, 12000.0, main.GetMaxPower())
+ site.applyCircuitLimits()
+ assert.Equal(t, 4000.0, main.GetMaxPower())
+
+ // follow the peak raises the limit and with it the circuit, a new month returns to the base
+ require.NoError(t, site.SetPeakShavingLimit(5000))
+ p.months = []peakMonth{{Month: "2026-09", Peak: 8800}}
+ require.NoError(t, site.SetPeakFollow(true))
+ assert.Equal(t, 8300.0, site.GetPeakShavingLimit())
+ assert.Equal(t, 8300.0, main.GetMaxPower())
+ clk.Set(time.Date(2026, 10, 1, 0, 5, 0, 0, time.Local))
+ site.updatePeakFollow()
+ site.applyCircuitLimits()
+ assert.Equal(t, 5000.0, main.GetMaxPower(), "below the configured value as well")
+ require.NoError(t, site.SetPeakFollow(false))
+
+ // load management off lifts it, on returns to the peak limit
+ require.NoError(t, site.SetLmEnabled(false))
+ assert.Zero(t, main.GetMaxPower())
+ assert.Equal(t, 7000.0, sub.GetMaxPower())
+ require.NoError(t, site.SetLmEnabled(true))
+ assert.Equal(t, 5000.0, main.GetMaxPower())
+
+ // peak shaving off: the configured value returns, on again the peak limit
+ require.NoError(t, site.SetPeakShaving(false))
+ assert.Equal(t, 10000.0, main.GetMaxPower())
+ require.NoError(t, site.SetPeakShaving(true))
+ assert.Equal(t, 5000.0, main.GetMaxPower())
+
+ // the switch off returns the configured value
+ require.NoError(t, site.SetLmAdvanced("circuitFromPeak", 0))
+ assert.Equal(t, 10000.0, main.GetMaxPower())
+ assert.False(t, site.advanced().CircuitFromPeak)
+ assert.Empty(t, site.lms().offLimits)
+
+ require.Error(t, site.SetLmAdvanced("circuitFromPeak", 2))
+}
+
+// The ui shows fromPeak only while the circuit's limit is the peak limit.
+func TestLmCircuitFromPeakState(t *testing.T) {
+ site, main, _ := lmSwitchCircuits(t, 3500)
+ ch := make(chan util.Param, 100)
+ site.valueChan = ch
+
+ state := func() lmSwitchState {
+ var res lmSwitchState
+ for {
+ select {
+ case p := <-ch:
+ if v, ok := p.Val.(lmSwitchState); ok && p.Key == keys.LmOff {
+ res = v
+ }
+ default:
+ return res
+ }
+ }
+ }
+
+ require.NoError(t, site.SetLmCircuit("main"))
+ assert.False(t, state().FromPeak)
+
+ require.NoError(t, site.SetLmAdvanced("circuitFromPeak", 1))
+ st := state()
+ assert.True(t, st.FromPeak)
+ assert.Equal(t, 10000.0, st.Limits["main"], "the configured value is kept")
+ assert.Equal(t, 3500.0, main.GetMaxPower())
+
+ // the peak limit equals the configured value: still from the peak
+ require.NoError(t, site.SetPeakShavingLimit(10000))
+ assert.True(t, site.lms().offFromPeak)
+ assert.Empty(t, site.lms().offLimits)
+ assert.True(t, state().FromPeak)
+
+ require.NoError(t, site.SetLmEnabled(false))
+ assert.False(t, state().FromPeak, "lifted")
+ require.NoError(t, site.SetLmEnabled(true))
+ assert.True(t, state().FromPeak)
+
+ require.NoError(t, site.SetPeakShaving(false))
+ assert.False(t, state().FromPeak, "peak shaving off")
+}
+
+// A circuit whose limit comes from a plugin stays, and so does one without a
+// power limit.
+func TestLmCircuitFromPeakPlugin(t *testing.T) {
+ site, main, _ := lmSwitchCircuits(t, 3500)
+
+ dyn, err := circuit.NewConfigurableFromConfig(context.Background(), map[string]any{
+ "title": "plugin",
+ "maxpower": 10000,
+ "getmaxpower": map[string]any{"source": "const", "value": "8000"},
+ })
+ require.NoError(t, err)
+ require.NoError(t, config.Circuits().Add(config.NewStaticDevice(config.Named{Name: "dyn"}, dyn)))
+ require.Equal(t, 8000.0, dyn.GetMaxPower())
+
+ require.NoError(t, site.SetLmCircuit("dyn"))
+ require.NoError(t, site.SetLmAdvanced("circuitFromPeak", 1))
+ assert.Equal(t, 8000.0, dyn.GetMaxPower(), "plugin limit untouched")
+ assert.True(t, site.lms().offDynamic["dyn"])
+ assert.False(t, site.lms().offFromPeak)
+ assert.Equal(t, 10000.0, main.GetMaxPower(), "the others untouched")
+
+ // no power limit set: nothing to replace
+ none, err := circuit.New(util.NewLogger("test"), "none", 16, 0, nil, 0)
+ require.NoError(t, err)
+ require.NoError(t, config.Circuits().Add(config.NewStaticDevice(config.Named{Name: "none"}, api.Circuit(none))))
+ require.NoError(t, site.SetLmCircuit("none"))
+ assert.Zero(t, none.GetMaxPower())
+ assert.False(t, site.lms().offFromPeak)
+}
+
+// The switch survives a restart and takes effect at once: restoreCustom brings
+// the load management settings back before the peak settings, so the circuit
+// takes the peak limit only after both, not with the first cycle.
+func TestLmCircuitFromPeakRestart(t *testing.T) {
+ a, main, _ := lmSwitchCircuits(t, 3500)
+ require.NoError(t, a.SetLmCircuit("main"))
+ require.NoError(t, a.SetLmAdvanced("circuitFromPeak", 1))
+ require.Equal(t, 3500.0, main.GetMaxPower())
+
+ // peak shaving on with 4000 W as stored by the api
+ settings.SetBool(keys.PeakShaving, true)
+ settings.SetFloat(keys.PeakShavingLimit, 4000)
+
+ // a restart: the circuit starts with its configured limit again
+ main.SetMaxPower(10000)
+ useLogFileDir(t)
+
+ b := &Site{log: a.log, batteryMeters: a.batteryMeters}
+ b.restoreCustom()
+ assert.True(t, b.advanced().CircuitFromPeak)
+ assert.Equal(t, "main", b.GetLmCircuit())
+
+ assert.Equal(t, 4000.0, main.GetMaxPower(), "the peak limit applies after the restore, not with the first cycle")
+ assert.True(t, b.lms().offFromPeak)
+ assert.Equal(t, 10000.0, b.lms().offLimits["main"], "the configured value is kept")
+
+ // not stored when off, so a default changed later applies
+ require.NoError(t, a.SetLmAdvanced("circuitFromPeak", 0))
+ var adv lmAdvanced
+ require.NoError(t, settings.Json(keys.LmAdvanced, &adv))
+ assert.False(t, adv.CircuitFromPeak)
+}
diff --git a/core/site_peakshaving.go b/core/site_peakshaving.go
index 38a027f474..0235fc42d3 100644
--- a/core/site_peakshaving.go
+++ b/core/site_peakshaving.go
@@ -1049,6 +1049,8 @@ func (site *Site) SetPeakShaving(val bool) error {
settings.SetBool(keys.PeakShaving, val)
site.publish(keys.PeakShaving, val)
site.Optimize() // custom: the optimizer inputs changed, see core/site_optimizer_lm.go
+ // the circuit may take the peak limit, see site_lm_switch.go
+ site.applyCircuitLimits()
// hand control back when switching off
if !val {
@@ -1364,6 +1366,8 @@ func (site *Site) SetPeakShavingLimit(limit float64) error {
// following the peak: the limit set by hand is the base
if site.peakFollowSetBase(limit) {
+ // the circuit may take the peak limit, see site_lm_switch.go
+ site.applyCircuitLimits()
return nil
}
@@ -1379,6 +1383,8 @@ func (site *Site) SetPeakShavingLimit(limit float64) error {
settings.SetFloat(keys.PeakShavingLimit, limit)
site.publish(keys.PeakShavingLimit, limit)
site.Optimize() // custom: the optimizer inputs changed, see core/site_optimizer_lm.go
+ // the circuit may take the peak limit, see site_lm_switch.go
+ site.applyCircuitLimits()
}
return nil
diff --git a/core/site_restore_test.go b/core/site_restore_test.go
index d29923bc72..91ee8d9fc3 100644
--- a/core/site_restore_test.go
+++ b/core/site_restore_test.go
@@ -46,6 +46,7 @@ func TestRestoreCustomAfterRestart(t *testing.T) {
require.NoError(t, a.SetLmEnabled(false))
require.NoError(t, a.SetBatteryIdentUse(true))
require.NoError(t, a.SetLmAdvanced("hysteresis", 3))
+ require.NoError(t, a.SetLmAdvanced("circuitFromPeak", 1))
require.NoError(t, a.SetPeakShavingLimit(7000))
require.NoError(t, a.SetPeakShavingReserve(40))
require.NoError(t, a.SetPeakShavingChargePower(4000))
@@ -86,6 +87,7 @@ func TestRestoreCustomAfterRestart(t *testing.T) {
assert.False(t, b.GetLmEnabled())
assert.True(t, b.GetBatteryIdentUse())
assert.Equal(t, 3.0, b.peakHysteresisSetting())
+ assert.True(t, b.advanced().CircuitFromPeak)
assert.Equal(t, 0.0, b.peakHysteresis(), "none for Marstek")
assert.Equal(t, 7000.0, b.GetPeakShavingLimit(), "the base, not a followed limit")
assert.Equal(t, 40.0, b.GetPeakShavingReserve())
diff --git a/i18n/de.json b/i18n/de.json
index bbab1626c5..963aaf70a2 100644
--- a/i18n/de.json
+++ b/i18n/de.json
@@ -81,6 +81,7 @@
"suggestion": "Vorschlag"
},
"peakShaving": {
+ "alsoCircuit": "Gilt auch für Stromkreis {name}",
"description": "Unter {reserve} deckt die Batterie nur, was das 15-Minuten-Mittel über {limit} heben würde. Darüber normaler Eigenverbrauch.",
"enable": "Untere Batteriereserve für Lastspitzen zurückhalten.",
"noEntity": "Keine Zielentität hinterlegt (Konfiguration → Lastmanagement-Details → Peak Shaving).",
@@ -213,6 +214,7 @@
"titleLabel": "Titel",
"template": "Stromkreis",
"custom": "Benutzerdefinierter Stromkreis",
+ "fromPeakHint": "Wird durch die Peak-Shaving-Grenze ersetzt (derzeit {limit}). Gilt nur ohne Peak Shaving.",
"meterHelp": "Zähler, der den Gesamtverbrauch dieses Stromkreises misst.",
"meterLabelAdd": "Stromkreiszähler hinzufügen",
"meterLabelEdit": "Stromkreiszähler bearbeiten",
@@ -580,6 +582,8 @@
"gridChargeWindowHelp": "Nur mit Optimizer: Beim Batterie-Netzladen nach Ladestand plant er den Stopp-Ladestand nach der Ladedauer mit der Netzladeleistung: ohne Lastspitzenkappung höchstens nach dieser Zeit, mit ihr so lange, wie der Platz unter dem Limit braucht.",
"circuitLabel": "Stromkreis Lastmanagement (Peak)",
"circuitHelp": "Der Stromkreis mit deiner Peak-Grenze, nicht der Absicherung. Nur er erscheint unter Mehr → Lastmanagement (Peak), wird dort mit dem Schalter aufgehoben und steigt bei Follow the Peak mit. Keiner: alle Stromkreise, keiner steigt mit.",
+ "circuitFromPeakHelp": "Der Kreis begrenzt steuerbare Lasten auf die Peak-Grenze aus dem Batterie-Reiter (mit Follow the Peak). Sein eingestellter Wert gilt nur ohne Peak Shaving.",
+ "circuitFromPeakLabel": "Grenze = Peak-Shaving-Grenze",
"circuitAll": "Keiner",
"homeForecastLabel": "Verbrauchsprognose",
"homeForecastHelp": "Für den Optimizer. evcc: Durchschnitt der letzten 28 Tage. Nach Wochentag: derselbe Wochentag der letzten 8 Wochen. Manuell: dein Lastprofil, gemischt mit den letzten 8 Wochen. Jüngere Tage zählen mehr, weicht das Profil in Verlauf oder Höhe ab, zählen sie stärker. Eine starke Abweichung der letzten 3 Stunden wirkt in die nächsten Stunden nach.",
@@ -1759,6 +1763,7 @@
"lmoverview": {
"battery": "Batterie (Netzladen)",
"circuitFollows": "folgt der Monatsspitze, eingestellt {limit}",
+ "circuitFromPeak": "aus Peak Shaving, eingestellt {limit}",
"circuitLifted": "Grenze {limit} aufgehoben",
"disabledHelp": "Aus: Die Leistungsgrenzen der Stromkreise gelten nicht, nichts wird dafür gedrosselt. Absicherung (Stromgrenzen), §14a und das Peak Shaving der Batterie bleiben aktiv.",
"enabled": "Lastmanagement",
diff --git a/i18n/en.json b/i18n/en.json
index 9e205bbc1b..50f80abb14 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -81,6 +81,7 @@
"suggestion": "Suggestion"
},
"peakShaving": {
+ "alsoCircuit": "Also applies to circuit {name}",
"description": "Below {reserve}, the battery only covers what would lift the 15 minute average above {limit}. Above it, normal self-consumption.",
"enable": "Hold the lower battery range back for demand peaks.",
"noEntity": "No target entity set (Configuration → Load management details → Peak Shaving).",
@@ -213,6 +214,7 @@
"titleLabel": "Title",
"template": "Circuit",
"custom": "User-defined circuit",
+ "fromPeakHint": "Replaced by the peak shaving limit (now {limit}). Only applies without peak shaving.",
"meterHelp": "Meter measuring the total consumption of this circuit.",
"meterLabelAdd": "Add Circuit Meter",
"meterLabelEdit": "Edit Circuit Meter",
@@ -580,6 +582,8 @@
"gridChargeWindowHelp": "Optimizer only: for soc-based battery grid charging it plans the stop soc after the charging time at the grid charge power: without peak shaving at most after this time, with it as long as the room below the limit takes.",
"circuitLabel": "Load management (peak) circuit",
"circuitHelp": "The circuit holding your peak limit, not the fuse. Only it shows under More → Load management (peak), is lifted by the switch there and rises with follow the peak. None: all circuits, none rises along.",
+ "circuitFromPeakHelp": "The circuit limits controllable loads to the peak limit from the battery tab (with follow the peak). Its configured value only applies without peak shaving.",
+ "circuitFromPeakLabel": "Limit = peak shaving limit",
"circuitAll": "None",
"homeForecastLabel": "Consumption forecast",
"homeForecastHelp": "For the optimizer. evcc: average of the last 28 days. By weekday: the same weekday of the last 8 weeks. Manual: your load profile, mixed with the last 8 weeks. Recent days count more, and the more the profile differs in shape or level, the more they count. A strong deviation of the last 3 hours carries over into the next hours.",
@@ -1759,6 +1763,7 @@
"lmoverview": {
"battery": "Battery (grid charging)",
"circuitFollows": "follows the month's peak, set {limit}",
+ "circuitFromPeak": "from peak shaving, configured {limit}",
"circuitLifted": "limit {limit} lifted",
"disabledHelp": "Off: the circuits' power limits do not apply, nothing is throttled for them. Fuses (current limits), §14a and battery peak shaving stay active.",
"enabled": "Load management",