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
61 changes: 60 additions & 1 deletion assets/js/components/Config/LmAdvancedModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,43 @@
</select>
</FormRow>

<!-- custom: home consumption forecast for the optimizer, see core/site_load_weekday.go -->
<FormRow
id="lmAdvanced-homeWeekday"
:label="$t('config.lmadvanced.homeWeekdayLabel')"
:help="$t('config.lmadvanced.homeWeekdayHelp')"
>
<select
id="lmAdvanced-homeWeekday"
v-model.number="values.homeWeekday"
class="form-select"
data-testid="lmadvanced-homeWeekday"
>
<option :value="0">{{ $t("config.lmadvanced.homeWeekdayOff") }}</option>
<option :value="1">{{ $t("config.lmadvanced.homeWeekdayOn") }}</option>
</select>
</FormRow>
<FormRow
id="lmAdvanced-percentile"
:label="$t('config.lmadvanced.percentileLabel')"
:help="$t('config.lmadvanced.percentileHelp')"
>
<select
id="lmAdvanced-percentile"
v-model.number="percentile"
class="form-select"
data-testid="lmadvanced-percentile"
>
<option v-for="p in percentiles" :key="p" :value="p">
{{
p
? $t("config.lmadvanced.percentileValue", { percentile: p })
: $t("config.lmadvanced.percentileAverage")
}}
</option>
</select>
</FormRow>

