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: 6 additions & 0 deletions .github/upstream-check/watch.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,12 @@
"keywords": ["log file", "logfile", "LogFile", "logrotate", "lumberjack", "rotat"],
"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"],
"paths": ["core/site_tariffs.go", "core/site_optimizer.go", "assets/js/views/Forecast.vue"]
},
{
"name": "Grundlagen, auf denen der Fork aufbaut",
"files": ["core/loadpoint_lm.go"],
Expand Down
68 changes: 68 additions & 0 deletions assets/js/components/Forecast/SnowCoverSwitch.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<template>
<div v-if="note">
<div class="form-check form-switch mt-2 mb-0 d-md-none">
<input
id="snowCoverRow"
:checked="snowCover"
class="form-check-input"
type="checkbox"
role="switch"
data-testid="snowcover-switch-row"
@change="change"
/>
<label class="form-check-label text-muted" for="snowCoverRow">
{{ $t("snowCover.label") }}
</label>
</div>
<div v-if="snowCover" class="small text-muted mt-2" data-testid="snowcover-note">
{{ $t("snowCover.note") }}
</div>
</div>
<div v-else class="form-check form-switch mb-0 text-nowrap d-none d-md-block">
<input
id="snowCover"
:checked="snowCover"
class="form-check-input"
type="checkbox"
role="switch"
data-testid="snowcover-switch"
@change="change"
/>
<label class="form-check-label text-muted" for="snowCover">
{{ $t("snowCover.label") }}
</label>
</div>
</template>

<script lang="ts">
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.
// 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({
name: "SnowCoverSwitch",
props: {
note: Boolean,
},
computed: {
snowCover() {
return !!store.state?.snowCover;
},
},
methods: {
async change(e: Event) {
const input = e.target as HTMLInputElement;
try {
await api.post(`snowcover/${input.checked ? "true" : "false"}`);
} catch (err) {
// back to the state evcc has, the store did not change
input.checked = this.snowCover;
console.error(err);
}
},
},
});
</script>
2 changes: 2 additions & 0 deletions assets/js/types/evcc-lm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ export interface LmStatus {

/** State fields added by this fork. */
export interface LmState {
/** Snow on pv: the optimizer plans without solar yield, see core/site_snow.go. */
snowCover?: 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
10 changes: 8 additions & 2 deletions assets/js/views/Forecast.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@
edge-to-edge
class="box-pull-out mb-4"
>
<template v-if="showSolarAdjust" #actions>
<div class="form-check form-switch mb-0 text-nowrap">
<template #actions>
<div v-if="showSolarAdjust" class="form-check form-switch mb-0 text-nowrap">
<input
id="solarForecastAdjust"
:checked="solarAdjusted"
Expand All @@ -47,6 +47,8 @@
<span class="d-none d-md-inline">{{ solarAdjustTextMedium }}</span>
</label>
</div>
<SnowCoverSwitch />
<!-- custom -->
</template>
<div class="chart-edge">
<SolarChart
Expand All @@ -58,6 +60,8 @@
@scroll="onChartScroll"
/>
</div>
<SnowCoverSwitch note />
<!-- custom -->
<SolarDetails :solar="solar" />
</Card>

Expand Down Expand Up @@ -138,6 +142,7 @@ import Card from "../components/Helper/Card.vue";
import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
import SolarChart from "../components/Forecast/SolarChart.vue";
import SolarDetails from "../components/Forecast/SolarDetails.vue";
import SnowCoverSwitch from "../components/Forecast/SnowCoverSwitch.vue"; // custom
import PriceChart from "../components/Forecast/PriceChart.vue";
import GridDetails from "../components/Forecast/GridDetails.vue";
import ValueChart, { type ValueChartType } from "../components/Forecast/ValueChart.vue";
Expand All @@ -159,6 +164,7 @@ export default defineComponent({
DynamicPriceIcon,
SolarChart,
SolarDetails,
SnowCoverSwitch, // custom
PriceChart,
GridDetails,
ValueChart,
Expand Down
1 change: 1 addition & 0 deletions core/fork_hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ var forkUiHooks = []struct {
{"assets/js/components/Config/LoadpointModal.vue", []string{"<PhaseSwitchFields", "emptyUnsetPhaseSwitch", "chargerIsStages"}},
{"assets/js/components/Loadpoints/Loadpoint.vue", []string{"chargerStages: Boolean"}},
{"assets/js/components/Loadpoints/Mode.vue", []string{"chargerStages: Boolean", "this.switchDevice || this.chargerStages"}},
{"assets/js/views/Forecast.vue", []string{"<SnowCoverSwitch />", "<SnowCoverSwitch note />", "SnowCoverSwitch, // custom"}},
{"assets/js/views/Log.vue", []string{"<LogFileButton", "LogFileButton,"}},
{"assets/js/components/Config/TariffCard.vue", []string{"<FeedInEegSummary"}},
{"assets/js/components/Config/TariffModal.vue", []string{"feedInEeg", "tariff-planner-optimizer"}},
Expand Down
3 changes: 3 additions & 0 deletions core/keys/site_custom.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,7 @@ const (

// log in daily files, see core/site_logfile.go
LogFile = "logFile"

// snow on pv, the optimizer plans without solar yield, see core/site_snow.go
SnowCover = "snowCover"
)
45 changes: 43 additions & 2 deletions core/lm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Part 1 describes the features, part 2 how the fork is kept maintainable.
16. [Optimizer inputs](#16-optimizer-inputs)
17. [Advanced settings](#17-advanced-settings)
18. [Log file](#18-log-file)
19. [Snow on PV](#19-snow-on-pv)

**Part 2: maintenance**

Expand Down Expand Up @@ -386,6 +387,7 @@ matches what the fork will do (`core/site_optimizer_lm.go`):
circuit) is not offered (`charge_from_grid`).
- load management: a loadpoint plans with at most its circuits' power,
priorities 0-3/4-6/7-10 become `c_priority` 0/1/2.
- snow on pv: the solar series is 0 while the switch is on, see [19](#19-snow-on-pv).
- a price tariff set as planner tariff is the grid price the optimizer plans
with (`p_N`); statistics and costs keep the grid tariff. With real prices
close to the feed-in price the optimizer never discharges (stored energy is
Expand All @@ -397,8 +399,8 @@ matches what the fork will do (`core/site_optimizer_lm.go`):
- the plan is solved again when soc-based grid charging starts or stops, and a
forced run arriving during a run follows right after it.

Without circuits, peak shaving and soc or one-time grid charging the request is
unchanged. The optimizer's automatic mode (evcc PR 32881) needs a gate at
Without circuits, peak shaving, soc or one-time grid charging and snow on pv the
request is unchanged. The optimizer's automatic mode (evcc PR 32881) needs a gate at
execution, prepared separately on top of these inputs.

## 17. Advanced settings
Expand Down Expand Up @@ -468,6 +470,40 @@ changes the logger underneath `util/log.go`; then the hook has to be set again a
checked whether evcc brings a file output itself. See `util/logstash/file_custom.go`,
`core/site_logfile.go`.

## 19. Snow on PV

Snow on the modules cuts the yield to almost nothing while the solar forecast
still shows the full yield, so the optimizer plans grid charging, reserve and
peaks around pv that does not come. *Forecast page → Solar card → Snow on PV*
(off by default) tells it: while it is on, the solar series of every optimizer
request is 0 (`core/site_snow.go`, `applySnowCover` from
`applyLmOptimizerInputs`, after evcc has built the series, so it also overrides
the adjusted forecast and the blend of the first slots). Switching runs the
optimizer again at once, as evcc's *adjust forecast* switch does. The forecast
display, the regulation and evcc's planner stay as they are. Below 768 px the
switch sits with the hint under the chart instead of the card header, which has
no room for it next to evcc's switch. It is not shown without a solar forecast.

It turns itself off: after each completed quarter hour the measured pv energy
is compared with the forecast energy of that quarter hour (the collector values
evcc's blend of the first slots uses, the forecast unscaled). A quarter hour
with a forecast under 100 Wh says nothing (night, dawn, overcast) and neither
counts nor resets. From 70 % of the forecast it counts as free, otherwise the
count is reset. After 4 free quarter hours in a row the switch goes off, is
saved and the optimizer runs again; INFO log `pv snow cover off: production back
to 70 % of the forecast for 1 h`. The count is in memory only, after a restart it
begins at 0 (turning off is delayed by one hour at most). The thresholds are
fixed. Fog, bad weather, an inverter outage and curtailment are not handled: if
the system is curtailed under snow with a full battery, the switch stays on
longer, it can be switched off by hand any time.

```
POST /api/snowcover/{true|false}
```

State `snowCover`. See `core/site_snow.go`,
`assets/js/components/Forecast/SnowCoverSwitch.vue`.

---

## Rules
Expand Down Expand Up @@ -512,6 +548,7 @@ Every change in an evcc file. Check these when merging a new evcc version.
| `assets/js/views/App.vue` | mounts `LoadManagement/GlobalModals.vue` |
| `assets/js/views/Battery.vue` | mounts the battery cards and the profile selection |
| `assets/js/views/Log.vue` | mounts `LogFile/LogFileButton.vue` next to the download button |
| `assets/js/views/Forecast.vue` | mounts `Forecast/SnowCoverSwitch.vue` twice: the switch in the solar card's `#actions` (which is now always rendered, evcc's own switch keeps its `v-if="showSolarAdjust"` on its inner `div`) and the hint under the chart |
| `assets/js/views/Config.vue` | *Lastmanagement-Details* section, `LmConfigModals.vue`, EEG tariff card and add button |
| `assets/js/components/BottomTabs/MoreMenu.vue` | mounts `LoadManagement/MoreMenuItems.vue` |
| `assets/js/components/Config/LoadpointModal.vue` | mounts `PhaseSwitchFields.vue`, 3-phase labels and minimum while it is shown; default mode labels Aus/Smart/Ein for a heater in stages (`chargerIsStages`) |
Expand All @@ -536,6 +573,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` |
| 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 @@ -580,6 +618,9 @@ unused:
`TestLoadpointUsesSiteLoadManagement`, `TestMergeRoutesKeepsUpstream`: the
hooks act on evcc's real control path.
- `TestRestoreCustomAfterRestart`: every fork setting survives a restart.
- `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`.
- `TestCustomRoutesMatch`: every api route reaches its handler; a new route
needs a sample request there.

Expand Down
4 changes: 4 additions & 0 deletions core/site/api_custom.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ type CustomAPI interface {
LogFile() logstash.FileState
SetLogFile(logstash.FileConfig) error

// snow on pv, see core/site_snow.go
GetSnowCover() bool
SetSnowCover(bool) error

// peak shaving, see core/site_peakshaving.go
GetPeakShaving() bool
SetPeakShaving(bool) error
Expand Down
5 changes: 4 additions & 1 deletion core/site_lm.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ type lmState struct {
type siteCustom struct {
lm lmState
peak peakState
snow snowState // snow on pv, see site_snow.go
}

// restoreCustom restores the fork's settings, called at the end of upstream's
Expand All @@ -101,15 +102,17 @@ func (site *Site) restoreCustom() {
site.restorePeakSettings()
site.restoreFeedInEeg()
site.restoreLogFile()
site.restoreSnowCover()
}

// updateCustom runs the fork's per cycle work once the meters are read, before
// upstream decides on battery grid charging and the battery mode: peak shaving,
// the export under the second feed-in tariff and the battery identification
// the export under the second feed-in tariff, the battery identification and snow on pv
func (site *Site) updateCustom(state siteState) {
site.updatePeakShaving(state)
site.updateFeedInEeg()
site.updateBatteryIdent()
site.updateSnowCover(time.Now())
}

// lms returns the load management state, applying defaults on first use
Expand Down
5 changes: 4 additions & 1 deletion core/site_optimizer_lm.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ package core
// (charge_from_grid off)
// - load management: a loadpoint plans with at most its circuits' power, and
// the priorities rank the batteries (c_priority)
// - snow on pv: no solar yield, see site_snow.go
// - a price tariff set as planner tariff is the grid price the optimizer
// plans with, statistics keep the grid tariff
//
// Without circuits, peak shaving and soc-based grid charging the request is
// Without circuits, peak shaving, soc-based grid charging and snow on pv the request is
// unchanged.

import (
Expand Down Expand Up @@ -79,6 +80,8 @@ func (site *Site) optimizerGridTariff() api.Tariff {

// applyLmOptimizerInputs adds the fork's settings to the optimizer request
func (site *Site) applyLmOptimizerInputs(req *optimizer.OptimizationInput, batteries []optimizerBattery) {
site.applySnowCover(req)

lmActive := len(config.Circuits().Devices()) > 0
peakOn, limit, reserve := site.peakShavingConfigured()

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 @@ -54,6 +54,7 @@ func TestRestoreCustomAfterRestart(t *testing.T) {
require.NoError(t, a.SetPeakFollow(true))
require.NoError(t, a.SetPeakTariff("price", 40))
require.NoError(t, a.SetLogFile(logstash.FileConfig{Enabled: true, Level: "info", Days: 30}))
require.NoError(t, a.SetSnowCover(true))

// set through Home Assistant in the ui, stored as names
settings.SetString(keys.PeakShavingEntity, "input_number.peak")
Expand Down Expand Up @@ -91,4 +92,5 @@ func TestRestoreCustomAfterRestart(t *testing.T) {
assert.True(t, b.LogFile().Enabled)
assert.Equal(t, "info", b.LogFile().Level)
assert.Equal(t, 30, b.LogFile().Days)
assert.True(t, b.GetSnowCover())
}
Loading
Loading