Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/upstream-check/watch.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
},
{
Expand Down
44 changes: 42 additions & 2 deletions assets/js/components/Forecast/SnowCoverSwitch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,27 @@
{{ $t("snowCover.label") }}
</label>
</div>
<div v-if="autoAvailable" class="mt-2">
<div class="form-check form-switch mb-0">
<input
id="snowAuto"
:checked="snowAuto"
class="form-check-input"
type="checkbox"
role="switch"
data-testid="snowauto-switch"
@change="changeAuto"
/>
<label class="form-check-label text-muted" for="snowAuto">
{{ $t("snowCover.autoLabel") }}
</label>
</div>
<div class="small text-muted" data-testid="snowauto-help">
{{ $t("snowCover.autoHelp") }}
</div>
</div>
<div v-if="snowCover" class="small text-muted mt-2" data-testid="snowcover-note">
{{ $t("snowCover.note") }}
{{ $t(snowCoverAuto ? "snowCover.noteAuto" : "snowCover.note") }}
</div>
</div>
<div v-else class="form-check form-switch mb-0 text-nowrap d-none d-md-block">
Expand All @@ -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({
Expand All @@ -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) {
Expand All @@ -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);
}
},
},
});
</script>
6 changes: 6 additions & 0 deletions assets/js/types/evcc-lm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
6 changes: 6 additions & 0 deletions core/keys/site_custom.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
53 changes: 52 additions & 1 deletion core/lm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`) |
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions core/site/api_custom.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions core/site_restore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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())
}
63 changes: 51 additions & 12 deletions core/site_snow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()

Expand Down
Loading
Loading