<div class="mt-4 d-flex justify-content-between gap-2 flex-column flex-sm-row">
<button
type="button"
Expand All @@ -80,7 +117,7 @@
<button
type="submit"
class="btn btn-primary order-1 order-sm-2 flex-grow-1 flex-sm-grow-0 px-4"
:disabled="saving || (!changed.length && !circuitChanged)"
:disabled="saving || (!changed.length && !circuitChanged && !percentileChanged)"
>
<span
v-if="saving"
Expand Down Expand Up @@ -137,12 +174,24 @@ export default {
initial: {},
circuit: "",
initialCircuit: "",
percentile: 0,
initialPercentile: 0,
};
},
computed: {
numberFields() {
return FIELDS;
},
percentiles() {
// upstream profilePercentile in %, 0 = average
const res = [0, 60, 70, 80, 90];
return res.includes(this.initialPercentile)
? res
: [...res, this.initialPercentile].sort((a, b) => a - b);
},
percentileChanged() {
return this.percentile !== this.initialPercentile;
},
circuitChanged() {
return this.circuit !== this.initialCircuit;
},
Expand All @@ -168,13 +217,16 @@ export default {
const values = {};
FIELDS.forEach((f) => (values[f.name] = state[f.name] ?? f.default));
values.phases = state.phases ?? 3;
values.homeWeekday = state.homeWeekday ? 1 : 0;

this.saving = false;
this.error = "";
this.values = { ...values };
this.initial = { ...values };
this.circuit = store.state?.lmOff?.circuit || "";
this.initialCircuit = this.circuit;
this.percentile = store.state?.profilePercentile ?? 0;
this.initialPercentile = this.percentile;
},
invalidField() {
return FIELDS.find((f) => {
Expand Down Expand Up @@ -210,6 +262,13 @@ export default {
await api.delete("lmcircuit");
}
}
if (this.percentileChanged) {
if (this.percentile) {
await api.post(`profilepercentile/${this.percentile}`);
} else {
await api.delete("profilepercentile");
}
}
for (const name of this.changed) {
await api.post(`lmadvanced/${name}/${this.values[name]}`);
}
Expand Down
2 changes: 2 additions & 0 deletions assets/js/types/evcc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ export interface LmAdvanced {
peakFreeze: number;
/** Allowed grid draw at most this multiple of the peak limit. */
peakCap: number;
/** Home consumption forecast per weekday. */
homeWeekday?: boolean;
/** Cycles after which a load ignoring its limit is no longer counted on, 0 = off. */
followCycles: number;
gridChargeWindow: number;
Expand Down
15 changes: 15 additions & 0 deletions core/lm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ Keep these in mind when merging a new evcc version:
| `core/site/api.go` | embeds `CustomAPI`, one line |
| `api/globalconfig/types.go`, `tariff/tariffs.go`, `cmd/setup.go`, `server/http_config_device_handler.go` | `feedInEeg` tariff role: ref field, `Used`/`IsConfigured`, one `configureTariff` call, cleared on delete |
| `assets/js/components/Config/TariffModal.vue` | `feedInEeg` offers the price templates |
| `core/site_load_predictor.go` | `homeProfileByWeekday` call in `homeProfile` |
| `core/site_optimizer.go` | `applyLmOptimizerInputs` where the optimizer request is assembled |
| `server/http.go` | merges `customSiteRoutes`, one loop |
| `assets/js/views/Battery.vue` | mounts the new cards, profile selection at the bottom |
Expand Down Expand Up @@ -263,6 +264,20 @@ configured limits (or the raised one of follow the peak). Survives a restart
Circuit limits changed at runtime are published with their configured values
in `lmOff.limits`, so the overview shows them.

## Home consumption forecast per weekday

Lastmanagement-Details → Erweitert → *Verbrauchsprognose*. evcc forecasts the
home base load for the optimizer from the average of the last 28 days per
quarter hour. *Nach Wochentag* takes each forecast day from the same weekday of
the last 8 weeks instead, so weekends and working days keep their own shape; a
weekday without complete data falls back to the average. Stored as
`homeWeekday` in the advanced settings, see `core/site_load_weekday.go`, one
call in `homeProfile` (`core/site_load_predictor.go`).

*Sicherheitszuschlag Verbrauch* sets upstream's `profilePercentile` (API only
upstream): a higher percentile per quarter hour instead of the mean, for both
profiles and the heating devices' profiles.

## Capacity tariff

Lastmanagement-Details → Leistungstarif. The month's highest quarter hour is
Expand Down
12 changes: 12 additions & 0 deletions core/metrics/profile_custom.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package metrics

// Custom extension: the weekday profile over a chosen history, for the home
// forecast per weekday, see core/site_load_weekday.go.

import "time"

// EnergyProfileWeekdaySince is EnergyProfileWeekday from the given time on
func (c *Collector) EnergyProfileWeekdaySince(weekday time.Weekday, from time.Time) (*[96]float64, error) {
wd := int(weekday)
return energyProfileFiltered(c.entity, from, &wd, profilePercentile())
}
10 changes: 10 additions & 0 deletions core/site_lm_advanced.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ type lmAdvanced struct {
FollowCycles *float64 `json:"followCycles,omitempty"`
// hours the optimizer may take to reach the stop soc of soc-based grid charging
GridChargeWindow *float64 `json:"gridChargeWindow,omitempty"`
// home consumption forecast per weekday, 1 = on, see site_load_weekday.go
HomeWeekday *float64 `json:"homeWeekday,omitempty"`
}

// lmAdvancedState is what the ui shows: the values in effect
Expand All @@ -40,6 +42,7 @@ type lmAdvancedState struct {
PeakCap float64 `json:"peakCap"`
FollowCycles int `json:"followCycles"`
GridChargeWindow float64 `json:"gridChargeWindow"`
HomeWeekday bool `json:"homeWeekday"`
}

// lmAdvancedLimit is a setting's valid range
Expand All @@ -58,6 +61,7 @@ var lmAdvancedLimits = map[string]lmAdvancedLimit{
"peakCap": {1, 10, false},
"followCycles": {0, 20, true},
"gridChargeWindow": {1, 24, true},
"homeWeekday": {0, 1, true},
}

// restoreLmAdvanced restores the persisted advanced settings
Expand Down Expand Up @@ -98,6 +102,7 @@ func (site *Site) publishLmAdvanced() {
PeakCap: site.peakCap(),
FollowCycles: site.lmFollowCycles(),
GridChargeWindow: site.gridChargeWindow().Hours(),
HomeWeekday: site.homeWeekday(),
})
}

Expand Down Expand Up @@ -148,6 +153,8 @@ func (site *Site) SetLmAdvanced(name string, value float64) error {
s.adv.FollowCycles = &v
case "gridChargeWindow":
s.adv.GridChargeWindow = &v
case "homeWeekday":
s.adv.HomeWeekday = &v
}
adv := s.adv
s.advMu.Unlock()
Expand All @@ -161,6 +168,9 @@ func (site *Site) SetLmAdvanced(name string, value float64) error {
if name == "timeout" {
lm.SetTimeout(site.lmTimeout())
}
if name == "homeWeekday" {
site.Optimize() // the home demand forecast changed
}

site.publishLmAdvanced()

Expand Down
5 changes: 5 additions & 0 deletions core/site_load_predictor.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import (
func (site *Site) homeProfile(minLen int) ([]float64, error) {
col := site.collectors[metrics.Home]

// custom: per weekday, see core/site_load_weekday.go
if site.homeWeekday() {
return site.homeProfileByWeekday(col, minLen)
}

base, err := col.EnergyProfile(now.BeginningOfDay().AddDate(0, 0, -28))
if err != nil {
return nil, err
Expand Down
77 changes: 77 additions & 0 deletions core/site_load_weekday.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package core

// Custom extension: home consumption forecast per weekday. evcc forecasts the
// home base load for the optimizer from the average of the last 28 days per
// time of day, so weekends and working days blur into one profile. With it on
// (Lastmanagement-Details → Erweitert), each forecast day takes the same weekday
// of the last 8 weeks instead. The percentile set there (upstream
// profilePercentile) applies to it like to the regular profile. A weekday
// without complete data yet falls back to the regular profile.

import (
"errors"
"time"

"github.com/evcc-io/evcc/core/metrics"
"github.com/evcc-io/evcc/tariff"
"github.com/jinzhu/now"
)

// homeWeekdayWeeks is how many weeks back the weekday profiles reach
const homeWeekdayWeeks = 8

// homeWeekday reports whether the home forecast is taken per weekday
func (site *Site) homeWeekday() bool {
v := site.advanced().HomeWeekday
return v != nil && *v == 1
}

// homeProfileByWeekday returns the home base load in Wh for minLen 15min slots
// starting now, each day from the profile of its weekday
func (site *Site) homeProfileByWeekday(col *metrics.Collector, minLen int) ([]float64, error) {
today := now.BeginningOfDay()
from := today.AddDate(0, 0, -7*homeWeekdayWeeks)
first := int(time.Now().Truncate(tariff.SlotDuration).Sub(today) / tariff.SlotDuration)

var fallback *[96]float64
profiles := make(map[time.Weekday]*[96]float64)

res := make([]float64, minLen)
for i := range res {
slot := first + i
weekday := today.AddDate(0, 0, slot/96).Weekday()

p, ok := profiles[weekday]
if !ok {
var err error
p, err = col.EnergyProfileWeekdaySince(weekday, from)
if err != nil && !errors.Is(err, metrics.ErrIncomplete) {
return nil, err
}

if err != nil || profileEmpty(p) {
if fallback == nil {
if fallback, err = col.EnergyProfile(today.AddDate(0, 0, -28)); err != nil {
return nil, err
}
}
p = fallback
}

profiles[weekday] = p
}

res[i] = p[slot%96] * 1e3
}

return res, nil
}

func profileEmpty(p *[96]float64) bool {
for _, v := range p {
if v != 0 {
return false
}
}
return true
}
Loading
Loading