Optimizer: publish battery forecast (#24633)

This commit is contained in:
andig 2026-02-19 15:22:21 +01:00 • committed by GitHub
parent 4af949a8e4
commit 5f5c363538
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 646 additions and 173 deletions

View file

@ -68,6 +68,92 @@ GridAndPV.args = {
},
} as any;
function hoursFromNow(h: number): string {
return new Date(Date.now() + h * 60 * 60 * 1000).toISOString();
}
const batteryBase = {
gridConfigured: true,
pvConfigured: true,
batteryConfigured: true,
homePower: 800,
};
const bat = (power: number, soc: number, forecast: any, devices: any[] = []) => ({
power,
soc,
capacity: 10,
devices,
forecast,
});
const dev = (title: string, power: number, soc: number, forecast: any) => ({
title,
power,
soc,
capacity: 10,
controllable: true,
forecast,
});
export const BatteryForecastDischarging = Template.bind({});
BatteryForecastDischarging.args = {
...batteryBase,
gridPower: 500,
homePower: 1800,
battery: bat(1300, 62, { full: null, empty: hoursFromNow(0.4) }),
} as any;
export const BatteryForecastCharging = Template.bind({});
BatteryForecastCharging.args = {
...batteryBase,
pvPower: 6000,
gridPower: -1000,
battery: bat(-4200, 45, { full: hoursFromNow(2.5), empty: null }),
} as any;
export const BatteryForecastBoth = Template.bind({});
BatteryForecastBoth.args = {
...batteryBase,
pvPower: 3000,
gridPower: -500,
battery: bat(-1700, 70, { full: hoursFromNow(2), empty: hoursFromNow(36) }),
} as any;
export const BatteryForecastMultiDischarging = Template.bind({});
BatteryForecastMultiDischarging.args = {
...batteryBase,
gridPower: 500,
homePower: 2500,
battery: bat(2000, 55, { full: null, empty: hoursFromNow(48) }, [
dev("Powerwall", 1200, 60, { full: null, empty: hoursFromNow(48) }),
dev("BYD", 800, 48, { full: null, empty: hoursFromNow(0.25) }),
]),
} as any;
export const BatteryForecastMultiCharging = Template.bind({});
BatteryForecastMultiCharging.args = {
...batteryBase,
pvPower: 8000,
gridPower: -1000,
homePower: 1000,
battery: bat(-6000, 40, { full: hoursFromNow(26), empty: null }, [
dev("Powerwall", -3500, 35, { full: hoursFromNow(26), empty: null }),
dev("BYD", -2500, 47, { full: hoursFromNow(0.7), empty: null }),
]),
} as any;
export const BatteryForecastGridChargeLimit = Template.bind({});
BatteryForecastGridChargeLimit.args = {
...batteryBase,
pvPower: 5000,
gridPower: -500,
batteryGridChargeLimit: 0.15,
smartCostType: "price",
currency: CURRENCY.EUR,
battery: bat(-3700, 50, { full: hoursFromNow(1.5), empty: null }),
} as any;
export const BatteryAndGrid = Template.bind({});
BatteryAndGrid.args = {
gridConfigured: true,

View file

@ -126,14 +126,34 @@
@details-clicked="openBatterySettingsModal"
@toggle="toggleBattery"
>
<template v-if="batteryGridChargeLimitSet" #subline>
<div class="d-none d-md-block">&nbsp;</div>
<template
v-if="batteryForecastExists || batteryGridChargeLimitSet"
#subline
>
<div
v-if="batteryForecastEmpty"
class="d-flex align-items-center mb-2"
>
<ForecastMessage>{{
batteryForecastEmpty
}}</ForecastMessage>
</div>
<div
v-else-if="batteryForecastFull"
class="d-none d-md-block mb-2"
>
&nbsp;
</div>
<div v-if="batteryGridChargeLimitSet" class="d-none d-md-block">
&nbsp;
</div>
</template>
<template v-if="hasMultipleBatteries" #expanded>
<EnergyflowEntry
v-for="(b, index) in batteryDevices"
:key="index"
:name="b.title || genericBatteryTitle(index)"
:prediction="devicePrediction(b, false)"
:details="b.soc"
:detailsFmt="batteryFmt"
:power="dischargePower(b.power)"
@ -250,8 +270,24 @@
@details-clicked="openBatterySettingsModal"
@toggle="toggleBattery"
>
<template v-if="batteryGridChargeLimitSet" #subline>
<template
v-if="batteryForecastExists || batteryGridChargeLimitSet"
#subline
>
<div
v-if="batteryForecastFull"
class="d-flex align-items-center mb-2"
>
<ForecastMessage>{{ batteryForecastFull }}</ForecastMessage>
</div>
<div
v-else-if="batteryForecastEmpty"
class="d-none d-md-block mb-2"
>
&nbsp;
</div>
<button
v-if="batteryGridChargeLimitSet"
type="button"
class="btn-reset d-flex justify-content-between text-start pe-4"
@click.stop="openBatterySettingsModal"
@ -276,6 +312,7 @@
v-for="(b, index) in batteryDevices"
:key="index"
:name="b.title || genericBatteryTitle(index)"
:prediction="devicePrediction(b, true)"
:details="b.soc"
:detailsFmt="batteryFmt"
:power="chargePower(b.power)"
@ -311,6 +348,7 @@ import Visualization from "./Visualization.vue";
import Entry from "./Entry.vue";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import AnimatedNumber from "../Helper/AnimatedNumber.vue";
import ForecastMessage from "./ForecastMessage.vue";
import settings from "@/settings";
import collector from "@/mixins/collector.js";
import { defineComponent, type PropType } from "vue";
@ -329,6 +367,7 @@ export default defineComponent({
Visualization,
EnergyflowEntry: Entry,
AnimatedNumber,
ForecastMessage,
},
mixins: [formatter, collector],
props: {
@ -363,7 +402,11 @@ export default defineComponent({
forecast: { type: Object as PropType<Forecast>, default: () => ({}) },
},
data: () => {
return { detailsOpen: false, detailsCompleteHeight: null as number | null, ready: false };
return {
detailsOpen: false,
detailsCompleteHeight: null as number | null,
ready: false,
};
},
computed: {
showCo2() {
@ -545,6 +588,21 @@ export default defineComponent({
consumers() {
return [...this.aux, ...this.ext];
},
batteryForecastFull(): string | null {
if (this.batteryExpanded && this.hasMultipleBatteries) return null;
const timeago = this.fmtForecastRelative(this.battery?.forecast?.full);
if (!timeago) return null;
return this.$t("main.energyflow.batteryForecastFull", { timeago });
},
batteryForecastEmpty(): string | null {
if (this.batteryExpanded && this.hasMultipleBatteries) return null;
const timeago = this.fmtForecastRelative(this.battery?.forecast?.empty);
if (!timeago) return null;
return this.$t("main.energyflow.batteryForecastEmpty", { timeago });
},
batteryForecastExists(): boolean {
return !!(this.batteryForecastEmpty || this.batteryForecastFull);
},
},
watch: {
pvConfigured() {
@ -646,6 +704,31 @@ export default defineComponent({
genericConsumerTitle(index: number) {
return `${this.$t("config.devices.consumer")} #${index + 1}`;
},
fmtForecastRelative(isoString?: string | null): string | null {
if (!isoString) return null;
const elapsed = new Date(isoString).getTime() - Date.now();
if (elapsed <= 0) return null;
return this.fmtTimeAgo(elapsed, "always");
},
deviceForecastFull(device: { forecast?: { full?: string | null } }): string | null {
return this.fmtForecastRelative(device.forecast?.full);
},
deviceForecastEmpty(device: { forecast?: { empty?: string | null } }): string | null {
return this.fmtForecastRelative(device.forecast?.empty);
},
devicePrediction(
device: { forecast?: { full?: string | null; empty?: string | null } },
full: boolean
): string | undefined {
const timeago = full
? this.deviceForecastFull(device)
: this.deviceForecastEmpty(device);
if (!timeago) return undefined;
const key = full
? "main.energyflow.batteryForecastFull"
: "main.energyflow.batteryForecastEmpty";
return this.$t(key, { timeago });
},
},
});
</script>

View file

@ -10,7 +10,10 @@
</span>
<div class="d-flex flex-grow-1 ms-3 align-items-center text-truncate">
<span v-if="!$slots['expanded']" class="text-truncate">
{{ name }}
{{ name
}}<ForecastMessage v-if="prediction" class="ms-1" inline>{{
prediction
}}</ForecastMessage>
</span>
<button
v-else
@ -80,14 +83,16 @@ import formatter, { POWER_UNIT } from "@/mixins/formatter";
import AnimatedNumber from "../Helper/AnimatedNumber.vue";
import VehicleIcon from "../VehicleIcon";
import ForecastIcon from "../MaterialIcon/Forecast.vue";
import ForecastMessage from "./ForecastMessage.vue";
import { defineComponent, type PropType } from "vue";
export default defineComponent({
name: "EnergyflowEntry",
components: { BatteryIcon, AnimatedNumber, VehicleIcon, ForecastIcon },
components: { BatteryIcon, AnimatedNumber, VehicleIcon, ForecastIcon, ForecastMessage },
mixins: [formatter],
props: {
name: { type: String },
prediction: { type: String },
icon: { type: String },
iconProps: { type: Object, default: () => ({}) },
power: { type: Number, default: 0 },

View file

@ -0,0 +1,52 @@
<template>
<router-link to="/optimize" class="root" @click.stop>
<template v-if="!inline">{{ $t("main.energyflow.forecast") }} </template>
<span class="message"><slot /></span>
</router-link>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name: "ForecastMessage",
props: {
inline: {
type: Boolean,
default: false,
},
},
});
</script>
<style scoped>
.root {
background: linear-gradient(
90deg,
var(--evcc-battery) 0%,
var(--evcc-battery) 15%,
var(--evcc-self) 30%,
var(--evcc-battery) 50%,
var(--evcc-battery) 65%,
var(--evcc-self) 80%,
var(--evcc-battery) 100%
);
background-size: 200% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: forecast-sweep 6s linear infinite;
text-decoration: none;
}
.message {
text-decoration: underline;
}
@keyframes forecast-sweep {
0% {
background-position: 0% 0;
}
100% {
background-position: -200% 0;
}
}
</style>

View file

@ -332,7 +332,7 @@ export default defineComponent({
const unit = this.energyPriceSubunit(currency) || CURRENCY_SYMBOLS[currency] || currency;
return `${unit}${short ? "" : "/kWh"}`;
},
fmtTimeAgo(elapsed: number) {
fmtTimeAgo(elapsed: number, numeric: "auto" | "always" = "auto") {
const units = {
day: 24 * 60 * 60 * 1000,
hour: 60 * 60 * 1000,
@ -346,7 +346,7 @@ export default defineComponent({
if (Math.abs(elapsed) > units[unitKey] || u == "second") {
try {
const rtf = new Intl.RelativeTimeFormat(this.$i18n?.locale, {
numeric: "auto",
numeric,
});
return rtf.format(Math.round(elapsed / units[unitKey]), unitKey);
} catch (e) {

View file

@ -511,17 +511,24 @@ export interface Meter {
energy?: number;
}
export interface BatteryForecast {
full: string | null; // ISO 8601 datetime
empty: string | null; // ISO 8601 datetime
}
export interface Battery {
power: number;
capacity: number;
soc: number;
devices: BatteryMeter[];
forecast?: BatteryForecast;
}
export interface BatteryMeter extends Meter {
soc: number;
controllable: boolean;
capacity: number; // 0 when not specified
forecast?: BatteryForecast;
}
export interface Vehicle {

View file

@ -600,9 +600,9 @@ func (site *Site) updatePvMeters() {
}
// updateBatteryMeters updates battery meters
func (site *Site) updateBatteryMeters() []types.Measurement {
func (site *Site) updateBatteryMeters() {
if len(site.batteryMeters) == 0 {
return nil
return
}
mm := site.collectMeters("battery", site.batteryMeters)
@ -666,10 +666,14 @@ func (site *Site) updateBatteryMeters() []types.Measurement {
site.log.DEBUG.Printf("battery soc: %.0f%%", math.Round(site.battery.Soc))
}
site.battery.Devices = mm
site.publish(keys.Battery, site.battery)
// keep forecast property created by optimizer
for i := range site.battery.Devices {
mm[i].Forecast = site.battery.Devices[i].Forecast
}
return mm
site.battery.Devices = mm
site.publish(keys.Battery, util.NewSharder(keys.Battery, site.battery))
}
// updateAuxMeters updates aux meters
@ -756,10 +760,8 @@ func (site *Site) updateGridMeter() error {
func (site *Site) updateMeters() error {
var eg errgroup.Group
var battery []types.Measurement
eg.Go(func() error { site.updatePvMeters(); return nil })
eg.Go(func() error { battery = site.updateBatteryMeters(); return nil })
eg.Go(func() error { site.updateBatteryMeters(); return nil })
eg.Go(func() error { site.updateAuxMeters(); return nil })
eg.Go(func() error { site.updateExtMeters(); return nil })
@ -770,7 +772,7 @@ func (site *Site) updateMeters() error {
}
if sponsor.IsAuthorized() {
go site.optimizerUpdateAsync(battery)
go site.optimizerUpdateAsync()
}
return nil

View file

@ -13,10 +13,12 @@ import (
evopt "github.com/andig/evopt/client"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/metrics"
"github.com/evcc-io/evcc/core/types"
"github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/sponsor"
@ -48,14 +50,40 @@ type batteryDetail struct {
Capacity float64 `json:"capacity,omitempty"`
}
type responseDetails struct {
type batteryResult struct {
batteryDetail
Full *time.Time `json:"full"`
Empty *time.Time `json:"empty"`
}
// func (br batteryResult) MarshalJSON() ([]byte, error) {
// var full, empty int64
// if !br.Full.IsZero() {
// full = int64(time.Until(br.Full).Seconds())
// }
// if !br.Empty.IsZero() {
// empty = int64(time.Until(br.Empty).Seconds())
// }
// return json.Marshal(struct {
// batteryResult
// UntilFull int64 `json:"untilFull,omitempty"`
// UntilEmpty int64 `json:"untilEmpty,omitempty"`
// }{
// batteryResult: br,
// UntilFull: full,
// UntilEmpty: empty,
// })
// }
type requestDetails struct {
Timestamps []time.Time `json:"timestamp"`
BatteryDetails []batteryDetail `json:"batteryDetails"`
}
const slotsPerHour = float64(time.Hour / tariff.SlotDuration)
func (site *Site) optimizerUpdateAsync(battery []types.Measurement) {
func (site *Site) optimizerUpdateAsync() {
var err error
if time.Since(updated) < 2*time.Minute {
@ -79,7 +107,7 @@ func (site *Site) optimizerUpdateAsync(battery []types.Measurement) {
}
}()
err = site.optimizerUpdate(battery)
err = site.optimizerUpdate(site.battery.Devices)
}
func (site *Site) optimizerUpdate(battery []types.Measurement) error {
@ -148,141 +176,37 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
// end of horizon Wh value
pa := lo.Min(req.TimeSeries.PN) * eta * 0.99
details := responseDetails{
details := requestDetails{
Timestamps: asTimestamps(dt),
}
add := func(battery evopt.BatteryConfig, detail batteryDetail) {
battery.PA = pa
req.Batteries = append(req.Batteries, battery)
details.BatteryDetails = append(details.BatteryDetails, detail)
}
for _, lp := range site.Loadpoints() {
// ignore disconnected loadpoints
if lp.GetStatus() == api.StatusA {
continue
}
v := lp.GetVehicle()
if v == nil || v.Capacity() == 0 {
if v := lp.GetVehicle(); v == nil || v.Capacity() == 0 {
continue
}
bat := evopt.BatteryConfig{
ChargeFromGrid: true,
CMin: float32(lp.EffectiveMinPower()),
CMax: float32(lp.EffectiveMaxPower()),
DMax: 0,
SMin: 0,
PA: pa,
}
if profile := loadpointProfile(lp, minLen); profile != nil {
bat.PDemand = prorate(profile, firstSlotDuration)
}
detail := batteryDetail{
Type: batteryTypeLoadpoint,
Title: lp.GetTitle(),
}
// vehicle
maxSoc := v.Capacity() * 1e3 // Wh
if v := lp.EffectiveLimitSoc(); v > 0 {
maxSoc *= float64(v) / 100
} else if v := lp.GetLimitEnergy(); v > 0 {
maxSoc = v * 1e3
}
bat.SInitial = float32(v.Capacity() * lp.GetSoc() * 10) // Wh
bat.SMax = max(bat.SInitial, float32(maxSoc)) // prevent infeasible if current soc above maximum
detail.Type = batteryTypeVehicle
detail.Capacity = v.Capacity()
if vt := v.GetTitle(); vt != "" {
if detail.Title != "" {
detail.Title += " – "
}
detail.Title += vt
}
// find vehicle name/id
for _, dev := range config.Vehicles().Devices() {
if dev.Instance() == v {
detail.Name = dev.Config().Name
}
}
var demand []float32
switch lp.GetMode() {
case api.ModeOff:
// disable charging
bat.CMax = 0
case api.ModeNow:
// forced max charging
demand = continuousDemand(lp, minLen)
case api.ModeMinPV:
// forced min charging
demand = continuousDemand(lp, minLen)
// add smartcost limit and plan goal, if configured
demand = applySmartCostLimit(lp, demand, grid, minLen)
site.applyPlanGoal(lp, &bat, minLen)
case api.ModePV:
// add smartcost limit and plan goal, if configured
demand = applySmartCostLimit(lp, nil, grid, minLen)
site.applyPlanGoal(lp, &bat, minLen)
}
if demand != nil {
bat.PDemand = prorate(demand, firstSlotDuration)
}
req.Batteries = append(req.Batteries, bat)
details.BatteryDetails = append(details.BatteryDetails, detail)
add(site.loadpointRequest(lp, minLen, firstSlotDuration, grid))
}
for i, b := range battery {
for i, dev := range site.batteryMeters {
b := battery[i]
if b.Capacity == nil || *b.Capacity == 0 || b.Soc == nil {
continue
}
dev := site.batteryMeters[i]
bat := evopt.BatteryConfig{
CMax: batteryPower,
DMax: batteryPower,
SInitial: float32(*b.Capacity * *b.Soc * 10), // Wh
PA: pa,
}
bat.SMax = max(bat.SInitial, float32(*b.Capacity*1e3)) // Wh
instance := dev.Instance()
if _, ok := instance.(api.BatteryController); ok {
bat.ChargeFromGrid = true
}
if m, ok := instance.(api.BatteryPowerLimiter); ok {
charge, discharge := m.GetPowerLimits()
bat.CMax = float32(charge)
bat.DMax = float32(discharge)
}
if m, ok := instance.(api.BatterySocLimiter); ok {
minSoc, maxSoc := m.GetSocLimits()
bat.SMin = min(bat.SInitial, float32(*b.Capacity*minSoc*10)) // Wh
bat.SMax = max(bat.SInitial, float32(*b.Capacity*maxSoc*10)) // Wh
}
req.Batteries = append(req.Batteries, bat)
details.BatteryDetails = append(details.BatteryDetails, batteryDetail{
Type: batteryTypeBattery,
Name: dev.Config().Name,
Title: deviceProperties(dev).Title,
Capacity: *b.Capacity,
})
add(site.batteryRequest(dev, b))
}
httpClient := request.NewClient(site.log)
@ -310,13 +234,225 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
site.publish("evopt", struct {
Req evopt.OptimizationInput `json:"req"`
Res evopt.OptimizationResult `json:"res"`
Details responseDetails `json:"details"`
Details requestDetails `json:"details"`
}{
Req: req,
Res: *resp.JSON200,
Details: details,
})
var batteries []batteryResult
for i, batReq := range req.Batteries {
batResp := resp.JSON200.Batteries[i]
batResult := batteryResult{
batteryDetail: details.BatteryDetails[i],
Full: matchSoc(batResp.StateOfCharge, func(soc float32) bool {
return soc >= batReq.SMax
}),
Empty: matchSoc(batResp.StateOfCharge, func(soc float32) bool {
return soc <= batReq.SMin
}),
}
batteries = append(batteries, batResult)
for j, dev := range site.batteryMeters {
if details.BatteryDetails[i].Name == dev.Config().Name {
site.battery.Devices[j].Forecast = &types.BatteryForecast{
Full: batResult.Full,
Empty: batResult.Empty,
}
break
}
}
}
site.publish("evopt-batteries", batteries)
site.battery.Forecast = site.addBatteryForecastTotals(req.Batteries, resp.JSON200.Batteries)
site.publish(keys.Battery, util.NewSharder(keys.Battery, site.battery))
return nil
}
func (site *Site) addBatteryForecastTotals(req []evopt.BatteryConfig, resp []evopt.BatteryResult) *types.BatteryForecast {
if len(resp) == 0 || len(resp[0].StateOfCharge) == 0 {
return nil
}
now := time.Now().Round(tariff.SlotDuration)
fullSlot, emptySlot := site.batteryForecastFullAndEmptySlots(req, resp)
const zero = -1
if fullSlot == zero && emptySlot == zero {
return nil
}
var res types.BatteryForecast
if fullSlot != zero {
if ts := now.Add(time.Duration(fullSlot) * tariff.SlotDuration); ts.After(time.Now()) {
res.Full = new(ts)
}
}
if emptySlot != zero {
if ts := now.Add(time.Duration(emptySlot) * tariff.SlotDuration); ts.After(time.Now()) {
res.Empty = new(ts)
}
}
return &res
}
func (site *Site) batteryForecastFullAndEmptySlots(req []evopt.BatteryConfig, resp []evopt.BatteryResult) (int, int) {
matchSlot := func(fun func(soc float32, batIdx int) bool) int {
for i := range resp[0].StateOfCharge {
for batIdx := range req {
if fun(resp[batIdx].StateOfCharge[i], batIdx) {
return i
}
}
}
return -1
}
fullSlot := matchSlot(func(soc float32, batIdx int) bool {
return soc >= req[batIdx].SMax
})
emptySlot := matchSlot(func(soc float32, batIdx int) bool {
return soc <= req[batIdx].SMin
})
return fullSlot, emptySlot
}
func (site *Site) loadpointRequest(lp loadpoint.API, minLen int, firstSlotDuration time.Duration, grid api.Rates) (evopt.BatteryConfig, batteryDetail) {
bat := evopt.BatteryConfig{
ChargeFromGrid: true,
CMin: float32(lp.EffectiveMinPower()),
CMax: float32(lp.EffectiveMaxPower()),
DMax: 0,
SMin: 0,
// PA: pa,
}
if profile := loadpointProfile(lp, minLen); profile != nil {
bat.PDemand = prorate(profile, firstSlotDuration)
}
detail := batteryDetail{
Type: batteryTypeLoadpoint,
Title: lp.GetTitle(),
}
// vehicle
v := lp.GetVehicle()
maxSoc := v.Capacity() * 1e3 // Wh
if v := lp.EffectiveLimitSoc(); v > 0 {
maxSoc *= float64(v) / 100
} else if v := lp.GetLimitEnergy(); v > 0 {
maxSoc = v * 1e3
}
bat.SInitial = float32(v.Capacity() * lp.GetSoc() * 10) // Wh
bat.SMax = max(bat.SInitial, float32(maxSoc)) // prevent infeasible if current soc above maximum
detail.Type = batteryTypeVehicle
detail.Capacity = v.Capacity()
if vt := v.GetTitle(); vt != "" {
if detail.Title != "" {
detail.Title += " – "
}
detail.Title += vt
}
// find vehicle name/id
for _, dev := range config.Vehicles().Devices() {
if dev.Instance() == v {
detail.Name = dev.Config().Name
}
}
var demand []float32
switch lp.GetMode() {
case api.ModeOff:
// disable charging
bat.CMax = 0
case api.ModeNow:
// forced max charging
demand = continuousDemand(lp, minLen)
case api.ModeMinPV:
// forced min charging
demand = continuousDemand(lp, minLen)
// add smartcost limit and plan goal, if configured
demand = applySmartCostLimit(lp, demand, grid, minLen)
site.applyPlanGoal(lp, &bat, minLen)
case api.ModePV:
// add smartcost limit and plan goal, if configured
demand = applySmartCostLimit(lp, nil, grid, minLen)
site.applyPlanGoal(lp, &bat, minLen)
}
if demand != nil {
bat.PDemand = prorate(demand, firstSlotDuration)
}
return bat, detail
}
func (site *Site) batteryRequest(dev config.Device[api.Meter], b types.Measurement) (evopt.BatteryConfig, batteryDetail) {
bat := evopt.BatteryConfig{
CMax: batteryPower,
DMax: batteryPower,
SInitial: float32(*b.Capacity * *b.Soc * 10), // Wh
// PA: pa,
}
bat.SMax = max(bat.SInitial, float32(*b.Capacity*1e3)) // Wh
instance := dev.Instance()
if _, ok := instance.(api.BatteryController); ok {
bat.ChargeFromGrid = true
}
if m, ok := instance.(api.BatteryPowerLimiter); ok {
charge, discharge := m.GetPowerLimits()
bat.CMax = float32(charge)
bat.DMax = float32(discharge)
}
if m, ok := instance.(api.BatterySocLimiter); ok {
minSoc, maxSoc := m.GetSocLimits()
bat.SMin = min(bat.SInitial, float32(*b.Capacity*minSoc*10)) // Wh
bat.SMax = max(bat.SInitial, float32(*b.Capacity*maxSoc*10)) // Wh
}
detail := batteryDetail{
Type: batteryTypeBattery,
Name: dev.Config().Name,
Title: deviceProperties(dev).Title,
Capacity: *b.Capacity,
}
return bat, detail
}
func matchSoc(ts []float32, fun func(float32) bool) *time.Time {
for i, soc := range ts {
if fun(soc) {
// TODO first slot
return new(time.Now().Add(time.Duration(i+1) * tariff.SlotDuration))
}
}
return nil
}

View file

@ -4,12 +4,14 @@ import (
"testing"
"time"
evopt "github.com/andig/evopt/client"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/metrics"
"github.com/evcc-io/evcc/server/db"
"github.com/jinzhu/now"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)
@ -108,3 +110,56 @@ func TestLoadpointProfile(t *testing.T) {
// expected slots: 0.25 kWh...
require.Equal(t, []float64{250, 250, 250, 250, 250, 250, 250, 50}, loadpointProfile(lp, 8))
}
func TestBatteryForecastTotals(t *testing.T) {
site := new(Site)
req := []evopt.BatteryConfig{
{SMax: 80},
{SMax: 80},
}
const zero = -1
for _, tc := range []struct {
name string
bat1, bat2 []float32
full, empty int
}{
{
"never full",
[]float32{0, 0},
[]float32{0, 0},
zero, 0,
},
{
"never empty",
[]float32{100, 100},
[]float32{100, 100},
0, zero,
},
{
"first full then empty",
[]float32{100, 0},
[]float32{100, 0},
0, 1,
},
{
"first empty then full",
[]float32{0, 100},
[]float32{0, 100},
1, 0,
},
} {
t.Run(tc.name, func(t *testing.T) {
resp := []evopt.BatteryResult{
{StateOfCharge: tc.bat1},
{StateOfCharge: tc.bat2},
}
full, empty := site.batteryForecastFullAndEmptySlots(req, resp)
assert.Equal(t, tc.full, full, "full")
assert.Equal(t, tc.empty, empty, "empty")
})
}
}

View file

@ -1,21 +1,51 @@
package types
import "github.com/evcc-io/evcc/api"
import (
"time"
"github.com/evcc-io/evcc/api"
)
// Measurement is the device measurements struct
type Measurement struct {
Title string `json:"title,omitempty"`
Icon string `json:"icon,omitempty"`
Power float64 `json:"power"`
Energy float64 `json:"energy,omitempty"`
Powers []float64 `json:"powers,omitempty"`
Currents []float64 `json:"currents,omitempty"`
ExcessDCPower float64 `json:"excessdcpower,omitempty"`
Capacity *float64 `json:"capacity,omitempty"`
Soc *float64 `json:"soc,omitempty"`
Controllable *bool `json:"controllable,omitempty"`
Title string `json:"title,omitempty"`
Icon string `json:"icon,omitempty"`
Power float64 `json:"power"`
Energy float64 `json:"energy,omitempty"`
Powers []float64 `json:"powers,omitempty"`
Currents []float64 `json:"currents,omitempty"`
ExcessDCPower float64 `json:"excessdcpower,omitempty"`
Capacity *float64 `json:"capacity,omitempty"`
Soc *float64 `json:"soc,omitempty"`
Controllable *bool `json:"controllable,omitempty"`
Forecast *BatteryForecast `json:"forecast,omitempty"`
}
type BatteryForecast struct {
Full *time.Time `json:"full"`
Empty *time.Time `json:"empty"`
}
// func (f BatteryForecast) MarshalJSON() ([]byte, error) {
// var full, empty int64
// if !f.Full.IsZero() {
// full = int64(time.Until(f.Full).Seconds())
// }
// if !f.Empty.IsZero() {
// empty = int64(time.Until(f.Empty).Seconds())
// }
// return json.Marshal(struct {
// BatteryForecast
// UntilFull int64 `json:"untilFull,omitempty"`
// UntilEmpty int64 `json:"untilEmpty,omitempty"`
// }{
// BatteryForecast: f,
// UntilFull: full,
// UntilEmpty: empty,
// })
// }
var _ api.TitleDescriber = (*Measurement)(nil)
// GetTitle implements api.TitleDescriber interface for InfluxDB tagging
@ -24,9 +54,10 @@ func (m Measurement) GetTitle() string {
}
type BatteryState struct {
Power float64 `json:"power"`
Energy float64 `json:"energy,omitempty"`
Capacity float64 `json:"capacity,omitempty"`
Soc float64 `json:"soc"`
Devices []Measurement `json:"devices,omitempty" influxdb:"battery"`
Power float64 `json:"power"`
Energy float64 `json:"energy,omitempty"`
Capacity float64 `json:"capacity,omitempty"`
Soc float64 `json:"soc"`
Devices []Measurement `json:"devices,omitempty" influxdb:"battery"`
Forecast *BatteryForecast `json:"forecast,omitempty"`
}

View file

@ -992,10 +992,13 @@
"battery": "Batterie",
"batteryCharge": "Batterie laden",
"batteryDischarge": "Batterie entladen",
"batteryGridChargeActive": "Netzladen aktiv",
"batteryGridChargeLimit": "Netzladen wenn",
"batteryForecastEmpty": "leer {timeago}",
"batteryForecastFull": "voll {timeago}",
"batteryGridChargeActive": "Netzladen: aktiv",
"batteryGridChargeLimit": "Netzladen: wenn",
"batteryHold": "Batterie (gesperrt)",
"batteryTooltip": "{energy} von {total} ({soc})",
"forecast": "Vorhersage: ",
"forecastTooltip": "Vorhersage: verbleibende PV-Produktion heute",
"gridImport": "Netzbezug",
"homePower": "Verbrauch",

View file

@ -992,10 +992,13 @@
"battery": "Battery",
"batteryCharge": "Battery charging",
"batteryDischarge": "Battery discharging",
"batteryGridChargeActive": "grid charging active",
"batteryGridChargeLimit": "grid charging when",
"batteryForecastEmpty": "empty {timeago}",
"batteryForecastFull": "full {timeago}",
"batteryGridChargeActive": "Grid charging: active",
"batteryGridChargeLimit": "Grid charging: when",
"batteryHold": "Battery (locked)",
"batteryTooltip": "{energy} of {total} ({soc})",
"forecast": "Forecast: ",
"forecastTooltip": "forecast: remaining solar production today",
"gridImport": "Grid use",
"homePower": "Consumption",

View file

@ -8,6 +8,7 @@ import (
"time"
"github.com/evcc-io/evcc/core/types"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)
@ -97,30 +98,37 @@ func (suite *mqttSuite) TestSlice() {
}
func (suite *mqttSuite) TestMeasurement() {
topics := []string{"test/title", "test/icon", "test/power", "test/energy", "test/powers", "test/currents", "test/excessDCPower", "test/capacity", "test/soc", "test/controllable"}
topics := lo.Map([]string{
"title", "icon", "power", "energy", "powers", "currents", "excessDCPower", "capacity", "soc", "controllable", "forecast",
}, func(s string, _ int) string {
return "test/" + s
})
suite.publish("test", false, types.Measurement{})
suite.Equal(topics, suite.topics, "topics")
suite.Equal([]string{"", "", "0", "", "", "", "", "", "", ""}, suite.payloads, "payloads")
suite.Equal([]string{"", "", "0", "", "", "", "", "", "", "", ""}, suite.payloads, "empty payloads")
suite.publish("test", false, types.Measurement{Energy: 1})
suite.Equal(topics, suite.topics, "topics")
suite.Equal([]string{"", "", "0", "1", "", "", "", "", "", ""}, suite.payloads, "payloads")
suite.Equal([]string{"", "", "0", "1", "", "", "", "", "", "", ""}, suite.payloads, "energy payloads")
suite.publish("test", false, types.Measurement{Controllable: new(false)})
suite.Equal(topics, suite.topics, "topics")
suite.Equal([]string{"", "", "0", "", "", "", "", "", "", "false"}, suite.payloads, "payloads")
suite.Equal([]string{"", "", "0", "", "", "", "", "", "", "false", ""}, suite.payloads, "controllable payloads")
suite.publish("test", false, types.Measurement{Currents: []float64{1, 2, 3}})
suite.Equal(append(topics, "test/currents/1", "test/currents/2", "test/currents/3"), suite.topics, "topics")
suite.Equal([]string{"", "", "0", "", "", "3", "", "", "", "", "1", "2", "3"}, suite.payloads, "payloads")
suite.Equal(append(topics, "test/currents/1", "test/currents/2", "test/currents/3"), suite.topics, "currents topics")
suite.Equal([]string{"", "", "0", "", "", "3", "", "", "", "", "", "1", "2", "3"}, suite.payloads, "currents payloads")
}
func (suite *mqttSuite) TestBatteryState() {
topics := []string{
"test/power", "test/energy", "test/capacity", "test/soc", "test/devices",
"test/devices/1/title", "test/devices/1/icon", "test/devices/1/power", "test/devices/1/energy", "test/devices/1/powers", "test/devices/1/currents", "test/devices/1/excessDCPower", "test/devices/1/capacity", "test/devices/1/soc", "test/devices/1/controllable",
}
topics := lo.Map([]string{
"power", "energy", "capacity", "soc",
"devices", "devices/1/title", "devices/1/icon", "devices/1/power", "devices/1/energy", "devices/1/powers", "devices/1/currents", "devices/1/excessDCPower", "devices/1/capacity", "devices/1/soc", "devices/1/controllable", "devices/1/forecast",
"forecast",
}, func(s string, _ int) string {
return "test/" + s
})
suite.publish("test", false, types.BatteryState{
Power: 2,
@ -132,5 +140,5 @@ func (suite *mqttSuite) TestBatteryState() {
})
suite.Equal(topics, suite.topics, "topics")
suite.Equal([]string{"2", "", "", "20", "1", "", "", "1", "", "", "", "", "", "10", ""}, suite.payloads, "payloads")
suite.Equal([]string{"2", "", "", "20", "1", "", "", "1", "", "", "", "", "", "10", "", "", ""}, suite.payloads, "payloads")
}

View file

@ -27,13 +27,13 @@ test.describe("battery settings co2", async () => {
await page.getByRole("button", { name: "Close" }).click();
await expectModalHidden(modal);
await page.getByTestId("energyflow").click();
await page.getByRole("button", { name: "grid charging active (≤ 150 g)" }).click();
await page.getByRole("button", { name: "Grid charging: active (≤ 150 g)" }).click();
await expectModalVisible(modal);
await modal.getByLabel("CO₂ limit").selectOption({ label: "≤ 10 g/kWh" });
await expect(modal.getByTestId("active-hours")).toHaveText("Active time");
await modal.getByRole("button", { name: "Close" }).click();
await expectModalHidden(modal);
await expect(modal).not.toBeVisible();
await expect(page.getByRole("button", { name: "grid charging when ≤ 10 g" })).toBeVisible();
await expect(page.getByRole("button", { name: "Grid charging: when ≤ 10 g" })).toBeVisible();
});
});

View file

@ -56,14 +56,16 @@ test.describe("battery settings", async () => {
await page.getByRole("button", { name: "Close" }).click();
await expectModalHidden(modal);
await page.getByTestId("energyflow").click();
await page.getByRole("button", { name: "grid charging active (≤ 50.0 ct)" }).click();
await page.getByRole("button", { name: "Grid charging: active (≤ 50.0 ct)" }).click();
await expectModalVisible(modal);
await modal.getByLabel("Price limit").selectOption({ label: "≤ -10.0 ct/kWh" });
await expect(modal.getByTestId("active-hours")).toHaveText("Active time");
await modal.getByRole("button", { name: "Close" }).click();
await expectModalHidden(modal);
await expect(modal).not.toBeVisible();
await expect(page.getByRole("button", { name: "grid charging when ≤ -10.0 ct" })).toBeVisible();
await expect(
page.getByRole("button", { name: "Grid charging: when ≤ -10.0 ct" })
).toBeVisible();
});
test("hold mode display", async ({ page }) => {