Optimizer: expose forecasted highest/lowest battery SOC (#29564)

This commit is contained in:
andig 2026-05-15 13:07:27 +02:00 • committed by GitHub
parent b76af6101d
commit 7e41160acf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 242 additions and 129 deletions

View file

@ -183,6 +183,7 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w
- Use placeholders for dynamic content: `{soc}`, `{duration}`, `{value}`
- Prefer context-specific keys over generic ones
- Test with German translations (20-40% longer text)
- Keep separators and trailing punctuation (`: `, `…`, `—`) in the template, not in the translation value.
### Testing

View file

@ -148,7 +148,7 @@ BatteryForecastDischarging.args = {
...batteryBase,
gridPower: 500,
homePower: 1800,
battery: bat(1300, 62, { full: null, empty: hoursFromNow(0.4) }),
battery: bat(1300, 62, { lowest: { soc: 0, time: hoursFromNow(0.4), limit: true } }),
} as any;
export const BatteryForecastCharging = Template.bind({});
@ -156,7 +156,7 @@ BatteryForecastCharging.args = {
...batteryBase,
pvPower: 6000,
gridPower: -1000,
battery: bat(-4200, 45, { full: hoursFromNow(2.5), empty: null }),
battery: bat(-4200, 45, { highest: { soc: 100, time: hoursFromNow(2.5), limit: true } }),
} as any;
export const BatteryForecastBoth = Template.bind({});
@ -164,7 +164,21 @@ BatteryForecastBoth.args = {
...batteryBase,
pvPower: 3000,
gridPower: -500,
battery: bat(-1700, 70, { full: hoursFromNow(2), empty: hoursFromNow(36) }),
battery: bat(-1700, 70, {
highest: { soc: 100, time: hoursFromNow(2), limit: true },
lowest: { soc: 0, time: hoursFromNow(36), limit: true },
}),
} as any;
export const BatteryForecastSocExtremes = Template.bind({});
BatteryForecastSocExtremes.args = {
...batteryBase,
gridPower: 200,
homePower: 1500,
battery: bat(1300, 95, {
highest: { soc: 100, time: hoursFromNow(20), limit: true },
lowest: { soc: 34, time: hoursFromNow(8) },
}),
} as any;
export const BatteryForecastMulti = Template.bind({});
@ -173,7 +187,7 @@ BatteryForecastMulti.args = {
pvPower: 8000,
gridPower: -1000,
homePower: 1000,
battery: bat(-6000, 40, { full: hoursFromNow(26), empty: null }, [
battery: bat(-6000, 40, { highest: { soc: 100, time: hoursFromNow(26), limit: true } }, [
dev("Powerwall", -3500, 35),
dev("BYD", -2500, 47),
]),
@ -187,7 +201,7 @@ BatteryForecastGridChargeLimit.args = {
batteryGridChargeLimit: 0.15,
smartCostType: "price",
currency: CURRENCY.EUR,
battery: bat(-3700, 50, { full: hoursFromNow(1.5), empty: null }),
battery: bat(-3700, 50, { highest: { soc: 100, time: hoursFromNow(1.5), limit: true } }),
} as any;
export const BatteryAndGrid = Template.bind({});

View file

@ -131,13 +131,13 @@
#subline
>
<div
v-if="batteryForecastEmpty"
v-if="batteryForecastLowest"
class="d-flex align-items-center mb-2"
>
<ForecastMessage :message="batteryForecastEmpty" />
<ForecastMessage :point="batteryForecastLowest" />
</div>
<div
v-else-if="batteryForecastFull"
v-else-if="batteryForecastHighest"
class="d-none d-md-block mb-2"
>
&nbsp;
@ -272,13 +272,13 @@
#subline
>
<div
v-if="batteryForecastFull"
v-if="batteryForecastHighest"
class="d-flex align-items-center mb-2"
>
<ForecastMessage :message="batteryForecastFull" />
<ForecastMessage :point="batteryForecastHighest" high />
</div>
<div
v-else-if="batteryForecastEmpty"
v-else-if="batteryForecastLowest"
class="d-none d-md-block mb-2"
>
&nbsp;
@ -350,6 +350,7 @@ import { defineComponent, type PropType } from "vue";
import {
SMART_COST_TYPE,
type Battery,
type BatteryForecastPoint,
type Meter,
type CURRENCY,
type Forecast,
@ -583,14 +584,14 @@ export default defineComponent({
consumers() {
return [...this.aux, ...this.ext];
},
batteryForecastFull(): string | undefined {
return this.fmtForecast(this.battery?.forecast, true);
batteryForecastHighest(): BatteryForecastPoint | undefined {
return this.battery?.forecast?.highest;
},
batteryForecastEmpty(): string | undefined {
return this.fmtForecast(this.battery?.forecast, false);
batteryForecastLowest(): BatteryForecastPoint | undefined {
return this.battery?.forecast?.lowest;
},
batteryForecastExists(): boolean {
return !!(this.batteryForecastEmpty || this.batteryForecastFull);
return !!(this.batteryForecastHighest || this.batteryForecastLowest);
},
},
watch: {
@ -687,18 +688,6 @@ export default defineComponent({
genericConsumerTitle(index: number) {
return `${this.$t("config.devices.consumer")} #${index + 1}`;
},
fmtForecast(
forecast: { full?: string | null; empty?: string | null } | undefined,
full: boolean
): string | undefined {
const isoString = full ? forecast?.full : forecast?.empty;
if (!isoString) return undefined;
const time = this.fmtAbsoluteDate(new Date(isoString));
const key = full
? "main.energyflow.batteryForecastFull"
: "main.energyflow.batteryForecastEmpty";
return this.$t(key, { time });
},
},
});
</script>

View file

@ -1,16 +1,43 @@
<template>
<router-link to="/optimize" class="root" @click.stop>
{{ $t("main.energyflow.forecast") }} <span class="message">{{ message }}</span>
{{ label }}: <span class="message">{{ message }}</span>
</router-link>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { defineComponent, type PropType } from "vue";
import formatter from "@/mixins/formatter";
import type { BatteryForecastPoint } from "@/types/evcc";
export default defineComponent({
name: "ForecastMessage",
mixins: [formatter],
props: {
message: { type: String, required: true },
point: { type: Object as PropType<BatteryForecastPoint>, required: true },
high: { type: Boolean, default: false },
},
computed: {
label(): string {
if (this.point.limit) {
return this.$t("main.energyflow.forecast");
}
return this.$t(
this.high
? "main.energyflow.batteryForecastNextHigh"
: "main.energyflow.batteryForecastNextLow"
);
},
message(): string {
const time = this.fmtAbsoluteDate(new Date(this.point.time));
if (this.point.limit) {
const key = this.high
? "main.energyflow.batteryForecastFull"
: "main.energyflow.batteryForecastEmpty";
return this.$t(key, { time });
}
const soc = this.fmtPercentage(this.point.soc);
return `${time} (${soc})`;
},
},
});
</script>

View file

@ -590,8 +590,14 @@ export interface Meter {
}
export interface BatteryForecast {
full: string | null; // ISO 8601 datetime
empty: string | null; // ISO 8601 datetime
highest?: BatteryForecastPoint;
lowest?: BatteryForecastPoint;
}
export interface BatteryForecastPoint {
soc: number; // percent
time: string; // ISO 8601 datetime
limit?: boolean; // true when SMax (highest) or SMin (lowest) boundary reached
}
export interface Battery {

View file

@ -292,51 +292,76 @@ func (site *Site) addBatteryForecastTotals(req []optimizer.BatteryConfig, resp [
return nil
}
now := time.Now().Round(tariff.SlotDuration)
fullSlot, emptySlot := site.batteryForecastFullAndEmptySlots(req, resp)
const zero = -1
if fullSlot == zero && emptySlot == zero {
high, low := batteryForecastSocExtremes(req, resp)
if high == nil && low == nil {
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)
cutoff := time.Now()
now := cutoff.Round(tariff.SlotDuration)
point := func(p *batteryForecastSlot) *types.BatteryForecastPoint {
if p == nil {
return nil
}
}
if emptySlot != zero {
if ts := now.Add(time.Duration(emptySlot) * tariff.SlotDuration); ts.After(time.Now()) {
res.Empty = new(ts)
ts := now.Add(time.Duration(p.slot) * tariff.SlotDuration)
if !ts.After(cutoff) {
return nil
}
return &types.BatteryForecastPoint{Soc: p.soc, Time: ts, Limit: p.limit}
}
res := types.BatteryForecast{
Highest: point(high),
Lowest: point(low),
}
if res.Highest == nil && res.Lowest == nil {
return nil
}
return &res
}
func (site *Site) batteryForecastFullAndEmptySlots(req []optimizer.BatteryConfig, resp []optimizer.BatteryResult) (int, int) {
matchSlot := func(fun func(soc float32, bat optimizer.BatteryConfig) bool) int {
NEXT:
for i := range resp[0].StateOfCharge {
for batIdx := range req {
if !fun(resp[batIdx].StateOfCharge[i], req[batIdx]) {
continue NEXT
}
}
return i
}
return -1
type batteryForecastSlot struct {
slot int
soc float64 // percent
limit bool // true when SMax (highest) or SMin (lowest) boundary reached
}
// batteryForecastSocExtremes returns the highest and lowest aggregate SOC
// points across home batteries (SCapacity > 0) over the forecast horizon.
// The Limit flag indicates whether the SOC reached the configured SMax (for
// the highest point) or SMin (for the lowest point) boundary - in which case
// the battery is forecasted to become fully charged or empty.
// Returns nil for either point when no home battery is present.
func batteryForecastSocExtremes(req []optimizer.BatteryConfig, resp []optimizer.BatteryResult) (*batteryForecastSlot, *batteryForecastSlot) {
homeIndices := lo.FilterMap(req, func(b optimizer.BatteryConfig, i int) (int, bool) {
return i, b.SCapacity > 0
})
if len(homeIndices) == 0 || len(resp) == 0 {
return nil, nil
}
fullSlot := matchSlot(func(soc float32, bat optimizer.BatteryConfig) bool {
return soc >= bat.SMax
})
emptySlot := matchSlot(func(soc float32, bat optimizer.BatteryConfig) bool {
return soc <= bat.SMin
})
totalCapacity := lo.SumBy(homeIndices, func(i int) float32 { return req[i].SCapacity })
totalSMax := lo.SumBy(homeIndices, func(i int) float32 { return req[i].SMax })
totalSMin := lo.SumBy(homeIndices, func(i int) float32 { return req[i].SMin })
return fullSlot, emptySlot
var high, low *batteryForecastSlot
for i := range resp[homeIndices[0]].StateOfCharge {
sum := lo.SumBy(homeIndices, func(idx int) float32 { return resp[idx].StateOfCharge[i] })
soc := float64(sum/totalCapacity) * 100
fullReached := totalSMax > 0 && sum >= totalSMax
emptyReached := sum <= totalSMin
// first slot at SMax wins for highest
if high == nil || (!high.limit && (soc > high.soc || fullReached)) {
high = &batteryForecastSlot{slot: i, soc: soc, limit: fullReached}
}
// first slot at SMin wins for lowest
if low == nil || (!low.limit && (soc < low.soc || emptyReached)) {
low = &batteryForecastSlot{slot: i, soc: soc, limit: emptyReached}
}
}
return high, low
}
func (site *Site) loadpointRequest(lp loadpoint.API, minLen int, firstSlotDuration time.Duration, grid api.Rates) (optimizer.BatteryConfig, batteryDetail) {

View file

@ -46,67 +46,105 @@ func TestAsTimestamps(t *testing.T) {
}, got)
}
func TestBatteryForecastTotals(t *testing.T) {
site := new(Site)
req := []optimizer.BatteryConfig{
{SMax: 80},
{SMax: 80},
}
const zero = -1
func TestBatteryForecastSocExtremes(t *testing.T) {
for _, tc := range []struct {
name string
bat1, bat2 []float32
full, empty int
name string
req []optimizer.BatteryConfig
soc [][]float32
high, low *batteryForecastSlot
}{
{
"never full",
[]float32{0, 0},
[]float32{0, 0},
zero, 0,
"no home battery",
[]optimizer.BatteryConfig{{SMax: 80}}, // SCapacity unset → vehicle
[][]float32{{1000, 2000}},
nil, nil,
},
{
"never empty",
[]float32{100, 100},
[]float32{100, 100},
0, zero,
"single home battery rising — reaches full",
[]optimizer.BatteryConfig{{SCapacity: 1000, SMax: 1000}},
[][]float32{{200, 500, 1000}},
&batteryForecastSlot{slot: 2, soc: 100, limit: true},
&batteryForecastSlot{slot: 0, soc: 20, limit: false},
},
{
"first full then empty",
[]float32{100, 0},
[]float32{100, 0},
0, 1,
"single home battery falling — reaches empty",
[]optimizer.BatteryConfig{{SCapacity: 1000, SMax: 1000}},
[][]float32{{900, 500, 0}},
&batteryForecastSlot{slot: 0, soc: 90, limit: false},
&batteryForecastSlot{slot: 2, soc: 0, limit: true},
},
{
"first full finally empty",
[]float32{100, 100, 0},
[]float32{100, 0, 0},
0, 2,
"single home battery — local extremes (no limit reached)",
[]optimizer.BatteryConfig{{SCapacity: 1000, SMax: 900, SMin: 100}},
[][]float32{{500, 800, 200}},
&batteryForecastSlot{slot: 1, soc: 80, limit: false},
&batteryForecastSlot{slot: 2, soc: 20, limit: false},
},
{
"first empty then full",
[]float32{0, 100},
[]float32{0, 100},
1, 0,
"two home batteries aggregated",
[]optimizer.BatteryConfig{
{SCapacity: 1000, SMax: 1000},
{SCapacity: 1000, SMax: 1000},
},
[][]float32{
{200, 400, 1000},
{800, 400, 1000},
},
&batteryForecastSlot{slot: 2, soc: 100, limit: true},
&batteryForecastSlot{slot: 1, soc: 40, limit: false},
},
{
"first empty finally full",
[]float32{0, 100, 100},
[]float32{0, 0, 100},
2, 0,
"vehicle and home battery — vehicle ignored",
[]optimizer.BatteryConfig{
{SMax: 80}, // vehicle
{SCapacity: 1000, SMax: 1000}, // home
},
[][]float32{
{0, 0, 80},
{200, 500, 900},
},
&batteryForecastSlot{slot: 2, soc: 90, limit: false},
&batteryForecastSlot{slot: 0, soc: 20, limit: false},
},
{
"first slot at SMax wins for highest",
[]optimizer.BatteryConfig{{SCapacity: 1000, SMax: 1000}},
[][]float32{{1000, 1000, 500}},
&batteryForecastSlot{slot: 0, soc: 100, limit: true},
&batteryForecastSlot{slot: 2, soc: 50, limit: false},
},
{
"near SMax is not full",
[]optimizer.BatteryConfig{{SCapacity: 1000, SMax: 1000}},
[][]float32{{500, 999, 800}},
&batteryForecastSlot{slot: 1, soc: 99.9, limit: false},
&batteryForecastSlot{slot: 0, soc: 50, limit: false},
},
} {
t.Run(tc.name, func(t *testing.T) {
resp := []optimizer.BatteryResult{
{StateOfCharge: tc.bat1},
{StateOfCharge: tc.bat2},
resp := make([]optimizer.BatteryResult, len(tc.soc))
for i, s := range tc.soc {
resp[i] = optimizer.BatteryResult{StateOfCharge: s}
}
full, empty := site.batteryForecastFullAndEmptySlots(req, resp)
assert.Equal(t, tc.full, full, "full")
assert.Equal(t, tc.empty, empty, "empty")
high, low := batteryForecastSocExtremes(tc.req, resp)
if tc.high == nil {
assert.Nil(t, high, "high")
} else {
require.NotNil(t, high, "high")
assert.Equal(t, tc.high.slot, high.slot, "high.slot")
assert.InDelta(t, tc.high.soc, high.soc, 1e-3, "high.soc")
assert.Equal(t, tc.high.limit, high.limit, "high.limit")
}
if tc.low == nil {
assert.Nil(t, low, "low")
} else {
require.NotNil(t, low, "low")
assert.Equal(t, tc.low.slot, low.slot, "low.slot")
assert.InDelta(t, tc.low.soc, low.soc, 1e-3, "low.soc")
assert.Equal(t, tc.low.limit, low.limit, "low.limit")
}
})
}
}

View file

@ -21,8 +21,17 @@ type Measurement struct {
}
type BatteryForecast struct {
Full *time.Time `json:"full"`
Empty *time.Time `json:"empty"`
Highest *BatteryForecastPoint `json:"highest,omitempty"`
Lowest *BatteryForecastPoint `json:"lowest,omitempty"`
}
// BatteryForecastPoint describes an extreme SOC point in the battery forecast.
// Limit indicates whether the configured SMax (for Highest) or SMin (for Lowest)
// boundary was reached, i.e. the battery becomes fully charged or empty.
type BatteryForecastPoint struct {
Soc float64 `json:"soc"`
Time time.Time `json:"time"`
Limit bool `json:"limit,omitempty"`
}
var _ api.TitleDescriber = (*Measurement)(nil)

View file

@ -1192,7 +1192,7 @@
"batteryGridChargeLimit": "Opladning fra elnettet: når",
"batteryHold": "Batteri (låst)",
"batteryTooltip": "{energy} af {total} ({soc})",
"forecast": "Prognose: ",
"forecast": "Prognose",
"forecastTooltip": "prognose: resterende solenergi produktion i dag",
"gridImport": "Import fra elnet",
"homePower": "Forbrug",

View file

@ -1170,13 +1170,15 @@
"battery": "Batterie",
"batteryCharge": "Batterie laden",
"batteryDischarge": "Batterie entladen",
"batteryForecastEmpty": "leer {time}",
"batteryForecastFull": "voll {time}",
"batteryForecastEmpty": "Leer ab {time}",
"batteryForecastFull": "Voll ab {time}",
"batteryForecastNextHigh": "Nächstes Hoch",
"batteryForecastNextLow": "Nächstes Tief",
"batteryGridChargeActive": "Netzladen: aktiv",
"batteryGridChargeLimit": "Netzladen: wenn",
"batteryHold": "Batterie (gesperrt)",
"batteryTooltip": "{energy} von {total} ({soc})",
"forecast": "Vorhersage: ",
"forecast": "Vorhersage",
"forecastTooltip": "Vorhersage: verbleibende PV-Produktion heute",
"gridImport": "Netzbezug",
"homePower": "Verbrauch",

View file

@ -1170,13 +1170,15 @@
"battery": "Battery",
"batteryCharge": "Battery charging",
"batteryDischarge": "Battery discharging",
"batteryForecastEmpty": "empty {time}",
"batteryForecastFull": "full {time}",
"batteryForecastEmpty": "Empty from {time}",
"batteryForecastFull": "Full from {time}",
"batteryForecastNextHigh": "Next high",
"batteryForecastNextLow": "Next low",
"batteryGridChargeActive": "Grid charging: active",
"batteryGridChargeLimit": "Grid charging: when",
"batteryHold": "Battery (locked)",
"batteryTooltip": "{energy} of {total} ({soc})",
"forecast": "Forecast: ",
"forecast": "Forecast",
"forecastTooltip": "forecast: remaining solar production today",
"gridImport": "Grid import",
"homePower": "Consumption",

View file

@ -1191,7 +1191,7 @@
"batteryGridChargeLimit": "Milloin ladataan verkosta",
"batteryHold": "Akku (lukittu)",
"batteryTooltip": "{energy} / {total} ({soc})",
"forecast": "Ennuste: ",
"forecast": "Ennuste",
"forecastTooltip": "ennuste: jäljellä oleva aurinkotuotanto tänään",
"gridImport": "Kulutus sähköverkosta",
"homePower": "Kodin sähkönkulutus",

View file

@ -1191,7 +1191,7 @@
"batteryGridChargeLimit": "Charge réseau : quand",
"batteryHold": "Batterie (verrouillée)",
"batteryTooltip": "{energy} sur {total} ({soc})",
"forecast": "Prévisions : ",
"forecast": "Prévisions",
"forecastTooltip": "prévision : production solaire restante aujourd’hui",
"gridImport": "Import depuis le réseau",
"homePower": "Consommation",

View file

@ -1169,7 +1169,7 @@
"batteryGridChargeLimit": "Ricarica dalla rete: quando",
"batteryHold": "Batteria (locked)",
"batteryTooltip": "{energy} di {total} ({soc})",
"forecast": "Previsioni: ",
"forecast": "Previsioni",
"forecastTooltip": "previsioni: produzione solare rimanente per oggi",
"gridImport": "Uso della rete",
"homePower": "Consumo",

View file

@ -1181,7 +1181,7 @@
"batteryGridChargeLimit": "Opluede vum Netz: wann",
"batteryHold": "Batterie (gespäert)",
"batteryTooltip": "{energy} vun {total} ({soc})",
"forecast": "Viraussoen: ",
"forecast": "Viraussoen",
"forecastTooltip": "Prognose: Rescht Solarproduktioun fir haut",
"gridImport": "Import vum Netz",
"homePower": "Verbrauch",

View file

@ -1192,7 +1192,7 @@
"batteryGridChargeLimit": "Įkrauti iš tinklo: kai",
"batteryHold": "Kaupiklis (užblokuotas)",
"batteryTooltip": "{energy} iš {total} ({soc})",
"forecast": "Prognozė: ",
"forecast": "Prognozė",
"forecastTooltip": "prognozė: likusi saulės energijos gamyba šiandien",
"gridImport": "Iš tinklo",
"homePower": "Namo suvartojimas",

View file

@ -1190,7 +1190,7 @@
"batteryGridChargeLimit": "Net laden: wanneer",
"batteryHold": "Batterij (geblokkeerd)",
"batteryTooltip": "{energy} van {total} ({soc})",
"forecast": "Voorspelling: ",
"forecast": "Voorspelling",
"forecastTooltip": "voorspelling: resterende zonne-energieproductie voor vandaag",
"gridImport": "Netafname",
"homePower": "Consumptie",

View file

@ -1090,7 +1090,7 @@
"batteryGridChargeLimit": "Ładowanie z sieci: gdy",
"batteryHold": "Magazyn energii (chroniony)",
"batteryTooltip": "{energy} z {total} ({soc})",
"forecast": "Prognoza: ",
"forecast": "Prognoza",
"forecastTooltip": "prognoza: pozostała na dziś produkcja energii słonecznej",
"gridImport": "Z sieci",
"homePower": "Konsumpcja",

View file

@ -1181,7 +1181,7 @@
"batteryGridChargeLimit": "Carga da rede: quando",
"batteryHold": "Bateria (suspensa)",
"batteryTooltip": "{energy} de {total} ({soc})",
"forecast": "Previsões: ",
"forecast": "Previsões",
"forecastTooltip": "previsão: produção solar restante de hoje",
"gridImport": "Importação da rede",
"homePower": "Consumo",

View file

@ -1084,7 +1084,7 @@
"batteryGridChargeLimit": "Încărcare din rețea: când",
"batteryHold": "Baterie (inchisa)",
"batteryTooltip": "{energy} din {total} ({soc})",
"forecast": "Prognoză: ",
"forecast": "Prognoză",
"forecastTooltip": "prognoză: producția solară rămasă astăzi",
"gridImport": "Folosirea rețelei",
"homePower": "Consum",

View file

@ -1193,7 +1193,7 @@
"batteryGridChargeLimit": "Nätladdning: när",
"batteryHold": "Batteri (låst)",
"batteryTooltip": "{energy} av {total} ({soc})",
"forecast": "Prognos: ",
"forecast": "Prognos",
"forecastTooltip": "Prognos: återstående solproduktion idag",
"gridImport": "Import från elnät",
"homePower": "Konsumtion",

View file

@ -1002,7 +1002,7 @@
"batteryGridChargeLimit": "கிரிட் சார்சிங்: எப்போது",
"batteryHold": "மின்கலம் (பூட்டபட்டது)",
"batteryTooltip": "{energy} ({total}) இன் {soc}",
"forecast": "முன்னறிவிப்பு: ",
"forecast": "முன்னறிவிப்பு",
"forecastTooltip": "முன்னறிவிப்பு: இன்று சூரிய விளைவாக்கம்",
"gridImport": "கட்டம் பயன்பாடு",
"homePower": "நுகர்வு",

View file

@ -1130,7 +1130,7 @@
"batteryGridChargeLimit": "Şebekeden doldurma: şayet",
"batteryHold": "Batarya (kilitli)",
"batteryTooltip": "{total} ({soc})'ın {energy}'ı",
"forecast": "Tahmin: ",
"forecast": "Tahmin",
"forecastTooltip": "“öngörü: bugün kalan güneşden üreti̇m”",
"gridImport": "Şebeke kullanımı",
"homePower": "Tüketim",