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/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/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 {