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
150 changes: 150 additions & 0 deletions assets/js/components/Battery/BatteryGridChargeOnce.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<template>
<div class="once border-top pt-3 mt-3" data-testid="battery-grid-charge-once">
<h6 class="mb-2">{{ $t("battery.gridChargeOnce.title") }}</h6>

<div v-if="running" class="d-flex flex-wrap align-items-center gap-2">
<span data-testid="battery-grid-charge-once-status">
{{ $t("battery.gridChargeOnce.running", { soc: fmtSoc(once.target) }) }} ·
{{ whenText }} ·
<span :class="once.active ? 'text-primary' : 'evcc-gray'">{{
once.active
? $t("battery.gridChargeOnce.active")
: $t("battery.gridChargeOnce.waiting")
}}</span>
</span>
<button
type="button"
class="btn btn-sm btn-outline-secondary ms-auto"
data-testid="battery-grid-charge-once-cancel"
:disabled="busy"
@click="cancel"
>
{{ $t("battery.gridChargeOnce.cancel") }}
</button>
</div>

<form v-else class="d-flex flex-wrap align-items-center gap-2" @submit.prevent="start">
<i18n-t keypath="battery.gridChargeOnce.sentence" tag="span" scope="global">
<template #soc>
<InlineSocSelect
id="batteryGridChargeOnceSoc"
:options="socOptions"
:selected="target"
:label="fmtSoc(target)"
nowrap
@change="target = parseInt(($event.target as HTMLInputElement).value, 10)"
/>
</template>
</i18n-t>
<select
id="batteryGridChargeOnceMode"
v-model="mode"
class="form-select form-select-sm w-auto"
data-testid="battery-grid-charge-once-mode"
:aria-label="$t('battery.gridChargeOnce.title')"
>
<option value="now">{{ $t("battery.gridChargeOnce.now") }}</option>
<option value="until">{{ $t("battery.gridChargeOnce.until") }}</option>
</select>
<input
v-if="mode === 'until'"
id="batteryGridChargeOnceTime"
v-model="time"
type="time"
class="form-control form-control-sm w-auto"
data-testid="battery-grid-charge-once-time"
:aria-label="$t('battery.gridChargeOnce.timeLabel')"
/>
<button
type="submit"
class="btn btn-sm btn-primary ms-auto"
data-testid="battery-grid-charge-once-start"
:disabled="busy || (mode === 'until' && !time)"
>
{{ $t("battery.gridChargeOnce.start") }}
</button>
</form>

<p v-if="error" class="text-danger small mt-2 mb-0">{{ error }}</p>
<p v-else class="small text-muted mt-2 mb-0">{{ $t("battery.gridChargeOnce.help") }}</p>
</div>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import formatter from "@/mixins/formatter";
import api from "@/api";
import store from "@/store";
import InlineSocSelect from "./InlineSocSelect.vue";

interface GridChargeOnce {
target: number;
until?: string | null;
active?: boolean;
}

// Custom extension: one-time grid charging up to a soc, right away or by a time
// of day at the cheapest slots, see core/site_lm_once.go
export default defineComponent({
name: "BatteryGridChargeOnce",
components: { InlineSocSelect },
mixins: [formatter],
data() {
return { target: 80, mode: "now", time: "06:00", busy: false, error: "" };
},
computed: {
once(): GridChargeOnce {
return store.state?.batteryGridChargeOnce || { target: 0 };
},
running(): boolean {
return (this.once.target || 0) > 0;
},
soc(): number {
return store.state?.battery?.soc || 0;
},
// only targets above the current soc make sense
socOptions() {
const options = [];
for (let i = 100; i >= 10; i -= 5) {
options.push({ value: i, name: this.fmtSoc(i), disabled: i <= this.soc });
}
return options;
},
whenText(): string {
if (!this.once.until) return this.$t("battery.gridChargeOnce.now");
return this.$t("battery.gridChargeOnce.byTime", {
time: this.fmtHourMinute(new Date(this.once.until)),
});
},
},
methods: {
fmtSoc(soc: number) {
return this.fmtPercentage(soc);
},
async start() {
this.busy = true;
this.error = "";
try {
const path =
this.mode === "until"
? `batterygridchargeonce/${this.target}/${encodeURIComponent(this.time)}`
: `batterygridchargeonce/${this.target}`;
await api.post(path);
} catch (e: any) {
this.error = e?.response?.data?.error || e?.message || String(e);
}
this.busy = false;
},
async cancel() {
this.busy = true;
this.error = "";
try {
await api.delete("batterygridchargeonce");
} catch (e: any) {
this.error = e?.response?.data?.error || e?.message || String(e);
}
this.busy = false;
},
},
});
</script>
5 changes: 4 additions & 1 deletion assets/js/components/Battery/BatterySocGridChargeCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
</template>
</i18n-t>
</div>
<!-- custom: one-time grid charging, see core/site_lm_once.go -->
<BatteryGridChargeOnce />
</Card>
</template>

