From 97f5c9970afa46910ab4b735b94d5456ad7b3e3c Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Mon, 12 Oct 2026 02:33:28 +0200 Subject: [PATCH 1/3] Lastmanagement: Grenze des Stromkreises aus der Peak-Shaving-Grenze, Schalter circuitFromPeak (P7a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer erweiterter Wert circuitFromPeak (Standard aus): bei gewähltem Lastmanagement-Kreis und aktivem Peak Shaving ist die Leistungsgrenze des Kreises die Peak-Grenze, mit Follow the Peak und auch unter dem eingestellten Wert. Der eingestellte Wert bleibt unverändert und gilt wieder, wenn der Schalter oder Peak Shaving aus ist. Lastmanagement aus hebt die Grenze wie bisher auf. Ohne gewählten Kreis, bei Kreisen ohne Leistungsgrenze und bei Plugin-Grenzen keine Wirkung. lmOff.fromPeak sagt der UI, ob die Grenze gerade aus der Peak-Grenze kommt. Setter von Peak-Grenze und Peak Shaving wenden die Kreisgrenzen sofort an. README Abschnitt 7 und 17, watch.json. Co-Authored-By: Claude Sonnet 5.5 --- .github/upstream-check/watch.json | 6 + core/lm/README.md | 15 +++ core/site_lm.go | 1 + core/site_lm_advanced.go | 11 ++ core/site_lm_advanced_test.go | 2 + core/site_lm_switch.go | 45 +++++-- core/site_lm_switch_test.go | 213 +++++++++++++++++++++++++++++- core/site_peakshaving.go | 6 + core/site_restore_test.go | 2 + 9 files changed, 287 insertions(+), 14 deletions(-) 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/core/lm/README.md b/core/lm/README.md index b853062199..e21fbb0ea0 100644 --- a/core/lm/README.md +++ b/core/lm/README.md @@ -170,6 +170,20 @@ 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. +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. 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 @@ -640,6 +654,7 @@ 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.` | +| `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)). diff --git a/core/site_lm.go b/core/site_lm.go index bb363aa87b..b7e769a7dd 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 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..0cbb6a49e9 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,205 @@ 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 with the next cycle. +func TestLmCircuitFromPeakRestart(t *testing.T) { + a, _, _ := lmSwitchCircuits(t, 3500) + require.NoError(t, a.SetLmCircuit("main")) + require.NoError(t, a.SetLmAdvanced("circuitFromPeak", 1)) + + b := &Site{log: a.log} + setPeakShaving(b, 4000, 20) + b.restoreLmAdvanced() + b.restoreLmSwitch() + assert.True(t, b.advanced().CircuitFromPeak) + assert.Equal(t, "main", b.GetLmCircuit()) + + dev, err := config.Circuits().ByName("main") + require.NoError(t, err) + assert.Equal(t, 4000.0, dev.Instance().GetMaxPower()) + + // 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()) From a33dce726e9d8a5a9ceb540b2915d260eb3c4e29 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Mon, 12 Oct 2026 02:37:47 +0200 Subject: [PATCH 2/3] Lastmanagement: UI-Schalter "Grenze = Peak-Shaving-Grenze" und Anzeige der Quelle (P7a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schalter unter Lastmanagement-Details, Erweitert, direkt unter dem Stromkreis Lastmanagement (Peak), nur aktiv mit gewähltem Kreis. Die Quelle der Grenze bleibt sichtbar, der gespeicherte Wert des Kreises wird nicht überschrieben: Übersicht zeigt "aus Peak Shaving, eingestellt ...", der Stromkreis-Dialog einen Hinweis unter den Feldern (neuer UI-Hook CircuitPeakHint in CircuitModal.vue, Eintrag in forkUiHooks und Touch-Point-Tabelle, Vertragstest in circuitFromPeak.test.ts), die Peak-Shaving-Karte im Batterie-Reiter nennt den Kreis. Texte de und en. Co-Authored-By: Claude Sonnet 5.5 --- .../Battery/BatteryPeakShavingCard.vue | 14 ++++++ assets/js/components/Config/CircuitModal.vue | 4 ++ .../js/components/Config/CircuitPeakHint.vue | 31 ++++++++++++ .../js/components/Config/LmAdvancedModal.vue | 32 ++++++++++++ .../LoadManagement/OverviewModal.vue | 5 ++ assets/js/types/evcc-lm.ts | 4 ++ assets/js/utils/circuitFromPeak.test.ts | 50 +++++++++++++++++++ assets/js/utils/circuitFromPeak.ts | 28 +++++++++++ core/fork_hooks_test.go | 1 + core/lm/README.md | 11 ++-- i18n/de.json | 5 ++ i18n/en.json | 5 ++ 12 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 assets/js/components/Config/CircuitPeakHint.vue create mode 100644 assets/js/utils/circuitFromPeak.test.ts create mode 100644 assets/js/utils/circuitFromPeak.ts 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{" Date: Mon, 12 Oct 2026 02:44:42 +0200 Subject: [PATCH 3/3] Review P7a: Peak-Grenze des Kreises gilt nach dem Neustart sofort restoreCustom stellte die Lastmanagement-Einstellungen vor den Peak-Einstellungen wieder her. Beim ersten applyCircuitLimits war Peak Shaving noch aus, die Kreisgrenze blieb auf dem eingestellten Wert und die Peak-Grenze griff erst in updateCustom des ersten Zyklus. Jetzt ruft restoreCustom nach restorePeakSettings applyCircuitLimits auf. Der Neustart-Test ruft restoreCustom mit gespeicherten Peak-Einstellungen in der echten Reihenfolge auf und prueft die Kreisgrenze sofort danach. Co-Authored-By: Claude Sonnet 5.5 --- core/lm/README.md | 5 +++-- core/site_lm.go | 3 +++ core/site_lm_switch_test.go | 27 ++++++++++++++++++--------- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/core/lm/README.md b/core/lm/README.md index 6f843a060f..d90a13b6aa 100644 --- a/core/lm/README.md +++ b/core/lm/README.md @@ -174,8 +174,9 @@ restart (`lmOff`). See `core/site_lm_switch.go`. 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. -The configured value is not overwritten: it applies again with the switch or +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 diff --git a/core/site_lm.go b/core/site_lm.go index b7e769a7dd..8df1d4d4e3 100644 --- a/core/site_lm.go +++ b/core/site_lm.go @@ -102,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_switch_test.go b/core/site_lm_switch_test.go index 0cbb6a49e9..838b72b0fa 100644 --- a/core/site_lm_switch_test.go +++ b/core/site_lm_switch_test.go @@ -322,22 +322,31 @@ func TestLmCircuitFromPeakPlugin(t *testing.T) { assert.False(t, site.lms().offFromPeak) } -// The switch survives a restart and takes effect with the next cycle. +// 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, _, _ := lmSwitchCircuits(t, 3500) + 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()) - b := &Site{log: a.log} - setPeakShaving(b, 4000, 20) - b.restoreLmAdvanced() - b.restoreLmSwitch() + // 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()) - dev, err := config.Circuits().ByName("main") - require.NoError(t, err) - assert.Equal(t, 4000.0, dev.Instance().GetMaxPower()) + 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))