From d4c1ee198826e7393f9380db3f607bf343cc6781 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Tue, 6 Oct 2026 01:01:53 +0200 Subject: [PATCH 1/4] Schnee auf PV: Automatik aus Open-Meteo-Schneefall Mit der Einstellung "Automatisch erkennen" holt der Fork alle 30 min Schneefall und Temperatur (15-min-Werte) von Open-Meteo, fuer den Standort der gespeicherten Open-Meteo-Solarprognose. Mindestens 1 cm bei hoechstens +1 C in 24 h, gerechnet aus den letzten 24 h und der Prognose bis zum naechsten Sonnenaufgang, schaltet den Schalter ein (Status snowCoverAuto). Schon gezaehlter Schnee (snowSeen, gespeichert) schaltet nicht erneut ein, auch nicht nach Ausschalten von Hand oder durch die Messung. Ohne Einstellung oder ohne Open-Meteo-Solarprognose passiert nichts (snowAutoAvailable). Ein fehlgeschlagener Abruf wird einmal je Serie gewarnt, der Schalter bleibt. Route /api/snowauto, Restore, Tests mit aufgezeichneter Antwort. Co-Authored-By: Claude Sonnet 5.5 --- core/keys/site_custom.go | 6 + core/site/api_custom.go | 2 + core/site_restore_test.go | 2 + core/site_snow.go | 58 ++- core/site_snow_auto.go | 319 +++++++++++++ core/site_snow_auto_test.go | 545 +++++++++++++++++++++++ core/site_snow_test.go | 2 + core/testdata/open-meteo-snow-tirol.json | 1 + server/http_custom.go | 3 +- server/http_custom_test.go | 2 + 10 files changed, 927 insertions(+), 13 deletions(-) create mode 100644 core/site_snow_auto.go create mode 100644 core/site_snow_auto_test.go create mode 100644 core/testdata/open-meteo-snow-tirol.json diff --git a/core/keys/site_custom.go b/core/keys/site_custom.go index a353ef0988..6959951263 100644 --- a/core/keys/site_custom.go +++ b/core/keys/site_custom.go @@ -75,4 +75,10 @@ const ( // snow on pv, the optimizer plans without solar yield, see core/site_snow.go SnowCover = "snowCover" + + // snow on pv, detected from the weather, see core/site_snow_auto.go + SnowAuto = "snowAuto" // setting: detect snow automatically + SnowCoverAuto = "snowCoverAuto" // the switch was turned on automatically + SnowAutoAvailable = "snowAutoAvailable" // published: an Open-Meteo solar forecast gives the location + SnowSeen = "snowSeen" // end of the last snow already counted ) diff --git a/core/site/api_custom.go b/core/site/api_custom.go index 4513bafbf8..4e9b3bb345 100644 --- a/core/site/api_custom.go +++ b/core/site/api_custom.go @@ -64,6 +64,8 @@ type CustomAPI interface { // snow on pv, see core/site_snow.go GetSnowCover() bool SetSnowCover(bool) error + GetSnowAuto() bool + SetSnowAuto(bool) error // peak shaving, see core/site_peakshaving.go GetPeakShaving() bool diff --git a/core/site_restore_test.go b/core/site_restore_test.go index 75b43251c3..cca72418ae 100644 --- a/core/site_restore_test.go +++ b/core/site_restore_test.go @@ -55,6 +55,7 @@ func TestRestoreCustomAfterRestart(t *testing.T) { 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)) + require.NoError(t, a.SetSnowAuto(true)) // set through Home Assistant in the ui, stored as names settings.SetString(keys.PeakShavingEntity, "input_number.peak") @@ -93,4 +94,5 @@ func TestRestoreCustomAfterRestart(t *testing.T) { assert.Equal(t, "info", b.LogFile().Level) assert.Equal(t, 30, b.LogFile().Days) assert.True(t, b.GetSnowCover()) + assert.True(t, b.GetSnowAuto()) } diff --git a/core/site_snow.go b/core/site_snow.go index ee90c651e7..f086f1c4ee 100644 --- a/core/site_snow.go +++ b/core/site_snow.go @@ -3,8 +3,9 @@ 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. +// off once the system produces near the forecast again; with the detection on +// it also turns itself on, see site_snow_auto.go. Without the switch nothing +// changes. See the README for the rules. import ( "sync" @@ -32,6 +33,15 @@ type snowState struct { cover bool // the switch clear int // free slots in a row, memory only slot time.Time // start of the last slot rated, memory only + + // detection from the weather, see site_snow_auto.go + auto bool // the setting + byAuto bool // the switch was turned on by the detection + seen time.Time // end of the last snow slot counted + available bool // an Open-Meteo solar forecast gives the location + fetchAt time.Time // last attempt, memory only + fetching bool // an attempt is running, memory only + failed bool // the last attempt failed, memory only } // snow returns the snow on pv state @@ -43,13 +53,26 @@ func (site *Site) snow() *snowState { func (site *Site) restoreSnowCover() { s := site.snow() + s.mu.Lock() if v, err := settings.Bool(keys.SnowCover); err == nil { - s.mu.Lock() s.cover = v - s.mu.Unlock() } + if v, err := settings.Bool(keys.SnowCoverAuto); err == nil { + s.byAuto = v && s.cover + } + if v, err := settings.Bool(keys.SnowAuto); err == nil { + s.auto = v + } + if v, err := settings.Time(keys.SnowSeen); err == nil { + s.seen = v + } + cover, byAuto, auto := s.cover, s.byAuto, s.auto + s.mu.Unlock() - site.publish(keys.SnowCover, site.GetSnowCover()) + site.publish(keys.SnowCover, cover) + site.publish(keys.SnowCoverAuto, byAuto) + site.publish(keys.SnowAuto, auto) + site.updateSnowAvailable() } // GetSnowCover returns the snow on pv switch @@ -62,29 +85,38 @@ func (site *Site) GetSnowCover() bool { return s.cover } -// SetSnowCover switches snow on pv. The optimizer runs again at once, as for -// upstream's adjusted forecast. +// SetSnowCover switches snow on pv by hand. The optimizer runs again at once, +// as for upstream's adjusted forecast. func (site *Site) SetSnowCover(val bool) error { + site.setSnowCover(val, false) + return nil +} + +// setSnowCover switches snow on pv, byAuto when the detection turns it on +func (site *Site) setSnowCover(val, byAuto bool) { s := site.snow() s.mu.Lock() changed := s.cover != val - s.cover = val + if changed { + s.cover = val + s.byAuto = val && byAuto + } s.clear = 0 s.slot = time.Time{} s.mu.Unlock() if !changed { - return nil + return } - site.log.DEBUG.Println("set snow on pv:", val) + site.log.DEBUG.Println("set snow on pv:", val, "automatic:", val && byAuto) settings.SetBool(keys.SnowCover, val) + settings.SetBool(keys.SnowCoverAuto, val && byAuto) site.publish(keys.SnowCover, val) + site.publish(keys.SnowCoverAuto, val && byAuto) go site.optimizerUpdateAsync(0) - - return nil } // snowClearStep rates a completed slot: the new count of free slots in a row @@ -131,6 +163,8 @@ func (site *Site) snowSlotEnergy(refs ...string) (float64, bool) { // 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) { + site.updateSnowAuto(now) + s := site.snow() s.mu.Lock() diff --git a/core/site_snow_auto.go b/core/site_snow_auto.go new file mode 100644 index 0000000000..a00511c3d2 --- /dev/null +++ b/core/site_snow_auto.go @@ -0,0 +1,319 @@ +package core + +// Custom extension: detect snow from the weather. With the setting on, the +// weather forecast of Open-Meteo is fetched every 30 minutes for the location +// of the Open-Meteo solar forecast. Enough new snow in the last 24 hours or +// before the next sunrise turns the switch of site_snow.go on, so the plan for +// the first snow day already runs without solar yield. Turning off stays with +// the measurement there. Without the setting, or without an Open-Meteo solar +// forecast, nothing happens. See the README for the rules. + +import ( + "fmt" + "sort" + "time" + + "github.com/evcc-io/evcc/api/globalconfig" + "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/db" + "github.com/evcc-io/evcc/db/settings" + "github.com/evcc-io/evcc/util/config" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/templates" + "github.com/spf13/cast" +) + +const ( + // the weather is fetched this often, a failed attempt is repeated after it as well + snowAutoInterval = 30 * time.Minute + // snow in cm within snowAutoWindow turns the switch on + snowAutoMinCm = 1.0 + // only snow at this air temperature in °C or below lies on the modules + snowAutoMaxTemp = 1.0 + // window of the snow amount, looking back as far as it is long + snowAutoWindow = 24 * time.Hour + // the template of the solar forecast the location is taken from + snowAutoTemplate = "open-meteo" +) + +// snowAutoURI is the Open-Meteo forecast, a variable for the tests +var snowAutoURI = "https://api.open-meteo.com/v1/forecast" + +// snowSlot is a quarter hour of the weather forecast +type snowSlot struct { + End time.Time // the amounts are those of the quarter hour before + Cm float64 // snowfall in cm + Temp float64 // air temperature in °C +} + +// snowWeather is the weather data of Open-Meteo +type snowWeather struct { + Slots []snowSlot + Sunrises []time.Time +} + +// snowResponse is the part of Open-Meteo's answer that is used +type snowResponse struct { + Minutely15 struct { + Time []int64 `json:"time"` + Snowfall []*float64 `json:"snowfall"` + Temp []*float64 `json:"temperature_2m"` + } `json:"minutely_15"` + Daily struct { + Sunrise []int64 `json:"sunrise"` + } `json:"daily"` +} + +// weather converts the answer, quarter hours without a value are left out +func (r snowResponse) weather() snowWeather { + var res snowWeather + + m := r.Minutely15 + for i, t := range m.Time { + if i >= len(m.Snowfall) || i >= len(m.Temp) || m.Snowfall[i] == nil || m.Temp[i] == nil { + continue + } + res.Slots = append(res.Slots, snowSlot{time.Unix(t, 0).UTC(), *m.Snowfall[i], *m.Temp[i]}) + } + + for _, t := range r.Daily.Sunrise { + res.Sunrises = append(res.Sunrises, time.Unix(t, 0).UTC()) + } + + return res +} + +// snowUntil returns the end of the forecast that counts: the next sunrise, +// the snow of the day after is looked at again then. Without a sunrise in the +// data it is the length of the window. +func snowUntil(sunrises []time.Time, now time.Time) time.Time { + for _, t := range sunrises { + if t.After(now) { + return t + } + } + + return now.Add(snowAutoWindow) +} + +// snowFall returns the snow in cm the slots hold in the most snowy 24 hours, +// the end of the last snowy slot and whether the amount turns the switch on. +// Counted are the slots at or below the temperature limit from the last 24 hours +// until the end, that end after seen: snow counted before does not count again. +func snowFall(slots []snowSlot, now, until, seen time.Time) (cm float64, last time.Time, ok bool) { + var snow []snowSlot + for _, s := range slots { + if s.Cm > 0 && s.Temp <= snowAutoMaxTemp && s.End.After(now.Add(-snowAutoWindow)) && !s.End.After(until) && s.End.After(seen) { + snow = append(snow, s) + } + } + + sort.Slice(snow, func(i, j int) bool { return snow[i].End.Before(snow[j].End) }) + + for i, from := range snow { + var sum float64 + for _, s := range snow[i:] { + if !s.End.Before(from.End.Add(snowAutoWindow)) { + break + } + sum += s.Cm + } + cm = max(cm, sum) + } + + if len(snow) > 0 { + last = snow[len(snow)-1].End + } + + // the sum of the quarter hours is not exact + return cm, last, cm+1e-9 >= snowAutoMinCm +} + +// snowCoordinates returns the location of the first Open-Meteo solar forecast +// of the tariffs set up in the ui +func snowCoordinates() (lat, lon float64, ok bool) { + if db.Instance == nil { + return 0, 0, false + } + + var refs globalconfig.TariffRefs + if err := settings.Json(keys.TariffRefs, &refs); err != nil { + return 0, 0, false + } + + for _, ref := range refs.Solar { + id, err := config.IDForName(ref) + if err != nil { + continue + } + + conf, err := config.ConfigByID(id) + if err != nil || conf.Class != templates.Tariff || conf.Disable || conf.Data["template"] != snowAutoTemplate { + continue + } + + lat, errLat := cast.ToFloat64E(conf.Data["lat"]) + lon, errLon := cast.ToFloat64E(conf.Data["lon"]) + if errLat != nil || errLon != nil || lat < -90 || lat > 90 || lon < -180 || lon > 180 { + continue + } + + return lat, lon, true + } + + return 0, 0, false +} + +// snowFetch gets the weather of the location +func (site *Site) snowFetch(lat, lon float64) (snowWeather, error) { + uri := fmt.Sprintf("%s?latitude=%v&longitude=%v&minutely_15=snowfall,temperature_2m&daily=sunrise&past_days=1&forecast_days=2&timezone=GMT&timeformat=unixtime", snowAutoURI, lat, lon) + + var res snowResponse + if err := request.NewHelper(site.log).GetJSON(uri, &res); err != nil { + return snowWeather{}, err + } + + w := res.weather() + if len(w.Slots) == 0 { + return snowWeather{}, fmt.Errorf("no weather data for %v, %v", lat, lon) + } + + return w, nil +} + +// updateSnowAvailable looks up the location and publishes whether an +// Open-Meteo solar forecast gives one +func (site *Site) updateSnowAvailable() (lat, lon float64, ok bool) { + lat, lon, ok = snowCoordinates() + + s := site.snow() + + s.mu.Lock() + s.available = ok + s.mu.Unlock() + + site.publish(keys.SnowAutoAvailable, ok) + + return lat, lon, ok +} + +// GetSnowAuto returns whether snow is detected from the weather +func (site *Site) GetSnowAuto() bool { + s := site.snow() + + s.mu.Lock() + defer s.mu.Unlock() + + return s.auto +} + +// SetSnowAuto sets whether snow is detected from the weather +func (site *Site) SetSnowAuto(val bool) error { + s := site.snow() + + s.mu.Lock() + changed := s.auto != val + s.auto = val + // the weather is fetched at the next cycle + s.fetchAt = time.Time{} + s.mu.Unlock() + + if !changed { + return nil + } + + site.log.DEBUG.Println("set snow detection:", val) + settings.SetBool(keys.SnowAuto, val) + site.publish(keys.SnowAuto, val) + + return nil +} + +// updateSnowAuto fetches the weather every 30 minutes while the detection is +// on, in the background as the request can take its time +func (site *Site) updateSnowAuto(now time.Time) { + s := site.snow() + + s.mu.Lock() + due := s.auto && !s.fetching && (s.fetchAt.IsZero() || now.Sub(s.fetchAt) >= snowAutoInterval) + if due { + s.fetching = true + s.fetchAt = now + } + s.mu.Unlock() + + if !due { + return + } + + go func() { + defer func() { + s.mu.Lock() + s.fetching = false + s.mu.Unlock() + }() + + site.snowAutoRun(now) + }() +} + +// snowAutoRun fetches the weather and turns the switch on for enough new snow +func (site *Site) snowAutoRun(now time.Time) { + if !site.GetSnowAuto() { + return + } + + s := site.snow() + + lat, lon, ok := site.updateSnowAvailable() + if !ok { + return + } + + w, err := site.snowFetch(lat, lon) + + s.mu.Lock() + failed := s.failed + s.failed = err != nil + auto, seen := s.auto, s.seen + s.mu.Unlock() + + if err != nil { + // once per series of failures, the next attempts come every 30 minutes + if !failed { + site.log.WARN.Printf("snow detection: weather data not available: %v", err) + } else { + site.log.DEBUG.Printf("snow detection: weather data not available: %v", err) + } + return + } + if failed { + site.log.INFO.Println("snow detection: weather data available again") + } + + if !auto { + // switched off meanwhile + return + } + + cm, last, ok := snowFall(w.Slots, now, snowUntil(w.Sunrises, now), seen) + if !ok { + site.log.TRACE.Printf("snow detection: %.2f cm of snow, below %.0f cm", cm, snowAutoMinCm) + return + } + + // the snow is counted: it does not turn the switch on again, also not after + // the switch went off by the measurement or by hand + s.mu.Lock() + s.seen = last + on := s.cover + s.mu.Unlock() + settings.SetTime(keys.SnowSeen, last) + + if on { + return + } + + site.log.INFO.Printf("pv snow cover on: %.1f cm of snow at up to %.0f °C until %s", cm, snowAutoMaxTemp, last.Local().Format("2006-01-02 15:04")) + site.setSnowCover(true, true) +} diff --git a/core/site_snow_auto_test.go b/core/site_snow_auto_test.go new file mode 100644 index 0000000000..89da5d51c1 --- /dev/null +++ b/core/site_snow_auto_test.go @@ -0,0 +1,545 @@ +package core + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "sync/atomic" + "testing" + "time" + + "github.com/evcc-io/evcc/api/globalconfig" + "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/db" + "github.com/evcc-io/evcc/db/settings" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/config" + "github.com/evcc-io/evcc/util/templates" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// the recorded forecast of Innsbruck for 24 to 26 January 2026: 4.4 cm of snow +// at -0.4 to +0.5 °C on the 25th ending 16:00 to 21:45 GMT, the next sunrise at 06:47 on the 26th +const snowTirolData = "testdata/open-meteo-snow-tirol.json" + +var snowTirolSnowEnd = time.Date(2026, 1, 25, 21, 45, 0, 0, time.UTC) + +func snowAt(day, hour, min int) time.Time { + return time.Date(2026, 1, day, hour, min, 0, 0, time.UTC) +} + +func TestSnowFall(t *testing.T) { + now := snowAt(10, 12, 0) + + // n quarter hours of the amount per quarter hour at the temperature, the first ending at from + run := func(from time.Time, n int, cm, temp float64) []snowSlot { + var res []snowSlot + for i := range n { + res = append(res, snowSlot{from.Add(time.Duration(i) * 15 * time.Minute), cm, temp}) + } + return res + } + + tc := []struct { + name string + slots []snowSlot + until time.Time + seen time.Time + cm float64 + ok bool + }{ + {"1 cm at -2 °C", run(snowAt(10, 6, 0), 4, 0.25, -2), snowAt(11, 7, 0), time.Time{}, 1, true}, + {"1 cm at +3 °C", run(snowAt(10, 6, 0), 4, 0.25, 3), snowAt(11, 7, 0), time.Time{}, 0, false}, + {"1 cm at exactly +1 °C", run(snowAt(10, 6, 0), 4, 0.25, 1), snowAt(11, 7, 0), time.Time{}, 1, true}, + {"0.5 cm", run(snowAt(10, 6, 0), 4, 0.125, -2), snowAt(11, 7, 0), time.Time{}, 0.5, false}, + {"ten times 0.1 cm is not short by rounding", run(snowAt(10, 6, 0), 10, 0.1, -2), snowAt(11, 7, 0), time.Time{}, 1, true}, + {"rain does not count", run(snowAt(10, 6, 0), 8, 0, 5), snowAt(11, 7, 0), time.Time{}, 0, false}, + {"mixed: only the cold quarter hours add up", append(run(snowAt(10, 6, 0), 4, 0.25, 4), run(snowAt(10, 7, 0), 3, 0.25, -1)...), snowAt(11, 7, 0), time.Time{}, 0.75, false}, + {"forecast for tonight counts", run(snowAt(10, 22, 0), 4, 0.25, -2), snowAt(11, 7, 0), time.Time{}, 1, true}, + {"after the next sunrise does not count yet", run(snowAt(11, 8, 0), 4, 0.25, -2), snowAt(11, 7, 0), time.Time{}, 0, false}, + {"snow ending at the sunrise counts", run(snowAt(11, 6, 15), 4, 0.25, -2), snowAt(11, 7, 0), time.Time{}, 1, true}, + {"older than 24 h does not count", run(snowAt(9, 10, 0), 4, 0.25, -2), snowAt(11, 7, 0), time.Time{}, 0, false}, + {"just inside the 24 h counts", run(snowAt(9, 12, 15), 4, 0.25, -2), snowAt(11, 7, 0), time.Time{}, 1, true}, + {"0.6 cm now and 0.6 cm in 20 h are 1.2 cm in 24 h", append(run(snowAt(10, 8, 0), 4, 0.15, -2), run(snowAt(11, 4, 0), 4, 0.15, -2)...), snowAt(11, 7, 0), time.Time{}, 1.2, true}, + {"0.6 cm yesterday and 0.6 cm in 26 h are not 24 h", append(run(snowAt(9, 13, 0), 4, 0.15, -2), run(snowAt(10, 15, 0), 4, 0.15, -2)...), snowAt(11, 15, 0), time.Time{}, 0.6, false}, + {"already counted does not count again", run(snowAt(10, 6, 0), 4, 0.25, -2), snowAt(11, 7, 0), snowAt(10, 7, 0), 0, false}, + {"snow after the counted counts alone", append(run(snowAt(10, 6, 0), 4, 0.25, -2), run(snowAt(10, 20, 0), 3, 0.25, -2)...), snowAt(11, 7, 0), snowAt(10, 7, 0), 0.75, false}, + {"enough new snow after the counted", append(run(snowAt(10, 6, 0), 4, 0.25, -2), run(snowAt(10, 20, 0), 4, 0.25, -2)...), snowAt(11, 7, 0), snowAt(10, 7, 0), 1, true}, + {"nothing", nil, snowAt(11, 7, 0), time.Time{}, 0, false}, + } + + for _, tc := range tc { + t.Run(tc.name, func(t *testing.T) { + cm, last, ok := snowFall(tc.slots, now, tc.until, tc.seen) + assert.InDelta(t, tc.cm, cm, 1e-9) + assert.Equal(t, tc.ok, ok) + + if cm > 0 { + // the last counted quarter hour is the one to remember + assert.True(t, last.After(tc.seen), "last %v", last) + assert.False(t, last.After(tc.until), "last %v", last) + } else { + assert.True(t, last.IsZero()) + } + }) + } + + // the last quarter hour counted, not the amount's window + slots := append(run(snowAt(10, 6, 0), 4, 0.25, -2), run(snowAt(10, 22, 0), 2, 0.25, 5)...) + _, last, _ := snowFall(slots, now, snowAt(11, 7, 0), time.Time{}) + assert.Equal(t, snowAt(10, 6, 45), last, "warm quarter hours are not counted") +} + +func TestSnowUntil(t *testing.T) { + sunrises := []time.Time{snowAt(9, 7, 0), snowAt(10, 7, 0), snowAt(11, 7, 0)} + + assert.Equal(t, snowAt(10, 7, 0), snowUntil(sunrises, snowAt(10, 5, 0)), "this morning's sunrise is next") + assert.Equal(t, snowAt(11, 7, 0), snowUntil(sunrises, snowAt(10, 7, 0)), "at the sunrise the next one") + assert.Equal(t, snowAt(11, 7, 0), snowUntil(sunrises, snowAt(10, 12, 0))) + assert.Equal(t, snowAt(11, 12, 0), snowUntil(sunrises[:2], snowAt(10, 12, 0)), "without a next sunrise 24 h") + assert.Equal(t, snowAt(11, 12, 0), snowUntil(nil, snowAt(10, 12, 0))) +} + +func readSnowTirol(t *testing.T) []byte { + t.Helper() + b, err := os.ReadFile(snowTirolData) + require.NoError(t, err) + return b +} + +// TestSnowAutoRecordedForecast: the recorded answer is read as the quarter +// hours and sunrises it holds, and rated at several times of the snow day +func TestSnowAutoRecordedForecast(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(readSnowTirol(t)) + })) + defer srv.Close() + setSnowAutoURI(t, srv.URL) + + site := &Site{log: util.NewLogger("test")} + w, err := site.snowFetch(47.27, 11.39) + require.NoError(t, err) + + assert.Len(t, w.Slots, 288, "three days of quarter hours") + assert.Len(t, w.Sunrises, 3) + assert.Equal(t, snowAt(24, 0, 0), w.Slots[0].End) + assert.Equal(t, snowAt(26, 6, 47), w.Sunrises[2].UTC().Truncate(time.Minute)) + + rate := func(now, seen time.Time) (float64, bool) { + cm, _, ok := snowFall(w.Slots, now, snowUntil(w.Sunrises, now), seen) + return cm, ok + } + + // the day before: no snow yet, the next sunrise comes before it + cm, ok := rate(snowAt(24, 20, 0), time.Time{}) + assert.Zero(t, cm) + assert.False(t, ok) + + // the morning of the snow day: it comes in the evening, before the next + // sunrise, so it counts already + cm, ok = rate(snowAt(25, 8, 0), time.Time{}) + assert.InDelta(t, 4.3, cm, 0.05) + assert.True(t, ok) + + // while it snows, and after it: still in the 24 h + for _, now := range []time.Time{snowAt(25, 19, 0), snowAt(26, 6, 0), snowAt(26, 15, 0)} { + _, ok = rate(now, time.Time{}) + assert.True(t, ok, now) + } + + // counted once, it does not count again + _, ok = rate(snowAt(25, 19, 0), snowTirolSnowEnd) + assert.False(t, ok) + _, ok = rate(snowAt(25, 8, 0), snowTirolSnowEnd) + assert.False(t, ok) + + // two days later it has left the window + cm, ok = rate(snowAt(26, 22, 30), time.Time{}) + assert.Zero(t, cm) + assert.False(t, ok) +} + +func setSnowAutoURI(t *testing.T, uri string) { + t.Helper() + prev := snowAutoURI + snowAutoURI = uri + t.Cleanup(func() { snowAutoURI = prev }) +} + +// snowAutoDB opens a database with the tariffs of the ui: the solar +// forecasts of the templates given, in this order, the first one as the first +// reference +func snowAutoDB(t *testing.T, tariffs ...config.Properties) { + t.Helper() + + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + t.Cleanup(func() { db.Instance = nil }) + + var refs globalconfig.TariffRefs + for _, p := range tariffs { + data := map[string]any{"template": p.Product, "lat": 47.27, "lon": "11.39"} + p.Product = "" + conf, err := config.AddConfig(templates.Tariff, data, config.WithProperties(p)) + require.NoError(t, err) + refs.Solar = append(refs.Solar, config.NameForID(conf.ID)) + } + + require.NoError(t, settings.SetJson(keys.TariffRefs, refs)) +} + +func snowTariff(template string) config.Properties { + return config.Properties{Type: "template", Product: template} +} + +func TestSnowCoordinates(t *testing.T) { + keepSettings(t) + + noSettingsDB(t) + _, _, ok := snowCoordinates() + assert.False(t, ok, "no database") + + snowAutoDB(t) + _, _, ok = snowCoordinates() + assert.False(t, ok, "no solar forecast") + + disabled := snowTariff("open-meteo") + disabled.Disable = true + + snowAutoDB(t, snowTariff("forecast-solar"), disabled) + _, _, ok = snowCoordinates() + assert.False(t, ok, "other templates and disabled ones do not give the location") + + snowAutoDB(t, snowTariff("forecast-solar"), snowTariff("open-meteo")) + lat, lon, ok := snowCoordinates() + assert.True(t, ok) + assert.Equal(t, 47.27, lat) + assert.Equal(t, 11.39, lon, "also stored as text") + + // the first one + snowAutoDB(t, disabled, snowTariff("open-meteo"), snowTariff("open-meteo")) + _, _, ok = snowCoordinates() + assert.True(t, ok) + + // a location out of range is none + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + conf, err := config.AddConfig(templates.Tariff, map[string]any{"template": "open-meteo", "lat": 147.0, "lon": 11.0}) + require.NoError(t, err) + require.NoError(t, settings.SetJson(keys.TariffRefs, globalconfig.TariffRefs{Solar: []string{config.NameForID(conf.ID)}})) + _, _, ok = snowCoordinates() + assert.False(t, ok) + db.Instance = nil +} + +// snowAutoSite is a site with the weather served as data, the requests counted +func snowAutoSite(t *testing.T, data func() []byte, status *atomic.Int32) (*Site, chan util.Param, *atomic.Int32) { + t.Helper() + + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if code := int(status.Load()); code != 0 { + w.WriteHeader(code) + return + } + _, _ = w.Write(data()) + })) + t.Cleanup(srv.Close) + setSnowAutoURI(t, srv.URL) + + pub := make(chan util.Param, 64) + return &Site{log: util.NewLogger("test"), valueChan: pub}, pub, &calls +} + +// TestSnowAutoTurnsOn: the weather turns the switch on once for snow in the +// forecast, marked as automatic; it does not come back after the switch went +// off, by hand or by the measurement, until new snow falls +func TestSnowAutoTurnsOn(t *testing.T) { + keepSettings(t) + snowAutoDB(t, snowTariff("open-meteo")) + + var status atomic.Int32 + data := readSnowTirol(t) + site, pub, calls := snowAutoSite(t, func() []byte { return data }, &status) + + // the setting is off: inert, no request + site.snowAutoRun(snowAt(25, 8, 0)) + site.updateSnowAuto(snowAt(25, 8, 0)) + assert.False(t, site.GetSnowCover()) + assert.Zero(t, calls.Load()) + + require.NoError(t, site.SetSnowAuto(true)) + assert.Equal(t, util.Param{Key: keys.SnowAuto, Val: true}, <-pub) + + // the morning before the snow: it is in the forecast for tonight + site.snowAutoRun(snowAt(25, 8, 0)) + assert.Equal(t, int32(1), calls.Load()) + assert.True(t, site.GetSnowCover()) + assert.True(t, site.snow().byAuto) + assert.Equal(t, snowTirolSnowEnd, site.snow().seen.UTC()) + + // the state is published and saved + got := map[string]any{} + for len(pub) > 0 { + p := <-pub + got[p.Key] = p.Val + } + assert.Equal(t, true, got[keys.SnowCover]) + assert.Equal(t, true, got[keys.SnowCoverAuto]) + assert.Equal(t, true, got[keys.SnowAutoAvailable]) + v, err := settings.Bool(keys.SnowCoverAuto) + require.NoError(t, err) + assert.True(t, v) + seen, err := settings.Time(keys.SnowSeen) + require.NoError(t, err) + assert.True(t, snowTirolSnowEnd.Equal(seen)) + + // off by hand: the same snow does not turn it on again + require.NoError(t, site.SetSnowCover(false)) + assert.False(t, site.snow().byAuto) + site.snowAutoRun(snowAt(25, 12, 0)) + site.snowAutoRun(snowAt(26, 8, 0)) + assert.False(t, site.GetSnowCover(), "off by hand stays off") + + // off by the measurement, after the snow, still within its 24 h + site.setSnowCover(true, true) + assert.True(t, site.GetSnowCover()) + site.setSnowCover(false, false) + site.snowAutoRun(snowAt(26, 15, 0)) + assert.False(t, site.GetSnowCover()) + + // new snow on the next day turns it on again: 1 cm at -3 °C at 08:00 to 08:45 on the 27th + slots := readSnowTirolResponse(t) + slots.add(snowAt(27, 8, 0), 4, 0.25, -3) + data = slots.json(t) + site.snowAutoRun(snowAt(27, 9, 0)) + assert.True(t, site.GetSnowCover()) + assert.True(t, site.snow().byAuto) + assert.Equal(t, snowAt(27, 8, 45), site.snow().seen.UTC()) + + // switched on by hand while it is on, nothing changes: the mark stays + require.NoError(t, site.SetSnowCover(true)) + assert.True(t, site.snow().byAuto) + + // the setting off in between: the answer is not used + require.NoError(t, site.SetSnowCover(false)) + slots.add(snowAt(28, 8, 0), 4, 0.25, -3) + data = slots.json(t) + require.NoError(t, site.SetSnowAuto(false)) + site.snowAutoRun(snowAt(28, 9, 0)) + assert.False(t, site.GetSnowCover()) +} + +// TestSnowAutoWhileOn: snow counted while the switch is on is remembered, so it +// does not turn the switch on again once the measurement turned it off +func TestSnowAutoWhileOn(t *testing.T) { + keepSettings(t) + snowAutoDB(t, snowTariff("open-meteo")) + + var status atomic.Int32 + data := readSnowTirol(t) + site, _, _ := snowAutoSite(t, func() []byte { return data }, &status) + require.NoError(t, site.SetSnowAuto(true)) + + // on by hand before the detection looked + require.NoError(t, site.SetSnowCover(true)) + site.snowAutoRun(snowAt(25, 20, 0)) + assert.True(t, site.GetSnowCover()) + assert.False(t, site.snow().byAuto, "by hand stays by hand") + assert.Equal(t, snowTirolSnowEnd, site.snow().seen.UTC()) + + // the measurement turns it off: the snow of the last 24 h does not count again + require.NoError(t, site.SetSnowCover(false)) + site.snowAutoRun(snowAt(26, 14, 0)) + assert.False(t, site.GetSnowCover()) +} + +// TestSnowAutoWithoutLocation: without an Open-Meteo solar forecast nothing is +// fetched and the detection is not offered +func TestSnowAutoWithoutLocation(t *testing.T) { + keepSettings(t) + snowAutoDB(t, snowTariff("forecast-solar")) + + var status atomic.Int32 + site, pub, calls := snowAutoSite(t, func() []byte { return nil }, &status) + require.NoError(t, site.SetSnowAuto(true)) + <-pub + + site.snowAutoRun(snowAt(25, 8, 0)) + + assert.Zero(t, calls.Load()) + assert.False(t, site.GetSnowCover()) + assert.False(t, site.snow().available) + assert.Equal(t, util.Param{Key: keys.SnowAutoAvailable, Val: false}, <-pub) + + // restored with the setting: offered as soon as the forecast is there + snowAutoDB(t, snowTariff("open-meteo")) + site.restoreSnowCover() + assert.True(t, site.snow().available) +} + +// TestSnowAutoFailure: an unavailable weather service leaves the switch as it +// is, and is warned about once per series +func TestSnowAutoFailure(t *testing.T) { + keepSettings(t) + snowAutoDB(t, snowTariff("open-meteo")) + + var status atomic.Int32 + data := readSnowTirol(t) + site, _, calls := snowAutoSite(t, func() []byte { return data }, &status) + require.NoError(t, site.SetSnowAuto(true)) + + for _, code := range []int32{500, 500} { + status.Store(code) + site.snowAutoRun(snowAt(25, 8, 0)) + assert.False(t, site.GetSnowCover()) + } + assert.Equal(t, int32(2), calls.Load()) + assert.True(t, site.snow().failed) + + // an answer without data is a failure as well + status.Store(0) + data = []byte(`{"minutely_15":{"time":[],"snowfall":[],"temperature_2m":[]}}`) + site.snowAutoRun(snowAt(25, 8, 0)) + assert.False(t, site.GetSnowCover()) + assert.True(t, site.snow().failed) + + data = []byte("not json") + site.snowAutoRun(snowAt(25, 8, 0)) + assert.False(t, site.GetSnowCover()) + + // and recovers + data = readSnowTirol(t) + site.snowAutoRun(snowAt(25, 8, 0)) + assert.False(t, site.snow().failed) + assert.True(t, site.GetSnowCover()) +} + +// TestSnowAutoInterval: the weather is fetched at the first cycle with the +// setting on and then every 30 minutes, one request at a time +func TestSnowAutoInterval(t *testing.T) { + keepSettings(t) + snowAutoDB(t, snowTariff("open-meteo")) + + var status atomic.Int32 + data := readSnowTirol(t) + site, _, calls := snowAutoSite(t, func() []byte { return data }, &status) + require.NoError(t, site.SetSnowAuto(true)) + + idle := func() bool { + s := site.snow() + s.mu.Lock() + defer s.mu.Unlock() + return !s.fetching + } + + now := snowAt(25, 8, 0) + site.updateSnowAuto(now) + require.Eventually(t, func() bool { return idle() && calls.Load() == 1 }, 5*time.Second, 5*time.Millisecond) + assert.True(t, site.GetSnowCover()) + + for _, d := range []time.Duration{time.Second, 15 * time.Minute, 29 * time.Minute} { + site.updateSnowAuto(now.Add(d)) + } + time.Sleep(50 * time.Millisecond) + assert.Equal(t, int32(1), calls.Load(), "not within 30 minutes") + + site.updateSnowAuto(now.Add(30 * time.Minute)) + require.Eventually(t, func() bool { return idle() && calls.Load() == 2 }, 5*time.Second, 5*time.Millisecond) + + // the setting off: no more requests + require.NoError(t, site.SetSnowAuto(false)) + site.updateSnowAuto(now.Add(2 * time.Hour)) + time.Sleep(50 * time.Millisecond) + assert.Equal(t, int32(2), calls.Load()) + + // on again: at once, not after the rest of the 30 minutes + require.NoError(t, site.SetSnowAuto(true)) + site.updateSnowAuto(now.Add(2*time.Hour + time.Minute)) + require.Eventually(t, func() bool { return idle() && calls.Load() == 3 }, 5*time.Second, 5*time.Millisecond) +} + +// TestSnowAutoRestore: the setting, the mark of an automatic switch and the +// counted snow survive a restart +func TestSnowAutoRestore(t *testing.T) { + keepSettings(t) + noSettingsDB(t) + + a := &Site{log: util.NewLogger("test")} + require.NoError(t, a.SetSnowAuto(true)) + a.setSnowCover(true, true) + a.snow().seen = snowTirolSnowEnd + settings.SetTime(keys.SnowSeen, snowTirolSnowEnd) + + b := &Site{log: util.NewLogger("test")} + b.restoreSnowCover() + assert.True(t, b.GetSnowAuto()) + assert.True(t, b.GetSnowCover()) + assert.True(t, b.snow().byAuto) + assert.True(t, snowTirolSnowEnd.Equal(b.snow().seen)) + + // by hand: not marked + require.NoError(t, a.SetSnowCover(false)) + require.NoError(t, a.SetSnowCover(true)) + c := &Site{log: util.NewLogger("test")} + c.restoreSnowCover() + assert.True(t, c.GetSnowCover()) + assert.False(t, c.snow().byAuto) + + // the mark without the switch is dropped + settings.SetBool(keys.SnowCover, false) + settings.SetBool(keys.SnowCoverAuto, true) + d := &Site{log: util.NewLogger("test")} + d.restoreSnowCover() + assert.False(t, d.snow().byAuto) +} + +// snowTestData is the recorded answer to change: further days of snow added +type snowTestData struct { + Time []int64 + Snowfall []float64 + Temp []float64 + Sunrise []int64 +} + +func readSnowTirolResponse(t *testing.T) *snowTestData { + t.Helper() + + var r snowResponse + require.NoError(t, json.Unmarshal(readSnowTirol(t), &r)) + + d := &snowTestData{Time: r.Minutely15.Time, Sunrise: r.Daily.Sunrise} + for i := range d.Time { + d.Snowfall = append(d.Snowfall, *r.Minutely15.Snowfall[i]) + d.Temp = append(d.Temp, *r.Minutely15.Temp[i]) + } + + return d +} + +// add lets it snow n quarter hours, the first ending at from, after the data +// that is there; quarter hours in between are dry and mild +func (d *snowTestData) add(from time.Time, n int, cm, temp float64) { + for t := time.Unix(d.Time[len(d.Time)-1], 0).Add(15 * time.Minute); t.Before(from.Add(time.Duration(n) * 15 * time.Minute)); t = t.Add(15 * time.Minute) { + snow, tmp := 0.0, 5.0 + if !t.Before(from) { + snow, tmp = cm, temp + } + d.Time = append(d.Time, t.Unix()) + d.Snowfall = append(d.Snowfall, snow) + d.Temp = append(d.Temp, tmp) + } +} + +func (d *snowTestData) json(t *testing.T) []byte { + t.Helper() + + b, err := json.Marshal(map[string]any{ + "minutely_15": map[string]any{"time": d.Time, "snowfall": d.Snowfall, "temperature_2m": d.Temp}, + "daily": map[string]any{"sunrise": d.Sunrise}, + }) + require.NoError(t, err) + + return b +} diff --git a/core/site_snow_test.go b/core/site_snow_test.go index fe92bcb0d9..29cf2226b5 100644 --- a/core/site_snow_test.go +++ b/core/site_snow_test.go @@ -134,6 +134,7 @@ func TestSnowCoverAutoOff(t *testing.T) { require.NoError(t, site.SetSnowCover(true)) assert.Equal(t, util.Param{Key: keys.SnowCover, Val: true}, <-pub) + assert.Equal(t, util.Param{Key: keys.SnowCoverAuto, Val: false}, <-pub) // the first slot is not complete yet: tried again, nothing is marked site.updateSnowCover(clk.Now()) @@ -167,6 +168,7 @@ func TestSnowCoverAutoOff(t *testing.T) { require.NoError(t, err) assert.False(t, v) assert.Equal(t, util.Param{Key: keys.SnowCover, Val: false}, <-pub) + assert.Equal(t, util.Param{Key: keys.SnowCoverAuto, Val: false}, <-pub) // off: further slots change nothing slot(0, 400) diff --git a/core/testdata/open-meteo-snow-tirol.json b/core/testdata/open-meteo-snow-tirol.json new file mode 100644 index 0000000000..2050b170f3 --- /dev/null +++ b/core/testdata/open-meteo-snow-tirol.json @@ -0,0 +1 @@ +{"latitude":47.260002,"longitude":11.379999,"generationtime_ms":23.331403732299805,"utc_offset_seconds":0,"timezone":"GMT","timezone_abbreviation":"GMT","elevation":590.0,"minutely_15_units":{"time":"unixtime","snowfall":"cm","temperature_2m":"°C"},"minutely_15":{"time":[1769212800,1769213700,1769214600,1769215500,1769216400,1769217300,1769218200,1769219100,1769220000,1769220900,1769221800,1769222700,1769223600,1769224500,1769225400,1769226300,1769227200,1769228100,1769229000,1769229900,1769230800,1769231700,1769232600,1769233500,1769234400,1769235300,1769236200,1769237100,1769238000,1769238900,1769239800,1769240700,1769241600,1769242500,1769243400,1769244300,1769245200,1769246100,1769247000,1769247900,1769248800,1769249700,1769250600,1769251500,1769252400,1769253300,1769254200,1769255100,1769256000,1769256900,1769257800,1769258700,1769259600,1769260500,1769261400,1769262300,1769263200,1769264100,1769265000,1769265900,1769266800,1769267700,1769268600,1769269500,1769270400,1769271300,1769272200,1769273100,1769274000,1769274900,1769275800,1769276700,1769277600,1769278500,1769279400,1769280300,1769281200,1769282100,1769283000,1769283900,1769284800,1769285700,1769286600,1769287500,1769288400,1769289300,1769290200,1769291100,1769292000,1769292900,1769293800,1769294700,1769295600,1769296500,1769297400,1769298300,1769299200,1769300100,1769301000,1769301900,1769302800,1769303700,1769304600,1769305500,1769306400,1769307300,1769308200,1769309100,1769310000,1769310900,1769311800,1769312700,1769313600,1769314500,1769315400,1769316300,1769317200,1769318100,1769319000,1769319900,1769320800,1769321700,1769322600,1769323500,1769324400,1769325300,1769326200,1769327100,1769328000,1769328900,1769329800,1769330700,1769331600,1769332500,1769333400,1769334300,1769335200,1769336100,1769337000,1769337900,1769338800,1769339700,1769340600,1769341500,1769342400,1769343300,1769344200,1769345100,1769346000,1769346900,1769347800,1769348700,1769349600,1769350500,1769351400,1769352300,1769353200,1769354100,1769355000,1769355900,1769356800,1769357700,1769358600,1769359500,1769360400,1769361300,1769362200,1769363100,1769364000,1769364900,1769365800,1769366700,1769367600,1769368500,1769369400,1769370300,1769371200,1769372100,1769373000,1769373900,1769374800,1769375700,1769376600,1769377500,1769378400,1769379300,1769380200,1769381100,1769382000,1769382900,1769383800,1769384700,1769385600,1769386500,1769387400,1769388300,1769389200,1769390100,1769391000,1769391900,1769392800,1769393700,1769394600,1769395500,1769396400,1769397300,1769398200,1769399100,1769400000,1769400900,1769401800,1769402700,1769403600,1769404500,1769405400,1769406300,1769407200,1769408100,1769409000,1769409900,1769410800,1769411700,1769412600,1769413500,1769414400,1769415300,1769416200,1769417100,1769418000,1769418900,1769419800,1769420700,1769421600,1769422500,1769423400,1769424300,1769425200,1769426100,1769427000,1769427900,1769428800,1769429700,1769430600,1769431500,1769432400,1769433300,1769434200,1769435100,1769436000,1769436900,1769437800,1769438700,1769439600,1769440500,1769441400,1769442300,1769443200,1769444100,1769445000,1769445900,1769446800,1769447700,1769448600,1769449500,1769450400,1769451300,1769452200,1769453100,1769454000,1769454900,1769455800,1769456700,1769457600,1769458500,1769459400,1769460300,1769461200,1769462100,1769463000,1769463900,1769464800,1769465700,1769466600,1769467500,1769468400,1769469300,1769470200,1769471100],"snowfall":[0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.07,0.07,0.07,0.07,0.07,0.07,0.14,0.14,0.14,0.14,0.21,0.21,0.14,0.21,0.28,0.28,0.21,0.21,0.28,0.42,0.42,0.28,0.14,0.07,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00],"temperature_2m":[3.8,4.0,4.2,4.3,4.2,3.8,3.1,2.4,1.9,1.9,2.1,2.4,2.5,2.3,2.0,1.7,1.4,1.2,1.1,1.0,0.8,0.5,0.1,-0.3,-0.6,-0.8,-1.1,-1.2,-1.0,-0.4,0.5,1.5,2.1,2.1,1.8,1.4,1.3,1.4,1.7,2.1,2.4,2.7,2.9,3.2,3.3,3.2,2.9,2.6,2.5,2.6,2.8,3.1,3.3,3.5,3.7,3.9,4.0,4.0,3.9,3.7,3.4,2.9,2.4,1.8,1.2,0.7,0.2,-0.3,-0.5,-0.6,-0.5,-0.3,0.2,1.1,2.3,3.4,4.1,4.2,3.8,3.2,2.6,2.0,1.2,0.5,0.3,0.8,1.8,2.8,3.5,3.8,3.7,3.6,3.5,3.4,3.2,3.1,3.2,3.7,4.3,5.0,5.3,5.3,5.1,4.7,4.4,4.1,3.8,3.5,3.2,2.9,2.6,2.3,1.9,1.5,1.0,0.5,0.0,-0.6,-1.2,-1.8,-2.2,-2.2,-2.0,-1.8,-1.6,-1.7,-1.8,-1.9,-1.8,-1.5,-1.0,-0.3,0.3,1.1,2.1,2.9,3.6,4.1,4.3,4.5,4.6,4.6,4.6,4.5,4.5,4.4,4.3,4.2,4.0,3.9,3.8,3.7,3.4,3.0,2.4,1.8,1.4,1.1,0.8,0.6,0.5,0.4,0.4,0.4,0.4,0.3,0.3,0.2,0.1,0.0,-0.1,-0.1,-0.2,-0.1,0.0,0.1,0.2,0.1,0.0,-0.1,-0.2,-0.2,-0.3,-0.4,-0.4,-0.3,-0.2,-0.1,-0.1,-0.1,-0.1,-0.1,-0.1,-0.2,-0.3,-0.4,-0.6,-0.9,-1.3,-1.7,-2.1,-2.5,-2.9,-3.3,-3.5,-3.5,-3.3,-3.2,-3.1,-3.1,-3.3,-3.4,-3.4,-3.3,-3.1,-2.9,-2.8,-2.8,-3.0,-3.2,-3.3,-3.5,-3.7,-3.7,-3.5,-2.8,-1.8,-0.8,0.0,0.5,0.8,1.0,1.3,1.6,1.9,2.2,2.4,2.7,2.9,3.1,3.2,3.3,3.4,3.4,3.3,3.2,3.1,2.9,2.8,2.7,2.6,2.5,2.2,1.8,1.4,0.8,0.3,-0.2,-0.7,-1.2,-1.6,-2.1,-2.6,-3.0,-3.4,-3.7,-3.9,-4.2,-4.3,-4.4,-4.5,-4.6,-4.6,-4.6,-4.5,-4.5,-4.5,-4.5,-4.5,-4.6,-4.8,-5.1,-5.5,-5.8,-5.9,-5.6,-5.1,-4.5]},"daily_units":{"time":"unixtime","sunrise":"unixtime"},"daily":{"time":[1769212800,1769299200,1769385600],"sunrise":[1769237364,1769323703,1769410039]}} \ No newline at end of file diff --git a/server/http_custom.go b/server/http_custom.go index 41c6f1356c..e5d2d3e9d8 100644 --- a/server/http_custom.go +++ b/server/http_custom.go @@ -106,8 +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 + // snow on pv, see core/site_snow.go and core/site_snow_auto.go "snowcover": {"POST", "/snowcover/{value:[01truefalse]+}", boolHandler(site.SetSnowCover, site.GetSnowCover)}, + "snowauto": {"POST", "/snowauto/{value:[01truefalse]+}", boolHandler(site.SetSnowAuto, site.GetSnowAuto)}, // peak shaving, see core/site_peakshaving.go "peakshaving": {"POST", "/peakshaving/{value:[01truefalse]+}", boolHandler(site.SetPeakShaving, site.GetPeakShaving)}, diff --git a/server/http_custom_test.go b/server/http_custom_test.go index a448d42dfd..959f3219b2 100644 --- a/server/http_custom_test.go +++ b/server/http_custom_test.go @@ -120,6 +120,7 @@ var customRouteSamples = map[string]string{ "logfile": "/logfile", "logfileset": "/logfile", "snowcover": "/snowcover/true", + "snowauto": "/snowauto/true", "peakshaving": "/peakshaving/false", "peakshavinglimit": "/peakshavinglimit/7000", "peakfollow": "/peakfollow/true", @@ -183,6 +184,7 @@ func TestCustomRoutesMatch(t *testing.T) { assert.Equal(t, 4000.0, site.GetPeakShavingChargePower()) assert.False(t, site.GetLmEnabled()) assert.True(t, site.GetSnowCover()) + assert.True(t, site.GetSnowAuto()) } func must[T any](v T, err error) T { From 26c4b52ed212952dd9a49da9e8029c7836914a45 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Tue, 6 Oct 2026 01:07:16 +0200 Subject: [PATCH 2/4] Schnee auf PV: Schalter "Schnee automatisch erkennen" auf der Prognose-Seite Unter dem Diagramm der Solar-Karte, nur wenn eine Open-Meteo-Solarprognose den Standort liefert (snowAutoAvailable). Kurzer Hilfetext, Hinweis "automatisch erkannt" wenn die Erkennung den Schalter eingeschaltet hat. Texte de und en. Kein neuer Hook, alles in SnowCoverSwitch.vue. Co-Authored-By: Claude Sonnet 5.5 --- .../components/Forecast/SnowCoverSwitch.vue | 44 ++++++++++++++++++- assets/js/types/evcc-lm.ts | 6 +++ i18n/de.json | 5 ++- i18n/en.json | 5 ++- 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/assets/js/components/Forecast/SnowCoverSwitch.vue b/assets/js/components/Forecast/SnowCoverSwitch.vue index 431935841a..79a940d473 100644 --- a/assets/js/components/Forecast/SnowCoverSwitch.vue +++ b/assets/js/components/Forecast/SnowCoverSwitch.vue @@ -14,8 +14,27 @@ {{ $t("snowCover.label") }} +
+
+ + +
+
+ {{ $t("snowCover.autoHelp") }} +
+
- {{ $t("snowCover.note") }} + {{ $t(snowCoverAuto ? "snowCover.noteAuto" : "snowCover.note") }}
@@ -39,7 +58,8 @@ import { defineComponent } from "vue"; import api from "@/api"; import store from "@/store"; -// Custom extension: switch "snow on pv" on the forecast page, see core/site_snow.go. +// Custom extension: switch "snow on pv" on the forecast page, see core/site_snow.go, +// and the switch to detect it automatically under the chart, see core/site_snow_auto.go. // In the card header from md up; below that it sits with the hint under the chart, // as the header has no room for it next to the title and evcc's switch. export default defineComponent({ @@ -51,6 +71,17 @@ export default defineComponent({ snowCover() { return !!store.state?.snowCover; }, + // turned on by the detection from the weather + snowCoverAuto() { + return !!store.state?.snowCoverAuto; + }, + snowAuto() { + return !!store.state?.snowAuto; + }, + // the detection needs the location of an Open-Meteo solar forecast + autoAvailable() { + return !!store.state?.snowAutoAvailable; + }, }, methods: { async change(e: Event) { @@ -63,6 +94,15 @@ export default defineComponent({ console.error(err); } }, + async changeAuto(e: Event) { + const input = e.target as HTMLInputElement; + try { + await api.post(`snowauto/${input.checked ? "true" : "false"}`); + } catch (err) { + input.checked = this.snowAuto; + console.error(err); + } + }, }, }); diff --git a/assets/js/types/evcc-lm.ts b/assets/js/types/evcc-lm.ts index 52121b101f..66ebc1460f 100644 --- a/assets/js/types/evcc-lm.ts +++ b/assets/js/types/evcc-lm.ts @@ -154,6 +154,12 @@ export interface LmStatus { export interface LmState { /** Snow on pv: the optimizer plans without solar yield, see core/site_snow.go. */ snowCover?: boolean; + /** Snow on pv was turned on by the detection from the weather, see core/site_snow_auto.go. */ + snowCoverAuto?: boolean; + /** Snow on pv is detected from the weather. */ + snowAuto?: boolean; + /** An Open-Meteo solar forecast gives the location for the detection. */ + snowAutoAvailable?: 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/i18n/de.json b/i18n/de.json index c80a1c8772..ee0e748763 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -2354,8 +2354,11 @@ "title": "Einspeisung priorisieren" }, "snowCover": { + "autoHelp": "Schaltet ein bei ≥ 1 cm Neuschnee (≤ 1 °C) in 24 h laut Open-Meteo, Prognose bis Sonnenaufgang eingerechnet. Aus nur über die Messung oder von Hand.", + "autoLabel": "Schnee automatisch erkennen", "label": "Schnee auf PV", - "note": "Schnee auf PV: Der Optimizer rechnet ohne PV-Ertrag. Geht aus, wenn die Anlage 1 h lang 70 % liefert." + "note": "Schnee auf PV: Der Optimizer rechnet ohne PV-Ertrag. Geht aus, wenn die Anlage 1 h lang 70 % liefert.", + "noteAuto": "Schnee auf PV (automatisch erkannt): Der Optimizer rechnet ohne PV-Ertrag. Geht aus, wenn die Anlage 1 h lang 70 % liefert." }, "startupError": { "configFile": "Verwendete Konfigurationsdatei:", diff --git a/i18n/en.json b/i18n/en.json index 4b741d90c1..ee01772b00 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2354,8 +2354,11 @@ "title": "Feed-in Priority" }, "snowCover": { + "autoHelp": "Turns on at ≥ 1 cm of new snow (≤ 1 °C) in 24 h per Open-Meteo, forecast up to sunrise included. Off only by measurement or by hand.", + "autoLabel": "Detect snow automatically", "label": "Snow on PV", - "note": "Snow on PV: the optimizer plans without solar yield. Turns off once the system delivers 70 % for 1 h." + "note": "Snow on PV: the optimizer plans without solar yield. Turns off once the system delivers 70 % for 1 h.", + "noteAuto": "Snow on PV (detected automatically): the optimizer plans without solar yield. Turns off once the system delivers 70 % for 1 h." }, "startupError": { "configFile": "Configuration file used:", From 79e497c69b7887ad31178caf194fd1ab12b8c284 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Tue, 6 Oct 2026 01:08:12 +0200 Subject: [PATCH 3/4] Schnee auf PV: README und watch.json fuer die Erkennung aus dem Wetter Abschnitt 19 um die Automatik ergaenzt (Daten, Regel, snowSeen, Fehlerverhalten, Route), eigene Dateien und Tests in der Liste, Eintrag in watch.json erweitert. Keine neuen Hooks in evcc-Dateien. Co-Authored-By: Claude Sonnet 5.5 --- .github/upstream-check/watch.json | 6 ++-- core/lm/README.md | 51 ++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/.github/upstream-check/watch.json b/.github/upstream-check/watch.json index 09fd4eba13..d0725bee8e 100644 --- a/.github/upstream-check/watch.json +++ b/.github/upstream-check/watch.json @@ -132,9 +132,9 @@ "paths": ["util/log.go", "util/logstash/"] }, { - "name": "Schnee auf PV (Schalter, Optimizer ohne PV-Ertrag)", - "files": ["core/site_snow.go", "assets/js/components/Forecast/SnowCoverSwitch.vue"], - "keywords": ["snow", "Snow", "Schnee", "snowCover"], + "name": "Schnee auf PV (Schalter, Optimizer ohne PV-Ertrag, Erkennung aus dem Wetter)", + "files": ["core/site_snow.go", "core/site_snow_auto.go", "assets/js/components/Forecast/SnowCoverSwitch.vue"], + "keywords": ["snow", "Snow", "Schnee", "snowCover", "snowfall"], "paths": ["core/site_tariffs.go", "core/site_optimizer.go", "assets/js/views/Forecast.vue"] }, { diff --git a/core/lm/README.md b/core/lm/README.md index 4ad26b155b..d35302280f 100644 --- a/core/lm/README.md +++ b/core/lm/README.md @@ -504,6 +504,48 @@ POST /api/snowcover/{true|false} State `snowCover`. See `core/site_snow.go`, `assets/js/components/Forecast/SnowCoverSwitch.vue`. +**Detect snow automatically** (*Detect snow automatically* under the chart, off by +default, `core/site_snow_auto.go`): the first snow day is not caught by the +measurement, the plan for it still runs with the full forecast. With the setting +on, the weather is fetched from Open-Meteo every 30 minutes (own request, +`minutely_15=snowfall,temperature_2m` and the sunrises, past day plus two forecast +days) for the location of the first Open-Meteo solar forecast set up in the ui +(template `open-meteo`, `lat`/`lon` read from the stored tariff configuration; +the template stays as it is, solar forecasts from a yaml file are not looked at). +Without such a forecast the switch is not shown (`snowAutoAvailable`) and nothing +is fetched. + +- Snow counts when the quarter-hour values at an air temperature of +1 °C or + below add up to 1 cm or more within 24 hours (the most snowy 24 hours of the + window). The window is the last 24 hours up to the next sunrise: snow falling + tonight turns the switch on right away (the plan for tomorrow runs without + solar yield; at night there is none anyway), snow of tomorrow afternoon is + looked at again after the sunrise. Without a sunrise in the data it is the next + 24 hours. The ground snow depth is not looked at, it says little about the roof. +- The switch goes on at once, from now on, shown as *automatic* (`snowCoverAuto`) + and logged at INFO (`pv snow cover on: ...`). Turning off stays with the + measurement above or by hand; thawing is not predicted, in Tirol one warm day or + several are needed depending on the amount. The first free day is therefore + planned pessimistically until 1 h at 70 % is measured. +- Snow that was counted (`snowSeen`, the end of the last snowy quarter hour, + saved) does not count again: not after the measurement turned the switch off, + not after the user did by hand. Only snow after it can turn the switch on + again. It is also remembered while the switch is already on (by hand or + automatic). When the setting is turned on while snow lies, that snow turns the + switch on once. +- A failed request leaves the switch as it is: WARN once per series of failures + (`snow detection: weather data not available`), the next attempts after 30 + minutes are logged at DEBUG, INFO when the data is back. The fetch runs in the + background, the control cycle does not wait for it. +- Turning the setting on or off fetches at the next cycle at once. + +``` +POST /api/snowauto/{true|false} +``` + +State `snowAuto` (setting), `snowCoverAuto`, `snowAutoAvailable`. Saved: +`snowAuto`, `snowCoverAuto`, `snowSeen`. See `core/site_snow_auto.go`. + --- ## Rules @@ -573,7 +615,7 @@ Every change in an evcc file. Check these when merging a new evcc version. | battery identification | `core/site_battery_ident.go`, `core/metrics/slots_custom.go` | | EEG | `core/site_feedin_eeg.go`, `core/metrics/feedin_eeg_custom.go`, `assets/js/components/Energy/feedInEeg.ts` | | log file | `util/logstash/file_custom.go`, `core/site_logfile.go`, `assets/js/components/LogFile/` | -| snow on pv | `core/site_snow.go`, `assets/js/components/Forecast/SnowCoverSwitch.vue` | +| 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` | | 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/components/LoadManagement/`, `assets/js/components/PeakShaving/`, the battery cards in `assets/js/components/Battery/` (`BatterySocGridChargeCard`, `BatteryGridChargeOnce`, `BatteryPeakShavingCard`, `BatteryProfileCard`, `ProfileIcon`), the config components in `assets/js/components/Config/` (`PeakShavingConfig`, `LmConfigModals` and its dialogs, `FeedInEegSummary`, `PhaseSwitchFields`) | @@ -621,6 +663,13 @@ unused: - `TestSnowCoverOptimizerInput`: the solar series is 0 with the switch on and the request unchanged with it off; it relies on evcc building `Ft` as one value per slot before `applyLmOptimizerInputs`. +- `TestSnowFall`, `TestSnowAutoRecordedForecast`, `TestSnowAutoTurnsOn`, + `TestSnowAutoWhileOn`, `TestSnowAutoWithoutLocation`, `TestSnowAutoFailure`, + `TestSnowAutoInterval`, `TestSnowAutoRestore`, `TestSnowCoordinates`: the rule + on made-up and recorded weather (`core/testdata`, Innsbruck, 24 to 26 January + 2026), the location from the stored tariffs, no request without the setting or + location, a failed request, the 30 minute interval. No network in the tests, a + local server answers. - `TestCustomRoutesMatch`: every api route reaches its handler; a new route needs a sample request there. From 3b17975770811ecbd8988946ad35f69a4140e3d7 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Tue, 6 Oct 2026 01:10:41 +0200 Subject: [PATCH 4/4] =?UTF-8?q?Schnee=20auf=20PV:=20Messung=20schaltet=20n?= =?UTF-8?q?icht=20aus,=20solange=20gez=C3=A4hlter=20Schnee=20noch=20bevors?= =?UTF-8?q?teht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wird Schnee für die Nacht an einem sonnigen Nachmittag erkannt, schaltete die Messung den Schalter nach 1 h wieder aus, und der schon gezählte Schnee hätte ihn nicht mehr eingeschaltet. Co-Authored-By: Claude Opus 5.5 --- core/lm/README.md | 4 +++- core/site_snow.go | 5 +++++ core/site_snow_test.go | 49 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/core/lm/README.md b/core/lm/README.md index d35302280f..f7604555e8 100644 --- a/core/lm/README.md +++ b/core/lm/README.md @@ -526,7 +526,9 @@ is fetched. and logged at INFO (`pv snow cover on: ...`). Turning off stays with the measurement above or by hand; thawing is not predicted, in Tirol one warm day or several are needed depending on the amount. The first free day is therefore - planned pessimistically until 1 h at 70 % is measured. + planned pessimistically until 1 h at 70 % is measured. While counted snow is + still to come (e.g. tonight's snow found on a sunny afternoon), the measurement + does not turn the switch off: free modules now say nothing about tomorrow. - Snow that was counted (`snowSeen`, the end of the last snowy quarter hour, saved) does not count again: not after the measurement turned the switch off, not after the user did by hand. Only snow after it can turn the switch on diff --git a/core/site_snow.go b/core/site_snow.go index f086f1c4ee..53733596ed 100644 --- a/core/site_snow.go +++ b/core/site_snow.go @@ -197,6 +197,11 @@ func (site *Site) updateSnowCover(now time.Time) { s.mu.Unlock() return } + // snow the detection counted is still to come, e.g. tonight's snow found on a + // sunny afternoon: the free modules of now say nothing about tomorrow + if off && s.seen.After(now) { + off, count = false, 0 + } s.clear, s.slot = count, slot s.mu.Unlock() diff --git a/core/site_snow_test.go b/core/site_snow_test.go index 29cf2226b5..7a6880230c 100644 --- a/core/site_snow_test.go +++ b/core/site_snow_test.go @@ -176,6 +176,55 @@ func TestSnowCoverAutoOff(t *testing.T) { assert.Zero(t, count()) } +// TestSnowCoverStaysForComingSnow: snow the detection counted that is still to +// come keeps the switch on, however free the modules are now +func TestSnowCoverStaysForComingSnow(t *testing.T) { + keepSettings(t) + + clk := clock.NewMock() + clk.Set(time.Date(2026, 1, 10, 13, 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) + + site := &Site{ + log: util.NewLogger("test"), + valueChan: make(chan util.Param, 64), + collectors: map[string]*metrics.Collector{"pv": pv, metrics.Forecast: fc}, + } + site.Meters.PVMetersRef = []string{"pv"} + + // sunny afternoon, snow forecast until 14:30 + site.setSnowCover(true, true) + site.snow().seen = time.Date(2026, 1, 10, 14, 30, 0, 0, time.UTC) + + free := func() { + require.NoError(t, pv.SetEnergy(0.3)) + require.NoError(t, fc.SetEnergy(0.3)) + clk.Add(15 * time.Minute) + require.NoError(t, pv.SetEnergy(0)) + require.NoError(t, fc.SetEnergy(0)) + site.updateSnowCover(clk.Now()) + } + + for range 4 { + free() // 13:00 ... 13:45 + } + assert.True(t, site.GetSnowCover(), "snow still to come") + assert.Zero(t, site.snow().clear) + + for range 4 { + free() // 14:00 ... 14:45, the snow ends at 14:30 + } + assert.False(t, site.GetSnowCover(), "off after four free slots once the snow is past") +} + // TestSnowCoverNeedsMeterAndForecast: without a pv meter or forecast collector nothing is rated func TestSnowCoverNeedsMeterAndForecast(t *testing.T) { keepSettings(t)