Expand All @@ -53,12 +55,13 @@ import formatter from "@/mixins/formatter";
import api from "@/api";
import Card from "../Helper/Card.vue";
import InlineSocSelect from "./InlineSocSelect.vue";
import BatteryGridChargeOnce from "./BatteryGridChargeOnce.vue";

// Soc-based grid charging: charge the home battery from the grid between a start
// and a stop soc, independent of the price-based grid charge limit.
export default defineComponent({
name: "BatterySocGridChargeCard",
components: { Card, InlineSocSelect },
components: { Card, InlineSocSelect, BatteryGridChargeOnce },
mixins: [formatter],
props: {
enabled: Boolean,
Expand Down
1 change: 1 addition & 0 deletions assets/js/components/Config/LmAdvancedModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ const FIELDS = [
{ name: "timeout", unit: "min", min: 1, max: 60, integer: true, default: 10 },
{ name: "peakFreeze", unit: "min", min: 1, max: 14, integer: true, default: 12 },
{ name: "peakCap", unit: "×", min: 1, max: 10, integer: false, default: 2 },
{ name: "gridChargeWindow", unit: "h", min: 1, max: 24, integer: true, default: 3 },
{
name: "followCycles",
unitKey: "config.lmadvanced.cycles",
Expand Down
3 changes: 3 additions & 0 deletions assets/js/types/evcc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ export interface LmAdvanced {
peakCap: number;
/** Cycles after which a load ignoring its limit is no longer counted on, 0 = off. */
followCycles: number;
gridChargeWindow: number;
}

// custom: a battery profile, see core/lm/profile. Values left out are not changed.
Expand Down Expand Up @@ -417,6 +418,8 @@ export interface State {
batterySocGridChargeStart?: number;
/** Soc in % at or above which soc-based grid charging stops. */
batterySocGridChargeStop?: number;
/** One-time grid charging up to target soc, right away or by until. */
batteryGridChargeOnce?: { target: number; until?: string | null; active?: boolean };
/** Battery peak shaving is enabled. */
peakShaving?: boolean;
/** Grid peak limit in W the battery reserve is used to stay below. */
Expand Down
1 change: 1 addition & 0 deletions core/keys/site_custom.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const (
BatterySocGridChargeStart = "batterySocGridChargeStart"
BatterySocGridChargeStop = "batterySocGridChargeStop"
BatterySocGridChargeRunning = "batterySocGridChargeRunning" // hysteresis state, not published
BatteryGridChargeOnce = "batteryGridChargeOnce" // one-time grid charging, see core/site_lm_once.go

// battery peak shaving
PeakShaving = "peakShaving"
Expand Down
40 changes: 39 additions & 1 deletion core/lm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ Keep these in mind when merging a new evcc version:
| `charger/switchsocket.go` | `RatedPower` config field, stands in for a missing power sensor |
| `templates/definition/charger/homeassistant-switch.yaml` | `ratedpower` parameter |
| `core/site/api.go` | embeds `CustomAPI`, one line |
| `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 |
| `assets/js/views/Config.vue` | load management details section and its modals, OeMAG modal |
Expand All @@ -152,7 +153,7 @@ Everything else lives in files of its own: `core/lm/`, `core/site_lm.go`, `core/
`core/site_lm_advanced.go`, `core/site_lm_status.go`, `core/site_lm_profiles.go`, `core/site_lm_follow.go`,
`core/site_peak_stats.go`, `assets/js/components/LoadManagement/`, `assets/js/components/PeakShaving/`,
`core/site_peakshaving.go`, `core/loadpoint_lm.go`, `charger/switchsocket_lm.go`, `core/keys/site_custom.go`,
`core/site/api_custom.go`, `server/http_custom.go`, `core/site_feedin.go`, `core/metrics/tariffs_custom.go`,
`core/site/api_custom.go`, `server/http_custom.go`, `core/site_feedin.go`, `core/metrics/tariffs_custom.go`, `core/site_optimizer_lm.go`, `core/site_lm_once.go`,
`tariff/oemag.go`, `tariff/wrapper_custom.go`, `templates/definition/tariff/oemag.yaml` and the new Vue
components.

Expand Down Expand Up @@ -232,6 +233,43 @@ counts negative), and how often the battery started covering a peak. Only
quarter hours metered from their start count. Kept for 24 months in
`peakMonths`, see `core/site_peak_stats.go`.

## Optimizer

The optimizer plans battery and vehicle charging and, today, advises: its
result is the battery soc forecast and the suggestions. The fork gives it its
settings as inputs, so the plan matches what the fork will actually do, see
`core/site_optimizer_lm.go`:

- peak shaving: peak limit as hard grid import limit (`p_max_imp`), reserve
as the home battery's minimum soc (`s_min`)
- soc-based grid charging: start soc as minimum soc, so the charging is
planned ahead before the battery would fall below it; while it runs the
stop soc as goal (`s_goal`) within the grid charge window (*Erweitert →
Netzlade-Ziel erreichen in*, default 3 h)
- one-time grid charging: its target as goal, right away or at the chosen time
- grid charging refused right now (shed hold-off, running peak, unknown charge
power on a circuit) is not offered (`charge_from_grid`)
- load management: a loadpoint plans with at most its circuits' power, the
priorities 0-3/4-6/7-10 become `c_priority` 0/1/2

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

`TestLmOptimizerReplay` sends a recorded request with these inputs to a
running optimizer (`OPTIMIZER_REPLAY`, `OPTIMIZER_URI`, optional
`REPLAY_SETTINGS`, `REPLAY_GRID_PRICE`, `REPLAY_SOC`) and checks the plan.

## One-time grid charging

Battery page, below *Netzladen nach Ladestand*: grid-charges once up to the
chosen soc and switches itself off, right away or by a time of day at the
cheapest slots before it (upstream planner on the planner tariff, right away
once the time passed or when the duration is unknown). It survives a restart,
can be cancelled and passes the same gate as the soc-based grid charging, see
`core/site_lm_once.go`.

## 4. Peak shaving

See `core/site_peakshaving.go`. The battery's lower soc range is reserved for
Expand Down
10 changes: 9 additions & 1 deletion core/site/api_custom.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package site

import "github.com/evcc-io/evcc/core/lm/profile"
import (
"time"

"github.com/evcc-io/evcc/core/lm/profile"
)

// CustomAPI is the part of the site api added by this fork. It lives in its own
// file so that upstream changes to API merge without conflicts; API embeds it.
Expand All @@ -13,6 +17,10 @@ type CustomAPI interface {
GetBatterySocGridChargeStop() float64
SetBatterySocGridChargeStop(float64) error

// one-time grid charging, see core/site_lm_once.go
SetBatteryGridChargeOnce(target float64, until time.Time) error
CancelBatteryGridChargeOnce() error

// load management shed priorities, see core/site_lm.go
SetLmPriority(name string, prio int) error

Expand Down
17 changes: 10 additions & 7 deletions core/site_lm.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,13 @@ type lmState struct {
advMu sync.Mutex
adv lmAdvanced

batteryShedUntil time.Time // battery grid charge hold-off after a shed
feedInTried time.Time // last feed-in finalization attempt, see site_feedin.go
feedInOnce sync.Once // feed-in history backfilled
feedInMarket *float64 // market price last published
batteryCircuit api.Circuit // resolved from the assignment
batteryCircuitRef string // what batteryCircuit was resolved from
batteryShedUntil time.Time // battery grid charge hold-off after a shed
feedInTried time.Time // last feed-in finalization attempt, see site_feedin.go
feedInOnce sync.Once // feed-in history backfilled
feedInMarket *float64 // market price last published
gridOnce gridChargeOnce // one-time grid charging, see site_lm_once.go
batteryCircuit api.Circuit // resolved from the assignment
batteryCircuitRef string // what batteryCircuit was resolved from
batteryLoad *batteryLoad
}

Expand Down Expand Up @@ -106,6 +107,7 @@ func (site *Site) restoreLmSettings() {
}

lm.SetPriorityLookup(site.lmPriorityLookup)
site.restoreGridChargeOnce()

site.restoreLmGuard()
site.restoreLmAdvanced()
Expand Down Expand Up @@ -358,9 +360,10 @@ func (site *Site) batteryCircuitAllows() bool {
func (site *Site) batteryGridChargeRequested(rate api.Rate) bool {
// evaluated unconditionally so the hysteresis keeps tracking the soc
socActive := site.batterySocChargeActive()
onceActive := site.batteryGridChargeOnceActive()

// a running demand peak needs the battery for shaving, not charging
if !socActive && !site.batteryGridChargeActive(rate) || site.peakPausesGridCharge() {
if !socActive && !onceActive && !site.batteryGridChargeActive(rate) || site.peakPausesGridCharge() {
// release what the battery had reserved on the circuit, lower priority
// loads would otherwise stay throttled until the reservation expires
lm.Forget(site.lmBattery())
Expand Down
Loading
Loading