+
+
{{ solarAdjustTextMedium }}
+
+
+
+
@@ -138,6 +142,7 @@ import Card from "../components/Helper/Card.vue";
import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
import SolarChart from "../components/Forecast/SolarChart.vue";
import SolarDetails from "../components/Forecast/SolarDetails.vue";
+import SnowCoverSwitch from "../components/Forecast/SnowCoverSwitch.vue"; // custom
import PriceChart from "../components/Forecast/PriceChart.vue";
import GridDetails from "../components/Forecast/GridDetails.vue";
import ValueChart, { type ValueChartType } from "../components/Forecast/ValueChart.vue";
@@ -159,6 +164,7 @@ export default defineComponent({
DynamicPriceIcon,
SolarChart,
SolarDetails,
+ SnowCoverSwitch, // custom
PriceChart,
GridDetails,
ValueChart,
diff --git a/core/fork_hooks_test.go b/core/fork_hooks_test.go
index 661c6a816c..7b5fcfbf78 100644
--- a/core/fork_hooks_test.go
+++ b/core/fork_hooks_test.go
@@ -64,6 +64,7 @@ var forkUiHooks = []struct {
{"assets/js/components/Config/LoadpointModal.vue", []string{"
", "
", "SnowCoverSwitch, // custom"}},
{"assets/js/views/Log.vue", []string{"
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/i18n/de.json b/i18n/de.json
index 2b3e55b3cb..c80a1c8772 100644
--- a/i18n/de.json
+++ b/i18n/de.json
@@ -2353,6 +2353,10 @@
"resetWarning": "Es ist kein dynamischer Einspeisetarif konfiguriert. Dennoch ist ein Grenze von {limit} eingerichtet. Konfiguration aufräumen?",
"title": "Einspeisung priorisieren"
},
+ "snowCover": {
+ "label": "Schnee auf PV",
+ "note": "Schnee auf PV: Der Optimizer rechnet ohne PV-Ertrag. Geht aus, wenn die Anlage 1 h lang 70 % liefert."
+ },
"startupError": {
"configFile": "Verwendete Konfigurationsdatei:",
"configuration": "Konfiguration",
diff --git a/i18n/en.json b/i18n/en.json
index 5c5cca0fff..4b741d90c1 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -2353,6 +2353,10 @@
"resetWarning": "There is no dynamic feed-in tariff configured. However, there is still a limit of {limit}. Clean up your configuration?",
"title": "Feed-in Priority"
},
+ "snowCover": {
+ "label": "Snow on PV",
+ "note": "Snow on PV: the optimizer plans without solar yield. Turns off once the system delivers 70 % for 1 h."
+ },
"startupError": {
"configFile": "Configuration file used:",
"configuration": "Config",
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 {