diff --git a/.github/upstream-check/watch.json b/.github/upstream-check/watch.json index 0d8368bc8d..5f9d62c7cf 100644 --- a/.github/upstream-check/watch.json +++ b/.github/upstream-check/watch.json @@ -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"], diff --git a/assets/js/components/Battery/BatteryPeakShavingCard.vue b/assets/js/components/Battery/BatteryPeakShavingCard.vue index 1440124a38..ef02757ec8 100644 --- a/assets/js/components/Battery/BatteryPeakShavingCard.vue +++ b/assets/js/components/Battery/BatteryPeakShavingCard.vue @@ -44,6 +44,15 @@ + +

+ {{ $t("battery.peakShaving.alsoCircuit", { name: alsoCircuit }) }} +

+
{{ error }}
+ + diff --git a/assets/js/components/Config/LmAdvancedModal.vue b/assets/js/components/Config/LmAdvancedModal.vue index e377cce650..9d56f13e18 100644 --- a/assets/js/components/Config/LmAdvancedModal.vue +++ b/assets/js/components/Config/LmAdvancedModal.vue @@ -44,6 +44,26 @@ + + +
+ +
+
+ 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",