@@ -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/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/lm/README.md b/core/lm/README.md
index 4ad26b155b..f7604555e8 100644
--- a/core/lm/README.md
+++ b/core/lm/README.md
@@ -504,6 +504,50 @@ 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. 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
+ 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 +617,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 +665,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.
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..53733596ed 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()
@@ -163,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_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..7a6880230c 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)
@@ -174,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)
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/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:",
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 {