From d37a367b85e086e89945e9a4999e83aa66fe802b Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Tue, 6 Oct 2026 00:36:21 +0200 Subject: [PATCH 1/4] Schnee auf PV: Schalter, Optimizer ohne PV-Ertrag, automatisch aus Ist der Schalter an, setzt der Fork die Solarzeitreihe des Optimizers auf 0. Nach jeder abgeschlossenen Viertelstunde werden gemessene PV-Energie und Prognose verglichen; nach vier freien Viertelstunden in Folge (mindestens 70 % der Prognose, Prognose ab 100 Wh) geht der Schalter von selbst aus. Gespeichert, veroeffentlicht, Route /api/snowcover, Optimizer laeuft beim Umschalten neu. Co-Authored-By: Claude Sonnet 5.5 --- core/keys/site_custom.go | 3 + core/site/api_custom.go | 4 + core/site_lm.go | 5 +- core/site_optimizer_lm.go | 5 +- core/site_restore_test.go | 2 + core/site_snow.go | 183 +++++++++++++++++++++++++++++++++++ core/site_snow_test.go | 193 +++++++++++++++++++++++++++++++++++++ server/http_custom.go | 3 + server/http_custom_test.go | 2 + 9 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 core/site_snow.go create mode 100644 core/site_snow_test.go diff --git a/core/keys/site_custom.go b/core/keys/site_custom.go index 9e4de467b3..a353ef0988 100644 --- a/core/keys/site_custom.go +++ b/core/keys/site_custom.go @@ -72,4 +72,7 @@ const ( // log in daily files, see core/site_logfile.go LogFile = "logFile" + + // snow on pv, the optimizer plans without solar yield, see core/site_snow.go + SnowCover = "snowCover" ) diff --git a/core/site/api_custom.go b/core/site/api_custom.go index 13d221a6e5..4513bafbf8 100644 --- a/core/site/api_custom.go +++ b/core/site/api_custom.go @@ -61,6 +61,10 @@ type CustomAPI interface { LogFile() logstash.FileState SetLogFile(logstash.FileConfig) error + // snow on pv, see core/site_snow.go + GetSnowCover() bool + SetSnowCover(bool) error + // peak shaving, see core/site_peakshaving.go GetPeakShaving() bool SetPeakShaving(bool) error diff --git a/core/site_lm.go b/core/site_lm.go index 058a125985..b900d51099 100644 --- a/core/site_lm.go +++ b/core/site_lm.go @@ -92,6 +92,7 @@ type lmState struct { type siteCustom struct { lm lmState peak peakState + snow snowState // snow on pv, see site_snow.go } // restoreCustom restores the fork's settings, called at the end of upstream's @@ -101,15 +102,17 @@ func (site *Site) restoreCustom() { site.restorePeakSettings() site.restoreFeedInEeg() site.restoreLogFile() + site.restoreSnowCover() } // updateCustom runs the fork's per cycle work once the meters are read, before // upstream decides on battery grid charging and the battery mode: peak shaving, -// the export under the second feed-in tariff and the battery identification +// the export under the second feed-in tariff, the battery identification and snow on pv func (site *Site) updateCustom(state siteState) { site.updatePeakShaving(state) site.updateFeedInEeg() site.updateBatteryIdent() + site.updateSnowCover(time.Now()) } // lms returns the load management state, applying defaults on first use diff --git a/core/site_optimizer_lm.go b/core/site_optimizer_lm.go index 219fcc768b..ed8f6ee68b 100644 --- a/core/site_optimizer_lm.go +++ b/core/site_optimizer_lm.go @@ -17,10 +17,11 @@ package core // (charge_from_grid off) // - load management: a loadpoint plans with at most its circuits' power, and // the priorities rank the batteries (c_priority) +// - snow on pv: no solar yield, see site_snow.go // - a price tariff set as planner tariff is the grid price the optimizer // plans with, statistics keep the grid tariff // -// Without circuits, peak shaving and soc-based grid charging the request is +// Without circuits, peak shaving, soc-based grid charging and snow on pv the request is // unchanged. import ( @@ -79,6 +80,8 @@ func (site *Site) optimizerGridTariff() api.Tariff { // applyLmOptimizerInputs adds the fork's settings to the optimizer request func (site *Site) applyLmOptimizerInputs(req *optimizer.OptimizationInput, batteries []optimizerBattery) { + site.applySnowCover(req) + lmActive := len(config.Circuits().Devices()) > 0 peakOn, limit, reserve := site.peakShavingConfigured() diff --git a/core/site_restore_test.go b/core/site_restore_test.go index eb0bd26088..75b43251c3 100644 --- a/core/site_restore_test.go +++ b/core/site_restore_test.go @@ -54,6 +54,7 @@ func TestRestoreCustomAfterRestart(t *testing.T) { require.NoError(t, a.SetPeakFollow(true)) require.NoError(t, a.SetPeakTariff("price", 40)) require.NoError(t, a.SetLogFile(logstash.FileConfig{Enabled: true, Level: "info", Days: 30})) + require.NoError(t, a.SetSnowCover(true)) // set through Home Assistant in the ui, stored as names settings.SetString(keys.PeakShavingEntity, "input_number.peak") @@ -91,4 +92,5 @@ func TestRestoreCustomAfterRestart(t *testing.T) { assert.True(t, b.LogFile().Enabled) assert.Equal(t, "info", b.LogFile().Level) assert.Equal(t, 30, b.LogFile().Days) + assert.True(t, b.GetSnowCover()) } diff --git a/core/site_snow.go b/core/site_snow.go new file mode 100644 index 0000000000..ee90c651e7 --- /dev/null +++ b/core/site_snow.go @@ -0,0 +1,183 @@ +package core + +// Custom extension: snow on the pv modules. With a switch in the ui the user +// says "snow lies": the optimizer then plans without solar yield, as the +// forecast shows a yield the modules do not deliver. The switch turns itself +// off once the system produces near the forecast again. Without the switch +// nothing changes. See the README for the rules. + +import ( + "sync" + "time" + + "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/core/metrics" + "github.com/evcc-io/evcc/db/settings" + "github.com/evcc-io/evcc/tariff" + optimizer "github.com/evcc-io/optimizer/client" +) + +const ( + // a completed slot counts as free when the pv energy is at least this share of the forecast + snowClearRatio = 0.7 + // the switch turns off after this many free slots in a row + snowClearSlots = 4 + // slots with less forecast energy (Wh) say nothing: night, dawn, overcast + snowMinForecastWh = 100 +) + +// snowState is the state of snow on pv +type snowState struct { + mu sync.Mutex + cover bool // the switch + clear int // free slots in a row, memory only + slot time.Time // start of the last slot rated, memory only +} + +// snow returns the snow on pv state +func (site *Site) snow() *snowState { + return &site.custom.snow +} + +// restoreSnowCover applies the persisted switch +func (site *Site) restoreSnowCover() { + s := site.snow() + + if v, err := settings.Bool(keys.SnowCover); err == nil { + s.mu.Lock() + s.cover = v + s.mu.Unlock() + } + + site.publish(keys.SnowCover, site.GetSnowCover()) +} + +// GetSnowCover returns the snow on pv switch +func (site *Site) GetSnowCover() bool { + s := site.snow() + + s.mu.Lock() + defer s.mu.Unlock() + + return s.cover +} + +// SetSnowCover switches snow on pv. The optimizer runs again at once, as for +// upstream's adjusted forecast. +func (site *Site) SetSnowCover(val bool) error { + s := site.snow() + + s.mu.Lock() + changed := s.cover != val + s.cover = val + s.clear = 0 + s.slot = time.Time{} + s.mu.Unlock() + + if !changed { + return nil + } + + site.log.DEBUG.Println("set snow on pv:", val) + settings.SetBool(keys.SnowCover, val) + site.publish(keys.SnowCover, val) + + go site.optimizerUpdateAsync(0) + + return nil +} + +// snowClearStep rates a completed slot: the new count of free slots in a row +// and whether the switch turns off. A slot with too little forecast energy +// leaves the count as it is. +func snowClearStep(count int, pv, fcst float64) (int, bool) { + if fcst < snowMinForecastWh { + return count, false + } + + if pv/fcst >= snowClearRatio { + count++ + } else { + count = 0 + } + + return count, count >= snowClearSlots +} + +// snowSlotEnergy returns the summed energy in Wh of the last completed metrics +// slot of the collectors, false until all of them have it +func (site *Site) snowSlotEnergy(refs ...string) (float64, bool) { + if len(refs) == 0 { + return 0, false + } + + var sum float64 + for _, ref := range refs { + c, ok := site.collectors[ref] + if !ok { + return 0, false + } + + v, ok := c.LastSlotEnergy() + if !ok { + return 0, false + } + sum += v + } + + return sum * 1e3, true +} + +// updateSnowCover rates every completed slot once while the switch is on and +// turns it off after enough free slots, called with the clock's time each cycle +func (site *Site) updateSnowCover(now time.Time) { + s := site.snow() + + s.mu.Lock() + on, count, last := s.cover, s.clear, s.slot + s.mu.Unlock() + + // the slot the collectors report as completed + slot := now.Truncate(tariff.SlotDuration).Add(-tariff.SlotDuration) + + if !on || slot.Equal(last) { + return + } + + // the same energies upstream's blend of the first slots uses, the forecast + // unscaled; until the collectors have the slot it is tried again next cycle + pv, ok := site.snowSlotEnergy(site.Meters.PVMetersRef...) + if !ok { + return + } + fcst, ok := site.snowSlotEnergy(metrics.Forecast) + if !ok { + return + } + + count, off := snowClearStep(count, pv, fcst) + + s.mu.Lock() + if !s.cover { + // switched off meanwhile + s.mu.Unlock() + return + } + s.clear, s.slot = count, slot + s.mu.Unlock() + + if off { + site.log.INFO.Println("pv snow cover off: production back to 70 % of the forecast for 1 h") + _ = site.SetSnowCover(false) + } +} + +// applySnowCover takes the solar yield out of the optimizer request while the +// switch is on, after upstream has built the series +func (site *Site) applySnowCover(req *optimizer.OptimizationInput) { + if !site.GetSnowCover() { + return + } + + clear(req.TimeSeries.Ft) +} diff --git a/core/site_snow_test.go b/core/site_snow_test.go new file mode 100644 index 0000000000..fe92bcb0d9 --- /dev/null +++ b/core/site_snow_test.go @@ -0,0 +1,193 @@ +package core + +import ( + "testing" + "time" + + "github.com/benbjohnson/clock" + "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/core/metrics" + "github.com/evcc-io/evcc/db" + "github.com/evcc-io/evcc/db/settings" + "github.com/evcc-io/evcc/util" + optimizer "github.com/evcc-io/optimizer/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSnowClearStep(t *testing.T) { + tc := []struct { + name string + count int + pv, fcst float64 + wantCount int + wantOff bool + }{ + {"no forecast", 2, 0, 0, 2, false}, + {"below 100 Wh does not count", 2, 90, 99.9, 2, false}, + {"below 100 Wh does not reset", 3, 0, 50, 3, false}, + {"exactly 100 Wh counts", 0, 100, 100, 1, false}, + {"exactly 70 % counts", 0, 70, 100, 1, false}, + {"just below 70 % resets", 3, 69.9, 100, 0, false}, + {"nothing produced resets", 2, 0, 400, 0, false}, + {"above the forecast counts", 1, 500, 400, 2, false}, + {"fourth in a row turns off", 3, 300, 400, 4, true}, + {"third in a row does not", 2, 300, 400, 3, false}, + } + + for _, tc := range tc { + t.Run(tc.name, func(t *testing.T) { + count, off := snowClearStep(tc.count, tc.pv, tc.fcst) + assert.Equal(t, tc.wantCount, count) + assert.Equal(t, tc.wantOff, off) + }) + } + + // a sequence: two free, a gap in the dusk, a miss, then four free + var count int + var off bool + for i, in := range [][2]float64{{300, 400}, {300, 400}, {0, 50}, {100, 400}, {300, 400}, {300, 400}, {300, 400}, {300, 400}} { + count, off = snowClearStep(count, in[0], in[1]) + assert.Equal(t, i == 7, off, "step %d", i) + } + assert.Equal(t, 4, count) +} + +// Contract: the optimizer plans without solar yield while the switch is on, and +// the request is untouched while it is off. +func TestSnowCoverOptimizerInput(t *testing.T) { + keepSettings(t) + noSettingsDB(t) + + site := &Site{log: util.NewLogger("test")} + newReq := func() optimizer.OptimizationInput { + return optimizer.OptimizationInput{TimeSeries: optimizer.TimeSeries{ + Dt: quarterHours(4), + Ft: []float32{100, 250, 400, 350}, + Gt: []float32{200, 200, 200, 200}, + }} + } + + // off: inert + req := newReq() + site.applyLmOptimizerInputs(&req, nil) + assert.Equal(t, newReq(), req) + + // on: no solar yield, nothing else + require.NoError(t, site.SetSnowCover(true)) + req = newReq() + site.applyLmOptimizerInputs(&req, nil) + assert.Equal(t, []float32{0, 0, 0, 0}, req.TimeSeries.Ft) + assert.Equal(t, newReq().TimeSeries.Gt, req.TimeSeries.Gt) + + // off again + require.NoError(t, site.SetSnowCover(false)) + req = newReq() + site.applyLmOptimizerInputs(&req, nil) + assert.Equal(t, newReq(), req) +} + +// TestSnowCoverAutoOff: the switch rates every completed slot once and turns +// off after four free slots in a row, saved and published +func TestSnowCoverAutoOff(t *testing.T) { + keepSettings(t) + + clk := clock.NewMock() + clk.Set(time.Date(2026, 1, 10, 10, 0, 0, 0, time.UTC)) + + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + t.Cleanup(func() { db.Instance = nil }) + require.NoError(t, metrics.SetupSchema()) + + pv, err := metrics.NewCollector(metrics.PV, "pv", "", metrics.WithClock(clk)) + require.NoError(t, err) + fc, err := metrics.NewCollector(metrics.Forecast, metrics.Forecast, "", metrics.WithClock(clk)) + require.NoError(t, err) + + pub := make(chan util.Param, 16) + site := &Site{ + log: util.NewLogger("test"), + valueChan: pub, + collectors: map[string]*metrics.Collector{"pv": pv, metrics.Forecast: fc}, + } + site.Meters.PVMetersRef = []string{"pv"} + + // slot sets the energies of the running slot in Wh + slot := func(pvWh, fcstWh float64) { + require.NoError(t, pv.SetEnergy(pvWh/1e3)) + require.NoError(t, fc.SetEnergy(fcstWh/1e3)) + } + // next completes the running slot, the controller rates it twice + next := func() { + clk.Add(15 * time.Minute) + slot(0, 0) + site.updateSnowCover(clk.Now()) + site.updateSnowCover(clk.Now()) + } + count := func() int { return site.snow().clear } + + // off: nothing is rated + slot(0, 0) + site.updateSnowCover(clk.Now()) + assert.Zero(t, count()) + assert.True(t, site.snow().slot.IsZero()) + + require.NoError(t, site.SetSnowCover(true)) + assert.Equal(t, util.Param{Key: keys.SnowCover, Val: true}, <-pub) + + // the first slot is not complete yet: tried again, nothing is marked + site.updateSnowCover(clk.Now()) + assert.True(t, site.snow().slot.IsZero()) + + slot(10, 300) // 10:00 snow + next() + assert.Equal(t, 0, count()) + assert.Equal(t, clk.Now().Add(-15*time.Minute), site.snow().slot) + + slot(250, 300) // 10:15 free + next() + assert.Equal(t, 1, count(), "rated once") + + slot(5, 50) // 10:30 dusk, does not count and does not reset + next() + assert.Equal(t, 1, count()) + + slot(100, 300) // 10:45 below 70 % + next() + assert.Equal(t, 0, count()) + + for i := range 4 { + assert.True(t, site.GetSnowCover(), "after %d free slots", i) + slot(300, 300) // 11:00 ... 11:45 + next() + } + + assert.False(t, site.GetSnowCover(), "off after four free slots") + v, err := settings.Bool(keys.SnowCover) + require.NoError(t, err) + assert.False(t, v) + assert.Equal(t, util.Param{Key: keys.SnowCover, Val: false}, <-pub) + + // off: further slots change nothing + slot(0, 400) + next() + assert.Zero(t, count()) +} + +// TestSnowCoverNeedsMeterAndForecast: without a pv meter or forecast collector nothing is rated +func TestSnowCoverNeedsMeterAndForecast(t *testing.T) { + keepSettings(t) + noSettingsDB(t) + + site := &Site{log: util.NewLogger("test"), collectors: map[string]*metrics.Collector{}} + require.NoError(t, site.SetSnowCover(true)) + + site.updateSnowCover(time.Now()) + assert.True(t, site.GetSnowCover()) + assert.True(t, site.snow().slot.IsZero()) + + site.Meters.PVMetersRef = []string{"pv"} + site.updateSnowCover(time.Now()) + assert.True(t, site.GetSnowCover()) + assert.True(t, site.snow().slot.IsZero()) +} diff --git a/server/http_custom.go b/server/http_custom.go index 171ba8da93..41c6f1356c 100644 --- a/server/http_custom.go +++ b/server/http_custom.go @@ -106,6 +106,9 @@ func customSiteRoutes(site site.API) map[string]route { "logfile": {"GET", "/logfile", logFileHandler(site)}, "logfileset": {"POST", "/logfile", logFileSetHandler(site)}, + // snow on pv, see core/site_snow.go + "snowcover": {"POST", "/snowcover/{value:[01truefalse]+}", boolHandler(site.SetSnowCover, site.GetSnowCover)}, + // peak shaving, see core/site_peakshaving.go "peakshaving": {"POST", "/peakshaving/{value:[01truefalse]+}", boolHandler(site.SetPeakShaving, site.GetPeakShaving)}, "peakshavinglimit": {"POST", "/peakshavinglimit/{value:[0-9.]+}", floatHandler(site.SetPeakShavingLimit, site.GetPeakShavingLimit)}, diff --git a/server/http_custom_test.go b/server/http_custom_test.go index 2a020b9d6e..a448d42dfd 100644 --- a/server/http_custom_test.go +++ b/server/http_custom_test.go @@ -119,6 +119,7 @@ var customRouteSamples = map[string]string{ "feedinsplit": "/feedinsplit?from=2026-09-01&to=2026-10-01&aggregate=day", "logfile": "/logfile", "logfileset": "/logfile", + "snowcover": "/snowcover/true", "peakshaving": "/peakshaving/false", "peakshavinglimit": "/peakshavinglimit/7000", "peakfollow": "/peakfollow/true", @@ -181,6 +182,7 @@ func TestCustomRoutesMatch(t *testing.T) { assert.Equal(t, 1000.0, site.GetPeakFollowBuffer()) assert.Equal(t, 4000.0, site.GetPeakShavingChargePower()) assert.False(t, site.GetLmEnabled()) + assert.True(t, site.GetSnowCover()) } func must[T any](v T, err error) T { From 4279e8a3b5f92d9e92decf2be39014d2424a4a2d Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Tue, 6 Oct 2026 00:42:07 +0200 Subject: [PATCH 2/4] Schnee auf PV: Schalter und Hinweis auf der Prognose-Seite Schalter in der Solar-Karte neben "Prognose anpassen" (ab md in der Kopfzeile, darunter in der Zeile unter dem Diagramm), Hinweis unter dem Diagramm solange der Schalter an ist. Hook in Forecast.vue, Vertragstest ueber forkUiHooks. Co-Authored-By: Claude Sonnet 5.5 --- .../components/Forecast/SnowCoverSwitch.vue | 67 +++++++++++++++++++ assets/js/types/evcc-lm.ts | 2 + assets/js/views/Forecast.vue | 10 ++- core/fork_hooks_test.go | 1 + i18n/de.json | 4 ++ i18n/en.json | 4 ++ 6 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 assets/js/components/Forecast/SnowCoverSwitch.vue diff --git a/assets/js/components/Forecast/SnowCoverSwitch.vue b/assets/js/components/Forecast/SnowCoverSwitch.vue new file mode 100644 index 0000000000..169c4d4aca --- /dev/null +++ b/assets/js/components/Forecast/SnowCoverSwitch.vue @@ -0,0 +1,67 @@ + + + diff --git a/assets/js/types/evcc-lm.ts b/assets/js/types/evcc-lm.ts index f98ca0ae64..52121b101f 100644 --- a/assets/js/types/evcc-lm.ts +++ b/assets/js/types/evcc-lm.ts @@ -152,6 +152,8 @@ export interface LmStatus { /** State fields added by this fork. */ export interface LmState { + /** Snow on pv: the optimizer plans without solar yield, see core/site_snow.go. */ + snowCover?: boolean; /** Soc-based grid charging of the home battery is enabled. */ batterySocGridCharge?: boolean; /** Soc in % at or below which soc-based grid charging starts. */ diff --git a/assets/js/views/Forecast.vue b/assets/js/views/Forecast.vue index 723c586502..52e6691582 100644 --- a/assets/js/views/Forecast.vue +++ b/assets/js/views/Forecast.vue @@ -32,8 +32,8 @@ edge-to-edge class="box-pull-out mb-4" > -